本论文的目的是,通过hello程序了解计算机系统概念的实现。其内容为分析预处理、编译、汇编和链接等的概念和结果。了解进程相关的概念以及进程是如何管理的。类似的分析了解存储管理和IO管理方面。本文的研究的方法是,首先引入它们概念的定义,其次自己虚拟系统上实验测试,然后分析其结果,如果需要,引用图标和运行结果的截图。所以深入理解计算机系统概念部分对一个实际程序中的运用。虽然hello是一个简单些的代码组成的程序,但是通过它的分析,提示很多的系统的思想。它的思想就将一切软硬件设备组织到好处,程序运行的背后是代码的流动,充满的思想。
关键词:程序;存储;进程;编译;计算机系统;
目 录
第1章 概述........................................................................................ - 4 -
1.1 Hello简介................................................................................. - 4 -
1.2 环境与工具................................................................................ - 4 -
1.3 中间结果.................................................................................... - 4 -
1.4 本章小结.................................................................................... - 5 -
第2章 预处理.................................................................................... - 6 -
2.1 预处理的概念与作用................................................................ - 6 -
2.2在Ubuntu下预处理的命令..................................................... - 6 -
2.3 Hello的预处理结果解析......................................................... - 6 -
2.4 本章小结.................................................................................... - 7 -
第3章 编译........................................................................................ - 8 -
3.1 编译的概念与作用.................................................................... - 8 -
3.2 在Ubuntu下编译的命令......................................................... - 8 -
3.3 Hello的编译结果解析............................................................. - 8 -
3.4 本章小结.................................................................................. - 12 -
第4章 汇编...................................................................................... - 13 -
4.1 汇编的概念与作用.................................................................. - 13 -
4.2 在Ubuntu下汇编的命令....................................................... - 13 -
4.3 可重定位目标elf格式........................................................... - 13 -
4.4 Hello.o的结果解析................................................................ - 15 -
4.5 本章小结.................................................................................. - 16 -
第5章 链接...................................................................................... - 17 -
5.1 链接的概念与作用.................................................................. - 17 -
5.2 在Ubuntu下链接的命令....................................................... - 17 -
5.3 可执行目标文件hello的格式.............................................. - 17 -
5.4 hello的虚拟地址空间............................................................ - 18 -
5.5 链接的重定位过程分析........................................................... -19 -
5.6 hello的执行流程.................................................................... - 20 -
5.7 Hello的动态链接分析........................................................... - 20 -
5.8 本章小结.................................................................................. - 20 -
第6章 hello进程管理............................................................... - 21 -
6.1 进程的概念与作用.................................................................. - 21 -
6.2 简述壳Shell-bash的作用与处理流程................................ - 21 -
6.3 Hello的fork进程创建过程................................................. - 21 -
6.4 Hello的execve过程............................................................. - 21 -
6.5 Hello的进程执行................................................................... - 22 -
6.6 hello的异常与信号处理........................................................ - 22 -
6.7本章小结................................................................................... - 24 -
第7章 hello的存储管理........................................................... - 26 -
7.1 hello的存储器地址空间........................................................ - 26 -
7.2 Intel逻辑地址到线性地址的变换-段式管理....................... - 26 -
7.3 Hello的线性地址到物理地址的变换-页式管理.................. - 26 -
7.4 TLB与四级页表支持下的VA到PA的变换........................ - 28 -
7.5 三级Cache支持下的物理内存访问..................................... - 28 -
7.6 hello进程fork时的内存映射.............................................. - 29 -
7.7 hello进程execve时的内存映射.......................................... - 29 -
7.8 缺页故障与缺页中断处理...................................................... - 30 -
7.9动态存储分配管理................................................................... - 30 -
7.10本章小结................................................................................. - 31 -
第8章 hello的IO管理............................................................ - 32 -
8.1 Linux的IO设备管理方法...................................................... - 32 -
8.2 简述Unix IO接口及其函数................................................... - 32 -
8.3 printf的实现分析................................................................... - 33 -
8.4 getchar的实现分析............................................................... - 35 -
8.5本章小结................................................................................... - 35 -
第1章 概述
1.1 Hello简介
hello程序键盘输入代码,得到.c文件首先进行预处理。编译得到汇编语言的代码,其次进行汇编处理生成目标文件。与库函数进行链接和重定位之后形成可执行文件。它在shell中运行和转入命令参数。shell通过fork函数,把hello变成一个进程。再通过execve执行此进程。CPU让hello运行跟其他程序,执行逻辑控制流。当程序终止后,父进程shell回收其进程,释放相关数据结构及占用内存空间。
1.2 环境与工具
处理器 Intel(R) Core(TM) i3-6100U CPU @ 2.30GHz,2304 Mhz,2 个内核,4 个逻辑处理器,已安装的物理内存(RAM) 4.00 GB。
操作系统名称 Microsoft Windows 10 企业版
Oracle VM VirtualBox 版本 5.2.18 r124319 (Qt5.6.2)
Ubuntu 16.04 LTS 64 位/
GDB;OBJDUMP;READELF;CodeBlocks 64位;gcc;HexEdit;
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 说明作用 |
hello | 连接后的可执行目标文件 |
hello.c | 测试的hello程序源代码文件 |
hello.elf | hello.o文件的ELF格式文件 |
hello.i | 预处理之后文本文件 |
hello.o | 汇编之后的可重定位目标执行文件 |
hello.objdump | hello的反汇编代码 |
hello.s | 编译之后的汇编文件 |
linked_hello.elf | hello文件的ELF格式文件 |
1.4 本章小结
本章简单介绍hello程序的编译,执行,操作系统相关事件等p2p,020过程。除此之外列出用户的软硬环境、中间结果。
第2章 预处理
2.1 预处理的概念与作用
预处理器cpp根据以字符#开头的命令,修改原始的C程序,例如#include、#define、#if等。将引用的所有库展开合并成为一个文本文件。预处理器实现在编译器进行编译前,对源代码做些转换。
2.2在Ubuntu下预处理的命令
预处理命令:gcc –E hello.c –o hello.i
图2.1 使用gcc命令生成.i文件
2.3 Hello的预处理结果解析
以stdio.h的展开为例,将stdio.h的内容引入,例如声明函数、定义结构体、定义变量、定义宏等内容。预处理实现在编译前对代码的初步处理。cpp 递归展开,还会对条件值进行判断来决定时候执行包含其中的逻辑。如下图所示:
图2.2 使用vim查看.i文件(main函数部分)
2.4 本章小结
本章介绍的是预处理器相关的定义及作用等概念。结合预处理后的程序进行解析,从而预处理是编译前对代码进行转换等处理。
第3章 编译
3.1 编译的概念与作用
编译是指编译器ccl将文本文件hello.i翻译成文本文件hello.s。它包含一个汇编语言程序。编译器的流程为词法分析、语法分析和语义分析过程。分析检查后无错误,则生成目标代码。其汇编语言的代码可以进行生成机器代码、链接等操作。
3.2 在Ubuntu下编译的命令
编译命令:gcc –E hello.i –o hello.s
图3.1 用gcc命令生成.s文件
3.3 Hello的编译结果解析
3.3.1 数据
sleepsecs:在程序中sleepsecs声明为全局变量,且已经赋值。在.data节声明该变量,.data节存放静态变量和已初始化的全局变量。首先在.text段声明全局变量sleepsecs,然后在.data段分配大小为4字节、long类型其值大小为2。
图3.2 sleepsecs的定义段节
i:在源代码中i是main函数里生命的int类型的数据。编译器编译后,在.s文件里可以看到,存放在栈上空间-4(%rbp)中,占用4B的空间。
还有第一个参数传入int类型的argc,立即数。
字符串:"Usage: Hello 学号 姓名!\n"和"Hello %s %s\n",前者在.s文件中都声明在只读数据段.rodata。
图3.3
.s文件中字符串声明段
数组:char *argv[],就是main函数执行时输入的命令行,它存放char指针的数组。单个元素的大小为8Byte,按照起始地址计算数据地址取数据。在.s文件中通过两次(%rax)操作,取出其值。
图3.4 .s文件中数组
3.3.2 类型
编译器通过申请不同大小的空间来实现类型。不同类型的数据存储方式也不同。sleepsecs将浮点数类(默认为double)型的2.5转换为int类型,按照向零舍入的原则,将它转换为4字节空间的值2。
3.3.3 赋值
编译器赋值操作时用mov指令。处理全局变量sleepsecs时,直接在.data节中声明long类型数据。i=0的时候,i是4个字节的int类型,所以movl进行赋值。
3.3.4 算术操作
算数操作有很多种,加ADD、减SUB、乘IMUL、除DIV,LEA等。它们指令具有后缀,根据它可以分辨不同的数据大小。hello程序中i++,通过指令addl实现,后缀l表示4字节。汇编中的leaq操作,计算LC1的短地址%rip+.LC1并传递给%rdi。
图3.5
算术操作
3.3.5 关系操作
程序中i<10,编译器在.s文件中cmpl指令实现,通过jmp指令进行跳转。关系操作的汇编指令还有TEST(测试-设置条件码)、SETxx(按照xx将条件码设置D)和Jxx(根据后缀与条件码进行跳转)。
图3.6 关系操作
3.3.6 控制操作
C语言包含一些关键词,continue if switch break else goto while等。它们都涉及控制转移。在hello程序里if(argc!=3),argc和常数3进行比较,如果不满足条件,则通过je指令判断ZF标志位,直接跳转到下面的操作。还有for(i=0;i<10;i++),使用计数变量循环10次。首先给i赋值为0,跳转cmpl指令进行比较。如果i小
于等于9,则跳转继续执行循环,否则循环结束。顺序执行以后的指令。
图3.7 控制操作
3.3.7 函数操作
在hello程序中涉及函数操作有,main函数。main函数被调用call,call指令将下一条指令的地址dest压栈,然后跳转到main函数。外部调用过程向main函数传递参数argc(%rdi)和argv(%rsi)。函数正常出口为return 0,将%eax设置0返回。使用%rbp记录栈帧的底,函数分配栈帧空间在%rbp上。最后调用leave指令,它相当于movl %rbp %rsp和pop %rbp。恢复栈空间为调用之前的状态。通过ret指令,将吓一跳要执行指令的地址设置为dest。程序中的函数操作还有printf函数、exit函数、sleep函数和getchar函数等。
图3.8 通用的栈帧结构
3.4 本章小结
本章介绍编译器的编译操作,如何处理各种数据类型及各种操作。通过这些处理和转换,成为从高级语言到底层的机器语言之间的重要的内容。
第4章 汇编
4.1 汇编的概念与作用
汇编是指汇编器(as)将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中。.o文件时一个二进制文件,包含程序的机器指令编码。作用是,实现将汇编代码转换为机器指令,使之在连接后能够被计算机直接执行。
4.2 在Ubuntu下汇编的命令
汇编指令:gcc –c –no-pie –fno-PIC hello.s –o hello.o
图4.1 使用gcc指令生成.o文件
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先readelf指令获得elf格式的文件。
图4.2 使用readelf指令获得.o文件的elf格式
图4.3 典型的ELF可重定位目标文件
文件组成的部分为,
ELF头:描述生成该文件的系统字的大小和字节顺序
图4.4 hello.o的ELF头(ELF Header)
.text:已编译程序的机器代码
.rodata:只读数据
.data:已初始化的全局和静态C变量
.bss:未初始化的全局和静态C遍历
.symtab:存放程序中定义和引用的函数和全局变量信息
图4.5 hello.o的.symtab
.rel.text:一个.text节中位置的列表,连接时修改
.rel.data:被模块引用或定义的所有全局变量的重定位信息
.debug:条目是局部变量、类型定义、全局变量及C源代码
.line:C源程序中行号和.text节机器指令的映射
.strtab:.symtab和.debug中符号表及街头部分中节的名字
节头部分:描述目标文件的节
图4.6 hello.o的节头部分(Section Header)
图4.7 hello.o的重定位节(.real.text)
4.4 Hello.o的结果解析
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
机器语言的构成的方面看,操作码与操作数组成,操作码与汇编语言符号有对应关系。由于操作树类型,汇编语言符号可能对应着不同的机器语言操作码。相对寻址需要经过处理,跟绝对地址或常数的情况不一样。
与汇编映射关系方面来讲,反汇编的结果通过一个地址表示,这跟编译生成的代码跳转目标通过.Li(i=1,...)不一样的点。反汇编的结果还包含可重定位目标文件中的机器语言代码及注解,一些相对寻址的信息和重定位信息。
机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用:主要的差别,分支转移——上述类似的,段名称.Li只是在汇编语言中便于编写的记号,所以生成机器语言之后的反汇编里不存在这些记号,而存在确定的地址。函数调用——汇编语言中,函数调用之后直接跟着函数的名称。而在机器语言的构成中,call的目标地址是当前下一条指令,因为.c文件中调用的是库中的函数。经过链接后才能确定函数的地址。
图4.8 用objdump生成的hello.o的反汇编、.o文件
4.5 本章小结
本章介绍了汇编过程相关的内容,通过.o文件的ELF格式和反汇编的代码与.s文件对照,分析机器语言和汇编语言的不一致。还有汇编语言映射到机器语言时需要实现转换。这种对照让用户了解前者和后者存在的映射关系,而且能够反映机器执行程序的逻辑。
第5章 链接
5.1 链接的概念与作用
链接是将文件中调用的函数、库收集并链接成一个单独文件的过程,它可以加载到内存并执行。连接器的程序执行链接,连接后实现将头文件中引用的函数装入到程序中。
5.2 在Ubuntu下链接的命令
连接命令:ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2
/usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o
/usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
图5.1 使用ld命令链接生成hello可执行程序
5.3 可执行目标文件hello的格式
使用readelf命令生成hello程序的ELF格式文件。其结果如图5.2和图5.3所示,特别是图5.3中的Section Header展示程序中的所有节信息,包括起始地址(Address行),大小(Name下面的Size行和Offset行)。
图5.2 ELF头(ELF Header)
图5.3 Setion Header
5.4 hello的虚拟地址空间
我的机器上一开始安装edb-debugger时一直出错,因此使用gdb和objdump来加载程序及查看进程的虚拟地址空间的各段信息。使用readelf命令查看ELF格式文件时,在Section Header中发现所有的地址在0x400200-400670和600e50-601040之间。通过查看的各段的起始地址,查看的信息里面,没有下标、偏移、对齐方面的内容,但是有更详细的指令和代码。其截图如下:
图5.4 .init节
图5.5 .plt节
图5.6 .text节
图5.7 .data节
图5.8 .rodata节
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
hello.o没有经过链接,所以main的地址从0开始,不存在调用的函数的代码。但它有较多的重定位标记,用来以后的连接过程。它的相对寻址部分没有准确、参考性。链接后的hello程序,main地址不是0。库函数的代码都已经连接到程序中,跳转的地址具有参考性。与hello.o的反汇编内容相比,hello的反汇编代码中多了各种节。比如,.interp, .hash, .dynsym, .gnu.version, rela.dyn, .fini等。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
| 程序名称 |
加载程序 | ld-2.23.so!_dl_start; ld-2.23.so!_dl_init; LinkAddress!_start; libc-2.23.so!_libc_start_main; libc-2.23.so!_cxa_atexit; LinkAddress!_libc_csu.init; libc-2.23.so!_setjmp; |
call main | LinkAddress!main |
程序终止 | libc-2.23.so!exit |
5.7 Hello的动态链接分析
在编译器工作时,无法确定动态链接库中的函数的地址。动态链接基于数据段与代码段相对距离不变这个事实。动态库函数模块调用时采用延迟绑定技术,它通过过程连接表(PLT)和全局偏移量(GOT)实现实现。PLT时代码段的一部分,而GOT是数据段的一部分,两者都是一个数组。用户代码调用的函数从PLT[2]开始的条目调用。GOT[2]指向动态链接器ld-linux.so模块。其余条目对应于被调用的函数,先进入PLT,然后PLT指令跳转到对应的GOT条目中,其地址为PLT[2]的下一条指令。将函数序号压栈后跳转到PLT[0],PLT[0]将动态链接库的一个参数压栈。跳转动态连接器中,动态链接器使用函数的序号和重定位表来确定函数运行时地址。用它重写GOT,再将控制传递给目标函数。之后再次调用时PLT[2]直接跳转到目标函数。
5.8 本章小结
本章介绍了链接的概念和作用。分析了虚拟地址空间、重定位过程、执行流程等。链接时,以地址进行重定位,可重定位目标文件转换成可执行目标文件。动态链接过程跟静态的不太一样,更复杂。
第6章 hello进程管理
6.1 进程的概念与作用
进程是执行中程序的示例,每个进程都有自己的空间,它是系统进行资源分配和调度的基本单位。它的作用是,每个程序在进程的上下文中运行。上下文是状态组成,其状态包括文本、数据和堆栈。
6.2 简述壳Shell-bash的作用与处理流程
shell的作用:shell是一种用C语言编写的应用及程序。shell应用程序提供界面,能够接受用户命令调用相应的应用程序,所以用户可以访问操作系统内核的服务等。
处理流程:从终端读入用户的命令,将输入字符串获得所有的参数。若是内置命令,则立即执行。否则调用相应的程序为其分配子进程并运行。
6.3 Hello的fork进程创建过程
运行的终端对输入的命令行进行解析,hello不是内置的shell命令,则解析之后中断程序判断该文件的语义。判断之后中断程序首先调用fork函数创建一个新的子进程(在这以hello为例),子进程得到与父进程用户级虚拟地址空间相同(但是独立的)一份副本。所以当父进程调用fork函数时,子进程可以读写父进程中打开的文件。它们之间的区别是不同的PID,进而并发运行的独立进程。
6.4 Hello的execve过程
子进程调用fork函数之后,继续调用execve函数(传入命令行参数)。在当前进程的上下文中,加载并发运行一个新程序,execve调用驻留在内存中的被启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据和堆栈段。新的堆栈初始化为零,通过将虚拟地址空间中的页,映射到可执行文件的页大小的片。新的代码和数据段初始化为可执行文件中的内容。最后加载器设置PC指向_start地址,_start最终调用hello中的main函数。除了头部信息,在加载过程中没有任何从磁盘到内存的数据复制。
图6.1 启动加载器创建的系统映像(地址空间)
6.5 Hello的进程执行
并发执行涉及到操作系统内核采取的上下文交换策略。一个进程执行它的控制流的一部分的每时间段叫做时间片。hello程序执行过程中存储时间片,与操作系统的其他进程并发运行。内核为每个进程维持一个上下文,内核可以决定抢占当前进程,并重新开始先前被抢占的进程。这过程称为调度。
所以hello程序也与其他进程通过调度过程,切换上下文。拥有各自的时间片,实现并发运行。程序在涉及到操作时,内核需要将当前状态从用户态切换到和心态,执行结束后再及时改用户态。从而保证系统的安全性及稳定性。
图6.2 进程上下文切换
6.6 hello的异常与信号处理
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
出现的异常种类:
1.中断是来自IO设备的信号异步发生。返回后续执行调用前待执行的下一条代码,就像没发生过中断。
2.陷阱是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
3.终止是不能回复的置命错误造成的结果。处理程序会将控制返回给一个Abort例程,该例程会终止这个应用程序。
4.故障是由错误情况引起的异常,可能会被故障处理程序修好。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
5.另外正常执行程序的结果时,当程序执行完之后,操作系统回收其进程。
不停乱按及回车时,只是将屏幕的输入缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做shell命令行输入。
产生的信号:
信号是一种通知用户异常发送的机制。例如,下面图中的Ctrl-Z和Ctrl-C等。它们分别出发SIGSTP和SIGINT信号。
怎么处理:收到信号后进程会调用相应的信号处理程序对其进行处理。按Ctrl-Z时,信号处理函数的逻辑是打印屏幕回显、将hello 进程挂起,通过 ps 命令我们可以看出 hello 进程没有被回收,此时他的后台job 号是 1,调用 fg 1 将其调到前台,此时 shell 程序首先打印 hello 的命令行命令,hello 继续运行打印剩下的 8 条 info,之后输入字串,程序结束,同时进程被回收。按Ctrl-C时,信号处理函数的逻辑是结束 hello,并回收 hello进程。
图6.3 不停乱按及回车
图6.4 正常运行及结束
图6.5 运行过程中按Ctrl-Z及运行些命令
图6.6 运行过程中按Ctrl-C
6.7本章小结
本章介绍了进程的定义与作用,程序再shell执行。shell的处理流程中,通过调用fork函数创建新的进程,调用execve执行程序。程序运行中遇到各种异常,包括故障、终止、中断和陷阱等,还会产生一些信号。操作系统都有他们的处理方法,以信号来实现异常的反馈。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址空间的格式为“段地址:偏移地址”,保护模式下以段描述符作为下标,通过在GDT/LDT表获得段地址,段地址加偏移地址得到线性地址。
线性地址:逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。分页机制中线性地址作为输入。线性地址空间是指一个非负整数地址的有序集合。
虚拟地址:在采用虚拟内存的系统中,CPU从一个有N = 2n个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间。
物理地址:对应于物理内存中M个字节的地址空间{0, 1, 2, 3, …, M-1}则称为物理地址空间。
7.2 Intel逻辑地址到线性地址的变换-段式管理
分段功能在实模式和保护模式下有所不同。
在在实模式,即不设防,也就是说逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出 32 位地址偏移量,则可以访问真实物理内存。
在保护模式下,线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下 32 位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。分段机制就可以描述为:通过解析段寄存器中的段选择符在段描述符表中根据 Index 选择目标描述符条目 Segment Descriptor,从目标描述符中提取出目标段的基地址 Base address,最后加上偏移量 offset 共同构成线性地址 Linear Address。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存系统将虚拟内存分割称为虚拟页,物理内存分割称为物理页。虚拟页存在未分配的、缓存的、未缓存的三种状态。其中缓存的页对应于物理页。
图7.1 页表
页表是这类地址转换的另一个重要概念,它将虚拟页映射到物理页,其每一项称为页表条目(PTE),由有效位和一个n位的地址字段组成。如果设置有效位说明该页已缓存,否则未缓存,在地址字段不为空的情况下指向虚拟页在磁盘上的起始地址。从虚拟地址到物理地址的翻译通过MMU(内存管理单元),它通过虚拟地址索引到对应的PTE,如果已缓存则命中,否则不命中称为缺页。发生缺页时,MMU会选择一个牺牲页,在物理内存将之前缺页的虚拟内存对应的数据复制到它的位置,并更新页表,然后重新触发虚拟地址翻译事件。通过页表,MMU可以实现从虚拟地址到物理地址的映射。
图7.2 分配虚拟页面
虚拟地址分为虚拟页号 VPN 和虚拟页偏移量 VPO,根据位数限制分析可以确定 VPN 和 VPO 分别占多少位是多少。MMU利用VPN选择适当的PTE,然后将页表条目中的物理页号(PPN)与虚拟地址中的VPO串联起来,得到物理地址。
图7.3 使用页面的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
CPU 产生虚拟地址VA,而TLB通过虚拟地址VPN部分进行索引,分为索引TLBI与标记TLBT两个部分。VA传送给MMU,MMU使用VPN作为TLBT+TLBI向TLB中匹配。如果命中,则得到 PPN与VPO组合成PA。
图7.4 TLB命中/不命中的操作
如果TLB 中没有命中,MMU向页表中查询,CR确定第一级页表的起始地址,VPN确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。如果查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
7.5 三级Cache支持下的物理内存访问
物理内存中数据访问时,同样需要经过缓存,即Cache,主流的处理器通常采用三级Cache。获得物理地址 VA之后,使用 CI进行组索引,对块分别匹配 CT。如果匹配成功且块的 valid 标志位为 1,则命中,根据数据偏移量 CO取出数据返回。
图7.5 内存系统和内存访问
如果没有匹配成功或者匹配成功但是标志位是 1,则不命中,向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,如果映射到的组内有空闲块,则直接放置,否则产生冲突,则采用最近最少使用策略 LFU 进行替换。
7.6 hello进程fork时的内存映射
当 fork 函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。
加载并运行 hello 需要以下几个步骤:
- 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
- 映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图7.6 加载器映射用户地址空间的区域
7.8 缺页故障与缺页中断处理
缺页故障是一种常见的故障,当指令引用一个虚拟地址,在 MMU 中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。其处理流程遵循故障处理流程。
缺页中断处理,缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU 重新启动引起缺页的指令,这条指令再次发送 VA 到MMU,这次 MMU 就能正常翻译VA。
7.9动态存储分配管理
动态存储分配管理,由动态内存分配器维护着一个进程的虚拟内存区域。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
其分配器的两个策略中第一是隐式分配器:它的实现涉及到特殊的数据结构。其所使用的堆块是由一个子的头部、有效载荷,以及可能的一些额外的填充组成的。通过头部记录的堆块大小,可以得到下一个堆块的大小,从而使堆块隐含地连接着,从而分配器可以遍历整个空闲块的集合。在链表的尾部有一个设置了分配位但大小为零的终止头部,用来标记结束块。
第二是显式分配器:要求应用显式地释放任何已分配的块。
7.10本章小结
在这章介绍计算机里面的程序采用几种不同方式空间。分析及理解它们之间的变换,而变换过程时涉及到各种不同来方式管理。讲这些时拥有页面、页表和命中等重要概念,也较好理解。再提到fork函数和execve,内存映射方面看看。最后介绍了动态内存分配的基本方法与策略。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列,所有的I/O设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这个设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得输入和输出都能以一种统一且一致的方式的来执行。一个应用程序通过要求内核打开相应的文件来宣告它想访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,而文件的相关信息由内核记录,应用程序只需要记录这个描述符。
8.2 简述Unix IO接口及其函数
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
- Shell 创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
- 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行 seek,显式地将改变当前文件位置 k。
- 读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k+n,给定一个大小为 m 字节的而文件,当 k>=m 时,触发 EOF。类似一个写操作就是从内存中复制 n>0个字节到一个文件,从当前文件位置 k 开始,然后更新 k。
- 关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
- int open(char* filename,int flags,mode_t mode),进程通过调用 open 函数来打开一个存在的文件或是创建一个新文件的。open 函数将 filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
- int close(fd),fd 是需要关闭的文件的描述符,close 返回操作结果。
- ssize_t read(int fd,void *buf,size_t n),read 函数从描述符为 fd 的当前文件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0表示 EOF,否则返回值表示的是实际传送的字节数量。
- ssize_t wirte(int fd,const void *buf,size_t n),write 函数从内存位置 buf复制至多 n 个字节到描述符为 fd 的当前文件位置。
8.3 printf的实现分析
Windows下的printf函数如下:
1 2 3 4 5 6 7 8 9 10 11 | 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; } | ||
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 | int vsprintf(char *buf, const char *fmt, va_list args) { char* p; char tmp[256]; va_list p_next_arg = args; for(p=buf; *fmt; fmt++){ if(*fmt != '%'){ *p++ = *fmt; continue; } fmt++; switch(*fmt){ case 'x': itoa(tmp, *((int *)p_next_arg)); strcpy(p, tmp); p_next_arg += 4; p += strlen(tmp); break; case 's': break; default: break; } } return (p-buf); } | ||
上面是查看 vsprintf 代码。
printf首先 arg 获得第二个不定长参数,即输出的时候格式化串对应的值。知道 vsprintf 程序按照格式 fmt 结合参数 args 生成格式化之后的字符串,并返回字串的长度。在 printf 中调用系统函数 write(buf,i)将长度为 i 的 buf 输出。
在 write 函数中,将栈中参数放入寄存器,ecx 是字符个数,ebx 存放第一个字符地址,int INT_VECTOR_SYS_CALL代表通过系统调用syscall。syscall 将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的 ASCII 码。字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存储到 vram 中。显示芯片会按照一定的刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | write: mov eax, _NR_write mov ebx, [esp + 4] mov ecx, [esp + 8] int INT_VECTOR_SYSCALL sys_call: call save push dword [p_proc_ready] sti push ecx push ebx call [sys_call_table + eax * 4] add esp, 4 * 3 mov [esi + EAXREG - p_STACKBASE], eax cli ret |
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar函数通过调用read函数返回字符。其中read函数的第一个参数是描述符。第二个参数输入内容的指针,字符的地址,最后一个参数是1,代表读入一个字符,符号getchar函数读一个字符的设定。read函数的返回值是读入的字符数,如果读入成功,那么直接返回字符,否则说明读到了buf的最后。
read函数同样通过sys_call中断来调用内核中的系统函数。键盘中断处理子程序会接受按键扫描码并将其转换为ASCII码后保存在缓冲区。然后read函数调用的系统函数可以对缓冲区ASCII码进行读取,直到接受回车键返回。如此getchar函数通过read函数返回字符,实现了读取一个字符的功能。
8.5本章小结
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了printf 函数和 getchar 函数。