参考链接

计算机系统基础(一):程序的表示、转换与链接_南京大学_中国大学MOOC(慕课)

概述

计算机基本工作原理

冯诺依曼结构

硬件模型基本结构

冯诺依曼结构

模型中包含以下四个部分

  1. 用来存放指令和数据的主存储器,简称==主存或内存==;
  2. 用来进行算术逻辑运算的部件,即算术逻辑部件(Arithmetic Logic Unit,简称==ALU==),在ALU操作控制
    信号ALUop的控制下,ALU可以对输人端A和B进行不同的运算,得到结果F;
  3. 用于自动逐条取出指令并进行译码的部件,即控制部件(Control Unit,简称CU),也称==控制器==;
  4. 用来和用户交互的==输入设备和输出设备==。

CPU内部不同的组件

  • 为了临时存放从主存取来的数据或运算的结果,还需要若干通用寄存器(General Purpose Register)组成通用寄存器组(==GPRs==),ALU两个输入端A和B的数据来自通用寄存器;
  • ALU运算的结果会产生标志信息,例如,结果是否为0(零标志ZF)、是否为负数(符号标志SF)等,这些标志信息需要记录在专门的==标志寄存器==中;
  • 从主存取来的指令需要临时保存在指令寄存器(Instruction Register,简称==IR==)中;
  • CPU为了自动按序读取主存中的指令,还需要有一个程序计数器(Program Counter,简称==PC==),在执行当前指令过程中,自动
    计算出下一条指令的地址并送到PC中保存。
  • 通常把控制部件、运算部件和各类寄存器互连组成的电路称为中央处理器(Central Processing Unit,简称CPU),简称处理器。

CPU读取数据过程

CPU需要从通用寄存器中取数据到ALU运算,或把ALU运算的结果保存到通用寄存器中,因此,需要给每个通用寄存器编号;

同样,主存中每个单元也需要编号,称为主存单元地址,简称主存地址。

通用寄存器和主存都属于存储部件,通常,计算机中的存储部件从0开始编号,例如,4个通用寄存器编号分别为0 ~ 3;16个主存单元编号分别为0 ~ 15。

CPU为了从主存取指令和存取数据,需要通过传输介质与主存相连,通常把连接不同部件进行信息传输的介质称为==总线==,其中,包含了用于传输地址信息、数据信息和控制信息的地址线、数据线和控制线。

CPU访问主存时,需先将主存地址、读/写命令分别送到总线的地址线、控制线,然后通过数据线发送或接收数据。CPU送到地址线的主存地址应先存放在主存地址寄存器(Memory Address Register,简称==MAR==)中,发送到或从数据线取来的信息存放在主存数据寄存器(Memory Data Register,简称==MDR==)中。

程序和指令的执行过程

指令(instruction)是用0和1表示的一串0/1序列,用来指示CPU完成一个特定的原子操作。例如,

  • 取数指令(load)从主存单元中取出数据存放到通用寄存器中;
  • 存数指令(store)将通用寄存器的内容写入主存单元;
  • 加法指令(add)将两个通用寄存器内容相加后送入结果寄存器;
  • 传送指令(mov)将一个通用寄存器的内容送到另一个通用寄存器;

指令通常被划分为若干个字段,有操作码、地址码等字段。

  • 操作码字段指出指令的操作类型,如取数、存数、加、减、传送、跳转等;
  • 地址码字段指出指令所处理的操作数的地址,如寄存器编号、主存单元编号等。

指令格式如下

指令格式

实现一个程序的过程

实现程序的过程

“存储程序”工作方式规定,程序执行前,需将程序包含的指令和数据先送入主存,一旦启动程序执行,则计算机必须能够在不需操作人员干预下自动完成逐条指令取出和执行的任务。

一个程序的执行就是周而复始地执行一条一条指令的过程。每条指令的执行过程包括:从主存取指令、对指令进行译码、PC增量(图中的PC+“1”表示PC的内容加上当前这一条指令的长度)、取操作数并执行、将结果送主存或寄存器保存。
程序执行前,首先将程序的起始地址存放在PC中,取指令时,将PC的内容作为地址访问主存。每条指令执行过程中,都需要计算下条将执行指令的主存地址,并送到PC中。若当前指令为顺序型指令,则下条指令地址为PC的内容加上当前指令的长度;若当前指令为跳转型指令,则下条指令地址为指令中指定的目标地址。当前指令执行完后,根据PC的值到主存中取到的是下条将要执行的指令,因而计算机能够周而复始地自动取出并执行一条一条指令。

程序执行过程

程序的开发与运行

程序设计语言和翻译程序

从抽象层次上来分,可以分成高级语言和低级语言两类。

  • 使用特定计算机规定的指令格式而形成的0/1序列称为==机器语言==,计算机能理解和执行的程序称为机器代码或机器语言程序,其中的每条指令都由0和1组成,称为==机器指令==。
  • 用简短的英文符号和机器指令建立对应关系,以方便程序员编写和阅读程序。这种语言称为==汇编语言==(assembly language),机器指令对应的符号表示称为汇编指令。如机器指令“11100110”对应的汇编指令为“lod0,6#”。

汇编语言和机器语言都属于低级语言,它们统称为机器级语言。

高级程序设计语言(high level programming language)简称高级编程语言,是指面向算法设计的、较接近于日常英语书面语言的程序设计语言,如BASIC、CC++、Fortran、Java等。它与具体的机器结构无关,同一个功能机器级语言表示需5条指令,而高级编程语言表示只需一条语句“z=x+y;”即可。

不过,因为计算机无法直接理解和执行高级编程语言程序,所以需要将高级编程语言程序转换成机器语言程序。因为这个转换过程是计算机自动完成的,所以把进行这种转换的软件统称为翻译程序(translator)。通常,程序员借助程序设计语言处理系统来开发软件。任何一个语言处理系统中,都包含翻译程序,它能把一种编程语言表示的程序转换为等价的另一种编程语言程序。被翻译的语言和程序分别称为==源语言和源程序==,翻译生成的语言和程序分别称为目标语言和目标程序。翻译程序有以下三类。

  1. 汇编程序(assembler):也称汇编器,实现将汇编语言源程序翻译成机器语言目标程序。
  2. 解释程序(interpreter):也称解释器,实现将源程序中的语句按其执行顺序逐条翻译成机器指令并立即执行。
  3. 编译程序(compiler):也称编译器,实现将高级语言源程序翻译成汇编语言或机器语言目标程序。

不同语言之间的转换

从源程序到可执行程序

现有以下程序

1
2
3
4
5
#include stdio.h>
int main()
{
printf("hello,world\n");
}

为了让计算机能够执行以上程序,需要按照以下的步骤进行操作

  1. 通过程序编辑软件得到hello.c文件。hello.c在计算机中以ASCI字符方式存放,图中给出了每个字符对应的ASCⅡ码的十进制值。例如,第一个字节的值是35,代表字符‘#’;第二个字节的值是105,代表字符‘i’;最后一个字节的值为125,代表字符‘}’。通常把用ASCI码字符或汉字字符表示的文件称为文本文件(text file),源程序文件都是文本文件,是可显示和可读的。

程序在计算机中的存放

  1. 将hello.c进行预处理、编译、汇编和链接,最终生成可执行目标文件。例如,在UNIX系统中,可用GCC编译驱动程序进行处理,命令如下:

    1
    gcc -o hello hello.c

    上面的一行命令实际上计算机进行了以下的操作

    源程序到可执行文件的转换顺序

预处理阶段:预处理程序(cpp)对源程序中以字符#开头的命令进行处理,例如,将#include命令后面的.h文件内容嵌入到源程序文件中。预处理程序的输出结果还是一个源程序文件,以.i为扩展名。

编译阶段:编译程序(cc1)对预处理后的源程序进行编译,生成一个汇编语言源程序文件,以.s为扩展名,例如,hello.s是一个汇编语言源程序文件。因为汇编语言与具体的机器结构有关,所以,对同一台机器来说,不管何种高级语言,编译转换后的输出结果都是同一种机器语言对应的汇编语言源程序。

汇编阶段:汇编程序(as)对汇编语言源程序进行汇编,生成一个==可重定位目标文件==(relocatable object file),以.o为扩展名,例如,hello.o是一个可重定位目标文件。它是一种二进制文件(binary file),因为其中的代码已经是机器指令,数据以及其他信息也都是用二进制表示的,所以它是不可读的,也即打开显示出来的是乱码。

链接阶段:链接程序(ld)将多个可重定位目标文件和标准函数库中的可重定位目标文件合并成为一个==可执行目标文件==(executable object file),可执行目标文件简称为可执行文件。本例中,链接器将hello.o和标准库函数printf所在的可重定位目标模块printf.o进行合并,生成可执行文件hello。

最终生成的可执行文件被保存在磁盘上,可以通过某种方式启动一个磁盘上的可执行文件运行。

可执行文件的启动和运行

对于一个存放在磁盘上的可执行文件,可以在操作系统提供的用户操作环境中,采用双击对应图标或在命令行中输人可执行文件名等多种方式来启动执行。在UNIX系统中,可以通过shell命令行解释器来执行一个可执行文件。

启动和执行程序的过程

  1. shell程序会将用户从键盘输入的每个字符逐一读入CPU寄存器中(对应线①),
  2. 然后再保存到主存储器中,在主存的缓冲区形成字符串"./hello"(对应线②)。
  3. 等到接收到[Enter]按键时,shell将调出操作系统内核中相应的服务例程,由内核来加载磁盘上的可执行文件hello到存储器(对应线③)。
  4. 内核加载完可执行文件中的代码及其所要处理的数据(这里是字符串"hello,world\n")后,将hello第一条指令的地址送到程序计数器(PC)中,CPU永远都是将PC的内容作为将要执行的指令的地址,因此,处理器随后开始执行hello程序,它将加载到主存的字符串"hello,word\n”中的每一个字符从主存取到CPU的寄存器中(对应线④),
  5. 然后将CPU寄存器中的字符送到显示器上显示出来(对应线⑤)。

从上述过程可以看出,一个用户程序被启动执行,==必须依靠操作系统==的支持,包括提供人机接口环境(如外壳程序)和内核服务例程。

例如,shell命令行解释器是操作系统外壳程序,它为用户提供了一个启动程序执行的环境,用来对用户从键盘输入的命令进行解释,并调出操作系统内核来加载用户程序(用户从键盘输人的命令所对应的程序)。显然,用来加载用户程序并使其从第一条指令开始执行的操作系统内核服务例程也是必不可少的。

此外,在上述过程中,涉及键盘、磁盘和显示器等外部设备的操作,这些底层硬件是不能由用户程序直接访问的。此时,也需要依靠操作系统内核服务例程的支持,例如,用户程序需要调用内核的read系统调用服务例程读取磁盘文件,或调用内核的write系统调用服务例程把字符串“写”到显示器等。

键盘、磁盘和显示器等外部设备简称为==外设==,也称为I/O设备,其中,I/O是输入/输出(Input/Output)的缩写。外设通常由机械部分和电子部分组成,并且两部分通常是可以分开的。机械部分是外部设备本身,而电子部分则是控制外部设备工作的I/O控制器或I/O适配器。

外设通过I/O控制器或I/O适配器连接到主机上,I/O控制器或I/O适配器统称为设备控制器。例如,键盘接口、打印机适配器、显示控制卡(简称显卡)、网络控制卡(简称网卡)等都是一种设备控制器,属于一种I/O模块。

[!NOTE]

程序的执行过程就是数据在CPU、主存储器和I/O模块之间流动的过程,所有数据的流动都是通过总线、I/O桥接器等进行的。数据在总线上传输之前,需要先缓存在存储部件中,因此,除了主存本身是存储部件以外,在CPU、I/O桥接器、设备控制器中也有存放数据的缓冲存储部件,例如,CPU中的通用寄存器,设备控制器中的数据缓冲寄存器等。

程序的链接

编译、汇编

编译和汇编

以GCC处理C语言程序为例来说明处理过程。可以通过-ⅴ选项查看GCC每一步的处理结果。如果想得到每个处理过程的结果,则可以分别使用==-E、-S和-c==选项来进行预处理、编译和汇编,对应的处理工具分别为==cpp、ccl和as==,处理后得到的文件的文件名后缀分别是.i、.s和.o。

预处理阶段

预处理是从源程序变成可执行程序的第一步,C预处理程序为==cpp==(即C Preprocessor),主要用于C语言编译器对各种预处理命令进行处理,包括对头文件的包含、宏定义的扩展、条件编译的选择等,例如,对于#include指示的处理结果,就是将相应h文件的内容插入到源程序文件中。

1
2
gcc -E main.c -o main.i	#gcc -E
cpp main.c -o main.i #cpp

用以上两行代码都可以将c文件转换为i文件,预处理后的文件是可显示的文本文件

编译阶段

C编译器在进行具体的程序翻译之前,会先对源程序进行词法分析、语法分析和语义分析,然后根据分析的结果进行代码优化和存储分配,最终把C语言源程序翻译成汇编语言程序。

编译器通常采用对源程序进行多次扫描的方式进行处理,每次扫描集中完成一项或几项任务,也可以将一项任务分散到几次扫描去完成。

例如,可以按照以下四趟扫描进行处理:

  1. 第一趟扫描进行词法分析;

  2. 第二趟扫描进行语法分析;

  3. 第三趟扫描进行代码优化和存储分配;

  4. 第四趟扫描生成代码。

GCC可以直接产生机器语言代码,也可以先产生汇编语言代码,然后再通过汇编程序将汇编语言代码转换为机器语言代码。

1
2
3
4
5
gcc-S main.i-o main.s
ccl main.i-o main.s
或者
gcc-S main.c-o main.s
gcc-S main.c

汇编阶段

汇编的功能是将编译生成的汇编语言代码转换为机器语言代码。因为通常最终的可执行目标文件由多个不同模块对应的机器语言目标代码组合而形成,所以,在生成单个模块的机器语言目标代码时,不可能确定每条指令或每个数据最终的地址,也即,单个模块的机器语言目标代码需要重新定位,因此,通常把汇编生成的机器语言目标代码文件称为可重定位目标文件。

1
2
3
4
5
gcc -c main.s -o main.o
as main.s -o main.o
或者
gcc -c main.c -o main.o
gcc -c main.c

可执行目标文件的生成

链接的功能是将所有关联的可重定位目标文件组合起来,以生成一个可执行文件。例如,对下面的两个模块main.c和test.c。


main.c

1
2
3
4
5
int add(int,int);
int main()
{
return add(20,13);
}

test.c

1
2
3
4
5
int add(int i,int j)
{
int x=i+j;
return x;
}

假定通过预处理、编泽和汇编,分别生成了可重定位目标文件main.o和test.o,则可以

1
2
gcc -o test main.o test.o
ld -o test main.o test.o

用以上的任意一个命令都可以生成可执行文件test。这里,ld是静态链接器命令。

静态链接图示

可重定位目标文件和可执行目标文件都是机器语言目标文件,所不同的是前者是单个模块生成的,而后者是多个模块组合而成的。因而,对于前者,代码总是从0开始的,而对于后者代码则是从ABI规范规定的虚拟地址空间中产生。

可以使用objdump来反汇编程序

1
objdump -d test.o

test.o的反汇编结果如下

1
2
3
4
5
6
7
8
9
10
11
00000000<add>:
0:55 push %ebp
1:89e5 mov %esp,%ebp
3:83ec10 sub $0x10,%esp
6:8b450c mov 0xc(%ebp )%eax
9:8b5508 mov 0x8(%ebp),%edx
C:8d0402 lea (%edx,%eax,1),%eax
f:8945fc mov %eax,-0x4(%ebp)
12:8b45fc mov -0x4(%ebp),%eax
15:c9 leave
16:c3 ret

test的反汇编结果如下

1
2
3
4
5
6
7
8
9
10
11
080483d4<add>:
80483d4:55 push %ebp
80483d5:89e5 mov %esp,%ebp
80483d7:83ec10 sub $0x10,%esp
80483da:8b450c mov Oxc(%ebp),%eax
80483dd:8b5508 mov 0x8(%ebp),%edx
80483e0:8d0402 lea (%edx,%eax,1),%eax
80483e3:8945fc mov %eax,-0x4(%ebp)
80483e6:8b45fc mov -0x4(%ebp),%eax
80483e9:c9 leave
80483ea:c3 ret

上述给出的通过objdump命令输出的结果包括指令的地址、指令机器代码和反汇编出来的汇编指令代码。可以看出,在可重定位目标文件test.o中add函数的起始地址为==0==;而在可执行目标文件test中add函数的起始地址为==0x80483d4==。

实际上,可重定位目标文件和可执行目标文件都不是可以直接显示的文本文件,而是不可显示的二进制文件,它们都按照一定的格式以二进制字节序列构成一种目标文件,其中包含

  • 二进制代码区
  • 只读数据区
  • 已初始化数据区
  • 未初始化数据区

而每个信息区称为一个==节==(section)

  • ==代码节==(.text)
  • ==只读数据节==(.rodata)
  • ==已初始化全局数据节==(.data)
  • ==未初始化全局数据节==(.bss)

静态链接器将多个可重定位目标文件合成一个可执行目标文件,主要完成以下==两个任务==。

  1. ==符号解析==
    符号解析的目的是将每个符号的引用与一个确定的符号定义建立关联。符号包括全局静态变量名和函数名,而非静态局部变量名则不是符号。例如,对于例子中的两个源程序文件main.c和test.c,在main.c中定义了符号main,并引用了符号add;在test.c中则定义了符号add,而i、j和x都不是符号。链接时需要将main.o中引用的符号add和test.o中定义的符号add建立关联。对于全局变量声明int *xp = &x;,,可看成引用符号x对符号xp进行定义。编译器将所有符号存放在可重定位目标文件的符号表中。

  2. ==重定位==
    可重定位目标文件中的代码区和数据区都是从地址0开始的,链接器需要将不同模块中相同的节合并起来生成一个新的单独的节,并将合并后的代码区和数据区按照ABI规范确定的虚拟地址空间划分(也称存储器映像)来重新确定位置。例如,对于32位Linux系统存储器映像,其只读代码段总是从地址0x8048000开始,而可读可写数据段总是在代码段后面的第一个4KB对齐的地址处开始。因而链接器需要重新确定每条指令和每个数据的地址,并且在指令中需要明确给定所引用符号的地址,这种重新确定代码和数据的地址并更新指令中被引用符号地址的工作称为==重定位==(relocation)。

目标文件格式

[!NOTE]

注意:window和Unix(Linux)的相关文件的后缀和组成格式均不同

  1. window文件后缀
    1. 动态库文件:dll
    2. 静态库文件:lib
    3. 可重定位文件:obj
    4. 可执行文件:exe
  2. Linux文件后缀
    1. 动态库文件:so
    2. 静态库文件:a
    3. 可重定位文件:o
    4. 可执行文件:一般没有特定的后缀,elf是其中的一种

ELF目标文件格式

目标文件既可用于程序的链接,也可用于程序的执行。

*链接视图*主要由不同的节(section)组成,节是ELF文件中具有相同特征的最小可处理信息单位,不同的节描述了目标文件中不同类型的信息及其特征,例如,代码节(.text)、只读数据节(.rodata)、已初始化全局数据节(.data)、未初始化全局数据节(.bss)等。

*执行视图*主要由不同的段(segment)组成,描述了目标文件中的节如何映射到存储空间的段中。可以将多个节合并后映射到同一个段,例如,可以合并节.data和节.bss的内容,并映射到一个可读可写数据段中。

ELF目标文件的两种视图

前面提到通过预处理、编译和汇编三个步骤后,可生成可重定位目标文件,多个关联的可重定位目标文件经过链接后生成可执行目标文件。这两类目标文件对应的ELF视图不同,显然,可重定位目标文件对应链接视图,而可执行目标文件对应执行视图。

节头表包含文件中各节的说明信息,每个节在该表中都有一个与之对应的项,每一项都指定了节名和节大小之类的信息。用于链接的目标文件必须具有==节头表==,例如,可重定位目标文件就一定要有节头表。程序头表用来指示系统如何创建进程的存储器映像。用于创建进程存储器映像的可执行文件和共享库文件必须具有程序头表,而可重定位目标文件不需要程序头表。

可重定位目标文件格式

可重定位目标文件主要包含代码部分和数据部分,它可以与其他可重定位目标文件链接,从而创建可执行目标文件、共享库文件。ELF可重定位目标文件由ELF头、节头表以及夹在ELF头和节头表之间的各个不同的节组成。

  • ELF头中包含的是该文件的一些基本信息,比如是什么格式的文件,在节头表中有几项表等等
  • 节中包含了代码中具体的数据及text
  • 节头表提供了指向每个节的信息,因此链接器和加载器可以使用这些信息来定位和处理文件的各个部分。

ELF可重定位目标文件格式

ELF头

ELF头位于目标文件的起始位置,包含文件结构说明信息。ELF头的数据结构分32位系统对应结构和64位系统对应结构。以下是32位系统对应的数据结构,共占52字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define EI_NIDENT 16
typedef struct
{
unsigned char eident[EI_NIDENT];
E1f32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
E1f32_Addr e_entry;
E1f32_Off e_phoff;
E1f32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half ephnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
E1f32_Half eshstrndx;
}Elf32_Ehdr;
  • 文件开头几个字节称为==魔数==,通常用来确定文件的类型或格式。在加载或读取文件时,可用魔数确认文件类型是否正确。在32位ELF头的数据结构中,字段e_ident是一个长度为16的字节序列,

    • eident最开始的4字节为魔数,用来标识是否为ELF文件,
    • 第一个字节为0x7F,后面三个字节分别为’E‘、’L’、‘F’。
    • 再后面的12个字节中,主要包含一些标识信息,例如,标识是32位还是64位格式、标识数据按小端还是大端方式存放、标识ELF头的版本号等。
  • 字段e_type用于说明目标文件的类型是可重定位文件、可执行文件、共享库文件,还是其他类型文件。

  • 字段e_machine用于指定机器结构类型,如IA-32、SPARC V9、AMD64等。

  • 字段e_version用于标识目标文件版本。

  • 字段e_entry用于指定系统将控制权转移到的起始虚拟地址(入口点),如果文件没有关联的入口点,则为零。例如,对于可重定位文件,此字段为0。

  • 字段e_ehsize用于说明ELF头的大小(以字节为单位)。

  • 字段e_shoff指出节头表在文件中的偏移量(以字节为单位)。

  • 字段e_shentsize表示节头表中一个表项的大小(以字节为单位),所有表项大小相同。

  • 字段e_shnum表示节头表中的项数。因此e_shentsize和e_shnum共同指定了节头表的大小(以字节为单位)。仅ELF头在文件中具有固定位置,即总是在最开始的位置,其他部分的位置由ELF头和节头表指出,不需要具有固定的顺序。

可以使用readelf -h命令对某个可重定位目标文件的ELF头进行解析。例如,以下是通过“readelf-h main.o”对某main.o文件进行解析的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement,little endian
Version: 1 (current)
OS/ABI: UNIX System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Inte180386
Version:0x1
Entry point address: 0x0
Start of program headers:0 (bytes into file)
Start of section headers:516 (bytes into file)
Flags: x0
Size of this header:52(bytes)
Size of program headers:0 (bytes)
Number of program headers:0
Size of section headers:40 (bytes)
Number of section headers:15
Section header string table index:12

从上述解析结果可以看出,该main.o文件中,ELF头长度(e_ehsize)为52字节,因为是可重定位文件,所以字段e_entry(Entry point address)为0,无程序头表(Size of program headers=0)。节头表离文件起始处的偏移(e_shoff)为516字节,每个表项大小(e_shentsize)占40字节,表项(e_shnum)为15个。字符串表(.strtab节)在节头表中的索引(e_shstrnd)12。

节(section)是ELF文件中的主体信息,包含了链接过程所用的目标代码信息,包括指令、数据、符号表和重定位信息等。一个典型的ELF可重定位目标文件中包含下面几个节。

  • .text:目标代码部分。

  • .rodata:只读数据,如printf语句中的格式串、开关语句(如switch-case)的跳转表等。

  • .data:已初始化的全局变量。

  • .bss:未初始化的全局变量。因为未初始化变量没有具体的值,所以无须在目标文件中分配用于保存值的空间,也即它在目标文件中不占据实际的磁盘空间,仅仅是一个占位符。目标文件中区分已初始化和未初始化全局变量是为了提高空间利用率。

    • [!NOTE]

      对于auto型局部变量,因为它们在运行时被分配在栈中,所以既不出现在.data节,也不出现在.bss节。

  • .symtab:符号表(symbol table)。程序中定义的函数名和全局静态变量名都属于符号,与这些符号相关的信息保存在符号表中。每个可重定位目标文件都有一个.symtab节。

  • .rel.text:.text节相关的可重定位信息。当链接器将某个目标文件和其他目标文件组合时,.txt节中的代码被合并后,一些指令中引用的操作数地址信息或跳转目标指令位置信息等都可能要被修改。通常,调用外部函数或者引用全局变量的指令中的地址字段需要修改。

  • .rel.data:.data节相关的可重定位信息。当链接器将某个目标文件和其他目标文件组合时,.data节中的代码被合并后,一些全局变量的地址可能被修改。

  • .dbug:调试用符号表,有些表项定义的局部变量和类型定义进行说明,有些表项对定义和引用的全局静态变量进行说明。只有使用带-g选项的gCc命令才会得到这张表。

  • .line:C源程序中的行号和.text节中机器指令之间的映射。只有使用带-g选项的gcc命令才会得到这张表。

  • .strtab:字符串表,包括.symtab节和.debug节中的符号以及节头表中的节名。字符串表就是以null结尾的字符串序列。

节头表

节头表由若干个==表项==组成,每个表项描述相应的一个节的节名、在文件中的偏移、大小、访问属性、对齐方式等,目标文件中的每个节都有一个表项与之对应。除ELF头之外,节头表是ELF可重定位目标文件中最重要的一部分内容。以下是32位系统对应的数据结构,节头表中每个表项占40字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct
{
Elf32_Word shname; //节名字符串在.strtab中的偏移
Elf32_Word sh_type; //节类型:无效/代码或数据/符号/字符串人…
E1f32_Word sh_flags; //该节在存储空间中的访问属性
Elf32_Addr sh_addr; //若可被加载,则对应虚拟地址
Elf32_Off sh_offset; //在文件中的偏移,.bss节则无意义
Elf32_Word sh_size; //节在文件中所占的长度
Elf32_Word sh_link;
Elf32 Word shinfo;
Elf32_Word sh_addralign; //节的对齐要求
Elf32_Word sh_entsize; //节中每个表项的长度
}E1f32_Shdr;

可以使用readelf-S命令对某个可重定位目标文件的节头表进行解析。例如,以下是通过“readelf-S test.o”对某test.o文件进行解析的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
There are 11 section headers,starting at offset 0x120:
Section Headers:
[Nr]Name Off Size ES F1g Lk Inf Al
[0] 000000 000000 00 0 0 0
[1].text 000034 00005b 00 AX 0 0 4
[2].rel.text 000498 000028 08 9 1 4
[3].data 000090 00000c 00 WA 0 0 4
[4].bss 00009c 00000c 00 WA 0 0 4
[5].rodata 00009c 000004 00 A 0 0 1
[6].comment 0000a0 00002e 00 0 0 1
[7].note.GNU-stack 0000ce 000000 00 0 0 1
[8].shstrtab 0000ce 000051 00 0 0 1
[9].symtab 0002d8 000120 10 10 13 4
[10].strtab 0003f8 00009e 00 0 0 1
Key to Flags:
W(write),A (alloc),X (execute),M (merge),S (strings)
I(info),L (1ink order),G (group),x (unknown)

从上述解析结果可以看出,该test.o文件中共有11个节,节头表从120字节处开始。其中,.text、.data、.bss和.rodata节需要在存储器中分配空间,.text节是可执行的,.data和.bss两个节是可读写的,而.rodata节则是只读不可写的。

根据每个节在文件中的偏移地址和长度,可以画出可重定位目标文件test.o的结构,图中左边是对应节的偏移地址,右边是对应节的长度。例
如,.text节从文件的第0x34=52字节开始,共占0x5b=91字节。从节头表的解析结果看,.bss节和.rodata节的偏移地址都是0x00009c,占用区域重叠,因此可推断出.bss节在文件中不占用空间,但节头表中记录了.bss节的长度为0x0c=12,因而,需在主存中给.bss节分配12字节空间。

test的节头表

可执行目标文件格式

链接器将相互关联的可重定位目标文件中相同的代码和数据节(如.text节、.rodata节、.data节和.bss节)合并,以形成可执行目标文件中对应的节。因为相同的代码和数据节合并后,在可执行目标文件中各条指令之间、各个数据之间的相对位置就可以确定,因而所定义的函数(过程)和变量的起始位置就可以确定,也即每个符号的定义(即符号所在的首地址)即可确定,从而在符号的引用处可以根据确定的符号定义进行重定位。

ELF可执行目标文件由ELF头、程序头表、节头表以及夹在程序头表和节头表之间的各个不同的节组成。

ELF可执行目标文件

可执行文件格式与可重定位文件格式类似,例如,这两种格式中,ELF头的数据结构一样,.text节.rodata节和.data节中除了有些重定位地址不同以外,大部分都相同。与ELF可重定位目标文件格式相比,ELF可执行目标文件的不同点主要有:

  1. ELF头中字段e_entry给出系统将控制权转移到的起始虚拟地址(入口点),即执行程序时第一条指令的地址。而在可重定位文件中,此字段为0。

  2. 通常情况下,会带有一个.init节和一个.fini节,其中.init节定义了一个_init函数,用于可执行目标文件开始执行时的初始化工作,当程序开始运行时,系统会在进程进入主函数之前,先执行这个节中的指令代码。.fini节中包含进程终止时要执行的指令代码,当程序退出时,系统会执行这个节中的指令代码。

  3. 少了.rel.text和.rel.data等重定位信息节。因为可执行目标文件中的指令和数据已被重定位,故可去掉用于重定位的节。

  4. 多了一个==程序头表,也称段头表==(segment header table),它是一个结构数组。可执行目标文件中所有代码位置连续,所有只读数据位置连续,所有可读可写数据位置连续。

    1. 在可执行文件中,ELF头、程序头表、.init节、.fini节、.text节和.rodata节合起来可构成一个只读代码段(read-only code segment);

    2. .data节和.bss节合起来可构成一个可读写数据段(read/write data segment)。

    3. [!NOTE]

      显然,在可执行文件启动运行时,这两个段必须装入内存且需要为之分配存储空间,因而称为可装入段。

为了在可执行文件执行时能够在内存中访问到代码和数据,必须将可执行文件中这些连续的、具有相同访问属性的代码和数据段映射到存储空间(通常是虚拟地址空间)中。程序头表就用于描述这种映射关系,一个表项对应一个连续的存储段或特殊节。程序头表表项大小和表项数分别由ELF头中的字段e phentsize和e phnum指定。

32位系统的程序头表中每个表项具有以下数据结构:

1
2
3
4
5
6
7
8
9
10
11
typedef struct
{
Elf32_Word p_type;
E1f32_Off p_offset;
Elf32_Addr p_vaddr;
E1f32_Addr ppaddr;
E1f32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
}E1f32_Phdr;
  • p_type描述存储段的类型或特殊节的类型。例如,是否为可装人段(PT_LOAD),是否是特殊的动态节(PT_DYNAMIC),是否是特殊的解释程序节(PT_INTERP)。
  • p_offset指出本段的首字节在文件中的偏移地址。
  • p_vaddr指出本段首字节的虚拟地址。
  • p_paddr指出本段首字节的物理地址,因为物理地址由操作系统根据情况动态确定,所以该信息通常是无效的。
  • p_filesz指出本段在文件中所占的字节数,可以为0。
  • p_memsz指出本段在存储器中所占字节数,也可以为0。
  • p_flags指出存取权限。
  • p_align指出对齐方式,用一个模数表示,为2的正整数幂,通常模数与页面大小相关,若页面大小为4KB,则模数为$2^{12}$。

使用“readelf -l main”命令显示的可执行目标文件main的程序头表信息。

1
2
3
4
5
6
7
8
9
10
11
Program Headers:
Type offset VirtAddr PhysAddr Filesiz MemSiz F1g Align
PHDR 0×000034 0x08048034 0×08048034 0x00100 0x00100 RE 0×4
INTERP 0×000134 0x08048134 0×08048134 0×00013 0x00013 R 0x1
[Requesting program interpreter:/1ib/1d-linux.so.2]
LOAD 0x000000 0×08048000 0x08048000 0x004d4 0x004d4 RE 0x1000
LOAD 0x000f0c 0x08049fc 0x08049f0c 0x00108 0x00110 RW 0×1000
DYNAMIC 0x000f20 0x08049f20 0x08049f20 0x000d0 0x000d0 RW 0×4
NOTE 0x000148 0×08048148 0×08048148 0x00044 0×00044 R 0x4
GNU_STACK 0x000000 0×00000000 0x00000000 0×00000 0x00000 RW 0x4
GNURELRO 0x000f0c 0x08049f0c 0x08049f0c 0×000f4 0×000f4 R 0x1

程序头表中有8个表项,其中有两个是可装入段(Type=LOAD)对应的表项信息。

第一个可装入段对应可执行目标文件中第0x00000~0x0043字节的内容(包括ELF头、程序头表以及.init、.text和.rodata节等),被映射到从虚拟地址0x8048000开始的长度为0x004d4字节的区域,按0x1000=22=4KB对齐,具有只读/执行权限(Flg=RE),它是一个只读代码段。

第二个可装入段对应可执行目标文件中第0x000f0c开始的长度为0x00108字节的内容(即.data节),被映射到从虚拟地址0x8049Oc开始的长度为0x00110字节的存储区域,在0x00110=272字节的存储区中,前0x00108=264字节用.data节的内容来初始化,而后面的272-264=8个字节对应.bss节,被初始化为0,该段按0x1000=4KB对齐,具有可读可写权限(Flg=RW),因此,它是一个可读写数据段。

从这个例子可以看出,.data节在可执行目标文件中占用了相应的磁盘空间,在存储器中也需要给它分配相同大小的空间;而.bss节在文件中==不占用磁盘空间==,但在存储器中==需要给它分配==相应大小的空间。

可执行文件的存储映像

对于特定的系统平台,可执行目标文件与虚拟地址空间之间的==存储器映像==(memory mapping)是由ABI规范定义的。

例如,对于IA-32+Linux系统,i386 System V ABI规范规定,

  • 只读代码段总是映射到从虚拟地址为0x8048000开始的一段区域;

  • 可读写数据段映射到只读代码段后面按4KB对齐的高地址上,其中.bss节所在存储区在运行时被初始化为0。

  • 运行时堆(run-time heap)则在可读写数据段后面4KB对齐的高地址处,通过调用malloc库函数动态向高地址分配空间,

  • 而运行时用户栈(run-time user stack)则是从用户空间的最大地址往低地址方向增长。

    • [!NOTE]

      堆区和栈区中间有一块空间保留给共享库目标代码,栈区以上的高地址区是操作系统内核的虚拟存储区。

可执行文件在存储中的映像

左边为可执行文件main中的存储信息,右边为虚拟地址空间中的存储信息。可以看出:

  • 可执行文件最开始长度为0x004d4的可装入段映射到从虚拟地址0x8048000开始的只读代码段;
  • 可执行文件中从0x00f0c到0x01013之间为.data节和.bss节(实际上都是.data节信息,而.bss节不占磁盘空间),映射到从虚拟地址0x8049000开始的可读写数据段,其中.data节从0x8049f0c开始,共占0x00108=264字节,随后的8个字节空间分配给.bss节中定义的变量,初值为0。

当启动一个可执行目标文件执行时:

  1. 首先会通过某种方式调出常驻内存的一个称为加载器(loader)的操作系统程序来进行处理。例如,任何UNX程序的加载执行都是通过调用execve系统调用函数来启动加载器进行的。
  2. 加载器根据可执行目标文件中的程序头表信息,将可执行目标文件中相关节的内容与虚拟地址空间中的只读代码段和可读写数据段通过页表建立映射,
  3. 然后启动可执行目标文件中的第一条指令执行。

根据ABI规范,特定的系统平台中的每个可执行目标文件都采用==统一==的存储器映像,映射到一个统一的虚拟地址空间,使得链接器在重定位时可以按照一个统一的虚拟存储空间来确定每个符号的地址,而不用关心其数据和代码将来存放在主存或磁盘的何处。因此,引入统一的虚拟地址空间简化了链接器的设计和实现。

同样,引入虚拟地址空间也简化了程序加载过程。因为统一的虚拟地址空间映像使得每个可执行目标文件的只读代码段都映射到从0x8048000开始的一块连续区域,而可读写数据段也映射到虚拟地址空间中的一块连续区域,因而加载器可以非常容易地对这些连续区域进行分页,并初始化相应页表项的内容。IA-32中页大小通常是4KB,因而,这里的可装入段都按$2^{12}$=4KB对齐。

加载时,只读代码段和可读写数据段对应的页表项都被初始化为“未缓存页”(即有效位为0),并指向磁盘中可执行目标文件中适当的地方。因此,程序加载过程中,==实际上并没有真正从磁盘上加载代码和数据到主存==,而是仅仅创建了只读代码段和可读写数据段对应的页表项。只有在执行代码过程中发生了“缺页”异常时,才会真正从磁盘加载代码和数据到主存。

符号表和符号解析

符号和符号表

链接器在生成一个可执行目标文件时,必须完成符号解析,而要进行符号解析,则需要用到符号表。通常目标文件中都有一个符号表,表中包含了在程序模块中被定义的所有符号的相关信息。

对于某个C程序模块m来说,包含在符号表中的符号有以下三种不同类型:

  • 在模块m中定义并被其他模块引用的*全局符号*(global symbol)。这类符号包括非静态的函数名和被定义为不带static属性的全局变量名。

  • 由其他模块定义并被m引用的*外部符号*(external symbol),包括在其他模块定义的外部函数名和外部变量名。

  • 在模块m中定义并在m中引用的*本地符号*(local symbol)。这类符号包括带static属性的函数名和全局变量名。这类在一个过程(函数)内部定义的带static属性的本地变量不在栈中管理,而是被分配在静态数据区,即编译器为它们在节.data或.bss中分配空间。如果在模块m内有两个函数使用了同名static本地变量,则需要为这两个变量都分配空间,并作为两个不同的符号记录到符号表中。

    例如,对于以下同一个模块中的两个函数func1和func2,假定它们都定义了static本地变量x且都被初始化,则编译器在该模块的.data节中同时为这两个变量分配空间,并在符号表中构建两个符号funcl.x和func2.x的关联信息。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    int func1()
    {
    static int × = 0;
    return x;
    }

    int func2()
    {
    static int x=1;
    return x;
    }

[!NOTE]

注意上述三类符号不包括分配在栈中的非静态局部变量(auto变量),链接器不需要这类变量的信息,因而它们不包含在由节.symtab定义的符号表中。

例如,对于两个源程序文件main.c和swap.c来说,

在main.c中

  • 全局符号有buf和main
  • 外部符号有swap

swap.c中

  • 全局符号有bufp0和swap

  • 外部符号有buf

  • 本地符号有bufp1。

  • 的temp是局部变量,是在运行时动态分配的,因此,它不是符号,不会被记录在符号表中。


==main.c==

1
2
3
4
5
6
extern void swap(void);
int buf[2]={1,2};
int main(){
swap();
return 0;
}

==swap.c==

1
2
3
4
5
6
7
8
9
10
11
extern int buf[];
int *bufp0 &buf[0];
static int *bufp1;
void swap()
{
int temp;
bufpl &buf[1];
temp *bufp0;
*bufp0 *bufpl;
*bufpl temp;
}

ELF文件中包含的符号表中每个表项具有以下数据结构。

1
2
3
4
5
6
7
8
9
10
typedef struct
{
E1f32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;
E1f32_Half st_shndx;
}
E1f32_Sym;

各字段的含义为:

  • st_name给出符号在字符串表中的索引(字节偏移量),指向在字符串表(.strtab节)中的一个以null结尾的字符串,即符号。

  • st_value给出符号的值,在可重定位目标文件中,是指符号所在位置相对于所在节起始位置的字节偏移量。

    • 例如,main.c的符号buf在.data节中,其偏移量为0。
    • 在可执行目标文件和共享目标文件中,st_value则是符号所在的虚拟地址。
  • st_size给出符号所表示对象的字节个数。

    • 若符号是函数名,则是指函数所占字节个数;
    • 若符号是变量名,则是指变量所占字节个数。
    • 如果符号表示的内容没有大小或大小未知,则值为0。
  • st_info指出符号的类型和绑定属性,从以下定义的宏可以看出,符号类型占低4位符号绑定属性占高4位。

    1
    2
    3
    #define ELF32_ST_BIND(info) 		((info)>>4)	//高四位
    #define ELF32_ST_TYPE(info) ((info)0xf) //低四位
    #define ELF32_ST_INFO(bind,type) (((bind)<<4) + ((type)&0xf)) //info的组成形式
    • 符号类型可以是未指定(==NOTYPE==)、变量(==OBJECT==)、函数(==FUNC==)、节(==SECTION==)等。当类型为“节”时,其表项主要用于重定位。

    • 绑定属性可以是本地(==LOCAL==)、全局(==GLOBAL==)、弱(==WEAK==)等。

      • 本地符号指在包含其定义的目标文件的外部是不可见的,名称相同的本地符号可存在于多个文件中而不会相互干扰。
      • 全局符号对于合并的所有目标文件都可见。
      • 弱符号与全局符号类似,但其定义具有较低的优先级。
  • 字段st_other指出符号的可见性。通常在可重定位目标文件中指定可见性,它定义了当符号成为可执行目标文件或共享目标库的一部分后访问该符号的方式。

  • 字段st_shndx用于指出符号所在节在节头表中的索引。有些符号属于三种特殊伪节(pseudo section)之一,伪节在节头表中没有相应的表项,无法表示其索引值,因而用以下特殊的索引值表示:

    • ABS表示该符号不会由于重定位而发生值的改变,即不应该被重定位;
    • UNDEF表示未定义符号,即在本模块引用而在其他模块定义的外部符号;
    • COMMON表示还未被分配位置的未初始化的变量,即.bss中的变量。对于COMMON类型的符号,其st_value字段给出的是对齐要求,而st_size给出的是最小长度。

可通过GNU READELF工具显示符号表。可使用命令readelf -s main.o查看main.o中的符号表。

1
2
3
4
Num:	Value	Size	Type	Bind	Ot	Ndx		Name
8: 0 8 OBJECT GLOBAL 0 3 buf
9: 0 17 FUNC GLOBAL 0 1 main
10: 0 0 NOTYPE GLOBAL 0 UND swap

从显示结果可看出:

  • main模块的三个全局符号中,buf是变量(Type=OBJECT),它位于节头表中第三个表项(Ndx=3)对应的.data节中偏移量为0(Value=0)处,占8个字节(Size=8);
  • main是函数(Type=FUNC),它位于节头表中第一个表项对应的.text节中偏移量为0处,占17个字节;
  • sawp是未指定(NOTYPE)且无定义(UND)的符号,说明swap是在main中被引用的由外部模块定义的符号。

使用GNU READELF工具显示可重定位目标文件swap.o符号表中最后四个表项:

1
2
3
4
5
Num:	Value	Size	Type	Bind	Ot	Ndx	Name
8: 0 4 OBJECT GLOBAL 0 3 bufp0
9: 0 0 NOTYPE GLOBAL 0 UND buf
10: 0 39 FUNC GLOBAL 0 1 swap
11: 4 4 OBJECT LOCAL 0 COM bufpl

可以看出,swap模块的四个符号中,有三个全局符号和一个本地符号。其中:

  • bufp0是全局变量,它位于节头表中第三个表项对应的.data节中偏移量为0处,占4个字节;
  • buf是未指定的且无定义的全局符号,说明buf是在swap中被引用的由外部模块定义的符号;
  • swap是函数,它位于节头表中第一个表项对应的.text节中偏移量为0处,占39个字节;
  • bufp1是未分配位置且未初始化(Ndx=COM)的本地变量,按4字节边界对齐,至少占4个字节,当swap模块被链接时,bufpl将作为.bss节中的一个变量来分配空间。注意.swa即模块中的变量temp是函数内的局部变量,因而不在符号表中说明。

符号解析

符号解析的目的是将每个模块中==引用的符号==与某个目标模块中的==定义符号==建立关联。每个定义符号在代码段或数据段中都被分配了存储空间,因此,将引用符号与对应的定义符号建立关联后,就可以在重定位时将引用符号的地址重定位为相关联的定义符号的地址。

对于在一个模块中定义且在同一个模块中被引用的本地符号,链接器的符号解析会比较容易进行,因为编译器会检查每个模块中的本地符号是否具有唯一的定义,所以,只要找到第一个本地定义符号与之关联即可。对于跨模块的全局符号的解析,则比较困难。

编译器在对源程序编译时,会把每个全局符号输出到汇编代码文件中,每个全局符号或者是强符号或者是弱符号。汇编器把全局符号的强、弱特性隐含地编码在可重定位目标文件的符号表中,以供链接时符号解析所用。

全局符号的强弱特性

强、弱符号的定义如下:函数名和已初始化的全局变量名是强符号,未初始化的全局变量名是弱符号。

例如,main、buf、swap和bufp0是强符号,bufp1为本地符号,而本地符号没有强弱之分,temp则是局部变量,不包含在符号表中。

链接器根据以下强符号和弱符号的处理规则来处理多重定义符号。

  1. 规则1:强符号不能多次定义。也即强符号只能被定义一次,否则链接错误。
  2. 规则2:若一个符号被说明为一次强符号定义和多次弱符号定义,则按强符号定义为准。
  3. 规则3:若有多个弱符号定义,则任选其中一个。

符号的解析过程

编译系统通常会提供一种将多个目标模块打包成一个单独的库文件的机制,这个库文件就是==静态库==(static library)。在构建可执行文件时只需指定库文件名,链接器会自动到库中寻找那些应用程序用到的目标模块,并且==只把用到的模块从库中拷贝出来==。

程序中的符号包括全局静态变量名和函数名:

  • 它们在程序中可能出现在定义处,称为符号的定义;
  • 也可能出现在引用处,称为符号的引用。

链接器按照所有可重定位目标文件和静态库文件出现在命令行中的顺序从左至右依次扫描它们,在此期间它要维护多个集合。其中

  1. 集合E是指将被合并到一起组成可执行文件的所有目标文件集合;

  2. 集合U是未解析符号的集合,未解析符号是指还未与对应定义符号关联的引用符号;

  3. 集合D是指当前为止已被加人到E的所有目标文件中定义符号的集合。

[!TIP]

所以说首先判断是不是要放入E中,然后再在E中找定义符号和未定义符号,定义符号放入D中,未定义符号放在U

符号解析开始时,集合EUD中都是空的。然后按照以下过程进行符号解析:

  1. 对命令行中的每一个输入文件f,链接器确定它是目标文件还是库文件,如果它是目标文件,就把f加入到E,根据f中未解析符号和定义符号分别对UD集合进行修改,然后处理下一个输入文件。
  2. 如果f是一个库文件,链接器会尝试把U中的所有未解析符号与f中各目标模块定义的符号进行匹配。如果某个目标模块m定义了一个U中的未解析符号x,那么就把m加入到E中,并把符号xU移入D中。不断地对f中的所有目标模块重复这个过程直到UD不再变化为止。那些未加入到E中的f里的目标模块就被简单地丢弃,链接器继续处理下一输入文件。
  3. 如果处理过程中往D加入一个已存在的符号(出现双重定义符号),或者当扫描完所有输入文件时U非空,则链接器报错并停止动作。否则,链接器把E中的所有目标文件进行重定位后合并在一起,以生成可执行目标文件。

与静态库的链接

在类UNX系统中,静态库文件采用一种称为存档档案(archive)的特殊文件格式,使用==.a==后缀。例如,标准C函数库文件名为libc.a,其中包含一组广泛使用的标准I/O函数、字符串处理函数和整数处理函数,如atoiprintfscanfstrcpy等,libc.a是默认的用于静态链接的库文件,无须在链接命令中显式指出。还有其他的函数库,例如浮点数运算函数库,文件名为libm.a,其中包含sin、cos和sqrt函数等。

用户也可以自定义一个静态库文件。以下通过一个简单例子来说明如何生成自己的静态库文件。假定有两个源文件myproc1.c和myproc22.c。


==myproc1.c==

1
2
3
4
5
#include <stdio.h>
void myfuncl()
{
printf("%s","This is myfuncl from mylib!\n");
}

==myproc2.c==

1
2
3
4
5
#include <stdio.h>
void myfunc2()
{
printf("%s","This is myfunc2 from mylib!\n");
}

可以使用==AR==工具生成静态库,在此之前需要用gcc -c命令将静态库中包含的目标模块先生成可重定位目标文件。以下两个命令可以生成静态库文件mylib.a,其中包含两个目标模块myproc1.omyproc2.o。然后再将这两个可重定位目标文件组合成静态库文件。

1
2
3
gcc -c myprocl.c
gcc-c myproc2.c
ar rcs mylib.a myproc1.o myproc2.o

现假定有一个函数main.c调用库中的函数。

1
2
3
4
5
6
void myfuncl(void);
int main()
{
myfuncl();
return 0;
}

为了生成可执行文件myproc,可以先将main.c编译并汇编为可重定位目标文件main.o,然后再将main.o和mylib.a以及标准C函数库libc.a进行链接。

1
2
gcc -c main.c
gcc -static -o myproc main.o ./mylib.a

命令中使用-static选项指示链接器应生成一个完全链接的可执行目标文件,即生成的可执行文件应能直接加载到存储器执行,而不需要在加载或运行时再动态链接其他目标模块。

符号解析过程如下。

  1. 一开始EUD都是空集,链接器首先扫描到main.o,把它加入E,同时把其中未解析符号myfun1加入U,把定义符号main加入D,而且因为main.o的默认静态链接库是libc.a,所以libc.a被加人到当前输入文件列表的末尾。

  2. 处理完main.o,接着扫描到mylib.a,因为这是个静态库文件,所以会拿当前U中的所有符号(本例中就一个符号myfunc1)与mylib.a中的所有目标模块(本例中有两个目标模块myproc1.omyproc2.o)依次匹配,看是否有哪个模块定义了U中的符号,结果发现在myproc1.o中定义了myfunc1,于是myprocl.o被加入到E,myfunclU转移到D。在myprocl.o中发现还有未解析符号printf,因而将其加到U中。同样,mylib.a指定的默认标准库还是libc.a,它已经被加到当前输入文件列表的末尾,因此在此可以忽略它。不断地在静态库mylib.a的各模块上进行迭代以匹配U中的符号,直到UD都不再变化。显然,此时UD就不再发生变化,U中只有一个未解析符号printf,而D中有mainmyfuncl两个定义符号。

  3. 因为模块myproc2.o没有被加入E中,因而它被丢弃。

  4. 接着扫描下一个输人文件,就是默认的库文件libc.a。链接器发现libc.a中的目标模块printf.o定义了符号printf,于是printf也从U移到D,同时printf.o被加入到E,并把它定义的所有符号都加入D,而所有未解析符号加入U。链接器还会把每个程序都要用到的一些初始化操作所在的目标模块(如crt0.o等)以及它们所引用的模块(如malloc.ofree.o等)自动加入到E中,并更新UD以反映这个变化。事实上,标准库中各目标模块里的未解析符号都可以在标准库内其他模块中找到定义,因此当链接器处理完libc.a时,U一定是空的。此时,==链接器合并E中的目标模块并输出可执行目标文件==。

    可重定位目标文件和静态库的链接

从上述描述的符号解析过程来看,符号解析结果与命令行中指定的输入文件的顺序相关。如果上述链接命令改为以下形式,则会发生链接错误。

1
gcc -static -o myproc ./mylib.a main.o

因为一开始先扫描到mylib.a,而mylib.a为静态库文件,所以,会根据其中是否存在U中的未解析符号对应的定义符号来确定是否将相应的目标模块加入E中,显然,开始时U是空的,因而在mylib.a中没有任何一个目标模块被加人E中,当扫描到main.o时,其引用符号myfunc1便不能被解析,所以被加入U中,这样,U中的myfunc1在后面将一直无法得到解析,最终因为U不空而导致链接器输出错误信息并终止。

[!IMPORTANT]

关于静态库的链接顺序问题,通常的准则是将==静态库文件放在命令行文件列表的后面==,如有多个静态库文件,则根据这些静态库文件的目标模块中的符号是否有引用关系来确定顺序。

  • 若相互之间都没有引用关系,则说明它们之间相互独立,此时顺序可以任意,只要都放在后面即可;
  • 若相互之间有引用关系,则必须按照引用关系在命令行中排列静态库文件,使得对于每个静态库目标模块中的外部引用符号,在命令行中至少有一个包含其定义的静态库文件排在后面。

例如,假设func.o调用了静态库libx.aliby.a中的函数,而libx.a又调用了libz.a中的函数,且libx.aliby.a之间、liby.alibz.a之间是相互独立的,则命令行中libx.a必须在libz.a之前,而libx.aiby.a之间、liby.alibz.a之间无须考虑顺序关系,即以下几个命令行都是可行的。

1
2
3
gcc -static -o myfunc func.o libx.a liby.a libz.a
gcc -static -o myfunc func.o liby.a libx.a libz.a
gcc -static -o myfunc func.o libx.a libz.a liby.a

如果两个静态库的目标模块有相互引用关系,则在命令行中可以重复静态库文件名。

例如,假设func.o调用了静态库libx.a中的函数,而libx.a又调用了liby.a中的函数,同时,liby.a也调用了libx.a中的函数,则可用以下命令进行链接。

1
gcc -static -o myfunc func.o libx.a liby.a libx.a

重定位过程

重定位的目的是在==符号解析的基础上将所有关联的目标模块==(即上述集合E中的模块)==合并==,并确定运行时每个定义符号在虚拟地址空间中的地址,在定义符号的引用处重定位引用的地址。

例如,在符号解析的例子中,编译main.c时,因为编译器还不知道函数myprocl的地址,所以编译器只是将一个“临时地址”放到可重定位目标文件main.ocall指令中,在链接阶段,这个“临时地址”将被修正为正确的引用地址,这个过程叫重定位。具体来说,重定位有以下两方面工作。

  1. 节和定义符号的重定位。链接器将相互关联的所有可重定位文件中相同类型的节合并,生成一个同一类型的新节。例如,所有模块中的.data节合并为一个大的.data节,它就是生成的可执行目标文件中的.data节。然后链接器根据每个新节在虚拟地址空间中的起始位置以及新节中每个定义符号的位置,为新节中的每个定义符号确定存储地址。
  2. 引用符号的重定位。链接器对合并后新代码节(.text)和新数据节(.data)中的引用符号进行重定位,使其指向对应的定义符号起始处。为了实现这一步工作,显然,链接器要知道目标文件中哪些引用符号需要重定位、所引用的是哪个定义符号等,这些称为重定位信息,放在重定位节(.rel.text和,.rel.data)中。

重定位信息

在可重定位目标文件的.rel.text节和.rel.data节中,存放着每个需重定位的符号的重定位信息。.rel.text节和.rel.data节采用的数据类型是结构数组,每个数组元素是一个表项,每个表项对应一个需重定位的符号,表项的数据结构如下:

1
2
3
4
5
typedef struct
{
Elf32_Addr r_offset;
Elf32_Word r_info;
}E1f32_Re1;
  • r_offset指出当前需重定位的位置相对于所在节起始位置的字节偏移量。若重定位的是变量的位置,则所在节为.data节;若重定位的是函数的位置,则所在节是.text节。
  • r_info指出当前需重定位的符号所引用的符号在符号表中的索引值以及相应的重定位类型。从以下的宏定义中可以看出,符号索引(r_sym)是r_info的高24位,重定位类型(r_type)是其低8位。
1
2
3
#define ELF32_R_SYM(info)	((info)>>8)
#define ELF32_R_TYPE(info) ((unsigned char)(info))
#define ELF32_R_INFO(sym,type) (((sym)<<8)+(unsigned char)(type))

重定位类型与特定的处理器有关,具体由ABI规范定义。IA-32处理器的重定位类型有多种,最基本的是以下两种。

  1. R_386_PC32:指明引用处采用PC相对寻址方式,即有效地址为PC内容加上重定位后的32位地址,PC的内容是下条指令地址。例如,调用指令call中的转移目标地址就采用相对寻址方式。
  2. R_386_32:指明引用处采用绝对地址方式,即有效地址就是重定位后的32位地址。

重定位表的信息可以用命令readelf -r来显示,例如,可用命令readelf-r main.o来显示main.o中的重定位表项。为方便起见,以下叙述中把重定位后的32位地址简称为重定位值。

重定位过程

重定位过程需要对.text节和.data节中由相应的重定位节,.rel.text和.rel.data的重定位表项指出的每一处按顺序执行。

现假设main.o的.rel.text节中有一个表项:

  1. r_offset=0x7
  2. r_sym=10
  3. r_type=R_386_PC32

该表项说明,需要在其.text节中偏移量为0x7的地方按照PC相对地址方式进行重定位,所引用的符号为main.o的符号表中第10个表项代表的符号
号。

现假设main.o的第十个表项为swap.o,swap.o的.rel.data中有一个表项:

  1. r_offset=0x0
  2. r_sym=9
  3. r_type=R_386_32

该表项说明,需要在其.data节中偏移量为0的地方按绝对地址方式进行重定位,所引用的符号为swap.o符号表中第9个表项代表的符号。该符号为buf

动态链接

前面介绍了可重定位目标文件和可执行目标文件,还有一类目标文件是==共享目标文件==(shared object file),也称为共享库文件。它是一种特殊的可重定位目标文件,其中记录了相应的代码、数据、重定位和符号表信息,能在可执行目标文件装入或运行时被动态地装入内存并自动被链接,该过程称为动态链接(dynamic link),由一个称为动态链接器dynamic linker)的程序来完成。

[!TIP]

类UNIX系统中共享库文件采用.so后缀,Windows系统中称其为动态链接库(Dynamic Link Library,简称DLL),采用.dl后缀。

动态链接的特性

静态链接方式因为库函数代码被合并、包含在可执行文件中,因而会造成磁盘空间和主存空间的极大浪费。

  • 例如,静态库libc.a中的printf模块会在静态链接时被合并到每个引用printf的可执行文件中,其中的printf代码会各自占用不同的磁盘空间。通常磁盘上存放有数千个可执行文件,因而静态链接方式会造成磁盘空间的极大浪费;

  • 在引用printf的应用程序同时在系统中运行时,这些程序中的printf代码也都会占用内存空间,对于并发运行几十个进程的系统来说,会造成极大的主存资源浪费。

==共享库==以动态链接的方式被正在加载或执行中的多个应用程序共享,因而,共享库的动态链接有两个方面的特点:

  1. ==共享性==:指共享库中的代码段在内存只有一个副本,当应用程序在其代码中需要引用共享库中的符号时,在引用处通过某种方式确定指向共享库中对应定义符号的地址即可。

    1. 例如,对于动态共享库libc.so中的printf模块,内存中只有一个printf副本,所有应用程序都可以通过动态链接printf模块来使用它。

    2. 因为内存中只有一个副本,磁盘中也只有共享库中一份代码,所以能节省主存资源和磁盘空间。

  2. ==动态性==:指共享库只在使用它的程序被加载或执行时才加载到内存,因而在共享库更新后并不需要重新对程序进行链接,每次加载或执行程序时所链接的共享库总是最新的。可以利用共享库的这个特性来实现软件分发或生成动态Web网页等。

动态链接有两种方式,一种是在程序==加载过程==中加载和链接共享库,另一种是在程序==执行过程==中加载和链接共享库。

程序加载时的动态链接

在类UNIX系统中,共享库文件使用.so后缀。例如,标准C函数库文件名为Iibc.so。用户也可以自定义一个动态共享库文件。例如,对于两个源程序文件myproc1.c和myproc2.c,可以使用以下GCC命令生成动态链接的共享库mylib.so

1
gcc -shared -fPIC -o mylib.so myprocl.c myproc2.c

上述命令中-shared选项告诉链接器生成一个共享库目标文件;

-fPIC选项告诉编译器生成与位置无关的代码(Position Independent Code,PIC),使得共享库在被任何不同的程序引用时都不需要修改其代码。这保证了共享库代码的存储位置可以是不确定的,而且即使共享库代码的长度发生改变也不会影响调用它的程序。

假定有一个main.c程序,其中调用了mylib.so中的函数myfunc1。

1
2
3
4
5
6
void myfunc1(void);
int main()
{
myfunc1();
return 0;
}

为了生成可执行目标文件myproc,可以先将main.c编译并汇编为可重定位目标文件main.o,然后再将main.o和mylib.so以及标准C函数共享库libc.so进行链接。以下命令可以完成上述功能:

1
gcc -o myproc main.c ./mylib.so

通过上述命令得到可执行目标文件myproc,这个命令与静态链接命令gcc -static -o myproc main.c mylib.a的执行过程不同。静态链接生成的可执行目标文件在加载后可以直接运行,因为所有外部函数都已包含在可执行目标文件中,而动态链接生成的可执行目标文件在加载执行过程中需要和共享库进行动态链接,否则不能运行。

这是因为在动态链接生成可执行目标文件时,其中对外部函数的引用地址是未知的。因此,在动态链接生成的可执行目标文件运行前,系统会首先将动态链接器以及所使用的共享库文件加载到内存。动态链接器和共享库文件的路径都包含在可执行目标文件中,其中,动态链接器由加载器加载,而共享库由动态链接器加载。

加载时动态链接的过程

动态链接加载过程被分成两步:

  1. 首先,进行静态链接以生成部分链接的可执行目标文件myproc,该文件中仅包含共享库(包括指定的共享目标文件mylib.so和默认的标准共享库文件libe.so)中的符号表和重定位表信息,而共享库中的代码和数据并没有被合并到myproc中;
  2. 然后,在加载myproc时,由加载器将控制权转移到指定的动态链接器,由动态链接器对共享目标文件libc.so、mylib.so和myproc中的相应模块内的代码和数据进行重定位并加载共享库,以生成最终的存储空间中完全链接的可执行目标,在完成重定位和加载共享库后,动态链接器把控制权转移到程序myproc。

[!IMPORTANT]

在执行myproc的过程中,共享库中的代码和数据在存储空间的位置一直是固定的。

在上述过程中,有一个重要的问题是,如何在加载过程中将控制权从加载器转移到动态链接器?

实际上在可执行目标文件的程序头表中有一个Type=INTERP的段。因此,这个问题的解决可通过在可执行目标文件myproc中加入一个特殊的.interp节来实现。当加载myproc时,加载器会发现在myproc的程序头表中包含了.interp节构成的段,其p_type字段取值为PT_INTERP,该节中包含了动态链接器的路径名,而动态链接器本身也是一个共享目标,在Linux系统中为ld-linux.so文件,.interp节中有这个文件的路径信息,因而
可以由加载器根据指定的路径来加载并启动动态链接器运行。动态链接器完成相应的重定位工作后再把控制权交给myproc,启动其第一条指令执行。

程序运行时的动态链接

在一些类UNⅨ系统中,提供了一个动态链接器接口,其中定义了相应的几个函数,如dlopen、dlsym、dlerror、dlclose等,其头文件为dlfen.h。

以下给出一个例子,用以说明如何在应用程序中使用动态链接器接口函数对共享库进行动态链接。图4.23给出了一个运行时进行动态链接的应用程序示例main.c。对于由图4.15所示的两个源程序文件myproc1.c和myproc2.c生成的共享库mylib.so,在main.c中调用了共享库mylib.so中的函数myfunc1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>
#include <dlfcn.h>
int main()
{
void *handle;
void (*myfuncl)();
char *error;

/*动态装入包含函数myfuncl()的共享库文件*/
handle = dlopen("./mylib.so",RTLD_LAZY);
if (!handle){
fprintf(stderr,"%s\n",dlerror());
ex1t(1);
}

/*获得一个指向函数nyfuncl()的指针nyfunc1*/
myfuncl = dlsym(handle,"myfunc1");
if ((error = dlerror()) != NULL){
fprintf(stderr,"%s\n",error);
ex1t(1);
}

/*现在可以像调用其他函数一样调用函数myfunc1()*/
myfunc1();

/*关闭(卸载)共享库文件*/
if (dlclose(handle)<0){
fprintf(stderr,"%s\n",dlerror());
exit(1);
}
return 0;
}

要编译该程序并生成可执行文件myproc,通常用以下GCC命令:

1
gcc -rdynamic -o myproc main.c -1dl
  • 选项-rdynamic指示链接器在链接时使用共享库中的函数,
  • 选项-ldl说明采用动态链接器接口中的dlopen、dsym等函数进行运行时的动态链接。

一个应用程序如果要在运行时动态链接一个共享库并引用库中的函数或变量,则必须经过以下几个步骤。

  1. 首先,通过dlopen函数加载和链接共享库,第10行的含义是启动动态链接器来加载并链接当前目录中的共享库文件mylib.so,这里dlopen函数的第二个参数为RTLD_LAZY,用来指示链接器对共享库中外部符号的引用不在加载时进行重定位,而是延迟到第一次函数调用时进行重定位,称为延迟绑定(lazy binding)技术。若dlopen函数出错,则返回值为NULL;否则返回指向共享库文件句柄的指针。
  2. 在dlopen函数正常返回的情况下,通过dlsym函数获取共享库中所需函数。如第17行所示。第17行的含义是指示动态链接器返回指定共享库mylib.so中指定符号myfunc1的地址。若指定共享库中不存在指定的符号,则返回NULL。dlsym函数的第一个参数是指定共享库的文件句柄,第二个参数用来标识指定符号的字符串,通常是后面将要使用的函数的函数名。
  3. 在dlsym函数正常返回的情况下,就可以使用共享库中的函数,如第24行所示。函数对应代码的首地址由dlsym函数返回。
  4. 在使用完程序所需的所有共享库内函数或变量后,使用dlclose函数卸载这个共享库。如第27行所示。若卸载成功,返回为0,否则为-1。若调用dlopen、dlsym和dlclose时发生出错,则出错信息可通过调用dlerror函数获得。

位置无关代码

共享库代码在磁盘上和内存中都只有一个备份,在磁盘上就是一个共享库文件,如类UNX系统中的.so文件或Windows系统中的.dll文件。为了让一份共享库代码可以和不同的应用程序进行链接,共享库代码必须与地址无关,也就是说,在生成共享库代码时,要保证将来不管共享库代码加载到哪个位置都能够正确执行,也即共享库代码的加载位置可以是不确定的,而且共享库代码的长度发生变化也不影响调用它的程序。满足上述这种特征的代码称为==位置无关代码==(Position-Independent Code,.PIC)。

显然,共享库文件必须是位置无关代码,因而在生成共享库文件时,须使用GCC选项-PIC来生成位置无关代码。

符号之间的所有引用包含以下四种情况:

  1. 模块内过程调用和跳转;
  2. 模块内数据引用;
  3. 模块间数据引用;
  4. 模块间过程调用和跳转。

对于前两种情况,因为是在模块内进行函数(过程)和数据的引用,因而采用PC相对偏移寻址方式就可以方便地实现位置无关代码。

对于后面两种情况,由于涉及模块之间的访问,所以无法通过PC相对偏移寻址来生成位置无关代码,需要有专门的实现机制。

模块内过程调用和跳转

函数foo调用了模块内的一个函数bar,因此属于模块内的过程调用。因为foo和bar在同一个模块中,因而这两个函数的代码都在同一个.text节中,相对位置固定,只要在实现过程调用的call指令中采用PC相对偏移寻址方式,即可生成位置无关代码。显然,不管so文件中的代码加载到哪里,call指令中的偏移量都不变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static int a;
static int b;
extern void ext();

void bar()
{
a=1;
b=2;
}

void foo()
{
bar();
ext();
}

编辑为机器指令的代码为

1
2
3
4
5
6
7
8
9
10
11
12
0000344<bar>:	
0000344: 55 push1 %ebp
0000345: 89e5 movl %esp,%ebp
......
0000362: c3 ret
0000363: 90 nop
0000364 <fo0>:
0000364: 55 push1 %ebp
......
0000374: e8 db ff ff ff ca11 0000344<bar>
0000379:
......

编译器在生成call指令时,只要根据被引用函数bar的起始位置和call指令下条指令的起始位置之间的位移量就可算出偏移地址为0x0000344-0x0000379=0xffff ffcb=-0x35。同样,模块内的跳转也可用jmp即指令通过PC相对寻址方式来生成位置无关代码。

模块内数据引用

函数bar引用了模块内的静态变量a和b,因此属于模块内的数据访问。因为在同一个模块内数据段总是紧跟在代码段后面,因而任何引用某符号的指令与数据段起始处之间的位移量,以及本地局部符号在数据段内的位移量都是确定的。编译器可以利用这些特性生成位置无关代码。

以下是源程序经编译后得到的部分机器级代码示例,主要给出了赋值语句a=1;的编译结果。可以看出,为了生成位置无关代码,编译器对语句a=1;生成了多条指令,这里假设call指令的下条指令到数据段起始位置之间的位移量为0x118c,数据段起始位置到变量a之间的位移量为0x28。

1
2
3
4
5
6
7
8
9
10
11
12
13
0000344	<bar>:
0000344: 55 push1 %ebp
0000345: 89e5 mov1 %esp,%ebp
0000347: e850000000 call 39c <get_pc>
000034c: 81c18c110000 addl $0x118c,%ecx
0000352: c78128000000 movl $0x1,0x28(%ecX)
......
0000362: c3 ret


000039c <get_pc>:
000039c: 8b0c24 mov1 (esp),%ecx
000039f: c3 ret

上述机器级代码0000347处开始的三条指令对应函数bar中语句a=1;。首先,通过指令“call 39c<_get-pc>”将下条指令的地址保存在栈顶位置,然后再通过000039c处的“movl (%esp),%ecx”指令将当前栈顶位置送到ECX中,这样,不管这段共享代码加载到哪里,都会将引用a的指令的地址记录在ECX中。下一条指令再将该地址值加上0x118c,得到数据段首地址送ECX,然后再通过“基址加偏移量”的方式得到a的地址,从而实现对静态变量a的引用。通常,生成位置无关代码会带来一些额外的开销,可以看出,模块内数据访问情况下的位置无关代码多用了4条指令。在x86-64中,因为允许将P寄存器作为基址寄存器,所以使用一条指令即可实现模块内数据引用,从而可以减少额外开销。

模块间数据引用

在以下函数bar中的赋值语句“b=2;”引用了模块外的一个外部变量b,因此属于模块间的数据访问。因为变量b是外部符号,所以在对赋值语句“b=2;”进行编译转换时,无法事先计算出变量b到引用b的指令之间的相对距离。

1
2
3
4
5
6
7
static int a;
extern int b;
extern void ext();
void bar(){
a=1;
b=2;
}

不过,因为任何引用符号的指令与本模块数据段起始处之间的位移量是确定的,因而,可以在数据段开始处设置一个表,只要在程序执行时外部变量b的地址已记录在这个表中,那么引用b的指令就可以通过访问这个表中的地址来实现对b的引用。以下是源程序经编译后得到的部分机器级代码示例。此例中,假设引用b的指令序列开始处(即popl指令起始处)到变量b所在的表项之间的位移量为0x1180。

1
2
3
4
5
6
7
8
0000344<bar>:
0000344: 55 push1 %ebp
......
0000357: e800000000 call 000035c
000035c: 5b popl %ebx
000035d: addl $0x1180,%ebx
...... mov1 (%ebx),%eax
...... movl $2,(%eax)

上述代码段中,通过0000357处开始的“call 000035c”和“popl %ebx”指令,将赋值语句“b=2;”对应的指令序列首地址送EBX;通过加上位移量0x1180,得到外部变量b的地址所存放的位置值送EBX;然后根据EBX访问变量b所对应的表项,得到变量b的地址送EAX;最后通过EAX引用变量b。这个设置在数据段起始处的用于存放全局变量地址的表称为==全局偏移量表==(Global OffsetTble,GOT),其中每个表项对应一个全局变量,用于在动态链接时记录对应的全局变量的地址。

ABI规范定义了GOT的具体结构与相应的处理过程。编译器为GOT中每一个表项生成一个重定位项,指示动态链接器在加载并进行动态链接时必须对这些GOT表项中的内容进行重定位,也即在动态链接时需要对这些表项绑定一个符号定义,并填人所引用的符号的地址。例如,对于上述例子,在加载并进行动态链接时,动态链接器应将符号b在其他模块中定义的地址,填人到本模块GOT中变量b对应的表项中。这样,在指令执行时,就可以从GOT中取到变量b在外部模块中的地址了。同样,模块间数据访问时的位置无关代码也有缺陷,除了多用4条指令外,还增加了用于实现GOT的空间和时间,并多使用了一个被调用者保存寄存器EBX。

模块间过程调用和跳转

以下是一个源程序部分代码,其中,函数foo调用了一个外部函数ext,因此,属于模块间过程调用。与模块间数据引用一样,模块间过程调用也可以通过在数据段起始处增加一个全局偏移量表GOT来解决位置无关代码的生成问题,只要在GOT中增加外部函数对应的表项即可。

1
2
3
4
5
6
7
8
static int a;
extern int b;
extern void ext();
void foo()
{
bar();
ext();
}

对于以上的源程序,可以在GOT中设置一个与外部函数ext对应的表项。以下是该源程序经编译后得到的部分机器级代码示例。此例中,假设调用ext函数的指令序列起始处(即popl指令起始处)与GOT中ext对应表项之间的位移量为0x1204。

1
2
3
4
5
6
7
8
000050c<fo0>:
000050c: 55 pushl %ebp
......
0000557: e800000000 call 000055c
000055c: 5b popl %ebx
000055d: addl $0x1204,%ebx
......
...... call *(%ebx)】

上述代码中,从0000557开始的三条指令用于将数据段起始处的GOT中ext对应表项的地址送EBX,000055d处随后的call*(%ebx)指令将EBX所指向的GOT表项中的地址作为调用函数的目标地址,转到ext函数去执行。这里,*(%ebx)为间接地址,即通过“R[eip]←M[R[ebx]]”实现过程调用。与模块间数据引用一样,编译器也要为GOT中ext对应表项生成一个重定位项,GOT中的ext函数地址也是在加载时通过动态链接进行重定位而得到的。

从上述代码可以看出,每次进行模块间过程调用都要额外执行三条指令。如果存在大量这种模块间过程调用的话,就会额外执行大量指令。为此,GCC编译器采用了一种延迟绑定技术,以减少额外指令条数。

*延迟绑定(lazy binding)*技术的基本思想是:对于模块间过程的引用不在加载时进行重定位,而是延迟到第一次函数调用时进行重定位。延迟绑定技术除了需要使用GOT外,还需要使用==过程链接表==(Procedure Linkage Table,PLT)。其中,GOT是.data节(包含在数据段中)的一部分,而PLT是.text节(包含在代码段中)的一部分。

可执行文件中的PLT和GOT

图中对应可执行文件foo中的PLT和GOT。采用延迟绑定技术时,GOT中开始三项总是固定的,含义如下:

  • GOT[0]为.dynamic节首址,该节中包含动态链接器所需要的基本信息,如符号表位置、重定位表位置等;
  • GOT[1]为动态链接器的标识信息;
  • GOT[2]为动态链接器延迟绑定代码的入口地址。

此外,所有被调用的外部函数在GOT中都有对应的表项,例如,上图的GOT[3]就是外部函数ext对应表项。

PLT中每个表项占16字节,它是.text节的一部分,每个表项中实际上包含的是3条指令。除PLT[0]外,其余各项各自对应一个共享库函数,例如,以下的PLT[1]对应ext函数。

1
2
3
4
5
6
7
8
PLT[O]
0804833c: ff3588950408 pushl 0x8049588
8048342: ff258c950408 jmp 0x804958c
8048348: 00000000
PLT[1] <ext>
0804834c: ff2590950408 jmp *0x8049590
8048352: 6800000000 pushl $0x0
8048357: e9 eo ff ff ff jmp 804833c

编译器在处理外部过程ext的调用时,首先在GOT和PLT中填人以上相应信息,然后生成以下机器级代码:

1
804845b:	e8ecfeffff	ca11	804834c	<ext>

启动对应的可执行文件运行后,当第一次执行到上述这条call指令时,将根据目标地址0x804834c,转到PLT[1]处执行。第一条间接跳转指令的执行过程是,先根据地址0x8049590找到ext对应的表项GOT[3],然后根据其中的内容再跳转到0x08048352处执行。此处是一条pushl指令,用于将ext对应的ID压栈,然后执行jmp指令,跳转到0x804833c处的PLT[0]处执行。

PLT[0]中第一条指令将GOT[1]的地址0x8049588压栈,然后通过间接跳转指令,转到GOT[2]指出的动态链接器延时绑定代码处执行。这样,动态链接器延时绑定代码将根据GOT[1]中记录的动态链接器标识信息和ext对应的ID信息,对外部过程ext进行重定位,即在GOT[3]中填入真正的外部过程ext的地址,并控制程序转ext过程执行。

这样,以后再调用外部过程ext时,每次都只要执行“jmp*0x8049590”就可以直接跳转到ext执行了,仅仅多执行了一条jmp指令,而不是多执行三条指令。可以看出,延迟绑定技术的开销主要在第一次过程调用,需要额外执行多条指令,而以后每次都只是多执行一条指令,这对于同一个外部过程被多次调用的情况非常有益。

层次结构存储系统

存储器概述

存储器的分类

  1. 按存储元件分类:存储元件必须具有截然不同且相对稳定的两个物理状态,才能被用来表示二进制代码0和1。

    • 半导体存储器:使用半导体器件构成
    • 磁性表面存储器:使用磁性材料作为存储器,如磁盘存储和磁带存储
    • 光盘存储器:使用光介质存储
  2. 按存取方式分类

    • 随机存取存储器:随机存取存储器(Random Access Memory,简称==RAM==)的特点是按地址访问存储单元,因为每个地址译码时间相同,所以,在不考虑芯片内部缓冲的前提下,每个单元的访问时间是一个常数,与地址无关。不过,现在的DRAM芯片内都具有行缓冲,因而有些数据可能因为已在行缓冲中而缩短了访问时间。随机存取存储器的存储介质是半导体存储器件。
    • 顺序存取存储器:顺序存取存储器(Sequential Access Memory,简称==SAM==)的特点是信息按顺序存放和读出,其存取时间取决于信息存放位置,以记录块为单位编址。磁带存储器就是一种顺序存取存储器,其存储容量大,但存取速度慢。
    • 直接存取存储器:直接存取存储器(Direct Access Memory,简称==DAM==)的存取方式兼有随机访问和顺序访问的特点。首先直接定位到需读写信息所在区域的开始处,然后按顺序方式存取,磁盘存储器就是如此。
    • 相联存储器:上述三类存储器RAM、SAM和DAM都是按所需信息的地址来访问,但有些情况下可能不知道所访问信息的地址,只知道要访问信息的内容特征,此时,只能按内容检索到存储位置进行读写。这种存储器称为按内容访问存储器Content Addressed Memory,简称==CAM==)或相联存储器(Associative Memory,简称AM)。
  3. 按信息的可更改性分类:按信息可更改性分读写存储器和只读存储器(Read Only Memory,简称==ROM==)。

    • 读写存储器中的信息可以读出和写人,RAM芯片是一种读写存储器;

    • 只读存储器ROM芯片中的信息一旦确定,通常在联机情况下只能读不能写,但在某些情况下也可重新写入。

    • [!IMPORTANT]

      RAM芯片和ROM芯片都采用随机存取方式进行信息的访问。

  4. 按断电后信息的可保存性分类:

    • 非易失性存储器(Nonvolatile Memory),非易失性存储器也称不挥发性存储器,其信息可一直保留,不需电源维持,例如,ROM、磁表面存储器、光盘存储器等都是非易失性存储器;
    • 易失性存储器(Volatile Memory),易失性存储器也称挥发性存储器,在电源关闭时信息自动丢失,例如,RAM、cache等都是易失性存储器。
  5. 按功能分类

    • 高速缓冲存储器:高速缓冲存储器(==cache==)简称高速缓存,位于主存和CPU之间,目前主要由静态RAM芯片组成,其存取速度接近CPU的工作速度,用来存放当前CPU经常使用到的指令和数据。
    • 存储器:指令直接面向的存储器是主存储器,简称主存。CPU执行指令时给出的存储地址最终必须转换为主存地址,若不采用虚拟存储管理,则CPU直接给出主存地址。主存是存储器分层结构中的核心存储器,用来存放系统中启动运行的程序及其数据,主存目前一般用MOS管半导体存储器构成。
    • 辅助存储器:把系统运行时直接和主存交换信息的存储器称为辅助存储器,简称辅存。磁盘存储器比磁带和光盘存储器速度快,因此,目前大多用磁盘存储器作为辅存,辅存的内容需要调入主存后才能被CPU访问。
    • 海量后备存储器:磁带存储器和光盘存储器的容量大、速度慢,主要用于信息的备份和脱机存档,因此被用作海量后备存储器。辅存和海量后备存储器统称为外部存储器,简称外存。

一般的计算机都会使用以下的存储层次结构

存储器层次化体系结构

主存储器的组成和基本操作

下图是主存储器(Main Memory,简称MM)的基本框图。其中由一个个存储0或1的记忆单元(cell)构成的存储阵列是存储器的核心部分。这种记忆单元也称为==存储元==、==位元==,它是具有两种稳态的能表示二进制0和1的物理器件。

==存储阵列==(bank)也称为存储体、存储矩阵。为了存取存储体中的信息,必须对存储单元编号,所编号码就是==地址==。编址单位(addressing unit)是指具有相同地址的那些位元构成的一个单位,可以是一个字节或一个字。

对各存储单元进行编号的方式称为==编址方式==(addressing mode),可以按字节编址,也可以按字编址。现在大多数通用计算机都采用字节编址方式,此时,存储体内一个地址中有一个字节。也有许多专用于科学计算的大型计算机采用64位编址,这是因为科学计算中数据大多是64位浮点数。

主存储器的基本框图

如上图所示,指令执行过程中需要访问主存时:

  1. CPU首先把欲访问的主存单元的地址送到主存地址寄存器(Memory Address Register,简称MAR)中,然后通过地址线将主存地址送到主存中的地址寄存器,以便地址译码器进行译码后选中相应单元。
  2. 同时,CPU将读/写控制信号通过控制线送到主存的读写控制电路。
    1. 如果是写操作,CPU同时将要写的信息送主存数据寄存器(Memory Data Register,简称MDR)中,在读写控制电路的控制下,经数据线将信息写入选中的单元;
    2. 如果是读操作,则主存读出选中单元的内容送数据线,然后送到MDR中。数据线的宽度与MDR的宽度相同,地址线的宽度与MAR的宽度相同。图中采用64位数据线,因此,在字节编址方式下,每次最多可以存取8个字节的内容。地址线的位数决定了主存地址空间的最大可寻址范围,例如,36位地址的最大可寻址范围为0~26-1。注意:在计算机中所有地址的编号总是从0开始。

主存与CPU的连接及其读写操作

主存芯片技术

动态RAM主要用作主存,目前主存常用的是基于SDRAM(Synchronous DRAM)芯片技术的内存条,包括DDR SDRAM、DDR2 SDRAM和DDR3 SDRAM等。SDRAM芯片与当年Intel推出的芯片组中北桥芯片的前端总线同步运行,因此,称为同步DRAM。

DRAM芯片技术

目前,动态存储芯片大多采用双译码结构。地址译码器分为X和Y方向两个译码器。

DRAM存储芯片

其中的存储阵列有4096个单元,需要12根地址线,用A~11~ ~ A ~0~表示。

  • A~11~ ~ A~6~送至X译码器,有64条译码输出线,各选择一行单元;
  • A~5~ ~ A~0~送至Y译码器,它也有64条译码输出线,分别控制一列单元的位线控制门。

假如输入的12位地址为A~11~A~10~…A~0~=000001_000000,则X译码器的第2根译码输出线(x~1~)为高电平,与它相连的64个存储单元的字选择W线为高电平。Y译码器的第1根译码输出线(y~0~)为高电平,打开第一列的位线控制门。在X、Y译码的联合作用下,存储阵列中(1,0)单元被选中。

在选中的行和列交叉点上的单元只有一位,因此,采用二维双译码结构的存储器芯片被称为位片式芯片。有些芯片的存储阵列采用三维结构,用多个位平面构成存储阵列,不同位平面在同一行、列交叉点上的多位构成一个存储字,被同时读出或写入。

在双译码结构中,一条X方向的选择线要控制在其上的各个存储单元的字选择线,所以负载较大,因此需要在译码器输出后加驱动电路。此外,I/O控制电路则用以控制被选中的单元的读出或写入,具有放大信息的作用。以下是4M×4位的芯片框图:

DRAM芯片

DRAM芯片容量较大,因而地址位数较多。为了减少芯片的地址引脚数,从而减小体积,大多采用地址引脚复用技术。行地址和列地址通过相同的引脚分先后两次输入,这样地址引脚数可减少一半。

子图a给出了芯片的引脚:

  1. 共有11根地址引脚线A~0~ ~ A~10~,在行地址选通信号RAS和列地址选通信号CS(低电平有效)的控制下,用于分时传送行、列地址;
  2. 有4根数据引脚线D~1~ ~ D~4~ ,因此,每个芯片同时读出4位数据;
  3. WE为读写控制引脚,低电平时为写操作;
  4. OE为输出使能驱动引脚,低电平有效,高电平时断开输出。

子图b给出了芯片内部的逻辑结构图,芯片存储阵列采用三维结构,芯片容量为2048×2048×4位($2048=2^{11}$,11条地址总线)。因此,行地址和列地址各是11位,有4个位平面,在每个行、列交叉处的4个位平面数据同时进行读写。行地址缓冲器和刷新计数器通过一个多路选择器MUX,将选择的行地址输出到行译码器,刷新计数器的位数也是11位,一次刷新相当于对一行数据进行一次读操作,通过对这一行数据读后再生进行刷新。

基本SDRAM技术

SDRAM的工作方式与传统的DRAM有很大不同:

  • 传统DRAM与CPU之间采用==异步方式==交换数据,CPU发出地址和控制信号后,经过一段延迟时间,数据才读出或写入。在这段时间里,CPU不断采样DRAM的完成信号,在没有完成之前,CPU插人等待状态而不能做其他工作。
  • 而SDRAM芯片则不同,其读写受外部系统时钟(即前端总线时钟CLK)控制,因此与CPU之间采用同步方式交换数据。它将CPU或其他主设备发出的地址和控制信息锁存起来,经过确定的几个时钟周期后给出响应。因此,主设备在这段时间内,可以安全地进行其他操作。

SDRAM的每一步操作都在外部系统时钟CLK的控制下进行,支持突发传输(bust)方式。只要在第一次存取时给出首地址,以后按地址顺序读写即可,而不再需要地址建立时间和行、列预充电时间,就能连续快速地从行缓冲器中输出一连串数据。内部的工作方式寄存器(也称模式寄存器)可用来设置传送数据的长度以及从收到读命令(与CAS信号同时发出)到开始传送数据的延迟时间等,前者称为突发长度(Burst Length,简称BL),后者称为CAS潜伏期(CAS Latency,简称CL)。根据所设定的BL和CL,CPU可以确定何时开始从总线上取数以及连续取多少个数据。在开始的第一个数据读出后,同一行的所有数据都被送到行缓冲器中,因此,以后每个时钟可从SDRAM读取一个数据,并在下一个时钟内通过总线传送到CPU。

基于SDRAM技术的芯片的工作过程大致如下:

  1. 在CLK时钟上升沿片选信号(CS)和行地址选通信号(RAS)有效。
  2. 经过一段延时t~RCD~(RAS to CAS delay),列选通信号CAS有效,并同时发出读或写命令,此时,行、列地址被确定,已选中具体的存储单元。
  3. 对于读操作,再经过一个CAS潜伏期后,输出数据开始有效,其后的每个时钟都有一个或多个数据连续从总线上传出,直到完成突发长度BL指定的所有数据的传送。对于写操作,则没有CL延时而直接开始写人。

由于只有读操作才有CL,所以CL又被称为读取潜伏期(Read Latency,简称RL)。t~RCD~和CL都是以时钟周期T~CK~为单位,例如,对于PC100 SDRAM来说,当T~CK~为10ns,CL为2时,则CAS潜伏期时延为20ns。BL可用的选项为1、2、4、8等,当BL为1时,则是非突发传输方式。

DDR SDRAM芯片技术

DDR(Double Data Rate)SDRAM是对标准SDRAM的改进设计,通过芯片内部I/O缓冲(I/O Buffer)中数据的两位预取功能,并利用存储器总线上时钟信号的上升沿与下降沿进行两次传送,以实现一个时钟内传送两次数据的功能。例如,采用DDR SDRAM技术的PC3200(DDR400)存储芯片内CLK时钟的频率为200MHz,意味着存储器总线上的时钟频率也为200MHz,利用存储芯片内部的两位预取技术,使得一个时钟内有两个数据被取到I/O缓冲中。
因为存储器总线在每个时钟内可以传送两次数据,而存储器总线中的数据线位宽为64,即每次传送64位,因而存储器总线上数据的最大传输率(即带宽)为$200MHz×2×64b/8(b/B)=3.2GB/s$。

DDR2 SDRAM

DDR2 SDRAM内存条采用与DDR类似的技术,利用芯片内部的I/O缓冲可以进行4位预取。例如,采用DDR2 SDRAM技术的PC2-3200(DDR2-400)存储芯片内部CLK时钟的频率为200MHz,意味着存储器总线上的时钟频率应为400MHz,利用存储芯片内部的4位预取技率应为400MHz,利用存储芯片内部的4位预取技术,使得一个时钟内有4个数据被取到/0缓冲中,存储器总线在每个时钟内传送两次数据,若每次传送64位,则存储器总线的最大数据传输率(即带宽)为$200MHz×4×64b/8(b/B)=400MHz×2×64b/8(b/B)=6.4GB/s$。

DDR2 SDRAM

DDR3 SDRAM芯片技术

DDR3 SDRAM芯片内部I/O缓冲可以进行8位预取。如果存储芯片内部CLK时钟的频率为200MHz,意味着存储器总线上的时钟频率应为800MHz,存储器总线在每个时钟内可传送两次数据,若每次传送64位,则对应存储器总线的最大数据传输率(即带宽)为$200MHz×8×64b/8(b/B)=800MHz×2×64b/8(b/B)=12.8GB/s$。

主存与CPU的连接及读写

CPU通过其芯片内的总线接口部件(即总线控制逻辑)与处理器总线相连,然后再通过总线之间的I/O桥接器、存储器总线连接到主存。

总线是连接其上的各部件共享的传输介质,通常的构成为:

  1. 控制线
  2. 数据线
  3. 地址线。

计算机中各部件之间通过总线相连,例如,CPU通过处理器总线和存储器总线与主存相连。在CPU和主存之间交换信息时,CPU通过总线接口部件把地址信息和总线控制信息分别送到地址线和控制线,CPU和主存之间交换的数据则通过数据线传输。

主存与CPU之间的连接

受集成度和功耗等因素的限制,单个芯片的容量不可能很大,所以往往通过存储器芯片扩展技术,将多个芯片做在一个内存模块(即内存条)上,然后由多个内存模块以及主板或扩充板上的RAM芯片和ROM芯片组成一台计算机所需的主存空间,再通过总线、桥接器等和CPU相连。下图中的a是内存条和内存条插槽(slot)示意图,b是存储控制器(memory controller)、存储器总线、内存条和DRAM芯片之间的连接关系示意图。存储控制器可以包含在上图中的I/O桥接器中。

image-20240928162922858

如上图a所示,==内存条插槽就是存储器总线==,内存条中的信息通过内存条的引脚,再通过插槽内的引线连接到主板上,通过主板上的导线连接到北桥芯片或CPU芯片。

现在的计算机中可以有多条存储器总线同时进行数据传输,支持两条总线同时进行传输的内存条插槽为双通道内存插槽,还有三通道、四通道内存插槽,其总线的传输带宽可以分别提高到单通道的两倍、三倍和四倍。例如,上图所示的内存条插槽支持双通道内存条,相同颜色的插槽可以并行传输,因此,对于上图所示的内存条插槽情况,如果只有两个内存条,则应该插在两个颜色相同的内存条插槽上,其传输带宽可以增大一倍。

由若干个存储器芯片构成一个存储器时,需要在字方向和位方向上进行扩展。

  • 扩展指用若干片位数较少的存储器芯片构成给定字长的存储器。例如,用8片4K×1位的芯片构成4K×8位的存储器,需在位方向上扩展8倍,而字方向上无须扩展。(==横向拓展==)
  • 扩展是容量的扩充,位数不变。例如,用16K×8位的存储芯片在字方向上扩展4倍,构成一个64K×8位的存储器。当芯片在容量和位数上都不满足存储器要求的情况下,需要对字和位同时扩展。例如,用16K×4位的存储器芯片在字方向上扩展4倍、位方向上扩展2倍,可构成一个64K×8位的存储器。(==纵向拓展==)

栗子:

用8个16M×8位(16MB)的DRAM芯片扩展构成一个128MB内存条的示意图。每片DRAM芯片中有一个4096×4096×8位的存储阵列,所以,行地址和列地址各12位($2^{12} = 4096$),有8个位平面。使用字扩展(==纵向拓展==)对容量进行扩容。

DRAM芯片的拓展

换算关系
$$
16MB = 16M \times 8bit = 2^{24} \times 8bit =2^{12} \times 2^{12} \times 8bit = 4096 \times 4096 \times 8bit
$$
内存条通过存储器总线连接到存储控制器,CPU通过存储控制器对内存条中的DRAM芯片进行读写,CPU要读写的存储单元地址通过总线被送到存储控制器,然后由存储控制器将存储单元地址转换为DRAM芯片的行地址i和列地址j,分别在行地址选通信号RAS和列地址选通信号CAS的控制下,通过DRAM芯片的地址引脚,分时送到DRAM芯片内部的行地址译码器和列地址译码器,以选择行、列地址交叉点(i,j)的8位数据同时进行读写,8个芯片就可同时读取64位,组合成总线所需要的64位传输宽度,再通过存储器总线进行传输。

现代通用计算机大多按==字节==编址,因此,在图中的存储器结构中,同时读出的64位可能是第0 ~ 7单元、第8 ~ 15单元、…、第8×k~ 8×k+7单元,以此类推。因此,如果访问的一个int型数据不对齐,假定在第6、7、8、9这四个存储单元中,则需要访问两次存储器;如果数据对齐的话,即起始地址是4的倍数,则只要访问一次即可。这就是数据需要对齐的原因。

若一个$2^{n} \times b$位DRAM芯片的存储阵列是r行×c列,则该芯片容量为$2^{n}\times b$位且$2^{n}=r\times c$,芯片内的地址位数为n,其中行地址位数为$log_2r$,列地址位数为$log_2c$,n位地址中==高位部分为行地址,低位部分为列地址==。为提高DRAM芯片的性价比,通常设置的r和c满足r≤c且r-c最小。例如,对于8K×8位DRAM芯片,其存储阵列设置为2行×2列,因此行地址和列地址的位数分别为6位和7位,13位芯片内地址A~12~A~11~…A~1~A~0~。中,行地址为A~12~…A~7~,列地址为A~6~…A~1~A~0~。

下图是DRAM芯片内部结构示意图。图中芯片容量为16×8位,存储阵列为4行×4列,地址引脚采用复用方式,因而仅需2根地址引脚,在RAS和CAS的控制下分时传送2位行地址和2位列地址。每个超元(supercell)有8位,需8根数据引脚,有一个内部行缓冲(row buffer),用来缓存指定行中每一列的数据,通常用SRAM元件实现。

DRAM芯片内部结构示意图

下图是DRAM芯片读写原理示意图。图a反映存储控制器在RAS有效时将行地址“2”送到行译码器后选中第“2”行时的状态,此时,整个一行数据被送到内部行缓冲中。图b反映存储控制器在CAS有效时将列地址“1”送到列译码器后选中第“1”列时的状态,此时,将内部行缓冲中第“1”列的8位数据超元(2,1)读到数据线,并继续向CPU传送。

image-20240928192010650

装入指令和存储指令操作过程

访存指令主要有两类:

  1. 装入(load)指令:用于将存储单元内容装入CPU的寄存器中,如IA-32中的movl 8(%ebp),%eax指令等;
  2. 存储(store)指令:用于将CPU寄存器内容存储到存储单元中,如IA-32中的movl %eax, 8(%ebp)指令等。

==装入指令==

假定装入指令movl 8(%ebp),%eax中存储器操作数8(%ebp)对应的主存地址为A,则取数过程为:

  1. CPU将主存地址A通过总线接口送到地址线,然后由存储控制器将地址A分解成行、列地址按分时方式送DRAM芯片;

    CPU通过地址线将地址A传送到主存

  2. 主存将地址A中的数据x通过数据线送到总线接口部件中;

    主存将A中的数据x读出,传送到数据线上

  3. CPU从总线接口部件中取出x存放到寄存器EAX中。实际上,上述过程的第一步同时还会把“存储器读”控制命令通过控制线送到主存。

    从主存单元取数到寄存器的操作过程

==存储指令==

假定存储指令movl %eax,8(%ebp)中主存操作数8(%ebp)的主存地址为A。则存数的过程为:

  1. CPU将主存地址A通过总线接口送到地址线,然后由存储控制器将地址A分解成行、列地址按分时方式送DRAM芯片;

    CPU通过地址线将地址A传送给主存

  2. CPU将寄存器EAX中的数据y通过总线接口部件送到数据线;

    CPU将数据y传送到数据线

  3. 主存将数据线上的y存到主存单元A中。实际上,上述过程的第一步同时还会把“存储器写’”控制命令通过控制线送到主存。而且,第二步将数据y送数据线也可以和第一步同时进行。

    主存将数y存放到主存单元A中

硬盘存储器

磁盘存储器结构

磁盘存储器主要由以下部件组成:

  1. 磁记录介质
  2. 磁盘驱动器:磁盘驱动器包括读写电路、读/写转换开关、读/写磁头与磁头定位伺服系统。
  3. 磁盘控制器:磁盘控制器(disk controller)包括控制逻辑、时序电路、“并→串”转换和“串→并”转换电路

磁盘驱动器的物理组成

磁盘驱动器主要由多张硬盘片、主轴、主轴电机、移动臂、磁头和控制电路等部分组成,通过接口与磁盘控制器连接。

  • 每个盘片的两个面上各有一个磁头,因此,磁头号就是盘面号。

    盘面

  • 磁头和盘片相对运动形成的圆构成一个磁道(tack),磁头位于不同的半径上,则得到不同的磁道。

  • 信息存储在盘面的磁道上,而每个磁道被分成若干扇区(sector),以扇区为单位进行磁盘读写。

    扇区

  • 多个盘片上相同磁道形成一个柱面(cylinder),所以,磁道号就是柱面号。

    柱面

  • 在读写磁盘时,总是写完一个柱面上所有的磁道后,再移到下一个柱面。磁道从外向里编址,==最外面的为磁道0==。

磁盘读写是指根据主机访问控制字中的盘地址(柱面号、磁头号、扇区号)读写目标磁道中的指定扇区。因此,其操作可归纳为寻道、旋转等待和读写三个步骤。

  1. 寻道操作:磁盘控制器把盘地址送到磁盘驱动器的磁盘地址寄存器后,便产生==寻道命令==,启动磁头定位伺服系统,根据磁头号和柱面号,选择指定的磁头移动到指定的柱面。此操作完成后,发出寻道结束信号给磁盘控制器,并转入旋转等待操作。
  2. 旋转等待操作:盘片旋转时,首先将扇区计数器清零,以后每来一个扇区标志脉冲,扇区计数器加1,把计数内容与磁盘地址寄存器中的扇区地址进行比较,如果一致,则输出扇区符合信号,说明要读写的信息已经转到磁头下方。
  3. 读写操作:扇区符合信号送给磁盘控制器后,磁盘控制器的读写控制电路开始动作。如果是写操作,就将数据送到写入电路,写入电路根据记录方式生成相应的写电流脉冲;如果是读操作,则由读出放大电路读出内容送磁盘控制器。

磁盘驱动器的内部逻辑结构

磁盘控制器是主机与磁盘驱动器之间的接口。磁盘存储器是高速外设,所以磁盘控制器和主机之间采用成批数据交换方式。

数据在磁盘上的记录格式分定长记录格式和不定长记录格式两种。目前大多采用定长记录格式。最早的硬盘由BM公司开发,称为温切斯特盘(Winchester是一个地名),简称温盘,它是几乎所有现代硬盘产品的原型,它采用定长记录格式。

温切斯特磁盘的磁道记录格式

温切斯特盘的每个磁道由若干个扇区(也称扇段)组成,每个扇区记录一个数据块,每个扇区由头空(间隙1)、ID域、间隙2、数据域和尾空(间隙3)组成。头空占17个字节,不记录数据,用全1表示,磁盘转过该区域的时间是留给磁盘控制器作准备用的;D域由同步字节、磁道号、磁头号、扇段号和相应的CRC码组成,同步字节标志D域的开始;数据域占515个字节,由同步字节、数据和相应的CRC码组成,其中真正的数据区占512字节;尾空是在数据块的CRC码后的区域,占20个字节,也用全1表示。

磁盘存储器的连接

现代计算机中,通常将复杂的磁盘物理扇区抽象成固定大小的逻辑块,物理扇区和逻辑块之间的映射由磁盘控制器来维护。磁盘控制器是一个内置固件的硬件设备,它能将主机送来的请求逻辑块号转换为磁盘的物理地址(柱面号、磁头号、扇区号),并控制磁盘驱动器进行相应的动作。

磁盘与CPU、主存的连接

磁盘控制器连接在I/O总线上,I/O总线与其他系统总线(如处理器总线、存储器总线)之间用桥接器连接。磁盘驱动器与磁盘控制器之间的接口有多种,一般文件服务器使用SCSI接口,而普通的PC前些年多使用==并行ATA(即IDE)接口==,目前大多使用==串行ATA(即SATA)接口==。

磁盘与主机交换数据的最小单位是扇区,因此,磁盘总是按成批数据交换方式进行读写,这种高速成批数据交换设备采用直接存储器存取(Direct Memory Access,DMA)方式进行数据的输入输出。该输入输出方式用专门的DMA接口硬件来控制外设与主存间的直接数据交换,数据不通过CPU。通常把专门用来控制总线进行DMA传送的接口硬件称为DMA控制器。在进行DMA传送时,CPU让出总线控制权,由DMA控制器控制总线,通过“窃取”一个主存周期完成和主存之间的一次数据交换,或独占若干个主存周期完成一批数据的交换。

固态硬盘

近年来,一种称为==固态硬盘==(Solid State Disk,简称SSD)的新产品开始在市场上出现(也被称为电子硬盘)。这种硬盘并不是一种磁表面存储器,而是一种使用NAND闪存组成的外部存储系统,与U盘并没有本质差别,只是容量更大,存取性能更好。它用闪存颗粒代替了磁盘作为存储介质,利用闪存的特点,以区块写入和擦除的方式进行数据的读取和写入。

固态硬盘的接口规范和定义、功能及使用方法与传统硬盘完全相同,在产品外形和尺寸上也与普通硬盘一致。目前接口标准使用USB、SATA和IDE,因此SSD通过标准磁盘接口与I/O总线互连。在SSD中有一个==闪存翻译层==,它将来自CPU的逻辑磁盘块读写请求翻译成对底层SSD物理设备的读写控制信号。因此,这个闪存翻译层相当于磁盘控制器。

SSD中一个闪存芯片由若干个区块组成,每个区块由若干页组成。通常,页大小为512B ~ 4KB,每个区块由32 ~ 128个页组成,因而区块大小为16KB ~ 512KB。数据可以按页为单位进行读写。当需要写某页信息时,必须先对该页所在的区块进行擦除操作。一旦一个区块被擦除过,区块中的每一页就可以直接再写一次。若某一区块进行了大约100000次重复写之后,就会被磨损而变成坏的区块,不能再被使用。因此,闪存翻译层中有一个专门的均化磨损(wear leveling)逻辑电路,试图将擦除操作平均分布在所有区块上,以最大限度地延长SSD的使用寿命。

电信号的控制使得固态硬盘的内部传输速率远远高于常规硬盘。SSD随机读访问时间(延时)大约为几十微秒,而随机写的访问时间(延时)大约为几百微秒。硬盘由于需要寻道和旋转等待,所以其访问时间大约是几毫秒到几十毫秒,因此,SSD随机读写延时比硬盘要低两个数量级。有测试显示,使用固态硬盘以后,Windows的开机速度可以提升至20秒以内,这是基于常规硬盘的计算机系统难以达到的速度性能。

与常规硬盘相比,除速度性能外,固态硬盘还具有抗震性好、安全性高、无噪声、能耗低、发热量低和适应性高的特点。由于不需要电机、盘片、磁头等机械部分,固态硬盘工作过程中没有任何机械运动和震动,因而抗震性好,使数据安全性成倍提高,并且没有常规硬盘的噪声;由于不需要电机,固态硬盘的能耗也得到了成倍的降低,只有传统硬盘的1/3甚至更低,延长了靠电池供电的设备的连续运转时间;而且由于没有电机等机械部件,其发热量大幅降低,延长了其他配件的使用寿命。此外,固态硬盘的工作温度范围很宽(-40~85℃),因此,其适应性上也远高于常规硬盘。固态硬盘在刚出现时与最高速的常规硬盘相比在读写性能方面各有上下,而且价格也较高。但随着相应技术的不断发展,目前固态硬盘的读写性能基本上超越了常规硬盘,且价格也不断下降。由于固态硬盘具有以上优点,加上其今后的发展潜力比传统硬盘要大得多,因而固态硬盘有望逐步取代传统硬盘。

高速缓冲存储器

由于CPU和主存所使用的半导体器件工艺不同,两者速度上的差距导致快速的CPU等待慢速的主存储器,为此需要想办法提高CPU访问主存的速度。除了提高DRAM芯片本身的速度和采用并行结构技术以外,加快CPU访存速度的主要方式之一是在CPU和主存之间增加高速缓冲存储器(简称高速缓存或cache)。

程序访问的局部性

对大量典型程序运行情况分析的结果表明,在较短时间间隔内,程序产生的地址往往集中在存储空间的一个很小范围,这种现象称为程序访问的局部性。这种局部性可细分为时间局部性和空间局部性:

  1. 时间局部性是指被访问的某个存储单元在一个较短的时间间隔内很可能又被访问。
  2. 空间局部性是指被访问的某个存储单元的邻近单元在一个较短的时间间隔内很可能也被访问。

出现程序访问的局部性特征的原因不难理解:程序是由指令和数据组成的,指令在主存按顺序存放,其地址连续,循环程序段或子程序段通常被重复执行,因此,指令具有明显的访问局部化特征;而数据在主存一般也是连续存放,特别是数组元素,常常被按序重复访问,因此,数据也具有明显的访问局部化特征。

例如,以下是一个C高级语言程序段。

1
2
3
4
sum = 0;
for (i = 0;i < n;i++)
sum += a[i];
*v = sum;

上述程序段对应的汇编程序段可由10条指令组成,用中间语言描述如下。

1
2
3
4
5
6
7
8
9
10
I0 	sum <- 	0
I1 ap <- A ;A是数组的起始地址
12 i <- 0
I3 if (i >= n) goto done
I4 loop: t <- (ap) ;数组元素a[i]的值
I5 sum <- sum + t ;累加值在sum中
I6 ap <- ap + 4 ;计算下一个数组元素的地址
I7 i <- i + 1
I8 if (i < n) goto loop
I9 done: V <- sum ;累加结果保存至地址V

上述描述中的变量sum、ap、i、n、t均可认为存放在通用寄存器中,A和V为主存地址。假定每条指令占4字节,每个数组元素占4字节,按字节编址,则指令和数组元素在主存中的存放情况如下图所示:

指令和数组在主存的存放

在程序执行过程中,首先,按指令I0 ~ I3的顺序执行,然后,指令 I4 ~ I8按顺序被循环执行n次。只要n足够大,程序在一段时间内,就一直在该局部区域内执行。对于取指令来说,程序对主存的访问过程是:

程序对主存的访问过程

上述程序对数组的访问在指令I4中进行,每次循环数组下标加4,即每次按4字节连续访问主存。因为数组元素在主存按a[0],a[1],…,a[n-1]的顺序连续存放,所以,该程序对数据的访问过程是:
$$
0x400 \to 0x404 \to 0x408 \to 0x40C \to … \to 0x7A4
$$
由此可见,在一段时间内,访问的数据也在局部的连续区域内。为了更好地利用程序访问的空间局部性,通常把当前访问单元以及邻近单元作为一个主存块一起调人cache。这个主存块的大小以及程序对数组元素的访问顺序等都对程序的性能有一定的影响。

cache的基本工作原理

cache是一种小容量高速缓冲存储器,由快速的SRAM组成,直接制作在CPU芯片内,速度较快,几乎与CPU处于同一个量级。在CPU和主存之间设置cache,总是把主存中被频繁访问的活跃程序块和数据块复制到cache中。由于程序访问的局部性,大多数情况下,CPU能直接从cache中取得指令和数据,而不必访问慢速的主存。

为便于cache和主存间交换信息,cache和主存空间都被划分为相等的区域。例如,将主存按照每512字节划分成一个区域,同时把cache也划分成同样大小的区域,这样主存中的信息就可按照512字节为单位送到cache中。我们把主存中的区域称为块(block),也称为主存块,它是cache和主存之间的信息交换单位;cache中存放一个主存块的区域称为行(line)或槽(slot),也称cache行(槽)。

cache的有效位

在系统启动或复位时,每个cache行都为空,其中的信息无效,只有在cache行中装入了主存块后才有效。为了说明cache行中的信息是否有效,每个cache行需要一个“有效位”(valid bit)。有了有效位,就可通过将有效位清0来淘汰某cache行中的主存;装人一个新主存块时,再使有效位置1。

CPU在cache中的访问过程

CPU执行程序过程中,需要从主存取指令或读写数据时,先检查cache中有没有要访问的信息,若有,就直接在cache中读写,而不用访问主存储器;若没有,再从主存中把当前访问信息所在的一个主存块复制到cache中,因此,==cache中的内容是主存中部分内容的副本==。下图给出了带cache的CPU执行一次访存操作的过程。

带cache的CPU的访存操作过程

整个访存过程包括:判断信息是否在cache中,从cache取信息或从主存取一个主存块到cache等,在对应cache行已满的情况下还要替换cache中的信息。这些工作要求在一条指令执行过程中完成,因而只能由硬件来实现。cache对程序员来说是透明的,程序员编程时根本不知道有cache的存在,更不用考虑信息存放在主存还是在cache,因此,cache位于微体系结构层面。在ISA层面,需要考虑提供“刷新cache”等特权指令,这些指令只能在操作系统内核态使用。

访问时间

在访存过程中,需要判断所访问信息是否在cache中。

  1. 若CPU访问单元所在的主存块在cache中,则称cache命中(hit),命中的概率称为命中率p(hit rate),它等于命中次数与访问总次数之比;

  2. 若不在cache中,则为不命中(miss),其概率称为缺失率(miss rate),它等于不命中次数与访问总次数之比。

  3. 命中时,CPU在cache中直接存取信息,所用的时间开销就是cache访问时间$T_c$。,称为命中时间(hit time);

  4. 缺失时,需要从主存读取一个主存块送cache,并同时将所需信息送CPU,因此,所用时间开销为主存访问时间$T_m$和cache访问时间$T_c$之和。通常把从主存读入一个主存块到cache的时间$T_m$​称为缺失损失(miss penalty)。CPU在cache-主存层次的平均访问时间为:
    $$
    T_a = p \times T_c + (1 - p) \times(T_m + T_c) = T_c + (1-p) \times T_m
    $$
    由于程序访问的局部性特点,cache的命中率可以达到很高,接近于1。因此,虽然缺失损失 >> 命中时间,但最终的平均访问时间仍可接近cache的访问时间。

cache行和主存块的映射

cache行中的信息取自主存中的某个块。在将主存块复制到cache行时,主存块和cache行之间必须遵循一定的映射规则,这样,CPU要访问某个主存单元时,可以依据映射规则到cache对应的行中查找要访问的信息,而不用在整个cache中查找。

根据不同的映射规则,主存块和cache行之间有以下三种映射方式:

  1. 直接(direct):每个主存块映射到cache的固定行中。
  2. 全相联(full associate):每个主存块映射到cache的任意行中。
  3. 组相联(set associate):每个主存块映射到cache的固定组的任意行中。

直接映射

直接映射的基本思想是,把主存每一块映射到一个固定cache行中,也称模映射,其映射关系如下:
$$
cache行号=主存块号 \quad mod \quad cache行数
$$

例如,假定cache共有16行,根据100 mod 16=4,可知主存第100块应映射到cache的第4行中。

直接映射方式下,主存地址被分成标记、cache行号和块内地址三个字段:

cache映射

假定cache共有$2^c$行,主存共有$2^m$块,主存块大小占$2^b$字节,按字节编址,则cache行号占c位、主存块号占m位,块内地址有b位。因为m位主存块号被分解成标记字段和cache行号字段,因而标记字段占$t=m-c$位。

下图给出了直接映射方式下主存块和cache行之间的映射示意:

cache和主存间的映射关系

图中主存第0、1、…、$2^c-1$块分别映射到cache第0、1、…、$2^c-1$行;主存第$2^c$、$2^c+1$、…、$2^{c+1}-1$块也分别映射到cache第0、1、…$2^c-1$行;等等。每个cache行中还应包含一个有效位,上图的cache行中省略了有效位。

直接映射方式下,CPU访存过程如下图所示:

CPU访存过程

首先根据主存地址中间的c位,直接找到对应的cache行,将对应cache行中的标记和主存地址的高t位标记进行比较,若相等且有效位为1,则访问cache“命中”,此时,根据主存地址中低b位的块内地址,在对应的cache行中存取信息;若不相等或有效位为0,则cache“缺失”,此时,CPU从主存中读出该主存地址所在的一块信息通过系统总线送到对应的cche行中,将有效位置1,并将标记设置为该地址的高t位,同时将该地址中的内容送CPU。

CPU访存时,读操作和写操作的过程有一些不同,相对来说,读操作比写操作简单。因为cache行中的信息是某主存块的副本,所以,在写操作时会出现cache行和主存块数据的一致性问题。

直接映射的优点是容易实现,命中时间短,但由于多个块号“同余”的主存块只能映射到同一个cache行,当访问集中在“同余”的主存块时,就会引起频繁的调进调出,即使其他cache行都空闲,也毫无帮助。很显然,直接映射方式不够灵活,使得cache存储空间得不到充分利用,命中率较低。

全相联映射

全相联映射的基本思想是一个主存块可装人cache任意一行中。全相联映射cache中,每行的标记用于指出该行取自主存的哪个块。因为一个主存块可能在任意一个cache行中,所以,需要比较所有cache行的标记,因此,主存地址中不需要cache行索引,只有标记和块内地址两个字段。全相联映射方式下,只要有空闲cache行,就不会发生冲突,因而块冲突概率低。

例子

假定主存按字编址,主存块与cache行之间采用全相联映射,块大小为512字。cache数据区容量为8K字,主存地址空间为1M字。问:主存地址如何划分?说明CPU对主存单元0240CH的访问过程。

cache数据区容量为8K字=$2^{13}$字=$2^4$行×512字/行=16行×$2^9$字/行。主存地址空间为1M字=$2^{20}$字=$2^{11}$块×512字/块。20位的主存地址划分为两个字段:标记位数t为11,块内地址位数b为9。

主存地址划分以及主存块和cache行之间的对应关系如下图所示。

全相联cache映射关系

全相联数据格式

主存地址0240CH展开为二进制数为0000 0010 0100 0000 1100,所以主存地址划分为:

数据展开

访问0240CH单元的过程为:首先将高11位标记00000010010与cache中每个行的标记进行比较,若有一个相等并且对应有效位为1,则命中,此时,CPU根据块内地址000001100从该行中取出信息;若都不相等,则不命中,此时,需要将0240CH单元所在的主存第00000010010块(即第18块)复制到cache的任何一个空闲行中,并置有效位为.1,置标记为00000010010(表示信息取自主存第18块)。

为了加快比较的速度,通常每个cache行都设置一个比较器,比较器位数等于标记字段的位数。全相联cache访存时根据标记字段的内容来访问cache行中的主存块,它查找主存块的过程是一种“按内容访问”的存取方式,因此,它是一种“相联存储器”。全相联映射方式的时间开销和所用元件开销都较大,实现起来比较困难,不适合容量较大的cache。

组相联映射

前面介绍了直接映射和全相联映射,它们的优缺点正好相反,两者结合可以取长补短。将两种方式结合起来产生组相联映射方式。

组相联映射的主要思想是,将cache分成大小相等的组,每个主存块被映射到cache固定组中的任意一行,也即采用组间模映射、组内全映射的方式。映射关系如下:
$$
cache组号=主存块号 \quad mod \quad cache组数
$$
若cache共16行,分成8组,则每组有2行,此时,主存第100块应映射到cache第4组的任意一行中,因为100 mod 8=4。

组相联方式下,主存地址被划分为标记、cache组号和块内地址三个字段。

组相联数据格式

假定cache共有$2^c$行,被分成$2^q$组,则每组有$2^c/2^q=2^{c-q}$行。设s=c-q,则cache映射方式称为$2^s$路组相联映射,即s=1为2路组相联;s=2为4路组相联,以此类推。若主存共有$2^m$块,主存块大小占2字节,按字节编址,则块内地址有b位,cache组号有g位,标记和cache组号共m位,因而标记占t=m-q位。

s的选取决定了块冲突的概率和相联比较的复杂性。s越大,则cache发生块冲突的概率越低,而相联比较的电路越复杂。选取适当的s,可使组相联映射的成本比全相联的低得多,而性能上仍可接近全相联方式。早几年,由于cache容量不大,所以通常s=1或2,即2路或4路组相联较常用,但随着技术的发展,cache容量不断增加,s的值有增大的趋势,目前有许多处理器的cache采用8路或16路组相联方式。

下图所示的是采用2路组相联映射的cache,其中每个cache行都有对应的有效位V、标记Tag和数据Data。整个访存过程为:

  1. 根据主存地址中的cache组号找到对应组;
  2. 将主存地址中的标记与对应组中每个行的标记Tag进行比较;
  3. 将比较结果和有效位V相“与”;
  4. 若有一路比较相等并有效位为1,则输出“Hit”(命中)为1,并选中这一路cache行中的主存块;
  5. 在“Hit”为1的情况下,根据主存地址中的块内地址从选中的一块内取出对应单元的信息,若“Hit”不为1,则CPU要到主存去读一块信息到cache行中。

组相联映射方式的硬件实现

组相联映射方式结合了直接映射和全相联映射的优点。当cache的组数为1时,变为全相联映射;当每组只有一个cache行时,则变为直接映射。组相联映射的冲突概率比直接映射低,由于只有组内各行采用全相联映射,所以比较器的位数和个数都比全相联映射少,易于实现,查找速度也快得多。

cache中主存块的替换方法

cache行数比主存块数少得多,因此,往往多个主存块会映射到同一个cache行中。当新的一个主存块复制到cache时,cache中的对应行可能已经全部被占满,此时,必须选择淘汰掉一个cache行中的主存块。

例如,现有2路组相联映射cache,假定第0组的两个cache行分别被主存第0块和第8块占满,此时若需调入主存第16块,根据映射关系,它只能存放到cache第0组,因此,已经在第0组的主存第0块和第8块这两个主存块,必须选择调出其中的一块。到底调出哪一块呢?这就是淘汰策略问题,也称为替换算法或替换策略。

常用的替换算法有:先进先出(first-In-First-Out,简称FIFO)、最近最少用(Least-Recently Used,简称LRU)、最不经常用(Least-Frequently Used,简称LFU)和随机替换等。可以根据实现的难易程度以及是否能获得较高的命中率这两方面来决定采用哪种算法。

==先进先出==

FIFO算法的基本思想是:总是选择最早装入cache的主存块被替换掉。这种算法实现起来较方便,但不能正确反映程序的访问局部性,由于最先进入的主存块也可能是目前经常要用的,因此,这种算法有可能产生较大的缺失率。

==最近最少==

LRU算法的基本思想是:总是选择近期最少使用的主存块被替换掉。这种算法能比较正确地反映程序的访问局部性,因为当前最少使用的块一般来说也是将来最少被访问的。它的实现比FIFO算法要复杂一些。采用LRU算法的每个cache行有一个计数器,用计数值来记录主存块的使用情况,通过硬件修改计数值,并根据计数值选择淘汰某个cache行中的主存块。这个计数值称为LRU位,其位数与cache组大小有关。2路组相联时有1位LRU位,4路组相联时有2位LRU位。

为简化上述LRU位计数的硬件实现,通常采用一种近似的LRU位计数方式来实现LRU算法。近似LRU计数方法仅区分哪些是新调入的主存块,哪些是较长时间未用的主存块,然后,在较长时间未用的块中选择一个被替换出去。

==最不经常用==

LFU算法的基本思想是:替换掉cache中引用次数最少的块。LFU也用与每个行相关的计数器来实现。这种算法与LRU有点类似,但不完全相同。

==随机替换==

从候选行的主存块中随机选取一个淘汰掉,与使用情况无关。模拟试验表明,随机替换算法在性能上只稍逊于基于使用情况的算法,而且代价低。

cache一致性问题

由于cache中的内容是某些主存块的副本,因此,当CPU进行写操作需对cache中的内容进行更新时,就存在cache和主存如何保持一致的问题。除此之外,以下情况也会出现cache一致性问题:

  1. 当多个设备都允许访问主存时。例如,像磁盘这类高速I/O设备可通过DMA方式直接与主存交换数据,如果cache中的内容被CPU修改而主存块没有更新的话,则从主存传送到I/O设备的内容就无效;若I/O设备修改了主存块的内容,则对应cache行中的内容就
    无效。
  2. 当多个CPU都带有各自的cache而共享主存时。在多CPU系统中,若某个CPU修改了自身cache中的内容,则对应的主存块和其他CPU中对应的cache行的内容都变为无效。

解决cache一致性问题的关键是处理好写操作。通常有两种写操作方式:

  1. 全写法
  2. 回写法

全写法

全写法(write through)的基本做法是:当CPU执行写操作时,若写命中,则同时写cache和主存;若写不命中,则有以下两种处理方式:

  1. 写分配法(write allocate)。先在主存块中更新相应存储单元,然后分配一个cache行,将更新后的主存块装入分配的cache行中。这种方式可以充分利用空间局部性,但每次写不命中都要从主存读一个块到cache中,增加了读主存块的开销。
  2. 非写分配法(not write allocate)。仅更新主存单元而不把主存块装入cache中。这种方式可以减少读入主存块的时间,但没有很好利用空间局部性。

由此可见,全写法实际上采用的是对主存块信息及其所有副本信息全都直接同步更新的做法,因此通常被称为通写法或直写法。

显然,全写法在替换时不必将被替换的cache内容写回主存,而且cache和主存的一致性能得到充分保证。但是,这种方法会大大增加写操作的开销。例如,假定一次写主存需要100个CPU时钟周期,那么10%的存储(store)指令就使得CPU增加了100×10%=10个时钟周期。

为了减少写主存的开销,通常在cache和主存之间加一个写缓冲(write buffer)。在CPU写cache的同时,也将信息写入写缓冲,然后由存储控制器将写缓冲中的内容写入主存。写缓冲是一个FIFO队列,一般只有几项,在写操作频率不是很高的情况下,因为CPU只需要将信息写入快速的写缓冲而不需要写慢速的主存,因而效果较好。但是,如果写操作频繁发生,则会使写缓冲饱和而发生阻塞。

回写法

回写法(write back)的基本做法是:当CPU执行写操作时,若写命中,则信息只被写入cache而不被写入主存;若写不命中,则在cache中分配一行,将主存块调入该cache行中并更新cache中相应单元的内容。因此,该方式下在写不命中时,通常采用写分配法进行写操作。

在CPU执行写操作时,回写法不会更新主存单元,==只有当cache行中的主存块被替换时,才将该主存块内容一次性写回主存==。这种方式的好处在于减少了写主存的次数,因而大大降低了主存带宽需求。为了减少写回主存块的开销,每个cache行设置了一个修改位(dirty bit,有时也称为“脏位”)。若修改位为1,则说明对应cache行中的主存块被修改过,替换时需要写回主存;若修改位为0,则说明对应主存块未被修改过,替换时不需要写回主存。

由此可见,该方式实际上采用的是回头再写或最后一次性写的做法,因此通常被称为回写法或一次性写方式。由于回写法没有同步更新cache和主存内容,所以存在cache和主存内容不一致而带来的潜在隐患。通常需要其他的同步机制来保证存储信息的一致性。

虚拟存储器

目前计算机主存主要由DRAM芯片构成,由于技术和成本等原因,主存的存储容量受到限制,并且各种不同计算机所配置的物理内存容量多半也不相同,而程序设计时人们显然不希望受到特定计算机的物理内存大小的制约,因此,如何解决这两者之间的矛盾是一个重要问题;此外,现代操作系统都支持多道程序运行,如何让多个程序有效而安全地共享主存是另一个重要问题。

为了解决上述两个问题,计算机中采用了虚拟存储技术。其基本思想是,程序员在一个不受物理内存空间限制并且比物理内存空间大得多的虚拟的逻辑地址空间中编写程序,就好像每个程序都独立拥有一个巨大的存储空间一样。程序执行过程中,把当前执行到的一部分程序和相应的数据调入主存,其他暂不用的部分暂时存放在硬盘上。

基本概念

在不采用虚拟存储机制的计算机系统中,CPU执行指令时,取指令和存取操作数所用的地址都是主存的物理地址,无须进行地址转换,因而计算机硬件结构比较简单,指令执行速度较快。实时性要求较高的嵌入式微控制器大多不采用虚拟存储机制。

目前,在服务器、台式机和笔记本等各类通用计算机系统中都采用虚拟存储技术。在采用虚拟存储技术的计算机中,指令执行时,通过存储器管理部件(Memory Management Unit,简称==MMU==)将指令中的逻辑地址(也称虚拟地址或虚地址,简写为==VA==)转化为主存的物理地址(也称主存地址或实地址,简写为==PA==)。

  • 在地址转换过程中由硬件检查是否发生了访问信息不在主存或地址越界或访问越权等情况。
  • 若发现信息不在主存,则由操作系统将数据从硬盘读到主存。
  • 若发生地址越界或访问越权,则由操作系统进行相应的异常处理。
  • 由此可以看出,虚拟存储技术既解决了编程空间受限的问题,又解决了多道程序共享主存带来的安全等问题。

下图是具有虚拟存储机制的CPU与主存的连接示意图,从图中可知,CPU执行指令时所给出的是指令或操作数的虚拟地址,需要通过MMU将虚拟地址转换为主存的物理地址才能访问主存,MMU包含在CPU芯片中。图中显示MMU将一个虚拟地址4100转换为物理地址4,从而将第4、5、6、7这四个单元的数据组成4字节数据送到CPU。(其中没有考虑cache等情况。)

具有虚拟存储机制的CPU和主存的连接

虚拟存储机制(简称虚存机制)由硬件与操作系统共同协作实现,涉及计算机系统许多层面,包括操作系统中的许多概念,如进程、存储器管理、虚拟地址空间、缺页处理等。

==进程==是一个具有一定独立功能的程序关于某个数据集合的一次运行活动,简单来说,进程就是程序的一次执行过程。每一个进程都有它自己的地址空间,一般情况下,地址空间包括只读区(代码和只读数据)、可读可写数据区(初始化数据和未初始化数据)、动态的堆区和栈区。一个静态的程序(可执行目标文件)只有被加载运行后,它才成为一个活动的实体,才能被称为进程。进程是与一个用户程序(即应用程序)对应的概念,因此,很多时候也称其为用户进程。

虚拟地址空间

每个高级语言源程序经编译、汇编、链接等处理生成可执行的二进制机器目标代码时,都被映射到一个统一的虚拟地址空间。所谓“统一”是指不同的可执行文件所映射的虚拟地址空间大小一样,地址空间中的区域划分结构也相同。

Linux虚拟地址空间

上图给出了在IA-32+Linux系统中一个进程对应的虚拟地址空间映像。虚拟地址空间分为两大部分:

  1. 内核虚拟存储空间,简称为内核空间(kernel space)。内核空间在0xc0000000以上的高端地址上,用来存放操作系统内核代码和数据等,
    其中内核代码和数据区在每个进程的地址空间中都相同。用户程序没有权限访问内核区。用户空间用来存放进程的代码和数据等,
  2. 用户虚拟存储空间,简称为用户空间(user space)。用户空间用来存放进程的代码和数据等,它又被分为以下几个区域:
    1. 用户栈(user stack)。用来存放程序运行时过程调用的参数、返回地址、过程局部变量等,随着程序的执行,该区会不断动态地从高地址向低地址增长或向反方向减退。
    2. 共享库(shared library)。用来存放公共的共享函数库代码,如hello中的printf()函数等。
    3. 堆(heap)。用于动态申请存储区,例如,C语言中用malloc()函数分配的存储区,或C++中用new操作符分配的存储区。申请一块内存时,动态地从低地址向高地址增长,可用free()函数或delete操作符释放相应的一块内存区。
    4. 可读写数据区。存放进程中的静态全局变量,堆区从该区域的结尾处开始向高地址增长。
    5. 只读数据和代码区。存放进程中的代码和只读数据,如hello进程中的程序代码和字符串“hello,world\n”。

每个区域都有相应的起始位置:

  • 堆区和栈区相向生长,栈区从内核起始位置0xc0000000开始向低地址增长,栈区和堆区合起来称为堆栈。
  • 共享库映射区从0x40000000开始向高地址增长。
  • 只读代码区(代码和只读数据)从0x8048000开始向高地址增长。

为了便于对存储空间的管理和存储保护,在确定存储器映像时,通常将内核空间和用户空间分在两端。在用户空间中又把动态区域和静态区域分在两端,动态区域中把过程调用时的动态局部信息(栈区)和动态分配的存储区(堆区)分在两端,静态区中把可读写数据区和只读代码区分在两端。这样的存储映像,便于每个区域的访问权限设置,因而有利于存储保护和存储管理。

所有进程的虚拟地址空间大小和结构一致,这简化了链接器的设计和实现,也简化了程序的加载过程。

虚拟存储管理机制为程序提供了一个极大的虚拟地址空间(也称为逻辑地址空间),它是主存和硬盘存储器的抽象。虚存机制带来了一个假象,使得每个进程好像都独占使用主存,并且主存空间极大。这有三个好处:

  1. 每个进程具有一致的虚拟地址空间,从而可以简化存储管理;
  2. 它把主存看成是硬盘存储器的一个缓存,在主存中仅保存当前活动的程序段和数据区,并根据需要在硬盘和主存之间进行信息交换,通过这种方式,使有限的主存空间得到了有效利用;
  3. 每个进程的虚拟地址空间是私有的、独立的,因此,可以保护各自进程不被其他进程破坏。

虚拟存储器的实现

对照前面介绍的cache机制(==cache是主存的缓存==),可以把DRAM构成的==主存看成是硬盘存储器的缓存==。因此,要实现虚拟存储器,也必须考虑交换块大小问题、映射问题、替换问题、写一致性问题等。根据对这些问题解决方法的不同,虚拟存储器分成三种不同类型:

  1. 分页式
  2. 分段式和段页式
  3. 分页式虚拟存储器

分页式虚拟存储器

在虚拟存储系统中,生成可执行文件时,会通过可执行文件中的程序头表,将可执行文件中具有相同访问属性的代码和数据段映射到虚拟地址空间中。

如下图所示,每个用户程序都有各自独立的虚拟地址空间,用户程序以可执行文件方式存在磁盘上。假定某一时刻用户程序1、用户程序2和用户程序k都已经被加载到系统中运行,那么,在这一时刻主存中就会同时有这些用户程序中的代码和相应的数据。CPU在执行某个用户程序时,只知道该程序中指令和数据在虚拟地址空间中的地址,怎么知道到哪个主存单元去取指令或访问数据呢?可执行文件中的指令代码和数据都在磁盘中,如何建立磁盘物理空间中的指令代码及数据信息与主存物理空间之间的关联呢?

分页式虚拟存储管理

在分页式虚拟存储系统中,虚拟地址空间被划分成大小相等的页面,硬盘和主存之间按==页面(page)==为单位交换信息。虚拟地址空间中的页称为虚拟页、逻辑页或虚页,简称为VP(Virtual Page);主存空间也被划分成同样大小的页框(页帧),有时把页框也称为物理页或实页,简称为PF(Page Frame)或PP(Physical Page)。

虚拟存储管理采用==“请求分页”==思想,每次访问指令或数据仅将当前需要的页面从硬盘调入主存某页框中,而进程中其他不活跃的页面保留在硬盘上。

  • 当访问某个信息所在页不在主存时发生缺页异常,此时,从硬盘将缺失页面装入主存。
  • 虚拟地址空间中有一些“空洞”的没有内容的页面。例如,堆区和栈区都是动态生长的,因而在栈和共享库映射区之间、堆和共享库映射区之间都可能没有内容存在。这些没有和任何内容相关联的页称为“未分配页”;
  • 对于代码和数据等有内容的区域所关联的页面,称为“已分配页”。已分配页中又有两类:
    • 已调入主存而被缓存在DRAM中的页面称为“缓存页”;
    • 未调入主存而存在硬盘上的页称为“未缓存页”。
  • 因此,任何时刻一个进程中的所有页面都被划分成三个不相交的页面集合:未分配页集合、缓存页集合和未缓存页集合。

在主存和cache之间的交换单位为主存块,在硬盘和主存之间的交换单位为页面。与主存块大小相比,页面大小要大得多。因为DRAM比SRAM大约慢10~100倍,而磁盘比DRAM大约慢100000多倍,所以进行缺页处理所花的代价要比cache缺失损失大得多。而且,根据磁盘的特性,磁盘扇区定位所用的时间要比磁盘读写一个数据的时间长大约100000倍,也即对扇区第一个数据的读写比随后数据的读写要慢100000倍。考虑到缺页代价的巨大和磁盘访问第一个数据的开销,通常将主存和磁盘之间交换的页的大小设定得比较大,典型的有4KB和8KB等,而且有越来越大的趋势。

因为缺页处理代价较大,所以提高命中率是关键,因此:

  • 在主存页框和虚拟页之间采用全相联映射方式。
  • 此外,当进行写操作时,由于磁盘访问速度很慢,所以,不能每次写操作都同时写DRAM和磁盘,因而,在处理一致性问题时,采用回写(write back)方式,而不用全写(write through)方式。

在虚拟存储机制中采用全相联映射,每个虚拟页可以存放到主存任何一个空闲页框中。因此,与cache一样,必须要有一种方法来建立各个虚拟页与所存放的主存页框号或磁盘上存储位置之间的关系,通常用页表(page table)来描述这种对应关系。

==页表==

进程中的每个虚拟页在页表中都有一个对应的表项,称为页表项。页表项内容包括该虚拟页的存放位置、装入位(valid)、修改位(dirty)、使用位、访问权限位和禁止缓存位等。

页表项中的存放位置字段用来建立虚拟页和物理页框之间的映射,用于进行虚拟地址到物理地址的转换。

  1. ==装入位==也称为有效位或存在位,用来表示对应页面是否在主存:
    1. 若为“1”,表示该虚拟页已从外存调入主存,是一个“缓存页”,此时,存放位置字段指向主存物理页号(即页框号或实页号);
    2. 若为“0”,则表示没有被调入主存,此时,若存放位置字段为null,则说明是一个“未分配页”,否则是一个“未缓存页”,其存放位置字段给出该虚拟页在磁盘上的起始地址。
  2. 修改位(也称脏位)用来说明页面是否被修改过,虚存机制中采用回写策略,利用修改位可判断替换时是否需写回磁盘。
  3. 使用位用来说明页面的使用情况,配合替换策略来设置,因此也称替换控制位,例如,是否最先调人(FFO位),是否最近最少用(LRU位)等。
  4. 访问权限位用来说明页面是可读可写、只读还是只可执行等,用于存储保护。
  5. 禁止缓存位用来说明页面是否可以装人cache,通过正确设置该位,可以保证磁盘、主存和cache数据的一致性。

下图给出了一个页表的示例,其中有4个缓存页:VP1、VP2、VP5和VP7;有两个未分配页:VP0和VP4;有两个未缓存页:VP3和VP6。

主存中的页表示例

对于上图所示的页表,假如CPU执行一条指令要求访问某个数据,

  • 若该数据正好在虚拟页VP1中,则根据页表得知,VP1对应的装入位为1,该页的信息存放在物理页PP0中,因此,可通过地址转换部件将虚拟地址转换为物理地址,然后到PP0中访问该数据;
  • 若该数据在VP6中,则根据页表得知,VP6对应的装入位为0,表示页面缺失,发生缺页异常,需要调出操作系统的缺页异常处理程序进行处理。缺页异常处理程序根据页表中VP6对应表项的存放位置字段,从磁盘中将所缺失的页面读出,然后找一个空闲的物理页框存放该页信息。若主存中没有空闲的页框,则还要选择一个页面淘汰出来替换到磁盘上。因为采用回写策略,所以页面淘汰时,需根据修改位确定是否要写回磁盘。缺页处理过程中需要对页表进行相应的更新,缺页异常处理结束后,程序回到原来发生缺页的指令继续执行。

对于上图所示的页表,虚拟页VP0和VP4是未分配页,但随着进程的动态执行,可能会使这些未分配页中有了具体的数据。例如,调用malloc函数会使堆区增长,若新增的堆区正好与VP4对应,则操作系统内核就在磁盘上分配一个存储空间给VP4,用于存放新增堆区中的内容,同时,对应VP4的页表项中的存放位置字段被填上该磁盘空间的起始地址,VP4从未分配页转变为未缓存页。

系统中每个进程都有一个页表,如分页式虚拟存储示例图所示,页表1为用户程序1对应进程的页表,页表k为用户程序k对应进程的页表。操作系统在加载程序时,根据可执行文件中的程序头表,确定每个可分配段(如只读代码段、可读写数据段)所在的虚页号及其磁盘存放位置,在主存生成一个初始页表,初始页表中对应的装入位都是0。在程序执行过程中,通过缺页异常处理程序,将磁盘上的代码或数据页面装入所分配的主存页框中,并修改页表中相应页表项,例如,将存放位置改为主存页框号,将装入位置1。

页表属于进程控制信息,位于虚拟地址空间的内核空间,页表在主存的首地址记录在页表基址寄存器中。页表的项数由虚拟地址空间大小决定。前面提到,虚拟地址空间是一个用户编程不受其限制的足够大的地址空间。因此,页表项数会很多,因而会带来页表过大的问题。例如,在Intel x86系统中,虚拟地址为32位,页面大小为4KB,因此,一个进程有$2^{32}/2^{12}=2^{20}$个页面,也即每个进程的页表可达$2^{20}$个页表项。若每个页表项占32位,则一个页表的大小为4MB。显然,这么大的页表全部放在主存中是不适合的。解决页表过大的方法有很多,可以采用限制大小的一级页表或者两级页表、多级页表方式,也可以采用哈希方式的倒置页表等方案。如何实现主要是操作系统考虑的问题,在此不多赘述。

==快表==

从上述地址转换过程可看出,访存时首先要到主存查页表,然后才能根据转换得到的物理地址再访问主存。如果缺页,则还要进行页面替换、页表修改等,访问主存的次数就更多。因此,采用虚拟存储机制后,使得访存次数增加了。为了减少访存次数,往往把页表中最活跃的几个页表项复制到高速缓存中,这种在高速缓存中的页表项组成的页表称为==后备转换缓冲器(Translation Lookaside Buffer,简称TLB)==,通常称为快表,相应地称主存中的页
表为慢表。

这样,在地址转换时,首先到快表中查页表项,如果命中,则无须访问主存中的页表。因此,快表是减少访存时间开销的有效方法。

快表比页表小得多,为提高命中率,快表通常具有较高的关联度,大多采用全相联或组相联方式。每个表项的内容由页表项内容加上一个TLB标记字段组成,TLB标记字段用来表示该表项取自页表中哪个虚拟页对应的页表项。因此,TLB标记字段的内容在全相联方式下就是该页表项对应的虚拟页号:组相联方式下则是对应虚拟页号的高位部分,而虚拟页号的低位部分作为TLB组索引用于选择TLB组。

下图是一个具有TLB和cache的多级层次化存储系统示意图,图中TLB和cache都采用组相联映射方式。

TLB和cache的访问过程

在上图中,CPU给出的是一个32位的虚拟地址,首先,由CPU中的MMU进行虚拟地址到物理地址的转换;然后,由处理cache的硬件根据物理地址进行存储访问。

MMU对TLB查表时,20位的虚拟页号被分成标记(Tag)和组索引两部分,首先由组索引确定在TLB的哪一组进行查找。查找时将虚拟页号的标记部分与TLB中该组每个标记字段同时进行比较,若有某个相等且对应有效位V为1,则TLB命中,此时,可直接通过TLB进行地址转换;否则TLB缺失,此时,需要访问主存去查慢表。图中所示的是两级页表方式,虚拟页号被分成页目录索引和页表索引两部分,根据这两部分可得到对应的页表项,从而进行地址转换,并将对应页表项的内容送入TLB形成一个新的TLB表项,同时,将虚拟页号的高位部分作为TLB标记填入新的TLB表项中。若TLB已满,还要进行TLB替换,为降低替换算法开销,TLB常采用随机替换策略。

在MMU完成地址转换后,cache硬件根据映射方式将转换得到的主存物理地址划分成多个字段,然后,根据cache索引,找到对应的cache行或cache组,将对应各cache行中的标记与物理地址中的高位地址进行比较,若相等且对应有效位为1,则cache命中,此时,根据块内地址取出对应的字,需要的话,再根据字节偏移量从字中取出相应字节送CPU。

目前TLB的一些典型指标为:TLB大小为16 ~ 512项,块大小为1 ~ 2项(每个表项4 ~ 8B),命中时间为0.5 ~ 1个时钟周期,缺失损失为10 ~ 100个时钟周期,命中率为90%~99%。

==CPU访存过程==

在一个具有cache和虚拟存储器的系统中,CPU的一次访存操作可能涉及TLB、页表、cache、主存和磁盘的访问,其访问过程如下图所示。

CPU访存过程

从上图可以看出,CPU访存过程中存在以下三种缺失情况。

  1. TLB缺失(TLB miss):要访问的虚拟页对应的页表项不在TLB中。
  2. cache缺失(cache miss):要访问的主存块不在cache中。
  3. 缺页(page miss):要访问的虚拟页不在主存中。

下表给出了三种缺失的几种组合情况。

序号 TLB page cache 说明
1 hit hit hit 可能,TLB命中则页一定命中,信息在主存,就可能在cache中
2 hit hit miss 可能,TLB命中则页一定命中,信息在主存,但可能不在cache中
3 miss hit hit 可能,TLB缺失但页可能命中,信息在主存,就可能在cache中
4 miss hit miss 可能,TLB缺失但页可能命中,信息在主存,但可能不在cache中
5 miss miss miss 可能,TLB缺失,则页也可能缺失,信息不在主存,一定也不在cache
6 hit miss miss 不可能,页缺失,说明信息不在主存,TLB中一定没有该页表项
7 hit miss hit 不可能,页缺失,说明信息不在主存,TLB中一定没有该页表项
8 miss miss hit 不可能,页缺失,说明信息不在主存,cache中一定也没有该信息

很显然,最好的情况是第1种组合,此时,无须访问主存;第2、3两种组合都需要访问一次主存;第4种组合要访问两次主存;第5种组合会发生“缺页”异常,需访问磁盘,并至少访问主存两次。

cache缺失处理由硬件完成;缺页处理由软件完成,操作系统通过缺页异常处理程序来实现;而对于TLB缺失,则既可以用硬件也可以用软件来处理。用软件方式处理时,操作系统通过专门的TLB缺失异常处理程序来实现。

对于分页式虚拟存储器,其页面的起点和终点地址固定。因此,实现简单,开销少。但是,由于页面不是逻辑上独立的实体,因此,对于那些不采用对齐方式存储的计算机来说,可能会出现一个数据或一条指令分跨在不同页面等问题,使处理、管理、保护和共享等都不方便。采用下面介绍的段式虚拟存储器就可避免这种情况的发生。

分段式虚拟存储器

根据程序的模块化性质,可按程序的逻辑结构划分成多个相对独立的部分,例如,过程、数据表、数据阵列等。这些相对独立的部分被称为段,它们作为独立的逻辑单位可以被其他程序段调用,形成段间连接,从而产生规模较大的程序。段通常有段名、段起点、段长等。段名可用用户名、数据结构名或段号标识,以便于程序的编写、编译器的优化和操作系统的调度管理等。

可以把段作为基本信息单位在主存一辅存之间传送和定位。分段方式下,将主存空间按实际程序中的段来划分,每个段在主存中的位置记录在段表中,段的长度可变,所以段表中需有长度指示,即段长。每个进程有一个段表,每个段在段表中有一个段表项,用来指明对应段在主存中的位置、段长、访问权限、使用和装入情况等。段表本身也是一个可再定位段,可以存在外存中,需要时调人主存,但一般驻留在主存中。

在分段式虚拟存储器中,虚拟地址由段号和段内地址组成。通过段表把虚拟地址转换成主存物理地址,其转换过程如下图所示。

分段式虚存的地址转换

每个进程的段表在内存的首地址存放在段表基址寄存器中,根据虚拟地址中的段号,可找到对应段表项,以检查是否存在以下三种异常情况。

  1. 缺段(段不存在):装入位=0。
  2. 地址越界:偏移量超出最大段长。
  3. 访问越权:操作方式与指定访问权限不符。

若发生以上三种情况,则调用相应的异常处理程序,否则,将段表项中的段首址与虚拟地址中的段内地址相加,生成访问主存时的物理地址。

因为段本身是程序的逻辑结构所决定的一些独立部分,因而分段对程序员(实际上是编译器)来说是不透明的;而分页方式则对编译器透明,即编译器不需知道程序如何分页。分段式管理系统的优点是段的分界与程序的自然分界相对应;段的逻辑独立性使它易于编译、管理、修改和保护,也便于多道程序共享;某些类型的段(如堆、栈、队列等)具有动态可变长度,允许自由调度以便有效利用主存空间。但是,由于段的长度各不相同,段的起点和终点不定,给主存空间分配带来麻烦,而且容易在主存中留下许多空白的零碎空间,造成浪费。

分段式和分页式存储管理各有优缺点,因此可采用两者相结合的段页式存储管理方式。

段页式虚拟存储器

在段页式虚拟存储器中,程序按模块分段,段内再分页,用段表和页表(每段一个页表)进行两级定位管理。段表中每个表项对应一个段,每个段表项中包含一个指向该段页表起始位置的指针,以及该段其他的控制和存储保护信息,由页表指明该段各页在主存中的位置以及是否装人、修改等状态信息。

程序的调入调出按页进行,但它又可以按段实现共享和保护。因此,它兼有分页式和分段式存储管理的优点。它的缺点是在地址映象过程中需要多次查表。

存储保护

为避免主存中多道程序相互干扰,防止某进程出错而破坏其他进程的正确性,或某进程不合法地访问其他进程的代码或数据区,应该对每个进程进行存储保护。

为了对操作系统的存储保护提供支持,硬件必须具有以下三种基本功能。

  1. 使部分CPU状态只能由操作系统内核程序写,而用户进程只能读不能写。
    例如,对于页表首地址、TLB内容等,只有操作系统内核程序才能用特殊指令(一般称为管态指令或特权指令)来写。常用的特权指令有刷新cache、刷新TLB、改变特权模式、停止处理器执行等。

  2. 支持至少两种特权模式。
    操作系统内核程序比用户程序具有更多的特权,例如,内核程序可以执行用户程序不能执行的特权指令,内核程序可以访问用户程序不能访问的存储空间等,为了区分这种特权,需要为内核程序和用户程序设置不同的特权级别或运行模式。

    执行内核程序时处理器所处的模式称为管理模式(supervisor mode)、内核模式(kernel mode)、超级用户模式或管理程序状态,简称管态、管理态、内核态或者核心态;执行用户程序时处理器所处的模式称为用户模式(user mode)、用户状态或目标程序状态,简称为目态或用户态。

  3. 提供让CPU在内核态和用户态之间相互转换的机制。
    如果用户进程需要访问内核代码和数据,那么必须通过系统调用接口(执行陷阱指令)来间接访问。响应异常和中断可使CPU从用户态转到内核态。异常和中断处理后的返回指令(return from exception)可使CPU从内核态转到用户态。

硬件通过提供相应的专用寄存器、专门的指令、专门的状态/控制位等,与操作系统一起实现上述三个功能。通过这些功能,并把页表保存在操作系统的地址空间中,操作系统就可更新页表,并防止用户进程改变页表,以确保用户进程只能访问由OS分配给它的存储空间。

存储保护包括以下两种情况:访问权限保护和存储区域保护。

访问权限保护

访问权限保护就是看是否发生了访问越权。若实际访问操作与访问权限不符,则发生存储保护错误。通常通过在页表或段表中设置访问权限位来实现这种保护。一般规定:

  1. 各程序对本程序所在的存储区可读可写;
  2. 对共享区或已获授权的其他用户信息可读不可写;
  3. 而对未获授权的信息(如OS内核、页表等)不可访问。
  4. 通常,数据段可指定为可读可写或只读;
  5. 程序段可指定只可执行或只读。

存储区域保护

存储区域保护就是看是否发生了地址越界,也即是否访问了不该访问的区域。通常有以下几种常用的存储区域保护方式。

  1. 加界重定位。每个程序或程序段都记录有起始地址和终止地址,分别称为上界和下界。对虚拟地址加界(即加基准地址)生成物理地址后,如果物理地址超过了上界和下界规定的范围,则地址越界。有些系统用专门的一对上界寄存器和下界寄存器来记录上界和下界,在分段式虚存中,通过段表来记录段的上界和下界。
  2. 键保护。操作系统为主存的每一个页框分配一个存储键,为每个用户进程设置一个程序键。进程运行时,将程序状态字寄存器中的键(程序键)和所访问页的键(存储键)进行核对,相符时才可访问,这两个键如同“锁”与“钥匙”的关系。为使某个页框能被所有进程访问,或某个进程可访问任何一个页框,可规定键标志为0,此时不进行核对工作。例如,操作系统有权访问所有页框中的页面,因此,可让内核进程的程序键为0。
  3. 环保护。主存中各进程按其重要性分为多个保护级,各级别构成同心环,最内环的进程保护级别最高,向外逐次降低。内环进程可以访问外环和同环进程的地址空间,而外环不得访问内环进程的地址空间。内核程序的保护级别最高,环号最小,而用户程序都处于外环上。IA-32就采用该方案,操作系统内核工作在第0环(内核态),操作系统其他部分工作在第1环,用户进程工作在第3环(用户态),留下第2环给中间软件使用。实际上,Linux等操作系统只用了第0环和第3环。

异常控制流

一个程序的正常执行流程有两种顺序:

  1. 一种是按指令存放的顺序执行,即新的PC值为当前指令地址加当前指令长度;
  2. 一种是跳转到由转移类指令指出的转移目标地址处执行,即新的PC值为转移目标地址。

CPU所执行的指令的地址序列称为CPU的控制流,通过上述两种方式得到的控制流为==正常控制流==。

在程序正常执行过程中,CPU会因为遇到内部异常事件或外部中断事件而打断原来程序的执行,转去执行操作系统提供的针对这些特殊事件的处理程序。这种由于某些特殊情况引起用户程序的正常执行被打断所形成的意外控制流称为==异常控制流==(Exceptional Control of Flow,
ECF)。显然,计算机系统必须提供一种机制,使得自身能够实现异常控制流。

在计算机系统的各个层面都有实现异常控制流的机制。例如,在底层的硬件层,CPU中有检测异常和中断事件并将控制转移到操作系统内核执行的机制;在中间的操作系统层,内核能通过进程的上下文切换将一个进程的执行转移到另一个进程执行:在上层的应用软件层,一个进程可以直接发送信号到另一个进程,使得接收到信号的进程将控制转移到它的一个信号处理程序执行。

主要介绍硬件层和操作系统层中涉及的对于内部异常和外部中断的异常控制流实现机制。主要内容包括:进程与进程上下文切换,异常的类型、异常的捕获和处理、中断的捕获和处理,系统调用的实现机制等。

进程与进程的上下文切换

程序和进程的概念

任何一个应用问题描述为处理算法后,都要用某种编程语言表示出来。绝大多数情况下,都采用高级语言编写源程序,而高级语言源程序需要进行编译转换为目标程序,在链接之前的目标程序是可重定位目标程序形式,链接之后是可执行目标形式,其代码部分是一个机器指令序列,可以被计算机直接执行。对计算机来说,==程序(program)==就是代码和数据的集合,程序的代码是一个机器指令序列,因而程序是一种静态的概念。它可以作为目标模块存放在磁盘中,或者作为一个存储段存在于一个地址空间中。

简单来说,==进程(process)==就是程序的一次运行过程。更确切地说,进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动,因而进程具有动态的含义。计算机处理的所有任务实际上是由进程完成的。

每个应用程序在系统中运行时(用户进程)均有属于自己的存储空间,用来存储自己的程序代码和数据,包括只读区(代码和只读数据)、可读可写数据区(初始化数据和未初始化数据)、动态的堆区和栈区等。

进程是操作系统对处理器中程序运行过程的一种抽象。进程有自己的生命周期,它由于任务的启动而创建,随着任务的完成(或终止)而消亡,它所占用的资源也随着进程的终止而释放。

一个可执行目标文件可以被多次加载执行,也就是说,一个程序可能对应多个不同的进程。例如,在Windows系统中用word程序编辑一个文档时,相应的进程就是winword.exe,如果多次启动同一个word程序,就得到多个winword.exe进程。

[!tip]

计算机系统中的任务通常指进程。例如,Linux内核中把进程称为任务,每个进程主要通过一个称为进程描述符(process descriptor)的结构来描述,其结构类型定义为task structure,包含了一个进程的所有信息。所有进程通过一个双向循环链表实现的任务列表(task list)来描述,任务列表中的每个元素是一个进程描述符。IA-32中的任务状态段(TSS)、任务门(task gate)等概念中所称的任务,实际上也是指进程。

对于现代多任务操作系统,通常一段时间内会有多个不同的进程在系统中运行,这些进程轮流使用处理器并共享同一个主存储器。程序员在编写程序或者语言处理系统在编译并链接生成可执行目标文件时,并不用考虑如何和其他程序一起共享处理器和存储器资源,而只要考虑自己的程序代码和所用数据如何组织在一个独立的虚拟存储空间中。也就是说,程序员和语言处理系统可以把一台计算机的所有资源看成由自己的程序所独占,可以认为自己的程序是在处理器上执行的和在存储空间中存放的唯一的用户程序。显然,这是一种“错觉”。这种“错觉”带来了极大的好处,它简化了程序员的编程以及语言处理系统的处理,即简化了编程、编译、链接、共享和加载等整个过程。

“进程”的引入为应用程序提供了以下两方面的抽象:

  1. 一个独立的逻辑控制流
  2. 一个私有的虚拟地址空间

每个进程拥有一个独立的逻辑控制流,使得程序员以为自己的程序在执行过程中独占使用处理器;每个进程拥有一个私有的虚拟地址空间,使得程序员以为自己的程序在执行过程中独占存储器。

为了实现上述两个方面的抽象,操作系统必须提供一整套的管理机制,包括处理器调度、进程的上下文切换、虚拟存储管理等。

进程的逻辑控制流

一个可执行目标文件被加载并启动执行后,就成为一个进程。不管是静态链接生成的完全链接可执行文件,还是动态链接后在存储器中形成的完全链接可执行目标,它们的代码段中的每条指令都有一个确定的地址,在这些指令的执行过程中,会形成一个指令执行的地址序列,对于确定的输入数据,其指令执行的地址序列也是确定的。这个确定的指令执行地址序列称为进程的逻辑控制流。

对于一个具有单处理器核的系统,如果在一段时间内有多个进程在其上运行,那么,这些进程会轮流使用处理器,也即处理器的物理控制流由多个逻辑控制流组成。例如,假定在某段时间内,单处理器系统中有三个进程P1、P2和P3在运行,其运行轨迹如下图所示。图中水平方向为时间,垂直方向为指令的虚拟地址,不同进程的虚拟地址空间是独立的。

进程P1、P2、P3的逻辑控制流

在上图中,进程$p_1$的执行过程为:从$t_0$到$t_1$时刻按序执行地址$A_{11}$到$A_{13}$处的指令,然后再跳转到$A_{11}$开始按序执行,直到$t_2$时刻执行到$A_{12}$处指令时被换下处理器,一直等到$t_4$时刻,又从上次被中断的$A_{12}$处被换上处理器开始执行,直到$t_6$时刻执行完成。一个进程的逻辑控制流总是确定的,不管中间是否被其他进程打断,也不管被打断几次或在哪里被打断,这样,就可以保证一个进程的执行不管怎么被打断其行为总是一致的。

可以看出,进程$p_1$的逻辑控制流为$A_{11}$ ~ $A_{13}$,$A_{11}$ ~ $A_{14}$、$A_{15}$ ~ $A_{16}$。也即其执行轨迹总是先按序从$A_{11}$执行到$A_{13}$;然后从$A_{13}$跳到$A_{11}$,按序从$A_{11}$执行到$A_{14}$;再从$A_{14}$跳到$A_{15}$,按序从$A_{15}$执行到$A_{16}$。在$p_1$整个逻辑控制流中,控制流在$A_{12}$处被$p_2$打断了一次。

进程$p_{2}$在$t_{2}$时刻被换上执行,在$t_4$时刻被换下处理器,然后在$t_7$时刻再次被换上处理器执行,直到$t_8$时刻执行完成。在$p_2$整个逻辑控制流中,控制流在$A_{24}$处被$p_1$打断了一次。

进程$p_3$则在$t_6$时刻被换上处理器执行,到$t_7$时刻执行完成。在$p_3$整个逻辑控制流中没有被打断。

从上图可以看出,有些进程的逻辑控制流==在时间上有交错==,通常把这种不同进程的逻辑控制流在时间上交错或重叠的情况称为==并发==(concurrency)。例如,进程$p_1$和$p_2$的逻辑控制流在时间上是交错的,因此,进程$p_1$和$p_2$是并发运行的,同样,$p_2$和$p_3$也是并发的,而$p_1$和$p_3$不是并发的。

[!important]

并发执行的概念与处理器核数没有关系,只要两个逻辑控制流在时间上有交错或重叠都称为并发,而==并行==(parallelism)则是并发执行的一个特例,即并行执行的两个进程定是并发的。我们称==两个同时执行的进程的逻辑控制流==是并行的,显然,并行执行的两个进程一定只能同时运行在不同的处理器或处理器核上。

从上图可以看出,三个进程的逻辑控制流在同一个时间轴上串行,也即进程是轮流在一个单处理器上执行的。连续执行同一个进程的时间段称为时间片(time slice)。例如,在上图中,从$t_0$到$t_2$为一个时间片,从$t_2$到$t_4$为一个时间片,从$t_4$到$t_6$为一个时间片。对于某一个进程来说,其逻辑控制流并不会因为中间被其他进程打断而改变,因为被打断后还能回到原被打断的“断点”处继续执行。这种能够从被其他进程打断的地方继续执行的功能是由进程的上下文切换机制实现的。时间片结束时,通过进程的上下文切换,换一个新的进程到处理器上执行,从而开始一个新的时间片,这个过程称为==时间片轮转处理器调度==。

进程的上下文切换

操作系统通过处理器调度让处理器轮流执行多个进程。实现不同进程中指令交替执行的机制称为进程的==上下文切换(context switching)==。

进程的物理实体(代码和数据等)和支持进程运行的环境合称为==进程的上下文==。由用户进程的程序块、数据块、运行时的堆和用户栈(统称为==用户堆栈==)等组成的==用户空间信息==被称为用户级上下文;由进程标识信息、进程现场信息、进程控制信息和系统内核栈等组成的==内核空间信息==被称为==系统级上下文==。进程的上下文包括了用户级上下文和系统级上下文。其中,用户级上下文地址空间和系统级上下文地址空间一起构成了一个进程的整个存储器映像,如下图所示:

进程的上下文

实际上它就是进程的虚拟地址空间。进程控制信息包含各种内核数据结构,例如,记录有关进程信息的进程表(process table)、页表、打开文件列表等。

处理器中各个寄存器的内容被称为寄存器上下文(也称硬件上下文)。上下文切换发生在操作系统调度一个新进程到处理器上运行时,它需要完成以下三件事:

  1. 将当前处理器的寄存器上下文保存到当前进程的系统级上下文的现场信息中;
  2. 将新进程系统级上下文中的现场信息作为新的寄存器上下文恢复到处理器的各个寄存器中;
  3. 将控制转移到新进程执行。这里,一个重要的上下文信息是PC的值,当前进程被打断的断点处的PC作为寄存器上下文的一部分被保存在进程现场信息中,这样,下次该进程再被调度到处理器上执行时,就可以从其现场信息中获得断点处的PC,从而能从断点处开始执行。

下面给出的例子是一种典型的进程上下文切换场景。以下是经典的hello.c程序:

1
2
3
4
5
6
#include <stdio.h>

int main()
{
printf("hello,world\n");
}

对于上述高级语言源程序,首先,需先对其进行预处理并编译成汇编语言程序,然后再用汇编程序将其转换为可重定位的二进制目标程序,再和库函数目标模块printf.o进行链接,生成最终的可执行目标文件hello。

假定在UNIX系统上启动hello程序,其shell命令行和hello程序运行的结果如下:

1
2
3
unix> ./hello [Enter]
hello,world
unix>

上下文切换指把正在运行的进程换下,换一个新进程到处理器执行。下图给出了上述shell命令行执行过程中shell进程和hello进程的上下文切换过程。首先运行shell进程,从shell命令行中读人字符串“./hello”到主存;当shell进程读到字符“[Enter]”后,shell进程将通过系统调用从用户态转到内核态执行,由操作系统内核程序进行上下文切换,以保存shell进程的上下文并创建hello进程的上下文;hello进程执行结束后,再转到操作系统完成将控制权从hello进程交回给shell进程的切换。

从上述过程可以看出,在一个进程的整个生命周期中,可能会有其他不同的进程在处理器中交替运行。例如,对于下图中的hello进程,用户感觉到的时间除hello进程本身的执行时间外,还包括了操作系统执行上下文切换的时间。对于$p_1$进程,用户感觉到的时间除了包括操作系统执行上下文切换的时间外,还包括用户进程$p_2$的一段执行时间。因此,对于每个进程的运行很难凭感觉给出准确时间。

显然,处理器调度等事件会引起用户进程的正常执行被打断,因而形成了突变的异常控制流,而进程的上下文切换机制很好地解决了这类异常控制
流,实现了从一个进程安全切换到另一个进程执行的过程。

进程上下文切换示例

进程的存储器映射

本节以Linux系统为例,对进程的存储器映射进行介绍。进程的存储器映射(memory mapping)是指将进程的虚拟地址空间中的一个区域与硬盘上的一个对象建立关联,以初始化一个vm_area_struct结构。可以使用mmap()函数实现存储器映射。

mmap函数的功能

在类UNIX系统中,可以使用mmap()函数进行存储器映射,创建某进程虚拟地址空间中的一个区域,从而可以据此生成一个vm_area_struct结构。

mmap()函数的用法如下:

1
void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offset);

若该函数的返回值是-1(MAP_FAILED),则表示出错;否则,返回值为指向映射区域的指针。该函数的功能是,将指定文件fd中偏移量offset开始的长度为length字节的一块信息,映射到虚拟地址空间中起始地址为start、长度为length字节的一块区域。

参数prot指定该区域内页面的访问权限位,对应vm_area_struct结构中的vm_prot字段。可能的取值包括以下几种:

  • PROT_EXE:区域内页面由可执行的指令组成。
  • PROT_READ:区域内页面可读。
  • PROT_WRITE:区域内页面可写。
  • PROT_NONE:区域内页面不能被访问。

参数flags指定该区域所映射的对象的类型,对应vm_area_struct结构中的vm_flags字段,可以是以下两种类型中的一种:

  1. 普通文件。最典型的是可执行文件和共享库文件,可将文件中的一个数据或代码节(section)划分成页面大小的片,每一片就是一个虚拟页在内存页框中的初始内容。

    1. 通常,映射到只读代码区域(.init、.text、.rodata)和已初始化数据区域(.data)的对象在可执行文件中,这些对象都属于私有对象,采用称为写时拷贝(copy-on-write)的技术映射到虚拟地址空间,所映射的区域称为私有区域,对应对象称为私有的写时拷贝对象,此时参数flags设置为MAP_PRIVATE;
    2. 映射到共享库区域的对象在共享库文件中,这些对象都属于共享对象,所映射的对应区域称为共享区域,此时,flags设置为MAP_SHARED。
    3. CPU第一次访问对应虚拟页面时,内核将在主存中找到一个空闲页框(没有空闲页框时,选择淘汰一个已存在页面),然后从硬盘上的文件中装入所映射的对象信息,如果文件中的对象不是正好为页面大小的整数倍,内核将用零来填充余下的部分。
  2. 匿名文件。由内核创建,全部由0组成,对应区域中的每个虚拟页面称为请求零的页(demand-zero page)。CPU第一次访问对应区域中的虚拟页面时,内核会在主存找到一个空闲页框(没有空闲页框时,选择淘汰一个已存在页面),用零覆盖所有内容并更新页表,将这个页面标记为驻留主存页面。显然,这种情况下,并没有在硬盘和主存之间进行实际的数据传送。若参数flags设置MAP_ANON位,则说明被映射的对象为一个匿名文件,相应的虚拟页为请求零的页。通常,未初始化数据区(.bss)、运行时堆和用户栈等区域中都为私有的、请求零的页,此时,flags设置为MAP_PRIVATE I MAP_ANON。

在一个虚拟页第一次被装入内存页框后,不管是由普通文件还是匿名文件对其进行了初始化,以后都是在主存页框和硬盘中的交换文件(swap file)之间进行调进调出。交换文件由内核进行管理和维护,也被称为交换分区(swap area)或交换空间(swap space)。因为主存页框被系统中所有进程共享,所以,当系统中存在许多进程时,主存中很可能不存在空闲页框。此时,若一个进程需装入新的页面,则内核会根据相应的替换策略,选择淘汰某进程的一个页面。若被淘汰页面被修改过(dirty位=1),则将其从所在的主存页框写到交换文件中;若以后再次访问该淘汰页面,则再从交换文件调入内存。

共享对象和私有的写时拷贝对象

共享库的动态链接具有“共享性”特点,虽然很多进程都调用共享库中的代码,但是共享库代码段在内存和硬盘中都只有一个副本。如何实现一个共
享库副本由多个进程共享呢?这个功能实际上是通过存储器映射机制来实现的。共享库文件中的共享对象可以映射到不同进程的用户空间区域中

假设进程1先将一个共享对象映射到了自己的VM用户空间区域中,在进程1运行过程中,内核为这个共享对象在主存分配了若干个页框。这些页框在主存不一定连续,为简化示意图,图中所示页框是连续的。

共享对象在进程1的VM空间饿映射

假定后来进程2也将这个共享对象映射到了自己的VM用户空间区域中。显然,这个共享对象映射到两个进程的VM区域起始地址可能不同。

同一个共享对象在进程2的VM空间的映射

因为共享对象在硬盘上只有一个副本,也即对应的共享库文件名是唯一的,所以内核可以判断出进程1已经在主存给共享对象分配了页框,因而在进程2的加载运行过程中,内核只要将进程2对应区域内页表项中的页框号直接填上即可。在多个进程共享同一个共享对象时,在主存中仅保存一个副本,每个进程在访问各自的共享区域时,实际上都在同一个对应页框中存取信息。因此,一个进程对共享区域进行的写操作结果,对于所有共享同一个共享对象的进程都是可见的,而且结果也会反映在硬盘上对应的共享对象中。

前面介绍进程概念时提到,一个可执行文件可被多次加载执行以形成不同的进程,因而系统中多个进程可能有同样的只读代码区域和可读可写数据区域,也即不同进程的区域可能会映射到同一个对象。与共享库文件中的共享对象不同,可执行文件中的对象是私有的,映射到的是进程的私有区域。因此,在这种私有区域中的写操作结果,对于其他进程是不可见的,也不会反映在对应的硬盘对象中。要实现这种功能,内核可以为不同进程中对应区域的虚拟页在主存中分配各自独立的页框。但是,这样会浪费很多主存空间。

有没有一种技术既能节省主存空间,又能实现不同进程私有区域的独立性呢?这种技术就是==私有对象的写时拷贝技术==,以下通过一个例子来说明该技术的基本思想。

假设可执行文件a.out对应的两个进程在系统中并发执行,先启动的进程1会将a.out中的私有对象映射到自己VM用户空间区域中,内核将这些区域中的页面标记为私有的写时拷贝页,并将对应页表项中的访问权限标记为只读。在进程1运行过程中,内核为这个私有对象在主存分配了若干个页框。同样,后启动的进程2也会将a.out中的私有对象映射到自己的VM用户空间区域中,标记对应页为私有的写时拷贝页和只读访问权限,并使页表项中的页框号与进程1中对应的页框号相同。若两个进程对某区域没有进行写操作,例如,只读代码区域就不会发生写操作,那么,该区域中的虚拟页在主存就只有一个副本,可以节省主存空间。

私有对象在两个进程的VM空间的映射

若进程2对私有的写时拷贝页面(例如,可读可写数据区域所在页面)发生了写操作,那么就与只读访问权限不相符,发生保护异常,内核就会进行页故障处理。在处理过程中,内核判断出保护异常是由于进程试图对私有的写时拷贝页面进行写操作造成的,此时,内核就会在主存中为这个页面分配一个新页框,把页面内容拷贝到新页框中,并修改进程2中相应的页表项,填如新分配的页框号,将访问权限修改成可读可写。

页故障处理结束后,回到发生故障的指令重新执行,此时,进程2就可以正常执行写操作了。写时拷贝技术通过延迟拷贝私有对象中写操作所在的页面,使得主存物理空间得到了最充分的使用。

进程2在私有对象映射空间中执行写操作

程序的加载和运行

当启动一个可执行目标文件执行时,首先会通过某种方式调出常驻内存的一个称为加载器(loader)的操作系统程序来进行处理。在UNIX/Linux系统中,可以通过调用execve()函数来启动加载器。

execve()函数的功能是在当前进程的上下文中加载并运行一个新程序。execve()函数的用法如下:

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

该函数用来加载并运行可执行目标文件filename,可带参数列表argv和环境变量列表envp。若出现错误,如找不到指定的文件filename,则返回-1并将控制权返回给调用程序;若函数功能执行成功,则不返回,而是将PC(EIP)设定指向在可执行文件ELF头中定义的入口点Entry Point(即符号_start处)。符号_start在启动例程crtl.o中定义,每个C程序都一样。

符号_start处定义的启动代码主要是一系列过程调用。

  1. 首先,依次调用__libc_init_first和_init两个初始化过程;
  2. 随后通过调用atexit()过程对程序正常结束时需要调用的函数进行登记注册,这些函数被称为终止处理函数,将由exit()函数自动调用执行;
  3. 然后,再调用可执行目标中的主函数main();
  4. 最后调用_exit()过程,以结束进程的执行,返回到操作系统内核。

因此,启动代码的过程调用顺序为:__libc_init_first --> _init --> atexit() --> main()(其中可能会调用exit()函数) --> _exit()。

通常,主函数main()的原型形式如下:

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

或者是如下的等价形式:

1
int main(int argc, char *argv[], char *envp[]);

其中,参数列表argv可用一个以null结尾的指针数组表示,每个数组元素都指向一个用字符串表示的参数。通常,argv[0]指向可执行目标文件名,argv[1]是命令(以可执行目标文件名作为命令的名字)第一个参数的指针,argv[2]是命令第二个参数的指针,以此类推。参数个数由argc指定。参数列表结构如下图所示。图中显示了命令行ld -o test main.o test.o对应的参数列表结构。

参数列表argv的组织结构

环境变量列表envp的结构与参数列表结构类似。也用一个以null结尾的指针数组表示,每个数组元素都指向一个用字符串表示的环境变量串。其中每个字符串都是一个形如NAME=VALUE的名-值对。

当IA-32+Linux系统开始执行main()函数时,在虚拟地址空间的用户栈中具有如下图所示的组织结构。

运行一个新程序的main函数时用户栈中的典型结构

如上图所示,用户栈的栈底是一系列环境变量串,然后是命令行参数串,每个串以null结尾,连续存放在栈中,每个串i由相应的envp[i]和argv[i]中的指针指示。在命令行参数串后面是指针数组envp的数组元素,全局变量environ指向这些指针中的第一个指针envp[0]。然后是指针数组argv的数组元素。在栈的顶部是main()函数的三个参数:envp、argv和argc。在这三个参数所在单元的后面将生成main()函数的栈帧。

对于可执行文件a.out的加载执行,大致过程如下:

  1. shell命令行解释器输出一个命令行提示符(如:unix>),并开始接受用户输入的命令行。
  2. 当用户在命令行提示符后输入命令行“./a.out[Enter]”后,开始对命令行进行解析,获得各个命令行参数并构造传递给函数execve()的参数列表argv,将参数个数送argc。
  3. 调用函数fork()。fork()函数的功能是,创建一个子进程并使新创建的子进程获得与父进程完全相同的虚拟空间映射和页表,也即子进程完全复制父进程的mm_struct、vm_area_struct数据结构和页表,并将父进程和子进程中每一个私有页的访问权限都设置成只读,将两个进程vm_area_struct中描述的私有区域中的页面说明为私有的写时拷贝页。这样,如果其中某一页发生写操作,则内核将使用写时拷贝机制在主存中分配一个新页框,并将页面内容拷贝到新页框中。
  4. 以第②步命令行解析得到的参数个数argc、参数列表argv以及全局变量environ作为参数,调用函数execve(),从而实现在当前进程(新创建的子进程)的上下文中加载并运行a.out程序。在函数execve()中,通过启动加载器执行加载任务并启动程序运行。具体的过程包括:
    1. 删除已有的VM用户空间中的区域结构vm_area_struct及其页表;
    2. 根据可执行文件a.out的程序头表创建新进程VM用户空间中各个私有区域和共享区域,生成相应的vm_area_struct链表,并为每个区域页面生成相应的页表项。其中,私有区域包括只读代码、已初始化数据、未初始化数据(.data)、栈和堆。

如下图所示,a.out进程用户空间中有4个区域(私有的只读代码区和已初始化数据区、共享的代码区和数据区)被映射到普通文件中的对象:只读代码区域(.text)和已初始化数据区域(.data)与可执行文件a.out中私有的写时拷贝对象进行映射;共享库的数据区域和代码区域与共享库文件中的共享对象(如libc.so中.data节和.text节等)分别进行映射。除上述区域外,未初始化数据(.bss)、栈和堆这三个区域都是私有的、请求零的页面,映射到匿名文件。未初始化数据区域长度由a.out中的信息提供,栈和堆对应区域的初始长度都为零。

进程用户空间各区域页面类型

这里的“加载”实际上并没有将a.out文件中的代码和数据(除ELF头、程序头表等信息)从硬盘读入主存,而是根据可执行文件中的程序头表,对当前进程上下文中关于存储器映射的一些数据结构进行了初始化,包括页表以及各个vm_area_struct等信息,也即进行了存储器映射工作。当加载器执行完加载任务后,便将PC设定指向入口点Entry Point(即符号_start处),从而开始转到a.out程序执行,从此,a.out程序开始在新进程的上下文中运行。在运行过程中,一旦CPU检测到所访问的指令或数据不在主存(即缺页),则调用操作系统内核中的缺页处理程序执行。在处理过程中才将代码或数据真正从a.out文件装入主存。

异常和中断

一个进程在正常执行过程中,其逻辑控制流会因为各种特殊事件被打断,例如,操作系统进行的时间片轮转处理器调度,在每个时间片到时,当前进程的执行被新进程打断。除此之外,打断进程正常执行的特殊事件还有:

  1. 用户按下Ctrl+C键
  2. 当前指令执行时发生了不能使指令继续执行的意外事件
  3. I/O设备完成了系统交给的任务需要系统进一步处理等。
  4. 这些特殊事件统称为异常(exception)或中断(interrupt)。

当发生异常或中断时,正在执行进程的逻辑控制流被打断,CPU转到具体的处理特殊事件的内核程序去执行。显然,这与上一节介绍的上下文切换一样,都会引起一个异常控制流。

基本概念

在早期的Intel 8086/8088微处理器中,并不区分异常和中断,两者统称为中断,由CPU内部产生的意外事件称为==“内中断”==,从CPU外部通过中断请求引脚INTR和NMI向CPU发出的中断请求为==“外中断”==。

从80286开始,Intel统一把“内中断”称为异常,而把“外中断”称为中断。在IA-32架构说明文档中,Intel对异常和中断进行了如下描述:处理器提供了异常和中断这两种打断程序正常执行的机制。

  • 中断是一种典型的由I/O设备触发的、与当前正在执行的指令无关的异步事件;
  • 而异常是处理器执行一条指令时,由处理器在其内部检测到的、与正在执行的指令相关的同步事件。

有时为了强调异常是CPU内部执行指令时发生,而中断是CPU外部的I/O设备向CPU发出的请求,特称异常为“内部异常”,而称中断为“外部中断”。中断是外设的一种输入输出方式,实际上,异常和中断两者的处理过程基本上是相同的。异常和中断引起的异常控制流如下图所示。图中反映了从CPU检测到用户进程发生异常或中断事件,到CPU改变指令执行控制流而转到操作系统中的异常或中断处理程序执行,再到从异常或中断处理程序返回用户进程执行的过程。

中断或异常事件

异常和中断处理的大致过程如下:当CPU在执行当前程序或任务(即用户进程)的第i条指令时检测到一个异常事件,或在执行第i条指令后发现有一个中断请求信号,则CPU会打断当前用户进程,然后转到相应的异常或中断处理程序去执行。

  • 若异常或中断处理程序能够解决相应问题,则在异常或中断处理程序的最后,CPU通过执行“异常/中断返回指令”回到被打断的用户进程的第i条指令或第i+1条指令继续执行;
  • 若异常或中断处理程序发现是不可恢复的致命错误,则终止用户进程。

通常情况下,对于异常和中断事件的具体处理过程全部由操作系统(可能包括驱动程序)软件来完成。通常,把处理异常事件的程序称为异常处理程序,把处理中断事件的程序称为中断服务程序,合在一起时称其为异常或中断处理程序。

异常的分类

Intel将内部异常分为三类:故障(fault)、陷阱(trap)和终止(abort)。

故障

故障是引起故障的指令在执行过程中CPU检测到的一类与指令执行相关的意外事件。这种意外事件有些可以恢复,有些则不能恢复。例如,指令译码时出现“非法操作码”;取指令或数据时发生“页故障(page fault)”;执行除法指令时发现“除数为0”等。

  • 对于像溢出和非法操作码等这类故障,因为无法通过异常处理程序恢复,所以不能回到被中断的程序继续执行,通常异常处理程序在屏幕上显示一个对话框告知发生了某种故障,然后调用内核中的abort例程,以终止发生故障的当前进程。
  • 对于除数为0的情况,根据是定点除法指令还是浮点除法指令有不同的处理方式。对于浮点数除0,异常处理程序可以选择将指令执行结果用特殊的值(如$\infty$或$NaN$)表示,然后返回到用户进程继续执行除法指令后面的一条指令;而对于整数除0,则会发生“整除0”故障,通常调用abort例程来终止当前用户进程。
  • 对于页故障,对应的页故障处理程序会根据不同的情况进行不同的处理。CPU在执行指令过程中需要访问存储器时,首先由MMU进行地址转换,在查页表进行地址转换时,判断相应页表项中的有效位是否为1,并且确定是否地址越界或访问越权如果检测到有效位不为1或者地址越界或者访问越权,都会产生“page fault’”异常,从而调出内核中相应的异常处理程序执行。因此,CPU产生的“page fault’”异常中包含了多种不同情况,需要页故障处理程序根据具体情况进行不同处理:首先检测是否发生地址越界或访问越权,如果是的话,则故障不可恢复;否则是真正的缺页故障,此时,可以通过从硬盘读入页面来恢复故障。Linux中,不可恢复的访存故障(地址越界和访问越权)都称为“段故障(segmentation fault)”。

陷阱

陷阱也称为自陷或陷入,与“故障”等其他异常事件不同,是预先安排的一种“异常”事件,就像预先设定的“陷阱”一样。当执行到陷阱指令(也称为自陷指令)时,CPU就调出特定的程序进行相应的处理,处理结束后返回到陷阱指令的下一条指令执行。其处理过程如下图所示。

陷阱指令执行时的处理过程

陷阱的重要作用之一是在用户程序和内核之间提供一个像过程一样的接口,这个接口称为系统调用,用户程序利用这个接口可以方便地使用操作系统内核提供的一些服务。操作系统给每个服务编一个号,称为系统调用号,每个服务功能通过一个对应的系统调用服务例程提供。

例如,在Linux系统中就提供了创建子进程(fork)、读文件(read)、加载并运行新程序·(execve)、存储器映射(mmap)等服务功能,系统调用forkreadexecvemmap的调用号,分别是1、3、11和90。

为了使用户程序在需要调用内核服务功能的时候,能够从用户态转到对应的系统调用执行,处理器会提供一个或多个特殊的系统调用指令,如IA-32处理器中的sysenter指令、MIPS处理器中的syscall指令等。这些系统调用指令属于陷阱指令,当执行到这些指令时,CPU通过一系列步骤自动调出内核中对应的系统调用服务例程执行。

此外,利用陷阱机制可以实现程序调试功能,包括设置断点和单步跟踪。

例如,在IA-32中,当CPU处于单步跟踪状态(TF=1且IF=1)时,每条指令都被设置成了陷阱指令,执行每条指令后,都会发生中断类型号为1的“调试”异常,从而转去执行特定的“单步跟踪处理程序”。该程序将当前指令执行的结果显示在屏幕上。单步跟踪处理前,CPU会自动把标志寄存器压栈,然后将TF和IF清0,这样,在单步跟踪处理程序执行过程中,CPU能以正常方式工作。单步处理结束、返回到断点处执行之前,再从栈中取出标志,以恢复TF和IF的值,使CPU回到单步跟踪状态,这样,下条指令又是“陷阱”指令,将被跟踪执行。如此下去,每条指令都被跟踪执行,直到将TF或IF清0为止。注意,对于“单步跟踪’这类陷阱,当陷阱指令是转移指令时,处理后不能返回到转移指令的下条指令执行,而是返回到转移目标指令执行。

在IA-32中,用于程序调试的“断点设置”陷阱指令为int3,对应机器码为CCH,若调试程序在被调试程序某处设置了断点,则调试程序就把该处指令第一字节改为CCH。当CPU执行到该指令时,就会暂停当前被调试程序的运行,并发出一个“EXCEPTION_BREAKPOINT”异常,从而最终调出相应的调试程序来执行,执行结束后再回到被设定断点的被调试程序执行。

在IA-32中,陷阱指令引起的异常称为编程异常(programmed exception),这些指令包括INT n、int 3、into(溢出检查)、bound(地址越界检查)等。通常将INT n称为软中断指令,执行该指令引起的“异常”通常也称为软中断(software interrupt)。在IA-32+Linux系统中,可以使用快速系统调用指令sysenter或者软中断指令int $0x80(即INT n指令中n=128时)进行系统调用。

终止

如果在执行指令过程中发生了严重错误,例如,控制器出现问题,访问DRAM或SRAM时发生校验错等,则程序将无法继续执行,只好终止发生问题的进程,在有些严重的情况下,甚至要重启系统。显然,这种异常是随机发生的,无法确定发生异常的是哪条指令。其处理过程如下图所示。

终止异常执行时的处理过程

中断的分类

中断是由外部I/O设备请求处理器进行处理的一种信号,它不是由当前执行的指令引起的。外部I/O设备通过特定的==中断请求信号线==向CPU提出中断申请。CPU在执行指令的过程中,每执行完一条指令就查看中断请求引脚,如果中断请求引脚的信号有效,则进入中断响应周期。在中断响应周期中,CPU先将当前PC值(称为断点)和当前的机器状态保存到栈中,并设置成“关中断”状态,然后,从数据总线读取中断类型号,根据中断类型号跳转到对应的中断服务程序执行。中断响应过程由硬件完成,而中断服务程序执行具体的中断处理工作,中断处理完成后,再回到被打断程序的“断点”处继续执行。中断的整个处理过程如下图所示。

外部中断的处理过程

Intel将外部中断分成可屏蔽中断(maskable interrupt)和不可屏蔽中断nonmaskable interrupt,NMI)。

可屏蔽中断

可屏蔽中断是指通过可屏蔽中断请求线INTR向CPU进行请求的中断,主要来自I\O设备的中断请求。CPU可以通过在中断控制器中设置相应的屏蔽字来屏蔽它或不屏蔽它,若一个I/O设备的中断请求被屏蔽,则它的中断请求信号将不会被送到CPU。

不可屏蔽中断

不可屏蔽中断通常是非常紧急的硬件故障,通过专门的不可屏蔽中断请求线NM1向CPU发出中断请求。如:电源掉电,硬件线路故障等,这类中断请求信号一旦产生,任何情况下它都不可以被屏蔽,因此一定会被送到CPU,以便让CPU快速处理这类紧急事件。通常,这种情况下,中断服务程序会尽快保存系统重要信息,然后在屏幕上显示相应的消息或直接重启系统。

异常和中断的响应过程

每种处理器架构都会各自定义它所处理的异常和中断类型,而且,对于异常和中断的处理,不同的处理器/操作系统平台可能也有所不同,但是,它们之间的差别不大,基本原理相同。

在CPU执行指令过程中,如果发生了异常事件或中断请求,则CPU必须进行相应的处理。CPU从检测到异常或中断事件,到调出相应的异常或中断处理程序开始执行,整个过程称为“异常和中断的响应”。CPU对异常和中断的响应过程可以分为以下三个步骤:

  1. 保护断点和程序状态
  2. 关中断
  3. 识别异常和中断事件并转到相应处理程序执行。

保护断点和程序状态

前面提到,对于不同的异常事件,其返回地址(即断点)不同。例如,“缺页故障”异常的断点是发生页故障的当前指令的地址;“陷阱”异常的断点则是陷阱指令后面一条指令的地址。显然,断点与异常类型有关。为了能在异常处理后正确返回到原被中断的程序继续执行,数据通路必须能正确计算断点处的地址。保护断点时,只要将计算出来的断点地址送到栈中或特定的寄存器中即可。

为了能够支持异常或中断的嵌套处理,大多数处理器将断点保存在栈中,如IA-32处理器的断点被保存在栈中;如果硬件不支持嵌套处理,则可以将断点保存在特定寄存器中,而不需送到栈中保存,如MIPS处理器用EPC寄存器专门存放断点。显然,后者CPU用于中断响应的开销较小,因为访问栈就是访问存储器,它比访问寄存器所用的时间要长。

因为异常处理后可能还要回到原被中断的程序继续执行,所以,被中断时原程序的状态(如产生的各种标志信息、允许自陷标志等)都必须保存起来。通常每个正在运行程序的状态信息存放在一个专门的寄存器中,这些专门寄存器统称为==程序状态字寄存器(PSWR)==,存放在PSWR中的信息称为==程序状态字(Program Status Word,简称PSW)==。例如,在IA-32中,程序状态字寄存器就是标志寄存器EFLAGS。与断点一样,PSW也要被保存到栈或特定寄存器中,在异常返回时,将保存的PSW恢复到PSWR中。

关中断

如果中断处理程序在保存原被打断程序现场的过程中又发生了新的中断,那么,就会因为要处理新的中断,而把原被打断程序的现场以及已保存的断点和程序状态等破坏掉。因此,应该有一种机制来禁止在处理中断时再响应新的中断。通常通过设置“==中断允许位==”(“中断允许”触发器)来实现。若中断允许位置1,则为开中断,表示允许响应中断;若中断允许位清0,则为关中断,表示不允许响应中断。例如,IA-32中的“中断允许位”就是EFLAGS寄存器中的中断标志位IF。

在中断响应过程中,通常由CPU将中断允许位清0,以进行关中断操作。例如,对于IA-32+Linux平台,CPU在中断响应过程中,会将标志寄存器EFLAGS中的IF清0,以禁止响应新的可屏蔽中断。

除了在中断响应阶段可以由CPU对中断允许位进行设置外,也可以在异常或中断处理程序中执行相应的指令来设置或清除中断允许位。在IA-32中,可通过执行指令sti或cli,将标志寄存器EFLAGS中的F位置1或清0,以使CPU处在开中断或关中断状态。

识别异常和中断事件并转相应的处理程序执行

在调出异常和中断处理程序之前,必须知道发生了什么异常或哪个I/O设备发出了中断请求。一般来说,内部异常事件和外部中断源的识别方式不同,大多数处理器会将两者分开来处理。

  • 内部异常事件的识别很简单。只要CPU在执行指令时把检测到的事件对应的异常类型号或标识异常类型的信息记录到特定的内部寄存器中即可。
  • 外部中断源的识别比较复杂。由于外部中断的发生与CPU正在执行的指令没有必然联系,相对于指令来说,外部中断是不可预测的,与当前执行指令无关,因此并不能根据指令执行过程中的某些现象来判断是否发生了“中断”请求。对于外部中断,只能在每条指令执行完后取下条指令之前去查询是否有中断请求。通常CPU通过采样对应的中断请求引脚(如Intel处理器的NTR)来进行查询。如果发现中断请求引脚有效,则说明有中断请求,但是,到底是哪个I/O设备发出的请求,还需要进一步识别。

通常是由CPU外部的中断控制器根据I/O设备的中断请求和中断屏蔽情况,结合中断响应优先级,来识别当前请求的中断类型号,并通过数据总线将中断类型号送到CPU。

异常和中断源的识别可以采用==软件识别==或==硬件识别==两种方式。

  • 软件识别方式是指,CPU中设置一个原因寄存器,该寄存器中有一些标识异常原因或中断类型的标志信息。操作系统使用一个统一的异常或中断查询程序,该程序按一定的优先级顺序查询原因寄存器,先查询到的先被处理。例如,MIPS就采用软件识别方式,CPU中有一个cause寄存器,位于地址0x80000180处有一个专门的异常/中断查询程序,它通过查询cause寄存器来检测异常和中断类型,然后转到内核中相应的异常处理程序或中断服务程序进行具体的处理。
  • 硬件识别方式称为==向量中断方式==。这种方式下,异常或中断处理程序的首地址称为中断向量,所有中断向量存放在一个表中,称为中断向量表。每个异常和中断都被设定一个中断类型号,中断向量存放的位置与对应的中断类型号相关,例如,类型0对应的中断向量存放在第0表项,类型1对应的中断向量存放在第1表项,以此类推,因而可以根据类型号快速找到对应的处理程序。IA-32中的异常和中断识别就是采用这种向量中断方式。

I/O操作的实现

I/O子系统概述

I/O子系统主要解决各种形式信息的输入和输出问题,即解决如何将所需信息(文字、图表、声音、视频等)通过不同外设输入到计算机中,或者计算机内部处理的结果如何通过相应外设输出给用户。

所有高级语言的运行时系统都提供了执行I/O功能的高级机制,例如,C语言中提供了像printf()和scanf()等这样的标准I/O库函数,C++语言中提供了如“<<”(输入)和“>>”(输出)这样的重载I/O操作符。从用户在高级语言程序中通过I/O函数或I/O操作符提出I/O请求,到I/O设备响应并完成I/O请求,整个过程涉及多个层次的I/O软件和I/O硬件的协调工作。

[!tip]

运行时系统(runtime system)也称为运行时环境(runtime environment)或简称为运行时(runtime),它实现了一种计算机语言的核心行为。不管是被编译转换的语言,还是被解释执行的语言,或者是嵌入式领域特定的语言等,每一种计算机语言都实现了某种形式的运行时系统。一个运行时系统除了要支持语言基本的低级行为之外,还要实现更高层次的行为,如库函数等,甚至提供类型检查、调试以及代码生成与优化等功能。

与计算机系统一样,I/O子系统也采用层次结构。下图是I/O子系统层次结构示意图。

I/O子系统包含I/O软件和I/O硬件两大部分。I/O软件包括最上层提出I/O请求的用户空间l/O软件(称为用户I/O软件)和在底层操作系统中对I/O进行具体管理和控制的内核空间I/O软件(称为系统I/O软件)。系统I/O软件又分三个层次,分别是与设备无关的I/O软件层、设备驱动程序层和中断服务程序层。I/O硬件在操作系统内核空间I/O软件的控制下完成具体的I/O操作。

I/O子系统层次结构

操作系统在I/O子系统中承担极其重要的作用,这主要是由I/O子系统的以下三个特性决定的。

  1. 共享性。I/O子系统被多个进程共享,因此必须由操作系统对共享的I/O资源统一调度管理,以保证用户程序只能访问自己有权访问的那部分I/O设备或文件,并使系统的吞吐率达到最佳。
  2. 复杂性。I/O设备控制的细节比较复杂,如果由最上层的用户程序直接控制,则会给广大的应用程序开发者带来麻烦,因而需操作系统提供专门的驱动程序进行控制,这样可以对应用程序员屏蔽设备控制的细节,简化应用程序的开发。
  3. 异步性。I/O子系统的速度较慢,而且不同设备之间的速度也相差较大,因而,I/O设备与主机之间的信息交换方式通常使用异步的中断I/O方式。中断导致从用户态向内核态转移,因此,I/O处理须在内核态完成,通常由操作系统提供中断服务程序来处理I/O。

用户程序总是通过某种I/O函数或I/O操作符请求I/O操作。例如,用户程序需要读一个磁盘文件中的记录时,它可以通过调用C语言标准I/O库函数fread(),也可以直接调用read系统调用的封装函数read()来提出I/O请求。不管用户程序中调用的是C库函数还是系统调用封装函数,最终都是通过操作系统内核提供的系统调用来实现I/O。

下图给出了用户调用printf来调出内核提供的write系统调用的过程。

用户程序、c语言库和内核之间的关系

从上图可以看出,对于一个C语言用户程序,若在某过程(函数)中调用了printf(),则在执行到调用printf()的语句时,便会转到C语言库中对应I/O标准库函数printf()去执行,而printf()最终又会转到调用函数write()执行;write()函数对应一个指令序列,其中有一条陷阱指令,通过这条陷阱指令,CPU从用户态转到内核态执行,内核程序在内核空间中找到write对应的系统调用服务例程来执行具体的打印显示任务。

每个系统调用的封装函数都会被转换为一组与具体机器架构相关的指令序列,这个指令序列中至少有一条陷阱指令,在陷阱指令之前可能还有若干条传送指令用于将I/O操作的参数送入相应的寄存器。

例如,在IA-32中,陷阱指令就是INT n指令,也称为软中断指令。在早期IA-32架构中,Linux系统将int $0x80指令用作系统调用,在系统调用指令之前会有一串传送指令,用来将系统调用号等参数传送到相应的寄存器。系统调用号通常在EAX寄存器中,内核程序可根据系统调用号选择执行一个系统调用服务例程。这样,用户进程的I/O请求通过调出操作系统中相应的系统调用服务例程来实现。

I/O子系统工作的大致过程如下:首先,CPU在用户态执行用户进程,当CPU执行到系统调用封装函数对应的指令序列中的陷阱指令时,会从用户态陷入到内核态;转到内核态执行后,CPU根据陷阱指令执行时EAX寄存器中的系统调用号,选择执行一个相应的系统调用服务例程;在系统调用服务例程的执行过程中可能需要调用具体设备的驱动程序;在设备驱动程序执行过程中启动外设工作,外设准备好后发出中断请求,CPU响应中断后,就调出中断服务程序执行,在中断服务程序中控制主机与设备进行具体的数据交换。

以下是Linux系统中write操作的执行过程示意图:

在Liunx系统中的write操作的执行过程

如上图所示,假定用户程序中有一个语句调用了库函数printf(),在printf()函数中又通过一系列的函数调用,最终转到调用write()函数。在write()函数对应的指令序列中,一定有一条用于系统调用的陷阱指令,在IA-32+Linux系统中就是指令int$ 0x80或sysenter。该陷阱指令执行后,进程就从用户态陷人到内核态执行。Linux中有一个系统调用的统一入口,即系统调用处理程序system_call()。CPU执行陷阱指令后,便转到system_call()的第一条指令执行。在system_call()中,将根据EAX寄存器中的系统调用号跳转到当前系统调用对应的系统调用服务例程sys_write()去执行。system_call()执行结束时,从内核态返回到用户态下的陷阱指令后面一条指令继续执行。

Linux系统下write()函数的用法如下:

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

这里的类型size_t和ssize_t分别是unsigned int和int。字节数n通常是unsigned类型,但是,因为返回值还可能是-1,所以,返回类型只能是带符号整数类型int。

write()封装函数的源代码编译后生成如下所示的汇编代码。按照函数调用时压栈的顺序可知,在某函数中调用write()函数时,最先被压入栈中的参数是n,其次是buf,最后是fd,参数压栈后执行调用指令call,此时,再将返回地址压栈。在执行完call指令后,便跳转到下面代码所示的write过程执行。执行第2行的push指令后,当前栈指针寄存器内容R[esp]指向刚保存的R[ebx],R[esp]+4指向返回地址,R[esp]+8指向参数fd,R[esp]+12指向参
数buf,R[esp]+16指向参数n。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
write:
push1 %ebx #将EBX入栈
movl $4,%eax #将系统调用号送EAX
mov1 8(%esp),%ebx #将第1个参数fd送EBX
movl 12(%esp),%ecx #将第2个参数buf送ECX
movl 16(%esp),%edx #将第3个参数n送EDX
int $0x80 #进入系统调用处理程序system_ca11执行
cmp1 $-132,%eax #检查返回值,假定最大出错码为131
jbe .L1#若无错误,则跳转至.L1
negl %eax #将返回值取负送EAX
mov1 ‰eax,error #将EAX的值送error
movl $-1,%eax #将wr1te函数返回值置-1
.L1:popl ebx
ret

上图给出的汇编代码中,第3行到第6行用来将系统调用的参数送到不同的寄存器,其中,系统调用号4保存在寄存器EAX中。第7行是陷阱指令int $0x80,CPU执行到该指令时,将从用户态切换到内核态,调出系统调用处理程序system_call()执行。在system_call()中,根据系统调用号为4,再跳转到相应的系统调用服务例程sys_write()执行,以完成将一个字符串写入文件的功能,其中,字符串的首地址由ECX指定,字符串的长度由EDX指出,写入文件的文件描述符由EBX指出。system_call()执行结束时,从内核返回的参数存放在EAX中。若返回参数表明在内核中执行系统调用发生错误,则将EAX取负后得到错误码,存放在error中,并将write()函数的返回值置-1;若没有发生错误,则write()函数的返回值就是从内核系统调用返回的值,它通常是真正写入文件的字节数。

用户空间I/O软件

用户程序中的I/O函数

在用户空间I/O软件中,用户程序可以通过调用特定的I/O函数提出I/O请求。在UNIX/Linux系统中,用户程序使用的I/O函数可以是C标准I/O库函数或系统调用封装函数,前者如文件I/O函数fopen()、fread()、fwrite()和fclose()或控制台I/O函数printf(()、scanf()等,后者如open()、read()、write()和close()等。

标准I/O库函数比系统调用封装函数抽象层次更高,后者属于系统级I/O函数,前者是基于后者实现的。下图给出了两者之间的关系。

C标准IO桉树和UNIX系统IO函数之间的关系

通常情况下,C程序员大多使用较高层次的标准I/O库函数,而很少使用底层的系统级I/O函数。使用标准I/O库函数得到的程序移植性较好,可以在不同体系结构和操作系统平台下运行,而且,因为标准I/O库函数中的文件操作使用了在内存中的文件缓存区,使得系统调用以及I/O次数显著减少,所以使用标准I/O库函数能提高程序执行效率。不过,使用C标准I/O库函数也有以下不足:

  1. 所有I/O操作都是同步的,即程序必须等待I/O操作真正完成后才能继续执行;
  2. 在一些情况下不适合甚至无法使用标准I/O库函数实现I/O功能,例如,C标准I/O库中不提供读取文件元数据的函数;
  3. 更有甚者,标准I/O库函数还存在一些问题,使得用它进行网络编程会造成易于出现缓冲区溢出等风险,同时它也不提供对文件进行加锁和
    解锁等功能。

虽然在很多情况下使用标准I/O库函数就能解决问题,特别是对于磁盘和终端设备(键盘、显示器等)的I/O操作。但是,在UNIX/Linux系统中,有时用标准I/O库函数或系统级I/O函数对网络设备进行I/O操作时会出现一些问题,因此,也可以基于底层的系统级I/O函数自行构造高层次I/O函数,以提供适合网络I/O的读操作和写操作函数。

在Windows系统中,用户程序同样可以调用C标准I/O库函数,此外,还可以调用Widows提供的API函数,如文件I/O函数CreateFile()、ReadFile()、WriteFile()、CloseHandle()和控制台I/O函数ReadConsole()、WriteConsole()等。

下表给出了关于文件I/O和控制台I/O的部分函数对照列表,其中包含了C标准I/O库函数、UNIX/Linux系统级I/O函数和用于I/O的Windows API函数。

序号 C标准库 UNIX/Linux Windows 功能描述
1 getc, scanf, gets read ReadConsole 从标准输入读取信息
2 fread read ReadFile 从文件读入信息
3 putc,printf,puts write WriteConsole 在标准输出上写信息
4 fwrite write WriteFile 在文件上写入信息
5 fopen open,creat CreateFile 打开/创建一个文件
6 fclose close CloseHandle 关闭一个文件(Close- Handle不限于文件)
7 fseek lseek SetFilePointer 设置文件读写位置
8 rewind lseek(0) SetFilePointer(0) 将文件指针设置成指向 文件开头
9 remove unlink DeleteFile 删除文件
10 feof 无对应 无对应 停留到文件末尾
11 perror strerror FormatMessage 输出错误信息
12 无对应 stat,fstat,lstat GetFileTime 获取文件的时间属性
13 无对应 stat,fstat,lstat GetFileSize 获取文件的长度属性
14 无对应 fent LockFile/UnlockFile 文件的加锁、解锁
15 使用stdin、stdout和stderr 使用文件描述符0、1和2 GetStdHandle 标准输入、标准输出和 标准错误设备

从上表可以看出,C标准库中提供的函数并没有涵盖所有底层操作系统提供的功能,如表中第12、13和第14项;不同的C标准库函数可能调用相同的系统调用,例如,表中第1和第2项中不同的C库函数是由同一个系统调用read实现的,同样,表中第3和第4项中不同的C库函数都是由write系统调用实现的;此外,C标准I/O库函数、UNTX/Linux和Windows的API函数所提供的I/O操作功能并不是一一对应的。虽然对于基本的I/O操作,它们有大致一样的功能,不过,在使用时还是要注意它们之间的不同。其中一个重要的不同点是,它们的参数中对文件的标识方式不同,例如,函数read()和write()的参数中指定的文件用一个整数类型的文件描述符来标识;而C标准库函数fread()和fwrite()的参数中指定的文件用一个指向特定结构的指针类型来标识。下一节简单介绍与文件相关的基本概念,而有关文件系统的细节内容请参看操作系统方面的书籍。

文件的基本概念

Linux操作系统是一个类UNIX系统,其文件格式和有关文件操作方面的系统调用等与UNIX中的类似。在UNIX系统中,所有的I/O操作都是通过读写一个文件来实现的,所有外设,包括网络(套接字socket)、终端设备(键盘和显示器)等,都被看成是一个文件。把所有不同的物理设备抽象成一个逻辑上统一的“文件”后,使得对于用户程序来说,访问一个物理设备与访问一个真正的磁盘文件是完全一致的,这样,就为用户程序和外设之间的信息交换提供了一个统一的处理接口。

在UNⅨ操作系统中,文件就是一个字节序列,因此,键盘可被看成是可以读取字节序列的输入设备文件,显示器看成是可以写入字节序列的输出设备文件,网络套接字是可以读取字节序列和写人字节序列的输入/输出设备文件。通常将键盘和显示器构成的设备称为终端(terminal),对应标准输入文件和标准输出文件。像磁盘、光盘等外存储器上的文件则是普通文件。

根据文件中的每个字节是否是可读的ASCII码,可将文件分成ASCII文件和二进制文件两类。ASCII文件也称为文本文件,可以由多个正文行组成,每行以换行符(‘\n’)结束,其中每个字节是一个字符。通常,终端设备上的标准输入文件和标准输出文件是ASCII文件;磁盘上的普通文件则可能是文本文件或二进制文件,例如,磁盘上的可重定位目标文件和可执行文件都是二进制文件,而源程序文件则是ASCII文件。对于系统中的文件,用户程序可以对其进行创建、打开、读写和关闭等操作。

==创建文件==

在大多数情况下,在读或写一个文件之前,用户程序必须告知系统将要对该文件进行什么操作,是读、写、添加还是可读可写,这个告知操作通过打开一个文件或创建一个文件来实现。

对于一个已存在的文件,可以直接打开文件;对于一个不存在的文件,则应先创建。创建一个新文件时,用户应指定所创建文件的文件名和访问权限,系统返回一个非负整数,它被称为文件描述符(file descriptor)fd。文件描述符用于标识被创建的文件,在以后对文件的读写等操作时用文件描述符代表文件。

==打开文件==

打开文件时,系统要检测文件是否存在、用户是否有访问文件的权限等。若这些操作都没有问题,则系统会返回一个非负整数作为文件描述符。

在UNIX中创建每个进程时,都会自动打开三个标准文件:标准输入(描述符为O)、标准输出(描述符为1)和标准错误(描述符为2)。键盘和显示器可以分别抽象成标准输入文件和标准输出文件。

==设置文件读写位置==

每个文件都有一个当前读写位置,它表示相对于文件最开始的字节偏移量,初始时为0。用户程序中可以用lseek系统调用设置文件读写位置。

==读文件和写文件==

一个新文件被创建后,用户程序可以将信息写到文件上。一个已存在的文件被打开后,用户程序可以从文件中读或写信息。写文件的操作从当前读写位置k(k≥0)处写入n(n>0)个字节,因而写入后文件当前读写位置为k+n。

读文件的操作从文件当前读写位置k(k≥0)处读出n(n>0)个字节,因而读出后文件当前读写位置为k+。假设文件大小为m个字节,当执行读文件操作时,若k=m,则当前位置为结尾处,这种情况称为文件结束EOF(end of file)。

==关闭文件==

当完成对文件的读写等操作后,用户程序需要通知内核关闭文件,表示用户程序不再对文件进行任何操作。关闭文件时将释放文件创建或打开时所创建的数据结构所在存储区,并回收文件描述符。不管一个进程因为何种原因终止,内核都会关闭所打开的文件,以释放其所用的存储资源。

系统级I/O函数

前面提到,与I/O操作相关的系统调用封装函数属于系统级I/O函数。在UNIX/Linux系统中,常用的这类函数有creat()、open()、read()、write()、lseek()、stat()/fstat()、close()等,它们的调用形式及其功能说明如下。使用以下函数时必须包含相应的头文件(如unistd.h等)。

==creat系统调用==

1
int creat(char* name,mode_t perms);
  1. 第一个参数name为需创建的新文件的名称,是一个表示路径名和文件名的字符串;
  2. 第二个参数perms用于指定所创建文件的访问权限,共有9位,分别指定文件拥有者、拥有者所在组成员以及其他用户各自所拥有的读、写和执行权限。通常用一个八进制数字中的三位分别表示读、写和执行权限。
    1. 例如,perms=0755
    2. 表示拥有者具有读、写和执行权限(八进制的7,即111B),
    3. 而拥有者所在组成员和其他用户都只有读和执行权限,没有写权限(八进制的5,即101B)。

正常情况下,该函数返回一个文件描述符,若出错,则返回-1。若文件已经存在,则该函数将把文件截断为长度为0的文件,也即,将文件原先的内容全部丢弃,因此,创建一个已存在的文件不会发生错误。

==open系统调用==

1
int open(char *name,int f1ags,mode_t perms);

除了默认的标准输入、标准输出和标准错误三种文件是自动打开以外,其他文件必须用相应的函数显式创建或打开后才能读写,可以用open系统调用显式打开文件。

正常情况下,open()函数返回一个文件描述符,它是一个用以唯一标识进程中被打开文件的非负整数,若出错,则返回-1。

  1. 第一个参数name为需打开文件的名称,是一个表示路径名和文件名的字符串;

  2. 第二个参数flags指出用户程序将会如何访问这个打开文件,例如:

    1. ORDONLY:只读。

    2. O_WRONLY:只写。

    3. O_RDWR:可读可写。

    4. O WRONLY O APPEND:可在文件末尾添加并且只写。

    5. O_RDWR|O_CREAT:若文件不存在则创建一个空文件并且可读可写。

    6. O_WRONLY|O_CREAT|O_TRUNC:若文件不存在则创建一个空文件,若文件存在则截断为空文件,并且可读可写。

    7. 上述这些带O_的常数必须事先定义在某个头文件中,例如,在System V UNIX系统的头文件sys/file.h或BSD版本的头文件sys/file.h中都定义了这些常数。

      假定用户程序将以只读方式使用文件test.txt,则可以用以下语句打开文件:

      1
      fd = open("test.txt",O_RDONLY,0);
  3. 第三个参数perms用于指定所创建文件的访问权限,通常在open()函数中该参数总是0,除非以创建方式打开,此时,参数flags中应带有O_CREAT标志。不以创建方式打开一个文件时,若文件不存在,则发生错误。对于不存在的文件,可用creat系统调用来打开。

==read系统调用==

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

该函数功能是将文件fd中从当前读写位置k开始的n个字节读取到buf中,读操作后文件当前读写位置为k+n。假定文件长度为m,当k+n>m时,则真正读取的字节数为m-k<n,并且读操作后文件当前读写位置为文件尾。函数返回值为实际读取字节数,因而,当m=k(EOF)时,返回值为0:出错时返回值为-1。

==write系统调用==

1
ssize_twr1te(int fd,const void* buf,size_t n):

该函数功能是将buf中的n字节写到文件f中,从当前读写位置k处开始写入。返回值为实际写入字节数m,写入后文件当前读写位置为k+m。对于普通的磁盘文件,实际写入字节数m等于指定写入字节数n。出错时返回值为-1。对于read/write系统调用,可以一次读/写任意字节,例如,每次读/写一个字节或一个物理块大小,如一个磁盘扇区(512字节)或一个记录大小等。显然,按照一个物理块大小来读(写)比较好,可以减少系统调用的次数。

有些情况下,read/write真正读/写的字节数比用户程序设定的所需字节数要少,这种情况并不被看成一种错误。通常,在读/写磁盘文件时,除非遇到EOF,否则不会出现这种情况。
但是,当读/写的是终端设备文件、网络套接字文件、UNIⅨ管道、Wb服务器等时,都可能出
现这种情况。

==Iseek系统调用==

1
long 1seek(int fd,long offset,int origin);

当随机读写一个文件的信息时,当前读写位置可能并非正好是马上要读或写的位置,此时,需要用lseek()函数来调整文件的当前位置。

  1. 第一个参数fd指出需调整位置的文件;

  2. 第二个参数offset指出相对字节数:

  3. 第三个参数origin指出offsett相对的基准,可以是文件开头(origin=0)、当前位置(origin=1)和文件末尾(origin=2)。例如:

    1
    2
    1seek(fd,0L,2);//定位到文件末尾
    1seek(fd,0L,0);//定位到文件开始

函数的返回值就是新的位置值,若发生错误,则返回-1。

==stat/fstat系统调用==

1
2
int stat(const* name,struct stat* buf);
int fstat(int fd,struct stat *buf)

文件的所有属性信息,包括文件描述符、文件名、文件大小、创建时间、当前读写位置等都由操作系统内核来维护,这些信息也称为文件的元数据(metadata)。用户程序可以通过stat()或fstat()函数来查看文件元数据。stat第一个参数指出的是文件名,而fstat指出的是文件描述符,这两个函数除了第一个参数类型不同外,其他方面全部一样。文件的元数据信息用stat数据结构描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct stat
{
dev_t st_dev; /*体包含该文件的文件目录项的设备ID*/
ino_t st_ino; /*节点编号,在给定文件系统中能唯一标识该文件*/
mode t st_mode; /*文件访问权限和文件类型*/
nlink_t st_nlink; /*连接链的数目*/
uid_t st_uid; /*文件拥有者的ID*/
gid_t st_gid; /*文件拥有者所在组的组ID*/
dev_t st_rdev; /*体设备ID,仅对于特殊的块设备和字符设备文件有效*/
off_t st_size; /*普通的磁盘文件的大小,对于特殊块设备和字符设备文件无效*/
unsigned 1ong st_blksize; /*块大小*/
unsigned 1ong st_blocks; /*分配的块数*/
time_t st_atime; /*最近一次访问的时间*/
time_t st_mtime; /*最近一次修改的时间*/
time_t stctime; /*最近一次修改文件状态的时间*/
};

==close系统调用==

1
close(int fd);

该函数的功能就是关闭文件fd。

C标准I/O库函数

标准I/O库函数是基于系统级I/O函数实现的。本节通过若干例子介绍如何通过系统级I/O函数来实现C标准I/O库函数。

C标准I/O库函数将一个打开的文件抽象为一个类型为FILE的“流”模型。上面曾提到,C标准I/O库函数中,文件是用一个指向特定结构的指针来标识的,这个特定结构就是FILE结构,它描述了包含文件描述符在内的一组信息。FILE结构在头文件stdio.h中描述,此外,stdio.h文件中还对其他与标准I/O有关的常量、数据结构、函数和宏等进行了定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#define	NULL	0
#define EOF (-1)
#define BUFSIZ 1024 /*缓冲区大小为1024字节*/
#define OPEN_MAX 20 /*可同时打开的最多文件数*/
typedef struct _iobuf{
int cnt; /*剩余未读写字节数*/
char *ptr; /*下一个读写位置*/
char *base; /*缓冲区的起始地址*/
int flag; /*文件的访问模式*/
int fd; /*文件描述符*/
}FILE;

extern FILE_iob[OPEN_MAX];

#define stdin (&1ob[0])
#define stdout (&iob[1])
#define stderr (&1ob[2])

enum _flags{
_READ = 01, /*打开的文件可读*/
_WRITE = 02, /*打开的文件可写*/
_UNBUF = 04, /*没有缓存区*/
_EOF = 010, /*文件遇到结束标志EOF*/
_ERR = 020 /*文件发生了错误*/
};

int _fillbuf(FILE*);
int _flushbuf(int,FILE *);

#define feof(p) (((p)->f1ag&E0F)I=0)
#define ferror(p) (((p)->f1ag&_ERR)!=0)
#define fileno(p) ((p)->fd)
#define getc(p) (--(p)->cnt >0 (unsigned char)*(p)->ptr++_fillbuf(p))
#define putc(x,p) (--(p)->cnt >0 *(p)->ptr++(x):_flushbuf((x),p))
#define getchar() getc(stdin)
#define putchar(x) putc((x),stdout)

文件fd的流缓冲区FILE由缓冲区起始位置base、下一个可读写位置ptr以及剩下未读写的字节数cnt来描述。标准I/O库函数中表示文件的参数通常是一个指向FILE结构的指针fp。

对于像fread()这种读文件的函数,其FILE是一个在内存的输入流缓冲区。下图给出了输入流缓冲区的工作原理。虽然fread()函数的功能是从文件中读信息,但实际上是从FILE缓冲区的ptr处开始读信息,而缓冲区中的信息则是从文件fd中预先读入的。每次执行读操作时,会先判断当前缓冲区中是否还有可读信息。若没有(即cnt=0),则从文件fd中读入1024字节(缓冲区大小BUFSIZ=1024)到缓冲区,并置ptr等于base,cnt等于1024。若从缓冲区读入n字节,则新的ptr等于ptr加n,cnt等于cnt减n。

输入流缓冲区的工作原理

对于像fwrite()这种写文件的函数,其FILE是一个在内存的输出流缓冲区。下图给出了输出流缓冲区的工作原理。虽然fwrite()函数的功能是向文件中写信息,但实际上是写到FILE输出流缓冲区的ptr处。输出缓冲区的属性有三种:全缓冲_IOFBF(fully buffered)、行缓冲_IOLBF(line buffered)和非缓冲_IO_NBF(no buffering)。普通文件的缓冲区属性为全缓冲(fully buffered),即使遇到换行符也不会写文件,只有当缓冲区满时才会将缓冲区内容真正写入文件fd中。

每次执行写操作时,会先判断当前缓冲区是否已写满(即cnt=0),对于行缓冲(line buffered)还要判断本次写的字节流中是否有换行符\n。若是,则将缓冲区信息一次性写到文件fd中,并置ptr等于base,cnt等于1024。若写人缓冲区n字节,则新的ptr等于ptr加n,cnt等于cnt减n。

输出流缓冲区的工作原理

在上述stdio.h文件中,定义了三个特殊的标准文件,分别是标准输入(stdin)、标准输出(stdout)和标准错误(stderr),它们分别定义为打开的文件列表中的前三个文件,对应的文件描述符分别是0、1和2,它们在结构数组_iob中前三项的初始化定义如下:

1
2
3
4
5
FILE _iob[OPEN_MAX]={	/*stdin,stdout,stderr:*/
{0,(char *)0,(char*)0,_READ,0},
{0,(char *)0,(char *)0,_WRITE,1},
{0,(char *)0,(char *)0,_WRITE | _UNBUF,2},
};

这三个标准文件的流缓冲区的初始化信息相同,其起始位置base、下一个可读写位置ptr以及剩下未读写字节数cnt都被初始化为0。

  1. 标准输入stdin的访问模式是只读(_READ)。
  2. 标准输出stdout和标准错误stderr的访问模式都为可写(_WRITE),但前者的缓冲区属性为行缓冲(line buffered),当缓冲区满或遇到换行符\n时,将缓冲区数据写文件;而后者为非缓冲(no buffering),因此,每个字符直接写文件。

在stdio.h中还给出了feof()、ferror()、fileno()、getc()、putc(、getchar()、putchar()等宏定义。
系统级I/O函数中对文件的标识是文件描述符fd,而C标准I/O库函数中对文件的标识是指向FILE结构的指针p,FILE结构将文件fd封装成一个文件的
流缓冲区,因而可以将文件中一批信息先读入缓冲区,然后再从缓冲区中一个一个读出,或者先写入缓冲区,写满缓冲区后再一次性把缓存信息写到文件中。

系统级I/O函数的功能通过执行内核中的系统调用服务例程来实现,在用户程序中每调用一次系统级I/O函数,就是进行一次系统调用。每次系统调用都有两次上下文切换,先从用户态切换到内核态,处理结束后再从内核态返回到用户态,因此,系统调用的开销非常高。例如,在IA-32中,因为系统调用属于陷类异常,所以从用户态切换到内核态的过程,每次系统调用会增加许多额外开销,因此,如果能够不用系统调用则应尽量不用,或尽量减少系统调用次数。

在C标准I/O库函数中引入流缓冲区,可以尽量减少系统调用的次数。因为使用流缓冲区后,可以使用户程序仅和缓冲区进行信息交换,也即,可使文件的内容缓存在用户的缓冲区中,而不是每次都直接读写文件,从而减少执行系统调用的次数。

从stdio.h中可以看出,有了流缓冲区后,getc()就只要对文件对应流缓冲区的指针进行修改(如cnt减1,ptr加1)并返回缓冲区中当前所指字符即可。如果流缓冲区的cnt减1后为负数,则说明已经没有字符可读,此时调用函数_fillbuf()来填充缓冲区。

通常在第一次调用getc()时,需要调用_fillbuf()函数进行缓冲区填充。在_fillbuf()函数中,若发现文件的打开模式不是READ(对应mode为‘r’的情况)时,就立即返回EOF;否则,它会通过malloc()函数试图分配一个缓冲区(如果是带缓冲读写的情况)。一旦缓冲区建立后,_fillbuf()就会执行read系统调用,以读入最多1024(BUFSIZ=1024)个字节到缓冲区中,并设定读写指针pr和剩余字节数ct等。以下代码给出的_illbuf()函数源代码摘自Brian W.Kernighan和Dennis M.Ritchie著的《The C Programming Language(Second Edition)》。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include "syscalls.h"
/*fillbuf:allocate and fill input buffer */

int _fillbuf(FILE *fp)
{
int bufsize;

if ((fp->flag & (_READ _EOF |_ERR))!=_READ)
{
return EOF;
}
bufsize = (fp->flag & _UNBUF) ? 1 : BUFSIZ;
if ((fp->base =NULL){ /*no buffer yet */
if (fp->base (char *malloc(bufsize))==NULL)
{
return EOF; /*canEt get buffer */
}
}
fp->ptr = fp->base;
fp->cnt = read(fp->fd,fp->ptr,bufsize);
if(--fp->cnt<0){
if (fp->cnt ==-1)
fp->flag |= _EOF;
else
fp->flag = _ERR;
fp->cnt = 0;
return EOF;
}
return (unsigned char)*fp->ptr++;
}

假定有一个重复调用getc()共n次的应用程序,第一次调用getc()时,实际上一下子通过read()读入了1024个字节到流缓冲区中,以后每次调用就只要从该流缓冲区读取并返回字符即可。这样,若n<1024,则执行read系统调用的次数为1。如果应用程序直接调用系统调用函数read()且每次只读一个字符,那么,应用程序就要执行n次read系统调用,从而增加许多额外开销。

假设函数filecopy()的功能是从一个输入文件复制信息到另一个输出文件,比较以下两种实现方式下系统调用的次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*方式一:getc/putc版本*/
void filecopy(FILE *infp,FILE *outfp)
{
int c;
while ((c=getc(infp)) != EOF)
putc(c,outfp);
}
/*方式二:read/人write版本*/
void filecopy(int *infp,int *outfp)
{
char c;
while (read(infp,&c,1) != 0)
write(outfp,&c,1);
}

显然,方式二的系统调用次数更多,因为每次调用read()和write()都只进行一个字符的读和写,所以,对于文件长度为n的情况,共需执行2n次系统调用。方式一用getc读取输入文件中的字符,第一次读取文件时会通过read系统调用将最多1024个字符一次读入流缓冲区中,这样,以后每次读取字符时就直接从流缓冲区读入,而无须调用read系统调用,因而,若输入文件长度小于1024字节,那么对于读操作只有一次系统调用。

前面提过,C标准I/O库函数和宏是基于底层的系统调用函数实现的,从上述函数_fillbuf()的实现也可以看出这一点。

以下用标准I/O库函数fopen()的实现作为例子来说明如何基于底层的系统级I/O函数实现C标准I/O库函数。fopen()的用法如下:

1
2
#include <stdio.h>
FILE *fopen(char *name,char *mode);

fopen()函数的功能是打开文件名为name的文件,具体来说,主要是分配一个FILE结构,并初始化其中的流缓冲区,返回指向该LE结构的指针,不管由于什么原因不能打开文件,都返回NULL。

参数mode指出用户程序将如何使用文件,可以是“rwab+”中的一个或多个字符构成的字符串,例如,‘r’、‘w’、‘a’、‘a+b’等。各字符的含义如下:

  • 若mode中有‘a’(append),则数据只能写到文件末尾,且当前读写位置为文件尾。
  • 若mode中有‘r’(read),则文件必须已经存在并包含了数据,若不存在或不能被打开,则返回NULL;数据只能写到文件末尾,且当前读写位置为文件尾。
  • 若mode中有‘w’(wite),则对于已存在的文件就截断到0字节,对于不存在的文件就创建它。
  • 若mode中有·+’(updata),则允许从该文件读取数据或写入数据。如果和‘r’或‘w’一起使用,则可以在任意一处从文件读取或写入。如果和‘a’一起使用,则数据只能写入文件尾部。
  • 若mode中有‘b’(binary),则表示文件按二进制形式打开,否则按文本形式打开。

假定系统级I/O函数定义包含在头文件syscalls.h中,C标准I/O库函数fopen()的一个实现版本如下代码所示(摘自Brian W.Kernighan,Dennis M.Ritchie,《The C Programming Language(Second Edition)》)。

以下代码给出的实现版本没有对所有访问模式进行处理,缺少了针对‘b’和‘+’情况的处理。从代码可以看出,fopen()是通过系统调用open和creat来实现的。在用fopen()打开文件后,就可以对其进行读写。通常,第一次读取一个打开的文件时,会像函数fbuf()实现的那样,先将文件中的一块数据读出填充到文件对应的流缓冲区,以后若需要读取这一块数据中的信息时,就可以从对应的流缓冲区读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <fcntl.h>
#include "syscalls.h"
#define PERMS 0666 /*RW for owner,group,others */
/*fopen:open files,return file ptr */
FILE *fopen(char *name,char *mode)
{
int fd;
FILE *fp;

if (*mode != 'r' && *mode != 'w' && *mode != 'a')
return NULL;
for (fp =_iob;fp <iob OPEN_MAX;fp++)
if ((fp->flag & (_READ |_WRITE )) ==0)
break;/*found free slot */
if (fp >= _iob + OPEN_MAX) /*no free slots */
return NULL;
if (*mode ='w')
fd = creat(name,PERMS);
else if (*mode =='a')
{
if ((fd open(name,O_WRONLY,0))==-1)
fd creat(name,PERMS);
1seek(fd,OL,2);
}
else
fd open(name,O_RDONLY,0);
if(fd=-l)
return NULL;/*文件名name不存在*/
fp->fd = fd;
fp->cnt = 0;
fp->base = NULL;
fp->flag = (*mode =='r') ? _READ : _WRITE;
return fp;
}

用户程序中的I/O请求

在用户空间I/O软件中,用户程序可以使用C标准I/O库函数的方式提出I/O请求,也可以直接使用操作系统提供的系统级I/O函数或API函数提出I/O请求。

例如,对于一个简单的文件复制功能,可以使用多种不同的实现方式。之前的代码给出了两种实现方式,而如以下代码所示的是另一种方式,它使用C标准I/O库函数fread()和fwrite()来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>
#include <errno.h>

int main(int argc,char *argv[])
{
FILE *srcfile,*dstfile;
char buf[BUFSIZ];
size_t srcsize,dstsize;
if( argc != 3 )
{
printf("usage:copyfile srcfile dstfile\n")
return 1;
}
srcfile = fopen(argv[1],"rb");
if(srcfile =NULL )
{
perror(argv[1]);
return 2;
}
dstfile=fopen(argv[2],"wb");
if(dstfile =NULL )
{
perror(argv[2]);
return 3;
}
while ((srcsize=fread(buf,1,BUFSIZ,srcfile))>0){
dstsize = fwrite(buf,1,srcsize,dstfile);
if (dstsize !srcsize)
{
perror("Write Error.");
return 4;
}
}
fclose(srcfile);
fclose(dstfile);
return 0;
}

对于文件复制功能,还可用函数fgetc()和fputc()来实现,需要把以上代码中的while循环体改为

1
while (!feof (srcfile))fputc(fgetc(srcfile),dstfile);

在Windows系统中,除了使用C标准库函数实现以外,还可使用API函数ReadFile()和WriteFile()来实现文件复制功能。此外,操作系统还可能会提供一些更高级的API函数,它通过组合若干基本API函数而形成,是用于完成特定功能的、抽象度更高的API函数。例如,在Windows系统中,提供了一个特定的用于文件复制的函数CopyFile(),它通过调用基本API函数CreateFile()、ReadFile(O、WriteFile()和CloseHandle()来实现,用户程序可以直接使用CopyFile()函数来实现文件复制功能。

上面就文件复制功能列举了多种不同的实现方式,不管用户空间I/O软件通过何种方式来提出I/O请求,编译器和汇编器最终都会将I/O请求转换为以下若干条指令。

1
2
3
4
5
movl	$4,%eax	;系统调用号送EAX
movl 8(%esp),%ebx ;将第1个参数fd送EBX
movl 12(%esp),%ec× ;将第2个参数buf送ECX
movl 16(%esp),%edx ;将第3个参数n送EDX
int $0x80 ;进入系统调用处理程序system_ca11执行

其中,传送指令用来准备系统调用的调用号和参数,通常,调用号被传送到EAX寄存器,而参数被传送到其他通用寄存器。例如,在LA-32+Liux系统中,参数将依次被存放在EBX、ECX、EDX等通用寄存器中。也可以不将参数送到通用寄存器,而是将参数在栈中的起始地址送到特定的通用寄存器,这种做法在参数较多时比较合适。例如,在IA-32+Windows系统中就是这样传递参数的,通常将第一个参数在栈中的地址送到EDX寄存器,因为调用系
统调用封装函数时,参数已经按序压入栈中,所以,通过EDX可以访问到栈中所有参数。在传送指令后面是一条用于系统调用的陷阱指令,在IA-32+Linux系统中,通常是int $0x80,在IA-32+Windows系统中通常是int $0x2e指令。这两种系统也都可以用快速系统调用指令sysenter进行系统调用。

I/O硬件与软件的接口

用户空间I/O软件中的I/O请求最终是通过一条陷阱指令转入内核由内核空间中的I/O软件来控制I/O硬件完成的。因为内核空间中底层I/O软件的编写与I/O硬件的结构密切相关,所以在介绍内核空间的I/O软件之前,先介绍I/O硬件的基本组成。对于编写内核空间I/O软件的程序员来说,所关心的是I/O硬件中与软件的接口部分,因此,本节主要介绍与软件相关的I/O硬件部分,而不是介绍如何设计和制造I/O硬件的物理部件。

I/O硬件通常由机械部分和电子部分组成,并且两部分通常是可以分开的。机械部分是I/O设备本身,而电子部分则称为设备控制器或I/O适配器。

l/O设备

I/O设备又称外围设备、外部设备,简称外设,是计算机系统与人类或其他计算机系统之间进行信息交换的装置。操作系统为了更好地对I/O设备进行统一管理,通常将I/O设备分成两类:

  1. 字符设备

    字符设备是以字符为单位向主机发送或从主机接收一个字符流的设备。字符设备传送的字符流不能形成数据块,无法对其进行定位和寻址。
    通常,大多数输入设备和输出设备都可以看作是一种字符设备。输入设备的功能是把数据、命令、字符、图形、图像、声音或电流、电压等信息,以计算机可以接受或识别的二进制代码形式输入到计算机中,例如,键盘、鼠标、触摸屏、跟踪球、控制杆、数字化仪、扫描仪、手写笔、光学字符阅读机等都是输入设备;输出设备的功能是把计算机处理的结果,变成最终可以被人理解的数据、文字、图形、图像和声音等信息,例如,显示器、打印机和绘图仪等都是输出设备。
    还有一类主要用于计算机和计算机之间通信的设备,称为机-机通信设备,例如,网络接口、调制解调器、数/模和模/数转换器等。通常,大多数机-机通信设备也可看作是一种字符设备。

  2. 块设备

    块设备以一个固定大小的数据块为单位与主机交换信息,通常,外部存储器是块设备,例如,磁盘驱动器、固态硬盘、光盘驱动器和磁带机等。块设备中的数据块的大小通常在512字节以上,它按照某种组织方式被写入或读出设备,每个数据块都有唯一的位置信息,因而是可寻址的。典型的块设备是硬盘和固态硬盘。

操作系统将所有设备划分成字符设备和块设备两类,主要是为了便于操作系统对各种不同字符设备和不同块设备的共同特点进行抽象,从而在实现操作系统中的I/O软件时,可以尽可能多地划分出与设备无关的软件部分。例如,对于块设备,可以在文件系统只处理与设备无关的抽象块设备,而把与设备相关的部分放到更低层次的设备驱动程序中实现。

基于总线的互连结构

下图给出了一个传统的基于总线互连的计算机系统结构示意图,在其互连结构中,除了CPU、主存储器以及各种接插在主板扩展槽上的I/O控制卡(如声卡、视频卡)外,还有北桥芯片和南桥芯片。这两块超大规模集成电路芯片组成一个“芯片组”,是计算机中各个组成部分相互连接和通信的枢纽。主板上所有的存储器控制功能和I/O控制功能几乎都集成在芯片组内,它既实现了总线的功能,又提供了各种I/O接口及相关的控制功能。

  1. 北桥是一个主存控制器集线器(Memory Controller Hub,MCH)芯片,本质上是一个DMA(Direct Memory Access)控制器,因此,可通过MCH芯片,直接访问主存和显卡中的显存。
  2. 南桥是一个I/O控制器集线器(I/O Controller Hub,ICH)芯片,其中可以集成USB控制器、磁盘控制器、以太网络控制器等各种外设控制器,也可以通过南桥芯片引出若干主板扩展槽,用以接插一些I/O控制卡。

外设、设备控制器和CPU及主存的连接

CPU与主存之间由处理器总线(也称为前端总线)和存储器总线相连,各类I/O设备通过相应的设备控制器(例如,USB控制器、显示适配卡、磁盘控制器)连接到I/O总线上,而I/O总线通过芯片组与主存和CPU连接。

传统上,总线分为处理器-存储器总线和I/O总线。处理器-存储器总线比较短,通常是高速总线。有的系统将处理器总线和存储器总线分开,中间通过北桥芯片(桥接器)连接,CPU芯片通过CPU插座插在处理器总线上,内存条通过内存条插槽插在存储器总线上。

处理器总线

早期的Intel微处理器的处理器总线称为前端总线(Front Side Bus,FSB),它是主板上最快的总线,主要用于处理器与北桥芯片进行信息交换。

FSB的传输速率单位实际上是MT/s,表示每秒传输多少兆次。通常所说的总线传输速率单位MHz是习惯上的说法,实质是时钟频率单位。早期的FSB每个时钟周期传送一次数据,因此时钟频率与数据传输速率一致。但是,从Pentium Pro开始,FSB采用quad pumped技术在每个总线时钟周期内传4次数据,也就是说总线的数据传输速率等于总线时钟频率的4倍,若时钟频率为333MHz,则数据传输速率为1333MT/s,即1.333GT/s,但习惯上称1333MHz。例如,Intel Xeon 5400处理器的前端总线运行速度可以是266MHz(1066MT/s)、333MHz(1333MT/s)或者400MHz(1600MT/s)。若前端总线的工作频率为1333MHz(实际时钟频率为333MHz),总线宽度为64位,则总线带宽为10.66GB/s。对于多CPU芯片的多处理器系统,则多个CPU芯片通过一个FSB进行互连,也即多个处理器共享一个FSB。

Intel推出Core i7时,北桥芯片的功能被集成到了CPU芯片内,CPU通过存储器总线(即内存条插槽)直接和内存条相连,而在CPU芯片内部的核与核之间、CPU芯片与其他CPU芯片之间,以及CPU芯片与IOH(Input/Output Hub)芯片之间,则通过QPI(Quick Path Interconnect)总线相连。

从下图中可以看出,一个Core i7处理器中有4个CPU核(core),每两个核之间都用QPI总线互连,并且每个核还有一条QPI总线可以与IOH芯片互连。处理器支持三通道DDR3 SDRAM内存条插槽,因此,处理器中包含3个内存控制器,并有3个并行传输的存储器总线,也意味着有3组内存条插槽。

Core i7的层次化存储结构

QPI总线是一种基于包传输的串行高速点对点连接协议,采用差分信号与专门的时钟信号进行传输。QPI总线有20条数据线,发送方(TX)和接收方(RX)有各自的时钟信号,每个时钟周期传输两次。一个QPI数据包包含80位,需要两个时钟周期或4次传输,才能完成整个数据包的传送。在每次传输的20位数据中,有16位是有效数据,其余4位用于循环冗余校验,以提高系统的可靠性。由于QPI是双向的,在发送的同时也可以接收另一端传输来的数据,这样,每个QPI总线的带宽计算公式如下:
$$
每秒传输次数×每次传输的有效数据×2
$$
QPI总线的速度单位通常为GT/s,若QPI的时钟频率为2.4GHz,则速度为4.8GT/s,表示每秒传输4.8G次数据,并称该QPI频率为4.8GT/s。因此,QPI频率为4.8GT/s的总带宽为4.8GT/s×2B×2=19.2GB/s。QPI频率为6.4GT/s的总带宽为6.4GT/s×2B×2=25.6GB/s。

存储器总线

早期的存储器总线由北桥芯片控制,处理器通过北桥芯片和主存储器、图形卡(显卡)以及南桥芯片进行互连。Core i7以后的处理器芯片中集成了内存控制器,因而,存储器总线直接连接到处理器。

根据芯片组设计时确定的所能处理的主存类型的不同,存储器总线有不同的运行速度。如上图所示的计算机中,存储器总线宽度为64位,每秒传输1333M次,总线带宽为1333M×64/8=10.66(GB/s),因而3个通道的总带宽为32GB/s,与此配套的内存条型号为DDR3-1333。

I/O总线

I/O总线用于为系统中的各种I/O设备提供输入/输出通路,在物理上通常是主板上的些I/O扩展槽。早期的第一代I/O总线有XT总线、ISA总线、EISA总线、VESA总线,这些I/O总线早已经被淘汰;第二代I/O总线包括PCI、AGP、PCI-X;第三代I/O总线包括PCI-Express。

与前两代I/O总线采用并行传输的同步总线不同,PCl-Express总线采用串行传输方式。两个PCI-Express设备之间以一个链路(Link)相连,每个链路可包含多条通路(lane),可能的通路数为1、2、4、8、16或32,PCI-Express×n表示具有n个通路的PC-Express链路。

每条通路由发送和接收数据线构成,在发送和接收两个方向上都各有两条差分信号线,可同时发送和接收数据。在发送和接收过程中,每个数据字节实际上被转换成10位信息传输,以保证所有位都含有信号电平的跳变。这是因为在链路上没有专门的时钟信号,接收器使用锁相环(PLL)从进入的位流0-1和1-0跳变中恢复时钟。

PCI-Express1.0规范支持通路中每个方向的发送或者接收速率为2.5Gb/s。因此,PCI-Express1.0总线的总带宽计算公式(单位为GB/s)如下:
$$
2.5Gb/s×2×通路数/10(b/B)
$$
根据上述公式可知,在PCI-Express 1.0规范下,PCI-Express×1的总带宽为0.5GB/s;PCI-Express×2的总带宽为1GB/s;PCI-Express×16的总带宽为8GB/s。

将北桥芯片功能集成到CPU芯片后,主板上的芯片组不再是传统的三芯片结构(CPU+北桥+南桥)。根据不同的组合有多种主板芯片组结构,有的是双芯片结构(CPU+PCH),有的是三芯片结构(CPU+IOH+ICH)。其中,双芯片结构中的PCH(Platform Controller Hub)芯片除了包含原来南桥(ICH)的I/O控制器集线器的功能外,以前北桥中的图形显示控制单元管理引擎(Management Engine,ME)单元也集成到了PCH中,另外还包括NVRAM(Non-Vola
tile Random Access Memory)控制单元等。也就是说,PCH比以前南桥的功能要复杂得多。下图给出了一个基于Intel Core i7系列三芯片结构的单处理器计算机系统互连示意图。

基于Intel Core i7系列处理器的计算机互连结构

图中Core i7处理器芯片直接与三通道DDR3 SDRAM主存储器连接,并提供一个带宽为25.6GB/s的QPI总线,与基于X58芯片组的IOH芯片相连。图中每个通道的存储器总线带宽为64/8×533×2=8.5(GB/s),因此所配内存条速度为533MHz×2=1066MT/s。

IOH的重要功能是提供对PCI-Express2.0的支持,最多可支持36条PCI-Express2.0通路,可以配置为一个或两个PCI-Express2.0×16的链路,或者4个PCI-Express2.0×8的链路,或者其他的组合,如8个PCI-Express2.0×4的链路等。这些PCI-Express链路可以支持多个图形显示卡。

IOH与ICH芯片(ICH10或ICH10R)通过DMI(Direct Media Interface)总线连接。DMI采用点对点的连接方式,时钟频率为100MHz,因为上行与下行各有1GB/s的数据传输率,因此总带宽达到2GB/s。ICH芯片中集成了相对慢速的外设I/O接口,包括6个PCI-Express×1接口、10/100/1000Mbps网卡接口、集成声卡(HD Audio)、6个SATA硬盘控制接口和12个支持USB2.0标准的USB接口。若采用ICH1OR芯片,则还支持RAD功能,即ICH1OR芯片
中还包含RAID控制器,所支持的RAID等级有SATA RAID O、RAID1、RAID5、RAID10等。

外设的I/O接口又称设备控制器或I/O控制器、I/O控制接口,也称为l/O模块,是介于外设和I/O总线之间的部分,不同的外设往往对应不同的设备控制器。设备控制器通常独立于I/O设备,可以集成在主板上(即ICH芯片内)或以插卡的形式插在I/O总线扩展槽上。例如,磁盘控制器、以太网卡(网络控制器)、USB控制器、声卡、视频卡等都是一种I/O接口。

I/O接口的功能和结构

I/O接口根据从CPU接收到的控制命令来对相应外设进行控制。它在主机一侧与I/O总线相连,在外设一侧提供相应的连接器插座,在插座上连上相应的连接外设的电缆,就可以将外设通过设备控制器连接到主机。

I/O接口的主要职能包括以下几个方面。

  1. 数据缓冲。主存和CPU寄存器的存取速度都非常快,而外设的速度则较低,在设备控制器中引入数据缓冲寄存器后,输出数据时,CPU只要把数据送到数据缓冲寄存器即可;在输入数据时,CPU只要从数据缓冲寄存器取数即可。在设备控制器控制将数据缓冲寄存器的数据输出到外设或从外设读入数据时,CPU可以做其他事情。
  2. 错误和就绪检测。提供了错误和就绪检测逻辑,并将结果保存在状态寄存器,以供CPU查用。状态信息包括各类就绪和错误信息,如:外设是否完成打印或显示、是否准备好输入数据以供CPU读取、打印机是否发生缺纸、磁盘数据是否发生检验错等。
  3. 控制和定时。接收主机侧送来的控制信息和定时信号,根据相应的定时和控制逻辑,向外设发送控制信号,以控制外设进行相应的处理。主机送来的控制信息存放在控制寄存器中。
  4. 数据格式的转换。提供数据格式转换部件(如进行串-并转换的移位寄存器),使通过外部接口得到的数据转换为内部接口需要的格式,或在相反的方向进行数据格式转换。例如,从磁盘驱动器以二进制位的形式读出或写入后,在磁盘控制器中,应对读出的数据进行串-并转换,或对写入的数据进行并-串转换。

不同的I/O接口(设备控制器)在其复杂性和控制外设的数量上相差很大,不可能一一列举。下图给出了一个I/O接口的通用结构。

I/O接口(设备控制器)的通用结构

设备控制器中包含数据缓冲寄存器、状态/控制寄存器等多个不同的寄存器,用于存放外设与主机交换的数据信息、控制信息和状态信息。因为状态信息和控制信息在传送方向上是相反的,而且CPU对它们的访问在时间上也是错开的,所以,在有些设备控制器中将它们合二为一。

设备控制器是连接外设和主机的一个“桥梁”,它在外设侧和主机侧各有一个接口。设备控制器在主机侧通过I/O总线和主机相连,实现将控制信息送往控制寄存器、将状态寄存器中的状态信息取至CPU或在数据缓冲寄存器与CPU寄存器之间进行数据交换的功能;设备控制器在外设侧通过各种接口电缆(如USB线、网线、并行电缆等)和外设相连。因此,通过连接电缆、设备控制器、各类总线及其桥接器,可以在I/O硬件、主存和CPU之间建立一个信息传输“通路”。

有了设备控制器,底层I/O软件就可以通过设备控制器来控制外设,因而编写底层I/O软件的程序员只需要了解设备控制器的工作原理,包括:设备控制器中有哪些用户可访问的寄存器、控制/状态寄存器中每一位的含义、设备控制器与外设之间的通信协议等,而关于外设的机械特性,程序员则无须了解。

在底层I/O软件中,可以将控制命令送到控制寄存器来启动外设工作;可以读取状态寄存器来了解外设和设备控制器的状态;可以通过直接访问数据缓冲寄存器来进行数据的输入和输出。当然,这些对数据缓冲寄存器、控制/状态寄存器的访问操作是通过相应的指令来完成的,通常把这类指令称为l/O指令。因为这些I/O指令只能在操作系统内核的底层I/O软件中使用,因而它们是一种特权指令。

例如,在IA-32中,提供了4条专门的I/O指令:in、ins、out和outs。其中in和ins指令用于将设备控制器中某个寄存器的内容取到CPU内的通用寄存器中;out和outs用于将通用寄存器的内容输出到设备控制器的某个寄存器中。

I/O端口及其编址

通常把设备控制器中的数据缓冲寄存器、状态/控制寄存器等统称为I/O端口(I/O port)。数据缓冲寄存器简称为数据端口,状态/控制寄存器简称为状态/控制端口。为了便于CPU对外设的快速选择和对I/O端口的寻址,必须对I/O端口进行编址,所有I/O端口编号组成的空间称为I/O地址空间。I/O端口的编址方式有两种:统一编址方式和独立编址方式。

统一编址方式

统一编址方式下,I/O地址空间与主存地址空间统一编址,也即,将主存地址空间分出一部分地址给I/O端口进行编号,因为I/O端口和主存单元在同一个地址空间的不同分段中,因而根据地址范围就可区分访问的是I/O端口还是主存单元,因此也就无须设置专门的I/O指令,只要用一般的访存指令就可以存取I/O端口。因为这种方法是将I/O端口映射到主存空间的某个地址段上,所以也被称为“存储器映射方式”。

因为统一编址方式下I/O访问和主存访问共用同一组指令,所以它的保护机制可由分段或分页存储管理来实现,而不需要专门的保护机制。这种存储器映射方式给编程提供了非常大的灵活性。任何对内存存取的指令都可用来访问位于设备控制器中的I/O端口。例如,可用访存指令实现CPU中通用寄存器和I/O端口之间的数据传送;可用AND、OR或TEST等指令直接操作设备控制器中的控制寄存器或状态寄存器。

采用统一编址的另一个好处是便于扩大系统吞吐率,因为外设或I/O端口数目除了受总的可寻址空间大小的限制外,几乎不受其他因素的限制。这在大型控制或数据通信系统等特殊场合很有用。不过,因为I/O空间占用了一部分主存空间的地址,因而会减少可寻址的主存空间。此外,由于在识别I/O端口时全部地址线都需参与地址译码,使译码电路变复杂了,并需用较长时间进行地址译码,所以寻址时间变长了。

独立编址方式

独立编址方式对所有的I/O端口单独进行编号,使它们成为一个独立的I/O地址空间。这种情况下,指令系统中需要有专门的I/O指令来访问I/O端口,I/O指令中地址码部分给出I/O端口号。

独立编址方式中,I/O地址空间和主存地址空间是两个独立的地址空间,因而无法从地址码的形式上区分CPU访问的是I/O端口还是主存单元,故需用专门的I/O指令来表明访问的是I/O地址空间。CPU执行I/O指令时,会产生I/O读或I/O写总线事务,CPU通过I/O读或I/O写总线事务访问I/O端口。
通常,I/O端口数比存储器单元少得多,选择I/O端口时,只需少量地址线,因此,在设备控制器中的地址译码逻辑比较简单,寻址速度快。独立编址的另一好处是,因为使用专用I/O指令,使得程序的结构比较清晰,很容易判断出哪部分代码是用于I/O操作的,因而便于理解代码以及检查代码的正确性,不过,I/O指令往往只提供简单的传输操作,故程序设计的灵活性差一些。

IA-32采用独立编址方式,有专门的I/O指令:in(ins)和out(outs)。在IA-32中,I/O地址空间中共有65536个8位的I/O端口,可以把两个连续的8位端口看成一个16位端口。

I/O控制方式

通过连接电缆、设备控制器和各类总线及其桥接器,在CPU、主存和I/O硬件之间建立了一个信息传输“通路”。底层I/O软件利用这个“通路”,通过读写设备控制器中的各类寄存器来控制设备进行输入输出。

I/O操作可以有三种不同控制方式:程序直接控制、中断控制和DMA控制。

程序直接控制I/O方式

程序直接控制I/O方式的基本实现思想是,直接通过查询程序来控制主机和外设的数据交换,因此,也称为程序查询或轮询(polling)方式。该方式在查询程序中安排相应的I/O指令,通过这些指令直接向设备控制器传送控制命令,并从状态寄存器中取得外设和设备控制器的状态后,根据状态来控制外设和主机的数据交换。

下面以打印输出一个字符串为例来说明其基本原理。假定一个用户程序P中调用了某个I/O函数,请求在打印机上打印一个具有个字符的字符串。显然,用户进程P通过一系列过程调用后,会执行一个系统调用来打开打印机设备。若打印机空闲,则用户进程可正常使用打印机,因而用户进程就通过另一个系统调用来对打印机进行写操作,从而陷入操作系统内核进行字符串打印。

操作系统内核通常将用户进程缓冲区中的字符串首先复制到内核空间,然后查看打印机是否“就绪”。如果“就绪”,则将内核空间缓冲区中的一个字符输出到打印控制器的数据端口中,并发出“启动打印”命令,以控制打印机打印数据端口中的字符;如果打印机“未就绪”,则等待直到其“就绪”。上述过程循环执行,直到字符串中所有字符打印结束。下面的程序段大致描述了上述过程。

1
2
3
4
5
6
7
8
copy_string_to_kernel(strbuf,kernelbuf,n);	//将字符串复制到内核缓冲区
for(1=0;1<n;1++)
{ //对于每个打印字符循环执行
while (printer_status != READY); //等待直到打印机状态为“就绪”
*printer_data_port = kernelbuf[i]; //向数据端口输出一个字符
*printer_control_port = START; //发送“启动打印”命令
}
return(); //返回

打印机的“就绪”和“缺纸”等状态记录在打印控制器的状态端口中。“启动打印”命令被送到打印控制器的控制端口。打印控制器在每次打印完当前数据端口中的字符时,自动将“就绪”状态置1,以表明打印控制器中的数据端口已准备就绪,CPU可以向数据端口送入新的欲打印字符。

程序直接控制I/O方式的特点是简单、易控制、设备控制器中的控制电路简单。但是,CPU需要从设备控制器中读取状态信息,并在外设未就绪时一直处于忙等待。因为外设的速度比CPU慢得多,因此,在CPU等待外设完成任务的过程中浪费了大量的处理器时间。

中断控制/O方式

中断控制I/O方式的基本思想是,当需要进行I/O操作时,首先启动外设进行第一个数据的I/O操作,然后使CPU转去执行其他用户进程,而请求I/O的用户进程被阻塞。在CPU执行其他进程的过程中,外设在对应设备控制器的控制下进行数据的I/O操作。当外设完成I/O操作后,向CPU发送一个中断请求信号,CPU检测到有中断请求信号后,就暂停正在执行的进程,并调出相应的中断服务程序执行。CPU在中断服务程序中,再启动随后数据的I/O操作,然后返回到被打断的进程继续执行。

例如,对于上述请求打印字符串的用户进程P的例子,如果采用中断控制I/O方式,则操作系统处理I/O的过程如下所示。

1
2
3
4
5
6
copy_string_to_kernel(strbuf,kernelbuf,n);	//将字符串复制到内核缓冲区
enable_interrupts(); //开中断,允许外设发出中断请求
while (printer_status != READY); //等待直到打印机状态为“就绪”
*printer_data_port = kernbuf[i]; //向数据端口输出第一个字符
*printer_control_port = START; //发送“启动打印”命令
scheduler(); //阻塞用户进程P,调度其他进程执行

在“字符串打印”系统调用服务例程中启动打印机后,它就调用处理器调度程序scheduler来调出其他进程执行,而将用户进程P阻塞。在CPU执行其他进程的同时,打印机在进行打印操作,CPU和打印机并行工作。若打印机打印一个字符需要5ms,则在打印一个字符期间,其他进程可以在CPU上执行5ms的时间。对于程序直接控制I/O方式,CPU在这5s的时间内只是不断地查询打印机状态,因而整个系统的效率很低。

打印字符的中断服务程序为:

1
2
3
4
5
6
7
8
9
10
11
12
13
1f(n==0)	//若字符串打印完,则
{
unblock_user(); //用户进程P解除阻寒,P进入就绪队列
}
else
{
*printer_data_port = kernelbuf[i]; //向数据端口输出一个字符
*printer_control_port = START; //发送“启动打印”命令
n=n-1; //未打印字符数减1
i=1+1; //下一个打印字符指针加1
}
acknowledge_interrupt(); //中断回答(清除中断请求)
return_from_interrupt(); //中断返回

[!tip]

在多道程序(多任务)系统中,单个处理器可以被多个进程共享,即多个进程可以轮流使用处理器。为此,操作系统必须使用某种调度方法决定何时停止一个进程在处理器上的运行,转而使处理器运行另一个进程。操作系统中使用某种调度方法进行处理器调度的程序称为处理器调度程序。

简单来说,一个进程有三种状态:运行、就绪和阻塞。正在处理器上运行着的进程处于运行态;可以被调度到处理器运行但因为时间片到等原因被换下的进程处于就绪态;因为某种事件的发生而不能继续在处理器上运行的进程处于阻塞态。处于阻塞态的进程也称为被挂起,典型的处于阻塞态进程的例子就是等待I/O完成的进程,因为I/O操作没有完成的话,进程便无法继续运行下去。处于就绪态的进程可能有多个,为方便选择就绪态进程运行,通常将所有就绪态进程组成一个就绪队列,解除阻塞的进程可进入就绪队列。

中断控制I/O方式下,一旦外设完成任务,就会向CPU发中断请求。对于上面代码的例子,当打印机完成一个字符的打印后,就会发中断请求,然后CPU暂停正在执行的其他进程,调出“字符打印”中断服务程序来执行。在中断处理程序中,中断服务程序首先判断是否已完成字符串中所有字符的打印,若是,则将用户进程P解除阻塞,使其进入就绪队列;否则,就向数据端口送出下一个欲打印字符,并启动打印,将未打印字符数减1和下一个打印字符指针加1后,执行中断返回,回到被打断的进程继续执行。下图描述了中断控制I/O的整个过程。

中断控制I/O过程

CPU与外设并行工作

现代计算机系统的中断处理功能相当丰富,没有配置中断系统的计算机是令人无法想象的。每个计算机系统的中断功能可能不完全相同,但中断系统的基本功能无外乎以下几个方面:

  1. 及时记录各种中断请求信号,通常用一个中断请求寄存器来保存;
  2. 自动响应中断请求,CPU在每条指令执行完后,会自动检测中断请求引脚,发现有中断请求后会自动响应中断;
  3. 在同时有多个中断请求时,能自动选择响应优先级最高的中断请求;
  4. 保护被中断程序的断点和现场,断点指中断返回时返回到被中断程序继续执行时那条指令的地址,而现场则是指被中断程序所使用的通用寄存器的内容,断点由CPU保存,现场由中断服务程序保存;
  5. 通过中断屏蔽实现多重中断的嵌套执行,中断屏蔽功能通常用一个中断屏蔽字寄存器来实现。

现代计算机系统大多采用中断嵌套技术。也就是说,中断系统允许CPU在执行某个中断服务程序时,被新的中断请求打断。但是并不是所有的中断处理都可被新的中断打断,对于一些重要的紧急事件的处理,就要设置成不可被其他新中断事件打断,这就是中断屏蔽的概念。

中断系统中要有中断屏蔽机制,使得每个中断可以设置它允许被哪些中断打断,不允许被哪些中断打断。这个功能主要通过在中断系统中设置中断屏蔽字来实现。屏蔽字中的每一位对应某一个外设中断源,称为该中断源的中断屏蔽位,例如,用“1”表示允许中断,“0”表示不允许中断(即屏蔽中断)。CPU还可以通过在程序中执行相应的I/O指令来修改屏蔽字的内容,从而动态地改变中断处理的先后次序。中断系统的基本结构如下图所示。

中断系统的基本结构

从上图可以看出,来自各个外设的中断请求记录在中断请求寄存器中的对应位,每个中断源有各自对应的中断屏蔽字,在进行相应的中断处理之前它被送到中断屏蔽字寄存器中。在CPU执行程序代码时,每当完成当前指令的执行、取出下一条指令之前,都会通过采样中断请求信号引脚(如IA-32中的NTR)来自动查看有无中断请求信号。若有,则会发出一个相应的中断回答信号,启动“中断查询”线,在该信号线的作用下,所有未被屏蔽的中断请求信号一起送到一个中断判优电路中,判优电路根据中断响应优先级选择一个优先级最高的中断源,然后用一个编码器对该中断源进行编码,得到对应的中断源设备类型号(即中断源的标识信息,称为中断类型号)。CPU取得中断源的标识信息后,经过一系列相应的转换,就可得到对应的中断服务程序的首地址,在下一个指令周期开始,CPU执行相应的中断服务程序。

IA-32系统中,中断系统的功能通过可编程中断控制器(Programmable Interrupt Controller,PIC)实现。每个能够发出中断请求的外部设备控制器都有一条RQ线,所有外设的RQ线连到PIC对应的IRQ引脚:IRQ0、IRQ1、…、IRQi、…。如果某个IRQi引脚信号有效,则PIC的中断请求寄存器中对应的那一位被置1,从而将RQ请求信号记录在中断请求寄存器中。PIC对所有外设发来的IRQ请求按照优先级进行排队,如果至少有一个IRQ线有请求且未被屏蔽,则PIC向CPU的INTR引脚发中断请求。在中断处理(即执行中断服务程序)过程中,若又有新的处理优先级更高的中断请求发生,那么CPU应立即暂停正在执行的中断服务程序,转去处理新的中断,这种情况被称为多重中断或中断嵌套。

中断嵌套过程

假定在执行用户进程时,发生了1#中断请求,因为用户进程不屏蔽任何中断,所以就响应1#中断,将用户进程的断点保存在栈中,然后调出1#中断服务程序执行,而在处理1#中断的过程中,又发生了2#中断,而2#中断的处理优先级比1#高,也即1#中断的屏蔽字对2#中断是开放的,此时,暂停1#中断的处理,而响应2#中断,把1#中断的断点信息保存在栈中,调出2#中断的中断服务程序执行。同样,3#中断也可以打断2#中断的执行。当3#中断处理完返回时,系统从栈顶取出返回的断点信息,这样,从3#中断返回后,首先回到2#中断的断点(K3+1)处,而不是回到1#中断或用户进程执行。利用栈能正确地实现中断嵌套。

从上面描述的过程来看,中断系统中存在两种中断优先级。一种是中断响应优先级,另一种是中断处理优先级。中断响应优先级是由中断查询程序或硬件判优电路(中断判优电路)决定的优先权,它反映的是多个中断同时请求时选择哪个先被响应;而中断处理优先级是由各自的中断屏蔽字(中断屏蔽字寄存器的内容)来动态设定的,反映了本中断与其他所有中断之间的处理优先关系。在多重中断系统中通常用中断屏蔽字对中断处理优先权进行动态分配。

中断控制I/O方式下,每次执行中断服务程序仅处理一个数据的传送。例如,对于上述字符串打印的例子,每次中断都只打印一个字符,并且,为了响应中断请求和执行中断服务程序,CPU多执行了许多额外操作,包括保存断点、保存现场、开/关中断、设置中断屏蔽字等各种操作。对于像磁盘这种高速设备的I/O,如果采用中断控制方式,那么,由于外设数据传输速度快,因而频繁引起中断,而CPU响应和处理中断的额外开销又大,使得CPU用于设备I/O的时间百分比过大,影响整个系统的效率。

对于程序直接控制I/O方式,在外设准备数据时,由于CPU一直在等待外设完成,所以,在这个阶段CPU用于I/O的时间为100%。对于中断控制I/O方式,在外设准备数据时,CPU被安排执行其他程序,外设和CPU并行工作,因而CPU在外设准备数据时没有I/O开销,只有响应和处理中断而进行数据传送时CPU才需要花费时间为I/O服务。这就是中断控制I/O方式相对于程序直接控制I/O方式的优点。

但是,对于像硬盘这种高速外设的数据传送,如果还是用中断控制I/O方式的话,则CPU用于I/O的开销是无法忽视的。高速外设速度快,因而中断请求频率高,导致CPU被频繁地被打断,而且,由于需要保存断点和现场、开中断/关中断、设置中断屏蔽字等,使得中断响
应和中断处理的额外开销很大,因此,在高速外设情况下,采用中断控制/O方式传送数据是
不合适的,通常采用DMA控制l/O方式。

DMA控制I/O方式

DMA(Direct Memory Access,直接存储器访问)控制I/O方式用专门的DMA接口硬件来控制外设和主存之间的直接数据交换,数据不通过CPU。通常把专门用来控制数据在主存和外设之间直接传送的接口硬件称为DMA控制器。

DMA控制器与设备控制器一样,其中也有若干个寄存器,包括主存地址寄存器、设备地址寄存器、字计数器、控制寄存器等,还有其他的控制逻辑,它能控制设备通过总线与主存直接交换数据。在DMA传送前,应先对DMA控制器初始化,将需要传送的数据个数、数据所在设备地址以及主存首地址、数据传送的方向(从主存到外设,还是从外设到主存)等参数送到DMA控制器。

DMA控制II/O方式的基本思想是,首先对DMA控制器进行初始化,然后发送“启动DMA传送”命令以启动外设进行I/O操作,发送完“启动DMA传送”命令后,CPU转去执行其他进程,而请求I/O的用户进程被阻塞。在CPU执行其他进程的过程中,DMA控制器控制外设和主存进行数据交换。DMA控制器每完成一个数据的传送,就将字计数器减1,并修改主存地址,当字计数器为0时,完成所有I/O操作,此时,DMA控制器向CPU发送一个“DMA完成”中断请求信号,CPU检测到有中断请求信号后,就暂停正在执行的进程,并调出相应的中断服务程序执行。CPU在中断服务程序中,解除用户进程的阻塞状态而使用户进程进入就绪队列,然后中断返回,再回到被打断的进程继续执行。

DMA控制I/O方式的处理过程如下代码所示。

1
2
3
4
copy_string_to_kernel(strbuf,kernelbuf,n);	//将字符串复制到内核缓冲区
initialize_DMA(); //初始化DMA控制器(准备传送参数)
*DMA_control_port=START; //发送“启动DMA传送”命令
scheduler(); //阻塞用户进程,调度其他进程执行

中断服务函数为:

1
2
3
acknowledge_interrupt();	//中断回答(清除中断请求)
unblock_user(); //用户进程P解除阻塞,进入就绪队列
return_from_interrupt(); //中断返回

DMA控制I/O方式下,CPU只要在最初的DMA控制器初始化和最后处理“DMA结束”中断时介入,而在整个一块数据传送过程中都不需要CPU参与,因而CPU用于I/O的开销非常小。

DMA方式下,数据传送不消耗任何处理器周期,所以即使硬盘一直在进行I/O操作,CPU为它服务的时间也仅占0.15%。事实上,硬盘在大多数时间内并不进行数据传送,因此,CPU为I/O所花费的时间会更少。当然,如果CPU同时也要访问存储器的话,由于存储器用于DMA传送,因而CPU会被推迟与存储器交换数据。但通过使用cache,CPU可避免大多数访存冲突,因为CPU的大部分访存过程都在cache中进行,所以存储器带宽的大部分都可让给DMA使用。

内核空间I/O软件

所有用户程序中提出的I/O请求,最终都是通过系统调用实现的,通过系统调用封装函数中的陷阱指令转入内核空间的I/O软件执行。内核空间的I/O软件分三个层次:

  1. 与设备无关的I/O软件层
  2. 设备驱动程序层
  3. 中断服务程序层

其中,后两个层次与I/O硬件密切相关。

与设备无关的I/O软件

一旦通过陷阱指令调出系统调用处理程序(如Linux中的system_call)执行,就开始执行内核空间的I/O软件。首先执行的是与具体设备无关的I/O软件,主要完成所有设备公共的I/O功能,并向用户层软件提供一个统一的接口。通常,它包括以下几个部分:

  1. 设备驱动程序统一接口
  2. 缓冲处理
  3. 错误报告
  4. 打开与关闭文件以及逻辑块大小处理等。

设备驱动程序统一接口

对于某个外设具体的I/O操作,通常需要通过执行设备驱动程序来完成。而外设的种类繁多,控制接口不一致,导致不同外设的设备驱动程序千差万别。如果计算机系统中每次出现一种新的外设,都要为添加一种新设备驱动程序而修改操作系统,那么就会给操作系统开发者和系统用户带来很大的麻烦。

为此,操作系统为所有外设的设备驱动程序规定了一个统一的接口,新设备驱动程序只要按照统一的接口规范来编制,就可以在不修改操作系统的情况下,在系统中添加新设备驱动程序并使用新的外设进行I/O,因为采用了统一的设备驱动程序接口,因而,内核中与设备无关的I/O软件包含了所有外设统一的公共接口中的处理部分。例如,在Linux系统调用函数执行过程中,刚陷入内核后所执行的system_call()函数就是与设备无关的I/O软件的一部分。

为了简化对外设的处理,在内核高层I/O软件中,将所有外设都抽象成一个文件,设备名和文件名在形式上没有任何差别,因而被称为设备文件名。内核中与设备无关的I/O软件必须将不同的设备名和文件名映射到对应的设备驱动程序。例如,在UNIX/Linux系统中,一个设备名将能够唯一地确定一个特殊文件的节点。一个ⅰ节点中包含了主设备号,而主设备号可用于定位相应的设备驱动程序。ⅰ节点还包括次设备号,次设备号可作为参数传递给设备驱动程序,用来确定进行I/O操作的具体设备位置。有关文件管理和设备管理的细节请参看操作系统方面的参考资料。

[!note]

在UNIX/Linux系统中,除了普通文件和目录文件以外,还有一类特殊文件,包括设备文件、链接文件等,设备特殊文件包含块设备特殊文件和字符设备特殊文件等,前者主要用于磁盘类设备,后者主要用于各类输入/输出设备,如终端、打印机、网络等。

ⅰ节点是一个固定长度的表,包含对应文件相关的各种信息,如文件大小、文件所有者、文件访问权限,以及文件是普通文件、目录文件还是特殊文件等,在节点中最重要的一项是磁盘地址列表。

特殊设备文件对应的ⅰ节点中包含主设备号和次设备号,主设备号确定设备类型(如USB设备、硬盘设备),因而它被系统用来确定设备驱动程序,次设备号被驱动程序用来确定具体的设备。

缓冲区处理

用户进程在提出I/O请求时,指定的用来存放I/O数据的缓冲区在用户空间中。例如,文件读函数fread(buf,size,num,fp)中的缓冲区bf在用户空间中。通过陷阱指令陷入到内核态后,内核通常会在内核空间中再开辟一个或两个缓冲区,这样,在底层I/O软件控制设备进行I/O操作时,就直接使用内核空间中的缓冲区来存放I/O数据。为何不直接使用用户空间缓冲区呢?因为,如果直接使用用户空间缓冲区,那么,在外设进行I/O期间,由于用户进程被挂起而使用户空间的缓冲区所在页面可能被替换出去,这样就无法获得缓冲区中的I/O数据。其他原因包括:可解决不同块设备读写单位的不一致、便于共享等。

每个设备的I/O都需要使用缓冲区,因而缓冲区的申请和管理等处理是所有设备公共的,可以包含在与设备无关的I/O软件部分。

此外,为了充分利用数据访问的局部性特点,操作系统通常在内核空间开辟高速缓存,将大多数最近从块设备读出或写入的数据保存在作为高速缓存的RAM区中。与设备无关的/O软件会确定所请求的数据是否已经在高速缓存RAM中,如果存在的话,就可能不需要访问块设备。

错误报告

在用户进程中,通常要对所调用的I/O库函数返回的信息进行处理,有时返回的是错误码。例如,fopen()函数的返回值为NULL时,表示无法打开指定文件。

虽然很多错误与特定设备相关,必须由对应的设备驱动程序来处理,但是,所有I/O操作在内核态执行时所发生的错误信息,都是通过与设备无关的I/O软件返回给用户进程的,也就是说,错误处理的框架是与设备无关的。

有些错误属于编程错误。例如:

  1. 请求了某个不可能的I/O操作;
  2. 写信息到一个输入设备或从一个输出设备读信息;
  3. 指定了一个无效的缓冲区地址或者参数;
  4. 指定了不存在的设备。

这些错误信息由设备无关的I/O软件检测出来并直接返回给用户进程,无须再进入底层的I/O软件处理。

还有一类是I/O操作错误。例如:

  1. 写一个已被破坏的磁盘扇区;
  2. 打印机缺纸;
  3. 读一个已关闭的设备。

这些错误由相应的设备驱动程序检测出来并处理,若驱动程序无法处理,则驱动程序将错误信息返回给设备无关的I/O软件,再由设备无关的I/O软件返回给用户进程。

打开与关闭文件

对设备或文件进行打开或关闭等I/O函数所对应的系统调用,并不涉及具体的I/O操作,只要直接对RAM中的一些数据结构进行修改即可,这部分工作也是由设备无关软件来处理。

逻辑块大小的处理

为了为所有的块设备和所有的字符设备分别提供一个统一的抽象视图,以隐藏不同块设备或不同字符设备之间的差异,与设备无关的I/O软件为所有块设备或所有字符设备设置了统一的逻辑块大小。例如,对于块设备,不管磁盘扇区和光盘扇区有多大,所有逻辑数据块的大小相同,这样一来,高层I/O软件就只需要处理简化的抽象设备,从而在高层软件中简化了数据定位等处理。

设备驱动程序

设备驱动程序是与设备相关的I/O软件部分。每个设备驱动程序只处理一种外设或一类紧密相关的外设。每个外设或每类外设都有一个设备控制器,其中包含各种I/O端口。通过执行设备驱动程序,CPU可以向控制端口发送控制命令来启动外设,可以从状态端口读取状态来了解外设或设备控制器的状态,也可以从数据端口中读取数据或向数据端口发送数据等。显然,设备驱动程序中包含了许多I/O指令,通过执行I/O指令,CPU可以访问设备控制器中的I/O端口,从而控制外设的I/O操作。

根据设备所采用的I/O控制方式不同,设备驱动程序的实现方式不同。

  1. 若采用程序直接控制I/O方式,驱动程序的执行与外设的I/O操作完全串行,驱动程序一直等到全部完成用户程序的I/O请求后结束。驱动程序执行完成后,返回到与设备无关的I/O软件,最后,再返回到用户进程。这种情况下,用户进程在I/O过程中不会被阻塞,内核空间的I/O软件一直代表用户进程在内核态进行I/O处理。
  2. 若采用中断控制I/O方式,驱动程序启动第一次I/O操作后,将调用处理器调度程序scheduler()来调出其他进程执行,而申请I/O的进程被阻塞。在CPU执行其他进程的同时,外设进行I/O操作,此时,CPU和外设并行工作。当外设完成I/O任务时,再向CPU提出中断请求,CPU检测到中断请求后,会暂停正在执行的其他进程,转到一个中断服务程序去执行,以启动下一次I/O操作。
  3. 若采用DMA控制I/O方式,驱动程序对DMA控制器进行初始化后,便发送“启动DMA传送”命令,使设备控制器控制外设开始进行I/O操作,发送完启动命令后,将执行处理器调度程序scheduler(),使CPU转去其他进程执行,而申请I/O的进程被阻塞。DMA控制器完成所有I/O任务后,向
    CPU发送一个“DMA完成”中断请求信号。CPU在中断服务程序中,解除该进程的阻塞状态,然后中断返回。

中断控制I/O和DMA控制I/O两种方式下,在执行设备驱动程序过程中都会进行处理器调度,以使当前用户进程被阻塞;也都会产生中断请求信号,前者由设备在每完成一个数据的I/O后产生中断请求,后者由DMA控制器在完成整个数据块的I/O后产生中断请求。外设完成驱动程序要求的I/O操作后,设备控制器或DMA控制器会向CPU发出中断请求,从而调出中断服务程序执行。

中断服务程序

下图给出了整个中断过程,包括两个阶段:

  1. 中断响应:中断响应完全由硬件完成
  2. 中断处理:中断处理则由CPU执行一个中断服务程序完成

虽然不同的中断源对应的中断服务程序不同,但是所有中断服务程序的结构是相同的。中断服务程序包含三个阶段:

  1. 准备阶段
  2. 处理阶段
  3. 恢复阶段。

图中给出的是多重中断系统下的中断服务程序结构。从图中可以看出,在保存断点、保护现场和旧的屏蔽字、设置新屏蔽字的过程中,CPU一直处于“中断禁止(“关中断”)状态。CPU响应中断的第一件事就是关中断,即由CPU直接将中断允许触发器清0,在进行具体的中断服务之前,再通过执行“开中断”指令来使中断允许触发器置1,因此,在进行具体中断服务过程中,若有新的未被屏蔽的中断请求出现,则CPU可以响应新的中断请求。同样,在恢复阶段也要让CPU关中断,并在中断返回前开中断,在中断处理阶段的开中断和关中断的功能,都是通过CPU执行相应的“开中断”和“关中断”指令(在IA-32中分别为sti指令和cli指令)实现的。

如果在准备阶段和恢复阶段CPU处在“开中断”状态,那么有可能在断点保存、现场和屏蔽字的保护和恢复等过程中响应新中断,这样,断点或现场及屏蔽字等重要信息就会被新的中断信息破坏,因而不能回到原来的断点继续执行或因为现场或屏蔽字被破坏而不能正确执行。

中断服务程序的典型结构