计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业
学 号
班 级
学 生
指 导 教 师 刘宏伟
计算机科学与技术学院
2024年5月
本论文详细探讨了hello程序在Linux环境下的各个执行阶段及其相关技术实现。通过对hello程序从预处理、编译、汇编、链接、进程管理、存储管理到IO管理的系统分析,全面展示了程序在Linux环境下的执行机制,为理解操作系统底层原理提供了有力支持。
关键词:计算机系统;预处理;编译;汇编;链接;进程;存储;I/O
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 6 -
5.3 可执行目标文件hello的格式....................................................................... - 23 -
6.2 简述壳Shell-bash的作用与处理流程......................................................... - 34 -
6.3 Hello的fork进程创建过程......................................................................... - 34 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 42 -
7.3 Hello的线性地址到物理地址的变换-页式管理.......................................... - 43 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................. - 43 -
7.5 三级Cache支持下的物理内存访问.............................................................. - 45 -
7.6 hello进程fork时的内存映射..................................................................... - 46 -
7.7 hello进程execve时的内存映射................................................................. - 46 -
第1章 概述
1.1 Hello简介
P2P,指Program to Progress,即Hello文件整个的编译与运行过程,包含了程序的指令与数据等信息。程序运行时,操作系统将hello.c文件加载到内存中,并且将运行中的程序实例抽象为一个进程(process)。首先由程序员使用高级语言(例如C语言)编写创建源代码hello.c文件,由预处理器处理源代码中的指令生成hello.i文件,编译器将预处理后的代码转换成汇编语言文件hello.s,再通过汇编器将汇编代码转换成机器语言,生成目标文件hello.o,经由ld链接器将目标文件与库文件链接在一起,生成可执行文件hello。随后当打开shell输入./hello指令时,操作系统为其fork创建子进程,通过execve系统调用,加载可执行文件到内存中,实现了P2P的过程。
图1-1 P2P过程框图
020,指从0到0,从在shell中输入./hello指令,shell调用fork产生子进程,再调用execve加载进程,运行hello,为其分配内存空间与CPU时间片,加载需要的物理内存,运行时程序在CPU的分配下译码执行,在CPU流水线上执行。操作系统运行结束后,通过exit系统调用回收进程,释放资源,清理程序运行时占用的内存以及程序在内核中的数据结构,关闭程序打开的文件描述符,至此进程结束,hello相关数据被删除,回归到0。
1.2 环境与工具
处理器:AMD Ryzen 5 5600 6-Core Processor 4.60 GHz;
机带RAM:32.0 GB;
软件:Windows 10 64位 专业版;Ubuntu 18.04.6 64位;
开发工具:VSCode、edb、gcc、gdb、readelf、HexEdit、ld。
1.3 中间结果
表1-1 得到的中间结果
文件名 | 功能 |
hello.i | 预处理器修改后的源程序,用来分析预处理器的行为 |
hello.o | 可重定位目标程序,用来分析汇编器的行为 |
hello.s | 可重定位目标程序,用来分析汇编器的行为 |
hello.elf | hello.o的elf格式 |
hello_asm.s | hello.o的反汇编代码 |
hello1.elf | hello的elf格式 |
hello | Linux下的可执行目标程序,用来分析链接器行为 |
hello1.s | hello反汇编之后的可重定位文件,用来分析重定位过程 |
1.4 本章小结
本章对P2P和020的概念进行了简要描述,介绍了编写报告过程中使用的软硬件环境与开发工具,并列出了所得到的中间结果。
第2章 预处理
2.1 预处理的概念与作用
预处理阶段,预处理器(cpp)根据以字符#开头的预编译命令(例如文件包含(#include)、宏定义(#define)、条件编译(#ifdef)等),修改原始的C程序,同时删除源代码中的注释。此处例如hello.c中的前三行:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
这三行命令告诉预处理器读取系统头文件stdio.h、unistd.h、stdlib.h的内容,并把它直接插入程序文本中,结果就得到了另一个C程序,通常是以.i作为文件扩展名,例如我们得到的hello.i文件。
预处理使编写的程序可以更方便地复用代码,简化复杂的代码,以及根据不同的条件编译不同的代码,使代码更易读,更方便修改、调试、移植,便于程序的模块化设计。
2.2在Ubuntu下预处理的命令
gcc hello.c -m64 -no-pie -fno-stack-protector -fno-PIC -E -o hello.i
图2-1 在Ubuntu下预处理hello.c的命令
2.3 Hello的预处理结果解析
由图2-2与图2-3,对比hello.c与hello.i可见,源代码hello.c仅有24行而预处理后的hello.i有3106行,由图2-3可见,原本的源代码部分在3092行之后,在这之前是hello.c引用的头文件stdio.h、unistd.h、stdlib.h的内容的展开,但我们发现,还插入了其他的头文件,例如libc-header-start.h等。这是因为,这三个头文件中同样使用#includ命令引入了其他头文件,这些头文件同样被插入hello.i文件中。具体引入的库可见下图2-2。
同时,对比源代码hello.c与预处理结果hello.i中的源代码部分,由图2-3所示,源代码hello.c头部出现的注释,在预处理后的hello.i中已不可见,这就印证了2.1中所述的预处理器的作用,由于源代码中不含宏定义(#define)和条件编译(#ifdef),因此此处无法展示。
图2-2 hello.c的预处理结果hello.i解析
图2-3 源代码hello.c与预处理结果hello.i中的源代码部分对比
2.4 本章小结
本章主要介绍了预处理(包括文件包含、宏定义、条件编译、删除注释等)的概念及作用,同时展示了Ubuntu系统下hello.c文件实际预处理之后得到的hello.i程序,并对预处理结果进行了解析。
第3章 编译
3.1 编译的概念与作用
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该程序包含函数main的定义,其中每条语句均以一种文本格式描述了一条低级机器语言指令。
汇编语言为不同高级语言的不同编译器提供了通用的输出语言,汇编语言在翻译目标程序时,同时进行词法、语法分析检查,分析过程中若出现语法错误将给出提示信息。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
图3-1 在Ubuntu下编译hello.i的命令
3.3 Hello的编译结果解析
3.3.1 数据
(1)常量
(a)数字常量:通过观察,我们可以发现,源代码中使用的数字常量均储存在.text段,包括在比较的时候使用的数字变量5,在循环的时候使用的循环比较变量等数字常量,均储存在.text 节,具体存储情况参见图3-2;
图3-2 main函数中数字常量存储情况
(b)字符串常量:可以发现,在printf 等函数中使用的字符串常量是储存在.rotate段,为只读数据,具体存储情况见图3-3;
图3-3 main函数中字符串常量存储情况
(2)变量
(a)全局变量:代码中并无全局变量。
(b)局部变量:局部变量被存储在栈中或者直接存储在寄存器中。以下对源代码中的局部变量逐一进行分析。
一是循环变量i,由汇编代码段知,i存储在栈中地址为-4(%rbp)的位置。此处为for循环开始处.L2,初始值令i=0。在.L3循环中每次循环i均与9进行比较,每次循环i+1。
图3-4 关于hello.i中局部变量i存储情况的汇编语句
二是局部变量argc,用于存储程序运行时输入的变量个数,由汇编代码段可知,argc存储在栈中地址为-20(%rbp)的位置。
图3-5 关于hello.i中局部变量argc存储情况的汇编语句
三是局部变量argv[],用于存储程序运行时输入的变量,由汇编代码段可知,argv[]存储在栈中地址为-32(%rbp)的位置。局部变量argc和argv[]均在此后的函数中被push入栈。
图3-6 关于hello.i中局部变量argv[]存储情况的汇编语句
3.3.2 赋值
汇编代码中的变量赋值操作只有对于循环变量i在循环初始时刻令i=0的操作。
for(i=0;i<10;i++){
图3-7 关于hello.i中赋值操作的源代码语句与汇编语句
3.3.3 算术操作
汇编代码中的算术操作如下图3-8。局部变量 i为循环变量,存放在栈中地址为-4(%rbp)的位置。在循环体中每一轮的循环中都要执行i++修改这个值。
for(i=0;i<10;i++){
图3-8 关于hello.i中算术操作的源代码语句与汇编语句
3.3.4 关系操作
源代码中共出现了2处关系操作,第一处是对argc的判断,当等于5时,进行条件跳转,源代码见下图3-9。je用于判断cmpl产生的条件码,如果两个操作数不等则跳转到指定地址.L2。
if(argc!=5){
printf("用法: Hello 学号 姓名 手机号 秒数!\n");
exit(1);
}
图3-9 关于hello.i中与argc有关的关系操作的源代码语句与汇编语句
第二处是for循环中对于循环变量i的判断,源代码如下图3-10,每次循环运行完成后均将i与10进行比较,若i<10则再次进入循环,否则循环结束,执行循环外的语句getchar()。
for(i=0;i<10;i++){
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
sleep(atoi(argv[4]));
}
图3-10 关于hello.i中与i有关的关系操作的源代码语句与汇编语句
3.3.5 数组/指针/结构操作
源代码中涉及argv[]数组的操作。由源代码知,在printf和atoi中传入了argv[1]、argv[2]、argv[3]、argv[4]作为参数,阅读汇编代码,如下图3-11,可见argv[]的起始地址被存放在栈中-32(%rbp)的位置,被3次调用传递参数给printf,printf函数传入argv[1]、argv[2]、argv[3],分别被存储在%rcx、%rdx、%rsi中,而argv[4]在%edi中作为atoi函数的输入。
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
sleep(atoi(argv[4]));
图3-11 关于hello.i中数组/指针/结构操作的源代码语句与汇编语句
3.3.6 控制转移操作
源代码中,控制转移主要包含if条件语句和for循环语句两种。一是if条件语句,判断传入参数argc是否为5,如果为5则执行跳转。阅读图3-12中的相应汇编代码,可见汇编代码中使用je判断cmpl产生的条件码,如果两个操作数不等则跳转到指定地址.L2。
if(argc!=5){
图3-12 关于hello.i中控制转移操作之if条件语句的源代码语句与汇编语句
二是for循环语句,采用了jump-to-middle策略,当执行完令i=0的初始化后,判断i是否小于10,如果小于10则继续进入循环。阅读图3-13中的相应汇编代码,可见汇编代码中分支操作使用cmpl产生条件码,由jle进行循环条件判定,若操作数的值小于10则跳转到循环体地址.L4。
for(i=0;i<10;i++){
图3-13 关于hello.i中控制转移操作之for循环语句的源代码语句与汇编语句
3.3.7 函数操作
源代码中共调用了main()、printf()、exit()、atoi()、sleep()、getchar()六个函数。 在x86-64系统中,可至多通过寄存器传递6个整型(包含整数和指针)参数。第1~6个参数依次储存在%rdi、%rsi、%rdx、%rcx、%r8、%r9这六个寄存器中,其余参数保存在栈空间中的其他位置。
表3-1 x86-64系统的参数传递顺序
1 | 2 | 3 | 4 | 5 | 6 | 7 |
%rdi | %rsi | %rdx | %rcx | %r8 | %r9 | 栈空间 |
(1)main函数:
参数传递:传入参数argc、*argv[],分别存储在寄存器%rdi和%rsi中;
函数调用:系统启动函数调用;
函数返回:在源代码中最后的返回语句为0,对应汇编语言中最后将%eax设置为0并返回。
int main(int argc,char *argv[]){
return 0;
}
图3-13 关于hello.i中函数操作之main函数的源代码语句与汇编语句
(2)printf函数:
参数传递:第一次if条件语句中调用时,call puts只传入了字符串参数首地址;第二次for 循环语句中调用时,call printf传入数组argv[1]、argv[2]、argv[3]的地址,分别存储在%rcx、%rdx、%rsi中;
函数调用:第一次if条件语句中经判断满足条件后调用,第二次for 循环语句中循环条件满足时调用。
printf("用法: Hello 学号 姓名 手机号 秒数!\n");
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
图3-14 关于hello.i中函数操作之printf函数的源代码语句与汇编语句
(3)exit函数:
参数传递:传入的参数为1,传入到%edi寄存器中;
函数调用:当if条件语句经判断满足条件时,调用,call exit执行退出命令。
exit(1);
图3-15 关于hello.i中函数操作之exit函数的源代码语句与汇编语句
(4)atoi函数:
参数传递:传入的参数为argv[3],将其传入%rdi寄存器中;
函数调用:在for循环语句中call atoi调用;
函数返回:将字符串输入转化为int型,返回值存放在%eax中。
sleep(atoi(argv[4]));
图3-16 关于hello.i中函数操作之atoi函数的源代码语句与汇编语句
(5)sleep函数:
参数传递:将atoi函数的返回值参数存放在寄存器%edi中传入;
函数调用:for循环语句中被call sleep调用,将返回结果存放在寄存器%eax中。
sleep(atoi(argv[4]));
图3-17 关于hello.i中函数操作之sleep函数的源代码语句与汇编语句
(6)getchar函数:
函数调用:getchar函数无参数传递,直接在main中call getchar调用。
getchar();
图3-18 关于hello.i中函数操作之getchar函数的源代码语句与汇编语句
3.4 本章小结
本章介绍了编译的概念与作用,完成了hello.i到hello.s的编译,针对汇编代码,根据hello程序main函数中主要使用的数据、赋值、算术操作、关系操作、数组/指针/结构操作、控制转移操作、函数操作等七种处理指令,具体分析了hello.s程序的编译结果。
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将hello.s翻译为机器语言指令,把这些指令打包成可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o文件中,hello.o文件是一个二进制文件,其包含的字节是函数main的指令编码。
汇编过程,将汇编语言翻译成机器语言,使之在链接(ld)后,能够被机器识别并执行。
4.2 在Ubuntu下汇编的命令
gcc -c -o hello.o hello.s
图4-1 在Ubuntu下汇编hello.o的命令
4.3 可重定位目标elf格式
使用readelf列出hello.o的ELF格式各节的基本信息。
readelf -a hello.o > hello.elf
图4-2 使用readelf列出hello.o的ELF格式各节的基本信息
图4-3 典型的ELF可重定位目标文件
4.3.1 ELF头
ELF头(ELF head)以一个16字节的序列Magic开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头大小、目标文件类型(如可重定位、可执行或共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。
图4-3 ELF头的内容
4.3.2 节头
ELF 文件中,每个节都有一个对应的节头表,列举和定位各个节的信息,记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐等信息。
图4-4 节头的内容
4.3.3 重定位节
.rela.text,一个.text节中重定位信息的列表,保存的是.text节中需要被修正的信息,包括偏移量、信息、类型、符号值、符号名称、+和加数等,当链接器把这个目标文件与其他文件组合时需要修改这些位置。任何调用外部函数或者引用全局变量的指令都需要被修正,调用外部函数的指令和引用全局变量的指令需要重定位,调用局部函数的指令不需要重定位。hello程序中,需要被重定位的有.rodata中的.L0和.L1、puts、exit、printf、atoi、sleep、getchar、.text。
图4-5 重定位节的内容
4.3.4 符号表
.symtab,一个符号表,它存放在程序中定义和引用的函数和全局变量的信息、节名称和位置,一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
图4-6 符号表的内容
4.4 hello.o的结果解析
通过以下命令对hello.o进行反汇编。
objdump -d -r hello.o > hello0_asm.s
图4-7 在Ubuntu下反汇编hello.o的命令
反汇编代码如下图4-8。总体上看大致与hello.s类似,但在以下三点有所不同。
图4-8反汇编hello.o得到的反汇编代码hello0_asm.s
(1)操作数表示:
反汇编代码hello0_asm.s中的立即数全部是以16进制表示的,而可重定位文件hello.s中仍为10进制,这是由于16进制与2进制之间的转换比十进制更方便,故机器语言反汇编而来的反汇编代码转换为了16进制。
hello.s
hello0_asm.s
图4-9 hello.s与hello_asm0.s中操作数表示的差异
(2)分支转移
可重定位文件hello.s中,跳转指令的目标地址直接记为段名称,如.L2、.L3等,但反汇编代码hello0_asm.s中,跳转指令的操作数使用的是确定的地址,机器代码中体现为目标指令地址与当前指令下一条指令的地址之差。因为段名称只是在汇编语言中便于编写的助记符,故汇编成机器语言之后不存在。
hello.s
hello0_asm.s
图4-9 hello.s与hello0_asm.s中分支转移的差异
(3)函数调用
在可重定位文件hello.s中call后面是需要调用函数的名称,而在反汇编文件hello0_asm.s中可以看到,call后面直接加入main函数相对地址偏移量,同时可以发现在hello.o的反汇编代码中,调用函数的操作数都为0,即函数的相对地址为0,因为在链接生成可执行文件hello后,才会生成其确定的地址,故此处相对地址均使用0代替。
hello.s
hello0_asm.s
图4-10 hello.s与hello0_asm.s中函数调用的差异
4.5 本章小结
本章介绍了汇编的概念与作用,完成了hello.s到hello.o的汇编,生成了hello.o的可重定位目标文件hello.elf,分析了可重定位目标文件的ELF头、节头部表、符号表和可重定位节,使用反汇编objdump查看hello.o经反汇编生成的代码,比较了hello.s和hello.o反汇编代码,分析了从汇编语言到机器语言的汇编过程。
第5章 链接
5.1 链接的概念与作用
链接是将各种不同文件的代码和数据片段收集并组合成一个单一文件的编译过程,这个文件可被加载到内存并执行,由链接器(ld)负责处理这种合并。
链接器把预编译好了的若干目标文件,例如hello中的printf等函数经预编译得到的printf.o等文件合并成为一个可执行目标文件,使得分离编译成为可能,从而不必将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为可独立修改和编译的模块。当改变这些模块中的一个时,只需简单重新编译它并重新链接即可,不必重新编译其他文件。
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.3 可执行目标文件hello的格式
使用readelf列出hello的ELF格式各节的基本信息。
readelf -a hello > hello1.elf
图5-2 使用readelf列出hello的ELF格式各节的基本信息
5.3.1 ELF头
与hello.elf中的ELF头相比,hello1.elf中的ELF头包含的信息种类基本相同,Magic,类别等基本信息未发生改变,而类型发生改变,由REL (可重定位文件)变为EXEC (可执行文件),程序头大小由0字节增加到64字节,节头数量由13增加到25,并且获得了入口点地址0x400550。
图5-3 ELF头的内容
5.3.2 节头
链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
图5-4 节头的内容
5.3.3 程序头
程序头部分是一个结构数组,描述了可执行文件的连续的片映射到连续的内存段的映射关系,描述了可执行文件在内存中的布局和加载信息。每个程序头对应一个段,包含段的类型、在文件中的偏移、在内存中的虚拟地址、物理地址、大小和访问权限。
PHDR:程序头表
INTERP:程序执行前需要调用的解释器
LOAD:程序目标代码和常量信息
DYNAMIC:动态链接器所使用的信息
NOTE::辅助信息
GNU_EH_FRAME:保存异常信息
GNU_STACK:使用系统栈所需要的权限信息
GNU_RELRO:保存在重定位之后只读信息的位置
可见,最开始是GNU_STACK,而LOAD从0x000e50开始,接着是PHDR,INTERP和NOTE。最后是DYNAMIC和GNU_RELRO部分。
图5-5 程序头的内容
5.3.4 Section to Segment mapping
图5-6 Section to Segment mapping的内容
5.3.5 Dynamic section
Dynamic section用于存储程序在运行时所需的动态链接信息,包含一系列的动态入口,这些入口提供了程序在运行时获取共享库和其他动态链接信息的方式。
图5-7 Dynamic section的内容
5.3.6 重定位节
此处可见,.rela.text节消失,这表明链接过程完成了重定位操作,但确切地址仍未定,需动态链接后才能确定。
图5-8 重定位节的内容
5.3.7 符号表
符号值(Symbol Value):符号的值或地址,表示符号在内存中的位置。
符号大小(Size):符号所占据的空间大小。
符号类型(Type):符号的类型,如函数、对象、未定义符号等。
符号绑定(Bind):符号的绑定类型,指示符号的可见性和链接性,如局部符号、全局符号、弱符号等。
符号可见性(Vis):表示符号可见性,是否可见。
符号的节的索引(Ndx):指示了符号所属的段或节的索引。
图5-9 符号表的内容
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息。由data dump部分可以看出,程序是从0x400000开始加载的,结束在约0x400ff0位置。
.init段起始地址为0x4004c0,.text段的起始地址为0x400550,.rodata段的起始地址为0x4006a0,.data起始地址为0x601048,在edb中转到相应地址,可以看到虚拟地址空间中的内容。
图5-10 使用edb查看hello的虚拟地址空间各段信息
5.5 链接的重定位过程分析
使用以下指令对可执行文件hello进行反汇编。
objdump -d -r hello > hello_asm.s
图5-11 在Ubuntu下反汇编hello.o的命令
图5-12反汇编hello得到的反汇编代码hello_asm.s
与反汇编hello.o得到的hello0_asm.s比较,反汇编hello得到的hello_asm.s有三点不同。
(1)链接后函数数量增加;
链接后,反汇编hello得到的反汇编文件中,增加了.plt、puts@plt、printf@plt、getchar@plt、atoi@plt、exit@plt、sleep@plt等函数的代码段,这是由于链接器ld将共享库中源代码hello.c使用的函数加入到可执行文件中。
图5-12 链接后反汇编文件hello_asm.s中函数数量增加
(2)call函数调用指令的参数变化
在链接完成之后,hello 中的所有对于地址的访问或是引用都调用的是绝对虚拟地址。这是由于 hello.o 中对于函数还未进行定位,只在.rel.text 中添加了重定位条目,而 hello 进行定位之后自然不需要重定位条目。call后的字节代码直接被链接器ld修改为目标地址与下一条指令的地址之差,指向相应的函数块代码段。
hello0_asm.s
hello_asm.s
图5-13链接后反汇编文件hello_asm.s中call函数调用指令的参数变化
(3)跳转指令参数发生变化
链接过程进行了符号解析与重定位,符号解析包括目标文件定义和引用符号,将每个符号引用和一个符号定义关联起来,而重定位是指编译器和汇编器生成从 0 开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器解析了重定位条目,计算了相对距离,将对应位置的字节代码修改为.plt中相应函数与下条指令的相对地址。
hello0_asm.s
hello_asm.s
图5-14链接后反汇编文件hello_asm.s中跳转指令参数发生变化
5.6 hello的执行流程
表5-1 hello的执行流程
地址 | 子程序名 |
00000000004004c0 | _init |
00000000004004e0 | .plt |
00000000004004f0 | puts@plt |
0000000000400500 | printf@plt |
0000000000400510 | getchar@plt |
0000000000400520 | atoi@plt |
0000000000400530 | exit@plt |
0000000000400540 | sleep@plt |
0000000000400550 | _start |
0000000000400580 | _dl_relocate_static_pie |
0000000000400582 | main |
0000000000400620 | __libc_csu_init |
0000000000400690 | __libc_csu_fini |
0000000000400694 | _fini |
5.7 Hello的动态链接分析
共享库是一个目标模块,在运行和加载时可以加载到指定的内存地址,并和一个在内存中的程序链接起来,这个过程称为动态链接,当程序调用一个由共享库定义的函数时,由于编译器无法预测此时函数的地址,GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。通过.got和过程链接表.plt的协作来解析函数的地址。从elf中可以读取.got和.plt的起始地址在加载时,动态链接器会重定位.got中的每个条目,使之包含正确的绝对地址,而.plt中的每个函数负责调用不同函数。那么,通过gdb,便可发现 dl_init后.got.plt 节发生的变化。
图5-15 从hello1.elf读取.got和.plt的起始地址
图5-16 dl_init前后.got.plt 节发生的变化
5.8 本章小结
本章中介绍了链接的概念与作用。通过在Ubuntu下链接的命令得到了hello可执行文件。通过readelf命令得到了链接后的hello可执行文件的ELF格式文本hello1.elf,据此分析了hello1.elf与hello.elf的异同,更好地掌握了链接与重定位的过程,利用edb查看了hello的虚拟地址空间,并根据hello的反汇编文件hello_asm.s与hello.o的反汇编文件hello0_asm.s的比较,借助gdb调试器厘清hello的执行流程,分析动态链接前后项目内容变化,加深了对重定位与动态链接的理解。
第6章 hello进程管理
6.1 进程的概念与作用
(1)进程的概念
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据。它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
(2)进程的作用
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
6.2 简述壳Shell-bash的作用与处理流程
(1)shell的作用
shell 是一个交互型应用程序,它代表用户运行其他程序。shell 执行一系列的读/求值(read/evaluate)步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并根据解析结果运行程序。
(2)shell的处理流程:
(a)从shell终端读入输入的命令;
(b)将输入字符串切分,获得并识别所有的参数并构造传递;
(c)如果是内置命令则立即执行;
(d)否则调用fork程序为其分配子进程并运行;
(e)如果输入参数非法,则返回错误信息;
(f)命令执行完成后,shell再次显示提示符,等待用户输入下一条命令。该过程循环进行,直到用户主动退出或关闭shell会话。
6.3 Hello的fork进程创建过程
父进程通过调用 fork 函数创建一个新的运行的子进程。调用 fork 函数后,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程虚拟地址空间相同的但是独立的一份副本,包括代码、数据段、堆、共享库以及用户栈,子进程还获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。但父进程和子进程的PID不同。fork被调用一次,却返回两次,子进程返回0,父进程返回子进程的 PID。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
6.4 Hello的execve过程
当创建了一个子进程之后,子进程调用execve函数在当前子进程的上下文中加载并运行一个新的程序——hello程序。
execve:execve调用驻留在内存中被称为启动加载器的操作系统代码来执行Hello程序,加载器删除子进程现有的用户虚拟内存段部分中已存在的区域结构,为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.text和.data区。bss区域是请求二进制零的映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。最后加载器设置PC 指向_start 地址,_start 最终调用hello中的main 函数。然后加载器将PC指向hello程序的起始位置(_start),即从下条指令开始执行hello程序。
图6-1 linux x86-64运行时内存映像
6.5 Hello的进程执行
用户模式和内核模式:处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围,从而使操作系统内核提供一个无懈可击的进程抽象。处理器通常是用某个控制寄存器的一个模式位来提供能这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。
上下文:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:(1)保存之前进程的上下文(2)恢复某个先前被抢先的进程被保存的上下文(3)将控制传递给这个新恢复的进程。
6.6 hello的异常与信号处理
当接受到不同的异常信号时,异常处理程序将对异常信号做出相应,执行相应的代码,每种信号都有不同的处理机制,对不同的异常信号,hello也有不同的处理结果。
hello程序可能会出现的异常有:
(1)中断:在程序执行过程中可能出现外部I/O设备引起的异常,或时钟中断等。中断处理程序将控制返回给应用程序控制流中的下一条指令。
图6-3 中断处理。中断处理程序将控制返回给应用程序控制流中的下一条指令。
(2) 陷阱:陷阱是执行一条指令的结果,hello执行sleep函数时会出现这个异常。陷阱处理程序将控制返回给应用程序控制流中的下一条指令。
图6-4 陷阱处理。陷阱处理程序将控制返回给应用程序控制流中的下一条指令。
(3) 故障:可能会发生缺页故障等。根据故障是否能够被修复,故障处理程序要么重新执行引起故障的指令,要么终止。
图6-5 故障处理。根据故障是否能够被修复,故障处理程序要么重新执行引起故障的指令,要么终止。
(4) 终止:终止是不可恢复的错误,在hello执行过程可能会出现DRAM或SRAM位损坏的奇偶错误等。终止处理程序将控制传递给一个内核abort例程,该例程会终止这个应用程序。
图6-6 终止处理。终止处理程序将控制传递给一个内核abort例程,该例程会终止这个应用程序。
不停乱按,乱码会被认为是命令,不影响进程。
图6-7 不停乱按
按下Ctrl+Z,产生中断异常,它的父进程收到SIGSTP信号,程序本身这时被挂起,Ctrl+Z 后运行 ps,打印出了各进程的 pid,可以看到之前挂起的进程 hello。Ctrl+Z 后运行 jobs,打印出了被挂起进程组的 jid,可以看到之前被挂起的 hello,已被挂起的标识“已停止”,此时运行fg可使停止的hello进程继续在前台运行。
图6-8 按下Ctrl+Z及使用jobs查看进程组jid
图6-9 使用命令ps查看进程组pid
图6-10 使用命令fg继续进程运行
使用命令pstree可以将hello的所有进程以树状图形式展示。
图6-11 使用命令pstree以树状图形式展示 hello的所有进程
按下Ctrl+Z 后运行 Kill:重新执行进程,可以发现 hello 的进程号为2148,那么便可通过 kill -9 2148发送信号 SIGKILL 给进程2148,它会导致该进程被杀死。然后再运行 ps,可发现已被杀死的进程 hello。
图6-12 使用命令kill杀死进程
按下 Ctrl+C:进程收到 SIGINT 信号,结束 hello。在 ps 中查询不到其 PID,在job 中也没有显示,可以看出 hello 已经被彻底结束。
图6-13 按下Ctrl+C及使用ps查看进程是否被结束
6.7本章小结
本章介绍了进程的概念与作用以及Shell-bash的基本概念,从可执行文件hello的执行过程出发,到fork进程创建、execve加载、进程执行和终止,以及通过键盘输入等过程。在hello运行过程中,内核有选择对其进行管理,决定何时进行上下文切换,需要各种各样的异常和中断等信息。程序的高效运行离不开异常、信号、进程等概念,正是这些机制支持 hello 能够顺利地在计算机上运行。
第7章 hello的存储管理
7.1 hello的存储器地址空间
(1)物理地址:数据真正存在主存DRAM中的位置,通过地址信号线发送给主存。计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址(Physical Address)。
(2)虚拟地址:即线性地址,是程序员用来访问一个内存单元的整数地址。一个逻辑地址在经过段地址机制的转化后变成一个线性分页地址,它与逻辑地址类似也是一个不真实的地址。其格式可以表示为“虚拟地址描述符:偏移量”。
(3)逻辑地址:“段地址:偏移地址”,是程序经过编译后出现在汇编代码中的地址,用来指定一个操作数或一条指令的地址。它由选择符和偏移量组成。linux下实质上就是虚拟地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,表示具体的是代码段寄存器还是栈段寄存器抑或是数据段寄存器。
图7-1 逻辑地址的组成
索引号就是“段描述符(segment descriptor)”的索引,段描述符具体地址描述了一个段。很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,每一个段描述符由8个字节组成,Base字段表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。那究竟什么时候该用GDT,什么时候该用LDT呢?这是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT,GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。将段描述符中的基地址与偏移量相加,得到线性地址。如果启用了分页机制,则线性地址可能还需要进一步转换为物理地址,否则线性地址就是物理地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
在hello程序中,线性地址(VA)到物理地址(PA)之间的转换通过对虚拟地址内存空间进行分页的分页机制完成。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片,然后把页式虚拟地址与内存地址建立一一对应的页表。通过7.2节中的段式管理过程,可以得到了线性地址/虚拟地址,记为VA。虚拟地址可被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),根据计算机系统的特性可以确定VPN与VPO的具体位数,由于虚拟内存与物理内存的页大小相同,因此VPO与PPO(物理页偏移量)一致。而PPN(物理页号)则需通过访问页表中的页表条目(PTE)获取,如下图所示。
图7-2 使用页表的地址翻译
若PTE有效位为1,则发生页命中,可以直接获取到物理页号PPN,PPN与PPO共同组成物理地址。若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中,产生缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面。再返回到原来的进程,再次调用导致缺页的指令。此时发生页命中,获取到PPN,与PPO共同组成物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
我们以一个实际系统的案例来研究TLB与四级页表支持下的VA到PA的变换:一个运行Linux的Intel Core i7。虽然底层的Haswell微体系结构允许完全的64位虚拟和物理地址空间,而现在的(以及可预见的未来的)Core i7实现支持48位(256TB)虚拟地址空间和52位(4PB)物理地址空间,还有一个兼容模式,支持32位(4GB)虚拟和物理地址空间。
Intel Core i7处理器封装(processor package)包括四个核、一个大的所有核共享的L3高速缓存,以及一个DDR3内存控制器。每个核包含一个层次结构的TLB、一个层次结构的数据和指令高速缓存,以及一组快速的点到点链路,这种链路基于QuickPath技术,是为了让一个核与其他核和外部I/O桥直接通信。TLB是虚拟寻址的,是四路组相联的。LI、L2和L3高速缓存是物理寻址的,块大小为64字节。L1和L2是8路组相联的,而L3是16路组相联的。页大小可以在启动时被配置为4KB或4MB。Linux使用的是4KB的页。
图7-3 Intel Core i7的内存系统
Core i7采用四级页表的层次结构。VPO与PPO有p=12位,故VPN为36位,PPN为40位。单个页表大小4KB,PTE大小8字节,则单个页表有512个页表条目,需要9位二进制进行索引,而四级页表则需要36位二进制进行索引,对应着36位的VPN。TLB有16组,故TLBI有t=4位,TLBT有36-4=32位。
CPU产生虚拟地址VA,虚拟地址VA传送给MMU,MMU使用VPN高位作为TLBT和TLBI,向TLB中寻找匹配。如果命中,则得到物理地址PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,以此类推,最终在第四级页表中找到PPN,与VPO组合成物理地址PA,添加到PLT。
图7-4 Intel Core i7页表翻译的概况
7.5 三级Cache支持下的物理内存访问
在具备三级缓存(Cache)支持的情况下,物理内存访问可以通过缓存来提高访问速度和效率。三级缓存由L1、L2和L3三个层级组成,每个层级的缓存容量逐级增大,但访问延迟也逐级增加。
当程序进行物理内存访问时,系统首先会检查L1缓存,也称为指令缓存(Instruction Cache)和数据缓存(Data Cache),来确定是否存在所需数据的副本。对Cache的访问需要把一个物理地址分为标记(CT)、组索引(CI)、块偏移(CO)三个部分。首先我们通过组索引来找到我们的地址在Cache中所对应的组号,再通过标记和Cache的有效位来判断我们的内容是否在Cache中。如果数据在L1缓存中命中,即缓存命中,系统可以直接从L1缓存中获取所需数据,从而避免了访问主内存的开销,提高了访问速度。
若数据在L1缓存未命中,系统会继续检查L2缓存,也称为高速缓存(Unified Cache)。L2缓存容量比L1缓存大,但访问延迟也相应增加。若数据在L2缓存中命中,系统将从L2缓存中获取数据,避免直接访问主内存,尽管相比于L1缓存,访问速度略有下降。当数据在L2缓存未命中时,系统将进一步检查L3缓存。
如果数据在三级缓存中都未命中,系统将通过主内存来获取所需数据。此时会发生缓存不命中(Cache Miss),系统将从主内存中读取数据,并将其加载到适当的缓存层级中,以供后续访问使用。
图7-5 Intel Core i7的3级Cache的概况
7.6 hello进程fork时的内存映射
Linux中除了根进程以外的进程都是由某个进程通过fork()自我复制而来的.在成功的情况下fork()调用一次,返回两次(父子进程中各返回一次,子进程中的“返回”实际上是子进程“获得新生”),通过fork()的返回值区分父子进程,并且它们也有不同的进程编号ID。
fork()会将子进程的虚拟页映射到父进程的页上,有的内存区二者共享(例如共享库),更多的内存区理论上应由进程分别私有,linux使用写时复制策略,使用这些页暂且在PTE上标记为只读,当其中一个进程尝试修改该页时,引发故障,内核为进程复制一份私有的页用来修改。这样既保证了内存空间的独立,又尽量通过共用页提高空间效率。
7.7 hello进程execve时的内存映射
系统调用execve将一个可执行文件加载到当前进程的上下文中,替换掉原来的程序。execve调用成功后不返回(因为原程序被杀死,原有用户区域被execve删除),也不创建新的进程。
execve实际上并不会直接把可执行文件的内容复制到物理内存,而是使用了内存映射机制,代码和数据区直接映射到可执行文件中的.text、data、.rodata等节(普通文件的内存映射,并且写时复制),等到用的时候缺页再加载。.bss、栈、堆都将映射到匿名文件,它们不会从磁盘中加载页,而是缺页时由内核直接填充二进制0。栈和堆的初始长度都是0(空),.bss的长度由可执行文件指定。
execve会将参数列表argv[]以及环境变量列表envp[]传递给加载的程序。程序可以使用带参数的main接受它们。这两个列表内的元素都是字符串指针,以NULL结尾。argv实际上相当于用户在shell内输入的命令行按空格分隔而成的字符串们,例如命令行“./me arg1 arg2”得到的argv[]就是“me”,“arg1”,“arg2”,argv[0]为程序名。
图7-6加载器映射用户地址空间区域的方式
7.8 缺页故障与缺页中断处理
当一条访问内存的指令执行时,这条指令会将虚拟地址提供给CPU,CPU会通过地址翻译单元MMU查询虚拟地址对应的虚拟页号(取虚拟地址的高若干位)在主存页表中对应的PTE。果这个页分配但未缓存,那么MMU将触发缺页故障,即页不命中。内核的处理程序采取页替换算法将主存里的某个物理页替换到磁盘里,并从磁盘内将目标页加载到这个物理位置,并修改页表,返回后重新执行内存访问指令。总之,MMU要通过页表得到虚拟地址对应的物理页,再访问物理页上对应页偏移(即地址的低若干位)的内存单元。
示例如下,CPU引用了 VP 3中的一个字,VP 3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP 3未被缓存,VP3中的字的引用会不命中,触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,缺页处理程序选择VP 4作为牺牲页,从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。
图7-7 VM缺页(之前),VP3中的字的引用会不命中.从而触发了缺页
图7-8 VM缺页(之后)。缺页处理程序选择VP 4作为牺牲页,并从磁盘上用VP 3的副本取代它。
7.9动态存储分配管理
动态内存分配器维护着一个称为堆的进程的虚拟内存区域。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
分配器有两种基本风格,显式分配器和隐式分配器。显示分配器要求应用显式地释放任何已分配的块;隐式分配器要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
图7-5 堆
C标准库提供了一个称为malloc程序包的显式分配器。程序通过调用malloc函数来从堆中分配块。malloc函数返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。实际中,对齐依赖于编译代码在32位模式(gcc -m32)还是64位模式(默认的)中运行。在32位模式中,malloc返回的块的地址总是8的倍数。在64位模式中,该地址总是16的倍数。以下是一个C程序的16字小堆的案例。每个方框代表了一个4字节的字。粗线标出的矩形对应于已分配块(有阴影的)和空闲块(无阴影的)。初始时,堆是由一个大小为16个字的、双字对齐的、空闲块组成的。(假设分配器返回的块是8字节双字边界对齐的)
(a)程序请求一个4字的块。malloc的响应是:从空闲块的前部切出一个4字的块,并返回一个指向这个块的第一字的指针。
(b)程序请求一个5字的块。malloc的响应是:从空闲块的前部分配一个6字的块。在本例中,malloc 在块里填充了一个额外的字,是为了保持空闲块是双字边界对齐的。
(c)程序请求一个6字的块,malloc就从空闲块的前部切出一个6 字的块。
(d)程序释放在(b)中分配的那个6字的块。注意,在调用free 返回之后,指针p2仍然指向被释放了的块。应用有责任在它被一个新的malloc调用重新初始化之前,不再使用p2。
(e)程序请求一个2字的块。在这种情况中,malloc分配在前一步中被释放了的块的一部分,并返回一个指向这个新块的指针。
图7-5 malloc和free分配与释放块的案例
7.10本章小结
本章主要介绍了hello的存储器的地址空间,介绍了四种地址空间的差别和地址的相互转换。同时介绍了hello的四级页表的虚拟地址空间到物理地址的转换。阐释了三级cache的物理内存访问、进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列:
,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件;打开标准输入、标准输出、标准错误;改变当前文件的位置;读写文件;关闭文件。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口
(1)打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_ FILENO、STDOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
(2)改变当前的文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为O。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
(3)读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(4)关闭文件
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 Unix I/O函数
表8-1 Unix I/O函数
函数名称 | 功能描述 | 所需头文件 | 函数原型 | 返回值 | 参数 |
open() | 用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数. | #include<sys/types.h> #include<sys/stat.h> #include<fcntl.h> | int open(const char *pathname,int flags,int perms) | 成功返回文件描述符;失败返回-1. | pathname:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式, |
close() | 用于关闭一个被打开的的文件. | #include <unistd.h> | int close(int fd) | 0成功,-1出错. | fd文件描述符 |
read() | 从文件读取数据. | #include <unistd.h> | ssize_t read(int fd, void *buf, size_t count); | 返回所读取的字节数;0(读到EOF);-1(出错). | fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。 |
write() | 向文件写入数据. | #include <unistd.h> | ssize_t write(int fd, void *buf, size_t count); | 写入文件的字节数(成功); -1(出错) | 同read |
lseek() | 用于在指定的文件描述符中将将文件指针定位到相应位置. | #include <unistd.h> #include <sys/types.h> | off_t lseek(int fd, off_t offset,int whence); | 成功返回当前位移,失败返回-1 | fd:文件描述符.offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移) |
stat() | 读取文件元数据. | #include <unistd.h> #include <sys/stat.h> | int stat(const char *filename, struct stat *buf); | 返回st_size成员包含的文件字节数大小,st_mode成员编码的文件访问许可位和文件类型 | filename文件名;stat数据结构中的各个成员 |
8.3 printf的实现分析
printf函数如下:
printf(const char *fmt,…)
{
int i;
va_list arg = (va_list)((char *)($fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
其声明了一个长度1024的缓冲区,之后建立可变参数表,随后调用vsprintf将可变参数填入格式字符串进行格式化,放到缓冲区中,随后打印缓冲区内信息
vsprintf函数如下:
int vsprintf(char *buf,const char *fmt,va_list args)
{
char *p;
char tmp[256];
va_listp_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);
}
}
vsprintf的主要作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
进一步对write进行追踪:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
此处给eax、ebx、ecx、esp等几个寄存器传递了参数,然后以int INT_VECTOR_SYS_CALL结束。INT_VECTOR_SYS_CALL代表通过系统调用syscall,再查看syscall的实现:
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
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,符显示驱动子程序:从ASCII到字模库到显示VRAM(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取VRAM,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
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)?(unsigned char) *bb++ : EOF;
}
getchar标准输入里读取下一个字符,相当于getc(stdin)。返回类型为 int 型,为用户输入的 ASCII 码或 EOF。getchar 可用宏实现:#define getchar() getc(stdin)。getchar 有一个 int 型的返回值。当程序调用 getchar时,程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar 才开始从stdin流中每次读入一个字符。getchar 函数的返回值是用户输入的字符的 ASCII 码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了Linux的I/O设备管理方法、Unix I/O接口及其函数,分析了printf函数和getchar函数的实现。
结论
(1)总结hello所经历的过程
(a) 预处理:hello.c预处理到hello.i文本文件;
(b) 编译:hello.i编译到hello.s汇编文件;
(c) 汇编:hello.s汇编到二进制可重定位目标文件hello.o;
(d) 链接:hello.o链接生成可执行文件hello;
(e) 创建子进程:bash进程调用fork函数,生成子进程;
(f) 加载程序:execve函数加载运行当前进程的上下文中加载并运行新程序hello;
(g) 访问内存:hello的运行需要地址的概念,虚拟地址是计算机系统最伟大的抽象;
(h) 交互:hello的输入输出与外界交互,与linux I/O息息相关;
(i) 终止:hello最终被shell父进程回收,内核会收回为其创建的所有信息。
(2)感悟
计算机系统的设计与实现是一个不断演进的过程,要求我们不断学习和适应新技术、新理念。计算机系统这门课理论与实践结合、硬件与软件结合,我们不仅能更深入地理解高级编程语言、汇编语言、计算机组成、数据结构、算法设计、操作系统等多个领域,还能为未来的创新奠定坚实的基础。让我们继续探索,不断挑战自我,为计算机科学与技术的发展贡献力量。
附件
文件名 | 功能 |
hello.i | 预处理器修改后的源程序,用来分析预处理器的行为 |
hello.o | 可重定位目标程序,用来分析汇编器的行为 |
hello.s | 可重定位目标程序,用来分析汇编器的行为 |
hello.elf | hello.o的elf格式 |
hello0_asm.s | hello.o的反汇编代码 |
hello1.elf | hello的elf格式 |
hello | Linux下的可执行目标程序,用来分析链接器行为 |
hello_asm.s | hello反汇编之后的可重定位文件,用来分析重定位过程 |
参考文献
[1] 深入理解计算机系统原书第3版.
[2] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[3] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[4] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[5] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[6] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[7] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.