Hello World 的一生

从敲下回车到屏幕上出现字符,中间发生了什么。 覆盖:编译链接 → 进程创建 → 内核加载 → 动态链接 → C 运行时 → 系统调用 → tty/pty → 终端模拟器 → 字形栅格化 → GPU 合成 → 扫描输出 → 液晶成像 → 进程退出。

基准环境:Linux x86-64 + glibc + 图形桌面(Wayland/X11)+ 终端模拟器。 其他环境(纯文本控制台、Windows、静态链接)的差异单独标注。


目录

# 阶段
先纠正五个常见错误
0 编译链接:程序是怎么来的
1 Shell 创建进程
2 内核加载(execve
3 动态链接器
4 C 运行时启动,到达 main
5 puts:用户态缓冲
6 write 系统调用与特权级切换
7 VFS → tty → pty 缓冲区
8 路径分叉:终端模拟器 vs 内核控制台
9 字形栅格化
10 窗口合成与 GPU
11 DRM/KMS、vsync 与 page flip
12 扫描输出与信号编码
13 显示器成像
14 程序退出
时间尺度总表
动手观察
完整流程图

先纠正五个常见错误

网上流传的"HelloWorld 全过程"版本里,有五处是实质性错误:

# 常见说法 实际情况
1 「执行 puts 函数(系统调用)」 puts 不是系统调用,是 libc 函数。它只把字符串拷进用户态的 stdio 缓冲区;真正的系统调用是后面的 write(2),而且未必立刻发生(取决于缓冲模式)
2 「操作系统找到要将字符串送往的显示设备 内核根本不"找显示设备"。它只把字节写进 fd 1 指向的那个文件对象,图形桌面下通常是一个 pty 从设备的环形缓冲区
3 窗口系统将字符串转换成像素」 角色搞反了。栅格化是终端模拟器(用户态进程)自己做的(FreeType/HarfBuzz),窗口系统/合成器只负责把已画好的窗口缓冲区合成到一起
4 「将像素写入设备的存储映像区 太笼统。实际链路是:终端模拟器画进自己的窗口缓冲 → 提交给合成器 → 合成器用 GPU 合成到 scanout buffer → DRM/KMS 在 vblank 时做 page flip
5 「操作系统设置 CPU 上下文,跳到程序开始处 动态链接的程序第一条执行的指令不在你的程序里,而在 ld.so。而且到 main 之前还要经过 _start__libc_start_main.init_array

另外通常完全缺失的环节:动态链接与重定位、syscall 指令的特权级切换、VFS 与 tty 层、pty 机制、字形栅格化、GPU 合成与 vsync、进程退出与 stdio flush。


阶段 0:编译链接,程序是怎么来的

#include <stdio.h>
int main(void) { puts("Hello World"); return 0; }
hello.c
  ├─ 预处理 (cpp)    展开 #include <stdio.h>,puts 的【声明】进来了
  ├─ 编译   (cc1)    生成汇编,call puts 的目标地址还是空的
  ├─ 汇编   (as)     生成 hello.o,符号表里 puts 是 U(未定义)
  └─ 链接   (ld)     生成 ELF 可执行文件

最终 ELF 里有几样东西后面要用到:

内容 作用
PT_LOAD 段(通常 2–4 个) 告诉内核哪些内容映射到内存、映射到哪、什么权限
PT_INTERP = /lib64/ld-linux-x86-64.so.2 "我需要这个解释器来启动我"
DT_NEEDED = libc.so.6 依赖哪些动态库
.plt / .got puts 的调用跳板与地址槽
e_entry 入口点 _start(PIE 下是相对偏移)
.init_array 全局对象构造函数指针表(C++ 才有内容)
.eh_frame 异常展开信息

关键点puts 的机器码不在你的可执行文件里,只有一个"待解析"的空槽。

$ nm hello | grep puts
                 U puts@GLIBC_2.2.5      # U = Undefined
$ readelf -l hello | grep interpreter
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

静态链接的差异gcc -static 会把 puts 的实现直接拷进可执行文件,没有 PT_INTERP,阶段 3 整个消失。代价是文件大几百 KB、无法共享 libc 的物理页、libc 有漏洞时必须重新编译。


阶段 1:Shell 创建进程

用户敲 ./hello + 回车
  │
  │  (键盘中断 → HID 驱动 → input 子系统 → tty → 终端模拟器 → pty →
  │    shell 从 pty 读到这一行。注意这是上面链路的【反方向】)
  ↓
shell 解析命令行,PATH 查找,检查文件存在与可执行权限
  ↓
fork()                    ← 创建子进程
  │  ★ 不拷贝内存!父子共享全部物理页,全部标记为只读 + 写时复制(COW)
  │  只复制 task_struct、页表、文件描述符表等元数据
  ↓ (子进程中)
可能的 fd 重定向:dup2() 处理 > < | 等
  ↓
execve("./hello", argv, envp)

fork + exec 是两步,不是一步。这个设计的好处是:两步之间有一个窗口,可以在子进程里从容地做重定向、改 uid、设 rlimit,然后再 exec。

(现代 shell 对简单命令常用 posix_spawnvfork/clone(CLONE_VM|CLONE_VFORK) 优化,避免复制页表。)


阶段 2:内核加载(execve

 1. 权限检查:可执行位、SUID/SGID、SELinux/AppArmor 策略、noexec 挂载选项
 2. 读文件头 128 字节,识别格式:
       \x7fELF  → binfmt_elf
       #!       → binfmt_script(递归去加载解释器)
       MZ       → binfmt_misc(比如注册了 Wine/qemu-user)
 3. point of no return:拆掉旧的地址空间(mm_struct),建一个全新的
       ★ 从这里开始失败就只能杀进程了,回不去
 4. 遍历 PT_LOAD,为每段调用 mmap 建立【文件映射】的 VMA:
       .text   → r-x, MAP_PRIVATE
       .rodata → r--
       .data   → rw-, MAP_PRIVATE(写时复制)
       .bss    → rw-, 匿名映射(零页)
    ★ 此刻【一个字节的程序代码都没有真正读进内存】,只建立了映射关系
 5. ASLR:随机化各区域基址
       PIE 可执行文件本身、栈、mmap 区(含所有共享库)、堆
 6. 建立栈 VMA,构造初始栈:
       [ argc ][ argv[] ][ NULL ][ envp[] ][ NULL ][ auxv ][ 字符串区 ]
       auxv(辅助向量)里有:
         AT_PHDR   程序头在内存中的位置
         AT_ENTRY  真正的程序入口
         AT_RANDOM 16 字节随机数 → 给 stack canary 用
         AT_SYSINFO_EHDR  vDSO 的地址
 7. 看到 PT_INTERP → 【把 ld.so 也 mmap 进来】
 8. 设置返回用户态时的 RIP = 【ld.so 的入口】,不是你的 _start
 9. 关闭标记了 FD_CLOEXEC 的文件描述符
10. sysret / iret 返回用户态

两个最容易被忽略的点

  • 第 4 步没有任何磁盘 I/O 读程序内容,只是建立虚拟地址到文件的映射关系,真正的读取由后面的缺页异常按需触发
  • 第 8 步跳过去的是动态链接器,不是你的程序
$ cat /proc/self/maps        # 看看 execve 建立了哪些 VMA
$ LD_SHOW_AUXV=1 ./hello     # 看 auxv 的内容

阶段 3:动态链接器

ld.so 的第一条指令一执行 —— 缺页异常。这是整个流程里第一次真正碰内存。

缺页处理

CPU 访问某个虚拟地址
  ↓ 页表项 Present = 0
#PF 异常 → 内核 do_page_fault()
  ↓
查 VMA:地址落在合法区间吗?权限对吗?
  ├─ 不合法 → SIGSEGV(这就是空指针解引用崩溃的机制)
  └─ 合法,是文件映射
        ↓
     page cache 里有这一页吗?
     ├─ 有(别的进程刚跑过 / 系统缓存着)
     │    → minor fault:直接把物理页填进页表,~1 µs
     └─ 没有
          → major fault:
              文件系统 → 块层 → NVMe/SATA 驱动 → 提交 I/O 请求
              → 设备 DMA 把数据写进物理页 → 中断通知
              → 填页表 → 重新执行那条触发异常的指令
              NVMe ~100 µs,机械硬盘 ~10 ms

两个推论:

  • .textMAP_PRIVATE 的只读文件映射,所以同一程序跑多个实例,代码段的物理页是共享的,只占一份内存。libc.so 更是被系统上几乎所有进程共享。
  • 第二次运行 hello 几乎全是 minor fault(page cache 命中),所以明显更快。这就是"冷启动"和"热启动"的差别。

链接器干的活

① 自举:先重定位自己(它自己也是一个动态库,且不能依赖任何库)
② 读 DT_NEEDED → mmap libc.so.6(同样只建映射,按需缺页)
     递归处理它的依赖
③ 符号解析:把 hello 里的 U 符号在各库的符号表里查出来
     查找顺序:可执行文件 → LD_PRELOAD → DT_NEEDED 的广度优先序
     ★ 这就是 LD_PRELOAD 能"劫持"函数的原理
④ 重定位:
     R_X86_64_RELATIVE  → 基址 + 偏移(PIE 的自身地址修正,数量最多)
     R_X86_64_GLOB_DAT  → 填 GOT 里的数据地址
     R_X86_64_JUMP_SLOT → PLT 对应的 GOT 槽
                          惰性绑定时先指回 PLT 桩,首次调用才解析
⑤ 初始化 TLS:把各模块的 TLS 模板拷进线程的 TLS 块,设置 fs 寄存器
⑥ 若开了 Full RELRO(-z now)→ 现在就解析全部符号,然后 mprotect 把 GOT 设为只读
⑦ 跳到 hello 的 e_entry,即 _start
$ LD_DEBUG=all ./hello 2>&1 | less     # 看链接器每一步做了什么
$ LD_DEBUG=statistics ./hello          # 看重定位数量和耗时
$ ldd hello                            # 看依赖树

动态链接的代价:一个链接了 Qt/GTK 的大程序,启动时可能要处理几十万条重定位,耗时几十毫秒 —— 这是 GUI 程序"点了半天才起来"的重要原因之一。预链接(prelink)、DT_GNU_HASH、Full RELRO 都是围绕这个问题的优化。


阶段 4:C 运行时启动,到达 main

_start                          ← 汇编写的,没有栈帧,没有返回地址
  │  从栈上取 argc / argv / envp
  │  把栈对齐到 16 字节(SysV ABI 要求 call 前 rsp 16 字节对齐)
  │  清零 rbp(栈回溯的终止标记)
  ↓
__libc_start_main
  │  初始化 stdio:构造 stdin / stdout / stderr 三个 FILE 对象
  │      ★ 在这里根据 isatty(1) 决定 stdout 是行缓冲还是全缓冲
  │  设置 stack canary:从 auxv 的 AT_RANDOM 取值,存进 fs:[0x28]
  │  初始化线程库(pthread)、locale
  │  atexit 注册 _dl_fini(析构动态库)
  │  遍历 .preinit_array 和 .init_array
  │      ★【C++ 全局/静态对象的构造函数在这里跑】
  ↓
main(argc, argv, envp)          ← 你的代码终于开始执行

这就是"C++ 全局对象在 main 之前构造"的具体位置。析构则通过 __cxa_atexit 注册,在阶段 14 按构造完成的反序执行。


阶段 5:puts —— 用户态缓冲

puts("Hello World");

5.1 第一次调用走惰性绑定

call puts@plt
  → PLT[n]: jmp *GOT[n]
  → GOT[n] 此时还指回 PLT[n] 的下一条指令(未解析)
  → push 重定位索引; jmp PLT[0]
  → PLT[0] 调用 _dl_runtime_resolve
  → ld.so 查出 puts 在 libc 里的真实地址,【回填 GOT[n]】
  → 跳过去执行

第二次调用:call puts@plt → jmp *GOT[n] → 直接命中真实地址,只多一次间接跳转

(开了 -z now / Full RELRO 时,这一步在阶段 3 就做完了。)

5.2 puts 做了什么 —— 它不是系统调用

// 简化后的逻辑
int puts(const char* s) {
    size_t len = strlen(s);
    // 把 s 逐字节拷进 stdout 的【用户态缓冲区】
    memcpy(stdout->_IO_write_ptr, s, len);
    stdout->_IO_write_ptr += len;
    *stdout->_IO_write_ptr++ = '\n';          // puts 自动补换行

    if (行缓冲 && 刚写入了 '\n')
        return _IO_flush(stdout);             // ← 这里面才有系统调用
    return len;
}

缓冲模式决定字符串什么时候真正离开进程

stdout 连到什么 缓冲模式 何时 flush 缓冲区大小
终端(tty) 行缓冲 _IOLBF 遇到 \n 或缓冲区满 1–4 KB
管道 / 文件 全缓冲 _IOFBF 缓冲区满 / fflush / 程序退出 4 KB(按 st_blksize
stderr 无缓冲 _IONBF 立即

这解释了几个日常现象:

printf("Hello");        // 没有 \n
abort();                // 崩溃 → 缓冲区没 flush → 【什么都看不到】
                        // 所以调试打印要么带 \n,要么用 stderr,要么手动 fflush
./prog | cat            # 重定向后 stdout 变全缓冲 → 输出变得"一顿一顿"
stdbuf -o0 ./prog | cat # 强制无缓冲
./prog > file           # 同样是全缓冲

因为 puts 补了 \n 且 stdout 连着终端,这里触发行缓冲刷新,进入下一阶段。


阶段 6:write 系统调用与特权级切换

write(1, "Hello World\n", 12);

glibc 的包装函数最终执行:

mov  eax, 1          ; __NR_write(x86-64 上 write 的系统调用号是 1)
mov  edi, 1          ; fd = 1 (stdout)
mov  rsi, buf        ; 缓冲区地址
mov  rdx, 12         ; 长度
syscall              ; ← 这一条指令完成用户态到内核态的切换

syscall 指令的硬件动作

1. 保存返回地址:RIP → RCX,RFLAGS → R11
     ★ 所以 syscall 会破坏 rcx 和 r11,它们是 caller-saved
2. 从 IA32_LSTAR MSR 取内核入口地址 → RIP = entry_SYSCALL_64
3. CPL 从 3 变为 0(用户态 → 内核态)
4. 【切换 CR3】—— 开启 KPTI 时用户页表和内核页表是分开的
     Meltdown 缓解措施,需要刷 TLB,是系统调用变慢的主因之一
5. 从 per-CPU 的 TSS 取内核栈指针,切换到内核栈
     ★ 每个线程有独立的内核栈(x86-64 上 16 KB)
6. 压入 pt_regs(保存全部用户态寄存器)
7. 查 sys_call_table[1] → 调用 ksys_write()

顺带一提:不是所有"系统调用"都真的进内核。gettimeofdayclock_gettimegetcpu 等通过 vDSO(内核映射进每个进程的一小段代码)在用户态直接完成,没有特权级切换。ldd 输出里那个 linux-vdso.so.1 就是它。

内核侧

ksys_write(fd=1, buf, count)
  ↓
fdget(1) → current->files->fdt->fd[1] → struct file*
  ↓
rw_verify_area()          检查偏移合法性、强制锁
  ↓
vfs_write()
  ↓
copy_from_user()          ★ 检查用户指针是否越界、是否可读
  │                         这里也可能触发缺页(用户缓冲区被换出)
  ↓
file->f_op->write_iter(...)     ← 多态分发,f_op 由文件类型决定

f_op 这一步是 VFS 的核心:同一个 write 系统调用,写普通文件、写管道、写 socket、写终端走的是完全不同的实现。fd 1 指向什么,就走哪套。


阶段 7:VFS → tty → pty 缓冲区

这是最容易被讲错的一段。 内核不"寻找显示设备",它只是把字节交给 fd 1 指向的那个对象。图形桌面下,那是一个 pty(伪终端)的从设备

$ ls -l /proc/self/fd/1
lrwx------ 1 user user 64 ... /proc/self/fd/1 -> /dev/pts/3

什么是 pty

pty 是一对成对的虚拟字符设备,本质是内核里的两个环形缓冲区:

     从设备 /dev/pts/N                    主设备 /dev/ptmx
     (程序以为自己连着终端)              (终端模拟器持有)
            │                                    │
            │  write("Hello\n")                  │  read() 阻塞中
            ↓                                    ↑
     ┌──────────────────────────────────────────────────┐
     │            内核:pty 的环形缓冲区(约 8 KB)        │
     └──────────────────────────────────────────────────┘

数据流

vfs_write
  ↓
tty_write()
  ↓
★ 线路规程(line discipline,N_TTY)
  │  按 termios 设置做输出后处理(c_oflag):
  │     OPOST  开启输出处理
  │     ONLCR  '\n' → "\r\n"    ← 这就是为什么裸 write 到终端要考虑 \r\n
  │     ONLRET / OCRNL / TAB 展开 ...
  │  (输入方向还负责行编辑、回显、Ctrl-C 转 SIGINT 等)
  ↓
pty 从设备驱动 pty_write()
  ↓
★ memcpy 进 pty 的【环形缓冲区】(内核内存)
  ↓
tty_flip_buffer_push() / wake_up_interruptible()
  ↓
唤醒阻塞在【主设备】read() 上的进程 —— 就是终端模拟器
  ↓
write 系统调用返回,返回值 12
  ↓
sysret 回到用户态,puts 返回,你的程序继续往下走

到这里,hello 这个进程的工作已经全部结束了。 它把字节交给了内核缓冲区,剩下的全是别人的事 —— 而剩下的部分占了总耗时的 99%

$ strace -e trace=write ./hello
write(1, "Hello World\n", 12)           = 12
# 只有这一次系统调用,puts 本身不出现在 strace 里

阶段 8:路径分叉 —— 终端模拟器 vs 内核控制台

从这里开始有两条完全不同的路径。

路径 A:图形桌面 + 终端模拟器(最常见)

终端模拟器(gnome-terminal / konsole / alacritty / kitty / Windows Terminal)
  从 /dev/ptmx 读到字节流 "Hello World\r\n"
  ↓
★ 解析 ANSI/VT100 转义序列
  │  普通字符 → 写入字符网格
  │  ESC[31m  → 设置前景色为红
  │  ESC[2J   → 清屏
  │  ESC[H    → 光标归位
  │  ...(终端模拟器本质上是一个 VT 状态机)
  ↓
更新自己的【字符网格模型】(screen buffer)
  每个 cell 记录:码点、前景色、背景色、粗体/下划线等属性
  ↓
计算 damage(哪些 cell 变了,只重画这些)
  ↓
进入渲染流程(阶段 9)

注意:这一切都在用户态进行。内核对"Hello World 长什么样"一无所知。

路径 B:纯文本控制台(Ctrl+Alt+F3 切过去的那个)

没有终端模拟器,内核自己干:

tty_write → vt 控制台驱动(drivers/tty/vt/vt.c)
  ↓
解析转义序列,更新内核里的屏幕字符数组
  ↓
fbcon(framebuffer console)
  ↓
★ 用【内核内置的位图字体】(如 8×16 的 VGA 字模)
  直接把字形位图写进 framebuffer 内存
  ↓
(现代系统上 framebuffer 由 DRM 的 fbdev 兼容层提供)

更古老的 VGA 文本模式:内核往物理地址 0xB8000 写"字符字节 + 属性字节", 显卡的字符发生器硬件负责查字模、变像素。CPU 完全不碰像素。

这条路径解释了为什么 kernel panic 的信息能显示出来 —— 它不依赖任何用户态进程。

其他分支

fd 1 指向 路径
重定向到文件 VFS → 文件系统 → page cache → 回写线程 → 块层 → 磁盘
管道 | pipe 的环形缓冲区 → 唤醒读端进程
socket(ssh 远程) TCP 协议栈 → 网卡驱动 → DMA → 网线 → 对端机器再走一遍显示流程
/dev/null 直接丢弃,write 立即返回

下面继续沿路径 A 展开。


阶段 9:字形栅格化

终端模拟器需要把"码点 + 字体 + 字号"变成像素。

'H' (U+0048)
  ↓
① 字体回退(fontconfig)
     按配置找到能显示这个码点的字体文件
     中文/emoji 常需要回退到另一个字体
  ↓
② 文本整形(HarfBuzz)
     码点序列 → 字形索引序列 + 定位
     处理连字(fi)、阿拉伯语连写、印度语重排、组合字符
     等宽终端里通常简化处理,但 emoji 和 CJK 宽度仍需判断
  ↓
③ 栅格化(FreeType)
     字体 cmap 表:U+0048 → glyph index
     取出轮廓(TrueType 二次贝塞尔 / CFF 三次贝塞尔)
     缩放到目标像素尺寸
     hinting:微调轮廓让笔画对齐像素网格(小字号下尤其重要)
     扫描线填充 → 抗锯齿(灰度或 subpixel/ClearType)
     → 得到一张灰度位图(覆盖率图)
  ↓
④ 缓存
     ★ 栅格化很贵,结果存进【glyph atlas】
       通常是一张 GPU 纹理,之后同一字符直接查表
  ↓
⑤ 上色 + blit
     用 glyph 的覆盖率作为 alpha,把前景色混合到背景色上
     写进终端模拟器自己的窗口缓冲区

性能要点:现代终端(alacritty、kitty、WezTerm)之所以快,主要就是把 glyph atlas 放在 GPU 上,渲染时只提交顶点和纹理坐标,用 GPU 做混合。


阶段 10:窗口合成与 GPU

终端模拟器画好了自己的窗口内容,接下来要交给合成器。

Wayland(现代路径)

客户端(终端模拟器)
  │  内容画在一块 buffer 里,两种来源:
  │    CPU 渲染 → wl_shm 共享内存 buffer
  │    GPU 渲染 → DMA-BUF(显存里的 buffer,通过 fd 传递,零拷贝)
  ↓
wl_surface.attach(buffer)
wl_surface.damage(x, y, w, h)     ← 只标记变化的区域
wl_surface.commit()
  ↓  (通过 Unix domain socket 发消息 + 传 fd)
合成器(Mutter / KWin / Weston / Hyprland / sway)
  │  收集所有客户端的 surface
  │  计算最终的屏幕布局(位置、缩放、透明度、圆角、模糊…)
  ↓
两种合成方式:
  ┌─ GPU 合成:用 OpenGL/Vulkan 把每个 surface 当纹理画到 scanout buffer
  └─ 硬件平面合成(DRM plane / overlay):
       显示控制器直接从多个 buffer 取数据合成,【零拷贝、省电】
       全屏视频、全屏游戏常走这条路

X11(传统路径)

客户端 → Xlib/XCB → X Server(Xorg)
  │  XShmPutImage / Present 扩展提交像素
  ↓
X Server 把内容画进窗口的 pixmap
  ↓
合成管理器(picom / compton / Mutter 的 X11 模式)
  用 GLX/EGL 把各窗口纹理合成

X11 的额外一跳(客户端 → X Server → 合成器)是它比 Wayland 延迟高的主要原因。

GPU 侧

合成器发出绘制命令
  ↓
Mesa(用户态驱动,如 iris / radeonsi / nouveau)
  把 OpenGL/Vulkan 调用翻译成 GPU 指令,写进【命令缓冲区】
  ↓
ioctl → DRM 内核驱动(i915 / amdgpu / nouveau / nvidia)
  ↓
把命令缓冲区提交到 GPU 的【环形缓冲区(ring buffer)】
  写门铃寄存器(doorbell)通知 GPU
  ↓
GPU 命令处理器取指、调度
  → 顶点着色器(画两个三角形拼成一个矩形)
  → 光栅化
  → 片元着色器(采样纹理、alpha 混合)
  → ROP 写入目标 framebuffer(显存)
  ↓
完成后发中断 → 内核 → 信号 fence → 通知合成器

阶段 11:DRM/KMS、vsync 与 page flip

合成好的画面在一块 framebuffer 里,需要让显示控制器去读它。

合成器调用:
  drmModeAtomicCommit(fd, req, DRM_MODE_ATOMIC_NONBLOCK | DRM_MODE_PAGE_FLIP_EVENT, ...)
  ↓
DRM/KMS 内核子系统
  ★ 这个提交【不会立刻生效】,而是排队等待下一次 vblank
  ↓
垂直消隐期(vertical blanking interval)到来
  ↓
硬件原子地切换 CRTC 指向的 framebuffer 地址
  ↓
发出 page flip 完成事件 → 合成器收到,可以复用旧 buffer

为什么要等 vblank

显示控制器在逐行扫描 framebuffer。如果扫描到一半就切换 buffer,屏幕上半部分是旧帧、下半部分是新帧 —— 这就是画面撕裂(tearing)

只在"一帧刚扫完、下一帧还没开始"的消隐期切换,才能保证完整。

代价:60 Hz 下 vblank 每 16.7 ms 才来一次。你的字符可能要在内存里干等最多 16.7 ms —— 这往往是整条链路上最大的一块延迟。

缓冲策略

策略 说明
双缓冲 一个在扫描(front),一个在画(back),vblank 时交换。画慢了就掉到 30 fps
三缓冲 多一个 buffer,画慢了不至于整帧掉,但延迟增加
VRR / FreeSync / G-Sync 显示器的刷新率跟着 GPU 走,消除等待
Tearing / immediate 不等 vblank,立刻切。延迟最低,会撕裂(竞技游戏偏好)

阶段 12:扫描输出与信号编码

现在像素躺在显存里,显示控制器要把它们变成电信号送出去。

CRTC(显示控制器 / 时序发生器)
  │  按【像素时钟】逐行读取 framebuffer(这个过程叫 scanout)
  │    1920×1080@60Hz 的像素时钟约 148.5 MHz
  │    每秒要读 1920 × 1080 × 4 字节 × 60 ≈ 500 MB —— 持续占用显存带宽
  ↓
后处理流水线:
  │  色彩空间转换(CSC):如 RGB → YCbCr
  │  Gamma / Degamma LUT
  │  色彩管理(ICC 配置、HDR 色调映射)
  │  抖动(dithering,8bit 面板显示 10bit 内容时)
  ↓
加入时序信号:
  │  HSYNC(行同步)、VSYNC(场同步)
  │  水平/垂直消隐期(blanking)—— CRT 时代给电子束回扫留的时间,
  │    数字时代保留下来用于传输音频、HDR 元数据等辅助数据包
  ↓
编码器(Encoder / PHY)
  ├─ HDMI/DVI:TMDS 编码
  │     8 bit → 10 bit(做 DC 平衡和跳变最小化)
  │     3 条数据通道(R/G/B)+ 1 条时钟通道,差分信号
  ├─ DisplayPort:微包(micro-packet)架构
  │     8b/10b(DP1.2)或 128b/132b(DP2.0)编码
  │     多条 lane,自带时钟恢复,支持 MST 菊花链
  └─ eDP(笔记本内屏)、LVDS(老设备)
  ↓
线缆 / 排线 → 显示器

顺带:显示器通过 DDC/CI 通道把 EDID 数据(支持哪些分辨率、刷新率、色域)报给显卡,这就是插上显示器就自动匹配分辨率的原因。


阶段 13:显示器成像

显示器接收电路
  │  解码 TMDS / DP 信号,恢复出像素数据
  ↓
Scaler(若输入分辨率 ≠ 面板原生分辨率则插值缩放)
  ↓
显示器内部图像处理:亮度、对比度、锐化、overdrive、局部调光
  ★ 这些处理是"输入延迟"的主要来源,游戏模式就是关掉它们
  ↓
TCON(时序控制器)
  │  把像素数据分发给:
  │    源极驱动(Source Driver)—— 列,负责施加数据电压
  │    栅极驱动(Gate Driver)  —— 行,负责逐行选通
  ↓
面板逐行刷新

LCD(液晶)的成像原理

背光(LED)常亮
  ↓
下偏振片:只让某一方向偏振的光通过
  ↓
★ 液晶层:施加电压改变液晶分子扭转角度
           → 改变透过光的偏振方向 → 决定能透过多少光
           (这就是"电压 → 亮度"的转换)
  ↓
彩色滤光片:每个像素分成 R / G / B 三个子像素
  ↓
上偏振片(与下偏振片正交)
  ↓
人眼:三个子像素的亮度组合被感知为一种颜色

液晶分子扭转需要时间 —— 这就是响应时间(1–5 ms),也是运动画面拖影的来源。

OLED

没有背光和液晶,每个子像素自己发光(电流驱动有机材料)。

  • 响应时间 < 0.1 ms
  • 关掉的像素是真正的黑(对比度理论无穷大)
  • 代价:烧屏、亮度衰减

「Hello World」出现在屏幕上。


阶段 14:程序退出

main 返回 0
  ↓
__libc_start_main 调用 exit(0)
  ↓
__run_exit_handlers:
  │  按注册的【反序】执行:
  │    atexit / on_exit 注册的函数
  │    __cxa_atexit 注册的【C++ 全局对象析构函数】
  │    _dl_fini(动态库的 .fini_array)
  ↓
_IO_cleanup()
  ★ flush 所有还有数据的 stdio 流
     这就是"全缓冲时程序退出会把剩余输出吐出来"的原因
  ↓
exit_group(0) 系统调用     ← 终止进程内所有线程
  ↓
内核 do_exit():
  │  释放 mm_struct:解除所有 VMA 映射,归还物理页
  │    (文件映射页留在 page cache 里,供下次运行复用)
  │  关闭所有文件描述符
  │  释放信号处理、定时器、命名空间引用等
  │  ★ 保留 task_struct,进程变成【僵尸(zombie)】
  │     因为退出状态还得留给父进程读
  ↓
向父进程(shell)发送 SIGCHLD
  ↓
shell 的 wait4() / waitpid() 返回,读走退出码
  ↓
内核释放 task_struct,进程彻底消失
  ↓
shell 打印新的提示符(走一遍上面的显示流程)

如果父进程不 wait,僵尸进程会一直占着 PID 和 task_struct。父进程先死的话,孤儿进程会被 init/systemd 收养,由它负责回收。


时间尺度总表

阶段 典型耗时 备注
fork + execve 100 µs – 1 ms 主要是页表操作和 VMA 建立
动态链接与重定位 100 µs – 1 ms 小程序;链了 Qt/GTK 的可达 10–50 ms
major page fault(NVMe) ~100 µs 冷启动,每页一次
major page fault(机械盘) ~10 ms
minor page fault ~1 µs 热启动,page cache 命中
C 运行时初始化 ~10 µs
puts 写用户缓冲 ~10 ns 就是一次 memcpy
syscall 往返 100 – 500 ns 开 KPTI 后显著变贵
tty/pty 处理 + 唤醒读端 1 – 10 µs
终端模拟器解析 + 栅格化 10 – 100 µs glyph 缓存命中时接近下限
提交给合成器 10 – 100 µs Wayland 比 X11 少一跳
GPU 合成 0.5 – 5 ms
等待 vblank 0 – 16.7 ms 60 Hz 下最大的单项延迟
显示器内部处理 2 – 20 ms 游戏模式下最低
液晶响应 1 – 5 ms OLED < 0.1 ms
端到端总计 约 20 – 60 ms

一个反直觉的结论

你的程序真正在跑的时间:       几微秒
从写完缓冲到人眼看见的时间:   几十毫秒

★ 99% 以上的时间花在【显示流水线】上,与你的代码毫无关系。

这也解释了为什么"输入延迟"是个独立的工程问题 —— 优化程序本身对它几乎没有帮助, 真正有效的是:关掉 vsync、用 VRR、高刷显示器、显示器开游戏模式。


动手观察

# —— 进程与系统调用 ——
strace -f ./hello                    # 所有系统调用(注意 puts 不出现)
strace -e trace=write ./hello        # 只看 write
ltrace ./hello                       # 库函数调用(能看到 puts)
perf trace ./hello                   # 更低开销的 strace

# —— 加载与动态链接 ——
readelf -l hello                     # program headers,看 PT_LOAD / PT_INTERP
readelf -d hello                     # 动态段,看 DT_NEEDED
ldd hello                            # 依赖树(注意 linux-vdso.so.1)
LD_DEBUG=all ./hello 2>&1 | less     # 链接器每一步
LD_DEBUG=statistics ./hello          # 重定位数量与耗时
LD_SHOW_AUXV=1 ./hello               # 辅助向量
nm -C hello | grep ' U '             # 未定义符号

# —— 内存 ——
cat /proc/self/maps                  # 地址空间布局
pmap -x $$                           # 带物理内存统计
/usr/bin/time -v ./hello             # minor/major page fault 计数
perf stat -e page-faults,minor-faults,major-faults ./hello

# —— 终端与 tty ——
tty                                  # 当前终端设备
ls -l /proc/self/fd/                 # fd 0/1/2 指向什么
stty -a                              # termios 设置(看 onlcr、icanon、echo)
stdbuf -o0 ./prog | cat              # 强制无缓冲,观察缓冲模式的影响
script -c ./hello /dev/null          # 强制分配 pty(即使重定向)

# —— 显示 ——
drm_info                             # DRM 设备、CRTC、encoder、connector
modetest -M i915                     # 显示模式与平面
cat /sys/class/drm/*/edid | edid-decode
weston-debug                         # Wayland 合成器调试
xrandr --verbose                     # X11 下的显示信息

# —— 缓冲模式对比 ——
./hello                              # 行缓冲,立即出现
./hello | cat                        # 全缓冲,攒够 4KB 或退出才出现

完整流程图

  用户敲回车
      │
 ┌────┴─────────────────────── 用户态 ────────────────────────┐
 │  shell: fork()                                            │
 └────┬──────────────────────────────────────────────────────┘
      │ execve()
 ┌────┴─────────────────────── 内核 ──────────────────────────┐
 │  binfmt_elf → 建立 VMA(不读内容)→ ASLR → 构造初始栈       │
 │  加载 ld.so → 设置 RIP = ld.so 入口 → 返回用户态            │
 └────┬──────────────────────────────────────────────────────┘
      │
 ┌────┴─────────────────────── 用户态 ────────────────────────┐
 │  缺页异常(←→ 内核,可能触发磁盘 I/O)                       │
 │  ld.so: 加载 libc → 符号解析 → 重定位 → TLS 初始化          │
 │  _start → __libc_start_main → .init_array → main()        │
 │  puts("Hello World")                                      │
 │    └─ 拷进 stdout 用户缓冲区 → 遇 '\n' 触发 flush           │
 └────┬──────────────────────────────────────────────────────┘
      │ syscall (write)
 ┌────┴─────────────────────── 内核 ──────────────────────────┐
 │  entry_SYSCALL_64 → 切 CR3/内核栈 → sys_write              │
 │  VFS → f_op->write_iter → tty_write                       │
 │  线路规程 N_TTY(ONLCR: \n → \r\n)                        │
 │  ★ 写入 pty 环形缓冲区 → 唤醒读端                           │
 └────┬──────────────────────────────────────────────────────┘
      │
      ├──── 路径 B(文本控制台)────────────────────────────┐
      │     vt 驱动 → fbcon → 内核位图字体 → 直写 framebuffer │
      │                                                    ↓
 ┌────┴─── 路径 A(图形桌面)── 用户态 ──────────────┐        │
 │  终端模拟器 read() 唤醒                          │        │
 │  解析 ANSI 转义序列 → 更新字符网格                │        │
 │  fontconfig → HarfBuzz → FreeType 栅格化         │        │
 │  glyph atlas 缓存 → blit 到窗口缓冲区             │        │
 │  wl_surface.commit() / XPresent                  │        │
 │      ↓                                          │        │
 │  合成器:收集所有 surface → GPU 合成             │        │
 │      ↓ Mesa → DRM ioctl → GPU ring buffer       │        │
 │  GPU 渲染到 scanout framebuffer(显存)           │        │
 └────┬────────────────────────────────────────────┘        │
      │ drmModeAtomicCommit                                 │
 ┌────┴─────────────────────── 内核 ──────────────┐          │
 │  DRM/KMS 排队 → ★ 等待 vblank(最多 16.7 ms)  │          │
 │  原子切换 CRTC 的 framebuffer 地址              │←─────────┘
 └────┬───────────────────────────────────────────┘
      │
 ┌────┴─────────────────────── 硬件 ──────────────────────────┐
 │  CRTC 逐行 scanout → gamma/CSC → 加入 HSYNC/VSYNC          │
 │  编码器:TMDS(HDMI)/ 8b10b(DP)→ 差分信号 → 线缆        │
 │  显示器:解码 → scaler → 图像处理 → TCON                   │
 │  源极/栅极驱动 → 液晶扭转(或 OLED 发光)→ 滤色片 → 偏振片  │
 └────┬──────────────────────────────────────────────────────┘
      │
   人眼看到 "Hello World"

一句话版本

Shell fork+execve,内核用 binfmt_elf 建立 VMA 但不读任何内容,随后跳进 ld.so; 链接器在一路缺页中加载 libc、完成重定位,再经 _start__libc_start_main.init_array 抵达 mainputs 不是系统调用,它只把字符串拷进 stdout 的用户态缓冲区,因为连着终端是行缓冲、遇 \n 才触发 write(2)syscall 指令切到内核态,VFS 分发到 tty 层,线路规程做 \n\r\n 转换后写进 pty 的环形缓冲区并唤醒终端模拟器; 终端模拟器(用户态)解析转义序列、用 FreeType 把字符栅格化成像素画进自己的窗口缓冲,提交给合成器; 合成器用 GPU 把所有窗口合成到 scanout buffer,通过 DRM/KMS 等待 vblank 后原子切换 CRTC 地址; 显示控制器逐行扫描、编码成 TMDS/DP 信号,显示器解码后驱动液晶扭转 —— 字符终于出现。 整条链路约 20–60 ms,其中 99% 与你的代码无关。


相关文档

  • memory-layout.md —— ELF 段、VMA、缺页、栈帧、系统调用的更多细节
  • value-categories.md —— 值类别与移动语义
  • lambda.md —— 闭包的实现

延伸阅读


本文以 Linux x86-64 + glibc + Wayland 为基准。时间数据为典型量级,会随硬件和配置显著变化。