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 信号量保护临界区的完整流程

下面这张图,把整个流程串起来了。你可以看到,信号量就像一个令牌,谁拿到谁进,用完必须归还。

信号量保护临界区流程图 任务A 任务B 信号量(初始=1) Take() Take() 临界区(共享缓冲区) 一次只允许一个任务进入 拿到锁 释放信号量 Give() 关键:Take() 和 Give() 必须成对出现,且执行时间要尽可能短

4.6 信号量 vs 关中断:什么时候用哪个?

你可能要问:保护临界区,关中断不就行了吗?为什么非要用信号量?

嗯,这里有个关键区别。关中断只适合保护非常短的代码段,比如修改一个寄存器、更新一个变量。如果你关中断的时间太长,系统的实时性就毁了——所有中断都被屏蔽了。

而信号量呢?它允许任务在等待锁的时候,其他中断仍然可以响应。所以,对于可能阻塞的临界区(比如等待硬件完成、等待DMA传输),必须用信号量。

保护方式 适用场景 对实时性影响 能否阻塞
关中断 极短操作(几个指令周期) 大(中断被屏蔽) 不能
信号量 较长操作(等待硬件、DMA) 小(中断仍可响应) 可以

我个人的经验是:能关中断解决的,就别用信号量。但一旦临界区里涉及等待或复杂操作,立刻换成信号量。别犹豫。

4.7 小结

信号量保护临界区,是驱动开发的基本功。记住三点:

  • 二值信号量用于保护单个共享资源
  • Take 和 Give 必须成对出现
  • 临界区代码要尽量短,别在里面干耗时的事