结构体与联合体的 const 与 volatile:const 结构体、volatile 联合体(用于 MMIO)

讲完了结构体和联合体的基本用法,咱们得聊聊两个关键字——constvolatile。说实话,很多工程师写了好几年 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 组合:只读硬件寄存器

有些硬件寄存器是只读的,比如状态寄存器、版本号寄存器。这时候,constvolatile 可以同时使用。

// 只读状态寄存器
#define STATUS_REG ((const volatile uint32_t *)0x40003000)

uint32_t get_status(void) {
    return *STATUS_REG;  // 每次读取都从硬件获取
    // *STATUS_REG = 0;  // 编译错误!不能写
}

const 防止你误写,volatile 防止编译器优化读取。两者不冲突,反而相辅相成。

我在项目中遇到过一种情况:某个芯片的版本寄存器,地址固定,但硬件会在复位后自动更新。如果不加 volatile,编译器会把第一次读取的值缓存起来,后面每次读都是同一个值。加了 const volatile,既保证了只读语义,又保证了每次读取都访问硬件。

五、知识体系图

下面这张图总结了本章的核心逻辑,帮你理清 constvolatile 在结构体与联合体中的应用场景。

const 与 volatile 在结构体/联合体中的应用 const 结构体 volatile 联合体 常量配置表(Flash/ROM) 函数参数保护(const 指针) 只读硬件寄存器(const volatile) MMIO 寄存器映射 联合体位域 + 整体访问 防止编译器优化读取 核心原则:该防改的用 const,该防优化的用 volatile

六、避坑指南

最后,分享几个我踩过的坑,希望能帮你少走弯路。

我曾经犯过的错:

  • 忘记 volatile 导致死循环:读取状态寄存器时没加 volatile,编译器把 while 循环优化成一次读取,程序卡死。排查了两天才发现。
  • const 结构体指针的误用:把一个 const 结构体的地址传给一个非 const 指针参数,编译器报警告,我直接强制类型转换忽略了。结果函数内部修改了常量区数据,触发硬件异常。
  • 联合体位域顺序问题:在 ARM 和 x86 上分别编译同一个位域联合体,发现 bit 顺序反了。从此以后,跨平台代码我尽量用移位宏代替位域。

我的建议:

  • 所有 MMIO 指针定义,一律加 volatile,形成肌肉记忆
  • 函数参数如果是结构体指针且不修改,加 const
  • 位域联合体只用于同一平台,跨平台用掩码+移位
  • 代码审查时,重点检查 volatile 有没有遗漏

好了,关于 const 和 volatile 在结构体与联合体中的应用,就讲到这里。这两个关键字看似简单,但用好了能让你的嵌入式代码更健壮、更安全。下次写驱动的时候,不妨多想想——这个寄存器该不该加 volatile?这个参数能不能加 const?