4. 嵌入式C语言进阶:位操作、指针与数组、结构体与联合体、volatile关键字、内存对齐

嵌入式C语言,说白了就是贴着硬件写代码。你写的每一行,最终都直接控制着寄存器、内存地址和中断响应。这一章我挑了几个最常踩坑、也最能体现嵌入式特色的知识点,咱们一个一个过。

本章核心: 掌握这五个知识点,你就能读懂绝大多数MCU的驱动库和BSP代码,也能写出更高效、更健壮的底层程序。

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的寄存器地址)
  • 中断服务程序和主循环共享的全局变量
  • 多线程/多任务环境下的共享变量
注意: volatile不能保证原子性。如果一个变量在32位MCU上是64位的,读写它可能被中断打断。这时候需要关中断或使用原子操作。

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
我的建议: 在定义结构体时,把大字节的成员放在前面,小字节的放在后面。这样可以减少填充字节,节省内存。比如上面的例子,把b放在a前面,结构体大小就从12字节降到了8字节。

如果你需要精确控制内存布局(比如网络协议栈里的数据包),可以用 __attribute__((packed)) 强制取消对齐。但代价是访问未对齐成员时,性能会下降,甚至在某些MCU上会触发异常。

嵌入式C语言进阶知识体系 嵌入式C语言进阶 位操作 指针与数组 结构体与联合体 volatile关键字 内存对齐 应用场景 • 寄存器读写(位操作 + volatile) • 协议解析(结构体 + 联合体 + 内存对齐) • 中断数据共享(volatile + 指针)

这一章的内容,说白了就是嵌入式C语言里最贴近硬件的几个点。位操作让你能精确控制寄存器,指针让你能直接操作内存,结构体和联合体帮你组织数据,volatile保证编译器不捣乱,内存对齐确保数据访问的正确性。把这五个点吃透了,你写底层代码的时候心里就有底了。