摘 要
本文从hello.c的一生入手,利用Ubuntu下的操作工具,介绍了它的诞生,即计算机系统是如何将一个文本文件转化为可执行文件的;运行,即可执行文件在计算机底层的实现逻辑,包括如何被加载到进程中,进程的上下文切换,参数的保存形式以及如何利用系统级I/O实现输入输出交互等。最后hello被回收,结束了它的一生,本文也就完整地梳理了计算机系统这门课程要教给我们的知识。
关键词:hello.c的一生,Ubuntu,计算机系统
(摘要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.5 三级Cache支持下的物理内存访问 - 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程序生命周期是从一个源文件开始的,即程序员通过文本编辑器创建并保存的文本文件,命名为hello.c。
P2P:Program to Process
Program:利用编辑器键入代码得到hello.c程序
Process:hello.c经过cpp的预处理,得到.i文件;再经过ccp编译,得到.s文件;再经过as汇编,得到.o文件;最后经过ld得链接,hello.c最终变成了可执行目标文件。在shell中键入启动命令./hello后,shell会为其fork产生子进程。
020: From Zero to Zero
shell为前面产生的hello进程调用execve,映射虚拟内存,进入程序入口后载入物理内存。进入main函数执行代码,代码执行完毕后,shell父进程回收hello进程,内核删除相关数据结构。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:X86CPU;2.4GHZ;8GB RAM;512GB SSD
软件环境:Windows10 ;VMware Workstation pro 15.5;Ubuntu 20.04
开发与调试工具:visual studio2015;codeblocks;edb;
1.3 中间结果
预处理后的文本文件:hello.i
编译后的文本文件:hello.s
汇编后的二进制文件:hello.o
链接后的可执行(二进制)文件:hello
hello.o的ELF格式:helloo.elf
hello的ELF格式:hello.elf
hello.o的反汇编代码:obj_o.txt
hello的反汇编代码:obj_hello.txt
1.4 本章小结
本章对hello的一生进行了简要的概括,简单说明了P2P,020的含义即过程,声明了实验过程中涉及到的硬件、软件环境和开发工具。总体展示了实验过程中产生的中间结果文件。
第2章 预处理
2.1 预处理的概念与作用
概念:
预处理阶段,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。预处理指令将引用的所有库展开合并成为一个完整的文本文件,一般被用来使源代码在不同的执行环境中被方便地修改或编译。
作用:
1.将源文件中用#include 形式声明的文件复制到新的程序中。比如 hello.c 第 6-8 行中的#include<stdio.h> 、#include <unistd.h> 、#include <stdlib.h>命令告诉预处理器读取系统头文件 stdio.h unistd.h stdlib.h 的内容,并把它直接插入到程序文本中。
2.用实际值替换用#define定义的字符串
3.根据#if 后面的条件决定需要编译的代码
4.删除所有的注释
5.特殊符号,预编译程序可以识别一些特殊的符号, 预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
预处理的结果是产生另一个C程序,通常是以.i作为文件扩展名。
2.2在Ubuntu下预处理的命令
在控制台输入命令:gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
打开hello.i文件,可以看到整个程序的规模达到了3060行,文件开始部分是头文件stdio.h unistd.h stdlib.h的依次展开。
对stdio.h的展开进行举例说明,找到.i文件中对应部分
根据其提供的地址,可以看到cpp 到Ubuntu 中默认的环境变量下寻找 stdio.h,根据路径/usr/include/stdio.h打开文件。
可以看到其中的内容包含#define、#include开头的命令,cpp会继续对其进行递归展开。对于#ifdef #ifndef 条件编译的语句, cpp 会对条件值进行判断来决定是否执行包含其中的逻辑。同时,预编译程序可识别一些特殊的符号,预编译程序对在源程序中出现的这些串将用合适的值进行替换。
2.4 本章小结
本章主要介绍了预处理的概念及功能,包括对头文件进行展开、宏替换、条件编译、删除注释。利用Ubuntu下控制台对实验提供的hello.c文件进行了预处理,并对产生的hello.i文件以及预处理过程进行了分析。
第3章 编译
3.1 编译的概念与作用
概念:
编译是指将预处理后的高级语言文本代码转化为中间代码或汇编代码的过程。程序会通过词法分析、语法分析确认所有的指令都符合语法规则,再通过语义分析确定语句是否真正有意义,最终将其转化为汇编代码。
作用:
1.判断代码的词法(如关键字、标识符、运算符等)和语法(语言的语法规则)是否合法。
2.判断代码语句是否有意义,如两个指针做乘法运算就是没有实际意义的。
3.产生中间代码。有一些在编译期间就能确定的值,会被直接确定。
4.代码优化,如选择合适的寻址方式、优化乘除法、删除多余的指令等。
5.产生目标代码,此处指将高级语言文本代码和产生的中间代码转化为汇编代码。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1文件声明
所有以‘.'开头的行都是指导汇编器和链接器工作的伪指令。如.file 指示源文件名、.align指示对齐方式、.globl指示全局变量、 .type指定是对象类型或是函数类型等。
3.3.2数据与赋值
整形常量:
源文件中的if语句,其中包含整形常量4,它被保存在.text中
反应到.s文件中如下
整形变量:
初始化的全局变量存放在.data节中,程序中未体现。局部变量存放在寄存器或栈里,程序中的局部变量定义如下:
结合循环判断语句
可以找到.s文件中的对应语句如下:
i被保存在栈中%rbp-4的位置,初始化为0.
字符串:
字符串被存放在.rodata中
反应到.s文件如下:
3.3.3关系操作与控制转移
下图中的判断语句,判断传入参数argc是否等于4
汇编代码如下,若相等则跳转到.L2表示的代码区
for循环中也存在条件判断,i<8时进行循环内语句,否则结束循环
判断部分汇编代码如下:
若i小于等于7,则跳转到.L4标记的代码段,执行for循环内部语句。否则直接执行下一条语句。
3.3.4数组/指针/结构操作
主函数main的参数中包含一个指针数组char *argv[]
argv[0]指向的是输入程序的路径和名称, argv[1]和argv[2]分别表示输入的两个两个字符串参数。
由于指针类型占8个字节,结合原程序可以判断,argv[1]和argv[2]分别存放在%rax+16和%rax+8处。
3.3.5函数操作
函数操作一般分为如下几个步骤
1.参数传递,参数按%rdi,%rsi,%rdx,%rcx,%r8,%r9的顺序,优先保存在寄存器中,从第七个参数开始放在调用者栈结构中。
2.函数调用,利用call指令将返回地址压栈,并将rip的值指向所调用函数的地址,等函数执行完之后调用ret弹出原来的rip并且将栈帧结构恢复。
3.函数返回:被调用函数运行结束后,将结果(若有)保存到%eax中,并返回
下面对文件中包含的函数逐个进行分析。
main函数
参数传递:传入参数argc和argv[],分别存储在%rdi和%rsi中
函数调用:被系统启动函数调用
函数返回:将0赋值给%rax并返回
printf函数
参数传递:打印字符串常量时时传入字符串参数首地址,在规格化输出时传入argv[1]和argv[2]中的地址。
源代码:
汇编代码:
源代码:
汇编代码:
函数调用:打印常量由call puts@PLT调用,规格化输出中由call printf@PLT调用。
函数返回:printf函数会返回输出的长度,一般可以忽略。
exit函数
参数传递:传入参数1,保存在%edi中
函数调用:通过call exit@PLT函数进行调用
源代码:
汇编代码:
atoi函数
参数传递:传入argv[3]的值,保存在%rdi中
参数调用:通过call atoi@PLT调用
sleep函数
参数传递:传入参数atoi(argv[3])
函数调用:for循环下通过call sleep@PLT调用
源代码:
汇编代码:
getchar函数
函数调用:main函数中通过call getchar@PLT调用
源代码
汇编代码:
3.4 本章小结
本章主要介绍了编译的概念、过程和作用,在Ubuntu下展示了将.i文件编译为.s文件的过程。对编译后的.s文件进行了分析,在文件声明部分解释了以.开头的伪代码意义;数据与赋值部分说明了整形常量与变量、字符串转化的方式和转化前后对比;关系操作与控制转移中介绍了汇编语句如何实现条件判断和跳转;数组/指针/结构操作部分介绍了汇编代码中指针数组的表现形式;函数操作部分介绍了汇编代码是如何完成参数传递、函数调用和函数返回的。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器将汇编语言指令翻译为机器语言指令的过程叫汇编。
作用:将汇编语言指令翻译为机器语言指令,同时把这些指令打包成可重定位目标文件,将结果保存在.o文件中。
4.2 在Ubuntu下汇编的命令
命令:gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
命令:readelf -a hello.o > helloo.elf
4.3.1 ELF头
ELF头部包含了系统的字的大小和字节顺序、ELF头大小、目标文件的类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。
4.3.2节头目表
节头部表描述了各个节的类型,位置和所占空间大小等信息,利用该表可以定位文件中所有的节。
4.3.3重定位条目
当汇编器生成一个目标模块是,它并不知道数据和代码最终将放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。
偏移量表示需要被修改的引用的节偏移。符号名称标识被修改引用应该指向的符号。类型告知链接器如何修改新的引用。加数是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
4.3.4符号表
符号表保存了查找程序符号、为符号赋值、重定位符号所需的全部信息。
符号是表示每个ELF文件的一个重要部分,因为它保存了程序实现或使用的所有(全局)变量和函数。如果程序引用了一个自身代码未定义的符号,则称之为未定义的符号。此类引用必须在静态链接期间用其他目标模块或者库解决,或在加载期间通过动态链接。
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o >obj_o.s
将反汇编代码保存在obj_o.s中。
汇编代码如下:
不同之处主要包含一下几点:
1.操作数
hello.s中,操作数以十进制表示
反汇编得到的obj_o.s中中操作数以十六进制表示。
2.分支转移
hello.s的跳转语句以.L2、.L3等段名称做标记
反汇编代码中跳转指令跟着的是相对偏移。
3.函数调用
hello.s中,call指令直接跟着函数名称。
而反汇编得到代码中call指令之后跟着的是函数的相对偏移地址。
4.5 本章小结
本章大致介绍了汇编的概念、过程和作用。展示了如何在ubuntu下将hello.s汇编为hello.o文件,以及对hello.o进行反汇编。详细分析了ELF文件中,包括ELF头、节头目表、重定位条目和符号表的内容和作用。最后对hello.o反汇编后产生的文件与hello.s进行对比,总结了三个显著的不同点。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:
将各个模块(可重定位文件)的代码、符号解析、重定位信息等收集并正确的衔接起来的过程叫做链接。
作用:
链接过程主要包括了地址和空间分配、符号决议和重定向。链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译( separate com-pilation)成为可能。利用链接可以将一个大型的应用程序分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
链接会将若干个可重定位文件合并为可执行文件。
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.3 可执行目标文件hello的格式
命令:readelf -a hello > hello.elf
5.3.1ELF头
描述文件的总体格式。包含程序入口点地址、文件类型、端序等信息。
5.3.2节头
描述了各个节的大小、偏移量等信息。链接器链接时,会将各个文件的相同段合并,根据合并后段的大小以及偏移量重新设置各个符号的地址。根据节头的信息可以方便地定位到文件中各个节的位置。
5.3.3程序头
Type指明描述的内存段的类型,或者如何解析该程序头的信息。如DYNAMIC表示该段描述的是动态链接库,NOTE表示该段描述一个以’\n’结尾的字符串,这个字符串中包含附加信息等。
Offset指明该段内容的起始位置相对于文件开头的偏移量。
VirtAddr指明该段中内容的起始位置在进程地址空间中的虚拟地址。
PhysAddr指明该段中内容的其实位置在进程地址空间中的物理地址。对于目前大多数操作系统而言,这个字段大多数情况下不起作用。(由于无法预知物理地址)
5.4 hello的虚拟地址空间
用edb打开hello,可以在Data Dump中查看加载到虚拟地址中的hello程序。
对照ELF中的程序头表依次映射。其中PHDR 保存程序头表自身。INTERP 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器。LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。DYNAMIC 保存了由动态链接器使用的信息。NOTE 保存辅助信息。GNU_STACK:权限标志,用于标志栈是否是可执行。GNU_RELRO:指定在重定位结束之后哪些内存区域是需要设置只读。
5.5 链接的重定位过程分析
命令:objdump -d -r hello >obj_hello.s
对比hello反汇编得到的obj_hello.s与hello.o反汇编得到的obj_o.s,可以得到以下几点不同:
1.链接后增加了很多库函数,如exit、printf、sleep、getchar等函数。
2.hello相比hello.o增加了.init和plt节
3.链接后hello不再需要重定位条目,并且跳转和函数调用的地址都变成了虚拟内存地址。同理,hello中对地址的访问也是根据虚拟内存地址而不是hello.o中的相对偏移地址。
重定位过程分析:
当链接器完成符号解析后,就可以开始进行重定位了。重定位由两步组成:
1.重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。包括hello.o在内的所有可重定位目标文件中的.data节被全部合并成一个节,这个节成为输出的可执行目标文件hello中的.data节。然后,连接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,hello中每条指令和包括.rodata、sleepsecs等全局变量都有唯一的运行时内存地址了。
2.重定位节中的符号引用。链接器依赖于hello.o中的重定位条目,修改代码节和数据节中对每个符号的引用,使得它们指向正确运行时的地址。
5.6 hello的执行流程
程序地址 子程序名
401030 puts@plt
401040 printf@plt
401050 getchar@plt
401063 atoi@plt
401070 exit@plt
401080 sleep@plt
401125 main
404048 data_start
5.7 Hello的动态链接分析
在hello.elf中可以找到
在edb中查看,找到0x404000位置,下图为edb执行Init前的地址
执行Init后的地址:
##对于变量而言,可以利用代码段和数据段的相对位置不变的原则计算正确地址。在函数调用时,首先跳转到PLT执行.plt中操作,第一次访问跳转时GOT地址为下一条指令,将函数序号入栈,然后跳转到PLT[0],之后将重定位表地址入栈,访问动态链接器,在动态链接器中使用在栈里保存的函数序号和重定位表计算函数运行时的地址,重写GOT,返回调用函数.之后如果还有对该函数的访问,就不用执行第二次跳转,直接参看GOT信息.
5.8 本章小结
本章主要介绍了链接的概念、过程与作用,展示了ubuntu下如何链接,使多个可重定位文件合并为可执行文件的过程。比较了hello和hello.o的反汇编代码,分析了hello的ELF格式文件,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:
进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:解释命令,连接用户和操作系统以及内核。
处理流程:
1.shell打印一个命令行提示符,等待用户在stdin上输入命令行。
2.对输入字符串(以元字符,如空格分割)进行切分,获得参数
3.第一个参数若是一个内置的shell 命令名,则马上就会解释这个命令。若是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
4.shell能够接受键盘输入信号,并对这些信号做出相应处理
5.命令执行完成后,返回第一步
6.3 Hello的fork进程创建过程
输入命令执行hello后,父进程判断其不是内部指令,接着通过fork函数创建子进程。子进程与父进程近似,并得到一份与父进程用户级虚拟空间相同且独立的副本——包括数据段、代码、共享库、堆和用户栈。子进程进程还会获得与父进程任何打开文件描述符相同的副本。但是二者的PID不同,在父进程中,fork返回子进程的PID,子进程中,fork返回0。
6.4 Hello的execve过程
execve函数能在当前进程的上下文中加载并运行一个新程序。execve函数加载并运行可执行目标文件Hello,且带列表argv和环境变量列表envp。argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串,argv [0]是可执行目标文件的名字。envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name=value”的名字-值对。只有当出现错误时,例如找不到Hello时,execve才会返回到调用程序。
在execve加载了Hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数。该主函数的原型如下:
当 shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一个复制。子进程通过execve系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,此时,操作系统利用它的页面调度机制自动将页面从碰盘传送到内存。
6.5 Hello的进程执行
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常是用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令( privileged instruction),比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
hello初始时运行在用户态,在调用sleep函数后,进入内核态,该时间片停止,并将hello进程从运行队列中移出,加入到等待队列中。此时定时器开始计时,同时内核进行上下文切换将控制转换到新的进程。定时器计时结束后,发送中断信号,此时将hello重新加入到运行状态。
6.6 hello的异常与信号处理
正常运行状态截图如下:
异常的类型 来源 异步/同步 返回行为
中断 来源于I/O设备输入 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回
处理方式:
中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。
图-中断的处理方式
陷阱是有意的异常,是执行一条指令后的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。
图-陷阱的处理方式
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果故障处理程序能够修正这个错误,它就将控制返回给引起故障的指令,从而重新执行它,否则,处理程序返回到内核中的 abort 例程,abort 例程会终止引起故障的应用程序。
图-故障的处理方式
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如 DRAM 或者 SRAM 位被损坏时发生的奇偶错误。终止处理程序不会将控制返回给应用程序。
图-终止的处理方式
运行中按下CTRL+Z之后,将会发送一个SIGTSTP信号给shell。然后shell将信号转发给当前执行的前台进程组hello使其挂起。
此时输入ps,可以查看hello的进程PID为2745
输入jobs,查看在后台运行的命令
输入fg,将后台命令调至前台继续运行
再次输入Ctrl-z后,输入kill -9 2745,杀死hello进程。输入ps查看后台进程,可以看到hello进程结束了。
在hello程序运行时输入CTRL+C会导致内核发送一个SIGINT信号到前台进程组的每个进程。默认情况下,结果是终止前台作业。
6.7本章小结
本章概括了进程的概念和作用,简述了shell-bash的作用以及处理流程。介绍了hello的fork进程创建过程与execve过程。分析了hello进程的执行原理与流程。对异常进行了分类,并按类介绍了它们的处理方法。最后在ubuntu下实际运行hello,并通过键盘输入向其传递各种信号,实际展示了信号的处理机制和不同信号导致的结果。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序代码经过编译后出现在 汇编程序中地址.逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址,要经过寻址方式的计算或变换才得到内存储器中的实际有效地址即物理地址。一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是个索引号,后面3位包含一些硬件细节。
线性地址:是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
虚拟地址:逻辑地址也叫虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。
物理地址:是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么hello的线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么hello的线性地址就直接是物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
如7.1种介绍,一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,其中前13位是个索引号,后面3位包含一些硬件细节。
索引号是段描述符的索引,很多的段描述符组成段描述表。可以通过段标识符的前13位在段描述符表种找到一个具体的段描述符,这个描述符就描述了一个段。
当给定一个完整的逻辑地址【段选择符:段内偏移地址】
1.先看段选择符的T1是1还是0,知道当前要转换是全局段描述符表(GDT)中的段,还是局部段描述符表(LDT)中的段,再根据相应寄存器,得到其地址和大小。就可以得到一个数组。
2.拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,有了Base字段,即基地址就知道了。
3.Base+offset,就是要转换的线性地址
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页,页式管理把内存空间按页的大小划分成片或者页面,然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
当给定了线性地址后,首先以其最高k位(通过页目录表确定k)作为索引,查看页目录表的对应项,里面存放的是页表的物理地址。以中间若干位(通过页表确定)作为索引,在页表中找到物理页的物理起始地址。物理页基地址加上线性地址种剩余低位的偏移量,就能找到线性地址最终对应的物理内存单元。
0
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。
如果我们有一个32位的地址空间、4KB的页面和一个4字节的PTE,那么即使应用所引用的只是虚拟地址空间中很小的一部分,也总是需要一个4MB的页表驻留在内存中。对于地址空间为64位的系统来说,问题将变得更复杂。用来压缩页表的常用方法是使用层次结构的页表。
下图描述了使用k级页表层次结构的地址翻译。虚拟地址被划分成为k个VPN和1个VPO。每个 VPNi都是一个到第i级页表的索引,其中1≤i≤k。第j级页表中的每个PTE,1≤j≤k-1,都指向第j+1级的某个页表的基址。第k级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个 PTE。对于只有一级的页表结构,PPO和VPO是相同的。
7.5 三级Cache支持下的物理内存访问
获得了物理地址VA之后,先进行组索引,找到对应组后匹配标记位,如果匹配成功且块的valid标志位为1,则命中,根据数据偏移量CO取出数据返回。
如果不命中,向下一级缓存中查询数据(L2 Cache->L3 Cache->主存),查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU(Least frequently used)进行替换.也就是替换掉最不经常访问的一次数据,替换时要注意被替换的那一行是否曾被修改,如果是,要更新其在低一层中的副本。最后修改放置后的行的有效位为1。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时复制。只要没有进程试图写它自己的私有区域,它们就可以继续共享物理内存中对象的一个单独副本。然而,只要有一个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
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做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个人口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
缺页处理程序主要执行三步:
1)判断虚拟地址A是否在某个区域结构定义的区域内(合法)。缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。
2)第二步确认访问权限是不是正确的。如果这一页是只读页,但是却要做出写这个动作,那明显是不行的。如果做出了这类动作,那么处理程序就会触发一个保护异常,默认行为是结束这个进程
3)当内核知道这个缺页是由于对合法的虚拟地址进行合法的操作造成的后,它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到 MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
7.9动态存储分配管理
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。分配器有两种风格。显式分配器要求应用显式地释放任何已分配的块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块.隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集。
程序通过malloc函数来从堆中分配块,malloc函数返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。实际中,对齐依赖于编译代码在32位模式(gcc -m32)还是64位模式(默认的)中运行。在32位模式中,malloc返回的块的地址总是8的倍数。在64位模式中,该地址总是16的倍数。如果malloc遇到问题(例如,程序要求的内存块比可用的虚拟内存还要大),那么它就返回NULL,并设置errno。malloc不初始化它返回的内存。
为了在吞吐率和利用率之间把握好平衡,通常使用两种策略来进行动态内存分配:
1.带边界标签的隐式空闲链表分配器
带边界标记的隐式空闲链表的每个块由一个字的头部、有效载荷、可能的额外填充以及脚部组成。头部编码了块的大小,以及这个块是已分配的还是空闲的。脚部是头部的副本。
在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。分配器可以通过检查它的脚部,判断前面一个块的起始位置和状态,从而完成空闲块的合并。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配合最佳适配。分配完后可以分割空闲块减少内部碎片。同时分配器在面对释放一个已分配块时,可以合并空闲块,其中便利用隐式空闲链表的边界标记来进行合并。
2.显示空闲链表
显式空闲链表将空闲块组织为显式数据结构。例如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。
在显式空闲链表中,一种方法是用后进先出的顺序维护链表,将新释放的块放置在链表的开始处。使用后进先出的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章首先讨论了逻辑地址、线性地址、虚拟地址、物理地址的概念以及彼此间的联系,接着介绍了从逻辑地址到线性地址、从线性地址到物理地址的变换过程和原理。介绍了利用TLB和页表实现VA到PA的变换,以及利用三级Cache完成物理内存访问的过程。分析了hello在fork和execve时的内存映射。概括了缺页故障和缺页中断的处理流程。最后介绍了隐式空闲链表和显示空闲链表这两种动态存储分配管理的方法。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有的IO设备都被模型化为文件,而所有的输入输出都被当作对相应文件的读和写来执行。
设备管理:将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix IO接口:
打开文件:内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。
改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k
关闭文件:当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中。
Unix 函数:
1.open 函数:打开一个已存在的文件或者创建一个新文件
函数原型:int open(char *filename, int flags, mode_t mode);
参数:open 函数将filename 转换为一个文件描述符,并且返回描述符数字.返回的描述符总是在进程中当前没有打开的最小描述符.flags 参数指明了进程打算如何访问这个文件,·O_RDONLY:只读。o_WRONLY:只写。.O_RDWR:可读可写。mode 参数指定了新文件的访问权限位.
函数返回:若成功则为新文件描述符,若出错为-1.
2.close 函数:关闭一个打开的文件.
函数原型:int close(int fd);
参数:fd为文件描述符。
返回:若成功则为0, 若出错则为-1.
3.read()函数:从文件中读取数据
函数原型:ssize_t read(int fd, void *buf, size_t n);
返回值:返回值-1表示一个错误,而返回值0 表示EOF.否则,返回值表示的是实际传送的字节数量.
4.write()函数:向文件中写入数据
函数原型:ssize_t write(int fd, const void *buf, size_t n);
返回值:若成功则为写的字节数,若出错则为-1.
8.3 printf的实现分析
printf的函数体如下:
vsprintf接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,返回要打印的字符串长度,通过write函数将字符串数组buf中的i个内容写到终端。
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
syscall的功能是不断地打印字符,直到遇到’\0’为止。
8.4 getchar的实现分析
getchar的函数体如下:
getchar通过调用read函数来读取字符,第一个参数0表示从标准输入中读入,第二个参数为指向输入字符的指针,第三个参数为读入字符的个数。
getchar函数的返回值是用户输入的第一个字符的ascii码,如出错或遇到文件结尾则返回EOF。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章概括了linux中IO的设备管理方法,简要介绍了Unix IO接口以及四个函数,最后分析了printf函数和getchar函数的底层实现逻辑。
(第8章1分)
结论
最简单的hello.c程序,它的一生却无比辉煌。首先经过预处理,“变身”为hello.i,接着被编译为汇编语句文本文件hello.s,之后被汇编器转化为可重定位的机器语言文件hello.o。最后,和库函数们一起被链接为hello可执行文件。
在shell中输入./hello,由bash新建进程,fork一个子进程并由execve函数加载运行hello。hello调用printf函数和getchar函数,进一步利用UnixI/O中的write和read函数打印出字符串。最后运行到exit函数,hello被shell父进程回收,内核删除掉它的全部数据。hello的一生走到尽头。
这看似简单的流程,却贯穿了计算机系统课程的完整体系。每一个简单的功能实现,背后都是无数程序员们常年累月的创新、补充、优化。
很早就听说过csapp是每个程序员必读的圣书,可惜当时基础知识并不完善,读到第三章就难以继续,本学期的课程让我完整地领略了计算机系统的各种功能的底层逻辑实现,虽然只是入门,但依然感到满足。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
预处理后的文件:hello.i
编译后的文件:hello.s
汇编后的文件:hello.o
可执行文件:hello
hello.o的ELF格式:helloo.elf
hello.o的反汇编代码obj_o.s
hello的ELF格式:hello.elf
hello的反汇编代码obj_hello.s
(附件0分,缺失 -1分)