3. 任务控制块(TCB)设计与实现:结构体定义、链表管理、状态机
任务控制块,简称 TCB。说白了,它就是 RTOS 给每个任务办的「身份证」。你想想看,内核怎么知道哪个任务在跑?哪个任务在睡觉?哪个任务优先级高?全靠这张身份证。
我早年刚接触 RTOS 时,总觉得 TCB 不就是个结构体嘛,随便写写就行。后来在一个工业控制项目里,任务一多,系统莫名其妙就崩了。查了三天,最后发现是 TCB 链表指针被踩了。嗯,从那以后,我对 TCB 的设计再也不敢马虎了。
3.1 TCB 结构体定义——这张身份证上写什么?
一个合格的 TCB,至少得包含三部分信息:任务身份、任务资源、任务状态。我习惯把它们组织成下面这样:
typedef struct tcb {
// 1. 任务身份
uint32_t task_id; // 任务唯一ID
char task_name[16]; // 任务名字(调试用)
// 2. 任务资源
uint32_t *stack_ptr; // 当前栈指针 (SP)
uint32_t *stack_base; // 栈底地址
uint32_t stack_size; // 栈大小(字为单位)
uint8_t priority; // 任务优先级 (0最高)
// 3. 任务状态
uint8_t state; // 当前状态 (就绪/阻塞/挂起/运行)
uint32_t delay_ticks; // 延时剩余tick数
// 4. 链表指针
struct tcb *next; // 单向链表下一节点
struct tcb *prev; // 双向链表上一节点
// 5. 扩展字段(性能调优用)
uint32_t total_runtime; // 累计运行时间 (tick)
uint32_t last_switch_time; // 上次切换时间戳
} TCB_t;
我的习惯:task_name 字段别省。调试时能直接打印任务名,比看 ID 方便太多了。我曾经在一个 32 任务的项目里,全靠名字快速定位问题。
这里有个关键点——stack_ptr 必须指向任务被切换时的现场保存位置。ARMv7 的异常压栈顺序是固定的:xPSR、PC、LR、R12、R3-R0,然后是 R4-R11。所以 TCB 里的 stack_ptr,其实就是任务被切出时,SP 寄存器的值。
3.2 链表管理——任务怎么排队?
任务多了就得排队。RTOS 里最常用的就是双向链表。为什么不用数组?你想想看,任务创建和删除是动态的,数组的插入删除要搬移数据,效率太低。
我一般维护两个核心链表:
| 链表名称 | 作用 | 管理方式 |
|---|---|---|
| 就绪链表 | 存放所有可以运行的任务 | 按优先级排序,头插或尾插 |
| 阻塞链表 | 存放因延时或等待而暂停的任务 | 按 delay_ticks 排序,最小在前 |
链表操作的核心函数就三个:
// 插入节点(按优先级升序)
void tcb_list_insert(TCB_t **head, TCB_t *node) {
TCB_t *curr = *head;
TCB_t *prev = NULL;
while (curr != NULL && curr->priority <= node->priority) {
prev = curr;
curr = curr->next;
}
node->next = curr;
node->prev = prev;
if (prev == NULL) {
*head = node; // 插在头部
} else {
prev->next = node;
}
if (curr != NULL) {
curr->prev = node;
}
}
// 删除节点
void tcb_list_remove(TCB_t **head, TCB_t *node) {
if (node->prev == NULL) {
*head = node->next; // 删除头节点
} else {
node->prev->next = node->next;
}
if (node->next != NULL) {
node->next->prev = node->prev;
}
node->next = NULL;
node->prev = NULL;
}
// 查找最高优先级任务
TCB_t* tcb_list_get_highest(TCB_t *head) {
return head; // 链表已按优先级排序,头节点就是最高优先级
}
踩坑提醒:我曾经在链表删除时忘了更新头指针,结果调度器拿到的是一个野指针,系统直接 HardFault。记住:删除头节点时,一定要把 head 指向 head->next。
3.3 状态机——任务的一生
每个任务都有生命周期。RTOS 里任务状态一般有 4 种:
- 就绪态 (READY):万事俱备,只差 CPU
- 运行态 (RUNNING):正在占用 CPU
- 阻塞态 (BLOCKED):等延时、等信号量、等消息
- 挂起态 (SUSPENDED):被强制暂停,不参与调度
状态切换的规则其实很简单:
typedef enum {
TASK_READY = 0,
TASK_RUNNING = 1,
TASK_BLOCKED = 2,
TASK_SUSPEND = 3
} TaskState_t;
// 状态切换函数
void tcb_set_state(TCB_t *tcb, TaskState_t new_state) {
TaskState_t old_state = tcb->state;
// 从就绪/运行 -> 阻塞
if ((old_state == TASK_READY || old_state == TASK_RUNNING) &&
new_state == TASK_BLOCKED) {
tcb_list_remove(&ready_list, tcb);
tcb_list_insert(&blocked_list, tcb);
}
// 从阻塞 -> 就绪
else if (old_state == TASK_BLOCKED && new_state == TASK_READY) {
tcb_list_remove(&blocked_list, tcb);
tcb_list_insert(&ready_list, tcb);
}
// 从就绪 -> 挂起
else if (old_state == TASK_READY && new_state == TASK_SUSPEND) {
tcb_list_remove(&ready_list, tcb);
// 挂起任务单独维护
}
tcb->state = new_state;
}
核心原则:状态切换必须原子操作。ARMv7 里可以用 CPSID I 关中断来保护临界区,切换完再 CPSIE I 开中断。否则在链表操作中间被切走,链表就乱套了。
下面这张图,是我画给团队新人的任务状态流转图,你看一眼就明白了:
3.4 初始化与销毁——TCB 的生死管理
任务创建时,TCB 怎么初始化?我一般这样做:
void tcb_init(TCB_t *tcb, char *name, uint8_t prio,
uint32_t *stack, uint32_t stack_words) {
// 清空结构体
memset(tcb, 0, sizeof(TCB_t));
// 填写身份信息
strncpy(tcb->task_name, name, 15);
tcb->task_name[15] = '\0';
tcb->priority = prio;
// 栈初始化
tcb->stack_base = stack;
tcb->stack_size = stack_words;
// 初始栈指针指向栈顶
// ARMv7 栈是向下生长的
tcb->stack_ptr = stack + stack_words - 1;
// 初始状态为就绪
tcb->state = TASK_READY;
tcb->delay_ticks = 0;
// 插入就绪链表
tcb_list_insert(&ready_list, tcb);
}
调试小技巧:初始化时把整个栈填成 0xDEADBEEF。任务运行一段时间后,检查栈空间有多少被覆盖了,就能算出实际栈使用量。这个在性能调优时特别有用。
任务销毁时,记得做三件事:从链表中摘除、释放资源、把 TCB 归还到空闲池。我习惯用一个空闲 TCB 链表来管理,创建时从空闲链表取,销毁时放回去,避免动态内存分配的开销。
3.5 性能调优视角——TCB 设计对调度的影响
TCB 设计得不好,调度性能直接打折。我总结了几条经验:
- 结构体对齐:ARMv7 建议 8 字节对齐。把 uint32_t 和指针放前面,uint8_t 放后面,避免 padding 浪费。
- 链表操作 O(1):就绪链表如果能做到 O(1) 取最高优先级任务,调度延迟就稳了。ARMv7 可以用位图法,32 级优先级刚好用一个 uint32_t 搞定。
- 状态切换路径:最频繁的路径是「运行→就绪→运行」,这条路径上的代码要精简。我见过有人每次切换都遍历整个链表,那性能就太差了。
一句话总结:TCB 是 RTOS 的基石。结构体定义决定了资源占用,链表管理决定了调度效率,状态机决定了任务行为。这三样搞扎实了,你的 RTOS 就稳了一半。