本次实验通过对hello.c的编译和运行全过程跟踪研究对计算机的结构和运行原理进行深入的理解。通过对一个简单的程序hello在Linux环境下的生命周期的分析,论述其从hello.c经过预处理、编译、汇编、链接等一系列操作生成可执行文件hello,再通过程序对进程的管理、内存空间的分配、信号和异常的处理、对 I/O 设备的调用等环节彻底解释hello从创建到结束的过程,进而加深对计算机系统的理解。
关键词:计算机系统;程序的生命周期;
目 录
第1章 概述................................................................................... - 4 -
1.1 Hello简介............................................................................ - 4 -
1.2 环境与工具........................................................................... - 4 -
1.3 中间结果............................................................................... - 4 -
1.4 本章小结............................................................................... - 4 -
第2章 预处理............................................................................... - 5 -
2.1 预处理的概念与作用........................................................... - 5 -
2.2在Ubuntu下预处理的命令................................................ - 5 -
2.3 Hello的预处理结果解析.................................................... - 5 -
2.4 本章小结............................................................................... - 5 -
第3章 编译................................................................................... - 6 -
3.1 编译的概念与作用............................................................... - 6 -
3.2 在Ubuntu下编译的命令.................................................... - 6 -
3.3 Hello的编译结果解析........................................................ - 6 -
3.4 本章小结............................................................................... - 6 -
第4章 汇编................................................................................... - 7 -
4.1 汇编的概念与作用............................................................... - 7 -
4.2 在Ubuntu下汇编的命令.................................................... - 7 -
4.3 可重定位目标elf格式........................................................ - 7 -
4.4 Hello.o的结果解析............................................................. - 7 -
4.5 本章小结............................................................................... - 7 -
第5章 链接................................................................................... - 8 -
5.1 链接的概念与作用............................................................... - 8 -
5.2 在Ubuntu下链接的命令.................................................... - 8 -
5.3 可执行目标文件hello的格式........................................... - 8 -
5.4 hello的虚拟地址空间......................................................... - 8 -
5.5 链接的重定位过程分析....................................................... - 8 -
5.6 hello的执行流程................................................................. - 8 -
5.7 Hello的动态链接分析........................................................ - 8 -
5.8 本章小结............................................................................... - 9 -
第6章 hello进程管理.......................................................... - 10 -
6.1 进程的概念与作用............................................................. - 10 -
6.2 简述壳Shell-bash的作用与处理流程........................... - 10 -
6.3 Hello的fork进程创建过程............................................ - 10 -
6.4 Hello的execve过程........................................................ - 10 -
6.5 Hello的进程执行.............................................................. - 10 -
6.6 hello的异常与信号处理................................................... - 10 -
6.7本章小结.............................................................................. - 10 -
第7章 hello的存储管理...................................................... - 11 -
7.1 hello的存储器地址空间................................................... - 11 -
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 -
7.9动态存储分配管理.............................................................. - 11 -
7.10本章小结............................................................................ - 12 -
第8章 hello的IO管理....................................................... - 13 -
8.1 Linux的IO设备管理方法................................................. - 13 -
8.2 简述Unix IO接口及其函数.............................................. - 13 -
8.3 printf的实现分析.............................................................. - 13 -
8.4 getchar的实现分析.......................................................... - 13 -
8.5本章小结.............................................................................. - 13 -
参考文献....................................................................................... - 16 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1.1.1 P2P: From Program to Process
P2P,即从程序到进程。用户利用高级语言C语言编写hello.c源程序,经过cpp预处理形成hello.i,再经过ccl编译形成汇编语言程序hello.s,然后经过as转换为机器语言指令,形成可重定位目标程序hello.o,最后通过ld与库函数链接并符号解析与重定位,形成可执行目标文件hello。而后可执行文件hello通过shell加载,fork产生子进程,经过以上步骤hello程序(program)变成了hello进程(process)。
1.1.2 020: From Zero-0 to Zero -0
020,即从运行到结束。初始时内存中没有hello文件相关的内容,通过fork产生hello子进程后,通过execve进行加载,先删除当前虚拟地址已存在的数据结构,为hello的代码、数据、bss等创建区域,然后映射共享区域,设置程序计数器,进入main函数,CPU分配时间片执行逻辑控制流。执行过程中,虚拟内存为进程提供独立的空间;存储结构层层递进,让数据从磁盘传输到CPU中;TLB、分级页表等也为数据的高效访问提供保障;I/O设备通过描述符与接口实现了hello的输入输出。多方面合作配合之下,hello完成执行。然后,shell回收hello进程,删除hello的所有痕迹,释放运行中占用的内存空间。至此,hello从运行到结束,完成020过程。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;3.20GHz;16G RAM;512GHD Disk
1.2.2 软件环境
Windows10 64位;VMware 16;Ubuntu 20.04 64位;
1.2.3 开发工具
Codeblocks 64位;vi/vim/gedit+gcc
1.3 中间结果
表1.3-1 中间结果
文件名 | 作用 |
hello.c | 源代码 |
hello.i | 预处理后的文本文件 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 链接后的可执行文件 |
hello.elf | 用readelf读取hello.o的ELF格式信息 |
hello.asm | 反汇编hello.o的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
1.4 本章小结
本章主要介绍了hello的P2P和020过程,从总体上简要阐述了hello的一生,给出了论文研究时的环境与工具以及中间生成的文件信息。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理步骤是指程序开始运行时,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序的过程。
2.1.2 预处理的作用
- 宏展开:将所有的宏定义进行替换,然后删除#define
- 条件编译:如果源代码中包含条件预处理指令(如#if),就会先判断条件,再修改源代码
- 头文件展开:对文件包含命令#include,引入对应头文件,将头文件的内容(.h)插入到命令所在位置,从而把头文件和当前源文件连接成一个源文件。
2.2在Ubuntu下预处理的命令
预处理命令:gcc -E hello.c -o hello.i
图2.2-1 预处理命令截图
2.3 Hello的预处理结果解析
预处理后的源文件从23行扩展到了3060行,main函数在第3047行,预处理器将头文件中的内容引入hello.i,将需要用到的库函数等加入到了文本中,让程序能够继续被编译器编译。
- 包含文件信息
图2.3-1 hello.i中部分文件信息
- 类型定义信息
图2.3-2 hello.i中部分类型定义信息
- 函数声明信息
图2.3-3 hello.i中部分函数声明信息
- 源码部分
图2.3-4 hello.i中源码部分
2.4 本章小结
在本章中,我们对hello.c进行了预处理,生成hello.i,预处理器会进行宏展开、头文件展开、条件编译等处理,并删除注释,对函数源码并不做过多修改,hello.i文件可用于下一步的处理。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译是指预处理后,编译器(ccl)将预处理文件hello.i翻译成汇编语言文件hello.s。
3.1.2 编译的作用
- 语法检查:检查代码是否存在语法错误,如果有错误的话就会报错。
- 生成汇编代码:将程序翻译成汇编语言,从而在下一阶段可以让汇编器翻译成机器语言指令。
- 代码优化:编译器会对程序进行优化,生成效率更高的目标代码。
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
图3.2-1 编译命令
3.3 Hello的编译结果解析
3.3.1 汇编代码展示
- .file "hello.c"
- .text
- .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"
- .text
- .globl main
- .type main, @function
- 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 $8, -4(%rbp)
- jle .L4
- call getchar@PLT
- movl $0, %eax
- leave
- .cfi_def_cfa 7, 8
- ret
- .cfi_endproc
- .LFE6:
- .size main, .-main
- .ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"
- .section .note.GNU-stack,"",@progbits
- .section .note.gnu.property,"a"
- .align 8
- .long 1f - 0f
- .long 4f - 1f
- .long 5
- 0:
- .string "GNU"
- 1:
- .align 8
- .long 0xc0000002
- .long 3f - 2f
- 2:
- .long 0x3
- 3:
- .align 8
- 4:
3.3.2 汇编文本结构分析
表3.2.2-1 hello.s文件结构
内容 | 含义 |
.file | 源文件 |
.text | 代码段 |
.global | 全局变量 |
.data | 存放已经初始化的全局和静态C 变量 |
.section .rodata | 存放只读变量 |
.align | 对齐方式 |
.type | 表示是函数类型/对象类型 |
.size | 表示大小 |
.long .string | 表示是long类型/string类型 |
3.3.3 常量
编译时对常量进行编码,并将其存储在只读代码区的 .rodata节,在程序运行时会直接通过寻址找到常量。
例如将hello.c中“Usage: Hello 学号 姓名 秒数!”编译为汇编代码第6行:
- .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"
3.3.4 变量
不同类型的变量在不同位置定义,初始化的全局变量和静态变量定义在只读代码区的.bss节,已初始化的全局和静态变量定义在只读代码区的.data节,局部变量在堆上进行定义和释放。
例如,局部变量int i被保存在栈上,通过机器指令对其赋值
- movl $0, -4(%rbp)
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.3.5 赋值操作
对局部变量进行赋值操作,使用MOV指令,根据不同的数据大小选择不同指令movb、movw、movl、movq等。具体见3.3.4例。
3.3.6 算术运算
表3.3.5-1 算数指令
指令 | 效果 |
leaq s,d | d=&s |
inc d | d+=1 |
dec d | d-=1 |
neg d | d=-d |
add s,d | d=d+s |
sub s,d | d=d-s |
imulq s | r[%rdx]:r[%rax]=s*r[%rax] |
mulq s | r[%rdx]:r[%rax]=s*r[%rax] |
idivq s | r[%rdx]=r[%rdx]:r[%rax] mod s r[%rax]=r[%rdx]:r[%rax] div s |
divq s | r[%rdx]=r[%rdx]:r[%rax] mod s r[%rax]=r[%rdx]:r[%rax] div s |
在hello.s中,例如,实现i++的操作:
- addl $1, -4(%rbp)
开辟栈以及回收栈:
- subq $32, %rsp
3.3.7 比较和跳转操作
通过COM指令进行比较,计算两个值相减大小,根据结果设置条件码,根据条件码来判断跳转值,也可通过跳转指令J判断有无符号。
例如,检查argc是否不等于4。在hello.s中,使用cmpl $4,-20(%rbp),比较 argc与4的大小并设置条件码,为下一步je利用条件码进行跳转作准备。
- cmpl $4, -20(%rbp)
- je .L2
表3.3.6-1 跳转指令
指令 | 条件 |
jmp | 直接跳转 |
je | 相等 |
jne | 不等于 |
js | 小于 |
jns | 小于等于 |
jg | 大于 |
jge | 大于等于 |
ja | 大于(无符号) |
jae | 大于等于(无符号) |
jbe | 小于等于(无符号) |
3.3.7 数组/指针操作
对数组的索引相当于在第一个元素地址的基础上通过加索引值乘以数据大小来实现。
例如,在hello.c中,存在char *argv[],根据图3-3可知,根据argv首地址获得argv[1]和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
3.3.8 函数操作
hello.c中包括main函数,printf函数,sleep函数,getchar函数,exit函数。
首先,内核shell获取命令行参数和环境变量地址,执行main函数,在main中需要调用其它函数,在main中为被调用函数分配栈空间。调用函数需要借助栈,先将返回地址压入栈中,并将PC设为被调用函数的起始地址,然后调用。返回时,先从栈中弹出返回地址,再PC设置为该返回地址。return正常返回后,leave恢复栈空间。
在hello.s中调用函数有:
call puts@PLT
call exit@PLT
call printf@PLT
call sleep@PLT
call getchar@PLT
3.4 本章小结
本章主要探讨编译器将经过预处理阶段后的C程序hello.i翻译成汇编语言程序的处理过程,包括对数据、算术操作、关系操作、控制转移、数组操作、函数操作的处理。编译器也会在处理过程中对程序进行一些优化,最终的结果被保存在hello.s文件中,能够在下一阶段让汇编器翻译机器语言指令。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是指汇编器(as)将hello.s翻译成机器语言指令的过程,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中。
4.1.2 汇编的作用
将汇编指令转换成机器可以直接读取分析的机器指令,生成hello.o文件,用于后续的链接。
4.2 在Ubuntu下汇编的命令
汇编命令:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
图4.2-1 汇编命令
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
在shell中输入readelf -a hello.o > hello.elf指令获得hello.o文件的 ELF 格式:
图4.3-1 生成elf格式文件
4.3.1 ELF头
以 16字节序列 Magic 开始,其描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
图4.3.1-1 ELF头
4.3.2 节头
节头记录了各节名称及大小、类型及全体大小、地址及旗标、连接、信息和偏移量及对齐信息。
图4.3.2-1节头
4.3.3 重定位节
当链接器把这个目标文件和其他文件组合时,需要修改表中的这些位置。一般,调用外部函数或者引用全局变量的指令都需要修改。
其中包括R_X86_64_PC32( PC相对地址的引用)和R_X86_64_32(绝对地址的引用)。
图4.3.3-1重定位节
4.3.4 符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
图4.3.4-1符号表
4.4 Hello.o的结果解析
使用objdump -d -r hello.o > hello.asm分析hello.o的反汇编,并与第3章的 hello.s文件进行对照分析。
图4.4-1生成反汇编文件
4.4.1 反汇编代码
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 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 08 cmpl $0x8,-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
4.4.2 与汇编代码比较
将hello.asm和hello.s进行比较,大部分相同,主要有一下几个方面不同:
- 包含内容:
hello.s中包含.type .size .align以及.rodata只读数据段等信息,而hello.asm中只有函数的相关内容。
2.分支转移:
在hello.s中,跳转指令的目标地址直接记为段名称,如.L1,.L2等。而在反汇编得到的hello.asm中,跳转的目标为具体的地址。
3.函数调用:
在hello.s文件中,call之后直接跟着函数名称,而在反汇编得到的hello.asm中,call 的目标地址是当前指令的下一条指令。
4. 全局变量访问:
在hello.s 文件中,使用段名称+%rip访问 rodata(printf 中的字符串),而在反汇编得到的hello.asm中,使用 0+%rip进行访问。
4.4.3 机器语言的构成
机器语言是机器能直接识别的程序语言或指令代码,无需经过翻译,每一操作码在计算机内部都有相应的电路来完成它,或指不经翻译即可为机器直接理解和接受的程序语言或指令代码。机器语言使用绝对地址和绝对操作码。不同的计算机都有各自的机器语言,即指令系统。从使用的角度看,机器语言是最低级的语言。
4.4.4 机器语言与汇编语言的映射关系
汇编语言和机器语言一般是一一对应的,汇编语言是机器语言的符号表示方式。而不同类型的CPU 有不同的机器指令系统,也就有不同的汇编语言,所以,汇编语言程序与机器有着密切的关系。所以,除了同系列、不同型号CPU 之间的汇编语言程序有一定程度的可移植性之外,其它不同类型(如:小型机和微机等)CPU 之间的汇编语言程序是无法移植的,也就是说,汇编语言程序的通用性和可移植性要比高级语言程序低。
4.5 本章小结
本章主要介绍了汇编的概念与作用,在ubuntu下进行汇编的具体指令,并通过对汇编后产生的 hello.o 的可重定位的 ELF 格式的考察、对重定位项目的举例分析以及对反汇编文件与 hello.s 的对比,从原理层次进一步阐述了汇编这一过程。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是指链接器(ld)将各种代码和数据片断收集并组合成一个单一可执行目标文件的过程。
5.1.2 链接的作用
使得分离编译成为可能,我们可以独立的修改和编译模块,当我们改变这些模块的其中一个时,只需简单的重新编译它,并重新链接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
链接命令:ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o hello.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -o hello
图5.2-1 链接命令
5.3 可执行目标文件hello的格式
在Shell中输入命令 readelf -a hello > hello2.elf 生成 hello 程序的 ELF 格式文件,保存为hello2.elf:
图5.3-1 生成hello.o的elf格式
5.3.1 ELF头
以 16字节序列 Magic 开始,其描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
图5.3.1-1 ELF头
5.3.2 节头
节头记录了各节名称及大小、类型及全体大小、地址及旗标、连接、信息和偏移量及对齐信息。
图5.3.2-1节头
5.3.3 程序头
一个结构数组,描述了系统准备程序执行所需的段或其他信息。
图5.3.3-1程序头
5.3.4 Dynamic section(动态section)
图5.3.4-1 Dynamic section
5.3.5 符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
图5.3.5-1符号表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
根据计算机系统的特性,程序被载入至地址0x400000~0x401000中。在该地址范围内,每个节的地址都与前一节中节对应的 Address 相同。通过ELF可知,程序从0x00400000到0x00400fff,在0x400fff之后存放的是.dynamic到.shstrtab节的内容。
图5.4-1用edb查看虚拟地址空间
5.5 链接的重定位过程分析
图5.5-1生成反汇编文件
在Shell中使用命令objdump -d -r hello > hello2.asm生成反汇编文件hello2.asm,与第四章中生成的hello.o.asm文件进行比较。
5.5.1 重定位概念
链接器在完成符号解析以后,就把代码中的每个符号引用和一个符号定义关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。
然后就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时的地址。在 hello 到 hello.o 中,首先是重定位节和符号定义,链接器将所有输入到 hello 中相同类型的节合并为同一类型的新的聚合节。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。然后是重定位节中的符号引用,链接器会修改 hello 中的代码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
5.5.2 hello与hello.o的不同
1.链接后函数数量增加,hello中增加了外部函数。
2.函数调用指令call的参数发生变化,hello中调用为call+函数名,hello.o中为call+相对偏移地址。
3.跳转指令参数发生变化。
4.hello.o中是相对偏移地址,hello为虚拟内存地址。
5.6 hello的执行流程
hello在执行的过程中要执行载入、执行和退出三个过程,列出其调用与跳转的各个子程序名或程序地址。
表5.6-1 程序名称与程序地址
程序名称 | 程序地址 |
<_start> | 4010f0 |
<__libc_csu_init> | 4011c0 |
<_init> | 401000 |
<main> | 401125 |
<.plt> | 401020 |
<puts@plt> | 401090 |
<printf@plt> | 4010a0 |
<atoi@plt> | 4010c0 |
<exit@plt> | 4010d0 |
<sleep@plt> | 4010e0 |
<getchar@plt> | 4010b0 |
<__libc_csu_fini> | 401230 |
<_fini> | 401238 |
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
5.7 Hello的动态链接分析
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
图5.7-1 全局偏移表的位置
图5.7-2 调用 dl_init 之前的全局偏移表
图5.7-3 调用 dl_init 之后的全局偏移表
由上图对比可知,调用dl_init函数后,发生动态链接,GOT条目改变。
5.8 本章小结
本章主要理解了Ubuntu下链接的过程,链接就是是将各种代码和数据片段收集并组合成一个单一文件的过程。通过查看hello的虚拟地址空间,并且对比hello.o和hello的反汇编代码,对重定位、动态链接的过程有了深入的了解。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程的经典定义就是一个执行中的程序的实例。进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
6.1.2 进程的作用
每次用户通过向 shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell作用
读取输入并解析命令行;替换特别字符,比如通配符和历史命令符;设置管道、重定向和后台处理;处理信号;程式执行的相关设置。
1. 可交互,和非交互的使用shell。在交互式模式,shell从键盘接收输入;在非交互式模式,shell从文件中获取输入。
2. shell中可以同步和异步的执行命令。在同步模式,shell要等命令执行完,才能接收下面的输入。在异步模式,命令运行的同时,shell就可接收其它的输入。重定向功能,可以更细致的控制命令的输入输出。另外,shell允许设置命令的运行环境。
3. shell提供了少量的内置命令,以便自身功能更加完备和高效。
4. shell除了执行命令,还提供了变量,流程控制,引用和函数等,类似高级语言一样,能编写功能丰富的程序。
5. shell强大的的交互性除了可编程,还体现在作业控制,命令行编辑,历史命令,和别名等方面。
6.2.2 Shell 处理流程
命令行是一串 ASCII 字符由空格分隔。字符串的第一个单词是一个可执行程序,或者是 shell 的内置命令。命令行的其余部分是命令的参数。
如果第一个单词是内置命令,shell 会立即在当前进程中执行。否则,shell 会新建一个子进程,然后再子进程中执行程序。新建的子进程又叫做作业。通常,作业可以由 Unix 管道连接的多个子进程组成。
如果命令行以&符号结尾,那么作业将在后台运行,这意味着在打印提示符并等待下一个命令之前,shell 不会等待作业终止。否则,作业在前台运行,这意味着 shell 在作业终止前不会执行下一条命令行。 因此,在任何时候,最多可以有一个作业运行在前台。 但是,任意数量的作业可以在后台运行。例如,键入命令行:sh> jobs,会让 shell 运行内置命令 jobs。键入命令行 sh> /bin/ls -l -d 会导致 shell 在前台运行 ls 程序。根据约定,shell会执行程序的 main 函数 int main(int argc, char *argv[]),argc 和 argv 会接收到下面的值:
argc == 3,
argv[0] == ‘‘/bin/ls’’,
argv[1]== ‘‘-l’’,
argv[2]== ‘‘-d’’.
下面以&结尾的命令行会在后台执行 ls 程序:
sh> /bin/ls -l -d &
Unix shell 支持作业控制的概念,允许用户在前台和后台之间来回移动作业,并更改进程的状态(运行,停止或终止)。在作业运行时,键入 ctrl-c会将 SIGINT 信号传递到前台作业中的每个进程。SIGINT 的默认动作是终止进程。类似地,键入 ctrl-z 会导致 SIGTSTP 信号传递给所有前台进程。SIGTSTP 的默认操作是停止进程,直到它被 SIGCONT 信号唤醒为止。Unixshell 还提供支持作业控制的各种内置命令。例如:
jobs:列出运行和停止的后台作业。
bg <job>:将停止的后台作业更改为正在运行的后台作业。
fg <job>:将停止或运行的后台作业更改为在前台运行。
kill <job>:终止作业。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的、处于运行状态的子进程。
函数原型:int fork(void);
调用fork函数后,子进程返回0,父进程返回子进程的PID;新创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本;子进程获得与父进程任何打开文件描述符相同的副本;但是子进程有不同于父进程的PID。fork函数:被调用一次,却返回两次!
Fork具体处理hello文件过程:
首先,带参执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程hello。子进程获取了与父进程的上下文,包括栈、通用寄存器、程序计数器,环境变量和打开的文件相同的一份副本。子进程与父进程的最大区别是有着跟父进程不一样的PID,子进程可以读取父进程打开的任何文件。当子进程运行结束时,父进程如果仍然存在,则执行对子进程的回收,否则就由init进程回收子进程。
图6.3-1程序执行过程
6.4 Hello的execve过程
调用函数fork创建新的子进程之后,子进程会调用execve函数,在当前进程的上下文中加载并运行一个新程序hello。execve 函数从不返回,它将删除该进程的代码和地址空间内的内容并将其初始化,然后通过跳转到程序的第一条指令或入口点来运行该程序。将私有的区域映射进来,例如打开的文件,代码、数据段,然后将公共的区域映射进来。后面加载器跳转到程序的入口点,即设置PC指向_start 地址。_start函数最终调用hello中的 main 函数,这样,便完成了在子进程中的加载。
6.5 Hello的进程执行
6.5.1 进程时间片
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
6.5.2 上下文信息
上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
6.5.3 用户模式和内核模式
处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
6.5.4 执行过程
进程调度指在执行过程中,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这个过程称为调度。内存收到中断信号之后,将当前进程加入等待序列,进行上下文切换将当前的进程控制权交给其他进程,当再次收到中断信号时将hello从等待队列加入运行队列。
6.6 hello的异常与信号处理
异常可以分为四类:中断(interrupt),陷阱(trap),故障(fault)和终止(abort)。
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。在当前指令完成执行后,处理器注意到中断引脚的电压变高,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回后,它就将控制返回给下一条指令。
陷阱是有意的异常,是执行一条指令的结果。应用程序执行一次系统调用,然后把控制传递给处理程序,陷阱处理程序运行后,返回到syscall之后的指令。
故障由错误情况引起,故障发生时处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
终止是不可恢复的致命错误造成的结果。终止处理程序从不将控制返回给应用程序,处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
以下给出程序运行过程中按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令的运行结果:
1)运行时不停乱按(包括回车)
发现他会把乱按的字符打印出来,按回车它会换一行,但是这些都不影响程序的正常执行,因为当程序执行时他不会受到外部输入的影响,它会阻塞这些操作产生的信号,而因为之前将大量字符(包括回车)输入到了屏幕上,所以最后不用自己再输入字符来结束程序,而是直接读取之前的输入。
图6.6-1 乱按键盘
2)运行程序时按Ctrl-C
运行hello时按Ctrl-C,会导致一个中断异常,从而内核产生信号SIGINT,父进程受到它后,向子进程发生SIGKILL来强制终止子进程hello并回收它。这时在运行ps,可以发现并没有进程hello,可以说明他已经被终止并回收了。
图6.6-2 执行Ctrl-c
3)运行程序时按Ctrl-Z
程序运行时按Ctrl-Z,这时,产生中断异常,它的父进程会接收到信号SIGSTP并运行信号处理程序,然后便发现程序在这时被挂起了,并打印了相关挂起信息。
图6.6-3 运行程序按Ctrl-Z
Ctrl-Z后运行ps,打印出了各进程的pid,可以看到之前挂起的进程hello。
图6.6-4 Ctrl-Z后运行ps
Ctrl-Z后运行jobs,打印出了被挂起进程组的jid,可以看到之前被挂起的hello,以被挂起的标识Stopped。
图6.6-5 Ctrl-Z后运行jobs
Ctrl-Z后运行pstree,可看到它打印出的信息。
图6.6-6 Ctrl-Z后运行pstree
因为之前运行jobs是得知hello的jid为1,那么运行fg 1可以把之前挂起在后台的hello重新调到前台来执行,打印出剩余部分,然后输入hello回车,程序运行结束,进程被回收。
图6.6-7Ctrl-Z后运行fg
重新开始hello,按Ctrl-Z后通过ps得知hello的进程号为3701,那么便可通过kill -9 3701发送信号SIGKILL给进程3701,它会导致该进程被杀死。然后再运行ps,可发现已被杀死的进程hello。
图6.6-8 Ctrl-Z后运行kill
6.7本章小结
本章主要讨论了进程和shell的概念与作用,进程的创建和执行过程,以及对异常和信号的处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址指由程序产生的与段相关的偏移地址部分。在有地址变换功能的计算机中,访存指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。从hello的反汇编代码中看到的地址,它们需要通过计算,即加上对应段的基地址才能得到真正的地址,这些便是hello中的逻辑地址。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,其偏移量加上基地址就是线性地址。hello的反汇编文件中看到的地址(即逻辑地址)中的偏移量,加上对应段的基地址,便得到了hello中内容对应的线性地址。
虚拟地址:使用虚拟寻址时,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送至内存前先转换成适当的物理地址。虚拟地址转化成物理地址的过程叫做地址翻译。在linux中,虚拟地址数值树等于线性地址,即hello中看到的地址加上对应段基地址的值。
物理地址:计算机系统的主存被组织成一个M个连续字节大小的单元组成的数组,每字节都有一个独立的物理地址。它是物理内存中实际对应的地址,在hello的运行中,在访问内存时需要通过CPU产生虚拟地址,然后通过地址翻译得到一个物理地址,并通过物理地址访问内存中的位置。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段选择符和偏移量组成,线性地址为段首地址与逻辑地址中的偏移量组成。其中,段首地址存放在段描述符中。而段描述符存放在描述符表中,也就是GDT(全局描述符表)或LDT(局部描述符表)中。
图7.2 段选择符
段选择符的组成如上图。其中TI指示段描述符是在GDT还是LDT中,而索引指示段描述符在段描述符表中的位置。由此,便可以通过段选择符的指示在段描述符表中找到对应的段描述符,然后便可从段描述符中获得段首地址,将其与逻辑地址中的偏移量相加,就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(虚拟地址)由虚拟页号VPN和虚拟页偏移VPO组成。首先,MMU从线性地址中抽取出VPN,并且检查TLB,看他是否因为前面某个内存引用缓存了PTE的一个副本。TLB从VPN中抽取出TLB索引和TLB标记,查找对应组中是否有匹配的条目。若命中,将缓存的PPN返回给MMU。若不命中,MMU需从页表中的PTE中取出PPN,若得到的PTE无效或标记不匹配,就产生缺页,内核需调入所需页面,重新运行加载指令,若有效,则取出PPN。最后将线性地址中的VPO与PPN连接起来就得到了对应的物理地址。
图7.3 线性地址到物理地址的变换
7.4 TLB与四级页表支持下的VA到PA的变换
虚拟地址VA虚拟页号VPN和虚拟页偏移VPO组成。若TLB命中时,所做操作与7.3中相同;若TLB不命中时,VPN被划分为四个片,每个片被用作到一个页表的偏移量,CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,依次类推。最后在L4页表中对应的PTE中取出PPN,与VPO连接,形成物理地址PA。
图7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
MMU将物理地址发给L1缓存,缓存从物理地址中取出缓存偏移CO、缓存组索引CI以及缓存标记CT。若缓存中CI所指示的组有标记与CT匹配的条目且有效位为1,则检测到一个命中,读出在偏移量CO处的数据字节,并把它返回给MMU,随后MMU将它传递给CPU。若不命中,则需到低一级Cache(若L3 cache中找不到则到主存)中取出相应的块将其放入当前cache中,重新执行对应指令,访问要找的数据。
图7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的pid。为了给这个新进程创建虚拟内存,他创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。当fork从新进程返回,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要:1)删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。2)映射私有区域:为新程序hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。3) 映射共享区域:如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。4) 设置程序计数器(PC) :设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
对虚拟内存来说,DRAM缓存不命中称为缺页。如下例所示,CPU引用了VP3中的一个字,而VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,因为有效位0,所以并未缓存,引发了缺页异常,调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,这里以存放在PP3中的VP4为例。若VP4被修改了,那么内核会将它复制回磁盘。内核会修改VP4的页表条目,反映出VP4以不在主存中。然后,内核从磁盘复制VP3到内存中PP3位置,然后处理程序返回,重新启动导致缺页的指令。这时,VP3已存在主存中,不会在导致缺页,可以正常读取。
图7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
C程序当运行时需要额外虚拟内存时,用动态内存分配器是一种更加方便也有更好可移植性的方法。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放。这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
在hello中,printf函数会调用malloc函数。malloc函数返回一个指针,指向大小至少为size字节的内存块。有时,当我们运行程序时,才会直到某些数据结构的大小,这样便需要使用到动态内存分配。
当进行动态内存分配时,任何分配器都需要一些数据结构,以隐式空闲链表为例,带边界标签的隐式空闲链表中的每个块是由一个字的头部和一个字的脚部,有效载荷以及可能的额外填充组成的。头部和脚部编码了块的大小以及块是已分配还是空闲的。它们之间便是malloc时请求的有效载荷,以及为了满足8字节对齐要求的填充部分。他是通过头部脚部中的大小字段隐式连接的。在应用请求k字节的块时,分配器搜索空闲链表,查到一个足够大可以放置所请求块的空闲块。一旦其找到匹配的空闲块,就要分配空闲块的空间,若剩余部分足以形成新的空闲块,则将其分割。若分配器找不到合适的空闲块,则需要向内核额外请求堆内存,将其转化为大的空闲块,插入到空闲链表中,然后将请求块放置于此。当分配器释放一个已分配块时,可能有其他空闲块与新释放的相邻,这时需要进行合并,这时由于每个块的头部脚部记录了块是否空闲,那么便可通过检查其前面块的脚部和后面块的头部来判断是否有空闲块相邻。若是,也只需通过修改头部脚部便可进行合并。
以上便是动态内存管理的基本方法,下面来说一下动态内存管理的策略。
首先,对与堆中的块的组织,可以选择隐式、显示、分离空闲链表等。当分配器查找空闲块时,又可以采用不同的放置策略,如首次适配(从头开始搜索链表)、下一次适配(从上一次找到的空闲块的剩余块除法)以及最佳适配等。在分割空闲块时,又可以采用将剩余块分割为新的空闲块的策略。在合并时,可以采用带边界标记的合并,就像在上一段基本方法中所描述,通过边界标记来判断当前块周围是否也同样是空闲块,以此来判断是否需要合并。
这些就是动态内存管理的基本方法与策略。
7.10本章小结
虚拟内存是对主存的一个抽象。本章通过对虚拟内存的了解,学会了TLB和四级页表支持下VA到PA的转换,以及得到了PA后,三级cache下的物理内存的访问过程。通过本章内容,更深入掌握了fork函数和exceve函数和虚拟内存的种种联系,最后还学会了动态内存分配的管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/ O 设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O(即unix io接口),这使得所有的输入和输出都能以一种统一且一致的方式来执行。这便是Linux的IO设备管理方法。
8.2 简述Unix IO接口及其函数
8.2.1 Unix接口
1)打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2)Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) ,标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k 。
4)读写文件:一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k>=m 时执行读操作会触发一个称为EOF 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。
类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
5)关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 Unix IO接口的函数:
1)打开文件:int open(char *filename, int flags, mode_t mode);
调用open函数,通知内核你准备好访问该文件,打开一个已存在的文件或创建一个新文件。flags参数指明进程如何访问这个文件,mode参数指定新文件的访问权限位。它返回一个小的描述符数字---- 文件描述符。返回的描述符总是在进程中当前没有打开的最小描述符。若返回-1,则出错。
2)关闭文件:int close(int fd);
调用close函数,通知内核你要结束访问一个文件,关闭打开的一个文件。成功返回0,出错返回-1。
3)读文件:ssize_t read(int fd, void *buf, size_t n);
调用read函数,从当前文件位置复制字节到内存位置,然后更新文件位置。从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示错误,返回值0表示EOF,否则,返回值表示的是实际传送的字节数量。返回类型 ssize_t 是有符号整数。
4)写文件:ssize_t write(int fd, const void *buf, size_t n);
调用write函数,从内存复制字节到当前文件位置,然后更新文件位置。从内存位置buf复制至多n个字节到描述符fd的当前文件位置。返回值-1表示出错,否则,返回值表示的是从内存向文件fd实际传送的字节数量。
8.3 printf的实现分析
首先,先观察一下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的第一个参数是const char类型的形参fmt,而后面的参数用…代替,因为对于printf来说传入的参数个数不确定。那么为了正确进行打印,在之后便需要设法得知传入参数的个数。
以上代码中第6行: va_list arg = (va_list)((char)(&fmt) + 4);
首先,va_list表示一个字符指针类型。那么下面分析该类型的变量arg代表着什么。向函数传递参数时,需要把参数按从右至左的顺序依次压入栈中,因为栈是从高地址向低地址增长,所以第一个参数fmt地址最低。那么&fmt便是fmt存在栈中的地址,地址占4字节,再加上4即为下一个参数的地址。所以arg指向了…中的第一个参数。
以上代码中的第7行:i = vsprintf(buf, fmt, arg);
看看vsprintf的内容:
- int vsprintf(char *buf, const char fmt, va_list args)
- {
- char p;
- char tmp[256];
- va_list p_next_arg = args;
- for (p=buf;*fmt;fmt++) {
- if (*fmt != ‘%’) {
- *p++ = *fmt;
- continue;
- }
- fmt++;
- switch (*fmt) {
- case ‘x’:
- itoa(tmp, ((int)p_next_arg));
- strcpy(p, tmp);
- p_next_arg += 4;
- p += strlen(tmp);
- break;
- case ‘s’:
- break;
- default:
- break;
- }
- }
- return (p - buf);
- }
我们先不看它的具体代码,思考printf要做什么:接受一个格式化的命令,并把指定的匹配的参数格式化输出。那么我们可以猜测vsprintf返回的是要打印出来的字符串的长度。再看printf第8行的代码:write(buf, i),这个操作就显然是把buf中i个元素的值写到终端了。所以说:vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
观察一下write的实现:
- mov eax, _NR_write
- mov ebx, [esp + 4]
- mov ecx, [esp + 8]
- int INT_VECTOR_SYS_CALL
由于这个步骤与硬件有关,需要限制程序执行的权限。而write中的最后一行int INT_VECTOR_SYS_CALL便是通过系统来调用函数sys_call来执行下一步操作。
sys_call的实现:
- call save//保存中断前进程的状态
- push dword [p_proc_ready]
- sti
- push ecx
- push ebx
- call [sys_call_table + eax * 4]
- add esp, 4 * 3
- mov [esi + EAXREG - P_STACKBASE], eax
- cli
- ret
单单从printf的功能看sys_call的功能,便是显示格式化了的字符串。
然后通过字符显示驱动子程序,从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar由宏实现:#define getchar() getc(stdin)它有一个int型的返回值。当程序调用getchar()时,它会等待用户按键来输入字符。用户输入的字符被存放在缓冲区中,直到用户按了回车键,这时getchar()才会从stdio流中读入一个字符。
getchar函数的返回值是用户输入的字符的ASCII码,若出错返回-1,且将用户输入的字符回显到屏幕。若用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
这可以看做一个异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar函数调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
Linux将I/O输入都抽象为了文件,并提供Unix I/O接口。接口使程序只需要简单的操作符就能够进行输入与输出,底层硬件实现操作系统就可以实现,然后通过各种函数丰富其功能完善。
(第8章1分)
结论
hello的一生经历的过程包括:
- hello.c诞生
程序员通过高级语言编写hello.c程序,存储在内存中;
- 预处理
将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中,完成字符串的替换,方便后续处理;
- 编译
通过编译过程,编译器将hello.i 翻译成汇编语言文件 hello.s;
- 汇编
将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o 目标文件中;
- 链接
通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成可执行的目标文件hello;
- 加载运行
打开Shell,在其中键入 ./hello 2021112888 lbz 2,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;
- 执行指令
在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器一步一步地更新,CPU不断地取指,顺序执行自己的控制逻辑流;
- 访存
内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存/磁盘中的数据;
- 动态申请内存
printf 会调用malloc 向动态内存分配器申请堆中的内存;
- 信号处理
进程时刻等待着信号,如果运行途中键入ctr-c ctr-z 则调用shell 的信号处理函数分别进行停止、挂起等操作,对于其他信号也有相应的操作;
- 终止并被回收
Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
感悟:
在我们写下一行行的代码后,轻松的按下回车就能让它运行起来。但是很少人了解它内部的运行原理。然而作为一个程序员,只有深刻的理解程序在我们的计算机上是怎么一步一步运行的,我们才能在以后的项目工程中避免一些隐含的BUG而减少以后带来的隐患。
此次大作业中我初步了解了最简单的hello程序运行的整个过程,但是计算机系统的设计复杂而严密,对内存,CPU等各个实现都有着精密的处理设计,各个部件之间相互配合,还依旧需要日后的深入学习。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 功能 |
hello.c | 源代码 |
hello.i | 预处理后的文本文件 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 链接后的可执行文件 |
hello.elf | 用readelf读取hello.o的ELF格式信息 |
hello.asm | 反汇编hello.o的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
(附件0分,缺失 -1分)
参考文献
[1] 兰德尔·布莱恩特. 大卫·奥哈拉伦. 深入理解计算机系统 机械工业出版社
[2] 动态链接原理分析 https://blog.csdn.net/shenhuxi_yu/article/details/71437167
[3] printf 函数实现的深入剖析
https://www.cnblogs.com/pianist/p/3315801.html
[4] getchar函数浅谈 https://blog.csdn.net/zhuangyongkang/article/details/38943863
[5] Linux 线性地址,逻辑地址和虚拟地址的关系
https://www.zhihu.com/question/29918252#answer-17622254
[6] 从逻辑地址到线性地址的转换流程
白话/图示 从逻辑地址到线性地址的转换流程 - image eye - 博客园
(参考文献0分,缺失 -1分)