摘 要
本文介绍了hello进程在linux系统下从编译到执行的过程,以及其执行过程涉及到的软硬件交互机制。在编译部分,介绍了编译系统各模块的概念与功能,并分析了各个模块文件的特点。在执行部分,介绍了linux系统的进程管理方式,
并分析了hello进程在其中的行为。最后的软硬件交互部分,主要介绍了内存管理和输入输出两个hello进程直接用到的部分,最后以hello的整个编译执行过程为骨干,回顾了计算机系统的特点与组成。
关键词:计算机系统;linux;编译;汇编;进程管理;内存管理;系统级IO
- 1章 概述
- Hello简介
hello从源文件hello.c开始,经历了预处理,编译,汇编,链接,四步形成可执行文件hello,运行进程时,操作系统解析调用命令,并且为hello安排执行的上下文,之后系统通过内存管理模块加载hello,并通过UNIX IO接口让hello进程能正常运行,输入,输出,当hello进程结束后,操作系统回收其占用的相关资源,到此hello结束。
- 环境与工具
硬件环境:Intel Core i9-10900K,RAM 32GB
系统类型:Ubuntu 16.04,64位操作系统。
开发调试工具:gcc/as/ld/gedit/edb/readelf/objdump
- 中间结果
Elf.txt:readelf提取出的elf文件(hello.o版本)
elfhello.txt:readelf提取出的elf文件(hello版本)
hello.i预处理后的hello文件
hello.s编译后的hello文件
hello.o汇编后的hello文件
hello可执行文件
hello_o_objdump.txt hello.o的反汇编结果
hello_objdump.txt hello的反汇编结果
- 本章小结
本章简要概括了hello进程在计算机系统上的执行过程,并介绍了分析这个执行流程所需的软硬件环境以及开发工具,此外,还介绍了各中间文件的作用。
- Hello的预处理结果解析
头部:最开始的代码是一些的库文件的集合。
头文件展开:中间的代码展开了头文件内部的内容。可以看到库函数的实现中有很多复杂的变量命名和中间函数。
- 2章 预处理
- 预处理的概念与作用
概念:预处理包括#include,#define,#ifdef等指令,是根据这些指令修改c语言源代码的过程。
作用:增强了c语言的编程功能,提高了编程效率。
在终端中调用cpp程序,并将输出重定向到hello.i中就能实现预处理。
具体指令为:cpp hello.c > hello.i
- 本章小结
本章介绍了预处理的概念和作用,给出了预处理hello.c的方式,并解析了经过其预处理之后的文件特点。
- 3章 编译
- 编译的概念与作用
概念:编译就是利用编译程序将不能被直接翻译为机器语言的高级语言转化为能被直接翻译为机器语言的汇编语言的过程。
作用:把便于人们阅读的语言转化为便于机器识别汇编语言,使程序能在不同的机器上执行,同时能完成对源代码的语法分析,代码优化等工作。
- 在Ubuntu下编译的命令
使用gcc应用程序进行编译,命令为gcc -S hello.i -o hello.s
- Hello的编译结果解析
3.3.1常量的存储与表示
二,参数传递:
参数数量较少的函数按顺序使用%rdi,%rsi,%rdx,%rcx,%r8,%r9这六个寄存器传递参数,剩余的参数直接用栈传递。
三,函数返回:
直接通过ret指令实现,如果有返回值,在返回之前将%eax寄存器的值设置好。
- 本章小结
本章首先介绍了编译的概念与作用,之后介绍了在linux系统下编译给定代码的方式,之后详细分析了汇编语言文件的结构,以及高级语言中各类特性在汇编层面的实现。
- 4章 汇编
- 汇编的概念与作用
概念:利用汇编器将汇编语言编写的程序生成包含机器语言的目标文件的过程。
作用:将汇编语言翻译成可被机器识别的机器码,使程序能在机器上运行。
- 在Ubuntu下汇编的命令
使用as程序进行汇编,具体命令为as hello.s -o hello.o
二,机器语言的构成及与汇编语言的映射关系。
机器语言和汇编语言有类似之处,比如都是以指令的方式实现程序,每条指令都由指令类型+操作数的形式构成。实际上汇编语言的指令类型可以直接翻译到机器语言的指令码,只是部分操作数还需要进一步的处理。
三,机器语言与汇编语言的不一致之处
在分支转移上,hello.s中跳转到L1,L2这些标记来标记跳转位置,而机器语言直接跳转到具体指令所在的虚拟地址,在函数调用上,hello.s中call对应函数名称,但是机器语言也是直接跳转到具体的虚拟地址。此外,由于链接尚未开始,重定位条目的虚拟地址需要到链接时继续计算。
- 本章小结
本章首先介绍了汇编的概念与作用,之后给出了在linux系统下,汇编给定的汇编代码生成可重定向文件的方式。随后详细解析了可重定向文件的各部分内容,并分析了机器语言与汇编语言间的映射关系和二者的异同。
- 5章 链接
- 链接的概念与作用
概念:将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。
作用:使分离编译称为可能,修改代码时无需重新编译整个应用程序,只需要重新编译对应的模块就能完成修改。
- 在Ubuntu下链接的命令
如图所示,je指令的跳转地址和callq指令的调用地址都是确切虚拟的地址,说明链接已经完成重定位,所有跳转和调用指令的操作数都已经
总的来讲,链接的执行过程就是链接器将各个可重定位执行文件的数据段合并,加工,同时利用重定位信息计算出函数调用和控制流跳转地址,最终将多个文件合并成一个可执行文件的过程。
- hello的执行流程
上述图片分别为在dl_init前后,这些项目的内容变化。
dl_init调用前,函数调用调用的是PLT相关内容,此时调用是初始化GOT。
dl_init调用后,链接器采取延迟绑定的策略,使用PLT+GOT实现函数动态链接,随后PLT使用GOT中地址跳转至目标函数。
- 本章小结
本章介绍了链接的概念与作用,并给出了在linux下链接文件的方式,同时本章比较了链接前后可执行文件内容的不同,分析了链接的特点。并且简单展示了在linux下动态跟踪程序执行的方法,借此分析了链接后程序的虚拟内存行为和动态链接行为。
- 6章 hello进程管理
- 进程的概念与作用
概念:一个执行中程序的实例。
作用:为应用程序提供一些关键的抽象,包括一个独立的逻辑控制流和一个私有的地址空间,是操作系统和应用程序之间的重要抽象。
- 简述壳Shell-bash的作用与处理流程
1.Shell接到输入“$./hello”
2.命令行解释器解释出argv和envp参数。
3.调用fork()进程处理该命令。
4.在子进程中调execve(),在这个子进程上下文中加载并运行hello程序,此时,hello的.text,.data,.bss节都被加载到当前进程的虚拟地址空间。
5.调用hello的main()函数,hello在进程的上下文中运行。
- Hello的fork进程创建过程
参考教材相关图片。
- Hello的execve过程
1.删除当前用户区域,从父进程独立
2.映射私有区,为hello的.text/data/bss/栈区创建新区域结构,权限为私有写时复制
3.映射共享区,将动态链接库等共享资源映射到用户虚拟地址空间的共享区。
4.设置PC,设置当前进程上下文PC到hello代码区入口点。因此执行成功的execve不会返回。只有在失败时才会返回。
- Hello的进程执行
上下文:内核为了重启被抢占的进程所需的额外状态,包括寄存器,PC
,内核栈,内核数据结构,用户栈等。
进程时间片:在一个程序被调用开始运行,到被另一个进程打断的时间,就成为时间片,因为这段时间程序真正在机器上运行。
用户态与核心态:CPU在用户态下工作时,部分指令和内存访问范围都收到限制,而在CPU核心态,程序可以执行所有的指令和任意访问内存,这样既保证系统能处理必要的工作,又保证了系统的安全性。
进程调度:在进程执行的某个时刻,内核可以决定抢占该进程,并重新开始一个之前被抢占的进程,这种决策就叫做调度,而在对进程进行调度的过程中,操作系统主要做了两件事:加载保存的寄存器,切换虚拟地址空间。
6.6 hello的异常与信号处理
一,程序正常运行状态
8.kill:
使用kill指令,发送kill信号到指定进程hello,hello进程被杀死。
本章介绍了进程的概念与作用和hello运行过程中的进程管理,从shell对hello的处理到hello进程执行中对各类信号和外界输入的反应原理都作了简单的分析。
- 7章 hello的存储管理
- hello的存储器地址空间
一,逻辑地址
逻辑地址是指由程序本身逻辑计算出的地址偏移量。hello.o中的内容就是逻辑地址。
- 线性地址
线性地址是用于将逻辑地址转换为到物理地址的中间抽象,在可执行文件hello中,代码会计算出地址偏移量,这些偏移量加上基地址就等于线性地址。
- 虚拟地址
虚拟地址是操作系统为程序hello提供的地址抽象,程序hello运行在虚拟地址空间中,此时CPU处于保护模式。
- 物理地址
数据在物理内存中的“真实地址”,其实就是寻址总线的输入,根据操作是读
出还是写入,对应的逻辑电路将数据写入到物理内存中或者是将数据从内存中取出,输出到数据总线上。Hello程序执行时,其所有数据都对应着确定的物理地址,存储在内存中。
- Intel逻辑地址到线性地址的变换-段式管理
- hello进程execve时的内存映射
在bash的进程中调用execve(“hello”,NULL,NULL),之后,execve函数在当前进程中加载并运行包含在可执行文件hello中的程序,并用hello替代当前bash中的程序,随后,删除已存在的用户区域,并映射私有和共享区域,并设置PC,最后execve设置当前进程上下文程序计数器到代码区入口,完成hello进程的启动。
- 缺页故障与缺页中断处理
- 缺页故障的产生:处理器生成的虚拟地址传送给到MMU后,MMU根据这个虚拟地址得到PTEA,并向主存请求相关PTE,如果PTE的有效位是0,就会触发缺页异常。
- 缺页中断处理:缺页故障产生后,触发异常控制流,操作系统调用缺页处理程序,处理程序确定出内存的牺牲页,(如果被修改了还要换回到磁盘),随后调入所需的页面,并更新PTE。此时返回原进程重新寻址,这样就能命中物理内存。
基本方法:动态储存分配管理是由动态内存分配器(如malloc)来维护的,其维护的虚拟内存区域被称为堆。堆内有若干大小不同的块,每个块就是一个连续的虚拟内存页,要么是已分配的,要么是空闲的。
块遵循以下规则在分配和空闲间转换。
已分配的块保持已分配的状态,直到它被释放,可以是应用程序显式释放,也可以是分配器隐式释放。
空闲块保持空闲,直到它显式地被应用所分配。
内存分配管理主要有两种策略。
带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、(可能的)额外填充以及一个字的尾部组成。
隐式空闲链表:空闲块通过头部的大小字段隐含地连接着。分配器遍历堆中所有的块,间接地遍历整个空闲块的集合。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配和最佳适配。分配器在面对释放一个已分配块时,可以合并相邻的空闲块,其中一种简单的方式,是利用隐式空闲链表的边界标记来进行合并。
显式空闲链表是将堆的空闲块组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。进行内存管理。在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
本章介绍了计算机的内存管理方式,以及内存管理中涉及到的多层抽象和映射,同时以hello的执行流程为引子,介绍了内存管理的策略和寻址策略,以及与其他计算机系统内部机制的联系。
- 8章 hello的IO管理
- Linux的IO设备管理方法
在Linux中,所有的IO设备都被模型化为文件,而所有设备的输入和输出都被当做对应文件的读和写来执行,而Unix IO接口就是由Linux内核引出的一个简单的,低级的应用接口,这样所有的输出和输出都能以一种统一的方式来执行。
- 简述Unix IO接口及其函数
(以下格式自行编排,编辑时删除)
- open()函数
作用:打开文件
函数声明:int open(const char* pathname,int flags);
参数1,pathname:文件的路径
参数2,flags:文件的权限
返回值:成功时返回文件描述符,否则返回-1
- close()函数
作用:关闭文件
函数声明:int close(int fd);
参数1,fd:文件描述符
返回值:成功时返回文件描述符,否则返回-1
- write()函数
作用:在文件中写数据
函数声明:ssize_t write(int fd,const void* buf,size_t count);
参数1,fd:文件描述符
参数2,buf:缓冲区,指向待写入的数据
参数3,count:写入的长度,单位为字节
返回值:成功时返回写入长度,否则返回-1
- read()函数
作用:从文件中读取数据
函数声明:ssize_t read(int fd,void* buf,size_t count);
参数1,fd:文件描述符
参数2,buf:缓冲区,指向读到的数据的待存储位置。
返回值:成功时返回读入长度,否则返回-1
- lseek()函数
作用:打开文件
函数声明:int open(const char* pathname,int flags);
参数1,pathname:文件的路径
参数2,flags:文件的权限
返回值:成功时返回文件描述符,否则返回-1
- dup(),dup2()函数
作用:创建一个新文件描述符,指向同一个文件表。
函数声明:int dup(int oldfd);
参数1,oldfd:旧文件描述符。
返回值:成功时返回文件描述符,否则返回-1
- access()函数
作用:判断文件是否有对应权限。
函数声明:int access(const char* pathname,int mode);
参数1,pathname:文件路径
参数2,mod:可以选择F_OK,R_OK,W_OK,X_OK,表示文件是否存在/有读权限/写权限/可执行权限。
返回值:满足mod要求时返回0,否则返回-1
- printf的实现分析
printf的实现需要辅助函数vsprintf,vsprintf函数将所有的参数格式化之后存入buf区,并返回格式化数组的长度,之后使用write系统函数将显示信息写到终端,,而字符驱动程序则需要字模库到和显示vram等软硬件设备的支持,最后,显示器的芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点,到此printf完成了它的输出功能。
- getchar的实现分析
一,缓冲阶段,直到输入\n为止,外界输入的字符都被被存放在内存的键盘缓冲区中。这段时间调用程序一直处于中止状态。
二,读入阶段,输入\n之后,getchar从stdio流中读取字符,但每次调用只读一个字符。并返回流中第一个字符的ascii码(出错返回-1),之后的getchar调用会直接从缓冲区中读取字符,直到缓冲区中的字符读完为止,才会继续等待用户按键。
三,异常控制:当用户按键时触发键盘中断,此时操作系统将控制转移到键盘中断处理子程序,将键盘输入转成ascii码,并保存到系统的键盘缓冲区。随后中断程序结束。
本章简单介绍了linux的IO体系架构,同时简单介绍了UNIX的IO接口函数,并详细分析了hello中用到的两个IO函数printf和getchar的实现原理。
用计算机系统的语言,逐条总结hello所经历的过程。
简单一个hello进程从编译到执行的过程,其实需要整个计算机系统的支持,
第一部分是编译阶段,预处理器负责预处理代码中的#命令,编译器负责将预处理好的c代码翻译成汇编,汇编器将汇编代码翻译成可重定位目标文件,而链接器将其与库文件链接,生成最终的可执行文件。
第二部分是执行阶段,这一阶段中bash调用fork制造子进程处理执行hello的命令行,这个子进程再调用execve函数加载hello所需要的上下文,最终hello结束运行,被shell父进程回收。
第三部分是支持hello运行的其他机制,包括内存管理机制和IO机制,这些机制建立了软件和硬件之间的合理抽象,使得hello能够在硬件上真正的执行起来。
通过完成这次大作业,我认识到计算机系统是一个严谨,复杂,精心构造的优美系统,层层抽象下每一层抽象各司其职,精妙的实现配合简洁的封装使得计算机系统便于更新迭代和理解。我认为,要想在系统层面作出创新是容易的,也是困难的,我们可以在某一个细节上作出创新,但是提升整体的效率却十分困难,
关于未来系统的畅想,我认为可以将量子计算技术融入计算机系统设计中,从底层带来颠覆性的影响。
Elf.txt:readelf提取出的elf文件(hello.o版本)
elfhello.txt:readelf提取出的elf文件(hello版本)
hello.i预处理后的hello文件
hello.s编译后的hello文件
hello.o汇编后的hello文件
hello可执行文件
hello_o_objdump.txt hello.o的反汇编结果
hello_objdump.txt hello的反汇编结果