本文是课程《计算机系统》的大作业报告。本文从一个普通的C语言程序hello出发,分析了hello从源代码文件开始,经过预处理、编译、汇编、链接、加载和运行,以及作为进程在操作系统中进行一系列操作,最终终止并被回收的完整生命周期,并基于计算机系统课程和《深入理解计算机系统》教材中的相关知识,分析了在hello生命周期各阶段所参与的计算机系统部分和相关原理,从计算机系统的角度讲述了hello短暂的一生。
关键词:计算机系统;编译;进程;操作系统
目录
第1章 概述
(0.5分)
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1.1.1 Hello的P2P过程
P2P,即“from program to process”,是指hello.c文件从可执行程序(Program)变成运行时进程(Process)的过程。
首先,编译器驱动程序调用C预处理器cpp,将hello.c翻译成一个ASCII码的中间文件hello.i,然后运行C编译器cc1,将hello.i翻译成ASCII汇编语言文件hello.s,驱动程序再运行汇编器as,将hello.s翻译成可重定位目标文件hello.o。最后,驱动程序运行链接器程序ld,创建一个可执行目标文件hello。
得到可执行目标文件hello后,我们就能运行hello程序了。在Linux下,我们在shell程序的命令行上输入“./hello”,随后shell通过fork创建一个子进程,子进程通过execve系统调用启动加载器。加载器删除现有的虚拟内存段, 并创建一组新的代码、数据、堆和栈段,再将可执行文件hello中的代码和数据复制到内存,最后将控制转移到hello程序的开头。于是可执行程序hello就变为一个运行时进程。
1.1.2 Hello的020过程
020,即“from zero to zero”,是指hello程序在运行前,内存中并没有hello程序的信息,这就是一开始的“0”;在hello加载和运行时,shell创建一个子进程,子进程调用execve,将hello程序的相关信息映射到内存中,然后设置当前进程上下文中的PC,使其指向hello程序的入口,开始运行hello;hello进程运行完毕并终止后,父进程回收hello进程,内核清除hello程序的相关信息,此时hello程序再次“to 0”。
1.2 环境与工具
1.2.1 硬件环境
11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz CPU;
16GB DDR4 3200MHz RAM;
OM3PDP3-AD 512GB SSD + Samsung SSD 980 500GB SSD + Elements SE 2623 2000GB HDD;
1.2.2 软件环境
Windows10 64位;Vmware Workstation 16 Pro 16.2.3;Ubuntu 22.04 LTS 64位;
1.2.3 开发与调试工具
Visual Studio 2022 64位,Visual Studio Code;vim,gedit,gcc,edb,objdump
1.3 中间结果
中间文件 | 作用 |
hello.i | hello.c经预处理后得到的ASCII码中间文件 |
hello.s | hello.i经过编译得到的ASCII汇编语言文件 |
hello.o | hello.s经过汇编得到的可重定位目标文件 |
hello.elf | hello.o用readelf命令得到的ELF格式输出文件 |
hello_exe.elf | 可执行目标文件hello用readelf命令得到的ELF格式输出文件 |
hello.asm | 可执行目标文件hello经过反汇编命令输出的文件 |
1.4 本章小结
本章首先根据Hello的自白,利用计算机系统的术语,简述了Hello的P2P,020的过程。然后描述了本机进行此次大作业所用的硬件环境、软件环境和开发与调试工具。最后列出了为编写本论文所生成的中间结果文件及相应的作用。
第2章 预处理
(0.5分)
2.1 预处理的概念与作用
2.1.1 预处理的概念
在编译C语言程序的过程中,需要先将C源程序(以hello.c为例)翻译成一个ASCII码的中间文件(hello.i),这一过程中,编译器驱动程序(gcc)调用C预处理器(cpp)对源代码进行一些文本性质的操作,包括删除注释、插入被#include指令包含的文件内容、定义和替换由#define指令定义的符号,同时确定代码的部分内容是否应该根据一些条件编译指令进行编译。
2.1.2 预处理的作用
1.宏定义
又称宏展开,预处理器将C源代码中形如“#define 宏名称 常量数据”的宏定义部分所有宏名称(不在字符串中)用常量数据进行替换。
2.文件包含
文件包含指C源代码中包含另一个文件的内容,形式为“#include 文件名”。预处理时,C预处理器将“#include”指令删除,并用包含文件的内容代替这条指令。
3.条件编译
条件编译可以选择代码的一部分或是代码的不同部分是被正常编译还是完全忽略。条件编译过程为,给编译指定一个条件,满足条件的源代码会被编译,不满足条件的源代码会被预处理程序从整个代码中删除。
2.2在Ubuntu下预处理的命令
使用C预处理器对hello.c进行预处理,输出至文件hello.i的预处理命令如下:
gcc -E hello.c -o hello.i
或
cpp hello.c > hello.i
预处理命令执行情况:
图 2.1 预处理命令执行1
图 2.2 预处理命令执行2
2.3 Hello的预处理结果解析
在Visual Studio Code打开hello.i文件,观察文件相比于hello.c的变化:
图 2.3 a)hello.c整体情况
图 2.3 b)hello.i整体情况
我们观察到经过预处理,hello.c文件由仅有不到20行代码(不包括注释)扩展到3091行代码,ASCII码中间文件hello.i相比于源文件增加了很多来自其他文件的代码。
然后观察文件的开头部分
图 2.4 预处理结果1-程序文件信息
开头包含了hello的文件名和命令行参数相关的文件信息。再观察文件下面部分
图 2.5 预处理结果2-包含文件的信息
这部分代码是包含文件的信息,包含文件路径和文件相关参数。
图 2.6 预处理结果3-结构定义信息
每部分包含文件的信息后都相对应一段上述文件所定义的结构信息。
图 2.7 预处理结果4-外部函数声明
除此之外,新增的代码段还含有包含文件的相关外部函数的声明,这样程序才能调用C语言库中的函数。
最后部分是hello.c的代码部分,预处理后的代码与源代码大致相同。
图 2.8 预处理结果5-程序源代码部分
综上所述,hello.c的预处理过程为,预处理器cpp读取头文件包含的指令,先读取到#include <stdio.h>指令,cpp删除该指令,然后到Linux系统的环境变量下查找文件stdio.h(如果文件名包含在双引号内,则cpp会在当前目录下查找相应文件),在“/usr/include/stdio.h”下找到并打开stdio.h,然后将头文件指令替换为stdio.h文件的相关内容。由于stdio.h中还使用了“#define”和“#include”等指令,所以cpp还会继续嵌套地寻找包含的文件,并将宏定义的所有宏名称用常量数据进行替换,将条件编译语句下的内容根据源文件的情况选择性地包含进预处理后的文件。预处理完#include <stdio.h>后,cpp继续处理#include <unistd.h>和#include <stdlib.h>指令,用同样的方式将指令替换成包含文件的内容。
2.4 本章小结
本章着重分析了hello.c在编译环节中的第一步——预处理过程。首先介绍了预处理的概念和作用,给出了在Ubuntu下预处理的两种命令,展示了两种预处理命令的执行结果。然后分析根据以上命令对hello.c进行预处理得到的hello.i文件,以hello.c为例大致描述了预处理器cpp的预处理过程。
第3章 编译
(2分)
3.1 编译的概念与作用
3.1.1 编译的概念
编译是程序构建的核心部分,由源代码(hello.c)经预处理得到的ASCII码中间文件(hello.i)经过编译器编译,会生成一个汇编代码文件(hello.s)。
在编译的过程中,编译器驱动程序调用C编译器(cc1),它对预处理后的文件进行一系列词法分析、语义分析及相关优化,将预处理得到的ASCII码中间文件由文本形式翻译成等价的机器语言,并最终生成汇编代码文件。
3.1.2 编译的作用
编译主要包括以下6个步骤,每个步骤在编译中发挥不同的作用:
1. 词法分析
词法分析程序对由字符组成的单词进行处理,从左至右逐个字符对源程序的字符流进行扫描,产生一个个单词符号,把作为字符串的源程序组织成为单词符号串的中间程序。
2. 语法分析
编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,最后检查是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构。
3. 语义分析
语义分析器使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致。它同时也收集类型信息,并把这些信息存放在语法树或符号表中,一边在随后的中间代码生成过程中使用。
4. 中间代码生成
编译器生成一个明确的低级的或类机器语言的中间表示,这种表示可以看作某个抽象机器的程序。中间表示一个易于生成,且能够被轻松翻译为目标机器上的语言。
5. 代码优化
代码优化指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。
6. 代码生成
代码生成器以源程序的中间表示形式作为输入,并把它映射到目标语言。如果目标语言是机器代码,那么就必须为程序使用的每个变量选择寄存器或内存位置。然后,中间指令被翻译成能够完成相同任务的机器指令序列。
3.2 在Ubuntu下编译的命令
使用以下命令运行C编译器(cc1)进行编译,将ASCII码中间文件hello.c翻译成一个ASCII汇编语言文件hello.s:
gcc -S hello.i -o hello.s
编译命令执行情况:
图 3.1 执行编译命令
3.3 Hello的编译结果解析
在由hello.i经编译得到的汇编代码文件hello.s中,文件头部的代码段如下:
图 3.2 hello.s头部的代码段
3.3.1 数据与赋值
C语言的数据有以下形式:常量、变量(全局/局部/静态)、表达式、类型、宏。汇编代码文件hello.s的头部包含以下类型的数据:
1. 常量-数字
在C代码中,一般的数组长出现在表达式的右边,或者以直接的数字形式作为函数的参数传递,与数字使用有关的C代码有:
…
13. if(argc!=4) // 数字4作为表达式的右值
…
15. exit(1); // 数字1作为exit函数的参数
…
17. for(i=0;i<8;i++) // 数字0,8作为表达式的右值
结果的分析在注释中。对应的汇编代码:
24. cmpl $4, -20(%rbp) //将argc与立即数4比较
…
29. movl $1, %edi //将立即数1作为exit函数的参数
30. call exit@PLT //调用exit函数
31. .L2:
32. movl $0, -4(%rbp) //将立即数0赋值给i
33. jmp .L3
…
54. .L3:
55. cmpl $7, -4(%rbp) //将i与立即数7比较
2. 常量-字符串
在hello.s中字符串主要是只读数据,为printf语句中的格式串,存放在.rodata节中。在图3.2中,汇编代码头部就能观察到
3. .section .rodata
4. .align 8
5. .LC0:
6. .string "Hello 2021113689 \351\273\204\345\275\246\351\222\247 3\357\274\201"
7. .LC1:
8. .string "Hello %s %s\n"
其中的两个.string字段保存了hello.c两处printf的格式字符串信息:
…
14. printf("Hello 2021113689 黄彦钧 3!\n");
…
18. printf("Hello %s %s\n",argv[1],argv[2]);
…
对于英文字符和数字,.string中直接表示;对于中文字符,使用UTF-8编码,在.string中每个汉字用三个“\xxx”序列来表示
3. 变量-局部变量
在hello.c中,我们声明了一个循环局部变量int i,在hello.s中,i并未在函数进入后即被定义,而是在后续的for循环中被分配栈地址并初始化为0:
31. .L2:
32. movl $0, -4(%rbp) //int i = 0
33. jmp .L3
这里将立即数0传给 -4(%rbp) 的位置,由于int类型占4字节,相当于将局部变量i分配在%rbp – 4的位置并初始化(赋值)为0。对应hello.c的以下代码:
…
11. int i;
…
17. for(i=0;i<8;i++)
除了变量赋值外,局部变量i还有相关的表达式“i++”在汇编代码中表示为
34. .L4:
…
53. addl $1, -4(%rbp) //i++
即局部变量i自增立即数1,其中指令后缀“l”表示对双字进行操作。
3.3.2 算术操作
在与数据相关的代码分析中,我们已经分析过表达式i++是对局部变量i做+1运算:
34. .L4:
…
53. addl $1, -4(%rbp) //i++
对应C代码
17. for(i=0;i<8;i++)
3.3.3 关系操作
C代码中有以下关系操作:
…
13. if(argc!=4) // argc!=4
…
17. for(i=0;i<8;i++) // i<8
注释给出了关系操作的部分。对应的汇编代码为:
…
24. cmpl $4, -20(%rbp) //将argc与4比较
25. je .L2 //相等就跳转(不相等就继续顺序执行)
…
54. .L3:
55. cmpl $7, -4(%rbp) //将i与7比较
56. jle .L4 //如果i<=7,则继续循环
以上注释对汇编代码的关系操作进行了大致分析,汇编代码主要通过 “cmpl指令 + 进行关系操作的操作数”置条件码,然后在下一条控制转移指令根据条件码进行条件转移。另外我们注意到,源代码中for循环终止条件为i < 8,而汇编代码将其等价为i <=7,这与C编译器的行为有关,但实际效果是完全相同的。
3.3.4 数组/指针/结构操作
在hello.c中有以下两行代码是对指针数组argv进行访问和读操作的。
18. printf("Hello %s %s\n",argv[1],argv[2]);
19. sleep(atoi(argv[3]));
首先访问数组的第2个元素和第3个元素,作为printf函数的第二个参数和第三个参数传入(第一个参数是格式串),然后将argv数组的第4个元素作为函数atoi的参数传入,在hello.s中关于argv数组的访问操作有以下几行:
34. .L4:
35. movq -32(%rbp), %rax //由上面的分析可知,-32(%rbp)就是argv[0]的地址
36. addq $16, %rax //%rax存argv[2]
37. movq (%rax), %rdx // argv[2]作为待传入printf的第三个参数
38. movq -32(%rbp), %rax //再次取首地址
39. addq $8, %rax //%rax存argv[1]
40. movq (%rax), %rax //间接寻址
41. movq %rax, %rsi // argv[2]作为待传入printf的第二个参数
42. leaq .LC1(%rip), %rax //加载格式串LC1的地址
43. movq %rax, %rdi //格式串作为printf的第一个参数
44. movl $0, %eax
45. call printf@PLT //调用printf函数
46. movq -32(%rbp), %rax //再次取首地址
47. addq $24, %rax //%rax存argv[3]
48. movq (%rax), %rax //间接寻址
49. movq %rax, %rdi // argv[3]作为待传入atoi的参数
50. call atoi@PLT //调用atoi函数
为了更加直观,以上注释已经对汇编代码的数组访问和指针相关操作进行了大致分析。另外,由于char*指针变量占用8字节,所以argv[1]与argvp[0]相距8字节,argv[2]与argv[0]相距16字节,argv[3] 与argv[0]相距24字节。
3.3.5 控制转移操作
在hello.c代码中,控制转移操作往往伴随着关系操作,主要在if语句和for语句中,即以下几行代码:
13. if(argc!=4){
…
16. }
17. for(i=0;i<8;i++){
…
20. }
对应的汇编代码如下:
…
24. cmpl $4, -20(%rbp) //将argc与4比较
25. je .L2 //相等就跳转(不相等就继续顺序执行)
…
54. .L3:
55. cmpl $7, -4(%rbp) //将i与7比较
56. jle .L4 //如果i<=7,则继续循环
其中je、jle均是转移指令,其作用分别为“相等则跳转”、“小于或等于则跳转”
3.3.6 函数操作
1. main函数
(1)函数原型
int main(int argc,char *argv[])
(2)参数传递
在hello.c中,我们向main函数传入两个参数:int类型的局部变量argc,和指向char类型指针数组的头指针argv。在hello.s中汇编指令对这两个参数作如下处理,将其分别暂存在寄存器%edi和%rsi中:
…
22. movl %edi, -20(%rbp)
23. movq %rsi, -32(%rbp)
(3)函数调用
在execve函数加载并运行可执行文件时,将调用启动程序,启动程序设置栈,并将控制传递给程序主函数,main函数在此时即被启动程序调用。
(4)局部变量
main中定义了用于控制循环的局部变量i,i的C代码和汇编代码的分析已经在上文中给出。
(5)函数返回
main函数通常返回一个整型值0,表示程序正常退出,与exit(0)的效果大致相同。C代码中用“return 0”设置返回值,汇编代码的返回部分如下:
…
58. movl $0, %eax //将返回值保存在%eax中
…
61. ret //main函数返回
2. printf函数
(1)函数原型
int printf ( const char * format, ... )
(2)参数传递
在“printf("Hello 2021113689 黄彦钧 3!\n")”中传入参数为格式串"Hello 2021113689 黄彦钧 3!\n",在汇编代码中有以下体现:
…
5. .LC0:
6. .string "Hello 2021113689 \351\273\204\345\275\246\351\222\247 \357\274\201"
…
26. leaq .LC0(%rip), %rax //将格式串的地址加载到%rax中
27. movq %rax, %rdi //将格式串作为第一个参数
28. call puts@PLT //调用puts函数
在“printf("Hello %s %s\n",argv[1],argv[2])”中传入的第一个参数为格式串"Hello %s %s\n",第二个参数为argv[1],第三个参数为argv[2],在汇编代码中有以下体现:
34. .L4:
35. movq -32(%rbp), %rax //由上面的分析可知,-32(%rbp)就是argv[0]的地址
36. addq $16, %rax //%rax存argv[2]
37. movq (%rax), %rdx // argv[2]作为待传入printf的第三个参数
38. movq -32(%rbp), %rax //再次取首地址
39. addq $8, %rax //%rax存argv[1]
40. movq (%rax), %rax //间接寻址
41. movq %rax, %rsi // argv[2]作为待传入printf的第二个参数
42. leaq .LC1(%rip), %rax //加载格式串LC1的地址
43. movq %rax, %rdi //格式串作为printf的第一个参数
44. movl $0, %eax
45. call printf@PLT //调用printf函数
参数传递过程已经在注释中说明。
(3)函数调用
令人意外的是,在hello.c源代码中调用printf函数,但是当printf函数的参数仅有纯字符的格式串时,汇编代码中会直接用puts函数代替prints函数,将printf函数的格式串参数作为puts函数,调用puts直接打印格式串。一般情况下,汇编代码中会先把参数组织好,放到相应的参数寄存器中,然后用call指令来调用printf函数。
3. exit函数
(1)函数原型
void exit(int status)
(2)参数传递
hello.c中向exit函数传入参数0,在汇编代码中为
…
29. movl $1, %edi
30. call exit@PLT
将立即数1传给寄存器%edi,作为exit函数的参数,再调用exit函数。
(3)函数调用
exit函数由main函数调用,汇编代码中的调用方式同样为call指令调用。
4. sleep函数
(1)函数原型
unsigned int sleep(unsigned int)
(2)参数传递
hello.c中向sleep函数传入参数为atoi函数的返回值,在汇编代码中为
…
50. call atoi@PLT //调用atoi函数
51. movl %eax, %edi //atoi函数的返回值作为sleep的参数
52. call sleep@PLT //调用sleep函数
传参过程在注释中作了大致说明。
(3)函数调用
在主程序中通过call指令调用sleep函数
5. atoi函数
(1)函数原型
int atoi(const char *_Str)
(2)参数传递
hello.c中向stoi函数传入参数为argv指针数组的第4个元素argv[3],在汇编代码中为
…
46. movq -32(%rbp), %rax //再次取首地址
47. addq $24, %rax //%rax存argv[3]
48. movq (%rax), %rax //间接寻址
49. movq %rax, %rdi // argv[3]作为待传入atoi的参数
50. call atoi@PLT //调用atoi函数
传参过程在注释中作了大致说明。
(3)函数调用
在主程序中通过call指令调用sleep函数
6. getchar函数
(1)函数原型
int getchar(void)
(2)函数调用
在主程序中通过call指令调用getchar函数,汇编代码如下:
…
57. call getchar@PLT
3.4 本章小结
本章介绍了编译的概念,并分步骤介绍了编译的过程和作用,即主要有词法分析、语法分析、语义分析、中间代码生成、代码优化、代码生成等6个步骤。然后本章给出了在Ubuntu下编译的命令,并实际执行命令,展示了编译命令的执行结果。
最后对编译得到的hello.s文件进行汇编代码的深入分析。详细地从数据与赋值、算术操作、关系操作、数组/指针/结构操作、控制转移操作和函数操作等方面,分析了各类C语言数据与操作在编译后得到的汇编代码中的情况,及各类操作的底层汇编实现原理。另外,分析过程中也将汇编代码和相应的C语言代码放在同一位置进行直观对比,便于理解二者的联系。
第4章 汇编
(2分)
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编指编译器驱动程序运行汇编器(assembler),汇编器将编译得到的汇编代码文件(hello.s)进一步翻译成机器语言指令,并将这些指令打包成一个可重定位目标文件(hello.o)。
4.1.2 汇编的作用
汇编器能将汇编语言翻译为可被机器直接识别并执行的机器语言程序,并将翻译后的文件以可重定位目标文件的形式保存在.o文件中,生成的结果可作为链接器的输入,供后续链接器进一步处理。
4.2 在Ubuntu下汇编的命令
使用以下命令运行汇编器(as)进行编译,将ASCII汇编语言文件hello.s翻译成一个可重定位目标文件hello.o:
gcc -c hello.s -o hello.o
或
as hello.s -o hello.o
两种命令的执行效果分别如下图所示:
图 4.1 汇编命令执行1
图 4.2 汇编命令执行2
4.3 可重定位目标elf格式
4.3.1 readelf命令及其执行效果
在终端输入以下命令,用readelf列出hello.o各节的基本信息:
readelf -a hello.o > hello.elf
readelf命令执行效果及hello.elf头信息如下所示:
图 4.3 执行readelf命令
图 4.4 hello.elf显示的ELF头信息
4.3.2 ELF头信息
根据图4.4所示的ELF头部分,ELF头的信息主要分为以下几个部分:
(1)ELF头部序列
这一序列大小共16字节,在图4.4中即为Magic后的16进制数的序列。Magic序列按字节从左至右依次为:DEL(0x7f),E(0x45),L(0x4c),F(0x46),0x02,0x01,0x01。序列的后9个字节均为0,即未定义。ELF头的Magic先标识了这是一个ELF文件,供操作系统识别,后几个有效字节标识了ELF文件的字的大小、字节顺序和版本号等信息。
(2)ELF头其余信息
在ELF头部序列后,ELF头还包含了帮助链接器语法分析和接收目标文件的信息:ELF头的大小(64字节)、目标文件的类型(REL,可重定位文件)、机器类型(Advanced Micro Devices X86-64)、节头部表的文件偏移(1056字节)和节头部表中条目的大小(64字节)和数量(14个条目)等。
4.3.3 ELF各节基本信息
图 4.5 hello.elf显示的ELF节头部表信息
从节头部表中我们可以观察到ELF文件共有以下几个节:一个空节、.text节、.rela.text节、.data节、.bss节、.rodata节、.comment节、 .note.GNU-stack节、.note.gnu.pr[...]节、.eh_frame节、.rela.eh_frame节、.symtab节、.strtab节、.shstrtab节,共14个节。
节头部表包含了各节的名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息和对齐等信息。我们可以发现在该可重定位目标文件中,各节的地址均为0,这些都需要后续重定位等步骤确定各部分在内存中的位置。另外,根据节头部表的类型信息,我们发现两种可重定位信息分别在.rela.text节和.rela.eh_frame节,且.data节包含程序相关信息,.bss节是全为0的位。
4.3.4 ELF符号表
符号表,即.symtab节,存放程序中定义和引用的函数和全局变量的信息。在hello.elf文件中,符号表信息如下:
图 4.6 hello.elf显示的符号表信息
图4.4显示包含11个条目,包含hello.c的文件名、程序的机器代码、只读数据和程序调用的各函数名称。
4.3.5 ELF重定位节信息
图 4.7 hello.elf显示的重定位节信息
图4.6给出了hello.elf中两个重定位节:.rela.text节和.rela.eh_frame节的信息,包括重定位条目的偏移量、条目信息、条目类型、符号值、符号名称和一个偏移调整的常数值。.rela.text节包含8个条目,类型有两种:R_X86_64_PC32和R_X86_64_PLT32;.rela.eh_frame节包含一个条目,为R_X86_64_PC32类型。其中“R_X86_64_PLT32”类型表示使用PLT表寻址。
4.4 Hello.o的结果解析
使用
objdump -d -r hello.o
命令对hello.o进行反汇编,结果如下:
图 4.8 反汇编命令执行情况
1. hello.o: 文件格式 elf64-x86-64
2.
3.
4. Disassembly of section .text:
5.
6. 0000000000000000 <main>:
7. 0: f3 0f 1e fa endbr64
8. 4: 55 push %rbp
9. 5: 48 89 e5 mov %rsp,%rbp
10. 8: 48 83 ec 20 sub $0x20,%rsp
11. c: 89 7d ec mov %edi,-0x14(%rbp)
12. f: 48 89 75 e0 mov %rsi,-0x20(%rbp)
13. 13: 83 7d ec 04 cmpl $0x4,-0x14(%rbp)
14. 17: 74 19 je 32 <main+0x32>
15. 19: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 20 <main+0x20>
16. 1c: R_X86_64_PC32 .rodata-0x4
17. 20: 48 89 c7 mov %rax,%rdi
18. 23: e8 00 00 00 00 call 28 <main+0x28>
19. 24: R_X86_64_PLT32 puts-0x4
20. 28: bf 01 00 00 00 mov $0x1,%edi
21. 2d: e8 00 00 00 00 call 32 <main+0x32>
22. 2e: R_X86_64_PLT32 exit-0x4
23. 32: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
24. 39: eb 4b jmp 86 <main+0x86>
25. 3b: 48 8b 45 e0 mov -0x20(%rbp),%rax
26. 3f: 48 83 c0 10 add $0x10,%rax
27. 43: 48 8b 10 mov (%rax),%rdx
28. 46: 48 8b 45 e0 mov -0x20(%rbp),%rax
29. 4a: 48 83 c0 08 add $0x8,%rax
30. 4e: 48 8b 00 mov (%rax),%rax
31. 51: 48 89 c6 mov %rax,%rsi
32. 54: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 5b <main+0x5b>
33. 57: R_X86_64_PC32 .rodata+0x1c
34. 5b: 48 89 c7 mov %rax,%rdi
35. 5e: b8 00 00 00 00 mov $0x0,%eax
36. 63: e8 00 00 00 00 call 68 <main+0x68>
37. 64: R_X86_64_PLT32 printf-0x4
38. 68: 48 8b 45 e0 mov -0x20(%rbp),%rax
39. 6c: 48 83 c0 18 add $0x18,%rax
40. 70: 48 8b 00 mov (%rax),%rax
41. 73: 48 89 c7 mov %rax,%rdi
42. 76: e8 00 00 00 00 call 7b <main+0x7b>
43. 77: R_X86_64_PLT32 atoi-0x4
44. 7b: 89 c7 mov %eax,%edi
45. 7d: e8 00 00 00 00 call 82 <main+0x82>
46. 7e: R_X86_64_PLT32 sleep-0x4
47. 82: 83 45 fc 01 addl $0x1,-0x4(%rbp)
48. 86: 83 7d fc 07 cmpl $0x7,-0x4(%rbp)
49. 8a: 7e af jle 3b <main+0x3b>
50. 8c: e8 00 00 00 00 call 91 <main+0x91>
51. 8d: R_X86_64_PLT32 getchar-0x4
52. 91: b8 00 00 00 00 mov $0x0,%eax
53. 96: c9 leave
54. 97: c3 ret
另外,在上一章汇编得到的hello.s汇编代码相应的部分对照如下:
1. endbr64
2. pushq %rbp
3. .cfi_def_cfa_offset 16
4. .cfi_offset 6, -16
5. movq %rsp, %rbp
6. .cfi_def_cfa_register 6
7. subq $32, %rsp
8. movl %edi, -20(%rbp)
9. movq %rsi, -32(%rbp)
10. cmpl $4, -20(%rbp)
11. je .L2
12. leaq .LC0(%rip), %rax
13. movq %rax, %rdi
14. call puts@PLT
15. movl $1, %edi
16. call exit@PLT
17. .L2:
18. movl $0, -4(%rbp)
19. jmp .L3
20. .L4:
21. movq -32(%rbp), %rax
22. addq $16, %rax
23. movq (%rax), %rdx
24. movq -32(%rbp), %rax
25. addq $8, %rax
26. movq (%rax), %rax
27. movq %rax, %rsi
28. leaq .LC1(%rip), %rax
29. movq %rax, %rdi
30. movl $0, %eax
31. call printf@PLT
32. movq -32(%rbp), %rax
33. addq $24, %rax
34. movq (%rax), %rax
35. movq %rax, %rdi
36. call atoi@PLT
37. movl %eax, %edi
38. call sleep@PLT
39. addl $1, -4(%rbp)
40. .L3:
41. cmpl $7, -4(%rbp)
42. jle .L4
43. call getchar@PLT
44. movl $0, %eax
45. leave
46. .cfi_def_cfa 7, 8
47. ret
总体来看,反汇编得到的汇编代码和编译得到的hello.s的汇编代码大致相同,但在以下几个方面有区别:
(1)操作数表示方式不同
在编译得到的汇编代码文件hello.s中,除立即数外,操作数均为10进制表示,而在反汇编代码中,同样操作的操作数均为16进制表示。
(2)函数调用方式不同
在编译得到的hello.s中,函数调用方式为call指令 + 函数名称@PLT,只说明了函数调用的控制转移方式为PLT寻址;而在反汇编代码中,函数调用形式为call指令 + 相对于主函数的偏移地址。反汇编代码说明在汇编后,函数调用的控制转移部分添加了可重定位信息,这些函数调用会在链接器重定位后确定需要跳转到的运行时地址,因此汇编器在.rela.text节中添加了函数调用的可重定位条目,后续将在链接器中完成重定位。
(3)分支转移方式不同
在编译得到的hello.s中,分支转移方式为跳转指令(含条件后缀) + 跳转目标代码段的名称,如.L2,.L3,.L4等。反汇编代码中分支转移方式为跳转指令(含条件后缀) + 相对于主函数的偏移地址。与函数调用类似,反汇编代码说明了在汇编后,分支转移的控制转移部分也添加了可重定位信息,待链接时确定需要跳转到的运行时地址。
(4)全局变量访问方式不同
在hello.s中,全局变量,如printf中的格式串以代码段的名称为标识(如.LC0,.LC1),放在汇编代码的起始部分,并在访问时以代码段名称+(%rip)作为地址加载的源操作数,如.LC0(%rip),.LC1(%rip)。而在反汇编代码中,格式串均通过0x0(%rip)的形式访问,不再以代码段名称作为区分。在汇编后的全局变量同样含有可重定位信息,于是在主函数中地址就都以0x0加上PC的当前运行时值表示,待后续链接时才确定全局变量的运行时地址。
4.5 本章小结
本章先介绍了汇编的概念与作业,给出了在Ubuntu下汇编的命令。然后对hello.s执行汇编命令得到hello.o可重定位目标文件,详细分析了ELF的头信息、各节基本信息、符号表和重定位节信息等ELF主要部分的情况。最后通过hello.o的反汇编结果分析了汇编后得到的可重定位文件于编译得到的汇编代码文件hello.s在数据表示和控制转移等方面的区别。
第5章 链接
(1分)
5.1 链接的概念与作用
5.1.1 链接的概念
链接(linking)是指通过链接器(linker)将各种代码和数据片段收集并组合为一个单一文件的过程,所得到的文件可以被加载(复制)到内存并执行。
在本章中,链接指的是将经过前面步骤得到的可重定位目标文件hello.o,通过调用链接器(ld),将各中代码和数据片段组合成一个可执行文件hello的过程。
5.1.2 链接的作用
- 链接对可重定位目标文件进行符号解析和重定位,将各个代码片段和数据片段有条理地组合到一个单一文件中,并赋予了各信息片段的运行时地址,从而让这些代码和数据片段真正能在操作系统中运行起来。
- 链接在软件开发中还有一个关键的作用,就是实现分离编译。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
在Ubuntu中执行以下命令,调用链接器ld,将可重定位文件hello.o和所需的库文件链接成单一可执行目标文件hello:
ld -o hello -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 hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
链接命令的执行情况如下:
图 5.1 执行链接命令
5.3 可执行目标文件hello的格式
5.3.1 readelf命令及其执行效果
在终端输入以下命令,用readelf列出可执行目标文件hello各段的基本信息:
readelf -a hello > hello_exe.elf
readelf命令的执行情况如下:
图 5.2 readelf命令执行情况
5.3.2 ELF头信息
打开生成的hello_exe.elf文件,观察hello的ELF格式下的头部信息:
图 5.3 hello在ELF格式下的头信息
与hello.o文件的ELF头信息相比,可执行目标文件hello的ELF头信息中,字节序列Magic、ELF头的大小(64字节)、机器类型(Advanced Micro Devices X86-64)和节头部表中条目的大小(64字节)都没有改变,但ELF文件中给出的文件的类型变为EXEC(可执行文件),还给出了程序的入口点地址(0x4010f0)。
另外,节头部表的文件偏移也改变成13568字节(原来是1056字节),节头部表中条目的数量增加到27个(原来是14个条目)。ELF文件中还能观察到,hello中出现了程序头的大小(56字节)和数量(12个)
5.3.3 ELF各段基本信息
节头部表中记录了各段的基本信息,包括段名称、段类型、起始地址、偏移量、大小、旗标、链接、对齐方式等信息:
图 5.4 hello在ELF格式下的头部表信息
与hello.o的ELF文件相比,hello的节头部表中条目明显更多,所包含的各段信息也更丰富。另外,hello的ELF格式下没有重定位类型的节,说明经过链接后hello程序的各段已经被赋予运行时地址,确定了在内存中的运行时位置。
5.4 hello的虚拟地址空间
使用edb加载hello,加载后界面如下图:
图 5.5 hello在ELF格式下的头部表信息
其中,Data Dump显示了本进程的虚拟地址信息:
图 5.6 hello进程的虚拟地址信息
虚拟地址范围为0x401000~0x402000,下面分析虚拟地址下的各段信息与ELF格式下的各段信息之间的对应关系。
图 5.7 虚拟地址中关于.interp段的信息
图 5.8 虚拟地址中关于.note.gnu.pr[…]节的信息
图 5.9 虚拟地址中关于.text段的信息
图 5.10 虚拟地址中关于.rodata段的信息
图 5.11 虚拟地址中关于.data段的信息
图 5.12 虚拟地址中关于.symtab段的信息(部分)
以上列举了部分虚拟地址区域中的数据,并与hello的ELF格式下各段的信息相对照。可以发现,hello的各段在虚拟地址空间中也是和节头部表中的信息相对应的。
5.5 链接的重定位过程分析
5.5.1 执行反汇编命令
在shell中执行命令,
objdump -d -r hello > hello.asm
通过可执行文件hello生成反汇编文件hello.asm:
图 5.13 反汇编命令执行情况
在图4.7中给出了hello.o的反汇编代码,此处给出的是可执行程序hello的反汇编代码文件hello.asm信息。
5.5.2 可执行文件与可重定位文件的区别
1. 新增函数
相比于hello.o的反汇编结果,hello反汇编代码中新增了puts@plt函数、printf@plt函数,getchar@plt函数,exit@plt函数,sleep@plt函数,这些都是main函数所调用的库函数,是动态链接器在动态链接时从共享库中加入到可执行文件中的。此外,程序在加载时需要被调用的启动函数_start(),还有程序初始化代码要调用的函数_init(),都是可执行文件相比于可重定位文件所新增的函数。
图 5.14 新增的调用库函数
图 5.15 新增的_start()函数
图 5.16 新增的_init()函数
2. 新增节
在可执行文件hello反汇编代码中,还能观察到一些新增的节,如.init节、.plt节、.plt.sec节、.fini节等:
图 5.17 新增的.init节
图 5.18 新增的.plt节
3. 可重定位信息
在hello的反汇编结果中,我们发现相比于hello.o的反汇编结果,已经没有了可重定位的信息。hello中没有重定位条目,而条件控制指令的调转目标已经变成虚拟内存地址,函数调用指令的目标也变为函数入口的地址,所有在hello中的代码都有确定的运行时地址。
5.5.3 综合分析
综合以上分析,对比可执行目标文件hello和可重定位目标文件hello.o,我们可以总结出链接器链接hello.o的过程:首先链接器进行符号解析,将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。由此就得到了含有可重定位信息的、已构造好对应关系的符号集合,链接器由此可知它的输入目标模块中的代码节和数据节的确切大小。
符号解析完成后,链接器开始重定位过程:第一步是重定位节和符号定义,链接器将所有相同类型的节合并为同一类型的新的聚合节,使得程序中的每条指令和全局变量都有唯一的运行时内存地址;第二步是重定位节中的符号引用,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
5.6 hello的执行流程
使用edb执行hello,首先设置调用hello的命令行参数:
edb --run hello 2021113689 huangyj 1
edb开始运行,指向第一条指令:
图 5.19 调用edb运行情况
从加载hello到_start,到call main,以及程序终止的所有过程中,程序调用与跳转的各个子程序名和程序地址如下:
程序名称 | 程序地址(16进制) |
ld-2.31.so!_dl_start | 0x7ffff7fd0df0 |
ld-2.31.so!_dl_init | 0x7ffff7ff606d |
hello!_start | 0x4004a6 |
libc-2.31.so!__libc_start_main | 0x7ffff7dd9aeb |
hello!printf@plt | 0x400485 |
hello!sleep@plt | 0x400499 |
hello!getchar@plt | 0x40048c |
libc-2.31.so!exit | 0x7ffff7ff3345 |
5.7 Hello的动态链接分析
程序在调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,而GNU 编译系统巧妙地使用延迟绑定(lazy binding)来解决这个问题,也就是将过程地址的绑定推迟到第一次调用该过程时。
动态链接器的延迟绑定是通过全局偏移量表(Global Offset Table, GOT)和过程链接表(Procedure Linkage Table, PLT)两个数据结构来实现的。其中,GOT存放需要跳转的目标函数地址,PLT使用GOT中的地址跳转到目标函数。
GOT和PLT协同工作的过程如下:首先在一个共享库函数被第一次调用时,延迟解析其运行时地址,程序调用进入PLT[2];第一条PLT指令通过GOT[4]进行间接跳转,将控制传回PLT[2]的下一条指令;然后将函数地址压栈,PLT[2]跳转到 PLT[0];最后PLT[0]通过GOT[l]把动态链接器的一个参数压入栈中,再通过GOT[2]间接跳转进动态链接器中。动态链接器确定函数运行时位置,重写GOT[4],同时将控制传递给函数。
图 5.20 调用dl_init前的.got.plt段
在调用dl_init后.got.plt情况如下:
图 5.21 调用dl_init后的.got.plt段
所以在调用后,GOT[1]和GOT[2]的内容发生了变化:GOT[1]保存指向已经加载的共享库的链表地址,GOT[2]是动态链接器在ld-linux.so模块中的入口。后续再次调用共享库函数时,就能利用GOT和PLT的协同工作实现动态链接。
5.8 本章小结
本章介绍了链接的概念和作用,给出了在Ubuntu下链接的命令。然后以hello.o的链接为例,分析了链接得到的可执行目标文件hello在ELF格式下的各部分信息。
本章除了分析hello的静态格式,也将hello加载到edb中分析运行时的虚拟地址空间,并在edb中详细对比了hello在链接前后的区别,介绍了hello.o链接的过程和重定位原理。最后在edb中动态调试hello程序,分析了程序的执行流程和动态链接的过程。
第6章 hello进程管理
(1分)
6.1 进程的概念与作用
6.1.1 进程的概念
进程的经典定义就是一个执行中程序的实例。进程是操作系统对正在运行的程序的一种抽象。系统中的每个程序都运行在某个进程的上下文(context)中。
6.1.2 进程的作用
进程给应用程序提供两个关键抽象:
- 一个独立的逻辑控制流,它提供一个假象,好像程序独占地使用处理器。
- 一个私有的地址空间,它提供一个假象,好像程序独占地使用内存系统。
在本文中以shell程序为例,通过在shell中输入hello文件名运行hello时,shell会创建一个进程,然后在这个新进程的上下文中运行可执行目标文件hello。hello进程也能创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
1. 交互式shell与登录式shell
用户可以与shell进行交互,处理用户键入的命令, 并得到命令输出,具有IO交互功能的shell为交互式shell;反之,执行脚本文件命令, 直至文件结束自动退出,这类运行shell脚本的shell为非交互式shell。
类似地,在登录会话产生时, 第一个以该用户ID执行的进程为登录shell,例如SSH;反之,登录会话中非第一个用户ID执行的进程都是非登录shell,如打开GUI命令行终端。
2. 实现内置命令
内置命令(builtin command)是Shell自己实现的命令, 一些内置命令是因为某些功能仅能通过内置化实现, 另一些则是追求性能而内置化实现。
3. 作业控制
shell能实现有选择地停止(挂起)某些进程的执行, 或者继续(恢复)某些停止进程的执行。在终端交互中, 终端驱动将一些键盘的特殊输入转变为信号发给前台进程, 如SIGINT(Ctrl+C), SIGQUIT(Ctrl+\), SIGTSTP(Ctrl+Z)。利用键盘的特殊输入与Shell的作业控制功能, 可以方便控制前后台作业的切换。
4. 重定向
Unix系统默认打开进程的3个文件描述符:标准输入(stdin)、标准输出(stdout)、标准出错(stderr)。命令在执行之前, 它的文件描述符可以在shell的处理下被复制、打开、关闭、指向不同的文件等。文件重定向便利了文件的IO操作。
6.2.2 Shell-bash的处理流程
shell处理的第一步是对输入的字符串进行词法分析,将命令行字符串切割为一个个单独的token,然后对第一个token进行别名检查, 若其为别名则进行展开, 并对展开字符串继续切割。接着继续进行一系列展开操作, 最后查找命令, 判断是函数、内置命令还是可执行文件, 并在特定环境中执行它们。这一过程根据引号的形式,大致分为以下三种情况:
1. 无引号
别名展开 → 花括号展开 → 波浪号展开 → 变量展开 → 算术展开 → 命令替换 → 路径名展开 → 命令查找
2. 单引号
直接进行命令查找
3. 双引号
仅有参数展开 → 算术展开 → 命令替换 → 命令查找
6.3 Hello的fork进程创建过程
在shell中输入“./hello 2021113689 huangyj 2”并键入回车,此时shell识别到这是一个不带引号的字符串,经过字符串切割和相应解析后,shell确定“hello”不是一个内置命令,所以shell将hello作为一个带参数的可执行文件来执行。
将hello作为可执行文件执行时,shell会为hello创建一个子进程,在子进程中执行hello程序。其中,新创建的子进程与父进程具有完全相同的且相互独立的虚拟地址空间,共享打开的文件描述符,但与父进程有不同的ID。另外,在调用fork函数创建子进程时,函数只在父进程中调用一次,但会返回两次:在父进程中国返回子进程的PID,在子进程中返回0。
子进程终止后,需要进行回收:若此时父进程未终止,则由父进程回收;若父进程终止,则由内核创建的进程init回收子进程。
图6.1 用fork创建hello进程
6.4 Hello的execve过程
execve 函数在当前进程的上下文中加载并运行可执行目标文件hello, 且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到文件名,execve才会返回到调用程序(返回-1)。与fork一次调用返回两次不同,一般情况下execve 调用一次且从不返回。
execve首先覆盖当前进程的代码、数据和栈等信息,删除已有的页表和结构体链表,将这块内存区域初始化,但保留当前的PID,并继承已打开的文件描述符和信号上下文。然后execve将可执行文件的代码和数据映射到相应的内存区域,最后设置PC的值指向_start()函数的入口地址, _start()会调用主函数,完成加载。
6.5 Hello的进程执行
1. 进程上下文信息
系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据, 它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
2. 进程时间片
我们向shell输入可执行目标文件hello,运行程序时,shell通过fork创建一个新的进程hello,并在hello进程的上下文中运行文件。操作系统为进程hello提供了一个独立的逻辑控制流,逻辑控制流即为程序运行时执行的指令所对应的PC值序列,一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)
3. 用户态和核心态
处理器用某个控制寄存器中的一个模式位来实现区分用户态和核心态。没有设置模式位时,进程处于用户态,不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据,否则会导致保护故障,直接终止进程。当设置模式位时,进程处于核心态,核心态下的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。进程从用户态进入核心态的唯一方法是通过诸如中断、故障或者陷人系统调用这样的异常。
4. 进程调度
我们将一个进程和其他进程轮流运行的概念称为多任务(multitasking),上面所说的时间分片就一种多任务技术。为了实现多任务,操作系统内核使用调度的策略和上下文切换(context switch)的异常控制流。调度指的是,内核为每个进程维持一个上下文,在hello进程执行的某些时刻(如调用sleep函数陷入系统调用),内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。当内核调度了一个新进程时,将保持hello进程的上下文,恢复原先被抢占进程的上下文,并将控制传递给被恢复的进程。
进程调度的过程依赖上下文切换的技术,最开始hello进程在用户态下运行,当hello调用了sleep函数时,内核决定抢占hello进程:先在核心态下代表hello进程执行指令,开始上下文切换,恢复原先被抢占进程的上下文。然后在某一时刻,内核开始代表被恢复的进程在核心态下执行指令,并最终回到用户态,在用户态下代表被恢复进程执行指令。在接下来的某一时刻(如sleep函数计时结束并返回时),内核收到一个信号,开始执行从被恢复进程回到hello进程的上下文切换,再次进入核心态,直至回到hello进程,完成上下文切换,继续hello的逻辑控制流。
6.6 hello的异常与信号处理
正常执行程序,在shell中输入“./hello 2021113689 huangyj 2”并键入回车,观察hello的执行情况:
图6.2 正常执行hello进程
程序每隔2秒输出一行语句,当我们在程序执行时输入空格,hello的执行情况如下:
图6.3 执行hello进程同时按下回车
可以发现,在hello执行时按下回车,并未对程序运行造成实际影响,hello依旧在每隔2秒打印一行语句,只是在按下回车后下一行语句将间隔一个空行后打印。当程序打印结束后,shell读取之前用户输入过的回车,作为空命令读取,打印与回车数量相同的命令提示符。
接着,我们在hello执行时按下Ctrl-Z,观察hello的执行情况:
图6.4 执行hello进程同时按下Ctrl-Z
我们看到在按下Ctrl-Z前,程序正常打印;按下Ctrl-Z后,shell显示“^Z”,同时hello进程停止。在用户按下Ctrl-Z时,内核向前台进程组中的所有进程发送SIGTSTP信号,前台进程(包括hello)收到信号后执行SIGTSTP下的默认行为,即进程停止(挂起)。此时命令行提示“[1]+ 已停止 ./hello 2021113689 huangyj 2”,我们在shell中输入jobs和ps命令确认hello是否被挂起:
图6.5 jobs命令查看hello进程挂起情况
图6.6 ps查看hello进程挂起情况
可以确定hello进程确实被挂起,并未被回收。然后此时输入pstree命令,可以查看当前的进程树:
图6.7 pstree查看当前进程树(部分)
现在输入“kill -9 %1”命令,即杀死后台挂起的hello所在的进程组所有进程,然后再次输入ps查看后台进程情况:
图6.8 kill杀死hello所在进程组进程
观察到进程hello已被杀死。说明我们从shell执行杀死指定进程(组)命令时,shell在判断当前命令的合法后解析需要杀死的进程(组),然后向该进程(组中的所有进程)发送SIGKILL信号,目标进程接收到信号,执行默认行为终止进程。另外,我们也可以在hello进程挂起时输入“fg 1”命令,将后台挂起的hello进程调到前台运行:
图6.9 fg将后台进程放到前台继续运行
输入fg命令后,观察到shell打印了最初执行hello所用的命令,然后hello进程继续停止前的执行状态,输出后几行语句。说明执行fg命令后,内核向PID为1的进程hello发送SIGCONT命令,hello进程接收SIGCONT命令,到前台继续运行。
我们再看在hello执行时键盘按下Ctrl-C,观察hello执行情况:
图6.10 执行hello进程同时按下Ctrl-C
观察到在按下Ctrl-C的同时,hello进程终止,不再输出语句,shell再次给出命令提示符。说明用户在键盘输入Ctrl-C时,内核会向当前前台作业的每个进程发送SIGINT信号,前台进程(包括hello)收到信号后执行默认行为,进程终止。
最后,在执行hello进程时不停乱按,观察hello执行情况:
图6.11 执行hello进程同时乱按键盘
我们观察到,在hello执行时不停乱按,产生的输入将缓冲至stdin,其中getchar函数读出一个“\n”结尾的字符串,则剩下的字符串(可能会因“\n”被当成命令)会在hello进程结束后作为命令行的输入呈现在命令行上。
6.7本章小结
本章介绍了进程的概念与作用,简述了Shell-bash的几个主要作用和命令解析等处理流程。随后分析了shell执行hello时的进程创建过程,加载hello时execve的执行过程。然后以hello为例,分析进程的操作系统中进程的执行情况。最后以hello执行过程中在shell上输入不同命令为例,分析了操作系统的异常控制流和信号处理过程。
第7章 hello的存储管理
( 2分)
7.1 hello的存储器地址空间
7.1.1 逻辑地址
在程序的存储空间中,逻辑地址是指由程序产生的与段相关的偏移地址部分,逻辑地址由选择符和偏移量两部分组成。以hello程序为例,其中的逻辑地址为hello.asm中的相对偏移地址。
7.1.2 线性地址
线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。以hello程序为例,线性地址为hello在实际运行时所在的内存区域。
7.1.3 虚拟地址
使用虚拟寻址时, CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送到内存之前先由MMU翻译成相应的物理地址。在一个带虚拟内存的系统中,虚拟地址也是一种线性地址。以hello为例,虚拟地址即为hello的运行时地址,虚拟地址是通常情况下程序可见的地址。
7.1.4 物理地址
在计算机系统中,存储器以字节为单位存储信息。为正确地存放或获取信息,每个字节单元对应一个唯一的存储器地址,称为物理地址,又叫实际地址或绝对地址。对于hello而言,其运行时的物理地址就是hello加载到内存在内存中实际占用的区域。
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.2.1 Intel段式管理简介
Intel处理器从逻辑地址到线性地址的变换通过段式管理的方式实现,段式管理包含保护和地址变换两个部分。80X86从逻辑地址到物理地址变换需经过两个阶段。第一阶段使用分段机制把程序的逻辑地址变换成处理器可寻址内存空间(称为线性地址空间)中的地址。第二阶段的分页机制把线性地址转换成物理地址。这里主要分析第一阶段的逻辑地址到线性地址的变换过程。
在段式管理中,一个逻辑地址包含两部分:段标识符和段内偏移量。段标识符由一个16位字段组成,称为段选择符。其中前13位是索引号,后3位包含一些硬件细节,用于标识代码段寄存器、栈段寄存器或数据段寄存器,如图7.1所示。
图7.1 16位段标识符(段选择符)的格式
索引号是段描述符(segment descriptor)的索引,段描述符具体地址描述了一个段。若干段描述符组成一个段描述符表数组。因此,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,每一个段描述符由8个字节组成。其中BASE字段表示包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。全局段描述符表(GDT)存放全局的段描述符,局部段描述符表(LDT)存放局部段描述符,如程序的局部段。T1 = 0则用GDT,T1 = 1则用LDT。
7.2.2 段式管理逻辑地址变换
如图7.2所示,逻辑地址到物理地址的变换过程主要有两部分:
- 先检查段选择符中的T1字段,以确定段描述符保存在哪一个描述符表中,T1=0时选择GDT中的段,此时分段单元从gdtr寄存器中得到GDT的线性基地址;T1=1时选择LDT中的段,此时分段单元从ldtr寄存器中得到GDT的线性基地址。再根据相应寄存器,得到其地址和大小。
- 由于一个段描述符占8字节,因此在GDT或LDT内的相对地址是由段选择符的最高13位的值左移3位得到。根据段选择符的前13位,在段描述符表查找到对应的段描述符,从而取得相应的地址偏移量offset
- 将基址值Base加上偏移量offset,即可得到最终的线性地址。
图7.2 段式管理逻辑地址变换
7.3 Hello的线性地址到物理地址的变换-页式管理
Hello的线性地址到物理地址的变换通过对虚拟地址空间进行分页和管理来实现。这里每个虚拟页块的大小为P=2p字节,由于内存中的页表作为作为磁盘的高速缓存,对应物理内存也以页为单元,物理页大小也为P字节。
图7.3给出了页式管理下虚拟(线性)地址到物理地址的变换过程:
图7.3 页式管理虚拟地址变换
hello进程执行时,CPU中的页表基址寄存器指向hello进程的页表。在程序访问内存单元时,数据的虚拟地址在内存管理单元(MMU)中被翻译成相应的物理地址,大致翻译过程为:首先MMU取出虚拟地址中的VPN字段,作为页表条目的地址找到相应位置的页表条目;然后根据有效位判断页表条目是否有效,若有效位为0,则出现缺页故障,转缺页异常处理;有效位为1时,MMU到页表项的PPN字段取出物理页号,然后将得到的PPN作为高位字段,与虚拟地址的偏移量字段VPO合并,得到相关存储单元的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB与四级页表支持下的VA到PA的变换如图7.4所示:
图7.4 TLB与四级页表下VA到PA的变换
7.4.1 TLB的引入
在只使用页表的情况下,每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE, 以便将虚拟地址翻译为物理地址。在最坏情况下,这会要求从内存多取一次数据,代价是几十到几百个周期。为了继续减少访存的开销,MMU中还包括了一个关于PTE的小的缓存,称为翻译后备缓冲器(Translation Lookaside Buffer,TLB)。TLB 是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块,通常有高度的相联度。
7.4.2 TLB命中情况下的地址变换
首先CPU产生一个虚拟地址,MMU从虚拟地址中取出VPN字段,先前往TLB查找相应的页表项。根据VPN字段的低位与TLB的组索引(TLBI)字段匹配进行组选择,再根据VPN的高位字段与TLB的标记(TLBT)进行行匹配,若有TLB条目的标记匹配成功,则说明页表项已经缓存在TLB中,即TLB命中。然后MMU在TLB中的相应条目下取出PPN字段,将PPN作为高位,与低位的PPO组成物理地址。
7.4.3 TLB不命中情况下的地址变换
当MMU根据VPN字段到TLB中进行组选择和行匹配后,未能找到相应的页表条目,则为TLB不命中,则MMU需要到页表中查找条目。对于四级页表,高位的VPN字段将被划分成4个较小的VPN字段,页表大小与页大小相同时,访问每级页表所需要的VPN字段长度也相同,于是MMU开始逐级访问页表:首先将VPN字段均分成四个字段VPN1~VPN4,根据VPN1字段的信息到第一级页表访问条目,其中每个第一级页表条目都存储第二级页表某个片的基址(每个片都是由一定大小的连续页面组成),在查找到与VPN1字段相对应的页表条目后,MMU从该条目下取出第二级页表的某个片的基址。
然后MMU根据取出基址,与VPN2字段组合成表索引,访问二级页表,二级页表条目存储三级页表的片基址,从而MMU取出三级页表片基址,与VPN3继续访问三级页表。四级页表的形式与单级页表基本相同,页表项下存储PPN的信息。于是在得到四级页表的片基址后,MMU通过VPN4访问四级页表项,取出相应页表项中的PPN,与原来的VPO作为PPO,组合成物理地址。
7.5 三级Cache支持下的物理内存访问
三级高速缓存层次结构及相关信息如图7.5所示:
图7.5 三级Cache层次结构
图7.6 通用的高速缓存存储器组织结构
7.5.1 三级cache结构
1. L1 cache
每个处理器核都含有一组L1级cache,每组L1 cache包含一个指令cache(i-cache)和一个数据cache(d-cache),一般为8路组相联结构,缓存L2 cache的内容。
2. L2 cache
L2统一cache也是每个处理器核各有一个,一般为8路组相联结构,缓存L3 cache的内容。
3. L3 cache
L3 cache是所有处理器核共用的高速缓存,一般为16路组相联结构,缓存来自主存的信息。
7.5.2 三级cache物理内存访问
根据以上对三级cache结构的分析,我们可以总结出各级cache之间数据访问的一般性。每一级缓存都缓存着低一级存储层次的信息,因此不失一般性,我们可以先根据图7.5的通用组织结构来分析三级cache的内存访问
首先对于某一级的缓存结构,一共有三个主要参数:缓存组数S=2s,对应物理地址有s位组索引;缓存每组行数E = 2e,则对应物理地址的e位组内偏移;缓存块大小B = 2b字节,对应b位块内偏移。物理地址剩下的字段作为标记,唯一标识缓存所映射的下一级存储器的存储区域。另外每个缓存行还有一个有效位,标志该缓存块是否有效。
图7.7 组相联高速缓存的地址字段划分
在访问组相联高速缓存时,物理地址被划分为三个字段,就是以上分析对应的标记字段、组索引字段、块偏移字段,如图7.6所示。访问高速缓存的过程(cache命中)大致如下:
- 首先根据物理地址的组索引字段,找到对应的缓存组;
- 根据物理地址的标记字段,在组内寻找是否有该标记的块;
- 若标记字段与缓存块标记匹配成功,且有效位为1,则缓存命中;
- 处理器根据块内偏移字段访问缓存块中具有相同块偏移的存储单元。
若cache不命中,即未找到组内有相同标记字段的缓存块,或者有效位为0,则缓存未命中,需要到下一级存储结构访问具有相同地址的存储单元,若下一级存储结构仍未命中,则继续访问更低级的存储结构。另外,在上述过程中每发生一次缓存不命中,都要有实施相应的替换策略,将下一级存储结构的相应存储单元加载到当前一级未命中的高速缓存中。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、vm_area_struct链表和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此也为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。execve加载并运行hello需要以下几个步骤:
1. 删除已存在的用户区域
删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2. 映射私有区域
为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.text和.data区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。图7.7概括了私有区域的不同映射。
3. 映射共享区域
hello程序与共享对象libc.so等库链接,那么这些共享对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4. 设置程序计数器(PC)
设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
图7.8 execve函数的内存映射
7.8 缺页故障与缺页中断处理
7.8.1 缺页故障
处理器中的MMU在试图翻译某个虚拟地址时,该地址所在页已被映射在虚拟地址空间中,但是目前并未被加载在物理内存中,从而引起中断异常。
缺页故障是一个故障类型的异常,属于潜在可恢复的错误。
7.8.1 缺页中断处理
发生缺页异常后处理过程如图7.8所示
图7.9 缺页中断处理过程
- 内核保存当前进程的上下文,调用缺页异常处理程序。
- 在异常处理程序中,内核会先检查虚拟地址是否合法。处理程序搜索区域结构的链表,把虚拟地址和每个区域结构中的vm_start和_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。
- 内核再判断试图进行的内存访问是否合法,进程是否有读、写或者执行这个区域内页面的权限。若内存访问不合法,那么缺页处理程序会触发一个保护异常,从而终止这个进程。
- 以上情况都不是,那么内核确认这个缺页是由于对合法的虚拟地址进行合法的操作造成的。处理程序检查是否有空闲页框,如果没有空闲页框,则执行页面替换算法寻找一个牺牲页。
- 若牺牲页被修改过(脏页),则安排该页写回磁盘,然后将新页装入内存中,更新页表。
- 恢复发生缺页中断指令以前的状态,程序计数器重新指向这条指令,访问引起缺页的地址,此时该页已经在内存中,故命中,主存将所请求字返回给处理器。
7.9本章小结
本章主要分析了系统在执行hello程序时的存储管理过程。首先从逻辑地址、线性地址、虚拟地址和物理地址方面分析了hello的存储器地址空间,然后介绍了Intel中段式管理的方式,解释了段式管理中逻辑地址到线性地址的变换过程。同时介绍了页式管理方式下hello的线性地址到物理地址的变换过程。
本章以TLB与四级页表支持下的虚拟存储结构为例,详细分析了该存储结构下TLB命中和不命中的虚拟地址到物理地址的变换过程。还以Intel Core i7处理器为例,分析了三级Cache的处理器存储结构,及三级cache支持下的物理内存访问。本章在虚拟内存方面也分别解释了hello在调用fork和execve时发生的内存映射的原理,在最后分析了缺页故障和缺页中断的处理流程。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello的一生是这样的
1. 预处理
hello.c源代码文件在C预处理器cpp的处理下,将所有include头文件合并到ASCII码源文件中,替换include指令,得到一个ASCII码中间文件hello.i。
2. 编译
在C编译器cc1的处理下,通过词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成等步骤,hello.i文件被翻译成一个汇编语言文件hello.s。
3. 汇编
hello.s文件在C汇编器as的处理下,文件中的汇编语言被翻译成机器语言,翻译后的机器语言程序文件以可重定位目标文件的形式保存在hello.o文件中。
4. 链接
机器语言程序文件hello.o在链接器ld的处理下,代码和数据片段与其他文件的内容一起收集并组合成一个单一的可执行目标文件hello。
5. 加载和运行
在shell中输入可执行文件hello的名称和相关命令行参数,shell调用fork为程序hello创建了一个新的进程,同时调用了execve将hello的代码和数据等信息加载到内存中,hello成为了一个运行状态下的进程。
6. 访存
在hello执行过程中,会访问操作系统的虚拟内存空间,执行程序指令和访问各种数据,这需要操作系统的虚拟内存管理和内核的各类功能来实现,需要软硬件协调工作。
7. 调度
作为一个在操作系统中运行的进程,hello需要接收内核的调度,在调用某些函数陷入系统调用时,hello进程在内核模式下运行,内核保存hello的上下文,通过上下文切换将控制传递给内核函数,执行系统调用,最终通过上下文切换将控制交还给hello进程。
8. 异常处理
在hello运行的过程中,时常会出现一些异常控制流。在访问虚拟内存时,可能会发生缺页异常,此时内核将控制转移给缺页异常处理程序,将缺失的页面加载到内存中,恢复程序的运行。另外,在hello运行时还会接收到内核发送的信号,当用户在shell中用键盘输入命令时,内核会向hello发送相应的信号,让hello在接收信号后执行相应行为。
9. 终止和回收
hello在退出运行,进程终止后,需要由父进程shell回收,内核删除hello进程在内存中的代码和数据。
最后,hello走完了“P2P”,“020”的一生。
理解与感悟
计算机系统这门课带领我深入学习了计算机系统各方面的底层原理,让我以一个编程者的角度实实在在地遨游了一遍计算机系统,带领我见识到以前从未见过,甚至从未听说过的事物,但是这些事物、概念又是十分基础、具有重要意义的。
计算机系统课程将许多编程者平时没注意到或者没有仔细研究过的系统底层部分,用图文并茂、通俗易懂的方式详细讲授给像我一样对编程没有深入的理解,对于计算系统的底层原理更是一窍不通的编程新手,让我感受到了深深的震撼。
学习完这门课程,我真正体会到了计算机是人类历史上一个相当伟大的发明,计算机系统也是人类史上一个非常精妙的设计。它用简单的逻辑门实现复杂的运算,实现各种硬件单元,而近乎无数的硬件单元组成各种功能强大的硬件模块,在这些硬件模块的基础上,人们又设计了很多程序。最后,各种硬件程序和底层软件,以精妙的方式相互协调,组成一个功能十分强大的计算机系统。和人类史上所有伟大的创造一样,都是由最简单的材料、最普通的元件,融合进人类的智慧和强大的创造力、想象力,搭建成一个不可思议的复杂系统,建造成一个雄伟辉煌的摩天大楼。
计算机系统课程就像一次旅行,从hello开始,又以hello结束。但是一个普普通通、平平无奇的C语言程序hello,竟能让我们认识到计算机系统的这么多方面,增长了许多宝贵的知识。“hello”当之无愧为这场旅行的神秘向导。当然,这场旅行只是一个开端,在将来我还会对计算机系统进行更深入的学习,许多我还未了解、尚未抵达的地方,正等待着我去探索、发现。
附件
中间文件 | 作用 |
hello.i | hello.c经预处理后得到的ASCII码中间文件 |
hello.s | hello.i经过编译得到的ASCII汇编语言文件 |
hello.o | hello.s经过汇编得到的可重定位目标文件 |
hello.elf | hello.o用readelf命令得到的ELF格式输出文件 |
hello_exe.elf | 可执行目标文件hello用readelf命令得到的ELF格式输出文件 |
hello.asm | 可执行目标文件hello经过反汇编命令输出的文件 |
参考文献
[1] Randal E.Bryant . 深入理解计算机系统[M]. 北京:机械工业出版社,2016.7
[2] Alfred V.Aho. 编译原理[M]. 北京:机械工业出版社,2009.1
[3] C语言的预处理_1、完成对类c语言源代码的预处理过程。 2、去除注释内容、空行及多余空格,包括单_Ssorrymaker的博客-CSDN博客
[4] C语言概念笔记——预处理_高处不胜han的博客-CSDN博客
[5] 编译和链接的过程_编译链接四个步骤_douguailove的博客-CSDN博客
[6] 深度剖析c语言main函数---main函数的返回值_main的返回值_z_ryan的博客-CSDN博客
[7] C printf() 详解之终极无惑_恋喵大鲤鱼的博客-CSDN博客
[8] C语言atoi函数_atoi的头文件_C语言技术网的博客-CSDN博客