专栏《Zero to C++》· 单元 05
阅读时间约 15 分钟。本篇跟一遍源码到可执行文件的全程,看 C++ 在这条路上比 C 多了什么。
这一篇解决什么
单元 04 补的是第一层里「内存与指针」那一半。这一半补「代码怎么变成程序」。
四步流水线(预处理、编译、汇编、链接)在 C 和 C++ 里是一样的,属于通用知识。差异出在两处:符号怎么起名 ,和同一份定义出现在多个文件里算不算违规 。理解了这两处,就理解了为什么用 gcc 编译 .cpp 会失败、为什么调 C 库要写 extern "C"。
本篇的所有输出都在本机实跑得到,命令一并给出。
四步流水线
拿一个 6 行的程序:
cpp
// hello.cpp
#include <iostream>
#include <string>
int main() {
std::string name = "cpp";
std::cout << "hello " << name << '\n';
}
它经过四个阶段:
| 阶段 | 命令 | 产物 | 这一步做什么 |
|---|---|---|---|
| 预处理 | g++ -E hello.cpp -o hello.i |
hello.i |
展开 #include 和 #define |
| 编译 | g++ -S hello.cpp -o hello.s |
hello.s |
把 C++ 翻成汇编 |
| 汇编 | g++ -c hello.cpp -o hello.o |
hello.o |
把汇编翻成机器码 |
| 链接 | g++ hello.o -o hello |
hello |
接上标准库,拼成可执行文件 |
实测各阶段的行数和字节数:
6 行 hello.cpp 126 字节
43331 行 hello.i 1106792 字节
1692 行 hello.s 49829 字节
hello.o 25840 字节
6 行的源文件,预处理后变成 43331 行。多出来的部分是 <iostream> 和 <string> 展开进来的------这两个头文件连带引入的内容有四十多 KB 的文本。这一步在 C 里体会不到,因为 C 的 <stdio.h> 小得多。它带来一个实际后果:头文件里多写一行 #include,每个包含它的源文件都要重编译一遍 ,这是 C++ 编译慢的主要原因之一。C++20 的 module 就是为了解决它。
链接是最后一步,也是新手最容易撞墙的一步。前面三步的错误说「你的代码有问题」,链接的错误说「你引用了某个不存在的东西」。
头文件里放什么
C 的惯例是声明放 .h、定义放 .c。C++ 沿用这个习惯,但规则更严格。
头文件守卫。 C 里用 #ifndef / #define / #endif 包住整个头文件,防止同一个头文件被重复展开。C++ 里可以直接写 #pragma once,语义相同、少三行样板。它是编译器的扩展,实践里几乎所有编译器都支持。
ODR,一处定义规则。 全局函数和变量的定义在所有翻译单元里只能出现一次。链接器看到两份同名的 add 定义,会直接报重复定义错误。
这条规则在链接期生效,报错长这样。假设两个源文件各自定义了一个同名函数:
cpp
// a.cpp
int helper() { return 1; }
// b.cpp
int helper() { return 2; }
再写一个 main.cpp 调用它,三个文件一起编译:
$ g++ -std=c++20 -Wall -Wextra main.cpp a.cpp b.cpp -o app
/usr/bin/x86_64-linux-gnu-ld.bfd: b.cpp:(.text+0x0): multiple definition of `helper()'; a.cpp:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status
编译阶段三个文件各自都没问题------单看任何一个 .cpp 都合法。冲突只在链接器把符号表拼到一起时才暴露。
multiple definition 和单元 06 会见到的那条 undefined reference 是同一层面的两类错误:一个说「这个名字定义了不止一次」,一个说「这个名字一次都没定义」。
排查 multiple definition 时看两个地方:是不是把函数体写进了头文件(头文件被多个 .cpp 包含,就会产生多份定义),或者是不是把同一个 .cpp 重复加进了编译命令。
这条规则对模板和 inline 函数有个例外:它们可以出现在多个翻译单元里,前提是每一份的定义完全相同。这就是为什么模板通常整个写在头文件里------每个用到它的 .cpp 都会实例化一份,靠这个例外才合法。C 里没有对应的问题,因为 C 没有模板。判定「两份定义是否相同」的规则由 ODR 管,细节见阶段七讲义 第三讲。
符号与名字修饰
编译产出 .o 文件时,每个函数在符号表里有一个名字。C 里符号名就是函数名。C++ 里不是。
实测一个 int add(int, int) 在两种语言里的符号名:
$ nm mathlib.o # 用 gcc 编译的 .c 文件
0000000000000000 T add
$ nm nomangle.o # 用 g++ 编译的 .cpp 文件
0000000000000000 T main
U _Z3addii
C 那边是 add。C++ 那边是 _Z3addii------_Z 是前缀,3add 是长度加函数名,ii 是两个 int 参数。这套规则叫名字修饰(name mangling)。
它存在的原因是 C++ 允许函数重载。add(int, int) 和 add(double, double) 是两个不同的函数,符号名必须区分开,所以参数类型被编进了名字里。命名空间也参与修饰。
代价是:C++ 和 C 编译出来的符号名对不上,两边不能直接链接。下面就是解决办法。
extern "C":混合链接实操
add 用 C 编译,main 用 C++ 写。不写任何额外声明时:
$ g++ nomangle.o mathlib.o -o nomangle
/usr/bin/x86_64-linux-gnu-ld.bfd: nomangle.o: in function `main':
nomangle.cpp:(.text+0x13): undefined reference to `add(int, int)'
collect2: error: ld returned 1 exit status
报错里的 add(int, int) 是 C++ 的写法,说明编译器在按修饰后的名字 _Z3addii 找符号,而 mathlib.o 里只有 add。
加上 extern "C":
cpp
extern "C" int add(int a, int b);
int main() { return add(1, 2); }
$ g++ mangle.o mathlib.o -o mangle
$ echo $?
0
链接通过。extern "C" 的作用是告诉编译器:这个函数按 C 的规则起名和调用,别修饰。
它在实践里出现的场合有两类。第一类是在 C++ 代码里声明一个来自 C 库的函数,标准头文件里的 extern "C" 已经替你写了。第二类是导出 C++ 写的函数给 C 用,这时要写成:
cpp
extern "C" int add(int a, int b) { return a + b; }
C 和 C++ 互相调用的完整规则(包括 void* 的传递、结构体布局的差异、以及一堆踩过的坑)在阶段七讲义 第三讲,那里对 A Tour §19.3 有逐条整理,本篇不重复。
gcc 还是 g++
零基础最常踩的坑。同一个 hello.cpp,用两个命令编译:
$ gcc -std=c++20 hello.cpp -o hello_gcc
/usr/bin/x86_64-linux-gnu-ld.bfd: hello.cpp:(.text+0x4b): undefined reference to `std::cout'
/usr/bin/x86_64-linux-gnu-ld.bfd: undefined reference to `std::basic_ostream<char, ...>& std::operator<< ...'
collect2: error: ld returned 1 exit status
$ g++ -std=c++20 hello.cpp -o hello_gpp
$ ./hello_gpp
hello cpp
gcc 能认出这是 C++ 代码、也能正确编译,但它不自动链接 C++ 标准库 。std::cout 的定义在 libstdc++ 里,找不到就报一堆 undefined reference。
记住一条:编译 .cpp 一律用 g++,编译 .c 一律用 gcc。 g++ 是 gcc 加上「按 C++ 规则处理、并链接 C++ 标准库」的驱动。
上面的报错还有一层用途:它和 extern "C" 那个报错长得很像,都是 undefined reference。区分方法是看引用的符号名------带 std:: 或者形状古怪(_Z...)的是 C++ 符号。这条判据在链接错误的排查里很常用。
静态库与动态库
.a 是静态库,.so 是动态库,C 和 C++ 都一样,命令也一样。
先把两者的差别跑一遍。源码只有一个函数:
cpp
// mathlib.cpp
int add(int a, int b) { return a + b; }
做成静态库,先编译成目标文件,再用 ar 打包:
$ g++ -std=c++20 -c mathlib.cpp -o mathlib.o
$ ar rcs libmathlib.a mathlib.o
链接时用 -L 指定库所在目录,-l 指定库名(注意库名里不含 lib 前缀和 .a 后缀):
$ g++ -std=c++20 main.cpp -L. -lmathlib -o app_static
$ ./app_static
add(2,3) = 5
做成动态库只差编译选项,-fPIC -shared 直接产出 .so:
$ g++ -std=c++20 -fPIC -shared mathlib.cpp -o libmathlib.so
$ g++ -std=c++20 main.cpp -L. -lmathlib -o app_shared
$ ./app_shared
./app_shared: error while loading shared libraries: libmathlib.so: cannot open shared object file: No such file or directory
同样一条链接命令,静态库那个直接能跑,动态库这个跑不起来。
原因在 -l 的查找分两个阶段:链接时链接器按 -L 找到了 .so,但程序里记下的是「我要加载 libmathlib.so」这个名字;运行时加载器再去找这个文件时,不查 -L 指定的目录,所以找不到。两种修法:
$ LD_LIBRARY_PATH=. ./app_shared # 运行时告诉它去哪找
add(2,3) = 5
或者把查找路径写进程序本身:
$ g++ -std=c++20 main.cpp -L. -lmathlib -Wl,-rpath,'$ORIGIN' -o app_rpath
$ ./app_rpath
add(2,3) = 5
ldd 能查清一个可执行文件依赖了哪些库、各自从哪加载:
$ ldd app_rpath | grep mathlib
libmathlib.so => /tmp/libdemo/libmathlib.so (0x00007a5c1d4d5000)
对 app_static 跑同样的命令则一条都找不到------静态库的代码在链接时已经被复制进可执行文件,运行时不再需要那个 .a。
这就是「静态」和「动态」的实际含义:.a 在链接时被搬进去,.so 在运行时才被加载。 前者部署省事、体积大;后者体积小、多个程序能共用一份,代价是运行时得让程序找得到它。
在 ABI 这一层,两者的敏感程度还不一样。C 的 ABI 稳定,一个二十年前编译的 .a 今天多半还能链上。C++ 的 ABI 涉及名字修饰规则、标准库容器的内部布局、异常处理机制、模板实例化方式,任何一样变了都可能链接失败或运行期崩溃。实践上的表现是:跨编译器版本、跨标准库版本使用预编译的 C++ 库,比用 C 库容易出问题。 拿到一个 .so 报 undefined symbol,先怀疑版本不匹配。
什么时候仍然用左边
和单元 04 一样,这张表的左边不是废弃写法:
- 写头文件时 ,
#pragma once和声明分离的习惯照旧,只是多了 ODR 这条更严的规则要遵守。 - 对接 C 库时 ,
extern "C"是唯一入口,没有替代品。 - 需要 ABI 稳定时(插件、SDK、跨语言绑定),接口层通常刻意限制成 C 的子集,就是为了拿到 C 那套稳定的调用约定。
- 理解编译错误时 ,
nm、ldd、objdump这些工具处理的是符号和段,第一层的知识在这里是主要工具。
A 组的五个单元到这里结束。下一个单元开始 B 组:从装编译器开始,按周给出零基础的路线图。
附录 · 术语表
本单元出现的词。不要求记住,读的时候遇到不懂的回来查一眼。
本单元的核心概念
翻译单元(translation unit) --- 一个源文件加上它 #include 进来的所有内容,预处理之后的整体。是编译的基本单位。
预处理(preprocessing) --- 四步里的第一步。处理 #include、#define、条件编译这些以 # 开头的指令。
符号(symbol) --- 目标文件里代表一个函数或全局变量的名字,链接器靠它把各部分接起来。
名字修饰(name mangling) --- C++ 把参数类型、命名空间编进符号名的做法,用来支持重载。
ODR(一处定义规则) --- 全局函数和变量在所有翻译单元里只能定义一次。模板和 inline 函数有例外。
ABI(二进制接口) --- 编译产物之间的约定:怎么调用、怎么传参、对象怎么排布。C 的 ABI 比 C++ 稳定。
静态库 / 动态库 --- .a 在链接时被复制进可执行文件;.so 在运行时才被加载。
本单元的代码与命令里出现的
#include --- 把另一个文件的内容插到当前位置。
#define --- 文本替换指令。C++ 里多数场合可以用 constexpr 替代。
#ifndef / #endif --- 条件编译指令,C 里用它们做头文件守卫。
#pragma once --- 让头文件只被展开一次,替代 #ifndef 守卫的写法。
extern "C" --- 让 C++ 按 C 的链接约定起名和调用。
inline --- 允许函数或变量在多个翻译单元里出现。
module --- C++20 引入的替代头文件的机制。
gcc / g++ --- GNU 编译器。处理 .c 用 gcc,处理 .cpp 用 g++。
-E / -S / -c --- 分别让 g++ 只做预处理 / 只到汇编 / 只到目标文件。
nm --- 查看目标文件里的符号表。
ld --- 链接器。undefined reference 这类报错由它发出。
undefined reference --- 链接错误:引用了某个找不到定义的符号。先检查是不是漏了某个 .cpp。
multiple definition --- 链接错误:同一个符号被定义了多次。先检查是不是把函数体写进了头文件。
ar rcs --- 把目标文件打包成静态库,产物是 libXXX.a。
-L. --- 告诉链接器到哪些目录去找库。-L. 表示当前目录。
-lmathlib --- 指定要链接的库名。写的时候去掉 lib 前缀和 .a / .so 后缀。
-fPIC --- 生成位置无关代码,做动态库时必须加。
-shared --- 生成动态库,产物是 libXXX.so。
-Wl,-rpath,'$ORIGIN' --- 把库的查找路径写进可执行文件,运行时按这个路径找。$ORIGIN 表示可执行文件所在目录。
LD_LIBRARY_PATH --- 环境变量,临时指定运行时去哪找动态库。
ldd --- 查看一个可执行文件依赖了哪些动态库、各自从哪加载。
libstdc++ --- GNU 的 C++ 标准库实现。gcc 不会自动链接它。
std::cout --- 标准输出流对象。