计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 工科试验班(医学类1)
学 号 2022112315
班 级 2252002
学 生 王莘然
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
本文通过介绍hello程序在电脑中的编译、汇编、链接过程,以及hello程序的进程管理、存储管理、I/O管理,详细地叙述了hello程序的一生。整个过程体现了计算机系统的深奥,展现了整个系统操作时的整体性,借此串联起本课程各章节的知识体系。
关键词:hello程序;编译;汇编;链接;进程管理;存储管理;I/O管理
目 录
第1章 概述
1.1 Hello简介
hello.c程序在cpp预处理器中被转换成hello.i文件,通过编译生成hello.s文件,经汇编生成hello.o文件,最后由链接器输出hello的可执行目标程序。
P2P指的是即From Program to Process。通过编译器中输入代码得到的hello.c是一个Program。hello.c经过上述操作最终变成可执行文件hello,最后在shell中进行解析,最后变成Process的过程。
020指的是From Zero to Zero。从程序hello.c经过上述过程得到ELF格式的文件hello。在bash中执行完毕后,会由shell的父进程回收hello,结束运行之后,相应多余的注释和空间会被内核释放,最终回归0的过程称为020。
1.2 环境与工具
硬件环境:处理器11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz 2.30 GHz;机带 RAM 16.0 GB (15.7 GB 可用);系统类型64位操作系统, 基于x64 的处理器
软件环境:Windows10;Vmware 11;Ubuntu20.04
1.3 中间结果
hello.c:hello程序的源代码(文本文件)
hello.i:经过预处理修改了的源程序(文本文件)
hello.s:编译器翻译成的汇编程序(文本文件)
hello.o:汇编器翻译汇编程序为二进制机器语言指令,这个文件是可重定位的目标程序
hello:调用printf.o函数,经过链接器后得到的可执行文件
hello.elf:hello.o的elf格式文件
hello1.elf:hello的elf格式文件
hello.asm:hello的反汇编文件
hello.o.asm:hello.o的反汇编文件
1.4 本章小结
对hello程序的P2P和020过程过程进行了简要的介绍,说明了作业时用到的实验环境与工具,列出了中间产生的文件,为下文展开详细介绍做铺垫。
第2章 预处理
2.1 预处理的概念与作用
预处理是进行源文件编译的第一个阶段,预处理不对源文件进行分析,而是对源文件进行文本操作:删除源文件中的注释,在源文件中插入包含文件的内容,定义符号并替换源文件中的符号等。通过这些处理,可以得到编译器实际进行分析的文本。
预处理器执行预处理的功能,而编译器往往将预处理器作为编译的第一个步骤,但是用户也可以单独调用预处理器。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
程序主体保持不变,成功地插入了头文件,并删除了源代码中的所有注释
2.4 本章小结
在本章节我们了解到了预处理的概念及其作用,知道了预处理的指令操作,在ubuntu中进行预处理操作得到了hello.i文件,为后续操作提供了准备。
第3章 编译
3.1 编译的概念与作用
编译是指将用C语言书写的源程序,翻译成等价的机器语言格式目标程序的翻译程序。解释程序是一种语言处理程序,在词法、语法和语义分析方面与编译程序的工作原理基本相同,但在运行用户程序时,它直接执行源程序或源程序的内部形式或中间代码,把对用户友好的语言文本编译成对机器友好的特定指令直接执行,而不是执行时一条一条通过解释器解析执行,从而提高执行的效率。
3.2 在Ubuntu下编译的命令
gcc -S hello.c -o hello.s
得到的文件如下图
3.3 Hello的编译结果解析
3.3.1声明
1. .file "hello.c:编译器在处理这段代码时所使用的源文件是"hello.c"。
2. .section .rodata: 这表示接下来的内容将放在只读数据段(read-only data section)中,通常用于存放程序中的常量数据。
3. .align 8:数据在内存中的对齐方式是按照8字节对齐的。
4. .LC0:这是一个标签,用于存储字符串。
5. .string "\347\224\250\346\263\225:Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \346\211\213\346\234\272\345\217\267\347\247\222\346\225\260\357\274\201":字符串常量,使用了转义字符表示特定的Unicode字符序列。
6. .LC1: 类似于.LC0,是另一个标签,用来存储字符串。
7. .string "Hello %s %s %s\n":这是另一个字符串常量,表示要输出的格式化字符串。
8. .globl main:说明main函数是全局可见的,可以被其他文件访问。
- .type main, @function:说明main是一个函数。
下面将按照数据、赋值、类型转换、算术操作、逻辑/位操作、关系操作、数组/指针/结构操作、控制转移、函数操作进行分类:
3.3.2数据类型
%edi、%esi、%rax、%rcx、%rdx、%rsi、%rdi、%eax、%rbp是寄存器
-20(%rbp)、-32(%rbp)、-4(%rbp)是基于栈指针%rbp的偏移量
3.3.3赋值:
movl %edi, -20(%rbp)将%edi中的值赋给-20(%rbp)
movq %rsi, -32(%rbp)将%rsi中的值赋给-32(%rbp)
3.3.4算术操作:
subq $32, %rsp减去栈指针的值
addq $24, %rax、addq $16, %rax、addq $8, %rax对%rax进行加法操作
addl $1, -4(%rbp)对-4(%rbp)中的值进行加法操作
3.3.5逻辑/位操作:
je .L2根据比较结果跳转到.L2标签处
jmp .L3无条件跳转到.L3标签处
3.3.6关系操作:
cmpl $5, -20(%rbp)比较-20(%rbp)中的值和5的大小
3.3.7数组/指针/结构操作:
movq (%rax), %rcx、movq (%rax), %rdx、movq (%rax), %rax从%rax指向的地址读取数据到%rcx、%rdx、%rax中
3.3.8控制转移:
je .L2、jmp .L3、jle .L4根据条件跳转到不同的标签处
3.3.9函数操作:
call puts@PLT、call exit@PLT、call printf@PLT.......
3.4 本章小结
在本章节我们了解到编译的概念及其作用,知道了编译的指令操作,在ubuntu中进行编译操作得到了hello.s文件,并分析了编译器是怎么处理C语言的各个数据类型以及各类操作的,为后续操作提供了准备。
第4章 汇编
4.1 汇编的概念与作用
汇编是指汇编器将以.s结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在.o目标文件中的过程。可以将汇编语言翻译为机器语言,并将相关指令以可重定位目标程序格式保存在.o文件中。
4.2 在Ubuntu下汇编的命令
gcc hello.s -c -o hello.o
4.3 可重定位目标elf格式
readelf -a hello.o > hello.elf
得到文件如下图
得到的hello文件的ELF头信息
7f 45 4c 46是ELF文件的标识符,用于识别文件是否为ELF格式文件;接下来的两个字节02 01代表了ELF文件的类别,其中01表示32位,02表示x86-64;紧接着的字节01表示字节序。后面的字节00 00 00 00 00 00 00 00是保留字段,没有特定含义,通常被填充为0。节头信息如下:
含义如下:.text 代码部分
.rodata 只读数据
.data 已初始化的全局和静态变量
.bss 未初始化的或初始化为0的全局和静态变量
.symtab 符号表
.rel.text 文本部分
.rel.data 全局变量的重定位数据
.debug 符号表中表示调试的部分
.line 原始C源程序中的行号和.text节中机器指令之间的映射
.strtab 字符串表名称
重定位节信息如下,包括信息、类型、符号值、符号名称+加数:
符号表信息如下
4.4 Hello.o的结果解析
反汇编结果如下
用指令objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的hello.s进行对照分析:在hello.s文件中,call后面紧随着函数名,hello.o中,call后面要跟着指令的地址。在数据格式上,hello.s中是十进制数字,而hello.o为十六进制。分支转移函数上,hello.s显示了跳转位置,hello.o指明了函数的偏移量位置。
4.5 本章小结
在本章节我们了解到汇编的概念及其作用,知道了汇编与反汇编的指令操作,在ubuntu中进行汇编与反汇编操作得到了hello.o和hello.elf文件,并对结果进行了分析,为后续操作提供了准备。
第5章 链接
5.1 链接的概念与作用
链接通常指的是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以在编译时、加载时或运行时执行。
作用:链接器使得分离编译成为可能,使得程序员不需要将一个大的应用程序组织成一个巨大的源文件,而是可以把它分解成更小的、更好管理的模块,这些模块可以独立地修改和编译,最终由链接器链接成为一个可执行文件。
5.2 在Ubuntu下链接的命令
gcc hello.o -o hello
5.3 可执行目标文件hello的格式
readelf -a hello > hello1.elf
得到的hello文件的ELF头信息
节头部表信息,每个节头都包含了每一个节的名称、大小、类型、地址(旗标、链接)、偏移量(信息、对齐)等信息。
程序头的相关信息
段节相关信息,包括标记、类型等
重定位节的相关信息
地址相关信息
5.4 hello的虚拟地址空间
由此处可知加载程序的程序头LOAD是从地址0x400000开始的
查看虚拟地址的情况
5.5 链接的重定位过程分析
hello 是一个可执行文件,可以直接在操作系统中运行。
hello.o 是一个目标文件,它包含了编译后的机器代码和符号表信息,但还没有经过链接器的处理,因此无法直接执行。
两者主要区别在于是否经过了整个链接的过程。
链接过程是指在将目标文件转换为可执行文件时,需要经过链接过程。链接过程主要包括两个步骤:符号解析和重定位。符号解析是指链接器会解析目标文件中使用的符号,即变量名、函数名等,找到它们对应的地址或符号表项。重定位指的是链接器会将解析得到的符号引用替换为实际的地址,以及调整代码中的跳转目标地址,以确保程序能够正确执行。hello.o中的重定位项目描述了目标文件中的代码和数据与其他目标文件或库文件中的符号之间的关系。这些重定位项目包括了需要在链接时进行处理的符号引用以及它们的位置信息。
在链接过程中,链接器会根据hello.o中的重定位项目,将其中引用的符号与其他目标文件或库文件中的定义进行匹配,并进行相应的地址替换和调整。这样,最终生成的可执行文件hello中的代码和数据就可以正确地与其他模块进行连接和执行。
5.6 hello的执行流程
调用的子程序名和程序地址如下:
init 0x401000
.plt 0x401020
puts@plt 0x401090
strtol@plt 0x4010a0
__printf_chk@plt 0x4010b0
exit@plt 0x4010c0
sleep@plt 0x4010d0
getc@plt 0x4010e0
_start 0x4010f0
_dl_relocate_static_pie 0x401120
deregister_tm_clones 0x401130
register_tm_colnes 0x401160
__do_global_dtors_aux 0x4011a0
frame_dummy 0x4011d0
main 0x4011d6
__libc_csu_init 0x401260
__libc_csu_fini 0x4012d0
__fini 0x4012d8
5.7 Hello的动态链接分析
动态链接的实现方式通常涉及以下几个步骤:
编译:编译源代码时,将动态链接库的调用信息嵌入到可执行文件中,而不是将实际的库函数代码复制到可执行文件中。
加载:当程序启动时,操作系统会将程序需要的动态链接库加载到内存中,并建立相应的映射关系。
符号解析:在运行时,程序需要调用动态链接库中的函数或者引用动态链接库中的变量。当程序进行这些调用时,操作系统会根据程序中的符号引用去动态链接库中进行符号解析,找到相应的函数或变量地址。
重定位:符号解析完成,操作系统会根据解析得到的地址信息,对程序进行重定位,确保程序能够正确地调用动态链接库中的函数或者引用动态链接库中的变量。
运行:程序运行时,可以调用动态链接库中的函数或者引用动态链接库中的变量,实现所需的功能。
.plt和.plt.got在elf文件中如下
使用edb查看,在动态链接加载前后hello重定位是不一样的:
dl_init前:
dl_init后:
5.8 本章小结
本章节了解链接过程得到了hello.o文件,通过链接器查看是如何转变为hello可运行文件的。同时还查看了hello的虚拟地址空间。并且分析了反汇编指令和hello.o的不同之处。最后,对hello的动态链接进行了分析。为后续操作提供了准备。
第6章 hello进程管理
6.1 进程的概念与作用
一个执行程序中的实例,提供给我们一种错觉:我们的程序好像是系统中当前运行的唯一程序,我们的程序独占使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序的代码和数据好像是系统中内存唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个交互型应用级程序,代表用户运行其他程序。如Windows下的命令行解释器等。shell是信号处理的代表,负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等。通过shell来跟核心沟通,以让核心达到我们所想要达到的工作。壳程序的功能只是提供使用者操作系统的一个接口,因此这个壳程序需要可以调用其他软件。可以通过shell调用应用程序,来运行所需的工作。
bash是一种流行的Unix/Linux命令行解释器,也是许多操作系统中默认的shell。它提供了丰富的命令和功能,使得用户能够编写强大的脚本来自动化各种任务。
下面是处理流程:
1.读取输入:用户在终端输入命令,shell会读取并解释这些输入。
2.解析命令:shell会对输入的命令进行解析,识别命令、参数、操作符等部分。
3.执行命令:根据解析后的命令,shell会执行相应的操作:涉及执行内置命令、调用系统命令或执行脚本文件等,整个过程涉及到命令解析、执行和输出结果的交互过程,以实现用户与系统的交互。
4.输出结果:执行命令后,shell会将结果输出到终端,用户可以看到命令的执行结果。
6.3 Hello的fork进程创建过程
1.父进程在某个地方调用fork系统,该系统调用会复制父进程的内存空间,包括代码段、数据段、堆栈等。
2.内核会创建一个新的进程,这个新进程是父进程的副本,但有自己独立的进程 ID。
3.子进程会继承父进程的内存空间的副本,但是子进程的内存空间是独立的,对其中的修改不会影响父进程。
4.fork调用在父进程中返回子进程的ID,而在子进程中返回0,这样可以通过返回值来区分父进程和子进程。
5.在fork调用之后,父进程和子进程会并发执行,它们各自独立地执行不同的代码段,但共享相同的程序代码和数据。通过fork创建的子进程是父进程的副本,但是它们是独立的进程,各自有自己的地址空间,可以独立运行和执行不同的操作。
6.4 Hello的execve过程
execve是一个系统调用,用于执行一个新的程序。当调用execve时,当前进程的内存空间会被新程序所替代,新程序开始执行,取代了原来的进程:
1.execve首先会打开并读取要执行的新程序文件,该文件通常是一个可执行文件。
2.execve将新程序的代码等信息加载到当前进程的内存空间中。
3.execve将新程序的命令行参数和环境变量设置为当前进程的参数和环境变量。
4.最后,execve调用内核中的系统调用,将控制权转交给新程序,开始执行新程序的代码。
6.5 Hello的进程执行
1.进程上下文信息:当操作系统进行进程切换时,需要保存当前进程的上下文信息,并切换到下一个进程的上下文信息。这样可以确保进程在切换后能够继续执行。
2.进程时间片:进程在CPU上执行时会被分配一个时间片,即一段时间内允许进程占用CPU。当时间片用尽或者进程主动放弃CPU时,操作系统会进行进程调度,切换到另一个进程执行。
3.进程调度过程:进程调度是操作系统根据一定的调度算法从就绪队列中选择下一个要执行的进程的过程。调度算法的选择会影响系统的性能和响应时间。
4.用户态与核心态转换:进程在执行时会涉及到用户态和核心态之间的转换。当进程执行普通的用户态代码时,处于用户态,不超过规定的访问空间;当进程执行需要特权级别的操作时,需要切换到核心态。这种切换是通过系统调用或者异常来实现的。
6.6 hello的异常与信号处理
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fult)和终止(abort)。
1.中断
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序。
I/O设备,例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断这个异常号标识了引起中断的设备。
在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指。结果是程序继续执行,就好像没有发生过中断一样。
剩下的异常类型是同步发生的,是执行当前指令的结果。我们把这类指令叫做故障指令。
2.陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
从程序员的角度来看,系统调用和普通的函数调用是一样的。然而,它们的实现非常不同,普通的函数运行在用户模式中,用户模式限制了函数可以执行的指令的类型,而且它们日能访问与调用函数相同的栈。系统调用运行在内核模式中,内核模式允许系统调用执行特权指令,并访问定义在内核中的栈。
3.故障
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort。
4.终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。
运行实例:
乱按一通
Ctrl+Z停止作业
Ctrl+C回收进程
输入ps jobs fg kill后查看结果
6.7本章小结
本章节介绍了进程和shell的概念及其作用,处理流程,了解了fork进程的创建,execve进程的过程以及进程执行,了解到了进程的异常和信号处理方法,并进行了实操,为之后的流程做准备。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:由段标志符与段内偏移量组成,是一种相对位置。例如hello.s中的相对偏移地址。
线性地址:如果地址空间的整数是连续的,那么即为线性地址空间。段地址+偏移地址得到线性地址,线性地址空间是非负整数地址的有序集合。
虚拟地址:虚拟地址与线性地址是相似的;虚拟地址空间是一个拥有N=2^n个地址的有序集合。
物理地址:每个物理地址与系统物理内存的一个字节相对应,对应物理内存的M个字节。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理通过对每个内存段进行描述,并通过段选择子和段描述符的配合,实现了逻辑地址到线性地址的转换。这种机制可以提供更加灵活的内存管理和访问控制。在段式管理中,每个内存段都由一个段描述符来描述,段描述符包含了段的起始地址、段的长度、访问权限等信息。具体的逻辑地址到线性地址的转换过程如下:
1.CPU中产生的逻辑地址包含了两部分信息,即段标志符和偏移量。段选择子用于选择一个段描述符,偏移量表示在选定的段内的偏移量。
2.CPU使用段选择子从全局描述符表或本地描述符表中找到对应的段描述符。
3.从段描述符中获取段的基址和段的限长。段基址表示段在内存中的起始地址,段限长表示段的长度。
4.将段基址与偏移量相加,得到线性地址。如果线性地址超出了段的限长范围,则会触发段访问异常。
5.CPU 会根据段描述符中的访问权限信息(如读写权限、执行权限等)对访问的合法性进行检查。如果访问权限不符合要求,会触发相应的异常。
6.在得到线性地址后,如果启用了分页机制,CPU 还需要通过页表将线性地址转换为物理地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块这些块作为磁盘和主存(较高层)之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页的大小固定的块来处理这个问题。每个虚拟页的大小为P=2^p字节。类似地,物理内存被分割为物理页,大小也为P字节。
在任意时刻,虚拟页面的集合都分为三个不相交的子集:
未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。
缓存的:当前已缓存在物理内存中的已分配页。
未缓存的:未缓存在物理内存中的已分配页。
7.4 TLB与四级页表支持下的VA到PA的变换
在一个典型的操作系统中,虚拟地址(Virtual Address,VA)到物理地址(Physical Address,PA)的变换是通过页表实现的。在支持四级页表的系统中,虚拟地址通常被划分为多个级别,每个级别对应一个页表,从而实现更灵活的地址映射。
TLB(Translation Lookaside Buffer)是一个高速缓存,用于加速虚拟地址到物理地址的转换过程。TLB存储了最近的一些虚拟地址到物理地址的映射,当CPU访问内存时,会先在TLB中查找对应的物理地址,如果找到了则直接使用,否则需要进行页表查找。
下面是一个简单的描述虚拟地址到物理地址的变换过程:
1. CPU生成一个虚拟地址,并根据虚拟地址的位数划分为不同的部分,每部分对应一个页表级别。
2.从最高级别的页表开始,CPU根据虚拟地址的对应部分找到对应的页表项,如果使用TLB,CPU会先在TLB中查找对应的物理地址,如果TLB未命中,则需要访问内存中的页表。
3.CPU根据找到的页表项中的物理页框号以及偏移量,计算出物理地址,最后访问物理地址对应的内存单元。
在支持四级页表的系统中,上述过程会根据页表的级别进行多次查找和计算,直到最终得到物理地址。TLB的作用在于缓存最近的一些虚拟地址到物理地址的映射,减少对内存的访问次数,提高地址转换的速度。
7.5 三级Cache支持下的物理内存访问
现代计算机系统中通常包含三级缓存:L1缓存、L2缓存和L3缓存。这些缓存层次结构的目的是提供快速访问数据的能力,以减少对慢速主存的访问次数。下面是访问的流程:
1.当一个处理器修改了缓存中的数据时,必须确保其他处理器的缓存中的相同数据是最新的。这通常通过缓存一致性协议来实现,如MESI协议。
2.当处理器需要访问物理内存时,它会将逻辑地址转换为物理地址。这个过程涉及到页表,用于将逻辑地址映射到物理地址。
3.如果所需数据在某个缓存层次中已经存在(缓存命中),处理器将直接从缓存中获取数据,而无需访问主存。如果所需数据不在任何缓存层次中(缓存未命中),处理器将从主存中获取数据,并将数据加载到适当的缓存层次中,以便将来的访问可以从缓存中获取。当缓存已满且需要为新数据腾出空间时,需要使用缓存替换策略。常见的替换策略包括最近最少使用和随机替换。当然,尽管缓存可以加速数据访问,但仍然存在内存访问延迟。当数据不在任何缓存中时,处理器必须从主存中获取数据,这会导致较长的访问延迟。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的m_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面因此,也就为每个进程保持了私有地址空间的抽象概念:
7.7 hello进程execve时的内存映射
execve函数内存映射加载并运行需要以下几个步骤:
1.删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss或者栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射在文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在文件中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域:如果程序与共享对象、链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
缺页故障指的是当程序访问某个虚拟内存页面时,该页面不在物理内存中,需要通过页面调度算法从磁盘或其他存储设备中加载到内存中的过程。缺页中断处理是操作系统对于缺页故障的响应和处理过程。当发生缺页故障时,CPU会触发一个异常,称为缺页中断,通知操作系统发生了缺页错误。操作系统需要通过一系列的步骤来处理这个中断,以将所需的页面加载到内存中,然后重新执行导致缺页故障的指令:
1.当CPU检测到发生了缺页故障时,会触发一个异常,进入内核态,操作系统会将控制权转移到相应的缺页中断处理程序,同时中断处理程序首先需要确定引起缺页的虚拟地址。通常,这个信息会包含在中断的上下文中,因此操作系统可以轻松地获取。
2.操作系统会检查引起缺页的虚拟地址所对应的页面是否在物理内存中。如果在磁盘上,操作系统需要执行页面调度算法选择一个页面将其调入内存。
3.如果内存中没有空闲页面,操作系统需要选择一个页面进行替换,以便给新的页面腾出空间。通常会使用页面置换算法来决定选择哪个页面进行替换。
4.如果页面不在物理内存中,操作系统需要从磁盘或其他存储设备中加载页面到内存中的空闲页面。
5.加载页面后,操作系统需要更新页表,将新加载的页面映射到正确的虚拟地址上,一旦缺页中断处理完成,操作系统会恢复进程的执行,重新执行导致缺页故障的指令,这次指令会成功访问到所需的页面。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存趋于,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块:
显式分配器,要求应用显式地释放任何已分配的块。
隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。
7.10本章小结
本章介绍了hello程序的存储管理:四种地址空间的概念、逻辑地址到线性地址的段式管理,线性地址到物理地址的页式管理,介绍了TLB与四级页表支持下VA到PA的转换,三级Cache支持下的物理内存访问,fork函数和execve函数时的内存映射,发生缺页故障后的处理方法,动态储存的分配管理方法,为后续的操作提供准备。
第8章 hello的I/O管理
8.1 Linux的IO设备管理方法
设备的模型化:文件:
每个Linux文件都有一个类型来表明它在系统中的角色:
文件类型:
1.普通文件:包含任意数据的文件。文本文件和二进制文件,文本文件是只含有ASCII或Unicode字符的普通文件;二进制文件是所有其他的文件。
2.目录:包含一组链接的文件,其中每个链接都将一个文件名映射到一个文件,这个文件可能是另一个目录。
3.套接字:用来与另一个进程进行跨网络通信的文件
其他的文件类型还有命名通道、符号链接、字符和块设备
设备管理:unix io接口:
打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有提作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住个描述符。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量 STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。
类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它的内存资源。
8.2 简述Unix IO接口及其函数
Unix I/O接口提供了一种机制,允许程序与系统资源(如文件、设备、网络套接字等)进行输入和输出操作。这些接口定义了一组函数,允许程序员在Unix及类Unix系统上进行标准的输入输出操作:
1.open():用于打开文件或创建新文件,并返回文件描述符,用于后续对文件的读写操作。
2.read()、write():执行输入和输出,读写文件。
3.rio_readn()、rio_writen():应用程序可以在内存和文件之间直接传送数据。
4.lseek():移动文件指针到指定的位置。
5.fcntl():对文件描述符进行各种控制操作,如修改文件状态标志、设置文件描述符的属性等。
6.ioctl():提供对设备的输入输出控制。
7.pipe():创建一个管道,用于实现进程间通信。
8.select() / poll() / epoll():用于多路复用I/O,允许程序同时监视多个文件描述符,以确定是否有数据可读或可写。
8.3 printf的实现分析
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);
}
这段代码的工作原理:
int vsprintf(char *buf, const char *fmt, va_list args):这个函数接受一个目标缓冲区buf,一个格式化字符串fmt,和一个可变参数列表args。
char* p:定义一个指针变量p,用于遍历目标缓冲区并存储输出。
char tmp[256]:定义一个临时数组tmp,用于存储格式化后的数据。
va_list p_next_arg = args:初始化一个可变参数列表,用于逐个访问传入的可变参数。
for (p=buf; *fmt; fmt++):遍历格式化字符串fmt,直到遇到字符串结尾符号\0。
if (*fmt != '%'):如果当前字符不是格式化符号%,直接将其拷贝到目标缓冲区中。
continue:跳过当前循环迭代,继续下一次迭代。
fmt++:移动指针到格式化符号的下一个字符。
switch (*fmt):根据格式化符号进行不同的处理。
case‘x’:如果是%x,表示输出一个整数的十六进制表示。
itoa(tmp, *((int*)p_next_arg)):将下一个可变参数转换为字符串形式,存储到临时数组 tmp 中。
strcpy(p, tmp):将临时数组tmp中的内容拷贝到目标缓冲区中。
p_next_arg += 4:由于是整数类型,下一个参数地址需要增加4个字节的偏移。
p += strlen(tmp):移动目标缓冲区指针到新的位置。
8.4 getchar的实现分析
键盘中断处理:操作系统通常会设置一个键盘中断处理程序。当用户按下键盘时,会触发一个硬件中断,操作系统的中断服务程序会被调用。中断服务程序会读取键盘控制器的状态,并获取键盘扫描码。扫描码可以被转换成相应的ASCII码,这样操作系统就能理解用户按下了哪个键。
键盘缓冲区:操作系统会有一个键盘缓冲区,用于存储从键盘中读取的字符。当键盘中断服务程序获取到一个字符后,它会将该字符存储到键盘缓冲区中。
getchar的实现:getchar函数通常会调用系统调用来读取键盘输入。在Unix/Linux系统中,通常会调用read系统调用来从标准输入读取字符。read系统调用会阻塞程序,直到有数据可读取为止。getchar会反复调用read系统调用,直到接收到回车符,表示用户输入结束。getchar会将每个接收到的字符从键盘缓冲区中读取,并返回给调用者。
返回字符:当getchar函数从read系统调用中接收到一个字符后,它会将该字符返回给调用者。如果getchar函数接收到回车符,则会结束,并返回该字符。
8.5本章小结
本章节介绍了Linux的I/O设备管理方法,并简述了Unix I/O接口及其函数,分析了printf的实现和getchar的实现。
结论
hello程序的一生如下:
编写程序hello.c -> 预处理生成hello.i文件,去除注释,打开调用的库 -> 编译:生成汇编文件hello.s -> 汇编生成可重定位目标文件hello.o -> 链接生成可执行目标程序hello -> 终端运行,调用fork进程和execve进程,并进行异常分析和处理 -> hello顺序执行逻辑控制流 -> VA到PA的变换 -> 物理内存的访问 -> fork和execve内存映射 -> 异常信号的处理 -> 动态内存的分配管理 -> 在Unix I/O下进行交互操作 -> shell父进程回收子进程
我的学习感悟如下:通过学习计算机系统,我更深入地理解了计算机底层的工作原理,包括处理器、内存、存储器、输入输出等组件是如何相互作用的。理解了各个组件之间是如何相互作用和配合,共同完成程序的执行过程,我深深感受到了计算机世界的奥妙所在,学习过程受益匪浅。
附件
hello.c:hello程序的源代码(文本文件)
hello.i:经过预处理修改了的源程序(文本文件)
hello.s:编译器翻译成的汇编程序(文本文件)
hello.o:汇编器翻译汇编程序为二进制机器语言指令,这个文件是可重定位的目标程序
hello:调用printf.o函数,经过链接器后得到的可执行文件
hello.elf:hello.o的elf格式文件
hello1.elf:hello的elf格式文件
hello.asm:hello的反汇编文件
hello.o.asm:hello.o的反汇编文件
参考文献
[1] 《深入理解计算机系统》Randal E.Bryant David R.O’Hallaron 机械工业出版社