HIT【2024春】csapp大作业——程序人生-Hello’s P2P
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 网络空间安全
学 号 2022111009
班 级 2203901
学 生 周天宇
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
摘 要
本文探讨了一个简单的 C 语言程序 hello.c 的整个生命周期。从最初的源代码编写开始,经历了编译、汇编和链接的过程,将 .c 文件转化为可执行文件。文章还深入解释了程序在运行时系统中的状态,包括内存分配、进程创建以及代码执行的过程,全面展示了程序从诞生到执行的精髓。
**关键词:**P2P;020;I/O管理。
**
**
目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 5 -
第2章 预处理 - 6 -
2.1 预处理的概念与作用 - 6 -
2.2在Ubuntu下预处理的命令 - 6 -
2.3 Hello的预处理结果解析 - 6 -
2.4 本章小结 - 7 -
第3章 编译 - 8 -
3.1 编译的概念与作用 - 8 -
3.2 在Ubuntu下编译的命令 - 8 -
3.3 Hello的编译结果解析 - 8 -
3.4 本章小结 - 14 -
第4章 汇编 - 15 -
4.1 汇编的概念与作用 - 15 -
4.2 在Ubuntu下汇编的命令 - 15 -
4.3 可重定位目标elf格式 - 15 -
4.4 Hello.o的结果解析 - 17 -
4.5 本章小结 - 19 -
第5章 链接 - 20 -
5.1 链接的概念与作用 - 20 -
5.2 在Ubuntu下链接的命令 - 20 -
5.3 可执行目标文件hello的格式 - 20 -
5.4 hello的虚拟地址空间 - 22 -
5.5 链接的重定位过程分析 - 23 -
5.6 hello的执行流程 - 24 -
5.7 Hello的动态链接分析 - 25 -
5.8 本章小结 - 26 -
第6章 hello进程管理 - 27 -
6.1 进程的概念与作用 - 27 -
6.2 简述壳Shell-bash的作用与处理流程 - 27 -
6.3 Hello的fork进程创建过程 - 28 -
6.4 Hello的execve过程 - 29 -
6.5 Hello的进程执行 - 29 -
6.6 hello的异常与信号处理 - 30 -
6.7本章小结 - 33 -
第7章 hello的存储管理 - 34 -
7.1 hello的存储器地址空间 - 34 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 34 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 35 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 35 -
7.5 三级Cache支持下的物理内存访问 - 36 -
7.6 hello进程fork时的内存映射 - 36 -
7.7 hello进程execve时的内存映射 - 37 -
7.8 缺页故障与缺页中断处理 - 37 -
7.9动态存储分配管理 - 37 -
7.10本章小结 - 39 -
第8章 hello的IO管理 - 40 -
8.1 Linux的IO设备管理方法 - 40 -
8.2 简述Unix IO接口及其函数 - 40 -
8.3 printf的实现分析 - 41 -
8.4 getchar的实现分析 - 43 -
8.5本章小结 - 44 -
结论 - 45 -
附件 - 46 -
参考文献 - 47 -
第1章 概述
1.1 Hello简介
P2P:
编译器的驱动程序负责将源文件转换为目标文件。你已经使用高级语言编写了一个名为hello.c的文件。GCC编译器驱动程序接下来读取这个hello.c源文件,并将其转换为一个名为hello的可执行目标文件。这个编译过程可以分为四个主要阶段。首先,预处理器(cpp)根据以字符#开头的命令修改原始的C程序,生成一个名为hello.i的中间文件。接着,编译器(ccl)将hello.i翻译成汇编语言程序的文本文件hello.s。然后,汇编器(as)将hello.s翻译成机器语言指令,并将结果保存在目标文件hello.o中,这个目标文件是可重定位的。最后,由于hello程序调用了标准C库的printf函数,因此需要使用链接器(ld)将包含printf函数定义的printf.o文件与hello.o程序合并,最终得到名为hello的可执行文件。在Linux系统中,通过内置的命令行解释器shell加载运行hello程序。hello程序会fork一个新的进程,完成了hello.c中定义的进程间通信(P2P)过程。
020:
在shell中,fork产生了一个子进程,通过execve加载了hello。首先,它清除了当前虚拟地址中已存在的用户部分数据结构,然后为hello的代码段、数据、bss以及栈区域创建了新的区域结构。接着,进行虚拟内存映射,并设置程序计数器,将其指向代码区域的入口点,从而进入程序入口。一旦程序开始载入物理内存并进入main函数,CPU就为hello分配时间片并执行其逻辑控制流。hello利用Unix I/O管理来控制输出。执行完成后,shell的父进程将回收hello进程,并且内核会从系统中清除hello的所有痕迹。至此,hello完成了O2O的过程。
1.2 环境与工具
硬件环境:CPU:12th Intel Core i5 12500H
软件环境:Windows 11, VMware,Ubuntu-22.04.3-desktop-amd64
工具:gcc, gdb,edb,vscode,readelf
1.3 中间结果
图1-1 初始文件及中间结果
1.4 本章小结
本章主要介绍了hello的P2P,020过程,并列出了本次实验信息、环境、中间结果,和生成的一些中间文件。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器(cpp)在编译器处理源代码之前对其进行处理,即根据以字符#开头的命令(主要包括文件包含、宏定义和替换、条件编译等特定操作),修改原始的c程序。
作用:提高代码的可读性、可维护性和编译效率。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i :
图2-1 预处理命令
2.3 Hello的预处理结果解析
查看预处理文件内容:
图2-2 hello.i文件
有以下变化:
1)文件长度行数增加了,这是因为头部的3个包含头文件stdio.h、unist.h和stdlib.h都被替换成了完整的内容。
2)注释内容被删除。
3)每一行’#’后面的数字表示行号, 表示编译器原始代码的位置,便于调试和错误报告。
此外,预处理器会将所有宏定义替换为其定义的内容,而此程序没有宏定义。
2.4 本章小结
介绍并展示了linux的预处理命令,查看了预处理文件并比较其与源文件的差别。
第3章 编译
3.1 编译的概念与作用
概念:编译器(cc1)将高级编程语言(经过预处理后的C语言代码)转换为汇编语言代码的过程。该程序包含函数 main 的定义。
作用:将高级语言的抽象表达(如数据类型和函数调用)翻译为低级汇编语言表示,使其更接近机器语言。同时编译器可以对中间代码进行各种优化,提高生成的汇编代码的运行效率。
3.2 在Ubuntu下编译的命令
gcc -S hello.c -o hello.s
图3-1 编译命令
3.3 Hello的编译结果解析
编译后的.s文件:
图3-2 hello.s文件
3.3.1数据
1)常量
数字常量:源代码中使用的数字常量一般储存在.text段中,而该程序的 if语句比较时使用的数字变量5是作为立即数直接嵌入在cmpl指令中的。
图3-3 数字常量存储
字符串常量:在 printf 等函数中使用的字符串常量存储在 .rodata 段中。
图3-4 字符串常量
2)变量
局部变量:可以发现局部变量是储存在栈中的某一个位置的或是做为立即数储存在寄存器中的。我们可以对源代码中的每一个局部变量逐一分析。源程序中的局部变量共有三个,一个是循环变量i,以及int型argc和数组argv[],
1)对于int型i,我们发现它储存在栈中地址为-4(%rbp)的位置,对于i的操作如下:
图3-5 局部变量i的存储
2)局部变量argc代表程序运行时输入的变量的个数,可以发现它储存在栈中地址为-20(%rbp)的位置,对于它的操作主要是与立即数5比较之后确定有一部分代码是否执行,如果等于5,则跳转至.L2。具体汇编代码如下:
图3-6 局部变量argc的存储
3)对于局部变量argv[],它是一个保存着输入变量的数组,观察发现它储存在栈中,具体汇编代码段如下:
图3-7 局部变量argv[]的存储
3.3.2赋值
变量赋值的操作在程序中出现了两次,一次是对于argv[] 元素的赋值,一次是循环变量i的在循环中的赋值,我们分别进行分析。
1)对于数组argv[]元素的赋值,正如图3-7所示,movq -32(%rbp), %rax表示从栈帧中的偏移量 -32 处读取一个 64 位的值,并存入寄存器 rax。addq $24, %rax表示将寄存器 rax 的值增加 24,这相当于 argv[3] 的地址。在这之后rax的值加16、加8,分别代表argv[2]、argv[1]的地址。比较特别的是,将 rax 的值增加 32,相当于 argv[4] 的地址,然后从 rax 所指向的内存地址(即 argv[4] 的地址)读取 64 位值,存入寄存器 rdi,作为函数的参数。
图3-8 argv[4]的赋值
2)对于局部变量i,每次循环结束的时候都对齐进行+1操作,具体的操作汇编代码如下:
图3-9 i的赋值及更新
3.3.3算数操作
此程序中只有循环变量i在每一轮的循环中进行加1操作,对于这个局部变量的算术操作的汇编代码如下:
图3-10 i的加1操作
3.3.4类型转换
函数atoi将输入的字符串转换为int型作为sleep函数的输入:
图3-11 string转换为int
3.3.5关系操作
源代码中一共出现了两处关系操作:
- if语句中5和变量argc的比较,对应的汇编代码如下:
图3-12 比较操作1
- for循环中循环变量i和9的比较,当循环变量i大于等于9的时候将进行条件跳转:
图3-13 比较操作2
3.3.6结构操作
此程序中出现的结构操作只有数组操作,argv数组的值均被存储在栈中,使用寄存器rax作为数组的索引。
图3-14 数组寻址
3.3.7控制转移
1)if语句有条件转移,argc不是5则跳转,是5则执行if中的语句。
图3-15 if语句
2)for循环也有条件转移,当循环变量i大于等于9的时候将进行条件跳转,如图3-7。
3.3.8函数
1)main函数:
参数:argc和argv[],其中argc储存在%rdi中,argv[]储存在栈中。
返回值:源代码中返回语句是return 0,因此在汇编代码中最后是将%eax设置为0并返回这一寄存器。
图3-16 main函数
2)printf函数:
参数:第一次调用无传参;for循环中调用的时候传入了argv[1]、argc[2]和argc[3]的地址。
调用:第一次是满足if条件时调用,第二次是在for循环条件满足时调用。
图3-17 printf函数
3)atoi函数:
参数:argc[4]
返回值:整型
图3-18 atoi函数
4)sleep函数:
参数:转换成int型的argc[4]
返回值:无
图3-19 sleep函数
3.4 本章小结
本章主要介绍了在将修改后的源程序文件转换为汇编程序的过程中,主要发生的变化以及汇编代码文件中的主要组成部分。还探讨了源代码中的一些主要操作在汇编代码中的表现形式。总的来说,编译器在进行词法分析和语法分析之后,确认源代码符合语法要求,然后将其转换为汇编代码。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器(as)将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件 hello.o 中,该文件是二进制文件。
作用:将汇编代码根据特定的转换规则转换为二进制代码,也就是机器代码,机器代码也是计算机真正能够理解的代码格式。
4.2 在Ubuntu下汇编的命令
gcc –c hello.s –o hello.o
图4-1 hello.o文件
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
使用readelf命令,将elf结果保存到elf.txt中:
图4-2 生成elf格式
1)ELF头
ELF头以一个16字节的序列开始,这个序列描述了文件生成系统的字大小及其他相关信息。ELF头的其余部分包含帮助链接器语法分析和解释目标文件的各种信息,包括:ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中每个条目的大小和数量。具体ELF头的代码如下:
图4-3 ELF头
2)节头表:描述了.o文件中每一个节出现的位置,大小,目标文件中的每一个节都有一个固定大小的条目。
图4-4节头表
3)重定位节:
重定位节包含了在代码中使用的一些外部变量等信息,在链接过程中,需要根据重定位节的信息对某些变量符号进行修改。链接器会根据重定位节的信息,决定如何计算外部变量符号的正确地址,例如通过偏移量等信息进行计算。
本程序需要重定位的信息有:.rodata中的模式串,puts,exit,printf,atoi, sleep,getchar。
图4-5重定位节
4)符号表
.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。例如本程序中的getchar、puts、atoi等函数名都需要在这一部分体现,具体信息如下图所示:
图4-6符号表
4.4 Hello.o的结果解析
使用objdump -d -r hello.o > fan_hello.s反汇编,并保存在fan_hello.s中
图4-7生成反汇编文件
图4-8反汇编文件
与第3章的 hello.s进行对照分析,得出以下几处不同:
1)进制不同:hello.s的数字用十进制表示,而hello.o反汇编之后数字的表示是十六进制的。
图4-9 (a)反汇编代码 图4-9 (b)汇编代码
2)条件跳转格式不同:hello.s中给出的是段的名字,例如.L2等来表示跳转的地址,而hello.o的反汇编代码由于已经是可重定位文件,对于每一行都已经分配了相应的地址,因此跳转命令后跟着的是需要跳转部分的目标地址。
3)全局变量访问不同:在 hello.s 中,直接通过段名称加 %rip 寄存器访问 .rodata 段。然而在 hello.asm 中,初始阶段是不知道 .rodata 段的数据地址的,所以只能先写成 0(%rip) 进行访问。而在重定位和链接后,链接器会更新确定的地址以正确访问 .rodata 段中的数据。
4.5 本章小结
本章对汇编过程进行了一个简明而完整的叙述。汇编器处理后,生成了一个可重定位文件,为下一步的链接做好了准备。通过与 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
图5-1 链接
5.3 可执行目标文件hello的格式
图5-2 ELF可执行目标文件的格式
1)ELF头
图5-3 ELF头
2)节头: 描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
图5-4 节头表
3)程序头部表:
参数含义:
Offset:目标文件中的偏移;
VirtAttr:内存地址;
FileSiz:目标文件中段的大小;
MemSiz:内存中的段大小
Flags:运行时访问权限
图5-5 头部表
5.4 hello的虚拟地址空间
使用edb加载hello可执行文件,在Data Dump窗口可以看到虚拟地址空间分配情况:
图5-6 edb的Data Dump窗口
发现该程序是从地址0x401000开始的,并且该处有ELF的标识。可以判断这是从可执行文件加载的信息。
接下来分析.elf中程序头的部分:其中PHDR保存的是程序头表;INTERP保存了程序执行前需要调用的解释器;LOAD记录程序目标代码和常量信息;DYNAMIC储存了动态链接器所使用的信息;NOTE记录的是一些辅助信息;GNU_EH_FRAME保存异常信息;GNU_STACK使用系统栈所需要的权限信息;GNU_RELRO保存在重定位之后只读信息的位置。
5.5 链接的重定位过程分析
运行命令:objdump -d -r hello > objdump_hello.s,查看反汇编代码:
图5-7 objdump的反汇编
链接的重定位过程主要包括两个步骤:符号解析和重定位。
符号解析:链接器收集符号定义和引用(每个目标文件都包含符号表),然后将每个符号引用与相应的符号定义匹配起来。如果找到多个定义,链接器会报错(符号冲突)。
重定位:链接器将所有目标文件的代码和数据段分配到最终的内存地址空间中,然后修改目标文件中的代码和数据,使得所有符号引用都指向其最终的内存地址。
hello与hello.o的不同:
在链接过程中,hello中加入了代码中调用的一些库函数,如getchar、sleep等,同时每一个函数都有了相应的虚拟地址。例如函数sleep、getchar都有明确的虚拟地址。
图5-8 sleep、getchar函数虚拟地址展示
5.6 hello的执行流程
图5-9 hello程序执行时函数的地址
例如,下图是main函数调用puts函数的过程:
图5-10 main函数调用puts函数
5.7 Hello的动态链接分析
当程序调用由共享库定义的函数时,编译器无法预先确定该函数的地址。为了解决这个问题,编译系统提供了延迟绑定的方法,将函数地址的绑定推迟到第一次调用该函数时完成。这个过程通过全局偏移表(GOT)和过程链接表(PLT)的协作来实现。在加载时,动态链接器会重定位GOT中的每个条目,使其包含正确的绝对地址,而PLT中的每个函数负责跳转到不同的共享库函数。通过观察edb,可以发现执行dl_init之后.got.plt节的变化。
先观察hello.elf中.got.plt节的地址,为0x404000:
图5-11 got.plt的地址
执行init前:
图5-12执行init前 got.plt的地址
执行init后:
图5-13执行init后 got.plt的地址
5.8 本章小结
在链接过程中,各种代码和数据片段被收集并组合成一个单一的可执行文件。通过使用链接器,实现了分离编译,使得我们不必将整个应用程序组织成一个巨大的源文件,而是可以将其分解为多个可独立管理的模块。这样,在需要时只需将这些模块链接在一起即可完成整个应用程序的构建。经过链接后,我们已经生成了一个可执行文件,接下来只需在shell中运行相应的命令,即可为该文件创建进程并执行它。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是计算机中正在运行的一个程序的实例。它是一个动态的实体,包含了程序代码以及程序执行时的当前活动,包括程序计数器、寄存器内容和变
量。进程是操作系统进行资源分配和任务调度的基本单位。
作用:
1)在资源管理上,每个进程都有自己的地址空间、内存、文件描述符等资源。操作系统通过进程管理来分配和回收这些资源,确保多个程序可以在系统中有效运行。
2)通过进程,操作系统可以实现并发执行,即多个进程可以同时在系统中运行。这利用了多核处理器的优势,提高了系统的整体效率和性能。
6.2 简述壳Shell-bash的作用与处理流程
Shell是用户与操作系统之间的接口,它允许用户输入命令来控制操作系统的行为。Bash Shell处理用户命令的一般流程如下:
1)显示提示符:Shell显示提示符,等待用户输入命令。通常是$或#。
2)读取命令:用户输入命令后,Shell读取整行输入。这一步通常使用read系统调用。
3)解析命令:Shell将用户输入的命令解析成单独的词(tokens),如命令名称、选项、参数等。它会考虑引号、转义字符和管道符号。
4)命令展开:路径名展开:将文件名模式(如*.txt)展开为匹配的文件名。变量替换:将变量(如$HOME)替换为其值。命令替换:将反引号括起来的命令(如`date`)替换为其输出。算术扩展:计算包含在$(( ))中的算术表达式。
5)执行前处理:
管道处理:识别并设置管道(|),将一个命令的输出作为下一个命令的输入。重定向:处理输入输出重定向(>、>等),调整文件描述符。后台执行:识别后台执行符号(&),将命令放入后台执行。
6)查找命令:
内建命令:检查是否是内建命令(如cd、echo)。外部命令:在系统的PATH环境变量指定的目录中查找可执行文件。
7)创建进程:使用fork系统调用创建子进程。如果是外部命令,子进程调用exec系列系统调用加载并执行程序。
8)等待命令完成:如果是前台进程,Shell等待子进程完成,返回其退出状态。如果是后台进程,Shell不等待其完成。
9)返回提示符:命令执行完毕后,Shell显示新的提示符,等待下一次用户输入。
图6-1shell-bash的处理流程
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个与调用进程几乎完全相同的新进程。操作系统会将父进程的地址空间(代码段、数据段、堆和栈)复制到子进程。子进程获得父进程的一个副本,包括打开的文件描述符、环境变量和其他资源。这意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程获得一个唯一的进程ID(PID),这与父进程的PID不同。fork被调用一次,却返回两次,子进程返回0,父进程返回子进程的PID。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
execv函数在当前进程的上下文中加载并运行一个新程序。它会加载指定的可执行文件,并接受参数列表和环境变量列表。execve只有在出现错误时才会返回到调用程序。与fork函数一次调用会返回两次不同,execve只会调用一次并且从不返回。
一旦可执行文件被加载,execve会启动启动代码。启动代码负责设置栈,将可执行文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的入口点或第一条指令来执行该程序,从而将控制权交给新程序的主函数。
6.5 Hello的进程执行
相关概念:
1)进程上下文:内核在重新启动被抢占的进程时所需的状态信息。它包括通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈,以及各种内核数据结构的值。
2)上下文切换:内核为每个进程维持一个上下文,上下文就是在进程执行的某些时刻,内核可以决定枪战当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度。系统调用和中断也可能引发上下文切换。
3)逻辑控制流:尽管系统中通常有许多其他程序在运行,每个进程都可以获得一种假象,仿佛它在独占地使用处理器。如果使用调试器单步执行程序,我们会看到一系列的程序计数器(PC)值,这些值唯一地对应于程序的可执行目标文件中的指令,或者是运行时动态链接到程序的共享对象中的指令。这个 PC 值的序列称为逻辑控制流,简称逻辑流。
Hello进程的执行过程:
shell为hello fork了一个子进程,这个子进程和shell进程有独立的逻辑控制流,它们是并发进行的,但是若hello是以前台任务进行的,那么shell将会挂起等待hello运行结束,否则它们将会“同时运行”。
内核调度hello的进程开始进行,输出Hello 2022111009周天宇 178****5218 5后执行sleep(atoi(argv[4]))函数,这个函数是系统调用,显式地请求让调用进程休眠。内核转而执行其他进程,这时就会发生一个上下文转换。5s后又会发生一次进程转换,恢复hello进程的上下文,继续执行hello进程。其余9次sleep的执行类似。
循环结束后,后面执行到getchar函数,getchar函数是通过read函数实现的。这时hello进程将会因为执行系统调用函数read而陷入内核,所以这里又会发生一个上下文切换转而执行其他进程。当数据被读取到缓存区后,将会发生一个中断,使内核发生上下文切换,重新执行hello进程,与调用sleep时的进程执行情况类似。
图6-2 上下文切换
6.6 hello的异常与信号处理
异常类型:
图6-3 异常类型
异常处理流程:
图6-4 中断异常处理
图6-5 陷阱异常处理
图6-6 故障异常处理
图6-7 中止异常处理
程序正常执行:
图6-8 正常执行
随机按字符,无影响:
图6-9 运行时输字符
运行时按Ctrl+C, 会发送SIGINT信号,向子进程发送SIGKILL信号使进程终止并回收:
图6-10 运行时输Ctrl+C
运行时按下Ctrl+Z,会发送SIGTSTP信号,子进程被挂起:
图6-11 运行时输Ctrl+Z
输入ps会显示所有的进程及其状态,可以发现hello是被挂起的状态,PID为9190:
图6-12输入ps
输入jobs可以显示暂停的进程:
图6-13输入jobs
输入pstree可以显示进程树,显示所有进程的情况:
图6-14输入pstree
输入fg可以使第一个后台作业变成前台作业,由于hello是第一个后台作业,所以它又变为前台执行:
图6-15输入fg
kill指令,先输入ps查看hello的PID,输入kill -9 9190,杀死hello进程:
图6-16输入kill
6.7本章小结
本章主要讲解了 hello 可执行文件的执行过程,涵盖了进程的创建、加载和终止,以及通过键盘输入等环节。从创建进程到回收进程的整个过程中,涉及到各种异常和中断信息。程序的高效运行离不开异常、信号和进程等概念,正是这些机制保障了 hello 程序在计算机上的顺利运行。
第7章 hello的存储管理
7.1 hello的存储器地址空间
(以下格式自行编排,编辑时删除)
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
1)逻辑地址:包含在机器语言指令中用来指定一个操作数或一条指令的地址。它促使程序员把程序分成若干段。每一个逻辑地址都由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。对应于hello.o中的相对偏移地址。
2)线性地址:逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
3)虚拟地址:指的是在操作系统虚拟内存中的地址,是逻辑地址经过计算得到的结果,不能直接用于访问存储,需要通过MMU进行翻译才能得到物理地址。在hello的反汇编代码中,经过计算后得到的就是虚拟地址。
4)物理地址:计算机主存中连续字节单元的唯一地址。每个字节都有其独特的物理地址。虚拟地址经过MMU翻译后才能得到物理地址。在hello中,经过翻译得到的物理地址用于获取所需的数据。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel处理器上,逻辑地址到线性地址的转换是通过段式管理实现的。逻辑地址由两个部分组成:段选择子和偏移量。段选择子指示一个段描述符,该描述符包含段的基地址和长度信息,而偏移量则是相对于该段基地址的地址偏移量。
首先,根据逻辑地址中的段选择子找到对应的段描述符。段描述符位于全局描述符表(GDT)或局部描述符表(LDT)中,它包含段的基地址及其他控制信息。从段描述符中获取段的基地址,并将其与逻辑地址中的偏移量相加,得到线性地址。这个线性地址是一个32位地址,位于操作系统的虚拟地址空间中。
在生成线性地址后,处理器会检查访问权限,例如是否有足够的权限读写该段,是否在合法的内存范围内等。如果开启了分页机制,处理器会继续进行地址翻译,将线性地址转换为物理地址。这个过程包括查找页目录、页表项等。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种计算机内存管理方式,它将主存储器分割成大小固定的页,并将进程的地址空间也划分为相同大小的页,从而实现物理内存和逻辑地址之间的灵活映射。其要点如下:
1.分页机制:操作系统将内存划分成固定大小的页,通常为4KB或更大。处理器通过页表来管理这些页,页表将线性地址映射到物理地址。
2.页目录:页目录是特殊的页表,存储着指向其他页表的指针。在分页机制中,处理器首先使用线性地址的高位来索引页目录,根据这些位找到对应的页目录项。
3.页表:页表是存储在内存中的数据结构,用于将线性地址映射到物理地址。每个进程都有自己的页表。处理器使用线性地址的中间位来索引页表,根据这些位找到对应的页表项。
4.偏移量:线性地址的低位部分是偏移量,用于在页内定位具体的地址。
5.地址翻译:处理器使用线性地址的高位部分找到对应的页目录项,然后使用中间位找到对应的页表项,最后加上偏移量得到物理地址。
6.TLB缓存:为提高地址翻译速度,处理器使用TLB缓存已经翻译过的页表项。如果在TLB中找到了对应的项,就能直接获取物理地址,否则需要访问内存获取页表项。 TLB(Translation Lookaside Buffer)是一个高速缓存,存储了最近使用的一些页表项,以加速地址转换过程。
7.通过这些机制,页式管理实现了高效的地址映射和内存管理,为多任务操作系统提供了良好的支持。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB(Translation Lookaside Buffer)是一个硬件缓存,用于存储最近的一些虚拟地址到物理地址的映射。在四级页表结构中,虚拟地址通常分为四个部分:索引1、索引2、索引3和偏移量。这四个部分一起构成了一个层级结构,用于访问页表。
当CPU需要转换虚拟地址到物理地址时,首先会查询TLB来查找映射。如果TLB中找到了对应的映射,这被称为TLB命中,CPU可以直接使用TLB中存储的物理地址。如果TLB中没有找到虚拟地址到物理地址的映射,这被称为TLB未命中。在这种情况下,CPU必须通过多级页表来查找地址映射。CPU首先使用索引1找到第一级页表项,再使用索引2找到第二级页表项,以此类推,直到找到最终的页表项,并从中获取物理地址。
TLB的作用是加速地址翻译的速度,因为TLB存储了最近使用过的虚拟地址到物理地址的映射,减少了对页表的频繁访问,从而提高了内存访问的效率。
7.5 三级Cache支持下的物理内存访问
一旦获取了物理地址,处理器会开始访问三级缓存以及内存以获取物理内存中的内容。
L1缓存:L1缓存是距离处理器核心最近的缓存层,速度最快。当处理器需要访问内存中的数据时,首先会检查L1缓存。如果数据在L1缓存中找到了对应的副本,则可以立即访问这个数据,这被称为L1缓存命中。
L2缓存:如果在L1缓存中未找到需要的数据,处理器会继续检查L2缓存。L2缓存通常比L1缓存更大,但速度稍慢一些。如果数据在L2缓存中找到了对应的副本,则处理器会从L2缓存中读取这个数据,这称为L2缓存命中。
L3缓存:如果在L2缓存中也未找到需要的数据,处理器会继续检查L3缓存。L3缓存通常更大,但速度可能比L2缓存稍慢。如果数据在L3缓存中找到了对应的副本,则处理器会从L3缓存中读取这个数据,这称为L3缓存命中。
主存储器:如果在处理器的各级缓存中都未找到需要的数据,则处理器需要从主存储器中读取这个数据。这将导致一个内存访问周期,处理器从主存中读取数据并将其加载到适当的缓存层中。
这些缓存层级的存在可以极大地提高内存访问速度,因为较高级别的缓存通常速度更快,而且更接近处理器核心。只有在缓存中未命中时,才会导致对较慢的主存储器的访问。
图7-1 一个存储器层次结构的示例
7.6 hello进程fork时的内存映射
在fork()系统调用时,新的子进程被创建。最初,子进程会拥有与父进程相同的内存映射。然而,这些内存映射并不是实际共享的,而是采用了写时复制(copy-on-write)的机制。具体来说,当fork()调用发生时,操作系统会为子进程创建一个与父进程相同的虚拟地址空间副本,但在物理内存上并不会真正存在相同的副本。相反,它们会共享相同的物理页面。直到父进程或子进程中有一个尝试修改这些共享内存页面时,才会发生实际的复制。当有一方尝试修改共享内存区域时,操作系统会将该内存页面复制到一个新的物理内存页面,并且这个新页面只属于修改它的进程。这样就实现了进程间的内存分离,避免了不必要的内存复制,提高了效率。
因此,在fork()之后,父子进程共享相同的内存映射,但在任何一个进程中对这些映射的修改都不会影响到其他进程,直到有进程修改了这些共享的页面,触发了实际的复制操作。
7.7 hello进程execve时的内存映射
在一个进程调用execve()时,该进程的虚拟地址空间会被一个新的程序所取代。execve()系统调用会完成以下步骤:
1. 清除原地址空间映射:
*移除旧程序的代码、数据和堆栈等内容。
*清空原先程序使用的内存区域。
2. 加载新程序的可执行文件:
*从磁盘上的可执行文件中加载新程序的代码和数据到内存。
*构建新程序的堆和栈。
3. 更新程序计数器:
*将程序计数器设置到新程序的入口点,使得程序从新的代码段开始执行。
4. 建立新的内存映射:
*根据新程序的需求,在虚拟地址空间中建立新的内存映射,包括代码段、数据段、堆和栈。
这些步骤确保了进程在调用execve()后能够正确加载并执行新的程序,同时清除了旧程序的内存映射,为新程序的执行做好了准备。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM 缓存不命中称为缺页。这时会触发缺页中断,导致操作系统介入并进行相应的处理。处理流程如下:
-
触发中断:CPU访问虚拟地址对应的页面,但该页面不在物理内存中,触发缺页中断。
-
操作系统介入:CPU转交控制权给操作系统内核,执行缺页中断处理程序。
-
检查缺页原因:操作系统分析引起缺页的原因,可能是页面未分配、页面被交换到磁盘上或者是页面错误等。
-
处理缺页:
7.9动态存储分配管理
动态内存分配器负责管理堆的分配和释放,维护对堆内存的跟踪,并提供接口供程序员进行内存的动态管理。
堆是进程运行时用于动态分配内存的一块区域,通常位于进程地址空间的底部,由动态分配器进行管理。程序在运行时可以请求从堆中分配内存空间,并在使用完后释放。堆内存的大小通常在程序启动时确定,但在运行时可以动态增长或缩减。
在程序中,使用动态内存分配器接口(例如malloc)来请求和释放内存时,实际上是向动态内存分配器发出请求,而动态内存分配器会在堆中进行相应的操作。
动态内存分配器可以分为两种类型:显式分配器和隐式分配器。
1)显式分配器:程序员显式地分配和释放内存。在这种情况下,程序员负责跟踪内存的分配和释放,并通过特定的API手动请求分配内存和释放不再使用的内存。
2)隐式分配器:由编程语言或者运行时环境自动处理内存的分配和释放。在这种情况下,内存的分配和释放是由语言或者环境的机制隐式完成的,程序员无需手动进行内存管理。例如,在一些高级语言中,有自动的垃圾回收机制,负责自动释放不再使用的内存。
不论是显式还是隐式的动态内存分配器,其目的都是为了方便程序员管理内存,提高内存的利用率和程序的性能。
7.10本章小结
本章深入探讨了hello程序的存储器地址空间和在Intel处理器上的内存管理机制,包括段式管理和页式管理。具体讨论了在Intel环境下的虚拟地址到物理地址的转换过程,以及物理内存的访问方式。同时,解释了TLB、四级页表和三级缓存在地址转换和内存访问中的作用和支持。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
Linux 中的 I/O 设备管理是通过文件系统来进行的,I/O 设备以文件的形式呈现,这些文件都位于 /dev 目录下。Linux 贯彻"一切皆文件"的理念,提供了统一的 I/O 设备访问模型,因此对于每个设备,用户可以通过文件 I/O 系统调用(如 open、read、write、close 等)来进行访问和控制。
8.2 简述Unix IO接口及其函数
Unix I/O接口是Unix和类Unix操作系统中用于进行输入输出操作的一组接口和函数。这些函数包括了文件操作、套接字操作、管道操作等,提供了统一的接口来处理各种不同类型的I/O操作。以下是Unix I/O接口中常见的函数:
Unix I/O函数按照功能和参数可以分为以下几类:
1.文件操作函数:
int open(const char *pathname, int flags, mode_t mode):打开文件或创建文件。
int close(int fd):关闭文件。
ssize_t read(int fd, void *buf, size_t count):从文件中读取数据到缓冲区。
ssize_t write(int fd, const void *buf, size_t count):将数据从缓冲区写入文件。
off_t lseek(int fd, off_t offset, int whence):在文件中定位文件指针的位置。
int fcntl(int fd, int cmd, …):对文件描述符进行控制操作。
int rename(const char *oldpath, const char *newpath):重命名文件。
int unlink(const char *pathname):删除文件名。
2.文件描述符操作函数:
int dup(int oldfd) / int dup2(int oldfd, int newfd):复制文件描述符。
3.设备操作函数:
int ioctl(int fd, unsigned long request, …):进行设备控制操作,对特定设备进行设置和查询。
4.套接字操作函数:
int socket(int domain, int type, int protocol):创建套接字。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen):绑定地址或建立连接。
int listen(int sockfd, int backlog):监听套接字上的连接请求。
5.多路复用函数:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout):用于多路复用I/O操作,检查多个文件描述符是否就绪。
6.管道操作函数:
int pipe(int pipefd[2]):创建管道。
8.3 printf的实现分析
printf函数接收一个格式字符串fmt和参数,按照fmt的格式输出匹配到的参数。在底层实现中,printf调用了两个外部函数:vsprintf和write。vsprintf负责将格式化的字符串写入缓冲区中,而write系统调用则将缓冲区中的内容写入到输出设备。这通常涉及到一个陷阱-系统调用,如int 0x80或syscall,将控制权从用户态切换到内核态。
printf()函数原型:
图8-1 printf函数示意图
其中, (char*)(&fmt) + 4) 表示的是…中的第一个参数。fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。
Vsprintf()函数原型:
图8-2 Vsprintf函数示意图
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
write()函数原型:
图8-3 write函数示意图
这里是给几个寄存器传递了几个参数,然后通过系统来调用sys_call这个函数。
sys_call函数:
图8-4 sys_call函数示意图
这个函数的功能就是不断的打印出字符,直到遇到:‘\0’; [gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串。
8.4 getchar的实现分析
在系统中,键盘输入会触发异步中断。当用户按下键盘上的键时,硬件将该动作转换为扫描码并发送给计算机。键盘中断处理程序接收按键的扫描码,并将其转换为相应的ASCII码,然后将ASCII码存储到系统的键盘缓冲区中。这个缓冲区通常是一个队列,用于存储键盘输入的字符序列。
在C语言中,getchar() 函数通常调用底层的系统调用,通常是 read()。在键盘输入的背景下,getchar()尝试从标准输入读取字符。它调用底层的 read() 系统调用,该调用会阻塞程序直到有字符输入或者发生错误。在键盘输入的情况下,read() 会等待按键输入,并当接收到字符后,将其读取为ASCII码。一旦接收到回车键的按下,getchar() 会从键盘缓冲区中读取字符,并返回相应的ASCII码值。这样,getchar()实现了一次键盘输入的处理,等待用户输入并在输入完成后返回。
因此,getchar()在键盘输入的实现中会通过系统调用 read() 从键盘读取字符,但会等待用户输入完成(按下回车键)后才返回。
8.5本章小结
本章重点介绍了Linux的I/O设备管理以及Unix的I/O接口函数。在此基础上,我们深入分析了printf和getchar函数的工作原理,突出了I/O在系统中的关键性作用,并探讨了Unix I/O接口的重要性。Linux操作系统将系统中的I/O设备抽象为文件,这一概念的引入大大简化了对I/O操作的处理。通过对文件的操作,包括打开、位置更改、读写和关闭等,实现了对各种I/O设备的统一管理。这种设计使得编程者可以用相同的方式处理不同类型的I/O设备,提高了代码的可读性和可维护性。
结论
Hello的一生是一个看似简单实则十分复杂的过程,当中蕴含着每一个c语言程序执行前的必经之路:
首先是可执行文件的形成过程:
- 编写:使用高级语言编写.c文件。
- 预处理:将.c文件转化为.i文件,合并外部库调用。
- 编译:将.i文件转化为.s汇编文件。
- 汇编:将.s文件翻译为机器语言指令,生成可重定位目标程序hello.o。
- 链接:将.o文件和动态链接库链接为可执行目标程序hello。
然后是在系统和硬件的精密协作下的运行过程:
1. 在Shell中输入命令,子进程被创建,并调用execve函数加载并运行hello。
2. CPU为进程分配时间片,加载器设置程序入口点,hello开始执行逻辑控制流。
3. 通过MMU映射虚拟内存地址至物理内存地址,CPU访问内存。
4. 动态内存分配根据需要申请内存。
5. 信号处理函数处理程序的异常和用户请求。
6. 执行完成后,父进程回收子进程,内核删除为该进程创建的数据结构,到此hello运行结束,完成了它短暂的“演出”。
感想:现代计算机是如此的精密,从硬件到软件,环环相扣又紧密配合,包含着无数巧妙的设计思想,让学习计算机的我们既“头疼”又深深被其中的奥秘吸引。希望今后能继续深入认识这个“朋友”,让它成为我们改造世界的利器。
附件
参考文献
[1] bash处理的12个步骤流程图_linux bash流程详解-CSDN博客
[2] 本电子书信息 - 深入理解计算机系统(CSAPP) (gitbook.io)
[3] [转]printf 函数实现的深入剖析 - Pianistx - 博客园 (cnblogs.com)
[4] 读CSAPP(4) - 虚拟内存 – heisenbug blog (heisenbergv.github.io)
[5] C语言编译过程详解 - kinfe - 博客园 (cnblogs.com)