2. 基础知识
做固件开发,C语言是绕不开的根基。很多刚入行的朋友觉得语法都懂,一上板子就出问题。说白了,嵌入式C和桌面端C是两回事——你得跟寄存器打交道,跟内存布局较劲,还得时刻提防编译器优化给你挖坑。
这一章,我把固件开发中最常用的几个C语言知识点串一遍。不会讲太基础的东西,重点放在「实际写固件时怎么用、容易在哪翻车」。我个人习惯把这些内容当作每天的「基本功热身」,每次写驱动前都会在脑子里过一遍。
2.1 指针与内存管理:别让指针「飞」了
指针在固件里有多重要?我这么说吧——你读寄存器、操作缓冲区、传递大结构体,全得靠它。但指针也是最容易出事的。我在项目中遇到过好几次「野指针」导致的死机,查了两天才发现是某处指针没初始化。
先看一个典型场景:操作MCU的寄存器。很多芯片的寄存器是映射到固定地址的,你得用指针去访问。
// 假设某个UART数据寄存器地址是 0x40013800
#define UART_DR (*(volatile uint32_t *)0x40013800)
void uart_send_byte(uint8_t data) {
// 等待发送缓冲区空
while (UART_DR & 0x80000000);
UART_DR = data;
}
这里有个细节:volatile 是必须加的,否则编译器可能把 UART_DR 的值优化到寄存器里,导致你读不到硬件的最新状态。这个后面会细讲。
内存管理方面,嵌入式环境通常没有操作系统帮你做动态分配。我建议你尽量用静态分配——在编译时就确定好缓冲区大小。实在要用 malloc,一定要检查返回值,而且得清楚你的堆空间有多大。
malloc 分配任务栈,结果堆碎片化导致系统运行三天后突然崩溃。从那以后,我所有任务栈都改用静态数组 + 宏定义大小。嵌入式里,「动态」不一定比「静态」好。
| 内存区域 | 典型用途 | 注意事项 |
|---|---|---|
| 栈 (Stack) | 局部变量、函数调用 | 大小有限,递归要谨慎 |
| 堆 (Heap) | 动态分配 (malloc) | 碎片化风险,慎用 |
| 全局/静态区 | 全局变量、static变量 | 生命周期长,注意初始化 |
| 代码区 (Flash) | 程序指令、常量 | 只读,不能写 |
2.2 结构体与联合体:把寄存器「封装」起来
你想想看,一个外设可能有几十个寄存器,每个寄存器里又有若干位域。如果都用宏定义 + 位操作去写,代码会变得又臭又长。结构体和联合体就是用来解决这个问题的。
我最喜欢用联合体来映射寄存器——既能按字节/字整体访问,又能按位域单独操作。
// 以STM32的GPIO输出数据寄存器为例
typedef union {
uint32_t word; // 32位整体访问
struct {
uint32_t ODR0 : 1; // 位0
uint32_t ODR1 : 1; // 位1
uint32_t ODR2 : 1; // 位2
// ... 省略中间位
uint32_t ODR15 : 1; // 位15
uint32_t : 16; // 保留位
} bits;
} GPIO_ODR_TypeDef;
// 使用时
#define GPIOA_ODR ((GPIO_ODR_TypeDef *)0x40020014)
GPIOA_ODR->bits.ODR0 = 1; // 只操作PA0
GPIOA_ODR->word = 0xFFFF; // 一次性操作所有引脚
这样做的好处很明显:代码自文档化。看到 bits.ODR0 就知道是在操作第0位,比 GPIOA->ODR |= (1 << 0) 直观得多。不过要注意位域的顺序——不同编译器对位域的排列方式可能不同,跨平台时得确认一下。
__attribute__((packed)),防止编译器在成员之间插入填充字节。寄存器地址是连续的,填充会打乱映射关系。
2.3 位操作技巧:用最少的指令干最多的活
位操作是固件工程师的「肌肉记忆」。设置位、清除位、翻转位、检查位——这些操作每天要写几十次。关键是,位操作直接对应CPU的位运算指令,效率极高。
我整理了几个最常用的「套路」:
// 设置第n位
REG |= (1 << n);
// 清除第n位
REG &= ~(1 << n);
// 翻转第n位
REG ^= (1 << n);
// 检查第n位是否为1
if (REG & (1 << n))
// 批量设置多个位
REG |= (BIT0 | BIT3 | BIT5);
// 读取某个位域的值
uint8_t field = (REG >> shift) & mask;
这里有个容易忽略的点:1 << n 在16位MCU上默认是 int 类型(16位),如果 n 大于15,结果会溢出。我建议养成习惯,写成 1UL << n,强制用无符号长整型。
REG |= (1 << 16),结果在16位编译器上只得到了0。因为 1 是16位,左移16位直接溢出。改成 1UL << 16 后问题解决。记住:位操作里,字面量类型很重要。
2.4 volatile关键字:告诉编译器「别乱优化」
volatile 可能是嵌入式C里最容易被误解的关键字。它的作用就一句话:告诉编译器,这个变量的值可能在程序流程之外被改变,每次访问都必须从内存地址读取,不能优化到寄存器里。
哪些场景必须用 volatile?
- 硬件寄存器:寄存器的值由硬件改变,编译器不知道
- 中断服务程序里修改的全局变量:主循环和中断共享的变量
- RTOS中多任务共享的变量:一个任务写,另一个任务读
- 延时循环中的空变量:防止编译器把循环优化掉
// 错误示例:没有volatile
uint8_t flag = 0;
void ISR(void) {
flag = 1; // 中断里置位
}
void main(void) {
while (!flag); // 编译器可能把flag优化成寄存器变量,永远读不到新值
}
// 正确示例
volatile uint8_t flag = 0;
你可能会问:那是不是所有全局变量都要加 volatile?不是。只有那些「被外部事件改变」的变量才需要。普通全局变量只在程序内部修改,编译器能追踪到,加了 volatile 反而会降低效率。
2.5 函数指针:让代码「活」起来
函数指针在固件里用得没桌面端那么频繁,但一旦用对地方,效果立竿见影。最常见的场景是:回调函数 和 状态机跳转表。
先看回调——比如你写一个定时器模块,希望定时时间到了之后执行某个函数。你不可能把具体函数写死在定时器驱动里,那样就失去通用性了。用函数指针就能解耦。
// 定时器回调类型定义
typedef void (*timer_callback_t)(void *arg);
// 定时器结构体
typedef struct {
uint32_t period;
timer_callback_t callback;
void *arg;
} Timer_TypeDef;
// 注册回调
void timer_register(Timer_TypeDef *tmr, timer_callback_t cb, void *arg) {
tmr->callback = cb;
tmr->arg = arg;
}
// 定时器中断里调用
void timer_isr(Timer_TypeDef *tmr) {
if (tmr->callback) {
tmr->callback(tmr->arg);
}
}
另一个经典用法是状态机跳转表。我做过一个通信协议解析模块,有十几个状态。如果用 switch-case,代码会膨胀到没法看。用函数指针数组,每个状态对应一个处理函数,代码结构清晰多了。
// 状态处理函数类型
typedef uint8_t (*state_handler_t)(uint8_t input);
// 状态跳转表
state_handler_t state_table[] = {
[STATE_IDLE] = handle_idle,
[STATE_HEADER] = handle_header,
[STATE_DATA] = handle_data,
[STATE_CRC] = handle_crc,
[STATE_DONE] = handle_done,
};
// 状态机主循环
uint8_t state_machine_run(uint8_t current_state, uint8_t input) {
if (state_table[current_state]) {
return state_table[current_state](input);
}
return STATE_ERROR;
}
if (callback) 的判断,成本几乎为零,但能省下大量调试时间。
嗯,这一章的内容就这些。五块知识点,每一块都是固件开发的「日常工具」。指针让你能直接操作硬件,结构体/联合体让代码更可读,位操作让你高效控制寄存器,volatile 保护你不被编译器坑,函数指针让代码更灵活。把这些练熟了,写固件的时候会顺手很多。