计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 电子与信息工程
学 号 2023111556
班 级 23L0502
学 生 姚雯彤
指 导 教 师 刘宏伟
计算机科学与技术学院
2024年5月
本篇论文旨在系统阐述C语言程序从源代码到可执行文件的完整转换机制。以经典示例hello.c为研究对象,论文逐步剖析了预处理、编译、汇编、链接及进程运行等关键阶段的处理流程。通过理论解析与实践演示相结合的方法,不仅深入探讨了各环节的核心原理与实现技术,还通过实验验证直观展示了转换过程的具体实现。这种理论与实践并重的研究方法,有效揭示了计算机系统在程序编译执行过程中的底层工作机制,为读者深入理解C语言程序的编译链和计算机体系结构与提供了系统的知识框架。
关键词:C语言程序;编译链;计算机系统
目 录
第1章 概述
1.1 Hello简介
1.1.1P2P(Program to Process):P2P过程展现了源代码如何经编译、链接成为进程。编写源代码(程序员编写hello.c,这是一个C语言源代码文件,包含main()函数和printf(“Hello”)等语句)→预处理(预处理器(cpp)处理#include、宏定义(#define)等指令,生成.i文件(纯C代码))→编译(编译器(cc1)将预处理后的代码翻译成汇编语言(.s文件))→汇编(汇编器(as)将汇编代码转换为机器指令,生成可重定位目标文件(.o文件))→链接(连接器(ld)将多个.o文件与标准库(如libc.so)合并,生成可执行文件(hello))→进程创建(Shell调用fork()创建子程序,再通过execve()加载hello程序)
1.1.2O2O(From Zero to Zero):
From Zero(初始状态):程序未运行时,仅以静态代码(hello.c)形式存在。
To Zero(终止程序):进程执行完毕后,所有运行时状态(寄存器、内存等)被OS回收,仿佛从未运行过。
计算机系统(CS)的见证:只有编译器、链接器、OS、硬件等底层系统知道hello曾经存在并运行过。程序的生命周期完全依赖于计算机系统的支持,但最终不留下任何痕迹。程序本身最终“挥一挥衣袖,不带走一片云彩”。
1.2 环境与工具
1.2.1硬件环境:
系统类型:64位操作系统,基于x64的处理器
1.2.2软件环境:Windows11 64位,VMware,Ubuntu 20.04 LTS
1.2.3开发与调试工具:Visual Studio 2022 64位;vim objump edb gcc readelf等工具
1.3 中间结果
文件名 |
功能 |
hello.c |
源程序 |
hello.i |
预处理后得到的文本文件 |
hello.s |
编译后得到的汇编语言文件 |
hello.o |
汇编后得到的可重定位目标文件 |
hello.elf |
用readelf读取hello.o得到的ELF格式信息 |
hello.asm |
反汇编hello.o得到的反汇编文件 |
hello1.asm |
反汇编hello可执行文件得到的反汇编文件 |
hello |
可执行文件 |
1.4 本章小结
本章首先介绍了hello的P2P,020流程,包括流程的设计思路和实现方法;然后,详细说明了本实验所需的硬件配置、软件平台、开发工具以及本实验生成的各个中间结果文件的名称和功能。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念:预处理是C/C++程序编译过程的第一阶段,由预处理器(Preprocessor)执行。预处理器的主要任务是在正式编译之前,对源代码进行文本级别的处理,包括:宏展开、头文件包含、条件编译、删除注释、特殊指令处理。预处理后的代码仍然是纯文本形式的C/C++代码,但已经去除了预处理指令,并进行了相应的替换和调整,生成.i(C)或.ii(C++)文件。
2.1.2预处理的作用:预处理的主要目的是简化代码编写、提高可维护性、增强灵活性,具体作用包括:
(1)宏定义与替换:定义常量、函数式宏,减少重复代码
预处理阶段会直接替换,例如MAX(1,2)会被替换成((1)>(2)?(1):(2))
(2)头文件包含
将外部库或自定义头文件插入当前文件,避免重复声明
预处理阶段会递归展开所有#include,生成一个完整的代码文件。
- 条件编译
根据不同的编译环境选择性地包含或排除代码
- 注释删除
预处理阶段会移除所有注释,减少编译器的处理负担
- 特殊指令处理
#program用于向编译器传递特殊指令
#error用于在预处理阶段强制报错
2.2在Ubuntu下预处理的命令
预处理的命令:gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
将源程序与预处理后的程序hello.i进行对比,发现预处理指令被扩展了很多,但除此之外,源程序的其他部分与原来相同,.c文件被修改了。
main函数代码出现之前的代码来自于头文件的展开。原始代码中的:#include<stdio.h>、#include<unistd.h>、#include<stdlib.h>会被替换为这些头文件的实际内容,例如:stdio.h → 插入 printf、FILE 等相关的函数和类型声明。
unistd.h → 插入 sleep、getchar 等系统调用声明。
stdlib.h → 插入 exit、atoi 等函数声明。
当预处理器遇到#include<stdio.h>时,它会在系统的头文件路径下查找stdio.h文件,一般在/usr/include目录下,然后把stdio.h文件中的内容复制到源文件中。
预处理器不会对头文件中的内容做任何计算或转换,只是简单地复制和替换。
2.4 本章小结
本章讲述了在linux环境中,如何用命令对C语言程序进行预处理,以及预处理的含义和作用。然后用一个简单的hello程序演示了从hello.c到hello.i的过程,并用具体的代码分析了预处理后的结果。通过分析,我们可以发现预处理后的文件hello.i包含了标准输入输出库stdio.h的内容,以及一些宏和常量的定义,还有一些行号信息和条件编译指令
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译是将预处理后的高级语言代码(如C/C++)转换为低级语言(汇编代码或机器码)的过程,由编译器(Compiler)完成。
3.1.2编译的作用
编译的核心目标是将人类可读的代码转换为机器可执行的指令,具体作用包括:
- 语法检查
检查代码是否符合C语言语法规则(如括号匹配、分号结尾等)。
若存在语法错误,编译会失败并报错(如 error: expected ‘;’ before ‘}’ token)。
- 语义分析
检查变量是否声明、类型是否匹配(如 int x = "string"; 会报错)。
检查函数调用是否合法(如未定义的函数 undefined reference to 'foo')。
- 代码优化
根据编译选项(如 -O1, -O2)对代码进行优化:
删除无用代码(死代码消除)。
循环展开(Loop Unrolling)。
内联函数(Inline Expansion)。
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1文件与段声明
.file:标识原始C源文件。
.text:存放程序指令的代码段(可执行机器码)。
.rodata:存放只读数据(如字符串常量)。
3.3.2字符串常量(只读数据段)
.LC0:错误提示字符串(编码为八进制转义序列)。
.LC1:printf的格式化字符串。
.align 8:内存地址按8字节对齐,提升访问效率。
3.3.3主函数定义
.globl main:使main函数可被链接器访问。
endbr64:Intel控制流强制技术(安全特性)。
3.3.4函数序言
%rbp(基址指针)和%rsp(栈指针)构成当前函数的栈帧。
subq $32, %rsp:为局部变量和临时数据预留空间。
3.3.5参数检查
参数传递规则(x86-64 System V ABI):
%edi:argc(int)。
%rsi:argv(char**)。
分支逻辑:若argc != 5,执行错误提示并退出。
3.3.6错误处理
puts@PLT:通过过程链接表(PLT)调用库函数puts。
exit(1):直接终止程序。
3.3.7主循环
参数访问:通过argv基址+偏移量获取参数(argv[1]~argv[4])。
函数调用:
printf@PLT:格式化输出。
atoi@PLT + sleep@PLT:将字符串转为整数并休眠。
3.3.8函数收尾
getchar:阻塞等待用户输入(防止程序直接退出)。
返回值:main返回0(movl $0, %eax)。
3.3.9元信息与注释
安全特性
.note.GNU-stack:标记栈为不可执行(NX保护)。
.note.gnu.property:包含控制流保护信息。
3.4 本章小结
这一章节主要讲解了C编译器将预处理后的hello.i文件转换为汇编代码hello.s的完整流程。首先阐述了编译阶段的基本概念和作用原理,随后通过实际编译命令展示了具体操作过程。作者深入解析了生成的hello.s文件,从多个维度对比了C源代码与对应汇编指令的实现差异:包括数据操作处理、函数调用机制、各类运算(赋值、算术、关系运算)的实现方式,以及程序流程控制(跳转指令)和数据类型转换等关键环节,系统性地呈现了高级语言到汇编语言的转换过程。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
汇编是指汇编器(as)将包含汇编语言的.s文件翻译为机器语言指令,并把这些指令打包成为一个可重定位目标文件的格式,生成目标文件.o文件。.o文件是一个二进制文件,包含main函数的指令编码。
4.1.2汇编的作用
汇编的作用是将高级语言转化为机器可直接识别执行的代码文件的过程,汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式。 .o 文件是一个二进制文件,它包含程序的指令编码。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
命令为:gcc -m64 -no-pie-fno-PIC -c hello.s -o hello.o
4.3 可重定位目标elf格式
在shell中输入readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式:
4.3.1ELF头
文件类型: 可重定位文件(REL),需链接后才能执行。
架构: x86-64 (AMD64),小端序。
节头表起始位置: 1088 字节,共 14 个节头。
无程序头表(因非可执行文件)。
4.3.2节头
代码与数据节
4.3.3重定位节
重定位表(.rela.text 和 .rela.eh_frame)记录链接时需要修正的地址。当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改,而调用本地函数的指令不需修改。
4.3.4符号表
这张符号表包含一个条目的数组,存放一个程序定义和引用的全局变量和函数的信息。该符号表不包含局部变量的信息。
4.4 Hello.o的结果解析
(以下格式自行编排,编辑时删除)
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
4.4.1命令
objdump -d -r hello.o > hello.asm
4.4.2与hello.s的对照
(1)
操作数表示:机器码使用二进制编码,汇编代码使用符号化操作数。
例:48 89 e5 → mov %rsp,%rbp(48是REX前缀,89是MOV操作码)。
分支/调用目标:机器码中为相对偏移量,汇编代码中为符号标签。
例:e8 00 00 00 00 → call printf(链接后填充真实地址)。
(2)条件分支(je)
Hello.s:
Hello.o反汇编
74 19:je的机器码,19是跳转偏移量(0x32 - 0x19 = 0x19)
(3)函数调用:
hello.s:
Hello.o反汇编:
e8是call指令,00 00 00 00为临时占位符(链接时填充真实偏移)。
R_X86_64_PLT32:重定位条目,指示链接器修正地址。
(4)内存操作数:
汇编代码:
机器码:
(5)立即数
汇编代码:
机器码:
4.5 本章小结
本章讲解了汇编的基本概念和作用,以Ubuntu系统中的hello.s文件为例,演示了如何将其汇编为hello.o文件,并最终生成ELF格式的可执行文件hello.elf。通过对比可重定位目标文件和ELF格式文件,解析了文件中的各个节区内容。此外,通过比较hello.o的反汇编代码(保存为hello.asm)与原始hello.s文件的异同,清晰地展现了汇编语言到机器语言的转换过程,以及机器为链接所做的准备工作。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
在程序编译过程中,链接(Linking)是指将目标文件(如 `hello.o`)与所需的库文件合并,解析符号引用并生成最终可执行文件(如 `hello`)的过程。它的主要作用是整合代码段、数据段,解决外部依赖,使程序能正确运行。
5.1.2链接的作用
链接的作用是将编译生成的目标文件(如 `hello.o`)与库文件进行合并,解析未定义的符号引用,调整地址关系,并最终生成可执行文件(如 `hello`),使其能够被操作系统加载和运行。
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
5.3 可执行目标文件hello的格式
(1)ELF头
Magic: 7f 45 4c 46(ELF 文件标识)
类别: ELF64(64位格式)
数据: 小端序(Little Endian)
类型: EXEC(可执行文件)
架构: x86-64
入口点地址: 0x4010f0(程序起始执行地址)
程序头起点/大小: 64 字节,共 12 个程序头
节头起点/大小: 13560 字节,共 27 个节头
(2)节头
(3)程序头
(4)Dynamic section
(5)Symbol table
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
观察程序头的LOAD可加载的程序段的地址为0x400000
使用edb打开hello,查看hello加载到虚拟地址的情况
程序从地址0x400000开始到0x401000被载入,虚拟地址从0x4000000x400f0结束
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
5.5.1分析hello与hello.o的区别
命令:objdump -d -r hello > hello1.asm生成反汇编文件hello1.asm
对比hello1.asm与hello.asm,主要的区别为:
(1)函数增加
Hello.asm:
仅包含 main 函数的代码。
所有外部函数调用(如 puts、printf)都是未解析的符号,通过 R_X86_64_PLT32 重定位标记占位。
Hello1.asm:
增加了多个节(section)和函数:
.init:程序初始化代码(_init)。
.plt 和 .plt.sec:过程链接表(PLT),包含动态链接的跳转逻辑(如 puts@plt、printf@plt)。
.text:新增 _start(程序入口点)和 _dl_relocate_static_pie(PIE重定位支持)。
.fini:程序终止代码(_fini)
- 函数调用指令 call 的参数变化
Hello.asm:
call 的目标地址是临时的 00 00 00 00,等待链接器填充。
使用相对偏移量(如 e8 00 00 00 00)。
依赖重定位条目(R_X86_64_PLT32)在链接时修正。
Hello1.asm:
call 的目标地址被替换为 PLT 条目地址(通过 bnd jmp 跳转到 GOT)。
地址固定为 0x401090(PLT 表项)。
实际调用逻辑:
跳转到 PLT 表(如 puts@plt)。
PLT 通过 GOT(全局偏移表)动态解析库函数地址。
- 跳转指令参数的变化
Hello.asm:
跳转目标为相对偏移量(基于当前指令地址)。
Hello1.asm:
跳转目标地址被修正为绝对地址(但仍用相对偏移编码)。
- 数据引用
Hello.asm:
数据地址用 00 00 00 00 占位,依赖 R_X86_64_PC32 重定位。
Hello1.asm:
数据地址被修正为实际地址(PC 相对寻址)。
5.5.2重定位过程
链接器(如 ld)的工作流程:
符号解析(Symbol Resolution)
遍历所有目标文件,建立全局符号表(如 puts、printf 的地址)。
如果找不到某个符号的定义,报错(如 undefined reference to 'foo')。
合并节区(Section Merging)
将所有 .o 文件的 .text、.data 等节区合并,并分配最终的内存地址。
重定位修正(Relocation Fixup)
根据重定位条目,修改代码和数据中的地址引用。
主要修正两类内容:
指令中的地址(如 call、jmp 的目标地址)。
数据中的地址(如全局变量的指针)。
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
5.6.1流程
开始执行:_start、_libe_start_main
执行main:_main、printf、_exit、_sleep、getchar
退出:exit
5.6.2程序名与地址
程序名 |
地址 |
_start |
0x4010f0 |
_libc_start_main |
0x2f12271d |
main |
0x401125 |
_printf |
0x4010a0 |
_sleep |
0x4010e0 |
_getchar |
0x4010b0 |
_exit |
0x4010d0 |
5.7 Hello的动态链接分析
动态链接是一种在程序运行时(而非编译时)解析外部函数和变量地址的机制。其核心思想是通过 "延迟绑定"(Lazy Binding) 技术,将符号(函数/变量)的地址解析推迟到程序实际调用时完成,从而提高程序加载效率并节省内存。
根据hello.elf文件可知,GOT起始表位置为:0x404000:
由图可发现GOT表位置在调用dl_init之前0x404008后的16个字节均为0
调用了dl_init之后字节改变了:
动态链接的关键机制
(1) PLT(Procedure Linkage Table)
作用:充当跳板,所有外部函数调用先经过 PLT。
首次调用流程:
call printf@plt 跳转到 PLT 条目。
PLT 检查 GOT 表,发现未解析,则调用动态链接器。
动态链接器解析 printf 的真实地址,更新 GOT。
跳转到真实地址执行。
后续调用:直接通过 GOT 跳转,无需解析。
(2) GOT(Global Offset Table)
作用:存储外部符号(函数/变量)的真实地址。
初始化:GOT 表项初始指向 PLT 的解析逻辑。
被动态链接器替换为实际地址(如 0x7ffff7e3b1d0)。
5.8 本章小结
本章系统性地介绍了程序链接的核心机制与实现过程。首先从链接的基本原理入手,详细讲解了符号解析和地址重定位的核心功能。通过实际操作演示了如何使用ld链接器将目标文件合并生成可执行文件hello,并深入剖析了生成文件在ELF格式下的组织结构。借助edb调试工具,我们直观地观察了hello程序加载后的虚拟内存空间布局,包括代码段、数据段等关键区域的地址映射。最后,以hello程序为研究对象,重点分析了三个关键过程:重定位过程中符号地址的绑定机制、程序执行时的指令流水线处理,以及动态链接环境下PLT/GOT表的协同工作原理。这一系列分析从理论到实践,完整展现了程序从静态代码到动态运行的转换链条。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程是操作系统进行资源分配和调度的基本单位,是程序的一次动态执行过程。它拥有独立的虚拟地址空间、文件描述符、寄存器状态等执行环境,由程序代码、数据和运行状态(如程序计数器、堆栈等)组成。操作系统通过进程控制块(PCB)管理进程信息,实现多任务的隔离与并发执行。每个进程都运行在受保护的内存空间中,通过系统调用与内核交互,是程序从静态代码到动态运行的实体化表现。
6.1.2进程的作用
进程的核心作用是实现
程序的并发执行与资源隔离。作为操作系统资源分配的基本单位,进程为每个运行中的程序提供独立的虚拟地址空间、CPU时间片和系统资源(如文件、网络等),确保多任务间互不干扰。通过进程调度,操作系统高效切换CPU执行权,实现宏观上的并行;同时利用内存保护机制,防止进程越界访问,保障系统安全稳定。进程模型将静态程序
转化为动态执行的实体,是操作系统实现多任务、提升资源利用率的关键机制。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1壳shell-bash的作用
Shell(如Bash)是用户与操作系统内核交互的“命令解释器”,充当核心指令的翻译中枢。它提供命令行界面(CLI)和脚本执行环境,将用户输入的命令(如`ls`、`gcc`)转化为系统调用(如`readdir`、`fork/exec`),控制进程的创建、管道通信和任务管理(如`jobs`/`fg`)。同时支持变量、循环等编程功能,实现自动化操作,是系统管理、程序开发和日常操作的核心入口。
6.2.2壳shell-bash的处理流程
Shell(如Bash)的处理流程分为四个核心阶段:① 词法解析:将输入的命令行拆解为令牌(如命令、参数、操作符);② 语法解析:构建抽象语法树(AST),处理管道、重定向等逻辑结构;③扩展替换:执行变量替换(`$var`)、通配符扩展(`*.txt`)等预处理;④ 执行控制:根据命令类型(内置/外部)调用`fork-exec`创建子进程,或直接执行内置命令(如`cd`),并管理进程组、信号和IO重定向。整个过程通过`readline`交互循环持续接收用户输入,直至退出。
6.3 Hello的fork进程创建过程
当通过Shell执行`./hello`时,Bash首先调用`fork()`创建子进程,该子进程完整复制父进程(Shell)的代码段、数据段和打开的文件描述符等资源;随后子进程通过`execve()`加载`hello`程序的代码和数据,替换原有内存空间,并传递命令行参数(学号、姓名等)。此时新创建的进程开始独立执行`main()`函数,由操作系统分配PID并加入调度队列,而父进程(Shell)可通过`wait()`等待子进程结束或通过`jobs`/`fg`管理其运行状态。`fork()`的写时复制(COW)机制优化了进程创建的性能。
6.4 Hello的execve过程
当Shell通过`fork()`创建子进程后,子进程调用`execve("./hello", argv, envp)`执行程序加载:该系统调用会**清空原进程的代码段、数据段和堆栈**,将磁盘上的`hello`可执行文件映射到进程内存空间,解析ELF格式并初始化`.text`(代码)、`.data`(数据)等段,同时设置`argc/argv`参数和环境变量`envp`,最终跳转到`_start`入口开始执行程序逻辑(如参数校验、循环打印等)。整个过程完成了进程执行映像的彻底替换,但保留原PID和文件描述符等资源。
6.5 Hello的进程执行
(以下格式自行编排,编辑时删除)
Hello程序的进程执行与调度过程:
1. 进程上下文与初始化
当`execve()`加载Hello程序后,操作系统为其构建完整的进程上下文,包括:
- 寄存器状态(RIP指向`_start`,RSP初始化栈顶)
- 虚拟内存映射(代码段、数据段、堆栈段)
- 文件描述符表(继承自Shell,如stdin/stdout)
2. 时间片分配与调度
进程被加入就绪队列后,内核调度器基于时间片轮转策略分配CPU资源:
- 默认时间片为5-100ms(取决于内核配置)
- 每次时钟中断(如10ms)触发调度器检查,若Hello进程时间片耗尽则保存其寄存器状态(PC/SP等),切换至其他进程
3. 用户态与内核态转换
Hello进程的执行涉及多次态切换:
- 系统调用(如`printf`触发`write()`、`sleep()`触发`nanosleep()`):通过`syscall`指令陷入内核,保存用户态寄存器,切换至内核态执行
- 中断处理(时间片到期、键盘输入):强制保存上下文,内核处理后再恢复
- 返回用户态:通过`iret`指令恢复寄存器,继续执行用户代码
4.阻塞与唤醒
- 当调用`sleep()`时,进程主动让出CPU,内核将其移出就绪队列,设置定时器唤醒
- `getchar()`使进程阻塞于等待I/O,当用户输入回车时触发中断,内核唤醒进程
5. 进程终止
`main()`返回后,`exit()`系统调用释放进程资源(内存、文件描述符),父进程Shell通过`wait()`回收退出状态。
关键点:进程通过时间片分时复用CPU,内核通过上下文保存/恢复实现透明切换,系统调用/中断是用户态与内核态交互的唯一安全通道。
6.6 hello的异常与信号处理
(以下格式自行编排,编辑时删除)
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1异常的分类
类别 |
原因 |
异步/同步 |
返回行为 |
中断 |
来自I/O设备的信号 |
异步 |
总是返回到下一条指令 |
陷阱 |
有意的异常 |
同步 |
总是返回到下一条指令 |
故障 |
潜在可恢复的错误 |
同步 |
可能返回到当前指令 |
终止 |
不可恢复的错误 |
同步 |
不会返回 |
6.6.2异常的处理方式
1. 中断(Interrupt)
触发方式:由外部硬件设备(如键盘、定时器、网卡)异步触发。
处理流程:
1. CPU 完成当前指令后,检测到中断请求(IRQ)。
2. 保存当前进程上下文(PC、寄存器等),切换到内核态。
3. 根据中断向量号跳转到对应的中断处理程序(如 `0x80` 为系统调用)。
4. 执行中断服务例程(如读取键盘输入、更新系统时钟)。
5. 恢复被中断的进程上下文,返回用户态继续执行。
特点:异步发生,与当前指令无关,通常用于处理I/O事件。
2. 陷阱(Trap)
触发方式:程序主动通过指令(如 `syscall`、`int 0x80`)触发。
处理流程:
1. 执行陷阱指令后,CPU 保存上下文并切换到内核态。
2. 根据陷阱号调用对应的内核服务(如系统调用 `write()`)。
3. 内核完成服务后,恢复上下文并返回到陷阱后的下一条指令。
特点:同步且可预测,用于实现系统调用或调试断点(如 `gdb` 的 `int3`)。
3. 故障(Fault)
触发方式:由指令执行错误触发(如缺页异常、除零错误)。
处理流程:
1. CPU 检测到错误(如访问无效地址),保存上下文并进入内核态。
2. 内核尝试修复问题(如加载缺失的页面)。
3. 若可修复(如缺页),重新执行触发故障的指令;否则终止进程(如段错误)。
特点:可能被修复后继续执行,典型例子是缺页异常(Page Fault)。
4. 终止(Abort)
触发方式:硬件或操作系统检测到不可恢复错误(如内存校验失败、非法指令)。
处理流程:
1. CPU 或内核强制终止进程,保存错误信息(如核心转储 `core dump`)。
2. 释放进程资源(内存、文件描述符),通知父进程(通过 `SIGSEGV` 等信号)。
3. 若为内核级错误(如硬件故障),可能触发系统崩溃(Kernel Panic)。
特点:不可恢复,直接终止程序或系统。
关键点
1.中断和陷阱是可控的,用于正常交互(I/O、系统调用);**故障和终止是错误处理。
2.上下文保存:所有异常都会保存寄存器状态(PC、SP等),以便恢复。
3.特权级切换:除终止外,其他异常可能返回到用户态继续执行。
4. 性能影响:缺页故障(频繁调页)和中断(高负载I/O)对性能最敏感。
6.6.3运行结果及相关命令
(1)正常运行状态
(2)运行时按下Ctrl+C
按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。
(3)运行时按下Ctrl + Z
按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。
(4)对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。
(5)在Shell中输入pstree命令,可以将所有进程以树状图显示:
(6)输入kill命令,则可以杀死指定(进程组的)进程:
(7)输入fg 1则命令将hello进程再次调到前台执行,可以发现Shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
(8)不停乱按
在程序执行过程中乱按所造成的输入均缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),hello结束后,stdin中的其他字串会当做Shell的命令行输入。
6.7本章小结
本章围绕计算机系统中的进程管理和Shell交互机制展开分析。首先以Hello程序为研究对象,系统阐述了进程作为执行实体的核心特性及其在资源隔离和并发控制中的关键作用,同时解析了Shell作为用户与内核中介的指令解析与任务调度功能。通过技术拆解,详细展示了该程序从fork创建进程、execve加载镜像到CPU时间片轮转调度的完整生命周期,包括上下文切换和用户态/内核态转换等底层细节。最后,针对程序运行中可能触发的外部中断(如Ctrl-C)、同步陷阱(系统调用)及致命故障(段错误)等异常场景,结合具体输入响应(如后台挂起、信号处理)进行了分类说明与原理阐释。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
程序代码中直接使用的地址(如main函数指针、变量地址)。
在分段机制下需转换为线性地址(现代系统通常禁用分段,逻辑地址直接等于线性地址)。
7.1.2线性地址
经过分段转换后的地址(若无分段则等于逻辑地址)。在分页机制前统一地址空间。
7.1.3虚拟地址
进程视角的地址空间(与物理地址隔离),通常等同于线性地址。
7.1.4物理地址
实际DRAM内存中的硬件地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.2.1基本概念
在Intel x86架构中,段式管理是地址转换的第一阶段,负责将程序使用的逻辑地址(Logical Address)转换为线性地址(Linear Address)。现代操作系统通常禁用段式管理(平坦模式),但了解其机制对理解CPU设计至关重要。
7.2.2逻辑地址的构成
(1)段选择符(Segment Selector):16位,指向段描述符表中的条目。
高13位:段描述符索引(Index)。
第2位:表指示位(TI,0=GDT,1=LDT)。
低2位:请求特权级(RPL)。
- 偏移量(Offset):32/64位,表示段内具体地址。
7.2.3段描述符表
CPU通过以下两种表管理段:
全局描述符表(GDT):系统级段定义,所有进程共享。
局部描述符表(LDT):进程私有段定义(现代系统很少使用)。
每个段描述符(8字节)包含:
段基址(Base Address):32/64位,段的起始线性地址。
段界限(Limit):20位,段的最大偏移量。
权限标志:如代码/数据段、特权级(DPL)、是否可读写等。
7.2.4转换流程
逻辑地址 → 线性地址的转换步骤:
解析段选择符:
CPU根据TI位选择GDT或LDT。
通过索引(高13位)定位段描述符。
检查权限:
比较当前特权级(CPL)和段描述符的DPL。
若权限不足(如用户态访问内核段),触发通用保护异常(GPF)。
计算线性地址:
线性地址 = 段基址 + 偏移量。
检查偏移量是否超过段界限,越界则触发段错误(#GP)。
7.2.5现代系统的平坦模式(Flat Model)
为简化内存管理,现代操作系统(如Linux)通常:
禁用段式功能:
所有段基址设为0,界限设为最大(0xFFFFF)。
逻辑地址直接等于线性地址(偏移量部分)。
保留必要段:
CS(代码段)、DS(数据段)等寄存器仍存在,但基址为0。
仅用于维护特权级(如区分内核/用户态)。
7.2.6段式管理的实际意义
尽管被弱化,段式机制仍用于:
(1)特权级隔离:通过CS/SS的DPL限制代码/栈访问权限。
(2)硬件任务切换:旧版CPU通过TSS(任务状态段)切换上下文。
(3)兼容性:支持传统实模式代码。
7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1页式管理核心概念
(1)线性地址(虚拟地址):进程视角的连续地址空间(如Hello的.text段位于0x401000)。
(2)物理地址:实际DRAM内存中的硬件地址。
(3)页表(Page Table):存储虚拟页到物理页帧的映射关系,由操作系统维护
7.3.2地址转换流程
(1)线性地址拆分
64位线性地址(实际使用48位)被划分为多个字段:
63-48 |
47-39 |
38-30 |
29-21 |
20-12 |
11-0 |
保留 |
PML4 |
PDP |
PD |
PT |
页内偏移 |
PML4/PDP/PD/PT:四级页表的索引。
页内偏移:12位,对应4KB页内字节位置。
(2)多级页表查询
CPU依次查询四级页表(CR3寄存器保存顶级页表物理地址):
PML4表:根据CR3 + PML4索引*8找到PDP表地址。
PDP表:根据PDP索引找到PD表地址。
PD表:根据PD索引找到PT表地址。
PT表:根据PT索引找到物理页帧号(PFN)。
物理地址:PFN << 12 + 页内偏移。
(3)TLB加速
TLB(快表):缓存近期页表查询结果,命中时直接返回物理地址,无需遍历页表。
7.3.3Hello程序的实际页表场景
(1) 代码段访问(.text)
访问0x401125(main函数):
首次访问时触发缺页异常,内核从磁盘加载代码页到物理内存,更新页表。
后续访问通过TLB直接转换。
(2) 栈空间访问
push %rbp操作栈地址0x7ffffffdd000:
页表映射到物理页帧,若未分配则触发缺页,内核分配零页。
(3) 动态内存(如printf缓冲区)
调用printf时,库函数可能使用堆内存(malloc),触发页表扩展。
7.3.4 缺页异常(Page Fault)处理
当页表项标记为“不存在”或权限不足时,CPU触发缺页异常(#PF),内核处理流程:
(1)检查错误原因:
是否合法访问(非法地址触发SIGSEGV)。
是否因惰性分配(如Linux的COW机制)。
(2)加载页面:
从磁盘(如Hello的可执行文件)读取代码页。
或分配零页(如栈扩展)。
更新页表并重试指令。
(3)Hello中的缺页场景:
首次执行main函数时,代码页未加载到内存。
栈增长时(如函数调用深度增加),需分配新物理页。
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 TLB查询(硬件自动完成)
CPU首先用虚拟地址0x401125查询TLB:
若命中:直接获取物理地址(如0x1000125),无需访问页表。
若未命中:触发页表遍历(Page Walk)。
7.4.2 四级页表遍历(Page Walk)
PML4表查询:
从CR3获取PML4表基址(如0x5000)。
索引:(0x401125 >> 39) & 0x1FF = 0。
访问物理地址0x5000 + 0*8 = 0x5000,获取PDP表基址(如0x6000)。
PDP表查询:
索引:(0x401125 >> 30) & 0x1FF = 0。
访问0x6000 + 0*8 = 0x6000,获取PD表基址(如0x7000)。
PD表查询:
索引:(0x401125 >> 21) & 0x1FF = 1。
访问0x7000 + 1*8 = 0x7008,获取PT表基址(如0x8000)。
PT表查询:
索引:(0x401125 >> 12) & 0x1FF = 0。
访问0x8000 + 0*8 = 0x8000,获取物理页帧号(PFN=0x1000)。
合成物理地址:
物理地址 = (0x1000 << 12) | (0x401125 & 0xFFF) = 0x1000125。
7.4.3更新TLB
将0x401125→0x1000125的映射存入TLB,加速后续访问。
7.5 三级Cache支持下的物理内存访问
以读取printf的格式化字符串"Hello %s\n"(物理地址0x1000125)为例:
7.5.1 Cache查询顺序
L1 Cache:
CPU首先查询L1数据缓存(D-Cache),索引由物理地址中间位决定(如0x1000125的位[11:6])。
若命中:3周期内返回数据,无需访问下级缓存。
L2 Cache:
L1未命中时查询L2,检查标签(Tag)是否匹配物理地址高位。
若命中:额外消耗10周期。
L3 Cache:
L2未命中时查询共享的L3缓存。
若命中:总延迟约30周期。
内存访问:
若L3未命中,CPU向内存控制器发送请求,通过总线读取DRAM,总延迟达100+周期。
7.5.2 缓存行填充
即使只需读取"Hello"(5字节),CPU会预取整个64字节缓存行(含相邻数据)。
后续访问同一缓存行内的数据(如"%s\n")直接命中L1。
7.5.3 写操作处理
Write-Through:直接写入内存(较少用)。
Write-Back(主流):
Hello的栈变量修改(如i++)暂存于缓存,仅当缓存行被替换时写回内存。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_ struct、.区域结构和页表的原样副本。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任何一个。后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为新程序的代码、数据、.bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,.bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
(3)映射共享区域。hello程序与共享对象1ibc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器。execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:
(1)检查虚拟地址是否合法,如果不合法则触发一个段错误,终止这个进程。
(2)检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。
(3)两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。
7.9动态存储分配管理
(以下格式自行编排,编辑时删除)
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
7.9.1基本管理方法
(1) 显式分配器(如`malloc/free`)
malloc:根据请求大小在堆中分配内存块,返回指针。
(2) 底层机制
系统调用:`malloc`最终通过`brk`或`mmap`向内核申请内存:
brk:调整堆顶指针,扩展连续堆空间(适合小内存请求)。
mmap:创建匿名内存映射(适合大块内存或频繁释放场景)。
7.9.2 分配策略
(1) 空闲链表管理
隐式空闲链表:通过块头部的元数据(如大小、分配状态)串联所有块。
显式空闲链表:单独维护空闲块链表,加速搜索(如GNU `malloc`)。
(2) 分割与合并
分割:若空闲块远大于请求,将其拆分为分配块+新空闲块。
合并:释放时检查相邻块是否空闲,合并为更大块(减少碎片)。
(3) 适配算法
首次适应(First Fit):从链表头部找到第一个足够大的块。
最佳适应(Best Fit):搜索最小合适的块(减少浪费,增加碎片)。
最差适应(Worst Fit):选择最大的块(适合中等大小请求)。
7.10本章小结
本章系统剖析了hello程序运行过程中的存储管理机制:首先从逻辑地址空间切入,详细解析了Intel处理器的段式寻址原理及其在hello中的实际作用;然后深入探讨了页式管理在hello中的具体实现,包括虚拟地址到物理地址的转换全过程。以Intel Core i7为硬件平台,完整呈现了从VA到PA的地址转换流水线,以及三级缓存架构下的物理内存访问优化策略。通过进程控制视角,重点分析了fork系统调用时写时复制的内存映射机制、execve加载程序时的地址空间重建过程,并对缺页异常触发的中断处理流程进行了技术拆解,包括页面替换算法和内核态处理逻辑。最后结合hello的实际运行场景,验证了上述理论在真实计算环境中的协同工作机制。
结论
一.Hello程序的生命周期
1. 程序编写与编译
源码到汇编:C代码(`hello.c`)经编译器(`gcc`)转换为汇编指令(`hello.s`),保留逻辑地址与符号引用。
汇编到目标文件:汇编器生成可重定位目标文件(`hello.o`),包含未解析的符号表和重定位条目。
静态链接:链接器合并`libc`等库,解析符号,生成可执行文件(`hello`),完成地址绑定。
2. 进程创建与加载
Shell解析命令:Bash调用`fork()`创建子进程,复制父进程上下文(COW优化)。
execve加载:清空子进程地址空间,加载`hello`的ELF文件,重建代码/数据/堆栈段映射。
动态链接:通过`ld.so`加载共享库(如`libc.so`),PLT/GOT实现延迟绑定。
3. 地址转换与内存访问
逻辑→线性地址:x86-64禁用段式管理,逻辑地址直接作为线性地址。
页表查询:MMU通过四级页表(PML4→PDP→PD→PT)将VA(如`0x401125`)转为PA。
TLB加速:缓存近期VA→PA映射,减少页表遍历开销。
三级缓存:L1/L2/L3 Cache分层缓存物理内存数据,降低DRAM访问延迟。
4. 指令执行与异常处理
CPU流水线:按取指→译码→执行→访存→写回流程运行`main`函数。
系统调用:`printf`触发`write()`,通过`syscall`陷入内核,切换特权级。
缺页处理:访问未加载页面时触发`#PF`,内核分配物理页并更新页表。
5. 进程终止
资源释放:`exit()`系统调用回收内存、文件描述符等资源。
父进程回收:Shell通过`wait()`获取子进程状态码。
二.对计算机系统设计的深刻感悟与创新理念
1. 分层抽象与协同优化
现代计算机系统通过硬件-OS-编译器的协同抽象(如虚拟内存、进程模型),在保证安全隔离的前提下,实现高性能与可编程性。感悟在于:
跨层优化:例如TLB与页表硬件的协同设计,或编译器对内存布局的优化(如结构体对齐)。
局部性利用:Hello的循环结构天然契合CPU缓存预取策略,体现算法与硬件的默契。
2. 创新设计思路
(1) 混合内存管理策略
问题:传统COW在频繁写操作时(如Hello初始化大数组)产生大量页复制。
创新:引入写时预复制(Pre-COW),在`fork()`时预测可能修改的页面(如堆栈),提前复制高频写入区,减少后续缺页中断。
(2) 智能页表预热
问题:Hello首次执行因缺页导致冷启动延迟。
创新:通过静态分析ELF文件,在内核加载阶段预填充页表(如`.text`段),结合机器学习预测后续可能访问的数据页。
(3) 缓存感知的内存分配器
问题:`printf`的`malloc`可能引发缓存行冲突。
创新:设计Cache-Local Allocator,根据CPU缓存拓扑(如L1/L2大小、关联度)分配对齐的内存块,避免跨缓存行访问。
3. 系统验证方法革新
形式化验证:用Coq等工具证明Hello的页表映射与TLB一致性。
轻量级仿真:基于QEMU构建Hello的微架构仿真器,可视化VA→PA转换、缓存命中等细节,辅助教学与调试。
三.总结
Hello虽小,却贯穿了计算机系统的核心机制。未来的设计需更紧密地结合硬件特性(如异构计算、NVM)与软件需求(如AI工作负载),在安全(如Spectre缓解)、性能(低延迟内存访问)、能效(动态电压频率调整)间寻求平衡。而Hello这样的微缩模型,正是验证创新思想的理想试验田。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 |
功能 |
hello.c |
源程序 |
hello.i |
预处理后得到的文本文件 |
hello.s |
编译后得到的汇编语言文件 |
hello.o |
汇编后得到的可重定位目标文件 |
hello.elf |
用readelf读取hello.o得到的ELF格式信息 |
hello.asm |
反汇编hello.o得到的反汇编文件 |
hello1.asm |
反汇编hello可执行文件得到的反汇编文件 |
hello |
可执行文件 |
参考文献
[1] Randal E.Bryant David R.O'Hallaron.深入理解计算机系统(第三版).机械工业出版社,2016.
[2] [转]printf 函数实现的深入剖析 - Pianistx - 博客园
[3] printf背后的故事 - Florian - 博客园.
[4] linux2.6 内存管理——逻辑地址转换为线性地址(逻辑地址、线性地址、物理地址、虚拟地址) - 刁海威 - 博客园