3、C语言基础复习:指针与函数指针、结构体与内存对齐、链表操作、volatile、位操作
好,咱们开始第三章。这一章我把它叫做「地基中的地基」。你想想看,RTOS 内核说到底就是一堆数据结构和操作这些结构的代码。指针、结构体、链表、位操作——这些东西你要是玩不转,后面写任务调度、消息队列、信号量,那真是寸步难行。
我当年刚入行时,觉得 C 语言基础我都会啊,结果第一次写内核里的链表插入,就搞出了野指针,板子直接跑飞。嗯,从那以后我再也不敢小看这些基础了。咱们今天就把这些硬骨头啃干净。
3.1 指针与函数指针
指针,说白了就是地址。但很多人一提到「指向指针的指针」就晕。我个人习惯这样理解:变量存数据,指针存地址,二级指针存指针的地址。
在 RTOS 内核里,指针用得最多的场景就是操作任务控制块(TCB)。比如你要修改一个任务的优先级,传进去的就是 TCB 的指针。
// 定义一个任务控制块
typedef struct {
uint32_t *stack_ptr; // 栈指针
uint8_t priority; // 优先级
void (*task_func)(void *arg); // 函数指针!
} TCB_t;
// 函数指针的典型用法:调用任务入口函数
void start_task(TCB_t *task, void *arg) {
task->task_func(arg); // 通过函数指针调用
}
重点:函数指针的声明语法确实反人类。我教你一个窍门:先写一个普通函数声明 void func(void);,然后把函数名换成 (*p),就成了 void (*p)(void);。记住这个套路,再也不怕函数指针了。
我在项目中遇到过一个问题:用函数指针数组实现状态机,结果因为函数指针类型不匹配,编译器报警了我没注意,最后在运行时跳到了错误的地址。所以,函数指针一定要做强制类型转换,别偷懒。
3.2 结构体与内存对齐
结构体这东西,看起来简单,但坑全在内存对齐上。你想想看,CPU 读取 4 字节的数据,如果地址是 4 的倍数,一次就读完了;如果不是,可能要读两次再拼起来。这就是对齐的底层逻辑。
看个例子:
// 猜猜这个结构体占多少字节?
struct example1 {
char a; // 1 字节
int b; // 4 字节
char c; // 1 字节
};
// 实际大小:12 字节(在 32 位系统上)
// 为什么?因为 int b 要对齐到 4 字节边界,a 后面会填充 3 个字节
// c 后面也会填充 3 个字节,让整个结构体大小是 4 的倍数
优化技巧:把大的成员放在前面,小的放后面,可以节省空间。
// 重新排列后,只占 8 字节
struct example2 {
int b; // 4 字节
char a; // 1 字节
char c; // 1 字节
// 后面填充 2 字节
};
在 RTOS 内核里,结构体对齐特别重要。我记得有一次调试一个任务切换的 bug,发现栈指针总是不对,查了两天才发现是结构体成员顺序导致的内存对齐填充,把栈指针的地址给算偏了。嗯,从那以后我写结构体都会手动算一遍对齐。
注意:如果你用 #pragma pack(1) 强制 1 字节对齐,虽然省了空间,但会降低访问速度。在 RTOS 这种对实时性有要求的场景,一般不建议用,除非你明确知道自己在做什么。
3.3 链表操作:插入、删除、遍历
链表是 RTOS 内核的骨架。任务就绪队列、等待队列、定时器链表——全是链表。我个人习惯用双向链表,因为删除操作 O(1),不用遍历找前驱节点。
先看一个最简单的链表节点定义:
typedef struct list_node {
struct list_node *prev;
struct list_node *next;
} list_node_t;
注意,这个节点里没有数据!在 RTOS 内核里,我们通常把链表节点嵌入到数据结构中,而不是用「数据包含节点」的方式。这叫 intrusive linked list,效率更高。
// 任务控制块中嵌入链表节点
typedef struct {
list_node_t node; // 链表节点
uint8_t priority;
void (*task_func)(void *);
} TCB_t;
插入操作
// 在节点 prev 后面插入新节点 new_node
void list_insert_after(list_node_t *prev, list_node_t *new_node) {
new_node->prev = prev;
new_node->next = prev->next;
if (prev->next) {
prev->next->prev = new_node;
}
prev->next = new_node;
}
关键点:插入操作一定要先处理新节点的前后指针,再处理前后节点的指针。顺序错了,链表就断了。我曾经犯过这个错,结果链表变成了环,任务调度直接死循环。
删除操作
// 从链表中删除节点 del_node
void list_remove(list_node_t *del_node) {
if (del_node->prev) {
del_node->prev->next = del_node->next;
}
if (del_node->next) {
del_node->next->prev = del_node->prev;
}
// 清空指针,防止野指针
del_node->prev = NULL;
del_node->next = NULL;
}
遍历操作
// 遍历链表,找到优先级最高的任务
TCB_t *find_highest_priority_task(list_node_t *head) {
list_node_t *curr = head->next;
TCB_t *highest = NULL;
uint8_t max_prio = 0;
while (curr != head) { // 循环链表,头节点不存数据
TCB_t *task = (TCB_t *)((char *)curr - offsetof(TCB_t, node));
if (task->priority > max_prio) {
max_prio = task->priority;
highest = task;
}
curr = curr->next;
}
return highest;
}
小技巧:上面用 offsetof 宏从链表节点反推出 TCB 的地址,这是 intrusive 链表的经典用法。你想想看,如果不用这个技巧,你就得在节点里存一个指向 TCB 的指针,多了一次间接访问,性能就差了。
3.4 volatile 关键字
volatile 这词,直译是「易变的」。在嵌入式里,它告诉编译器:这个变量的值可能会被意想不到地改变,别给我优化掉。
什么时候用?三种场景:
- 硬件寄存器:比如读取状态寄存器,值可能被硬件修改
- 中断服务程序中修改的变量:主循环和中断共享的变量
- RTOS 中多任务共享的变量:比如全局的调度标志
// 典型例子:中断中设置的标志
volatile uint8_t g_tick_flag = 0;
void SysTick_Handler(void) {
g_tick_flag = 1; // 中断中修改
}
void main_loop(void) {
while (1) {
if (g_tick_flag) { // 如果没有 volatile,编译器可能把 g_tick_flag 优化到寄存器里
g_tick_flag = 0;
// 处理定时任务
}
}
}
我曾经踩过的坑:写一个 bootloader 时,有个变量用来标记固件是否下载完成。没加 volatile,编译器把它优化成了常量,结果下载完成后标志位永远读不到新值,bootloader 以为没下载完,反复重启。查了两天,最后加了个 volatile 就好了。嗯,从那以后我见到共享变量就条件反射地加 volatile。
3.5 位操作技巧
嵌入式里,位操作是家常便饭。设置寄存器、操作标志位、打包解包数据——全得靠它。我个人觉得,位操作是 C 语言里最「性感」的部分,几行代码就能搞定很复杂的事情。
常用位操作宏
// 设置第 n 位
#define BIT_SET(x, n) ((x) |= (1U << (n)))
// 清除第 n 位
#define BIT_CLEAR(x, n) ((x) &= ~(1U << (n)))
// 翻转第 n 位
#define BIT_TOGGLE(x, n) ((x) ^= (1U << (n)))
// 检查第 n 位是否为 1
#define BIT_IS_SET(x, n) (((x) & (1U << (n))) != 0)
// 获取位域(从第 m 位开始,取 n 位)
#define BIT_GET_FIELD(x, m, n) (((x) >> (m)) & ((1U << (n)) - 1))
注意:移位操作时,如果 n 等于数据类型位数,结果是未定义的。比如 1U << 32 在 32 位系统上是未定义行为。所以写宏的时候最好加个范围检查,或者用 uint64_t 来移位。
实际应用:任务优先级位图
在 RTOS 里,我们经常用位图来管理任务优先级。比如有 32 个优先级,用一个 32 位整数来表示哪些优先级上有就绪任务。查找最高优先级时,用位操作可以 O(1) 完成。
// 优先级位图
volatile uint32_t g_ready_prio_bitmap = 0;
// 标记优先级 prio 的任务就绪
void task_set_ready(uint8_t prio) {
BIT_SET(g_ready_prio_bitmap, prio);
}
// 标记优先级 prio 的任务非就绪
void task_clear_ready(uint8_t prio) {
BIT_CLEAR(g_ready_prio_bitmap, prio);
}
// 查找当前最高就绪优先级
uint8_t find_highest_ready_priority(void) {
// 利用 __builtin_clz 或类似指令,找到最高位的位置
// 这是 ARM Cortex-M 内核的指令,一条指令搞定
return 31 - __builtin_clz(g_ready_prio_bitmap);
}
性能提示:用位图管理优先级,查找操作是 O(1) 的。如果用链表遍历,最坏情况是 O(n)。在实时系统里,确定性比平均性能更重要。所以我个人强烈推荐用位图来实现优先级调度。
好了,这一章的内容就这些。指针、结构体对齐、链表、volatile、位操作——这些东西看起来基础,但每一个都是 RTOS 内核的基石。你把这些吃透了,后面写任务调度器、消息队列、信号量的时候,就会觉得「哦,原来就这么回事」。
下一章咱们开始动手写第一个内核组件:任务控制块和就绪队列。到时候这些知识全都会用上。