前置知识: C、C

const 与 volatile 详解

10 min中级

两个最容易混的限定符:编译器优化、硬件视角与组合用法。

const 与 volatile 详解

给演唱会舞台灯控固件写 C 代码时,会遇到两类特殊变量:配色表一旦烧录进闪存就永远不该被改写;而升降台状态寄存器恰恰相反——软件一行都不能写,硬件却在不停地改。const 与 volatile 正是描述这两种”非常规”约束的限定符:前者对编译器说”请禁止改写并放心优化”,后者说”请每次都老老实实读写”。两者语义正交,可以单独使用,也可以组合。本文用舞台设备场景把它们的每一层含义拆开讲清。

前置知识

学习目标

  1. 掌握 const 与指针组合的四种形态,能用”右左法则”准确读出声明含义。
  2. 说清 volatile 阻止优化的三大场景,能解释缺了它为什么会产生死循环。
  3. 理解 const volatile 组合的典型对象:软件只读、硬件可变的寄存器。
  4. 能对比 const 变量、宏与枚举三种常量手段的适用边界。
  5. 理解两者在嵌入式与多线程环境下的真实意义与能力边界。

一、const 的三层含义:变量、指针、指向

const 修饰的是”声明里的某个名字被限定为只读”,与谁组合决定了限定谁:修饰普通变量,值不可改;与指针组合时则有两个独立的位置——指针本身可否改(指向谁),以及所指对象可否通过它改写。四种组合可以按”右左法则”(从标识符往左读)逐一分辨。

/* 舞台灯控固件:const 与指针的四种组合 */
int theme_color = 0x3399FF;               /* 当前应援色:蓝(普通变量) */
int alt_color = 0xFF69B4;                 /* 备用应援色:粉 */

const int *p_to = &theme_color;           /* 指向 const 的指针:*p_to 不可写,p_to 可改指 */
int *const p_fix = &theme_color;          /* const 指针:p_fix 不可改指,*p_fix 可写 */
const int *const p_both = &theme_color;   /* 双重 const:改值、改指都不行 */

/* p_to = &alt_color;    合法:换个颜色看板(指针可改指) */
/* *p_fix = 0xFFD700;    合法:把灯改成金色(对象可写) */
/* *p_to = 0x000000;     编译错误:不能通过 p_to 改写 */
/* p_fix = &alt_color;   编译错误:p_fix 生来只认一个对象 */

理解的关键是分清”两个自由度”:指针指向哪里、所指对象的内容。const 出现在 * 左边管后者,出现在 * 右边管前者。函数形参里最常见的 const char *title 表达的是”函数承诺不通过这个指针改写你的歌名”,这是接口契约,而不是说调用方的变量从此不可变。

工程上还有一条顺手的约定:能写 const 的形参一律写 const,它不改变函数行为,却让接口自我文档化——阅读者一眼可知哪些参数不会被函数改写,编译器也顺势获得更多检查与优化机会。在多层指针、结构体指针满天飞的固件代码里,const 注记几乎等于免费的正确性文档。

二、volatile:阻止优化的场景

C 标准规定 volatile 用于三种对象:内存映射的硬件寄存器、信号处理程序与执行流之间共享的对象、以及 setjmp/longjmp 之间存活的对象。它们的共同点是”值可能在编译器看不见的地方被改变”。没有 volatile,编译器有权把重复读取合并成一次——变量被缓存在寄存器里,外部世界的任何变化都再也无法被程序观察到。

/* 舞台升降台状态:由中断服务程序置位,主循环轮询 */
volatile int lift_ready = 0;

/* 中断服务程序:硬件到位后触发(由平台注册) */
void LiftInterruptHandler(void)
{
    lift_ready = 1; /* 这行写入发生在主循环的控制流之外 */
}

int WaitLiftReady(void)
{
    while (lift_ready == 0) {
        /* 若缺少 volatile:编译器可能只读一次并缓存到寄存器,
           主循环永远看到 0,形成死循环 */
    }
    return 1;
}

volatile 的效果是精确而狭窄的:每次访问都真实地读或写内存一次,访问之间不重排(相对其他 volatile 访问)。它不提供原子性,也不插入内存屏障;多核之间、多字节数据上的并发正确性,需要 C11 的 _Atomic 或平台临界区来保证。一句话总结:volatile 解决”看得见”,原子与同步原语解决”算得对”。

编译器对普通变量的优化相当激进:循环不变量外提(把循环内不变的表达式搬到循环外)、公共子表达式消除、把频繁访问的变量缓存在寄存器里。这些优化对”编译器可见范围内不变”的变量是安全的,而 volatile 的定义正是”禁止上述假设”,编译器必须按源代码的字面次数与相对顺序访问内存。理解了这一层,就明白 volatile 的开销与收益都来自”少优化”。

三、const volatile 组合:只读寄存器

const 与 volatile 修饰的是两个正交的维度:谁能写(软件契约)、值何时变(运行时事实)。状态寄存器恰好同时满足”软件只读”与”硬件随时改变”,两个限定符就要叠加使用。

/* 状态寄存器映射在固定地址:软件只读,硬件自行变化 */
volatile const unsigned int *const STATUS_REG =
    (volatile const unsigned int *)0x40021000u;

int StageReady(void)
{
    /* 每次循环都真实读取寄存器(volatile 保证);
       误写 *STATUS_REG 会在编译期被 const 拦下 */
    while ((*STATUS_REG & 0x1u) == 0u) {
        ; /* 等待舞台自检通过位 */
    }
    return 1;
}

逐层读这个声明:最内层 const unsigned int 表示所指数据不可通过此指针写;volatile 表示数据随时可能被硬件改变,禁止缓存与合并读取;最右边的 * const 表示指针本身初始化后不再改指。三个限定各司其职,缺一个都会引入缺陷:去掉数据侧 const,固件里某次误写可能通过编译;去掉 volatile,轮询可能变成永真或永假的死循环。嵌入式代码评审中,寄存器映射声明值得逐字符核对。

值得一提的是,不同编译器对 volatile 访问的优化细节存在差异,跨编译器构建(GCC、Clang 或厂商工具链并用)时应以最严格者为准。另外,寄存器映射通常还要配合链接脚本或启动代码把地址段标记为不可缓存,这属于平台移植的功课;C 代码层面能做的是把声明写对、把注释写清。

四、与宏、枚举定义常量对比

C 里表达”不变的值”有三种手段:#define 宏、枚举常量、const 变量。宏在预处理期做文本替换,无类型、无作用域、不占内存,但可参与常量表达式(数组长度、case 标签);枚举是整型常量表达式,天然适合一组相关的整数常量;const 变量有类型、有作用域、能进符号表利于调试,但在 C 中它不是常量表达式,不能用来定数组长度或做 case 标签(这一点与 C++ 不同)。

/* 三种常量手段对比 */
#define MAX_TICKETS 1000        /* 宏:无类型、文件级生效,预处理期替换 */
enum { MAX_ROWS = 20 };         /* 枚举:整型常量表达式 */
const int vip_rows = 5;         /* const:有类型有作用域,但非常量表达式 */

int seats[MAX_ROWS];            /* 合法:枚举可用于数组长度 */
/* int vipseats[vip_rows];       C 中非法(C99 变长数组是另一回事) */

switch (999) {
case MAX_TICKETS:               /* 合法:宏展开为整型常量 */
    break;
default:
    break;
}

选择建议:一组相关的整数(状态码、档位)用枚举;需要类型检查与作用域、且只作为”只读数据”使用时用 const 变量;需要参与编译期计算(数组长度、位宽宏、条件编译开关)时才用宏,且定义时给整体加括号。C23 之后宏的替代品仍在演进,这条”默认枚举、次 const、慎用宏”的经验法则依然可靠。

三种手段的差异可以列成一张表:

维度#define 宏枚举const 变量
类型检查无有(限整型)有(任意类型)
作用域从定义点到文件尾花括号内遵循 C 作用域规则
常量表达式是是否(C 语言中)
调试符号无,替换后不可见有有
占用存储不占(纯替换)不占通常占(可被优化掉)

表中”调试符号”一行常被忽视:宏在崩溃现场只剩展开后的表达式,排查全靠猜;const 与枚举在调试器里都有自己的名字。不少嵌入式团队把常量从宏迁到枚举后,现场排障效率的提升立竿见影。

五、嵌入式与多线程下的意义

在嵌入式场景,const 还有一个物理意义:被 const 修饰且无地址取用的数据通常进入只读段(.rodata),链接器可以放进闪存,运行期由 MPU 等机制提供写保护,省下宝贵的 RAM。配色表、字形库、曲目元数据这类”烧录即定”的数据都应声明为 const。

/* 应援色配色表:烧录进闪存的只读段 */
static const unsigned int theme_palette[] = {
    0x3399FFu, /* 蓝 */
    0xFF69B4u, /* 粉 */
    0xFFD700u, /* 金 */
};

/* 中断与主循环共享的节拍值:volatile 保证可见性 */
volatile unsigned int stage_bpm = 120;

/* 多字节共享数据的读取仍需临界区:volatile 不提供原子性 */
unsigned int ReadBpmSafe(void)
{
    unsigned int value;
    __disable_irq();   /* 临界区入口(平台相关伪代码,如 Cortex-M 关中断) */
    value = stage_bpm;
    __enable_irq();    /* 临界区出口 */
    return value;
}

多线程场景下结论同样成立:两个线程共享的标志位至少要 volatile 才能保证一方写入、另一方看得见;但涉及”读改写”、多字节复合值或需要顺序保证时,必须升级为 _Atomic 类型或互斥锁。把 volatile 当线程同步工具用,是 C 并发缺陷排行榜上的常客,评审时看到它在锁与原子类型旁边就要提高警惕。

把三者放进同一个心智模型:const 管”该不该写”,volatile 管”看得见吗”,_Atomic 与锁管”同时改会怎样”。固件与多线程代码评审时,对每个共享对象依次问这三个问题,缺哪层就补哪层。这条三问清单简单到可以在评审时口算,却能拦下绝大多数并发与硬件交互缺陷。

易错点与最佳实践

  1. 把 volatile 当原子操作用。 错误:两个线程同时 stage_bpm++,认为 volatile 能保证正确。修正:改用 C11 原子类型或临界区。

    #include <stdatomic.h>
    atomic_uint stage_bpm = 120;          /* 修正:原子类型 */
    /* 读改写:atomic_fetch_add(&stage_bpm, 1); */
  2. 通过强转写 const 数据。 错误:*(int *)&theme_color = 0; 若对象真被放进只读段,运行期触发写保护错误,属未定义行为。修正:需要修改就不要声明为 const;确需绕过契约的场景(如回调的上下文参数)重新审视设计。退一步说,即使对象碰巧不在只读段,强转写 const 也会让所有依赖”它不变”的优化假设失效,属于”能跑但全错”的代码。

  3. 宏常量缺括号。 错误:#define SEAT_FEE 10+2 后写 SEAT_FEE * 3,展开成 10+2*3。修正:整体加括号,或直接改用枚举/const。

    #define SEAT_FEE (10 + 2)   /* 修正:括号兜底,或改用 enum { SEAT_FEE = 12 }; */
  4. 共享变量漏写 volatile。 错误:中断置位的标志没有 volatile,主循环轮询被优化成死循环。修正:所有”控制流之外可能被修改”的对象一律加 volatile,并理解它只保证可见性。反过来也别矫枉过正:普通局部变量、互斥锁保护下的共享数据都不需要 volatile,滥用它会关闭优化、掩盖真正的同步问题。

  5. 函数形参的 const 被误解为全局承诺。 void play(const char *title) 只承诺函数内部不改,调用方的 title 本身可以随时变化。修正:需要跨线程长期持有的数据,在接口层复制或由调用方保证生存期,不要指望形参 const 提供任何运行时保护。

本篇小结

  1. const 修饰”声明中的名字”:与指针组合时,* 左侧限定所指内容、右侧限定指针本身,右左法则可读出任意组合。
  2. volatile 的三大法定场景是寄存器、中断共享与 setjmp 跨越点;它禁止缓存与合并访问,但不提供原子性与内存屏障。
  3. const volatile 组合描述”软件只读、硬件可变”的对象,两个限定符分别守住契约与事实,缺一不可。
  4. 常量手段的选择:相关整数用枚举、只读数据用 const 变量、编译期计算才用宏,且宏定义务必加括号。
  5. 嵌入式与多线程下,const 换来只读段与写保护,volatile 只解决可见性;算术正确性要靠 _Atomic 或锁。

动手实践

  1. 在模拟器中实现一个假想的内存映射状态寄存器:把某全局变量的地址当寄存器用,分别写”有 volatile”与”无 volatile”的轮询版本,用 -O2 编译并反汇编(或 gdb 观察),找出编译器合并读取的证据。
  2. 为灯控固件设计一组常量:三档亮度、四种应援色、最大并发灯组数,分别选择枚举、const 数组与宏来表达,并写一段注释说明每个选择的理由。
  3. 构造一个双线程自增计数器的竞态程序:volatile 版本与 _Atomic unsigned int 版本各跑一次十万次自增,对比最终值与期望值的偏差,把结果记录进并发学习笔记。