Hello World是如何显示出来的
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:编译链接,程序是怎么来的
int
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 的机器码不在你的可执行文件里,只有一个"待解析"的空槽。
|
|
静态链接的差异:
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_spawn 或 vfork/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 步跳过去的是动态链接器,不是你的程序
阶段 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
两个推论:
.text是MAP_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
|
动态链接的代价:一个链接了 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 —— 用户态缓冲
;
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
缓冲模式决定字符串什么时候真正离开进程:
| stdout 连到什么 | 缓冲模式 | 何时 flush | 缓冲区大小 |
|---|---|---|---|
| 终端(tty) | 行缓冲 _IOLBF |
遇到 \n 或缓冲区满 |
1–4 KB |
| 管道 / 文件 | 全缓冲 _IOFBF |
缓冲区满 / fflush / 程序退出 |
4 KB(按 st_blksize) |
| stderr | 无缓冲 _IONBF |
立即 | — |
这解释了几个日常现象:
; // 没有 \n
; // 崩溃 → 缓冲区没 flush → 【什么都看不到】
// 所以调试打印要么带 \n,要么用 stderr,要么手动 fflush
| |
因为 puts 补了 \n 且 stdout 连着终端,这里触发行缓冲刷新,进入下一阶段。
阶段 6:write 系统调用与特权级切换
;
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()
顺带一提:不是所有"系统调用"都真的进内核。
gettimeofday、clock_gettime、getcpu等通过 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(伪终端)的从设备:
什么是 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%。
=
# 只有这一次系统调用,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、高刷显示器、显示器开游戏模式。
动手观察
# —— 进程与系统调用 ——
# —— 加载与动态链接 ——
LD_DEBUG=all | LD_DEBUG=statistics LD_SHOW_AUXV=1 |
# —— 内存 ——
# —— 终端与 tty ——
|
# —— 显示 ——
|
# —— 缓冲模式对比 ——
|
完整流程图
用户敲回车
│
┌────┴─────────────────────── 用户态 ────────────────────────┐
│ 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抵达main;puts不是系统调用,它只把字符串拷进 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—— 闭包的实现
延伸阅读
- 《深入理解计算机系统》(CSAPP) 第 7、8、9 章
- 《程序员的自我修养 —— 链接、装载与库》
man 2 execve/man 7 pty/man 3 setvbuf/man 7 termios- How programs get run: ELF binaries (LWN)
- The Linux Graphics Stack
本文以 Linux x86-64 + glibc + Wayland 为基准。时间数据为典型量级,会随硬件和配置显著变化。
暂无评论,欢迎留下第一条评论。