摘要
本文从程序员的角度出发hello.c例如,从预处理到hello运行结束时,从预处理、编译、汇编、链接、过程/存储/IO系统分析管理方面,简要描述hello从诞生到灭亡的整个过程。同时,也作为计算机系统课程的总结。
计算机系统;程序;过程。
1.1 Hello简介... - 4 -
1.2 环境与工具... - 5 -
1.3 中间结果... - 5 -
1.4 本章小结... - 5 -
2.1 预处理的概念和作用... - 6 -
2.2在Ubuntu下一个预处理命令... - 6 -
2.3 Hello预处理结果分析... - 6 -
2.4 本章小结... - 7 -
3.1 概念与作用的编译... - 8 -
3.2 在Ubuntu下面编译的命令... - 8 -
3.3 Hello的编译结果解析... - 8 -
3.4 本章小结... - 10 -
4.1 汇编的概念和功能... - 12 -
4.2 在Ubuntu下面汇编的命令... - 12 -
4.3 可重定位目标elf格式... - 12 -
4.4 Hello.o的结果解析... - 13 -
4.5 本章小结... - 14 -
5.1 链接的概念和功能... - 15 -
5.2 在Ubuntu下链接命令........................................... - 15 -
5.3 可执行目标文件hello的格式.................................................................. - 15 -
5.4 hello的虚拟地址空间................................................................................ - 16 -
5.5 链接的重定位过程分析............................................................................... - 16 -
5.6 hello的执行流程........................................................................................ - 17 -
5.7 Hello的动态链接分析................................................................................ - 19 -
5.8 本章小结....................................................................................................... - 19 -
6.1 进程的概念与作用....................................................................................... - 20 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 20 -
6.3 Hello的fork进程创建过程..................................................................... - 20 -
6.4 Hello的execve过程................................................................................. - 20 -
6.5 Hello的进程执行........................................................................................ - 20 -
6.6 hello的异常与信号处理............................................................................ - 20 -
6.7本章小结....................................................................................................... - 21 -
7.1 hello的存储器地址空间............................................................................ - 24 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 24 -
7.3 Hello的线性地址到物理地址的变换-页式管理...................................... - 24 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 24 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 25 -
7.6 hello进程fork时的内存映射.................................................................. - 25 -
7.7 hello进程execve时的内存映射.............................................................. - 25 -
7.8 缺页故障与缺页中断处理........................................................................... - 26 -
7.9动态存储分配管理....................................................................................... - 26 -
7.10本章小结..................................................................................................... - 26 -
8.1 Linux的IO设备管理方法.......................................................................... - 27 -
8.2 简述Unix IO接口及其函数....................................................................... - 27 -
8.3 printf的实现分析........................................................................................ - 27 -
8.4 getchar的实现分析.................................................................................... - 27 -
8.5本章小结....................................................................................................... - 27 -
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
hello.c程序(program)的初始状态是由程序员创建的文本文件,用于完成以验证argc和argv数组为主的,有关C语言参数表示的一系列实验验证。当程序正确编写后,程序员可以通过IDE或是在命令行中键入命令gcc -m64 -Og -no-pie -fno-PIC hello.c -o hello等进行预处理、编译、汇编、链接等一系列操作后生成可执行文件hello。
在运行可执行文件hello的过程中,涉及到操作系统的进程管理和存储管理等一系列技术。进程管理中使用fork()函数创建新的子进程,使用execve()函数加载并运行这个hello程序,mmap系统调用使得进程之间通过映射同一个普通文件实现共享内存,hello中对库函数的调用依赖于mmap。Hello程序运行时消耗CPU资源,操作系统使用逻辑控制流的方式,将某一些细小的时间片分配给hello进程,使其看上去好像在独占地使用CPU。
在存储管理中,MMU(内存管理单元)需要根据CPU中页表基址寄存器从页表中获得虚拟地址(VA)索引和物理页号,再由此找到偏移量组合形成物理地址(PA),以从物理内存中获得所需的数据和指令。TLB作为页表的缓存,三级Cache作为主存的缓存存在,以利用其硬件速度上的优势,提高搜索的效率;页表分级的目的是节省空间,提高存储的空间利用率;Pagefile虚拟内存作为主存的后备,用户储存超出RAM大小的数据。
信号处理提供了诸如SIG_INT的一系列信号和中断、阻塞等一系列处理机制,IO管理用于连接IO设备和计算机CPU并提供二者之间的信号传递和处理。同样的,流水线式的异常处理机制使得用户看上去(对多核处理器确实可能是)键盘、主板、显卡和屏幕的工作是同时的,即用户在shell中输入指令(包括参数)、按下回车,屏幕立即有变化,而执行过程中ctrl+c等按键也有不同的效果。
当进程运行完毕(hello中一般指return,有时也可能因为出错直接exit),该进程形成僵死进程,而当初fork的父进程负责回收子进程。在命令行执行时,这个父进程就是bash进程,它负责为hello“收尸”。
1.2 环境与工具
X64 CPU;2GHz;2G RAM;256GHD Disk 以上
Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位;
GDB/OBJDUMP;EDB
1.3 中间结果
hello.c(源文件)
hello.i(预处理中间文件)
hello.s(编译产生的中间文件)
hello.o(汇编产生的可重定位文件)
hello(链接产生的可执行文件)
1.4 本章小结
本章对hello的P2P,020的整个过程作了简要介绍,列出了运行的环境及使用的工具以及中间结果,是后面章节的概括。
2.1 预处理的概念与作用
预处理指驱动程序运行C预处理器(cpp),它将C的源程序hello.c翻译成一个ASCII码的中间文件hello.i:
gcc hello.c -E -o hello.i
这一过程包括以下内容:
(1)将所有的#define删除,并展开所有的宏定义; (2)处理所有的预编译指令,例如:#if,#elif,#else,#endif; (3)处理#include预编译指令,将被包含的文件插入到预编译指令的位置; (4)添加行号信息文件名信息,便于调试; (5)删除所有的注释:// /**/; (6)保留所有的#pragma编译指令,因为在编写程序的时候,我们经常要用到#pragma指令来设定编译器的状态或者是指示编译器完成一些特定的动作。 (7)生成.i文件。
2.2在Ubuntu下预处理的命令
Ubuntu下执行预处理命令生成hello.i文件
在命令行键入命令gcc hello.c -E -o hello.i,回车执行后生成中间文件hello.i。
2.3 Hello的预处理结果解析
Hello.c的预处理中间文件hello.i用gedit打开后,发现是由源程序经(1)去注释 (2)宏替换 (3)头文件展开 (4)条件编译后生成的。尤其是宏替换和头文件展开在源代码前序部分生成了一段很长的代码(如图所示)。
部分hello.i代码
2.4 本章小结
预处理命令,可以在编译器编译之前,提前进行一些操作,比如定义常量,还可以进行条件编译以方便调试,可以进行文件引入来导入一些预先写好的模块,便于程序的组织和调试和一些特殊的编程技巧的实现,是一项非常有用的功能。
3.1 编译的概念与作用
编译是指驱动程序运行C编译器(ccl),将预处理生成的中间文件hello.i翻译成一个ASCII汇编语言文件hello.s的过程。包括以下过程:
(1)扫描,语法分析,语义分析,源代码优化,目标代码生成,目标代码优化;
(2)生成汇编代码;
(3)汇总符号;
(4)生成.s文件
3.2 在Ubuntu下编译的命令
在命令行键入命令gcc -S hello.i -o hello.s,回车执行后生成汇编文件hello.s。
Ubuntu下执行编译命令生成hello.s文件
3.3 Hello的编译结果解析
使用gedit打开hello.s文件后,发现文件的源码部分已经由C语言代码变为汇编语言代码,而展开的头文件、预编译指令都不在文件中。Hello.s中的汇编代码具有明显的loop结构和条件控制转移结构,与源码逻辑是符合的。
部分hello.s代码
3.3.1 函数调用
Hello.s中主要采用call命令调用函数,包括printf、atoi、sleep、exit、getchar等等,这些函数的传参方式都是寄存器传参,然而值得注意的是,在hello.s文件中,main函数的传参是寄存器传参。函数的返回方面,main函数在.L3中使用ret语句正常返回,在.LBF6中因参数个数不等于4,调用exit(1)结束程序,所以无返回值,main函数中调用的函数多为库函数,按各自逻辑进行返回。
.L3和.LFB6对应的汇编代码
3.3.2 变量
Hello.s中,局部变量int i被存放在栈中最靠近栈底的4个字节的位置,即-4(%rbp)处,随着每次循环进行自加,并与7比较,大于7时跳出循环。
与i相关的循环体部分代码
3.3.3 赋值操作
局部变量i在定义时不赋初值,仅到当进入循环体时被赋值为0,赋值操作采用mov命令,将立即数存入栈中i的位置。
对i的赋值操作
3.3.4 算术操作
局部变量i在每次循环后自加,自加采用add命令,将立即数1直接加到栈中i的位置处。
对i的算术操作
3.3.5 关系操作
局部变量i在每次自加后,都需要判断与7的关系,以决定是否跳转回到循环体,或者跳出循环返回,关系判断使用cmp和jle命令。源码中要求i<8,i<=7也能表达相同的意思。
对i的关系操作
除此以外,在程序运行伊始,需要判断main函数的参数个数argc是否为4,这里使用到关系操作!=,使用cmp和je命令。Je用于判断相等,其作用和jne是对称的,具体使用由编译器决定。
对argc的关系操作
3.3.6 数组操作
在循环体中要求打印参数argv[1]和argv[2],并由argv[3]决定sleep的时长。正如前文中介绍的,main函数的参数数组元素的地址存储在栈中,因此读取参数数组的元素时使用mov语句,将第0个参数的地址移至寄存器rax中,使用rax加上偏移量计算各个参数的地址,最后取值放入printf函数的传参寄存器中。
对参数数组argv[]的操作
3.3.7 控制转移
在判断参数个数argc是否为4的分支中采用条件控制转移语句,源码中的if语句,对应到汇编代码中是cmp+je语句,代码在上文中贴出,这里不再赘述。
3.4 本章小结
本节涉及到的指令全部为gun汇编程序(gas)的伪汇编指令,相比最后的汇编指令内容更为精简,方便阅读、分析。程序将常量放入.rodata节,初始化全局变量放入.data节,通过标签定义和跳转等方式定义许多操作,为后序的汇编和链接生成可执行文件准备。
4.1 汇编的概念与作用
汇编是指驱动程序运行汇编器(as),将hello.s翻译成一个由机器语言构成的可重定位目标文件(relocatable object file)hello.o的过程,包括了以下内容:
(1)根据汇编指令和特定平台,把汇编指令翻译成二进制形式;
(2)合并各个section,合并符号表;
(3)生成.o文件。
4.2 在Ubuntu下汇编的命令
在命令行键入命令gcc -c hello.s -o hello.o,回车执行后生成汇编文件hello.o。
Ubuntu下执行汇编命令生成hello.o文件
4.3 可重定位目标elf格式
Hello.o的elf头由.text、.rela.text(重定位节,包含.text节需要进行重定位的信息,在.o生成可执行文件的时候会被修改,本hello.o需要被重定位的是printf、puts、exit、sleepsecs、getchar、sleep和.rodata中的.L0和.L1)、.data、.bss、.rodata、.comment、.note.GNU-stack、.note.gnu.property、.eh_frame、.rela.eh_frame(.eh_frame节重定位信息)、.symtab、.strtab、.shstrtab构成,使用readelf -S -W hello.o指令列出各节的信息(包括大小Size、偏移Off等)如下图所示:
readelf列出各节信息
4.4 Hello.o的结果解析
执行反汇编语句截图
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
反汇编的结果与hello.s基本一致,在某些细节上有些许差别。例如:objdump反汇编输出的立即数都是用十六进制表示的,而hello.s中是十进制;hello.s中调用函数使用的是call+函数名,在反汇编中使用的是与main的相对地址表示(这个地址显然不是被调函数的地址,被调函数的信息在下一行中给出);hello.s中条件控制转移的跳转指令的跳转点在反汇编中也是使用相对地址进行表示。
机器语言由指令和操作数构成,其指令种类、操作数个数决定了指令的长短和内容。机器语言中的某些组合和汇编语言中的指令相对应,某些组合和汇编语言中的操作数相对应。例如:十六进制的55代表push %rbp指令,7e代表jle,01可以代表立即数$0x1,当然,同一个指令也可能映射到不同的机器码上,其中的规则是复杂的。
4.5 本章小结
汇编是将计算机不能读懂的汇编语言翻译成计算机能读懂的机器语言的不可缺少的重要步骤。
5.1 链接的概念与作用
驱动程序运行连接器程序ld,将hello.o和一些必要的系统目标文件组合起来,创建一个可执行目标文件(executable object file)的过程。包括以下内容:
(1)合并各个.obj文件的section,合并符号表,进行符号解析;
(2)符号地址重定位;
(3)生成可执行文件
5.2 在Ubuntu下链接的命令
在命令行键入命令gcc hello.o -o hello,回车执行后生成汇编文件hello。
Ubuntu下执行链接命令生成hello文件
5.3 可执行目标文件hello的格式
hello的elf头格式
使用readelf展开hello的elf头如上图所示,hello的elf包括若干节,诸如.interp、.note.gnu.property、.note.gnu.build-id、.note.ABI-tag、.gnu.hash、.dynsym、.dynstr、.gnu.version、.gnu.version_r、.rela.dyn、.rela.plt、.init、.plt、.plt.got、.plt.sec、.text、.fini、.rodata、.eh_frame_hdr、.eh_frame、.init_array、.fini_array、.dynamic、.got、.data、.bss、.comment、.symtab、.strtab、.shstrtab节。并从图中可以读出各段的起始地址、大小等信息,如:.bss节的起始地址为0x4010,大小为8个字节。
5.4 hello的虚拟地址空间
Hello虚拟地址空间分布情况
本进程的虚拟地址空间各段信息如上图所示,包含了hello文件、libc库、ld库等组成部分。实际运行中,所有虚拟地址空间段大小都为0x1000,且一段中包含一个或多个5.3中的程序段。动态链接库中的文件映射到内存的内容,与hello文件中映射到内存的内容地址间隔较大。程序中还包括[stack],[vvar],[vdso],[vsyscall]等特殊用途的地址段。
5.5 链接的重定位过程分析
使用objdump -d -r hello输出hello的十分详细的节信息,发现其包含的节是hello.o的子集,仅保留:.init、.plt、.plt.got、.plt.sec、.text、.fini节的详细信息。
hello中包含一些外部文件的宏定义、变量、库函数和操作系统的启动代码等,且.o文件.text节从0开始,而可执行文件.text节并非从0开始。
重定位的过程分为符号解析和重定位两步。
符号解析:目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量。符号解析的目的是将每个符号引用正好和一个符号定义关联起来
重定位:编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
hello.o的文件中包含一些重定位条目,这些重定位条目告诉链接器32位PC相对地址或32位绝对地址进行重定位,这些重定位条目通过计算地址或直接调用保存的绝对地址,达到重定位的目的。
无论何时汇编器遇到对最终位置未知的目标引用,会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的条目放在.rel.data中。
5.6 hello的执行流程
程序运行调用(途径)的所有函数如下所示:
_start //程序开始
__libc_start_main
__GI___cxa_atexit
__internal_atexit
__GI___cxa_atexit
__internal_atexit
__new_exitfn
__internal_atexit
__GI___cxa_atexit
__libc_start_main
_setjmp
__sigsetjmp
__sigjmp_save
__libc_start_main //准备调用main函数
__GI_exit
__run_exit_handlers
__GI___call_tls_dtors
__run_exit_handlers
__do_global_dtors_aux
deregister_tm_clones
__do_global_dtors_aux
_fini
__run_exit_handlers
_IO_cleanup
_IO_unbuffer_all
_IO_cleanup
_IO_flush_all_lockp
_IO_cleanup
_IO_unbuffer_all
_IO_cleanup
_IO_unbuffer_all
_IO_new_file_setbuf
_IO_default_setbuf
_IO_new_file_sync
_IO_default_setbuf
__GI__IO_setb
_IO_default_setbuf
__GI__IO_setb
_IO_default_setbuf
_IO_new_file_setbuf
_IO_unbuffer_all
_IO_new_file_setbuf
_IO_default_setbuf
_IO_new_file_sync
_IO_default_setbuf
__GI__IO_setb
_IO_default_setbuf
__GI__IO_setb
_IO_default_setbuf
_IO_new_file_setbuf
_IO_unbuffer_all
_IO_cleanup
__run_exit_handlers
__GI__exit //程序结束
5.7 Hello的动态链接分析
动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址,在汇编代码中也无法像静态库的函数那样体现。
hello程序对动态链接库的引用,基于数据段与代码段相对距离不变这一个事实,因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。
GNU编译系统采用延迟绑定技术来解决动态库函数模块调用的问题,它将过程地址的绑定推迟到了第一次调用该过程时。
延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)实现。如果一个目标模块调用定义在共享库中的任何函数,那么就有自己的GOT和PLT。前者是数据段的一部分,后者是代码段的一部分。
5.8 本章小结
链接是组建大型程序和团队编程不可缺少的重要部分,掌握链接器的一些原理和动态链接是非常有必要的,也是学习库打桩等强大机制的基础。虽然hello.c很简单,但是也需要和标准库进行链接。了解hello.c链接的来龙去脉,对掌握链接技术很有帮助。
6.1 进程的概念与作用
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程提供给应用程序两个关键的抽象:
(1)一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;
(2)一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个交互型的应用级程序,它代表用户运行其他程序。最早的shall是sh程序,后面出现了一些变种,比如csh、tcsh、ksh和bash。Shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
bash 是一个为GNU项目编写的Unix shell,也就是linux用的shell。
6.3 Hello的fork进程创建过程
shell调用fork函数,形成自身的一个拷贝(子进程),为运行hello做准备。
6.4 Hello的execve过程
在shell的子进程中执行execve函数,将参数传给Hello程序,并执行hello。
6.5 Hello的进程执行
一开始,hello运行在用户模式,当程序收到一个信号(可能是用户的键盘输入或者硬件中断等)时,进入内核模式,运行信号处理程序,之后再返回用户模式。在hello运行的过程中,cpu不断切换上下文,使hello程序运行过程被切分成时间片,与其他进程交替占用cpu,实现进程的调度。
6.6 hello的异常与信号处理
程序终止
处理过程是向程序发送SIG_INT信号,程序执行默认行为:终止前台进程。
(2)输入Ctrl-Z时,程序挂起,如下图所示:
程序挂起
处理过程是向程序发送SIGSTP信号,程序执行默认行为:挂起程序,之后会返回shell中。
(3)乱按+回车
会将输入的字符串显示在两组本应输出的Hello字符串之间,并不会有其他变化。
乱按+回车
(4)ps jobs pstree fg kill命令
ps:显示当前进程的状态
当前程序状态
jobs:查看后台运行的进程(在上文中已经演示过了)
fg:恢复一个后台进程
恢复一个后台进程
pstree:显示进程树
部分进程树内容
kill:结束一个进程
可能会产生IO中断、时钟中断、系统调用等等,会产生SIGINT、SIGSTP等信号。
6.7本章小结
linux命令行shell是一个非常强大的工具,用它可以更方便的执行Hello和发送各种命令请求。通过信号等方式可以实现异常处理,让Hello在顺序执行者也能处理一些突发状况和实现一些功能。进程调度实现了各个进程计算资源合理分配,互不干扰,提高了系统稳定性和效率。
7.1 hello的存储器地址空间
用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。在hello从预处理到运行结束的过程中,并观察不到这个地址。
(2)逻辑地址(logical address)
逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址,是编写hello程序代码时使用到的地址。比如说argv[]数组的首地址argv。
(3)线性地址(linear address)或也叫虚拟地址(virtual address)
跟逻辑地址类似,它也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址,是hello程序运行时使用的地址,在edb中可以查看到。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel使用段式管理处理逻辑地址到线性地址的变换。
逻辑地址=段选择符+逻辑地址偏移量。Intel设置了四类段寄存器:SS(栈段寄存器)、ES/GS/FS(辅助段寄存器)、DS(数据段寄存器)、CS(代码段寄存器)用于存放段选择符。段选择符由三个字段组成:索引、TI、RPL,TI决定描述符表的类型,RPL决定进程的状态转换,索引用来确定当前使用的段描述符在描述符表中的位置。Intel处理器中,通过段选择符查表获得段基址、段限、存取权限信息。通过基址寄存器、变址寄存器、比例因子和逻辑地址的偏移量可以计算出一个有效地址EA,再加上段基址得到线性地址。
线性地址到主存地址的转换可以通过分页完成。
7.3 Hello的线性地址到物理地址的变换-页式管理
分页的基本原理是把内存划分成大小固定的若干单元,每个单元称为一页(page),每页包含4k字节的地址空间(为简化分析,我们不考虑扩展分页的情况)。这样每一页的起始地址都是4k字节对齐的。为了能转换成物理地址,我们需要给CPU提供当前任务的线性地址转物理地址的查找表,即页表(page table)。
为了节约页表占用的内存空间,x86将线性地址通过页目录表和页表两级查找转换成物理地址。32位的线性地址被分成3个部分:最高10位 Directory 页目录表偏移量,中间10位 Table是页表偏移量,最低12位Offset是物理页内的字节偏移量。页目录表的大小为4k(刚好是一个页的大小),包含1024项,每个项4字节(32位),项目里存储的内容就是页表的物理地址。如果页目录表中的页表尚未分配,则物理地址填0。页表的大小也是4k,同样包含1024项,每个项4字节,内容为最终物理页的物理内存起始地址。
每个活动的任务,必须要先分配给它一个页目录表,并把页目录表的物理地址存入cr3寄存器。页表可以提前分配好,也可以在用到的时候再分配。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB(翻译后备缓冲器)是一个小的、虚拟寻址的缓存,其