计算机科学与技术学院
2022年5月
每个程序员的第一个程序都会是“hello world!”,而运行这简单的程序,计算机后台会经过很多过程,包括——预处理、编译、汇编、链接、优化、运行、终止,本文将从各个方面深入阐释一个C文件从创建到终止的各个过程,解释程序运行的规则,帮助大家更加深入的了解诶计算机系统。
目 录
第1章 概述
1.1 Hello简介
P2P:
1、在终端中通过命令创建hello.c文件
2、在文件内编程结束后,便可以利用C预处理器(cpp)进行预处理
3、利用C语言编译器(ccl),将.c文件编译为汇编文件hello.s
4、利用汇编器(as),将hello.s汇编为可重定位目标文件hello.o
5、最后利用链接器将程序与可重定位目标文件将链接起来变为可执行目标文件hello
020:
- 在shell中通过fork建立一个新的进程,在调用execve映射虚拟内存,再通过mmap为程序开辟出一片新的空间。
- CPU从虚拟内存.text和.data节中读取代码与数据,再将虚拟内存载入物理内存,在进入main函数中运行程序。
- 当程序运行结束后,shell回收hello进程,内核删除相关数据结构,再次回到0状态。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk
软件环境:Windows11 64位,Vmware 11,Ubuntu 21
开发工具:Visual Studio 2022 64位,Dev C++,gdb
1.3 中间结果
hello.c :源程序
hello.o :汇编后的可重定位的目标文件
hello.i :预处理后的程序
hello.s : 编译后的汇编文件
hello : 链接后的可执行目标文件
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
1.4 本章小结
本章阐述了hello从创立到执行再到终止的过程,并且介绍了执行整个程序需要用到的开发工具以及软硬件环境等一些基本信息。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理:将C文件进行处理的第一步,可以将C文件根据#开头的命令进行初步处理,预处理从系统的头文件包中将头文件的源码插入到目标文件中,宏和常量标识符已全部被相应的代码和值替换,最终生成.i文件,以便计算机对i文件进行下一步处理。
2.2在Ubuntu下预处理的命令
在终端中输入以下命令,即可以在目标文件夹内生成一个与C文件同名的i文件
2.3 Hello的预处理结果解析
经过预处理后代码的长度大大增长,原因是经过预处理,各种宏和定义都均变为了代码和符号表示,因此预处理后代码长度会变长。
2.4 本章小结
本章展示了如何将C文件进行预处理以及预处理后的结果,并且分析了预处理后代码变长的原因。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译:将经过预处理的文件进行编译,将.i文件转变为.s文件,将其变为汇编文件,并对程序进行各种分析——语法分析、语义分析、源代码生成、程序优化、目标文件生成,在编译器中,计算机将对程序进行检验,如果没有错误,则可以将其生成汇编文件。
3.2 在Ubuntu下编译的命令
在终端中输入以上代码,即可对经过预处理后的文件进行汇编处理。
3.3 Hello的编译结果解析
3.3.1汇编初始部分
节名称 作用
.file 声明源文件
.text 代码节
.section.rodata 只读数据段
.globl 声明全局变量
.type 声明一个符号是函数类型还是数据类型
.size 声明大小
.string 声明一个字符串
.align 声明对指令或者数据的存放地址进行对齐的方式
3.3.2 main函数
(1)字符串
字符串都在只读的段中,表示了printf中的字符串。Text表示了代码节,而global代表了全局变量main。
- main函数
这部分是main函数中的内容,其中leaq后面的是代表了printf内的参数,而跳转到L2即是进入到循环语句中。
L1、L2、L3为循环中的操作,其中的cmpl为判断条件是否符合,call sleep为使用sleep函数,leaq后为printf中的参数,再调用printf函数进行打印。
其中比较%rbp内的元素是否小于等于,如果不小于等于8即进行跳转,退出循环;如果是,则进入L4中进行下一轮的循环计算。这一步为i++,运行完这一步后就进入L3,进行下一步的判断。
3.3.3赋值操作
hello.c中赋值操作是for循环中i=0;在汇编代码中使用mov指令实现,mov指令根据操作数的字节大小分为:
movb:一个字节
movw:“字”
movl:“双字”
movq:“四字”
3.3.4函数操作
汇编语言中运用call 声明的即函数调用,需要向函数中传递参数,函数也有可能需要向内存申请空间。在hello.c中atoi(argv[3])将字符串类型转换为整型。int、float、double、short、char可以进行相互转化。
3.4 本章小结
在本章中,我们主要介绍了在编译过程中,计算机将C语言的各个部分都变为了汇编语言,并且分析了每条C语言语句在汇编文件中的表示,并且阐述了汇编语言中的各种用法规则,帮助我们更加深入的了解了汇编语言,提高了反向编程工作的能力。
第4章 汇编
4.1 汇编的概念与作用
汇编:汇编是将编译过的文件变为机器语言指令文件,并且将其变为可重定位目标文件,并将文件保存为.o文件,.o文件为一个二进制文件,变为可重定位的目标文件后便与计算机后来识别与运行。
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
表头中以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
.text:已编译程序的机器代码
.rodata:只读数据
.data:已初始化的全局和静态C变量
.bss:未初始化的全局和静态C变量
.symtab:符号表
.rel.text:代码段重定位信息表
.rel.data:数据段重定位信息表
.debug:调试符号表
.line:C代码行号与机器码行号映射表
.strtab:字符串表
节头记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
重定位节:
.rela.text中保存的是.text节中需要被修正的信息;任何调用外部函数或者引用全局变量的指令都需要被修正;调用外部函数的指令需要重定位;引用全局变量的指令需要重定位; 调用局部函数的指令不需要重定位;在可执行目标文件中不存在重定位信息。本程序需要被重定位的是printf、puts、exit、sleepsecs、getchar、sleep和.rodata中的.L0和.L1。
符号表:.symtab,一个符号表,它存放在程序中定义和引用的函数和全局变量的信息,一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
4.4 Hello.o的结果解析
4.4.1、跳转语句不通
反汇编代码:
Hello.s:
分析:反汇编代码中是计算出地址再进行跳转的,hello.s中是利用L2进行代表进行跳转的。
4.4.2数字表示不同
Hello.s:
反汇编代码:
分析:hello.s中使用十进制,反汇编中使用16进制
4.4.3有无重定位条目
4.4.4 机器语言和汇编语言的关系
1、机器语言处理器的指令集及使用它们编写程序的规则。
指令集=指令系统处理器支持的所有指令的集合。
指令控制计算机完成一个操作的命令。
每个机器指令对应一个二进制数0和1组成的代码(Code),这是处理器能够直接执行的命令。一个机器语言程序就是一段二进制代码序列。
2、汇编语言用助记符表示的指令以及使用它们编写程序的规则。
汇编将汇编语言书写的程序翻译成机器语言程序的过程。
汇编程序将汇编语言书写的程序翻译成机器语言程序的软件。
不要与汇编语言程序这个说法混淆,后者表示用汇编语言书写的程序,或称汇编语言源程序。
汇编语言是一种符号语言,比机器语言容易理解和掌握、也容易调试和维护。不过汇编语言本质上还是机器语言,还是一种面向机器的低级程序设计语言。
本章对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,分析了汇编语言与机器语言的对应关系。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接:链接的作用是将各个小的部分模块整合到一起,形成一个单一的文件,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。链接可以帮助我们将庞大的应用程序分解为小的模块,使得修改和编译更加的便捷,更好的管理整个应用程序。
5.2 在Ubuntu下链接的命令
5.2.1生成可执行文件hello
指令: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.3.1查看hello的表头
ELF头中显示了各种信息,包括大小为64bit,表头数为25,目标文件类型为EXEC等。
节头部表显示了各个节头的起始地址、类型以及偏移量,
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
程序表头是一个结构数组,里面显示了各个部分的地址偏移量大小等基本信息。
符号表则显示了各种语义的符号包括其序号和大小。
5.4 hello的虚拟地址空间
虚拟空间从00400000开始
5.5 链接的重定位过程分析
输入后在终端显示
Hello和hello.o的差别是虚拟地址不同、跳转时引用的名称不同、
重定位依旧利用refaddr = ADDR(s) + r.offset;*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr);进行计算。
5.6 hello的执行流程
1. 从加载到进入main函数的过程
开始时,经过一系列执行,程序首先跳转到子程序_start,该子程序位于地址4010f0处。随后通过callq *0x2ed2(%rip) 指令跳转到位于地址0x7f38faffefc0的“Libc-2.31.so!_libc_start_main”子程序,在子程序中,通过call *%rax指令跳转到main函数,地址为0x401125。
2. 从main函数到程序执行完
进入main函数,程序按照源代码顺序依次执行,在执行的过程中分别调用不同的子程序,子程序名称和地址如下:
401090 <puts@plt>
4010a0 <printf@plt>
4010b0 <getchar@plt>
4010c0 <atoi@plt>
4010d0 <exit@plt>
4010e0 <sleep@plt>
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
由结果可知在dl_init前后,0x403ff0 处和0x404000 处的8bit数据分别由000000000000变为了c07d74c57b7f 和e03299c57b7f,GOT[1]指向重定位表,作用是确定调用函数的地址,GOT[2]指向动态链接器ld-linux.so运行时地址
5.8 本章小结
本章主要介绍了链接的概念与作用,链接可分为符号定义和重定位,了解了可执行文件的ELF格式,分析了hello的虚拟地址空间,重定位过程,执行过程,动态连接过程,对链接有了更深的理解。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。首先是一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。其次他占有一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell是一个交互型应用级程序,为使用者提供操作界面,接收用户命令,然后调用相应的应用程序。
处理流程:首先shell从输入端获得指令,并将字符串切分获得所有参数,得到指令后检查是否为内部指令(即检查第一个参数是否是内置shell的命令)。若为内部指令直接执行,若果不是内部指令则调用fork()进行创建一个新进程,
6.3 Hello的fork进程创建过程
在终端中输入命令行./hello 2021110515 zyh后,首先shell对我们输入的命令进行解析,由于我们输入的命令不是一个内置的shell命令,因此shell会调用fork()创建一个子进程,子进程几乎但不完全与父进程相同。通过fork函数,子进程得到与父进程用户级虚拟地址空间相同的但是独立的一份副本,拥有不同的PID,但在相同的进程组是并发运行的。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开所有文件描述符。
hello的execve过程可以总结为以下几个步骤:删除已存在的用户区域;映射私有区域;映射共享区域;设置程序计数器。
6.5 Hello的进程执行
Hello的执行依赖于进程的抽象基础,其中包括独立逻辑控制流,以及私有的地址空间,上下文切换也是其中必不可少的一个环节,上下文切换的意思是当进程发生堵塞时,内核会让当前进行挂起,而进行另外一个进程。在hello执行的过程中,在命令行输入需要执行的参数后2,计算机会调用sleep函数进入内核模式,进行信号的运输,完成后再返回用户模式,在此过程中,CPU会不断的进行上下文的切换,将其分成不同多个时间片,实现进程的调度。
6.6 hello的异常与信号处理
在hello执行的过程中,会发生很多异常,总体来讲可分为以下四种:
而接收到的信号也有很多
键盘随机按键:如果按键过程中没有回车键,会把输入屏幕的字符串缓存起来;如果按键过程中有回车键,则当程序运行完成后,缓存区中的换行符前的字符串会被shell当作指令执行。
如果按Ctrl+z则会立刻停止进程。
Ps指令是查看当前进程的状态
Jobs是查看后台进程及其状态,这里后台只有hello并且是处于stopped的状态
Fg是将后台的程序放到前台继续运行,因此这里看到hello被放到前台继续运行。
Pstree指令是以树状图来显示所有的行程
在运行过程中输入Ctrl+c会直接停止运行并且开启一个新的输入窗口,并且是直接终止kill掉,不是挂起,如图jobs没有后台程序正在工作。
6.7本章小结
本章介绍了进程的定义与作用,对hello被加载、执行的过程进行分析,同时介绍shell的一般处理流程和作用,并且着重分析了调用fork 函数创建新进程,调用execve函数加载并执行hello,以及hello的异常与信号处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址: cpu执行程序过程中的一种中间地址。一个逻辑地址,是由一个段标识符加上一个指定段内的相对地址的偏移量。
线性地址:是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
虚拟地址:是相对于物理内存对整个内存的抽象描述。有了这样的抽象,一个程序可以使用比真实物理地址大得多的地址空间,多个进程可以使用相同的地址。
物理地址: 用于内存芯片级的单元寻址,与地址总线相对应。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址是程序源码编译后所形成的,跟实际内存没有直接联系的地址,即在不同的机器上,使用相同的编译器来编译同一个源程序,则其逻辑地址是相同的。
线性地址=段基址*16+偏移的逻辑地址,而段基址由于不同的机器其任务不同,其所分配的段基址(线性地址)也会不相同,因此,其线性地址会不同。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址对应到物理地址是通过分页机制,即通过页表查找来对应物理地址。
分页是CPU提供的一种机制,Linux根据这种机制的规则,利用它实现了内存管理。在保护模式下,控制寄存器的最高位PG位控制着分页管理机制是否生效,如果PG=1,分页机制生效,需通过页表查找才能把线性地址转换物理地址。
分页的基本原理是把内存划分成大小固定的若干单元,每个单元称为一页,每页包含4k字节的地址空间。这样每一页的起始地址都是4k字节对齐的。为了能转换成物理地址,我们需要给CPU提供当前任务的线性地址转物理地址的查找表,即页表。x86将线性地址通过页目录表和页表两级查找转换成物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
一到三级页表中存放的数据是指向下一级页表的首地址,而不是物理页号。逐步访问到第四级页表,第四级页表中装的就是物理页号,通过第四级页表读出的物理页号链接上虚拟地址中的VPO获得物理地址。用页表进行虚实地址转化的基本原理如下图:
7.5 三级Cache支持下的物理内存访问
首先是CPU发出一个虚拟地址给TLB里面搜索,如果命中的话就直接先发送到L1cache里面,没有命中的话就先在页表里面找到以后再发送过去,到了L1里面以后,寻找物理地址又要检测是否命中。
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID 。并且创建hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当这两个进程中的任一一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。即删除之前shell运行时已经存在的区域结构。
2.映射私有区域。为hello的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text 和.data区。
3.映射共享区域。hello程序与共享对象链接,比如标准C库1ibc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的人口点。
7.8 缺页故障与缺页中断处理
在请求分页系统中,可以通过查询页表中来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存时,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,并将其调入内存。
处理:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它换入新的页面并更新页表。当缺页处理程序返回时,CPU 重新启动引起缺页的指令。
7.9动态存储分配管理
在程序运行时程序员使用动态内存分配器获得虚拟内存,动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合,每个块要么是已分配的,要么是空闲的。分配器的类型有显示分配和隐式分配,前者要求应用显式地释放任何已分配的块,后者应用检测到已分配块不再被程序所使用,就释放这个块。
7.10本章小结
本节分析了hello的存储器空间,并给出了 TLB与四级页表支持下的VA与 PA的变换和三级Cache支持下的物理内存访问过程的介绍。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备管理:unix io接口
所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
8.2 简述Unix IO接口及其函数
- 打开文件:返回一个小的非负整数,即描述符。用描述符来标识文件。
- 改变当前文件位置 从文件开头起始的字节偏移量。系统内核保持一个文件位置k,对于每个打开的文件,起始值为0。
- 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
函数:
Open()-打开一个已经存在的文件或是创建一个新文件
Read()-从文件读取数据,执行输出
Write()-从文件中读取数据,执行输出
Close()-关闭一个被打开的文件
Lseek()-用于在指定的文件描述符中将文件指针定位到相应位置
8.3 printf的实现分析
printf()函数将变长参数的指针arg作为参数,传给vsprintf函数。然后vsprintf函数解析格式化字符串,调用write()函数。在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,最后,write函数调用syscall(int INT_VECTOR_SYS_CALL)。syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存 储到 vram 中。显示芯片会按照一定的刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量),最终打印出了我们需要的字符串。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本节主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,简单分析了 printf 函数和 getchar 函数的实现。
(第8章1分)
结论
1.编写:程序员通过键盘输入可得hello.c(program)c语言源程序。
2.预处理:hello.c在预处理器(cpp)处理后得到hello.i。
3.编译:通过编译器(ccl),得到汇编程序hello.s。
4.汇编:通过汇编器(as),得到可重定位的目标程序hello.o。
5.链接:通过链接器(ld)得到可执行的目标程序hello(process)。
6.运行:在shell命令行输入./hello ************ 3运行程序。
7.创建子进程:shell调用fork函数创建子进程。
8.运行程序:调用execve将程序加载进去。
9.结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。
感悟:
即使最简单的一个hello程序,其执行处理的过程也是非常复杂的,要想真正理解他要花费很大的功夫,我从中学到了预处理、编译、汇编、链接、进程管理、存储管理、IO管理的相关知识,这次的课程让我获益匪浅。