4、任务调度器移植:任务控制块(TCB)结构体适配、上下文切换代码实现、临界区保护策略、调度器启动与停止。
好,咱们接着往下聊。上一章我们把时钟和中断搞定了,现在该碰调度器最核心的部分了。说实话,调度器移植是整个工作中最容易出鬼的地方。我见过太多人,系统跑不起来,最后发现是TCB结构体里一个字段没对齐,或者上下文切换时寄存器压栈顺序搞反了。
这一节,我带你把这四个硬骨头啃下来:TCB结构体适配、上下文切换、临界区保护、调度器启停。每个点我都会结合项目里踩过的坑来讲。
4.1 任务控制块(TCB)结构体适配
TCB是什么?说白了就是每个任务的“身份证”。操作系统靠它来管理任务的一切。移植时,TCB结构体必须和你的CPU架构、编译器对齐。
我个人习惯,先把原生RTOS的TCB结构体拿出来,然后对照目标CPU的寄存器列表,一个一个核对。举个例子,ARM Cortex-M3/M4有16个通用寄存器(R0-R15),其中R13是栈指针SP,R14是链接寄存器LR,R15是程序计数器PC。上下文切换时,这些寄存器都得保存。
下面是我在某个项目里用过的TCB结构体,适配了Cortex-M4内核:
typedef struct tcb {
uint32_t *stack_ptr; // 当前栈指针,指向任务栈顶
uint32_t stack_size; // 栈大小(字节)
uint8_t priority; // 任务优先级
uint8_t state; // 任务状态:就绪、阻塞、挂起等
char name[16]; // 任务名字,调试用
void (*entry)(void*); // 任务入口函数
void *arg; // 入口函数参数
struct tcb *next; // 链表指针,用于就绪队列
uint32_t delay_ticks; // 延时节拍数
// 以下是硬件相关字段
uint32_t initial_sp; // 初始栈指针,用于栈溢出检测
} TCB_t;
这里有个坑:stack_ptr必须是结构体的第一个成员。为什么?因为调度器切换任务时,直接通过TCB首地址拿到栈指针,然后恢复寄存器。如果顺序不对,CPU会从错误的地方取数据,系统直接崩掉。我曾经在一个项目里,因为加了一个调试字段在前面,结果调度器一跑就进HardFault,查了两天才发现。
另外,结构体对齐也要注意。ARM架构下,建议用__attribute__((aligned(8)))强制8字节对齐。有些RTOS的TCB里会有浮点寄存器保存区,如果你的CPU有FPU,别忘了加上。
4.2 上下文切换代码实现
上下文切换,就是保存当前任务的状态,恢复下一个任务的状态。说白了,就是“换人”。
这部分必须用汇编写,因为你要直接操作CPU寄存器。C语言做不到。嗯,这里要注意:不同CPU的压栈顺序不一样,一定要看芯片手册。
以Cortex-M3为例,硬件在进入中断时会自动压栈一部分寄存器(R0-R3、R12、LR、PC、xPSR),我们只需要手动压栈剩下的(R4-R11)。所以上下文切换的汇编代码大概长这样:
; 任务切换函数:PendSV_Handler
PendSV_Handler:
; 保存当前任务上下文
MRS R0, PSP ; 获取当前任务的栈指针
STMFD R0!, {R4-R11} ; 手动压栈 R4-R11
; 更新当前TCB中的栈指针
LDR R1, =current_tcb ; 获取当前TCB指针地址
LDR R2, [R1] ; 读取当前TCB指针
STR R0, [R2] ; 保存栈指针到TCB的第一个字段
; 切换到下一个任务
LDR R1, =next_tcb ; 获取下一个TCB指针地址
LDR R2, [R1] ; 读取下一个TCB指针
LDR R0, [R2] ; 从TCB中取出栈指针
; 恢复下一个任务上下文
LDMFD R0!, {R4-R11} ; 弹出 R4-R11
MSR PSP, R0 ; 更新栈指针
; 异常返回,硬件自动弹出 R0-R3, R12, LR, PC, xPSR
BX LR
你想想看,这段代码里最容易被忽略的是什么?是BX LR。在Cortex-M上,LR在异常处理中存放的是EXC_RETURN值,不是普通的返回地址。如果你直接写BX LR,CPU会把它当成异常返回来处理,自动触发硬件出栈。这个机制很巧妙,但新手很容易搞混。
4.3 临界区保护策略
临界区,就是那些不能被中断打断的代码段。比如操作就绪队列、修改TCB状态。如果不保护,中断一来,数据可能被破坏。
最常用的策略是“关中断-执行-开中断”。但这里有个度:关中断时间不能太长,否则会影响实时性。我见过有人把整个调度器函数都包在临界区里,结果中断延迟飙到几十微秒,电机控制直接抖成筛子。
下面是我常用的临界区保护宏:
#define ENTER_CRITICAL() do { \
uint32_t __primask; \
__primask = __get_PRIMASK(); \
__disable_irq(); \
// 这里可以保存中断状态,用于嵌套保护
#define EXIT_CRITICAL() do { \
if (!__primask) { \
__enable_irq(); \
} \
} while(0)
注意看,我用了__get_PRIMASK()保存当前中断状态。为什么?因为临界区可能嵌套。如果外层已经关了中断,内层退出时就不能开中断,否则会破坏外层的保护。这个细节,很多移植教程都没提。
还有一种策略是“调度器锁”,只禁止任务切换,不禁止中断。适合那些需要响应中断,但不能被切换走的场景。比如在中断里给任务发信号量,你不想让调度器在中间插一杠子。
4.4 调度器启动与停止
调度器启动,说白了就是“把控制权交给操作系统”。停止,就是“把控制权拿回来”。
启动调度器时,要做三件事:
- 初始化系统节拍定时器——让SysTick开始跑,产生周期性中断。
- 设置PendSV优先级为最低——确保所有其他中断都能抢占PendSV,这是Cortex-M的推荐做法。
- 触发一次PendSV异常——手动启动第一次上下文切换,让最高优先级的任务跑起来。
代码实现大概是这样:
void scheduler_start(void) {
// 1. 初始化系统节拍定时器
SysTick_Config(SystemCoreClock / TICK_RATE_HZ);
// 2. 设置PendSV优先级为最低(数值最大)
NVIC_SetPriority(PendSV_IRQn, 0xFF);
// 3. 手动触发PendSV,启动第一次切换
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
// 4. 这里永远不会返回
while(1);
}
你可能会问:为什么PendSV要设成最低优先级?因为这样,任何中断都能打断PendSV,保证中断响应速度。PendSV只在没有其他中断要处理时才执行,这就是“尾链”机制。说白了,就是让中断优先,任务切换靠后。
调度器停止呢?这个场景比较少用,但调试时很有用。比如你想单步调试某个任务,就得先把调度器停了。停止的方法很简单:关掉SysTick中断,然后关全局中断。但要注意,停止后所有任务都冻结了,你只能通过调试器操作。
scheduler_stop(),结果系统直接死机,最后只能按复位键。
好了,调度器移植的四个核心点就这些。TCB结构体是地基,上下文切换是骨架,临界区保护是护城河,调度器启停是总开关。这四个点搞定了,你的RTOS就能在目标芯片上跑起来了。下一章,我们聊聊任务创建和删除的移植,那又是另一番天地。