计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号
班 级
学 生
指 导 教 师 吴锐
计算机科学与技术学院
2022年5月
摘 要
本文通过分析一个简单地hello程序,通过分析其预处理,编译,汇编,链接,进程,内存管理,I/O管理几大模块,即分析了hello的从编译到执行结束输出的过程,又将CSAPP所学的内容串联了起来
关键词:预处理;编译;汇编;链接;进程;内存管理;I/O管理
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
附件
参考文献
P2P:Program to Process
在linux中,hello.c经过预处理,编译,汇编,ld的链接最终成为可执行目标程序hello,在shell中键入启动命令后,shell通过调用fork,产生子进程,产生子进程后shell为hello调用execve,于是hello便从Program(程序项目)变为Process(进程)。
2.O2O:Zero-0 to Zero-0
Process在内存中From Zero to Zero。产生子进程后shell为hello execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流,hello在运行时会经历诸多的异常与信号,以及对存储器的访问也会涉及诸多机制,以及通过中断和IO端口与外设交互,等等。最终,当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构,这便是hello的从无到有再到无的过程(from zero to zero)。
LAPTOP-ILDNSCGQ
Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz 2.30 GHz
vim,gedit,gcc,as,ld,readelf,objdump,edb
8.记录hello的elf的hello.elf。
9.记录hello的objdump结果的hello.objdump
(第1章0.5分)
1.预处理概念:
预处理(或称预编译)是指在进行编译的第一遍扫描(词法扫描和语法分析)之前所作的工作。预处理指令指示在程序正式编译前就由编译器进行的操作,可放在程序中任何位置。
预处理是C语言的一个重要功能,它由预处理程序负责完成。当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。
2.预处理作用:
预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。C语言提供多种预处理功能,主要处理#开始的预编译指令,如宏定义(#define)、文件包含(#include)、条件编译(#ifdef)等。合理使用预处理功能编写的程序便于阅读、修改、移植和调试,也有利于模块化程序设计。
大多数预处理器指令属于下面3种类型:
●宏定义:#define 指令定义一个宏,#undef指令删除一个宏定义。
●文件包含:#include指令导致一个指定文件的内容被包含到程序中。
●条件编译:#if,#ifdef,#ifndef,#elif,#else和#dendif指令可以根据编译器可以测试的条件来将一段文本包含到程序中或排除在程序之外。
在程序所在的文件夹打开终端,输入gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i
图 2.1 使用 gcc -E命令生成 hello.i 文件
然后就会生成选中的这个hello.i文件(如下图):
图2.2生成的hello.i文件
打开得到的hello.i文件,我们直接拉到最后,可以发现hello.c中的程序主体没变:
图 2.3 hello.i 中 main 函数的位置
但是上面却多了几千行,它们主要是预处理器执行宏替换、条件编译以及包含指定的文件。比如这个程序加入的就是stdio.h,stdlib.h和unistd.h文件
下三张图分别就是helloo.i文件中加入stdio.h,stdlib.h,unistd.h三个文件内容的开头部分
图2.4hello.i中stdlib的开头部分
图2.5hello.i中unistd的开头部分
图2.6hello.i中stdio的开头部分
对程序运行前做的一些预处理,比如宏替换,将一些文件的内容加入程序文本中,然后分析了得到的.i文件
(第2章0.5分)
1.编译的概念:
编译器首先要检查代码的规范性,是否有语法错误等,以确定代码的实际要做的工作,再检查无误后,编译器(ccl)见文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
2.编译的作用:
产生汇编语言程序,汇编语言是计算机提供给用户的最快最有效的语言,也是能够利用计算机的所有硬件特性并能够直接控制硬件的唯一语言。但是由于编写和调试汇编语言程序要比高级语言复杂,因此目前其应用不如高级语言广泛。
汇编语言比机器语言的可读性要好,但跟高级语言比较而言,可读性还是较差。不过采用它编写的程序具有存储空间占用少、执行速度快的特点,这些是高级语言所无法取代的。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
在第二章生成.i文件的步骤后继续在终端输入gcc -S hello.i -o hello.s得到hello.s文件如图:
图 3.1 使用 gcc -S命令生成hello.s文件
图 3.2生成的hello.s文件
图3.3hello.s的一些声明
上述以“.”开头的行是指导汇编器和链接器工作的伪指令
指令前的声明如下:
.file |
源文件名 |
.globl |
全局变量 |
.data |
数据段 |
.align |
对齐 |
.type |
指定是对象类型或是函数类型 |
.size |
大小 |
.long |
长整型 |
.section |
节头表 |
.rodata |
只读数据段 |
.string |
字符串 |
.text |
代码段 |
图3.4hello.s的程序主体一部分
.cfi_startproc 用在每个函数的开始,用于初始化一些内部数据结构
3.3.1数据:
图3.5hello.s的局部变量argv首地址
1.局部变量:从上面图片的global可以看到没有全局变量,由cmpl $4, %-20(%rbp)这句指令可以得到有一个局部变量在%-20(%rbp)中,,所以断定main函数的第一个参数argc被存到了%rdi中,又因为%rsi用来存放第二个参数,所以argv的首地址存到了%-32(%rbp)
局部变量i:
图3.6hello.s的局部变量i
根据上图的movl $0, %-4(%rbp)以及在控制转移部分提及的L2处的cmpl $7, %-4(%rbp),%-4(%rbp)中存放的是计数变量i
图3.7hello.s的字符串常量
从上图puts前将.LC0传送到%edi中,再结合hello.s文件前面的声明,.LC0和.LC1的string是字符串常量
3.3.2赋值:
图3.8hello.s的赋值语句
赋值只有一句:movl $0, %-4(%rbp)为计数变量i赋初值0
3.3.3类型转换:
hello.s文件中没有类型转换的汇编实现
3.3.4 sizeof
无
3.3.5算术操作:
加法:
图3.9hello.s的加法操作
对计数变量i在循环体中反复加一
3.3.6逻辑/位操作:
hello.s中体现不出逻辑/位操作
3.3.7关系操作:
一般在条件跳转前由关系操作,下图中两处彩色均为关系操作,分别是将argc参数和4比较以及将计数变量i与7比较,一般比较后都会伴随条件跳转来实现分支或者循环
图3.10 hello.s的cmpl关系操作
3.3.8数组/指针/结构操作
首先,由于main的第二个参数是一个argv数组的首地址,一开始存在%rsi中,跟踪指令会发现:指针由%rsi转移到了-32(%rbp):
图3.11 hello.s的地址操作
argv[]中存储着各个字符串的首地址,访问数组元素 argv[1],argv[2]时,按照起始地址+(下标*8B)计算数据地址,然后取数据
3.3.9控制转移
如下图条件跳转jne指令,如果传入的argc不等于4,则跳转至L6处,L6将输出信息后,结束指令以及如果argc等于4,则在短暂的指令执行后无条件跳转到L2,L2即为函数在输入符合条件的主体部分:
图3.12 hello.s的控制转移
以及下图的条件跳转指令:
图3.13 hello.s的条件跳转
此时%edi存储的是计数变量i,从0开始到7,如果小于等于7,那么跳到L3,L3是一个简单的输出程序,否则跳出循环
3.3.10函数操作
函数的传参:
%rdi/%edi:储存第一个参数
%rsi/%esi:储存第二个参数
%rdx/%edx储存第三个参数
%rcx/%ecx:储存第四个参数
图3.14 hello.s的四个函数
hello调用函数如下:
1.puts函数:
传递数据:第一次printf 将%rdi 设置为“用法:Hello 学号,姓名!”字符串的首地址。
图3.15 hello.s的printf函数
第二次printf 设置%rdi 为“Hello %s %s\n”的首地址,设置%rsi为argv[1],%rdx为argv[2]。
我使用了更具体的优化,发现:
图3.16 hello.s的printf函数的更进一步优化版本
(上图采用了更高一点的优化得到hello.s的另一个版本)
传递控制:第一次printf 因为只有一个字符串参数,所以call puts;第二次printf_chk使用call printf_chk。(用更高的优化等级查看linux的函数得到)
图3.17 printf_chk的参数与返回类型
图3.18 hello.s的exit函数
传递数据:将%edi 设置为1。
控制传递:call exit。
以下是atoi:
图3.19 hello.s的atoi函数
首先argv首地址存在-32(%rbp)中,其次,将该地址存到%rax中,再将其加24,得到了argv[3],再将内存中%rax中的地址所存的字符传入%rdi,调用atoi
当我使用另一种优化等级时,得到的hello.s有一点不同,对此,我对atoi的函数理解更容易一些,分析如下:
图3.20 hello.s的atoi函数在高一点优化版本的体现
图3.21 strtol函数的具体解释
传递数据:将%rdi设置为argv[3]即输入的间隔秒数,%rdx设置为10进制,%rsi设置为从第0个字开始,即将第一个字就转化为数字
传递控制:call atoi。
5.sleep 函数:
传递数据:将%edi设置为%eax(strtol的返回值)。
传递控制:call sleep。
6.getchar函数:
传递控制:call getchar。
本章主要阐述了编译器是如何处理各个数据类型以及各类操作,并结合所给的hello.c程序,生成并查看了hello.s代码,验证了数据、操作等在汇编代码中的实现。
编译器将 hello C语言程序翻译为更加低层的汇编语言程序。
(第3章2分)
1.汇编的概念:
汇编器将hello.s 翻译成机器语言指令,把这些指令打包成叫做可重定位目标程序的格式,并将结果保存在目标.o文件中,.o文件是一个二进制文件,它包含的字节是函数main的指令编码
2.汇编的作用:
产生的程序是机器语言写成的,机器语言是机器能直接识别的程序语言或指令代码,无需经过翻译,每一操作码在计算机内部都有相应的电路来完成它,或指不经翻译即可为机器直接理解和接受的程序语言或指令代码。机器语言使用绝对地址和绝对操作码。不同的计算机都有各自的机器语言,即指令系统。从使用的角度看,机器语言是最低级的语言,具有灵活、直接执行和速度快等特点
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
输入下图的最后一条指令后得到了hello.o文件
图4.1 使用 gcc -c命令生成hello.o文件
图4.2 生成的hello.o文件
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
使用readelf -a hello.o > hello.o.elf命令,获得 hello.o 文件的 ELF 格式。其组成如下:
图4.3 生成hello.o的elf文件
图4.4 hello.o.elf文件的ELF头
图4.5 hello.o.elf文件的节头
重定位条目告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。如图,偏移量是需要被修改的引用的节偏移,符号标识被修改引用应该指向的符号。类型告知链接器如何修改新的引用,加数是一个有符号常数,一些类型的重定位要用它对被修改引用的值做偏移调整。
offset |
需要进行重定向的代码在.text或.data 节中的偏移位置,8 个字节。 |
info |
包括symbol 和type 两部分,其中symbol 占前4 个字节, type 占后4 个字节,symbol 代表重定位到的目标在.symtab中的偏移量,type 代表重定位的类型 |
Addend |
重定位时用于偏移调整的辅助信息 |
Type |
重定位到的目标的类型 |
Name |
重定向到的目标的名称 |
图4.6 hello.o.elf文件的重定位节
符号表.symtab:用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明
图4.7 hello.o.elf文件的符号表
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
使用objdump -d -r hello.o > hello.o.objdump,查看hello.o.objdump文件
1.首先是调用函数时:
图4.8 hello.o.objdump的call指令
图4.9 hello.o.objdump的call指令
在反汇编文件中,call后面直接加地址,比如callq *0x2ed2(%rip)或者callq 401090
2.和分支转移时(控制转移):
与此同时,跳转指令也变成了直接跳到目的地址:jle 40120c
图4.10 hello.o.objdump的跳转指令
3.反汇编文件中使用十六进制,而hello.s文件中使用的是十进制
4.反汇编中有nop空指令,和hlt 指令分别用来占步和使程序停止运行,而hello.s中没有
图4.11 hello.o.objdump的nop和hlt指令
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
机器语言的构成:
机器语言与汇编语言的映射关系:
从机器指令到汇编:指令码会被替代成更易于人类读懂的符号,地址码也会被取代为某个功能的地址,使得其含义显现在符号上而不再隐藏在编码中。
本章主要分析了hello 从hello.s 到hello.o 的汇编过程,查看了hello.o 的elf 格式、使用objdump 得到反汇编代码与hello.s 进行了比较,了解了从汇编语言映射到机器语言需要实现的转换。在这个过程中,生成了重定位条目,为之后的链接中的重定位过程奠定了基础。
(第4章1分)
1.链接的概念:
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。在现代系统中,链接是由叫做链接器的程序自动执行的
2.作用:
链接使代码变得方便和简洁,同时可移植性强,模块化程度比较高。因为链接的过程可以将程序封装成很多的模块,程序员过程中只用写主程序的部分,对于其他的部分有些可以直接调用模块。
作为编译的后一步链接,就是处理当前程序调用其他模块的操作,将该调用的模块中的代码组合到相应的可执行文件中去。
注意:这儿的链接是指从 hello.o 到hello生成过程。
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 使用ld命令生成hello可执行文件
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
使用readelf -a hello > hello.elf命令得到hello.elf文件
图5.2readelf -a hello > hello.elf命令得到hello.elf文件
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
图5.3ELF头
图5.4节头
图5.5程序头
图5.6重定位
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
查看ELF文件的程序头,程序头在为链接器提供运行时的加载内容和提供动态链接的信息,每一个表项提供了各段在虚拟地址空间大小和物理地址空间大小、位置、标志、访问权限和对齐方式
图5.7程序头
从下表可以看出,程序包含 8 个段(其中两个LOAD):
段名称 |
功能 |
PHDR |
保存程序头表 |
INTERP |
指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动态链接器) |
LOAD |
表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串)、程序的目标代码等。 |
DYNAMIC |
保存了由动态链接器使用的信息 |
NOTE |
保存辅助信息 |
GNU_STACK |
权限标志,标志栈是否是可执行的 |
GNU_RELRO |
指定在重定位结束之后那些内存区域是需要设置只读 |
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
使用 objdump -d -r hello > hello.objdump 获得 hello 的反汇编文件。与之前的hello.o的反汇编文件对比,有以下几点的不同:
节名称 |
描述 |
_init |
程序初始化需要执行的代码 |
.plt |
动态链接-过程链接表 |
puts@plt |
puts对应的plt表条目 |
printf@plt |
printf对应的plt表条目 |
getchar@plt |
getchar对应的plt表条目 |
atoi@plt |
atoi对应的plt表条目 |
exit@plt |
exit对应的plt表条目 |
sleep@plt |
sleep对应的plt表条目 |
_fini |
当程序正常终止时需要执行的代码 |
链接过程:
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
程序名称 |
程序地址 |
hello!_start |
0x4010f0 o |
libc-2.27.so!_libc_start_main |
0x7fc16a6d2fc0 o |
-libc-2.27.so!_cxa_atexit |
0x7fc16a6f5e10 o |
-libc-2.27.so!_libc_csu_init |
0x4011c0 o |
hello!_init |
0x4001000 o |
libc-2.27.so!_setjmp |
0x7fc16a6f1cb0 o |
-libc-2.27.so!_sigsetjmp |
0x7fc16a6f1b10 o |
hello!main |
0x401125 o |
hello!puts@plt |
0x401030 o |
hello!exit@plt |
0x401070 o |
*hello!printf@plt |
0x401040 o |
*hello!sleep@plt |
0x401080 o |
*hello!getchar@plt |
0x401050 o |
libc-2.27.so!exit |
0x7fc16a6f5a70 o |
5.7 Hello的动态链接分析
CSAPP:在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
ELF 文件中GOT和PLT的起始地址如下
图5.8GOT和PLT起始地址
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
dl_init之前:
dl_init之后:
链接是将原来的只保存了你写的函数的代码与代码用所用的库函数合并的一个过程。在这个过程中链接器会为每个符号、函数等信息重新分配虚拟内存地址,方法就是用每个.o文件中的重定位节与其它的节想配合,算出正确的地址。同时,将你会用到的库函数加载(复制)到可执行文件中。这些信息一同构成了一个完整的计算机可以运行的文件。链接让我们的程序做到了很好的模块化,我们只需要写我们的主要代码,对于读入、IO等操作,可以直接与封装的模块相链接,这样大大的简化了代码的书写难度。
(第5章1分)
2.进程的作用:进程提供给了应用程序一个关键的抽象:一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器,一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统
shell是用户和Linux之间的接口程序。用户在提示符下输入的每个命令都由shell先解释然后传给Linux内核。
在Linux 和 UNIX系统里可以使用多种不同的shell可以使用。最常用的几种是 Bourne shell (sh), C shell (csh), 和 Korn shell (ksh)。 Bash是 Bourne shell 的扩展。bash 与 Bourne shell 完全向后兼容,并且在 Bourne shell 的基础上增加和增强了很多特性。bash 也包含了很多 C 和 Korn shell 里的优点。bash 有很灵活和强大的编程接口,同时又有很友好的用户界面。
处理流程:
如果不是交互模式,输入一般来自文件。此时,bash使用C语言标准库的stdio来获得输入,然后stdio缓冲文件内容,并将语句逐行交给bash处理;
解析阶段的主要工作为:词法分析和语法解析
词法分析指分析器从Readline或其他输入获取字符行,根据元字符将它们分割成word,并根据上下文环境标记这些word(确定单词的类型)。元字符包括:| & ; ( ) < > space tab
语法解析指解析器和分析器合作,根据各个单词的类型以及它们的位置,判断命令是否合法以及确定命令类型。单词分为很多种:重定向,关键字,赋值,别名,其他;
如果是内置命令则立即执行
否则调用相应的程序为其分配子进程并运行
shell 应该接受键盘输入信号,并对这些信号进行相应处理
在通过向shell输入./hello 后面加上参数后,运行程序时,shell就会利用fork创建一个新的进程,然后在这个新的进程的上下文中运行这个可执行目标文件。创建新进程时shell自行调用fork完成的。
新创建的hello子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库和用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程与父进程有不同的pid。fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0.父进程与子进程是并发运行的独立进程,而且父进程和子进程时并发执行的。
execv函数在当前进程的上下文中加载并运行可执行文件filename,且带参数列表argv和环境变量envp,加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域。如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
逻辑控制流:一系列程序计数器PC 的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
在hello运行过程中,若hello进程不被抢占,则正常执行;若被抢占,则进入内核模式,进行上下文切换,转入用户模式,调度其他进程。
程序在执行sleep函数时,sleep函数首先会调用atoi函数将argv[3]中的字符或字符串转换为整型数字,此时sleep函数调用wait或waitpid函数挂起直到atoi函数执行完成并返回一个数字,此时sleep函数作为父进程回收执行atoi函数的子进程。
此时,在sleep函数获取参数后,sleep系统调用显式地请求让调用进程休眠,调度器抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。sleep的倒计时结束后,控制会回到hello进程中。程序调用getchar()时,内核可以执行上下文切换,将控制转移到其他进程。getchar()的数据传输结束之后,引发一个中断信号,控制回到hello进程中。
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
图6.1正常状态
中途没有任何操作,无异常,无信号传递
向程序传递了SIGSTOP信号,同时发生了中断异常,对应的行为是:停止直到下一个SIGCONT
异常:时钟中断:不处理
图6.2ctrl+z
程序停止,然后分别输入ps和pstree列出了当前进程的快照(进程瞬间的状态)其中的参数:PID: 运行着的命令(CMD)的进程编号TTY: 命令所运行的位置(终端)TIME: 运行着的该命令所占用的CPU处理时间CMD: 该进程所运行的命令
pstree则将所有进程以树状图的形式呈现
然后输入fg使程序继续执行,此时程序收到SIGCONT信号,由于该进程停止了,所以继续运行
图6.3fg
图6.4kill
进程收到信号:SIGKILL、杀死进程异常:程序中断,不处理
进程收到的信号:SIGINT(shell使进程终止),异常:时钟中断:不处理
图6.5 ctrl+z
图6.6乱按
进程收到的信号:无
收到的异常:时钟中断:不处理,系统调用:调用write函数打印字符
I/O中断:将键盘输入的字符读入缓冲区,缺页故障(可能):缺页处理程序
本章简述了进程、shell的概念与作用,并描述了fork和execve的过程,最后给出了执行hello时遇到的异常和信号的处理方式。
(第6章1分)
逻辑地址:逻辑地址是指由程序产生的与段相关的偏移地址部分,由段选择符+偏移地址构成。hello.o里面的相对偏移地址是逻辑地址。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:即为上述的线性地址,虚拟地址很明显是线性(连续)的。在之前hello链接的时候,各种函数的地址,比如main函数的地址是0x000000000032718,实际上是虚拟地址,这是将物理地址映射成了虚拟地址,方便加载、链接等。
物理地址:物理地址是CPU外部地址总线上的寻址信号,是地址变换的最终结果,一个物理地址始终对应实际内存中的一个存储单元。Hello实际存储的空间(内存)使用的是物理地址。
1.基本原理:
在段式存储管理中,将程序的地址空间划分为若干个段(segment),这样每个进程有一个二维的地址空间。在段式存储管理系统中,为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
在为某个段分配物理内存时,可以采用首先适配法、下次适配法、最佳适配法等方法。
在回收某个段所占用的空间时,要注意将收回的空间与其相邻的空间合并。
段式存储管理也需要硬件支持,实现逻辑地址到物理地址的映射。
程序通过分段划分为多个模块,如代码段、数据段、共享段:
–可以分别编写和编译
–可以针对不同类型的段采取不同的保护
–可以按段为单位来进行共享,包括通过动态链接进行代码共享
这样做的优点是:可以分别编写和编译源程序的一个文件,并且可以针对不同类型的段采取不同的保护,也可以按段为单位来进行共享。
总的来说,段式存储管理的优点是:没有内碎片,外碎片可以通过内存紧缩来消除;便于实现内存共享。缺点与页式存储管理的缺点相同,进程必须全部装入内存。
从概念上讲,虚拟内存被组织为磁盘上存储的N个连续字节大小的单元的阵列。每个字节都有一个唯一的虚拟地址,用作数组的索引。磁盘上阵列的内容缓存在主内存中。与存储器层次结构中的任何其他高速缓存一样,磁盘上的数据被划分为块,这些块充当磁盘和主存储器之间的传输单元,这些块被称为页。每个虚拟页的大小为P = 2 ^ p字节。同样,物理内存被划分为物理页,大小也为P= 2 ^ p字节。
通过段式管理,我们得到了线性地址(虚拟地址VA)。虚拟地址被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),相应的物理地址也被分为两个部分:PPN(物理页号)和PPO(物理页偏移量)。由于虚拟内存与物理内存的页大小相同,因此VPO与PPO相同。页式管理的关键在于:VPNà PPN。
内存中常驻页表,描述了从虚拟页à物理页的映射关系。根据VPN可以在页表中找到相应的页表项(PTE),从中取出PPN(此处先不考虑多级页表)。处理流程如下:
若PTE的有效位为1,页命中,则获取到PPN,与PPO组成物理地址。
若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中,引发一个缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面。再返回到原来的进程,再次调用导致缺页的指令。这次页命中,则获取到PPN,与PPO组成物理地址。
图7.1页式管理
Linux由自己的虚拟内存系统,如下图所示:
图7.2linux的虚拟内存
Linux 将虚拟内存组织成了段的集合,不是段内的虚拟内存不存在,于是系统不会记录它们。内核为hello进程维护段的任务结构即上图的task_struct,条目mm描述了虚拟内存的当前状态,对应指向了mm_struct,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct 的链表,一个链表条目对应一个段,所以链表相连指出了 hello 进程虚拟内存中的所有段。
TLB:页表常驻内存,内存管理单元在翻译地址时需要去访问内存但是一般较低级内存访问开销很大,为减少开销,内存管理单元中包含了一个关于PTE的小缓存,称为翻译后备缓冲器(TLB)。
多级页表:将页表分为多个层级,以减少内存要求。
CPU将虚拟页号(VPN)进一步划分为TLBT(TLB标记)和TLBI(TLB索引)。
图7.3虚拟地址的划分
先访问TLBI中的某组,遍历该组中的所有行,如果有一行的tag等于TLBT,且有效位为1,,则缓存命中,PPN即在该行存储,否则缓存不命中,需要到页表中找到被请求的块替换原TLB表项中的块,然后再次执行指令。
图7.4 多级页表的访问
多级页表的访问:缓存不命中后,VPN被解释成从低位到高位的等长的4段,从高地址开始,第一段VPN作为第一级页表的索引,用以确定第二级页表的基址;第二段VPN作为第二级页表的索引,用以确定第三级页表的基址;第三段VPN作为第三级页表的索引,用以确定第四级页表的基址;第四段VPN作为第四级页表的索引,若该位置的有效位为1,则第四级页表中的该表项存储的是所需要的PPN的值。
如下图:一个36位的虚拟地址被分割成4个片,每个片时9位。CR3寄存器包含L1页表的物理地址。VPN1有一个到L1 PTE的偏移量,找到这个PTE以后又会包含到L2页表的基础地址;VPN2包含一个到L2PTE的偏移量,找到这个PTE以后又会包含到L3页表的基础地址;VPN3包含一个到L3PTE的偏移量,找到这个PTE以后又会包含到L4页表的基础地址;VPN4包含一个到L4PTE的偏移量,找到这个PTE以后就是相应的PPN(物理页号)。
图7.4CSAPP
CPU将物理地址解释为三部分:CT(缓存标记),CI(组索引),CO(块偏移)。如下图
图7.5 虚拟地址的进一步划分
首先根据组索引在缓存中寻找组中所有行的tag是否等于CT,若等于且有效位为1,那么缓存命中,根据块偏移即CO读取块中对应的数据,否则缓存不命中。
缓存不命中时,系统则向下一级缓存中查找数据,根据L1 Cache à,L2 Cache,L3Cache ,主存,磁盘的顺序依次寻找,找到数据之后,开始进行行替换。若该组中有空行,那就将数据缓存至这个空行,并设置tag和有效位,否则根据具体情况使用策略来决定驱逐哪一行,如最不常使用策略(LFU)和最近最少使用策略(LRU)。LFU策略会替换过去某个时间段内访问次数最少的那一行,LRU策略会替换最后一次访问时间最久远的那一行。
当前进程调用fork函数时,内核会为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新流程创建虚拟内存,它创建了当前流程的mm_struct,区域结构和页表的原样副本。它将两个进程中的每个页面标记为只读,并将两个进程中的每个区域结构标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存完全相同。当这两个进程的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
execve函数在当前进程的上下文中加载并执行可执行目标文件hello 中的程序。加载并运行hello 需要执行以下步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3.映射共享区域。hello 程序与共享对象libc.so 链接,libc.so 是动态链接到这个程序中,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
缺页故障是一种常见的故障,当指令引用一个虚拟地址VA,在内存管理单元中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。
图7.6 进程对信号的响应
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,就将其写回,然后换入新的页面并更新页表。缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送 虚拟地址VA 到 内存管理单元MMU,这次 MMU 就能正常翻译虚拟地址 VA 了。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(如下图)。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
图7.8堆区域
1.隐式空闲链表:
图7.9 隐式空闲链表
空闲块通过头部中的大小字段隐含地连接着。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。
(1)放置策略:首次适配、下一次适配、最佳适配。
首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
(2)合并策略:立即合并、推迟合并。
立即合并就是在每次一个块被释放时,就合并所有的相邻块;推迟合并就是等到某个稍晚的时候再合并空闲块。
带边界标记的合并:
在每个块的结尾添加一个脚部,分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,从而使得对前面块的合并能够在常数时间之内进行。
2.显式空闲链表(如下图)
图7.10 显式空闲链表
每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。使用双向链表使首次适配的时间减少到空闲块数量的线性时间。
空闲链表中块的排序策略:一种是用后进先出,将新释放的块放置在链表的开始处,使用后进先出的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
分离存储:维护多个空闲链表,每个链表中的块有大致相等的大小。将所有可能的块大小分成一些等价类,也叫做大小类。
分离存储的方法:简单分离存储和分离适配。
本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,以intel Core7为例介绍了VA 到PA 的变换、物理内存访问,还介绍了hello 进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
设备的模型化:IO设备被模型化为文件
设备管理:输入和输出都被当做对应文件的读和写来执行,这种将设备映射为文件的方式,是Linux内核引出一个简单,低级的应用接口,称为Unix I/O。
设备的模型化:文件
设备管理:unix io接口
Unix I/O接口:
Unix I/O函数:
返回:若成功则为新文件描述符,若出错为-1。
返回:若成功则为新文件描述符,若出错为-1。
返回:若成功则为新文件描述符,若出错为-1。
首先查看printf函数的函数体:
对该句分析:va_list arg = (va_list)((char*)(&fmt) + 4)
va_list的定义:typedef char *va_list,是字符型指针。
(char*)(&fmt) + 4) 表示的是...中的第一个参数(函数参数从右往左依次压栈)。
继续分析vsprintf函数:1. int vsprintf(char *buf, const char *fmt, va_list args)
2. {
3. char* p;
4. char tmp[256];
5. va_list p_next_arg = args;
6.
7. for (p=buf;*fmt;fmt++) {
8. if (*fmt != '%') {
9. *p++ = *fmt;
10. continue;
11. }
12.
13. fmt++;
14.
15. switch (*fmt) {
16. case 'x':
17. itoa(tmp, *((int*)p_next_arg));
18. strcpy(p, tmp);
19. p_next_arg += 4;
20. p += strlen(tmp);
21. break;
22. case 's':
23. break;
24. default:
25. break;
26. }
27. }
28.
29. return (p - buf);
30. }
vsprintf的作用是格式化。它的参数格式字符串fnt用来确定输出格式。用格式字符串对个数变化的参数进行格式化,产生格式化输出,并返回要打印的字符串的长度。语句i = vsprintf(buf, fmt, arg);得到要打印出来的字符串长度。
write(buf, i)将buf中的i个元素写到终端。
接下来分析write函数:
这里是给几个寄存器传递了几个参数,其中:int INT_VECTOR_SYS_CALL表示对syscall的系统调用,查看syscall的实现:
syscall 将字符串中的字节“Hello 120L022303于昊冬\n”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的 ASCII 码。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
1.int getchar(void)
2. {
3. static char buf[BUFSIZ];
4. static char *bb = buf;
5. static int n = 0;
6. if(n == 0)
7. {
8. n = read(0, buf, BUFSIZ);
9. bb = buf;
10. }
11. return(--n >= 0)?(unsigned char) *bb++ : EOF;
12. }
getchar函数调用read函数,将整个缓冲区都读到buf里,当n将缓冲区的长度赋值给n,若n>0,则返回buf的第一个元素。
异步异常-键盘中断的处理:键盘接口得到一个与按键相对应的键盘扫描码,同时产生中断请求来抢占目前进程运行键盘中断子程序,中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,了解了 printf 函数和 getchar 函数的实现。
(第8章1分)
预处理阶段:hello.c引用的所有外部的库和宏定义展开合并到一个 hello.i 的文本文件中。
编译阶段:hello.i文件编译成为汇编文件hello.s,形成汇编指令。
汇编阶段:将hello.s 文件汇编成为可重定位目标文件hello.o,这个文件是二进制文件,但仍需要重定位。
链接阶段:将hello.o与可重定位目标文件和动态链接库链接成为可执行程序hello,此时的hello可以被shell执行。
Shell:在 shell程序中输入./hello 120L022303 于昊冬 3,shell解析命令行,调用 fork 为其创建一个子进程,调用execve函数,启动加载器,将hello加载进内存,此时的hello成为了一个进程。
执行阶段:
在该进程被调度时,CPU 为其分配时间片,在一个时间片中,hello 享有CPU全部资源,PC寄存器一步一步地更新,CPU不断地取指,执行自己的控制逻辑流。
访存:内存管理单元(MMU)将逻辑地址转化成物理地址,通过三级高速缓存访问物理内存/磁盘中的数据。
动态申请内存:调用malloc 向动态内存分配器申请堆中的内存。
信号处理:进程时刻等待着信号,如果运行途中键入ctr-c ctr-z 等则调用shell 的信号处理函数分别进行停止、挂起等操作。
终止并被回收:shell父进程等待并回收子进程,内核删除为这个进程创建的所有数据结构(task_struct等)。
图10.1 hello的“诞生”
(结论0分,缺失 -1分,根据内容酌情加分)
文件名称 |
文件作用 |
hello.c |
hello程序c语言源文件 |
hello.i |
hello.c预处理生成的文本文件 |
hello.s |
hello.i编译得到的汇编文件 |
hello.o |
hello.s汇编得到的可重定位目标文件 |
hello |
hello.o与动态链接库链接得到的可执行目标文件 |
hello.o.objdmp |
使用objdump反汇编hello.o得到的反汇编文件 |
hello.objdmp |
使用objdump反汇编hello得到的反汇编文件 |
hello.o.elf |
hello.o的ELF格式 |
hello.elf |
hello的ELF格式 |
(附件0分,缺失 -1分)
[1] 大卫R.奥哈拉伦,兰德尔E.布莱恩特. 深入理解计算机系统[M]. 机械工业出版社.2018.4.
[2] 几种地址的含义:Linux0.11内核--几种地址(逻辑地址、线性地址、物理地址)的含义_YongXMan的博客-CSDN博客
[3] printf 函数实现的深入剖析:[转]printf 函数实现的深入剖析 - Pianistx - 博客园