第16章:C 与内存模型
元数据卡
| 属性 | 值 |
|---|---|
| 难度 | ●●●●○ |
| 前置 | 第6章(ELF 与链接)、第10章(指针与地址)、第15章(流水线) |
| 关键词 | 数据段、BSS、对齐、restrict、volatile、C11 原子操作、happens-before、data race |
| C 关联 | 本章就是 C 专题;stdatomic.h、_Alignas、_Atomic 均来自 C11 |
| Java | volatile 保证 Happens-Before(含栅栏);C 的 volatile 仅是编译器屏障 |
| Python | GIL 给了你虚假的安全感——开多线程用 ctypes 操作 C 指针时,C 层面的 data race 仍然存在 |
你在哪
"地心里最危险的地方——C 和它的内存模型。没有任何安全网,你直接操作内存地址和指针。一个越界写入就能导致程序崩溃,甚至打开安全漏洞。"
第15章让你看到:CPU 执行指令时,流水线、转发、分支预测在物理层面替程序员做了大量工作。但还有另一层在暗中起作用——内存。
一个函数里的局部变量存在哪里?全局变量呢?malloc 出来的内存去哪了?不同线程同时写同一个变量会发生什么?为什么编译器有时代码看起来工作正常,换个优化级别就炸了?
这些问题的答案藏在 C 语言的内存模型里。它不是一个「模型」——它是编译器、链接器、CPU、缓存、操作系统之间的一组合约。谁违反合约,谁得到未定义行为。
你的任务
本章分层
- 必读(C 内存布局):进程地址空间五大段(text/rodata/data/bss/heap/stack);结构体对齐与 padding 的规则
- 必读(并发内存模型):C11 原子操作的三种核心内存顺序;data race 的定义与识别
- 深水区:
restrict关键字的别名分析;volatile的正确使用场景辨析;happens-before 推导 本章不会要求你掌握- C11 所有六种 memory_order 的差异
- RCU(Read-Copy-Update)锁的使用
- C++ memory_order_consume 的弃用原因
读完本章你能:
- 给同事画出 Linux 进程地址空间布局(stack / heap / data / text / BSS)
- 解释为什么 C 结构体成员顺序影响 sizeof,并手动算出对齐 padding
- 写出 volatile 的正确使用场景(MMIO/信号处理)以及错误场景(多线程同步)
- 用 C11
atomic_家族写出无锁的计数器,且用memory_order_acquire/release解释为什么不崩 - 定义 data race 并指出三段代码里哪里出了 race
遭遇战
// mem_layout.c — 编译后看看它们在内存中的邻居关系
#include <stdio.h>
int global_init = 42; // .data
int global_uninit; // .bss
static int local_static = 7; // .data(但符号不导出)
const char *msg = "hello"; // msg → .data, "hello" → .rodata
int main(void) {
int local = 0; // stack
int *heap = malloc(4); // heap
printf("stack: %p\nheap: %p\ndata: %p\nbss: %p\ntext: %p\n",
&local, heap, &global_init, &global_uninit, main);
free(heap);
return 0;
}运行几次,观察地址的大小关系。在 Linux x86-64 上典型输出顺序:
stack: 0x7fff... ← 高位(向下生长)
heap: 0x5555... ← 地位(向上生长)
data: 0x5555... ← 在 heap 下方
bss: 0x5555... ← 紧挨 data
text: 0x5555... ← 最低.stack 和 heap 之间巨大的空洞——那就是地址空间留给映射文件的区域。
第一部分:C 内存布局
第一部分:C 内存布局
常见陷阱
1. 进程地址空间的五大段
| 段 | ELF Section | 内容 | 典型地址 (x86-64) |
|---|---|---|---|
| Text | .text | 机器码 | 0x400000~ (PIE 则随机化) |
| rodata | .rodata | 字符串常量、const 全局 | 紧挨 text |
| Data | .data | 已初始化的全局/静态变量 | 高过 text |
| BSS | .bss | 未初始化全局/静态(ELF 中只记录大小,不占文件空间) | 紧挨 data |
| Heap | — | malloc / brk | 向高地址生长 |
| Stack | — | 局部变量、参数、返回地址 | 最高位 0x7fff... 向低生长 |
低地址
+--------------------+
| .text (机器码) |
| .rodata (常量) |
| .data (已初始化) |
| .bss (未初始化) |
| ↓ heap |
| (空闲映射区域) |
| ↑ stack |
| 环境变量/参数 |
+--------------------+
高地址关键洞察:BSS 在 ELF 文件中不占磁盘空间,只在加载时分配零页。这就是为什么定义 int big[1000000] 的全局数组不撑大二进制文件。
2. 内存对齐(Alignment)
CPU 通常以 4 字节或 8 字节为粒度从内存读数据。如果 int 放在地址 0x1001(非 4 对齐),很多 RISC 机直接触发总线错误;x86 虽然容忍,但要两次访存才能拼出一个 int——性能灾难。
C 的对齐规则
struct P {
char c; // 1 byte, offset 0
// 3 bytes padding
int i; // 4 bytes, offset 4
short s; // 2 bytes, offset 8
// 此时 sizeof(P) = 10
// 但结构体对齐到最大成员(int 4)→ 填充到 12
};
// sizeof(struct P) = 12
struct Q {
int i; // offset 0, 4 bytes
short s; // offset 4, 2 bytes
char c; // offset 6, 1 byte
// 已用 7 bytes,对齐到 4 → 填充到 8
};
// sizeof(struct Q) = 8 ← 重排成员节省了 4 字节!规则:
- 每个成员的偏移必须是它自身对齐值的整数倍
- 结构体的总大小必须是最大成员对齐值的整数倍
- 编译器允许在成员之间和结构体末尾插入 padding
C11 提供了 _Alignas 控制:
// 强制 64 字节对齐(适合缓存行对齐避免伪共享)
struct alignas(64) CacheLine {
int data;
};语言深度:
restrict是 C99 引入的关键字,主要用于指导编译器做向量化优化。以下是面向性能敏感型 C 程序员的进阶内容。
3. restrict 关键字——给编译器的承诺
restrict 告诉编译器:这块内存只有这个指针访问它(通过该指针的访问不会与其他指针别名冲突)。
void add(int *restrict a, int *restrict b, int *restrict c, int n) {
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i];
}
}没有 restrict,编译器必须假设 a 和 c 可能指向同一块内存(如 add(x, y, x, n),即 c 和 a 重叠)。这意味着每次循环都不能安全地向量化——因为写入 c[i] 可能改了下一个 a[i+1] 的值。
加上 restrict 后,编译器可以放心地生成 SIMD 指令批量计算。
Java 差异:Java 没有 restrict,但 JIT 会在编译热点路径时做自动别名分析——如果发现没冲突就生成 SIMD。但保守情况比 C 多。
4. volatile 语义——被误解的关键字
volatile 只做一件事:阻止编译器把对该变量的访问优化掉。
// 正确的使用:MMIO(内存映射 IO)
volatile uint32_t *status_reg = (uint32_t *)0xFFFF0000;
while (*status_reg & BUSY); // 每次真的读硬件寄存器
// 错误的「经典」:你以为 volatile 能做线程同步
volatile int flag = 0;
// Thread 1: while (!flag); ← 编译确实不会优化掉
// Thread 2: flag = 1; ← 但 flag 的值可能被 CPU 缓存,对其他核不可见
// volatile 不插入内存屏障!在 x86 上靠强序故事能跑,ARM 上直接死循环。volatile 的正确使用场景只有三种:
- MMIO 寄存器访问
- 信号处理函数中访问全局变量(
sig_atomic_t类型已隐含 volatile) - setjmp/longjmp 涉及的变量
不是:多线程同步、原子操作、内存屏障。
Java 差异窗:Java
volatile在 JMM 中保证 Happens-Before——每次 volatile 读之后,所有之前的 volatile 写都对当前线程可见。这等价于 C11 的memory_order_seq_cst。C 的 volatile 连acquire语义都没有,千万别混用。
第二部分:并发内存模型
并发进阶:以下内容涉及 C11 标准的内存模型,是并发编程/无锁编程方向的核心话题。如果你初学 C 或主要写应用层代码,可以跳过这部分直接进入通关挑战。
第二部分:并发内存模型
5. C11 内存模型——happens-before 在你眼前
C11 把原子操作从编译器内置扩展到语言标准,定义了六种 memory_order。这里只讲三个核心的:
Relaxed (memory_order_relaxed)
最弱的次序——保证原子性,不保证任何顺序。
atomic_int x = 0, y = 0;
// Thread 1
atomic_store_explicit(&x, 1, memory_order_relaxed);
atomic_store_explicit(&y, 1, memory_order_relaxed);
// Thread 2
int r1 = atomic_load_explicit(&y, memory_order_relaxed);
int r2 = atomic_load_explicit(&x, memory_order_relaxed);
// 可能看到 r1=1, r2=0 —— 即使 x 先写,CPU 可以重排 relaxed 操作Acquire-Release
Thread 1 (release): Thread 2 (acquire):
atomic_store_explicit(&flag, 1, while (!atomic_load_explicit(
memory_order_release); &flag, memory_order_acquire));
// 在 release 之前的所有写, // acquire 之后能看到 T1 release 之前
// 对 acquire 之后的读可见 的所有写工作方式:
release:之前的普通写不能重排到此操作之后(x86 上是普通 store,ARM 上是dmb ish)acquire:之后的普通读不能重排到此操作之前
这形成了线程间的 happens-before 关系。
Sequentially Consistent (memory_order_seq_cst)
默认值,最直观也最慢。所有 seq_cst 操作在所有线程看来是单一全局序。
atomic_int x = 0;
atomic_store(&x, 1); // 默认 seq_cst
int r = atomic_load(&x); // 默认 seq_cst代价:x86 上每次 seq_cst store 编译成 xchg 或 mfence(比其他三种慢 10~100 纳秒),ARM 上也要 dmb ish full。
6. Data Race——什么时候程序是错的?
C11 标准定义:两个不同的线程同时访问同一内存位置,且至少一个是写操作,又没有通过原子操作或锁建立 happens-before 关系——这就是 data race,结果是未定义行为。
// DATA RACE: 同一位置,无锁,无原子
int shared = 0;
// Thread A: for (int i=0; i<1e6; i++) shared++;
// Thread B: for (int i=0; i<1e6; i++) shared--;
// 正确的做法:
atomic_int a_shared = 0;
// Thread A: for (int i=0; i<1e6; i++) atomic_fetch_add(&a_shared, 1);
// Thread B: for (int i=0; i<1e6; i++) atomic_fetch_sub(&a_shared, 1);即使是 int 赋值也不是原子的——在 32 位平台上写 64 位 long long 可以触发撕裂写(tearing),即一半字节被新值覆盖,另一半还是旧的,中间状态被另一个线程看到。
通关挑战
// 你能找出多少问题?
#include <pthread.h>
#include <stdatomic.h>
volatile int done = 0; // ← 问题 1:volatile 不保证可见性
int result;
void *worker(void *arg) {
result = compute(); // expensive
done = 1; // signal
return NULL;
}
int main(void) {
pthread_t t;
pthread_create(&t, NULL, worker, NULL);
while (!done); // ← 问题 2:无 fences,可能永循环
printf("%d\n", result);
pthread_join(t, NULL);
}Q1:这段代码为什么在 ARM 上可能永远跑不完? Q2:改为 C11 atomic 的正确写法是什么?用 memory_order_acquire/release。 Q3:两个 struct 都有 3 个成员(char, int, char),为什么四种排列顺序能得到四种不同的 sizeof 值?
验收标准
| 检查项 | 通过条件 |
|---|---|
| 画出地址空间布局 | 六个区域相对位置正确,标注生长方向 |
| 对齐 padding | 任意 struct,能写出成员偏移量和 sizeof |
| volatile 辨析 | 能给出一个「该用」和一个「不该用 volatile」的例子 |
| C11 内存顺序 | 区分 relaxed / acquire-release / seq_cst 的代价和语义 |
| 识别 data race | 给 5 行多线程代码,指出 race 并给出修复方案 |
常见卡点
| 卡点 | 原因 | 破解 |
|---|---|---|
| volatile = 线程安全 | 把编译屏障和内存屏障混为一谈 | 记住:volatile 只防编译器重排,不防 CPU 重排 |
| BSS 太大撑大二进制 | 误以为未初始化变量占文件空间 | ls 看文件大小 vs readelf -S 看 .bss 的 size |
| atomic 一定慢 | 觉得随便用会拖垮性能 | 读多写少时 relaxed 几乎零开销,x86 acquire/release 对读也零开销 |
| restrict 是性能银弹 | 写 restrict 就能自动快 | 冲突时是 UB,编译器不检查,可能生成错误代码 |
现在不需要理解
- RCU(Read-Copy-Update)锁——Linux 内核特有的无锁模式,门槛高
- C++ memory_order_consume——C17 已降级为弃用,大部分实现退化成 acquire
- 伪共享(False Sharing)——不同变量意外落在同一缓存行导致的性能退化,下一章再展开
- LSE / LSE2 原子指令——ARMv8.1 的大核原子指令,优化层面细节
旅人笔记
C 的内存模型像一份合约,签约方包括:
编译器(不优化可见的 volatile 访问)
CPU(某些指令提供 acquire/release 语义)
操作系统(按 ELF 段加载程序)
程序员(用 alignas / atomic / restrict 写明意图)
数据段布局决定了你程序的体积和加载速度。
对齐决定了结构体的内存开销——重排成员就能免费省字节。
volatile 只做一件事:不让编译器优化掉访问。
C11 atomic 才是线程之间正确通信的工具。
data race = UB,编译器可以合法地让你的程序在星期五下午崩溃。→ 下一站预告
地址空间里的段已经看清楚了,原子操作和内存屏障也会写了。但还有一件事没解决:编译器怎么把你写的 C 代码变成 CPU 能执行的指令。
一个函数调用背后发生了什么?参数放哪?返回值怎么取?寄存器谁负责保存?下一章——汇编基础与调用约定,揭开 C 代码和机器码之间的那层薄纱。
Java 差异窗:
java.util.concurrent.atomic包(AtomicInteger、AtomicReference、LongAdder)等价于 C11memory_order_seq_cst。引入VarHandle(Java 9+)后可以指定更细的getOpaque()/getAcquire()/setRelease(),终于接近 C11 的粒度。Python 差异窗:CPython 有 GIL,所以简单类型赋值似乎是原子的——但实际上
x += 1不是原子操作(取→加→存三步)。ctypes中c_int的赋值在 x86 上凑合,但别靠它。真想原子操作用multiprocessing.Value(typecode, lock=True)或切换语言。