计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 未来技术学院人工智能
学 号 2022112543
班 级 22WL021
学 生 颜湛珉
指 导 教 师 刘宏伟
计算机科学与技术学院
2024年5月
本文简单分析了hello.c经过预处理、编译、汇编和链接成为可执行文件的过程,再介绍了HELLO可执行文件的进程管理、储存管理和IO管理。通过对hello.c这个C语言文本文件创建到运行结束的“hello的一生”的简单回顾,了解了C语言程序是怎样在电脑中运行的,同时回顾了计算机系统知识。
关键词:C语言;计算机系统;hello的一生;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第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 本章小结............................................................................... - 5 -
第3章 编译................................................................................... - 6 -
3.1 编译的概念与作用............................................................... - 6 -
3.2 在Ubuntu下编译的命令.................................................... - 6 -
3.3 Hello的编译结果解析........................................................ - 6 -
3.4 本章小结............................................................................... - 6 -
第4章 汇编................................................................................... - 7 -
4.1 汇编的概念与作用............................................................... - 7 -
4.2 在Ubuntu下汇编的命令.................................................... - 7 -
4.3 可重定位目标elf格式........................................................ - 7 -
4.4 Hello.o的结果解析............................................................. - 7 -
4.5 本章小结............................................................................... - 7 -
第5章 链接................................................................................... - 8 -
5.1 链接的概念与作用............................................................... - 8 -
5.2 在Ubuntu下链接的命令.................................................... - 8 -
5.3 可执行目标文件hello的格式........................................... - 8 -
5.4 hello的虚拟地址空间......................................................... - 8 -
5.5 链接的重定位过程分析....................................................... - 8 -
5.6 hello的执行流程................................................................. - 8 -
5.7 Hello的动态链接分析........................................................ - 8 -
5.8 本章小结............................................................................... - 9 -
第6章 hello进程管理.......................................................... - 10 -
6.1 进程的概念与作用............................................................. - 10 -
6.2 简述壳Shell-bash的作用与处理流程........................... - 10 -
6.3 Hello的fork进程创建过程............................................ - 10 -
6.4 Hello的execve过程........................................................ - 10 -
6.5 Hello的进程执行.............................................................. - 10 -
6.6 hello的异常与信号处理................................................... - 10 -
6.7本章小结.............................................................................. - 10 -
第7章 hello的存储管理...................................................... - 11 -
7.1 hello的存储器地址空间................................................... - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理............. - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换.................... - 11 -
7.5 三级Cache支持下的物理内存访问................................ - 11 -
7.6 hello进程fork时的内存映射......................................... - 11 -
7.7 hello进程execve时的内存映射..................................... - 11 -
7.8 缺页故障与缺页中断处理................................................. - 11 -
7.9动态存储分配管理.............................................................. - 11 -
7.10本章小结............................................................................ - 12 -
第8章 hello的IO管理....................................................... - 13 -
8.1 Linux的IO设备管理方法................................................. - 13 -
8.2 简述Unix IO接口及其函数.............................................. - 13 -
8.3 printf的实现分析.............................................................. - 13 -
8.4 getchar的实现分析.......................................................... - 13 -
8.5本章小结.............................................................................. - 13 -
参考文献....................................................................................... - 16 -
第1章 概述
1.1 Hello简介
P2P:from Program to Process
一开始hello.c作为C语言程序(program),经过预处理器(cpp)进行预处理,对源代码进行一系列的处理操作,包括宏替换、文件包含、条件编译等[1],同时产生中间文件hello.i,然后生成的hello.i经过编译器(cc1)进行编译,同时生成中间文件hello.s汇编代码,hello.s再经过汇编器(as)进行汇编,将汇编代码生成可执行的指令,同时生成可重定位目标文件hello.o,hello.o再经过链接器(ld),进行地址和空间的分配,符号决议和重定位,最后生成可执行目标文件Hello。要运行可执行文件Hello,在Linux Shell的命令行键入 “./Hello”,shell调用相关函数为其创建进程(process),执行程序。程序是静态的概念,它就是躺在磁盘里的一个文件。进程是动态的概念,是动态运行起来的程序。
020:from zero to zero
在Linux Shell的命令行键入“./Hello”后,shell首先bash进程调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件 ,内核开始真正的装载工作[2],给进程分配独立的虚拟地址空间,将可执行文件映射到进程的虚拟地址空间(mmap),将CPU指令寄存器设置到程序的入口地址,开始执行。当进程结束时,shell会回收hello进程,并且内核会从系统中删除Hello所有痕迹。这就是020的过程。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:12th Gen Intel(R) Core(TM) i7-12700H@2.70 GHz x64,RAM 16.0 GB
软件环境:Vmware 17,ubuntu 22.04 x64
开发与调试工具:codeblock, gcc, gdb, edb, objdump, readelf
1.3 中间结果
中间结果文件名称 | 文件作用 |
hello.i | 预处理后的文件 |
hello.s | 汇编代码 |
hello.o | 可重定位目标文件 |
Hello | 可执行目标文件 |
hello_o_elf.txt | hello.o的elf格式代码 |
d_hello_o.txt | hello.o的反汇编代码 |
d_Hello.txt | Hello的反汇编代码 |
Hello_elf.txt | Hello的elf格式代码 |
1.4 本章小结
对hello的一生简单的做了概述性的总结,同时给出了本次大作业开展的软硬件环境和开发调试工具,以及产生的需要进行分析的中间文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
(以下格式自行编排,编辑时删除)
概念:C语言预处理是预处理器(cpp)在编译之前对源代码进行一系列的处理操作,包括宏替换、文件包含、条件编译等,最终生成经过预处理的代码,然后再进行编译。
作用:
1. 宏替换:通过使用#define定义宏,可以将一段代码或表达式抽象成一个标识符,在编译时将标识符替换成对应的代码或表达式。
2. 文件包含:通过使用#include指令,可以将其他文件的内容包含到当前文件中,方便代码的组织和复用。
3. 条件编译:通过使用#ifdef、#ifndef、#endif、#if、#elif、#else等指令,可以根据条件编译开关的设置决定是否编译某段代码,从而实现不同平台或配置下的代码选择。
4. 编译器指令:通过使用#pragma指令,可以向编译器发出一些特殊的命令,控制编译过程的行为。
5. 删去源代码中注释的内容。
2.2在Ubuntu下预处理的命令
预处理命令如下:
cpp hello.c >hello.i
或者 gcc -E hello.c -o hello.i
需要注意的是,预处理器只是对源代码进行替换、复制等简单的文本处理操作,并不进行语法检查和语义分析。因此,在使用预处理器时需要谨慎,避免产生预期之外的结果。
图2.1 Ubuntu的shell预处理命令
2.3 Hello的预处理结果解析
图2.3 hello.c源代码
图2.4 hello.i部分代码
hello.c经过预处理后,代码量由24行变为3092行,原来的main函数从hello.i的3079行开始,3079之前的内容为#include指令的内容,将stdio.h,unisted.h和stdlib.h三个头文件的内容包含到当前文件中。
图2.5 stdio.h头文件引用部分内容
图2.6 unisted.h头文件引用部分内容
从图2.5和图2.6看出,头文件包含不只提到的三个头文件,这是由于这些代码包含了多个标准库头文件的嵌套引用,这三个标准库头文件中有对其他头文件的引用,其中涉及到一些条件编译的指令,如#ifdef和#ifndef指令。
同时由图2.5看出,# 1 "hello.c" 表示当前文件是 hello.c,并且当前行号是1,原本hello.c源代码为注释的部分都被删去了,变成空行了,这就是预处理器的作用之一。
2.4 本章小结
该部分简单介绍了预处理的概念和作用(宏替换、文件包含、条件编译、编译器指令、删去注释),同时展示了Ubuntu中对hello.c进行预处理的shell指令,简单说明了预处理器对hello.c的预处理操作。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:编译过程就是将预处理后的文件进行一系列的:词法分析、语法分析、语义分析及优化,生成相应的汇编代码文件[3]。
过程及作用:
1. 词法分析(Lexical Analysis):将源代码转换为一系列记号(Tokens)。由扫描器(Lexer)完成。扫描源代码,将字符序列分割成有意义的词法单位,如关键字、标识符、字面量、运算符和特殊字符等。过滤掉空白字符和注释。生成的记号流作为语法分析的输入。
2. 语法分析(Syntax Analysis):将记号流转换为语法树(Syntax Tree)。由语法分析器(Parser)完成。语法分析器根据上下文无关文法(Context-Free Grammar, CFG)检查记号流的语法结构。如果源代码符合语法规则,生成语法树(或抽象语法树AST);否则,报告语法错误。
3. 语义分析(Semantic Analysis):对语法树进行语义检查,确保程序在语义层面上的正确性。由语义分析器完成。检查类型一致性、变量声明和使用、函数调用参数匹配等。确保所有操作符和操作数类型匹配,并处理类型转换。生成增强的语法树或中间表示(Intermediate Representation, IR)。报告语义错误,如未声明的变量、类型不匹配等。
4. 中间代码生成(Intermediate Code Generation):将增强的语法树或IR转换为中间代码形式。通常生成三地址代码(Three-Address Code, TAC),方便后续优化。中间代码是一种抽象机器语言,独立于具体目标机器。
5. 优化(Optimization):对中间代码进行优化,提高代码执行效率和减少资源消耗。包括局部优化(如常量折叠、公共子表达式消除)和全局优化(如循环优化、代码移动)。数据流分析、控制流分析等技术用于识别和应用优化机会。
6. 目标代码生成(Code Generation):将优化后的中间代码转换为目标机器代码。根据目标机器的指令集生成具体的机器指令。为每个中间代码指令生成相应的目标代码指令序列。分配寄存器和管理内存。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
编译命令如下:
gcc -S hello.c -o hello.s
或者 cc1 hello.i -o hello.s
图3.1 Ubuntu下编译的命令
3.3 Hello的编译结果解析
汇编文件头部声明:
.file 源文件(指从hello.c汇编得来)
.text表示接下来的指令是代码部分,处理器会从这里开始执行。
.section .rodata .rodata 表示只读数据段,通常用于存储字符串常量等不可修改的数据。
.align 8代码对齐到8字节边界
.global 全局变量
.type声明一个符号是数据类型还是函数类型
.string 声明了两个字符串分别为 .LC0; .LC1
3.3.1 全局变量
全局变量是定义在函数外部,整个程序范围内都可见的变量。在这段代码中,没有直接定义全局变量,只有几个函数为全局(global),如main,exit,printf,getchar,sleep等
3.3.2 局部变量
局部变量只在变量所定义的作用域内有效,如方法、函数、语句中。在这段代码中,main 函数内的变量都是局部变量。一般来说,局部变量是储存在栈中的某一个位置的或是直接储存在寄存器中的。
如图所示,为int i,int argc,char *argv[]开辟了32个字节的空间。
其中,对于数组argv[],它在进入main函数之前就已经初始化好了,作为程序运行时传入的参数,在程序初始化时就已经分配好空间了。
由图所示,%rsi寄存器的值(即argv[]首地址)在main栈帧之上。
3.3.3 字符串常量
在 .rodata 段中,定义了两个字符串常量:
1).LC0 包含一个中文字符串:“用法: Hello 学号 姓名 手机号 秒数!\n”
2).LC1 包含一个格式化字符串:“Hello %s %s %s\n”
3.3.4 赋值
下面的图分别表示,给int argc赋值,给赋值,将0赋给i。
3.3.5 算术操作
表示i++.
3.3.6 关系操作
表示argc!=5。
表示i<10.
3.3.7 数组操作
表示argv[1],argv[2],argv[3], argv [4].
3.3.8 控制转移
表示if(argc!=5)
表示for(i=0;i<10;i++)
(以下格式自行编排,编辑时删除)
3.3.9 函数操作
分别表示调用头文件提供的puts,printf,sleep,atoi,getchar函数。
3.4 本章小结
本章简单介绍了编译的概念和作用,同时就hello.s汇编代码文件分析了C语言的数据与操作在编译语言层面上如何表示和实现,同时直观地看到了编译的结果,展现了高级语言与汇编语言的差别。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编是将汇编代码转转变成机器可执行的指令,每一个汇编语句几乎都对应一条机器指令。就是根据汇编指令和机器指令的对照表一一的进行翻译,也不做指令优化。最终可将汇编代码文件(.s)转换成可重定位目标文件(.o)。
4.2 在Ubuntu下汇编的命令
汇编命令如下:gcc -c hello.c -o hello.o
或者as hello.s -o hello.o
4.3 可重定位目标elf格式
4.3.1 elf文件
下面先介绍elf文件:
在Linux下,可执行文件/动态库文件/目标文件(可重定向文件)都是同一种文件格式,我们把它称之为ELF文件格式,即Executable and Linkable Format,要么是可执行的、要么是可链接的。虽然它们三个都是ELF文件格式但都各有不同[4]。
1. 可重定位(relocatable)目标文件:通常是.o文件。包含二进制代码和数据,其形式可以再编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
2. 可执行(executable)目标文件:是完全链接的可执行文件,即静态链接的可执行文件。包含二进制代码和数据,其形式可以被直接复制到内存并执行。
3. 共享(shared)目标文件:通常是.so动态链接库文件或者动态链接生成的可执行文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。注意动态库文件和动态链接生成的可执行文件都属于这一类。
一般来说elf文件格式如下:
利用命令readelf -a hello.o>helo_o_elf.txt 生成hello.o的elf格式文件
4.4.2 ELF头(ELF Header)
一般来说,整个ELF文件的前64个字节为ELF头。ELF 头以一个 16 字节的序列开始,这个序列描述了生成该文件系统下的字的大小以及一些其他信息ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括 ELF 头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
4.3.3 节头表(Section Header Table)
节头表包含了文件中的各个节,每个节都指定了一个类型,定义了节数据的语义。各节都指定了大小和在二进制文件内部的偏移。
程序头表是从加载的角度来看ELF文件的,可
重定位目标文件没有该表,每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对其方面的信息。
4.3.4 重定位节
.rela.text和.rela.eh_frame储存的是需要重定位的信息,当链接器链接可重定位目标文件时,会根据重定位节的信息计算正确的地址,重定位其中的信息。
4.3.5 符号表(Symbol Table)
.symtab是符号表,它存放在程序中定义和引用的函数和全局变量的信息。Ndx列是每个符号所在的Section编号,例如data_items在第3个Section里(也就是.data),各Section的编号见Section Header Table。Value列是每个符号所代表的地址,在目标文件中,符号地址都是相对于该符号所在Section的相对地址,比如data_items位于.data段的开头,所以地址是0,_start位于.text段的开头,所以地址也是0,但是start_loop和loop_exit相对于.text段的地址就不是0了。从Bind这一列可以看出_start这个符号是GLOBAL的,而其它符号是LOCAL的,GLOBAL符号是在汇编程序中用.globl指示声明过的符号。[5]
4.4 Hello.o的结果解析
(以下格式自行编排,编辑时删除)
使用objdump -d -r hello.o> d_hello_o.txt命令,生成hello.o的反汇编代码如下
与第3章的 hello.s进行对照分析,发现有以下几点不同:
1. 进制不同:hello.s对于立即数的表示是十进制的,而 hello.o 反汇编之后数字的表示是十六进制的。
2. 控制转移跳转地址表示不同:对于条件跳转,hello.s中跳转给出的是段的名字,例如.L2 等来表示跳转的地址,而 hello.o 由于已经是可重定位目标文件,对于每一行都已经分配了相应的地址,因此跳转命令后跟着的是需要跳转部分的目标地址。
3. 函数调用形式不同:hello.s 中,call 指令后跟的是需要调用的函数的名称,而 hello.o 反汇编代码中 call 指令使用的是 main 函数的相对偏移地址。同时可以发现在hello.o 反汇编代码中被调用函数的地址没分配好,并且两个字符串常量的地址也没确定,这里只简单采用顺序偏移,再链接生成可执行文件后才会生成其确定的地址。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
4.5 本章小结
本章简单介绍了汇编的概念和作用,同时介绍了elf文件的格式与内容。并且对hello.o反汇编代码与hello.s进行了简单的比较,分析了两者之间的不同之处,更加深入地理解了代码在汇编过程中发生的变化,这些变化都是为了链接做准备的。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
(以下格式自行编排,编辑时删除)
链接是一个复杂的过程,链接的时候需要把一堆文件链接在一起才生成可执行程序。链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。链接解决的是一个项目中多文件、多模块之间互相调用的问题。
C语言中的链接实现符号决议和重定位的主要步骤如下[6]:
1. 汇编阶段生成对象文件(.o文件)
编译单个源文件后生成对应的对象文件。对象文件包含代码段、数据段以及符号表等信息。
2. 链接阶段读取对象文件
链接器读取所有对象文件,并构建一个全局符号表。
3. 符号决议
链接器检查全局符号表中是否存在重复定义或未定义的外部符号。如果有,报错;如果没有,继续下一步。
4. 重定位
对每个对象文件:
扫描重定位表,获取需要重定位的位置。
查找位置对应的符号在全局符号表中的地址。
更新位置的值为符号地址。
5. 构建输出文件
链接器根据对象文件中的代码和数据段,生成一个符合目标格式的可执行文件。 其中:
代码段由各对象文件代码段连接而成。
数据段由静态存储区连接而成。
符号表包含链接后符号的最终地址信息。
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
在标准的linux平台下,link的顺序是:ld crt1.o crti.o [user_objects] [system_libraries] crtn.o [7]。crt1.o中包含程 序的入口函数_start以及两个未定义的符号__libc_start_main和main,由_start负责调用 __libc_start_main初始化libc,然后调用我们源代码中定义的main函数;另外,由于类似于全局静态对象这样的代码需要在main函数之前执行,crti.o和crtn.o负责辅助启动这些代码。
5.3 可执行目标文件hello的格式
使用readelf -a Hello> Hello_elf.txt 命令,生成Hello的ELF代码文件。
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息,其结构与可重定位目标文件hello.o类似。
5.3.1 ELF头(ELF Header)
描述体系结构和操作系统等基本信息,指出节头表(section header table)和程序头表(program header table)在文件的位置。
5.3.2 节头部表(Section Header Table)
保存了所有的section的信息,这是从编译和链接的角度来看ELF文件的。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大以及偏移量重新设置各个符号的地址。
由于采用动态链接,其中.interp段该可执行文件所需的动态链接器的路径。
5.3.2 程序头表(Program Header Table)
程序头表也叫段头部表,这个是从运行的角度来看ELF文件的,主要给出了各个segment的信息,在汇编和链接过程中没用。
下图是节(section)到段(segment)的映射。其实sections和segments占的一样的地方。这是从链接和加载的角度来讲的。sections是程序员可见的,是给链接器使用的概念,而segments是程序员不可见的,是给加载器使用的概念。一般是一个segment包含多个section。
5.3.4 动态段(Dynamic 段)
动态段包含了一些在运行时需要的信息,例如需要的共享库,初始化和终止函数的地址等。这些信息用于动态链接器在运行时链接共享库。
这里是一些关键条目的解释:
NEEDED:这个条目指示了程序运行所需要的共享库。在这个例子中,程序需要libc.so.6库。
INIT和FINI:这两个条目分别指向程序的初始化和终止函数。在这个例子中,初始化函数的地址是0x401000,终止函数的地址是0x4011c8。
HASH和GNU_HASH:这两个条目是用于快速查找符号的哈希表。GNU_HASH是GNU引入的一个优化版的哈希表。
STRTAB和SYMTAB:这两个条目分别指向字符串表和符号表。字符串表包含了符号名,符号表包含了符号的信息。
PLTGOT:这个条目指向过程链接表(Procedure Linkage Table, PLT)和全局偏移表(Global Offset Table, GOT)。这两个表用于实现动态链接。
5.3.5 重定位表
重定位表用于解决符号的地址问题。在链接过程中,链接器会根据这些信息来填充正确的地址。
.rela.dyn重定向表,在程序启动时就需要重定位完成,主要是针对外部数据变量符号。.rela.plt保存了重定位表的信息,涉及到延迟绑定(lazy binding)[8]。
5.3.6 .dynsym符号表
包含了动态链接需要的符号信息。动态链接符号表是静态链接符号表(.symtab)的子集,其只保留了与动态链接相关的符号信息
5.3.7 .symtab符号表
(静态链接)符号表的作用是保存当前 目标文件中所有对符号的定义和引用。符号表中UND的符号不是当前目标文件定义的,也就是对符号的引用。符号表中其他非UND的符号,全部是定义在当前目标文件中的,也就是对符号的定义[9]。
5.4 Hello的虚拟地址空间
ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段。5.3的程序头部表(Program Header Table)描述了这种映射关系。一般的C语言程序在Linux系统下的内存映像如图所示。
5.4.1 ELF头
由程序头部表也可知道ELF文件被加载到虚拟空间的哪处地址,如图所示,ELF文件被加载到0x400000地址处。
利用edb工具运行Hello,可以查看0x40000地址处的内容如下
发现的确是ELF头的内容,magic的值也与5.3的一致。可以确定被加载的ELF文件内容开始于虚拟内存地址0x400000处。
5.4.2 .interp
由程序头部表知道,.interp段开始于内存地址0x4002e2。查看该处内容发现的确为interp段的内容,存储的是Hello可执行文件所需的动态链接器的路径。
5.4.3 .dynstr
.strtab符号表不需要加载到内存,但动态链接需要的符号信息需要被加载进内存,因此.dynsym段和.dynstr段需要被加载到内存,动态符号表.dynsym中所包含的符号的符号名保存在动态符号字符串表.dynstr 中。
如图所示,节头部表也包含各段地址信息,.dynstr段开始于0x400480地址,edb查看内容如下
5.4.4 .text
.text段存储的是已编译程序的机器代码,由节头部表可知.text段开始于0x4010f0
edb查看内容如下
部分对应汇编代码如下
其余段内容也可用同样的手段得到,这里不再赘述。
5.5 链接的重定位过程分析
5.5.1 hello与hello.o的不同
利用objdump -d -r Hello> d_Hello.txt命令,得到可执行文件Hello的反汇编代码文件。
将d_Hello.txt与d_hello_o.txt进行比较,发现有以下几点不同:
1. 调用函数跳转地址不同。动态链接后生成的Hello实现的是PIC函数调用,可执行文件代码段的过程链接表(PLT)有多个重定位桩函数,如puts@plt,printf@plt,getchar@plt,atoi@plt,exit@plt和sleep@plt,调用puts()等共享库中的函数其实是调用这些函数,进入这些函数后会跳转到对应全局偏移量表(GOT)中储存的地址,采用PC相对引用,同时还采用了延迟绑定技术。而可重定位文件hello.o调用函数仅仅相当于是顺序转移,即跳转到下一条指令。
2. 控制转移跳转地址不同。hello.o跳转地址仅在当前文件有效,而在Hello生成的反汇编文件中的跳转地址十分明确,指向了虚拟空间地址。这是因为链接器根据hello.o和空间映射关系,修改跳转地址,其实还是PC相对寻址,比如 <main+0x32>,只不过main函数的地址变了,被映射到虚拟空间了,有了虚拟空间地址。
3. 没有.rela.text
经过链接器的重定位后,Hello已经不含.rela.text,即.text部分不含重定位条目,各个外部定义的函数或者全局变量地址已经确定(共享库函数地址即是对应的重定位桩函数地址)。
(以下格式自行编排,编辑时删除)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
5.5.2 链接的重定位过程
链接的重定位过程是编译和链接程序时将符号引用解析为实际内存地址的过程。这个过程包括编译时重定位和运行时重定位。以下是详细的解释:
1. 编译时重定位
在编译时,编译器将每个源文件编译成目标文件(.o 文件)。目标文件中包含符号信息,但符号地址通常是相对的或未解析的。链接器负责将这些符号解析为最终的内存地址,并将目标文件合并为可执行文件或共享库。
编译时重定位的步骤:
1)编译源文件:
编译器将每个源文件编译成目标文件(.o 文件)。
每个目标文件包含代码段(.text)、数据段(.data)、只读数据段(.rodata)和符号表等信息。
2)链接器合并目标文件:
链接器将多个目标文件合并,解析和调整符号引用。
链接器更新符号表,将每个符号的相对地址转换为绝对地址。
3)生成可执行文件或共享库:
链接器输出一个包含所有必要符号信息的可执行文件或共享库。
2. 运行时重定位
对于动态链接的可执行文件和共享库,在程序加载到内存时,动态链接器(如 ld.so)会进行运行时重定位。这确保程序可以在不同的内存地址空间中正确运行。
运行时重定位的步骤:
1)加载可执行文件和共享库:
操作系统加载器将可执行文件和所需的共享库加载到内存中。每个模块的内存地址在加载时确定。
2)动态链接器初始化:
动态链接器负责解析动态符号,并进行必要的重定位。
3)解析全局偏移表(GOT)和过程链接表(PLT):
GOT 和 PLT 用于延迟绑定和符号解析。初次调用动态函数时,通过 PLT 跳转到动态链接器进行解析。
4)符号解析:
动态链接器根据符号表和重定位表,查找每个符号的实际地址。更新 GOT 表,将符号地址写入 GOT 表中。
5)重定位过程:
动态链接器将每个符号引用替换为实际的内存地址。修改代码段或数据段中的地址引用,以指向正确的内存位置。
3. 重定位类型
重定位的类型取决于符号引用的种类。常见的重定位类型包括:
1)绝对重定位:
将符号引用替换为绝对地址。例如,将全局变量的引用替换为它在内存中的实际地址。
2)相对重定位:
使用相对地址,计算出符号相对于当前位置的偏移。常用于跳转指令和函数调用。
5.6 hello的执行流程
5.6.1 函数执行流程
利用edb --run ./Hello 1 2 3 4 命令使用edb运行Hello,观察%rip(指令指针寄存器),得到程序运行流程以及得到各函数调用情况
Hello带正确参数运行:
_start --> __libc_start_main --> main --> printf@plt --> atoi@plt --> sleep@plt
--> printf@plt --> ……(再打印8次) --> getchar@plt --> exit()
Hello不带正确参数运行:
_start --> __libc_start_main --> main --> puts@plt --> exit@plt
5.6.2 各子程序名和程序地址
子程序名 | 程序地址 |
_start | 0x4010f0 |
main | 0x401125 |
__libc_start_main | 0x403ff0 |
puts@plt | 0x401090 |
printf@plt | 0x4010a0 |
getchar@plt | 0x4010b0 |
atoi@plt | 0x4010c0 |
exit@plt | 0x4010d0 |
sleep@plt | 0x4010e0 |
5.7 Hello的动态链接分析
当程序调用一个由共享库定义的函数,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。不过,这种方法并不是 PIC,因为它需要链接器修改调用模块的代码段,GNU 编译系统使用了一种很有趣的技术来解决这个问题,称为延迟绑定(lazy binding),将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互来实现的,这两个数据结构是:GOT 和过程链接表(Procedure Linkage Table,PLT)。简单的原理就是会有一个plt的段,当进行模块间的函数调用时,代码段中的调用都是先到plt段中,plt中会继续调用dl_runtime_resolve函数进行符号的解析和重定位进一步到got中的地址,当函数第二次调用到plt段中就能直接找到相应got中的地址实现跳转[10]。
下面以printf函数为例简单讲解动态链接过程,首先先调用printf@plt
bnd jmp *0x2f7d(%rip):从 GOT 表中获取函数地址,发现其要跳转到0x404020地址所存的地址
使用edb查看0x404040处内容,即printf函数的GOT值,发现初次调用时指向 0x401030
跳转到地址0x401040,
即进入PLT表
push $0x0:将索引值 0x1(表示 printf)压入堆栈。
bnd jmp 401020:跳转到 PLT 表的第一个入口 0x401020
进入PLT 表的第一个入口 0x401020
push 0x2fe2(%rip):把动态链接器的一个参数压入栈中。
bnd jmp *0x2fe3(%rip):跳转到动态链接解析器(如 _dl_runtime_resolve)
动态链接解析器解析符号地址(如 puts),将实际地址写入 GOT 表对应位置(0x404018)。同时从栈中恢复返回地址,并跳转到实际函数地址。
5.8 本章小结
本章简单介绍了链接的概念和作用,分析了可执行目标文件的ELF结构,同时将Hello生成的反汇编代码与hello.o生成的反汇编代码进行了比较,分析了两者重定位过程的不同,并且还详细说明了动态链接共享库函数的过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程(Process)是程序在其自身的地址空间中执行的一个实例。它包含了程序的代码、数据、堆栈以及一些进程控制信息。操作系统通过进程来管理和调度程序的执行。进程是计算机科学中最深刻、最成功的概念之一。
6.1.2 进程的作用
1. 资源分配单位:进程是资源分配的基本单位。操作系统通过进程为其分配CPU时间、内存、文件、输入/输出设备等资源。每个进程拥有自己的资源集,确保各个进程独立运行。
2. 并发执行:进程使得多任务操作系统能够并发执行多个任务。通过进程的并发执行,用户可以同时运行多个程序,提高了系统的利用率和用户体验。[11]
- 独立性与隔离:进程之间相互独立,每个进程在自己的地址空间内运行。这种隔离性提高了系统的安全性和稳定性,防止一个进程的错误或故障影响到其他进程。
- 便于管理和控制:操作系统可以通过进程管理机制(如进程调度、进程同步、进程通信等)对程序的执行进行有效的管理和控制,确保系统资源的合理利用和系统的正常运行。
- 错误隔离:由于进程独立运行,一个进程的错误(如崩溃)不会直接影响到其他进程。这种隔离提高了系统的容错能力。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
Shell是操作系统的用户接口,允许用户与操作系统进行交互。Bash(Bourne Again Shell)是Linux和Unix系统中常用的一种Shell。Shell的主要作用包括:
- 命令解释器:接受用户输入的命令并将其传递给操作系统执行。
- 脚本编程环境:支持编写脚本(Shell脚本)来自动化执行任务。
- 用户界面:提供一个命令行界面,让用户可以直接与系统进行交互。
- 任务控制:管理任务(作业),支持任务的后台运行、暂停和恢复等。
- 变量和环境配置:支持定义变量和环境配置,以影响程序的运行环境。
实际上就是shell 将使用者的命令翻译给核心 (kernel) 处理,之后再将核心的处理结果翻译给使用者。[12]
6.2.2 处理流程
1. 启动。当用户登录系统或打开一个终端窗口时,Bash Shell会启动。Bash读取初始化文件(如/etc/profile、~/.bash_profile、~/.bashrc等)来配置环境。同时Bash Shell会显示一个提示符(通常是$或#),等待用户输入命令。
2. 读取从键盘输入的命令。用户在提示符($)后输入命令,按下回车键。Bash读取整行输入,进行解析。
3. 判断命令是否正确,且将命令行的参数改造为系统调用execve() 内部处理所要求的形式
3. 终端进程调用fork() 来创建子进程,自身则用系统调用wait() 来等待子进程完成
4. 当子进程运行时,它调用execve() 根据命令的名字指定的文件到目录中查找可行性文件,调入内存并执行这个命令。
5. 命令执行结束后,Bash将输出结果显示在终端。
6. 如果命令行末尾有后台命令符号& 终端进程不执行等待系统调用,而是立即发提示符,让用户输入下一条命令;如果命令末尾没有& 则终端进程要一直等待。当子进程完成处理后,向父进程报告,此时终端进程被唤醒,做完必要的判别工作后,再发提示符,让用户输入新命令。
7. 任务控制。用户可以使用特定命令(如jobs、fg、bg等)控制后台任务。还可以使用快捷键(如Ctrl+Z、Ctrl+C)暂停或终止任务。
8. Shell 的退出用户可以输入exit命令或按Ctrl+D退出Bash Shell。Bash执行一些清理操作,保存历史命令记录,然后退出。
6.3 Hello的fork进程创建过程
在shell 键入./Hello 命令后,shell判断命令格式正确后会在当前工作目录下寻找Hello可执行程序,根据命令行的参数改造为系统调用execve() 内部处理所要求的形式,之后shell 调用fork()来创建子进程。
当执行fork()时,操作系统会创建一个新的进程(子进程),这个进程是父进程的副本。子进程继承了父进程的大部分属性(如打开的文件描述符、环境变量等)。子进程获得父进程的一个拷贝,包括数据段、堆栈段等,但两者在物理内存中是独立的。操作系统采用写时复制(Copy-On-Write)机制,只有当子进程或父进程尝试修改这些内存区域时,才会真正进行内存复制。在父进程中,fork返回子进程的进程ID(PID)。在子进程中,fork返回0。如果fork失败,返回值为-1,此时不会创建子进程
6.4 Hello的execve过程
execve是Unix和Linux操作系统中的一个系统调用,用于执行一个新的程序。在调用execve后,当前进程的代码段、数据段和堆栈段将被新的程序替换,但进程ID保持不变。
execve() 的原型如下:
int execve(const char *pathname, char *const argv[], char *const envp[]);
pathname 为要执行的程序的路径。argv 代表参数列表。envp 代表环境变量列表。
键入执行Hello可执行文件的命令后,shell会根据命令行的参数改造为系统调用execve() 内部处理所要求的形式,即转化为argv,一个指向字符串指针的数组,表示要执行的程序的参数列表。第一个参数通常是程序的名称,最后一个参数必须是NULL。以及envp,一个指向字符串指针的数组,表示要传递给新程序的环境变量列表。通常可以直接传递当前进程的环境变量。
如果execve成功,Hello就开始执行。
如果execve调用失败(例如,程序文件不存在或没有执行权限),execve返回-1,并设置errno来指示错误类型。在这种情况下,shell可以通过perror或其他方法来报告错误。
6.5 Hello的进程执行
6.5.1 初始化进程上下文
操作系统通过 fork() 系统调用创建一个新的进程,同时利用execve() 将 hello 程序加载到子进程的内存空间中。首先初始化进程上下文,操作系统为 Hello 程序初始化进程控制块(PCB)、堆栈、程序计数器(PC)等信息。子进程开始在用户态执行。
6.5.2 进程调度:
进程时间片是指操作系统为每个进程分配的CPU执行时间。它控制了每个进程能够连续执行的最大时间量。一旦进程的时间片用完,操作系统会进行进程调度,切换到另一个进程执行。
当子进程的时间片用完时,操作系统会进行进程调度,决定下一个要执行的进程。那么如何选择下一个进程呢,操作系统可能会选择其他处于就绪态的进程继续执行,或者继续执行 Hello 进程,如果它还没有完成。
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
如果操作系统选择另一个进程执行,它会保存当前进程的上下文信息,然后加载下一个进程的上下文信息,进行上下文切换。
6.5.3 用户态与核心态转换
用户态:进程在用户态运行时,只能访问有限的系统资源和指令,不能直接访问操作系统的关键资源和指令,例如内存管理单元(MMU)。
核心态(或内核态):进程在核心态时,可以访问所有的系统资源和指令,包括操作系统的关键数据结构和硬件设备。
用户态和核心态的切换是通过操作系统的系统调用机制来实现的。当进程需要执行特权操作(如I/O操作、内存管理等)时,必须切换到核心态。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个 read 系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是 sleep 系统调用,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
当 hello 程序执行 printf 函数时,它需要向标准输出设备写入数据,这涉及到系统调用 write。write 是一个核心态的系统调用,因为它涉及访问操作系统的资源(文件描述符、设备驱动等)。当 hello 程序执行 write 系统调用时,操作系统将会发生以下步骤:
- 子进程发出系统调用 write。
- 操作系统检查并验证参数,然后将控制传递给适当的设备驱动程序来处理 write。
- 在核心态下,操作系统控制访问并写入设备。
6.6 hello的异常与信号处理
异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。
常见的异常如下所示:
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自 I/O 设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。
6.6.1 Hello程序的正常运行
依据“Hello 学号 姓名 手机号”格式打印10次,每次打印时间间隔一段时间,为用户给的最后一个参数,最后打印十次后若缓冲区内有字符,程序才能结束退出。
6.6.2 不停乱按
程序仍然正常运行至结束,键盘输入加上回车键后会将屏幕的输入缓存到缓冲区。乱码被认为是命令,但不影响当前进程的执行,最后Hello程序的getchar() 读取缓冲区的一个字符后正常退出。
6.6.3 运行时键入Ctrl-C
程序运行时按 Ctrl-C,这时内核就会发送一个 SIGINT 信号(号码 2)给这个前台进程组中的每个进程,Hello程序的这个进程会接收到信号 SIGSTP 并运行信号处理程序,终止运行。
6.6.4 运行时键入Ctrl-Z
程序运行时按 Ctrl-C,这时内核就会发送一个 SIGTSTP 信号(号码 2)给这个前台进程组中的每个进程,Hello程序的这个进程会接收到信号 SIGTSTP 并运行信号处理程序,产生的结果是程序在这时被挂起,并打印了相关挂起信息
- Ctrl-Z 后运行 ps命令
在shell窗口运行命令ps,发现打印出了各进程的 pid,可以看到之前挂起的进程 Hello。
- Ctrl-Z 后运行 jobs命令
在shell窗口运行命令 jobs,打印出各作业的组号,可以看到之前被挂起的 Hello为作业1,标识为已停止(stopped)。
- Ctrl-Z 后运行 pstree命令
在shell窗口运行命令pstree,可以看出各进程的关系,Hello和pstree是bash的两个子进程。
- Ctrl-Z 后运行fg命令
在shell窗口运行命令fg 1,内核发送一个 SIGCONT 信号(号码 18)给这个前台进程组中的每个进程,Hello程序的这个进程会接收到信号SIGCONT并运行信号处理程序,最终继续该进程。
- Ctrl-Z 后运行kill命令
在shell窗口运行命令kill -9 %1,即将信号9(SIGKILL)发送给作业1,即Hello进程,表示杀死程序,再运行ps命令时发现Hello进程被杀死了,不再运行或被挂起。
6.6.5 Hello进程的结束
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。
当Hello进程运行结束时,通过exit() 退出或者被终止时,它会发送SIGCHLD(号码17)给它的父进程,即bash,父进程通过wait() 或者waitpid() 来回收子进程,释放资源。
6.7本章小结
本章对进程和shell-bash的概念和作用进行了简单介绍,同时分析了Hello 程序再shell中的运行,并且简单介绍了Hello进程的创建过程,接着详细分析了Hello进程的执行,以及执行过程的异常和信号处理。深刻地了解了程序在shell中的运行过程。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1 逻辑地址
逻辑地址指由程序产生的与段相关的偏移地址部分,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。
逻辑地址由两部份组成,段标识符和段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。[13]其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
每一个段描述符由8个字节组成
其中,Base字段,它表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。这由段选择符中的T1字段表示,等于0,表示用GDT,等于1表示用LDT,GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
7.1.2 线性地址
线性地址是逻辑地址到物理地址之间的一个中间层变换,程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址,逻辑地址是如何知道自己的段基的地址呢,一般是通过局部LDT段描述符获取的。[14]
如果启用了分页机制,那么MMU内存管理单元会在内存映射表里寻找与线性地址对应的物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
7.1.3 虚拟地址
虚拟地址是CPU保护模式下的一个概念,保护模式是80286系列和之后的x86兼容CPU操作模式,在CPU引导完操作系统内核后,操作系统内核会进入一种CPU保护模式,也叫虚拟内存管理,在这之后的程序在运行时都处于虚拟内存当中,虚拟内存里的所有地址都是不直接的,所以你有时候可以看到一个虚拟地址对应不同的物理地址,比如A进程里的call函数入口虚拟地址是0x001,而B也是,但是它俩对应的物理地址却是不同的
。
7.1.4 物理地址
物理地址是内存中的内存单元实际地址,不是外部总线连接的其他电子元件的地址。它是地址变换的最终结果地址。
物理地址其实就是内存中每个内存单元的编号,这个编号是顺序排好的,物理地址的大小决定了内存中有多少个内存单元,物理地址的大小由地址总线的位宽决定。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel逻辑地址到线性地址的变换主要有以下步骤:
1、看段选择符的T1=0还是1,即先检查段选择符中的TI字段,以决定段描述符保存在哪一个描述符表中,知道当前要转换是GDT中的段(在这种情况下,分段单元从gdtr寄存器中得到GDT的线性基地址),还是LDT中的段(在这种情况下,分段单元从ldtr寄存器中得到GDT的线性基地址),再根据相应寄存器,得到其地址和大小。
2、由于一个段描述符是8字节长,因此她在GDT或LDT内的相对地址是由段选择符的最高13位的值乘以8得到,拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Offset,即偏移地址就知道了。
3、把Base + offset,就是要转换的线性地址了。
如图所示:
段式管理有以下特点:
1.段式管理以段为单位分配内存,每段分配一个连续的内存区。
2.由于各段长度不等,所以这些存储区的大小不一。
3.同一进程包含的各段之间不要求连续。
4.段式管理的内存分配与释放在作业或进程的执行过程中动态进行。
7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1 线性地址
不管系统采用多少级分页模型,线性地址本质上都是索引+偏移量的形式,可以将整个线性地址看作N+1个索引的组合,N是系统采用的分页级数。在四级分页模型下,线性地址被分为5部分,如下图:
在线性地址中,每个页表索引即代表线性地址在对应级别的页表中中关联的页表项。正是这种索引与页表项的对应关系形成了整个页表映射机制。
7.3.2 页表
多个页表项的集合则为页表,一个页表内的所有页表项是连续存放的。页表本质上是一堆数据,因此也是以页为单位存放在主存中的。因此,在虚拟地址转化物理物理地址的过程中,每访问一级页表就会访问一次内存。
7.3.3 页表项
从四种页表项的数据结构可以看出,每个页表项其实就是一个无符号长整型数据。每个页表项分两大类信息:页框基地址和页的属性信息。在x86-32体系结构中,每个页表项的结构图如下:
这个图是一个通用模型,其中页表项的前20位是物理页的基地址。由于32位的系统采用4kb大小的 页,因此每个页表项的后12位均为0。内核将后12位充分利用,每个位都表示对应虚拟页的相关属性。
不管是哪一级的页表,它的功能就是建立虚拟地址和物理地址之间的映射关系,一个页和一个页框之间的映射关系体现在页表项中。
上图中的物理页基地址是 个抽象的说明,如果当前的页表项位于页全局目录中,这个物理页基址是指页上级目录所在物理页的基地址;如果当前页表项位于页表中,这个物理页基地址是指最 终要访问数据所在物理页的基地址。
7.3.4 转换机制
如下图所示,线性地址到物理地址的转换机制结构图:
基本过程如下:
1.从CR3寄存器中读取页目录所在物理页面的基址(即所谓的页目录基址),从线性地址的第一部分获取页目录项的索引,两者相加得到页目录项的物理地址。
2.第一次读取内存得到pgd_t结构的目录项,从中取出物理页基址取出(具体位数与平台相关,如果是32系统,则为20位),即页上级页目录的物理基地址。
3.从线性地址的第二部分中取出页上级目录项的索引,与页上级目录基地址相加得到页上级目录项的物理地址。
4.第二次读取内存得到pud_t结构的目录项,从中取出页中间目录的物理基地址。
5.从线性地址的第三部分中取出页中间目录项的索引,与页中间目录基址相加得到页中间目录项的物理地址。
6.第三次读取内存得到pmd_t结构的目录项,从中取出页表的物理基地址。
7.从线性地址的第四部分中取出页表项的索引,与页表基址相加得到页表项的物理地址。
8.第四次读取内存得到pte_t结构的目录项,从中取出物理页的基地址。
9.从线性地址的第五部分中取出物理页内偏移量,与物理页基址相加得到最终的物理地址。
10.第五次读取内存得到最终要访问的数据。
整个过程是比较机械的,每次转换先获取物理页基地址,再从线性地址中获取索引,合成物理地址后再访问内存。不管是页表还是要访问的数据都是以页为单 位存放在主存中的,因此每次访问内存时都要先获得基址,再通过索引(或偏移)在页内访问数据,因此可以将线性地址看作是若干个索引的集合。
7.4 TLB与四级页表支持下的VA到PA的变换
Linux在v2.6.11以后,最终采用的方案是4级页表,分别是:
PGD:page Global directory(47-39), 页全局目录
PUD:Page Upper Directory(38-30),页上级目录
PMD:page middle directory(29-21),页中间目录
PTE:page table entry(20-12),页表项
7.4.1 TLB
CPU的每一次虚实转换都需要访问存放在内存里的页表,而页表的级数增大到四级,每次内存I/O的VA到PA的转换都需要5次查询,进行地址转换需要的内存IO次数多,且耗时。
TLB通过虚拟地址VPN部分进行索引,分为索引(TLBI)与标记(TLBT)两个部分。里面存放的是一些页表文件(虚拟地址到物理地址的转换表)。当处理器要在主内存寻址时,不是直接在内存的物理地址里查找的,而是通过一组虚拟地址转换到主内存的物理地址,TLB就是负责将虚拟内存地址翻译成实际的物理内 存地址,而CPU寻址时会优先在TLB中进行寻址。这样,MMU在读取PTE时会直接通过TLB,如果不命中再从内存中将PTE复制到TLB。处理器的性能和寻址的命中率有很大的关系。
7.4.2 TLB与四级页表支持下的VA到PA的变换
在没有TLB的情况下,四级页表的VA到PA的变换就是上述线性地址到物理地址的变换,因此不再赘述。
下面简单介绍有TLB支持的VA到PA的变换:
- TLB 是利用 VPN 的位进行虚拟寻址的。一般来说TLB采用类似cache的组相联映射。如图所示,假设有16组,VPN 的低4位就作为组索引(TLBI)。VPN 中剩下的高32位作为标记(TLBT),用来区别可能映射到同一个 TLB 组的不同的 VPN。
同时还有高速缓存,用来储存一些主存数据,直接映射的缓存是通过物理地址中的字段来寻址的。因为每个块都是 64 字节,所以物理地址的低 6 位作为块偏移(CO)。因为有 64 组,所以接下来的 6位就用来表示组索引(CI)。剩下的 40 位作为标记(CT)。
2.当要通过一个虚拟地址访问一个物理地址内存时:
1)检查该页表是否在TLB中,如在则直接访问物理地址;
2)如果该页表不在TLB中,则发生TLB miss,去页表中查找,如果在页表中也没找到,就会发生缺页异常;如果找到了则继续访问物理地址;
这只是对有TLB参与的VA到PA的变换的简单介绍,一般来说,TLB中的项由两部分组成:标识和数据。标识中存放的是虚地址的一部分,而数据部分中存放物理页号、存储保护信息以及其他一些辅助信息。虚地址与TLB中项的映射方式有三种:全关联方式、直接映射方式、分组关联方式。
7.5 三级Cache支持下的物理内存访问
MMU 将物理地址发给 L1 缓存,缓存从物理地址中取出缓存偏移 CO、缓存组索引 CI 以及缓存标记 CT。若缓存中 CI 所指示的组有标记与 CT 匹配的条目且有效位为 1,则检测到一个命中条目,读出在偏移量 CO 处的数据字节,并把它返回给 MMU,随后 MMU 将它传递给 CPU。若不命中,则在下一级 Cache 或是主存中寻找需要的内容,储存到上一级 cache 后再一次请求读取。
Cache地址与主存地址的映射方式也是三种:全关联方式、直接映射方式、分组关联方式。不过Intel i7芯片多采用组相联映射。
7.6 hello进程fork时的内存映射
当 fork 函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID。
为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数在当前进程中加载并运行包含在可执行目标文件 Hello 中的程序,用 Hello程序有效地替代了当前程序。加载并运行 Hello需要以下几个步骤:
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 Hello 文件中的. text 和. data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 Hello 中。栈和堆区域也是请求二进制零的,初始长度为零。下图 概括了私有区域的不同映射
- 映射共享区域。如果Hello 程序与共享对象(或目标)链接,比如标准 C 库 libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的:
1. 处理器生成一个虚拟地址,并将它传送给 MMU
2. MMU 生成 PTE 地址,并从高速缓存/主存请求得到它
3. 高速缓存/主存向 MMU 返回 PTE
4. PTE 中的有效位是 0,所以 MMU 出发了一次异常,传递 CPU 中的控制到操作系统内核中的缺页异常处理程序。
5. 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
6. 缺页处理程序页面调入新的页面,并更新内存中的 PTE
7. 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU 将引起缺页的虚拟地址重新发送给 MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。
7.9动态存储分配管理
概念:一种内存管理方法。对内存空间的分配、回收等操作在进程执行过程中进行,以便更好地适应系统的动态需求,提高内存利用率。
虽然可以使用低级的 mmap 和 munmap 函数来创建和删除虚拟内存的区域,但是 C 程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器(dynamic memory allocator)更方便,也有更好的可移植性。[15]
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量 brk(读做 “break”),它指向堆的顶部。
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,分别为显式分配器(explicit allocator)和隐式分配器(implicit allocator)。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
7.10本章小结
本章简单介绍了Hello的储存管理,先是介绍了计算机中各种地址的概念,介绍了各种地址的变换,尤其着重介绍了虚拟地址到物理地址的变换以及该过程的各种软硬件机制,同时还介绍了fork()和execve()两个系统调用的内存映射,还介绍了缺页故障和中断处理,以及动态内存分配管理。通过本章,深刻体会到了地址是如何变换的程序运行时的数据是如何传输的,认识到了软硬件协同的美妙。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。在Linux系统下,设备文件位于 /dev 目录下,分为字符设备文件和块设备文件。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
- 打开文件。
- 改变当前的文件位置。
- 读写文件。
- 关闭文件。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口
这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2.2 Unix IO函数
1. 打开文件
函数原型为int open(char *filename, int flags, mode_t mode);
返回:若成功则为新文件描述符,若出错为 -1。
open 函数将 filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件:
O_RDONLY:只读。
O_WRONLY:只写。
O_RDWR:可读可写。
mode 参数指定了新文件的访问权限位。
- 关闭文件
函数原型为int close(int fd);
返回:若成功则为 0,若出错则为 -1。 - 读文件
函数原型为ssize_t read(int fd, void *buf, size_t n);
返回:若成功则为读的字节数,若 EOF 则为0,若出错为 -1。
read 函数从描述符为 fd 的当前文件位置复制最多 n 个字节到内存位置 buf。返回值 -1 表示一个错误,而返回值 0 表示 EOF。否则,返回值表示的是实际传送的字节数量。
- 写文件
函数原型为ssize_t write(int fd, const void *buf, size_t n);
返回:若成功则为写的字节数,若出错则为 -1。
write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件位置。
8.3 printf的实现分析
Linux下printf函数如下图所示:
1.代码解析
首先va_list args 声明一个变量 args,用于处理可变参数。va_start(args, fmt)初始化 args 以便访问可变参数列表,这两句代码方便后面调用vsprintf()函数,计算格式化的参数个数。
vsprintf(printbuf, fmt, args)中, vsprintf 函数将格式化的输出写入 printbuf 字符数组中。它根据 fmt 指定的格式字符串和 args 中的参数生成最终的格式化字符串。同时返回要打印出来的字符串的长度。
write(1, printbuf, i): 使用 write 系统调用将 printbuf 中的字符串写入文件描述符 1(标准输出)。i 是由 vsprintf 返回的写入字符数。
在write 系统调用最终会触发一个陷阱指令(如 int 0x80 或 syscall),将控制权交给操作系统内核。内核根据传入的系统调用号和参数执行相应的操作。在 x86 架构下,通过 int 0x80 指令触发系统调用。
在syscall中,简单来说这个函数的功能就是不断的打印出字符,直到遇到:'\0'。
- 显示驱动过程
1)从 ASCII 到字模库
ASCII: 是一种字符编码标准,每个字符对应一个数字。
字模库: 字模库(font library)将每个字符映射到一组点阵,用于显示在屏幕上。例如,字符 'A' 可能映射到一个 8x8 的点阵。
2)从字模到显示内存 (vram)
vram: 显示内存(video RAM)用于存储屏幕上每个像素的颜色信息。每个像素通常由三个分量(红、绿、蓝,即 RGB)表示。
字符映射: 将字符的点阵数据写入 vram 中对应的位置。例如,字符 'A' 的点阵数据在 vram 中占据一部分连续的内存区域。
3)从 vram 到显示器
显示芯片: 负责读取 vram 中的图像数据,并按照一定的刷新频率逐行传输到显示器。
刷新频率: 通常以赫兹(Hz)为单位,表示显示器每秒钟更新图像的次数。
信号线传输: 显示芯片将 vram 中的 RGB 数据通过信号线传输到液晶显示器,每个像素的 RGB 分量分别传输。
4)液晶显示器显示图像
像素: 液晶显示器的每个像素由红、绿、蓝三个子像素组成,通过调整每个子像素的亮度来显示不同的颜色。
逐行扫描: 显示器按照行的顺序逐行更新屏幕上的图像。
简单来说就是,字符显示驱动子程序,从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:
1. 键盘中断处理子程序
当用户在键盘上按下一个键时,键盘会发送一个中断信号给 CPU。这个中断信号被称为键盘中断,通常对应于特定的中断请求 (IRQ),比如 IRQ1。CPU 在接收到键盘中断后会执行相应的中断处理程序。
2. 接收按键扫描码并转成 ASCII 码
键盘中断处理程序会读取键盘控制器发送的扫描码。扫描码是一个字节,表示按下或释放的键。中断处理程序将扫描码转换为对应的 ASCII 码。
3. 保存到系统的键盘缓冲区
转换后的 ASCII 码被保存到系统的键盘缓冲区。这个缓冲区是一个环形队列,用于存储按键数据,直到用户程序读取它们。
getchar调用read系统函数:
1. getchar 函数
getchar 函数是标准 I/O 库中的一部分,负责从标准输入读取一个字符。它通常通过 read 系统调用从键盘缓冲区中获取数据。
2. read 系统调用
read 系统调用从文件描述符(这里是标准输入,文件描述符 0)读取数据。在键盘输入的情况下,read 将从键盘缓冲区中读取数据。
3. 直到接受到回车键才返回
在典型的行缓冲模式下,read 系统调用在读取一行完整的输入之前不会返回,即它会一直读取字符直到遇到回车键(Linux系统下ASCII 码 为 10)。当用户按下回车键时,键盘中断处理程序会将回车键的 ASCII 码保存到缓冲区,从而使得 read 系统调用能够返回完整的一行数据。
8.5本章小结
本章简单介绍了Hello的IO管理,先是介绍了Linux的IO设备管理方法,简述了Unix IO接口及一些函数,最后着重详细分析了printf和getchar的实现。通过本章内容,深刻体会到了底层代码对硬件及IO设备的调用,体会到了Unix将IO设备抽象成文件的思想,即“everything is a file”。由于设备文件被抽象为普通文件,用户和程序无需关心底层的硬件实现,只需使用统一的接口进行操作,更有利于开发工作。
(第8章1分)
结论
通过简单的hello.c程序的一生,见微知著,可以看到很多C语言程序的一生,与hello所经历的过程一样:
1.预处理。处理宏定义和文件包含。
2.编译。将预处理后的文件进行一系列的:词法分析、语法分析、语义分析及优化。
3.汇编。将汇编代码转转变成机器可执行的指令。
4.链接。经过地址和空间分配,符号决议和重定位等这些步骤,把一堆ELF文件链接在一起生成可执行程序。
5.创建进程。在shell中输入./Hello命令,终端判断指令是否正确,是不是内置指令,指令无误后调用fork函数创建新的子进程,同时分配资源,设置进程上下文。
6.加载程序。操作系统的加载器读取可执行文件的头信息,分配内存调用execve()系统调用将代码和数据段加载到内存,并进行必要的重定位。在运行外部函数时需要进行动态链接。
7.执行程序。CPU分配时间片(chunk), 逐条执行指令,完成程序逻辑。
8.I/O 设备输入输出。Hello调用printf 最终通过write系统调用将数据输出到标准输出。操作系统(内核)将数据从程序输出到硬件设备(显示器)。
9.异常与信号处理。当进程中出现异常,如键盘键入Ctrl+C,发送SIGINT信号给前台所有进程组,系统进入异常处理函数进行响应。
10.回收进程。程序执行完毕,内核安排父进程回收子进程,操作系统清理资源,进程结束。
感悟:
程序在电脑上的成功运行都是软硬件协调配合的结果,少了哪方都不行。
同时程序要想有良好的可移植性,必须采用同一个标准,同一个hello.c可以在不同电脑上运行,可见Linux的C语言环境都采用了同一套标准这种标准化极大地促进了软件的可移植性和可复用性。
并且,为了提高计算机的性能,我们反复地利用着局部性,提高数据的命中率,同时不断进行调度和优先处理,提高进程并发效率,这些机制保证了系统的高效运行。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
中间产物文件名称 | 文件作用 |
hello.i | 预处理后的文件 |
hello.s | 汇编代码 |
hello.o | 可重定位目标文件 |
Hello | 可执行目标文件 |
hello_o_elf.txt | hello.o的elf格式代码 |
d_hello_o.txt | hello.o的反汇编代码 |
d_Hello.txt | Hello的反汇编代码 |
Hello_elf.txt | Hello的elf格式代码 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] https://cloud.tencent.com/developer/article/2399279. C语言从入门到实战—预处理详解.
[2] https://cloud.tencent.com/developer/article/2083612. 含大量图文解析及例程 | Linux下的ELF文件、链接、加载与库(中)
[3] https://cloud.tencent.com/developer/article/2399278. C语言从入门到实战——编译和链接
[4] https://blog.csdn.net/weixin_44966641/article/details/120631079. Linux下的ELF文件、链接、加载与库(含大量图文解析及例程)
[5] https://blog.csdn.net/daide2012/article/details/73065204. ELF文件详解—初步认识.
[6] https://cloud.tencent.com/developer/article/2392453. 【C语言】编译和链接深度剖析
[7] https://blog.csdn.net/farmwang/article/details/73195951. crt1.o, crti.o, crtbegin.o, crtend.o, crtn.o
[8] https://blog.csdn.net/hanchaoman/article/details/103614665. GCC连接器--动态库(Shared Library)中 PLT 和 GOT 的使用机制
[9] https://blog.csdn.net/lidan113lidan/article/details/119901186. ELF文件中的各个节区
[10] https://blog.csdn.net/leapmotion/article/details/131040518. linux下动态链接过程
[11] https://cloud.tencent.com/developer/article/1546730. 一文读懂什么是进程、线程、协程
[12] https://blog.csdn.net/b_ingram/article/details/122936657. 【linux系统编程】剖析shell运行原理
[13] https://cloud.tencent.com/developer/article/1869910. 「linux」物理地址,虚拟地址,内存管理,逻辑地址之间的关系
[14] https://cloud.tencent.com/developer/article/1869911. 「linux」物理地址,虚拟地址,内存管理,逻辑地址之间的关系2
[15] 《深入理解计算机系统》 Randal E.Bryant David R.O’Hallaron 机械工业出版社
(参考文献0分,缺失 -1分)