计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机类
学 号 120L021701
班 级 2003005
学 生 张晨曦
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
本文主要讲述了hello.c在编写完成后运行在Linux系统中的生命历程,运用相关工具进行分析程序预处理、编译、汇编、链接等过程在Linux下实现的原理,分析这些过程中产生的文件的相应信息以及作用;并介绍了shell的内存管理,IO管理,进程管理等相关知识,了解虚拟内存、异常信号等内容。
本文涵盖了计算机系统课程的主要知识点和主体框架。
关键词:预处理;编译;汇编;链接;shell;IO管理;进程管理;虚拟内存;异常信号
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 35 -
6.3 Hello的fork进程创建过程... - 36 -
6.6.1 异常种类及其产生信号和处理方法... - 38 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 41 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 43 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 45 -
7.5 三级Cache支持下的物理内存访问... - 46 -
7.6 hello进程fork时的内存映射... - 47 -
7.7 hello进程execve时的内存映射... - 47 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:
即From Program to Process
Program: 程序员在编辑器中输入代码得到hello.c(Program)。
Process: hello.c经过一系列处理最终得到的新的子进程(Process)。
Process:Linux下,由程序员在编辑器编写的hello.c(Program)经过cpp的预处理、ccl的编译、as的汇编、 ld 的链接最终成为可执行目标文件hello。在 shell 中输入启动命令./hello后, shell调用fork函数,产生新的子进程(Process)。
020:
即From Zero-0 to Zero-0
程序员在 shell 中运行可执行目标文件hello。shell 识别出这是一个外部命令,先调用 fork 函数创建了一个新的子进程(Process),然后调用 execve函数在新的子进程中加载并运行 hello,运行 hello 还需要 CPU 为 hello 分配内存、时间片。在 hello运行的过程中,CPU 要访问相关数据需要 MMU 的虚拟地址到物理地址的转化,其中 TLB 和四级页表为提高地址翻译的速度做出了巨大贡献,得到物理地址后三级 Cache 又帮助 CPU 快速得到需要的字节。系统的进程管理帮助 hello 切换上下文、shell 的信号处理程序使得 hello 在运行过程中可以处理各种信号,当程序员主动地按下 Ctrl+Z 或者 hello 运行到” return 0 ”;时hello 所在进程将被杀死,shell 会回收它的僵死进程。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
1.2.1 硬件环境
X64 CPU;2.11GHz;16GRAM;512G SSD
1.2.2 软件环境
Windows10 64位;Virtual Box 6.1;Ubuntu 20.04 LTS 64位
1.2.3 开发工具
CodeBlocks 64位;vi/vim/gedit+gcc;GDB;OBJDUMP;EDB
1.3 中间结果
文件名称 | 作用 |
hello.i | hello.c经预处理得到的ASCII文本文件 |
hello.s | hello.i经编译得到的汇编代码ASCII文本文件 |
hello.o | hello.s经汇编得到的可重定位目标文件 |
hello_elf.txt | hello.o经readelf分析得到的文本文件 |
hello_dis.txt | hello.o经objdump反汇编得到的文本文件 |
hello | hello.o经链接得到的可执行文件 |
hello1_elf.txt | hello经readelf分析得到的文本文件 |
hello1_dis.txt | hello经objdump反汇编得到的文本文件 |
1.4 本章小结
本章中我们介绍了hello的一生的两大重要转变:P2P和020,让我们看到了一个源程序经过预处理、编译、汇编、链接等阶段,最终成为一个可执行目标文件。
本章还介绍了本次实验需要用到的环境与工具,以及从hello.c到hello的过程中产生的所有中间结果(即中间文件)。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理阶段一般发生在编译阶段之前,在这个阶段中预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如 对于#include<stdio.h>, #include 命令告诉预处理器读取系统头文件 stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常 是以.i作为文件扩展名。
预处理的作用:
预处理的作用主要可分为以下三部分:
(1) 宏展开:预处理程序中的“#define”标识符文本,用实际值(可以是字符 串、代码等)替换用“#define”定义的字符串;
(2) 文件包含复制:预处理程序中用“#include”格式包含的文件,将文件的内 容插入到该命令所在的位置并删除原命令,从而把包含的文件和当前源文 件连接成一个新的源文件,这与复制粘贴类似;
(3) 条件编译处理:根据“#if”和“#endif”、“#ifdef”和“#ifndef”后面的 条件确定需要编译的源代码。
2.2在Ubuntu下预处理的命令
利用指令cpp hello.c > hello.i 可以将预处理结果输出至文件hello.i中
预处理命令及查看命令截图
图2.2-1 cpp指令
预处理结果的部分截图如下:
图2.2-2 预处理结果
2.3 Hello的预处理结果解析
原本除注释外为18行的源程序在经过预处理后变为了3060行。
1到7行为源程序相关的一些信息
图2.3-1 源程序相关信息
随后依次进行头文件stdio.h unistd.h stdlib.h的展开。
以stdio.h为例来介绍展开的具体过程:
stdio.h是标准库文件,cpp会到Linux系统的环境变量下寻找stdio.h,打开/usr/include/stdio.h (具体见下图文件包含信息)
图2.3-2 stdio.h的展开
随后cpp发现stdio.h使用了“#define”、“#include” 等,故cpp对它们进行递归展开替换,最终的hello.i文件中删除了原有的这部分;对于其中使用的“#ifdef”、“#ifndef”等条件编译语句,cpp会对条件值进行判断来决定是否对此部分进行包含。(最终结果具体见下图的类型定义信息和函数声明信息)
类型定义信息:
图2.3-3 类型定义信息
函数声明信息:
图2.3-4 函数声明信息
最后的部分是hello.c的源代码,由下图我们可以观察到除注释和以“#”开头的语句被删除外,其他内容保持不变。
图2.3-4 预处理后保留的源程序部分
2.4 本章小结
本章我们介绍了预处理的概念和作用,以及预处理的指令,随后分析了预处理的过程与结果。通过本章的学习我们也了解到了C 语言预处理一般由预处理器(cpp)进行,主要完成四项工作:宏展开、文件包含复制、条件编译处理和删除注释及多余空白字符。
第3章 编译
3.1 编译的概念与作用
(1)编译的概念
编译是指对经过预处理之后的源程序代码进行分析检查,确认所有语句均符合语法规则后将其翻译成等价的中间代码或汇编代码的过程。在本过程中即指编译器将 hello.i 翻译成 hello.s。
(2)编译的作用
编译一般可以分为以下五个步骤,其中每个步骤的作用分别为:
(1) 词法分析:对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。
(2) 语法分析:编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序。
(3) 中间代码:源程序的一种内部表示,或称中间语言。中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码。
(4) 代码优化:对程序进行多种等价变换,便于生成更有效的目标代码。
(5) 目标代码:目标代码生成器把语法分析后或优化后的中间代码变换成目标代码,此处指目标代码为汇编代码。可以说,编译的作用是通过一系列步骤让源代码更接近机器语言。编译是汇编阶段翻译成机器语言的前提。
3.2 在Ubuntu下编译的命令
输入命令gcc -S hello.i -o hello.s 进行编译
输入命令 gedit hello.s 进行查看
输入命令及得到的部分结果如下图所示:
图3.2-1 编译指令及部分结果
3.3 Hello的编译结果解析
3.3.1 数据
C语言中的数据包括常量、变量(全局/局部/静态)、表达式、类型、宏。
常量以立即数的形式出现在hello.s中,如汇编代码中的
图3.3.1-1 常量举例1
对应源代码中的if(argc!=4),其中的4就以立即数的形式出现。
以及汇编代码中的
图3.3.1-2 常量举例2
对应源代码中的exit(1),其中的1也以立即数的形式出现。
值得注意的是,for(i=0;i<8;i++)中的i<8被优化为了i<=7,所以下面的汇编代码中的立即数为对应的7。
图3.3.1-3 常量举例3
变量包括全局变量、局部变量和静态变量。由课上学习的知识我们可以知道已初始化的全局变量和静态变量存放在.data节,未初始化的全局变量和静态变量存放在.bss节,而局部变量存放在栈中管理。观察源程序hello.c我们可以知道其中没有全局变量和静态变量,故hello.s的最开始伪指令中没有.data和.bss。
for(i=0;i<8;i++)中的i是一个局部变量,如下图所示,在汇编代码中首先将其初始化为0:
图3.3.1-4 局部变量举例1
随后不断地进行加一和与7比较(i<8被优化为i<=7所以与7进行比较):
图3.3.1-5 局部变量举例2
表达式共有四处,在源程序中分别为argc!=4 i=0 i<8 i++.
argc!=4在汇编代码中的实现即为
图3.3.1-6 表达式举例1
i=0在汇编代码中的实现即为
图3.3.1-7 表达式举例2
i<8在汇编代码中的实现(i<8被优化为i<=7所以与7进行比较)即为
图3.3.1-8 表达式举例3
i++在汇编代码中的实现即为
图3.3.1-9 表达式举例4
3.3.2 赋值
源程序中共有一个赋值语句i=0,在汇编程序中借助movl指令实现。
图3.3.2 赋值举例
3.3.3 算术操作
源程序中共有一处算术操作为i++,在汇编代码中借助于addl指令实现
图3.3.3 算术举例
3.3.4 关系操作
源程序中关系操作共有两处,分别为argc!=4和i<8。
argc!=4在汇编代码中借助于cmpl指令实现(如下图所示)
图3.3.4-1 关系操作举例1
i<8在汇编代码中借助于cmpl指令实现(如下图所示,i<8被优化为i<=7所以与7进行比较)
图3.3.4-2 关系操作举例2
3.3.5 控制转移
源程序所对应的汇编代码中利用到了控制转移的共有两处,分别为
(1)if语句
图3.3.5-1 控制转移举例1
在汇编代码中借助于cmpl指令和je指令来实现(如下图所示)
图3.3.5-2 控制转移举例2
(2)for循环
图3.3.5-3 控制转移举例3
在汇编代码中借助于cmpl指令和jle指令来实现(如下图所示)
图3.3.5-4 控制转移举例4
3.3.6 函数调用
源程序中的函数调用共有四处
- printf("用法: Hello 学号 姓名 秒数!\n"),利用寄存器传值随后利用call指令转移到调用的程序来实现。汇编代码实现如图:
图3.3.6-1 函数调用举例1
2.exit(1),利用寄存器传值随后利用call指令转移到调用的程序来实现。汇编代码实现如图:
图3.3.6-2 函数调用举例2
3.sleep(atoi(argv[3])),利用寄存器%rdi传值随后利用call指令转移到调用的程序来实现。汇编代码实现如图:
图3.3.6-3 函数调用举例3
4.printf("Hello %s %s\n",argv[1],argv[2]),可以看到汇编代码利用%rdx和%rsi来传递argv[1]和argv[2]。随后利用call指令转移到调用的程序来实现。汇编代码实现如图:
图3.3.6-4 函数调用举例4
5.除此之外还有利用ret指令来实现源程序中的return 0;汇编代码实现如图
图3.3.6-5 函数调用举例5
3.3.7 数组操作
在源程序中存在一个指针数组argv,每个数组元素是一个指向参数字符串的指针。源程序中共存在两次数组操作。
图3.3.7-1 argv数组的元素分布
(1)printf("Hello %s %s\n",argv[1],argv[2]);所对应的汇编代码实现如图:
图3.3.8-2 第一次数组操作
可以看到汇编代码利用寄存器%rdx和%rsi来传递argv[1]和argv[2]。
(2)sleep(atoi(argv[3])); 所对应的汇编代码实现如图:
图3.3.8-3 第二次数组操作
可以看到汇编代码利用寄存器%rdi来传递argv[3]。
3.4 本章小结
本章主要介绍了编译的概念以及过程。编译程序所做的工作,就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码表示。同时通过示例函数表现了C语言如何转换成为汇编代码。介绍了汇编代码如何实现数据、赋值、算术操作、关系操作、控制转移、函数调用、数组操作。通过本章的学习我更深刻地理解了 C 语言的数据与操作,对 C 语言翻译成汇编语言的过程有了更好的掌握。
第4章 汇编
4.1 汇编的概念与作用
- 汇编的概念
驱动程序运行(或直接运行)汇编器 as,将汇编语言程序(在本章指 hello.s)翻译成机器语言指令,并将这些指令打包成可重定位目标文件(在本章中指hello.o)的过程称为汇编,hello.o 是二进制编码文件,包含程序的机器指令编码。
- 汇编的作用
汇编就是将高级语言转化为机器可直接识别执行的代码文件的过程。汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
利用指令 as hello.s -o hello.o 即可进行汇编
图4.2-1 汇编指令
可以看到成功得到了hello.o
图4.2-2 输入指令后的结果
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
4.3.1 readelf命令
利用命令 readelf -a hello.o > hello_elf.txt 将 hello.o 中ELF格式相关信息重定向至文件 hello_elf.txt。
图4.3.1-1 输入readelf指令并用gedit打开
4.3.2 ELF头
具体内容如下:
图4.3.2-1 ELF头信息
分析:
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小,目标文件的类型(可重定位、可执行或者是共享的)、机器类型(如x86-64)、节头部表的文件偏移、以及节头部表中条目的大小和数量。
4.3.3 节头目录
此部分列出了 hello.o 中的14个节的名称、类型、地址、偏移量、大小等信息。具体内容(hello_elf.txt 文件第 22 ~ 52 行)如下:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000092 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000388
00000000000000c0 0000000000000018 I 11 1 8
[ 3] .data PROGBITS 0000000000000000 000000d2
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 000000d2
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .rodata PROGBITS 0000000000000000 000000d8
0000000000000033 0000000000000000 A 0 0 8
[ 6] .comment PROGBITS 0000000000000000 0000010b
000000000000002c 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 00000137
0000000000000000 0000000000000000 0 0 1
[ 8] .note.gnu.propert NOTE 0000000000000000 00000138
0000000000000020 0000000000000000 A 0 0 8
[ 9] .eh_frame PROGBITS 0000000000000000 00000158
0000000000000038 0000000000000000 A 0 0 8
[10] .rela.eh_frame RELA 0000000000000000 00000448
0000000000000018 0000000000000018 I 11 9 8
[11] .symtab SYMTAB 0000000000000000 00000190
00000000000001b0 0000000000000018 12 10 8
[12] .strtab STRTAB 0000000000000000 00000340
0000000000000048 0000000000000000 0 0 1
[13] .shstrtab STRTAB 0000000000000000 00000460
0000000000000074 0000000000000000 0 0 1
分析:
(1) 由于是可重定位目标文件,所以每个节都从0开始,用于重定位;
(2) .text段是可执行的,但是不能写;
(3) .data段和.rodata段都不可执行且.rodata段不可写;
(4) .bss段大小为0。
4.3.4 重定位节
重定位节记录了各段引用的符号相关信息,在链接时,需要通过重定位节对这些位置的地址进行重定位。链接器会通过重定位条目的类型判断如何计算地址值并使用偏移量等信息计算出正确的地址。
具体内容如下:
Relocation section '.rela.text' at offset 0x388 contains 8 entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000001c 000500000002 R_X86_64_PC32 0000000000000000 .rodata - 4
000000000021 000c00000004 R_X86_64_PLT32 0000000000000000 puts - 4
00000000002b 000d00000004 R_X86_64_PLT32 0000000000000000 exit - 4
000000000054 000500000002 R_X86_64_PC32 0000000000000000 .rodata + 22
00000000005e 000e00000004 R_X86_64_PLT32 0000000000000000 printf - 4
000000000071 000f00000004 R_X86_64_PLT32 0000000000000000 atoi - 4
000000000078 001000000004 R_X86_64_PLT32 0000000000000000 sleep - 4
000000000087 001100000004 R_X86_64_PLT32 0000000000000000 getchar - 4
Relocation section '.rela.eh_frame' at offset 0x448 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0
分析:本程序需要重定位的符号有:.rodata,puts,exit,printf,sleepsecs, sleep,getchar及.text等。注意到重定位类型仅有R_X86_64_PC32(PC相对寻址) 和R_X86_64_PLT32(使用PLT表寻址)两种,而未出现R_X86_64_32(绝对寻 址)。
4.3.5 符号表
符号表(.symtab)存放在程序中定义和引用的函数和全局变量的信息。 具体内容如下:
Symbol table '.symtab' contains 18 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS hello.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 8
8: 0000000000000000 0 SECTION LOCAL DEFAULT 9
9: 0000000000000000 0 SECTION LOCAL DEFAULT 6
10: 0000000000000000 146 FUNC GLOBAL DEFAULT 1 main
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND exit
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND atoi
16: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sleep
17: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND getchar
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
4.4.1 objdump命令
输入命令 objdump -d -r hello.o > hello_dis.txt 随后利用gedit查看。
4.4.2 反汇编代码
反汇编代码具体如下:
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 20 sub $0x20,%rsp
c: 89 7d ec mov %edi,-0x14(%rbp)
f: 48 89 75 e0 mov %rsi,-0x20(%rbp)
13: 83 7d ec 04 cmpl $0x4,-0x14(%rbp)
17: 74 16 je 2f <main+0x2f>
19: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 20 <main+0x20>
1c: R_X86_64_PC32 .rodata-0x4
20: e8 00 00 00 00 callq 25 <main+0x25>
21: R_X86_64_PLT32 puts-0x4
25: bf 01 00 00 00 mov $0x1,%edi
2a: e8 00 00 00 00 callq 2f <main+0x2f>
2b: R_X86_64_PLT32 exit-0x4
2f: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
36: eb 48 jmp 80 <main+0x80>
38: 48 8b 45 e0 mov -0x20(%rbp),%rax
3c: 48 83 c0 10 add $0x10,%rax
40: 48 8b 10 mov (%rax),%rdx
43: 48 8b 45 e0 mov -0x20(%rbp),%rax
47: 48 83 c0 08 add $0x8,%rax
4b: 48 8b 00 mov (%rax),%rax
4e: 48 89 c6 mov %rax,%rsi
51: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 58 <main+0x58>
54: R_X86_64_PC32 .rodata+0x22
58: b8 00 00 00 00 mov $0x0,%eax
5d: e8 00 00 00 00 callq 62 <main+0x62>
5e: R_X86_64_PLT32 printf-0x4
62: 48 8b 45 e0 mov -0x20(%rbp),%rax
66: 48 83 c0 18 add $0x18,%rax
6a: 48 8b 00 mov (%rax),%rax
6d: 48 89 c7 mov %rax,%rdi
70: e8 00 00 00 00 callq 75 <main+0x75>
71: R_X86_64_PLT32 atoi-0x4
75: 89 c7 mov %eax,%edi
77: e8 00 00 00 00 callq 7c <main+0x7c>
78: R_X86_64_PLT32 sleep-0x4
7c: 83 45 fc 01 addl $0x1,-0x4(%rbp)
80: 83 7d fc 07 cmpl $0x7,-0x4(%rbp)
84: 7e b2 jle 38 <main+0x38>
86: e8 00 00 00 00 callq 8b <main+0x8b>
87: R_X86_64_PLT32 getchar-0x4
4.4.3 比较分析
将反汇编得到的结果与hello.s进行对比,可以发现以下不同:
- 数的表示:hello.s 中的操作数是十进制,hello.o 反汇编代码中的操作数是十六进制。
- 函数调用:hello.s 中,call 指令使用的是函数名称,而反汇编代码中 call 指令使用的是 main 函数的相对偏移地址。因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text 节中为其添加了重定位条目。
- hello.s 中提供给汇编器的辅助信息在反汇编代码中不再出现,可能是在汇编器处理过程中被移除,如“.cfi_def_cfa_offset 16”等。
- 分支转移:跳转语句之后,hello.s 中是.L2 和.LC1 等段名称,而反汇编代码中跳转指令之后是相对偏移的地址,即间接地址。
4.5 本章小结
本章对汇编结果进行了详尽的介绍。经过汇编器的处理,汇编语言转化为机器语言,同时hello.o 可重定位目标文件的生成为后面的链接做了准备。通过对比 hello.s 和 hello.o 反汇编代码的区别,令我更深刻地理解了汇编语言到机器语言实现地转变。通过本章内容的过程加深了我对汇编过程、ELF 格式以及重定位的理解。
第5章 链接
5.1 链接的概念与作用
(1)链接的概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程(在本章中,链接即是指将可重定向目标文件 hello.o 与其他一些文件组合成为可执行目标文件 hello),这个文件可被加载到内存并执行。在现代系统中,链接是由链接器进行的。
(2)链接的作用
链接可以实现分离编译。我们可以借助链接的优势将大型的应用程序分解成更小、更加易于管理的模块,使得各模块之间的修改都和编译相互独立。这样当我们需要修改某一模块时只需要重新编译经过修改的模块并重新链接即可,不需要重新编译其他文件。
5.2 在Ubuntu下链接的命令
利用指令ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o hello.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -o hello
指令截图如下:
图5.2-1 ld指令
观察文件夹可以看到我们成功得到了可执行文件hello
图5.2-2 ld指令运行结果
5.3 可执行目标文件hello的格式
输入指令 readelf -a hello > hello1.elf 将 hello 中 ELF 格式相关信息重定向至文件 hello1_elf.txt。
各段的基本信息的具体内容如下:
ELF文件头:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x4010f0
Start of program headers: 64 (bytes into file)
Start of section headers: 14208 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 12
Size of section headers: 64 (bytes)
Number of section headers: 27
Section header string table index: 26
节头:
描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 00000000004002e0 000002e0
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.gnu.propert NOTE 0000000000400300 00000300
0000000000000020 0000000000000000 A 0 0 8
[ 3] .note.ABI-tag NOTE 0000000000400320 00000320
0000000000000020 0000000000000000 A 0 0 4
[ 4] .hash HASH 0000000000400340 00000340
0000000000000038 0000000000000004 A 6 0 8
[ 5] .gnu.hash GNU_HASH 0000000000400378 00000378
000000000000001c 0000000000000000 A 6 0 8
[ 6] .dynsym DYNSYM 0000000000400398 00000398
00000000000000d8 0000000000000018 A 7 1 8
[ 7] .dynstr STRTAB 0000000000400470 00000470
000000000000005c 0000000000000000 A 0 0 1
[ 8] .gnu.version VERSYM 00000000004004cc 000004cc
0000000000000012 0000000000000002 A 6 0 2
[ 9] .gnu.version_r VERNEED 00000000004004e0 000004e0
0000000000000020 0000000000000000 A 7 1 8
[10] .rela.dyn RELA 0000000000400500 00000500
0000000000000030 0000000000000018 A 6 0 8
[11] .rela.plt RELA 0000000000400530 00000530
0000000000000090 0000000000000018 AI 6 21 8
[12] .init PROGBITS 0000000000401000 00001000
000000000000001b 0000000000000000 AX 0 0 4
[13] .plt PROGBITS 0000000000401020 00001020
0000000000000070 0000000000000010 AX 0 0 16
[14] .plt.sec PROGBITS 0000000000401090 00001090
0000000000000060 0000000000000010 AX 0 0 16
[15] .text PROGBITS 00000000004010f0 000010f0
0000000000000145 0000000000000000 AX 0 0 16
[16] .fini PROGBITS 0000000000401238 00001238
000000000000000d 0000000000000000 AX 0 0 4
[17] .rodata PROGBITS 0000000000402000 00002000
000000000000003b 0000000000000000 A 0 0 8
[18] .eh_frame PROGBITS 0000000000402040 00002040
00000000000000fc 0000000000000000 A 0 0 8
[19] .dynamic DYNAMIC 0000000000403e50 00002e50
00000000000001a0 0000000000000010 WA 7 0 8
[20] .got PROGBITS 0000000000403ff0 00002ff0
0000000000000010 0000000000000008 WA 0 0 8
[21] .got.plt PROGBITS 0000000000404000 00003000
0000000000000048 0000000000000008 WA 0 0 8
[22] .data PROGBITS 0000000000404048 00003048
0000000000000004 0000000000000000 WA 0 0 1
[23] .comment PROGBITS 0000000000000000 0000304c
000000000000002b 0000000000000001 MS 0 0 1
[24] .symtab SYMTAB 0000000000000000 00003078
00000000000004c8 0000000000000018 25 30 8
[25] .strtab STRTAB 0000000000000000 00003540
0000000000000158 0000000000000000 0 0 1
[26] .shstrtab STRTAB 0000000000000000 00003698
00000000000000e1 0000000000000000 0 0 1
5.4 hello的虚拟地址空间
使用edb加载hello,结果如图所示:
图5.4-1 edb结果
通过与Symbols窗口对照,可以发现各段均一一对应,如下图所示:
图 5.4-1 Symbols与Data Dump对照截图1
图 5.4-2 Symbols与Data Dump对照截图2
图 5.4-3 Symbols与Data Dump对照截图3
图 5.4-4 Symbols与Data Dump对照截图4(.text段部分)
图 5.4-5 Symbols与Data Dump对照截图5(.rodata段部分)
图 5.4-6 Symbols与Data Dump对照截图6(.data段部分)
在图5.4-5、5.4-6中,我们分别可以看到程序printf函数的字符串、全局变量sleepsecs(值为2),进一步说明了各段的虚拟地址与节头部表的对应关系。
5.5 链接的重定位过程分析
命令:objdump -d -r hello > hello1_dis.txt
注:hello的完整反汇编代码见hello1_dis.txt,hello.o的反汇编代码见hello_dis.txt,此节进行分析和说明。
分析如下:
- 整体上来看,hello的反汇编代码比hello.o的反汇编代码多了一些节(如.init, .plt, .plt.sec等),如下图所示:
图 5.5-1 hello相较于hello.o多出了一些节
- hello中加入了一些函数,如_init(),_start()以及一些主函数中调用的库函数,如下图所示:
图 5.5-2 hello相较于hello.o增加了一些函数
- hello中不再存在hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址,如下图:
图 5.5-3 hello对于函数调用和跳转语句中重定向的处理
根据以上分析我们可以看出,链接过程会扫描分析所有相关的可重定位目标文件,并完成两个主要任务:首先进行符号解析,将每个符号引用与一个符号定义关联起来;随后进行重定位,链接器使用汇编器产生的重定位条目的详细指令,把每个符号定义与一个内存位置关联起来。最终的结果是将程序运行所需的各部分组装在一起,形成一个可执行目标文件。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
使用edb单步调试运行程序,观察其调用的函数,这里可以发现在调用main之前主要进行了初始化的工作调用了_init,在这个函数之后动态链接的重定位工作已经完成,我们可以看到在这个函数的调用之后是一系列在这个程序中所用到的库函数(printf,exit,atoi等等)这些函数实际上在代码段并不占用实际的空间只是一个占位的符号,实际上他们的内容在共享区(高地址)处。之后调用了_start这个就是起始的地址,准备开始执行main的内容,main函数内部所调用的函数在第三章已经进行了充分的分析这里略过main内部的函数,在执行main之后还会执行__libc_csu_init 、__libc_csu_fini 、_fini 最终这个程序才结束。
下面列出了各个函数的名称与地址
- _init <0x00000000004004e0>
- puts@plt <0x0000000000400510>
- printf@plt <0x0000000000400520>
- getchar@plt <0x0000000000400530>
- atoi@plt <0x0000000000400540>
- exit@plt <0x0000000000400550>
- sleep@plt <0x0000000000400560>
- _start <0x0000000000400570>
- _dl_relocate_static_pie <0x00000000004005a0>
- deregister_tm_clones <0x00000000004005b0>
- register_tm_clones <0x00000000004005e0>
- __do_global_dtors_aux <0x0000000000400620>
- frame_dummy <0x0000000000400650>
- main <0x0000000000400657>
- __libc_csu_init <0x00000000004006f0>
- __libc_csu_fini <0x0000000000400760>
- _fini <0x0000000000400764>
5.7 Hello的动态链接分析
我们可以在elf文件中可以找到:
[20] .got PROGBITS 0000000000403ff0 00002ff0
0000000000000010 0000000000000008 WA 0 0 8
[21] .got.plt PROGBITS 0000000000404000 00003000
0000000000000048 0000000000000008 WA 0 0 8
随后进入edb查看:
图5.7-1 edb执行init之前的地址
图5.7-2 edb在执行init之后的地址
通过以上两张图的对比,我们可以观察到,对于变量而言,我们利用代码段和数据段的相对位置不变的原则计算正确地址。对于库函数而言,需要plt、got合作,plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。plt就能跳转到正确的区域。
5.8 本章小结
本章着重介绍了可重定位目标文件 hello.o 经过链接生成可执行目标文件 hello 的过程。首先详细介绍、分析了链接的概念、作用及具体工作。随后验证了 hello 的虚拟地址空间与节头部表信息的对应关系,分析了 hello 的执行流程。最后对 hello 程序进行了动态链接分析。经过本章的学习,我更加深刻地理解了链接和重定位的相关概念,复习了课程第七章链接的相关知识,也了解了动态链接的过程及作用。
第6章 hello进程管理
6.1 进程的概念与作用
(1)进程的概念
进程的经典定义是一个执行中程序的实例,是操作系统对一个正在运行的程 序的一种抽象。
(2)进程的作用
每次运行程序时,shell 创建一新进程,在这个进程的上下文切换中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它 们自己的代码或其他应用程序。
进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理 器;一个私有的地址空间,如同程序独占内存系统。
6.2 简述壳Shell-bash的作用与处理流程
(1)作用
Shell-bash 是一个交互型应用级程序,代表用户运行其他程序。它是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。
(2)处理流程
Shell 首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(比如Linux 自身的应用程序,如 ls 和 rm,也可以是应用商店的应用程序,如 xv)。随后 shell 在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给 Linux 内核。
简化后的处理流程如下所示:
1) 从终端读入输入的命令;
2) 将输入字符串切分获得所有的参数;
3) 如果是内置命令则立即执行;
4) 若不是则调用相应的程序执行;
5) shell 应该随时接受键盘输入信号,并对这些信号进行相应处理。
6.3 Hello的fork进程创建过程
根据 shell 的处理流程,可以推断,输入命令执行 hello 后,父进程如果判断不是内部指令,即会通过 fork 函数创建子进程。子进程与父进程近似,并得到一份与父进程用户级虚拟空间相同且独立的副本——包括数据段、代码、共享库、堆和用户栈。父进程打开的文件,子进程也可读写。二者之间最大的不同或许在于 PID 的不同。Fork 函数只会被调用一次,但会返回两次,在父进程中,fork 返回子进程的 PID,在子进程中,fork 返回 0。
6.4 Hello的execve过程
execve函数在加载并运行可执行目标文件Hello,且带列表argv和环境变量列表envp。该函数的作用就是在当前进程的上下文中加载并运行一个新的程序。
只有当出现错误时,例如找不到Hello时,execve才会返回到调用程序,这里与一次调用两次返回的fork不同。
在execve加载了Hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下的原型:
int main(intargc , char **argv , char *envp);
结合虚拟内存和内存映射过程,可以更详细地说明exceve函数实际上是如何加载和执行程序Hello:
- 删除已存在的用户区域(自父进程独立)。
- 映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的。
- 映射共享区:比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
- 设置PC:exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
6.5 Hello的进程执行
进程上下文信息:
进程上下文信息即指内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成
在内核调度了一个新的进程运行时,它就抢占当前进程,并使用一种上下文切换的机制来控制转移到新的进程。具体过程为:①保存当前进程的上下文;②恢复某个先前被抢占的进程被保存的上下文;③将控制传递给这个新恢复的进程。
进程时间片:
一个进程执行它的控制流的一部分的每一个时间段叫做时间片(time slice),多任务也叫时间分片(time slicing)。
进程调度:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这种决策称为调度,是由内核中的调度器代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
hello程序调用sleep函数休眠时,内核将通过进程调度进行上下文切换,将控制转移到其他进程。当hello程序休眠结束后,进程调度使hello程序重新抢占内核,继续执行。
用户态与核心态的转换:
为了保证系统安全,需要限制应用程序所能访问的地址空间范围。因而存在用户态与核心态的划分,核心态拥有最高的访问权限,而用户态的访问权限会受到一些限制。处理器使用一个寄存器作为模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,一定程度上保证了系统的安全性。
图 6.5-1 hello程序进程执行过程示意
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1 异常种类及其产生信号和处理方法
Hello执行过程中会出现4类异常:
- 中断:异步发生,是来自处理器外部的I/O设备的信号的结果。具体的处理为:
- 在当前指令的执行过程中,中断引脚电压变高了
- 在当前指令完成后,控制传递给处理程序
- 中断处理程序运行
- 处理程序返回下一条指令
- 陷阱:同步发生,有意的异常,是执行一条指令的结果。最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,即系统调用。具体的处理为:
- 应用程序执行一次系统调用
- 控制传递给处理程序
- 陷阱处理程序运行
- 处理程序返回到syscall之后的指令
- 故障:同步发生,由错误情况引起的,可能能够被故障处理程序修正。也就是说,如果处理程序能够修正这个错误情况,那么将控制返回到引起故障的指令,重新执行它;否则,处理程序返回内核中的abort例程,进行终止操作。具体处理为:
- 当前指令导致一个故障
- 控制传递给处理程序
- 故障处理程序运行
- 处理程序要么重新执行当前指令,要么终止
- 终止:同步发生,不可恢复的致命错误造成的结果,不将控制返回给应用程序。具体处理为:
- 发生致命的硬件错误
- 控制传递给处理程序
- 终止处理程序运行
- 处理程序返回到abort例程
6.6.2 具体异常与运行结果
(1)正常运行:
图6.6.2-1 正常运行的结果
(2)中途随便输入:只是将屏幕的输入缓存到缓冲区,待进程结束后又作为命令行进行输入
图6.6.2-1 运行过程中随机输入的结果
(3)Ctrl+Z:进程收到SIGTSTP信号,信号的动作是将hello挂起,通过ps命令看到hello进程没有被回收,其进程号是15840,用jobs命令看到job ID是1,状态是“已停止”,使用fg 1命令将其调到前台,shell会先键入之前给入的命令行的命令之后继续打印剩下的没有打印完的信息,之后再次键入Ctrl+Z,再将进程挂起。
图6.6.2-2 键入Ctrl+Z
此时键入pstree命令,看到的情况如下:在终端中看到了hello
图6.6.2-3 键入pstree指令
之后,再键入kill -9 15840命令,用jobs命令查看,看到进程的状态是“已杀死”,说明进程被终止了。利用ps命令查看,其中没有了hello进程,说明进程被终止且回收。
图6.6.2-3 键入kill指令
(4)Ctrl+C:进程收到的是SIGINT信号,程序终止并回收进程。ps命令查看下,确实已经没有了hello进程。
图6.6.2-4 键入Ctrl+C
6.7本章小结
本章主要介绍了进程的概念及作用,阐述了壳shell-bash的作用与处理流程,并以hello为例,分析了fork函数的执行过程,execve函数的执行过程,并对进程的创建、执行、上下文切换、用户态与内核态转化做了详细的分析。最后给出了异常的种类以及对hello进行一些异常操作处理,得到相应的结果。
在 hello 程序运行的过程中,内核对其进行进程管理,决定何时进行进程调度,在接收到不同的异常、信号时,还要及时地进行对应的处理。本章的内容引导我复习了课程第 8 章—异常控制流的相关内容,使我对进程、信号及异常相关概念的理解更加深刻。
第7章 hello的存储管理
(1)逻辑地址
逻辑地址(Logical Address)是指由程序产生的与段相关的偏移地址部分,是相对应用程序而言的,如hello.o中代码与数据的相对偏移地址。
(2)线性地址
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。逻辑地址加上相应段的基地址就生成了一个线性地址,如hello中代码与数据的地址。
(3)虚拟地址
有时我们也把逻辑地址称为虚拟地址(Virtual Address)。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。
(4)物理地址
物理地址(Physical Address)是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址(hello程序运行时代码、数据等对应的可用于直接在内存中寻址的地址)。如果启用了分页机制(当今绝大多数计算机的情况),那么线性地址会使用页目录和页表中的项变换成物理地址;如果没有启用分页机制,那么线性地址就直接成为物理地址了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
(1)基本原理:
在段式存储管理中,将程序的地址空间划分为若干段,这样每个二进制进程就有一个二维的地址空间。在段式存储管理系统中,为每个段分配一个连续的区,而进程的各个段可以被不连续地放进内存的不同分区中。程序加载时,操作系统为所有段分配其内存所需,这些段不必连续,物理内存的管理采用动态分区的管理方式。
程序通过分段划分出多个模块,如代码段、数据段、只读段等。
(2)数据结构:
段描述符,实际上就是段表项。分为两类:用户的代码和数据段描述符,以及系统控制段描述符(包括特殊系统控制段描述符以及控制转移类描述符)。描述符号实际上就是段表,由段描述符(段表项)组成,有三种类型:全局描述符表(GDT),局部描述符表(LDT),中断描述符表(IDT)。段描述符的定义:
图7.2.1 段地址描述
(3)段式管理下逻辑地址到线性地址的转换:
图7.2.2 段地址转化为线性地址
在段式管理系统中,整个进程的地址空间是二维的,即其逻辑地址由段号和段内地址组成,为了完成该进程逻辑地址到线性地址的转换,需要进行:
①使用段选择符中的偏移值(段索引)在GDT或LDT表中定位相应的段描述符.(仅当一个新的段选择符加载到段寄存器中是才需要这一步)
②利用段选择符检验段的访问权限和范围,以确保该段可访问。
③把段描述符中取到的段基地址加到偏移量(也就是上述汇编语言汇中直接出现的操作地址)上,最后形成一个线性地址。
图7.2.3 逻辑地址构建线性地址的具体过程
7.3 Hello的线性地址到物理地址的变换-页式管理
(1)基本原理:
将程序的逻辑地址空间划分为固定大小的页,而物理内存划分为同样大小的页框。程序加载时可将任意一页放入内存中任意一个页框,这些页框不必连续,从而实现了离散分配。在页式存储管理方式中地址结构由两部分构成,前一部分是虚拟页号(VPN),后一部分是虚拟页偏移量(VPO)。
图7.3.1 页式存储管理中的地址构成
页式管理方式的特点:没有外碎片,一个程序不必连续存放,便于改变程序占用空间的大小(随着程序运行,所需要的动态地址不断再增加)。
(2)数据结构:
在页式系统中进程建立时,操作系统为进程中所有的页分配页框,当进程撤销时回收所有分配的页框。在程序运行期间,如果允许进程动态地申请空间,操作系统还要为进程申请的空间分配物理页框。在操作系统完成这些功能的时候,必须记录系统内存中实际的页框使用情况。操作系统还要在进程切换时,正确地切换两个不同的进程地址空间到物理内存空间的映射,这也要求操作系统要记录每个进程页表的相关信息。
页表:页表将虚拟内存映射到物理地址空间,每次地址翻译硬件将一个虚拟地址转换为一个物理地址时,都会读取页表。页表是一个页表条目(PTE)的数组。虚拟地址空间中每个页在页表中一个固定偏移量处都有一个PTE,假设每个PTE是由是由一个有效位和n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中,如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页缓存了这个虚拟页;如果没有设置有效位,那么一个空地址表示这个页还没有被分配。
图7.3.2 页式管理
(3)页式管理地址变换:
MMU利用VPN来选择适当的PTE,将列表条目中的PPN与虚拟地址中的VPO串联起来,就得到相应的物理地址。
图7.3.3 虚拟地址转换为物理地址的具体过程
7.4 TLB与四级页表支持下的VA到PA的变换
TLB(翻译后备缓冲器):消除每次CPU产生一个虚拟地址MMU就必须查阅一个PTE带来的时间开销而在MMU中包括的一个关于PTE的小的缓存。TLB是一个小的、虚拟寻址的缓存、其中的每一行都保存着一个由单个PTE组成的块。TLB通常具有高度的相联度,TLB的速度快于一级cache。
TLB通过虚拟页号VPN部分进行索引,分为TLBT(TLB标记)和TLBI(TLB索引),这样每次MMU会从TLB中取出相应的PTE(页表条目),当TLB不命中时,MMU又从L1缓存中取出相应的PTE,新取出的PTE会存放在TLB中此时可能会覆盖一个已存在的条目。
使用层次结构的页表来压缩页表,形成相应的k级页表。那么虚拟地址被划分为VPO(虚拟页偏移量)以及k个VPN(虚拟页号),每个VPN i都是一个到第i级页表的索引。第j级页表中的每个PTE都指向第j+1级的某个页表的基址。第k级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE。
图7.4 页表支持下VA转换为PA
7.5 三级Cache支持下的物理内存访问
下图为通用的高速缓存存储器(Cache)组织结构示意图:
图 7.5 高速缓存存储器组织结构示意
- 根据PA、L1高速缓存的组数和块大小确定高速缓存块偏移(CO)、组索引(CI)和高速缓存标记(CT),使用CI进行组索引,对组中每行的标记与CT进行匹配。如果匹配成功且块的valid标志位为1,则命中,然后根据CO取出数据并返回数据给CPU。
- 若未找到相匹配的行或有效位为0,则L1未命中,继续在下一级高速缓存(L2)中进行类似过程的查找。若仍未命中,还要在L3高速缓存中进行查找。三级Cache均未命中则需访问主存获取数据。
- 若进行了(2)步,说明至少有一级高速缓存未命中,则需在得到数据后更新未命中的Cache。首先判断其中是否有空闲块,若有空闲块(有效位为0),则直接将数据写入;若不存在,则需根据替换策略(如LRU、LFU策略等)驱逐一个块再写入。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任何一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序。加载并运行a.out需要以下几个步骤:
①删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构;
②映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。代码和数据域被映射到hello文件中的.text节和.data节,bss节是请求二进制零的,映射到匿名文件,栈和堆区域也是请求二进制零的,初始长度为0;
③映射共享区域:如果hello程序域共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域中;
④设置程序计数器:设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。
7.8 缺页故障与缺页中断处理
在虚拟内存中DRAM缓存不命中称为缺页。缺页异常调用内核中的缺页异常处理程序。具体的过程为:
图7.8 缺页异常的处理
①处理器生成一个虚拟地址,并把它传送给MMU;
②MMU生成PTE地址,并从高速缓存/主存请求得到它;
③高速缓存/主存向MMU返回PTE;
④若PTE中的有效位是零,则MMU触发一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序;
⑤缺页异常处理程序确定物理内存中的牺牲页,如果这个页面已经被修改,则把它换出到磁盘;
⑥缺页处理程序页面调入新的页面(内核从磁盘复制所需的虚拟页面到内存中),更新内存中的PTE;
⑦缺页处理程序返回到原来的进程中,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU,因为虚拟页面已经现在在物理内存中了,所以就会命中。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,即堆。堆是一个请求二进制零的区域,它紧接在未初始化数据区域(.bss)后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个brk指针,指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格:显式分配器和隐式分配器。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
①显式分配器:要求应用显式地释放任何已分配的块。例如C程序通过调用malloc函数来分配一个块,通过调用free函数来释放一个块。其中malloc采用的总体策略是:先系统调用sbrk一次,会得到一段较大的并且是连续的空间。进程把系统内核分配给自己的这段空间留着慢慢用。之后调用malloc时就从这段空间中分配,free回收时就再还回来(而不是还给系统内核)。只有当这段空间全部被分配掉时还不够用时,才再次系统调用sbrk。当然,这一次调用sbrk后内核分配给进程的空间和刚才的那块空间一般不会是相邻的。
②隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫做垃圾收集器。
下面介绍两种重要的组织结构及原理:
带边界标记的隐式空闲链表的基本原理:
对于带边界标签的隐式空闲链表分配器,一个块是由一个字的头部、有效载荷、可能的一些额外的填充,以及在块的结尾处的一个字的脚部组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
图7.9-1 带边界标记的隐式空闲链表
显示空闲链表的基本原理:
显式空闲链表结构将堆组织成一个双向空闲链表,在每个空闲块的主体中,都包含一个pred(前驱)和succ(后继)指针。
图7.9-2 带脚部的显示空闲链表
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
但显示链表的缺点就是空闲块必须足够大,以包含所需要的指针,以及头部和可能的脚部。如此便导致了更大的最小块的大小,也潜在地提高了内部碎片的程度。
在选定了结构之后,还需要进行块的放置,包括以下三种适配策略:
①首次适配:从头开始搜索链表,选择第一个合适的空闲块。优点是它趋向于将大的空闲块保留在链表的后面。缺点是它趋向于在靠近链表起始处留下小空闲块的“碎片”,这就增大了对较大块的搜索时间;
②下一次适配:从上一次查询结束的地方开始。(关键的想法是如果我们上一次已经在某个空闲块中发现了一个匹配,那么下一次我们很有可能在剩余块中发现匹配)下一次适配比首次适配要快,但是对内存的利用率很低;
③最佳适配:检查每个空闲块,选择适合所需请求大小的最小空闲块。其内存利用率在三者中是最好的,但是在简单空闲链表组织中应用时,需要对堆进行彻底的搜索,时间开销比较大。但是用分离的空闲链表组织时,就不需要进行彻底搜索。
最后对空闲块的合并处理的说明:
最主要的就是边界标记技术,上文已经进行过说明,此处给出的是具体的情况:
图7.9-3 边界标记合并时的四种情况
①若当前块的两个相邻接的块都是已分配的,此时不能进行合并,所以直接返回当前块;
②若当前块的前一块是已分配的而后一块是空闲的,则可以将当前块与后一块进行合并;
③若当前块的前一块是空闲的而后一块是已分配的,则可以将当前块与前一块进行合并;
④当前块的相邻接的块都是空闲的,那么自然就需要将所有的三个块形成一个单独的空闲块。
7.10本章小结
本章介绍了hello的存储地址空间,intel的段式管理、hello的页式管理,以及在TLB和四级页表的支持下完成VA到PA的变换过程,三级Cache支持下的物理内存访问。解释了hello进程的fork与execve时的内存映射,缺页故障及其处理,以及进程的动态存储分配的管理。让我对第六章第九章的知识掌握更加清晰。
第8章 hello的IO管理
-
- Linux的IO设备管理方法
(1)设备的模型化——文件
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件。
例如:/dev/sda2文件是用户磁盘分区,/dev/tty2文件是终端。
(2)设备管理——Unix IO接口
将设备模型化为文件的方式允许Linux内核引入一个简单、低级的应用接口,称为Unix IO,这使得所有的输入和输出都能以一种统一的方式来执行。
8.2 简述Unix IO接口及其函数
(1)Unix I/O接口:
①打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需要记住这个描述符;
②Linux shell 创建的每个进程开始时都有三个打开文件:标准输入(描述符为0)、标准输出(描述符为1)、标准错误(描述符为2);
③改变当前的文件位置:对于每个打开的文件,内核都保持着一个文件位置k,初始为0.这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显示地显示文件的当前位置为k;
④读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为EOF的条件,应用程序能检测到这俄格文件。在文件结尾处并没有明确的“EOF符号”;类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置开始,然后更新k
⑤关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为相应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们。
(2)Unix I/O函数:
①int open(char *filename, int flags, mode_t mode):open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(可读可写)
②int close(int fd):关闭一个打开的文件,注意关闭一个已关闭的描述符会出错。
③ssize_t read(int fd, void *buf, size_t n):read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
④ssize_t write(int fd, const void *buf, size_t n):write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
- printf函数体:
int printf(const char *fmt, ...)
{
int i;
va_list arg = (va_list)((char *)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
分析:
- printf函数调用了vsprintf函数,最后通过系统调用函数write进行输出;
- va_list是字符指针类型;
- ((char *)(&fmt) + 4)表示...中的第一个参数。
- printf调用的vsprintf函数:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char *p;
chartmp[256];
va_listp_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的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出写入buf供系统调用write输出时使用。
- write系统调用:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
分析:这里通过几个寄存器进行传参,随后调用中断门int INT_VECTOR_SYS_CALL
即通过系统来调用sys_call实现输出这一系统服务。
- sys_call部分内容:
sys_call:
/*
* ecx中是要打印出的元素个数
* ebx中的是要打印的buf字符数组中的第一个元素
* 这个函数的功能就是不断的打印出字符,直到遇到:'\0'
* [gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
*/
xor si,si
mov ah,0Fh
mov al,[ebx+si]
cmp al,'\0'
je .end
mov [gs:edi],ax
inc si
loop:
sys_call
.end:
ret
分析:通过逐个字符直接写至显存,输出格式化的字符串。
- 最后一部分工作:
字符显示驱动子程序实现从ASCII到字模库到显示vram(即显存,存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
int getchar(void)
{
static char buf[BUFSIZE];
static char* b = buf;
static int n = 0;
if (n == 0)
{
read(0, buf, BUFSIZE);
b = buf;
}
return ((--n) > 0) ? (unsigned char)*b++: EOF;
}
当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCⅡ码,保存到系统的键盘缓冲区中。
getchar调用read系统函数,read通过syscall(系统调用)调用内核中的系统函数,读取存储在键盘缓冲区中的ASCⅡ码,直到读入回车符,然后返回整个字符串,getchar函数只从中读取第一个字符,其他字符被存放在输入缓冲区。
8.5本章小结
本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及 Unix I/O 接口 及其函数。最后分析了 printf 函数和 getchar 函数的工作过程。本章内容使我们体会到 Unix IO 接口在 Linux 系统中的重要作用,同时也了解了作为异步异常之一的键盘中断的处理。
结论
一、用计算机系统的语言,逐条总结hello所经历的过程。
伴随着本篇大作业的书写,hello也走完了自己精彩的一生,其中需要指出的关于hello一生的重大事件包括:
①编写生成源程序,利用文本编辑器生成hello.c;
②进行预处理,经过预处理器cpp的预处理,生成了hello.i文件;
③进行编译,经过编译器ccl将文本文件hello.i编译成汇编文件hello.s;
④进行汇编,经过汇编器as将汇编文件转换为机器语言的可重定位二进制目标文件hello.o;
⑤进行链接,经过链接器ld将可重定位二进制目标文件与动态链接库.so进行链接生成可执行二进制目标文件hello,至此hello成为了一个可执行文件;
⑥运行,在shell中键入./1190200926 郭鑫杰 1,程序开始运行;
⑦进程管理,shell调用fork函数为程序创建子进程,fork函数在新的子进程中运行相同的程序;
⑧加载,shell调用execve函数,execve函数调用启动加载器,加载器删除子进程现有的虚拟内存段,创建一组新的代码、数据、堆和栈。虚拟地址空间中的页映射到可执行文件的页大小的片,最后加载器会跳到_start位置,进入main函数;
⑨执行,CPU为其分配时间片(chunk),在每一个chunk中hello享有私有的地址空间,独立执行自己的逻辑控制流;
⑩访问内存,CPU访问hello的时候,请求一个虚拟地址,MMU把虚拟地址转换成物理地址并通过三级cache访存;
⑪动态申请内存,printf函数通过malloc向动态内存分配器向堆中申请内存;
⑫信号,在hello运行的过程中,向shell键入Ctrl+Z将进程挂起,fg重新启动进程,除此之外,shell还可以处理其他信号;
⑬结束,在挂起时键入kill命令杀死进程,或者键入Ctrl+C也可将进程杀死,之后父进程完成对子进程的回收,内核将其从系统中清除,hello的一生结束。
二、你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
(1)计算机系统这门课程介绍了计算机系统的基本概念,包括最底层的内存中的数据表示、流水线指令的构成、虚拟存储器、编译系统、动态加载库等知识,让我们更好地理解了程序执行的完整历程,使我们受益匪浅。
(2)计算机系统的设计和实现大量体现了抽象的思想:文件是对 I/O 设备的抽象, 虚拟内存是对主存和磁盘设备的抽象,进程是对处理器、主存和 I/O 设备的抽 象,进程是操作系统对一个正在运行的程序的抽象等等;
(3)CSAPP 这本书引领我们从程序员的角度第一次系统地、全面地认识了现代操作系统的各种机制、设计和运行原理,使我对计算机体系结构产生了极大的兴趣。将来我会努力学习更多的相关知识。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名称 | 说明 | 对应本问章节 |
hello.i | hello.c经预处理得到的ASCII文本文件 | 第2章 |
hello.s | hello.i经编译得到的汇编代码ASCII文本文件 | 第3章 |
hello.o | hello.s经汇编得到的可重定位目标文件 | 第4章 |
hello_elf.txt | hello.o经readelf分析得到的文本文件 | 第4章 |
hello_dis.txt | hello.o经objdump反汇编得到的文本文件 | 第4章 |
hello | hello.o经链接得到的可执行文件 | 第5章 |
hello1_elf.txt | hello经readelf分析得到的文本文件 | 第5章 |
hello1_dis.txt | hello经objdump反汇编得到的文本文件 | 第5章 |
参考文献
[1] Randal E.Bryant, David R.O'Hallaron.深入理解计算机系统[M]. 北京:机械工业出版社,2016.
[2] 袁春风. 计算机系统基础[M]. 北京:机械工业出版社,2018.
[3] Alan Clements. 计算机组成原理[M]. 北京:机械工业出版社,2017.
[4] James F.Kurose / Keith W.Ross. 计算机网络[M]. 北京:机械工业出版社,2018.
[5] ELF文件头结构. CSDN博客.
ELF文件头结构_js0huang的博客-CSDN博客_elf.h
[6] printf函数实现的深入剖析. 博客园.