摘 要
本文通过Linux开发工具,从源文件hello.c经预处理、编译、汇编及链接成为可执行文件hello,再通过shell的动态链接执行hello进程。还包括程序执行过程中内存管理、进程管理以及I/O管理等等内容。对ICS课程的知识进行了整合与运用。
关键词:Linux;hello;预处理;编译;汇编;shell;内存;进程;I/O;ICS
目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在Ubuntu下预处理的命令 - 5 -
2.3 Hello的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在Ubuntu下编译的命令 - 6 -
3.3 Hello的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在Ubuntu下汇编的命令 - 7 -
4.3 可重定位目标elf格式 - 7 -
4.4 Hello.o的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
5.1 链接的概念与作用 - 8 -
5.2 在Ubuntu下链接的命令 - 8 -
5.3 可执行目标文件hello的格式 - 8 -
5.4 hello的虚拟地址空间 - 8 -
5.5 链接的重定位过程分析 - 8 -
5.6 hello的执行流程 - 8 -
5.7 Hello的动态链接分析 - 8 -
5.8 本章小结 - 9 -
第6章 hello进程管理 - 10 -
6.1 进程的概念与作用 - 10 -
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
6.3 Hello的fork进程创建过程 - 10 -
6.4 Hello的execve过程 - 10 -
6.5 Hello的进程执行 - 10 -
6.6 hello的异常与信号处理 - 10 -
6.7本章小结 - 10 -
第7章 hello的存储管理 - 11 -
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 -
7.9动态存储分配管理 - 11 -
7.10本章小结 - 12 -
第8章 hello的IO管理 - 13 -
8.1 Linux的IO设备管理方法 - 13 -
8.2 简述Unix IO接口及其函数 - 13 -
8.3 printf的实现分析 - 13 -
8.4 getchar的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -
第1章 概述
1.1 Hello简介
a.P2P是指From Program to Process。即从源文件转化为目标文件(可执行文件)。这一过程为:hello.c经cpp处理转化为hello.i(预处理),hello.i经ccl转化为hello.s(编译),hello.s经as转换为hello.o(翻译),最后经过printf函数,将hello.o转化为hello(可执行程序)。
b.O2O是指From Zero-0 to Zero-0。即从无到有再到无。Shell中原本不存在这个程序,输入命令运行程序时,shell调用fork函数创建一个新的子进程,再调用execve使内核为它开辟空的空间和数据结构,将程序内容装入其中,这是从无到有的过程;当程序运行结束后,内核将清空它留下的数据和空间,逐步清除程序运行的痕迹,最终回到程序运行前的状态,这是从有到无的过程。
1.2 环境与工具
硬件环境:X64 CPU ,2.50GHz , 8G RAM
软件环境:Windows 10 64位 ,Vmware 14 ,Ubuntu 16.04 LTS 64 位
开发工具:gcc + gedit , Codeblocks , gdb edb
1.3 中间结果
1)hello.i 预处理后的源代码文件
2)hello.s hello.i编译后的汇编文件
3)hello.o hello.s汇编后的可重定位目标文件
4)hello hello.s与其他可重定位目标文件链接后的可执行程序
5)hello.txt hello.o的反汇编代码
6)hello1.txt hello的反汇编代码
7)hello.elf hello.o的elf文件
8)hello1.elf hello的elf文件
1.4 本章小结
本章描述了最为基本的P2P流程及O2O原理,在进行编程有关的工作时了解底层原理其实也十分重要。第一章为后文奠定了基础。
第2章 预处理
2.1 预处理的概念与作用
概念:在计算机编程中,预处理是在进行下一步翻译之前对源代码执行处理的阶段。在c语言中,预处理是编译的第一个阶段,主要是根据代码中#开头的指令对代码进行分割、替换等处理,这些指令被称为预处理指令。预处理的结果是生成.i文件。
作用:1.包含:以字符#开头的命令,告诉预处理器读取系统头文件stdio.h的内容,并将其直接插入程序文本中。例如:hello中#include 、#include 、#include 告诉预处理器读取系统头文件 stdio.h、 unistd.h、 stdlib.h 的内容,并把它直接插入到程序文本中。
2.宏定义:用实际值替换用#define 定义的字符串
3.条件编译:根据#if 后面的条件决定需要编译的代码
2.2在Ubuntu下预处理的命令
预处理操作指令:gcc -E 1.c-o 1.i (E要大写)
2.3 Hello的预处理结果解析
打开hello.i之后发现,整个hello.i程序已经拓展为3205行,main函数前被插入了大量代码,这些都是头文件文件。
2.4 本章小结
本阶段完成了对hello.c的预处理工作。介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析。
第3章 编译
3.1 编译的概念与作用
C语言程序编译过程内的“编译”步骤,是指将.i形成的简单直接的c语言码经过分析并优化,翻译成一份汇编语言代码,得到的结果为.s文件。
编译的作用是把c语言翻译成汇编语言并进行优化,使其更加接近机器二进制语言,得到更简单明了和更符合机器运作规律的逻辑,方便下一步的汇编步骤。
3.2 在Ubuntu下编译的命令
Gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
1数据:
常量:以十进制数字的形式保存
全局变量:int sleepsecs
在汇编文件中使用sleepsecs(%rib)进行引用,在文件的开头,就为其赋初值,由于是int型,对小数部分舍去,赋初值为2.被存放在。Rodata节中Main数名
局部变量:
Int I 存储在-4(%rbp),并未赋初值,使用movl
$0 , -4(%rbp)语句,为i赋初值为0;Int argc存储在edi寄存器中,然后将其放在-20(%rbp)中,Char ** argv存储在rsi寄存器中,然后将其放置在-32(%rbp)中
2类型转换
对于sleepsecs存在2次类型转换
第一次是在赋初值时,将double类型的常数舍去小数赋值给sleepsecs
第二次是在作为sleep函数的参数时,将其做转为无符号整形,传给sleep函数
3算数运算
++:通过addl $1,-4(%rbp)实现对int型变量i加1,并将结果存储在i中的操作
4逻辑位操作
未使用该操作
5关系操作
Argc!=3:通过cmpl $3,-20(%rbp)对argc和3进行比较,设置状态位,再用je.Le语句如果b==a则跳转,否则不跳转继续往下执行I<10:开始时对i赋初值为0,然后依次使用addl $1,-4(%rbp)将i增大,使用语句cmpl $7,-4(%rbp)实现i和7的比较,设置状态位,在使用语句jle.L4如果i<=9则继续循环,否则跳出循环
6.数组、指针、结构
指针:argv是二级指针,指向的元素是一级指针
字符串也是用指针的形式进行使用
数组argv[]是一个数组,引用通过对argv的地址加上偏移量得到元素的首地址
7控制转移
If:使用je.L2进行转移判断,如果条件成立,跳转到.L2处,否则顺序执行
For:在循环开始时,给i赋初值为0,然后跳转到.L3处进行比较,使用jle.L4进行转移判断,如果条件成立,跳转到.L2处,否则顺序执行
8函数操作
Main函数:参数传递,%edi存储着argc的值(参数变量的个数),为第一个参数%rsi存储argv的值,作为地址,指向参数字符串数组的首地址为第二个参数;函数调用,主函数,第一个执行的函数;函数返回,返回0
Printf函数:参数传递,%rdi中存储传递的第一个参数,如果要输出字符串,字符串在开头.LC0和.LC1处,将字符串的地址赋值给%rdi作为第一个参数,在第二个printf中有三个参数,另外两个参数通过%rax赋值给%rsi和%rdx;函数调用使用call指令调用;函数返回,将返回值存储在%rax中,使用指令ret返回
Exit函数:参数传递,将1放在%edi中作为参数传递;函数调用,使用call指令进行调用;函数返回,无返回
Sleep函数:参数传递,将sleepsecs的值作为参数,将%eax赋值给%edi,%edi中的值作为第一个参数;函数调用,使用call指令进行调用;函数返回将返回值存储在%eax,使用ret指令返回。
3.4 本章小结
本节对汇编的概念和作用进行了阐述,对汇编代码进行了分析和解释,并与C语言中的语句进行对比,生成了hello.s文件
第4章 汇编
4.1 汇编的概念与作用
汇编将上一步得到的汇编代码翻译成二进制代码,并按照ELF格式存储.在本文件中对未定义的符号和不能确定地址的符号的引用会以一个重定位条目替代,等待链接阶段处理.
4.2 在Ubuntu下汇编的命令
gcc -c -no-pie -fno-PIC hello.s -o hello.o
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
节头部表
.text :已编译程序的机器代码
.rodata: 只读数据
.data : 初始化的全局和静态变量
.bss : 为初始化的全局和静态变量
.symtab: 符号表
.rel.text: 一个.text节中位置的列表
.rel.data: 别模块引用或定义的所有全局变量和重定位信息
.debug : 调试符号表
.line: 行号与.text 的机器指令之间的映射
.strtab:一个字符串表
图:
.rela.text为.text节中的重定位条目
.data, .bss的size都为零,因为我们没有定义任何全局变量.rodata节存储了代码中用到但没有定义符号的两个字符串字面量.symtab为符号列表,包括用户和系统的数据和函数,这里看到,为两个字符串也定义了临时的符号.
4.4 Hello.o的结果解析
a.机器可以识别机器语言,机器语言操作码和操作数组成
机器语言与汇编语言是一一对应的映射关系.(但不同平台的指令集不同,所以不同平台翻译的机器语言可能不同)
机器语言中的操作数使用的是十六进制格式;而汇编语言则是十进制
机器语言的跳转对函数的引用是使用相对与头部的偏移量值来表示位置的;而汇编代码中则是一个用一个标号表示位置.
机器语言中,全局变量的访问采用段名称+%rip的方式.
机器语言的反汇编代码为每条语句都加上了具体的地址
b.主要区别及原因:
⑴分支转移:反汇编代码跳转指令的操作数使用的不是段名称如.L3,而段名称只是在汇编语言中便于编写的助记符,在汇编成机器语言之后变成了确定的地址。
⑵函数调用:在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待静态链接的进一步确定。
⑶全局变量访问:在.s文件中,访问rodata(printf中的字符串),使用段名称+%rip,在反汇编代码中0+%rip,因为rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
4.5 本章小结
本章对汇编和汇编后产生的可重定位文件进行可阐述,分析了.o可重定位文件的结构和组成部分,各组成部分的内容和作用;比较了机器语言和汇编代码的不同和关系.这样就生成了一个hello.o可重定位文件。
第5章 链接
5.1 链接的概念与作用
链接器(ld)将.o文件(hello.o)中一些未定义的变量,函数,与其所在的.o文件(如printf.o)进行和合并,生成可执行文件(hello)
作用:合并各个.obj文件的节合并符号表,进行符号解析;进行符号地址的重定位;生成可执行文件
5.2 在Ubuntu下链接的命令
ld-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/10/crtbegin.ohello.o-lc /usr/lib/gcc/x86_64-linux-gnu/10/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
5.3 可执行目标文件hello的格式
。
从上面的节头表中可以看出各个节的起始地址,在文件中的位置偏移和大小.选取几个重要的节进行进一步分析.dynsym为动态符号表,可以看出,此处保存了所有对动态库中符号的引用.下一个节.dynstr是符号的字符串表, .dynsym和.dynstr的关系类似.symtab和.strtab的关系
.
.plt为PLT表所在节,文件中存储的PLT表是加载运行前的初始状态,利用objdump反汇编查看,可以看出,PLT[0]此时指向GOT的一个表项,而GOT表会再将程序导向动态链接器,其他PLT表项此时都指向PLT[0],也就是动态链接器.
5.4 hello的虚拟地址空间
前三行为hello所有,对应上一节提到的类型为LOAD的段,按照权限不同分为可读可执行,只读,可读可写的三部分,可以看到下面4行libc和ld占用的内存也有同样的结构,这部分内存是共享区域,在这之前还应该有堆区域,但hello没有用到malloc申请内存,故堆段为空,共享区域以下是栈段和系统映射区域.
可以看出,可执行文件为程序提供的信息都包含在前3行所指示的虚拟内存空间中,而其他部分都由于内存使用效率等各种原因,在加载阶段动态地处理,并且依赖于可执行文件提供的信息.
5.5 链接的重定位过程分析
两者反汇编的对比,主要有以下几项区别:以0开头的虚拟地址变成了具体的内存地址;函数的调用也变成了内存地址;增加了.init和.plt节;增加了getchar等库函数。由此可以知道,链接的过程就是将不同.o文件的内容按合理顺序拼接在一起使得彼此能够配合的过程。在重定位时,链接器需要整理符号表中的条目,分配出内存地址。先将每个同类节合并成同一个节,然后为它们赋予内存地址,使指令和变量有唯一的内存地址。最后将重定位节中的符号引用改为内存地址。
以 puts 为例,简述其链接过程:
puts第一次被调用时程序从过程链接表PLT中进入其对应的条目;
第一条PLT指令通过全局偏移量表GOT中对应条目进行间接跳转,初始时每个GOT条目都指向它对应的PLT条目的第二条指令,这个简单跳转把控制传送回对应PLT条目的下一条指令
把puts函数压入栈中之后,对应PLT条目调回PLT[0];
PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]间接跳转进动态链接器中。动态链接器使用两个栈条目确定puts的运行时位置,用这个地址重写puts对应的GOT条目,再把控制传回给puts。
第二次调用的过程:
puts被调用时程序从过程链表PLT中进入对应的条目;
通过对应GOT条目的间接跳转直接会将控制转移到puts。
数据访问 hello.o 反汇编文件中,对. rodata 中 printf 的格式串的访问需要通过链接时重定位的绝对引用确定地址,因此在汇编代码相应位置仍为占位符表示,对. data 中已初始化的全局变量 sleepsecs 为 0x0+%rip 的方式访问;而 hello 反汇编文件中对应全局变量已通过重定位绝对引用被替换为固定地址。
5.6 hello的执行流程
加载程序 ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
加载hello hello!_start
libc-2.27.so!__libc_start_main
-libc-2.27.so!__cxa_atexit
-libc-2.27.so!__libc_csu_init
Hello初始化 hello!_init
libc-2.27.so!_setjmp
-libc-2.27.so!_sigsetjmp
–libc-2.27.so!__sigjmp_save
调用main函数(运行) hello!main
调用打印函数 hello!puts@plt
调用退出函数 hello!exit@plt
ld-2.27.so!_dl_runtime_resolve_xsave
-ld-2.27.so!_dl_fixup
–ld-2.27.so!_dl_lookup_symbol_x
退出程序 libc-2.27.so!exit
5.7 Hello的动态链接分析
通过查找直到dl_init在0x00007fb81f4d3630位置,在执行_dl_int函数前:从而找到该处地址,在执行dl_init函数后:在dl_init函数执行之后global_offset表由全0地状态被赋值上相应地值.
a.Got1
b.PLT
c.Got2
由上图知,hello要调取由共享库定义的函数puts,printf,而程序调用一个由共享库定义的函数,编译器没有办法预测这个函数的运行地址,因为定义它的共享模块在运行时可以加载到任何位置。为了解决这个问题,GNU编译系统使用了延迟绑定技术:当hello尝试调用puts时,不直接调用puts,而是调用进入puts对应的PLT条目。这个条目会尝试利用GOT项进行间接跳转。
第一次被调用时,GOT项的值为PLT条目中的下一条指令地址,因而接下来会跳回PLT条目,在把puts的ID 0压入栈后,会转到PLT[0]的位置,PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]跳转进动态链接器中。动态链接器使用两个栈条目来确定puts的运行时位置,用这个地址重写puts的GOT项,再把控制传递给puts。在下一次执行到puts对应的PLT条目时,GOT项已经被修改,因此利用GOT项进行的间接跳转会直接跳转到puts函数
5.8 本章小结
本章讨论详细了链接的过程,对hello.o文件的链接和执行流程进行了分析.对链接生成可执行程序hello进行了具体的分析.链接使得我们可以将文件分解成若干个小块进行设计,更行,使得程序设计更加高效,有序.链接分为静态链接和动态链接,2者各有好处;静态链接虽然每次更新都要重新编译,但是可以有效的防止别人的恶意攻击;动态链接的实现给了我们很大的好处,也是如今常用的链接形式,对于一个函数或者变量的多个引用只需要一份代码, 并节省了内存空间;并且更新代码也无需重新连接,十分便利.链接实现多个文件的合并,最终创建一个可执行的完整的程序,到此,一个可执行程序的建立就已完成.
第6章 hello进程管理
6.1 进程的概念与作用
进程是程序执行的一个实例,是计算机中的程序关于数据结合上的一次运行活动.
作用:建立进程的概念,让计算机系统可以更好的管理不同程序的运行情况,方便与上下文的切换.提供每一个进程一个独立虚拟空间,营造他们在独占内存和cpu的假象,实现并行的现象
6.2 简述壳Shell-bash的作用与处理流程
作用:提供用户与操作系统交互的界面,用户通过这个界面访问操作系统内核的服务。可以输入命令行,执行相应的指令。
处理流程:
1.读取输入的命令行,
2.shell首先从命令行中找出特殊字符(元字符),在将元字符翻译成间隔符号.元字符将命令行划分成小tokens.
3.程序块tokens被处理,检查是否是shell中所引用的关键字.
4.当程序块tokens被确定以后, shell根据aliases文件中的列表来检查命令的第一个单词,如果这个单词在其中,执行替换操作并且处理过回到到第二部重新分割程序块tokens.
5.shell对符号~进行替换
6.shell对所有前面带有符 号 的 变 量 进 行 替 换 7. s h e l l 将 命 令 行 的 内 嵌 命 令 表 达 式 替 换 成 命 令 8. s h e l l 计 算 采 用 符号的变量进行替换 7.shell将命令行的内嵌命令表达式替换成命令 8.shell计算采用符号的变量进行替换7.shell将命令行的内嵌命令表达式替换成命令8.shell计算采用(expression)标记的算数表达式
9.shell根据栏位分割符号将命令字符串重新划分为新的块tokens.
10.shell执行通配符 * ? []的替换
11.shell讲所有处理结果中用到的注释删除,并按照下面的顺序实行命令检查:
A.内建的命令
B.shell函数(用户自己定义的)
C.可执行的脚本
12.在执行前的最后一步是初始化所有的输入输出重定向
13.最后执行命令
6.3 Hello的fork进程创建过程
命令行中键入./hello指令,shell分析处理命令,发现不是内置命令和shell函数,边当作可执行的脚本程序,使用fork创建一个和父进程相同独立的子进程,在子进程中fork返回0,由此识别是子进程,执行hello程序.
6.4 Hello的execve过程
execve函数加载并运行可执行目标文件execve将hello加载到当前的进程中,将原先程序覆盖,释放掉原先程序的用户空间,加载hello程序到用户空间中,hello程序和原程序的进程相同,拥有相同的PID.
首先execve加载了可执行程序的名字hello后调用启动代码.启动代码设置栈(用户栈的组织如下,其中argv指向参数字符串,envp指向环境变量字符串),并将控制传递给新程序主函数.
6.5 Hello的进程执行
1.逻辑控制流:每个程序都会有一个程序计数器的值的序列,这个序列中的值的转变的序列就叫做控制流;
2.用户态与核心态:处理器通过用某个控制寄存器中的模式位来限制一个应用可以执行的指令和他可以访问的地址空间范围.当设置了模式为是,进程运行在核心态,否则是用户态
用户态是运行用户程序,核心态则是运行操作系统的程序.在核心态下,程序可以使用特权指令,而在用户态下则不能使用这些指令,同时也不允许直接引用地址空间中内核区的代码和数据.
3.用户态与核心态的转换:
可以通过中断,陷阱,故障,终止等异常情况,程序会从用户态转换到内核态,内核保存进程的上下文,恢复接下来进程的上下文,将控制转递给该进程,状态回复到用户态.
同时可以通过设置程序状态字PSW来从内核态转换到用户态:
4.上下文信息:执行进程所需要的信息状态.
5.进程时间片:每一个进程都在不同的时间段运行,每个进程占用CPU的时间段,即控制在该进程上的时间段,就是该进程的时间片.
hello的进程调度过程:先执行hello程序,进程在用户态,控制暂时在hello的进程中.当输入的参数不是3个时,会调用exit函数,终止进程,shell回收hello进程; 当输入的参数时3个事,会进入循环,调用sleep函数,进程会休眠一段时间,这是控制可能会传递给内核,内核保存此时hello的上下文,包括i,sleepsecs的值,寄存器,栈,pc,条件码,state等的值,然后恢复要进入进程的上下文,最后将控制传递给该进程,并开始计时,在休眠了固定时间后,sleep会传送一个信号,可能会调用中断信号处理函数,将控制再传递给内核,内核保存当前进程的上下文,恢复hello进程的上下文,将控制传递给hello进程,执行hello程序.
6.6 hello的异常与信号处理
异常:异常用来提醒系统某些事件的发生,系统接收到异常之后就会执行相应的异常处理程序.异常可以分为4类:
中断:来自外围设备的异步信号,当设备给处理器发送中断信号后,处理器就执行对应的中断处理程序,完毕后回到接收信号之前的那条指令的下一条指令继续执行.
陷阱:陷阱是由一些特定指令触发的异常,最常见的陷阱是系统调用,系统调用将控制流转向系统服务,结束后返回系统调用指令的下一条指令.
故障:是由错误产生的异常,对于可以被故障处理程序修正的错误,处理完成后回到发生错误的指令,否则进入abort例程,终止故障的程序.
终止:由硬件错误等不可修复的错误产生,进入abort例程,终止程序.
在hello中最多的异常应该是系统调用,由于没有和外存或其他IO设备的交互,可能不会发生中断,如果出现硬件错误就会触发终止.还有一种可能频繁出现的异常就是缺页中断,这是一种故障,当程序使用的虚拟内存页不在物理内存中时就会发生缺页中断,此时系统调用缺页中断处理程序,淘汰某一物理页并将程序要求的页加入物理内存,完成后返回到触发故障的那条指令重新执行,此时不会再发生异常.
信号:信号是Linux的一种软件异常机制,一个信号是在进程中传递的一条消息,标志着某个事件的发生,进程捕捉到信号,并且处在该进程的用户态时就会执行相应的处理程序,与异常的处理程序不同的是,用户可以自己定义并指定一些信号的处理程序.
每个进程保存为其所有的待处理信号向量和阻塞信号向量,当控制流转到进程的用户态时,就会依次调用未被阻塞的待处理信号的处理程序.需要注意的是,信号接收是没有排队的,也就是说除了第一次接收到的信号,之后接收到的同类型信号都不会被处理.
常用的信号有SIGINT(来自键盘中断),SIGQUIT(来自键盘的退出),SIGFPE(浮点运算异常),SIGKILL(杀死进程),SIGSEGV(段错误异常),SIGTERM(软件终止),SIGCHLD(子进程终止或停止)等.
异常种类:
终止异常:exit函数,Ctrl-C会产生终止信号,终止程序.
中断异常:Ctrl-Z,
陷阱异常:sleep函数会触发;
信号:
SIGINT:Ctrl-Z会产生这个信号,会中断进程,切换到别的进程中;
SIGCONT:使用fg命令,会向进程发送该信号,切换到该进程继续执行(如果停止了).
SIGTRAP:sleep函数会触发这个信号,调用陷阱异常处理程序,并跟踪陷阱.
SIGCHLD:exit函数会发送这个信号,终止进程.
kill可以给根据PID给进程发送信号.
6.7本章小结
本章中介绍了进程,异常,信号的概念, 对控制流,异常控制流的概念做了阐述,并介绍了核心态和用户态2种模式.分析了进程的创建,程序的运行,上下文的切换,并发程序的机理,简要描述了一些异常,信号和其处理函数.描述了一些命令行的作用和shell工作的简单原理.这样hello可执行程序就可载入内存运行了.
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:是指由程序产生的和段相关的偏移地址(出现再机器语言中).由一个标识符加上一个指定段内的相对地址的偏移量,是cpu执行程序中的一种中间地址.hello.o反汇编的输出文件中的地址就是逻辑地址.
线性地址:逻辑地址到物理地址变换之间的中间层.逻辑地址加上相应段的基地址就生成了线性地址
虚拟地址:虚拟地址是一个抽象的概念空间,每一个虚拟地址对应与一个虚拟页,每一个虚拟页会映射一个磁盘空间的一页,如果要使用该数据,则会将该页载入内存,这样每个虚拟地址就对应与唯一的一个物理地址.
物理地址:指出目前CPU外部地址总线上的寻址物理内存的地址信号,用于内存级芯片的单元寻址.可以将内存看成一个从0字节开始的大数组,数组中每个字节拥有独有的物理地址.
7.2 Intel逻辑地址到线性地址的变换-段式管理
段映射是从逻辑地址到线性地址的映射,用段的基地址加上逻辑地址也就是偏移量,就得到了线性地址。逻辑地址由两部分组成,前16位为段标识符,后面为偏移量。通过段标识符中的前13位可以在一个叫段描述符表的表中找到其对应的三个信息,分别是段基地址、段限长、段属性(可读可写性等)。段描述符表又有全局描述符表(GDT) 和局部描述符表 (LDT)两种。因此,通过一个逻辑地址就可以找出基地址和偏移量,算出唯一的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页映射是从线性地址到物理地址的映射,由cpu的页式管理单元负责将线性地址翻译成物理地址。物理内存的最小分配单位是帧,页是虚拟内存中相当于帧的单位。线性地址的后半部分是页偏移量,而前半部分就是页号了,标志着其属于虚拟内存的哪一页。而物理内存中还有一个页表,它存储着虚拟内存页和物理内存帧的对应关系。这样,通过页号-》页表-》物理内存帧,再加上页偏移量,就得到了物理内存的地址。
7.4 TLB与四级页表支持下的VA到PA的变换
为减少时间开销,MMU中存在一个关于PTE的缓存,成为翻译后备缓冲器TLB。其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。7.5 三级Cache支持下的物理内存访问
1、CPU给出VA
2、MMU用VPN到TLB中找寻PTE,若命中,得到PA;若不命中,利用VPN(多级页表机制)到内存中找到对应的物理页面,得到PA。
3、PA分成PPN和PPO两部分。利用其中的PPO,将其分成CI和CO,CI作为cache组索引,CO作为块偏移,PPN作为tag。
先访问一级缓存,不命中时访问二级缓存,再不命中访问三级缓存,再不命中访问主存,如果主存缺页则访问硬盘
7.6 hello进程fork时的内存映射
Fork函数被调用是内核为hello新进程创建虚拟内存和各种数据结构并为其分配唯一的PID。为进行以上操作,还创建了当前进程的mm_struct、区域结构和样表的原样副本。Shell将两个进程中每个页面都标为只读,并将每个进程中的每个区域结构标记为写时复制。
7.7 hello进程execve时的内存映射
1.删除已存在的用户区域. 删除当前继承虚拟地址的用户部分中的已存在的区域结构
2.映射私有区域. 为新程序的代码,数据,bss和站区域创建新的区域结构.所有这些新的区域都是私有的,写时复制的.代码和数据区域被映射为hello.out文件中的.text和.data取. bss区域时请求二进制零的.
3.映射共享区域. 如果hello.out程序与共享对象(或目标)链接,比如标准c库 libc.so, 那么这些对象都是动态链接这个程序的, 然后再映射到用户虚拟地址空间中的共享区域内.
4.设置程序计数器(PC). execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点.
7.8 缺页故障与缺页中断处理
1.缺页:将DRAM的缓存不命中成为缺页
2.缺页故障:当cpu引用的虚拟地址所在的虚拟页的PTE有效位为0,即所对应的虚拟页不再内存,会引发缺页故障异常.
3.缺页中断处理: 缺页异常调用内核的缺页异常处理程序.程序选择内存中的一个页作为牺牲页,如果这个页被修改过(修改位被设置),则将该页写回磁盘;然后按照目标虚拟页的PTE的磁盘地址,将磁盘的页取出放内存中,同时修改PTE. 然后返回程序中断处的当前指令,继续请求访问该虚拟地址.
7.9动态存储分配管理
为了面对用户在运行时使用多个不同大小的内存空间的需求,进程专门留出一块虚拟空间来存放这些内存块,称为堆.在管理堆的内存时需要解决几个问题:
堆内存从何而来;如何交给用户一个至少有他需要的大小的内存块;如何管理未被分配的内存.
对于第一个问题,堆的虚拟空间同样映射到一个私有对象,只不过这个文件并不是由用户创建和管理的文件,而是由系统创建出来专门存储堆数据的匿名文件,包含的内容全部是二进制0,要注意的是在磁盘中并不实际存在这个文件,当进程第一次使用它的页时,就选择一个淘汰页将其全部写为0,并将此页标记为常驻内存的,从而并未与外存发生任何交互.当堆空间不足或可以缩小时可以调用void *sbrk(intptr_t incr);调整堆的大小.
对于第二个问题,首先为了CPU读取的高效,分配给用户的内存应该是8字节对齐的(在64位系统中),也就是每个分配块的起始地址和大小都应是8的倍数.除此之外,为了堆内存的管理,分配的内存可能有一个最小值,具体细节在讨论第三个问题时讨论.这两个要求将可能导致用户得到多于他需求的内存,此时这些多出来的内存不能再次被分配给用户,这就造成了一种内存空间的浪费,称为内部碎片.
对于第三个问题,存在许多的解决策略.首先要认识到,堆内存可以按照块的方式组织,一个块中可以包含大小不定的内存,但他们的状态要么全部是分配给用户的,要么全部是空闲的,按照上面的叙述,他们还应该是8字节对齐的.于是这些内存块就可以分为两种:分配块和空闲块.其中分配块是由用户的申请产生的,并且用户拥有释放它们,也就是使它们成为空闲块的责任.而空闲块由动态存储分配 器管理,下面介绍一些管理空闲块的方法.
第一种方法使用链表管理空闲块,根据链表节点的结构不同又可以分为隐式空闲链表和显式空闲链表.隐式空闲链表使用一个字(4字节)来存储这个块的大小和分配状态,称为头标记,由于大小总是8的倍数,所以它的最低3位恒为0,可以使用他们来存储额外的信息,例如用最后一位存储这个块是被分配的(a),还是空闲的(f).然而这时只能按照向后的方向访问链表,所以我们又在块的负载之后加一个字作为尾标记,这样就可以方便地读取前一个块的大小从而向前遍历链表.隐式空闲链表中分配块和空闲块采用相同的结构,由一个链表串联所有的分配块和空闲块.节点结构如下.
7.10本章小结
现代操作系统多采用虚拟内存系统,访存时地址需要从逻辑地址翻译到虚拟地址并进一步翻译成物理地址。操作系统通过地址的页式管理来实现对磁盘的缓存、内存管理、内存保护等功能。
虚拟内存为便捷的加载、进程管理提供了可能。程序运行过程中往往涉及动态内存分配,动态内存分配通过动态内存分配器完成。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O。设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix IO是Linux内核提供的一种简单,低级的读写文件的方法.包括几个功能:
打开文件
设置当前文件位置
读写文件
关闭文件
下面逐个分析Unix IO的函数
int open(char *filename, int flags, mode_t mode);
字符串filename描述了文件的路径,有两种方式:相对路径和绝对路径,相对路径是从当前工作路径开始的,绝对路径是从根目录开始的.
flags指明了以何方式访问这个文件,代表打开方式的宏有
O_RDONLY:只读
O_WRONLY:只写
O_RDWR:可读可写
O_CREAT:如果文件不存在就创建一个空的新文件
O_TRUNC:如果文件存在就截断它
O_APPEND:在每次写操作之前将文件位置指向文件尾,也就是追加写.
在这些flag中,不冲突的可以通过|连接作为参数传递给open,可以以多种方式访问文件.
mode通过一些宏指定新创建文件的权限.
open的返回值是文件描述符数字.
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf, size_t n);
读和写的形式比较相似,第一个参数fd是open得到的文件描述符数字,表示读/写到的文件,第二个参数是读到/要写的内存位置的指针,第三个参数是读/写的最大数量.read和write都在成功时返回读/写的字节数,在失败时返回-1,特别地,当read读到EOF时结束读的过程并返回0.
int close(int fd);
close函数关闭fd代表的文件,从进程的文件描述符表中删除它.成功返回0,失败返回-1.
8.3 printf的实现分析
由vsprintf生成显示信息.对字符串进行格式化,他接受输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出,返回字符串的长度,将长度储存在变量i中.
从vsprintf代码中
可以看到当*fmt是’%’,会进行相应格式的替换,这里只进行十六进制数输出的替换
接着调用write函数将修改好的格式化字符串和字符串长度传递给他.
write的部分运行如下:
这里传递了参数,并发送了一个中断信息,调用中断信息处理函数syscall,在传递的参数中,ecx是要syscall要打印出的元素个数,ebx中的是要打印的字符数组的第一个元素,.syscall就是完成不断地打印出字符,直到遇到:’\0’,
接着字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
printf是C语言标准IO库函数,实现了格式化输出到STDOUT的功能.我们将调用printf直到屏幕上显示文字的过程按操作系统的边界分开分析研究.
OS之上
源码:
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函数可以接受不确定数量的参数,从函数参数的栈帧结构我们可以推断出va_list arg = (va_list)((char*)(&fmt) + 4);这一行是将arg指向除格式串之外的第一个参数,也就是被格式化数据的第一个.
接下来使用缓冲区buf,格式串fmt和上面讲到的arg传给vsprintf函数,这个函数将fmt中的格式标签替换为arg指向的对应的数据,并写到buf中,最终返回实际写了的字节数.这个函数的实现并没有使用到IO,我们不做具体分析.
printf函数接下来调用write函数,将buf的i字节写到标准输出.明显用户并没有直接操控IO读写的权限,因此write的工作就是显式地使用int指令调用系统调用,给OS发出写到屏幕的请求.
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
write函数实际上使用int INT_VECTOR_SYS_CALL将控制流转到这个系统调用对应的处理函数,而这个函数又调用sys_call函数,sys_call是内核代码,在其中就可以真正向现实设备写数据了.
操作系统管理了一系列字体,每个字体都保存了一定样式的各种字符的点阵数据,也就是字模库.在向屏幕输出字符是,需要一个程序接受ascii码输入,找到一定字体中对应的字符点阵,并将读到的RGB数据写到显存(VRAM)中[5].
OS之下
至此,连接用户和硬件的操作系统的工作就都完成了,它现在可以去调度进程进行其他工作,直至显示设备准备好下一次显示再进行下一次显示内容的计算.与此同时,IO设备正在以缓慢的速度(相对CPU)处理和传输显示数据,完成将RGB数据显示在屏幕上的工作.
现在可使用的屏幕种类十分繁多,但从原理上讲都使用了逐行扫描的思路,也就是屏幕一次接收一行上各像素的显示数据(例如RGB数据)并显示在屏幕上,接下来再接收下一行并显示,最终按照一定频率完整地刷新一次所有行,称为屏幕的刷新率.
printf的最后一步就是刷新屏幕,使字符显示在屏幕上.在屏幕和显卡之间存在一系列总线,从显存中逐行读取显示数据传输给屏幕并显示.
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar是C标准IO库函数,从键盘缓冲区中读取一个字符并返回.首先介绍键盘缓冲区:
键盘缓冲区是BIOS管理的一块内存,它保存键盘输入但未被处理的字符.当键盘的一个键被按下时,键盘就向主板上的处理芯片发送一个扫描码,来说明哪个键被按下,处理芯片收到一个扫描码就向CPU发送一个中断,CPU执行完当前指令就会转去执行键盘中断的处理程序,读取扫描码,判断输入的是何字符并作相应处理,最后存入键盘缓冲区.键盘中断是一个可屏蔽中断[8].
getchar()是一个宏定义
#define getchar() getc(stdin)[6]
getc也是C标准IO,它是一种有缓冲读取,并且利用了C标准库的流的处理方法.getc也是宏,它的定义如下[7].
#define getc(_stream) (--(_stream)->_cnt >= 0 ? 0xff & *(_stream)->_ptr++ : _filbuf(_stream))
它的意义是当数据存储区域剩余字符的数目大于0的时候,减少一个剩余字符计数,返回0xff & *(_stream)->_ptr++,否则,调用_filbuf(_stream)._filbuf的功能是调用C标准IO中的read函数,从文件流中读取一定大小的内容填满缓冲区.
默认情况下stdin就是键盘输入,也就是键盘缓冲区,所以getchar的功能就是从键盘缓冲区中读取一个字符并返回
8.5本章小结
本章关于IO管理。首先说明了Linux通过将IO文件化,来很好的统一管理IO。接着具体描述了一些I/O接口和函数,包括带缓冲和不带缓冲的;最后对2个常见函数printf和getchar实现进行了具体分析,很好的说明了I/O的一般实现。结论
程序源代码通过预处理、编译、汇编、链接等步骤成为可执行程序、并通过进程管理、存储管理、IO管理等系统机制在操作系统中得以运行和结束的整个过程,这样的过程被详细地称作“P2P”(From Program to Process)和“O2O”(From Zero-0 to Zero-0)。通过梳理P2P和O2O的过程,我们也串联回顾了计算机系统所学的知识,使知识结构更加清晰。
计算机中所有的程序的运行都会经历hello经历的一部分。在hello的一生中,我们可以看到计算机内部工作的严谨与精密。所有的函数、指令都一环扣一环,任何一个环节出错都将导致程序运行出错。
附件
hello.c:c语言编写的源代码程序
hello.i:预编译后后的预编译文件
hello.s:编译器编译后的汇编文件
hello.o:汇编器汇编后的可重定位文件
hello:链接器链接处理可重定位文件生成的可执行文件
hello_dump: hello的反汇编代码
hello.o_dump:hello.o的反汇编代码
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Randal E.Bryant, David R.O’Hallaron.深入理解计算机系统(原书第三版). 北京:机械工业出版社,2016.7.
[2] printf函数实现:https://www.cnblogs.com/pianist/p/3315801.html
[3] Robert Love. Linux内核设计与实现(原书第3版). 北京:机械工业出版社,2011-4-30.
[4] 博韦,西斯特(美). 深入理解LINUX内核(第三版). 北京:中国电力出版社,2007-10-01.
[5] 内存管理:http://www.cnblogs.com/edisonchou/p/5115242.html
[6] sleep函数: https://www.ibm.com/support/knowledgecenter/zh/SSMKHH_10.0.0
/com.ibm.etools.mft.doc/bk52030_.htm