程序人生-Hello’s P2P

第一章 概述

1.1 Hello 简介

1.1.1 P2P(Program to Process,从程序到进程)

P2P指Hello.c从源程序到进程的过程。

Hello.c经过预处理器的编译预处理,得到预编译文件Hello.i;Hello.i又经过编译转换为目标文件Hello.o;Hello.o又经过汇编器翻译为机器语言命令汇编文件Hello.s;最后经过链接器链接得到可执行文件Hello;Hello经过运行产生进程。

图1-1,编译系统

1.1.2 020 (zero to zero,从0到0)

020指可执行文件Hello产生的进程从进入内存到从内存被回收的过程。

子进程由父进程fork()产生成为父进程的副本,随着execve函数的执行,可执行目标文件hello被加载并运行,新程序由此开始;随着进程的进行,进程由于某种原因终止而变为僵死进程,最后又父进程或养父进程回收,到此进程被从系统中删除所有痕迹。

1.2 环境与工具

1.2.1 硬件环境

x64 CPU;1.60GHz;8G RAM;256GHD Disk。

1.2.2 软件环境

Windows10 64位。

1.2.3 开发工具

VM VirtualBox 6.1;Ubuntu 20.04 LTS 64位;

Visual Studio 2019 64位;CodeBlocks 17.12 64位;vi/vim/gedit+gcc。

1.2.4 调试工具

gcc;gdb;edb;hexedit。

1.3 中间结果

hello.chello 的C源文件
hello.ihello.c经过预处理后的预编译文本文件
hello1.ihello.c经过加上-P选项的预处理屏蔽垃圾内容后的预编译文本文件。hello.s :hello.i经过编译得到的汇编语言文本文件

hello.s

hello.i经过编译得到的汇编语言文本文件

hello.ohello.s经过汇编得到的机器语言二进制文件(可重定位目标文件)
hellohello.o经过链接得到的机器语言二进制文件(可执行目标文件)
hello_objdump.txthello经过反汇编得到的机器语言与对应反汇编语言的文本文件

gdb.txt

gdb中用于在hello每个函数上加断点的辅助文件

breakpoints.txt

记录gdb中所有断点

1.4 本章小结

hello的一生经历了P2P和020的过程,在中间有很多层次(程序和进程)和中间过程(预处理、编译、汇编、链接等),在进程中含有很多抽象。让我们在接下来的几章中详细了解他们。


第二章 预处理

2.1 预处理的概念与作用

概念:预处理(Pre-treatment),指在程序源代码被翻译为目标代码的过程中,汇编之前对源程序替换修改的过程。典型地,由预处理器(Pre-processor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。

作用:预处理将以#开头的行解释为预处理指令,读取相应文件代题#开头行插入到程序文本,得到以.i为文件扩展名的预编译文件。其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令) 。

预处理可以令程序在预处理阶段插入系统或其他头文件(文件包含),将用宏替换相同的变量(宏定义),也可以按不同情况编译不同代码(条件编译),甚至可以阻止编译进行(错误指令)等,便于程序的修改、阅读、调试和移植,也便于实现模块化程序设计。

2.2 在Ubuntu下预处理的命令

待预处理文件文件以hello.c为例:

使用命令gcc -E hello.c -o -hello.i (或gcc -E - hello.i)进行预处理

图2-1,预处理命令

图2-2,预处理结果

也可以使用 gcc -E -P hello.c -o hello.i (或gcc -E -P - hello.i)进行预处理,并屏蔽垃圾内容。

图2-3,-P预处理命令

图2-4,-P预处理结果

可以看到加了-P的指令自动删除了hello.i中头部的没有意义的不合乎代码规范的垃圾内容(即# 1 “XXX”...),并删除了hello.i中的提示字符串,然后删除了其中无用的空行。

2.3 Hello的预处理结果解析

查看预处理结果我们发现,文件是一个文本文件,文件内容仍然为C语言,但是内容量非常多,加了-P参数的hello.i一共有近两千行,而不带-P的有三千行,在结尾有我们的源程序代码,并没有被修改。这是因为我们在hello.c头部引入了头文件stdio.h、unistd.h和stdlib.h,预处理器将他们替换为系统文件中的内容,这样虽然我们的程序中没有写系统文件的内容,我们仍然能在预处理后经过预处理器的替换而得到这些函数,在编译后能够为我们所用,而我们不需要在源程序中写这些大量的复杂的系统函数。

图2-5,预处理结果分析(可以看到结尾有源程序)

2.4 本章小结

预处理是编译系统进行的第一步程序,他在编译器编译前进行操作:首先进行条件编译和错误指令;若无问题则将源文件进行修改,替换掉其中的目标头文件和定义的宏。

预处理提供了编译前处理的手段,和在程序中利用外部系统或其他头文件的办法。


第3章 编译

3.1 编译的概念与作用

编译(Compilation),指在程序源代码被翻译为目标代码的过程中,程序源文件被预处理器修改成为的预处理文件被转换为汇编程序的过程。编译器(Compiler)将与预处理文件中的高级语言以文本格式转换为汇编语言,得到一个用汇编语言描述程序的文本文件。

注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序

     

3.2 在Ubuntu下编译的命令

使用命令 gcc -S hello.i -o hello.s 进行编译

图3-1,编译命令

3.3 Hello的编译结果解析

3.3.1数据:

3.3.1.1 字符常量

图3-2,字符常量对应汇编语言

分别为两个字符串提示符,放在静态存储区,不可改变,且一直存在。

图3-3,字符常量在源程序中的位置

3.3.1.2 变量

(1)局部变量i。只在初次使用时赋值,储存在栈中,当所在函数返回, 栈会被复原,局部变量在栈中所占的空间也会被释放。

图3-4,局部变量对应汇编语言
图3-5,局部变量在源程序中的位置

(2)函数参数。即形式参数,实际为局部变量,只在所调用的函数中起作用,函数调用开始时储存在栈中,当函数返回所占空间被释放。

图3-6,函数参数对应汇编语言

main将argc和argv作为局部变量存储在栈中(argc为main函数命令行参数个数,argv为存放参数字符串指针的数组)

3.3.1.3 表达式

C语言的表达式分为很多中,其中包括:变量常量表达式,算数表达式,赋值表达式,逗号表达式,关系表达式,逻辑表达式,复合表达式。

其中变量常量表达式我们在上面已经讲述完文件中的变量和常量部分,由于复合表达式是其他表达式的复合情况,我们不需要讲述。所以我们只讲述其他5个存在的表达式。

(1)算数表达式

即类似a+b或i++形式的使用运算符(一元或二元)将操作数连接起来的 的表达式。

我们的程序中的i++每次循环自增1:

图3-7,自增1算术表达式对应汇编语言

每次循环结束编译器使用ADD指令为储存在栈中的局部变量i增加1。

图3-8,自增1算术表达式在源程序中位置

(2)赋值表达式

编译器使用MOV类指令将一个值赋给另一个地址:

图3-9,赋值表达式对应汇编语言
图3-10,赋值表达式在源程序中位置

(3)关系表达式

编译器使用CMP对两个地址的值进行对比,然后根据比较结果使用jump指令跳转到相应地址。

图3-11,argc参数个数关系表达式对应汇编语言
图3-12,循环i关系表达式对应汇编语言
图3-13,关系表达式在源程序中的位置

3.3.2 赋值

对于常量、全局变量和静态变量,编译器在程序开始时就已经赋值;对于局部变量,程序只在初次使用时赋值。(见3.3.1.3(2)赋值表达式)

3.3.3 算术操作

(见3.3.1.3(1)算术表达式)

3.3.4 关系操作

(见3.3.1.4(3)关系表达式)

3.3.5 控制转移

编译器使用jump指令进行跳转转移,一般为判断或循环进行分支操作时,由于不同的逻辑表达式结果导致程序执行不同的代码,编译器使用CMP指令更新条件码寄存器后,使用相应的jump指令跳转到存放对应代码的地址。

图3-14,hello.s中的控制转移1
图3-15,hello.s中的控制转移2
图3-16,hello.s中的控制转移3

3.3.6 数组操作

argv为字符串指针数组,其中存储着命令行内输入的4个参数的字符串的指针。

我们寻找数组内元素的地址,由于argv被存储在-32(%rbp),而64位编译下指针大小为8个字节,于是每个参数字符串地址相差8个字节。编译器以数组起始-32(%rbp)为基地址,以偏移量为索引,寻找各个字符串指针的地址。即-32(%rbp)+8,-32(%rbp)+16,-32(%rbp)+24。

图3-17,hello.s中的数组操作对应汇编语言

3.3.7 函数操作

3.3.6.1 参数传递

调用函数之前,编译器会将参数存储在寄存器中,方便调用的函数使用。

当函数参数个数不大于6个时,按照以下优先级顺序使用寄存器传递参数:rdi,rsi,rdx,rcx,r8,r9;当参数个数大于6个时,前六个参数使用寄存器存放,其他参数压入栈中存储。

我们的程序中多处调用了函数,

例如当参数不足时打印提示信息,将提示字符串产量赋给rdi寄存器,调用puts函数输出。

图3-18,调用printf传递参数对应汇编语言

再如将参数转为整形时,使用相对寻址得到参数字符串地址分别传递给rdi,rsi,rdx,然后调用函数atoi进行转化。

图3-19,调用atoi传递参数对应汇编语言

3.3.6.2 函数调用

程序使用汇编指令call+函数地址调用函数,在hello.o中使用函数名作为助记符代替由于没有重定位而无法得知的函数地址。call将返回地址压入栈中,为局部变量和函数参数建立栈帧,然后转移到调用函数地址。

图3-20,函数调用

3.3.6.3 函数返回

程序使用汇编指令ret从调用的函数中返回,还原栈帧,返回栈中保存的返回地址。

图3-21,函数返回

3.4 本章小结

编译过程中,编译器会将高级语言翻译为对应的汇编代码,针对不同的数据和操作使用不同的汇编指令,为转变为机器语言做准备。


第4章 汇编

4.1 汇编的概念与作用

汇编,指汇编器(Assembler)把汇编语言翻译成机器语言的过程中,将汇编语言文本文件转变为二进制可重定位目标文件。可重定位文件可以经过重定位和链接与其他可重定位文件合并,创建可执行目标文件。

注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。

4.2 在Ubuntu下汇编的命令

gcc -c hello.s -o hello.o

图4-1,汇编命令

4.3 可重定位目标elf格式

分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。

目标文件(Object file)有三种形式(可重定位目标文件,可执行目标文件,共享目标文件),而hello.o是一个可重定位目标文件(Relocatable file),但仍然不能运行。汇编器在翻译汇编语言的过程中,会按照特定的ELF格式,将程序中的模块组织起来。ELF格式如下图所示:

图4-2,可重定位布标文件ELF格式

4.3.1 ELF Header

使用readelf -h hello.o 查看ELF文件头:

图4-3,hello.o的ELF文件头

在 readelf  -h的输出中:
第1行,ELF 头: 指名ELF文件头开始。
第2行,Magic用来指名该文件是一个 ELF 目标文件。第一个字节7F是个固定的数;后面的 3 个字节正是 E, L, F 三个字母的 ASCII 形式。
第3行,表示文件类型,ELF64指这个文件是64位的ELF格式。
第4行,表示文件中的数据是按照什么格式组织(大端或小端)的,不同处理器平台数据组织格式可能就不同,如x86平台为小端存储格式。
第5行,当前 ELF 文件头版本号,这里版本号为1 。
第6行,OS/ABI,指出操作系统类型,ABI是Application Binary Interface 的缩写。
第7行,ABI 版本号,当前为0 。
第8行,表示文件类型。ELF 文件有 3 种类型,一种是如上所示的Relocatable file 可重定位目标文件,一种是可执行文件(Executable),另外一种是共享库(Shared Library) 。
第9行,机器平台类型。
第10行,当前目标文件的版本号。
第11行,程序的虚拟地址入口点,因为这还不是可运行的程序,故而这里为零。
第12行,程序头起点位置,与 11 行同理,这个目标文件没有程序头。
第13行,节头开始处,这里 1240 是十进制。
第14行,是一个与处理器相关联的标志,x86 平台上该处为0。
第15行,ELF 文件头的字节数。
第16行,程序头大小,因为这个不是可执行程序,故此处大小为0。
第17行,程序头数量,同理于第 16 行。
第18行,节头的大小,这里每个节头大小为64个字节。
第19行,一共有多少个节头,这里是14个,与Section Headers中的数量一致。
第20行,节头字符串表索引号。

4.3.2 Section Headers

使用readelf -S (-W) hello.o,查看节头部表,可以了解各节的基本信息

图4-4,hello.o的节头部表

对象文件中的重定位条目,会构成一个个单独的节。这些节的名字,常是".rel.XXX"或".rela.XXX"的形式。其中XXX表示的是这些重定位条目所作用到的节名,如 .text 。重定位条目所构成的节(如.rela.text)需要和另外两个节产生关联:符号表节(.symtab,表示要重定位的是哪一个符号)以及受影响地址单元所在的节(如.text)。

 4.3.3 .symtab

使用readelf -s hello.o查看符号表节信息

图4-5,hello.o的符号表节

符号表存放在程序中定义和引用的函数和全局变量的信息,不包含局部变量的条目。符号表表示了重定位的所有符号。

4.3.4 .rela.XXX

使用readelf -r hello.o 查看重定位信息

图4-6,hello.o的重定位信息

可以看到.rela.XXX中的重定位信息和.symtab中的信息对应,但.rela.XXX的来源不只有一个节(这里为.rela.text和.rela.eh_frame)

另外,使用readelf x.XXX hello.o 可以看到XXX节内的信息,但内容可读性不好,故使用readelf -r hello.o查看重定位信息。这里仅以查看rela.text的信息举例:

图4-7,hello.o的rela.text节信息

4.4 Hello.o的结果解析

objdump -d -r hello.o  分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。

说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。

我们可以使用objdump -d -r hello.o 对hello.o进行反汇编,得到机器代码对应的反汇编代码。

图4-8,hello.o反汇编信息

我们可以看到左边为十六进制机器指令,而右边为对应的汇编指令。通过与我们的原汇编文件hello.o对比我们可以发现大体上二者指令内容相同,主要有几点区别:

(1)伪指令:汇编代码中的前后有着指导汇编器和连接器的伪指令(以.开头的如.file等),而反汇编代码只有开头的文件格式说明。

(2)分支转移:反汇编代码没有使用助记符(如.L1)进行转移,而是使用如je 2f的直接地址跳转。只有汇编代码能够使用助记符(如MOV,.L1等)帮助我们理解指令内容,但机器必须将其翻译为二进制机器指令才能识别。

(3)函数调用:反汇编代码进行函数调用时与汇编代码一样使用callq(e8后放偏移地址),但在反汇编代码中地址显示的是下一行指令的地址,在下一行提示了我们在重定位信息中看到过的符号+加数信息,而汇编代码直接使用函数名称作为助记符。这是因为汇编代码没有进行重定位,函数调用时并不知道函数的位置,于是使用00 00 00 00代替,而e8后接的四个字节表示偏移地址,于是该条指令显示为跳转到下一条指令。

(4)全局变量:反汇编代码使用0x0(%rip)寻找全局变量的地址,而汇编代码直接引用助记符(如.LC0)。在反汇编的指令下面我们可以看到重定位信息,我们自然可以猜到这与重定位有关。我们的两个字符串提示符放在静态存储区,属于全局变量,需要进行重定位,而反汇编代码不知道位置于是暂时使用偏移0x0代题,%rip+0x0即下一条指令的开头。

4.5 本章小结

汇编过程中汇编器将汇编代码翻译为机器指令,生成可重定位目标文件,但该文件仍然不能运行,其中可能有外部声明的变量或函数,需要与其他文件一起生成可执行的文件。可重定位目标文件按照ELF格式组织,方便我们后续与其他可重定位目标文件进行链接,生成可执行目标文件。


5链接

5.1 链接的概念与作用

链接(linking)是链接器将一个或多个可重定位目标文件(包括特殊的共享目标文件)通过符号解析和重定位生成可执行目标文件的过程。链接包括静态链接和加载时共享库的动态链接。

注意:这儿的链接是指从 hello.o 到hello生成过程。

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 /usr/lib/x86_64-linux-gnu/crtn.o /usr/lib/x86_64-linux-gnu/libc.so hello.o

进行链接

图5-1,链接命令

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

图5-2,可执行目标文件ELF格式

5.3.1 ELF Header

图5-3,hello的ELF头

与hello.o的ELF头对比可以发现,首先类型变成了EXEC(可执行文件),并且在hello的ELF头中大部分在hello.o的ELF头中暂时被填为0的条目被重新填充了,且于是各起始地点也相应向后移动了。

例如:

第11行,程序的虚拟地址入口点,被改为0x4010f0。
第12行,程序头起点,被改为64。
第13行,节头开始处,被改为14208。
第15行,ELF 文件头的字节数被改为64。
第16行,程序头大小被改为56。
第17行,程序头数量被改为12。
第18行,节头的大小,这里每个节头大小为64个字节。
第19行,节头增加了13个。
第20行,节头字符串表索引号,同样增加了13个。

5.3.2 Program Header

使用 readelf -l hello查看程序头表

图5-4,hello的程序头表

可执行文件的连续的片(chunk)被映射到连续的内存段(segment),程序头部表描述了这种映射关系:

对比图5-2以及程序头部表图5-4,我们可以发现可执行文件被映射为12个段。

(1)上边的程序头描述了段的目标文件中的偏移(Offset),内存地址(VirAddr/PhyAddr),目标文件中的段大小(filesz),内存中的段大小(memsz),访问权限(Flags)(R:可读/W:可写/E:可执行),对齐要求(Align)。

我们可以得到各个段的信息,比如:

索引号为02的段LOAD(代码段),有读/执行访问权限,开始于内存地址0x400000处,总共的内存大小为0x5c0字节,并且被初始化为可执行目标文件的头0x5c0个字节,进行2^(12)=4096字节对齐。(其他各个段类似可以得到相应信息)

(2)下边的段节标注了对应索引号的段所映射的节。

索引号为02的程序头LOAD,对应映射的节有:.interp , .note.gnu.property , .note.ABI.tag , .hash , .gnu.hash , .dynsym , .dynstr , .gnu.version_r , .rela.dyn , .rela.plt 。(其他各个段类似可以得到相应信息)

5.3.3 .symtab

使用readelf -s hello 查看符号表

图5-5,hello的符号表

符号表中,原来的函数名被替换为XXX_GLIBC_2.2.5,说明其在某个库中找到了该符号的定义;但与hello.o的符号表不同的是,多了一个节.dynsym。

.dynsym是动态符号表(Dynamic Symbol),由于我们在链接时链接了共享库(-dynamic-linker /lib64/ld-linux-x86-64.so.2),.dynsym节保存了动态链接相关的导入导出符号,不包括模块内部的符号。.dynsym节为.symtab节的子集,并且其内部的符号都没有初始化,这是因为动态链接将连接过程推迟到加载或运行时再加载进内存并链接,于是.dynsym节中的符号都没有初始化。

.symtab节中除了来自.dynsym的多出来符号,还有一些符号,他们来自其他的可重定位目标文件(.o)。

5.3.4 重定位信息

使用readelf -r hello查看重定位信息

图5-6,hello的重定位信息

与5.3.3类似,多出一个节.rela.dyn,与动态链接有关,连接器在动态链接时在共享库/lib64/ld-linux-x86-64.so.2中找到函数定义,并且等待运行在加载或运行时再加载进内存并链接,故没有符号值。其他符号已经重定位完成,从重定位信息中删除。

在静态链接中有专门用于表示重定位信息的重定位表,即代码段重定位表.rela.text及数据段重定位表.rela.data,而在动态链接中既然也需要重定位那么当然也有重定位表,即.rela.dyn及.rela.plt,其中.rela.dyn是对数据引用的修正,用于修正.got及数据段;.rela.plt是对函数引用的修正,用于修正.got.plt。

5.3.5 Section Headers

使用readelf -S (-W) hello查看节头部表

图5-7,hello的节头部表

相比hello.o的节头信息(图4-4)多了一些节,如5.3.3和5.3.4所述,这新增的些节包括动态符号表等与链接有关的节。

5.4 hello的虚拟地址空间   

5.4.1 虚拟内存地址中查看各段

通过我们在5.3.3中分析的各段信息,再结合我们hello在edb中的虚拟地址空间,我们可以查看各段和节在虚拟地址空间中的对应位置。

(1)首先可以看到虚拟地址从0x400000开始到0x4005a0,这与我们分析的LOAD段(代码段)起始与大小相对应。(02索引号的LOAD起始位置0x400000,大小0x5a0),如图:

图5-8,02索引LOAD段上半段
图5-9,02索引LOAD段下半段

(2)03索引号的LOAD起始位置0x401000,大小0x245,如图:

图5-10,03索引LOAD段

注意这里段的结尾需要对齐,而不是我们看到的有效信息的结尾位置。比如LOAD为0x1000对齐,则段起始和结尾位置为0x400000到0x401000。

其他各段用这种方式都可以得到验证,其中程序头图中前两个段与动态链接有关,PHDR指定程序头表在文件及程序内存映像中的位置和大小;INTERP指定要作为解释程序调用的以空字符结尾的路径名的位置和大小。PHDR与LOAD段重合,两者都可读,但只有LOAD段是可执行的,由于多个节可以映射到同一个段,段可以有重合部分,但不能有多个段对同一个节进行写。

我们查看INTERP段的信息,发现确实是动态链接信息:

图5-11,INTERP段

5.4.2edb中查看各段

在edb中view -> Memory Regions下可以看到段信息

图5-12,edb中Memory Regions查看段信息

通过与程序头图5-4对比发现二者类似但也有差别。

例如索引号为02的LOAD段,虽然大小为0x5c0字节,但由于0x1000对齐,段结束位置为0x401000,而不是0x4005c0,开始位置为0x400000。

由于只有LOAD可以装载进程序,于是进程的段只有LOAD对应的段,以0x1000字节对齐。其余的段除了安全相关的辅助段以及与栈有关的段之外,剩下的是名字以.so结尾的段,由于动态链接需要在加载或执行程序时载入,故有这些段。

图5-13,虚拟内存映像

将各段的虚拟内存地址与内存映像对比,可以发现合理性。即载入的段从0x400000开始向向生长;与用户栈有关的段与动态链接有关的段地址较大,分别映射到图中的“用户栈(运行时创建)”和“共享库的内存映射区域”。

5.5 链接的重定位过程分析

objdump -d -r hello查看反汇编结果发现反汇编结果有很多内容,它按节(如.init)组织函数(_init),并进行反汇编。

图5-14,hello反汇编内容

通过与hello.o的反汇编结果对比我们发现,在hello的反汇编代码中我们在4.4中分析的hello.o反汇编代码的特点有所改变,主要有两点:

(1)函数调用:hello反汇编代码进行函数调用时直接使用虚拟内存地址,而hello.o反汇编代码由于没有重定位使用0代替。

图5-15,hello反汇编使用虚拟内存地址调用函数
图5-16,hello.o反汇编由于没有重定位使用0代替

(2)全局变量:hello.o反汇编代码使用0x0(%rip)寻找全局变量的地址,hello反汇编代码由于重定位找到了全局变量位置(%rip)前面是有意义的偏移。

图5-17,hello反汇编使用有意义的偏移寻址全局变量
图5-18,hello.o反汇编由于没有重定位使用0代替偏移进行寻址

(3)虚拟内存地址:hello反汇编代码每行命令左边有对应的虚拟内存地址位置,而hello.o使用数字作为相对位置。

图5-19,hello反汇编每行指令和函数有虚拟内存地址
图5-20,hello.o反汇编函数没有地址,指令行使用相对偏移量标记相对位置

5.6 hello的执行流程

使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。

gdb中打开hello,在所有函数处断点,运行程序,记录从开始到程序终止调用的所有函数(共享库内函数太多,这里只列非共享库的函数,调用的共享库函数在breakpoints.txt中):

_start ()
__libc_csu_init ()
_init ()
main ()
printf@plt ()
atoi@plt ()
sleep@plt ()
getchar@plt ()
_fini ()

5.7 Hello的动态链接分析

对于一个动态链接来说,操作系统映射完ELF文件中的Segment并且将所有需要的共享对象全部装载并映射完成后,需要把控制权交给动态链接器,由其进行链接,然后再将控制权交给ELF文件的入口地址。

我们在前面说到动态链接在加载或执行程序时进行,我们的重定位符号表中还有一部分符号没有重定位(5.3.3和5.3.4),而我们在程序中又是用到了这些符号,说在加载时dl_init对这些符号进行了重定位。

5.8 本章小结

链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。


6hello进程管理

6.1 进程的概念与作用

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础

6.2 简述壳Shell-bash的作用与处理流程

在计算机科学中,Shell俗称壳(用来区别于核),是指"为使用者提供操作界面"的软件(命令解析器)。它类似于DOS下的command.com和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。

shell负责确保用户在命令提示符后输入的命令被正确执行。其功能包括:
(1) 读取输入并解析命令行
(2) 替换特别字符,比如通配符和历史命令符
(3) 设置管道、重定向和后台处理
(4) 处理信号
(5) 程式执行的相关设置

处理流程:

  1. Shell首先从命令行中找出特殊字符(元字符),在将元字符翻译成间隔符 号。元字符将命令行划分成小块tokens。Shell中的元字符如下所示: 
    SPACE , TAB , NEWLINE , & , ; , ( , ) ,< , > , | 
    2.程序块tokens被处理,检查看他们是否是shell中所引用到的关键字。 
    3.当程序块tokens被确定以后,shell根据aliases文件中的列表来检查命令 的第一个单词。如果这个单词出现在aliases表中,执行替换操作并且处理过程 回到第一步重新分割程序块tokens。

4.Shell对~符号进行替换。 
5.Shell对所有前面带有$符号的变量进行替换。 
6.Shell将命令行中的内嵌命令表达式替换成命令;他们一般都采用$(command) 标记法。 
7.Shell计算采用$(expression)标记的算术表达式。 
8.Shell将命令字符串重新划分为新的块tokens。这次划分的依据是栏位分割 符号,称为IFS。缺省的IFS变量包含有:SPACE , TAB 和换行符号。 
9.Shell执行通配符* ? [ ]的替换。 
10.shell把所有从处理的结果中用到的注释删除,並且按照下面的顺序实行命 令的检查: 
    A.内建的命令 
    B. shell函数(由用户自己定义的) 
    C.可执行的脚本文件(需要寻找文件和PATH路径) 
11.在执行前的最后一步是初始化所有的输入输出重定向。

12.最后,执行命令。 

6.3 Hello的fork进程创建过程

父进程通过调用fork函数创建一个新的运行的子进程。

新创建的子进程几乎但不完全与父进程相同,子进程会获得与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆共享库以及用户栈。

在父进程创建新的子进程时,子进程还会获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork函数时,子进程可以读写父进程中打开的任何文件。父进程和子进程之间的最大区别在于它们有不同的PID。

fork函数是有趣的(也是常常令人迷惑的),因为它只被调用一次,却会返回两次:一次是在调用进程(父进程)中返回,返回值是新建的子进程的PID;一次是在新建的子进程中返回,返回值是0。因此总是可以利用fork的返回值来判断程序是在父进程还是子进程中执行。

图6-1,fork过程示例

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序。

int execve(char *filename, char *argv[], char *envp[])

execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp.只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次,execve调用一次并从不返回。

在当前进程中载入并运行程序: loader加载器函数,loader删除子进程现有的虚拟内存段,创建一组新的段(栈与堆初始化为0),并将虚拟地址空间中的页映射到可执行文件的页大小的片chunk,新的代码与数据段被初始化为可执行文件的内容,覆盖当前进程的代码、数据、栈,保留有相同的PID,继承已打开的文件描述符和信号上下文。loader结束后,将控制传递给新程序的主函数:

int main(int argc, char **argv, char *envp);

argv的在内存中组织方式如下图:

图6-2,argv组织方式

envp的在内存中组织方式如下图:

图6-3,encp组织方式

环境变量的列表是由一个和指针数组类似的数据结构表示,envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量串,其中每个串都是形如“NAME=VALUE”的键值对。

用户栈的结构组织结构如图:

图6-4,用户栈的组织结构

6.5 Hello的进程执行

在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。

进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context) 中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

每次用户通过向shell 输人一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。

6.5.1 逻辑控制流

即使在系统中通常有许多其他程序在运行, 进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序, 我们会看到一系列的程序计数器(PC) 的值, 这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC 值的序列叫做逻辑控制流,或者简称逻辑流。

图6-5,逻辑控制流

6.5.2时间片与时间分片

一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行。更准确地说,流X 和Y 互相并发,当且仅当X 在Y 开始之后和Y结束之前开始, 或者Y 在X 开始之后和X 结束之前开始。

多个流并发地执行的一般现象被称为并发( concurrency )。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。

6.5.3用户模式和内核模式

为了使操作系统内核提供一个无懈可击的进程抽象, 处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。

处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。

没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个I / O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。

运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。

Linux 提供了一种聪明的机制,叫做/ proc 文件系统,它允许用户模式进程访问内核数据结构的内容。/ proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你可以使用/ proc 文件系统找出一般的系统属性,比如CPU 类型(/proc/cpuinfo) ,或者某个特殊的进程使用的内存段(/ proc / < process-id > /maps)。2.6版本的Linux 内核引人/sys 文件系统,它输出关于系统总线和设备的额外的低层信息。

6.5.4 上下文切换

操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。

内核为每个进程维持一个上下文。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling) ,是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2) 恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。

当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换, 运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是系统调用,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。

中断也可能引发上下文切换。比如,所有的系统都有某种产生周期性定时器中断的机制,通常为每1毫秒或每10毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。

如图6-6展示了一对进程A 和B 之间上下文切换的示例。在这个例子中, 进程A 初始运行在用户模式中,直到它通过执行系统调用read 陷入到内核。内核中的陷阱处理程序请求来自磁盘控制器的DMA 传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。

图6-6,上下文切换示例

磁盘取数据要用一段相对较长的时间(数量级为几十毫秒),所以内核执行从进程A 到进程B 的上下文切换,而不是在这个间歇时间内等待,什么都不做。注意在切换之前,内核正代表进程A 在用户模式下执行指令(即没有单独的内核进程)。在切换的第一部分中,内核代表进程A 在内核模式下执行指令。然后在某一时刻,它开始代表进程B(仍然是内核模式下)执行指令。在切换之后,内核代表进程B 在用户模式下执行指令。

随后,进程B 在用户模式下运行一会儿,直到磁盘发出一个中断信号, 表示数据已经从磁盘传送到了内存。内核判定进程B 已经运行了足够长的时间, 就执行一个从进程B 到进程A 的上下文切换, 将控制返回给进程A 中紧随在系统调用read 之后的那条指令。进程A 继续运行, 直到下一次异常发生, 依此类推。

6.6 hello的异常与信号处理

 hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。

6.6.1 异常(Exceptions)

异常是指为响应某个事件将控制权转移到操作系统内核中的情况。

异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。

图6-7,异常种类

(1)中断

中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。

例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放在总线上,来触发中断,这个异常号标识了引起中断的设备。

图6-8,中断处理过程

在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,他就将控制返回给下一条指令。结果是程序继续执行,好像什么也没发生一样。

(2)陷阱

陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。

用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊"syscall n” 指令,当用户程序想要请求服务n时,可以执行这条指令。执行syscall 指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。

图6-9,陷阱处理过程

(3)故障

故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时, 处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort 例程,例程会终止引起故障的应用程序。

图6-10,中断处理过程

(4)终止

终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM 或者SRAM 位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。

图6-11,终止处理过程

6.6.2 信号(Signal)

一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。

每种信号类型都对应于某种系统事件,底层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。

信号种类

图6-12,信号种类

传送一个信号到目的进程是由两个不同步骤组成的:

· 发送信号。内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。发送信号可以有如下两种原因:

  1. 内核检测到一个系统事件,比如除零错误或者子进程终止。
  2. 一个进程调用了kill 函数,显式地要求内核发送一个信号给目的进程。一个进程可以发送信号给它自己。

· 接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时, 它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(Slgnal handler)的用户层函数捕获这个信号。

图6-13,信号接收并处理过程

Linux异常对应信号及处理程序:

图6-14,Linux异常对应信号及处理程序

6.6.3 异常和信号处理

(1)不停乱按

图6-15,程序执行时乱按键盘

我们按什么按键屏幕会输出什么按键,但进程不停止并一直继续执行,隔一段时间在我们的输入后面输出。进程结束后,bash处理我们乱按输入的信息。

(2)Ctrl-C

图6-16,Cirl C程序终止

进程终止。由于我们从键盘输入Ctrl-C,bash向进程发送信号SIGINT,进程终止。输入jobs(查看当前进程)看不到我们的hello进程。

(3)Ctrl-Z

图6-17,Cirl Z程序暂停

进程被暂停。由于我们从键盘输入Ctrl-Z,bash向进程发送信号SIGTSTP,进程停止。

使用ps t,可以看到终端进程的状况,我们的hello进程状态为T,表示已停止,还能看到进程的PID。

图6-18,查看进程状况

使用pstree可以查看所有进程树状图显示。

图6-19,查看进程树状图

其中可以找到我们的hello进程。

图6-20,树状图中发现hello

使用fg %1再次激活hello进程,使其到前台运行。

图6-21,hello被再次启动,又被暂停

使用kill -9 PID 向进程发送信号SIGKILL杀死进程hello。

图6-22,hello进程被杀死

6.7 本章小结

 进程是执行中的程序的实例,系统通过使异常控制流发生突变对系统状态变化做出反应,异常控制流发生在计算机系统的各个层次,他们包括异常处理程序、上下文切换、信号和非本地跳转。


7hello的存储管理

7.1 hello的存储器地址空间

(1)逻辑地址:是由程序产生的由段选择符和段内偏移地址组成的地址。

在hello中即为通过偏移相对寻址的例如前面的0x12(%rip)寻找静态提示字符串,又如利用指针进行寻址等。

(2)线性地址(虚拟地址):CPU在保护模式下,“段基址+段内偏移地址”叫做线性地址,注意,保护模式下段基址寄存器中存储的不是真正的段基值(和实模式的含义不一样),而是被称为“段选择子”的东西,通过段选择子在GDT(全局描述表)中找到真正的段基值。另外,如果CPU在保护模式下没有开启分页功能,则线性地址就被当做最终的物理地址来用,若开启了分页功能,则线性地址就叫虚拟地址(在没开启分页功能的情况下线性地址和虚拟地址就是一回事)。但是,如果开启分页功能,虚拟地址(或线性地址)还要通过页部件电路转换成最终的物理地址。

在hello中即为虚拟内存映像中的虚拟地址。

(3)物理地址:物理地址就是内存单元的绝对地址。

即我们使用CPU外部地址总线上的寻址物理内存的地址信号。

图7-1,各种地址翻译过程

7.2 Intel逻辑地址到线性地址的变换-段式管理

Linux为每个进程维护了一个单独的虚拟地址空间,包括那些熟悉的代码、数据、堆、共享库以及栈段。

图7-2,虚拟地址空间

我们使用逻辑地址,保存着段选择符和段内偏移地址,我们想要够访存到一个数据,就需要按照段和偏移找到其虚拟地址,然后如果需要再将虚拟地址转为物理地址,如果不需要则直接访问。

虚拟地址(VA) = 段基地址(BA) + 段内偏移量(S)

7.3 Hello的线性地址到物理地址的变换-页式管理

7.3.1 虚拟内存到物理内存映射

概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。VM 系统通过将虚拟内存分割为称为虚拟页(Virtual Page ,VP)的大小固定的块来处理这个问题。每个虚拟页的大小为P =2^p字节。类似地,物理内存被分割为物理页(PhysicaI Page,PP) ,大小也为P 字节(物理页也被称为页帧(page frame))。

在任意时刻,虚拟页面的集合都分为三个不相交的子集:

· 未分配的:VM 系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。

· 缓存的:当前已缓存在物理内存中的已分配页。

· 未缓存的:未缓存在物理内存中的已分配页。

图7-3,一个VM系统如何使用主存作为缓存

如图7-4展示了一个页表的基本组织结构。页表就是一个页表条目(Page Table Entry,PTE) 的数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。为了我们的目的,我们将假设每个PTE 是由一个有效位(valid bit)和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM 中。如果设置了有效位,那么地址字段就表示DRAM 中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位, 那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。

图7-4,页表

7.3.2 虚拟地址到物理地址翻译

如图7-5展示了MMU如何利用页表来实现虚拟地址空间(VAS)到物理地址空间(PAS)的映射。CPU 中的一个控制寄存器,页表基址寄存器(Page Table Base Reglster,PTBR)指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(Virtual Page Offset,VPO) 和一个(n-p)位的虚拟页号(Virtual Page Number,VPN)。MMU利用VPN来选择适当的PTE。

图7-5,使用页表的地址翻译

7.4 TLB与四级页表支持下的VA到PA的变换

二级页表中的每个PTE 都负责映射一个4KB 的虚拟内存页面,就像我们查看只有一级的页表一样。注意, 使用字节的PTE , 每个一级和二级页表都是4KB 字节,这刚好和一个页面的大小是一样的。

图7-6描述了使用级页表层次结构的地址翻译。虚拟地址被划分成为k个VPN 和1 个VPO 。每个VPN i都是一个到第i 级页表的索引,其中1≤i≤k。第j级页表中的每个PTE, 1≤j≤k,都指向第j + 1 级的某个页表的基址。第k级页表中的每个PTE 包含某个物理页面的PPN ,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU 必须访问差个PTE 。对于只有一级的页表结构, PPO和VPO是相同的。

图7-6,使用k级页表的地址翻译

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

早期计算机系统的存储器层次结构只有三层: CPU 寄存器、DRAM 主存储器和磁盘存储。不过,由于CPU 和主存之间逐渐增大的差距,系统设计者被迫在CPU 寄存器文件和主存之间插人了一个小的SRAM 高速缓存存储器,称为LI 高速缓存(一级缓存),大约四个适中周期可以访问。随着CPU 和主存之间的性能差距不断增大,系统设计者在凵高速缓存和主存之间又插入了一个更大的高速缓存,称为L2 高速缓存,可以在大约10 个时钟周期内访问到它。有些现代系统还包括有一个更大的高速缓存, 称为L3 高速缓存,在存储器层次结构中,它位于L2 高速缓存和主存之间,可以在大约50 个周期内访问到它。

L1~3的原则是通用的,这里我们只讨论L1高速缓存。

高速缓存被组织成一个有S个高速缓存组的数组,每个组包含E个高速缓存行,每个行有B个字节的数据块。

图7-7,a)高速缓存结构 b)存储器地址结构

对于地址参数S和B 将m个地址位分为了三个字段,如图7-7中b 所示。A 中5 个组索引位是一个到S 个组的数组的索引。第一个组是组0 , 第二个组是组1 , 依此类推。组索引位被解释为一个无符号整数,它告诉我们这个字必须存储在哪个组中。一旦我们知道了这个字必须放在哪个组中,A 中的t个标记位就告诉我们这个组中的哪一行包含这个字(如果有的话)。当且仅当设置了有效位并且该行的标记位与地址A 中的标记位相匹配时,组中的这一行才包含这个字。一旦我们在由组索引标识的组中定位了由标号所标识的行,那么b 个块偏移位给出了在B个字节的数据块中的字偏移。

7.6 hello进程fork时的内存映射

虚拟内存和内存映射解释了fork函数如何为每个新进程提供私有的虚拟地址空间.

为新进程创建创建当前进程的的mm_struct, vm_area_struct和页表的原样副本,两个进程中的每个页面都标记为只读,两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW),在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存,随后的写操作通过写时复制机制创建新页面。

图7-8,Linux是如何组织虚拟内存的
图7-9,写时复制

7.7 hello进程execve时的内存映射

execve函数在当前进程中加载并运行新程序a.out的步骤:

删除已存在的用户区域

创建新的区域结构

私有的、写时复制

代码和初始化数据映射到.text和.data区(目标文件提供)

.bss和栈堆映射到匿名文件 ,栈堆的初始长度0

共享对象由动态链接映射到本进程共享区域

设置PC,指向代码区域的入口点

Linux根据需要换入代码和数据页面

7.8 缺页故障与缺页中断处理

假设MMU 在试图翻译某个虚拟地址A 时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:

1) 虚拟地址A 是合法的吗?换句话说,A 在某个区域结构定义的区域内吗? 为了回答这个问题,缺页处理程序搜索区域结构的链表,把A 和每个区域结构中的vm_start和vm_end 做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。

2)试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限? 例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的, 那么缺页处理程序会触发一个保护异常,从而终止这个进程。

3)此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU 重新启动引起缺页的指令,这条指令将再次发送A 到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。

图7-10,Linux缺页处理

7.9 动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做“break”),它指向堆的顶部。

分配器将堆视为一组不同大小的块(clock)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个己分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

7.9.1 放置已分配的块

当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求的空闲块。分配器执行这种搜索方式是由放置策略确定的,一些常见策略是首次适配、下一次适配和最佳适配。

首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。

下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。

最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。

7.9.2 分割空闲块

一旦分配器找到一个匹配的空闲块,就必须做一个另策决定,那就是分配这个块多少空间。

分配器通常会选择将这个空闲块分割为两部分。第一部分变为了已分配块,而剩下的变成一个新的空闲块。

7.9.3 获取额外的堆内存

如果分配器不能为请求块找到空闲块,一个选择是合并那些在物理内存上相邻的空闲块来创建一个更大的空闲块。然而如果这样还是不能生成一个足够大的块,或者空闲块已经最大程度地合并了,分配器会通过调用sbrk函数,向内核请求额外的内存。分配器将额外的内存转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。

7.9.4 合并空闲块

Knuth提出了一种聪明而通用的技术,叫做边界标记(boundary tag),在每个块的结尾处添加一个脚部(footer,边界标记),其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。

图7-11,使用边界标记的堆块的格式

7.9.5 堆块格式与空间链表的组织方式

  ①隐式空闲链表

隐式空闲链表简单堆块形式(可使用边界标签)

图7-12,隐式空闲链表堆块格式

这种情况下,一个块是由一个字的头部、有效载荷,以及可能的填充组成。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。

头部后面是应用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。

块的格式如图7-13所示,我们可以将堆组织为一个连续的已分配块和空闲块的序列。

图7-13,隐式空闲链表组织堆

我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。

显示空闲链表

一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义, 程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。

图7-14,使用双向空闲链表堆块格式

分离链表

就像我们已经看到的, 一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关系的时间来分配块。一种流行的减少分配时间的方法, 通常称为分离存储(segregated storage) ,就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类(size class) 。有很多种方式来定义大小类。例如,我们可以根据2的幂来划分块大小:

{ 1} ,{ 2 } ,{ 3 ,4 } ,{5~8},… ,{ 1025~2048 } ,{2049~ 4096 } ,{ 4097~∞}

或者我们可以将小的块分派到它们自己的大小类里, 而将大块按照2 的幂分类:

{ 1} ,{ 2 },{ 3 } ,… ,{ 1023 } ,{ 1024 },{ 1025~2048 } ,{ 2049~4096 } ,{4097~∞}

分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为n的块时,它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。

7.10 本章小结

Linux为每个进程维护一个单独但一致的虚拟地址空间,将进程的信息映射到虚拟空间上;虚拟内存地址被翻译为物理地址,又将虚拟内存映射到物理内存上。这样在主存与进程之间构成了一个抽象的层——虚拟内存。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互。


8hello的IO管理

8.1 Linux的IO设备管理方法

8.1.1 设备的模型化:文件

一个Linux 文件就是一个个m字节的序列:

B0,B1,B2,… ,Bk,… ,Bm-1

所有的I /O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输人和输出都被当作对相应文件的读和写来执行。

8.1.2 设备管理:Unix I/O接口

这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口, 称为Unix I / O ,这使得所有的输人和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

8.2.1 打开和关闭文件

(1)int open(char *filename, int flags, mode_t mode);

open函数将filename转换位一个文件描述符,并且返回描述符数字。flgas参数指明了进程打算如何访问这个文件;modec参数指定了新文件的访问权限位。

(2)int close(int fd);

进程通过调用close关闭一个打开的文件,关闭一个已关闭的描述符会出错。

8.2.2 读和写文件

(1)ssize_t read(int fd, void *buf,  size_t n);

read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。

(2)ssize_t write(int fd , const void *buf, size_t n);

write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。

8.3 printf的实现分析

8.3.1 printf

首先看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为字符指针,指向...中第一个参数,即我们要格式化输出的字符串

8.3.2 vsprintf

再看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':

                 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);

}

vsprintf接受确定输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出。

8.3.3 write

最后wirte函数将buf中i个元素写到终端

mov eax, _NR_write 
     mov ebx, [esp + 4] 
     mov ecx, [esp + 8] 
     int INT_VECTOR_SYS_CALL 

在 write 函数中,将栈中参数放入寄存器,ecx 是要打印的元素个数,ebx 存放buf中的第一个元素地址,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,将要输出的信息的ASCII码从总线传送到显存中,显卡需要把这些图像用帧的形式推送到显示器上,需要调用字符显示驱动子程序,经过三个步骤:

(1)将ASCII码转换为二字节码存储在VRAM中

对要输出在屏幕上的所有字符,需要转换为二字节的特殊编码保存在VRAM中,其中1字节为ASCII码,1字节为字符的属性(颜色、亮度等)。转换后将新的编码储存在VRAM中,等待向屏幕输出。

(2)将VRAM中的二字节码转换为字形码存储在ROM中

字符发生器ROM将VRAM中的输出信息转换为可显示字符字形的点阵数据,并存储起来。

为了将汉字的字形显示输出,汉字信息处理系统还需要配有汉字字模库,也称字形库,它集中了全部汉字的字形信息。需要显示汉字时,根据汉字内码向字模库检索出该汉字的字形信息。

图8-1,字形点阵

(3)显示控制过程(从VRAM输出到屏幕)

①根据当前被显示字符在屏幕上的位置为地址,到VRAM 中找出被显示字符的ASCII 码;

②再用字符ASCII 码和电子束所处的字符点阵行位置为地址,到ROM中读出该字符的点阵行数据;

③把字符点阵行数据送到移位寄存器,通过逐位移位操作,输出被显示内容的显示点控制信号,送CRT 栅极实现对屏幕像素的显示控制。

图8-2,向屏幕输出过程

8.4 getchar的实现分析

getchar功能:程序调用getchar时,程序等待用户从键盘输入信息。在用户输入有效信息时,输入的字符被放入字符缓冲区,getchar不进行处理;当用户输入回车键时,getchar以字符为单位读取字符缓冲区,但不会读取回车键和文件结束符。

异步异常-键盘中断:getchar的从键盘输入的实现是异常中断后键盘中断的处理程序的结果。

当我们进行键盘输入时,我们从当前进程跳转到键盘中断处理子程序,接受按键扫描码。当键盘上的一个按键按下时,键盘会发送一个中断信号给CPU,与此同时,键盘会在指定端口(0x60) 输出一个数值,这个数值对应按键的扫描码(make code)叫通码,当按键弹起时,键盘又给端口输出一个数值,这个数值叫断码(break code),这样计算机知道我们何时按下何时按下、松开,是否一直按着按键。

中断处理子程序把键盘中断通码和断码数值转换为按键编码(对于字母键、数字键为ASCII码),缓存到键盘缓冲区,然后把控制器交换给原来任务(getchar),若没有遇到回车键,继续等待用户输入,重复上述过程,遇到回车键后getchar按字节读取键盘缓冲区内的内容,处理完毕后getchar返回,getchar进程结束。

8.5 本章小结

应用程序现实中需要利用操作系统提供I/O函数来与外部设备传输数据,例如我们的输入输出,需要利用到一系列I/O函数,以及其他辅助函数来帮我们达到输入和输出的目的。


结论

hello的一生包括程序(Program)和进程(Progress)两个阶段:

  1. Program:hello在编译系统内经过五个阶段,将高级语言的程序逐步翻译为机器能读懂得机器语言:从hello.c被预处理到hello.i,hello.i被编译到hello.s,hello.s被汇编到hello.o,hello.o被链接到hello。
  2. Progress:可执行目标文件hello在服务于软硬件交互的操作系统上运行,操作系统对其程序抽象为进程,好像系统上只有这个程序在进行,系统利用异常控制流控制进程的运行,利用虚拟内存实现数据到物理内存的映射,提供接口实现与I/O设备以及其他程序通信,使程序在系统上能够自如地走完自己的一生。

附件

hello.chello 的C源文件
hello.ihello.c经过预处理后的预编译文本文件
hello1.ihello.c经过加上-P选项的预处理屏蔽垃圾内容后的预编译文本文件。hello.s :hello.i经过编译得到的汇编语言文本文件

hello.s

hello.i经过编译得到的汇编语言文本文件

hello.ohello.s经过汇编得到的机器语言二进制文件(可重定位目标文件)
hellohello.o经过链接得到的机器语言二进制文件(可执行目标文件)
hello_objdump.txthello经过反汇编得到的机器语言与对应反汇编语言的文本文件

gdb.txt

gdb中用于在hello每个函数上加断点的辅助文件

breakpoints.txt

记录gdb中所有断点



参考文献


[1]  Farmwang. ld运行时动态链接[EB/OL]. https://blog.csdn.net/farmwang/article/details/75807886?utm_medium=distribute.pc_relevant.none-task-blog-2~default~BlogCommendFromMachineLearnPai2~default-3.control&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2~default~BlogCommendFromMachineLearnPai2~default-3.control,2017-07-22/2021-06-23.

[2]  懒惰的劳模. Linux ELF 详解2 -- Section header&Section[EB/OL]. https://blog.csdn.net/helowken2/article/details/113757332,2021-02-09/2021-06-23.

[3]  beyond702. 动态链接库中的.symtab和.dynsym[EB/OL]. https://blog.csdn.net/beyond702/article/details/50979340,2016-03-25/2021-06-23.

[4]  globbo. GDB调试中 如何在so共享库中打断点、保存断点以及加载断点[EB/OL].https://blog.csdn.net/hao1183716597/article/details/97389312?utm_medium=distribute.pc_relevant.none-task-blog-2~default~BlogCommendFromMachineLearnPai2~default-1.control&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2~default~BlogCommendFromMachineLearnPai2~default-1.control,2019-07-26/2021-06-23.

[5]  GeanQin. 认识各种内存地址[EB/OL]. https://blog.csdn.net/angjia7206/article/details/106546203,2020-06-04.

[6]  汉字字模码[DB/OL]. 360百科,2017-10-24/2021-06-23. http://www.sciencemag.org/cgi/ collection/anatmorp.

[7]  劳复思. 计算机是如何将一个字符显示到显示器上的[EB/OL]. http://blog.sina.com.cn/s/blog_576245b00102xxv9.html,2018-03-30/2021-06-23.

[8]  ldjsld. 字符显示器的显示控制过程[EB/OL]. http://www.360doc.com/content/16/0730/16/152409_579583865.shtml,2016-07-30/2021-06-23.

[9]  快乐之家. 输入输出设备与输入输出系统[DB/OL]. https://www.docin.com/p-7202672.html,2009-01-29/2021-06-23.

[10]  xumingjie 1658. 键盘中断的处理过程[EB/OL]. https://blog.csdn.net/xumingjie1658/article/details/6965176,2011-11-13/2021-06-23.

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值