计算机系统大作业

时间:2024-03-26 20:08:40

程序人生-Hello’s P2P

**

摘要

**

本文遍历了hello.c在Linux下生命周期,借助Linux下系列开发工具,通过对其预处理、编译、汇编等过程的分步解读及对比来学习各个过程在Linux下实现机制及原因。同时通过对hello在Shell中的动态链接、进程运行、内存管理、I/O管理等过程的探索来更深层次的理解Linux系统下的动态链接机制、存储层次结构、异常控制流、虚拟内存及UnixI/O等相关内容。旨在将课本知识与实例结合学习,更加深入地理解计算机系统的课程内容。

**

关键词

**
操作系统;编译;链接;虚拟内存;异常控制流;

1.1Hello简介

1.1.1 From Program to Process
首先hello.c通过I/O设备如键盘等经过总线存入主存。然后GCC编译器驱动程序读取源程序文件hello.c,通过预处理器cpp变成hello.i(修改了的源程序)然后通过编译器ccl变成hello.s(汇编程序),然后通过汇编器as变成hello.o(可重定位目标程序),这时的hello.o就不是之前的文本了,而是对机器友好的二进制代码了。最后再通过链接器ld与标准C库进行链接,最终变成hello(可执行的二进制目标程序)此时的hello就是一个Program了。然后在shell(Bash)里面输入字符串“./hello”后,shell程序将字符逐一读入寄存器,然后再放入到内存里面去,然后shell调用fork函数创建一个新运行的子进程,这个子进程是父进程shell的一个复制,然后子进程通过execve系统调用启动加载器。加载器删除子进程现有的虚拟内存段,然后使用mmap函数创建新的内存区域,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk), 新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。然后程序从内存读取指令字节,然后再从寄存器读入最多两个数,然后在执行阶段算术/逻辑单元要么执行指令指明的操作,计算内存引用的有效地址要么增加或者减少栈指针。然后在流水线化的系统中,待执行的程序被分解成几个阶段,每个阶段完成指令执行的一部分。最后变成一个Process运行在内存中。
1.1.2 From Zero-0 to Zero-0
首先说明这里的020应该指的是程序(Process)在内存中From Zero to Zero。一开始hello先是执行了上面所述的过程,然后在程序执行结束以后,该进程会保持在一种已终止的状态中,直到该进程被其父进程也就是shell进程回收然后退出,shell会再次变成hello执行之前的状态,也就是说又变成Zero了。

1.2 环境与工具

硬件环境:Intel Core i7-6700HQ x64CPU,16G RAM,256G SSD +1T HDD;
软件环境:Ubuntu18.04.1 LTS;
开发与调试工具:vim,gcc,as,ld,edb,readelf,HexEdit;

1.3 中间结果

hello.c:源代码
hello.i:hello.c预处理生成的文本文件。
hello.s:hello.i经过编译器翻译成的文本文件hello.s,含汇编语言程序。
hello.o:hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件
hello.elf:hello.o的ELF格式。
hello_o_asm.txt:hello.o反汇编生成的代码。
hello:经过hello.o链接生成的可执行目标文件。
hello_out.elf:hello的ELF格式。
hello_out_asm.txt:hello反汇编生成的代码。

1.4 本章小结

本章主要是漫游式地了解hello在系统中生命周期,对每个部分需要有系统地了解;同时本章列出本次实验的基本信息。

第2章 预处理

2.1 预处理的概念与作用

概念:预处理器cpp根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件。
主要功能如下:
1、将源文件中用#include形式声明的文件复制到新的程序中。比如hello.c第6-8行中的#include<stdio.h> 等命令告诉预处理器读取系统头文件stdio.h unistd.h stdlib.h 的内容,并把它直接插入到程序文本中。
2、用实际值替换用#define定义的字符串。
3、根据#if后面的条件决定需要编译的代码。

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i
计算机系统大作业

2.3 Hello的预处理结果解析

计算机系统大作业
计算机系统大作业

在这之前出现的是stdio.h unistd.h stdlib.h的依次展开,以stdio.h的展开为例,cpp到默认的环境变量下寻找stdio.h,打开/usr/include/stdio.h 发现其中依然使用了#define语句,cpp对此递归展开,所以最终.i程序中是没有#define的。而且发现其中使用了大量的#ifdef #ifndef的语句,cpp会对条件值进行判断来决定是否执行包含其中的逻辑。其他类似。

2.4 本章小结

.c文件中包含有头文件也就是有外部文件的,还有一些程序员需要但是对于程序执行没有任何帮助的宏定义以注释,和一些程序员需要的条件编译和完善程序文本文件等操作都需要通过预处理来实现。预处理可以使得程序在后序的操作中不受阻碍,是非常重要的步骤。

第3章 编译

3.1 编译的概念与作用

概念: 编译是利用编译程序从预处理文本文件产生汇编程序(文本)的过程。主要包含五个阶段:词法分析;语法分析;语义检查、中间代码生成、目标代码生成。
作用:编译作用主要是将文本文件hello.i翻译成文本文件hello.s,并在出现语法错误时给出提示信息,执行过程主要从其中三个阶段进行分析:

  1. 词法分析。词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序;
  2. 语法分析。语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位;
  3. 目标代码生成。目标代码生成器把语法分析后或优化后的中间代码经汇编程序汇编生成汇编语言代码,成为可执行的机器语言代码。
    注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序

3.2 在Ubuntu下编译的命令

gcc -S -o hello.s hello.i
计算机系统大作业
应截图,展示编译过程!

3.3 Hello的编译结果解析

3.3.1 数据
有变量int sleepsecs,编译器将其编译成
.type sleepsecs, @object
.size sleepsecs, 4
3.3.2 赋值
赋值语句sleepsecs=2.5编译器将其编译成
sleepsecs:
.long 2
.section .rodata
赋值语句i=0编译器将其编译成
movl $0, -4(%rbp)
3.3.3 类型转换(显示或隐式)
由于sleepsecs是int型的而2.5是float类型的,这就有一个隐式的类型转换,编译器将2.5隐式地转换成了2存入sleepsecs。
3.3.4 算术操作
编译器将i++编译成
addl $1, -4(%rbp)
3.3.5 关系操作
编译器将i<10编译成
cmpl $9, -4(%rbp)
jle .L4
将argc!=3编译成
cmpl $3, -20(%rbp)
je .L2
3.3.6 数组/指针/结构操作
printf函数里面的一系列对指针和对数组的操作编译器编译为:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
3.3.7 控制转移
编译器将if,for等控制转移语句都使用了cmp来比较然后使用了条件跳转指令来跳转。编译器将if(argc!=3)编译成:
cmpl $3, -20(%rbp)
je .L2
将for循环里面的比较和转移编译成:
cmpl $9, -4(%rbp)
jle .L4
3.3.8 函数操作
编译器将printf(“Usage: Hello 学号 姓名!\n”);编译为:
movl $.LC0, %edi
call puts
将printf(“Hello %s %s\n”,argv[1],argv[2]);编译为:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
movl $.LC1, %edi
movl $0, %eax
call printf
将sleep(sleepsecs);编译为:
movl sleepsecs(%rip), %eax
movl %eax, $edi
call sleep

此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。

3.4 本章小结

本章系统阐述了编译器将预处理文本文件hello.i翻译为文本文件hello.s的具体操作,主要就汇编语言伪指令、数据类型、汇编语言操作、控制转移,函数操作、类型转换六方面针对hello.s中各部分做出相应的解释说明。

第4章 汇编

4.1 汇编的概念与作用

概念:把汇编语言翻译成机器语言的过程称为汇编。
作用:汇编器(as)将hello.s翻译成机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在二进制目标文件hello.o中。

注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。

4.2 在Ubuntu下汇编的命令

as hello.s -o hello.o
计算机系统大作业
计算机系统大作业
应截图,展示汇编过程!

4.3 可重定位目标elf格式

计算机系统大作业
计算机系统大作业
计算机系统大作业
计算机系统大作业
计算机系统大作业
下面以.L1的重定位为例阐述之后的重定位过程:链接器根据info信息向.symtab节中查询链接目标的符号,由info.symbol=0x05,可以发现重定位目标链接到.rodata的.L1,设重定位条目为r,根据图4.5知r的构造为:
r.offset=0x18, r.symbol=.rodata, r.type=R_X86_64_PC32, r.addend=-4,
重定位一个使用32位PC相对地址的引用。计算重定位目标地址的算法如下(设需要重定位的.text节中的位置为src,设重定位的目的位置dst):

   refptr = s +r.offset 

(1)refaddr = ADDR(s) + r.offset
(2)*refptr = (unsigned) (ADDR(r.symbol) + r.addend-refaddr)(3)
其中
(1)指向src的指针
(2)计算src的运行时地址,
(3)中,ADDR(r.symbol)计算dst的运行时地址,在本例中,ADDR(r.symbol)获得的是dst的运行时地址,因为需要设置的是绝对地址,即dst与下一条指令之间的地址之差,所以需要加上r.addend=-4。
对于其他符号的重定位过程,情况类似。
.rela.eh_frame : eh_frame节的重定位信息。
.symtab:符号表,用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。

4.4 Hello.o的结果解析

机器语言指的是二进制的机器指令集合,而机器指令是由操作码和操作数构成的。汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上,汇编指令是机器指令便于记忆的书写格式。
在对比两个文件后,汇编器在汇编hello.s时:
为每条语句加上了具体的地址,全局变量和常量都被安排到了具体的地址里面。
操作数在hello.s里面都是十进制,在到hello.o里面的机器级程序时都是十六进制。
跳转语句jx&jxx原来对应的符号都变成了相对偏移地址。
函数调用时原来的函数名字也被替换成了函数的相对偏移地址。
计算机系统大作业
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。

4.5 本章小结

汇编器将汇编语言转化成机器语言,机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合。它是计算机的设计者通过计算机的硬件结构赋予计算机的操作功能。机器语言具有灵活、直接执行和速度快等特点。 不同型号的计算机其机器语言是不相通的,按着一种计算机的机器指令编制的程序,不能在另一种计算机上执行。
一条指令就是机器语言的一个语句,它是一组有意义的二进制代码,指令的基本格式如,操作码字段和地址码字段,其中操作码指明了指令的操作性质及功能,地址码则给出了操作数或操作数的地址。
用机器语言编写程序,编程人员要首先熟记所用计算机的全部指令代码和代码的涵义。手编程序时,程序员得自己处理每条指令和每一数据的存储分配和输入输出,还得记住编程过程中每步所使用的工作单元处在何种状态。这是一件十分繁琐的工作。编写程序花费的时间往往是实际运行时间的几十倍或几百倍。而且,编出的程序全是些0和1的指令代码,直观性差,还容易出错。除了计算机生产厂家的专业人员外,绝大多数的程序员已经不再去学习机器语言了。但是作为最基础的语言我们还是要稍作了解,目的是对计算机系统的执行方式进行了解,有助于我们编写出质量更高的代码。

第5章 链接

5.1 链接的概念与作用

链接本质:合并相同的“节”
作用:目标代码不能直接执行,要想将目标代码变成可执行程序,还需要进行链接操作。才会生成真正可以执行的可执行程序。链接操作最重要的步骤就是将函数库中相应的代码组合到目标文件中。

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
计算机系统大作业
计算机系统大作业
计算机系统大作业

使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件

5.3 可执行目标文件hello的格式

使用readelf -a hello > hello.elf 命令生成hello程序的ELF格式文件。

在ELF格式文件中,Section Headers对hello中所有的节信息进行了声明,其中包括大小Size以及在程序中的偏移量Offset,因此根据Section Headers中的信息我们就可以用HexEdit定位各个节所占的区间(起始位置,大小)。其中Address是程序被载入到虚拟地址的起始地址。
计算机系统大作业
计算机系统大作业
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

5.4 hello的虚拟地址空间

查看ELF格式文件中的Program Headers,程序头表在执行的时候被使用,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。在下面可以看出,程序包含8个段:

1、PHDR保存程序头表。
2、INTERP指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动态链接器)。
3、LOAD表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量4、数据(如字符串)、程序的目标代码等。
5、DYNAMIC保存了由动态链接器使用的信息。
6、NOTE保存辅助信息。
7、GNU_STACK:权限标志,标志栈是否是可执行的。
8、GNU_RELRO:指定在重定位结束之后那些内存区域是需要设置只读。
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
5.5 链接的重定位过程分析
hello相对于hello.o有如下不同:
1、hello.o中的相对偏移地址到了hello中变成了虚拟内存地址。
2、hello中相对hello.o增加了许多的外部链接来的函数。
3、hello相对hello.o多了很多的节类似于.init,.plt等。
4、hello.o中跳转以及函数调用的地址在hello中都被更换成了虚拟内存地址。
重定位:链接器在完成符号解析以后,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。然后就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时的地址。在hello到hello.o中,首先是重定位节和符号定义,链接器将所有输入到hello中相同类型的节合并为同一类型的新的聚合节。例如,来自所有的输入模块的.data节被全部合并成一个节,这个节成为hello的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每一个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。然后是重定位节中的符号引用,链接器会修改hello中的代码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。

5.6 hello的执行流程

加载程序 ld-2.23.so_dl_start
ld-2.23.so!_dl_init
LinkAddress!_start
ld-2.23.so!_libc_start_main
ld-2.23.so!_cxa_atexit
LinkAddress!_libc_csu.init
ld-2.23.so!_setjmp
运行 LinkAddress!main
程序终止 ld-2.23.so!exit

加载程序 ld-2.23.so_dl_start
ld-2.23.so!_dl_init
LinkAddress!_start
ld-2.23.so!_libc_start_main
ld-2.23.so!_cxa_atexit
LinkAddress!_libc_csu.init
ld-2.23.so!_setjmp
运行 LinkAddress!main
程序终止 ld-2.23.so!exit
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。

5.7 Hello的动态链接分析

在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。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条目。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。

5.8 本章小结

本章了解了链接的概念作用,分析可执行文件hello的ELF格式及其虚拟地址空间,同时通过实例分析了重定位过程、加载以及运行时函数调用顺序以及动态链接过程,深入理解链接和重定位的过程。

第6章 hello进程管理

6.1 进程的概念与作用

概念:进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量、以及打开文件描述符的集合。
作用:进程给应用程序提供的关键抽象有两种:
a) 一个独立的逻辑控制流,提供一个假象,程序独占地使用处理器。
b) 一个私有的地址空间,提供一个假象,程序在独占地使用系统内存。

6.2 简述壳Shell-bash的作用与处理流程

shell作为UNIX的一个重要组成部分,是它的外壳,也是用户于UNIX系统交互作用界面。Shell是一个命令解释程序,也是一种程序设计语言。
1.读入命令行、注册相应的信号处理程序、初始化进程组。
2. 通过paraseline函数解释命令行,如果是内置命令则直接执行,否则阻塞信号后创建相应子进程,在子进程中解除阻塞,将子进程单独设置为一个进程组,在新的进程组中执行子进程。父进程中增加作业后解除阻塞。如果是前台作业则等待其变为非前台程序,如果是后台程序则打印作业信息。

6.3 Hello的fork进程创建过程

首先了解进程的创建过程:父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时。子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的id。

fork后调用一次返回两次,在父进程中fork会返回子进程的PID,在子进程中fork会返回0;父进程与子进程是并发运行的独立进程。内核能够以任何方式交替执行他们逻辑控制流中的指令。

6.4 Hello的execve过程

execve 函数加载并运行可执行目标文件filename, 且带参数列表argv 和环境变量列表envp 。只有当出现错误时,例如找不到filename, execve 才会返回到调用程序。所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回。

6.5 Hello的进程执行

Linux 系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当shell 运行一个程序时,父shell 进程生成一个子进程,它是父进程的一个复制。子进程通过execve 系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk), 新的代码和数据段袚初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

6.6 hello的异常与信号处理

6.6.1 异常种类
hello执行过程中会出现的异常种类有:
1、中断:SIGSTP:挂起程序
2、终止:SIGINT:终止程序
6.6.2命令的运行
计算机系统大作业
计算机系统大作业
计算机系统大作业
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。

6.7本章小结

本章从进程的角度分别描述了hello子进程fork和execve过程,并针对execve过程中虚拟内存映像以及栈组织结构等作出说明。同时了解了逻辑控制流中内核的调度及上下文切换等机制。阐述了Shell和Bash运行的处理流程以及hello执行过程中可能引发的异常和信号处理。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址空间是由段地址和偏移地址构成的。
例如:23:8048000 段寄存器(CS等16位):偏移地址(16/32/64);
实模式下:逻辑地址CS:EA —>物理地址CS*16+EA;
保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址, 段地址+偏移地址=线性地址。
线性空间地址为非负整数地址的有序集合,例如{0,1,2,3…}。
虚拟地址空间为N = 2n 个虚拟地址的集合,例如{0,1,2,3,….,N-1}。
物理地址空间为M = 2m 个物理地址的集合,例如{0,1,2,3,….,M-1}。物理地址是真实的物理内存的地址。
Intel采用段页式存储管理(通过MMU)实现:
·段式管理:逻辑地址—>线性地址==虚拟地址;
·页式管理:虚拟地址—>物理地址。
以hello中的puts调用为例:mov $0x400714,%edi callq 4004a0,$0x400714为puts输出字符串逻辑地址中的偏移地址,需要经过段地址到线性地址的转换变为虚拟地址,然后通过MMU转换为物理地址,才能找到对应物理内存。
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。

7.2 Intel逻辑地址到线性地址的变换-段式管理

最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。
分段功能在实模式和保护模式下有所不同。
实模式,即不设防,也就是说逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
在保护模式下,线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下32位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。描述符表中的一个条目描述一个段。
Base:基地址,32位线性地址指向段的开始。Limit:段界限,段的大小。 DPL:描述符的特权级0(内核模式)-3(用户模式)。
所有的段描述符被保存在两个表中:全局描述符表GDT和局部描述符表LDT。gdtr寄存器指向GDT表基址。
在保护模式下,分段机制就可以描述为:通过解析段寄存器中的段选择符在段描述符表中根据Index选择目标描述符条目Segment Descriptor,从目标描述符中提取出目标段的基地址Base address,最后加上偏移量offset共同构成线性地址Linear Address。
当CPU位于32位模式时,内存4GB,寄存器和指令都可以寻址整个线性地址空间,所以这时候不再需要使用基地址,将基地址设置为0,此时逻辑地址=描述符=线性地址,Intel的文档中将其称为扁平模型(flat model),现代的x86系统内核使用的是基本扁平模型,等价于转换地址时关闭了分段功能。在CPU 64位模式中强制使用扁平的线性空间。逻辑地址与线性地址就合二为一了。

7.3 Hello的线性地址到物理地址的变换-页式管理

线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2 ^ 20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。
1.分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。
2.每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中
3.每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位)
依据以下步骤进行转换:
1.从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器)。
2.根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
3.根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址。
4.将页的起始地址与线性地址中最后12位相加,得到最终我们想要的物理地址。

7.4 TLB与四级页表支持下的VA到PA的变换

36位v*n 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。v*n 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。v*n 2 提供到一个L2 PTE 的偏移量,以此类推。

7.5 三级Cache支持下的物理内存访问

前提:只讨论L1 Cache的寻址细节,L2与L3Cache原理相同。L1 Cache是8路64组相联。块大小为64B。
解析前提条件:因为共64组,所以需要6bit CI进行组寻址,因为共有8路,因为块大小为64B所以需要6bit CO表示数据偏移位置,因为VA共52bit,所以CT共40bit。
在上一步中我们已经获得了物理地址VA,使用CI(后六位再后六位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。
如果没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。

7.6 hello进程fork时的内存映射

当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

7.7 hello进程execve时的内存映射

execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。

7.8 缺页故障与缺页中断处理

缺页故障是一种常见的故障,当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。

分配器将堆视为一组不同大小的块(block) 的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
基本方法:这里指的基本方法应该是在合并块的时候使用到的方法,有最佳适配和第二次适配还有首次适配方法,首次适配就是指的是第一次遇到的就直接适配分配,第二次顾名思义就是第二次适配上的,最佳适配就是搜索完以后最佳的方案,当然这种的会在搜索速度上大有降低。
策略:这里的策略指的就是显式的链表的方式分配还是隐式的标签引脚的方式分配还是分离适配,带边界标签的隐式空闲链表分配器允许在常数时间内进行对前面块的合并。这种思想是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。显式空间链表就是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果找到了一个,那么就(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。
Printf会调用malloc,请简述动态内存管理的基本方法与策略。

7.10本章小结

本章从Linux存储器的地址空间起,阐述了Intel的段式管理和页式管理机制,以及TLB与多级页表支持下的VA到PA的转换,同时对cache支持下的物理内存访问做了说明。针对内存映射及管理,简述了hello的fork和execve内存映射,了解了缺页故障与缺页中断处理程序,对动态分配管理做了系统阐述。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

本章从Linux存储器的地址空间起,阐述了Intel的段式管理和页式管理机制,以及TLB与多级页表支持下的VA到PA的转换,同时对cache支持下的物理内存访问做了说明。针对内存映射及管理,简述了hello的fork和execve内存映射,了解了缺页故障与缺页中断处理程序,对动态分配管理做了系统阐述。
设备的模型化:文件
设备管理:unix io接口

8.2 简述Unix IO接口及其函数

Unix I/O接口统一操作:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O函数:
int open(char* filename,int flags,mode_t mode) ,进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。
ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。

8.3 printf的实现分析

前提:printf和vsprintf代码是windows下的。
查看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;
}

首先arg获得第二个不定长参数,即输出的时候格式化串对应的值。
查看vsprintf代码:

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':     //只处理%x一种情况

         		itoa(tmp, *((int*)p_next_arg)); //将输入参数值转化为字符串保存在tmp

           strcpy(p, tmp);  //将tmp字符串复制到p处
           p_next_arg += 4; //下一个参数值地址
           p += strlen(tmp); //放下一个参数值的地址
           break;
           case 's': break;
            default:  break;
        }
    }
    return (p - buf);   //返回最后生成的字符串的长度
}

则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:

write:
    mov eax, _NR_write
    mov ebx, [esp + 4]
    mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL

在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall,查看syscall的实现:

sys_call:
     call save
     push dword [p_proc_ready]
     sti
     push ecx
     push ebx
     call [sys_call_table + eax * 4]
     add esp, 4 * 3
     mov [esi + EAXREG - P_STACKBASE], eax
     cli
     ret

syscall将字符串中的字节“Hello 1170300825 lidaxin”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是我们的打印字符串“Hello 1170300825 lidaxin”就显示在了屏幕上。
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章系统的了解了Unix I/O,通过LinuxI/O设备管理方法以及Unix I/O接口及函数了解系统级I/O的底层实现机制。通过对printf和getchar函数的底层解析加深对Unix I/O以及异常中断等的了解。

结论

hello程序 终于 完成了它 艰辛 的一生。hello的一生大事记如下:

编写,通过editor将代码键入hello.c
预处理,将hello.c调用的所有外部的库展开合并到一个hello.i文件中
编译,将hello.i编译成为汇编文件hello.s
汇编,将hello.s会变成为可重定位目标文件hello.o
链接,将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello
运行:在shell中输入./hello 1170300825 lidaxin
创建子进程:shell进程调用fork为其创建子进程
运行程序:shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数。
执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流
访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存。
信号:如果运行途中键入ctr-c ctr-z则调用shell的信号处理函数分别停止、挂起。
结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

附件

文件名称 文件作用
hello.i 预处理之后文本文件
hello.s 编译之后的汇编文件
hello.o 编译之后的可重定位目标执行
hello 链接之后的可执行目标文件
hello2.c 测试程序代码
hello2 测试程序
hello.objdmp Hello的反汇编代码
hello.elf Hellode ELF格式
hmp.txt 存放临时数据

列出所有的中间产物的文件名,并予以说明起作用。

参考文献

为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.