计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机
学 号 120L020712
班 级 2003011
学 生 张扬
指 导 教 师 郑贵滨
计算机科学与技术学院
2021年5月
摘 要
本文将通过追溯hello.c程序的完整生命周期,通过对其预处理、编译、汇编等过程的分步解读阐述各个阶段在Linux下的实现机制及原因,解析了Linux计算机系统中一个程序从生成到结束的完整过程,使我们熟悉程序的进程管理、存储管理和IO管理,便于我们深入理解计算机的底层实现。
关键词:预处理,编译,汇编,链接,进程管理,存储管理,IO管理,底层实现
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
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 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
第1章 概述
1.1 Hello简介
先使用编译器编写hello程序并建立.c文件,然后使用cpp,将.c文件预处理为.i文件,再用ccl将其编译为汇编文件hello.s,再经过as的汇编将其翻译为可重置定位目标的文件hello.o,再经过ld的链接将hello.o和系统目标文件组合最终成为可执行目标程序hello,具体过程见图:
在可执行文件hello、执行后,Shell通过fork函数创建了一个新的进程,之后调用exceve在建立的子进程中加载hello并映射虚拟内存,通过mmap为hello程序开创新空间。进入main函数后,CPU为hello分配时间片执行逻辑控制流,有益尝试触发一场处理子程序。Hello通过unix I/O管理来控制输出。在程序执行完成后,父进程会回收hello进程和它创建的子进程,释放其所占的内存并删除有关进程上下文。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows10 64位;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发工具:Visual Studio 2019 64位;GDB/OBJDUMP;GCC;EDB等
1.3 中间结果
hello.c:源程序
hello.i:预处理后生成的文件
hello.s:hello.i经过编译后生成的文件
hello.o:hello.s经过会变厚生成的可重定位目标文件
hello:hello.o与所需文件链接后生成的可执行程序
hello.elf:hello.o的ELF文件
hello.o_asm.txt:hello.o的反汇编文件
hello_asm.txt:hello的反汇编文件
hello1.elf:hello的ELF文件
1.4 本章小结
本章简要介绍了hello程序的“一生”,进行了对P2P、O2O的概述。以及进行了对实验时的软硬件环境及开发与调试工具等的介绍。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念:编译器在编译开始前调用预处理器执行以#开头的指令(读取头文件、执 行宏替代等)、删除注释、包含其他文件、执行条件编译、布局控制等修改原始的C程序,生成以.i结尾的文本文件
2.1.2 预处理的作用:处理以#开头的指令,修改原始的C程序
2.2在Ubuntu下预处理的命令
Ubuntu中预处理的命令为:
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
hello.i文件的部分截图:
结果分析:查看hello.i文件后发现文件内容相较源文件大大增加,且源代码中的注释都被删除,头文件等都被替代为了相应代码。由于hello.c的头文件中还包含其他的头文件,因此系统会敌对的寻址和展开,直到文件不含宏定义且相关头文件都已经被引入。
2.4 本章小结
本章介绍了预处理的概念和作用,并展示了预处理过程中对头文件stdio.h、stdlib.h、unistd.h的解析
第3章 编译
3.1 编译的概念与作用
编译的概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
编译的作用:生成的汇编文件每条语句都以一种文本格式描述一条低级机器指令,汇编语言为不同的高级语言的不同编译器提供通用的输出语言,汇编语言相比预处理文件更利于机器理解,同时它还可进行语法检查、程序优化。
3.2 在Ubuntu下编译的命令
在Ubuntu下编译的命令为:
gcc -S hello.c -o hello.s
3.3 Hello的编译结果解析
3.3.1 数据
a) 局部变量
.L2中声明了局部变量i,并将其存放在-4(%rbp)中(编译器在进行编译时会将局部变量i放在堆栈中),并将其初始化为0
之后进入一个循环
b) 数组
char *argv[]中的每个元素都是一个指向字符类型的指针。数组的起始地址存放在栈中-32(%rbp)的位置
经过了两次调用后传递给了printf函数
3.3.2 赋值操作
hello.c中赋值操作是for循环中的i=0,在汇编代码中通过mov指令完成,而根据操作数的大小mov指令可以有不同类型。如:movb:一个字节、movw:字、movl:双字、movq:四字。利用mov指令时,如果是局部变量则不赋初值,并且在汇编代码中没有体现,只会在用到并赋初值时才会用到寄存器等来存储。
3.3.3 算术操作
在hello.c中出现的算术操作为i++,其汇编语言为:汇编语言addl $1, -4(%rbp)
其余的算术操作有:
leaq S,D D=&S
INC D D+=1
DEC D D-=1
NEG D D=-D
ADD S,D D=D+S
SUB S,D D=D-S ……
3.3.4 关系操作
a) 在hello.c中argc!=4是一个条件判断语句,在进行编译时这条语句被编译为cmpl $4, -20(%rbp),再根据条件码判断是否进行跳转
b) 在hello.c中i<8是一个条件判断语句,在进行编译时这条语句被编译为
cmpl $7, -4(%rbp),设置条件码,并利用条件码判断是否进行跳转
3.3.5 类型转换
atoi(argv[3])将字符串类型转换为了整型
3.3.6 控制转移
利用jmp和jxx类型的条件跳转指令可以进行控制转移。通过cmpl进行比较后,根据条件码来进行跳转
在hello.c中判断argv是否等于4,如果等于,则不执行if语句,否则执行
而在一个for循环中,通过判断i<8是否成立来决定是否继续进行循环
3.3.7 函数操作
a) 函数调用通过call语句来实现
b) 参数传递时使用不同的寄存器来存储函数调用时所用到的参数
c) 函数调用结束后将返回值存储在寄存器%rax中,在进行函数的操作之后ret即可返回%rax中的值
在hello.c中的函数操作有:
main( )调用:其参数是int argv char *argv
printf( )调用:其参数是argv[1],argv[2]
exit( )调用:其参数是1
sleep( )调用:其参数是atoi(argv[3])
getchar( )调用:无参数
3.4 本章小结
本章介绍了编译器的概念和作用以及编译器处理C语言程序的基本过程,同时还介绍了一些编译指令和运算操作,此外还介绍了部分的数据类型。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编器(as)将.s文件翻译为机器语言指令并将这些指令打包成可重定位目标程序的格式,结果保存在二进制目标文件hello.o中
汇编的作用:将汇编代码转换为机器指令,便于计算机理解识别和执行
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
在linux下生成hello.o文件elf格式的命令:readelf -a hello.o > hello.elf
对hello.oELF格式的分析:
a) ELF头:以16字节的Magic序列开始,描述生成该文件的系统的字的大小和字节顺序,其余部分包含有助于链接器进行语法分析和对目标文件进行解析的信息,包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
b) 节头:包含文件中各个节的语义,记录各个节的名称、类型、地址、偏移量等
c) 重定位节:调用外部函数或引用全局变量的指令都需要重定位,调用局部函数的指令不需要重 定位,在可执行目标文件中不存在重定位信息。重定位需要引用的符号都在其中声明。本程序 需要被重定位的是printf、puts、exit、atoi、getchar、sleep和.rodata
.rela.text,保存的是.text节中需要被修正的信息
d) 符号表:存放程序中定义的函数和全局变量的信息,重定位需要引用的符号都在其中声明
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
objdump -d -r hello.o >hello.o_asm.txt
与hello.s的区别:
- a) 操作数:在hello.s中操作数为10进制,在hello.o中操作数为16进制
- b) 分支转移:
反汇编代码中跳转指令使用的是确切的地址,而不是段名称
- c) 函数调用:
- 在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令
4.5 本章小结
本章介绍了汇编的概念和作用,以及从hello.s到hello.o的汇编过程,此外还有查看hello.o的elf格式和使用objdump得到反汇编代码的方法。最后比较了反汇编和hello.s的区别
第5章 链接
5.1 链接的概念与作用
链接的概念:将各种代码和数据片段收集并组合为单一文件,链接器使得分离编译成为了可能。
链接的作用:将一个程序组织为一个源文件,并把它分解为更小、更好管理的模块,并且可以独立地修改和编译。当我们改变这些模块中的一个时,只需对其进行重新编译,并将其重新链接,这样一来就不必再对其余文件进行编译
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
ld链接命令: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的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
readelf -a hello > hello1.elf 生成hello文件的ELF格式
- a) ELF头:
ELF头:以16字节的Magic序列开始,描述生成该文件的系统的字的大小和字节顺序,其余部分包含有助于链接器进行语法分析和对目标文件进行解析的信息,包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
hello的文件头和hello.o文件头的不同之处为:hello是一个可执行目标文件,有27个节
- b) 节头表:包含文件中各个节的语义,记录各个节的名称、类型、地址、偏移量等
- c) 程序头:
- d) 重定位头:
- e) 符号表:
5.4 hello的虚拟地址空间
程序头中LOAD可加载的程序段的地址为0x400000
使用edb打开hello观察hello加载到虚拟地址的状况,并查看各段信息
可以通过edb找到各个节的信息,比如.rodata节,虚拟地址开始于0x40200
5.5 链接的重定位过程分析
objdump -d -r hello > hello_asm.txt 进行反汇编
hello.o反汇编代码虚拟地址从0开始,是相对地址,hello的地址是进行重定位后的虚拟地址。hello.o反汇编代码直接是.text段,然后是main函数,而hello反汇编,由于链接重定位加入各种函数、数据。如开始的函数和调用的函数填充在main函数之前。所以main函数位置发生巨大改变。可执行文件跳转和应用就是虚拟内存地址(相对或绝对)。hello.o反汇编的跳转的就是只要hello数据时对应的位置。
根据hello和hello.o的不同,分析链接过程为:链接器ld将各个目标文件组装,即把.o文件中的各个函数段按一定规则组合,如:解决符号依赖、库依赖关系,生成可执行文件。链接器完成的主要任务:符号解析和重定位。重定位:重定位节和符号定义,重定位节中的符号引用。
5.6 hello的执行流程
开始执行:_start、_libc_start_main
执行main:_main、_printf、_exit、_sleep、_getchar
退出:exit
_start 0x4010f0
_libc_start_main 0x2f12271d
main 0x401125
_printf 0x401040
_exit 0x401070
_sleep 0x401080
_getchar 0x401050
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
在ELF文件中可以看到:动态链接器使用过程链接表PLT和全局偏移量表GOT实现动态链接。其中GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数
调用dl_init之前:
调用dl_init之后:
5.8 本章小结
本章介绍了链接的概念与作用,了解了可执行文件的ELF格式,分析了hello的虚拟地址空间,重定位过程,执行过程,动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程是程序关于某数据集合上的一次运行,是系统进行资源分配和调度的基本单位,操作系统的结构基础
进程的作用:进程提供给应用程序两个关键抽象,逻辑控制流和私有地址空间:逻辑控制流进程使得每个程序独占地使用CPU,私有地址空间使得每个程序独占地使用内存系统
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:Shell作为命令语言,它交互式解释和执行用户输入的命令或自动地解释和执行预先设定好的一连串命令;而作为程序设计语言,它定义各种变量和参数,并提供许多在高级语言中才具有的控制结构,包括循环和分支。
而Bash是一个命令处理器,通常运行于文本窗口中,并能执行用户直接输入的命令。Bash还能从文件中读取命令,这样的文件称为脚本。Bash是一个满足POSIX规范的shell,但有很多扩展。
处理流程:
- a) 从终端读入输入的命令。
- b) shell对输入命令进行解析,判断是否为内置命令,如果是则立即执行,如果不是内置命令,调用fork( )创建新进程/子进程执行
- c) 判断是否为前台程序,如果是则调用等待函数等待前台作业结束;否则将程序转入后台,直接进行对命令的输入
- d) shell接受键盘输入信号,并对这些信号进行处理
6.3 Hello的fork进程创建过程
在输入执行hello的命令之后,shell会对其进行判断,判断是否为内置命令,再通过fork函数创建出子进程即hello程序。子进程与父进程有相同的虚拟空间——包括代码和数据段、堆、共享库以及用户栈。Hello进程相对于父进程独立存在,但hello进程还可获得与shell任何打开文件描述符相同的副本,即当shell调用fork 时,hello可读写shell中打开的任何文件。Sehll和Hello进程之间最大的区别在于它们有不同的PID。创建出的fork函数会调用一次,但是会返回两次,父进程返回子进程的PID,子进程返回0。因为子进程的PID总是为非0,返回值就提供了一个方法来分辨程序是在父进程还是在子进程中执行
6.4 Hello的execve过程
int main(int argc , char **argv , char *envp);
fork后,子进程会调用execve函数在当前进程的上下文中加载并运行一个新程序即hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC指向_start地址,_start最终调用hello中的main函数。
execve 函数加载并运行可执行目标文件filename, 且带参数列表argv 和环境变量列表envp 。只有当出现错误时,例如找不到filename, execve 才会返回到调用程序。所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
- a) 上下文信息:上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成
- b) 进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片
- c) 进程调度:在进程执行的某些时刻,内核可决定抢占当前进程,并重新开始一个先前被抢占了的进程。当内核选择一个新进程运行时,即内核调度了这个进程。在内核调度了一个新进程后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程
- d) 上下文切换:保存以前进程的上下文,恢复进程被保存的上下文,将控制传递给这个新恢复的进程并完成上下文切换
- e) 用户态和核心态转换:核心态拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证系统的安全性,当没有设置模式位时,进程就处于用户态,用户态的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
- a) 下图为hello程序的正常运行状态
- b) 在输出了两条info后按下ctrl-z后,shell父进程收到SIGSTP信号,信号处理函数的逻辑是打印屏幕回显、将hello进程挂起。此时hello进程的后台job号是1
- c) 用ps命令可以看到,hello进程并没有被回收
- d) 调用fg 1将其调到前台,此时shell程序首先打印hello的命令行命令,hello继续运行打印剩下的info,之后输入字串,程序结束,同时进程被回收。
- e) 在程序运行时按下Ctrl-C之后,会导致内核发送一个SIGINT信号到前台进程组中的每个进程,默认情况下结果是终止前台作业
- f) 在程序运行中途乱按,可以发现乱按只是将屏幕的输入缓存到stdin
当getchar的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做shell命令行输入
- g) pstree:
6.7本章小结
本章介绍了进程的概念和作用、shell-bash的处理过程与作用并分析了调用fork创建新进程,调用execve函数执行hello,了解了逻辑控制流中内核的调度及上下文切换等机制,以及hello在运行时遇到的异常与信号处理
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1 逻辑地址:程序经过编译后出现在汇编代码中的地址。用于指定操作数或者操作指令的地址。由一个段标识符加上一个指定段内相对地址的偏移量组成
7.1.2 线性地址:逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合
7.1.3 虚拟地址:现代操作系统提供了一种内存管理的抽像,即虚拟内存。进程使用虚拟内存中的地址,由操作系统协助相关硬件,把它“转换”成物理地址。
这样一来程序就可以使用比真实物理地址大得多的地址空间。因为转换后的物理地址并非相同的,甚至多个进程可以使用相同的地址。有时我们也把逻辑地址称为虚拟地址
7.1.4 物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应,是内存单元的真正地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理:一个逻辑地址由两部分组成,段标识符,段内偏移量。段标识符是一个16位长的字段组成,称为段选择符,其中前13位是一个索引号。后面三位包含一些硬件细节。索引号,可直接理解成数组下标,它对应的“数组”就是段描述符表,段描述符具体描述了一个段地址,这样,很多段描述符就组成段描述符表。可以通过段标识符的前13位,在段描述符表中找到具体的段描述符,这个描述符就描述了一个段。
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的,就放在“局部段描述符表(LDT)”中。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
首先,给定一个完整的逻辑地址 [段选择符:段内偏移地址],看段选择符的T1等于0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小,我们就得到了一个数组,拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样一来它的Base,即基地址就知道了,把Base + offset,就是要转换的线性地址了
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址被分为以固定长度为单位的组,称为页(page)。
- a) 分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。
- b) 每一个活动的进程,都有其独立的虚似内存,则它也对应了一个独立的页目录地址。运行一个进程,需要将它的页目录地址放到cr3寄存器中
- c) 每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位)
转换步骤:
- a) 从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器)
- b) 根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了
- c) 根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址
- d) 将页的起始地址与线性地址中最后12位相加,得到最终我们想要的物理地址
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,内存管理单元就必须查阅一个页表条目,以便将虚拟地址翻译为物理地址。多级页表:将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。
Core i7 MMU 使用四级的页表来将虚拟地址翻译成物理地址:36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推
7.5 三级Cache支持下的物理内存访问
CPU发送一条虚拟地址,随后内存管理单元按上述操作获得了物理地址PA。根据cache的大小要求,将PA分为CT(标记位)CS(组号),CO(偏移量)。根据提供的组号去寻找正确的组,比较每一级的cache是否命中以及CT是否正确。如果命中就直接返回自己想要的数据,如果不命中,那么就依次去L2和L3判断是否命中,当主存命中时,将数据传给CPU同时更新各级cache的cacheline
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID 。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
- a) 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存 在的区域结构
- b) 映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。
- c) 映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
- d) 设置程序计数器(PC): execve 做的最后一件事情就是设置当前进程 上下文的程序计数器,使之指向代码区域的入口点,而下一次调度这个进程时,他将从这个入口点开始执行。
7.8 缺页故障与缺页中断处理
物理内存(DRAM)缓存不命中成为缺页。假设CPU引用了磁盘上的一个字,而这个字所属的虚拟页并未缓存在DRAM中。地址翻译硬件会从内存中读取虚拟页对应的页表,推断出这个虚拟页未被缓存,然后触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页。如果被牺牲的页面被修改了,那么内核会把它复制回磁盘。总之,内核会修改被牺牲页的页表条目,表示它不再缓存在DRAM中了。
之后,内核从磁盘把本来要读取的那个虚拟页,复制到内存中牺牲页的那个位置,更新它的页表条目,随后返回。当异常处理程序返回时,会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。于是,地址翻译硬件可以正常处理现在的页命中了。
7.9动态存储分配管理
7.9.1隐式空闲链表管理
隐式空闲链表:在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配合最佳适配。分配完后可以分割空闲块减少内部碎片。同时分配器在面对释放一个已分配块时,可以合并空闲块,其中便利用隐式空闲链表的边界标记来进行合并。
7.9.2显示空闲链表管理
显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱
7.10本章小结
本章介绍了存储管理的有关内容。讲述了如何解决hello可执行文件从磁盘到进程的上下文中,之后怎么解决该进程的“独占内存”(虚拟内存)手段,以及在执行的过程之中,处理器是怎么将这一虚拟内存转化为物理地址,以及遇到页表中有未加载到内存的数据时怎么处理(缺页中断处理程序)。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的接口,称为Unix I/O,这使得所有的输入和输出能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口:
- a) 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
- b) Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
- c) 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K 。
- d) 读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
- e) 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 Unix I/O函数:
- a) 进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件的:
int open(char *filename, int flags, mode_t mode);
open 函数将filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
返回:若成功则为新文件描述符,若出错为-1。
- b) 进程通过调用close 函数关闭一个打开的文件。
- int close(int fd);
- 返回:若成功则为0, 若出错则为-1。
- c) 应用程序是通过分别调用read 和write 函数来执行输入和输出的。
- ssize_t read(int fd, void *buf, size_t n);
- read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
- 返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。
- ssize_t write(int fd, const void *buf, size_t n);
- write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。
- 返回:若成功则为写的字节数,若出错则为-1。
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;
}
va_list是字符指针,而(char*)(&fmt + 4)表示fmt后的第一个参数的地址。vsprintf函数返回值是要打印出来的字符串的长度,其作用是格式化,产生格式化的输出并保存在buf中。最后的write函数即为写操作,把buf中的i个元素的值写到终端。
在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函数。在write函数中可以理解为其功能为显示格式化了的字符串。
可以看出代码里面的call是访问字库模板并且获取每一个点的RGB信息最后放入到eax也就是输出返回的应该是显示vram的值,然后系统显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return(--n>=0)?(unsigned char)*bb++:EOF;
}
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar由宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
8.5本章小结
本章介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。
结论
围绕着hello程序的一生,我们更加深入的了解了计算机深层的构建及运行机制。回顾hello程序的生命历程:
最初它是一个C语言源程序hello.c,被IO设备编写,以文件的方式储存在主存中。
- a) 预处理:对带#的指令进行解析,生成hello.i文件。
- b) 编译:经过编译器生成文本文件hello.s,包含了程序的汇编代码。
- c) 汇编:hello.s经汇编器翻译成机器语言指令并打包为可重定位目标文件hello.o。
- d) 链接:链接器将外部文件与hello.o动态链接为可执行二进制文件hello。
- e) 创建进程:在shell利用./hello运行hello程序,shell调用fork为hello创建进程。
- f) 加载运行程序:shell调用execve函数,删除原来的进程内容,加载当前进程。运行过程中系统会接受各种信号,比如ctrl+z和ctrl+c分别表示挂起(SIGSTP)和终止(SIGINT)的效果。可以用fg,bg决定前台后台运行,kill发送杀死程序的信号(SIGLKILL)。运行hello时,内存管理单元MMU、翻译后备缓冲器TLB、多级页表机制、三级cache协同合作,完成对地址的请求。
- g) 动态申请内存:当hello程序执行printf函数时,调用 malloc 向动态内存分配器申请堆中的内存。
- h) 异常处理机制:保证hello能够对异常信号进行处理,使程序具有健壮性
- i) 终止:当hello运行完毕,shell父进程回收hello,内核删除为这个进程创建的所有数据结构
附件
hello.c:源程序
hello.i:预处理后生成的文件
hello.s:hello.i经过编译后生成的文件
hello.o:hello.s经过会变厚生成的可重定位目标文件
hello:hello.o与所需文件链接后生成的可执行程序
hello.elf:hello.o的ELF文件
hello.o_asm.txt:hello.o的反汇编文件
hello_asm.txt:hello的反汇编文件
hello1.elf:hello的ELF文件
参考文献
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.