计算机系统
大作业
题 目程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 120L022401
班 级2003008
学 生 鲁懿丹
指 导 教 师吴锐
计算机科学与技术学院
2021年5月
摘 要
本文通过分析hello程序在Linux系统中的一生,进一步认识hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件,并由操作系统OS进行进程管理、存储管理和I/O管理的全过程。以此全面复习、总结和梳理课程内容,加深对计算机系统的理解与体会。
关键词:hello程序;预处理;编译;汇编;链接;操作系统OS;进程管理;存储管理;I/O管理;计算机系统;Linux
目 录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
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本章小结
结论
附件
参考文献
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
(1)P2P:
From Program to Progress,从程序到进程。在Linux环境下,Hello一开始是一段储存在磁盘上的程序文本hello.c(Program),经过cpp预处理得到文本hello.i;然后经过ccl编译得到汇编语言程序hello.s;再经过as汇编得到可重定位目标程序hello.o;最后经过ld链接得到可执行目标程序hello。这时,在Shell中输入./hello命令启动程序,Shell调用fork函数创建子进程(Process),实现从程序到进程。
(2)020:
From Zero-0 to Zero-0,从无而终。Shell接着调用execve函数进行加载,运行hello需要CPU为hello分配时间片、内存,以便执行逻辑控制流;由OS进行存储管理,CPU访问相关数据需要MMU实现虚拟地址到物理地址的转换,其中TLB、四级页表、三级cache等可以加速转换,从而加速访问过程;系统的进程管理使hello可以实现切换上下文,shell的信号处理程序使hello在运行过程中可以处理各种信号。当hello运行结束后,由父进程回收hello,删除相关数据。这样就实现了赤条条来去无牵挂,从Zero-0到Zero-0。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
1.2.1 硬件环境
X64 CPU;2.3GHz;16G RAM;256GHD Disk
1.2.2 软件环境
Windows1164位
1.2.3 开发工具
VirtualBox6.1.32;Ubuntu 20.04LTS 64位;Visual Studio 2019 64位;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
中间结果文件 | 文件作用 |
hello.c | 源程序文件 |
hello.i | 预处理后生成的文件 |
hello.s | 编译后生成的汇编语言程序 |
hello.o | 汇编后生成的可重定位目标程序 |
hello_o_asm.txt | hello.o的反汇编文件 |
hello_o_elf.txt | hello.o的ELF文件格式 |
hello | 链接后生成的可执行目标程序 |
hello_asm.txt | hello的反汇编文件 |
hello_elf.txt | hello的ELF文件格式 |
1.4 本章小结
本章概述了hello的一生,介绍了hello从编译生成,到加载执行,再到终止回收的P2P、020的全过程,并且列出了本次实验的软硬件环境及工具,最后列出了从hello.c到hello的过程中生成的中间文件及作用。
第2章 预处理
2.1预处理的概念与作用
预处理的概念:
预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor)对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
预处理的作用:
预处理过程扫描源代码,对其进行初步转换,结果得到另一个C程序,通常是以.i作为文件扩展名,具体作用如下:
(1)头文件展开:预处理器搜索以字符#开头的命令,通过将预处理指令替换为实际代码中的内容,来修改原始C程序,例如#include是预处理指令,是包含头文件的操作,将所包含头文件的指令替换为实际内容,同时如果头文件中包含了其他头文件,也需要将头文件展开;
(2)处理宏定义:预处理器读入源代码,对源代码中所有使用宏定义的地方使用符号表示的实际值替换定义的符号;
(3)处理条件编译指令:条件编译指令如#ifdef,#else,#endif等,这些伪指令的引入使程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预处理将根据有关文件,将不必要的代码过滤掉;
(4)删除程序中的注释和多余的空白字符;
(5)处理特殊符号:预处理可以对程序中出现的特殊符号例如FILE、#error等用合适的值替换。
2.2在Ubuntu下预处理的命令
预处理的命令:gcc-E hello.c -o hello.i
图2.1Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
图2.2Hello的预处理结果图
Hello预处理得到.i文件,打开后发现将.c的代码扩展到了3060行。
头文件展开:#include “file name”;#include ;#include 记号序列。预处理时,源文件中的这些行将被替换为由文件名指定的文件内容,hello.c的头文件有stdio.h, unistd.h , stdlib.h。
图2.3头文件展开的部分截图
删除程序主体段的注释信息如下:
图2.4删除主体段的注释信息对比
由于源程序中不存在宏定义#define;条件编译指令#if、#endif;特殊符号等,因此该部分不做展示。
2.4 本章小结
本章介绍了在预处理过程中预处理器的工作:头文件展开、处理宏定义、处理条件编译指令、删除程序中的注释和多余的空白字符、处理特殊符号等,并在Ubuntu下对hello.c进行预处理,最后展示解析处理结果。
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译就是将源语言经过词法分析、语法分析、语义分析以及经过一系列优化后生成汇编代码的过程。其以高级程序设计语言书写的源程序作为输入,而以汇编语言或机器语言表示的目标程序作为输出。编译器将文本文件hello.i 翻译成文本文件hello.s,它包含一个汇编语言程序。
编译的作用:
生成的汇编语言程序每条语句都以一种文本格式描述了一条低级机器指令,汇编语言为不同的高级语言的不同编译器提供了通用的输出语言,汇编语言相对于预处理文件更利于机器理解。除此之外,编译程序还具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人际联系等重要功能。
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
图3.1Ubuntu下编译的命令
3.3 Hello的编译结果解析
图3.2Hello的编译结果图
编译得到.s文件,最开始以.开头的行都是指导汇编器和链接器工作的伪指令:
.file “hello.c”(源文件名)
.text(代码段)
.section .rodata(只读代码段)
.align 8(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(指出是对象类型或是函数类型)
下面说明编译器是如何处理C语言的各个数据类型以及各类操作的:
3.3.1数据
3.3.1.1常量
①数字常量:大多是以立即数的形式出现在汇编代码中,例如if(argc!=4)中的4;exit(1)中的1;for(i=0;i<8;i++)中的8(这里编译器将<8翻译为≤7)。
图3.3if(argc!=4)中的4图3.4exit(1)中的1 图3.5for(i=0;i<8;i++)中的8
②字符串常量:在printf等函数中出现的字符串常量存储在.rotate段。
图3.6字符串常量的存储情况
3.3.1.2变量
①全局变量、静态变量:该程序没有全局变量和静态变量,所以在hello.s的伪指令中没有.data和.bss,但是存在.rodata只读数据节,这是用来存放printf的字符串的,在上面字符串常量中已提到,不再赘述。
②局部变量:局部变量存储在栈中或直接存储在寄存器中,共三个局部变量:i、argc、argv:
图3.7局部变量i存储在栈中-4(%rbp)位置
图3.8局部变量argc表示程序输入变量的个数,存储在栈中-20(%rbp)位置
图3.9局部变量argv是保存着程序输入变量的数组,存储在栈中。
3.3.1.3表达式
共3个表达式,分别为:
赋值表达式i=0,汇编语言为movl $0, -4(%rbp);
关系表达式argc!=4,汇编语言为cmpl $4, -20(%rbp);
关系表达式i<8,汇编语言为分别用cmpl $7, -4(%rbp)。
3.3.2赋值
赋值语句i=0,汇编语言为movl $0, -4(%rbp)。
附用于赋值的mov指令:
3.3.3算术操作
算数操作语句i++,汇编语言为addl $1, -4(%rbp)。
附用于算术操作的指令:
3.3.4关系操作
共两处关系操作:
3.3.4.1
if(argc!=4)对应的汇编指令如下:
图3.10
3.3.4.2
for(i=0;i<8;i++)对应的汇编指令如下:
图3.11
3.3.5数组操作
仅有一处数组操作,即对argv数组的操作,观察汇编代码发现argv数组中的两个值都存储在栈中,printf(“Hello %s %s\n”,argv[1],argv[2])其中argv[1]存储在-32(%rbp)位置,argv[2]存储在-16(%rbp)位置,相应的汇编代码为:
图3.12
3.3.6控制转移
共两处控制转移:
3.3.6.1
图3.13
首先用cmpl指令比较argc和4的大小,然后用je指令判断argc与4是否相等,若相等,则跳转到.L2;若不相等,则继续运行紧挨着的下一条指令。
3.3.6.2
图3.14
首先用cmpl指令比较i和7的大小,然后用jle指令判断i是否≤7,若是, 则跳转到.L4;若不是,则继续运行紧挨着的下一条指令。
3.3.7函数操作
该程序的函数有六个,分别是:main函数,printf函数,sleep函数,getchar函数,exit函数和atoi函数,函数返回值保存在寄存器%eax中。
3.3.7.1main函数
main函数的参数是argc和argv,main函数被调用,即call才能执行;call指令将下一条指令的地址压栈,然后跳转到main函数;程序结束时,调用leave指令恢复栈空间为调用之前的状态,然后ret返回。
图3.15 main的两个参数argc与argv
图3.16 main函数返回
3.3.7.2printf函数
printf函数将参数.LC0传递到寄存器%rdi中,然后调用puts函数。
图3.17
3.3.7.3sleep函数
sleep函数参数是atoi (argv[3]),每次循环都调用一次sleep函数。
图3.18
3.3.7.4getchar函数
退出循环时调用getchar函数。
图3.19
3.3.7.5exit函数
exit参数是1,将参数传递到寄存器%edi中,然后调用exit函数;
图3.20
3.3.7.6atoi函数
该函数有三个参数,函数具体实现在这里不去深究,可以看出调用完atoi 后,返回值保存在寄存器%eax中,传递给sleep函数做参数。
图3.21
图3.22
3.4 本章小结
本章介绍了编译的概念及作用,然后在Ubuntu下将hello.i编译成汇编语言程序hello.s并展示了编译结果,最后分别从数据、赋值、算术操作、关系操作、数组操作、控制转移、函数操作等方面对编译结果进行了解析。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
汇编就是将汇编语言转化为机器可直接读懂并执行的代码文件的过程。汇编器将hello.s汇编语言程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中,.o文件是二进制文件,它包含程序的指令编码。
汇编的作用:
将汇编代码转化为机器可直接识别执行的二进制代码。
4.2 在Ubuntu下汇编的命令
汇编命令:gcc -m64 -Og -c -no-pie -fno-PIC hello.c -o hello.o
图4.1 Ubuntu下汇编的命令
图4.2 Hello的汇编结果图
4.3 可重定位目标elf格式
4.3.1
可重定位目标文件的ELF格式为:
ELF头 | 节 |
.text | |
.rodata | |
.data | |
.bss | |
.symtab | |
.rel.text | |
.rel.data | |
.debug | |
.line | |
.strtab | |
节头部表 | 描述目标文件的节 |
用命令:readelf -a hello.o > ./hello_o_elf.txt导出可重定位目标文件的ELF格式文件:
图4.3 导出可重定位目标文件的ELF格式文件结果图
4.3.2用readelf等列出其各节的基本信息
①读取ELF头
命令:readelf -h hello.o
图4.4 读取ELF头
可以观察到,ELF头是对ELF文件整体信息的描述,以16字节的序列Magic开始,Magic描述系统字的大小和字节顺序。同时,ELF头还包含了有关文件类型和大小的有关信息,以及文件加载后程序执行的入口点信息等。
②读取节头部表
命令:readelf -S hello.o
图4.5 读取节头部表
可以观察到,节头部表包含了描述文件节区的信息,比如节的类型、大小、偏移、读写权限、对齐方式等。每个节都从0开始,用于重定位。在ELF头得到节头部表的信息,然后利用节头部表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,各节的读写权限等。
③读取重定位节
命令:readelf -r –relocs hello.o
重定位节包含.text节中需要进行重定位的信息,当链接器ld把可重定位目标文件与其他文件组合时,需要修改这些位置。
重定位节中各符号的信息:
偏移量:需要修改的符号引用的起始位置在目标节中的偏移量;
信息:包括符号表索引和重定位类型,符号表索引占高32位,重定位类型占用低32位;
符号值:打印所对应符号表条目的符号值;
符号名:打印所对应符号表条目的符号名;
加数:一个有符号常数,一些重定位类型要使用它对被修改符号引用的值做偏移调整。
计算重定位地址常见有两种方法:
R_X86_64_32:重定位绝对引用。重定位时使用一个32位的绝对地址的引用,通过绝对寻址,CPU直接把在指令中编码的32位值作为有效地址。
R_X86_64_PC32:重定位PC相对引用。重定位时使用一个32位的PC相对地址的引用。一个PC相对地址就是据PC程序计数器当前运行值的偏移量。
图4.6 读取重定位节
④查看符号表
命令:readelf -s hello.o
图4.7 符号表
可以观察到,符号表用来存放程序中定义和引用的函数以及全局变量的信息。例如本程序中的main、puts、exit、sleep等函数的信息均在该部分出现。其中,符号定义的实质是指分配了存储空间,目标文件中的其它部分通过某个符号在符号表中的索引值来使用该符号。符号表各项定义:value在重定位文件中,是符号相对于目标节的偏移量,在可执行目标文件中,该值是一个虚拟地址,直接指向符号所在的内存位置;size是符号对应目标的字节大小,type是符号对应目标的类型;Bind字段表明符号是本地符号还是全局符号;name是符号名,对应重定位节的符号名。
4.4 Hello.o的结果解析
4.4.1 hello.o的反汇编结果
命令:objdump -d -r hello.o> hello_o_asm.txt,将生成的反汇编文件导入hello_o_asm.txt中。
图4.8 反汇编结果图
4.4.2反汇编结果与第3章的hello.s对照
①反汇编代码在指令前增加了其十六进制表示,即机器指令码;
②操作数:hello.s中操作数为十进制,而hello.o的反汇编中操作数为十六进制;
③分支跳转:hello.s中用函数名进行跳转,而hello.o的反汇编中通过地址进行跳转;
④函数调用:hello.s中call后跟的是函数名,而hello.o的反汇编中,call后跟的是下一条指令,因为这些函数都是共享库函数,地址不确定,因此call指令将相对地址全部设置为0,再在.rela.text节中为其添加重定位条目,等待链接生成可执行文件后才能确定地址。
⑤调用.rodata节中的格式串:hello.s中通过.LC0(%rip)实现,而hello.o的反汇编中.rodata节中的数据是在运行时确定的,需要重定位,先填0占位,再在.rela.text节中为其添加添加重定位条目。
4.4.3机器语言的构成
机器指令是CPU能直接识别并执行的指令,它的表现形式是二进制编码。
机器指令通常由操作码和操作数两部分组成,操作码指出该指令所要完成的操作,即指令的功能,操作数指出参与运算的对象,以及运算结果所存放的位置等。
4.4.4机器语言与汇编语言的映射关系
具体分析见上述4.4.2机器语言与汇编语言的对照,这里仅给出截图示例:
①分支跳转:hello.s中用函数名进行跳转,而hello.o的反汇编中通过地址进行跳转。例如判断argc!=4后跳转:
图4.9 hello.o的反汇编 图4.10 hello.s
②函数调用:hello.s中call后跟的是函数名,而hello.o的反汇编中,call后跟的是下一条指令,并且增加了重定位条目用于链接时重定位。例如调用exit函数:
图4.11 hello.o的反汇编图4.12 hello.s
③调用.rodata节中的格式串:hello.s中通过leaq .LC0(%rip),%rdi实现,而hello.o的反汇编中通过mov $0x0,%edi实现,先填0占位,并且增加了重定位条目用于链接时重定位。例如printf语句中的字符串:
图4.13 hello.o的反汇编图4.14 hello.s
4.5 本章小结
本章介绍了汇编的概念及作用,然后在Ubuntu下将hello.s汇编成可重定位目标程序hello.o,展示了汇编结果,并导出了可重定位目标文件的ELF格式文件;用readelf命令读取了ELF头、读取了节头部表、读取了重定位节、查看了符号表等,并分析了相关信息;同时,分析了hello.o的反汇编,并与第3章的hello.s进行对照分析;最后说明了机器语言的构成,与汇编语言的映射关系。
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时、加载时、运行时。
链接的作用:
链接使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个 巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改 和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它, 并重新链接,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
链接的命令:
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 Ubuntu下链接的命令
图5.2 Hello的链接结果图
5.3 可执行目标文件hello的格式
5.3.1
可执行目标文件的ELF格式为:
ELF头 | 只读代码段 | 将连续的文件节映射到运行时内存段 |
段头部表 | ||
.init | ||
.text | ||
.rodata | ||
.data | 读/写数据段 | |
.bss | ||
.symtab | 不加载到内存的符号表和调试信息 | |
.debug | ||
.line | ||
.strtab | ||
节头部表 | 描述目标文件的节 |
用命令:readelf -a hello > ./hello_elf.txt导出可执行目标文件的ELF格式文件:
图5.3 导出可执行目标文件的ELF格式文件结果图
5.3.2用readelf等列出其各节的基本信息
可以观察到ELF头、节头部表、.text节、.rodata节和.data节与可重定位目标文件的ELF格式是类似的;.init节定义了一个函数_init,程序初始化代码会调用它;可执行文件是完全链接后得到的文件,因此无记录重定位信息的.rel节。
①读取ELF头
命令:readelf -h hello
图5.4 读取ELF头
可以观察到,ELF头是对ELF文件整体信息的描述,包含了有关文件类型和大小的有关信息,以及文件加载后程序执行的入口点信息等,hello与 hello.o的ELF头大体相同,不同之处在于hello的类型为EXEC(可执行文件), 表明hello是一个可执行目标文件。
②读取节头部表
命令:readelf -S hello
图5.5 读取节头部表
可以观察到,节头部表描述了各个节的大小、偏移量及其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
③读取重定位节
命令:readelf -r –relocs hello
图5.6 读取重定位节
④查看符号表
命令:readelf -s hello
图5.7 符号表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息:
图5.8 hello的虚拟地址空间图1
图5.8 hello的虚拟地址空间图2
可以观察到hello的虚拟地址空间从0x401000开始,最后结束于0x401ff0。
根据5.3的节头部表信息,可以通过edb查看每个节的信息,例如.text节,虚拟地址起始于0x4010f0,大小为0x135,如下图:
图5.9 节头部表中.text节的信息
图5.10 edb查看.text节的信息
5.5 链接的重定位过程分析
用命令:objdump -d -r hello> hello_asm.txt,反汇编hello,并将生成的反汇编文件导入hello_asm.txt中,分析hello与hello.o的不同,并结合hello.o的重定位项目,分析hello中对其怎么重定位的:
图5.11 hello的反汇编文件hello_asm.txt
①hello反汇编比hello.o反汇编增加了很多经过重定位后的函数,如_start、__libc_csu_init等,hello.o反汇编在.text节只有一个main函数;
图5.12 hello增加的函数示例
图5.13hello.o在.text节的main函数
②hello反汇编比hello.o反汇编增加了很多节,如.init、.plt.sec等:
图5.14 hello增加的节示例
③hello反汇编代码中是完成了重定位后的确定的虚拟地址,而hello.o反汇编代码中是相对偏移地址,未完成重定位
图5.14 hello反汇编中确定的虚拟地址图5.15hello.o反汇编中的相对偏移地址
④hello的反汇编中调用函数call后接的是函数的PC相对地址,而hello.o的反汇编中,call后跟的是下一条指令,并且增加了重定位条目,用于链接时重定位。例如调用exit函数:
图5.16 hello反汇编中调用exit函数
图5.17hello.o反汇编中调用exit函数
⑤对数据的重定位:调用.rodata节中的格式串时,hello的反汇编中用字符串的地址做为参数传递,而hello.o的反汇编中通过mov $0x0,%edi实现,先填0占位,并且增加了重定位条目用于链接时重定位。
例如printf语句中的字符串:
图5.18 hello的反汇编
图5.19 hello.o的反汇编
⑥利用call语句分析重定位后机器指令中的字节表示,例如call调用sleep函数,函数的地址为0x4010d0,call下一条指令的地址为0x401189,0x4010d0减去0x401189等于0xffffff47,小端法就得到了如图的机器指令的字节表示:图5.20 重定位后机器指令中的字节表示
链接的过程:
链接主要分为两个过程:符号解析和重定位。
符号解析:目标文件定义和引用符号,符号解析将每个符号引用和一个符号定义关联起来;
重定位:编译器和汇编器生成从0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
5.6 hello的执行流程
使用edb执行hello,可以观察到先调用_init函数加载hello;然后是一系列main中会用到的函数,如puts、getchar、printf等,这些函数在代码段并不占用实际的空间只是一个占位的符号,实际上他们的内容在共享区(高地址)处;接着调用_start函数,即起始的地址;然后call main开始执行main函数,main函数中会通过PC相对地址调用所需函数;main函数执行结束后还会执行__libc_csu_init 、__libc_csu_fini 、_fini函数,最终程序结束。
列出hello调用与跳转的各个子程序名如下:
_init
puts@plt
printf@plt
getchar@plt
exit@plt
sleep@plt
_start
_dl_relocate_static_pie
main
__libc_csu_init
__libc_csu_fini
__fini
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。
动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射。使用偏移量表GOT和过程链接表PLT实现函数的动态链接。GOT中存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用:
首先在hello的elf文件中找到.got.plt的起始地址是0x404000
图5.21 hello的elf文件
在edb中找到相应地址,经分析在dl_init前后,经动态链接,GOT条目已经改变,增加了如图两个地址,即GOT[1],GOT[2]:
图5.22 dl_init前
图5.23 dl_init后
5.8 本章小结
本章介绍了链接的概念及作用,然后在Ubuntu下链接生成可执行目标文件hello,展示了链接结果,并导出了可执行目标文件的ELF格式文件;用readelf命令读取了ELF头、读取了节头部表、读取了重定位节、查看了符号表等,并分析了相关信息;最后,分析了链接的重定位过程、hello的执行流程、hello的动态链接。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
一个执行中的程序的实例,同时也是系统进行资源分配和调度的基本单位。一般情况下,包括文本区域、数据区域和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
进程的作用:
进程提供了一种假象,程序好像是独占的使用CPU和内存,CPU好像是无间断地一条接一条执行程序中的指令,即实现了逻辑控制流。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:
Shell是指为用户提供操作界面的软件(命令解析器)。它接收用户的操作(点击图标、输入命令),并进行简单的处理,然后再传递给内核,内核和用户之间就多了一层“中间代理”,Shell其实就是一种脚本语言,也是一个可以用来连接内核和用户的软件。
常用的Shell:bash由GNU组织开发,sh是UNIX上的标准Shell,是第一个流行的Shell,bash保持了对sh的兼容性,是各种Linux发行版默认配置的shell。现在sh已经基本被bash代替,bash是sh的扩展补充,但是也有些是不兼容的,大多数情况下区别不大,特殊场景可以使用bash代替sh。
处理流程:
(1)终端进程读取用户通过键盘输入的命令行;
(2)解析命令行字符串,获取命令行参数,并构造传递给execve的argv向量;
(3)检查第一个命令行参数是否是一个内置的shell命令;
(4)如果不是内部命令,调用fork( )函数创建子进程;
(5)在子进程中,用步骤2获取的参数,调用execve( )加载执行指定程序;
(6)如果用户没要求后台运行(命令末尾没有&号),则shell使用waitpid(或wait…)等待进程终止后由父进程回收并返回;
(7)如果用户要求后台运行(如果命令末尾有&号),则shell返回。
6.3 Hello的fork进程创建过程
首先,要执行hello,在shell中输入命令行./hello 120L022401 鲁懿丹1
然后,Shell解析命令行字符串,检查第一个命令行参数是否是一个内置的shell命令,发现不是内部命令;
那么,父进程shell就会调用fork()函数创建一个新的运行的子进程,子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同但是独立的一个副本,子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的区别是他们的PID不同。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程的任务是等待子进程的完成。
6.4 Hello的execve过程
当fork了一个子进程之后,子进程调用execve函数在当前子进程的上下文加载并运行一个新的程序,即hello程序。execve函数调用启动加载器来执行hello程序,加载器执行的操作是:删除子进程现有的虚拟内存段;创建新的代码、数据、堆和栈段,其中代码和数据段被初始化为hello的代码和数据,堆和栈段被初始化为0;保留相同的PID,继承已打开的文件描述符和信号上下文,除非有错误,否则execve函数被调用一次且从不返回;最后,加载器将PC指向hello程序的起始位置,即从下条指令开始执行hello程序。
6.5 Hello的进程执行
进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
进程上下文信息:进程由常驻内存的操作系统代码块——内核管理,控制流通过上下文切换从一个进程传递到另一个进程。进程的物理实体(代码和数据等)和支持进程运行的环境合称为进程的上下文。由进程的程序块、数据块、运行时的堆和用户栈等组成的用户空间信息被称为用户级上下文;由进程标识信息、进程现场信息、进程控制信息和系统内核栈等组成的内核空间信息被称为系统级上下文;处理器中各寄存器的内容被称为寄存器上下文(硬件上下文)即进程的现场信息。
进程调度的过程:虽然系统中有许多程序在同时运行,但是进程会给每个程序提供一种假象:程序好像是在独占地使用CPU和内存,CPU好像是无间断地一条接一条执行程序中的指令,即每个进程是个逻辑控制流,对于单处理器系统,进程会轮流使用处理器,处理器的物理控制流由多个逻辑控制流组成,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。如果单步调试程序可以发现在执行程序时一系列的PC程序计数器的值,这个PC值的序列就是逻辑控制流。在进程执行的某个时刻,内核决定暂时挂起当前进程,执行其他进程,这种决定就叫进程调度,进程调度是由内核中称为调度器的代码处理的,并使用上下文切换机制来将控制转移到新的进程。
图6.1 逻辑控制流
用户模式和内核模式:shell使用户可以有机会修改内核,所以需要设置一些防护措施来保护内核。用户模式指执行用户程序的代码或环境,内核模式指执行内核程序的代码或环境,二者操作权限不同,用户模式无法访问内核模式的资源,而内核模式可以访问所有资源。
上下文切换、用户模式与内核模式转换:内核利用上下文切换来实现多任务。内核为每个进程维持一个上下文,它是内核重启被抢占的进程所需的状态,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构的值。内核通过上下文切换机制来将控制转移到新的进程,上下文切换机制步骤为:①保存当前进程上下文;②恢复某个先前被抢占(挂起)的进程被保存的上下文;③将控制转移给这个新恢复的进程。
图6.2 上下文切换
最后,分析hello的进程调度,hello调用sleep函数,sleep函数显式请求调用休眠进程,sleep进程将内核抢占,hello进程被挂起,进入倒计时,倒计时结束时会产生一个中断信号,中断当前正在进行的休眠进程,进行上下文切换,恢复原进程hello的上下文信息,控制转移回hello继续执行。当循环结束后,调用getchar函数,原来hello运行在用户模式下,调用getchar时进入内核模式,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并执行上下文切换,把控制转移给键盘缓冲区传输的信号代表的进程。当完成键盘缓冲区到内存的数据传输后,产生一个中断信号,内核将控制从其他进程转移回hello进程;最后,执行return,进程终止。
6.6 hello的异常与信号处理
hello执行过程中可能出现四类异常:中断、陷阱、故障和终止:
①中断是来自I/O设备的信号,异步发生,中断处理程序对中断异常进行处理,总是返回到下一条指令,hello执行过程中遇到来自I/O设备的中断,例如乱按键盘;
②陷阱是有意的异常,同步发生,是执行一条指令的结果,陷阱处理程序对陷阱进行处理,总是返回到下一条指令,hello执行过程中的陷阱异常,如sleep函数;
③故障是由潜在的可恢复的错误引起的,同步发生,它可能能够被故障处理程序修正。如果修正成功,返回到当前指令;否则将终止程序,例如hello运行时发生缺页异常;
④终止是不可恢复的错误引起的,通常是一些硬件错误,同步发生,不会返回,例如hello执行时DRAM、SRAM的奇偶错误。
下面分析hello执行过程中对各种异常和信号的处理:
(1)正常运行:
共循环8次,每次输出Hello argv[1] argv[2]\n,即解析完命令行字符串得到的argv向量,输出后等待argv[3]秒,继续循环,最后需要输入一个字符回车结束程序。
图6.3 正常运行
(2)运行过程中不停乱按:
①运行过程中仅乱按,不包含回车,只是将乱按的内容输出,程序继续执行:
图6.4 运行过程中不停乱按图1
②运行过程中乱按,包含回车,输入的内容到第一个回车之前会被当作getchar缓冲掉,只是将内容显示输出程序继续执行,第一个回车后面的内容将会在该程序结束之后作为命令行输入:图6.5 运行过程中不停乱按图2
(3)按下Ctrl-Z:
进程收到SIGSTP信号,hello进程挂起。用ps命令查看其进程PID,发现hello进程的PID是4615;再用jobs命令查看此时hello的后台job号是1;最后用命令fg 1将hello调回前台,使其继续执行:图6.6 按下Ctrl-Z
(4)kill命令:
挂起的进程被kill,即进程被终止,用ps命令查看其进程PID,查不到;再用jobs命令查看此时hello的后台job号,也没有显示:
图6.7 kill命令
(5)按下Ctrl-C:
进程进程收到SIGINT信号,结束hello。用ps命令查看其进程PID,查不到;再用jobs命令查看此时hello的后台job号,也没有显示,说明hello进程已彻底结束:
图6.7 按下Ctrl-C
(6)打印进程树:
以树的形式打印出从开机开始各个进程的父子关系:
图6.8进程树
6.7本章小结
本章先介绍了进程的概念及作用、Shell的作用及处理流程;然后介绍并分析了Shell通过fork函数和execve函数运行可执行文件Hello的过程;接着结合进程上下文信息、进程时间片,阐述了进程调度的过程、用户态与核心态转换等;最后,指出了Hello的异常与信号的处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:
逻辑地址是hello程序源码编译后所形成的跟实际内存没有直接联系的地址,是编译后出现在汇编代码中的地址,用来指定一个操作数或一条指令的地址。那么,在不同的机器上使用相同的编译器来编译同一个源程序其逻辑地址就是相同的,由段标识符加上段偏移量表示,也叫相对地址。
线性地址:
线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,再加上基地址就是线性地址。
虚拟地址:
虚拟地址是程序访问存储器所使用的逻辑地址,与实际物理内存容量无关。
物理地址:
物理地址是出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,则hello的线性地址能再经变换产生一个物理地址;若没有启用分页机制,则hello的线性地址就是物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理:指把一个程序分成若干段如代码段,数据段,共享段等进行存储,每个段都是一个逻辑实体。段式管理是通过段表进行的,包括段号(段名),段起点,装入位,段的长度等。
逻辑地址是hello程序源码编译后所形成的跟实际内存没有直接联系的地址,是编译后出现在汇编代码中的地址,用来指定一个操作数或一条指令的地址。那么,在不同的机器上使用相同的编译器来编译同一个源程序其逻辑地址就是相同的。逻辑地址由两部分组成:段标识符和段偏移量。段标识符由一个16位长的字段组成,即段选择符,前13位是一个索引号,后3位为一些硬件细节。索引号即段描述符的索引,每个段描述符的长度是8字节,含有3个主要字段:段基地址、段限长和段属性。段描述符通常由编译器、链接器、加载器或者操作系统来创建,但绝不是应用程序,由很多个段描述符组成段描述符表。通过段标识符的前13位在段描述符表中查找到段描述符。
实模式下:逻辑地址CS:EA到物理地址CS*16+EA;
保护模式下:以段描述符作为下标,到全局描述符表/局部表述符表查表获得段基地址,段基地址+偏移地址=线性地址。图7.1 逻辑地址到线性地址的转化
全局描述符表(GDT)整个系统只有一个,包含:操作系统使用的代码段、数据段、堆栈段的描述符;各任务、程序的局部描述符表段;
每个任务、程序有一个独立的局部描述符表(LDT),包含:对应任务、程序私有的代码段、数据段、堆栈段的描述符;对应任务、程序使用的门描述符:任务门、调用门等。
段选择符中TI=0,选择全局描述符表(GDT);TI=1,选择局部描述符表(LDT)。
段选择符中RPL=00,为第0级,位于最高级的内核态;RPL=11,为第3级,位于最低级的用户态。
被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基地址,与32位段内偏移量相加得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理:
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页,页式管理把内存空间按页的大小划分成片或者页面,然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。VM系统将虚拟内存分割,称为虚拟页,类似地,物理内存也被分割成物理页。利用页表来管理虚拟页,页表就是一个页表条目(PTE)的数组,将虚拟页地址映射到物理页地址,每个页表条目由一个有效位和物理页号(PPN)组成,有效位表明了该虚拟页当前是否被缓存在DRAM中,如果有效位=1,那么地址字段就表示DRAM中相应的物理页的PPN;如果有效位=0,页面不在存储器中,发生缺页,则从磁盘读取。
MMU利用页表实现从虚拟地址到物理地址的翻译。
当页命中时,CPU硬件执行以下步骤:
- 处理器生成一个虚拟地址并把它传送给MMU;
- MMU生成PTEA,并从高速缓存/主存请求得到它;
- 高速缓存/主存向MMU返回PTE;
- MMU构造物理地址,并把它传送给高速缓存/主存;
- 高速缓存/主存返回所请求的数据字给处理器。
图7.2 页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
按照上述模式,每次CPU生成一个虚拟地址并传送给地址管理单元MMU,MMU必须找到一个PTE将虚拟地址翻译为物理地址。为了消除这种操作带来的大量时间开销,MMU中存在一个关于PTE的小的缓存,称为翻译后备缓冲器(TLB)。TLB通过虚拟页号(VPN)进行索引,MMU利用VPN选择合适的PTE,如果虚拟页是已缓存的,直接将页表条目的物理页号PPN和虚拟页偏移量VPO串联起来就得到相应的物理地址;如果虚拟页未缓存不命中,会触发缺页故障,调用缺页处理程序将磁盘的虚拟页重新加载到内存中,再从内存中将PTE复制到TLB。
core i7采用四级页表层次结构,减少了页表过大造成的空间损失。虚拟地址由VPN和VPO组成,36位虚拟页号(VPN)被划分成四个9位的片VPN1、VPN2、VPN3、VPN4,每个片VPNi是到第i级页表的索引。CR3寄存器包含第一级页表的物理地址,VPN1提供到L1 PTE的偏移量,L1 PTE包含第二级页表的基地址,VPN2提供到L2 PTE的偏移量,以此类推,最后L4 PTE包含页的物理地址。
图7.3Core i7 MMU如何使用四级页表将VA转换为PA
7.5 三级Cache支持下的物理内存访问
L1Cache的物理内存访问的大致过程为:
(1) 组选择:取出虚拟地址的组索引位,把二进制组索引转化为一个无符号整数,找到相应的组;
(2) 行匹配:把虚拟地址的标记位与相应的组中所有行的标记位进行比较,当虚拟地址的标记位和高速缓存行的标记位匹配并且高速缓存行的有效位是1,则高速缓存命中;
(3) 字选择:一旦高速缓存命中,通过块偏移位提供的第一个字节的偏移量,取出这个字节的内容,返回给CPU;
(4)不命中:如果高速缓存不命中,那么需要从存储层次结构中的下一层取出被请求的块,然后将新的块存储在组索引位所指示的组中的一个高速缓存行中。这里给出一种简单的放置策略:如果映射到的组内有空闲块,则直接放置;如果没有空闲块,则采用最近最少使用策略LFU进行替换。
其他两级L2 Cache、L3cache的原理相同。
7.6 hello进程fork时的内存映射
当前进程调用fork函数时,内核会为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,内核创建了当前进程的mm_struct、区域结构和页表的原样副本。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任何一个后来进行写操作时,复制机制就会创建新页面,因此,能够为每个进程保持独享私有地址空间的抽象概念。
图7.4 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
创建了一个子进程后,子程序调用execve函数在上下文加载hello程序,需要以下步骤:
- 删除当前虚拟地址中已存在的用户区域;
- 映射私有区域,为新程序的代码、数据、bss和堆栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data;bss是请求二进制零的区域,映射到匿名文件,其大小包含在hello中;堆栈也是请求二进制零的区域,初始长度为零;
- 映射共享区域,hello程序与共享对象libc.so链接,这些对象动态链接到hello程序,然后映射到用户虚拟地址空间中的共享区域内;
- 设置程序计数器(PC),设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障:
当程序试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。通常情况下,用于处理此中断的缺页处理程序是操作系统的一部分。如果操作系统判断此次访问是有效的,那么操作系统会尝试将相关的分页从硬盘上的虚拟内存文件中调入内存。而如果访问是不被允许的,那么操作系统通常会结束相关的进程
如果程序执行过程中遇到了缺页故障,那么内核就会调用缺页处理程序,缺页中断处理会进行如下步骤:
- 检查虚拟地址是否合法:如果不合法,则触发一个段错误,程序终止;
- 然后检查进程是否有读、写或执行该区域页面的权限,如果不具有以上权限,则触发保护异常,程序终止;
- 在两步检查都无误后,内核选择一个牺牲页,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。
- 最后,将控制转移回hello进程,再次执行触发缺页故障的指令。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格。两种风格都是要求显示的释放分配块。
1.显式分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器;
2.隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块,也叫垃圾收集器。
隐式空闲链表:
带边界标记的隐式空闲链表的每个块由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成。在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的,分配器可以通过遍历堆中所有的块间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配、最佳适配。分配完后可以分割空闲块减少内部碎片,其中分配器在面对释放一个已分配块时也可以合并空闲块。
显示空闲链表:
显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里。例如堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱指针和一个后继指针。在显式空闲链表中,可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处;也可以采用按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,这时,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配有着更高的内存利用率,接近最佳适配的利用率。
图7.6隐式空闲链表的原理
图7.7 显示空闲链表的原理
7.10本章小结
本章介绍了hello存储器的地址空间、段式管理、页式管理、VA到PA的转换机制、三级Cache支持下的物理内存访问;分析了hello进程fork时的内存映射,hello进程execve时的内存映射,缺页故障与缺页中断处理;最后介绍了动态内存管理的基本方法与策略。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux所有的I/O设备都可以被模型化为文件,相应的输入输出就是对文件的读和写,这种方式允许Linux内核引出简单低级的应用接口即unix io接口,使得所有的输入输出能够以一种统一且一致的方式来执行。
设备的模型化:文件,所有的I/O设备都被模型化为文件,甚至内核也被映射为文件。
设备管理:unix io接口,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
Unix I/O使得所有的输入输出都以一种统一且一致的方式来执行,Unix I/O接口的几种操作:
(1)打开文件:一个应用程序通过要求内核打开相应文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,描述符将在后续对此文件的所有操作中标识这个文件。
(2)shell在进程的开始为其打开三个文件:标准输入、标准输出和标准错误,其中标准输入描述符为0,标准输出描述符为1,标准错误描述符为2。
(3)改变当前文件的位置:对于每个打开的文件,内核保留着一个文件位置k,是从文件开头起始的字节偏移量,初始为0。程序能够通过执行seek操作显式地设置文件的当前位置为k。
(4)读写文件:一个读操作就是从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k。
(5)关闭文件:内核释放打开文件时创建的数据结构以及占用的内存资源,并将描述符恢复到可用的描述符池中。无论一个进程因为何种原因终 止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix IO函数:
(1)打开文件:int open(char *filename, int flags, mode_t mode);
open函数将filename转换为一个文件描述符,若成功返回描述符数字,返回的描述符总是在进程中没有打开的最小描述符;否则返回-1。flags参数指明进程打算如何访问这个文件,也可以为写提供一些额外的指示;mode参数指定新文件的访问权限位。
(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,否则返回值表示的是实际传送的字节数量。
(4)写文件:ssize_t write(int fd, const void *buf, size_t n);
调用write函数从内存位置buf最多复制n个字节到描述符为fd的当前文件位置。返回值-1表示出错,否则返回值表示的是实际传送的字节数量。
8.3 printf的实现分析
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用int 0x80或syscall等:
研究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;
}
va_list是字符指针,而(char*)(&fmt + 4)表示fmt后的第一个参数的地址。vsprintf函数返回值是要打印出来的字符串的长度,其作用是格式化,产生格式化的输出并保存在buf中。最后的write函数即为写操作,把buf中的i个元素的值写到终端。
对write系统函数追踪后的结果如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在write函数中,将栈中参数放入寄存器,寄存器%ecx是字符个数,寄存器%ebx存放第一个字符的地址。int INT_VECTOR_SYS_CALL表示要通过陷阱-系统来调用sys_call函数,在write函数中可以理解其功能为显示格式化了的字符串。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息):syscall将字符串中的字节从寄存器中复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序利用ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),于是我们的打印字符串就显示在了屏幕上。
8.4 getchar的实现分析
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return(–n>=0)” />}
在getchar函数中,首先声明了几个静态变量:buf表示缓冲区,BUFSIZ为缓冲区的最大长度,而bb指针指向缓冲区的首地址。getchar调用read函数,将缓冲区读入到buf中,并将长度送给n,再重新令bb指针指向buf。最后返回buf中的第一个字符(如果长度n < 0,则报EOF错误)。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,read函数也通过sys_call调用内核中的系统函数,将读取存储在键盘缓冲区中的ASCII码,直到读到回车符,然后返回整个字符串,getchar函数只从中读取第一个字符,其他的字符被缓存在输入缓冲区。通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章先介绍了Linux的IO设备管理方法:设备的模型化——文件、设备管理——unix io接口;然后简述了unix io接口及其函数;接着分析了printf的实现;最后分析了getchar的实现。
结论
小小hello,有着大大奥秘。hello可以说是每个程序员的初恋,是每个程序员关于代码晒的第一条朋友圈,它神秘高贵,我欣喜若狂!hello从被懵懵懂懂一字一句敲进电脑成为hello.c开始,到最后完美谢幕后由Bash收尸,它的一生可谓是历经艰险,台上一分钟,台下十年功。接下来,我们就一起走一走hello的一生。
程序员首先在文本编辑器中用高级语言一字一句敲出hello.c(Program),然后对其进行预处理(CPP),将头文件展开、处理宏定义、处理条件编译指令、删除程序中的注释和多余的空白字符、处理特殊符号等,产生文本hello.i;接着通过编译(ccl),hello.i被编译为汇编语言程序hello.s,是用汇编语言书写的,我们也可以大致看懂里面的内容,但是仍然不能被机器直接处理;随后,通过汇编(as),hello.s变为机器可直接读懂并执行的可重定位目标文件hello.o;最后,通过链接(ld)将各种代码和数据片段收集并组合成一个单一文件,这个文件可被加载(复制)到内存并执行,链接使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个 巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。
这时,在Shell中输入./hello命令启动程序,Shell调用fork函数创建子进程(Process),实现P2P,从程序到进程。Shell接着调用execve函数进行加载,运行hello需要CPU为hello分配时间片、内存,以便执行逻辑控制流;由OS进行存储管理,CPU访问相关数据需要MMU实现虚拟地址到物理地址的转换,其中TLB、四级页表、三级cache等可以加速转换,从而加速访问过程;系统的进程管理使hello可以实现切换上下文,shell的信号处理程序使hello在运行过程中可以处理各种信号,例如ctrl+z可以将其挂起,ctrl+c可以将其终止。当hello运行结束后,由父进程回收hello,删除相关数据。这样就实现了赤条条来去无牵挂,020即从Zero-0到Zero-0。
接下来,谈一谈对于计算机系统这门课我的感受。计算机系统这门课让我对计算机有了更系统、更深刻的认识和理解,学会了很多原来从没接触过的知识:汇编语言、Linux、链接、进程、信号处理等等很多很多。CSAPP这本教材的内容很多很详细,通过老师课上的悉心讲解,课后自己付出时间和努力去复习、去阅读理解教材,很多晦涩难懂的概念过程慢慢也都理解了。同时,我认为本次大作业设置得很完美,在恰当的时间帮助我们把整本书的内容串了个线,做了一个整体的复习。最后,还要说一说计统的实验,要谈起实验的最大收获,莫过于更加提高了自主学习的能力:从自己下载安装VirtualBox,Ubuntu,Visual Studio,CPU-Z,winhex等一系列软件工具,到自主学习软件的使用、学习Linux系统、学习Shell命令等等,再到逐步深入进行程序的编写,编译运行,分析、调试与跟踪,虽然这是一些看起来很容易的任务,但是也需要投入时间与精力。同时,更让我懂得了:细节决定成败,在实验中许多细节问题卡了我很久,通过查阅大量资料请教老师同学,才解决了这些问题。实验过程中有崩溃瞬间也有高光时刻,总之,顺利完成实验让我感到十分开心与满足!
最后,感谢老师与助教的悉心付出与指导!祝身体健康,工作顺利!
附件
列出所有的中间产物的文件名,并予以说明起作用。
中间产物文件 | 文件作用 |
hello.c | 源程序文件 |
hello.i | 预处理后生成的文件 |
hello.s | 编译后生成的汇编语言程序 |
hello.o | 汇编后生成的可重定位目标程序 |
hello_o_asm.txt | hello.o的反汇编文件 |
hello_o_elf.txt | hello.o的ELF文件格式 |
hello | 链接后生成的可执行目标程序 |
hello_asm.txt | hello的反汇编文件 |
hello_elf.txt | hello的ELF文件格式 |
参考文献
[1][美]布赖恩特(Bryant,R.E.).深入了解计算机系统.机械工业出版社,2016
[2] [美]派特、派特尔. 计算机系统概论. 机械工业出版社,2017
[3] 鸟哥. 鸟哥的Linux私房菜. 人民邮电出版社,2010
[4] Linux命令大全(手册) – 真正好用的Linux命令在线查询网站
[5] 什么是shell? bash和shell有什么关系? – 代码ok – 博客园