4、信号量实战:使用信号量保护共享资源(驱动中的临界区)
好,咱们进入信号量的实战环节。
前面讲了那么多理论,说白了,信号量在驱动开发里最核心的用途就一个——保护共享资源。你想想看,一个全局变量、一个硬件寄存器、一个DMA缓冲区,如果多个任务同时去读写,不出乱子才怪。我早年做一款工业控制器的驱动时,就吃过这个亏。两个任务同时操作一个FIFO缓冲区,结果数据全乱了,设备直接死机。从那以后,我对临界区的保护就再也不敢马虎了。
4.1 什么是临界区?为什么需要保护?
临界区,就是一段访问共享资源的代码。在这段代码里,如果有两个或以上的任务同时执行,就会出问题。比如:
- 任务A读取全局变量
count,准备加1 - 任务B也读取
count,也准备加1 - 结果两个任务都读到同一个值,加完后写回去,
count只增加了1,而不是2
这就是典型的竞态条件。信号量就是用来解决这个问题的——它像一个门卫,一次只放一个任务进去执行临界区代码。
核心原则:任何被多个任务共享的全局变量、硬件寄存器、缓冲区,访问时都必须加锁保护。
4.2 二值信号量 vs 计数信号量
在驱动开发中,保护临界区最常用的是二值信号量。它只有两个状态:0(被占用)和1(空闲)。
计数信号量呢?它允许多个任务同时访问,适合资源池的场景。比如有5个相同的DMA通道,你可以用计数信号量初始化为5,每个任务拿一个通道用。
| 类型 | 初始值 | 典型用途 | 最大并发数 |
|---|---|---|---|
| 二值信号量 | 1 | 保护单个共享资源(临界区) | 1 |
| 计数信号量 | N | 管理多个相同资源(如内存池、通道) | N |
我个人习惯,在驱动里保护临界区,99%的情况都用二值信号量。简单、直接、不容易出错。
4.3 实战:用信号量保护一个共享缓冲区
咱们来看一个具体的例子。假设你有一个串口驱动,发送缓冲区是全局的。多个任务都可能调用 uart_send() 往缓冲区里写数据。如果不加保护,数据就乱了。
下面是伪代码,我用的是类FreeRTOS的API,但思路在任何RTOS里都一样。
/* 全局变量 */
static SemaphoreHandle_t xUartSemaphore;
static uint8_t uart_buffer[256];
static uint16_t buffer_index = 0;
/* 初始化驱动 */
void uart_driver_init(void)
{
/* 创建二值信号量,初始为1(空闲) */
xUartSemaphore = xSemaphoreCreateBinary();
xSemaphoreGive(xUartSemaphore); /* 记得先给一次,让信号量可用 */
/* 其他硬件初始化... */
}
/* 发送数据(可能被多个任务调用) */
void uart_send(const uint8_t *data, uint16_t len)
{
/* 等待信号量——获取锁 */
if (xSemaphoreTake(xUartSemaphore, portMAX_DELAY) == pdTRUE)
{
/* 临界区开始:只有拿到信号量的任务才能进来 */
for (uint16_t i = 0; i < len; i++)
{
uart_buffer[buffer_index++] = data[i];
if (buffer_index >= 256)
{
buffer_index = 0; /* 环形缓冲区回绕 */
}
}
/* 触发DMA传输... */
/* 临界区结束:释放信号量 */
xSemaphoreGive(xUartSemaphore);
}
}
小技巧:创建二值信号量后,一定要先调用一次 Give 让它可用。很多新手忘了这一步,结果第一个任务就永远等不到信号量。
4.4 避坑指南:信号量使用的常见错误
我曾经在一个项目里,因为信号量使用不当,导致系统死锁。排查了整整两天才找到原因。下面这几个坑,你一定要记住:
- 忘记释放信号量:如果临界区里有
return或异常分支,一定要在所有出口都释放信号量。否则其他任务永远等不到锁。 - 在中断里调用阻塞式Take:中断上下文不能阻塞等待。如果要在中断里用信号量,必须用
GiveFromISR版本,并且不要在中断里Take。 - 信号量嵌套:一个任务已经拿到了信号量,又在临界区里再次
Take同一个信号量——这会导致死锁。除非你用的是可重入信号量(递归信号量)。 - 超时时间设置不合理:
portMAX_DELAY是无限等待,适合必须拿到锁的场景。但如果你的驱动可以接受失败,建议设置一个合理的超时时间,比如100ms,避免任务被永久挂起。
警告:千万不要在临界区里调用可能阻塞的函数(比如延时、等待消息队列)。这会导致其他任务长时间无法访问共享资源,严重降低系统实时性。
4.5 信号量保护临界区的完整流程
下面这张图,把整个流程串起来了。你可以看到,信号量就像一个令牌,谁拿到谁进,用完必须归还。
4.6 信号量 vs 关中断:什么时候用哪个?
你可能要问:保护临界区,关中断不就行了吗?为什么非要用信号量?
嗯,这里有个关键区别。关中断只适合保护非常短的代码段,比如修改一个寄存器、更新一个变量。如果你关中断的时间太长,系统的实时性就毁了——所有中断都被屏蔽了。
而信号量呢?它允许任务在等待锁的时候,其他中断仍然可以响应。所以,对于可能阻塞的临界区(比如等待硬件完成、等待DMA传输),必须用信号量。
| 保护方式 | 适用场景 | 对实时性影响 | 能否阻塞 |
|---|---|---|---|
| 关中断 | 极短操作(几个指令周期) | 大(中断被屏蔽) | 不能 |
| 信号量 | 较长操作(等待硬件、DMA) | 小(中断仍可响应) | 可以 |
我个人的经验是:能关中断解决的,就别用信号量。但一旦临界区里涉及等待或复杂操作,立刻换成信号量。别犹豫。
4.7 小结
信号量保护临界区,是驱动开发的基本功。记住三点:
- 二值信号量用于保护单个共享资源
- Take 和 Give 必须成对出现
- 临界区代码要尽量短,别在里面干耗时的事