计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能模块
学 号 2022112531
班 级 22WL028
学 生 陈宇轩
指 导 教 师 郑贵滨
计算机科学与技术学院
2024年5月
摘 要
本文以hello.c程序为例,深入剖析了Ubuntu环境下C语言程序的编译、链接、执行过程,以及操作系统的内存与IO设备管理方法。编译过程包括预处理、编译和汇编三个阶段,每个阶段都有特定的命令和结果,对程序的结构和功能产生深远影响。链接阶段则将各个目标文件合并成可执行文件,涉及地址空间的重定位等复杂操作。
程序执行时,操作系统负责进程创建、调度和资源分配。进程在执行过程中会经历用户态与核心态的切换,特别是在进行系统调用时。此外,操作系统通过内存映射和动态存储分配管理内存资源,确保程序能够高效、安全地运行。当发生缺页故障时,操作系统会及时加载页面并更新页表,以保证程序的连续执行。
在IO设备管理方面,Linux系统提供了丰富的接口和策略,使得程序能够方便地与外部设备进行交互。例如,通过Unix IO接口,程序可以实现数据的输入和输出操作。
综上所述,本文详细解析了C语言程序的编译和执行过程,深入探讨了操作系统的内存和IO设备管理方法,深入解析了hello的P2P、020过程。
关键词:程序;预处理;编译;汇编;链接;进程管理;存储管理; IO管理
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,可以概括P2P过程如下:程序员编写好C语言程序后,经过以下四个步骤将其转化为可执行程序:预处理、编译、汇编、链接。首先经过预处理器cpp生成hello.i文件,然后经过编译器ccl生成hello.s汇编程序,接下来经过汇编器as生成hello.o文件,最后经过链接器ld生成可执行文件hello。然后shell系统调用execve等函数创建新进程并且把程序内容加载,从而实现程序到进程的转化,这就是P2P过程。
020可概括如下:一开始可以将程序视为“0”。程序运行前,shell会调用execve函数加载hello程序,将程序内容载入物理内存;在程序运行结束后,shell父进程回收进程,释放虚拟内存空间,删除相关数据,又回到了“0”的状态,这就是020过程。
1.2 环境与工具
硬件环境:X64 CPU;3.2GHz;16G RAM
软件环境:Windows11 64位;VMware Workstation Pro 17;Ubuntu 20.04.4
开发和调试工具:gcc;objdump;edb;CodeBlocks20.03
1.3 中间结果
hello.i:hello.c预处理后的文件,可用于编译。
hello.s:hello.i编译后的文件,可用于汇编。
hello.o:hello.s汇编后的文件,可用于链接。
hello:hello.o链接后的文件,可用于执行。
1.4 本章小结
本章讲述了hello.c的P2P和020的过程,介绍了完成这次作业所使用的环境和工具,列出了产生的中间结果。
第2章 预处理
2.1 预处理的概念与作用
程序预处理是指在编译或执行程序之前,对源代码或数据进行的一系列处理和转化的过程。程序预处理的目的是为了使源代码或数据适应特定的编译器或运行环境,提高程序的可读性和可维护性。
程序预处理的主要作用包括:
1.条件编译:根据特定的条件,选择性地编译或忽略某些代码块,实现代码的可配置和可扩展。
2.宏替换:将某个名称或表达式替换为另一个名称或表达式,简化代码和提高可读性。
3.文件包含:将其他文件的代码合并到当前文件中,实现代码的重用和模块化。
4.类型转换:将一种数据类型转换为另一种数据类型,使数据适应特定的编译器或运行环境。
5.错误处理:捕捉和处理程序运行时的错误和异常,提高程序的可靠性和稳定性。
2.2在Ubuntu下预处理的命令
利用命令gcc -E hello.c -o hello.i 生成hello.i文件,如下图所示。
图2.2.1 Ubuntu下的预处理命令
2.3 Hello的预处理结果解析
由上图可以看出,预处理后生成了hello.i文件,共3061行。第一张图显示的是程序用到的头文件,第二张图显示了利用typedef定义变量名的别名,第三张图是hello.i的最后部分,可以看出与hello.c完全相同。可以注意到预处理后所有的注释已经消失了。
图2.3.1 程序用到的头文件
图2.3.2 变量名别名定义
图2.3.3 hello.i的结尾与hello.c完全相同的部分
2.4 本章小结
本章介绍了预处理的概念与作用,给出了在Ubuntu下进行预处理的命令,解析了预处理的结果。
第3章 编译
3.1 编译的概念与作用
编译是指将源代码(此处为hello.i)转换为目标代码(此处为hello.s)的过程。编译的作用在于将人类易于理解和编写的源代码转换为计算机可以执行的目标代码。编译后的汇编语言程序可以被汇编器进一步转换为机器语言程序,最终由计算机的CPU执行。
3.2 在Ubuntu下编译的命令
使用gcc -S hello.i -o hello.s命令进行编译,得到hello.s文件,如下图所示。
图3.2.1 Ubuntu下的编译命令
3.3 Hello的编译结果解析
3.3.1 常量
3.3.1.1 字符串常量
.LC0 和 .LC1 定义了字符串常量。例如,.LC0 存储了一个中文字符串,用于输出特定的消息。
图3.3.1 hello.s中的字符串常量
3.3.1.2 数值型常量
以立即数的形式展现,
图3.3.2 hello.s中的数值型常量
3.3.2 变量
hello.s中只出现了局部变量,为int i,存储在栈中,如 -4(%rbp) 存储了一个局部变量。
图3.3.3 局部变量的存储
3.3.3 赋值操作
在汇编代码 hello.s 中,有几个赋值操作的实例。下面是对这些赋值操作的分析:
3.3.3.1函数参数的赋值:
movl %edi, -20(%rbp):将寄存器 %edi 中的值(通常是 main 函数的第一个参数,即 argc)存储到栈上的 -20(%rbp) 位置。
movq %rsi, -32(%rbp):将寄存器 %rsi 中的值(通常是 main 函数的第二个参数,即 argv)存储到栈上的 -32(%rbp) 位置。
3.3.3.2局部变量的赋值:
movl $0, -4(%rbp):将立即数 0 存储到栈上的 -4(%rbp) 位置,这通常用于初始化一个局部变量。
3.3.3.3循环计数器的赋值:
addl $1, -4(%rbp):将栈上的 -4(%rbp) 位置的值增加 1,用于循环计数器的增量。
3.3.3.4函数返回值的赋值:
movl $0, %eax:将立即数 0 存储到寄存器 %eax 中,这是 main 函数的返回值。
3.3.3.5是否赋初值
关于是否赋了初值,我们可以看到:
-20(%rbp) 和 -32(%rbp) 存储的是 main 函数的参数,这些参数在函数调用时已经由调用者设置,因此它们不是由 main 函数内部赋初值的。
-4(%rbp) 在 main 函数内部被赋了初值 0,这是通过 movl $0, -4(%rbp) 指令实现的。总结来说,汇编代码中的赋值操作包括函数参数的存储、局部变量的初始化和增量操作,以及函数返回值的设置。其中,局部变量 -4(%rbp) 被明确地赋了初值 0。
3.3.4 类型转换(隐式或显式)
hello.s中存在类型转换,通过atoi@PLT来实现,将字符串转化为整数。
图3.3.4 类型转换
3.3.5 算术操作
hello.s中涉及了多种算术操作:
加法操作:
addq 24, %rax:将 %rax 寄存器的内容加上 24。
addq 8, %rax:将 %rax 寄存器的内容加上 8。
addq 32, %rax:将 %rax 寄存器的内容加上 32。
减法操作:
subq $32, %rax:这个指令执行减法操作,从寄存器 %rax 的值中减去 32。
比较操作:
cmpl 9, -4(%rbp):比较 -4(%rbp) 内存位置的内容和 9 的大小。
自增操作:
addl 1, -4(%rbp):等同于自增操作,将 -4(%rbp) 内存位置的内容加 1。
图3.3.5 算术操作
3.3.6 关系操作
hello.s中涉及了关系操作,虽然没有直接使用关系操作符,但是通过cmpl和其他跳转指令来实现。
等于:
图3.3.6 等于操作
小于等于:
图3.3.7 小于等于操作
3.3.7 数组操作
hello.s中涉及了数组操作,通过movq和addq指令来实现。对数组的操作是先找到数组的首地址,然后加上偏移量。在main中,调用了argv[1],argv[2],argv[3],在汇编代码中,每次将%rbp-32的的值即数组首地址传%rax,然后将%rax分别加上偏移量24,16,8,得到argv[1]、argv[2]、argv[3],分别存入对应的寄存器%rcx、%rdx、%rsi作为第一个参数、第二个参数和第三个参数,之后调用printf函数时使用。调用完printf后,在偏移量为32时,取出一个参数在调用函数atoi使用。
图3.3.8 数组操作
3.3.8 控制转移
hello.s中涉及了控制转移操作,涉及了if和for循环,if体现在比较5和-20(%rbp)的大小,即检查argc是否为5,如果相等则会跳转到.L2位置。for循环体现在每次循环结束使用cmpl指令判断循环条件,满足则继续,否则退出循环。
图3.3.9 if的使用
图3.3.10 for的使用
3.3.9 函数操作
hello.s涉及的函数操作如下:
1.参数传递(地址/值):
在.L4标签处,有movq -32(%rbp),%rax、addq $24,%rax和movq (%rax),%rcx这几条指令,这表明它从内存地址-32(%rbp)+24中加载了一个值到%rcx寄存器中。这个值可能是一个参数传递给后面的printf@PLT函数。
在.L4标签处,有movq -32(%rbp),%rax、addq $16,%rax和movq (%rax),%rdx这几条指令,这表明它从内存地址-32(%rbp)+16中加载了一个值到%rdx寄存器中。这个值可能是一个参数传递给后面的printf@PLT函数。
在.L4标签处,有movq -32(%rbp),%rax和addq $8,%rax这几条指令,这表明它从内存地址-32(%rbp)+8中加载了一个值到%rax寄存器中。这个值可能是一个参数传递给后面的printf@PLT函数。
2.函数调用:
在.L4标签处,有leaq .LC1(%rip),%rdi、movl $0,%eax和call printf@PLT这几条指令,这表明它调用了printf@PLT函数。
在.L4标签处,有movq -32(%rbp),%rax和call atoi@PLT这几条指令,这表明它调用了atoi@PLT函数。
在.L4标签处,有movq -32(%rbp),%rax和call sleep@PLT这几条指令,这表明它调用了sleep@PLT函数。
在main函数的末尾,有call getchar@PLT这条指令,这表明它调用了getchar@PLT函数。
3.局部变量:
在main函数的开头,有subq $32,%rsp这条指令,这表明它在内存中分配了32字节的空间作为局部变量。
在.L4标签处,有movl $0,-4(%rbp)这条指令,这表明它在内存地址-4(%rbp)中存储了一个值0,这是一个局部变量。
4.函数返回:
在main函数的末尾,有movl $0,%eax这条指令,这表明它将返回值存储在%eax寄存器中。
在main函数的末尾,有leave和ret这两条指令,这表明它已经结束了函数并返回了。
图3.3.11 函数操作
3.4 本章小结
本章讲述了编译的概念和作用,给出了Ubuntu下的编译命令,并对编译结果进行了详细的解析。
第4章 汇编
4.1 汇编的概念与作用
汇编是把汇编程序翻译为机器语言的过程,便于计算机的执行,在此处是指将hello.s转化为hello.o的过程。汇编的作用为将汇编代码转变成可以执行的指令,生成目标文件,方便机器直接分析。
4.2 在Ubuntu下汇编的命令
在Ubuntu下使用gcc -C hello.s -o hello.o命令利用hello.s转化为hello.o文件,实现汇编。
图4.2.1 Ubuntu下的汇编过程
4.3 可重定位目标elf格式
hello.o的elf格式如下:
ELF头 |
.text |
.rodata |
.data |
.bss |
.symtab |
.rel.text |
.rel.data |
.debug |
.line |
.strlab |
节头部表 |
使用readelf -h hello.o命令查看ELF头如下:
图4.3.1 ELF头
使用readelf -S hello.o命令查看节头表如下:
图4.3.2 节头表
图4.3.3 节头表
图4.3.4 节头表
图4.3.5 节头表
使用readelf -s hello.o命令查看符号表如下:
图4.3.6 符号表
图4.3.7 符号表
图4.3.8 符号表
图4.3.9 符号表
图4.3.10 符号表
使用readelf -r hello.o命令查看重定位节如下:
图4.3.11 重定位节
图4.3.12 重定位节
4.4 Hello.o的结果解析
使用objdump -d -r hello.o 命令对hello.o进行反汇编,如下图所示。
图4.4.1 hello.o反汇编
图4.4.2 hello.o反汇编
图4.4.3 hello.o反汇编
图4.4.4 hello.o反汇编
图4.4.5 hello.o反汇编
图4.4.6 hello.o反汇编
图4.4.7 hello.o反汇编
图4.4.8 hello.o反汇编
图4.4.9 hello.o反汇编
汇编语言中每个机器码与操作数对应一个01序列,机器语言由01构成,操作数在汇编语言中为十进制,在机器语言中为十六进制0x。
hello.o反汇编文件与hello.s汇编代码部分相同,但反汇编文件多了机器代码,每一条机器代码对应一条机器指令。
分支转移:汇编语言使用跳转指令(如je .L2)决定跳转,机器语言直接使用对应地址实现跳转。
函数调用:汇编语言直接使用函数名,机器语言中call目标地址为当前指令的下一条指令地址。对于不确定地址的调用,机器语言会先将下一条指令的相对地址设置为0,在链接时确定地址。
4.5 本章小结
本章首先介绍了汇编的概念和作用,接着对hello.s文件进行汇编生成hello.o,接着使用readelf工具查看了hello.o的ELF头、节头表、符号表和重定位节。在反汇编后,将反汇编结果与hello.s比较,分析了不同之处和映射关系。
第5章 链接
5.1 链接的概念与作用
链接的概念:链接器将多个可重定位目标文件、外部库文件等各种代码和数据的片段组合成一个可执行文件或库文件的过程。
链接的作用:
1.链接使得分离编译成为可能,这意味着我们可以将一个大的程序分解为多个小的模块进行独立编译,从而提高了编译的效率和便利性。当我们需要修改某个模块时,只需要重新编译该模块,而无需重新编译整个工程。
2.链接有利于共享库的构建。共享库是一种可重用的代码库,它可以被多个程序共享使用,从而减少了代码的重复开发和存储。链接器可以将程序链接到相应的共享库,从而实现代码的共享和重用。
3.链接可以解决目标文件之间的符号引用,为外部函数和变量分配实际的内存地址,使得程序能够正确地执行。
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进行链接,如下图所示
图5.2.1 Ubuntu下的链接命令
5.3 可执行目标文件hello的格式
hello的ELF格式如下所示:
ELF头 |
程序头表 |
.init |
.text |
.rodata |
.data |
.bss |
.symtab |
.debug |
.line |
.strtab |
节头部表 |
使用readelf -h hello命令查看ELF头如下:
图5.3.1 ELF头
使用readelf -S hello命令查看节头表如下:
图5.3.2 节头表
图5.3.3 节头表
使用readelf -s hello命令查看符号表如下:
图5.3.4 符号表
图5.3.5 符号表
使用readelf -r hello命令查看重定位节如下:
图5.3.6 重定位节
各段的起始地址,大小等信息如上图所示。
5.4 hello的虚拟地址空间
使用edb加载hello查看本进程的虚拟地址空间各段信息如下图所示,从0x0000000000401000开始,到0x0000000000402000结束。
图5.4.1 hello的虚拟地址空间各段信息
图5.4.2 .init的信息
图5.4.3 .rodata的信息
.init的起始地址是0x0000000000401000,与hello的起始地址相同,.rodata的起始地址是0x0000000000402000,与hello的结束地址相同。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
图5.5.1 hello反汇编
图5.5.2 hello反汇编
图5.5.3 hello反汇编
图5.5.4 hello反汇编
图5.5.5 hello反汇编
可以看出,在hello.o中跳转指令和call指令后为绝对地址,而在hello中是重定位之后的虚拟地址。hello中还多了很多重定位之后的函数,如多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt,atoi@plt等,hello.o在.text段之后只有main函数。
链接的过程如下:
- 符号解析:在程序中,定义和引用的符号(如函数和全局变量)存储在符号表(.symtab 节)中,它是一个结构数组。编译器将符号的引用存储在重定位节(.rel.text 和 .rel.data 节)中。链接器则将每个符号引用与确定的符号定义关联起来,实现符号解析。
- 重定位:链接器将多个代码段和数据段分别合并为一个完整的代码段和数据段。此时,计算每个定义的符号在虚拟地址空间的绝对地址,而不是相对偏移量。接着,链接器将可执行文件中的符号引用处修改为重定位后的地址信息,完成重定位过程。
接下来以atoi函数为例介绍hello的重定位过程。
图5.5.6 atoi函数
由图可知,当前PC的值为callq指令的下一条地址,即0x4011a5,atoi函数的地址为0x4010c0,两者相差0xe5,因此PC需要减去0xe5,即加上0xff ff ff 1b,因为是小端序,所以重定位目标处应填上1b ff ff ff,与图中0x4011a0行的结果一致。
5.6 hello的执行流程
hello调用与跳转的各个子程序名及程序地址如下表所示:
子程序名 |
hello!_start |
libc-2.31.so!__libc_start_main |
hello!_init |
hello!main |
hello!printf@plt |
hello!atoi@plt |
hello!sleep@plt |
hello!getchar@plt |
hello!exit@plt |
hello!__libc_csu_init |
hello!_fini |
5.7 Hello的动态链接分析
根据.got的地址,在edb中进行查看。
图5.7.1 .got的地址
图5.7.2 dl_init前的内容
图5.7.3 dl_init后的内容
可以看到,在dl_init后,.got地址处的内容发生了改变,这是因为动态链接器通过过程链接表和全局偏移量表协作。全局偏移量表存储函数的绝对目标地址,过程链接表利用这些地址进行跳转。在加载时,动态链接器会修正全局偏移量表,将每个条目的地址变为指向实际目标函数的绝对地址,实现动态链接。
5.8 本章小结
本章介绍了链接的概念和作用,以hello为例,分析了可执行文件的ELF格式、虚拟地址空间,将hello的反汇编文件与hello.o的反汇编文件进行比较,并分析了重定位的过程与动态链接的过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:是计算机系统中执行程序的基本单位,它包括程序代码、数据和进程控制信息。
进程的作用:进程可以并发执行,通过操作系统管理和调度,实现资源的有效利用和任务的并行处理。进程间通过通信机制交换信息,协作完成复杂任务,提高系统效率和响应速度。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash 是一种命令行解释器,主要用于与操作系统内核通信,允许用户输入命令并执行。它的作用包括:
1.命令解释:接收用户输入的命令,解释并执行。
2.脚本执行:可以运行包含一系列命令的脚本文件。
3.环境配置:提供配置文件来设置用户环境变量和别名。
4.程序执行管理:管理后台进程、作业控制等。
处理流程大致如下:
1.启动:用户通过终端启动bash。
2.读取命令:bash等待用户输入命令。
3.解析命令:分析命令行,确定命令和参数。
4.执行命令:查找并执行命令,可能涉及系统调用。
5.结果输出:将命令执行结果输出到终端。
6.循环处理:重复上述步骤,直到用户退出bash。
6.3 Hello的fork进程创建过程
在shell中,当输入./hello命令时,shell命令行解释器首先解析这个命令,将其构建成一个环境变量数组envp(环境变量列表)和一个命令行参数数组argv(命令参数列表)。接着,它调用内核中的fork()系统调用。
fork()函数在父进程中执行,创建了一个新的子进程。这个过程是复制式的,子进程会从父进程获取一个几乎完全相同的虚拟地址空间副本,包括代码段(代码执行区域)、数据段(静态数据和全局变量)、堆(动态分配的内存)以及用户栈。共享库(如动态链接的库)也会被复制,但每个进程有自己的独立副本,以避免并发访问时的冲突。
子进程和父进程之间的关键区别在于,它们虽然共享相同的可执行代码,但数据和堆栈是独立的,这意味着它们可以拥有各自独立的状态。此外,fork()函数在父进程中返回的是子进程的PID(进程标识符),而子进程返回的是0。这个PID值使得我们可以区分是父进程还是子进程,因为它们的返回值不同。
总结来说,父进程通过fork()创建了一个新的子进程,它们共享代码,但拥有独立的数据和堆栈,以及各自的PID。这样,每个进程都有自己的生命周期,互不影响,但可以协同工作。父进程和子进程在执行过程中,通过PID来确认自己的身份和相互之间的关系。
6.4 Hello的execve过程
可执行程序hello的execve过程如下:
1.父进程调用fork()创建一个子进程。
2.子进程调用execve()函数,传入可执行文件hello的路径名作为第一个参数,一个指向参数列表的指针作为第二个参数,以及一个指向环境变量列表的指针作为第三个参数。
3.execve()函数调用内核中的加载器,加载器负责将hello程序加载到子进程的虚拟地址空间中。
4.加载器首先删除子进程现有的虚拟内存段,然后创建一组新的段,包括代码段、数据段、BSS段、堆和栈等。
5.加载器将hello程序的代码和数据段加载到子进程的虚拟地址空间中,并将BSS段初始化为0。
6.加载器将虚拟地址空间中的页映射到可执行文件的页大小的片。
7.加载器跳转到hello程序的开头_start,开始执行hello程序。
8.如果execve()函数执行成功,它将不会返回,因为子进程已经开始执行hello程序。如果execve()函数执行失败,它将返回-1,并将错误代码存储在errno变量中。
总之,execve过程是将一个可执行文件加载到子进程的虚拟地址空间中,并开始执行该程序的过程。在这个过程中,加载器负责将程序的代码和数据加载到内存中,并将虚拟地址空间映射到可执行文件的内存中。
6.5 Hello的进程执行
可执行程序hello的进程执行过程如下:
1.父进程调用fork()创建一个子进程,并将hello程序的路径名传递给子进程。
2.子进程调用execve()函数,将hello程序加载到虚拟地址空间中,并开始执行hello程序。
3.进程上下文信息包括进程的程序计数器(PC)、寄存器、内存映射和其他状态信息。在执行hello程序时,子进程的进程上下文信息会被加载到CPU寄存器中,以便于执行。
4.进程调度是指操作系统在多个进程中分配CPU时间片的过程。当hello进程使用完CPU时间片后,操作系统会将其暂停并切换到另一个进程。
5.在用户态和核心态之间转换是指进程在执行用户代码和系统调用时所经历的状态转换。当进程执行系统调用时,它会从用户态切换到核心态,并在完成系统调用后再次返回到用户态。
6.在执行hello程序时,进程可能会进行多次用户态和核心态的转换,例如在进行文件IO操作时会进行系统调用,从而进入核心态。
7.当hello进程执行完毕后,它会释放所有资源并退出,父进程可以选择等待子进程退出并获取其结束状态。
总之,可执行程序hello的进程执行过程包括进程上下文信息、进程调度、用户态和核心态之间的转换等等。在执行过程中,进程会被操作系统调度并分配CPU时间片,并在用户态和核心态之间转换以执行系统调用。当hello进程执行完毕后,它会释放所有资源并退出。
6.6 hello的异常与信号处理
hello的运行过程中可能出现中断、陷阱、故障、终止四种异常。
hello的运行过程中可能产生的信号如下表所示:
编号 | 信号名称 | 缺省动作 | 说明 |
1 | SIGHUP | 终止 | 终止控制终端或进程 |
2 | SIGINT | 终止 | 键盘产生的中断(Ctrl-C) |
3 | SIGQUIT | dump | 键盘产生的退出 |
4 | SIGILL | dump | 非法指令 |
5 | SIGTRAP | dump | debug中断 |
6 | SIGABRT/SIGIOT | dump | 异常中止 |
7 | SIGBUS/SIGEMT | dump | 总线异常/EMT指令 |
8 | SIGFPE | dump | 浮点运算溢出 |
9 | SIGKILL | 终止 | 强制进程终止 |
10 | SIGUSR1 | 终止 | 用户信号,进程可自定义用途 |
11 | SIGSEGV | dump | 非法内存地址引用 |
12 | SIGUSR2 | 终止 | 用户信号,进程可自定义用途 |
13 | SIGPIPE | 终止 | 向某个没有读取的管道中写入数据 |
14 | SIGALRM | 终止 | 时钟中断(闹钟) |
15 | SIGTERM | 终止 | 进程终止 |
16 | SIGSTKFLT | 终止 | 协处理器栈错误 |
17 | SIGCHLD | 忽略 | 子进程退出或中断 |
18 | SIGCONT | 继续 | 如进程停止状态则开始运行 |
19 | SIGSTOP | 停止 | 停止进程运行 |
20 | SIGSTP | 停止 | 键盘产生的停止 |
21 | SIGTTIN | 停止 | 后台进程请求输入 |
22 | SIGTTOU | 停止 | 后台进程请求输出 |
23 | SIGURG | 忽略 | socket发生紧急情况 |
24 | SIGXCPU | dump | CPU时间限制被打破 |
25 | SIGXFSZ | dump | 文件大小限制被打破 |
26 | SIGVTALRM | 终止 | 虚拟定时时钟 |
27 | SIGPROF | 终止 | profile timer clock |
28 | SIGWINCH | 忽略 | 窗口尺寸调整 |
29 | SIGIO/SIGPOLL | 终止 | I/O可用 |
30 | SIGPWR | 终止 | 电源异常 |
31 | SIGSYS/SYSUNUSED | dump | 系统调用异常 |
图6.6.1 hello执行过程(乱按+回车)
由图可知,在hello的执行过程中乱按和回车对其没有任何影响。
图6.6.2 hello执行过程(Ctrl-C)
由图可知,在hello的执行过程中按下Ctrl-C使进程直接停止。
图6.6.3 hello执行过程(Ctrl-Z+ps+jobs)
由图可知,在hello的执行过程中按下Ctrl-Z进程也会停止。
图6.6.4 hello执行过程(pstree)
由图可知,按下Ctrl-Z后,继续输入ps,jobs,pstree命令后可以实现对应功能。
图6.6.5 hello执行过程(pstree)
图6.6.6 hello执行过程(pstree)
图6.6.7 hello执行过程(fg)
由图可知,按下Ctrl-Z后,继续输入fg命令可以使程序恢复执行。
图6.6.8 hello执行过程(kill)
由图可知,输入kill命令杀死指定进程后,该进程被成功杀死。
6.7本章小结
本章介绍了进程的概念和作用,Shell-bash的作用,以及fork进程创建过程、execve过程,描述了进程的执行和hello的异常和信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
在讨论hello程序的上下文中,逻辑地址是指CPU生成的、程序中使用的地址,如hello.o文件中的偏移量。这些地址需要通过寻址计算才能转换为内存中的实际有效地址。
线性地址是逻辑地址经过分段和分页处理后的结果,它是处理器可寻址的内存空间(线性地址空间)中的地址。在hello程序中,逻辑地址加上段基址形成线性地址。若启用分页机制,线性地址进一步转换为物理地址;否则,线性地址本身就是物理地址。
虚拟地址是虚拟存储技术中使用的地址,它也是程序中使用的地址,但在物理内存中没有直接对应的位置。操作系统将虚拟地址映射到物理地址,以优化内存使用。在分段机制中,逻辑地址加上基地址得到线性地址,通常是一个32位无符号整数,可以表示4GB的地址空间。线性地址通常以十六进制表示。hello程序的反汇编文本文件中的地址即为虚拟地址,也是线性地址。
物理地址是CPU通过地址总线访问内存时使用的实际存在的地址。操作系统将虚拟地址映射到物理地址,以实现内存的有效管理。物理地址主要用于访问内存条中的内存,但也可能映射到其他存储器,如显存、BIOS等。在hello程序中,反汇编得到的文本文件中的地址反映了这些地址层次结构的信息。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel的段式管理将逻辑地址分为两部分:段基址和段内偏移地址。段基址是由操作系统分配的,用于定位内存中的段起始地址。段内偏移地址是指段内的偏移地址,用于定位段内的数据。在段式管理中,逻辑地址转换为线性地址的过程如下:
1.取出段基址,将段基址与段内偏移地址相加,得到段内的线性地址。
2.检查该线性地址是否超出当前段的长度。如果超出,则产生越界错误。
3.如果没有超出段的长度,则将段内的线性地址作为最终的线性地址。
在Intel的x86架构中,段基址和段内偏移地址都是16位的,最大可表示64KB的内存空间。因此,如果需要访问超过64KB的内存空间,则需要使用多个段,并通过分段机制将它们连接起来。
7.3 Hello的线性地址到物理地址的变换-页式管理
在hello的页式管理中,内存被划分为固定大小的页框,每个页框有一个唯一的物理地址。程序的逻辑地址空间也被划分为相同大小的页,每个页对应一个线性地址。页式管理通过将线性地址映射到物理地址来实现内存的访问。
线性地址到物理地址的变换过程如下:
1.分页单元将线性地址分为两个部分:页号和页内偏移。页号用于索引页表,页内偏移用于定位页内的数据。
2.页表中存储了每个页对应的物理页框号。页表通常由操作系统维护,存储在内存中。
3.分页单元通过页号在页表中查找对应的物理页框号。
4.将物理页框号与页内偏移相加,得到最终的物理地址。
在页式管理中,每个页都有一个页表项,PTE 中存储了页的物理页框号、页的权限、页的状态等信息。操作系统可以通过修改 PTE 来改变页的属性。
页式管理的优点是可以提高内存利用率,因为可以将内存分成多个固定大小的页框,每个页框可以独立管理,避免了内存碎片的产生。同时,页式管理也可以提高系统的安全性,因为可以通过设置页的权限来控制对内存的访问。
但是,页式管理也存在一些缺点,例如页表需要占用大量的内存空间,因此需要使用一些技术来减少页表的存储空间,例如多级页表、页表压缩等。此外,页式管理也需要更多的硬件和软件支持,因此系统开销较大。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB (Translation Lookaside Buffer) 和四级页表是Intel处理器中用于实现虚拟地址(VA)到物理地址(PA)变换的两个重要机制。当CPU需要访问内存时,它会先查看TLB中是否存在对应的VA的映射关系。如果存在,TLB中的映射关系会被直接使用,从而加快了内存访问速度。如果不存在,CPU会进入页表查找VA对应的PA。在四级页表中,VA被分成了四个部分,通过索引页目录项和页表项获取页的物理页框号,再加上页内偏移得到PA。如果TLB中没有对应的VA的映射关系,CPU会将其加入TLB中,以便下次使用。这两个机制共同工作,使得Intel处理器可以加速VA到PA的变换,提高内存访问速度。
7.5 三级Cache支持下的物理内存访问
在三级 Cache(L1、L2、L3)支持下的物理内存访问过程如下:
- 本地缓存(L1):L1 Cache是最靠近处理器核心的缓存,速度最快。当CPU访问一个数据后,首先在L1Cache中查找。如果命中(数据在缓存中),则几乎瞬时返回,无需访问内存,从而提高了效率。
- L2 Cache:如果L1 Cache未找到,数据会流向L2 Cache。L2 Cache的容量比L1大,但速度稍慢。如果L2 Cache命中,会比L1 Cache访问慢一些,但仍然比内存快得多。
- L3 Cache:如果L2 Cache也未找到,数据才会流向L3 Cache。L3 Cache是整个系统中最大的缓存,它的存在是为了进一步减少访问内存的频率。如果L3 Cache命中,CPU性能将进一步提升,因为L3 Cache的数据传输速度接近于内存速度。
- 内存访问:如果L3 Cache也未找到数据,CPU才会正式访问物理内存即主存。内存访问速度较慢,因为它位于CPU之外,通过总线连接。由于内存访问时间较长,所以整个系统会暂停其他操作,直到数据返回。
- 数据返回:一旦数据从内存返回,L3 Cache会先接收,然后根据需要,L2和L1 Cache也会更新,以减少未来访问内存的次数。
通过这种逐级查找的机制,三级Cache显著减少了内存访问延迟,提高了计算机的整体性能。当数据频繁重复访问时,缓存命中率高,性能提升更为显著。
7.6 hello进程fork时的内存映射
hello进程在调用fork时的内存映射过程如下:
1.创建新进程的数据结构:当hello进程调用fork时,内核会为新创建的子进程创建各种数据结构,包括进程控制块(PCB)、进程描述符等,并为子进程分配一个唯一的进程标识符(PID)。
2.复制内存管理结构:内核会为子进程创建一个与父进程相同的内存描述符(mm_struct)、区域结构(vm_area_struct)和页表。这些结构描述了进程的虚拟内存布局和映射关系。
3.标记页面为只读和私有写时复制:内核将父子进程中的每个页面都标记为只读,并将每个区域结构标记为私有的写时复制。这意味着,如果任何一方尝试写入这些页面,内核将通过写时复制机制来处理。
4.写时复制机制:在初始阶段,父子进程共享相同的物理内存页。如果任何一个进程尝试写入这些共享的只读页面,内核会捕获这个写操作,并为该进程创建一个新的物理内存页副本,然后将该页的访问权限更改为可写。这个过程对进程是透明的。
5.子进程的虚拟内存布局:当fork在新创建的子进程中返回时,子进程的虚拟内存布局与父进程在调用fork时的虚拟内存布局完全相同。这意味着子进程继承了父进程的所有内存映射,包括代码、数据、堆栈以及任何共享库的映射。
6.后续的写操作:如果父子进程中的任何一个在fork之后进行写操作,写时复制机制将确保只有实际被修改的页面才会被复制,从而避免了不必要的内存复制,提高了效率。
综上所述,hello进程在调用fork时,子进程会继承父进程的虚拟内存布局,并通过写时复制机制共享物理内存页。只有当进程尝试写入共享页面时,内核才会为该进程创建新的内存页副本,确保进程间的内存隔离。这种机制使得fork操作在创建新进程时非常高效,尤其是在进程不需要修改共享内存的情况下。
7.7 hello进程execve时的内存映射
当hello进程调用execve函数时的步骤如下:
1.终止当前进程:execve调用之前,hello进程会终止其当前的执行上下文,包括清除栈、关闭文件描述符等,这会释放掉大部分内存映射。
2.加载新程序:execve会加载新的程序(通常是程序的可执行文件)到内存中。内核会为新的程序创建一个新的内存映射,包括代码段(text)、数据段(data)、堆(heap)和栈(stack)等。这个新的内存映射通常不会与父进程共享,而是为新程序独立分配。
3.重置内存映射:执行新程序时,内核会重新设置进程的内存映射,确保新程序的代码、数据和动态分配的内存区域(如堆和栈)与新加载的程序文件对应。这可能包括加载新的页表,重新初始化区域结构。
4.权限和共享:如果execve加载的是一个共享库(例如动态链接的程序),内核会创建一个私有的映射,使得每个进程都有自己的私有副本,即使它们共享相同的库。这样可以确保线程安全,并在需要时进行写时复制。
5.清除旧映射:由于execve替换的是整个进程的执行环境,所以之前进程的内存映射(除了内核空间和一些特定的系统区域)通常会被清除,以避免安全和资源问题。
6.初始化新进程状态:执行新程序后,内核会根据新程序的入口点初始化进程状态,包括设置堆栈指针、程序计数器等。
综上所述,hello进程在调用execve时,会终止当前的执行环境,替换为新程序的内存映射,创建独立的代码、数据和堆栈区域,确保新程序的执行环境与旧进程隔离。这个过程允许进程在执行不同的程序时,有效地管理和隔离内存。
7.8 缺页故障与缺页中断处理
缺页故障是操作系统中的一种异常,发生在程序访问的内存页不在物理内存中时。当发生缺页故障时,操作系统会通过缺页中断来处理这种情况。缺页故障与缺页中断处理的介绍如下:
缺页故障
1.触发条件:当程序尝试访问一个内存地址,而这个地址对应的页不在物理内存中(即不在RAM中),而是在磁盘上的交换区或文件系统中时,就会触发缺页故障。
2.异常处理:缺页故障被视为一种异常,需要操作系统的异常处理机制来处理。
缺页中断处理的几种方式如下:
1.中断响应:当CPU检测到缺页故障时,会立即中断当前的执行流程,跳转到预设的缺页中断处理程序。
2.查找页表:中断处理程序首先检查页表,确认发生故障的页是否真的不在物理内存中。如果页表项标记为无效,那么确实是缺页故障。
3.加载页面:如果确认是缺页故障,操作系统会从磁盘上的交换区或文件系统中读取相应的页面到物理内存中。这通常涉及到磁盘I/O操作,是处理过程中最耗时的部分。
4.更新页表:页面加载到物理内存后,操作系统会更新页表,将该页的状态标记为有效,并记录其对应的物理内存地址。
5.重新执行指令:页面加载并更新页表后,操作系统会重新执行导致缺页故障的那条指令,因为此时所需的页面已经在物理内存中了。
6.内存管理:在加载新页面到物理内存时,如果物理内存已满,操作系统可能需要执行页面置换算法(如LRU、FIFO等),选择一个或多个页面从物理内存中移除,以便为新页面腾出空间。
7.9动态存储分配管理
printf函数本身不会直接调用malloc来分配内存,因为printf主要用于格式化输出,并不涉及动态内存分配。然而,在某些情况下,如果printf的参数需要动态分配内存,那么可能会间接调用malloc。
动态内存管理的基本方法与策略如下:
基本方法
1.内存分配:
使用malloc等函数来分配内存,通常会从堆(heap)中分配一块连续的内存空间,并返回一个指向该空间起始地址的指针。malloc 函数接受一个参数,即所需分配的内存大小(以字节为单位),并返回一个指向分配内存起始地址的指针。如果分配成功,返回的指针指向一块连续的内存空间;如果分配失败,返回 NULL。
2.内存释放:
使用free函数来释放之前通过malloc系列函数分配的内存。释放内存后,该内存块可以被重新分配给其他部分使用。
3.内存管理:
操作系统或运行时库负责管理堆内存,包括跟踪哪些内存块已被分配,哪些是空闲的,以及如何高效地分配和回收内存。
策略
1.内存分配策略:
首次适应(First-fit):从空闲链表中找到第一个足够大的空闲块,并分配所需内存。
最佳适应(Best-fit):找到最小的足够大的空闲块,以减少内存碎片。
最坏适应(Worst-fit):分配最大的空闲块,以期望剩余的空闲块仍然足够大,可以被其他分配请求使用。
伙伴系统(BuddySystem):将内存分割成对称的块,通过合并相邻的空闲块来减少碎片。
2.内存回收策略:
立即回收:当内存被释放时,立即合并相邻的空闲块。
延迟回收:将释放的内存块保留在空闲链表中,直到需要时再进行合并。
3.内存碎片管理:
内部碎片:由于分配的内存块大小通常是固定大小的整数倍,可能导致分配的内存块内部有未使用的空间。
外部碎片:随着内存的分配和释放,可能会导致空闲内存被分割成许多小块,难以满足大块内存的分配请求。
4.内存分配器优化:
池分配(PoolAllocation):预先分配一组固定大小的内存块,用于快速分配和回收相同大小的对象。
垃圾回收(GarbageCollection):自动跟踪和管理不再使用的内存,无需程序员显式调用free。
动态内存管理是编程中的一个复杂主题,需要仔细考虑内存分配和释放的时机,以避免内存泄漏和悬挂指针等问题。良好的内存管理策略可以提高程序的性能和稳定性。
7.10本章小结
本章介绍了hello的存储器地址空间、Intel逻辑地址到线性地址的变换-段式管理、Hello的线性地址到物理地址的变换-页式管理、LB与四级页表支持下的VA到PA的变换、三级Cache支持下的物理内存访问、hello进程fork时的内存映射、hello进程execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理的相关内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
“设备的模型化:文件”是指所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
“设备管理:unix io接口”是指这种将设备映射为文件的方式允许Linux内核引出一个简单、低级的应用接口,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix I/O接口是Linux内核提供的一套标准输入输出函数,用于处理文件和设备的操作。接口函数主要包括:
open():打开文件或设备,返回一个文件描述符。
read():从文件或设备读取数据。
write():向文件或设备写入数据。
close():关闭文件或设备,释放文件描述符。
lseek():移动文件读写指针到指定位置。
ioctl():用于设备特定的控制操作。
8.3 printf的实现分析
printf的函数体如下:
- intprintf(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;
- }
vsprintf的函数体如下:
- intvsprintf(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);
- }
在write函数执行时,它会将参数加载到寄存器中,然后通过int 21h指令触发系统调用。系统调用负责将字符串中的字节从寄存器经由总线传输到显卡的显存中,显存内存储着字符的ASCII码。字符显示驱动程序根据这些ASCII码在字模库中查找对应的点阵信息,并将这些点阵信息写入到vram中。显示芯片随后以设定的刷新频率逐行扫描vram,通过信号线将每个像素的RGB颜色信息传输给液晶显示器。最终,hello程序的输出内容便在屏幕上显示出来。
8.4 getchar的实现分析
在键盘中断处理子程序中,程序接受按键扫描码并转换为 ASCII 码,保存到系统的键盘缓冲区。getchar() 等调用 read() 系统函数时,通过系统调用读取按键 ASCII 码,直到检测到回车键才返回。当程序调用 getchar() 函数时,程序会等待用户按键,用户输入的字符被存放在键盘缓冲区,直到用户按回车为止(回车也在缓冲区中)。getchar() 函数每次从 stdio 流中读取一个字符,返回用户输入的第一个字符的 ASCII 码,如出错返回 -1。在用户按回车之前输入的其他字符将保留在键盘缓冲区中,等待后续 getchar() 调用读取。在用户按键时触发键盘终端,操作系统转移控制到键盘中断处理子程序,中断处理程序执行,接受按键扫描码转成 ASCII 码,保存到系统的键盘缓冲区,并显示在用户输入的终端内。中断处理程序执行完毕后,返回到下一条指令运行。
8.5本章小结
本章介绍了Linux的IO设备管理方法,简述了简述Unix IO接口及其函数,分析了printf和getchar的实现。
结论
当hello.c被程序员写出来后,还不是一个可执行程序,需要经过以下步骤来赋予它“生命”:
hello.c预处理得到hello.i文件;
hello.i编译得到hello.s文件;
hello.s汇编得到二进制可重定位目标文件hello.o;
hello.o通过链接生成可执行程序hello;
Shell通过fork函数生成子进程;
execve函数加载并运行程序hello;
hello的运行与存储管理息息相关,也与IO管理紧密相连;
hello最终被shell父进程回收,内核会收回为其创建的所有信息,hello的一生到此结束。
通过这次大作业,我深刻体会了一个程序从编写到执行的各个环节,深刻感受到了执行一个程序需要的步骤之多,我对计算机系统的理解也大大加深。通过在Ubuntu下进行各个命令的执行,我实现了对hello的各方面的解析,还认识了许多原来不那么熟悉的函数,比如我以atoi函数为例介绍了hello的重定位过程。hello的一生虽然短暂,但却凝聚着人类的许多智慧,让我获益颇多。
附件
文件名 | 作用 |
hello.i | hello.c预处理后的文件,可用于编译 |
hello.s | hello.i编译后的文件,可用于汇编 |
hello.o | hello.s汇编后的文件,可用于链接 |
hello | hello.o链接后的文件,可用于执行 |
参考文献
[1] Randal E. Bryant,David O’Hallaron,《深入理解计算机系统》[M](原书第三版),北京:机械工业出版社,2021:10-1.
[2] https://www.cnblogs.com/pianist/p/3315801.html