结构体与联合体的 const 与 volatile:const 结构体、volatile 联合体(用于 MMIO)
讲完了结构体和联合体的基本用法,咱们得聊聊两个关键字——const 和 volatile。说实话,很多工程师写了好几年 C 代码,对这两个关键字的理解还停留在「const 就是只读,volatile 就是防止优化」这种表面层次。但在嵌入式领域,尤其是操作硬件寄存器(MMIO)的时候,这两个关键字用不对,轻则程序跑飞,重则设备冒烟。
我当年刚入行时,就吃过这个亏。一个串口驱动死活收不到数据,调试了整整两天,最后发现是结构体指针少了 volatile 修饰。嗯,从那以后我再也不敢小看这两个关键字了。
一、const 结构体:不只是「只读」那么简单
const 修饰结构体,最常见的就是用来定义常量配置表。比如一个系统的参数集,烧在 Flash 里,运行时不允许修改。
// 定义系统配置结构体
typedef struct {
uint32_t baud_rate;
uint8_t data_bits;
uint8_t stop_bits;
uint8_t parity;
} UART_Config;
// 常量配置表,存放在 Flash
const UART_Config default_config = {
.baud_rate = 115200,
.data_bits = 8,
.stop_bits = 1,
.parity = 0
};
这里有个细节:const 修饰的是整个结构体变量,意味着你不能通过 default_config.baud_rate = 9600 来修改它。编译器会直接报错。
核心要点:
const结构体变量通常分配在只读存储区(如 Flash、ROM)- 通过指针访问时,指针类型也建议加
const,否则编译器会警告 - 结构体成员如果是指针,
const只保护指针本身,不保护指针指向的数据
我个人习惯在定义硬件抽象层(HAL)的配置结构体时,全部用 const 修饰。这样既能防止误修改,又能让代码的意图更清晰——「这些参数,初始化时一次性写入,之后别碰」。
二、const 结构体指针:函数接口设计的利器
在实际项目中,更常见的是用 const 修饰结构体指针参数。你想想看,一个函数如果接收结构体指针,又不修改它,那最好加上 const。
// 打印配置,不修改结构体
void print_uart_config(const UART_Config *cfg) {
printf("Baud: %lu\n", cfg->baud_rate);
printf("Data bits: %u\n", cfg->data_bits);
// cfg->baud_rate = 9600; // 编译错误!不能修改
}
这样做的好处很明显:
- 调用者可以放心传入
const变量的地址 - 编译器能帮你检查是否意外修改了数据
- 代码自文档化——看到
const就知道这个函数不会改你的数据
小技巧:
如果你写一个库函数,参数是结构体指针,默认加 const。除非你明确要修改它。这是我在代码审查中反复强调的一点。
三、volatile 联合体:MMIO 的标配
好,接下来是重头戏——volatile。这个关键字在嵌入式开发中,几乎和 MMIO(内存映射 I/O)绑定在一起。
为什么需要 volatile?说白了,就是告诉编译器:「这个变量的值,随时可能被硬件改变,你别自作聪明去优化它。」
举个例子,一个 32 位的状态寄存器,地址是 0x40001000。硬件每完成一个操作,就会自动更新这个寄存器的值。如果你不用 volatile,编译器可能会把读取操作优化掉,导致你永远读不到最新的状态。
// 错误示范:没有 volatile
uint32_t *status_reg = (uint32_t *)0x40001000;
while (*status_reg & 0x01); // 编译器可能只读一次,然后死循环
// 正确示范:加 volatile
volatile uint32_t *status_reg = (volatile uint32_t *)0x40001000;
while (*status_reg & 0x01); // 每次都从内存读取
那联合体在这里有什么用呢?联合体可以让你用不同的视角去访问同一个硬件寄存器。比如一个 32 位的控制寄存器,你可以整体读写,也可以按位操作。
// 定义联合体,映射到硬件寄存器地址
typedef union {
uint32_t word; // 整体 32 位访问
struct {
uint32_t enable : 1;
uint32_t mode : 2;
uint32_t irq_en : 1;
uint32_t reserved : 28;
} bits; // 按位域访问
} Control_Reg;
// 定义指针,指向硬件地址,加 volatile
#define CTRL_REG ((volatile Control_Reg *)0x40002000)
// 使用示例
void init_hardware(void) {
// 按位设置
CTRL_REG->bits.enable = 1;
CTRL_REG->bits.mode = 2;
CTRL_REG->bits.irq_en = 1;
// 整体读取
uint32_t val = CTRL_REG->word;
}
注意:
位域(bit-field)的布局是编译器相关的。不同编译器、不同字节序下,位域的顺序可能不同。跨平台代码要小心。我建议在项目初期就定好位域的排列规则,并在文档中明确说明。
四、const volatile 组合:只读硬件寄存器
有些硬件寄存器是只读的,比如状态寄存器、版本号寄存器。这时候,const 和 volatile 可以同时使用。
// 只读状态寄存器
#define STATUS_REG ((const volatile uint32_t *)0x40003000)
uint32_t get_status(void) {
return *STATUS_REG; // 每次读取都从硬件获取
// *STATUS_REG = 0; // 编译错误!不能写
}
const 防止你误写,volatile 防止编译器优化读取。两者不冲突,反而相辅相成。
我在项目中遇到过一种情况:某个芯片的版本寄存器,地址固定,但硬件会在复位后自动更新。如果不加 volatile,编译器会把第一次读取的值缓存起来,后面每次读都是同一个值。加了 const volatile,既保证了只读语义,又保证了每次读取都访问硬件。
五、知识体系图
下面这张图总结了本章的核心逻辑,帮你理清 const 和 volatile 在结构体与联合体中的应用场景。
六、避坑指南
最后,分享几个我踩过的坑,希望能帮你少走弯路。
我曾经犯过的错:
- 忘记 volatile 导致死循环:读取状态寄存器时没加 volatile,编译器把 while 循环优化成一次读取,程序卡死。排查了两天才发现。
- const 结构体指针的误用:把一个 const 结构体的地址传给一个非 const 指针参数,编译器报警告,我直接强制类型转换忽略了。结果函数内部修改了常量区数据,触发硬件异常。
- 联合体位域顺序问题:在 ARM 和 x86 上分别编译同一个位域联合体,发现 bit 顺序反了。从此以后,跨平台代码我尽量用移位宏代替位域。
我的建议:
- 所有 MMIO 指针定义,一律加 volatile,形成肌肉记忆
- 函数参数如果是结构体指针且不修改,加 const
- 位域联合体只用于同一平台,跨平台用掩码+移位
- 代码审查时,重点检查 volatile 有没有遗漏
好了,关于 const 和 volatile 在结构体与联合体中的应用,就讲到这里。这两个关键字看似简单,但用好了能让你的嵌入式代码更健壮、更安全。下次写驱动的时候,不妨多想想——这个寄存器该不该加 volatile?这个参数能不能加 const?