前阵子帮朋友看一块工业采集板,两个任务定时往同一片 SPI Flash 写日志,代码里用一个全局volatile标志当锁,跑起来偶尔还是写花。翻 uC/OS-II 内核源码的时候我就想,这类问题其实在本系列第 6 篇要讲的这块代码里早就给出答案了——今天的主角是事件控制块(OS_EVENT),以及它最典型的使用者:信号量。整套 uC/OS-II 内核源码加起来 6736 行左右,其中真正撑起所有同步机制的公共地基,就是os_core.c里那几百行事件操作代码。读透它,信号量、互斥量、消息邮箱、消息队列、事件标志组这五样东西基本就是同一套骨架换皮。这篇适合两类人:一是在用 RTOS 做项目、想知道OSSemPend背后到底发生了什么的嵌入式工程师;二是准备 RTOS 面试题、被问"信号量怎么实现的"答不利索的同学。我会把结构体逐字段拆开、把等待唤醒链路一步步走完、再给出移植到 GD32F103 上实测的数据,代码版本以 V2.92 为准。
1. 为什么第 6 篇要专门啃事件控制块这块"公共地基"
1.1 先看清 6736 行代码是怎么分布的
很多人读 RTOS 源码的习惯是从OSStart()一路顺着往下读,读到调度就卡住了,因为调度后面会不断跳到os_sem.c、os_q.c、os_flag.c,每个文件里都有一堆看着差不多的等待表操作。我第一次读的时候也绕了很久,后来把文件行数统计出来贴在屏幕边上,思路一下就清楚了。
| 文件 | 大致行数 | 职责 |
|---|---|---|
| os_core.c | 约 1500 | 初始化、调度器、就绪表、事件控制块公共操作 |
| os_task.c | 约 1000 | 任务创建、删除、挂起、恢复 |
| os_tmr.c | 约 600 | 软件定时器 |
| os_q.c | 约 600 | 消息队列 |
| os_flag.c | 约 500 | 事件标志组 |
| os_time.c | 约 300 | 延时、时钟节拍 |
| os_mutex.c | 约 300 | 互斥量、优先级继承 |
| os_mem.c | 约 300 | 内存分区管理 |
| os_sem.c | 约 200 | 信号量 |
| os_mbox.c | 约 200 | 消息邮箱 |
加起来 6700 行上下,跟我标题里说的 6736 行基本吻合(不同版本、不同编译选项下会有几十行浮动,我这份开了OS_EVENT_NAME_EN和OS_TASK_NAME_EN,关掉大概能少 100 行左右)。
关键发现是:os_sem.c只有 200 行,os_mbox.c也只有 200 行,而这 200 行里真正属于"信号量独有"的逻辑可能只有 40 行,剩下的全是参数校验和调用os_core.c里的公共函数。这就是为什么我说事件控制块是公共地基——它不在os_sem.c里,也不在os_q.c里,它在os_core.c里,被所有同步对象共用。
1.2 那个全局标志为什么会失效
回到开头那个 SPI Flash 的例子。原来的代码大概长这样:
volatile uint8_t flash_busy = 0; void task_log(void *p_arg) { for (;;) { if (flash_busy == 0) { /* 检查 */ flash_busy = 1; /* 占用 */ flash_write_page(...); flash_busy = 0; /* 释放 */ } OSTimeDly(10); } }看着挺对,问题出在"检查"和"占用"这两条语句之间。uC/OS-II 是抢占式调度,OSTimeTick每秒会打断任务几百次,只要 tick 中断正好落在第 4 行和第 5 行之间,并且中断退出时触发了任务切换,另一个任务就会看到flash_busy还是 0,于是两个任务同时进了临界区。这种 bug 不常复现,但上了产线就是灾难。
要修,要么用OS_ENTER_CRITICAL()把这两行包起来关中断,要么交给内核的信号量。前者的问题是关中断时间不可控,Flash 写一页要好几百微秒,全局关中断这么久,整个系统的实时性就废了。所以正确答案是信号量:让它去管"谁在等、谁被唤醒、计数怎么变"。
1.3 先划边界:ECB 不做哪些事
读源码最怕的就是把它的职责想多了。事件控制块这块代码,我总结下来明确"不干"的三件事:
- 它不保护你的数据。
OSSemPend关中断保护的只是等待表和计数器,你 pend 之后操作的那个全局变量、那片 Flash,还是得你自己保证只有一个任务在碰。 - 它不做优先级继承。信号量就是纯粹的计数和排队,谁先来谁先得。优先级继承是
os_mutex.c在OSMutexPend里额外加的一段代码,跟 ECB 本体无关。 - 它不可重入、不递归。同一个任务连续 pend 两次同一个信号量,第二次会把自己挂上去,然后你就等着看它永远阻塞吧。这是新手最常见的死锁来源之一。
把这三条边界记住,后面看代码会顺很多:ECB 只负责一件事——维护一张"谁在等这个事件"的优先