计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L020701
班 级 2003005
学 生 董琦
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
本论文借助hello这个简单的程序实例,分析了它的整个P2P、020生命周期。详细介绍分析了其经过预处理、编译、汇编、链接生成可执行程序的过程。然后结合它的加载、运行整个过程,分析了一个程序运行时需要完成的进程创建与管理、中断和信号处理、虚拟内存分配、动态链接、IO管理等内容。
通过完成大作业的整个过程,我对计算机系统有了更加具体和深入的认识,将所学知识串联到了一起,深切体会到了计算机系统这门课在我们知识框架中的重要性。
关键词:计算机系统;计算机体系结构;程序生命周期;计算机组成原理
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 32 -
6.3 Hello的fork进程创建过程... - 33 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 39 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 40 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 41 -
7.5 三级Cache支持下的物理内存访问... - 42 -
7.6 hello进程fork时的内存映射... - 43 -
7.7 hello进程execve时的内存映射... - 43 -
第1章 概述
1.1 Hello简介
Hello的一生经过了P2P、020两个过程。
P2P是From Program to Process,指Hello从一个c的文本文件(hello.c)到系统中的一个进程(Process)的过程。在Linux系统下,这个过程经过如下几个阶段:①预处理阶段。hello.c首先经过预处理器(cpp)的处理,将用(#)宏定义的文本进行替换,包括include、define等内容,这样处理后得到hello.i文本文件。②编译阶段。hello.i之后再经过编译器(ccl)处理,翻译成文本文件hello.s,它包含一个汇编语言程序。此时,hello已经经过了从一个c语言程序到汇编语言程序的转变。③汇编阶段。接下来,汇编器(as)将hello.s翻译成机器语言指令,将这些指令打包成“可重定位目标程序”的格式,并将结果保存在目标文件hello.o中。此时的hello.o文件是一个二进制文件,在文本编辑器中打开后将看到一堆乱码。④链接阶段。Hello程序需要与其他文件合并(调用的程序文件)才可以执行。链接器(ld)就负责处理这种合并。结果得到hello可执行文件,它可以被加载到内存中,由系统执行。⑤系统加载并执行hello文件,从而得到hello进程。
020是From zero to zero,指Hello从零产生,又在执行后重归于零的过程。hello从零开始编辑,生成hello.c源代码后,然后经过GCC翻译成目标文件hello后,被操作系统(OS)在调用exevce加载到内存后,经过分配虚拟地址后正式在CPU上作为进程执行,期间需进行IO管理和信号处理等才顺利执行完毕,在执行完毕后,由父进程回收后,不在操作系统中留下痕迹,重归于零。
1.2 环境与工具
Windows:
版本 Windows 11 家庭中文版
版本 21H2
安装日期 2022/2/4
操作系统版本 22000.556
体验 Windows 功能体验包 1000.22000.556.0
Ubuntu:
版本 Ubuntu 20.04.3 LTS
类型 64位
调试工具:Visual Studio Code;
gedit,gcc,notepad++,readelf, objdump, hexedit, edb
1.3 中间结果
表格 1 中间结果
文件名 | 功能 |
hello.i | 预处理后得到的C语言文本文件 |
hello.s | 编译后得到的汇编语言文本文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
1.4 本章小结
本章简要介绍了Hello的P2P、020的具体含义,然后列出了编写此论文时电脑的软硬件环境和生成的所有中间文件。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理是指预处理器(cpp)根据以字符#开头的命令,修改原始的C程序这一过程。
2.1.2 预处理的作用
预处理的作用是根据我们的预定义对源代码进行文本替换或条件编译等。比如#include 指令即是导入头文件,将头文件的代码文本直接插入到我们的代码文本中;#define 则是宏定义,并且可以带参数,预处理后讲代码中所使用的宏定义符号全部替换为后面的字符串;#if、#ifdef等是条件编译,可以进行多种编译选择;此外还有#error、#pragma等选项。此外,预处理后还会删除代码中的所有注释(当然可以选择保留)。
简单来说,预处理就是一个文本替换的过程,根据语法规则将我们所写的代码完全转化为将要编译的C语言代码。
2.2在Ubuntu下预处理的命令
cpp hello.c > hello.i
图 1 预处理指令
图 2 预处理结果
2.3 Hello的预处理结果解析
打开hello.i文件,发现原先的23行代码已经扩展到了3060行。而原先的main函数对应位置如下:
图 3 main函数对应代码
而前面的3000多行则是这三个头文件中的代码:
图 4 引入的头文件
#include <filename.h>是导入系统标志头文件的含义,cpp会去系统默认库路径下寻找我们引入的stdio.h、unistd.h、stdlib.h这三个头文件(注意,不会在当前目录中寻找),然后进行原地替换:删除对应的#include语句,然后将对应源代码放入到替换的位置。若引入的代码仍有预处理标识符,那么会进行同样的操作,直至预处理全部完成。
此外,可以看到原来代码中的注释在hello.i中已经全部被删除。
图 5 原来的注释
2.4 本章小结
本章介绍了编译C语言程序的第一步——预处理,并且结合了hello.c文件预处理后得到的hello.i文件结果,进行了解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
这里的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序这一过程。即完成了从C语言程序到汇编语言的转变。在这个过程中ccl会先进行词法分析、语法分析的过程,若无错误则编译为汇编文件,若有错误则抛出错误(也就是写代码时候出现语法错误QAQ)。
3.1.2 编译的作用
编译器的作用首先是检查语法错误,保证C代码的正确性,然后将之转变为汇编代码。汇编代码是机器码的人类可懂表示。这一步是将我们更偏向人类语言的代码转化为机器所理解并执行的机器码的桥梁。
3.2 在Ubuntu下编译的命令
gcc -S hello.s -o hello.i
图 6 编译指令
3.3 Hello的编译结果解析
3.3.1 hello.s文件总览
下面是生成的hello.s文件:
图 7 hello.s文件
可以看到,hello.s分为如下结构:
表格 2 hello.s中符号
内容 | 含义 |
.file | 源文件 |
.text | 代码段 |
.global | 全局变量 |
.data | 存放已经初始化的全局和静态C 变量 |
.rodata | 存放只读变量 |
.align | 对齐方式 |
.type | 表示是函数类型/对象类型 |
.size | 表示大小 |
.long .string | 表示是long类型/string类型 |
3.3.2 数据
3.3.2.1 常量、全局变量和局部变量
1.字符串常量
代码中有俩个字符串常量,由于是用于printf输出的,故为只读,存放在.rodata节里:
图 8 .rodata节
.section后的.rodata表示这两个字符串存放在.rodata节中,是只读变量。.align表示以八字节对齐,若不足八字节则会用0补齐。
然后再看字符串内容。Linux使用utf-8编码,大部分汉字使用三字节来编码,于是在.LC0中会出现三个字一组的字节符,这对应了代码中的汉字。
当使用时,会将该字符串的地址放入寄存器中,然后按寄存器寻址去找到字符串,遇到\0表示字符串终止。从这里的汇编代码可以看出,是通过PC相对寻址来找到字符串常量的地址的。
图 9 通过标记相对寻址
这里的标记意思是相对.rodata节地址的偏移量。
2.立即数(整型常量)
代码中多次出现了立即数:
图 10 立即数
这些立即数会直接插入到汇编代码中,无需寻址。比如:
图 11 立即数直接写入汇编代码
便是直接将8这个立即数写在汇编代码中,然后赋给rax寄存器。
这种例子有很多,就不一一叙述了。
3.局部变量
这个函数里声明了一个int类型局部变量 i。
局部变量在寄存器或栈中储存。如下图:
图 12 栈中保存局部变量
这是i的初始化和更改数值的语句,对应i=0,i++。可以看到,i被分配在栈中,大小是四个字节,刚好在栈最底部。
4.全局变量
这个函数里没有使用全局变量。已初始化的全局变量存放在.data节中,在文件中占据空间,当加载程序时也被加载到内存中。而未初始化的全局变量则在.bss节中,在文件中不占据空间,当使用时在内存中分配这些变量,初始值为0。
静态变量同全局变量。
3.3.2.2 数据类型
此函数中一共涉及三种数据类型:整数、字符串、数组。
- 整数:i,argc。Int类型数据使用四个字节存放,使用补码编码方式。
- 字符串:即俩个字符串变量,按照utf-8编码方式编码,不同的符号对应的字节数不同(这里中文三个字节,英文字母和常见标点一个字节)。
- 数组:即argv[]命令行参数数组,存放在栈中,使用地址和偏移量来进行寄存器寻址,找到对应的参数。
3.3.3 赋值
代码中只有“i=0”一处是赋值操作。
对局部变量的赋值操作是通过mov指令:
根据赋值对象的字节大小,mov有不同的后缀.
表格 3 mov指令的后缀
后缀 | b | w | l | q |
大小(字节) | 1 | 2 | 4 | 8 |
3.3.4 类型转换
Hello.c中并没有进行隐式或显式类型转换,但是使用了atoi函数,该函数作用是把一个数字字符串转换为对应的整数。
通过调用函数传参实现类型转换:
图 13 调用atoi
3.3.5 算数操作
汇编语言中,算数操作的指令包括:
表格 4 算数操作指令
指令 | 效果 |
leaq s,d | d=&s |
inc d | d++ |
dec d | d-- |
neg d | d=-d |
add s,d | d=d+s |
sub s,d | d=d-s |
imulq s | r[%rdx]:r[%rax]=s*r[%rax](有符号) |
mulq s | r[%rdx]:r[%rax]=s*r[%rax](无符号) |
idivq s | r[%rdx]=r[%rdx]:r[%rax] mod s(有符号) r[%rax]=r[%rdx]:r[%rax] div s |
divq s | r[%rdx]=r[%rdx]:r[%rax] mod s(无符号) r[%rax]=r[%rdx]:r[%rax] div s |
而hello.s中设计的算数操作有:
1. subq $32, %rsp 开辟main函数栈帧(经过分析,其实不用这么多空间)
2. addq $16, %rax 数组寻址
3. addq $8, %rax 数组寻址
4. leaq .LC1(%rip), %rdi 取地址操作
5. addl $1, -4(%rbp) i++
3.3.6 关系操作
Hello.c中有俩处关系操作。
- argc!=4
对应的汇编代码是:cmpl $4, -20(%rbp)
此处-20(%rbp)即存放argc的地址。
根据关系式的结果,会设置条件码的值,后续根据条件码的值来进行控制跳转。
- i<8
对应汇编代码是:cmpl $7, -4(%rbp)
图 14 条件转移
当i<=7时,进行跳转。
3.3.7 数组操作
Hello.c中有一命令行参数数组argv[]。在汇编代码中,该数组地址被存放在栈中:-32(%rbp)
当访问数组中元素时,通过(数组基址+偏移量)寻址:
图 15 数组寻址
3.3.8 控制转移
Hello.c中涉及俩种控制转移。
- if( )结构:
if(argc!=4){
printf("用法: Hello 学号 姓名 秒数!\n");
exit(1);
}
对应的汇编代码:
图 16 if条件控制
通过argc与4做比较来设置条件码,然后je指令读取条件码,若相等则前往执行.L2节,若不相等则继续顺序执行。
- for( : : )结构:
for(i=0;i<8;i++){
printf("Hello %s %s\n",argv[1],argv[2]);
sleep(atoi(argv[3]));
}
对应汇编代码:
图 17 for条件控制
通过比较i与7的大小来设置条件码,然后根据结果来决定是否跳转。当i=8时,结束循环,执行之后的指令。
3.3.9 函数操作
Hello.c中共调用了五次函数。
调用函数时进行的操作如下:
- 传递控制:
进行函数 Q 的时候,PC必须设置为 Q 的代码的起始地址,然后在返回时,要把PC设置为调用函数中调用 Q 后面那条指令的地址。
- 传递数据:
调用函数必须能够向 Q 提供一个或多个参数,Q 可以向 P 中返回一个值。
- 分配和释放内存:
在开始时,Q 可能需要为局部变量在栈中分配空间,而在返回前,又必须释放这些空间。
在汇编代码中的调用函数举例:
- Printf(第一次)
图 18 调用printf
Printf的参数数量可变,在这里只需要用到一个参数,即一个字符串常量,于是将其地址放入%rdi中,向被调用函数传递,然后通过call指令来调用puts函数。
- Atoi
图 19 调用atoi
atoi函数接受一个字符串,返回其对应的整数。在汇编代码中,先将要转化的字符串指针放入%rdi中,然后call这个函数,得到的返回值存放在%eax中,然后通过mov指令传送到%edi中作为sleep函数的参数。
其他的函数调用大同小异,这里便不一一赘述。
3.4 本章小结
说明了汇编的概念和作用,然后结合hello.s文件,说明了C语言的数据和操作是如何在汇编语言中表示和实现的。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是指汇编器(assembler)将以.s结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标文件格式,最终结果保存在.o 目标文件中的过程。
4.1.2 汇编的作用
将汇编语言翻译为机器可以读懂的机器语言,并将相关函数或变量的地址以可重定位目标文件格式保存在.o文件中。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
图 20 汇编指令
4.3 可重定位目标elf格式
生成elf文件在unbuntu下命令:readelf -a hello.o > hello.elf
图 21 生成elf文件指令
ELF文件格式如下:
图 22 elf文件格式
其中.text,.rodata,.data,.bbs会在程序运行时加载到内存中;.rel.data,rel.text会在链接时处理,将重定位条目替换,然后删除这些节;其余的节都是用于描述该文件的信息,不会在执行时加载到内存中。
- ELF头:
图 23 elf头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包含ELF头的大小、目标文件的类型(如可重定位的、可执行的和可共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中的每一个节都有一个固定大小的条目。
比如我们这里就可以看到我们的ELF中数据是2补码的小端法放置,是一个可重定位文件,机器类型为x86-64等。
- 节头信息:
图 24 section headers
这个表描述了每一个节的信息,包括类型、大小、偏移值、对齐值等。比如.rela.text和.rela.eh_frame是RELA类型,表示需要在链接时重定位;.data、.rodata和.text是PROGBITS类型,是可执行文件中的数据和指令;.symtab是SYMTAB类型,是符号表。
- 重定位节:
图 25 rela.text
重定位节中记录了需要在链接时重定位的条目的信息,每一条包含的内容有:
- offset:相对.text节的地址偏移。
- info:包括symbol和type两部分。其中symbol占前半部分,代表重定位到的目标在.symtab中的偏移量;type占后半部分,代表重定位的类型。type告知链接器如何修改新的引用。PC32是相对程序计数器的偏移量来在静态链接时修改引用;PLT是修改过程链接表(PLT),在运行时修改引用。
- addend:一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
下面是重定位条目的定义:
图 26 重定位条目定义
- 符号表:
图 27 符号表
每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
●由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量。
●由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态C函数和全局变量。
●只被模块m定义和引用的局部符号。它们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
在这里,可以看到hello.o中共有17个符号,其中第1个是hello自己,为file类型;2~9为链接器内部使用的符号;12~17为引用的函数,但是由于尚未链接,故类型是NOTYPE。
图 28 elf符号结构体定义
name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字。value 是符号的地址。对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。size 是目标的大小(以字节为单位)。type通常要么是数据,要么是函数。符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目。所以这些目标的类型也有所不同。binding字段表示符号是本地的
还是全局的。
每个符号都被分配到目标文件的某个节,由section字段表示,该字段也是一个到节头部表的索引。有三个特殊的伪节(pseudosection),它们在节头部表中是没有条目的:ABS代表不该被重定位的符号; UNDEF代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号;COMMON表示还未被分配位置的未初始化的数据目标。对于COMMON符号,value 字段给出对齐要求,而size给出最小的大小。注意,只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的。
4.4 Hello.o的结果解析。
使用指令:objdump -d -r hello.o > hello.asm
图 29 生成反汇编文件指令
结果如下:
图 30 生成结果
该文件是hello.o的.text反汇编结果。
可以看到,汇编语言编译成机器语言后,所对应的形式是二进制的数据。汇编语言的不同指令,在机器语言中有不同的二进制编码。我们的CPU可以根据这些机器语言来执行对应的操作。具体编码规则这里不做赘述。
下面是hello.s与hello.asm的差异:
- 开辟栈帧:
图 31 开辟main函数栈帧
这里发现在.s中对main开辟栈空间为32字节,而.asm中是20字节,而实际用到的空间也是20字节。具体原因超出了我的知识范围了,我怀疑是那几句.cfi指令的原因。
- 地址表示:
图 32 字符串常量的引用对比
图 33 函数调用对比
在hello.s中,地址转移控制或者对全局变量的调用都是通过.LCX、.LX或函数名这种符号来确定跳转或使用的位置的,其实并没有分配具体的地址。而在.asm中可以看到,所使用的字符串常量和要跳转的地址已经给分配好了具体地址,而一些函数调用由于尚未链接,故先用0来做地址填充。
4.5 本章小结
本章介绍了汇编的概念与作用,然后在Ubuntu下通过实际操作将hello.s文件翻译为hello.o文件。并生成hello.o的ELF格式文件hello.elf,研究了ELF格式文件的具体结构。通过比较hello.o的反汇编代码(保存在hello.asm中)与hello.s中代码,了解了汇编语言与机器语言的异同之处。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时、加载时、运行时,我们这里的链接只指编译时的链接。
5.1.2 链接的作用
链接的作用是将多个可重定位文件链接成一个可加载到内存中执行的文件,是生成可执行程序的最后一步。链接使得分离编译成为可能。
注意:这儿的链接是指从 hello.o 到hello生成过程。
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
图 34 链接指令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5.3 可执行目标文件hello的格式
图 35 生成elf文件
指令如图,将得到的elf文件命名为hello2.elf,同先前得到的elf进行区分。
下面我们结合hello.elf的内容,对生成的文件进行具体分析:
- ELF头:
图 36 elf header
可以看到,文件的Type已经发生变化:先前的Type是REL(可重定位文件),而现在的是EXEC(可执行文件);然后文件也获得了入口地址:0x4010f0,也就是当程序运行时要执行的第一条指令的地址;文件还拥有了Program header条目,共12个;section header条目也从14增多到27个。
图 37 ELF headers 对比
- Section headers
图 38 section headers 对比(1)
图 39 section headers 对比(2)
与hello.asm相比,这里的section条目数量更多,并且为每一节分配了地址,展现了链接后的变化。
- Program headers
图 40 Program headers
程序头部表描述了可执行文件的连续的片被映射到连续的内存段这一映射关系。可以看到每一节都有对应的地址、内存大小、对齐等。
Type为LOAD的节会在执行时被加载到内存中,DYNAMIC是执行时动态链接的表,对应.dynamic节。
- Section to Segment mapping
图 41 segment sections
- 符号表:
图 42 符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
- 其他
除列出之外,还有很多部分没有列出,但是其已经超出了所学范围,故暂不深究。
5.4 hello的虚拟地址空间
Hello的虚拟地址空间从0x400000开始:
图 43 部分虚拟地址
此部分对应的section为:
图 44 对应的section
例如,对于.interp节,节头部表中给出了它的偏移量为0x2e0,大小为0x1c字节。因此它的虚拟地址空间就从0x4002e0开始,在edb中查看该虚拟内存地址,可以看出,.interp节确实在这个位置。
我们运行的代码段在0x401000段:
图 45 0x401000段
对应的section:
图 46 45图对应的section
其余部分同理,不一一分析了。
图 47 标记为LOAD的segment
可以看出,hello加载进内存的每一节都分配了虚拟内存,而且各节间并不是连续排列的,中间会有0填充,以保证某些节是关于页大小(0x1000)对齐的,即4KB,其中原因是不同节的权限不同:代码段是可执行的,有E标签;.dynamic段是可写的,有W标签。将访问权限不同的节分在不同的页更便于权限管理。
5.5 链接的重定位过程分析
图 48 链接指令
为了便于对比分析,我把输出结果重定向到了hello2.asm文件中。
对比结果如下:
- 增加了init、start等其他函数:
图 49 init函数
图 50 start函数
- 指令地址发生了变化:
在未链接的hello.o中,指令的起始地址是0,而在链接后的hello中,指令的起始地址是0x401000。可见链接后为代码和数据分配好了虚拟地址。
图 51 指令对应的虚拟地址
- 增加了PLT表,然后对标准库中的函数引用地址发生了变化:
图 52 .plt表
图 53 .plt.sec
可以看到,每一个库函数调用在.plt和.plt.sec中都有一个对应的条目,这里的结构同书上的略有不同。然后每一个对函数的调用,地址都指向了对应的plt条目。
图 54 函数地址未确定
图 55 函数地址已确定
上为.o文件中内容,下为可执行文件中内容。
- 对字符串常量的引用发生变化:
图 56 未确定字符串常量地址
图 57 已确定字符串常量地址
可以看到,未连接的文件中对全局变量的引用是标记对应节的偏移量,然后地址用零填充,而链接后的可执行文件则是直接引用其对应的虚拟地址,说明在链接后为全局变量分配了对应的虚拟地址。
总结:从以上分析可以看出,链接这一过程便是将多个文件的数据和指令整合到一起,为其分配虚拟地址。然后对所有符号进行解析,将之前尚未确定位置的符号与其他文件中的符号关联起来,从而在指令中将正确的地址填入其中,进行重定位,这是静态链接的内容。而动态链接是通过PLT和GOT表的配合,在运行时才确定调用函数或全局变量的地址。
5.6 hello的执行流程
表格 7 程序名称与程序地址
表格 7 程序名称与程序地址
| |||||||||||||||||||||||||||||||||||||||||||
及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
5.7 Hello的动态链接分析
链接的全部内容不局限于生成可执行文件时的静态链接,还包括加载、运行程序时的动态链接。
5.7.1 加载时
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程就是动态链接,是由一个叫做动态链接器的程序来执行的。共享库也称为共享目标,在linux系统中通常使用.so后缀来表示。微软的windows系统中称为DLL,以.dll形式的后缀文件形式存在。
经过静态链接的hello程序是部分链接的,然后当加载hello时,需要动态完成链接过程。当加载器加载和运行可执行文件hello时,它注意到hello包含一个.interp节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标(比如我们下图中的ld-linux-x86-64.so.2)。加载器加载和运行这个动态链接器,然后,动态链接器执行以下重定位完成链接任务:
- 重定位libc.so的文本和数据到某个内存段。
- 重定位hello中所有对由libc.so定义的符号引用。
最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。
图 58 interp节
5.7.2 运行时
Hello可以在运行时要求动态链接器加载和链接某个共享库,而无需在编译时将那些库链接到应用中。
运行时的链接是通过GOT和PLT这俩个表的配合来实现的。我们以hello来说明这个过程。
.got与.plt节保存着全局偏移量表GOT,其内容从地址0x601000开始。通过edb查看,在dl_init调用前,其内容如下:
图 59 调用前的情况
在调用后,其内容变为:
图 60 调用后的情况
比较可以得知,0x601008~0x601017之间的内容,对应着全局偏移量表GOT[1]和GOT[2]的内容发生了变化。GOT[1]保存的是指向已经加载的共享库的链表地址。GOT[2]是动态链接器在ld-linux.so模块中的入口。这样,接下来执行程序的过程中,在调用printf、exit等外部函数时,就可以使用过程链接表PLT和全局偏移量表GOT进行动态链接。
5.8 本章小结
本章中介绍了链接的概念与作用,并结合链接后生成的hello可执行文件的ELF格式文本hello2.elf,分析了静态链接对可重定位文件所做的改变。然后,根据反汇编文件hello2.asm与hello.asm的比较,加深了对重定位的理解。之后分析了hello的执行流程,对hello加载和执行时发生的动态链接过程进行了分析。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
6.1.2进程的作用
给应用程序提供两个关键抽象:
- 一个独立的逻辑控制流,提供一个假象,好像程序独占地使用处理器
- 一个私有地址空间,提供一个假象,好像程序独占地使用内存系统
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯--的对象。这些假象都是通过进程的概念提供给我们的。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个交互型的应用级程序,它代表用户运行其他程序。Shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
Shell利用fork和execve运行程序。Shell解析用户输入的命令行后,若指令是要求运行一个程序,则通过fork开辟一个新的线程,然后通过execve函数来运行这个程序。
并且,shell也负责回收所有的子进程。若命令要求在前台运行程序,那么shell程序显式地调用waitpid函数来等待子进程停止;若命令要求在后台运行程序(即最后一个参数为&),那么shell程序通过SIGCHLD信号处理程序来回收终止的子进程,信号处理程序中依然使用waitpid这个函数。由于信号处理程序同主程序是并发执行的,所以shell可以继续执行其他作业,而不需要等待后台进程停止。所以,一个shell程序可以有一个前台作业和多个后台作业。
6.3 Hello的fork进程创建过程
我们在shell中键入 ./hello 120L020701 董琦 5 ,运行hello程序。
Shell进程会处理我们输入的命令行,对之进行分割、识别。shell识别出我们键入的命令是执行一个程序后,调用fork函数开辟一个新的子进程。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
fork函数是有趣的(也常常令人迷惑),因为它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork返回 子进程的PID。在子进程中,fork返回0。因为子进程的PID总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
6.4 Hello的execve过程
Execve函数在当前进程的上下文中加载并运行一个程序。
Shell在调用函数fork创建新的子进程之后,子进程会调用execve函数,调用驻留在储存器中称为加载器的操作系统代码来运行hello。加载器在当前进程的上下文中加载并运行hello。
加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。加载器删除子进程现有的虛拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虛拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。这个将程序复制到内存并运行的过程叫做加载。在加载过程中,除了一些头部信息,没有任何从磁盘到内存的复制,知道CPU引用一个被映射的虚拟页时才会进行复制。
当加载器运行时,它创建hello的新的内存映像。在程序头部表的引导下,加载器将可执行文件的片(chunk)复制到代码段和数据段。接下来,加载器跳转到程序的.人口点,也就是_ start 函数的地址。这个函数是在系统目标文件ctrl.o中定义的,对所有的C程序都是一样的。_ start 函数调用系统启动函数__ libe_ start. main,该函数定义在libc.so中。它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。
6.5 Hello的进程执行
Hello进程执行的时候,其实并不是在时间上连续的。一个进程执行的整个周期,可以被划分为多个事件片,在每个事件片中,CPU加载了这个进程的上下文,给它一个独占处理器的假象。而实际上,CPU可以同时执行多个进程,这些进程也被划分为多个事件片,单个CPU通过交替执行不同进程的事件片来并行地执行多个进程。
而在不同进程当中,CPU所加载的上下文信息是不同的,因此在不同的进程切换时,CPU需要加载不同的上下文。操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。上下文切换机制是建立在那些较低层异常机制之上的。
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。在hello里,get函数需要等待用户在标准输入中输入字符,在等待的过程中,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从标准输入到达。另一个示例是sleep系统调用,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
中断也可能引发上下文切换。比如,所有的系统都有某种产生周期性定时器中断的机制,通常为每1毫秒或每10毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
图 61 上下文切换过程
6.6 hello的异常与信号处理
- 正常执行hello:
循环8次,每次打印一句话,然后挂起4秒。程序结束后,向shell进程发送信号,由shell回收。
图 62 正常执行
- 在执行时乱按键盘:
运行时看起来不太想搭理我,运行结束后开始清算,看来是将我输入的指令做了缓存。
图 63 乱按键盘
- 发送SIGINT信号:
按下Ctrl+C,
程序会提前终止。
图 64 Ctrl+C
- 发送SIGTSTP信号:
按下Ctrl+Z,
Hello程序会停止。
图 65 Ctrl+Z
然后输入jobs指令,可以看到我们停止的进程。
图 66 jobs
然后输出ps指令,可以看到进程的pid。
图 67 ps
然后我们通过kill指令发送SIGKILL信号,可以看到进程被杀死:
图 68 kill
或者输入fg指令,进程会继续执行,完成剩下的输出:
图 69 fg
- 发送SIGQUIT信号:
按下Ctrl+ \。SIGQUIT和SIGINT类似, 但由QUIT字符( 通常是Ctrl-\ )来控制, 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信号。
图 70 Ctrl+\
- Pstree指令:
图 71 ptree
列出了当前系统下进程的树型结构图。我们的hello进程路径是:
System--- gnome-terminal--- bash--- hello;
System--- gnome-terminal---bash--- edb---hello。
6.7本章小结
本章叙述了进程的概念和作用,然后讲解了开辟一个进程的过程,结合hello文件叙述了fork和execve函数的功能和作用。之后通过实际操作演示了进程的运行和接受并处理信号的方式。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
- 逻辑地址
逻辑地址是指由程序产生的与段相关的偏移地址部分,逻辑地址由选择符和偏移量两部分组成。具体而言,其为hello.asm中的相对偏移地址,即L0、L1、LC0、LC1这种的标记。
- 线性地址
逻辑地址经过段机制转化后为线性地址,其为处理器可寻址空间的地址,用于描述程序分页信息的地址。具体以hello而言,线性地址标志着 hello 应在内存上哪些具体数据块上运行。
- 虚拟地址
根据CSAPP教材,虚拟地址即为上述线性地址。
- 物理地址
通过MMU将虚拟地址翻译为物理地址,CPU通过地址总线的寻址,找到真实的物理内存对应地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器从逻辑地址到线性地址的变换通过段式管理的方式实现。每个程序在系统中都保存着一个段表,段表保存着该程序各段装入主存的状况信息,包括段号或段名、段起点、装入位、段的长度、主存占用区域表、主存可用区域表等,从而方便进行段式管理。
在段寄存器中,存放着段选择符,可以通过段选择符来得到对应段首地址。段选择符的结构如下:
图 72 段选择符的情况
其包含三部分:索引,TI,RPL
索引:用来确定当前使用的段描述符在描述符表中的位置;
TI:根据TI的值判断选择全局描述符表(TI=0,GDT)或选择局部描述符表(TI=1,LDT);
RPL:判断重要等级。RPL=00,为第0级,位于最高级的内核,RPL=11,为第3级,位于最低级的用户状态;
通过一个索引,可以定位到段描述符,进而通过段描述符得到段基址。段基址与偏移量结合就得到了线性地址,虚拟地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(VA)到物理地址(PA)之间的转换通过对虚拟地址内存空间进行分页的分页机制完成。
通过7.2节中的段式管理过程,可以得到了线性地址/虚拟地址,记为VA。虚拟地址可被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),根据计算机系统的特性可以确定VPN与VPO的具体位数,由于虚拟内存与物理内存的页大小相同,因此VPO与PPO(物理页偏移量)一致。而PPN(物理页号)则需通过访问页表中的页表条目(PTE)获取,如下图所示。
图 73 Hello的线性地址到物理地址的变换-页式管理
若PTE的有效位为1,则发生页命中,可以直接获取到物理页号PPN,PPN与PPO共同组成物理地址。
若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中,产生缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面。再返回到原来的进程,再次调用导致缺页的指令。此时发生页命中,获取到PPN,与PPO共同组成物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
针对Intel Core i7 CPU研究VA到PA的变换。
Intel Core i7 CPU的基本参数如下:
- 虚拟地址空间48位(n=48)
- 物理地址空间52位(m=52)
- TLB四路十六组相连
- L1,L2,L3块大小为64字节
- L1,L2八路组相连
- L3十六路组相连
- 页表大小4KB(P=4x1024=2^12),四级页表,页表条目(PTE)大小8字节
由上述信息可以得知,VPO与PPO有p=12位,故VPN为36位,PPN为40位。单个页表大小4KB,PTE大小8字节,则单个页表有512个页表条目,需要9位二进制进行索引,而四级页表则需要36位二进制进行索引,对应着36位的VPN。TLB有16组,故TLBI有t=4位,TLBT有36-4=32位。
图 74 TLB与四级页表支持下的VA到PA的变换
如图所示, CPU产生虚拟地址VA,并将其传送至MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)在TLB中进行匹配,若命中,则得到PPN(40bit)与VPO(12bit)组合成物理地址PA(52bit)。若TLB没有命中,则MMU向页表中查询,由CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,则执行下一步确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并向TLB中添加条目。多级页表的工作原理展示如下:
图 75 多级页表结构
若查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
7.5 三级Cache支持下的物理内存访问
因为三级Cache的工作原理基本相同,所以在这里以L1 Cache为例,介绍三级Cache支持下的物理内存访问。
L1 Cache的基本参数如下:
- 8路64组相连
- 块大小64字节
由L1 Cache的基本参数,可以分析知:
块大小64字节→需要6位二进制索引→块偏移6位
共64组→需要6位二进制索引→组索引6位
余下标记位→需要PPN+PPO-6-6=40位
故L1 Cache可被划分如下(从左到右):
CT(40bit)CI(6bit)CO(6bit)
在7.4中我们已经由虚拟地址VA转换得到了物理地址PA,首先使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO取出相应的数据后返回。
若没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中请求数据(请求顺序为L2 Cache→L3 Cache→主存,若仍不命中才继续向下一级请求)。查询到数据之后,需要对数据进行读入,一种简单的放置策略如下:若映射到的组内有空闲块,则直接放置在空闲块中,若当前组内没有空闲块,则产生冲突(evict),采用LFU策略进行替换。
7.6 hello进程fork时的内存映射
当fork函数被当前进程hello调用时,内核为新进程hello创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的hello创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当着两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域
删除当前进程hello虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域
为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。其中,代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域
若hello程序与共享对象或目标(如标准C库libc.so)链接,则将这些对象动态链接到hello程序,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器
最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
发生一个缺页异常后,控制会转移到内核的缺页处理程序。判断虚拟地址是否合法,若不合法,则产生一个段错误,然后终止这个进程。
若操作合法,则缺页处理程序从物理内存中确定一个牺牲页,若该牺牲页被修改过,则将它换出到磁盘,换入新的页面并更新页表。当缺页处理程序返回时,CPU 再次执行引起缺页的指令,将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,主存将所请求字返回给处理器。
7.9动态存储分配管理
动态内存管理的基本方法与策略介绍如下:
动态内存分配器维护着一个称为堆的进程的虚拟内存区域。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
具体而言,分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
下面介绍动态存储分配管理中较为重要的概念:
- 隐式链表
堆中的空闲块通过头部中的大小字段隐含地连接,分配器通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
对于隐式链表,其结构如下:
图 76 隐式链表的结构
- 显式链表
在每个空闲块中,都包含一个前驱(pred)与后继(succ)指针,从而减少了搜索与适配的时间。
显式链表的结构如下:
图 77 显式链表的结构
- 带边界标记的合并
采取使用边界标记的堆块的格式,在堆块的末尾为其添加一个脚部,其为头部的副本。添加脚部之后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。从而实现快速合并,减小性能消耗。
- 分离存储
维护多个空闲链表,其中,每个链表的块具有相同的大小。将所有可能的块大小分成一些等价类,从而进行分离存储。
7.10本章小结
本章主要介绍了hello 的存储器地址空间、intel 的段式管理、hello 的页式管理, VA 到PA 的变换、物理内存访问,hello进程fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件。
8.2 简述Unix IO接口及其函数
- Unix I/O接口:
- 打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。对于Shell创建的每个进程,其都有三个打开的文件:标准输入,标准输出,标准错误。
- 改变当前的文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
- 读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件
内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
- Unix I/O函数:
- int open(char* filename,int flags,mode_t mode)
进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
- int close(fd)
fd是需要关闭的文件的描述符,close返回操作结果。
- ssize_t read(int fd,void *buf,size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
- ssize_t wirte(int fd,const void *buf,size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
查看windows系统下的printf函数体:
图 78 printf的函数体
形参列表中的…是可变形参的一种写法,当传递参数的个数不确定时,用这种方式来表示。
va_list的定义:typedef char *va_list,说明它是一个字符指针,其中 (char*)(&fmt) + 4) 即arg表示的是...中的第一个参数。
再进一步查看windows系统下的vsprintf函数体:
图 79 vsprintf的函数体
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
printf函数的功能为接受一个格式化命令,并按指定的匹配的参数格式化输出,故i = vsprintf(buf, fmt, arg)是得到打印出来的字符串长度,其后的write(buf, i)是将buf中的i个元素写到终端。
因此,vsprintf的作用为接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,进而产生格式化输出。
再进一步对write进行追踪:
图 80 write的情况
这里给几个寄存器传递了参数,然后以一个int INT_VECTOR_SYS_CALL结束。INT_VECTOR_SYS_CALL代表通过系统调用syscall,查看syscall的实现:
图 81 syscall的情况
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
8.5本章小结
本章主要介绍了linux的IO设备管理方法和及其接口和函数,对printf函数和getchar函数的底层实现有了基本了解。
(第8章1分)
结论
hello程序的一生经历了如下过程:
- 代码的编写
首先需要由程序员写出正确的hello.c的代码。
- 预处理
预处理器根据#之后的指令对hello.c进行文本处理,生成hello.i文件。将#include引入的头文件在原地复制;将#define进行的宏定义进行文本替换;根据条件编译的指令在不同情况编译不同的代码。
- 编译
编译器通过词法分析和语法分析,将C语言指令翻译成等价的汇编代码,生成hello.s文件。
- 汇编
汇编器将hello.s汇编程序翻译成二进制的机器语言,并把这些指令打包成可重定位目标程序格式,保存在hello.o 目标文件中;
- 链接
通过链接器,将hello.o文件与其他可重定位文件、静态库链接到一起,然后进行符号解析,再将所相同类型的节合并为同一类型的新的聚合节,最后修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。至此,已经生成hello可执行程序。但是链接并没有结束,在加载时、运行时,hello仍然需要与动态库进行链接,从而调用动态库函数。
- 加载运行
打开Shell,在其中键入 ./hello 120L020701 董琦,shell为其fork新建进程,然后通过execve将hello加载到内存中,为其更改进程的上下文,开始执行hello进程。
- 执行指令
在该进程被调度时,CPU为hello其加载上下文、分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器一步一步地更新,CPU不断地取指,顺序执行自己的控制逻辑流;
- 访存
内存管理单元MMU将虚拟地址映射为物理地址,进而通过三级高速缓存系统访问物理内存/磁盘中的数据;
- 信号处理
进程在运行时可以接收信号,与信号处理程序并发地执行。如果hello运行途中键入ctr-c ctr-z 则调用shell 的信号处理函数分别进行终止、停止等操作。
- 终止并被回收
Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 功能 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o生成的.elf文件 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 用readelf读取hello生成的.elf文件 |
hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Bryant R E, David R. O'Hallaron. Computer Systems a Programmer's Perspective[M]. 3rd. Carnegie Mellon University, 2016.
[2] C语言的预处理详解_绘夜的博客-CSDN博客_c语言预处理
[3] ELF文件解析(一):Segment和Section - JollyWing - 博客园 (cnblogs.com)
[4] Florian.printf背后的故事[EB/OL].2014[2021-6-10].
https://www.cnblogs.com/fanzhidongyzby/p/3519838.html.
(参考文献0分,缺失 -1分)