A PROGRAMMER'S PERSPECTIVE · SYSTEMS LAB
从 printf 到 hello world:一次程序运行的完整旅程
一行输出背后,是编译器、汇编器、链接器、运行库、操作系统装载器、虚拟内存、CPU 指令和终端设备协同完成的一条链路。
- C17
- x86-64
- ELF
- PE
- 编译原理
先建立地图:一次点击编译,实际上启动了什么?
这是一张静态总览图,不需要自动播放。沿着箭头从左到右、再按折行方向阅读;点击节点即可在右侧查看这一步的作用。构建阶段负责生成程序文件,运行阶段负责建立进程并执行指令。带虚线的术语可以悬停或点按查看解释。
读图提示:绿色是构建期(把源码制成可执行文件),灰色是运行期(把可执行文件变成正在执行的进程)。图中的箭头表示先后关系,不代表每一步都由同一个程序进程完成。
起点:C 源代码到底表达了什么?
为了明确边界,我们使用一个足够小、但符合标准 C 程序结构的示例。最后的输出故意没有换行符。
#include <stdio.h>
int main(void) {
printf("hello world");
return 0;
}头文件:声明与接口
stdio.h 让编译器知道 printf 的函数声明、参数类型和返回类型。它通常不包含 printf 的机器码实现。程序入口(C 语言层面)
main 是宿主环境中常规 C 程序的入口函数;但操作系统装载器并不是直接跳到 main,中间通常先运行 C 运行库的启动代码。正常结束的状态码
return 0 表示向运行环境报告成功。它不是打印动作的一部分,也不是 CPU 的“停机指令”。点击“编译”:构建系统启动了一条工具链
编辑器里的“运行”按钮通常负责组织命令、显示日志和启动进程;真正解析 C 代码的是编译器工具链,而不是 IDE 的按钮本身。
发起构建请求
工具链调度器
gcc 驱动程序,按目标平台和参数调用预处理器、编译器、汇编器与链接器。创建工具进程
四个经典阶段:一步一个产物
这是 GCC 常用的教学模型(流程图构建段的四步)。每步输入输出不同;细节已在流程图节点里,这里只留作用与产物。
| 阶段 | 一步作用 | 本例产物 |
|---|---|---|
| 预处理 Preprocess | 展开头文件、宏与条件编译,让编译器看见 printf 的声明 | hello.i · 预处理后的文本 |
| 编译 Compile | 把 C 语义翻译成目标指令(内部常经 AST / IR 与优化) | hello.s · 汇编文本 |
| 汇编 Assemble | 把助记符编码成机器字节,登记符号与重定位 | hello.o · 目标文件 |
| 链接 Link | 解析 printf 等外部符号,修正地址,合成可执行映像 | hello · ELF / hello.exe · PE |
.o / .obj 已含机器码但仍带符号与重定位;只有最终可执行文件把部件组合好。阶段是概念模型,编译器也可能直接生成目标文件、不落盘中间文件。
想亲自验证:gcc -E 停在预处理、-S 停在汇编文本、-c 停在目标文件;gcc -### hello.c -o hello 可查看驱动拟执行的子命令。
编译器内部:从字符到指令选择
词法、语法与语义
中间表示 IR 与优化
指令选择与寄存器分配
C 源码 → tokens → AST → IR → 优化 → x86-64 指令 → 目标文件。AST = Abstract Syntax Tree(抽象语法树);IR = Intermediate Representation(中间表示);ISA = Instruction Set Architecture(指令集架构)。它们是常见编译器概念,不意味着所有编译器都按一模一样的内部阶段执行。
平台对照:Ubuntu/Linux 与 Windows 10
两者拥有相似的概念链条,但工具链、目标文件格式、入口启动方式、动态库和加载器细节不同。切换平台查看对应流程。
# 1. 只做预处理:输出 hello.i
gcc -E hello.c -o hello.i
# 2. 编译并输出汇编文本 hello.s
gcc -S -O0 -masm=intel hello.c -o hello.s
# 3. 汇编为可重定位目标文件 hello.o
gcc -c -O0 hello.c -o hello.o
# 4. 链接为可执行文件 hello
gcc -O0 hello.o -o hello
# 查看符号、重定位、段 / 程序头
nm hello.o
readelf -r hello.o
readelf -h -l -d hello
objdump -drwC -Mintel hello.o
# 运行;本示例不含换行符
./helloELF + System V ABI
GCC / Clang + GNU binutils
gcc 是驱动程序;幕后可能涉及 cc1、as、ld 和启动文件。精确组合取决于发行版、安装的工具链和参数。glibc + ld-linux
libc.so.6,ELF 的 .interp 可指定动态加载器,如 /lib64/ld-linux-x86-64.so.2。:: 先打开 Visual Studio Developer Command Prompt
:: 预处理到 hello.i
cl /nologo /P hello.c
:: 编译到汇编列表(选项 /FA 生成汇编列表)
cl /nologo /Od /FAcs /c hello.c
:: 编译并链接为 hello.exe
cl /nologo /W4 /Od hello.c /Fe:hello.exe
:: 查看 PE 头和反汇编(安装 Visual Studio 工具)
dumpbin /headers hello.exe
dumpbin /imports hello.exe
dumpbin /disasm hello.exe
:: 运行
hello.exeCOFF / PE(PE32+)
.obj,链接器生成 PE 可执行映像 .exe。Windows x64 程序通常为 PE32+。cl.exe + link.exe
cl.exe 可以编译,也可以在正常构建时驱动链接器。/c 只编译不链接;/FA 生成汇编列表。CRT / UCRT / Windows API
mainCRTStartup,由它初始化运行库并调用用户的 main。printf 最终经运行库把标准输出写到当前句柄指向的目标。预处理:为什么需要 stdio.h?
它处理源代码中的预处理指令,而不是把整个标准 I/O 实现“粘进”程序里。
预处理前后(概念示意)
/* hello.c */
#include <stdio.h>
int main(void) {
printf("hello world");
return 0;
}↓ 读取头文件、处理指令、删除注释等 ↓
/* hello.i · 摘录 / 示意 */
/* 这里还会出现大量平台相关的声明 */
extern int printf(const char *, ...);
int main(void) {
printf("hello world");
return 0;
} 上面只展示概念,真正的 hello.i 远大于这个片段,声明细节也会因 libc、头文件和编译器而异。
让编译器知道如何调用
printf 的声明会告诉编译器:它返回 int,第一个参数是格式字符串,后面还有可变数量参数。编译器不需要知道 libc 中函数体如何实现,才能生成调用。函数实现在哪里?
编译器实际看到的输入
编译:C 语句如何变成汇编指令?
不要先背汇编。先把它当作“把一个 C 语句拆成处理器能完成的小动作”的记录,再逐条看每个动作为什么出现。
-O0)的示例输出。C 语言标准只规定程序的含义,并不规定必须生成这些指令;换编译器、优化等级或平台后,汇编可以不同,但“把字符串地址作为参数交给 printf”这个意图不变。先认识汇编里的基本积木
1. 指令与助记符
mov 表示搬运数据,lea 计算地址,call 调用函数,ret 返回。它们是机器指令的可读名字;CPU 最终读取的是相应的字节编码。2. 寄存器 Register
RAX、RDI、RSP、RBP 是 CPU 内部的小型高速存储位置。它们可以暂存数据、参数、栈地址或返回值,不是内存中的变量名。3. 操作数 Operand
mov rdi, rax 的两个操作数都是寄存器。[rip+0x123] 表示按地址计算规则访问内存;lea 使用类似写法时,是计算有效地址而不读取那个地址里的字符串内容。4. 标签与伪指令
main:、.LC0: 是标签,给代码或数据一个可引用的名字。.text、.section、.string 是给汇编器的组织指示,不是让 CPU 执行的指令。5. 代码区与数据区
.text 通常放机器指令;.rodata 是 read-only data(只读数据)的惯用 section 名,常放字符串常量。指令与数据都是字节,但它们的用途不同。6. 函数调用约定
ABI;本例按 Linux x86-64 System V ABI 解释。这次编译器输出的汇编
.section .rodata # 告诉汇编器:接下来是只读常量数据
.LC0: # 编译器给字符串起的内部标签
.string "hello world" # 11 个可见字符 + 结尾的 NUL 终止字节
.text # 接下来是机器指令所在的代码 section
main: # 函数入口标签
push rbp # 保存调用者的帧指针
mov rbp, rsp # 用当前栈顶建立本函数的帧指针
lea rax, .LC0[rip] # 算出字符串地址,放进 RAX
mov rdi, rax # 按 Linux x86-64 ABI,把第一个参数放进 RDI
mov eax, 0 # 可变参数调用的 ABI 要求:AL 中向量参数数目为 0
call printf@PLT # 调用 printf 的 PLT 入口
mov eax, 0 # main 的返回值 0
pop rbp # 恢复调用者的帧指针
ret # 返回运行库启动代码把 C 语句和汇编一一对上
编译器不需要让每条 C 语句对应一条汇编指令。一行 C 代码可能变成多条指令;反过来,多条 C 语句也可能被优化成一段指令。这里是便于学习的“职责对应”,不是语言规范要求的固定映射。
| C 源代码 / 隐含工作 | 对应的汇编 | 这一步具体做了什么? |
|---|---|---|
“hello world” 字符串常量 | .section .rodata · .LC0: · .string “hello world” | 给字符串分配一段数据空间,并设置内部标签 .LC0 方便其他指令引用。字符串末尾有一个 00 终止字节,它用于标记 C 字符串结束,不会作为可见字符打印。 |
printf(“hello world”); 第 1 步:找到参数 | lea rax, .LC0[rip] | 计算字符串的地址并放入 RAX。这里的 lea 是“取有效地址”,不会把 12 个字符串字节塞进寄存器。 |
| 同一条语句第 2 步:按约定传参 | mov rdi, rax | 把刚算出的地址复制到 RDI。Linux x86-64 System V ABI 规定第一个整数或指针参数用 RDI 传递,所以 printf 收到的是“字符串在哪儿”的地址。 |
| 同一条语句第 3 步:完成可变参数约定 | mov eax, 0 | printf 是可变参数函数。System V AMD64 ABI 用 AL 传递本次调用使用的向量寄存器数量;本例没有向量参数,所以此处设为 0。它不是“把 hello world 清空”。 |
| 第 4 步:真正发起函数调用 | call printf@PLT | call 保存返回位置并转移控制流。@PLT 表示调用会经过 ELF 动态链接常见的过程链接表入口;它不等于把 printf 的实现内联在 main 里。 |
return 0; 以及函数收尾 | mov eax, 0 · pop rbp · ret | 把整数返回值 0 放进 EAX,恢复此前保存的帧指针,再返回运行库启动代码。前面的 push rbp / mov rbp,rsp 是本次未优化构建常见的函数序言,用于建立栈帧。 |
.rodata 保存“要打印的内容”;lea 找到内容所在的地址;mov rdi, rax 按 ABI 把地址交给函数;call 转到函数入口。CPU 执行的是每条指令的机器码,标签和注释只是帮助汇编器 / 人类组织和理解程序。汇编器:指令和字符串最终都是字节
CPU 读的是内存中的二进制编码。字符串常量本身也被编码为字节;对于这个 ASCII 字符串,UTF-8 的字节与 ASCII 一致。
字符串 "hello world" 的内存表示
十六进制(Hex)
68 65 6c 6c 6f 20 77 6f 72 6c 64 00
每两个十六进制数字表示一个 8-bit 字节。末尾的 00 是 C 字符串的空字符终止符,不会被 printf 作为字符输出。
ASCII / UTF-8 解码
h e l l o ␠ w o r l d \0
例如 0x68 对应字符 h,0x20 是空格。终止符告知字符串函数到此结束。
从目标文件的字节到链接后的字节:中间到底发生了什么?
先记住一个模型:汇编器「留坑 + 记便签」,链接器「按便签填坑」。下面两条指令需要写“目标离我多远”,但编译 hello.o 时距离根本算不出来——字符串坐哪要等链接器排座位,printf 干脆在另一个文件里。汇编器不猜:把要写距离的 4 个字节先留成 0(坑),再在重定位表里记一张便签(“这里有个坑,按相对某某符号的规则填”)。链接器排好座位后逐条读便签、算距离、填坑。下面沿这两条指令,把坑和便签从 hello.o 一路追到最终 hello。
lea rax, .LC0[rip]:用标签指向目标,距离留给以后算 第一步:目标文件里到底有什么?——带坑的指令 + 便签
用 objdump -drwC -Mintel hello.o 看目标文件:每行是“section 内偏移、机器字节、反汇编文本”,-r 会把便签(重定位记录)一起打印。偏移从 .text 起点算起,不是运行时的内存地址。先放大看 lea 这 7 个字节和它的便签:
7 字节的 lea:3 字节形式 + 4 字节坑
48 8d 05 · 00 00 00 00
前 3 字节已经表达“RIP 相对的 lea”这种指令形式;后 4 字节是 32 位距离字段,现在是坑(全 0)。指令从偏移 0x4 开始,走过 3 字节形式才到坑——所以坑的偏移是 0x7。这正是便签里 offset 0x7 的来历。
对应的便签:谁、在哪、怎么填
R_X86_64_PC32 .rodata-0x4
这行不是 CPU 指令,是 objdump 顺带打印的便签:0x7 是坑的位置(见上一格),.rodata 是要相对的目标,PC32 是“按 32 位相对位移填”的规则,-0x4 是基准修正量(第二步拆解)。
0000000000000000 <main>:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 48 8d 05 00 00 00 00 lea rax,[rip+0x0]
7: R_X86_64_PC32 .rodata-0x4
b: 48 89 c7 mov rdi,rax
e: b8 00 00 00 00 mov eax,0x0
13: e8 00 00 00 00 call printf
14: R_X86_64_PLT32 printf-0x4
18: b8 00 00 00 00 mov eax,0x0
1d: 5d pop rbp
1e: c3 ret| 你看到的内容 | 应该怎样理解 |
|---|---|
4: 48 8d 05 00 00 00 00 | 偏移 0x4 起是一条 7 字节的 lea:前 3 字节是指令形式,后 4 字节是坑(位移字段,暂为 0)。坑从偏移 0x7 开始。 |
7: R_X86_64_PC32 .rodata-0x4 | 便签,不是指令。0x7 = 坑的位置;.rodata = 相对谁;PC32 = 填法(32 位相对位移);-0x4 = 基准修正量。 |
13: e8 00 00 00 00 | 同理:e8 是近相对 call 的 1 字节操作码,后 4 字节又是一个坑。call 0 不是真的调地址 0,只是距离还没填。 |
14: R_X86_64_PLT32 printf-0x4 | call 的便签:坑在 0x14(0x13 + 1 字节操作码);printf 是外部符号,PLT32 表示按“可指向 PLT 入口”的相对位移来填。 |
第二步:便签怎么写?——三个问题和一条填数公式
一张便签只回答三个问题:填哪里(offset,坑的字节位置)、相对谁(symbol,拿来算距离的目标)、怎么算(type + addend,规则和修正量)。把 hello.o 的两张便签集中打印:
Offset Type Symbol + Addend
000000000007 R_X86_64_PC32 .rodata - 4
000000000014 R_X86_64_PLT32 printf - 4 填数规则一句人话:坑里要填的数 = 目标地址 − 下一条指令的地址。为什么是“下一条指令”?因为 x86 的 RIP 相对寻址就是以它为基准。写成公式里的字母就是 S + A − P:
人话:填入 = 目标地址 − 下一条指令地址(S −(P + 4))
公式:PC32 → S + A − P · PLT32 → L + A − P(其中 A = −4)
字母对照:S 是符号地址,L 是 PLT 项地址(函数调用走它),P 是坑的第 1 个字节的地址,A = -4 把基准从坑开头校正到下一条指令。坑宽 4 字节(32 位),结果就写 4 字节。注意:不同重定位类型的公式不同,不能把同一条公式套在所有便签上。
第三步:链接器怎样按便签填坑?
本次示例构建里,链接器把 main 排到 0x1139、字符串排到 0x2004,并为 printf 准备了 0x1030 处的 PLT 入口。座位定了,两张便签就能算了:
0000000000001139 <main>:
1139: 55 push rbp
113a: 48 89 e5 mov rbp,rsp
113d: 48 8d 05 c0 0e 00 00 lea rax,[rip+0xec0] # 0x2004:字符串位置
1144: 48 89 c7 mov rdi,rax
1147: b8 00 00 00 00 mov eax,0
114c: e8 df fe ff ff call 0x1030 <printf@plt>
1151: b8 00 00 00 00 mov eax,0
1156: 5d pop rbp
1157: c3 ret例子 A · 字符串的坑
坑在 0x1140(lea 开头 0x113d + 3 字节形式),下一条指令在 0x1144。代入公式:
0x2004 − 0x1144 = 0x0ec0
结果按小端序(低字节在前)写进坑:c0 0e 00 00。执行时 RIP = 0x1144,0x1144 + 0x0ec0 = 0x2004,正好落在字符串上。
例子 B · printf 的坑
目标 printf@plt 在 0x1030,下一条指令在 0x1151。代入公式:
0x1030 − 0x1151 = −0x121
位移可正可负:这里为负,因为 PLT 排在 main 前面,要往回跳。-0x121 写进坑是 df fe ff ff(有符号 32 位、小端序)。执行时先落到 PLT 跳板,再由动态链接路径转到 libc 里的 printf。
第四步:哪些坑不在链接时填?——运行时动态链接
链接时填的坑:字符串、printf@plt 这类目标在生成 hello 时座位已定,链接器按便签填完就不再改。
运行时才填的坑:call 的目标是 PLT 跳板,而不是 libc 里 printf 的真身——真身的地址要等进程启动、共享库加载后才知道。这些坑(例如 GOT 中的函数地址槽)由动态加载器按动态链接信息补填,具体方式随链接选项与安全设置变化。
| 谁 | 什么时候 | 知道什么 | 做什么 |
|---|---|---|---|
| 汇编器 | 生成 hello.o 时 | 指令形式;最终座位未知 | 留坑(写 0)+ 记便签 |
| 链接器 | 生成 hello 时 | 映像内的座位(S、PLT 位置) | 按便签填坑 |
| 动态加载器 | 进程启动时 | 共享库加载后的真实地址 | 填 GOT 等运行时地址槽 |
三个角色处理的是三个时间点的地址问题:汇编时写不下的距离,链接时填映像内部的,启动时填跨共享库的。
这些偏移和字节是本次 GCC 14.2.0、x86-64 Linux、-O0 示例构建的具体结果,不是每台机器都固定如此。PIE、ASLR、链接器和构建选项都可能改变最终显示的地址。
链接:一个目标文件如何找到 printf?
编译器只知道这里需要调用名为 printf 的外部函数。链接器要处理这个符号,以及调用点、字符串常量和库之间的地址关系。
链接器面对的“未完成引用”
0000000000000000 T main
U printf T main 表示 main 在目标文件的代码 section 中定义;U printf 表示 printf 是未定义的外部符号,需要从其他链接输入或动态库依赖中解决。
典型动态链接路径(Linux)
符号解析
printf 对应的实现。重定位
.rodata 的引用和函数调用补完整。动态库依赖
libc.so.6 依赖。共享库的实现一般在运行时由动态加载器映射、重定位。可执行文件并非“一团机器码”
ELF 和 PE 都有格式头、节 / 段信息、入口点、导入或动态链接信息等元数据。操作系统和加载器依靠这些结构理解文件。
Linux:ELF 结构
Class: ELF64
Data: little endian
Type: DYN (PIE executable)
Machine: x86-64
Interpreter: /lib64/ld-linux-x86-64.so.2
NEEDED: libc.so.6 ELF 头描述文件的基本属性。Section(节)是链接 / 调试时常用的逻辑组织(例如 .text、.rodata、.symtab);Program Header(程序头)里的 PT_LOAD 等条目则描述运行时要映射的 segment。不要把“节”和“段”混为一谈。
Windows:PE 结构
DOS header / DOS stub
PE signature
COFF File Header
Optional Header (PE32+)
Section Headers
.text · code
.rdata · read-only data / imports
.data · initialized data
Import Table / IAT
Relocation data / other directoriesPE Optional Header 含有入口点的 RVA(相对映像基址的地址)和数据目录。导入表说明程序依赖哪些 DLL 和符号,IAT(导入地址表)提供运行时调用导入函数的地址槽位。节名有约定意义,但具体布局由链接器决定。
文件最前面的几个字节:魔数(magic number)
ELF 文件开头 · Linux
7f 45 4c 46
0x7f 后面跟着 ASCII 的 E L F。接下来的标识字节还会说明 32 / 64 位类别、字节序和 ELF 版本。
PE 文件开头 · Windows
4d 5a
即 ASCII MZ。PE 签名 50 45 00 00 不一定紧接在文件开头;DOS 头中的偏移字段会指向 PE 头所在位置。
可以在 Linux 上执行 xxd -l 64 hello 查看文件前 64 字节;Windows 开发者命令提示符可用 dumpbin /headers hello.exe 读取结构化头信息。
装载:从磁盘文件到进程的虚拟地址空间
可执行文件不会简单地被整个复制到一块连续的物理内存。内核与加载器根据格式信息建立虚拟内存映射,页通常会按需调入物理内存。
虚拟地址空间 · 结构示意图
进程映像
execve 会用新程序映像替换当前进程映像。虚拟地址不是物理地址
页面按需进入内存
地址不一定每次都一样
为什么不是直接跳进 main?运行库先搭好舞台
从操作系统入口到 C 语言的 main,中间还有 ABI 约定、运行库初始化与进程启动代码。
可执行映像入口
_start 周边启动路径),不是直接调用 C 的 main。初始化运行时
进入 main()
main 调用一次 printf。正常退出清理
main 返回后,运行库处理退出状态并完成正常退出清理,包括刷新 C 标准 I/O 缓冲。CPU 执行:一次函数调用在寄存器和栈上发生什么?
用一条指令一步步播放 GCC 输出的 main。寄存器值为概念追踪,真实地址与运行时寄存器值会因每次运行和工具链而变。
表中将最后几条收在同一步,方便教学;实际调用 printf 会跳进链接器解析到的库代码,并执行大量运行库指令,而不只是跳一条指令。
printf 到底怎样让文字出现在屏幕上?
关键区别:C 标准库函数、操作系统系统调用和终端模拟器是三个不同层次。
先认识下文的 fd 1:fd 是文件描述符(file descriptor)——进程里一个代表“已打开的输出目标”的小编号,内核靠它认路;1 号固定是标准输出(stdout)。先把它当作“输出通道 1 号”:往 fd 1 写,就是往 stdout 当前指向的地方写。
printf 格式化与缓冲
stdout 的缓冲区。返回值通常为成功写出的字符数,即 11。把字节交给文件描述符 / 句柄
write 等系统调用把字节写向文件描述符 1;Windows 的 CRT 使用 Windows I/O API 与句柄机制。实际底层调用可能因实现而异。终端模拟器绘制字形
从用户程序到显示器:七个步骤
为什么会有缓冲?
exit 时也可能刷新。系统调用不等于普通函数调用
printf 是用户态库函数,不是 Linux 系统调用。一次 printf 可能不触发系统调用,之后一次缓冲刷新也可能合并、拆分或走不同的底层路径。为什么提示符可能接在后面?
把整条链路翻译成 Windows 10 的心智模型
Windows 上仍然有编译、汇编、链接、运行库、装载与输出,但不能直接把 Linux 的 ELF / ld-linux / glibc 细节套上去。
MSVC 前端与 COFF 目标文件
cl.exe 预处理并编译 C 代码;常见中间产物是 .i、汇编列表和 .obj。目标文件中的外部引用通过符号表与重定位记录表达。LINK 解析导入并生成 PE
Windows Loader 映射映像与 DLL
CRT 进入 main,再进入 UCRT 输出
main。printf 经过 UCRT 的流和缓冲逻辑,将数据写往标准输出句柄;最终目标可能是控制台、文件或管道。调用约定差异:第一个参数放在哪里?
; Linux x86-64 System V ABI
lea rdi, [rip + string]
xor eax, eax
call printf@PLT
; Windows x64 ABI(概念示意)
lea rcx, [string]
; 调用者通常还要预留 32-byte shadow space
call printf_import_thunk Linux System V x86-64 通常用 RDI 传第一个指针参数;Windows x64 通常用 RCX。Windows x64 调用约定要求调用者为被调函数预留 32 字节 shadow space。示意代码不代表某个 MSVC 版本的精确输出。
如果出错,错误会在哪一阶段出现?
区分编译错误、链接错误、装载错误与运行错误,定位问题会容易很多。
头文件找不到
stdio.h 找不到;或语法 / 声明检查失败。程序通常还没有生成目标文件。未定义引用
无法执行或缺 DLL
输出或退出异常
术语速查:点名字,查看它解决的问题
第一次阅读时不必背下所有缩写;先将名称与它所属的层次对应起来。
如何在自己的机器上验证?
把这份页面当成路线图,实际运行工具查看真实构建产物。命令结果会随编译器、版本、架构和链接选项变化。
gcc --version
gcc -v hello.c -o hello
file hello
size hello
nm hello.o
readelf -h -l -S -d hello
readelf -r hello.o
objdump -drwC -Mintel hello.o
objdump -d -Mintel hello
strace -e trace=execve,write,exit_group ./hellocl /Bv
cl /nologo /Od /FAcs /c hello.c
dumpbin /headers hello.obj
dumpbin /symbols hello.obj
dumpbin /relocations hello.obj
dumpbin /disasm hello.obj
link /verbose hello.obj /out:hello.exe
dumpbin /imports hello.exe
dumpbin /headers hello.exe Windows 部分命令需要 Visual Studio / Build Tools 的开发者命令提示符。Linux 的 strace 可能需要单独安装;系统调用跟踪展示的是某次运行的实际系统调用,不应将其误认为 C 语言标准保证的固定序列。
延伸阅读与资料来源
以下资料分别覆盖程序员视角、编译工具链、对象文件格式、动态链接、I/O 缓冲与 Windows CRT/PE。页面内容是据此整理的教学说明,不是对任何一本书的逐段复述。
- Computer Systems: A Programmer’s Perspective(CS:APP)
Bryant 与 O'Hallaron。程序表示、汇编、链接、异常控制流、虚拟内存与系统交互的主线教材。
- GCC 官方文档:Overall Options
解释 GCC 驱动程序的四阶段编译流程,以及 -E、-S、-c 等选项。
- Linux man-pages:elf(5)
ELF 头、程序头、section 与 segment 等文件格式信息。
- Linux man-pages:ld.so(8)
动态链接器 / 加载器如何查找共享对象并为程序运行做准备。
- Linux man-pages:execve(2)
进程映像替换与新程序执行入口相关的系统接口。
- GNU libc 手册:Flushing Buffers
输出流缓冲区刷新的常见条件,包括换行、缓冲区满、关闭与正常退出。
- Microsoft Learn:PE 格式
Windows PE/COFF 文件格式、节表、入口点、导入表和 IAT 结构。
- Microsoft Learn:CRT 初始化
默认 CRT 启动代码如何初始化运行库并调用用户提供的 main。
- Microsoft Learn:/ENTRY
Windows 链接器入口点与使用 CRT 初始化的注意事项。
- Microsoft Learn:x64 ABI conventions
Windows x64 的参数寄存器、shadow space、栈与函数序言约定。
- System V AMD64 ABI(PDF)
Linux x86-64 调用约定、寄存器用途、栈布局以及可变参数函数的 ABI 规则。
- 《程序员的自我修养:链接、装载与库》
俞甲子、石凡、潘爱民著,电子工业出版社。聚焦 Windows/Linux 编译、链接、装载、动态链接、运行库和系统调用。
- Linux man-pages:printf(3)
printf 的格式化输出接口、返回值与关联的标准 I/O 函数。