计算机科学与技术学院
2022年5月
Hello是一个最简单的程序,本文通过对hello从编写到执行的每一个中间步骤及其实现的底层机制原理都进行了详尽的分析。其中利用了gdb,edb等工具对其代码的汇编进行了解析。通过利用hello在系统中的运行,我们还拓展到对计算机存储的研究,揭示了程序如何利用,调用计算机的存储,如何在计算机上下文当中以流水线运行,如何处理来自外部的种种信号等内容。
关键词:hello.c;汇编语言;进程管理;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
在壳中(shell),进程管理将用fork为其创建子进程,使得复制出一份于父进程一样的参数和储存空间的子进程,用execve解析输入的命令,将其送入子函数中执行,并为其分配内存和流水线中的时间片。这样子便是P2P,即从程序到进程的转变。
程序执行时会访问虚拟内存,从而在物理内存上留下足迹,这都是依靠TLB、4级页表、3级Cache,Pagefile等等组织结构实现的。同时在执行过程中shell还会处理来自外部的各类信号,为HELLO的执行保驾护航。当整个程序执行完后,Bash会回收所有HELLO留下的痕迹,这便是O2O即From Zero-0 to Zero-0
1.2 环境与工具
硬件环境:AMD Ryzen 5 Mobile 3500U 512GB储存 8GB 内存
软件环境:Ubuntu64 2020 gdb codeblocks
1.3 中间结果
hello.i 预处理之后文本文件
hello.s 编译之后的汇编文件
hello.o 汇编之后的可重定位目标执行文件
hello 链接之后的可执行目标文件
hello.o.obj Hello.o的反汇编文件
hello.obj Hello的反汇编文件
1.4 本章小结
HELLO程序的执行看似简单,实则波澜壮阔,历经千险。其中每一步都离不开计算机系统对其软硬件的支撑,是计算机系统精巧的构造使得HELLO能够从诞生执行,到恢复如初,下面我们将正式开始HELLO的程序人生。
第2章 预处理
2.1 预处理的概念与作用
预处理主要是对各种预处理命令进行处理,具体包括对头文件的包含,宏定义的扩展,条件编译的选择等。
C语言的预处理器在源代码编译之前对其进行一些文本性质的操作。它的主要任务包括删除注释、插入被#include指令包含的文件内容、定义和替换由#define指令定义的符号,同时确定代码的部分内容是否应该根据一些条件编译指令进行编译。
2.2在Ubuntu下预处理的命令
具体的过程:
2.3 Hello的预处理结果解析
以上为hello.i文件的部分截图,仔细查看其中内容可以方向里面包含了我们内置的一些基本函数,诸如fscanf,putchar,getw,putw等。它还对我们源代码中的头文件#include <stdio.h>#include <unistd.h>#include <stdlib.h>进行了展开。
2.4 本章小结
本章研究了程序从.c文件向.i文件的转换过程,这便是预处理的过程,其作用便是整合头文件,替换宏定义,并对部分代码进行了编译,为后续处理提供支持。
第3章 编译
3.1 编译的概念与作用
编译器能够识别代码中的词汇、句子以及各种特定的格式,并将他们转换成计算机能够识别的二进制形式,这个过程称为编译(Compile)。
编译包括词法分析、语法分析、语义分析、性能优化、生成可执行文件五个步骤,它将hello.i文件转换为hello.s文件,即将高级语言转换为机器能读懂的机器语言,其具备具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用等多种功能。
3.2 在Ubuntu下编译的命令
其具体过程如下:
3.3 Hello的编译结果解析
3.3.1数据
字符串储存在内存的,rodata段(read only只读数据段),且这里有两串字符。
声明main为globe类型的全局变量。
可以看到%rbp栈帧被拓展了4位,其中应当储存有我们函数中的唯一局部变量i,因为定义i位int型,所以这里分配4个字节。
3.3.2赋值
通过寄存器赋值或者立即数赋值。
3.3.3算术操作
将%rax中的值加上立即数16并存到%rax中。
对i进行i++操作。
3.3.4关系操作
比较i和7的大小关系以实现循环。
3.3.5数组操作
分别取数组中的元素,其对基址rbp的偏移分别为32,16,和8。
3.3.6控制转移
比较二者大小后判断是否跳转到.L4执行。
3.3.7函数调用
调用sleep函数
调用Puts函数。
3.4 本章小结
通过对汇编后的文件分析,一一对一于源码中的各种功能,实现跳转,算数运算,比较的各种功能。汇编语言较于源码不易读懂,但它更亲和于机器,其位之后转化为机器码铺平了道路。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念是将hello.s文件通过翻译变成一类可重定向目标二进制文件——hello.o。
他的作用是将汇编语言转化为机器可以理解的二进制代码。
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
通过readelf -a hello.o获取ELF头。
elf头中包含了数据存储方式为小端序,采用补码形似表示,指明这是一个64位的头,并给出了系统相关信息。
这是一个节头表,其中包含了每个节的地址,偏移,类型,名称等。在目标文件中,每个节都有固定的大小。
可重定位节,包含一些引用函数和其他目标文件在连接器中的连接时需要的偏移量。通常在进行引用这些函数和调用目标文件时将会修改这些地址。
Synble table,用于存放全局变量,引用的函数和文件名称等信息。Num表示序号,Size,type分别表示大小和类型,Bind表示这是全局变量还是本地变量。
4.4 Hello.o的结果解析
发现反汇编文件中对分支函数的调用都是通过直接跳转到相应的地址进行执行的,二hello.s文件中这是通过调用对应的函数名进行的,这是因为在汇编文件中程序尚未完全链接,在动态链接时才会将函数地址固定下来,所以.s件采用函数名,二反汇编采用相对寻址方式调用。同时可以看出在汇编文件中采用十进制立即数,而在反汇编文件中采用十六进制立即数。
机器语言就是一串数码,其分为操作码,操作数,目标地址三部分。通过识别操作码进行相应的对操作数的操作,并将结果存入目标地址中。
4.5 本章小结
本章通分析了汇编后的文件中包含的内容,了解了从汇编代码到机器代码的转变过程。并对过反汇编程序对生产的.o文件进行反汇编,通过对.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.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,发现
从0x00...401000地址处开始存放有我们的代码和数据,其与ELF中声明的一致。
5.5 链接的重定位过程分析
可以看出hello的汇编比.o的明显多了很多,这便是连接器的作用——将各种函数与目标文件结合在了一起。同时比较发现,hello的地址都是固定的的,它将.o文件中相对的地址确定下来,这样做实现了利用地址对函数的直接跳转(他们的函数都存在与文件中固定偏移的位置,不会发生改变,可以直接利用地址调用)。
5.6 hello的执行流程
跳转的各个子程序地址如下:
00007fb401ad5103
00007fb401ad5e9f
00007fb401ad6070
00007fb401ad6090
00007fb401ad6210
00007fb401ad5ea9
00007fb401ad5e9f
00007fb401ad5e78
00007fb401ad60b3
程序开始执行时先对堆栈进行了操作,为运行时数据存放开辟了空间,接着将6各通用寄存器压入栈中,进入主函数并执行。期间会进行多次比较操作,进行相应的分子跳转,当满足条件后程序退出。
5.7 Hello的动态链接分析
如上图所示即为调用链接器的语句,当程序第一步链接时并未将所有信息全部整合,而仅仅将共享库之中的部分符号表和可从定位表包含在内。当程序实际调用对应的未被链接的子函数或目标文件时,连接器才会将其链接,此即成为动态链接。根据在源文件中包含的头文件和程序中使用到的库函数,如stdio.h中定义的printf()函数,在libc.a中找到目标文件printf.o(这里暂且不考虑printf()函数的依赖关系),然后将这个目标文件和我们hello.o这个文件进行链接形成我们的可执行文件 这里有一个小问题,就是从上面的图中可以看到静态运行库里面的一个目标文件只包含一个函数,如libc.a里面的printf.o只有printf()函数,strlen.o里面只有strlen()函数。
我们知道,链接器在链接静态链接库的时候是以目标文件为单位的。比如我们引用了静态库中的printf()函数,那么链接器就会把库中包含printf()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中。由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那些没有被用到的目标文件就不要链接到最终的输出文件中。
第6章 hello进程管理6.1 进程的概念与作用进程就是一个执行中的程序的实例,系统中的每个程序都运行在某个进程的上下文中。系统通过fork()函数创建子进程。通常若一个父进程中止,则其子进程将成为僵尸子进程,此时系统会调用init进程成为孤儿进程的养父并对其进行回收,但通常我们应调用函数主动回收它。此时可以调用waitpid函数或者他的简单版本wait函数对其进行回收,其调用方式如下: 6.2 简述壳Shell-bash的作用与处理流程shell 是一个交互型应用级程序,代表用户运行其他程序。如Windows下的命令行解释器,cmd、powershell,图形界面的资源管理器。Linux下的Terminal/tcsh、bash等等,当然也包括图形化的GNOME桌面环境。Shell是信号处理的代表,负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等。 Shell首先调用parseline函数解析输入的命令,并构造参数传递给execve和argv向量,接着调用eval函数检查命令的存在,并创建子进程并运行或者结束后再次循环。 6.3 Hello的fork进程创建过程Hello在系统终端(shell)运行时,由于第一行命令并非内置命令,Hello 7203610229 张晓星 1 将会使壳创建一个新的子进程,这是通过调用函数fork实现的,该进程将会得到与父进程一模一样的地址空间及参数,但在运行过程中会修改这些。 6.4 Hello的execve过程在执行我们的hello程序过程中,shell本身不会直接运行程序,它通过调用exeve函数在当前进程的上下文中插入一个新的进程,这个进程就是我们的hello。实际上Hello进程的父进程是shell进程,它是shell进程fork出来的一个子进程然后执行execve之后在执行的Hello,所以我们下面来看看这个execve: #include <unistd.h> int execve(const char *filename, char *const argv[], char *const envp[]); filename:包含准备载入当前进程空间的新程序的路径名。既可以是绝对路径,又可以是相对路径。 argv[]:指定了传给新进程的命令行参数,该数组对应于c语言main函数的argv参数数组,格式也相同,argv[0]对应命令名,通常情况下该值与filename中的basename(就是绝对路径的最后一个)相同。 envp[]:最后一个参数envp指定了新程序的环境列表。参数envp对应于新程序的environ数组。 由于exeve执行一个新的程序,它没有任何返回值。 6.5 Hello的进程执行进程上下文一般在进程切换中提到,进程控制块PCB,保存着进程的诸多详细信息——当进程要切换时当前进程的寄存器内容以及内存页表的详细信息等等内容,也就是关于描述进程的信息 时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片,即该进程允许运行的时间,使各个程序从表面上看是同时进行的。 对于hello进程,首先由系统完成上下文切换,保存hello执行前的系统进程信息,接着执行我们的hello,当hello调用函数getchar时,实际上利用了信号的异常——陷阱,实现对操作系统的调用,完成字符串的接收。而当hello调用sleep函数时,系统将会利用hello休眠的时间给其它进程,此时系统会进行上下文切换,将控制从用户的hello转到系统进程,实现从用户态到内核态的转换。 6.6 hello的异常与信号处理程序的异常可能有中断,陷阱,故障,中止等。 按下Ctrl-C,操作系统接收到了中止的信号,直接对hello进行了中断,同时回收并退出了程序。 按下Ctrl-Z,发送SIGSTP信号到程序,将使得前台的所有程序被挂起,也就是停止运行。 Ps命令显示当前进程信息 通过kill对已经中断后的hello程序进行杀死。 6.7本章小结本章对shell的分析加深了我们对hello1进程控制的认识,熟悉了fork,exeve等函数的作用,使用。实现了异常,信号的处理与利用。 第7章 hello的存储管理7.1 hello的存储器地址空间线性地址指的是段的基址加上偏移,它也成为虚拟地址,因为他不是真正的内存地址。 而物理地址即使真正的内存地址,其是每一个存储记忆单元的特定的标记的总和,每个字节的存储单元都有一个固定的物理地址。 7.2 Intel逻辑地址到线性地址的变换-段式管理首先应明确: 1、逻辑地址=段选择符+偏移量 2、每个段选择符大小为16位,段描述符为8字节(注意单位)。 3、GDT为全局描述符表,LDT为局部描述符表。 4、段描述符存放在描述符表中,也就是GDT或LDT中。 5、段首地址存放在段描述符中。 每个段的首地址都存放在自己的段描述符中,而所有的段描述符都存放在一个描述符表中(描述符表分为全局描述符表GDT和局部描述符表LDT)。而要想找到某个段的描述符必须通过段选择符才能找到。 段选择符由三个部分组成,依次是RPL、TI、index(索引)。当TI=0时,表示段描述符在GDT中,当TI=1时表示段描述符在LDT中。 可以将描述符表看成是一个数组,每个元素都存放一个段描述符,那index就表示某个段描述符在数组中的索引。 我们假设有一个段的段选择符,它的TI=0,index=8。我们可以知道这个段的描述符是在GDT数组中,并且他的在数组中的索引是8。 假设GDT的起始位置是0x00020000,而一个段描述符的大小是8个字节,由此我们可以计算出段描述符所在的地址:0x00020000+8*index,从而我们就可以找到我们想要的段描述符,从而获取某个段的首地址,然后再将从段描述符中获取到的首地址与逻辑地址的偏移量相加就得到了线性地址。 7.3 Hello的线性地址到物理地址的变换-页式管理首先明确:.线性地址(linear address)(也称虚拟地址virtual address):是一个32位无符号整数,用来表示高达4GB的地址。物理地址(physical address):实际地址。VM 即虚拟内存 ,PM 即物理内存。(1)PGD(Page Global Directory) 即页全局目录。(2)PUD(Page Upper Directory)即页上级目录。(3)PMD(Page Middle Directory)即页中间目录。(4)PT即页面表, PT中的表项称为PTE, 是“Page Table Entry”的缩写。offset 即位移量,偏移量。 其变换过程如下: 1.从CR3寄存器中读取页目录所在物理页面的基址(即所谓的页目录基址),从线性地址的第一部分获取页目录项的索引,两者相加得到页目录项的物理地址。 2.第一次读取内存得到pgd_t结构的目录项,从中取出物理页基址取出(具体位数与平台相关,如果是32系统,则为20位),即页上级页目录的物理基地址。 3.从线性地址的第二部分中取出页上级目录项的索引,与页上级目录基地址相加得到页上级目录项的物理地址。 4.第二次读取内存得到pud_t结构的目录项,从中取出页中间目录的物理基地址。 5.从线性地址的第三部分中取出页中间目录项的索引,与页中间目录基址相加得到页中间目录项的物理地址。 6.第三次读取内存得到pmd_t结构的目录项,从中取出页表的物理基地址。 7.从线性地址的第四部分中取出页表项的索引,与页表基址相加得到页表项的物理地址。 8.第四次读取内存得到pte_t结构的目录项,从中取出物理页的基地址。 9.从线性地址的第五部分中取出物理页内偏移量,与物理页基址相加得到最终的物理地址。 10.第五次读取内存得到最终要访问的数据。 7.4 TLB与四级页表支持下的VA到PA的变换TLB与四级叶表下的虚拟地址到物理地址的转换过程图如上所示。 7.5 三级Cache支持下的物理内存访问当以上步骤进行到物理地址获取时,会使用CI当作索引,对该索引内8路块数据进行CT的匹配。匹配成功且可读写时即完成地址获取,否则就向三级缓存的下一级进行类似的操作,直到匹配成功。 7.6 hello进程fork时的内存映射当系统调用fork函数创建新进程时,会复制一份一模一样的数据及结构给子进程,并指定一个唯一的PID。新获得的子进程只是获得了一个和FORK函数虚拟内存一样的备份,但其中数据被改写时,其地址内容才会发生变化。 7.7 hello进程execve时的内存映射执行exeve函数就是执行当前的hello程序,其关于内存的操作有以下几步: 1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。 2映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。 3.映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。 4.设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。 5.将构造好的argc,argv[],envp[],写入栈。 7.8 缺页故障与缺页中断处理当进程执行过程中发生缺页中断时,首先硬件会陷入内核,在堆栈中保存程序计数器。大多数机器将当前指令的各种状态信息保存在CPU中特殊的寄存器中。 系统启动一个汇编代码例程保存通用寄存器及其它易失性信息,以免被操作系统破坏。这个例程将操作系统作为一个函数来调用。当操作系统发现是一个页面中断时,查找出来发生页面中断的虚拟页面(进程地址空间中的页面)。这个虚拟页面的信息通常会保存在一个硬件寄存器中,如果没有的话,操作系统必须检索程序计数器,取出这条指令,用软件分析该指令,通过分析找出发生页面中断的虚拟页面。之后操作系统会检查虚拟地址的有效性及安全保护位。如果发生保护错误,则杀死该进程。接着操作系统查找一个空闲的页框(物理内存中的页面),如果没有空闲页框则需要通过页面置换算法找到一个需要换出的页框。如果找的页框中的内容被修改了,则需要将修改的内容保存到磁盘上,此时会引起一个写磁盘调用,发生上下文切换(在等待磁盘写的过程中让其它进程运行)。页框干净后,操作系统根据虚拟地址对应磁盘上的位置,将保持在磁盘上的页面内容复制到“干净”的页框中,此时会引起一个读磁盘调用,发生上下文切换。当磁盘中的页面内容全部装入页框后,向操作系统发送一个中断。操作系统更新内存中的页表项,将虚拟页面映射的页框号更新为写入的页框,并将页框标记为正常状态。系统恢复缺页中断发生前的状态,将程序指令器重新指向引起缺页中断的指令。 并调度引起页面中断的进程,操作系统返回汇编代码例程。汇编代码例程恢复现场,将之前保存在通用寄存器中的信息恢复。 7.9动态存储分配管理动态内存分配由动态内存分配器实现,其维护着一个进程的虚拟内存区域,称为堆。其将之视为一组不同大小的块来维护。每个块要么是已经分配的,要么是空闲的,已分配的块不可被写入,除非它被释放之后。动态内存分配器有三个简单的分配策略: 首次适配——从头开始搜索空闲列表,找到第一个空闲块便返回。 下一次适配——不用从头开始,记录上一次返回的地方再次开始搜索即可。 最佳适配——对整个链表搜索并找到满足最小碎片化的一个内存块。 7.10本章小结本章探究了储存器的地址空间和相关的内存访问机制,地址翻译机制,以及进程执行过程中对内存进行的相关操作。通过对计算机存储底层机制的探究,感受了计算机结构精巧美。 第8章 hello的IO管理8.1 Linux的IO设备管理方法设备的模型化:文件 设备管理:unix io接口 以上就是说,计算机将所有的外部设备都翻译为文件,机器对这些外部设备的操作被理解为对这些“文件”的读和写,而这读和写的通道,也就是I/O接口,就是机器为外部设备所预留的。 8.2 简述Unix IO接口及其函数IO接口:打开文件,移动,读写文件,关闭文件。 相应的函数:open函数,用于打开或者创建一个文件,其原型为:int open(const char *pathname,int flags,int perms);close函数,用于关闭文件,其函数原型为::int close(int fd);read函数,用于从文件读取数据,其函数原型为:ssize_t read(int fd, void *buf, size_t count);write函数,用于向文件写入数据,其函数原型为:ssize_t write(int fd, void *buf, size_t count); 8.3 printf的实现分析printf函数的函数体如下: int printf(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; } 其中va_list为一个字符指针,而fmt是指向第一个参数char*)(&fmt中的第一个元素的指针。而这个参数就是...中的第一个地址。vsprintf的作用是格式化,它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。之后程序调用write对结果进行打印输出,这当中涉及系统陷阱的利用,通过调用system_call()实现用户态转入系统态,并让系统调用sys_write ()实现最终的输出。 内核会通过调用字符显示子程序,根据传入的字符的ASCLL吗到相应的库中找到对应字符矢量,借由显示芯片实现到屏幕像素点的转换,其将按照刷新频率读取输入,并通过信号线向显示器传输每一个点的显示与否,这就完成了从printf函数到打印的全部过程。 8.4 getchar的实现分析int getchar(void) { static char buf[BUFSIZ]; static char* bb=buf; static int n=0; if(n==0) { n=read(0,buf,BUFSIZ); bb=buf; } return (–n>=0)?(unsigned char)*bb++:EOF; } Getchar函数通过对异步异常-键盘中断的处理,调用键盘中断处理子程序,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区之后。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。 8.5本章小结本章主要探究了系统IO的管理,及涉及IO操作的一系列函数,并主要分析了printf和getchar函数,了解了系统对外界输入的调用方式。最终完成了计算机由内部运行到外部交互的过程。 结论
感悟: 这个大作业花费了我5天时间,算上每天大概1个小时,合计也有12个小时左右。期间不仅在自己的虚拟机上进行相关的代码分析,还上网搜索了各路大神对相关知识点的精辟总结。通过对hello的从编译执行,到执行底层的相关机制的深入剖析,我越发觉得计算机的设计真是十分精妙。通过计算机个部分的协调合作,分工执行,互相交互,这由字符组成的hello仿佛就有了自己的生命似的,它既能和我交互,还能对我打印信息,向我问好。通过亲眼见证hello的一生,我感到计算机工作者的辛苦,即使是这么一行小小的hello,其中都涉及那么多步骤,更别谈日常处理的都是一个项目几万行代码了。如果一旦出现bug,那么检查起来将是非常之困难的,因为出错的不只是你写的代码逻辑,还有可能是在“hello”一生中所经历的任何一个阶段,为此,我向程序工作者和计算机科学工作者们致以崇高的敬意。 |