程序人生—Hello’s P2P

作者 殷霖晟

摘要

本论文以 Linux/x86-64 环境下的示例程序 hello 为对象,围绕“从源代码到进程,再到退出回收”的完整生命周期,系统梳理计算机系统各层机制如何协同支撑一个用户程序的生成、装载、执行与终止。论文首先从编译系统流水线出发,按照预处理、编译、汇编、链接的顺序,对中间产物(.i/.s/.o/可执行文件)及其关键结构进行分析;随后从 ELF 装载视角解释可执行文件的段/节组织、入口点、动态段以及 PLT/GOT 等动态链接支撑结构,并通过调试与反汇编验证符号解析、重定位与延迟绑定的运行时效果。进一步地,论文在进程管理层面结合 shell 的 fork/execve 模式、作业控制与信号机制,说明 hello 在阻塞—唤醒—调度、停止/继续与终止等状态转换中的行为特征。最后从存储管理与 I/O 管理角度,概述进程虚拟地址空间布局、分段与分页的地址转换链路、TLB 与四级页表的加速机理、Cache 层次对访存性能的影响,以及 printf/getchar 经由 stdio 缓冲与系统调用进入内核 I/O 子系统的路径闭环。

关键词:编译系统;ELF;动态链接;进程与信号;虚拟内存;Unix I/O

第1章 概述

1.1 Hello简介

  • P2P:From Program to Process(从程序到进程)
    • Program(程序)阶段:Hello 最初以源代码 hello.c 的形式存在,是“静态的文件”(disk file)。
    • 翻译与构建(toolchain)阶段:在编译系统的流水线上,依次经历
      1. 预处理(Preprocess)hello.c → hello.i(展开宏、处理头文件、条件编译)
      2. 编译(Compile/cc1)hello.i → hello.s(生成汇编,完成类型/表达式/控制流到指令的映射)
      3. 汇编(Assemble/as)hello.s → hello.o(生成可重定位目标文件 ELF64 relocatable)
      4. 链接(Link/ld)hello.o + CRT + libc… → hello(生成可执行 ELF,完成符号解析与重定位,建立 PLT/GOT/动态段)
    • Process(进程)阶段:在 Bash 中执行 ./hello ...
      • Bash 通过 fork/clone 创建子进程;
      • 子进程通过 execvehello 的程序映像替换自身;
      • 内核按 ELF Program Headers mmap 代码段/数据段/共享库,动态链接器 ld-linux 完成动态装载与重定位;
      • 进入 _start → __libc_start_main → main,Hello 从“文件”变成“正在运行的进程”。
  • 020:From Zero to Zero(从 0 到 0)
    • 起点 0(从无到有):最初只是磁盘上的源文件与构建产物(“还没运行”)。
    • 运行过程:OS 为其分配虚拟地址空间(text/rodata/data/bss/heap/stack、共享库、vdso/vvar),由 MMU(页表/TLB)与 Cache 支持执行;通过系统调用与 I/O 子系统与外界交互(printf/sleep/getchar)。
    • 终点 0(归零离场)main 返回 0,随后 exit(0) → _exit(0),进程资源被内核回收,留下的只是输出与退出码(常见“0 表示正常结束”)。
    • 动态链接器初始化 → hello:_start__libc_start_mainmainexit/_exit

1.2 环境与工具

  • 硬件与平台
    • x86-64(AMD64)体系结构环境
    • 终端/键盘/显示等 I/O 设备
  • 操作系统与运行环境
    • Ubuntu 24.04 Linux
    • 动态链接器:/lib64/ld-linux-x86-64.so.2
    • 关键运行库:libc.so.6libgcc_s.so.1
  • 构建工具链(编译系统)
    • cpp(预处理器)
    • cc1(C 编译器后端/编译阶段,.i → .s
    • as(汇编器,.s → .o
    • ld(链接器,.o → 可执行文件
    • gcc(driver,用于驱动以上工具并打印 link 命令/参数)
  • 分析与调试工具
    • ELF/目标文件分析:readelfobjdump
    • 调试:gdb
    • 系统调用与进程行为跟踪:strace
    • 进程/作业管理观察:psjobspstreefg/bgkill
    • 运行时信息:/proc/<pid>/maps

1.3 中间结果

  • 源与核心构建产物
    • hello.c:C 源文件(程序文本)
    • hello.i:预处理输出(展开头文件/宏后的纯 C 文本)
    • hello.s:编译输出汇编文件(x86-64 汇编,包含标签、指令与伪指令)
    • hello.o:可重定位目标文件(ELF64 REL,含 .text/.rodata/.symtab/.rela.text 等)
    • hello:最终可执行文件(ELF64 EXEC/动态链接,含 PT_LOAD、.dynamic、PLT/GOT 等)
  • 链接与格式分析产物(你已生成/上传过的典型文件)
    • link_cmd.txt:gcc 驱动打印出的实际链接命令(包含 crt*.o、-lc、动态链接器等)
    • readelf_h_hello.txtreadelf -h hello(ELF Header)
    • readelf_l_hello.txtreadelf -l hello(Program Headers/段信息)
    • readelf_S_hello.txtreadelf -S hello(Section Headers/节信息)
    • readelf_d_hello.txtreadelf -d hello(Dynamic Section/动态段)
    • dr_o.txtobjdump -d -r hello.o(可重定位文件反汇编+重定位项)
    • dr_hello.txtobjdump -d -r hello(可执行文件反汇编+重定位对照)
  • 进程与系统行为跟踪产物
    • trace.txtstrace 跟踪 bash 执行 hello 的 fork/execve 等系统调用证据

1.4 本章小结

  • Hello 的“自白”可以系统化为:源文件(Program)经预处理/编译/汇编/链接形成可执行文件,再由 shell 触发 fork+execve 进入运行态(Process)
  • Hello 的生命周期体现了 020:从磁盘上的静态文件开始,经历装载、动态链接、执行、系统调用与调度,最终以 exit code=0 结束并被内核回收。

第2章 预处理

2.1 预处理的概念与作用

概念:

预处理是编译流程的第一阶段。处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。

输入:.c 源文件 输出: 预处理后的 .i 文件(仍然是C源码文本,但已“展开”)

作用:

  • 头文件展开:处理 #include,把头文件内容插入到当前文件中。
  • 宏处理:处理 #define 宏定义,进行宏替换与展开。
  • 去注释/保留行号信息:注释通常会被移除;同时会生成 #line/# 行标记用于后续报错定位。

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i
cpp hello.c > hello.i

图1 运行预处理命令
图1 运行预处理命令

2.3 Hello的预处理结果解析

请添加图片描述
图2 hello.i 与 hello.c 对比

  1. 头文件展开(#include 被替换为大量内容)
  2. 行号标记 如# 1 “hello.c” # 1 “/usr/include/…” 这些标记用于让后续编译器报错时还能定位回原始文件与行号
  3. 注释不再保留 在 c 顶部的 // … 注释在 hello.i 里不再保留。注释对编译语义无影响,预处理阶段会移除
  4. 原函数、代码基本没有变化

2.4 本章小结

  • 预处理阶段完成头文件展开、宏展开、条件编译处理,并输出 .i 文件。
  • .i 文件体积显著增大,包含大量系统头文件内容,同时保留行号标记以支持后续报错定位。
  • 预处理仅做文本级变换,不会生成汇编/机器码,也不改变程序的运行逻辑;这些工作在后续编译、汇编、链接阶段完成。

第3章 编译

3.1 编译的概念与作用

概念:

编译器(cc1)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。

作用:

  • 词法/语法/语义分析:检查语法结构、类型一致性等;
  • 中间表示生成与优化:例如常量折叠、死代码消除(在 -Og 下较保守);
  • 指令选择与寄存器分配:把 C 的表达式/控制流翻译为目标架构(如x86-64)的指令序列;
  • 栈帧布局:为局部变量、调用保存寄存器等分配栈空间;
  • 生成汇编文本:包含 .text/.rodata、标签(labels)、指令和伪指令。

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s //gcc -S
$(gcc -print-prog-name=cc1) -S hello.i -o hello.s //cc1
gcc -m64 -Og -fno-stack-protector -fno-PIC -S hello.i -o hello.s //带参数
  • S:只编译到汇编,生成 .s
  • m64:生成 x86-64 代码
  • Og:便于调试的优化级别,结构更接近源代码
  • fno-stack-protector/-fno-PIC:减少额外保护/位置无关带来的干扰

在这里插入图片描述图3 运行编译

3.3 Hello的编译结果解析

在这里插入图片描述图4 hello.s的内容

3.3.1 数据:常量、变量(局部/参数)、类型、字符串常量

(1)字符串常量(只读数据段 .rodata)

编译器将 C 中的字符串字面量放入只读段,并用标签引用:

  • .LC0:用法提示字符串
  • .LC1:“Hello %s %s %s\n” 格式串

在代码中通过 movl $.LC0, %edi / movl $.LC1, %edi 将其地址作为参数传递。

(2)整型立即数常量

在汇编中以 $常量 直接出现,例如:

cmpl $5, %edi:与 argc 比较(常量 5)
movl $0, %ebp:循环变量 i=0
movl $1, %edi:exit(1)
cmpl $9, %ebp:循环边界比较(9)
movl $0, %eax:返回值 0

(3)变量与类型

  • 参数 argc(int):进入 main 时在 %edi 中
  • *参数 argv(char ):进入 main 时在 %rsi 中,8017 movq %rsi, %rbx 保存到 %rbx。
  • 局部变量 i(int):用寄存器 %ebp 存储(movl $0,%ebp,addl $1,%ebp)。

3.3.2 赋值与初始化

(1)对指针参数的赋值保存

movq %rsi, %rbx:将 argv 保存到 callee-saved 寄存器 %rbx,用于后续多次访问 argv[k]。

(2)循环变量赋初值

movl $0, %ebp:对应 i = 0。

3.3.3 数组/指针操作

本程序出现的关键“数组/指针”操作是 argv[k]。argv 是 char **,即“指向指针的指针”。一个指针 8 字节,因此 argv[k] 等价于:

地址计算:(argv + k) → 偏移 8k 字节取值

汇编体现(以 %rbx 保存的 argv 为基址):

movq 8(%rbx), %rsi :取 argv[1]
movq 16(%rbx), %rdx :取 argv[2]
movq 24(%rbx), %rcx :取 argv[3]
movq 32(%rbx), %rdi :取 argv[4](传给 atoi)

这些指令说明:编译器把 argv[k] 翻译为“基址 + 常量偏移”的访存形式(base+disp addressing)。

3.3.4 关系操作与控制转移

(1)关系操作 !=:对应 if(argc != 5)

cmpl $5, %edi:比较 argc 与 5
jne .L6:若不相等则跳转到 .L6
.L6 分支执行 puts 打印用法,再 exit(1) 终止进程。

(2)for 循环:比较 + 回跳形成循环

循环结构在汇编中由标签和跳转构成:

初始化:movl $0, %ebp
条件检查块 .L2:cmpl $9, %ebp + jle .L3
循环体块 .L3:printf → atoi → sleep → addl $1, %ebp
回到 .L2 再比较

该逻辑等价于执行 10 次(i 从 0 到 9)。这就是 C 语言 for(i=0;i<10;i++) 在汇编层的体现。

3.3.5 算术操作(++ 等)

自增:addl $1, %ebp:对应 i++(对 32 位整数加 1)

3.3.6 类型转换(隐式/显式):

C 源码:

sleep(atoi(argv[4]));

汇编:

movq 32(%rbx), %rdi:argv[4](char*)作为 atoi 的入参(指针)
call atoi:atoi 返回值在 %eax(32 位 int)
movl %eax, %edi:把返回的 int 放到 %edi,作为 sleep 的第一个参数
call sleep

指针类型(char*)用 64 位寄存器传递;atoi 的返回是 32 位整型,也就是说这里通过函数atoi显式转换了。

sleep 在 C 库里原型为 unsigned int sleep(unsigned int seconds),因此这里int到unsigned int属于隐式类型转换

3.3.7 函数操作(参数传递、函数调用、返回):

(1)参数传递(值/地址)与寄存器分配

前6个整型/指针参数用寄存器传递。

puts:movl $.LC0, %edi(把字符串地址作为第 1 参数)→ call puts
exit:movl $1, %edi(把整数 1 作为第 1 参数)→ call exit
//printf(fmt, argv1, argv2, argv3):
movq 8(%rbx), %rsi(第 2 参数)
movq 16(%rbx), %rdx(第 3 参数)
movq 24(%rbx), %rcx(第 4 参数)
movl $.LC1, %edi(第 1 参数:格式串)
call printf
getchar:无参调用 → call getchar

(2)函数调用与返回值

  • call X:压入返回地址并跳转到 X;
  • 返回值通常在 %rax/%eax(例如 atoi 返回 int 在 %eax);
  • main 返回:movl $0, %eax + ret 表示返回 0。

(3)局部保存与栈帧

  • 建立:pushq %rbp; pushq %rbx; subq $8,%rsp
  • %rbx 属于 callee-saved,被用来长期保存 argv 基址,所以函数进入时先保存,退出时恢复。
  • 返回:addq $8,%rsp; popq %rbx; popq %rbp; ret

3.4 本章小结

  1. 编译器将 C 语言中的字符串常量放入只读数据段(如 .rodata),并通过标签(如 .LC0/.LC1)在代码中引用。
  2. 变量与类型在汇编层面的主要体现为:
    • argc 以 32 位整型寄存器参与比较;argv 以 64 位指针寄存器保存并用于索引访问;
    • 局部变量 i 被分配到寄存器(而非栈),以减少访存开销。
  3. C 语言控制结构被翻译为“比较指令 + 条件跳转 + 标签”的控制流:if(argc!=5) 对应 cmp/jne 分支,for 循环对应条件检查、循环体与回跳结构。
  4. argv[k] 的数组/指针访问被编译为“基址 + 常量偏移”的地址计算方式(8 字节步长),体现了指针数组在汇编层的实现。
  5. 函数调用:整型/指针参数前6个用寄存器传递,返回值通过 %eax/%rax 传递

第4章 汇编

4.1 汇编的概念与作用

概念:

汇编器 (as) 将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件 hello.o 中。 hello.o 文件是一个二 进制文件。输出的 hello.o 不是最终可执行文件:它没有入口点(entry point),也不能直接运行,必须经过链接生成可执行文件。

作用:

  • 指令编码:把每条汇编指令翻译为对应的机器码字节序列(opcode + 操作数字段等);
  • 符号与节管理:生成 .text/.rodata/.data/.bss 等节(section),建立符号表(symbol table);
  • 生成重定位信息(relocation):对无法在本文件内确定最终地址的引用(如外部函数、只读数据地址等)生成重定位项,留待链接阶段修正。

4.2 在Ubuntu下汇编的命令

gcc -c -m64 hello.s -o hello.o
as --64 hello.s -o hello.o

在这里插入图片描述

图5 运行汇编

4.3 可重定位目标elf格式

4.3.1 ELF 头

在这里插入图片描述

图6 readelf 查看 hello.o ELF头的内容

  • Class:ELF64
    • 表明这是 64 位 ELF 文件,对应 x86-64 体系结构的目标文件。
  • Data:2’s complement, little endian
    • 表明字节序为小端序,数值采用二进制补码表示。
  • OS/ABI:UNIX - System V,ABI Version:0
    • 表明符合 System V ABI,用于规定调用约定、对象文件格式等。
  • Type:REL (Relocatable file)
    • 表明 hello.o 是可重定位目标文件
    • 含义:该文件包含代码与数据节以及符号/重定位信息,不能直接执行,必须经链接器把多个 .o 与库组合成可执行文件或共享库。
  • Machine:Advanced Micro Devices X86-64
    • 表明目标架构是 x86-64(AMD64)
  • Entry point address:0x0
    • 可重定位文件没有“程序入口点”,因此入口为 0。
    • 入口点只有在 可执行文件(EXEC)或共享对象(DYN) 中才有实际意义。
  • Start of program headers:0,Number/Size of program headers:0
    • 没有 Program Header Table(程序头表)
    • 原因:程序头表用于描述“如何装载到内存的段”,而 .o 只是给链接器使用的 section 级别对象文件,不需要描述装载映像。
  • Start of section headers:1104,Number of section headers:15,Section header string table index:14
    • 说明该文件包含 15 个节表项,节表从文件偏移 1104 字节处开始;
    • Section header string table index: 14 表示第 14 号节保存节名字符串表(.shstrtab),用于给节表项命名。

4.3.2 节表

在这里插入图片描述

图7 readelf 查看 hello.o 节表

  • 一共有 15 个节
  • 由于它是 可重定位文件(REL),节表中各节的 Address 基本为 0:最终的装载地址要到链接生成可执行文件后才确定,因此这里不体现运行时虚拟地址。

.text

[1] .text Type=PROGBITS Flags=AX Offset=0x40 Size=0x75
  • .text 保存 机器指令(也就是 main 对应的指令序列)。
  • Flags=AX
  • Size=0x75 表示该目标文件中 .text 的指令总字节数为 0x75(117)字节。

只读字符串常量

[5] .rodata.str1.8 Flags=AMS Align=8 Size=0x30
[6] .rodata.str1.1 Flags=AMS Align=1 Size=0x10
  • .rodata.str1.* 是 GCC 常见的字符串合并/分组节(用于把字符串常量集中放置)。
  • Flags=AMS
  • .8 与 .1 主要反映 对齐策略:.rodata.str1.8 对齐 8 字节更适合某些常量布局;.rodata.str1.1 只要求 1 字节对齐。

重定位表 .rela.text与 .rela.eh_frame

[2] .rela.text Type=RELA Offset=0x2e8 Size=0xc0 EntSize=0x18 Link=12 Info=1
  • .rela.text 是 针对 .text 节的重定位表:记录 .text 中哪些位置需要链接器后续“修补地址/位移”。
  • EntSize=0x18(24 字节/项),Size=0xc0(192 字节),因此 .rela.text 中共有:
    • 0xc0 / 0x18 = 8 条重定位项
  • Link=12 指向 .symtab:重定位要通过符号表找到“引用的是哪个符号”;
  • Info=1 表示这些重定位项作用于 第 1 号节 .text

同理:

[11] .rela.eh_frame Type=RELA Size=0x18 EntSize=0x18 Info=10
  • 说明 .eh_frame 也有 1 条重定位项(0x18/0x18=1),用于异常回溯/栈展开信息的地址修补。

符号与字符串表:.symtab/.strtab/.shstrtab

[12] .symtab Type=SYMTAB Offset=0x188 Size=0x120 EntSize=0x18 Link=13 Info=5
[13] .strtab:符号名字符串表(给 .symtab 的符号提供名字)
[14] .shstrtab:节名字符串表(给节表项提供名字)
  • .symtab 是静态符号表,包含 main、以及对外部库函数(printf/puts/…)的未定义引用等。
  • .symtab 中符号数量可由 Size/EntSize 得到:
    • 0x120 / 0x18 = 12 个符号项
  • Link=13 表示符号名来自 .strta
  • Info=5 的含义:符号表中 前 0~4 号为局部符号(local),从索引 5 开始通常是全局/外部符号。

.data/.bss

  • .data/.bss 的 Size 均为 0,说明该程序在该编译配置下没有产生全局可写数据或未初始化全局数据。

4.3.3 符号表

在这里插入图片描述

图8 readelf 查看hello.o 符号表

hello.o 的符号表 .symtab 共包含 12 个符号项

局部符号(LOCAL):文件、节与字符串标签

  • #1 hello.c(FILE, LOCAL, ABS)
    • 记录源文件名,便于调试/定位,不参与运行时链接。
  • #2 .text(SECTION, LOCAL, Ndx=1)
    • 代表代码节本身,供重定位/调试引用。
  • #3 .LC0、#4 .LC1(NOTYPE, LOCAL, Ndx=5/6)
    • .LC0、.LC1 是编译器为字符串常量生成的局部标签。
    • 它们分别位于只读字符串节中.
    • 这些字符串常量在 .o 内部“已定义”,因此是 LOCAL 符号,链接器会依据对它们的引用生成/处理相应重定位。

全局已定义符号(GLOBAL + DEFINED):main

  • #5 main(FUNC, GLOBAL, Ndx=1, Size=117)
    • main 是本目标文件中定义的函数符号,位于 .text(Ndx=1)。
    • Size=117 与节表中 .text 大小 0x75(即 117 字节)一致,说明本目标文件的代码主要由 main 构成。
    • 链接时,main 会作为可执行文件中的一个全局符号参与符号解析,并由启动代码(如 _start / __libc_start_main)间接调用。

(4)全局未定义符号(GLOBAL + UND):外部库函数引用

以下符号均为 GLOBAL DEFAULT UND

  • puts(#6)
  • exit(#7)
  • printf(#8)
  • atoi(#9)
  • sleep(#10)
  • getchar(#11)
  • 这些符号在 hello.o 中 未定义(UND),表示本文件仅“引用”它们,而不提供实现。
  • 它们的真实定义通常来自 C 标准库/系统库(例如 glibc)。
  • 因为目标地址在汇编阶段无法确定,链接器在链接时将:
    1. 在库中找到这些符号的定义;
    2. 结合 .rela.text 中的重定位项,修补 call puts/printf/… 等指令的目标地址(或 PLT/GOT 相关跳转信息)。

4.3.4 重定位表

在这里插入图片描述

图9 readelf 查看 hello.o 重定位表

本目标文件包含两张重定位表:

  • .rela.text:作用于代码节 .text,共 8 条重定位项。
  • .rela.eh_frame:作用于 .eh_frame(栈展开/异常回溯信息),共 1 条重定位项(可简要说明)。

4.3.4.1 .rela.text:对.text指令中的“地址/调用目标”进行修补

输出显示:.rela.text 含 8 entries,每条重定位项包含:

  • Offset:需要修补的位置(相对 .text 起始的偏移,单位字节)。
  • Type:重定位类型(决定“怎么修补”)。
  • Sym. Name + Addend:引用的符号名与附加数(addend)。

这些条目可分为两类:

  1. 字符串常量地址引用(PC 相对地址):R_X86_64_PC32
00000000001c  R_X86_64_PC32  .LC0 - 4
00000000003e  R_X86_64_PC32  .LC1 - 4
  • .LC0、.LC1 是本文件内字符串常量的符号。
  • 由于最终链接时 .rodata 与 .text 在可执行文件中的相对布局可能变化,编译/汇编阶段不能确定最终地址,于是使用 PC-relative 32-bit 形式:链接器会把该位置的 4 字节立即数修补为S+A-P

其中:S 为符号地址(.LC0/.LC1),A 为 addend,P 为需要修补位置的地址。

为什么 addend 是 -4:

  • 对于 x86-64 的 PC-relative 编码,位移通常相对于下一条指令地址计算,而重定位位置 P 指向位移字段的起始;两者差一个 4 字节位移字段长度,因此常见出现 -4 的修正,使计算基准对齐到“下一条指令”。
  • 结果:链接器能在最终布局确定后,让指令正确指向字符串常量。
  1. 外部函数调用(经 PLT 的 PC 相对调用):R_X86_64_PLT32
000000000021  R_X86_64_PLT32  puts    - 4
00000000002b  R_X86_64_PLT32  exit    - 4
000000000048  R_X86_64_PLT32  printf  - 4
000000000051  R_X86_64_PLT32  atoi    - 4
000000000058  R_X86_64_PLT32  sleep   - 4
000000000065  R_X86_64_PLT32  getchar - 4
  • puts/exit/printf/atoi/sleep/getchar 在符号表中均为 UND,说明它们在 hello.o 内无定义,需要链接时从 libc 等库解析。
  • call puts 这类指令在机器码层面是相对位移调用),因此同样需要 PC-relative 32-bit 的位移。
  • 这里重定位类型为 R_X86_64_PLT32,含义是:链接器会把该调用目标修补为指向 PLT(Procedure Linkage Table)入口的相对位移(动态链接时通过 PLT/GOT 实现延迟绑定)。
  • 同样出现 -4 的 addend,原因与上面一致:相对位移按“下一条指令地址”计算,需要补偿位移字段长度。

4.3.4.2 .rela.eh_frame:用于栈展开信息的重定位

.rela.eh_frame contains 1 entry:
000000000020  R_X86_64_PC32  .text + 0
  • .eh_frame 保存与函数栈展开/异常回溯相关的信息(用于调试、异常处理、调用栈回溯)。
  • 该重定位项表示 .eh_frame 中有一处需要引用 .text 的位置(通常用于描述某段代码的范围/关联),同样采用 R_X86_64_PC32 形式,在链接时由链接器修补为最终的相对地址。

4.4 Hello.o的结果解析

在这里插入图片描述

图10 objdump查看 hello.o 反汇编

4.4.1 机器语言的构成与反汇编输出格式

objdump -d -r hello.o 的每一行通常包含三部分:

  • 地址/偏移:如 19: 表示该指令位于 .text 节内偏移 0x19(对 .o 来说是节内偏移,不是运行时虚拟地址);
  • 机器码字节序列:如 48 83 ec 08
  • 反汇编助记符与操作数:如 sub $0x8,%rsp

subq $8,%rsp 为例:

subq $8, %rsp //.s
48 83 ec 08 sub $0x8,%rsp //.o 反汇编

4.4.2 hello.o与hello.s的对比

(1)函数序言与栈/寄存器保存:一一对应

pushq %rbp; pushq %rbx; subq $8,%rsp //.s
55(push %rbp)、53(push %rbx)、48 83 ec 08(sub $0x8,%rsp)//.o

说明:这类纯本地操作不依赖外部符号地址,因此在 .o 中操作数已经完全确定,机器码与汇编指令可直接一一对应。

(2)if 分支(argc != 5):局部跳转的位移已确定(无需重定位)

//.s
cmpl $5, %edi
jne .L6
//.o
83 ff 05  cmp $0x5,%edi
75 0a     jne 19 <main+0x19>
  • jne 的目标是同一函数内的局部标签(同一 .text 节内),汇编器在生成 .o 时就能计算出相对位移(这里 0x0a),因此不会产生重定位项。
  • 这体现了“分支转移(局部)与函数调用(外部)”在 .o 中的关键差异:局部标签可立即解析,外部符号需链接时解析。

(3)字符串地址引用

hello.s 错误分支中:

leaq .LC0(%rip), %rdi
call puts@PLT

hello.o 中对应为:

48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi

并伴随重定位注释:R_X86_64_PC32 .LC0-0x4(偏移 0x1c)

e8 00 00 00 00 call ...

并伴随:R_X86_64_PLT32 puts-0x4(偏移 0x21)

  • 在 .o 阶段,.LC0 的最终地址未知(链接后 .rodata 会被重新布局),因此 lea 指令中的位移字段先置零(00 00 00 00),并在 .rela.text 里记录“这里要改成指向 .LC0 的 PC 相对位移”。
  • call puts 同理:call 的机器码是 E8 rel32,其中 rel32 必须等链接器决定 puts 的最终解析结果(通常经 PLT)后才能填入,所以这里也先置 0,并记录 R_X86_64_PLT32 puts-0x4 重定位项。

同样的机制出现在正常路径的格式串 .LC1 与 printf 调用处:

leaq .LC1(%rip), %rdi → .o 里 lea 0x0(%rip),%rdi + R_X86_64_PC32 .LC1-0x4
call printf@PLT → .o 里 e8 00 00 00 00 + R_X86_64_PLT32 printf-0x4

在 .o 中外部可变地址只能先占位,再由链接器重定位修补。

(4)循环体中 argv[k] 的访存:两者完全一致

//hello.s
movq 24(%rbx), %rcx
movq 16(%rbx), %rdx
movq 8(%rbx), %rsi
movq 32(%rbx), %rdi
//hello.o
48 8b 4b 18 mov 0x18(%rbx),%rcx
48 8b 53 10 mov 0x10(%rbx),%rdx
48 8b 73 08 mov 0x8(%rbx),%rsi
48 8b 7b 20 mov 0x20(%rbx),%rdi
  • argv[k] 是对寄存器 %rbx(保存的 argv 基址)做常量偏移寻址,偏移量分别是 8*k,这在汇编阶段完全确定,所以 .o 中不会产生重定位。
  • 机器码里可看到 0x18/0x10/0x08/0x20 等位移字节,直接体现了数组索引的“指针步长为 8 字节”。

(5)函数调用与返回值

//hello.s
call atoi@PLT
movl %eax, %edi
call sleep@PLT
//hello.o
e8 00 00 00 00 + R_X86_64_PLT32 atoi-0x4
89 c7 mov %eax,%edi(返回值寄存器到参数寄存器的搬运)
e8 00 00 00 00 + R_X86_64_PLT32 sleep-0x4
  • “返回值寄存器 %eax”到“参数寄存器 %edi”的传递在 .o 中完全确定,机器码可直接固定;
  • 但 call 目标地址仍取决于链接阶段解析到的函数入口(通常通过 PLT),因此 call 的位移字段先置 0 并记录重定位。

(6)循环条件与回跳:局部跳转位移已编码

cmpl $9,%ebp + jle .L3 //.s
83 fd 09 cmp $0x9,%ebp + 7e cb jle 2f <main+0x2f> //.o
  • .L3 是函数内局部标签,汇编器能计算相对位移(这里 0xcb,即一个向后跳转的有符号位移),因此该跳转不需要重定位。

(7)结尾 getchar 与 return:call 需重定位,ret 固定

call getchar@PLT,movl $0,%eax,ret //.
e8 00 00 00 00 + R_X86_64_PLT32 getchar-0x4,随后 b8 00 00 00 00 mov $0,%eax,最后 c3 ret //.o

4.5 本章小结

  • 将 hello.s 汇编为可重定位目标文件 hello.o,目标文件采用 ELF REL格式,包含代码节 .text、只读数据节 .rodata*、符号表 .symtab 及重定位表 .rela.text 等。
  • hello.o 中 main 在本文件内定义,而 puts/printf/atoi/sleep/getchar/exit 等库函数符号通常为 UND,需在链接阶段解析。
  • 每条汇编指令都被编码为对应的机器码字节序列;对于外部符号引用与只读数据地址引用,汇编阶段采用“占位值 + 重定位项”的方式记录,待链接器统一修补。
  • 局部跳转(同一 .text 内标签)已被汇编器解析为相对位移,而函数调用/全局地址引用则通过 .rela.text 重定位记录,为链接做准备。

第5章 链接

5.1 链接的概念与作用

概念:

将可重定位目标文件 hello.o 链接生成可执行目标文件 hello

作用:

  • 符号解析(symbol resolution):把 hello.o 中的未定义符号(如 printf/puts/…)与库/运行时文件中的定义匹配起来;
  • 重定位(relocation):依据 .rela.text 等重定位项,修补 .text 中的地址/位移(例如把 call 的位移、lea .LCx(%rip) 的位移填成正确值);
  • 节合并与段生成(section→segment):将 .text/.rodata/.data/.bss 等节合并并组织为可装载的程序段(Program Headers, PT_LOAD);
  • 生成动态链接信息(若为动态链接):建立 PLT/GOT、.dynamic、解释器(PT_INTERP)等,使程序运行时可由动态链接器加载共享库并解析符号。

5.2 在Ubuntu下链接的命令

/usr/bin/ld --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed \
  -dynamic-linker /lib64/ld-linux-x86-64.so.2 -z relro \
  -o hello \
  /usr/lib/x86_64-linux-gnu/crt1.o \
  /usr/lib/x86_64-linux-gnu/crti.o \
  /usr/lib/gcc/x86_64-linux-gnu/13/crtbegin.o \
  -L/usr/lib/gcc/x86_64-linux-gnu/13 \
  -L/usr/lib/x86_64-linux-gnu \
  -L/lib/x86_64-linux-gnu \
  -L/usr/lib \
  hello.o \
  -lgcc --push-state --as-needed -lgcc_s --pop-state \
  -lc \
  -lgcc --push-state --as-needed -lgcc_s --pop-state \
  /usr/lib/gcc/x86_64-linux-gnu/13/crtend.o \
  /usr/lib/x86_64-linux-gnu/crtn.o

在这里插入图片描述

图11 运行链接

5.3 可执行目标文件hello的格式

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

readelf -h hello | tee readelf_h_hello.txt
readelf -l hello | tee readelf_l_hello.txt
readelf -S hello | tee readelf_S_hello.txt
readelf -d hello | tee readelf_d_hello.txt

5.3.1 ELF Header(readelf -h hello

在这里插入图片描述

图12 查看 hello 的ELF头

  • Class: ELF64Machine: Advanced Micro Devices X86-64:说明 hello64 位 x86-64 可执行文件。
  • Type: EXEC (Executable file):说明这是 可执行目标文件
  • Entry point address: 0x4010f0:程序入口地址为 0x4010f0,运行时从该地址开始执行。
  • Number of program headers: 12Number of section headers: 30:该可执行文件包含 12 个程序头(段描述)与 30 个节(用于链接/调试/组织)。

5.3.2 Program Headers(段信息,readelf -l hello

在这里插入图片描述

图13 查看 hello 的段信息

hello4 个 PT_LOAD 段,对应运行时将文件内容映射到进程虚拟地址空间的主要区域:

  1. LOAD(只读,R)
  • VirtAddr 0x400000FileSiz/MemSiz 0x5f8Flags RAlign 0x1000
  • 作用:存放只读的 ELF 元信息与动态链接相关表(如 .interp/.dynsym/.dynstr/.rela.* 等),用于装载与动态链接。
  1. LOAD(代码段,R E)
  • VirtAddr 0x401000FileSiz/MemSiz 0x259Flags R EAlign 0x1000
  • 作用:可执行代码区域,包含 .init/.plt/.text/.fini。其中 .text 的起始地址为 0x4010f0(与入口地址一致)。
  1. LOAD(只读数据段,R)
  • VirtAddr 0x402000FileSiz/MemSiz 0x0f4Flags RAlign 0x1000
  • 作用:只读数据,如 .rodata(字符串常量等)与 .eh_frame(栈展开信息)。
  1. LOAD(读写数据段,RW)
  • VirtAddr 0x403dd8FileSiz 0x268MemSiz 0x270Flags RWAlign 0x1000
  • 作用:读写数据与运行时结构:.init_array/.fini_array/.dynamic/.got/.got.plt/.data/.bss
  • 这里 MemSiz 比 FileSiz 多 0x8,对应 .bss 这类“文件中不占空间但运行时需要置零的区域”。

5.3.3 Section Headers(节信息,readelf -S hello

在这里插入图片描述

图14 查看 hello 的节表

节表给出了每个节的虚拟地址与大小,便于把“段(segment)”内部再细分到具体“节(section)”:

  • 代码相关(位于 R E 段 0x401000)
    • .text0x4010f0,大小 0x15b(程序主体代码)。
    • .plt/.plt.sec0x401020/0x401090(PLT,用于动态链接函数跳转桩)。
  • 只读数据(位于 R 段 0x402000)
    • .rodata0x402000,大小 0x48(字符串常量等)。
    • .eh_frame0x402048,大小 0x0ac(栈展开/回溯信息)。
  • 读写数据(位于 RW 段 0x403dd8)
    • .got0x403fd8.got.plt0x403fe8(GOT/PLT 相关表项)。
    • .data0x404030,大小 0x10.bss0x404040,大小 0x8
  • 动态链接重定位相关节(通常位于只读 LOAD 段 0x400000)
    • .rela.dyn0x400538.rela.plt0x400568(运行时仍需处理的动态重定位信息)。

同时,readelf -lSection to Segment mapping 明确给出了节到段的映射关系,例如 .text 映射到 R E 段、.rodata 映射到只读段、.got/.data/.bss 映射到 RW 段等。

5.3.4 Dynamic Section(动态段,readelf -d hello

在这里插入图片描述

图15 查看 hello 的动态段

动态段表明该程序为动态链接,并给出依赖库与重定位/PLT/GOT 的关键位置:

  • 依赖共享库(NEEDED):
    • libc.so.6libgcc_s.so.1(链接时引入,运行时由动态链接器装载)。
  • 动态链接关键地址:
    • PLTGOT = 0x403fe8(对应 .got.plt 起始)。
    • JMPREL = 0x400568PLTRELSZ = 144:PLT 相关重定位表位置与大小(对应 .rela.plt)。
    • RELA = 0x400538RELASZ = 48:一般动态重定位表位置与大小(对应 .rela.dyn)。
  • 初始化/终止相关:
    • INIT = 0x401000FINI = 0x40124c,以及 .init_array/.fini_array 的地址与大小(说明运行时在进入 main 前后会执行初始化/清理逻辑)。

5.4 hello的虚拟地址空间

在这里插入图片描述

图16 gdb查看虚拟地址空间

5.4.1 使用 gdb 查看进程虚拟地址空间

使用 gdb 启动并从入口指令开始执行:

  • set args * * * * 设置运行参数
  • starti 从程序入口开始单步执行

gdb 输出显示当前停在:

  • 0x00007ffff7fe4540 in _start () from /lib64/ld-linux-x86-64.so.2

说明:程序刚被加载后,CPU 首先进入的是 动态链接器 ld-linux_start,由动态链接器完成装载与动态链接初始化后,才会跳转到可执行文件 hello 的入口(这与 5.3 中存在 INTERP 段一致)。

随后用 info proc mappings 得到本进程的映射信息:

0x400000–0x401000 r--p /.../hello
0x401000–0x402000 r-xp /.../hello
0x402000–0x403000 r--p /.../hello
0x403000–0x405000 rw-p /.../hello
  • 以及 [vvar][vdso]ld-linux-x86-64.so.2[stack] 等映射段

5.4.2 与Program Headers(PT_LOAD)对照分析

(1)只读段(R):0x400000–0x401000

  • 映射: 0x400000–0x401000 r--p hello
  • 含义: 对应 5.3 中 第一个 LOAD(Flags=R) 的装载区域。
  • 内容: 通常包含 ELF/动态链接相关元数据(例如 .interp/.dynamic/.dynsym/.dynstr/.rela.* 的一部分),只读映射便于安全与共享。

(2)代码段(R E):0x401000–0x402000

  • 映射: 0x401000–0x402000 r-xp hello
  • 含义: 对应 5.3 中 代码 LOAD(Flags=R E)
  • 内容: .init/.plt/.text/.fini 等可执行代码。5.3 中 hello 的入口地址位于 0x4010f0,就在该段内,因此该段是程序真正执行用户代码的区域。

(3)只读数据段(R):0x402000–0x403000

  • 映射: 0x402000–0x403000 r--p hello
  • 含义: 对应 5.3 中 只读数据 LOAD(Flags=R)
  • 内容: .rodata(字符串常量等)、.eh_frame(栈展开信息)等只读数据。该段只读有助于防止运行时篡改常量。

(4)可读写数据段(RW):0x403000–0x405000

  • 映射: 0x403000–0x405000 rw-p hello
  • 含义: 对应 5.3 中 读写数据 LOAD(Flags=RW)
  • 内容: .data/.bss.got/.got.plt.dynamic.init_array/.fini_array 等。
  • 说明: .bss 在文件中不占实际空间但运行时需要置零,因此该段的内存大小通常会大于文件大小;这也是 5.3 中“MemSiz > FileSiz”的运行时表现之一。

5.5 链接的重定位过程分析

运行以下指令:

objdump -d -r hello.o > dr_o.txt
objdump -d -r hello   > dr_hello.txt

在这里插入图片描述

图17 objdump分别反汇编hello和hello.o

  • hello.o 中,凡是涉及 .LC0/.LC1 地址或外部函数调用的位置,均以“0 占位 + 重定位项”形式存在(R_X86_64_PC32R_X86_64_PLT32)。dr_o
  • 链接生成 hello 后,链接器根据最终布局完成重定位:
    • lea 的 PC 相对位移修补为指向 .rodata 的实际地址;
    • call 的 PC 相对位移修补为指向各 xxx@plt 的入口,从而为动态链接解析留出机制。

5.6 hello的执行流程

5.6.1 从加载到 hello 的 _start

在这里插入图片描述

图18 gdb 查看 _start 的地址

  • 使用 gdbstarti 从第一条指令开始执行,程序最初停在动态链接器 ld-linux-x86-64.so.2_start(表明动态链接器先运行,负责装载共享库与完成动态链接初始化)。
  • info files 显示 hello 的入口地址(Entry point)为 0x4010f0。对该地址下断点并继续运行后,命中 hello_start。在 _start 附近可观察到启动代码完成寄存器/栈对齐与参数准备等操作。

5.6.2 _start__libc_start_mainmain

在这里插入图片描述

图19 gdb 查看 main的地址

  • hello:_start(0x4010f0) 继续执行进入 glibc 启动例程 __libc_start_main_impl。在该断点处可直接看到参数中 main=0x4011d6 <main>,说明 glibc 启动框架将调用用户定义的 main
  • 随后命中 main(地址 0x4011d6),执行用户程序逻辑:打印 10 次并调用 getchar() 等待输入;输入字符后 main 返回 0

5.6.3 main 返回后的退出流程:exit → 退出处理 → _exit

在这里插入图片描述

图20 gdb 查看 exit 与 _exit 的地址

main 返回后,程序进入 exit(status) 并执行退出清理,最终调用 _exit 结束进程。gdb 断点与回溯结果如下:

  1. 进入 exit
  • 命中 __GI_exit(status=0),其地址为 0x7ffff7c47ba0
  • 调用链(回溯)显示:
    • _start (hello)__libc_start_main_impl__libc_start_call_main__GI_exit
  1. 执行退出处理并进入 _exit
  • 继续执行命中 __GI__exit(status=0),即 _exit,地址为 0x7ffff7cee200
  • 回溯显示退出路径为:
    • __GI__exit(最终终止点)
    • __run_exit_handlers(执行 atexit 回调、析构函数、flush 等退出处理)
    • __GI_exit
    • __libc_start_call_main
    • __libc_start_main_impl
    • hello:_start(0x401115 为 _start 内部位置)

hello 的执行主线为:动态链接器初始化 → hello 入口 _start → glibc 启动框架 __libc_start_main_impl 调用 mainmain 返回 → exit 执行退出处理 → _exit 系统调用级终止进程。

主要地址:

  • ld-linux:_start0x7ffff7fe4540(动态链接器入口,加载与重定位)
  • hello:_start(Entry point):0x4010f0
  • main0x4011d6
  • __GI_exit0x7ffff7c47ba0
  • __run_exit_handlers0x7ffff7c47a36(exit 内部退出处理)
  • __GI__exit(_exit):0x7ffff7cee200(最终终止)

5.7 Hello的动态链接分析

5.7.1 动态链接项目

在这里插入图片描述

图21 readelf 列出动态链接

hello 为动态链接可执行文件,其动态段与重定位信息表明运行时需要动态链接器参与符号解析与重定位:

  • 依赖共享库(NEEDED)libc.so.6libgcc_s.so.1
  • PLT/GOT 相关地址PLTGOT = 0x403fe8,说明程序存在 .got.plt 并用于外部函数调用的跳转/绑定。
  • PLT 重定位表JMPREL = 0x400568PLTRELSZ = 144.rela.plt 的位置与大小)。
  • 动态重定位表RELA = 0x400538RELASZ = 48.rela.dyn 的位置与大小)。

readelf -robjdump -R 可见多个 R_X86_64_JUMP_SLOT 条目(puts/printf/getchar/atoi/exit/sleep),其重定位地址位于 .got.plt 区域,例如:

  • printf@GLIBC_2.2.5JUMP_SLOT 重定位地址为 0x404008,对应 printf@got.plt 槽位;

    这表明这些外部符号的最终函数地址将在运行时由动态链接器写入对应的 GOT 表项,从而完成动态绑定。

5.7.2 动态链接前后变化分析(以 printf 为例)

在这里插入图片描述

图22 gdb 查看 printf 的GOT槽地址(调用前)

选择 printf 作为观察对象,其 GOT 槽地址由重定位表确定为:

  • printf@got.plt0x404008

在 gdb 中对比“第一次调用 printf 前/后”该地址处的内容:

  1. 第一次调用 printf 之前(断在 main 起始处)
  • main(0x4011d6) 入口处读取 GOT:
    • (0x404008) = 0x401040
  • 其中 0x401040 位于程序 .plt 区域(见 .plt 反汇编),说明此时 GOT 表项尚未指向 libc 的真实 printf,而是指向 PLT 内的解析入口(用于触发动态链接器解析符号)。
  1. 第一次调用 printf 之后(断在 call printf@plt 返回处 0x401222)

    在这里插入图片描述

    图23 gdb 查看 printf 的GOT槽地址(调用后)

  • main 中第一次调用点为:call 0x4010a0 <printf@plt>,其返回后的下一条指令地址为 0x401222
  • 0x401222 处再次读取 GOT:
    • (0x404008) = 0x7ffff7c60100
  • 0x7ffff7c60100 位于共享库地址范围(通常属于 libc.so.6 的映射区间),说明动态链接器已在首次调用后将 printf@got.plt 改写为 libc 中 printf 的真实入口地址。

结论: printf@got.plt 在第一次调用前指向 .plt 内部入口(0x401040),第一次调用后被改写为 libc 内真实地址(0x7ffff7c60100)。这体现了动态链接的**延迟绑定(lazy binding)**机制:首次调用经 PLT 触发解析并回填 GOT,后续调用可通过 GOT 直接跳转到真实函数,提高效率。

5.8 本章小结

  • 链接阶段将可重定位目标文件 hello.o 与启动文件(crt*.o)及运行库(如 libclibgcc_s)组合,生成可执行文件 hello,并完成符号解析与地址空间布局。
  • 链接器依据 hello.o 的重定位表,对 .text 中的占位操作数进行修补:字符串常量的 RIP 相对地址(R_X86_64_PC32)被填为正确位移,外部函数调用(R_X86_64_PLT32)被改写为跳转到对应 xxx@plt
  • 生成的 hello 采用 ELF64 格式,包含 4 个主要 PT_LOAD 段(R / R-E / R / RW),分别承载元信息与动态链接数据、代码、只读数据、读写数据(含 GOT/PLT、.data/.bss 等)。
  • 通过 gdb 的虚拟地址空间映射可验证:运行时对 hello 的四段映射与 Program Headers 的段信息一致,且动态链接器 ld-linux 会先执行再进入 hello 的入口 _start
  • 执行流程上,程序从动态链接器初始化进入 hello:_start,再经 __libc_start_main_impl 调用 mainmain 返回后进入 exit 的退出处理链,最终由 _exit 终止进程。
  • 动态链接分析表明,hello.rela.plt/.got.plt/.plt 等结构;以 printf 为例,其 GOT 表项在首次调用前指向 PLT 解析入口,首次调用后被回填为 libc 中真实地址,体现了延迟绑定(lazy binding)机制。

第6章 hello进程管理

6.1 进程的概念与作用

  • 概念:进程(process)是操作系统进行资源分配与调度的基本单位,是“正在执行的程序”的运行实例。
  • 进程上下文
    • 地址空间:代码段、数据段、堆、栈、共享库映射等;
    • CPU 上下文:PC/RIP、通用寄存器、标志寄存器等;
    • 内核维护信息:PID/PPID、进程状态、调度信息、打开文件描述符表、信号处理表、页表指针等。
  • 作用
    • 隔离与保护:不同进程地址空间隔离;
    • 并发与共享:多个进程并发运行,必要时通过 IPC/文件/管道共享数据;
    • 资源管理:CPU 时间片、内存、文件、设备等由内核统一管理。

6.2 简述壳Shell-bash的作用与处理流程

  • 作用:bash 是命令解释器(shell),负责读取用户命令、解析语法(参数、重定向、管道、环境变量)、创建进程并管理作业(job control)。
  • 处理流程
    1. 读取命令行并解析(tokenize/expand:变量展开、通配符展开等);
    2. 查找可执行文件路径(PATH 搜索);
    3. fork 创建子进程;
    4. 子进程 execve 装载目标程序替换自身映像;
    5. 父进程(bash)根据前后台运行决定:等待(wait/waitpid)或把任务放后台并维护 jobs 表;
    6. 处理终端控制与信号(Ctrl-C/Ctrl-Z 等),并可通过 fg/bg/kill 控制作业。

6.3 Hello的fork进程创建过程

使用 strace -f -e trace=process,execve bash -c '...' 跟踪 bash 执行 ./hello ... 的进程创建与回收过程。输出显示 bash 通过 clone/fork 类系统调用创建子进程,随后由子进程 execve 装载 hello,父进程等待并回收子进程。

在这里插入图片描述

图24 trace返回的结果

  • 创建关系clone(...) = 167559 表示 bash 创建出子进程(功能上等价于 fork/vfork 的“创建子进程”语义),父进程获得子 PID,子进程从同一执行点开始运行(返回值语义在用户态表现为父子分支)。
  • 资源继承:子进程继承父进程的诸多内核对象(例如打开文件描述符表、当前工作目录、环境变量 envp、终端控制信息等),因此 hello 能直接使用继承来的标准输入/输出与终端交互。
  • 写时复制(COW):fork/clone 创建初期父子进程共享物理页,只有在写入时才复制,从而降低创建成本(这也是 shell 能频繁创建外部命令进程的基础)。
  • 父进程回收(wait):父 bash 通过 wait4 等待子进程结束;当子进程退出后,父进程从 wait4 返回并获得退出码,随后会收到 SIGCHLD 通知并完成子进程资源回收。

6.4 Hello的execve过程

在 fork/clone 创建出子进程后,子进程并不直接运行 hello 的代码,而是通过 execve 用 hello 程序替换自身进程映像,这是 shell 执行外部命令的标准模式。

  • 装载 ELF 与建立虚拟地址空间:内核根据 hello 的 Program Headers 映射代码段、只读段、数据段与 bss,建立新的页表映射与栈空间。
  • 入口与启动:将控制流设置到 hello 的入口点(第 5 章你已得到 entry point = 0x4010f0),从 _start 开始执行,再进入 glibc 启动框架并最终调用 main
  • 动态链接介入:由于 hello 是动态链接程序,execve 后会先装载解释器 /lib64/ld-linux-x86-64.so.2,由其完成共享库装载与重定位,然后再跳转到 hello_start(这一点与第 5 章 5.6 的 gdb 观察一致)。

6.5 Hello的进程执行

6.5.1 进程上下文信息与运行状态

在这里插入图片描述

图25 ps观察hello的进程信息

运行 ./hello ... 0 时,通过 ps 观察到 hello 进程信息如下(PID=174457):

  • PID=174457PPID=173446:hello 由父进程 bash 创建并运行(PPID 为启动它的 shell)。
  • PGID=174457:hello 自身作为一个进程组的组长(通常前台作业会以子进程 PID 作为 PGID)。
  • TPGID=174457(运行时):hello 所在进程组为当前终端前台进程组,说明它占有终端控制权。
  • TTY=pts/5:hello 与该伪终端绑定,标准输入/输出来自该终端。
  • STAT=S+S 表示 可中断睡眠(sleeping)+ 表示其处于前台进程组。
  • PRI=19NI=0:调度优先级/ nice 值为默认水平。
  • ELAPSED 持续增长而 TIME 仍很小:说明 hello 大部分时间并不占用 CPU,而是在等待/阻塞(符合程序中 sleep()getchar() 的行为特征)。

6.5.2 进程调度与时间片

hello 的执行在用户态与内核态之间反复切换,关键原因是其包含多个可能阻塞或触发系统调用的库函数:

  • sleep(atoi(argv[4])):最终会进入内核执行 nanosleep/clock_nanosleep 类系统调用。进程在睡眠期间会被置为睡眠态(STAT=S),不占用 CPU,调度器将时间片分配给其他就绪进程;时间到期后由定时器事件唤醒,回到就绪队列等待再次调度。
  • getchar():最终调用 read(0,...) 等系统调用等待终端输入,若无输入则阻塞,同样会处于 S 态。
  • printf(...):最终会触发 write(1,...) 等系统调用将内容写到终端。该过程涉及用户态到内核态的陷入(trap),完成后再返回用户态继续执行。

因此,hello 的运行模式是:

用户态执行一段 → 系统调用陷入内核 → 进入阻塞(S)→ 被唤醒 → 被调度得到 CPU 时间片 → 再次运行

6.5.3 用户态与核心态转换、上下文切换

在这里插入图片描述

图26 ps 查看 hello 进程信息(停止后)

在 hello 前台运行时按下 Ctrl-Z,终端驱动向前台进程组发送 SIGTSTP,hello 被停止,表现为:

  • jobs -l 显示:[1]+ 174457 Stopped ./hello ...
  • ps 显示:STAT=T,并且此时 TPGID 不再是 hello 的 PGID(变为新的前台进程组 ID),说明 hello 已失去前台终端控制权且不再参与 CPU 调度。
  • 信号到达会触发内核对进程状态的改变(从可运行/睡眠转为 stopped),并在调度层面将其移出可运行队列;
  • 使用 fg %1 后,shell 会对该作业发送 SIGCONT 并重新置为前台,进程恢复执行。

6.6 hello的异常与信号处理

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。

程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。

6.6.1 hello 运行中会出现的异常/信号(报告文字模板)

(1)异常/中断类别

  • 陷入(Trap,同步异常):由系统调用触发,例如 printf 触发 writesleep 触发 nanosleepgetchar 触发 read。发生时 CPU 从用户态进入内核态,执行系统调用后返回用户态继续运行。
  • 缺页异常(Page Fault,同步异常):程序访问未映射页时触发,通常由按需分页/共享库加载导致,多数由内核处理后继续执行。
  • 外部中断(Interrupt,异步):时钟中断驱动调度与时间片;I/O 中断驱动终端输入等事件唤醒阻塞进程。

(2)信号(Signals)与处理

  • SIGINT(Ctrl-C):终端向前台进程组发送,处理:终止进程。
  • SIGTSTP(Ctrl-Z):终端向前台进程组发送,处理:停止进程(T 状态)。
  • SIGCONT:继续执行(由 fg/bgkill -CONT 发送)。
  • SIGTERM:请求终止(可被捕获/清理退出),处理:终止。
  • SIGKILL:强制终止(不可捕获/不可忽略)。

6.6.2 回车+乱按键盘

在这里插入图片描述

图27 在程序执行时随机按键盘与回车

  • 由于秒数为0,hello 会打印 10 次后停在 getchar() 等输入。此时在终端 A 随便按几次回车/字母,程序会立即结束(因为 getchar 读到一个字符就返回,main 返回 0,进入 exit)。
  • getchar 引发 read(0,...),无输入时阻塞(S);输入到达后 I/O 中断唤醒,read 返回,程序继续执行并正常退出。

6.6.3 Ctrl-Z(SIGTSTP,停止作业)

在这里插入图片描述

图28 停止作业、ps查看与恢复前台作业

  • Ctrl-Z 由终端驱动向前台进程组发送 SIGTSTP;
  • 内核将进程置为 stopped(STAT=T);
  • fg 让 bash 把作业切回前台并发送 SIGCONT,使其继续执行。

6.6.4 Ctrl-C(SIGTMP,停止作业)

在这里插入图片描述

图29 CTRL-C 终止进程

  • Ctrl-C → SIGINT → 默认动作 terminate;
  • 这是异步信号,导致进程立刻终止(不再继续打印)。

6.6.5 kill 发送信号

在这里插入图片描述

图30 kill -CONT 发送信号

  • 使用 kill -CONT 对 stopped 的 hello 发送继续信号;若 hello 此时处于后台且执行到 getchar(),会因后台读取终端触发 SIGTTIN 而再次停止,表现为 jobs -l 显示 Stopped (tty input)。此现象说明 SIGCONT 仅改变“是否可运行”,但不改变前台/后台属性,终端读写权限仍由作业控制决定。

在这里插入图片描述

图31 kill -TERM 和 kill -KILL 发送信号

  • 使用 kill -TERM PID 向运行中的 hello 发送终止请求信号;由于程序未安装自定义处理函数,采用默认动作直接终止,ps 查询不到该 PID。
  • 使用 kill -KILL PID 强制终止 hello;SIGKILL 不可被捕获或忽略,进程会被内核立即结束。

6.7本章小结

  • 进程是操作系统进行资源分配与调度的基本单位,内核通过维护进程的地址空间、寄存器上下文、打开文件、信号表等信息来实现隔离与管理。
  • bash 作为命令解释器,完成命令解析、路径查找与作业控制;执行外部程序时通常采用 fork/clone 创建子进程 + execve 装载程序,并由父进程通过 wait/waitpid 回收子进程。
  • 通过 strace 观察到 hello 的创建与回收过程:父进程创建子进程、子进程 execve 替换为 hello,退出后父进程接收 SIGCHLD 并完成回收。
  • 通过 ps/jobs 观察到 hello 的运行状态与调度特征:程序大量时间处于 sleep/getchar 引起的阻塞状态(如 S+),ELAPSED 增长而 CPU TIME 很小,体现了阻塞—唤醒—再调度的运行模式与时间片调度机制。
  • 通过 Ctrl-Z/Ctrl-C/kill 等实验验证了信号处理与作业控制:
    • Ctrl-Z 触发 SIGTSTP 使进程停止(T);fg/SIGCONT 可恢复;
    • Ctrl-C 触发 SIGINT 使进程终止;
    • SIGTERM 实现可控终止请求,SIGKILL 实现强制终止;
    • SIGTTIN 现象表明后台作业读终端会被内核停止,体现了终端前后台与信号联动的作业控制规则。

第7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1 逻辑地址、线性地址、虚拟地址、物理地址

  • 逻辑地址(Logical Address)

    程序指令中形成的地址表达形式,传统上可表示为“段选择子 : 段内偏移”。在 x86-64 的 long mode 下,除 FS/GS(线程局部存储等用途)外,CS/DS/ES/SS 段基址通常为 0,因此逻辑地址经过分段变换后一般不会发生变化。

  • **线性地址(Linear Address)**Linear=Base+Offset

    逻辑地址经过**段式变换(Segmentation)**得到的地址:

    Linear=Base+Offset

    在 x86-64 long mode 中,常见用户进程的 base≈0,因此线性地址通常与逻辑地址几乎一致。

  • 虚拟地址(Virtual Address, VA)

    进程在用户态“看到并使用”的地址空间。Linux/x86-64 中通常将线性地址作为虚拟地址来讨论,并通过页表将 VA 映射到物理内存。/proc/<pid>/maps 显示的就是进程的虚拟地址空间布局

  • 物理地址(Physical Address, PA)

    真实 DRAM 中的地址(物理内存条地址)。CPU 最终访问内存时必须落到 PA;VA→PA 的转换由 MMU + 页表 + TLB 完成。用户态一般无法直接看到 PA(需要内核权限读取页表/pagemap),因此本节主要通过 maps 证明 VA 的组织方式,并在后续 7.3–7.4 解释 VA→PA 变换机制。

7.1.2 hello 进程的虚拟地址空间布局

通过 cat /proc/221215/maps 可得到 hello 进程的主要虚拟地址映射如下(摘录):

(1)hello 可执行文件的映射(代码/只读数据/可写数据)

00400000-00401000r--p ... /home/.../hello
00401000-00402000r-xp ... /home/.../hello
00402000-00403000r--p ... /home/.../hello
00403000-00404000r--p ... /home/.../hello
00404000-00405000 rw-p ... /home/.../hello
  • 0x00401000-0x00402000 r-xp代码段(text),具有可执行权限 x,CPU 执行的指令主要来自这里。
  • r--p 区间:只读映射,通常用于只读数据/只读重定位信息等(例如 .rodata、部分元信息)。
  • 0x00404000-0x00405000 rw-p:可写数据区(如 .data/.bss 所在的可写映射),用于全局变量等运行期可修改数据。
  • 从权限上看,ELF 文件被拆分为不同权限区间(r–/r-x/rw-),体现了最小权限原则(代码可执行但不可写,数据可写但不可执行)。

(2)堆(heap):动态内存分配区域

27eed000-27f0e000 rw-p ...[heap]
  • [heap] 是进程的堆区,用于 malloc/new 等动态分配。
  • 本程序虽未显式调用 malloc,但运行时库(如 stdio 缓冲、locale 等)可能在内部使用堆空间,因此 heap 区域仍会出现。

(3)共享库映射:libc、libgcc_s 与动态链接器 ld-linux

7516b1400000-.../usr/lib/.../libc.so.6
7516b1712000-.../usr/lib/.../libgcc_s.so.1
7516b174e000-.../usr/lib/.../ld-linux-x86-64.so.2
  • 这些区间是 hello 动态链接依赖的共享库映射:
    • libc.so.6:C 标准库(printf/sleep/getchar/exit 等来自这里);
    • libgcc_s.so.1:GCC 运行时支持库;
    • ld-linux-x86-64.so.2:动态链接器(解释器),负责程序启动阶段的动态重定位和符号解析。
  • 每个共享库通常也被分成 r--p / r-xp / rw-p 多段映射,对应其只读段、代码段、可写段。

(4)栈(stack):函数调用与局部变量区域

7ffd5deda000-7ffd5defc000 rw-p ...[stack]
  • [stack] 是用户栈区,保存函数调用链、返回地址、局部变量与部分参数等。
  • 栈通常是可读写的 rw-p,并随运行发生动态增长(向低地址方向扩展)。

(5)vvar / vdso:内核提供的用户态辅助映射

7ffd5dfc6000-7ffd5dfca000r--p ...[vvar]
7ffd5dfca000-7ffd5dfcc000r-xp ...[vdso]
  • [vvar]:内核导出给用户态的只读变量页(如时间相关数据)。
  • [vdso]:虚拟动态共享对象,提供若干系统调用的“快速路径”(例如获取时间的相关接口),减少频繁陷入内核的开销。
  • 它们体现了“进程地址空间中不仅有程序与库,还有内核提供的特殊映射区域”。

7.2 Intel逻辑地址到线性地址的变换-段式管理

在这里插入图片描述

图32 gdb 查看段寄存器

x86 体系结构中,CPU 形成的地址可抽象为逻辑地址(logical address),其基本形式为“段选择子(segment selector)+ 段内偏移(offset)”。段选择子存放在段寄存器(CS/DS/SS/ES/FS/GS)中,包含 index、TI(选择 GDT/LDT)以及 RPL 等字段。处理器根据 selector 在 GDT/LDT 中索引到段描述符,获得段基址 base 与访问权限等属性,并进行特权级检查。随后通过公式 L i n e a r = b a s e + o f f s e t Linear = base + offset Linear=base+offset 将逻辑地址转换为线性地址(linear address),并在传统分段模式下可结合 limit 做越界检查。

对于本实验的 hello(ELF64,x86-64 long mode),分段机制在用户态被弱化:CS/DS/ES/SS 的段基址通常为 0,使得逻辑地址到线性地址在多数情况下近似恒等变换(Linear≈Offset)。因此进程间隔离与内存保护主要由分页机制实现;分段在 long mode 中更多用于兼容与 FS/GS(如 TLS)等特定用途。

7.3 Hello的线性地址到物理地址的变换-页式管理

在这里插入图片描述

图33 查看页大小

x86-64用户程序的线性地址(linear address)通常可视为虚拟地址(VA)。处理器通过 MMU 结合多级页表将 VA 翻译为物理地址(PA)。分页以固定页大小 2 P 2^P 2P 划分地址空间(本系统页大小为 4096B,即P=12),因此虚拟地址可分解为 V A = V P N ∣ ∣ V P O VA = VPN || VPO VA=VPN∣∣VPO ,其中 VPO 为低 12 位页内偏移;物理地址同理为 P A = P P N ∣ ∣ P P O PA = PPN || PPO PA=PPN∣∣PPO ,且 PPO 与 VPO 相同,页表仅改变页号部分(VPN→PPN)而保持页内偏移不变。

页表项(PTE)除包含物理页框号外,还携带权限与状态位(如 Present、R/W、U/S、NX 等),从而实现按页的访问控制。结合 hello 的地址空间映射可见:代码区域为 r-x(可读可执行不可写),数据/堆/栈区域为rw-(可读写不可执行),共享库(如 libc、ld-linux)也以多段不同权限映射到进程虚拟地址空间。通过“每进程独立页表”实现进程隔离,通过“共享页框映射”实现共享库等内存共享,通过“按需分页/缺页处理”实现延迟分配与装载。

7.4 TLB与四级页表支持下的VA到PA的变换

7.4.1 四级页表总体结构

x86-64 Linux 通常使用 四级页表完成虚拟地址到物理地址转换,页表层级依次为:

  • PML4(Page Map Level 4)
  • PDPT(Page Directory Pointer Table)
  • PD(Page Directory)
  • PT(Page Table)
  • PTE(Page Table Entry,最终指向物理页框 PPN)

硬件在发生 TLB miss 时,会进行 page walk(页表遍历):按层级逐级查表,最终得到 PTE 中的物理页框号(PPN),再与页内偏移拼接得到物理地址。

7.4.2 48 位 canonical VA 的位划分

在 4KB 页(页内偏移 12 位)情况下,典型 canonical 虚拟地址(低 48 位有效)划分如下:

  • [47:39]:PML4 index(9 位)
  • [38:30]:PDPT index(9 位)
  • [29:21]:PD index(9 位)
  • [20:12]:PT index(9 位)
  • [11:0]:Page offset(12 位)

因此可写成:

V A = P M L 4 i ∣ ∣ P D P T i ∣ ∣ P D i ∣ ∣ P T i ∣ ∣ O f f s e t VA=PML4i∣∣PDPTi∣∣PDi∣∣PTi∣∣Offset VA=PML4i∣∣PDPTi∣∣PDi∣∣PTi∣∣Offset

其中 Offset 不参与页表查找,最终直接拼到 PA 的低 12 位。

7.4.3 页表遍历(page walk)过程(TLB miss 时发生)

当 CPU 需要访问某个 VA 时:

  1. 先查 TLB(缓存了最近 VA→PA 的翻译结果)

    • TLB hit:直接得到 PPN,快速组成 PA 并访问缓存/内存。
  2. TLB miss:触发硬件 page walk

    • CR3 寄存器获得当前进程的顶级页表基址(PML4 的物理地址)。
    • 用 VA 的 PML4 index 在 PML4 中取出 PDPTE 的地址;
    • 用 PDPT index 在 PDPT 中取出 PDE 的地址;
    • 用 PD index 在 PD 中取出 PTE 表(PT)的地址;
    • 用 PT index 在 PT 中取出最终 PTE
  3. 检查 PTE 权限与存在位

    • Present=0 触发 缺页异常(page fault)(后续 7.8 讨论)。
    • 权限不符(例如写只读页、在 NX 页执行)也会触发异常。
  4. 得到物理页框号 PPN 后:

    P A = P P N    ∣ ∣    O f f s e t PA=PPN  ∣∣  Offset PA=PPN  ∣∣  Offset

  5. 将该翻译结果写入 TLB,以加速后续访问。

7.4.4 TLB 的作用与命中/未命中影响

  • **TLB(Translation Lookaside Buffer)**本质上是一个专用缓存,保存页表遍历结果(VPN→PPN + 权限等)。
  • TLB hit:无需访问页表(少量周期)。
  • TLB miss:需要访问内存中的多级页表(最坏需要多次内存读),明显变慢;因此操作系统与编译器通常利用局部性减少 TLB miss。

7.4.5 用 main 的 VA 做四级页表索引拆分

在这里插入图片描述

图34 查看 main 的 VA

以 **VA = 0x00000000004011d6**为例,按 4KB 页(offset 12 位)拆分:

**Offset** = VA & 0xFFF
= 0x4011d6 & 0xfff = 0x1d6
PT index = (VA >> 12) & 0x1FF
VA >> 12 = 0x401
0x401 & 0x1ff = **0x001
PD index** = (VA >> 21) & 0x1FF
VA >> 21 = 0x2
0x2 & 0x1ff = **0x002
PDPT index** = (VA >> 30) & 0x1FF
VA 小于 0x40000000,因此 >>30 = **0x000
PML4 index** = (VA >> 39) & 0x1FF
同理为 **0x000**

所以这一页的四级索引为:

PML4i = **0x000**
PDPTi = **0x000**
PDi = **0x002**
PTi = **0x001**
Offset= **0x1d6**

7.5 三级Cache支持下的物理内存访问

  • Cache 的位置与作用

    Cache 位于 CPU 与主存(DRAM)之间,按“用更小但更快的 SRAM 缓存热点数据/指令”的思想,降低平均访存延迟,提高吞吐。

  • 三级 Cache 结构

    • L1 Cache:离核心最近、最小、最快;通常分为 **L1I(指令 Cache)**与 L1D(数据 Cache)
    • L2 Cache:通常每核私有,比 L1 大但慢。
    • L3 Cache(LLC,Last Level Cache):通常多核共享,容量最大、速度最慢(但仍远快于 DRAM)。
  • **访问路径(从 CPU 视角)**L1→L2→L3→Memory

    当 CPU 访问某个物理地址(PA)时,典型路径为:

    L1 hit → 直接返回;若 miss,则依次查 L2、L3,仍 miss 才访问 DRAM

    L 1 → L 2 → L 3 → M e m o r y L1→L2→L3→Memory L1L2L3Memory

  • 命中/未命中与局部性

    • 时间局部性:刚访问过的数据很可能再次访问(如循环变量、调用栈)。

    • 空间局部性:访问某地址后,其附近地址也可能被访问(如顺序扫描数组/字符串)。

      Cache 通过按 cache line(缓存行)(常见 64B)成块搬运数据来利用空间局部性。

7.6 hello进程fork时的内存映射

fork 发生时(父进程 → 子进程):

  1. 虚拟地址空间布局基本相同

    子进程继承父进程的内存映射:代码段、数据段、堆、栈、共享库映射等在 VA 层面几乎完全一致/proc/<pid>/maps 很像,通常只有少量如 [stack] 标记或 VDSO 位置等细节可能不同,但整体一致)。

  2. 不会立刻复制整块物理内存

    内核不会把所有物理页复制一份给子进程,否则 fork 成本太高。Linux 使用 写时复制(Copy-On-Write, COW)

    • fork 后父子进程最初共享同一批物理页框;
    • 这些页在页表里会被标为只读(或通过 COW 标志);
    • 当任一方尝试写入某页时,触发 缺页异常(page fault),内核为写入方复制该页(分配新物理页、拷贝内容、更新 PTE),从而实现“看似复制,实际按需复制”。
  3. 共享库代码页天然可共享

    libc.so.6 等共享库的只读代码页可被多个进程映射到各自 VA,但底层物理页可共享,进一步节省内存。

7.7 hello进程execve时的内存映射

execve 的本质:在同一个 PID 的进程里,用新程序的地址空间替换旧地址空间

  1. 进程身份不变:PID 不变(还是那个子进程),但“进程映像(memory image)”被替换。
  2. 旧的用户态地址空间被清空/替换:原来的 .text/.data/heap/stack 等映射会被移除,建立 hello 的新映射。
  3. 按照 ELF Program Headers 建立映射:把 hello 的 PT_LOAD 段映射进来(r-x 代码段、r-- rodata、rw- data+bss),并建立新栈、初始化堆。
  4. 动态链接器与共享库映射出现:因为 hello 是动态链接程序,会先映射解释器 ld-linux-x86-64.so.2,再映射 libc.so.6libgcc_s.so.1 等;并建立 .interp/.dynamic/.got/.plt 相关结构。

7.8 缺页故障与缺页中断处理

7.8.1 缺页故障的概念与触发条件

  • 缺页故障(Page Fault):当 CPU 进行 VA→PA 转换或访问内存时,发现该页在页表中不存在(Present=0),或访问权限不满足(如写只读页、在 NX 页执行),CPU 会触发 #PF 异常,陷入内核处理。
  • 触发缺页的常见场景:
    1. 按需分页(demand paging):代码/数据尚未装入物理内存,第一次访问时才分配/装入;
    2. 匿名页分配(anonymous memory):例如堆/栈扩展,首次写入触发分配“零页”(zero page)或新页框;
    3. 写时复制(COW):fork 后共享页被写入时触发缺页以完成复制;
    4. 非法访问:访问未映射地址或权限不允许,导致进程收到 SIGSEGV/SIGBUS。

7.8.2 缺页异常的硬件信息:错误码与CR2

在 x86-64 中,#PF 发生时硬件提供关键诊断信息:

  • CR2:保存导致缺页的线性地址(faulting linear address)。
  • 错误码 error code(常见位含义):
    • P:0 表示 not-present,1 表示保护错误(权限问题)

    • W/R:1 表示写访问导致,0 表示读访问导致

    • U/S:1 表示用户态访问导致,0 表示内核态访问导致

    • I/D:1 表示取指导致(执行 NX 页)

      这些信息帮助内核判断是“正常的按需分配/装入”,还是“非法访问要杀进程”。

7.8.3 Linux 对缺页故障的处理流程(简化但完整)

当 #PF 发生时,CPU 从用户态切换到内核态,进入页故障处理入口(架构相关),随后 Linux 大致按如下流程处理:

  1. 保存现场并读取 fault address(CR2)与错误码

    内核获得发生缺页的虚拟地址和访问类型(读/写/执行、用户/内核)。

  2. 查找 VMA(虚拟内存区域)

    在进程的 mm_struct 中根据 fault address 查找对应 vm_area_struct

    • 若地址不在任何 VMA 范围内 → 判定为非法访问 → 发送 SIGSEGV(段错误)并终止进程(默认行为)。
    • 若在 VMA 中,但权限不匹配(例如写只读) → 同样 SIGSEGV 或 SIGBUS。
  3. 区分缺页类型并处理

    • 匿名页(heap/stack)首次访问:分配新的物理页框(或使用零页),建立 PTE,设置权限位。
    • 文件映射页(代码段、共享库、mmap 文件):从页缓存(page cache)查找,不在则从磁盘读入,建立映射。
    • COW 写缺页:复制原共享页到新页框,更新 PTE 指向新页,并设为可写。
  4. 更新页表并刷新 TLB(必要时)

    页表更新完成后,相关 TLB 项失效/刷新。

  5. 返回用户态,指令重启

    #PF 是同步异常:修复后,CPU 会回到触发缺页的那条指令处重新执行,程序通常“无感继续”。

7.8.4 hello:哪些地方可能发生缺页

hello 虽然逻辑简单,但运行时仍可能触发多类“正常缺页”:

  • 首次执行.text.rodata、PLT/GOT、以及 libc.so.6 的代码/数据页可能按需调入;
  • 首次调用 printf/atoi/sleep/getchar:可能首次触发加载相关 libc 代码页或数据页;
  • 栈增长:函数调用导致栈页逐步触碰,可能触发匿名页分配缺页;
  • 堆(若库函数内部 malloc):如 printf 触发缓冲区/locale 等分配,也可能触发匿名页缺页。

7.9本章小结

  • hello 进程的地址空间出发,区分并梳理了 逻辑地址、线性地址、虚拟地址与物理地址 的概念关系:程序使用 VA,最终需经硬件与操作系统映射到 PA 才能访问内存。
  • 分段机制(逻辑→线性) 在 x86-64 long mode 下被弱化:多数段基址为 0,使得线性地址通常与逻辑地址近似一致;FS/GS 等用于 TLS 等特殊用途。
  • 分页机制(线性/VA→PA) 的基本原理:页大小、VPN/VPO 分解、PTE 权限与按页保护;并结合 hello 的 maps 映射展示了代码段/只读段/可写段、堆、栈、共享库、vdso/vvar 等典型布局。
  • TLB + 四级页表 支持下 VA→PA 转换流程:TLB 命中加速转换,未命中触发硬件页表遍历;并以 main 的 VA 给出索引拆分示例,体现位段划分与 page walk 的层级结构。
  • 从存储层次结构角度概述了 三级 Cache(L1/L2/L3) 对物理内存访问的加速作用,以及依赖时间/空间局部性与缓存行搬运降低平均访存延迟。
  • 从进程视角分析 fork 与 execve 的内存映射语义差异:fork 采用 COW 实现“VA 继承、PA 延迟复制”,execve 则在 PID 不变的前提下 整体替换进程映像,并引入动态链接器与共享库映射。
  • 缺页故障(#PF) 的触发原因与 Linux 处理流程:通过 VMA 判定合法性与权限,按需分配/装入或执行 COW;若非法访问则以 SIGSEGV/SIGBUS 终止进程,体现虚拟内存“按需 + 保护”的核心机制。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

  • 设备模型化为文件
    • 大多数 I/O 资源都用“文件描述符 fd”抽象:普通文件、终端、管道、socket、字符设备等。
    • 统一由 VFS(Virtual File System) 提供一致的访问语义:open/read/write/close 等。
  • 设备文件与驱动绑定
    • /dev/xxx 是设备节点(device node),通过 主设备号/次设备号(major/minor) 关联到内核驱动。
    • 驱动向上提供 file_operations(如 read/write/ioctl),向下与具体硬件/总线交互。
  • 缓冲与缓存
    • 普通文件 I/O 常走 页缓存(page cache);字符设备(如终端)更多走内核缓冲队列与驱动队列。
  • 同步/异步与阻塞
    • I/O 默认可阻塞(读不到就睡眠),也可通过 O_NONBLOCKselect/poll/epoll 做非阻塞/事件驱动。
  • 用户态与内核态分工
    • 用户态:调用库函数(如 printf/getchar)组织数据与缓冲;
    • 内核态:系统调用进入内核,VFS/驱动完成实际 I/O。

8.2 简述Unix IO接口及其函数

  • 核心接口(文件描述符)
    • int open(const char *path, int flags, mode_t mode):打开文件/设备,返回 fd
    • ssize_t read(int fd, void *buf, size_t n):从 fd 读
    • ssize_t write(int fd, const void *buf, size_t n):向 fd 写
    • int close(int fd):关闭 fd
  • 文件位置与元数据
    • off_t lseek(int fd, off_t off, int whence):修改文件偏移(终端/管道通常不可用)
    • stat/fstat/lstat:获取文件元信息
  • I/O 复用与控制
    • select/poll/epoll:等待多个 fd 就绪
    • ioctl:对设备做控制操作(终端属性、网卡参数等)
  • 与标准 I/O(stdio)的关系
    • printf/getchar 属于 stdioFILE* 流),底层最终仍会调用 read/write 系统调用完成 I/O。

8.3 printf的实现分析

  • 用户态格式化阶段(stdio 层)
    • printf("Hello %s ...", ...) 进入 libc 的 printf/vfprintf
    • 内部调用类似 vsnprintf/vsprintf 将格式串与参数格式化为一段字节序列(ASCII/UTF-8)。
  • 缓冲策略(关键点)
    • stdout 对终端通常是 行缓冲(line-buffered):遇到 \n 或缓冲满时刷新;
    • 刷新时将缓冲区内容写入 fd=1(stdout)。
  • 进入内核:write 系统调用
    • libc 调用 write(1, buf, len)
    • 在 x86-64 上通常通过 syscall 指令陷入内核。
  • 内核路径(简化)
    • sys_write → VFS → 具体文件对象(终端通常是 pty/tty);
    • 经过 tty 子系统/行规程(line discipline)与驱动,最终把字符送到终端显示系统(如 pty→终端模拟器)。
  • “字符如何显示到屏幕”
    • 字符数据由终端/显示子系统处理:字符编码(ASCII/UTF-8)→ 字形渲染(字模/字体)→ 写入显示缓冲(可抽象为 VRAM/帧缓冲);
    • 显示硬件按刷新频率读取显示缓冲并输出像素信号到显示器。

8.4 getchar的实现分析

  • 键盘输入是异步事件
    • 键盘产生输入事件,触发硬件中断;
    • 内核中断处理/输入子系统将扫描码(scancode)转换为键值,再经终端/输入层形成字符(ASCII/UTF-8),写入内核缓冲(终端输入队列)。
  • getchar 的用户态行为(stdio 层)
    • getchar()stdinFILE* 读一个字符;
    • 若 stdio 缓冲区为空,会调用底层 read(0, ...)(fd=0,stdin)。
  • 为什么“通常回车后才返回”(终端规范模式)
    • 默认终端处于 规范模式(canonical mode):内核按“行”缓冲输入;
    • 只有收到换行(Enter)后,read 才把这一行数据交给用户进程;
    • 若切换到 raw/cbreak 模式(如某些程序),则可做到按键即返回。
  • 阻塞与唤醒
    • 没有可读数据时,read 使进程睡眠(阻塞);
    • 键盘输入到达后触发唤醒,read 返回,getchar 得到字符并继续执行。

8.5本章小结

  • Linux 以“文件”统一抽象 I/O 设备,通过 fd + VFS + 驱动提供一致的访问接口。
  • Unix I/O 的核心是 open/read/write/close,stdio(printf/getchar)在其上提供格式化与缓冲。
  • printf 先在用户态完成格式化与缓冲,刷新时调用 write,通过 syscall 进入内核并最终输出到终端/显示系统。
  • getchar 本质是从 stdin 读取:键盘输入由中断驱动进入内核缓冲,在规范模式下通常“回车后”才让 read/getchar 返回。

结论

Hello 的完整经历

  1. 源代码阶段(Program as Text)

    Hello 起始是磁盘上的 hello.c,属于“静态文件(disk file)”,尚未成为可调度的执行实体。

  2. 编译系统流水线(Translation System / Toolchain)

    Hello 依次经历:

    • 预处理:hello.c → hello.i(头文件展开、宏展开、行号标记等)
    • 编译:hello.i → hello.s(生成 x86-64 汇编;控制流变为 cmp/jcc/label;参数与返回值遵循 ABI 寄存器约定)
    • 汇编:hello.s → hello.o(ELF64 REL;生成 .text/.rodata,并产生 .rela.text 等重定位信息)
    • 链接:hello.o + crt*.o + libc/libgcc… → hello(符号解析+重定位+节合并成段;生成 PLT/GOT、.dynamic、解释器信息)
  3. 可执行文件与装载视角(ELF → Segments)

    生成的 hello 为 ELF64 可执行文件,入口点 0x4010f0,并通过多个 PT_LOAD 段组织为只读段/代码段/只读数据段/读写段等,为后续 mmap 装载提供蓝图。

  4. 从程序到进程(P2P:Program → Process)

    在 Bash 中执行 ./hello ... 时:

    • Bash 通过 fork/clone 创建子进程
    • 子进程通过 execve 用 hello 的程序映像替换自身
    • 内核依据 Program Headers 建立虚拟地址空间映射(代码、数据、堆、栈、vvar/vdso 等)
  5. 动态链接与延迟绑定(ld-linux + PLT/GOT)

    由于 hello 是动态链接程序,控制流会先进入动态链接器 ld-linux 的启动入口,完成共享库装载/重定位后再跳转到 hello 的 _start。并且以 printf 为例:其 GOT 槽位 0x404008 在首次调用前指向 PLT 解析入口,首次调用后被回填为 libc 中真实地址,体现 lazy binding

  6. 运行期的“存储管理协作”

    • 地址转换:用户态使用 VA(线性/虚拟地址),通过 TLB + 四级页表完成 VA→PA;页大小 4KB,地址被拆分为多级索引+页内偏移。
    • 性能层次:最终的 PA 访问遵循 L1/L2/L3/DRAM 三级 Cache 层次结构,依赖时间/空间局部性降低平均延迟。
    • 进程语义:fork 通过 COW 实现“VA 继承、PA 延迟复制”;execve 则“PID 不变但整体替换映像”。
  7. 运行期的“进程管理与信号机制”

    Hello 在 sleep/getchar 等系统调用处体现阻塞—唤醒—再调度;在作业控制上可被 Ctrl-Z 触发 SIGTSTP 停止、fg/SIGCONT 恢复、Ctrl-C/SIGINT 终止;后台读终端触发 SIGTTIN 的现象体现了“终端前后台+信号”的规则闭环。

  8. I/O 路径闭环(stdio → syscall → driver)

    printf 在用户态完成格式化与缓冲,刷新时调用 write 通过 syscall 进入内核,再经 VFS/tty 子系统输出到终端显示;getchar 本质是 read(0,…),在终端规范模式下通常“回车后”才返回,体现“异步中断输入→内核缓冲→阻塞唤醒”的 I/O 机制。

  9. 020:From Zero to Zero(从 0 到 0)

    Hello 从“磁盘上的静态文件(近似 0)”开始,经装载、动态链接、执行与系统调用后,在 main 返回 0 并经 exit/_exit 结束,内核回收其资源,留下退出码与输出(归零离场)。

计算机系统设计与实现的创新理念

  • 分层抽象是可控复杂性的根本手段:从“源代码→ELF→进程→系统调用→页表/Cache→设备驱动”,每一层都通过稳定接口(ABI、ELF 规范、Unix I/O、VFS、PTE 位等)隔离变化、复用能力。
  • 性能来自“把慢路径变少”:TLB 命中、Cache 命中、lazy binding、COW 都是在减少昂贵操作(页表遍历、DRAM 访问、立即复制、提前解析)的触发频率。
  • 安全来自“默认不信任 + 最小权限”:W^X 权限拆分(r-x/rw-)、页级权限位、非法访问触发异常并以信号终止进程,体现“硬件检查 + 内核裁决”的防护链。

附件

  • 源与核心构建产物
    • hello.c:C 源文件(程序文本)
    • hello.i:预处理输出(展开头文件/宏后的纯 C 文本)
    • hello.s:编译输出汇编文件(x86-64 汇编,包含标签、指令与伪指令)
    • hello.o:可重定位目标文件(ELF64 REL,含 .text/.rodata/.symtab/.rela.text 等)
    • hello:最终可执行文件(ELF64 EXEC/动态链接,含 PT_LOAD、.dynamic、PLT/GOT 等)
  • 链接与格式分析产物(你已生成/上传过的典型文件)
    • link_cmd.txt:gcc 驱动打印出的实际链接命令(包含 crt*.o、-lc、动态链接器等)
    • readelf_h_hello.txtreadelf -h hello(ELF Header)
    • readelf_l_hello.txtreadelf -l hello(Program Headers/段信息)
    • readelf_S_hello.txtreadelf -S hello(Section Headers/节信息)
    • readelf_d_hello.txtreadelf -d hello(Dynamic Section/动态段)
    • dr_o.txtobjdump -d -r hello.o(可重定位文件反汇编+重定位项)
    • dr_hello.txtobjdump -d -r hello(可执行文件反汇编+重定位对照)
  • 进程与系统行为跟踪产物
    • trace.txtstrace 跟踪 bash 执行 hello 的 fork/execve 等系统调用证据

参考文献

[1] Randal E. Bryant. David R. O’Hallaron. 深入理解计算机系统.

[2] https://ysyx.oscc.cc/slides/hello-x86.html

[3] https://www.cnblogs.com/pianist/p/3315801.html

[4] Linux man-page. ld-linux / ld.so. printf(3), setvbuf(3), write(2).proc(5).execve(2), fork(2), clone(2), waitpid(2).

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐