摘 要
编程就是一种语言翻译为另一种语言的过程,我们按照规则,将自己能看懂、带有目的的符号组合在一起形成高级语言,通过一系列指令将这种高级语言变成机器能读懂的二进制语言,在软硬件协同配合下,生成了我们期望中的结果。
Helloworld几乎是所有程序员编写的第一个程序,本文围绕这个较简单的hello.c函数的整个生命周期展开,介绍了该示例程序从编写到执行到结束的全过程,详细分析了每个阶段的原理和具体操作过程,以及可能发生的一些异常情况等。此过程中将通过Linux中的命令行解析器shell进行操作,并使用edb等调试工具,对计算机系统编译源文件、运行进程等机制进行较深入的分析和介绍,增加对计算机体系结构的深刻认识。
关键词:预处理;编译;汇编;链接;进程;存储;IO
目 录
第1章 概述... - 5 -
1.1 Hello简介... - 5 -
1.2 环境与工具... - 5 -
1.2.1 硬件环境... - 5 -
1.2.2 软件环境... - 5 -
1.2.3 开发工具... - 5 -
1.3 中间结果... - 5 -
1.4 本章小结... - 6 -
第2章 预处理... - 7 -
2.1 预处理的概念与作用... - 7 -
2.2在Ubuntu下预处理的命令... - 7 -
2.3 Hello的预处理结果解析... - 8 -
2.4 本章小结... - 10 -
第3章 编译... - 11 -
3.1 编译的概念与作用... - 11 -
3.2 在Ubuntu下编译的命令... - 12 -
3.3 Hello的编译结果解析... - 12 -
3.4 本章小结... - 20 -
第4章 汇编... - 21 -
4.1 汇编的概念与作用... - 21 -
4.2 在Ubuntu下汇编的命令... - 21 -
4.3 可重定位目标elf格式... - 21 -
4.3.1 readelf命令... - 21 -
4.3.2 分析ELF格式... - 21 -
4.4 Hello.o的结果解析... - 24 -
4.5 本章小结... - 25 -
第5章 链接... - 25 -
5.1 链接的概念与作用... - 25 -
5.2 在Ubuntu下链接的命令... - 25 -
5.3 可执行目标文件hello的格式... - 26 -
5.4 hello的虚拟地址空间... - 28 -
5.5 链接的重定位过程分析... - 30 -
5.6 hello的执行流程... - 32 -
5.7 Hello的动态链接分析... - 33 -
5.8 本章小结... - 34 -
第6章 hello进程管理... - 34 -
6.1 进程的概念与作用... - 34 -
6.2 简述壳Shell-bash的作用与处理流程... - 35 -
6.3 Hello的fork进程创建过程... - 35 -
6.4 Hello的execve过程... - 36 -
6.5 Hello的进程执行... - 36 -
6.5.1 逻辑控制流和时间片... - 37 -
6.5.2 用户模式和内核模式... - 37 -
6.5.3 上下文... - 37 -
6.5.4 调度的过程... - 37 -
6.5.5 用户态与核心态转换... - 37 -
6.6 hello的异常与信号处理... - 37 -
6.6.1正常运行状态... - 38 -
6.6.2异常类型与处理方式... - 38 -
6.6.3信号... - 38 -
6.7本章小结... - 40 -
第7章 hello的存储管理... - 41 -
7.1 hello的存储器地址空间... - 41 -
7.1.1 逻辑地址... - 41 -
7.1.2 线性地址... - 41 -
7.1.3 虚拟地址... - 41 -
7.1.4 物理地址... - 41 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 41 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 42 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 42 -
7.5 三级Cache支持下的物理内存访问... - 42 -
7.6 hello进程fork时的内存映射... - 42 -
7.7 hello进程execve时的内存映射... - 43 -
7.8 缺页故障与缺页中断处理... - 43 -
7.9动态存储分配管理... - 44 -
7.10本章小结... - 45 -
第8章 hello的IO管理... - 46 -
8.1 Linux的IO设备管理方法... - 46 -
8.2 简述Unix IO接口及其函数... - 46 -
8.3 printf的实现分析... - 46 -
8.4 getchar的实现分析... - 47 -
8.5本章小结... - 48 -
结论... - 48 -
附件... - 50 -
参考文献... - 51 -
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
在编辑好源文件之后,通过cpp、gcc、ld等命令,将C语言的源文件进行预处理、编译、汇编和链接,最终形成可执行目标文件hello,由存储器保存在磁盘中。它通过fork创建进程,被execve函数加载至内存,顺着逻辑控制流,在硬件上完成取指、译码、执行,最终显示在屏幕上,程序终止后由shell进行回收。
系统类型 64 位操作系统, 基于 x64 的处理器
版本 20H2(操作系统内部版本 19042.1526)
处理器 Intel(R) Core(TM) i7-1065G7 CPU @ 1.30GHz 1.50 GHz
机带 RAM 16.0 GB (15.8 GB 可用)
磁盘驱动器 INTEL SSDPEKN 512G8H
WDC WD10 SPZX-22Z10T1
Windows10 X64;VirtualBox/Vmware15.5.0;Ubuntu 20.04.3 LTS _Focal Fossa_ - Release amd64 (20210819)
Visual Studio Community2019 16.11.5 X64
Code::Blocks Release 20.03 rev 11997 2020-04-18, 19:47:24 - wx3.0.4 - gcc 9.3.0 (Linux, unicode) - 64 bit
gcc version 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04)
文件名称 |
文件作用 |
hello.c |
源文件 |
hello.i |
经预处理后的文件 |
hello.s |
编译之后的汇编文件 |
hello.o |
汇编之后的可重定位目标文件 |
hello.txt |
hello.o的ELF格式文件 |
dump_hello.txt |
hello.o的反汇编文件 |
hello |
链接之后的可执行目标文件 |
outELF.txt |
hello的ELF格式文件 |
hello_objdump.s |
hello反汇编文件 |
表一: 中间文件及作用
本章对hello的一生进行了简要的介绍和描述,介绍了P2P的整个过程,介绍了本计算机的硬件环境、软件环境、开发工具,介绍了为编写本论文的中间文件的名称和其作用。
(1)预处理概念
程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。
典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
预处理器在UNIX传统中通常缩写为PP,在自动构建脚本中C预处理器被缩写为CPP的宏指代。为了不造成歧义,C++(cee-plus-plus) 经常并不是缩写为CPP,而改成CXX。
(2)预处理作用
预处理能改善程序设计的环境, 有助于编写易移植、易调试的程序, 也是模块化程序设计的一个工具。对编译预处理命令的灵活运用, 可以使程序结构优良, 更加易于调试和阅读。
最常见的预处理是C语言和C++语言。ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase),通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
在Ubuntu中使用预处理指令
图2-1 预处理指令
出现文件hello.i
图2-2 生成hello.i
查看内容首先会发现hello.i文件有3000多行,而hello.c文件仅有20多行。
多出来的代码是什么?
图2-3 hello.i库文件
图2-4 结构体声明与定义
图2-5 内部函数声明
图2-6 源代码保留
本章介绍了预处理的概念及作用,在Ubuntu中使用cpp指令对hello.c进行预处理获得hello.i文件,并对hello.i的内容进行了粗浅的介绍,进一步加深了对预处理的印象。
编译就是把高级语言变成计算机可以识别的二进制语言,计算机只认识1和0,编译程序把人们熟悉的语言换成2进制的。
编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:
词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。执行词法分析的程序称为词法分析程序或扫描器。
编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位。编译程序的语法规则可用上下文无关文法来刻画。语法分析的方法分为两种:自上而下分析法和自下而上分析法。
中间代码是源程序的一种内部表示,或称中间语言。中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码,即为中间语言程序,中间语言的复杂性介于源程序语言和机器语言之间。中间语言有多种形式,常见的有逆波兰记号、四元式、三元式和树。
代码优化是指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。所谓等价,是指不改变程序的运行结果。所谓有效,主要指目标代码运行时间较短,以及占用的存储空间较小。这种变换称为优化。
有两类优化:一类是对语法分析后的中间代码进行优化,它不依赖于具体的计算机;另一类是在生成目标代码时进行的,它在很大程度上依赖于具体的计算机。对于前一类优化,根据它所涉及的程序范围可分为局部优化、循环优化和全局优化三个不同的级别。
目标代码生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。本文特指生成汇编语言代码,须经过汇编程序汇编后,成为可执行的机器语言代码。
编译主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
图3-1 编译命令
生成hello.s文件
图3-2 生成hello.s
源程序hello.c中有两个字符串常量,“用法:Hello 学号 姓名 秒数!\n”和“Hello %s %s\n”都是printf的格式参数。
图3-3(a) 源文件中第一次调用printf
图3-3(b)源文件中第二次调用printf
他们存储在.rodata节中,位于标号LC0和LC1中。根据UTF-8编码规则,汉字编码为三个字节,在hello.s中以编码形式存在,而英文和其他标点则被编码为一个字节,与ASCII码规则兼容,以原来的形式存在。
图3-4 printf格式参数存储位置
argc!=4中的4保存在.text中,作为指令的一部分与-20(%rbp)保存的argc进行比较。
图3-5(a)源文件的常量4
图3-5(b)hello.s的常量4
exit(1)中的1指示status,在hello.s文件中保存在%edi寄存器中。
图3-6(a) 源文件中常量1
图3-6(b)hello.s中常量1
循环条件中0和8也保存在text中。
图3-7(a)源文件的常量0和8
图3-7(b)hello.s中常量0
图3-7(c)hello.s中常量8
全局变量:已经初始化且初始值不为0的全局变量存储在.data节,它们的初始化不需要汇编语句,而是通过虚拟内存请求二进制零的页直接完成。
局部变量:存储在寄存器或者栈中。局部变量i保存在栈%rbp-4位置处。
图3-8(a) 源文件局部变量i
图3-8(b)hello.s局部变量i
算数操作
++操作,在汇编语言中表示为addl $1,-4(%rbp)。-4(%rbp)保存变量i,在自身加1后覆盖原数据。
图3-9(a)源文件中++操作
图3-9(b)hello.s中++操作
判断参数argc是否等于4
图3-10(a) 源文件关系操作
汇编代码为
图3-10(b)hello.s中关系操作
je用于判断cmpl产生的条件码,若两个操作数相等则跳转到标号L2处执行,若不等则从movl指令处继续执行。
图3-11(a) 源文件循环操作
汇编代码
图3-11(b) hello.s循环操作
先赋初值i=0,然后跳转到.L3进行条件判断,jle用于判断cmpl产生的条件码,若不相等调转到.L4执行循环体,i加一后重新判断和执行。至i=7后结束循环。
指针数组char *argc[]
图3-12(a) 源文件指针数组
argc保存在%edi中,argv首地址保存在%rsi中。
图3-12(b)hello.s中参数位置
在argv数组中,argv[0]指向输入程序的路径和名称,其他数组元素分别储存字符串。char*类型占8个字节,可推知argv[1]首地址在M[%rbp-32+16]中,argv[2]首地址在M[%rbp-32+8] ,argv[3]首地址在M[%rbp-32+24]中。
图3-12(c)hello.s中数组地址
main函数
参数传递:argc保存在%edi中,argv首地址保存在%rsi中。
函数调用:被系统启动函数调用。
函数返回:将%eax设为0后返回。
图3-13 hello.s中main函数返回
pintf函数
两次调用printf
第一次
图3-14(a)第一次调用printf
汇编代码
图3-14(b)hello.s中第一次调用printf
条件不符合时才调用。现将参数保存在%edi中,然后调用puts输出。
第二次调用
图3-15(a)第二次调用printf
汇编代码
图3-15(b)hello.s中第二次调用printf
参数传递:传入格式字符串的首地址、argv[1]和argv[2]的首地址。
函数调用:在循环体中执行,循环条件满足时才调用。汇编指令为call printf。
exit函数
图3-16(a) 源文件调用exit
汇编代码
图3-16(b)hello.s中调用exit
参数设为1后执行call exit。
atoi函数
图3-17(a) 源文件调用atoi
汇编代码
图3-17(b)hello.s中调用atoi
获得argv[3]的首地址,保存到%rdi中,然后执行call atoi。
sleep函数
图3-18(a) 源文件调用sleep
汇编代码
图3-18(b)hello.s中调用sleep
atoi的返回值保存在%eax中,先将其保存到%edi中再执行call sleep。
getchar 函数
图3-19(a)源文件调用getchar
汇编代码
图3-19(b)hello.s中调用getchar
无参数,循环结束后调用call getchar。
在本节中介绍了编译的概念及作用,并在Ubuntu中生成了hello.s文件,通过比较hello.c和hello.s文件,熟悉了数据如何存储,赋值操作、算数操作、关系操作、数组操作、控制转移和数操作等如何用汇编实现和表示。同时加深了对argc和argv使用的理解,更加理解参数传递的规则、寄存器、堆栈的使用。
驱动程序运行汇编器as,将汇编语言的ascii码文件(这里是hello.s)翻译成机器语言的可重定位目标文件(hello.o)的过程称为汇编。
汇编将.s汇编程序翻译为机器语言指令,并把这些指令打包生成可重定位目标程序的格式ELF,并将结果保存在.o文件中。
图4-1 生成hello.o
使用readelf指令生成文本文件
图4-2 readelf命令
图4-3 生成hello.txt文件
ELF文件内容有两个平行的视角:一个是程序连接角度,另一个是程序运行角度,如图所示。
图4-4 ELF文件格式
ELF header在文件开始处描述了整个文件的组织,Section提供了目标文件的各项信息(如指令、数据、符号表、重定位信息等),Program header table指出怎样创建进程映像,含有每个program header的入口,section header table包含每一个section的入口,给出名字、大小等信息。
ELF头包含了系统信息、字节顺序、操作系统、ELF头大小、节大小等信息。
图4-5 ELF头
描述每个节的特性,如名称、大小、类型、位置等。
图4-6 section headers
各个段引用的外部符号等在链接时需要通过重定位对这些位置的地址进行修改。链接器会通过重定位节的重定位条目计算出正确的地址。
hello.o需重定位:.rodata中的模式串,puts,exit,printf,atoi,sleep,getchar等符号。
图4-7 重定位节
存放函数和静态变量名,节名称和位置
图4-8 符号表
使用objdump -d -r hello.o > dump_hello.txt命令生成反汇编文件
图4-9 hello.o反汇编
分析hello.o的反汇编并与hello.s进行对照分析。
图4-10(a) 反汇编代码十六进制
如
图4-10(b) 反汇编代码函数调用
本章介绍了汇编的概念及作用,在Ubuntu中使用gcc将hello.s转化为了hello.o可重定位目标文件。使用readelf指令生成了hello.txt文件用于分析ELF文件格式,重点分析了ELF头、节头表、段头表、重定位表和符号表。然后我们使用objdump指令对hello.o进行反汇编并得到了反汇编文件dump_hello.txt,将其与hello.s文件进行对照分析,了解了汇编语言与机器语言的区别。目前程序已经能被机器直接识别了,只差链接就可以让程序运行起来了。
链接是将不同的可重定位目标文件的代码和数据综合在一起,通过符号解析和重定位过程,生成一个可以在程序中加载和运行的单一可执行目标文件的过程。
可以将公共函数聚合为单个文件,生成共享函数库;使分开编译成为可能,在当需要改动文价时,只需更改一个源文件,编译,然后重新编译,不需要重新编译其他源文件。
命令:
图5-1 ld命令生成hello
图5-2 生成ELF格式文件outELF.txt
ELF文件头
图5-3 ELF文件头
节头
描述每个节的特性,如名称、大小、类型、位置等。
图5-4 节头
使用edb加载hello,data dump窗口可以查看加载到虚拟地址中的hello程序。
图5-5 edb查看hello
program headers告诉链接器运行时加载的内容并提供动态链接需要的信息。
图5-6 program headers
VirtAddr表示虚拟空间地址。
程序包括PHDR,INTERP,LOAD,DYNAMIC,NOTE,GNU_PROPERTY,GNU_STACK,GNU_RELRO几个部分。
图5-7 生成hello反汇编文件hello_objdump.s
a) 新增函数文件
链接加入了hello.c用到的库函数printf、getchar、exit等。
图5-8 新增函数文件
b) 新增节:
新增.init和.plt节,以及节中定义的函数。
图5-9 新增节
c) 函数调用地址与跳转调用地址
链接过程完成了调用函数的重定位,此时hello里的函数调用地址和跳转的地址已经是确切的虚拟地址了。
图5-10 调用地址变化
(3) 链接过程:
b) 重定位:将多个单独的代码节和数据节合并为单个节,将符号从它们的.o文件的相对位置重新定位到可执行文件的最终绝对内存位置,更新所有对这些符号的引用来反映它们的新位置。
图5-11 重定位示例
c) 具体分析重定位过程:
在链接过程中使用的命令指定了动态链接器为64位的:/lib64/ld-linux-x86-64.so.2,同时添加了crti.o,crt1.o,crtn.o等系统目标文件,将程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中的sleep,printf,exit,getchar等函数以及_start函数中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将这些函数从不同文件中链接生成一个可执行文件。同时链接器根据可重定位目标文件中的重定位表同符号表一一对应,修改重定位信息。
使用edb执行hello,从加载hello到_start,到call main,以及程序终止的所有过程中其调用与跳转的各个子程序名或程序地址。
地址 |
名称 |
401000 |
<_init> |
401020 |
<.plt> |
401090 |
|
4010a0 |
|
4010b0 |
|
4010c0 |
|
4010d0 |
|
4010e0 |
|
4010f0 |
<_start> |
401120 |
<_dl_relocate_static_pie> |
401125 |
|
401152 |
|
4011a1 |
|
40115b |
|
4010a0 |
|
4010c0 |
|
4010e0 |
|
40115b |
|
4010a0 |
|
4010c0 |
|
4010e0 |
|
40115b |
|
4010a0 |
|
4010c0 |
|
4010e0 |
|
40115b |
|
4010a0 |
|
4010c0 |
|
4010e0 |
|
40115b |
|
4010a0 |
|
4010c0 |
|
4010e0 |
|
40115b |
|
4010a0 |
|
4010c0 |
|
4010e0 |
|
40115b |
|
4010a0 |
|
4010c0 |
|
4010e0 |
|
40115b |
|
4010a0 |
|
4010c0 |
|
4010e0 |
|
4010b0 |
|
4011c0 |
<__libc_csu_init> |
401230 |
< libc_csu_fini> |
401238 |
<_fini> |
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。
在ELF文件中能找到:
图5-12 ELF中节头
利用代码段和数据段的相对位置不变的原则计算变量的正确地址。而对于库函数,需要plt、got的协作。
通过edb的datadump窗口定位到GOT表处,在dl_init前后设置断点,run this line观察GOT表的变化。
图5-13 GOT表
plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。plt就能跳转到正确的区域。
本章研究了链接的过程。通过edb查看hello的虚拟地址空间,对比hello与hello.o的反汇编代码,深入研究了链接的过程中重定位的过程。
一个执行中程序的实例。
进程给应用程序提供两个关键抽象:一是独立的逻辑控制流,每个程序似乎独占CPU,这是通过OS内核中的上下文切换机制实现;二是私有地址空间,每个程序似乎独占内存系统,这是由OS内核中的虚拟内存机制实现。
Shell是用户级的应用程序,代表用户控制操作系统中的任务。处理流程如下:
① 在shell命令行中输入命令:$./hello
② shell命令行解释器构造argv和envp;
③ 调用fork()函数创建子进程,其地址空间与shell父进程完全相同,包括只读代码段、读写数据段、堆及用户栈等
④ 调用execve()函数在当前进程(新创建的子进程)的上下文中加载并运行hello程序。将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间
⑤ 调用hello程序的main()函数,hello程序开始在一个进程的上下文中运行。
Linux通过clone()系统调用来实现fork(),由于clone()可以自主选择需要复制的资源,所以这个系统调用需要传入很多的参数标志用于指明父子进程需要共享的资源。
fork(),vfork(),__clone()函数都需要根据各自传入的参数去底层调用clone()系统调用,然后再由clone()去调用do_fork()。
do_fork()完成了创建的大部分工作,该函数调用copy_process()函数,然后让进程开始运行。
copy_process()函数完成的工作分为这几步:
int execve(char *filename, char *argv[], char *envp[])
参数说明:
filename:可执行文件
目标文件或脚本(用#!指明解释器,如 #!/bin/bash)
argv:参数列表,惯例:argv[0]==filename
envp[]:最后一个参数envp指定了新程序的环境列表。参数envp对应于新程序的environ数组。
调用过程:
进程的运行本质上是CPU不断从程序计数器 PC 指示的地址处取出指令并执行,值的序列叫做逻辑控制流。操作系统会对进程的运行进行调度,执行进程A->上下文切换->执行进程B->上下文切换->执行进程A->… 如此循环往复。 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。在一个程序被调运行开始到被另一个进程打断,中间的时间就是运行的时间片。
用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据。
内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
在对进程进行调度的过程,操作系统主要做了两件事:加载保存的寄存器,切换虚拟地址空间。
为了能让处理器安全运行,需要限制应用程序可执行指令所能访问的地址范围。因此划分了用户态与核心态。
核心态可以说是拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。
图6-1 正常运行状态
图6-2 异常
图6-3 按回车的影响
图6-4 输入Ctrl-Z、ps、jobs、pstree、fg、kill的结果
图6-5(a) 输入Ctrl-C、ps、jobs命令
图6-5(b) fg命令
图6-7 kill命令
本章了解了进程的创建及执行,分析了异常和信号的处理机制,对进程管理有了深入的理解。
逻辑地址(Logical Address)是指由程序产生的与段相关的偏移地址部分。在这里指的是hello.o中的内容。
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
CPU启动保护模式后,程序hello运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。
放在寻址总线上的地址。放在寻址总线上,如果是读,电路根据这个地址每位的值就将相应地址的物理内存中的数据放到数据总线中传输。如果是写,电路根据这个地址每位的值就在相应地址的物理内存中放入数据总线上的内容。物理内存是以字节(8位)为单位编址的。
在 Intel 平台下,逻辑地址(logical address)是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。我们把这个过程称作段式内存管理。
一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。
页式管理将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后将页式虚拟地址与内存地址之间建立一个一一对应的页表,并用相应的硬件地址变换机构(MMU等),来解决地址变换(翻译)问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
操作:在获得线性地址(虚拟地址)后,我们将这个地址分成VPN和VPO,VPN表示虚拟页号,VPO表示虚拟页偏移量,我们可以通过VPN来获得PPN(物理页号),具体如下:在TLB(翻译后备缓冲器)中,将VPN分为TLBI,TLBT来寻找所求的物理页号;若不在TLB中,则去缓存中的或内存中的页表中寻找,若缺页,MMU触发一次异常,更新页表。最终将取得的PPN与VPO组合得到我们要的物理地址。
当我们在TLB中找不到我们要的PPN时,我们需要在页表中寻找。我们知道CR3寄存器始终指向一级页表,因此在四级页表中我们将VPN拆成4部分,在四级页表中从一级二级三级的在这三级页表目录中寻找我们的页表的地址,然后在四级的页表中,我们找到我们要的PPN,然后与VPO组合,得到PA。
在获得了PA之后,我们需要用这个地址去缓存中寻找我们要的数据。
首先,我们将PA拆分成CT,CI,CO,这三个分别表示标记,组索引,块内偏移量。
我们先通过组索引来确定我们的组,然后在这个组中找有效位为1的而且有对应标记的缓存行,若找到,则用块内偏移量锁定我们要的数据块,如果找不到,则到第二级(下一级)cache中去寻找,以此类推。
mm_struct(内存描述符):描述了一个进程的整个虚拟内存空间。
vm_area_struct(区域结构描述符):描述了进程的虚拟内存空间的一个区间。
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页表都标记为只读,并将两个进程的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的(这些是第九章虚拟内存的内容)。代码和数据区被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域。如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。
页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的:
图7-1 缺页中断处理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为一组不同大小的块 (blocks)的集合来维护,每个块要么是已分配的,要么是空闲的。分配器主要分为显式分配器和隐式分配器。
策略:显式空闲链表和隐式空闲链表。
隐式空闲链表:通过头部的大小字段隐式的连接。分配器可以通过遍历堆中的所有块,从而间接地遍历整个空闲块地集合。可以通过添加脚部的方式实现隐式双向链表。寻找空闲块时可使用首次适配、下一次适配、最佳适配和分离适配等分配策略;分配空闲块时,如果块较大且找不到更合适的,则可以进行分割;释放块时需要按照四种情况合并相邻空闲块。
显式空闲链表:通过某种数据结构来管理、分配空闲块,而不去管理已分配的块。
本章介绍了存储器地址空间、段式管理、页式管理,VA 到 PA 的变换、物理内存访问, hello 进程fork时和execve 时的内存映射、缺页故障与缺页中断处理、包括隐式空闲链表和显式空闲链表的动态存储分配管理。
设备的模型化:文件
文件类型如下:
设备管理:unix io接口
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
printf函数:
图8-1 printf函数
vsprintf 函数:
图8-2 vsprintf函数
vsprintf函数将所有的参数内容格式化之后存入buf,返回格式化数组的长度。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
当程序调用getchar时,程序等待用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车(回车也在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:当用户按键时触发键盘终端,操作系统将控制转移到键盘中断处理子程序,中断处理程序执行,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区,显示在用户输入的终端内。当中断处理程序执行完毕后,返回到下一条指令运行。
本章介绍了linux的IO设备管理方法、Unix的IO接口及其函数、printf函数的实现和getchar函数的实现。
用计算机系统的语言,逐条总结hello所经历的过程。
计算机系统是一个庞大复杂且由软硬件相互协调工作的系统。原来看似简单的几行代码,实则需要许多过程才能让它真正运行起来。通过对hello程序的整个生命周期进行分析,充分理解到计算机系统的各个部分是如何协调工作的,程序运行不仅需要宏观上的把控,还需要抽象的、底层的配合。如今只是看到了计算机系统的冰山一角,还需要更加深入的研究才能真正学明白。
计算机科学发展迅速,这要求我们不仅要学习以前的知识,还要跟上时代的步伐,洞悉计算机的前景和机遇,紧跟国家战略安排,提高创新意识与创新能力,不局限于已有的计算机系统实现方式,多学多思多实践。
图9:hello的一生
文件名称 |
文件作用 |
hello.c |
源文件 |
hello.i |
经预处理后的文件 |
hello.s |
编译之后的汇编文件 |
hello.o |
汇编之后的可重定位目标文件 |
hello.txt |
hello.o的ELF格式文件 |
dump_hello.txt |
hello.o的反汇编文件 |
hello |
链接之后的可执行目标文件 |
outELF.txt |
hello的ELF格式文件 |
hello_objdump.s |
hello反汇编文件 |
[1] Randal E.Bryant / David O’Hallaron.深入理解计算机系统(第三版).机器工业出版社,2016-11
[2] 预处理_百度百科 (baidu.com)
[3] (133条消息) c语言中argc和argv[ ]的作用及用法_fxfreefly的博客-CSDN博客_c语言argc和argv怎么使用
[4] (133条消息) C语言atoi函数_C语言技术网的博客-CSDN博客_c语言atoi函数头文件
[5] (134条消息) fork()函数详解以及进程创建的过程_one-77的博客-CSDN博客_(1) 系统调用fork()是如何创建进程的?
[6] (134条消息) 5.execve()到底干了啥?_chengonghao的博客-CSDN博客_execve
[7] Linux下的文件I/O编程 | 《Linux就该这么学》 (linuxprobe.com)