程序人生-Hello’s P2P

原文GitHub地址:https://github.com/WeihuaSun/ICS-helloP2P

摘  要

     为研究程序从原始C代码文件到到加载运行,再到终止的全过程,本实验在linux系统环境下,依托一个hello.c文件,对其进行预处理,编译,汇编及链接操作,并对上述过程原理解释和细节分析;同时演示了程序加载,信号处理,内存访问,输出信息,终止退出的过程,并对其进行分析解释。通过实验,实现了对C语言程序从产生到终止的过程分析和原理解释。

关键词:C语言;编译;汇编;预处理;链接;信号;内存映射;Unix I/O;      

目录

第1章 概述

1.1 Hello简介

1.2 环境与工具

1.3 中间结果

1.4 本章小结

第2章 预处理

2.1 预处理的概念与作用

2.1.1概念

2.2.2作用

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.1.1 概念

3.1.2 作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.3.1数据实现

3.3.2算数操作

3.3.3关系操作

3.3.4控制转移

3.3.5数组操作

3.3.6函数调用及其参数传递

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.1.1概念

4.1.2 作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

4.3.1 ELF 头

4.3.2节头部表

4.3.3重定位信息

4.3.4 .symtab

4.4 Hello.o的结果解析

4.5 本章小结

第5章 链接

5.1 链接的概念与作用

5.1.1 概念

5.1.2 作用

5.2 在Ubuntu下链接的命令

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

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.6 hello的执行流程

5.7 Hello的动态链接分析

5.8 本章小结

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1概念

6.1.2作用

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

6.2.1作用

6.2.2处理流程

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.6.1正常运行

6.6.2乱按任意字母

6.6.3乱按回车

6.6.4 按下Ctrl-Z

6.6.5按下Ctrl-C

6.7本章小结

第7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1逻辑地址

7.1.2线性地址

7.1.3虚拟地址

7.1.4物理地址

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

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

7.3.1页表

7.3.2地址翻译

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

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

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

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

7.9动态存储分配管理

7.9.1 带边界标签的隐式空闲链表分配器原理

7.9.2显式空间链表的基本原理

7.10本章小结

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

8.2 简述Unix IO接口及其函数

 8.2.1打开文件

8.2.2 读写文件

8.2.3关闭文件

8.3 printf的实现分析

8.4 getchar的实现分析

8.5本章小结

结论

附件

参考文献


第1章 概述

1.1 Hello简介

Hello.c文件通过预处理器,进行预处理,对c程序进行初步的解释和修改。预处理后的程序经过编译,生成汇编代码程序,实现了从高级语言到低级语言的转变。通过汇编器,由汇编程序生成可重定位目标文件,这是一个二进制的机器代码文件。最后经过链接器,将可重定位的文件进行符号解析和重定位,生成可执行目标文件。现在hello做好了它运行之前的准备工作,已经可以加载到内存运行了。

通过shell,当输入./hello命令行时,shell将执行加载hello程序的过程。通过调用fork和execve函数,将hello程序加载到内存中,并修改程序计数器的值,运行hello程序。

在hello程序运行中,需要进行共享库的链接,来完成对printf函数的调用。同时,printf函数会引起系统调用的陷阱以及malloc动态内存分配,将代码中的文本输出到屏幕中。

当hello程序运行结束后,shell回收这个子进程。Hello的一生到此结束。

1.2 环境与工具

硬件环境:

Intel(R) Core(TM) i5-9400 CPU @ 2.90GHz   2.90 GHz;

虚拟机分配内存4G;

虚拟机分配硬盘空间:40G;

软件环境:

Ubuntu 20.04 LTS 64;

Vmware 15.5;

调试工具:

code::blocks

gcc

edb

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

文件

内容

hello.c

原始C程序

hello.i

预处理文件

hello.s

汇编文件

hello.o

可重定位目标文件

hello

可执行目标文件

helloelf_o.txt

hello.o使用readelf指令后产生的内容

helloelf_out.txt

hello使用readelf指令后产生的内容

helloobjdump_o.txt

hello.o使用objdump指令后产生的内容

helloobjdump_out.txt

hello使用objdump指令后产生的内容

1.4 本章小结

本章概括性的描述了hello程序从产生到结束的全过程,包括c程序经过处理形成可执行目标文件,以及从可执行目标文件到加载到内存运行,最后终止被回收的全过程。本实验所有内容均在Ubuntu下进行。

第2章 预处理

2.1 预处理的概念与作用

2.1.1概念

预处理器根据以字符#开头的命令,修改原始的C程序。具体操作是尝试将#起始的行,解释为预处理指令,其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)[1] [2]

一个具体的例子。在hello.c中的第一行#include命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入到程序文本。

经过预处理后得到了另一个C程序,通常是以.i作为文件的扩展名。

2.2.2作用

C预处理器提供如下独立的功能。

源文件包含(#include):将头文件的直接内容复制到程序的文本中。

条件编译(#if/#ifdef/#ifndef/#else/#elif/#endif):按照不同的条件去编译代码,从而产生不同的目标文件。这有助于程序的移植和调试。

宏定义(#define): 将宏名替换为字符串,这增加了程序的可读性,同时也方便书写和修改程序;此外,还可以通过宏来定义表达式。

行控制(#line):使用程序将源文件合并或重新排列为一个中间文件,然后对其进行编译,则可以使用行控制来通知编译器每个源文件行源自何处。

此外还有上文提到的#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)等。

在《深入理解计算机系统》的第七章,编译时打桩过程中,通过调整预处理器的搜索顺序,实现调用包装函数替换目标函数。

2.2在Ubuntu下预处理的命令

预处理命令:gcc -E hello.c -o hello.i

图2-1:预处理过程

2.3 Hello的预处理结果解析

查看预处理生成后的hello.i文件,代码起始的三行#include指令已经被替换为对应的头文件的内容。

图2-3-1 hello.i头文件替换

       除去#include后的内容被替换之外,main函数的内容与hello.c保持一致。

      

图2-3-2 非#开头指令不改变

2.4 本章小结

       C预处理就是一个简单的替换的过程。预处理器通过识别以#起始的指令行,实现对于代码内容的替换,支持包括条件编译、宏定义、源文件包含、行控制、#error错误指令、实现相关的杂注等功能的实现。

第3章 编译

3.1 编译的概念与作用

3.1.1 概念

编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。GCC C语言编译器以汇编代码的形式产生输出,汇编代码即机器代码的文本表示。

3.1.2 作用

代码转换:汇编代码转换机器码,这与具体的机器有关。

  优化程序性能 :编译器提供的优化机制,在一定程度上提高了程序运行的效率。 

检查程序错误:编译器提供的类型检查能帮助程序员发现部分程序错误并能够保证按照一致的方式来引用和处理数据。

3.2 在Ubuntu下编译的命令

编译命令:gcc -S hello.i -o hello.s

图3-2-1:编译

3.3 Hello的编译结果解析

3.3.1数据实现

在hello.s中出现了数字常量,字符串常量,局部变量,下面对各个数据进行分析。

数据

类型

实现

int i/i=0

整型局部变量

movl      $0, -4(%rbp),存储在栈中,且被初始化为立即数$0。

exit(1)中的1

整型常量

movl      $1, %edi,立即数$1

argc!=4

整型常量

cmpl      $4, -20(%rbp),立即数$4

i<8中的8

整型常量

cmpl $7, -4(%rbp),这里因为使用jle指令,所以用立即数$7来代替8

argv[1]、argv[2]、argv[3]中的1、2、3

整型常量

addq      $16, %rax

addq      $8, %rax

addq      $24, %rax

这是根据参数的大小,访问栈中的数据

return 0的0

整型常量

movl      $0, %eax,立即数$0

"用法: Hello 学号 姓名 秒数!\n"

字符串常量

leaq .LC0(%rip), %rdi,.LC0指向字符串常量的位置

"Hello %s %s\n"

字符串常量

leaq .LC1(%rip), %rdi,.LC1指向字符串常量的位置

3-3-1 数据实现

可以看到,对于不同的数据类型,实现方式是不同的。整型常量一般通过立即数实现,变量一般都存储在栈中。同时还应该注意到,局部变量i声明和赋值是在一起的,对i的操作第一次出现在for循环的汇编代位置,而不是出现在main函数的起始位置。而且,根据不同的数据类型及其所占空间,汇编代码会使用不同的指令。例如对于整型数据,使用cmpl,movl等。

3.3.2算数操作

在hello文件中,对于局部变量i的加法通过addl    $1, -4(%rbp)实现,这行代码解释为,将-4(%rbp)位置存储的局部变量i的值增加1,并存储到-4(%rbp)位置。

3.3.3关系操作

在hello.s中,有两个地方需要判断两个数据的关系。分别是if条件语句的条件判断和for循环语句的循环控制。这将在下一部分对于控制转移的说明中具体展开。

3.3.4控制转移

Hello.c中主要有两个控制转移的部分。一个是if条件语句,另一个是for循环语句。

首先是条件语句if(argc!=4)。

对于这个if语句,汇编代码使用cmpl指令和je指令完成。

观察第一条指令cmpl    $4, -20(%rbp),注意到,在上面的代码中,-20(%rbp)中存储的正是参数argc的值,所以这条指令比较argc和4的大小关系。

紧接其后的是je指令,这条指令的意义是,当cmpl的比较结果为相等的时候,程序跳转到.L2指示的位置;否则忽略这条指令,继续往下执行。

所以.L2所指示位置的代码,正是if条件不满足时所要进行的工作;而26-29行,则是满足if条件(argc!=4)时所进行的工作。这四行代码调用puts函数输出信息,并以1为参数调用exit。

图3-3-1:if条件语句

下面分析循环语句for(i=0;i<8;i++)。

对于这个语句,汇编代码主要使用cmpl和jle两个指令完成。

在31行movl    $0, -4(%rbp),这是将局部变量i存储在栈中,并把其值初始化为0。

52行.L3是for循环的开始。第一行cmpl     $7, -4(%rbp)比较局部变量i和立即数7的大小关系。       jle   .L4这行指令,根据上一行指令的比较结果进行跳转。若结果为小于或者等于,则跳转到.L4所指示的位置,否则忽略这条指令,继续往下运行。

.L4正是for循环内部的操作,可看到,在.L4的最后一行,执行addl      $1, -4(%rbp),将局部变量i的值增加1,这相当于实现i++。

当代码继续往下运行,再次到达.L3,继续判断局部变量i和立即数7的大小关系,直到i大于7,for循环结束。

图3-3-2:for循环控制

3.3.5数组操作

这里主要体现的是对char *argv[]的访问操作。在main函数开始时,程序将argv存储在-32(%rbp)位置(下一节有详细说明)。这个数组中包含的内容为指针。因为程序被编译为64位,所以指针的大小为8个字节。程序首先将数组首地址赋值给%rax寄存器,然后通过addq$16, %rax访问第3个数组元素argv[2],addq   $8, %rax访问第2个数组元素argv[1] ,addq$24, %rax访问第4个数组元素argv[3]。这三条指令,使得%rax中指向待操作元素的首地址,接下来movq  (%rax), %rax将地址中的内容复制到%rax中,实际上,这也是个地址。

图3-3-3:数组操作

3.3.6函数调用及其参数传递

首先对int main(int argc,char *argv[])进行分析。

main函数有两个参数,一个是int argc另一个是char*argv。这两个参数是分别存储在寄存器%edi和寄存器%rsi。在main函数的开始,汇编程序将这两个参数放入刚申请的栈中。

具体操作为:

movl      %edi, -20(%rbp)

      movq     %rsi, -32(%rbp)

图3-3-4main函数参数操作

下面分析程序中几处对puts函数的调用。

首先是printf("用法: Hello 学号 姓名 秒数!\n");

在汇编代码中通过下面两行代码实现。第一行leaq   .LC0(%rip), %rdi,将参数的地址传递复制到%rdi寄存器中,这个寄存器将参数传递给下面调用的函数。然后call   puts@PLT调用puts函数输出参数地址所存储的字符串。

图3-3-5:puts调用 1

接下来是printf("Hello %s %s\n",argv[1],argv[2]);

在下图圈出的三行指令,分别是将argv[2]、argv[1]和字符串地址参数复制到寄存器中,准备接下来的调用。当调用puts函数的时候,这三个寄存器的值将作为参数传递给puts函数。其中%rdi中存储的是字符串的地址。

图3-3-6:puts调用 2

此外,hello中还有对exit的调用。程序先将立即数1传递到寄存器%edi中,接下来以1为参数调用exit函数。

图3-3-7:exit调用

同时也有对atoi,getchar,sleep函数的调用,实现方法都是先将参数传递到寄存器中,然后使用call指令调用相关函数。

图3-3-8:atoi,getchar,sleep调用

除了函数的调用,还有函数的返回操作。

在main函数的最后,使用return 0实现返回。而在汇编代码中,使用三行指令完成函数返回。

第一行movl      $0, %eax将函数返回值写导寄存器%eax中。

第二行leave相当于两条指令,即movq %rbp, %rsp 和popl %rbp。这两条指令恢复main函数运行之前的%rsp的值,并恢复%rbp,栈顶保存着返回地址。

ret指令使得程序跳转到栈顶指针所指的位置,并执行一次pop操作,弹出栈顶所存储的返回地址。

图3-3-9:函数返回

3.4 本章小结

编译指的是将高级语言翻译为相应机器代码的过程。C语言的编译生成的是汇编代码。在hello.s中,汇编代码实现了数据操作,算术操作,关系操作,数组操作,控制转移,函数操作等功能,并对这些代码进行了具体分析。

第4章 汇编

4.1 汇编的概念与作用

4.1.1概念

汇编器将汇编代码翻译成机器语言指令,并将这些指令打包为可重定位目标程序的格式,并将结果保存在二进制文本(.o)文件中。[3]

4.1.2 作用

   将汇编代码翻译成机器可以执行的机器代码。

4.2 在Ubuntu下汇编的命令

命令:gcc -c hello.s -o hello.o

图4-2-1 汇编

4.3 可重定位目标elf格式

4.3.1 ELF

       ELF 头 以一个16 字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF 头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64) 、节头部表(section header table) 的文件偏移,以及节头部表中条目的大小和数量。[4]

图4-3-1:ELF Header

4.3.2节头部表

节头部表描述了不同节的位置和大小。

其中[Nr]是节的序号,Name是节的名称,Type是节的类型,Address描述偏移的起始位置,可以看到,从0开始。off是偏移地址,也就是说,每一节都是从off所指的位置作为起始。Size则记录了每一节的大小,Flg是每一节的标识,记录了每一节的一些特性。可以查询下面Key to Flags部分查询每个符号的信息。

图4-3-2:Section Headers

4.3.3重定位信息

.rela.text是一个.text 节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。

Offset记录了这些待重定位值的偏移地址,Info中包含了它们的一些信息,Type则指明了这些重定位的目标的类型,这也确定了重定位的方式。

可以看到,第一行,和第四行重定位的地址确定在.rodata节,这是对静态字符常量的重定位,其他的条目是对外部函数的重定位信息。当外部函数的地址确定的时候,用他们的地址替换待重定位地址。

图4-3-3 重定位信息

4.3.4 .symtab

符号表,它存放在程序中定义和引用的函数和全局变量的信息。这张符号表包含一个条目的数组。

图4-3-4 符号表条目

下面分析以readelf读取的符号表的各个条目。

Value项是符号的地址,对于hello的可重定位目标文件来说,value是距定义目标节的起始位置的偏移,在可执行目标文件中,这将是一个绝对地址。Bind项则标识该符号是全局变量还是局部变量。Type项区别该符号是数据还是函数。Size则给出字符的最小的大小。Ndx(section)则指明了这些符号被分配到了哪些节,其中数字指的是真实存在的节,这些数字与Section Headers中节的序号一一对应;而ABS和UND是伪节,其中ABS代表不该被重定位的符号,UND代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号;。

一个具体的例子:在表的第9行,是main函数的有关条目。可以看到,main(Name指向main)函数是一个位于.text节(Ndx=1)中,偏移量为0(value=0)处的146字节(Size=146)的函数(Type=FUNC)。

11-17行的Ndx项都显示为UND,这是因为这些符号虽然在main函数调用了,但是在别的地方定义,显然调用库函数是UND的。

图4-3-5  . symtab 符号表

4.4 Hello.o的结果解析

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

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

反汇编与汇编语言在指令上是一一对应的,在细节上有略微的区别。反汇编是根据机器代码文件来确定汇编代码。

在hello程序中,它们的区别如下:

1.指令的命名规则不同:反汇编代码省略了很多指令结尾的‘q’,如mov,sub指令后面的q均被省略,但是反汇编语言在ret和call指令后面添加了‘q’后缀。

2.分支转移函数操作数不同:在hello.s中,跳转的操作时是.Lx的标识位置的符号,而在hello.o的反汇编代码中,跳转指令的操作数变为了一个相对main函数起始位置的偏移量地址。

3.函数调用操作数不同,在汇编代码中,函数调用指令call后的操作数是一个函数名,而在反汇编代码中,callq后面的操作数是00 00 00 00,其后有一串重定位信息,在重定位后,这个地址被替换为被调用函数的地址。

4.对只读数据区的数据访问操作不同:在hello.s中,访问一个字符常量的地址所用的指令为leaq       .LC0(%rip), %rdi,而在反汇编代码中,为lea 0x0(%rip),%rdi       

1c: R_X86_64_PC32 .rodata-0x4。这也是将注记符.LC0,替换为0,并增加可重定位信息,在重定位的时候,替换相应地址。

图4-4-1汇编与反汇编代码比较

4.5 本章小结

      汇编指的是汇编器将汇编代码翻译成机器语言指令,并将这些指令打包为可重定位目标程序的格式。汇编生成的可重定位目标格式的文件包含ELF头,.text节,.rodata节,.data节,.bss节,符号表,.rel.text节,.real.text节,.line节,.debug节,.line节,.strtab和节头部表等信息。

反汇编代码与汇编代码在指令上是一一对应的,但是细节上有细微的差别。具体体现在命名规则,条件转移,函数调用等方面。

 

第5章 链接

5.1 链接的概念与作用

5.1.1 概念

链接(linking) 是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行千编译时(compile time) ,也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time) ,也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time) ,也就是由应用程序来执行。[5]

5.1.2 作用

链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。[6]

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

图5-2-1 链接

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

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

ELF头:描述了ELF文件的一些基本信息。

图5-3-1 ELF头

节头部表:包含各节的基本信息。Name项指向节的名字。Address指向各个节的起始地址,Off表示节偏移量。

图5-3-2 节头部表

动态链接库的重定位信息。

图5-3-3动态链接库的重定位信息

5.4 hello的虚拟地址空间

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

图5-4-1 edb加载hello

首先查看.interp段的内容,由5.3中的节头部表,可以直到该段的起始地址为0x4002e0,使用edb的Data Dump查看该段内容,其内容为: /lib64/ld-linux-x86-64.so.2.这是链接器的信息。

图5-4-2 .interp

再查看.dynstr节,其内容为,.libc.so.6.exit.puts.printf.getchar.atoi.sleep.

_libc_start_main.GLIBC_2.2.5.gmon_start__ 这包含了共享库的一些信息。

图5-4-3 .dynstr

然后查看.rodata节,包含一些字符串常量。

图5-4-4 .rodata

其他节的内容不再赘述。

5.5 链接的重定位过程分析

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。、

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

Hello的反汇编代码相对于hello.o的反汇编代码,增加了一些节,如.init

有一些初始化的操作,以及.plt和.plt.sec和调用的库函数有关。

在mian函数中,两个版本的反汇编代码也有不同。

可以看到,在左边的hello.o的反汇编代码中,对函数的调用和对字符串常量的引用都带有重定位信息,而在hello的反汇编代码中,调用和引用都变成了一个实际的地址,而且没有重定位的信息。

图5-5-1 hello与hello.o的区别

下面分析链接重定位的过程。

重定位有两步组成:

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

重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目。

先看一下在hello.o的重定位条目。在这里有两种重定位方式,一种是R_X86_64_PC32,另一种是R_X86_64_PLT32。

对于第一种是比较熟悉的相对地址方式的重定位。重定位一个使用32 位PC 相对地址的引用。一个PC 相对地址就是距程序计数器(PC) 的当前运行时值的偏移量。当CPU 执行一条使用PC 相对寻址的指令时,它就将在指令中编码的32 位值加上PC 的当前运行时值,得到有效地址(如call指令的目标), PC 值通常是下一条指令在内存中的地址。

在hello中,对于两个字符串的引用,通过这种方式完成重定位。

重定位后的两条指令如下lea 0x2c6c(%rip),%rbp 、lea  0xec3(%rip),%rdi。可以看到,都是相对于PC的寻址方式。

第二种重定位是动态链接的重定位,在程序加载的时候,完成动态链接的过程。

图5-5-2 重定位条目表

5.6 hello的执行流程

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

ld-2.31.so!_dl_start

ld-2.31.so!_dl_init

hello!_start 0x400500

libc-2.31.so!__libc_start_main

-libc-2.31.so!__cxa_atexit

-libc-2.31.so!__libc_csu_init

hello!_init

libc-2.31.so!_setjmp

-libc-2.31.so!_sigsetjmp

–libc-2.31.so!__sigjmp_save

hello!main

hello!puts@plt

hello!exit@plt

*hello!printf@plt

*hello!sleep@plt

*hello!getchar@plt

ld-2.31.so!_dl_runtime_resolve_xsave

ld-2.31.so!_dl_fixup

ld-2.31.so!_dl_lookup_symbol_x

libc-2.31.so!exit

5.7 Hello的动态链接分析

在加载可执行文件时,加载器发现在可执行文件的程序头表中有.interp段,其中包含了动态连接器路径ld-linux.so . 加载器加载动态链接器,动态链接器完成相应的重定位工作后,再将控制权交给hello。

hello有自己的GOT 和PLT 。GOT 是数据段的一部分,而PLT 是代码段的一部分。

PLT 是一个数组,其中每个条目是16 字节代码。PLT[0] 是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT 条目。每个条目都负责调用一个具体的函数。下图是hello的PLT数组。

图5-7-1 PLT数组

GOT 是一个数组,其中每个条目是8 字节地址。和PLT 联合使用时, GOT[O] 和GOT[l] 包含动态链接器在解析函数地址时会使用的信息。GOT[2] 是动态链接器在ld-linux.so 模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT 条目。[7]

下面分析printf函数的动态链接。第一次调用printf时,程序进入PLT表,跳转到GOT表的对应条目。因为每个GOT 条目初始时都指向它对应的PLT 条目的第二条指令,这个间接跳转只是简单地把控制传送回PLT中的下一条指令。在把printf的ID压入栈之后,跳转到PLT[0]。再通过动态链接器重写对应printf的GOT条目信息,最后把控制传递给printf。

由图5-3-3动态链接库的重定位信息可知,printf的GOT条目地址为0x404020。在调用printf之前,其对应GOT条目信息为0x404010,指向PLT表。当调用printf后,对应条目修改为0x7fc29994ee10,为共享的printf函数的地址。

图5-7-2 printf调用前GOT表

图5-7-3 printf调用后GOT表

5.8 本章小结

本章介绍了链接的基本概念和作用。结合hello程序,分析了其静态链接的重定位过程,动态链接共享库的过程。在此基础上,比较分析链接前后hello程序的区别,以及运行中hello对于信号处理的过程。

 

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1概念

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

6.1.2作用

进程是对处理器、主存和I/0 设备的抽象表示。进程的抽象形成一种假象:程序“似乎”是系统中当前运行的唯一的程序,独自占用硬件。

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

Linux系统的shell作为操作系统的外壳,为用户提供使用操作系统的接口。它是命令语言、命令解释程序及程序设计语言的统称。

6.2.1作用

shell是用户和Linux内核之间的接口程序,如果把Linux内核想象成一个球体的中心,shell就是围绕内核的外层。当从shell或其他程序向Linux传递命令时,内核会做出相应的反应。

shell是一个命令语言解释器,它拥有自己内建的shell命令集,shell也能被系统中其他应用程序所调用。用户在提示符下输入的命令都由shell先解释然后传给Linux核心。

6.2.2处理流程

shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。

然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。

如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。

如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。

6.3 Hello的fork进程创建过程

在Linux终端输入./hello运行hello程序。此时父进程Shell调用fork函数创建一个新的运行的子进程hello。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同(但是相互独立)一份副本,包括代码段、数据段、堆、共享库及用户栈。子进程hello与父进程Shell最大的区别在于它们之间有不同的PID。

fork 函数只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork 返回子进程的PID。在子进程中,fork 返回0。

6.4 Hello的execve过程

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

在execve 加载了hello之后,调用启动代码设置栈,并将控制传递给hello的主函数,该主函数具有如下函数原型:

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

当main开始执行的时候,用户栈的组织结构如下图所示。这其中包括参数和环境字符串,环境变量和参数数组。

图6-4-1 execve后程序内存映像

6.5 Hello的进程执行

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

当通过向shell 输入可执行目标文件hello,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行hello。

通过Shell的操作,内核中的调度器决定抢占Shell进程,使得Shell进程暂时停止运行,并保存Shell的上下文,然后将hello程序的上下文准备就绪。然后内核将控制传递到hello进程,当hello进程执行结束的时候,内核再次使用调度器,恢复shell的上下文,将控制换回到shell。

注意到hello程序中有sleep的系统调用,这个函数显示地请求让调用进程(hello)休眠,这个时候内核仍然会执行上下文切换,运行其他的程序。所以在执行hello期间内核的上下文切换可能是非常频繁的。

图6-5-1 上下文切换

但是程序看起来仍然是独占整个硬件,而且好像系统其他的程序也仍然在正常的运行,这就是所谓的并发流。在hello运行的过程中,hello和其他的进程轮流运行,实现时间分片。如下图所示:

图6-5-2 并发流

6.6 hello的异常与信号处理

6.6.1正常运行

运行8次后,从键盘输入任意字符,回车后程序结束。

图6-6-1 正常运行

6.6.2乱按任意字母

程序仍然正常运行。

图6-6-2 按下任意字母

6.6.3乱按回车

程序继续正常运行,但是运行结束后执行了几个回车命令。

对于以上这三种输入,会引起输入中断,但是对于hello程序的执行几乎没有影响。

图6-6-3 乱按回车

6.6.4 按下Ctrl-Z

当按下Ctrl-Z,内核给前台进程hello发送SIGTSTP信号,这个信号使得hello程序被暂时挂起,暂停执行。

图6-6-4-a 按下Ctrl-Z

此时输入ps命令查看当前进程的状态,看到有三个程序,分别是hello,ps和bash。

图6-6-4-b 输入ps指令

然后输入jobs查看当前作业的内容,发现hello程序已经被挂起。

图6-6-4-c 输入jobs指令

输入pstree 查看bash的进程树,发现bash下有两个子进程,一个是hello,一个是pstree.

图6-6-4-d 输入pstree指令

输入fg命令,前台进程hello继续执行。

图6-6-4-e 输入fg指令

当程序被挂起的时候,我们输入kill 指令到hello程序,然后发现hello程序的状态已经变成终止状态(Terminated)。

图6-6-4-f 输入kill指令

6.6.5按下Ctrl-C

按下Ctrl-C时,内核给hello发送SIGINT信号,收到这个信号后,信号处理程序使得hello程序终止。

图6-6-5按下Ctrl-C

6.7本章小结

通过引入进程的抽象,使得每个程序近乎独占地使用硬件。进程之间通过上下文切换产生并发流,使得多个程序并发执行。

程序的加载离不开fork和execve创建和加载程序。fork函数创建和父进程内存映像几乎一样的子进程,execve加载一个新的进程。

程序运行过程中,通过信号的传递,实现对异常的处理。Linux信号是一种更高层次的软件形式的信号。

 

第7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1逻辑地址

       在有地址变换功能的计算机中,访内指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。对于hello来说,汇编指令给出的地址是逻辑地址。

7.1.2线性地址

       如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。对于hello来说,就是它映射的虚拟内存和物理内存的地址空间都是连续的整数。

7.1.3虚拟地址

       CPU产生用来访问主存的地址叫做虚拟地址。CPU通过生成一个虚拟地址,然后通过内存管理单元,翻译虚拟地址为物理地址,访问主存。对于hello来说,他是一个独立的地址空间,hello程序独占这个虚拟地址空间。

7.1.4物理地址

计算机系统的主存被组织成一个由M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。第一个字节的地址为0,接下来的字节地址为1, 再下一个为2,依此类推。对于hello程序来说,它的物理地址,就是它存储在主存的数组中的数组索引。

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

       段寄存器,用于存放段选择符。其中,CS(代码段)是程序代码所在段SS(栈段)指示栈区所在段;DS(数据段)指示全局静态数据区所在段;其他3个段寄存器ES、GS和FS可指向任意数据段。

        段选择符各字段含义:

图7-2-1 段选择符结构

TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT);RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级;高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置。

被选中的段描述符先被送至描述符cache,每次从描述符cache中取段基址,与段内偏移量(有效地址)相加得到线性地址。

图7-2-2 32位线性地址翻译

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

       这里所说的线性地址指的是虚拟地址。先介绍页表的概念,最后阐述如何通过页表以及软硬件实现从虚拟地址到物理地址的翻译。

7.3.1页表

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

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

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

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

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

页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。

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

7.3.2地址翻译

CPU 中的一个控制寄存器,页表基址寄存器指向当前页表。n 位的虚拟地址包含两个部分:

一个p 位的虚拟页面偏移(VPO) 和一个(n-p) 位的虚拟页号(VPN) 。MMU 利用VPN 来选择适当的PTE。将页表条目中物理页号(PPN) 和虚拟地址中的VPO 串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P 字节的,所以物理页面偏移(PPO) 和VPO 是相同的。

图7-3-1 虚拟地址和物理地址转换

具体的地址翻译步骤为:

第1 步:处理器生成一个虚拟地址,并把它传送给MMU。

第2 步:MMU 生成PTE 地址,并从高速缓存/主存请求得到它。

笫3 步:高速缓存/主存向MMU返回PTE。

第4 步:MMU 构造物理地址,并把它传送给高速缓存/主存。

第5 步:高速缓存/主存返回所请求的数据字给处理器。

图7-3-2 地址翻译

页面命中完全是由硬件来处理的,与之不同的是,处理缺页要求硬件和操作系统内核协作完成:

笫1 步到笫3 步:和上述第1 步到第3 步相同。

第4 步: PTE 中的有效位是零,所以MMU 触发了一次异常,传递CPU 中的控制到操作系统内核中的缺页异常处理程序。

笫5 步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。

第6 步:缺页处理程序页面调入新的页面,并更新内存中的PTE 。

第7 步:缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU 将引起缺页的虚拟地址重新发送给MMU。[10]

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

与7.3节步骤大致相同,但是增加了对TLB的访问和对4级页表的使用。

TLB是PTE的缓存,所以当MMU查找PTE的时候,先从TLB中查找PTE的内容,若命中则直接加载,否则依然要从下一级存储中寻找PTE。为了查找相应PTE,VPN被分为TLBT和TLBI,分别对应标记和索引,依靠这两个内容,从TLB中寻找相应PTE元素。

图7-4-1 虚拟地址中用以访问TLB 的组成部分

多级页表是将页表分级处理,有效降低了页表的存储开销。在I7处理器中36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移扯,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移最,以此类推。最后一级页表的项指向物理内存中的某一页。

图7-4-2 TLB与四级页表支持下的VA到PA的变换

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

当由虚拟地址转换为物理地址时,CPU使用物理地址对各级Cache进行访问。当CPU要访问一个内存中的内容的时候,先从L1 Cache寻找相应内容。若一级Cache命中,则从一级Cache中访问相应数据,否则继续查找下一级cache。当三级Cache中都没有存储要访问的数据时,则需要从内存中加载数据。注意,CPU对这三级Cache的访问使用相同的物理地址。

高速缓存被组织成S组的,每组E行,每行B字节存储块的结构。其访问地址也被分为三片,如下图所示,分别为b位的块偏移,s位的组索引,t位的标记。首先,组索引被翻译为一个无符号整数,确定了要访问的数据在哪一个组中。当找到相应的组后,查找各个行,当遇到有效位位1且标记位与地址中标记位相同的行时,缓存命中。然后根据块偏移,确定访问的起始地址。

图7-5-1 高速缓存通用组织

7.6 hello进程fork时的内存映射

当fork 函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID 。为了给这个新进程创建虚拟内存,它创建了当前进程的rnm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保待了私有地址空间的抽象概念。[11]

7.7 hello进程execve时的内存映射

execve 函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。需要以下几个步骤:

删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。

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

映射共享区域。hello程序与共享对象链接。

设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。[12]

图7-7-1 内存映射

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

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

1) 虚拟地址A 是合法的吗。缺页处理程序搜索区域结构的链表,把A 和每个区域结构中的vrn_start 和vrn_end 做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。

2) 试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限。如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。

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

7.9动态存储分配管理

Printf会调用malloc,请简述动态内存管理的基本方法与策略。

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

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

分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放巳分配的块。

显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如, C 标准库提供一种叫做malloc 程序包的显式分配器。C 程序通过调用malloc 函数来分配一个块,并通过调用free 函数来释放一个块。

隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器而自动释放未使用的巳分配的块的过程叫做垃圾收集。例如,诸如Lisp 、ML 以及Java 之类的高级语言就依赖垃圾收集来释放已分配的块。[14]

7.9.1 带边界标签的隐式空闲链表分配器原理

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

头部后面就是应用调用malloc 时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。

我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要某种特殊标记的结束块,设置已分配位而大小为零的终止头部。[15]

图7-9-1 隐式空闲链表空闲块

为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块。Knuth 提出了一种聪明而通用的技术,叫做边界标记(boundary tag) ,允许在常数时间内进行对前面块的合并。这种思想,如图所示,是在每个块的结尾处添加一个脚部(footer, 边界标记),其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。[16]

图7-9-2 带有脚部的空闲链表空闲块

7.9.2显式空间链表的基本原理

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

图7-9-3显示空闲链表块结构

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。

一种方法是用后进先出(LIFO) 的顺序维护链表,将新释放的块放置在链表的开始处。

使用LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。

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

7.10本章小结

       本章主要讲述了hello程序的存储管理,从虚拟内存,地址翻译,内存映射,高速缓存访问以及动态内存分配方面对hello的存储进行了分析和解释。其中地址翻译应用了TLB和多级页表,加快了页表访问速度并减少了页表存储开销。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

一个Linux 文件就是一个m个字节的序列。所有的I/0 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。

8.2 简述Unix IO接口及其函数

 8.2.1打开文件

一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/0 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。

使用open函数打开文件:

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

open 函数将filename 转换为一个文件描述符,并且返回描述符数字。

8.2.2 读写文件

一个读操作就是从文件复制n>O 个字节到内存,从当前文件位置k开始,然后将K 增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file (EOF) 的条件,应用程序能检测到这个条件。

类似地,写操作就是从内存复制n>O 个字节到一个文件,从当前文件位置K开始,然后更新k。

读文件:ssize_t read(int fd, void *buf, size_t n);

写文件:ssize_t write(int fd, const void *buf, size_t n);

read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-l表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。

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

8.2.3关闭文件

当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

       关闭文件的函数原型为:int close(int fd);

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

  }

va_list arg得到fmt后第一个参数的地址,然后将这个地址作为参数调用vsprintf函数。

这个函数的作用时将要输出的字符串格式化,比如识别%d,输出十进制的整型,识别%x,进行16进制的输出。格式化后的字符串被存储在buf数组中,而该函数的返回值是字符串的长度。

接下来调用write函数,这是unix I/O中的函数,作用是写文件。调用这个函数将会产生系统调用的陷阱。

追踪write:
        mov eax, _NR_write
     mov ebx, [esp + 4]
     mov ecx, [esp + 8]
     int INT_VECTOR_SYS_CALL

这里进行了参数传递和系统调用。[18]syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中(存储每一个点的RGB颜色信息)。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。实现字符的输出显示。

8.4 getchar的实现分析

当按下键盘时候,出发键盘中断,这是一个一步异常。调度键盘中断处理子程序接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

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

8.5本章小结

       本章简要介绍了Unix IO的一些基本操作,包括读写文件,打开关闭文件等。然后分析了printf和getchar通过异常机制,实现字符串的输出显示和键盘输入的接收。

结论

用计算机系统的语言,逐条总结hello所经历的过程。

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

1.程序员编写hello.c程序,一个程序的文本文件就此诞生;

2.预处理器通过修改hello.c中#开头的指令,生成hello.c;

3.编译器将hello.i编译为汇编语言,这是机器代码的文本表示。这个过程生成hello.s;

4.汇编器将hello.s汇编文本内容转换为二进制机器语言,产生重定位目标文件hello.o;

5.链接器将可重定位目标文件进行符号解析和重定位,生成可执行目标文件hello;

6.shell通过输入./hello指令,调用fork和execve函数将hello.o映射到虚拟内存;

7.程序被加载到物理内存,开始运行;

8.CPU按顺序执行加载到内存中的指令;

9.当接收到信号时,内核调用信号处理程序,对信号进行处理;

10.通过动态链接,重定位prinf函数等,然后通过系统调用实现内容的输出和读取;

11.当程序返回时,程序终止,shell收到SIGCHLD信号,回收子进程。Hello的一生结束。

纵观hello的一生,简简单单的几行代码输出到屏幕经历了十分复杂的过程。计算机从CPU,内存,屏幕,键盘,总线到预处理器,编译器,汇编器,操作系统内核,异常处理程序,硬件与软件分工协作,相互配合,才产生了一行非常简单的字符串输出。

可以看到,计算机系统有很多精巧的地方:用二进制表示信息,存储器层次结构加快了数据访问,多级页表机制减少了页表的存储空间,流水线实现程序并行,分支预测技术加快了指令的执行,虚拟内存为程序提供了一致的空间……

无论从软件还是硬件,很多富有智慧技术和结构都应用到了计算机系统,这离不开在这个领域投身研究的前辈;而且这个复杂的系统仍然在不断地发展,向着更加快速,更加合理更加丰富的方向发展。

通过遍历hello程序的一生,也的的确确认识到计算机系统的复杂,虽然大概了解了计算机系统的运作过程,但是想要深入理解计算机系统仍然需要更加深入的学习和研究。

附件

列出所有的中间产物的文件名,并予以说明起作用。

文件

内容

hello.c

原始C程序

hello.i

预处理文件

hello.s

汇编文件

hello.o

可重定位目标文件

hello

可执行目标文件

helloelf_o.txt

hello.o使用readelf指令后产生的内容

helloelf_out.txt

hello使用readelf指令后产生的内容

helloobjdump_o.txt

hello.o使用objdump指令后产生的内容

helloobjdump_out.txt

hello使用objdump指令后产生的内容

[1] [S]ISO/IEC(E) 9899:2011
[2 ] [S]ISO/IEC(E) 14882:2011
[3]Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 4
[4] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 467
[5]Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 464
[6] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 464
[7] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 491
[8] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 508
[9] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 511
[10] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 569
[11] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 584行
[12] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 584
[13] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 581
[14 ] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 587
[15] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 593
[16]Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 595 6
[17] Randal E. Bryant DavidR. O’Hallaron.深入理解计算机系统[M].第三版.机械工业出版社.2016.7: 603
[18] printf函数的实现https://www.cnblogs.com/pianist/p/3315801.html

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值