计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2021112335
班 级 2103102
学 生 季凯文
指 导 教 师 刘宏伟
计算机科学与技术学院
2022年5月
本文通过分析hello从ASCII编码的文本文件到被加载进内存作为一个进程运行的过程,总结梳理《深入理解计算机系统》中学习的知识。包括编译,链接,加载,运行,等。
关键词: CSAPP;编译;链接;进程;
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式....................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 10 -
6.3 Hello的fork进程创建过程........................................................................ - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理.......................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................ - 11 -
7.5 三级Cache支持下的物理内存访问............................................................. - 11 -
7.6 hello进程fork时的内存映射..................................................................... - 11 -
7.7 hello进程execve时的内存映射................................................................. - 11 -
7.8 缺页故障与缺页中断处理.............................................................................. - 11 -
8.2 简述Unix IO接口及其函数.......................................................................... - 13 -
第1章 概述
1.1 Hello简介
hello这个简单的小程序将贯穿本文的始终,源代码hello.c内容如下:
// 大作业的 hello.c 程序 // gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.c -o hello // 程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等。 // 可以 运行 ps jobs pstree fg 等命令 #include <stdio.h> #include <unistd.h> #include <stdlib.h> int main(int argc,char *argv[]){ int i; if(argc!=4){ printf("用法: Hello 学号 姓名 秒数!\n"); exit(1); } for(i=0;i<9;i++){ printf("Hello %s %s\n",argv[1],argv[2]); sleep(atoi(argv[3])); } getchar(); return 0; } |
P2P:hello将要经历预处理,编译,汇编,链接一系列过程才能形成可执行程序,然后在shell中键入命令,shell调用fork,execve,然后hello会被操作系统载入内存,运行。此即from Program to Process,从程序到进程。(一般P2P指的是点对点技术(peer-to-peer))。
020:hello程序被操作系统载入内存,作为一个进程运行,接受操作系统的调度,在CPU,内存,IO设备上驰骋。在结束时变为僵尸进程,由父进程回收,不在系统中留下任何痕迹,仿佛从未来过。这就是020(from 0 to 0)。
在接下来几章中,本文将较详细地介绍上述过程。
1.2 环境与工具
软件环境:
本文对hello的一切操作都在Ubuntu 22.04.1 LTS下进行。使用vim/gedit进行编辑,使用gcc作为编译工具。使用bash作为终端shell。
硬件环境:
CPU:Intel core i7, RAM: 16GB, SSD: 512G
1.3 中间结果
文件 | 描述 | 作用 |
hello.i | 预处理结果 | 展示预处理的过程 |
hello.s | 编译得到的到汇编代码 | 展示编译过程 |
hello.o | 目标文件 | 展示汇编过程 |
hello.disasm | hello.o的反汇编 | 展示hello.o的反汇编 |
hello | 可执行目标文件 | 展示链接过程 |
1.4 本章小结
本章主要介绍本文的大致内容和主线:通过hello的一生介绍计算机系统知识。
第2章 预处理
2.1 预处理的概念与作用
作为hello那波澜壮阔地一生的第一步的是预处理(Preprocess)。
在将源码交付编译器编译前先对源代码进行文本处理,这个过程即是预处理。预处理可以认为只是简单的文本操作,包括删除,插入,替换。通过预处理,我们可以做到复杂的编译控制。可以方便地定义常量。
预处理主要是删除注释,和执行预处理命令。
预处理指令由#起头,独占一行。常用的预处理指令无非就是三类:文本的插入,替换,条件插入。
插入:
#include命令。有两种用法:
#include <filename>
#include “filename”
该命令就是简单的将指定的文件复制到本条指令的位置。如果是尖括号,则优先在系统路径中寻找文件,平时使用的库文件:stdio.h, stdlib.h, string.h等都属此类。如果是引号,则优先在当前工作路径(即键入编译器命令时所在目录)下寻找文件,自己定义的同文件都属此类。
替换:
#define。#define有两种用法:
#define MACRO string
#define MACRO(arg1, arg2,…) string(arg1, arg2, …)
第一种用法就是定义一个宏,之后在源文件文本中遇到MACRO,就会将其替换为string。如#define PI 3.14159 就会把之后源文件中的PI替换为3.14159。第二种用法是带参宏,只用如果遇到形如MACRO(a, b, c…)这样的文本,就会用a, b, c替换宏定义中字符串中的arg1, arg2等位置,再将此替换后的字符串放到使用该宏的地方。
条件插入:
#ifdef, #ifndef #else #endif. ifdef就是if defined,如果定义了…,ifndef就是if not defined 如果没有定义…。#ifdef和#ifndef必须和#endif配套使用。用法如下:
#ifdef MACRO 或者 #ifndef MACRO text1 #else text2 #endif |
图2.1-1
如果之前使用#define定义了(使用ifndef时则为没有定义)MACRO,那么插入text1的内容,丢弃text2的内容,否则插入text2的内容,丢弃text1的内容。这通常用于头文件保护,即防止头文件被重复包含。
注意,以上命令都是递归执行的。例如,#define A(a) a(x) #define B(x) (x + x)。则A(B)就会被替换为B(x),这还不会结束,又会被替换为(x + x)。这就是递归执行的含义。
以上讲解省略了一些细节,如以什么标准匹配一个宏?举例来说#define ONE 1。那么 NONE会被替换为N1吗?答案是不会的,匹配宏时,是看以一个完整词素来匹配的,刚才的例子中NONE是一个完整词素,而ONE不是,因此NONE中的ONE与不会被替换。而C预处理机制又是如何定义什么是一个完整的词素的呢?还有,有一些标准可能未规定的命令,比如#pragma once,#pragma pack(n)等,这些细节在此略去。
2.2在Ubuntu下预处理的命令
在Ubuntu使用gcc对hello.c只进行预处理,应使用:
gcc -E hello.c -o hello.i
或者使用cpp(C preprocesser,此处不是指c++):
cpp -E hello.c -o hello.i
命令如下:
图2.2-1
查看结果(仅截取了部分内容):
图2.2-2
2.3 Hello的预处理结果解析
源代码中跟预处理相关的有:注释,三条#include
首先看开头几行是一些#开头的文件信息,而注释已经消失了。以#开头地行会被编译器忽略,所以当他们不存在。
图2.3-1
然后是include语句,第一条在第12行可看到stdio.h的内容,可以看到还有别的头文件,这些是在stdio.h中包含的,由于预处理指令是递归执行的,所以这些也被正确地包含进来。
图2.3-2
在743行,unistd.h被包含:
图2.3-3
在2004行,stdlib.h被包含:
图2.3-4
除此之外没有什么别的变化。
2.4 本章小结
本章主要介绍了预处理阶段,简要介绍了预处理的机制和简单的预处理指令。并演示了如何在Ubuntu下进行预处理,还展示了预处理的结果。
第3章 编译
3.1 编译的概念与作用
注:这里的编译是指从预处理结果到汇编语言的过程。
经过预处理之后,源文件中无用信息已经被去除掉了,包含的信息也已经被复制进来了,接下来就可以开始编译了。
这里的编译是指:将高级语言(这里是C语言)描述的程序,转换成等价的低级(这里是汇编语言)描述。
实际上编译是一个比较广泛的概念,比如Java解释器会将Java源代码(高级语言)翻译成Java字节码(低级描述)。尽管Java字节码不能直接对应于机器码。和编译相似的概念存在于计算机系统各个地方:比如在shell中用户输入的命令行就可作为一个高级描述,shell对这个描述进行解析,转化成低级描述,也就是解析成具体的命令,提取出具体的参数。Latex排版语言以一段代码作为输入,输出排版好的文本。汇编器将汇编语言翻译成字节码也可看作一种编译过程。在CPU中,将机器码作为高级描述,将每个机器码转换成一系列更简单的操作,例如访存,执行(使用ALU计算),访问寄存器,等低级操作,这也有编译的思想。
编译大致分为以下几个阶段:
- 词法分析:将源代码文本,拆分为词素(token)的序列。非正式的说,词素是构成语法结构的最小单元。像数字,变量名,运算符都是词素。经过这一步,源程序就成为一个词法单元(由词素和该词素的属性构成的元组叫做词法单元,例如数字1对应的词法单元为<NUM, 1>)流,而不是ASCII编码的字节流了。
- 语法分析:将词法单元流转化为具有层次结构的语法树,或该语法树的等价表示。在这一步仅仅只是将源文件的层次结构提取出来,初此之外都不是必须做的)理解这一步需要理解产生式(production)的概念。
- 语义分析:分析语法树的语义,在这一步,可能会进行常量表达式的计算,对于强类型语言,还会进行类型检查,检查函数调用的参数,对于函数可重载的语言还会进行重载决议等。
- 中间代码生成:这一步将上一步生成的语法树转化成一种机器无关的中间表示,比如三元式,四元式等。这种中间表示足够的低级(简单,容易转换成汇编语言),又不至于太低级(跟具体的汇编语言无关,是各种各样的汇编语言的统一抽象)。例如 a = b + (c + d)转换成三元式可能是这样的:
t1 = c + d t2 = b + t1 a = t2 |
图3.3-1
- 中间代码优化:这一步是可选的,是机器无关的,这一步可以改进上一步生成的中间表示。全局数据流分析是一种较常用的全局优化技术,在gcc中,开启O2优化就会执行全局数据流分析,这种优化能显著提高运行速度。
- 目标代码生成:这一步开始就与具体机器有关了,这一步是将生成的中间表示转化成汇编代码。这一步主要的任务就是指令的选择和寄存器的分配(即决定哪些变量放在寄存器中,哪些放在栈中,放在寄存器中的变量又该放在哪个寄存器,在什么时间段放在寄存器)。在这一步,在开始真正的汇编代码生成之前,还可以进行一些机器有关的优化,例如循环展开(在gcc中,使用O3优化选项即可启用循环展开),针对cache重新排列循环,重排指令以减少数据依赖,提高指令级并行性。
到此为止,编译就结束了。经过上述步骤,C源代码就会转化成汇编代码可以方便的进一步转化成机器码(实际上是目标文件)。注:以上过程是抽象出来的统一模型,在实践中,有些步骤可能会合并,有些步骤可能不会显示的执行。
这里提一嘴,现在流行的X86汇编格式主要有两种:AT&T和Intel。为和CSAPP教材保持一致,本文使用AT&T格式进行讲解。
3.2 在Ubuntu下编译的命令
使用gcc进行编译:
gcc -S hello.i -o hello.s
使用cc(C compiler)进行编译:
cc -S hello.i -o hello.s
实际上为了防止编译器优化,栈保护机制等的影响,附加了一些编译选项:
图3.2-1
结果如下:
图3.2-2
图3.2-3
3.3 Hello的编译结果解析
3.3.1 数据
首先看源代码中的数据部分,源代码中的数据只有字符串和整形字面值常量,整形字面值常量都被硬编码在指令中作为立即数(在64位编译时,通常栈帧指针不是必须的,因此若不加编译选项,rsp,esp会被当作普通的寄存器使用):
line 25: cmpl $4, %edi 对应源代码中13行argc != 4 line 33: movl $1, %edi 对应源代码中exit(1)中的参数1 line 28: movl $0, %ebp 对应源代码中17行i = 0 line 47: cmpl $8, %ebp 对应源代码中17行i < 8 line 36: movq 16(%rbx), %rdx 对应源代码中argv[2] line 37: movq 8(%rbx), %rsi 对应源代码中argv[1] line 41: movq 24(%rbx), %rdi 对应源代码中argv[3] line 50: movl $0, %eax 对应源代码中return 0 |
图3.3-1
对于字符串,编译器将其放在了.rodata段中:
.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" |
图3.3-2
第一行.section .rodata 表明进入.rodata段。.LC0后面的字符串及对应错误提示信息的字符串,.LC0代表了该字符串的地址。LC1代表了第18行的格式描述字符串的地址。
3.3.2 赋值与算术运算
hello中涉及的算数运算只有自增。操作对应如下:
line 28: movl $0, %ebp 对应源代码中17行i = 0,由于使用ebp存放变量i,于是这一句只需将0移入ebp中即可完成赋值的翻译 line 45: addl $1, %ebp 对应于源码中17行i++。将1加到ebp上即可完成自增操作。 |
图3.3-3
3.3.3 函数操作
首先介绍以下调用约定(calling convention)的概念,调用约定是指,调用函数时,参数传递方式和返回值传递方式的约定。在64位linux下,使用System V AMD64 ABI[2]调用约定,简要地说就是调用函数时,前六个参数依次通过rdi, rsi, rdx, rcx, r8, r9传递,剩下的逆序压栈传递,如果有浮点参数,则按照顺序通过向量寄存器(xmm0~xmm7)传递。当调用可变参数函数时,寄存器al用来存放使用的向量寄存器的个数[3]。
movq 16(%rbx), %rdx movq 8(%rbx), %rsi movl $.LC1, %edi movl $0, %eax call printf |
图3.3-4
如图3.3-4,可以看到格式字符串.LC1,被移入edi,因为他是第一个参数。然后是argv[1],也就是8(%rbx)的内容,被放进rsi,这是第二个参数。第三个参数就是argv[2]了,被放进rdx中传递。可以看到在调用printf之前,eax被清0了。这是因为printf是可变参数函数,而本次调用中未使用向量寄存器,故将al(是eax的一部分)设为零。
3.3.4 关系操作和控制转移
关系的判断常与控制转移联系在一起。
cmpl $4, %edi jne .L6 movq %rsi, %rbx movl $0, %ebp jmp .L2 .L6: movl $.LC0, %edi call puts movl $1, %edi call exit .L2: other code… .cfi_endproc |
图3.3-5
第一个错误检查部分的汇编如上,先使用cmpl 比较4和edi的内容(argc),jne .L6意味着若不等,则执行分支的内容,也就是跳转到.L6处。若相等,则继续顺序执行。顺序执行时会将ebp(i)置为0(这是后面for循环的内容),然后跳转到。L2执行接下来的代码。跳转到.L6执行时即调用puts输出.LC0然后调用exit退出。
for循环部分如下图:
movl $0, %ebp jmp .L2 other codes… .L3: movq 16(%rbx), %rdx movq 8(%rbx), %rsi movl $.LC1, %edi movl $0, %eax call printf movq 24(%rbx), %rdi call atoi movl %eax, %edi call sleep addl $1, %ebp .L2: cmpl $8, %ebp jle .L3 call getchar 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.3-6
源程序中的for循环等价于如下代码:
i = 0; while (i < 9) { do something… i++; } |
图3.3-7
因此可见图3.3-5中先将0移入ebp,接下来是while循环的jump to middle 翻译方式,先跳到.L2处执行条件判断, jle .L3表明如果条件成立,跳到.L3处执行循环体。
可以看到由于分支的存在,各个代码块的顺序排布显得很凌乱。实际上,有一种很好的理解编译器产生代码的方式:流图(flow graph)。流图是一个有向图,每个顶点代表一个基本块(basic block,基本块是极大的必然可以连续完整执行的指令序列,也就是说,控制流既不会跳到基本块的中间,也不会从基本块中间跳出。只会从基本块开头跳入,从基本块末尾跳出),而每个从顶点u到v的有向边表示控制流可能从u的最后一个指令跳到v的第一条指令。通过画出流图,可以较清楚的看出控制流的走向。流图也通常在编译器执行机器无关优化时使用。
3.4 本章小结
本章介绍了编译的概念、作用以及编译的大致过程在Ubuntu下通过gcc指令对.i文件进行编译的方法,通过对hello.i进行预处理得到hello.s,将hello.c与hello.s进行对比,选取hello.c中C语言的典型数据类型以及各类操作说明了编译阶段做了怎样的处理。
第4章 汇编
4.1 汇编的概念与作用
在经过编译后,我们得到了hello的C源代码的等价的汇编代码文件hello.s。想想这一路上从C源代码那高度抽象的程序描述,我们得到了与其等价的如此具体的汇编代码,心中还是有些感慨的。接下来就是汇编了。
汇编是指将汇编语言描述的程序翻译成等价的机器码(目标文件)的过程。它能够将编译得到的文本文件(汇编代码文件)翻译成你的机器真正能看懂的指令的二进制编码。
4.2 在Ubuntu下汇编的命令
应截图,展示汇编过程!
通过gcc 汇编:
gcc -c hello.s -o hello.o
通过as汇编:
as -c hello.s -o hello.o
图4.2-1
图4.2-2
汇编得到的目标文件的部分内容。
4.3 可重定位目标elf格式
可重定位目标文件不是可执行文件,其中还有一些没能确定的信息如引用的外部全局变量地址,调用的外部函数的地址等。这些信息需要之后与其他文件链接才可以形成可执行文件。通常可重定位目标文件的结构如下:
ELF头 |
.text(编译得到的机器代码) |
.rodata(只读数据) |
.data(已初始化的全局和静态C变量) |
.bss(未初始化的全局和静态C变量,初始化为0的全局或静态变量) |
.symtab(符号表) |
.rel.text(.text节的重定位信息) |
.rel.data(全局变量的重定位信息) |
.debug(调试符号表) |
.line(C源程序与.text指令之间的位置映射) |
.strtab(字符串表) |
节头部表 |
图4.3-1
其中.debug节,.line节只有在调用gcc时加-g编译选项才会存在。
使用readelf查看hello.o的各个节的信息:
图4.3-2
我们首先看看符号表.symtab节的信息:
图4.3-3
符号表中存放了地址需要解析的符号信息,在上图中值得关注的信息从第五条起。main函数,puts函数等各种在我们的hello.c中用到的函数(puts其实是源程序中错误输出部分的printf,由于我们只传了一个字符串,所以gcc就“自作聪明”地把它改成puts以提高效率了),Ndx项都为UND(undefined),表明他们都是未定义的符号,也就是外部符号。
再看.rel.text节的信息:
图4.3-4
下面有一个.rela.eh_frame节,.eh_frame节是用来实现运行时异常的[4],而.rela.eh_frame节就是.eh_frame的重定位信息,在这里我们不需要关注这一项。
.rela.text这个节存放了.text节(生成的机器码)中的重定位信息,Offset表明在相对.text开头的偏移Offset处引用了外部的某个变量或函数,Type表明了这个外部的地址的计算方式,如R_X86_64_32表示32位绝对寻址。最后Sym.Name表明Offset位置引用的外部符号的名字,而Addend是一个修正项,在计算地址时会用这个进行修正(想想call,jmp使用立即数跳转时,使用的时相对地址,而在计算跳转目标地址时,PC已经指向call,jmp后面一条指令的位置了,所以为了正确计算跳转偏移,需要将PC减取call,jmp指令的长度,这个长度就是Addend)。
对于hello.o,没有.rela.data节,因为没有需要重定位的全局符号。
4.4 Hello.o的结果解析
使用objdump将hello.o的反汇编结果输出到hello.disasm:
图4.4-1
我们来看看hello.o的反汇编:
图4.4-2
相比于图3.2-2和图3.2-3所示的hello.s文件,这个文件的信息要少了很多。一条机器码对应一条汇编语句。反汇编结果只有代码段的内容。标号也都没有了,转而使用相对地址描述跳转。
重点在各个外部地址处,所有需要重定位的地址都是0,并在下一行给出了重定位方式。
4.5 本章小结
本章介绍了汇编的概念,作用,以及使用gcc进行汇编的方式,并解析了可重定位目标文件hello.o的部分结构。
第5章 链接
5.1 链接的概念与作用
经过汇编后,会生成可重定位目标文件hello.o。接下来就是要把hello.o与语言库链接,以解决我们对printf,sleep等函数的调用问题。
链接是解析一个或多个可重定位文件和库的外部符号的引用和定义,将这些文件连接成一个完整的可执行文件的过程。
经过链接之后,我们得到的就是一个真正的可执行程序了。
5.2 在Ubuntu下链接的命令
使用gcc链接:
图5.2-1
后面的-no-pie选项是由于在之前编译阶段,我们使用-no-pie编译选项,所以在这里我们也加上。
使用ld进行链接相当的复杂,每个高级语言都有自己的语言库,这个语言库完成程序的初始化,调用入口函数main,包装系统调用,从main返回时清理资源并终止进程等工作。故需要大量的链接文件。且链接之前的步骤都是由gcc完成的,我们很难知到gcc使用了哪些选项,我们不使用这些选项链接会不会造成冲突。因此极不推荐使用ld手动链接。但在这里我们可以给出ld的链接演示过程。命令如下:
图5.2-2
如果好奇这是怎么来的,我可以在这里告诉你。gcc提供了一种查看编译具体过程的方式。使用gcc编译时加上-v或--verbose编译选项,即可看到编译过程。在编译过程的最后,可以看到调用了collect2,其实这个就是ld的一层包装,我们只需将collect2的调用参数复制下来作为ld的参数,在命令行中输入即可。
5.3 可执行目标文件hello的格式
经过链接得到的hello是可执行目标文件。这样的目标文件可以很容易的被加载进内存执行。使用
图5.3-1
我们查看几个节,有一个.interp节,这是用来完成动态链接的。由一个.init节这个节里有负责初始化的代码(_init函数),然后调用.text节中的入口函数。然后是.text节,这是我们写的代码和语言库链接后得到的机器码。然后是.data和.bss。这里就是全局变量和静态变量了。还有.rodata是存放只读数据的。
5.4 hello的虚拟地址空间
使用edb运行hello,并查看内存区域地址:
图5.4-1
可以看到虚拟内存从0x400000处开始被使用,至于为何在这之下的空间不使用,可参阅[5]。
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
使用edb执行hello,单步调试,首先进入动态链接器所在区域,然后跳到
_start函数:0x004010f0
main: 0x004011d6
printf:0x004010b0
atoi:0x004010a0
sleep:0x004010d0
getchar:0x004010e0
然后结束。
5.7 Hello的动态链接分析
函数的动态链接通常采用延迟绑定的方式,通过GOT(global offset table),和PLT(procedure linkable table)的密切合作,在第一次调用该函数时完成链接。
在调用函数前GOT:
图5.7-1
在调用后函数后GOT:
图5.7-2
可以看到,在经过程序调用后,GOT表中的地址逐渐被填充。这体现了函数的动态链接的延迟绑定。
5.8 本章小结
本章解释了链接的概念和作用,并展示了hello的大致的执行过程,还简单展示了函数的延迟绑定。
第6章 hello进程管理
6.1 进程的概念与作用
存在很多进程的定义[6]:
- 一个正在执行的程序。
- 计算机中正在运行的程序的一个实例。
- 可以分配给处理器并由处理器执行的一个实体。
- 由单一顺序的执行线索,一个当前状态和一组相关的系统资源所描述的活动单元。
进程是计算机科学中的一个Great Idea,他为我们提供了两个重要抽象[1]:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
操作系统是复杂的,我们利用操作系统管理计算机很难直接接触操作系统内核功能,也没有必要。因此,有必要提供一个接口,使得我们可以通过一种简单地方式描述我们想做的,由这个接口负责正确调用操作系统功能来实现我们想做的事。这个接口程序就是shell。bash是linux上的使用的shell。一个shell普遍的处理流程是:
- 等待用户输入命令。
- 解析命令,如果是内置命令,立即执行,并转到1,否则3.
- 在环境变量中和当前路径下寻找命令中的可执行文件,未找到则输出错误信息,转到1。找到转到4。
- 若指定在后台运行,则加载程序并运行,立即转到1,若在前台运行,则加载程序并执行,等待其停止或终止。然后转到1。
(以下格式自行编排,编辑时删除)
6.3 Hello的fork进程创建过程
当我们在bash中输入如下命令时:
./hello 12345678 Hanekawa 10
bash解析命令后得知我们希望运行hello程序,并提供了参数。此时bash会调用系统函数fork()来复制自己得到一个子进程,这个子进程几乎和原来的父进程一模一样,但是这两个进程从fork()获得的返回值不一样,父进程将获得子进程的ID,子进程将获得0。因此这两个进程根据返回值可以确定自己是父进程还是子进程。子进程之后就会变成我们的hello程序。
6.4 Hello的execve过程
在上一步中,bash复制了几乎一模一样的自己。在这一步,bash刚刚复制的子进程会执行系统函数execve,这个函数会在指定路径寻找hello,将其载入内存。具体而言,是将子进程的代码,数据替换成了hello的代码,数据(实际上此处的替换并不意味着会发生磁盘上内容载入内存中载入,具体原因与虚拟内存有关)。但是一些环境变量,文件描述符仍然不变,再将程序计数器指向入口。然后这个子进程就变成了我们的hello进程,之后就可以接受内核调度在计算机上驰骋了。也就是说,hello被载入到内存中执行的过程,实际上是bash复制了一个自己,然后让这个复制品变成了我们的hello。
6.5 Hello的进程执行
hello正式地成为了一个进程,但这不意味着它马上就会被执行。它执行的时机受操作系统的控制。先介绍几个概念:
进程时间片:一个系统中的所有正在运行的进程,实际上是交替执行的例如现有A,B,C三个进程,则可能是A运行一段时间,然后B在运行再然后是C,像这样不断快速交替运行以达到同时执行的假象。像这样一个进程连续运行的一段完整时间片段就是分配给它的时间片。一个进程的时间片长度不会一成不变,操作系统会测量进程的资源消耗程度,来动态调整该进程的时间片长短。
用户模式和内核模式:这是CPU运行的两个模式。内核模式也叫超级用户模式。用户模式和内核模式的主要区别是用户模式不能执行一些特权指令,而内核模式可以。CPU是通过某个标志位来设置当前模式的。应用程序一般运行于用户模式下。只有发生中断,故障,或者系统调用,控制流转到异常处理程序,进程才会切换到内核模式。当从异常处理程序返回时,就会回到用户模式。
上下文切换:上下文是指一个程序当前正常运行所需的状态。当要从一个进程A切换到另一个进程B时,会发生上下文切换,先把当前进程A的上下文信息(当前运行状态)保存起来,再把之前保存的B的上下文恢复,再将控制流交给进程B。
现在回到hello的执行。上面说到,系统中的进程,有自己的时间片,按照时间片交替执行。那么问题是,在什么时候,会发生交替呢?很明显,就是刚才说的上下文切换。那么什么时候会发生上下切换呢?答案是当该进程的运行时间达到时间片长度,或者由于硬件I/O这些费时操作不得不先暂停(挂起)时。那么什么时候操作系统会检查当前进程运行时间,并考虑切换到另一进程呢?答案是:在当前进程进入内核模式的时候。由于一个程序通常会频繁的进行系统调用(I/O操作需要进行系统调用),因此操作系统也就有许多机会检查运行时间,若已达到时间片时长,或检查到要进行一些耗时操作,则会根据特定的调度算法选择另一进程运行。即使这个程序长时间不进行系统调用也没关系。外部中断,如键盘鼠标之类,也会使进程进入内核模式。就算没有键盘鼠标,CPU内部还有比一个会周期性产生中断的计时器,定时给CPU信号,此时进程也会进入内核模式。而在内核模式中,我们的hello进程就有可能接受调度,获得时间片,开始运行。当我们的hello运行了足够长的时间,在下一次进入内核模式时,就会被抢占,将CPU资源交给别的进程。
6.6 hello的异常与信号处理
异常分为4类[1]:
- 中断:是来自外部I/O设备的信号,如鼠标键盘等。
- 故障:由错误情况引起,如缺页异常,可能被修复,也可能无法恢复并终止进程。
- 终止:不可恢复的错误,如内存数据损坏,会强行终止进程。
- 陷阱:是有意的,通过指令产生的异常,通常以系统调用的方式出现。
hello的执行过程中,以上四种异常都有可能遇到,敲击键盘就会产生中断,当hello刚开始执行时,会引发缺页故障(涉及虚拟内存),当hello的内存中的代码,因为外界物理因素被损坏时,就会产生终止,hello调用printf,sleep等时,就会陷入系统调用,也就是陷阱。
hello在正常执行中,可能会收到以下信号:
- 当我们键入ctrl+z时,会向hello发送SIGTSTP信号,此时hello会停止执行(不是终止),控制回到bash(在我们用户看来)。
图6.6-1
- 键入ctrl+c,会向hello发送SIGINT信号,hello会被终止,变成僵尸进程,等待父进程的回收(通常是bash)。
图6.6-2
- 当键入ctrl+z后,使用jobs查看其任务号,使用fg将其移入前台,会向其发送SIGCONT信号,使hello进程继续运行。
图6.6-3
- 将hello放在后台运行,使用kill -2 pid命令,会向其发送SIGINT,hello会被终止。
图6.6-4
附加内容:pstree可以查看进程树,这里展示其一部分:
图6.6-5
6.7本章小结
本章介绍了进程的概念和作用,讲解了hello作为一个进程被操作系统调度的机制,以及通过hello展现了linux的信号机制。
第7章 hello的存储管理
7.1 hello的存储器地址空间
从本章开始都属于由于学时紧张而未讲授的内容,可能有许多错误。
逻辑地址:古老的8086年代,内存紧张,硬件受限,8086的寻址空间为1MB,需要20根地址线(220=1MB)。而实际上8086的字长是16位,只有16根地址线,因此为了能寻址1MB,使用两个16位整数来表示一个地址,表示为SEG:OFF。形如SEG:OFF的地址实际上表示 (SEG << 4) + OFF的物理地址单元,当SEG固定时,所能寻址的总空间就取决于OFF的位数,16位的OFF能寻址216=64KB,以(SEG<<4)为起始地址的这么大的一片空间就叫做一个段,SEG << 4就是段地址,OFF叫做段偏移地址。在8086上有CS,DS,ES,SS四个段寄存器,用来存放段地址。放到今天,内存充足,64位的地址线也能寻址足够大的空间。但是分段这个思想继承了下来,现在的CPU中的段寄存器存放的不再是段地址,而是段选择符,段选择符描述了段地址在内存中的位置,从内存中取出这个段地址,与偏移地址相加即可得到线性地址。但今天的应用程序几乎都不会使用分段机制。具体来说,就是将所有的段地址都设为0。
线性地址:在8086的时代,线性地址就是物理地址,段地址加偏移地址得到物理地址。而现在由于分页机制,虚拟内存技术,不能将线性地址与真实的物理地址直接对应,于是就有了线性地址作为逻辑地址到物理地址转换的中间层。
虚拟地址:就是线性地址。
物理地址:物理内存的真实的地址,即可以通过地址线进行正确访问的地址。
后面的就都不写了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
虽然hello只是个很简单的小程序,但是,他和几乎所有其他程序一样,都要完整地经历本文描述的阶段,全面深入地了解hello从源代码到执行的过程对我们有很大的好处。
- 预处理,做简单的文本处理,编译控制,宏,头文件的包含。
- 编译,通过编译器,将源代码转化成等价的汇编语言描述。
- 汇编,将汇编语言翻译成机器码(实际上是目标文件)。
- 链接,处理外部符号的引用,将多个可重定位目标文件连接成一个可执行目标文件。
- 加载,在shell中键入命令,shell通过fork创建子进程,子进程通过execve把hello加载进内存。
- 运行,hello接受内核的调度,在自己的时间片上运行。
- 结束,被系统终止,清理资源,由父进程回收。
附件
描述 | 作用 | |
hello.i | 预处理结果 | 展示预处理的过程 |
hello.s | 编译得到的到汇编代码 | 展示编译过程 |
hello.o | 目标文件 | 展示汇编过程 |
hello.disasm | hello.o的反汇编 | 展示hello.o的反汇编 |
hello | 可执行目标文件 | 展示链接过程 |
参考文献
[1] Randal E.Bryant, David R.O’Hallaron. Computer Systems A programmer’s perspective[M].龚奕利,贺莲 译. 机械工业出版社,2017
[2]ComputerInBook. Linux/Unix平台X64函数调用约定[EB/OL]. Linux/Unix平台X64函数调用约定_ComputerInBook的博客-CSDN博客_linux x64 函数调用约定, 2022-8-24.
[3]System V ABI AMD64[EB/OL ? ]. System V ABI AMD64
[4].eh_frame是干么用的?[EB/OL]. eh_frame是干么用的? - C/C++-Chinaunix
[5]Ch_ty. 解密虚拟内存0x400000以下的地方[EB/OL]. 解密虚拟内存0x400000以下的地方_Ch_ty的博客-CSDN博客_0x400000, 2022-4-19
[6]William Stallings. Operating System: Internals and Design Principles[M]. 陈向群,陈渝 译. 机械工业出版社,2010