16.1 对象布局、别名与 volatile
走到计算机地心最靠近 C 的区域,安全栏杆明显少了。你在地址地图上看见 stack、mapping 与 allocator 区域,很容易把这张 Linux 实现图误当成 C 语言契约,于是得出“局部变量一定在栈上”或“malloc 一定让 heap 向上增长”这样的结论。
C 先定义 object、storage duration、alignment 和允许的访问方式;OS 与 toolchain 再把程序放进 ELF mapping、thread stack 和动态分配区域。这一课先拆开两层契约,再讨论 struct padding、aliasing、restrict 和 volatile。下一课才进入 thread 之间的原子顺序。
1. C 标准先定义存储期
C 对象有几类 storage duration:
| 存储期 | 典型来源 | 生命周期 |
|---|---|---|
| automatic | 普通块作用域局部对象 | 每次进入声明所在块开始,离开时结束 |
| static | 文件作用域对象、static 局部对象 | 整个程序执行期 |
| thread | _Thread_local 对象 | 对应线程执行期 |
| allocated | malloc/calloc/realloc 返回的存储 | 从成功分配到 free 或程序结束 |
storage duration 不等于固定物理区域。优化器可以把局部值只保存在寄存器里,甚至完全消除对象;取地址或调试构建时,它才更可能在调用栈帧中占有位置。
malloc 也不承诺来自一个连续向高地址生长的“heap”。分配器可以从 program break、mmap 区域、arena 或预留映射中提供存储。
2. 常见 ELF/Linux 映射只是实现图
一个 ELF 进程常见映射包括:
executable code and read-only data
writable initialized data
zero-initialized static storage
allocator-managed regions
shared libraries and mmap regions
per-thread stacks工具链常把机器码放入 .text,只读常量放入 .rodata,非零静态对象放入 .data,零初始化静态对象放入 .bss。ELF 的 SHT_NOBITS section 会记录内存大小而不在文件中保存同等长度的零字节,因此大型零初始化数组不必等量扩大文件。
链接器可合并、重排 section,加载器按 segment 映射页面,ASLR 还会改变基址。共享库、PIE、sanitizer 和不同操作系统都会改变布局。下面这张图只能帮助定位,不是 C 语言保证:
lower virtual addresses
executable mappings
writable program mappings
allocator / mmap / shared libraries
thread stacks
higher virtual addresses“stack 一定从最高地址向下长,heap 一定从 data 后向上长”是某些 ABI/实现的常见形态,不应写进可移植程序逻辑。
3. 指针对象与它指向的对象要分开
const char *message = "hello";这里至少有两个对象:
message是一个可修改的指针对象,若在文件作用域通常有 static storage duration;- 字符串字面量对应一个字符数组,程序尝试修改其元素会产生未定义行为。
const char * 表示不能通过该指针修改所指字符,不表示指针自身不可改。若指针也要固定,可以写:
const char *const message = "hello";理解这种层次比把所有东西都归入“data 段”更有用。
4. 对齐与 padding
每种完整对象类型都有 alignment requirement。数组元素连续排列,因此 sizeof(T) 必须包含足够的尾部 padding,让下一个元素也满足 T 的对齐。
下面的程序用标准接口观察布局,不把某个平台的结果写成语言定律:
#include <stdalign.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
struct P {
char first;
int number;
short small;
};
struct Q {
int number;
short small;
char first;
};
int main(void) {
alignas(64) struct Q cache_aligned = { 0 };
printf("P: size=%zu align=%zu offsets=%zu,%zu,%zu\n",
sizeof(struct P), alignof(struct P),
offsetof(struct P, first),
offsetof(struct P, number),
offsetof(struct P, small));
printf("Q: size=%zu align=%zu offsets=%zu,%zu,%zu\n",
sizeof(struct Q), alignof(struct Q),
offsetof(struct Q, number),
offsetof(struct Q, small),
offsetof(struct Q, first));
uintptr_t address = (uintptr_t)&cache_aligned;
printf("explicitly aligned: %s\n",
address % 64 == 0 ? "yes" : "no");
return address % 64 == 0
? EXIT_SUCCESS : EXIT_FAILURE;
}cc -std=c17 -O2 -Wall -Wextra layout.c
./a.out常见 ABI 上 P 可能比 Q 大,因为 int 前后需要 padding;具体数字由实现的类型大小与对齐决定。alignas(64) 对这个对象提出 64 字节对齐要求,若实现不支持该有效对齐,程序应在编译阶段被拒绝,而不是运行后悄悄失效。
成员重排不是永远可做
调整成员顺序可能减少 padding,却会改变:
- ABI 与外部库约定;
- 二进制文件或网络协议布局;
- 与硬件寄存器对应的布局;
- cache line 上哪些字段彼此相邻;
- 源码初始化器和调试工具预期。
持久化或传输结构体时,不要直接写 sizeof(struct) 个原始字节。padding 值未必确定,端序和类型大小也不具备跨平台协议语义。应显式编码字段,参见第 1 章的字节序与位掩码。
5. 未对齐访问不能靠“x86 能跑”推广
把任意字节地址强转为 uint32_t * 并解引用,可能同时违反:
- 指针对齐要求;
- 对象有效类型/别名规则;
- 缓冲区边界;
- 字节序预期。
读取外部字节流时,memcpy 是更可移植的起点:
uint32_t value;
memcpy(&value, bytes + offset, sizeof(value));这只解决了安全搬运到正确对齐对象;若数据格式规定网络字节序,还要显式解码。编译器通常能把已知长度的 memcpy 优化为适合目标架构的 load。
6. 别名规则让优化器能够缓存值
alias 表示两个表达式可能访问同一存储。若编译器必须假设任意类型指针都能互相别名,许多值就不能留在寄存器,向量化也会受限。
C 允许通过字符类型的 lvalue 查看对象表示。对其他不兼容类型直接重解释并解引用,可能违反 effective type/compatible type 规则。需要搬运比特表示时,优先使用 memcpy;需要数值转换时,使用显式转换。
union type punning、共同初始序列和编译器扩展有各自边界,不应把“内存里的位一样”当作任意指针强转都合法。
7. restrict 是调用者必须兑现的承诺
考虑逐元素相加:
void add_arrays(size_t length,
const float *restrict left,
const float *restrict right,
float *restrict output) {
for (size_t i = 0; i < length; ++i) {
output[i] = left[i] + right[i];
}
}restrict 不是运行时检查,也不是“这个地址全程序只有一个指针”。它对相应 block execution 中基于受限指针访问对象的方式施加约束。若调用者传入违反这些关联要求的重叠范围,并发生相关访问,行为未定义。
编译器因此可以不为重叠保留保守顺序,并更容易向量化。即使没有 restrict,编译器也可能生成运行时别名检查,在不重叠路径使用快速版本。
只在 API 能清楚表达且调用者能长期保证不重叠时使用 restrict。为了“试试会不会更快”随手添加,可能把正确程序变成未定义行为。
8. 控制台警示灯需要 volatile,多线程协议不够
Memory-mapped device register 或 signal handler 共享的窄边界,需要程序真的执行相应访问;volatile 可以表达这类要求。它没有建立 thread 之间的 atomicity 或 happens-before,不能拿来代替下一课的并发内存模型。
volatile-qualified object 的访问是抽象机中的可观察行为之一,具体哪些访问、何时完成还受完整表达式与实现定义的严格行为约束。把它缩写成“禁止编译器优化”会漏掉边界,也容易被误用成线程同步。
常见合法用途包括:
实现规定的 MMIO。 平台 ABI 或设备 SDK 可能要求通过 volatile-qualified lvalue 访问寄存器。地址、宽度、顺序和屏障仍由平台规定;ISO C 本身不定义物理设备寄存器。
信号处理通信。 异步 signal handler 与主流程之间,可使用 volatile sig_atomic_t 做标准允许的有限标志通信:
#include <signal.h>
static volatile sig_atomic_t stop_requested = 0;
static void handle_signal(int number) {
(void)number;
stop_requested = 1;
}sig_atomic_t 不是“隐含 volatile”,两者承担不同作用。handler 仍只能调用 async-signal-safe 的接口。
setjmp/longjmp 边界。 调用 setjmp 的函数中,某些 automatic 局部对象若在其后修改且不是 volatile-qualified,longjmp 返回后值会变得不确定。这是非常具体的语言规则,不是通用线程可见性。
volatile 不提供:
- 原子 read-modify-write;
- 跨线程 happens-before;
- mutex 互斥;
- cache flush;
- 可移植的硬件 memory barrier。
线程共享数据应使用 _Atomic、mutex 或其他同步原语。
9. 对象生命周期结束后,地址也不能随意使用
free(pointer) 后,对应 allocated object 的生命周期结束。即使字节暂时没被覆盖,通过旧指针读取仍是 use-after-free。
realloc 成功返回新地址时,旧指针失效;若失败并返回 NULL,原对象仍有效。因此要用临时变量接收返回值。
地址数值恰好相同也不能证明是同一个对象。分配器可以迅速复用刚释放的地址,这也是 lock-free 算法里 ABA 问题的一种来源。对象身份与生命周期不能只靠打印指针判断。
10. 观察布局的工具
可以组合使用:
readelf -S program
readelf -l program
nm -S program
cat /proc/PID/mapsreadelf -S 看链接文件 section,readelf -l 看加载 segment,/proc/PID/maps 看某次运行的虚拟映射。三者回答不同问题,不能拿 section 表直接当运行时页表。
优化构建中变量可能没有稳定内存位置。调试器显示 optimized out 并不表示源码变量“跑丢了”,而是编译后的程序不再需要一个可独立定位的对象。
11. 小结
C 对象模型与操作系统地址空间需要分层理解:
- C 定义 storage duration,不保证“局部=栈、malloc=连续 heap”;
- ELF section、加载 segment 与运行时 mapping 是三个层次;
- 结构体包含内部和尾部 padding,布局受 ABI 与对齐约束;
- 原始结构体字节不适合作为跨平台序列化格式;
memcpy是处理未对齐外部字节与对象表示的重要工具;restrict是调用者必须兑现的别名承诺;volatile有 MMIO、信号和setjmp等特定边界,不同步线程。