本报告以 “Hello's P2P:From Program to Process” 为主题,深入剖析了hello.c程序在现代计算机系统中的完整生命周期,涵盖预处理、编译、汇编、链接、进程管理、存储管理及 IO 管理等核心环节,揭示了从源代码到进程运行的底层机制。
在程序转换阶段,预处理通过展开宏定义、包含头文件等操作生成纯净 C 源码hello.i;编译阶段将其转换为汇编代码hello.s,详细解析了函数调用、条件判断等高级语法与汇编指令的映射关系;汇编阶段生成可重定位目标文件hello.o,分析了 ELF 格式中段结构与重定位信息;链接阶段通过符号解析、地址分配和重定位,将目标文件与库文件合并为可执行文件hello,并探讨了动态链接机制。
进程管理方面,阐述了 Shell(Bash)通过fork创建子进程、execve加载新程序的流程,分析了进程执行中的用户态 / 内核态切换、时间片轮转调度、信号处理(如Ctrl+C触发SIGINT)及进程退出时的资源回收机制。
存储管理部分,结合 Intel x86_64 架构,解析了逻辑地址到线性地址的段式管理、线性地址到物理地址的页式管理过程,探讨了 TLB 与四级页表的地址翻译机制、三级 Cache 的物理内存访问流程,以及fork时的写时复制(COW)技术和execve的内存重映射机制,还分析了缺页故障的处理流程。
IO 管理聚焦于 Linux 系统的 IO 设备管理方法,解析了printf和getchar的实现原理,揭示了用户空间与内核空间通过系统调用交互的机制。
通过对hello程序全生命周期的系统性分析,本报告全面展示了计算机系统各子系统(如处理器、内存、IO 设备)的协同工作流程,为理解程序执行机制、系统性能优化及故障调试提供了理论与实践依据。
关键词:程序生命周期;编译原理;进程管理;存储管理;系统调用。
目 录
2.1 预处理的概念与作用............................................................................. - 5 -
2.2在Ubuntu下预处理的命令.................................................................. - 5 -
2.3 Hello的预处理结果解析...................................................................... - 5 -
3.1 编译的概念与作用................................................................................. - 6 -
3.2 在Ubuntu下编译的命令..................................................................... - 6 -
3.3 Hello的编译结果解析.......................................................................... - 6 -
4.1 汇编的概念与作用................................................................................. - 7 -
4.2 在Ubuntu下汇编的命令..................................................................... - 7 -
4.3 可重定位目标elf格式......................................................................... - 7 -
4.4 Hello.o的结果解析.............................................................................. - 7 -
5.1 链接的概念与作用................................................................................. - 8 -
5.2 在Ubuntu下链接的命令..................................................................... - 8 -
5.3 可执行目标文件hello的格式............................................................ - 8 -
5.4 hello的虚拟地址空间.......................................................................... - 8 -
5.5 链接的重定位过程分析......................................................................... - 8 -
5.7 Hello的动态链接分析.......................................................................... - 8 -
第6章 hello进程管理........................................................................... - 10 -
6.1 进程的概念与作用............................................................................... - 10 -
6.2 简述壳Shell-bash的作用与处理流程............................................. - 10 -
6.3 Hello的fork进程创建过程............................................................. - 10 -
6.4 Hello的execve过程......................................................................... - 10 -
6.6 hello的异常与信号处理.................................................................... - 10 -
第7章 hello的存储管理........................................................................ - 11 -
7.1 hello的存储器地址空间.................................................................... - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理.................................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理............................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换..................................... - 11 -
7.5 三级Cache支持下的物理内存访问.................................................. - 11 -
7.6 hello进程fork时的内存映射.......................................................... - 11 -
7.7 hello进程execve时的内存映射...................................................... - 11 -
7.8 缺页故障与缺页中断处理................................................................... - 11 -
7.9动态存储分配管理............................................................................... - 11 -
第8章 hello的IO管理......................................................................... - 13 -
8.1 Linux的IO设备管理方法.................................................................. - 13 -
8.2 简述Unix IO接口及其函数............................................................... - 13 -
8.4 getchar的实现分析............................................................................ - 13 -
第1章 概述
1.1 Hello简介
本次大作业以“Hello's P2P:From Program to Process”为主题,深入剖析一个简单的 hello.c 程序在现代计算机系统中的编译、链接、执行与运行机制。该程序由用户提供命令行参数,在终端每隔若干秒输出指定格式的问候信息。尽管程序逻辑简单,其背后体现了从源代码到最终进程运行的全流程,涉及预处理、编译、汇编、链接、进程创建、内存映射、系统调用、中断处理等核心概念。
在整个流程中,Hello程序由 Bash Shell 启动,依次经过预处理器、编译器、汇编器、链接器,再由操作系统加载到内存形成一个进程。进程通过系统调用使用 CPU、内存、IO 设备等资源,体现了“从程序到进程”的完整生命周期,是计算机系统各子系统协作的缩影。
1.2 环境与工具
本次实验与分析主要在如下软硬件环境中进行:
操作系统:Ubuntu 22.04 LTS (64位)
编译器:GCC 11.4.0
调试器:GDB、EDB(可选)
反汇编工具:objdump, readelf
文本编辑器:Vim / VSCode
截图工具:gnome-screenshot 或 Flameshot
硬件平台:x86_64 架构,Intel Core i7-10510U,16GB RAM
1.3 中间结果
在从源文件到执行过程中,生成了如下关键中间文件:
文件名 说明
hello.i 预处理后的C源文件
hello.s 编译生成的汇编代码
hello.o 汇编生成的可重定位目标文件
hello 链接后的可执行文件
hello运行截图.png 程序运行终端截图
这些中间文件分别代表源代码转化过程中的不同阶段,对于分析其底层机制具有重要意义。
1.4 本章小结
本章介绍了 Hello 程序的背景与意义,列出了使用的工具环境,并归纳了整个过程中产生的重要中间产物。后续章节将分别深入剖析每一个阶段的技术细节与系统机制,以揭示一个C程序从源代码转变为可执行进程的全过程。
第2章 预处理
2.1 预处理的概念与作用
C语言的预处理是编译的第一阶段,其主要作用包括:
展开宏定义(如 #define)
处理头文件的包含(#include)
条件编译(如 #ifdef)
删除注释等
其结果是生成 .i 文件,即纯净、完整的C语言源代码,为后续编译做好准备。预处理并不会生成任何机器码,仅在源文件层面完成文本替换和组织。
2.2在Ubuntu下预处理的命令
在 Ubuntu 环境下使用 GCC 的 -E 参数进行预处理。对于 hello.c 文件,预处理命令如下:
gcc -m64 -no-pie -fno-stack-protector -fno-PIC -E hello.c -o hello.i

图 1预处理命令截图
2.3 Hello的预处理结果解析
打开 hello.i 文件可以看到:#include <stdio.h> 被展开为 stdio.h 的所有内容;
程序中的注释被完全移除;所有宏(如 NULL)已被替换;没有任何函数体、流程或行为发生改变,仅是代码“扩展”而非“转换”。
使用如下命令查看部分预处理输出并截图:
head -n 50 hello.i

图 2预处理结果截图
2.4 本章小结
本章介绍了预处理阶段的主要任务与实现方式。通过对 hello.c 进行预处理,我们获得了 .i 文件,并验证了宏展开、注释移除与头文件替换等特性。预处理是编译阶段的基础,为后续的语法分析与代码生成提供了准备。
第3章 编译
3.1 编译的概念与作用
在 C 语言程序的构建过程中,编译阶段的任务是将预处理后的 .i 文件翻译为汇编代码 .s 文件。该过程由编译器(如 gcc 的内部前端)完成,具体操作包括词法分析、语法分析、语义分析、符号表建立、优化与汇编代码生成等。
编译器将高级语言翻译为机器相关的中间表示(IR)或直接生成汇编语言,这是程序从“源代码”向“可执行体”转换的重要桥梁。
3.2 在Ubuntu下编译的命令
在终端中输入以下命令将 .i 文件编译为汇编代码。
gcc -m64 -no-pie -fno-stack-protector -fno-PIC -S hello.i -o hello.s
![]()
图 3编译命令截图
3.3 Hello的编译结果解析
打开 hello.s 文件可以看到,C 语言的语句被逐行转换为汇编指令。以下是一些典型的数据类型与操作在 hello.s 中的体现与分析
3.3.1 函数调用与参数传递
源代码片段:printf("Hello %s %s %s\n", argv[1], argv[2], argv[3]);
在 x86-64 架构中,函数参数通常通过以下寄存器传递:
- 第1个参数 → rdi
- 第2个参数 → rsi
- 第3个参数 → rdx
- 第4个参数 → rcx
- 第5个参数 → r8
- 第6个参数 → r9
汇编代码:
movq -32(%rbp), %rax ; argv 存储在栈帧偏移 -32
addq $24, %rax
movq (%rax), %rcx ; argv[3] -> rcx
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx ; argv[2] -> rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi ; argv[1] -> rsi
movl $.LC1, %edi ; 格式字符串 -> edi
movl $0, %eax ; 变参规则要求 eax=0
call printf
代码分析:
- argv 是一个 char* 指针数组。由于它本身作为参数传入 main,被保存为 -32(%rbp)。
- 每次访问 argv[i] 实际就是取 argv + i * 8,因此对应加上 8, 16, 24。
- 加载字符串指针后依次放入 rsi、rdx、rcx,而格式字符串常量 .LC1 放入 edi。
- eax 被清零是为遵守浮点参数计数规则,即使当前没有传递浮点类型参数。
- call printf 是真正的函数调用点,它将跳转到 printf 所在位置。
本段完整展示了从 C 源代码到汇编中函数参数的拆解、地址计算、寄存器装载与调用跳转过程,反映了现代编译器遵守的标准调用约定。
3.3.2 条件语句和跳转实现
源代码:if (argc != 5) {
printf("用法: Hello 2023111785 姚鹏举 18645267050 0!\n");
exit(1);
}
汇编代码:
cmpl $5, -20(%rbp)
je .L2
movl $.LC0, %edi
call puts
movl $1, %edi
call exit
汇编分析:
- argc 参数被保存为 -20(%rbp),为 32 位整数;
- cmpl $5, -20(%rbp):将常数 5 与 argc 进行比较;
- je .L2:如果相等,跳过错误处理部分,进入 .L2(主程序逻辑);
- 若不等于 5:
- 将错误提示字符串 .LC0 地址装入 edi;
- 调用 puts 打印错误信息;
- 调用 exit(1) 退出程序。
这一段体现了 C 中条件判断的底层实现方式:比较值和目标;通过条件跳转控制执行路径;不同条件操作(如 !=, <, >=)会映射为不同跳转指令,如 jne, jl, jge 等。
3.3.3 变量定义与循环结构
源代码如下:
for(i=0;i<10;i++){
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
sleep(atoi(argv[4]));
}
变量定义与赋初值
汇编代码:movl $0, -4(%rbp)
分析:i 是一个局部变量,存放在栈帧中偏移 -4(%rbp) 处。使用 movl $0 对其进行初始化,即对应 int i = 0这一步。
循环控制结构
汇编代码:
jmp .L3 ; 跳过循环体,先判断条件
.L4: ; 循环体开始
... ; 循环体代码:printf + sleep
addl $1, -4(%rbp) ; i++
.L3:
cmpl $9, -4(%rbp) ; i <= 9 ?
jle .L4 ; 若小于等于,跳回循环体
分析:
标签 .L3 是循环判断部分,.L4 是循环体开始;
条件判断使用 cmp + jle;
- cmpl $9, -4(%rbp) → 比较 i 和 9
- jle .L4 → 若 i ≤ 9,跳到循环体
自增语句 i++ 被翻译为 addl $1, -4(%rbp)
3.3.4 嵌套函数调用sleep(atoi(argv[4]))
这是一个经典的函数嵌套调用。它的执行顺序是:
- 先调用 atoi(argv[4]) 把字符串转为整数;
- 再把这个整数传入 sleep 函数,控制延时秒数。
对应汇编代码:
movq -32(%rbp), %rax
addq $32, %rax
movq (%rax), %rax ; 取 argv[4]
movq %rax, %rdi ; 参数传入 rdi
call atoi ; 调用 atoi(argv[4])
movl %eax, %edi ; 将 atoi 返回值传入 edi
call sleep ; 调用 sleep()
分析说明:
- -32(%rbp) 是 argv;
- argv[4] = *(argv + 4) = *(argv + 32) → 因为每个指针是8字节,偏移 4 * 8 = 32;
- 将 argv[4] 放入 rdi,满足 atoi(char*) 的调用规则;
- atoi 返回 int,结果保存在 eax;
- 将 eax 放入 edi,传入 sleep(int);
- 执行 call sleep。
这个过程清晰地体现了嵌套函数调用的分步处理机制:
- 编译器不会试图把两个函数合并;
- 它先调用内层函数,将返回值存在通用寄存器(eax);
- 再将结果作为参数传入外层函数
这段嵌套调用展示了编译器在处理表达式求值、函数返回值利用、函数嵌套结构展开方面的细致逻辑。嵌套函数调用被线性化为先后两次调用,每次严格使用约定的寄存器传递参数与返回值,是理解函数调用机制的关键实例。
3.3.5栈帧建立与函数返回
当程序进入 main 函数时,编译器为其生成了典型的函数入口和栈帧建立语句:
main:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
含义解释:
- pushq %rbp:保存调用者的基址指针;
- movq %rsp, %rbp:建立当前函数的基准栈帧;
- subq $32, %rsp:为本函数的局部变量分配32字节栈空间。
函数返回语句(函数“结尾”):
call getchar
movl $0, %eax
leave
ret
含义解释:
- call getchar:等待用户输入,使程序暂停;
- movl $0, %eax:设置返回值为 0;
- leave:函数清理工作,等价于:
mov %rbp, %rsp
pop %rbp
- ret:从函数中返回,跳转回调用该函数的位置。
3.3.6 数据结构与局部变量分析
本节从“数据”的角度,分析 hello.c 程序中涉及的变量、指针、数组和字符串常量的类型定义、存储位置以及在汇编中的体现,特别关注局部变量在栈帧中的分配和使用方式。
一、数据结构与类型维度分析
- 整型变量 i
- 类型:int
- 存储位置:主函数的栈帧 [rbp-4]
- 用途:控制 for 循环
- 汇编操作:
movl $0, -4(%rbp) ; i = 0
addl $1, -4(%rbp) ; i++
cmpl $9, -4(%rbp) ; 判断 i <= 9
- main 函数参数 argc 和 argv
- 类型:int argc, char *argv[](即 char**)
- 初始通过寄存器 %edi 和 %rsi 传入,被保存为局部变量:
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
- 指针数组访问(argv[i])
- 类型:char* 指针
- 表现形式:通过偏移地址访问,例如:
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rsi ; 取 argv[1]
- 本质是数组解引用:argv[1] = *(argv + 1)
- 字符串常量(只读数据)
- 示例:"Hello %s %s %s\n"、错误提示字符串
- 编译器统一存储在 .rodata 段:
.LC1:
.string "Hello %s %s %s\\n"
- 汇编中作为地址使用:
movl $.LC1, %edi
二、局部变量结构与栈帧分析
| 变量名 | 类型 | 栈帧偏移 | 汇编用途描述 |
| i | int | -4(%rbp) | 控制 for 循环,自增、比较、初始化 |
| argc | int | -20(%rbp) | 参数计数,判断是否为 5 |
| argv | char** | -32(%rbp) | 指向字符串数组,用于取出各参数 |
这些局部变量均分配在栈帧中,函数进入时通过以下语句建立栈帧并分配空间:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp ; 为局部变量分配栈空间
函数退出时通过 leave 与 ret 指令恢复现场并返回。
3.4 本章小结
本章从源文件经过预处理后的 .i 文件出发,详细分析了 C 程序在编译阶段被转换为汇编代码 .s 文件的全过程。通过对实际生成的 hello.s 文件进行解读,我们深入了解了编译器如何将函数调用、条件判断、循环结构、嵌套表达式等高级语法翻译为底层的汇编指令。
本章内容包括:
- 函数调用参数的寄存器传递规则;
- 条件语句通过 cmp 与跳转指令实现的控制流程;
- for 循环拆解为初始化、判断、自增与跳转标签;
- sleep(atoi(...)) 等嵌套函数调用的顺序拆解;
- 栈帧的建立与清理过程;
- 局部变量、整型参数、指针数组和字符串常量等数据结构在栈与内存中的存储与访问机制。
通过这些内容的讲解,不仅揭示了 C 源码与汇编指令之间的映射关系,也展示了现代编译器生成高效汇编的规律性。理解这一过程是深入掌握计算机系统工作机制、调试程序及分析性能瓶颈的重要基础。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编阶段是将 .s 汇编语言源文件翻译为 .o 机器语言目标文件的过程,它由汇编器(如 as)完成。汇编器会将每一条汇编指令转化为对应的机器指令(十六进制编码),并将符号、段等信息封装为 ELF(Executable and Linkable Format)格式的可重定位目标文件。
该目标文件尚未具备运行能力,但包含完整的代码段、数据段、符号表与重定位信息,准备进入链接阶段。该过程对深入理解指令编码与链接机制具有重要意义。
4.2 在Ubuntu下汇编的命令
使用 gcc 进行汇编的命令如下:
gcc -m64 -no-pie -fno-stack-protector -fno-PIC -c hello.s -o hello.o
- -c 表示仅编译为目标文件,不进行链接;
- -m64 指定 64 位系统;
- -no-pie 和 -fno-PIC 关闭位置无关代码,方便观察地址;
- hello.o 是生成的目标文件,二进制格式,不可直接阅读。
![]()
图 4汇编命令截图
4.3 可重定位目标elf格式
目标文件 hello.o 是一种 ELF 格式文件。可以使用 readelf 查看其结构:readelf -S hello.o

图 5 hello.o节区
重点节区解析:
- .text
可执行代码段,包含 main 函数的所有指令,是目标文件中最关键的一部分。 - .rodata
存储 "Hello %s %s %s\n"、"用法: Hello ..." 等字符串,是常量数据,运行时不可更改。 - .rela.text
包含对外部函数(如 printf, sleep 等)和常量地址的重定位条目,供链接器处理。 - .symtab / .strtab
.symtab 是符号表,记录所有变量、函数等符号的位置信息; .strtab 是用于存储这些符号名字的字符串池。 - .eh_frame / .rela.eh_frame
提供异常处理所需的元信息,支持 gcc 的栈展开与调试机制。
未使用段(.data / .bss)
本程序中没有使用全局变量或静态变量,因此 .data 和 .bss 段的大小均为 0,是“空段”。
4.4 Hello.o的结果解析
使用以下命令对目标文件 hello.o 进行反汇编并查看重定位项:
objdump -d -r hello.o
该命令输出 .text 段的机器指令(十六进制)与汇编指令,并指出了需要在链接阶段修正的地址。本节将结果与前面第3章中手写的汇编源代码 hello.s 进行对照,分析汇编语言与机器语言之间的映射关系,重点探讨机器语言的构成与操作数表达方式,特别是在函数调用、条件跳转等控制转移指令中的差异。
- 函数调用
在汇编代码hello.s中,函数调用直接标上了函数的名称。而在反汇编代码中,call目标地址是当前指令的下一条指令地址。这是因为hello.c中调用的函数都是共享库(如stdio.h,stdlib.h)中的函数,如puts,exit,printf,atoi,sleep等,需要等待链接器进行链接之后才能确定响应函数的地址。因此,机器语言中,对于这种不确定地址的调用,会先将下一条指令的相对地址全部设置为0,然后在.rel.text节中为其添加重定位条目,等待链接时确定地址。

图 6 getchar汇编

图 7 getchar反汇编

图 8hello.o反汇编
- 分支跳转
在汇编语言中使用跳转指令只需要在后面加上标识符便可以跳转到标识符所在的位置,而机器语言经过翻译直接通过长度为一个字节的PC(Program Counter,程序计数器)相对地址进行跳转。

图 9 跳转汇编代码

图 10 跳转反汇编代码
- 立即数部分
原本十进制的立即数都变成了二进制。输出的文件是二进制的,对于objdump来说,直接将二进制转化为十六进制比较方便,也有利于程序员以字节为单位观察代码。

图 11 立即数汇编

图 12 立即数反汇编
4.5 本章小结
本章对汇编的概念、作用、可重定向目标文件的结构及对应反汇编代码等进行了较为详细的介绍。经过汇编阶段,汇编语言代码转化为机器语言,生成的可重定位目标文件(hello.o)为随后的链接阶段做好了准备。完成本章内容的过程加深了我对汇编过程、ELF格式以及重定位的理解。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接作为编译流程的收官环节,其核心功能是将编译器生成的中间目标文件整合成完整的可执行程序或共享库组件。具体而言,这一过程包含以下关键操作:
(1)符号解析:链接器依据目标文件内的符号表信息,对程序中所有函数与变量的引用关系进行解析,确保各类外部调用(如标准库函数、跨文件变量引用)精准映射到正确的内存地址空间。
(2)地址分配:链接器按照程序的虚拟内存布局规则,为代码段、数据段等不同类型的内存区域分配逻辑地址,保障程序各部分在加载至内存时能够占据正确位置,避免地址冲突。
(3)重定位处理:针对目标文件中以相对地址形式存在的代码和数据引用,链接器会根据最终的内存布局调整其地址偏移量,将相对地址转换为可直接访问的物理地址或虚拟地址。
5.1.2 链接的作用
- 多模块整合:编译器通常将源代码拆分为多个独立的目标文件(.o 格式),每个文件对应源代码的一个逻辑模块。链接器通过合并这些目标文件,消除模块间的符号引用间隙,生成单一的可执行文件或库文件,实现程序从离散模块到完整实体的转变。
- 符号映射与地址修正:当程序中存在跨模块的函数调用或全局变量引用时,链接器负责解析这些符号的实际定义位置,并通过重定位机制更新所有相关引用的地址值。例如,函数调用指令中的目标地址会从编译时的占位符更新为运行时的真实内存地址,确保程序执行流的正确性。
- 库文件的差异化链接
- 静态链接:在编译阶段将所需的库函数代码直接嵌入目标文件,生成的可执行文件完全独立于外部库环境。这种方式的优势是程序可在无依赖环境下运行,但会导致文件体积增大且无法共享库资源。
- 动态链接:仅在目标文件中记录库函数的引用关系,程序运行时由操作系统动态加载所需的共享库文件(如.so 格式)。该模式显著节省内存空间,支持多程序共享同一库实例,同时便于库的版本升级与维护。
- 内存布局优化:链接器通过分析程序各模块的访问特性与依赖关系,优化内存分段布局(如将只读代码段与可写数据段分离),并移除未被使用的代码段(Dead Code Elimination),在提升程序执行效率的同时减小二进制文件体积,实现存储资源的高效利用。
5.2 在Ubuntu下链接的命令
在终端中,输入ld -o hello -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 /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o并回车,结果如下图所示:

图 13 链接命令
5.3 可执行目标文件hello的格式
可执行目标文件的格式类似于可重定位目标文件(hello.o)的格式,但稍有不同。ELF头中字段e_entry给出执行程序时的第一条指令的地址,而在可重定位文件中,此字段为0。可执行目标文件多了一个程序头表,也称为段头表,是一个结构数组。可执行目标文件还多了一个.init节,用于定义_init函数,该函数用来执行可执行目标文件开始执行时的初始化工作。因为可执行目标文件不需要重定位,所以比可重定位目标文件少了两个.rel节。
- ELF头
与第四章使用的方法相似,我们可以使用readelf查看hello文件的ELF头。
图 14 helloELF头
2.节头
节头描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。

图 15 hello的section头
3.符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。

5.4 hello的虚拟地址空间
在终端中输入edb并回车,打开edb界面,在File-Open中选择hello文件,点击open,界面显示如下。

图 16 edb
可以看到hello虚拟地址空间的起始地址为0x401000,结束地址为0x401ff0。
根据5.3中的节头部表,可以通过edb找到各段的信息。
例如.text节,首先先从节头部表找到开始的虚拟地址。然后在edb中查询地址。

图 17 .text段
.plt起始地址为0x401090,在edb中查询地址。


图 18 plt段
5.5 链接的重定位过程分析
在终端输入命令objdump -d -r hello并回车,查看hello可执行文件的反汇编条目,结果如下:

图 19 objdump结果
对比第四章中生成的hello.o反汇编文件和hello反汇编文件,这两份反汇编文件其实反映了程序在「未链接」和「已链接」两种不同阶段的样子,以下我们来分析不同之处。
hello反汇编代码中函数调用时不再仅仅储存call当前指令的下一条指令,而是已经完成了重定位,调用的相应函数已经有对应的明确的虚拟地址空间。

图 20 函数调用
hello反汇编代码中相比.o反汇编代码多出来的节都是经过链接之后加入进来的。例如.init节就是程序初始化需要执行的代码所在的节,.dynamic节是存放被ld.so调用过的 动态链接信息的节等等。
图 21 .init节
- 节与符号定义的重定位
链接器首先对所有输入目标文件中类型相同的节(如.text、.rodata等)进行合并,形成聚合节。随后,链接器为这些聚合节以及输入模块中定义的每个符号分配运行时内存地址。通过这一操作,程序中的每条指令、全局变量和函数符号均被赋予唯一的虚拟内存地址,为后续的地址解析奠定基础。
2.节内符号引用的重定位
在完成地址分配后,链接器需修正代码节和数据节中对符号的引用,使其指向正确的运行时地址。这一过程依赖于可重定位目标模块中的重定位条目(Relocation Entries),这些条目记录了需要调整的符号引用位置及类型。链接器根据重定位条目中的信息,对目标文件中未解析的符号引用(如外部函数调用、全局变量访问)进行逐一修正,确保程序在运行时能够正确访问目标地址。
5.6 hello的执行流程
- 调试环境启动
通过 EDB(Embedded Debugger)启动并调试 "hello" 可执行文件,触发程序加载流程。 - 动态链接器初始化
程序控制权首先转移至动态链接器的初始化函数_dl_init,该函数负责解析共享库依赖、执行重定位操作,并完成运行时环境的初始化。初始化完成后,程序跳转到 "hello" 的入口点_start。 - C 标准库初始化
_start函数通过call指令调用动态链接库ld-2.27.so中的_libc_start_main函数。此函数作为 C 标准库的入口点,负责:- 初始化栈帧和环境变量
- 设置程序终止处理机制
- 调用用户程序的main函数
- 终止处理函数注册
_libc_start_main调用动态链接库中的__cxa_atexit函数,注册程序退出时需要执行的清理函数(如全局对象析构函数)。这些函数会被存入一个全局函数表中,在程序正常退出时依次调用。 - 静态库初始化
控制权返回__libc_start_main后,调用由静态库引入的__libc_csu_init函数。该函数执行额外的初始化工作,如初始化全局变量、设置信号处理等。 - 异常处理机制设置
再次返回__libc_start_main后,调用动态链接库中的_setjmp函数,设置非本地跳转点(用于异常处理和longjmp机制),确保程序能正确处理异常情况。 - 进入用户代码
完成所有初始化后,__libc_start_main正式调用用户程序的main函数,传递命令行参数(argc, argv)和环境变量(envp)。 - 条件判断与程序退出
在 EDB 调试环境中运行时,由于未提供命令行参数,main函数内部的条件判断(如if (argc < 2))被触发,程序通过exit(1)提前终止。 - 退出流程执行
exit函数首先调用之前通过__cxa_atexit注册的清理函数,然后执行标准 I/O 缓冲区刷新、文件描述符关闭等操作,最终通过系统调用_exit终止进程。 - 内核资源回收
操作系统内核回收进程占用的所有资源(如内存、文件描述符、CPU 时间片等),完成程序的整个生命周期。

5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它,延迟绑定是通过GOT和PLT实现的。
.plt:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
.got:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
一次调用某个函数时,程序不是直接调用,而是调用进入函数所在的PLT条目,第一条PLT指令通过GOT进行间接跳转,每个GOT条目初始时都指向其对应的PLT条目的第二条指令,这个间接跳转只是简单将控制传送回函数所在的PLT条目的下一条指令。之后将函数的ID压入栈中之后,函数所在的PLT条目跳转到PLT[0],最后PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]简介跳转进入动态链接器。动态链接器通过使用两个栈条目来确定函数的运行时位置,再将控制传递给函数。
后续调用时,则可以不用通过GOT[4]的跳转将控制给到函数。
hello在动态连接器加载前后的重定位是不一样的,在加载之后才进行重定位。

图 23 .got段源代码截图
5.8 本章小结
本章解释了链接的基本概念和作用,使用命令链接生成hello可执行文件,观察了hello文件ELF格式下的内容,利用edb观察了hello文件的虚拟地址空间使用情况,对其链接过程进行分析,最后阐明了他的重定位,执行流程和动态链接。
第6章 hello进程管理
6.1 进程的概念与作用
进程是程序的一次运行实例,是操作系统资源分配和调度的基本单位。它包含程序代码、数据、内存空间和运行时状态。
作用:
- 资源隔离:每个进程有独立的内存空间,互不干扰。
- 资源管理:操作系统通过进程分配 CPU、内存、文件等资源。
- 并发执行:支持多个进程同时运行,实现多任务。
- 调度单位:进程是操作系统调度与管理的基本单位。
- 程序执行载体:程序必须以进程形式运行才能被执行
6.2 简述壳Shell-bash的作用与处理流程
Shell(如 Bash) 是用户与操作系统之间的命令解释器,它接收用户输入的命令并传给内核执行。
作用:
- 提供命令行接口;
- 解析并执行用户命令;
- 控制进程、重定向、管道等功能;
- 支持脚本编程,自动化任务。
处理流程:
- 用户输入命令;
- Shell 解析命令行(词法/语法分析);
- 查找命令(内建或外部程序);
- 创建子进程执行命令;
- 显示执行结果并等待下一条命令。
6.3 Hello的fork进程创建过程
首先用户在shel1界面输入指令:./hello 2023111785姚鹏举 18645267050 0
Shell判断该指令不是内置命令,于是父进程调用fork函数创建一个新的子进程,该子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程与父进程最大的区别就是具有不同的PID。在父进程中,fork返回子进程的PID,而在子进程中fork返回0,返回值提供一个明确的方法来分辨程序是父进程还是在子进程中执行。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个程序。函数声明如下:
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并不返回。
6.5 Hello的进程执行
hello 程序从启动到退出,其执行过程可以分为以下几个关键阶段,对应操作系统为进程和 CPU 调度所提供的抽象:
6.5.1 逻辑控制流与私有地址空间
当借助 execve ("./hello", argv, envp) 启动 hello 程序时,操作系统会对当前进程的映像进行替换,并为其构建一个全新的虚拟地址空间。该空间涵盖了可执行文件的代码段、数据段、未初始化数据段(BSS)、堆区以及用户栈,同时还会映射所需的共享库。程序计数器(PC)从 main 函数的入口处开始,按顺序取出指令并执行,这一系列的指令序列就构成了进程的 “逻辑控制流”。在这个隔离的地址空间内,hello 程序看似独占了全部内存,然而实际上,内核是通过页表和虚拟内存机制在物理内存中为其分配相应资源的。
6.5.2 用户态到内核态的切换
在hello程序执行`printf("Hello 2023111785 姚鹏举 18645267050 0\n")`时,标准I/O库函数最终会触发底层的`write`系统调用。此时,处理器会从用户态自动切换至内核态,跳转至内核中`write`系统调用的实现逻辑,完成字符缓冲区到内核空间的拷贝,并通过设备驱动完成实际的输出操作。当系统调用执行完毕后,内核会将处理器状态恢复为用户态,并返回到`printf`调用后的下一条指令继续执行。
6.5.3 时间切片与上下文切换
在多进程运行环境下,为实现CPU资源的公平分配,内核采用时间片轮转调度策略。每个进程被分配一个固定时长的时间片,当时间片用尽或出现更高优先级的就绪进程时,调度器会触发上下文切换。
切换过程中,内核首先将当前进程的通用寄存器、程序计数器(PC)、栈指针等状态信息保存至进程控制块(PCB),随后加载下一个进程的上下文信息,恢复其寄存器状态并从上次中断处继续执行。通过这种机制,多个进程的逻辑控制流能够在单核或多核CPU上交替运行,实现宏观上的并发效果。
6.5.4 休眠与信号处理
当 hello 程序调用 sleep(n) 时,它会进入可中断的睡眠状态,内核在定时器中登记一个唤醒事件,并将该进程挂起,让出 CPU 给其他就绪进程。睡眠期间,如果有信号到达(例如用户按下中断键或定时器信号),内核会提前唤醒 hello,先执行相应的信号处理程序,再根据处理结果决定是否继续睡眠或返回到用户态的后续指令。到了预定睡眠时长,内核将 hello 重新加入就绪队列,等待下一次调度。
6.5.5 进程退出
当 hello 程序执行到 return 0; 或调用 exit() 时,会再次触发一次内核切换,将退出码记录到父进程可查看的位置,并开始回收资源:撤销其虚拟内存映射、关闭打开的文件描述符、释放内核数据结构。进程控制块被标记为“已终止”,并在适当时机从就绪队列中移除。至此,hello 的整个执行生命周期才算圆满结束。
6.6 hello的异常与信号处理
异常的类别:

正常运行状态

图 24 正常运行
运行时按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。
图 25 ctrl+c
运行时按下Ctrl + Z
按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。
图 26 停止
对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。
图 27 查看挂起
输入kill命令,则可以杀死指定(进程组的)进程:
图 28 杀死进程
6.7本章小结
第6章围绕 hello 程序的进程管理展开,从进程的基本概念、Shell bash 的启动流程,到 hello 进程的创建、替换、执行与终结,系统地揭示了操作系统如何借助多种抽象来支持应用程序运行。首先,我们介绍了进程作为资源分配与调度的基本单位,具有独立的地址空间和控制流;随后阐述了 Bash 解析命令、通过 fork() 派生子进程并在其中执行外部程序的全过程;在 execve() 调用中,原进程映像被新程序替换,用户栈上按约定布置 argc/argv/envp;接着剖析了 hello 从启动、执行 printf 和 sleep 等系统调用中的用户态/内核态切换,到内核以时间片和上下文切换实现多进程并发;还说明了信号到达时的中断唤醒与处理机制;最后,当 hello 调用 exit() 退出时,内核回收其虚拟空间和内核数据结构,完整地关闭了这一进程生命周期。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址
在有地址变换功能的计算机中,访问指令给出的地址(操作数)叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。逻辑地址是由一个段标识符加上一个指定段内相对地址的偏移量,由程序hello产生的与段相关的偏移地址部分
线性地址
线性地址是逻辑地址到物理地址变换之间的一步,程序hello的代码会产生逻辑地址,在分段部件中逻辑地址是段中的偏移地址,加上基地址就是线性地址。
虚拟地址
程序访问存储器所使用的逻辑地址称为虚拟地址。虚拟地址经过地址翻译得到物理地址。与实际物理内存容量无关,是hello中的虚拟地址
物理地址
在存储器里以字节为单位存储信息,每一个字节单元给一个唯一的存储器地址,这个地址称为物理地址,是hello的实际地址或绝对地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址空间采用二维结构设计,由段标识符和段内偏移量两部分构成。其中,段标识符由16位的段选择符表示,前13位作为索引号指向段描述符表中的特定条目,后3位用于存储硬件相关的访问权限和特权级信息。段描述符表由多个段描述符组成,每个描述符详细定义了一个段的基地址、界限及访问控制属性。
系统中存在两种级别的段描述符表:
1. 全局描述符表(GDT):整个系统仅存在一个实例,存储操作系统核心组件(如内核代码段、数据段、堆栈段)的描述符,以及各任务/程序的局部描述符表(LDT)段描述符。
2. 局部描述符表(LDT):每个任务/程序拥有独立的LDT,包含该任务私有的代码段、数据段、堆栈段描述符,以及用于实现进程间通信和特权级切换的门描述符(如任务门、调用门)。
这种两级结构既保证了系统核心资源的全局可见性,又实现了各任务私有资源的隔离保护,为操作系统提供了灵活且安全的内存管理机制。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。VM系统将虚拟内存分割,称为虚拟页,类似地,物理内存也被分割成物理页。利用页表来管理虚拟页,页表就是一个页表条目(PTE)的数组,每个PTE由一个有效位和一个位地址字段组成,有效位表明了该虚拟页当前是否被缓存在DRAM中,如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,如果发生缺页,则从磁盘读取。
MMU利用页表来实现从虚拟地址到物理地址的翻译。

图 29 使用页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7采用四级页表的层次结构。CPU产生虚拟地址VA,虚拟地址VA传送给MU,MMU使用VPN高位作为TLBT和TLBI,向TLB中寻找匹配。如果命中,则得到物理地址PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,以此类推,最终在第四级页表中找到PPN,与VPO组合成物理地址PA,添加到PLT。工作原理如下:

图 30 corei7地址翻译概况

图 31 多级页表
7.5 三级Cache支持下的物理内存访问
当CPU核心需要读取物理地址(PA)的数据时,首先由内存管理单元(MMU)将虚拟地址(VA)转换为物理地址(PA),随后进入多级缓存的查找流程,具体步骤如下:
1. L1缓存(一级缓存)
作为离CPU核心最近的缓存,L1通常分为数据缓存(L1d)和指令缓存(L1i),每个核心独立拥有。其容量较小(32–64 KB),但访问速度极快(1–4个时钟周期)。CPU通过物理地址的高位部分作为索引,在L1的组相联结构中定位缓存行,并对比标签(Tag)和有效位(Valid bit)。若匹配且有效,则直接从L1读取数据(L1命中),完成访问。
2. L2缓存(二级缓存)
若L1未命中,CPU转而访问私有L2缓存(容量256 KB–1 MB,延迟10–15个时钟周期)。L2同样采用组相联结构,通过物理地址的索引和标签查找缓存行。若命中,L2会将数据回填到L1,再由L1交付给CPU;若未命中,则继续向L3缓存请求数据。
3. L3缓存(三级缓存)
多核处理器通常共享L3缓存(容量4–32 MB,延迟30–50个时钟周期),采用高相联度或全相联结构以提升命中率。CPU再次通过物理地址的索引和标签查找缓存行,若命中,数据依次回填至L2和L1,最终返回给执行单元;若未命中,则进入主存访问流程。
4. 物理内存(DRAM)
当三级缓存均未命中时,内存控制器根据物理地址的行地址(64 B对齐)定位DRAM中的行(Row)和列(Column),执行行激活(ACTIVATE)、读(READ)或写(WRITE)操作。主存访问延迟较高(100–200纳秒),读取的数据会先装载到L3(或直接至L2/L1,依架构而定),再逐层回传至CPU,同时更新各级缓存的对应条目。
7.6 hello进程fork时的内存映射
当 hello 进程调用 fork() 时,内核启动高效的子进程创建机制:首先为子进程分配独立的进程描述符(task_struct),并复制父进程的内存管理核心数据结构,包括 mm_struct(记录地址空间全局信息)、虚拟内存区域(VMA)链表(描述各内存段属性)和页表结构的镜像,确保子进程在逻辑上拥有与父进程一致的虚拟地址空间视图。
在物理内存层面,父子进程共享原始物理页面,内核通过将页面的引用计数加一,并在双方页表中将对应页表项标记为只读,激活 “写时复制”(Copy-On-Write, COW)机制。此时,两者的虚拟地址虽指向同一物理页,但任何写入操作都会触发 CPU 的页错误(Page Fault)。
当父进程或子进程首次尝试修改共享页面时,CPU 检测到页表项的只读标志,触发异常并陷入内核处理流程。内核识别到这是 COW 场景后,会为发起写操作的进程分配全新的物理内存页,将原页面内容完整拷贝至新页,并更新该进程的页表项指向新页且允许写入,而另一进程的页表项仍指向旧页(保持只读状态)。
这种机制使得父子进程在逻辑上拥有独立的地址空间,但在实际物理内存使用上,仅在发生写操作时才按需分配资源。它避免了传统复制方式对内存的大量消耗,尤其适用于 fork 后立即执行 execve(如启动新程序覆盖地址空间)的场景,显著提升了进程创建的效率和内存利用率。通过 COW 技术,Linux 内核在隔离性与性能之间实现了优化平衡,成为现代操作系统进程管理的核心机制之一。

图 32 进程2写了私有区域的一个页
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结 构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名 文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3.映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链 接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。execve 做的最后一件事情就是设置当前进程 上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:
- 检查虚拟地址是否合法,如果不合法则触发一个段错误,终止这个进程。
2.检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。
3.两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。

图 33 缺页异常情况处理
7.9动态存储分配管理
7.10本章小结
本章从多个角度分析了hello的动态存储管理,包括存储器地址空间、intel的段式管理、hello的页式管理,以intel Core i7在指定环境下介绍了虚拟地址VA到物理地址PA的转换、物理内存访问,分析了hello进程fork时的内存映射、hello进程、execve时的内存映射、缺页故障与缺页中断处理以及动态存储分配管理
结论
总结hello所经历的过程如下:
首先要完成hello.c的C语言源程序的编写。这就是Hello的起点。
下一步使用命令gcc -E进行预处理,Hello完成从hello.c到hello.i的成长。
下一步使用命令gcc -S进行编译,Hello完成从hello.i到hello.s的成长。
下一步使用命令gcc -c进行汇编,Hello完成从hello.s到hello.o的成长。此时的Hello已经变成一个二进制文件了。
对Hello进行链接,将Hello的好伙伴们和Hello联系起来,把它和其他可重定位二进制文件合体,变成一个可以在计算机上运行的二进制文件。
打开Shell,输入命令./hello 2023111785 姚鹏举 18645267050 0来运行Hello程序。
Shell进行第六章中讲述的一系列判断:首先判断输入命令是否为内置命令。经过检查后发现其不是内置命令,则Shell将其当作程序执行。
随机Shell调用Fork()函数为Hello创建一个进程。
shell调用execve函数,execve函数会将新创建的子进程的区域结构删除,然后将其映射到hello程序的虚拟内存,然后设置当前进程上下文中的程序计数器,使其指向hello程序的入口点。
运行hello时,内存管理单元、TLB、多级页表机制、三级cache协同工作,完成对地址的翻译和请求。
当Hello运行到printf这一步时,操作系统会调用malloc函数从堆中申请内存。
当Hello执行时,可以通过IO输入等操作向进程发送信号。例如我们从键盘输入Ctrl-c,就会发送一个SIGINT信号,使当前前台进程的作业中断;同样哦们可以使用命令jobs来查看被抢占的进程,使用命令fg %<pid>来恢复对应ID的进程。
当进程执行结束后,由父进程对子进程进行回收。至此,Hello的一生结束。
附件
| 文件名 | 生成指令 | 作用说明 |
| hello.c | 源程序 | |
| hello.i | gcc -E hello.c -o hello.i | 预处理输出的纯 C 源码 |
| hello.s | gcc -S hello.i -o hello.s | 编译阶段生成的汇编语言文件。 |
| hello.o | gcc -c hello.s -o hello.o | 汇编器输出的可重定位目标文件,包含机器码二进制、符号表和重定位信息,供链接器合并。 |
| hello | ld -o hello -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 /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o | 最终可执行文件 |
参考文献
[1] Randal E.Bryant David R.O'Hallaron.深入理解计算机系统(第三版).机械工业出版社.
[2] Kerrisk, M. (2010). The Linux Programming Interface. No Starch Press.
[3] GCC: The GNU Compiler Collection. (n.d.). GCC Official Documentation. Retrieved from https://gcc.gnu.org/onlinedocs/
[4] Intel Corporation. (2023). *Intel® 64 and IA-32 Architectures Software Developer
[5] Tanenbaum, A. S., & Bos, H. (2015). Modern Operating Systems (4th ed.). Pearson.
[6] Levine, J. R. (2000). Linkers and Loaders. Morgan Kaufmann.

2524

被折叠的 条评论
为什么被折叠?



