C/C++ 编译四阶段全流程拆解
本文用 真正的 gcc/g++ 13.3.0 (WSL2 Ubuntu 24.04)把 C/C++ 的编译过程拆成
预编译 → 编译 → 汇编 → 链接 四个阶段逐一执行,每一步的输入都是上一步的产物文件 ,
所有行数、字节数、地址、符号全部来自 readelf/map/nm 的实测输出,没有一个数字是估算的 ;
四个演示项目的 8 个源码文件全文收录在第三节 ,复制即可复现,文末附完整命令清单。
全部源码与构建脚本已开源:https://github.com/hwwei521/c-cpp-elf-demo
全文较长,建议收藏后食用。
一、为什么要写这篇
本文的做法:四个项目 (C 单文件 / C 多文件+静态库 / C++ 单文件 / C++ 多文件+静态库),
每个项目严格走完四阶段链条,用 binutils 全家桶(readelf/nm/size/c++filt)+ GNU ld 的 map 文件
把每个阶段的产物解剖给你看。
实测环境(版本号都经得起核对):
| 组件 | 版本 / 配置 |
|---|---|
| 操作系统 | WSL2(Linux 6.18.40.1-microsoft-standard-WSL2)+ Ubuntu 24.04 |
| C/C++ 编译器 | gcc / g++ 13.3.0(Ubuntu 包版本 13.3.0-6ubuntu2~24.04.1) |
| 链接器/汇编器/解析工具 | GNU ld、as、ar、readelf、nm、size、c++filt(binutils 2.42) |
| 编译选项 | -O0 -g0 -Wall(关优化保持源码对应,关调试信息让段表干净) |
任何 Linux 发行版(gcc 12+)都能复现,数字可能略有出入,结论完全一致。
二、四阶段总览:一条严格的链条
text
main.c / main.cpp 源代码
│
│ ① 预编译 (Preprocessing) gcc -E / g++ -E
▼ 展开 #include、替换 #define 宏、删除注释
main.i / main.ii 仍是 C/C++ 文本(纯翻译单元)
│
│ ② 编译 (Compilation) gcc -S main.i ← 输入必须是①的产物
▼ 词法/语法/语义分析 → 中间代码 → 代码生成
main.s x86-64 汇编文本
│
│ ③ 汇编 (Assembly) gcc -c main.s ← 输入必须是②的产物
▼ GNU as 翻译成机器码 + 生成重定位表
main.o ELF64 REL(可重定位,地址全为 0)
│
│ ③.5 归档(多文件项目) ar rcs lib*.a *.o
▼
libvector.a 静态库 = .o 的归档 + 符号索引
│
│ ④ 链接 (Linking) gcc main.o -L. -lvector -o app -Wl,-Map=app.map
▼ 符号解析 + 重定位 + 同名节合并 + 拼入 crt 启动件
app ELF64 DYN/PIE(可执行!)
四个阶段的实测耗时(毫秒)------先建立个体感:
| 项目 | ① 预编译 | ② 编译 | ③ 汇编 | ④ 链接 |
|---|---|---|---|---|
| C 单文件 | 27 | 18 | 15 | 113 |
| C++ 单文件 | 137 | 140 | 19 | 143 |
三个观察:
- C++ 的①②比 C 慢约 8 倍,时间全花在解析 90 万字符的预处理产物上(第四节见);
- ③汇编永远最快------它是纯机械翻译,不理解任何语义;
- ④链接比想象中重------它要拼入 C 运行时启动件、生成动态链接信息,是 C 项目里最慢的单阶段。
三、演示源码:8 个文件全文,让每个段都有"演员"
四个项目的完整源码如下(与实测构建用的文件一字不差,可直接复制复现):
text
demo/
├── c_single/ main.c 项目1:C 单文件(段分布大全)
├── c_multi/ main.c vector.c vector.h 项目2:C 多文件 + 静态库
├── cpp_single/ main.cpp 项目3:C++ 单文件(vtable/模板/init_array)
└── cpp_multi/ main.cpp shape.cpp shape.h 项目4:C++ 多文件 + 静态库
3.1 项目 1:C 单文件 c_single/main.c(84 行)
每个全局对象都被刻意放置到不同的段中,便于用 readelf 观察:
c
/*
* ============================================================================
* C 单文件示例 ------ 覆盖 ELF 的各个典型段
* ============================================================================
* .text 代码段(函数机器指令)
* .rodata 只读数据段(const 常量、字符串字面量)
* .data 已初始化全局/静态变量(可读写)
* .bss 未初始化(或初始化为 0)的全局/静态变量(不占文件体积)
*/
#include <stdio.h>
/* ========================= .data 段:已初始化的全局变量 ========================= */
int g_counter = 42; /* 4 字节,SHT_PROGBITS,可写 */
double g_ratio = 3.14159; /* 8 字节 */
char g_name[] = "deep_learn"; /* 数组初始化 → 内容拷贝进 .data */
int g_array[4] = {1, 2, 3, 4};
/* ========================= .bss 段:未初始化的全局变量 ========================== */
int g_bigbuf[4096]; /* 16 KB,只记录大小,不占 ELF 文件空间 */
char g_flag;
double g_result;
/* ========================= .rodata 段:只读常量 ================================= */
const char g_msg[] = "Hello, ELF sections!"; /* const 数组 → .rodata */
const int g_lut[8] = {1, 1, 2, 3, 5, 8, 13, 21};
const char *const g_pp = "pointer to const string";/* 指针本身也是 const */
/* 注意:普通 char *p = "str"; 中 p 在 .data,"str" 在 .rodata */
char *g_ptr_to_literal = "literal in rodata";
/* ========================= 文件作用域 static 变量 =============================== */
static int s_calls = 0; /* 已初始化 → .data(符号为 LOCAL) */
static int s_hidden; /* 未初始化 → .bss (符号为 LOCAL) */
static const char s_tag[] = "[static]"; /* → .rodata */
/* ========================= .text 段:函数 ===================================== */
int add(int a, int b)
{
return a + b;
}
static int next_call_id(void) /* static 函数 → .text,符号 LOCAL */
{
s_calls++;
return s_calls;
}
/* switch 跳转表演示:编译器可能生成 .rodata 中的跳转表 */
const char *level_name(int level)
{
switch (level) {
case 0: return "zero";
case 1: return "one";
case 2: return "two";
case 3: return "three";
default: return "unknown";
}
}
void compute(void)
{
g_result = 0.0;
for (int i = 0; i < 4096; i++) {
g_bigbuf[i] = i * 2;
g_result += g_bigbuf[i] * g_ratio;
}
g_result += add(g_counter, g_array[3]);
}
int main(void)
{
printf("%s %s\n", s_tag, g_msg);
printf("call id = %d\n", next_call_id());
printf("g_counter = %d\n", g_counter);
printf("g_name = %s\n", g_name);
printf("g_ptr = %s\n", g_ptr_to_literal);
printf("lut[7] = %d\n", g_lut[7]);
printf("level(2) = %s\n", level_name(2));
compute();
printf("g_result = %.2f (bss var written at runtime)\n", g_result);
printf("g_flag = %d (bss auto zero-init)\n", (int)g_flag);
return 0;
}
⚠ 注意源码里
s_calls的注释写着"已初始化 → .data"------这是教科书常识,但实测结果打脸了 :初值为 0 的变量被 gcc 编成
.comm公共符号、最终落进.bss。这正是本文"一切以实测为准"的第一个案例,详见第五节和第九节。
3.2 项目 2:C 多文件 + 静态库 c_multi/
vector.h(31 行)------头文件里只有声明,没有实体:
c
/*
* vector.h ------ 简易整型向量库(头文件)
* 头文件在预处理阶段被"原样展开"进翻译单元:
* - 只有声明(函数原型 / extern 变量 / 类型 / 宏)不会产生实体数据
* - 实体的定义在 vector.c 中,编译后位于 vector.o 的各个段
*/
#ifndef VECTOR_H
#define VECTOR_H
#include <stddef.h>
#define VECTOR_INIT_CAP 8
typedef struct {
int *data; /* 堆内存指针(运行时 malloc,不属于任何静态段) */
size_t size;
size_t cap;
} Vector;
/* extern 声明:告诉编译器"定义在别的 .o 里",链接器负责解析地址 */
extern int g_vector_total_created; /* 定义在 vector.c → .data */
extern const char g_vector_lib_name[]; /* 定义在 vector.c → .rodata */
void vector_init(Vector *v);
void vector_push(Vector *v, int value);
int vector_sum(const Vector *v);
void vector_free(Vector *v);
void vector_print(const Vector *v);
#endif /* VECTOR_H */
vector.c(62 行)------实体定义,将归档为 libvector.a:
c
/*
* vector.c ------ 向量库实现(独立翻译单元)
* 单独编译成 vector.o 时:
* - 本文件定义的实体分别进入 .text/.data/.rodata/.bss
* - 链接阶段由链接器把两个 .o 的同名段"拼接"成最终可执行文件的段
*/
#include "vector.h"
#include <stdio.h>
#include <stdlib.h>
/* ---- .data:已初始化全局变量(被 main.c 用 extern 引用)---- */
int g_vector_total_created = 0;
/* ---- .rodata:常量数组 ---- */
const char g_vector_lib_name[] = "mini-vector-lib v1.0";
/* ---- .bss:未初始化全局变量 ---- */
static size_t s_total_pushes; /* static → 符号表中标记 LOCAL */
/* ---- .text ---- */
void vector_init(Vector *v)
{
v->data = malloc(VECTOR_INIT_CAP * sizeof(int));
if (!v->data) { perror("malloc"); exit(1); }
v->size = 0;
v->cap = VECTOR_INIT_CAP;
g_vector_total_created++;
}
void vector_push(Vector *v, int value)
{
if (v->size == v->cap) { /* 扩容 */
v->cap *= 2;
v->data = realloc(v->data, v->cap * sizeof(int));
if (!v->data) { perror("realloc"); exit(1); }
}
v->data[v->size++] = value;
s_total_pushes++; /* .bss 变量运行时自增 */
}
int vector_sum(const Vector *v)
{
int s = 0;
for (size_t i = 0; i < v->size; i++) s += v->data[i];
return s;
}
void vector_free(Vector *v)
{
free(v->data);
v->data = NULL;
v->size = v->cap = 0;
}
void vector_print(const Vector *v)
{
printf("vector(%zu/%zu, pushes=%zu): [", v->size, v->cap, s_total_pushes);
for (size_t i = 0; i < v->size; i++) printf("%d%s", v->data[i], i + 1 < v->size ? ", " : "");
printf("] sum=%d\n", vector_sum(v));
}
main.c(41 行)------编译时看不到 vector.c,调用全是 UND 符号:
c
/*
* main.c ------ C 多文件示例的主程序(翻译单元 1)
* 编译时看不到 vector.c 的实现,vector_push 等调用在 main.o 中
* 只是"未定义符号 (UND)",并生成重定位条目;链接时由 vector.o 填补。
*/
#include <stdio.h>
#include "vector.h" /* 预处理阶段被展开到此处 */
/* ---- 本翻译单元自己的段内容 ---- */
int g_main_seed = 7; /* .data */
int g_main_scratch[512]; /* .bss */
const char g_title[] = "C multi-file demo"; /* .rodata */
/* 由链接器在 vector.o 中找到定义并绑定地址 */
static int fib_like(int n) /* .text, LOCAL */
{
if (n < 2) return n;
return fib_like(n - 1) + fib_like(n - 2);
}
int main(void)
{
Vector v;
vector_init(&v); /* → 重定位到 vector.o 的 .text */
for (int i = 0; i < 12; i++)
vector_push(&v, fib_like(i) + g_main_seed);
printf("== %s ==\n", g_title);
printf("lib: %s\n", g_vector_lib_name); /* extern → vector.o .rodata */
vector_print(&v);
printf("total vectors created: %d\n", g_vector_total_created); /* extern → vector.o .data */
/* 用一下 .bss 数组,证明运行时可写 */
for (int i = 0; i < 512; i++) g_main_scratch[i] = i;
printf("scratch[511] = %d\n", g_main_scratch[511]);
vector_free(&v);
return 0;
}
3.3 项目 3:C++ 单文件 cpp_single/main.cpp(87 行)
覆盖 C++ 特有的 ELF 内容:.init_array、.data.rel.ro(vtable)、名字修饰、模板实例化:
cpp
/*
* ============================================================================
* C++ 单文件示例 ------ 除 C 的段之外,重点展示 C++ 特有的 ELF 内容
* ============================================================================
* .init_array 全局对象构造函数指针表(main 之前执行)★C++ 特有
* .data.rel.ro 虚函数表 vtable、typeinfo(重定位后只读)★C++ 特有
* 名字修饰 符号表中可见 _Z... 形式的 mangled name
*/
#include <iostream>
#include <string>
/* ===================== .data / .bss:全局对象 ===================== */
int g_tick = 100; // .data(POD,值直接写入文件)
std::string g_app_name = "cpp_single"; // .data + .init_array(构造函数)
double g_samples[128]; // .bss (零初始化,不占文件体积)
/* ===================== .rodata:常量 ===================== */
const char kBanner[] = "C++ single-file ELF demo";
const double kPi = 3.14159265358979;
/* ===================== 类:虚函数 → vtable (.data.rel.ro) ===================== */
class Sensor {
public:
Sensor() : id_(++s_created) {} // 构造函数 → .text(修饰名 _ZN6SensorC1Ev)
virtual ~Sensor() = default; // 虚析构 → vtable 条目
virtual const char *kind() const = 0; // 纯虚 → vtable 中指向 __cxa_pure_virtual
virtual double read() const; // 虚函数 → .text + vtable 条目
int id() const { return id_; }
static int instance_count() { return s_created; } // inline → .text
private:
int id_;
static int s_created; // 静态成员声明
};
int Sensor::s_created = 0; // 静态成员定义 → .data
double Sensor::read() const { return id_ * kPi; } // .text
class TempSensor : public Sensor { // 派生类:另一张 vtable
public:
const char *kind() const override { return "temperature"; } // inline 虚函数
double read() const override;
};
double TempSensor::read() const { return 25.0 + id(); }
/* ===================== 全局对象:构造函数指针进入 .init_array ===================== */
TempSensor g_boot_sensor; // main 之前必须构造 → .init_array
/* ===================== 模板:实例化发生在编译期,代码进 .text ===================== */
template <typename T>
T max3(T a, T b, T c)
{
T m = a > b ? a : b;
return m > c ? m : c;
}
/* ===================== .text ===================== */
static void report(const Sensor &s) // static → 符号 LOCAL
{
std::cout << " sensor#" << s.id()
<< " kind=" << s.kind() // 通过 vtable 动态分派
<< " read=" << s.read() << "\n";
}
int main()
{
std::cout << kBanner << "\n";
std::cout << "app_name(.data+init_array): " << g_app_name << "\n";
report(g_boot_sensor); // 全局对象(.init_array 已构造)
TempSensor local;
report(local);
/* 两次模板实例化 → .text 中两份不同的修饰符号
_Z4max3IiET_S0_S0_S0_ 与 _Z4max3IdET_S0_S0_S0_ */
std::cout << "max3<int>(3,9,4) = " << max3<int>(3, 9, 4) << "\n";
std::cout << "max3<double>(1.5,9.25,3.75) = " << max3<double>(1.5, 9.25, 3.75) << "\n";
g_samples[0] = g_tick; // .bss 运行时写入
std::cout << "s_created(.data static member) = " << Sensor::instance_count() << "\n";
return 0;
}
3.4 项目 4:C++ 多文件 + 静态库 cpp_multi/
shape.h(51 行)------类定义被两个翻译单元共同包含(ODR 的舞台):
cpp
/*
* shape.h ------ 图形库接口(被两个翻译单元共同包含)
* 预处理后,main.cpp 与 shape.cpp 各自持有一份完全相同的声明。
* 类定义本身不产生实体数据;vtable/成员函数的实体在 shape.o 中生成,
* 链接时 main.o 通过未定义符号 + 重定位找到它们。
*/
#ifndef SHAPE_H
#define SHAPE_H
#include <string>
/* 抽象基类:vtable 将出现在 shape.o 的 .data.rel.ro */
class Shape {
public:
explicit Shape(std::string name);
virtual ~Shape() = default;
virtual double area() const = 0; // 纯虚
virtual const char *describe() const; // 普通虚函数
const std::string &name() const { return name_; } // inline → 各 .o 的 .text(弱符号)
static int alive_count(); // 静态成员函数
private:
std::string name_;
static int s_alive; // 静态成员声明
static const char s_lib_tag[]; // const 静态成员(类内初始化)
};
class Circle : public Shape {
public:
explicit Circle(double r);
double area() const override;
const char *describe() const override;
private:
double radius_;
};
class Rect : public Shape {
public:
Rect(double w, double h);
double area() const override;
private:
double w_, h_;
};
/* 跨文件 extern 全局变量:定义在 shape.cpp */
extern int g_shape_seq; // .data
extern double g_total_area; // .bss(零初始化)
#endif /* SHAPE_H */
shape.cpp(60 行)------vtable/构造/静态成员的实体,将归档为 libshape.a:
cpp
/*
* shape.cpp ------ 图形库实现(翻译单元 2)
* 单独编译为 shape.o:
* - 包含本文件所有实体的段(.text/.data/.rodata/.bss/.data.rel.ro)
* - 对 std::string 等库符号的引用是 UND(未定义),等待链接
* - vtable 里的函数地址在链接前是重定位条目(.rela.data.rel.ro)
*/
#include "shape.h"
#include <cmath>
#include <cstdio>
/* ===================== .data / .bss ===================== */
int Shape::s_alive = 0; // 静态成员定义 → .data
int g_shape_seq = 1000; // → .data
double g_total_area; // → .bss
/* ===================== .rodata ===================== */
const char Shape::s_lib_tag[] = "shape-lib"; // → .rodata
static const double kUnitScale = 1.0; // → .rodata(LOCAL)
/* ===================== .text ===================== */
Shape::Shape(std::string name) // 修饰名 _ZN5ShapeC2ENSt7__cxx1112basic_string...
: name_(std::move(name))
{
s_alive++;
g_shape_seq++;
}
const char *Shape::describe() const
{
return "generic shape";
}
int Shape::alive_count() { return s_alive; }
Circle::Circle(double r) : Shape("circle"), radius_(r) {}
double Circle::area() const
{
return M_PI * radius_ * radius_ * kUnitScale; // 调用 libm → UND 符号
}
const char *Circle::describe() const
{
return "circle: pi*r^2";
}
Rect::Rect(double w, double h) : Shape("rect"), w_(w), h_(h) {}
double Rect::area() const
{
return w_ * h_;
}
/* 工厂函数:演示跨文件调用的普通符号 */
Shape *make_default_shape()
{
return new Circle(1.0);
}
main.cpp(45 行)------对 shape.o 的一切引用在链接前都是修饰名 UND:
cpp
/*
* main.cpp ------ C++ 多文件示例主程序(翻译单元 1)
* main.o 中:
* - Shape/Circle/Rect 的构造函数、vtable 全部是 UND 符号
* - 虚调用生成的代码引用 "vtable@GOTPCREL" 重定位,链接时绑定到 shape.o
*/
#include <iostream>
#include "shape.h"
/* shape.cpp 中定义、未在头文件声明的工厂函数 ------ 手工声明 */
Shape *make_default_shape();
/* ---- 本翻译单元的段 ---- */
int g_run_id = 1; // .data
int g_hist[256]; // .bss
const char g_prog[] = "cpp_multi demo"; // .rodata
/* 全局对象:其构造函数地址会登记到最终可执行文件的 .init_array */
static Circle g_unit_circle(2.0); // .bss/.data + .init_array(LOCAL)
int main()
{
std::cout << "== " << g_prog << " ==\n";
Rect r(3.0, 4.0); // 构造 → 重定位到 shape.o .text
Shape *d = make_default_shape();
Shape *shapes[] = { &g_unit_circle, &r, d };
g_total_area = 0.0;
for (Shape *s : shapes) {
std::cout << " " << s->name()
<< " area=" << s->area() // 虚调用:查 vtable
<< " (" << s->describe() << ")\n";
g_total_area += s->area();
}
std::cout << "total_area(.bss in shape.o) = " << g_total_area << "\n";
std::cout << "alive_count = " << Shape::alive_count() << "\n";
std::cout << "g_shape_seq(.data in shape.o) = " << g_shape_seq << "\n";
g_hist[0] = static_cast<int>(g_total_area); // 运行时写 .bss
delete d;
return 0;
}
四、阶段① 预编译:亲眼看见 87 行代码膨胀成 3.7 万行
命令与产物(C 用 -E 输出 .i,C++ 惯例输出 .ii):
bash
gcc -O0 -g0 -Wall -E main.c -o build/main.i
六个翻译单元的实测膨胀数据:
| 项目 | 源码 | 预处理产物 | 膨胀倍数 |
|---|---|---|---|
| C 单文件 main.c | 84 行 / 3 238 B | 888 行 / 22 750 B | 10.6×(stdio.h) |
| C 多文件 main.c | 41 行 | 886 行 / 22 858 B | 21.6×(stdio.h + vector.h) |
| C 多文件 vector.c | 62 行 | 2 008 行 / 50 500 B | 32.4×(+stdlib.h) |
| C++ 单文件 main.cpp | 87 行 / 3 838 B | 36 658 行 / 909 218 B | 421×(iostream/string) |
| C++ 多文件 main.cpp | 45 行 | 36 665 行 | 815× |
| C++ 多文件 shape.cpp | 60 行 | 35 592 行 / 964 292 B | 593×(cmath/cstdio/string) |
一个 C++ 程序员每天都在为这 90 万字符买单 ------这就是为什么 C++ 编译慢、
为什么要有预编译头/模块(C++20 modules)、为什么头文件要写 include guard。
三个可验证的实测结论:
.i文件里残留的#include/#define数量 = 0 ------所有预处理指令消失,只剩纯 C 文本
和# 1 "main.c"这样的行号标记(给编译器报错用);- 头文件是文本级复制 :vector.h 的
extern int g_vector_total_created;在 main.i 中原样出现; - 宏是文本级替换 :vector.c 里的
malloc(VECTOR_INIT_CAP * sizeof(int))
在 vector.i 中实测变成了malloc(8 * sizeof(int))。
核心概念 :①之后,编译器眼里的世界只有这一个
.i文件------这就是翻译单元(Translation Unit)。它根本不知道别的
.c文件存在,跨文件的函数调用此刻只是一个名字。
五、阶段② 编译:.s 文件里已经写明了每个符号的命运
bash
gcc -O0 -g0 -Wall -S build/main.i -o build/main.s # 注意:输入是 .i,不是 .c!
实测:888 行 .i → 373 行 .s (6 143 B),其中 317 条汇编指令 + 196 条伪指令。
下面是 main.s 的原文摘录(未加工),段归属一目了然:
asm
.file "main.c"
.text
.globl g_counter # 全局符号(链接器可见)
.data # ★ 切换到 .data 段
.align 4
.type g_counter, @object
.size g_counter, 4
g_counter:
.long 42 # 初始值 42 直接写进文件
...
.bss # ★ 切换到 .bss 段
.align 32
.type g_bigbuf, @object
.size g_bigbuf, 16384
g_bigbuf:
.zero 16384 # 只记录 16KB 大小,不写 16384 个 0!
...
.globl g_msg
.section .rodata # ★ 只读段
g_msg:
.string "Hello, ELF sections!"
...
g_ptr_to_literal:
.quad .LC1 # 指针本体在可写段,指向 .rodata 里的字面量
.local s_calls # ★ static → 符号绑定 LOCAL,别的 .o 永远看不见
.comm s_calls,4,4 # ★ 注意这里!显式 =0 被编成 .comm 公共符号
两个容易讲错的细节,实测给出了准确答案:
static int s_calls = 0;明明"已初始化",gcc 却发了.comm(公共符号)而不是.data------
链接器会把所有零初始化的公共符号 并入.bss。所以"已初始化→.data"的准确说法是
"初始值非零→.data"(第八节 readelf 会验证它确实躺在 .bss)。- 编译期告警
warning: 's_hidden' defined but not used不影响任何段归属,
未使用的 static 变量照样在.bss里占 4 字节内存(但不占文件)。
5.1 C++ 的②阶段:名字修饰、vtable、init_array 在此诞生
bash
g++ -O0 -g0 -Wall -S build/main.ii -o build/main.s # 36 658 行 → 1 364 行汇编
C++ 特有的三类构造在 .s 里全都能看到(原文摘录):
asm
# ── COMDAT 折叠组:inline/模板函数放独立小节,链接时可跨 .o 去重 ──
.section .text._ZN6SensorC2Ev,"axG",@progbits,_ZN6SensorC2Ev,comdat
.weak _ZN6SensorC2Ev # ★ WEAK 绑定(不是 GLOBAL!)
_ZN6SensorC2Ev: # Sensor::Sensor() 的修饰名
movl _ZN6Sensor9s_createdE(%rip), %eax
# ── vtable 实体:6 个 .quad = 48 字节 ──
.section .data.rel.ro._ZTV10TempSensor,"awG",@progbits,_ZTV10TempSensor,comdat
_ZTV10TempSensor:
.quad 0 # 槽0: offset-to-top
.quad _ZTI10TempSensor # 槽1: typeinfo 指针(RTTI)
.quad _ZN10TempSensorD1Ev # 槽2: 虚析构(完整对象)
.quad _ZN10TempSensorD0Ev # 槽3: 虚析构(删除)
.quad _ZNK10TempSensor4kindEv # 槽4: 虚函数 kind()
.quad _ZNK10TempSensor4readEv # 槽5: 虚函数 read()
# ── 构造函数把虚表指针写进对象:+16 正是跳过槽0和槽1 ──
leaq 16+_ZTV10TempSensor(%rip), %rdx
# ── 全局对象构造包装函数(它的地址稍后进 .init_array)──
_GLOBAL__sub_I_g_tick: # 负责 g_app_name、g_boot_sensor 的构造
名字修饰(name mangling)对照表 (Itanium ABI,gcc/clang 通用,全部经 c++filt 实测还原):
| 源码写法 | ELF 符号 | 含义 |
|---|---|---|
Sensor::Sensor() |
_ZN6SensorC2Ev |
N...E=嵌套名,6=名字长度,C2=基类构造,Ev=void 参数 |
TempSensor::read() const |
_ZNK10TempSensor4readEv |
K=const 成员函数 |
max3<int> |
_Z4max3IiET_S0_S0_S0_ |
I...E=模板实参 |
max3<double> |
_Z4max3IdET_S0_S0_S0_ |
同模板不同实例 → 不同符号,两份代码 |
Sensor::s_created |
_ZN6Sensor9s_createdE |
静态数据成员 |
vtable for TempSensor |
_ZTV10TempSensor |
TV = virtual table |
typeinfo for Sensor |
_ZTI6Sensor |
TI = typeinfo(RTTI) |
修饰的本质:把参数类型、类作用域、const 属性 编码进符号名------重载和模板就是靠它实现的。
这也是 C++ 链接必须用 g++ 驱动(自动链 libstdc++)的原因。
六、阶段③ 汇编:.o 文件的"有名无实"
bash
gcc -O0 -g0 -Wall -c build/main.s -o build/main.o
readelf -h build/main.o # Type: REL (Relocatable file)
实测:main.o 只有 4 784 字节,但它的节头表(readelf -S -W 原文整理)声称自己装着 16 KB:
| Nr | 节名 | 类型 | 地址 | 大小 | 属性 | 说明 |
|---|---|---|---|---|---|---|
| 1 | .text |
PROGBITS | 0 | 0x27e (638 B) | A X | 5 个函数的机器码 |
| 2 | .rela.text |
RELA | 0 | 0x450 | I | .text 的重定位表(48 条"欠条") |
| 3 | .data |
PROGBITS | 0 | 0x30 (48 B) | W A | g_counter=42 等非零初值 |
| 4 | .bss |
NOBITS | 0 | 0x4018 (16 408 B) | W A | 文件中不占一个字节 |
| 5 | .rodata |
PROGBITS | 0 | 0x170 (368 B) | A | g_msg/g_lut/跳转表 |
| 13 | .eh_frame |
PROGBITS | 0 | 0xb8 | A | 栈展开信息 |
| 15 | .symtab |
SYMTAB | 0 | 0x258 | 符号表(链接用,可 strip) |
三个关键点:
- NOBITS 的魔法 :
.bss大小 16 408 B > 整个文件 4 784 B。NOBITS 节只在节头里记一个数字,
加载时由内核清零分配------int g_bigbuf[4096]若改成= {1,2,...}非零初始化,文件立刻胖 16 KB; - 所有节地址都是 0 :
.o里的代码还不知道自己将来住哪,每次跨节引用都登记在
.rela.*重定位表里(本例 53 条R_X86_64_*),等链接器填坑; .o不可执行:没有入口点,UND 符号一堆。
C++ 的 main.o(21 800 B)比 C 的多出三样东西(readelf 实测):
- WEAK 符号 :模板实例
_Z4max3IiET_.../_Z4max3IdET_...、虚表_ZTV6Sensor、
inline 成员函数、构造/析构多版本(C1/C2/D0/D1/D2)------它们可能在多个 .o 里各有一份,
链接器任取一份去重,不算重复定义错误; - 30+ 个 COMDAT 小节 :
.text._ZN6SensorC2Ev(57B)、.data.rel.ro._ZTV6Sensor(48B)、
.rodata._ZTS6Sensor(8B)......每个 inline/模板实体独立成节,就是为去重服务的; .init_array8 B:本翻译单元全局构造包装函数的指针 ×1。
七、阶段③.5 归档:静态库的本质就是"带索引的压缩包"
bash
ar rcs build/libvector.a build/vector.o # 实测 3 678 B(vector.o 本体 3 416 B)
nm -s build/libvector.a # 查看归档符号索引
nm -s 实测输出------这个索引就是链接器"查字典"的依据:
text
Archive index:
g_vector_total_created in vector.o
g_vector_lib_name in vector.o
vector_init in vector.o
vector_push in vector.o
vector_sum in vector.o
vector_free in vector.o
vector_print in vector.o
.a只是 ar 归档(tar 的近亲),不含任何编译动作 。链接器先查这份索引,只把被引用到的成员
.o抽进链接 ------库里没用到的.o会被整个跳过,这就是"静态库不会让二进制无脑膨胀"的原理。抽取过程会记录在 map 文件里(第九节)。
八、阶段④ 链接:一场精确到字节的"平账"
8.1 链接前的符号账本:欠账 vs 存货
readelf -s -W 实测 main.o 的未定义符号(UND)------它调用了却看不见实现:
text
U printf ← libc 里(运行时动态解析)
U __stack_chk_fail ← libc 的栈保护
U vector_init / vector_push / vector_print / vector_free ← vector.o 的 .text 里
U g_vector_lib_name ← vector.o 的 .rodata 里
U g_vector_total_created ← vector.o 的 .bss 里
vector.o 的存货(已定义的 GLOBAL 符号)恰好平账:
text
FUNC vector_init / vector_push / vector_sum / vector_free / vector_print (节1=.text)
OBJECT g_vector_lib_name (节5=.rodata)
OBJECT g_vector_total_created (节4=.bss)
配不上账就是经典报错 undefined reference to 'xxx';配上了,进入下一步。
8.2 重定位表:地址的"空洞"长什么样
readelf -r -W main.o 实测节选:
text
偏移 类型 符号 加数
0000000000000062 R_X86_64_PLT32 vector_init -4
000000000000008b R_X86_64_PLT32 vector_push -4
00000000000000b3 R_X86_64_PLT32 printf -4
000000000000007b R_X86_64_PC32 g_main_seed -4
00000000000000ba R_X86_64_PC32 g_vector_lib_name -4
含义:.text 偏移 0x62 处有一条 call,操作数暂时是 0,链接时改写为
vector_init最终地址 − 下一条指令地址(PLT32=经 PLT 的相对调用);
数据引用在 PIE 下用 PC32 直接相对寻址。
8.3 map 文件:链接器亲笔写的"装配清单"
GNU ld 用 -Wl,-Map=app.map 直接生成 map(本文 4 个 app 全部生成)。map 是三段式结构,
第一段就回答了"链接器为什么拉进这个 .o":
text
Archive member included to satisfy reference by file (symbol)
libvector.a(vector.o)
main.o (g_vector_total_created) ← 抽取原因铁证
第三段"Linker script and memory map"记录了 .text 的完整装配过程(实测原文):
text
LOAD .../x86_64-linux-gnu/Scrt1.o ← 链接器自动吃进的 C 运行时启动件
LOAD .../crti.o LOAD .../13/crtbeginS.o
LOAD .../main.o LOAD .../libvector.a ← 你的代码和库
.text 0x0000000000001120 0x26 Scrt1.o
0x0000000000001120 _start ← 真正的程序入口!
.text 0x0000000000001146 0x0 crti.o
*fill* 0x0000000000001146 0xa ← 对齐填充也如实记录
.text 0x0000000000001150 0xb9 crtbeginS.o
.text 0x0000000000001209 0x170 main.o ← 你的代码 368 B
0x0000000000001248 main
.text 0x0000000000001379 0x2a1 libvector.a(vector.o) ← 库成员被抽入 673 B
0x0000000000001379 vector_init
0x00000000000013ec vector_push
...
对账验证 :0x26 + 0xb9 + 0x170 + 0x2a1 + 0xa(fill) = 0x4fa ,
与 readelf -S app 显示的 .text 大小 0x4fa 逐字节一致------段合并的每一次贡献都有据可查。
8.4 链接后:REL → DYN(PIE),每个符号有了最终户口
bash
readelf -h app
# Type: DYN (Position-Independent Executable file) Entry point address: 0x1120
两个常见误区被实测纠正:
- 入口不是 main !Entry 0x1120 指向 crt 的
_start(map 里可见),
它准备 argc/envp 后调__libc_start_main,后者先跑.init_array再进main; - gcc 在 Ubuntu 上默认生成 PIE (Type=DYN,地址从 0 起、加载基址由内核随机化 = ASLR),
不是老教材里的固定地址 EXEC。
跨文件符号的最终定址(readelf -s -W app 实测,节号:16=.text、18=.rodata、25=.data、26=.bss):
| 符号 | 定义于 | 所在节 | 最终地址 | 大小 |
|---|---|---|---|---|
main |
main.o | .text (16) | 0x1248 | 305 B |
fib_like(static) |
main.o | .text (16) LOCAL | 0x1209 | 63 B |
vector_init |
vector.o | .text (16) | 0x1379 | 115 B |
vector_push |
vector.o | .text (16) | 0x13ec | 199 B |
vector_print |
vector.o | .text (16) | 0x1548 | 210 B |
g_title |
main.o | .rodata (18) | 0x2010 | 18 B |
g_vector_lib_name |
vector.o | .rodata (18) | 0x2070 | 21 B |
g_main_seed (=7) |
main.o | .data (25) | 0x4010 | 4 B |
g_main_scratch[512] |
main.o | .bss (26) | 0x4040 | 2 048 B |
g_vector_total_created (=0) |
vector.o | .bss (26) | 0x4840 | 4 B ★初值0→.bss |
s_total_pushes(static) |
vector.o | .bss (26) LOCAL | 0x4848 | 8 B |
两个 .o 的
.text被拼进同一个最终.text,.data同理相邻拼接;static 符号绑定为 LOCAL,即使两个文件里有同名 static 变量也互不冲突。
九、readelf 验收:C 与 C++ 的段归属总表
9.1 C 单文件 app:教科书四大段的实测地址
| 符号 | 源码写法 | 地址 | 大小 | 绑定 | 节 |
|---|---|---|---|---|---|
main |
int main(void) |
0x129c | 299 B | GLOBAL | .text |
next_call_id |
static 函数 | 0x1161 | 31 B | LOCAL | .text |
g_counter |
int g_counter = 42 |
0x4010 | 4 B | GLOBAL | .data |
g_ptr_to_literal |
char *p = "字面量" |
0x4040 | 8 B | GLOBAL | .data(指针本体;字符串在 .rodata) |
g_msg / g_lut |
const |
0x2020 / 0x2040 | 21/32 B | GLOBAL | .rodata |
g_bigbuf |
int g_bigbuf[4096] |
0x4080 | 16 384 B | GLOBAL | .bss |
s_calls |
static int s_calls = 0 |
0x8090 | 4 B | LOCAL | .bss ★ |
★ 验证了第五节的预言:显式 = 0 的变量(经 .comm)最终落在 .bss,不占文件空间。
程序头视角 (readelf -l -W,内核加载只认这个):
text
Type Offset VirtAddr FileSiz MemSiz Flg
INTERP 0x000318 ... 0x00001c 0x00001c R ← /lib64/ld-linux-x86-64.so.2
LOAD 0x000000 0x0000000000000000 0x000658 0x000658 R ← ELF头+动态链接元数据
LOAD 0x001000 0x0000000000001000 0x0003d5 0x0003d5 R E ← .text .plt(可执行)
LOAD 0x002000 0x0000000000002000 0x000314 0x000314 R ← .rodata .eh_frame
LOAD 0x002db0 0x0000000000003db0 0x000298 0x0042e8 RW ← .data .bss
GNU_RELRO ... 0x000250 0x000250 R ← 启动重定位后改只读
GNU_STACK ... ← 栈不可执行(安全)
最后一个 LOAD:FileSiz=0x298 但 MemSiz=0x42e8------多出的 16 KB 就是 .bss ,
文件里不存在、加载时内核清零。整个 app 文件 16 680 B,却"承诺"了 19 680 B 的内存
(size 命令实测 text 2576 + data 664 + bss 16440)。
9.2 C++ 单文件 app:三个反直觉的实测结论
先看符号表(readelf -s -W,节号:16=.text、18=.rodata、24=.data.rel.ro、27=.data、28=.bss):
| 符号(修饰名) | 源码对应 | 绑定 | 节 | 地址 | 大小 |
|---|---|---|---|---|---|
_ZL7kBanner |
const char kBanner[] |
LOCAL | .rodata | 0x3010 | 25 B |
g_tick |
int g_tick=100 |
GLOBAL | .data | 0x5010 | 4 B |
_Z10g_app_nameB5cxx11 |
std::string g_app_name="cpp_single" |
GLOBAL | .bss ★ | 0x5160 | 32 B |
g_samples |
double g_samples[128] |
GLOBAL | .bss | 0x5180 | 1 024 B |
_ZN6Sensor9s_createdE |
Sensor::s_created = 0 |
GLOBAL | .bss ★ | 0x5580 | 4 B |
g_boot_sensor |
TempSensor g_boot_sensor |
GLOBAL | .bss | 0x5590 | 16 B |
_ZTV6Sensor |
Sensor 虚表 | WEAK | .data.rel.ro ★ | 0x4cb8 | 48 B |
_ZTV10TempSensor |
TempSensor 虚表 | WEAK | .data.rel.ro | 0x4c88 | 48 B |
_Z4max3IiET_S0_S0_S0_ |
max3<int> |
WEAK | .text | 0x2ac0 | 54 B |
_Z4max3IdET_S0_S0_S0_ |
max3<double> |
WEAK | .text | 0x2af6 | 78 B |
_GLOBAL__sub_I_g_tick |
全局构造包装 | LOCAL | .text | 0x27de | 15 B |
结论 1:std::string g_app_name = "cpp_single" 的存储在 .bss,不在 .data!
非 POD 全局对象在静态初始化前必须全零,字符串内容要等运行时 .init_array 里的构造函数写入。
实测 libstdc++ 的 string 对象是 32 字节(libc++ 是 24 字节------sizeof(std::string) 是实现细节)。
结论 2:vtable 在 .data.rel.ro,不在 .rodata。
虚表里存的是函数地址 ,链接前需要重定位(所以可写),启动完成后被 RELRO 机制
mprotect 成只读。48 B = 2 个保留槽 + 4 个虚函数指针,与第五节的 .s 逐行对应。
结论 3:.init_array 里到底是谁? map 文件直接给出名单:
text
.init_array 0x0000000000004c70 0x8 crtbeginS.o ← frame_dummy(注册异常帧)
.init_array 0x0000000000004c78 0x8 main.o ← _GLOBAL__sub_I_g_tick
main 之前,C 运行时遍历这张表逐个调用------"全局对象先于 main 构造"的实现本质就这 16 个字节。
再看动态依赖(readelf -d)------g++ 与 gcc 链接的差异一目了然:
text
NEEDED libstdc++.so.6 ← g++ 自动追加(gcc 不会)
NEEDED libgcc_s.so.1
NEEDED libc.so.6
9.3 C++ 多文件 app:跨文件的虚表与 ODR
main.o 的 UND 是修饰名 (实测):_ZN4RectC1Edd、_ZN6CircleC1Ed、_ZTV4Rect/_ZTV5Shape/_ZTV6Circle、
g_shape_seq、g_total_area......全部由 libshape.a(shape.o) 在④平账(map 里有抽取记录)。
修饰名必须逐字节一致才能配对------这就是"声明与定义的类布局必须相同"(ODR 单一定义规则)的链接层原因。
几个有意思的实测细节:
| 符号 | 绑定 | 节 | 地址 | 大小 | 说明 |
|---|---|---|---|---|---|
_ZTV5Shape/_ZTV6Circle/_ZTV4Rect |
WEAK | .data.rel.ro | 0x4c90/0x4c60/0x4c30 | 48 B ×3 | 虚表只在定义了 key function 的 shape.o 生成 |
_ZN6CircleC1Ed |
GLOBAL | .text | 0x2962 | 225 B | 构造函数有 C1/C2 两版本(完整对象/基对象) |
main |
GLOBAL | .text | 0x23e9 | 863 B | 对比 C 版 main 305 B:iostream 内联展开的代价 |
_ZL13g_unit_circle |
LOCAL | .bss | 0x5560 | 48 B | = vptr(8) + string(32) + radius(8),构造前全零 |
g_shape_seq (=1000) |
GLOBAL | .data | 0x5020 | 4 B | 与 main.o 的 g_run_id (0x5010) 拼接在同一 .data |
g_total_area |
GLOBAL | .bss | 0x5598 | 8 B | shape.o 的 .bss,main.cpp 里 extern 引用 |
十、四个程序全部原生运行成功(实测输出)
C 单文件------每个段的语义都被运行时验证:
text
[static] Hello, ELF sections! ← .rodata 的两个常量
call id = 1 ← s_calls(.bss, LOCAL) 运行时自增
g_counter = 42 ← .data 初值随文件加载
g_ptr = literal in rodata ← 指针在 .data,字面量在 .rodata
level(2) = two ← switch 跳转表
g_result = 52694312.06 (bss var written at runtime)
g_flag = 0 (bss auto zero-init) ← .bss 自动零初始化
C 多文件------跨文件链接与静态库验证:
text
== C multi-file demo ==
lib: mini-vector-lib v1.0 ← 来自 vector.o 的 .rodata
vector(12/16, pushes=12): [7, 8, 8, 9, 10, 12, 15, 20, 28, 41, 62, 96] sum=316
total vectors created: 1 ← 来自 vector.o 的 .bss 计数器
C++ 单文件------init_array / 虚函数 / 模板验证:
text
C++ single-file ELF demo
app_name(.data+init_array): cpp_single ← main 之前已构造完成
sensor#1 kind=temperature read=26 ← 经 vtable 动态分派
max3<int>(3,9,4) = 9 ← _Z4max3IiET_...
max3<double>(1.5,9.25,3.75) = 9.25 ← _Z4max3IdET_...(第二份实例代码)
s_created(.data static member) = 2 ← 静态成员计数
C++ 多文件------跨文件 vtable / extern / 库抽取验证:
text
== cpp_multi demo ==
circle area=12.5664 (circle: pi*r^2) ← g_unit_circle(main.o 的 LOCAL 全局对象)
rect area=12 (generic shape) ← 构造代码来自 shape.o
circle area=3.14159 (circle: pi*r^2) ← make_default_shape() 跨文件工厂调用
total_area(.bss in shape.o) = 27.708
g_shape_seq(.data in shape.o) = 1003 ← 1000 + 3 次构造
四个可执行文件横向对比(size 实测)
| 项目 | text | data | bss | 文件体积 | 入口点 |
|---|---|---|---|---|---|
| C 单文件 | 2 576 | 664 | 16 440 | 16 680 B | 0x1060 |
| C 多文件 | 3 381 | 652 | 2 096 | 16 704 B | 0x1120 |
| C++ 单文件 | 8 800 | 944 | 1 376 | 25 368 B | 0x22a0 |
| C++ 多文件 | 9 912 | 1 036 | 1 376 | 26 120 B | 0x2300 |
C++ 的 text 约为 C 的 3.4 倍 :iostream 内联模板、vtable 派发桩、异常表(.eh_frame、
.gcc_except_table)、全局构造包装全在 .text 里------"C++ 零开销抽象"的账单在链接后看得清清楚楚。
十一、运行时内存布局与启动顺序
text
高地址 ┌────────────────────┐ 基址+0x7fff...(PIE:基址由内核随机化 = ASLR)
│ 栈 ↓ │ 局部变量、函数参数
│ 堆 ↑ │ malloc/new ------ 不属于任何 ELF 节
├────────────────────┤
│ .bss (RW, 清零) │ g_bigbuf / g_app_name 的存储...
├────────────────────┤ 0x4000
│ .data (RW) │ g_counter=42, g_tick=100...(文件中有原值)
├────────────────────┤ 0x3db0
│ .init_array/.got/ │ 启动重定位后 mprotect → R (GNU_RELRO)
│ .data.rel.ro │ ← C++ vtable 住这里
├────────────────────┤ 0x1000
│ .text/.plt (R+X) │ 全部机器指令(入口 _start)
├────────────────────┤ 0x2000(按页对齐穿插)
│ .rodata/.eh_frame(R)│ 常量、字符串、展开表
├────────────────────┤ 0x0
│ ELF头/.interp (R) │
低地址 └────────────────────┘
完整启动顺序(对应本文所有实测结构):
- 内核 execve 读 ELF 头,按
.interp加载/lib64/ld-linux-x86-64.so.2; - 动态链接器映射 libc.so / libstdc++.so,处理
.rela.dyn/.rela.plt重定位; - 执行
.init_array:frame_dummy →_GLOBAL__sub_I_*(g_app_name、g_boot_sensor 在此构造); _start(Scrt1.o)调__libc_start_main→ 终于进入 main;- main 返回后执行
.fini_array(全局析构)→ exit。
十二、总结
四阶段本质一句话
| 阶段 | 本质 | 语言变化 |
|---|---|---|
| ① 预编译 | 纯文本替换,不懂任何 C 语法 | C → 更长的 C |
| ② 编译 | 唯一懂语法的阶段,以翻译单元为单位 | C → 汇编 |
| ③ 汇编 | 机械翻译 + 写重定位"欠条" | 汇编 → ELF REL(地址全 0) |
| ④ 链接 | 平账:拼节、配对符号、填地址、装 crt | 多个 REL → 一个 DYN/PIE |
gcc 与 g++ 同流程下的实测差异
| 差异点 | gcc © | g++ (C++) |
|---|---|---|
| 预处理产物 | 888 行(stdio.h) | 36 658 行(iostream/string,41 倍) |
| 符号名 | 原样 vector_push |
修饰名 _ZN6CircleC1Ed(c++filt 还原) |
| 符号绑定 | GLOBAL / LOCAL | 多出 WEAK(模板/vtable/inline 跨 TU 去重) |
| 特有节 | --- | .data.rel.ro(vtable)、.init_array、.gcc_except_table、30+ COMDAT 小节 |
| NEEDED | libc.so.6 | libstdc++.so.6 + libgcc_s.so.1 + libc.so.6 |
| text 体积 | 2 576 B | 8 800 B(3.4 倍) |
五条最值得记住的实测结论
- ".bss 不占文件"是真的 :16 KB 数组让文件 0 字节增长,只在节头记个数字(NOBITS),
内核加载时清零------FileSiz 0x298 vs MemSiz 0x42e8就是铁证; - "已初始化→.data"不严谨 :初值为 0 的变量(gcc 发
.comm)实测全部进了 .bss; - 入口不是 main :是 crt 的
_start,main 之前还跑完了.init_array(C++ 全局对象构造); - 静态库按需抽取有审计记录:map 文件第一段写明"因为哪个符号拉进了哪个成员";
- C++ 的抽象不是零成本的,但成本清晰可查 :vtable 48 B/张、模板每实例一份代码、
size 命令里 text 段 3.4 倍的差距,每一分钱都花在明处。
附:源码仓库与完整复现命令
本文四个演示项目的全部源码 + 严格链式构建脚本(build_wsl_chain.sh)已开源到 GitHub:
bash
git clone https://github.com/hwwei521/c-cpp-elf-demo.git # 分支 main / feature/elf-demo
cd c_cpp_elf_demo
bash build_wsl_chain.sh # 一键复现本文全部实测(WSL/Linux,gcc 12+)
手工逐阶段复现(任何 Linux + gcc 12 以上):
bash
CFLAGS="-O0 -g0 -Wall"
mkdir -p build
# ① 预编译(输入:源码)
gcc $CFLAGS -E main.c -o build/main.i
# ② 编译(输入:①的产物)
gcc $CFLAGS -S build/main.i -o build/main.s
# ③ 汇编(输入:②的产物)
gcc $CFLAGS -c build/main.s -o build/main.o
# ③.5 归档(多文件项目;输入:③的产物)
ar rcs build/libvector.a build/vector.o
# ④ 链接(输入:③/③.5 的产物;-Wl,-Map 生成装配清单)
gcc $CFLAGS build/main.o -Lbuild -lvector -o build/app -Wl,-Map=build/app.map
# 验收工具链
readelf -h build/app # ELF 头:Type/Entry
readelf -S -W build/app # 节头表(链接视图)
readelf -l -W build/app # 程序头表(加载视图)
readelf -s -W build/app | grep 符号 # 符号归属
readelf -r -W build/main.o # 链接前的重定位"空洞"
readelf -d build/app # NEEDED 动态依赖
size -A build/app # 每节大小
nm -s build/libvector.a # 静态库符号索引
c++filt <<< _ZN6CircleC1Ed # 修饰名还原
./build/app # 跑起来!
本文全部数据来自 2026-10 在 WSL2 Ubuntu 24.04 + gcc/g++ 13.3.0 上的真实构建,
429 行完整过程日志与 14 份 readelf 归档可随时核对。
如果本文帮你搞懂了"编译到底在干什么",欢迎点赞收藏,评论区聊聊你踩过的链接错误。