Skip to content

第16章:C 与内存模型


元数据卡

属性
难度●●●●○
前置第6章(ELF 与链接)、第10章(指针与地址)、第15章(流水线)
关键词数据段、BSS、对齐、restrict、volatile、C11 原子操作、happens-before、data race
C 关联本章就是 C 专题;stdatomic.h_Alignas_Atomic 均来自 C11
Javavolatile 保证 Happens-Before(含栅栏);C 的 volatile 仅是编译器屏障
PythonGIL 给了你虚假的安全感——开多线程用 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 的弃用原因

读完本章你能:

  1. 给同事画出 Linux 进程地址空间布局(stack / heap / data / text / BSS)
  2. 解释为什么 C 结构体成员顺序影响 sizeof,并手动算出对齐 padding
  3. 写出 volatile 的正确使用场景(MMIO/信号处理)以及错误场景(多线程同步)
  4. 用 C11 atomic_ 家族写出无锁的计数器,且用 memory_order_acquire/release 解释为什么不崩
  5. 定义 data race 并指出三段代码里哪里出了 race

遭遇战

c
// 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
Heapmalloc / 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 的对齐规则

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 字节!

规则

  1. 每个成员的偏移必须是它自身对齐值的整数倍
  2. 结构体的总大小必须是最大成员对齐值的整数倍
  3. 编译器允许在成员之间和结构体末尾插入 padding

C11 提供了 _Alignas 控制:

c
// 强制 64 字节对齐(适合缓存行对齐避免伪共享)
struct alignas(64) CacheLine {
    int data;
};

语言深度restrict 是 C99 引入的关键字,主要用于指导编译器做向量化优化。以下是面向性能敏感型 C 程序员的进阶内容。

3. restrict 关键字——给编译器的承诺

restrict 告诉编译器:这块内存只有这个指针访问它(通过该指针的访问不会与其他指针别名冲突)。

c
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,编译器必须假设 ac 可能指向同一块内存(如 add(x, y, x, n),即 c 和 a 重叠)。这意味着每次循环都不能安全地向量化——因为写入 c[i] 可能改了下一个 a[i+1] 的值。

加上 restrict 后,编译器可以放心地生成 SIMD 指令批量计算。

Java 差异:Java 没有 restrict,但 JIT 会在编译热点路径时做自动别名分析——如果发现没冲突就生成 SIMD。但保守情况比 C 多。


4. volatile 语义——被误解的关键字

volatile 只做一件事:阻止编译器把对该变量的访问优化掉

c
// 正确的使用: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 的正确使用场景只有三种:

  1. MMIO 寄存器访问
  2. 信号处理函数中访问全局变量sig_atomic_t 类型已隐含 volatile)
  3. 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)

最弱的次序——保证原子性,不保证任何顺序。

c
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 操作在所有线程看来是单一全局序

c
atomic_int x = 0;
atomic_store(&x, 1);  // 默认 seq_cst
int r = atomic_load(&x); // 默认 seq_cst

代价:x86 上每次 seq_cst store 编译成 xchgmfence(比其他三种慢 10~100 纳秒),ARM 上也要 dmb ish full


6. Data Race——什么时候程序是错的?

C11 标准定义:两个不同的线程同时访问同一内存位置,且至少一个是写操作,又没有通过原子操作或锁建立 happens-before 关系——这就是 data race,结果是未定义行为

c
// 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),即一半字节被新值覆盖,另一半还是旧的,中间状态被另一个线程看到。


通关挑战

c
// 你能找出多少问题?
#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/releaseQ3:两个 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 包(AtomicIntegerAtomicReferenceLongAdder)等价于 C11 memory_order_seq_cst。引入 VarHandle(Java 9+)后可以指定更细的 getOpaque() / getAcquire() / setRelease(),终于接近 C11 的粒度。

Python 差异窗:CPython 有 GIL,所以简单类型赋值似乎是原子的——但实际上 x += 1 不是原子操作(取→加→存三步)。ctypesc_int 的赋值在 x86 上凑合,但别靠它。真想原子操作用 multiprocessing.Value(typecode, lock=True) 或切换语言。

Built with VitePress | Software Systems Atlas