计算机系统
计算机科学与技术学院
2021年5月
摘 要
hello.c是如何完成从一个.c文件到可执行文件的转变的呢?它需要经历预处理、编译、汇编、链接等重重关卡,才能成为能被机器加载到内存的可执行文件。在shell中输入./hello后,shell会为其调用fork函数创建子进程,调用execve函数映射虚拟内存,mmap为其创建虚拟空间,MMU为其翻译地址实现访存,最终却仿佛什么都没有发生过,hello的程序人生是怎样的精彩,接下来我们一起探究。
关键词:linux;程序;编译;进程;加载;回收;信号;异常;信号处理程序;异常处理程序;内存映射;存储;
目 录
第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简介
P2P: From Program to Process,即程序到进程的过程,对于hello.c,一个c文件,在成为一个程序被机器执行之前,还要经过预处理,编译,汇编,链接后,才能变成一个可执行目标文件。在终端输入./hello,shell为其调用fork函数,产生子进程,这个时候才完成P2P的过程。
P2P: From Program to Process,即程序到进程的过程,对于hello.c,一个c文件,在成为一个程序被机器执行之前,还要经过预处理,编译,汇编,链接后,才能变成一个可执行目标文件。在终端输入./hello,shell为其调用fork函数,产生子进程,这个时候才完成P2P的过程。
1.预处理:C预处理器扩展源代码,插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏。
2.编译:编译器产生两个源文件的汇编代码。
3.汇编:汇编器将汇编代码转化成二进制目标代码文件。
4.链接:链接器最后将目标代码文件与实现库函数的代码合并,产生最终的可执行代码文件。
020:From Zero-0 to Zero-0,当hello程序加载到子程序之后,shell利用多级页表映射虚拟内存,然后通过缺页中断对应到相应的物理内存。CPU为其分配时间片来执行逻辑控制流。在执行过程中,信号也会影响其运行,如通过键盘键入的Ctrl+Z会通过SIGTSTP信号停止其运行,Ctrl+C会通过SIGINT信号终止进程。IO系统使其能够输出在屏幕上。当程序运行结束后,父进程负责回收hello进程,内核删除与之相关的所有数据。Hello先是从0到1,仿佛拥有了很多,当程序结束时,一切又从1回到0,这就时020的过程。
1.2 环境与工具
硬件环境:X64 CPU,Intel Core i7 10875H; 2.20GHz; 16G RAM;
软件环境:windows 11 ,VM virtual box,Ubuntu 20.04LTS
开发与调试工具:gcc,edb,codeblocks,objdump
1.3 中间结果
| 文件名 | 作用 |
| hello.c | 程序源代码 |
| hello.i | 预处理得到的文件 |
| hello.s | 编译得到的文件 |
| hello.o | 汇编后可重定位目标文件 |
| hello | 最终的可执行文件 |
| hello_o.objdump | hello.o的反汇编文件 |
1.4 本章小结
本章对hello进行了简单的介绍,分析了其P2P和020的过程,列出了硬件、软件环境和开发与调试工具,并且列举了任务过程中出现的中间产物,是后续实验部分的基础。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理就是预处理器根据#标识的命令(头文件、宏定义、条件编译等),修改原始c代码,将包含的头文件插入到c代码中,并将宏定义进行替换,去除注释等,形成一个.i文本文件。
作用:
(1)将所有的#define删除,并且展开所有的宏定义,即就是字符替换,如#define PI 3.1415926,将所有的PI换成3.1415926。
(2)处理所有的条件编译指令,#ifdef #ifndef #endif等。
(3)处理#include,将#include指向的文件插入到该行处。
(4)删除所有注释,如‘//’‘’。
(5)添加行号和文件标示,以便在调试和编译出错的时候知道是哪个文件的哪一行。
(6)保留#pragma编译器指令,以便编译器使用它们。
2.2在Ubuntu下预处理的命令
在命令行中输入:gcc -E hello.c -o hello.i
图1 ubantu下预处理
2.3 Hello的预处理结果解析
开头是一些系统头文件的路径,根据预处理命令的功能,预处理器已经把系统头文件的内容插入到.i文件中了,其中包括声明一些变量,结构体等等,头文件中的内容通过递归展开被包含进该文件中。这就是为什么.i文件能有3000多行的原因,这是一个完整的能够被编译器进行编译的程序文件。main函数在3046到3060行。
图2 hello.i的内容
2.4 本章小结
本章主要介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析。预处理是计算机对程序进行操作的第一个步骤,在这个过程中预处理器会对.c文件进行初步的解释,对头文件、宏定义和注释进行操作,最后将初步处理完成的文本保存在.i文件中。
第3章 编译
3.1 编译的概念与作用
概念:编译阶段是编译器对.i文件进行处理的过程。此阶段编译器会完成一系列对代码的语法和语义的分析,生成汇编代码,并保存在.s文件中。
作用:经过词法分析,语法分析,语义分析,之后进行优化。最后可以生成相应的汇编代码(hello.s)
3.2 在Ubuntu下编译的命令
在命令行输入:gcc -S hello.i -o hello.s
图3 ubantu下编译
3.3 Hello的编译结果解析
图4 hello.s程序
头部声明:
图5 hello.s的头部声明
.file -------------- 声明源文件
.text -------------- 代码段
.globl ------------声明全局变量
.align ----------声明对指令或者数据的存放地址进行对齐的方式
.type ------------指明函数类型或对象类型
.size -------------- 声明符号大小
.rodata--------- 只读数据段
数据:
- 整型:
int i
整型变量i是一个局部变量,局部变量会被放置在寄存器或堆栈中,在汇编语言中%rbp-4对应于整型变量i,通过其初始化、比较和加一等操作可以确定这个位置存放的变量就是i。
图6 对i的初始化过程
图7 循环结束对i加一
图8 将i与7比较判断循环是否结束
int argc:
hello.c中argc有一个与4比较的操作,因此可以通过cmpl $4的指令来找到argc的存放位置,可以观察到argc开始存放在%edi中,然后放入堆栈中与4比较。
图9 argc的相关操作
- 字符串:
在汇编语言中有两个字符串LC0和LC1,分别与源代码中需要打印的两个字符串相对应,这两个字符串都被放在.rodata段中,LC0字符串中含有汉字,汉字按照UTF-8格式被编码。
图10 两个字符串
- 指针数组argv[]
作为main函数的参数,argc放置在%edi寄存器中,argv[]放置在%esi寄存器中。在汇编语言中找到%rsi寄存器,这里存放的是argv[],可以看到它被取出放到了堆栈中,在读取其中信息的时候,通过在其地址上+8、+16(单个元素大小为8位)来实现地址偏移,从而读出数组中的元素argv[1],argv[2]。
图11 指针数组
赋值
赋值通过mov指令来实现,根据数据大小使用不同后缀
| 指令 | b | w | l | q |
| 大小 | 8位 | 16位 | 32位 | 64位 |
int i=0对应于movl $0, -4(%rbp)
算术操作
在此程序中出现了加法和取地址操作
图12加法操作
图13 取地址操作
| INC A | A=A+1 |
| DEC A | A=A-1 |
| NEG A | A=-A |
| SUB A,B | B=B-A |
| IMUL A,B | B = A * B,有符号或无符号乘法,结果截断为64位 |
| Imulq A | R[%rdx]:R[%rax] = A * R[%rax],有符号乘法,保留128位 |
| mulq A | R[%rdx]:R[%rax] = A * R[%rax],无符号乘法,保留128位 |
| IDIVQ A | R[%rdx] = R[%rdx]:R[rax] mod A; R[%rax] = R[%rdx]:R[rax] div A 有符号 |
| DIVQ A | R[%rdx] = R[%rdx]:R[rax] mod A; R[%rax] = R[%rdx]:R[rax] div A 无符号 |
关系操作
程序中涉及到的关系操作:
1.!=: if(argc!=3)对应于
cmpl $3, -20(%rbp)
je .L2
设置标志位,利用je来判断$3和argv是否相等。
2.<: for(i=0;i<10;i++)对应于
cmpl $9, -4(%rbp)
jle .L4
i<10即是i<=9,所以将i与9比较,利用jle来判断。
| TEST A,B | 根据A&B来设置条件码,不改变寄存器 |
| SETXX A | 根据XX得到标志位的值来设置A的值 |
数组操作
printf("Hello %s %sn",argv[1],argv[2]);
图14 数组操作
根据函数调用的参数传递可知:%rsi寄存器存储的是argv[1],%rdx寄存器存储的是argv[2],-32(%rbp)存放的是argv[ ]的首地址,其中argc[ ]为字符指针类型,其大小为8字节,所以argv[1]的地址为:argv首地址-32(%rbp)+偏移量8,然后在对其进行寻址,得到argv[1]的值,argv[2]寻址与argv[1]类似,只是将偏移改为16。
控制转移
if语句:比较-20(%rbp)与3是否相等,若相等则跳到.L2,若不相等,则继续执行if内的语句
图15 if语句
for循环:先在.L2中对循环变量i赋值0,然后跳到.L3判断i是否满足循环条件,若满足则跳到.L4,否则执行循环体之后的语句,在循环体.L4的最后每次对循环变量i执行加1操作。
图16 for循环
函数操作
传参规则:参数数量1-6分别存放在%rdi,%rsi,%rdx,%rcx,%r8,%r9,若大于6个则存放在栈中。
在此程序中,有以下几个参数传递:
main函数:int main(int argc,char *argv[ ]):
在进入main函数之前,其参数存在%rdi寄存器和%rsi寄存器中。
printf函数:
①printf("用法: Hello 学号 姓名 秒数!n");
输出常量字符串,编译器采用puts输出,在调用puts之前,参数被存入%rdi中。
图17 puts
②printf("Hello %s %sn",argv[1],argv[2]);
在调用之前,参数被存入%rdi寄存器中,printf要输出的字符串中包含指针数组的内容,所以通过首地址的相对偏移寻址。
图18 printf
exit函数:exit(1)参数为1,存入%rdi中
图19 exit函数
atio函数:参数存在%rdi中
图20 atio函数
sleep函数:将atoi函数的返回值给寄存器%edi,%edi是%rdi的低32位。
图21 sleep函数
函数调用:参数构造完成后,通过call+函数名的方式来调用函数
函数返回:函数在返回时,若有返回值,在返回之前会将返回值存放到%eax寄存器中再返回,若没有则利用ret汇编指令返回。在返回之前要恢复栈帧,删除被调用函数的栈帧,恢复调用函数的栈帧。在函数返回时存在leave指令,其作用是平衡堆栈。
3.4 本章小结
本章主要介绍了编译的概念和作用,使用gcc -S hello.c -o hello.s生成了编译后的文件。对于生成的.s文件,分析了C语言的数据与操作在机器之中如何被处理翻译的,为下一步汇编打下了基础。
第4章 汇编
4.1 汇编的概念与作用
第4章 汇编
4.1 汇编的概念与作用
概念:汇编是指汇编器(Assembler)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标文件,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,包含hello程序执行的机器指令。
作用:将.s文件翻译成可重定位目标文件.o,将汇编代码翻译成机器语言
4.2 在Ubuntu下汇编的命令
在命令行中输入:gcc -no-pie -fno-PIC -c hello.c -o hello.o
图22 ubantu下汇编
4.3 可重定位目标elf格式
在命令行中输入:readelf -h hello.o来查看elf信息
ELF头部:
图23 ELF头部
ELF头以一个16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序。文件类型为REL(可重定位文件),数据存放方式为小端法存储,程序从虚拟地址0x0开始运行,EFL头大小为64字节,节头大小也为64字节,节头数目为14。
节头部表:
在命令行中输入:readelf -S hello.o
图24 节头部表
通过节头部表可以知道每个节的大小,类型,地址以及偏移量
重定位信息
在命令行中输入readelf -r hello.o
图25 重定位信息
.rela.text显示的是重定位信息,这些重定位信息对.L0、puts函数、exit函数、.L1、printf函数、sleep函数、getchar函数进行重定位声明。图片中加数(addent)是由于某些类型的重定位要用其对被修改引用的值做偏移调整。
符号表信息
在命令行中输入readelf -s hello.o
图26 符号表信息
.symtab节,符号表,用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。
4.4 Hello.o的结果解析
在命令行中输入objdump -d -r hello.o > hello_o.objdump
图27 ubantu下objdump
图28 hello_o.objdump信息
.o 反汇编文件与.i汇编文件有以下不同:
- 反汇编语言中mov,sub等操作没有表示操作数大小的后缀,而汇编代码中有b,w,l,q
- 反汇编中没有‘.’开头的伪指令
- 反汇编中,对函数的调用通过call数字(通过PC相对寻址得到);汇编代码中,通过直接call函数名来调用函数。
- 反汇编中跳转为jmp地址的形式,而汇编代码为jmp Lable的形式
- 对全局变量的访问方面,利用PC相对寻址。反汇编中,通过全局变量所处的段+%rip进行访问,由于未进行重定位,所以暂时用0占位;汇编代码中,通过段名称+ %rip来进行访问。
机器语言构成:机器语言由二进制的机器指令序列构成,机器指令包括操作码和操作数。
映射关系:一条汇编语言对应一条机器指令,机器指令中,分支转移、函数调用时的操作数是PC相对寻址的结果,由汇编语言中的地址计算而来,并不是直接使用。每条机器指令长度不一定一致。
4.5 本章小结
本章介绍了hello从hello.s到hello.o的汇编过程,查看了hello.o的ELF格式并且使用objdump得到反汇编代码与hello.s进行比较。但hello.o还不是可执行程序,需要通过链接来生成可执行程序。
第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
图29 uabntu下链接
5.3 可执行目标文件hello的格式
Elf头信息:
在命令行中输入readelf -a hello
图30 ELF头信息
大部分信息都与hello.o相同,与hello.o不同的是,此文件类型为EXEC可执行文件,程序头大小为56字节,有12个程序头,节头数量为27
图31 节头信息
节头展示了各个节的信息,有名称,地址,大小和偏移量,链接之后的内容更加丰富。
图32 程序头信息
程序头是可执行文件的ELF所特有的,链接器在链接可执行文件或动态库的过程中,将来自不同可重定位对象文件中的相同名称的节合并起来构成同名的节。它是一种结构数组,说明了程序运行的时段等相关信息。
图33 Dynamic section
图34 符号表信息
符号表中存储了定位、重定位程式中字符界定和引入的相关信息,而每个重定位所需要引入的字符都在这里声明。
5.4 hello的虚拟地址空间
图35 hello虚拟地址空间
程序被载入至地址0x401000到0x401ff0中。在这个地址范围内,各节的地址都需要与上一节中各节所对应的地址范围一致。根据edb的显示,在这一段地址空间内存储了和前面地址空间一致的程序,而后面存储的则是从.dynamic到.shstrtab节的内容。
5.5 链接的重定位过程分析
在命令行中输入objdump -d -r hello
图36 链接重定位
不同之处:
- 地址访问不同,hello.o中的相对地址在hello中为虚拟地址。hello.o对.rodata的访问是$0x0和0(%rip),是由于它们的地址是在执行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全置为0,同时增加重定位条目。
- hello中链接加入了.c文件中的函数,如exit,sleep等
- hello中加入.init和.plt节,还有节中定义的函数
- hello中无hello.o中的重定位条目,跳转和函数调用的地址在hello中都变为虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
重定位过程:
链接器先将代码中符号引用和符号定义关联起来,再将所有输入到hello中相同类型的节合并为同一类型的节,再修改hello中代码段和数据段对每一个符号的引用,使其指向正确的地址
5.6 hello的执行流程
5.7 Hello的动态链接分析
变化前:
图37 变化前
变化后:
图38 变化后
如图所示为在执行函数dl_init前后,数据段的一些变化。通过对比,注意到0x601000开始的地方发生了变化,由前面的分析可以知道GOT表的位置就在0x601000处,这里执行ld_init之后填入了GOT的信息。
5.8 本章小结
本章介绍了链接的相关概念和作用,分析了可执行文件elf的信息,探索分析了重定位过程,比较了其与hello.o文件的不同之处,对hello的执行流程,动态链接进行了分析。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程的经典定义就是一个执行中程序的实例,系统中的每个程序都运行在一个进程的上下文中。
作用:进程提供了两个关键抽象:
1.一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占CPU。
2.一个私有的地址空间,它提供一个假象,好像我们的程序独占内存。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell是一个交互型的应用级程序,它代表用户运行其他程序。能够执行一系列的读/求值步骤,然后终止。
处理流程:1.从终端读入命令
- 切分字符串,获得所有的参数
- 如果是内置命令则立即执行,否则调用相应函数,创建子进程并运行
- 运行完成之后,shell回收这个进程并输出提示符,等待下一个输入的命令行
- 接受用户通过键盘输入的信息如ctrl+z等
6.3 Hello的fork进程创建过程
用户在shell终端输入./hello与相关参数如学号姓名秒数,父进程调用fork函数创建子进程,在子进程的上下文中运行hello程序,新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码和数据段、堆、共享库以及用户栈。父进程与新创建的子进程之间最大的区别在于它们的PID不同。当子进程运行结束时,父进程如果仍然存在,则执行对子进程的回收,否则init进程回收子进程。
6.4 Hello的execve过程
fork函数生成子进程后,子进程调用execve函数,在当前进程的上下文中加载并运行hello程序。execve函数的功能是加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有发生错误时execve才会返回到调用程序,否则不返回。因此,与fork函数一次调用返回两次不同,execve调用一次但从不返回。
图39 execve函数
6.5 Hello的进程执行
上下文信息:上下文是内核重新启动一个被抢占的进程所需要的状态,它由目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
调度过程:进程执行时,内核可以决定抢占当前进程,并重新开始之前被抢占的进程,这种决策叫做调度。抢占进程时,内核会保存之前进程的上下文,恢复新进程被保存的上下文,将控制传递给该进程,完成上下文切换。
用户态与核心态的转换:进程从用户模式变为内核模式的方法是通过诸如中断、故障或陷入系统调用之类的异常。当异常发生,控制传递给异常处理程序,处理器将从用户模式转变成内核模式。处理程序运行在内核模式中。当它返回到应用程序代码时,处理器由内核模式回到用户模式。
hello进程的执行:程序开始运行时,shell为hello调用fork函数生成子进程,它与shell有单独的逻辑控制流,它们是并发进行的。hello进程执行中,若其不被抢占,则顺利进行,否则将进入内核模式,进行上下文切换,然后进入用户模式。hello调用sleep函数时,sleep请求让调用进程休眠,内核执行其他进程,从而完成上下文切换,之后再恢复hello进程的上下文,继续执行hello进程。
6.6 hello的异常与信号处理
1.程序正常运行时,打印八次提示信息,以输入回车为标志结束程序,并回收进程。
图40 程序正常进行
- 程序运行时按回车,会打印空白行,但可以正常结束
图41 运行过程按回车
- 按下ctrl+c,进程会收到SIGINT信号,shell结束回收hello进程
图42 运行过程按下ctrl+c
- 按下ctrl+z,进程收到SIGTSTP信号,终端显示相关信息并挂起hello进程
图43 运行过程按下ctrl+z
- 通过ps和jobs命令查看进程信息,发现进程被暂时挂起
图44 ps与jobs指令
- 输入pstree指令,查看进程树状图
图45 进程树状图
- 输入kill指令,杀死hello进程
图46 kill指令
- 在运行过程中乱按键盘,shell将用户乱输入的字符除了第一个回车按下之前的字符当做getchar的输入之外,其余都当做新的shell命令,在hello进程结束被回收之后,将会解释这些命令。
图47 乱按键盘
10.输入fg指令把hello进程调至前台执行,可以看到Shell首先自动打印hello的命令行指令,然后hello再在挂起处继续执行,最后自动打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
6.7本章小结
本章介绍了进程的概念和作用,shell的概念,shell通过fork创建子进程、通过execve使hello程序在子进程中的加载并执行、hello的运行过程以及过程中发生的异常和产生的信号并且给出了进程、时间片、调度和上下文等重要概念的定义。
第7章 hello的存储管理
7.1 hello的存储器地址空间
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑位置是指由程序所生成的和段有关的偏移位置部分,而逻辑位置由段标识符和偏移量所构成。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。线性地址标志着 hello 应在内存上哪些具体数据块上运行。
虚拟地址:程序访问存储器所使用的逻辑地址就是虚拟地址。
物理地址:CPU通过地址总线的寻址,找到物理内存对应地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
段寄存器中存放段选择符,段选择符由索引、TI和RPL组成。其中TI决定描述符表的选取,若TI为0,则选择全局描述符表GDT;TI为1,则选择局部描述符表LDT。RPL表示CPU当前的特权级,若RPL为00,则为最高级的内核态;若RPL为11,则为最低级的用户态。索引部分为13位,它表示当前使用的段描述符在对应的描述符表中的位置。
逻辑地址到线性地址的转化过程中,首先需要从段寄存器中取出段选择符,从而确定段描述符,将其送到描述符cache中,取出的32位段基址与32位偏移量相加即为32位线性地址。
图48 段选择符
7.3 Hello的线性地址到物理地址的变换-页式管理
在这里,线性地址即虚拟地址,通过页表来实现转换。
线性地址表示为虚拟页号VPN+虚拟页偏移VPO
图49 VPN与VPO
VPN与VPO位数由计算机系统特性决定,PPN(物理页号)则需浏览页表中的页表项目(PTE)获得。
图50 页式管理
如果PTE的有效位为1,则页命中,直接获得物理页号PPN,PPN和PPO一起构成了物理地址。
如果PTE的有效位为0,则说明发生缺页故障,调出缺页程序,并调出新的页,再退回到原先的进程,并继续调出造成缺页的命令。此时发生页命中,并得到PPN,和PPO一起构成物理地址。
图51 页面的命中与缺页
7.4 TLB与四级页表支持下的VA到PA的变换
通过学习我们可以知道,将页表分级的策略可以降低常驻内存的页表开销,依照多级cacahe的原理,将页表进行一级一级缓存。
图52 k级页表的地址翻译
对于Intel Core i7 CPU,VPO与PPO有12位,VPN有36位,PPN有40位。单个页表大小4KB,PTE大小8字节,故单个页表有512个页表条目,需要9位二进制进行索引,而四级页表则需要36位二进制进行索引,对应着36位的VPN。TLB有16组,故TLBI有t=4位,TLBT有36-4=32位。把虚拟地址的VPN分割为相等程度的不同的部分,每部分都用来搜索由上一层所确定的页表基址以及对应的页表条目。
在四级页表层次结构的地址翻译中,虚拟地址被划分为4个VPN和1个VPO。每个VPNi都是一个到第i级页表的索引,第j级页表中的每个PTE都指向第j+1级某个页表的基址,第四级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问四个PTE。
图53 TLB与四级页表支持下VA到PA的变换
7.5 三级Cache支持下的物理内存访问
由于L2,L3与L1的处理结果类似,这里分析L1的处理过程,L1 Cache,8路64组相连,块大小为64字节,所以组索引为6位,块内偏移为6位,剩下的40位为标记位。L1被划分为CT CI CO。我们根据组索引来选择相应的组,根据标记位选择相应的行,若该行有效位为1,则命中,否则不命中。若不命中,继续在L2中查找,并会在找到时放入一级cache。若一级cache有空闲行则直接放入,否则驱逐一行,驱逐行的选择有许多策略,其中一种是选择最不常用的行,还有最近最少使用的一行。
图54 多级cache访问
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
execve加载运行hello需要经过以下步骤:
- 删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构。
- 映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域:hello程序与共享对象链接,这些共享对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
图55 内存映射
7.8 缺页故障与缺页中断处理
缺页故障:当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中时,发生缺页故障。
图56 缺页故障
缺页中断处理:选择一个牺牲页,如果这个牺牲页被修改过,那么将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,此时MMU可以正常翻译VA。
图57 缺页中断处理
7.9动态存储分配管理
动态存储分配管理由动态内存分配器完成。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制零的区域,它紧接在未初始化的数据区后开始,并向更高的地址。分配器将堆视为一组不同大小的块的集合来维护。
分配器有两种:
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器。
隐式链表:堆中的每个空闲块都经过头部中的不同字段隐含的链接,由于空闲块会合并的特性,链表中的元素的连接一定是空闲块的分配块交替连接的。
图58 隐式链表结构
显式链表:基本原理与隐式链表相同,但显示链表在空闲块中都含有一组前驱(pred)与后继(succ)的指针,从而减少了搜索与适配的时间。
每次通过指针来为malloc寻找空闲块,这样就不用每次都经过已分配的块来找下一个块,通过将某个空闲块前一个块和后一个块的指针连在一起,来删除空闲链表中所找到的适配的块。
有两种方式可以将需要free的块加入到空闲链表中,一种是LIFO,即每次都将释放的块插入到第一个块,这样操作时间短,但会导致内存利用率低。还有一种是按地址顺序插进去,这样操作时间长,但是内存利用率接近最佳适配的利用率。
图59 显式链表的结构
7.10本章小结
本章介绍了虚拟地址、线性地址、物理地址,存储地址、地址转换、段式管理和页式管理,简单描述了四级页表以及三级cache的结构和相关工作原理,从虚拟内存的角度分析了fork和execve的内存映射,还介绍了缺页故障与缺页中断处理和动态内存分配的原理以及管理方式。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列:B0,B1,……,Bk,……,B(m-1)
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种同一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
1.打开文件
应用程序通过要求内核打开相应的文件。
2.shell创建的每个进程都有三个打开的文件。
3.改变文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0,k从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置。
4.读/写文件
读操作是从文件复制n个字节到内存,从当前文件位置k开始,然后将 k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。
写操作是从内存复制n个字节到一个文件,从当前文件位置k开始,然后更新k。
5.关闭文件
内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
函数:
1.int open(char* filename,int flags,mode_t mode):进程通过调用open函数来打开一个存在的文件或创建一个新文件。open函数将filename转换为一个文件描述符,并且返回描述符数字,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
2.int close(fd):fd是需要关闭的文件的描述符,close返回操作结果。
3.ssize_t read(int fd,void *buf,size_t n):read函数从描述符为fd的当前文件位置赋值到内存位置buf。
4.ssize_t wirte(int fd,const void *buf,size_t n):write函数从内存位置buf复制到描述符为fd的当前文件位置。
8.3 printf的实现分析
printf函数实现:
图60 printf实现
vsprintf实现:
图61 vsprintf实现
Printf调用write
图62 printf调用write
从ASCII到字模库再到显示vram(保存每一组点的RGB颜色数据信息)。显示器件根据刷新速率逐行读出vram,并经过信号线向液晶显示屏上传送每一个点(RGB分量)。
8.4 getchar的实现分析
getchar实现:
图63 getchar实现
键盘中断的处理:键盘中断处理程序,接受按键扫描码转成ascII码,保存到系统的键盘缓冲区。
8.5本章小结
本章主要介绍了Linux下IO设备和Unix I/O接口以及与I/O相关的系统级函数的函数原型。
结论
hello经历的历程:编写代码->预处理(生成hello.i)->编译(hello.s)->汇编(hello.o) ->链接(生成可执行文件)->带参运行->执行指令->访存->动态内存申请->信号处理(ctrl+c,ctrl+z)->结束进程(exit)
通过大作业,我对计算机系统有了更深入的理解,学习到了更多不一样的知识,感受到了计算机系统的复杂,了解了程序的一生。虽然hello程序极其简单,但它的运行需要计算机系统各个环节相互配合,相互合作。
附件
| 文件名 | 作用 |
| hello.c | 程序源代码 |
| hello.i | 预处理得到的文件 |
| hello.s | 编译得到的文件 |
| hello.o | 汇编后可重定位目标文件 |
| hello | 最终的可执行文件 |
| hello_o.objdump | hello.o的反汇编文件 |
参考文献
- 兰德尔E.布莱恩特 大卫R.奥哈拉伦 深入理解计算机系统(第三版)
[2] Linux 链接详解----动态链接库 - 沐浴凌风 - 博客园 Linux 链接详解----动态链接库
[3] 深入解析Linux内核I/O剖析(open,write实现) - 笨拙的菜鸟 - 博客园
[4] LINUX 逻辑地址、线性地址、虚拟地址和物理地址_十一月zz的博客-CSDN博客_linux 逻辑地址Linux 逻辑地址、线性地址、虚拟地址、物理地址



