题 目 程序人生-Hello’s P2P
专 业 ——
学 号 2022112287
班 级 2252003
学 生 赵——
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
本文从描绘hello这一个入门级的简单程序方面入手,阐述了hello从代码到执行过程中的各个环节,从进一步分析了Linux系统中各个环节的根本操作和实现原理。立足生成程序,本文分别呈现了预处理、编译、汇编、链接等各个过程的操作和原理。同时,从计算机系统的角度,本文后半部分介绍了一个程序在进程内存,硬件交互方面实现功能的原理。通过本文的流程,能够以小见大的完整体现一个程序在计算机中的实现的全过程,体现了计算机系统这门课中所讲到的每个方面知识。
关键词:计算机系统;汇编原理;编译;链接;I/O管理;内存管理;进程管理
目 录
第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简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1.2 环境与工具
硬件环境:
12th Gen Intel(R) Core(TM) i9-12900H 2.50 GHz;16.0G RAM;256GHD Disk。
软件环境
Windows7 64位以上;VirtualBox/Vmware 17.0.0;Ubuntu 22.04 LTS 64位。
开发工具:
Visual Studio 2022;Code Blocks 64位;vi/vim/gedit+gcc/edb/readelf。
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
1.4 本章小结
本章主要简述了hello程序的整个P2P的过程,概括了Hello程序如何从一段代码变成程序运行;也指出了进程管理中Hello由生到死的020的一生。本章也列出了实验探究Hello程序的一生所使用的软硬件环境和开发编译工具。
第2章 预处理
2.1 预处理的概念与作用
预处理是指在进行编译(词法扫描和语法分析)之前所作的工作。预处理是C语言区别于其他高级语言的特征之一,它由预处理程序负责完成。当对一个源文件进行编译时,系统自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。
预处理包含:
1) 文件包含:将#include 包含的文件,替换成原有的文件的内容。可以实现文件分离,声明和实现相分离,使程序更加的模块化。
2)宏定义:用一个宏名代替一个字符串,以简化编程,提高程序的可读性。
3)条件编译:指在很多情况下,我们希望程序的其中一部分代码只有在满足一定条件时才进行编译,否则不参与编译(只有参与编译的代码最终才能被执行)
预处理的作用:合理地使用预处理功能编写的程序便于阅读、修改、 移植和调试,也有利于模块化程序设计。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
图 2- 1预处理命令及结果
2.3 Hello的预处理结果解析
预处理程序在源文件代码意外加上了大量其他代码,代码行数由几十行增长至3千余行,主要用于声明各个函数,目的是为了后续编译做一步准备
2.4 本章小结
这个章节,我们解释了预处理的定义和作用,并且展示了对Hello.c文件进行预处理后的Hello.i文件,具体分析了预处理结果。
第3章 编译
3.1 编译的概念与作用
概念:编译是指编译程序读取源程序,对之进行词法分析、语法分析、语义检查和中间代码生成、代码优化以及目标代码生成五个步骤,将高级语言指令转换为功能等效的汇编代码,得到.s文件。
作用:编译将高级语言指令转化为汇编代码,为后续汇编环节转换成机器码做准备;同时在这儿一步骤也可以对代码进行优化和错误分析。
3.2 在Ubuntu下编译的命令
gcc -m64 -no-pie -fno-PIC -S -o hello.s hello.i
图 3- 1编译命令
图 3- 2编译结果
3.3 Hello的编译结果解析
3.3.1数据:
常量 :
图 3- 3常量数据
2个字符串,储存在只读数据段中,分别保存在.LC0和.LC1中。
局部变量:
图 3- 4变量数据
将局部变量i储存在栈-4(%rbp)中,对其操作时不占用节的空间,直接对栈进行操作。
全局变量:
图 3- 5全局变量
作为全局变量,argv先被存放在寄存器中,之后转移到栈中。argc也被存放在栈中-20(%rbp)。
3.3.2赋值
图 3- 6赋值操作
i在循环中通过movl值指令进行赋值操作,赋初值0 。
3.3.3类型转换
本程序源代码中sleep函数的输入用到了利用atoi函数进行类型转换,将字符转换成整型。
汇编程序中该部分实现如下图所示;
图 3- 7类型转换
首先,对地址去掉引用,将数据存在寄存器%rdi中,从而传入atoi函数进行引用。
3.3.4算术操作
图 3- 8算术操作
循环体中,每次循环,i都需要加一,i存放在栈中-4(%rbp)。
3.3.5关系操作
图 3- 9if语句判断
用cmpl指令判断argc是否等于5,argc也被存放在栈中-20(%rbp)。
图 3- 10for语句判断
循环体中用cmpl指令判断i是否到达了循环条件界限,i存放在栈中-4(%rbp)。
3.3.6数组操作
图 3- 11数组操作
数组如3.3.1中提及,通过寄存器rsi中转,存放在栈中,数据通过mov指令存入数组。
3.3.7控制转移
图 3- 12if语句跳转
图中为源代码中if语句的汇编指令,通过关系操作完成条件判断,之后je指令指向条件成立时执行的部分,如果成立则跳到.L2,否则执行指令指导调用exit函数。
.L2中有跳转至.L3的指令
图 3- 13for语句跳转
.L3对应源代码中循环体的部分,首先关系操作判断循环界限,界限内会跳至.L4,.L4中对应循环体中的代码。
3.3.8函数操作
图 3- 14printf函数
对应源代码中if语句中调用的printf函数,在汇编语言中通过call指令调用puts,将.LC0中存放的字符串传递给寄存器edi来输入。
图 3- 15exit函数
对应源代码中if语句中调用的exit函数,在汇编语言中通过call指令调用exit,将直接数1传递给寄存器edi来输入。
图 3- 16printf函数
对应源代码中for语句中调用的printf函数,在汇编语言中通过call指令调用printf,将.LC1中存放的字符串传递给寄存器edi来输入。
图 3- 17atoi函数
图 3- 18sleep函数
源代码中sleep函数嵌套调用atoi,在汇编代码中体现为调用sleep前,使用atoi存放在寄存器eax中的返回值转移到寄存器edi作为sleep函数的参数。
对于atoi函数的调用,在汇编语言中通过call指令调用atoi,输入参数时首先对地址去掉引用,将数据存在寄存器%rdi中,从而传入atoi函数进行引用。(如3.3.3中所示)
图 3- 19getchar函数
对应源代码中主函数中调用的printf函数,在汇编语言中通过call指令调用getchar。
3.4 本章小结
本章节主要讨论了程序生成过程中的编译过程。首先我们解释了编译的概念和作用,之后通过Linux指令,根据上文中得到的预处理文件,得到hello程序的.s文件,记录着hello程序对应的汇编语句。之后针对hello程序对应的汇编语句,我们进行了数据、赋值、类型转换、算术操作、关系操作、数组操作、控制转移、函数操作方面的分析,解释了编译器是怎么处理C语言的各个数据类型以及各类操作的。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编是指编译器将汇编语言转换为机器语言指令的二进制代码,并打包成可重定位目标程序格式,结果保存在.o文件。
作用:将汇编指令变成CPU可以直接执行的机器指令
4.2 在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -c -o hello.o hello.s
图 4- 1汇编命令
4.3 可重定位目标elf格式
elf头:
图 4- 2ELF头
观察magic序列,去掉开头固定的7f 45 4c 46,02表示系统字大小为64位,01表示小端,第二个01表示ELF头版本为1.剩下的文本给出了关于文件的许多其他信息,比如ELF头大小、目标文件类型、系统架构、节头的大小和数量 。
节头:
图 4- 3节头
节头记录了各个节的名称、类型、地址以及大小等等。
重定位节 '.rela.text':
图 4- 4重定位节 '.rela.text'
这是一个重定位节,是一个.text节中位置的列表,存放着.text节中需要重定位的条目,类型为 RELA。
当链接组合这些重定位条目时,需要修改记录的这些位置,即重定位入口,重定位节中给出了重定位入口的类型、名称、在段中的偏移量等。
重定位节 '.rela.eh_frame':
图 4- 5重定位节 '.rela.eh_frame'
这是一个重定位节,是一个.eh_frame节中位置的列表,存放着.eh_frame节中需要重定位的条目,类型为 RELA。
符号表:
图 4- 6符号表.symtab
符号表包含用来定位、重定位程序中符号定义和引用的信息,符号表记录了该文件中的所有符号,即经过修饰了的函数名或者变量名。每个可重定位目标文件在.symtab中都有一张符号表,.symtab符号表不包含局部变量的条目,这一点和编译器中的符号表有所不同。
4.4 Hello.o的结果解析
objdump -d -r hello.o得到反汇编结果,即根据机器语言逆推出的汇编语言,与上一张得到的正向汇编的程序比较。
反汇编结果:
图 4- 7反汇编结果
分析得到不同:
- Call指令调用函数是,调用内容不再是单调的函数名,而是会先给出地址,并且后续会给出函数的各方面信息。
- Jump跳转指令与call指令相似,跳转位置也变为了地址的表示方式,而非段名。
- 机器代码中所有操作数都是十六进制,而汇编代码中均为十进制。
- 在机器代码中许多mov指令后标志数据大小的“l、q”等字符被省略。
综上能发现机器语言中所有涉及到地址的跳转指令都被具体到了地址。
4.5 本章小结
本章节主要讨论了程序生成过程中的汇编过程。首先我们解释了汇编的概念和作用,之后通过Linux指令,根据上文中得到的编译好的.s文件,得到hello程序的.o文件,记录着hello程序对应的机器指令。之后针对hello程序对应的机器代码,我们进行了对ELF文件的解读,列出了可重定位文件提供的信息。之后,对比机器代码的反汇编结果和正向汇编结果,说明了机器语言的构成与汇编语言的映射关系。
第5章 链接
5.1 链接的概念与作用
概念:链接是将代码和数据片段整合成一个可以被加载到内存中执行的文件。现代操作系统中,链接是由链接器自动执行的。
作用:链接最大的作用是分离编译,在编写大型应用程序时,不需要再编译出一个巨大的源文件,可以把代码编译成比较小的,单独的模块。文件修改时只需要重新编译单独的模块就可以了,不需要重新编译整个文件。
5.2 在Ubuntu下链接的命令
使用ld的链接命令: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.3 可执行目标文件hello的格式
Linux指令:readelf -a hello得到可执行文件hello的ELF信息。
ELF头:
图 5- 2可执行文件ELF头
分析同第四章;
观察magic序列,去掉开头固定的7f 45 4c 46,02表示系统字大小为64位,01表示小端,第二个01表示ELF头版本为1.剩下的文本给出了关于文件的许多其他信息,比如ELF头大小、目标文件类型、系统架构、节头的大小和数量。
节头:
图 5- 3可执行文件节头
分析同第四章;
节头记录了各个节的名称、类型、地址以及大小等等。
程序头:
图 5- 4可执行文件程序头
程序头记录了程序运行所需的各种段的种类和信息如文件大小,存储空间大小和文件状态等。
动态节:
图 5- 5可执行文件动态节
重定位节:
图 5- 6重定位节‘.rela.dyn’
这是一个重定位节,是一个.dyn节中位置的列表,存放着.dyn节中需要重定位的条目,类型为 rela。
当链接组合这些重定位条目时,需要修改记录的这些位置,即重定位入口,重定位节中给出了重定位入口的类型、名称、在段中的偏移量等。
图 5- 7重定位节‘.rela.plt’
这是一个重定位节,是一个.plt节中位置的列表,存放着.plt节中需要重定位的条目,类型为 rela。
符号表:
图 5- 8符号表.symtab
符号表包含用来定位、重定位程序中符号定义和引用的信息,符号表记录了该文件中的所有符号,即经过修饰了的函数名或者变量名。每个可重定位目标文件在.symtab中都有一张符号表,.symtab符号表不包含局部变量的条目,这一点和编译器中的符号表有所不同。
动态符号表:
图 5- 9符号表.dynsym
.dynsym符号表包含动态链接的符号
5.4 hello的虚拟地址空间
图 5- 10虚拟地址空间
使用edb加载hello,进程的虚拟地址空间从0x401000开始,由前文ELF头中信息可知入口点地址为0x4010f。如果想寻找某个特定节的地址可以通过入口点地址加上节表中的偏移量找到。
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
objdump -d -r hello得到可执行文件的反汇编结果,如下
图 5- 11可执行文件反汇编看结果
与hello.o对比发现有以下不同点:
- hello.o的反汇编代码中只有main函数这一个具体函数,而hello的反汇编代码中还给出了所有调用的函数的具体代码。
- 程序中所有跳转地址的语句和调用函数的语句中的参数均发生了改变,jump指令的相对地址被替换成了具体的地址,call指令中调用的函数名称也变成了节中具体地址。
- 程序分节书写,如section .init、section .plt、section .plt.sec、section .text、section .fini。
链接重定位的过程:
- 符号解析,链接器将每个符号引用正好与一个符号定义关联起来。
- 链接器将每个模块中相同类型的节合并为一个该类型的新的节,作为连接后程序的这一种节。
- 将符号从它们的在.o文件的相对位置重新定位到可执行文件的最终绝对内存位置。更新所有对这些符号的引用来反映它们的新位置,使得它们指向正确运行时的地址。
5.6 hello的执行流程
使用gdb/edb执行hello,下列为从加载hello到_start,到call main,以及程序终止的所有过程中调用与跳转的各个子程序名及程序地址。
ld-2.33.so!_dl_start 0x7ffff7fcdd70
ld-2.33.so!_dl_init 0x7ffff7fdc060
hello!_start 0x0000000000401090
hello!main 0x0000000000401172
hello!puts@plt 0x0000000000401030
hello!printf@plt 0x0000000000401040
hello!strtol@plt 0x0000000000401050
hello!sleep@plt 0x0000000000401070
hello!getc@plt 0x0000000000401080
5.7 Hello的动态链接分析
链接会调用共享库,但编译器无法在调用的同时预测一个函数的运行地址,因此需要使其地址可以被加载到任何地址。通常会为该引用生成一条重定位记录,之后在程序加载的时候动态链接器会对他进行解析。这一操作使用过程链接表PLT+全局偏移量表GOT来实现。
如图,GOT在运行前为空,运行后其中存放这被调用的函数的偏移量。
图 5- 12运行前GOT
图 5- 13运行后GOT
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
本章主要就介绍了程序生成可执行文件前的最后一步——链接。我们首先介绍了连接的概念和作用,并根据hello这一实例介绍了可执行文件的ELF格式,并根据信息展示了程序的虚拟地址。、通过反汇编,我们分析了hello的可执行文件与hello.o的重定位项目有什么不同,并根据异同分析了hello程序重定位的过程,又经过edb调试分析hello程序的动态链接项目。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个执行中程序的示例。是OS对CPU执行的程序的运行过程的一种抽象。是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。
作用:进程对处理器,内存和IO整个系统进行了抽象,通过这种抽象,OS能给予程序在独立使用处理器和内存的假象,并控制程序不得滥用硬件。此外,还能将硬件操作抽象成进程方便OS操作。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell是一个命令行解释器,它为用户提供了一个向Linux内核发送请求以便运行程序的界面系统级程序,用户可以用Shell来启动、挂起、停止甚至是编写一些程序,实现用户与Linux核心的接口。。
Shell还是一个功能相当强大的编程语言,易编写,易调试,灵活性较强。Shell是解释执行的脚本语言,在Shell中可以直接调用Linux系统命令。
处理流程:shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。
6.3 Hello的fork进程创建过程
当父进程调用fork时,操作系统会创建一个新的子进程。
操作系统为子进程分配新的进程控制块,其中包含子进程的状态信息。操作系统为子进程分配新的内存空间,复制父进程的地址空间到子进程,包括代码段、数据段、堆和栈。值得注意的是,子进程拥有独立的上下文。
子进程继承父进程的文件描述符表。文件描述符是指向打开文件的指针,因此父子进程共享打开的文件。
子进程获得一个唯一的进程标识符(PID),这个PID与父进程的PID不同。子进程的父进程ID(PPID)被设置为父进程的PID。
fork调用在父进程中返回子进程的PID;fork调用在子进程中返回0。
6.4 Hello的execve过程
`execve` 是一个系统调用,它不会创建新进程,而是用新的程序替换当前进程的地址空间。以下是execve的过程
内核首先检查传递给 `execve` 的参数,包括文件路径、命令行参数和环境变量。内核查找hello程序的可执行文件,并验证文件的类型和权限。当前进程的地址空间被清空,释放所有旧的内存映射。为hello程序的可执行文件创建新的地址空间,包括代码段、数据段、堆和栈。根据可执行文件的格式(如ELF),内核加载程序头信息,确定需要映射到内存的各个段。内核初始化新的用户堆栈,并将命令行参数和环境变量复制到新堆栈中。将程序计数器(PC)设置为hello程序的入口点。根据需要,关闭或重置文件描述符。内核将控制权交给hello程序的入口点,开始执行hello程序的代码。如果 `execve` 调用成功,当前进程的执行上下文被新程序完全替换,不会返回到调用点。如果失败,`execve` 返回一个错误码,并保持当前进程的状态不变。
6.5 Hello的进程执行
程序运行时,shell调用fork函数为hello程序创造了一个进程,拥有独立的控制流。控制权将会被交给子进程调用execve函数加载这个进程。Hello运行时,如果被抢占,比如时间片到时或者中断,则进入内核模式,操作系统会收回控制权并转交给另一个进程,此时再回到用户模式,这就是上下文切换。与此类似的,当hello程序中调用了sleep函数时,也会进行上下文切换,切换到其他进程。当sleep函数的时间结束,函数返回时,此时间片结束,触发中断,系统再次进行上下文切换,重新开始执行hello进程。
6.6 hello的异常与信号处理
不停乱按/回车
图 6- 1不停乱按/回车
由图可以看出,乱按键盘和回车均不会影响程序正常输出。但是在按回车后的一行字符会被看做输入的一条指令,在程序结束后被输入,如果再按回车会被执行,可能是因为第一个回车被当作了getchar的输入。
Ctrl-C
图 6- 2Ctrl-C测试
由图可以看出,Ctrl-C会结束程序的运行,这是因为程序收到了一个SIGINT信号,处理方式为杀死当前进程。
Ctrl-Z
图 6- 3Ctrl-Z测试
由图可以看出,Ctrl-Z会暂停程序的运行,这是因为程序收到了一个SIGSTP信号,处理方式为挂起当前进程
图 6- 4ps结果
输入ps,我们发现hello进程仍然存在,说明它只是被挂起,没有被杀死
图 6- 5jobs结果
图 6- 6pstree结果
图 6- 7hello所在分支
同样的,输入jobs/pstree也能找到hello进程,他在pstree中位于systemd的子分支
图 6- 8fg结果
输入fg指令会让hello进程重新开始执行,即回到前台。
图 6- 9kill结果
输入kill指令会杀死hello进程,程序中是,且当使用ps查看时发现已无hello进程
6.7本章小结
本章节我们全面阐述了进程和信号。首先,我们简述了进程的概念和作用,以及shell的作用和处理流程。并且,通过对进程的进一步了解,我们还阐述了hello的fork创造进程过程、execve过程;并且结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换。
此外,我们还举例分析了hello进程中遇到几种常见的异常,列出了处理结果。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址:
逻辑地址是由CPU生成的地址,通常在程序编译时生成。它是hello程序代码中使用的地址,是hello.asm的相对偏移量。逻辑地址由段选择子和段内偏移组成。
2. 线性地址:
线性地址是由逻辑地址通过段选择和段内偏移转换而来的地址。在x86架构中,线性地址是通过段寄存器和段描述符表(GDT或LDT)进行转换的结果。线性地址是一个平坦的地址空间,没有段的概念。
3.虚拟地址:
虚拟地址是由操作系统提供的一个抽象地址空间,允许hello进程拥有独立的地址空间。虚拟地址通过页表映射到物理地址。虚拟地址空间使得程序可以认为自己独占整个内存,从而简化编程和提高安全性。
4. 物理地址:
物理地址是实际的内存硬件地址,用于访问计算机的物理内存。物理地址是由内存管理单元(MMU)将虚拟地址转换而来的。物理地址直接对应到内存芯片上的位置。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段选择子和段内偏移组成。CPU通过段选择子从全局描述符表或局部描述符表中选择一个段描述符。段描述符包含了段的基址、界限、访问权限等信息。之后,CPU将逻辑地址中的段选择子和偏移量组合成线性地址。线性地址是一个32位的地址,它由段基址和偏移量相加得到。
7.3 Hello的线性地址到物理地址的变换-页式管理
在页式管理中,进程的虚拟地址空间被划分为固定大小的页,而物理内存也被划分为相同大小的页面框。系统使用页表完成从虚拟页到物理页的映射,每一个页表条目由有效位和一个n位的地址字段组成。当进程访问虚拟地址空间中的某个页面时,操作系统会将该页面映射到物理内存中的一个页面框,从而实现地址变换。
页式管理的过程大致如下:
1. 当进程访问虚拟地址空间中的某个页面时,操作系统会首先检查该页面是否已经在物理内存中。如果是,则直接访问对应的页面框;如果不在,则执行下一步。
2. 如果所需页面不在物理内存中,操作系统会将其从磁盘中加载到内存中的一个空闲页面框中。
3. 操作系统会更新页表,将虚拟地址空间中的页面与物理内存中的页面框进行映射。
4. 进程可以继续访问所需的页面,而操作系统会根据页表的映射关系完成地址变换,将虚拟地址转换为物理地址。
通过页式管理,操作系统可以实现虚拟内存的概念,使得每个进程都拥有独立的虚拟地址空间,而不受物理内存大小的限制。同时,页式管理也可以提高内存的利用率,通过页面置换机制将不常用的页面置换出去,从而为更重要的页面腾出空间。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB用于记录存储虚拟地址到物理地址的映射关系,以加快地址转换的速度。TLB中存储了最近访问过的虚拟地址与物理地址的映射,当CPU访问内存时,先在TLB中查找对应的映射关系,如果找到则直接使用对应的物理地址,否则需要通过页表进行地址转换。
在四级页表的支持下,虚拟地址(VA)到物理地址(PA)的变换通过以下方法实现:
CPU生成虚拟地址。虚拟地址被分为低到高的4个部分,用于索引页表的不同级别。CPU使用虚拟地址的高位部分来索引最顶层的页表,获取下一级页表的物理地址。CPU再使用虚拟地址的中间部分来索引下一级页表,获取更低一级页表的物理地址。依此类推,直到获取到最底层的页表项。最底层的页表项中存储了对应的物理页框号。CPU将虚拟地址的偏移部分与页表项中的物理页框号组合,得到最终的物理地址。需要额外的时间来访问页表,然后将新的映射关系存储到TLB中。
7.5 三级Cache支持下的物理内存访问
得到物理地址后,在cache支持下访问物理内存。由于三级cache匹配原理相似,此处以L1 cache举例说明:
L1 Cache为8路,64组,块大小为64B,因此需要6位的CI作为组索引和6位的CO作为偏移量。根据物理地址分别匹配组索引,标记位和判断有效位,如果命中则根据偏移量得到所需数据,如果未命中则CPU会继续检查第二级缓存(L2 Cache)。如果数据在L2 Cache中找到,CPU会从L2 Cache中读取数据,虽然比在L1 Cache中读取数据慢一些,但仍然比直接访问主内存要快得多。
如果数据既不在L1 Cache也不在L2 Cache中,CPU会继续检查第三级缓存(L3 Cache)。L3 Cache通常是共享的,多个CPU核心可以共享同一块L3 Cache。如果数据在L3 Cache中找到,CPU会从L3 Cache中读取数据。
如果数据既不在L1、L2、L3 Cache中,CPU会向主内存发起访问请求。主内存是存储在内存模块中的大容量存储器,访问主内存的延迟相对较高,因为需要通过内存总线进行数据传输。
7.6 hello进程fork时的内存映射
当为hello进程调用fork函数时,内核会为子进程创造各种数据结构,分为它分配独一无二的pid。为了给与hello进程虚拟内存,内核会为他创造mm_struct(内存描述符)、区域结构和页表的副本。内核会将2个进程的页面均设置为只读和私有的写时复制。这使得父子进程拥有相同的虚拟内存,并且在fork()之后,父子进程共享物理内存,只有在其中一个进程尝试修改共享内存时,才会进行实际的复制操作,确保父子进程之间的内存独立性。
7.7 hello进程execve时的内存映射
在调用execve函数时,操作系统会创建一个新的进程,并加载新的程序映像到进程的地址空间中。内存映射具体步骤如下:
1. 清空原有的进程地址空间:在调用execve之前,操作系统会清空原有进程的地址空间,包括清除原有的代码段、数据段和堆栈等内容。
2. 加载新程序映像:操作系统会为hello的代码、数据、bss 和栈区域创建新的区域结构。操作系统会将新的程序映像加载到进程的地址空间中。这包括将可执行文件的代码段加载到进程的代码段区域,将可执行文件的数据段加载到进程的数据段区域,以及为新程序分配堆栈空间。
3. 更新进程控制块:操作系统会更新进程控制块中的相关信息,包括更新进程的程序计数器(PC)和堆栈指针(SP)等寄存器的值,使之指向代码入口,以便程序能够正确执行。
7.8 缺页故障与缺页中断处理
缺页故障是指当程序访问的页面不在主存中时发生的一种故障。当程序需要访问一个页面,而该页面不在主存中时,即不在页表中,就会发生缺页故障。处理缺页故障的过程称为缺页中断处理。
缺页中断处理的一般步骤如下:
1. CPU发出一个内存访问请求,发现所需的页面不在主存中,触发缺页中断。
2. 操作系统接收到缺页中断信号,首先会检查缺页是否是合法的,即检查访问的页面是否在进程的地址空间中。
3. 如果缺页是合法的,操作系统会将缺页的页面从磁盘加载到主存中的空闲页面框中。
4. 加载完成后,操作系统更新页表,将新加载的页面映射到进程的地址空间中。
5. 最后,操作系统重新执行之前被中断的指令,使程序能够继续执行。
7.9动态存储分配管理
在程序运行时程序员使用动态内存分配器 (比如 malloc) 获得虚拟内存,动态内存分配器维护者一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块(blocks)的集合来维护,每个块要么是已分配的,要么是空闲的。分配器分为隐式分配器和显式分配器。显式分配器要求应用显式地释放任何已分配的快块;隐式分配器当应用检测到已分配块不再被程序所使用,就释放这个块。
关于如何记录空闲块,有隐式空闲链表、隐式空闲链表和分离空闲链表。
在隐式空闲链表中,内存被划分为一系列大小不等的块,每个块可以被分配给程序中的变量或数据结构。当一个块被释放时,它会被添加到空闲链表中,以便后续的内存分配可以重复利用这些空闲块。隐式空闲链表的特点是在每个空闲块中并不存储其大小信息,而是通过遍历链表来查找合适大小的空闲块。这种设计可以减少内存的碎片化,但也会增加查找空闲块的时间复杂度。隐式空闲链表通常包括头指针指向第一个空闲块,每个空闲块包含指向下一个空闲块的指针。当程序需要分配内存时,会在空闲链表中查找第一个大小足够的空闲块,然后将其分配给程序。当程序释放内存时,该内存块会被添加回空闲链表中,以便后续的内存分配可以重复利用。
显式空闲链表每个节点表示一个可用的内存块。每个节点通常包含指向下一个空闲块的指针,以及描述该内存块大小的信息。当需要分配内存时,系统会遍历这个链表,找到合适大小的内存块,并将其分配出去。当内存被释放时,系统会将这块内存重新加入到空闲链表中,以便后续的分配。显式空闲链表相对于隐式空闲链表来说,更加灵活,因为它可以支持更多的内存管理操作,比如合并相邻的空闲块、按照特定的策略排序空闲块等。这使得它在一些场景下更加高效和灵活。
分离空闲链表是指将一个链表中所有空闲的节点提取出来,形成一个新的链表,原链表中不包含这些空闲节点。这个过程通常用于内存管理中,用于管理空闲内存块的分配和释放。在分离空闲链表的过程中,需要遍历原链表,将空闲节点从原链表中移除,并将其连接到新的空闲链表中。这样可以更高效地管理内存的分配和释放,避免内存泄漏和碎片化。
7.10本章小结
本章节我们从逻辑地址、线性地址、虚拟地址、物理地址的概念入手,分别讨论了cpu寻址和内存分配等等储存管理相关的机制和策略。Cpu寻址方面,我们介绍了Intel逻辑地址到线性地址的变换-段式管理、Hello的线性地址到物理地址的变换-页式管理、TLB与四级页表支持下的VA到PA的变换和三级Cache支持下的物理内存访问。内存管理上,我们考虑了缺页故障并解释了他的处理方法。我们还针对c语言中常用的malloc函数,引入了关于动态内存分配的内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
在Linux系统中,所有的IO设备都被模型化为设备文件的形式,Linux系统通过设备文件系统来管理和组织设备文件。设备文件系统提供了一种统一的方式来访问各种IO设备,使用户可以通过文件系统的接口来操作IO设备,用读和写抽象化的代替了设备的输入输出。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
接口就是连接CPU与外设之间的部件,它完成CPU与外界的信息传送。还包括辅助CPU工作的外围电路,如中断控制器、DMA控制器、定时器、高速CACHE。使用接口能解决外设品种繁多;工作速度慢;信号类型和电平种类不同;信息结构格式复杂等问题。
函数:
1.open():
功能:打开一个文件或设备。
原型:int open(const char *pathname, int flags, mode_t mode);
参数:
pathname:文件路径。
flags:打开模式(如只读、只写、读写等)。
mode:文件权限(在创建文件时使用)。
返回值:成功时返回文件描述符,失败时返回1。
2.close():
功能:关闭一个文件描述符。
原型:int close(int fd);
参数:
fd:文件描述符。
返回值:成功时返回0,失败时返回1。
3.read():
功能:从文件描述符中读取数据。
原型:ssize_t read(int fd ,void buf ,size_t count);
参数:
fd:文件描述符。
buf:存储读取数据的缓冲区。
count:要读取的字节数。
返回值:成功时返回读取的字节数,失败时返回1。
4.write():
功能:向文件描述符中写入数据。
原型:ssize_t write(int fd ,const void buf ,size_t count);
参数:
fd:文件描述符。
buf:要写入的数据缓冲区。
count:要写入的字节数。
返回值:成功时返回写入的字节数,失败时返回1。
5.lseek():
功能:移动文件描述符的读写位置。
原型:off_t lseek(int fd ,off_t offset ,int whence);
参数:
fd:文件描述符。
offset:偏移量。
whence:基准位置(如文件开头、当前位置、文件结尾)。
返回值:成功时返回新的文件偏移量,失败时返回1。
8.3 printf的实现分析
首先写出printf函数
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
总体来说,printf函数的作用就是输入一个fmt决定输出的格式,且传入的参数的数目并不确定,然后把匹配的参数根据传入的fmt格式输出。
va_list arg = (va_list)((char*)(&fmt) + 4);将fmt后面传入的第一个参数的参数的地址以字符形式存放在arg中。
之后调用vsprintf函数,观察他的函数体,我们能够发现它的作用就是根据fmt要求的格式,从arg提供的参数地址开始,生成格式字符串,返回字符串的长度
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
之后调用write函数,把字符串写到终端,在这个的函数中,通过陷阱-系统调用syscall,把字符从寄存器中通过总线复制到显存中,储存成ASCII码。
字符显示驱动子程序通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
当调用getchar时,用户键盘输入的字符会被置于缓冲区,知道输入空格结束字符串。此时,getchar开始从stdin流中读取字符,一次一个,返回对应的ASCII码,如出错返回-1。当执行getchar函数时按键盘相当于一个异步异常行为,即键盘中断。因为进入getchar函数后进程处于阻塞状态,等待键盘输入。当键盘缓冲区得到字符,系统会调用read函数,读取出缓冲区中的字符串。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章节中从hello程序介绍了unix系统的I/O管理,首先阐述了Linux的IO设备管理方法,然后简述了Unix IO接口及其函数。最后,我们具体分析了printf和getchar的实现。
结论
Hello在计算机中的一生经历了一下这些过程:
- 代码编写;
- 预处理:将hello.c文件预处理为hello.i,为编译做准备;
- 编译:将hello.i编译成汇编语言文件hello.s;
- 汇编:将汇编语言代码hello.s转换成机器语言代码存放在重定位文件hello.o中;
- 链接:根据hello.o文件进行重定位,得到可执行文件hello;
- 创造进程:调用fork创造hello进程;
- 加载运行:调用execve加载hello进程;
- 访问内存:映射虚拟内存,载入物理内存;
- 信号处理:进程收到键盘信号,执行信号处理程序;
- 杀死进程:kill指令回收进程的所有数据结构
至此hello简单(代码编写)而复杂(计算机原理)的一生就此落幕,即使是一段简单的程序,在csapp的路途上也要应用到百花齐放的原理与技术方案,不禁让我慨叹计算机人的辛勤与智慧。
附件
源程序:hello.c
预处理后的文件:hello.i
编译后的文件:hello.s
汇编后的文件:hello.o
可执行文件:hello
hello.o的反汇编文件:hello.objdump(方便分析使用,文中未提到)
hello的反汇编文件:hello.exec(方便分析使用,文中未提到)
图-附 应用到的文件
参考文献
[1] Randal E.Bryant, Dvaid R. O’Hallaron. Computer Systems: A programmer’s perspective[M]. 北京:机械工业出版社,2016:465-500.
[2] https://blog.csdn.net/m0_72410588/article/details/132774613.
[3] https://blog.csdn.net/weixin_45907789/article/details/108870071.