计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 网络空间安全
学 号 2022112241
班 级 2203901
学 生 孙浩馨
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
本文围绕hello.c程序而展开,研究了这一最基本c语言代码在Linux系统下的P2P,020的生命周期,对hello.c文件经过计算机的预处理、编译、汇编、链接、加载运行、执行、访问内存、动态申请内存、信号处理、终止与回收的全过程,从而了解hello.c的完整一生。通过对hello.c的研究,对计算机系统课程的体系进行了回顾与串联,实现了对计算机体系的深入理解。
关键词:计算机系统; hello.c;计算机体系结构 ;程序生命周期 ;
目 录
第1章 概述
1.1 Hello简介
-
-
- P2P
P2P即 From Program to Progress,从程序到进程。当hello被输入到计算机中,会被存储为hello.c的源代码文件。hello.c源代码经过cpp预处理后,解决了包括头文件引用等问题,生成了文本文件hello.i。hello.i通过cc1编译生成了汇编语言文件hello.s。hello.s经过as汇编将代码转换为了可执行的机器码,再通过ld将编译后的目标文件与系统库连接,形成可执行文件hello。
进程管理中,操作系统(OS)通过fork()系统调用为hello创建了一个新的进程。OS调用execve()加载了hello的可执行文件,并开始执行。内存管理单元(MMU)和操作系统为hello提供了虚拟地址到物理地址的映射,以及内存管理的支持,包括TLB,页表等。IO管理和信号处理允许hello与外部设备进行交互。hello利用CPU、RAM和IO资源,在计算机硬件上运行,并通过OS提供的各种服务实现其功能。 - 020
020即From Zero to Zero,从“零”到“零”。hello在被创造出来时,是一张白纸,即“From Zero”。当hello经过了P2P的过程后,成为了一个完整的程序,在程序执行完成后,hello进程被回收,与hello相关的所有状态信息与数据被清除,即“to Zero”。
- P2P
-
1.2 环境与工具
1.2.1 硬件环境
CPU:11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz 2.30 GHz
RAM:16GB
1.2.2 软件环境
Windows11 64位
VMware Workstation Pro 17.5.2
Ubuntu 22.04.3
1.2.3 调试工具
Visual Studio 2022
cpp
gcc
as
ld
readelf
gdb
edb
1.3 中间结果
文件名 | 作用 |
hello.i | hello.c预处理后得到的文本文件 |
hello.s | hello.i编译后得到的汇编代码 |
hello.o | hello.s汇编得到的可重定位目标文件 |
helloo.elf | readelf读取hello.o得到的文本 |
helloo.asm | objdump反汇编hello.o得到的反汇编文件 |
hello | hello.o链接后得到的可执行文件 |
hello.elf | readelf读取hello得到的文本 |
hello.asm | objdump反汇编hello得到的反汇编文件 |
1.4 本章小结
本章首先简要解释了P2P,020的含义,然后列出了本文研究所使用的软硬件环境,最后列出了研究中所生成的中间结果文件以及其作用。
第2章 预处理
2.1 预处理的概念与作用
预处理(Preprocessing)是编译过程中的第一个阶段,在源代码被编译之前执行。它的主要作用是对源代码进行一些文本上的处理,以准备后续的编译工作。预处理器会扫描源代码文件,并根据预定义的规则执行以下一系列操作:
宏替换(Macro substitution): 处理源代码中的宏定义,将宏名称替换为其对应的值,使代码更加易读并减少代码的重复性。
文件包含(File inclusion): 处理源代码中的文件包含指令(如#include),将指定的文件内容插入到当前文件中,提高代码的可维护性和重复性。
条件编译(Conditional compilation): 根据条件判断指令(如#ifdef、#ifndef、#if、#elif、#else和#endif)来选择性地包含或排除部分代码,根据不同的编译选项或环境控制代码的编译行为。
行连接(Line splicing): 将跨越多行的代码连接成一行,增加代码的可读性。
注释删除(Comment stripping): 删除源代码中的注释,减少编译后的文件大小。
其他预处理指令处理: 处理其他预处理指令,如#error、#pragma等。
预处理完成后,生成的输出通常是另一个经过预处理后的源代码文件,其中已经包含了预处理器所执行的所有操作。这个经过预处理的文件将作为编译器的输入,进行后续的编译工作。
2.2在Ubuntu下预处理的命令
在Ubuntu下可以通过cpp hello.c > hello.i
来预处理hello.c,得到hello.i文本。
预处理执行命令截图如下:
图2-1 预处理命令截图
2.3 Hello的预处理结果解析
hello.c源文件只含有如下的24行代码。
图2-2 hello.c文件截图
经过预处理后得到hello.i。
图2-3 hello.i文件截图
经过预处理后,代码扩展至3061行。
1——7行代码中是源代码文件中相关信息,14——3047行为预处理扩展的内容。hello.i中3048——3061行代码对应hello.c中11——24行主函数代码。
图2-4 hello.i 1-7行代码截图
14——58行为部分文件包含信息。
图2-5 文件包含信息截图
61——94行为部分类型定义信息
图2-6 类型定义信息
329——378行为部分函数声明信息。
图2-7 函数声明信息
14——3047行代码为hello.c中引用的头文件stdio.h,unistd.h,stdlib.h的展开。
以stdio.h论述展开的具体流程如下:
stdio.h为标准库文件,当被引用时,原有的#inlcude <stdio.h>会被删除,cpp会在系统默认的环境变量中寻找stdio.h,找到其路径为/usr/include/stdio.h后打开stdio.h,用stdio.h替换被删除掉的#include<stido.h>段,并对stdio.h中的#include,#define段代码递归执行上述操作直至所有#include,#define全部被替换。同时,cpp还会将代码中的注释与空白字符部分进行删除,并对一些量进行替换。
图2-8 默认的环境变量
2.4 本章小结
本章主要对预处理部分进行了说明,介绍了预处理的概念以及作用,展示了对hello.c文件进行预处理的过程,并对得到的hello.i文件进行了分析。
第3章 编译
3.1 编译的概念与作用
编译(Compilation)是计算机程序开发中的重要步骤,它将高级编程语言(如C、C++、Java等)编写的源代码转换为低级的机器语言或汇编语言程序。在这个过程中,编译器扮演着关键的角色。
编译的主要作用如下:
语法分析与词法分析:编译器首先对经过预处理的源代码进行词法分析和语法分析。词法分析器将源代码分解成一个个的词法单元(tokens),而语法分析器则将这些词法单元组合成语法结构,以验证语法的正确性。
语义分析:编译器进行语义分析,检查源代码中的语义错误,并生成相应的错误信息。
优化:在编译过程中,编译器可能会对源代码进行优化,以提高程序的性能和效率。这包括控制流优化、数据流优化、指令级优化等。
代码生成: 经过语法和语义分析后,编译器将源代码转换成等价的机器语言或汇编语言程序。这个阶段的输出可以是目标机器的汇编代码或是中间表示(Intermediate Representation),它们可以被进一步转换成目标机器的机器码。
生成汇编语言程序: 在某些编译器中,编译器可能会直接生成汇编语言程序,而不是目标机器的机器码。生成汇编语言程序的过程是将高级语言的源代码转换成汇编语言的等效表示,其中每一条指令都对应着目标机器上的一条指令。
编译的结果是一个与源代码等价的、可执行的程序,它可以被计算机硬件直接执行,或者通过进一步的链接和加载过程转换成可执行文件。总的来说,编译是将高级语言的源代码转换成机器语言或汇编语言的过程,使得计算机可以理解和执行程序。
3.2 在Ubuntu下编译的命令
在Ubuntu下通过gcc -S -o hello.i hello.s将hello.i编译成hello.s。
3.3 Hello的编译结果解析
3.3.1 文件结构
内容 | 含义 |
.file | 源文件 |
.text | 代码段 |
.globl | 全局变量 |
.data | 存放已经初始化的全局和静态C变量 |
.section .rodata | 存放只读变量 |
.align | 对齐方式 |
.type | 表示是函数类型/对象类型 |
.size | 表示大小 |
.long | 表示是long类型 |
.string | 表示是string类型 |
3.3.2 数据类型
(1)常量数据
a)printf中的输出,格式字符串都被存放在.rodata中
hello.c源程序中代码:
hello.s汇编程序中代码:
b)if条件判断值、for循环终止条件值在.text段
hello.c源程序中代码:
hello.s汇编程序中代码:
- 变量数据
- 全局变量:程序中无全局变量
- 局部变量:局部变量i(4字节int型)在运行时保存在栈中,使用一条movl指令进行赋值,使用一条addl指令进行增一。
hello.c源程序中代码:
hello.s汇编程序中代码:
局部变量i在赋初值后被保存在地址为%rbp-4的栈位置上。
3.3.3 算数操作
for循环体中,对循环变量i的更新使用了++自增运算,汇编代码翻译成addl指令(4字节int型对应后缀“l”):
hello.c源程序中代码:
hello.s汇编程序中代码:
3.3.4 关系操作与控制转移
(1)if条件判断
hello.c源程序中代码:
hello.s汇编程序中代码:
je使用cmpl设置的条件码(ZF),若ZF = 0,说明argc等于5,条件不 成立,控制转移至.L2(for循环部分,程序主体功能);若ZF = 1,说明argc 不等于5(即执行程序时传入的参数个数不符合要求),继续执行输出提示信 息并退出。
(2)for循环条件终止
hello.c源程序中代码:
hello.s汇编程序中代码:
此处jle使用cmpl设置的条件码(ZF SF OF),若(SF^OF) | ZF = 1,说明循环终止条件不成立(变量i的值小于或等于9),控制转移至.L4,继续执行循环体;若(SF^OF) | ZF = 0,则循环终止条件成立(变量i的值达到10),不再跳转至循环体开始位置,继续向后执行直至退出。
值得注意的是,源程序代码的逻辑与编译器翻译生成的逻辑有细微的差别。源代码中判断i<10,而编译器将其调整为判断i<=9,但实际上二者等价。
3.3.5 数组、指针、结构操作
数组*argv[]为参数字符串数组指针,在数组*argv[]中,argv[0]为输入程序的路径和名称字符串起始位置,argv[1]、argv[2]、argv[3]依次为后面三个参数字符串的起始位置。
hello.s汇编程序中代码:
将main()的第二个参数从寄存器写到了栈空间中。
从栈上取这一参数,并按照基址-变址寻址法访问argv[1]、argv[2]、argv[3](由于指针char*大小为8字节,分别偏移8、16、24字节来访问)。
3.3.6 函数操作
hello.c源文件中含有main主函数、printf()函数、exit()函数、sleep()函数、atoi()函数。
- main函数
a) 参数传递:
汇编代码:
在写入栈空间时,第一个参数通过寄存器EDI传递,第二个参数通过寄存器RSI传递。
b) 函数调用
此处对程序入口进行标记。
c) 函数返回
参数不正确返回值为1,调用exit()函数。
参数正确返回0。
- printf()函数
a) 参数传递:需要输出的字符串
源代码:
汇编代码:
b) 函数调用:由主函数通过call调用。
c) 函数返回:返回输出的字符数量。
- exit()函数
a) 参数传递:退出状态值1
b) 函数调用:由主函数通过call调用。
c) 函数返回:无返回值,正常终止程序,返回一个状态代码给调用程序或操作系统
- sleep()函数
- 参数传递:
源代码:
汇编代码:
- 函数调用:由主函数通过call调用。
- 函数返回:返回实际休眠时间。
- atoi()函数
- 参数传递:
源代码:
汇编代码:
- 函数调用:由主函数通过call调用。
- 函数返回:参数字符串被转换后的整数值。
- getchar()函数
- 参数传递:
源代码:
汇编代码:
- 函数调用:由主函数通过call调用。
- 函数返回:返回char类型值。
3.4 本章小结
本章由对编译进行简要的介绍开始,然后展示了编译的过程,后又对编译的结果进行了逐步的分析。
第4章 汇编
4.1 汇编的概念与作用
汇编(Assembly)是将汇编语言程序转换成机器语言二进制程序的过程。在这个过程中,汇编器(Assembler)扮演着关键的角色。
汇编的主要作用包括:
转换为机器语言: 汇编器将汇编语言程序翻译成对应的机器语言指令序列,这些指令直接由计算机硬件执行。
生成目标文件: 汇编器生成的输出通常是目标文件,其中包含了转换后的机器语言指令。目标文件通常是二进制格式的文件,可以被进一步处理。
符号解析和地址计算: 汇编器负责解析程序中的符号(如标签、变量名等),并计算它们的地址。这是为了确保生成的机器代码能够正确地引用和访问这些符号所代表的内存位置。
代码和数据段分配: 汇编器将程序中的代码段(包含可执行指令)和数据段(包含静态数据)分配到内存中的不同区域,并生成相应的指令和数据的存储地址。
处理伪指令和指令格式: 汇编器处理汇编语言中的伪指令(如宏指令、伪操作符等)以及指令格式(如操作码、操作数等),将它们转换成对应的机器语言表示。
生成可链接的目标文件: 汇编器生成的目标文件通常是可链接的,可以被链接器进一步处理,与其他目标文件进行链接,形成最终的可执行文件。
总的来说,汇编的作用是将汇编语言程序转换成机器语言二进制程序,使得计算机可以直接执行。它是编译过程中的一个重要阶段,负责将高级语言或汇编语言转换成机器可执行的指令序列。
4.2 在Ubuntu下汇编的命令
通过gcc -c hello.s -o hello.o将hello.s汇编成hello.o。
4.3 可重定位目标elf格式
通过readelf -a hello.o>helloo.elf得到hello.o的elf格式:
4.3.1 elf头:
以 16字节序列 Magic 开始,其描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
4.3.2 节头:
包含了文件中出现的各个节的意义,包括节的类型、位置和大小等信息。
4.3.3 重定位节:
重定位节记录了各段引用的符号相关信息,在链接时,需要通过重定位节对这些位置的地址进行重定位。链接器会通过重定位条目的类型判断如何计算地址值并使用偏移量等信息计算出正确的地址。本程序需要重定位的符号有:.rodata,puts,exit,printf,atoi,sleep,getchar及.text。注意到重定位类型仅有R_X86_64_PC32(PC相对寻址)和R_X86_64_PLT32(使用PLT表寻址)两种,而未出现R_X86_64_32(绝对寻址)。
4.3.4 符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
4.4 Hello.o的结果解析
通过objdump -d -r hello.o > helloo.asm 得到hello.o的反汇编,并与第3章的 hello.s进行对照分析。
4.4.1 分支转移:
在 hello.s 中,分支跳转的目标位置是通过 .L1、.L2 这样的助记符来实现的,而 hello.o中,跳转的目标位置是具体的数值。但注意这个数值还不是具体的一个地址,因为此时还没进行链接,它是通过重定位条目进行计算得来的,是一个相对的地址值,由于不同文件代码链接合并和,一个文件本身的代码的相对地址不会改变,所以不需要与外部重定位,而可以直接计算出具体的数值,因此这里就已经完成了所有的操作,这条语句将以这种形式加载到内存中被cpu读取与执行。
4.4.2 函数调用:
在hello.s中,用call指令进行调用函数时,总会在call指令后直接加上函数名,而通过.o文件反汇编得到的汇编代码中,call指令后会跟着的是函数通过重定位条目指引的信息,由于调用的这些函数都是未在当前文件中定义的,所以一定要与外部链接才能够执行。在链接时,链接器将依靠这些重定位条目对相应的值进行修改,以保证每一条语句都能够跳转到正确的运行时位置。
4.4.3 操作数:
反汇编代码中的立即数是十六进制数,而 hello.s 文件中的数是十进制的。寄存器寻址两者相同。内存引用 hello.s 中会用伪指令(如.LC0)代替,而反汇编则是基址加偏移量寻址:0x0(%rip)。
4.5 本章小结
本章首先简要介绍了汇编的概念与作用,并展示了汇编的具体过程,并对汇编后的文件进行分析,介绍了可重定位文件的格式以及各部分的作用,最后对hello.o的结果进行解析,对hello.o文件和hello.s差异进行辨析。
第5章 链接
5.1 链接的概念与作用
链接(Linking)是指将多个目标文件或库文件链接在一起,形成一个可执行文件或共享目标文件的过程。在这个过程中,链接器(Linker)会解析各个目标文件之间的符号引用和定义关系,然后将它们合并成一个整体,最终生成一个可执行文件或共享目标文件。过程包括解析符号、解决符号引用、地址重定位等步骤,最终生成一个完整的可执行文件,其中包含了程序的所有代码和数据,以便在计算机上执行。
链接的作用包括:
符号解析:链接器通过符号表解析各个目标文件中使用的符号,并将其与相应的定义进行关联。这包括全局变量、函数、外部库函数等。
符号重定位:在链接过程中,链接器会调整各个目标文件中的符号地址,以便将它们正确地映射到最终的内存地址上。
库链接:链接器还负责将程序所需的库文件链接到程序中,以便在运行时能够正确地调用库中的函数和符号。
5.2 在Ubuntu下链接的命令
通过ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o hello.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -o hello命令得到hello
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
通过readelf -a hello>hello.elf得到hello.elf。
5.3.1 elf头
5.3.2 节头
5.3.3 程序头
5.3.4 段节
5.3.5 动态section
5.3.6 重定位节
5.3.7 符号表
5.4 hello的虚拟地址空间
用EDB查看hello的虚拟地址 。
虚拟地址范围为0x401000至0x402000。
通过symbol一一对照,得到虚拟地址与节头部表的对应关系。
5.5 链接的重定位过程分析
通过objdump -d -r hello > hello.asm得到反汇编文件hello.asm。
不同之处:
- hello.o的反汇编中只含有.text节,而hello的反汇编中还有.init,.plt,.plt.sec。
- 在hello中链接加入了exit、printf、sleep、getchar等在hello.c中用到的库函数。
- hello中不再存在hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
使用EDB开始执行hello。
根据symbol说明程序执行的函数以及其地址。
函数名 | 函数地址 |
.init | 0x401000 |
.plt | 0x401020 |
puts@plt | 0x401030 |
printf@plt | 0x401040 |
getchar@plt | 0x401050 |
atoi@plt | 0x401060 |
exit@plt | 0x401070 |
sleep@plt | 0x401080 |
_start | 0x4010f0 |
main | 0x401125 |
.fini | 0x401248 |
5.7 Hello的动态链接分析
编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,在GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。
调用dl_init之前.got.plt段的内容:
调用dl_init之后.got.plt段的内容:
5.8 本章小结
本章首先介绍了链接的概念与作用,然后对文件进行链接,并对可执行文件hello的格式进行了分析,然后查看了hello的虚拟地址,并对链接的重定位过程进行了分析,概述了hello的执行流程,并对hello的动态链接进行了分析。
第6章 hello进程管理
6.1 进程的概念与作用
进程是程序执行的一个实例。一个程序在运行时会被操作系统加载到内存中,并分配一个独立的执行环境,这个执行环境就是一个进程。进程是操作系统进行资源分配和调度的基本单位,它具有以下特点和作用:
独立性: 每个进程都拥有独立的地址空间,使得它们彼此之间不会相互干扰。进程之间通常是隔离的,一个进程的崩溃不会影响其他进程的正常运行。
并发执行: 操作系统可以同时运行多个进程,每个进程都在自己的执行环境中独立执行。这种并发执行的方式使得计算机系统可以更有效地利用多核处理器和其他硬件资源。
资源分配: 操作系统为每个进程分配了一定的系统资源,包括内存空间、CPU时间、文件描述符等。进程可以通过操作系统提供的接口来请求和释放这些资源。
调度和管理: 操作系统负责对进程进行调度和管理,以确保系统资源被合理地分配和利用。这包括进程的创建、销毁、挂起、恢复以及切换等操作。
通信与同步: 进程之间可以通过各种机制进行通信和同步,包括共享内存、消息队列、管道、信号量、锁等。这使得不同进程之间可以进行数据交换和协作,实现复杂的任务分解和并发处理。
程序执行环境: 每个进程都有自己的程序执行环境,包括代码、数据、堆栈、寄存器状态等。操作系统负责管理这些执行环境,并在需要时进行切换和调度。
6.2 简述壳Shell-bash的作用与处理流程
壳是用户与操作系统内核之间的接口,它接收用户输入的命令并将其转换成操作系统内核能够理解和执行的指令。
壳的主要作用是将我们的指令翻译给OS内核,让内核来进行处理,并把处理的结果反馈给用户。(Windows下的壳程序就是图形化界面)shell的存在使得用户不会直接操作OS,保证了OS的安全性。
壳的处理流程通常包括以下步骤: 壳从标准输入(通常是终端)读取用户输入的命令,对用户输入的命令进行解析,分析命令的结构和含义。根据解析后的命令调用相应的系统程序或应用程序进行执行。如果是内建命令(如cd、echo等),则直接在壳内部执行。根据命令中的I/O重定向符号(如<、>、|等)对输入输出进行重定向处理。 如果命令中包含管道符号(|),壳将多个命令连接起来,形成管道,将前一个命令的输出作为后一个命令的输入。检测并处理命令执行过程中可能出现的错误,并将错误信息输出给用户。等待用户输入命令并执行,直到用户退出。
6.3 Hello的fork进程创建过程
输入命令后,shell会判断该命令不是内部指令,转而通过fork函数创建一个子进程hello。hello会得到一份包括数据段、代码、共享库、堆、用户栈等均与父进程相同且独立的副本。同时子进程还会获得与父进程打开任何文件描述符相同的副本,这表示当父进程调用fork时子进程可以读写父进程的内容。父进程和子进程只有PID不同,在父进程中,fork返回子进程的PID,在子进程中,fork返回0.
6.4 Hello的execve过程
在fork创建子进程后,execve函数会加载并运行可执行目标文件hello,删除当前进程虚拟地址的用户部分中的已存在的区域结构。然后为hello程序的代码、数据、bss和栈区域创建新的私有的、写时复制的数据结构,然后将标准库libc.so映射到用户虚拟地址空间中的共享区域内。最后设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点,即设置PC指向_start地址。
6.5 Hello的进程执行
6.5.1 上下文:
内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
6.5.2 进程上下文切换:
在内核调度了一个新的进程运行时,它就抢占当前进程,并使用一种上下文切换的机制来控制转移到新的进程。具体过程为:①保存当前进程的上下文;②恢复某个先前被抢占的进程被保存的上下文;③将控制传递给这个新恢复的进程。
6.5.3 进程时间片:
一个进程执行它的控制流的一部分的每一个时间段叫做时间片(time slice),多任务也叫时间分片(time slicing)。
6.5.4 进程调度:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这种决策称为调度,是由内核中的调度器代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
hello程序调用sleep函数休眠时,内核将通过进程调度进行上下文切换,将控制转移到其他进程。当hello程序休眠结束后,进程调度使hello程序重新抢占内核,继续执行。
6.5.5 用户态与核心态的转换:
为了保证系统安全,需要限制应用程序所能访问的地址空间范围。因而存在用户态与核心态的划分,核心态拥有最高的访问权限,而用户态的访问权限会受到一些限制。处理器使用一个寄存器作为模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,一定程度上保证了系统的安全性。
6.5.6 hello程序进程执行示意图:
6.6 hello的异常与信号处理
6.6.1 程序正常执行
程序正常运行时,循环输出十次消息即停止输出,并结束程序回收进程。
6.6.2 不停乱按
当程序运行时不停乱按,程序仍能正常输出十次信息,且程序结束后输入的信息被当作命令输入。
6.6.3 输入回车
输入回车后程序仍能正常运行,正常输出十次信息。
6.6.4 输入ctrl+c
输入后shell进程收到SIGINT信号,程序直接停止运行,并回收hello进程。
6.6.5 输入ctrl+z
输入后shell进程收到SIGSTP信号,程序直接停止运行,并挂起hello进程。
通过ps命令查看证明hello的确不是被回收而是被挂起,且其job代号为1。
输入pstree命令,以树状图显示所有的进程。
再输入kill命令,则可以将该挂起的命令杀死。
使用kill命令前
使用kill命令后
证明该挂起的hello进程已被杀死。
6.7本章小结
本章首先简述了进程的概念与作用,然后概述了壳的作用与处理流程,并对hello的fork进程创建过程以及hello的execve过程进行了介绍,然后对hello的进程执行进行了说明,最后分析了hello的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址(Logical Address):逻辑地址是指程序中使用的地址空间,是相对于进程而言的。它是在程序执行过程中使用的抽象地址,通常由程序员或操作系统内核管理,不直接对应到实际的物理内存位置。逻辑地址是在程序运行时生成的,用于访问进程的虚拟地址空间,而不考虑该地址在物理内存中的具体位置。
线性地址(Linear Address):线性地址是指逻辑地址经过分页机制或段页式内存管理机制转换后得到的地址,也称为虚拟地址(Virtual Address)。在分页系统中,逻辑地址被分为页号和页内偏移量,通过页表将页号转换为物理页框号,然后加上页内偏移量得到线性地址。在段页式系统中,逻辑地址被分为段选择符和段内偏移量,通过段描述符将段选择符转换为段基址,然后加上段内偏移量得到线性地址。
虚拟地址(Virtual Address):虚拟地址是指在程序执行时,由程序中的指针所引用的地址空间,通常是相对于进程而言的,与物理内存的实际地址无直接关系。虚拟地址由逻辑地址经过内存管理单元(MMU)转换后得到的线性地址。
物理地址(Physical Address):物理地址是指内存中实际的存储单元的地址,是RAM或其他存储设备上的真实地址。物理地址是在内存访问过程中由内存管理单元(MMU)将线性地址转换为的最终地址,用于实际的数据读写操作。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel x86 架构中,逻辑地址到线性地址的变换涉及到段式管理机制。这个过程有以下步骤:
1.逻辑地址的生成:
2.逻辑地址通常由 CPU 中的段寄存器和偏移量组成。在 x86 架构中,有四个段寄存器:CS(代码段)、DS(数据段)、SS(堆栈段)和ES(附加数据段)。偏移量表示在指定段内的位置。
3.段选择符解析:
4.CPU 使用逻辑地址中的段选择符从全局描述符表(GDT)或局部描述符表(LDT)中获取相应的段描述符。段选择符包含了段描述符在描述符表中的索引。
5.段描述符解析:
6.从描述符表中获取到的段描述符包含了段的基址和长度等信息。段基址是一个32位的线性地址,用于指示段在内存中的起始位置。段长度则决定了段的大小。
7.线性地址的计算:
8.将段基址与偏移量相加,得到线性地址。这个线性地址是一个32位地址,用于访问物理内存。
9.地址转换和访问:
10.当 CPU 访问内存时,将逻辑地址转换为线性地址。这个线性地址通过内存管理单元(MMU)进行转换,最终映射到物理内存中的相应位置。
这个过程中,段式管理机制允许操作系统将程序的地址空间划分为多个段,每个段可以具有不同的访问权限和大小。这样可以更灵活地管理内存,并提供更好的安全性和隔离性。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(VA)到物理地址(PA)的转换通过虚拟地址内存空间的分页机制实现。首先,从段式管理中获得线性地址,然后将其划分为虚拟页号(VPN)和虚拟页偏移量(VPO)。VPN指示了页面在虚拟地址空间中的位置,而VPO指示了页面内的具体偏移量。由于虚拟内存与物理内存的页大小相同,所以VPO与物理页偏移量(PPO)一致。
然后,需要通过访问页表中的页表条目(PTE)来获取物理页号(PPN)。如果PTE的有效位为1,则发生页命中,可以直接获取到物理页号PPN。PPN与PPO组合成物理地址。
然而,如果PTE的有效位为0,则表示对应的虚拟页没有缓存在物理内存中,会触发缺页故障。在这种情况下,操作系统的内核会介入,执行缺页处理程序。该程序会确定牺牲页,并将新的页面调入物理内存。随后,控制返回到原始进程,并重新执行导致缺页的指令。这时候发生了页命中,可以获取到PPN,然后与PPO组合成物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB:
每当CPU生成一个虚拟地址时,MMU需要检索相关的PTE以将其转换为物理地址。在最糟糕的情况下,这个过程需要额外的内存访问,消耗几十到几百个周期。但如果MMU的TLB中恰好缓存了PTE,这个开销就能降低到1或2个周期。因此,许多系统都引入了TLB,它是一个小型的PTE缓存,用于减少内存访问的开销。
多级页表:
多级页表采用了层次结构,以减少对内存的需求。首先,如果一级页表中的某个PTE为空,那么对应的二级页表就不需要存在,从而节省了内存空间。其次,只有最顶层的一级页表必须一直驻留在主存中,而其他级别的页表可以根据需要在主存和辅存之间进行调度,最常使用的二级页表则会被缓存在主存中,有效减轻了主存压力。
VA到PA的转换:
对于四级页表,虚拟地址(VA)被划分为4个VPN和1个VPO。每个VPN i都是到第i级页表的索引。在前三级页表中,每个PTE指向下一级的页表基址。而在最底层的页表中,每个PTE则包含某个物理页面的PPN,或者指向磁盘块的地址。在构建物理地址之前,MMU需要访问k个PTE。与单级页表相似,PPO和VPO具有相同的值。
7.5 三级Cache支持下的物理内存访问
首先,根据高速缓存的组数和块大小来确定高速缓存块的偏移量(CO)、组索引(CI)和高速缓存标记(CT)。通过组索引,将数据行与高速缓存标记进行匹配。如果匹配成功且该数据行的有效位为1,则表示发生了命中,此时可以根据偏移量取出数据并将其返回给CPU。
如果在当前级别的高速缓存中未找到匹配的数据行,或者找到的数据行的有效位为0,则表示发生了未命中。此时,系统将会继续在下一级高速缓存(如L2)中进行类似的查找操作。如果在L2中仍然未找到匹配项,则继续在更低一级的高速缓存(例如L3)中进行查找。如果直到三级高速缓存均未命中,则需要访问主存来获取所需数据。
如果发生了高速缓存未命中,意味着至少有一级高速缓存未能存储所需数据。在从更低级别的存储层次获取数据后,系统需要更新未命中的高速缓存。首先,系统会检查是否存在空闲的高速缓存块,即有效位为0的块。如果存在空闲块,则可以直接将获取的数据写入其中。如果不存在空闲块,则需要根据替换策略(例如最近最少使用或最不经常使用)选择一个块进行替换,然后将获取的数据写入该块中。
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 缺页故障与缺页中断处理
缺页概念:在虚拟内存系统中,当DRAM缓存中未找到所需的页面时,被称为缺页。缺页是一种异常情况,属于可恢复错误的范畴。相关处理流程可以在本文的第6.6节中找到。
处理流程:缺页异常触发内核中的缺页异常处理程序。该程序首先会选择一个牺牲页,如果该牺牲页已经被修改,则内核会将其写回磁盘。随后,内核会从磁盘中复制导致缺页异常的页面到内存中,并更新相应的页表项,将其指向这个新复制的页面。处理程序执行完成后,内核将重新启动导致缺页的指令。该指令将重新发送导致缺页的虚拟地址给地址翻译硬件,这次访问会命中页面。
这样处理的结果是,在缺页异常处理程序的调用和重新发送缺页地址之后,系统能够成功地将缺页的页面复制到内存中,并更新页表项,以便后续的访问可以命中所需的页面。
7.9动态存储分配管理
动态内存分配管理是通过动态内存分配器进行的。这个分配器负责管理进程的虚拟内存区域,也称为堆。它将堆看作是一系列不同大小的块的集合,每个块都是一段连续的虚拟内存片段,可能是已分配的,也可能是空闲的。已分配的块被明确保留供应用程序使用,而空闲块可以被分配。空闲块保持空闲状态,直到应用程序明确分配它们。已分配的块保持已分配状态,直到被释放,释放可以由应用程序显式执行,也可以由内存分配器隐式执行。
内存分配器有两种风格:显式和隐式。C语言中的malloc函数属于显式分配器。显式分配器必须满足一些严格的条件:处理任意请求序列,立即响应请求,只使用堆,对齐块(对齐要求),不修改已分配的块。在这些限制条件下,分配器的目标是最大化吞吐量和内存利用率。
常见的放置策略包括:
1.首次适配:从空闲链表的头部开始搜索,选择第一个合适的空闲块。
2.下一次适配:类似于首次适配,但是从上一次搜索结束的地方开始。
3.最佳适配:选择所有空闲块中适合所需请求大小的最小空闲块。
有几种组织内存块的方法:
1.隐式空闲链表:空闲块通过头部的大小字段隐式连接,可以添加边界标记以提高合并空闲块的速度。
2.显式空闲链表:在隐式空闲链表的基础上,每个空闲块中都添加了一个前驱(pred)指针和一个后继(succ)指针。
3.分离的空闲链表:将块按大小分类,分配器维护一个空闲链表数组,每个大小类一个空闲链表,以减少分配时间并提高内存利用率。C语言中的malloc函数采用了这种方法。
4.树形结构(如红黑树等):按块大小将空闲块组织成树形结构,同样可以减少分配时间和提高内存利用率。
7.10本章小结
本章主要对hello的存储器地址空间、intel 的段式管理、hello 的页式管理, VA 到PA 的变换、物理内存访问,hello进程fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理进行了介绍。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
1.设备的模型化——文件
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件。
例如:/dev/sda2文件是用户磁盘分区,/dev/tty2文件是终端。
2.设备管理——Unix IO接口
将设备模型化为文件的方式允许Linux内核引入一个简单、低级的应用接口,称为Unix IO,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
- Unix I/O接口:
1.打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。对于Shell创建的每个进程,其都有三个打开的文件:标准输入,标准输出,标准错误。
2.改变当前的文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
3.读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
4.关闭文件
内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
- 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的实现分析
1.printf函数体:
int printf(const char *fmt, ...)
{
int i;
va_list arg = (va_list)((char *)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
分析:
printf函数调用了vsprintf函数,最后通过系统调用函数write进行输出;
va_list是字符指针类型;
((char *)(&fmt) + 4)表示...中的第一个参数。
2.printf调用的vsprintf函数:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char *p;
chartmp[256];
va_listp_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的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出写入buf供系统调用write输出时使用。
3.write系统调用:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
分析:这里通过几个寄存器进行传参,随后调用中断门int INT_VECTOR_SYS_CALL
即通过系统来调用sys_call实现输出这一系统服务。
4.sys_call部分内容:
sys_call:
/*
* ecx中是要打印出的元素个数
* ebx中的是要打印的buf字符数组中的第一个元素
* 这个函数的功能就是不断的打印出字符,直到遇到:'\0'
* [gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
*/
xor si,si
mov ah,0Fh
mov al,[ebx+si]
cmp al,'\0'
je .end
mov [gs:edi],ax
inc si
loop:
sys_call
.end:
ret
分析:通过逐个字符直接写至显存,输出格式化的字符串。
5.最后一部分工作:
字符显示驱动子程序实现从ASCII到字模库到显示vram(即显存,存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法,并简要介绍了Unix IO接口及其函数。也对printf和getchar的实现进行了分析。
结论
Hello程序的一生可以描述为以下过程:
预处理阶段:Hello程序的源代码文件hello.c首先经过预处理器处理,其中包括将所有被包含的外部头文件的内容直接插入到程序文本中,完成字符串的替换等操作,得到调整和展开后的ASCII文本文件hello.i。
编译阶段:经过编译器的编译,hello.i被转换成等价的汇编代码文件hello.s。这个过程包括词法分析和语法分析,将源代码翻译成相应的汇编语言代码。
汇编阶段:汇编器将hello.s汇编程序翻译成机器语言指令,并将这些指令打包成可重定位目标程序格式,最终生成可重定位目标文件hello.o。
链接阶段:链接器将hello程序的目标文件hello.o与动态链接库等收集整理,生成一个单一的文件,即完全链接的可执行目标文件hello。
加载与运行阶段:用户在shell中输入命令,shell解释这个命令并为其创建一个新进程,调用execve加载hello程序到新进程的内存空间,并开始执行。
执行指令阶段:当CPU调度到hello进程时,它分配一个时间片给hello程序,CPU按顺序执行hello程序中的指令,PC寄存器不断更新,CPU取指并执行。
访存阶段:在执行过程中,内存管理单元将逻辑地址映射成物理地址,通过三级高速缓存系统访问物理内存/磁盘中的数据。
动态申请内存阶段:如果程序中有动态内存分配的操作,比如调用printf函数,会向动态内存分配器申请堆中的内存。
信号处理阶段:程序可能会接收到各种信号,如Ctrl-C、Ctrl-Z等,在收到这些信号时,操作系统会调用相应的信号处理函数来进行相应的操作。
终止与回收阶段:程序执行完成后,父进程(通常是shell)等待并回收hello子进程,内核删除为hello进程创建的所有数据结构,清理资源。
这些阶段的协同工作构成了Hello程序的一生。
附件
文件名 | 作用 |
hello.i | hello.c预处理后得到的文本文件 |
hello.s | hello.i编译后得到的汇编代码 |
hello.o | hello.s汇编得到的可重定位目标文件 |
helloo.elf | readelf读取hello.o得到的文本 |
helloo.asm | objdump反汇编hello.o得到的反汇编文件 |
hello | hello.o链接后得到的可执行文件 |
hello.elf | readelf读取hello得到的文本 |
hello.asm | objdump反汇编hello得到的反汇编文件 |
参考文献
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
[2] Pianistx.printf 函数实现的深入剖析[EB/OL].2013[2021-6-9].
https://www.cnblogs.com/pianist/p/3315801.html.
[3] ELF文件头结构. CSDN博客.
[4] read和write系统调用以及getchar的实现. CSDN博客.
read和write系统调用以及getchar的实现_getchar 和read-CSDN博客
[5] GCC online documentation. GCC online documentation- GNU Project
[6] 深入理解计算机系统-之-内存寻址(三)--分段管理机制(段描述符,段选择子,描述符表). CSDN博客.
深入理解计算机系统-之-内存寻址(三)--分段管理机制(段描述符,段选择子,描述符表)_ds的段地址是固定的吗-CSDN博客