CSAPP大作业

计算机系统

大作业

计算机科学与技术学院

2021年5月

摘  要

从一个简单的C语言程序代码hello.c出发,历经预处理,编译,汇编,链接,加载,进程管理,IO等等过程,最终成为计算机系统中活跃着的一个进程,在属于它的时间里,它虚拟地占有整个内存,调动它所需的所有资源,通过信号机制与其他进程交流,最终使用SIGCHLD向shell通知它的消亡.在它短暂的时间片中,hello走过了它的程序人生.

本文跟随hello的程序人生, 使用各种工具例如gcc,gdb,edb,ps,截取每一个步骤的执行过程和结果进行细致分析,从计算机系统的使用者,学习者甚至设计者,开发者的角度审视hello的程序人生,以及为它的生存而设计的机制,hello与和它共同存在,共同运行的程序之间的关系.从这次分析中可以学习到系统设计的思想和细节,并获得系统设计的基本知识和灵感.

关键词:计算机系统;GCC;Linux;进程;虚拟内存;IO;                           

目  录

第1章 概述... - 4 -

1.1 Hello简介... - 4 -

1.2 环境与工具... - 4 -

1.3 中间结果... - 4 -

1.4 本章小结... - 4 -

第2章 预处理... - 5 -

2.1 预处理的概念与作用... - 5 -

2.2在Ubuntu下预处理的命令... - 5 -

2.3 Hello的预处理结果解析... - 5 -

2.4 本章小结... - 7 -

第3章 编译... - 9 -

3.1 编译的概念与作用... - 9 -

3.2 在Ubuntu下编译的命令... - 9 -

3.3 Hello的编译结果解析... - 9 -

3.4 本章小结... - 15 -

第4章 汇编... - 16 -

4.1 汇编的概念与作用... - 16 -

4.2 在Ubuntu下汇编的命令... - 16 -

4.3 可重定位目标elf格式... - 16 -

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

4.5 本章小结... - 19 -

第5章 链接... - 20 -

5.1 链接的概念与作用... - 20 -

5.2 在Ubuntu下链接的命令... - 20 -

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

5.4 hello的虚拟地址空间... - 22 -

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

5.6 hello的执行流程... - 25 -

5.7 Hello的动态链接分析... - 25 -

5.8 本章小结... - 26 -

第6章 hello进程管理... - 28 -

6.1 进程的概念与作用... - 28 -

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

6.3 Hello的fork进程创建过程... - 28 -

6.4 Hello的execve过程... - 29 -

6.5 Hello的进程执行... - 29 -

6.6 hello的异常与信号处理... - 31 -

6.7本章小结... - 33 -

第7章 hello的存储管理... - 34 -

7.1 hello的存储器地址空间... - 34 -

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

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

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

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

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

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

7.8 缺页故障与缺页中断处理... - 41 -

7.9动态存储分配管理... - 41 -

7.10本章小结... - 43 -

第8章 hello的IO管理... - 44 -

8.1 Linux的IO设备管理方法... - 44 -

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

8.3 printf的实现分析... - 46 -

8.4 getchar的实现分析... - 47 -

8.5本章小结... - 48 -

结论... - 49 -

附件... - 50 -

参考文献... - 51 -

第1章 概述

1.1 Hello简介

P2P:From Process 2 Process

hello的一生经历了许多进程(process),它的雏形来自文本编辑器的进程,保存成.c文件后,它的编译又经历了GCC和其他许多进程,例如as,ld等,最后成为可执行文件的hello又被一直在我们面前的那个进程——shell加载成为一个新的进程.hello执行的过程中,又在动态链接器的帮助下逐渐完善.直到hello执行完毕,shell进程将其回收,它的一生就此结束.

020:From Zero 2 Zero

       在shell加载hello之前,对于系统来说hello程序的存在就是"0",直到shell执行fork创建子进程,在子进程中调用execve, execve首先清除子进程复制自父进程的内容,加载属于hello的代码,数据,为其分配堆,栈空间和.bss段空间.自此hello在它的虚拟内存空间中终于是">0"的了,对于hello映射到的那些请求二进制零,直到hello访问这些空间,它们的物理内存才">0".hello执行完毕后,被shell回收,它曾经占有的空间和资源都被释放,允许其他进程使用,对于操作系统hello又再次成为完全的"0".

1.2 环境与工具

虚拟机:VMware15

虚拟机设置:内存2GB, CPU4核.

系统:Ubuntu18.04

GCC版本:10

工具:gcc, gdb, edb, readelf, objdump, ps, pstree,

1.3 中间结果

hello.i:         hello.c的预编译结果

hello.s:         hello.c的编译结果

hello.o:         hello.c的编译结果

hello:            hello.o和必要库链接得到的可执行文件

hello.o.elf:   hello.o使用readelf -a得到的结果保存为文件

hello.elf:      hello使用readelf -a得到的结果保存为文件

precompile.c, precompile.h     :为测试预编译编写的文件

precompile.i:      precompile.c和precompile.h预编译的结果

第2章 预处理

2.1 预处理的概念与作用

预处理是将源代码中的宏和文件包含进行替换,以及处理各种预编译指令的过程.

宏替换:将形如#define NAME value的指令进行处理,把文件中NAME替换为value.

文件包含:将#include后面跟的文件替换到指令的位置.

预编译指令:将包括#ifdef, #ifndef, #else, #endif等条件进行解释和处理.

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i

2.3 Hello的预处理结果解析

图 1预编译结果

可以看到,只有最下面的几行是hello.c中的代码,并且原封不动的保留下来了,前面几千行都是来自include的stdio.h,unistd.h和stdlib.h.

下面用另一个例子来展示其他的预编译指令

图 2预编译指令测试代码

分别测试是否include precompile.h时预处理的结果:

图 3不使用(左)和使用(右)include指令的预处理结果

可以看到,第一,在第一次#define N和#undef N之后的第二次#define N后面的赋值语句有不同的右值,并且都是以直接替换的方式写入.i文件中;第二在没有include precompile.h时受ifndef控制的#define M 1生效,而引入后只有#define M 2生效.这说明引入文件的拼接先于define以及相关指令的处理,这正是在每个头文件中使用#ifndef防止重复引入的原理.

2.4 本章小结

预编译是编译程序的第一步,虽然只做了简单的文件拼接,宏替换和一些简单的逻辑分支处理,但预处理指令可以说是在C语言的框架之下实现多文件编程的基础之一.

第3章 编译

3.1 编译的概念与作用

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

编译过程将C代码编译成汇编代码,语句的正确性分析,语法分析,各种优化,翻译成汇编代码都在这一步完成.

本过程虽然不产生ELF格式文件,但已经在数据和指令的分节上给出了一些指示.另外,32位和64位编译从这一步开始产生分歧.

本过程的结果不涉及具体的地址,包括应该在.data节, .rodata节中的数据,以及call,jmp等的指令的操作数这些会用到地址的地方都用符号替代.

3.2 在Ubuntu下编译的命令

gcc -S -m64 -Og hello.i -o hello.s

图 4编译命令

3.3 Hello的编译结果解析

(注:以下汇编指令均以AT&T格式书写)

图 5编译结果

3.3.1 数据类型

根据处理指令的不同,我们将C语言数据类型分为以下几类,按照占用位置大小的不同还可以继续进行细分,这部分在数据操作的类型转换部分进一步介绍.

图 6数据类型分类

       由于计算和存储方法存在较大差异,整数类型和浮点数类型使用两套不同的寄存器.

图 7 64位机器整数寄存器[1]

       整数在一个汇编语言中可以有几种存在形式:立即数,寄存器内容,符号指向的内存中的值.

       立即数:直接呈现在汇编文件中的数,以$符号开头,可以使用十进制和十六进制表示,立即数通常来自在源代码中也直接呈现的数(包括预处理阶段替换的宏),例如:

       实际对应源代码中的for循环中的比较语句

       寄存器的内容:寄存器中有一些用于保存程序执行相关的数据,而不是单纯用于计算(rsp-栈指针,rip-程序计数器等),其他寄存器都可以用来进行整数处理和计算.寄存器中数的来源是寄存器和立即数/内存中数据的计算,终究来自其他两类.

       符号指向的内存中的值:这部分数随编译存储进可执行程序的文件中,并随程序加载进入内存,通常是初始化的全局变量和程序中出现的字符串字面量和程序编译链接过程中使用的数据结构.这部分数仍可以细分为可读可写的和仅可读的,具体的来源和作用上的差异在后面章节详解.

3.3.2 数据操作

       本节主要介绍几部分:算术运算,逻辑运算,赋值和类型转换,数组索引.

       算术运算:

      

指令

操作

add S,T

T←T+S

sub S,T

T←T-S

imul S,T

T←T*S(T,S为有符号数)

mul S,T

T←T*S(T,S为无符号数)

neg S

S←-S

       上面是常规的几种算术运算的指令,在实际使用时会在指令助记符最后标记操作数的大小,b-1字节,w-2字节,l-4字节,q-8字节,以下为一个例子

图 8算术运算

       一些简单的线性运算也会使用lea指令和内存地址表达式来计算:Imm(b,i,s)表示地址Imm+b+i*s,其中Imm,b,i都可以是寄存器的值,Imm还可以是立即数(不必加$),s取值1,2,4,8.

       逻辑运算:

指令

操作

not S

S←~S

and S,T

T←S&T

or S,T

T←S|T

xor S,T

T←S^T

sal k,S

S←S<

sar k,S

S←S>>k(算术右移)

shl k,S

S←S<

shr k,S

S←S>>k(逻辑右移)(以上4条k缺省时为1)

       以上为一些基本的逻辑运算,其中shl和sal互为别名,实际执行没有区别,sar和shr的区别在与右移时左边补符号位还是补0位,实际上分别实现了有符号数和无符号数的除2的k次幂.这也要求我们在需要将整数作为位向量,并需要提取其中若干位分析时应该采用无符号数声明.

指令

操作

cmp S,D

计算D-S,并设置条件码寄存器,不写入D

       比较指令只有这一条,可以在助记符最后加b,w,l,q来指示大小,其中的两个操作数S和D可以是立即数,寄存器,比较的原理是计算D-S,并设置条件码寄存器,但不改变D,S的值.

       赋值和类型转换:

指令

操作

mov[b,w,l,q] S,D

D←S(1,2,4,8字节)

movabsq S,D

D←S(8字节绝对值)

movzb[w,l,q]

从1字节到[2,4,8]字节的零扩展

movzw[l,q]

从2字节到[4,8]字节的零扩展

movsb[w,l,q]

从1字节到[2,4,8]字节的符号扩展

movsw[l,q]

从2字节到[4,8]字节的符号扩展

movslq

从4字节到8字节符号扩展

cltq

将%eax符号扩展到%rax

       赋值一般使用传送指令,而赋值操作可能包含隐性的类型转换,上面是不同大小的整数类型之间的隐性类型转换,包括零扩展和符号扩展,零扩展在高位补0,而符号扩展补符号位,注意到这些都是从小转换到大,从大转换到小的策略是直接抹去高位,这是不安全的,应该尽量避免.

       除此之外还有整数和浮点数之间的类型转换,按照IEEE754标准,从整数转换到浮点数,只需将整数写为±1.r×2^e的形式再将符号,r,e写到浮点数的对应位置即可,从浮点数到整数则截取整数部分写到整数中.由于int数有32位,而float数的尾数位只有23位,因此两个方向的转换都是存在风险的.

       数组索引:

       数组索引通常使用上面介绍的内存地址表达式:Imm(b,i,s),其中Imm,i,s都可以缺省,s缺省时为1,例如索引一个int数组v的第c个位置,那么可能用到以下的指令

       movq -0x20(%rbp), %rbx       #数组v首地址

       movq -0x24(%rbp), %rcx        #c的值

       leaq (%rbx, %rcx, 4), %rax     #计算v[c]的地址

       movl (%rax), %eax                  #读出v[c]

3.3.3 非计算操作

       非计算操作主要包括控制流操作,用户栈操作.

       控制流操作:

指令

操作

jmp T

将控制流转到指定位置

条件 jxx T

读取若干条件码并判断是否满足条件,若满足跳转到指定位置

call T

调用函数,先将rip压入栈后跳转到T(编译中T为符号)

ret

读取栈顶,返回该数指向的代码段位置

       控制流操作主要是各种跳转和函数调用和返回,条件跳转最常用的地方是分支和循环,在hello.s中是

图 9 hello中的循环

       先使用cmp设置条件码,再使用条件跳转判断接下来执行的指令是循环体还是循环之后的指令.

       函数调用和返回总是配对使用的(在用户代码中),这两个指令管理了函数的栈帧,在64位系统中栈帧结构如下:

图 10 64位系统栈帧

       用户栈操作:

指令

操作

push S

压入S(寄存器)的值

pop D

将栈顶值弹出到D寄存器

leaveq

将栈指针指向返回地址

sub/add w,%rsp

直接操控栈指针

       push和pop只能操作大小小于等于8字节的值,因此当需要给函数开辟大量空间时通常使用add直接操控rsp,当栈帧中的值不再重要时,通常使用leave或sub指令,抛弃原栈顶以下很多字节的值.

3.4 本章小结

编译过程将源代码翻译成汇编指令,在经过可能的一系列优化之后,程序的执行方法就确定下来了,虽然给予不同的参数,程序可能有不同执行路径,但在正常情况下,程序的行为不会超出现在的范围了.因此,这也是我们研究程序本身执行的最后一道关卡,下一步就会进入机器的领域,二进制的世界了.

第4章 汇编

4.1 汇编的概念与作用

       汇编将上一步得到的汇编代码翻译成二进制代码,并按照ELF格式存储.在本文件中对未定义的符号和不能确定地址的符号的引用会以一个重定位条目替代,等待链接阶段处理.

4.2 在Ubuntu下汇编的命令

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

4.3 可重定位目标elf格式

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

ELF节头:

图 11 hello.o节头

       .text节在4.4中具体分析

       .rela.text为.text节中的重定位条目

图 12 .text节重定位条目

       使用到了2种重定位条目,以第一个为例分析R_X86_64_PC32重定位条目.先找到了在偏移0x388的第一个重定位条目.

按照重定位条目结构分析,该条在.text节偏移为0x18,类型为0x2,符号为0x9(对应.LC0,一个字符串),补充偏移为-4,按照相对引用的计算方法,替换这32位的数值应该是ADDR(.LC0)+(-4)-(ADDR(.text)+0x18)=400748-4-(400652+18)=da,在链接后我们可以验证这个计算是正确的.

.data, .bss的size都为零,因为我们没有定义任何全局变量.

.rodata节存储了代码中用到但没有定义符号的两个字符串字面量.

.symtab为符号列表,包括用户和系统的数据和函数,这里看到,为两个字符串也定义了临时的符号.

4.4 Hello.o的结果解析

指令的机器码可以简单的分为指令码和操作数,操作数又可以分为寄存器指示符和常数字,一条指令所占的大小从1字节到15字节不等.由于x86-64为CISC指令集,指令种类数量都繁多,实际的机器码结构在上述的结构下又有许多位数上的变化,例如

这几个mov指令操作的数据大小都为8字节,

而这两个的数据大小为4字节,他们机器码之间的差别就相当大,这与x86-64需要兼容x86也有关.然而想要知道一条指令的具体结构,还需要参考Intel的文档.

可以看到,jxx的常操作数和实际跳转地址并不相同,jxx使用的是相对寻址,假设jxx的常操作数为C,那么实际跳转到的地址是C+%rip,%rip为执行该指令时的rip,也就是下一条指令的地址,使用相对寻址的原因是方便链接,不管指令的地址如何改变,与跳转目的地之间的距离是一定的,另外,如果跳转目标在jxx指令之前,那么相对距离用补码表示.

 57=15+42

 2b=5c-31

       call指令的操作数也是缺省的,因为call的地址计算也使用了相同的方法,事实上,一个call指令就相当于一次push %rip和一次jmp.

在反汇编得到的结果中,我们看到所有指令的类型和顺序与.s文件都是相同的,但是有一些指令的常数操作数还是0. -r选项为我们指出了这些空白对应的重定位条目,汇编的过程实际上将本文件中未定义的符号和地址未确定的符号以重定位条目的方式替换和保存,以便链接生成可执行文件时将这些确定下来,写入指令中缺省而以0填充的操作数.

4.5 本章小结

       除了翻译工作外,对各种符号的引用方式也在这一步确定,体现的方式就是重定位条目的类型.汇编过程还将各种指令和数据按照ELF格式分节组合成ELF文件,在这一步生成的.o文件类型为REL(可重定位目标文件).

5章 链接

5.1 链接的概念与作用

链接过程将一个或多个可重定位目标文件组织成一个整体,也就是一个可执行文件,它同样是一个ELF格式文件,这是程序被加载进入内存成为一个进程之前的最后一种形态.这个过程处理了所有重定位条目,对于静态库引用和文件中定义的符号引用,在重定位条目指向的位置写入地址,对于动态符号引用则整理好GOT和PLT表以供加载时使用.

5.2 在Ubuntu下链接的命令

ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/10/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/10/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello

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

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

图 13 hello的elf节头表

       从上面的节头表中可以看出各个节的起始地址,在文件中的位置偏移和大小.选取几个重要的节进行进一步分析.

       .dynsym为动态符号表,可以看出,此处保存了所有对动态库中符号的引用.下一个节.dynstr是符号的字符串表, .dynsym和.dynstr的关系类似.symtab和.strtab的关系.

图 14 .dynsym表

       .rela.dyn和.rela.plt为动态链接重定位条目和共享库重定位条目,在5.5中具体介绍.

       .plt为PLT表所在节,文件中存储的PLT表是加载运行前的初始状态,利用objdump反汇编查看,可以看出,PLT[0]此时指向GOT的一个表项,而GOT表会再将程序导向动态链接器,其他PLT表项此时都指向PLT[0],也就是动态链接器.

图 15 .plt节

       .text段存储程序的可执行机器码,除了用户代码之外,还包括许多系统代码,这些代码是链接阶段自动为程序加上的. .rodata, .data, .bss节都和.text节类似,是链接了所有用户数据和一些库的数据的结果.

图 16 节到程序段的映射

       程序段头表和从elf文件节到加载后的程序段的映射关系如上,其中Type为LOAD的两个段表示从ELF文件中加载,第3段的权限为可读可执行,包括.plt, .text, .rodata等节,第4段的权限为可读可写,包括.got, .data, .bss等节, 对应可执行程序虚拟地址空间中的代码段和数据段.

5.4 hello的虚拟地址空间

图 17 hello的虚拟地址空间分段信息

       前三行为hello所有,对应上一节提到的类型为LOAD的段,按照权限不同分为可读可执行,只读,可读可写的三部分,可以看到下面4行libc和ld占用的内存也有同样的结构,这部分内存是共享区域,在这之前还应该有堆区域,但hello没有用到malloc申请内存,故堆段为空,共享区域以下是栈段和系统映射区域.

       可以看出,可执行文件为程序提供的信息都包含在前3行所指示的虚拟内存空间中,而其他部分都由于内存使用效率等各种原因,在加载阶段动态地处理,并且依赖于可执行文件提供的信息.

5.5 链接的重定位过程分析

图 18 hello.o中重定位条目

图 19 hello的main

       hello.o中的重定位条目所指向的空指令操作符在hello中都被填入了相应的地址,对于静态符号引用填充了符号在最终程序中所在的地址,这一过程已经在本文4.3中具体分析过,对于动态符号的引用所填写的地址是PLT表中对应的条目,之后的调用就交由PLT机制和动态链接器处理,这一过程将在5.7中具体分析.

       除此之外,使用readelf -r hello指令可以看到,hello又有了一些新的重定位条目,通过偏移可以看出这些重定位条目在.got和.got.plt节中,他们是动态链接所用到的.

图 20 hello的重定位条目

      

5.6 hello的执行流程

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

首先执行的是动态链接器ld-2.27.so,依次调用的是

_start

_dl_start

_dl_start_user

      _dl_init

接下来执行hello,从调用_start开始

_start

      ld-2.27.so!__libc_start_main

             ld-2.27.so!__cxa_atexit

             hello!__libc_csu_init

                    hello!_init

             ld-2.27.so!_setjmp

             hello!main

main结束后调用ld-2.27.so!exit,程序结束执行

5.7 Hello的动态链接分析

图 21 加载前内存

       程序在执行_dl_start之前的内存状态如上,与执行main时相比没有libc的内容,执行call ld-2.27.so!_dl_start这一行后内存结构变成完整的状态.

在调用libc中的函数之前,查看.got.plt所在内存,发现每个函数对应的GOT条目指向它的PLT条目中push指令的位置.

图 22 未调用时的GOT表

       以call printf@plt这一行为例,控制流首先转到了printf对应的plt条目第一行,这个jmp指令又将控制转到该PLT条目对应的GOT条目所保存的地址,上面提到,在第一次调用时这个地址为PLT条目的第二行,也就是将PLT条目索引压入栈中之后调用PLT[0]. PLT[0]将GOT[1](link_map的地址)压入栈中并调用GOT[2](计算真正函数地址的函数_dl_runtime_resolve_xsavec).

图 23 printf的PLT条目

       在第二次及以后调用printf时,仍然先跳转到printf的PLT条目,然后跳转到它的GOT条目保存的地址,只不过此时这个地址就是本次运行中printf函数的地址,不需要再调用其他函数计算了.

图 24 第一次调用后的GOT表

5.8 本章小结

链接是C/C++多文件编程最重要的一环,它将多个文件中所有的符号串联起来,将所有独立的代码,数据单元连接在一起,并使它们完整,同时为动态加载做好数据结构和机制上的准备,这一切不仅使从项目的角度编程成为可能,还使"库"这一概念能够产生和得到利用,它的意义和重要性都是非凡的.

6章 hello进程管理

6.1 进程的概念与作用

进程是程序的一次执行,是一个执行中的程序的实例,由操作系统管理其包括创建,回收,上下文存储和切换等过程和运行,停止,终止,僵死等状态.

通过以进程的方式运行程序,系统可以管理各个进程使用资源的情况,这种统一调度可以带来很多好处,一是高效利用资源,包括存储设备和各种IO设备,二是防止死锁,三是实现了多任务.进程的概念与虚拟内存的结合使用还简化了链接和内存调度的过程,同时提高了内存的使用效率.

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

shell是用户和系统内核的交互程序,shell接受用户的指令输入,并执行相应的程序,shell可以是图形界面的,也可以是命令行式的.

命令行式shell接受用户输入,将其按照空格分隔为若干字符串,然后进行求值,之后调用第一个字符串指定的程序,并将其余字符串作为程序的输入参数,在执行程序前判断命令行结尾是不是&,如果是则让程序后台执行,否则shell等待前台进程执行直到终止.

6.3 Hello的fork进程创建过程

先介绍fork函数,fork函数原型如下:

pid_t fork(void);

       fork函数为当前进程创建一个子进程,这个子进程和父进程有相同的上下文和虚拟内存空间,也就是说,从程序执行的角度来看,两个进程具有相同的内存内容和控制流执行到的位置(%rip),唯一的区别是fork函数在两个进程中返回值不同,它在父进程中返回创建的子进程的pid,在子进程中返回0.在应用中经常通过判断fork的返回值来区别子进程和父进程的控制流转移,从而使子进程执行新的任务.

hello进程的来源是shell中调用fork函数产生的子进程,shell在解析输入命令后,判断出用户是要执行hello程序.接下来在子进程中shell就可以使用execve加载hello程序,并把用户输入中后面的字符串作为参数送给hello程序.在父进程中需要判断用户是不是要求程序在后台执行,如果前台运行,那么shell就等待子进程结束.

6.4 Hello的execve过程

先介绍execve函数,execve函数原型如下:

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

       第一个参数是要加载的程序的文件路径,第二个参数是传递给程序的参数的字符串列表,以NULL串结束,第三个参数是环境变量的字符串列表.如果执行成功execve不返回,如果执行失败返回-1,并将错误信息存储在errno中.

       execve函数清除进程中原有的虚拟内存空间,并加载新的程序,为其分配新的内存,同时将%rip指向程序的第一条指令的位置,加载完成后子进程就不再执行和父进程相同的任务,但进程号保持不变,这意味着父进程还可以调度子进程和进行进程间通信.

       shell在fork返回后判断当前进程是不是子进程,如果是就可以立刻使用execve加载hello了,shell可能执行类似如下的代码:

      

if(fork() == 0)

{

       if(execve("./hello",argv,env) == -1)

       {

              ... //错误处理

       }

}

       其中argv = {"./hello", "1190202025","李承豫","1",NULL},env为环境变量指针.

6.5 Hello的进程执行

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

首先阐述几个重要概念.

进程切换:系统暂时搁置当前进程,而去执行另外一个进程.系统会为每个等待执行的进程分配时间片,在某个进程的时间片里,CPU只处理这个进程,时间片结束后转去处理另外一个分配好时间片的进程.

进程状态:Linux中有7种常见的进程状态[4]:

      R:可执行状态,标识进程正在或已经准备好被执行.

      S:可中断的睡眠状态,标识进程正在等待某个事件发生,这时进程被放入事件的等待队列中,当事件发生时这些进程就转变为R状态.

      D:不可中断的睡眠状态,同样是等待某些事件发生的睡眠状态,但这个状态下的进程不能被异步信号中断例如kill -9, kill -15等,这个状态下的进程通常在等待IO,这是一个相对漫长的过程(相对CPU),因此它不能处于可执行状态耽误所有进程的执行,同时也不响应外来信号,否则程序就会被一段处理程序打断.

      T:停止或追踪状态,在进程接收SIGTSTP或SIGSTOP信号后进入此状态,此时程序不会被执行,在收到SIGCONT信号后才继续执行.

      Z:僵死状态,进程结束后等待父进程回收的状态

      X:终止状态,进程从系统中删除时的状态

进程挂起:由于资源受限,系统会把一些进程暂时调离内存,等待条件允许时再被调回内存继续执行.

内核态与用户态:进程执行的两种权限状态,内核态拥有最高权限,可以访问任意地址的内存,用户态只能访问该进程的用户的内存空间.从内核态到用户态的切换通常发生在如下的几种场景:

      系统调用:在用户主动调用内核程序时,会从用户态切换到内核态.

      异常:在一些异常发生,进入异常处理程序之前会进入内核态.

      外部中断:来自外部设备例如硬盘的中断信号后,进入相应的处理程序之前进入内核态.

在系统程序执行完后,从内核态切换回用户态.

       进程上下文:进程执行的环境,包括代码,内存数据,寄存器,用户堆栈,进程状态等,可以分为如下的几类[3]:

用户级上下文: 正文、数据、用户堆栈以及共享存储区;

寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);

系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈

       在进程切换中,上述的所有上下文都需要切换,而在状态切换时(用户态和内核态)只需要切换寄存器上下文.

       结合以上概念,我们来阐述进程调度的过程.进程调度包括进程切换和进程状态管理.一个进程首先被父进程产生出来,进入待执行的进程队列,并标记为可执行状态(R).在执行过程中它可能收到SIGINT,SIGTSTP等信号改变状态,可能因为等待一些过程执行进入睡眠,也可能是在执行完自己的时间片后,总之,将CPU的使用权交给另外一个进程,首先进入内核态,再调用系统程序将上下文切换为新进程的上下文,然后令CPU执行新进程的任务,这个过程就是进程切换.在一些事件发生后,例如停止的进程收到SIGCONT信号,进程所等待的事件发生(期待的信号,IO读写完成等),或重新进入这个进程的时间片等,CPU的使用权就会重新回到这个进程手中.在进程执行结束后,它就成为僵死进程等待回收.

       值得注意的是,回收进程这一过程并不一定由系统完成,而是由父进程完成,这依赖于接下来介绍的信号.

6.6 hello的异常与信号处理

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

程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps  jobs  pstree  fg  kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。

异常:异常用来提醒系统某些事件的发生,系统接收到异常之后就会执行相应的异常处理程序.异常可以分为4类:

中断:来自外围设备的异步信号,当设备给处理器发送中断信号后,处理器就执行对应的中断处理程序,完毕后回到接收信号之前的那条指令的下一条指令继续执行.

陷阱:陷阱是由一些特定指令触发的异常,最常见的陷阱是系统调用,系统调用将控制流转向系统服务,结束后返回系统调用指令的下一条指令.

故障:是由错误产生的异常,对于可以被故障处理程序修正的错误,处理完成后回到发生错误的指令,否则进入abort例程,终止故障的程序.

终止:由硬件错误等不可修复的错误产生,进入abort例程,终止程序.

       在hello中最多的异常应该是系统调用,由于没有和外存或其他IO设备的交互,可能不会发生中断,如果出现硬件错误就会触发终止.还有一种可能频繁出现的异常就是缺页中断,这是一种故障,当程序使用的虚拟内存页不在物理内存中时就会发生缺页中断,此时系统调用缺页中断处理程序,淘汰某一物理页并将程序要求的页加入物理内存,完成后返回到触发故障的那条指令重新执行,此时不会再发生异常.

       信号:信号是Linux的一种软件异常机制,一个信号是在进程中传递的一条消息,标志着某个事件的发生,进程捕捉到信号,并且处在该进程的用户态时就会执行相应的处理程序,与异常的处理程序不同的是,用户可以自己定义并指定一些信号的处理程序.

       每个进程保存为其所有的待处理信号向量和阻塞信号向量,当控制流转到进程的用户态时,就会依次调用未被阻塞的待处理信号的处理程序.需要注意的是,信号接收是没有排队的,也就是说除了第一次接收到的信号,之后接收到的同类型信号都不会被处理.

常用的信号有SIGINT(来自键盘中断),SIGQUIT(来自键盘的退出),SIGFPE(浮点运算异常),SIGKILL(杀死进程),SIGSEGV(段错误异常),SIGTERM(软件终止),SIGCHLD(子进程终止或停止)等.

       按下键盘Ctrl+C,向当前的前台进程组发送SIGINT信号,在hello程序中SIGINT使用默认处理程序,也就是结束程序执行.执行ps命令可以看到,已经没有hello这个进程了.

       按下键盘Ctrl+Z,向前台进程组发送SIGTSTP信号,使进程进入停止状态(T),可以通过ps看到,进程未被结束.使用-l选项来看到一些另外的信息,可以看到hello和ps的ppid(父进程ID)都是bash.

       调用jobs,可以看到现在只有一个用户作业,状态是已停止

       此时调用fg命令让hello回到前台,可以看到进程继续执行,在输出剩下的4行后就可以输入任意字符并结束了,再使用ps查看,发现已经没有hello进程.

       下面进行一系列操作,先执行进程,使用Ctrl+Z令其停止,然后使用kill向其发送SIGCONT信号,可以看到进程继续执行(此处bash的显示有些问题),再输入Ctrl+Z令其停止,然后向其发送SIGINT令其终止.

       在停止时使用ps可以看到进程的PID,以便使用kill向其发送信号,最后调用ps显示hello被杀死.

6.7本章小结

       进程,进程调度,异常,信号等等概念共同构成了一个进程系统,在这个进程系统内实现了多任务处理,解决了随之而来的内存管理,时间分配,资源管理等等问题.在这个进程系统之外留给了用户一个接口,用户可以通过这个接口查看,操控进程,也可以令系统执行一些进程,这就是shell.

7章 hello的存储管理

7.1 hello的存储器地址空间

程序中的地址经过了几个阶段的转换,从高级语言程序员编写的符号开始,首先变成逻辑符号,再转换成虚拟地址(线性地址),再转换成物理地址,最终到达存储设备,下面分别介绍这三种地址的概念.

逻辑地址:逻辑地址是一个由16位段选择符和16/32/64位偏移量组成的地址,意义是在某个段中的某个位置.使用objdump查看hello.o和hello的反汇编中的.text段, 我们可以看到许多16进制地址,这些都是逻辑地址.只不过是省去了前面的段寄存器信息.

图 25 hello.o反汇编片段

图 26 hello反汇编片段

       虚拟地址(线性地址):为了介绍虚拟地址和物理地址,先介绍地址空间,地址空间是一个非负整数地址的有序集合,例如{0,1,2,...,1023},如果一个地址空间中的数是连续的,那么把它称为一个线性空间.

       虚拟地址是虚拟地址空间中的一个地址.Linux使用了一些数据结构,使得一个程序的虚拟地址空间不一定是线性的,但它一定是一个线性空间的若干片段的并集,在64位系统中,这个线性空间是.

       物理地址:物理地址是存储器使用的地址,来自于物理地址空间,物理地址空间是一个线性空间,大小与设备有关.物理地址对于程序员是不可见的,它由硬件和软件系统共同处理,将逻辑地址转化为虚拟地址,再转化为物理地址.

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

如上所述,逻辑地址的前端是一个16位的段选择符,它不是偏移一样存储在文件中的,而是在运行时通过读取段寄存器来确定的.Intel CPU提供了6个段寄存器来缓存段选择符:cs,ss,ds,es,fs,gs.其中cs,ss,ds有专门的用途.

cs指向代码段,并且包含2位标志权限级别的域,从ring0到ring3依此降低,Linux将ring0作为内核态权限,将ring3作为用户态权限.ss指向用户栈段的起始,ds指向数据段的起始.

每个段的信息由一个8字节的段描述符来存储,段描述符要么存储在全局描述符表(GDT)里,要么存储在某个进程的本地描述符表(LDT)中.这两个表的起始地址分别保存在gdtr和ldtr寄存器里.段描述符保存了段起始的虚拟地址(线性地址)BASE,段的大小LIMIT,大小是否按字节表示G,该段是否为系统段S,段的类型TYPE,段的权限级别DPL,段是否在内存中P.

图 27 段描述符格式[2]

从逻辑地址变为虚拟地址的过程包括:读取寄存器获得段选择符;从GDT/LDT中读出段的信息,判断能否访问该地址;将段起始地址和偏移相加得到虚拟地址.

段选择符用来找到段描述符,包含三个域:索引Index,位置TI(GDT或LDT),请求者权限RPL,先读取TI,判断是从GDT还是LDT中读取,然后读表中第Index个段描述符,根据权限,偏移是否超出段的大小等等判断能否访问这个地址,最后将段描述符中的起始地址和偏移相加得到虚拟地址.[2]

图 28 段选择符格式[2]

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

虚拟内存机制使用了一种叫做页表的数据结构,页表描述了虚拟地址到物理地址的映射关系.

首先,将虚拟地址空间和物理地址空间分为大小相同的页,通常是4K.在内存中存储着一个页表,一个页表条目对应着一个虚拟页,这个对应是线性的,将一个虚拟地址的高几位作为虚拟页号(VPN),低位作为虚拟页偏移(VPO),这个虚拟地址所在页对应的页表条目就是索引为虚拟页号的那一个,即将页表基址寄存器中保存的地址加上虚拟页号就能得到对应的页表条目.

图 29 虚拟地址分段

一个页表条目保存了该条目的有效位,该虚拟页的权限和对应的物理页首地址,一个页表条目也即一个虚拟页可能有几种状态:未分配,该页未被分配对应的物理页,由于虚拟地址空间比物理地址空间大得多,这种情况实际上占据了大多数,这时页表条目中存储NULL;未缓存,该页已经被分配了一个物理页,但当时该物理页未在内存中,而在外存设备中,此时页表条目保存这个物理页所在的外存地址,当需要使用本页中的内存时,就要选择一个内存中的物理页淘汰掉,并加载本页以继续使用;已缓存,该虚拟页对应的物理页此时就在内存中,页表条目中保存着该物理页的首地址.

当确定一个虚拟页的物理页首字节所在的物理地址后,就可以使用上面得到的虚拟页偏移作为物理页偏移与物理页首地址相加,就得到一个虚拟地址对应的物理地址.

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

TLB:由于每次地址翻译都需要读取一个页表条目,如果连续读取相冲突的页表条目 (在多级cache中),可能造成很大程度上的浪费,因此有必要单独设计一个缓存来存储页表条目,它应该有较高的相连度.为此,我们对虚拟地址进行再分,将VPN分为组索引和标记.

图 30 使用TLB的虚拟地址分段

       使用TLB,读取页表条目的过程就和从多级cache系统读取内存很类似,首先查找组索引为TLBI的组中有没有标记为TLBT的条目,如果有则命中,如果没有则按照一定策略淘汰一个条目,并从内存中读取需要的条目.

       为了保证速度,TLB使用了十分高速的存储设备,因此容量不大,且没有使用多级缓存.

       多级页表:多级页表解决了页表条目过多的问题,以64位系统计算,假设一个页表条目需要8字节空间,那么64位系统虚拟地址空间所需要的页表总大小就是2PB的内存空间,明显不可能为页表分配如此大的空间,而且这些页表条目绝大部分是未分配的,没必要储存,因此设计了多级页表以树的方式管理页表.

       令树的一个节点占据恰好一页的空间,如果一个条目为8字节,那么一个节点就能保存512个条目,多级页表中仅有叶节点保存实际的页表条目,而非叶节点的条目都保存了下一层节点的地址.

图 31 多级页表简单图示

       通过简单的计算,可以知道4级页表恰好满足48位虚拟地址,页大小为4K的情况,使用多级页表时地址分割如下.

图 32 使用多级页表的虚拟地址分段

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

三级cache指的是在CPU寄存器和物理内存之间的三个高速存储设备,称为L1,L2,L3,他们的容量依次增大,并且都使用相对高速的存储设备,例如SRAM(相对内存的较低速的DRAM).

cache按照缓存条目组织内存,最小的存取单位称为块,为了方便读写,从L1到L3的块大小都是相同的.一个缓存条目由若干个块(通常是1个块)组成,并且每个条目有若干位的标志位和1位的有效位,一个缓存组由若干个条目组成,整个缓存由若干个组组成,如下图.

图 33 cache的组织示意图

       cache的几个基本参数包括块大小/行(条目)大小B,此处认为一行只有一个块,相联度(一个组中有几个条目)E,组数S,标志位位数t,总容量C.下图为使用CPU-Z查看的机器三级缓存信息.

图 34 缓存信息

cache的寻址机制:假设令某一级cache处理一个m位物理内存地址,它首先把m位分为3部分,分别占t,s,b位,其中,如下图.

图 35 地址分割方式

将整个缓存看作一个组的数组,中间的s位组索引选择了一个组,接下来将前面t位和这个组中每一个行的标志位进行对比,如果找到一个标志位相同且有效的行,那么这次寻址就称为命中,接下来只需从找到的行中找到地址最后b位给出的偏移所指示的位置继续读/写即可.如果没有找到标志相同且有效的行,那么这次寻址就未命中,接下来需要选择本组中的一个行淘汰掉,并向下一级缓存请求这个地址指向的块,放入淘汰行中,再继续读/写.淘汰行根据其是否被修改选择是否需要向下一级缓存写.

三级cache的寻址就是从L1到主存逐级寻址,如果在上一级缓存中未找到就到下一级中寻找,L3的下一级就是主存.如果内存中也找不到,那么将触发缺页中断,在后面具体介绍.

cache的读:读的情况比较简单,如果在读时寻址命中,那么就将数据读入CPU,如果未命中,就试图从下一级缓存中寻找,并写入该级缓存中由特定算法决定的淘汰行中.

cache的写:在写时,cache面临两个问题.一是当命中时,如何使下一级缓存中相对应的值也修改;二是当不命中时,如何完成写的操作.

对于第一个问题,有两种策略,直写策略直接向下一级写修改后的内容;写回策略等待需要淘汰被修改的行时才把它写到下一级缓存,这样就需要每一行再增加一个修改位,以便决定淘汰时是否真的需要写到下一级.

对于第二个问题同样有两种策略,写分配和非写分配,写分配策略从下一级缓存中将被写块提取到该级缓存中并写,非写分配直接向下一级缓存写.写分配策略通常和写回策略搭配使用,非写分配通常和直写策略搭配使用.

7.6 hello进程fork时的内存映射

利用虚拟内存保护内存的功能,可以管理进程对某个内存区域的读写权限.对一个进程来说,有两种内存映射,映射私有对象和映射共享对象(对象指存储在外存中的文件).共享对象可以被所有进程共享,每个进程可以将不同的虚拟内存区域映射到相同的共享文件,并且都拥有对它的读写权限.然而当多个进程都将同一对象映射为私有对象时冲突就产生了,下面介绍写时复制的技术来解决这个冲突.

当多个进程将同一个对象映射为私有对象,一开始物理内存中只为他们保存该对象的一个副本,当某个进程向这个对象中写时,先申请一个新的物理页,将原对象中被写的部分复制到新页中再继续写,并修改页表使得被写的虚拟页映射到新的物理页中.如下图.

图 36 写时复制示意图

前面介绍过,fork产生的子进程与父进程有相同但独立的内存空间,所以开始时父进程和子进程有完全相同的虚拟内存映射,并将所有私有对象标记为只读和写时复制,只有父进程或子进程向私有对象写时才触发写时复制.

7.7 hello进程execve时的内存映射

execve函数加载并执行一个新的程序,所以它

  1. 首先删除原来进程的所有用户内存映射区域.
  2. 然后重新设置私有对象的映射,包括.text, .data节等映射到elf文件的部分和用户堆栈, .bss节等映射到匿名文件的部分.
  3. 然后映射共享区域,也就是映射到共享库文件等
  4. 最后设置rip到程序的入口处,这个入口的虚拟地址是写在elf文件中的.

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

缺页是当某个虚拟内存页对应的物理页不在物理内存中时发生的故障,出现的原因是这个物理页曾经被淘汰,交换到了外存中.这个故障发生后,调用缺页故障处理程序,将需要的页交换回内存中.

一个虚拟地址在进行地址翻译时读取它所对应的页表条目,如果此时页表条目标记为未缓存则发生了缺页故障,异常控制流进入缺页异常处理函数,它选择一个页淘汰掉,如果这个页被修改过那么就将其写回外存,然后将需要的页从外存读进物理内存,最后将控制流还给发生缺页异常的那条指令继续执行.由于在物理内存(DRAM)和外存之间的读写速度很慢,因此当DRAM作为缓存时通常把它看做是全相联的,同时采取相对复杂的替换算法,例如最近最少使用(LRU)或最不常使用(LFU)等.

7.9动态存储分配管理

为了面对用户在运行时使用多个不同大小的内存空间的需求,进程专门留出一块虚拟空间来存放这些内存块,称为堆.在管理堆的内存时需要解决几个问题:

  1. 堆内存从何而来;
  2. 如何交给用户一个至少有他需要的大小的内存块;
  3. 如何管理未被分配的内存.

对于第一个问题,堆的虚拟空间同样映射到一个私有对象,只不过这个文件并不是由用户创建和管理的文件,而是由系统创建出来专门存储堆数据的匿名文件,包含的内容全部是二进制0,要注意的是在磁盘中并不实际存在这个文件,当进程第一次使用它的页时,就选择一个淘汰页将其全部写为0,并将此页标记为常驻内存的,从而并未与外存发生任何交互.当堆空间不足或可以缩小时可以调用void *sbrk(intptr_t incr);调整堆的大小.

对于第二个问题,首先为了CPU读取的高效,分配给用户的内存应该是8字节对齐的(在64位系统中),也就是每个分配块的起始地址和大小都应是8的倍数.除此之外,为了堆内存的管理,分配的内存可能有一个最小值,具体细节在讨论第三个问题时讨论.这两个要求将可能导致用户得到多于他需求的内存,此时这些多出来的内存不能再次被分配给用户,这就造成了一种内存空间的浪费,称为内部碎片.

对于第三个问题,存在许多的解决策略.首先要认识到,堆内存可以按照块的方式组织,一个块中可以包含大小不定的内存,但他们的状态要么全部是分配给用户的,要么全部是空闲的,按照上面的叙述,他们还应该是8字节对齐的.于是这些内存块就可以分为两种:分配块和空闲块.其中分配块是由用户的申请产生的,并且用户拥有释放它们,也就是使它们成为空闲块的责任.而空闲块由动态存储分配器管理,下面介绍一些管理空闲块的方法.

第一种方法使用链表管理空闲块,根据链表节点的结构不同又可以分为隐式空闲链表和显式空闲链表.隐式空闲链表使用一个字(4字节)来存储这个块的大小和分配状态,称为头标记,由于大小总是8的倍数,所以它的最低3位恒为0,可以使用他们来存储额外的信息,例如用最后一位存储这个块是被分配的(a),还是空闲的(f).然而这时只能按照向后的方向访问链表,所以我们又在块的负载之后加一个字作为尾标记,这样就可以方便地读取前一个块的大小从而向前遍历链表.隐式空闲链表中分配块和空闲块采用相同的结构,由一个链表串联所有的分配块和空闲块.节点结构如下.

图 37 隐式空闲链表节点

       显示空闲链表给空闲块增加两个字的内容,存储上一个和下一个空闲块的地址,这样链表中就可以只连接空闲块,而不必管理分配块,这会大大提高查找合适大小的空闲块的速度.空闲块排列的方式通常使用LIFO(后入先出)顺序或地址大小顺序,其中LIFO顺序就是总将用户归还的空闲块放在链表头部.另外,显示链表还可以使用分离存储的技术,就是按照块的大小将空闲块分为多个链表存储和管理,这也能提高查找空闲块的速度.

       使用链表管理内存在查找合适大小的空闲块时有几种不同的方法.首次适配方法从头遍历链表,直到找到大于要求的大小的块,从中分割需要的大小,标记为分配块,并将剩下部分标记为空闲块(如果有);下一次适配方法保存每次分配后的块的位置,在下一次分配时不是从头开始,而是从上次分配结束的位置开始找合适大小的块并分割;最佳适配方法遍历整个链表,找到大小最接近要求大小的块.查找并分割空闲块的最优程度影响内存的空间占用率,如果查找得不够好,就可能分割出一些不能被再使用的小空闲块,这些被称为外部碎片.一般来说,外部碎片的数量最优适配<首次适配<下一次适配,然而查找速度顺序相反.

       由于头,尾标记和前后向指针的存在,隐式空闲链表的块大小最小为2个字,显示空闲链表的块大小最小为4个字.

       第二种方法使用平衡树管理空闲块,例如红黑树,它可以将从n个空闲块中查找合适大小空闲块的时间复杂度降低到O(log(n)),而使用链表总归是线性时间复杂度.

7.10本章小结

计算机的存储器系统分为许多层次,每个层次都不相同,相邻层次之间的关系和读写方式也不相同,我们用存储器山来概括这个庞大的存储器系统.

图 38 存储器山及寻址过程

       一次简单的访问数据经历了非常复杂的"地址下山"和"数据上山"过程,最终山顶端的CPU利用数据完成一次计算.从山顶到山脚,存储设备体现出速度降低,造价降低和容量增加的趋势,正因如此,即使是相似的过程也可能使用完全不同的处理方法.例如cache的相联度一般不高,TLB的相联度就更高,物理内存作为缓存时甚至当作全相联缓存处理,他们的淘汰算法也差别很大.正是由于对不同情况选择了更优的处理,才使不同的存储设备协调工作,使任务的完成更加高效.

8章 hello的IO管理

8.1 Linux的IO设备管理方法

以下格式自行编排,编辑时删除

设备的模型化:文件

设备管理:unix io接口

众所周知,在Linux中一切皆文件,在文件系统中不论各种文件还是目录都作为文件管理,所有的IO设备也看作文件管理.下面介绍文件的有关概念.

对Unix系统来说文件分为几种类型:

普通文件:保存任意数据的文件,对应用可以分为文本文件和二进制文件,但从系统的视角来看都是相同的.

目录文件:包含一组链接,一个链接将一个文件名映射到一个文件,映射到的可以是另一个目录.

套接字文件:用来和另外一个进程进行网络通信的文件.

设备文件:代表IO设备存在于文件系统中的文件,是设备驱动程序的接口.按照读写有无缓冲可以分为块设备文件和字符设备文件.

使用stat命令可以查看文件的元数据,包括文件名,文件大小,类型,权限,用户ID(Uid),用户组ID(Gid),最近访问时间,最近更改时间,创建时间等.

图 39 文件元数据

       进程使用文件描述符表和系统的打开文件表和v-node表联合使用管理进程对文件的读写.

       每个进程管理一个文件描述符表,保存它打开的文件,文件描述符的每一行代表一个文件,指向系统的打开文件表中的表项.每个进程默认打开三个文件STDIN标准输入,STDOUT标准输出和STDERR标准错误输出.

       打开文件表保存了所有进程打开的文件,每个表项包括当前的文件位置,引用计数(所有进程中指向它的文件描述符表项的数量),以及一个指向v-node表中对应表项的指针,一个进程修改它的文件描述符表(打开或关闭文件)时,对应的文件表项中的引用计数也会修改,内核只在一个表项的引用计数为0时删除这个表项.

       v-node表也由所有进程共享,一个v-node表项包含文件的大多数元数据.

       通过这个机制,可以方便地实现多进程共享文件,单个进程打开多个相同文件,输入输出重定向等功能.

8.2 简述Unix IO接口及其函数

Unix IO是Linux内核提供的一种简单,低级的读写文件的方法.包括几个功能:

  1. 打开文件
  2. 设置当前文件位置
  3. 读写文件
  4. 关闭文件

下面逐个分析Unix IO的函数

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

字符串filename描述了文件的路径,有两种方式:相对路径和绝对路径,相对路径是从当前工作路径开始的,绝对路径是从根目录开始的.

flags指明了以何方式访问这个文件,代表打开方式的宏有

  1. O_RDONLY:只读
  2. O_WRONLY:只写
  3. O_RDWR:可读可写
  4. O_CREAT:如果文件不存在就创建一个空的新文件
  5. O_TRUNC:如果文件存在就截断它
  6. O_APPEND:在每次写操作之前将文件位置指向文件尾,也就是追加写.

在这些flag中,不冲突的可以通过|连接作为参数传递给open,可以以多种方式访问文件.

mode通过一些宏指定新创建文件的权限.

open的返回值是文件描述符数字.

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

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

       读和写的形式比较相似,第一个参数fd是open得到的文件描述符数字,表示读/写到的文件,第二个参数是读到/要写的内存位置的指针,第三个参数是读/写的最大数量.

       read和write都在成功时返回读/写的字节数,在失败时返回-1,特别地,当read读到EOF时结束读的过程并返回0.

int close(int fd);

       close函数关闭fd代表的文件,从进程的文件描述符表中删除它.成功返回0,失败返回-1.

8.3 printf的实现分析

printf是C语言标准IO库函数,实现了格式化输出到STDOUT的功能.我们将调用printf直到屏幕上显示文字的过程按操作系统的边界分开分析研究.

8.3.1 OS之上

首先分析printf的源码:

int printf(const char *fmt, ...)

{

int i;

char buf[256];

   va_list arg = (va_list)((char*)(&fmt) + 4);

   i = vsprintf(buf, fmt, arg);

   write(buf, i);

  

   return i;

}

参数列表中的"..."表示printf函数可以接受不确定数量的参数,从函数参数的栈帧结构我们可以推断出va_list arg = (va_list)((char*)(&fmt) + 4);这一行是将arg指向除格式串之外的第一个参数,也就是被格式化数据的第一个.

接下来使用缓冲区buf,格式串fmt和上面讲到的arg传给vsprintf函数,这个函数将fmt中的格式标签替换为arg指向的对应的数据,并写到buf中,最终返回实际写了的字节数.这个函数的实现并没有使用到IO,我们不做具体分析.

printf函数接下来调用write函数,将buf的i字节写到标准输出.明显用户并没有直接操控IO读写的权限,因此write的工作就是显式地使用int指令调用系统调用,给OS发出写到屏幕的请求.

write:

     mov eax, _NR_write

     mov ebx, [esp + 4]

     mov ecx, [esp + 8]

     int INT_VECTOR_SYS_CALL

write函数实际上使用int INT_VECTOR_SYS_CALL将控制流转到这个系统调用对应的处理函数,而这个函数又调用sys_call函数,sys_call是内核代码,在其中就可以真正向现实设备写数据了.

操作系统管理了一系列字体,每个字体都保存了一定样式的各种字符的点阵数据,也就是字模库.在向屏幕输出字符是,需要一个程序接受ascii码输入,找到一定字体中对应的字符点阵,并将读到的RGB数据写到显存(VRAM)中[5].

8.3.2 OS之下

       至此,连接用户和硬件的操作系统的工作就都完成了,它现在可以去调度进程进行其他工作,直至显示设备准备好下一次显示再进行下一次显示内容的计算.与此同时,IO设备正在以缓慢的速度(相对CPU)处理和传输显示数据,完成将RGB数据显示在屏幕上的工作.

       现在可使用的屏幕种类十分繁多,但从原理上讲都使用了逐行扫描的思路,也就是屏幕一次接收一行上各像素的显示数据(例如RGB数据)并显示在屏幕上,接下来再接收下一行并显示,最终按照一定频率完整地刷新一次所有行,称为屏幕的刷新率.

       printf的最后一步就是刷新屏幕,使字符显示在屏幕上.在屏幕和显卡之间存在一系列总线,从显存中逐行读取显示数据传输给屏幕并显示.

8.4 getchar的实现分析

以下格式自行编排,编辑时删除

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

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

getchar是C标准IO库函数,从键盘缓冲区中读取一个字符并返回.首先介绍键盘缓冲区:

键盘缓冲区是BIOS管理的一块内存,它保存键盘输入但未被处理的字符.当键盘的一个键被按下时,键盘就向主板上的处理芯片发送一个扫描码,来说明哪个键被按下,处理芯片收到一个扫描码就向CPU发送一个中断,CPU执行完当前指令就会转去执行键盘中断的处理程序,读取扫描码,判断输入的是何字符并作相应处理,最后存入键盘缓冲区.键盘中断是一个可屏蔽中断[8].

getchar()是一个宏定义

#define getchar() getc(stdin)[6]

getc也是C标准IO,它是一种有缓冲读取,并且利用了C标准库的流的处理方法.getc也是宏,它的定义如下[7].

#define getc(_stream) (--(_stream)->_cnt >= 0 ? 0xff & *(_stream)->_ptr++ : _filbuf(_stream))

它的意义是当数据存储区域剩余字符的数目大于0的时候,减少一个剩余字符计数,返回0xff & *(_stream)->_ptr++,否则,调用_filbuf(_stream)._filbuf的功能是调用C标准IO中的read函数,从文件流中读取一定大小的内容填满缓冲区.

默认情况下stdin就是键盘输入,也就是键盘缓冲区,所以getchar的功能就是从键盘缓冲区中读取一个字符并返回

8.5本章小结

       IO是CPU和计算机中其他硬件交互的途径,在大多数计算机中都存在北桥,南桥芯片组管理IO,控制总线传输信息.其中北桥芯片组主要负责高速IO设备通信,南桥主要负责低速IO设备通信.总线包括数据总线,指令总线,地址总线等,相当于计算机系统中的高速公路,在传输信息的同时还可以管理信息的来源和去向.

       操作系统为用户提供了使用IO的方法,即Unix IO接口.在此之上又有许多开发人员编写了适合于不同应用场景的IO函数,例如更健壮的RIO和将文件看作流的C标准IO等,它们还引入了缓冲机制来提高读写效率.

       总之,IO通过软件和硬件的结合,实现了整个计算机系统的联通,也实现了用户和计算机的联结.

结论

首先用一个图对hello的程序人生做一个总结.

图 40 hello的程序人生

       纵观hello的一生,它在出生前就被赋予了使命,我们开发者用我们的语言编写了hello的任务,赋予它程序生涯的意义,之后将我们的语言翻译为hello的世界通用的语言:某一指令集规定的指令代码和二进制数据.当我们需要时,hello就被他的父进程产生出来,开始执行我们赋予它的任务.

尽管hello并不能准确地知道它身处何方,但是进程调度机制和虚拟内存机制能够让它"错误"地认为他一直拥有整个计算机的所有资源,从而它可以自由地完成自己的任务.在hello的生涯中,它不断地读写内存中,磁盘中或是外围设备通过IO传来的数据,并利用CPU进行计算,当计算机系统中有某些异常事件发生时(包括中断,系统调用,信号等),它就急忙使用紧急措施解决问题,在此过程中hello不断丰富自己,逐步完成任务.然而我们并不希望它拥有过大的自由,不然它不仅可能完不成任务,还可能破坏其他程序乃至系统的运行,因此我们在访问内存,执行指令等方面都设置了权限,阻止hello的僭越行为.

最终hello的任务完成,也就是它寿终正寝的时候了,父进程shell回收它和它曾用过的资源,在系统中hello就像不曾存在一样,然而他确实完成了它的使命(或者不幸遭遇意外没有完成),我们纪念hello.

附件

hello.i:         hello.c的预编译结果

hello.s:         hello.c的编译结果

hello.o:         hello.c的编译结果

hello:            hello.o和必要库链接得到的可执行文件

hello.o.elf:   hello.o使用readelf -a得到的结果保存为文件

hello.elf:      hello使用readelf -a得到的结果保存为文件

precompile.c, precompile.h     :为测试预编译编写的文件

precompile.i:      precompile.c和precompile.h预编译的结果

参考文献

  1. 深入理解计算机系统.
  2. Linux内存寻址之二:逻辑地址到虚拟地址的转换_Smith先生的博客-CSDN博客
  3. 进程切换(进程上下文和中断上下文)详解_mw_nice的博客-CSDN博客_中断上下文
  4. Linux 进程状态 说明_Dave的博客-CSDN博客
  5. 字符显示器的显示控制过程
  6. getchar()函数 - 发展才是硬道理 - 博客园
  7. 走进C标准库(3)——"stdio.h"中的getc和ungetc - 彼岸在脚下 - 博客园
  8. 第六篇 键盘中断与应用程序读取键盘缓冲区_lulipeng_cpp的博客-CSDN博客

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值