计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 数据科学与大数据技术
学 号 2022110xxx
班 级 2203501
学 生 xx
指 导 教 师 吴锐
计算机科学与技术学院
2024年5月
摘 要
本文主要围绕“hello程序的一生”,介绍了从hello.c经过预处理、编译、汇编、链接等一直到生成可执行目标文件hello的过程,且在Ubuntu的环境下通过各种调试工具详细分析了每一阶段产生的结果,对于深入理解计算机系统有很大帮助。
关键词:hello;预处理;编译;汇编;链接;进程管理;存储管理;I/O管理;
目 录
第1章 概述
1.1 Hello简介
P2P(From Program to Process):
程序通过预处理、编译、汇编、链接最终形成可执行文件。在shell中输入相关命令,调用fork()函数创建子进程。
图1.1-1 编译系统
预处理阶段:预处理器(cpp)根据以字符#开头的命令,修改原始c程序。
编译阶段:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言的程序。
汇编阶段:汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在hello.o中。
链接阶段:链接器(ld)将单独的预编译好了的目标文件合并到hello.o中,结果得到hello文件
O2O(From Zero-0 to Zero-0):
在shell中输入相关命令,调用fork函数创建新的子进程,execve函数加载并运行程序,通过内存映射映射到相应的虚拟内存空间,加载需要的物理内存,运行hello,CPU在流水线上逐条执行指令。当hello运行完毕后,父进程回收hello进程,内核清除相关信息。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows10 64位;VMware Workstation Pro15.5.1;Ubuntu 20.04.4
开发和调试工具:gdb;edb;readelf;objdump;Code::Blocks20.03
1.3 中间结果
文件名称 | 文件作用 |
hello.c | hello程序源代码 |
hello.i | 源代码hello.c经过预处理产生的文件 |
hello.s | hello程序对应的汇编文件 |
hello.o | 可重定位目标文件 |
hello | hello链接后的可执行目标文件 |
hello_elf.txt | hello.o的ELF格式文件 |
1.4 本章小结
本章根据hello的自白,简述了hello的P2P和O2O的整个过程,此外,还介绍了完成本次实验的软硬件环境、调试和开发工具及中间结果的文件名称和文件作用。
第2章 预处理
2.1 预处理的概念与作用
1.预处理的概念:
预处理器(cpp)根据以字符#开头的命令,修改原始c程序,结果得到另一个C程序,通常是以.i作为文件扩展名。
2.预处理的作用:[1]
- 条件编译:根据条件有选择性的保留或者放弃源文件中的内容。常见的条件包含#if、#ifdef、#ifndef指令开始,以#endif结束。
- 源文件包含:搜索指定的文件,并将它的内容包含进来,放在当前所在的位置。源文件包含有两种,包含系统文件以及用户自定义文件。
- 宏替换:把一个标识符指定为其他一些成为替换列表的预处理记号,当这个标识符出现在后面的文本中时,将用对应的预处理记号把它替换掉,宏的本质是替换。宏的定义分为两种方式:有参和无参。
- 行控制:行控制指令以"#"和“line”引导,后面是行号和可选的字面串。它用于改变预定义宏"__LINE__"的值,如果后面的字面串存在,则改变“__FILE__”的值。
- 抛错:抛错指令是以“#”和“error”引导,抛错指令用于在预处理期间发出一个诊断信息,在停止转换。抛错是人为的动作。
- 杂注:杂注指令用于向C实现传递额外的信息(编译选项),对程序的某些方面进行控制。杂注指令以“#”开始,跟着“pragma”,后面是其他预处理记号,即所谓的选项。
2.2在Ubuntu下预处理的命令
命令:gcc -E hello.c -o hello.i
图2.2-1 预处理命令截图
2.3 Hello的预处理结果解析
查看hello.i文件内容,下面只展示部分内容。
图2.3-1 hello.i部分结果截图
图2.3-2 hello.i部分结果截图
预处理操作将“#include”指令进行了处理,对头文件stdio.h、unistd.h、stdlib.h中的内容展开。
图2.3-3 hello.i部分结果截图
这里展示了删去注释的源代码。
hello.c只有24行,而经过预处理得到的hello.i有3061行,可见hello.i文件大了很多,且是一个文本文件。
2.4 本章小结
本章主要介绍了预处理的概念与作用,在Ubuntu环境下实现了预处理过程,主要包括源文件包含、宏替换、条件编译,并对预处理得到的hello.i文件进行分析。
第3章 编译
3.1 编译的概念与作用
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序。
- 编译的概念:
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言的程序。
2.编译的作用:
- 词法分析:对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序;
- 语法分析:以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构;
- 语义检查和中间代码生成:使编译程序的结构在逻辑上更为简单明确,特别是使目标代码的优化比较容易实现中间代码;
- 代码优化:对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码;
- 目标代码生成:把语法分析后或优化后的中间代码变换成目标代码。
3.2 在Ubuntu下编译的命令
编译的命令为:gcc -S hello.i -o hello.s
图3.2-1 编译命令截图
3.3 Hello的编译结果解析
3.3.1数据
1.常数:
常数以立即数形式出现,数字前面加‘$’。
hello.c文件中if(argc!=5)中的5:
hello.c文件中exit(1)中的1:
hello.c文件中for(i=0;i<10;i++)中i<10被优化为i<=9,立即数9:
2.局部变量
以hello.c文件中的变量i为例进行分析:
初始值为0:
每次循环i=i+1,并与9进行比较:
3.表达式
hello.c文件中argc!=5,argc与5比较:
hello.c文件中i=0,赋初值:
hello.c文件中i++,在每次循环+1:
hello.c文件中i<10,已被优化为i<=9,所以与9进行比较:
3.3.2赋值
hello.c文件中i=0,为i赋初值,通过movl指令实现:
hello.c文件中i++,在每次循环i赋值为i+1,通过addl指令实现:
3.3.3算术操作
hello.c文件中i++,通过addl指令实现:
3.3.4关系操作
hello.c文件中argc!=5,将argc与5比较,通过cmpl指令实现:
hello.c文件中i<10,已被优化为i<=9,所以与9进行比较:
3.3.5数组操作
hello.c文件中printf("Hello %s %s %s\n",argv[1],argv[2] ,argv[3])。先在%rax中存储-32(%rbp),再将%rax加24,最后将%rax指向的数据传递给%rcx;先在%rax中存储-32(%rbp),再将%rax加16,将%rax指向的数据存储在%rdx中;先在%rax中存储-32(%rbp),再将%rax加8,将%rax指向的数据存储在%rax中,将%rax的值传递给%rsi;也就是argv[1]、argv[2]和argv[3]的值存储在%rsi、%rdx和%rcx中,最后调用printf函数:
图3.3.5-1 数组操作截图例一
hello.c文件中sleep(atoi(argv[4])),将argv[4]存储在%rdi中:
图3.3.5-2 数组操作截图例一
3.3.6控制转移
1.if语句
图3.3.6-1 if语句源代码截图
使用cmpl指令和je指令,将agrc与5作比较,如果argc==5则跳转至.L2部分执行,否则继续执行下面的语句,直到最后退出:
图3.3.6-2 if语句截图
2.for循环
图3.3.6-3 for循环语句源代码截图
执行循环中的内容和i++操作在.L4部分,判断是否继续执行循环在.L3部分,使用cmpl指令和jle指令,如果i<=9,则跳转到.L4部分继续执行循环:
图3.3.6-4 for循环语句截图
3.3.7函数操作
1.main()
传入参数argc和*argv[],并返回0:
图3.3.7-1 main函数截图
2.printf()
printf("Hello 2022110525 张菲 18947955718 3!\n");传入字符串首地址,通过call指令转移到指定程序:
图3.3.7-2printf函数例一截图
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]); 传入参数argv[1]和argv[2],通过call指令转移到指定程序:
图3.3.7-3 printf函数例二截图
3.exit()
通过movl指令传递参数1到%rdi,之后通过call指令转移到指定位置:
图3.3.7-4 exit函数截图
4.sleep()
通过movq传递参数,之后通过call指令转移到指定位置:
图3.3.7-5 sleep函数截图
3.4 本章小结
本章主要介绍了编译的概念与作用,在Ubuntu下进行编译的命令,详细分析了编译器如何处理C语言中的数据、赋值、算术运算、关系操作、数组操作、控制转移和函数操作。帮助理解c代码在汇编语言中的表现形式,及对c代码产生了一定的优化。
第4章 汇编
4.1 汇编的概念与作用
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
1.汇编的概念
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在hello.o中。
2.汇编的作用
将汇编代码转换成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。
-
- 在Ubuntu下汇编的命令
汇编的命令为:gcc -m64 -no-pie -fno-PIC -c -o hello.o hello.s
图4.2-1 汇编命令截图
4.3 可重定位目标elf格式
4.3.1命令
使用readelf -a hello.o > hello_elf.txt命令将elf文件导出至hello_elf.txt
4.3.2 ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
图4.3.2-1 ELF头内容截图
4.3.3 节头
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
图4.3.3-1 节头内容截图
4.3.4重定位节
重定位节中包含了在代码中使用的一些外部变量等信息,在链接的时候需要根据重定位节的信息对这些变量符号进行修改。链接的时候链接器会根据重定位节的信息对外部变量符号决定选择何种方式计算正确的地址,通过偏移量等信息计算出正确的地址。
图4.3.4-1 重定位节内容截图
这里出现了R_X86_64_PC_32和R_X86_64_PLT32两种基本的重定位类型。
4.3.5符号表
.symtab是一个符号表,存放在程序中定义和引用的函数和全局变量的信息,和编译器的符号表不同,.symtab符号表不包含局部变量的条目。
图4.3.5-1 符号表内容截图
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
输入objdump -d -r hello.o命令得到以下输出:
hello.o: 文件格式 elf64-x86-64
Disassembly of section .text:
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 05 cmpl $0x5,-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 53 jmp 8b <main+0x8b>
38: 48 8b 45 e0 mov -0x20(%rbp),%rax
3c: 48 83 c0 18 add $0x18,%rax
40: 48 8b 08 mov (%rax),%rcx
43: 48 8b 45 e0 mov -0x20(%rbp),%rax
47: 48 83 c0 10 add $0x10,%rax
4b: 48 8b 10 mov (%rax),%rdx
4e: 48 8b 45 e0 mov -0x20(%rbp),%rax
52: 48 83 c0 08 add $0x8,%rax
56: 48 8b 00 mov (%rax),%rax
59: 48 89 c6 mov %rax,%rsi
5c: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 63 <main+0x63>
5f: R_X86_64_PC32 .rodata+0x25
63: b8 00 00 00 00 mov $0x0,%eax
68: e8 00 00 00 00 callq 6d <main+0x6d>
69: R_X86_64_PLT32 printf-0x4
6d: 48 8b 45 e0 mov -0x20(%rbp),%rax
71: 48 83 c0 20 add $0x20,%rax
75: 48 8b 00 mov (%rax),%rax
78: 48 89 c7 mov %rax,%rdi
7b: e8 00 00 00 00 callq 80 <main+0x80>
7c: R_X86_64_PLT32 atoi-0x4
80: 89 c7 mov %eax,%edi
82: e8 00 00 00 00 callq 87 <main+0x87>
83: R_X86_64_PLT32 sleep-0x4
87: 83 45 fc 01 addl $0x1,-0x4(%rbp)
8b: 83 7d fc 09 cmpl $0x9,-0x4(%rbp)
8f: 7e a7 jle 38 <main+0x38>
91: e8 00 00 00 00 callq 96 <main+0x96>
92: R_X86_64_PLT32 getchar-0x4
96: b8 00 00 00 00 mov $0x0,%eax
9b: c9 leaveq
9c: c3 retq
- 操作数
操作数的表示方式不同,hello.s中的操作数是十进制数,hello.o反汇编中的操作数是十六进制数。
图4.4-1hello.o的反汇编中操作数截图
图4.4-2 hello.s中操作数截图
2.分支转移
hello.s中,不同的跳转被分成了不同的段,通过段名称进行跳转定位。hello.o的反汇编中,跳转指令根据确定的相对main的偏移地址进行跳转定位。
图4.4-3 hello.o的反汇编中分支转移截图
图4.4-4 hello.s中分支转移截图
3.函数调用
hello.s中call指令后显示的是函数名称,即根据函数名称确定调用的函数,hello.o的反汇编中call指令后显示的是与main函数的相对偏移地址,即根据确定的目的地址确定调用的函数,这是因为需要调用的函数最终需要动态链接器才能确定函数的运行时的执行地址。
图4.4-5 hello.o的反汇编中函数调用截图
图4.4-6 hello.s中函数调用截图
4.5 本章小结
本章主要介绍了汇编的概念与作用,汇编的命令,对hello.o的可重定位目标elf格式进行分析,包括ELF头、节头、重定位节和符号表内容,解析了hello.o的结果,对比分析了hello.o反汇编的结果与hello.s,比如操作数、分支转移、函数调用的区别。
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
- 链接的概念:
链接是指将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
- 链接的作用:
使得分离编译成为可能,不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
图5.2-1 链接命令截图
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
5.3.1 readelf命令
图5.3.1-1 readelf命令及部分输出截图
5.3.2 ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。与4.3.2节中介绍内容类似。
图5.3.2-1 ELF头内容截图
5.3.3 节头
详细标识了每个节的名称、类型、地址、偏移量、大小、读取权限、对齐方式等,如.text节,类型为PROGBITS,起始地址为0,偏移量0x40,大小为0x92,属性为AX,即可装入可执行,对齐方式为1字节。
图5.3.3-1 节头内容截图
5.3.4 程序头
程序头的主要作用是描述磁盘上可执行文件的内存布局以及如何映射到内存中。
图5.3.4-1 程序头内容截图
5.3.5 重定位节
重定位节有偏移地址、基址信息、链接器识别修改类型、重定位目标的名称等。
图5.3.5-1 重定位节内容截图
5.3.6 符号表
Hello模块中定义和引用的函数和全局变量的信息,连接后符号表条目增加。
图5.3.6-1 符号表内容截图
5.3.7 动态节
动态节提供动态链接信息、标记依赖关系和指示符号表和重定位表的位置。
图5.3.7-1 动态节内容截图
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
使用edb加载hello截图如下:
图5.4-1 edb加载hello截图
从Data Dump窗口可以看到hello的虚拟地址空间分配情况。
图5.4-2 Data Dump窗口内容截图
图5.4-3 ELF文件中.init节虚拟地址截图
从图中可以看出起始虚拟地址为00000000:00401000,与hello的ELF文件中节头部表中的init节虚拟地址相同。
通过edb中的SymbolView窗口可以查看hello各节的起始地址,与5.3对照可以看到二者对应的虚拟地址相同。
图5.4-4 edb中SymbolView内容截图
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
5.5.1 objdump命令
命令:objdump -d -r hello
图5.5.1-1 objdump命令及部分输出截图
5.5.2 hello与hello.o对比及重定位过程分析
1.函数变化
在hello的反汇编文件中,链接器将hello.c中用到的库函数,如puts,printf,getchar等加入到了文件中。
图5.5.2-1 反汇编文件中函数变化截图
2.增加部分节
hello比hello.o增加了.init、.plt、.plt.sec等节。
图5.5.2-2 .init节
图5.5.2-3 .plt节
图5.5.2-4 .plt.sec节
3.地址变化
hello删去了hello.o中的重定位条目,hello.o中跳转的目的地址和函数地址都是与main函数的相对偏移地址,hello中跳转的目的地址和函数地址都是虚拟内存地址。
图5.5.2-5 hello中跳转截图
图5.5.2-6 hello.o中跳转截图
5.5.3 链接过程
主要分为两步:
- 符号解析:
目标文件定义和引用符号,每个符号对应于一个函数、一个局部变量或一个静态变量。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
- 重定位:
编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
5.6 hello的执行流程
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
图5.6-1 edb执行hello过程截图
0000000000401000 <_init>
0000000000401020 <.plt>
0000000000401030 <puts@plt>
0000000000401040 <printf@plt>
0000000000401050 <getchar@plt>
0000000000401060 <atoi@plt>
0000000000401070 <exit@plt>
0000000000401080 <sleep@plt>
00000000004010f0 <_start>
0000000000401120 <_dl_relocate_static_pie>
0000000000401125 <main>
00000000004011d0 <__libc_csu_init>
0000000000401240 <__libc_csu_fini>
0000000000401248 <_fini>
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
程序调用由共享库定义的函数,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定将过程地址的绑定过程推迟到第一次调用该过程时。通过两个数据结构——GOT和PLT协作在运行时解析函数的地址。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,PLT是代码段的一部分。
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
由5.3.3节中节头关于.got.plt的信息可知,其首地址为0x0000000000404000,通过edb中的data dump窗口观察该地址在_init前后的变化。
图5.7-1 节头中关于.got.plt信息截图
图5.7-2 .got.plt在_init前内容截图
图5.7-3 .got.plt在_init后内容截图
5.8 本章小结
本章主要介绍了链接的概念与作用,链接的命令,可执行目标文件hello的格式,包括ELF头、节头、重定位节、符号表,通过对比hello与hello.o,更好地理解链接与重定位的相关过程,对hello的执行流程和动态链接进行分析。
第6章 hello进程管理
6.1 进程的概念与作用
1.进程的概念:
进程的经典定义就是一个执行中程序的实例。
2.进程的作用:
每次用户向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文运行它们自己的代码或其他应用程序。进程提供给应用程序两个关键抽象:
①一个独立的逻辑流,它提供一个假象,好像我们的程序独占地使用处理器。
②一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
1.壳shell-bash的作用:
shell是一个程序,可以称之为壳程序,用于用户与操作系统进行交互。Linux预设就是bash,bash可以实现以下功能:
- 记录历史命令:bash可以记录曾经的命令,保持在~/.bash_history文件中,只保存上次注销登录之后的命令
- tab键自动补全:使用tab见可以自动不全命令或者目录i
- alias命令别名:可以使用alias ll='ls -al'来设置命令的别名
- 工作控制:可以将某些任务放在后台去运行,这里不多种介绍
- 程序脚本:可以执行shell脚本文件
- 通配符:在查找相关文件或者执行相关命令时,可以使用通配符*
- 内建命令type:可以使用type 命令来查看某个命令是否为内建在bash当中的命令[2]
2.壳shell-bash的处理流程:
- 判断命令是否通过绝对路径执行;
- 判断命令是否存在alias别名;
- 判断用户输入的是内部命令还是外部命令;
- Bash内部命令直接执行,外部命令检测是否存在缓存;
- 通过PATH路径查找命令,有执行,无报错;[3]
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程;子进程返回0,父进程返回子进程的PID;新创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本(代码、数据段、堆、共享库以及用户栈);子进程获得与父进程任何打开文件描述符相同的副本,子进程有不同于父进程的PID;fork函数:被调用一次,却返回两次。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。execve函数加载并运行可执行目标文件,且带参数列表和环境变量列表。只有当出现错误时,execve才会返回到调用程序。所以,与fork依次调用返回两次不同,execve调用一次并从不返回。在execve加载了可执行目标文件之后,其调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
(1)进程上下文信息:
系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
(2)进程时间片:
一个进程执行它的控制流的每一部分的每一时间段叫做时间片。
(3)进程调度的过程:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中被称为调度器的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,他就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换①保存当前进程的上下文;②恢复某个先前进程被抢占的进程被保存的上下文;③将控制传递给这个新恢复的进程。
(4)用户态与核心态转换:
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1 异常的种类
异常可以分为四类:中断、陷阱、故障和终止。
图6.6.1-1 异常的种类
6.6.2 产生的信号
①中断:信号SIGTSTP,默认行为是停止直到下一个SIGCONT;
②终止:信号SIGINT,默认行为是终止。
6.6.3处理方式
中断:
图6.6.3-1 中断处理
陷阱:
图6.6.3-2 陷阱处理
故障:
图6.6.3-3 故障处理
终止:
图6.6.3-4 终止处理
6.6.4命令、运行结果、信号与异常的处理
1.正常运行:程序每隔三秒输出一次,共输出10次。
图6.6.3-4 终止处理
6.6.4命令、运行结果、信号与异常的处理
1.正常运行:程序每隔三秒输出一次,共输出10次。
图6.6.4-2 不停乱按输出截图
2.回车:不影响当前进程执行,在结束hello进程后,shell会把之前敲击的回车当做命令行,读入终端。
图6.6.4-3 多次按下回车输出截图
3.运行时按Ctrl+C:进程接收到SIGINT信号,进程终止。输入ps查看发现没有相关信息。
图6.6.4-4 运行时按Ctrl+C输出截图
4.运行时按Ctrl+Z,之后输ps:Ctrl-Z挂起前台作业,ps显示进程的详细信息。
图6.6.4-5 运行时按Ctrl+Z命令,之后输ps输出截图
6. 运行时按Ctrl+Z,之后输jobs:Ctrl-Z挂起前台作业,jobs显示任务列表和任务状态。
图6.6.4-6 运行时按Ctrl+Z,之后输jobs输出截图
7.运行时按Ctrl+Z,之后输pstree:Ctrl-Z挂起前台作业,pstree以树状结构显示进程之间的关系。
图6.6.4-7 运行时按Ctrl+Z,之后输pstree输出截图1
图6.6.4-8 运行时按Ctrl+Z,之后输pstree输出截图2
8. 运行时按Ctrl+Z,之后输fg %1:Ctrl-Z挂起前台作业,fg %n使第n个任务在前台运行。
图6.6.4-9 运行时按Ctrl+Z,之后输fg %1输出截图
9. 运行时按Ctrl+Z,之后输kill -9 2861:Ctrl-Z挂起前台作业,kill命令杀死进程。通过kill -9 2861给进程2861发送SIGKILL信号,终止hello进程。
图6.6.4-10 运行时按Ctrl+Z,之后输kill -9 2861输出截图
6.7本章小结
本章主要介绍了进程的概念与作用、壳Shell-bash的作用与处理流程、fork进程创建过程、execve过程、进程执行和异常与信号处理等内容。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
- 逻辑地址:
由程序产生的与段相关的偏移地址部分,hello.o中跳转的目的地址和函数地址都是相对main函数偏移地址。
- 线性地址:
逻辑地址到物理地址变换之间的中间层,线性地址=段地址+偏移地址。程序hello的代码会产生段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
- 虚拟地址:
虚拟内存是对整个内存的抽象描述,是相对于物理内存来讲的。虚拟内存也就是线性地址空间。如hello中main函数的段内偏移地址。
- 物理地址:
用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。在hello运行中,需要根据虚拟地址通过地址翻译(MMU)得到物理地址,并通过物理地址访问在内存中的位置。物理内存是以字节为单位编址的。[4]
7.2 Intel逻辑地址到线性地址的变换-段式管理
1.段寄存器:
图7.2-1 段寄存器含义
段寄存器(16位)用于存放段选择符
CS(代码段):程序代码所在段
SS(栈段):栈区所在段
DS(数据段):全局静态数据区所在段
其他3个段寄存器ES、GS和FS可指向任意数据段
2.段选择符:
逻辑地址一共有 48 位。前 16 位是段选择符。
图7.2-2 段选择符格式
这 16 位的格式如上图。
- 索引:描述符表的索引(Index)
- TI:如果 TI 是 0。描述符表是全局描述符表(GDT),如果 TI 是 1。描述符表是局部描述表(LDT)
- RPL:段的级别。为 0,位于最高级别的内核态。为 11,位于最低级别的用户态。在 linux 中也仅有这两种级别。
3.段描述符:
段描述符就是表项,一种记录每个段信息的数据结构。段选择符就是描述符表(段表)中的索引。
4.描述符表:
实际上就是段表,由段描述符(段表项)组成。有三种类型:
- 全局描述符 GDT:只有一个,用来存放系统内用来存放系统内每个任务共用的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及 TSS(任务状态段)等都属于 GDT 中描述的段。
- 局部描述符表 LDT:存放某任务(即用户进程)专用的描述符
- 中断描述符表 IDT:包含 256 个中断门、陷阱门和任务门描述符
5.整体过程:
通过索引在描述符表中找到段基址,如下图描述:
图7.2-3 整体过程
6.段式管理:
根据段选择符定位到相应的段描述符,根据段描述符在描述符表中得到相应的段基址,加上偏移量,得到线性地址。[5]
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存机制使用页表的数据结构进行页式管理,它把线性地址空间和物理地址空间划分成大小相同的页,然后通过建立线性地址空间的页同物理地址空间中的页的映射,实现线性地址到物理地址的转化。其对应过程如下图所示,MMU利用虚拟页号(VPN)找到对应的物理页号(PPN),然后将找到的PPN与由虚拟页偏移量(VPO)得到物理页偏移量(PPO)组合就构成了实际的物理地址[6]。
图7.3-1 页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
若TLB命中,则MMU从TLB中取出相应的PTE,将这个虚拟地址翻译为物理地址;若TLB不命中,根据VPN1在一级页表选择对应的PTE,该PTE包含二级页表的基地址;根据VPN2在二级页表选择对应的PTE,该PTE包含三级页表的基地址;根据VPN3在三级页表选择对应的PTE,该PTE包含四级页表的基地址;在四级页表取出对应的PPN,与VPO串联起来,就得到相应的物理地址。
图7.4-1 TLB与四级页表支持下的VA到PA的变换
图7.4-2 虚拟地址中用以访问TLB的组成部分
7.5 三级Cache支持下的物理内存访问
图7.5-1 Core i7 的内存系统
首先访问一级Cache,寻找该物理内存对应的内容是否已被缓存且有效,若已被缓存且有效,则缓存命中;否则缓存不命中,则需要访问二级Cache,重复上述步骤;若二级Cache中依然缓存不命中,则需要访问三级Cache,直到访问主存。将访问到的内容分别加载进上一层缓存,再进行后续操作。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
①删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
②映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域都被映射为hello文件中的.text和.data区.bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
③映射共享区域。如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
④设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
图7.7-1 加载器是如何映射用户地址空间区域的
7.8 缺页故障与缺页中断处理
缺页故障:DRAM缓存不命中称为缺页,CPU引用了VPm中的一个字,VPm并未缓存在DRAM中。地址翻译硬件从内存中读取PTEm,从有效位推断出PTEm未被缓存,并且触发一个缺页异常。
缺页中断处理:缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页(假设其为VPn)。如果VPn已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VPn的页表条目,反映出VPn已经不在缓存在主存中这一事实。接下来,内核从磁盘复制VPm到内存中,更新PTEm,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接着未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组大小不同的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
①显式分配器,要求应用显式地释放任何已分配的块。
②隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程就叫做垃圾收集。
7.10本章小结
本章主要介绍了hello的存储器地址空间、Intel逻辑地址到线性地址的变换-段式管理、Intel线性地址到物理地址的变换-页式管理、TLB与四级页表支持下的VA到VP的变换、三级Cache支持下的物理内存访问,hello进程fork和execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对对应文件的读和写来执行。
设备管理:unix io接口,将设备映射为文件的方式,使得Linux内核引出一个简单、低级的应用接口,称为I/O接口,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口
所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O ,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2.2 Unix I/O函数
1.打开文件:int open(char *filename, int flag, mode_t mode);
进程通过调用open函数来打开一个已存在的文件或者创建一个新文件。open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件, mode参数指定了新文件的访问权限位。
2. 关闭文件:int close(int fd);
进程通过调用close函数关闭一个打开的文件。关闭一个已关闭的描述符会出错。
3. 读文件:ssize_t read(int fd, void *buf, size_t n);
应用程序通过调用read函数执行输入。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
4. 写文件:ssize_t write(int fd, const void *buf, size_t n);
应用程序通过调用write函数执行输出。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
printf函数体:
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
printf函数调用vsprintf函数进行格式化,将所有的参数内容格式化后放到buf中,返回格式化数组的长度给i,接受确定输出格式的格式字符串fmt,;调用write函数把buf中的i个元素的值写到终端。syscall函数不断地打印出字符,直到遇到'\0'结束。
8.4 getchar的实现分析
getchar函数体:
int getchar(void)
{
static char buf[BUFSIZE];
static char *b=buf;
static int n=0;
if(n==0)
{
read(0,buf,BUFSIZE);
b=buf;
}
return ((--n)>0) ? (unsigned char) *b++ : EOF;
}
异步异常-键盘中断的处理:当用户按下键盘上的一个键时,键盘的接口电路会生成一个扫描码, 键盘接口将这个扫描码发送到计算机的主板上,并请求一个中断, CPU响应中断,将当前执行的任务挂起,并将控制权转移给中断处理程序, 键盘中断处理程序接受扫描码,将其转换为ASCII码, 转换后的ASCII码被保存到系统的键盘缓冲区中。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。getchar的返回值是用户输入字符的ascii码,若到文件结尾则返回-1(EOF),且将用户输入显示到屏幕。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法、Unix I/O接口及其函数,同时对printf和getchar的实现进行分析。
结论
1.用计算机系统的语言,逐条总结hello所经历的过程。
- 预处理:hello.c经过预处理器(cpp)得到修改了的源程序(文本)hello.i;
- 编译:hello.i经过编译器(ccl)得到汇编程序(文本)hello.s;
- 汇编:hello.s经过汇编器(as)得到可重定位目标程序(二进制)hello.o;
- 链接:hello.o经过链接器(ld)将其与其它可重定位目标文件和动态库进行链接得到可执行目标文件hello;
- shell调用fork函数创建子进程;
- shell调用execve函数加载hello程序,映射到对应的虚拟内存;
- hello程序执行过程中通过进程管理实现异常与信号的处理,存储管理实现内存访问,同时相应的IO设备配合hello程序实现输入输出等功能;
- 运行结束,父进程回收子进程,内核将其从系统中清除,释放资源。
2.你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
通过对hello程序的一生的探索,了解一个hello.c程序如何从预处理、编译、汇编…一直到最终被回收的整个过程,让我对计算机系统的知识有了更加深刻的理解,同时对进程管理、存储管理、I/O管理等都有了进一步的理解。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名称 | 文件作用 |
hello.c | hello程序源代码 |
hello.i | 源代码hello.c经过预处理产生的文件 |
hello.s | hello程序对应的汇编文件 |
hello.o | 可重定位目标文件 |
hello | hello链接后的可执行目标文件 |
hello_elf.txt | hello.o的ELF格式文件 |
参考文献
[1]程序预处理阶段,在做什么_预处理阶段主要做的是哪两件事-CSDN博客http://t.csdnimg.cn/Z5Vfu
[3] Linux Bash shell - 知乎 (zhihu.com)
[4] 逻辑地址、物理地址、虚拟地址_虚拟地址 逻辑地址-CSDN博客
[5] 段页式访存——逻辑地址到线性地址的转换_某采用段页式管理系统中,一操作数的逻辑地址为9976h,若逻辑地址格式为段号(3-CSDN博客
[6] https://blog.csdn.net/m0_63712213/article/details/130487255
[7] 《深入理解计算机系统》 Randal E.Bryant & David R.O’Hallaron 机械工业出版社