计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 物联网工程
学 号 2021111491
班 级 2137301
学 生 业知翰
指 导 教 师 吴锐
计算机科学与技术学院
2023年5月
本论文以hello.c程序为中心,研究了其在Linux系统的整个生命周期,依次深入分析了从hello.c源代码文件经过预处理、编译、汇编、链接、执行、终止和回收的全过程,从而了解hello.c程序的程序人生。其中还结合课程《深入理解计算机系统》以及所学知识了解了Linux操作系统如何对hello.c程序进行进程管理、存储管理和I/O管理。
关键词:计算机系统;底层原理;进程加载;Liunx
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式........................................................................ - 8 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 10 -
6.3 Hello的fork进程创建过程......................................................................... - 10 -
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 -
8.2 简述Unix IO接口及其函数.......................................................................... - 13 -
第1章 概述
1.1 Hello简介
⑴P2P(Program to Process):
在Linux系统中,源代码文件hello.c(program)先经预处理器cpp预处理生成文本文件hello.i,hello.i经编译器cc1编译生成汇编文件hello.s,汇编文件hello.s将被提交给汇编器as,汇编器as将其中的文本翻译成二进制的机器指令形式并打包成可重定位目标程序hello.o,由于hello.c中调用了外部库中的printf函数,为此需要链接器ld将printf.o和hello.o进行链接生成可执行程序文件hello,最后在shell中键入命令./hello后,操作系统(OS)的进程管理为其fork创建子进程 (process)。
⑵O2O(Zero-0 to Zero-0):
程序执行前,不占用内存空间(第1个Zero-0)。P2P后,shell子进程首先调用execve,将程序内容依次进行虚拟内存映射、物理内存载入,随后进入主函数main执行程序代码,程序调用各种系统函数实现屏幕输出信息等功能,程序运行结束后,shell父进程回收此子进程,释放虚拟内存空间和数据结构等相关内容清除(第2个Zero-0)。
1.2 环境与工具
⑴硬件环境:
X64 CPU;2GHz;2G RAM;256GHD Disk 以上
⑵软件环境:
Windows7/10 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上;
⑶开发工具:
Visual Studio 2010 64位以上;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
说明 | 对应章节 | |
hello.i | hello.c预处理生成的文本文件 | 第2章 |
hello.s | hello.i编译生成的汇编文件 | 第3章 |
hello.o | hello.s汇编生成的可重定位目标文件 | 第4章 |
hello1.elf | hello.o经readelf分析得到的.elf文件 | 第4章 |
hello1.asm | hello.o经objdump反汇编得到的文本文件 | 第4章 |
hello | hello.o链接生成的可执行文件 | 第5章 |
hello2.elf | hello经readelf分析得到的.elf文件 | 第5章 |
hello2.asm | hello经objdump反汇编得到的文本文件 | 第5章 |
1.4 本章小结
本章简要介绍了P2P、O2O的概念,随后列出了使用的环境及工具的相关信息,最后介绍了论文过程中用到的所有中间文件。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理是指对某源文件进行编译前,系统将自动检查包含预处理指令的语句和宏定义并进行相应的替换,还会删除注释及多余空白字符等,最后将调整后的源代码给编译器。C语言提供多种预处理功能,主要处理#开始的预处理指令,如宏定义(#define)、文件包含(#include)、条件编译(#ifdef)等。
预处理的作用:
- 宏展开:预处理程序中的 #define标识符文本,用实际值(字符串、代码等)替换用 #define定义的字符串;
- 文件包含复制:预处理程序中用 #include格式包含的文件,将文件的内容插入到该命令所在的位置并删除原命令,从而把包含的文件和当前源文件连接成一个新的源文件;
- 条件编译处理:根据 #if和 #endif 、#ifdef和 #ifndef后面的条件确定需要编译的源代码。
2.2在Ubuntu下预处理的命令
预处理的命令:
gcc -E hello.c -o hello.i
预处理的命令截图:
2.3 Hello的预处理结果解析
原本的23行hello.c文件经过预处理生成了3060行的hello.i预处理文本文件
⑴源代码文件信息(第1 ~ 7行):
⑵预处理扩展内容(第13 ~ 3041行)
①文件包含信息:
②类型定义信息:
③函数声明信息:
⑶源代码(第3043 ~ 3060行):
2.4 本章小结
了解了预处理的概念、作用和命令,分析了hello.c源代码文件的预处理过程,通过生成的.i文件与.c文件进行了结果比对,加深了我对预处理的理解,为之后的编译等流程奠定了基础。
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译是指对经过预处理之后的源程序代码通过编译器ccl进行词法分析和语法分析,所有语句均符合语法规则后将其翻译成等价的中间代码或汇编代码的过程。
编译的作用:
- 词法分析:对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序;
- 语法分析:编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序;
- 中间代码:使程序的结构在逻辑上更为简单明确;
- 代码优化:对程序进行多种等价变换,便于生成更有效的目标代码;
- 目标代码:目标代码生成器把语法分析后或优化后的中间代码变换成目标代码,此处目标代码为汇编代码。
3.2 在Ubuntu下编译的命令
编译的命令:
gcc -S hello.i -o hello.s
编译的命令截图:
3.3 Hello的编译结果解析
3.3.1文件结构分析:
⑴汇编代码:
.file "hello.c"
.text
.section .rodata
.align 8
.string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201"
.LC1:
.string "Hello %s %s\n"
.text
.globl main
.type main, @function
⑵分析:
内容 | 含义 |
.file | 源文件 |
.text | 代码段 |
.section .rodata | 存放只读变量 |
.align | 对齐方式 |
.string | 表示是string类型 |
.type | 表示是函数类型/对象类型 |
3.3.2数据:
3.3.2.1常量数据:
⑴源程序代码:
①if(argc!=4){
②printf("用法: Hello 学号 姓名 秒数!\n");
③exit(1);
④for(i=0;i<8;i++){
⑤printf("Hello %s %s\n",argv[1],argv[2]);
⑵汇编代码:
①cmpl $4, -20(%rbp)
②.LC0:
.string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201"
③movl $1, %edi
④movl $0, -4(%rbp)
cmpl $7, -4(%rbp)
addl $1, -4(%rbp)
⑤.LC1:
.string "Hello %s %s\n"
3.3.2.2变量数据:
3.3.2.2.1局部变量:
⑴源程序代码:
int i;
for(i=0;i<8;i++){
⑵汇编代码:
movl $0, -4(%rbp)
cmpl $7, -4(%rbp)
3.3.3赋值:
⑴源程序代码:
①exit(1);
②for(i=0;i<8;i++){
⑵汇编代码:
①movl $1, %edi
②movl $0, -4(%rbp)
3.3.4类型转换(隐式或显式) :
⑴源程序代码:
sleep(atoi(argv[3]));
⑵汇编代码:
call atoi@PLT
3.3.5算术操作:
⑴源程序代码:
for(i=0;i<8;i++){
⑵汇编代码:
addl $1, -4(%rbp)
3.3.6关系操作:
①if(argc!=4){
②for(i=0;i<8;i++){
⑵汇编代码:
①cmpl $4, -20(%rbp)
je .L2
②cmpl $7, -4(%rbp)
jle .L4
3.3.7数组/指针/结构操作:
⑴源程序代码:
int main(int argc,char *argv[]){
printf("Hello %s %s\n",argv[1],argv[2]);
sleep(atoi(argv[3]));
⑵汇编代码:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
3.3.8控制转移:
⑴源程序代码:
①if(argc!=4){
②for(i=0;i<8;i++){
⑵汇编代码:
①cmpl $4, -20(%rbp)
je .L2
②cmpl $7, -4(%rbp)
jle .L4
3.3.9函数操作:
⑴main()函数:
①参数传递:int argc, char *argv[]。
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
②函数调用:无。
main:
.LFB6:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
③局部变量:int i;
④函数返回:参数个数不正确返回1,正确返回0。
movl $1, %edi
call exit@PLT
movl $0, %eax
leave
⑵printf()函数:
①参数传递:需要输出的字符串。
printf("用法: Hello 学号 姓名 秒数!\n");
leaq .LC0(%rip), %rdi
call puts@PLT
printf("Hello %s %s\n",argv[1],argv[2]);
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
leaq .LC1(%rip), %rdi
movl $0, %eax
call printf@PLT
②函数调用:通过call指令调用。
③局部变量:无
④函数返回:忽略返回值。
⑶exit()函数:
①参数传递:退出状态值。
exit(1);
movl $1, %edi
call exit@PLT
②函数调用:通过call指令调用。
③局部变量:无
④函数返回:函数不返回,直接退出程序。
⑷sleep()函数:
①参数传递:休眠时间。
sleep(atoi(argv[3]));
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
movq %rax, %rdi
call atoi@PLT
movl %eax, %edi
call sleep@PLT
②函数调用:通过call指令调用。
③局部变量:无
d)函数返回:返回实际休眠时间。
⑸getchar()函数:
①参数传递:无。
②函数调用:通过call指令调用。
③局部变量:无
④函数返回:返回char类型值,被忽略。
3.4 本章小结
了解了编译的概念、作用和命令,编译是将文本文件翻译成汇编语言程序,为后续转化为二进制机器码做准备的过程,通过编译器处理各个数据类型以及各类操作,验证了大部分数据、操作在汇编代码中的实现,加深了我对编译阶段的理解。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
汇编是指汇编器将汇编语言程序翻译成机器语言指令,并将这些指令打包成可重定位目标文件的过程。
汇编的作用:
将汇编程序转化为机器可直接识别执行的机器语言程序。
4.2 在Ubuntu下汇编的命令
汇编的命令:
gcc -c hello.s -o hello.o
汇编的命令截图:
4.3 可重定位目标elf格式
命令:readelf -a hello.o > hello1.elf
分析:
Magic用于标识ELF文件,其描述了生成该文件的系统的字的大小和字节顺序,7f 45 4c 46分别对应ASCII码的Del、字母E、字母L、字母F,操作系统在加载可执行文件时会确认是否正确,如果不正确则拒绝加载,其余标识位数、小/大端序、版本号等,后九个字节未定义。ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
⑵节头:
分析:
列出了hello.o中的14个节的名称、类型、地址、偏移量、大小等信息。由于是可重定位目标文件,所以每个节都从0开始,用于重定位,.text段是可执行的,但是不能写,.data段和.rodata段都不可执行且.rodata段不可写,.bss段大小为0。
⑶重定位节:
分析:
重定位节记录了各段引用的符号相关信息,在链接时,需要通过重定位节对这些位置的地址进行重定位。链接器会通过重定位条目的类型判断如何计算地址值并使用偏移量等信息计算出正确的地址。本程序9条重定位信息分别是对.L0(第一个 printf 中的字符串)、puts 函数、exit 函数、.L1(第二个 printf 中的字符串)、printf 函数、atoi函数、sleep 函数、getchar 函数和.text进行重定位声明。注意到重定位类型仅有R_X86_64_PC32(PC相对寻址)和R_X86_64_PLT32(使用PLT表寻址)两种,而未出现R_X86_64_32(绝对寻址)。
偏移量 | 代表需要进行重定向的代码在.text或.data节中的偏移位置。 |
信息 | 包括symbol和type两部分,其中symbol占前半部分,type占后半部分,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型。 |
类型 | 重定位到的目标的类型。 |
加数 | 计算重定位位置的辅助信息。 |
⑷符号表:
分析:
符号表保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o > hello1.asm
比较分析:
(1) 分支控制转移不同:对于跳转语句跳转的位置,hello.s中是.L2、.L3等代码块的名称,而反汇编代码中跳转指令跳转的位置是相对于main函数起始位置偏移的地址(相对地址);
(2) 函数调用表示不同:hello.s中,call指令使用的是函数名,而反汇编代码中call指令使用的是待链接器重定位的相对偏移地址,这些调用只有在链接之后才能确定运行时的实际地址,因此在.rela.text节中为其添加了重定位条目,等待静态链接进一步确定;
(3) 全局变量访问不同:hello.s中的全局变量、printf字符串等符号被替换成了待重定位的地址,其原因与函数调用类似,rodata 中数据地址在运行时才能确定;
(4) 数的表示不同:hello.s中的操作数均为十进制,而hello.o反汇编代码中的操作数被转换成十六进制;
(5) hello.s中提供给汇编器的辅助信息在hello.o反汇编代码中不再出现,如.cfi_def_cfa_offset 16等。
说明:
反汇编代码与hello.s比较发现,汇编指令代码几乎相同,反汇编代码除了汇编代码之外,最左侧为相对地址,还显示了机器代码,在左侧用16进制表示。机器指令有操作码和操作数组成,和汇编指令一一对应。
跳转指令和函数调用等指令,在反汇编代码中表示为对应地址的偏移,而在hello.s中直接表示为函数名或定义的符号。
4.5 本章小结
了解了汇编的概念、作用和命令,通过实际操作将hello.s文件翻译为hello.o文件,并生成hello.o的ELF格式文件hello.elf,研究了ELF格式文件的具体结构。通过比较hello.s的代码与hello.o的反汇编代码,了解了汇编语言与机器语言的异同之处与一一对应关系。为随后的链接阶段做好了准备,加深了我对汇编过程、ELF格式以及重定位的理解。
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接是指通过链接器,将程序编码与数据块收集并整理成为一个单一文件,生成完全链接的可执行的目标文件的过程。
链接的作用:
提供了一种模块化的方式,可以将程序编写为一个较小的源文件的集合,且实现了分开编译更改源文件,不用将一个大型的应用程序组织为一个巨大的源文件,从而减少整体文件的复杂度与大小,增加容错性,当改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
链接的命令:
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o hello.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -o hello
链接的命令截图:
5.3 可执行目标文件hello的格式
命令:readelf -a hello > hello2.elf
⑴ELF 头:
分析:
Magic用于标识ELF文件,其描述了生成该文件的系统的字的大小和字节顺序,7f 45 4c 46分别对应ASCII码的Del、字母E、字母L、字母F,操作系统在加载可执行文件时会确认是否正确,如果不正确则拒绝加载,其余标识位数、小/大端序、版本号等,后九个字节未定义。ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
与hello1.elf相比较,hello2.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
⑵节头:
分析:
列出了hello.o中的27个节的名称、类型、地址、偏移量、大小等信息。
与hello.elf相比,hello2.elf在链接之后的内容更加丰富详细。
⑶程序头:
分析:
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
⑷Section to Segment mapping:
⑸Dynamic section at offset 0x2e50 contains 21 entries:
⑹重定位节:
分析:
重定位节记录了各段引用的符号相关信息,在链接时,需要通过重定位节对这些位置的地址进行重定位。链接器会通过重定位条目的类型判断如何计算地址值并使用偏移量等信息计算出正确的地址。本程序8条重定位信息分别是对__libc_start_main、__gmon_start__ 、puts 函数、printf 函数、getchar 函数、atoi函数、exit 函数、sleep 函数进行重定位声明。
偏移量 | 代表需要进行重定向的代码在.text或.data节中的偏移位置。 |
信息 | 包括symbol和type两部分,其中symbol占前半部分,type占后半部分,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型。 |
类型 | 重定位到的目标的类型。 |
加数 | 计算重定位位置的辅助信息。 |
⑺符号表:
分析:
符号表保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
5.4 hello的虚拟地址空间
对照分析:
Data Dump窗口中显示的就是hello的虚拟空间内容,显示范围为0x401000至0x402000。
与Symbols窗口对照(即与5.3对照)可以发现各段均一一对应,可以看出各段的虚拟地址与节头的对应关系。
5.5 链接的重定位过程分析
命令:objdump -d -r hello > hello2.asm
分析:
hello的反汇编代码比hello.o的反汇编代码多了一些节,如.init, .plt, .plt.sec等。此外,hello中加入了一些函数,如_init(),_start()以及一些主函数中调用的库函数,hello中不再存在hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。
可以看出链接过程会分析所有相关的可重定位目标文件,并进行符号解析,将每个符号引用与一个符号定义关联起来。然后进行重定位,链接器使用汇编器产生的重定位条目的详细指令,把每个符号定义与一个内存位置关联起来。最终将程序运行所需的各部分组装在一起,形成一个可执行目标文件。
5.6 hello的执行流程
程序名称 | 程序地址 |
hello!_start | 0x4010f0 |
hello!_init | 0x401000 |
hello!main | 0x401125 |
hello!puts@plt | 0x401030 |
hello!printf@plt | 0x401040 |
hello!getchar@plt | 0x401050 |
hello!atoi@plt | 0x401060 |
hello!exit@plt | 0x401070 |
hello!sleep@plt | 0x401080 |
5.7 Hello的动态链接分析
分析:
调用dl_init之前.got.plt段的内容:
调用dl_init之后.got.plt段的内容:
延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)的协同工作实现函数的动态链接,其中GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。在此之后,程序调用共享库函数时,会首先跳转到PLT执行指令,第一次跳转时,GOT条目为PLT下一条指令,将函数ID压栈,然后跳转到PLT[0],在PLT[0]再将重定位表地址压栈,然后转进动态链接器,在动态链接器中使用两个栈条目确定函数运行时地址,重写GOT,再将控制传递给目标函数。以后如果再次调用同一函数,则通过间接跳转将控制直接转移至目标函数。
5.8 本章小结
了解了链接的概念、作用和命令,围绕可重定位目标文件hello.o链接生成可执行目标文件hello的过程,验证了hello的虚拟地址空间与节头部表信息的对应关系,分析了hello的执行流程,最后对hello程序进行了动态链接分析。更加深刻地理解了链接和重定位的相关概念,了解了动态链接的过程及作用。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
进程是一个正在运行的程序的实例,是操作系统对一个正在运行的程序的一种抽象,是系统进行资源分配和调度的基本单位, 是操作系统结构的基础,系统中的每一个程序都运行在某个进程的上下文中。
进程的作用:
给应用程序提供两个关键抽象:
①一个独立的逻辑控制流,提供一个假象,好像程序独占地使用处理器
②一个私有地址空间,提供一个假象,好像程序独占地使用内存系统
6.2 简述壳Shell-bash的作用与处理流程
壳Shell-bash的作用:
Shell-bash是一个交互型应用级程序,代表用户运行其他程序。Shell-bash是系统的用户界面,提供了用户与内核进行交互操作的一种接口,用户可以通过这个界面进行系统的基本操作,访问操作系统内核的服务。
壳Shell-bash的处理流程:
①从终端读入输入的命令;
②将输入字符串切分获得所有的参数;
③若输入参数为内置命令,则立即执行;
④若输入参数并非内置命令,则调用相应的程序为其分配子进程并运行;
⑤若输入参数非法,则返回错误信息;
⑥处理完当前参数后继续处理下一参数,直到处理完毕。
6.3 Hello的fork进程创建过程
打开Shell,输入命令./hello 2021111491 业知翰,带参数执行生成的可执行文件。Shell判断其不是内部指令,即会通过fork函数创建子进程。首先,带参执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程hello。子进程获取了与父进程的上下文,包括栈、共享库、堆、通用寄存器和程序计数器等,是一份与父进程用户级虚拟空间相同且独立的副本。子进程与父进程的最大区别是有不一样的PID,fork函数被调用一次会返回两次,在父进程中,fork函数返回子进程的PID,在子进程中,fork函数返回0。当子进程运行结束时,父进程如果仍然存在,则执行对子进程的回收,否则就由init进程回收子进程。
+------------>子进程
|
|
-----+------------>父进程(shell)
fork
6.4 Hello的execve过程
函数原型:int execve (char *filename, char *argv [], char *envp [])(filename:可执行文件,argv:参数列表,惯例:argv [0] == filename,envp:环境变量列表)
execve函数在当前进程中载入并运行一个新程序hello,调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数。execve函数执行过程会覆盖当前进程的代码、数据、栈,保留有相同的 PID ,继承已打开的文件描述符和信号上下文。调用一次并从不返回,除非有错误,例如:指定的文件不存在。
6.5 Hello的进程执行
上下文信息:
内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。在内核调度了一个新的进程运行时,它就抢占当前进程,并使用一种上下文切换的机制来控制转移到新的进程。
具体过程为:
①保存当前进程的上下文;
②恢复某个先前被抢占的进程被保存的上下文;
③将控制传递给这个新恢复的进程。
进程时间片:
一个进程执行它的控制流的一部分的每一个时间段叫做时间片(time slice),多任务也叫时间分片(time slicing)。
进程调度的过程:
hello程序调用sleep函数休眠时,内核将通过进程调度进行上下文切换,将控制转移到其他进程。当hello程序休眠结束后,进程调度使hello程序重新抢占内核,继续执行。
用户态与核心态转换:
为了保证系统安全,需要限制应用程序所能访问的地址空间范围。因而存在用户态与核心态的划分,核心态拥有最高的访问权限,而用户态的访问权限会受到一些限制。处理器使用一个寄存器作为模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,一定程度上保证了系统的安全性。
6.6 hello的异常与信号处理
⑴程序正常运行(打印8次提示信息,以输入回车为标志结束程序,并回收进程。):
⑵不停乱按(在程序执行过程中乱按所造成的输入均缓存到stdin,当getchar的时候读出一个"\n"结尾的字串(作为一次输入),hello结束后stdin中的字符串会当做Shell的命令行输入。):
⑶回车(会多打印几处空行,程序可以正常结束。):
⑷Ctrl-Z(Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。):
①运行ps(发现hello进程确实被挂起而非被回收):
②运行jobs(发现hello进程确实被挂起而非被回收):
③运行pstree(将所有进程以树状图显示):
④运行fg(将hello进程再次调到前台执行,可以发现Shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。):
⑤运行kill(杀死指定进程组的进程):
⑸Ctrl-C(Shell进程收到SIGINT信号,Shell结束并回收hello进程。):
⑴hello执行过程中的异常和处理:
异步异常:
中断:来自I/O设备的信号(Ctrl-C、Ctrl-Z)。
同步异常:
①陷阱:有意的异常,执行指令的结果(例如:exit);
②故障:潜在可恢复的错误(如:缺页异常)。
⑵hello执行过程中的信号和处理:
①SIGINT:键入Ctrl-C后内核向hello进程发送,终止程序。
②SIGSTP:键入Ctrl-Z后内核向hello进程发送,停止直到下一个SIGCONT。
③SIGCONT:键入fg后内核向hello进程发送,若停止则继续执行。
④SIGKILL:键入kill -9 <PID>后内核向hello进程发送,终止程序。
6.7本章小结
了解了进程的概念与作用,以及Shell-bash的作用与处理流程。根据hello可执行文件的具体示例研究了fork函数,execve函数的原理与执行过程,紧接着给出了hello带参执行情况下各种异常与信号处理的结果。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:
逻辑地址是指由程序产生的与段相关的偏移地址部分,逻辑地址由选择符和偏移量两部分组成。如hello.o中代码与数据的相对偏移地址。
线性地址:
逻辑地址经过段机制转化后为线性地址,其为处理器可寻址空间的地址,用于描述程序分页信息的地址。如hello中代码与数据的地址。
虚拟地址:
逻辑地址和线性地址也称为虚拟地址,因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的。
物理地址:
物理地址是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如hello程序运行时代码、数据等对应的可用于直接在内存中寻址的地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在段式管理中,逻辑地址(也称为虚拟地址)由两部分组成:段选择子和偏移量。
段选择子是一个16位的值,它指定了段表中的哪一项来映射逻辑地址。 段表是一种数据结构,用于存储每个段的基地址和长度。 偏移量是一个16位的值,它表示从段的基地址开始计算的偏移量。
为了将逻辑地址转换为线性地址,首先需要从段选择子中找到对应的段描述符。 段描述符中包含了段的基地址和长度。 通过将段选择子左移3位(因为每个段描述符在段描述符表中占用8个字节,即64位,所以每个段选择子乘以8就可以得到段描述符在段描述符表中的偏移量),可以得到段描述符在段描述符表中的偏移量。 然后可以通过该偏移量找到对应的段描述符,并从中读取段的基地址和长度。
接下来,将段的基地址与偏移量相加,就可以得到线性地址。 如果线性地址超出了进程的地址空间,则会触发一个异常。
最后,线性地址将被送到页表进行页式管理的转换,以获得物理地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
在页式管理中,每个进程都被分为固定大小的页,每个页都有一个唯一的页号。这些页存储在物理内存中的物理页框中。
为了将线性地址转换为物理地址,需要进行以下步骤:
①首先,将线性地址(虚拟地址)VA拆分为虚拟页号VPN和虚拟页内偏移量VPN。虚拟页号用于查找该页所在的物理页框,而虚拟页内偏移量则用于定位页内的具体地址。
②接下来,需要访问页表来查找该页所在的物理页框号。页表是一个数据结构,用于将每个页号映射到其对应的物理页号。利用虚拟页号VPN来选择适当的页表项PTE,若PTE有效位为1,则说明其后内容为物理页号PPN,否则缺页。
③一旦找到物理页号PPN,就可以将虚拟页内偏移量添加到该物理页框的起始地址上虚拟页内偏移量PPO,PPN与PPO连接获取物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
针对Intel Core i7 CPU研究VA到PA的变换。
Intel Core i7 CPU的基本参数如下:
⑴虚拟地址空间48位(n=48)
⑵物理地址空间52位(m=52)
⑶TLB四路十六组相连
⑷L1,L2,L3块大小为64字节
⑸L1,L2八路组相连
⑹L3十六路组相连
⑺页表大小4KB(P=4x1024=2^12),四级页表,页表条目(PTE)大小8字节
由上述信息可以得知,VPO与PPO有p=12位,故VPN为36位,PPN为40位。单个页表大小4KB,PTE大小8字节,则单个页表有512个页表条目,需要9位二进制进行索引,而四级页表则需要36位二进制进行索引,对应着36位的VPN。TLB有16组,故TLBI有t=4位,TLBT有36-4=32位。
如图所示, CPU产生虚拟地址VA,并将其传送至MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)在TLB中进行匹配,若命中,则得到PPN(40bit)与VPO(12bit)组合成物理地址PA(52bit)。若TLB没有命中,则MMU向页表中查询,由CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,则执行下一步确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并向TLB中添加条目。多级页表的工作原理展示如下:
若查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
7.5 三级Cache支持下的物理内存访问
三级Cache的工作原理基本相同,所以在这里以L1 Cache为例,介绍三级Cache支持下的物理内存访问。
L1 Cache的基本参数如下:
①8路64组相连
②块大小64字节
由L1 Cache的基本参数,可以分析知:
块大小64字节→需要6位二进制索引→块偏移6位
共64组→需要6位二进制索引→组索引6位
余下标记位→需要PPN+PPO-6-6=40位
故L1 Cache可被划分如下(从左到右):
CT(40bit)CI(6bit)CO(6bit)
在7.4中我们已经由虚拟地址VA转换得到了物理地址PA,首先使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO取出相应的数据后返回。
若没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中请求数据(请求顺序为L2 Cache→L3 Cache→主存,若仍不命中才继续向下一级请求)。查询到数据之后,需要对数据进行读入,一种简单的放置策略如下:若映射到的组内有空闲块,则直接放置在空闲块中,若当前组内没有空闲块,则产生冲突(evict),采用LFU策略进行替换。
7.6 hello进程fork时的内存映射
当fork函数被父进程(shell)调用时,内核为新进程(未来加载执行hello的进程)创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射
execve加载和运行hello程序会经过以下步骤:
①删除已存在的用户区域:这里指在fork后创建于此进程用户区域中的shell父进程用户区域副本。
②映射私有区域:为hello程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射到hello可执行文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
③映射共享区域:hello程序与一些共享对象或目标链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
④设置程序计数器(PC):设置此进程上下文中的程序计数器,使之指向hello代码区域的入口点。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。缺页故障属于异常类别中的故障,是潜在可恢复的错误,主要处理流程可见本文6.6节中关于故障处理的部分。
缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果牺牲页已经被修改了,内核会将其复制回磁盘。随后内核从磁盘复制引发缺页异常的页面至内存,更新对应的页表项指向这个页面,随后返回。
缺页异常处理程序返回后,内核会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件,此次页面会命中。
7.9动态存储分配管理
⑴动态内存管理的基本方法与策略介绍:
动态内存分配器维护着一个称为堆的进程的虚拟内存区域。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
⑵分配器分为两种基本风格:
①显式分配器:
要求应用显式地释放任何已分配的块。必须在一些相当严格的约束条件下工作:①处理任意请求序列;②立即响应请求;③只使用堆;④对齐块(对齐要求);⑤不修改已分配的块。在以上限制条件下,分配器要最大化吞吐率和内存使用率。
②隐式分配器:
要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
⑶一些组织内存块的方法:
①隐式链表:
堆中的空闲块通过头部中的大小字段隐含地连接,分配器通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
②显式链表:
在每个空闲块中,都包含一个前驱(pred)与后继(succ)指针,从而减少了搜索与适配的时间。
③分离的空闲链表:
将块按块大小划分大小类,分配器维护一个空闲链表数组,每个大小类一个空闲链表,减少分配时间同时也提高了内存利用率。C语言中的malloc程序包采用的就是这种方法。
④红黑树等树形结构:
按块大小将空闲块组织为树形结构,同样有减少分配时间和提高内存利用率的作用。
⑷重要概念:
①带边界标记的合并:
采取使用边界标记的堆块的格式,在堆块的末尾为其添加一个脚部,其为头部的副本。添加脚部之后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。从而实现快速合并,减小性能消耗。
②分离存储:
维护多个空闲链表,其中,每个链表的块具有相同的大小。将所有可能的块大小分成一些等价类,从而进行分离存储。
7.10本章小结
了解了hello的存储器地址空间、intel逻辑地址到线性地址的变换-段式管理、Hello的线性地址到物理地址的变换-页式管理、TLB与四级页表支持下的VA到PA的变换、三级Cache支持下的物理内存访问、hello进程fork时的内存映射、hello进程execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。看到了现代计算机系统为提高内存存储效率和使用率以至程序运行的效率使用了大量的机制和技术。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。
设备管理:unix io接口
将设备模型化为文件的方式允许Linux内核引入一个简单、低级的应用接口,称为Unix IO,这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件。
8.2 简述Unix IO接口及其函数
Unix IO接口:
①打开文件:
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。对于Shell创建的每个进程,其都有三个打开的文件:标准输入,标准输出,标准错误。
②改变当前的文件位置:
对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
③读写文件:
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
④关闭文件:
内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
函数:
①open()函数:
函数原型:int open(char *filename, int flags, mode_t mode);
打开一个文件,将filename转换为一个文件描述符,并返回进程中未打开的最小描述符。flags参数指明进程如何访问文件,mode参数指定新文件的访问权限位。
②close()函数:
函数原型:int close(int fd);
关闭一个打开的文件,关闭一个已关闭的描述符会出错。
③read()函数:
函数原型:ssize_t read(int fd, void *buf, size_t n);
从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
④write()函数:
函数原型:ssize_t write(int fd, const void *buf, size_t n);
从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
⑤lseek()函数:
通过调用此函数,应用程序能够显式地修改当前文件的位置。
8.3 printf的实现分析
⑴printf函数体:
int printf(const char *fmt, ...)
{
int i;
va_list arg = (va_list)((char *)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
分析:
①printf函数调用了vsprintf函数,最后通过系统调用函数write进行输出;
②va_list是字符指针类型;
③((char *)(&fmt) + 4)表示...中的第一个参数。
⑵printf调用的vsprintf函数:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char *p;
chartmp[256];
va_listp_next_arg = args;
for (p = buf; *fmt; fmt++)
{
if (*fmt != '%')
{
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt)
{
case 'x':
itoa(tmp, *((int *)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
/* 这里应该还有一些对于
其他格式输出的处理 */
default:
break;
}
return (p - buf);
}
}
分析:
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出写入buf供系统调用write输出时使用。
⑶write系统调用:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
分析:
这里通过几个寄存器进行传参,随后调用中断门int INT_VECTOR_SYS_CALL,即通过系统来调用sys_call实现输出这一系统服务。
⑷sys_call部分内容:
sys_call:
xor si,si
mov ah,0Fh
mov al,[ebx+si]
cmp al,'\0'
je .end
mov [gs:edi],ax
inc si
loop:
sys_call
.end:
ret
分析:
通过逐个字符直接写至显存,输出格式化的字符串。
⑸字符显示驱动子程序实现从ASCII到字模库到显示vram(即显存,存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
当程序运行至getchar函数时,程序通过系统调用read等待用户键入字符并按回车键(通知系统输入完成)
#include "sys/syscall.h"
#include <stdio.h>
int getchar(void)
{
char c;
return (read(0,&c,1)==1)?(unsigned char)c:EOF //EOF定义在stdio.h文件中
}
当用户键入回车之后,getchar通过系统调用read从输入缓冲区中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1。
异步异常——键盘中断(用户输入)的处理:键盘中断处理子程序接受按键扫描码并转成ASCII码,保存在系统的键盘缓冲区。
8.5本章小结
了解了Linux的IO设备管理方法、Unix IO接口及其函数、printf的实现、getchar的实现。体会到Unix IO接口在Linux系统中的重要作用,同时也了解了作为异步异常之一的键盘中断的处理。
结论
⑴hello所经历的过程:
①预处理
将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中,完成字符串的替换,方便后续处理;
②编译
通过词法分析和语法分析,将合法指令翻译成等价汇编代码。通过编译过程,编译器将hello.i翻译成汇编语言文件hello.s;
③汇编
将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o目标文件中;
④链接
通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello;
⑤加载运行
打开Shell,输入./hello 2021111491 业知翰,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;
⑥执行指令
在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器一步一步地更新,CPU不断地取指,顺序执行自己的控制逻辑流;
⑦访存
内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存/磁盘中的数据;
⑧动态申请内存
printf会调用malloc向动态内存分配器申请堆中的内存;
⑨信号处理
进程时刻等待着信号,如果运行途中输入Ctrl-C,Ctrl-Z则调用Shell 的信号处理函数分别进行停止、挂起等操作,对于其他信号也有相应的操作;
⑩终止并被回收
Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
⑵深切感悟:
计算机系统的设计和实现大量体现了抽象的思想,计算机系统中的原理和机制相当的复杂和精巧,从宏观角度对计算机的底层原理进行窥探。计算机系统课程内容虽然繁多,但逻辑结构清晰、层次分明。引领我从程序员的角度第一次系统地、全面地认识了现代操作系统的各种机制、设计和运行原理。在未来我将继续深入学习相关知识并在实践中不断使用和创新。
附件
文件名 | 说明 | 对应章节 |
hello.i | hello.c预处理生成的文本文件 | 第2章 |
hello.s | hello.i编译生成的汇编文件 | 第3章 |
hello.o | hello.s汇编生成的可重定位目标文件 | 第4章 |
hello1.elf | hello.o经readelf分析得到的.elf文件 | 第4章 |
hello1.asm | hello.o经objdump反汇编得到的文本文件 | 第4章 |
hello | hello.o链接生成的可执行文件 | 第5章 |
hello2.elf | hello经readelf分析得到的.elf文件 | 第5章 |
hello2.asm | hello经objdump反汇编得到的文本文件 | 第5章 |
参考文献
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统(原书第三版)[M]. 机械工业出版社.2016.
[2] C语言编译和链接详解(通俗易懂,深入本质). C语言中文网. http://c.biancheng.net/view/1736.html
[3] ELF格式可重定位目标文件. CSDN. https://blog.csdn.net/zycdeCSDN/article/details/102084045
[4] readelf命令使用说明. CSDN. https://blog.csdn.net/yfldyxl/article/details/81566279?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522165260367716781818741548%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=165260367716781818741548&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-1-81566279-null-null.142%5ev9%5econtrol,157%5ev4%5enew_style&utm_term=readelf&spm=1018.2226.3001.4187
[5] read和write系统调用以及getchar的实现. CSDN. https://blog.csdn.net/ww1473345713/article/details/51680017
[6] printf函数实现的深入剖析. 博客园. https://www.cnblogs.com/pianist/p/3315801.html