线程同步_信号量

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 1 Likes: 0 Comments: 0

256.2.7线程同步_信号量

#include <rtthread.h> // 引入 RT-Thread 内核接口:线程、信号量和延时函数都在其中声明
#define DBG_TAG "main" // 设置 rtdbg 日志输出时使用的模块标签
#define DBG_LVL DBG_LOG // 设置日志级别,允许输出 LOG_D、LOG_E 等日志
#include <rtdbg.h> // 引入 RT-Thread 的调试日志宏
rt_thread_t th1_ptr,th2_ptr; // 保存线程 th1 和 th2 的动态线程对象句柄
rt_sem_t sem1_ptr,sem2_ptr; // 保存动态信号量 sem1 和 sem2 的句柄
void th1_printf(void *parameter) // 线程 th1 的入口函数;parameter 是创建线程时传入的参数
{ // th1_printf 函数体开始
    rt_thread_t current_ptr; // 课程源码保留的局部变量,本例没有使用,因此编译器会给出未使用警告
    while(1) // 让 th1 持续等待信号量、打印并通知 th2
    { // th1 无限循环体开始
        rt_sem_take(sem1_ptr,RT_WAITING_FOREVER); // 永久等待 sem1;成功取得后 sem1 的计数减 1
        rt_kprintf("In %s,func %s is running! the line is %d!\r\n" ,__FILE__,__FUNCTION__, __LINE__); // 打印源文件名、当前函数名和本行行号
        rt_thread_mdelay(1000); // 让 th1 延时约 1000 ms,使交替输出更容易观察
        rt_sem_release(sem2_ptr); // 释放 sem2:若 th2 正在等待,则将其唤醒
    } // th1 无限循环体结束
} // th1_printf 函数体结束
void th2_printf(void *parameter) // 线程 th2 的入口函数;parameter 是创建线程时传入的参数
{ // th2_printf 函数体开始
    while(1) // 让 th2 持续等待信号量、打印并通知 th1
    { // th2 无限循环体开始
        rt_sem_take(sem2_ptr,RT_WAITING_FOREVER); // 永久等待 sem2;初值为 0,所以必须先等 th1 释放它
        rt_kprintf("In %s,func %s is running! the line is %d!\r\n" ,__FILE__,__FUNCTION__, __LINE__); // 打印源文件名、当前函数名和本行行号
        rt_thread_mdelay(1000); // 让 th2 延时约 1000 ms,使交替输出更容易观察
        rt_sem_release(sem1_ptr); // 释放 sem1:若 th1 正在等待,则将其唤醒
    } // th2 无限循环体结束
} // th2_printf 函数体结束
int main(void) // RT-Thread 用户主线程的入口函数
{ // main 函数体开始
    int count = 1; // 初始化循环计数变量;课程源码只用它维持 while 循环
    sem1_ptr = rt_sem_create("sem1", 1, RT_IPC_FLAG_PRIO); // 动态创建 sem1,初值为 1,等待线程按优先级排队
    if(sem1_ptr == RT_NULL) // 判断 sem1 是否因内存不足等原因创建失败
    { // sem1 创建失败分支开始
        LOG_E("tsem1 rt_sem_create failed!\r\n"); // 输出课程源码中的 sem1 创建失败日志
        return -RT_ENOMEM; // 以“内存不足”错误码结束 main
    } // sem1 创建失败分支结束
    sem2_ptr = rt_sem_create("sem2", 0, RT_IPC_FLAG_PRIO); // 动态创建 sem2,初值为 0,使 th2 启动后先等待
    if(sem2_ptr == RT_NULL) // 判断 sem2 是否创建失败
    { // sem2 创建失败分支开始
        LOG_E("tsem1 rt_sem_create failed!\r\n"); // 原课程源码仍写 tsem1,这里按要求原样保留
        return -RT_ENOMEM; // 以“内存不足”错误码结束 main
    } // sem2 创建失败分支结束
    th1_ptr = rt_thread_create("th1", th1_printf, NULL, 1024, 20, 10); // 创建 th1:栈 1024 字节、优先级 20、时间片 10 tick
    if(th1_ptr == RT_NULL) // 判断 th1 是否创建失败
    { // th1 创建失败分支开始
        LOG_E("th1_printf rt_thread_create failed!\r\n"); // 输出 th1 创建失败日志
        return -RT_ENOMEM; // 以“内存不足”错误码结束 main
    } // th1 创建失败分支结束
    rt_thread_startup(th1_ptr); // 启动 th1,使其进入调度系统
    th2_ptr = rt_thread_create("th2", th2_printf, NULL, 1024, 20, 10); // 创建 th2:栈 1024 字节、优先级 20、时间片 10 tick
    if(th2_ptr == RT_NULL) // 判断 th2 是否创建失败
    { // th2 创建失败分支开始
        LOG_E("th2_printf rt_thread_create failed!\r\n"); // 输出 th2 创建失败日志
        return -RT_ENOMEM; // 以“内存不足”错误码结束 main
    } // th2 创建失败分支结束
    rt_thread_startup(th2_ptr); // 启动 th2,使其进入调度系统
    while (count++) // count 非 0 时持续循环;课程源码借此让 main 长期运行
    { // main 循环体开始
        LOG_D("Hello RT-Thread!"); // 输出主线程的调试信息
        rt_thread_mdelay(1000); // 主线程延时约 1000 ms,让其他就绪线程获得运行机会
    } // main 循环体结束
    return RT_EOK; // 理论上的正常返回值;本例通常不会执行到这里
} // main 函数体结束

源码说明: 上面是课程配套工程 rttcode/06SEM/applications/main.c 的最终业务代码。仅去除了原文件头注释并增加逐行解释,没有修正或补全课程代码。源码中的未使用变量 current_ptr、第二处仍写成 tsem1 的错误日志,以及未检查信号量操作返回值等现象都按原样保留,便于与视频和工程逐行对照。

一、信号量

信号量(semaphore)可以先理解为“计数值 + 等待线程队列”。线程要使用一次许可时执行 take:有许可就把计数减 1 后继续,没有许可就根据超时参数立即返回或进入等待;任务完成或事件发生后执行 release,把许可交给等待线程或使计数加 1。

本节项目实际使用 RT-Thread 5.0.2。在该版本中,信号量计数保存在 rt_uint16_t value 中,允许的最大值为 RT_SEM_VALUE_MAX(即 65535),计数不会变成负数。创建动态信号量需要启用信号量与动态内存相关配置;配套工程已经启用了 RT_USING_SEMAPHORE 和 RT_USING_HEAP。

信号量既能表示资源数量,也能表示事件或执行许可。理解时必须先问:“这个计数代表的究竟是什么?”

  • 表示资源时,计数是当前可用资源份数。
  • 表示同步事件时,计数是尚未消费的事件或执行许可数。
  • 本例把 sem1、sem2 当作在线程之间传递的“接力棒”,目的是强制两段代码交替执行,而不是保护一项共享资源。

1.1 二值信号量

二值信号量通常只让计数在 0 和 1 之间变化:

数值常见含义(资源模型)常见含义(同步模型)
1有 1 份资源可取有 1 次执行许可待消费
0暂无资源可取事件尚未发生,或许可已被消费

初始化为 1 常用于“第一次允许直接进入”;初始化为 0 常用于“必须先等待另一方发出通知”。但“0 表示资源已被占用”只适用于某些资源场景,并非信号量的固定定义。

还要注意:RT-Thread 并没有为本例另设一个自动封顶为 1 的二值信号量对象。这里仍是普通计数信号量,只是程序按 0/1 的约定使用它。如果没有等待线程时连续多次调用 rt_sem_release(),计数仍可能增长到 2、3……因此,是否保持“二值”取决于业务逻辑是否严格一取一还。

本节最终设置为:

sem1_ptr = rt_sem_create("sem1", 1, RT_IPC_FLAG_PRIO);
sem2_ptr = rt_sem_create("sem2", 0, RT_IPC_FLAG_PRIO);

因此 th1 能先取得 sem1;th2 启动后则必须等待 th1 释放 sem2。

1.2 计数型信号量

当初始值大于 1 时,可以把它看作有多份同类资源或多次许可。例如初值为 5 时,前五次成功的 rt_sem_take() 会依次把计数变为 4、3、2、1、0,第六次再取时才会等待。

视频中曾把 sem1 的初值临时改成 5 做实验。由于 th1 的循环每次打印后延时 1 秒、且当时没有归还 sem1,所以它能打印五次,随后在第六次 take 处阻塞。这证明的是“允许五次成功获取”,并不意味着线程只会运行五次;如果其他执行路径不断释放信号量,线程还可继续运行。

资源计数模型可写为:

创建:value = N
take 成功且 value > 0:value = value - 1
release 且没有等待者:value = value + 1
release 且已有等待者:直接唤醒队首线程,不额外累计 value

最后一种情况尤其重要:释放操作优先把许可交给已经在等待的线程,而不是先简单地把可见计数加 1。

二、信号量的使用

课程新建了 06SEM 工程,实际内核版本为 RT-Thread 5.0.2,芯片系列选择 STM32F1/STM32F103,串口使用 UART1(PA9/PA10),调试器选择 ST-LINK、接口使用 SWD;然后复制上一节的两个动态线程作为实验基础。这些硬件选项用于下载程序和查看串口输出,本节信号量原理本身不依赖某一款 STM32。

信号量有两套生命周期,必须成对使用:

对象类型存储来源创建/初始化删除/脱离
动态信号量RT-Thread 从动态对象内存中分配rt_sem_create()rt_sem_delete()
静态信号量调用者提供 struct rt_semaphore 存储rt_sem_init()rt_sem_detach()

不能把动态创建的对象交给 rt_sem_detach(),也不能把静态初始化的对象交给 rt_sem_delete()。本节使用的 rt_sem_t sem1_ptr, sem2_ptr 是句柄,两个对象都由 rt_sem_create() 动态创建。

2.1 获取信号量

1. 创建信号量

动态创建接口为:

rt_sem_t rt_sem_create(const char *name, rt_uint32_t value, rt_uint8_t flag);
参数含义
name内核对象名称,便于调试和在命令行中识别对象
value初始计数,RT-Thread 5.0.2 要求小于 65536
flag多个线程已经阻塞在该信号量上时,等待队列的排列规则
返回值成功返回信号量句柄,失败返回 RT_NULL

flag 常用值如下:

  • RT_IPC_FLAG_FIFO:按进入等待队列的先后顺序唤醒。
  • RT_IPC_FLAG_PRIO:优先唤醒线程优先级更高的等待者;RT-Thread 的优先级数值越小,优先级越高。优先级相同者仍保持先到先服务。

这个标志只决定“已有多个等待者时先唤醒谁”,不决定哪个新建线程先运行,也不是把信号量本身设成某个优先级。线程能否运行首先取决于它是否就绪,然后才由调度器比较线程优先级。

2. 获取信号量
rt_err_t rt_sem_take(rt_sem_t sem, rt_int32_t time);

调用后的行为取决于当前计数和等待时间:

条件结果
value > 0立即把计数减 1,返回 RT_EOK
value == 0 且 time == RT_WAITING_NO不等待,立即返回 -RT_ETIMEOUT
value == 0 且 time > 0挂起当前线程,最多等待指定 tick 数;超时返回 -RT_ETIMEOUT
value == 0 且 time == RT_WAITING_FOREVER一直等待,直到信号量可用或对象被删除

rt_sem_trytake(sem) 等价于 rt_sem_take(sem, RT_WAITING_NO),适用于只想尝试一次、不能阻塞的线程代码。

重要纠错: rt_sem_take() 的等待参数单位是 OS tick,不是固定的毫秒。配套工程配置 RT_TICK_PER_SECOND = 1000,所以本工程中 100 tick 名义上约等于 100 ms;换到 100 Hz 的系统就约等于 1 秒。需要表达毫秒时,宜使用 rt_tick_from_millisecond(ms) 换算,而不要把裸数字当成跨工程通用的毫秒值。

课程最终传入 RT_WAITING_FOREVER,意味着两个工作线程在许可不到时睡眠等待,而不是在循环中不断查询。这种阻塞等待不会无意义地占用 CPU。

3. 释放信号量
rt_err_t rt_sem_release(rt_sem_t sem);
  • 若已有线程等待,内核按 FIFO 或 PRIO 规则选出队首线程并恢复它,许可直接交给该线程。
  • 若没有等待线程,信号量计数加 1。
  • 若没有等待线程且计数已经达到 RT_SEM_VALUE_MAX,返回 -RT_EFULL。
  • 释放信号量可以在中断服务程序中使用;获取信号量是会涉及线程阻塞的操作,应在适合的线程上下文中调用。

课程源码没有检查 rt_sem_take() 和 rt_sem_release() 的返回值,是为了突出同步流程。实际项目尤其在采用有限超时、对象可能被删除或计数可能溢出时,应根据返回值决定是否执行后续业务。

2.2 删除信号量

动态信号量的删除接口为:

rt_err_t rt_sem_delete(rt_sem_t sem);

删除时,RT-Thread 会先唤醒所有正在该对象上等待的线程;这些等待调用以错误状态结束,随后动态对象被销毁并释放。删除后原句柄已经失效,不能再执行 take 或 release。由于对象销毁和线程调度相关,动态信号量的创建、删除应放在线程上下文,而不是中断服务程序中。

静态对象则使用:

rt_err_t rt_sem_detach(rt_sem_t sem);

detach 让对象脱离内核对象管理并唤醒等待者,但不会释放由调用者提供的结构体存储。

本节两个信号量服务于常驻线程,程序没有退出路径,因此课程最终源码没有调用删除函数。实际产品若线程和功能模块会动态退出,应先协调相关线程停止使用该对象,再按创建方式调用正确的销毁接口;不能在另一个线程仍可能访问句柄时直接删除。

三、信号量的应用案例

3.1 例题: 线程同步信号量应用

实验目标是让 th1 与 th2 严格交替输出。视频通过三个阶段逐步验证:

  1. 先观察普通调度。 实验中曾临时令 th1 的优先级为 20、th2 为 10。两者都就绪时,因为 RT-Thread 数字越小优先级越高,th2 会先执行。
  2. 只获取、不释放。 创建 sem1 = 1、sem2 = 0,让两线程分别永久等待自己的信号量。th1 能获得一次 sem1 并打印,然后再次获取时阻塞;th2 因 sem2 为 0,从一开始就阻塞。把 sem1 临时改成 5 后,th1 可以成功打印五次,再在第六次获取处阻塞。
  3. 互相释放,形成闭环。 把 sem1 恢复为 1;th1 完成一次工作后释放 sem2,th2 完成后释放 sem1,从而不断传递唯一的执行许可。课程最终工程中两个工作线程的优先级都为 20,与文首源码一致。

两个信号量分别表示:

信号量初值等待者释放者含义
sem11th1th2允许 th1 执行下一轮
sem20th2th1允许 th2 执行下一轮

这套关系很像两个人传递一根接力棒:初始时棒在 th1 手中;th1 做完把棒交给 th2;th2 做完再交回 th1。任一方在没拿到棒时都会阻塞。

课程源码还有几个应当“识别但不擅自修改”的细节:

  • th1_printf() 中的 current_ptr 没有被使用,所以视频最终编译会出现一个未使用变量警告。
  • 创建 sem2 失败时,日志仍写成 tsem1,这是诊断字符串笔误,不影响同步逻辑。
  • 任一后续创建失败时,前面已经动态创建的对象没有清理;常驻课堂例程容易忽略这一点,具有退出/重启路径的产品代码应设计统一的失败清理流程。
  • while (count++) 只是课程用来维持主线程循环的写法。对有符号 int 长期自增最终可能溢出,不适合作为永久循环的工程写法;这里仍按课程源码原样保留。

3.2 现象分析

最终程序的关键时序如下:

时刻th1 的动作th2 的动作信号量状态/结果
启动后等待 sem1等待 sem2初始 sem1=1、sem2=0
第 1 步取得 sem1 并打印,然后延时 1 秒因 sem2=0 而阻塞sem1 变为 0
第 2 步释放 sem2,随后回到循环并等待 sem1被唤醒许可直接交给 th2
第 3 步因 sem1=0 而阻塞取得 sem2 并打印,然后延时 1 秒sem2 保持无可用许可
第 4 步被唤醒释放 sem1,随后回到循环并等待 sem2许可直接交给 th1
后续重复第 1~4 步重复第 1~4 步两线程持续交替

因此工作线程的输出顺序稳定为:

th1_printf
th2_printf
th1_printf
th2_printf
...

实际串口中还会穿插主线程每隔约 1 秒打印的 Hello RT-Thread!。配套工程的主线程优先级为 10,两个工作线程最终均为 20;主线程延时结束后会先于低优先级工作线程获得 CPU,但它打印完又主动延时,所以不会破坏 th1、th2 之间由信号量建立的先后约束。

即使把 th2 设成比 th1 更高的优先级,sem2=0 仍会让 th2 阻塞:没有取得许可的线程不处于就绪态,优先级再高也不能越过信号量继续执行。 th1 得以运行并释放 sem2 后,高优先级的 th2 才可能抢占运行。由此应区分两个层次:

  1. 信号量决定线程是否具备继续执行的条件。
  2. 调度器在所有就绪线程中按优先级和时间片决定实际运行者。

本例把 rt_thread_mdelay(1000) 放在打印之后、释放对方信号量之前,所以每次交棒都会被主动推迟约 1 秒。如果把延时移动到 release 之后,下一线程会更早获得许可,观察到的节奏也会不同。这里不是“持有互斥锁休眠”,但确实是在延迟同步事件的发送。

最后,信号量只约束实际调用了相应 take/release 的代码路径,并不会自动规定整个系统中所有线程的执行顺序。若某段共享数据既需要确定先后关系,又需要防止并发修改,应根据问题分别选择信号量、互斥量、事件或消息队列等机制,而不能仅凭“输出看起来交替”就认为所有共享访问都已安全。

核对依据: 本节接口语义、等待队列规则、计数上限和返回值均按课程所用 RT-Thread 5.0.2 的 ipc.c、rtdef.h,以及 RT-Thread 官方的线程间同步文档和信号量 API 文档交叉核对。