计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 小卫星
学 号 2021****
班 级 21TS001
学 生 黄*能
指 导 教 师 刘宏伟
计算机科学与技术学院
2023年5月
摘 要
本文以一个简单的hello.c程序为例,介绍了一个程序在Linux下由文本程序到可执行文件并运行的完整生命周期,包括预处理、编译、汇编、链接、进程管理、存储管理、I/O管理这几部分,一步步详细分析了程序从被键盘输入、保存到磁盘,到最后程序运行结束,变为僵尸进程以及被回收的全过程。清晰地观察hello.c的完整周期,直观地表现其生命历程。
关键词:Linux;P2P;O2O;
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 6 -
3.2 在Ubuntu下编译的命令............................................................................. - 8 -
4.2 在Ubuntu下汇编的命令........................................................................... - 15 -
5.2 在Ubuntu下链接的命令........................................................................... - 24 -
5.3 可执行目标文件hello的格式.................................................................. - 24 -
5.5 链接的重定位过程分析............................................................................... - 30 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 35 -
6.3 Hello的fork进程创建过程..................................................................... - 35 -
6.6 hello的异常与信号处理............................................................................ - 37 -
第7章 hello的存储管理............................................................................... - 40 -
7.1 hello的存储器地址空间............................................................................ - 40 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 40 -
7.3 Hello的线性地址到物理地址的变换-页式管理...................................... - 40 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 42 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 43 -
7.6 hello进程fork时的内存映射.................................................................. - 43 -
7.7 hello进程execve时的内存映射.............................................................. - 43 -
7.8 缺页故障与缺页中断处理........................................................................... - 44 -
8.1 Linux的IO设备管理方法.......................................................................... - 46 -
8.2 简述Unix IO接口及其函数....................................................................... - 46 -
第1章 概述
1.1 Hello简介
P2P(From Program to Process)过程:
hello的生命周期是从一个高级C语言程序开始的,分为四个阶段:首先通过预处理器cpp对hello.c进行预处理,生成文本文件hello.i,然后通过编译器ccl生成hello.s汇编程序,接着通过汇编器as生成hello.o文件,最后通过链接器ld将其与引用的库函数链接,生成可执行文件hello。最后通过加载器,shell通过fork函数创建一个新的进程,并且调用execve函数把程序内容加载至内存上,实现有程序到进程的转化。
Fig. 1 hello的P2P过程
O2O(From Zero-0 to Zero-0)过程:
程序运行前,shell调用execve函数将hello程序加载到相应的上下文中,建立于虚拟内存的映射关系,之后CPU通过MMU把VA转化成PA,通过缺页处理的方式把程序从虚拟内存中加载到物理内存中。shell在hello运行后调用main函数,执行waitpid等待hello执行结束。程序运行结束后,父进程回收进程,释放虚拟内存空间,删除相关内容。
1.2 环境与工具
1.2.1 硬件环境
处理器 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz 2.42 GHz
机带 RAM 16.0 GB (15.7 GB 可用)
设备 ID 79516807-C9BC-42AB-8E2C-D6097337BAFE
产品 ID 00342-36202-17985-AAOEM
系统类型 64 位操作系统, 基于 x64 的处理器
笔和触控 没有可用于此显示器的笔或触控输入
1.2.2 软件环境
版本 Windows 10 家庭中文版
Ubuntu 20.04 LTS 64位
VersualBox version 7.0
1.2.3 开发工具
Visual Studio Code Version: 1.77.0 (user setup)
gedit+gcc
CodeBlocks
1.3 中间结果
Table. 1 hello的中间结果
文件名 | 描述 |
hello.c | 源程序 |
hello.i | hello.c通过预处理器cpp预处理后的文本文件 |
hello.s | hello.i通过编译器ccl编译后的汇编程序 |
hello.o | hello.s通过汇编器as汇编后的文件 |
hello | hello.o通过链接器ld链接后的可执行文件 |
hello1.txt | hello.o反汇编后代码 |
hello2.txt | hello用readelf -a hello指令生成的文件 |
hello. elf | hello.o用readelf -a hello.o指令生成的文件 |
hello3.txt | hello 反汇编后代码 |
1.4 本章小结
本章根据hello的自白,概括并介绍了hello的P2P和O2O过程,并给出了本实验中使用的硬软件环境和开发调试工具。
第2章 预处理
2.1 预处理的概念与作用
-
-
- 预处理的概念
-
当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。C语言提供多种预处理功能,主要处理#开始的预处理指令,如宏定义(#define)、文件包含(#include)、条件编译(#ifdef)等。
-
-
- 预处理的作用
-
根据源代码中的预处理指令修改源代码,预处理从系统的头文件包中将头文件的源码插入到目标文件中,宏和常量标识符全部被相应的代码和值替换,最终生成.i文件。
2.2在Ubuntu下预处理的命令
gcc -E -o hello.i hello.c
应截图,展示预处理过程!
Fig. 2 ubuntu下预处理
2.3 Hello的预处理结果解析
经过预处理之后,hello.c变为hello.i文件,打开该文件可以发现,文件变为3060行,大大增加,且仍为可以阅读的C语言程序文本文件。
预处理把用#include所引入的头文件的实际地址解析出来,同时也会解析该头文件所用#include引入的头文件,一直递归解析下去。如下图所示,hello.c中用到了stdio.h,stdio.h中又用到了libc-header-start.h。
Fig. 3 预处理中的地址解析
预处理对原程序中的宏进行了宏展开,还会把程序里用#define,#ifdef等等指令,根据实际逻辑进行对程序的删减。
Fig. 4 未修改的原程序
2.4 本章小结
在C语言中,源代码通过gcc的-E参数可以选择只开启预编译选项生成预编译处理完成的.i文件。预编译过程中,会解析程序用到所有头文件的绝对路径,除此之外还会根据预处理器指令将不需要的代码删除等一系列操作。
第3章 编译
3.1 编译的概念与作用
-
-
- 编译的概念
-
编译器(ccl)把预处理的文件.i进行一系列的语法分析并且进行优化后生成的相应的汇编程序.s
-
-
- 编译的作用
-
将高级语言书写的源程序转换为一条条汇编指令,方便下一步生成二进制文件。
3.2 在Ubuntu下编译的命令
gcc hello.i -S -o hello.s
(以下格式自行编排,编辑时删除)
应截图,展示编译过程!
Fig. 5 ubuntu下编译
3.3 Hello的编译结果解析
-
-
- 文件信息
-
Fig. 6 hello.s中ELF文件节的相关信息
-
-
- argv参数传递
-
Fig. 7 对main函数的参数argv的传递
在main函数的开始部分,因为后面还会使用到%rbp数组,所以先将%rbp压入栈保存起来。21行将栈指针减少32位,然后分别将%rdi和%rsi的值存入栈中。
由此,%rbp-20和%rbp-32的位置分别存了argv数组和argc的值。
-
-
- 数据操作
- 局部变量
- 数据操作
-
Fig. 8 局部变量int i存入栈中
局部变量存储在栈中,当进入函数main的时候,会根据局部变量的需求,在栈上申请一段空间供局部变量使用。当局部变量的生命周期结束后,会在栈上释放。
-
-
-
- 字符串常量
-
-
在main函数前,在.rodata处的.LC0和.LC1已经存储了字符串常量,标记该位置是代码是只读的。在main函数中使用字符串时,得到字符串的首地址。hello中printf函数中写的格式化字符串被当作局部的字符串常量处理。
Fig. 9 字符串常量解析结果
-
-
- 赋值操作
-
Fig. 10 赋值操作解析结果
hello.c中赋值操作是for循环中i=0;在汇编代码中使用mov指令实现。
-
-
- 算数操作
- 立即数
- 算数操作
-
立即数直接用$加数字表示
Fig. 11 编译中的立即数
-
-
-
- 算数操作符++
-
-
Fig. 12 ++的解析结果
-
-
- 关系操作
- 关系操作符<
- 关系操作
-
Fig. 13 <的编译结果
i<9在进行编译时,被解析成了i<=8.
-
-
-
- 关系操作符!=
-
-
Fig. 14 !=的编译结果
编译中使用cmp和je指令实现!=。
-
-
- 数组
-
Fig. 15 数组的编译结果
对数组的操作,都是先找到数组的首地址,然后加上偏移量即可。例如在main中,调用了argv[1]和argv[2],在汇编代码中,每次将%rbp-32的的值即数组首地址传%rax,然后将%rax分别加上偏移量16和8,得到了argv[1]和argv[2],在分别存入对应的寄存器%rsi和%rdx作为第二个参数和第三个参数,之后调用printf函数时使用。
-
-
- 控制转移
- 条件控制if
- 控制转移
-
Fig. 16 条件控制的编译结果
if语句一般是通过cmp和jmp系列指令配合实现的。
-
-
-
- for循环
-
-
Fig. 17 条件控制for循环的编译结果
-
-
- 函数调用
-
Fig. 18 printf函数的调用
函数的前六个参数由寄存器传参,返回值存在%rax寄存器中。在函数调用时,先将相应的值存入相应的寄存器,然后使用call指令调用函数和ret指令返回函数。注意,由于函数是公用一套寄存器的,在调用一个函数之前,要先将当前函数的一些值保存起来,调用完再恢复。
对printf函数的调用,将argv数组的第二个和第三个元素放入寄存器%rsi和%rdx,然后41行取得了字符串的地址,并存入了2%rdi中作为第一个参数,这样三个参数都准备好后,用call指令调用了printf函数。
3.4 本章小结
编译阶段进行了将代码从C语言转化成了与机器指令一一对应的汇编语言,便利了之后的一系列处理。在编译过程中,会把局部变量储存在栈中,把字符串常量存储在堆中,对于算数运算符和关系运算符,汇编语言和C语言并无太多差别,不过在一些时候编译器会把小于(大于)转化成等价的小于等于(大于等于),下标运算符号[i]都是通过数组指针+8(指针长度)*i实现的,大多数函数传参都尽可能使用寄存器进行传递,而printf稍有例外。if控制语句的汇编语言与C语言的if结构基本类似,而for控制语句则更接近于go-to控制语句。
第4章 汇编
4.1 汇编的概念与作用
-
-
- 汇编的概念
-
汇编器(as)将汇编程序.s翻译为机器语言指令,然后把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件.o中。该文件是个二进制文件。
-
-
- 汇编的作用
-
将汇编代码翻译为机器指令,使其在链接后能被机器识别并执行
4.2 在Ubuntu下汇编的命令
gcc hello.s -c -o hello.o
应截图,展示汇编过程!
Fig. 19 unbuntu下汇编
4.3 可重定位目标elf格式
-
-
- 典型的ELF可重定向目标文件格式
-
Fig. 20 典型的ELF可重定位目标文件结构 S
- ELF头:以16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助两届其语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型(如可执行、可重定位或者共享的)、机器类型、节头部表的文件偏移以及节头部表中条目的大小和数量。
- .text:已编译程序的机器代码。
- .rodata:只读数据。
- .data:已初始化的全局变量和局部静态变量。
- .bss:未初始化的全局变量和局部静态变量,仅是占位符,不占据任何实际磁盘空间。
- .symtab:符号表,存放函数和全局变量(符号表)信息,不包括局部变量。
- .rel.text:.text节的重定位信息,用于重新修改代码段的指令中的地址信息。
- .rel.data:.data节的重定位信息,用于对被模块使用或定义的全局变量重定位的信息。
- .debug:调试符号表,只有以-g方式调用编译器驱动程序时,才会得到这张表。
- .line:原始C源程序中的行号和.text节中机器指令之间的映射。
- .strtab节:字符串表,包括.symtab和.debug节中的符号表。
- 节头表:每个节的节名、偏移和大小。
-
- Hello.o的ELF分析
-
打开终端,用readelf -a hello.o > hello.elf指令生成hello.o的elf文件,查看节的基本信息。
Fig. 21 生成elf文件
-
-
-
- ELF头
-
-
ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
Fig. 22 ELF头
-
-
-
- 节头表
-
-
记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
Fig. 23 节头表
-
-
-
- 重定位节
-
-
.rela.text,保存的是.text节中需要被修正的信息;任何调用外部函数或者引用全局变量的指令都需要被修正;调用外部函数的指令需要重定位;引用全局变量的指令需要重定位; 调用局部函数的指令不需要重定位;在可执行目标文件中不存在重定位信息。hello需要被重定位的是printf、puts、exit、sleepsecs、getchar、sleep和.rodata中的.L0和.L1。
Fig. 24 .rela.text的重定位节
.rela.eh_frame节是.eh_frame节重定位信息。
Fig. 25 .rela.eh_frame的重定位节
-
-
-
- 符号表
-
-
.symtab,符号表,存放在程序中定义和引用的函数和全局变量的信息,一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
Fig. 26 符号表
4.4 Hello.o的结果解析
-
-
- Hello.o的反汇编
-
打开终端,用objdump -d -r hello.o > hello1.txt指令生成hello.o的反汇编文件。
Fig. 27 反汇编
Fig. 28 hello1.txt的内容
-
-
- 与Hello.s的比较
- 分支转移
- 与Hello.s的比较
-
Fig. 29 hello.s的分支转移 |
Fig. 30 hello1.txt中的分支转移 |
在.s文件中,分支跳转是使用段名作为标识的,而在.o文件中,是用地址的偏移量来表示的。因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
-
-
-
- 函数调用
-
-
Fig. 31 hello.s中的函数调用 |
Fig. 32 hello1.txt中的函数调用 |
函数调用与分支跳转类似,在.s中是使用函数名来进行标识的,而在.o文件中是当前位置的下一条指令,因为这些被调用的函数都是在其它库里,现在还不能确定其位置,需要等到静态链接或者动态链接后才能确定其具体的位置。
-
-
-
- 数据调用
-
-
Fig. 33 hello.s中的内存数据引用 |
Fig. 34 hello1.txt中的数据引用 |
对于存放在堆中的数据(全局变量,字符串常量),在.o文件中,是通过如:.LC1(%rip)的形式进行访问的,而在.o文件中是0(%rip),这同样是因为在堆中的数据需要等到运行时才能确定,使用了0(%rip)的形式进行占位。
4.5 本章小结
本章首先介绍了汇编的概念和作用,接着通过实操,对hello.s文件进行汇编,生成ELF可重定位目标文件hello.o,接着使用readelf工具,通过设置不同参数,查看了hello.o的ELF头、节头表、可重定位信息和符号表等。最后将hello.o的反汇编结果与hello.s比较,分析说明机器语言与汇编语言的一一对应关系。
第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
Fig. 35 ubuntu下的链接
5.3 可执行目标文件hello的格式
-
-
- 可执行文件的典型格式
-
Fig. 36 可执行目标文件格式
-
-
- Hello的ELF格式
-
使用指令readelf -a hello > hello2.txt生成hello的elf文件
Fig. 37 生成可执行文件的ELF文件
-
-
-
- ELF头
-
-
在ELF头中,记录了该文件的类型,版本信息,数据表示方式,程序入口地址等信息。
Fig. 38 hello的ELF头
-
-
-
- 节头
-
-
节头描述了每个节的名字,类型,偏移地址,大小等基本信息,其中.text段(存放程序的段)的地址与ELF头中程序入口地址一致。
Fig. 39 hello的节头
-
-
-
- 程序头
-
-
程序头中包括所指向段的类型、其在ELF文件中的偏移地址、大小,映射到内存的虚拟地址信息,段的读写权限等等。
Fig. 40 hello的程序头
-
-
-
- 重定位节
-
-
Fig. 41 hello的重定位节
-
-
-
- 符号表
-
-
Fig. 42 hello的符号表
5.4 hello的虚拟地址空间
由下图,可以看到虚拟地址空间的起始地址为0x400000。
Fig. 43 edb查看hello虚拟地址空间
由readelf得出.inerp段的起始地址为04002e0。
Fig. 44 readelf中的.inerp段信息
在edb中找到:
Fig. 45 edb中.inerp段
.rodata在ELF文件中描述在0x402000处,在该处可以发现有两个字符串,对应.s文件中的LC0和LC1,可以发现两个字符串是连续存储的(中间以’\0’分割)。
Fig. 46 .rodata段
5.5 链接的重定位过程分析
Fig. 47 查看链接的重定位
-
-
- 分支跳转
-
在.o文件中,分支跳转是采用偏移量的方式进行跳转的,而在可执行文件中,分支跳转时采用直接跳转到具体的地址实现的。
Fig. 48 hello.o中的分支跳转 |
Fig. 49 hello中的分支跳转 |
-
-
- 函数调用
-
在.o文件中,函数的调用时直接用当前的下一个地址占位,而在可执行文件中是跳转到一个【函数名@plt】的函数的地址处。
Fig. 50 hello.o中的函数调用 |
Fig. 51 hello中的函数调用 |
-
-
- 数据访问
-
在.o文件中,对于rodata中的数据(全局变量,字符串常量)的访问是用0(%rip)的形式占位标记的。而在可执行文件中直接根据给出了具体的偏移量。
Fig. 52 hello.o中的数据引用 |
Fig. 53 hello中的数据引用 |
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
子程序名 | 子程序地址 |
hello!_start | 0x00000000004010f0 |
hello!__libc_csu_init | 0x0000000000401270 |
hello!_init | 0x0000000000401000 |
hello!frame_dummy | 0x00000000004011d0 |
hello!register_tm_clones | 0x0000000000401160 |
hello!main | 0x00000000004011d6 |
hello!printf@plt | 0x0000000000401040 |
hello!atoi@plt | 0x0000000000401060 |
hello!sleep@plt | 0x0000000000401080 |
hello!getchar@plt | 0x0000000000401050 |
hello!exit@plt | 0x0000000000401070 |
hello!__do_global_dtors_aux | 0x00000000004011a0 |
hello!deregister_tm_clones | 0x0000000000401130 |
hello!_fini | 0x00000000004012e8 |
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。
首先找到.got的地址为0x403ff0。
Fig. 54 由elf找到.got地址
GOT表位置在调用dl_init之前0x404008后的16个字节均为0:
Fig. 55 dl_init前
调用后:
Fig. 56 dl_init后
5.8 本章小结
本章首先介绍了链接的概念和作用,详细说明了可执行目标文件的结构,及重定位过程。并且以可执行目标文件hello为例,具体分析了各个段、重定位过程、虚拟地址空间、执行流程等。
第6章 hello进程管理
6.1 进程的概念与作用
-
-
- 进程的概念
-
狭义上,进程就是一个执行中程序的实例。
广义上,进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
-
-
- 进程的作用
-
进程提供给应用程序的关键抽象:
(1)一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
(2)一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
-
-
- Shell-bash的作用
-
Shell 是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。Shell 有自己的编程语言用于对命令的编辑,它允许用户编写由 shell 命令组成的程序。 Shell 编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的 Shell 程序与其他应用程序具有同样的效果。
-
-
- 处理流程
-
- 从终端读入输入的命令。
- 将输入字符串切分获得所有的参数。
- 检查第一个命令行参数是否是一个内置的shell命令,如果是则立即执行。
- 如果不是内部命令,调用fork( )创建新进程/子进程执行指定程序。
- shell应该接受键盘输入信号,并对这些信号进行相应处理
- Hello的fork进程创建过程
在终端中输入”./hello 2021113657 黄建能”后,shell判断hello不是内置指令,则认为hello时可执行文件,调用fork()创建子进程,子进程通过fork函数获得与父进程用户级虚拟地址空间相同的但是独立的副本,拥有不同的PID。注意,此时hello还未被加载成为进程。
6.4 Hello的execve过程
当调用fork()函数创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,需要以下步骤:
- 删除之前进程在用户部分中已存在的结构
- 创建新的代码、数据、堆和栈段。所有这些区域结构都是私有的,写时复制的。
- 映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序,然后再映射到用户虚拟地址空间中的共享区域
- 设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的起始点
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
- 上下文信息:操作系统使用一种称为上下文切换的较高层次的异常控制流来实现多任务。其实上下文就是进程自身的虚拟地址空间,分为用户级上下文和系统及上下文。每个进程的虚拟地址空间和进程本身一一对应(因此和PID一一对应)。由于每个CPU只能同时处理一个进程,而很多时候系统中有很多进程都要去运行,因此处理器只能一段时间就要切换新的进程去运行,而实现不同进程中指令交替执行的机制称为进程的上下文切换
- 时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片
处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,为用户模式;设置模式为为内核模式。用户模式就是运行相应进程的代码段的内容,此时进程不允许运行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;而内核模式中,进程可以运行任何指令。
在Hello中,程序加载成功后进入就绪态等待进入运行态。进入运行态后,打印相应的字符串,打印完成后shell会调用sleep,因而进入等待态。休眠结束后进入就绪态等待进入运行态,依次循环,直到for循环结束。结束后会因为需要等待用户输入再次进入等待态等待用户执行,用户执行完后,该进程经过一系列退出操作后,会发出SIGCHLD信号,等待被shell回收。
6.6 hello的异常与信号处理
-
-
- 异常的种类
-
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不返回 |
-
-
- Hello中可能出现的异常
-
(以下格式自行编排,编辑时删除)
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
- 中断:异步发生的。在执行hello程序的时候,由处理器外部的I/O设备的信号引起的。I/O设备通过像处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断。这个异常号标识了引起中断的设备
- 陷阱:陷阱是有意的异常,hello执行sleep函数的时候会出现这个异常
- 故障:由错误引起,可能被故障处理程序修正。在执行hello时,访问内存时可能出现缺页故障
- 终止:不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者 SRAM位被损坏时发生的奇偶错误,在hello运行过程中不太可能发生
-
- 键盘操作引发的异常
-
- 回车
Fig. 57 运行时回车
- Ctrl+C
Fig. 58 运行时ctrl+C
- Ctrl+Z
Fig. 59 运行时ctrl+Z
输入ctrl-z默认结果是挂起前台的作业,hello进程并没有回收,而是运行在后台下,用ps命令可以看到,hello进程并没有被回收。此时他的后台 job 号是 1,调用 fg 1 将其调到前台,此时 shell 程序首先打印 hello 的命令行命令, hello 继续运行打印剩下的 5 条 info,之后输入字串,程序结束,同时进程被回收S。
Fig. 60 ps
Fig. 61 fg 1
- 不停乱按
Fig. 62 运行时乱按
6.7本章小结
本章介绍了进程的概念和作用、shell-bash的处理过程与作用并且着重分析了调用fork创建新进程,调用execve函数执行hello,hello的进程执行过程,以及hello在运行时遇到的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
- 逻辑地址:程序经过编译后出现在汇编代码中的地址
- 线性地址:逻辑地址向物理地址转化过程中的一步,逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式,分页机制中线性地址作为输入
- 虚拟地址:就是线性地址
- 物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成:段标识符:段内偏移量。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
索引号就是“段描述符”的索引,段描述符具体地址描述了一个段。很多个段描述符,就组了一个数组,叫“段描述符表”,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。每一个段描述符由8个字节组成。全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
Linux通过分段机制,将逻辑地址转化为线性地址。给定一个完整的逻辑地址[段选择符:段内偏移地址]。首先,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。然后,拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,基地址就知道了。最后,把基地址+偏移量,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。
正如同在cache中寻找内容也需要索引,从虚拟内存到物理内存也需要索引。因此在内存中,额外存储一个叫做页表的数据结构,作为对应的索引。因此,让每个进程都有一个页表,页表中的每一项都记录着该进程中对应的一页所投影到的物理地址、是否有效、还有一些其他信息等。
然而由于页的大小为2^12个字节,而虚拟内存有2^32个字节,导致页表项会有2^20项,占用空间确实太大了,而且很多页表项应该其实都是空的,毕竟进程普遍没有占用很大的地址空间。因此系统采用了多级页表的结构来进行索引。
系统将虚拟页作为进行数据传输的单元。Linux下每个虚拟页大小为4KB。物理内存也被分割为物理页, MMU(内存管理单元)负责地址翻译,MMU使用页表将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
每次将虚拟地址转换为物理地址,都会查询页表来判断一个虚拟页是否缓存在DRAM的某个地方,如果不在DRAM的某个地方,通过查询页表条目可以知道虚拟页在磁盘的位置。页表将虚拟页映射到物理页。页表就是一个页表条目的数组,每一个页表条目是由一个有效位和一个n为地址字段组成。有效位表明虚拟页是否缓存在DRAM中,n位地址字段是物理页的起始地址或者虚拟页在磁盘的起始地址。
n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,例如VPN 0选择PTE 0。根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。这里的VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。
7.4 TLB与四级页表支持下的VA到PA的变换
MMU把虚拟地址(VA)转化成物理地址(PA)是通过查询页表(PTE)实现的,PTE中存储了虚拟页到物理页的映射关系,PTE是常驻内存的一个表,如果CPU每次查询都去访问内存访问PTE的话速度太慢,所以引入了TLB(与Cache类似),TLB是MMU中一个小的具有较高相联度的缓存,其运行机理类似于Cache,只不过存储的只是PTE而已,通过这样的缓存极大的提高了PTE的访问效率,但对于地址空间位64位的系统来讲,PTE占用了非常多的内存,于是引入了多级页表,第一级页表常驻内存,而其它的级数只在用到的时候创建放入内存中,这样极大的减少了内存的需要。
在访问时,MMU通过把根据虚拟地址查表一级PTE,PTE根据虚拟地址指向下一级页表,下一级页表又根据虚拟地址指向下下级页表,到第四级页表时查询得到具体的物理页号(PPN),根据PPN和VPO(虚拟页面偏移量与物理页面偏移量PPO相同),就可以访问到具体的物理内存了。
7.5 三级Cache支持下的物理内存访问
在三级Cache支持下,CPU访问物理内存首先会去L1中找需要访问的内存是否已被缓存,是否有效,如果已被缓存且有效就称为Cache命中,直接就对其进行读写即可,如果L1没有命中,就去L2中寻找,还是没有就去L3中寻找,都没有才会访问内存,把需要访问的内存附近的Cache line都加载进入Cache L3,L2,L1中,然后再进行读写操作。
7.6 hello进程fork时的内存映射
在shell中输入命令./hello后,内核调用fork函数创建子进程,为hello程序的运行创建上下文,并分配一个与父进程不同的唯一的PID。为了给子进程创建虚拟内存,创建了当前进程的 mm_struct、区域结构和页表的原样副本。将这两个进程的每个页面都标记为只 读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。
加载并运行 hello 需要以下几个步骤:
- 删除当前进程虚拟地址中已存在的用户区域
- 映射私有区域,为新程序的代码、数据、bss和栈创建新的区域结构,所有这些新的区域都是私有的、写时复制的
- 映射共享区域,将hello与libc.so动态链接,然后再映射到虚拟地址空间中的共享区域
- 设置当前进程上下文程序计数器(PC),使之指向代码区域的入口点
7.8 缺页故障与缺页中断处理
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:.
- 检查虚拟地址是否合法,如果不合法则触发一个段错误,程序终止
- 检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止
- 两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表
- 将控制转移给hello进程,再次执行触发缺页故障的指令
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆,系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高地址)。对于每个进程,内核维护着一个变量brk,它指向对的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显示地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显示执行的。要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显示地分配块。他们的不同之处在于由哪个实体来负责释放已分配的块。
- 显式分配器:要求应用显式地释放任何分配的块,例如C标准库提供的malloc程序包
- 隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就是放这个块,也被称为垃圾收集器
在C语言中,动态内存时采用显示分配器(malloc,free)实现的,在程序中调用malloc申请内存(在堆中),在分配完的内存前后会额外用一些地址空间用来标记分配的该片内存的大小,再次调用malloc会根据这些表示找到空闲空间进行申请,而调用free则会根据标识释放与之前申请相对应的内存空间。
printf在运行时,是从前往后扫描格式化字符串的,每执行一个格式化字符串就会比较输出的大小,根据这个大小决定是否要调用malloc申请内存。
7.10本章小结
本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,以intel Core7在指定环境下介绍了虚拟地址VA到物理地址PA的转换、物理内存访问,分析了hello进程fork时的内存映射,hello进程execve时的内存映射、缺页故障与缺页中断处理和动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有IO设备都被模型化为文件,所有的输入和输出都能被当做相应文件的读和写来执行。
设备管理:Linux内核有一个简单、低级的接口,成为Unix I/O,是的所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
-
-
- Unix IO接口
-
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想 要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在 后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
- Linux 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)
进程通过调用close函数关闭一个打开的文件,fd是需要关闭的文件的描述符。
- 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的实现分析
(以下格式自行编排,编辑时删除)
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
printf函数的函数体:
在红框中,第一行目的是让argv指向第一个字符串;第二句的作用是格式化,并返回要打印的字符串的长度,第三句的作用是调用write函数将buf的前i个字符输出到终端,调用了unix I/O。syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),于是我们的打印字符串就显示在了屏幕上。
8.4 getchar的实现分析
(以下格式自行编排,编辑时删除)
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar源代码如下:
int getchar(void)
{
static char buf[BUFSIZ];
static char *bb = buf;
static int n = 0;
if (n == 0)
{
n = read(0, buf, BUFSIZ);
bb = buf;
}
return (--n >= 0) ? (unsigned char)*bb++ : EOF;
}
getchar函数内部调用了read函数,通过系统调用read读取存储在键盘缓冲区的ASCII码,直到读到回车符才返回。不过read函数每次会把所有的内容读进缓冲区,如果缓冲区本来非空,则不会调用read函数,而是简简单单的返回缓冲区中最前面的元素
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
8.5本章小结
本章的讲述了linux系统下的I/O设备的管理方法,这套管理方法为软件提供了统一而一致的接口来控制复杂而通常大不相同的I/O硬件设备。同时阐述了系统级别的printf和getchar是怎么实现。
结论
hello在程序员通过键盘输入保存在磁盘上以.c文件存储,之后经过预处理,编译,汇编,链接等一系列过程,它从人能看懂的文本文件变成了机器能够看懂的二进制文件。
之后,在shell输入./hello,shell根据输入判断,不是内置指令,于是先执行fork,创建了子进程,此时复制了一份虚拟内存并且都映射到物理内存中相同的地址空间中,并把他们标记成为写复制,之后在子进程中调用execve装载hello的程序,此时会把虚拟内存中原有的区域结构删除,建立新的区域结构,至此hello成为了独立的进程。hello加载进入内存之后,首先会进行动态链接,动态链接器会根据hello的需要构建一个查表函数,而hello通过这个查表函数来进行对共享库函数的调用,在hello执行完毕之后,如果函数内部没有调用exit,__lib_start_main函数会帮我们调用exit退出,在退出后会给shell发送一个SIGCHLD信号,shell收到这个信息后会释放之前用于存储hello信息的一些内存空间,这就是hello从Zero到Zero的一生。
通过本次大作业,系统的认识了整个计算机系统结构,通过本次大作业,系统的认识了整个计算机软硬件系统结构,一句小小的HelloWorld实现起来需要考虑的方面是如此之多。总之,计算机系统这门课让我受益颇多,我之后也将继续学习更多计算机相关知识。
附件
hello.i:hello.c预处理后的文件。
hello.s:hello.i编译后的文件。
hello.o:hello.s汇编后的文件。
hello:hello.o链接后的文件。
hello1.txt:hello.o反汇编后代码。
hello2.txt:hello用readelf -a hello指令生成的文件。
hello. elf:hello.o用readelf -a hello.o指令生成的文件。
hello3.txt:hello 反汇编后代码。
参考文献
[1] Linux进程状态模型示例操作系统原理,进程的基本状态,运行态,就绪态,等待态与转换模型,进程的其他状态,创建,终止,挂起与转换模型,Linux进程状态模型示例_运行就绪等待_优秀的邓宗磊的博客-优快云博客
[2] Randal E. Bryant;David R. O’Hallaron. 深入理解计算机系统. 北京:机械工业出版社
[3] 一个简单程序从编译、链接、装载(执行)的过程-静态链接 - 知乎 (zhihu.com)
[4] (24条消息) readelf命令使用说明_木虫下的博客-优快云博客