摘 要
Hello是最简单的程序,是每个程序员的初见,不过短短几行,不是很难理解和编写,但从其出生到死亡可谓是一段漫长而又丰富的历程,本论文主要介绍了hello这一最简单程序的生命周期。在这一生命周期,他遇到过生命中的“贵人”帮助他从program变成process,“从静止到跑起来”,即从.c源文件经过预处理、编译、汇编、链接生成可执行文件,再到shell为其fork进程成为process。还有020,从无到有再到化为历史长河的一粒沙,execbe加载运行hello完成内存映射,访问内存读取数据、再到结束shell回收相关资源。从诞生到结束,从运行到终止,探讨追踪了hello这一最简单程序在计算机系统内软硬件结合如何一步步运行直到死亡的过程。
关键词:编译汇编链接,进程管理,内存管理,IO管理。
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章
1.概述 - 4 -
1.1 HELLO简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 5 -
1.4 本章小结 - 5 - 第2章 预处理 - 6 -
2.1 预处理的概念与作用 - 6 -
2.2在UBUNTU下预处理的命令 - 8 -
2.3 HELLO的预处理结果解析 - 8 -
2.4 本章小结 - 9 - 第3章 编译 - 10 -
3.1 编译的概念与作用 - 10 -
3.2 在UBUNTU下编译的命令 - 11 -
3.3 HELLO的编译结果解析 - 11 -
3.4 本章小结 - 20 - 第4章 汇编 - 21 -
4.1 汇编的概念与作用 - 21 -
4.2 在UBUNTU下汇编的命令 - 21 -
4.3 可重定位目标ELF格式 - 21 -
4.4 HELLO.O的结果解析 - 25 -
4.5 本章小结 - 28 - 第5章 链接 - 29 -
5.1 链接的概念与作用 - 29 -
5.2 在UBUNTU下链接的命令 - 29 -
5.3 可执行目标文件HELLO的格式 - 29 -
5.4 HELLO的虚拟地址空间 - 33 -
5.5 链接的重定位过程分析 - 35 -
5.6 HELLO的执行流程 - 37 -
5.7 HELLO的动态链接分析 - 39 -
5.8 本章小结 - 41 - 第6章 HELLO进程管理 - 42 -
6.1 进程的概念与作用 - 42 -
6.2 简述壳SHELL-BASH的作用与处理流程 - 42 -
6.3 HELLO的FORK进程创建过程 - 42 -
6.4 HELLO的EXECVE过程 - 44 -
6.5 HELLO的进程执行 - 44 -
6.6 HELLO的异常与信号处理 - 46 -
6.7本章小结 - 46 - 第7章 HELLO的存储管理 - 49 -
7.1 HELLO的存储器地址空间 - 49 -
7.2 INTEL逻辑地址到线性地址的变换-段式管理 - 50 -
7.3 HELLO的线性地址到物理地址的变换-页式管理 - 51 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 51 -
7.5 三级CACHE支持下的物理内存访问 - 54 -
7.6 HELLO进程FORK时的内存映射 - 55 -
7.7 HELLO进程EXECVE时的内存映射 - 57 -
7.8 缺页故障与缺页中断处理 - 58 -
7.9动态存储分配管理 - 58 -
7.10本章小结 - 59 - 第8章 HELLO的IO管理 - 62 -
8.1 LINUX的IO设备管理方法 - 62 -
8.2 简述UNIX IO接口及其函数 - 62 -
8.3 PRINTF的实现分析 - 62 -
8.4 GETCHAR的实现分析 - 65 -
8.5本章小结 - 66 - 结论 - 66 - 附件 - 68 - 参考文献 - 69 -
第1章 概述
1.1 Hello简介
P2P:即从 Program to process由我们在IDE像是VScode、Codeblocks里一行行用C语言这一高级语言编写代码开始,在静态检查无错误后保存得到hello.c文件,而这样的文件仅我们程序员可理解,机器无法理解,也就是programmer(程序员)写出的程序(program),然后经过一系列处理:预处理(预处理器cpp生成hello.i修改的源文件代码)、编译(编译器cc1生成编译文件hello.s)、汇编(汇编器as生成hello.o可重定位目标文件)、链接(链接器ld生成hello可执行目标文件),最后在shell里输入命令运行hello可执行目标文件,shell为其fork一个子进程, hello就实现了从program到process(进程)的转变。
020:即: From Zero-0 to Zero-0。shell为hello程序fork进程后,子进程调用execve,将hello加载到内存,由0开始,对hello这个文件进行内存映射,将可执行目标文件中的代码和数据从磁盘中复制到内存中,设置当前进程上下文中的程序计数器,使PC为hello的入口点,然后开始运行hello,运行过程中,PC发生变化,将指令和数据入内存,CPU以流水线形式读取并执行指令,执行逻辑控制流。操作系统负责进程调度,为进程分时间片,进行上下文切换。执行过程其多次访问L1、L2、L3高速缓存、TLB、多级页表等进行存储管理,通过I/O系统进行键盘输入输出来改变异常控制流直到结束。当程序运行结束后,成为僵死进程,hello的父进程shell/bash回收hello进程的内存,hello结束了,又回归到0,则就是O2O过程。
1.2 环境与工具
硬件环境:X64 CPU Intel酷睿i7 9750H;2.9GHZ;16G RAM; 512G SSD;
软件环境:Windows10 64位;VMware WorkStation Pro16.1;Ubuntu 20.04 LTS;
开发工具:Visual Studio 2019 64位;CodeBlocks 64位;GDB;edb-debugger;
gedit;
1.3 中间结果
文件名称 文件作用
hello.c 保存hello源代码即源文件
hello.i 经过预处理的修改了的源文件
hello.s 编译器编译hello.i生成的汇编文件
hello.o 编译器编译hello.s生成的可重定位目标文件
hello.elf hello.o文件的ELF格式文件,查看hello.o各节的信息
hello.txt objdump反汇编hello.o生成的汇编文件与hello.s对比
hello 链接器重定位、链接生成的可执行目标文件
hello2.elf hello文件的ELF格式文件,查看hello各节的信息
hello2.txt objdump反汇编hello文件生成的汇编文件
1.4 本章小结
本章主要简述了hello的诞生,即Hello的简介,介绍了个人的实验环境包括硬件环境和软件环境,以及大作业中用到的开发工具,列出了论文中间文件的名字及作用,是本篇论文的背景和简要介绍。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
先介绍预处理指令的概念:C 语言编程过程中,经常会用到如 #include、#define 等指令,这些标识开头的指令被称为预处理指令,预处理指令由预处理程序(预处理器)操作。
预处理指令及分类:
ANSI C 定义的预处理指令主要包括:文件包含、宏定义、条件编译和特殊控制等 4 类。
1.文件包含:#include 是 C 程序设计中最常用的预处理指令。例如,几乎每个需要输入输出的 C 程序,都要包含 #include<stdio.h> 指令,表示把 stdio.h 文件中的全部内容,替换该行指令。
此外,包含文件的格式有 #include 后面跟尖括号 <> 和双引号 “” 之分。两者的主要差别是搜索路径的不同。
(1)尖括号形式:如 #include<math.h>,预处理器直接到系统目录对应文件中搜索 math.h 文件,搜索不到则报错。系统提供的头文件一般采用该包含方式,而自定义的头文件不能采用该方式。
(2)双引号形式:如 #include"cal.h",首先到当前工作目录下查找该文件,如果没有找到,再到系统目录下查找。包含自定义的头文件,一般采用该方式。虽然系统头文件采用此方式也正确,但浪费了不必要的搜索时间,故系统头文件不建议采用该包含方式。
2. 宏定义:包括定义宏 #define 和宏删除 #undef。如我们常用的#define N 5
定义无符号宏,或定义符号常量N;
3. 条件编译:主要是为了有选择性地执行相应操作,防止宏替换内容(如文件等)的重复包含。常见的条件编译指令有 #if、#elif、#else、#endif、#ifdef、#ifndef。
4. 特殊控制:ANSI C 还定义了特殊作用的预处理指令,如 #error、#pragma。
#error:使预处理器输出指定的错误信息,通常用于调试程序。
#pragma:是功能比较丰富且灵活的指令,可以有不同的参数选择,从而完成相应的特 定功能操作。调用格式为:#pragma 参数。
再介绍一下C预处理器(C Pre-Processor)也常简写为 CPP,是一个与 C 编译器独立的小程序,预处理器并不理解 C 语言语法,它仅是在程序源文件被编译之前,实现文本替换的功能。如我们常用#define N 100,其会将程序.c文件所有的N替换为100,仅仅修改程序源文件罢了。
预处理过程进行的操作:
1.将所有的“#define”删除,并且展开所有的宏定义
2.处理所有的条件编译指令,比如“#if”、“#ifdef”、“#elif”、“#else”、“#endif”
3.处理“#include”预编译指令,将被包含的头文件插入到该编译指令的位置。(这个过程是递归进行的,因为被包含的文件可能还包含了其他文件)
4.删除所有的注释“//”和“/* */”。
5.添加行号和文件名标识,方便后边编译时编译器产生调试用的行号心意以及编译时产生编译错误或警告时能够显示行号。
6.保留所有的#pragma编译指令,因为编译器需要使用它们。
预处理的作用:
我们先看hello.c的源代码
图2 1 hello.c的源代码
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如上图中hello.c中第一行的代码:#include <stdio.h> 命令高速预处理器读取系统头文件stdio.h的内容,以此类推,第二、三行代码#include <unistd.h>;#include<stdlib.h>则是命令高速预处理器读取系统头文件unistd.h 、stdlib.h的内容,然后将这些系统头文件的内容直接插入到程序文本中。结果得到另一个C程序,通常是以.i作为文件拓展名,其是一个修改了的源程序(文本),将源程序文件hello.c进行拓展,便于后面编译。
2.2在Ubuntu下预处理的命令
在ubutun终端输入如下指令:
linux> gcc –E –o hello.i hello.c
图2 2 ubutun下预处理命令及生成的hello.i文件
2.3 Hello的预处理结果解析
linux下gedit打开刚才所得到hello.i文件进行查看:
图2 3 hello.i文件的内容
从上图可以看到其不再是#include <stdio.h>; #include <unistd.h>;#include<stdlib.h>;等语句,而是高速预处理器读取读取系统头文件stdio.h、unistd.h 、stdlib.h的内容,然后将这些系统头文件的内容直接插入到程序文本中,那我们原来main函数的内容还在吗?继续往下查看hello.i的内容,原来hello.c文件中,main函数在最后面,cpp将原来#include的预处理指令替换成系统头文件的内容,那么main函数应该也是在hello.i的末尾,查看后果然如此,前面三千多行都是头文件内容,并未发生变化。
图2 4 hello.i文件中main函数的内容
这里我们就验证了预处理的作用:就是预处理器CPP处理预处理指令,预处理器读取了系统头文件unistd.h 、stdlib.h的内容,然后将这些系统头文件的内容直接插入到程序文本中,对原来的预处理指令进行文本替换,得到修改了的源程序hello.i,便于后面的编译器对程序进行编译。
2.4 本章小结
这一章主要先是介绍了预处理指令的概念,通常以#开头,再介绍了预处理器,然后预处理器CPP处理预处理指令,其并不理解 C 语言语法,它仅是在程序源文件被编译之前,实现文本替换的功能,将预处理指令替换,在hello.c文件中就是,预处理器读取了系统头文件unistd.h 、stdlib.h的内容,然后将这些系统头文件的内容直接插入到程序文本中,对原来的预处理指令进行文本替换,得到修改了的源程序hello.i,便于后面的编译器对程序进行编译。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译的概念:
要介绍编译,就需要先介绍CPU指令集,指令集包括汇编语言形式和二进制机器码格式,然后根据CPU指令集,去完成处理器的架构,就是处理器的硬件架构,称为微架构,是一堆硬件电路,去实现指令集所规定的操作运算。处理器是一堆硬件电路,只能识别二进制数据,所以其执行的是二进制代码或是机器代码(这叫机器指令,机器能理解并且执行),而汇编代码或汇编指令就是人类可读的机器代码的表示,每条汇编指令都有对应的机器码指令。现有CPU架构包括鼎鼎有名的Intel的X86架构(x86指令集)、ARM的ARM架构、MIPS的MIPS架构、DEC的Alpha架构。
编译,其就是将我们前一步预处理器CPP对hello.c文件进行预处理之后得到修改的源文件hello.i翻译成文本文件hello.s,把代码转化为汇编代码的过程,编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码,而编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,是人类可读的,不像机器代码是一串二进制数。
编译的作用:
编译程序过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
- 词法分析:输入源程序,对构成源程序的字符串进行扫描和分解,识别出一个个的单词(亦称单词符号或简称符号),如基本字(begin、end、if、for、while),标识符、常数、运算符和界符(标点符号、左右括号)。
- 语法分析:在词法分析的基础上,根据语言的语法规则,把单词符号串分解成各类语法单位(语法范畴),如“短语”、“句子”、“程序段”和“程序”等。
- 词义分析与中间代码产生:对语法分析所识别出的各类语法范畴,分析其含义,并进行初步翻译(产生中间代码)。
- 代码优化:优化的任务在于对前段产生的中间代码进行加工变换,以期在最后阶段能产生出更为高效(省时间和空间)的目标代码。
- 目标代码生成:这一阶段的任务是:把中间代码(或经优化处理之后)变换成特定机器上的低级语言代码。目标代码的形式可以是绝对指令代码或可重定位的指令代码或汇编指令代码。如目标代码是绝对指令代码,则这种目标代码可立即执行。如果目标代码是汇编指令代码,则需汇编器汇编之后才行运行。
3.2 在Ubuntu下编译的命令
在ubutun终端输入如下指令:
linux> gcc –S –o hello.s hello.i
图3 1 Ubuntu下编译的命令及编译生成的hello.s文件
3.3 Hello的编译结果解析
先观察hello.c的源代码:
图3 2 hello.c的源代码
我们可以发现其中主要定义了3中类型的数据:
int argc,main函数的形参全局int型变量:argc,表示传入main函数的参数个数;
char *argv[],main函数的形参全局char指针类型变量:argv,表示传入main函数的参数序列或指针,该指针指向一个字符串数组的首地址。
int i;main函数中的局部int型变量:i;
再观察其中C语言的操作:
先是控制转移和关系操作:if(argc != 4) 一个if判断,if控制转移, !=关系操作;
再是for(i=0;i<8;i++)一个循环,for表示控制转移,=赋值操作,<关系操作,++算术操作,argv[i]数组操作;
此外还有函数操作,最常见的printf函数,sleep函数,getchar函数的调用,其中包含了参数传递,最后是main函数返回 return 0。
下面则根据上述这些数据类型及操作说明,编译器是怎么处理C语言的各个数据类型以及各类操作的。
3.3.1常量
这里主要有两个字符串常量,既然是字符串常量,那么存放在文件.rodata节中,只读,如图3-3所示,
图 3 3 字符串常量
那么这两个字符串常量又在哪里使用呢?查看汇编代码可发现,其是printf函数输出的语句,作为参数被传入printf函数调用,如图3-4划线所示。
图 3 4 字符串常量对应汇编代码
再看对应源代码,其就是被printf函数输出,只是汇编代码中汉字被编码,而不是汉字的形式。
图 3 5 字符串对应源代码
3.3.2变量
这里主要有三个变量:argc,argv,i;
先是argc,其作为main函数的第一个参数,那么根据CPU传参寄存器顺序,其应该保存在%edi中,为什么不是%rdi,因为这里argc为int类型,4字节,32位寄存器%edi恰好放得下,这里就是movl %edi, -20(%rbp),movl传送双字,4字节,将argc的值保存到栈内%rbp-20处,栈内保存的是局部变量。
图 3 6 参数argc和argv
再是第二个参数*argv,其应该保存在%rsi中,对应汇编语句movq $rsi,-32(%rbp);*argv是一个指针,指向一个字符串数组,其存储的就是字符串数组的首地址,8字节,64位寄存器%rsi恰好能放得下,movq表示传送四字,8字节,将argv的值保存到栈内%rbp-32处。
再是另一个整形变量i,我们在上面的汇编代码中并没有找到,其实因为这里只是对其定义,而未赋值初始化,所以找不到,但是栈内开辟了存储这个局部变量i的空间。在后面.L2节中,对i赋值为0,我们发现了i。如下图所示。
图 3 7 变量i
3.3.3赋值操作
这里仅有一处赋值操作,对应movl $0, -4 (%rbp)
图 3 8 赋值i=0
movl就是将0传递给栈内%rbp-4处的变量i,也就是将i的值赋为0。
图 3 9 赋值对应源代码
3.3.4算术操作
源代码只有一处算术操作,查看汇编代码,寻找对应汇编语句:
addl $1, -4(%rbp),如图3-6所示:
图 3 10 算术操作++
通过前面赋值操作已知,栈内%rbp-4存放的是局部变量i,这里是对其进行i+1的算术操作,同时我们可以发现其是.L4段的最后一条语句,.L4段对应for循环体内容,i++就应该在循环体内的语句都执行完以后再执行,然后再判断i<8,符合C语言逻辑。其对应源代码就是:i++;
3.3.5关系操作
源代码共有2处关系操作,我们查看汇编代码来寻找对应的关系操作,先是!=关系操作: cmpl $4, -20(%rbp)
图 3 11 !=关系操作
通过对前面数据的分析,已经得知栈内%rbp-20处存放的是传进main函数的形参argc,这里将其与4比较,mpl指令行为和sub指令的行为指令是一样的,其根据两个操作数之差来设置条件码,下一条汇编指令je,根据条件码,相等才发生跳转.L2执行,不相等则不跳转执行下一条语句,正好对应了图3-5所展示源代码所作的比较。
图 3 12 !=关系操作对于源代码
再看另一处关系操作<:cmpl $7, 44(%rbp)
图 3 13 关系操作<
通过对前面分析,已经得知栈内%rbp-4处存放的是main函数中定义的局部整型变量i,这里与7作比较,似乎与我们图3-7所示对应源代码不一致,再看下一条汇编指令jle,表示i小于等于7才跳转执行循环,其就等价于i<8,才执行循环,与之相对应。
图 3 14 <关系操作对应源代码
3.3.6控制转移
源代码共有2处控制转移,我们查看汇编代码来寻找对应的控制转移,先是if控制转,对应图3-8划线两条语句所示。
图 3 15 控制转移if
前面我们已经分析过了关系操作,这里先将argc与4比较对应cmpl,cmpl指令行为和sub指令的行为指令是一样的,其根据两个操作数之差来设置条件码,je根据条件码,argc等于4则跳转执行.L2的代码,不等于就执行下一条语句调用printf函数输出,对应源码图3-5所示的“if(argc!=4);”的这条语句。
再看另一处控制转移,for的控制转移,对应图3-9划线两条汇编代码所示。
图 3 16 for控制转移的汇编代码
前面我们已经分析过了关系操作,这里先将i与7比较对应cmpl,其根据两个操作数之差来设置条件码,jle根据条件码,i<= 7则跳转执行.L4的代码,即for循环体内的代码,i>8则跳出循环执行下一条语句调用getchar函数,对应源码图3-9所示的for循环的源代码。
3.3.7 数组/指针/结构体操作
在源代码中我们共发现了三处数组操作,我们在汇编代码中寻找,如图3-17所示:
图 3 17 对argv数组的操作
我们前面已知%rbp-32存放的是argv,即argv数组的首地址,这里先是通过地址+16的偏移量访问argv[2],将其传入寄存器%rdx中,再是通过地址+8的偏移量访问argv[1],将其传入寄存器%rsi中,为下面调用printf函数做传参准备;再往下走,通过地址+24的偏移量访问argv[3],将其传入寄存器%rdi中,为下面调用atoi函数做传参准备,如下图对应源码。
图 3 18 数组操作对应源码
3.3.8函数操作
这里的函数操作较多,从头到尾先看源码分析。
图 3 19 hello.c中源代码的函数操作
第一处调用printf函数输出,汇编代码如图3-20所示,
图 3 20 第一处printf函数调用
我们可以看到,其将.L0存储的字符串传入寄存器%rdi,恰好也是CPU传参使用寄存器的传递第一个参数所用的寄存器,然后调用puts函数输出,对应源代码图3-19中划线的1。
然后是调用exit函数退出,其汇编代码如图3-21所示。
图 3 21 调用exit函数退出
其先是将1传递到寄存器%edi,恰好也是CPU传参使用寄存器的传递第一个参数所用的寄存器,1为参数,为exit(1)中的‘1’,exit(1)表示异常退出,在退出前可以给出一些提示信息,然后调用exit函数退出,对应源代码图3-19中划线的2。
再是第二处调用printf函数输出,汇编代码如图3-22所示。
图 3 22 第二处调用printf函数
之前对数组操作分析可知,通过对栈内存放argv数组首地址加偏移量+16、+8,访问了argv[2]、argv[1],并将其分别传入寄存器%rdx、%rsi中,传参使用对应寄存器的第三个、第二个,而传参用的第一个寄存器却是传进的.LC1的字符串常量,有些奇怪,与我们的源代码并不符合。这里其实对printf函数的一个理解,其先传进去的是printf函数输出的格式,告诉printf按照一个什么样的格式输出,然后再是传递我们要输出的值的参数,对应源代码图3-19中划线的3。
再是调用atoi函数,汇编代码如图3-23所示。
图 3 23 调用atoi函数
之前对数组操作分析可知,通过对栈内存放argv数组首地址加偏移量+24,访问了argv[3],然后传入%rdi,作为调用atoi函数的参数,atoi是将字符串存储的数转化为整数类型,对应源代码图3-19中划线的4。
接着是调用sleep函数,汇编代码如图3-24所示。
图 3 24调用sleep函数
前面我们已经分析了atoi函数的调用,其将argv[3]作为参数,将argv[3]字符串类型转换为整数数值,然后返回,其返回值就保存在寄存器%eax中,然后这里在传递给%edi,变成了sleep函数的参数,然后调用sleep函数,对应源代码图3-19中划线的5。
再接着是调用getchar函数,汇编代码如图3-25所示。
图 3 25 getchar函数的调用
这里getchar函数的调用的汇编代码较为简单,但没有运行,不知道其功能,猜测可能是读取键盘输入,对应源代码图3-19中划线的6。
然后最后一个函数操作,汇编代码如图3-26所示。
图3 26 main函数返回
这里也较为简单,main函数结尾return 0;返回值设为0,保存在寄存器%eax中,然后main函数返回,程序退出。对应源代码图3-19中划线的7。
3.4 本章小结
本章主要介绍编译,编译是C语言编译器对经过预处理后修改完的源代码hello.i文件的一个翻译,基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例将其翻译为人类可读、可理解的二进制代码或者说机器代码的文本表示——汇编代码,保存在hello.s文件中,依然是文本文件,接下来一步就是将汇编代码通过汇编器转换为机器代码,机器可读懂并执行的代码,一串二进制数,人很难理解。所以汇编代码就十分重要,其可以帮助我们去查看用高级语言所写代码在计算机内运行的逻辑及过程,理解汇编代码,可以帮助我们debug以及运用维护,同时也要注意到,有时候编译器会程序进行一定的优化,会改变一些库函数的调用,比如我们第一处调用printf函数,其计算机实际调用的却是puts函数。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
我们都知道,计算机的硬件作为一种电路元件,它的输出和输入只能是有电或者没电,也就是所说的高电平和低电平,所以计算机传递的数据是由“0” 和“1”组成的二进制数,所以说二进制的语言是计算机语言的本质,那么就有了机器指令的概念。
要讲汇编,需要先理解指令,之前我们在汇编部分已有谈及,这里给出更明晰的定义:指令被编码为有一个或多个字节序列组成的二进制格式一个处理器支持的指令和指令的字节级编码成为它的指令集体系结构,像Intel IA32和x86-64,x86-64也就是我们现在电脑windows系统所用的指令集体系结构。
这里就是汇编器(as)将前面得到汇编文件hello.s从汇编语言指令翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并讲解结果保存在文件hello.o中,hello.o是一个二进制文件,其包含的字节是main函数的指令编码,这些指令的编码是基于该电脑上的指令集体系结构,用文本编辑器打开,将看到一堆乱码,人类完全无法读懂,但是计算机可以读懂并且执行,但部分要经过后续的链接才能执行。
4.2 在Ubuntu下汇编的命令
在ubutun终端输入如下指令:
linux> gcc –c hello.s –o hello.o
如下图4-1所示。
图 4 1 Ubutun下汇编的命令及汇编生成hello.o文件
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
Ubutun终端输入以下命令:linux> readelf –a hello.o > hello.elf
readelf读出hello.o——ELF可重定位目标文件所有信息,保存到hello.elf文件中,便于查看。
图 4 2 readelf读取信息并保持到hello.elf文件
先回顾典型ELF可重定位目标文件的格式,如图4-2所示。
图 4 3 典型ELF可重定位目标文件
然后打开hello.elf文件分析。
先是ELF 头:描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。如图4-3所示。我们对此分析:首先是magic,
一个16字节的序列,描述了生成该文件的系统的字的大小和字节顺序。ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息, 其中包括 ELF 头的大小、目标文件的类型、 机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。观察其指明了该ELF文件类别为ELF64文件,数据是64位,采用补码为2的小端法存储,系统架构为X86-64,该文件为REL,可重定位文件,程序入口点地址为0x0,节头部表条目数量为14,大小为64字节,偏移量为1240字节,还有符号表等信息。
图 4 4 ELF 头
再是节头部表:描述目标文件的节,如图4-4所示。
图 4 5 节头部表信息
节头部表也是段表,我们观察到有14个节,其给出各个节的节名,在文件中的偏移、大小、访问属性、对齐信息等等。这些数据是由段表中一系列段描述符承载完成的,其中最为重要的就是下一步链接所要使用的信息:该段符号表的位置及重定位表的信息。
下面针对其比较重要的几个节来说明:
.text节: 存储已编译程序的机器代码,大小为0x92字节,偏移量为0x0;
.rodate节: 存储只读数据,如字符串常量,大小为0x33字节,偏移量为0xd8。
.data节: 存储已初始化的全局和静态C变量,大小为0x0字节,偏移量为0xd2。
.bss节: 存储未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量,大小为0x0字节,偏移量为0xd2。
.symtab节: 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息,大小为0x1b0字节,偏移量为0x180。
.strtab节: 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字,大小为0x48字节,偏移量为0x340。
.rela.text节:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这信息。大小为0xc0字节,偏移量为0x388。
再是重定位节:
图 4 6 重定位节
先介绍重定位的概念:重定位是连接符号引用与符号定义的过程。例如,程序调用函数时,关联的调用指令必须在执行时将控制权转移到正确的目标地址。可重定位文件必须包含说明如何修改其节内容的信息。通过此信息,可执行文件和共享目标文件可包含进程的程序映像的正确信息。重定位项即是这些数据。该信息就存储在重定位节中。
重定位节’.rela.text’:一个.text节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者应用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改,就如图4-6所示,hello.o的重定位信息:各个节的符号名称、符号值、加数、类型、偏移量、信息等等。
最后是符号表:一个符号表,用来存放程序中定义和引用的函数和全局变量的信息。每个重定位文件都在.symtab中都有符号表,需要引用的符号都在其中声明。如图4-7所示,里面有我们在hello.c中看到几个函数的符号:exit,printf,atoi等等。
图 4 7 符号表
4.4 Hello.o的结果解析
linux>objdump -d -r hello.o,如图4-8所示。
图 4 8 objump反汇编并保存为hello.txt文件
反汇编得到文件后,打开查看:
图 4 9 hello.o反汇编文件内容
发现与我们在第3章编译得到的hello.s的汇编代码并不一样,有很多差异。
首先我们发现其开头表明其是由hello.o文件反汇编得到,文件格式为elf64-x86-64,而不是我们然后我们之前hello.s文件的存储的字符串常量.LC0和.LC1,在hello.s中,访问全局变量是通过字节使用声明中的助记符+off+%rip来实现的;而在这里hello.o反汇编代码中,访问全局变量是通过用$0x0(%rip)来确定,其因为.data与.rodata段中的数据地址在链接运行后时才确定的,因此,对全局变量的访问需要重定位,全局变量的访问需要添加重定位条目。
此外,其左侧多了一栏地址(格式为xx:),以及一串十六进制数,如4: 55,4:表示地址,55则表示指令的字节级编码,是由汇编指令翻译而来,其再翻译成二进制数,就是低级语言,机器指令或是机器语言。以sub $0x20,%rsp这条汇编指令举例,其意思为将寄存器%rsp存储的值减去-0x20然后存储在寄存器内,其对应的指令的字节级编码为48 83 ec 20。我们这里解析一下这个指令编码,其与汇编代码的对应关系。每条指令需要1-10个字节不等,这取决于需要哪些字段,此外每个字节编码都只有唯一的结束,任何一个字节序列要么就是一个为的指令序列的编码,要么就不是一个合法的字节序列。
指令的编码为48 83 ec 20共8位,按顺序放。
0 1 2 3 4 5 6 7
4 8 8 3 e c 2 0
每条指令的第一个字节表明指令的类型,这里就是0、1两位存储的一个字节,这个字节分为两个部分,每部分4位:高4位是代码部分,低4位是功能部分,这里0存储的就是高4位代码部分,表明其是什么操作,整数操作、分支条件传送或是函数操作等等;这里1存储的就是低4位功能部分,0里面存储的4表示整数操作,8表示是整数操作里面的减法操作。再接着往下看2、3位存储的字节,是寄存器指示符字节,指定一个或两个寄存器,根据指令类型,其可以指定用于数据源和目的寄存器,或是用地址计算的基址寄存器,其表明使用的寄存器的编号,如3位存储的3为该指令集下寄存器%rsp的相对应的标识符。再往下看是一个常数字,6、7两位存储的0x20,表明要减去立即数0x20。同时我们可以看到,地址8的下一条语句的地址是c,而不是9,有些奇怪,其实这里的地址是等于上一条语句的地址+指令的字节数,这里上一条语句地址为8,指令的字节数为4,8+4=12,反以十六进制就是12。
下面还有一个明显区别就是,分支转移。我们在第3章可以观察到其发生跳转都是跳转到另一段中,使用助记符.LX,例如.L2,.L4等。而在反汇编代码中,我们可以发现转跳指令语句中目的地址使用的是实际地址,如<main+0x20>等等。如图4-10所示。
图 4 10 hello.o与hello.s跳转转移的对应
再看函数调用也有区别,在hello.s文件中,函数调用call的对象直接就是调用函数名称,而在hello.o反汇编中,call 的对象却是目标地址,而不是之前的函数名称,而是当前下一条指令地址,但这并不是目标函数的地址,其是因为hello.c中调用的函数不是本地函数,而是共享库中的函数,需要通过动态链接器要将共享库与程序链接后才能确定函数的运行时真正的地址,而在hello.o可重定位文件生成为机器语言的时候,这些地址并不确定,所以被称为可重定位文件,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全目标地址正是下一条指令,然后在.rela.text节中为其添加重定位条目,在连接阶段静态链接会重定位。如图4-11所示
图 4 11 函数调用的对应
4.5 本章小结
本章先是介绍了汇编的概念及作用,汇编器as将前面得到汇编文件hello.s从汇编语言指令翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式hello.o文件,分析了elf文件的内容,包含的elf头、符号表、.text节等等各种信息,说明其为什么是可重定位文件,其还要经过后续的链接重定位才可运行。然后比较了hello.o饭汇编文件与hello.s编译文件的差异,同为汇编代码,却在表达上有种种差异,如条件转移,函数调用,同时也讲明了汇编代码与机器代码的映射,指令字节级编码的概念。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是链接器(ld)将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行与加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是有应用程序来执行。链接由链接器的程序自动执行的。
链接器在软件开发中扮演着一个关键的角色,因为他们使得分离编译成为可能。我们不用讲一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块的一个时,只需要简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
举例:hello程序调用了printf函数,它是每个C编译器都提供的标准C库中的一个函数。printf函数存在于一个名为printf.o的单独预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器(ld)就负责处理这种合并。结果就是得到hello文件,他是一个可执行目标文件,可以被加载到你内存中,由系统执行。
5.2 在Ubuntu下链接的命令
Linux>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所示。
图 5 1 链接指令及链接生成的hello可执行目标文件
5.3 可执行目标文件hello的格式
Ubutun终端输入下列命令,将其保存到hello2.elf文件中,与之前读取hello.elf区别开。
linux> readelf -a hello > hello2.elf
同样先回顾一下典型的可执行目标文件的ELF格式,如图5-2所示。
图 5 2 典型的ELF可执行目标文件
可以发现可执行目标文件的ELF格式与可重定位目标文件的ELF格式基本一致,但其少了部分内容,少了rela.text等等,虽少了这些内容,但hello的ELF文件内容较hello.o多了许多,内容上有差异,我们将hello和hello.o对照观察,有些差异,如图5-3所示。
图 5 3 hello文件elf格式与hello.o文件elf格式对比
从hello文件elf格式与hello.o文件的elf格式的对比,可以观察到,文件类型由hello.o的可重定位文件(REL)变为hello的可执行文件(EXEC),入口点地址也由0变为0x4010f0,程序头起点也由0变为64字节,节头部的开始由1240变为14208,
节的数量和程序头的数量都变多了。
下面查看hello的ELF文件其各段的基本信息,包括各段的起始地址、大小、偏移量、对齐等信息等,如图5-4及5-5所示。
图 5 4 hello的ELF格式的节的信息1
图 5 5 hello的ELF格式的节的信息2
hello文件的ELF格式还较hello.o文件的ELF格式多出了一个程序头的内容:程序头表描述了可执行文件的连续片与连续内存段之间的映射关系,根据程序表头的内容,可以根据可执行文件的内容初始化两个内存片段。如下图5-6所示。
图 5 6 hello文件程序头的内容
此外还多了段节的内容以及动态节的内容,如图5-7所示。
图 5 7 段节及动态节的内容
可重定位节的内容也发生了改变,多了我们hello中所调用系统库内函数的内容。
图 5 8 重定位节的内容
此外,符号表的内容也大大增多,包含51项,较原来的18项大大增多。如下图5-9所示。
图 5 9 符号表的内容
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
edb运行hello程序,打开data dump可查看hello进程的虚拟地址空间各段信息,如图5-10所示。
图 5 10 hello的虚拟地址空间
观察发现程序被分配到虚拟地址为0x0400000~0x0405000的虚拟空间上,同时我们观察到点运行后,跳转到地址0x0401000,刚好与我们刚才elf文件(图5-3所示)读出信息,程序入口点地址为0x401000一致,结束地址为0x0401ff0,各个段的顺序与节表头的顺序一一对应。
举例说明:
根据ELF文件读出信息查看:先是ELF头:
图 5 11 ELF头从0x401000开始
再是.interp段的地址,存储着动态链接共享库路径:
图 5 12 .interp从0x4002e0开始
此外,我们再查看ELF 格式文件中的程序头,程序头表在执行的时候被使用,通过表中的信息我们可以得知内存与段之间的映射关系,表中得到每一个项提供了各段的基本信息,如图5-11所示,其中offset表示偏移,birtAddr就表示虚拟地址的起始,physAddr表示物理地址的起始,FileSiz表示目标文件中的段大小,MemSiz表示内存中的段大小,Flags表示运行时访问权限,最后Align表示对齐。
图 5 13 程序表头的内容
程序表头主要有以下几个内容:
- PHDR:保存程序头表。
2.INTERP:指定在程序已经从可执行文件映射到内存之后,必须调用的解释
器(如动态链接器)。 - LOAD:只读代码段。
4.LOAD:读写数据段:表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串) 、程序的目标代码等。
5.DYNAMIC:保存了由动态链接器使用的信息。
6.NOTE:保存辅助信息。
7.GNU_STACK: 权限标志,标志栈是否是可执行的。
8.GNU_RELRO: 指定在重定位结束之后那些内存区域是需要设置只读。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
在linux终端下输入下列命令:
linux > objdump –d –r hello > hello2.txt
将hello可执行文件反汇编的内容保存到hello2.txt文件中,与hello.o文件做区别,如图5-12所示。
图 5 14 hello文件反汇编得到hello2.txt
然后将二者比对来看:
图 5 15 hello.o文件与hello文件反汇编对比
我们发现,程序入口地址其由原来的0变成0x401000,此外也不是以mian函数开始,而是以init函数开始,地址在链接器重定位后,变成了CPU实际分配的虚拟地址,其地址计算是通过0x040000+数据所在节的地址+再加节内偏移量即可得出;此外还多了许多我们在hello里面调用函数的内容,像是puts、getchar等等库文件的内容,如图5-14所示,这是我们之前hello.o反汇编文件里面所没有看到,变化这样一来也是验证了链接,将共享库的内容与hello.o的内容链接、合并了进来。其中
图 5 16 hello反汇编文件调用函数的内容
此外,还多出了一些节的内容,像是_init等等。多出来节的作用:
.init: 程序初始化执行的代码;
.plt: 静态连接的连接表;
.plt.got: 保存函数引用的地址;
.fini: 程序正常终止时执行的代码。
然后介绍重定位的概念:
重定位:在连接阶段,连接器将所有的相同类型的节合并为同一类型的新的聚合节,然后连接器将运行时内存地址赋值给新的聚合节,赋值给输入模块定义的每个节,以及以及赋值给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址。
重定位节中的符号引用:在这一步中,连接器修改代码节的符号节中的引用,使它们指向正确的运行时地址,完成这一步,连接器需要依赖可重定位目标模块中称为重定位条目。
给出重定位条目的数据结构
typedef struct{
long offset; //节内偏移
long type:32, //重定位类型
symbol:32; //说绑定的符号
long addend; //偏移调整
}Elf64_Rela
我们先看hello.o文件的重定位节的内容,然后举例说明
图 5 17 hello.o重定位条目与hello.o反汇编文件的对应
图5-17中R_X86_64_PC32:32位相对地址引用,一个PC相对地址就是据程序计数器(PC)的当前运行值的偏移量。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址,PC值通常是下一条指令在内存中的地址。图5-17中.rodata-4就是PC相对寻址。
那么符号(symbol)puts偏移(offset)是21,类型(type)是R_X86_64_PLT32,
addend为-4;
图5 18puts函数在hello文件里的地址
当运行到call puts函数这条语句的PC值为下一条语句的地址:0x40114a,再看call这条机器指令的字节级编码:e8表是call,后面的46 ff ff ff则是相对寻址的偏移量
小端存储,为0xffffff46 = -0xba
故call 指令后面的地址值为0x40 114a-0xba=0x401090。
5.6 hello的执行流程
使用edb执行hello,从加载hello到_start,到call main,以及程序终止的所有过程。
调用程序 地址
ld-2.31.so!_dl_start 0x00007f58fe20fb30
ld-2.31.so!_dl_init 0x00007f58fe20fbc0
hello!_start ox4010e0
libc-2.31.so!_libc_start_main 0x00007fe977429bc0
ld-2.31.so!_dl_fixup 0x00007ffed6dbae70
libc-2.31.so!_cxa_atexit 0x00007ffed6a1b700
libc-2.31.so!_libc_csu_init 0x401260
libc-2.31.so!_setjmp 0x00007ffed6a166d0
hello!main 0x4011d6
hello!puts@plt 0x401090
hello!exit@plt 0x4010b0
ld-2.31.so!_dl_fixup 0xo0007ffcd6dbae70
libc-2.31.so!exit 0x00007f69325cf4b0
libc-2.31.so!_run_exit_handlers 0x00007f69325cf390
5.7 Hello的动态链接分析
动态链接的概念:
动态链接,在可执行文件装载时或运行时,由操作系统的装载程序加载库。大多数操作系统将解析外部引用(比如库)作为加载过程的一部分。
以下两个概念较为重要
1.装载时重定位:对于动态共享库中的函数,在编译阶段无法获得其真实地址;在动态连接阶段,连接器将共享函数设置一个重定位项,其中包含重定位信息(ADDR(.plt)+offset)和符号表信息,然后在运行时根据重定位项信息才能确定其真实地址。
2. PIC(地址无关代码):动态链接库希望所有进程共享指令段而各自拥有数据段的私有副本,为了实现这个目标,就要采用与地址无关代码的技术。该实现的基本思想是:把指令中需要修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分则在每个进程拥有一个副本。
在之前读取hello的ELF格式文件时,发现了一个dynamic section。假如一个object文件参与动态的连接,它的程序头表将有一个类型为PT_DYNAMIC
的元素。该“段”包含了.dynamic sectio。说明有下列这些项目参与了动态的链接,
图 5 19 Dynamic section
此外,我们分析在dl_init前后,观察这些项目的内容变化。
我们先观察没有dl_init的.plt和.pot的内容,如图所示:
图 5 20 未dl_init的内存
dl_init后.plt和.pot的内容,如图所示
图 5 21 dl_init后的内存
可以发现,原先有些内存存储的是0被赋值,其内容对应着那些动态链接进来的项目,举例第二行的地址内容即为以下函数的地址;libc-2.31.so!_libc_start_main , ox00007fe977429bc0。
同时还可以发现其增加了链接了一些库,如图所示。
图 5 22 dl_init后增加的库
5.8 本章小结
本章主要介绍了链接的概念,链接器(ld)将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。其中我们观察了链接前后,hello.o的elf格式文件与hello的elf格式文件内容上的差异,以及由二者反汇编代码的差异去分析、理解链接的概念,包括重定位等等。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程:一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正常运行所需的状态组成。
关注进程提供给应用程序的关键抽象:
- 一个独立的逻辑控制流,他提供一个假象,好像我们的程序独占地使用处理器;
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
创建:父进程调用fork函数创建一个新的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。
回收方法:
1.当一个进程终止时,内核并不是立即把它从系统中清除,直到被其父进程回收。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,完成进程回收。
2.如果一个父进程终止了,内核会安排init进程成为其子进程的父进程,负责回收父进程已终止的子进程。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个用C语言编写的程序,通过Shell用户可以访问操作系统内核服务,类似于DOS下的command和后来的cmd.exe。shell既是一种命令语言,又是一种程序设计语言。shell是一个应用程序。shell还是系统的用户界面,提供了用户与内核进行交互操作的一种接口,其接收用户输入的命令并把它传入内核去执行。
shell的功能: - 当shell作为命令语言,它交互式地解释和执行用户输入的命令;
- 当shell作为程序设计语言,它定义了各种变量、参数、函数、流程控制等等。它调用了系统核心的大部分功能来执行程序、建立文件并以并行的方式协调各个程序的运行。
shell的处理流程:
1)从终端读取输入的命令。
2)将输入字符串切分获得所有的参数
3)如果是内置命令则调用内部函数立即执行,否则调用相应的程序执行
4)shell可接受键盘输入信号,并对这些信号进行相应处理
6.3 Hello的fork进程创建过程
在linux在终端下执行下列语句来启动hello程序。
linux> ./hello 1190201307 徐伟 30
看到屏幕输出: Hello 1190201307 徐伟
且程序没有停止,直到过了30s后才结束,这里就表明运行了hello这个程序。
shell从命令行读取了这行命令,然后对其进行解析,判断非内置命令,而是运行程序,调用fork机制,为这个程序fork了一个进程,为shell的子进程,与shell同属一个进程组,进程组号相同,这里hello进程pid为31524,新创建的子进程拥有几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,其意思是当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的 PID。此外,父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。如果在shell上无作特殊说明,则在子进程执行期间,父进程(shell)默认选项是显式等待子进程的完成,如指定为后台程序,则无需等待子进程完成。
图 6 1 为hellofork的进程
下面用一个进程图来说明Hello的fork进程创建过程。
图 6 2 hello的fork进程创建
6.4 Hello的execve过程
因为shell判断出hello不是内置命令,且hello是一个可执行文件,所以shell在当前进程的上下文中调用execve函数加载并运行hello程序,且带参数列表argv和环境变量列表envp。argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串,按照惯例,argv[0]是可执行目标文件的名字,所以我们可以想到hello.c文件中,其访问的是argv[1],argv[2],argv[3],存储的参数分别是我们的学号、姓名、sleep时间的字符串,就是刚才我们在终端输入“1190201307 徐伟 30”,即为参数。环境变量的列表是有一个类似的数据结构表示的,envp变量指向一个以null结尾的指针数组,其中每一个指针指向一个环境变量字符串。execve是通过调用驻留在存储器中称为加载器的操作系统代码来运行hello, 其删除进程现有虚拟内存映射关系,给hello分配新的虚拟内存地址,并映射hello中的文件或匿名文件至虚拟内存中。然后跳转至_start函数,再通过调用libc_start_main函数初始化环境变量等内容,并将程序执行至main函数入口。加载器将可执行目标文件中的代码和数据从磁盘中复制到内存中,然后通过跳转到程序的第一条指令或者入口点来运行hello。
6.5 Hello的进程执行
前面已经介绍过了进程的概念。我们提到了进程向每个程序提供了一个假象,好像它在独占地使用处理器。如果用调试器单步之形成,可以看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享库对象中的指令。这个PC值的序列就叫叫做逻辑控制流,简称逻辑流。若一个逻辑流的执行时间和另一个流重叠,则成为并发流,这两个流被称为并发地运行。多个流并发地执行地一般现象被称为并发。一个进程和其他进程轮流运行的概念为多任务,一个进程执行它的控制流的一部分的每一时间段叫做时间片,因此多任务也叫做时间分片。
图 6 3 逻辑并发流
而操作系统内核则使用上下文切换的较高层形式的异常控制流来实现多任务。
内核为每一个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。它由包括目的寄存器、程序计数器、用户栈、状态寄存器在内的对象的值构成。
当子进程调用exceve()函数在上下文中加载并运行hello程序后,hello程序不会立即运行,需要内核调度它。进程调度是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,就说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,使用一种称为上下文切换的机制来将控制转移到新的进程。
处理器提供了一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。通常用某个控制寄存器的一个模式位来提供这种机制,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程运行在内核模式中,进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置;没有设置模式位时,进程运行在用户模式中,进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据,否则会导致保护故障。运行应用程序代码的进程初始时在用户模式中,进程需要通过中断、故障或者陷入系统调用这样的异常才能从用户模式变为内核模式。
由于负责进程调度的是内核,因此内核调度需要运行在内核模式下。当内核代表用户执行系统调用时,可能会发生上下文切换,中断也可能引发上下文切换。同时,系统通过某种产生周期性定时器中断的机制判断当前进程已经运行了足够长的时间,并切换到一个新的进程。
然后分析我们的hello的例子,当调用printf函数时,需要访问内核,这时就会进行上下文切换,于是进程就会从用户模式切换到内核模式。再比如说当hello进程调用sleep时,由于sleep是系统调用,进程陷入内核模式。这时hello进程被挂起,内核会选择调度其他进程,通过上下文切换保存hello进程的上下文,将控制传递给新调度的进程。定时器的时间到了后会发送中断信号,进入内核模式,将挂起的hello进程变成运行状态,这时hello进程就可以等待内核调度它。当从内核模式变回用户模式时,可能就返回切换到另一个进程了。这时会进行上下文切换,切换过程一般可分为3个部分,分别为:(1)保存当前进程的上下文;(2)恢复要执行进程之前保存的上下文;(3)将控制传递给新恢复的进程。如图6-4所示。
图 6-4 hello的上下文切换
6.6 hello的异常与信号处理
hello执行过程中,四类异常都可能会出现:故障,中断,陷阱,终止,图6-5总结了异常的类别。
故障:由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
中断:中断是来自IO设备的信号,异步发生,中断处理程序对其进行处理,泛回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
陷阱:有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
终止:是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程
序会将控制返回给一个abort例程,该例程会终止这个应用程序。
图 6 5 异常的类别
(1)正常执行:hello程序正常执行,途中不作任何中断程序的输入,同时尝试键盘乱按,发现乱按会将乱按的输入的输出, 在执行完毕之后,hello进程被其父进程回收,同时getchar会读取键盘输入作为shell终端指令的输入,如图6-6所示。
图 6 6 hello正常执行
(2)键盘输入Ctrl+c,给正在运行的前台作业hello发送SIGINT信号,终止其进行,如图6-7所示。
图 6 7 hello被Ctrl+c终止
再使用linux> ps 查看是否有这个hello进程,发现没有hello程序。
图 6 8 ps查看进程
(3)键盘输入Ctrl+z,给正在运行的前台作业hello发送SIGSTP信号,停止其进行,并且ps查看进程,发现其已停止,如图6-9所示。
图 6-9 hello被Ctrl+z停止
再使用linux>jobs查看当前shell的任务信息,linxu>pstree打印进程关系树,显示进程之间的关系。
图6-10 jobs查看任务信息及pstree查看进程关系树
此外linux> bg,可以让暂停被提交至后台的hello作业转到前台继续运行,最后linux>kill -9 pid ,杀死hello进程,终止其运行,如图6-10所示。
图 6-11 杀死hello进程
6.7本章小结
本章主要涉及到异常控制流的内容,引入了进程、上下文的概念,简述了壳Shell-bash的作用与处理流程,结合hello程序讲述了hello的fork进程创建过程、hello的execve过程和hello的进程执行,一个程序在计算机里运行的状态,前台作业或后台作业,以及运行各种可能出现的异常,以及对异常的处理,主要是各种来自键盘的中断等等。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
先分别给出这4种地址的概念:
逻辑地址(Logical Address):包含在机器语言指令中用来指定一个操作数或一条指令的地址。这种寻址方式在80x86著名的分段结构中表现得尤为具体,它促使windows程序员把程序分成若干段。每个逻辑地址都由一个段地址和偏移量组成,偏移量指明了从段开始的地方到实际地址之间的距离。实际上也就是hello.o中的我们看到的以及hello.o的ELF格式文件中我们看到了偏移量及相对偏移地址。
线性地址:逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址后加上基地址就是线性地址。
虚拟地址:由CPU生产,经过MMU转换可以转换为物理地址,虚拟地址实际上就是一种线性地址。我们在hello反汇编文件中看到的就是虚拟地址。
物理地址:用于内存芯片级内存单元寻址。它们与从微处理器的地址引脚按发送到内存总线上的电信号相对应。物理地址由32位或36位无符号整数表示。计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每一个字节都有一个唯一的物理地址。在hello得到虚拟地址后,通过查询页表等分页机制,将其转化为物理地址,到内存或磁盘中寻址,读取文件。
其关系如图7-1所示。
图 7 1 4类地址之间关系
hello.c在汇编生成hello.o可重定位目标文件后,还未与共享库链接,所以这里hello.o的反汇编代码体现的就是逻辑地址,仅有偏移量。再到重定位链接生成hello可执行文件,此时反汇编代码所体现的就是虚拟地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式内存管理方式就是直接将逻辑地址转换成线性地址,一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。如7-2所示。
图 7 2 段标识符
逻辑空间:逻辑空间分为若干个段,其中每一个段都定义了一组具有完整意义的信息,逻辑地址对应于逻辑空间。
段: 段是对程序逻辑意义上的一种划分,一组完整逻辑意义的程序被划分成一段,所以段的长度是不确定的。
段描述符:段描述符段中的元素,用于描述一个段的详细信息的结构,段描述符一般是由8个字节组成。此外,又有很多段,就又相应多的段描述符,这些段描述符就组成了“段描述符表”。如图7-3所示。
图 7 3 段描述符的定义
逻辑地址到线性地址的转换过程:首先获取一个完整的逻辑地址[段选择符(段基值):段内偏移地址], 看段选择描述符中的T1字段是0还是1,可以知道当前要转换的是GDT(全局段描述符)中的段,还是LDT(局部段描述符表)中的段,再根据指定的相应的寄存器,得到其地址和大小,我们就有了一个数组了。 拿出段选择符中的前13位,可以在这个数组中查找到对应的段描述符,这样就有了Base,即基地址就知道了,把基地址Base+Offset,就是要转换的下一个阶段的物理地址。如图7-4所示。
图 7 4 逻辑地址到线性地址的转换过程
7.3 Hello的线性地址到物理地址的变换-页式管理
前面我们已经介绍到了,由逻辑地址经过段式管理得到线性地址,也就是虚拟地址,而又虚拟地址并不够,还需要物理地址,才能到磁盘上存取。而线性地址(虚拟地址VA)到物理地址(PA)之间的转换通过分页机制以及页式管理来完成。
概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组,磁盘上数组的内容被缓存在物理内存。 这些内存块被称为页。
操作系统通过将虚拟内存空间以页为单位分割管理,一页大小4KB;类似地,物理内存空间也以页以页为单位分割管理,一页大小4KB。通过MMU,可以将虚拟地址映射到唯一物理地址。
由此引入页表的概念:页表是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。如图7-5所示。
图 7 5 页表
MMU利用页表实现从虚拟地址到物理地址的变换。CPU中的一个控制寄存器,页表基址寄存器指向当前页表。
再介绍虚拟地址和物理地址的组成。
虚拟地址组成:虚拟地址由两部分组成VPN和VPO。VPN为虚拟页号,在虚拟地址高位位置;VPO为虚拟页偏移量,在虚拟地址低位位置。VA具体占用多少位,由系统环境决定;在Intel Core i7环境下,VPN占据高36位,VPO占据低12位。
物理地址组成:物理地址由两部分组成PPN和PPO。PPN为物理页号,在物理地址高位位置;PPO为物理页偏移量,在物理地址低位位置。PA具体占用多少位,由系统环境决定;在Intel Core i7环境下,PPN占据高40位,VPO占据低12位。
MMU利用VPN选择适当的PTE,如果这个PTE设置了有效位,则页命中,将页表条目中的物理页号和虚拟地址中的VPO连接起来就得到相应的物理地址。否则会触发缺页异常,控制传递给内核中的缺页异常处理程序。缺页处理程序确定物理内存中的牺牲页,调入新的页面,并更新内存中相应PTE。处理程序返回到原来的进程,再次执行导致缺页的指令,MMU重新进行地址翻译,此时和页命中的情况一样。
整个流程如下图:
图 7 6 基于页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
(以下格式自行编排,编辑时删除)
先介绍TLB的概念:TLB,也称翻译后备缓冲器,是在MMU中包括一个关于PTE的缓存。TLB是一个小的、虚拟寻址的缓存,每一行保存着一个由单个PTE组成的块。由于VA到PA的转换过程中,需要使用VPN确定相应的页表条目,因此TLB需要通过VPN来寻找PTE。和其他缓存一样,需要进行组索引和行匹配。如果TLB有2t个组,那么TLB的索引TLBI由VPN的t个最低位组成,TLB标记TLBT由VPN中剩余的位组成。如图7-7所示。
图 7 7 TLB的构成
接着介绍TLB的工作机制。这里的关键点是,所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。
第1步:CPU产生一个虚拟地址。
第2步和第3步:MMU从TLB中取出相应的PTE。
第4步:MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
第5步:高速缓存/主存将所请求的数据字返回给CPU。
当TLB不命中时,MMU必须从L1缓存中取出相应的PTE,如图7-8(b)所示。新取出的PTE存放在TLB中,可能会覆盖一个已经存在的条目。
图 7 8 TLB命中与不命中
再接着介绍多级页表的概念:多级页表可以用来压缩页表,对于k级页表层次结构,虚拟地址的VPN被分为k个,每个VPNi是一个到第i级页表的索引。当1≤j≤k-1时,第j级页表中的每个PTE指向某个第j+1级页表的基址。第k级页表中的每个PTE和未使用多级页表时一样,包含某个物理页面的PPN或者一个磁盘块的地址。
图 7 9 二级页表层次结构
再将页表翻译:首先四级页表中,第一级页表为第二级页表的索引,第二级页表为第三级页表的索引,第三级为第四级的索引,最终的第四级页表映射到物理地址。然后对虚拟地址进行划分,得到VPN,则根据VPN访问页表,根据页表判断相应地址的数据是否缓存,若缓存,可直接从页表中读出PPN,则VPO与PPN组成一个完整的物理地址,接下来便可访存。
图 7 10 使用4级页表的地址翻译
7.5 三级Cache支持下的物理内存访问
先介绍cache的概念:高速缓存cache被组成一个有S=2s个高速缓存组的数组,每个组包含E个高速缓存行。每个是由一个B=2b字节的数据块组成,一个有效位指明这个行是否存储有意义的信息,还有t=m-b-s个标记为,唯一标识存在这个高速缓存的块。如图7-11所示。
图 7 11 cache的结构
再给出三级Cache的相关概念,Cache被分为三级。L1级cache作为L2级cache的缓存,L2级cache作为L3级cache的缓存,而L3级cache作为内存(DRAM)的缓存。而L1cache又分为指令cache和数据cache。如图7-11所示。
图 7 12 Core i7的内存系统
当我们进行进行物理内存访问时,先将物理地址发送给L1级cache,看L1级cache中是否缓存了需要的数据。L1级cache共64组,每组8行,块大小64Bytes。由之前cache的结构将物理地址分为三部分,块偏移6位,组索引6位,剩下的为标记位40位。首先利用组索引位进行组匹配找到对应的组,然后在组中进行行匹配找到对应的行,对于组中的8个行,分别查看有效位并将行的标记位与物理地址的标记位匹配,当标记位匹配且有效位是1时,缓存命中,根据块偏移位取出对应的块就课直接将cache中缓存的数据传送给CPU。如果缓存不命中,需要继续从存储层次结构中的下一层中取出被请求的块,将新块存储在相应组的某个行中,可能会替换某个缓存行。
L1级cache不命中时,会继续向L2级cache发送数据请求。和L1级cache的过程一样,需要进行组索引、行匹配和字选择,找到后将数据传送给L1级cache。如果L2级cache不命中时,则会继续向L3级cache发送数据请求。最后,L3级cache不命中时,只能从内存中请求数据了。
7.6 hello进程fork时的内存映射
前面在第6章我们讲hello的fork进程创建时,就已提到shell父进程为新创建的hello子进程提供了一个几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,其意思是当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的 PID。为了给这个新进程创建虚拟内存,fork创建了当前进程的mm_struct,区域结构和页表的原样副本。fork将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记私有的写时复制。
图 7 13 一个共享的对象
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中任一个后来进行写操作时,写时复制机制就会创建新页面。
图 7 14 一个私有的写时复制对象
7.7 hello进程execve时的内存映射
execve通过调用某个驻留在存储器中称为加载器的操作系统代码来运行hello,加载器将可执行目标文件中的代码和数据从磁盘中复制到内存中,然后通过跳转到程序的第一条指令或者入口点来运行该程序,当加载器运行时,会hello创建内存映像。加载并运行 hello 需要以下几个步骤:
- 删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
- 映射共享区域:hello 程序与共享对象(或者目标)链接,libc.so是动态链库,那么这些对象都是接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC):execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
内存映射示意图如图7-15所示
图 7 15 加载器映射用户地址空间的区域
7.8 缺页故障与缺页中断处理
同样先给出缺页故障的概念,缺页故障:CPU产生一个虚拟地址给MMU,MMU经过一系列步骤获得了相应的PTE,当PTE的有效位未设置时,说明虚拟地址对应的内容还没有缓存在内存中,这时MMU会触发缺页故障。
而缺页故障则会使正在运行的程序陷入内核,从而触发缺页中断处理程序。缺缺页处理程序主要会执行以下三个步骤:
- 判断虚拟地址是否合法。缺页处理程序搜索区域结构的链表,把虚拟地和每个区域结构中的vm_start和vm_end做比较。如果指令不合法,缺页处理程序会触发一个段错误,从而终止这个进程。
- 判断内存访问是否合法。比如缺页是否由一条试图对只读页面进行写操作的指令造成的。如果访问不合法,缺页处理程序会触发一个保护异常,从而终止这个进程。
- 此刻,内核知道这个缺页是由合法的操作造成的。内核会选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。处理程序返回时,CPU重新执行引起缺页的指令,这条指令将再次发送给MMU。这次,MMU能正常地进行地址翻译,不会再产生缺页中断了。
Linux缺页处理如图7-16所示。
图 7 16 Linux缺页处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,堆。假设堆为一个请求二进制零的区域,紧接在未初始化的数据区域(.bss)后开始,并向上生长(向更高的地址),对应每个进程,内核维护着一个brk,指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,块为已分配的或为空闲的。已分配的块显式地保留为供应用程序使用,已分配块保持已分配状态,直到它被释放,或被应用程序显式执行释放,或是内存分配器自身隐式执行释放。空闲块可用来分配,空闲块保持空闲,直到它被显式地被所应用分配。
分配器有两种基本风格,其都要求应用显式地分配块,不同之处在于哪个实体来负责释放已分配的块。
显式分配器,要求应用显式地释放任何已分配的块,如C语言的free函数。
隐式分配器,也称垃圾收集器,要求分配器检测一个已分配的块何时不再被程序所使用,然后释放这个块,代表有Java语言。
分配器的要求和目标:无法控制分配块的数量或大小;处理任意请求序列;立即响应请求;必须对齐块,使得它们可以保护任何类型的数据对象;只能操作或改变空闲块;一旦块被分配,就不允许修改或移动它了。
分配器的数据结构:
隐式空闲链表,在隐式空闲链表中,一个块是由一个字(4字节)的头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有填充),且标记了这个块是已分配的还是空闲的,如图7-17所示。
图 7 17 隐式空闲链表的堆块格式
头部后面就是应用调用malloc时请求的有效载荷,有效载荷后面是一片不使用的填充块,其大小是任意的,满足对齐要求。
在设计完成了堆块的结构后,我们将一个对组织成为一个连续的已分配块和空闲块的序列,隐式空闲链表指的是并不直接对空闲块进行链接,而是将对内存空间中的所有块组织成一个大链表,其中头部和脚部中的信息隐式地起到连接的作用。如图7-18所示。
图 7 18 隐式空闲链表结构
放置策略主要有三种:
首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块。
下一次适配:是从上一次查询结束的地方开始搜索空闲链表。
最佳适配:检查每个空闲块,选择适合所需请求大小的最小空闲块。
分割:放置时,可能分配块空间远大于我们实际需要空间大小,可以选择分割。
一旦分配器找到一个匹配的空闲块,就必须做一个另一个策略决定,那就是分配这个块多少空间。分配器通常将空闲块分割为两部分,第一部分变为分配块,剩下的变成一个新的空闲块。
合并空闲块:在带边界标签的隐式空闲链表分配器下有4中情况:前面的块和后面的块都是已分配的,则就直接将当前块释放,不合并;前面的块和后面的块都是空闲的,那么将这三块合并;前面的块空闲后面的块已分配,将前一个块和当前块合并;前面的块已分配后面的块空闲,当前块和后面的块合并。
此外较为常用的是显式空间链表。显式空闲链表是将空闲块组织为某种形式的显式数据结构。堆被组织为一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继的指针。
块结构如下:
图 7 19 显式空间链表的堆块结构
链表结构如图7-20所示:
图 7 20 显式空间链表结构
使用双向链表而不是隐式空闲链表,使得首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。
一种方法是用后进先出的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在常数时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。
7.10本章小结
本章主要先是介绍了逻辑地址、线性地址、虚拟地址、物理地址的概念,由此引入段式管理将逻辑地址翻译到线性地址,再到页式管理将虚拟地址翻译到物理地址,这一连串地址的转化及之间关系的论述,再到更为复杂的TLB及四级页表,三级cache,是对计算机存储系统的一个全面的回顾。此外,还探讨了一个程序在运行时,父进程与其共享的虚拟映射,讲到了写时复制以及缺页处理等等,明晰了进程与存储的关系。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
(以下格式自行编排,编辑时删除)
设备的模型化:文件
一个linux文件就是一个m个字节的序列:B0, B1, … Bk, …, Bm-1
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。
每个Linux文件都有一个类型(type)来表明它在系统中的角色:
- 普通文件(regular file)包含任意数据。应用程序常常要区分文本文件(text file)和二进制文件(binary file),文本文件是只含有ASCII或Unicode字符的普通文件;二进制文件是所有其他的文件。对内核而言,文本文件和二进制文件没有区别。
- 目录( directory)是包含一组链接(link)的文件,其中每个链接都将一个文件名(filename)映射到一个文件,这个文件可能是另一个目录。每个目录至少含有两个条目:“.”是到该目录自身的链接,以及“…”是到目录层次结构(见下文)中父目录( parent directory)的链接。你可以用mkdir命令创建一个目录,用ls查看其内容,用rmdir删除该目录。
- 套接字(socket)是用来与另一个进程进行跨网络通信的文件。
其他文件类型包含命名通道(named pipe)、符号链接(symbolic link),以及字符和块设备(character and block device)。
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:Unix I/O
8.2 简述Unix IO接口及其函数
Unix IO 接口,使得所有的输入和输出都能以一种统一且一致的方式来执行:
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,即描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
- Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件中的常量可以代替显式的描述符值。
- 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置 k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
- 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发EOF条件,应用程序能检测到这个条件。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数: - 进程通过调用open函数打开一个存在的文件或者创建一个新文件。
函数声明:int open(char* filename,int flags,mode_t mode);
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。
flags参数如下:
O_RDONLY:只读
O_WDONLY:只写
O_RDWR:可读可写
O_CREAT:如果文件不存在,就创建它的一个截断的空文件
O_TRUNC:如果文件已经存在,就截断它 - 进程通过调用close函数关闭一个打开的文件,所需头文件: #include <unistd.h>。
函数声明:int close(int fd);
fd是需要关闭的文件描述符,成功返回0,错误返回-1。关闭一个已关闭的描述符会出错。 - 应用程序通过分别调用read和write函数来执行输入和输出,所需头文件: #include <unistd.h>
函数声明如下:
ssize_t read(int fd,void *buf,size_t n);
ssize_t wirte(int fd,const void *buf,size_t n);
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则返回值表示的是实际传送的字节数量;write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
图 8 1printf函数的源代码
然后观察发现其调用了一个我们没见过的vsprintf函数,此外还有我们前面提到的write函数。先分析这个vsprintf函数。
图 8 2 vsprintf函数的源代码
仔细观察发现 vsprintf他对传进来的字符串,循环判断是否为‘%’,或‘x’,或‘s’,在循环中用参数替换占位符,最后返回的整数类型,返回的是要打印出来的字符串的长度,vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出。
然后再到write函数的分析,write,写操作,把buf中的i个元素的值写到终端,引发系统调用syscall,显示格式化了的字符串,字符串的每个字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
同样先研究getchar函数的源代码:
图 8 3 getchar函数源代码
观察源代码发现,getchar调用了read函数,从标准输入缓冲区读取BUFSIZE个字符到静态字符串数组buf中,从缓冲区中返回一个字符,而当缓冲区字符个数为0时,则返回EOF,表示读到文件末尾。getchar函数的返回值是用户输入的字符的ASCII码。若用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar继续读取,则后续的getchar调用不会等待用户按键,而会直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
这一章主要介绍了hello的IO管理,简述了文件的概念,以及unix I/O的接口和函数,同时分析了库函数printf和getchar函数的实现。以此为基础,了解我们的hello程序是如何在屏幕上输出的,同时其中调用getchar函数获取我们的输入等等,
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
hello这个源程序不过短短几行而已,同时也并不是很难,但从其出生到死亡可谓是漫长而又丰富的一段历程,其中它遇到了许多“人”,为其服务,帮助他成长,下面一条条简要论述。
- 编写源程序:在IDE中用c语言编写代码,形成hello.c文件(文本文件)。
- 预处理:hello.c经过预处理器cpp处理,处理开头的预处理指令,将库文件加到文件中,解析宏定义,生成一个hello.i文件,一个修改了的源文件(文本文件)。
- 编译:hello.i文件经过编译器(ccl)处理,将c语言高级语言翻译成汇编语言,生成hello.s汇编文件(文本文件)。
- 汇编:hello.s经过汇编器as处理,编译成机器代码,生成可重定位目标文件hello.o文件)。
- 链接:hello.o可重定位目标文件经链接器lld和动态链接库链接,实现重定位,生成可执行目标程序hello(二进制文件)
- fork创建进程:在终端输入运行hello的指令,shell为hello程序fork子进程。
- execve加载运行程序:子进程中调用execve函数,加载并运行hello程序,进入hello的程序入口点,hello开始运行。
- 进程管理:内核负责调度进程,进行上下文切换,在用户模式和内核模式之间切换。
- 内存管理:hello运行需要读取指令和数据。MMU、TLB、多级页表、cache、DRAM内存、动态内存分配器相互协作,共同完成内存的管理。Unix I/O使得程序与文件进行交互。
- 信号处理:程序运行过程中,会碰到各种异常和接收到各种信号比如来自键盘的输入,调用异常处理程序处理这些异常和信号。
- 终止:hello走到生命的最后,终止运行,shell回收hello进程及相关资源,内核删除为hello进程创建的所有数据结构,hello丰富的一生结束了。
对计算机系统的设计与实现的深切感悟:
计算机系统这门课,可以说是每个程序员的必修课,要不然对于程序的理解永远是肤浅的。上完数据结构和算法这些课程后,我并没有太强烈的感觉成为了一个程序员,就是写代码、设计数据结构、设计算法来编写代码运行完成所要实现的需求就完了,说白了就是对高级语言语法的掌握和数据结构、算法的设计,其中重要的编写代码的逻辑。但是在上完计算机系统后,了解了一个编写的程序到底是怎么在计算机上运行的整个过程,其并不是我们简单地点一下运行就好了,其中发生了经历许多的变化,我感觉对程序地运行有了更深的理解、以及对更底层的理解。不再是前面的代码编写的软件部分,开始触碰到硬件,软硬结合, 威力无边。正如书所说,真正开始以一个程序员的角度看待程序,可谓是豁然开朗之感。
计算机系统的设计与实现经常让我发出惊叹,如此巧妙,又如此神奇,将计算机如此一个庞大、复杂的结构、体系层层相扣,从最基础的电路的有电没电,到高级语言程序的编写,而这样的体系在短短不到100年就发展起来,着手神奇。
总之,这门课受益匪浅,意犹未尽,感谢老师的辛苦教学。
附个人博客地址:https://1nvisble.github.io/
文章地址:https://1nvisble.github.io/2021/06/27/Hello%E7%9A%84%E4%B8%80%E7%94%9F/
CSDN博客地址:https://blog.csdn.net/weixin_45961864/article/details/118282290
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名称 文件作用
hello.c 保存hello源代码即源文件
hello.i 经过预处理的修改了的源文件
hello.s 编译器编译hello.i生成的汇编文件
hello.o 编译器编译hello.s生成的可重定位目标文件
hello.elf hello.o文件的ELF格式文件,查看hello.o各节的信息
hello.txt objdump反汇编hello.o生成的汇编文件与hello.s对比
hello 链接器重定位、链接生成的可执行目标文件
hello2.elf hello文件的ELF格式文件,查看hello各节的信息
hello2.txt objdump反汇编hello文件生成的汇编文件
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 图中部分图片来源于老师上课PPT
[2] 图中部分图片来源于书籍《深入理解计算机系统》第三版。
[3] https://www.runoob.com/linux/linux-comm-pstree.html
[4] https://www.runoob.com/cprogramming/c-function-vsprintf.html
[5] https://blog.csdn.net/yusiguyuan/article/details/9664887
[6] https://blog.csdn.net/cherisegege/article/details/80708143
[7] https://blog.csdn.net/weixin_41571493/article/details/80692749
(参考文献0分,缺失 -1分)