uml系统设计期末大作业_计算机系统大作业 hello

摘 要

本文将对hello.c程序从编写完成后到运行在linux中的生命历程进行讲述,借助相关工具分析预处理、编译、汇编、链接等各个过程在linux下实现的原理,分析了这些过程中产生的文件的相应信息和作用。在后几章将介绍虚拟内存、异常处理等内容以及shell的内存管理、IO管理、进程管理等相关知识。

关键词:计算机系统;内存管理;编译;进程管理;

目 录

第1章 概述 - 4 -

1.1 Hello简介 - 4 -

1.2 环境与工具 - 4 -

1.3 中间结果 - 4 -

1.4 本章小结 - 4 -

第2章 预处理 - 6 -

2.1 预处理的概念与作用 - 6 -

2.2在Ubuntu下预处理的命令 - 6 -

2.3 Hello的预处理结果解析 - 7 -

2.4 本章小结 - 10 -

第3章 编译 - 11 -

3.1 编译的概念与作用 - 11 -

3.2 在Ubuntu下编译的命令 - 11 -

3.3 Hello的编译结果解析 - 11 -

3.4 本章小结 - 18 -

第4章 汇编 - 19 -

4.1 汇编的概念与作用 - 19 -

4.2 在Ubuntu下汇编的命令 - 19 -

4.3 可重定位目标elf格式 - 20 -

4.4 Hello.o的结果解析 - 23 -

4.5 本章小结 - 25 -

第5章 链接 - 26 -

5.1 链接的概念与作用 - 26 -

5.2 在Ubuntu下链接的命令 - 26 -

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

5.4 hello的虚拟地址空间 - 31 -

5.5 链接的重定位过程分析 - 31 -

5.6 hello的执行流程 - 34 -

5.7 Hello的动态链接分析 - 35 -

5.8 本章小结 - 35 -

第6章 hello进程管理 - 36 -

6.1 进程的概念与作用 - 36 -

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

6.3 Hello的fork进程创建过程 - 36 -

6.4 Hello的execve过程 - 37 -

6.5 Hello的进程执行 - 38 -

6.6 hello的异常与信号处理 - 40 -

6.7本章小结 - 47 -

第7章 hello的存储管理 - 49 -

7.1 hello的存储器地址空间 - 49 -

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

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

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

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

7.6 hello进程fork时的内存映射 - 53 -

7.7 hello进程execve时的内存映射 - 54 -

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

7.9动态存储分配管理 - 57 -

7.10本章小结 - 62 -

第8章 hello的IO管理 - 63 -

8.1 Linux的IO设备管理方法 - 63 -

8.2 简述Unix IO接口及其函数 - 63 -

8.3 printf的实现分析 - 64 -

8.4 getchar的实现分析 - 66 -

8.5本章小结 - 67 -

结论 - 68 -

附件 - 69 -

参考文献 - 70 -

第1章 概述

1.1 Hello简介

Hello的P2P: From Program to Process

用高级程序语言编写得到hello.c文件,再经过编译器预处理得到.i文件,再对其进行编译得到.s汇编文件。此后通过汇编器将.s文件翻译成机器语言,将指令打包成可重定位的.o目标文件,再通过链接器与库函数链接得到可执行文件hello,执行此文件,操作系统会为其fork产生子进程,再调用execve函数加载进程。

Hello的O2O: From Zero-0 to Zero-0

shell通过execve加载并执行hello,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。hello通过Unix I/O管理来控制输出。hello执行完成后shell会回收hello进程,并且内核会从系统中删除hello所有痕迹,至此,hello完成O2O的过程。

1.2 环境与工具

硬件环境:Inter(R) Core(TM) i5-8300H CPU @2.30GHz 8G RAM

软件环境:Windows 10 64位 ,Vmware 15 ,Ubuntu 18.04 LTS 64 位

开发工具:gcc,gedit,VS2019版,gdb ,edb,readelf

1.3 中间结果

hello.c :hello源代码

hello.i :预处理后的文本文件

hello.s :hello.i编译后的汇编文件

hello.o :hello.s汇编后的可重定位目标文件

hello_objdump :hello的反汇编代码

hello.0_objdump :hello.o的反汇编代码

hello :链接后的可执行文件

1.4 本章小结

本章对Hello进行简单的介绍,描述了Hello的P2P和O2O的含义,介绍了本次实验的软硬件环境和工具,以及说明了为完成本次实验在各个过程中生成的文件及其作用。

第2章 预处理

2.1 预处理的概念与作用

概念:

预处理英文简写为cpp,是编译器在编译开始之前调用预处理器来执行以#开头的命令(读取头文件、执行宏替代等)、删除注释、包含其他文件、执行条件编译、布局控制等修改原始的C程序,生成以.i结尾的文本文件。

作用:

在预处理的过程中,对于引用的一些封装的库或者代码这些命令来说,会告诉预处理器读取头文件中用到的库的代码,将这段代码直接插入到程序文件中。对于宏定义来说,会完成对宏定义的替换。注释会直接删除掉。条件编译是根据实际定义宏(某类条件)进行代码静态编译的手段。可根据表达式的值或某个特定的宏是否被定义来确定编译条件。布局控制的主要功能是为编译程序提供非常规的控制流信息。最后将处理过后的新的文本保存在hello.i文件中,后面的种种操作计算机将直接对hello.i文件进行操作。预处理阶段的作用是让编译器在随后对文本进行编译的过程中,更加方便,因为访问库函数这类操作在预处理阶段已经完成,减少了编译器的工作。

2.2在Ubuntu下预处理的命令

gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i

d817eaa723c4e7a763b9c03f571fc456.png

图2.2-1预处理命令

2.3 Hello的预处理结果解析

01fa708101626791cfbb30047170e04c.png

图2.3-1 hello.i的大小

e19ea08ed0680e5a76bd8b3ea3230529.png

图2.3-2 hello.c的大小

通过对比可以发现,经过预处理后,文件大小变化特别大,下面我们将分析为什么会多了如此多的字节。

在预处理文本文件hello.i中,首先是对文件包含中系统头文件的寻址和解析。

6e2291b4459d675119061749b4dfdfea.png

图2.3-3 hello.i文件

因为hello.c包含的头文件中还包含有其他头文件,因此系统会递归式的寻址和展开,直到文件中不含宏定义且相关的头文件均已被引入。同时引入了头文件中所有typedef关键字,结构体类型、枚举类型、通过extern关键字调用并声明外部的结构体及函数定义。在.i文件中也可明显看出,删除了所有注释部分和#include。

e846fd34e33ee4a0049f6c5ffc641935.png

图2.3-4 hello.i文件

25f0dc655af66d3877f6d6a3d9ac866b.png

7f7d2f15672126b9c77204d45487f7f9.png

图2.3-5 hello.i文件

在文件的最后部分,才是我们很熟悉的hello文件。

de0f756c1788ef2bb68978872a065c6f.png

图2.3-6 hello.i文件

2.4 本章小结

预处理过程是计算机对程序进行操作的起始过程,在这个过程中预处理器会对hello.c文件进行初步的解释,对头文件、宏定义和注释进行操作,将程序中涉及到的库中的代码补充到程序中,对于执行程序没有用的部分(注释)删除,最后将初步处理完成的文本保存在hello.i中,为后面的生成汇编语言程序的操作做准备。

第3章 编译

3.1 编译的概念与作用

概念:

编译阶段就是编译器对hello.i文件进行处理的过程。此阶段编译器会完成对代码的语法和语义的分析,生成汇编代码,并将这个代码保存在hello.s文件中。

作用:

编译程序的基本功能是把源程序(高级语言)翻译成目标程序。除了基本功能之外,编译程序还具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人机联系等重要功能。

3.2 在Ubuntu下编译的命令

gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s

3d2487b1890e34e6a2b0987a1848f151.png

图3.2-1 编译命令

3.3 Hello的编译结果解析

4a1e38992184a09f075e85243fa7711a.png

图3.3-1 hello.s的起始部分

.file:源文件名

.text:代码段

.section .rodata:下面是.rodata节

.align:对齐方式

.string:字符串

.globl:全局变量

.type:指定是对象类型或是函数类型

3.3.1数据:

根据hello.c的文件可以看出只有局部变量和字符串常量,没有全局变量。

所以跳过data段从rodata段开始介绍。

(一):rodata段

rodata段存在的是各种常量,在本程序中对应的就是字符串常量。

argv[1]和argv[2]都在.rodata中,并且给出了字符串的编码。具体见图

eef23ca5dd07ff4310e5791e084253f9.png

图3.3.2 .rodata段的内容

6ad87d134f876285cf54d068b10e34fd.png

55cd43fb2c6334224415f1b79081bef2.png

两个打印的字符串即是rodata段的只读内容。第一个是字符串常量,第二个是格式字符串。

下面开始详细分析rodata段的表示

.section 表示这是一个区段

.LC0 和.LC1 分别是两个字符串的标签,也代表这两个字符串的首地址。

.string 表示申请一个字符串变量的空间

我们可以看到“学号 姓名!n”这部分变成了加数字的形式,这是因为汉字采用 UTF-8 的编码形式,这里的“!”也是用中文输入法输入的,因此也采用这种编码形式,这里的345255 等都是这些编码的八进制表示形式。我们也可以看到这行的n 消失了,这时因为编译器将 printf 优化成了 puts,因此省略了n

.rodata 段中除了可以存放字符串常量,还可以存放:const 修饰的已初始化的全局变量(无论初始化是否为 0,无论有无 static 修饰) 和const 和 static 共同修饰的已初始化的局部变量(无论初始化是否为 0) 还有switch 跳转表,这部分在书中有详细的介绍,在此因为在程序中也没有出现就不再赘述了。

(二):text段

text段存放的是程序代码和立即数。立即数是在前面加$。下图中的$0,$24,$8等即是。

f8dc8d10c62f149e5e710f9483a82f45.png

图3.3.3立即数

(三)bss段

.bss段存放的是未初始化的全局变量,在hello中没有这种变量,所以这里只是简单的介绍。这些变量有两种标识方式,一种是通过.bss 指示,直接标识该变量属于.bss 区段,另一种是通过.comm 指示,标识这是一个 COMMON 符号,链接阶段会决定哪些 COMMON 符号属于.bss 区段。

(四)堆栈段

C 语言过程调用的一个关键特性就是使用了栈结构提供了先进后出的内存管理原则。

调用函数时,函数的前六个参数一般存储在对应的六个寄存器中,这六个寄存器依此为%rdi,%rsi,%rdx,%rcx,%r8,%r9,当函数参数多于六个时,多余的参数将通过栈帧保存和传递。 当 x86-64 过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间,这个部分被称为过程的栈帧。x86-64 的栈向低地址方向增长,而栈指针%rsp 指向栈顶元素。%rbp 指向栈底(是可选的)。

3.3.2:赋值

程序的第一行int i,然后再将i赋值,此处的赋值是通过movl语句实现的,具体看如下汇编代码和源代码对应。

f35eefc165869092fe05d2ad008bae0d.png

ce288d191278e80ce4b9b3926f8c93af.png

387c37dbba49db4760935121e71fb79c.png

图3.3.4赋值语句分析

在汇编代码中,赋值大多采用 mov 指令,此处源代码的 i = 0 这一句在汇编代码中就是 movl $0, -4(%rbp),mov 类语句有四种,分别是movb,movw,movl,movq,均表示传送,mov后面的后缀bwlq是表示数据类型的大小。b表示字节,w表示字,l表示双字,q表示四字。因为这里的i 是 int 型变量,32位,也就是双字,所以采用了 movl 传送指令。根据格式要求,在AT&T 格式的汇编代码中,源操作数在前,目的操作数在后,所以 movl $0, -4(%rbp)就是将 0 传送给-4(%rbp)这个位置。

3.3.3:算数操作

在hello.c的代码中,只涉及到了一处算数操作,就是在for循环中每次对i进行的加一操作。i++对应的汇编代码是 addl $1, -4(%rbp)。

fcd364a03caeb587560d2d7c2552579f.png

4f7670684fcc5709a02eb3878584484e.png

图3.3.5 i++操作

3.3.4:关系操作

在hello.c中有两个关系操作,分别如图3.3.6所示,一个是不等于操作,另一个是小于操作。在汇编代码中对应了两句操作。一个是cmpl另一个是一个j加上一些字母。表示的含义根据j后面的字母不同代表也不同。

5f6f497cd64ffe0d94fb8d6581c9e125.png

7d3dba2439db893488fec466866ad2e2.png

0c600966884805944a07124e7fc97beb.png

e2a79a770db49e5eeaa4788555bf33da.png

图3.3.6 两个关系操作

cc896b738ff02a26a18ea23776959889.png

图3.3.7 跳转条件指令

条件码中一共有四位,每一位都有不同的含义。如图3.3.8所示。对于不同的比较结果,操作码中就保存了不同的值。然后决定是否跳转,具体跳转的分析将在3.3.6下一小节中展开。

18988109d54af14b5337cbefd8d5ab25.png

图 3.3.8 条件码含义

3.3.5:数组/指针/结构操作

hello.c中在输出的时候调用了argv数组中的元素。如图3.3.9所示,我们可以看到,在汇编中,我们已经没有了数组、结构等概念,我们有的只是地址和地址中存储的值。所以对于一个数组的保存,在汇编中我们只保存了他的起始地址,对应的也就是argv[0]的地址,对于数组的中其他元素,我们利用了数组在申请的过程中肯定是一段连续的地址这样的性质,直接用起始地址加上偏移量就得到了我们想要的元素的值。

e7147ec6d973301d8ac6d401313bcd30.png

ccdfb7805b5ddd57247f1a3f9fbb3e50.png

图3.3.9 利用地址偏移进行数组指针结构操作

3.3.6:控制转移

在程序中有两处控制转移跳转对应在两次的比较操作后面,

argc != 4 就是 cmpl $4, -32(%rbp)比较 argc 和 4 的大小,然后下一句就是 je .L2 e 表示 equal 相等,也就是说如果相等,跳转到标签.L2 处的代码,否则继续向下执行。可以看到,如果 je 没有满足,接下来的指令就是 puts 和 exit;如果 je 满足,.L2 处就是循环的开始,这都和 C 代码逻辑一致。

前面提到过 i < 8 在汇编代码中被优化成了与 7进行 比较,对应的代码就是 cmpl $7, -4(%rbp)下面的一句时 jle,l e就是小于等于的意思,cmp 指令有两个操作数,比较的结果一定是后一个对前一个而言,即这里的 jle 是在判断后一个操作数是否小于等于前一个,即 i 是否小于等于 7,如果成立,跳转到.L4 代码,可以看到.L4 中执行了 printf,sleep 还访问了 sleepsecs,毫无疑问就是在执行循环体里的代码,.L4 的最后一句是 add $1, -4(%rbp),即将 i 加 1,然后继续向下运行,又回到.L3,然后再判断 i 是否小于等于 7,直到不满足这个条件,即 i 大于 7 时, jle .L4 这条指令才不会跳转回.L4,而是继续向下执行 getchar,最后 ret 返回。

3.3.7:函数操作

如3.3.1所叙述,首先补充一下栈帧结构,如图3.3.10。%eax寄存器中保存了函数的返回值。作为一个函数,我们肯定需要向函数内进行传参操作,对于参数比较少的情况来说,就直接存储在特定的寄存器中,如%rdi,%rsi,%rdx,%rcx就分别用来存储第一至四个参数。X86的及其一共为我们提供了6个寄存器来保存参数。如果参数多于6个,那么就只能放在栈中保存了。如图中所示,我们直接利用call指令,后面加上调用函数的名称,就直接可以去到被调用的函数的位置。在被调用的函数执行完毕之后,程序会将函数的返回值存在%eax中,然后执行ret语句,将函数程序返回到调用的地方。这样就完成了整个的函数调用。

d8202df744cece04a45530f2916eddda.png

图3.3.10 栈帧结构

5136e8b6f95fe50dc05f3d7666d99796.png

图3.3.11 函数调用

3.4 本章小结

本章我们主要介绍了编译器是如何将文本编译成汇编代码的。可以发现,编译器并不是原封不动的按照我们原来的代码顺序,逐条语句翻译成汇编文件的。编译器在编译的过程中,不仅会对我们的代码做一些隐式的优化,而且会将原来代码中用到的跳转,循环等操作操作用控制转移等方法进行解析。最后生成我们需要的hello.s文件。通过本章节的介绍,你应该对汇编代码及 hello 的编译阶段有所了解。

第4章 汇编

4.1 汇编的概念与作用

概念:

汇编器将汇编语言源程序转化为机器指令序列的过程叫做汇编。在汇编时,汇编器(as) 将hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program) 的格式,并将结果保存在目标文件hello.o 中。

作用:

将编译过的.s文件中的汇编语言指令转化成机器语言指令并且装入到.o可重定向文件。生成每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,根据汇编指令和机器指令的对照表一一翻译即可。

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

4.2 在Ubuntu下汇编的命令

gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o

d028d8141bc3cfc00d6606d37bfcab36.png

图4.2.1 汇编程序

或者用gcc -c hello.s -o hello.o没有加限制,采用gcc的默认配置。

4.3 可重定位目标elf格式

95940836991a5fd661ba9426332cb67d.png

图4.3.1 ELF文件信息

通过 readelf 工具可以获取 ELF 文件的信息,在终端输入指令:

首先先来了解一下ELF格式中都存储了哪些文件,如图4.3.1所示,ELF中存储了很多不同的节的信息,每一个节中保存了程序中对应的一些变量或者重定位等这些信息,至于为什么要保存这些信息,是因为程序在链接的时候会用到这些信息。这些信息的含义以及链接的作用我们会在下一章中进行介绍。

readelf -a hello.o ,可以查看详细信息,下面详细介绍各部分的信息。

4.3.1:ELF头

ELF头部以一个16字节的序列开始,描述生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器分析语法和解释目标文件的信息,其中包含ELF头大小、目标文件的类型、及其类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。

656313d1a5b76a6c315a3b484e523373.png

图4.3.1 ELF头

4.3.2:节头部表

节头部表描述了不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。具体的描述包括节的名称、类型、地址和偏移量等。

c56fd670c6c8207d7d2a1cbf9b8ebf25.png

图4.3.2 节头部表

节类型:

NULL(表示该节不使用)

PROGBITS(保存程序必须解释的信息的节,如二进制代码,数据等)

SYMTAB (保存符号表的节)

DYNSYM(保存动态链接符号的节,是 SYMTAB 的子集)

STRTAB(保存字符串表的节)

REL(保存重定位信息的节,没有补齐)

RELA(保存重定位信息的节,可能有补齐)

HASH(保存了一个散列表的节,使得符号表中的项可以快速查找)

DYNAMIC(包含动态链接信息的节)

HIPROC,LOPROC(保留给处理器专用语义的节区上下界)

HIUSER,LOUSER(保留给应用程序的索引上下界) 节头部表下方的 Key to Flags

4.3.3:重定位条目

当汇编器生成一个目标模块是,它并不知道数据和代码最终将放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。

ELF重定位条目的数据结构如下:

typedef struct{

long offset; /*需要被修改的引用的节偏移*/

long type:32, /*重定位类型*/

symbol:32; /*标识被修改引用应该指向的符号*/

long attend; /*符号常数,对修改引用的值做偏移调整*/

}Elf64_Rela;

两种最基本的重定位类型:

R_X86_64_PC32 :重定位一个使用32位PC相对地址的引用。

R_X86_64_32 :重定位一个使用32位PC绝对地址的引用。

根据图4.3.3,可以看出8条重定位信息的详细情况,分别对符号.rodata,函数puts,exit等,加数也在符号名称之后。

fce1aaf80e7b1ace117e7f810677cd1a.png

图4.3.3 重定位节

4.3.4:符号表

.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。

f1df22e6f2fab8c11aef1b7688e4eaad.png

图4.3.5 符号表

Num:符号在符号表中的索引

Value,Size,Type,Bind 就是符号的 value, size,type,binding 变量

Ndx:就是符号的 section 变量,1 就是.text,3 就是.data,UND 表示未定义,ABS 表示不该被重定位,COM 表示这是一个 COMMON 符号 Name:符号的名字

4.4 Hello.o的结果解析

接下来用objdump -d -r hello.o 分析hello.o的反汇编。

对比hello.s文件和反汇编代码,主要有以下的差别

1. 操作数:hello.s中的操作数时十进制,hello.o反汇编代码中的操作数是十六进制。

2. 分支转移:跳转语句之后,hello.s中是.L2和.L3等段名称,而反汇编代码中跳转指令之后是相对偏移的地址。

3. 函数调用:hello.s中,call指令之后直接是函数名称,而反汇编代码中call指令之后是函数的相对偏移地址。因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。

4. 全局变量的访问:在hello.s文件中,对于.rodata和sleepsecs等全局变量的访问,是$.LC0和sleepsecs(%rip),而在反汇编代码中是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。

如 je .L2 变成了 je 29,这是告诉链接器,链接时这里应该填 main+0x29 的地址。.LC0,.LC1,sleepsecs,sleep,printf,getchar,exit 这样的全局符号的标签也不见了,而是由0代替,如movl $.LC0, %edi变成了mov $0x0, %edi。 call 指令也改变了,如 call exit 变成了 callq 1f,但指令变成了 e8 00 00 00 00,也就是说操作数为 0,其他 call 指令相同,指令码都变为了 e8 00 00 00 00,无论是变成 0 的 mov 还是 call,它们下一行都会有一个重定位条目,类似于 R_X86_64_32 .rodata 这样的格式。这就是在告诉链接器,这些变成 0 的地方在链接的时候要根据下一行的重定位条目信息,填上相应的数据。

97cae254cad51c417380e914ee0fa561.png

f3538f3701c35a4dd6c0f5bdd0eaf1ef.png

e9156e23d9b156aa7e0aaa7e3067f867.png

eb977931ce9e658b41bac2572e6c3c04.png

ab0732fb72a11a8aa9569eb8520fad64.png

078e445e160719edcee9be590bbb736c.png

76c75a3d6b58b819e89671a1a0a89956.png

0cb9654d458321806bb7c0984596a651.png

b3fbc7102d25d20fa3ce75c6a4834538.png

708790700daa65749628446112b47a76.png

9ac2791a6aab9d4ee45dc066ef5f04e8.png

图4.4.1 hello.s和反汇编文件对比

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

4.5 本章小结

汇编器将汇编代码处理成机器可以看懂的机器码,也就是二进制代码。二进制代码较汇编代码来说,虽然可读性变得比较差,但是在执行效率方面有了非常大的提升,汇编代码虽然已经在原来的文本的基础上进行了优化,但是还是存在着一些字符等不能够直接处理的数据。但是二进制代码中,已经将所有的指令、函数名字等量变成了相应的存储地址,这样机器就可以直接读取这些代码并执行。所以总的来说hello.o已经非常接近一个机器可以执行的代码了。

第5章 链接

5.1 链接的概念与作用

链接的概念:

链接是通过链接器(ld)将各种代码和数据片断收集并组合成一个单一文件的过程。这个文件可以被加载(复制)到内存并执行。

链接的作用:

因为有了链接这个概念的存在,所以我们的代码才回变得比较方便和简洁,同时可移植性强,模块化程度比较高。因为链接的过程可以使我们将程序封装成很多的模块,我们在变成的过程中只用写主程序的部分,对于其他的部分我们有些可以直接调用模块,就像C中的printf一样。

作为编译的多后一步链接,就是处理当前程序调用其他模块的操作,将该调用的模块中的代码组合到相应的可执行文件中去。

5.2 在Ubuntu下链接的命令

ld -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/gcc/x86_64-linux-gnu/7/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello

ff572681c399f0b70dc7a3c3422a704c.png

图5.2.1 链接操作

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

f62f140d276946f84620727d3eaa1e89.png

图5.3.1 hello的ELF头

e268ea1658e42afaf0ca8df235489763.png

图5.3.2 hello的节头部表

15a57ad6ed9a7d740815d653b56ecc70.png

图5.3.3 hello的程序头表

dbab06e510be5c2db2a89a024e48cf1d.png

图5.3.4 hello的符号表

可执行文件hello中的各个节的信息。可以看到hello文件中的节的数目比hello.o中多了很多,说明在链接过后有很多文件有添加了进来。下面列出每一节中各个信息条目的含义:

名称和大小这个条目中存储了每一个节的名称和这个节在重定位文件种所占的大小。

地址这个条目中,保存了各个节在重定位文件中的具体位置也就是地址。

偏移量这一栏中保存的是这个节在程序里面的地址的偏移量,也就是相对地址。

5.4 hello的虚拟地址空间

在edb中打开hello,通过Data Dump查看hello程序的虚拟地址空间各段信息。在Memory Regions选择View in Dump可以分别在Data Dump中看到只读内存段和读写内存段的信息。表中前面两项分别是 hello 的代码段和数据段。 观察图5.4.1中节头部表和程序头表的各个表项,它们的虚拟地址减去偏移量不是 0x400000 就是 0x600000,这是系统内核规定好的,每一个程序都有相同的私有的虚拟空间结构,代码段总是从 0x400000 开始,数据段总是从 0x600000 开始。

591c3b1441e14858cc10222b7031d157.png

图5.4.1 hello 进程虚拟地址空间各段信息

f38d1c99c379cc785ec1c4236837597f.png

图5.4.2 data dump

使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。

5.5 链接的重定位过程分析

反汇编命令:objdump -d -r hello

c3dd251c3c23a02bccb28b2d6f1646e7.png

e53349937a79d7828b139433e68d87b9.png

0518af1415acc1d0a5443cd6fbc606a2.png

0cb9654d458321806bb7c0984596a651.png

图5.5.1 hello的重定位文件和hello.o的重定位文件对比

hello与hello.o主要有以下的不同:

1.链接增加新的函数:在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数。

2.增加的节:hello中增加了.init和.plt节,和一些节中定义的函数。

3.函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。

4.地址访问:hello.o中的相对偏移地址变成了hello中的虚拟内存地址。而hello.o文件中对于.rodata和sleepsecs等全局变量的访问,是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。

根据hello和hello.o的不同,分析出链接的过程为:链接就是链接器ld将各个目标文件组装在一起,就是把.o文件中的各个函数段按照一定规则累积在一起,比如规则:解决符号依赖,库依赖关系,并生成可执行文件。

根据hello.o中的重定位项目,分析hello重定位过程:

重定位过程合并输入模块,并为每个符号分配运行时地址,主要有以下两步:

1.重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。包括hello.o在内的所有可重定位目标文件中的.data节被全部合并成一个节,这个节成为输出的可执行目标文件hello中的.data节。然后,连接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,hello中每条指令和包括.rodata、sleepsecs等全局变量都有唯一的运行时内存地址了。

2.重定位节中的符号引用。链接器依赖于hello.o中的重定位条目,修改代码节和数据节中对每个符号的引用,使得它们指向正确运行时的地址。

总体来说,重定位的过程就是应用重定位文件中存储的信息,在对应的符号表和汇编代码中将要重定位的符号或者函数的位置准确的放到可执行文件中。

5.6 hello的执行流程

子程序名

ld-2.27.so!_dl_start

ld-2.27.so!_dl_setup_hash

ld-2.27.so!_dl_sysdep_start

ld-2.27.so!_dl_init

libc-2.27.so!__cxa_atexit

libc-2.27.so!__new_nextfn

hello! _init

hello!main

hello!printf@plt

hello!atoi@plt

hello!sleep@plt

hello!getchar@

或者当argv!=4时

main后面改为

hello!puts

plt hello!exit@plt

5.7 Hello的动态链接分析

GOT表发生了变化,这是因为动态链接要在程序加载运行时才能确定重定位符号的位置,需要动态链接时重定位的符号都存储在 GOT 表中。在执行函数dl_init的前后,地址0x600ff0中的值由0发生了变化。我们可以借助图5.7.1中的信息,得到这个地址是.got节的开始,而got中是一个全局函数表。这就说明,这个表中的信息是在程序执行的过程中动态的链接进来的。也就是说,我们在之前重定位等一系列工作中,用到的地址都是虚拟地址,而我们需要的真实的地址信息会在程序执行的过程中用动态链接的方式加入到程序中。当我们每次从PLT表中查看数据的时候,会首先根据PLT表访问GOT表,得到了真实地址之后再进行操作。

768731c8b4f3abc79313c16cb8e1a9a8.png

图5.7.1 dl_init前后变化

5.8 本章小结

链接的过程,是将原来的只保存了你写的函数的代码与代码用所用的库函数合并的一个过程。在这个过程中链接器会为每个符号、函数等信息重新分配虚拟内存地址,方法就是用每个.o文件中的重定位节与其它的节想配合,算出正确的地址。同时,将你会用到的库函数加载(复制)到可执行文件中。这些信息一同构成了一个完整的计算机可以运行的文件。链接让我们的程序做到了很好的模块化,我们只需要写我们的主要代码,对于读入、IO等操作,可以直接与封装的模块相链接,这样大大的简化了代码的书写难度。

第6章 hello进程管理

6.1 进程的概念与作用

进程的概念:进程就是一个执行中程序的实例。

进程的作用:

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

2.进程提供给应用程序的关键抽象:一个独立的逻辑控制流,好像我们的程序独占地使用处理器;一个私有的地址空间,好像我们的程序独占地使用内存系统。

3.内核为每个进程维护一个上下文,内核通过调度器和一种称为上下文切换的方式来实现多任务。

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

1.shell的作用

实际上Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果

2.shell的处理流程

shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。

6.3 Hello的fork进程创建过程

fork 函数原型:pid_t fork(void);

父进程(Shell)可以通过 fork 函数创建一个新的运行的子进程(hello)。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的但是独立地一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的差别在于它们有不同的 PID。

fork 函数是有趣的,并且有一些微妙的方面:调用一次,返回两次。父进程调用一次 fork,但却返回两次。一次是返回到父进程,而另一次是返回到新创建的子进程。在子进程中,fork 函数返回 0,在父进程中,fork 返回新创建子进程的 PID。

并发执行。父进程和子进程是并发运行的独立进程,内核可以以任意方式交替执行它们的逻辑控制流中的指令。通常父进程用 waitpid 函数来等待子进程终止或停止。在父进程调用 fork 后,到 waitpid 子进程终止或停止这段时间里,父进程执行的操作,和子进程的操作(如果没有什么其它复杂的操作的话),在时间顺序上是拓扑排序执行的。有可能,这段时间里父子进程的逻辑控制流指令交替执行。我们并不能提前预知他们的执行次序。而父进程的 waitpid 后的指令,只能在子进程终止或停止后,waitpid 返回后才能执行。

相同但是独立的地址空间。每个进程都有相同的用户栈、相同的本地变量、相同的堆、相同的全局变量值以及相同的代码。这一部分将在第七章内存映射中具体讲到。

共享文件。子进程继承了父进程打开的所有文件。

76c75a3d6b58b819e89671a1a0a89956.png

图6.3.1 fork进程

6.4 Hello的execve过程

execve 函数原型:

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

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

argv 变量指向一个以 NULL 结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例 argv[0]是可执行目标文件的名字。环境变量列表也是由一个类似的数据结构表示的,envp 变量指向一个以 NULL 结尾的指针数组,其中每个指针指向一个环境变量字符串,每个字符串都是形如”name=value”的“名字-值”对。

在 execve 加载了 hello 后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型:

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

当 main 开始执行时,用户栈的组织结构如下图所示:

078e445e160719edcee9be590bbb736c.png

图6.4.1 用户栈结构

6.5 Hello的进程执行

首先介绍进程上下文信息、进程时间片、用户模式和内核模式的概念。

1.进程上下文信息,就是内核重新启动一个被抢占的程序所需的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈、和各种内核数据结构。

2.进程时间片,是指一个进程和执行它的控制流的一部分的每一时间段。

3.用户模式和内核模式:处理器为了安全起见,不至于损坏操作系统,必须限制一个应用程序可执行指令能访问的地址空间范围。就发明了两种模式用户模式和内核模式,其中内核模式(上帝模式)有最高的访问权限,甚至可以停止处理器、改变模式位,或者发起一个I/O操作,处理器使用一个寄存器当作模式位,描述当前进程的特权。进程只有当中断、故障或者陷入系统调用时,才会将模式位设置成上帝模式,得到内核访问权限,其他情况下都始终在用户权限中,就能够保证系统的绝对安全。

操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表、以及包含进程已打开文件的信息的文件表。

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

下面通过图6.5.1详细说明。

ab0732fb72a11a8aa9569eb8520fad64.png

图6.5.1 上下文切换

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

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

hello代码中调用了sleep函数, 我们知道这个函数中用到的参数的值是自己输入的,所以这个sleep函数的作用就是当运行到这一句的时候,程序会产生一个中断,内核会将这个进程挂起,然后运行其它程序,当内核中的计时器到了输入的秒钟的时候,会传一个时间中断给CPU,这时候CPU会将之前挂起的进程放到运行队列中继续执行。

从图6.5.1中我们可以比较清晰的看出CPU是如何在程序建进行切换的。假设hello进程在sleep之前一直在顺序执行。在执行到sleep函数的时候,切换到内核模式,将hello进程挂起,然后切换到用户模式执行其它进程。当到了一定秒数之后,发生一个中断,切换到内核模式,继续运行之前被挂起的进程。最后切换回用户模式,继续运行hello进程。

6.6 hello的异常与信号处理

图6.6.1中显示了hello程序正常运行的结果。可以看到在执行ps命令之后,程序后台并没有hello进程正在执行了,说明进程正常结束,已经被回收了。

fb54f3c2cbd020670708d4072acb4dea.png

图6.6.1 hello正常运行结果

异常就是控制流中的突变,用来响应处理器状态中的某些变化。状态变化被称为事件,事件可能与当前指令的执行直接相关。比如,发生虚拟内存缺页、算术溢出,或者一条指令试图除零。另一方面,事件也可能和当前指令的执行没有关系。

比如,一个系统定时器产生的信号或者一个 I/O 请求完成。

在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用,到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序)。当异常处理程序完成处理后,根据引起异常的事件的类型来决定是否返回,或返回到哪一条指令。

异常可以分为四类

类型

原因

异步/同步

返回行为

中断

来自 I/O 设备的信号

异步

总是返回到下一条指令

陷阱

有意的异常

同步

总是返回到下一条指令

故障

潜在可恢复的错误

同步

可能返回到当前指令

终止

不可恢复的错误

同步

不会返回

图6.6.2 异常种类

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

eb977931ce9e658b41bac2572e6c3c04.png

图 6.6.3 中断处理

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

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

f3538f3701c35a4dd6c0f5bdd0eaf1ef.png

图 6.6.4 陷阱处理

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

97cae254cad51c417380e914ee0fa561.png

图 6.6.5 故障处理

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

e9156e23d9b156aa7e0aaa7e3067f867.png

图 6.6.6 终止处理

对于 hello 程序来说,并不会发生中断异常,可能会发生终止异常。由于 hello 一定会执行 exit 或 sleep 函数,因此一定会发生系统服务的调用,也就一定会发生陷阱异常。hello 在第一次加载指令和数据时,缓存中没有相应的页表,因此一定会发缺页,触发缺页异常处理程序(关于缺页异常,将在第七章讲到)也就一定会发生故障异常。

下面介绍更高层的软件形式的异常——信号。

信号就是一小条消息,它通知进程系统中发生了一个某种类型的事件。比如下表展示了 Linux 系统上支持的 30 种不同类型的信号。

序号

名称

默认行为

相应事件

1

SIGHUP

终止

终端线挂断

2

SIGINT

终止

来自键盘的中断

3

SIGQUIT

终止

来自键盘的退出

4

SIGILL

终止

非法指令

5

SIGTRAP

终止并转储内存

跟踪陷阱

6

SIGABRT

终止并转储内存

来自 abort 函数的终止信号

7

SIGBUS

终止

总线错误

8

SIGFPE

终止并转储内存

浮点异常

9

SIGKILL

终止

杀死程序

10

SIGUSRI

终止

用户定义的信号 1

11

SIGSEGV

终止并转储内存

无效的内存引用(段故障)

12

SIGUSR2

终止

用户定义的信号 2

13

SIGPIPE

终止

向一个没有读用户的管道做写操作

14

SIGALRM

终止

来自 alarm 函数的定时器信号

15

SIGTERM

终止

软件终止信号

16

SIGSTKFLT

终止

协处理器上的栈故障

17

SIGCHLD

忽略

一个子进程停止或者终止

18

SIGCONT

忽略

继续进程如果该进程停止

19

SIGSTOP

停止直到下一个 SIGCONT

不是来自终端的停止信号

20

SIGTSTP

停止直到下一个 SIGCONT

来自终端的停止信号

21

SIGTTIN

停止直到下一个 SIGCONT

后台进程从终端读

22

SIGTTOU

停止直到下一个 SIGCONT

后台进程向终端写

23

SIGURG

忽略

套接字上的紧急情况

24

SIGXCPU

终止

CPU 时间限制超出

25

SIGXFSZ

终止

文件大小限制超出

26

SIGVTALRM

终止

虚拟定时器期满

27

SIGPROF

终止

剖析定时器期满

28

SIGWINCH

忽略

窗口大小变化

29

SIGIO

终止

在某个描述符上可执行 I/O 操作

30

SIGPWR

终止

电源故障

图6.6.7 LINUX信号

每种信号类型都对应于某种系统事件。底层的硬件异常是由内核异常处理程序处理的,正常情况下对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。比如,如果一个进程试图除以 0,那么内核就发送给它一个 SIGFPE 信号。如果一个进程执行一条非法指令,那么内核就发送给它一个 SIGILL 信号。如果进程进行非法内存引用,内核就发送给它一个 SIGSEGV 信号。其他信号对应于内核或者其他用户进程中较高层的软件事件。比如,如果当进程在前台运行时,你键入 CTRL+C,那么内核就会发送一个 SIGINT 信号给这个前台进程组的每一个进程,当一个子进程终止或者停止时,内核会发送一个 SIGCHLD 信号给父进程。

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

发送信号,内核通过更新目的进程的上下文中的某个状态,发送一个信号给目的进程。

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

b3fbc7102d25d20fa3ce75c6a4834538.png

图6.6.8 信号处理

不停乱按

不停乱按的结果是程序运行情况和前面的相同,不同之处在于shell将我们刚刚乱输入的字符除了第一个回车按下之前的字符当做getchar的输入之外,其余都当做新的shell命令,在hello进程结束被回收之后,将会在命令行中尝试解释这些命令。中间没有任何对于进程产生影响的信号被产生。

d7fc25f415555188e3be2df2a2453819.png

图6.6.9 不停乱按的运行结果

Ctrl+Z:

使用ctrl+z之后,将会发送一个SIGTSTP信号给shell,然后shell将转发给当前执行的前台进程组,使hello进程挂起。

6058b250e8b728b4fd03d4c3b05686cc.png

图6.6.10 运行时Ctrl+Z

命令行输入ps,查看当前存在的进程中还有hello

eceb28356730441f0bc137da5ec3b9ee.png

图6.6.11 查看进程

输入 pstree 命令以树状图显示进程间的关系

5fac432f3f4b94d762a26b6827a3581f.png

图6.6.12 进程关系

重新暂停一次,使用fg命令调出hello进程完成剩下的执行

edb31e1918af48ba2d3bf43383723f32.png

图6.6.13 继续执行

再重新挂起一次,这次使用kill函数终止进程。如下图:可以看出kill后进程已经不再运行了。

321927774bccfa483eb53d46cfa570f9.png

图6.6.14 kill进程

Ctrl+C:

在键盘上输入 CTRL+C 会导致内核发送一个 SIGINT 信号到前台进程组的每个进程。默认情况下,结果是终止前台作业。

6eb598c77c2ac445b41fb0882001dce6.png

图6.6.15 Ctrl+C

6.7本章小结

本章简述了进程管理的一些简要信息,比如进程的概念作用,shel的基本原理,shell如何调用fork和execve我们的hello进程,我们的hello进程在执行时会遇到什么样的情况(包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令的处理),它对这些情况如何做出反应。又介绍了一些常见异常和其信号处理方法。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:又称相对地址,是程序运行由CPU产生的与段相关的偏移地址部分。他是描述一个程序运行段的地址。

物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。他是在前端总线上传输的而且是唯一的。在hello程序中,他就表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。

线性地址:是经过段机制转化之后用于描述程序分页信息的地址。他是对程序运行区块的一个抽象映射。

虚拟地址:其实虚拟地址跟线性地址是一个东西,都是对程序运行区块的相对映射。

就hello而言,他是在物理地址上运行的,但是对于CPU而言,CPU看到的hello运行的地址是逻辑地址,在具体操作的过程中,CPU会将逻辑地址转换成线性地址再变成物理地址。

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

首先,介绍一些定义。

段式管理就是把虚拟地址空间中的虚拟内存组织成一些长度可变的称为段的内存单元。段是虚拟地址到线性地址转化的基础。每个段有三个参数定义:

段基地址:指定段在线性地址空间中的开始地址。基地址是线性地址对应于段中偏移 0 处。

段限长:是虚拟地址空间中段内最大可用偏移地址。定义了段的长度。

段属性:指定段的特性。如该段是否可读、可写或可作为一个程序执行,段的特权级等。

这三个参数存储在一个称为段描述符的结构项中。在逻辑地址到线性地址的转换映射过程中会使用这个段描述符。段描述符保存在内存中的段描述符表中。

45b1747639a2b76391aaf0d0faffd9b1.png

图 7.2.1 段描述符

段描述符表是包含段描述符项的一个简单数组。使用段选择符来指定段描述符表中一个段描述符的位置来指定相应的段。段描述符表有两种:全局描述符表 GDT 和局部描述符表 LDT。段描述符表结构如下图所示:

b7341ec0f3e880efe5592b88373bd0ee.png

图 7.2.2 段描述符表

段选择符是段的一个 16 位标志符,如下图所示。段选择符并不直接指向段,而是指向段描述符表中定义段的段描述符。 段选择符包括 3 个字段的内容:

请求特权级 RPL, RPL=00,为第 0 级,位于最高级的内核态,RPL=11,为第 3 级,位于最低级的用户态,第 0 级高于第 3 级。这是一种环保护机制:内核工作在 0 环,用户工作在 3 环,中间环留给中间软件用。Linux 仅用第 0 和第 3 环。

表指引标志 TI,TI = 0 ,表示描述符在 GDT 中,TI = 1,表示描述符在 LDT 中。

索引值,给出了描述符在 GDT 或 LDT 表中的索引项号。

655ac16b9f5256ba12593c3e5b1b025e.png

图 7.2.3 段选择符

段寄存器,用于存放段选择符,段寄存器主要有:代码段(CS)、数据段(DS)、

栈段(SS)、目的地址段(ES)、未命名段(FS、GS)。

处理器把逻辑地址转化成一个线性地址的过程:

  1. 首先确定要访问的段,然后决定使用的段寄存器。
  2. 使用段选择符中的索引值在 GDT 或 LDT 中定位相应的段描述符,他们的首地址则通过 GDTR 寄存器和 LDTR 寄存器来获得。
  3. 将段选择符的索引值*8,然后加上 GDT 或 LDT 的首地址,就能得到当前段描述符的地址。(乘 8 是因为段描述符为 8 字节)
  4. 利用段描述符校验段的访问权限和范围,以确保该段是可以访问的并且偏移量位于段界限内。

利用段描述符中取得的段基地址加上偏移量,形成一个线性地址。

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

物理内存被划分为一小块一小块,每块被称为帧(Frame)。分配内存时,帧是分配时的最小单位,最少也要给一帧。在虚拟内存中,与帧对应的概念就是页(Page)。

线性地址的表示方式是:前部分是虚拟页号后部分是虚拟页偏移。

e53349937a79d7828b139433e68d87b9.png

图7.3.1虚拟地址

CPU通过将逻辑地址转换为虚拟地址来访问主存,这个虚拟地址在访问主存前必须先转换成适当的物理地址。CPU芯片上叫做内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址。然后CPU会通过这个物理地址来访问物理内存。

页表结构:在物理内存中存放着一个叫做页表的数据结构,页表将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。

页表就是一个页表条目(PTE)数组,虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个PTE。PTE是由一个有效位和一个n个字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。

a115865fadad9c09bea7212266300660.png

31288cc7f3f10282d3f18c856b2ef6dd.png

图7.3.2 页表的地址翻译

如上图,MMU利用虚拟页号(VPN)来在虚拟页表中选择合适的PTE,当找到合适的PTE之后,PTE中的物理页号(PPN)和虚拟页偏移量(VPO)就会组合形成物理地址。其中VPO与PPO相同,因为虚拟页大小和物理页大小相同,所需要的偏移量位数也就相同。此时,物理地址就通过物理页号先找到对应的物理页,然后再根据物理页偏移找到具体的字节。

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

如下图 7.4.1,Intel Core i7 处理器采用的就是 TLB 与四级页表支持的翻译方案。虚拟地址(VA)48 位,物理地址(PA)52 位,虚拟地址的 0~11 位是 VPO(虚拟页偏移量),12~47 位是 VPN(虚拟页号),利用 VPN 到页表中查找对应的页,页表被分为四级,每级对应 12~47 位中的 9 位,在一级页表中查找与 VPN 的 27~35 位对应的二级页表,再在这个二级页表中查找 VPN 的 18~26 位对应的三级页表,以此类推,最后在四级页表中找到目标页表条目,这个页表条目中存储的就是 PPN (物理页号),VPO 与 PPO(物理页偏移量)相等,直接将这两部分合起来就得到了物理地址(PA),即 PA = PPN + PPO。

dd4ac04b96ba53a5cb72781b4b3c9e27.png

图7.4.1 四级页表结构

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

在上一节中我们已经获得了物理地址VA,我们接着上图7.4.1的右侧部分进行说明。使用CI6位进行组索引,每组8路,对8路的块分别匹配 CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。 如果没有匹配成功或者匹配成功但是标志位是 1,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略 LFU 进行替换。

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,他创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制,当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。

37a51d2ab6981da7d96163930c882354.png

图7.6.1新进程和shell进程

e27afc5ce97fefc19eb0cb26a0dc2a63.png

图7.6.2 写时复制

7.7 hello进程execve时的内存映射

Shell 通过 fork 函数创建新进程后,就要通过 execve 函数在这个新进程上加载并运行 hello 程序。

execve 函数加载并运行 hello 程序需要以下几个步骤:

  1. 删除已存在的用户区域删除当前进程虚拟地址的用户部分中已存在的区域结构。(即清除这个进程曾经运行过的程序遗留下来的痕迹,为新程序初始化区域)
  2. 映射私有区域

为新程序的代码、数据、bss 和栈区创建新的区域结构。所有的这些区域都是私有的,写时复制的。代码和数据区被映射为 hello 文件中的.text 和.data 区。bss 区是请求二进制零的,映射到匿名文件,其大小包含在 hello 中。

栈和堆也是请求二进制零的,初始长度为 0。

  1. 映射共享区域如果 hello 程序与共享对象链接,比如标准 C 库 libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
  2. 设置程序计数器: execve 做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。Linux 将根据需要换入代码和数据页面。

0518af1415acc1d0a5443cd6fbc606a2.png

图 7.7.1 加载器是如何映射用户地址空间的区域的

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

分三步:

第一步先确认是不是一个合法的地址,即通过不断将这个地址与每个区域的vm_start&vm_end进行比对,如果并不是在一个区域里的话,就给出segmentation fault,因为它引用了一个不合法的地址

第二步确认访问权限是不是正确的。即如果这一页是只读页,但是却要做出写这个动作,那明显是不行的。如果做出了这类动作,那么处理程序就会触发一个保护异常,默认行为是结束这个进程

第三步确认了是合法地址并且是符合权限的访问,那么就用某个特定算法选出一个牺牲页,如果该页被修改了,就将此页滑出(swap out)并且swap in那个被访问的页,并将控制传递到触发缺页中断的那条指令,这条指令继续执行的时候就不会触发缺页中断,这样就可以继续执行下去。

5961698b8b3ab7af075cbc6ba9439688.png

图7.8.1 缺页处理

举一个书上的例子:

对于一个访问虚拟内存的指令来说,如果发生了缺页现象,CPU就会触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,例如存放在PP3中的VP4,如果VP4已经被更改,那就先将他存回到磁盘中。

找到了要存储的页后,内核会从磁盘中将需要访问的内存,例如图7.8.1所示的VP3放入到之前已经操作过的PP3中,并且将PTE中的信息更新,这样就成功的将一个物理地址缓存在了页表中。当异常处理返回的时候,CPU会重新执行访问虚拟内存的操作,这个时候就可以正常的访问,不会发生缺页现象了。

417921050fda255ef5af5dbe893211e9.png

图7.8.1 缺页

7.9动态存储分配管理

hello 程序中的 printf 会调用 malloc 函数,这是一个显式分配器。程序通过调用 malloc 的函数来从堆中分配块。下面介绍 malloc 如何工作:

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。如图 7.9.1,堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上增长。对于每个进程,内核维护着一个指针 brk,它指向堆的顶部。分配器将堆视为一组不同大小的块的集合。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲块保持空闲,直到它显式地被应用所分配(如 malloc)。一个以已分配的块保持分配状态直到它被释放,这种释放要么是应用程序显式执行的(如 free),要么是内存分配器自身隐式执行的(如垃圾收集器)。

分配器有两种基本风格:显式分配器和隐式分配器。两种分配器都要求应用程序显式地分配块。不同之处在于显式分配器要求应用显式地释放任何已分配的块。

C 程序通过调用 malloc 函数来分配一个块,并通过 free 函数来释放这个块,属于显式分配器。

任何的实际分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块。大多数分配器将这种信息嵌入块本身。一个简单的方法如下图所示:

f96def6fcf85246d6b4d0ce4110d18cf.png

图 7.9.1 一个简单的堆块的格式

一个块由一个字大小的头部、有效载荷以及可能的一些额外的填充组成。头部编码了这个块的大小,以及这个块是已分配的还是空闲的。如果我们强加一个双字对齐的约束条件,那么块大小就总是 8 的倍数,且块大小的最低 3 位总是 0。因此我们只需要内存大小的 29 个高位,释放剩余的 3 位来编码其他的信息。我们用最低位来指明这个块是已分配的还是空闲的。

我们可以将堆组织成为一个连续的已分配块和空闲块的序列:

1a156208869fe82c069e3a0bd5ddc621.png

图 7.9.2 隐式空闲链表

我们称这种结构为隐式空闲链表,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中的所有块,从而间接地遍历整个空闲块的集合。

当一个应用请求一个 k 字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的。放置策略有三种:首次适配,从头开始搜索空闲链表,选择第一个合适的空闲块。

下一次适配,从上一次查询结束的地方开始,选择第一个合适的空闲块。

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

一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中的多少空间。一个选择是利用整个块,虽然这种方式简单而快捷,但是主要的缺点就是当所需空间小于分配的空闲块的空间时,会造成内部碎片。

分配器通常会选择将这个空闲块分割为两部分,一部分变成合适大小的分配块,剩下的变成新的空闲块。

c11dfac2536b8e43560b9bc25797a527.png

图 7.9.3 分割空闲块

当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻,这些空闲块导致一种假象:它们明明连在一起,却不能合在一起使用。比如两个 3 字节的空闲块连在一起,却无法成功申请一个 4 字节的空闲块,这就造成了假碎片。因此我们需要合并相邻的空闲块。这就出现了一个重要的策略决定,那就是何时执行合并。分配器可以选择立即合并,也就是在每次一个块被释放时,就合并所有的相邻块。或者它也可以选择推迟合并,例如直到某个分配请求失败,然后扫描整个堆,合并所有空闲块。快速的分配器通常会选择某种形式的推迟合并。

那么分配器是如何实现合并的?Knuth 提出了一种聪明而通用的技术:边界标记,这种思想是在每个块的结尾处添加一个脚部,脚部就是头部的一个副本。如果每个块包含这样一个脚部,那么分配器就可以通过检查它的脚部判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。

8e6cb38cf3360953909a10325c77cf3c.png

图 7.9.4 使用边界标记的堆块的格式

图 7.9.5 释放当前块的四种可能存在的情况及合并方式

隐式空闲链表为我们提供了一种介绍一些基本分配器概念的简单方法。然而,因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不合适的。一种更好的方法是将空闲块组织为某种形式的显示数据结构,即显式空闲链表,例如堆可以组织成一个双向的空闲链表,在每个空闲块中都包含一个 pred

(前驱)和 succ(后继)指针,如下图所示:

57501d9f5336e9240a8f4d7f422e0c7e.png

图 7.9.6 使用双向空闲链表的堆块的格式

7883b61578d5719b4b82d40b464d317c.png

图 7.9.7 显示空闲链表的分配

这样首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块可以是线性的,也可以是个常数,这取决于我们所选择的空闲链表中块的排序策略: 一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。

b1eeb657e46a27faedae7eea97261456.png

图 7.9.8 LIFO 回收策略

另一种方法是按照地址顺序来维护链表,其中链表每个块的地址都小于它后继的地址。在这种情况下释放一个块需要线性时间的搜索来确定合适的前驱。

一个使用显式空闲块链表的分配器通常需要与空闲块数量呈线性关系的时间来分配块。一种流行的减少分配时间的方法,通常称为分离存储,就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类。有很多方式来定义大小类。例如,我们可以根据 2 的幂次来划分块大小。或者我们可以将小的块分派到它们自己的大小类里,而将大块按照 2 的幂分类。

e5ce5fbaa5c349ca274aa197d18913f7.png

图 7.9.9 分离的空闲链表

当分配器需要一个大小为 n 的块时,它就搜索相应大小类的空闲链表,如果不能找到合适的块与之匹配,就搜索下一个空闲链表,以此类推。

为了让你大致了解有哪些可能的分离存储方法,我们简单介绍三种方法: 简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。例如,如果定义某个大小类为 17~32,那么这个类的空闲链表完全由大小为 32 的块组成。为了分配一个给定大小的块,我们检查相应的空闲链表。如果链表非空,分配其中的第一块,如果链表为空,分配器就向操作系统请求一个固定大小的额外内存片,将这个片分成大小相等的块,并将这些块链接起来形成新的空闲链表。要释放一个块,分配器只要简单地将这个块插入到相应的空闲链表的前部。

分离适配,使用这种方法,分配器维护着一个空闲链表的数组,每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是这个大小类的成员。为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果找到了一个,那么就(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到适合的块,那么就搜索下一个更大的大小类的空闲链表。如此重复直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置到合适的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。

伙伴系统,每个大小类都是 2 的幂。基本的思路是假设堆的大小为2 个字,我们为每个块大小2 维护一个空闲链表,其中 0 <= k <= m。请求块大小向上舍入到最接近的 2 的幂。为了分配一个块大小为2 的块,我们找到第一个可用的、大小为 2 的块,其中 k <= j <= m。如果 j = k 那么我们就完成了。否则我们递归地分割这个块,直到 j = k。当我们进行这样的分割时,每个剩下的半块(也叫做伙伴)被放置在相应的空闲链表中。要释放一个大小为2 的块,我们继续合并空闲的伙伴。当遇到一个已分配的伙伴时,停止合并。

7.10本章小结

虚拟内存是对主存的一个抽象。本章通过对虚拟内存的了解,学会了TLB和四级页表支持下VA到PA的转换,以及得到了PA后,三级cache下的物理内存的访问过程。通过本章内容,更深入掌握了fork函数和exceve函数和虚拟内存的种种联系,最后还学会了动态内存分配的管理。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一的方式来执行。

8.2 简述Unix IO接口及其函数

Unix IO接口:

打开文件,内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。

Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。

改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。

读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k

关闭文件。当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中

Unix IO函数:

1. open()函数

功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。

函数原型:int open(const char *pathname,int flags,int perms)

参数:pathname:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式,

返回值:成功:返回文件描述符;失败:返回-1

2. close()函数

功能描述:用于关闭一个被打开的的文件

所需头文件: #include <unistd.h>

函数原型:int close(int fd)

参数:fd文件描述符

函数返回值:0成功,-1出错

3. read()函数

功能描述: 从文件读取数据。

所需头文件: #include <unistd.h>

函数原型:ssize_t read(int fd, void *buf, size_t count);

参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。

返回值:返回所读取的字节数;0(读到EOF);-1(出错)。

4. write()函数

功能描述: 向文件写入数据。

所需头文件: #include <unistd.h>

函数原型:ssize_t write(int fd, void *buf, size_t count);

返回值:写入文件的字节数(成功);-1(出错)

5. lseek()函数

功能描述: 用于在指定的文件描述符中将将文件指针定位到相应位置。

所需头文件:#include <unistd.h>,#include <sys/types.h>

函数原型:off_t lseek(int fd, off_t offset,int whence);

参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)

返回值:成功:返回当前位移;失败:返回-1

8.3 printf的实现分析

在 linux 里,printf 函数是这样表示的:

  1. typedef char *va_list;
  2. static int printf(const char *fmt, ...)
  3. {
  4. va_list args;
  5. int i;
  6. va_start(args, fmt);
  7. write(1, printbuf, i = vsprintf(printbuf, fmt, args));
  8. va_end(args);
  9. return i;
  10. }

...是可变形参的一种写法,当传递参数的个数不确定时,就可以用这种方式来表示。

args是va_list 类型,也就是一个字符指针,可以理解为指向当前参数的一个指针,取参必须通过这个指针进行。然后对 args 进行初始化,让它指向可变参数表里面的第一个参数,这是通过 va_start 来实现的,第一个参数是 args 本身,第二个参数是在可变参数表前面紧挨着的一个变量,即 fmt 参数。我们并不知道 va_start 具体内容是什么,但其基本功能可以通过下面的方式实现:

1.

void

va_start(

va_list

args,

fmt)

2.

{

3.

args

=

(

va_list

)((

char

*)(

&fmt

)

+

4)

;

4.

}

C语言中,参数压栈的方向是从右往左。也就是说,当调用 printf 函数的时候,先是最右边的参数入栈。fmt 是第一个参数,那么也就是最后一个入栈的参数,&fmt 就是它在栈帧中的地址,由于栈是向下增长的,即从高地址向低地址方向增长的, fmt 是一个指针,它的大小是 4 个字节,所以(char*)&fmt+4 其实就是 fmt 下一个参数在栈帧中的地址。也就是...中第一个参数的地址,再强制转换为 char*类型,所以(char*)(&fmt) + 4 表示的是可变参数表中的第一个参数。

write 的第一个参数是 1,根据 8.2 小节可知,1 是标准输出文件的描述符,也就是说这里调用 write 要向标准输出文件中输出内容,printfbuf 是要输出的内容, i 是输出的字节数。

显然这里的vsprintf函数做了两件事:第一件是将要输出的内容写到printbuf中,第二件是返回输出的字节数。我们可以推测 vsprintf 的实现方式就是通过一个字符一个字符地分析 fmt 格式字符串,遇到%或者这样的标识符就对 args 进行相应地处理和移动,最后返回输出了多少字符。获取所有的参数之后,我们有必要将这个 args 指针关掉,以免发生危险,方法是调用 va_end,他使 args 置为 NULL,相当于 free。

无论如何 printf 函数都不能确定可变参数表究竟在什么地方结束,也就是说,它不道参数的个数。它只会根据 format 中的打印格式的数目依次打印栈帧中参数 format 后面地址的内容,这样就存在一个可能的缓冲区溢出问题。

接下来我们追踪 write 函数(下面的汇编代码是 Intel 格式): write: mov eax, _NR_write mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

这里是给三个寄存器传递了三个参数(就是 write 函数的三个参数),然后一个 int 指令结束,引发中断过程,中断号就是 INT_VECTOR_SYS_CALL 使系统进入陷阱处理。我们可以找到 INT_VECTOR_SYS_CALL 的实现:

init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER);

INT_VECTOR_SYS_CALL 是中断号,DA_386IGate 表示一个中段门,sys_call 表示要调用 sys_call 函数,PRIVILEGE_USER 推测应该是访问权限。

也就是说中断号为 INT_VECTOR_SYS_CALL 的系统调用函数就是 sys_call 再来看看 sys_call 的实现(还是 Intel 格式): 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

可以看到 sys_call 里又 call 了一个 sys_call_table + eax * 4,从 write 中知道 eax 中存的是_NR_WRITE,_NR_WRITE 是系统调用号,sys_call_table 是系统调用分派表,显然系统要到系统调用分派表的相应位置去执行系统函数了。

write 函数所对应的系统函数是 sys_write,我们可以在系统内核中查找到这个函数的具体内容。但过于复杂,在这里就不再继续介绍了。总之系统最终会调用字符显示驱动子程序,将每个 ASCII 码对应到已经存储好的字模库中,然后存储在 vram(存储每一个点的 RGB 颜色信息)中。显示芯片按照刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量)。

8.4 getchar的实现分析

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

当程序调用 getchar 时,程序就等着用户按键。当系统检测到键盘输入时,会触发异步异常-键盘中断的处理,运行键盘中断处理子程序。接受按键扫描码转成 ascii 码,保存到系统的键盘缓冲区。getchar 调用 read 系统函数,与 write 类似, read 也通过中断程序调用内核函数 sys_read,通过系统调用读取按键 ASCII 码。当用户键入回车之后,getchar 才开始从 stdin 流中每次读入一个字符。getchar 函数的返回值是用户输入的字符的 ASCII 码,若文件结尾(End-Of-File)则返回-1(EOF)。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续 getchar 调用读取。也就是说,后续的 getchar 调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。

8.5本章小结

本章节讲述了一下linux的I/O设备管理机制,了解了开、关、读、写、转移文件的接口及相关函数,简单分析了printf和getchar函数的实现方法以及操作过程。这些标准 I/O 函数的实现都调用了系统的 I/O 函数,并通过中断指令将程序控制权交给系统内核,进行相应的中断处理,然后对硬件进行操作。

结论

每个程序员第一个程序都是hello,虽然所用语言不同,但是里面的道理是相通的。一个简单的hello程序背后所蕴含的知识包括了计算机系统的方方面面,本文详细的介绍了hello的坎坷一生:从诞生开始由I/O设备编写为hello.c,存储在计算机磁盘中。

hello.c 经过预处理成为了 hello.i。hello.i 经过编译器,被编译成汇编代码 hello.s,这个过程十分繁琐,编译器对源代码进行了词法、语法和语义分析,又层层优化,编写成对应的代码,并通过伪指令来为汇编过程添加各种注释。然后hello.s经过汇编阶段被翻译为可重定位目标文件hello.o,hello.o 在链接阶段,通过链接器,与其他必要的可重定位目标文件、共享库链接,并对符号进行重定位,最后生成可执行目标文件 hello。

待一切准备活动完成后,hello便在舞台上开始他的高光时刻了。程序的运行需要在进程这个载体之上,LINUX的shell通过fork为hello创建一个新进程,然后调用execve在进程上运行hello程序。Shell调用mmap函数创建新的虚拟内存空间,构建内存映射。计算通过内核和各种内存硬件对内存进行翻译访问和加速。同样,程序的运行需要严格的时间控制,内核通过上下文切换来调度,为我们的hello程序分配时间片。

当hello在舞台上出现异常甚至错误时,内核也准备了相当充分的异常处理程序来解决各种问题。Linux的信号机制可以帮助hello在运行时对外部的各类指令做出反应。最后再hello下台时,shell调用waitpid函数来回收进程,释放内存,删除所有创建的数据结构。Hello的一生至此完美谢幕。

九层之台起于累土,计算机科学与技术专业发展了这么多年,知识已经十分繁杂了,想要一次性学通所有并不现实,本文旨在于帮助我们理清脉络。一个优秀的程序员是不会只懂代码,但是对程序如何在计算机上运行一概不知的。并且,计算机系统的知识能够帮助我们优化代码,写出程序性能更好的程序。

附件

ad60337ed88b64011ec9d8eec22c6f3a.png

hello.i:hello.c 预处理后生成的文件,用于分析预处理阶段

hello.s:hello.i 编译后生成的汇编代码文件,用于分析编译阶段

hello.o:hello.s 经汇编后生成的可重定位目标程序,用于分析汇编阶段

hello:hello.o 经链接后生成的可执行目标程序,用于分析链接阶段

hello1.elf:hello.o 的 elf 格式信息,用于分析可重定位目标程序的 elf 信息和分析重定位过程

hello2.elf:hello 的 elf 格式信息,用于分析可执行目标程序的 elf 信息

hello1.objdump:hello.o 的反汇编文件,用于与 hello.s 比较分析汇编代码

hello2.objdump:hello 的反汇编文件,与 hello1.objdump 进行对比,用于分析重定位过程

参考文献

  1. BryantO’Hallaron, D.R.R.E.,. (2019). 深入理解计算机系统(第三版). 北京: 机械工业出版社.
  2. C 预处理器 | 菜鸟教程 . 检索来源 : 菜鸟教程 :

https://www.runoob.com/cprogramming/c-preprocessors.html

  1. Pianistx. (2013 年 9 月 11 日). [转]printf 函数实现的深入剖析. 检索来源: 博

客园: https://www.cnblogs.com/pianist/p/3315801.html

  1. 百 度 百 科 : 编 译 器 . 检 索 来 源 : 百 度 百 科 :

https://baike.baidu.com/item/%E7%BC%96%E8%AF%91%E5%99%A8

  1. 百 度 百 科 : 编 译 原 理 . 检 索 来 源 : 百 度 百 科 :

https://baike.baidu.com/item/%E7%BC%96%E8%AF%91%E5%8E%9F%E7% 90%86/4194

  1. 百 度 百 科 : 预 处 理 . 检 索 来 源 : 百 度 百 科 :

https://baike.baidu.com/item/%E9%A2%84%E5%A4%84%E7%90%86

  1. 百度百科:预处理命令 . 检索来源 : 百度百科 :

https://baike.baidu.com/item/%E9%A2%84%E5%A4%84%E7%90%86%E5%9

1%BD%E4%BB%A4

  1. 柏 666. (2019 年 6 月 4 日 ). 02. 汇编指令 . 检索来源 : 简书 :

https://www.jianshu.com/p/7ec425403779

  1. 嘿哈哈哈. (2019 年 1 月 8 日). 编译原理:总结. 检索来源: CSDN:

https://blog.csdn.net/qq_39384184/article/details/86037568

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值