C语言编译与链接

从 .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 语法,它只做文本替换 。所有以 # 开头的行都是给它的指令,主要四类:

  1. 宏定义与展开:#define
  2. 文件包含:#include
  3. 条件编译:#if / #ifdef / #ifndef / #else / #elif / #endif
  4. 其他:#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 里只有几十个函数声明,展开后应该很短。实际上:

  1. 头文件层层包含 。stdio.h 会包含 stddef.h、stdarg.h、stdint.h 之类的标准头,标准头又包含编译器/运行库的私有头(MSVC 下是 vcruntime.h、sal.h、corecrt.h 等),一层套一层。
  2. 大量条件编译分支 。同一个头文件要同时支持 32/64 位、C/C++、不同版本的运行库,真正参与编译的只是其中一小部分。预处理后剩下的空行、#line 标记也会占行数。
  3. 头文件里不只有声明,还有定义 。为了让一些短小的函数能被内联,标准库会把它们的实现直接写在头文件里(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)把汇编代码逐条翻译成机器码,并完成三件事:

  1. 把指令编码成二进制(操作码 + 操作数);
  2. 把代码、已初始化数据、未初始化数据分门别类放进不同的节(section);
  3. 生成符号表 和重定位表,记录哪些位置需要链接器来填。

输出的 .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 的现象。


五、链接:符号解析与重定位

链接器主要做两件事:

  1. 符号解析(symbol resolution):把每个"未定义符号"和某个目标文件/库里的"定义"对上号;
  2. 重定位(relocation):合并同类节,为它们分配运行时地址,然后修改所有引用处的地址。

5.1 符号解析:强符号、弱符号、未定义符号

每个目标文件的符号分三类:

  • 定义符号 :本文件提供了实现(T add、B g_count、D g_init);
  • 未定义符号 :本文件引用了、但没实现(U add);
  • 局部符号 :static 修饰的函数/全局变量(小写 t、b、d),不参与全局符号解析。

而"定义"又分强、弱:

类型 典型来源 规则
强符号(strong) 函数定义、带初值的全局变量 同名强符号最多一个
弱符号(weak) __attribute__((weak)) 显式标注、未初始化的全局变量(COMMON) 同名强符号存在时,弱符号被忽略

链接器的三条规则:

  1. 不允许两个同名的强符号 同时存在 → multiple definition of 'g_count';
  2. 一强一弱,选强的;
  3. 两个弱符号,选占用空间大的那个(COMMON 场景)。

弱符号的实用价值:库可以提供一个"默认实现",用户在自己的代码里重新定义一个同名函数就能覆盖它,不用改库。Linux 内核和不少驱动框架都靠这个机制做钩子。

5.2 重定位:把地址填进去

符号解析完,链接器按顺序做:

  1. 合并节 :把所有 .o 的 .text 拼成一个 .text,所有 .data 拼成一个 .data......同名的 .bss 只累加大小;
  2. 分配地址:为合并后的每个节分配虚拟地址(VMA,Virtual Memory Address);
  3. 修正引用 :遍历每个 .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 为例:

  1. 内核加载可执行文件,发现它有 PT_INTERP 段,里面写着动态链接器的路径(如 /lib64/ld-linux-x86-64.so.2);
  2. 内核把控制权交给动态链接器,而不是程序的入口;
  3. 动态链接器读取可执行文件的 DT_NEEDED 项,得到依赖的 .so 列表;
  4. 按搜索路径(DT_RPATH/DT_RUNPATH、LD_LIBRARY_PATH、/etc/ld.so.cache、系统目录)找到每个 .so,用 mmap 映射进进程地址空间(多个进程可以共享同一份物理内存);
  5. 处理重定位:填写 GOT,绑定函数地址(可以延迟到第一次调用);
  6. 执行各库的初始化函数(.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 全局变量重复定义为什么链接期才报错?

把前面两节合起来就是答案:

  1. 编译器一次只处理一个翻译单元,物理上看不到 别的 .c;
  2. 每个 .c 单独看都合法:都定义了一个 int g_count;
  3. 符号的冲突只有在合并多个目标文件时才暴露,而这正是链接器的工作。

同理,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 集合;GNU ld 单遍从左到右扫描,-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 不会。

相关推荐
我闪了个去13 分钟前
三个机位、一台录音机:一个允许说“我不确定”的波形对齐
开源
你三大爷14 分钟前
从 Hessian 迁移到 protostuff:深嵌套模型反序列化耗时与高并发类加载锁的根治实战
后端·架构
IT枫斗者枫哥15 分钟前
子线程断言失败,JUnit 为什么还显示通过?
java·后端
stark张宇2 小时前
Vibe Coding 入门实战:用 Claude Code 跑通 AI 产品开发流程(上)
后端·claude·vibecoding
考虑考虑3 小时前
JDK27中的Set.ofLazy
java·后端·java ee
菩提小狗4 小时前
每日极客日报 · 2026年11月13日
ai·开源·极客日报·it热点·技术资讯
乌暮4 小时前
Spring AOP 与 Spring 事务:用大白话彻底理解原理与实践
java·数据库·spring boot·后端·sql·学习·spring
wflynn4 小时前
GitHub 日榜趋势速报 | 2026-11-14
开源·github
我不会起名字3225 小时前
未接入最终读上限的参数:`websocketMaxMsgLen` 与 12.5 倍差距
后端·websocket·go·即时通讯·im·故障排查