跳到内容

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、restrictvolatile。下一课才进入 thread 之间的原子顺序。

1. C 标准先定义存储期

C 对象有几类 storage duration:

存储期典型来源生命周期
automatic普通块作用域局部对象每次进入声明所在块开始,离开时结束
static文件作用域对象、static 局部对象整个程序执行期
thread_Thread_local 对象对应线程执行期
allocatedmalloc/calloc/realloc 返回的存储从成功分配到 free 或程序结束

storage duration 不等于固定物理区域。优化器可以把局部值只保存在寄存器里,甚至完全消除对象;取地址或调试构建时,它才更可能在调用栈帧中占有位置。

malloc 也不承诺来自一个连续向高地址生长的“heap”。分配器可以从 program break、mmap 区域、arena 或预留映射中提供存储。

2. 常见 ELF/Linux 映射只是实现图

一个 ELF 进程常见映射包括:

text
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 语言保证:

text
lower virtual addresses
  executable mappings
  writable program mappings
  allocator / mmap / shared libraries
  thread stacks
higher virtual addresses

“stack 一定从最高地址向下长,heap 一定从 data 后向上长”是某些 ABI/实现的常见形态,不应写进可移植程序逻辑。

3. 指针对象与它指向的对象要分开

c
const char *message = "hello";

这里至少有两个对象:

  • message 是一个可修改的指针对象,若在文件作用域通常有 static storage duration;
  • 字符串字面量对应一个字符数组,程序尝试修改其元素会产生未定义行为。

const char * 表示不能通过该指针修改所指字符,不表示指针自身不可改。若指针也要固定,可以写:

c
const char *const message = "hello";

理解这种层次比把所有东西都归入“data 段”更有用。

4. 对齐与 padding

每种完整对象类型都有 alignment requirement。数组元素连续排列,因此 sizeof(T) 必须包含足够的尾部 padding,让下一个元素也满足 T 的对齐。

下面的程序用标准接口观察布局,不把某个平台的结果写成语言定律:

c
#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;
}
bash
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 是更可移植的起点:

c
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 是调用者必须兑现的承诺

考虑逐元素相加:

c
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 做标准允许的有限标志通信:

c
#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. 观察布局的工具

可以组合使用:

bash
readelf -S program
readelf -l program
nm -S program
cat /proc/PID/maps

readelf -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 等特定边界,不同步线程。

线程间如何建立可见顺序,回到配套的C 并发内存模型。下一章则进入汇编与调用约定

Built with VitePress | Software Systems Atlas