Hello,是每个程序员都写过的第一个程序,它是那么简单,以至于在程序员几分钟就学会并抛弃了它;它又是那么伟大,因为它麻雀虽小,五脏俱全。学习计算机系统以后,再回头看一看它,才恍然大悟,Hello才是一切的开始……
本论文旨在通过讲述Hello这个程序运行时计算机进行的各种操作,来揭示Hello从出生到回收(Zero to Zero)的全过程,系统地回顾计算机系统这门课程,加深对计算机系统的认识。
关键词:hello;预处理;编译;汇编;链接;进程;管理……
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式..................................................................... - 15 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 21 -
6.3 Hello的fork进程创建过程........................................................................ - 21 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 25 -
7.3 Hello的线性地址到物理地址的变换-页式管理......................................... - 26 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................ - 28 -
7.5 三级Cache支持下的物理内存访问............................................................. - 30 -
7.6 hello进程fork时的内存映射..................................................................... - 31 -
7.7 hello进程execve时的内存映射................................................................. - 32 -
7.8 缺页故障与缺页中断处理.............................................................................. - 32 -
8.2 简述Unix IO接口及其函数.......................................................................... - 35 -
第1章 概述
1.1 Hello简介
P2P:hello.c源代码经过预处理->编译->汇编->链接四个步骤生成一个hello的二进制可执行文件,然后由shell执行。
020:shell执行时,为其映射出虚拟内存,在开始运行进程的时候分配并载入物理内存,来执行hello的程序,将输出显示到屏幕,然后进程结束,shell回收内存空间。
1.2 环境与工具
硬件环境:X64CPU;2GHz;2GRAM;128GB HD
软件环境:Ubuntu 16.04 LTS 64位
使用工具:objdump,gdb,edb
1.3 中间结果
hello.i:预处理后的文件
hello.s:编译后文件
hello.o:汇编后文件
hello:链接后文件
1.4 本章小结
对hello的分析由此开始。
第2章 预处理
2.1 预处理的概念与作用
使用gcc的-E命令生成一个.i文件,C预处理器(C Preprocesser),简称CPP。在预处理的过程中,处理的主要是源代码中的预处理指令,引入头文件,去除注释,处理所有的条件编译指令,替换宏,添加行号,保留所有的编译器指令。
预处理过的文件将不再存在宏,源程序中所有的宏都会被替代。
2.2在Ubuntu下预处理的命令
gcc -E helo.c > hello.i
图 2-1-1 Ubuntu下预处理的命令
图 2-1-2 Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
预处理后,头文件被引入(#include xxxxxx),注释被去除,宏都被替换,添加了行号,所有的编译器指令被保留。
2.4 本章小结
这是一个程序出生的第一步,文件中的宏将不再存在,源程序中所有的宏都会被替代。预处理也可用来查看宏是否正确或者头文件包含是否正确。
第3章 编译
3.1 编译的概念与作用
编译器(Compiler/Compiling Program),对预处理后的文件进行语法分析,词法分析,语义分析,符号汇总,生成汇编代码。
3.2 在Ubuntu下编译的命令
gcc -S hello.i > hello.s
图 3-1-1 Ubuntu下编译的命令
图 3-1-2 Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.3.1全局变量(sleepsecs)
sleepsecs被保存在.rodata段中,由于其值在随后的程序中没有被更改, 故其被优化为只读变量。
图 3-2-1 hello.s中有关sleepsecs的段
3.3.2局部变量(i)
i是循环控制变量,被存储在栈中,于.L2中被初始化。
图 3-2-2 hello.s中有关i的节
3.3.3条件控制语句(if())
hello.s中关于if()的描述如图3-3-1,“movl %edi, -20(%rbp)” 是读 取主函数参数argc,“cmpl $3, -20(%rbp)”则是比较3与argc的关系, 若相
等,则执行.L2的代码,若不等,则执行“je .L2”后的代码(图 3-3-2)。
图 3-3-1 hello.s中关于if()的语句
图 3-3-2 “je .L2”后的代码
3.3.4 for()循环
for()循环分为三部分。
先是初始化变量,即赋值循环控制变量i为0,汇编代码如图 3-4-1。%rbp的前面4个字节被赋为0。
然后是条件判断语句(i < 10),如图3-4-2。“cmpl $9, -4(%rbp)”将i与9比较,i <= 9时跳转至.L4。
循环体的代码在.L4中,如图3-4-3。
图 3-4-1 初始化变量
图 3-4-2 .L3部分代码
图 3-4-3 .L4代码
3.3.5函数调用(printf)
先是传递参数,如图3-3-4-3,%rdi,%rsi,%rdx即为三个参数,分别 构造这三个参数,其中%rdi用到了.L1中的代码(如图3-5)。
图 3-5 .L1
3.4 本章小结
编译将高级语言转化为汇编语言,这其中针对各种变量、操作符、结构和函数调用都有其处理方式,本章通过hello.s中的几种操作,讲述了这些处理方式。
第4章 汇编
4.1 汇编的概念与作用
汇编过程将汇编代码转化成二进制文件,二进制文件可由机器读取。一条汇编语句对应一句机器语言。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s > hello.o
图 4-1-1 Ubuntu下汇编的命令
图 4-1-2 Ubuntu下汇编的命令
4.3 可重定位目标elf格式
图 4-2-1 ELF头信息
从ELF头可读出,数据采用补码表示,为小端法。共有13个节头表,每个占64bytes。
下面分析一些重要的节头表(顺序分别为
Name,Type, Address, Offset
Size, EntSize,Flags,Links,Info,Align):
如图4-2-2,是代码段(.text),偏移为0x40,大小为0x7d,对齐为1byte。
图 4-2-2 .text节头表
如图4-2-3,是代码段重定位(.rela.text),偏移为0x320,大小为0xc0,对齐为8byte。
图 4-2-3 .rela.text节头表
如图4-2-4,是数据段(.data),保存已经初始化了的全局变量和静态变量,偏移为0xc0,大小为0x04,对齐为4byte。
图 4-2-4 .data节头表
如图4-2-5,是只读数据段(.rodata),保存只读数据,例如printf打印的字符串,偏移为0xc8,大小为0x180,对齐为8byte。
图 4-2-5 .rodata节头表
如图4-2-6,是符号表(.symtab),偏移为0x168,大小为0x180,对齐为8byte。
图 4-2-6 .symtab节头表
4.4 Hello.o的结果解析
(以下格式自行编排,编辑时删除)
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
4.4.1跳转指令“je”在汇编和反汇编中的区别
对比如图4-3-1和图4-3-2,je采用PC相对方式跳转,在反汇编代码中, “74”代表“je”,执行到je时,%rip的值为0x13,故可得.L2的代码位于 0x13+0x2+0x14即0x29处。
图 4-3-1
图 4-3-2
4.4.2全局变量的引用在汇编和反汇编中的区别
对比如图4-4-1和图4-4-2,汇编代码中,学号和姓名使用UTF-8编码 来表示,而返汇编代码是把只读数据中的地址传给%edi。“bf”代表“mov”操 作,其后4位表示引用变量与当前PC的相对位置,其值将在链接过程中确认。
图 4-4-1-1
图 4-4-1-2
图 4-4-2
4.4.3调用共享库在汇编和反汇编中的区别
对比如图4-5-1和图4-5-2,调用“puts”函数,“e8”代表“callq”,其后 4位表示引用变量与当前PC的相对位置,其值将在链接过程中确认。
图 4-5-1
图 4-5-2
4.5 本章小结
汇编操作会生成一个重定位目标文件hello.o,及二进制文件。另外,汇编操作会生成符号表,这些符号表会被分配虚拟地址。
第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-1 Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
对比图4-2-1与图5-2-1,可发现在ELF头中发生变化的部分有Type,Entry point address,Start of program headers,Start of section headers,Size of program headers,Number of section headers和Section header string table index。
图 5-2-1
节头表由12个增加为24个。
下面分析一些重要的节头表(顺序分别为
Name,Type, Address, Offset
Size, EntSize,Flags,Links,Info,Align):
如图5-3-1,是PLT重定位段(.rela.plt),偏移为0x3a0,大小为0x90,对齐为8byte。
图 5-3-1 .rela.plt
如图5-3-2,是PLT代码段(.plt),偏移为0x450,大小为0x70,对齐为16byte。
图 5-3-2 .plt
如图5-3-3,是代码段(.text),偏移为0x4d0,大小为0x122,对齐为16byte。
图 5-3-3 .text
如图5-3-4,是只读数据段(.rodata),偏移为0x600,大小为0x3a,对齐为8byte。
图 5-3-4 .rodata
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析。
如图5-4、5-5,00400450即为PLT代码节,004004d0即为.text代码段。
图 5-4
图 5-5
5.5 链接的重定位过程分析
5.5.1全局变量的引用
对比图5-6-1和5-6-2,在链接前,全局变量的地址暂时使用0来表示, 在经过链接重定位之后,地址被替换为一个虚拟地址(绝对)(根据Offset 和Type等信息计算而得)。
图 5-6-1 全局变量链接前
图 5-6-2 全局变量链接后
5.5.2函数调用
调用函数的汇编语言为callq(e8),对比图5-7-1和5-7-2,链接前函数的地址是0,链接之后其被替换为一个虚拟地址(相对)。
图 5-7-1 函数调用链接前
图 5-7-2 函数调用链接后
5.6 hello的执行流程
hello执行前,系统创建子进程,从内存加载数据,然后跳转至_start()函数,如图5-8。
图 5-8 _start()
然后_start()调用系统启动函数__lib_start_main@plt,如图5-9。
图 5-9 __lib_start_main@plt为main调用创建环境
整个过程中调用或跳转的子程序名:
_start
__libc_start_main
_dl_relocate_static_pie
_dl_aux_init
__libc_init_secure
get_common_indeces.constprop.1
__tunable_get_val
strcspn_ifunc
mempcpy
wmemset
strcmp_ifunc
wcsnlen
memset_ifunc
strcasecmp_l
strstr
memchr_ifunc
strchrnul
stpcpy
strrchr_ifunc
5.7 Hello的动态链接分析
在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑。在执行过_dl_init之后被赋上了相应的偏移量的值。
5.8 本章小结
本章介绍了链接的概念和作用,分析了hello的ELF格式,在链接的过程中,链接器将程序在汇编时留下的地址空槽填上虚拟地址。还介绍了重定位和执行的过程以及动态链接的过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中的程序的实例,其作用可以提供独立的逻辑控制流和私有的地址空间。
6.2 简述壳Shell-bash的作用与处理流程
shell提供了一个用户与内核交互的界面。
处理流程为:
读取用户输入->分析获得参数->执行命令->执行命令同时监视键盘的输入并 做出反映
6.3 Hello的fork进程创建过程
如图。
图 6-1 Hello的fork进程创建过程
6.4 Hello的execve过程
shell会调用execve函数执行hello,execve执行hello需如下过程:
6.4.1删除已存在的用户区域
即删除之前shell运行时已经存在的区域结构。
6.4.2映射私有区域
为hello的代码、数据、bss和栈区域创建新的区域结构。代码和数据区域被映射为hello文件中的.text和.data区。bss区域被映射到匿名文件,其大小包含在hello中。栈和堆区域初始长度为零。
6.4.3映射共享区域
hello程序与共享对象链接,例如标准C库libc.so,那么这些对象都是先动态链接到这个程序,然后再映射到用户虚拟地址空间中的共享区域内。
6.4.4设置程序计数器
设置当前进程上下文中的程序计数器指向代码区域的入口点。
6.5 Hello的进程执行
6.5.1Sleep调度
程序运行到sleep函数时,hello进程进入休眠,等到sleepsecs(2s) 到达后,从其他进程切换回hello进程继续执行。其过程为:
从hello陷入内核模式->内核进行上下文切换,执行与hello并发的其他 进程->休眠结束->其他程序陷入内核->内核进行上下文切换,继续执行hello
6.5.2正常调度
hello运行一段时间后,系统会自动切换至其他进程,过程与sleep相似。
6.6 hello的异常与信号处理
6.6.1Ctrl-Z
Ctrl-Z是SIGTSTP信号,hello进程将暂停,可通过ps命令查看hello 进程是否被回收,如图6-2。
图 6-2 Ctrl-Z信号
6.6.2Ctrl-C
Ctrl-C是SIGINT信号,结束hello进程,然后回收,如图6-3。
图 6-3 Ctrl-C信号
6.6.3乱按
乱按键入的信息都会被getchar()读入,在不小心碰到回车(\n)时,会被作为指令输入,如图6-4。
图 6-4 乱按
6.7本章小结
进程是一个执行中的程序的实例,提供了独立的逻辑控制流和私有的地址空间。Shell收到指令就会解析,执行过程中可以通过各种命令来挂起、切换进程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1物理地址
CPU通过地址总线的寻址,找到真实的物理内存对应地址。
7.1.2逻辑地址
逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的 地址,即程序的机器代码中保存的地址。
7.1.3线性地址
也叫虚拟地址,对应了硬件页式内存的转换前地址。
7.1.4地址转换关系
如图7-1。
图 7-1 三种地址转关系(图片来自网络)
7.2 Intel逻辑地址到线性地址的变换-段式管理
如图7-2,7-3。
图 7-2 (图片来自网络)
图 7-3 (图片来自网络)
转换过程如图7-4。
图 7-4 转换过程(图片来自网络)
7.3 Hello的线性地址到物理地址的变换-页式管理
如图7-5,7-6,7-7。
图 7-5 (图片来自网络)
图 7-6 (图片来自网络)
图 7-7 地址转换过程
7.4 TLB与四级页表支持下的VA到PA的变换
如图7-8,7-9,7-10,7-11。
图 7-8 基本概念
图 7-9 (图片来自网络)
图 7-10 (图片来自网络)
图 7-11 (图片来自网络)
7.5 三级Cache支持下的物理内存访问
如图7-12。
图 7-12 (图片来自网络)
7.6 hello进程fork时的内存映射
如图 7-13。
图 7-13 (图片来自网络)
7.7 hello进程execve时的内存映射
参考6.4。
7.8 缺页故障与缺页中断处理
缺页故障处理:流程图如图7-14。
图 7-14 (图片来自网络)
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,MMU就能正常翻译VA了。
7.9动态存储分配管理
printf函数会调用malloc,下面简述动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器分为两种基本风格:
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块, 自动释放未使用的已经分配的块的过程叫做垃圾收集。
如图7-15。
图 7-15 动态存储分配管理
7.10本章小结
本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,Hello中涉及到的地址都保存在编码中,保存在编码中的地址成为逻辑地址。在CPU层面上,程序需要将逻辑地址交给CPU,然后得到线性地址。在这个过程中我们了解了intel 的段式管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件, 所有的输人和输出都被当作对相应文件的读和写来执行。
设备管理:设备的模型化将设备映射为文件,这就允许Linux内核引出一个 简单、低级的应用接口,(Unix I/O)。
8.2 简述Unix IO接口及其函数
8.2.1接口
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访 问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文 件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准 错误。
改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k, 初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过 执行seek,显式地将改变当前文件位置k。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件 位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当 k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一 个文件,从当前文件位置k开始,然后更新k。
关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到 可用的描述符池中去。
8.2.2Unix I/O函数
int open(char* filename,int flags,mode_t mode) ,进程通过调用 open函数来打开一个存在的文件或是创建一个新文件的。open函数将 filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是 在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这 个文件,mode参数指定了新文件的访问权限位。
int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。
ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当 前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0 表示EOF,否则返回值表示的是实际传送的字节数量。
ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置 buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
参考如下链接
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求。CPU收到中断请求后,挂起当前进程,然后运行键盘中断子程序。键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符(\n)然后返回整个字串到stdin中。
8.5本章小结
Linux系统将设备的模型化简化了程序和设备的关系,看似简单的printf和getchar函数其实其实现机制却很复杂,这里无法详尽讨论。
结论
hello的一生主要经历了如下几个阶段:
1.预处理:gcc执行hello.c中的预处理命令,合并库,宏展开。
2.编译:将hello.i编译成为汇编文件hello.s。
3.汇编:将hello.s会变成为可重定位目标文件hello.o。
4.链接:将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序。
5.运行:在shell中运行程序。
6.创建子进程:shell进程调用fork为其创建子进程,分配pid。
7.运行程序:子进程shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数。
8.执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。
9.访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
10.动态申请内存:调用malloc向动态内存分配器申请堆中的内存。
11.信号:运行途中键入ctr-c ctr-z则调用shell的信号处理函数分别停止、挂起。
12.结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。
hello,因为被程序员无情抛弃而充满了委屈,我也因此在摘要中把它想象得很伟大。但经过本文一分析,hello的生命过程不过那么几个阶段,其他程序也一样,更何况hello除了会打印“hello xxx xxx”并无实际用途,让我分析了这么久,现在我把你分析透彻了,还委屈不?
附件
hello.i:预处理后的文件
hello.s:编译后文件
hello.o:汇编后文件
hello:链接后文件