C/C++ 编译四阶段全流程拆解

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。

三个可验证的实测结论:

  1. .i 文件里残留的 #include/#define 数量 = 0 ------所有预处理指令消失,只剩纯 C 文本
    和 # 1 "main.c" 这样的行号标记(给编译器报错用);
  2. 头文件是文本级复制 :vector.h 的 extern int g_vector_total_created; 在 main.i 中原样出现;
  3. 宏是文本级替换 :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)

三个关键点:

  1. NOBITS 的魔法 :.bss 大小 16 408 B > 整个文件 4 784 B。NOBITS 节只在节头里记一个数字,
    加载时由内核清零分配------int g_bigbuf[4096] 若改成 = {1,2,...} 非零初始化,文件立刻胖 16 KB;
  2. 所有节地址都是 0 :.o 里的代码还不知道自己将来住哪,每次跨节引用都登记在
    .rela.* 重定位表里(本例 53 条 R_X86_64_*),等链接器填坑;
  3. .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_array 8 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)   │
低地址  └────────────────────┘

完整启动顺序(对应本文所有实测结构):

  1. 内核 execve 读 ELF 头,按 .interp 加载 /lib64/ld-linux-x86-64.so.2;
  2. 动态链接器映射 libc.so / libstdc++.so,处理 .rela.dyn/.rela.plt 重定位;
  3. 执行 .init_array:frame_dummy → _GLOBAL__sub_I_*(g_app_name、g_boot_sensor 在此构造);
  4. _start(Scrt1.o)调 __libc_start_main → 终于进入 main;
  5. 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 倍)

五条最值得记住的实测结论

  1. ".bss 不占文件"是真的 :16 KB 数组让文件 0 字节增长,只在节头记个数字(NOBITS),
    内核加载时清零------FileSiz 0x298 vs MemSiz 0x42e8 就是铁证;
  2. "已初始化→.data"不严谨 :初值为 0 的变量(gcc 发 .comm)实测全部进了 .bss;
  3. 入口不是 main :是 crt 的 _start,main 之前还跑完了 .init_array(C++ 全局对象构造);
  4. 静态库按需抽取有审计记录:map 文件第一段写明"因为哪个符号拉进了哪个成员";
  5. 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 归档可随时核对。

如果本文帮你搞懂了"编译到底在干什么",欢迎点赞收藏,评论区聊聊你踩过的链接错误。