计算机系统原理大作业
题 目 程序人生-Hello’s P2P
专 业 AI先进技术领军班
学 号 2024113010
班 级 24Q0302
学 生 崔鑫
指 导 教 师 史先俊
计算学部
2025年9月
摘 要
本文以 hello.c 程序为研究对象,系统梳理其从源代码到运行结束的完整生命周期,涵盖预处理、编译、汇编、链接、进程管理、存储管理及 IO 管理等核心环节。基于 Ubuntu 环境,通过借助 GCC、Readelf、Objdump、GDB 等工具,实操关键命令并分析中间产物的格式与作用,深入剖析 ELF 文件结构、虚拟地址与物理地址转换、动态链接、系统调用等底层原理,分析程序在各阶段的转换过程、文件格式变化及系统底层工作机制。本文详细记录了实验环境、中间产物、关键命令与结果解析,旨在以具象化的方式理解计算机系统的核心原理,为后续深入学习系统开发与优化奠定基础。
关键词:hello 程序;编译链接;进程管理;存储管理;IO 机制
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 25 -
6.3 Hello的fork进程创建过程... - 25 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 29 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 29 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 29 -
7.5 三级Cache支持下的物理内存访问... - 30 -
7.6 hello进程fork时的内存映射... - 30 -
7.7 hello进程execve时的内存映射... - 30 -
第1章 概述
1.1 Hello简介
Hello 的 P2P(Program to Process) 始于源代码 hello.c,经预处理(生成 hello.i)、编译(产出 hello.s)、汇编(封装为含重定位信息的 ELF 目标文件 hello.o)、链接(生成可执行 ELF 文件 hello),完成从静态程序到可运行文件的蜕变。随后,Shell 通过 fork 系统调用 创建子进程,再经 execve 系统调用,加载 hello:内核释放原 Shell 内存映射,解析 ELF 头分配虚拟地址空间,将二进制内容加载至对应地址并设置入口点(_start)。运行时,OS 调度器 分配 CPU 时间片,进程在用户态执行 main 逻辑,调用 printf/sleep 时陷入内核态;存储侧,MMU借四级页表和TLB缓存实现虚拟地址(VA)到物理地址(PA)的转换,三级 Cache(L1/L2/L3)加速内存访问;IO 交互 中,printf 通过标准输出文件描述符触发内核 IO 调度,getchar 依赖中断机制响应键盘输入。进程终止时,exit 系统调用回收资源,父进程(Shell)经 wait 清理僵尸进程。
而 O2O(From Zero-0 to Zero-0) 是生命周期闭环:初始无 Hello 进程,仅静态代码;经编译、加载、运行,占用 CPU、内存、IO 等资源完成功能;终止后所有资源被 OS 回收,回归无进程初始状态,实现从 “0”(无资源占用)到 “0”(资源归零)的轮回。
1.2 环境与工具
硬件环境有64 CPU(Intel 酷睿 i7-14650HX,主频 5.5GHz);16G RAM;1TB SSD Disk。
软件环境有 Windows 11 64位;Vmware 17pro ;Ubuntu 20.04.4 LTS 64位。
开发与调试工具说明有:
- GCC:用于预处理、编译、汇编、链接 C 语言程序
- Readelf:查看 ELF 文件(.o、可执行文件)的结构信息
- Objdump:对目标文件进行反汇编,分析机器指令与汇编代码的对应关系
- GDB:命令行调试工具,用于跟踪程序执行流程、查看内存与寄存器状态
- EDB:图形化调试工具,直观展示程序动态链接与内存分布
- Ps/Jobs/Pstree:查看进程状态、作业列表与进程树
- Kill:发送信号终止进程
1.3 中间结果
表1中间结果文件名及作用
| 生成阶段 | 作用 | |
| hello.i | 预处理阶段 | 预处理后的源代码文件,包含头文件展开、宏替换等内容 |
| hello.s | 编译阶段 | 汇编语言源代码文件,由.i文件转换而来 |
| hello.o | 汇编阶段 | 可重定位目标文件(ELF 格式),包含机器指令但未解决符号引用 |
| hello | 链接阶段 | 可执行文件(ELF 格式),已解决符号引用,可直接运行 |
1.4 本章小结
本章明确了本次大作业的研究对象——hello.c程序,梳理了程序的核心功能与生命周期脉络;详细列出了完成实验所需的软硬件环境与工具,明确了各工具的用途;同时梳理了实验过程中生成的中间文件及其作用。
第2章 预处理
2.1 预处理的概念与作用
预处理是编译过程的第一步,由预处理器(cpp)完成,作用是对 C 语言源代码进行文本替换与清理,生成预处理文件(.i)。核心功能包括:头文件展开,即将#include指定的头文件内容直接插入源代码对应位置;宏替换,即替换#define定义的宏;条件编译,即处理#if、#ifdef等指令;清理注释,即删除源代码中的 // 和 /* */ 注释;行号与文件名标记,即为后续编译错误定位提供支持;预处理不进行语法检查,仅做文本层面的转换,确保后续编译阶段处理的代码是完整、无冗余的。
2.2在Ubuntu下预处理的命令
将hello.c预处理为hello.i,-E表示仅执行预处理,不进行后续编译
- gcc -m64 -E hello.c -o hello.i
- ls -l hello.i

图1 预处理过程图
2.3 Hello的预处理结果解析
原文件hello.c约 300 字节,而预处理文件hello.i:约 10KB+,主要因头文件展开,如stdio.h包含大量函数声明与宏定义。用文本编辑器打开hello.i,可观察到头文件展开、注释删除、保留了核心代码等现象,原代码中的#include <stdio.h>、#include <stdlib.h>被替换为stdio.h和stdlib.h的完整内容,包含printf、getchar、exit、atoi等函数的声明,原代码中的注释已被全部删除,main函数的代码结构未变,仅删除注释。
2.4 本章小结
本章完成了hello.c程序的预处理操作,通过gcc -E命令生成了预处理文件hello.i;明确了预处理的核心作用是头文件展开、注释删除等文本转换,确保后续编译过程正常进行。预处理后的hello.i文件为下一步编译阶段提供了完整、干净的源代码。
第3章 编译
3.1 编译的概念与作用
编译是预处理后的下一步,由编译器gcc完成,核心作用是将预处理后的源代码文件(.i)转换为汇编语言文件(.s),其内容是机器指令的 “人类可读形式”,为后续汇编阶段提供输入。该过程会进行:
- 语法分析:检查代码是否符合 C 语言语法规则,如括号匹配、语句结束符等
- 语义分析:检查代码逻辑合理性,如变量未定义、类型不匹配等
- 中间代码生成:将高级 C 语言转换为中间表示(IR)
- 目标代码生成:将中间代码转换为特定架构(如 x86-64)的汇编语言指令
3.2 在Ubuntu下编译的命令
将hello.i编译为hello.s,-S表示仅执行编译,不进行汇编
-
gcc -m64 -S hello.i -o hello.s
-
ls -l hello.s

图2编译过程图
3.3 Hello的编译结果解析
图3编译结果图
3.3.1 数据类型:常量、局部变量、指针
1.常量:字符串常量,如"Hello!2024113010崔鑫15545891685"、"Hello %s %s %s\n"存储在 .rodata 段,通过标签.LC0、.LC1引用:
.LC0:
.string "Hello!2024113010\345\264\224\351\221\25315545891685" ;
.LC1:
.string "Hello %s %s %s\n" ;
编译时确定内容,运行时不可修改,体现常量的只读特性。
2.局部变量:main函数的局部变量(argc、argv、i)存储在栈帧中(基于%rbp基址的偏移):
subq $32, %rsp ; 栈上分配32字节空间
movl %edi, -20(%rbp) ; argc(int,32位)存入栈:-20(%rbp)
movq %rsi, -32(%rbp) ; argv(char*[],64位指针)存入栈:-32(%rbp)
movl $0, -4(%rbp) ; i(int,32位)赋初值0,存入栈:-4(%rbp)
变量随函数调用创建,函数返回时随栈帧销毁,体现局部变量的作用域特性。
3.指针操作(argv):argv是字符指针数组,访问元素时通过基址 + 偏移计算地址(每个指针占 8 字节):
movq -32(%rbp), %rax ; 取argv基址到%rax
addq $8, %rax ; 偏移8字节(argv[1],跳过argv[0])
movq (%rax), %rax ; 取argv[1]的指针值
对应 C 的数组访问argv[i]和指针解引用*p。
3.3.2 赋值与初始化
显式赋值:
- i = 0:通过movl $0, -4(%rbp)直接向栈上的i赋初值,体现变量初始化。
- argc、argv的保存:movl %edi, -20(%rbp)和movq %rsi, -32(%rbp)将函数参数(寄存器值)存入栈变量,实现参数到局部变量的赋值。
3.3.3 类型转换
atoi(argv[4]):argv[4]是字符串(char*),通过atoi显式转换为int。
movq -32(%rbp), %rax ; 取argv基址
addq $32, %rax ; 偏移32字节,argv[4],每个指针8字节,4×8=32
movq (%rax), %rdi ; 将argv[4]的指针传入%rdi(atoi的参数)
call atoi@PLT ; 调用atoi,返回值存入%eax(32位,匹配int类型)
体现字符串到整数的显式类型转换。
3.3.4 算术操作
自增操作:for循环中i++对应汇编指令。
addl $1, -4(%rbp) ; 对栈上的i(-4(%rbp))执行32位加法,实现i++
利用addl指令处理int 类型的自增操作,体现算术操作的硬件指令映射。
3.3.5 关系操作
1.argc != 5:通过比较 + 跳转实现条件判断。
cmpl $5, -20(%rbp) ; 比较argc(-20(%rbp))与5,设置标志位
je .L2 ; 若相等(argc == 5),跳转到循环入口.L2;否则执行错误分支
间接实现!=逻辑(不相等时执行puts和exit)。
2.i < 10:循环条件通过比较i与 9。
cmpl $9, -4(%rbp) ; 比较i(-4(%rbp))与9
jle .L4 ; 若i ≤ 9,跳转到循环体.L4,继续执行
利用cmpl和jle指令,体现关系操作的硬件级判断。
3.3.6 控制转移
1.if-else 分支(argc != 5):
cmpl $5, -20(%rbp) ; 比较argc
je .L2 ; 相等则跳转到循环(else分支)
leaq .LC0(%rip), %rdi; 否则,加载错误提示字符串到%rdi(puts的参数)
call puts@PLT ; 调用puts输出错误信息
movl $1, %edi ; 加载退出码1到%edi(exit的参数)
call exit@PLT ; 调用exit终止程序
通过条件跳转指令je实现分支逻辑,体现if-else 的控制流映射。
2.for 循环(for(i=0;i<10;i++)):
.L2: ; 循环入口(i=0)
movl $0, -4(%rbp) ; i = 0(初始化)
jmp .L3 ; 跳转到条件判断
.L4: ; 循环体(printf、sleep等)
... ; 执行循环逻辑
addl $1, -4(%rbp) ; i++(自增)
.L3: ; 条件判断
cmpl $9, -4(%rbp) ; 比较i与9
jle .L4 ; 满足i ≤ 9则跳回循环体
通过跳转指令jmp、jle实现初始化到条件到执行再到自增的循环闭环,体现for 循环的控制流拆解。
3.3.7 函数操作
1.参数传递(printf):按x86-64 调用约定,前 4 个参数依次存入%rdi、%rsi、%rdx、%rcx。
leaq .LC1(%rip), %rdi; 格式串.LC1 → %rdi(第1参数)
movq %rax, %rsi ; argv[1] → %rsi(第2参数)
movq (%rax), %rdx ; argv[2] → %rdx(第3参数)
movq (%rax), %rcx ; argv[3] → %rcx(第4参数)
call printf@PLT ; 调用printf
体现值传递(指针为地址值)和可变参数函数的调用约定。
2.函数调用(atoi、sleep、getchar):
atoi:接收argv[4](char*),返回int(存入%eax)。
sleep:接收atoi的返回值(%eax),实现进程暂停。
getchar:无参数,等待用户输入,体现无参函数调用。
所有调用通过call指令触发,依托PLT(过程链接表)处理动态链接。
3.函数返回(main):
movl $0, %eax ; 设置返回值0(int类型,存入%eax)
leave ; 销毁栈帧,恢复%rbp和%rsp
ret ; 返回到调用者(__libc_start_main)
体现函数返回值的设置和栈帧的清理。
3.4 本章小结
本章通过gcc -S命令将预处理文件hello.i编译为汇编文件hello.s,明确了编译的核心作用是将高级 C 语言转换为汇编语言;深入分析了汇编代码中数据类型、函数调用等的实现方式,理解了 x86-64 架构的函数调用约定与栈帧布局,为后续汇编阶段的机器指令分析奠定基础。
第4章 汇编
4.1 汇编的概念与作用
汇编是编译后的下一步,由汇编器(as)完成,核心作用是将汇编语言文件(.s)转换为可重定位目标文件(.o,ELF 格式)。该过程的核心是:
- 将汇编指令转换为对应的机器指令,即二进制代码
- 记录符号信息,如函数名、变量名,与重定位信息(未解决的符号引用)
- 按 ELF 格式组织文件结构,如代码段、数据段、重定位表等
可重定位目标文件(.o)包含机器指令,但部分符号,如 printf、sleep 等库函数的地址尚未确定,需后续链接阶段解决。
4.2 在Ubuntu下汇编的命令
将hello.s汇编为hello.o,-c表示仅执行汇编,不进行链接
gcc -m64 -c hello.s -o hello.o
ls -l hello.o

图4 汇编过程图
4.3 可重定位目标elf格式

图5 可重定位目标elf格式结果图
执行readelf -S hello.o,readelf -r hello.o,输出关键段信息如下:
表2 可重定位目标elf格式节选结果表
| 节名称 | 类型 | 大小 | 偏移量 | 核心作用 |
| (空) | NULL | 0 | 0 | 节头表的空条目,无实际作用 |
| .text | PROGBITS | 0x9d | 0x40 | 代码段:存储main函数的机器指令 |
| .rela.text | RELA | 0x390 | 0x11 | .text段的重定位表,记录.text中需链接时修正的符号位置 |
| .data | PROGBITS | 0 | 0xdd | 数据段:存储已初始化全局 / 静态变量 |
| .bss | NOBITS | 0 | 0xdd | 未初始化数据段:运行时分配空间 |
| .rodata | PROGBITS | 0x60 | 0x1e0 | 只读数据段:存储字符串常量 |
| .comment | PROGBITS | 0x32 | 0x112 | 存储编译器版本信息 |
| .note.GNU-stack | PROGBITS | 0x1 | 0x13c | 标记栈是否可执行 |
| .note.gnu.property | NOTE | 0x20 | 0x140 | 存储 GNU 属性信息 |
| .eh_frame | PROGBITS | 0x38 | 0x160 | 异常处理框架信息:记录函数的异常处理元数据 |
| .rela.eh_frame | RELA | 0x450 | 0x48 | .eh_frame段的重定位表:修正异常处理框架中的符号引用 |
| .symtab | SYMTAB | 0x198 | 0x8 | 符号表:存储所有符号,如main函数、puts外部函数、字符串常量 |
| .strtab | STRTAB | 0x1b0 | 0x348 | 字符串表:存储符号名的文本 |
| .shstrtab | STRTAB | 0x48 | 0x468 | 节名称字符串表:存储各节的名称 |
重定位表(.rela.text)是可重定位目标文件的核心,记录.text段中未解析的外部符号位置(需链接阶段修正)。通过readelf -r hello.o可查看其条目,见图5。
- Offset:重定位项在.text段的偏移量,即需修正的机器指令地址。
- Type:重定位类型,R_X86_64_PC32表示 32 位 PC 相对寻址重定位。
- Sym. Name:需重定位的符号名,如 printf、exit 等
- Addend:重定位偏移量调整值
4.4 Hello.o的结果解析
执行objdump -d -r hello.o

图6 反汇编结果图
objdump -d -r的功能是反汇编.text段(-d)并显示重定位信息(-r)。x86-64 架构的机器语言是字节序列,每条指令由前缀、操作码、操作数组成:其中前缀,如48表示 64 位操作,适配 x86-64 的 64 位寄存器或地址;操作码即唯一标识指令功能;操作数即指定指令的操作对象,如寄存器编码、立即数、地址偏移。
hello.s是汇编源代码,hello.o的反汇编是机器码对应的汇编指令,二者核心映射关系分为本地指令的一一对应和符号化元素的差异两类:
本地指令即机器码与汇编代码的一一映射,对于无外部符号依赖的本地指令,如栈帧操作、局部变量赋值,hello.s与反汇编完全对应:
1.栈帧初始化
hello.s代码:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
反汇编结果:
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 20 sub $0x20,%rsp
55是pushq %rbp的操作码,48 89 e5中48是 64 位前缀、89是mov操作码、e5是寄存器%rbp的编码。
2.局部变量赋值
hello.s代码:movl $0, -4(%rbp)(i=0)
反汇编结果:2f: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
机器码c7 45 fc 00 00 00 00中,c7是movl操作码,45 fc表示 “栈基址%rbp偏移 - 4 字节”,00 00 00 00是立即数0。
符号化元素即机器码与汇编代码的差异,hello.s中使用符号化标签或函数名,但汇编阶段无法确定这些符号的实际地址,因此反汇编中会出现占位操作数、重定位标记,与hello.s存在明显差异:
1.分支转移:标签到相对偏移量
hello.s代码:je .L2(若argc==5跳转到.L2)
反汇编结果:17: 74 16 je 2f <main+0x2f>
hello.s中用符号标签.L2表示跳转目标;机器码中74是je的操作码,16是相对偏移量,表示从当前指令跳转到16字节后的地址,即main+0x2f—— 汇编器将符号标签转换为指令间的相对偏移,而非保留标签。
2.函数调用:函数名到占位 0 + 重定位项
hello.s代码:call puts@PLT
反汇编结果:20: e8 00 00 00 00 callq 25 <main+0x25> 21: R_X86_64_PLT32 puts-0x4
hello.s中用puts@PLT表示函数名;器码中e8是callq的操作码,但操作数是00 00 00 00(占位值)—— 因为puts是外部函数,汇编阶段无法确定其地址,需在链接阶段修正;末尾的R_X86_64_PLT32 puts-0x4是重定位标记,告知链接器需将puts的实际地址填充到该位置,并按 PC 相对寻址调整偏移。
3.字符串引用:标签到占位 0 + 重定位项
hello.s代码:leaq .LC0(%rip), %rdi
反汇编结果:1c: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 23 <main+0x23> 1d: R_X86_64_PC32 .rodata-0x4
hello.s中用.LC0表示字符串标签;机器码中48 8d 3d是leaq的操作码,但操作数00 00 00 00是占位值;重定位标记R_X86_64_PC32 .rodata-0x4表示:链接阶段需将.rodata段中字符串的实际地址(按 RIP 相对寻址)填充到该位置。
4.5 本章小结
本章通过gcc -c命令将汇编文件hello.s转换为可重定位目标文件hello.o,明确了汇编的核心作用是将汇编指令转换为机器指令并组织为 ELF 格式;通过readelf和objdump工具分析了hello.o的 ELF 结构(代码段、重定位表、符号表等)、重定位信息及机器指令与汇编指令的映射关系,理解了可重定位目标文件的特点,即包含机器指令但存在未解决的符号引用,为后续链接阶段的地址修正奠定基础。
第5章 链接
5.1 链接的概念与作用
链接是汇编后的最后一步,由链接器(ld)完成,核心作用是将可重定位目标文件(.o)与所需的库文件合并,生成可执行文件(hello),链接后的可执行文件包含完整的机器指令与数据,可直接被操作系统加载运行。该过程的核心工作为:
- 符号解析:找到hello.o中未定义的符号在库文件中的定义
- 重定位:根据符号的实际地址,修正hello.o中重定位表记录的指令地址
- 合并段:将hello.o的代码段、数据段与库文件的对应段合并,形成可执行文件的统一段结构
5.2 在Ubuntu下链接的命令
64位系统下链接,-dynamic-linker指定动态链接器,-lc指定链接C标准库
ld -m elf_x86_64 -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o -lc /usr/lib/x86_64-linux-gnu/crtn.o -o hello
ls -l hello

图7 链接过程图
5.3 可执行目标文件hello的格式

图8 hello的elf分析图
ELF 文件头(readelf -h)核心信息
表3 文件头信息表
| 字段 | 取值或说明 |
| 类型 | EXEC(可执行文件) |
| 架构 | x86-64 |
| 入口点地址 | 0x4010f0(程序从该地址开始执行,对应启动函数_start) |
| 程序头数量 | 13(描述内存加载的段) |
| 节头数量 | 27(描述文件内的逻辑分区) |
程序头(Segment,readelf -l):程序头定义文件如何映射到内存。
表4 程序头信息表
| 段类型 | 虚拟地址 | 文件偏移 | 文件大小 | 作用 |
| LOAD | 0x400000 | 0x0 | 0x1000 | 存储可执行代码 |
| LOAD | 0x401000 | 0x1000 | 0x1250 | 存储数据 |
| DYNAMIC | 0x402500 | 0x2500 | 0x1f0 | 动态链接元数据 |
| INTERP | 0x4002e0 | 0x2e0 | 0x1c | 动态链接器路 |
节头(Section,readelf -S):节头定义文件内的功能分区。
表5节头信息表
| 节名称 | 虚拟地址 | 文件偏移 | 大小 | 作用 |
| .text | 0x4010f0 | 0x10f0 | 0x110 | 存储main及库函数指令 |
| .rodata | 0x401200 | 0x1200 | 0x60 | 存储字符串常量 |
| .data | 0x401260 | 0x1260 | 0x0 | 无已初始化全局变量 |
| .bss | 0x401260 | 0x1260 | 0x1a0 | 运行时分配未初始化数据 |
| .dynamic | 0x402500 | 0x2500 | 0x1f0 | 动态链接元数据 |
| .got.plt | 0x4026f0 | 0x26f0 | 0x30 | 全局偏移表 |
5.4 hello的虚拟地址空间
使用gdb/edb加载hello,查看本进程的虚拟地址空间各段信息。
readelf 看到的 LOAD、DYNAMIC、INTERP 等段,定义了文件如何被加载到内存的规则(地址、大小、权限)。.text、.rodata、.dynamic 等节,定义了文件内的逻辑分工(代码、数据、动态元数据)。地址随机化(ASLR)让程序基地址随机变化,提升安全性。动态链接,通过 INTERP 启动动态链接器,加载 libc 等库,解析 DYNAMIC 段的依赖,实现 “运行时绑定”。运行时扩展,堆、栈、vsyscall 是 OS 在程序启动后动态创建的区域,补充 ELF 静态结构的不足。info proc mappings 展示的运行时地址空间,完美呼应了 readelf 揭示的静态设计。

图9虚拟空间地址
5.5 链接的重定位过程分析
objdump -d -r hello

图10 链接的重定位过程
链接器解析hello.o中printf符号,找到其在 C 标准库中的定义,由于使用动态库,链接器不直接填入printf的真实地址,而是填入 PLT 条目的地址,程序运行时,PLT 条目通过全局偏移表(GOT)查找printf的真实地址(动态重定位)
5.6 hello的执行流程
hello作为动态链接的 ELF 可执行文件,其执行流程遵循操作系统加载到启动函数初始化到用户代码执行再到资源回收的生命周期:操作系统加载hello到虚拟地址空间,从 ELF 指定的入口_start开始执行;_start调用 C 标准库启动函数__libc_start_main,完成库初始化、参数解析后调用main;main函数按逻辑执行(参数判断、循环、库函数调用);程序执行完毕后,通过exit系统调用终止进程,回收资源。
表6执行流程表
| 执行阶段 | 关键函数 | 实际地址 | 调用关系 |
| 程序加载与入口 | _start | 0x555555555100 | 操作系统加载后首执行 |
| C库启动函数调用 | __libc_start_main | 0x555555557fe0 | _start通过callq调用 |
| 用户代码入口 | main | 0x5555555551e9 | __libc_start_main调用 |
| main错误分支处理 | puts@plt | 0x5555555550a0 | main(argc≠5 时)调用 |
| main错误分支终止 | exit@plt | 0x5555555550e0 | main(argc≠5 时)调用 |
| main正常分支循环初始化 | main(i=0) | 0x555555555218 | main(argc=5 时)跳转 |
| 循环内字符串输出 | printf@plt | 0x5555555550b0 | main循环内调用 |
| 循环内字符串转整数 | atoi@plt | 0x5555555550d0 | main循环内调用 |
| 循环内进程暂停 | sleep@plt | 0x5555555550f0 | main循环内调用 |
| 循环自增与条件判断 | main(i++) | 0x555555555270 | main循环内执行 |
| 循环结束等待输入 | getchar@plt | 0x5555555550c0 | main循环结束后调用 |
| 程序正常终止 | exit | 0x7ffff7e0b760 | main返回后触发 |
图11 执行流程图
5.7 Hello的动态链接分析
在 printf@plt 处设置断点,以便在 printf 函数被调用时中断程序;使用 run 命令启动程序,并传递命令行参数 a b c 2;在程序中断时,通过 GDB 命令检查 printf 函数在 GOT 表中的条目内容;使用 step 命令单步执行程序,观察程序的执行流程和 GOT 表项的变化。在 printf@plt 处设置断点后,程序启动并在第一次调用 printf 函数时中断。此时,程序还未执行 printf 函数的实际代码,处于 PLT(程序链接表)的跳转指令处。通过 print &_GLOBAL_OFFSET_TABLE_ 命令获取 GOT 表的起始地址,然后根据 printf 函数在 GOT 表中的偏移量计算出其对应的条目地址为 0x7ffff7fab020。在程序第一次中断于 printf@plt 时,检查 GOT 表项的内容为 0xf7f47780。这个地址属于 libc.so.6 共享库的地址范围,说明 printf 函数的地址已经被动态链接器解析并填入 GOT 表。使用 step 命令单步执行程序,进入 printf 函数的实际代码。再次检查 GOT 表项的内容,发现其仍然为 0xf7f47780,与之前的值相同。这表明在第一次调用 printf 函数时,动态链接器已经完成了地址解析和绑定,后续的调用将直接使用该地址,无需再次进行解析。


图12动态链接图
5.8 本章小结
本章通过ld命令将hello.o与 C 标准库链接生成可执行文件hello,明确了链接的核心作用是符号解析与重定位;分析了hello的 ELF 格式(程序头表、虚拟地址分布);通过 GDB/EDB 调试,梳理了程序的执行流程,从_start到main再到exit,与动态链接机制(PLT/GOT);理解了静态链接与动态链接的区别,以及可执行文件如何依赖共享库运行。
第6章 hello进程管理
6.1 进程的概念与作用
hello程序运行时,操作系统会为其创建一个进程,分配 CPU、内存、IO 等资源,进程执行完成后资源被回收。
6.2 简述壳Shell-bash的作用与处理流程
Shell(如 bash)是用户与操作系统内核之间的命令解释器,核心作用是接收用户输入的命令,创建进程执行命令,将执行结果反馈给用户。bash 处理./hello a b c 2命令的流程:
- 读取命令:bash 从标准输入(键盘)读取用户输入的./hello a b c 2
- 解析命令:bash 解析出命令名./hello与参数a b c 2
- 创建进程:bash 通过fork()系统调用创建一个子进程
- 加载程序:子进程通过execve()系统调用,将hello程序加载到自身地址空间,替换原 bash 的代码与数据
- 执行程序:子进程开始执行hello的_start函数,按流程运行
- 等待终止:父进程(bash)通过wait()系统调用等待子进程终止
- 反馈结果:子进程终止后,bash 输出命令执行结果(若有),并等待下一个用户命令
6.3 Hello的fork进程创建过程
图13 fork 进程创建过程
如上图所示,当 Shell(bash)需要执行hello程序时,它首先调用了clone(...)系统调用(这是fork的现代实现)。这行输出清晰地展示了父进程(Shell)创建子进程的过程。clone的返回值2764就是新创建的子进程的 PID。
6.4 Hello的execve过程
fork之后,子进程(PID 为 2764)并不会继续执行 Shell 的代码,而是立即调用execve("./hello", ...)系统调用。这个调用的作用是用hello程序的代码和数据,完全替换掉当前子进程的内存空间。从这一刻起,这个 PID 为 2764 的进程就变为hello进程,并开始执行hello的代码,打印出 "Hello a b c"。
6.5 Hello的进程执行
用户态执行,即hello进程开始运行,执行for循环,这是在用户态。切换到核心态,即当执行到printf(...)时,printf函数内部最终会调用write()系统调用。此时,CPU 从用户态切换到核心态。内核接管 CPU,负责将字符串输出到终端屏幕。返回用户态,即write完成后,CPU 从核心态切换回用户态,hello进程继续执行sleep(...)。再次切换到核心态并阻塞,即sleep(2)也是一个系统调用。CPU 再次切换到核心态。内核将hello进程的状态从运行 (R)改为可中断睡眠 (S),并将其从 CPU 的就绪队列中移除。这个进程会放弃 CPU,进入等待状态,直到 2 秒后定时器到期。进程调度,即在hello睡眠的这 2 秒内,CPU 可以去执行其他进程。当hello的时间片用完,或者它主动放弃 CPU(如sleep),操作系统的调度器就会选择另一个就绪的进程,保存hello的上下文,恢复新进程的上下文,让新进程开始运行。唤醒与恢复,即2 秒后,hello的定时器到期,内核将其状态改回就绪 (R)。当调度器再次选中它时,恢复它的上下文,hello从sleep返回的地方继续执行for循环。

图14 hello进程执行
6.6 hello的异常与信号处理

图15 hello的异常与信号处理
- 在程序运行时,按下 Ctrl + C。程序会立即停止运行,并返回到 Shell 提示符。Ctrl+C组合键会向前台进程发送一个SIGINT(Interrupt,中断信号)。对于hello程序,它没有自定义处理SIGINT信号。因此,它会执行该信号的默认动作——终止进程。
- 在程序运行时,按下 Ctrl + Z。程序被暂停了。[1]是作业号(Job ID)。+表示这是当前默认的作业。Suspended表示进程状态为暂停。
- 直接输入 jobs, jobs命令列出了当前终端会话中所有的作业。
- 输入 ps -ef | grep hello, 找到hello进程,记下它的PID(第二列,6148)
- 输入 pstree -p | grep hello,输出|-hello(6148),验证了hello是当前 Shell(bash)的子进程。
- 输入fg,程序会从暂停的地方继续运行。
- 在hello运行时,按 Ctrl + Z。发送SIGKILL信号:输入 kill -9 6148,kill 命令用于发送信号。-9 是信号SIGKILL的编号。SIGKILL是一个特殊的信号,进程无法捕获或忽略它,只能被强制终止。运行 jobs,会看到作业被标记为Killed。
6.7本章小结
本章通过对hello程序从创建到执行,再到异常处理的全过程分析,深入理解了 Linux 系统中进程管理的核心机制。通过fork()系统调用,一个进程可以创建一个几乎完全相同的子进程。子进程随后通过execve()加载并执行新的程序映像,完成从复制品到独立程序的转变。进程在操作系统中并非一直占有 CPU,而是由调度器根据时间片和进程状态进行管理。进程在执行普通代码时处于用户态,执行系统调用时切换到核心态,这种隔离保证了系统的稳定性和安全性。信号是 Linux 系统中事件通知和进程间通信的重要手段。Ctrl+C、Ctrl+Z等操作本质上都是向进程发送特定的信号。进程可以选择忽略、捕获并处理或执行默认动作来响应信号,这为程序提供了灵活的异常处理能力。jobs, fg, bg等命令构成了 Shell 的作业控制机制,允许用户方便地管理前台和后台的进程。通过本章的学习,我不仅掌握了ps, kill, strace等实用工具的使用,更重要的是建立了对操作系统底层工作原理的直观认识,为后续深入学习打下了坚实的基础。
第7章 hello的存储管理
7.1 hello的存储器地址空间
- 逻辑地址 (Logical Address):程序编译后,代码和变量在程序内部的地址。比如main函数在文件里的偏移,一个全局变量相对于文件开头的位置。这是链接器关心的地址。
- 虚拟地址 (Virtual Address):程序运行时,CPU 看到的地址。每个进程都有自己独立的、从0x0000000000000000到0x7fffffffffffffff的巨大地址空间。
- 物理地址 (Physical Address):真实的硬件内存地址,是内存条上实实在在的编号。只有操作系统内核(通过页表)才能将虚拟地址翻译成物理地址。
- 线性地址 (Linear Address):在现代 64 位 Linux 系统中,段式管理被极大简化,逻辑地址经过段转换后得到的地址就是线性地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 64 位 Linux 系统中,为了简化内存管理,操作系统将所有用户进程的代码段(CS)、数据段(DS)等的段描述符基地址都设置为 0。因此,逻辑地址经过段式变换后得到的线性地址与逻辑地址完全相同。这意味着,段式管理在现代 Linux 中更多是一种兼容机制,内存管理的核心任务由后续的页式管理承担。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是 Linux 内存管理的核心。当hello进程访问变量i的虚拟地址0x7fffffffe3ac时,CPU 的 MMU 会将其分解。操作系统通过查询hello进程的页表,找到该虚拟页对应的物理页框,最终计算出真实的物理地址。这个过程对程序是完全透明的,但它是现代操作系统能够实现虚拟内存、内存保护和高效利用物理内存的基础。
7.4 TLB与四级页表支持下的VA到PA的变换
为了加速虚拟地址到物理地址的转换,CPU 在 MMU 中集成了 TLB(地址转换旁路缓冲器),它缓存了最近使用的页表项。当hello进程连续访问栈上的变量时,TLB 会大幅提高转换效率。同时,64 位 Linux 采用四级页表结构来管理庞大的虚拟地址空间,在保证灵活性的同时节省了页表本身占用的内存。
7.5 三级Cache支持下的物理内存访问
在地址转换得到物理地址后,CPU 并不是直接去访问慢速的主内存,而是先访问速度快得多的 Cache。hello程序中的for循环和printf调用体现了良好的时间局部性和空间局部性,使得数据和指令能很好地被 Cache 缓存,从而显著减少了 CPU 等待内存数据的时间。
7.6 hello进程fork时的内存映射
当 Shell 通过fork()创建hello进程时,为了提高效率和节省内存,操作系统采用了写时复制(COW)技术。初始时,父子进程共享相同的物理内存页。只有当任一进程试图修改数据时,操作系统才会为该页创建一个副本。这种机制极大地优化了fork的性能。
7.7 hello进程execve时的内存映射
execve()是进程变身的关键。它会彻底清除当前进程的旧内存映像,并根据新程序hello的 ELF 文件格式,通过mmap系统调用重新建立代码段、数据段等内存映射。最终,CPU 的执行流从新程序的入口点开始,一个全新的hello进程诞生了。
7.8 缺页故障与缺页中断处理
hello程序在启动时,其代码和数据并不是一次性全部加载到物理内存的。当 CPU 首次执行某段代码或访问某个变量时,如果对应的物理页尚未加载,就会触发缺页故障。操作系统内核会处理这个中断,从磁盘读取数据并建立映射。这个 “按需加载” 的机制使得程序可以运行在比自身尺寸小的物理内存上,极大地提高了内存利用率。
7.9动态存储分配管理
hello中的printf函数为了格式化输出,会调用malloc动态分配内存缓冲区。动态内存管理由 C 标准库实现,其核心是高效地管理堆空间。常用的策略包括空闲链表、伙伴系统等,它们共同目标是减少内存碎片,提高分配和释放的效率。
7.10本章小结
本章通过对hello程序存储管理全过程的分析,揭示了现代操作系统内存管理的核心机制。从程序编译链接产生的逻辑地址,到运行时操作系统提供的虚拟地址空间,再到通过页表、TLB 和 Cache 将虚拟地址最终转换为物理地址的复杂过程,每一步都体现了硬件与软件的协同设计。写时复制(COW)优化了进程创建,execve实现了程序的动态加载,而缺页中断则是虚拟内存 “按需加载” 思想的具体体现。动态内存管理则为程序提供了灵活使用内存的能力。这些机制共同构成了一个高效、安全且抽象的内存管理体系,使得hello这样一个简单的程序能够在复杂的现代计算机系统中高效、稳定地运行。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
1.设备的模型化:文件
在 Linux 眼中,所有的输入输出设备都被看作是文件。键盘是一个文件,显示器也是一个文件。这些设备文件通常存放在/dev目录下。
2.设备管理:Unix I/O 接口
因为设备被抽象成了文件,所以操作系统就可以提供一套统一的接口来操作它们。这个接口就是Unix I/O 接口,主要包括open, read, write, close等函数。这意味着,向显示器输出字符串和向一个普通文件写入内容,使用的是同一个write函数,操作系统内核会在底层处理好细节。
Linux 系统通过一切皆文件的思想,将键盘、显示器等 I/O 设备抽象为文件(如/dev/tty)。这种设计使得所有设备都可以通过一套统一的 Unix I/O 接口(read, write等)来操作。例如,使用echo命令向/dev/tty写入数据,就会在终端上显示出来,这直观地体现了设备与文件操作的统一性。
8.2 简述Unix IO接口及其函数
基于一切皆文件的抽象,Linux 提供了统一的 Unix I/O 接口。hello程序中的printf函数最终会调用write系统调用,向标准输出(文件描述符为 1)写入数据。而getchar函数则会调用read系统调用,从标准输入(文件描述符为 0)读取数据。这种统一的接口极大地简化了程序设计。
8.3 printf的实现分析
printf的实现是一个典型的从高层应用到底层硬件的层层调用过程。首先,vsprintf在用户态完成字符串格式化;然后,通过write系统调用陷入内核态;接着,内核的终端驱动程序将 ASCII 码转换为像素信息并写入 VRAM;最后,显示芯片从 VRAM 读取数据并驱动显示器显示。strace的输出证实了write系统调用的存在,它是连接用户程序和内核驱动的桥梁。
通过 GDB 调试,可以清晰地看到其内部的函数调用链。当main函数调用printf后,程序会进入 C 库的__printf函数,该函数随后调用核心的格式化函数__vfprintf_internal。__vfprintf_internal负责解析格式化字符串并生成最终的输出内容。内容生成后,C 库的 I/O 子系统(如_IO_new_file_xsputn和_IO_new_do_write等函数)会接管,负责管理输出缓冲区。最后,__GI___libc_write函数被调用,它通过执行syscall指令触发系统调用,将 CPU 从用户态切换到内核态,请求操作系统将缓冲区中的 12 个字节数据("Hello a b c\n")写入到标准输出设备(文件描述符为 1)。这一完整的调用栈(main -> __printf -> __vfprintf_internal -> ... -> __libc_write)完美地揭示了printf的实现机制。

图16 printf的实现
8.4 getchar的实现分析
getchar的实现依赖异步中断 + 内核缓冲区的机制:当hello的main函数调用getchar()时,会逐层进入 C 库的 I/O 函数,最终触发read(0, ...)系统调用(strace结果证实了这一点);若键盘未输入,read会让进程阻塞;此时 CPU 可以执行其他任务,直到按下键盘,硬件会触发键盘中断,内核的中断处理程序将按键的 ASCII 码存入键盘缓冲区;内核唤醒hello进程,read从缓冲区读取字符并返回,getchar将字符传递给main,程序继续执行。这一过程完美体现了异步中断驱动 I/O的核心思想。
图17 getchar的分析
8.5本章小结
本章通过分析hello程序中的printf和getchar,系统地阐述了 Linux 的 I/O 管理机制。“一切皆文件” 的抽象是 Linux I/O 管理的基石,它使得所有设备都能通过统一的 Unix I/O 接口进行操作,极大地简化了编程模型。printf的实现体现了从用户态的 C 库函数(vsprintf),到系统调用(write),再到内核态的驱动程序(终端驱动),最终到硬件(VRAM 和显示器)的完整调用链。getchar则展示了硬件中断在 I/O 中的核心作用。键盘中断异步地唤醒了阻塞的read系统调用,实现了高效的事件驱动式输入。从高层的字符串格式化到底层的像素点渲染,从同步的函数调用到异步的硬件中断,本章内容覆盖了现代计算机系统 I/O 管理的方方面面,揭示了日常操作背后隐藏的复杂而精妙的工作原理。
结论
hello.c 程序的从静态到动态之旅,是计算机系统核心机制协同运作的缩影。静态阶段,预处理通过头文件展开、宏替换生成完整源代码文件 hello.i,为编译扫清文本冗余;编译将高级 C 语言转换为适配 x86-64 架构的汇编代码 hello.s,完成语法语义校验与指令映射;汇编将汇编指令翻译为机器指令,封装为含重定位信息的 ELF 目标文件 hello.o,记录未解析的符号引用;链接通过符号解析与地址重定位,合并 C 标准库生成可执行 ELF 文件 hello,构建起可被系统加载的完整指令与数据结构。动态执行阶段,Shell 通过 fork 创建子进程,execve 替换进程映像加载 hello 程序,操作系统为其分配独立虚拟地址空间;进程执行时,CPU 在用户态与核心态间切换,调度器依据时间片与进程状态分配 CPU 资源,信号机制处理中断与异常;存储管理层面,MMU 借助四级页表与 TLB 加速虚拟地址到物理地址的转换,三级 Cache 优化内存访问速度,写时复制与缺页中断实现内存资源的高效利用;IO 交互中,“一切皆文件” 的抽象使 printf、getchar 通过统一的 Unix I/O 接口与内核驱动交互,中断机制保障输入输出的异步响应。整个过程形成 “0 到 0” 的生命周期闭环,资源按需分配、用完即回收,体现系统设计的严谨性。
计算机系统的设计核心是分层抽象与协同优化。从应用层的 C 语言代码到硬件层的物理内存,每一层都通过抽象屏蔽底层复杂性:高级语言屏蔽汇编指令细节,虚拟内存屏蔽物理内存分配细节,文件抽象屏蔽 IO 设备差异,这种分层设计降低了开发难度,提升了系统兼容性。同时,软硬件的深度协同是高效运行的关键:CPU 的 TLB 与 Cache 硬件加速配合操作系统的页表管理策略,使内存访问效率倍增;中断控制器与内核中断处理程序协同,实现 IO 操作的异步响应,这种硬件提供能力、软件优化策略的模式,最大化发挥了系统性能。此外,高效利用资源是系统设计的核心目标。写时复制技术避免 fork 时的冗余内存拷贝,缺页中断实现按需加载,动态链接减少可执行文件体积,这些机制从不同维度优化资源占用,使有限的硬件资源能支撑多进程并发运行。而标准化的接口设计(如 x86-64 调用约定、Unix I/O 接口)则保障了组件间的兼容性与可扩展性,使不同编译器、库文件、设备驱动能无缝协作。
基于本报告,有两点优化方向:一是动态链接的预加载优化,针对高频调用的库函数(如 printf),可在程序启动时提前完成地址解析与绑定,减少首次调用时的重定位开销,尤其适用于循环中频繁调用库函数的场景;二是存储管理的智能缓存策略,结合程序的内存访问特征(如 hello 程序的循环局部性),动态调整 Cache 替换算法,对高频访问的栈区、代码段采用优先级缓存,进一步提升内存访问命中率。此外,可探索 “生命周期可视化工具” 的开发,将预处理、编译、进程调度、内存映射等过程通过图形化界面实时展示,结合中间产物的结构解析,帮助学习者更直观地理解系统底层工作机制,弥补当前工具多为命令行、可视化程度低的不足。这种工具不仅可用于教学,也能为系统开发与调试提供更高效的分析手段。
附件
表7 文件及说明
| 文件名 | 生成阶段 | 作用说明 |
| hello.c | 源文件阶段 | 程序原始源代码,包含 main 函数及 printf、getchar 等核心逻辑,是整个生命周期的起点 |
| hello.i | 预处理阶段 | 预处理后的源代码文件,包含头文件(stdio.h、stdlib.h)展开、宏替换、注释删除后的完整代码,为编译阶段提供输入 |
| hello.s | 编译阶段 | 汇编语言源代码文件,由 hello.i 转换而来,记录 x86-64 架构的汇编指令,体现高级语言到机器指令的中间映射 |
| hello.o | 汇编阶段 | 可重定位目标文件(ELF 格式),包含机器指令、符号表与重定位表,未解决外部库函数(如 printf)的地址引用 |
| hello | 链接阶段 | 可执行目标文件(ELF 格式),已完成符号解析与重定位,合并 C 标准库相关代码,可被操作系统直接加载运行 |
参考文献
[1] 兰德尔·E.布莱恩特(Randal E. Bryant),大卫·R.奥哈拉伦(David R. O'Hallaron). 深入理解计算机系统(第3版)[M]. 龚奕利,贺莲 译. 北京:机械工业出版社,2016.
[2] 唐朔飞. 计算机组成原理(第2版)[M]. 北京:高等教育出版社,2008.
[3] 亚伯拉罕·西尔伯沙茨(Abraham Silberschatz),彼得·巴雷特(Peter Baer Galvin),格雷格·加格诺(Greg Gagne). 计算机操作系统概念(第9版)[M]. 郑扣根,译. 北京:机械工业出版社,2021.
[4] 戴维·A.帕特森(David A. Patterson),约翰·L.亨尼斯(John L. Hennessy). 计算机组成与设计:硬件/软件接口(第5版·ARM版)[M]. 包云岗,译. 北京:机械工业出版社,2015.
[5] 王利涛. 嵌入式C语言自我修养:从芯片、编译器到操作系统[M]. 北京:人民邮电出版社,2020.
转载自CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2501_93445624/article/details/156573962







