程序人生-Hello’s P2P
摘要
本文以《深入理解计算机系统》为脉络,利用gcc和edb等工具,讲述了hello程序从预处理、编译到汇编、链接最后生成可执行程序的主要过程。本文还就具体实例分析了进程中存储管理、异常处理和I/O操作的基本原理和方法。
关键词:CSAPP,程序底层处理,操作系统,异常处理
第1章 概述
1.1 Hello简介
hello在P2P的过程中,将其源代码进行了预处理、编译、汇编、链接后,最终形成了可执行目标文件hello。而在所谓“020”过程中,从bash执行fork产生的子进程开始,调用execve等函数,为hello分配了虚拟内存和时间片。在其时间片上CPU执行hello中的指令,并通过MMU、多级页表等结构为hello读取内存、缓存、磁盘等区域的数据。最后程序终止,hello所在的进程被回收,其资源被释放,hello的历程完结。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU
AMD Ryzen 7 4800H with Radeon Graphics 2.90GHz
RAM 32G
1.2.2 软件环境
Windows10 64位
VirtualBox + Ubuntu 20.04.4 64位
1.2.3 开发工具
Visual Studio Code,edb
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
1.3 中间结果
1.4 本章小结
本章介绍了实验的基本流程,实验环境和实验结果文件的基本情况。
第2章 预处理
2.1 预处理的概念与作用
处理发生在源代码编辑之后,汇编代码生成之前。C语言和C++语言中这个过程主要是将源代码中预处理指令部分展开,使程序呈现为若干有序阶段的代码,并且通过条件编译、宏定义等一系列手段对源代码进行编辑。
预处理的作用在于将源代码中仅提供预处理指令的部分展开完善为对应代码,并提高了源代码在不同的运行环境中编译的便利性。
2.2 在Ubuntu下预处理的命令
执行该指令后生成了hello.i文件,即预处理结果(图2.2-1)。
2.3 Hello的预处理结果解析
生成的hello.i文件(64.73kB)在从文件体积上远大于程序源文件hello.c(0.53kB)。相比23行代码的源文件,hello.i格式上同样为C语言代码,但内容多达3060行。其中前3042行包含了引用的stdio.h、unistd.h和stdlib.h三个库的代码,最后为预处理之后之后部分的源码(图2.3-1)。
2.4 本章小结
预处理部分并非是对源代码进行解释或编译的过程,而是根据预处理指令生成便于编译的、内容完整的源代码。
第3章 编译
3.1 编译的概念与作用
编译是指将程序源代码的指令转化为计算机可以识别的目标程序的过程,即从高级语言生成汇编语言。在编译过程中,编译程序需要通过词法分析和语法分析确认源代码中的指令都符合语法规范,然后将其翻译成等价的中间代码进而生成汇编代码。
编译的作用:①词法分析和语法分析中可以判断源代码中的指令是否符合语法规范。②生成中间代码并可以在此基础上对程序的逻辑进行简化,实现对程序的优化。③将中间代码变换成目标代码,生成汇编语言代码。
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.3.1 数据与赋值方法
①在main函数中,涉及到数值常量的部分在汇编文件中以立即数的方式实现。
判断参数数量的语句
在汇编中将4作为立即数存储:
for循环的边界条件和递增值
在汇编语句中将边界7(i<=7)和递增值1作为立即数存储。
②字符串部分存储在了只读数据段
③变量
程序中在main中声明的局部变量i
存储在栈中:
3.3.2 算术操作与关系操作及控制方法
①加法运算
执行i=i+1时:
使用了addl指令:
图3.3.2-2 汇编加法
②关系操作
判断是否相等:(if控制语句)
采用了cmpl指令+je指令:
判断是否小于(小于等于):
图3.3.2-5 比较
采用了cmpl指令+jle指令:
将17行的加法运算和53-54行的比较跳转语句结合,构成了完整的for循环控制语句。
3.3.3 数组与指针方法
在程序中唯一需要操作的数组为参数argv。在调用main的过程中其被存储在栈里。在程序中需要分别获取argv[1],argv[2],argv[3]三个位置的变量。
在汇编中通过移动指针的方法实现:
3.3.4 函数方法
在main函数中调用了exit,printf,atoi,sleep,getchar函数。
①main函数
参数:参数个数argc和参数数组地址argv
返回值:正确完成后返回0
源代码:
汇编指令:(初始化和存储参数环节)
②exit函数
功能:退出程序
参数:退出后返回值
源代码:
汇编指令:
③printf函数
参数:格式化字符串,参数地址
返回值:打印字符个数(未应用)
仅包括格式化字符串(被优化为puts):
源代码:
汇编指令:
包括格式化字符串及参数
源代码:
汇编指令:
④atoi函数
参数:待转换字符串地址
返回值:转换后的数字(若失败则返回0)
源代码:
汇编指令:
⑤sleep函数
参数:休眠时间值
返回值:返回结束时剩余秒数(若完成休眠过程则返回0)
源代码:
汇编指令:
⑥getchar函数
参数:无
返回值:获取的字符
源代码:
汇编指令:
3.4 本章小结
本章讲述了编译的过程和意义,并详细介绍了hello.c的源代码在编译后数据、指针、函数调用、运算等各个部分是如何被转化为汇编指令的目标代码的。
第4章 汇编
4.1 汇编的概念与作用
汇编指将汇编语言代码转换成机器语言的可重定位目标文件的过程。
汇编语言虽然十分接近与机器代码,但它是用可读性较高的文本格式表示的,因此需要转换成机器识别的二进制格式。
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
通过以下命令生成elf文件:
4.3.1 ELF头
ELF头部分(图4.3.1-1)包含了系统的基本信息,编码方式,程序入口信息,以及节的数量和大小等。
4.3.2 节头
节头部分(图4.3.2-1)描述了可重定向文件中各个节的大小,名称地址和偏移量。
4.3.3 重定位节
重定位节(图4.3.3-1)显示了链接器对引用的外部信息在链接时进行重定位,根据重定位节的重定位条目计算出正确的地址。其中.rodata中的两个字符串,调用的puts,exit等外部函数都需要重定位。
4.3.4 符号表
符号表(图4.3.4-1)存储了程序中定义和引用的函数和全局变量。
4.4 Hello.o的结果解析
通过以下命令(图4.4-1)可以得到反汇编结果dump_hello.txt:
text段的反汇编结果:
机器语言每条指令表现为一组或多组根据指令集生成的操作码和操作数(以上显示为16进制),每条指令都对应着汇编语言中的一条指令。
①反汇编结果中,汇编语言中的立即数和对应机器语言的操作数均采用16进制。
如上图(图4.4-2)中的38行,对rax执行加$0x18的加法操作,在原汇编语言中为下图(图4.4-3)中的45行加$24的加法操作。
②控制分支跳转的差别
在控制分支跳转的实现上,原汇编语言采用了上图(图4.4-4)中分段后跳转至指定段名称位置的方法。而反汇编后的代码(图4.4-5)中则采用了直接跳转到机器语言目标代码的虚拟地址。
③函数调用的差别
在函数调用的实现上,原汇编语言采用了上图(图4.4-6)中call的方法。而反汇编后的代码中(图4.4-7)则采用了直接跳转到机器语言目标函数的虚拟地址。
4.5 本章小结
本章中介绍了汇编语言最终经过重定向等过程真正生成了机器可以识别的机器语言目标代码的过程,并分析了原始汇编代码与机器语言反汇编后结果的差别,理解了在汇编过程中产生的变化和差异。
第5章 链接
5.1 链接的概念与作用
链接时通过重定位和符号解析等方式将不同文件中的代码和数据组合,最终形成一个可执行文件的过程。
链接的作用在于提供了分离编译的可能性:当程序中单一部分出现改动,无需编译整个工程,只需要编译修改的文件即可。
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
①ELF文件头(图5.3-1):
②节头信息:
其中包括了各个节的大小,偏移量和起始地址等信息。
后续:
5.4 hello的虚拟地址空间
edb中显示的虚拟地址空间从0x400000开始,到0x405000结束。(图5.4-1)
根据5.3节中关于各节地址的内容,0x400000开始为PROGBITS等节(图5.4-2)。
0x401000到0x402000虚拟地址空间包括了init,plt,plt.sec,text,fini各节。(图5.4-3)
其后0x402000开始为rodata节等。(图5.4-4)
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
①反汇编代码中包括了被调用函数的部分plt(图5.5-1)等新增的节,同时在.text节中也增添了新的部分start(图5.5-2)。
②反汇编代码中调用时采用虚拟地址(图5.5-3),原hello.o在链接前采用文件内部地址(图5.5-4)。
5.6 hello的执行流程
_start
__libc_start_main
cxa_atexit
__libc_csu_init
main
printf
atoi
sleep
getchar
exit
5.7 Hello的动态链接分析
动态链接的过程是指在程序运行时才将各模块进行链接形成完整的程序,依靠GOT表实现,因此可以观察GOT表的变化。
由5.3可知,GOT表(.got.plt)起始位置为0x404000。调用dl_init前,0x4008往后16个字节均为0(图5.7-1)。
运行后发生变化(图5.7-2)可以判定是动态链接的结果。
5.8 本章小结
本章介绍了链接的概念和作用,了解了链接中重定位等方法,并通过ELF文件和在edb中调试hello等方法认识到了链接的过程和意义。
第6章 hello进程管理
6.1 进程的概念与作用
进程是计算机中一个执行中的程序的实例1,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。系统中的每个程序都运行在某个进程的上下文中。
在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
6.2 简述壳Shell-bash的作用与处理流程
Shell指“为用户提供用户界面”的软件,通常指的是命令行界面的解析器。一般来说,这个词是指操作系统中提供访问内核所提供之服务的程序。Shell也用于泛指所有为用户提供操作界面的程序,也就是程序和用户交互的层面
处理流程:
1)从终端读入输入的命令。
2)将输入字符串切分获得所有的参数
3)如果是内置命令则立即执行,否则调用相应的程序执行
4)shell 应该接受键盘输入信号,并对这些信号进行相应处理。
6.3 Hello的fork进程创建过程
调用fork函数可以创建一个与父进程仅pid不同均一致的子进程,即用户栈、数据段、代码段、共享库均一致。fork产生的子进程和父进程并发运行,当子进程运行时,父进程默认会等待其结束。
当shell识别出执行的"./hello 1 2 3"不是内置命令后,会fork创建新的子进程。
6.4 Hello的execve过程
创建出新的子进程会调用execve在进程的上下文上加载、运行程序。
execve在当前进程中加载并运行包含在可执行目标文件中的程序,并用改程序有效地替代了当前程序。其步骤包括:
①删除已存在的用户区域。
②映射私有区域。新的区域都是私有的、写时复制的。其中代码和初始化数据映射到.text和.data区。bss和栈堆映射到匿名文件 ,栈堆的初始长度0。
③将共享对象由动态链接映射到共享区域。
④设置PC,指向代码区域的入口点。
6.5 Hello的进程执行
进程执行其控制流时的每一时间段称为时间片。当内核通过上下文切换的机制令内核得以调度一个进程后,抢占当前进程并转移控制到新的进程。
在hello的程序中,sleep和getchar的运行中发生了上下文切换。其中sleep为显式切换,sleep系统调用请求让进程休眠,进程进入内核模式,此时内核执行上下文切换,切换到其他进程,并使该进程进入等待队列。直至定时器发出中断信号,内核执行中断处理,恢复该进程。
getchar调用使进程陷入内核,内核中的陷阱处理程序请求来自键盘缓存区的传输,此时内核进行上下文切换。当输入完成后,发生中断信号,内核再次通过上下文切换回到该进程。
6.6 hello的异常与信号处理
6.6.1 异常类型
在hello的运行中会出现四种异常。
6.6.2 各命令结果
①Ctrl+C
按下Ctrl+C后,进程收到终止信号,直接结束(图6.6.2-1)。
②Ctrl+Z
按下Ctrl+Z后,进程挂起,执行ps可得知其pid为3742,jobid为1。执行fg使其回到前台,程序继续执行直到结束(图6.6.2-2)。
③kill
挂起后执行kill -9指令(SIGKILL),将进程杀死。执行kill后,执行ps发现hello已被杀死,执行jobs发现此时也没有挂起的进程(图6.6.2-3)。
④乱按
在hello执行期间,多次按下ps+Enter,在运行结束后,刚才进入缓存区的文本被识别为命令。(图6.6.2-4)
6.7 本章小结
在本章中讲述了进程的执行过程,异常处理机制下内核对进程的调度,以及shell在运行hello时,对不同命令的响应。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是代码在编译之后出现在汇编代码中的地址。即hello.o中的部分。
线性地址是将段内的偏移地址加上段的基地址生成的,是连续的虚拟地址。
虚拟地址是程序从逻辑地址到物理地址的中间环节,不能直接访存,需经MMU翻译为物理地址。hello等大多数程序在虚拟内存上运行。
物理地址是内存中实际每个内存单元的编号,是放置寻址总线上的地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel平台中,逻辑地址由段标识符和段内偏移量构成。其中段标识符为16位长的字段,前13位是索引号。可以根据索引号在段描述符表中找到对应的位置,其中Base字段为段开始位置的线性地址。
对于一个逻辑地址([段选择符:段内偏移位置])来说,首先看段选择符的T1确定是GDT中的段(0)还是LDT中的段(1),然后根据寄存器获取地址和大小。最后根据索引号得到基地址,加上偏移量就可以得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
通过分页可以从线性地址转换到物理地址。CPU通过虚拟地址来访问主存,过程中需要MMU利用主存中的查询表将虚拟地址翻译为物理地址。
页表为页表条目的数组。若虚拟地址在页表中的虚拟页缓存在物理内存中,则直接返回物理地址;若未缓存在物理地址中,则需要在磁盘中查询该虚拟页的位置,并改变页表或物理内存。由此构建了页表到物理内存和虚拟内存的映射。(图7.3-1)
基本流程如下(图7.3-2):
①处理器生成虚拟地址,并传至MMU。
②MMU通过页表生成PTE地址。
③MMU将物理地址传至高速缓存或主存。
④若命中,返回请求的数据到处理器。
在执行过程中,若发生缺页异常,CPU将控制内核调用缺页异常处理程序,将物理内存中的牺牲页替换到磁盘中,调入新的页面并更新PTE,最后回到原进程。
7.4 TLB与四级页表支持下的VA到PA的变换
四级页表情况下,48位的虚拟地址中前36位的VPN分为4个9位的片,分别作用于一个到页表的偏移量。如图7.4-1所示,访问时寄存器包含L1页表的物理地址,VPN1提供到L1的PTE的偏移量,该PTE包含L2页表的基地址,再通过VPN2获得L2的PTE,直到在最后一级获得物理页号,与虚拟地址最后的VPO组合成物理地址,至此实现了VA到PA的转变。之后一方面添加到TLB,另一方面检测是否命中。
7.5 三级Cache支持下的物理内存访问
CPU借助上述MMU等将VA变换到PA后,根据cache将PA分为CT,CI,CO即标识位,组索引和块偏移。如图7.5-1所示,通过组索引可以获取到正确的组,然后依次匹配,若L1中命中请求的数据,直接返回;否则去依次前往L2,L3和主存查找并更新缓存。
7.6 hello进程fork时的内存映射
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只 读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任意一个后来进行写操作时,写时复制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
在hello运行前,bash中的进程执行了execve(“./hello”,NULL,NULL),在这个过程中:
①删除已存在的用户区域,即当前进程虚拟地址用户部分存在的区域结构。
②映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。
③映射共享区域。若hello与共享对象链接,则需要动态链接指定库并映射到用户虚拟地址空间中的共享区域。
④设置程序计数器。使当前进程的上下文的程序计数器使之指向代码区域入口。
7.8 缺页故障与缺页中断处理
调用缺页处理程序后,有以下过程:
①识别虚拟地址是否是合法的。缺页处理系统搜索区域结构链表,检查vm_start和vm_end,确认虚拟地址是否在某个区域内,若不合法则造成段错误,进程终止,即图上的【1】。
②验证视图进行的内存访问是否合法,若访问不合法则会触发保护异常。如对代码段中的只读页面进行写操作等,即图上的【2】。
③若确定缺页是合法的虚拟地址进行合法的操作时产生的,则正常处理该缺页:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
7.9 动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向更高的地址增长。对于每个进程内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为不同大小的块,每个块是一个连续的虚拟内存块,其状态为“已分配”和“空闲”两种情况。分配器的风格主要有两种:显式分配器和隐式分配器。
其中显式分配器要求应用显式释放所有已分配的块。如C中的malloc和free,以及C++中的new和delete均为显式分配。隐式分配器要求分配器检测当内存块什么时候不再被程序使用就释放该内存块。这个过程中隐式分配器自动释放不被程序使用的内存块,该过程称为“垃圾收集”。
7.10 本章小结
本章中介绍了hello运行过程中的存储管理过程,涉及到从逻辑地址、线性地址到虚拟地址再到物理地址的过程,讨论了fork和execve过程中的内存映射和缺页情况的处理方法。最后阐述了动态存储分配管理的过程。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件是m字节的序列,所有的IO设备都被模型化为文件,这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口Unix I/O。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O 接口
①打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想 要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个操作符。Linux Shell 创建的每个进程开始时都有三个打开的文件:标准输入,标准输出,标准错误。
②改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek操作,显式地将改变当前文件位置为k。
③读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的文件,当k>=m 时,执行读操作会触发一个称为EOF的条件,应用程序能检测到这个条件。类似地,一个写操作就是从内存中复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。
④关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
8.2.2 Unix I/O函数
①int open(char* filename,int flags,mode_t mode)
open 函数将 filename(文件名,含后缀)转换为一个文件描述符(C 中表现为指针),并且返回描述符数字。
②int close(fd)
fd 是需要关闭的文件的描述符(C 中表现为指针),close 返回操作结果。
③ssize_t read(int fd, void *buf, size_t n)
read 函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf .返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
④ssize_t write(int fd, const void *buf, size_t n)
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。图10-3 展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出返回。若成功则为写的字节数,若出错则为-1。
8.3 printf的实现分析
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5 本章小结
本章介绍了Unix 系统中的I/O 操作的基本形式,并解析了printf和getchar两个函数的内部实现。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
在剖析hello的一开始,他只是一串简单的C语言代码。随着对hello程序认识的一步步深入,我们逐渐认识到了为了hello的顺利运行所经历的复杂历程。
在预处理环节,C语言源代码中用户设定的宏定义和引用的头文件被展开,生成hello.i文件,但此时还仍是未解释的C语言文件。之后通过编译生成的hello.s就是经过解释的汇编代码了。此时的程序代码就逐渐变换为方便机器阅读、理解的格式。汇编后形成的hello.o彻底成为机器阅读的机器码,在链接后最终形成可执行文件hello。
而hello在shell中的运行也困难重重。shell识别到非内置命令后fork出能令hello运行的子进程;子进程中调用execve将hello的内容放入进程并加载映射了虚拟内存。在CPU分配给hello的时间片中,CPU执行了hello代码中的指令并从内存、缓存和磁盘中读取需要的数据。
hello的运行过程中,也有可能发生异常。无论是终止、中断、故障还是运行陷阱程序产生有意的异常,都会使内核暂时挂起hello所在的进程,直到某个时刻返回或是再也不会到hello所在的进程。
最后,hello所在的进程被回收了。hello的一生结束了。从创造它、解释它、执行它、观察它、回收它的过程中,我们看到了为了一个普通计算机程序,计算机硬件、软件各个部分的各司其职与和谐配合。
附件
参考文献
[^1] :EBryant R, David R.O’Hallaron. 深入理解计算机系统[M]. 3北京:机械工业出版社,2016:508.
1 ↩︎