xzh 发布的文章

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 相关选项