单元五 · 对称认知·下:编译与链接

专栏《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 库容易出问题。 拿到一个 .soundefined symbol,先怀疑版本不匹配。

什么时候仍然用左边

和单元 04 一样,这张表的左边不是废弃写法:

  1. 写头文件时#pragma once 和声明分离的习惯照旧,只是多了 ODR 这条更严的规则要遵守。
  2. 对接 C 库时extern "C" 是唯一入口,没有替代品。
  3. 需要 ABI 稳定时(插件、SDK、跨语言绑定),接口层通常刻意限制成 C 的子集,就是为了拿到 C 那套稳定的调用约定。
  4. 理解编译错误时nmlddobjdump 这些工具处理的是符号和段,第一层的知识在这里是主要工具。

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 编译器。处理 .cgcc,处理 .cppg++

-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 --- 标准输出流对象。

相关推荐
西城微科方案开发41 分钟前
雾化器方案开发:低功耗精密雾化技术的全场景落地
单片机·嵌入式硬件
橘色的喵44 分钟前
Claude Code 状态栏脚本:一行显示模型、上下文占用、git 分支和当前目录
linux·git·bash·claude
Jason_zhao_MR1 小时前
新国标下DTUFTU设计最优解——米尔基于全志T153核心板_排版优化
linux·单片机·架构·t113i·双处理器·mcu+linux·电力采集
程序员-Benothing1 小时前
Linux 用户与用户组管理:useradd usermod groupadd 实战
linux·运维·服务器
2501_930472441 小时前
从物理机到 CVM 全流程落地:部署脚本的 8 个云化改造点(附代码对比)
开发语言·人工智能·架构·腾讯云·perl
byte轻骑兵1 小时前
【BlueZ 】util 模块:通用工具函数,源码中高频复用的基础组件
linux·人工智能·bluez·电脑蓝牙·嵌入式蓝牙
老当益壮梁奶奶1 小时前
51单片机入门总结(一):基本概念、最小系统、流水灯与按键实验(附完整代码)
笔记·stm32·单片机·嵌入式硬件·51单片机
M78佐菲1 小时前
51单片机学习笔记(2)
linux·笔记·嵌入式硬件·学习·51单片机
脉动数据行情11 小时前
Java SpringBoot 国际期货批量采集实践 美原油 / 黄金 / 指数期货定时落库
java·开发语言·spring boot