Mahoo Space
← 学苑

A PROGRAMMER'S PERSPECTIVE · SYSTEMS LAB

从 printf 到 hello world:一次程序运行的完整旅程

一行输出背后,是编译器、汇编器、链接器、运行库、操作系统装载器、虚拟内存、CPU 指令和终端设备协同完成的一条链路。

  • C17
  • x86-64
  • ELF
  • PE
  • 编译原理
$ gcc hello.c -o hello && ./hello → hello world · process exited · status 0

先建立地图:一次点击编译,实际上启动了什么?

这是一张静态总览图,不需要自动播放。沿着箭头从左到右、再按折行方向阅读;点击节点即可在右侧查看这一步的作用。构建阶段负责生成程序文件,运行阶段负责建立进程并执行指令。带虚线的术语可以悬停或点按查看解释。

读图提示:绿色是构建期(把源码制成可执行文件),灰色是运行期(把可执行文件变成正在执行的进程)。图中的箭头表示先后关系,不代表每一步都由同一个程序进程完成。

起点:C 源代码到底表达了什么?

为了明确边界,我们使用一个足够小、但符合标准 C 程序结构的示例。最后的输出故意没有换行符。

C
#include <stdio.h>

int main(void) {
    printf("hello world");
    return 0;
}
01 / #include

头文件:声明与接口

stdio.h 让编译器知道 printf 的函数声明、参数类型和返回类型。它通常不包含 printf 的机器码实现。
02 / main

程序入口(C 语言层面)

main 是宿主环境中常规 C 程序的入口函数;但操作系统装载器并不是直接跳到 main,中间通常先运行 C 运行库的启动代码。
03 / return 0

正常结束的状态码

return 0 表示向运行环境报告成功。它不是打印动作的一部分,也不是 CPU 的“停机指令”。

点击“编译”:构建系统启动了一条工具链

编辑器里的“运行”按钮通常负责组织命令、显示日志和启动进程;真正解析 C 代码的是编译器工具链,而不是 IDE 的按钮本身。

EDITOR / IDE

发起构建请求

读取任务配置、工作目录、环境变量和编译参数,然后启动外部工具进程。
COMPILER DRIVER

工具链调度器

例如 GCC 的 gcc 驱动程序,按目标平台和参数调用预处理器、编译器、汇编器与链接器。
OPERATING SYSTEM

创建工具进程

操作系统装载编译器可执行文件、建立它的虚拟地址空间并调度 CPU 执行它;之后同样的 OS 还会执行生成的程序。

四个经典阶段:一步一个产物

这是 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 可查看驱动拟执行的子命令。

编译器内部:从字符到指令选择

FRONT END

词法、语法与语义

把字符序列切成 token,按 C 文法构造语法结构,再检查类型、声明、参数匹配等语言规则。错误会以诊断信息报告。
MIDDLE END

中间表示 IR 与优化

编译器常把程序转换成与具体 CPU 指令相对独立的 IR,执行常量传播、死代码删除、控制流优化等。优化目标可能是速度、体积或两者平衡。
BACK END

指令选择与寄存器分配

把 IR 映射到目标 ISA,分配寄存器、安排指令和栈布局,最后输出汇编文本或直接输出机器码。具体内部结构取决于编译器实现。

C 源码 → tokens → AST → IR → 优化 → x86-64 指令 → 目标文件。AST = Abstract Syntax Tree(抽象语法树);IR = Intermediate Representation(中间表示);ISA = Instruction Set Architecture(指令集架构)。它们是常见编译器概念,不意味着所有编译器都按一模一样的内部阶段执行。

平台对照:Ubuntu/Linux 与 Windows 10

两者拥有相似的概念链条,但工具链、目标文件格式、入口启动方式、动态库和加载器细节不同。切换平台查看对应流程。

Shell
# 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

# 运行;本示例不含换行符
./hello
OBJECT FORMAT

ELF + System V ABI

常见 Ubuntu x86-64 程序使用 ELF(Executable and Linkable Format)。目标文件通常为 ELF relocatable;最终动态链接程序可能是 PIE ELF。
TOOLCHAIN

GCC / Clang + GNU binutils

gcc 是驱动程序;幕后可能涉及 cc1、as、ld 和启动文件。精确组合取决于发行版、安装的工具链和参数。
RUNTIME

glibc + ld-linux

常见动态链接程序依赖 libc.so.6,ELF 的 .interp 可指定动态加载器,如 /lib64/ld-linux-x86-64.so.2。
PowerShell
:: 先打开 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.exe
OBJECT FORMAT

COFF / PE(PE32+)

MSVC 编译器产生 COFF 目标文件 .obj,链接器生成 PE 可执行映像 .exe。Windows x64 程序通常为 PE32+。
TOOLCHAIN

cl.exe + link.exe

cl.exe 可以编译,也可以在正常构建时驱动链接器。/c 只编译不链接;/FA 生成汇编列表。
RUNTIME

CRT / UCRT / Windows API

默认入口一般是 CRT 提供的 mainCRTStartup,由它初始化运行库并调用用户的 main。printf 最终经运行库把标准输出写到当前句柄指向的目标。

预处理:为什么需要 stdio.h?

它处理源代码中的预处理指令,而不是把整个标准 I/O 实现“粘进”程序里。

预处理前后(概念示意)

C
/* hello.c */
#include <stdio.h>

int main(void) {
    printf("hello world");
    return 0;
}

↓ 读取头文件、处理指令、删除注释等 ↓

C
/* hello.i · 摘录 / 示意 */
/* 这里还会出现大量平台相关的声明 */
extern int printf(const char *, ...);

int main(void) {
    printf("hello world");
    return 0;
}

上面只展示概念,真正的 hello.i 远大于这个片段,声明细节也会因 libc、头文件和编译器而异。

声明 declaration

让编译器知道如何调用

printf 的声明会告诉编译器:它返回 int,第一个参数是格式字符串,后面还有可变数量参数。编译器不需要知道 libc 中函数体如何实现,才能生成调用。
定义 definition

函数实现在哪里?

通常在 C 标准库的实现中(Linux 常见为 glibc,Windows 常见为 UCRT)。动态链接时,它不需要被复制进每个程序的机器码正文,而是在运行时解析到相应共享库。
翻译单元 translation unit

编译器实际看到的输入

预处理后的完整 C 源文本连同已展开的宏和头文件内容,形成该次编译的翻译单元。随后的编译阶段才进行语言语义分析与代码生成。

编译:C 语句如何变成汇编指令?

不要先背汇编。先把它当作“把一个 C 语句拆成处理器能完成的小动作”的记录,再逐条看每个动作为什么出现。

我们究竟在观察什么? 下面采用一次 GCC 14.2.0、x86-64 Linux、未开启优化(-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 解释。

这次编译器输出的汇编

Asm
.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, 0printf 是可变参数函数。System V AMD64 ABI 用 AL 传递本次调用使用的向量寄存器数量;本例没有向量参数,所以此处设为 0。它不是“把 hello world 清空”。
第 4 步:真正发起函数调用call printf@PLTcall 保存返回位置并转移控制流。@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]:用标签指向目标,距离留给以后算
② 目标文件 .o 指令已编码;距离字段留成 0(坑),并记下重定位(便签)
③ 链接器 排定 section / 符号座位,按便签算出距离并填坑
④ 最终 ELF 两处坑已填好;GOT 等运行时地址槽仍留给加载器

第一步:目标文件里到底有什么?——带坑的指令 + 便签

用 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 是基准修正量(第二步拆解)。

Text
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-0x4call 的便签:坑在 0x14(0x13 + 1 字节操作码);printf 是外部符号,PLT32 表示按“可指向 PLT 入口”的相对位移来填。

第二步:便签怎么写?——三个问题和一条填数公式

一张便签只回答三个问题:填哪里(offset,坑的字节位置)、相对谁(symbol,拿来算距离的目标)、怎么算(type + addend,规则和修正量)。把 hello.o 的两张便签集中打印:

Text
Offset          Type               Symbol + Addend
000000000007    R_X86_64_PC32      .rodata - 4
000000000014    R_X86_64_PLT32     printf - 4

填数规则一句人话:坑里要填的数 = 目标地址 − 下一条指令的地址。为什么是“下一条指令”?因为 x86 的 RIP 相对寻址就是以它为基准。写成公式里的字母就是 S + A − P:

P · 坑的第 1 字节 P + 4 · 下一条指令
48 8d 05 · 指令形式(3 字节) lea rax, [rip+位移]
00 00 00 00 · 坑(4 字节) 待填的 32 位相对位移
S · 目标地址 例如字符串 hello world 所在位置
坑里填的数 = S −(P + 4) = S + A − P A = −4 的唯一作用:把基准从「坑开头 P」校正到「下一条指令 P+4」
x86 的 RIP 以「下一条指令」为基准 —— 不是坑的开头

人话:填入 = 目标地址 − 下一条指令地址(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 入口。座位定了,两张便签就能算了:

Text
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 的外部函数。链接器要处理这个符号,以及调用点、字符串常量和库之间的地址关系。

链接器面对的“未完成引用”

Text
0000000000000000 T main
                 U printf

T main 表示 main 在目标文件的代码 section 中定义;U printf 表示 printf 是未定义的外部符号,需要从其他链接输入或动态库依赖中解决。

典型动态链接路径(Linux)

main 里的 call 指令 · call printf@PLT 距离坑已在「链接时」填好 —— 相对位移指向 PLT 跳板
PLT · 过程链接表 几条跳板指令 跳板会去读 GOT 槽里的地址
GOT · 全局偏移表 每个导入函数一个地址槽 printf 的槽「启动时」由加载器写入
libc.so 里的 printf 真身 共享库实现 —— 加载到哪,要等进程启动才知道
① 调用时 · 跳进 PLT
读槽
② 按槽内地址跳到真身
这是概念图,不代表所有构建都采用相同的 PLT/GOT 绑定策略;例如启用立即绑定、不同链接器、静态链接或优化后的调用方式时,路径可能不同。
SYMBOL RESOLUTION

符号解析

根据符号名、绑定规则、可见性和库输入,决定 printf 对应的实现。
RELOCATION

重定位

修正指令或数据里暂未确定的地址 / 偏移。链接器把 .rodata 的引用和函数调用补完整。
DYNAMIC DEPENDENCY

动态库依赖

本次实际构建的 ELF 记录了 libc.so.6 依赖。共享库的实现一般在运行时由动态加载器映射、重定位。

可执行文件并非“一团机器码”

ELF 和 PE 都有格式头、节 / 段信息、入口点、导入或动态链接信息等元数据。操作系统和加载器依靠这些结构理解文件。

Linux:ELF 结构

Text
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 结构

Text
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 directories

PE 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 读取结构化头信息。

装载:从磁盘文件到进程的虚拟地址空间

可执行文件不会简单地被整个复制到一块连续的物理内存。内核与加载器根据格式信息建立虚拟内存映射,页通常会按需调入物理内存。

虚拟地址空间 · 结构示意图

高地址Stack · 栈返回地址、局部状态 · 向低地址增长 ↓mmap · 共享库与映射libc、动态加载器、内存映射文件Heap · 堆malloc 等动态分配 · 向高地址增长 ↑.data / .bss全局与静态可写数据.rodata字符串常量 hello world.textmain 的机器指令与程序代码hello · ELF 文件磁盘上的可执行文件含 .text / .rodata 节经 PT_LOAD 映射进进程映射低地址蓝色 = hello 文件映射 · 其余 = 运行时建立
教学示意,非按比例内存布局。hello 的 .text / .rodata 经映射进入地址空间;栈、堆、mmap 等区域由内核与运行库在装载时建立,页面按需调入。
01 / process image

进程映像

内核建立新进程的执行上下文与地址空间,并将可执行文件的映射和必要的运行时映射安排好。Linux 的 execve 会用新程序映像替换当前进程映像。
02 / virtual memory

虚拟地址不是物理地址

程序看到的是自己的虚拟地址。CPU 的 MMU 与页表把虚拟页映射到物理页或文件后备页;内存保护属性限制页面能否读、写或执行。
03 / demand paging

页面按需进入内存

可执行文件映射后,不必立即将每个字节都读到 RAM。当访问某个尚未驻留的映射页时,可能触发缺页异常,由内核完成映射 / 读入后再继续执行。
04 / ASLR + PIE

地址不一定每次都一样

ASLR(地址空间布局随机化)可让映像、共享库、栈等区域在不同运行中使用不同地址。PIE 让主程序映像也能被放置在不同基址附近。不要把某次反汇编地址当作固定地址。

为什么不是直接跳进 main?运行库先搭好舞台

从操作系统入口到 C 语言的 main,中间还有 ABI 约定、运行库初始化与进程启动代码。

OS ENTRY

可执行映像入口

内核 / 加载器把控制权交给 ELF entry point(如 _start 周边启动路径),不是直接调用 C 的 main。
STARTUP CODE

初始化运行时

准备进程参数、环境、运行库状态;调用必要的初始化逻辑。
CALL MAIN

进入 main()

按目标平台的调用约定传递参数并执行用户代码。这个最小示例中 main 调用一次 printf。
EXIT PATH

正常退出清理

main 返回后,运行库处理退出状态并完成正常退出清理,包括刷新 C 标准 I/O 缓冲。

CPU 执行:一次函数调用在寄存器和栈上发生什么?

用一条指令一步步播放 GCC 输出的 main。寄存器值为概念追踪,真实地址与运行时寄存器值会因每次运行和工具链而变。

表中将最后几条收在同一步,方便教学;实际调用 printf 会跳进链接器解析到的库代码,并执行大量运行库指令,而不只是跳一条指令。

printf 到底怎样让文字出现在屏幕上?

关键区别:C 标准库函数、操作系统系统调用和终端模拟器是三个不同层次。

先认识下文的 fd 1:fd 是文件描述符(file descriptor)——进程里一个代表“已打开的输出目标”的小编号,内核靠它认路;1 号固定是标准输出(stdout)。先把它当作“输出通道 1 号”:往 fd 1 写,就是往 stdout 当前指向的地方写。

LAYER 1 · C LIBRARY

printf 格式化与缓冲

解析格式字符串(本例没有格式占位符),把 11 个可见字符放到标准输出流 stdout 的缓冲区。返回值通常为成功写出的字符数,即 11。
LAYER 2 · KERNEL

把字节交给文件描述符 / 句柄

缓冲区刷新时,Linux 常见路径最终通过 write 等系统调用把字节写向文件描述符 1;Windows 的 CRT 使用 Windows I/O API 与句柄机制。实际底层调用可能因实现而异。
LAYER 3 · TERMINAL

终端模拟器绘制字形

如果 stdout 连接到终端,终端程序接收字节流并将字符解码为字形,更新屏幕显示。若输出被重定向,接收方可能是文件或另一个程序,不会出现终端字形。

从用户程序到显示器:七个步骤

</>用户程序(你的代码)printf(“hello world”);1调用 printf(),把 11 个字符写入 stdout 缓冲区此时屏幕上还什么都没有M内存进程的地址空间.text · main 的机器指令.rodata · 字符串常量堆 / 栈 · 运行时分配libc · stdiostdout 缓冲区(用户态内存)68 65 6c 6c 6f 20 77 6f 72 6c 64 002缓冲区满 / 程序退出时才把字节交给内核CPUCPU执行指令3执行 write() 系统调用flush 时从用户态陷入内核触发点:main 返回之后OS操作系统内核态4处理 write() 系统调用从 stdout 缓冲区读出 11 个字节,按 fd 1找到它指向的目标设备(这里是终端)fd = 文件描述符:代表输出目标的编号,1 号 = 标准输出5设备驱动字节交给终端设备驱动(tty 驱动)由它写进设备侧的缓冲,而不是直接画屏用户代码不能直接碰设备 —— 只能请内核代理这条路径对所有进程都一样操作系统的工作管理进程管理内存管理设备提供系统调用接口TTY终端设备驱动tty 驱动6写入终端设备缓冲内核把字节交给驱动,驱动只管”写进去”终端侧条件成熟时再取走 ——这就是你可能感觉到的”延迟”UI显示器终端模拟器hello world7字节 → 字符 → 字形,逐格画到屏幕上1printf 写入stdout 缓冲区2字符暂存在用户态内存3flush 触发write() 进内核4内核处理write,定位 fd 15经 tty 驱动写入终端6终端缓冲接收字节7渲染成字形显示到屏幕!小结printf 本身并不操作显示器 —— 它只是把 11 个字符交给 stdout 缓冲区。真正让文字出现的是操作系统、设备驱动与终端模拟器。
从 printf 到屏幕的完整路径(步骤编号见底部流程条)。缓冲何时刷新取决于缓冲模式与退出路径;字节目标由 fd 1 决定,不一定是终端。

为什么会有缓冲?

减少频繁进入内核的开销,按块提交数据。行缓冲流遇到换行符时一般会刷新;缓冲区满、流关闭或正常调用 exit 时也可能刷新。

系统调用不等于普通函数调用

printf 是用户态库函数,不是 Linux 系统调用。一次 printf 可能不触发系统调用,之后一次缓冲刷新也可能合并、拆分或走不同的底层路径。

为什么提示符可能接在后面?

程序打印的文本没有换行,所以退出后 shell 的提示符可能显示在同一行。添加换行符会改变可见效果,也通常改变行缓冲时机。

把整条链路翻译成 Windows 10 的心智模型

Windows 上仍然有编译、汇编、链接、运行库、装载与输出,但不能直接把 Linux 的 ELF / ld-linux / glibc 细节套上去。

A / BUILD

MSVC 前端与 COFF 目标文件

cl.exe 预处理并编译 C 代码;常见中间产物是 .i、汇编列表和 .obj。目标文件中的外部引用通过符号表与重定位记录表达。
B / LINK

LINK 解析导入并生成 PE

链接器把目标文件、启动库、CRT 库和导入库组合成 PE 映像。导入库通常提供链接期所需的符号 / DLL 导入描述,不必把整个 DLL 实现复制进 EXE。
C / LOAD

Windows Loader 映射映像与 DLL

进程创建时,Windows 的映像加载路径解释 PE 头和节表,映射可执行映像、解析 DLL 依赖和导入地址,并处理必要的重定位。底层实现会随 Windows 版本变化。
D / RUN

CRT 进入 main,再进入 UCRT 输出

常规控制台程序的 CRT 入口初始化运行库后调用 main。printf 经过 UCRT 的流和缓冲逻辑,将数据写往标准输出句柄;最终目标可能是控制台、文件或管道。

调用约定差异:第一个参数放在哪里?

Asm
; 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 版本的精确输出。

如果出错,错误会在哪一阶段出现?

区分编译错误、链接错误、装载错误与运行错误,定位问题会容易很多。

PREPROCESS / COMPILE

头文件找不到

stdio.h 找不到;或语法 / 声明检查失败。程序通常还没有生成目标文件。
LINK

未定义引用

声明能通过编译,但最终链接找不到符号或导入库,例如 undefined reference / unresolved external。
LOAD

无法执行或缺 DLL

文件格式 / 架构不匹配、权限不允许执行、动态加载器或依赖库找不到。
RUNTIME

输出或退出异常

进程已启动,但可能因运行时错误、I/O 目标异常或退出前的异常路径而未按预期输出。

术语速查:点名字,查看它解决的问题

第一次阅读时不必背下所有缩写;先将名称与它所属的层次对应起来。

如何在自己的机器上验证?

把这份页面当成路线图,实际运行工具查看真实构建产物。命令结果会随编译器、版本、架构和链接选项变化。

Shell
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 ./hello
PowerShell
cl /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。页面内容是据此整理的教学说明,不是对任何一本书的逐段复述。