Build Your Own RTOS 系列文章

  1. 理解M3架构
  2. 硬件自动压栈
  3. 初始化栈
  4. PendSV
  5. 时间片轮转
  6. 延时
  7. 当前阅读:信号量

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,关中断。
  2. 每次退出临界区,计数器-1。
  3. 只有当计数器减到0时,才真正打开中断。

我们在os_core.c里实现。首先我们需要一个全局变量g_CriticalNesting,其代表临界区嵌套的个数。

3 信号量:让任务学会“睡觉”

如果不使用信号量,当消费者任务等待数据时,代码通常写成:

1
2
while (flag == 0); // 傻等,CPU 100% 占用,发热且低效
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
/* in os_core.h */
typedef enum {
TASK_READY = 0, ///< 就绪:随时可以跑,就在RunList里
TASK_BLOCKED, ///< 阻塞:在等时间,或者等信号量,暂时不能跑
} OS_TaskState;

然后,我们需要在TCB里增加两个关键成员:

  1. State:记录当前任务是醒着还是睡着。
  2. NextWaitTask:这是一个极其关键的指针。
  • Next指针是用来连接所有任务的(调度链表)。
  • NextWaitTask指针是用来连接正在等待同一个信号量的任务的(等待链表)。
  • 这两个链表是独立的! 一个任务可以存在于调度链表中,但如果它阻塞了,它就会被逻辑上“移出”调度圈,并挂到信号量的等待链表上去。
1
2
3
4
5
6
7
8
9
10
/* in os_core.h */
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
/* in os_core.c */
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)
{
// 1. 从当前任务的下一个开始找
OS_TCB *TempTCB = CurrentTCB->Next;

// 2. 如果该任务没就绪(State != TASK_READY),就找它的下一个,直到找到为止
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--),开心返回。
  • 没资源? -> 那我得排队。
  1. 把自己设为 TASK_BLOCKED(调度器以后别找我了)。
  2. 把自己挂到信号量的 WaitList 队尾(利用 NextWaitTask 指针)。
  3. 触发任务切换(让出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
/* in os_core.c */
uint8_t OS_SemWait(OS_Sem *p_sem)
{
OS_EnterCritical();

if (p_sem->count > 0) // 情况1:有资源
{
p_sem->count--;
OS_ExitCritical();
return 1;
}
else // 情况2:没资源,我要排队
{
CurrentTCB->State = TASK_BLOCKED; // 1. 睡觉
CurrentTCB->NextWaitTask = NULL; // 我是队尾,后面没人

// 2. 把自己挂到等待队列的尾巴上
if (p_sem->WaitListHead == NULL) // 如果之前没人排队
{
p_sem->WaitListHead = CurrentTCB; // 我既是头
p_sem->WaitListTail = CurrentTCB; // 也是尾
}
else // 已经有人在排队了
{
p_sem->WaitListTail->NextWaitTask = CurrentTCB; // 告诉目前队尾那个人,我排你后面
p_sem->WaitListTail = CurrentTCB; // 更新队尾指针指向我
}

// 3. 立即切换任务
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; // 找到队首任务

// 1. 从等待队列里把这人摘出来
p_sem->WaitListHead = TaskToWake->NextWaitTask; // 队首变成下一个人

// 如果取完这个人队列空了,要把尾指针也清空
if (p_sem->WaitListHead == NULL)
{
p_sem->WaitListTail = NULL;
}

// 2. 叫醒它
TaskToWake->NextWaitTask = NULL; // 断开它的排队链接
TaskToWake->State = TASK_READY; // 设置为就绪!

// 3. 触发调度
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); // 每次检测间隔10ms,让出CPU
}
}
}
OS_Delay(10);
}
}

最后插板子,编译下载。

每按一次按键,LED翻转一次。如果不按,任务1完全不占用CPU(在Block状态),效率极高。

5 总结

至此,Build Your Own RTOS系列的基础篇已经全部结束了!

通过这7期内容,我们从0开始:

  1. 理解了汇编层面的堆栈上下文切换
  2. 实现了PendSV中断调度。
  3. 写出了时间片轮转算法。
  4. 最终实现了信号量任务阻塞机制。

现在你手里的这个工程,虽然简陋,但已经具备了一个现代RTOS最核心的骨架。接下来作者也许会继续出提高篇(比如优先级调度、互斥锁、内存管理),不过不知道得什么时候了。

感谢大家的陪伴,我们下个系列见!