计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机学院
学 号 120L020427
班 级 2003004
学 生 易焯平
指 导 教 师 史先俊
计算机科学与技术学院
2022年5月
摘 要
几乎全世界的程序员都将HelloWorld作为打开未知的编程语言大门的钥匙,当他们笨拙地在代码编辑器中敲出一句或者几句代码,小心翼翼地点击集成开发环境中的运行按钮,喜出望外地看到了控制台中那个稀松平常的字符串,HelloWorld也将渐渐地从他们的视线中消失。可是,当他们不断遇到各种bug和底层问题,慢慢成为更强大的程序员,他们总有一天会意识到——回到起点,HelloWorld程序蕴含着无数计算机系统机制设计者的思想精华。
在编译源文件的过程中,gcc通过调用cpp/cc1/as/ld,将C语言源文件进行预处理、编译、汇编、链接,最终形成可执行目标文件hello,由存储器保存在磁盘中。运行进程时,操作系统为其分配虚拟地址空间,提供异常控制流等强大的工具,Unix I/O为其提供与程序员和系统文件交互的方式。本文通过分析Hello程序从代码编辑器到运行进程的过程,对计算机系统编译源文件、运行进程等机制进行较深入的分析和介绍。
**关键词:**计算机系统;操作系统;进程加载; C语言底层实现;CSAPP;
**
**
目 录
通过对汇编代码进行汇编,生成机器代码的可重定位文件,然后使用readelf进行阅读对ELF文件有了更深入的了解。再对反汇编文件和原汇编语言文件进行对比发现了机器代码和汇编代码的关系第5章 链接
第1章 概述
1.1 Hello简介
1.1.1 P2P(From Program to Process)过程
hello.c 是使用c语言编写的源程序文件,驱动程序(gcc)首先运行C预处理器(cpp),它将C的源程序hello.c翻译成一个ASCII码的中间文件hello.i:
cpp [other arguments] hello.c hello.i
然后,驱动程序运行C编译器(ccl),它将hello.i翻译成一个ASCII汇编语言文件hello.s
gcc -S hello.i -o hello.s
然后,驱动程序运行汇编器(as),它将hello.s翻译成一个可重定位目标文件(relocatable object file)hello.o
as [other argument] -o hello.o hello.s
最后,它运行链接器程序ld,将hello.o和一些必要的系统目标文件组合起来,创建一个可执行目标文件(executable object file)prog:
Ld -o prog [system object files and args] /tmp/hello.o
图1-1 P2P过程
1.1.2 O2O(From Zero-0 to Zero-0)过程
要运行可执行文件prog,我们可以在Linux shell的命令行上输入它的名字:
Linux> ./prog
Shell调用操作系统中一个叫加载器(loader)的函数,它将可执行文件prog中的代码和数据赋值到内存,然后将控制转移到这个程序的开头。操作系统为程序分配虚拟空间并且映射到物理内存空间。随后CPU为它分配逻辑控制流。随后进程终止,shell回收进程,操作系统释放虚拟空间。所以进程从0到了0。
1.2 环境与工具
1.2.1 硬件环境
处理器 AMD Ryzen 7 4800H with Radeon Graphics 2.90 GHz
机带 RAM 16.0 GB (15.4 GB 可用)
1.2.2 软件环境
Windows10 64位;Vmware 16.0;Ubuntu 20.04 LTS 64位/优麒麟 64位
1.2.3 开发工具
Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
1.hello.c:c语言源程序文件
2.hello.i:经过预处理的文件,进行了宏替换以及将#include的内容添加进去。
3.hello.s:经过编译生成的汇编文件。
4.hello.o:汇编生成的可重定位的文件。
5.hello:链接生成的可执行文件。
6.helloobjdump:将hello反汇编生成的文件。
7.hellooobjdump:将hello.o反汇编生成的文件。
8.helloo.elf:hello的elf文件。
9.hellooo.elf:hello.o的elf文件。
1.4 本章小结
本章主要是对hello程序从0到0,以及p2p的过程简介。主要介绍了预处理、编译、汇编、链接的过程。以及进程在执行时shell和操作系统的行为。从一个大的全局层面来概括一个程序的出生到结束。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。
预处理是C语言的一个重要功能,它由预处理程序负责完成。当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。
预处理中会展开以#起始的行,试图解释为预处理指令。包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
2.1.2 预处理的作用
C语言提供多种预处理功能,主要有三个方面的内容:
2.2在Ubuntu下预处理的命令
命令:cpp [other arguments] hello.c hello.i
图2-2 预处理命令和生成的文件
2.3 Hello的预处理结果解析
hello.c源文件中含有以下三个库:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
预处理后将以上头文件内容复制进来形成hello.i文件,大大增大了代码的尺寸。
图2-3 形成的hello.i文件
2.4 本章小结
本章对预处理做了一个简单的介绍,通过展现hello.c预处理到hello.i的过程,介绍了预处理的过程和目的。通过实例可以发现,预处理阶段对后续程序的编译汇编都有很大的作用。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
C语言编译器会进行词法分析、语法分析(-fsyntax-only),接着会把源代码翻译成中间语言,即汇编语言。
3.1.2 编译的作用
进行词法分析和语法分析。并且会进行代码优化,生成汇编代码,将hello.i翻译成一个ASCII汇编语言文件
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
图3-1 编译命令和生成的文件
3.3 Hello的编译结果解析
3.3.1 数据
1.常量
字符串常量:
图3-2 字符串常量
- 变量
局部变量:寄存器rdi,rsi依次保存第一、二个参数,
图3-3 main函数参数
临时变量i初始化为0
图3-5 赋值操作
3.3.3 类型转换
1. 将argv[1],argv2 隐式转换为字符串
图3 -6 类型转换
3.3.4 sizeof
图3-7 隐式sizeof
rax = argv[0];
rax = rax +2;(rax里面地址实际加了2*sizeof(char*));
rdx = *rax;
3.3.7 关系操作
1. if (argc == 4) jmp .L2
图3-8 关系操作
2 if (i <=7) jmp .L4
图3- 9 关系操作
3.3.8 数组/指针/结构操作
1. rax = argv[0];
rax = rax +2;
rdx = *rax;
rax = &argv[0];
rax = rax +1;
rax = *rax;
rsi = rax;
rdi为字符串"Hello %s %s\n
图 3-10 数组/指针操作
3.3.9 控制转移
1. if (argc == 4) jmp .L2
图3 - 11 控制
- L4.循环八次
图 3-12 循环
3.3.10 函数操作
1.main函数开头系列操作及其作用
图3-15函数操作
2.puts函数调用及参数传递
图3-16函数操作
- exit函数调用及其参数传递
图3-17函数操作
- Printf 函数调用及其参数传递
图3-18函数操作
- Sleep函数调用及其参数传递
图3-19函数操作
- main函数返回
图3-20函数操作
3.4 本章小结
本章学习了编译的概念与作用,在Ubuntu下编译的命令以及hello的编译结果解析
第4章 汇编
4.1 汇编的概念与作用
汇编的概念
汇编就是将汇编语言翻译为机器语言。一般而言,汇编生成的是目标代码,需要经链接器(Linker)生成可执行代码才可以执行。
汇编的作用
汇编的作用就是将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
汇编命令
4.3 可重定位目标elf格式
4.3.1生成ELF
用readelf -a hello.o > helloo.elf 生成文本文件。
ELF生成命令
4.3.2 ELF头
ELF头
我们逐行解析如下:
Magic:魔数,其实也称为神奇数字,我们大多数人是在学习计算机过程中接触到这个词的。它被用来为重要的数据定义标签,用独特的数字唯-地标识该数据,这种独特的数字是只有少数人才能掌握其奥秘的“神秘力量”。
故,直接出现的一个数字,只要其意义不明确,感觉很诡异,就称之为魔数。魔数应用的地方太多了
如elf 文件头ELF Header :
Magic : 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
这个Magic 后面的一长串就是魔数, elf 解析器(通常是程序加载器)用它来校验文件的类型是否是elf。
最开始的四个字节是所有ELF文件都必须相同的标识符,分别为0x7F,0x45,0x4c,0x46,第一个字节对应ASCII字符里面的DEL控制符,后面三个字节刚好是ELF这三个字节的ASCII码。这四个字节称为ELF的魔数。几乎所有的可执行文件格式的最开始的几个字节都是魔数。这种魔数用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数是否正确。
Class:类别 ELF64
Data:数据,表示2补码,小端序
Version:版本
Type:类型,REL,可重定位文件
Machine:系统架构
4.3.3 节头表
节头表
.test:程序代码
.data:初始化的全局变量
.bss:未初始化的全局变量
.rodata:只读数据节
.symtab:符号节
.strtab:字符串节。
4.4 Hello.o的结果解析
4.4.1 反汇编命令
objdump -d -r hello.o > hellooobjdump.txt
反汇编命令
4.4.2 反汇编文件
反汇编文件
4.4.3 与hello.s进行对比
我们会发现,每行代码末尾的指令基本是相同的,但是在每条指令前面都会有一串十六进制的编码。
从hello.o与hello.s文件内容分析,hello.s是由汇编语言组成的,相对于计算机能识别的机器级指令,汇编代码仍是抽象语言;而反汇编得到的代码不仅仅有汇编代码,还有机器语言代码。机器语言代码是计算机可识别执行的,是一种纯粹的二进制编码。
机器指令由操作码和操作数构成,从反汇编代码我们可以很清楚地看到,每一条汇编语言操作码都可以用机器语言二进制编码表示,所以可以将所有的汇编代码与二进制机器语言建立一一对应的映射关系。至于操作数,可以直观地机器语言与汇编语言的不同之处。
4.4.3.1分支转移
hello.s的分支转移是通过指令jmp或je, jne, … ,等直接跳转到某一段代码,例如
分别跳转到.L2和.L4代码段
而在反汇编文件中,并不存在汇编语言中的代码段地址,而是直接跳转在当前过程的起始地址加上偏移量得到的直接目标代码地址,如下图所示。
4.4.3.2 函数调用
在汇编文件中,call指令直接调用函数,call后紧跟函数的名字,如下图:
而在反汇编文件中,我们可以看到call调用的目标指令为call指令的下一个指令。这是因为,在汇编之后、链接之前的hello.o文件中是机器语言文件,但它是缺少调用C库函数的机器语言文件。所以还需要链接将调用的函数的.o文件与hello.o链接到一起,才能得到最终的可执行目标文件。所以在call指令的后面,会留下为链接准备的空间,等待链接器的下一步的重定位、链接
4.5 本章小结
通过对汇编代码进行汇编,生成机器代码的可重定位文件,然后使用readelf进行阅读对ELF文件有了更深入的了解。再对反汇编文件和原汇编语言文件进行对比发现了机器代码和汇编代码的关系 第5章 链接
5.1 链接的概念与作用
连接的概念
链接是将各种代码和数据片段收集并组合成为一个但一文件的过程,这个文件可被加载(复制)到内存并执行。
链接可以执行于编译时,也可以执行于加载时,甚至是运行时,由链接器来执行。
链接由链接器程序自动执行。链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件
连接的作用
将可重定位文件链接成可执行文件。链接可以帮助构造大型程序。链接还可以帮助使用共享库。
5.2 在Ubuntu下链接的命令
ld的链接命令:ld -o hello.o -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 /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
ld的链接命令
5.3 可执行目标文件hello的格式
5.3.1 ELF Header
命令:readelf -h hello
能够看到程序入口,包括elf header的大小和程序头的大小,31个section header等。
ELF Header
5.3.2 Section Header Table
命令:readelf -S hello
这是hello的节头,可以看到大小、地址以及偏移量等
Section Header Table
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
使用edb可以查看hello虚拟地址空间。从ELF开始,我们可以知道起始地址为0x400000;
与5.3对照,我们可以根据5.3中每一节对应的起始地址在edb中找到响应信息,比如:
对应的起始地址为0x4010f0:
虚拟空间地址
与5.3部分的地址进行对比可以看到,现在加载的进程的地址是系统分配的虚拟空间地址,与一开始的0开头的地址不同。而且也有部分程序是动态链接,使地址看上去不同。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
hello反汇编文件中,每行指令都有唯一的虚拟地址,而hello.o的反汇编没有。这是因为hello.o经过链接,已经完成重定位,每条指令的地址关系已经确定;
hello 反汇编
首先可以很直观的发现,文件比hello.o的反汇编文件大很多,内容多了很多。说明链接将很多代码内容放入了文件再生成了可执行文件。
其次,可以看到.o文件的反汇编是从0开始的地址,而hello的反汇编是401000开始的地址。
在hello反汇编文件中,出现了很多hello.o中没有的过程。这同样是经过重定位,然后链接的结果,链接器将需要重定位的call调用的过程的.o连接到hello当中,并确定下地址关系。所以在hello反汇编文件中,我们可以看到hello.s中曾分析过所调用的函数
并且多了init函数。用于对程序的初始化
新增函数
链接加入了在hello.c中用到的库函数,如exit、printf、sleep、getchar等函数
新增节
hello中增加了.init和.plt节,和一些节中定义的函数
函数调用地址:
Hello实现了调用函数时的重定位,因此在调用函数时调用的地址已经是函数确切的虚拟地址
函数调用地址:
Hello实现了调用函数时的重定位,因此在调用函数时调用的地址已经是函数确切的虚拟地址
控制流跳转地址:
Hello实现了调用函数时的重定位,因此在跳转时调用的地址已经是函数确切的虚拟地址
新增函数
重定位:
1、重定位节和符号定义:链接器将所有相同类型的节合并成同一个类型的新的聚合节。
2、重定位节中的符号应用:在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为“重定位条目”的数据结构。
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置,也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以当汇编器遇到对最终位置未知的目标饮用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
0000000000401000 <_init>
0000000000401020 <.plt>
0000000000401090 <puts@plt>
00000000004010a0 <printf@plt>
00000000004010b0 <getchar@plt>
00000000004010c0 <atoi@plt>
00000000004010d0 <exit@plt>
00000000004010e0 <sleep@plt>
00000000004010f0 <_start>
0000000000401120 <_dl_relocate_static_pie>
0000000000401130 <deregister_tm_clones>
0000000000401160 <register_tm_clones>
00000000004011a0 <__do_global_dtors_aux>
00000000004011d0 <frame_dummy>
00000000004011d6 <main>
0000000000401270 <__libc_csu_init>
00000000004012e0 <__libc_csu_fini>:
00000000004012e8 <_fini>
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接:等到程序运行时才进行链接,它提高了程序的可扩展性(可作为为插件)和兼容性。动态链接的基本思想是把程序按照模块拆分成各个相对独立的部分,在程序运行时才将它们连接在一起形成完整的程序
在elf文件中可以找到.dynamic:根据下图,我们可以知道动态链接调用的函数的位置
got.plt
在dl_init前,如图所示都为0。
dl_init前
dl_init后现在都有了值。如图所示。
dl_init后
相应的,调用的各个函数也都从0变成有了地址。
5.8 本章小结
本章主要讲了链接的过程。一个可重定位文件经过过静态链接以及动态链接后成为了可执行的程序。链接的出现很大程度上减少了代码的体积。通过对比与.0文件的elf文件以及反汇编文件,对链接时文件的变化有了更深入的认识。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
作用:
(1) 我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存。
(2) 处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象
6.2 简述壳Shell-bash的作用与处理流程
shell是你(用户)和Linux(或者更准确的说,是你和Linux内核)之间的接口程序。你在提示符下输入的每个命令都由shell先解释然后传给Linux内核。
shell 是一个命令语言解释器(command-language interpreter)。拥有自己内建的 shell 命令集。此外,shell也能被系统中其他有效的Linux 实用程序和应用程序(utilities and application programs)所调用。
Shell是一个交互型的应用级程序,它代表用户去运行其他程序。
Shell从标准输入或脚本中读取的每行称为一个管道行,它包含一个或多个由0个或多个管道字符(|)分隔的命令。
处理流程:
① 在shell命令行中输入命令:$./hello
② shell命令行解释器构造argv和envp;
③ 检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令
,如果不是内部命令,调用fork()函数创建子进程,其地址空间与shell父进程完全相同,包括只读代码段、读写数据段、堆及用户栈等
④ 调用execve()函数在当前进程(新创建的子进程)的上下文中加载并运行hello程序。将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间
⑤ 调用hello程序的main()函数,hello程序开始在一个进程的上下文中运行,如果用户没要求后台运行(命令末尾没有&号),shell使用waitpid(或wait等待作业终止后返回。如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。
父进程和子进程最大的不同时他们的PID是不同的,父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。
对于hello来说,我们输入了命令参数后,因为命令不是系统内部的命令,所以需要fork创建一个子进程。
6.4 Hello的execve过程
Execve的参数包括需要执行的程序(通常是argv[0])、参数argv、环境变量envp。 1. 删除已存在的用户区域(自父进程独立)。
2. 映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的。
3. 映射共享区:比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
4. 设置PC:exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
5. execve在调用成功的情况下不会返回,只有当出现错误时,例如找不到需要执行的程序时,execve才会返回到调用程序。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
进程提供给应用程序两个关键抽象:一个独立的逻辑流控制;一个私有的地址空间。
6.5.1 逻辑控制流和时间片
在计算机系统中,通常有多个程序同时运行,但进程让用户看起来当前计算机是独占地使用处理器。用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一的对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列就是逻辑控制流
进程的运行本质上是CPU不断从程序计数器 PC 指示的地址处取出指令并执行,值的序列叫做逻辑控制流。操作系统会对进程的运行进行调度,执行进程A->上下文切换->执行进程B->上下文切换->执行进程A->… 如此循环往复。 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。在一个程序被调运行开始到被另一个进程打断,中间的时间就是运行的时间片。
6.5.2 用户模式和内核模式:
用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据。
内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
6.5.3 上下文:
上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
保存当前进程的上下文;
恢复某个先前被抢占的进程被保存的上下文;
将控制传递给这个新恢复的进程
6.5.4 调度的过程:
在对进程进行调度的过程,操作系统主要做了两件事:加载保存的寄存器,切换虚拟地址空间。
6.5.5 用户态与核心态转换:
为了能让处理器安全运行,需要限制应用程序可执行指令所能访问的地址范围。因此划分了用户态与核心态。
核心态可以说是拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1 异常的种类
异常的种类有:中断、陷阱、故障、终止。
对于hello来说,这四种异常都是有可能的。比如中断可能是来自处理器外部的I/O设备的信号的的结果。对于陷阱,hello的exit以及sleep都会发生。对于故障,比如缺页故障。对于终止,比如硬件错误,DRAM和SRAM被损坏时发生的奇偶校验错误
常见的信号
6.6.2 各命令以及运行结果
-
正常运行结果
正常运行 -
不停乱按(除了后续的特定键入组合):不影响当前进程执行;
不停乱按 -
Ctrl-Z
发出SIGTSTP信号 -
Ctrl-C
发出SIGINT信号 -
Ctrl-z后运行ps
ps
-
Ctrl-z后运行jobs
jobs -
Ctrl-z后运行pstree
pstree -
Ctrl-z后运行fg
fg -
Ctrl-z后运行kill
kill
6.7本章小结
本章主要是讲了进程管理,主要包括了shell、异常、信号以及进程的创建和执行过程。对这一部分进行梳理,使得对hello程序的理解进一步提升。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:包含在机器语言指令中用来指定一个操作数或一条指令的地址,每个逻辑地址都由一个段和偏移量组成,偏移量指明了从段开始的地方到实际地址之间的距离。页式存储器的逻辑地址由两部分组成:页号和页内地址。[段标识符 : 段内偏移地址] 的表示形式,其中的段内偏移地址就是指逻辑地址
线性地址:也就是虚拟地址。是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
物理地址:是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。
结合hello来说的话,比如i的逻辑地址就是&i得到的,这时只是他在进程中的当前数据段的一个地址,然后和段的基地址结合就有了虚拟地址,这个虚拟地址再映射到一个物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
1、逻辑地址=段选择符+偏移量;
2、每个段选择符大小为16位,段描述符为8字节(注意单位);
3、GDT为全局描述符表,LDT为局部描述符表;
4、段描述符存放在描述符表中,也就是GDT或LDT中;
5、段首地址存放在段描述符中;
在 Intel 平台下,逻辑地址(logical address)是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。我们把这个过程称作段式内存管理。
段寄存器(16位),用于存放段选择符。CS(代码段):程序代码所在段。SS(栈段):栈区所在段。DS(数据段):全局静态数据区所在段。其他3个段寄存器ES、GS和FS可指向任意数据段。
一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址
7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1 页式管理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。
页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
磁盘中的字节都有唯一的虚拟地址,然后虚拟内存被分割成虚拟页。虚拟页的大小P=2p字节。然后有个页表条目PTE的数组,虚拟地址空间中的每个页在页表中都有一个固定偏移量。
7.3.2 线性地址到物理地址的变换
如图所示,每个虚拟地址都映射到磁盘的一个页。然后页表中有效位为1的。可以映射到DRAM中,也就是物理内存。有效位为0,物理页号为null的就是说还未分配,物理页号不为空那就是没有存储在物理内存中。
页式管理
简单来说,虚拟地址分为两部分:前一部分为虚拟页号,可以索引到当前进程的的物理页表地址,后一部分为虚拟页偏移量,将来可以直接作为物理页偏移量,页表是一个存放在物理内存中的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。如果不命中,则需要从下级存储单元往上级取。
虚拟地址分为VPN和VPO。VPN又分为TLBT和TLBI,后者是TLB的组号,前者是标记。找到后就取出物理地址的PPN(物理页号),然后VPO变成PPO,CI是组号,CO是偏移,CT是标记。
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 翻译后备缓冲器
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。
7.4.2 多级页表
将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。
Core i7采用四级页表层次结构,以Core i7的地址翻译为例
四级页表支持下的VA到PA的变换
如图所示为四级页表的VA(虚拟地址)到PA(物理地址)的转换。36位VPN被划分为了9位的片,每个片被用做到一个页表的偏移量。CR3寄存器包含L1页表的物理地址,CR3控制寄存器指向第一级页表(L1)的起始位置,CR3的值是每个进程上下文的一部分,每次上下文切换时,C3的值都会被恢复。VPN1提供到一个L1PTE的偏移量,这个PTE1包含L2页表的基地址。VPN2提供到一个L2PTE的偏移量,这个PTE2包含L3页表的基地址,以此类推,最后得到页的物理地址PPN。VPO可以直接转化为VPO从而和PPN结合然后转化为物理地址。
7.4.3 TLB与四级页表支持下的VA到PA的变换
TLB与四级页表支持下的VA到PA的变换
处理器生成一个虚拟地址,并将其传送给MMU。MMU用VPN向TLB请求对应的PTE,如果命中,则跳过之后的几步。MMU生成PTE地址(PTEA),并从高速缓存/主存请求得到PTE。如果请求不成功,MMU向主存请求PTE,高速缓存/主存向MMU返回PTE。PTE的有效位为零, 因此 MMU触发缺页异常,缺页处理程序确定物理内存中的牺牲页 (若页面被修改,则换出到磁盘——写回策略)。缺页处理程序调入新的页面,并更新内存中的PTE。缺页处理程序返回到原来进程,再次执行导致缺页的指令。
7.5 三级Cache支持下的物理内存访问
由虚拟地址翻译得到物理地址之后,将物理地址分为缓存偏移CO、缓存组索引CI以及缓存标记CT。
首先,利用组索引CI来寻找我们的地址是否在Cache中有对应的组;然后利用标记CT来判断我们的内容是否在Cache中。若有效位有效且标记位一致则命中。
如果命中,直接返回想要的数据,将数据传给CPU同时更新各级cache的储存。若不命中,则进行下一层Cache的索引、访问,以此类推。
7.6 hello进程fork时的内存映射
当fork()函数被当前进程调用时,内核为新进程创建了各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork()时存在的虚拟内存相同。当这两个进程中的人一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
程序执行了execve调用:execve(“hello”,NULL,NULL);
1、删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2、映射私有区域。为新的程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.test和.data区。.bss区域是请求二进制零的,映射到匿名文件,其大小包括在hello中。栈和堆区域也是请求二进制零的,初始长度为0.
3、映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库Libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC)。Execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
缺页故障
处理器生成一个虚拟地址,并将它传送给MMU,MMU生成PTE地址,并从高速缓存/主存请求得到它,当引用一个虚拟地址,而与该地址相对于的物理页面不在内存中,因此必须从磁盘中取出时,就会发送缺页故障。
缺页中断处理
在试图翻译某个虚拟地址A时,触发了一个缺页异常。这个异常转移到内核的缺页异常处理程序。
缺页异常处理程序要进行判断:
1.如果虚拟地址是一个不存在的页面,则视为段错误;
2.如果虚拟地址不合法,比如违反了只读的约定,则触发保护异常机制;
3.如虚拟地址可访问且合法,那么视为正常缺页。一个页面就是虚拟内存的一个连续的块(典型的是4KB)。缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。缺页处理程序页面调入新的页面,并更新内存中的PTE,缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态储存分配管理
动态储存分配管理使用动态内存分配器(如malloc)来进行。动态内存分配器维护着堆,堆顶指针是brk。分配器将堆视为一组不同大小的块的集合。每个块就是一个连续的虚拟内存页,要么是已分配的,要么是空闲的。
已分配的块显式地保留为供应用程序使用。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存分配器有两种风格,一种叫显式分配器,使用malloc和free等;一种叫隐式分配器,也叫垃圾收集器。两种风格都要求应用显示地分配块。它们的不同之处在于由哪个实体负责释放已分配的块。
显式分配器:要求应用显式地释放任何已分配的块。例如,c标准库提供一种叫做malloc程序包的显式分配器。c程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。c++中的new和delete操作符与c中的malloc和free相当。
隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集,例如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
显式分配器必须要满足以下条件:
-
处理任意请求序列
-
立即响应请求
-
只使用堆
-
对齐块
-
不修改已分配的块。
在这些限制条件下,分配器试图实现吞吐率最大化和使用率最大化。吞吐率就是每个单位时间里完成的请求数。内存利用率可以用峰值利用率来衡量,也就是有效载荷占已堆当前当前大小的比值。
造成堆利用率的一个原因是碎片现象。碎片分为内部碎片和外部碎片。内部碎片是分配一个已分配块比有效载荷大时发生的。外部碎片是当空闲内存合计一起来满足一个分配请求但没有一个单独的空闲块足够大时发生的。
为了实现一个分配器,必须考虑: -
空闲块的组织
-
放置
-
分割
-
合并。
可以设计一个隐式空间链表。在这种情况下,一个块是由字的头部和有效载荷组成的。
放置已分配的块的策略有首次适配、下一次适配和最佳适配。首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配是从上一次查询家属的地方开始检查空闲块。最简式配检查每个空闲块,选择所需请求的最小空闲块。
分割空闲块通常是把第一部分变成分配块,剩下的变成新的空闲块。
当合适的空闲块不够的时候将申请额外的堆内存,插入空闲链表中。
合并空闲块,当分配器释放一个分配块时,可能有其他空闲块与这个新释放的空闲块相邻,就必须合并。与下一块合并很简单,但是和链表中的上一块合并很困难,所以提出了边界标记,就是在结尾处增加一个脚部,如下图所示。
基本方法与策略
隐式空闲链表
任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块。大多数分配器将这些信息嵌入块本身,有一种方法是:一个块除了是由一个字的头部、有效载荷、可能的一些额外的填充组成外,还有一个与头部相同的脚部组成。头部和脚部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
我们称这种结构称为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。在带边界标签的隐式空闲链表中,我们的脚部就标记了一个块的结束。
合并的时候分配器就可以通过检查脚部来检查前一块的状态和大小了。
显式空闲链表
还有一种更好的方法,是将空闲块组织为某种形式的显示数据结构是一种更好的方法,因为根据定义,程序不需要一个空闲块的主体,所以实现空闲链表数据结构的指针可以存放在这些空闲块的主体里面。
使用显式链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。
另一种方法是按照地址顺序来维护链表,其中链表中的每一个块的地址都小于它后一个块的地址,在这种情况下释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章主要讲了hello的存储管理,hello的存储器地址空间,Intel逻辑地址到线性地址的变换-段式管理,Hello的线性地址到物理地址的变换-页式管理,TLB与四级页表支持下的VA到PA的变换,三级Cache支持下的物理内存访问,hello进程fork时的内存映射,hello进程execve时的内存映射,缺页故障与缺页中断处理以及动态存储分配管理。对整个hello程序的存储管理有了更深入的认识。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个子节点序列:B0,B1…,Bk,…,Bm-1。
所有的IO设备都被模型化为文件,而所有的输入和输出都被当作相应文件的读和写来执行。这种将设备优雅地映射为文件的方法,允许Linux内核引出一个简单、低级的应用接口,称为Unix IO,这使得所有输入和输出都能以一种统一且一致的方式来执行:
1、打开文件2、Linux shell创建的每个进程开始时都有三个打开的文件。3、改变当前的文件位置。4、读写文件。5、关闭文件。
8.2 简述Unix IO接口及其函数
Unix I/O接口,使得所有的输入和输出都能以一种统一且一致的方式来执行
打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息
Linux shell创建的每个进程开始时都有三个打开的文件,标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)
读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n;类似地,写操作就是从内存复制n>0个字节到文件,从当前文件位置k开始,然后更新k
关闭文件
内核释放文件打开时创建的数据结构,并将这个描述符恢 复到可用的描述符池中去
Unix I/O函数
1、int open(char* filename,int flags,mode_t mode)
进程通过调用 open() 函数来打开一个存在的文件或是创建一个新文件
open函数将filename 转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
2、int close(fd)
fd 是需要关闭的文件的描述符,close 返回操作结果。
3、ssize_t read(int fd,void *buf,size_t n)
read 函数从描述符为 fd 的当前文件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0 表示 EOF,否则返回值表示的是实际传送的字节数量。
4、ssize_t wirte(int fd,const void *buf,size_t n),write 函数从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置。
改变当前的文件位置
对于每个打开的文件,内核保持着一个文件位置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行 seek,显式地将改变当前文件位置 k。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
研究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结合参数args生成格式化之后的字符串,并返回字串的长度。
然后让我们追踪下write:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里是给几个寄存器传递了几个参数,然后一个int结束。将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址。
再来看看sys_call的实现:
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将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
当程序调用getchar时,程序等待用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车(回车也在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:当用户按键时触发键盘终端,操作系统将控制转移到键盘中断处理子程序,中断处理程序执行,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区,显示在用户输入的终端内。当中断处理程序执行完毕后,返回到下一条指令运行
8.5本章小结
本章主要讲了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。通过对这些知识点讲解,对系统的IO有了更深刻地理解。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello所经历的过程
- 首先有高级语言产生的hello.c文件
2、经过预处理后,hello.c进行了宏替换并且还将库函数.h文件等添加进入文件生成了hello.i
3、再通过编译器编译产生hello.s,变成了汇编语言。
4、再通过汇编器产生了hello.o,变成了可重定位文件。
5、通过链接器,将其他可重定位的文件写入进来变成hello可执行文件。
6、在shell中按要求输入并执行该可执行文件,shell会先fork一个子进程然后execve,加载运行hello。
7、创建了虚拟内存空间,并映射到物理内存。
8、在sleep函数时会发生异常,然后上下文切换。
9、printf函数会使用malloc函数申请堆空间,使用到动态内存管理技术。
10、printf函数会使用IO设备管理进行输出。
11、最后,进程结束被回收。Hello结束
感悟
计算机系统的实现分为很多层次。从程序员的角度分析计算机系统,主要是考虑计算机系统的硬件结构,然后在其上构建相应的软件。 在很多情况下,程序的效率需要考虑到硬件的实现方式,才能有效地提高软件的效率。 在计算机系统的高级语言设计层面,还需要考虑系统是如何工作的。 这帮助我们解决了很多潜在的问题,一个小细节可以对整个程序带来巨大的改进。
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.c 源文件
hello.i 预处理后的文件
hello.s 编译后的汇编文件
hello.o 汇编后的可重定位文件
hello 链接后的可执行文件
hello.elf hello的elf文件
helloo.elf hello.o的elf文件
hellooobjdump hello.o的反汇编文件
helloobjdump helloob的反汇编文件
参考文献
[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.