Github:https://github.com/hahalidaxin/ProgramLessons/tree/master/CSAPP/FinalWork
声明update :反汇编直接使用的是 ld 链接生成的可执行目标文件,没有使用 gcc –m64 –no-pie –fno-PIC 选项 --12/31/2018
在Editor中键入代码得到hello.c程序。
在linux中,hello.c经过cpp的预处理、ccl的编译、as的汇编、ld的链接最终成为可执行目标程序hello,在shell中键入启动命令后,shell为其fork,产生子进程,于是hello便从Program摇身一变成为Process,这便是P2P的过程。
之后shell为其execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构,以上全部便是020的过程。
硬件环境:Intel Core i7-6700HQ x64CPU,16G RAM,256G SSD +1T HDD.
软件环境:Ubuntu18.04.1 LTS
开发与调试工具:vim,gcc,as,ld,edb,readelf,HexEdit
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名称 |
文件作用 |
hello.i |
预处理之后文本文件 |
hello.s |
编译之后的汇编文件 |
hello.o |
汇编之后的可重定位目标执行 |
hello |
链接之后的可执行目标文件 |
hello2.c |
测试程序代码 |
hello2 |
测试程序 |
helloo.objdmp |
Hello.o的反汇编代码 |
helloo.elf |
Hello.o的ELF格式 |
hello.objdmp |
Hello的反汇编代码 |
hello.elf |
Hellode ELF格式 |
tmp.txt |
存放临时数据 |
本章主要简单介绍了hello的p2p,020过程,列出了本次实验信息:环境、中间结果。
(第1章0.5分)
概念:预处理器cpp根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件。
主要功能如下:
命令:cpp hello.c > hello.i
图2.1 使用cpp命令生成hello.i文件
使用vim打开hello.i之后发现,整个hello.i程序已经拓展为3188行,main函数出现在hello.c中的代码自3099行开始。如下:
图2.2 hello.i中main函数的位置
在这之前出现的是stdio.h unistd.h stdlib.h的依次展开,以stdio.h的展开为例,cpp到默认的环境变量下寻找stdio.h,打开/usr/include/stdio.h 发现其中依然使用了#define语句,cpp对此递归展开,所以最终.i程序中是没有#define的。而且发现其中使用了大量的#ifdef #ifndef的语句,cpp会对条件值进行判断来决定是否执行包含其中的逻辑。其他类似。
hello.c需要用到许多不是自身的“前件儿”,在真正投入造 程序 的汪洋大海之前还需要装备整齐,体体面面……
本章主要介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析。
(第2章0.5分)
编译器将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。这个过程称为编译,同时也是编译的作用。
编译器的构建流程主要分为3个步骤:
命令:gcc -S hello.i -o hello.s
图3.1 使用gcc命令生成64位的hello.s文件
指令 |
含义 |
.file |
声明源文件 |
.text |
以下是代码段 |
.section .rodata |
以下是rodata节 |
.globl |
声明一个全局变量 |
.type |
用来指定是函数类型或是对象类型 |
.size |
声明大小 |
.long、.string |
声明一个long、string类型 |
.align |
声明对指令或者数据的存放地址进行对齐的方式 |
hello.s中用到的C数据类型有:整数、字符串、数组。
程序中的字符串分别是:
其中后两个字符串都声明在了.rodata只读数据节。
图3.2 hello.s中声明在.LC0和.LC1段中的字符串
程序中涉及的整数有:
图3.3 hello.s中sleepsecs的声明
程序中涉及数组的是:char *argv[] main,函数执行时输入的命令行,argv作为存放char指针的数组同时是第二个参数传入。
argv单个元素char*大小为8B,argv指针指向已经分配好的、一片存放着字符指针的连续空间,起始地址为argv,main函数中访问数组元素argv[1],argv[2]时,按照起始地址argv大小8B计算数据地址取数据,在hello.s中,使用两次(%rax)(两次rax分别为argv[1]和argv[2]的地址)取出其值。如图3.4。
图3.4 计算地址取出数组值
程序中涉及的赋值操作有:
指令 |
b |
w |
l |
q |
大小 |
8b (1B) |
16b (2B) |
32b (4B) |
64b (8B) |
因为i是4B的int类型,所以使用movl进行赋值,汇编代码如图3.5。
图3.5 hello.s中变量i的赋值
程序中涉及隐式类型转换的是:int sleepsecs=2.5,将浮点数类型的2.5转换为int类型。
当在double或float向int进行类型转换的时候,程序改变数值和位模式的原则是:值会向零舍入。例如1.999将被转换成1,-1.999将被转换成-1。进一步来讲,可能会产生值溢出的情况,与Intel兼容的微处理器指定位模式[10…000]为整数不确定值,一个浮点数到整数的转换,如果不能为该浮点数找到一个合适的整数近似值,就会产生一个整数不确定值。
浮点数默认类型为double,所以上述强制转化是double强制转化为int类型。遵从向零舍入的原则,将2.5舍入为2。
进行数据算数操作的汇编指令有:
指令 |
效果 |
leaq S,D |
D=&S |
INC D |
D+=1 |
DEC D |
D-=1 |
NEG D |
D=-D |
ADD S,D |
D=D+S |
SUB S,D |
D=D-S |
IMULQ S |
R[%rdx]:R[%rax]=S*R[%rax](有符号) |
MULQ S |
R[%rdx]:R[%rax]=S*R[%rax](无符号) |
IDIVQ S |
R[%rdx]=R[%rdx]:R[%rax] mod S(有符号) R[%rax]=R[%rdx]:R[%rax] div S |
DIVQ S |
R[%rdx]=R[%rdx]:R[%rax] mod S(无符号) R[%rax]=R[%rdx]:R[%rax] div S |
程序中涉及的算数操作有:
进行关系操作的汇编指令有:
指令 |
效果 |
描述 |
CMP S1,S2 |
S2-S1 |
比较-设置条件码 |
TEST S1,S2 |
S1&S2 |
测试-设置条件码 |
SET** D |
D=** |
按照**将条件码设置D |
J** |
—— |
根据**与条件码进行跳转 |
程序中涉及的关系运算为:
程序中涉及的控制转移有:
图3.6 if语句的编译
图3.7 for循环的编译
函数是一种过程,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。P中调用函数Q包含以下动作:
64位程序参数存储顺序(浮点数使用xmm,不包含):
1 |
2 |
3 |
4 |
5 |
6 |
7 |
%rdi |
%rsi |
%rdx |
%rcx |
%r8 |
%r9 |
栈空间 |
程序中涉及函数操作的有:
造 程序 首先要承受ccl的“降维打击”,如果你问为什么,还不是因为要让as看得懂……
本章主要阐述了编译器是如何处理C语言的各个数据类型以及各类操作的,基本都是先给出原理然后结合hello.c C程序到hello.s汇编代码之间的映射关系作出合理解释。
编译器将.i的拓展程序编译为.s的汇编代码。经过编译之后,我们的hello自C语言解构为更加低级的汇编语言。
(第3章2分)
汇编器(as)将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,.o文件是一个二进制文件,它包含程序的指令编码。这个过程称为汇编,亦即汇编的作用。
指令:as hello.s -o hello.o
图4.1 使用as指令生成hello.o文件
使用readelf -a hello.o > helloo.elf 指令获得hello.o文件的ELF格式。其组成如下:
图4.2 ELF Header
图4.3 节头部表Section Headers
图4.4 重定位节.rela.text
.rela节的包含的信息有(readelf显示与hello.o中的编码不同,以hello.o为准):
offset |
需要进行重定向的代码在.text或.data节中的偏移位置,8个字节。 |
Info |
包括symbol和type两部分,其中symbol占前4个字节,type占后4个字节,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型 |
Addend |
计算重定位位置的辅助信息,共占8个字节 |
Type |
重定位到的目标的类型 |
Name |
重定向到的目标的名称 |
下面以.L1的重定位为例阐述之后的重定位过程:链接器根据info信息向.symtab节中查询链接目标的符号,由info.symbol=0x05,可以发现重定位目标链接到.rodata的.L1,设重定位条目为r,根据图4.5知r的构造为:
r.offset=0x18, r.symbol=.rodata, r.type=R_X86_64_PC32, r.addend=-4,
重定位一个使用32位PC相对地址的引用。计算重定位目标地址的算法如下(设需要重定位的.text节中的位置为src,设重定位的目的位置dst):
refptr = s +r.offset (1)
refaddr = ADDR(s) + r.offset (2)
*refptr = (unsigned) (ADDR(r.symbol) + r.addend-refaddr)(3)
其中(1)指向src的指针(2)计算src的运行时地址,(3)中,ADDR(r.symbol)计算dst的运行时地址,在本例中,ADDR(r.symbol)获得的是dst的运行时地址,因为需要设置的是绝对地址,即dst与下一条指令之间的地址之差,所以需要加上r.addend=-4。
之后将src处设置为运行时值*refptr,完成该处重定位。
图4.5 通过HexEdit查看hello.o中的.rela.text节
对于其他符号的重定位过程,情况类似。
3).rela.eh_frame : eh_frame节的重定位信息。
4).symtab:符号表,用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。
使用 objdump -d -r hello.o > helloo.objdump获得反汇编代码。
总体观察图4.6后发现,除去显示格式之外两者差别不大,主要差别如下:
图4.6 hello.s与反汇编代码main函数对照
啥,还要“降维打击”,我……
本章介绍了hello从hello.s到hello.o的汇编过程,通过查看hello.o的elf格式和使用objdump得到反汇编代码与hello.s进行比较的方式,间接了解到从汇编语言映射到机器语言汇编器需要实现的转换。
(第4章1分)
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。
命令:
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
注意:因为需要生成的是64位的程序,所以,使用的动态链接器和链接的目标文件都应该是64位的。
图5.1 使用ld命令链接生成可执行程序hello
使用readelf -a hello > hello.elf 命令生成hello程序的ELF格式文件。
在ELF格式文件中,Section Headers对hello中所有的节信息进行了声明,其中包括大小Size以及在程序中的偏移量Offset,因此根据Section Headers中的信息我们就可以用HexEdit定位各个节所占的区间(起始位置,大小)。其中Address是程序被载入到虚拟地址的起始地址。
图5.2 hello ELF格式中的Section Headers Table
使用edb打开hello程序,通过edb的Data Dump窗口查看加载到虚拟地址中的hello程序。
在0x400000~0x401000段中,程序被载入,自虚拟地址0x400000开始,自0x400fff结束,这之间每个节(开始 ~ .eh_frame节)的排列即开始结束同图5.2中Address中声明。
如图5.3,查看ELF格式文件中的Program Headers,程序头表在执行的时候被使用,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。在下面可以看出,程序包含8个段:
图5.3 ELF格式文件中的Program Headers Table
通过Data Dump查看虚拟地址段0x600000~0x602000,在0~fff空间中,与0x400000~0x401000段的存放的程序相同,在fff之后存放的是.dynamic~.shstrtab节。
使用objdump -d -r hello > hello.objdump 获得hello的反汇编代码。
与hello.o反汇编文本helloo.objdump相比,在hello.objdump中多了许多节,列在下面。
节名称 |
描述 |
.interp |
保存ld.so的路径 |
.note.ABI-tag |
Linux下特有的section |
.hash |
符号的哈希表 |
.gnu.hash |
GNU拓展的符号的哈希表 |
.dynsym |
运行时/动态符号表 |
.dynstr |
存放.dynsym节中的符号名称 |
.gnu.version |
符号版本 |
.gnu.version_r |
符号引用版本 |
.rela.dyn |
运行时/动态重定位表 |
.rela.plt |
.plt节的重定位条目 |
.init |
程序初始化需要执行的代码 |
.plt |
动态链接-过程链接表 |
.fini |
当程序正常终止时需要执行的代码 |
.eh_frame |
contains exception unwinding and source language information. |
.dynamic |
存放被ld.so使用的动态链接信息 |
.got |
动态链接-全局偏移量表-存放变量 |
.got.plt |
动态链接-全局偏移量表-存放函数 |
.data |
初始化了的数据 |
.comment |
一串包含编译器的NULL-terminated字符串 |
通过比较hello.objdump和helloo.objdump了解链接器。
1)函数个数:在使用ld命令链接的时候,指定了动态链接器为64的/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o中主要定义了程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。
2)函数调用:链接器解析重定条目时发现对外部函数调用的类型为R_X86_64_PLT32的重定位,此时动态链接库中的函数已经加入到了PLT中,.text与.plt节相对距离已经确定,链接器计算相对距离,将对动态链接库中函数的调用值改为PLT中相应函数与下条指令的相对地址,指向对应函数。对于此类重定位链接器为其构造.plt与.got.plt。
3).rodata引用:链接器解析重定条目时发现两个类型为R_X86_64_PC32的对.rodata的重定位(printf中的两个字符串),.rodata与.text节之间的相对距离确定,因此链接器直接修改call之后的值为目标地址与下一条指令的地址之差,指向相应的字符串。这里以计算第一条字符串相对地址为例说明计算相对地址的算法(算法说明同4.3节):
refptr = s + r.offset = Pointer to 0x40054A
refaddr = ADDR(s) + r.offset= ADDR(main)+r.offset=0x400532+0x18=0x40054A
*refptr = (unsigned) (ADDR(r.symbol) + r.addend-refaddr) = ADDR(str1)+r.addend-refaddr=0x400644+(-0x4)-0x40054A=(unsigned) 0xF6,
观察反汇编验证计算:
其他.rodata引用,函数调用原理类似。
使用edb执行hello,观察函数执行流程,将过程中执行的主要函数列在下面:
程序名称 |
程序地址 |
ld-2.27.so!_dl_start |
0x7fce 8cc38ea0 |
ld-2.27.so!_dl_init |
0x7fce 8cc47630 |
hello!_start |
0x400500 |
libc-2.27.so!__libc_start_main |
0x7fce 8c867ab0 |
-libc-2.27.so!__cxa_atexit |
0x7fce 8c889430 |
-libc-2.27.so!__libc_csu_init |
0x4005c0 |
hello!_init |
0x400488 |
libc-2.27.so!_setjmp |
0x7fce 8c884c10 |
-libc-2.27.so!_sigsetjmp |
0x7fce 8c884b70 |
--libc-2.27.so!__sigjmp_save |
0x7fce 8c884bd0 |
hello!main |
0x400532 |
hello!puts@plt |
0x4004b0 |
hello!exit@plt |
0x4004e0 |
*hello!printf@plt |
-- |
*hello!sleep@plt |
-- |
*hello!getchar@plt |
-- |
ld-2.27.so!_dl_runtime_resolve_xsave |
0x7fce 8cc4e680 |
-ld-2.27.so!_dl_fixup |
0x7fce 8cc46df0 |
--ld-2.27.so!_dl_lookup_symbol_x |
0x7fce 8cc420b0 |
libc-2.27.so!exit |
0x7fce 8c889128 |
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。如在图5.4 (a)。
在dl_init调用之后,如图5.4 (b),0x601008和0x601010处的两个8B数据分别发生改变为0x7fd9 d3925170和0x7fd9 d3713680,如图5.4(c)其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址,如图5.4(d)GOT[2]指向动态链接器ld-linux.so运行时地址。
图5.4 (a) 没有调用dl_init之前的全局偏移量表.got.plt
(根据.plt中exit@plt jmp的引用地址0x601030可以得到其.got.plt条目为0x4004e6,正是其下条指令地址)
图5.4(b)调用dl_init之后的全局偏移量表.got.plt
图5.3(c)0x7fd9 d3925170指向的重定位表
图5.4(d) 0x7fd9 d3713680目标程序-动态链接器
在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数。
因为在PLT中使用的jmp,所以执行完目标函数之后的返回地址为最近call指令下一条指令地址,即在main中的调用完成地址。
“大贤者”ld赋予了hello.o“捕食者”技能,hello.o不是仇家crt1.o,crti.o,crtn.o,libc.so中需要的技能成为可执行程序hello,最终消灭“仇人笔记本”上所有仇家,但真正的链接不止于此,对于hello中需要的PIC函数调用则需要动态链接器/lib64/ld-linux-x86-64.so.2,ld.so是个懒家伙,只有在函数调用的时候才会进行实际上的重定位,这就是动态链接中的延迟绑定。
历经艰辛,hello可算诞生了呦QWQ
在本章中主要介绍了链接的概念与作用、hello的ELF格式,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。
(第5章1分)
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。
进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
Shell的作用:Shell是一个用C语言编写的程序,他是用户使用Linux的桥梁。Shell 是指一种应用程序,Shell应用程序提供了一个界面,用户通过这个界面访问操作系统内核的服务。
处理流程:
1)从终端读入输入的命令。
2)将输入字符串切分获得所有的参数
3)如果是内置命令则立即执行
4)否则调用相应的程序为其分配子进程并运行
5)shell应该接受键盘输入信号,并对这些信号进行相应处理
在终端Gnome-Terminal中键入 ./hello 1170300825 lidaxin,运行的终端程序会对输入的命令行进行解析,因为hello不是一个内置的shell命令所以解析之后终端程序判断./hello的语义为执行当前目录下的可执行目标文件hello,之后终端程序首先会调用fork函数创建一个新的运行的子进程,新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,这就意味着,当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的PID。
父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
简单进程图如下:
图6.1 终端程序的简单进程图
当fork之后,子进程调用execve函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即hello程序,execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC指向_start地址,_start最终调用hello中的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
加载器创建的内存映像如下:
图6.2 启动加载器创建的系统映像
逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
简单看hello sleep进程调度的过程:当调用sleep之前,如果hello程序不被抢占则顺序执行,假如发生被抢占的情况,则进行上下文切换,上下文切换是由内核中调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,并进行1)保存以前进程的上下文2)恢复新恢复进程被保存的上下文,3)将控制传递给这个新恢复的进程 ,来完成上下文切换。
如图6.3,hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时(2.5secs)发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
图6.3 hello进程sleep上下文切换的简单理解
之后的9个sleep进程调度如上。
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。进程切换如图6.3,省略。
如图6.4(a),是正常执行hello程序的结果,当程序执行完成之后,进程被回收。
如图6.4(b),是在程序输出2条info之后按下ctrl-z的结果,当按下ctrl-z之后,shell父进程收到SIGSTP信号,信号处理函数的逻辑是打印屏幕回显、将hello进程挂起,通过ps命令我们可以看出hello进程没有被回收,此时他的后台job号是1,调用fg 1将其调到前台,此时shell程序首先打印hello的命令行命令,hello继续运行打印剩下的8条info,之后输入字串,程序结束,同时进程被回收。
如图6.4(c)是在程序输出3条info之后按下ctrl-c的结果,当按下ctrl-c之后,shell父进程收到SIGINT信号,信号处理函数的逻辑是结束hello,并回收hello进程。
如图6.4(d)是在程序运行中途乱按的结果,可以发现,乱按只是将屏幕的输入缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做shell命令行输入。
图6.4 (a) 正常运行hello程序
图6.4(b)运行中途按下ctrl-z
图6.4(c)运行中途按下ctrl-c
图6.4(d)运行中途乱按
Shell(Gnome-Terminal)下达命令,进程管理为hello提供了活动空间,Shell为其fork,为其execve,为其分配时间片,Linux是繁忙的,但是却依靠进程调度使得每个进程安稳运行,在庞大但有序的Linux都市中,hello还只是个naive的孩子呀,too young,too simple……
在本章中,阐明了进程的定义与作用,介绍了Shell的一般处理流程,调用fork创建新进程,调用execve执行hello,hello的进程执行,hello的异常与信号处理。
(第6章1分)
物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。 CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。
逻辑地址:程序代码经过编译后出现在 汇编程序中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述符的选择符)和偏移量(偏移部分)组成。
线性地址:逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。分页机制中线性地址作为输入。
至于虚拟地址,只关注CSAPP课本中提到的虚拟地址,实际上就是这里的线性地址。
图7.1[转] 三种地址之间的关系
最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。
分段功能在实模式和保护模式下有所不同。
实模式,即不设防,也就是说逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
在保护模式下,线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下32位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。描述符表中的一个条目描述一个段,构造如下:
图7.2[转] 段描述符表中的一个条目的构造
Base:基地址,32位线性地址指向段的开始。Limit:段界限,段的大小。 DPL:描述符的特权级0(内核模式)-3(用户模式)。
所有的段描述符被保存在两个表中:全局描述符表GDT和局部描述符表LDT。gdtr寄存器指向GDT表基址。
段选择符构造如下:
图7.3[转] 段选择符的构造
TI:0为GDT,1为LDT。Index指出选择描述符表中的哪个条目,RPL请求特权级。
所以在保护模式下,分段机制就可以描述为:通过解析段寄存器中的段选择符在段描述符表中根据Index选择目标描述符条目Segment Descriptor,从目标描述符中提取出目标段的基地址Base address,最后加上偏移量offset共同构成线性地址Linear Address。保护模式时分段机制图示如下:
图7.4[转] 保护模式下分段机制
当CPU位于32位模式时,内存4GB,寄存器和指令都可以寻址整个线性地址空间,所以这时候不再需要使用基地址,将基地址设置为0,此时逻辑地址=描述符=线性地址,Intel的文档中将其称为扁平模型(flat model),现代的x86系统内核使用的是基本扁平模型,等价于转换地址时关闭了分段功能。在CPU 64位模式中强制使用扁平的线性空间。逻辑地址与线性地址就合二为一了。所以分段机制也就成为时代的眼泪了(?
线性地址(书里的虚拟地址VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。
首先Linux系统有自己的虚拟内存系统,其虚拟内存组织形式如图7.5,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
图7.5 Linux是如何组织虚拟内存的
系统将每个段分割为被称为虚拟页(VP)的大小固定的块来作为进行数据传输的单元,在linux下每个虚拟页大小为4KB,类似地,物理内存也被分割为物理页(PP/页帧),虚拟内存系统中MMU负责地址翻译,MMU使用存放在物理内存中的被称为页表的数据结构将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
如图7.6,不考虑TLB与多级页表(在7.4节中包含这两者的综合考虑),虚拟地址分为虚拟页号VPN和虚拟页偏移量VPO,根据位数限制分析(可以在7.4节中看到分析过程)可以确定VPN和VPO分别占多少位是多少。通过页表基址寄存器PTBR+VPN在页表中获得条目PTE,一条PTE中包含有效位、权限信息、物理页号,如果有效位是0+NULL则代表没有在虚拟内存空间中分配该内存,如果是有效位0+非NULL,则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中,如果有效位是1则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,与虚拟页偏移量共同构成物理地址PA。
图7.6 地址翻译
在Intel Core i7环境下研究VA到PA的地址翻译问题。前提如下:
虚拟地址空间48位,物理地址空间52位,页表大小4KB,4级页表。TLB 4路16组相联。CR3指向第一级页表的起始位置(上下文一部分)。
解析前提条件:由一个页表大小4KB,一个PTE条目8B,共512个条目,使用9位二进制索引,一共4个页表共使用36位二进制索引,所以VPN共36位,因为VA 48位,所以VPO 12位;因为TLB共16组,所以TLBI需4位,因为VPN 36位,所以TLBT 32位。
如图 ,CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)向TLB中匹配,如果命中,则得到PPN(40bit)与VPO(12bit)组合成PA(52bit)。
如果TLB中没有命中,MMU向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。
如果查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
图 TLB与4级页表下Core i7的地址翻译情况
前提:只讨论L1 Cache的寻址细节,L2与L3Cache原理相同。L1 Cache是8路64组相联。块大小为64B。
解析前提条件:因为共64组,所以需要6bit CI进行组寻址,因为共有8路,因为块大小为64B所以需要6bit CO表示数据偏移位置,因为VA共52bit,所以CT共40bit。
在上一步中我们已经获得了物理地址VA,使用CI(后六位再后六位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。
如果没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。
图 物理内存的访问
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
图 加载器是如何映射用户地址空间区域的
缺页故障是一种常见的故障,当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。其处理流程遵循图 所示的故障处理流程。
图 故障处理流程
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。
printf函数会调用malloc,下面简述动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
1)堆及堆中内存块的组织结构:
在内存块中增加4B的Header和4B的Footer,其中Header用于寻找下一个blcok,Footer用于寻找上一个block。Footer的设计是专门为了合并空闲块方便的。因为Header和Footer大小已知,所以我们利用Header和Footer中存放的块大小就可以寻找上下block。
2)隐式链表
所谓隐式空闲链表,对比于显式空闲链表,代表并不直接对空闲块进行链接,而是将对内存空间中的所有块组织成一个大链表,其中Header和Footer中的block大小间接起到了前驱、后继指针的作用。
3)空闲块合并
因为有了Footer,所以我们可以方便的对前面的空闲块进行合并。合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变Header和Footer中的值就可以完成这一操作。
将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如下图:
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
维护链表的顺序有:后进先出(LIFO),将新释放的块放置在链表的开始处,使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
Linux可不是一个想呆在哪就呆在哪的地方,这个大都市是如此有序,公共活动场所又是如此宝贵,只有当真正要进行活动的时候,hello才能向MMU递交统一格式的虚拟地址来获得自己真正该玩耍的地方,活动时如果需要临时存放 个程序 物品,还需要特别地调用malloc申请堆空间。通过网上冲浪hello还了解到一个叫做段式管理的都市传说,也是个令 程序 摸不到头脑的东西 555。虽然繁琐,但Linux可真使 程序 感到安心呀……
本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,以intel Core7在指定环境下介绍了VA到PA的变换、物理内存访问,还介绍了hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
设备的模型化:所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
Unix I/O接口统一操作:
Unix I/O函数:
前提:printf和vsprintf代码是windows下的。
查看printf代码:
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;
}
首先arg获得第二个不定长参数,即输出的时候格式化串对应的值。
查看vsprintf代码:
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 != '%') //忽略无关字符
{
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt)
{
case 'x': //只处理%x一种情况
itoa(tmp, *((int*)p_next_arg)); //将输入参数值转化为字符串保存在tmp
strcpy(p, tmp); //将tmp字符串复制到p处
p_next_arg += 4; //下一个参数值地址
p += strlen(tmp); //放下一个参数值的地址
break;
case 's':
break;
default:
break;
}
}
return (p - buf); //返回最后生成的字符串的长度
}
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall,查看syscall的实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
syscall将字符串中的字节“Hello 1170300825 lidaxin”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是我们的打印字符串“Hello 1170300825 lidaxin”就显示在了屏幕上。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。
对于printf和getchar,hello以前只知道调用之后一个能打印字符,一个能读入字符,可究竟为啥,不知道,学习完Linux都市的IO管理手册之后,hello多少明白了其中奥妙,原来他们都是Unix I/O的封装,而真正调用的是write和read这样的系统调用函数,而它们又都是由内核完成的,之所以键盘能输入是因为引发了异步异常,之所以屏幕上会有显示是因为字符串被复制到了屏幕赖以显示的显存当中,至于其中细节,也值得好好研究一番……
本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数。
(第8章 1分)
hello程序 终于 完成了它 艰辛 的一生。hello的一生大事记如下:
这一天,世界上终于响起那首关于Hello的歌曲“只有 CS 知道……我曾经……来…………过……”,但听到这首歌的绝非仅有CS……
还有我啊 啊 啊 啊 啊……
(结论0分,缺失 -1分,根据内容酌情加分)
文件名称 |
文件作用 |
hello.i |
预处理之后文本文件 |
hello.s |
编译之后的汇编文件 |
hello.o |
汇编之后的可重定位目标执行 |
hello |
链接之后的可执行目标文件 |
hello2.c |
测试程序代码 |
hello2 |
测试程序 |
helloo.objdmp |
Hello.o的反汇编代码 |
helloo.elf |
Hello.o的ELF格式 |
hello.objdmp |
Hello的反汇编代码 |
hello.elf |
Hellode ELF格式 |
hmp.txt |
存放临时数据 |
(附件0分,缺失 -1分)
[1] ELF 构造:https://www.cs.stevens.edu/~jschauma/631/elf.html
[1] 16进制计算器:http://www.99cankao.com/digital-computation/hex-calculator.php
[2] Linux下进程的睡眠唤醒:https://blog.csdn.net/shengin/article/details/21530337
[3]进程的睡眠、挂起和阻塞:https://www.zhihu.com/question/42962803
[4]虚拟地址、逻辑地址、线性地址、物理地址:https://blog.csdn.net/rabbit_in_android/article/details/49976101
[5] printf函数实现的深入剖析:https://blog.csdn.net/zhengqijun_/article/details/72454714
[6] 内存地址转换与分段 https://blog.csdn.net/drshenlei/article/details/4261909