计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2022111378
班 级 2203102
学 生 杜雨霞
指 导 教 师 史先俊
计算机科学与技术学院
2023年4月
摘 要
Hello的一生从一个hello.c源程序开始,历经预处理、编译、汇编、链接,生成了可执行程序hello,又通过shell调用fork函数、execve函数被加载成了进程。在运行过程中,hello被分配时间片和内存空间,提供IO设备输出结果,并可能受到Ctrl-C等命令行发出的信号和缺页故障的影响。直到运行结束后,hello被其父进程回收,内核删除为这个进程创建的所有数据结构。hello的一生结束。本论文正是着眼于hello的一生,探索linux系统下hello从源程序到运行结束被回收的每个阶段。
关键词:计算机系统;预处理;编译;汇编;链接;进程;地址存储;IO管理
目 录
第1章 概述…………………………………………… – 4 –
1.1 Hello简介…………………………………….. – 4 –
1.2 环境与工具……………………………………. – 4 –
1.3 中间结果……………………………………….. – 4 –
1.4 本章小结……………………………………….. – 5 –
第2章 预处理……………………………………….. – 6 –
2.1 预处理的概念与作用……………………… – 6 –
2.2在Ubuntu下预处理的命令……………. – 6 –
2.3 Hello的预处理结果解析……………….. – 6 –
2.4 本章小结……………………………………….. – 7 –
第3章 编译…………………………………………… – 8 –
3.1 编译的概念与作用…………………………. – 8 –
3.2 在Ubuntu下编译的命令……………….. – 8 –
3.3 Hello的编译结果解析…………………… – 9 –
3.4 本章小结……………………………………… – 12 –
第4章 汇编…………………………………………. – 13 –
4.1 汇编的概念与作用……………………….. – 13 –
4.2 在Ubuntu下汇编的命令……………… – 13 –
4.3 可重定位目标elf格式…………………. – 13 –
4.4 Hello.o的结果解析……………………… – 13 –
4.5 本章小结……………………………………… – 13 –
第5章 链接…………………………………………. – 14 –
5.1 链接的概念与作用……………………….. – 14 –
5.2 在Ubuntu下链接的命令……………… – 14 –
5.3 可执行目标文件hello的格式……… – 14 –
5.4 hello的虚拟地址空间………………….. – 14 –
5.5 链接的重定位过程分析………………… – 14 –
5.6 hello的执行流程…………………………. – 14 –
5.7 Hello的动态链接分析…………………. – 14 –
5.8 本章小结……………………………………… – 15 –
第6章 hello进程管理…………………….. – 16 –
6.1 进程的概念与作用……………………….. – 16 –
6.2 简述壳Shell-bash的作用与处理流程.. – 16 –
6.3 Hello的fork进程创建过程………… – 16 –
6.4 Hello的execve过程…………………… – 16 –
6.5 Hello的进程执行………………………… – 16 –
6.6 hello的异常与信号处理………………. – 16 –
6.7本章小结………………………………………. – 16 –
第7章 hello的存储管理…………………. – 17 –
7.1 hello的存储器地址空间………………. – 17 –
7.2 Intel逻辑地址到线性地址的变换-段式管理…………………………………………………… – 17 –
7.3 Hello的线性地址到物理地址的变换-页式管理……………………………………………….. – 17 –
7.4 TLB与四级页表支持下的VA到PA的变换………………………………………………………. – 17 –
7.5 三级Cache支持下的物理内存访问 – 17 –
7.6 hello进程fork时的内存映射……… – 17 –
7.7 hello进程execve时的内存映射….. – 17 –
7.8 缺页故障与缺页中断处理…………….. – 17 –
7.9动态存储分配管理………………………… – 17 –
7.10本章小结…………………………………….. – 18 –
第8章 hello的IO管理………………….. – 19 –
8.1 Linux的IO设备管理方法…………….. – 19 –
8.2 简述Unix IO接口及其函数………….. – 19 –
8.3 printf的实现分析………………………… – 19 –
8.4 getchar的实现分析…………………….. – 19 –
8.5本章小结………………………………………. – 19 –
结论……………………………………………………… – 20 –
附件……………………………………………………… – 21 –
参考文献………………………………………………. – 22 –
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:在Linux系统下,hello.c源程序经过预处理、编译、汇编、链接,生成可执行目标文件 hello。shell先调用fork函数创建一个新的子进程,然后调用execve函数将hello程序加载到新的子进程中,hello从一个程序变成一个进程P2P(From Program to Process)。
020: shell 执行可执行目标文件hello,通过段式管理和页式管理将程序载入物理内存中执行。进入main函数执行目标代码,CPU为hello分配时间片,进行取指、译码、执行等流水线操作。当程序运行结束后,hello子进程向父进程发送信号,父进程对其回收,内核删除为这个进程创建的所有数据结构,完成从无到有再到无的过程020(FromZero to Zero)。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境: 处理器:12th Gen Intel(R) Core(TM) i5-12500H 2.50 GHz
RAM:16.0 GB (15.7 GB 可用)
系统:64 位操作系统, 基于 x64 的处理器
软件环境:win 11;Ubuntu 20.04.4
开发与调试工具:gcc;CodeBlocks ; readelf;edb;gedit
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名字 | 文件作用 |
hello.c | 源程序 |
hello.i | 预处理文件 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 链接后的可执行目标程序 |
hello_o.elf | hello.o可重定位文件的elf文件格式 |
hello.elf | hello的elf文件格式 |
hello_o.txt | hello.o的反汇编文件 |
hello.txt | hello的反汇编文件 |
1.4 本章小结
本章概括了hello的P2P,020的过程;列出了写本论文所处的环境、所用的工具;还列出了写本论文过程中产生的hello相关的文件。是整篇论文的背景和概括。
第2章 预处理
2.1 预处理的概念与作用
(1)概念:在进行编译的第一遍扫描(词法扫描和语法分析)之前所作的工作。预处理器(cpp)根据以字符#开头的命令,修改原始的 C 程序。
(2)作用:
1. 处理头文件。比如 hello.c 的第一行的#include命令告诉预处理器 读取系统有文件 stdio.h 的内容,并把它直接插入程序文本中。
2. 处理宏定义:对于 #define 指令,进行宏替换,对于代码中所有使用宏定义的地方使用符号表示的实际值替换定义的符号。
3. 处理条件编译:根据可能存在的 #ifdef 来确定程序需要执行的代码段。
4. 处理特殊符号:例如 #error 等,预编译程序可以识别一些特殊的符号,并在后续过程中进行合适的替换。
5. 删除 C 语言源程序中的注释部分。
结果得到另一个C程序,通常以.i作为文件的扩展名。
2.2在Ubuntu下预处理的命令
cpp hello.c > hello.i
(或
cpp hello.c -o hello.i
)
或
gcc -E hello.c -o hello.i
图2.2-1 hello预处理过程截图
2.3 Hello的预处理结果解析
1.24行的源程序已经被扩展到了3061行,预处理对源文件中的宏进行了宏展开,hello.c程序中的头文件 stdio.h、unistd.h、stdlib.h中的内容插入到了hello.i文件中,内容包括函数声明、结构体定义、变量定义,宏定义等,头文件的内容被包含进hello.i文件中。插入的库文件的具体信息如下图所示:
图2.3-1 hello.i中对插入的库文件的描述截图
2.在源代码头部出现的注释在预处理之后的源代码部分找不到了,印证了上面说的在预处理过程中预处理器将删除源代码中的注释部分。
2.4 本章小结
这一部分介绍了在预处理过程中预处理器的工作(头文件展开,宏替换,删除注释,条件替换等),同时使用 ubuntu 系统展示了对于 hello.c 文件的预处理操作与预处理结果。
第3章 编译
3.1 编译的概念与作用
(1)概念:编译器(cc1)将文本文件 hello.i翻译成文本文件 hello.s,它包含一个汇编语言程序。编译过程就是把预处理完的文件进行一系列词义分析,语法分析,语义分析,中间代码生成,目标代码生成与优化后生成相应的汇编代码文件。
(2)作用:编译是整个程序构建的核心部分,编译器将源代码由文本形式转换成机器语言,相当于一个翻译的过程,编译程序还具备语法检查、目标程序优化等功能。具体可分为如下部分:
1. 扫描(词义分析):将源代码程序输入扫描器,将源代码中的字符序列分割为一系列 C 语言中的符合语法要求的字符单元,这一部分可以分为自上而下的分析和自下而上的分析两种方式。
2. 语法分析:基于词法分析得到的字符单元生成语法分析树。
3. 语义分析:在语法分析完成之后由语义分析妻进行语义分析,主要就是为了判断指令是否是合法的 c 语言指令,这一部分也可以叫做静态语义分析,并不判断一些在执行时可能出现的错误,例如如果不存在 IDE 优化,这一步对于 I/O这种只有在动态类型检查的时候才会发现的错误,代码将不会报错。
4. 中间代码:中间代码的作用是可使使得编译程序的逻辑更加明确,主要是为了下一步代码优化的时候优化的效果更好。
5. 代码优化:根据用户指定的不同优化等级对代码进行安全的、等价的优化,这一行为的目的主要是为了提升代码在执行时的性能。
6. 生成代码:生成是编译的最后一个阶段。在经过上面的所有过程后,在这一过程中将会生成一个汇编语言代码文件,也就是我们最后得到的 hello.s 文件,这一文件中的源代码将以汇编语言的格式呈现。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
或cc1 hello.i -o hello.s
但由于在我的Ubuntu中,ccl不在path中,这里使用命令:
/usr/lib/gcc/x86_64-linux-gnu/9/cc1 hello.i -o hello.s
编译过程:
图3.2-1 hello编译过程截图
3.3 Hello的编译结果解析
3.3.1数据
1.常量:本程序中为字符串数据类型
图3.3-1 hello.s中对字符串数据的定义截图
可知两字符串为本程序中常量。
图3.3-2 hello.s中对字符串数据的调用截图
可知两字符串在hello.s中被调用的地方。
- 变量
局部变量:
int型:i,argc
char*型: argv[]
图3.3-3 hello.i中的argc,argv定义截图
可知main函数中两个参数:argc和*argv[],以及一个局部变量i为本程序中变量。
图3.3-4 hello.s中对argc,argv的调用的截图
图3.3-5 hello.s中对i的调用的截图
可知参数argc保存在-20(%rbp),参数argv保存在-32(%rbp);局部变量i存储在-4(%rbp)。可以发现局部变量是储存在栈中的某一个位置的或是直接储存在寄存
器中的。
全局变量:
即在 sleep 中需要使用的 sleepsecs 变量,这一变量的初始化不需要其他的汇编语句,在刚开始的时候就初始化好了。且放在了.data 段中。
3.3.2赋值
Hello程序只对i做了赋值操作,编译完成的时候就已经完成了对于全局变量 sleepsecs 的赋值,因此不需要其他的赋值语句。
图3.3-6 hello.s中对i的赋值的截图
31行对i赋值为0
3.3.3类型转换
图3.3-7 hello.s中对argv[3]的类型转换的截图
将字符串数据类型argv[3]转换为整数数据类型atoi(argv[3])
3.3.4算术操作(只有i++)
图3.3-8 hello.s中对i的算术操作的截图
51行对i进行了+1操作
3.3.5关系操作
图3.3-9 hello.c中对“!=”的操作的截图
对应.s文件中的
图3.3-10 hello.s中对“!=”的操作的截图
即有关系操作 != ,对应条件判断argc != 4,条件不成立则跳转到L2
图3.3-11 hello.c中对“<=7”的操作的截图
对应.s文件中的
图3.3-12 hello.s中对“<=7”的操作的截图
即有关系操作 <= ,对应条件判断i<=7,条件成立则跳转到L4
3.3.6数组/指针/结构操作
图3.3-13 hello.c中对数组argv[]的截图
可知main函数中存在对argv[]的操作
图3.3-14 hello.s中对数组argv[]的截图1
图3.3-15 hello.s中对数组argv[]的截图2
可知,通过%rbp指针的不同偏移位置找到argv[1],argv[2],argv[3],并将arcv[1]的值赋给%rsi,arcv[2]的值赋给%rdx,arev[3]的值赋给%rdi。
这时%rdi作为atoi函数的参数,最后将函数得到的结果%rax赋给%rdi,让%rdi作为sleep函数的参数调用函数。
3.3.7控制转移
图3.3-16 hello.s中对if控制转移的截图
即if语句判断,如果argc = 4则跳转到L2。对应源程序:
图3.3-17 hello.c中对if控制转移的截图
2.
图3.3-18 hello.s中对if控制转移的截图
即for循环,循环条件是判断i是否小于8,小于则继续循环。对应源程序:
图3.3-19 hello.c中对if控制转移的截图
3.3.8函数操作
由在 X86 系统中函数参数储存的规则,第 1~6 个参数依次储存在
%rdi、%rsi、%rdx、%rcx、%r8、%r9 这六个寄存器中,其余的参数保存在栈中的某些位置。
main函数:
参数传入:argc 和 argv,其中 argv 储存在栈中,argc 储存在%rdi 中
返回:在源代码中最后的返回语句是 return 0,因此在汇编代码中最后是将%eax设置为 0 并返回这一寄存器。
图3.3-20 hello.s中main函数的截图
getchar函数:
无需参数传递,读取缓冲区字符。
图3.3-21 hello.s中getchar函数的截图
printf 函数:
参数:第一次调用的时候只传入了LC0处的字符串参数首地址;for 循环中调用的时候传入了LC1处的字符串和 argv[1]和 argc[2]的地址。
调用:第一次是满足 if 条件的时候调用,第二次是在 for 循环条件满足的时候调用。
图3.3-22 hello.s中第一次调用printf函数的截图
图3.3-23 hello.s中第二次调用printf函数的截图
atoi函数:
参数:argv[3],将字符串数据类型转换为整型数据类型。
图3.3-24 hello.s中atoi函数的截图
sleep 函数:
参数:以全局变量 sleepsecs 为参数,参数储存在%edi 中。
调用:在满足 for 循环的条件下被调用。
图3.3-25 hello.s中sleep函数的截图
exit 函数:
参数:传入的参数为 1,执行退出命令。
调用:当 if 条件满足的时候调用这一函数。
图3.3-26 hello.s中exit函数的截图
3.4 本章小结
这一部分介绍了在编译过程中编译器(ccl)的工作(词义分析,语法分析,语义分析,中间代码生成,目标代码生成与优化,最后生成相应的汇编代码文件),同时使用 ubuntu 系统展示了对于 hello.i 文件的编译操作与编译结果。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器 (as) 将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在目标文件 hello.o 中。(hello.o 是一个二进制文件)。
作用:将汇编代码根据特定的转换规则转换为二进制代码,也就是机器代码,机器代码也是计算机真正能够理解的代码格式。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o
或
gcc -c hello.s -o hello.o
图4.2-1 hello汇编过程截图
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
通过命令readlef -a hello.o > hello_o.elf,将hello.o文件中的信息读到hello_o.elf中。
图4.3-1 读取hello.o的elf格式截图
hello_o.elf文件内容:
- ELF头:
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、处理器体系结构、节头部表的文件偏移,以及节头部表中条目的大小和数量[8]。
图4.3-2 hello_o.elf文件中的elf头截图
- 节头:
节头表中一共有14项,其中第一项为空。剩下13项对应于可重定位文件中每一节的相关内容,包含名称,类型,地址,偏移量,大小,访问权限,对齐方式。
图4.3-3 hello_o.elf文件中的节头截图1
且旗标字母的含义:
图4.3-4 hello_o.elf文件中的节头截图2
- 重定位节:
重定位节中包含了在代码中使用的一些外部变量等信息,在链接的时候需要根据重定位节的信息对这些变量符号进行修改。链接的时候链接器会根据重定位节的信息对外部变量符号决定选择何种方法计算正确的地址,通过偏移量等信息计算出正确的地址[1]。
本程序需要重定位的信息有:.rodata 中的模式串,puts,exit,printf,slepsecs,sleep,getchar 这些符号同样需要与相应的地址进行重定位。具体重定位节的信息如下图所示:
图4.3-5 hello_o.elf文件中的重定位节截图
4. 符号表:
.symtab 是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
例如本程序中的 getchar、puts、exit 等函数名都需要在这一部分体现,具体信息如下图所示:
图4.3-6 hello_o.elf文件中的符号表截图
4.4 Hello.o的结果解析
一、使用命令objdump -d -r hello.o 分析hello.o的反汇编。
hello.o的反汇编:
图4.4-1 hello.o的反汇编内容截图1
图4.4-2 hello.o的反汇编内容截图2
hello.s:
图4.4-3 hello.s的内容截图1
图4.4-4 hello.s的内容截图2
图4.4-5 hello.s的内容截图3
二、请与第3章的 hello.s进行对照分析
- 操作数进制不同:hello.s 反汇编之后对于数字的表示是十进制的,而 hello.o数字的表示是十六进制的。
图4.4-6 hello.s中为十进制的32
图4.4-7 hello.o反汇编中为十六进制的32
- 分支转移:对于条件跳转,hello.s 反汇编中给出的是段的名字,例如.L3 等来表示跳转的地址,而 hello.o 由于已经是可重定位文件,对于每一行都已经分配了相应的地址,因此跳转命令后跟着的是需要跳转部分的目标地址。
图4.4-8 hello.o反汇编条件跳转时jmp的是L3的目标地址
图4.4-9 hello.s条件跳转时jmp的是段的名字
值得注意的是,在机器代码(hello.o的反汇编)中,采用的是相对寻址方式。如:
图4.4-10 hello.o反汇编条件跳转时jmp的是0x80
这行代码在机器的二进制代码中跳转的是0x48。因此实际跳转的位置是下一条指令的地址0x38+0x48=0x80的位置。
- 函数调用:hello.s 中,call 指令后跟的是需要调用的函数的名称,而 hello.o 反汇编代码中 call 指令使用的是 main 函数的相对偏移地址。同时可以发现在hello.o 反汇编代码中调用函数的操作数都为 0,即函数的相对地址为 0,因为再链接生成可执行文件后才会生成其确定的地址,所以这里的相对地址都用 0代替。
图4.4-11 hello.o反汇编调用exit函数 call指令后是main 函数的相对偏移地址
图4.4-12 hello.s调用exit函数 call指令后是函数名
4.5 本章小结
本章介绍了汇编的概念、作用,实现了在linux系统下汇编的命令,并通过readelf解析了可重定位文件的格式,比较了反汇编代码与hello.s中代码的区别。
第5章 链接
5.1 链接的概念与作用
概念:链接(linking)是指将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
作用:链接的作用是将预编译好了的一个目标文件(hello.o)或若干目标文件外加链接库合并成为一个可执行目标文件(hello)。使得分离编译称为可能,不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为可独立修改和编译的模块。当改变这些模块中的一个时,只需简单重新编译它并重新链接即可,不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
ld -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 /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -o hello
图5.2-1 hello链接过程的截图
5.3 可执行目标文件hello的格式
与4.3同理,使用命令readelf -a hello > hello.elf,将输出内容保存为文本文件hello.elf。
hello的ELF格式:
节头表的第一列按地址顺序列出了各段的名称及大小,第三列列出来各段的起始地址,最后一列列出来各段的偏移量。
图5.3-1 hello的elf格式的节头信息截图1
图5.3-2 hello的elf格式的节头信息截图2
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图5.4-1 edb加载hello的过程
加载完成。
1. 在edb的symbol窗口,可以查看各段对应的名称(如.dynsym)以及各段的起始位置(如4002e0)与结束的位置(如400398 + d8 = 400470 即后一节的起始位置),与5.3中所展示出来的elf格式中各段的信息的相对应。
图5.4-2 edb的symbol窗口与hello的elf格式内容对应的截图
2. edb 的 Data Dump 窗口看到 hello 的虚拟地址空间分配的情况,具体内容截图如下:
图5.4-3 edb的data dump窗口与hello的elf格式的对应的截图
可以发现这一段程序的地址是从 0x401000 开始的。
接下来可以分析其中的一些具体的内容:其中PHDR保存的是程序头表;INTERP保存了程序执行前需要调用的解释器;LOAD记录程序目标代码和常量信息;DYNAMIC 储存了动态链接器所使用的信息;NOTE 记录的是一些辅助信息;GNU_EH_FRAME 保存异常信息;GNU_STACK 使用系统栈所需要的权限信息;GNU_RELRO 保存在重定位之后只读信息的位置。
5.5 链接的重定位过程分析
objdump -d -r hello > hello.txt将输出内容保存为文本文件再进行查看,将hello.o与hello文件的反汇编代码进行对比分析,得到如下几方面的不同。
1.代码量增加
图5.5-1 hello.o与hello的反汇编的内容多少对比
hello.o的反汇编代码只有53行,hello的有249行。
- 插入C标准库中的代码(给调用函数分配虚拟地址)
图5.5-2 hello.o与hello的反汇编的调用函数声明对比
hello.o的反汇编程序中只有main函数,没有调用的exit等函数;经过链接后,原来调用的C标准库中的函数的代码都插入了hello的反汇编代码中,且每个函数都被分配了各自的虚拟地址。
图5.5-3 hello.o与hello的反汇编的call指令调用函数地址对比截图
在hello.o的反汇编程序中,由于当时函数未被分配地址,所以调用函数的位置都用call加下一条指令地址来表示;而在hello的反汇编程序中,由于各函数已拥有了各自的虚拟地址,所以在call后加其虚拟地址来实现函数调用。
- 给语句分配虚拟地址
图5.5-4 hello.o与hello的反汇编的语句虚拟地址分配的截图
hello.o的反汇编程序中main函数所有语句前的地址都从main函数开始、从0依次递增,不是虚拟地址;链接后,每条语句被分配了虚拟地址。
- 给字符串常量分配虚拟内存
图5.5-5 hello.o与hello的反汇编的字符串虚拟地址分配的截图
在hello.o的反汇编程序中,由于当时字符串常量并未分配虚拟内存,字符串常量的位置是用0加%rip的值来表示的;而在hello的反汇编程序中,因为字符串常量都有了相应的位置,所以用实际的相对下一条语句的偏移量加%rip(下一条语句的地址)的值来描述其位置。
5.跳转指令
图5.5-6 hello.o与hello的反汇编的跳转指令的截图
hello.o的反汇编程序中,对于跳转指令,在其后加上目的地址,为main从0开始对每条指令分配的地址;而在hello的反汇编程序中,由于各语句拥有了各自的虚拟地址,所以同样加上目的地址,但这里是每条指令的虚拟地址。
说明链接的过程。
链接的过程主要分为符号解析和重定位这两个过程。
1)符号解析:符号解析解析目标文件定义和引用符号,并将每个符号引用和一个符号定义相关联。
2)重定位:编译器和汇编器生成从0开始的代码和数据节。而链接器通过把每个符号定义与一个虚拟内存地址相关联,从而将这些代码和数据节重定位,然后链接器会修改对所有这些符号的引用,使得它们指向这个虚拟内存地址。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
对于hello来说,链接器把hello中的符号定义都与一个虚拟内存位置关相关联,重定位了这些节,并在之后对符号的引用中把它们指向重定位后的地址。hello中每条指令都对应了一个虚拟地址,而且对每个函数,全局变量也都它关联到了一个虚拟地址,在函数调用,全局变量的引用,以及跳转等操作时都通过虚拟地址来进行,从而执行这些指令。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
5.6.1 执行过程
首先点击运行,这时程序会运行前面的初始化函数到main,然后step over逐步运行程序。
图5.6.1-1 edb执行hello截图
- 从加载hello到_start:
程序先调用_init函数,之后是puts、printf等库函数,最后调用_start函数。
- 从_start到call main:
程序先调用_libc_csu_init等函数,完成初始化工作,随后调用main函数。
- 从main函数到程序终止:
程序执行main函数,调用main函数用到的一些函数,在main函数执行完毕后调用_libc_csu_fini、_fini完成资源释放和清理工作。
5.6.2执行函数及虚拟内存地址
401000
401020
401030
401040
401050
401060
401070
401080
401090
4010c0
4010c1
401150
4011b0
4011b4
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接主要就是把程序按照模块拆分成各个相对独立的部分,在程序运行时才将它们链接在一起形成一个完整的程序。在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。通常的方法是延迟绑定,即为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。
延迟绑定是通过GOT和 PLT实现的,根据hello.elf文件可知,GOT 起始表位置为0x404000,大小0x48。
图5.7-1 hello的elf格式文件中.got.plt的信息截图
图5.7-2 .got.plt前一部分在dl_init前的内容截图
图5.7-3 .got.plt前一部分在dl_init后的内容截图
5.8 本章小结
本章主要介绍了链接的的概念以及链接的作用,主要是在Ubuntu下链接器(ld)将预编译好了的一个目标文件(hello.o)或若干目标文件外加链接库合并成为一个可执行目标文件(hello)、在Ubuntu下链接的命令。本章还分析了hello的ELF格式,并用readelf等列出了其各节的基本信息。用edb查看了hello的虚拟地址空间,发现各节的名称都与相应的一段虚拟地址相对应,同时查看了各节的起始位置与大小。对可执行目标文件hello进行反汇编,得到了反汇编程序,并与hello.o的反汇编程序进行比较。分析了链接的过程,包括符号解析以及重定位。使用edb执行hello,说明了从加载hello到_start,到call main,以及程序终止的所有过程,并列出了其调用与跳转的各个子程序名以及程序地址。分析了hello程序的动态链接项目,通过edb调试,分析了在dl_init前后,这些.got.plt节的的内容变化,是由动态链接的延迟绑定造成的。
第6章 hello进程管理
6.1 进程的概念与作用
(1)概念:进程就是一个执行中程序的实例。
(2)作用:进程提供独立的逻辑控制流,好像我们的程序独占地使用处理器;也提供一个私有的地址空间,好像我们的程序独占地使用内存系统;使CPU被科学有效地划分成多个部分以并行地运行多个进程。
6.2 简述壳Shell-bash的作用与处理流程
(1)作用:首先shell为用户提供命令行界面,使用户可以在这个界面中输入shell命令,然后shell执行一系列的读/求值步骤,读步骤读取用户的输入的命令行,求值步骤则解析命令行,并运行程序。完成后重复上述步骤,直到用户退出shell。从而完成用户与计算机的交互来操作计算机。
(2)处理流程:shell首先打印一个命令行提示符,等待用户输入指令。在用户输入指令后,从终端读取该命令并进行解析:若该命令为shell的内置命令,则立即执行该命令;若不是内置命令,是一个可执行目标文件,则shell创建会通过fork创建一个子进程,并通过execve加载并运行该可执行目标文件,用waitpid命令等待执行结束后对其进行回收,从内核中将其删除;若将该文件转到后台运行,则shell返回到循环的顶部,等待下一个命令行。完成上述过程后,shell重复上述过程,直到用户退出shell。
6.3 Hello的fork进程创建过程
父进程通过调用fork()函数可以创建一个新的运行的子进程,该子进程几乎但不完全与父进程相同。子进程得到与父进程虚拟地址空间相同的但独立的一份副本,包括代码、数据段、堆、共享库以及用户栈,且子进程获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork()函数时,子进程可以读写父进程中打开的任何文件。子进程有不同于父进程的PID。fork()被调用一次,返回两次,子进程返回0,父进程返回子进程的PID[2]。
Hello进行fork进程创建,在shell中输入命令:./hello 2022111378 杜雨霞 2 即可。
6.4 Hello的execve过程
execve启动一个新程序,替换原有的进程,被调用一次且不返回,只有当出现错误时,才会返回到调用程序。shell通过fork()函数创建子进程后,会调用execve函数,在进程的上下文中加载并运行hello,调用启动代码_start创建新的且被初始化为0的栈等[3],将可执行目标文件中的代码和数据从磁盘复制到内存中,随后通过跳转到程序的第一条指令或入口点来运行该程序,由此将控制给主函数main,并传入参数列表和环境变量列表。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
- 进程上下文信息
上下文信息,既包括虚拟内存、栈、全局变量等用户态的资源,也包括内核堆栈、寄存器等内核态的资源。不同类型的上下文切换,会涉及到不同类型资源的切换。
- 进程时间片
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
- 进程调度的过程
Linux 是一个多任务操作系统,它能支持远大于 CPU 数量的任务同时运行。但实际上同一时刻只会有 CPU 数量的进程在运行,等 CPU 时间片到了之后,进程调度器就会把 CPU 资源分配给其他进程。在这个过程中就会涉及到进程之间的切换,这时候就需要将当前进程的上下文信息保存下来,随后加载被调度进程的上下文信息,发生上下文切换。
- 用户态与核心态转换
运行应用程序代码的进程初始时是在用户模式中的。当发生中断、异常、系统调用时,内核会休眠该进程,并在内核态中进行上下文切换,控制将交付给其他进程。进程由用户态切换为内核态。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器把模式从内核模式改回到用户模式。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结果截屏,说明异常与信号的处理。
6.6.1 异常与信号的处理
1.异常
- 硬件异常(中断)(异步):处理器外部I/O设备引起。
图6.6-1 硬件异常的处理流程
- 陷阱(同步):有意的,执行指令的结果。
图6.6-2 陷阱的处理流程
- 故障(同步):不是有意的,但可能被修复。
图6.6-3 故障的处理流程
- 终止(同步):非故意,不可恢复的致命错误造成。
图6.6-4 终止的处理流程
2.信号
- 按ctrl+c键通常产生中断信号SIGINT,默认终止进程。
- 硬件异常产生信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。
- 按ctrl+z键通常产生中断信号SIGSTOP,默认停止进程,直到下一个SIGCONT。
6.6.2 各命令及运行结果截屏
- 正常运行
6.6-5 hello的正常运行截图
hello每隔两秒打印一行“Hello 2022111378 杜雨霞”,进入循环,共打印八次。打印完毕后,调用getchar()函数,等待用户输入回车后程序终止。Shell回收hello子进程,继续等待用户输入指令。
- 不停乱按
6.6-6 hello运行过程不停乱按截图
按下的字符串会直接显示,但不会干扰程序的运行,由于在乱按过程中没有输入回车,所以在最后一行hello的字符串打印完毕后,需要敲一个回车才能退出程序。
- 按回车
图6.6-7 hello运行过程按回车截图
首先,打印的过程中显示换行。在打印完毕最后一行字符串后,由于输入的回车依然存在于stdin中,所以在调用getchar()函数时,会读取stdin中的回车,因此无需再敲回车键,便能终止程序。
- 按Ctrl-Z
图6.6-8 hello运行过程按Ctrl-Z截图
进程发送信号SIGSTP,hello的父进程shell接收到信号并运行信号处理程序,hello被挂起,并打印相关信息。
- Ctrl-Z后运行ps命令:
图6.6-9 hello运行过程中按Ctrl-Z后输入ps命令截图
各进程的pid,包括被挂起的hello被打印出来。
- Ctrl-Z后运行jobs命令:
图6.6-10 hello运行过程中按Ctrl-Z后输入jobs命令截图
查看当前终端放入后台的工作(工作ID,进程ID,工作状态,进程名),但只能看当前终端生效的进程[4]。
- Ctrl-Z后运行pstree命令:
图6.6-11 hello运行过程中按Ctrl-Z后输入pstree命令截图1
图6.6-12 hello运行过程中按Ctrl-Z后输入pstree命令截图2
图6.6-13 hello运行过程中按Ctrl-Z后输入pstree命令截图3
图6.6-14 hello运行过程中按Ctrl-Z后输入pstree命令截图4
pstree指令即ps+tree,ps命令可以显示当前正在运行的那些进程的信息,tree主要功能是创建文件列表,将所有文件以树的形式列出来。pstree命令是用于查看进程树之间的关系,即哪个进程是父进程,哪个是子进程,以树状展示,可以清楚地看出来是谁创建了谁。
- Ctrl-Z后运行fg命令:
图6.6-15 hello运行过程中按Ctrl-Z后输入fg命令截图
当在命令行中运行一个命令并中断它(例如使用 Ctrl+Z ),该命令会被暂停并从前台放入后台。 此时,可以使用 fg 命令来恢复该命令并在前台继续执行。
- Ctrl-Z后运行kill命令:
图6.6-16 hello运行过程中按Ctrl-Z后输入kill命令截图
Ctrl-Z将前台作业挂起放入后台,kill命令给该作业唯一进程4827发送SIGKILL信号杀死进程。
- 按Ctrl-C
图6.6-17 hello运行过程中按Ctrl-C截图
输入Ctrl-C命令后,hello进程被终止。
6.7本章小结
本章主要介绍了进程的概念与作用,壳Shell-bash的作用与处理流程,hello的fork进程的创建过程,hello调用execve过程,hello进程如何,包括执行调度的过程以及用户态和核心态的转换,hello的异常和信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:分为段地址和偏移地址两部分,形式是 段地址:偏移地址。Hello反汇编代码中的就是逻辑地址。
线性地址:是逻辑地址到物理地址变换之间的中间层。CPU在保护模式下,段地址+偏移地址=线性地址。如果CPU在保护模式下没有开启分页功能,则线性地址就被当做最终的物理地址来用(此时线性地址和虚拟地址就是一回事),若开启了分页功能,则线性地址就叫虚拟地址。
虚拟地址:程序访问存储器所使用的逻辑地址。在linux中,虚拟地址数值等于线性地址。
物理地址:是内存中真实的地址,是由CPU生成并传递给内存控制器的地址,通过地址总线传递到内存模块,访问实际的存储单元。
7.2 Intel逻辑地址到线性地址的变换-段式管理
1.段寄存器(16位 用于存放段选择符)
图7.2-1 段寄存器的含义
CS(代码段):程序代码所在段;
SS(栈段):栈区所在段;
DS(数据段):全局静态数据区所在段;
其他3个段寄存器ES、GS和FS可指向任意数据段;
2. 段选择符
图7.2-2 段选择符的组成
①TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT);
②CS寄存器中的RPL字段表示CPU的当前特权级。RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级;
③高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置。
3. 段描述符
段描述符是一种数据结构,实际上就是段表项,分两类:用户的代码段和数据段描述符、系统控制段描述符。
其中系统控制段描述符又分两种:特殊系统控制段描述符和控制转移类描述符。
特殊系统控制段描述符,包括:局部描述符表(LDT)描述符和任务状态段(TSS)描述符;控制转移类描述符,包括:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符。
4. 段式管理
首先根据段选择符的TI位获得段描述符表;接着查看段选择符的前13位,通过索引在段描述符表中找到对应的段描述符;从而可以得到Base字段,即开始位置的线性地址。其与段内偏移量相加,就能得到相应的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页表是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。
虚拟地址(VA)被分为虚拟页号(VPN)与虚拟页偏移量(VPO)。
处理器将虚拟地址发送给 MMU(内存管理单元),MMU取出虚拟页号,通过页表基址寄存器来定位页表条目生成PTE地址:有效位为0+NULL时,则代表没有在虚拟内存空间中分配该内存; 有效位为0+非NULL时,则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中(有效位为0则MMU 触发缺页异常);有效位为1时,页面命中。
图7.3-1 基于页表的地址翻译过程截图
图7.3-2 页面命中时地址变换处理截图
若页面命中,从页表条目中取出信息物理页号(PPN),通过将物理页号与虚拟页偏移量(VPO)结合,得到由物理地址(PPN)和物理页偏移量(PPO)组合的物理地址(PA)。MMU 将物理地址传送给高速缓存/主存,高速缓存/主存返回所请求的数据字给处理器。
图7.3-3 缺页异常时地址变换处理
若缺页异常,缺页处理程序确定物理内存中牺牲页 (若页面被修改,则换出到磁盘),缺页处理程序调入新的页面,并更新内存中的PTE,缺页处理程序返回到原来进程,再次执行缺页的指令。
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个 PTE (页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果 PTE 碰巧缓存在 L1 中,那么开销就会下降 1 或 2 个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU 中包括了一个关于 PTE 的小的缓存,称为翻译后备缓存器(TLB)。TLB是MMU中一个小的具有高相联度的集。
虚拟地址(VA)被分为虚拟页号(VPN)与虚拟页偏移量(VPO)。
图7.4-1 MMU通过VPN访问TLB的过程
若 TLB 命中,之后所做操作与 7.3 中相同(④和⑤);
图7.4-2 TLB命中时地址变换过程
若 TLB 不命中,VPN 被划分为四个片,每个片被用作到一个页表的偏移量,CR3 寄存器包含 L1 页表的物理地址。VPN1 提供到一个 L1 PTE的偏移量,这个 PTE 包含 L2 页表的基地址。VPN2 提供到一个 L2 PTE 的偏移量,依次类推。最后在 L4 页表中对应的 PTE 中取出 PPN,与 VPO 连接,形成物理地址 PA。之后操作与TLB命中时相同。
图7.4-3 TLB不命中时地址变换过程
7.5 三级Cache支持下的物理内存访问
对Cache的访问需要把一个物理地址分为标记、组索引、块偏移三个部分,
图7.5-1 物理地址组成部分
通过组索引来找到地址在Cache 中所对应的组号,再通过标记和 Cache的有效位来判断我们的内容是否在Cache 中,在Ll中如果不命中则进入L2中寻找,若依然未命中则进入L3,若未命中,则进入主存,将查找到的数据加载到Cache里。
图7.5-2 存储器层次结构
7.6 hello进程fork时的内存映射
fork函数为新进程(即hello进程)创建虚拟内存,包括创建当前进程的的mm_struct, vm_area_struct和页表的原样副本。两个进程中的每个页面都标记为只读,每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)。
在新进程返回时,新进程拥有与调用fork进程时相同但相互独立的虚拟内存。映射的也是同一个物理内存。当这两个进程中的任一个进行写操作时,写时复制机制就会创建新页面,在新的页面中进行写操作,并且原来的虚拟内存映射到创建的新页面上,因此每个进程都具有私有的地址空间。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行新程序hello(用新程序代替当前程序)的步骤:
1. 删除已存在的用户区域,即:删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2. 映射私有区域,即:为新程序hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有、写时复制的。代码和数据区域被映射为hello文件中的.text和.data 区。bss 区域是二进制零的,映射到匿名文件,其大小包含在hello 中,但没有内容。栈和堆区域也是二进制零的,初始长度为零。
3. 映射共享区域,即:如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4. 设置程序计数器(PC) ,即:设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
虚拟内存中的字不在物理内存中,即DRAM缓存不命中称为缺页[8]。
图7.8-1 发生缺页的三种原因
图7.8-2 发生缺页异常的页表
如图,CPU需要访问VP3,则地址翻译硬件从内存中读取PTE3,从有效位为0推断出VP3未被缓存,从而触发一个缺页异常,内核中的缺页异常处理程序被调用。该程序会选择一个牺牲页,即存放在PP3中的VP4。接下来,内存从磁盘复制VP3到内存中的PP3来替换VP4的页表条目,更新PTE3,随后返回,重新启动导致缺页的指令。此时,VP3已经缓存在主存中,可以正常处理。经过缺页处理的页表状态如下图所示:
图7.8-3 经过缺页处理的页表
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存是在堆区上开辟空间的,而静态内存是在栈区上开辟空间的。堆区中申请的空间是可以被修改的,而栈区中申请的空间是固定死的无法修改。在C语言中通过这几个函数来实现的:malloc、calloc、realloc、free,且必须先引用一个头文件[5]。
动态内存分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留以供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。分配器有两种基本风格。两种风格都是要求显式地释放分配块。不同之处在于由哪个实体来负责释放已分配的块[6]。
(1)显式分配器,要求应用显式地释放任何已分配的块。
(2)隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程就叫做垃圾收集。
7.10本章小结
本章主要介绍了hello的存储器地址空间,包括逻辑地址、线性地址、虚拟地址、物理地址;了解了逻辑地址到线性地址,线性地址到物理地址的变换;分析了TLB与四级页表支持下的VA到PA的变换;介绍了三级Cache支持下的物理内存访问的流程;分析了hello进程fork与execve时的内存映射;介绍了缺页故障与缺页中断的处理;分析了动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的 I/O 设备都被模式化为文件,而所有的输入输出都被当做对相应文件的读和写。从而允许Linux内核引出一个简单、低级的应用接口,即Unix I/O接口。从而使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口
- 打开文件:
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
shell 创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符 0)、标准输出(描述符为 1),标准出错(描述符为 2)。头文件定义了常量 STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,可用来代替显式的描述符值。
- 关闭文件:
当一个应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
- 文件定位:
文件偏移量:每个打开的文件都有一个与其相关联的“当前文件偏移量”(current file offset)。它通常是一个非负整数,用以度量从文件开始处计算的字节数。通常,读、写操作都从当前文件偏移量处开始,并使偏移量增加所读写的字节数。按系统默认的情况,当打开一个文件时,除非指定O_APPEND选项,否则该偏移量被设置为0[7]。
lseek函数可以修改已打开的文件描述符的文件偏移量, 执行成功后,返回新的文件偏移量。
- 读写文件:
一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n。给定一个大小为m 字节的文件,当k>=m 时执行读操作会触发EOF (end of file) 条件。
类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。
8.2.2 Unix IO接口函数
- 打开文件:
int open(char *filename, int flags, mode_t mode);
open()函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。打开失败返回-1。
- 关闭文件:
int close(int fd);
fd是需要关闭的文件的描述符。关闭成功返回0,异常返回-1。
- 文件定位:
off_t lseek(int fd, off_t offset, int whence);
fd是文件描述符,offset是文件偏移量,whence是偏移的起始位置,有三个取值:
SEEK_SET,则将该文件的偏移量设置为距文件开始处offset个字节。
SEEK_CUR,则将该文件的偏移量设置为其当前值加offset,offset可为正或负。
SEEK_END,则将该文件的偏移量设置为文件长度加offset,offset可为正或负。
成功则返回移动后的目标位置与文件开始处的偏移量,否则返回-1。
- 读文件:
ssize_t read(int fd, void *buf, size_t n);
fd是要操作的文件描述符,buf是目标缓冲区,count是期望读取的字节数。成功则返回读取到的字节数,若到达文件尾则返回0;否则返回 -1。
- 写文件:
ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。若成功则返回写的字节数,若出错则返回-1。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等。
1. 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; }
形参列表里有这么一个“token:…”, 这个是可变形参的一种写法。 当传递参数的个数不确定时,就可以用这种方式来表示。很显然,我们需要一种方法,来让函数体可以知道具体调用时参数的个数。
2.
va_list arg = (va_list)((char*)(&fmt) + 4);
(char*)(&fmt) + 4) 表示的是…中的第一个参数。这是因为在C语言中,参数压栈的方向是从右往左。也就是说,当调用printf函数的时候,先是最右边的参数入栈。
3.
i = vsprintf(buf, fmt, arg);
先看一下vsprintf(buf, fmt, arg)的函数体:
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 函数将所有的参数内容格式化之后存入 buf,然后返回格式化数组的长度。即i是要打印的字符串的长度。
4.
write(buf,i);
即传入buf与参数数量i,将buf中的i个元素写到终端。
4.1先看一下write的实现
write: mov eax, _NR_write mov ebx, [esp + 4] mov ecx, [esp + 8]int INT_VECTOR_SYS_CALL
就是给几个寄存器传递了几个参数,然后int INT_VECTOR_SYS_CALL结束。
4.2 再看一下INT_VECTOR_SYS_CALL的实现
init_idt_desc(INT_VECTOR_SYS_CALL,DA_386IGate,sys_call,PRIVILEGE_USER);
发现要通过系统来调用sys_call这个函数。
4.3 最后看一下sys_call的实现
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 cliret
发现sys_call实现了显示格式化了的字符串。
5. 字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
6. 显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
可以看成异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar函数体:
int getchar(void){ static char buf[BUFSIZE]; static char *b=buf; static int n=0; if(n==0) { read(0,buf,BUFSIZE); b=buf; } return ((--n)>0) " />当程序调用getchar时,程序将用户输入的字符存放在键盘缓冲区中,直到用户按回车,回车字符被放在缓冲区中,且getchar开始从stdin流中读入一个字符。getchar函数的返回值是用户输入的第一个字符的ASCII码。如出错则返回-1,且将用户输入的字符回显到屏幕。后续的getchar调用不会等待用户按键,而是直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。8.5本章小结
本章主要介绍了Linux的IO设备管理方法,即所有的I/O设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写。又简述了Unix IO接口及其函数,包括打开、关闭、定位、读写文件的接口及其函数,并分析了printf(Unix I/O接口写的思想)、getchar(Unix I/O接口读的思想 + 异步异常-键盘中断的处理)的实现。
结论
一、用计算机系统的语言,逐条总结hello所经历的过程。
Hello的经历从hello.c源程序开始。
- 预处理器cpp根据以字符#开头的命令对hello.c预处理,生成修改了的C程序hello.i。
- 编译器ccl将hello.i翻译成汇编程序hello.s,它包含了一个汇编语言程序。
- 汇编器as将hello.s翻译成机器语言指令,把这些指令一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o是hello目前经历中第一个二进制文件。
- 链接器ld将hello.o和hello用到的共享目标文件合并,结果就得到hello文件,它是一个可执行目标文件。
- 加载hello:在shell中利用fork函数创建子进程,并为子进程分配一个与父进程相同但独立的虚拟内存空间,实行写时复制机制。再用execve函数加载hello程序。Hello由一个程序变成进程。(hello执行过程中,在命令行输入命令(Ctrl-C,Ctrl-Z等)会发送信号给进程,触发异常处理子程序)。
- 运行hello:CPU为hello分配时间片,进行取指、译码、执行等流水线操作。初始时虚拟内存中的字不在物理内存中,从而触发缺页处理程序,将缺失页加载到物理内存中。此时CPU重新执行引起故障的指令,页面命中。程序继续向下执行。(hello执行printf函数时,会调用malloc函数从堆中申请动态内存)。
- 最后通过I/O接口根据代码指令进行输出。程序运行结束后成为僵死状态,由父进程对其回收,内核删除为这个进程创建的所有数据结构。hello的一生结束。
二、你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
1. 系统又较为深刻地理解了程序从源程序到运行结束回收的过程,对计算机系统的构成有了更深的认识。
2. 将本学期课程中讲授的概念串连起来,对本学期的有了更深的体悟。
3. 感受到计算机系统仍然有许多深奥又神奇的知识等待探索,激发了对所学专业的好奇心。
4. 写报告的过程中遇到不了解的术语时通过百度等弄明白,锻炼了信息检索和自学能力。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名字
文件作用
hello.c
源程序
hello.i
预处理文件
hello.s
编译后的汇编文件
hello.o
汇编后的可重定位目标文件
hello
链接后的可执行目标程序
hello_o.elf
hello.o可重定位文件的elf文件格式
hello.elf
hello的elf文件格式
hello_o.txt
hello.o的反汇编文件
hello.txt
hello的反汇编文件
参考文献
[1] ELF可重定位目标文件的格式-CSDN博客
[2]【Linux】——进程创建fork()详解_fork进程-CSDN博客
[3] execve函数族详解_execve 传入参数-CSDN博客
[4] Linux后台任务管理:jobs、nohup、disown与& - 知乎 (zhihu.com)
[5] 动态内存管理【详解】 - 知乎 (zhihu.com)
[6] 哈工大计算机系统实验八——动态内存分配器_计算系统实验动态内存分配-CSDN博客
[7] 【Linux C | 文件I/O】文件的读写 | read、write、lseek 函数-CSDN博客
[8]《深入理解计算机系统》 Randal E.Bryant & David R.O’Hallaron 机械工业出版社