计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 物联网工程
学 号 2021111566
班 级 2137301
学 生 张昊
指 导 教 师 吴锐
计算机科学与技术学院
2023年5月
本文主要讲述了hello.c在编写完成后运行在Linux系统中的生命历程,运用相关工具进行分析程序预处理、编译、汇编、链接等过程在Linux下实现的原理,直到应用程序运行并结束过程中的相关内容,包括对于进程、存储以及IO等方面的分析。;并介绍了shell的内存管理,IO管理,进程管理等相关知识,了解虚拟内存、异常信号等内容。
关键词:计算机系统;计算机体系结构;hello.c;Linux
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
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 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
第1章 概述
1.1 Hello简介
P2P(From Program to Process):编写完成的hello.c文件,首先经过预处理器预处理生成hello.i文件;再经过编译器编译,生成汇编代码文件hello.s;再经过汇编器翻译成一个重定位目标文件hello.o;最后使用链接器将多个可重定位目标文件组合起来,形成一个可执行目标文件hello。
020(From Zero-0 to Zero-0):在执行hello程序之后,shell通过execve进行加载到虚拟内存中。之后程序载入物理内存,然后进入 main函数执行代码。然后在程序执行结束后,进程保持终止状态,父进程进行回收后,shell保持最初状态。
1.2 环境与工具
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.s |
汇编后得到的可重定位目标文件 | hello.o |
readelf读取hello.o得到的ELF格式信息 | hello.elf |
反汇编hello.o得到的反汇编文件 | hello.asm |
由hello可执行文件生成的.elf文件 | hello1.elf |
反汇编hello可执行文件得到的反汇编文件 | hello1.asm |
1.4 本章小结
本章对hello进行了总体的介绍,分别对P2P和020的意思进行了解释,并给出了实验进行的软件环境,硬件环境,以及开发/调试工具,最后列出了在实验过程中生成的中间结果文件及其作用。
第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下预处理的命令
命令:gcc -E -o hello.i hello.c
2.3 Hello的预处理结果解析
可以看出,经过编译处理后文件变为3091行,文件大小大大增加,预处理器删除了源文件当中全部的注释,对源文件中的宏进行了宏展开,将stdio.h stdlib.h unistd.h当中的内容插入到程序当中,同时也对#define相应的符号进行了替换。
以 stdio.h 的展开为例:cpp首先移除指令#include <stdio.h>,并根据Ubuntu系统默认的环境变量查找 stdio.h,最终打开路径/usr/include/stdio.h下的stdio.h文件。如果stdio.h文件中存在#define指令,则按照上述流程继续递归展开,直到所有#define指令都被解释替换掉,因此最终的.i 文件中不会出现#define 的。如果遇到大量的#ifdef #ifndef条件编译指令,cpp会根据条件值进行判断来决定是否执行其中的逻辑。此外,cpp还会进行删除程序中的注释和多余的空白字符等操作,并对一些值进行替换。
2.4 本章小结
本章主要介绍了预处理的概念和作用,包括头文件的展开、宏替换、去掉注释、条件编译等功能,以及Linux下预处理的两个指令。同时,本章还以Ubuntu系统下的hello.c文件为例,展示了预处理后得到的hello.i文件,并对其内容进行了解析,深入地了解了预处理的过程和细节。
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译过程是指编译器ccl通过词法分析、语法分析、语义分析和优化,将源程序文件 hello.i 转换为汇编程序文件 hello.s。在这个过程中,编译器将源程序中的每条合法指令翻译成等价的低级机器语言指令,并以文本的形式描述在 hello.s 中。
编译的作用:
编译的作用是将用高级语言编写的源代码转换为机器代码、字节码或另一种编程语言。编译的过程需要通过词法分析、语法分析、语义分析和优化等步骤,检查源代码是否符合语法规则,并将其翻译成目标平台能够执行的格式。
3.2 在Ubuntu下编译的命令
编译后在同一目录下生成乐一个hello.s文件
3.3 Hello的编译结果解析
hello.s文件的内容如下:
3.3.1文件说明
.file 指明源文件名
.text 代码段:用来存放程序代码的区域
.section 指示将代码分成若干个段
.rodata 用于维护只读数据
.align 8 地址对齐伪指令,用来指定符号的对齐方式
.string 字符串的存储位置
.global 用来让一个符号对链接器可见,可以供其他链接对象模块使用,告诉编译器后续跟的是一个全局可见的名字
.type 指定是对象类型或者函数类型
3.3.2数据
(1). 常量:
常量例如立即数:
立即数直接出现在代码段之中。
常量例如字符串:
在.LC0和.LC1中都含有字符串常量
.LC0和.LC1部分是标签的一种,它们提供了一个符号名称,用于引用标签后面出现的字节。在本例中,LC1表示字符串“Hello %s %s\n”。GNU汇编程序遵循一种约定,将以“L”开头的标签视为“局部标签”。在本例中,编译器使用它来引用特定目标文件专有的符号。在本例中,它表示一个字符串常量。
(2). 变量
- 全局变量:
已初始化的全局变量被存储在.data节中,它的初始化没有汇编指令,而是直接完成的。
- 局部变量:
局部变量存储在寄存器或栈中。
例如:
在c程序中:
在汇编代码中:
注:局部变量i被存储在栈中-4(%rbp)的位置。
在这里我们可以看到在循环体中,局部变量i被初始化为0,经过比较后,决定进入或跳出循环,在循环体中的代码执行结束后,加1。
3.3.3 赋值:
(1). 通过mov来实现赋值:
在这段代码中,汇编语言通过mov立即数的方式来对i进行赋值操作。
(2). 通过leal来实现赋值:
3.3.4 算术操作:
汇编语言的算术指令是用于对数据进行简单的加减乘除运算的指令。常见的算术指令有以下几种:
ADD:将一个字节变量与累加器相加,结果存放在累加器中。如果从第7位产生了进位,进位标志位CF会被置1,否则被清零。
SUB:将一个字节变量与累加器相减,结果存放在累加器中。如果从第7位产生了借位,借位标志位CF会被置1,否则被清零。
MUL:将两个字节变量相乘,结果存放在AX寄存器中。高8位存放在AH中,低8位存放在AL中。如果高8位为0,溢出标志位OF会被清零,否则被置1。
DIV:将一个字或双字变量除以一个字节变量,商和余数分别存放在不同的寄存器中。如果被除数是一个字,那么商存放在AL中,余数存放在AH中;如果被除数是一个双字,那么商存放在AX中,余数存放在DX中。如果除数为0或者商超过8位或16位,会产生除法溢出异常。
hello.s中出现了加,减两种操作
加:
减:
3.3.5 关系操作和控制转移
关系操作与控制转移一般是一起出现的。
例如如下代码:
关系操作还出现在if语句的汇编之中,如:
它所对应的关系操作与控制转移如下:
为了判断argc是否等于4,首先执行cmpl指令,该指令将立即数4与argc的值进行比较,并根据比较结果设置条件码。然后执行je指令,该指令根据条件码的值决定是否跳转到L2地址。如果条件码表示后一个操作数与前一个操作数相等,则跳转到L2地址,否则继续执行下一条指令。
3.3.6 数组/指针/结构操作:
主函数main的参数中有指针数组char *argv[]。
在argv数组中,argv[0]指向输入程序的名称,argv[1]和argv[2]分别指向两个字符串。
C语言代码中的数组访问有argv[1],argv[2],argv[3],在汇编代码中访问这三个量都使用首地址加偏移量的方式。
如图:
3.3.7 函数操作:
函数的调用使用call指令,该指令会将当前的程序计数器压入栈中,并跳转到函数的起始地址。函数的参数可以通过寄存器或栈来传递,具体取决于函数的调用约定。函数执行完毕后,使用ret指令返回到调用者,该指令会从栈中弹出程序计数器,并将其作为下一条指令的地址。函数的返回值通常保存在%rax寄存器中,除非返回值是浮点数或结构体等特殊类型。
hello中出现的函数操作有:
调用puts
puts: 将一个以空字符结尾的字符串发送到标准输出流(通常是屏幕)。该函数会在字符串的末尾自动添加一个换行符,使得下一次输出从新的一行开始。
调用exit
exit: 立即终止调用进程。
调用sleep
sleep:实现程序的休眠。
调用getchar
getchar:从缓冲区中读取字符。
3.4 本章小结
本章的目的是探讨编译的原理和实践,以及C语言与汇编语言之间的对应关系。首先,介绍了编译的定义和功能,即将文本文件转换为汇编语言程序,为生成机器码做好准备。其次,介绍了在Ubuntu环境下使用gcc命令对hello.c源文件进行编译,并生成hello.s汇编文件。然后,对hello.s文件中的各种数据类型和操作进行了详细的分析和解释,展示了编译器如何处理不同的数据和指令。通过本章的学习,加深了对C语言和汇编语言的理解,为后续的链接、加载和运行过程打下了基础。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
汇编是一种面向机器的程序设计语言,它用易于理解和记忆的符号或名称来表示机器指令的操作码和操作数。汇编语言与机器语言之间有一一对应的关系,可以通过汇编器将汇编语言翻译成机器语言,也可以通过反汇编器将机器语言还原成汇编语言。汇编语言的优点是程序执行效率高,缺点是难于编写和调试,且与具体的处理器密切相关。
汇编的作用:
(1)适应不同的高级语言和编译器,提供通用的输出语言。
(2)便于分析和调试底层的问题,查看反汇编代码和机器状态。
(3)提高程序的执行效率和性能,利用处理器的特殊指令和优化技巧。
(4)理解计算机的工作原理和结构,掌握数据的存储格式和寻址方式。
4.2 在Ubuntu下汇编的命令
命令:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
4.3 可重定位目标elf格式
4.3.1命令
在shell中输入readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式:
4.3.2结构分析
(1)ELF头(ELF Header)
目标文件的开头是一个 16字节的序列 Magic,它表示了该文件是由哪种系统生成的,以及该系统的字长和字节序。在 Magic 之后,是 ELF 头的其他部分,它提供了一些有助于链接器理解和处理目标文件的信息,例如 ELF头的长度、目标文件的类别、处理器的类型、节头部表在文件中的位置,以及节头部表中每个条目的大小和个数等相关信息。
(2)节头
描述了.o文件中出现的各个节的意义,包括节的类型、位置、所占空间大小等信息。
(3)重定位节
重定位节包含了一些描述了段中引用外部符号的位置的条目,在链接过程中,这些位置的地址需要被修改为正确的值。链接器根据重定位条目的类型,采用相应的计算方法,得到正确的地址值。
① .rela.text
- .rela.eh_frame
(4)符号表(Symbol table)
.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
4.4 Hello.o的结果解析
4.4.1命令
在shell中输入 objdump -d -r hello.o > hello.asm 指令输出hello.o的反汇编文件,并与第3章的hello.s文件进行对照分析。
得到汇编文件如图:
与hello.s文件(见第三章配图)进行对比
经过分析 hello.s 和 hello.o 两个文件中的汇编代码,比较了它们之间的差异和原因。主要有以下几个方面:
- 操作数的表示:hello.s 中的操作数是十进制的,而 hello.o 的反汇编代码中的操作数是十六进制的,这是为了方便与机器码对应。
- 全局变量的访问:hello.s 中,使用段名称+%rip 访问 rodata(printf 中的字符串),而 hello.o 中,使用 0+%rip 进行访问。这是因为 rodata 中数据地址在运行时才能确定,因此在汇编时,将操作数设置为全0,并添加重定位条目,在链接时才填入正确的地址。
- 分支转移:hello.s 中,跳转指令的目标地址直接记为段名称,如 .L2,.L3 等。而 hello.o 中,跳转的目标为具体的地址,在机器码中体现为目标指令地址与当前指令下一条指令的地址之差。这是因为分支转移是相对寻址,需要根据当前指令的位置计算偏移量。
- 函数调用:hello.s 中,call 指令后跟着函数名称,而 hello.o 中,call 指令的目标地址是当前指令的下一条指令。这是因为 hello.c 中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址。在汇编时,对于这些不确定地址的函数调用,将其 call 指令后的相对地址设置为全0,并添加重定位条目,在链接时才填入正确的地址。
4.5 本章小结
本章介绍了汇编的概念与作用,并以一个实例说明了汇编的过程和结果。本章使用 as 汇编器将汇编代码文件 hello.s 转换为机器语言文件 hello.o,并生成了符合 ELF 格式的可重定位目标文件 hello.elf。本章还通过反汇编工具查看了 hello.o 的机器码,并与 hello.s 的汇编代码进行了对比,分析了它们之间的差异和原因。通过本章的学习,加深了对汇编语言、机器语言、ELF 格式和重定位的理解。
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接是一种将多个代码和数据片段组合成一个可执行文件的过程,该文件可以被加载到内存并运行。链接由链接器完成,它负责解析和重定位各个文件中的符号,例如printf函数。链接可以在不同的时机发生,例如编译时、加载时或运行时,取决于源代码如何被转换为机器代码。
链接的作用:
链接使得分离编译成为可能,这样我们可以将一个大型的应用程序分解为更小、更易管理的模块,每个模块都可以独立地修改和编译。链接还可以将一些常用的函数文件(如printf.o)与我们的模块合并,从而节省源程序空间,减少文件的复杂度和大小,增加容错性,并方便针对性修改。链接还负责将各个模块之间相互引用的部分正确地衔接起来,例如从hello.o到hello的生成过程。
5.2 在Ubuntu下链接的命令
命令:ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
在Shell中输入命令 readelf -a hello > hello1.elf 生成 hello 程序的 ELF 格式文件,保存为hello1.elf
(1)ELF头(ELF Header)
hello1.elf和hello.elf都是ELF格式的文件,它们的ELF头包含了一些描述文件属性和结构的信息,例如系统的字长和字节序序列Magic,以及链接器需要的一些元数据。这两个文件的ELF头有一些相同的信息(如Magic,类别等),也有一些不同的信息,例如类型,程序头大小,节头数量和入口地址。hello1.elf相比于hello.elf,有更多的程序头和节头,并且指定了一个入口地址。
(2)节头
节头记录了每个节的属性、大小和位置。链接器在合并文件时,会将同名的节合并为一个较大的节,并且根据这个较大的节的位置和大小重新计算每个符号的地址。
(3)程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
(4)Dynamic section
(5)Symbol table
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
5.4 hello的虚拟地址空间
使用edb打开hello,可以查看ELF格式文件的Program Headers,它们描述了链接器运行时需要加载的各个段,并提供了动态链接的信息。每个段都有一些属性,例如虚拟地址、物理地址、大小等。hello程序包含了PHDR,INTERP,LOAD ,DYNAMIC,NOTE ,GNU_STACK,GNU_RELRO等段,如下图所示:
5.5 链接的重定位过程分析
在Shell中使用命令objdump -d -r hello > hello1.asm生成反汇编文件hello1.asm5.6 hello的执行流程
与第四章中生成的hello.asm文件进行比较,其不同之处如下:
- 链接后函数数量增加
链接后的反汇编文件hello2.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。原因是动态链接器将共享库中hello.c用到的函数加入可执行文件中。
- 函数调用指令call的参数发生变化
链接器在链接过程中,处理了重定位条目,将call指令后的字节码修改为目标地址与下一条指令地址的差值,从而使call指令能够跳转到正确的代码段,生成了完整的反汇编代码。
整体代码如下:
- 跳转指令参数发生变化
在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
整体代码如下:
5.6 hello的执行流程
(1) 载入:_dl_start、_dl_init
(2) 开始执行:_start、_libc_start_main
(3) 执行main:_main、_printf、_exit、_sleep、_getchar、_dl_runtime_resolve_xsave、_dl_fixup、_dl_lookup_symbol_x
(4) 退出:exit
5.7 Hello的动态链接分析
在动态链接阶段之前,首先执行静态链接,生成部分链接的可执行目标文件hello,该文件不包含共享库中的代码和数据。当加载hello时,动态链接器对共享目标文件中的相关模块进行重定位,并加载共享库,从而生成完全链接的可执行目标文件。动态链接采用延迟加载策略,即只有在函数被调用时才进行符号解析。函数的动态链接通过全局偏移量表GOT和过程链接表PLT来实现。GOT存储函数的目标地址,PLT为每个全局函数创建一个代理函数,并将对函数的调用转换为对代理函数的调用。
5.8 本章小结
本章介绍了链接的概念和作用,以及动态链接的原理。通过使用edb、gdb、objdump等工具,分析了hello程序的虚拟地址空间、ELF文件格式、重定位和执行过程,深入理解了链接的各个环节。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
进程的作用:
进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统
6.2 简述壳Shell-bash的作用与处理流程
shell的作用:
Shell-bash 是一个用C语言编写的交互型应用程序,它是用户和Linux内核之间的接口。它可以接收用户在提示符下输入的命令,解释并传递给内核去执行。它也可以调用其他程序,并在它们之间传递数据和参数。它把一个程序的输出作为另一个程序的输入,从而实现数据流的处理。Shell-bash 本身也可以被其他程序调用,作为系统的用户界面,提供了一种方便的方式访问操作系统内核的服务。shell的处理流程:
- 判断命令是否是内部命令,即 shell 自带的一些功能,如 cd 和 echo。
- 如果不是内部命令,就在搜索路径中查找命令对应的可执行文件,这些文件可以是 Linux 系统自带的应用程序,如 ls 和 rm,也可以是用户安装的第三方应用程序,如 xv。
- 如果在搜索路径中找不到可执行文件,就报错并提示命令不存在或无法执行。
- 如果找到可执行文件,就将其转换为系统调用,并将其传递给 Linux 内核进行处理。
6.3 Hello的fork进程创建过程
为了运行hello程序,shell在命令行输入./hello命令后,调用fork()函数创建一个子进程。子进程继承了父进程的虚拟地址空间,包括代码和数据段、堆、共享库和用户栈,以及所有打开的文件描述符。子进程可以读写父进程中打开的任何文件。子进程与父进程的唯一区别是它们有不同的进程标识符(PID)。fork()函数在父进程中返回子进程的PID,在子进程中返回0。当子进程运行结束时,如果父进程仍然存在,它会回收子进程的资源,否则init进程会负责回收。
6.4 Hello的execve过程
为了运行hello程序,shell在命令行输入./hello命令后,调用fork()函数创建一个子进程。子进程继承了父进程的虚拟地址空间和文件描述符。然后,子进程调用execve函数,在当前进程的上下文中加载并运行hello程序。execve函数首先删除子进程的用户区域,然后为hello程序的代码、数据、.bss和栈区域创建新的私有区域,这些区域都是写时复制的。接着,execve函数映射hello程序需要的共享区域,比如与标准C库链接的对象。最后,execve函数设置子进程的程序计数器,使之指向hello程序的入口点。execve函数只有在出现错误时才会返回,否则它会执行hello程序,并将参数和环境变量传递给它。
6.5 Hello的进程执行
代码逻辑控制流:代码逻辑控制流是指程序执行的顺序和分支,它决定了程序的功能和行为。代码逻辑控制流可以用控制流图、伪代码或其他方式表示。
时间片:时间片是指操作系统给每个进程分配的一段固定的CPU运行时间,当一个进程用完它的时间片后,操作系统会切换到另一个进程,这样可以实现多任务的并发执行。
用户模式和内核模式:用户模式和内核模式是指处理器的两种运行状态,用户模式下只能执行受限的指令集,不能直接访问硬件资源,内核模式下可以执行任何指令集,可以直接访问硬件资源。操作系统通常在内核模式下运行,用户程序通常在用户模式下运行。当用户程序需要调用操作系统提供的服务时,需要通过系统调用切换到内核模式,并在完成后切换回用户模式。
上下文信息:上下文信息是指进程或线程在某一时刻的运行状态,包括程序计数器、寄存器、栈、内存、文件描述符等。当操作系统需要切换进程或线程时,需要保存当前的上下文信息,并恢复下一个要运行的上下文信息。
运行程序的shell 进程通过fork 系统调用创建一个子进程,该子进程继承了父进程的虚拟内存空间。然后,子进程执行execve 系统调用,加载可执行文件并替换其虚拟内存空间。加载器首先清除原有的内存段,然后为代码、数据、堆和栈分配新的内存段,并将其初始化。代码和数据段的初始化是通过将可执行文件中的页对应到虚拟地址空间中的页来实现的。堆和栈段的初始化是通过将其内容置零来实现的。加载器完成后,跳转到_start 地址开始执行程序,该地址最终会调用main 函数。在用户态和内核态之间切换的过程称为上下文切换。
6.6 hello的异常与信号处理
异常的类别:
在进程中乱按,进程将输入当作是输入的命令缓存到缓冲区中,在程序执行结束后读取命令。
当按下Ctrl+C时,进程会收到SIGINT信号,结束hello程序,使用ps查看进程,发现进程被彻底结束。
当按下Ctrl+Z时,进程收到SIGSTP信号 ,hello程序被挂起,当使用ps查看进程时,可以看到进程仍然存在,通过fg加job号可以恢复执行。
使用kill可以使挂起的进程彻底被杀死
使用pstree指令可以看到进程树。
6.7本章小结
本章介绍了进程、Shell的概念和作用、shell如何调用fork和execve函数、以及通过对hello的创建、加载和终止、进程执行、异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是指在hello反汇编代码中可见的地址,它由一个段标识符和一个段内偏移量组成,用于定位操作数或指令的位置。
线性地址是逻辑地址向物理地址转换过程中的中间结果,它是通过段机制将逻辑地址转换为一维地址空间的地址。在保护模式下,虚拟地址也可以用“段:偏移量”的形式表示,其中的段是指段选择器。Hello反汇编的地址即为虚拟地址。
虚拟地址是保护模式下程序访问存储器所使用的逻辑地址,它可以通过分页机制映射到不同的物理地址。
物理地址是加载到内存地址寄存器中的地址,它是内存单元的实际地址。CPU通过地址总线寻址时,使用的是物理地址。在前端总线上传输的内存地址也都是物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理的优点是能够实现程序的动态链接、保护和共享。动态链接是指在程序运行过程中,根据需要动态地装入或卸载某些段,实现程序的模块化。保护是指通过设置每个段的存取控制位,防止非法访问或修改。共享是指允许多个进程共同使用某些公共的程序或数据段,节省内存空间。
线性地址是逻辑地址向物理地址转换过程中的中间结果,它是通过将逻辑地址中的段内偏移量加上相应的段基址得到的。线性地址可以看作是一维的虚拟地址空间,它可以进一步通过分页机制映射到物理地址空间。
7.3 Hello的线性地址到物理地址的变换-页式管理
为了实现线性地址到物理地址的转换,操作系统采用了分页机制,将线性地址空间划分为等长的单元,称为页。由于页表可能占用大量的内存空间,操作系统使用了两级页表结构,即页目录表和页表。页目录表的物理地址保存在cr3寄存器中。
当处理器访问一个线性地址时,它会通过内存管理单元(MMU)生成一个虚拟地址,并根据虚拟地址的高10位在页目录表中查找对应的页表地址。然后,它会根据虚拟地址的中间10位在页表中查找对应的页帧地址。最后,它会将页帧地址和虚拟地址的低10位相加,得到最终的物理地址。
在这个过程中,处理器需要向高速缓存或主存发出请求,获取页目录表项和页表项。如果这些项已经存在于高速缓存中,那么处理器可以快速地得到物理地址。如果这些项不存在于高速缓存中,那么处理器需要从主存中读取它们,并将它们缓存在高速缓存中,以便下次使用。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个存储单个PTE的块的小型虚拟地址缓存,每个块对应一个虚拟页。当MMU接收到虚拟地址时,它会在TLB中查找匹配的块。如果找到,它会从PTE中获取物理页号,并与虚拟页偏移量组合成52位的物理地址。
如果TLB未命中,MMU需要访问四级页表来获取PTE。CR3寄存器指向第一级页表的基址。虚拟页号的第一部分作为第一级页表的索引,得到一个PTE。如果该PTE有效且权限正确,它会指向第二级页表的基址。同样地,虚拟页号的第二、第三和第四部分分别作为第二、第三和第四级页表的索引,得到最终的PTE。该PTE包含物理页号,与虚拟页偏移量组合成物理地址,并添加到TLB中。
如果在访问任何级别的页表时发现PTE无效或权限错误,MMU会触发缺页中断。
7.5 三级Cache支持下的物理内存访问
采用CT、CI、CO三个字段对物理地址进行分割,并按照以下步骤实现cache的访问和替换。首先,根据CI字段在一级cache中定位相应的组,并检查该组中所有标志位为有效的块的CT字段是否与物理地址的CT字段相匹配。如果匹配成功,则表示一级cache命中,并返回相应的数据块。如果匹配失败,则表示一级cache不命中,需要继续在二级cache中查找。同样地,根据CI字段在二级cache中定位相应的组,并检查该组中所有标志位为有效的块的CT字段是否与物理地址的CT字段相匹配。如果匹配成功,则表示二级cache命中,并将该数据块复制到一级cache中,并返回数据块。如果匹配失败,则表示二级cache不命中,需要继续在三级cache中查找。依此类推,如果三级cache也不命中,则需要从主存中读取数据块,并将其复制到各级cache中,并返回数据块。如果主存发生缺页中断,则需要从硬盘中读取数据块,并将其复制到主存和各级cache中,并返回数据块。
7.6 hello进程fork时的内存映射
shell执行fork函数时,内核为hello进程分配了唯一的PID,并建立了相应的数据结构和task_struct,同时创建了独立的虚拟地址空间。为了实现虚拟内存,内核复制了当前进程的mm_struct、区域结构和页表。它将两个进程共享的页面都设为只读,并将两个进程的区域结构都设为私有的写时复制。这样,fork在新进程返回时,新进程的虚拟内存与fork调用时的虚拟内存完全一致。当两个进程中任意一个进行写操作时,写时复制机制会生成新页面,从而保证了每个进程都拥有私有的地址空间抽象
7.7 hello进程execve时的内存映射
在加载新程序时,操作系统需要对当前进程的用户虚拟地址空间进行重新映射。首先,它会删除已存在的用户区域结构,释放原来的代码、数据、bss、栈和堆区域所占用的物理内存。然后,它会为新程序的代码、数据、bss和栈区域创建新的用户区域结构,并将它们映射到相应的文件或匿名文件中。这些新的区域都是私有的,并且采用写时复制的策略。接着,它会检查新程序是否与共享对象链接,如果是的话,它会动态地将这些对象映射到用户虚拟地址空间中的共享区域中。最后,它会设置程序计数器(PC),使其指向新程序的代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障:当CPU访问虚拟内存中的某个地址,而该地址对应的物理页已经加载在主存中时,称为页命中。反之,如果主存中没有该物理页,称为缺页。
缺页中断处理:当发生缺页时,CPU会调用缺页异常处理程序,该程序负责从主存中选择一个可替换的物理页,称为牺牲页。如果牺牲页已经被修改过,那么需要将其写回磁盘。然后,从磁盘中读取所需的物理页,并将其放入牺牲页的位置。同时,更新相关的PTE,使其指向新的物理页。最后,恢复引起缺页的指令,并让CPU重新发送虚拟地址给MMU。
7.9动态存储分配管理
动态内存管理是系统软件的基本组件之一,它负责在堆上为应用程序动态地分配和回收内存空间。动态内存分配器通过维护堆上的不同大小的内存块来实现动态内存管理,其中每个内存块都是一段连续的虚拟地址空间,可以是已经被分配给应用程序的或者是尚未被使用的。当应用程序请求一定大小的内存时,动态内存分配器需要从空闲块中选择一个合适的块来满足请求,并将其标记为已分配状态。当应用程序释放已分配的内存时,动态内存分配器需要将其恢复为空闲状态,并尝试与相邻的空闲块进行合并,以减少内存碎片。
为了高效地管理堆上的内存块,动态内存分配器通常采用以下三种策略:
记录空闲块策略:这种策略决定了如何组织和查找空闲块,以便快速地找到合适的空闲块来满足应用程序的请求。常见的记录空闲块策略有显示空闲链表、隐式空闲链表、分离空闲链表和红黑树等。
放置策略:这种策略决定了如何从找到的空闲块中划分出所需大小的内存给应用程序,并处理剩余部分。常见的放置策略有首次适配、下一次适配和最佳适配等。
合并策略:这种策略决定了何时以及如何将相邻的空闲块进行合并,以避免或减少内存碎片。常见的合并策略有立即合并和延迟合并等。
7.10本章小结
本章主要介绍了hello 的存储器地址空间、intel 的段式管理、hello 的页式管理, VA 到PA 的变换、物理内存访问,hello进程fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理的相关内容,对hello的存储管理有了较为深入的讲解。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux内核采用了一种优雅的设备抽象方式,即将所有的I/O设备(如网络、磁盘和终端)视为文件,并将所有的输入和输出操作统一为对应文件的读写操作。这样,Linux内核就可以提供一个简单而低级的接口,即Unix I/O,实现了输入和输出操作的一致性和统一性。
8.2 简述Unix IO接口及其函数
接口:
(1)打开文件:程序通过调用open()函数来请求内核分配一个可用的描述符,并将其与指定路径名的文件关联起来。描述符是一个非负整数,可以唯一地标识进程打开的某个文件。内核维护了一个打开文件表,记录了每个打开文件的相关信息,如访问权限、当前位置、引用计数等。程序在后续对该文件的操作中只需使用该描述符即可。
(2)linux shell创建的每个进程都有三个预定义的描述符:标准输入、标准输出和标准错误。它们分别对应着0、1和2这三个常量值,可以在头文件中找到它们的定义。它们分别表示进程从终端读取输入、向终端输出结果和向终端报告错误信息所使用的描述符。
(3)改变当前的文件位置:对于每个打开的文件,内核都会记录其当前位置,即从该文件起始处开始计算的字节偏移量。程序可以通过调用lseek()函数来显式地改变某个描述符所对应的当前位置,以便在随机访问模式下读写该文件。
(4)读写文件。读操作就是通过调用read()函数来将指定描述符所对应的当前位置开始的多个字节复制到内存缓冲区中。如果在给定大小范围内执行读操作时遇到了该文件的末尾,则会产生一个称为EOF(end-of-file)的条件,并返回实际读取到的字节数。应用程序可以通过检测这个条件来判断是否已经读完了该文件。类似地,写操作就是通过调用write()函数来将内存缓冲区中的多个字节复制到指定描述符所对应的当前位置开始的那些字节上,并更新当前位置。
(5)关闭文件。当程序不再需要访问某个打开的文件时,它可以通过调用close()函数来通知内核释放该描述符,并更新打开文件表中相应条目的信息。作为响应,内核会减少该条目的引用计数,并在引用计数为零时将其删除。当进程因为任何原因终止时,内核也会自动关闭该进程打开的所有文件,并释放相关资源。
函数:
(1)open()函数用于打开或创建文件。
(2)close()函数用于关闭一个被打开的的文件。
(3)read()函数用于从文件读取数据。
(4)write()函数用于向文件写入数据。
(5)lseek()函数用于在指定的文件描述符中将将文件指针定位到相应位置。
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;
}
va_list是字符指针,而(char*)(&fmt + 4)表示fmt后的第一个参数的地址。
vsprintf函数的返回值是格式化后的字符串的长度,其功能是根据fmt中指定的格式标识符将可变参数列表转换为相应的格式。
write函数是一个系统调用,用于将buf中的i个字节写入到文件描述符handle所指向的文件中,返回值是实际写入的字节数,如果发生错误则返回-1。
write函数在执行时,会通过eax寄存器传递系统调用号_NR_write,通过ebx和ecx寄存器传递参数handle和buf,然后触发中断向量INT_VECTOR_SYS_CALL,进入内核态并调用sys_call函数。为了保护进程的状态,在进入内核态之前会保存当前进程的寄存器和栈信息。
字符显示驱动程序负责将ASCII码转换为对应的字模数据,并将其写入到显存vram中。显存vram是一个二维数组,每个元素存储一个像素点的RGB颜色值。
显示芯片按照一定的刷新频率逐行扫描显存vram,并通过信号线向液晶显示器发送每个像素点的RGB颜色值,从而在屏幕上显示出文字。
8.4 getchar的实现分析
getchar函数是一个标准库函数,用于从标准输入流(stdin)中读取一个字符,并返回其ASCII码值。它的原型是:
int getchar(void);
getchar函数的实现分析可以从以下几个方面进行:
getchar函数的内部实现通常会调用read系统调用,向内核请求从文件描述符0(即标准输入)中读取一个字节的数据,并将其存放在一个缓冲区中。read系统调用的原型是:
ssize_t read(int fd, void *buf, size_t count);
其中,fd是文件描述符,buf是缓冲区指针,count是请求读取的字节数。read系统调用返回实际读取的字节数,或者在出错时返回-1。
当程序调用getchar函数时,如果标准输入流中没有可用的数据,那么程序就会阻塞等待用户输入。用户输入的字符被键盘驱动程序接收并转换为ASCII码,然后存放在系统的键盘缓冲区中,直到用户按下回车键为止(回车字符也放在缓冲区中)。这个过程涉及到异步异常-键盘中断的处理。当键盘发生中断时,内核会调用键盘中断处理子程序,从键盘控制器中读取按键扫描码,并将其转换为ASCII码,然后保存到系统的键盘缓冲区中。
当用户按下回车键后,getchar函数才开始从标准输入流中每次读取一个字符,并返回其ASCII码值。如果标准输入流已经到达文件结尾(EOF),那么getchar函数会返回-1。同时,getchar函数还会将用户输入的字符回显到标准输出流(stdout)上,以便用户看到自己输入的内容。
如果用户在按下回车键之前输入了多个字符,那么这些字符会保留在系统的键盘缓冲区中,等待后续的getchar函数调用读取。也就是说,后续的getchar函数调用不会等待用户输入,而是直接从缓冲区中读取一个字符,并返回其ASCII码值。直到缓冲区中的字符读完为止,才会再次等待用户输入。
8.5 本章小结
本章主要介绍了Linux系统中的I/O设备管理和Unix I/O接口,以及它们在实现基本的输入输出功能中的作用。窗体顶端
窗体底端
(第8章1分)
结论
- 编写一个输出“Hello, world!”的hello.c源文件
- 使用gcc命令对hello.c进行预处理、编译、汇编和链接,生成可执行程序hello
- 使用./hello命令运行可执行程序hello
- shell进程调用fork()函数创建一个子进程,并复制父进程的地址空间和状态
- 子进程调用execve()函数加载可执行程序到虚拟内存,并覆盖原来的地址空间和状态
- MMU负责将程序中的虚拟地址转换为物理地址,并检查地址是否合法和可访问
- 处理器根据程序中的指令执行逻辑控制流,包括顺序执行、条件分支、循环等
- 程序正常结束或者出现异常时,调用信号处理函数进行处理,并返回相应的退出码
- 进程结束后,操作系统负责释放和回收其占用的资源,包括内存、文件描述符等
计算机系统的设计毫无疑问使非常精妙的,实现这整个系统每个环节环环相扣,缺一不可
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
文件的作用 | 文件名 |
预处理后得到的文本文件 | hello.i |
编译后得到的汇编语言文件 | hello.s |
汇编后得到的可重定位目标文件 | hello.o |
readelf读取hello.o得到的ELF格式信息 | hello.elf |
反汇编hello.o得到的反汇编文件 | hello.asm |
由hello可执行文件生成的.elf文件 | hello1.elf |
反汇编hello可执行文件得到的反汇编文件 | hello1.asm |
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] (31条消息) 哈工大计算机系统Lab4.Tiny Shell_哈工大计算机系统实验4_Castria的博客-CSDN博客
[2] (31条消息) 哈工大计算机系统大作业(2022)_何以牵尘的博客-CSDN博客
[3] (31条消息) 哈工大计统大作业_计算机学院杜鑫_那我就让时光倒流的博客-CSDN博客
(参考文献0分,缺失 -1分)