2019CSF程序人生

第一章

1. 1 hello简介
用户通过键盘的输入,根据高级语言的规范形成编译器可以读懂的代码。之后hello.c文件经过编译器的预处理对源文件进行转换,编译得到汇编语言,汇编再将汇编语言转换成二进制的机器语言,最后与库函数进行链接并重定位,形成可执行文件。
可执行文件可以通过shell运行并传入命令行参数。shell同样是一个程序,它通过fork函数创建一个新进程,通过execve函数执行。操作系统的并发机制可以使得hello程序与其他程序并发运行。
操作系统将程序的各类信息从磁盘加载到内存得以有效的执行。cpu读取其代码并以流水线的方式执行,通过高速缓存高速的读取指令,将程序的各个指令在硬件上实现。
当hello对数据进行处理时,其空间在内存上申请。操作系统提供的虚拟内存机制为每个进程维护自己的空间,从而避免了相互干扰。
TLB、分级页表等机制又为数据在内存中的高效访问提供了支持。
操作系统将I/O设备都抽象成文件,将底层与应用层隔离,将用户态与内核态隔离,通过描述符与接口,让hello程序能够调用硬件进行输入输出。
hello程序终止后,父进程shell与操作系统一同将其回收,释放其占用的内存空间。

1.2 环境与工具
硬件环境:Intel®Core™i7-4710MQ CPU;2.50GHz;8.00G RAM;512GB SSD
软件环境:Windows10 64位;Vmware14.1.3;Ubuntu 18.04.1
开发工具:Visual Studio 2017 64位;CodeBlocks 64位
1.3 中间结果
hello.i : hello.c预处理结果,研究预编译的作用以及进行下一步编译操作。
hello.s : hello.i编译之后的结果,用于研究汇编语言以及编译器的汇编操作。
hello.o : hello.s汇编之后的结果,可重定位的目标文件,用于连接器或编译器链接生成最终的可执行程序。
hello:链接之后的可执行文件。
asm.s:对可执行目标文件反汇编得到。可用来分析链接过程与寻址过程。
hello.o.s:对可重定位的目标文件反汇编得到,可以与asm.s对比进行分析链接过程。
1.4 本章小结
本章主要介绍了hello程序从代码经过预处理编译汇编连接执行过程中发生的事件。

第二章

2.1 预处理的概念与作用
预处理:是指预处理器(cpp)根据以字符#开头的命令,修改原始的c程序,比如#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。其他常见的预处理命令还有#define(定义宏)、#if、#ifdef、#endif等。
作用:预处理实现了在编译器进行编译之前对源代码做做某些转换。例如将头文件中的代码插入到程序中,通过宏对符号就行替换等。得到以.i为后缀的文件。

2.2在Ubuntu下预处理的命令
预处理命令:gcc -m64 –no-pie –fno-PIC –E hello.c –o hello.i



2.3 hello的预处理结果解析


解析:预处理是对各种预处理命令进行处理,具体包括对头文件的包含,宏定义的扩展,条件编译的选择等。预处理主要是根据#符号进行处理,在hello中,对于#include指示的处理结果就是将相应的.h文件的内容插入到源程序中。在这个hello程序中主要是对stdio.h unistd.h stdib.h的依次展开。
2.4 本章小结
预处理对程序代码进行某些转换和处理,从而使编译器得以成功的进行。

第3章

3.1 编译的概念与作用
编译:编译程序(CCL)对预处理后的文件进行编译,生成一个汇编语言源程序文件。
作用:将c语言文件翻译为汇编语言文件。c编译器在进行具体的翻译之前,先对源程序文件进行词法分析和语法分析,然后根据分析的结果进行代码的优化和存储分配,最终把c语言源程序翻译为汇编语言程序。

3.2 在Ubuntu下编译的命令
编译命令:gcc –m64 –no-pie –fno-PIC –S hello.i –o hello.s



3.3 Hello的编译结果解析

3.3.1 整数(int型)
1.全局变量sleepsecs:

可见,在.text节中申明为globl变量,之后再.data节中申明对齐方式为4字节对齐,为object类型,大小为4字节。
2.局部变量i:
在栈中分配内存,.data和.rodata均不做说明。如下,通过rbp分配栈空间给i。

3.3.2 数组
argv和envp由shell解析后构造,传递给execve作为参数启动加载器,存放在main的栈帧之上。

3.3.3 字符串

printf函数的命令行格式串,首先申明在.rodata 节中,在申明为string类型,分别用LC0和LC1指代。
3.3.4 赋值
int sleepsecs = 2.5 :sleepsecs是全局变量,在.data节中申明值位2的long类型数据。
i = 0: 整形数据的赋值是通过使用mov指令完成,在栈或寄存器中分配存储空间。
3.3.5 类型转换(隐式)

隐式类型转换,将float转换为int型。当在double和float向int进行类型转换的时候,程序改变数值和位模式的原则是:值会向零舍入,直接舍掉小数点后的部分。
3.3.6 算术操作

i++为运算操作,在汇编中通过add或lea实现:

3.3.7 关系操作


< 和 != 为关系操作,在汇编中通过cmp,test,set等实现,通过设置标志位来实现对两个操作数大小关系的判断,例如cmp a,b 若a和b相等则ZF设置为1,其他标志位用来判断大小关系。


3.3.8数组/指针/结构操作

argv数组是shell由命令行参数构造出来的,传递给main函数的第二个参数,具体在栈中的映像如图
通过rbp在栈中寻找argv的首地址,由于是char*类型,故分别加6加8得到argv[0]和argv[1],取内存内容后分别放入rsi和rdx,传给printf做参数。

3.3.9 控制转移


在汇编中通过进行控制转移,由cmp和jmp语句构成条件判断语句,在这个程序中


3.3.10 函数操作

  1. main函数

    main函数申明在.text节中,申明为global变量,类型为函数。两个参数argc和argv[]由shell构造好后,传入execve作为参数,调用加载器,在之前的上下文中运行程序。
  2. printf函数

第一次调用printf函数,由于只输出一个字符串,实际调用的是puts函数(更快),.LC0只带了第一个字符串,将参数传入edi之后,调用puts函数
第二次分别将参数传入rdx、rsi、edi之后,调用printf函数。
3. exit函数

将1作为参数放入edi中,调用exit函数。
4. sleep函数

将参数放入edi中,调用sleep函数。
5. getchar函数

3.4 本章小结
编译器实现将c语言代码转换成汇编代码,从而最终转换为机器代码。生成汇编代码的这个过程需要对数据、操作都进行对应的转换。数据包括常量、变量(全局/局部/静态)、表达式、宏等,操作包括算术、逻辑、位、关系、函数等操作。
汇编代码搭建了从高级语言到底层机器语言的桥梁,实现了对内存数据的各种操作。编译器通过语法分析、语义分析等编译c语言代码到汇编代码,为接下来生成机器代码奠定了基础。

第4章 汇编

4.1 汇编的概念与作用
汇编:汇编程序(as)对汇编语言源程序进行汇编,生成一个扩展名为.o的可重定位目标文件。
作用:将编译生成的汇编语言代码转换为机器语言代码。
4.2 在Ubuntu下汇编的命令
汇编命令:gcc –m64 –no-pie –fno-PIC –c hello.s –o hello.o



4.3 可重定位目标elf格式



分析:

  1. ELF头:以16B的序列Magic描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件类型、机器类型、字节头部表(section header table)的文件偏移,以及字节头部表中条目的大小和数量等信息。32位的ELF头数据结构如下:

    在32 位 ELF 头的数据结构中, 字段 e_ident是一个长度为16 的字节序列, 其中, 最开始的4字节用来标识是否为ELF 文件, 第一个字节为Ox7F, 后面三个字节分别为 ’ E ’ 、 L’ 、’ F ’ 。再后面的12 个字节中, 主要包含一些标识信息, 例如, 标识是32 位还是64 位格式、标识数据按小端还是大端方式存放、标识 ELF 头的版本号等。字段 e_type 用于说明目标文件的类型是可重定文件、可执行文件、共享库文件, 还是其他类型文件。字段e_ machine 用于指定机器结构类型,如从 32、SPARC V9 、AMD64 等。字段 e_ version 用千标识目标文件版本。字段 e_entry用于指定系统将控制权转移到的起始虚拟地址 (入口点), 如果文件没有关联的入口点, 则为零。例如,对于可重定位文件, 此字段为0。字段 e_ehsize 用千说明ELF 头的大小 (以字节为单位)。字段e_shoff 指出节头表在文件中的偏移扯(以字节为单位)。字段 e_shentsi ze 表示节头表中一个表项的大小(以字节为单位,)所有表项大小相同。字段e_shnum 表示节头表中的项数。因此 e_shentsize和 e_shnum 共同指定了节头表的大小(以字节为单位)。仅 ELF 头在文件中具有固定位置, 即总是在最开始的位置, 其他部分的位置由ELF 头和节头表指出, 不需要具有固定的顺序。
  2. 节头表
    节头表由若干个表项组成,每个表项相应的描述一个节的节名,位置和长度等信息,每个节都有一个相应的表与之对应。
  3. 重定位节
    .rel.text一个.text节中位置的列表,当链接器将目标文件与其他目标文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。注意,可执行目标文件不需要重定位信息,因此通常省略。
    .rel.data 被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果他的值是一个全局变量的地址或者外部定义函数的地址,都需要被修改。
    各个属性的意义如下:
    偏移量 需要进行重定向的代码在.text或.data节中的偏移位置,8个字节。
    信息 包括symbol和type两部分,其中symbol占前4个字节,type占后4个字节,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型
    类型 重定位到的目标的类型
    符号值 重定向到的目标的值
    符号名称 重定向到的目标的名称
    加数 计算重定位位置的辅助信息,共占8个字节
    4.4 Hello.o的结果解析

1.机器语言的构成,与汇编语言的映射关系:可以看出,机器语言就是一堆16进制序列(实际是01序列),指令和寄存器由特定的值代表,立即数由小端表示的16进制数表示,由指令分隔开连续的16进制序列,从而与汇编语言一一对应。
2. 机器语言中的操作数与汇编语言不一致:每条指令对应的01序列的含义有不同的规定,例如push %rbp,指令为55H = 01010101B,其中高5位01010为push的操作码,为小端法,即A0,后三位101为rbp的编号,为5。再例如leave指令为C9H = 11001001B,没有显示操作数,故8位都是指令操作码。
3. hello.o的反汇编与hello.s的对比:
对比: a.反汇编出的代码没有全局变量的信息
b.分支转移:反汇编出的代码跳转处都采用相对寻址,而在编译出的.s文件里用标志代替。因为标志只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
c.操作数:反汇编出的代码中数字采用十六进制,而编译出的.s文件里数字采用十进制
d. 函数调用:在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待静态链接的进一步确定。
4.5 本章小结
汇编过程将汇编语言转换为机器代码,生成可重定位的目标文件,使机器能够直接处理与执行。可以通过readelf读取elf文件信息,得到其符号表的相关信息。另外,可以通过objdump反汇编目标文件,从中可以得到机器代码与汇编代码的对照。
作为机器可以直接执行的语言,机器语言与汇编语言存在映射关系,能够反映机器执行程序的逻辑。

第5章 链接

5.1 链接的概念与作用
链接:将关联的所有可重定位的目标文件结合到一起,形成一个具有统一地址空间的可执行目标文件的过程。
作用:1.模块化。它能使一个程序被划分为不同的模块,由不同的程序员进行编写,而且可以构建公共的函数库以提供给不同的程序共用。
2.效率高。每个模块可以分开编译,在程序修改时只需重新编译修改过的源程序文件,再重新链接,提高了时间效率。
3.提高了空间利用率。源程序文件中无需包含共享库的所有代码,只要直接调用即可,而且可执行文件运行时的内存中,也只需要包含所调用的函数的代码而不需要包含整个共享库,空间利用率高。

5.2 在Ubuntu下链接的命令
链接命令:


5.3 可执行目标文件hello的格式


.text段 起始地址:0x400500 大小:0x122
.data段 起始地址:0x601040 大小:0x8
5.4 hello的虚拟地址空间
自虚拟地址0x400000开始,到0x400fff结束,这之间每个节(.interp - .en_frame节)的排列即为上图中地址中声明

ELF可执行文件被设计的很容易加载到内存,可执行文件的连续的片被映射到连续的内存段。程序头表(也称段头部表)描述了这种映射关系。32位的程序头表中的每个表项用来描述一个节,其数据结构如下:

p_type(对应下图中的Type)描述存储段的类型或特殊节的类型。例如,是否为可装入段 ( PT_LOAD),是否是特殊的动态节 ( PT_DYNAMIC) ,是否是特殊的解释程序节 ( PT_INTERP) 。p_offset (对应下图中的offset)指出本段的首字节在文件中的偏移地址。p_ vaddr (对应下图中的VirtAddr)指出本段首字节的虚拟地址。p_paddr (对应下图中的PhysAddr)指出本段首字节的物理地址,因为物理地址由操作系统根据情况动态确定, 因而该信息通常是无效的。p_filesz(对应下图中的FileSiz)指出本段在文件中所占的字节数,可以为0。p_memsz(对应下图中的MemSiz)指出本段在存储器中所占字节数,也可以为0。p_flags(对应下图中的Flags)指出存取权限。p_align(对应下图中的Align) 指出对齐方式,用 一个模数表示, 为 2 的正整数幕, 通常模数与页面大小相关,若页面大小为4KB , 则模数为2^12

5.5 链接的重定位过程分析

Hello的反汇编结果与hello.o的反汇编结果相比,多了以下的节:
_init 程序初始化代码
gmon_start call_gmon_start函数初始化gmon profiling system,这个系统是在编译程序时加上-pg选项,程序通过gprof可以输出函数调用等信息
_dl_relocate_static_pie 静态库链接
.plt 动态链接-过程链接表
Puts(等函数)@plt 动态链接各个函数
_start 编译器为可执行文件加上了一个启动例程
__libc_csu_init 程序调用libc库用来对程序进行初始化的函数,一般先于main函数执行
_fini 当程序正常终止时需要执行的代码

链接过程:

  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之后的职位目标地址与下一条指令的地址之差,指向相应的字符串。

基本的重定位类型有:1.R_386_PC32:相对寻址方式。2.R_386_32绝对寻址方式。此处以相对寻址方式为例解析重定位过程:
信息(r_info)可分为高位的索引值和地位的重定位类型。如下图,以第二个表项为例,偏移量为0x1b,类型为R_386_PC32,即相对寻址方式,索引值为0xb,故所引用的符号为符号表中的第11项,即为puts。

下图为hello.o的反汇编结果,可以看出符号puts从.text节中偏移量为0x1b处开始,类型为R_386_PC32,与前面分析的一致。不妨假设puts在main函数之后0x29处,则puts的地址为0x8048380+0x29=0x80483a9,对齐后为0x80483aa。

转移目标地址计算公式为:转移目标地址= PC + 偏移地址。call指令中的重定位地址就是偏移地址,故重定位值=转移目标地址 – PC。转移目标地址就是puts定义的首地址,前面计算为0x80483aa,PC值为0x8048380+0x1f=0x804839f,所以,重定位值应为0x80483aa-0x804839f=0xb,即重定位代码应改为e8 0b 00 00 00。
5.6 hello的执行流程
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
5.7 Hello的动态链接分析
1.加载时进行动态链接
整个过程被分为两步:首先,进行静态链接以生成部分链接的可执行目标文件hello,该文件中仅包含共享库中的符号表和重定位表信息,而共享库中的代码和数据并没有被合并到hello中;然后,在加载hello时,由加载器将控制权转移到指定的动态链接器,由动态链接器对共享目标文件libc.so、mylib.so和hello中的相应模块内的代码和数据进行重定位并加载共享库,以生成最终的存储空间中完全链接的可执行目标文件,在完成重定位和加载共享库后,动态链接器把控制权转到hello。在执行hello的过程中,共享库中的代码和数据在存储空间的位置一直是固定的。
2.还可以在程序运行时进行动态链接。

3.延迟加载
动态库是在进程启动时加载进来的,加载后,动态链接器需要对其做一系列的初始化,如符号重定位(动态库内以及可执行文件内),这些工作时比较费时的,特别是对函数的重定位,所以延迟重定位可以提高效率,具体来说,就是应该等到第一次发生对该函数的调用时才进行符号绑定。实现步骤如下:
a建立一个GOT.PLT表,该表用来放全局函数的实际地址,但最开始时,里面存放的不是实际地址而是一个跳转。
b 对每一个全局函数,链接器生成一个与之相对应的影子函数,如puts@plt。
c所有对puts的调用,都换成对puts@plt的调用。



di_init调用前后GOT的变化
跳转到这个地址,发现正是动态链接库的入口地址。

GOT[2]指向的地址
为了验证延迟绑定的实现,可以查看printf调用前后printf@plt的指令跳转地址,也就是对应GOT中的值,可以发现,调用后确实链接到了动态库。
5.8 本章小结
经过链接ELF可重定位目标文件形成可执行目标文件,链接器会将静态库代码写入程序中,以及动态库调用的相关信息,并且将地址进行重定位,从而保证寻址的正确进行。静态库直接写入代码即可,而动态链接过程相对复杂,涉及共享库的寻址。

第6章 hello进程管理

6.1 进程的概念与作用
进程:进程的经典定义就是一个可执行程序的实例。简单来说,进程时程序的一次运行过程,更确切的说,进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动,更具有动态含义。
作用:“进程”的引入为应用程序提供了以下两方面的抽象:一个独立的逻辑控制流和一个私有的虚拟地址空间。每个进程拥有一个独立的逻辑控制流,使得程序以为自己的程序在执行过程中独占的使用处理器;每个进程拥有一个私有的虚拟地址空间,使得程序员以为自己独占的使用存储区。
6.2 简述壳Shell-bash的作用与处理流程
作用:1.接受用户命令,解释用户输入的命令,将他传递给内核,然后调用相应的程序
2.调用其他程序,给其他程序传递数据或参数,并获取程序的处理结果
3.在多个程序之间传递数据,把一个程序的输出作为另一个程序的输入
4.shell本身也可以被其他程序调用

流程:1.shell命令行解释器输出命令行提示符,接受用户命令
2.解析命令,构建argv和envp参数列表和参数个数argc
3如果是内置命令则立即执行,否则fork子进程
4.以构建的argc argv envp 为参数调用execve以启动加载器,从而在当前进程上下文加载并运行程序
5.shell接受键盘输入信号,并对这些信号进行检查
6.3 Hello的fork进程创建过程
1.shell命令行解释器输出命令行提示符,接受用户命令
2.解析命令,构建argv,envp参数列表和参数个数argc
3.fork子进程,新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,这就意味着,当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的PID。
4.以构建的argc,arhv,envp为参数调用execve以启动加载器,从而在当前进程上下文中加载并运行程序
6.4 Hello的execve过程
可执行文件执行时,会通过加载器进行加载。在Linux/Unix中,可以通过execve()启动加载器,execve()函数的功能是在当前进程的上下文中加载并运行一个新程序。execve () 函数的用法如下

该函数的具体运行如下:该函数用来加载并运行可执行目标文件filenam,可带参数列表argv[]和环境变量列表envp[],若出现错误,如找不到指定文件filename,则返回-1,并将控制权返回给调用程序;若函数执行成功,则不返回,最终将控制权传递到可执行文件的main函数。此时main函数的虚拟内存中用户栈结构如下:

加载过程如下:
1.shell 命令行解释器输出一个命令行提示符(如:unix >),并开始接受用户输入的命令行。
2.当用户在命令行提示符 后输入命令行 "./ hello [ Enter] "后,开始对命令行进行解析,获得各个命令行参数并构造传递给函数 execve () 的参数列表argv ,将参数个数送 argc。
3.调用函数fork (),创建一个子进程,新创建的子进程获得与父进程完全相同的虚拟存储空间中的一个备份,包括只读段、可读写数据段、堆以及用户栈等 。
4.以第2 步命令行解析得到的参数个数argc、参数列表 argv以及全局变量 environ作为参数,调用函数 execve () ,从而实现在当前进程(新创建的子进程)的上下文中加载并运行 hello程序。在函数execve () 中,通过启动加载器执行加载任务,将可执行目标文件 hello 中的.text、. data、.bss节等内容加载到当前进程的虚拟地址空间(实际上并没有将 hello 文件中的代码和数据从磁盘读入主存,而是修改了当前进程上下文中关于存储映像的一些数据结构)。当加载器执行完加载任务后,便开始转到 hello 程序的第一条指令执行,从此, hello 程序开始在一个进程的上下文中运行。
6.5 Hello的进程执行
上下文:进程的物理实体(代码和数据等)和支持进程运行的环境合称为进程的上下文
上下文切换:操作系统通过处理器调度让处理器轮流执行多个进程。实现不同进程中指令交替执行的机制称为进程的上下文切换。
进程时间片:连续执行同一个进程的时间段称为时间片( time slice )。
时间片轮转处理器调度:每个时间片结束时,通过进程的上下文切换,换一个新的进程到处理器上执行 ,从而开始一个新的时间片,这个过程称为时间片轮转处理器调度。

进程的上下文切换具体实现:
上下文切换发生在操作系统调度一个新进程到处理器上运行时,它需要完成以下三件事:1.将当前进程的寄存器上下文保存到当前进程的系统级上下文的现场信息中;2.将新进程系统级上下文中的现场信息作为新的寄存器上下文恢复到处理器的各个寄存器中;3.将控制转移到新进程执行。这里,一个重要的上下文信息是PC的值.当前进程被打断的断点处的 PC作为寄存器上下文的一部分被保存在进程现场信息中,这样,下次该进程再被调度到处理器上执行时,就能从现场信息中获得端点处的PC值,从而能从断点处开始执行。
具体到Hello进程,则首先由shell通过加载器加载可执行目标文件hello,由操作系统完成上下文切换,从而进入hello进程(用户态),hello进程调用sleep函数后进入内核态,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,上下文切换后控制转移到shell进程,定时器到时后发送一个中断信号,由信号处理函数完成处理,将hello进程从等待队列中移出重新加入到运行队列,从而进行上下文切换进入到Hello进程,10次调用sleep函数则重复以上过程10次。
当hello调用getchar的时候,实际是执行输入流是stdin的系统调用read(通过syscall调用),hello之前运行在用户模式,syscall调用read之后进入陷阱,此时进行上下文切换,开始执行shell进程,在进行相应处理后(键盘输入后),再进行上下文切换,转回执行hello进程。

6.6 hello的异常与信号处理
可能出现的异常种类:中断 故障(缺页故障,调用缺页异常处理函数即可) 可能出现的信号:SIGINT SIGSTP

crt-z:shell父进程收到SIGTSTP信号,信号处理程序将hello进程挂起,放到后台,ps可以看到hello进程没有被回收;fg 1将进程调到前台执行。

crt –c:shell父进程收到SIGINT信号,由信号处理程序结束hello,并回收hello进程。

当使用fg 1命令将hello调到后台执行时,hello进程并没有被回收,可以通过kill命令发送信号给hello子进程回收该进程。
6.7本章小结
异常控制流发生在计算机系统的各个层次。比如, 在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。

第7章 hello的存储管理

7.1 hello的存储器地址空间
逻辑地址:是指由程序产生的与段相关的偏移地址部分。就是hello.o里面的相对偏移地址。
线性地址:地址空间是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。就是hello里面的虚拟内存地址。
虚拟地址:cpu通过生成虚拟地址来访问主存。在访问时转换为物理地址。同逻辑地址。
物理地址:计算机的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有一个唯一的地址,叫做物理地址。cpu访问内存最自然的方式就是使用物理寻址。
7.2 Intel逻辑地址到线性地址的变换-段式管理

逻辑地址包含 16位的段选择符和32位的段内偏移量。转换过程中MMU首先根据段选择符中的TI 确定选择全局描述符表 ( GDT) 还是局部描述符表 ( LDT) 。若 TI = 0 , 选用GDT; 否则,选用 LDT。确定描述符表后,再通过段选择符内的13 位索引值,从被选中的描述符表中找到对应的段描述符。因为每个段描述符占 8 个字节,所以位移量为索引值乘 8,加上描述符表首地址(其中, GDT 首地址从 GDTR 的高 32 位获得,LDT首地址从 LDTR 对应的LDT 描述符cache 中高32 位获得),就可以确定选中的段描述符的地址,从中取出32 位的基地址 ( B31 - BO), 与逻辑地址中 32 位的段内偏移量相加,就得到 32 位线性地址。MMV 在计算线性地址 LA 的过程中,可以根据段的限界和段的访问权限判断是否“地址越界”或“ 访问越权”,以实现存储保护。
通常情况下 , MMU并不需要到主存中去访问 GDT 或 LDT, 而只要根据段寄存器对应的描述符cache中的基地址、限界和访问(存取)权限来进行逻辑地址到线性地址的转换,如图所示。

逻辑地址中32位的段内偏移量即是有效地址 EA,它由指令中的寻址方式来确定如何得到。从上图可看出,IA- 32 中有效地址的形成方式有以下几种:偏移量、基址、变址、比例变址、基址加偏移量、基址加变址、基址加比例变址、基址加变址加偏移量、基址加比例变址加偏移量等。比例变址时,变址值等于变址寄存器的内容乘以比例因子。
7.3 Hello的线性地址到物理地址的变换-页式管理
IA-32和x86-64采用段页式虚拟存储管理方式,通过分段方式完成逻辑地址到线性地址的转换后,再进一步通过分页方式将线性地址转换为物理地址。
下图所示的是分页部件将线性地址转换为物理地址的基本过程,为了解决页表过大的问题,采用了两级页表方式。

在一个两级页表分页方式中,线性地址由三个字段组成, 它们分别是10 位页目录索引 ( DIR ) 、10 位页表索引 ( PAGE ) 和12位页内偏移量( OFFSET)。页目录项和页表项的格式如图:

页目录项和页表项中部分字段的含义简述如下。
P: P= 1表示页表或页在主存中;p = 0表示页表或页不在主存中,此时发生页故障(缺页异常),需将页故障线性地址记录在CR2 中。操作系统在处理页故障时会将缺失的页表或页从磁盘装入主存中,并重新执行引起页故障的指令。
R/W: 该位为0时表示页表或页只能读不能写;为1时表示可读可写。
UI S : 该位为0时表示用户进程不能访问;为1时允许用户进程访问。该位可以保护操作系统所使用的页不受用户进程的破坏 。
PWT: 用来控制页表或页对应的cache写策略是全写( write through )还是回写( write back)。
PCD: 用来控制页表或页能否被缓存到cache中。
A : A= 1表示指定页表或页被访问过,初始化时操作系统将其清0。利用该标志,操作系统可清楚地了解哪些页表或页正在使用,一般选择长期未用的页或近来最少使用的页调出主存。由MMU在进行地址转换时将该位置1。
D: 修改位或称脏位( dirty bit)。该位在页目录项中没有意义,只在页表项中有意义。D =1 表示页被修改过;否则说明页面内容未被修改,因而在操作系统将页面替换出主存时,无需将页面写入磁盘。初始化时操作系统将其清0 , 由MMU 在进行写操作的地址转换时将该位置 l。
页目录项和页表项中的高20位是页表或页在主存中的首地址对应的页框号, 即首地址的高20位。每个页表的起始位置都按 4KB 对齐。
从图中可看出,线性地址向物理地址的转换过程如下:首先,根据控制寄存器 CR3 中给出的页目录表首地址找到页目录表,由DIR字段提供的10位页目录索引找到对应的页目录项,每个页目录项大小为 4B; 然后,根据页目录项中 20 位基地址指出的页表首地址找到对应 页表,再根据线性地址中间的页表索( PAGE字段)找到页表中的页表项;最后, 将页表项中的20 位基地址和线性地址中的 12 位页内偏移量组合成 32 位物理地址。上述转换过程中10 位的页目录索引和10 位的页表索引都要乘以4 , 因为每个页目录项和页表项都是 32 位,占4 个字节。由千页目录索引和页表索引均为10位;每个页目录项和页表项占 用 4 个字节,因此页目录表和页表的长度均为 4 KB, 并分别含有1024个表项。这样,对于 12 位偏移地址,32 位的线性地址所映射的物理地址空间是1024 x 1024 x4KB = 4GB。
7.4 TLB与四级页表支持下的VA到PA的变换

如上图所示(图中为二级页表,只要将主存框内改为4级页表即可),首先(以32位为例,64位也是一样)在TLB中查找是否有页表项,将虚拟地址根据页的大小分为虚拟页号和页内地址,再根据TLB的条目数分为标记位和组索引,在TLB(在cache里)中遍历各个条目(由页表基址寄存器值+组索引),如果标记位相同且有效位为1则读出相应的物理页号,和页内地址拼接则得出物理地址。
如果TLB中没有找到符合的条目,即TLB缺失,则在主存中查找四级页表。将虚拟页号根据四级页表的页表项数量分为四个虚拟页号,根据四个虚拟页号(前三个为页目录索引,第四个为页表索引),在四级页表中找出物理页号,如果相应页表条目为已缓存,则读出物理页号和页内地址相拼接得到物理地址。否则做缺页处理。
综合过程如下:

7.5 三级Cache支持下的物理内存访问

讨论组相联下的读取,其他类似。
得到物理地址后,根据cache块大小和行(组)数将物理地址分为标记位,组索引和块内地址,如果组索引下的cache行标记位相同,有效位为1,则命中,读出cache行中第块内地址个处的字节。如果不命中,则向下一级缓存(或主存)取相应的cache行(主存行),此时如果原来的组中有空闲行,则直接替换,如果没有空闲行,则需要替换,常用的替换策略有随机替换,LRU,LFU等。
7.6 hello进程fork时的内存映射
Shell通过调用函数fork()创建一个子进程,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,新创建的子进程获得与父进程完全相同的虚拟存储空间中的一个备份,包括只读段,可读写数据段,堆以及用户栈等。
7.7 hello进程execve时的内存映射
在函数execve () 中,通过启动加载器执行加载任务,将可执行目标文件 hello 中的.text、. data、.bss节等内容加载到当前进程的虚拟地址空间(实际上并没有将 hello 文件中的代码和数据从磁盘读入主存,而是修改了当前进程上下文中关于存储映像的一些数据结构)。当加载器执行完加载任务后,便开始转到 hello 程序的第一条指令执行。

具体步骤有:
1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
3.映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
5.将main的参数传入栈,如图,将构造好的argc,argv[],envp[],写入栈作为参数

7.8 缺页故障与缺页中断处理
首先要分清缺页故障和段故障:在主存中查找页表时,如果相应页表条目有效位为0且物理页号为NULL,则该页表条目处于未分配,此时应该是属于段故障的一种,相应异常处理程序为终止;如果相应页表条目有效位为0但是物理页号指向磁盘,则为真正的缺页故障,此时调用相应的异常处理程序,从磁盘装入相应页到内存并更新页表,再返回到故障指令开始执行。
缺页故障为返回到当前指令

7.9动态存储分配管理
动态存储分配管理由动态内存分配器完成。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制零的区域,它紧接在未初始化的数据区后开始,并向上生长(向更高的地址)。分配器将堆视为一组不同大小的块的集合来维护。
每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显示地被应用程序所分配。
一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存分配器从堆中获得空间,将对应的块标记为已分配,回收时将堆标记为未分配。而分配和回收的过程中,往往涉及到分割、合并等操作。
动态内存分配器的目标是在对齐块的基础上,尽可能地提高吞吐率及空间占用率,即减少因为内存分配造成的碎片。其实现常见的数据结构有隐式空闲链表、显式空闲链表、分离空闲链表,常见的放置策略有首次适配、下一次适配和最佳适配。
为了更好的介绍动态存储分配的实现思想,以隐式空闲分配器的实现原理为例进行介绍。

隐式空闲链表分配器的实现涉及到特殊的数据结构。其所使用的堆块是由一个子的头部、有效载荷,以及可能的一些额外的填充组成的。头部含有块的大小以及是否分配的信息。有效载荷用来存储数据,而填充块则是用来对付外部碎片以及对齐要求。基于这样的基本单元,便可以组成隐式空闲链表。
通过头部记录的堆块大小,可以得到下一个堆块的大小,从而使堆块隐含地连接着,从而分配器可以遍历整个空闲块的集合。在链表的尾部有一个设置了分配位但大小为零的终止头部,用来标记结束块。
当请求一个k字节的块时,分配器搜索空闲链表,查找足够大的空闲块,其搜索策略主要有首次适配、下一次适配、最佳适配三种。
一旦找到空闲块,如果大小匹配的不是太好,分配器通常会将空闲块分割,剩下的部分形成一个新的空闲块。如果无法搜索到足够空间的空闲块,分配器则会通过调用sbrk函数向内核请求额外的堆内存。
当分配器释放已分配块后,会将释放的堆块自动与周围的空闲块合并,从而提高空间利用率。为了实现合并并保证吞吐率,往往需要在堆块中加入脚部进行带边界标记的合并。
7.10本章小结
虚拟存储机制的引入,使得每个进程具有一个一致的、极大的、私有的虚拟地址空间。虚拟地址空间按照等长的页来划分,主存也相应划分。通过页表建立虚拟页和主存之间的对应关系。虚拟存储器有分页式、分段式、段页式三种。虚拟地址需转换为物理地址才能访存,为减少访问内存中页表的次数,通常将活跃页的页表项放到一个高速缓存TLB中。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
文件就是一个字节序列,所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix I/O接口统一操作:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
2.Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
3.改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
4.读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5.关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
系统级I/O函数:
1.create系统调用
用法:int creat(char•name, mode_t perms) ;
第一个参数 name为需创建的新文件的名称,是一个表示路径名和文件名的字符串;第二个参数perms用于指定所创建文件的访问权限,共有 9 位,分别指定文件拥有者、拥有者所在组成员以及其他用户各自所拥有的读、写和执行权限。通常用一个8进制数字中的三位分别表示读、写和执行权限,例如,perms = 0755, 表示拥有者具有读,写和执行权限,而拥有者所在组成员和其他用户都只有读和执行权限。正常情况下,该函数返回一个文件描述符,若出错,则返回- 1。若文件巳经存在,则该函数将把文件截断为长度为0的文件,也即,将文件原先的内容全部丢弃,因此,创建一个已存在的文件不会发生错误。
2. open系统调用
用法:int open( char name, int flags , mode_t perms);
除了默认的标准输入、标准输出和标准错误三种文件是自动打开以外,其他文件必须用相应的函数显式创建或打开后才能读写,可以用 open 系统调用显式打开文件。正常情况下,open () 函数返回一个文件描述符,它是一个用以唯一标识被打开文件的非负整数,若出错,则返回- 1。第一个参数name 为需打开文件的名称,是一个表示路径名和文件名的字符串;第二个参数flags 指出用户程序将会如何访问这个打开文件。第三个参数perms 用于指定所创建文件的 访问权限,通常在open () 函数中该参数总是 0 ,除非以创建方式打开,此时,参数flags中应带有O_C REAT 标志。不以创建方式打开一个文件时,若文件不存在,则发生错误。对于不存在的文件,可用 creat 系统调用来打开。
3. read系统调用
用法:ssize_t read(int fd, void *buf, size_t n);
该函数功能是将文件肛中从当前读写位置k开始读取n个字节到 buf 中,读操作后文件当前读写位置为k + n。假定文件长度为m, 当k + n > m 时,则真正读取的字节数为m - k < n , 并且读操作后文件当前读写位置为文件尾 。函数返回值为实际读取 字节数,因而,当 m = k(EOF)时,返回值为 0;出错时返回值为 - 1。
4.write系统调用
用法:ssize_t write(int fd, const void *buf, size_t n);
该函数功能是将 buf中的n字节写到文件fd中,从当前读写位置k处开始写入。返回值为实际写入字节数 m , 写入后文件当前读写位置为 k + m。对于普通的磁盘文件,实际写入字节数 m 等于指定写入字节数 n。出错时返回值为 - 1。对于 read 和 write 系统调用,可以一次读(写) 任意字节,例如,每次读(写)一个字节或一个物理块大小,如一个磁盘 扇区 (512 字节)或一个记录大小等。显然,按照一个物理块大小来读(写) 比较好,可以减少系统调用的次数。有些情况下 , read和 write 真正读(写)的字节数比用户程序设定的所需字节数要少,这 种 情况并不被看成是一种错误 。通常,在读(写)磁盘文件时,除非遇到 EOF, 否则不会出现这种情况。但是,当读写的是终端设备文件、网络套接字文件、UNIX 管道、Web 服务 器等时,都可能出现这种情况。
5.lseek系统调用
用法:long lseek(int fd, long offset , int origin );
当随机读写一个文件的信息时,当前读写位置可能并非正好是马上要读或写的位置,此时,需要用 lseek () 函数来调整文件的当前位置。第一个参数fd 指出需调整位置的文件;第二个参数指出相对字节数; 第三个参数origin指出offset相对的基准,可以是文件开头 (origin = 0 ) 、当前位置 ( origin = 1 ) 和文件末尾 ( origin= 2 ) 。
6.stat/fstat系统调用
用法: int stat(const name, struct statbuf);
int fstat(int fd, struct stat *buf ) ;
文件的所有属性信息 ,包括文件描述符、文件名、文件大小、创建时间、当前读写位置等都由操作系统内 核来维护, 这些信息也称为文件的元数据( metadata ) 。用户程序可以通过 stat ()或£stat () 函数来查看文件元数据。stat 第一个参数指出的是文件名,而fstat指出的是文件描述符,这两个函数除了第一个参数类型不同外,其他方面全部一样。
7.close系统调用
用法:lose(int fd ) ;
该函数的功能就是关闭文件fd。
8.3 printf的实现分析

如图所示,假定用户程序中有一个语句调用了库函数 printf () , 在 printf () 函数中又通过一系列的函数调用,最终转到调用write () 函数。在write () 函数对应的指令序列中,一定有一条用于系统调用的陷阱指令,即system_call。该陷阱指令执行后,进程就从用户态陷人到内核态执行。Linux中有一个系统调用的统一入口, 即系统调用处理程序 system_call()。CPU执行陷阱指令后,便转到system_call ()的第一条指令执行在system_call()中,将根据RAX寄存器中的系统调用号跳转到当前系统调用对应的系统调用务例程 sys_write () 去执行。system_call()执行结束时,从内核态返回到用户态下的陷阱指令后面一条指令继续执行 。
write通过执行syscall指令实现了对系统服务的调用,从而使内核执行打印操作。内核会通过字符显示子程序,根据传入的ASCII码到字模库读取字符对应的点阵,然后通过vram(显存)对字符串进行输出。显示芯片将按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终实现printf中字符串在屏幕上的输出。
8.4 getchar的实现分析
getchar定义在stdio.h文件中,我们在stdio.h中可以找到其相关的定义

getc()的函数原型如下:

getchar函数实际上就是getc(stdin),即标准输入下的getc()。通过调用read函数返回字符。其中read函数的第一个参数是描述符fd,0代表标准输入。第二个参数输入内容的指针,这里也就是字符c的地址,最后一个参数是1,代表读入一个字符。read函数的返回值是读入的字符数,如果为1说明读入成功,那么直接返回字符,否则说明读到了buf的最后。
read函数同样通过sys_call中断来调用内核中的系统函数。键盘中断处理子程序会接受按键扫描码并将其转换为ASCII码后保存在缓冲区。然后read函数调用的系统函数可以对缓冲区ASCII码进行读取,直到接受回车键返回
8.5本章小结
Linux 将 I/O 输入都抽象为了文件,并提供 Unix I/O 接口。通过这个接口,程序能够进行输入与输出,只需要直到描述符,底层硬件实现操作系统就可以实现。Linux 本身提供的一些系统函数已经实现了对底层的调用,例如 write 函数。printf函数正是通过它间接向标准输出这个文件输出内容,它会调用 syscall 触发中断以内核模式对硬件进行操作。
有了 I/O 接口与文件这个抽象,应用程序能够很方便的调用底层,对输入与输出设备进行操作

结论

1.预处理,C语言编译器对各种预处理命令进行处理,包括对头文件的包含,宏定义的扩展,条件编译的选择等,将hello.c转换为hello.i。
2.编译,将C语言文件hello.i翻译为汇编语言文件hello.s
3.汇编,将汇编语言代码文件hello.s转换为可重定位目标文件hello.o
4.链接,将多个可重定位目标文件hello.o、libc.a等经过符号解析和重定位结合,形成一个具有统一地址空间的可执行目标文件hello
5.运行,在shell下输入命令./hello 1170300916 pyx,shell解析命令并构造参数列表
6.Fork子进程,shell调用fork创建一个子进程,具有和父进程完全相同的虚拟存储空间备份
7.加载,shell将构造好的参数列表传给execve作为参数,启动加载器并开始执行hello的第一条指令,实现在当前进程上下文中运行hello,
8.执行,cpu通过上下文切换分配时间片
9.访存,hello程序运行中需要的代码和数据,通过虚拟地址在TLB和主存页表中查找转换为相应物理地址,再在cache和主存中读取
10.异常处理,如果通过键盘输入导致外部中断,相应异常处理程序发送信号给进程,进程调用相应信号处理函数
11.回收,shell父进程回收进程,内核删除为这个进程创建的所有数据结构。
(结论0分,缺失 -1分,根据内容酌情加分)

附件

hello.i : hello.c预处理结果,研究预编译的作用以及进行下一步编译操作。
hello.s : hello.i编译之后的结果,用于研究汇编语言以及编译器的汇编操作。
hello.o : hello.s汇编之后的结果,可重定位的目标文件,用于连接器或编译器链接生成最终的可执行程序。
hello:链接之后的可执行文件。
asm.s:对可执行目标文件反汇编得到。可用来分析链接过程与寻址过程。
hello.o.s:对可重定位的目标文件反汇编得到,可以与asm.s对比进行分析链接过程。