计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机类
学 号 1201140117
班 级 2003003
学 生 刘若愚
指 导 教 师 史先俊
计算机科学与技术学院
2022****年5月
摘 要
本文旨在研究hello.c的整个生命周期。利用gcc,edb、objdump、readelf等调试工具,探究一个简单的hello程序的生命中历程,分析了预处理、编译、汇编、链接的实现原理,此外,还分析了hello在运行过程中涉及的进程管理、内存管理、IO管理的过程。
**关键词:**计算机系统 编译 进程 内存 链接
**
**
目 录
1.11 From Program to Process. - 5 -
1.12 From Zero-0 to Zero-0. - 5 -
6.2 简述壳Shell-bash的作用与处理流程… - 33 -
7.2 Intel逻辑地址到线性地址的变换-段式管理… - 40 -
7.3 Hello的线性地址到物理地址的变换-页式管理… - 42 -
7.4 TLB与四级页表支持下的VA到PA的变换… - 43 -
7.7 hello进程execve时的内存映射… - 48 -
**
**
第1章 概述
1.1 Hello简介
1.11 From Program to Process
一个hello程序,首先的形式是一个hello.c的文本(Program),这个符合C语言语法规范的文本,在gcc的作用下,经过了预处理、编译、汇编、链接等过程后,成为了一个二进制可执行程序。这个可执行程序,在shell里,通过系统的进程管理,成为了一个运行的进程(Process)。
1.12 From Zero-0 to Zero-0
一个hello程序最初是在文本编辑器(Editor)里面被编写的 ,之后通过调用C预处理器(cpp)将hello.c翻译为一个ASCII码的中间件hello.i,之后运行C语言编译器(cc1),将hello.i翻译为汇编语言文件hello.s,接着编译器驱动程序运行汇编器(as),将hello.c转换为一个可重定位目标文件hello.o,最后,运行链接器程序(ld),将hello.c和一些必要的文件组合起来,创建一个可执行目标文件。在操作系统(OS)的管理下,程序成为进程,在shell里面,输入的一行命令会被解析,如果是shell内置命令就直接执行了,如果不是,shell会fork一个子进程,在子进程里面execve需要执行的程序
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NCtd7kM2-1652888832103)(大作业/clip_image006.jpg)]
Figure 1From Zero-0 to Zero-0
1.2 环境与工具
1.21 硬件环境
Figure 2 处理器参数
Figure 3 内存参数
1.22软件环境
l Windows10专业版
l Ubuntu20.04 LTS
l Codeblocks64位Linux版
l Vim
l Visualstudio2022
l Vmware workstation pro
l Clion
l Codeblocks64位Linux版
l Vim
1.33 开发及调试工具
l Codeblocks64位Linux版
l Vim
l Clion
l Visualstudio2022
1.3 中间结果
名称 | 作用 |
---|---|
hello | 最终生成的可执行文件 |
hello.asm | 对hello.o反汇编生成的文件 |
hello.c | 源程序 |
hello.elf | 由hello.o生成的elf文件 |
hello.elf2 | 由hello生成的elf文件 |
hello.i | hello.c预编译得到的文件 |
hello.o | 汇编生成的可重定位目标文件 |
hello.s | 由hello.c编译生成的汇编语言代码 |
hello2.asm | 由hello反汇编生成的代码 |
test.txt | 测试用例 |
1.4 本章小结
本章概述了hello.c从程序到进程的过程,以及一个典型程序的生命周期,对开发用到的软硬件环境以及开发工具作出了较为详细的介绍。最后,列举了在调试hello过程中的中间件的名称,以及文件的作用。
第2章 预处理
2.1 预处理的概念与作用
简要的说,预处理就是将C语言源程序处理为一个ASCII码的中间文件,这个过程主要针对宏定义(#define)、文件(#include)和条件编译(#ifdef),还有删除注释等工作。
2.11宏定义的预处理
C语言源程序中允许用一个标识符来表示一个字符串,称为“宏”。被定义为宏的标识符称为“宏名”。在编译预处理时,对程序中所有出现的宏名,都用宏定义中的字符串去代换,这称为宏替换或宏展开。
宏定义是由源程序中的宏定义命令完成的。宏替换是由预处理程序自动完成的。
常见的宏定义预处理有不带参数的宏定义和带参数的宏定义,格式和示例如下
#define 标识符 字符串
//#define AX 100
#define 宏名(形参表) 字符串
//#define SQUARE(x) ((x)*(x))
2.12文件包含的预处理
文件包含的命令的一般形式为:
#include"文件名"
文件是后缀名为".h"或".hpp"、".c"的头文件。文件包含命令把指定头文件插入该命令行位置取代该命令行,从而把指定的文件和当前的源程序文件连成一个源文件。
在程序设计中,文件包含的作用主要体现在模块化。通常来说,一个大程序可以分为多个模块,由多个程序员分别编程。有些公用的符号常量或宏定义等可单独组成一个文件,在其它文件的开头用包含命令包含该文件即可使用。由此可避免在每个文件开头都去书写那些公用量,从而节省时间,并减少出错。
包含命令中的文件名可用双引号括起来,也可用尖括号括起来,如#include "abc.h"和#include<math.h>。区别为使用尖括号表示在包含文件目录中去查找(包含目录是由用户在设置环境时设置的include目录),而不在当前源文件目录去查找;使用双引号则表示优先在当前源文件目录中查找,若未找到才到包含目录中去查找。用户编程时可根据自己文件所在的目录来选择某一种命令形式。
一个include命令只能指定一个被包含文件,若有多个文件要包含,则需用多个include命令。
文件包含允许嵌套,即在一个被包含的文件中又可以包含另一个文件。
2.13条件编译的预处理
一般情况下,源程序中所有的行都参加编译。但有时希望对其中一部分内容只在满足一定条件才进行编译,也就是对一部分内容指定编译的条件,这就是“条件编译”。有时,希望当满足某条件时对一组语句进行编译,而当条件不满足时则编译另一组语句。更多的情况下,不想重复编译。
条件编译功能可按不同的条件去编译不同的程序部分,从而产生不同的目标代码文件。
下面介绍几个场常见的预处理指令
#ifndef CPP_PROJECT_ASD_H
#define CPP_PROJECT_ASD_H
class asd {
};
#endif //CPP_PROJECT_ASD_H
如果标识符已经被#define定义,那么就不编译了,如果没有,执行内部的代码,可以防止重复编译。
或者使用#pragma once,也可以保证只编译一次,#pragma指令较为复杂 ,这里不进行展开
2.2在Ubuntu下预处理的命令
上文已进行较为详细的阐述,这里仅进行列举,列举命令和功能
宏定义方面:
#define MAX_TIME 1000
#define pint (int*)
文件包含的预处理:
#define <stdio.h>
#define “mypro.h”
条件编译的预处理:
#ifndef ABCPROJECT_H
#define ABCPROJECT_H
#endif //编译一次
#pragma once //编译一次
shell里面的预处理命令
Figure 4 预处理命令
2.3 Hello的预处理结果解析
这里截取一部分预处理的结果
Figure 5 预处理结果
原程序中的stdio.h是标准库文件(按照前文所述,非标准库文件包含时一般使用双引号,编译器会在当前目录下进行查找),编译器在Linux系统的环境变量下寻找stdio.h,打开文件/usr/include/stdio.h,发现其中使用了“#define”、“#include”等,故对其进行递归展开替换,最终的hello.i文件中删除了原有的这部分。对于其中使用的“#ifdef”、“#ifndef”等条件编译语句,编译器会对条件值进行判断,然后再来来决定是否对此部分进行包含。
可以看到,预处理主要是做了一些宏替换、文件包含、注释替换和条件编译。预处理的程序仍是C语言的源程序,可以正常的阅读,只是将源程序做了一些简单的处理。预处理后的程序在体量上变得非常大,也更加的全面。
2.4 本章小结
本章主要介绍了预处理的概念及作用、并结合Ubuntu系统下hello.c文件实际预处理之后得到的hello.i程序对预处理结果进行了解析
**
**
第3章 编译
3.1 编译的概念与作用
编译阶段(compile),总的来说,就是根据上一阶段得到的预处理的ASCII码,根据一定的语法规则,翻译为汇编语言代码hello.s,汇编代码是机器代码的文本表示,它给出了程序中的每一条指令,通过这些指令可以很方便地生成机器码。
编译通常具有以下流程,首先是词法分析,对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。接着是语法分析,编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序。然后生成中间代码:使程序的结构在逻辑上更为简单明确。根据编译指令中的代码优化等级,对程序进行多种等价变换,便于生成更有效的目标代码。最后,目标代码生成器把语法分析后或优化后的中间代码变换成目标汇编代码。值得注意的是,编译指令可以很大程度地影响编译结果,不同的编译器得到的结果也不尽相同。
3.2 在Ubuntu下编译的命令
对于hello.c或hello.i执行一下命令
gcc -S hello.c -o hello.s
即可生成汇编代码
编译有许多选项,这里只介绍常用的一部分:
\1. 优化选项
-O执行不同程度的优化,有不同的优化等级。
\2. -Wall 生成所有警告信息。
\3. -o FILE 生成指定的输出文件。用在生成可执行文件时。
Figure 6 编译命令
3.3 Hello的编译结果解析
3.31 hello.s代码简要分析
程序的全部代码如下
.file “hello.c”
.text
.section .rodata.str1.8,“aMS”,@progbits,1
.align 8
.LC0:
.string “\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201”
.section .rodata.str1.1,“aMS”,@progbits,1
.LC1:
.string “Hello %s %s\n”
.text
.globl main
.type main, @function
main:
.LFB50:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
pushq %rbx
.cfi_def_cfa_offset 24
.cfi_offset 3, -24
subq $8, %rsp
.cfi_def_cfa_offset 32
cmpl $4, %edi
jne .L6
movq %rsi, %rbx
movl $0, %ebp
jmp .L2
.L6:
leaq .LC0(%rip), %rdi
call puts@PLT
movl $1, %edi
call exit@PLT
.L3:
movq 16(%rbx), %rcx
movq 8(%rbx), %rdx
leaq .LC1(%rip), %rsi
movl $1, %edi
movl $0, %eax
call __printf_chk@PLT
movq 24(%rbx), %rdi
movl $10, %edx
movl $0, %esi
call strtol@PLT
movq %rax, %rdi
call sleep@PLT
addl $1, %ebp
.L2:
cmpl $7, %ebp
jle .L3
movq stdin(%rip), %rdi
call getc@PLT
movl $0, %eax
addq $8, %rsp
.cfi_def_cfa_offset 24
popq %rbx
.cfi_def_cfa_offset 16
popq %rbp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE50:
.size main, .-main
.ident “GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0”
.section .note.GNU-stack,“”,@progbits
.section .note.gnu.property,“a”
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string “GNU”
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
其中,下列代码是是main函数的主题框架
main:
.LFB50:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
pushq %rbx
.cfi_def_cfa_offset 24
.cfi_offset 3, -24
subq $8, %rsp
.cfi_def_cfa_offset 32
cmpl $4, %edi
jne .L6
movq %rsi, %rbx
movl $0, %ebp
jmp .L2
通过跳转,分别执行两种可能的结果。当参数不符合要求时,跳转到下列代码
.L6:
leaq .LC0(%rip), %rdi
call puts@PLT
movl $1, %edi
call exit@PLT
当参数符合要求时,跳转到.L2,.L2与.L3配合,完成循环
.L3:
movq 16(%rbx), %rcx
movq 8(%rbx), %rdx
leaq .LC1(%rip), %rsi
movl $1, %edi
movl $0, %eax
call __printf_chk@PLT
movq 24(%rbx), %rdi
movl $10, %edx
movl $0, %esi
call strtol@PLT
movq %rax, %rdi
call sleep@PLT
addl $1, %ebp
.L2:
cmpl $7, %ebp
jle .L3
movq stdin(%rip), %rdi
call getc@PLT
movl $0, %eax
addq $8, %rsp
.cfi_def_cfa_offset 24
popq %rbx
.cfi_def_cfa_offset 16
popq %rbp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
3.32 数据
总的来说,该程序中的数据主要分三种,局部变量,字符串和数组,下面对其进行解析
局部变量:局部变量是存在栈里面的,在该程序里面存在寄存器%ebx里面
赋初值
累加
事实上,这个局部变量就是源程序里面的 int i
字符串:
字符串一般存在内存里面
数组:
Argv[]数组存在内存里面。
3.33操作
3.331 赋值操作
简单的来说,赋值操作可以通过move指令来完成,本程序中的实例如下
Move指令有很多变种,列举如下:
根据所传输的数据大小,move有 b,w,l,q后缀,分别对应;字节,单字,双字,四字,一般来说,只变动从低到高指定的位数,只有l后缀会清空高32位。此外,move的源操作数指定的值必须是一个立即数,这个立即数存在内存,寄存器,或者直接就是$123这样的立即数。move的目的操作数是一个位置,要么是一个寄存器,要么是一个内存的地址。X86不允许从内存到内存,必须用寄存器周转一下。
而根据赋值的方式,move也有其他版本,例如表示绝对值传送的movabsq指令。
3.332 算数操作
常用的算数操作指令列举如下(也包含了一些逻辑指令)
Figure 7 常用算术指令
在本程序里面的体现为
一个简单的加法指令
3.333 压栈弹栈操作
一般来说,栈是倒置的,也就是说,最上面的地址最大,最下面的是栈顶,因为栈指针指向这里,这里的地址小。压栈操作,使得栈指针减小。
Pop %rax 弹出数据(多少位根据pop的类型来看)到%rax寄存器
Push %rax 压入数据(同样的,多少位根据push的类型来算),将%rax的值压入栈中。
Popq %rax 等价于 moveq (%rsp) %rax ;add $8 %rsp
Pushq %rax 等价于 add $-8 %rsp ; moveq %rax (%rsp)
本程序里面的压栈弹栈有很多
压入
弹出
3.334 跳转与控制跳转
首先介绍比较常用的无条件跳转jmp指令,jmp后跟标号或者操作数,后者为间接跳转,间接跳转需要加上*符号。
另一类的是条件跳转,根据条件码进行判断是否进行跳转。常和cmp指令连用
在本程序中,出现的跳转和解释如下。
这个是main函数里面的跳转,主要是判断是否符合参数个数的条件然后选择执行不同的分支。
cmpl $4, %edi判断大小并设置条件码,jne .L6表示如果不相等,就跳转相应的地址。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T6VpyBjG-1652888832112)(大作业/clip_image036.png)]
这一步用来判断计数循环是否达到了退出条件。
3.335循环体
在该程序中,循环体是通过条件跳转实现的
Figure 8 循环体
在.L2中,如果条件不满足,会执行跳转到.L3,.L3顺序执行直到再次遇到条件判断,由此形成循环
3.336 函数调用
本程序出现了很多的函数调用,不具体解释了,仅介绍函数调用的一般步骤。
函数调用是通过call指令来完成的,一个函数调用需要完成以下任务
1.传递控制:
进行过程 Q 的时候,程序计数器必须设置为 Q 的代码的起始地址,然后在返回时,要把程序计数器设置为 P 中调用 Q 后面那条指令的地址。
\2. 传递数据:
P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 中返回一个值。
\3. 分配和释放内存:
在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
3.4 本章小结
本章介绍了编译的概念与作用,同时,分析了其具体实现。一般来说,编译是将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备的过程。同时,本章以 hello.s 文件为例,介绍了编译器如何处理各个数据类型以及各类操作。
第4章 汇编
4.1 汇编的概念与作用
汇编是指汇编器(assembler)将以.s 结尾的汇编程序翻译成机器语言指令,
并把这些指令打包成可重定位目标程序格式,最终结果保存在.o 目标文件
中的过程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oDASmbwE-1652888832113)(大作业/clip_image040.png)]
Figure 9 汇编
4.2 在Ubuntu下汇编的命令
-c 参数,用于汇编阶段,把汇编语言代码转换为二进制代码 ,也就是可重定位目标文件(.o)
4.3 可重定位目标elf格式
4.31、ELF头
elf头是位于elf文件的头部,里面存储着一些机器和该ELF文件的基本信息,其中开始的序列描述了生成该文件的系统的字的大小和字节顺序
Figure 10 ELF头
4.32节头目标
列举了节的偏移、类型、地址等信息
Figure 11 节头目表
4.33、重定位节
在链接之后,链接器将代码中的符号引用和符号定义关联起来,此时就知道输入目标模块中国的代码节和数据节的确切大小。此时就可以进行重定位了。重定位将合并输入模块,并未每个符号分配运行时地址。
重定位主要分两步
4.331重定位节和符号定义
将同一类型的节合并,将运行时内存地址赋给新的聚合节、模块定义的节、符号。这样,程序中的每条指令和全局变量就有了唯一的运行时地址。
4.332重定位节中的符号引用
修改代码节和数据节中的对每个符号的引用,使其指向正确的运行时地址。
这里展示的是代码的重定位条码目,其中offset是需要被修改引用的节偏移,symbol是指被修改引用应指向的符号,type是重定位类型。图中的R_X86_64_PC32是比较常见的32位pc相对寻址,一个pc相对地址对应的是程序计数器pc当前运行值的偏移量。
由于没有全局变量,所以没有数据重定位条目
Figure 12 重定位
符号表
存放程序中的定义和引用的函数和全局变量的信息。特别的,.symtab不包括局部变量的条目
Figure 13 符号表
4.4 Hello.o的结果解析
Hello.o中,函数调用的call与条件跳转指令的地址为具体的地址,在本例中,用的是PC相对寻址。而在hello.s中,条件跳转使用的是段名称,也就是标号,而函数调用使用的直接是函数名称,这是因为 hello.c 中调用的函数都是共享库中的函数,最终需要通过动态链接器作用才能确定函数的运行时执行地址。
Figure 14 hello结果解析
其次hello.o中的每一条指令都有了运行时地址。
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
4.5 本章小结
本章对汇编的概念、作用、可重定向目标文件的结构及对应反汇编代码等进行了较为详细的介绍。经过汇编阶段,汇编语言代码转化为机器语言,生成的可重定位目标文件(hello.o)为随后的链接阶段做好了准备。
**
**
第5章 链接
5.1 链接的概念与作用
链接是指通过链接器(Linker),将程序编码与数据块收集并整理成为一个单一文件,生成完全链接的可执行的目标文件(windows 系统下为.exe 文件,Linux 系统下一般省略后缀名)的过程,链接提供了一种模块化的方式,使分离编译成为可能,可以将程序编写为一个较小的源文件的集合,且实现了分开编译更改源文件,从而减少整体文件的复杂度与大小,增加了容错性,也方便对某一模块进行针对性修改。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QiOQojX6-1652888832116)(大作业/clip_image054.png)]
Figure 15 链接
5.2 在Ubuntu下链接的命令
Figure 16 链接指令
5.3 可执行目标文件hello的格式
5.31 ELF头
elf头是位于elf文件的头部,里面存储着一些机器和该ELF文件的基本信息,其中开始的序列描述了生成该文件的系统的字的大小和字节顺序,而且加入了入口地址。
Figure 17 ELF头
5.32 节表头
节的类型、位置、偏移量和大小等信息
Figure 18 节表头
5.33 符号表(仅列出一部分)
Figure 19 符号表
5.34 程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
Figure 20 程序头
5.35 动态链接部分
Figure 21 动态链接部分
5.4 hello的虚拟地址空间
虚拟地址空间的各段信息如下:
Figure 22 虚拟地址
程序加载的地址为0x400000,而虚拟地址从0x401000开始,而各段的地址可以在节表头中找到对应。
5.5 链接的重定位过程分析
不同:增加了一些节,例如.init ,.plt 等,库函数也链接到了程序中,call函数的指令也发生了变化,操作数变为了pc相对寻址的地址之差,之前是没有的。
Figure 23 链接中的重定位
链接的过程:
简单的来说,就是将共享库的目标模块加载到指定的内存地址,并和一个在内存中的程序链接起来
重定位的过程:
1、重定位节和符号定义
2、重定位节中的符号引用
5.6 hello的执行流程
过程:
1、从加载hello到_start
2、调用主函数
3、调用其他函数
4、退出程序
程序名称与地址
\5.
40116d: e8 3e ff ff ff callq 4010b0 __printf_chk@plt
\6.
401188: e8 43 ff ff ff callq 4010d0 sleep@plt
40119c: e8 3f ff ff ff callq 4010e0 getc@plt
00000000004010c0 exit@plt
5.7 Hello的动态链接分析
下图为调用 dl_init 之前.got.plt(地址为0x 0000000000404000) 段的内容
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DjYNJcqx-1652888832122)(大作业/clip_image076.png)]
Figure 24 动态链接分析
下图为调用之后
Figure 25 调用之后的相应段
可以看到发生了变化,这是动态链接器的延迟绑定的初始化部分。延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)的协同工作实现函数的动态链 接,其中GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
(以下格式自行编排,编辑时删除)
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
本章围绕可重定位目标文件 hello.o 链接生成可执行目标文件 hello 的过程,首先介绍并分析了链接的概念、作用及具体工作原理。随后验证了hello的虚拟地址 空间与节头部表信息的对应关系,分析了 hello 的执行流程。最后对hello程序进行了动态链接分析。
第6章 hello进程管理
6.1 进程的概念与作用
\1. 进程的概念
进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体。
\2. 进程的作用
每次用户通过 shell 输入一个可执行目标文件的名字,运行程序时,shell 就会 创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用 程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其 他应用程序。 进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占地使 用处理器;一个私有的地址空间,如同程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
shell概述 | 交互型应用级程序,代表用户运行其他程序。是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行 |
---|---|
功能 | Shell最重要的功能是命令解释,从这种意义上说,Shell是一个命令解释器。Linux系统上的所有可执行文件都可以作为Shell命令来执行。当用户提交了一个命令后,Shell首先判断它是否为内置命令,如果是就通过Shell内部的解释器将其解释为系统功能调用并转交给内核执行;若是外部命令或实用程序就试图在硬盘中查找该命令并将其调入内存,再将其解释为系统功能调用并转交给内核执行。在查找该命令时分为两种情况:(1)用户给出了命令的路径,Shell就沿着用户给出的路径进行查找,若找到则调入内存,若没找到则输出提示信息;(2)用户没有给出命令的路径,Shell就在环境变量PATH所制定的路径中依次进行查找,若找到则调入内存,若没找到则输出提示信息。 |
处理流程 | shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。 |
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程,对于函数int fork(void);子进程中,fork返回0;父进程中,返回子进程的PID;新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程虚拟地址空间相同的但是独立的一份副本,子进程获得与父进程任何打开文件描述符相同的副本,最大区别是子进程有不同于父进程的PID,但是二者的组PID是一样的。
对于进程的回收,终止的子进程可由父进程回收,如果父进程终止而子进程未终止,就会调用祖先进程init回收子进程。可以通过调用waitpid或其简化版本wait来回收进程
6.4 Hello的execve过程
通过调用 fork 创建新的子进程之后,子进程会调用 execve 函数,在当前进程的上下文中加载并运行一个新程序 hello,并且带参数和环境变量。execve 函数从不返回,它将删除该进程的代码和地址空间内的内容并将其初始化,然后通过跳转到程序的第一条指令或入口点来运行该程序。将私有的区域映射进来,例如打开的文件,代码、数据段,然后将公共的区域映射进来。后面加载器跳转到程序的入口点,即设置 PC 指向_start 地址。_start 函数最终调用 hello 中的 main 函数,这样,便完成了在子进程中的加载
6.5 Hello的进程执行
6.51用户模式和内核模式
为了保证系统安全,处理器需要限制应用程序所能访问的地址空间范围。因而存在用户态与核心态的划分,内核态拥有最高的访问权限,而用户态的访问权限会受到一些限制。处理器使用一个寄存器作为模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中。
6.52 上下文切换
内核通过使用一种称为上下文切换的较高层次形式的异常控制流来实现多任务。在内核调度了一个新的进程运行时,它就抢占当前进程,并使用上下文切换来控制转移到新的进程。具体过程为:保存当前进程的上下文;恢复某个先前被抢占的进程被保存的上下文;将控制传递给这个新恢复的进程。
6.53 分析
在hello的运行过程中,如果是正常执行,则处在用户模式。当调用sleep函数后触发异常,通过上下文切换,过渡到内核模式。当sleep函数的计时结束之后,从内核态切换到用户态,继续执行。
6.54 hello进程执行示意图
Figure 26 hello的进程调度
6.6 hello的异常与信号处理
hello的执行过程中会出现的异常如下:
6.61 中断异常
在运行hello是可以从IO设备输入一些信号,例如Ctrl-Z,Ctrl-C等,这些异常属于异步异常
Figure 27 中断
以下为中断异常对应的信号及截屏
6.611 Ctrl-C
发送SIGINT信号,终止程序
程序确实被终止了
6.612 Ctrl-C
发送SIGSTOP,暂停程序,直到遇到SIGCONT信号
程序暂停
利用fg将暂停的程序放到前台运行
Ps命令结果
Pstree指令
不停乱按
在程序执行过程中乱按所造成的输入均缓存到 stdin,当随hello结果一起输出
Kill指令
发送信号
6.62 陷阱异常
陷阱异常是有意的异常,是执行一条指令的结果。陷阱处理程序将控制返回到下一条指令,在用户程序和内核之间提供了一个像过程一样的接口,即系统调用。
例如exit函数,sleep函数。
Figure 28 陷阱
6.7本章小结
本章主要阐述了 hello 的进程管理,包括进程创建、加载、执行以至终止的全 过程,并分析了执行过程中的异常、信号及其处理。在 hello 程序运行的过程中, 内核对其进行进程管理,决定何时进行进程调度,在接收到不同的异常、信号时, 还要及时地进行对应的处理。美中不足的是,没有对信号的阻塞作出介绍。
**
**
第7章 hello的存储管理
7.1 hello的存储器地址空间
\1. 逻辑地址 (Logical Address)
是指由程序产生的与段相关的偏移地址部分,是相对应用程序而言的,如 hello.o 中代码与数据的相对偏移地址。 即段地址和偏移量
\2. 线性地址(Linear Address)
CPU在保护模式下,“段基址+段内偏移地址”叫做线性地址,注意,保护模式下段基址寄存器中存储的不是真正的段基值(和实模式的含义不一样),而是被称为“段描述符”的东西,通过段选择子在GDT(全局描述表)或LDT中找到真正的段基值。另外,如果CPU在保护模式下没有开启分页功能,则线性地址就被当做最终的物理地址来用,若开启了分页功能,则线性地址就叫虚拟地址(在没开启分页功能的情况下线性地址和虚拟地址就是一回事)。但是,如果开启分页功能,虚拟地址(或线性地址)还要通过页部件电路转换成最终的物理地址。
\3. 虚拟地址(Virtual Address)
又称线性地址。
\4. 物理地址(Physical Address)
是指出现在 CPU 外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址(hello 程序运行时代码、数据等对应 的可用于直接在内存中寻址的地址)。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。否则的话线性地址就是物理地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel 平台下,逻辑地址(logical address)是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。这个过程称作段式内存管理。
一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
Figure 29 段描述
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。
Figure 30 线性地址的获取
7.3 Hello的线性地址到物理地址的变换-页式管理
通过分页机制实现线性地址(书里的虚拟地址VA)到物理地址(PA)之间的转换
IA32页式管理如下,一个32位的地址在两级页表中分为三个部分,10位的页目录索引,10位的页表索引和12位的页内偏移量。
Figure 31 IA32页式管理
Core i7的虚拟地址到线性地址的页式管理如下所示
每个进程都有一个页表。本例中的hello进程执行时,CPU中的页表基址寄存器指向hello进程的页表,当hello进程访问一个虚拟(线性)地址时,这个48位的线性地址包含两部分:一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号。MMU(内存管理单元)利用虚拟页号来选择适当的PTE(首先查快表),若PTE有效位为1,则说明其后内容为物理页号,否则缺页。而物理地址中低p位的物理页偏移量与虚拟页偏移量相同,PPN与PPO连接即得物理地址
Figure 32 Core i7页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
TLB即快表,每次 CPU 产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个 PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果 PTE 碰巧缓存在 L1 中,那么开销就会下降到 1 或 2 个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU 中包括了一个关于 PTE 的小的缓存。
为了减少页表所占空间,提出了多级页表概念,多级页表为层次结构,用于压缩页表。这种方法从两个方面减少了内存要求。第一,如果一级页表中的一个 PTE 是空的,那么相应的二级页表就根本不会存在;第二,只有一级页表才需要总是在主存中,虚拟内存系统可以在需要时创建、页面调出或调入二级页表,最经常使用的二级页表才缓存在主存中,减少了主存的压力。
其变换如下
Figure 33 K级页表的翻译
CPU产生虚拟地址VA,并将其传送至MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)在TLB中进行匹配,若命中,则得到PPN与VPO组合成物理地址PA(52bit)。若TLB没有命中,则MMU向页表中查询,由CR3确定第一级页表的起始地址
Figure 34 Core i7的页表翻译
7.5 三级Cache支持下的物理内存访问
下图为通用的高速缓存存储器(Cache)组织结构示意图:
Figure 35 高速缓存
假设系统的存储器地址共有m位,形成M = 2m 个不同的地址。该机器的高速缓存被组织成一个有S = 2m个高速缓存组,每组包含E个高速缓存行,每行包含B = 2b个字节组成。
一般来说,系统选择使用物理地址来访问cache,下图展示了高速缓存支持下的内存访问。处理器将虚拟地址发送给 MMU,MMU 使用PTE地址获取PTE,如果命中,MMU可产生PA,利用PA在cache里面查数据,如果遇到不命中,通过异常从内存里面取出相应的项。
Figure 36 附带高速缓存
下图是真实的三级cache下的物理内存访问
Figure 37 三级cache下的物理内存访问
7.6 hello进程fork时的内存映射
当 fork 函数被父进程(shell)调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID。为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将两个 进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有 的写时复制。 当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的 虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创 建新页面,因此,也就为每个进程保持了私有空间地址的抽象概念
7.7 hello进程execve时的内存映射
execve 函数加载并运行 hello 需要以下几个步骤:
\1. 删除已存在的用户区域
删除当前进程 hello 虚拟地址的用户部分中的已存在的区域结构。
\2. 映射私有区域
为新程序的代码、数据、bss 和栈区域创建新的私有的、写时复制的区域
结构。其中,代码和数据区域被映射为 hello 文件中的.text 和.data 区。bss
区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中。栈和
堆区域也是请求二进制零的,初始长度为零。
\3. 映射共享区域
若 hello 程序与共享对象或目标(如标准 C 库 libc.so)链接,则将这些对
象动态链接到 hello 程序,然后再映射到用户虚拟地址空间中的共享区域
内。
\4. 设置程序计数器
execve 设置当前进程上下文中的程序计数器,使之指向代码区域的
入口点。
7.8 缺页故障与缺页中断处理
缺页异常是由硬件和操作系统共同完成的,具体的步骤如下:
\1. 处理器生成一个虚拟地址,并将它传送给MMU
\2. MMU生成PTE地址,并从高速缓存/主存请求得到它
\3. 高速缓存/主存向MMU返回PTE
\4. PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
\5. 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
\6. 缺页处理程序页面调入新的页面,并更新内存中的PTE
\7. 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-leLFeM65-1652888832133)(大作业/clip_image117.png)]
Figure 38缺页异常
7.9动态存储分配管理
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
具体而言,分配器分为两种基本风格:显式分配器、隐式分配器。显式分配器:要求应用显式地释放任何已分配的块。隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集
动态内存管理的基本方法与策略介绍如下:
l 隐式链表
堆中的空闲块通过头部中的大小字段隐含地连接,分配器通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
对于隐式链表,其结构如下:
Figure 39 malloc函数
l 显式链表
在每个空闲块中,都包含一个前驱(pred)与后继(succ)指针,从而减少了搜索与适配的时间,结构如下:
Figure 40 显示链表
l 带边界标记的合并
采取使用边界标记的堆块的格式,在堆块的末尾为其添加一个脚部,其为头部的副本。添加脚部之后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。从而实现快速合并,减小性能消耗。典型的情况如下:
Figure 41 带边界标记的合并
7.10本章小结
本章主要介绍了 hello 的存储器地址空间、几种地址的区别、intel 的段式管理、hello 的页式管理, VA 到 PA 的变换、物理内存访问,hello 进程在fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux将所有的IO设备都模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件。此外,每个进程开始时都有三个打开的文件:stdin,stdout,stderr。
8.2 简述Unix IO接口及其函数
8.21 Unix IO的接口
l 打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负数,叫做描述符,它在后续对此文件的操作中标识这个文件,内核记录有关这个打开文件的所有信息,应用程序只需要记住这个描述符改变当前文件的位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。
l 改变当前文件位置
对于每一个打开的文件,内核保持一个文件位置k,初试为0,这个文件位置就是文件从头开始的字节偏移量,应用程序可以通过执行seek操作显示的设置文件的当前位置为k。
l Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h> 定义了常量STDIN_FILENO、STOOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
l 读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m的字节文件,当k>=m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件,在文件结尾处并没有明确的EOF符号。类似的,写操作就是从内存复制n个字节到一个文件。从当前位置k开始,然后更新k。
l 关闭文件
当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有的打开的文件,并释放内存资源。
8.22 函数
l write()函数
ssize_t write(int fd, const void* buf, size_t ntyes); 若写入成功则返回写入的字节数;失败返回-1. buf为写入内容的缓冲区,ntyes为期待写入的字节数,通常为sizeof(buf)。一般情况下返回值与ntypes相等,否则写入失败。
l read()函数
ssize_t read(int fd, void *buf, size_t nbytes); 若读取成功,读到文件末尾返回0,未读到文件末尾返回当前读的字节数。若读取失败,返回-1。fd为要读取文件的文件描述符。buf为读取文件数据缓冲区,nbytes为期待读取的字节数,通常为sizeof(buf)。
l open()函数
int open(const char* path, int oflag, …/mode_t mode/); 若文件打开失败返回-1,打开失败原因可以通过errno或者strerror(errno)查看;若成功将返回最小的未用的文件描述符的值。path为要打开的文件的文件路径oflag为文件打开模式.…为可变参数,可以视情况添加
l create()函数
int create(const char *path, mode_t mode); 若文件创建失败返回-1;若创建成功返回当前创建文件的文件描述符。
8.3 printf的实现分析
通过分析printf函数的定义可以看出,这个函数调用了vsprintf和write函数
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;
}
通过分析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’:
itoa(tmp, ((int)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case ‘s’:
break;
default:
break;
}
}
return (p - buf);
}
接着通过write函数,把buf中的i个元素的值写到终端,下面简要分析write函数的一部分汇编代码
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
INT_VECTOR_SYS_CALL就是一个系统调用,而ecx中是要打印出的元素个数, ebx中的是要打印的buf字符数组中的第一个元素。总的来说,这个函数的功能就是不断的打印出字符,直到遇到:‘\0’。
8.4 getchar的实现分析
getchar()函数实际上是int getchar(void),所以它返回的是ASCII码,所以只要是ASCII码表里有的字符它都能读取出来。在调用getchar()函数时,编译器会依次读取用户键入缓存区的一个字符(注意这里只读取一个字符,如果缓存区有多个字符,那么将会读取上一次被读取字符的下一个字符),如果缓存区没有用户键入的字符,那么编译器会等待用户键入并回车后再执行下一步 (注意键入后的回车键也算一个字符,输出时直接换行)。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ASCII码,直到接受到回车键才返回。
Getchar函数 的源代码如下
#include “libioP.h”
#include “stdio.h”
#undef getchar
int
getchar (void)
{
int result;
if (!_IO_need_lock (stdin))
return _IO_getc_unlocked (stdin);
_IO_acquire_lock (stdin);
result = _IO_getc_unlocked (stdin);
_IO_release_lock (stdin);
return result;
}
#ifndef _IO_MTSAFE_IO
#undef getchar_unlocked
weak_alias (getchar, getchar_unlocked)
#endif
(以下格式自行编排,编辑时删除)
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了UNIX的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。。Unix I/O是一个抽象,把一切输入输出都归结为对文件的操作,而且这一抽象是十分成功的,因为I/O的过程,在本质上就是一个对信息交换的过程。
结论
一个hello.c源程序,经历了预处理、编译、汇编、链接,生成一个可执行文件,每一步都有很多底层的原理需要实现。当生成一个可执行文件之后,程序的执行又要经历一番过程。需要调用fork和execve函数,在程序执行过程中,还有终端,陷阱等异常,程序在内存中,又涉及到存储管理,虚拟内存等概念。等到程序打印出一个hello,又会牵涉到IO管理。总而言之,一个小小的hello程序,从源代码到执行经历了如此多的波折,仅解释过程就需要60页的文档,属实不易。以前只顾着学习算法,编出一些高效能运行的代码就觉得可以了,并没有注意到底层的原理,现在才知道计算机系统的意义所在。我们学的书叫深入理解计算机系统,到此才体会到何为“深入”。
hello的生命历程如下(按照顺序排列):
\1. 预处理
将 hello.c 中 include 的所有外部的头文件头文件内容直接插入程序文本中,
完成字符串的替换和条件编译,以及注释的消除,生成的文件方便后续处理。
\2. 编译
编译器通过词法分析和语法分析,将C语言代码翻译成等价汇编代码。通过编译过程,编译器将 hello.i 翻译成汇编语言文件 hello.s;
\3. 汇编
将 hello.s 汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目
标程序格式,最终结果保存在 hello.o 目标文件中;
\4. 链接
通过链接器ld,将 hello 的程序编码与动态链接库等收集整理成为一个单一
文件,生成完全链接的可执行的目标文件 hello;
\5. 加载运行
在 Shell,程序中fork 出一个子进程,然后通过 execve 把代码和数据加载入虚拟内存空间,程序开始执行;
\6. 执行指令
在该进程被调度时,CPU 为 hello 其分配时间片,在一个时间片中,hello
享有 CPU 全部资源,PC 寄存器一步一步地更新,CPU 不断地取指,顺序执
行自己的控制逻辑流;
\7. 访存
内存管理单元 MMU 将逻辑地址,一步步映射成物理地址,进而
访问物理内存/磁盘中的数据;
\8. 信号处理
进程时刻等待着信号,如果运行途中键入 Ctrl-C 、Ctrl-Z 则向程序发出信号,程序接收信号之后作出相应的回应。也可以使用kill指令发送信号。
\10. 终止并被回收
Shell 父进程等待并回收 hello 子进程,内核删除为 hello 进程创建的所有数
据结构。
附件
名称 | 作用 |
---|---|
hello | 最终生成的可执行文件 |
hello.asm | 对hello.o反汇编生成的文件 |
hello.c | 源程序 |
hello.elf | 由hello.o生成的elf文件 |
hello.elf2 | 由hello生成的elf文件 |
hello.i | hello.c预编译得到的文件 |
hello.o | 汇编生成的可重定位目标文件 |
hello.s | 由hello.c编译生成的汇编语言代码 |
hello2.asm | 由hello反汇编生成的代码 |
test.txt | 测试用例 |
**
**
参考文献
深入理解****为完成本次大作业你翻阅的书籍与网站等
[1] 深入理解计算机系统 第三版
[2] 计算机系统基础第二版
[3] https://www.cnblogs.com/pianist/p/3315801.html
[4] http://c.biancheng.net/view/8189.html
[5] https://blog.csdn.net/qq_41627703/article/details/122108425
[6] https://blog.csdn.net/weixin_51633776/article/details/122058439
[7] https://blog.csdn.net/bing_Max/article/details/120218231
[8] https://zhuanlan.zhihu.com/p/358951580