摘要:本文主要介绍hello程序在linux系统下的生命周期,从hello.c文件开始,经过预处理、编译、汇编、链接阶段最终变成可执行文件。在此过程中,结合课程所学知识,对于运行过程中的进程管理、信号和异常处理、存储处理、IO管理等操作进行了探究。梳理了计算机系统课程主要框架,回顾了相关知识点。
关键词:计算机系统预处理编译汇编链接操作系统管理
目 录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.2.1 硬件环境
1.2.2 软件环境
1.2.3 开发工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1预处理的概念与作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
附件
参考文献
第1章 概述
1.1 Hello简介
P2P,即from program to progress,在linux系统中,这个过程主要是对hello.c文本(program)进行预处理生成hello.i文件,编译生成汇编代码hello.s文件,再通过汇编生成目标文件hello.o文件,最后链接生成可执行文件hello。然后shell为程序fork,创建子进程,即为process。
020即from zero to zero,在P2P过程后,子进程调用execve,映射虚拟内存、载入物理内存;随后进入主函数执行目标代码,程序调用各种系统函数实现屏幕输出信息等功能。程序结束后,父进程shell会回收该子进程,其内存中的相关状态信息和数据结构被清除。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2GHz;2G RAM;256GHD Disk
1.2.2 软件环境
Windows10 64位;Vmware-workstation-15;Ubuntu 16.04 LTS 64位
1.2.3 开发工具
Codeblocks 64位;vi/vim/gedit+gcc
1.3 中间结果
文件名称 | 文件作用 |
hello.c | 源代码 |
hello.i | 预处理后的文件 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的目标文件 |
hello | 链接后的可执行文件 |
objdump.txt | 对hello.o的反汇编文件 |
hello_dis.txt | 对hello的反汇编文件 |
hello.elf | hello.o的elf文件 |
hello_elf2.elf | hello的elf文件 |
1.4 本章小结
本章主要介绍hello的P2P和020过程,给出了实验的硬件、软件环境和使用的工具,以及实验中产生的中间文件及其作用。
第2章 预处理
2.1预处理的概念与作用
预处理阶段,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中的#include 命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中,结果就得到了另一个C程序,通常是以.i作为文件扩展名
预处理的作用主要包括:
- 宏展开:将所有的宏定义进行替换,然后删除#define
- 条件编译:如果源代码中包含条件预处理指令(如#if),就会先判断条件,再修改源代码
- 头文件展开:对文件包含命令#include,引入对应头文件,将头文件的内容(.h)插入到命令所在位置,从而把头文件和当前源文件连接成一个源文件
2.2在Ubuntu下预处理的命令
命令:gcc -E hello.c -o hello.i
图2.2.1 预处理命令截图
2.3 Hello的预处理结果解析
预处理后的文件从23行扩展到了3060行,扩展的内容包括:
图2.3.1 包含文件信息
图2.3.2 类型定义信息
图2.3.3 函数声明信息
这是预处理器对包含的头文件进行读取、复制、处理,将需要用到的库函数等加入到了文本中,让程序能够继续被编译器编译。
再找到最后的源代码部分,发现删除了#include和注释,其他内容没有发生改变。
图2.3.4 源代码部分
2.4 本章小结
预处理阶段是对源程序文件hello.c进行翻译的起始阶段,预处理器会进行宏展开、头文件展开、条件编译等处理,并删除了注释,而对函数没有进行什么修改,最终得到了hello.i这个新的C程序,方便了后续编译器对文本进行编译。
第3章 编译
3.1 编译的概念与作用
编译阶段,编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序,每条语句都以一种文本格式描述了一条低级机器语言指令。
编译的作用主要包括:
- 语法检查:检查代码是否存在语法错误,如果有错误的话就会报错
- 生成汇编代码:将程序翻译成汇编语言,从而在下一阶段可以让汇编器翻译成机器语言指令
- 代码优化:编译器会对程序进行优化,生成效率更高的目标代码
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
图3.2.1 编译命令截图
3.3 Hello的编译结果解析
3.3.1 数据
1. 常量
.rodata段保存了printf函数中用到的字符串
if语句和for循环的条件判断值作为常数被保存在代码段使用
2. 变量
局部变量int i被保存在栈上
3.3.2 算术操作
i++:addl $1, -4(%rbp)
对保存在栈上的变量i的值加1
3.3.3 关系操作
argv!=4:cmpl $4, -20(%rbp)
用cmpl指令来判断保存在栈上的argv的值是否等于4
i<8:cmpl $7, -4(%rbp)
用cmpl指令来判断保存在栈上的i的值是否小于8
3.3.4 控制转移
if语句:
cmpl $4, -20(%rbp)
je .L2
根据cmpl指令更新的条件码来执行跳转
for语句:
.L2:
movl $0, -4(%rbp)
jmp .L3
.L3:
cmpl $7, -4(%rbp)
jle .L4
在.L2中进行初始化,然后跳转到.L3判断是否满足条件,如果满足则跳转到.L4执行循环体,执行完毕后会再次跳转到.L3判断是否满足条件,直到条件不满足循环结束。
3.3.5 数组操作
main函数的第二个参数char *argv[]是字符串数组,相关操作为:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
数组的首地址被保存在栈上,当需要调用数组的其他元素时,只需对首地址加上偏移量即可
3.3.6 函数操作
1. printf函数
printf(“用法: Hello 学号 姓名 秒数!\n”):
leaq .LC0(%rip), %rdi
call puts@PLT
这里输出只有一个字符串,编译器进行了程序优化,将字符串.LC0存放在寄存器中作为参数进行传递,然后用call指令调用puts函数
printf(“Hello %s %s\n”,argv[1],argv[2]):
movq %rax, %rsi
leaq .LC1(%rip), %rdi
movl $0, %eax
call printf@PLT
将argv[1]、argv[2]以及字符串.LC1存放在寄存器中作为参数进行传递,然后用call指令调用printf
2.getchar函数
getchar():
call getchar@PLT
没有参数,直接调用
- sleep函数
sleep(atoi(argv[3])):
movl %eax, %edi
call sleep@PLT
传入一个参数后调用
- exit函数
exit(1):
movl $1, %edi
call exit@PLT
传入参数1,然后调用,函数不返回直接退出
- atoi函数
atoi(argv[3]):
movq %rax, %rdi
call atoi@PLT
传入字符串,然后调用,返回值被保存在寄存器rax中
3.4 本章小结
本章主要探讨编译器将经过预处理阶段后的C程序hello.i翻译成汇编语言程序的处理过程,包括对数据、算术操作、关系操作、控制转移、数组操作、函数操作的处理。编译器也会在处理过程中对程序进行一些优化,最终的结果被保存在hello.s文件中,能够在下一阶段让汇编器翻译机器语言指令。
第4章 汇编
4.1 汇编的概念与作用
汇编阶段,汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,包含了函数main的指令编码,如果在文本编辑器中打开hello.o文件将看到一堆乱码。
4.2 在Ubuntu下汇编的命令
命令:gcc -c hello.s -o hello.o
图4.2.1 汇编命令截图
4.3 可重定位目标elf格式
用命令readelf -a hello.o >hello.elf得到hello.o的ELF格式
主要内容包括:
- ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
- 节头部表
节头部表包含各个节的名称、类型、地址、偏移量、大小等信息。
- 重定位节
重定位节包含了.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。本程序中的重定位信息分别是对.L0、puts、exit、.L1、printf、atoi、sleep、getchar函数进行重定位声明。
图4.3.1 ELF头的内容
图4.3.2 节头部表的内容
图4.3.3 重定位节的内容
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o >objdump.txt
机器语言指的是二进制的机器指令集合,由操作码和操作数构成的。汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上,汇编指令是机器指令便于记忆的书写格式。
图4.4.1 反汇编代码
图4.4.2 汇编代码
对于分支转移,汇编代码中使用的跳转位置是像.L2这样的代码块名称,而反汇编代码中跳转的位置是相对于main函数起始位置偏移的地址。
对于函数调用,汇编代码中调用的是函数名称,而反汇编代码中call指令使用的是相对偏移地址,需要在链接后进行重定位,在.rela.text节中也为其添加了重定位条目。
另外,汇编代码中的操作数是十进制,反汇编代码中则是十六进制。对于全局变量,汇编代码中使用段名称+%rip表示,反汇编代码中用0+%rip表示。
4.5 本章小结
本章讨论了汇编阶段将汇编代码翻译成机器语言指令的过程,将结果保存在hello.o文件中,等待链接器的处理。同时,还讨论了汇编代码与反汇编代码之间的不同之处。
第5章 链接
5.1 链接的概念与作用
链接阶段,链接器(ld)将各种代码和数据片断收集并组合成一个单一可执行目标文件(或者简称为可执行文件)的过程。这个文件可以被加载(复制)到内存并执行。
链接使分离编译成为可能,我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解成更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
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.2.1 链接命令截图
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
命令:readelf -a hello >hello_elf2.elf
图5.3.1 节头部表内容
节头部表中包含了hello各个段的大小、起始地址、类型、偏移量等信息
5.4 hello的虚拟地址空间
在ELF中可以发现,用于程序初始化的.init节的地址是0x401000,.plt节的地址是0x401020,.text节的地址是0x4010f0,.rodata节的地址是0x402000。使用edb加载hello,Data Dump窗口会显示它的虚拟地址空间内容,运行,发现程序从0x401000开始运行。
图5.4.1 Data Dump窗口
图5.4.2 Symbols截图1
hello!._init的地址是0x401000,hello!.plt的地址是0x401020
图5.4.3 Symbols截图2
hello!._start的地址是0x4010f0,hello!.rodata的地址是0x402000
这说明了各段的虚拟地址与节头部表的对应关系。
5.5 链接的重定位过程分析
命令:objdump -d -r hello >hello_dis.txt
以比较hello和hello.o中对于puts函数调用的不同为例:
图5.5.1 hello.o反汇编代码puts函数调用
图5.5.2 hello反汇编代码puts函数调用
链接前的反汇编代码留下了一个待重定位的标记,参数是0;链接后参数变成了正确的偏移量,链接器将我们链接的库函数在可执行文件中准确定位了出来。
链接过程中,链接器会扫描分析所有相关的可重定位目标文件,先进行符号解析,将每个符号引用与一个符号定义关联起来,随后进行重定位,使用汇编器产生的重定位条目的详细指令,把每个符号定义与一个内存位置关联起来。最终的结果是把程序运行所需的各部分组装在一起,形成一个可执行目标文件。
5.6 hello的执行流程
hello在执行的过程中要执行载入、执行和退出三个过程,列出其调用与跳转的各个子程序名或程序地址。
子程序名 地址
hello!_start 0x00000000004010f0
__libc_csu_init 0x00000000004011c0
_init 0x0000000000401000
main 0x0000000000401125
hello!puts@plt 0x0000000000401030
hello!exit@plt 0x0000000000401070
hello!printf@plt 0x0000000000401040
hello!atoi@plt 0x0000000000401060
hello!sleep@plt 0x0000000000401080
hello!getchar@plt0x0000000000401050
5.7 Hello的动态链接分析
图5.7.1 dl_init前的内容
图5.7.2 dl_init后的内容
对于动态共享链接库中 PIC 函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表 GOT 实现函数的动态链接,GOT 中存放函数目标地址,PLT 使用 GOT 中地址跳转到目标函数。在dl_init 调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。
在函数调用时,首先跳转到PLT执行.plt中操作,第一次访问跳转时GOT地址为下一条指令,将函数序号入栈,然后跳转到PLT[0],之后将重定位表地址入栈,访问动态链接器,在动态链接器中使用在栈里保存的函数序号和重定位表计算函数运行时的地址,重写GOT,返回调用函数.之后如果还有对该函数的访问,就不用执行第二次跳转,直接参看GOT信息。
5.8 本章小结
本章讨论了可重定位目标文件hello.o链接生成可执行目标文件hello的过程,包括hello的虚拟地址空间,重定位过程,执行流程,动态链接过程分析。
第6章 hello进程管理
6.1 进程的概念与作用
进程就是一个执行中程序的实例。
每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个进程的上下文中运行这个可执行目标文件。应用程序也能创建新的进程,并且在这个进程上下文中运行自己的代码和程序。进程提供给应用程序关键的抽象:
- 一个独立的逻辑控制流,提供一个假象,好像我们的程序独占使用处理器。
- 一个私有的地址空间,提供一个假象,好像我们的程序独占使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
shell 是一个交互型应用级程序,代表用户运行其他程序。如Windows下的命令行解释器,cmd、powershell,图形界面的资源管理器。Linux下的Terminal/tcsh、bash等等,当然也包括图形化的GNOME桌面环境。
Shell是信号处理的代表,负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等。
处理流程:shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。对fork函数的返回值,子进程返回0,父进程返回子进程的PID,如果出错,则为-1。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
在创建的子进程中通过if(pid==0),调用execve加载可执行文件,且带参数列表argv和环境变量列表envp,调用启用代码,启动代码设置栈,将栈和堆初始化为0,代码段与数据段初始化为可执行文件中的内容,最后将PC指向_start的地址,将控制传递给hello程序的主函数。
只有出现错误时(例如找不到可执行目标文件hello),execve才会返回到调用程序,这里与调用一次返回两次的fork函数不同。
6.5 Hello的进程执行
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
时间进程片:执行进程的控制流的一部分的每一时间段,多任务也叫时间分片(time slicing)。
用户态与核心态:处理器使用一个寄存器提供两种模式的区分,该寄存器描述进程当前的权限,在没有设置模式位的时候,进程处于用户态,此时进程不允许执行特权指令,同时也不允许直接引用地址空间中内核区域的代码和数据;但一旦设置了模式位,该进程就处于核心态,可以执行指令集中任何指令,也可以访问系统中任何内存位置。
进程调度指在执行过程中,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这个过程称为调度。内存收到中断信号之后,将当前进程加入等待序列,进行上下文切换将当前的进程控制权交给其他进程,当再次收到中断信号时将hello从等待队列加入运行队列。
6.6 hello的异常与信号处理
异常可以分为四类:中断(interrupt),陷阱(trap),故障(fault)和终止(abort)。
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。在当前指令完成执行后,处理器注意到中断引脚的电压变高,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回后,它就将控制返回给下一条指令。
陷阱是有意的异常,是执行一条指令的结果。应用程序执行一次系统调用,然后把控制传递给处理程序,陷阱处理程序运行后,返回到syscall之后的指令。
故障由错误情况引起,故障发生时处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
终止是不可恢复的致命错误造成的结果。终止处理程序从不将控制返回给应用程序,处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
图6.6.1 乱按的结果
图6.6.2 Ctrl-C的运行结果
图6.6.3 ctrl-z的运行结果
可以发现,ctrl-C会直接结束进程,而ctrl-Z会让进程被挂起放到后台,但是进程并没有结束,并且可以通过指令继续这个进程。
6.7本章小结
本章主要讨论了进程和shell的概念与作用,进程的创建和执行过程,以及对异常和信号的处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是程序运行由CPU产生的与段相关的偏移地址部分。
物理地址是CPU地址总线传来的地址。
线性地址是逻辑地址到物理地址变换之间的中间层。
虚拟地址与线性地址类似,同样是对程序运行区块的相对映射。
如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。如果没有启用分页机制,那么线性地址直接就是物理地址。
hello运行于物理地址,对于CPU而言, hello的运行地址是逻辑地址,在具体操作的过程中,CPU会将逻辑地址转换成线性地址再变成物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
计算机需要对内存分段,以分配给不同的程序使用。每个段的首地址都存放在自己的段描述符中,而所有的段描述符都存放在一个描述符表中(描述符表分为全局描述符表GDT和局部描述符表LDT)。而要想找到某个段的描述符必须通过段选择符才能找到。
处理流程:根据逻辑地址判断段选择符和段内偏移地址,根据段选择符判断当前转换段是属于GDT还是LDT,再根据相应的寄存器得到地址和大小。查找出段描述符,得到基地址,通过线性地址=基地址+偏移地址计算出线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
hello进程执行时,CPU中的页表基址寄存器指向hello进程的页表,当hello进程访问其虚拟空间内的指令、数据等内容时,CPU芯片上的MMU(内存管理单元)会将对应的线性地址变换为物理地址以进行寻址访问。
处理流程:处理器生成一个虚拟地址,并把它传送给MMU。MMU生成PTE地址,并从高速缓存/主存请求得到它。高速缓存/主存向MMU返回PTE,MMU构造物理地址,并把它传送给高速缓存/主存,高速缓存/主存返回所请求的数据字给处理器。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB即翻译后备缓存器,是PTE的小的缓存。多级页表是一个层次结构,用于压缩页表,将完整的页表分组,分别对应到低一级的一节页表的一个PTE中。
变换过程:CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT(前32位+TLBI(后4位)向TLB中匹配。如果命中,则得到 PPN (40bit)与VPO(12bit)组合成 PA(52bit)。如果未命中,MMU 向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中找到PPN,与VPO组合成PA,并向TLB 中添加条目。
图7.4.1 VA到PA变换示意图
7.5 三级Cache支持下的物理内存访问
先在TLB里找,若不命中,则在页表中找到PTE,构造出物理地址PA。使用CI(倒数7-12位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中,根据数据偏移量CO(后6位)取出数据返回。如果没有匹配成功或者匹配成功但标志位是1,则不命中,向下一级缓存中查询数据(L2 Cache->L3 Cache->主存),查询到数据之后,如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突,则采用最近最少使用策略LFU(Least frequently used)进行替换。(这是其中一种简单的放置策略)
7.6 hello进程fork时的内存映射
创建当前进程的内存描述符mm_struct(描述了进程的虚拟地址空间),区域结构描述符vm_area_struct(描述了进程虚拟地址空间的一个区间)和页表的原样副本。两个进程的每个页面都标记为只读页面,每个区域结构描述符vm_area_struct都标记为私有,只能在写入时复制。
7.7 hello进程execve时的内存映射
步骤如下:
- 删除已存在用户区域
- 映射私有区域
- 映射共享区域
- 设置程序计数器PC,使之指向代码入口点
7.8 缺页故障与缺页中断处理
DRAM缓存不命中称为缺页。缺页故障属于异常类别中的故障,是潜在可恢复的错误。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果牺牲页已经被修改了,内核会将其复制回磁盘。随后内核从磁盘复制引发缺页异常的页面至内存,更新对应的页表项指向这个页面,随后返回。返回当前程序后再次执行,就不会发生缺页故障了。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域-堆,分配器将堆视为一组不同大小的块来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。用户调用相应的申请和释放函数,动态内存分配器就会改变相应的块来完成要求,或检查相应的块,或遍历寻找空闲块。
分配器有显式分配器和隐式分配器,malloc程序包就是一种显式分配器。对于隐式空闲链表分配器,它所使用的堆块是由一个子的头部、有效载荷,以及可能的一些额外的填充组成。其中头部含有块的大小以及是否分配的信息。有效载荷用来存储数据,而填充块则是用来对付外部碎片以及对齐要求。基于这样的基本单元,便可以组成隐式空闲链表。在隐式空闲链表中,空闲块通过头部中大小字段隐含连接,可添加边界标记提高合并空闲块的速度。一个块是由一个字的头部,有效载荷,以及可能的一些额外的填充组成的,其中头部编码了这个块的大小,以及这个块是已分配的还是空闲的。如果强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的高29位,释放剩余3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明它是已分配的还是空闲的。
7.10本章小结
本章主要讨论了hello程序的存储管理,包括存储地址空间、段式管理、页式管理、物理内存访问,进程创建和运行时的内存映射,以及缺页故障与缺页中断处理、动态存储分配管理等内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件。所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件。
设备管理:unix io接口,使得所有的输入和输出都能以一种统一的方式来进行,包括打开文件,改变当前的文件位置,读写文件,关闭文件等操作。
8.2 简述Unix IO接口及其函数
1. 打开文件:
一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息,应用程序只需记住这个描述符。
open()函数:
函数原型:int open(char *filename, int flags, mode_t mode);
此函数打开一个文件,将filename转换为一个文件描述符,并返回描述符数字(总是进程中未打开的最小描述符)。flags参数指明进程如何访问文件,mode参数指定新文件的访问权限位。
2. 关闭文件:
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
close()函数:
函数原型:int close(int fd);
此函数关闭一个打开的文件,关闭一个已关闭的描述符会出错。
3. 读取与写入文件:
一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发EOF条件,应用程序能检测到这个条件。在文件结尾处并没有明确的EOF符号。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k,直到k+n。
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的当前文件位置。
4. 游标移动
对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K。
lseek()函数:
通过调用此函数,应用程序能够显式地修改当前文件的位置。
8.3 printf的实现分析
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;}
要实现printf函数,需要接受fmt格式,将匹配到的参数按照格式输出。
vsprintf的实现如下:
intvsprintf(char*buf,constchar*fmt,va_listargs){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;default:break;}return(p-buf);}}
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出写入buf供系统调用write输出时使用。
Write系统函数:
write:moveax,_NR_writemovebx,[esp+4]movecx,[esp+8]intINT_VECTOR_SYS_CALL
write系统调用通过几个寄存器传参,随后调用中断门intINT_VECTOR_SYS_CALL
来调用sys_call实现输出这一系统服务。sys_call通过逐个字符直接写至显存,输出格式化的字符串。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar的函数实现:
intgetchar(void){charc;return(read(0,&c,1)==1)" />getchar()调用一个read函数,将缓冲区内容读到c中,返回值函数为1的时候返回c,否则返回EOF。异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要讨论了I/O设备管理机制,包括打开、关闭、读取、写入文件等操作的接口及相关函数,并简单分析了printf和getchar函数的实现方法。
结论
hello的一生,从预处理、编译、汇编到链接,历经艰辛,最终才诞生一个完美的生命。它经历的过程包括:
- 通过文本编辑器或高级集成开发环境IDE,我们使用C语言一字一键敲进电脑,编写了hello.c文件。
- 经过预处理,hello.c文件得到了初步的调整修改,通过头文件、宏定义的展开和条件编译,生成了更容易被编译器理解的hello.i文件
- 编译器将hello.i文件翻译成了汇编语言,并将汇编代码保存在了hello.s文件中
- 然后,汇编器将hello.s中的汇编语言处理成了能够被机器理解的机器语言指令,生成了一个二进制的可重定位的目标文件--hello.o
- 链接器将hello.o与外部文件(库)进行链接,hello.o变为了一个可执行文件hello,它可以被加载到内存中,由系统执行
- 随后在shell中输入./hello120L021304 wyf,内核就会找到可执行文件hello,分配需要的空间,为其创建新进程
- 在程序运行的时候,我们可以在外部输入信号对进程进行操作,比如用Ctrl-Z挂起,用Ctrl-C终止。
- 在hello需要访问磁盘信息的时候,CPU通过MMU帮助hello寻址
- 当hello调用printf、getchar等函数时,这些函数的实现与Linux系统I/O设备管理、Unix IO接口等息息相关
- 最终,在hello进程执行结束之后,父进程shell会对它进行回收,内核也会清除在内存中为其创建的各种数据结构和信息
在这次大作业中,一个小小的hello程序的一生就为我展示了计算机系统的各个环节,让我看到了现代计算机系统的精巧严密,让我对许多抽象的思想有了更深入的理解,从程序员的角度认识计算机系统的运行机制和原理。在我成为优秀程序员的过程中,这会让我受益匪浅,我也会在未来更深入的学习相关知识,并将其运用于具体实践。
附件
文件名称
文件作用
hello.c
源代码
hello.i
预处理后的文件
hello.s
编译后的汇编文件
hello.o
汇编后的目标文件
hello
链接后的可执行文件
objdump.txt
对hello.o的反汇编文件
hello_dis.txt
对hello的反汇编文件
hello.elf
hello.o的elf文件
hello_elf2.elf
hello的elf文件
参考文献
[1] Randal E.Bryant,David R.O’Hallaron. 深入理解计算机系统
[2] Printf函数的深入剖析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
[3] EDB的安装与使用
EDB的安装和如何带参数运行程序_FZQuantum的博客-CSDN博客
[4] 逻辑地址、线性地址与物理地址
逻辑地址、线性地址和物理地址 | vosamo
[5] read和write系统调用以及getchar的实现
read和write系统调用以及getchar的实现_Vincent's Blog的博客-CSDN博客_getchar实现