Build Your Own RTOS 系列文章:
- 理解M3架构
- 硬件自动压栈
- 初始化栈
- PendSV
- 时间片轮转
- 延时
- 当前阅读:信号量
1 孤独的任务
上期我们实现了多任务调度,两个任务轮流运行,看起来很不错。但是实际任务肯定不止点灯这么简单,如果多个任务想要同时接触一个全局变量,或者任务1要等待按键按下才运行,怎么办?
- 如果直接访问:会发生竞争冒险,数据会出错。
- 如果用
while死循环等待:会让CPU卡死在这里(如果我们实现了优先级调度,低优先级的任务就会被“饿死”)。
所以,为了解决以上的问题,我们需要实现两个功能:临界区和信号量。
2 临界区
假如任务1和任务2要完成cnt++(假设用三条指令:读、改、写完成)。
正当任务1读到cnt = 5的时候,中断来了,切换成任务2,它也读到了cnt = 5,然后把数据加上去,此时cnt = 6。然后任务1又回来了,它只记得cnt = 5,于是把cnt = 6写上去。结果就是,虽然看起来加了两次,但是只成功了一次。对于一个敏感的数据,这个错误是不可接受的。
解决办法也很简单:在cnt++时,强行把中断关了,不让任务切换,直到我完成数据处理之后。这就是临界区的意义。
在上期中,我们已经实现了一个简单的临界区:
1 2 3 4 5 6 7 8
| void OS_Delay(uint32_t ticks) { OS_Disable_IRQ(); CurrentTCB->DelayTicks = ticks; OS_Enable_IRQ(); OS_Trigger_PendSV(); }
|
2.1 嵌套陷阱
实际上,临界区的实现非常简单,只需要封装好开关中断的函数就可以。但是这还不够。
想象这样一个场景:函数A关了中断,准备干大事。中间它调用了函数B。函数B也需要保护,所以它也关了中断,干完活后开中断返回。
此时回到函数A,灾难发生了:函数A还没干完活,中断却被函数B打开了!
所以,简单的开关中断是不够的,我们需要引入“嵌套计数”的概念。
2.2 嵌套计数器的实现
逻辑很简单:
- 每次进入临界区,计数器+1,关中断。
- 每次退出临界区,计数器-1。
- 只有当计数器减到0时,才真正打开中断。
我们在os_core.c里实现。首先我们需要一个全局变量g_CriticalNesting,其代表临界区嵌套的个数。
点击查看答案
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| volatile uint32_t g_CriticalNesting = 0;
void OS_EnterCritical(void) { OS_Disable_IRQ(); g_CriticalNesting++; }
void OS_ExitCritical(void) { if(g_CriticalNesting == 0) return;
g_CriticalNesting--; if(g_CriticalNesting == 0){ OS_Enable_IRQ(); } }
|
3 信号量:让任务学会“睡觉”
如果不使用信号量,当消费者任务等待数据时,代码通常写成:
1 2
| while (flag == 0); DoWork();
|
我们需要一种机制,让任务在条件不满足时告诉调度器:“我没法干活了,把我从调度名单里划掉,我去睡会儿。”当条件满足(比如按键中断来了),再由别人把该任务“叫醒(放回就绪名单)”。
信号量就是这个“条件”的载体。本期我们实现的是一个极简化的信号量,只完成一个死等的动作。
3.1 改造数据结构
首先,信号量本质上是一个计数器,代表着“可用资源的数量”。但更重要的是,如果有任务来要资源却没要到,它得有个地方排队。
所以,我们在os_core.h里定义信号量结构体:
1 2 3 4 5 6
| typedef struct Semaphore { volatile uint16_t count; OS_TCB *WaitListHead; OS_TCB *WaitListTail; } OS_Sem;
|
接下来是重头戏:改造TCB(任务控制块)。
上期中,我们的任务只有“跑”和“不跑”的区别。现在任务变聪明了,它可能有不同的状态。我们要新建一个枚举:
1 2 3 4 5
| typedef enum { TASK_READY = 0, TASK_BLOCKED, } OS_TaskState;
|
然后,我们需要在TCB里增加两个关键成员:
State:记录当前任务是醒着还是睡着。
NextWaitTask:这是一个极其关键的指针。
Next指针是用来连接所有任务的(调度链表)。
NextWaitTask指针是用来连接正在等待同一个信号量的任务的(等待链表)。
- 这两个链表是独立的! 一个任务可以存在于调度链表中,但如果它阻塞了,它就会被逻辑上“移出”调度圈,并挂到信号量的等待链表上去。
1 2 3 4 5 6 7 8 9 10
| typedef struct Task_Control_Block { volatile uint32_t *stackPtr; struct Task_Control_Block *Next; OS_TaskState State; volatile uint32_t DelayTicks; struct Task_Control_Block *NextWaitTask; } OS_TCB;
|
3.2 改造调度器
结构体变了,所有涉及TCB的函数都要改。
首先是OS_TaskCreate(),我们需要初始化新增的成员:
1 2 3 4 5 6 7 8 9 10 11
| void OS_TaskCreate(...) { tcb->stackPtr = OS_StackInit(task_function, stack_init_address, stack_depth);
tcb->DelayTicks = 0; tcb->State = TASK_READY; tcb->NextWaitTask = NULL;
}
|
接下来是调度逻辑的改变。我们修改FindNextTask()函数,让它从找没“睡觉”的任务到找准备好的任务。
1 2 3 4 5 6 7 8 9 10 11 12 13
| OS_TCB *FindNextTask(void) { OS_TCB *TempTCB = CurrentTCB->Next;
while (TempTCB->State != TASK_READY) { TempTCB = TempTCB->Next; }
return TempTCB; }
|
同时,OS_Tick_Handler()也需要进化。它不仅要负责计数,还要负责把“睡够了”的任务叫醒。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| void OS_Tick_Handler(void) { if (CurrentTCB == NULL) return;
g_SystemTickCount++;
OS_TCB *ptr = CurrentTCB; do { if (ptr->State == TASK_BLOCKED && ptr->DelayTicks > 0) { ptr->DelayTicks--; if (ptr->DelayTicks == 0) { ptr->State = TASK_READY; } } ptr = ptr->Next; } while (ptr != CurrentTCB);
NextTCB = FindNextTask();
if (NextTCB != CurrentTCB) { OS_Trigger_PendSV(); } }
|
同理,OS_Delay函数也需要配合修改,把任务设为阻塞态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| void OS_Delay(uint32_t ticks) { OS_EnterCritical();
CurrentTCB->DelayTicks = ticks; CurrentTCB->State = TASK_BLOCKED;
NextTCB = FindNextTask(); OS_Trigger_PendSV();
OS_ExitCritical(); }
|
3.3 实现信号量逻辑
做好了铺垫,终于可以写信号量的核心逻辑了:Wait(申请)和Post(释放)。
1. OS_SemWait (申请资源)
逻辑如下:
- 有资源吗? (
count > 0) -> 有,拿走 (count--),开心返回。
- 没资源? -> 那我得排队。
- 把自己设为
TASK_BLOCKED(调度器以后别找我了)。
- 把自己挂到信号量的
WaitList 队尾(利用 NextWaitTask 指针)。
- 触发任务切换(让出CPU给别人)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| uint8_t OS_SemWait(OS_Sem *p_sem) { OS_EnterCritical(); if (p_sem->count > 0) { p_sem->count--; OS_ExitCritical(); return 1; } else { CurrentTCB->State = TASK_BLOCKED; CurrentTCB->NextWaitTask = NULL;
if (p_sem->WaitListHead == NULL) { p_sem->WaitListHead = CurrentTCB; p_sem->WaitListTail = CurrentTCB; } else { p_sem->WaitListTail->NextWaitTask = CurrentTCB; p_sem->WaitListTail = CurrentTCB; }
NextTCB = FindNextTask(); OS_Trigger_PendSV(); OS_ExitCritical(); return 1; } }
|
2. OS_SemPost (释放资源)
逻辑如下:
- 有人在排队吗? (
WaitListHead != NULL)
- 有:别把资源放回去了,直接把排在队首的那哥们叫醒(
TASK_READY),把资源给它(虽然代码上没有count++再count--,但逻辑上是直接移交)。然后把队首指针往后移一位。
- 没人排队:那就把资源计数器+1 (
count++)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| uint8_t OS_SemPost(OS_Sem *p_sem) { OS_EnterCritical(); if (p_sem->WaitListHead == NULL) { p_sem->count++; } else { OS_TCB *TaskToWake = p_sem->WaitListHead;
p_sem->WaitListHead = TaskToWake->NextWaitTask; if (p_sem->WaitListHead == NULL) { p_sem->WaitListTail = NULL; }
TaskToWake->NextWaitTask = NULL; TaskToWake->State = TASK_READY;
NextTCB = FindNextTask(); OS_Trigger_PendSV(); } OS_ExitCritical(); return 1; }
|
4 启动测试
OK,所有准备工作结束,我们就可以开始测试了。
- 任务1:等待信号量。一旦拿到信号量,就翻转LED。
- 任务2:监控按键。一旦按键按下,释放一个信号量。
效果应该是:平时LED不亮(任务1在死等),按下按键后,LED翻转一次。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| OS_Sem Sem = { .count = 0, .WaitListHead = NULL, .WaitListTail = NULL };
void Task1(void) { for(;;){ if(OS_SemWait(&Sem)){ HAL_GPIO_TogglePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin); } } }
void Task2(void) { for(;;){ if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_9) == GPIO_PIN_RESET) { OS_Delay(20); if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_9) == GPIO_PIN_RESET) { OS_SemPost(&Sem);
while (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_9) == GPIO_PIN_RESET) { OS_Delay(10); } } } OS_Delay(10); } }
|
最后插板子,编译下载。

每按一次按键,LED翻转一次。如果不按,任务1完全不占用CPU(在Block状态),效率极高。
5 总结
至此,Build Your Own RTOS系列的基础篇已经全部结束了!
通过这7期内容,我们从0开始:
- 理解了汇编层面的堆栈和上下文切换。
- 实现了PendSV中断调度。
- 写出了时间片轮转算法。
- 最终实现了信号量和任务阻塞机制。
现在你手里的这个工程,虽然简陋,但已经具备了一个现代RTOS最核心的骨架。接下来作者也许会继续出提高篇(比如优先级调度、互斥锁、内存管理),不过不知道得什么时候了。
感谢大家的陪伴,我们下个系列见!