前置知识: C

C 语言理论收束:抽象机、别名与未定义行为

24 min高级

-O0 正常 -O2 出错的现场开题:用 as-if 规则与可观察行为讲清编译器的授权边界,收束对象与有效类型、严格别名、未定义/未指定/实现定义三分法、翻译单元与链接、freestanding 与标准演进主线,把全模块用过的机制连成一条理论主线。

前置知识

  • 已完成 内存深水区:见过 use-after-free「有时才崩」的手感,知道那叫未定义行为;
  • 已完成 函数调用栈帧:知道同一份 C 代码在 System V 与 Windows 上汇编不同,调用约定属于 ABI;
  • 已完成 运算符与表达式:知道 i = i++ 是禁手,函数实参求值顺序不可依赖。

分工说明:本篇是 C 模块末段的理论收束。项目实战 把你带到这里,学习总结 从这里接棒。它不引入新的语言机制,而是把一路用过的东西——volatile、求值顺序、malloc、翻译单元、交叉编译、C23 关键字——统一到「抽象机语义」一条主线上,回答每个机制「为什么这样设计」。volatile 与 as-if 的优化器视角已在 volatile 深水区 讲过,本篇把它上升为整个语言的地基,不重复其事故现场。

学习目标

读完本文你将能够:

  1. 用 as-if 规则与「可观察行为」解释 -O0 与 -O2 的行为差异,判断一次优化是合法重排还是踩中未定义行为;
  2. 说出对象、有效类型与严格别名三条规则,用 memcpy 或 union 正确做类型双关,权衡 -fno-strict-aliasing 的取舍;
  3. 把一条「行为怪异」归入未定义 / 未指定 / 实现定义三类,并指出标准依据与已学篇章的实例;
  4. 说出 C11 6.8.5p6 对空转循环的后果,解释 Linux 内核为什么要开 -fno-delete-null-pointer-checks;
  5. 讲清分离编译、freestanding 与 hosted、标准演进主线的设计动机,读懂 -std= 与 STDC_HOSTED 这类开关。

预计 60 到 80 分钟,含 3 组实验、1 道预测题与 1 道挑战题。

1. 问题引入:-O0 说出直觉,-O2 说出标准

先用有符号溢出写一个「溢出检测器」——逻辑看起来天衣无缝:加完变小的就是溢出了。

c
/* overflow.c:用回绕检测有符号溢出 */
#include <stdio.h>
#include <limits.h>

int will_overflow(int a, int b) {
    return a + b < a;        /* 直觉:若回绕,和会小于 a */
}

int main(void) {
    printf("INT_MAX + 1 溢出了吗? %d\n",
           will_overflow(INT_MAX, 1));
    return 0;
}
bash
gcc -Wall -Wextra -g overflow.c -o overflow_o0 && ./overflow_o0
gcc -Wall -Wextra -O2 overflow.c -o overflow_o2 && ./overflow_o2

一次典型输出:

text
INT_MAX + 1 溢出了吗? 1     (-O0)
INT_MAX + 1 溢出了吗? 0     (-O2)

同一份代码,没改一个字,答案相反。这不是编译器 bug,也不是玄学:a + b 在 a = INT_MAX 时发生有符号溢出,而 C 标准对有符号溢出不提任何要求——这是未定义行为。-O0 让硬件的回绕行为直接透出来,于是检测「成功」;-O2 的编译器获准假设「程序里没有溢出」,既然 a + b 永不溢出,a + b < a 恒为假,整个函数被化简成 return 0。

理论不是屠龙术。凡是「-O0 正常、-O2 出错」「换了编译器就坏」「换了 CPU 就坏」的现象,唯一能给出解释的工具就是本篇:C 程序的语义到底定义在哪台机器上,编译器被授权改写什么、不许改写什么。

2. 抽象机与 as-if 规则:编译器到底欠你什么

2.1 语义定义在抽象机上

C 标准不描述你的 CPU,它描述一台抽象机(abstract machine,C11 5.1.2.3):程序在这台机器上串行执行,语句逐条进行,每次读写都真实落到内存,运算按源代码写出的顺序发生。你的 C 程序「是什么意思」,先由这台机器定义。

真实的实现(编译器 + CPU)远比抽象机激进:变量缓存进寄存器、指令重排、循环展开、向量化。那实现的自由度边界在哪?标准给出了唯一的紧箍咒:

as-if 规则:实现可以做任何变换,只要程序的可观察行为与抽象机一致。

2.2 可观察行为三件套

C11 5.1.2.3 把三样东西列进可观察行为:

  1. volatile 对象的访问——每一次读写都必须严格按抽象机发生;
  2. 程序终止时写入文件的数据——最终落盘内容不能变;
  3. 与交互式设备的读写——输出提示必须在等待输入之前送达(提示先刷新)。

三件套之外的内部细节——变量住在寄存器还是内存、循环跑了几次、中间结果按什么顺序算出——实现随意处置,只要「外面看不出来」。这条边界你在前面的篇章已经用过三次,现在它们有了统一的解释:

已学现象as-if 解释
求值顺序 的 i = i++ 禁手抽象机上两个副作用之间没有先后约定,C11 6.5p2 直接判 UB;出任何结果都算「实现自由」
volatile 深水区 的忙等死循环普通 while (!ready) 里没有可观察行为,as-if 授权把读取外提缓存进寄存器;volatile 把访问变成可观察行为,授权链被截断
C 与汇编交互 的空 asm volatile("nop")没有 volatile 的内联汇编「计算不出可观察行为」,as-if 授权删除或复制它;volatile 声明「这一条必须物理发生」,再配 memory clobber 挡重排

2.3 实验:看 -O2 把函数化简成什么

回到第 1 节的 will_overflow,看编译器到底写了什么:

bash
gcc -O2 -S overflow.c -o - | grep -A3 will_overflow

x86-64 上一次典型输出:

text
will_overflow:
        xorl    %eax, %eax
        ret

xorl %eax, %eax 是「清零返回值」的惯用法:整个函数被化简成 return 0。推理链只有一步——「标准没定义溢出时会发生什么,所以我按永不溢出来设计」。这条授权的原文与后果,第 4 节展开。

3. 对象、类型与有效类型:内存上的类型解释

3.1 对象与有效类型

C 里对象(object,C11 3.15)指「内存中一段字节区,加上一种类型解释」。同一个地址按 int 读是一个数,按 float 读是另一个数——字节没变,解释变了。指针深度解析 里「指针类型决定怎么解释那几个字节」的心智模型,在这里落到了标准层级。

声明 int x; 给这段字节定了户口:它的声明类型(declared type)是 int。malloc 分配的内存没有声明类型,它的有效类型(effective type)由写入决定(C11 6.5p6):通过某个类型的左值写入,该类型就成为这块内存此后读取的有效类型;经 memcpy 或字符数组逐字节拷来的数据,继承源对象的类型。这就是「先写后读必须类型一致」的标准依据,也是类型双关问题出现的地方。

3.2 严格别名:允许的访问清单

C11 6.5p7 规定,一个对象的存储值只能通过下列类型的左值访问:

  1. 与对象有效类型兼容的类型;
  2. 上述兼容类型的限定版本(const、volatile 修饰);
  3. 与有效类型对应的 signed 或 unsigned 类型(如经 unsigned int * 读 int 对象);
  4. 包含上述类型的聚合或联合(成员,递归到子聚合与内含联合);
  5. 字符类型(char、signed char、unsigned char——可别名任何对象)。

清单之外,就是未定义行为。这就是严格别名规则(strict aliasing):float *fp = (float *)&x; *fp = 1.0f; 违规——float 与 int 不兼容,不是彼此的符号变体,也不在清单里。名字里的「别名」指两个不同类型的指针指向同一地址。

规则的回报是优化空间:编译器获得「int * 与 float * 不指向同一内存」的推定,可以放心重排读写、复用寄存器里的旧值。

c
void scale(int *i, float *f, int n) {
    for (int k = 0; k < n; k++) {
        i[k] = 0;        /* 编译器推定 i 与 f 不重叠 */
        f[k] = 1.0f;     /* 两个写可以自由重排、合并、向量化 */
    }
}

若调用方真的传入了别名指针(scale((int *)buf, (float *)buf, n)),优化后的行为与源码直觉不符——但按标准,错在调用方:它先违反了 6.5p7。

3.3 工程现实:memcpy、union 与 -fno-strict-aliasing

标准规则与工程现实之间有三座桥:

  • memcpy 是万无一失的:按字节搬运不构成「经不兼容类型的访问」,编译器能把它优化成一条 mov。要做类型双关(type punning,结构体与联合体 写 A 读 B 的那件事),这是首选;
  • union 双关是工程通行、标准有脚注背书的:C99 起标准脚注明确描述了「经 union 换成员重读」的过程(正是 type punning),同时提醒结果可能是陷阱表示;GCC 文档写明经 union 本身做类型双关可行,但取地址、强转指针、再解引用即使强转目标是 union 类型也仍是 UB——写进 union 再读出来,别绕指针;
  • -fno-strict-aliasing 是过渡手段:关闭整套推定,换取遗留代码能跑。代价是编译器失去一大类优化;Linux 内核与不少大型 C 项目常年开着它,那是历史包袱管理,不是新代码的推荐姿势。

4. 未定义、未指定、实现定义:三分法

4.1 三条定义

标准给「标准没管够」的行为分了三个等级(C11 3.4):

  • 未定义行为(undefined behavior,UB):标准不提任何要求——崩溃、静默出错、看似正常、连后续代码被一起优化掉,全都合规;
  • 未指定行为(unspecified behavior):标准给出两个或以上的可能性,不作进一步要求——实现从中任取,但不许失败;
  • 实现定义行为(implementation-defined behavior):同样在有限集合中任取,但实现必须文档化自己的选择。

一句话区分:UB 没有答案;unspecified 有答案但不告诉你;implementation-defined 有答案且写在文档里。

4.2 常见项归类表

把全模块遇到过的「行为怪异」一次归档,每一项都能回链到亲手做过的实验:

行为归类已在哪学过
有符号整数溢出UB数据类型详解
解引用空指针UB内存深水区
i = i++ 等无序修改同一对象UB运算符与表达式
数组越界读写UB数组详解
读未初始化的自动变量(不确定值)UB变量与常量
修改字符串字面量UB变量与常量
经不兼容类型指针访问对象UB本篇第 3 节
移位计数超过位宽UB位运算与位域
函数实参的求值顺序unspecified运算符与表达式
malloc 出的内存初始内容unspecified(不确定值)动态内存
char 的符号性implementation-defined数据类型详解
int 的宽度范围implementation-defined数据类型详解
字节序(大小端)implementation-defined位运算与位域
负数右移的结果implementation-defined位运算与位域

一个工程推论:严格意义上的「处处可移植」只存在于玩具程序——任何非平凡 C 程序都会触及若干条实现定义与未指定项。工程说的「可移植」是「在目标平台集合上行为一致」,这正是 跨平台编程 条件编译与定宽整型存在的理由。

4.3 UB 为什么存在,编译器怎么用它

UB 不是标准委员会偷懒,而是性能与实现自由度的交易:把「这里不保证」写进标准,编译器就不必为每个角案生成检查代码,还能反过来利用「角案不存在」做推理。

编译器利用 UB 的经典实例(LLVM 开发者博客的 UB 系列是第一手出处):

  • 有符号溢出推断:既然 x + 1 不溢出,x + 1 > x 恒为真——比较被直接化简。第 1 节的实验就是它;
  • 循环边界推断:for (int i = 0; i <= n; ++i) 若 i 会溢出则循环可能无限;标准保证不溢出,于是循环恰好跑 n+1 次——多余的边界检查被消除;
  • 空指针推断:解引用之后编译器获准假设指针非空,后面的判空代码可被删除;
  • 空转循环删除(C11 6.8.5p6):一条迭代语句,若控制表达式不是常量表达式,且在循环体、控制表达式、步进中没有任何 I/O、volatile 访问、同步或原子操作,实现可以假设它终止——标准脚注说明,无限循环可以按此方式终止。while (status != 1) ; 这类无可观察行为的等待循环因此可被整体删除;for (;;) 与 while (1) 的条件是常量表达式,豁免。

空指针推断不是理论演习。2009 年 Linux 内核的 tun_chr_poll 漏洞:一个补丁把指针解引用挪到了判空之前,GCC 顺势删掉了后面的判空分支,空指针从「进程崩溃」升级为「可利用的内核提权漏洞」(LWN 的《Fun with NULL pointers》有全程复盘)。内核此后把 -fno-delete-null-pointer-checks 列入固定编译选项——一行编译选项背后是一条标准条款加一段漏洞史。

4.4 UB 防御三件套

把本节收口成三道防线(工具全景在 静态分析与 Sanitizers):

  1. 警告常开:gcc -Wall -Wextra -O2——注意 -O2 要开,UB 推理发生在优化路径上,不少警告只在优化时出现;
  2. UBSan 常跑:gcc -fsanitize=undefined -g,CI 里再加 -fno-sanitize-recover=all 让 UB 直接判失败。有符号溢出、越界移位、错类型对齐访问都能当场报行号;
  3. 评审清单常问:看到 (T *)&x 强转读写、i = i++、无 volatile 的忙等、有符号哈希累乘——停下问一句「标准对这里说了什么」。三件套挡不住的,只有人能挡。

5. 翻译单元与链接:为什么这样设计

5.1 标准语义收束

作用域、存储期与链接性 与 多文件编译 已经把「怎么用」讲透,这里收束成标准语义的一句话版:

  • 声明引入名字与类型;定义除此之外还提供实体(变量分配存储,函数提供函数体)——一个实体可以多次声明,只能有一次定义;
  • 链接性决定名字跨翻译单元的可见范围:external(全程序)、internal(本翻译单元)、none(仅本块);
  • 存储期决定生存时间:static(整个程序)、automatic(所在块)、allocated(malloc 到 free)、thread(线程一生)。

5.2 设计动机:分离编译的代价模型

C 诞生在内存以 KB 计的年代,把整个程序装进一次编译不现实,于是语言以翻译单元(一个 .c 文件经预处理后的完整结果)为独立翻译的原子——多文件编译 里「每个 .c 都是一座孤岛」说的就是它。这个设计决定解释了一串你已经见过的现象:

  • 编译器翻译一个翻译单元时看不到其他单元的内部,所以需要头文件——它不是配置文件,是单元之间的契约:声明写给本单元的编译器看,定义留给链接器对账。undefined reference 与 multiple definition 两大经典报错,正是一边契约失信、一边账本重号的现场;
  • 跨单元的优化默认做不了。inline 在 C 里的微妙语义(与 C++ 不同,详见 内联函数与宏)正是这个代价模型的产物;「我声明了它就该存在」与「它真的存在」之间隔着链接器;
  • 链接器按符号合并各单元的产物,ABI 在这一层登场:参数怎么传、符号怎么命名,函数调用栈帧 的调用约定表就是它的核心。库的打包、soname 与符号版本是同一主题的交付形态,由 动态库与静态库 承接,本篇不重复。

现代编译器的 LTO(链接时优化)本质上是给这个代价模型打补丁:把各单元的中间表示攒到链接期一起优化,缩小「分离编译」与「全程序编译」的差距。

6. freestanding 与 hosted:两种实现环境

标准里的「实现」(implementation)分两种(C11 4):

  • hosted(宿主环境):有操作系统,程序可以使用整个标准库,从 main 开始;
  • freestanding(独立环境):没有操作系统兜底。实现只须接受「不使用 complex 类型、库使用仅限标准头子集」的严格一致程序——C11 划定的子集是 9 个头:<float.h>、<iso646.h>、<limits.h>、<stdalign.h>、<stdarg.h>、<stdbool.h>、<stddef.h>、<stdint.h>、<stdnoreturn.h>;C23 又把 <stdbit.h> 等纳入。入口函数与启动、终止细节由实现自定。
c
/* hosted.c:查一查自己在哪种环境里 */
#include <stdio.h>

int main(void) {
    /* __STDC_HOSTED__ 由编译器预定义,无须额外头文件 */
    printf("hosted=%d\n", __STDC_HOSTED__);   /* 1 = hosted,0 = freestanding */
    return 0;
}
text
hosted=1     (桌面 gcc 默认输出)

这是 嵌入式 C 编程 的理论背景:MCU 上没有 OS 与完整运行库,启动代码(startup)替代 C 运行时完成清零 bss、建栈、跳 main 三件事;printf 这类依赖宿主的函数要么没有,要么是你自己移植的实现。写固件时「标准库还剩什么」,答案就是上面那个头文件清单。

交叉编译在标准语境里的位置也随之清楚:标准只定义「实现」与目标环境的关系,不关心编译器跑在哪台机器上。交叉编译器——在开发机上运行、生成目标机代码——只是实现的一种交付形态,语言语义仍按目标机的 hosted 或 freestanding 语义解释。构建系统的组织见 构建系统。

7. 标准与方言:从 K&R 到 C23 一条线

7.1 一个时代一段话

ISO C 的权威性来自一份合同:标准定义抽象机语义与库,实现按合同交付,实现文档补足 implementation-defined 项。C 语言概述 有一张演进速查表,这里抓每个时代「改变了什么」:

  • K&R(1978):没有正式标准,编译器即法律。「信任程序员」的传统从这里来,函数可以不写参数类型;
  • C89/C90(1989/1990):第一份 ISO 标准,函数原型带来参数类型检查。函数详解 里 int foo(void) 与旧式声明的分界就是这一年划下的;
  • C99(1999):面向「写大程序」的一轮扩容:<stdint.h> 定宽整型、指定初始化器、行注释、inline、变长数组(VLA,后来 C11 降级为可选特性)。你在 结构体与联合体 用过的 .x = 1 指定初始化就是 C99 特性;
  • C11(2011):正视并发与安全:<threads.h>、_Atomic 与内存模型(原子操作与内存模型 的地基)、_Generic、边界检查接口 Annex K(可选且争议很大);
  • C17(2018):纯缺陷修订,没有新特性;
  • C23(2024):关键字化与现代化:bool、static_assert、alignof 转正,nullptr、constexpr、#embed 进场。特性清单与上手由 C23 上手 与 C23 深水区 承接,本篇不重复;
  • C2y(草案中):下一站。现状与编译器支持度的查法在 C23 深水区篇有跟踪。

7.2 GNU 扩展的地位

gcc -std=c17 编译纯标准 C;不带 -std 时 gcc 实际使用 gnu17——标准加 GNU 扩展。扩展不是「方言背叛」,而是试验田:typeof 从 GNU 扩展起家,最终进入 C23。

扩展最著名的应用是 Linux 内核的 container_of 宏——从成员指针反推宿主结构体指针,侵入式链表的地基。它由两部分组成:offsetof 是标准宏(<stddef.h>,编译期算成员偏移,机制地基是 内存对齐 的布局规则),typeof 是 GNU 扩展(做类型检查)。宏本体与逐层拆解已在 预处理器与宏 完成,「什么时候必须用宏」的判断在 内联函数与宏,本篇只补一句理论定位:它站在「标准保证布局可计算」与「扩展保证类型可查」的交点上。

自己的代码想保持可移植,用 -std=c17 -pedantic(或 c23)检查纯度;确实要用扩展时,显式声明标准级别并注释理由,别让读者猜「这是标准还是私货」。

8. 常见错误与调试实录

8.1 实录一:类型双关被 -O2 改写

c
/* pun.c:把 int 的位型写进内存,再读回来 */
#include <stdio.h>

void show(int *ip, float *fp) {
    *ip = 0x40490FDB;     /* 按 int 写入 pi 的 IEEE 754 位型 */
    *fp = 2.0f;           /* 经 float* 写同一块内存:严格别名违规 */
    printf("ip reads %#x\n", *ip);
}

int main(void) {
    int x = 0;
    show(&x, (float *)&x);    /* 强转不洗白违规 */
    return 0;
}
bash
gcc -Wall -Wextra -g pun.c -o pun_o0 && ./pun_o0
gcc -Wall -Wextra -O2 pun.c -o pun_o2 && ./pun_o2

一次典型输出:

text
ip reads 0x40000000     (-O0:物理上 2.0f 的位型被 int 重读)
ip reads 0x40490fdb     (-O2:编译器推定 *fp 不影响 *ip,
                          直接把第 5 行写入的常量转发给 printf)

两个答案都「对」:前者是硬件碰巧给的,后者是标准授权的推定。-Wall 在 -O2 下对这种一眼能看穿的强转常能给出 -Wstrict-aliasing 提示,但更隐蔽的写法(隔着函数边界传指针)它会沉默——警告只能当烟感器,不能当灭火器。修法就是第 3.3 节的两座桥:

c
/* 修法一:union(写 A 读 B 的标准姿势,结果可能是陷阱表示) */
union { int i; float f; } u;
u.f = 2.0f;
printf("ip reads %#x\n", u.i);

/* 修法二:memcpy(最通用,编译器优化成一条 mov) */
#include <string.h>
float f = 2.0f;
int bits;
memcpy(&bits, &f, sizeof bits);
printf("ip reads %#x\n", bits);

验收标准:-O0 与 -O2 输出一致,且与 2.0f 的 IEEE 754 位型对得上。

8.2 实录二:依赖 UB 的代码,换挡就坏

事故复盘(典型形态,C 项目里年年重演):一个跑了五年的配置加载模块,字符串哈希这样写:

c
/* hash.c:出事版本的哈希 */
int hash(const char *s) {
    int h = 0;
    while (*s) {
        h = h * 31 + *s++;    /* 长键:h 溢出(有符号溢出是 UB) */
    }
    return h;
}

症状:-O0 与历史发布版生成的哈希值一致,预建的索引文件能读;CI 切到 -O2(或换了编译器)后哈希值变了,老数据全部「损坏」。排障三步:

  1. 对比复现:同一输入在 -O0 与 -O2 下各打一次哈希,确认差异稳定复现——先排除数据本身的问题;
  2. UBSan 定位:gcc -fsanitize=undefined -g hash.c && ./hash,报告直接点名 runtime error: signed integer overflow 与行号;
  3. 按标准修:哈希要的就是回绕,就写成标准保证回绕的形式——累加器换成 unsigned int(无符号溢出按 2^N 取模,是良定义,数据类型详解 的溢出分岔表),或用 C23 的 ckd_add 显式检查。

复盘要点:这段代码从来没有「对」过——它在每个曾经能跑的编译器上都只是运气好。依赖 UB 的正确性,只在「恰好没人利用 UB」的窗口里成立;窗口一关(新优化器、新标志、新架构),债立刻到期。

9. 实际项目中的使用场景

  • 读懂工具链的编译选项:看到 -fno-strict-aliasing、-fno-delete-null-pointer-checks、-fwrapv 不再是黑话——每一项都对应本篇的一条标准条款与一段事故史;
  • 可移植库的 CI 矩阵:-std=c17 -pedantic 查标准纯度,ASan/UBSan 查未定义行为,多编译器(gcc/clang/msvc)把 unspecified 与 implementation-defined 项的真实差异跑成测试用例,工程组织见 跨平台编程;
  • 评审与接手遗留代码:第 4.2 节的归类表就是评审清单的底稿;接到「-O0 能跑 -O2 不能」的工单,先查 UB 再怀疑编译器;
  • 读标准与草案:cppreference 查语义速查,WG14 的 N 编号文档读原文;遇到争议条目(比如 6.5p7 的联合条款)能顺着脚注找到缺陷报告原文。

10. 小练习

预测题(5 分钟):i 是非 volatile 的全局 int,某处(比如中断服务程序)会把它改成 1。下面两个等待循环在 -O2 下的命运一样吗?

c
while (i != 1) { }    /* A */
for (;;) { }          /* B */

参考答案(先写再看):不一样。A 的控制表达式不是常量,循环体无 I/O、无 volatile、无原子操作,按 C11 6.8.5p6 实现可以假设它终止,从而删除或改写它;B 的条件是常量表达式,豁免,保证无限循环。等待硬件标志必须用 volatile(volatile 深水区),不能指望空转。

修改题(15 分钟):把 pun.c 的类型双关分别用 union 与 memcpy 修复。验收:两种修法在 -O0 与 -O2 下输出一致,都打印 0x40000000;-Wstrict-aliasing 不再触发。

修 Bug 题(15 分钟):把 8.2 节的 hash.c 改成无符号累加。验收:UBSan 下无报告;同一输入在 -O0 与 -O2 下哈希值一致。提示:unsigned h 与 h = h * 31u + (unsigned char)*s++;——*s 先按 char 取值,若 char 有符号会先做符号扩展,转 unsigned char 才是按字节哈希。

挑战题(半小时,不看答案先动手):给 4.2 节归类表补一列「标准出处」,并为至少 6 项在 cppreference 找到对应页面。提示两级如下。

提示(思路方向):UBSan 报告的英文短句往往直接引用标准措辞,比如 signed integer overflow 对应 6.5p5 的「结果不能以其类型表示」;从报错反查比从目录正查快。

展开(可查关键词):undefined behavior(cppreference 有专门的 UB 清单页)、unspecified behavior、implementation-defined behavior、effective type、strict aliasing、forward progress。验收:每项都能给出「条款号 + 页面链接 + 已学篇章」三件套。

11. 与之前和之后的知识的关系

12. 官方文档

13. 自我检查

  • 能画出「源码 → 抽象机语义 → as-if → 可观察行为」的授权链,并指出 volatile 在哪一环截断它;
  • 能默写 C11 6.5p7 的五类允许访问,判断一段类型双关代码是否违规,并给出两种修法;
  • 能把「字节序」「参数求值顺序」「i = i++」「负数右移」逐项归入三分法,并说出标准依据与已学篇章;
  • 能解释 C11 6.8.5p6 对空转循环的后果,以及 while(1) 为什么豁免;
  • 能说清头文件作为「契约」与分离编译代价模型的关系,以及 LTO 在补什么课;
  • 能对着 -std= 开关与 STDC_HOSTED 说出当前代码落在哪个标准、哪种环境。

本章总结

C 程序的语义定义在抽象机上,实现唯一的义务是保住三件可观察行为:volatile 访问、落盘数据、交互式 I/O——as-if 规则授权了其余一切变换。对象是「字节区加类型解释」,malloc 的内存由写入定有效类型,访问只能走 C11 6.5p7 的五类左值,违规即 UB;memcpy 与 union 是两条合法通道,-fno-strict-aliasing 是历史包袱管理而非新代码的姿势。标准把管不住的行为分三档:UB 无任何要求,unspecified 任取但不许失败,implementation-defined 必须文档化。UB 是性能与实现自由度的交易,编译器据此化简溢出比较、删除判空、抹掉空转循环(C11 6.8.5p6),内核为此付出过提权漏洞的代价;防御靠警告常开、UBSan 常跑、评审常问三件套。翻译单元的独立翻译解释了头文件契约、inline 的微妙与 LTO 的存在;freestanding 用九个头文件划出嵌入式 C 的疆界;从 K&R 到 C23 的每个时代都在回答一个新问题。理论收束于此:看到反直觉行为,先问标准怎么说,再问实现怎么选。

下一步

进入 C 语言学习总结:理论的地基打完,把整个 C 模块的知识体系串成一张网,用虚拟歌手音乐平台的例子做最后一次全链路演练。