正在上传…重新上传取消
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 未来技术学院2+x人工智能模块
学 号 2021113258
班 级 21WL024
学 生 曹云涵
指 导 教 师 郑贵滨
计算机科学与技术学院
2023年5月
本文介绍了程序员第一课,即经典程序“hello world”从“出生”到“死亡”的过程,即p2p、020的过程,从程序到进程, 从运行到最后被回收的过程。在这个过程中通过查找资料和研究学习,了解了一个程序在计算机系统中经历的一些列处理过程,加深了对计算机系统工作流程和工作模式的理解。
关键词:hello world;计算机系统;程序;进程
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述................................................... - 4 -
1.1 Hello简介............................................ - 4 -
1.2 环境与工具........................................... - 4 -
1.3 中间结果............................................... - 4 -
1.4 本章小结............................................... - 4 -
第2章 预处理............................................... - 5 -
2.1 预处理的概念与作用........................... - 5 -
2.2在Ubuntu下预处理的命令................ - 5 -
2.3 Hello的预处理结果解析.................... - 5 -
2.4 本章小结............................................... - 5 -
第3章 编译................................................... - 6 -
3.1 编译的概念与作用............................... - 6 -
3.2 在Ubuntu下编译的命令.................... - 6 -
3.3 Hello的编译结果解析........................ - 6 -
3.4 本章小结............................................... - 6 -
第4章 汇编................................................... - 7 -
4.1 汇编的概念与作用............................... - 7 -
4.2 在Ubuntu下汇编的命令.................... - 7 -
4.3 可重定位目标elf格式........................ - 7 -
4.4 Hello.o的结果解析............................. - 7 -
4.5 本章小结............................................... - 7 -
第5章 链接................................................... - 8 -
5.1 链接的概念与作用............................... - 8 -
5.2 在Ubuntu下链接的命令.................... - 8 -
5.3 可执行目标文件hello的格式........... - 8 -
5.4 hello的虚拟地址空间......................... - 8 -
5.5 链接的重定位过程分析....................... - 8 -
5.6 hello的执行流程................................. - 8 -
5.7 Hello的动态链接分析........................ - 8 -
5.8 本章小结............................................... - 9 -
第6章 hello进程管理.......................... - 10 -
6.1 进程的概念与作用............................. - 10 -
6.2 简述壳Shell-bash的作用与处理流程.. - 10 -
6.3 Hello的fork进程创建过程............ - 10 -
6.4 Hello的execve过程........................ - 10 -
6.5 Hello的进程执行.............................. - 10 -
6.6 hello的异常与信号处理................... - 10 -
6.7本章小结.............................................. - 10 -
第7章 hello的存储管理...................... - 11 -
7.1 hello的存储器地址空间................... - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................................ - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理........................................................ - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................................ - 11 -
7.6 hello进程fork时的内存映射......... - 11 -
7.7 hello进程execve时的内存映射..... - 11 -
7.8 缺页故障与缺页中断处理................. - 11 -
7.9动态存储分配管理.............................. - 11 -
7.10本章小结............................................ - 12 -
第8章 hello的IO管理....................... - 13 -
8.1 Linux的IO设备管理方法................. - 13 -
8.2 简述Unix IO接口及其函数.............. - 13 -
8.3 printf的实现分析.............................. - 13 -
8.4 getchar的实现分析.......................... - 13 -
8.5本章小结.............................................. - 13 -
结论............................................................... - 14 -
附件............................................................... - 15 -
参考文献....................................................... - 16 -
第1章 概述
1.1 Hello简介
Hello的P2P是指program to process,翻译过来是由程序到进程。从我们在ide或者文本编辑器中编写的C语言程序到进程的过程。hello.c通过cpp预处理生成ASCII码的中间文件hello.i,之后经过cc1翻译成汇编语言文件hello.s,接着as将hello.s转换为一个可重定位目标文件hello.o,最后运行ld,将hello.o和一些必要的文件组合起来,创建一个可执行目标文件。在shell中,通过系统的进程管理,成为一个进程。
Hello的020是指from zero-0 to zero-0,说的是内存数据的从无到有再到无,通过shell中的execute函数将hello载入内存为其分配空间。当程序结束后,进程又被回收,内核删除内存里关于hello的数据,完成to zero-0。
1.2 环境与工具
硬件环境
X64 CPU;2.80GHz;16G RAM;512G SSD
软件环境
Windows 11 64位;VMWare Workstation Pro 17;Ubuntu 22.04.2;LTS 64位
开发工具
Visual Studio 2022 64位;Visual Studio Code 64位;CodeBlocks 32位;gedit+gcc
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello | 最终生成的可执行文件 |
hello.c | 源程序 |
hello.elf | 由hello.o生成的elf文件 查看各节信息 |
hello_elf.elf | 由hello生成的elf文件 查看各节信息 |
hello.i | hello.c预编译得到的文件 |
hello.o | 汇编生成的可重定位目标文件 |
hello.s | 由hello.c编译生成的汇编语言代码 |
hello.objdump | hello.o的反汇编代码文件 |
hello_.objdump | hello的反汇编代码文件 |
表-1:生成的中间结果文件的名字,文件的作用
1.4 本章小结
本章讲述了hello程序p2p,020的过程,从程序到进程的过程,还有一个程序的生命周期,还对我的环境与工具进行了介绍,最后,列举了在调试hello过程中的中间结果,和它们的作用。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程.典型地,由预处理器对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——预处理记号用来支持语言特性。
作用:
1、将源文件中以”include”格式包含的文件复制到编译的源文件中。
2、用实际值替换用“#define”定义的字符串。
3、根据“#if”后面的条件决定需要编译的代码。
2.2在Ubuntu下预处理的命令
正在上传…重新上传取消
正在上传…重新上传取消
图-1、2:预处理
2.3 Hello的预处理结果解析
正在上传…重新上传取消
打开生成的hello.i 文件可以发现足足生成了3060行,但是主体代码只有14行。
正在上传…重新上传取消
开始部分有一系列外部库.h文件路径。
正在上传…重新上传取消
然后还有一些typedef,将头文件所用到的别名对应到标准数据类型当中。
正在上传…重新上传取消
还有些关于内部函数的声明。
正在上传…重新上传取消
直到最后才是我们写的main函数部分。
2.4 本章小结
这一部分介绍了hello预处理的过程,并分析了hello预处理的文件hello.i。
我们可以学习到通过宏指令可以使我们的代码过呢更加简洁,可读性更加好。预处理过程也使本来比较残缺的hello.c,变成更加完全的hello.i。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。
作用:分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。即将完整代码hello.i转换成汇编语言的hello.s。
3.2 在Ubuntu下编译的命令
正在上传…重新上传取消
图-3、4:编译
3.3 Hello的编译结果解析
3.3.1 常量
1.字符型常量
正在上传…重新上传取消
正在上传…重新上传取消
将printf打印的字符串常量存到.LC0中。
- 其他常量
正在上传…重新上传取消
正在上传…重新上传取消
cmpl比较argc与4是否相等,如果不相等,就上述的字符串常量地址加载到寄存器中,然后打印。
3.3.2变量与运算
- 局部变量
正在上传…重新上传取消
i是局部变量,作为for循环语句中的循环量
正在上传…重新上传取消
可以发现i被存在栈中(从%rbp可看出)。
- 算术操作
正在上传…重新上传取消
通过addl指令,完成for循环中对局部变量i的自增操作。
3.3.3 数组/指针操作
首先在main函数的参数中,有一个字符串数组
正在上传…重新上传取消
其中,argc表示输入参数的个数,即argv中元素的个数。
可以发现atoi函数调用了argv数组
正在上传…重新上传取消
可以发现-32(%rbp)即为argv首地址,通过加24操作,移动到argv[3]的地址。(char*在linux 64位中字节大小为8个字节)
3.3.4 控制转移
正在上传…重新上传取消
通过比较i和8的大小,用cmpl和jle来实现比较,如果i > 8那么就会跳出循环。
3.3.5函数的调用与返回
- main函数
传入参数为argc,和argv,为系统调用,且参数中shell中传入,返回值设为0.
- printf函数
正在上传…重新上传取消
通过设置寄存器%rdi和%rsi的值来传入参数并调用
- exit函数
正在上传…重新上传取消
通过设置寄存器%rdi和%rsi的值传入参数并调用
- atoi函数
正在上传…重新上传取消
将%eax的值设为argv[3],并赋值给%edi,作为传入参数并调用。
3.4 本章小结
本章介绍了从完整代码hello.i文件汇编成hello.s文件的过程,以及汇编语言下各部分变量、控制转移、函数调用和运算的等等的实现。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中。.o文件是一个二进制文件,其包含程序的指令编码。
作用:汇编器首先检查汇编程序语法的正确性,若正确,则将其翻译成与之等价的机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件中。
4.2 在Ubuntu下汇编的命令
正在上传…重新上传取消
正在上传…重新上传取消
图-5、6:汇编
4.3 可重定位目标elf格式
hello.o 文件在 x86-64 Linux 和 Unix 系统中使用可执行可链接格式即 (ELF),典型的 elf 可重定位目标文件格式如下:
正在上传…重新上传取消
图-7:elf可重定位目标文件格式
4.3.1 ELF头
正在上传…重新上传取消
ELF头以16字节的magic序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,elf头文件剩下的部分包含帮助连接器语法分析和解释目标文件的信息,其中包括elf头的大小、目标文件的类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。
ELF头中还包括程序的入口点,也就是程序运行时要执行的第一条指令的地址为0x1100,可以查看hello.o的反汇编代码,运行时的第一条指令的地址确实为0x1100.正在上传…重新上传取消
4.3.2 节头部表
使用readelf -S hello.o 命令查看节头部表
正在上传…重新上传取消
正在上传…重新上传取消
节头部表描述了hello.o中各个节的语义,包括节的类型、位置和大小等信息。
Key to flags 描述了各节的读写权限。
4.4.3 符号表
使用readelf -s hello.o命令查看.symtab节中的ELF符号表
正在上传…重新上传取消
正在上传…重新上传取消
它存放程序中定义和引用的函数和全局变量的信息。
正在上传…重新上传取消
这张图里由上文所谈到的一些函数的信息。
4.3.4 重定位条目
使用 readelf -r hello.o 命令查看hello.o的重定位条目
正在上传…重新上传取消
当汇编器生成hello.o后,它并不知道数据和代码最终将存放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,他就会生成一个重定位条目,告诉连接器在将目标文件合并成可执行文件时,如何修改这个引用。代码重定位条目放在.rela.plt中,已初始化数据的重定位条目放在.rela.dyn中。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
正在上传…重新上传取消
正在上传…重新上传取消
可以发现分支跳转和函数调用不一样,在hello.s 中,分支跳转的目标位置是通过.L1 .L2这样的助记符来实现,而hello.o中,跳转的目标位置是指令的位置。
函数调用在hello.s中,call后的目标函数是它的函数名,而在hello.o中,call的是目标函数的相对偏移的值。
4.5 本章小结
本章分析了汇编的过程,并分析了ELF头、节头部表、符号表以及重定位节。表交了hello .s和hello.o反汇编之后的代码的不同。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:链接是通过链接器将各种代码和数据片断收集并组合成一个单一文件的过程。这个文件可以被加载(复制)到内存并执行。
作用:使得分离编译成为可能;动态绑定(binding):使定义、实现、使用分离。
5.2 在Ubuntu下链接的命令
正在上传…重新上传取消
图-8、9:链接
5.3 可执行目标文件hello的格式
5.3.1 elf 头
正在上传…重新上传取消
查看hello的elf头
可以看到程序的类型变成了EXEC,程序入口点地址分配成了0x4010f0
5.3.2 节头部表
正在上传…重新上传取消
连接器将各个文件对应的段都合并了,并且重新分配计算了相应的节的类型、位置大小等信息。
各个节的地址从0开始进行了分配,可以看到.text节的起始地址是0x4010f0,这刚好就是前面elf头的程序入口地址,与.text中存放程序的机器代码符合。
5.4 hello的虚拟地址空间
正在上传…重新上传取消
使用edb打开hello,可以看到hello的开始地址位0x4010000
正在上传…重新上传取消
例如.text文件起始于ox4010f0,然后我们查看这个内存单元
正在上传…重新上传取消
例如.data文件起始于0x404048,查看这个内存单元。
5.5 链接的重定位过程分析
正在上传…重新上传取消
5.5.1 新增函数
如图,链接以后,加入了许多用到的的库函数:
正在上传…重新上传取消
5.5.2 新增节
新增了.init节和.plt节
正在上传…重新上传取消
正在上传…重新上传取消
5.5.3 新增代码 endbr64
可以观察到,由hello反汇编生成的代码中有一句出现的频率非常高,那就是endbr64,几乎每一个函数或者代码片段的开头都是这句代码。
其实这是 Intel 的 CET 技术,这个机制主要是用来对抗 ROP 攻击。我们在第 3 章学到,黑客可以利用缓冲区溢出来进行攻击,使程序执行黑客想要执行的程序,增加系统风险。而 CPU 和操作系统也采用了相应措施来避免这个风险:
栈随机化。这段程序分配的栈的位置在每次运行时都是随机的,这就使我们无法确定在哪里插入代码限制可执行代码区域。它限制栈上存放的代码是不可执行的。
但是这些措施却无法阻挡 ROP 攻击。什么是 ROP 攻击呢?ROP:面向返回的程序设计,所谓 ROP 攻击就是黑客在已经存在的程序中找到特定的以 ret 结尾的指令序列为我们所用,称这样的代码段为 gadget,把要用到部分的地址压入栈中,每次 ret 后又会取出一个新的 gadget,于是这样就能形成一个程序链,从而实现黑客的目的。对于 ROP 攻击也能找到解决办法,就是每次在跳转后,检查这段代码是不是程序想要的代码,也就是 CET 技术。CET 通过编译器在合理的间接跳转 (call/jmp) 中用新的指令做标记,新指令包含 endbr32 和 endbr64。程序每次执行跳转时,CPU 都会判断下一条指令是不是 endbr32/endbr64 指令,如果是则正常执行,如果不是,则会触发 #CP 异常。
这也就是每个代码段和函数开头都有一句 endbr64的原因了。
正在上传…重新上传取消
由于hello文件已经是重定位后的可执行目标文件,所以每一个call/jmp语句的目标地址就是确切的虚拟地址。
正在上传…重新上传取消
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出入口点,也就是 _start 函数的地址。这个函数是在系统目标文件 ctrl.o 中定义的。_start 函数调用系统启动函数 __libc_start_main,该函数定义在 libc.so 中。它初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并且在需要的时候把控制返回给内核。[5]
正在上传…重新上传取消
ld-2.31.so!_dl_catch_exception@plt <0x00007f9fddc6d010>
ld-2.31.so!malloc@plt <0x00007f9fddc6d020>
ld-2.31.so!_dl_signal_exception@plt <0x00007f9fddc6d030>
ld-2.31.so!calloc@plt <0x00007f9fddc6d040>
ld-2.31.so!realloc@plt <0x00007f9fddc6d050>
ld-2.31.so!_dl_signal_error@plt <0x00007f9fddc6d060>
ld-2.31.so!_dl_catch_error@plt <0x00007f9fddc6d070>
ld-2.31.so!_dl_rtld_di_serinfo <0x00007f9fddc77090>
ld-2.31.so!_dl_debug_state <0x00007f9fddc7e1d0>
ld-2.31.so!_dl_mcount <0x00007f9fddc7fe00>
ld-2.31.so!_dl_get_tls_static_info <0x00007f9fddc80680>
ld-2.31.so!_dl_allocate_tls_init <0x00007f9fddc80770>
ld-2.31.so!_dl_allocate_tls <0x00007f9fddc809a0>
ld-2.31.so!_dl_deallocate_tls <0x00007f9fddc80a10>
ld-2.31.so!_dl_make_stack_executable <0x00007f9fddc81130>
ld-2.31.so!_dl_find_dso_for_object <0x00007f9fddc81480>
ld-2.31.so!_dl_exception_create <0x00007f9fddc84ca0>
ld-2.31.so!_dl_exception_create_format <0x00007f9fddc84da0>
ld-2.31.so!_dl_exception_free <0x00007f9fddc85250>
ld-2.31.so!__tunable_get_val <0x00007f9fddc865d0>
ld-2.31.so!__tls_get_addr <0x00007f9fddc86da0>
ld-2.31.so!__get_cpu_features <0x00007f9fddc86df0>
ld-2.31.so!malloc <0x00007f9fddc89490>
ld-2.31.so!calloc <0x00007f9fddc895b0>
ld-2.31.so!free <0x00007f9fddc895f0>
ld-2.31.so!realloc <0x00007f9fddc897e0>
ld-2.31.so!_dl_signal_exception <0x00007f9fddc89a70>
ld-2.31.so!_dl_signal_error <0x00007f9fddc89ac0>
ld-2.31.so!_dl_catch_exception <0x00007f9fddc89c40>
ld-2.31.so!_dl_catch_error <0x00007f9fddc89d30>
hello!_init <0x0000000000401000>
hello!puts@plt <0x0000000000401030>
hello!printf@plt <0x0000000000401040>
hello!getchar@plt <0x0000000000401050>
hello!atoi@plt <0x0000000000401060>
hello!exit@plt <0x0000000000401070>
hello!sleep@plt <0x0000000000401080>
hello!_start <0x00000000004010f0>
hello!_dl_relocate_static_pie <0x0000000000401120>
hello!main <0x0000000000401125>
hello!__libc_csu_init <0x00000000004011c0>
hello!__libc_csu_fini <0x0000000000401230>
hello!_fini <0x0000000000401238>
5.7 Hello的动态链接分析
在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件 hello。此时共享库中的代码和数据没有被合并到 hello 中。只有在加载 hello 时,动态链接器才对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。
比如查看 _GLOBAL_OFFSET_TABLE 的内容:
在运行前:
正在上传…重新上传取消
运行dl_init后
正在上传…重新上传取消
5.8 本章小结
本章详细介绍了hello的连接过程,比对链接后的hello与hello.o的不同,最后使用gdb工具逐行查看了hello的运行过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程是操作系统对一个正在运行的程序的一种抽象。进程是程序的基本执行实体;在面向线程设计的系统中,进程本身不是基本执行单位,而是线程的容器。程序本身只是指令、数据及其组织形式的描述,相当于一个名词,进程才是程序的真正执行实例。
进程的作用:hello 在运行时,操作系统会提供一种假象,就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和 I/O 设备。处理器看上去就像在不间断地一条接一条地执行程序中的指令,即该程序的代码和数据是系统内存中唯一的对象。这些假象就是通过进程来实现的。
6.2 简述壳Shell-bash的作用与处理流程
Shell 处理流程:
1.在 Shell 中输入 hello 程序的路径。
2.Shell 判断用户输入的是否为内置命令,如果不是,就认为它是一个可执行目标文件。
3.Shell 构造 argv 和 envp。
4.Shell 使用 fork() 创建子进程,调用 execve() 函数在新创建的子基础南横的上下文中加载并运行 hello 程序。将 hello 中的 .text 节、.data 节、.bss 节等内容加载到当前进程的虚拟地址空间。
5.execve() 函数调用加载器,跳转到程序的入口点,开始执行 _start 函数,我们的 hello 程序便正式开始执行了。
6.3 Hello的fork进程创建过程
当shell运行一个程序时,父进程通过fork函数生成这个程序的进程。新创建的子进程几乎但不完全与父进程相同,包括代码、数据段、堆、共享库以及用户栈。父进程和新创建的子进程之间最大的区别在于他们有不同的PID。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。
fork函数只被调用一次,却会返回两次。一次是在调用进程中,一次是在新创建的子进程中。在父进程中,fork返回子进程的pid,在子进程中,fork返回0。因为子进程的PID总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。[4]
创建过程:
(1)给新进程分配一个标识符
(2)在内核中分配一个PCB,将其挂在PCB表上
(3)复制它的父进程的环境(PCB中大部分的内容)
(4)为其分配资源(程序、数据、栈等)
(5)复制父进程地址空间里的内容(代码共享,数据写时拷贝)
(6)将进程置成就绪状态,并将其放入就绪队列,等待CPU调度。
6.4 Hello的execve过程
execve() 函数加载并运行可执行目标文件,且带参数列表 argv 和环境变量列表 envp,execve() 函数调用一次从不返回。它的执行过程如下:
1.删除已存在的用户区域
2.映射私有区:为 hello 的代码、数据、.bss 和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的
3.映射共享区:比如 hello 程序与共享库 libc.so 链接
4.设置 PC:exceve() 做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点
5.execve() 在调用成功的情况下不会返回,只有当出现错误时,例如找不到需要执行的程序时,execve() 才会返回到调用程序
6.5 Hello的进程执行
6.5.1.逻辑控制流
操作系统将一个 CPU 物理控制流,分成多个逻辑控制流,每个进程独占一个逻辑控制流。当一个逻辑控制流执行的时候,其他的逻辑控制流可能会临时暂停执行。一般来说,每个逻辑控制流都是独立的。当两个逻辑控制流在时间上发生重叠,我们说是并行的。处理器在多个进程中来回切换称为多任务,每个时间当处理器执行一段控制流称为时间片。因此多任务也指时间分片。
6.5.2 用户模式与内核模式
为了限制一个应用可以执行的指令以及它可以访问的地址空间范围,处理器用一个控制寄存器中的一个模式位来描述进程当前的特权。
用户模式:用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个 I/O 操作。也不允许用户模式的进程直接引用地址空间中内核区内的代码和数据。用户程序必须通过系统调用接口间接地访问内核代码和数据。
进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
6.5.3 上下文切换
操作系统内核为每个进程维护一个上下文。所谓上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表,包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
6.5.4 hello的执行
从 Shell 中运行 hello 时,它运行在用户模式,运行过程中,内核不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用执行,实现进程的调度。如果在运行过程中收到信号等,那么就会进入内核模式,运行信号处理程序,之后再返回用户模式。
6.6 hello的异常与信号处理
6.6.1 异常
异常可以分为四类:中断、陷阱、故障和终止。它们的性质如图:
正在上传…重新上传取消表-2:异常类别及其性质
中断:比如在 hello 运行过程中,我们敲击键盘,那么就会触发中断,系统调用内核中的中断处理程序执行,然后返回,hello 继续执行,如图:
正在上传…重新上传取消
图-10:中断处理
陷阱:陷阱就是系统调用,我们的 hello 运行在用户模式下,无法直接运行内核中的程序,比如像 fork,exit 这样的系统调用。于是就通过陷阱的方式,执行 systemcall 指令,内核调用陷阱处理程序来执行系统调用,如图:
正在上传…重新上传取消
图-11:陷阱处理程序
故障:当我们的 hello 运行时,当某一条指令引用一个虚拟地址,而地址相对应的物理页面不在内存中,就会发生故障。内核调用故障处理程序(这里是缺页处理程序),缺页处理程序从磁盘中加载适当的页面,然后将控制返回给引起故障的指令,该指令就能顺畅地执行了。
正在上传…重新上传取消
图-12:故障处理
终止:hello 在运行时,也有可能遇到硬件错误,那就只能自认倒霉,终止 hello 的运行。
正在上传…重新上传取消
图-13:发生硬件错误的处理情况
6.6.2信号
在运行hello过程中,测试部分信号。
Sigstp:在hello在前台运行时,按下ctrl + z 会向它发送sigstp信号,这个进程就会暂时挂起,可以使用fg %<pid>命令,让它在前台继续执行:
正在上传…重新上传取消
Sigint:当hello在前台运行时,按下ctrl + c 会向它发送sigint信号,这个进程就会被终止:
正在上传…重新上传取消
也可以在执行 hello 程序的命令后面加上 &,这样它就会在后台运行,分别使用 ps 和 jobs 来查看 hello 进程。
正在上传…重新上传取消
ps 和 jobs 的区别在于,ps 会打印系统中的所有进程,包括我正在使用的终端 Shell,而 job 只会打印 Shell 正在维护的进程,它不会包括自己。
当进程在后台运行时,我们用键盘发送的信号是无法发送给它,这时可以用 kill 命令来终止它:
正在上传…重新上传取消
6.7本章小结
本讲解了 hello 如何运行在操作系统的上下文中,以及它如何受到信号的控制。(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
我们的 hello 进程是与其它进程共享 CPU 和主存资源的,为了更加有效地管理内存并且少出错,现代操作系统提供了一种对主存的抽象概念,叫做虚拟内存。虚拟内存时硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间。首先确定一些概念
- 1逻辑地址:格式为“段地址:偏移地址”,是 CPU 生成的地址,在内部和编程使用,并不唯一。
- 2线性地址:逻逻辑地址到物理地址变换之间的中间层,逻辑地址经过段机制后转化为线性地址。
- 3虚拟地址:保护模式下,hello 运行在虚拟地址空间中,它访问存储器所用的逻辑地址。
- 4物理地址:加载到内存地址寄存器中的地址,内存单元的真正地址。CPU 通过地址总线的寻址,找到真实的物理内存对应地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel 平台下的实模式中,逻辑地址为:CS:EA,CS 是段寄存器,将 CS 里的值左移四位,再加上 EA 就是线性地址。而保护模式下,要用段描述符作为下标,到 GDT(全局描述符表)/LAT(局部描述符表)中查表获得段地址,段地址+偏移地址就是线性地址。段描述符是一个 16 位字长的字段,如图:
正在上传…重新上传取消
图-14:描述符图示
TI 位指示选择 GDT 还是 LDT,前 13 位作为索引来确定段描述符在描述符表中的位置。从段描述符和偏移地址得到线性地址的过程如图:
正在上传…重新上传取消
图-15:获取线性地址的流程
7.3 Hello的线性地址到物理地址的变换-页式管理
VM 系统将虚拟内存分割为成为虚拟页的大小固定的快,物理内存也被分割为物理页,成为页帧。虚拟页面就可以作为缓存的工具,被分为三个部分:
未分配的:VM 系统还未分配的页
已缓存的:当前已缓存在物理内存中的已分配页
未缓存的:未缓存在物理内存的已分配页
如图:
正在上传…重新上传取消
图-16:页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
页表是 PTE(页表条目)的数组,它将虚拟页映射到物理页,每个 PTE 都有一个有效位和一个 n 位地址字段,有效位表明该虚拟页是否被缓存在 DRAM 中,地址字段表明 DRAM 中相应物理页的起始位置,它分为两个部分:VPO(虚拟页面偏移)和 VPN(虚拟页号),如图:
正在上传…重新上传取消
图-16:从虚拟地址到物理地址
7.4.1 TLB加速地址翻译
为了优化 CPU 产生一个虚拟地址后,MMU 查阅 PTE的过程,在 MMU 中设置一个关于 PTE 的小缓存,称为 TLB(翻译后备缓冲器)。像普通的缓存一样,TLB 的索引和标记是从 PTE 中的 VPN 提取出来的,如图:
正在上传…重新上传取消
图-17:TLB提取
7.4.2 四级页表翻译
下面举Core i7的例子。
正在上传…重新上传取消
图-18:四级页表翻译
每次 CPU 产生一个虚拟地址后,通过它的 VPN 部分看 TLB 中是否缓存,如果命中,直接得到 PPN,将虚拟地址中的 VPO 作为物理页偏移,这样就能得到物理地址;如果 TLB 未命中,则经过四级页表的查找得到最终的PTE,从而得到 PPN,进而得到物理地址。
7.5 三级Cache支持下的物理内存访问
得到物理地址后,将物理地址分为 CT(标记位)、CI(组索引) 和 CO(块偏移)。根据 CI 查找 L1 缓存中的组,依次与组中每一行的数据比较,有效位有效且标记位一致则命中。如果命中,直接返回想要的数据。如果不命中,就依次去 L2、L3 缓存判断是否命中,命中时将数据传给 CPU 同时更新各级缓存。
7.6 hello进程fork时的内存映射
在 Shell 输入命令行后,内核调用fork创建子进程,为 hello 程序的运行创建上下文,并分配一个与父进程不同的PID。通过 fork 创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。
7.7 hello进程execve时的内存映射
execve() 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。加载并运行 hello 需要以下几个步骤:
- 1删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 2映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的 text 和 data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
- 3映射共享区域, hello 程序与共享对象 libcso 链接,libcso 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
- 4设置程序计数器(PC),execv() 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
DRAM 缓存不命中称为缺页,以下图为例假设CPU引用了(虚拟页)VP 3中的一个字VP 3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE 3,从有效位推断VP 3未被缓存,并且触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,缺页处理程序就执行下面步骤:
1)查看虚拟地址是否合法,若不合法,那么缺页处理程序就触发一个段错误,从而终止这个进程;
2)查看试图进行的内存访问是否合法,如果试图访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程;
3)选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送虚拟地址到MMU,MMU就能正常翻译它了,而不会再产生缺页中断了。
在此例中就是牺牲页就是存放在(物理页)PP 3中的VP 4。如果VP 4已经被修改了,那么内核就会将它复制回磁盘。无论哪种结果,内核都会修改VP 4的页表条目,反映出VP 4不再缓存在主存中这一事实。接下来,内核从磁盘复制 VP 3 到内存中的 PP 3,更新 PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP 3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。
正在上传…重新上传取消
正在上传…重新上传取消
图-19、20:异常发生情况及其处理
7.9动态存储分配管理
7.9.1 堆
动态内存分配器维护着一个进程的虚拟内存区域,称为堆,如图:
正在上传…重新上传取消
图-21:堆
-
-
- 隐式空闲链表管理
-
想要设计好的数据结构维护空闲块需要考虑以下方面:
- 空闲块组织:利用隐式空闲链表记录空闲块
- 放置策略:如何选择合适的空闲块分配?
- 首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块
- 下一次适配:从上一次查询结束的地方开始搜索选择第一个合适的空闲块
- 最佳适配:搜索能放下请求大小的最小空闲块
- 分割:在将一个新分配的块放置到某个空闲块后,剩余的部分要进行处理
- 合并:释放某个块后,要让它与相邻的空闲块合并
- 隐式空闲链表中的每个块由头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。头部后面就是应用程序调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。
为什么既设置头部又设置尾部呢?这是为了能够以常数时间来进行块的合并。无论是与下一块还是与上一块合并,都可以通过他们的头部或尾部得知块大小,从而定位整个块,避免了从头遍历链表。空闲块怎么组织呢?如图:
正在上传…重新上传取消图-22:空闲块的处理方法
- 为了消除合并空闲块时边界的考虑,将序言块和结尾块的分配位均设置为已分配。为了保证双字对齐,在序言块的前面还设置了 4 个字节作为填充。
7.9.3 显式空闲链表管理
显式空闲链表的已分配块的块结构和隐式链表的相同,由一个字的头部、有效载荷、可能的一些额外的填充以及一个脚部组成。
而在每个空闲块中,增加了一个前驱指针和后继指针。通过这些指针,可以将空闲块组织成一个双向链表。
空闲链表中块的排序策略包括后进先出顺序、按照地址顺序维护、按照块的大小顺序维护等。显式空闲链表降低了放置已分配块的时间,但空闲块必须足够大,以包含所需要的指针、头部和脚部,这导致了更大的最小块大小,潜在提高内部碎片程度。
正在上传…重新上传取消图-23:空闲块的处理方法
简单分离存储:从不合并与分离,每个块的大小就是大小类中最大元素的大小。例如大小类为 {17~32},则需要分配块的大小在这个区间时均在此对应链表进行分配,并且都是分配大小为 32 的块。这样做,显然分配和释放都是常数级 的,但是空间利用率较低
分离适配:每个大小类的空闲链表包含大小不同的块,分配完一个块后,将这个块进行分割,并根据剩下的块的大小将其插入到适当大小类的空闲链表中。这个做法平衡了搜索时间与空间利用率,C 标准库提供的 GNU malloc 包就是采用的这种方法。[3]
7.10本章小结
本章介绍了hello的存储器地址空间的相关概念。详细讲解了Intel的段式管理和页式管理方式。并且说明了程序运行时利用四级页表将虚拟地址VA转换成物理地址PA的过程和使用三级Cache访存的过程。本章还介绍了在调用fork函数创建子进程和调用execve函数加载并运行程序时的内存映射情况。针对访存时可能引发的缺页故障,我们介绍了缺页中断处理机制,讨论了成功的处理过程和失败的处理过程。最后,还对动态存储分配管理的基本方法与各种策略进行了介绍。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备管理:unix io接口
所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
8.2 简述Unix IO接口及其函数
打开文件:返回一个小的非负整数,即描述符。用描述符来标识文件。
改变当前文件位置 从文件开头起始的字节偏移量。系统内核保持一个文件位置k,对于每个打开的文件,起始值为0。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
函数:
Open()-打开一个已经存在的文件或是创建一个新文件
Read()-从文件读取数据,执行输出
Write()-从文件中读取数据,执行输出
Close()-关闭一个被打开的文件
Lseek()-用于在指定的文件描述符中将文件指针定位到相应位置
8.3 printf的实现分析
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
首先 arg 获得第二个不定长参数,即输出的时候格式化串对应的值。
查看 vsprintf 代码:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_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 结合参数 args 生成格式化之后的字符串,并返回字串的长度。
在 printf 中调用系统函数 write(buf,i)将长度为i的buf输出。write 函数如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在 write 函数中,将栈中参数放入寄存器,ecx 是字符个数,ebx 存放第一个字符地址,int INT_VECTOR_SYS_CALLA 代表通过系统调用 sys_call,查看 sys_call的实现:
·Sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
printf()函数将变长参数的指针arg作为参数,传给vsprintf函数。然后vsprintf函数解析格式化字符串,调用write()函数。在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,最后,write函数调用syscall(int INT_VECTOR_SYS_CALL)。syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存 储到 vram 中。显示芯片会按照一定的刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量),最终打印出了我们需要的字符串。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
(第8章1分)
结论
Hello的一生:
程序员为他编写最初的代码 — hello.c
预处理器完善代码 — hello.i
编译器将它转化为汇编语言 — hello.s
汇编器为它的诞生做出最后的准备 — hello.o
链接器让它成为一个完整的程序 — hello
Shell为它创建子进程,使他成为系统的一份子
加载器映射虚拟内存
cpu的逻辑流时他交叉于用户与内核之间
Hello加载到内存,内存管理单元MMU、翻译后备缓冲器TLB、多级页表机制、三级cache协同工作,完成对地址的翻译和数据的读写
信号与异常约束它的行为,使它不偏离正道
最后shell回收子进程,内核删除与它相关的一切
Hello world作为我最初接触计算机方面知识的时候第一个成功实现出来的程序,对我有非凡的意义,在经历了一学期对计算机系统这门课的学习后,我发现表面上如此简单的程序,其背后的实现过程竟是如此的庞大且复杂,但同时它的一生也是完整的、精妙的,让我领略到计算机系统的魅力。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello | 最终生成的可执行文件 |
hello.c | 源程序 |
hello.elf | 由hello.o生成的elf文件 查看各节信息 |
hello_elf.elf | 由hello生成的elf文件 查看各节信息 |
hello.i | hello.c预编译得到的文件 |
hello.o | 汇编生成的可重定位目标文件 |
hello.s | 由hello.c编译生成的汇编语言代码 |
hello.objdump | hello.o的反汇编代码文件 |
hello_.objdump | hello的反汇编代码文件 |
(附件0分,缺失 -1分)
参考文献
- Randal E.Bryant / David O'Hallaron.深入理解计算机系统(原书第3版):机械工业出版社,2016
- https://hansimov.gitbook.io/csapp/
- https://www.doc88.com/p-13647136102967.html
- https://blog.csdn.net/yueyansheng2/article/details/78860040
- https://blog.csdn.net/langchibi_zhou/article/details/5744922
(参考文献0分,缺失 -1分)