本研究聚焦于在Linux系统下,以简单的C语言文件hello.c为对象,全面深入地探讨了该程序的完整生命周期。从最初的原始程序出发,系统性地研究了编译、链接、加载、运行、终止和回收等关键阶段,以揭示hello.c文件的整个“一生”过程。通过深入分析hello.c程序,将重点放在其在Ubuntu系统下的执行过程上,并结合《深入理解计算机系统》一书的相关理论知识以及在课堂上老师的深入讲解。通过对hello程序的生命周期进行研究,致力于将计算机系统的各个方面有机地串联起来,使之形成一个完整而深刻的体系。
关键词:计算机系统;C语言;程序生命周期;底层原理;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
Hello的P2P是指hello.c文件从可执行程序(Program)变为运行时进程(Process)的过程。在Linux系统下,hello.c文件依次经过cpp(C Pre-Processor,C预处理器)预处理、ccl(C Compiler,C编译器)编译、as(Assembler,汇编器)汇编、ld(Linker,链接器)链接最终成为可执行目标程序hello(在Linux下该文件无固定后缀)。打开shell,输入命令./hello后,shell通过fork产生子进程,hello便从可执行程序(Program)变成为进程(Process)。
Hello的020是指hello.c文件“From Zero-0 to Zero-0”,初始时内存中并无hello文件的相关内容,这便是“From Zero-0”。通过在shell下调用execve函数,系统会将hello文件载入内存,执行相关代码,当程序运行结束后,hello进程被回收,并由内核删除hello相关数据,这即为“to Zero-0”。
1.2 环境与工具
硬件:AMD Ryzen 7 5800H with Radeon Graphics 3.20 GHz
16GB RAM
512GB SSD + 1T SSD
软件:Windows 11 家庭中文版 64 位
VMware Workstation 16.2.2
Ubuntu 20.04.4 LTS 64 位
调试工具:Visual Studio 2022 64-bit;
gcc,readelf, objdump, edb
1.3 中间结果
表格 1 中间结果
文件名 | 描述 |
hello.i | 预处理后得到的ASCII码的中间文件 |
hello.s | 编译后得到的ASCII汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello1.elf | 用readelf读取hello.o得到的ELF格式信息 |
asm1.txt | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
asm2.txt | 反汇编hello可执行文件得到的反汇编文件 |
1.4 本章小结
本章简要介绍了hello的P2P,020的具体含义,同时列出了研究时采用的软硬件环境和中间结果程序。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理步骤是指程序开始运行时,预处理器(cpp,C Pre-Processor,C预处理器)根据以字符#开头的命令,修改原始的C程序的过程。例如,hello.c文件7到9行中的#include命令会告诉预处理器读取系统头文件stdio.h,unistd.h,stdlib.h的内容,并把这些内容直接插入到程序文本中。用实际值替换用#define定义的字符串。除此之外,预处理过程还会删除程序中的注释和多余的空白字符。预处理通常得到另一个以.i作为拓展名的ASCII码的中间文件。
2.1.2 预处理的作用
预处理过程将#include后继的头文件内容直接插入程序文本中,完成字符串的替换,方便后续处理。预处理过程中并未直接解析程序源代码的内容,而是对源代码进行相应的分割、处理和替换。简单来说,预处理是一个文本插入与替换的过程,生成的 hello.i文件仍然是文本文件。
2.2在Ubuntu下预处理的命令
在Ubuntu系统下,进行预处理的命令为:
cpp hello.c > hello.i
图 2-1 预处理过程
2.3 Hello的预处理结果解析
在Linux下打开hello.i文件,可以发现hello.i程序已经拓展为3061行,行数比起hello.c文件大幅增加。其中,hello.c中的main函数相关代码在hello.i程序中对应着3048行到3061行。
图 2-2 预处理结果部分展示
在main函数内代码出现之前是大段的头文件stdio.h unistd.h stdlib.h的依次展开。展开的具体流程概述如下(以stdio.h为例):CPP先删除指令#include <stdio.h>,并到Ubuntu系统的默认的环境变量中寻找stdio.h,最终打开路径/usr/include/stdio.h下的stdio.h文件。若stdio.h文件中使用了#define语句,则按照上述流程继续递归地展开,直到所有#define语句都被解释替换掉为止。除此之外,CPP还会进行删除程序中的注释和多余的空白字符等操作,并对一些值进行替换。
2.4 本章小结
本章主要介绍了预处理的概念及作用、并结合hello.c文件预处理之后得到的hello.i程序对预处理结果进行了解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译是指C编译器ccl通过词法分析和语法分析,将合法指令翻译成等价汇编代码的过程。通过编译过程,编译器将文本文件hello.i翻译成汇编语言文件hello.s,在hello.s中,以文本的形式描述了一条条低级机器语言指令。
3.1.2 编译的作用
将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
根据PPT要求,在Ubuntu系统下,进行预处理的命令为:
gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
图 3-1 编译过程
3.3 Hello的编译结果解析
3.3.1 文件结构分析
对hello.s文件整体结构分析如下:
表格 2 hello.s文件结构
内容 | 含义 |
.file | 源文件 |
.text | 代码段 |
.section .rodata | 存放只读变量 |
.align | 对齐方式 |
.global | 全局变量 |
.type | 表示是函数类型/对象类型 |
.size | 表示大小 |
.long .string | 表示是long类型/string类型 |
3.3.2 数据类型
在hello.s中,涉及的数据类型包括以下三种:整数,字符串,数组。下面对每种数据类型依次进行分析。
一、整数
在hello.s中,涉及的整数有:
- int i
查看C语言文件可知,i为int型局部变量。编译器将局部变量存储在寄存器或者栈空间中。i作为函数内部的局部变量,并不占用文件实际节的空间,只存在于运行时栈中。对于i的操作就是直接对寄存器或栈进行操作。
在 hello.s 中我们可以看出,i占据了4字节的地址空间:
图 3-2 整数
其中movl表示传送双字,‘l’表示4字节整数。
- int argc
argc是main函数的第一个参数,64位编译下,由寄存器%rdi传入,进而保存在栈中。
图 3-3 argv
- 立即数4
立即数4在汇编语句中直接以$4的形式出现,对应C源程序中if(argc!=4)
图 3-4 立即数
- 字符串
hello.s中保存了两个字符串,分别为:
图 3-5 字符串
两者均为字符串常量,储存在.text 数据段中。\XXX为UTF-8编码,一个汉字对应三个字节。
- 数组
程序中涉及的数组为char *argv[],即函数的第二个参数。在hello.s中,其首地址由寄存器%rsi传入,进而保存在栈中。
图 3-6 数组
访问时,通过如下寄存器寻址的方式访问,-32(%rbp)是数组的首地址即argv[0]的地址,+16得到argv[2]的地址,+8得到argv[1]的地址,这是因为char *数据类型大小为8字节。
图 3-7 数组
3.3.3 赋值操作
1. int i
在for循环中,i首先被赋值为0,然后再经过一系列++操作:
图 3-8 赋值操作
对局部变量的赋值在汇编代码中通过mov指令完成。具体使用哪条mov指令由数据的大小决定,如图所示:
图 3-9 数据传输指令
3.3.4 类型转换
在 C 语言源程序中包含一个类型转换:
atoi完成从字符串到整数的类型转换,返回值保存到%rax中,再传入%rdi作为sleep的参数,接着调用sleep
3.3.5 算数操作
汇编语言中,算数操作的指令包括:
图 3-10 算术操作
图 3-11 算术操作
在hello.s中,具体涉及的算数操作包括:
- subq $32, %rsp 开辟栈帧
- addq $16, %rax 修改地址偏移量
- addq $8, %rax 修改地址偏移量
- addq $24, %rax 修改地址偏移量
- addl $1, -4(%rbp) 实现 i++的操作
3.3.6 关系操作
在 hello.s 中,具体涉及的关系操作包括:
- argv!=4
检查argc是否不等于3。在hello.s中,使用cmpl $4, -20(%rbp),比较 argc
与4的大小并设置条件码,为下一步je利用条件码进行跳转作准备。
- i<8
检查i是否小于8。在hello.s中,使用cmpl $7, -4(%rbp)比较i与7的大小,然后设置条件码,为下一步jle利用条件码进行跳转做准备。
3.3.7 数组操作
如前述3.3.2所述,hello.s中存在数组char *argv[],对其的访问操作通过寄存器寻址方式实现。
3.3.8 控制转移
程序中控制转移的具体表现有两处:
- if(argc!=4)
当argc不等于4时,执行函数体内部的代码。在hello.s中,使用cmpl $4, -20(%rbp),比较argc与4是否相等,若相等,则跳转至.L2,不执行后续部分内容;若不等则继续执行函数体内部对应的汇编代码。
- for(i=0;i<8;i++)
当i < 8时进行循环,每次循环i++。在hello.s中,使用cmpl $7, -4(%rbp),比较i与7是否相等,在 i<=7 时继续循环,进入.L4,i>7时跳出循环。
3.3.9 函数操作
C语言中,调用函数时进行的操作如下:
1. 传递控制:
进行过程Q的时候,程序计数器必须设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
2. 传递数据:
P必须能够向Q提供一个或多个参数,Q必须能够向P中返回一个值。
3. 分配和释放内存:
在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
具体到hello.s中,程序入口处,调用了main函数,其在hello.s中标注为@function函数类型。之后又调用puts,printf,sleep,exit,getchar函数,对函数的调用都通过call指令进行。
图 3-12 分配释放内存
3.4 本章小结
本章介绍了编译的概念与作用,编译是将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备的过程。同时,本章以hello.s文件为例,介绍了编译器如何处理各个数据类型以及各类操作,验证了大部分数据、操作在汇编代码中的实现。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是指汇编器(assembler)将以.s结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在.o目标文件中的过程
4.1.2 汇编的作用
将汇编语言翻译为机器语言,并将相关指令以可重定位目标程序格式保存在.o 文件中
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
在 Ubuntu 下汇编的命令为:
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
汇编过程如下:
图 4-1 汇编过程
4.3 可重定位目标elf格式
在shell中输入readelf -a hello.o > hello1.elf指令获得hello.o文件的ELF格式:
图 4-2 生成elf格式文件
其结构分析如下:
- ELF头(ELF Header):
以16字节序列Magic开始,其描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
图 4-3 ELF头
- 节头:
包含了文件中出现的各个节的意义,包括节的类型、位置和大小等信息。
图 4-4 节头
- 重定位节.rela.text:
一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。在这里,8条重定位信息分别是对.L0(第一个printf中的字符串)、puts函数、exit函数、.L1(第二个printf中的字符串)、printf函数、atoi函数、sleep函数、getchar函数进行重定位声明。
图 4-5 重定位节.rela.text
2.重定位节.rela.eh_frame:
图 4-6 重定位节.rela.eh_frame
3.符号表symbol table
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
图 4-7 符号表
4.4 Hello.o的结果解析
使用objdump -d -r hello.o > asm1.txt分析hello.o的反汇编,并与第3章的hello.s文件进行对照分析。
图 4-8 反汇编hello.o
通过对比asm1.txt与hello.s可知,两者在如下地方存在差异:
1. 分支转移:
在hello.s中,跳转指令的目标地址直接记为段名称,如.L2,.L3等。而在反汇编得到的asm1.txt中,跳转的目标为具体的地址,在机器代码中体现为目标指令地址与当前指令下一条指令的地址之差,如下。
19+14=2d
36+46=7c
82+b4=(1)36
2. 函数调用:
在hello.s文件中,call之后直接跟着函数名称,而在反汇编得到的asm1.txt中,call的目标地址是当前指令的下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器作用才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后相对PC的地址设置为全0(此时,目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待动态链接进一步确定。
3. .rodata数据访问:
在hello.s文件中,使用段名称访问.rodata中数据(printf中的字符串),而在反汇编得到的asm1.txt中,使用0地址进行访问。.rodata中数据地址在链接时才能确定,故需要重定位。在链接时,将0地址替换为绝对地址。
4.5 本章小结
本章介绍了汇编的概念与作用,在Ubuntu下通过实际操作将hello.s文件翻译为hello.o文件,并生成hello.o的ELF格式文件hello.elf,研究了ELF格式文件的具体结构。通过比较hello.o的反汇编代码(保存在asm1.txt中)与hello.s中代码,了解了汇编语言与机器语言的异同之处。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是指通过链接器(Linker),将程序编码与数据块收集并整理成为一个单一文件,生成完全链接的可执行的目标文件(Windows系统下为.exe文件,Linux系统下一般省略后缀名)的过程。
5.1.2 链接的作用
提供了一种模块化的方式,可以将程序编写为一个较小的源文件的集合,且实现了分开编译更改源文件,从而减少整体文件的复杂度与大小,增加容错性,也方便对某一模块进行针对性修改。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
在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 /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o hello.o -lc -z relro
链接过程如下:
图 5-1 链接过程
5.3 可执行目标文件hello的格式
在shell中输入命令readelf -a hello > hello2.elf生成hello程序的ELF格式文件,保存为hello2.elf:
图 5-2 生成hello的elf文件
打开hello2.elf,分析hello的ELF格式如下:
- ELF头(ELF Header):
hello2.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以描述了生成该文件的系统的字的大小和字节顺序的16字节序列Magic开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello1.elf相比较,hello2.elf中的基本信息未发生改变(如 Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
图 5-3 ELF头
- 节头:
hello2.elf中的节头包含了文件中出现的各个节的语义,包括节的类型、位置、偏移量和大小等信息。与 hello1.elf 相比,其在链接之后的内容更加丰富详细(此处仅截取部分展示)。
图 5-4 节头
- 程序头:
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
图 5-5 程序头
- Dynamic section:
图 5-6 Dynamic section
- Symbol table:
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明(此处仅截取部分展示)。
图 5-7 Symbol table
5.4 hello的虚拟地址空间
打开edb,通过data dump查看加载到虚拟地址的程序代码。
图 5-8 hello虚拟地址空间
根据Linux x86-64的特性,虚拟地址空间的起始地址为0x400000,如上图。由hello2.elf中的节头得到.interp段的起始地址为0x4002e0:
在edb中找到.interp段:
图 5-9 .interp
.text段的起始地址为0x4010f0:
在edb中找到.text段:
图 5-10 .text
.rodata段的起始地址为0x402000:
在edb中找到.rodata段:
图 5-11 .rodata
5.5 链接的重定位过程分析
在shell中使用命令objdump -d -r hello > asm2.txt生成反汇编文件asm2.txt,与第四章中生成的asm1.txt文件进行比较,其不同之处如下:
图 5-12 反汇编hello
- 链接后函数数量增加。链接后的反汇编文件asm2.txt中,多出了puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为链接器执行了一些静态链接以用于后续动态链接器将共享库中hello.c用到的函数加入虚拟内存中。
图 5-13 链接后函数数量增加
2.函数调用指令call的参数发生变化。在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
图 5-14 指令call的参数发生变化
4011f9 + fffffe97 = (100)401090
401203 + fffffecd = (100)4010d0
3.跳转指令参数发生变化。
图 5-15 跳转指令参数发生变化
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
使用edb执行hello,持续单步执行,观察遇到的jmp指令和call指令及其操作数,发现子程序则step into进入,该过程中发现了如下图中的子程序:
图 5-16 edb执行hello
图 5-17 edb追踪过程
汇总各子程序及其地址如下:
表格 3 程序名称与程序地址
程序名称 | 程序地址 |
hello!_start | 0x4010f0 |
libc-2.31.so!__libc_start_main | 0x7febeca2af90 |
libc-2.31.so!__cxa_atexit | 0x7febeca4dde0 |
hello!__libc_csu_init | 0x401270 |
hello!_init | 0x401000 |
frame_dummy | 0x4011d0 |
hello!register_tm_clones | 0x401160 |
libc-2.31.so!_setjmp | 0x7fc8d54b9c80 |
libc-2.31.so!_sigsetjmp | 0x7f4d51077bb0 |
hello!main | 0x4011d6 |
printf@plt | 0x4010a0 |
atoi@plt | 0x4010c0 |
sleep@plt | 0x4010e0 |
getchar@plt | 0x4010b0 |
libc-2.31.so!exit | 0x7f08323daa40 |
5.7 Hello的动态链接分析
编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,在GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。
观察hello2.elf得.got从地址0x403ff0开始。
通过edb查看,在dl_init调用前,其内容如下:
图 5-18 dl_init调用前.got内容
在调用后,其内容变为:
图 5-19 dl_init调用后.got内容
5.8 本章小结
本章中介绍了链接的概念与作用、并得到了链接后的hello可执行文件的ELF格式文本hello2.elf,据此分析了hello2.elf与hello.elf的异同;之后,根据反汇编文件asm2.txt与asm1.txt的比较,加深了对重定位与动态链接的理解。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程是一个正在运行的程序的实例,系统中的每一个程序都运行在某个进程的上下文中。
6.1.2 进程的作用
给应用程序提供两个关键抽象:
1. 一个独立的逻辑控制流,提供一个假象,好像程序独占地使用处理器
2. 一个私有地址空间,提供一个假象,好像程序独占地使用内存系统
6.2 简述壳Shell-bash的作用与处理流程
shell的作用:
shell是一个交互性的应用级程序,它代表用户运行其他程序。shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
shell的处理流程大致如下:
1.shell打印一个命令行提示符,等待用户在stdin上输入命令行,然后对这个命令行求值。
2.对命令行求值的代码首先调用parseline函数,这个函数解析了以空格分隔的命令行参数,并构造最终会传递给execve的argv向量。第一个参数被假设为要么是一个内置的shell命令名,要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。如果最后一个参数是一个“&”字符,那么parseline返回1,表示应该在后台执行该程序(shell不会等待它完成)。否则,它返回0,表示应该在前台执行这个程序(shell会等待它完成)。
3.在解析了命令行之后,eval函数调用builtin_command函数,该函数检查第一个命令行参数是否是一个内置的shell命令,如果是,它就立即解释这个命令,并返回值1。否则返回0。
4.如果builtin_command返回0,那么shell创建一个子进程,并在子进程中执行所请求的程序。如果用户要求在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid函数等待作业终止。当作业终止时,shell就开始下一轮迭代。
6.3 Hello的fork进程创建过程
在shell中输入命令./hello 2022113557 宫名扬 1
fork进程的创建过程如下:首先,带参执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程hello。子进程获取了与父进程的上下文,包括栈、通用寄存器、程序计数器,环境变量和打开的文件相同的一份副本。子进程与父进程的最大区别是有着跟父进程不一样的PID,子进程可以读取父进程打开的任何文件。当子进程运行结束时,父进程如果仍然存在,则执行对子进程的回收,否则就由 init 进程回收子进程。
图 6-1 shell运行hello
6.4 Hello的execve过程
调用函数fork创建新的子进程之后,子进程会调用execve函数,在当前进程的上下文中加载并运行一个新程序hello。execve函数从不返回,它将删除该进程的代码和地址空间内的内容并将其初始化。将私有的区域映射进来,例如打开的文件,代码、数据段,然后将公共的区域映射进来。然后加载器跳转到程序的入口点,即设置PC指向_start地址。_start函数调用系统启动函数__libc_start_main,其调用hello中的main函数,这样,便完成了在子进程中的加载。
6.5 Hello的进程执行
在程序运行时,shell为hello fork了一个子进程,这个子进程与shell有独立的逻辑控制流。在hello的运行过程中,若hello进程不被抢占,则正常执行;若被抢占,则进入内核模式,进行上下文切换,转入用户模式,调度其他进程。直到当hello调用 sleep 函数时,为了最大化利用处理器资源,sleep函数会向内核发送请求将hello挂起,并进行上下文切换,进入内核模式切换到其他进程,切换回用户模式运行抢占的进程。与此同时,将hello进程从运行队列加入等待队列,由用户模式变成内核模式,并开始计时。当计时结束时,sleep函数返回,触发一个中断,使得hello进程重新被调度,将其从等待队列中移出,并内核模式转为用户模式。此时hello进程就可以继续执行其逻辑控制流。
6.6 hello的异常与信号处理
- 在程序正常运行时,打印8次提示信息,输入任意字符结束程序,并回收进程。
图 6-2 正常运行
2. 在程序运行时乱按,包括回车,程序可以正常结束。在程序执行过程中乱按所造成的输入均缓存到stdin,当getchar的时候读出一个’\n’结尾的字串作为输入,hello结束后,stdin中的其他字串会当做shell的命令行输入。
图 6-3 乱按正常
3. 按下Ctrl + C,进程收到SIGINT信号,结束并回收hello进程。
图 6-4 Ctrl + C结束进程
- 按下Ctrl + Z,进程收到SIGSTP信号,shell显示提示信息并挂起hello进程。
图 6-5 Ctrl + Z挂起进程
对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。
图 6-6 ps和jobs查看
在shell中输入pstree命令,可以将所有进程以树状图显示(此处仅展示部分):
图 6-7 pstree查看
输入kill命令,则可以杀死指定进程:
图 6-8 kill杀死进程
或者输入fg %1命令将hello进程再次调到前台执行,shell显示进程continue,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
图 6-9 fg继续运行
6.7本章小结
本章介绍了进程的概念与作用,以及Shell-bash的基本概念。针对进程,在这一章中根据hello可执行文件的具体示例研究了fork,execve函数的原理与执行过程,并给出了hello带参执行情况下各种异常与信号处理的结果。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
1. 逻辑地址
逻辑地址由两个部分组成:段选择符和偏移量。这两者一起构成了逻辑地址,指向内存中的一个具体位置。
2. 线性地址
逻辑地址经过段机制转化后为线性地址,其为处理器可寻址空间的地址,用于描述程序分页信息的地址。具体以hello而言,线性地址标志着hello应在内存上哪些具体数据块上运行。
3. 虚拟地址
虚拟地址即为上述线性地址。
4. 物理地址
真实的物理内存对应地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器从逻辑地址到线性地址的变换通过段式管理的方式实现。每个程序在系统中都保存着一个段表,段表保存着该程序各段装入主存的状况信息,包括段号或段名、段起点、装入位、段的长度、主存占用区域表、主存可用区域表等,从而方便进行段式管理。在段寄存器中,存放着段选择符,可以通过段选择符来得到对应段首地址。
段选择符是一个16位的值,它由一个索引和一个表(通常是全局描述符表或局部描述符表)标识。这个选择符用来指定一个段的起始地址和访问权限。
通过一个索引,可以定位到段描述符,进而通过段描述符得到段基址。段基址与偏移量结合就得到了线性地址,虚拟地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU中的一个控制寄存器,页表基址寄存器(PTBR)指向当前页表。n位的虚拟地址即线性地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个(n一p)位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE。例如,VPN 0选择PTE 0,VPN 1选择PTE 1,以此类推。将页表条目中物理页号(PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。因为物理和虚拟页面都是P字节的,所以物理页面偏移(PPO)和VPO是相同的。
图 7-1 页式管理
若PTE的有效位为1,则发生页命中,可以直接获取到物理页号PPN,PPN与PPO共同组成物理地址。若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中,产生缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面。再返回到原来的进程,再次调用导致缺页的指令。此时发生页命中,获取到PPN,与PPO共同组成物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。如图所示,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由 VPN中剩余的位组成的。
图 7-2 TLB
36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN 1提供到一个Ll PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,以此类推。第四级页表中PTE包含一个40位的PPN,它指向物理内存中某一页的基地址,与PPO组成物理地址。
图 7-3 四级页表
7.5 三级Cache支持下的物理内存访问
三级Cache结构如下图:
图 7-4 三级Cache结构
参数S和B将m个地址位分为了三个字段,如图所示。A中s个组索引位是一个到S个组的数组的索引。第一个组是组0,第二个组是组1,依此类推。组索引位被解释为一个无符号整数,它告诉我们这个字必须存储在哪个组中。一旦我们知道了这个字必须放在哪个组中,A中的t个标记位就告诉我们这个组中的哪一行包含这个字(如果有的话)。当且仅当设置了有效位并且该行的标记位与地址A中的标记位相匹配时,组中的这一行才包含这个字。一旦我们在由组索引标识的组中定位了由标号所标识的行,那么b个块偏移位给出了在B个字节的数据块中的字偏移。如果缓存不命中,那么它需要从存储器层次结构中的下一层取出被请求的块,然后将新的块存储在组索引位指示的组中的一个高速缓存行中。一般而言,如果组中都是有效高速缓存行了,那么必须要驱逐出一个现存的行。
图 7-5 Cache结构
在7.4中我们已经由虚拟地址VA转换得到了物理地址PA,首先使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量 CO 取出相应的数据后返回。若没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中请求数据(请求顺序为 L2 Cache→L3 Cache→主存,若仍不命中才继续向下一级请求)。查询到数据之后,需要对数据进行读入,一种简单的放置策略如下:若映射到的组内有空闲块,则直接放置在空闲块中,若当前组内没有空闲块,则产生冲突(evict),采用最不常使用(LFU)策略进行替换。
图 7-6 物理内存访问
7.6 hello进程fork时的内存映射
当fork函数被当前进程hello调用时,内核为新进程hello创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的hello创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当着两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数加载并运行hello需要以下几个步骤:
1. 删除已存在的用户区域
删除当前进程hello虚拟地址的用户部分中的已存在的区域结构。
2. 映射私有区域
为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。其中,代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
3. 映射共享区域
若hello程序与共享对象或目标(如标准C库libc.so)链接,则将这些对象动态链接到hello程序,然后再映射到用户虚拟地址空间中的共享区域内。
4. 设置程序计数器
最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
1)虚拟地址A是合法的吗?换句话说,A在某个区域结构定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在图中标识为“1”。
因为一个进程可以创建任意数量的新虚拟内存区域,所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux使用某些我们没有显示出来的字段,Linux在链表中构建了一棵树,并在这棵树上进行查找。
2)试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这种情况在图中标识为“2”。
3)此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
图 7-7 缺页处理
7.9动态存储分配管理
动态内存分配器维护着一个称为堆的进程的虚拟内存区域。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
具体而言,分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
下面介绍动态存储分配管理中较为重要的概念:
1. 隐式链表
堆中的空闲块通过头部中的大小字段隐含地连接,分配器通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
对于隐式链表,其结构如下:
图 7-8 隐式链表的结构
2. 显式链表
在每个空闲块中,都包含一个前驱(pred)与后继(succ)指针,从而减少了搜索与适配的时间。
显式链表的结构如下:
图 7-9 显式链表的结构
3. 带边界标记的合并
采取使用边界标记的堆块的格式,在堆块的末尾为其添加一个脚部,其为头部的副本。添加脚部之后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。从而实现快速合并,减小性能消耗。
4. 分离存储
维护多个空闲链表,其中,每个链表的块具有相同的大小。将所有可能的块大小分成一些等价类,从而进行分离存储。
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接口及其函数
8.2.1 Unix I/O 接口:
1. 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2. Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和 STDERR_FILENO,它们可用来代替显式的描述符值。
3. 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
4. 读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k十n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5. 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 Unix I/O 函数:
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的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
4. ssize_t wirte(int fd, const void *buf, size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
printf函数的函数体如下:
图 8-1 printf
形参列表中的...是可变形参的一种写法,当传递参数的个数不确定时,用这种方式来表示。
va_list的定义:typedef char *va_list ,说 明它是一个字符指针,其中(char*)(&fmt) + 4即arg表示的是...中的第一个参数。
再进一步查看vsprintf函数体:
图 8-2 vsprinf
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
printf函数的功能为接受一个格式化命令,并按指定的匹配的参数格式化输出, i = vsprintf(buf, fmt, arg)是得到打印出来的字符串长度,其后的write(buf, i)是将buf中的i个元素写到终端。
因此,vsprintf的作用为接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,进而产生格式化输出。
再进一步对 write 进行追踪:
图 8-3 write
这里给几个寄存器传递了参数,然后以一个int INT_VECTOR_SYS_CALL结束。INT_VECTOR_SYS_CALL代表通过系统调用syscall,查看syscall的实现:
图 8-4 syscall
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,符显示驱动子程序:从ASCII 字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar的源代码如下:
图 8-5 getchar
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法和及其接口和函数,对printf函数和getchar函数的底层实现有了基本了解。
(第8章1分)
结论
hello程序的一生经历了如下过程:
1. 预处理
将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中,完成字符串的替换,得到hello.i,方便后续处理;
2. 编译
通过词法分析和语法分析,将合法指令翻译成等价汇编代码。通过编译过程,编译器将hello.i翻译成汇编语言文件hello.s;
3. 汇编
将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o目标文件中;
4. 链接
通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello;
5. 加载运行
打开shell,在其中输入./hello 2022113557 宫名扬 1,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;
6. 执行指令
在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器逐步更新,CPU按照控制逻辑流执行指令;
7. 访存
内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存中的数据;
8. 动态申请内存
printf会调用malloc向动态内存分配器申请堆中的内存;
9. 信号处理
进程时刻等待着信号,如果运行途中键入ctrl-c ctrl-z则调用shell的信号处理函数分别进行停止、挂起等操作,对于其他信号也有相应的操作;
10. 终止并被回收
shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 描述 |
hello.i | 预处理后得到的ASCII码的中间文件 |
hello.s | 编译后得到的ASCII汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello1.elf | 用readelf读取hello.o得到的ELF格式信息 |
asm1.txt | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
asm2.txt | 反汇编hello可执行文件得到的反汇编文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版
社.2018.4
[2] printf 函数实现的深入剖析
https://www.cnblogs.com/pianist/p/3315801.html
[3] 内存管理:物理地址、虚拟地址、逻辑地址
https://blog.csdn.net/weixin_48524215/article/details/125589384
(参考文献0分,缺失 -1分)