计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
计算机科学与技术学院
2021年5月
本文以hello程序从hello.c到进程各个阶段所经历的过程为背景,来深入分析理解现代计算机系统中软硬件的工作原理。通过对这些过程的理解,深入了解了汇编、编译流程、操作系统资源管理的相关知识。
关键词:预处理;编译;汇编;链接;elf;进程;三级缓存;I/O
目 录
第1章 概述
1.1 Hello简介
Hello几乎是每个编程初学者接触的第一个程序,虽然程序只有短短几行,虽然仅仅是在屏幕上简单地输出一条消息。但是和所有的程序一样,Hello也经历了P2P(From Program to Process),O2O(From Zero-0 to Zero-0)。
1、P2P(From Program to Process)
Program:指的是源程序hello.c,
Process:这里是指执行中的hello程序实例。
从Program到Process中间经历了预处理、编译、汇编、链接,最后通过bash-shell启动5个过程。
- O2O(From Zero-0 to Zero-0)
Hello运行时先fork进程,然后通过execve加载hello程序运行,这期间会为hello分配内存资源,同时调度器为其分配CPU资源。执行完后由操作系统回收资源,不留下痕迹。
1.2 环境与工具
1.2.1 硬件环境
联想拯救者Y9000X 2021
1.2.2 软件环境
Ubuntu 20.04
1.2.3 开发与调试工具
Visual Studio Code
GCC
GDB
EDB
1.3 中间结果
hello.i : 预编译结果,用于编译。
hello.s : 编译结果,用于汇编。
hello.o : 目标文件,用于连接器生成最终的可执行程序。
hello:可执行文件。
hello.txt:hello反汇编后的输出,用于分析hello。
hello.o.txt:hello.o反汇编后的输出,用于分析hello.o。
1.4 本章小结
本章介绍了Hello的P2P、020过程。Hello的开发与调试工具,运行的软硬件环境,以及生成的中间文件。
第2章 预处理
2.1 预处理的概念与作用
C语言代码在交给编译器之前,由预处理器进行的一些文本替换、删除方面的操作。主要作用是:
- 处理#include,将源代码文件的头文件包含进对应位置。
- 删除#define,展开宏定义。
- 处理条件编译 如#if #ifdef。
- 删除所有注释。
- 添加行号和文件标识符。
其中:
“-E”用于指定gcc只进行预处理。
“-o”用于指定输出的预处理文件。
2.3 Hello的预处理结果解析
2.行号和文件标识符
预处理后的文件,会添加源文件的行号以及文件名,用于定位编译错误、警告的位置。
如图所示,预处理后的文件中main函数前中包含了源文件中main函数的行号,main函数在hello.c中第10行。
3.注释删除
默认情况下,预处理操作会删除注释(包含文件中的注释一样会删除),可以看到,原来hello.c中的注释已经不见了。
若须保留注释。可以在预处理时添加-C参数:
可以看到添加-C参数后,预处理后的结果保留了注释。
4.头文件包含
预处理阶段会递归添加头文件,
例如:源文件中包含头文件stdio.h。stdio.h部分内容如图所示,可以看到stdio.h中的内容被包含到了结果中。
stdio.h中包含的头文件同样会被包含到输出文件中。
5.宏定义展开以及条件编译
hello.c中不存在宏定义,但是可以看到stdio.h存在宏定义“#define stdin stdin”,输出文件中宏定义被展开,原来的宏定义被去掉,其它地方同理。
stdio.h中的条件编译“#ifndef __USE_FILE_OFFSET64”,被展开,其它地方同理。
2.4 本章小结
本章主要是对预处理的概念、作用介绍。介绍gcc预处理的使用方法,以及使用gcc预处理前后的详细对比。
第3章 编译
3.1 编译的概念与作用
编译是指将预处理后的文件生成目标平台的汇编语言程序的过程。
具体作用:
- 扫描(词法分析)
- 语法分析
- 语义分析
- 源代码优化(中间语言生成)
- 代码生成,目标代码优化
3.2 在Ubuntu下编译的命令
其中
“-S”用于指定gcc只进行编译。
“-m64”用于指定编译为64位汇编程序。
“-o”用于指定输出的文件。
3.3 Hello的编译结果解析
3.3.1 函数操作
函数调用约定
在 x86_64的Linux只有一种调用约定。
当参数在 6 个以内,参数从左到右依次放入寄存器: rdi、rsi、rdx、rcx、r8、r9。
当参数大于 6个, 大于六个的部分的依次从右向左压入栈中,
1、printf函数调用
(1)printf只有一个参数且有行末换行的情况下,
printf("用法: Hello 学号 姓名 秒数!\n");
实际上汇编中调用的是puts函数,参数用rdi传递,然后使用call指令调用函数
leaq .LC0(%rip), %rdi
call puts@PLT
(2)printf有多个参数的情况下,
printf("Hello %s %s\n", argv[1], argv[2]);
汇编中调用的是printf函数,用rdi传递format,用rsi传递argv[1],用rdx传递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
leaq .LC1(%rip), %rdi
movl $0, %eax
call printf@PLT
2、exit以及后面的sleep、atoi函数调用逻辑同理,都是用rdi(edi)传递参数,然后用call指令调用。
movl $1, %edi
call exit@PLT
...
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
movq %rax, %rdi
call atoi@PLT
movl %eax, %edi
call sleep@PLT
函数返回
返回前将返回值放入eax中,然后调用ret指令返回。
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
3.3.2 变量
hello.c中涉及的变量有函数参数argc,argv以及int变量i。
1、函数参数
int main(int argc, char *argv[])
根据3.3.1的调用规则,main中的argc采用rdi传递(由于argc为int类型,只需要rdi的低32位,即edi),argv采用rsi传递,
在main中先将argc,argv保存到栈,需要使用时再从栈中装载到寄存器。
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
2、int变量
int i;
if (argc != 4)
{
printf("用法: Hello 学号 姓名 秒数!\n");
exit(1);
}
for (i = 0; i < 8; i++)
这里的int变量i是保存到栈中,后续对i的运算都是直接对栈内存中的数据直接运算(当然这取决于编译器的优化,有些时候,编译器会将值先装载到寄存器中,例如argv)。
由于i在定义的时候没有赋初值。因此,在汇编中直到for循环中给i赋值时才首次出现对i的赋值操作。
movl $0, -4(%rbp)
3.3.3 常量
- 字符串常量
字符串常量采用.string伪指令定义在rodata段,常量区rodata段存放的是只读数据,比如字符串常量、全局const变量。常量区在程序中大小确定;在进程中内存只读。
.section .rodata
.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"
.LC1:
.string "Hello %s %s\n"
2、立即数常量
并不是所有的常量都会被保存到rodata段,存在一些特殊情况:
有些立即数会直接编码到指令里,位于代码段。重复的字符串常量会合并,程序中只保留一份。
例如argc != 4中的常量“4”被编码到指令中,
cmpl $4, -20(%rbp)
for (i = 0; i < 8; i++)中的常量“8”被编码到指令中,在汇编程序中用“i≤7”等价代替“i<8”
cmpl $7, -4(%rbp)
jle .L4
argv[3]中数组下标的立即数同样是被编码到指令中,其中指针argv的值被取出保存在 rax中,因此argv[3]即为rax+3*8的内存地址所在的值。argv[1],argv[2]也是用同样的方式被编码到指令
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
return 0;
return 0中的返回值0,直接编码到指令中
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
3.3.4 赋值
for (i = 0; i < 8; i++)
根据前面的分析,变量i是存放在栈中-4(%rbp),对变量i的赋值是直接使用mov指令进行赋值。
movl $0, -4(%rbp)
3.3.5 关系操作
hello中涉及的关系操作有“<”、“!=”。
if (argc != 4)
...
for (i = 0; i < 8; i++)
编译器对于关系操作的处理,是使用cmp指令比较大小,然后根据具体关系和对应的处理进行跳转,对于预处理文件中的 “i<8”,编译器实际上是采用“i≤7”等价代替。
cmpl $4, -20(%rbp)
je .L2
...
cmpl $7, -4(%rbp)
jle .L4
3.3.6 算术操作
for (i = 0; i < 8; i++)
hello中涉及的算术操作有“++”,在这里是采用add指令处理“++”。
addl $1, -4(%rbp)
3.3.7 数组操作
对于从数组中取值,先计算待取元素的地址,具体是通过数组首地址加上待取元素的偏移得到,然后通过mov指令将元素装载到寄存器。
例如hello汇编程序中取出argv[1], argv[2]的操作如下,其中-32(%rbp)保存的是数组argv的地址
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
leaq .LC1(%rip), %rdi
movl $0, %eax
3.3.8 控制转移
hello中涉及的控制转移语句有“if”,“for”。
1、if语句
if语句的实现是采用cmp指令比较,然后跳转到条件满足的地方,例如
int i;
if (argc != 4)
{
printf("用法: Hello 学号 姓名 秒数!\n");
exit(1);
}
汇编中实际的处理是先比较argc与4,相等则跳到if代码块后面,
cmpl $4, -20(%rbp)
je .L2
leaq .LC0(%rip), %rdi
call puts@PLT
movl $1, %edi
call exit@PLT
.L2:
2、for语句
for (i = 0; i < 8; i++)
{
printf("Hello %s %s\n", argv[1], argv[2]);
sleep(atoi(argv[3]));
}
for语句是先对迭代变量i赋初值,然后跳转到判断循环条件的地方(.L3)。
.L2:
movl $0, -4(%rbp)
jmp .L3
判断循环采用3.3.5介绍的关系操作<,满足循环条件跳转到循环体(.L4)。
.L3:
cmpl $7, -4(%rbp)
jle .L4
循环体结束的时候,将迭代变量进行3.3.6介绍的算术操作++。
.L4:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
leaq .LC1(%rip), %rdi
movl $0, %eax
call printf@PLT
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
movq %rax, %rdi
call atoi@PLT
movl %eax, %edi
call sleep@PLT
addl $1, -4(%rbp)
3.4 本章小结
本章主要是简单介绍gcc编译的概念过程、作用,以及怎么用gcc对预处理后的文件进行编译。然后着重介绍了函数调用约定,gcc编译时对常量、变量、控制语句、赋值语句、算术操作、关系操作、数组等的处理。
第4章 汇编
4.1 汇编的概念与作用
汇编器是指将汇编代码转变成机器可以执行的指令的过程。
作用:生成目标文件(执行的指令)。
4.2 在Ubuntu下汇编的命令
其中
“-c”用于指定gcc汇编选项,实际上是调用汇编器进行汇编
因此可以直接使用“as hello.s -o hello.o”编译
4.3 可重定位目标elf格式
4.3.1 elf文件头
使用readelf -h hello.o可以查看elf文件头具体信息。
4.3.2 elf节头
使用“readelf -S hello.o”可以查看各节的基本信息。
- .text:代码段,
默认情况下,代码都是放到这个段中的。
- .rela.text:可重定位表,
保存的是.text节中需要被修正的位置信息;
- 任何调用外部函数的指令都需要重定位;
- 任何引用全局变量的指令都需要重定位;
使用命令readelf -r hello.o可以查看重定位表。
3、.data:保存的是已初始化的全局变量和静态C变量
4、.bss:保存的是未初始化的静态变量及初始化为0的全局变量和静态变量
5、.rodata:ro代表read only,即只读数据(const),根据3.3.3的分析,常量不一定就放在rodata里,有的立即数直接编码在指令里,存放在代码段(.text)中,对于字符串常量,编译器会自动去掉重复的字符串,保证一个字符串在一个目标文件中只存在一份拷贝。rodata是在多个进程间是共享的,这可以提高空间利用率。
6、.comment:注释信息段
7、.note.GNU-stack:堆栈提示段
8、.note.gnu.propert:属性提示段
9、.eh_frame:它生成描述如何unwind堆栈的表
10、.rela.eh_frame:.eh_frame的重定位信息
11、.symtab:符号表,保存了程序中的所有符号
使用命令readelf -s --syms hello.o可以查看符号表
12、.strtab:字符串表,保存了程序中的字符串,以及符号,如变量名,函数名,文件名等。
13、.shstrtab:保存了各个section的名字。
4.4 Hello.o的结果解析
使用命令“objdump -d -r hello.o”对hello.o进行反汇编。
其中-r用于指定输出重定位位置。
反汇编的结果即机器语言为汇编语言的映射关系。
机器语言一般由操作码、寄存器编号、立即数构成。同一个指令,操作数类型不同,比如立即数或者寄存器,操作码是不同的。
例如,mov指令部分操作码:
mnemonic | op1 | op2 | po | flds |
MOV | Eb | Gb | 88 | dw |
MOV | Ev | Gv | 89 | dW |
MOV | Gb | Eb | 8A | Dw |
MOV | Gv | Ev | 8B | DW |
MOV | Mw | Sw | 8C | d |
MOV | Rv | Sw | ||
MOV | Sw | Ew | 8E | D |
MOV | AL | Ob | A0 | w |
MOV | eAX | Ov | A1 | W |
MOV | Ob | AL | A2 | w |
MOV | Ov | eAX | A3 | W |
MOV | Zb | Ib | B0 | +r |
MOV | Zv | Iv | B8 | +r |
MOV | Eb | Ib | C6 | w |
MOV | Ev | Iv | C7 | W |
以反汇编中mov指令为例:
根据上表,操作码b8,第一个操作数(Zv),是指由操作码低3位指定一个通用寄存器,第二个操作数(Iv),表示立即操作数,寄存器eax、ecx、edx、ebx、esp、ebp、esi、edi。它们的内部编号分别为0到7。
...
25: bf 01 00 00 00 mov $0x1,%edi
...
58: b8 00 00 00 00 mov $0x0,%eax
...
bf 01 00 00 00中bf的低3位为7,表示,寄存器为edi,后面紧跟的是立即数(小端,32位),因此bf 01 00 00 00表示mov $0x1,%edi。
b8 01 00 00 00中b8的低3位为0,表示,寄存器为eax,后面紧跟的是立即数(小端,32位),因此b8 00 00 00 00表示mov $0x0,%eax。
4.4.1 伪指令
反汇编
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 20 sub $0x20,%rsp
c: 89 7d ec mov %edi,-0x14(%rbp)
f: 48 89 75 e0 mov %rsi,-0x20(%rbp)
13: 83 7d ec 04 cmpl $0x4,-0x14(%rbp)
17: 74 16 je 2f <main+0x2f>
19: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 20 <main+0x20>
1c: R_X86_64_PC32 .rodata-0x4
20: e8 00 00 00 00 callq 25 <main+0x25>
21: R_X86_64_PLT32 puts-0x4
25: bf 01 00 00 00 mov $0x1,%edi
2a: e8 00 00 00 00 callq 2f <main+0x2f>
2b: R_X86_64_PLT32 exit-0x4
2f: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
36: eb 48 jmp 80 <main+0x80>
38: 48 8b 45 e0 mov -0x20(%rbp),%rax
3c: 48 83 c0 10 add $0x10,%rax
40: 48 8b 10 mov (%rax),%rdx
43: 48 8b 45 e0 mov -0x20(%rbp),%rax
47: 48 83 c0 08 add $0x8,%rax
4b: 48 8b 00 mov (%rax),%rax
4e: 48 89 c6 mov %rax,%rsi
51: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 58 <main+0x58>
54: R_X86_64_PC32 .rodata+0x22
58: b8 00 00 00 00 mov $0x0,%eax
5d: e8 00 00 00 00 callq 62 <main+0x62>
5e: R_X86_64_PLT32 printf-0x4
62: 48 8b 45 e0 mov -0x20(%rbp),%rax
66: 48 83 c0 18 add $0x18,%rax
6a: 48 8b 00 mov (%rax),%rax
6d: 48 89 c7 mov %rax,%rdi
70: e8 00 00 00 00 callq 75 <main+0x75>
71: R_X86_64_PLT32 atoi-0x4
75: 89 c7 mov %eax,%edi
77: e8 00 00 00 00 callq 7c <main+0x7c>
78: R_X86_64_PLT32 sleep-0x4
7c: 83 45 fc 01 addl $0x1,-0x4(%rbp)
80: 83 7d fc 07 cmpl $0x7,-0x4(%rbp)
84: 7e b2 jle 38 <main+0x38>
86: e8 00 00 00 00 callq 8b <main+0x8b>
87: R_X86_64_PLT32 getchar-0x4
8b: b8 00 00 00 00 mov $0x0,%eax
90: c9 leaveq
91: c3 retq
hello.s
...
main:
.LFB6:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $32, %rsp
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
cmpl $4, -20(%rbp)
je .L2
leaq .LC0(%rip), %rdi
call puts@PLT
movl $1, %edi
call exit@PLT
.L2:
movl $0, -4(%rbp)
jmp .L3
.L4:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
leaq .LC1(%rip), %rdi
movl $0, %eax
call printf@PLT
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
movq %rax, %rdi
call atoi@PLT
movl %eax, %edi
call sleep@PLT
addl $1, -4(%rbp)
.L3:
cmpl $7, -4(%rbp)
jle .L4
call getchar@PLT
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
可以看到hello.s中cfi开头的指令在反汇编的结果中没有出现。
CFI代表调用帧信息,这是编译器描述函数中发生的事情的方式。调试器可以用它来显示栈调用,连接器可以合成异常日志,用于堆栈深度分析或者别的事情。并不会被汇编为实际的机器指令。
例如.cfi_startproc,.cfi_endproc:
.cfi_startproc 用在每个函数的开始,用于初始化一些内部数据结构;
.cfi_endproc 在函数结束的时候使用与.cfi_startproc相配套使用。
4.4.2 分支转移
反汇编结果中跳转指令都使用相对地址。
...
17: 74 16 je 2f <main+0x2f>
...
36: eb 48 jmp 80 <main+0x80>
...
84: 7e b2 jle 38 <main+0x38>
...
在hello.s中。使用的是Label。Label只是在汇编语言中便于编写的助记符,在汇编时就能确定。
...
je .L2
...
jmp .L3
...
jle .L4
...
4.4.3 函数调用
在hello.s中,函数调用都是直接“call 函数名称”。
...
call puts@PLT
movl $1, %edi
call exit@PLT
...
call printf@PLT
...
call atoi@PLT
movl %eax, %edi
call sleep@PLT
...
call getchar@PLT
...
而在反汇编结果中,如果被调用函数没有在hello.s中定义,调用都变成了“callq 下一条指令的地址”。
...
20: e8 00 00 00 00 callq 25 <main+0x25>
25: bf 01 00 00 00 mov $0x1,%edi
2a: e8 00 00 00 00 callq 2f <main+0x2f>
...
5d: e8 00 00 00 00 callq 62 <main+0x62>
...
70: e8 00 00 00 00 callq 75 <main+0x75>
75: 89 c7 mov %eax,%edi
77: e8 00 00 00 00 callq 7c <main+0x7c>
...
86: e8 00 00 00 00 callq 8b <main+0x8b>
...
这是由于hello.s中调用的函数需要链接后才能确定函数的“地址”,在汇编时,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rel.text节中为其添加重定位条目,等待链接时再进行替换。
4.5 本章小结
本章介绍了汇编的概念、作用与用法、elf可重定位目标格式。并对反汇编与汇编源码进行类深入对照分析。
第5章 链接
5.1 链接的概念与作用
链接是指使用链接器将目标文件与库文件、启动文件等链接起来生成可执行文件的过程。
作用:生成可执行文件。
5.2 在Ubuntu下链接的命令
其中
“-lc” 表示链接libc
crt1.o里面包含了程序的入口函数_start,由它负责调用__libc_start_main初始化libc并且调用main函数进入真正的程序主体。
5.3 可执行目标文件hello的格式
5.3.1 elf文件头
使用readelf -h hello可以查看elf文件头具体信息,可以看到hello的类型为可执行文件。
用“readelf -l hello”可以看到其各段的基本信息,包括各段的起始地址,大小等信息。
5.4 hello的虚拟地址空间
只有类型为LOAD的段才是需要装入的。
由于页对齐原因,每个段的起始与终止地址十六进制低三位一定均为0,这与5.3中显示的VirtAddr不对应。
5.5 链接的重定位过程分析
使用命令“objdump -d -r hello”对hello进行反汇编。
其中-r用于指定输出重定位位置。
Disassembly of section .init:
...
Disassembly of section .plt:
...
Disassembly of section .text:
...
0000000000401125 <main>:
401125: f3 0f 1e fa endbr64
401129: 55 push %rbp
40112a: 48 89 e5 mov %rsp,%rbp
40112d: 48 83 ec 20 sub $0x20,%rsp
401131: 89 7d ec mov %edi,-0x14(%rbp)
401134: 48 89 75 e0 mov %rsi,-0x20(%rbp)
401138: 83 7d ec 04 cmpl $0x4,-0x14(%rbp)
40113c: 74 16 je 401154 <main+0x2f>
40113e: 48 8d 3d c3 0e 00 00 lea 0xec3(%rip),%rdi # 402008 <_IO_stdin_used+0x8>
401145: e8 46 ff ff ff callq 401090 <puts@plt>
40114a: bf 01 00 00 00 mov $0x1,%edi
40114f: e8 7c ff ff ff callq 4010d0 <exit@plt>
401154: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
40115b: eb 48 jmp 4011a5 <main+0x80>
40115d: 48 8b 45 e0 mov -0x20(%rbp),%rax
401161: 48 83 c0 10 add $0x10,%rax
401165: 48 8b 10 mov (%rax),%rdx
401168: 48 8b 45 e0 mov -0x20(%rbp),%rax
40116c: 48 83 c0 08 add $0x8,%rax
401170: 48 8b 00 mov (%rax),%rax
401173: 48 89 c6 mov %rax,%rsi
401176: 48 8d 3d b1 0e 00 00 lea 0xeb1(%rip),%rdi # 40202e <_IO_stdin_used+0x2e>
40117d: b8 00 00 00 00 mov $0x0,%eax
401182: e8 19 ff ff ff callq 4010a0 <printf@plt>
401187: 48 8b 45 e0 mov -0x20(%rbp),%rax
40118b: 48 83 c0 18 add $0x18,%rax
40118f: 48 8b 00 mov (%rax),%rax
401192: 48 89 c7 mov %rax,%rdi
401195: e8 26 ff ff ff callq 4010c0 <atoi@plt>
40119a: 89 c7 mov %eax,%edi
40119c: e8 3f ff ff ff callq 4010e0 <sleep@plt>
4011a1: 83 45 fc 01 addl $0x1,-0x4(%rbp)
4011a5: 83 7d fc 07 cmpl $0x7,-0x4(%rbp)
4011a9: 7e b2 jle 40115d <main+0x38>
4011ab: e8 00 ff ff ff callq 4010b0 <getchar@plt>
4011b0: b8 00 00 00 00 mov $0x0,%eax
4011b5: c9 leaveq
4011b6: c3 retq
4011b7: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1)
4011be: 00 00
...
Disassembly of section .fini:
...
可以看到hello.o反编译的结果只有.text段main函数,hello的反编译结果还包括了其它的段、其它的函数,例如:_init程序初始化代码、_fini 当程序正常终止时需要执行的代码。动态链接库中的函数已经加到plt中。原本调用函数的地址,都替换成了plt中的地址。
在链接的时候并不是简单的将目标文件进行合并,而是各个文件的相同段进行合并。具体过程分为两步:
第一步 空间与地址分配:扫描所有输入的目标文件,获得它们各个段的长度、属性和位置。并将目标文件中所有的符号收集起来放到全局符号表,计算输出文件各个段合并后的长度与位置。
第二步 符号解析与重定位:使用第一步收集到的信息,读取文件中段的数据、重定位信息,进行符号解析与重定位,调整代码中的地址。
在elf文件中的重定位表,记录了有关重定位的信息,如果.text中有要被重定位的地方,就会有对应的.rel.text,如果.data中有要被重定位的地方,就会有对应的.rel.data。根据4.4.3可知,在重定位前,编译器并不知道函数地址,而是采用0代替。重定位表中描述了如何修改相应段里的内容。
重定位表的定义
struct Elf64_Rel
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
};
r_offset:该字段指明重定位所作用的位置,即重定位入口的偏移;
对于重定位文件来说,该值是受重定位所作用的存储单元在节中的字节偏移量,即重定位入口所要修正的位置的第一个字节相对段起始的偏移;
对于可执行文件或共享目标文件来说,该值是受重定位所作用的存储单元的虚拟地址,即重定位入口所要修正的位置的第一个字节的虚拟地址;
r_info:该字段指明重定位所作用的符号表索引和重定位的类型;
其中,低8位表示重定位的入口类型,高24位表示重定位入口的符号在符号表中的下标。如果是一个函数需要重定位,则该字段的值就是被调函数所对应的符号表索引;
查看hello.o中的重定位表:
5.6 hello的执行流程
文件 | 函数 | 虚拟空间地址 |
ld-2.31.so | 动态链接器入口 | BASE1+0x1100 |
ld-2.31.so | _dl_init | BASE1+0x1df0 |
ld-2.31.so | _dl_start | BASE1+0x11c10 |
hello | _start | 0x4010f0 |
libc-2.31.so | __libc_start_main | BASE2+0x23fc0 |
libc-2.31.so | __cxa_atexit | BASE2+0x46e10 |
hello | __libc_csu_init | 0x4011c0 |
hello | init | 0x401000 |
hello | main | 0x401125 |
libc-2.31.so | exit | BASE2+0x46a70 |
5.6.1 动态链接器
程序执行一开始从0x7f0efc996100执行(每次运行随机的虚拟地址),
可以看到,此处是位于ld-2.31.so的.text段(ld-2.31.so每次运行的虚拟地址不是固定的),通过readelf -h ld-2.31.so查看ld-2.31.so的信息,ld-2.31.so入口点地址为0x1100(file Offset)。
对ld-2.31.so反汇编:
...
Disassembly of section .text:
0000000000001100 <_dl_rtld_di_serinfo@@GLIBC_PRIVATE-0x9f90>:
1100: 48 89 e7 mov %rsp,%rdi
1103: e8 e8 0c 00 00 callq 1df0 <_dl_catch_error@plt+0xd00>
1108: 49 89 c4 mov %rax,%r12
...
.text段的起始偏移为0x1000,故入口地址在.text中的偏移为0x100,.text在虚拟空间中的起始地址为0x7f0efc996000,故入口地址的虚拟地址为0x7f0efc996100。
ld-2.31.so的作用是用于加载动态库,此时可以看到libc-2.31.so已加载到虚拟空间,加载完接着call 0x4010f0。
5.6.2 hello执行流程
动态链接器加载完动态库会call 0x4010f0。
可以看到,0x4010f0位于hello的.text段。
对hello反汇编可以看到0x4010f0为hello中的_start函数。
...
Disassembly of section .text:
00000000004010f0 <_start>:
4010f0: f3 0f 1e fa endbr64
4010f4: 31 ed xor %ebp,%ebp
4010f6: 49 89 d1 mov %rdx,%r9
...
_start中接着会调用libc-2.31.so中的__libc_start_main。
...
0000000000023fc0 <__libc_start_main@@GLIBC_2.2.5>:
23fc0: f3 0f 1e fa endbr64
23fc4: 41 57 push %r15
23fc6: 31 c0 xor %eax,%eax
23fc8: 41 56 push %r14
...
在__libc_start_main中接着调用__cxa_atexit。
...
2400f: e8 fc 2d 02 00 callq 46e10 <__cxa_atexit@@GLIBC_2.2.5>
...
接着调用__libc_csu_init。
...
00000000004011c0 <__libc_csu_init>:
4011c0: f3 0f 1e fa endbr64
4011c4: 41 57 push %r15
4011c6: 4c 8d 3d 83 2c 00 00 lea 0x2c83(%rip),%r15 # 403e50
...
接着调用_init。
0000000000401000 <_init>:
401000: f3 0f 1e fa endbr64
401004: 48 83 ec 08 sub $0x8,%rsp
401008: 48 8b 05 e9 2f 00 00 mov 0x2fe9(%rip),%rax # 403ff8 <__gmon_start__>
40100f: 48 85 c0 test %rax,%rax
最后再调用hello中的main函数,main数执行结束接着调用exit函数。
...
240b1: ff d0 callq *%rax #call main
240b3: 89 c7 mov %eax,%edi
240b5: e8 b6 29 02 00 callq 46a70 <exit@@GLIBC_2.2.5> #call exit
...
...
0000000000401125 <main>:
401125: f3 0f 1e fa endbr64
401129: 55 push %rbp
40112a: 48 89 e5 mov %rsp,%rbp
40112d: 48 83 ec 20 sub $0x20,%rsp
...
5.7 Hello的动态链接分析
由5.6.1可知,程序通过动态链接器ld-2.31.so动态链接动态库。
在加载hello时,由加载器将控制权转移到指定的动态链接器(由5.4可知动态链接器是在.interp 段指定位置),由动态链接器对共享目标文件libc-2.31.so,和hello中的相应模块内的代码和数据进行重定位并加载共享库。
下面是动态链接前虚拟空间:
下面是动态链接后虚拟空间:
可以看到在_dl_init后,虚拟空间多出了libc-2.31.so。
动态库的地址是随机分配,但在执行hello的过程中,共享库中的代码和数据在存储空间的位置是固定的。
5.8 本章小结
本章介绍链接的作用与用法。然后对elf可执行文件格式以及虚拟地址空间的做了分析,最后对hello的重定位,动态链接,以及hello的执行流程做了详细分析。
第6章 hello进程管理
6.1 进程的概念与作用
进程是程序的动态的执行过程。程序是静态的过程,进程就是一个执行中的程序实例。
进程是操作系统进行资源分配和调度的基本单位。
作用:将程序变成执行的实例。方便操作系统进行资源分配和调度。每个进程都运行在各自独立的虚拟地址空间。因此,即使一个进程发生了异常,它也不会影响到系统的其他进程。
6.2 简述壳Shell-bash的作用与处理流程
Shell俗称壳,壳程序只是提供用户操作系统的一个接口,它是用户使用 Linux 的桥梁。
作用:接收来自用户的命令,与内核进行沟通。这里既可以是键盘输入,也可以是文件读取输入,或者其它程序输出重定向。
处理流程:
1、按行读取命令
2、处理引用问题
双引号内的字符将失去其原有意义,除了$, "和\。
单引号内的字符将失去其原有意义,包括$, "和\。
3、将输入的一行字符串按照 ; 分割成多个命令。
4、处理特殊字符
{..}, <(..), < ..., <<< .., .. | ..等特殊字符会被按照特殊的执行次序处理。
重定向符号会被从命令行中移除,所以在执行命令时... > log, 2>&1这些命令都是不会提交给内核处理命令的进程的。
其他符号会被其对应的结果表达所替代,如{..}命令:
5、变量替换、参数展开
将带$符号的变量 $parameter替换成变量内容
6、将命令行分割成被执行命令和参数
分割的原则是任何空白(空格、Tab)都将作为分隔符将一整条命令分割成一个一个的词。分割后结果的第一个词作为命令,其他词作为参数(如果命令词中包含空白,需要用引号包含。)
7、执行命令
如果命令是内置函数(function or builtin),该命令将会被接收命令的同一个Bash process处理。
否则, Bash将会fork,创造一个新的Bash子进程,将解析好的命令传递给它,第0个参数被设置为这个程序的名字,剩余参数是用户提供的参数,并等待它返回结果。一般情况下,子进程将会继承父进程的标准流。后面将介绍这种进程创建的具体过程。
6.3 Hello的fork进程创建过程
我们在shell上输入“./hello 120L033620 支卓 秒数”,这个不是一个内置的shell命令,所以shell会认为hello是一个可执行目标文件,将会执行它。
当shell运行一个程序时,实际上先执行了fork函数,再进行后续加载。Fork函数的作用是创建一个与当前进程几乎但不完全一样的新进程。除了子进程有新的pid,其他的包括代码、数据段、堆、共享库以及用户栈都与父进程一样。
子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,所以,当父进程调用fork时,子进程可以读写父进程中打开的任何文件。
fork在子进程与父进程中都会返回,但其返回值不同。在父进程中返回值是子进程的PID,而子进程中返回0。一般情况下,在fork之后,是父进程先执行,还是子进程先执行是不确定的(取决于内核的调度算法)。
因为fork使子进程得到的返回值是0,因此父子进程可以通过这个返回值来确定自己下一步要执行的代码。此时fork的子进程与hello还没有关系。
6.4 Hello的execve过程
进程在fork子进程之后,在子进程中调用execve函数在当前进程的上下文中加载并运行一个新程序(hello)。
总的来说是通过启动加载器执行加载hello,将filename可执行目标文件 hello 中的.text、. data段等内容加载到当前进程的虚拟地址空间。当加载器执行完加载任务后,便开始转到 hello 程序。
下面将详细介绍execve过程
6.4.1 execve
int execve(const char *filename,
char *const argv[],
char *const envp[]);
- 第一个参数filename字符串所代表的文件路径,这里就是我们的hello程序,
- 第二个参数是利用指针数组来传递给执行文件参数,并且需要以空指针(NULL)结束,
- 最后一个参数则为传递给执行文件的新环境变量数组。
- 如果execve函数执行失败则直接返回-1,并将控制权返回给调用程序;若execve函数执行成功,则不返回,最终将控制权传递到可执行文件的main函数。u
系统调用execve的内核入口为sys_execve。具体流程:
1、用getname在系统空间建立一个路径名的副本
(1)在系统空间定义一个缓冲指针
(2)分配一个物理页面作为缓冲区
(3)将路径名从用户空间复制到缓冲区
(4)缓冲区指针指向这个物理页面
2、do_execve
sys_execve的核心是调用do_execve函数,传给do_execve的第一个参数是已经拷贝到内核空间的路径名filename,第二个和第三个参数仍然是系统调用execve的第二个参数argv和第三个参数envp,它们代表的传给可执行文件的参数和环境变量,仍然保留在用户空间中。
3、释放路径名副本
6.4.2 do_execve
1、open_exec
打开可执行文件。
2、初始化bprm数据结构
bprm是用于保存可执行文件上下文的数据结构,初始化流程:
(1)初始化各个成员变量
(2)统计参数个数和环境变量个数
(3)从可执行文件中读入开头128个字节到bprm缓冲区,不管是什么格式的可执行文件,开头的128个字节包含了关于可执行文件属性的信息。
(4)把filename从系统空间复制到bprm
(5)把参数从用户空间复制到bprm
(6)把环境变量从用户空间复制到bprm
3、search_binary_handler
用formats队列中相应的“代理人”来“认领”可执行文件
(1)依次让formats队列中的每个成员尝试它们的load_binary()函数。
load_binary()用于装入可执行程序。这里是调用load_elf_binary()。
(2)若尝试成功(那么一定已经装入成功),则让可执行文件投入运行。
(3)若全部失败,则装入相应的模块,再从step1开始再尝试一次(一共只尝试两次)。
6.4.3 load_elf_binary
内核实现了一个函数register_binfmt, 这个函数用于注册内核支持的处理程序(目前主要为out, elf, script),register_binfmt其实就是把指定的结构插入到formats链表当中。
load_elf_binary执行流程:
- 填充并且检查目标程序ELF头部
- 加载目标程序的程序头表
- 读取elf的动态解释器路径
- 加载动态解释器的程序头表
- 做必要的内存映射
根据程序头表装入目标程序的段,在目标程序中,只有类型为PT_LOAD的段才是需要装入的。
- load_elf_interp
- 获取动态解释器ld-2.31.so的入口地址,并赋值给elf_entry
- start_thread
设置pc指针为动态解释器ld-2.31.so的入口地址
当cpu从系统调用返回到用户空间时,就从regs->pc确定的地址开始执行,达到了间接跳转的目的,而这个pc地址正好是动态解释器ld-2.31.so的入口地址(不再是execve的调用者地址)。
所以当execve函数返回的时候,就直接跳转到了动态解释器ld-2.31.so中运行。至此execve执行过程完毕,后面的执行流程就承接5.6.1。
6.5 Hello的进程执行
6.5.1 时间片
一般情况下,处理器的数量远小于进程数,因此所有进程都以轮流占用处理器的方式交叉运行。使得每个进程都有运行的机会。
调度器为每个进程分配了一个占用处理器的时间额度,这个额度叫做进程的“时间片”,其初值就存放在进程控制块(Processing Control Block)的counter域中。进程每占用处理器一次,系统就将这次所占用时间从counter中扣除,因为counter反映了进程时间片的剩余情况,所以叫做剩余时间片。
6.5.2 进程调度
内核必须提供一种方法, 在各个进程之间尽可能公平地共享CPU时间, 而同时又要考虑不同的任务优先级,这个方法称为进程调度策略(scheduling policy)。
调度策略的任务就是决定什么时候以怎么样的方式选择一个新进程占用CPU运行。一个优秀的调度策略必须实现几个互相冲突的目标:
- 进程响应时间尽可能快
- 后台作业的吞吐量尽可能高
- 尽可能避免进程的饥饿现象
- 低优先级和高优先级进程的需要尽可能调和等等。
Linux调度的主要思想为:调度器大致以所有进程时间片的总和为一个调度周期;在每个调度周期内可以发生若干次调度,每次调度时,所有进程都以counter为资本竞争处理器控制权,counter值大者胜出,优先运行;凡是已耗尽时间片(即counter=0)的,则立即退出本周期的竞争;当所有未被阻塞进程的时间片都耗尽,那就不等了。然后,由调度器重新为进程分配时间片,开始下一个调度周期。
只有处于TASK_RUNNING状态的进程才会进入调度器进行调度。
目前,标准Linux系统支持非实时(普通)和实时两种进程。任何实时进程的优先级都要高于普通进程。在每个进程的PCB中都有一个域policy,用来指明该进程为何种进程,应该使用何种调度策略。
Linux采用了两种不同的优先级范围,一种是nice值,一种是实时优先级。
- nice值的范围是-20~+19,默认为0,越大的nice值意味着越低的优先级;
- 实时优先级的表示范围从0~99,值越大代表优先级越高;
- 一个进程不能有两个优先级,实时优先级高于nice值。
linux内核目前实现了六种调度策略, 用于对不同类型的进程进行调度, 或者支持某些特殊的功能。
策略 | 描述 | 调度器类 |
SCHED_NORMAL | 普通进程调度策略,也叫SCHED_OTHER。 | CFS |
SCHED_FIFO | 实时进程调度策略,先入先出调度算法,该策略不涉及CPU时间片机制 (分时复用机制 ) , 在没有高优先级进程的前提下 , 只能等待其它进程主动释放CPU资源 | RT |
SCHED_RR | 实时进程调度策略,时间片轮转调度算法,进程使用完CPU时间片后, 会加入到与进程优先级 相应的执行队列的末尾; 同时, 释放CPU资源 , CPU 时间片会被轮转给 相同进程优先级的其它进程。 | RT |
SCHED_BATCH | 普通进程调度策略,SCHED_NORMAL的分化版本。采用分时策略,根据动态优先级,分配CPU运算资源。 这类进程比上述两类实时进程优先级低,换言之,在有实时进程存在时,实时进程优先调度。但针对吞吐量优化, 除了不能抢占外与常规任务一样,允许任务运行更长时间,更好地使用高速缓存,适合于成批处理的工作 | CFS |
SCHED_IDLE | 普通进程调度策略,优先级最低,在系统空闲时才跑这类进程 | CFS-IDLE |
SCHED_DEADLINE | 限期进程调度策略,新支持的实时进程调度策略,针对突发型计算,且对延迟和完成时间高度敏感的任务适用。基于Earliest Deadline First (EDF) 调度算法 | Deadline |
通过命令“ps -eo class,cmd | grep ./hello”查看“hello”的调度策略。
可以看到hello的调度策略为TS,对照下表可知,策略为SCHED_OTHER。
TS | SCHED_OTHER |
FF | SCHED_FIFO |
RR | SCHED_RR |
B | SCHED_BATCH |
ISO | SCHED_ISO |
IDL | SCHED_IDLE |
进程的上下文切换(context switch):
内核为每个进程维护了一个上下文,操作系统内核使用上下文切换来实现多任务。上下文就是内核重新启动一个被抢占进程所需要的状态集:通用寄存器,浮点寄存器,程序计数器,用户栈,状态寄存器,内核栈和各种内核数据结构(比如也表,进程表,文件表等)。
内核通过调度器来抢占一个执行的进程的流程:
(1)保护当前进程的上下文
(2)恢复某个先前被抢占进程所保存的上下文
(3)将控制传递给新回复的进程。
以下情况会发生上下文切换:
(1)当内核代表用户执行系统调用时。例如,系统调用因等待某个事件而发生阻塞,那么内核可以让当前进程休眠,切换到另一个进程,比如read请求磁盘访问。
(2)用户程序调用sleep系统调用,显式的让调用进程休眠。
(3)中断。比如所有的系统都有某种产生周期性定时器中断的机制。每次发生定时器中断时,内核判断当前进程是否已经运行了足够长的时间,并切换到另一个进程。
进程从用户模式变为内核模式的方法是通过中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器由用户模式转为内核模式。当它返回到应用程序代码时,处理器由内核模式转换回到用户模式。
6.6 hello的异常与信号处理
6.6.1 异常与信号处理
异常处理过程类似于过程调用,一般都会保存当前寄存器状态和返回地址。异常处理的返回地址要么是当前执行指令,要么是当前执行指令的下一条指令。异常发生时,如果此时内核代表用户程序执行(比如说系统调用),这些状态将会被压入到内核栈,而不是压入到用户栈。
此外,对于异常处理程序通常运行在内核模式,此时对于所有的系统资源具有访问权限。
异常的类别:
(1)中断(interrupt):中断是异步发生的,通常是来自I/O设备的信号。例如网络适配器、磁盘控制器通过向处理器芯片上的一个管脚发信号,并将异常号放在系统总线上,来触发中断,这个异常号标识了引起中断的设备。中断处理程序总是返回到当前指令的下一条指令。
(2)陷阱(trap):陷阱是同步异常,是有意的异常,是执行一条指令的结果。陷阱最重要的用途是在用户程序和内核之间提供系统调用接口。陷阱总返回到当前指令的下一条指令。
(3)故障(fault):故障由错误引起,它可能被故常处理程序修正,如果修正成功,将返回到当前正在执行的指令,重新执行。否则处理程序返回到内核的abort例程,终止故障程序。故障的一个典型是缺页异常。。
(4)终止(abort):由不可恢复的知名错误造成的结果,处理程序将返回到内核中的abort例程,终止应用程序,不会返回。
信号:
一个信号就是一条消息,它通知进程一个某种类型的事件已经在系统中发生。每种信号都对应某个类型的系统事件。
底层的硬件异常是由内核异常处理程序处理的,对用户进程通常不可见,而信号提供了向用户进程通知这些异常发生的机制。比如:一个进程试图除以0,内核就会发送SIGFPE信号。
其他的信号对应于内核或者其他用户中较高层次的软件事件。比如SIGINT、SIGKILL等。
编号为1 ~ 31的信号为传统UNIX支持的信号,是不可靠信号(非实时的),其余的为自定义的信号,称做可靠信号(实时信号)。
不可靠信号和可靠信号的区别在于前者不支持排队,可能会造成信号丢失,而后者不会。
不可靠信号种类:
序号 | 名称 | 说明 |
1 | SIGHUP | 连接断开 |
2 | SIGINT | 终端中断符 |
3 | SIGQUIT | 终端退出符 |
4 | SIGILL | 非法硬件指令 |
5 | SIGTRAP | 硬件故障 |
6 | SIGABRT | 异常终止 (abort) |
7 | SIGBUS | 硬件故障 |
8 | SIGFPE | 算术异常 |
9 | SIGKILL | 终止 |
10 | SIGUSR1 | 用户定义信号 |
11 | SIGUSR2 | 用户定义信号 |
12 | SIGSEGV | 无效内存引用 |
13 | SIGPIPE | 写至无读进程的管道 |
14 | SIGALRM | 定时器超时 (alarm) |
15 | SIGTERM | 终止 |
16 | SIGSTKFLT | 堆栈错误 |
17 | SIGCHLD | 子进程状态改变 |
18 | SIGCONT | 使暂停进程继续 |
19 | SIGSTOP | 停止 |
20 | SIGTSTP | 终端停止符 |
21 | SIGTTIN | 后台读控制 tty |
22 | SIGTTOU | 后台写向控制 tty |
23 | SIGURG | 紧急情况 (套接字) |
24 | SIGXCPU | 超过 CPU 限制 (setrlimit) |
25 | SIGXFSZ | 超过文件长度限制 (setrlimit) |
26 | SIGVTALRM | 虚拟时间闹钟 (setitimer) |
27 | SIGPROF | 梗概时间超时 (setitimer) |
28 | SIGWINCH | 终端窗口大小改变 |
29 | SIGIO | 异步 I/O |
30 | SIGPWR | 电源失效 / 重启动 |
31 | SIGSYS | 无效系统调用 |
在以上列出的信号中:
程序不可捕获、阻塞或忽略的信号有:SIGKILL、SIGSTOP
不能恢复至默认动作的信号有:SIGILL、SIGTRAP
默认会导致进程流产的信号有:SIGABRT、SIGBUS、SIGFPE、SIGILL、SIGIOT、SIGQUIT、SIGSEGV、SIGTRAP、SIGXCPU、SIGXFSZ
默认会导致进程退出的信号有:SIGALRM、SIGHUP、SIGINT、SIGKILL、SIGPIPE、SIGPOLL、SIGPROF、SIGSYS、SIGTERM、SIGUSR1、SIGUSR2、SIGVTALRM
默认会导致进程停止的信号有:SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU
默认进程忽略的信号有:SIGCHLD、SIGPWR、SIGURG、SIGWINCH
6.6.2 hello中的异常与信号
1、按下Ctrl-C 后,hello程序终止。
内核发送 SIGINT 信号给前台进程组中的所有进程。
2、按下Ctrl-Z后,hello停止运行。
内核发送 SIGTSTP 信号给前台进程组中的所有进程,将正在前台执行的命令放到后台,并且处于暂停状态。
3、按下Ctrl-Z后,此时前台可以接收新的命令输入,
输入jobs可以查看当前有多少在后台运行或者被挂起的任务。
jobs -l选项可显示所有任务的PID,jobs的状态可以是running, stopped, Terminated,但是如果任务被终止了(kill),shell 从当前的shell环境已知的列表中删除任务的进程标识。
4、fg
fg命令将后台中的命令调至前台继续运行,发送 SIGTSTP 信号给后台进程,可以发现此时hello已经在前台执行,不能输入新的命令执行。
如果后台中有多个命令,可以用fg %jobnumber将选中的命令调出,%jobnumber是通过jobs命令查到的后台正在执行的命令的序号(不是pid)。
5、bg
将一个在后台暂停的命令,变成继续执行 (在后台执行),发送 SIGTSTP 信号给后台进程,可以发现此时hello已经在后台执行,并且可以输入新的命令执行。
此处由于我stty的设置,如果后台执行的进程尝试向控制台输出,就会被suspended掉。可以通过stty -tostop来修改这一行为。
如果后台中有多个命令,可以用bg %jobnumber将选中的命令调出,%jobnumber是通过jobs命令查到的后台正在执行的命令的序号(不是pid)。
6、ps
ps命令可以显示当前正在运行的那些进程的信息
7、pstree
pstree命令以树状图显示进程间的关系。
8、kill
可以通过kill命令传递信号,输入kill -l可以查看所有信号:
例如输入 kill -2 $(pidof hello) 可以向hello发送SIGINT信号,其中pidof命令是输出所有名字是hello的进程的pid。
6.7本章小结
本章介绍了进程、shell、进程调度、异常、信号等相关概念。讨论了hello进程从execve到start_thread的整个启动流程,以及hello中信号的处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1、逻辑地址(Logical Address),
intel为了兼容,将早期的段式内存管理方式保留了下来。逻辑地址即程序产生的段内偏移地址,是由一个段选择符加上一个指定段内相对地址的偏移量 (Offset)组成的,表示为 [段选择符:段内偏移量],例如:[CS:EIP]。
在hello程序中就是代码中的“地址”。
2、线性地址(Linear Address)
是逻辑地址到物理地址变换之间的中间层。
逻辑地址中偏移地址,然后加上段基地址就是线性地址。此过程是由处理器根据GDT来将段地址例如CS:EIP等映射成线性地址,这是一个硬件过程,程序感知不到,操作系统也无法控制。
CPU未开启分页功能时,线性地址就被当做最终的物理地址来用。
3、虚拟地址(Virtual Address),
CPU开启了分页功能时,线性地址又称为虚拟地址。
在Linux中逻辑地址的偏移量的值与相应的线性地址(虚拟地址)是相等的,因为段基址为0。
这是由于绝大多数硬件平台都不支持段机制,只支持分页机制,所以为了让Linux具有更好的可移植性,需要去掉段机制而只使用分页机制。Linux的设计人员将段的基地址设为为0,以“绕过”段机制。
4、物理地址(Physical Address)
内存芯片级的单元寻址,和处理器相连的地址总线相对应。在hello程序中就是虚拟内存地址经过翻译后获得的地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.2.1 全局段号记录表
为了表示一个段,需要有以下信息。
- q 段的大小是多少
- q 段的起始地址在哪里
- q 段的管理属性(禁止写入,禁止执行,系统专用等)
下面以32位保护模式为例:
Intel CPU用8个字节(64位)的数据来表示这些信息。
struct SEGMENT_DESCRIPTOR
{
short limit_low, base_low;
char base_mid, access_right;
char limit_high, base_high;
};
0-16bit | limit(0-16bit) |
16-32bit | base(0-16bit) |
32-40bit | base(16-24bit) |
40-48bit | access(16-24bit) |
48-56bit | limit(16-20bit) |
56-64bit | base(24-32bit) |
1、段的基址
段的地址用32位来表示。这个地址称为段的基址。在这个结构体里base又分为low(2字节)、mid(1字节)、high(1字节)三段,合起来刚好是32位。所以,这里只要按顺序分别填入相应的数值就行。
至于为什么要分为3段,主要是为了与80286时代的程序兼容。有了这样的规格,80286用的操作系统,也可以不用修改就在386以后的CPU上运行了。
- 段上限
它表示一个段有多少个字节。段上限只能使用20位。这样一来,段上限最大也只能指定到1MB为止。
只能用其中的1MB,有种又回到了16位时代的错觉。于是英特尔在段的属性里设了一个标志位,叫做Gbit。这个标志位是1的时候,limit的单位不解释成字节(byte),而解释成页(page),1页=4KB。
这20位的段上限分别写到limit_low和limit_high的低4位里。
- 段属性
段属性又称为“段的访问权属性”,段属性用剩下的12位表示。段属性中的高4位放在limit_high的高4位里。
高4位被称为“扩展访问权”,高4位的访问属性在80286的时代还不存在,在,到386以后才可以使用。这4位是由“GD00”构成的,其中:
G是指上面所说的Gbit,
D是指段的模式, 1是指32位保护模式, 0是指16位实模式。
对于低8位,这里列举5个最主要的。
00000000(0x00) | 未使用的记录表 |
10010010(0x92) | 系统专用,可读写的段。不可执行 |
10011010(0x9a) | 系统专用,可执行的段。可读不可写 |
11110010(0xf2) | 应用程序用,可读写的段。不可执行 |
11111010(0xfa) | 应用程序用,可执行的段。可读不可写 |
CPU有系统模式(也称为“ring0” )和应用模式(也称为“ring3”)之分。操作系统等“管理用”的程序,和应用程序等“被管理”的程序,运行时的模式是不同的。在应用模式下,无法执行某些特定的指令。CPU到底是处于系统模式还是应用模式,取决于执行中的应用程序是位于访问权为0x9a的段,还是位于访问权为0xfa的段。
4、段选择符
段选择符(或称段选择子)是段的一个十六位标志符,其实就是被加载到段寄存器里的值,段选择符并不直接指向段,而是指向段描述符表中定义段的段描述符。段选择符包括 3 个字段的内容:
0-2bit | 请求特权级RPL |
2-3bit | 表指引标志TI TI = 0,表示描述符在GDT中, TI = 1,表示描述符在LDT中。 |
3-13bit | 描述符在GDT或LDT表中的索引项号 |
用于指定段的寄存器只有16位。段寄存器的高13位用于表示索引值。因此能够处理的就只有位于0~8191的段。
因为能够使用0~8191的范围,即可以定义8192个段,所以设定这么多段就需要8192×8=65536字节(64KB),这64KB(实际上也可以比这少)的数据就称为GDT。GDT是“global (segment) descriptor table”的缩写,即全局段号记录表。对应的,应用程序自己的段记录表,称作“局部段描述符表(LDT)”。
将这些数据整齐地排列在内存的某个地方,然后将内存的起始地址和GDT有效设定个数放在CPU内被称作GDTR的特殊寄存器中,GDT设定就完成了。对应的,IDT起始地址和有效设定个数放则IDTR寄存器
GDTR寄存器:
0-16(bit) | 段上限(GDT的有效字节数-1) |
16-48(bit) | GDT的开始地址 |
这是一个特殊的48位寄存器,无法使用我们常用的MOV指令来赋值。给它赋值的时候,唯一的方法就是指定一个内存地址,从指定的地址读取6个字节(48位),然后赋值给GDTR寄存器,对应的指令为LGDT。
逻辑地址是 selector:offset 这种形式,逻辑地址转换为线性地址的过程:
(1)用段选择符(selector)去GDT中得到段描述符;
(2)从段描述符中得到段基地址;
(3)线性地址(虚拟地址) =段基地址+段内偏移
正在上传…重新上传取消
7.2.2 Linux中的段式管理
在Linux中,由于IA32段机制规定,分段机制是固有的,必须经过这个环节才能得到一个线性地址。
所以 Linux 系统中,为了“不使用”分段机制,但是又无法绕过,只好定义了“平坦”的分段模型。Linux内核将所有类型的段的segment base address都设成0(包括内核数据段、内核代码段、用户数据段、用户代码段等)
这样一来所有段都重合了,也就是不分段了,此外由于段限长是地址总线的寻址限度,所以这也就相当于所有段内空间跟整个线性空间重合了。
这样逻辑地址也就简化为了段内的偏移量,由于段基地址变为了0,那么线性地址=逻辑地址=虚拟地址。所以,在x86 Linux内核里,逻辑地址、虚拟地址、线性地址,这是三个地址是一致的。
由于Linux内核运行在特权级0,而用户程序运行在特权级别3,根据IA32的段保护机制规定,特权级3的程序是无法访问特权级为0的段的,所以Linux必须为内核和用户程序分别创建其代码段和数据段。
这就意味着Linux必须创建4个段描述符:特权级0的代码段和数据段,特权级3的代码段和数据段。
7.3 Hello的线性地址到物理地址的变换-页式管理
分页机制在分段机制的基础上完成线性地址到物理地址转换的过程。分段机制把逻辑地址转换成线性地址,而分页则把线性地址转换成物理地址。
分页可以用于任何一种分段模型。处理器分页机制会把线性地址空间(段已映射到其中)划分成页面,然后这些线性地址空间页面被映射到物理地址空间的页面上。分页机制有几种页面级保护措施,可和分段机制保护机制合用或替代分段机制的保护措施。例如,在基于页面的基础上可以加强读/写保护。另外,在页面单元上,分页机制还提供了用户-超级用户两级保护。
我们通过设置控制寄存器CR0的PG位可以启用分页机制。
- 如果PG=1,则启用分页操作,处理器将线性地址转换成物理地址。
- 如果PG=0,则禁用分页机制,此时分段机制产生的线性地址被直接用作物理地址。
分段机制在各种可变长度的内存区域上操作。与分段机制不同,分页机制对固定大小的内存块(称为PAGE)进行操作。分页机制把线性和物理地址空间都划分成页面。线性地址空间中的任何页面可以被映射到物理地址空间的任何页面上。并在这两个空间之间提供了任意映射。
在保护模式中,8086允许线性地址空间直接映射到大容量的物理内存(如4GB的RAM)上,或者(使用分页)间接地映射到较小容量的物理内存和磁盘存储空间中。这后一种映射线性地址空间的方法被称为虚拟存储或者需求页(Demand-paged)虚拟存储。
当使用分页时,处理器会把线性地址空间划分成固定大小的页面(一般长度4KB),这些页面可以映射到物理内存中或磁盘存储空间中。当一个程序(或任务)引用内存中的逻辑地址时,处理器会把该逻辑地址转换成一个线性地址,然后使用分页机制把该线性地址转换成对应的物理地址。
在Linux下,我们通过命令“getconf PAGE_SIZE”可以查看到当前操作系统的页大小:
如果包含线性地址的页面当前不在物理内存中,处理器就会产生一个页错误异常。页错误异常的处理程序通常就会让操作系统从磁盘中把相应页面加载到物理内存中(操作过程中可能还会把物理内存中不同的页面写到磁盘上)。当页面加载到物理内存中之后,从异常处理过程的返回操作会使得导致异常的指令被重新执行。处理器用于把线性地址转换成物理地址时所需的信息及处理器产生页错误异常(若必要的话)所需的信息都存储于页目录和页表中。
分页与分段最大的不同之处在于分页使用了固定长度的页面。段的长度通常与存放在其中的代码或数据结构具有相同的长度。与段不同,页面有固定的长度。如果仅使用分段地址转换,那么存储在物理内存中的一个数据结构将包含其所有的部分。但如果使用了分页,那么一个数据结构就可以一部分存储于物理内存中,而另一部分保存在磁盘中。
7.4 TLB与四级页表支持下的VA到PA的变换
Linux采用的方案是四级页表,分别是:
- PGD:page Global directory, 页全局目录
Linux系统中每个进程对应用户空间的PGD是不一样的,但是linux内核的PGD是一样的。当创建一个新的进程时,都要为新进程创建一个新的页面目录PGD,并从内核的页面目录swapper_pg_dir中复制内核区间页面目录项至新建进程页面目录PGD的相应位置。
- PUD:Page Upper Directory,页上级目录
- PMD:page middle directory,页中间目录
- PTE:page table entry,页表项
由于页表是存在内存里的。一次内存IO光是虚拟地址到物理地址的转换就要去内存查4次页表,再算上真正的内存访问,需要5次内存IO才能获取一个内存数据.
为了减少地址转换的内存IO次数,最近访问的页目录和页表会被存放在处理器的缓冲器件中。该缓冲器件被称为转换查找缓冲区(Translation Lookaside Buffer,TLB)。
TLB可以满足大多数读页目录和页表的请求而无需使用总线周期。只有当TLB中不包含要求的页表项时才会使用额外的总线周期从内存中读取页表项,通常在一个页表项很长时间没有访问过时才会出现这种情况。
7.5 三级Cache支持下的物理内存访问
如果访问内存中的一个数据A,那么很有可能接下来再次访问到,同时还很有可能访问与数据A相邻的数据B,这分别叫做时间局部性和空间局部性。
为了更好的利用局部性原理,弥补 CPU 与内存两者之间的性能差异,减少CPU访问主存的次数。就在 CPU 内部引入了 CPU Cache,也称高速缓存。
CPU Cache 通常分为大小不等的三级缓存,分别是 L1 Cache、L2 Cache 和 L3 Cache。每个核都有一个独享的L1 Cache(L1 Cache分为数据缓存和指令缓存)和L2 Cache,而L3 Cache是所有的核共享缓存。
程序执行时,会先将内存中的数据加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,最后 进入到最快的 L1 Cache,之后才会被CPU读取。
7.6 hello进程fork时的内存映射
从shell输入“./hello 120L033620 支卓 秒数”后, shell 会认为hello是一个可执行目标文件,调用fork创建子进程。
fork时,会创建一个与当前进程几乎但不完全一样的新进程,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本,因此子进程与父进程用户级虚拟地址空间是相同的(但是独立的)。
7.7 hello进程execve时的内存映射
遍历程序头中每个段,搜索类型为PT_LOAD的段(Segment)。在二进制映像中,只有类型为PT_LOAD的段才是需要装入的。
在装入之前,需要确定装入的地址,检查地址和页面的信息,例如页面对齐,还有该段的p_vaddr域的值。确定了装入地址后,就通过elf_map建立用户空间虚拟地址空间与目标映像文件中某个连续区间之间的映射,其返回值就是实际映射的起始地址。
7.8 缺页故障与缺页中断处理
缺页异常,指的是当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元(MMU)所发出的中断。
例如:
- Linux在动态分配内存时,分配的仅仅是虚拟地址,并没有映射实际的物理地址,在首次访问时会产生一个缺页中断。
- fork创建出的子进程,与父进程共享内存空间,通过COW技术可减少分配和复制大量资源时带来的瞬间延时。因此fork()之后,进行写操作,那么会产生的分页错误(页异常中断page-fault)。
内核接受到MMU发出的缺页中断后,会为该虚拟地址分配实际的页。
7.9动态存储分配管理
从操作系统角度来看,Linux进程分配动态内存有两种方式,分别由两个系统调用完成:brk和mmap。
1、brk是将数据段(.data)的最高地址指针_edata往高地址推;
参数brk表示所要求的新边界,这个边界不能低于代码段的终点,并且必须与页面大小对齐。如果新边界低于老边界,那就不是申请分配空间,而是释放空间。
2、mmap是在进程的虚拟地址空间的堆和栈中间中(称为文件映射区域的地方)找一块空闲的虚拟内存,释放时使用munmap。
这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。brk分配的内存需要等到高地址内存释放以后才能释放,这样就会产生内存碎片,而mmap分配的内存可以单独释放。
7.9.1 malloc
在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk,mmap,munmap这些系统调用实现的。
malloc采用的是内存池的实现方式,先申请一大块内存(当malloc申请小内存时,使用brk分配内存。当malloc申请大内存时,使用mmap分配内存,在堆和栈之间找一块空闲内存分配。
然后将内存分成不同大小的内存块,利用chunk链表结构来管理内存块。
- size of previous chunk:
若上一个 chunk 可用,则此字段赋值为上一个 chunk 的大小;否则,此字段被用来存储上一个 chunk 的用户数据;
- size of chunk
此字段表示当前 chunk 的大小,其最后三位包含标志信息:
- P:PREV_INUSE,置「1」表示上个 chunk 被分配;
- M:IS_MMAPPED,置「1」表示这个 chunk 是通过 mmap 申请的(较大的内存);
- A:NON_MAIN_ARENA,置「1」表示这个 chunk 属于一个 thread arena。
用户申请内存时,它沿连接表寻找一个大到足以满足用户请求所需要的内存块。然后,将该内存块一分为二(一块的大小与用户请求的大小相等,另一块的大小就是剩下的字节)。接下来,将分配给用户的那块内存传给用户,并将剩下的那块(如果有的话)返回到连接表上。
为了支持多线程并行处理时对于内存的并发请求操作,malloc的实现中把全局用户堆(heap)划分成很多子堆(sub-heap)。这些子堆是按照循环单链表的形式组织起来的。每一个子堆利用互斥锁(mutex)使线程对于该子堆的访问互斥。
当某一线程需要调用malloc分配内存空间时,该线程搜索循环链表试图获得一个没有加锁的子堆。如果所有的子堆都已经加锁,那么malloc会开辟一块新的子堆。
7.10本章小结
本章介绍了从逻辑地址到线性地址到物理内存最后再到CPU三级缓存的转换过程,其中涉及段式管理,页式管理以及L1、L2、L3三级缓存。接着介绍了缺页中断,以及linux动态内存分配和glibc中的动态内存分配函数malloc。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
在Linux中,一切皆文件。IO设备(如键盘、监视器、硬盘、打印机)以及套接字(socket)、网络通信等资源等都被抽象成了文件。设备就如同是普通文件一样,用户访问设备就如同访问文件,设备的访问权限也如同文件的访问权限一样设置。
对设备的操作是通过Unix IO接口统一操作管理。
8.2 简述Unix IO接口及其函数
8.2.1 open
函数原型
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
功能:打开和创建文件(建立一个文件描述符,其他的函数可以通过文件描述符对指定文件进行读取、写入等操作)。
8.2.2 close
函数原型
int close(int fd);
功能:关闭文件描述符,使其不再引用任何文件。
8.2.3 read
函数原型
ssize_t read(int fd, void *buf, size_t count);
功能:函数从打开的文件描述符中读取数据,同时文件的当前读写位置向后移。
8.2.4 write
函数原型
ssize_t write(int fd, const void *buf, size_t count);
功能:函数从打开的文件描述符中写入数据,同时文件的当前读写位置向后移。
8.3 printf的实现分析
8.3.1 printf的实现
printf的实现在glibc stdio-common/printf.c(glibc-2.31)。
int __printf (const char *format, ...)
{
va_list arg;
int done;
va_start (arg, format);
done = __vfprintf_internal (stdout, format, arg, 0);
va_end (arg);
return done;
}
可以看到printf内部调用了__vfprintf_internal ,通过vfprintf将format输出到stdout中。vfprintf主要作用是格式化字符串,最后将字符串输出到文件中,这里就对应的是stdout。
vfprintf中先是进行一系列初始化,然后处理整个格式字符串。
最后是通过底层write调用系统将处理好的字符串输出到终端。
在Linux x86_64中,函数是通过syscall进入内核模式,用rax传递系统调用号(2),分别用rdi,rsi,rdx传递参数:
mov rax, 2
mov rdi, 1
mov rsi, string
mov rdx, len
syscall
8.3.2 字符的显示
以24位RGB色彩模式为例:
显示字符时,先根据字符的编码在字体中获取该字符对应文字的点阵信息。然后将点阵位图数据转换为RGB像素数据,接着逐行将每个像素的RGB数据复制到对应像素的vram地址。
最后显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。就实现了字符的显示。
8.4 getchar的实现分析
8.4.1 getchar的实现
getchar的实现在glibc libio/getchar.c(glibc-2.31)。
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;
}
可以看到getchar调用了_IO_getc_unlocked(stdin)。
_IO_getc_unlocked是一个宏定义,实际上调用了__uflow函数,最后通过read系统调用函数读取按键ascii码。
用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续read系统调用读取。
调用期间,进程进入等待态,进程不会被调度为运行态,直到接受到回车键才返回。
8.4.2 键盘中断的处理过程
当键盘上的一个按键按下时,键盘会发送一个中断信号给CPU,与此同时,键盘会在指定端口输出一个数值,这个数值对应按键的扫描码叫通码(make code);当按键弹起时,键盘又给端口输出一个数值,这个数值叫断码(break code)。我们以按键’A’为例,当按键’A’按下时,键盘给端口0x60发出的扫描码是0X1E, 当按键’A’弹起时,键盘会给端口0x60发送断码0x9E。
接着键盘中断服务程序先从端口(0x60)取得按键的扫描码,然后根据其扫描码判断用户所按的键并作相应的处理,最后通知中断控制器本次中断结束并实现中断返回。
8.5本章小结
本章介绍了Linux的IO设备管理方法,以及unix的IO接口及其函数。最后分析了glibc中printf和getchar的实现,以及显示文字到屏幕,和从键盘接收数据的具体过程。
结论
1、预处理
将hello.c转换为hello.i,进行的一些文本替换、删除方面的操作。
2、编译
从hello.i生成目标平台的汇编语言hello.s。
3、汇编
将汇编语言代码文件hello.s转变成机器可以执行的指令。
4、链接
将重定位目标文件hello.o与库文件、启动文件等链接起来生成可执行文件。
5、Fork:shell解析命令并构造参数列表,fork子进程。
6、execve:装载hello并执行hello 。
7、进程回收:shell父进程回收hello进程,内核删除hello用户地址空间的内存映射页面。
计算机可以帮助我们完成很多事,但对太多说普通用户来说,根本不存在什么信息安全的观念,而且普通用户对这些东西也不太懂。经过本文的分析,我们可以发现,写一个恶意程序对技术完全没有什么要求,几乎没有什么门槛,所以这些东西也没有什么可炫耀的。作为一个程序员,应该是做一些有价值,有意义的的拍卖会西。现代计算机系统设计应该更注重信息安全这方面。
附件
1、hello.i : 预编译结果,用于编译。
2、hello.s : 编译结果,用于汇编。
3、hello.o : 目标文件,用于连接器生成最终的可执行程序。
4、hello:可执行文件。
5、hello.txt:hello反汇编后的输出,用于分析hello。
6、hello.o.txt:hello.o反汇编后的输出,用于分析hello.o。
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 川合秀实. 30天自制操作系统. 北京:人民邮电出版社.
[2] 李忠,王晓波,余洁著. x86汇编语言:从实模式到保护模式. 北京:电子工业出版社.
[3] 高剑林. Linux内核探秘 深入解析文件系统和设备驱动的架构与设计. 北京:机械工业出版社.
[4] 兰德尔·E. 布莱恩特. 深入理解计算机系统(原书第3版)[M]. 北京:机械 工业出版社.