程序人生-Hello’s P2P
摘 要
本文通过hello程序从编写源程序被编译、汇编、链接、运行,从外部存储设备,经过I/O桥,进入到内存,各级cache,最后在I/O中输出,最后被回收的过程描述,诠释了hello,简单却复杂的一生,描述了最简单的程序,却在生命周期中有着同样复杂的经历,从而揭开程序接近底层运行机制的过程。
关键词:hello 编译 汇编 链接 存储 进程
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:指从Program到process的过程,具体如下。
- 编写源程序Program, hello.c源程序;
- 从.c的文本文件经过cpp预处理器预处理,根据#开头的命令修改源程序,生成新的源程序.i文件;
- 再通过编译器ccl,将.i文件编译成.s的汇编程序;
- 经过编译器as将.s文件翻译成机器语言,生成可重定位目标程序,一个二进制文件;
- 链接:将标准C库中的printf.o函数合并,得到二进制的可执行文件。
- 运行:输入hello 117030**
- 创建子程序:通过fork创建子程序hello成为进程的开始,也就是process的开始;
- 执行:通过execve加载器载入,建立虚拟内存映射,设置当前进程的上下文中的程序计数器,使之指向程序入口处。CPU为其分配相应的时间分片,形成同其他程序并发执行的假想;
- 访存:CPU上的内存管理单元MMU根据页表将CPU生成的虚拟地址翻译成物理地址,将相应的页面调度;
- 动态内存申请:printf调用malloc进行动态内存分配,在堆中申请所需的内存;
- 接收信号:中途接受ctrl+z挂起,ctrl+c终止;
- 结束:程序返回后,内核向父进程发送SIGCHLD信号,此时终止的hello被父进程回收。
020:从原来OS存储管理,MMU根据TLB将VA翻译成PA,向cache发出请求,发生缺页故障后,逐层申请,发生一系列的不命中后,通过页面交换进入内存,就这样,hello离开磁盘,通过I/O桥,一生的旅行就开始了。
再经过上面P2P的一系列过程之后,hello的一生以被父进程回收为终点。生也OS,死也OS,form zero-O to zero-O.
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2.80GHz;8G RAM;256G SSD +1T HDD
1.2.2 软件环境
Windows10 64位;Vmware 14.1.3;Ubuntu 16.04 LTS 64位;
1.2.3 开发工具
Edb, gdb, ccp, as, ld, readelf, gcc
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 文件作用
hello.i 预处理之后的源程序
hello.s 编译之后的汇编程序
hello.o 汇编之后的可重定位目标程序
hello.elf hello.o的ELF格式
helloexe.elf hello的ELF格式
hello 可执行目标程序
hello2.s 反汇编后输出的程序
Helloobj.txt Hello可执行程序的反汇编代码
Temp.c 临时数据存放
1.4 本章小结
本次实验,我们基于Hello展开从Program到Process的过程,通过对其预处理、编译、汇编、链接的过程,领会程序由.c源文件到可执行文件的细节及工作方式。
进一步体会程序从存储管理中的源文件,通过IO加载到主存,再通过CPU执行,将Hello的内容显示在屏幕上的具体过程原理。本次实验将在Win10系统的VMWAare虚拟机上完成,具体工作过程见后面章节。
第2章 预处理
2.1 预处理的概念与作用
概念:预编译又称为预处理,是做些代码文本的替换工作。就是为编译做的预备工作的阶段。是根据以字符#开头的命令,修改源程序的过程,最后最后生成.i的文本文件。
作用:C语言预处理主要包括3个方面:1.宏定义;2.文件包含;3.条件编译
预处理即将宏进行展开。
1.#define标识符 字符串
如上格式的宏定义中,预处理的过程中,将标识符用字符串替代,倘若含有参数,形如#define 宏名(参数表) 字符串,则还要做参数替换,但不进行语法检查,不计算。
2.文件包含 #include <文件名>
根据#修改文件后,编译时就以包含处理以后的文件为编译单位,被包含的文件将作为源文件的一部分被编译。
3.条件编译
有些语句希望在条件满足时才编译,还有些语句当标识符已经定义时,才编译。
使用条件编译可以使目标程序变小,运行时间变短。
预编译使问题或算法的解决方案增多,有助于我们选择合适的解决方案。
2.2在Ubuntu下预处理的命令
cpp hello.c > hello.i
图2.2.1 预处理过程图
将预处理之后的文本文件输出到hello.i文件中,后面是在gedit中打开的预处理后的文本文件
2.3 Hello的预处理结果解析
![图2.3.1 预处理结果part1
图2.3.1 预处理结果part1
上图为.c源程序文件名、命令行参数、环境变量
图2.3.1 预处理结果part2
上图是对包含的.h头文件的预处理,用绝对路径将其替代
图2.3.1 预处理结果part3
上图是标准C库中一些数据类型的声明
图2.3.1 预处理结果part4
上图是结构体的定义
图2.3.1 预处理结果part5
上图是对引用的外部函数的声明
图2.3.1 预处理结果part6
经过上述一系列的标识符替换、修改,环境、引用的外部函数声明之后是原.c程序部分。因为预处理只是对#开头的命令进行标识符的替换,并不进行语法检查等操作,所以原程序只进行了插入、修改替换操作,并没有大的变化。
2.4 本章小结
预处理过程只是做些代码文本的替换工作,是为编译做的预备工作的阶段,对源程序并没有进行语法检查等操作,且程序没有大的变化。这是我们从.c源程序到可执行文件的第一步。
第3章 编译
3.1 编译的概念与作用
概念:通过编译器ccl,将文本文件.i编译成文本文件.s,是将高级语言转换成低级机器语言指令的过程。它包含了一个汇编语言程序。不同高级语言经过编译器编译后,都输出为同一汇编语言。
在此过程中,编译器将会对程序进行优化、语法检查等
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
Gcc -v -S hello.c -o hello.s (此处加了-v输出详细的编译过程)
图3.2.1 编译.c程序生成.s文件
3.3 Hello的编译结果解析
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.3.1
全局变量sleepsecs以被初始化为2.5,被存放在堆的.data节中
局部变量存放在栈中,通过指向栈底的寄存器偏移量间接寻址获得,存放在%rdi与%rsi当中
待打印的string类型串“Usage:Hello 117030 ,如图*1
"存放在.string节中,argc, *argv参数局部变量存放在栈中,用寄存器存放地址,相应地指向该地址单元如图2
3.3.2 赋值
用movx src,dec,由x控制字长(如图3)
函数调用前,将用作返回值的寄存器初始化movl $0, %eax
3.3.3运算
加法addx 操作数1,操作数2
减法subx 操作数1,操作数2(如图4)
3.3.4if条件语句判断
Cmpx操作数1,操作数2 jxx条件跳转语句实现,往下跳(如图5)
3.3.5循环控制语句
Cmpx操作数1,操作数2 jxx条件跳转语句实现,往上跳(如图6)
3.3.6关系运算
Cmpx操作数1,操作数2
3.3.7函数返回
pushq %rbp,将上一个栈顶地址压栈,为函数返回时,出栈做准备
通过%rax 返回返回值(图10)
3.3.8参数传递,通过栈底指针间接寻址,暂时存放在寄存器%rdi,%rsi中.(图8)
3.3.9函数调用
call 函数名,在调用前,准备好参数,存放在寄存器%rdi, %rsi当中,并将返回值寄存器初始化movl $0, %eax(图9)
3.3.10数组
通过偏移地址+基址寻址
movq -32(%rbp), %rax
addq $16, %rax
图1
图2
图3
图4
图5
图6
图7
图8
图9
图10
3.4 本章小结
本章通过编译时,以hello为例,讲述了编译器是怎么处理C语言的各个数据类型以及各类操作的实际应用说明,像是数据:常量、变量(全局/局部/静态)的存放,通过movx的赋值 = ,算术操作:+、 - 、++,关系操作: != 、<=,数组/指针的引用:A[i]、*p 控制转移:if、for的使用,函数操作:参数传递(地址/值)、函数调用()、函数返回 return的实现等等,进一步深化,我们对C语言中的数据与操作的认识。
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将hello.s编译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在一个二进制的目标文件中的过程。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
输入:as hello.s -o hello.o, 产生汇编生成的二进制文件
用readelf -a hello.o > hello.elf, 命令,将二进制文件hello.o输出.elf文件形式,再用cat hello.elf命令,查看.elf可重定位目标文件
结果如图4.1
图4.1
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
ELF头以一个16字节序列开始,描述了生成该文件系统字的大小和字节顺序。其余的为帮助链接器进行语法分析和解释目标文件的信息,包括ELF文件的大小,节头部的起始位置(此处为1120),程序的入口地点,目标文件的类型,机器类型(此处为小端机器),节头部表的文件偏移,以及节头部表中条目的大小与数量。如图4.2
图4.2
重定位.rel.text节,有偏移量,重定位类型,符号值等。当链接器将当前目标文件与其他文件组合时,需要修改这些位置,此处,修改的有puts(),exit(),printf(),sleepsecs,sleep,getchar等函数。而程序调用的本地函数指令地址属于绝对地址,重定位类型为R_X86_64_32,不需修改重定位后的地址信息。
图4.3
重定位.symtab节,包含包含在程序中定义与引用的函数与全局变量信息。任何已初始化的全局变量地址或外部函数地址都需要被修改。
图4.4
4.4 Hello.o的结果解析
(以下格式自行编排,编辑时删除)
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
- 分支转移:在反汇编汇编语言中,分支转移时的跳转目标地址为相对偏移量,而原来的.s文件中是.L2, .L3等注记符。因为转换成机器语言,在反汇编之后,注记符不复存在。(如图4.5)
- 访问全局变量时,.s文件中,使用的是注记符,而反汇编文件中是.rodata+偏移量(如图4.6)
- 函数调用时,.s文件中用的是call 函数名,而反汇编得到的汇编代码中,是当前PC+偏移量,来调用。(如图4.7)
图4.5
图4.6
图4.7
4.5 本章小结
本章通过汇编器(as)将hello.s编译成机器语言指令,再通过readelf查看可重定位目标程序文件,通过对elf文件结构的分析,获得相关数据的运行时地址,以及不同节的、条目的大小、偏移量等信息。同时,通过.s文本文件与由机器语言反汇编获得的汇编代码比较,易得.s文件中,通过注记符寻址和经反汇编后,重定位表示的地址信息差异。
第5章 链接
5.1 链接的概念与作用
通过链接器,将程序调用的外部函数(.o文件)与当前.o文件以某种方式合并,并得到./hello可执行目标文件的的过程成为链接。且该二进制文件可被加载到内存,并由系统执行。
链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。基于此特性的改进,以提高程序运行时的时间、空间利用效率。
链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。它将巨大的源文件分解成更小的模块,易于管理。我么可以通过独立地修改或编译这些模块,并重新链接应用,不必再重新编译其他文件。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
输入命令:ld /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/5/crtbeginT.o -L/usr/lib/gcc/x86_64-linux-gnu/5 hello.o -lc -lgcc -dynamic-linker /lib64/ld-linux-x86-64.so.2
/usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -o hello
截图如下:
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
与可重定位文件结构类似。ELF头中给出了一个16字节序列,描述了生成该文件系统字的大小和字节顺序。其余的为帮助链接器进行语法分析和解释目标文件的信息,包括ELF文件的大小,节头部的起始位置,程序的入口地点,目标文件的类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小与数量,如图5.2。
节头表中给出了各节的名称、大小、偏移量、地址,如图5.3
图5.2 ELF头
图5.3 节头表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
使用edb打开hello可执行程序,通过edb的Data Dump窗口查看加载到虚拟虚拟地址空间的hello程序。
在0x00400000-段中,程序被载入,即对应的.init, .text, .rodata, .data, .bss节
如图5.4,查看ELF格式文件中的Program Headers, 程序头表在执行时被使用,它告诉链接器加载的内容,并提供动态链接信息,每个表项提供了各段在虚拟地址空间的大小、偏移量,和物理空间的地址、权限标记、对齐长度。
由图5.4知,程序包含8个段:
段名 功能
PHDR 保存程序头表
INTERP 程序映射到内存后,调用的解释器
LOAD 程序需要从二进制文件映射到虚拟地址空间的段,保存了常量数据、目标的空间代码等
DYNAMIC 保存动态链接器使用的相关信息
NOTE 存储辅助信息
GNU_STACK 权限标志,标志是否可执行
GNU_RELRO 指定重定位后的哪些区域只需要设置只读
图5.4 可执行文件hello的ELF格式文件 的Program Headers Table
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
不同:
1.除了原来.text节外,增加了.init, .plt, .plt.got, .fini段,;
2.同时,在.txt段中,增加了_start,deregister_tm_clones,register_tm_clones,__do_global_dtors_aux,frame_dummy,__libc_csu_init,__libc_csu_fini,_fini等函数;
3.hello的反汇编代码中函数调用时,call的地址为运行时的绝对地址,而hello.o
的反汇编代码中,是重定位条目信息,如图5.5中exit函数重定位的例子。
链接过程:为了构造可执行文件,链接器先后完成两个主要任务:符号解析和重定位。
每个符号对应一个函数、全局变量、静态变量,通过符号解析,将定义与引用关联起来。链接器维护3个集合,可重定位目标文件的集合E(该集合中的文件将会被合并为可执行文件),U引用了但尚未被定义的集合,D在前面输入集合中已被定义的符号集合。初始时,3个集合均为空。链接器会判断命令行上的每一个输入文件,f,若f为目标文件,则链接器将f添加到E, 并修改U和D来反应f中的符号定义与引用;若f为一个归档文件,则链接器尝试匹配U中未解析的符号和f中定义的符号。直至U和D均不再变化,则将f丢弃。最后,若U为空,则合并可重定位目标文件E为可执行文件;否则,报错。
重定位:下面以exit函数的重定位过程为例,结合hello.o的重定位条目信息,分析hello中对其怎么重定位的。
由下图5.5,左侧为可执行文件的反汇编代码,易知,exit函数运行时地址为ADDR(s.symbol) = 0x4004a0,而引用的运行时地址为ADDR(s)+ s.offset = 0x4005ee + 0x25 = 0x400613. 引用应当修改的偏移调整为s.addend = - 0x4. 因此,可得应当更新该PC相对引用,使其在运行时指向exit函数,*refptr = 0x4004a0 – 0x400613 -0x4 = -0x177. = 0xfffffe89. 由hello的反汇编代码(如图5.6),可验证正确。
图5.5 可执行文件中运行时地址与可重定位目标文件中的可重定位条目信息对比
图5.6 可执行目标文件PC相对引用信息验证
5.6 hello的执行流程
(使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
程序名 程序地址
_dl_start 00007f36c1ed69b0
_dl_setup_hash 00007f36c1ee0a50
_dl_sysdep_start 00007f36c1eee210
brk 00007f36c1eef4b0
strlen 00007f36c1ef1fd0
sbrk 00007f36c1eef500
dl_main 00007f36c1ed71e0
dl_next_ld_env_entry 00007f36c1eeed40
dl_new_object 00007f36c1ee0b90
strlen 00007f36c1ef1fd0
calloc 00007f36c1eeef00
malloc 00007f36c1eeef00
memalign 00007f36c1eeee00
memcpy 00007f36c1ef2f60
dl_add_to_namespace 00007f36c1ee0b02
rtld_lock_default_lock_recursive 00007f36c1ed5c90
strcmp 00007f36c1ef0b40
dl_discover_osversion 00007f36c1eeeb81
dl_init_paths 00007f36c1edd4e0
dl_important_hwcaps 00007f36c1ee41c0
access 00007f36c1ef03c0
memset 00007f36c1ef2c40
dl_debug_initialized 00007f36c1ee6075
do_count_modid 00007f36c1ee8260
dl_map_object_deps 00007f36c1ee2f80
strchr 00007f36c1ef0920
dl_catch_error 00007f36c1ee54f0
dl_initial_error_catch_tsd 00007f36c1ed5c80
sigsetjmp 00007f36c1ef0610
openaux 00007f36c1ee2b70
dl_name_match_p 00007f36c1ee6980
dl_load_cache_lookup 00007f36c1eed3e9
read_whole_file 00007f36c1ee66f0
open64 00007f36c1ef0360
_fxstat 00007f36c1ef02e0
mmap64 00007f36c1ef0480
access 00007f36c1ef03c0
open_verify.consprop.7 00007f36c1eda710
read 00007f36c1ef0380
_dl_map_object_from_fd 00007f36c1edb330
_dl_receive_error 00007f36c1ee55c0
_dl_initial_error_catch_tsd 00007f36c1ed5c80
version_check_doit 00007f36c1ed6970
_dl_check_all_versions 00007f36c1ee72d0
_dl_check_map_versions 00007f36c1ee6e20
match_symble 00007f36c1ee6a70
_dl_relocate_object 00007f36c1ee1270
_init 400430
puts 400450
printf 400470
__libc_start_main 400480
getchar 400490
exit 4004a0
sleep 4004b0
.plt.got 4004c0
_start 4004d0
deregister_tm_clones 400500
register_tm_clones 400540
__do_global_dtors_aux 400580
frame_dummy 4005b0
__libc_csu_init 400670
__libc_csu_fini 4006e0
_fini 4006e4
因edb逐步运行实在太费时间,且栈空间的分布是随意的(除了相对位置不变外),于是后面半部分的地址来自gdb反汇编的.text节中的各函数地址
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定的技术解决这个问题,将过程地址的延迟绑定推迟到第一次调用该过程时。
延迟绑定要用到全局偏移量表(GOT)和过程链接表(PLT)两个数据结构。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,跳转到动态链接器中。每个条目都负责调用一个具体的函数。PLT[[1]]调用系统启动函数 (__libc_start_main)。从PLT[[2]]开始的条目调用用户代码调用的函数。GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[[1]]包含动态链接器在解析函数地址时会使用的信息。GOT[[2]]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。
如图5.,7找到GOT的起始位置
图5.7
图5. 8没有调用dl_init之前的全局偏移量表.got.plt
图5. 9调用dl_init之后的全局偏移量表.got.plt
5.8 本章小结
本章从ld链接器将hello.o的链接命令,到可执行文件ELF的查看,分析可执行文件的相关信息,hello的重定位过程,执行流程,hello的动态链接分析,进一步加深了对链接过程细节的理解。
第6章 hello进程管理
6.1 进程的概念与作用
进程即运行中的程序实例,系统中的每个进程均运行在进程的上下文中。上下文即程序需要正确运行所需要的状态,它包括存放在内存中的程序代码、数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程向每个程序提供一个假象,好像它在单独地使用着处理器,,独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:作为C编写的程序,它是用户使用Linux的桥梁。Shell是一种应用程序,它为用户访问操作系统内核提供了一个交互界面。
处理流程:
- 读入输入的命令;
- 分割字符串,获取命令;
- 若为内置命令则执行,否则调用相应的程序为其分配子进程执行;
- Shell可以异步接收来自I/O设备的信号,并对这些中断信号进行处理。
6.3 Hello的fork进程创建过程
在终端输入./hello,因为./hello不是终端命令,而是当前目录下的可执行文件,于是终端调用fork函数在当前进程中创建一个新的子进程,该进程与父进程几乎完全相同。子进程得到与父进程用户级虚拟地址空间相同的副本,包括代码、数据、堆、共享库和用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,即子进程可读写当前父进程打开的任何文件。子进程与父进程最大的区别在于他们有不同的PID。
父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行他们的逻辑控制流中的指令。如图6.1,为fork进程图的过程
图6.1 hello进程图
6.4 Hello的execve过程
父shell进程fork之后,生成一个子进程功能,它相当于父进程的一个副本,子进程再通过execve系统调用启动加载器,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的堆和栈段被初始化为零。通过虚拟地址空间中的页映射到可执行文件的页大小的片。新代码被初始化为可执行文件的内容。最后加载器跳转到__start地址,它最终会调用应用程序的main函数。但是,除了一些头部信息,在加载过程中并没有从磁盘到内存的数据复制,直到CPU引用一个被映射的虚拟页时,才进行复制,此时,操作系统利用他的页面调度机制自动将页面从磁盘传送到内存。Main函数在启动时的栈结构如图6.2.
图6.2 Linux下32位用户栈在程序开始时的典型组织结构
注:execve函数不同于fork函数,execve函数只有在找不到文件时才会返回,否则调用一次,不返回。进程图如图6.3
图6.3 execve加载并运行可执行文件hello的进程图
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
逻辑控制流:一系列程序计数器(PC)的值序列,若不发生抢占,则顺序执行,若发生抢占,则当前进程被挂起,控制转移至下一个进程。
时间分片:各个进程是并发执行的,每个进程轮流在处理器上执行,一个进程执行它控制流的一部分成为时间分片。
上下文切换:1)保存当前进程的上下文,2)恢复某个先前被抢占的进程保存的上下文,3)将控制传递给当前新恢复的进程。
操作系统内核使用上下文切换的异常控制流来实现上下文切换,过程图6.4.
图6.4. hello上下文切换剖析
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
如图6.5 正常运行,进程被sleep显示休眠,按下enter后,程序继续执行,终止后,被父进程回收。
如图6.6 运行途中按下ctrl+z,内核向前台进程发送一个SIGSTP信号,前台进程被挂起,直到通知它继续的信号到来,继续执行。当按下fg 1 后,输出命令行后,被挂起的进程从暂停处,继续执行。
如图6.7 运行途中按下ctrl+c,内核向前台进程发送一个SIGINT信号,前台进程终止,内核再向父进程发送一个SIGCHLD信号,通知父进程回收子进程,此时子进程不再存在
如图6.6 运行途中乱按后,只是将乱按的内容输出,程序继续执行,而当程序执行至sleep,进程被显示的请求休眠后,程序等待一个’\n’后,进程终止,父进程接收到一个SIGCHLD信号后,子进程被父进程回收。
如图6.9 输入jobs,打印进程状态信息
如图6.10 输入fg 1,打印前台进程组
图6.11 输入kill,终止前台进程
如图6.12 运行时输入回车,’\n’会在最后sleep调用后,从缓冲区被读入,程序继续执行,至退出,终止后,被父进程回收。
图6.5 正常运行
图6.6 运行途中按下ctrl+z
图6.7 运行途中按下ctrl+c
图6.8 运行途中乱按
图6.9 输入ps打印前台进程组
图6.10 输入pstree
图6.11 输入jobs
图6.12 输入fg 1,继续执行前台进程1
图6.13 输入kill
图6.14 运行时输入回车
6.7本章小结
本章从进程的概念开始,讲述了shell的概念、作用,以图文并茂的方式阐述了hello从被父进程的fork创建,再被execve加载,再到通过内核模式控制下的上下文切换,来实现hello进程以时间分片的形式并发执行的过程。最后通过hello执行过程中可能发生的异常,以及信号处理方式的实际操作实践,感受了异常处理过程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量, 表示为 [段标识符:段内偏移量]
线性地址:某地址空间中的地址是连续的非负整数时,该地址空间中的地址被称为线性地址。
虚拟地址:CPU在寻址的时候,是按照虚拟地址来寻址,然后通过MMU(内存管理单元)将虚拟地址转换为物理地址。
物理地址:计算机主存被组织成由M个连续字节大小的内存组成的数组,每个字节都有一个唯一的地址,该地址被称为物理地址。
三种地址的在寻址时的关系如图7.1
图7.1 三种地址在寻址时的关系
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个段描述符由8个字节组成。它描述了段的特征,可以分为GDT和LDT两类。通常来说系统只定义一个GDT,而每个进程如果需要放置一些自定义的段,就可以放在自己的LDT中。IA-32中引入了GDTR和LDTR两个寄存器,就是用来存放当前正在使用的GDT和LDT的首地址。
在Linux系统中,每个CPU对应一个GDT。一个GDT中有18个段描述符和14个未使用或保留项(如图7.2)。其中用户和内核各有一个代码段和数据段,然后还包含一个TSS任务段来保存寄存器的状态。
图7.2 Linux中不同段的描述符
在IA-32中,逻辑地址是16位的段选择符+32位偏移地址,段寄存器不在保存段基址,而是保存段描述符的索引。
段地址转换过程(如图7.3所示):
1.IA-32首选确定要访问的段(方式x86-16相同),然后决定使用的段寄存器。
2.根据段选择符号的TI字段决定是访问GDT还是LDT,他们的首地址则通过GTDR和LDTR来获得。
3.将段选择符的Index字段的值*8,然后加上GDT或LDT的首地址,就能得到当前段描述符的地址。(乘以8是因为段描述符为8字节)
4.得到段描述符的地址后,可以通过段描述符中BASE获得段的首地址。
5.将逻辑地址中32位的偏移地址和段首地址相加就可以得到实际要访问的物理地址
图7.3 段地址转换
7.3 Hello的线性地址到物理地址的变换-页式管理
Linux下的虚拟地址VA即属于上面提到的线性地址的一种。下面,我们讲讲如何从VA转化到物理地址。
如图7.4,一级页表中的每个PTE负责映射虚拟地址空间中的一个4MB的片,这里每个片都是由101124个连续的页面组成,如PTE0映射第一片,PTE1映射第二片,以此类推。若地址空间为4GB,则一级页表需要1024个表项。最后以及PTE即为对应的PPN,再结合原先VA中的偏移量VPO=PPO,将PPN与PPO结合,即为所求的物理地址。
图7.4
7.4 TLB与四级页表支持下的VA到PA的变换
首先,CPU生成一个VA;
再到MMU的TLB中寻找相应的VPN,若命中,则读取相应的PPN与原VA中的VPO结合即为所求的PA;
倘若TLB不命中,则将虚拟地址划分为4个VPN和一个VPO,每个VPN i都是从一个i级页表的索引,其中,1≤j≤4.当1≤j≤3时,第j级页表的每个PTE均为执行j+1级页表的基址,第4级页表的每个PTE为物理地址的VPN,而原VA的VPO=PPO,将PTE与PPO结合之后,便是物理地址PA。
图7.5 Core i7 地址翻译部分过程
7.5 三级Cache支持下的物理内存访问
(以下格式自行编排,编辑时删除)
首先,将MMU生成的PA按照cache的相关参数进行划分。
其次,在cache中寻址。如图7.6,cache L1为64组,于是CI共有6位,而每行的大小为64B,于是由图可知CT占40位,CI占6位,CO占6位。若在cache中命中,则将结果返回;
再者,若cache不命中,则到依次到L2、L3、主存中寻址。L2、L3中的寻址方式与L1类似。
图7.6 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
(以下格式自行编排,编辑时删除)
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的页面标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
在当前进程中的程序执行了execve(”a.out”,NULL, NULL)调用时,execve函数在当前程序中加载并运行包含在可执行文件a.out中的程序,用a.out代替了当前程序。加载并运行a.out主要分为一下几个步骤:
1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构;
2.映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零;
3.映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内;
4.设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图7.7 加载器是如何映射用户地址空间区域的
7.8 缺页故障与缺页中断处理
缺页故障:当指令引用一个虚拟内存地址,而该地址相对应的物理页面不在内存中,则必须从内存取出,因此引发故障。
缺页中断处理:当缺页异常发生时,处理器将控制传递给缺页处理程序。缺页处理程序从磁盘加载相应界面,然后将控制返回给引起缺页故障的指令。当之前引起缺页故障的指令再次执行时,相应的物理页面已经驻留在内存中,指令就没有故障地完成了。
图7.8 故障处理流程
7.9动态存储分配管理
(以下格式自行编排,编辑时删除)
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存维护着进程的虚拟内存区,称为堆,分配器将堆视为不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器主要有两种风格:显示分配器,隐式分配器。
隐式分配器:要求分配器检测一个分配块何时不再被程序使用,就释放这个块。隐式分配器将块大小、是否已分配信息嵌入块头部,来区分边界以及标识是否已分配。这时,块主要分为反部分:头部、有效载荷、填充部分。如图7.9
图7.9 使用边界标记的堆块格式
显示分配器:要求应用显示地释放已分配的块,C库中提供了malloc来申请,free来释放。将实现的数据结构指针存放在空闲块主体中,如,堆可组织成一个双向空闲链表,每个空闲块照中包含一个pred前驱和一个succ后继指针。(如图7.10)使首次适配的分配时间从块总数 的线性时间减少到空闲块数量的线性时间。,释放块的时间也是线性的,有时也可能为常数。
图7.10 使用双向链表的堆块格式
根据不同要求,分配器采取不同的放置策略,常见的有首次适配、下次适配、最佳适配。至于选择哪种放置策略,要根据实际对时间效率、空间效率的要求确定。
合并策略:为解决假碎片问题,分配器必须合并相邻的空闲块,常采用的有立即合并,延迟合并等。
7.10本章小结
本章首先讲述了虚拟地址、线性地址、物理地址的区别,通过虚拟地址到物理地址的转换,进一步加深了对虚拟地址空间的理解运作及其强大作用,在fork、execve过程中扮演着重要的角色,使进程的私有地址空间变成了现实。同时,还体会了动态内存管理时,申请、分割、合并、回收等具体过程,加深了我们对动态内存管理过程的理解与认识。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化: 文件
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入输出都被当作相对应文件的读和写。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
8.2 简述Unix I/O接口及其函数
输入输出都以统一的方式来执行:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K 。
4.读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数
1.进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件的:
int open(char *filename, int flags, mode_t mode);
open 函数将filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
返回:若成功则为新文件描述符,若出错为-1。
2.进程通过调用close 函数关闭一个打开的文件。
int close(int fd);
返回:若成功则为0, 若出错则为-1。
3.应用程序是通过分别调用read 和write 函数来执行输入和输出的。
ssize_t read(int fd, void *buf, size_t n);
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。返回:若成功则为写的字节数,若出错则为-1。
8.3 printf的实现分析
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
printf的实现:
typedef char *va_list;
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;
}
fmt是一个指针,这个指针指向第一个const参数(const char fmt)中的第一个元素。
由于栈是从高地址向低地址方向增长的,可知(char)(&fmt) + 4) 表示的是第一个参数的地址。
下面来看看vsprint的实现:
int vsprintf(char *buf, const char fmt, va_list args)
{
char p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') { //若不是格式符,则复制到buf所指向的单元
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) { //遇到格式符处理
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4; //指向参数的下一个字符
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
由此可知,vsprint的功能为把指定的匹配的参数格式化,并返回字符串的长度。
下面我们反汇编追踪一下write:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里的int表示要调用中断门了。通过中断门,来实现特定的系统服务。
再看看INT_VECTOR_SYS_CALL的原型:
init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER);
即int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
再来看看sys_call的实现:
sys_call:
call save //保存中断前进程的状态
push dword [p_proc_ready]
sti
push ecx //ecx中是要打印出的元素个数
push ebx //ebx中的是要打印的buf字符数组中的第一个元素
call [sys_call_table + eax * 4] //不断的打印出字符,直到遇到:’\0’
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
//[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
cli
ret
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)
Sys_call将字符串中的字节“Hello 117030**** *”从寄存器中通过总线复制到显卡的显存中,此时字符以ASCII码形式存储。字符显示驱动子程序将ASCII码在自模库中找到点阵信息将点阵信息存储到vram中。最后,显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是,“Hello 117030 ***”就显示在了屏幕上。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要讲述了Linux的IO设备管理方法,Unix I/O接口及其函数,以及printf函数实现的分析和getchar函数的实现。在此过程中,我们对系统I/O函数和Linux中将设备映射为文件式来管理的方式有了进一步的认识。特别地,在printf函数的底层实现的分析过程中,将原来只是简单的的打印函数一层层地展开,让我们进一步认识了底层的工作过程。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
Hello程序经历的过程:
- 程序编写;
- 预处理:将hello.c源程序的#开头的指令进行符号替换,生成hello.i文件
- 编译:将hello.i转化为汇编代码,生成hello.s文件
- 汇编:将hello.s转化为二进制的机器代码,生成hello.o的可重定位目标程序
- 链接:对hello.o中引用的外部函数、全局变量等进行符号解析,并重定位为可执行文件hello。
- 运行:在终端输入hello 117030****
- 创建子程序:通过fork创建子程序;
- 执行:通过execve加载器载入,建立虚拟内存映射,设置当前进程的上下文中的程序计数器,使之指向程序入口处。CPU为其分配相应的时间分片,形成同其他程序并发执行的假想;
- 访存:CPU上的内存管理单元MMU根据页表将CPU生成的虚拟地址翻译成物理地址,将相应的页面调度;
- 动态内存申请:printf调用malloc进行动态内存分配,在堆中申请所需的内存;
- 接收信号:中途接受ctrl+z挂起,ctrl+c终止;
- 结束:程序返回后,内核向父进程发送SIGCHLD信号,此时终止的hello被父进程回收。
本次实验将这学期以来的所有知识点贯穿起来,从数据的运算处理,到汇编、链接、信号异常的处理、内存管理、再到后面的I/O管理。但是最让我震撼的是虚拟内存的实现,它将硬件异常、硬件翻译、汇编、链接、加载、内存共享等等一系列难题都大大简化了。将内存看作磁盘的高速缓存由它实现,从而使整个内存系统同时具备了高速度、大容量的特点;它为每个进程提供了一致的私有空间,从而大大简化了进程管理,同时它又保护了进程的地址空间不被破坏。虽然有时它会造成不易察觉的错误,但是无法否认,它是伟大又可爱的!因此,在硬件提高速度有限的同时,建立抽象模型,优化软件,在提高计算机性能方面也是信息信息时代技术革命不容忽略的主题。
附件
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
文件名 文件作用
hello.i 预处理之后的源程序
hello.s 编译之后的汇编程序
hello.o 汇编之后的可重定位目标程序
hello.elf hello.o的ELF格式
helloexe.elf hello的ELF格式
hello 可执行目标程序
hello2.s 反汇编后输出的程序
Helloobj.txt Hello可执行程序的反汇编代码
Temp.c 临时数据存放
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] https://www.cnblogs.com/zmrlinux/p/4921394.html
[2] https://blog.csdn.net/zsl091125/article/details/52556766
[3] https://blog.csdn.net/baidu_35679960/article/details/80463445
[4] https://blog.csdn.net/Six_666A/article/details/80635974
[5] https://www.cnblogs.com/pianist/p/3315801.html
[6] 深入理解计算机系统 第三版 兰德尔E.布莱恩特 Randal E. Bryant 大卫 R. 奥哈拉伦DavidR. O’Hallaron [美]卡耐基梅隆大学著