4. 嵌入式C语言进阶:位操作、指针与数组、结构体与联合体、volatile关键字、内存对齐
嵌入式C语言,说白了就是贴着硬件写代码。你写的每一行,最终都直接控制着寄存器、内存地址和中断响应。这一章我挑了几个最常踩坑、也最能体现嵌入式特色的知识点,咱们一个一个过。
4.1 位操作:控制寄存器的基本功
嵌入式里没有“开关变量”这种奢侈东西。一个32位的寄存器,可能同时控制着GPIO方向、输出电平、上下拉电阻。你要改其中某一位,还不能动别的位。这时候就得靠位操作。
我习惯把这四个操作记成口诀:
- 置1用或:
REG |= (1 << n) - 清0用与:
REG &= ~(1 << n) - 翻转用异或:
REG ^= (1 << n) - 读取用与:
if (REG & (1 << n))
举个例子,你想让GPIOB端口的第5脚输出高电平,同时保持其他脚不变:
// 假设GPIOB的BSRR寄存器地址是0x40010C00
#define GPIOB_BSRR (*(volatile uint32_t *)0x40010C00)
// 置位第5脚(BSRR低16位用于置位)
GPIOB_BSRR = (1 << 5);
#define PIN5_MASK (1 << 5)。这样代码可读性高,也方便后期修改引脚。
4.2 指针与数组:内存操作的灵魂
指针在嵌入式里就是一把双刃剑。用好了,你能直接访问任意内存地址;用不好,一个野指针就能让系统跑飞。
数组名和指针的关系,我见过太多人搞混了。记住一句话:数组名是常量指针,不能自增自减。
uint8_t buffer[64];
uint8_t *ptr = buffer; // 等价于 ptr = &buffer[0]
// 下面这行是合法的
ptr++;
// 下面这行是非法的!数组名不能自增
// buffer++; // 编译报错
我在项目中遇到过一个问题:用指针遍历数组时,不小心把数组名当指针用了,结果编译通过但运行就死机。后来查了半天,发现是数组名被当成了左值。
void func(uint8_t arr[]) 和 void func(uint8_t *arr) 是完全等价的。你无法在函数内部通过 sizeof(arr) 获取数组长度,它只会返回指针的大小(通常是4字节)。
4.3 结构体与联合体:数据组织的利器
结构体用来把相关的数据打包在一起。联合体则让同一块内存有不同的解读方式。这两个东西在寄存器映射和协议解析里特别有用。
比如,你想把一个32位的寄存器拆成4个8位的字节来操作:
typedef union {
uint32_t word; // 整体32位
uint8_t byte[4]; // 按字节访问
struct {
uint8_t b0 : 1; // 位域:第0位
uint8_t b1 : 1; // 第1位
uint8_t reserved : 6;
uint8_t b2 : 1;
// ... 其他位
} bits;
} REG32_t;
这样你就可以用 reg.word 读写整个寄存器,用 reg.byte[0] 访问最低字节,用 reg.bits.b0 直接操作某一位。是不是很方便?
4.4 volatile关键字:告诉编译器别乱优化
这个关键字,我愿称之为嵌入式C语言的“保命符”。
volatile的意思是“易变的”。它告诉编译器:这个变量的值可能在程序流程之外被改变,比如被中断服务程序修改,或者被硬件寄存器更新。所以编译器不能对它做任何优化,每次访问都必须从内存重新读取。
我曾经犯过一个错:写了一个轮询标志位的循环,忘了加volatile。结果编译器把标志位优化到了寄存器里,循环永远跳不出去。嗯,从那以后我再也不敢忘加volatile了。
// 错误示例:没有volatile
uint8_t flag = 0;
void ISR(void) {
flag = 1; // 中断里修改标志位
}
void wait_for_flag(void) {
while (flag == 0) {
// 编译器可能把flag优化成一直为0,死循环!
}
}
// 正确做法
volatile uint8_t flag = 0;
什么时候必须用volatile?记住这三个场景:
- 硬件寄存器映射(如GPIO、UART的寄存器地址)
- 中断服务程序和主循环共享的全局变量
- 多线程/多任务环境下的共享变量
4.5 内存对齐:性能与兼容性的平衡
内存对齐,说白了就是数据在内存里的摆放规则。很多MCU要求特定类型的数据必须放在特定地址上,比如4字节的int必须放在能被4整除的地址上。不满足对齐要求的话,轻则性能下降,重则直接触发硬件异常。
我习惯用一张表来记对齐规则:
| 数据类型 | 对齐要求 | 说明 |
|---|---|---|
uint8_t |
1字节 | 任意地址 |
uint16_t |
2字节 | 地址必须是偶数 |
uint32_t |
4字节 | 地址必须被4整除 |
uint64_t |
8字节 | 地址必须被8整除 |
struct |
最大成员的对齐 | 编译器可能会填充字节 |
结构体的对齐最坑人。你想想看,如果结构体里有uint8_t和uint32_t,编译器会在中间填充几个字节,让uint32_t对齐到4字节边界。这会导致结构体的大小比你想象的大。
typedef struct {
uint8_t a; // 偏移0
// 填充3字节
uint32_t b; // 偏移4
uint16_t c; // 偏移8
// 填充2字节(为了结构体整体对齐到4字节)
} __attribute__((packed)) MyStruct_t;
// 如果不加packed,sizeof(MyStruct_t) = 12
// 加了packed,sizeof(MyStruct_t) = 7
如果你需要精确控制内存布局(比如网络协议栈里的数据包),可以用 __attribute__((packed)) 强制取消对齐。但代价是访问未对齐成员时,性能会下降,甚至在某些MCU上会触发异常。
这一章的内容,说白了就是嵌入式C语言里最贴近硬件的几个点。位操作让你能精确控制寄存器,指针让你能直接操作内存,结构体和联合体帮你组织数据,volatile保证编译器不捣乱,内存对齐确保数据访问的正确性。把这五个点吃透了,你写底层代码的时候心里就有底了。