分类 linux-C++ 下的文章

C 语言内存安全问题的排查与思考

摘要:内存安全问题是 C/C++ 工程实践中最难定位的一类缺陷。与 Java、Go 这类带运行时检查的语言不同,C 语言在编译期不做数组边界检查,对未定义行为也不提供任何保证。一段存在越界写入、悬空指针或资源泄漏的代码,往往可以编译通过、通过功能测试,甚至在长期运行中表现稳定,然后在某个不确定的时刻以难以复现的方式失效。本文从一段命令行通讯录程序出发,梳理了它在文件读写、序列化存取、资源管理三个方面暴露出的 11 类缺陷,也一并讨论了链表宏实现本身带来的使用约束,逐一给出标准依据与实测证据,并进一步讨论了 AddressSanitizer 这类动态检测工具的能力边界与使用注意事项。

本文分为四个部分:第一部分介绍背景与基本概念;第二部分逐个剖析四类问题;第三部分讨论消毒剂工具的检测边界与一套可复用的排查方法;第四部分进行总结并列出实践中的常见误区。

说明:本文案例取自一个完整的命令行通讯录程序,出于篇幅考虑,文中只保留与主题相关的代码片段。文中所有结论均在 Linux 6.8 / gcc 11.4.0 / x86_64 环境下实测验证,输出原样引用。

1 背景

1.1 问题的来源

这份通讯录程序的功能很朴素:用双向链表保存联系人,支持增删查,并能把链表写进文本文件、再从文件读回。它通过了所有的功能性自测——每条记录都能正确插入、查找、删除,保存文件里确实有内容,读取之后链表也确实不为空。

但它同时存在 11 类缺陷,其中若干条会让功能直接失效。这些缺陷有一个共同点:编译器不报错(只有一处例外),sanitizer 也不一定报,而常规的功能测试完全覆盖不到。

促使我系统梳理这份代码的,是一次存取往返测试中发现的现象:把一条记录保存到文件、再读回来,电话号码的前面会多出一个 phone: 前缀;反复存取,前缀会不断累积。这个过程中没有任何越界、没有任何崩溃、没有触发任何 sanitizer——但数据已经错了。

由此引出一个值得展开的问题:在 C 语言里,"能跑"和"正确"之间的距离究竟在哪里?

1.2 为什么要专门讨论内存安全

C 语言把内存管理的责任完全交给了程序员。这既是它能在操作系统内核、嵌入式固件、高性能中间件等领域长期占据主导的原因,也是这些领域缺陷密度偏高的原因。具体来说,C 语言在以下三个方面不设保护:

  • 不做边界检查。 数组下标、指针运算、字符串写入,编译器都不验证是否落在对象范围内。
  • 不追踪对象生命周期。 对象被释放之后,指向它的指针不会失效,继续使用是未定义行为。
  • 不定义越界与未定义行为的后果。 C11 只声明这类行为"未定义",具体表现取决于编译器、优化级别、运行时内存布局,可能是静默的数据损坏,可能是崩溃,也可能看起来一切正常。

这三个特征叠加起来,使得内存安全缺陷的现场往往远离成因。一个被字符串覆写坏掉的链表指针,可能在几十次操作之后才被解引用,此时栈上早已没有留下任何有用的线索。

1.3 本文的组织

本文按缺陷的分布层次组织,从 I/O 边界,到持久化约定,再到资源管理,最后讨论检测工具的能力边界:

章节讨论范围缺陷数
第 3 章双向链表的宏实现(机制与使用约束)——
第 4 章文件读写循环与缓冲区边界5
第 5 章序列化存取不对称2
第 6 章返回值与内存所有权4

第 3 章讨论的是链表实现方式本身带来的使用约束(属于设计层面的注意事项,不计入缺陷清单),第 7 章讨论验证工具的能力边界与排查方法论,不单独对应缺陷。

2 基本概念

在展开具体问题之前,先统一定义本文反复使用的几个概念。

2.1 内存安全(Memory Safety)

内存安全指程序在运行期间只访问属于它的、并且仍然有效的内存区域。与之相对的是内存安全缺陷,常见形态包括:

  • 缓冲区溢出(buffer overflow):写入超出目标对象容量。
  • 释放后使用(use-after-free):对象释放之后继续通过指针访问。
  • 重复释放(double free):同一块内存被释放两次。
  • 内存泄漏(memory leak):对象失去所有引用但未被释放。

C11 未对上述行为定义语义,它们统一落入"未定义行为(undefined behavior)"的范畴。C11 3.4.3 对未定义行为的定义是:使用了一个不可移植或错误的程序构造,或错误的数据时,标准不施加任何要求。Annex J.2 中列举了大量属于未定义行为的情形,越界访问与解引用无效指针均在列。

2.2 数组容量与字符串长度

这是后面多个缺陷的根源,需要严格区分:

  • 数组容量是编译期确定的字节数量。char name[16] 的容量恒为 16 字节,不随内容变化。
  • 字符串长度是运行期由第一个空字符('\0')的位置决定的量。

C 语言的字符串处理函数(printf("%s")、strlen、strcpy)只信任终止空字符,不信任数组长度。这意味着写入方必须自行保证:在容量范围内为终止空字符留出一个字节。也就是——

数组的最大有效字符串长度 = 容量 − 1

NAME_LENGTH 定义为 16,可实际能容纳的姓名上限是 15 个字符。这个"减一"如果被遗忘,后果不是一个字符的截断,而是字符串失去终点:读取方会越过数组边界一直读下去。

2.3 对象边界与字段边界

这个区分在讨论检测工具时至关重要。

  • 对象指一次分配产生的内存块。栈上的一个局部变量、堆上的一次 malloc、一个结构体实例,都是独立的对象。
  • 字段指结构体内部的成员。

对下面这个结构体:

struct person {
    char name[16];      /* 偏移 0  */
    char phone[32];     /* 偏移 16 */
    struct person *next; /* 偏移 48 */
    struct person *prev; /* 偏移 56 */
};

sizeof(struct person) 为 64 字节(实测确认)。从 name 的第 17 个字节开始写入,在字段层面是越界——它踩坏了 phone;但在对象层面它仍在 64 字节范围之内。第 7 章会看到,这个差别直接决定了 AddressSanitizer 能否发现该缺陷。

2.4 所有权(Ownership)

所有权指"谁负责释放一块内存"这一约定。本文案例中涉及的所有权关系是:

对象分配点释放点(应有)
struct contactsmainmain 退出前
struct personinsert_entry / load_file链表所有者(struct contacts)统一释放
FILE *fopen同一函数内的 fclose

所有权一旦不明确,就会同时导致泄漏和重复释放。第 6.2 节会给出这两种情况的实测结果。

3 链表实现:宏带来的收益与代价

3.1 双向链表与头插法

链表的插入与删除被实现为两个宏:

#define LIST_INSERT(item, list) do {                        \
    (item)->prev = NULL;                                    \
    (item)->next = (list);                                  \
    if ((list) != NULL) (list)->prev = (item);              \
    (list) = (item);                                        \
} while(0)

#define LIST_REMOVE(item, list) do {                        \
    if (item->prev != NULL) item->prev->next = item->next;  \
    if (item->next != NULL) item->next->prev = item->prev;  \
    if (list == item) list = item->next;                    \
    item->prev = item->next = NULL;                         \
} while(0)

选择双向链表而非单链表,动机在删除操作上。删除一个已知节点时,单链表需要从头遍历找到它的前驱才能修改前驱的 next,时间复杂度 O(n);双向链表通过 item->prev 直接拿到前驱,可以做到 O(1)。通讯录这类以删除为高频操作的数据结构,这个差异是实质性的。

LIST_INSERT 采用的是头插法:(list) = (item); 让新节点成为链表头。头插法不需要维护尾指针,插入代价恒为 O(1)。但它的可见后果是——遍历输出顺序与插入顺序相反。实测印证:

head->id = 2, head->next->id = 1(头插法,顺序反过来)

LIST_REMOVE 完整覆盖了四种情形:被删节点位于表头(prev == NULL,跳过第一句,链表头改为 item->next)、位于表中(前后指针都需修改)、位于表尾(next == NULL,跳过第二句)、以及链表仅剩一个节点(两个条件均不成立,链表头置为 NULL)。

值得单独指出的是最后一句 item->prev = item->next = NULL;。它保证一旦发生"删除后再次访问"的错误,访问到的是空指针,而不是一个已经脱离链表、却仍指向原邻居的节点。后者会导致链表结构被意外重新连上,问题更难定位。不过需要明确:该宏只做指针摘除,不释放内存,free 由上层负责,这样职责划分才清晰。

3.2 为什么用宏,以及 do { } while(0) 的用途

用宏而不是函数的理由:函数参数是强类型的。要用函数实现通用的链表插入,只有两条路——为每种节点类型各写一个专用函数,或把参数退化为 void * 并额外传递成员偏移量。宏在预处理阶段做纯文本替换,不参与类型检查,因此同一套逻辑可以同时服务于 struct person、struct task、struct worker 等任意节点类型。这是用宏换取泛型能力。

do { } while(0) 的用途:这两个宏都是多语句宏。如果直接写成 { ... },下面的代码会出错:

if (cond)
    LIST_INSERT(p, head);   /* 展开为大括号块 */
else
    do_something();         /* 编译错误 */

展开后是 if (cond) { ... }; else ...,多出的分号提前结束了 if 语句,else 失去配对的 if。用 do { ... } while(0) 包裹,整体加上末尾分号后恰好构成一条完整语句,在任何上下文都不会被拆散。编译器在优化阶段会消除 while(0) 判断,不产生运行时开销。这是 C 语言中多语句宏的标准写法。

3.3 宏的固有代价:参数重复求值

宏参数在展开时是纯文本替换,参数名在宏体中出现几次,传入的表达式就会被求值几次。统计 LIST_INSERT 中 (list) 的出现位置:

  1. (item)->next = (list);
  2. if ((list) != NULL)
  3. (list)->prev = (item);
  4. (list) = (item);

共四处。若实参是普通变量名,读取四次不产生副作用;若实参是带副作用的表达式,问题立即显现。构造一个返回链表头地址并同时计数的函数,可以量化这一现象:

static int eval_count = 0;

static struct node **head_slot(void) {
    eval_count++;
    return &head;
}

LIST_INSERT(&a, *head_slot());

实测输出:

第 1 次插入:实参被求值 3 次
第 2 次插入:实参被求值 4 次

同一行调用,求值次数随运行状态变化。 原因在于第 2 和第 3 处位于同一个 if 内:链表为空时 (list) != NULL 为假,短路求值使第 3 处被跳过,因此是 3 次;链表非空时两处都要求值,变为 4 次。

这一实验的结论不是"宏不可用",而是一条使用约束:

宏的实参不应包含带副作用的表达式——自增、自减、函数调用、赋值都属于此类。

若必须在宏中保证单次求值,常见做法有二:改用 static inline 函数;或在 GNU C 中使用语句表达式 ({ ... }) 先将实参存入临时变量。

3.4 选型建议

方案泛型能力参数求值次数类型检查调试友好度
宏强(不区分类型)多次(取决于宏体)无差(调试器看不到宏内变量)
static inline 函数弱(需固定类型)一次有好
void * + 偏移量中一次弱一般
C11 _Generic中一次有一般

实践中的取舍是:若节点类型固定、且宏体较复杂,优先用 static inline 函数;只有在需要跨多种节点类型复用、且实参确无副作用时,才使用宏。

4 文件读写:循环条件与缓冲区边界

4.1 feof 的语义

读文件时最经典的一种写法:

while (!feof(fp)) {
    char buffer[BUFFER_LENGTH] = {0};
    fgets(buffer, BUFFER_LENGTH, fp);
    /* 处理 buffer */
}

C11 7.21.10.2 对 feof 的规定是:当且仅当流的文件结束指示器(end-of-file indicator)被设置时,返回非零值。而该指示器由一次失败的读取操作来设置。

因此 feof 是一个事后标志:它回答的是"上一次读取是否撞到了文件末尾",而不是"下一次读取是否会读到内容"。把它放在循环条件里,等价于用后视镜判断前方路况。

4.2 实测:2 行的文件跑了 3 轮

构造一个只有 2 行的数据文件,观察循环行为:

数据文件共 2 行:
    name: aa, phone: 111
    name: bb, phone: 222

第 1 轮: fgets 返回 非NULL strlen(buffer) = 21  buffer = "name: aa, phone: 111
"
第 2 轮: fgets 返回 非NULL strlen(buffer) = 21  buffer = "name: bb, phone: 222
"
第 3 轮: fgets 返回 NULL   strlen(buffer) = 0  buffer = ""

实际执行 3 轮,文件只有 2 行 —— 最后一轮读的是空内容。

时序是:进入第 3 轮 → fgets 尝试读取、失败、返回 NULL、同时设置文件结束指示器 → buffer 保持初始化时的全零状态 → 处理逻辑拿到一个空缓冲区。

4.3 为什么这个缺陷在测试中隐身

如果"多跑一轮"只是多处理一次空数据,它算不上严重问题。它真正值得警惕的地方在于会与另一个缺陷互相掩盖。

本例的解析函数开头有这样一句:

if (length < MIN_TOKEN_LENGTH) return -2;   /* MIN_TOKEN_LENGTH = 5 */

第 3 轮的 buffer 全零,strlen 得到 0,小于 5,函数返回 -2,调用方 continue 跳过。空轮被这道长度检查挡住了。数据文件中的空行(长度为 1)同样是被这一句跳过的。

于是两个缺陷叠加的结果是:程序看起来完全正常。但只要调整或删除这句长度检查,隐藏的空轮问题立刻现形。

这给出一条工程经验:不能仅凭"当前测试通过"判断某段代码可以删除。 一个防御性检查可能正在掩盖另一个缺陷,删除它之前需要先确认它拦截的到底是哪些输入。

4.4 正确写法

把读取本身作为循环条件,让循环次数由成功读取的次数决定:

while (fgets(buffer, BUFFER_LENGTH, fp) != NULL) {
    /* 处理 buffer */
}

fgets 的返回值既是控制条件,也是有效性证明,循环体只在真正读到内容时执行。这是一个通用模式,读取二进制数据时同理:

size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
    /* 处理 n 字节 */
}

4.5 无界读入:scanf("%s")

struct person *p = (struct person*)malloc(sizeof(struct person));
memset(p, 0, sizeof(struct person));

scanf("%s", p->name);       /* name 只有 16 字节 */

C11 7.21.6.2 对 %s 转换说明符的定义是:匹配一段非空白字符的最长序列,将其加上终止空字符写入对应参数指向的数组;标准同时要求"该数组必须足以容纳这段序列和终止空字符"。

也就是说,容量是否足够由调用方负责,scanf 不做任何检查。不写宽度限制,%s 会一直读到空白字符为止。

输入 100 个字符时的实测结果:

==ERROR: AddressSanitizer: heap-buffer-overflow
0x506000000060 is located 0 bytes to the right of 64-byte region [0x506000000020,0x506000000060)
    #1 ... in insert_entry contacts.c:186
SUMMARY: AddressSanitizer: heap-buffer-overflow ... in scanf_common

被破坏的是 insert_entry 中 malloc 得到的 64 字节对象,写入位置正好在该对象右侧边界外 0 字节处。

修法是限定宽度,让 scanf 最多读取 容量 − 1 个字符:

scanf("%15s", p->name);     /* 16 字节数组,最多写 15 个字符 */

需要注意 %15s 中的 15 是字符数而非字节数,处理多字节编码时不能直接套用数组长度。更稳妥的做法是先读入一个足够大的缓冲区再截断:

char buf[256];
if (fgets(buf, sizeof(buf), stdin) != NULL) {
    buf[strcspn(buf, "\n")] = '\0';          /* 去掉换行符 */
    strncpy(p->name, buf, sizeof(p->name) - 1);
    p->name[sizeof(p->name) - 1] = '\0';     /* 兜底:确保终止 */
}

至少 fgets 的容量参数是显式写出的,不会遗漏。

4.6 手写解析器的两处边界错误

解析一行 name: liaoqf,telephone: 23456543789 的函数如下:

int parser_token(char *buffer, int length, char *name, char *phone) {
    if (buffer == NULL) return -1;
    if (length < MIN_TOKEN_LENGTH) return -2;

    int i = 0, j = 0, status = 0;
    for (i = 0; buffer[i] != ','; i++) {          /* 缺陷 1 */
        if (buffer[i] == ' ') {
            status = 1;
        } else if (status == 1) {
            name[j++] = buffer[i];                /* 缺陷 2 */
        }
    }

    status = 0;
    j = 0;
    for (; i < length; i++) {
        if (buffer[i] == ' ') {
            status = 1;
        } else if (status == 1) {
            phone[j++] = buffer[i];
        }
    }
    return 0;
}

缺陷 1:循环终止条件只认逗号。 for (i = 0; buffer[i] != ','; i++) 只检查字符是否为逗号。如果该行不含逗号,'\0' 不等于 ',',循环不会在字符串末尾停止,而是继续向后读取。

单独复现这一条需要一点技巧——构造一行既无逗号也无空格的数据,使写入路径保持安静,从而单独暴露读取问题:

==ERROR: AddressSanitizer: stack-buffer-overflow
READ of size 1
    #0 ... in parser_token contacts.c:119
    #1 ... in load_file contacts.c:160
    [128, 256) 'buffer' (line 151) <== Memory access at offset 256 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow contacts.c:119 in parser_token

buffer 是 load_file 中的 char buffer[128]。报告显示访问发生在偏移 256 处,即越过整个 128 字节数组后又前进了 128 字节。

修法是把长度纳入条件:

for (i = 0; i < length && buffer[i] != ','; i++)

length 由调用方通过 strlen(buffer) 计算并传入,本身是正确的,问题只在于循环没有使用它。

缺陷 2:写入时不比较目标容量。 name[j++] = buffer[i]; 中 j 只增不减,也从未与 NAME_LENGTH 比较。调用方传入的缓冲区:

char name[NAME_LENGTH] = {0};    /* 16 字节 */
char phone[PHONE_LENGTH] = {0};  /* 32 字节 */

只要文件中某一行的姓名字段超过 15 字节,就会写穿这 16 字节。实测:

==ERROR: AddressSanitizer: stack-buffer-overflow
    #0 ... in parser_token contacts.c:123
    #1 ... in load_file contacts.c:160
    [32, 48) 'name' (line 157) <== Memory access at offset 48 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow contacts.c:123 in parser_token

这里有两点值得注意。

其一,两处缺陷存在先后关系。 写越界(第 123 行)在多数输入下先触发,把读越界(第 119 行)挡住。这解释了为什么 ASan 报告的往往不是第一个缺陷,而是执行顺序上最先到达的那一个——修完一处必须重跑,才能看到后面藏着的另一处。

其二,length 参数在这里帮不上忙。 它约束的是源缓冲区的长度,而缺陷在于目标缓冲区的容量,两者是独立的量,必须分别检查:

if (status == 1) {
    if (j >= NAME_LENGTH - 1) return -3;   /* 留一个字节给终止空字符 */
    name[j++] = buffer[i];
}

4.7 终止符缺失引发的连锁后果

越界写入最直观的后果是"覆盖了相邻变量",但实际影响往往更隐蔽。将 40 个 A 从标准输入喂入 insert_entry(p->name 仅 16 字节),ASan 未报告任何错误,而程序打印出:

name: AAAAAAAAAAAAAAAA13900001111,phone: 13900001111

对照正常输入(alice):

name: alice,phone: 13900001111

分析其成因。在 64 字节的结构体中,偏移 0–15 属于 name,16–47 属于 phone。scanf 从 name 起始处写入 40 个 A,随后写入终止空字符,于是:

  1. name 的 16 字节被完全填满,终止空字符落在 name 之外,进入 phone 所在区域;
  2. 接下来 scanf("%s", p->phone) 又把电话号码写入 phone,覆盖了那个终止符;
  3. 结果 name 数组中不含任何 0 字节。

由于 C 语言的字符串以终止空字符界定,printf("%s", item->name) 会从 name[0] 逐字节输出,直到遇见 0 字节为止——而 0 字节此时已经在 phone 内。因此它先输出 16 个 A,随后越过数组边界继续输出,把紧邻的 phone 内容一并印出。

这个案例把 2.2 节的结论具体化了:数组容量与字符串长度是两个不同的量,写入方必须保证容量范围内留有一个字节给终止空字符。 忘记"减一"的后果不是截断一个字符,而是让字符串失去终点。

4.8 缓冲区操作的检查清单

综合以上各点,凡涉及缓冲区写入的地方,建议逐项确认:

  • [ ] 目标缓冲区容量是否已知(sizeof(array) 或显式传入)?
  • [ ] 写入长度是否在写入前与容量比较,而不是写入之后?
  • [ ] 是否为终止空字符预留了位置(最大长度 = 容量 − 1)?
  • [ ] 源数据的长度与目标缓冲区的容量是否分别检查?
  • [ ] scanf("%s")、strcpy、sprintf、gets 这类无界接口是否已替换?
  • [ ] 循环中是否存在依赖输入内容的终止条件(如"直到遇见某字符")?是否已加上长度上界?

5 持久化:存取不对称

5.1 格式约定的隐性依赖

保存函数写出的是这样的格式:

fprintf(fp, "name: %s, phone: %s\n", item->name, item->phone);

注意逗号后面带有一个空格。

而解析函数的规则是:以逗号切分两段,然后在每一段中取第一个空格之后的全部内容作为值。

这条规则本身不难理解,问题在于它对格式极为敏感——"第一个空格"出现的位置,取决于逗号后面有没有空格。对仓库中三份数据文件的实测结果:

文件行格式实测解析结果
a.txtname: liaoqf,telephone: 23456543789liaoqf --> 23456543789(正确)
b.txtname: qiuxiang,telephone: 98765678123qiuxiang --> 98765678123(正确)
c.txtname: king, phone: 344567754king --> phone:344567754(错误)

a.txt / b.txt 中逗号紧跟字段名,第一个空格出现在字段名冒号之后,取到的值是纯数字;c.txt 中逗号后面先有一个空格,这个空格被当作字段名与值的分隔符,于是字段名 phone: 连同冒号一起被保留在值里。

差别只在一个空格,解析结果却完全不同——这说明格式约定存在隐性依赖,而这类依赖通常不会被写进文档。

5.2 实测:一次往返就损坏数据

把上面的格式问题与保存格式对应起来,结果就不只是"某份测试数据格式不对"了。保存时写出的是逗号加空格——恰恰是解析会出错的那一种。实测一次完整的存取往返:

保存进 rt.txt 的内容:
name: alice, phone: 13900001111

读回时解析结果:
file token : alice --> phone:13900001111

遍历输出:
name: alice,phone: phone:13900001111       ← 从文件读回的记录
name: alice,phone: 13900001111             ← 内存中原有的记录

每执行一次"保存再读取",电话号码前面就会多一个 phone: 前缀。 反复存取会不断累积。这个缺陷不会触发任何 sanitizer,因为从内存安全的角度看,代码一行越界都没有。

同时还可以看到两个相关问题:

  • 加载是追加语义。 load_file 将读到的节点逐个头插进链表,调用方已有的节点一个都不动。因此输出中 alice 出现两次——一次是内存中原有的,一次是从文件读入的。cts->count 也随之累加,对同一文件加载两次,记录数会翻倍。
  • 顺序被反转两次。 save_file 按链表顺序逐行写出,load_file 用头插法读回,两个方向相反,因此"保存 → 读取"之后链表顺序与原状态相反。

5.3 往返测试:拦截这类缺陷的唯一手段

这类缺陷不越界、不崩溃、不产生编译警告,动态检测工具完全无力。能守住它的只有"往返测试"——把序列化与反序列化作为一对操作来验证:

构造数据 → 序列化 → 清空内存 → 反序列化 → 逐字段比较

实现成本极低,却能覆盖一整类缺陷。需要注意的是,仅验证"保存后文件非空"和"读取后链表非空"是远远不够的:

/* 不足够的断言 */
assert(file_size > 0);
assert(list_is_empty() == false);

/* 有效的断言 */
save(list, path);
clear(list);
load(&list, path);
assert(list_count() == original_count);
for (i = 0; i < original_count; i++)
    assert(strcmp(list[i].name, original[i].name) == 0 &&
           strcmp(list[i].phone, original[i].phone) == 0);

只有比较内容本身,才能发现"值里多了个字段名前缀"这类错误。

6 资源管理:返回值与所有权

6.1 隐式返回值

int save_file(struct person *people, const char *filename) {
    FILE *fp = fopen(filename, "w");
    if (fp == NULL) return -1;
    /* ... */
    fclose(fp);
}                                    /* 函数结束,但没有 return 语句 */

本例中 save_file、print_entry、save_entry、load_entry 四个函数都是这种形态:声明返回 int,正常路径上却没有任何 return。

C11 6.9.1p12 规定:若执行到终结函数的 },且调用方使用了该次调用的返回值,则行为未定义。注意构成未定义行为需要同时满足两个条件——"到达函数末尾"与"调用方使用返回值"。当前 main 中:

case OPER_SAVE:
    save_entry(cts);        /* 返回值被丢弃 */
    break;

返回值确实未被使用,因此按标准字面,当前代码不构成未定义行为。但这是一个脆弱的巧合:只要有人将调用改为 if (save_entry(cts) != 0) { ... },未定义行为立即成立,而届时编译器未必还会警告——尤其是当该函数被编译在另一个翻译单元、-Wreturn-type 看不到函数体时。

好消息是,GCC 在 -Wall 下会直接指出(-Wreturn-type 属于 -Wall):

contacts.c: In function 'save_file':
contacts.c:111:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'print_entry':
contacts.c:218:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'save_entry':
contacts.c:274:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'load_entry':
contacts.c:284:1: warning: control reaches end of non-void function [-Wreturn-type]

四个函数的位置被逐行列出。也就是说这类问题在零成本的条件下就能被发现。之所以长期存在,只是因为警告没有被当作需要处理的问题。

由此得到一条实践建议:编译带上 -Wall -Wextra,并要求零警告。 警告数量一旦被允许存在,人就会习惯性地忽略它们,而忽略的代价是放弃了一层免费的静态检查。若希望更严格,可叠加 -Werror 将警告升级为错误,或使用 -fanalyzer(GCC 10 起提供)做过程间分析。

6.2 内存泄漏实测

main 退出前的清理只有一句:

exit:
    free(cts);
    return 0;

cts 是 malloc 得到的 struct contacts,释放它是正确的。但 cts->people 指向的整条链表——每个节点都由 insert_entry 单独 malloc——一个都没有被释放。用 LeakSanitizer 运行完整流程(插入一条记录后退出):

==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 64 byte(s) in 1 object(s) allocated from:
    #0 ... in __interceptor_malloc
    #1 ... in insert_entry contacts.c:186
    #2 ... in main contacts.c:317

SUMMARY: AddressSanitizer: 64 byte(s) leaked in 1 allocation(s).

泄漏 64 字节,正是 sizeof(struct person);分配点在 insert_entry 第 186 行。插入 N 条记录即泄漏 64 × N 字节。

6.3 释放顺序

正确的清理函数如下:

void contacts_destroy(struct contacts *cts) {
    struct person *item = cts->people;
    while (item != NULL) {
        struct person *next = item->next;   /* 先保存后继,再释放当前节点 */
        free(item);
        item = next;
    }
    cts->people = NULL;
    cts->count = 0;
}

next 必须在 free(item) 之前取出。 若写成:

free(item);
item = item->next;      /* 错误:释放后使用 */

就是典型的释放后使用(use-after-free)。free 之后那块内存的内容可能已被分配器改写,再读 item->next 得到的可能是任意地址。使用 -fsanitize=address 时,这类访问会以 heap-use-after-free 的形式报出,报告中会注明该区域"已被释放"。

此外,load_file 中还有一处错误路径泄漏:

struct person *p = (struct person*)malloc(sizeof(struct person));
if (p == NULL) return -2;      /* 直接返回,fp 没有 fclose */

分配失败时函数提前返回,fopen 得到的文件句柄永不关闭。修法是让失败路径统一在 fclose(fp) 之后返回,即每个函数只保留一个出口;或者用 goto cleanup 集中处理。

这一点容易被忽略,因为"分配失败"在测试中几乎不会出现——64 字节的分配失败通常意味着进程早已无法继续。但在内存受限的嵌入式环境中,这恰恰是最需要正确处理的分支。

7 消毒剂的能力边界

7.1 影子内存与红区机制

前文多处越界之所以能被发现,依靠的是 AddressSanitizer。其原理是:为每个内存对象两侧添加红区(redzone),并用一段影子内存(shadow memory)记录每个字节是否可访问;编译阶段在每次内存访问前插入检查,命中不可访问区域即报告并打印调用栈。

影子内存的粒度可以从 ASan 自身的输出中读出:

Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack right redzone:     f3
  Intra object redzone:    bb

一个影子字节对应 8 个应用字节。红区的分类中,f1 / f3 是理解检测边界的关键:红区加在栈变量之间,而不是加在结构体字段之间。

7.2 实测:同一个越界,两种结果

下面这段对比程序,在 struct person 的 name[16] 上做越界写入,分别放在结构体内部和独立局部数组中:

__attribute__((noinline))
static void write_into_struct(const char *src, int n) {
    struct person p;
    memset(&p, 0, sizeof(p));
    for (int i = 0; i < n; i++) p.name[i] = src[i];   /* A:溢出落在结构体内部 */
    printf("  [A] phone 字段读到: %.32s\n", p.phone);
}

__attribute__((noinline))
static void write_into_local(const char *src, int n) {
    char name[16];
    memset(name, 0, sizeof(name));
    for (int i = 0; i < n; i++) name[i] = src[i];     /* B:溢出踩到相邻栈对象 */
    printf("  [B] name 前 16 字节: %.16s\n", name);
}

以 -fsanitize=address 编译,改变写入长度,实测结果如下:

写入长度情形 A(写在 struct person 内部,对象共 64 字节)情形 B(写在独立的 char name[16] 之外)
17未报告,phone 被污染 1 字节报告 stack-buffer-overflow
32未报告报告
64未报告(刚好写满整个结构体)报告
80报告 stack-buffer-overflow报告

情形 A 在写入 32 字节时的完整输出:

情形 A:越界写在 struct person 内部
  [A] 写入 32 字节后,phone 字段读到: XXXXXXXXXXXXXXXX

16 个 X 恰好对应从 name 溢出的 16 个字节——数据已经写坏,ASan 却未报告。原因在于,从 ASan 的视角看,p 是一个 64 字节的对象,偏移 0 至 63 全部可访问;至于程序员认为 name 只应占前 16 字节,影子内存无从得知。只有当写入越过整个 64 字节对象时(情形 A 的 80 字节),红区才会被踩到。

7.3 这一边界对实际代码的影响

回到 4.5 节的代码:

struct person *p = (struct person*)malloc(sizeof(struct person));  /* 64 字节 */
scanf("%s", p->name);       /* 写入 17~64 字节之间:静默损坏 phone / next / prev */

输入长度在 17 到 64 之间时,越界发生在对象内部,ASan 不报告;只有超过 64 字节才会报 heap-buffer-overflow。而 next / prev 两个指针一旦被字符串覆写,链表结构会被破坏为任意值,之后任何一次遍历都是解引用野指针。这类缺陷的现场往往远离成因,定位难度极高。

这正是 ASan 最需要覆盖、却恰恰覆盖不到的区域。

能否让 ASan 也检测对象内部越界?可以,Clang 提供 -fsanitize-address-field-padding=<n>,在字段之间插入填充与红区。但这是 Clang 独有的选项:本文验证环境仅有 gcc 11.4.0,加上该参数直接报错——

gcc: error: unrecognized command-line option '-fsanitize-address-field-padding=2'

因此这一条未能在 GCC 上实测,只能从 ASan 输出中 Intra object redzone: bb 这一项确认该机制存在。若工程中使用 GCC 并希望覆盖对象内部越界,需依赖代码审查与显式的边界检查。

7.4 一个实践细节:泄漏检测会吞掉标准输出

用同一程序演示内存泄漏时,容易遇到一个会造成误判的现象。将程序的标准输出接入管道,输出会是完全空的:

$ printf "1\nalice\n13900001111\n2\n0\n" | ./contacts_asan 2>/dev/null
(无输出)

原因在 LeakSanitizer 的退出方式:检测到泄漏后,它走的是 _exit 一类不执行清理的终止路径,不会刷新 stdio 缓冲区。标准输出接终端时是行缓冲,每次换行即落盘;接入管道时变为全缓冲,内容仍留在缓冲区中,进程直接终止便一并丢失。

要看正常输出,显式关闭泄漏检测即可:

ASAN_OPTIONS=detect_leaks=0 ./contacts_asan

这不是程序的缺陷,但排查时若不了解,很容易把"输出为空"误判为"程序未运行"。

8 排查方法论

8.1 缺陷全景

将本文涉及的全部缺陷汇总如下,标注各自的可见性:

#问题编译警告ASan 捕获修法要点
1保存格式与读取规则不匹配否否统一格式,写往返测试
2加载采用追加而非替换否否明确语义,同步 count
3scanf("%s") 无宽度限制否仅越过整个对象时加宽度限制或改用 fgets
4解析循环终止条件只认逗号否是(越界读)加入长度上界
5解析写入不检查目标容量否对象内部不报源长度与目标容量分别检查
6while (!feof(fp)) 多跑一轮否否改用 while (fgets(...) != NULL)
7错误路径文件句柄泄漏否部分(LSan 可报)统一出口
8四个函数缺少 return是否编译零警告
9退出时未释放链表否是(LeakSanitizer)实现 destroy,注意释放顺序
10调用方不检查返回值否否检查并反馈
11循环内 fflush 增加 I/O 次数否否交给 fclose 统一刷新

可以看到,11 类缺陷中只有 1 类会产生编译警告,3 类能被 ASan 捕获。其余 7 类需要依靠代码审查、往返测试与边界检查清单来覆盖。

8.2 建议的排查流程

结合以上经验,对涉及内存操作与 I/O 的模块,可以采用如下流程:

  1. 零警告编译。 gcc -Wall -Wextra -O0 -g,要求零警告。这一步成本最低,收益最直接。
  2. 静态审查缓冲区。 按 4.8 节的清单逐项核对,重点是所有无界接口与依赖输入内容的循环条件。
  3. 接入 ASan 运行。 -fsanitize=address,跑通全部功能路径。注意它的检测边界(第 7 章):对象内部的越界不会报告,需要人工补位。
  4. 补写往返测试。 凡涉及序列化与反序列化的模块,必须验证"写出去再读回来,内容相等"。
  5. 检查所有权。 每个 malloc 都要能对应到一处 free,每个 fopen 都要能对应到一处 fclose(包括所有错误路径)。
  6. 逐个修复,逐个重跑。 ASan 通常只报告执行顺序上最先到达的缺陷——修完一处必须重跑,才能暴露后面隐藏的问题。

8.3 关于"缺陷互相掩盖"

第 4.3 节提到的现象值得单独强调:load_file 的长度检查(length < MIN_TOKEN_LENGTH)恰好掩盖了 feof 多跑一轮的后果,使缺陷在测试中隐身。

缺陷之间存在互相伪装的可能。 一份通过了全部测试的代码,未必是"没有缺陷的代码",也可能是"缺陷恰好被彼此抵消或掩盖的代码"。这也是为什么修复应当逐个进行、每改一处就重跑一次验证——批量修改会让新的问题难以归因。

9 总结

本文从一段功能上"完全正常"的通讯录程序出发,梳理了 11 类内存安全与资源管理缺陷,覆盖双向链表的宏实现、文件读写循环、缓冲区边界、序列化存取与内存所有权五个方面。主要结论如下:

第一,feof 是事后标志,不是预测。 C11 7.21.10.2 规定它查询的是文件结束指示器,而该指示器仅在一次失败的读取之后才被设置。任何形如 while (!feof(fp)) 的循环都会多执行一轮,正确做法是让读取操作的返回值充当循环条件。

第二,数组容量与字符串长度是两个独立的量。 容量决定可写入的字节数,字符串长度由终止空字符界定。写入方必须预留终止空字符的位置(最大长度 = 容量 − 1);读取方只信任那个 0 字节。忘记这一条,后果不是截断,而是字符串失去终点、越界读取。

第三,宏的参数会被求值多次。 实测同一个宏在链表为空时求值 3 次、非空时 4 次,差异来自宏体内 if 的短路求值。宏的实参不应包含带副作用的表达式。

第四,ASan 的检测边界是"对象"而非"字段"。 红区加在变量之间而不加在结构体字段之间,因此结构体内部的越界写入不会报告,只有越过整个对象才会。而越是容易破坏链表结构的越界(覆写 next / prev),越落在这个盲区内。GCC 不支持 Clang 的字段填充选项,无法用它在 GCC 上补齐。

第五,序列化必须成对设计,并配往返测试。 本例中 save 写出 ", ",load 却把逗号后的空格当成分隔符,每存取一次就给电话号码加上一个字段名前缀。这类缺陷不触发任何 sanitizer,只能靠"写出去再读回来、比较内容"的测试发现。

第六,编译警告应当清零。 四个缺少 return 的函数没有被人工发现,而 GCC 早已逐行指出。允许警告存在,等于放弃了一层免费的静态检查。

9.1 常见误区

实践中,有几个容易出现的认识偏差:

误区一:程序能跑通就说明没有内存问题。 未定义行为的特征恰恰是"可能看起来正常"。越界写入未必落在关键字段上,释放后使用未必立刻崩溃,泄漏在内存充足时完全无症状。

误区二:接入了 sanitizer 就万事大吉。 ASan 有明确的检测边界(第 7 章)。它覆盖不到对象内部的越界,也覆盖不到语义错误(如第 5 章的存取不对称)。工具是必要而非充分条件。

误区三:用增加检查的方式保证安全,不考虑检查本身的正确性。 本例中 if (length < MIN_TOKEN_LENGTH) return -2; 就属于这类"看似周全"的检查——它在挡住空行的同时,也掩盖了 feof 的缺陷。检查的语义必须与它要拦截的输入精确对应。

误区四:把编译警告当作噪声。 -Wall 报出的是标准明确要求提示的内容。长期忽略警告,等于让编译器这层免费的检查失效。

误区五:批量修复所有缺陷。 ASan 一次通常只报告最先到达的问题。批量修改会让验证结果无法归因,反而延长定位时间。逐个修复、逐个验证才是更快的路径。

参考文献

  1. ISO/IEC 9899:2011(C11)6.9.1p12 —— 函数返回值与到达函数末尾的未定义行为
  2. ISO/IEC 9899:2011(C11)7.21.6.2 —— fscanf 系列对 %s 转换说明符的容量要求
  3. ISO/IEC 9899:2011(C11)7.21.10.2 —— feof 函数语义
  4. ISO/IEC 9899:2011(C11)3.4.3、Annex J.2 —— 未定义行为的定义与情形列举
  5. AddressSanitizer 官方文档 —— 影子内存布局与红区类型
  6. GCC 手册 —— -Wreturn-type、-fsanitize=address、-fanalyzer

从竞态条件到双重释放:C 并发编程的几个关键问题

多线程编程里,真正难处理的往往不是"写不出来",而是写出来了、跑通了、然后在某个不确定的时刻崩溃。

本文讨论四个在实际代码中反复出现的核心问题:

  1. 竞态条件——count++ 为什么不是原子操作,以及四种同步方案的取舍
  2. 条件变量的正确用法——为什么等待必须包在 while 里,为什么执行阶段要放锁
  3. 内存所有权的归属——双重释放是怎么产生的,如何从设计上规避
  4. 运行期验证——如何用消毒剂在测试阶段捕捉上述问题

一、竞态条件:count++ 为什么不是原子操作

多线程对同一个变量做自增时,count++ 在高级语言里看是一条语句,编译成机器指令却是三条:

  1. 从内存读取 count 到寄存器
  2. 寄存器加一
  3. 把寄存器写回内存

这三步之间存在可被抢占的窗口。假设 count 当前为 5,线程 A 执行完第 1 步读到 5,此时发生线程切换;线程 B 完整执行三步,把 count 变成 6;切回 A,它寄存器里还是 5,加一后写回,count 依然是 6。

两次自增,结果只增加了 1。 这就是竞态条件(race condition)。

注意问题的根源是复合操作的不可分割性被破坏,而不是"读到了脏数据"。即使内存访问本身是原子的(现代 CPU 对对齐的机器字访问确实是原子的),自增这个复合操作仍然不是。

二、四种同步方案的取舍

方案实现未获取到时的行为适用场景
无同步(*pcount)++——仅用于验证竞态确实存在
互斥锁pthread_mutex_lock线程休眠,让出 CPU临界区长、竞争激烈
自旋锁pthread_spin_lock线程忙等,持续占用 CPU临界区极短,省下切换开销
CPU 原子指令lock; xaddl无锁,单条指令完成读改写单个变量的简单操作

互斥锁与自旋锁的分界线是"临界区长度"。互斥锁在拿不到锁时会把线程挂起,这涉及两次上下文切换(挂起、唤醒),每次切换的开销在微秒量级。如果临界区本身就只需要几十纳秒,那么切换的开销远大于等待本身——这种情况下自旋锁更划算,它让线程原地空转等待,避免了切换代价,代价是空转期间持续占用 CPU 周期。

原子指令方案则是另一个层次:它不依赖锁,而是让 CPU 直接提供一条不可分割的读-改-写指令。

int inc(int *value, int add) {
    int old;
    __asm__ volatile(
        "lock; xaddl %2, %1;"      /* lock 前缀保证多核下原子执行 */
        : "=a" (old)                /* 输出:旧值放入 eax */
        : "m" (*value), "a"(add)    /* 输入:内存操作数、加数放入 eax */
        : "cc", "memory"            /* 会修改标志寄存器与内存,阻止编译器重排 */
    );
    return old;
}

几个要点:

  • xaddl 的语义是交换后相加——先交换寄存器值和内存值,再把两者之和写回内存。返回值(old)是交换出来的旧值,这正是自增操作需要的语义。
  • lock 前缀不是"加锁"的意思,而是一个指令前缀,作用是让该指令在执行期间锁定对应的缓存行,其他核心无法同时访问同一地址。这是硬件层面的互斥,粒度远小于互斥锁。
  • 约束列表里的 "cc" 表示该指令会修改条件码寄存器(EFLAGS),"memory" 表示会读写内存。如果漏掉 "memory",编译器可能把其他内存访问重排到这条指令之前或之后,破坏原子性——这是内联汇编里最容易出错的地方。

三、线程池:条件变量的正确用法

线程池的本质是复用。每来一个任务就 pthread_create 有两个代价:创建本身需要分配内核对象和栈空间;任务量突增时线程数失控,CPU 大量时间耗在上下文切换而不是实际计算上。

固定大小的线程池用三个结构体撑起:

结构体作用挂在哪条链表
nTask待执行任务:函数指针 + 用户数据manager->tasks
nWorker工作线程:线程 ID + 终止标志manager->workers
nManager管理器:两条链表头 + 互斥锁 + 条件变量本身

任务队列和线程列表都用双向链表实现,LIST_INSERT 采用头插法,LIST_REMOVE 分别处理被删元素位于表头、表中、表尾以及链表只剩一个元素的情形。用宏而不是函数实现,是因为宏不区分类型,一套逻辑可以同时服务于两种节点。

条件变量的语义

条件变量(condition variable,pthread_cond_t)必须与互斥锁配合使用。它的核心 API 是:

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

这个调用做三件事,且整体是原子的:

  1. 释放传入的互斥锁
  2. 让当前线程进入睡眠
  3. 被唤醒时重新获取该互斥锁,然后才返回

"释放锁"和"进入睡眠"必须是原子的,否则会有一个致命窗口:如果先释放锁、还没睡着,此时另一个线程插入任务并发出信号,这个信号就会丢失,而当前线程随后睡下去,永远等不到唤醒。

消费循环的三个细节

pthread_mutex_lock(&manager->mutex);
while (manager->tasks == NULL) {           /* 细节 1 */
    if (worker->terminate) break;
    pthread_cond_wait(&manager->cond, &manager->mutex);
}
if (worker->terminate) {                   /* 细节 2 */
    pthread_mutex_unlock(&manager->mutex);
    break;
}
struct nTask *task = manager->tasks;
LIST_REMOVE(task, manager->tasks);
pthread_mutex_unlock(&manager->mutex);     /* 细节 3 */
task->task_func(task);

细节 1:用 while 而不是 if。 pthread_cond_wait 返回不代表条件一定成立。原因有两类:一是 pthread_cond_broadcast 会唤醒所有等待者,但其中只有一个能抢到任务;二是 POSIX 标准明确允许虚假唤醒(spurious wakeup)——即使没有任何线程发出信号,等待也可能返回。因此被唤醒后必须重新检查条件,if 只检查一次,会漏掉这两种情况。

细节 2:终止检查要在两处都有。 内层循环里的检查处理"线程在等待中被唤醒"的路径;循环外的检查处理"队列非空、直接跳过内层循环"的路径。少任何一处都有线程退不出去或提前退出的问题。

细节 3:执行任务时不持锁。 这是整个设计中最重要的性能决定。task_func 是用户代码,执行时间不可预知。如果在锁内调用,所有工作线程都会被串行化,线程池退化成"单线程加一个队列",完全失去并发意义。

正确的做法是:持锁期间只做"取出任务并摘链"这个 O(1) 的链表操作,拿到任务指针后立即放锁,再执行。

四、内存所有权:双重释放的成因与规避

这个问题在一次线程池销毁逻辑的修正中暴露出来。

原始代码在回调函数结尾有一句 free(worker);——线程退出时释放自己的结构体。而销毁流程缺了 pthread_join 和资源释放,属于资源泄漏。看起来"补上 join 和 free"就能修好,但这样做会踩进一个更严重的坑:

同一个指针被释放两次。

线程退出时 free 一次,销毁流程再 free 一次,构成双重释放(double free)。这是 C 语言中的未定义行为,实际表现通常是堆管理器的内部链表被破坏,然后在完全无关的代码位置崩溃——排查成本极高,因为崩溃点不是案发现场。

问题的根源不是"忘记加判断",而是所有权(ownership)没有定义清楚:这块内存该由谁负责回收?写代码时如果没有明确回答这个问题,释放逻辑迟早会出现重复或遗漏。

可行的所有权模型主要有两种:

模型 A:生产者/管理者持有。 由创建方统一负责释放,使用者只借用。

模型 B:自持有。 谁创建谁释放,或者对象自行负责生命周期。

线程池这里选择模型 A——worker 结构体的所有权归线程池管理器:

  • 回调函数不再自释放,改为 return NULL;
  • 销毁流程统一负责回收

确定所有权之后,销毁顺序也被约束了,每一步的前后关系都不能调换:

置终止标志 → 广播唤醒 → 逐个 join → free(worker) → 释放残留任务 → 销毁同步原语
  • join 必须在 free 之前:线程还在运行时,它的结构体不能被回收,否则线程会访问已释放的内存。
  • terminate 的写入必须在持锁状态下:否则与工作线程读取该标志构成数据竞争。虽然 int 的读写在多数平台上是原子的,但 C 语言的内存模型并不保证这一点,编译器的重排和 CPU 的乱序执行都可能让另一个线程读到意外值。

五、运行验证:用消毒剂捕捉并发缺陷

代码"看起来对"和"确实对"之间差距很大,并发场景下尤其明显——很多缺陷只在特定时序下出现,低负载测试根本触发不到。

GCC 和 Clang 内置的消毒剂(sanitizer)可以在运行时捕捉这类问题,代价只是重新编译一次。

AddressSanitizer:内存错误

gcc -fsanitize=address -g -pthread program.c -o program_asan

ASan 的原理是在每次内存分配的周围插入不可访问的"红区"(redzone),并记录每个内存块的分配与释放状态。一旦发生越界访问、访问已释放内存、重复释放,它会立刻中止程序并打印完整的调用栈。程序退出时若存在内存泄漏,也会给出泄漏点。

用 ASan 验证线程池的销毁逻辑,可以直接确认双重释放是否真的被消除。

ThreadSanitizer:数据竞争

gcc -fsanitize=thread -g -pthread program.c -o program_tsan

TSan 通过记录每个内存访问的线程标识和同步顺序,检测多个线程未经同步地访问同一内存、且至少有一个是写操作的情况。它会输出冲突双方的调用栈以及涉及的内存地址。

一个环境兼容问题

TSan 在部分系统上会直接启动失败:

FATAL: ThreadSanitizer: unexpected memory mapping 0x58de4eb36000-0x58de4eb37000

这不是代码缺陷,而是 TSan 对进程的内存布局有特定要求,与系统的 ASLR(Address Space Layout Randomization,地址空间布局随机化)冲突。解决办法是关闭 ASLR 后运行:

setarch $(uname -m) -R ./program_tsan

-R 表示禁用地址随机化,只对当前进程生效,不影响系统全局设置。

两项检查全部无报告,才能说明这段并发代码通过了内存安全和数据竞争的基本检验。

六、两个容易被忽略的工程细节

换行符的跨平台差异

三种系统对"换行"的定义不同:

名称字节序列使用系统
LF\n(1 字节)Unix / Linux / macOS
CRLF\r\n(2 字节)Windows
CR\r早期 Mac,已淘汰

混用的后果是:Git 会认为整个文件的每一行都被修改过,因为每个换行符的字节序列都不同,diff 显示为全文重写。

处理办法是配置 core.autocrlf:

git config --global core.autocrlf true

它在两个方向上自动转换:提交时把工作区的 CRLF 转成 LF 存入仓库,检出时把仓库的 LF 转成 CRLF 写回工作区。结果是仓库里永远只存 LF,换行符差异被完全隔离在本地。

提交时偶尔会看到警告 LF will be replaced by CRLF the next time Git touches it,这是提示而非错误,说明下次检出时会执行转换。

二进制文件与版本控制

编译产物不应纳入版本管理——它们体积大、每次重编译都产生巨大差异、且可以从源码重新生成。

.gitignore 的常规写法能覆盖大部分情况:

build/
bin/
*.o
*.obj
*.exe
*.so

但有一个特例:Linux 下的可执行文件通常不带扩展名。gcc lock.c -o lock 生成的文件就叫 lock,无法用 *.xxx 匹配,必须按路径显式列出:

# Linux 可执行文件没有扩展名,无法用通配符匹配
/01-concurrent-lock/lock

开头的斜杠表示路径从仓库根目录算起,避免误伤其他同名文件。这也意味着每新增一个可执行文件都要检查是否需要补一行——相比之下,把编译输出统一约定到被忽略的 bin/ 目录下更省心。

七、小结

几个可以直接带走的结论:

  1. 竞态条件的根源是复合操作不可分割性被破坏,而不只是"内存访问不同步"。解决思路要么是让操作变得原子(lock 前缀),要么是让临界区互斥(锁)。
  2. pthread_cond_wait 必须包在 while 里,因为存在广播唤醒和虚假唤醒两种情况。
  3. 持锁期间只做必要的临界操作——线程池在锁内只摘链,执行任务时放锁。
  4. 写释放逻辑之前先明确所有权归属,否则双重释放和内存泄漏迟早出现。
  5. join 永远在 free 之前,销毁顺序由所有权模型决定,不能随意调整。
  6. 并发代码要用 ASan 和 TSan 验证,而不是"跑一遍没崩"就认为没问题。

C/C++ 数组:内存布局、指针退化与二维数组的本质

一份从 Java 数组讲义迁移过来时最容易踩空的清单。文中所有示例均在
Linux 6.8 / gcc 11.4.0(x86-64)上实际编译运行,输出为真实结果。
编译命令统一为 gcc -std=c11 -Wall -Wextra。

引子

Java 里 int[] arr 是一个对象:它有类型、有 length 字段、越界会抛异常、不初始化会自动填零。

C 里 int a[5] 只是一块连续内存。它没有长度字段、越界不会报错、局部变量不初始化就是一堆垃圾数据。C 把"边界与初始化的责任"全部交给了程序员。

下面这句话是理解全部差异的起点:凡是 Java 用语言机制替你保证的,C 都换成了两种东西——未定义行为,或者要求你自己记账。


一、数组的本质:C 里没有"数组对象"

1.1 数组类型

C 标准对数组类型(array type)的定义是(C11 §6.2.5p20):

元素类型确定、元素个数确定,且连续分配的非空对象集合。

拆开看三个要点:

  • 元素类型确定:所有元素类型完全一致,这一点与 Java 相同。
  • 元素个数确定:长度在编译期或运行期确定后不可更改,没有 push、没有扩容。
  • 连续分配:元素在内存里紧挨着排布,中间没有任何空隙或元数据。这是 C 数组全部性能优势的来源,也是它全部安全隐患的来源。

1.2 数组名退化

数组名退化(array-to-pointer conversion,数组到指针的转换)是 C 里最重要的隐式转换规则(C11 §6.3.2.1p3):

除作为 sizeof 运算符的操作数、_Alignof 运算符的操作数、一元 & 运算符的操作数,或用于初始化数组的字符串字面量外,类型为"数组类型"的表达式会被转换为"指向该数组首元素的指针"。

这条规则的含义是:在绝大多数表达式里,a 这个名字根本不是数组,而是一个 int *。所以 C 里没有 a.length —— 名字已经退化成一个纯粹的首地址,长度信息在这个转换中丢失了。

1.3 对照表

维度JavaC
数组是什么堆上的对象,含长度字段一块连续内存
获取长度arr.length(字段)sizeof(a)/sizeof(a[0]),且只在未退化时有效
越界访问抛 ArrayIndexOutOfBoundsException未定义行为,不报错
默认值一定被填成 0 / 0.0 / false / null只有静态存储期才保证为零
能否变长不能,但可重新赋新数组不能,只能重新申请
传参传引用,长度随之传递只传首地址,长度丢失
元素交换直接交换需要临时变量,跨函数需传指针

二、初始化

2.1 静态初始化

C 的静态初始化指元素值在写代码时就已确定,长度可由初始化列表自动推导:

#include <stdio.h>

int main(void)
{
    int a[3] = {12, 24, 36};       /* 显式长度 */
    int b[]  = {12, 24, 36};       /* 长度由元素个数推出,等于 3 */
    int c[3] = {12, 24, 36};

    for (int i = 0; i < 3; ++i)
        printf("a[%d]=%d  b[%d]=%d  c[%d]=%d\n", i, a[i], i, b[i], i, c[i]);

    return 0;
}

有两点和 Java 不同:

第一,C 没有 [] 与 {} 混用的写法。 Java 里 new int[]{1,2,3} 是合法的,C 里 int a[3] = {1,2,3} 的等号右侧只能是初始化列表,不能带 new 也不能带长度。写 int a[3] {1,2,3} 或 int a[3] = new int[3]{1,2,3} 一律编译错误。

第二,C 允许"部分初始化",未给出的元素被补零(C11 §6.7.9p21)。实测:

#include <stdio.h>

int global_a[5];          /* 静态存储期:未显式初始化即为零 */

int main(void)
{
    int part[5]  = {1, 2};                 /* 只给前两个 */
    int desig[5] = {[2] = 30, [4] = 50};   /* C99 指定初始化器 */

    printf("global_a :");
    for (int i = 0; i < 5; ++i) printf(" %d", global_a[i]);
    putchar('\n');

    printf("part     :");
    for (int i = 0; i < 5; ++i) printf(" %d", part[i]);
    putchar('\n');

    printf("desig    :");
    for (int i = 0; i < 5; ++i) printf(" %d", desig[i]);
    putchar('\n');

    return 0;
}

实际输出:

global_a : 0 0 0 0 0
part     : 1 2 0 0 0
desig    : 0 0 30 0 50

第三行用到的指定初始化器(designated initializer)是 C99 引入的语法,[2] = 30 表示"下标 2 的元素是 30"。这在初始化稀疏数组时比逐项写零清晰得多。注意:C++ 直到 C++20 才支持这个语法,所以写 .cpp 文件时用不了(GCC 会把它当作扩展放行,但那不可移植)。

2.2 动态大小:Java 的 new int[8] 在 C 里对应什么

Java 的动态初始化 new int[8] 语义是"在堆上分配 8 个 int,全部填零"。C 里没有 new,按场景有三条路:

路径 A:栈上固定大小(最简单)

double scores[8];   /* 8 个 double,在栈上,内容是垃圾数据 */

编译期长度就确定了。缺点是长度写死在源码里,且栈空间有限(Linux 默认 8 MB),大数组会栈溢出。

路径 B:变长数组 VLA(C99,有风险)

int n = 8;
int a[n];           /* 长度在运行期确定,仍然分配在栈上 */

变长数组(Variable Length Array,VLA)是 C99 引入、C11 改为"可选特性"、C++ 从未采纳的语法。三个必须知道的风险:

  1. 不能处理分配失败。栈溢出的表现是段错误,你没有任何机会做错误处理。
  2. 不能是 0 或负数,否则是未定义行为。
  3. C++ 不支持,写 .cpp 编译不过;C11 下也取决于编译器是否实现该可选特性。

路径 C:堆分配(工程上推荐)

#include <stdlib.h>

double *scores = malloc(sizeof(double) * n);
if (scores == NULL) {           /* malloc 可能失败,必须检查 */
    perror("malloc");
    return 1;
}
/* ... 使用 ... */
free(scores);                   /* 必须配对释放 */

malloc 从堆申请内存,成功返回首地址、失败返回 NULL。需要注意:

  • malloc 不初始化内存,内容是不确定值;
  • calloc(n, sizeof(double)) 会把内存清零,语义上更接近 Java 的 new;
  • 用完后必须 free,否则内存泄漏;
  • sizeof(double) * n 里要把 n 转成 size_t 避免有符号/无符号混算,写成 sizeof(double) * (size_t)n 更稳妥。

2.3 最容易致命的一条:不初始化 ≠ 零

Java 保证数组元素一定有默认值。C 完全不保证,这取决于存储期(storage duration)——一个对象从分配到释放的时间区间,以及这段时间内它的存储是否被隐式初始化。

存储期例子是否隐式初始化
静态存储期全局数组、static 数组是,全部为零
自动存储期函数内 int a[5];否,内容不确定
动态存储期malloc 得到的空间否;calloc 才清零

"未初始化的自动存储期数组,其内容为不确定值(indeterminate value),读取它是未定义行为(undefined behavior)"——这是 C11 §6.2.4p5 与 §6.7.9p10 的直接结论。

未定义行为的定义是(C11 §3.4.3):本国际标准对程序的行为不作任何要求。编译器可以假定它永远不会发生,因此可以据此做优化;一旦真的发生,程序的运行结果不可预测——可能输出垃圾、可能崩溃、也可能碰巧"看起来正常"。

实测对照(这段代码刻意不读 local_a,只取地址):

#include <stdio.h>

int global_a[5];            /* 静态存储期:自动零初始化 */
static int static_a[8];     /* 同上 */

int main(void)
{
    int part[5]  = {1, 2};                 /* 其余补零 */
    int desig[5] = {[2] = 30, [4] = 50};
    int local_a[5];                        /* 自动存储期且未初始化 */

    printf("global_a (全局,未显式初始化):");
    for (int i = 0; i < 5; ++i) printf(" %d", global_a[i]);
    putchar('\n');

    printf("static_a (静态,未显式初始化):");
    for (int i = 0; i < 8; ++i) printf(" %d", static_a[i]);
    putchar('\n');

    printf("local_a  (局部,未初始化)地址 = %p,内容不确定,此处不读取\n",
           (void *)local_a);

    return 0;
}

输出:

global_a (全局,未显式初始化): 0 0 0 0 0
static_a (静态,未显式初始化): 0 0 0 0 0 0 0 0
local_a  (局部,未初始化)地址 = 0x7ffe73b85530,内容不确定,此处不读取

工程建议:局部数组一律显式初始化。最省事的两种写法:

int a[5] = {0};                        /* 整个数组全零,常用惯例 */
double buf[64];
memset(buf, 0, sizeof(buf));           /* 需要 <string.h>,适合大数组 */

三、元素访问、越界与长度

3.1 a[i] 的真实含义是 *(a + i)

C 标准对下标运算符的定义(C11 §6.5.2.1p2):

表达式 E1[E2] 等价于 (*((E1) + (E2)))。

也就是说,a[2] 在形式上是"先做指针加法 a + 2 得到地址,再解引用"。指针加法不是地址数值加 2,而是加 2 个元素——加法的结果地址 = 首地址 + 2 * sizeof(int)。

这个定义有个著名推论:由于加法满足交换律,a[i] 和 i[a] 完全等价。下面两行都合法且含义相同:

int a[3] = {10, 20, 30};
printf("%d\n", a[2]);    /* 30 */
printf("%d\n", 2[a]);    /* 30,同样合法 */

写 2[a] 没有任何实用价值,但理解它证明了"下标只是加法的语法糖",能帮你把数组和指针彻底统一起来。

另外,a[i] 还能出现在赋值号左边(a[0] = 1;)。能出现在赋值号左边的表达式叫左值(lvalue),a[i] 作为解引用表达式正是左值。

3.2 越界:从异常到未定义行为

这是从 Java 迁移到 C 时最危险的一处差异。

Java 的越界检查由语言保证:任何 arr[i] 在访问前都要验证 0 <= i < arr.length,不满足就抛 ArrayIndexOutOfBoundsException。

C 不做任何检查。数组类型里根本不存长度,编译器没见过 a 的边界信息,所以无从检查。越界访问是未定义行为。

下面这段代码,越界写了一格:

#include <stdio.h>

int main(void)
{
    int a[5] = {1, 2, 3, 4, 5};
    int victim = 0x61616161;          /* 'aaaa',一旦被改写一眼可见 */

    a[5] = 0x62626262;                /* 合法下标只有 0..4 */

    printf("程序照常跑完了,没有任何报错\n");
    printf("victim = 0x%x\n", (unsigned)victim);

    return 0;
}

第一步,编译。 gcc -Wall -Wextra -O0 与 -O2 都没有给出任何越界警告:

t6_oob.c: In function ‘main’:
t6_oob.c:5:9: warning: variable ‘a’ set but not used [-Wunused-but-set-variable]

唯一的警告是"变量 a 赋值后没有被使用"——与越界毫无关系。注意:开启优化确实能让编译器发现一部分越界(-Warray-bounds),但那只在它能静态推出下标范围时生效,不能指望它兜底。

第二步,运行。 输出是:

程序照常跑完了,没有任何报错
victim = 0x61616161

程序正常退出,victim 甚至没有被改写——越界写落在栈上的填充区里,没有任何可见症状。这就是 C 数组越界最典型的形态:它经常不出声。今天没症状,不代表明天换一个编译器版本、换一个优化等级、多一个局部变量之后,它不会去改写别的关键数据。

第三步,用 AddressSanitizer 抓它。 加一个编译选项重新编译:

gcc -std=c11 -Wall -Wextra -g -fsanitize=address t6_oob.c -o t6_asan
./t6_asan

输出(节选):

==997100==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc717b2c54 at pc 0x5bc6c722745f
WRITE of size 4 at 0x7ffc717b2c54 thread T0
    #0 0x5bc6c722745e in main /tmp/aryverify/t6_oob.c:8

Address 0x7ffc717b2c54 is located in stack of thread T0 at offset 52 in frame
    #0 0x5bc6c7227258 in main /tmp/aryverify/t6_oob.c:4

  This frame has 1 object(s):
    [32, 52) 'a' (line 5) <== Memory access at offset 52 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow /tmp/aryverify/t6_oob.c:8 in main

AddressSanitizer(ASan)是 GCC/Clang 内置的运行期检查器,原理是在每次内存分配的边界插入不可访问的"红区(redzone)",并记录每个内存块的分配与释放状态。越界访问一旦命中红区,立刻中止程序并打印完整调用栈。

注意报告里的 [32, 52) 和 offset 52:a 占据栈帧里第 32 到 52 字节(5 个 int = 20 字节,恰好对上),而访问落在 offset 52——正好是第一个越界的位置。ASan 把"越界了多少字节、越的是哪个变量"都指出来了。

结论:C 数组越界不会给你异常,也不会保证崩溃,它只会让程序进入未定义行为。唯一的可靠防线是在测试阶段开启 ASan,而不是"跑一遍没崩就当它对"。

3.3 长度:sizeof 技巧与它的失效边界

C 没有 length 字段,所以取长度的标准做法是 sizeof:

int a[5];
size_t n = sizeof(a) / sizeof(a[0]);   /* 20 / 4 = 5 */

sizeof 是运算符而不是函数,它在编译期求值,结果是整个数组占用的字节数。除以单个元素的字节数就得到元素个数。

但这个技巧有一个致命的失效边界:它只在 a 依然是数组类型时成立。一旦把 a 作为参数传给函数,退化就发生了,此时 sizeof(a) 得到的是指针的大小(x86-64 上是 8 字节),不再与数组长度相关。

实测(t1_sizeof.c):

#include <stdio.h>

/* 形参写成 int a[],与 int *a 完全等价 —— 退化已经发生 */
static void show_decay(int a[])
{
    printf("  函数内部 sizeof(a)     = %2zu   <-- 指针的大小,不是数组\n", sizeof(a));
    printf("  函数内部 sizeof(a[0])  = %2zu\n", sizeof(a[0]));
    printf("  算出的\"长度\"           = %2zu   <-- 错的\n", sizeof(a) / sizeof(a[0]));
}

int main(void)
{
    int a[5] = {1, 2, 3, 4, 5};

    printf("main 内部 sizeof(a)    = %2zu   <-- 整个数组的字节数\n", sizeof(a));
    printf("main 内部 sizeof(a[0]) = %2zu\n", sizeof(a[0]));
    printf("算出的长度             = %2zu   <-- 对的\n", sizeof(a) / sizeof(a[0]));

    puts("把同一个 a 传给函数之后:");
    show_decay(a);

    return 0;
}

输出:

main 内部 sizeof(a)    = 20   <-- 整个数组的字节数
main 内部 sizeof(a[0]) =  4
算出的长度             =  5   <-- 对的
把同一个 a 传给函数之后:
  函数内部 sizeof(a)     =  8   <-- 指针的大小,不是数组
  函数内部 sizeof(a[0])  =  4
  算出的"长度"           =  2   <-- 错的

同一个表达式 sizeof(a) / sizeof(a[0]),在 main 里得 5,在函数里得 2。 正确值 5 被无声地变成 2,之后的循环只会处理前两个元素——没有任何报错,只是少算了三个。

更值得注意的是:GCC 对这个错误有专门的警告。用 -Wall -Wextra 编译上面这段代码,会得到:

t1_sizeof.c:6:82: warning: ‘sizeof’ on array function parameter ‘a’ will return size of ‘int *’ [-Wsizeof-array-argument]
    6 |     printf("  函数内部 sizeof(a)     = %2zu   <-- 指针的大小,不是数组\n", sizeof(a));
      |                                                                                  ^
t1_sizeof.c:4:28: note: declared here
    4 | static void show_decay(int a[])
      |                        ~~~~^~~

这条警告的名字叫 -Wsizeof-array-argument,它说明这个坑普遍到编译器都专门为它设了一条规则。所以务必开启 -Wall -Wextra——它能把这类静默错误变成显式警告。

正确的做法:长度必须显式传给函数。

static void show(const int *a, size_t n)   /* 长度作为独立参数 */
{
    for (size_t i = 0; i < n; ++i)
        printf("%d\n", a[i]);
}

形参写成 const int *a 比 int a[] 更好,因为它诚实地告诉你"这里收到的其实是指针",不会让人误以为还有数组信息可用。


四、遍历

4.1 索引遍历

最基础的形式,与 Java 的 for 循环结构相同,只是 arr.length 换成了显式传入的 n:

int a[4] = {20, 30, 40, 50};

for (size_t i = 0; i < n; ++i)          /* n 由调用方传入 */
    printf("%d\n", a[i]);

类型选择:下标变量用 size_t(无符号)还是 int(有符号)?两种都常见。用 int 的风险是,当它与 sizeof 的结果(size_t)比较时,会触发 -Wsign-compare 类警告;用 size_t 的风险是,递减循环 for (size_t i = n - 1; i >= 0; --i) 会因无符号下溢而变成死循环。经验规则:正向遍历用 size_t,需要倒序时用 int(或写成 for (size_t i = n; i-- > 0; ))。

4.2 指针遍历与尾后指针

既然 a[i] 就是 *(a + i),遍历当然可以直接用指针:

int a[4] = {20, 30, 40, 50};

for (int *p = a; p != a + 4; ++p)      /* p 从首元素走到尾后 */
    printf("%d\n", *p);

这里有三个必须讲清的点:

第一,p != a + 4 里的 a + 4 叫尾后指针(one-past-the-end pointer),它指向最后一个元素之后的那一格。C 标准明确允许形成这个指针并参与比较(C11 §6.5.6p8):

若指针指向数组的最后一个元素,则 P + 1 指向该数组最后一个元素之后的位置;若该位置存在,它不指向任何元素。

第二,尾后指针只能比较,不能解引用。 *(a + 4) 是越界访问,未定义行为。它存在的唯一意义是作为"终点标记",让循环有一个干净的终止条件。

第三,指针版与索引版编译出的机器码通常完全一致。 选哪个是表达意图的问题,不是性能问题——现代编译器的优化器会把两者归约到同一形式。

4.3 C++ 的范围 for 与它的退化

C++11 的范围 for(range-based for)语法上最简洁:

int a[5] = {12, 24, 36, 48, 60};
for (int x : a) std::cout << x << ' ';

但有一个关键限制:它同样依赖 a 是数组类型。一旦 a 退化成指针,范围 for 就无法推导出长度,编译直接失败:

void f(int a[]) {
    for (int x : a) { }   // 编译错误:范围 for 不能作用于指针
}

范围 for 能"知道"长度,靠的是 std::begin / std::end 这个辅助函数族,而它们对数组类型的重载同样要用 sizeof 推导。所以它继承了一切退化问题。

改进手段:std::array 或 std::span。 std::array<T, N> 把长度做成了类型的一部分,拷贝和传参都不丢;std::span<T>(C++20)作为一个"指针 + 长度"的轻量视图。详见第八节。

4.4 三种遍历的适用性

方式语法是否受退化影响适用场景
索引遍历for (i = 0; i < n; ++i)不受(长度自己传)需要下标做额外计算时
指针遍历for (p = a; p != a + n; ++p)不受与指针语义打交道的底层代码
范围 for(C++)for (T x : a)受,退化后编译失败只读遍历且 a 是数组/容器

五、四个经典场景

5.1 求最值

思路:先用数组的第一个元素初始化最值变量,再从头遍历比较。

为什么初值取 a[0] 而不是 0? 如果初值取 0,那么当数组元素全部是负数时,最大值会错误地保持为 0——这个 0 根本不是数组里的元素。取 a[0] 则保证初值一定属于该数组。

#include <stdio.h>

int main(void)
{
    int scores[] = {15, 9000, 10000, 20000, 9500, -5};
    size_t n = sizeof(scores) / sizeof(scores[0]);

    int max = scores[0];
    int min = scores[0];

    for (size_t i = 1; i < n; ++i) {        /* 从 1 开始,第 0 个已经当过初值 */
        if (scores[i] > max) max = scores[i];
        if (scores[i] < min) min = scores[i];
    }

    printf("最大值 = %d\n", max);
    printf("最小值 = %d\n", min);

    return 0;
}

另一种写法是用 <limits.h> 里的 INT_MAX / INT_MIN 作为初值,好处是不依赖数组非空:

#include <limits.h>

int max = INT_MIN;      /* 比任何 int 都小 */
int min = INT_MAX;

但要注意 INT_MIN 是个常量,如果数组确实为空,结果就是 INT_MIN 这个无意义的值。工程上更稳妥的做法是先判断 n == 0 并单独处理空数组。 C 语言不允许零长度数组(int a[0] 在标准 C 里非法),所以"空数组"在 C 里通常表示为"指针非空但长度为 0",需要调用方保证。

5.2 成绩统计:输入 + 统计

Java 用 Scanner,C 用 scanf。这里有两个必须注意的细节:

#include <stdio.h>

#define N 8

int main(void)
{
    double scores[N];
    double sum = 0.0;

    for (int i = 0; i < N; ++i) {
        /* scanf 必须拿到地址才能写回;返回值是"成功赋值的个数" */
        if (scanf("%lf", &scores[i]) != 1) {
            fprintf(stderr, "第 %d 个成绩读入失败\n", i + 1);
            return 1;
        }
    }

    double max = scores[0];
    double min = scores[0];

    for (int i = 0; i < N; ++i) {
        sum += scores[i];
        if (scores[i] > max) max = scores[i];
        if (scores[i] < min) min = scores[i];
    }

    printf("最高分 = %.2f\n", max);
    printf("最低分 = %.2f\n", min);
    printf("总分   = %.2f\n", sum);
    printf("平均分 = %.2f\n", sum / N);

    return 0;
}

输入 88 76.5 92 65 100 58 81 79.5,实际输出:

最高分 = 100.00
最低分 = 58.00
总分   = 640.00
平均分 = 80.00

三个要点:

第一,scanf 的参数必须是地址。 C 是值传递,只有把地址交给 scanf,它才能把读到的数据写回你的变量。scanf("%lf", scores[i]) 少了 & 会导致 scanf 把变量值当作地址去写,这是未定义行为,通常直接段错误。

第二,格式说明符必须与类型严格匹配。 double 用 %lf,float 用 %f,int 用 %d。用错说明符不会报错,但会写出错误的数据。

第三,必须检查返回值。 scanf 返回成功赋值的项数,输入不合法(比如用户敲了字母)时会返回 0 或 EOF。不检查就会拿着未初始化的元素继续算。

C++ 的等价写法(cin >> 天然按类型解析,不需要写说明符和取地址符):

#include <iostream>

double x;
while (std::cin >> x) { /* ... */ }

5.3 元素交换:值传递与指针

交换两个变量用临时变量,这部分和 Java 一样:

int t = a[0];
a[0] = a[4];
a[4] = t;

但一旦把交换封装成函数,C 和 Java 就完全不同了。 关键在于 C 的参数传递方式是值传递(pass by value):函数收到的永远是实参的副本,形参的修改不影响调用方。

#include <stdio.h>

/* 错误版本:交换的是形参这两个副本 */
static void swap_wrong(int x, int y)
{
    int t = x;
    x = y;
    y = t;
}

/* 正确版本:传指针,交换的是调用方那块内存里的值 */
static void swap_right(int *x, int *y)
{
    int t = *x;
    *x = *y;
    *y = t;
}

int main(void)
{
    int a = 10, b = 20;

    swap_wrong(a, b);
    printf("调用 swap_wrong(a, b) 之后: a = %d, b = %d\n", a, b);

    swap_right(&a, &b);
    printf("调用 swap_right(&a, &b) 之后:a = %d, b = %d\n", a, b);

    return 0;
}

实际输出:

调用 swap_wrong(a, b) 之后: a = 10, b = 20
调用 swap_right(&a, &b) 之后:a = 20, b = 10

swap_wrong 里确实交换了,但交换的是函数内那两份副本,函数一返回就随栈帧消失,调用方的 a、b 原封不动。

swap_right 接收的是指针——指针本身也是按值传递的,但传递的这个"值"是 a 的地址。函数内部通过 *x 解引用,操作的就是调用方那块内存,所以修改能保留下来。

Java 里的说法在 C 里要对齐一下:Java 说"基本类型传值、对象传引用"。C 里只有一条规则——一切都是值传递。当你想让函数修改调用方的数据时,传的是"指针这个值",即地址的副本。理解成"引用传递"会掩盖掉一个事实:你完全可以修改指针本身而不影响调用方(比如让 x 指向别处),因为改动只作用于副本。

顺带说明 C++ 的两种改良写法:

void swap_ref(int &x, int &y) { int t = x; x = y; y = t; }   /* 引用,调用处无需 & */
void swap_ptr(int *x, int *y) { int t = *x; *x = *y; *y = t; }

引用(reference)是 C++ 引入的独立类型,它是某个已存在对象的别名,不是新对象、不需要取地址。swap_ref(a, b) 的调用形式更干净,但语义上和传指针完全一致。C 里没有引用,只能传指针。

5.4 随机点名

Java 用 Random.nextInt(n)。C 用 rand() 配合 srand():

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void)
{
    const char *names[] = {
        "张誉", "刘疏桐", "田启峰", "伊成元", "赵志刚",
        "解天野", "高续升", "王世举", "周字伦", "刘帅",
        "陈小伟", "陈伟北", "高明", "毕振宇", "王乾麟",
    };
    size_t n = sizeof(names) / sizeof(names[0]);

    srand((unsigned)time(NULL));           /* 只播种一次,放在循环外面 */
    size_t idx = (size_t)rand() % n;       /* 0 .. n-1 */

    printf("%s同学,出来回答问题!\n", names[idx]);

    return 0;
}

这里有四个知识点:

第一,const char *names[] 是"字符串数组"的正确写法。 C 里没有 String 类型,字符串是以 '\0' 结尾的 char 数组。"张誉" 这样的字符串字面量(string literal),类型是 const char[7]——两个汉字按 UTF-8 各占 3 字节共 6 字节,加上结尾的 '\0' 正好 7。names 是一个元素类型为 const char *(指向字符的指针)的数组,共 15 个元素,每个元素指向一个字符串字面量。

注意 sizeof 的区别:

  • sizeof(names) = 15 × 8 = 120(指针数组的总字节数)
  • sizeof(names[0]) = 8(单个指针的大小)
  • 而 sizeof("张誉") 会是字符串本身的字节数
    所以 sizeof(names)/sizeof(names[0]) 得到的是元素个数 15,这正是我们要的。但如果写成 sizeof(names[0])/sizeof(names[0][0]) 就完全错了——sizeof(names[0][0]) 是单个 char 的大小 1,得到 8 这个无意义的值。

第二,srand 只能调用一次。 time(NULL) 的精度是秒。如果把 srand 放进循环,同一秒内每次调用都播种成同一个种子,rand() 就返回同一个数。实测:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void)
{
    puts("在循环里反复 srand(time(NULL)):");
    for (int i = 0; i < 3; ++i) {
        srand((unsigned)time(NULL));
        printf("  %d\n", rand());
    }

    puts("只播种一次,然后连续取值:");
    srand((unsigned)time(NULL));
    for (int i = 0; i < 3; ++i)
        printf("  %d\n", rand());

    return 0;
}

输出:

在循环里反复 srand(time(NULL)):
  1547618903
  1547618903
  1547618903
只播种一次,然后连续取值:
  1547618903
  297209375
  677920273

前三个数完全相同。这是"随机数看着不随机"最常见的成因。

第三,rand() 不是密码学安全的。 rand() 是伪随机数生成器(pseudorandom number generator,PRNG):它由种子完全确定,同一份种子必然产生同一串序列。这在需要可复现的场合(测试、实验)是优点,但在涉及安全(生成令牌、密钥、防猜测)的场合是致命缺陷——序列可以被反推。

第四,Linux 上应当用 getrandom() 获取真随机。 需要不可预测的随机数时,现代 Linux 的正确做法是

#include <sys/random.h>

unsigned char buf[16];
ssize_t got = getrandom(buf, sizeof(buf), 0);   /* 内核 CSPRNG,阻塞直到有足够熵 */

getrandom(2) 从内核的密码学安全伪随机数生成器(CSPRNG)取值,是 rand() 在安全性上的替代方案。老一些的写法是读 /dev/urandom:

#include <fcntl.h>
#include <unistd.h>

int fd = open("/dev/urandom", O_RDONLY);
read(fd, buf, sizeof(buf));
close(fd);

六、洗牌:一个被流传很广的错误实现

6.1 讲义里的写法错在哪

先看常见写法(下标每次都从整个数组里随机取):

/* 每一轮都从整个数组里随机取一个下标来交换 */
static void shuffle_naive(int *a, int n)
{
    for (int i = 0; i < n; ++i)
        swap(&a[i], &a[rand() % n]);
}

它把"随机"理解成了"做 n 次随机交换"。但n 次随机交换的结果分布不是均匀分布——这一点在 n=2 时看不出来(两种结果的概率恰好都是 1/2),n≥3 才开始明显偏离。

6.2 实测:跑 100 万次看频率

固定种子、跑 100 万轮,统计"每个元素落在每个位置"的频率。理论上每种情况都应该是 1/4 = 0.2500:

#include <stdio.h>
#include <stdlib.h>

#define N 4
#define ROUNDS 1000000L

static void swap(int *x, int *y) { int t = *x; *x = *y; *y = t; }

/* 讲义写法:每一轮都从整个数组里随机取一个下标来交换 */
static void shuffle_naive(int *a, int n)
{
    for (int i = 0; i < n; ++i)
        swap(&a[i], &a[rand() % n]);
}

/* Fisher-Yates:每一轮只在"尚未确定"的区间 [0, i] 里取下标 */
static void shuffle_fy(int *a, int n)
{
    for (int i = n - 1; i > 0; --i)
        swap(&a[i], &a[rand() % (i + 1)]);
}

int main(void)
{
    long cnt_naive[N][N] = {{0}};   /* cnt[位置][元素] */
    long cnt_fy[N][N] = {{0}};
    int a[N];

    srand(20261011u);              /* 固定种子,让结果可复现 */

    for (long r = 0; r < ROUNDS; ++r) {
        for (int i = 0; i < N; ++i) a[i] = i;
        shuffle_naive(a, N);
        for (int i = 0; i < N; ++i) cnt_naive[i][a[i]]++;

        for (int i = 0; i < N; ++i) a[i] = i;
        shuffle_fy(a, N);
        for (int i = 0; i < N; ++i) cnt_fy[i][a[i]]++;
    }
    /* ... 打印频率表 ... */
    return 0;
}

实际输出:

=== 讲义写法:每轮都从整个数组取下标 ===
元素\位置    位0      位1      位2      位3
元素 0     0.2496   0.2499   0.2498   0.2508
元素 1     0.2932   0.2227   0.2342   0.2500
元素 2     0.2462   0.2820   0.2224   0.2494
元素 3     0.2111   0.2454   0.2937   0.2499

=== Fisher-Yates:可取区间逐轮缩小 ===
元素\位置    位0      位1      位2      位3
元素 0     0.2502   0.2499   0.2502   0.2497
元素 1     0.2504   0.2500   0.2502   0.2494
元素 2     0.2495   0.2509   0.2498   0.2499
元素 3     0.2499   0.2492   0.2498   0.2510

第一张表里,元素 1 落在位置 0 的概率是 0.2932,元素 3 落在位置 0 的只有 0.2111——相对 0.25 的理论值,偏差达到 ±17% 和 ±16%。元素 0 看起来"正常"只是巧合。

有意思的是位置 3 那一列,四种元素的频率全是 0.2500,完全均匀。原因是循环最后一次执行的是 swap(a[3], a[rand()%4]),这一步把一个均匀随机挑选的元素放到了位置 3,所以最后一位反而是无偏的。偏差全都集中在它前面的若干位上。

第二张表——Fisher-Yates——全部落在 0.2492 ~ 0.2510 之间,与理论值 0.25 的差距在 100 万次采样的统计涨落范围内。

6.3 正确实现:Fisher–Yates 洗牌

/* 每一轮只在"尚未确定"的区间 [0, i] 里取下标 */
static void shuffle_fy(int *a, int n)
{
    for (int i = n - 1; i > 0; --i)
        swap(&a[i], &a[rand() % (i + 1)]);
}

关键差异只有一个:随机下标的取值范围逐轮缩小。 第 i 轮取的是 [0, i],而不是 [0, n-1]。

不变量(invariant):每一轮结束时,位置 i+1 .. n-1 上的元素已经定稿,且区间 [0, i] 内每个元素落入已定稿位置的概率均等。这个不变量逐步归纳即得整体均匀性——每一步都是"从剩余候选中均匀挑一个放到当前位置",等价于对排列空间做一次均匀采样。

6.4 顺带排除一个干扰因素:rand() % n 的取模偏差

看到 rand() % n 时还有一个常见怀疑:取模偏差(modulo bias)。rand() 返回值在 [0, RAND_MAX] 上均匀,但如果 RAND_MAX + 1 不能被 n 整除,某些余数就会比其他的多出现一次。

在 glibc 上实测:

RAND_MAX = 2147483647
RAND_MAX 是否为 2^31-1:是
取值个数 RAND_MAX+1 = 2^31 = 2147483648
2147483648 % 4 = 0(为 0 说明 n=4 时没有取模偏差)

因为取值空间大小 2^31 能被 4 整除,上表里 n=4 的偏差完全来自算法本身,与取模无关。这一点很重要:如果偏差来自取模,那么换一个随机源就能解决;而这里偏差来自算法,必须改算法。

反过来说,如果 n 是不能整除 2^31 的值(比如 n = 3),取模偏差就会叠加进来,必须用拒绝采样消除:

/* 拒绝采样:把落在尾部"不完整区间"的取值丢掉重取 */
static unsigned rand_below(unsigned n)
{
    unsigned limit = UINT_MAX - (UINT_MAX % n);   /* n 的最大整数倍范围内 */
    unsigned r;
    do { r = (unsigned)rand(); } while (r >= limit);
    return r % n;
}

更省事的做法是用标准库的成熟实现:C 用 arc4random_uniform(n)(BSD/Linux 部分实现),C++ 用 <random> 里的 std::uniform_int_distribution(见第八节),它会自动处理取模偏差。


七、二维数组

7.1 本质差异:整块内存 vs 数组的数组

维度Java new int[3][5]C int a[3][5]
结构数组的数组,3 个独立的一维数组对象一块连续内存,15 个 int 紧挨排布
各行长度可以不等(锯齿数组)全部相同
内存是否连续行与行之间不连续完全连续
行对象的开销每行有一个对象头无
元素定位两次指针跳转一次乘法:base + (i*5 + j) * sizeof(int)
类型int[][]int[3][5],退化成 int (*)[5]

C 的二维数组是行主序(row-major order)存储:先放第 0 行的全部元素,再放第 1 行,依此类推。a[i][j] 等价于 *(*(a + i) + j),展开成地址计算就是"首地址 + (i * 列数 + j) 个元素"。

连续存储带来两个实际收益:一是 元素定位只需一次乘法,没有指针跳转;二是 遍历时访问相邻元素落在同一条或相邻的缓存行里,缓存局部性(cache locality)更好。

7.2 真二维数组的 sizeof

实测(t5_2d.c):

#include <stdio.h>

#define ROWS 3
#define COLS 5

int main(void)
{
    int a[ROWS][COLS];

    printf("sizeof(a)        = %2zu  ( %d * %d * %zu )\n",
           sizeof(a), ROWS, COLS, sizeof(int));
    printf("sizeof(a[0])     = %2zu  ( %d * %zu )\n",
           sizeof(a[0]), COLS, sizeof(int));
    printf("sizeof(a[0][0])  = %2zu\n", sizeof(a[0][0]));
    printf("行数 = sizeof(a)/sizeof(a[0])            = %zu\n",
           sizeof(a) / sizeof(a[0]));
    printf("列数 = sizeof(a[0])/sizeof(a[0][0])      = %zu\n",
           sizeof(a[0]) / sizeof(a[0][0]));
    printf("总元素个数 = sizeof(a)/sizeof(a[0][0])   = %zu\n",
           sizeof(a) / sizeof(a[0][0]));

    return 0;
}

输出:

sizeof(a)        = 60  ( 3 * 5 * 4 )
sizeof(a[0])     = 20  ( 5 * 4 )
sizeof(a[0][0])  =  4
行数 = sizeof(a)/sizeof(a[0])            = 3
列数 = sizeof(a[0])/sizeof(a[0][0])      = 5
总元素个数 = sizeof(a)/sizeof(a[0][0])   = 15

关键是理解 a[0] 是什么:a[0] 是"第 0 行那个一维数组",类型是 int[5],不是 int。 所以 sizeof(a[0]) = 20 而不是 4。三层 sizeof 分别对应整个数组、一行、一个元素,行数/列数正是由前两层之比得到。

7.3 声明读法:数组指针 vs 指针数组

这两个声明只差一对括号,含义完全不同,是 C 里最经典的读法练习:

int  *p[3];    /* 指针数组:p 是"含 3 个 int* 的数组" */
int (*p)[5];   /* 数组指针:p 是"指向含 5 个 int 的数组 的指针" */

读声明的方法:从变量名出发,先看右边,再看左边,括号优先。

  • int *p[3]:p 右边是 [3],说明 p 是含 3 个元素的数组;向左看 int *,说明元素类型是 int *。整体:含 3 个 int * 的数组。
  • int (*p)[5]:p 被括号包住,先看括号内 *p,说明 p 是指针;再看括号右边的 [5],说明它指向的是含 5 个元素的数组;再看 int。整体:指向 int[5] 的指针。

第一行占 3 × 8 = 24 字节(3 个指针),第二行只占 8 字节(1 个指针)——尽管两个声明的文字几乎一样。

7.4 传参:为什么必须写列数

把二维数组传给函数时,列数必须写在形参里:

static void inspect(int a[][COLS], int rows)   /* 等价于 int (*a)[COLS] */
{
    /* ... */
}

原因还是退化。int a[3][5] 在表达式中退化成指向首行的指针,而"一行"的类型是 int[5],所以退化结果是 int (*)[5]——不是 int **。

用 -Wall -Wextra 编译时,GCC 会把这个退化类型明确打印出来:

t5_2d.c:9:71: warning: ‘sizeof’ on array function parameter ‘a’ will return size of ‘int (*)[5]’ [-Wsizeof-array-argument]
    9 |     printf("  函数内部 sizeof(a)  = %2zu   <-- 一行的字节数\n", sizeof(a));
      |                                                                       ^
t5_2d.c:7:25: note: declared here
    7 | static void inspect(int a[][COLS], int rows)
      |                     ~~~~^~~~~~~~~

注意警告原文里的 int (*)[5]——编译器的措辞直接确认了退化后的类型。

传参之后的行为实测:

传参之后:
  函数内部 sizeof(a)  =  8   <-- 一行的字节数
  函数内部 sizeof(*a) = 20   <-- 整个一行

sizeof(a) = 8 是指针大小,sizeof(*a) = 20 才是"一行"的字节数(*a 是 int[5],没有退化,因为它是 sizeof 的操作数)。

为什么不能写成 int **? 因为 int ** 表示"指向 int * 的指针",而真二维数组里根本没有 int * 这一层——它是 3 × 5 个连续的 int。用 int ** 接收 int a[3][5],程序会把数组的前 8 字节当作一个指针去解引用,然后跳转到一个由元素值拼成的地址上。这通常立刻段错误。只有"指针数组"(每个元素是独立指针)才对应 int **。

7.5 不规则二维:C 里怎么做锯齿数组

Java 里 int[][] a = new int[4][]; a[0] = new int[3]; 是合法的。C 里没有"数组的数组"这种原生结构,需要手动构造。三条路:

路径 A:指针数组 + 逐行 malloc(最接近 Java 的写法)

#include <stdlib.h>

int rows = 4;
int *grid[rows];                        /* 4 个指针;VLA 数组,C99 */

for (int i = 0; i < rows; ++i) {
    grid[i] = malloc(sizeof(int) * (size_t)col_len[i]);   /* 每行单独申请 */
    if (!grid[i]) { /* 处理失败,注意释放前面已成功的行 */ }
}

/* 使用:grid[i][j] */
grid[0][0] = 1;

for (int i = 0; i < rows; ++i)
    free(grid[i]);                      /* 逐行释放 */

缺点有两个:行与行之间不连续(每行独立分配,中间可能夹着别的数据),缓存局部性差;释放时必须逐行 free,容易漏。

路径 B:一维扁平数组 + 手工索引换算(工程上推荐)

#include <stdlib.h>

int rows = 4, cols = 5;
int *flat = malloc(sizeof(int) * (size_t)rows * (size_t)cols);
if (!flat) { /* 处理失败 */ }

/* 访问 (i, j) */
#define AT(i, j) flat[(size_t)(i) * (size_t)cols + (size_t)(j)]

AT(2, 3) = 7;
printf("%d\n", AT(2, 3));

free(flat);                             /* 只需一次释放 */

整块内存连续,一次 malloc 一次 free,缓存友好。代价是索引要手工换算(或者像上面这样封装成宏)。注意宏里的括号不能省——(size_t)(i) * (size_t)cols + (size_t)(j) 如果漏掉括号,AT(a+1, b) 这类实参会因为运算符优先级被错误展开。

路径 C:真二维数组,但用最大列数(浪费一点空间)

int grid[4][5];    /* 所有行长度相同 */

如果各行长度差异不大,直接用最大列数最省心。这也是性能最好的一种。

路径内存连续性释放方式适用场景
A 指针数组逐行 malloc不连续逐行 free行长差异大,且必须保留 [i][j] 语法
B 一维扁平连续一次 free性能敏感、可接受索引换算
C 真二维数组连续无需释放行长统一

7.6 案例:座位表与游戏地图

座位表:4 排、每排人数不同,用"名字指针数组 + 行长数组"表示。

#include <stdio.h>

#define ROWS 4

int main(void)
{
    const int row_len[ROWS] = {3, 3, 4, 2};
    const char *seats[] = {           /* 长度由初始化列表推出:3+3+4+2 = 12 */
        "张无忌", "赵敏", "周芷若",
        "张三丰", "宋远桥", "殷梨亭",
        "灭绝", "陈昆", "玄冥二老", "金毛狮王",
        "杨逍", "纪晓芙",
    };

    int k = 0;
    for (int i = 0; i < ROWS; ++i) {
        printf("第%d排:", i + 1);
        for (int j = 0; j < row_len[i]; ++j)
            printf("%s  ", seats[k + j]);
        k += row_len[i];
        putchar('\n');
    }

    return 0;
}

实际输出:

第1排:张无忌  赵敏  周芷若
第2排:张三丰  宋远桥  殷梨亭
第3排:灭绝  陈昆  玄冥二老  金毛狮王
第4排:杨逍  纪晓芙

这里有一个值得注意的编译细节。如果把 seats 写成 const char *seats[ROWS](即固定 4 个元素),而初始化列表里有 12 个字符串,GCC 会报:

t9_2d_cases.c:14:19: warning: excess elements in array initializer

"声明长度 4、初始化 12 个元素"会被编译器拦住,这一点 C 比 Java 严格(Java 的 new int[4] 后面跟着 12 个元素同样报错,但 C 这里还额外提醒你类型上是"指针数组"还是"二维数组"完全是两回事)。写成 seats[] 让长度自动推导,才是这里正确的写法。

游戏地图:4×4 石头迷阵,同时验证行主序。

#include <stdio.h>

#define ROWS 4
#define COLS 4

int main(void)
{
    int maze[ROWS][COLS];
    int num = 1;

    for (int i = 0; i < ROWS; ++i)
        for (int j = 0; j < COLS; ++j)
            maze[i][j] = (num <= 15) ? num++ : 0;

    puts("===== 石头迷阵 =====");
    for (int i = 0; i < ROWS; ++i) {
        for (int j = 0; j < COLS; ++j)
            printf("%2d\t", maze[i][j]);
        putchar('\n');
    }

    /* 用一维视角访问同一块内存,验证行主序 */
    int *flat = &maze[0][0];
    printf("maze[2][1] = %d, flat[2*4+1] = %d  -> %s\n",
           maze[2][1], flat[2 * COLS + 1],
           maze[2][1] == flat[2 * COLS + 1] ? "一致,确认行主序" : "不一致");

    return 0;
}

实际输出:

===== 石头迷阵 =====
 1     2     3     4
 5     6     7     8
 9    10    11    12
13    14    15     0
maze[2][1] = 10, flat[2*4+1] = 10  -> 一致,确认行主序

&maze[0][0] 取的是第一个元素的地址,把它当一维数组用(int *flat),flat[2*COLS + 1] 与 maze[2][1] 访问到的是同一个 int——这一行实测结果直接证实了二维数组就是按行主序排布的一整块内存。

注意 int *flat = &maze[0][0]; 这里必须取 &maze[0][0](一个 int 的地址),而不是 maze(类型 int (*)[4],与 int * 不兼容)。

堆上的真二维数组:需要运行期决定大小时,用"指向数组的指针"接收 malloc 的返回值。

#include <stdio.h>
#include <stdlib.h>

static void heap_2d(int rows, int cols)
{
    /* 注意类型:int (*)[cols] 是指向"含 cols 个 int 的数组"的指针 */
    int (*grid)[cols] = malloc(sizeof(int) * (size_t)rows * (size_t)cols);
    if (!grid) { perror("malloc"); return; }

    for (int i = 0; i < rows; ++i)
        for (int j = 0; j < cols; ++j)
            grid[i][j] = i * cols + j;

    printf("grid[%d][%d] = %d\n", rows - 1, cols - 1, grid[rows - 1][cols - 1]);
    printf("整块大小 = %zu 字节,行与行之间连续\n",
           sizeof(int) * (size_t)rows * (size_t)cols);

    free(grid);     /* 一次释放即可 */
}

实测输出:

grid[2][4] = 14
整块大小 = 60 字节,行与行之间连续

int (*grid)[cols] 里的 cols 是运行期值,所以这是一个 VLA 类型,属于 C99 特性。它让 grid[i][j] 这种二维写法能直接作用在 malloc 得到的一整块内存上,同时只做一次 free。


八、C++ 里应该用什么

前面所有内容都适用于 C++,但 C++ 提供了三件工具,可以系统性地消除"退化"和"手工释放"这两个问题源头。

8.1 std::array:长度成为类型的一部分

#include <array>
#include <iostream>

std::array<int, 5> b = {1, 2, 3, 4, 5};

std::cout << b.size() << '\n';      // 5
std::cout << b[2]     << '\n';      // 3
std::cout << sizeof(b) << '\n';     // 20

关键性质:std::array<int, 5> 和 std::array<int, 6> 是两个不同的类型。长度进入了类型,因此:

  • 拷贝、赋值传参都不会丢长度(不像原生数组一传就退化);
  • b.size() 永远正确,不需要 sizeof 技巧;
  • 没有隐式转换到指针,std::array 不会静默退化成 int *;
  • 内存布局与 int b[5] 完全相同,零额外开销。

代价是长度必须是编译期常量。需要运行期长度就用 std::vector。

8.2 std::vector:运行期可增长

#include <iostream>
#include <vector>

std::vector<double> scores;
scores.push_back(88.5);
scores.push_back(76.0);
scores.push_back(92.5);

double sum = 0.0;
for (double s : scores) sum += s;

std::cout << scores.size() << '\n';                             // 3
std::cout << sum / static_cast<double>(scores.size()) << '\n';  // 85.6667

(实体输出:vector 元素数 = 3, 平均分 = 85.6667)

std::vector 是动态数组:内部维护一块连续内存,容量不足时重新分配一块更大的并把元素搬过去。它是 C 里 malloc + 手工扩容的完整替代品,且内存由析构函数自动释放——不再需要 free,不再有泄漏。

它同时保留了 C 数组的连续性优势(&v[0] 或 v.data() 可以拿到首地址喂给任何 C 接口),这是它相对 std::list 的关键区别。

8.3 std::span:作为函数参数的"指针 + 长度"(C++20)

#include <span>

void show(std::span<const int> a)      // 长度随 span 一起传,无需第二个参数
{
    for (int x : a) std::cout << x << ' ';
}

std::span 是一个轻量视图,内部就是"指针 + 长度"两个字段。它直接解决了第 3.3 节那个"长度退化了"的根本问题:调用 show(a) 时,如果 a 是原生数组,span 会通过 std::begin/std::end 推导出长度;如果 a 是 vector,则用容器自身的 size()。两种来源都能接。

8.4 std::shuffle:标准库的无偏洗牌

#include <algorithm>
#include <random>

std::mt19937 gen(20261011u);            /* 梅森旋转,比 rand() 质量高得多 */
std::shuffle(std::begin(a), std::end(a), gen);

实测输出:

std::shuffle 之后: 60 48 24 36 12

std::mt19937(Mersenne Twister,梅森旋转)是一个周期为 2^19937-1 的 PRNG,统计质量远优于 rand()。std::shuffle 内部实现的就是 Fisher–Yates,且用 std::uniform_int_distribution 消除了取模偏差——所以第六节里那两个坑它一次都不踩。

8.5 选型表

需求C 做法C++ 做法
编译期固定长度int a[5]std::array<int, 5>
运行期长度malloc + freestd::vector<int>
作为函数参数接收任意长度(const int *a, size_t n)std::span<const int>
二维矩阵(规则)int a[3][5] + int (*)[5] 形参std::vector<std::array<int,5>>
不规则二维指针数组 + 逐行 mallocstd::vector<std::vector<int>>
洗牌手写 Fisher–Yatesstd::shuffle + std::mt19937
获取长度sizeof(a)/sizeof(a[0]).size()

九、编译与验证

C 数组的绝大多数缺陷(越界、未初始化读取、类型不匹配)都属于未定义行为,编译器默认不会告警,运行时也不会报错。所以必须主动开启检测手段。

第一层:打开警告。 至少 -Wall -Wextra,它们能把退化陷阱(-Wsizeof-array-argument)、符号不匹配等变成显式警告。

gcc -std=c11 -Wall -Wextra -O2 program.c -o program

第二层:开启运行期检查器。 -fsanitize=address 抓越界与释放后使用,-fsanitize=undefined 抓有符号溢出、除零、非法移位等。

gcc -std=c11 -Wall -Wextra -g -fsanitize=address    program.c -o program_asan
gcc -std=c11 -Wall -Wextra -g -fsanitize=undefined  program.c -o program_ubsan

有一个环境兼容问题值得先知道:ThreadSanitizer(-fsanitize=thread)在部分系统上会因与 ASLR 冲突而启动失败,报 FATAL: ThreadSanitizer: unexpected memory mapping。解决办法是关闭地址随机化后运行:

setarch $(uname -m) -R ./program_tsan

第三层:养成"边界自己算"的习惯。 凡是把数组传给函数,同时把长度传过去;凡是索引来自外部输入,先校验范围。


十、小结

可以直接带走的结论:

  1. C 的数组没有长度信息,数组名在表达式中会退化成首元素指针。 Java 的 arr.length 在 C 里没有任何对应物,sizeof(a)/sizeof(a[0]) 只在 a 未退化时有效。
  2. 越界不是异常,是未定义行为。 实测中 a[5] = ... 既没编译警告也没运行报错,-Wall -Wextra 和 -O2 都放行,程序正常退出——但 ASan 精确指出了"偏移 52 越过了 [32, 52) 的 a"。没有症状不等于正确。
  3. 只有静态存储期的数组才保证零初始化。 局部数组的内容是不确定值,malloc 也不清零(calloc 才清)。
  4. 长度必须显式传递。 传参后 sizeof 得到的是指针大小,实测中长度 5 会静默变成 2。GCC 为此专设了 -Wsizeof-array-argument 警告,说明这是高频错误。
  5. C 只有值传递。 想修改调用方的数据就必须传指针,swap(int, int) 什么也改不了;而指针本身也是按值传递的。
  6. 随机数只用 srand 播种一次。 time(NULL) 精度是秒,放进循环会让 rand() 反复返回同一个值。安全性要求高时用 getrandom(2)。
  7. n 次随机交换不是洗牌。 实测 100 万次:rand()%n 全范围取下标时,元素落在位置 0 的概率是 0.2932 而非 0.25;Fisher–Yates 把取值区间逐轮缩小,实测全部落在 0.2492~0.2510。用 std::shuffle 则两个坑都不会踩。
  8. C 的二维数组是行主序的一整块内存,不是数组的数组。 int a[3][5] 退化成 int (*)[5],不是 int **;传给函数时列数必须写在形参里。
  9. 不规则二维有三条路:指针数组逐行 malloc(不连续、逐行释放)、一维扁平数组加索引换算(连续、一次释放)、真二维数组取最大列数(最简单)。性能敏感选中间那条。
  10. 用工具而不是用"跑一遍没崩"来验证。 -Wall -Wextra + ASan + UBSan,这三样能覆盖 C 数组绝大多数静默缺陷。

C/C++ 编译链接机制与语言基础详解

引言

C/C++ 是系统软件与嵌入式开发的主力语言。但"代码能编译、能运行"和"清楚编译器到底做了什么",中间往往还隔着不小的距离:一条 gcc hello.c -o hello 命令背后依次调用了预处理器、编译器、汇编器和链接器四个独立程序,而很多"编译通过但行为异常"的问题,最后都能定位到这四步里的某一步。

这类认知空白在写功能时通常不会暴露,要等到开始做性能优化、排查诡异的运行结果,或者把同一份代码从 x86 换到 ARM 上编译——问题才会集中出现。

这篇文章从 GCC 工具链入手,结合真实的编译产物,把 C/C++ 的编译链接机制与语言基础梳理一遍。内容分四部分:

  1. 语言定位与工具链(第一、二节):C 与 C++ 的标准沿革、工具链组成、关键编译选项;
  2. 从源码到可执行文件(第三、四节):翻译单元、声明与定义分离、预处理到链接的四个阶段;
  3. 语言基础(第五节):注释、字面量、变量与存储期、数据类型、关键字与标识符;
  4. 工程实践(第六节):编译期警告体系与运行期 Sanitizer 的落地方式。

本文所有示例均在 Linux 6.8 / GCC 11.4.0 / glibc 2.35(x86-64,LP64)环境下实际编译运行,输出为真实结果。编译命令统一为 gcc -std=c11 -Wall -Wextra,C++ 示例为 g++ -std=c++17 -Wall -Wextra。


一、语言定位与标准沿革

1.1 设计初衷

C 由贝尔实验室的 Dennis Ritchie 在 1972 年前后设计。它的出发点非常具体:为了用一门高级语言重写 Unix,而这门语言必须能够直接操作内存与硬件、生成接近汇编效率的代码。因此 C 的设计哲学是——语言本身保持极小,把复杂的功能交给库;把内存管理、边界检查、类型转换的全部责任交给程序员。

C++ 由 Bjarne Stroustrup 于 1979 年开始开发,早期名为 "C with Classes",1983 年正式更名 C++。它的目标是"在不牺牲运行效率的前提下,把抽象能力带到系统级编程中"——即著名的"零开销抽象(zero-overhead abstraction)"原则:不使用某特性时,不为它付出任何代价。

这两种定位决定了后续所有差异:C 追求机制最小化,C++ 追求在保持性能的前提下提高表达能力。

1.2 标准化历程

语言标准编号主要版本
CISO/IEC 9899C90 / C99 / C11 / C17 / C23
C++ISO/IEC 14882C++98 / C++03 / C++11 / C++14 / C++17 / C++20 / C++23

C 语言各版本引入的关键内容:

标准引入内容
C90第一个国际标准
C99// 注释、long long、变长数组(VLA)、指定初始化器、<stdint.h>、<stdbool.h>
C11_Bool、_Static_assert、_Generic、_Thread_local、线程库 <threads.h>
C17缺陷修正,无新语言特性
C23nullptr、constexpr、typeof、_BitInt、二进制字面量

需要说明的是,编译器的默认方言通常不是纯 ISO 标准。以本机为例,gcc foo.c 未指定 -std= 时使用 -std=gnu17,即在 C17 基础上附加 GNU 扩展。凡是要长期维护或移植的代码,都应显式指定标准版本。

1.3 C 与 C++ 的关系:兼容但不包含

" C++ 是 C 的超集"这个说法在工程实践中会造成实际误导。准确的表述是:C++ 在设计上尽量与 C 保持源级兼容,但并不完全包含 C 的所有构造,且同一段代码在两种语言中的语义可以不同。

两个具体的例子:

其一,字符常量的类型不同。 同一行 sizeof('A'),在 C 与 C++ 下的结果不同:

gcc -std=c11  :  sizeof('A') = 4
g++ -std=c++17:  sizeof('A') = 1

依据是 C11 §6.4.4.4p10:C 中字符常量的类型是 int;而 C++ 中字符常量的类型是 char。

其二,指定初始化器。 int a[5] = {[2] = 30}; 是 C99 语法,C++ 直到 C++20 才引入,且语义有关键约束(C++ 的指定初始化器必须按声明顺序书写,且不能嵌套)。

因此,"把 .c 文件改个后缀当 .cpp 编译就能跑"这一假设并不成立。

1.4 执行环境:hosted 与 freestanding

C 标准把一致性实现分为两类(C11 §4p6),这一区分直接决定可用哪些头文件和函数,在嵌入式开发中是必须掌握的前提:

  • 宿主环境(hosted environment):有完整的 C 标准库和操作系统支撑,桌面与服务器程序均属此类;
  • 独立环境(freestanding environment):无操作系统,标准只保证提供极少数头文件——<float.h>、<iso646.h>、<limits.h>、<stdalign.h>、<stdarg.h>、<stdbool.h>、<stddef.h>、<stdint.h>、<stdnoreturn.h>——且程序启动流程由实现自行安排,不要求提供 main 的调用。

裸机固件、单片机程序、RTOS 上的应用都属于 freestanding。GCC 中对应的选项是 -ffreestanding。这不是学术概念:在 freestanding 环境下调用 printf、malloc 是否能链接成功,完全取决于目标平台是否提供了这些函数的实现。


二、编译工具链

2.1 工具链的组成

理解 C/C++ 构建过程的关键在于:命令行中的 gcc 本身不执行任何编译工作,它是一个驱动程序(driver)——读取命令行参数,然后依次调用下面几个真正干活的程序。

程序可执行文件输入 → 输出职责
预处理器cpp / cc1 -E.c → 翻译单元 .i展开 #include 与 #define、执行条件编译、删除注释
编译器cc1 / cc1plus.i → 汇编 .s语法语义分析、优化、生成汇编
汇编器as.s → 目标文件 .o汇编翻译为机器码,产出可重定位目标文件
链接器ld(经 collect2 调用)多个 .o + 启动代码 + 库 → 可执行文件符号解析、地址重定位、拼装最终产物

用 gcc -v 可以把 driver 的调度过程打印出来。以下是本机输出的关键两行:

 as -v --64 -o /tmp/cc4dGq0b.o /tmp/cc4eY26n.s
GNU assembler version 2.38 (x86_64-linux-gnu) using BFD version

 /usr/lib/gcc/x86_64-linux-gnu/11/collect2 -plugin ... -o hello ... \
     Scrt1.o crti.o crtbeginS.o /tmp/cc4dGq0b.o -lgcc -lgcc_s -lc ... crtendS.o crtn.o

可以看到,driver 先调 as 将汇编转为 .o,再调 collect2(其内部调用 ld)把 .o、启动代码与库拼装成可执行文件。注意链接命令行里出现的 Scrt1.o、crti.o、crtbeginS.o——这些启动代码正是"程序为什么能从 main 开始执行"的答案:在 main 之前,还有一段代码负责建立运行环境。

工具链还依赖以下组件:

  • binutils:提供 as、ld、objdump、nm、readelf、strings 等底层工具;
  • C 标准库:Linux 上通常为 glibc,嵌入式环境常用体积更小的 musl;
  • C++ 标准库:libstdc++(GCC 默认)或 libc++(Clang 默认)。

2.2 gcc 与 g++ 的差异

这两个驱动的差异集中在两点:

  • 语言判定:gcc 依据文件扩展名选择语言——.c 按 C 编译,.cpp 按 C++ 编译;g++ 一律按 C++ 编译,即使文件后缀是 .c。
  • 链接阶段:gcc 默认不链接 C++ 标准库;g++ 会自动加上 -lstdc++。

第二点会直接导致链接失败。实测用 gcc 编译一个使用了 std::cout 的 .cpp 文件:

$ gcc -std=c++17 hello.cpp -o hcpp_gcc
hello.cpp:(.text+0x15): undefined reference to `std::cout'
/usr/bin/ld: hello.cpp:(.text+0x1d): undefined reference to
    `std::basic_ostream<char, std::char_traits<char> >& std::operator<< ...'
/usr/bin/ld: hello.cpp:(.text+0x2f): undefined reference to
    `std::ostream::operator<<(std::ostream& (*)(std::ostream&))'

编译阶段通过了(语法正确),失败发生在链接阶段——链接器找不到 std::cout 等符号的定义,因为它们位于 libstdc++,而 gcc 未将其纳入链接。结论:编译 C++ 统一使用 g++。

2.3 关键编译选项

类别选项说明
标准-std=c11 / -std=c++17指定语言标准版本(方言)
阶段控制-E / -S / -c分别停在预处理 / 汇编 / 目标文件阶段
输出-o <file>指定产物名
优化-O0 ~ -O3 / -Os / -Og优化等级;-Os 优化体积,-Og 面向调试
警告-Wall -Wextra打开常用警告(强烈建议默认开启)
严格性-pedantic-errors把标准要求的告警升级为错误
转换-Wconversion / -Wsign-conversion暴露隐式窄化与符号转换
调试-g生成调试信息(DWARF)
检测-fsanitize=address,undefined插入运行期检查代码
头文件-I <dir>追加头文件搜索路径
库-L <dir> / -l<name>库搜索路径 / 指定链接的库
宏-D<macro>[=value]命令行定义宏
独立环境-ffreestanding面向裸机环境编译

另外有两个环境变量供构建系统读取:CC(C 编译器)与 CXX(C++ 编译器)。CMake、Make 等工具会优先读取它们,这是在不修改脚本的前提下切换工具链的标准做法。

2.4 环境校验

gcc --version           # 版本信息
gcc -dumpversion        # 仅输出版本号
gcc -dumpmachine        # 目标平台三元组

第三条输出的是目标三元组(target triple),格式为 <架构>-<厂商>-<系统/ABI>。本机实测:

$ gcc -dumpmachine
x86_64-linux-gnu

这个字符串说明编译器为目标平台 x86-64 生成代码。交叉编译器的三元组形如 arm-linux-gnueabihf,是判断"当前在为哪个平台构建"最直接的依据。

本文验证环境实测:

Linux 6.8.0-138-generic x86_64
gcc (Ubuntu 11.4.0-1ubuntu1~22.04.3) 11.4.0
g++ (Ubuntu 11.4.0-1ubuntu1~22.04.3) 11.4.0
GNU ld (GNU Binutils for Ubuntu) 2.38
ldd (Ubuntu GLIBC 2.35-0ubuntu3.15) 2.35

三、工程组织基础

3.1 编辑器与 IDE 选型

工具类型适用场景
VS Code + C/C++ 扩展 / clangd编辑器 + 扩展跨平台通用,当前最主流的组合
CLionIDEJetBrains 出品,原生支持 CMake
Visual StudioIDEWindows 平台体验最佳,自带 MSVC
Vim / Neovim / Emacs编辑器Linux 内核、GCC 等项目的实际主流
Qt Creator / Eclipse CDTIDEQt 项目 / 传统 C++ 工程

需要明确的是一点:C/C++ 的编辑器与 IDE 只是外壳(front end),真正执行编译的是外部工具链。 因此必须在编辑器中显式配置编译器路径、头文件搜索目录与构建方式。这与"安装即完整环境"的托管型语言有本质区别,也是 C/C++ 环境搭建中最容易卡住的环节。

3.2 翻译单元:编译模型的基本单位

翻译单元(translation unit,TU)是编译的独立单位,其定义是:一个 .c / .cpp 源文件,加上它通过 #include 引入的所有头文件,经预处理展开之后形成的整体。

由此产生三个必须建立的认识:

  1. 每个 TU 单独编译成一个 .o,TU 之间在编译期互不知情;
  2. 一个头文件被多少个源文件包含,就会被展开多少次——这是 C/C++ 项目编译耗时的主要来源;
  3. TU 之间的连接只能在链接阶段通过符号完成,这是"未定义引用"这类错误的根源。

3.3 声明与定义分离

C/C++ 的工程惯例是声明放头文件、定义放源文件:

  • foo.h:放置 #include、宏、类型声明、函数原型(函数签名,不含函数体);
  • foo.c:放置函数体;
  • 使用方 #include "foo.h",只能获得声明;真正的定义在链接阶段从 foo.o 中解析。

这套手工机制需要配合两条纪律:

(1)头文件保护。 同一个头文件被重复包含时会导致重定义错误,标准解法是 include guard:

#ifndef PROJECT_MODULE_FOO_H
#define PROJECT_MODULE_FOO_H
/* 声明内容 */
#endif

部分编译器支持 #pragma once,写法更简洁,但它不是 ISO 标准的一部分——在可移植性要求高的代码中应使用 include guard。

(2)static 与 extern 控制可见性。 文件作用域下用 static 声明的函数或变量只在本 TU 内可见(内部链接),是 C 语言控制"模块私有性"的标准手段;extern 声明表示"该符号在其他 TU 中定义"。这两种链接属性的细分(外部链接 / 内部链接 / 无链接)是 C 相比多数托管语言独有的机制。

3.4 构建系统与编译数据库

工程规模稍大就必须引入构建系统。当前的事实标准是 CMake,它生成 Makefile 或 Ninja 文件,屏蔽平台差异;常见替代方案有 Make(传统)、Meson、Bazel(大型单体仓库)。

此外有一个容易被忽略的实践:生成编译数据库。CMake 加 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON 会产出 compile_commands.json,其中记录了每个源文件的确切编译命令。clangd 等语言服务器依赖它来正确解析代码——在宏密集的 C/C++ 项目中,缺少这份文件会直接导致跳转与补全大面积失效。这是 C/C++ 开发环境配置中最常见的"工具不听话"问题。


四、从源码到可执行文件

4.1 源码

新建文件 hello.c:

#include <stdio.h>

int main(void)
{
    printf("Hello, World!\n");
    return 0;
}

几个关键点:

#include <stdio.h> 是预处理指令,不是语法层面的模块导入。 它的语义是"将 stdio.h 的内容原样插入到本行位置",是纯文本操作。stdio.h 中声明了 printf 等标准输入输出函数的原型——返回类型与参数类型,不含函数体。

main 是程序的入口点。 其签名为:

  • int main(void):不接受参数;
  • int main(int argc, char *argv[]):接受命令行参数,argc 为参数个数,argv 为参数数组。

返回值是进程退出状态码(exit status),由父进程或 shell 读取(echo $? 即为该值),惯例为 0 表示成功、非 0 表示失败。C++ 标准明确要求 main 的返回类型为 int;写成 void main() 在部分编译器上可以编译,但不符合标准。

4.2 编译与运行

gcc -std=c11 -Wall -Wextra hello.c -o hello
./hello

输出:

Hello, World!

4.3 阶段一:预处理

gcc -std=c11 -E hello.c -o hello.i

实测产物:

-rw-rw-r--  13875  hello.i

hello.i 共 549 行,而源文件只有 7 行,多出来的 540 余行全部来自 #include <stdio.h> 的展开。源文件里自己写的那几行落在文件末尾:

545:int main(void)
546-{
547-    printf("Hello, World!\n");
548-    return 0;
549-}

这一步的语义由 C11 §5.1.1.2 规定的翻译阶段(translation phases)界定。其中与本阶段相关的两条值得注意:

其一,注释在阶段 3 就被处理,早于预处理指令的执行(阶段 4)。 标准原文:

源文件被分解为预处理记号与空白字符序列(包括注释)。……每个注释被替换为一个空格字符。

注意是替换为一个空格,而非删除。这一点可以用一个实验直接验证——int/**/x = 7; 能够正常编译并返回 7,说明它等价于 int x = 7; 而不是 intx = 7;:

$ gcc -std=c11 -Wall -Wextra sp.c -o sp && ./sp; echo $?
7

其二,注释在预处理输出中已经不存在。 实测:源文件中有两处中文注释,预处理后:

$ grep -c "注释" cmt.i
0

原位置只留一个空行。由此得到两个工程结论:

  • /* */ 不能嵌套。 第一个 */ 即结束注释,后续内容会被解析为代码。若要批量屏蔽代码,应使用条件编译:

    #if 0
        /* 此处的内容不会参与编译,包含块注释与非法语法均无妨 */
    #endif

    #if 0 在阶段 4 处理,晚于注释移除,因此内部内容不受干扰。实测这段同时含块注释与语法错误(int bad = ;)的代码可以正常编译运行。

  • // 会吞掉本行之后的所有内容,包括本应保留的代码。

4.4 阶段二:编译

gcc -std=c11 -S hello.c -o hello.s

实测产物 668 字节,删去调试伪指令后的主体如下:

    .section    .rodata
.LC0:
    .string    "Hello, World!"
    .text
    .globl    main
    .type    main, @function
main:
    pushq    %rbp
    movq    %rsp, %rbp
    leaq    .LC0(%rip), %rax
    movq    %rax, %rdi
    call    puts@PLT
    movl    $0, %eax
    popq    %rbp
    ret

有一处细节值得展开:源码写的是 printf,生成的却是对 puts 的调用。 这不是笔误。printf 在 GCC 中是内建函数(builtin),编译器理解其语义;当格式化串为单个字符串且以 \n 结尾时,printf(s) 与 puts(s) 的可见行为完全等价,而 puts 实现更轻量,编译器因此直接替换。

这条优化的意义在于说明一个原则:编译产物与源码并非一一对应。 在性能分析与故障排查时,不能仅凭阅读源码推断实际执行的指令序列——必须结合 -O 等级与反汇编产物判断。

4.5 阶段三:汇编

gcc -std=c11 -c hello.c -o hello.o

实测产物 1504 字节。hello.o 是可重定位目标文件(relocatable object file):其内部指令中的地址尚未最终确定,例如调用 puts 的指令在 objdump -d 中显示为 call 17 <main+0x17> 这类占位形式,实际目标地址留待链接阶段填入。

"可重定位"是目标文件的核心属性,也是链接器存在的意义。

4.6 阶段四:链接

gcc hello.o -o hello

实测产物 15960 字节。链接器将目标文件、启动代码与库合并,完成符号解析与地址重定位。

四个阶段的产物大小对照:

阶段产物大小
预处理hello.i13875 字节
编译hello.s668 字节
汇编hello.o1504 字节
链接hello15960 字节

4.7 产物分析:文件格式与运行期依赖

链接产物可以用两个工具描述清楚:

$ file hello
hello: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
       dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
       BuildID[sha1]=e6712e0c..., for GNU/Linux 3.2.0, not stripped

$ ldd hello
    linux-vdso.so.1 (0x00007fff0d374000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000074ae4c600000)
    /lib64/ld-linux-x86-64.so.2 (0x000074ae4c9a9000)

输出中包含三条关键信息:

(1)ELF 64-bit LSB pie executable, x86-64 —— 文件格式为 ELF(Executable and Linkable Format),64 位,小端(LSB),目标指令集 x86-64,且为 PIE(Position Independent Executable,位置无关可执行文件)。PIE 是当前发行版的默认配置,配合内核的地址空间随机化(ASLR)提升安全性。

(2)interpreter /lib64/ld-linux-x86-64.so.2 —— 这是动态链接器的路径。程序被执行时,内核先将控制权交给它,由它完成动态库的加载与符号绑定。

(3)libc.so.6 —— 程序运行期依赖 glibc,printf 最终调用的 puts 即位于其中。ldd 的名字来自 "list dynamic dependencies",列出的是运行期动态库依赖。在部署环节,ldd 是排查"本机可用、目标机报错"这类问题的第一个命令。

4.8 C++ 版本

#include <iostream>

int main()
{
    std::cout << "Hello, World!" << std::endl;
    return 0;
}
g++ -std=c++17 -Wall -Wextra hello.cpp -o hellocpp
./hellocpp

三点先建立印象,细节留待后续文章展开:

  • #include <iostream> 引入的是 iostream 头文件。C++ 标准库头文件不带 .h 后缀(带后缀的 <iostream.h> 是 1998 年标准之前的写法);
  • std::cout 是标准输出流对象,std:: 是命名空间(namespace)限定符。命名空间是 C++ 用于名字隔离的机制;
  • << 在此处不是移位运算符,而是重载后的运算符,语义为"将右侧数据写入左侧流"。

4.9 关于跨平台

结合前面的产物分析,可以给出一个准确的结论:C/C++ 编译产物与目标平台绑定。

file 的输出 ELF 64-bit LSB pie executable, x86-64 已经说明:这个可执行文件既包含 x86-64 的机器指令,也采用 Linux/ELF 的容器格式。换到 ARM 平台上,指令集与文件格式双重不兼容,该文件完全无法运行。

因此 C/C++ 的跨平台方式不是"一次编译、处处运行",而是为每个目标平台分别编译。为目标平台编译的两种途径:

  • 本机编译:直接在目标平台上编译;
  • 交叉编译(cross-compilation):在一种平台上生成另一种平台的可执行文件,例如在 x86-64 主机上使用 arm-linux-gnueabihf-gcc 为 ARM 开发板构建。这是嵌入式开发的标准做法,也是 C/C++ 工具链管理(与构建系统配置)成本显著高于托管语言的直接原因。

五、语言基础

5.1 注释

C/C++ 提供两种注释形式:

  • 块注释:/* ... */,可跨行,C90 起支持;
  • 行注释:// ...,自 // 至本行末尾,C 语言从 C99 起支持,C++ 从第一版即支持。

// 在 C 标准中的引入时间较晚,这一点具有实际意义。用严格模式编译同一段使用 // 的代码:

$ gcc -std=c89 -pedantic-errors cmt89.c -o cmt89_x
cmt89.c:4:5: error: C++ style comments are not allowed in ISO C90
    4 |     // C89 没有这个语法
      |     ^

注释的去向已在 4.3 节说明:在翻译阶段 3被替换为一个空格,早于预处理指令的执行。因此注释既不出现在 .i 文件中,也不出现在任何编译产物中。

文档注释方面,C/C++ 生态的标准工具是 Doxygen,语法上使用 /** ... */ 或 ///,配合 @param、@return 等标签。它同样只是注释,由 Doxygen 这一外部工具提取,与编译器无关。

5.2 字面量

字面量(literal)是在源码中直接写出其值的记号。各类字面量的类型由语言规则决定——不由它看起来像什么决定。

类别写法示例C 中的类型C++ 中的类型
整数(十进制)100intint
整数(八进制)0144intint
整数(十六进制)0x64intint
整数(二进制)0b1100100C23 起C++14 起
浮点13.14doubledouble
浮点(float)13.14ffloatfloat
字符'A'int(4 字节)char(1 字节)
字符串"abc"char[4](数组)const char[4]
布尔true / falseint(来自 <stdbool.h>)bool(关键字)
空指针NULLvoid * 或 00(C++11 起用 nullptr)

实测验证:

=== gcc -std=c11 ===
sizeof(char)      = 1
sizeof('A')       = 4   <- 字符常量的类型
sizeof("abc")     = 4   <- 字符串字面量含结尾 '\0'
sizeof("")        = 1   <- 空串只含一个 '\0'
中文字面量 "中" 的字节数 = 4
sizeof(true)      = 4

=== g++ -std=c++17 ===
sizeof('A')       = 1
sizeof(true)      = 1

逐条说明:

(1)字符常量的类型在 C 与 C++ 中不同。 C 中为 int(4 字节),C++ 中为 char(1 字节),依据分别是 C11 §6.4.4.4p10 与 C++ 标准中字符常量的类型规定。这是第 1.3 节所述"同一代码两种语义"的典型实例。

(2)C 中没有独立的字符串类型。 字符串是以 '\0' 结尾的字符数组。"abc" 的类型是 char[4]——三个字符加一个 '\0'。空串 "" 是 char[1]。

这是整个 C 字符串处理体系的根基,它决定了三件事:字符串不能直接用 == 比较(比较的是地址);长度为 n 的字符串需要 n+1 字节存储;字符串操作必须依赖 strlen、strcpy 等库函数。C++ 中的 std::string 是对这一底层模型的封装,其内部表示仍以空字符结尾的字符序列为基础。

(3)单引号与双引号的区别具有严重后果。 'A' 的类型是 int,"A" 的类型是 char[2]。若将 'A' 传给 %s 格式说明符,printf 会把整数 65 当作地址解引用,属未定义行为,通常导致段错误。

(4)sizeof 返回的是字节数,不是字符数。 实测 sizeof("中") = 4——源文件为 UTF-8 编码,该汉字占 3 字节,加结尾 '\0' 共 4 字节。统计"字符个数"必须按编码规则处理,sizeof 无法胜任。

(5)整数默认为 int,浮点默认为 double。 这是两条重要的默认规则:

int    a = 100;          /* int */
long   b = 100L;         /* 后缀 L */
float  f = 13.14f;       /* 必须加 f,否则 13.14 是 double */
double d = 13.14;        /* 默认 double */

float f = 13.14; 不会编译报错,但会静默发生一次 double → float 的窄化转换并损失精度。这类"编译通过但结果错误"的问题,正是第 6.1 节 -Wconversion 要解决的。

(6)true / false 在 C 与 C++ 中的性质不同。 C 中 <stdbool.h> 把 bool 定义为 _Bool 的宏,true / false 定义为 1 / 0 的宏——它们是预处理宏,不是关键字;C++ 中三者均为关键字。实测 sizeof(true):C 中为 4(int),C++ 中为 1(bool)。

5.3 变量与存储期

C 标准对对象(object)的定义(C11 §3.15)是:

执行环境中的一块数据存储区域,其内容可以表示值。

变量就是对象的名字。 更准确地说:变量名是访问某块存储区域的标识符,而类型决定这块区域的大小与其中比特位的解释方式。

int    age   = 18;      /* 定义并初始化 */
int    count;           /* 仅定义 */
count = 0;              /* 赋值 */

5.3.1 未初始化变量的值由存储期决定

这是 C/C++ 中最容易被低估的一处风险。一个未显式初始化的对象具有什么值,取决于它的存储期(storage duration)——即该对象从分配到释放的时间区间:

存储期典型对象未显式初始化时的值
静态存储期(static)全局变量、static 变量保证为零
自动存储期(automatic)函数内局部变量不确定值(indeterminate value)
动态存储期(allocated)malloc 返回的空间不确定值(calloc 才清零)

读取一个具有自动存储期且未初始化的对象,属于未定义行为。依据链条为:

  • C11 §3.19.2 定义不确定值:或为一个未指定值(unspecified value),或为一个陷阱表示(trap representation);
  • C11 §6.7.9p10 规定:具有自动存储期且未显式初始化的对象,其值为不确定值;
  • C11 附录 J.2(未定义行为清单)明确列出"使用了具有自动存储期且值不确定的对象"。

实测(uninit.c):

#include <stdio.h>

int global_v;          /* 静态存储期,自动为零 */

int main(void)
{
    int local_v;       /* 自动存储期,未初始化 */

    printf("global_v = %d\n", global_v);
    printf("local_v  = %d\n", local_v);

    return 0;
}

同一个可执行文件连续运行两次:

$ gcc -std=c11 -O0 uninit.c -o u0
$ ./u0
global_v = 0
local_v  = 22745
$ ./u0
global_v = 0
local_v  = 23592

而用 -O2 编译时,local_v 恰好为 0:

$ gcc -std=c11 -O2 uninit.c -o u2 && ./u2
global_v = 0
local_v  = 0

这就是"不确定值"的实际形态:同一程序、同一机器,两次运行得到两个不同结果;切换优化等级,结果又变。它既非"随机值",也非"必然是垃圾值"——它是编译器不受任何约束的状态。编译器可以假定未定义行为不会发生,并据此做优化,因此结果不可预测。

-Wall -Wextra 对这种最直接的情形能够告警:

$ gcc -std=c11 -Wall -Wextra -O0 uninit.c -o /dev/null
uninit.c: In function ‘main’:
uninit.c:10:5: warning: ‘local_v’ is used uninitialized [-Wuninitialized]

但该告警依赖静态分析,仅在编译器能完整推导"未初始化"到"被使用"的路径时生效。中间隔一次函数调用、一次指针传递或一次分支,告警即会消失。 因此 -Wuninitialized 是辅助手段而非保障。可靠做法只有一条:局部变量一律显式初始化。

int a = 0;
double buf[64] = {0};                 /* 整个数组清零 */
char text[256];
memset(text, 0, sizeof text);         /* 需要 <string.h>,适用于大块内存 */

5.3.2 声明位置

C 标准对声明位置的规定存在历史变化:

  • C90:所有声明必须位于块的开头,在任何语句之前;
  • C99 起:放宽,允许与语句混排;
  • C++:从一开始即允许。

实测同一段"声明与语句混排"的代码:

$ gcc -std=c89 -pedantic-errors mix.c -o mix_x
mix.c:5:5: error: ISO C90 forbids mixed declarations and code
                 [-Wdeclaration-after-statement]
(退出码 1)

$ gcc -std=c11 -pedantic-errors mix.c -o mix_y
(退出码 0)

维护历史代码时,这条差异是判断源码可用标准的重要线索。

5.3.3 作用域与链接

除块作用域(block scope)、文件作用域(file scope)、函数原型作用域之外,C 还有两个概念需要单独掌握:

  • 存储类说明符(storage-class specifier):static、extern、register、typedef。其中 static 在文件作用域表示该符号仅在本 TU 可见(内部链接),在块作用域表示该存储的生命期贯穿整个程序、且只初始化一次;
  • 链接(linkage):外部链接 / 内部链接 / 无链接,决定一个名字能否被其他 TU 引用。这是 C 的可见性控制机制。

5.4 数据类型

5.4.1 类型体系

C 的类型分类(C11 §6.2.5):

  • 基本类型(basic types):char、有符号与无符号整型、浮点类型;
  • 枚举类型:enum;
  • void:表示无值;
  • 派生类型(derived types):数组、指针、函数、结构体、联合体。

C++ 在此基础上增加了引用类型与类类型,并把 bool 提升为独立基本类型。

5.4.2 标准不规定整数宽度

这是 C/C++ 类型系统中最需要建立的认识:C 标准不规定整数类型的确切字节数,只规定最小范围与相对大小关系(C11 §5.2.4.2.1):

类型标准保证的最小范围保证的最小位宽
char−127 ~ 1278
short−32767 ~ 3276716
int−32767 ~ 3276716
long−2147483647 ~ 214748364732
long long−9223372036854775807 ~ 922337203685477580764

注意 int 的最小保证只有 16 位,标准允许一个 int 为 2 字节的实现——在 16 位的编译器中它确实是 2 字节。因此,源码中出现 int 时,无法从标准推断它占多少字节。

需要确定宽度时,必须使用 <stdint.h>(C99 起)提供的定宽整型:int8_t、uint16_t、int32_t、uint64_t 等。凡是与硬件寄存器、网络协议、文件格式、持久化数据打交道的代码,都应使用定宽类型。 这是工程规范而非偏好。

另外,long 的宽度在不同平台上一致性问题尤其突出:

数据模型平台intlong指针
LP64Linux / macOS488
LLP64Windows448

long 在 Linux 上为 8 字节、在 Windows 上为 4 字节——这是跨平台代码必须避免使用 long 而改用 int64_t 的直接原因。

5.4.3 实测尺寸与范围

本机(x86-64,LP64,GCC 11.4.0)实测:

-------- 整型 --------
char      sizeof = 1  CHAR_MIN=-128  CHAR_MAX=127
short     sizeof = 2  -32768 ~ 32767
int       sizeof = 4  -2147483648 ~ 2147483647
long      sizeof = 8  -9223372036854775808 ~ 9223372036854775807
long long sizeof = 8  -9223372036854775808 ~ 9223372036854775807
-------- 浮点 --------
float  sizeof = 4  FLT_DIG=6  FLT_MAX=3.40282e+38
double sizeof = 8  DBL_DIG=15  DBL_MAX=1.79769e+308
-------- C99 起才有 --------
_Bool  sizeof = 1
long double sizeof = 16

几点说明:

sizeof(char) == 1 是定义而非巧合。 C11 §6.5.3.4 规定 sizeof 作用于 char、signed char、unsigned char 时结果为 1。sizeof 的计量单位就是"一个 char 的大小"。

sizeof 是运算符,不是函数。 它在编译期求值,结果为 size_t(无符号整型),打印应使用 %zu。写成 int n = sizeof(a); 在部分平台会触发有符号与无符号比较的告警。

long double 在本机为 16 字节。 x86-64 上 GCC 采用 80 位扩展精度格式(x87),对齐后占 16 字节。它不属于 IEEE-754 标准格式,各平台实现差异很大(ARM 上通常与 double 相同),可移植性差。

FLT_DIG / DBL_DIG 表示"能保证的十进制有效位数":float 为 6 位,double 为 15 位。float 与 double 遵循 IEEE-754,这是数值语义在各平台间一致性最高的部分。

_Bool 是 C99 引入的关键字,sizeof 为 1,通过 <stdbool.h> 封装为 bool。C++ 中 bool 自第一版即为关键字。

5.4.4 有符号与无符号

C/C++ 提供了完整的无符号整型。使用中需要注意标准规定的通常算术转换(usual arithmetic conversions):有符号与无符号操作数参与同一运算时,有符号操作数会被转换为无符号。

unsigned int a = 1;
int b = -2;

if (a + b > 0)            /* 该条件成立 */
    puts("意外结果");

a + b 中的 -2 被转换为无符号值(2³² − 2),相加结果是一个极大的正数,条件成立。这是"无符号陷阱"的典型形式。

另一类常见缺陷是无符号自减导致循环不终止:

for (size_t i = n - 1; i >= 0; --i) { /* ... */ }   /* n == 0 时永不退出 */

size_t 为无符号类型,n - 1 在 n == 0 时下溢为 SIZE_MAX,而 i >= 0 对无符号数恒为真。

工程建议:尽量避免有符号与无符号混算;确实需要无符号语义时统一类型;开启 -Wsign-compare、-Wsign-conversion 使隐式转换显式暴露。

5.4.5 char 的符号性由实现决定

C11 §6.2.5p15 规定:编译器必须使 char 的行为等同于 signed char 或 unsigned char 之一,具体选择由实现决定。 本机实测 CHAR_MIN = -128,说明此处的 char 为有符号。

这一条的实际影响是:凡表示"字节数据"的场合(二进制缓冲区、UTF-8 文本、协议报文),都应使用 unsigned char 或 uint8_t。 使用 char 表示字节时,在 char 为有符号的平台上,大于 127 的字节会变为负数,参与运算时结果与预期不符。

5.5 关键字与标识符

5.5.1 关键字

关键字(keyword)是语言预定义的、具有固定语法含义的标识符,不能用作普通名字。

C11 §6.4.1 列出了 44 个关键字(C90 为 32 个,C99 增至 37 个)。C++ 的关键字数量随标准增长,C++20 已接近 90 个。C 与 C++ 在关键字层面的主要差异:

关键字CC++说明
sizeof有(运算符)有编译期求值
struct / union / enum有有复合类型
static有有链接属性与存储期控制
const有有C++ 中参与重载决议,语义更强
register / extern / typedef / volatile有有(register 在 C++17 弃用)
class / namespace / template无有C++ 的抽象机制
_Bool / _Atomic / _Thread_local有无(C++ 用 bool / atomic / thread_local)命名约定不同

volatile 在嵌入式与并发场景中必须正确理解:它告诉编译器该对象的值可能被程序之外的因素改变,不得优化掉对它的访问。需要注意,volatile 不提供原子性与内存序保证——若需要这些保证,应使用 C11 <stdatomic.h> 中的 _Atomic 类型。

5.5.2 标识符的构成规则

标识符(identifier)是程序员为对象、函数、类型等所起的名字。C11 §6.4.2.1 规定的构成规则为:

标识符由标识符非数字字符开头,后接零个或多个标识符非数字字符或数字。
其中标识符非数字字符可以是:下划线 _、小写字母、大写字母,或一个通用字符名。

即标准允许的字符集为 ASCII 字母、数字与下划线。四条实用规则:

  1. 只能由字母、数字、下划线组成;
  2. 不能以数字开头;
  3. 不能使用关键字;
  4. 区分大小写。

关于 $。 它不是 ISO C 的标识符字符。GCC 默认通过扩展允许它(扩展名为 -fdollars-in-identifiers),因此不关闭扩展时能够编译:

$ gcc -std=c11 ident.c -o ident_y
(编译通过,退出码 0)        <-- 依赖编译器扩展

关闭扩展即暴露问题:

$ gcc -std=c11 -fno-dollars-in-identifiers ident.c -o ident_z
ident.c:3:10: error: stray ‘$’ in program
(退出码 1)

其它特殊字符在任何模式下均被拒绝:

$ gcc -std=c11 bad.c -o bad_x
bad.c:1:21: error: stray ‘#’ in program

"GCC 能编过"不等于"标准允许"。 这是 C/C++ 开发中最需要养成的判断习惯:遇到不熟悉的语法构造,应先确认它属于标准还是编译器扩展。

5.5.3 保留标识符

C 标准(C11 §7.1.3)将一部分名字保留给实现,用户代码不得使用:

  • 以两个下划线开头的标识符(如 __init);
  • 以一个下划线 + 一个大写字母开头的标识符(如 _Foo);
  • 在文件作用域下以一个下划线开头的标识符(如 _counter)。

这解释了系统头文件中大量出现的 _IO_FILE、__builtin_expect 等命名——它们属于实现自身的命名空间。用户代码使用这类命名可能撞上系统内部符号,产生极难定位的冲突。

5.5.4 命名规范

C 社区(Linux 内核、glibc 风格):

  • 函数与变量:小写下划线分隔,如 read_sensor、motor_speed;
  • 类型:小写加 _t 后缀,如 motor_state_t;
  • 宏与常量:全大写,如 MAX_BUFFER_SIZE。

C++ 社区:

  • 标准库一律小写下划线,如 std::vector、push_back;
  • 多数项目的类型名使用大驼峰,如 MotorController,函数与变量使用小写下划线或小驼峰。

六、工程实践:编译期与运行期检查

C/C++ 与托管语言在错误发现机制上存在结构性差异:后者有运行时异常与字节码验证,前者的大量错误属于未定义行为——编译不报、运行不报,仅表现为结果不符预期。因此必须主动建立一套检测手段。

6.1 编译期:警告与标准选项

第一层:开启警告。 至少 -Wall -Wextra:

gcc -std=c11 -Wall -Wextra -O2 program.c -o program

第二层:显式指定标准。 不要依赖编译器默认方言:

gcc -std=c11 -Wall -Wextra program.c -o program        # 严格 ISO C11
gcc -std=c17 -pedantic-errors program.c -o program     # 拒绝扩展

-pedantic-errors 将标准要求的告警升级为错误,是检查可移植性最直接的手段——5.5.2 节的 $ 标识符与 5.1 节的 C90 行注释正是依靠它查出的。

第三层:打开转换告警。 -Wall -Wextra 不覆盖隐式窄化转换,需要额外开启 -Wconversion -Wsign-conversion。这一点的实际影响可以用一个例子说明:

char c = 200;
printf("%d\n", c);

在 -Wall -Wextra 下编译没有任何告警,程序输出 -56:

$ gcc -std=c11 -Wall -Wextra cv.c -o cv_w
(退出码 0,无告警)
$ ./cv_w
-56

追加 -Wconversion 后问题显式暴露:

$ gcc -std=c11 -Wall -Wextra -Wconversion cv.c -o cv_c
cv.c:5:14: warning: conversion from ‘int’ to ‘char’
                      changes value from ‘200’ to ‘-56’ [-Wconversion]

同类问题还有格式说明符与实参类型不匹配,它由 -Wall 覆盖:

long v = 1234567890123L;
printf("%d\n", v);          /* 应为 %ld */
fmt.c:7:14: warning: format ‘%d’ expects argument of type ‘int’,
                   but argument 2 has type ‘long int’ [-Wformat=]
    7 |     printf("%d\n", v);
      |             ~^     ~
      |              |     |
      |              int   long int
      |             %ld

格式串不匹配不会导致编译失败,但会写出错误数据或读取错误内存。建议:警告不是"最好打开",而是必须打开,并且不允许在告警状态下提交代码。

6.2 运行期:Sanitizer

编译期警告无法覆盖运行期才能确定的问题。GCC / Clang 内置的三个 Sanitizer 是 C/C++ 开发中最关键的运行期检测手段:

选项检测范围
-fsanitize=address缓冲区越界、释放后使用、内存泄漏
-fsanitize=undefined有符号溢出、除零、非法移位、空指针解引用
-fsanitize=thread数据竞争、锁误用

以下两个例子可以说明其价值。

例一:有符号整数溢出。 依据 C11 §3.4.3 与 §6.5p5,有符号溢出是未定义行为。实测:

int a = INT_MAX;
a = a + 1;
printf("%d\n", a);

默认编译没有任何提示,程序照常运行并输出回绕后的值:

$ gcc -std=c11 ub.c -o ub && ./ub
-2147483648

加入 UBSan 后:

$ gcc -std=c11 -Wall -Wextra -g -fsanitize=undefined ub.c -o ub_san && ./ub_san
-2147483648
ub.c:8:7: runtime error: signed integer overflow:
           2147483647 + 1 cannot be represented in type 'int'

注意 UBSan 的输出有两行:先给出回绕结果 -2147483648,再报告运行时错误。 原因在于 UBSan 默认是恢复式(recoverable)的:它报告问题后继续执行,不中断程序。在测试流程中应当配合 -fno-sanitize-recover=all 让它直接中止,以便在 CI 中捕获非零退出码。

例二:释放后使用。 这是实际项目里内存问题的常见形态:

int *p = malloc(sizeof(int) * 4);
free(p);
p[0] = 1;               /* 释放后使用 */

默认编译下程序正常退出,退出码为 0,没有任何异常表现:

$ gcc -std=c11 uaf.c -o uaf && ./uaf && echo "退出码 $?"
退出码 0

加入 ASan 后:

$ gcc -std=c11 -Wall -Wextra -g -fsanitize=address uaf.c -o uaf_asan && ./uaf_asan
=================================================================
==1425561==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
WRITE of size 4 at 0x502000000010 thread T0
    #0 0x5c32d360c233 in main /tmp/cintro2/uaf.c:11
    #1 0x7e7d00429d8f  (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)

0x502000000010 is located 0 bytes inside of 16-byte region
[0x502000000010,0x502000000020)
freed by thread T0 here:
    #0 0x7e7d008b4537 in __interceptor_free ...asan_malloc_linux.cpp:127
    #1 0x5c32d360c1fc in main /tmp/cintro2/uaf.c:9

previously allocated by thread T0 here:
    #0 0x7e7d008b4887 in __interceptor_malloc ...asan_malloc_linux.cpp:145
    #1 0x5c32d360c1de in main /tmp/cintro2/uaf.c:5

SUMMARY: AddressSanitizer: heap-use-after-free /tmp/cintro2/uaf.c:11 in main

ASan 的原理是在每次内存分配的边界插入不可访问的红区(redzone),并记录每个内存块的分配与释放状态;访问命中红区或已释放区域时立即中止并打印完整调用栈。上面的报告同时给出三类信息:越界/释放后使用的发生位置(uaf.c:11)、内存块被释放的位置(uaf.c:9)、内存块最初分配的位置(uaf.c:5)。对于第三方库导致的堆损坏,这三条位置信息往往是最关键的排查线索。

工程落地方案:

# 日常开发:ASan + UBSan 可以合并编译(实测可正常运行)
gcc -std=c11 -Wall -Wextra -g -fsanitize=address,undefined program.c -o program_san

# 需要捕获溢出时直接中止
gcc -std=c11 -g -fsanitize=undefined -fno-sanitize-recover=all program.c -o program_ub

# 多线程场景需单独构建,TSan 与 ASan 的插桩机制冲突
gcc -std=c11 -g -fsanitize=thread program.c -o program_tsan

Sanitizer 会带来性能与内存开销,因此只在测试阶段开启,生产构建不启用。

另有一个环境兼容问题需要注意:ThreadSanitizer 在部分系统上会因与地址空间随机化(ASLR)冲突而启动失败,报 FATAL: ThreadSanitizer: unexpected memory mapping。解决办法是关闭 ASLR 后运行:

setarch $(uname -m) -R ./program_tsan

6.3 产物与依赖检查

前两节针对源码。在部署环节,以下四个命令覆盖了产物层面的主要检查需求:

file program        # 产物格式、位宽、目标架构、PIE 属性
ldd  program        # 运行期动态库依赖
nm   program.o      # 目标文件符号表(排查未定义符号)
objdump -d program  # 反汇编(核对优化结果)

一个典型的应用场景:程序在本机运行正常,部署到目标机报 error while loading shared libraries。此时在目标机上执行 ldd 即可定位缺失或版本不符的动态库——这是 ldd 在部署环节最常见的用途。


七、总结

本文从 GCC 工具链出发,梳理了 C/C++ 的编译链接机制与语言基础,可带走的结论如下:

  1. gcc 是驱动程序,不执行编译。 它依次调用预处理器、编译器(cc1)、汇编器(as)与链接器(ld)。gcc -v 的输出完整展示了这一调度过程,链接命令行中的 Scrt1.o、crti.o 等启动代码解释了"程序为何能从 main 开始执行"。
  2. gcc 与 g++ 的差异在链接阶段。 gcc 默认不链接 libstdc++,编译含 std::cout 的代码会得到 undefined reference to 'std::cout'。编译 C++ 应统一使用 g++。
  3. 注释在翻译阶段 3 被替换为一个空格(C11 §5.1.1.2),早于预处理指令的执行。实测 int/**/x = 7; 可正常编译,证明是"替换为空格"而非"删除";#if 0 能够屏蔽含块注释与非法语法的代码,是因为它在阶段 4 才被处理。
  4. C/C++ 的编译产物与目标平台绑定。 file hello 输出 ELF 64-bit LSB pie executable, x86-64,说明文件中同时包含目标架构的机器码与 ELF 容器格式。跨平台的正确方式是为每个平台分别编译,嵌入式场景通常采用交叉编译。
  5. 编译产物与源码并非一一对应。 源码中的 printf 被编译器替换为 puts@PLT。性能分析与故障排查必须结合优化等级与反汇编结果判断,不能仅凭阅读源码推断。
  6. 字符常量的类型在 C 与 C++ 中不同:C 中为 int(sizeof 为 4),C++ 中为 char(sizeof 为 1)。这是"同一代码、两种语义"的典型实例。
  7. C 中没有独立的字符串类型。 字符串是以 '\0' 结尾的字符数组,sizeof("abc") 为 4。sizeof 返回字节数而非字符数,实测 sizeof("中") 为 4(UTF-8 编码)。
  8. 未初始化变量的值由存储期决定。 静态存储期保证零初始化;自动存储期是不确定值,读取即未定义行为(C11 §3.19.2 + §6.7.9p10 + 附录 J.2)。实测同一可执行文件两次运行分别得到 22745 与 23592,切换 -O2 后变为 0。
  9. 标准不规定整数宽度,只规定最小范围。 int 的最小保证仅 16 位;long 在 Linux 上为 8 字节、Windows 上为 4 字节。与硬件、协议、持久化数据相关的代码必须使用 <stdint.h> 的定宽类型。
  10. char 的符号性由实现决定(C11 §6.2.5p15),本机 CHAR_MIN 为 −128。表示字节数据应使用 unsigned char 或 uint8_t。
  11. $ 不是 ISO C 的标识符字符,GCC 通过 -fdollars-in-identifiers 扩展放行;关闭该扩展后立即报 stray '$' in program。此外 C 标准保留了大量下划线开头的名字(C11 §7.1.3),用户代码不得使用。
  12. 警告体系需要主动配置。 -Wall -Wextra 不覆盖隐式窄化转换,char c = 200 在无告警状态下静默变为 −56,须追加 -Wconversion 才能暴露。
  13. 未定义行为必须依靠运行期工具捕获。 有符号溢出在默认编译下无任何提示,UBSan 可精确报出;释放后使用在默认编译下程序正常退出,ASan 可给出释放位置与分配位置。"跑一遍没崩"不能作为正确性依据。

参考标准

  • ISO/IEC 9899:2011(C11):§3.15 对象、§3.19.2 不确定值、§4 一致性、§5.1.1.2 翻译阶段、§5.2.4.2.1 整型尺寸、§6.2.5 类型、§6.4.1 关键字、§6.4.2.1 标识符、§6.4.4.4 字符常量、§6.5.3.4 sizeof、§6.7.9 初始化、§7.1.3 保留标识符、附录 J.2 未定义行为
  • ISO/IEC 14882(C++):字符常量类型、main 函数签名、翻译阶段
  • GCC 手册:-fdollars-in-identifiers、-Wconversion、Sanitizer 相关选项