摘 要
本文是计算机系统课程的课程作业,旨在研究hello程序在Linux系统下的整个生命周期,包括其预处理、编译、汇编、链接,以及其进程管理、存储管理和IO管理。本文以CASPP教材为本,通过实践将其中内容落实并展现出来。
关键词:CSAPP;生命周期;Linux;计算机系统
第1章 概述
1.1 Hello简介
P2P过程:
在Linux系统中,hello.c程序通过cpp的预处理、ccl编译、as汇编、ld链接最终成为可执行目标程序hello。在Shell中键入命令执行该程序后,shell为其fork子进程,于是hello从Program变成了Process,即P2P的过程。
O2O过程:
execve加载hello,映射虚拟内存、载入物理内存,进入main函数执行目标代码,CPU为hello分配实现片,执行其逻辑控制流。程序运行结束后,shell父进程回收hello进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境:Intel Core i9 x64CPU;32GB 2400MHz RAM
软件环境:Ubuntu 20.04 LTS
开发和调试工具:VSCode,readelf,gcc,edb-debugger,as,ld
1.3 中间结果
hello.i | 预处理之后的文本文件 |
hello.s | 编译之后的文本文件 |
hello.o | 汇编之后的二进制文件 |
hello.elf | hello.o的ELF格式 |
hello.objdump | hello.o的反汇编代码 |
hello | 链接后的可执行目标文件 |
exhello.elf | hello的ELF格式 |
exhello.objdump | hello的反汇编代码 |
1.4 本章小结
本章简要介绍了hello的P2P、O2O过程,描述了实验环境和中间结果文件。
本章无附件。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器根据宏定义、条件编译等以字符#开头的命令,修改原始C程序,并将引用的所有库展开合并成一个完整的文本文件的过程。
作用:
1.处理文件包含
将源文件中#include方式引用的文件复制到新的文件中。比如在hello.c中的#include <stdio.h>,#include <unistd.h> 和#include <stdlib.h>。预处理器根据这些命令读取系统文件中stdio.h,unistd.h,stdlib.h的内容,并将他们置入新的文本文件里去。
2.处理宏定义
处理#define的命令,将宏名替换为字符串、代码等实际文本。比如根据#define PI 3.14,预处理器将文件中所有的标识符PI都换成3.14。
3.处理条件编译
有些语句希望在满足某些条件的情况下进行编译。预处理器根据#if后面的条件决定需要编译的代码。
2.2在Ubuntu下预处理的命令
命令:cpp hello.c > hello.i
图2.1 通过cpp命令得到hello.i
图2.2 main函数在第3047行
2.3 Hello的预处理结果解析
预处理得到的hello.i文件是一个3061行的文本文件。如图2.2,文件最后的部分(3047行开始)是main函数。而前面上千行内容是stdio.h,unistd.h和stdlib.h的一次展开。而且,在展开的过程中,被展开文件如stdio.h文件中依然可能包含#define,#ifdef等语句,cpp会将其一并展开,所以最终得到的hello.i文件中是不包含#define等语句的。
2.4 本章小结
本章展示了预处理的过程和作用,生成并分析了hello.i文件。
本章的附件是hello.i。
第3章 编译
3.1 编译的概念与作用
编译即编译器将文本文件hello.i翻译成文本本间hello.s的过程。hello.s文件包含一个汇编语言程序。其大致过程:
1.词法分析,将字符串转化为内部表示结构;
2.语法分析,将token生成一棵语法树;
3.生成目标代码,将语法树转化为代码。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
图3.1 编译得到hello.s
3.3 Hello的编译结果解析
得到的hello.s是一个81行的文本文件,其中是hello程序的汇编代码。
3.3.1 数据
1.一些整数
1)循环中的i:
i被放在栈中,即-4(%rbp)的位置。在图3.2中可以看到,在for循环中其被反复使用。
2)argc:
作为main的第一个参数被放置在%rdi中,之后与4比较时被复制到-20(%rbp)的位置:
- movl %edi, -20(%rbp)
- movq %rsi, -32(%rbp)
- cmpl $4, -20(%rbp)
3)其他的整数:
其他整数比如与argc比较的4等,表现为立即数。在汇编代码中可以看到其前面都有$符号。
图3.2 hello.s中的for循环
2.字符串
在hello.c的printf语句中出现了字符串,作为输出内容和格式化输出格式。体现在汇编代码的5至8行:
- .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"
- .LC1:
- .string "Hello %s %s\n"
可以看到,第一个字符串,即"用法: Hello 学号 姓名 秒数!\n"的汉字内容被编码为UTF-8格式。
3.数组
程序中hello.c中有数组char *argv[],用来存储运行时命令行输入的参数。从图3.2的for循环中可以看到,其位置在-32(%rbp)处,程序将其位置赋给%rax并通过改变%rax来访问argv[1]、argv[2]、argv[3]。
3.3.2 赋值、算术和关系
1.赋值
程序在for循环中给i赋值。在图3.2中可以看到,31行中,通过movl将立即数0赋值给i。
2.算术操作
1)在for循环中,计数器i自增。从图3.2中第51行可以看到,程序通过addl指令将立即数1增加到i上,实现加法。
2)在取得要访问的地址时,程序常用leaq指令,如第26行的:
leaq .LC0(%rip), %rdi。它计算了字符串的地址作为printf的参数。
3.关系操作
1)hello.c在if语句中进行了argc!=4的判断。体现在hello.s的第24行,通过cmpl语句将立即数4和argc进行比较。比较之后设置条件码,进而为下一步的跳转提供条件。
2)for循环中进行了i<8的判断。体现在hello.s的第53行,同样使用cmpl语句将立即数7和i进行比较,并设置条件码。
3.3.3 控制
1.for循环
图3.2展示了for循环的汇编代码。这部分代码中有两个跳转语句,分别是32行的jmp和54行的jle。正是这两个跳转实现了for循环。
第32行的jmp是无条件跳转,跳入.L3的部分,也就是for循环终止条件的判断。
第54行的jle是根据53行cmpl设置的条件码进行条件跳转,即:若满足循环继续进行的条件,跳转到.L4,进行下一次循环;若不满足,不跳转而继续向下进行,相当于结束循环。
2.if分支
图3.3展示了if分支的汇编代码。可以看到,24行进行了hello.c中argc!=4的判断。判断后,如果不满足if的条件,即argc和4相等,那么通过25行的je语句跳转到.L2,也就是hello.c中if的大括号之外的部分。否则会继续向下执行26至29行,即if的大括号内的部分。
图3.3 hello.s中的if分支
3.3.4 函数
1.参数
在64位下,函数的前6个参数分别存储在%rdi,%rsi,%rdx,%rcx,%r8,%r9中。
比如,在此程序中,main函数的第一个参数argc就存在%rdi中:从图3.3中的第22行可以看到,argc从%edi中复制到栈中,然后在第24行进行了比较。
又比如,图3.3中的第28行,在执行exit函数之前,将其参数,也就是立即数1,移动到%edi中,供exit函数使用。
2.函数调用
调用函数时,利用call指令,把调用指令的下一条指令压入栈中。比如图3.3的第27行堆printf函数的调用、第29行对exit函数的调用等。
3.函数返回
函数返回时,利用ret指令,把call压入栈的指令弹出并跳转到该指令的位置。比如hello.s的第59行。
3.4 本章小结
本章介绍了编译的作用和具体处理方式。
本章将上一章得到的hello.i文件编译生成了hello.s的汇编代码,并通过对hello.s内容的分析,展示了编译器和汇编代码对数据、算术操作、控制转移、函数操作等C程序各方面内容的处理。
本章的附件是hello.s。
第4章 汇编
4.1 汇编的概念与作用
汇编,指汇编器(Assembler)将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中。.o文件是一个二进制文件,其包含程序的指令编码。
汇编的作用在于将人类可读内容转化为机器可读内容。
4.2 在Ubuntu下汇编的命令
命令:as hello.s -o hello.o
图4.1 汇编得到hello.o
4.3 可重定位目标elf格式
执行readelf -a hello.o > hello.elf得到ELF格式。其包含以下几个部分:
1.ELF头
以16字节的Magic开始。Magic中含有生成该文件的系统的字的大小和字节顺序。Magic之后的部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件类型、字节头部表文件偏移等信息。
图4.2 ELF头
2.节头部表
包含文件中出现的各个节的语义——节的类型、位置和大小等。
图4.3 节头部表
图4.4 重定位节.rela.text
3.重定位节
.rela.text,一个.text节中位置的列表,包含.text节中需要进行重定位的信息。其高速链接器在将目标文件合并成可执行文件的时候如何修改这个应用。
如图4.4所示,其中的8条重定位声明分别对应第一个printf中字符串、puts函数、exit函数、第二个printf中字符串、printf函数、atoi函数、sleep函数、getchar函数。
.rela.eh_frame,包含eh_frame节的重定位信息。
4.符号表
符号表.symtab用来存放程序中定义和引用的函数和全局变量的信息。重定位引用的符号都在其中声明。
4.4 Hello.o的结果解析
执行命令objdump -d -r hello.o > hello.objdump得到反汇编代码。将之与hello.s对比,发现二者差别不大。在细节上有如下差别:
1.函数调用
在hello.s中,调用函数时call指令后面直接跟着函数名称。而通过反汇编得到的hello.objdump中,call后面跟着的是当前下一条指令的地址。
比如,在hello.s的第55行中的“call getchar@PLT”对应hello.objdump的49行的“callq 8b <main+0x8b>”。
这种区别的原因是,hello.c中调用的函数是共享库中的函数,需要通过动态链接器确定地址。所以在汇编成机器语言的时候,会将call后的相对地址设置为0。
2.分支转移
反汇编代码的跳转指令的参数并不是类似“.L3”、“.L4”的段名称,而是确切的地址。
这种区别的原因是,段名称在汇编程序中只是便于人类理解的符号,在翻译为机器语言后显然不复存在。在.o文件中应当使用的是确切地址。
3.全局变量
在hello.s中,对printf中的字符串的访问是通过段名称+%rip的方式。而在反汇编代码里,是通过0+%rip的方式访问。
这种区别的原因是,rodata中数据地址在运行时确定,需要重定位然后访问。所以在机器语言里,操作数设置为0并添加重定位条目。
4.5 本章小结
本章介绍了hello.s到hello.o的汇编的过程。
本章通过查看hello.o的elf格式和对hello.o进行反汇编并与hello.s进行比较的方式,展示了从汇编语言到机器语言的过程中汇编器进行的转换。
本章的附件有hello.o,hello.elf和hello.objdump。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成一个单一文件的过程。这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。
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.1 链接得到可执行文件hello
5.3 可执行目标文件hello的ELF格式
执行命令readelf -a hello > exhello.elf得到hello程序的ELF格式文件。
在ELF格式文件中,Section Header声明了hello中所有节信息。包括大小以及在程序中的偏移量offset等。
图5.2 节头的一部分
5.4 hello的虚拟地址空间
使用edb加载可执行文件hello,在Data Dump窗口能够看到虚拟地址空间中的hello程序。这其中的节的排布正如ELF格式文件的节头中地址所声明的。
比如在节头中声明了.init如图5.3:
图5.3 .init在节头中的声明
在edb中也能看到程序自虚拟地址0x401000开始。
图5.4 edb中Date Dump
5.5 链接的重定位过程分析
执行objdump -d -r hello > exhello.objdump得到hello的反汇编代码。
将hello的反汇编代码和我们在上一章中得到的hello.o的反汇编代码进行对比,发现hello的反汇编代码中多了许多节:.init、.plt、.plt.sec、.fini。而且在.text节中也多出了一些函数。具体地:
1.新增函数
在使用ld命令链接时,链接器将初始化函数_init,程序入口_start(以及其调用的函数),还有hello.c中用的printf等许多函数加入进来。
2.函数调用
我们发现,call后面的参数是被调用函数的实际地址,而不是下一条指令的地址。因为此时链接器已经计算完相对距离。
3.对.rodata的引用
链接器对.rodata重定位,.rodata和.text节之间的距离已经确定,所以我们能够看到程序对printf中字符串的引用不再是“0+%rip”的形式,而是如exhello.objdump的111行中的“lea 0xec3(%rip),%rdi”这种形式。
5.6 hello的执行流程
ld-2.34.so!_dl_start
ld-2.34.so!_dl_init
hello!_start
libc-2.34.so!__libc_start_main
-libc-2.34.so!__cxa_atexit
-libc-2.34.so!__libc_csu_init
hello!_init
libc-2.34.so!_setjmp
-libc-2.34.so!_sigsetjmp
--libc-2.34.so!__sigjmp_save
hello!main
hello!puts@plt
hello!exit@plt
*hello!printf@plt
*hello!sleep@plt
*hello!getchar@plt
ld-2.34.so!_dl_runtime_resolve_xsave
-ld-2.34.so!_dl_fixup
--ld-2.34.so!_dl_lookup_symbol_x
libc-2.34.so!exit
5.7 Hello的动态链接分析
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所 以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代 码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量 表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
5.8 本章小结
本章介绍了链接的概念和作用。
本章链接得到hello可执行文件,并通过分析可执行文件hello的ELF格式、反汇编代码、虚拟地址空间、重定位和执行的过程等展示了链接的过程。
本章的附件有hello、exhello.elf和exhello.objdump。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需要的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。
进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序,我们的程序好像是独占地使用处理器和内存,处理器好像是无间断地执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中的唯一对象。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell是用C语言编写的程序,它是用户使用Linux的桥梁。Shell是指一种应用程序,它提供一个界面,用户通过这个界面访问操作系统内核的服务。
处理流程:
1.从终端读入输入的命令;
2.将输入的字符串切分,获得输入的参数;
3.如果是内置命令则立刻执行;
4.否则调用相应的程序为其分配子进程并运行;
5.shell接收键盘输入信号,并对这些信号进行相应的处理。
6.3 Hello的fork进程创建过程
在终端输入./hello之后,终端程序会对输入的命令进行解析:由于这不是一个内置的shell命令,所以终端程序判断./hello的语义是执行当前目录的可执行文件hello。
然后,终端程序调用fork函数创建一个新的子进程,这个子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,这意味着父进程调用fork时,子进程可以读写父进程打开的任何文件。父子进程的最大区别在于拥有不同的PID。
父进程和子进程独立并发地运行,内核能够以任意方式交替执行它们的逻辑控制流的指令。
有关hello的进程图如图6.1:
图6.1 hello的fork
6.4 Hello的execve过程
在fork之后,子进程调用execve函数在当前进程的上下文中加载一个新程序即hello。一个被称为启动加载器的系统代码被execve调用,进而执行hello。加载器删除子进程现有的虚拟内存段,创建一组新的代码、数据、堆栈并初始化。然后加载器设置PC指向_start地址,然后通过_start调用hello中的main。
6.5 Hello的进程执行
逻辑控制流:一系列程序计数器PC的值的序列叫做程序控制流。进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不能够直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文信息:上下文即是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
我们考虑hello调用sleep函数的进程调度过程。
当调用sleep之前,如果hello未被抢占则顺序执行即可;如果被抢占,则由内核调度器进行上下文切换。
我们的hello一开始运行于用户模式,在调用sleep之后:
1)进入内核模式,内核处理sleep的休眠请求;
2)hello被挂起,其他进程获得当前进程控制权(用户模式);
3)收到信号后,进入内核模式,执行中断处理,将hello重新加入到运行队列;
4)hello继续执行自己的逻辑控制流。
上述过程如图6.2。在这个过程中的异常与信号处理在下节讨论。
图6.2 hello上下文切换的简单示意
6.6 hello的异常与信号处理
图6.3所示为hello正常执行的结果。在getchar接收字符后程序执行完毕,进程被回收。
图6.3 正常执行hello
如果在程序执行过程中按下ctrl-c,shell父进程收到SIGINT信号,信号处理函数中断hello并回收hello进程,如图6.4所示。
图6.4 hello执行过程中按下crtl-c
如果在程序执行过程中按下ctrl-z,shell父进程收到SIGSTP信号,程序会被挂起,但正如图6.5所示,程序并未被回收。执行fg 1指令可将其调回前台,hello会继续执行,输出余下的字符串,然后程序结束,进程被回收。
图6.5 hello执行过程中按下ctrl-z
如果在执行过程中胡乱输入,可以发现,键盘的输入进入到缓存stdin,getchar函数会读一个以回车为结尾的串。之后的输入会被shell当作命令行输入。
图6.6 hello执行过程中胡乱输入
6.7本章小结
本章解释了进程的定义和作用,并简单介绍了Shell的处理流程。
本章通过执行hello程序并采取不同的输入,展示了hello的异常与信号处理机制。
本章无附件。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.物理地址
用于内存芯片级的单元寻址,与CPU连接的地址总线相对应。
2.逻辑地址
程序代码在编译之后出现在汇编程序中的地址。逻辑地址由选择符和偏移量组成。
3.线性地址/虚拟地址
逻辑地址经过段机制后转化为线性地址,形如“描述符:偏移量”的组合。分页机制中,线性地址为输入。
7.2 Intel逻辑地址到线性地址的变换-段式管理
1.段式管理
在段式存储管理中,程序的地址空间被划分为许多段(segment),如此一来,每个进程有一个二维的地址空间。在动态分区分配方式中,系统为整个进程分配一个连续的内存空间。而在段式存储系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存段不同分区中。程序加载时,系统为所有段分配内存,这些段可以不连续。
程序通过分段划分为多个模块,比如代码段、数据段、共享段。这些段可以分别编写和编译、可以针对不同类型的段采取不同级别的保护、可以按段为单位进行共享。
2.地址变换
在段式管理系统中,整个进程的地址空间是二维的,即其逻辑地址由段号和段内地址两部分组成。为了将逻辑地址映射到物理地址,处理器会查找内存中的段表,通过段号得到段首地址,加上段内地址以得到物理地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
1.页式管理
页式管理系统将程序的逻辑地址空间划分为固定大小的页(page),物理地址划分为同样大小的页框(page frame)。程序加载时,可以把任意页放入内存中任意页框。由于这些页框不必连续,所以实现了离散的分配。在页式存储管理中,地址结构由两部分组成:页号P和页内地址W(位移量)。
页式管理的好处在于:没有外部碎片、程序可以离散存放、便于改变程序占用空间的大小。但其要求程序全部装入内存,对内存大小有要求。
2.地址变换
页式管理系统中,地址分为两部分:逻辑页号和页内地址。
CPU中的内存管理单元(MMU)按逻辑页号通过查进程页表得到物理页框号,然后将物理页框号于页内地址相加以获得物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
考虑这样一个Intel Core i7下的VA到PA到翻译问题:虚拟地址空间48位,物理地址空间52位,页表大小4KB,四级页表。TLB 4路16组相联。一共4个页表共36位二进制索引,所以VPN共36位,因为VA48位,所以VPO12位,TLBI需4位,TLBT32位。
如图7.1,CPU产生虚拟地址VA并传送给MMU,MMU使用前36位VPN作为TLBT+TLBI(32位+4位)向TLB中试图匹配。
如果命中,则得到PPN与VPO组合得到PA(40位+12位=52位)。
如果没有命中,那么MMU在页表中查询,CR3确定第一级页表的起始地址,VPN1确定第一级页表中的偏移量,查询出PTE,如果其在物理内存中则确定第二级页表的起始地址,以此类推,最终在第四级页表中查询好PPN,与VPO组合得到PA,最后向TLB中添加条目。在查询的过程中,如果查询PTE发现不在物理内存中,则引发缺页。如果权限出问题,则引发段错误。
图7.1
7.5 三级Cache支持下的物理内存访问
如图7.1所示,物理地址被分为CT(标志位)、CI(索引位)、CO(偏移量)。
在刚才的讨论中,我们已经获得了物理地址VA。使用CI进行组索引,每组8路,对8路的块分别尝试匹配CT,如果匹配成功且块的标志位为1,则命中,根据CO取出数据。
如果没有匹配成功,或者成功但标志位不是1,则不命中,向下一级缓存查询数据,并将其放置到当前级别缓存。放置时,如果有空闲块则直接放置其中,如果没有则根据LRU等算法寻找牺牲块进行替换。
7.6 hello进程fork时的内存映射
当fork函数被shell进程调用,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给新进程创建虚拟内存,内核创建了当前进程的mm_struct、区域结构和页表的副本。这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
函数execve调用内核的启动加载器代码,在当前进程中加载并运行在可执行目标文件hello中的程序,相当于用hello代替了当前程序。加载并运行hello主要有以下的阶段:
1)删除已经存在的用户区域,删除当前进程虚拟地址的用户部分中的一存在的区域结构;
2)映射私有区域,为新程序的代码、数据、bss和栈区域等创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data段,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,堆栈地址也是请求二进制零的,初始长度为零;
3)映射共享区域,hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内;
4)设置程序计数器PC,execve做的最后一件事就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图7.2 加载器映射用户地址空间区域
7.8 缺页故障与缺页中断处理
缺页故障:缺页故障发生在指令引用一个虚拟地址,但在MMU查找页表时发现与该地址相对应的物理地址不存在内存中,因此必须从磁盘中取出的时候。
缺页中断处理:缺页中断处理程序时系统内核的代码。它选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令重新发送VA到MMU,这时MMU就可以正常翻译该VA。
7.9动态存储分配管理
动态内存分配器维护一个进程的虚拟内存区域,即堆。分配器将堆视为一组大小不同的块的集合。每个块是一个连续的虚拟内存片,其要么是已分配的,要么是空闲的。已分配的块显式地保留为供程序使用。空闲块可以用来分配。空闲块在被应用所分配前保持空闲,而已分配的块在被释放之前一直保持已分配的状态。
分配器分为两种基本风格:显式分配器、隐式分配器。显式分配器要求应用显式地释放任何已分配的块,而隐式分配器要求分配器检测一个已分配块何时不再使用,不使用时自动释放这个块(即垃圾收集)。
对块的一种组织方法是带边界标签的隐式空闲链表。如图7.3所示,每个块中包含4B的头部和4B的脚部,二者完全相同。头部和脚部中包含块的大小和块是否是空闲的的信息。可以利用头部和脚部信息来寻找前一个或后一个块的开始位置,也就是实现了一个链表。
因为头部和脚部的存在,对相邻的空闲块的合并变得容易。如果一个块变为空闲块,它可以通过头部和脚部检查前面的块和后面的块是否为空闲的,进而进行合并。
图7.3 使用边界标记的堆块的格式
对隐式空闲链表的一种改进是显式空闲链表。可以将每个空闲块中加入前驱和后继,这样能够从一个空闲块直接访问下一个空闲块而忽略已分配的块。
图7.4 使用显式空闲链表的堆块格式
7.10本章小结
本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,并展示了VA到PA到变换过程及物理内存的访问。还介绍了hello进程的fork和execve时的内存映射、缺页鼓掌与缺页中断处理、动态存储分配管理。
本章无附件。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的IO设备都被模型化为文件,而所有的输入和输出都被当作对相应文件对读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用节空,成为Unix I/O。
8.2 简述Unix IO接口及其函数
1.Unix I/O接口统一操作:
1) 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
2)Shell创建的每个进程都有三个打开的文件:标准输入,标准输出和标准错误。
3)改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行 seek,显式地将改变当前文件位置k。
4)读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置 k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5)关闭文件。内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
2.Unix I/O函数:
1)int open(char* filename,int flags,mode_t mode),进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
2)int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。
3)ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
4)ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
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;
- }
参数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生成格式化的字符串buf,然后返回字符串的长度。
最后,printf函数调用write将长度为i的字符串buf输出。函数write如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里,栈中参数被置入寄存器,其中ecx是字符的个数,ebx存放字符串开始地址。然后通过系统调用syscall,其实现如下:
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
这个syscall将字符串中的字节从寄存器通过总线复制到显存里,以ASCII的形式存储。字符显示驱动子程序根据ASCII码在字模库中找到点阵信息并将点阵存在vram中。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点。
如此一来,字符串“Hello 1190202425 fyz”便显示在了屏幕上。
8.4 getchar的实现分析
异步异常-键盘的中断处理:用户按键,键盘接口得到一个代表这个案件的扫描码,同时产生中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得这个扫描码,然后将扫描码转换为ASCII,保存到系统的键盘缓冲区。
函数getchar底层调用了read系统函数,通过系统调用读取存储在键盘缓冲区的ASCII码直到读到回车,然后返回整个字符串。封装后的getchar读取字符串的第一个字符后返回。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法、Unix IO接口和函数,然后分析了hello程序中调用的printf和getchar函数的实现。
本章无附件。
结论
hello程序的整个生命周期还是复杂的——这也体现了现代计算机系统的复杂精妙。整个生命周期包括下面一些过程:
0)编写程序。
1)预处理,将hello.c调用的外部库等展开合并到一个hello.i文件中。
2)编译,将hello.i编译成汇编代码文件hello.s。
3)汇编,将hello.s变成可重定位目标文件hello.o。
4)链接,将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello。
5)运行,用户在shell中输出./hello + 参数。
6)创建子进程,shell调用fork为hello创建子进程。
7)执行程序,shell调用execve,调用启动加载器,加载程序,映射虚拟内存,载入物理内存,进入main函数。
8)执行逻辑控制流,CPU为进程分配时间片,在一个时间片中,hello享有CPU资源,并可以顺序执行自己的逻辑控制流。
9)内存访问,MMU通过页表将hello使用的虚拟内存地址映射成为物理内存地址。
10)动态申请内存,hello程序中有printf函数,会调用malloc向动态内存分配器申请堆中的空间。
11)信号处理,如果运行时按下ctrl-c,ctrl-z等,则调用shell的信号处理函数进行相应的挂起、终止等操作。
12)结束,shell父进程回收子进程,内核删除hello有关数据结构。
小小程序,却如此复杂,这也也体现了计算机系统的精巧奇妙,玄而莫测。
附件
hello.i | 预处理之后的文本文件 |
hello.s | 编译之后的文本文件 |
hello.o | 汇编之后的二进制文件 |
hello.elf | hello.o的ELF格式 |
hello.objdump | hello.o的反汇编代码 |
hello | 链接后的可执行目标文件 |
exhello.elf | hello的ELF格式 |
exhello.objdump | hello的反汇编代码 |
参考文献
[1] Randal E. Bryant, David R. O’Hallaron. Computer Systems A Programmer’s Perspective[M]. 龚奕利, 贺莲, 译.北京:机械工业出版社, 2016: 587-605.
[2] printf函数实现的深入剖析: https://www.cnblogs.com/pianist/p/3315801.html