从 .c 到可执行文件的全过程
这节课解决什么问题?
写 C 语言时,我们习惯了在 IDE 里点一下"运行",代码就跑起来了。但中间其实发生了四件事:预处理、编译、汇编、链接。平时它们被一个按钮藏了起来,一旦出问题,报错信息就会突然变得陌生:
- 明明写了
add函数,为什么报undefined reference to 'add'? - 两个
.c文件里都写了int g_count;,编译器一声不吭,链接器却喊multiple definition of 'g_count'? - 为什么
gcc main.o -ladd能过,把-ladd挪到main.o前面就过不了? - 为什么头文件里只能放声明,不能放定义?
这些问题的答案,都藏在"一个 .c 文件如何变成可执行文件"这条流水线上。这篇笔记把流水线拆成四段,说清每一段的输入、输出和它到底做了什么 ,再借《程序员的自我修养------链接、装载与库》里的视角,打开 .o/.obj 看看里面有什么。
关于本文的输出
- 标 【实测】 的终端输出,来自本机 MSVC 14.51(Visual Studio 18 Community,x64,Debug 默认
/Od)真实编译运行的结果,命令可以照抄。- 本机没有 gcc/clang,也没有 WSL,所以
gcc、nm、objdump、readelf、size的输出一律标注 【示意输出,未在本机验证】。这些命令和参数本身是标准用法,但具体行号、字节数会随编译器版本变化,请以你自己机器上的结果为准。- 文中所有报错文本,MSVC 的是实测原文(本机为中文语言包),另一种语言版本的写法在括号里注明;gcc 的报错文本是示意。
一、总览:四个阶段,四道翻译
一个 .c 文件并不是"直接变成程序"的,它要经过四次形态变化:
text
main.c add.c add.h
│ │ │
│ └──────┘
▼
┌─────────────┐ 输入:.c + .h(文本)
│ 预处理 │ 输出:.i(还是 C 源码,但已"摊开")
│ gcc -E │ 工作:宏展开、头文件包含、条件编译、删注释
└─────────────┘
▼ main.i
┌─────────────┐ 输入:.i
│ 编译 │ 输出:.s / .asm(汇编代码,文本)
│ gcc -S │ 工作:词法/语法/语义分析、优化、生成汇编
└─────────────┘
▼ main.s
┌─────────────┐ 输入:.s
│ 汇编 │ 输出:.o / .obj(目标文件,二进制)
│ gcc -c │ 工作:汇编指令 → 机器码,建符号表、重定位表
└─────────────┘
▼ main.o add.o
┌─────────────┐ 输入:.o + 库文件
│ 链接 │ 输出:可执行文件(或 .so/.dll)
│ gcc / ld │ 工作:符号解析 + 重定位,合并节
└─────────────┘
▼
app
| 阶段 | gcc 命令 | MSVC 命令 | 输入 | 输出 | 核心工作 |
|---|---|---|---|---|---|
| 预处理 | gcc -E |
cl /P |
.c + .h |
.i(文本) |
宏展开、头文件插入、条件编译、去注释 |
| 编译 | gcc -S |
cl /FA |
.i |
.s / .asm(文本) |
词法、语法、语义分析,优化,生成汇编 |
| 汇编 | gcc -c |
cl /c |
.s |
.o / .obj(二进制) |
汇编转机器码,生成符号表、重定位表 |
| 链接 | gcc(内部调 ld) |
link.exe |
.o + 库 |
可执行文件 | 符号解析、重定位、合并节 |
几个术语先解释清楚:
- 翻译单元(translation unit) :一个
.c文件加上它#include进来的所有内容,预处理完之后形成的那一大坨代码。编译器每次只处理一个翻译单元,看不到别的.c文件。这一点是理解后面所有"为什么链接期才报错"的关键。 - 符号(symbol):函数名、全局变量名、静态变量名,在目标文件里都记成"符号",附带它的地址(或暂时没有地址)和类型。
- 目标文件(object file) :编译汇编之后、还没链接的文件,Linux 下叫
.o,Windows 下叫.obj。它里面的地址都还是"待定"的,所以又叫可重定位目标文件。 - 链接器(linker) :把多个目标文件和库拼成一个可执行文件的程序,Linux 下是
ld,Windows 下是link.exe。
"编译"这个词有狭义和广义两种用法:广义指从 .c 到可执行文件的整个过程,狭义只指中间生成汇编那一步。本文按狭义用。
二、预处理:把代码"摊开"
2.1 预处理做什么
预处理器(preprocessor)不认识 C 语法,它只做文本替换 。所有以 # 开头的行都是给它的指令,主要四类:
- 宏定义与展开:
#define - 文件包含:
#include - 条件编译:
#if/#ifdef/#ifndef/#else/#elif/#endif - 其他:
#pragma、#error、#line
预处理的输出仍然是纯 C 源码 ,只是里面不再有 #include、#define,所有宏都被替换成了实际内容。所以预处理阶段能发现的错误,基本都是"找不到头文件""宏写错了""#if 没配对"这一类。
【实测】本机用 MSVC 预处理 main.c:
powershell
cl /nologo /P main.c
得到 main.i,它的规模是:
text
main.i:10773 行,204713 个字符
而 main.c 本身只有 10 行左右。
Linux 下的等价命令是 gcc -E main.c -o main.i。预处理结果的开头是系统头文件里成百上千行的声明,末尾 才是你自己写的那几行------所有 #include 已经被替换掉,printf 的声明就明晃晃地躺在那里(【示意输出,未在本机验证】,这里省略了 # 行号 "文件名" 形式的标记行):
c
extern int printf (const char *__restrict __format, ...);
int add(int a, int b);
extern int g_count;
int main(void)
{
int r = add(2, 3);
printf("add(2,3)=%d, g_count=%d\n", r, g_count);
return 0;
}
注意:add.h 里的 #ifndef ADD_H 这一整套 guard 已经消失了,int add(int a, int b); 被直接复制到了这里。这就是"头文件包含 = 文本复制"最直观的证据。
2.2 宏展开
宏分两种:
c
#define MAX 100 /* 对象宏:像一个常量 */
#define SQUARE(x) ((x) * (x)) /* 函数宏:带参数,但本质仍是文本替换 */
为什么函数宏的每个参数和外层都要加括号? 因为宏是文本替换,不是函数调用:
c
#define BAD_SQUARE(x) x * x
#define GOOD_SQUARE(x) ((x) * (x))
BAD_SQUARE(1 + 2) /* 展开成 1 + 2 * 1 + 2,结果是 5,不是 9 */
GOOD_SQUARE(1 + 2) /* 展开成 ((1 + 2) * (1 + 2)),结果才是 9 */
还有一个更隐蔽的坑:参数会被求值多次。
c
int i = 1;
int r = GOOD_SQUARE(i++); /* 展开成 ((i++) * (i++)),i 自增两次,行为未定义 */
宏没有类型检查、没有作用域、调试时看不到,所以能用 static inline 函数或 const 常量替代时,优先不用宏。宏真正不可替代的地方是:需要把"名字"本身当参数用的时候------这就轮到 # 和 ## 了。
2.3 # 与 ##:字符串化与粘贴
#x:把参数x的原文变成字符串字面量;a##b:把a和b两个记号粘成一个记号。
c
#include <stdio.h>
#define STR(x) #x
#define CAT(a, b) a##b
#define PRINT_INT(x) printf(#x " = %d\n", x)
int main(void)
{
int CAT(var, 1) = 7; /* 等价于 int var1 = 7; */
printf("%s\n", STR(hello)); /* "hello" */
printf("%s\n", STR(1 + 2)); /* "1 + 2",注意空格被规范化,也不会先算成 3 */
PRINT_INT(var1); /* 等价于 printf("var1" " = %d\n", var1); */
return 0;
}
PRINT_INT 是这两个运算符最典型的用法:打印变量名的同时打印它的值,靠的就是 #x 把 var1 变成字符串 "var1"。
注意:# 只能用在带参数的宏里,后面必须跟参数名,不能对普通记号使用;## 不能出现在替换列表的开头或结尾。
【实测】上面这段代码在 MSVC 下运行输出:
text
hello
1 + 2
var1 = 7
STR(1 + 2) 输出的是原文 1 + 2,而不是 3------因为 # 只是把记号拼成字符串,不会先求值。
2.4 头文件包含
#include 做的事非常暴力:把目标文件的全部内容原样复制到这一行所在的位置 。它不是 Java 的 import,也不是 Python 的 import,没有任何"模块"概念。
两种写法的搜索路径不同:
c
#include <stdio.h> /* 只在系统头文件目录里找 */
#include "add.h" /* 先找当前源文件所在目录,找不到再找系统目录 */
gcc -I./include 可以额外添加搜索目录;MSVC 对应 /I。
2.5 条件编译与 include guard
条件编译让同一份代码在不同平台、不同配置下编译出不同结果:
c
#define DEBUG 1
#if DEBUG
printf("debug: x = %d\n", x);
#endif
#ifdef _WIN32
/* Windows 专用代码 */
#elif defined(__linux__)
/* Linux 专用代码 */
#else
#error "unsupported platform"
#endif
defined(X) 判断宏 X 是否定义过,常用于 #elif。
条件编译最经典的用途是头文件保护(include guard):
c
/* add.h */
#ifndef ADD_H /* 如果 ADD_H 还没定义 */
#define ADD_H /* 那就定义它,并把这之间的内容纳入编译 */
int add(int a, int b);
extern int g_count;
#endif /* ADD_H */
为什么需要它? 因为 #include 是纯文本复制,同一个头文件可能被间接包含多次:
c
#include "add.h"
#include "add.h" /* 同一个文件被包含两次 */
如果没有 guard,第二次包含时 int add(int, int); 只是重复声明,没问题;但如果头文件里有结构体定义,就会报重定义。
【实测】去掉 guard 后,连续包含两次:
c
/* noguard.h */
struct Point { int x; int y; };
c
/* double_include.c */
#include "noguard.h"
#include "noguard.h"
int main(void) { struct Point p = { 1, 2 }; return p.x; }
text
C:\...\noguard.h(1): error C2011: "Point":"struct"类型重定义
C:\...\noguard.h(1): note: 参见"Point"的声明
英文语言包下的原文是:
text
error C2011: 'Point': 'struct' type redefinition
加上 #ifndef/#define/#endif 之后,第二次包含时整个文件体被跳过,问题消失。
#pragma once 也能达到同样效果,写法更短,但它不是 C 标准,只是编译器扩展(GCC、Clang、MSVC 都支持)。头文件保护宏要全局唯一,通常用文件名大写加下划线。
2.6 为什么 #include <stdio.h> 会展开成几千行
很多人以为 stdio.h 里只有几十个函数声明,展开后应该很短。实际上:
- 头文件层层包含 。
stdio.h会包含stddef.h、stdarg.h、stdint.h之类的标准头,标准头又包含编译器/运行库的私有头(MSVC 下是vcruntime.h、sal.h、corecrt.h等),一层套一层。 - 大量条件编译分支 。同一个头文件要同时支持 32/64 位、C/C++、不同版本的运行库,真正参与编译的只是其中一小部分。预处理后剩下的空行、
#line标记也会占行数。 - 头文件里不只有声明,还有定义 。为了让一些短小的函数能被内联,标准库会把它们的实现直接写在头文件里(C++ 的
std::模板更是如此)。
第 3 点在本机可以直接看到:main.asm 里出现了 _vfprintf_l、__local_stdio_printf_options 这些函数体,它们的注释明确写着来自 stdio.h 和 corecrt_stdio_config.h:
asm
; Function compile flags: /Odtp
; COMDAT __local_stdio_printf_options
_TEXT SEGMENT
__local_stdio_printf_options PROC ; COMDAT
; File C:\Program Files (x86)\Windows Kits\10\Include\10.0.26100.0\ucrt\corecrt_stdio_config.h
; Line 92
lea rax, OFFSET FLAT:?_OptionsStorage@?1??__local_stdio_printf_options@@9@9
ret 0
__local_stdio_printf_options ENDP
想在自己机器上数一遍,Linux 下执行 gcc -E main.c -o main.i && wc -l main.i。行数会随 libc 版本、编译选项变化,不必纠结具体数字,关键是它远多于你写的那十几行。
也正因为头文件里可能有函数定义,链接器才需要处理"多个 .o 都存在同一个函数"的情况------MSVC 用 COMDAT 段的 pick any 规则去重(后面会看到 selection 2 (pick any)),GCC 用弱符号/COMDAT 组处理。这也是 C++ 里 inline 函数可以写在头文件中的底层原因。
三、编译:从文本到汇编
编译阶段是整个流程里最"聪明"的部分,通常分六步:
| 步骤 | 做什么 | 出错时的典型报错 |
|---|---|---|
| 词法分析 | 把字符流切成记号(token):关键字、标识符、字面量、运算符 | 非法字符 |
| 语法分析 | 按语法规则把记号组织成语法树(AST) | 少分号、括号不配对 |
| 语义分析 | 类型检查、隐式转换、把标识符登记进符号表 | 类型不匹配、未声明标识符 |
| 中间代码生成 | 转成与机器无关的中间表示 IR | ------ |
| 优化 | 常量折叠、死代码消除、循环优化、内联 | ------ |
| 目标代码生成 | 选择指令、分配寄存器、生成汇编 | ------ |
几个概念:
- 记号(token) :编译器眼中的最小语法单位。
int a = 1;会被切成int、a、=、1、;五个记号。 - 中间表示(IR):编译器内部的一种"半成品语言",比 C 简单、比汇编抽象。GCC 用 GIMPLE/RTL,LLVM 用 LLVM IR。所有优化都在 IR 上做,因为 IR 规整,容易分析。
- 符号表(symbol table):编译期记录"当前翻译单元里有哪些名字、什么类型、是定义还是引用"的表格。它会被写进目标文件,成为链接时的依据。
3.1 生成汇编
bash
gcc -S main.c -o main.s # 【示意命令】Linux
powershell
cl /nologo /c /FA main.c # 【实测】Windows,生成 main.asm 和 main.obj
【实测】main.asm 中 main 函数的全部指令:
asm
_TEXT SEGMENT
r$ = 32
main PROC
; Line 6
mov edx, 3
mov ecx, 2
call add
mov DWORD PTR r$[rsp], eax
; Line 7
mov r8d, DWORD PTR g_count
mov edx, DWORD PTR r$[rsp]
lea rcx, OFFSET FLAT:$SG10205
call printf
; Line 8
xor eax, eax
; Line 9
add rsp, 56
ret 0
main ENDP
_TEXT ENDS
这段代码信息量很大:
mov ecx, 2/mov edx, 3:按 x64 调用约定(前两个整型参数放ecx、edx)传参;call add:只有名字,没有地址 。因为编译main.c时,编译器根本不知道add会被放在哪里------它可能在另一个.o里,也可能在某个.lib/.so里;mov r8d, DWORD PTR g_count:直接按名字访问全局变量g_count,同样没有地址;$SG10205:格式字符串的标号,它的内容放在数据节里,地址也是待定的。
3.2 符号表与重定位的雏形
编译器在生成这些"没有地址的引用"时,会同时在符号表里记一笔:
add:**未定义(UNDEF)**的外部符号;g_count:**未定义(UNDEF)**的外部符号;main:已定义(External) ,在.text节里;printf:在本机这份main.obj里居然是已定义的 ------因为stdio.h里的内联定义被编进了main.obj。
这些"待定地址"会交给下一阶段的汇编器,变成重定位项(relocation entry):一条记录写着"这个位置上的 4 个字节,应该填符号 X 的最终地址"。链接器的工作之一,就是把这些空位补上。
编译阶段能发现的错误:语法错误、类型错误、未声明的标识符。注意区分两种"找不到":
c
int main(void)
{
int r = add(2, 3); /* 如果前面没有 #include "add.h" */
return 0;
}
这里编译器不知道 add 是谁,属于编译期 问题。C99 起取消了隐式函数声明,新版本 GCC 直接报 error: implicit declaration of function 'add'【示意】;MSVC 在 C 模式下会给 warning C4013: "add"未定义;假设外部返回 int,然后按返回 int 处理。
而如果声明了但没有定义,编译器是满意的------它只负责生成一个"待填地址"的调用,剩下的事交给链接器。
四、汇编:生成目标文件
4.1 汇编器做什么
汇编器(assembler)把汇编代码逐条翻译成机器码,并完成三件事:
- 把指令编码成二进制(操作码 + 操作数);
- 把代码、已初始化数据、未初始化数据分门别类放进不同的节(section);
- 生成符号表 和重定位表,记录哪些位置需要链接器来填。
输出的 .o/.obj 就是可重定位目标文件。"可重定位"的意思是:里面的地址都是从 0 开始的相对偏移,链接器可以把它们搬到任意位置并修正引用。
bash
gcc -c add.c -o add.o # 【示意命令】
powershell
cl /nologo /c add.c # 【实测】生成 add.obj
4.2 目标文件里有什么
一个目标文件通常由四部分组成:
text
┌──────────────────────────┐
│ 文件头(File Header) │ 魔数、平台、节数量、符号表位置......
├──────────────────────────┤
│ 各 个 节(Sections) │ .text 代码 / .data 数据 / .bss 零数据
│ │ .rodata 只读数据 / .debug 调试信息
├──────────────────────────┤
│ 符号表(Symbol Table) │ 每个符号:名字、所在节、偏移、绑定属性
├──────────────────────────┤
│ 重定位表(Relocation) │ "第 X 个位置要填符号 Y 的地址"
└──────────────────────────┘
ELF(Linux 使用的可执行文件格式)和 PE/COFF(Windows 使用的格式)在细节上不同,但概念一一对应:
| 内容 | ELF(Linux) | PE/COFF(Windows) | 说明 |
|---|---|---|---|
| 代码 | .text |
.text$mn |
机器指令 |
| 已初始化数据 | .data |
.data |
初值非 0 的全局/静态变量 |
| 未初始化数据 | .bss |
.bss |
初值为 0 或未初始化,不占文件空间 |
| 只读数据 | .rodata |
.rdata(或 .data) |
字符串字面量、const 数据 |
| 符号表 | .symtab |
COFF 符号表 | 名字 → 地址/节 |
| 重定位 | .rela.text 等节 |
每个节自带的 relocation 表 | 待填地址清单 |
4.3 实测:打开 add.obj 看看
add.c 只有两个全局定义:
c
#include "add.h"
int g_count = 0;
int add(int a, int b)
{
g_count++;
return a + b;
}
【实测】dumpbin /headers add.obj(节选,省略了部分字段):
text
SECTION HEADER #3
.bss name
0 physical address
0 virtual address
4 size of raw data
0 file pointer to raw data
0 number of relocations
C0300080 flags
Uninitialized Data
4 byte align
Read Write
SECTION HEADER #4
.text$mn name
0 physical address
0 virtual address
23 size of raw data
18F file pointer to raw data (0000018F to 000001B1)
1B2 file pointer to relocation table
2 number of relocations
60500020 flags
Code
16 byte align
Execute Read
Summary
4 .bss
28 .chks64
84 .debug$S
2F .drectve
23 .text$mn
逐条读:
.bss大小 4 字节,正好是g_count(int)的大小;file pointer to raw data是 0,说明它在文件里不占任何字节,只记录了"需要 4 字节、加载时清零";.text$mn大小0x23= 35 字节,就是add函数的机器码;.text$mn有 2 个重定位项 ,对应g_count++里的"读一次、写一次";- 还有
.debug$S(调试信息)、.drectve(给链接器的指示)、.chks64(增量链接校验)等辅助节。
为什么零初始化的全局变量放 .bss 而不是 .data? 如果放 .data,每个变量都要在磁盘文件里占一份全 0 的字节。放到 .bss,文件里只记一个大小,程序加载时操作系统直接把这段内存清零,文件能小很多。C 标准规定未初始化的全局/静态变量初值为 0,正好用这个机制实现。
【实测】换几个变量再验证一次:
c
int g_init = 42; /* 初值非 0 */
int g_zero = 0; /* 显式初始化为 0 */
char g_buf[64]; /* 未初始化 */
text
SECTION HEADER #3
.data name
4 size of raw data ← g_init = 42
1BF file pointer to raw data (000001BF to 000001C2)
Initialized Data
Read Write
SECTION HEADER #4
.bss name
4 size of raw data ← g_zero = 0
0 file pointer to raw data
Uninitialized Data
Read Write
符号表:
008 00000000 SECT3 notype External | g_init
00B 00000000 SECT4 notype External | g_zero
00C 00000040 UNDEF notype External | g_buf
g_init 进了 .data,g_zero 进了 .bss,符合预期。有意思的是 g_buf:它是 UNDEF ,但后面跟着一个值 00000040(64),这表示它是一个公共符号(common symbol) ------大小 64、还没有分配节,留给链接器统一分配。这就是《程序员的自我修养》里讲的 COMMON 块:多个目标文件里同名的"暂定定义"可以在链接时合并成一份,不会冲突。GCC 10 之前默认也是这个行为(-fcommon),GCC 10 起默认改成 -fno-common,同名变量直接变成强符号,冲突就暴露出来了。
4.4 实测:符号表与重定位表
【实测】dumpbin /symbols add.obj(节选):
text
006 00000000 SECT3 notype Static | .bss
Section length 4, #relocs 0, #linenums 0, checksum 0
008 00000000 SECT3 notype External | g_count
009 00000000 SECT4 notype Static | .text$mn
Section length 23, #relocs 2, #linenums 0, checksum C91C131C
00B 00000000 SECT4 notype () External | add
g_count是 External ,位于 SECT3(.bss),偏移 0;add是 External ,位于 SECT4(.text$mn),偏移 0;- 这两个符号就是
add.obj提供给外部使用的"出口"。
【实测】dumpbin /relocations add.obj:
text
RELOCATIONS #4
Symbol Symbol
Offset Type Applied To Index Name
-------- ---------------- ----------------- -------- ------
0000000A REL32 00000000 8 g_count
00000012 REL32 00000000 8 g_count
两条重定位都在 .text 里,偏移 0x0A 和 0x12,类型 REL32(32 位相对地址),目标是符号表第 8 项 g_count。链接器做完符号解析、算出 g_count 的最终地址后,会回到这两个位置把地址填进去。
4.5 Linux 下怎么看(示意输出)
同样的信息,在 Linux 上可以用 nm、objdump、size、readelf 查看。以下均为**【示意输出,未在本机验证】**,数字按上文 add.c 的写法推算,实际以你机器为准。
bash
gcc -c add.c -o add.o
nm add.o:
text
0000000000000000 T add
0000000000000000 B g_count
T:在.text节里的全局符号(Text);B:在.bss节里的全局符号(Bss);U:未定义(Undefined),需要别的目标文件提供。
nm main.o:
text
U add
U g_count
U printf
0000000000000000 T main
一眼就能看出 main.o 缺什么:add 和 g_count 由 add.o 提供,printf 由 C 运行库提供。
objdump -h add.o(节选):
text
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000023 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000063 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 0000000000000000 0000000000000000 00000063 2**0
ALLOC
3 .rela.text 00000030 0000000000000000 0000000000000000 00000068 2**3
CONTENTS, RELOC
size add.o:
text
text data bss dec hex filename
35 0 4 39 27 add.o
objdump -r add.o:
text
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
000000000000000c R_X86_64_PC32 g_count-0x4
0000000000000015 R_X86_64_PC32 g_count-0x4
反汇编 objdump -d add.o 能看到"待填空位"长什么样:
text
0000000000000000 <add>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 89 7d fc mov %edi,-0x4(%rbp)
7: 89 75 f8 mov %esi,-0x8(%rbp)
a: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 10 <add+0x10>
10: 83 c0 01 add $0x1,%eax
13: 89 05 00 00 00 00 mov %eax,0x0(%rip) # 19 <add+0x19>
19: 8b 55 fc mov -0x4(%rbp),%edx
1c: 8b 45 f8 mov -0x8(%rbp),%eax
1f: 01 d0 add %edx,%eax
21: 5d pop %rbp
22: c3 ret
注意两处 00 00 00 00:这两个 4 字节的位移字段现在都是 0,因为此刻还不知道 g_count 在哪。链接器会根据重定位表,把这两个字段改成 g_count 相对当前指令的偏移。这就是"重定位"三个字的字面意思。
readelf -S add.o(节选):
text
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000023 0000000000000000 AX 0 0 1
[ 2] .data PROGBITS 0000000000000000 00000063
0000000000000000 0000000000000000 WA 0 0 1
[ 3] .bss NOBITS 0000000000000000 00000063
0000000000000004 0000000000000000 WA 0 0 1
[ 4] .rela.text RELA 0000000000000000 00000068
0000000000000030 0000000000000018 I 9 1 8
.bss 的类型是 NOBITS ------没有比特,即文件里没有对应内容;它的 Offset 和 .data 相同(都是 0x63),说明文件指针没有前进。这正对应前面 MSVC 里 file pointer to raw data = 0 的现象。
五、链接:符号解析与重定位
链接器主要做两件事:
- 符号解析(symbol resolution):把每个"未定义符号"和某个目标文件/库里的"定义"对上号;
- 重定位(relocation):合并同类节,为它们分配运行时地址,然后修改所有引用处的地址。
5.1 符号解析:强符号、弱符号、未定义符号
每个目标文件的符号分三类:
- 定义符号 :本文件提供了实现(
T add、B g_count、D g_init); - 未定义符号 :本文件引用了、但没实现(
U add); - 局部符号 :
static修饰的函数/全局变量(小写t、b、d),不参与全局符号解析。
而"定义"又分强、弱:
| 类型 | 典型来源 | 规则 |
|---|---|---|
| 强符号(strong) | 函数定义、带初值的全局变量 | 同名强符号最多一个 |
| 弱符号(weak) | __attribute__((weak)) 显式标注、未初始化的全局变量(COMMON) |
同名强符号存在时,弱符号被忽略 |
链接器的三条规则:
- 不允许两个同名的强符号 同时存在 →
multiple definition of 'g_count'; - 一强一弱,选强的;
- 两个弱符号,选占用空间大的那个(COMMON 场景)。
弱符号的实用价值:库可以提供一个"默认实现",用户在自己的代码里重新定义一个同名函数就能覆盖它,不用改库。Linux 内核和不少驱动框架都靠这个机制做钩子。
5.2 重定位:把地址填进去
符号解析完,链接器按顺序做:
- 合并节 :把所有
.o的.text拼成一个.text,所有.data拼成一个.data......同名的.bss只累加大小; - 分配地址:为合并后的每个节分配虚拟地址(VMA,Virtual Memory Address);
- 修正引用 :遍历每个
.o的重定位表,把"符号最终地址"写进对应的空位。
除了指令里的地址,还有一类重定位是绝对地址 (比如函数指针表、全局数组初始化里存了另一个变量的地址),它们需要的是符号的绝对地址,而不是相对位移。这就是重定位项里"类型"字段的作用:R_X86_64_PC32 表示填相对位移,R_X86_64_64 表示填 64 位绝对地址。
节(section)与段(segment)的区别 :节是链接时 的组织单位,按内容类型划分(代码、数据、只读数据),用 objdump -h / readelf -S 看;段是加载时 的组织单位,链接器会把权限相同的多个节合并成一个段(比如把所有可读可执行的节合成一个 LOAD 段),用 readelf -l 看。程序运行时,操作系统是按段来映射内存权限的。
VMA 与 LMA:VMA 是变量/代码运行时的地址,LMA 是它在存储介质里的装载地址。桌面系统上两者通常相同;在嵌入式里,代码可能被烧到 Flash(LMA),运行时再拷贝到 RAM(VMA),这时两者不同。这里只需知道有这么回事。
5.3 静态链接:.a / .lib
静态库 就是把一堆 .o 打包成一个文件,Linux 下用 ar 生成 .a(archive),Windows 下用 lib.exe 生成 .lib。
bash
ar rcs libadd.a add.o # 【示意命令】打包
gcc main.o -L. -ladd -o app # 【示意命令】链接
关键点:链接器不会把整个 .a 塞进可执行文件 ,而是"按需抽取"------只把那些解决了当前未定义符号 的成员 .o 拉进来。没被用到的成员不会进入最终程序。
这直接导致了 -l 的顺序问题。GNU ld 默认从左到右单遍扫描命令行:
bash
gcc main.o -L. -ladd -o app # 正确:先看到 main.o 缺 add,再扫描 libadd.a,抽出 add.o
gcc -L. -ladd main.o -o app # 错误:扫描 libadd.a 时还没有任何未定义符号,什么都不抽
后者的报错是【示意输出,未在本机验证】:
text
/usr/bin/ld: main.o: in function `main':
main.c:(.text+0x1e): undefined reference to `add'
/usr/bin/ld: main.c:(.text+0x2a): undefined reference to `g_count'
collect2: error: ld returned 1 exit status
规则可以记成一句话:依赖别人的目标文件放前面,被依赖的库放后面 。如果库之间互相依赖,可以用 -Wl,--start-group ... -Wl,--end-group 让链接器反复扫描;如果希望库里的成员全部被链接进来(比如注册表、静态构造函数),用 -Wl,--whole-archive。
补充说明(实测) :MSVC 的链接器会反复扫描库列表,对顺序不敏感。本机把命令改成 cl /Fe:app_static2.exe add.lib main.obj(库在前)依然链接成功、运行正常。所以"顺序问题"是 GNU ld(以及多数 Unix 链接器)的行为,不要把它当成所有链接器的通用规则。
静态链接的优缺点:
| 优点 | 缺点 |
|---|---|
| 部署简单,一个可执行文件即可运行 | 每个程序都带一份库代码,磁盘和内存都有副本 |
| 没有运行时依赖,不会有"找不到 .so" | 库升级要重新链接整个程序 |
| 启动快,不需要加载器解析符号 | 无法在运行时替换实现(插件机制做不了) |
| 可以只链接用到的部分,体积可控 | 多个程序共用同一库时,内存无法共享 |
5.4 动态链接:.so / .dll
**动态库(共享库)**在链接时只记录"我需要它",不复制代码。Linux 下是 .so(shared object),Windows 下是 .dll。
bash
gcc -fPIC -shared -o libadd.so add.c # 【示意命令】生成共享库
gcc main.o -L. -ladd -o app # 【示意命令】链接时只记录依赖
ldd app # 【示意命令】查看依赖
几个术语:
- PIC(Position Independent Code,位置无关代码) :共享库加载到哪个地址不确定,所以代码不能写死绝对地址,只能用"相对当前指令"的方式寻址。
-fPIC让编译器生成这种代码。共享库必须用 PIC,否则加载时要做大量地址修正,甚至无法加载。 - GOT(Global Offset Table,全局偏移表):放在数据段里的一张表,存着各个外部符号的实际地址。代码通过 GOT 间接访问,从而与加载地址无关。
- PLT(Procedure Linkage Table,过程链接表):函数调用的一层跳板。第一次调用时通过动态链接器解析真实地址并写回 GOT(这叫懒绑定 lazy binding),之后直接跳转。
-rdynamic:把可执行文件里的符号也导出到动态符号表,这样dlsym、backtrace_symbols、插件回调才能按名字找到主程序里的函数。默认情况下主程序的符号对动态链接器是不可见的。
程序运行时,动态库是怎么被加载的?
以 ELF 为例:
- 内核加载可执行文件,发现它有
PT_INTERP段,里面写着动态链接器的路径(如/lib64/ld-linux-x86-64.so.2); - 内核把控制权交给动态链接器,而不是程序的入口;
- 动态链接器读取可执行文件的
DT_NEEDED项,得到依赖的.so列表; - 按搜索路径(
DT_RPATH/DT_RUNPATH、LD_LIBRARY_PATH、/etc/ld.so.cache、系统目录)找到每个.so,用mmap映射进进程地址空间(多个进程可以共享同一份物理内存); - 处理重定位:填写 GOT,绑定函数地址(可以延迟到第一次调用);
- 执行各库的初始化函数(
.init_array),最后跳到程序入口。
Windows 的流程类似,只是由 PE 加载器解析导入表(Import Table) ,把 DLL 映射进来并填写导入地址表(IAT),找不到 DLL 时直接报错退出。
本机实测的动态链接:
powershell
cl /nologo /LD /Fe:dynadd.dll dlladd.c
cl /nologo /Fe:app_dyn.exe main_dyn.c dynadd.lib
其中 dlladd.c 用 __declspec(dllexport) 导出函数:
c
__declspec(dllexport) int add(int a, int b)
{
return a + b;
}
构建产物:
text
dynadd.dll 103424 字节 ← 共享库本体
dynadd.lib 1666 字节 ← 导入库,只含"怎么找到 add"的桩
app_dyn.exe 140800 字节 ← 可执行文件,不含 add 的代码
dumpbin /exports dynadd.dll(节选):
text
Section contains the following exports for dynadd.dll
ordinal hint RVA name
1 0 00001000 add
dumpbin /imports app_dyn.exe(节选):
text
Section contains the following imports:
dynadd.dll
KERNEL32.dll
可执行文件里明确记录了"我要从 dynadd.dll 导入符号"。把 dynadd.dll 改名后再运行,程序没有任何输出 ,进程以 -1073741515 退出:
text
exit=-1073741515 (十六进制 0xC0000135,即 STATUS_DLL_NOT_FOUND)
它根本没进 main,因为加载器在启动阶段就找不到依赖了。Linux 上对应的现象是【示意输出,未在本机验证】:
text
./app: error while loading shared libraries: libadd.so: cannot open shared object file: No such file or directory
顺带说一句:本例里 app_static.exe(140288 字节)和 app_dyn.exe(140800 字节)体积几乎一样,因为主要体积来自 C 运行库,示例函数太小,看不出差别。动态链接真正省下的是多个程序之间共享同一份库代码 ,以及库可以独立升级。
动态链接的优缺点正好和静态链接互补:
| 优点 | 缺点 |
|---|---|
| 多个程序共享一份库代码,省内存和磁盘 | 依赖管理复杂(DLL 地狱、版本不匹配) |
| 库可以单独升级,不用重新链接程序 | 启动时要加载、解析符号,启动稍慢 |
| 支持插件、运行时替换实现 | 部署时必须带上正确的库,或依赖系统已安装 |
| 可执行文件本身小 | 符号解析、GOT/PLT 带来轻微运行时开销 |
六、完整示例:两个 .c + 一个头文件
下面这套代码在后文所有命令里都会用到,可以直接复制。
6.1 三个文件
c
/* add.h */
#ifndef ADD_H
#define ADD_H
int add(int a, int b);
extern int g_count; /* 声明:g_count 定义在别处 */
#endif
c
/* add.c */
#include "add.h"
int g_count = 0; /* 定义:分配空间,初值 0 */
int add(int a, int b)
{
g_count++;
return a + b;
}
c
/* main.c */
#include <stdio.h>
#include "add.h"
int main(void)
{
int r = add(2, 3);
printf("add(2,3)=%d, g_count=%d\n", r, g_count);
return 0;
}
6.2 Linux 下分步构建(命令可照抄)
bash
# 1. 预处理:生成 main.i / add.i
gcc -E main.c -o main.i
gcc -E add.c -o add.i
# 2. 编译:生成汇编 main.s / add.s
gcc -S main.c -o main.s
gcc -S add.c -o add.s
# 3. 汇编:生成目标文件 main.o / add.o
gcc -c main.c -o main.o
gcc -c add.c -o add.o
# 4. 链接:生成可执行文件 app
gcc main.o add.o -o app
# 5. 运行
./app
输出:
text
add(2,3)=5, g_count=1
实际开发中通常一步到位:
bash
gcc main.c add.c -o app
这条命令内部仍然依次执行预处理、编译、汇编、链接,只是中间文件被放进了临时目录。
6.3 Windows 下分步构建(本机实测)
如果用的是"x64 Native Tools Command Prompt for VS",可以直接运行 cl。用普通 PowerShell 的话,先设置环境变量:
powershell
$msvc='C:\Program Files\Microsoft Visual Studio\18\Community\VC\Tools\MSVC\14.51.36231'
$sdk='C:\Program Files (x86)\Windows Kits\10'; $v='10.0.26100.0'
$env:INCLUDE="$msvc\include;$sdk\Include\$v\ucrt;$sdk\Include\$v\shared;$sdk\Include\$v\um"
$env:LIB="$msvc\lib\x64;$sdk\Lib\$v\ucrt\x64;$sdk\Lib\$v\um\x64"
$cl="$msvc\bin\Hostx64\x64\cl.exe"
& $cl /nologo /P main.c # 预处理 → main.i
& $cl /nologo /c /FA main.c # 编译 + 汇编 → main.asm、main.obj
& $cl /nologo /c add.c # 汇编 → add.obj
& $cl /nologo /Fe:app.exe main.obj add.obj # 链接
.\app.exe
【实测】输出:
text
add(2,3)=5, g_count=1
6.4 未定义符号:报在链接期
只拿 main.obj 去链接,故意不带 add.obj:
powershell
cl /nologo /Fe:main_alone.exe main.obj
【实测】:
text
main.obj : error LNK2019: 无法解析的外部符号 add,函数 main 中引用了该符号
main.obj : error LNK2019: 无法解析的外部符号 g_count,函数 main 中引用了该符号
main_alone.exe : fatal error LNK1120: 2 个无法解析的外部命令
英文语言包下的原文:
text
main.obj : error LNK2019: unresolved external symbol add referenced in function main
main.obj : error LNK2019: unresolved external symbol g_count referenced in function main
main_alone.exe : fatal error LNK1120: 2 unresolved externals
gcc/ld 的对应报错是【示意输出,未在本机验证】:
text
/usr/bin/ld: main.o: in function `main':
main.c:(.text+0x1e): undefined reference to `add'
/usr/bin/ld: main.c:(.text+0x2a): undefined reference to `g_count'
collect2: error: ld returned 1 exit status
为什么编译期不报? 因为编译 main.c 时,add.h 已经明确告诉编译器"add 是一个返回 int、接受两个 int 的函数",g_count 是一个 int 变量。编译器的工作到此为止:生成调用、留好空位。至于这个符号最终在哪,它管不着,也看不到。
6.5 重复定义:也报在链接期
再写一个 main_dup.c,里面也定义 g_count:
c
#include <stdio.h>
#include "add.h"
int g_count = 100; /* 和 add.c 里的定义撞车 */
int main(void)
{
int r = add(2, 3);
printf("add(2,3)=%d, g_count=%d\n", r, g_count);
return 0;
}
【实测】cl /nologo /Fe:main_dup.exe main_dup.obj add.obj:
text
add.obj : error LNK2005: g_count 已经在 main_dup.obj 中定义
main_dup.exe : fatal error LNK1169: 找到一个或多个多重定义的符号
英文语言包下:
text
add.obj : error LNK2005: g_count already defined in main_dup.obj
main_dup.exe : fatal error LNK1169: one or more multiply defined symbols found
gcc/ld 的对应报错【示意输出,未在本机验证】:
text
/usr/bin/ld: add.o:(.bss+0x0): multiple definition of `g_count'; main_dup.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status
为什么编译期不报? 因为两个 .c 是两个独立的翻译单元 。编译 add.c 时编译器看不到 main_dup.c,编译 main_dup.c 时也看不到 add.c,各自看都很合法。只有当链接器把两个 .o 的符号表放在一起时,才发现"两个强符号同名",于是报错。
这也解释了为什么全局变量重复定义 和函数重复定义的报错长得一样:它们在目标文件里都是符号,链接器的处理规则相同。
6.6 静态库版本
【实测】把 add.obj 打包成静态库再链接:
powershell
lib /nologo /OUT:add.lib add.obj
cl /nologo /Fe:app_static.exe main.obj add.lib
.\app_static.exe
text
add(2,3)=5, g_count=1
产物大小:
text
app_static.exe 140288 字节
add.lib 990 字节
对比直接链接两个 .obj,结果完全一样------静态库本质上就是 .obj 的压缩包,链接器按需抽取。
6.7 static 的内部链接(实测)
两个 .c 文件各自定义同名的 static 变量:
c
/* stat_a.c */
#include <stdio.h>
static int counter = 0;
void bump_a(void) { counter++; printf("a: counter=%d\n", counter); }
c
/* stat_b.c */
#include <stdio.h>
static int counter = 0;
void bump_b(void) { counter++; printf("b: counter=%d\n", counter); }
【实测】三个文件一起编译链接,没有任何冲突,运行结果:
text
a: counter=1
b: counter=1
a: counter=2
dumpbin /symbols stat_a.obj 里 counter 的绑定属性是 Static:
text
02C 00000000 SECTD notype Static | counter
Static 在这里表示局部符号 (内部链接),不是"静态存储期"。它不会出现在链接器的全局符号表里,所以另一个文件里的同名 counter 完全不会冲突------每个文件各有一份独立的变量。
七、几个高频困惑点
7.1 为什么声明放头文件、定义放 .c?
- 声明 (
int add(int, int);、extern int g_count;)只是告诉编译器"有这么个东西,长这样",不分配空间,可以在多个文件里重复出现; - 定义 (函数体、
int g_count = 0;)会真正产生代码或分配空间,整个程序里只能有一份。
头文件会被多个 .c 包含,所以里面只能放可以重复的东西 :声明、宏、类型定义(struct/typedef/enum)、static inline 函数。如果把 int g_count = 0; 写进头文件,每个包含它的 .o 都会有一份定义,链接时必然 multiple definition。
例外是 static 变量和 static inline 函数:它们有内部链接,每个翻译单元各有一份副本,写进头文件不会冲突(代价是每个文件都有一份拷贝)。C++ 的 inline 函数和类定义也是靠类似机制(COMDAT/弱符号)允许写在头文件里的。
7.2 static 如何影响链接?
static 在不同位置含义不同,这是初学者最容易混的地方:
| 位置 | 含义 | 对链接的影响 |
|---|---|---|
| 全局变量/函数前 | 内部链接:符号只在本翻译单元可见 | 不出现在全局符号表,别的 .o 看不到,也不会冲突 |
| 函数内局部变量前 | 静态存储期:生命周期贯穿整个程序,但作用域仍限于函数 | 与链接无关;符号通常是局部的(如 counter.0) |
所以"static 让变量只初始化一次"说的是第二种;"static 避免命名冲突"说的是第一种。两者不是一回事。
工程上的常见用法:库内部的辅助函数一律加 static,既能避免污染全局命名空间,也能让编译器做更好的优化(比如内联、删除未使用的函数)。
7.3 extern 到底做什么?
extern 是"声明而非定义"的显式写法:
c
extern int g_count; /* 声明:g_count 在别的翻译单元里定义 */
int g_count; /* 在 C 里这是"暂定定义",通常会分配空间 */
int g_count = 0; /* 定义:分配空间并初始化 */
- 对函数 来说,
extern是默认的,写不写一样; - 对变量 来说,
extern明确告诉编译器"别在这里分配空间,链接时去别处找"; extern常配合头文件使用:头文件里写extern int g_count;,某一个.c里写int g_count = 0;。
注意一个历史坑:在 GCC 10 之前,int g_count; 这种"暂定定义"会被当成 COMMON 符号,多个文件里都写也不会报错,链接器默默合并。GCC 10 起默认 -fno-common,它变成强符号,重复定义就会报错。老项目迁移时遇到的 multiple definition 暴涨,多半是这个原因。要临时恢复旧行为可以加 -fcommon,但正确做法是改成 extern 声明 + 一处定义。
7.4 全局变量重复定义为什么链接期才报错?
把前面两节合起来就是答案:
- 编译器一次只处理一个翻译单元,物理上看不到 别的
.c; - 每个
.c单独看都合法:都定义了一个int g_count; - 符号的冲突只有在合并多个目标文件时才暴露,而这正是链接器的工作。
同理,undefined reference 也只在链接期出现:编译器能检查"你有没有声明",但检查不了"有没有人实现"。声明是编译期的事,定义是链接期的事。
7.5 头文件为什么一定要加 include guard?
因为 #include 是文本复制,同一个头文件可能被多条路径重复包含:
text
main.c → a.h → common.h
→ b.h → common.h ← common.h 被包含两次
如果 common.h 里有结构体、typedef、enum 的定义,第二次就会出现重定义错误(前面实测的 C2011 就是这种情况)。guard 保证同一个翻译单元里,头文件体只被展开一次。
要注意 guard 的边界 :它只防"同一翻译单元内重复包含",防不了"两个 .c 各自定义同名全局变量",也防不了两个不同头文件用了同一个宏名。所以 guard 宏要起得足够独特,比如 PROJECT_MODULE_FILE_H。
7.6 C++ 为什么需要 extern "C"?
C++ 支持函数重载,所以编译器要对函数名做名字修饰(name mangling):把参数类型编码进符号名。
cpp
int add(int a, int b); // 符号可能是 _Z3addii
double add(double, double); // 符号可能是 _Z3adddd
而 C 没有重载,符号名就是函数名本身(add)。于是:
- C++ 代码调用 C 库时,会去找
_Z3addii,但 C 库里只有add,链接失败; - C 库的头文件被 C++ 包含时,需要用
extern "C"告诉 C++ 编译器"这些符号按 C 的规则来,不要修饰"。
标准写法:
c
#ifdef __cplusplus
extern "C" {
#endif
int add(int a, int b);
extern int g_count;
#ifdef __cplusplus
}
#endif
这样同一个头文件既能被 C 编译,也能被 C++ 编译。C 编译器不认识 extern "C",所以要用 __cplusplus 宏包起来。
顺带一提:调试时看到 _Z3addii 这种名字,可以用 nm -C 或 c++filt 还原成人类可读的形式。
八、动手实验清单
8.1 Linux(gcc)
bash
# 环境确认
gcc --version
# 全流程
gcc -E main.c -o main.i && wc -l main.i
gcc -S main.c -o main.s && cat main.s
gcc -c main.c -o main.o
gcc -c add.c -o add.o
gcc main.o add.o -o app && ./app
# 看目标文件
nm add.o
nm main.o
objdump -h add.o
objdump -r add.o
objdump -d add.o
size add.o
readelf -S add.o
readelf -l app
# 静态库
ar rcs libadd.a add.o
ar t libadd.a
gcc main.o -L. -ladd -o app_static && ./app_static
gcc -L. -ladd main.o -o app_bad # 故意制造顺序错误
# 动态库
gcc -fPIC -shared -o libadd.so add.c
gcc main.o -L. -ladd -o app_dyn
ldd app_dyn
LD_LIBRARY_PATH=. ./app_dyn
8.2 Windows(MSVC)
powershell
# 在 x64 Native Tools Command Prompt 里
cl /nologo /P main.c
cl /nologo /c /FA main.c
cl /nologo /c add.c
cl /nologo /Fe:app.exe main.obj add.obj
.\app.exe
dumpbin /headers add.obj
dumpbin /symbols add.obj
dumpbin /relocations add.obj
dumpbin /disasm add.obj
lib /nologo /OUT:add.lib add.obj
cl /nologo /Fe:app_static.exe main.obj add.lib
cl /nologo /LD /Fe:dynadd.dll dlladd.c
cl /nologo /Fe:app_dyn.exe main_dyn.c dynadd.lib
dumpbin /exports dynadd.dll
dumpbin /imports app_dyn.exe
8.3 工具对照表
| 想做什么 | GNU(Linux) | MSVC(Windows) |
|---|---|---|
| 只预处理 | gcc -E x.c -o x.i |
cl /P x.c |
| 生成汇编 | gcc -S x.c -o x.s |
cl /FA /c x.c |
| 生成目标文件 | gcc -c x.c -o x.o |
cl /c x.c |
| 查看节 | objdump -h x.o、readelf -S x.o |
dumpbin /headers x.o |
| 查看符号 | nm x.o、nm -C x.o |
dumpbin /symbols x.o |
| 查看重定位 | objdump -r x.o、readelf -r x.o |
dumpbin /relocations x.o |
| 反汇编 | objdump -d x.o |
dumpbin /disasm x.o |
| 各节大小 | size x.o |
dumpbin /headers 末尾的 Summary |
| 查看依赖库 | ldd app |
dumpbin /dependents app.exe |
| 查看导出符号 | nm -D libx.so |
dumpbin /exports x.dll |
九、知识点清单
- 完整链路:预处理(
.c+.h → .i)→ 编译(.i → .s)→ 汇编(.s → .o)→ 链接(.o + 库 → 可执行文件)。 - 翻译单元 = 一个
.c加上它包含的所有头文件;编译器每次只看到一个翻译单元。 - 预处理只做文本替换:宏展开、头文件插入、条件编译、删注释;
#字符串化,##记号粘贴。 -
#include <stdio.h>展开成几千行,是因为头文件层层包含、大量条件编译分支,而且标准头里还带着内联函数定义。 - 编译阶段做词法分析、语法分析、语义分析、生成 IR、优化、生成汇编;这一阶段能查语法和类型,查不了"有没有定义"。
- 目标文件里有文件头、各种节、符号表、重定位表;地址都还是从 0 开始的待定值。
- ELF 常用节:
.text代码、.data已初始化数据、.bss零初始化数据(不占文件空间,NOBITS)、.rodata只读数据、.symtab符号表、.rela.text重定位表。 - 节是链接时的组织单位(
readelf -S),段是加载时的组织单位(readelf -l);VMA 是运行地址,LMA 是装载地址。 - 链接器做两件事:符号解析 + 重定位(合并节、分配地址、修补引用)。
- 强符号(函数定义、带初值的全局变量)不能重名;弱符号可以被强符号覆盖;未初始化的全局变量在旧工具链里是 COMMON 符号,GCC 10 起默认
-fno-common。 -
undefined reference to/LNK2019:声明了但没人定义,报在链接期。 -
multiple definition of/LNK2005:两个.o都定义了同名强符号,报在链接期,因为编译器看不到别的翻译单元。 - 静态库
.a/.lib是按需抽取的.o集合;GNUld单遍从左到右扫描,-l必须放在依赖它的目标文件后面(MSVC 链接器对顺序不敏感)。 - 动态库
.so/.dll链接时只记录依赖,运行时由动态链接器/PE 加载器映射进内存并填 GOT/IAT;-fPIC生成位置无关代码,-rdynamic把主程序符号导出给动态链接器。 - 声明放头文件、定义放
.c:声明可重复,定义只能一份;头文件里的定义会导致multiple definition。 -
static在全局位置表示内部链接(符号只在本翻译单元可见),在局部位置表示静态存储期(与链接无关)。 -
extern是"声明而非定义",常配合头文件使用;int g_count;在 C 里是暂定定义,不是纯声明。 - include guard 防的是同一翻译单元内的重复包含;
#pragma once等效但非标准。 - C++ 需要
extern "C"是因为名字修饰(name mangling):C++ 会把参数类型编进符号名,C 不会。