计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 物联网工程
学 号 2022111164
班 级 2237301
学 生 张文豪
指 导 教 师 吴锐
计算机科学与技术学院
2024年5月
本文在Linux系统上对hello.c程序逐步进行预处理、编译、汇编、链接等步骤,生成可执行文件,然后从进程管理角度分析程序运行过程,从存储管理角度分析程序执行过程的存储占用情况,从IO角度观察Hello的一生。探索了hello从程序诞生到程序结束(被回收)的全部流程。
关键词:Hello world;计算机系统。
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
作为一个菜鸟程序员,我们懵懵懂懂的笨笨磕磕的将hello一字一键敲进电脑存成hello.c,hello便诞生了。
接下来,hello.c偷偷地经过了预处理器cpp,编译器cc1,汇编器as,链接器ld依次处理,最终生成一个可以加载到内存执行的可执行目标文件hello。
然后,我们在Shell中执行命令,Shell通过fork函数创建一个新的进程,然后在子进程里通过execve函数将hello程序加载到内存。hello完成了其P2P(From Program to Process)的过程。
之后,CPU一条条的从hello的.text段取指令,寄存器们的值随着程序的执行而不断变化着,异常处理程序监视着键盘的输入。hello中的syscall系统调用会使进程触发陷阱,让内核接手进程,执行write函数,将一串字符传递给屏幕IO的映射文件。
映射文件对传入数据进行分析,读取VRAM,然后在屏幕上将结果显示出来
最后,hello程序运行结束,Shell通过waitpid函数通知内核回收hello进程,hello进程消失。至此,hello完成了其程序执行的一生,从不带来什么,也不带走什么,是真正的O2O(From Zero to Zero)。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2GHz;2G RAM;256GHD Disk
1.2.2 软件环境
Windows10 64位; Vmware 16;
1.2.3 开发工具
Visual Studio 2010 64位;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
hello.c | C源文件 |
hello.i | C预处理文件,由hello.c预处理得到 |
hello.s | 汇编语言文件,由hello.i编译得到 |
hello.o | 可重定位目标文件,由hello.s汇编得到 |
hello_elf.txt | 由readelf生成的关于hello.o的ELF信息 |
hello_asm.txt | 由objdump生成的关于hello.o的反汇编信息 |
hello | 可执行文件,由hello.o链接得到 |
hello_exe_elf.txt | 由readelf生成的关于hello的ELF信息 |
hello_exe_asm.txt | 由objdump生成的关于hello的反汇编信息 |
1.4 本章小结
本章为准备章节,对hello地一生进行了简单的介绍,同时对实验环境进行了简要的说明,最后附上实验中所有的中间文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理是指在进行编译(词法扫描和语法分析)之前所作的工作。预处理是C语言区别于其他高级语言的特征之一,它由于预处理程序负责完成。当对一个源文件进行编译时,系统自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。
作用:合理地使用功能编写的程序便于阅读、修改、移植和调试,也有利于模块化程序设计。具体表现如下:
(1) 将所有的#define 删除,并且展开所有的宏定义,并且处理所有的条件预编译指令,比如#if #ifdef #elif #else #endif 等。
(2) 处理#include 预编译指令,将被包含的文件插入到该预编译指令的位置。
(3) 删除所有注释“//”和“/* */”。
(4) 添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。
(5) 保留所有的#pragma 编译器指令,后续编译过程需要使用它们。
2.2在Ubuntu下预处理的命令
可以使用 gcc-E hello.c -o hello.i命令对hello.c进行预处理
图2.2-1 对hello.c文件的预处理过程
2.3 Hello的预处理结果解析
图2.2-1 Ubuntu下预处理后文件的对比
可以观察到经过预处理命令后,hello.i文件占3092行,远远大于hello.c的24行,为什么会多出几十倍的内容呢?
对比hello.i文件的最后几行,可以发现hello.c中main函数中的内容被保留到了hello.i中,但是几个头文件stdio.h,unistd.h,stdlib.h都消失不见了。
![]() | ![]() |
图2.2-3 Ubuntu下预处理后头文件被预编译 | 图2.2-4 Ubuntu下预处理后头文件被预编译 |
分析是预处理器预编译指令,将被包含的文件插入到该预编译指令的位置,同时可见没有包含在头文件中的start.h、cdef.h、time64.h等内容,他们是由stdio.h等间接引用的,一起被插入到hello.i中。
另外,可以发现hello.c中的注释都被删除了。
2.4 本章小结
本章对hello.c文件进行预处理,产生了hello.i文件,该文件依然是可读的文本文件,对比hello.c文件,hello.i文件删去注释、插入头文件,程序主体内容依然保留,为之后的编译过程做好了准备工作。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:在这里,编译是指编译器(cc1)将文本文件.i翻译成汇编语言程序文件.s的过程。
作用:编译过程对预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生成相应的汇编代码。
3.2 在Ubuntu下编译的命令
可以使用gcc -S hello.i -o hello.s命令对hello.i文件进行编译
图3.2-1 对hello.i文件的编译过程
3.3 Hello的编译结果解析
3.3.1 数据
(1)整型数据
图3.3.1-1 hello.i文件中的整型常量5
图3.3.1-2 hello.s文件中的立即数$5
图3.3.1-3 hello.i文件中的整型常量10
图3.3.1-4 hello.s文件中的立即数9(符合循环条件)
![]() | ![]() |
图3.3.1-5 hello.i文件中的整型常量0 | 图3.3.1-6 hello.s文件中的立即数$0 |
- 字符型常量
图3.3.1-7 hello.s文件中存储的字符型常量
图3.3.1-8 hello.s文件中将存储的字符型常量传入寄存器
图3.3.1-9 hello.s文件中将存储的字符型常量传入寄存器
- 局部变量
图3.3.1-10 hello.i文件中唯一的局部变量i
![]() | ![]() |
![]() | ![]() |
![]() | ![]() |
图3.3.1-11 hello.i文件与hello.s文件对比图
对比hello.i文件和hello.s文件可知,对局部变量i的操作(赋值、加1、比较)是借助于操作寄存器%rbp的值实现的。
3.3.2 赋值
hello.i中的赋值语句在hello.s中体现为使用movl指令将立即数赋给寄存器%rbp
![]() | ![]() |
图3.3.2-1 hello.i与hello.s中的赋值语句对照
3.3.3 类型转换
图3.3.3-1 hello.i文件中的类型转换
atoi函数的作用是将一个字符串转换为一个整型数据,即返回值为int类型。因此在sleep函数这里argv[4]发生了隐式类型转换。
图3.3.3-2 hello.s文件中对atoi函数的调用
观察hello.s文件中的函数调用可知寄存器rdi中是字符串所在的地址,而atoi函数的返回值被保存在寄存器eax中,本质上两个寄存器中存储的都是unsigned int数据,从汇编语言的角度来看其实并没有发生类型转换。
3.3.4 算术运算
在hello.i中仅有一处算术运算,为i的自增运算。
图3.3.4-1 hello.i文件中i的自增运算
i的自增操作对应hello.s文件中对rbp寄存器进行加1的操作,并赋给寄存器rbp本身。
图3.3.4-1 hello.s文件中i的自增运算体现
3.3.5 关系运算
图3.3.5-1 hello.i文件中的!=
图3.3.5-2 hello.s文件中!=的体现
hello.i中的关系运算!=在hello.s中体现为cmpl与je的组合判断语句,先用cmpl语句比较立即数5与-20(%rbp),其实质是进行剑法判断标志位,如果5和-20(%rbp)的值相等,就会跳转到L2,进行循环的执行。
图3.3.5-3 hello.s中L2
实际上,L2进行的工作是为循环增量i赋初值,并跳转到L3进行循环判定。
图3.3.5-4 hello.i中的关系运算<
图3.3.5-5 hello.s中关系运算<的表示
分析上图,L3对应的是循环条件的判断。为实现循环终止条件i<10,hello.s文件中比较立即数9与-4(%rbp)的值,这里的立即数是9而不是10,这是因为小于等于9等价于小于10。在进行完循环条件的判断后,就会真正进入循环体的执行。
3.3.6 数组操作
图3.3.6-1 hello.i中的数组操作
程序中的数组操作体现在对argv[]的操作,通过观察可知,hello.s文件中对数组元素的访问是依靠对数组首地址(在寄存器中)进行add操作与movl操作实现的。
原语句为printf("Hello %s %s %s\n",argv[1],argv[2],argv[3])
对应第二个实参为argv[1],对应操作为addq $24,%rax及其后两行
对应第三个实参为argv[2],对应操作为addq $16,%rax及其后两行
对应第四个实参为argv[3],对应操作为addq $8,%rax及其后两行
同理,$32,%rax及其后两句的作用是将argv[4]作为参数传入函数atoi
图3.3.6-2 hello.s借助add与movl命令进行数组的访问
3.3.7 控制转移
hello.i文件中的控制转移语句有两句,分别为if语句与for语句
图3.3.7-1 hello.i中的控制转移语句
hello.s中的具体体现如下图所示
图3.3.7-2 hello.s实现控制转移if语句与for语句的过程
3.3.8 函数操作
程序的函数操作共有六处,如下图所示
(1)printf函数
![]() | ![]() |
图3.3.8-1 函数调用1——printf
其中printf函数被转化为puts函数,实现输出功能。
- exit函数
![]() | ![]() |
图3.3.8-2 函数调用2——exit
寄存器edi中为数1,作为参数传递给exit函数。
- printf函数
图3.3.8-3 函数调用3——printf
图3.3.8-4 函数调用3——printf之参数分析
- sleep函数
![]() | ![]() |
图3.3.8-5 函数调用4——sleep
atoi函数的返回值被送到寄存器edi中,并作为参数传递给函数sleep
- atoi函数
![]() | ![]() |
图3.3.8-6 函数调用5——atoi
atoi函数的参数是argv[]数组的首地址+32,即为argv[4]
- getchar函数
![]() | ![]() |
图3.3.8-7 函数调用6——getchar
3.4 本章小结
本章进行编译,将hello.i文件转换为hello.s文件,即将经过预处理的高级语言程序代码转换为汇编代码。
使用了命令gcc -S hello.i -o hello.s命令对hello.i文件进行编译
文件hello.s只有85行。为后面的进一步汇编打下基础
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
作用:汇编过程调用对汇编代码进行处理,生成处理器能识别的指令,保存在后缀为.o的目标文件中。
4.2 在Ubuntu下汇编的命令
可以使用命令gcc -c hello.s -o hello.o命令对hello.s文件进行汇编
图4.2-1 汇编过程
4.3 可重定位目标elf格式
4.3.1 elf头文件
使用readelf -a hello.o命令,可在终端查看hello.o的ELF格式。也可使用readelf -a hello.o > hello_elf.txt将结果重定向至文本文件hello_elf.txt中,便于查看。
图4.3.1-1 查看hello.o的ELF格式
图4.3.1-2 ELF头文件
可以在elf.h源码中找到64位 ELF的文件头的数据结构,其结构体定义如下:
- typedef struct
- {
- unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
- Elf64_Half e_type; /* Object file type */
- Elf64_Half e_machine; /* Architecture */
- Elf64_Word e_version; /* Object file version */
- Elf64_Addr e_entry; /* Entry point virtual address */
- Elf64_Off e_phoff; /* Program header table file offset */
- Elf64_Off e_shoff; /* Section header table file offset */
- Elf64_Word e_flags; /* Processor-specific flags */
- Elf64_Half e_ehsize; /* ELF header size in bytes */
- Elf64_Half e_phentsize; /* Program header table entry size */
- Elf64_Half e_phnum; /* Program header table entry count */
- Elf64_Half e_shentsize; /* Section header table entry size */
- Elf64_Half e_shnum; /* Section header table entry count */
- Elf64_Half e_shstrndx; /* Section header string table index */
- } Elf64_Ehdr;
下面我们将对其中每个字段进行介绍。
- e_iden
e_ident 是一个包含 16 字节的数组成员,对应 readelf -h 给出的 magic 部分.
magic 部分就是我们所说的魔数,魔数通常就是自定义的识别码,对于 32 位的 elf 文件而言,magic 部分有 16 个字节.
大部分的文件组织形式都是这样的,头部是一串特殊的识别码,标识该文件的一些概要信息,主要用于外部程序快速地对这个文件进行识别,快速地判断文件类型.
- e-type
type 表示 elf 文件的细分类型,总共有四种:
·可重定位的目标文件
·可执行文件
·动态链接文件
·coredump 文件,这是系统生成的调试文件.
这四种类型的文件各有各的特点,比如可重定位的目标文件针对的是链接器.
而可执行文件针对加载器,需要被静态加载到内存中执行,而动态链接文件则是运行过程中的加载.
coredump 文件主要保存的是系统出错时的运行断点信息,方便人为地或者借 助 gdb 分析 bug.
- e-machine
标识指定的机器,比如40代表ARM,其它的比如 x86,mips 等都对应不同的编码.
- e-version
四个字节的 version code
- e-entry
程序的入口虚拟地址,对于可重定位的目标文件默认是0,而对于可执行文件而言是真实的程序入口.
程序入口是被加载器使用的,在程序加载过程中会读取该程序入口,作为应用程序的开始执行地址,在实际的加载过程中,内核加载完当前 elf 可执行文件之后其实并不是跳到该入口地址,而是先执行动态链接器代码,在动态链接完成之后才会跳到该入口地址。
- e-phoff
四个字节的 program headers 的起始偏移地址。
- e-shoff
四个字节的 section headers 的起始偏移地址。
- e-flags
和处理器相关的标志位集合,不同的处理器有不同的参数,根据 e_machine 进行解析。
- e-ehsize
指示 elf header 的 size,对于 arm 而言,52 或者 64。
- e-phentsize
每一个 program header 的 size,在可重定位目标文件中为 0。
- e-phnum
该文件中一共有多少个 program header,在可重定位目标文件中为0。
- e-shentsize
文件中每一个section header 的大小,通常是 40。
- e-shunum
该文件中一共有多少个 section header,上述的示例文件中为 10 个。
- e-shstrndx
在 elf 格式的文件中,符号,section,文件的命名通常是字符串,这些字符串并不会保存在其对应的 section 中,而是统一地使用一个字符串表来保存,该字段指示节标题字符串所在的 section,在上面的示例中,section 标题(.text,.data,...)对应的 e_shstrndx 即段序号为 7,即保存在 .shstrtab 段中。这些 section 标题在链接的过程中需要使用到,在程序执行时是无用的,所以分开有利于精简 section 内容的大小,从而程序加载运行时需要更小的空间。
除了 section 标题,还有符号名,文件名等字符串,这些默认会被保存在 .strtab section 中。
4.3.2 节头表
图4.3.2-1 hello_elf中显示的节头表
节头表中的每一项都对应了一个结构体,其定义如下:
- typedef struct
- {
- Elf64_Word sh_name; /* Section name (string tbl index) */ // 4 bytes
- Elf64_Word sh_type; /* Section type */
- Elf64_Xword sh_flags; /* Section flags */ // 8 bytes
- Elf64_Addr sh_addr; /* Section virtual addr at execution */ // 8 bytes
- Elf64_Off sh_offset; /* Section file offset */ // 8 bytes
- Elf64_Xword sh_size; /* Section size in bytes */
- Elf64_Word sh_link; /* Link to another section */
- Elf64_Word sh_info; /* Additional section information */
- Elf64_Xword sh_addralign; /* Section alignment */
- Elf64_Xword sh_entsize; /* Entry size if section holds table */
- } Elf64_Shdr;
各个成员的含义见下表:
成员 | 含义 |
sh_name | 一个偏移量,指向本节的名字 |
sh_type | 指明本节的类型 |
sh_flags | 指明本节的属性 |
sh_offset | 指明本节的位置 |
sh_size | 指明该节的大小,以字节为单位 |
sh_link | 指向节头表中本节所对应的位置 |
sh_info | 指明该节的附加信息 |
sh_addralign | 指明该节内容对齐字节的数量 |
sh_entsize | 指明该节对应的每一个表项的大小 |
表4.3.2-2 节头表项各个数据成员的含义
4.3.3 重定位节
重定位是连接符号引用与符号定义的过程。例如,程序调用函数时,关联的调用指令必须在执行时将控制权转移到正确的目标地址。可重定位文件必须包含说明如何修改其节内容的信息。重定位节即包含了这些用于重定位的数据信息。
图4.3.3-1 hello_elf中显示的重定位节
在hello_elf.txt中,我们可以看到两个重定位节,分别是.rela.text节与.rela.en_frame节。
上述重定位节中的每一个表项都对应了一个结构体,其定义如下:
- typedef struct {
- Elf64_Addr r_offset;
- Elf64_Xword r_info;
- Elf64_Sxword r_addend;
- } Elf64_Rela;
r_offset成员指定了应用可重定位操作的位置。对于可重定位文件,该值表示节偏移值。
r_info成员指定了必须对其进行重定位的符号表索引以及要应用的重定位类型。
r_addend成员指定了一个常量加数,用于计算将存储在可重定位字段中的值。
通过对r_info成员施用宏
#define ELF64_R_TYPE_ID(info) (((Elf64_Xword)(info)<<56)>>56)
可以得到其对应的重定位类型,即图4.3.3中的“类型”一项。在图4.3.3中,我们见到了三种ELF中最为基本的重定位类型:
- R_X86_64_32 重定位一个使用32位绝对地址的引用。
- R_X86_64_PC32 重定位一个使用32位PC相对地址的引用。
- R_X86_64_PLT32 过程链接表延迟绑定。
4.3.4 符号表
符号表保存了程序实现或使用的所有全局变量和函数,如果程序引用一个自身代码未定义的符号,则称之为未定义符号。这类引用必须在静态链接期间用其他目标模块或库解决,或在加载时通过动态链接解决。
图4.3.4-1 hello_elf中显示的重定位节
符号表的每一个表项都对应了一个结构体,其定义如下:
- typedef struct {
- Elf64_Word st_name;
- unsigned char st_info;
- unsigned char st_other;
- Elf64_Half st_shndx;
- Elf64_Addr st_value;
- Elf64_Xword st_size;
- } Elf64_Sym;
各个成员的含义见下表:
成员 | 含义 |
st_name | 目标文件的符号字符串表的索引 |
st_info | 指明了符号类型与绑定属性 |
st_others | 符号的可见性 |
st_shndx | 表明该符号对应的节头在节头表中的偏移 |
st_value | 相关符号的值。根据上下文,这可以是绝对值,地址等。 |
st_size | 符号的大小 |
表4.3.4-2 符号表项中各个数据成员的含义
观察表4.3.4-1中的Ndx列,该列即为结构体中的st_shndx,即为该符号对应的节头在节头表中的偏移:.text节偏移量为1,.rodata节偏移量为5,.main节偏移量为1,都与图4.3.2-1相对应,但是我们发现puts,exit等函数这一列为UND,即为undefined(未定义),我们将在“链接”这一章时进行解释。
4.4 Hello.o的结果解析
使用objdump -d -r hello.o命令可以在终端查看hello.o对应的反汇编代码。其中 -d指disassemble,反汇编,-r 指reloc,显示文件的重定位入口。
也可使用objdump -d -r hello.o > hello_asm.txt将结果重定向至文本文件hello_asm.txt中,便于查看。
对比hello_asm.txt与hello.s文件,我们发现了以下不同点:
- 代码行数
![]() | ![]() |
图4.4-1 代码行数不同
hello.s中有85行代码,hello_asm.txt中仅有58行代码,相较更为简洁
(2)汇编指示符
![]() | ![]() |
图4.4-2 汇编指示符有无
在hello.s中时常出现的汇编指示符.cfi_***没有在hello_asm.txt中出现。
(3)进制表示
![]() | ![]() |
图4.4-3 进制表示不同
在hello.s文件中数字以十进制方式表示,而在反汇编hello_asm.txt文件中,数字以十六进制的方式表示
- 分支转移
![]() | ![]() |
图4.4-4 分支转移
在hello.s文件中,分支转移的控制采用.L2,.L3等标识符进行,而在反汇编文件hello_asm.txt中,每一条指令都有一个对应的地址码,在进行分支转移时,采用间接寻址,如图4.4-4右,如果5与-0x14(%rbp)的值相等,则跳转到main+0x32处,即进行循环赋初值工作。
- 函数调用的不同
![]() | ![]() |
图4.4-5 分支转移
在hello.s中调用函数需要使用call语句,并且需要使用到PLT节中的内容。在hello_ams.txt中函数被展开了,要调用函数时需要指令跳转到相应的地址处(即为函数名的下一行命令)。
4.5 本章小结
本章在前文得到的hello.s文件的基础上,使用命令gcc -c hello.s -o hello.o 对hello.s文件进行汇编,形成二进制文件hello.o,向着程序机器化表示更进了一步。
之后使用readelf -a hello.o命令查看hello.o的ELF文件,分析其组成。
最后使用反汇编命令objdump -d -r hello.o对hello.o文件进行反汇编,生成反汇编文件hello_asm.txt,对比汇编与反汇编文件,体会机器语言与汇编语言的映射关系。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合为一个单一文件的过程,所得到的文件可以被加载到内存之中并执行。链接由链接器(ld)程序执行,链接执行的时机可以是编译时,即源代码被翻译成机器码的时候;以及加载时,即程序被加载器加载到内存并执行的时候;甚至是运行时,即在应用程序执行链接命令的时候。
作用:链接的两个主要任务是符号解析和重定位。符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。链接的存在也使得分离式编译成为了可能。
5.2 在Ubuntu下链接的命令
在Ubuntu下,使用ld进行链接的命令如下:
ld -plugin /usr/lib/gcc/x86_64-linux-gnu/11/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/11/lto-wrapper -plugin-opt=-fresolution=/tmp/ccbaDZBp.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -z relro /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/crt1.o /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/11/crtbegin.o -L/usr/lib/gcc/x86_64-linux-gnu/11 -L/usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/11/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/11/../../.. hello.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/11/crtend.o /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/crtn.o -o hello
链接过程如图:
图5.2-1 链接过程
可见生成了hello文件
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
使用readelf -a hello.o命令,可在终端查看hello的ELF格式。也可使用readelf -a hello > hello_exe_elf.txt将结果重定向至文本文件hello_exe_elf.txt中,便于查看。
图5.3-1 hello的ELF头
如图可见hello文件的ELF头,发现hello为可执行文件,同时ELF头也包含了关于hello的其他基本信息。
下图为hello的节头表,第一列按序展示了各段的名称、大小信息,第二列展示了对应的类型,第三列展示了对应的地址。
图5.3-2 hello的节头表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图5.4-1 使用edb加载hello
图5.4-2 回顾5.3
图5.4-3 本进程的虚拟地址空间
根据5.3中的节头表的内容,我们可以在edb的Data Dump中跳转至对应地址,从而查看某段的原始数据。上图对.int段内容进行了查看
5.5 链接的重定位过程分析
5.5.1 hello与hello.o的不同
使用objdump -d -r hello命令可以在终端查看hello对应的反汇编代码。也可使用objdump -d -r hello > hello_exe_asm.txt将结果重定向至文本文件hello_exe_asm.txt中,便于查看。
(1)代码行数
![]() | ![]() |
图5.5.1-1 代码行数
可见hello的反汇编代码行数有211行,而hello.o的反汇编代码仅有58行。这是由于hello.o的反汇编代码中只有main函数的内容,而hello文件链接了其他内容,导致代码总行数增多。
- 指令地址
![]() | ![]() |
图5.5.1-2 指令地址
对比可见hello.o文件反汇编后指令地址起始为0。而在经过链接后,每一节都被分配了相应的虚拟地址空间,如上图右所示的401000。
- 插入了用于调用共享库中的函数的指令
图5.5.1-3 插入了用于调用共享库中的函数的指令
- 控制转移的不同
![]() | ![]() |
图5.5.1-4 控制转移的不同
我们对比上图,同样是je指令,hello.o文件跳转的地址是根据相对偏移量确定的,而在hello中,由于已经分配了虚拟地址空间,所以程序可以直接跳转到相应的虚拟地址。
- 函数调用的不同
![]() |
![]() |
图5.5.1-4 函数调用的不同
如图所示,我们可以发现在进行函数调用时,hello.o使用相对寻址调用函数,参数用0来代替,而在hello中,由于已经分配了虚拟地址空间,所以需要调用函数时可以直接call其对应的虚拟地址。上图为两文件对printf函数的调用对比。
5.5.2 链接过程
链接过程主要分为符号解析和重定位这两步
- 符号解析
链接器解析符号引用的方法就是将每个引用与它对应的可重定位目标文件的符号表中的一个确定的符号定义关联起来。
对于局部符号及静态局部变量解析比较简单:只需要保证每个模块中的每个局部符号只有一个定义。对于全局符号的解析:当编译器遇到一个不是在当前模块定义的符号时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并交给链接器处理;如果链接器在任何输入的模块中都找不到该定义就报错并且终止。
- 重定位
重定位将每个符号引用和符号定义关联起来,并且为每个符号分配运行时地址。
重定位包括:重定位节和符号定义:链接器将所有相同类型的数据节合并为同一类型的聚合节,并且将运行时的内存地址赋值给新的聚合节及每个模块定义的符号;重定位节中的符号引用:链接器修改代码节和数据节中对每个符号的引用,使得其指向正确的运行地址。
5.5.3 重定位过程分析
图5.5.3-1 使用PC相对地址的可重定位条目
图5.5.3-2 对应重定位条目
最终.rodata节的地址确定需要借助PC相对寻址确定。
5.6 hello的执行流程
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
对hello.o进行重新编译,加入-g便于后续使用edb调试:
使用命令:gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.c -o hello1
程序的调用顺序是:
- 调用了动态链接库linux-x86-64.so.2、libc.so中的几个函数
- _start(0x401160)
- __libc_start_main(0x7f2be0029dc0)
- __cxa_atexit(0x7f2be00458c0)
- libc.so中几个函数(0x7f2be00456d0)
- 动态链接库libc.so.6里的函数(包括_setjmp等)
- hello中main函数(0x4010d0)
- __printf_chk@plt(0x401090)
- strtol@plt(0x401080)
- sleep@plt(0x4010b0)
- 重复8/9/10步骤9次
- getc@plt(0x4010c0)
- libc.so.6!exit
图5.6-1 程序执行流程
5.7 Hello的动态链接分析
当程序调用一个由共享库定义的函数时,因为该函数所在的共享模块可能会加载到内存的任意位置,编译系统采用延迟绑定的策略,将过程地址的绑定推迟到第一次调用该过程的时候。动态链接器使用过程链接表(PLT)和全局偏移量表(GOT)来实现函数的动态链接。在GOT中存放函数的目标地址,而PLT则利用GOT中的地址跳转到目标函数。PLT是一个数组,其中每个条目是16字节的代码。PLT [0]是一个特殊的条目,它跳转到动态链接器中。而GOT是另一个数组,其中每个条目是8字节的地址。GOT [2]是动态链接器在1d-linux.so模块中的入口点。每个条目都对应一个匹配的PLT条目。
下图为动态链接前后对比GOT在运行时的地址。
![]() | ![]() |
图5.7-1 动态链接前后Got运行时的地址对比
5.8 本章小结
本章主要介绍了链接的概念及作用,其主要指是将各种代码和数据片段收集并组合为一个单一文件的过程。
在Ubuntu下通过链接命令将hello.o文件经由链接器生成了可执行目标文件hello。通过readelf列出了其各节的基本信息,包括起始位置、大小等信息;用edb查看了hello的虚拟地址空间,同时查看了各节的起始位置与大小;通过objdump对hello进行反汇编,得到了其反汇编程序hello_exe_asm.txt,并与hello.o的反汇编程序hello _asm.txt进行了多方面的比较。又分析了hello的重定位过程与执行过程。最后,通过edb,分析了hello程序的动态链接项目在dl_init前后的内容变化。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:一个正在运行的程序或者软件就是一个进程,它是操作系统进行资源分配的基本单位,也就是说每启动一个进程,操作系统都会给其分配一定的运行资源(内存资源)保证进程的运行。
作用:进程提供了独立的逻辑控制流,好像我们的程序是独占地使用着处理器;也提供一个私有的地址空间,好像我们的程序独占地使用着内存系统。通过进程逻辑控制流,可以使CPU被科学有效地划分成多个部分以并行地运行多个进程。
6.2 简述壳Shell-bash的作用与处理流程
作用:
Shell是一个交互型应用级程序,可代表用户运行其他程序。
Shell最重要的功能是命令解释,从这种意义上说,Shell是一个命令解释器。Linux系统上的所有可执行文件都可以作为Shell命令来执行。
处理流程:
当用户提交了一个命令后,Shell首先判断它是否为内置命令,如果是就通过Shell内部的解释器将其解释为系统功能调用并转交给内核执行;若是外部命令或应用程序就试图在硬盘中查找该命令并将其调入内存,再将其解释为系统功能调用并转交给内核执行。
执行外部命令时,Shell创建会通过fork创建一个子进程,并通过execve加载并运行该外部命令(可执行目标文件),当该进程执行结束时在信号处理子程序中用waitpid命令对其进行回收,从内核中将其删除。
当执行的进程为前台进程时,Shell会阻塞命令的输入直到前台进程终止运行。
6.3 Hello的fork进程创建过程
hello的执行是通过在终端中输入命令 ./hello来完成的。
当我们在终端中输入命令 ./hello时,Shell会先判断发现这个参数并不是内置的命令,从而把这条命令当作一个可执行程序的名字尝试执行。
接下来,Shell会执行fork函数,创建一个子进程。hello程序将会在这个进程中执行。
fork函数的作用是创建一个与当前进程平行运行的子进程。内核会将父进程的上下文,包括代码,数据段,堆,共享库以及用户栈,甚至父进程打开的文件的描述符,都创建一份副本。然后利用这个副本执行子进程。从这个角度上来说,子进程与父进程直到执行完fork的瞬间都是完全相同的。
6.4 Hello的execve过程
在shell创建的子进程中,使用execve函数加载并运行我们的hello程序。当子进程调用execve函数时,会触发syscall陷阱指令,执行execve系统调用。在陷阱处理程序中,会调用相应的系统调用服务例程sys_execve()来执行。sys_execve()首先会回收或重新初始化当前进程的资源,如删除当前进程虚拟地址的用户部分中已存在的区域结构,重新初始化进程控制块等资源。随后,它会调用操作系统的加载器,将hello程序加载到当前进程的虚拟空间中,包括映射新程序的代码段、数据段等私有区域,映射共享区域,并设置程序计数器。值得注意的是,execve成功调用时不会返回到调用程序,只有在出现错误时才会返回到调用程序。
6.5 Hello的进程执行
6.5.1.逻辑控制流
进程的运行本质上是CPU不断从程序计数器 PC 指示的地址处取出指令并执行,其中这些PC值的序列叫做逻辑控制流,并且这些值与可执行目标文件的指令或者包含在运行时动态链接到程序的共享对象中的指令一一对应。
6.5.2.进程时间片
操作系统对进程的运行进行调度,采取执行进程A、上下文切换、执行进程B、再次上下文切换、执行进程A的循环。在进程执行的某些时刻,内核可能会决定抢占当前进程,并重新开始先前被抢占的进程,这个决策过程被称为调度,由内核中的调度器代码来处理。当内核选择一个新的进程运行时,我们称内核调度了该进程。一旦内核调度了新的进程运行,它就会抢占当前进程,并利用上下文切换机制将控制权转移到新的进程。在一个程序从被调用开始到被另一个进程打断之间的时间段被称为运行的时间片。
当一个逻辑流的执行与另一个流的执行在时间上重叠时,称为并发流,这两个流同时运行。多个流同时执行的概念称为并发。当一个进程与其他进程交替运行时,这种概念称为多任务。一个进程在其控制流的每个时间段内的执行称为一个时间片,多任务也被称为时间分片。
6.5.3.用户与内核模式
用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据。
内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
运行程序代码初始时都是在用户模式中的,当发生中断故障或系统调用的异常时,进程从用户模式转变为内核模式。当异常发生时,控制传递到异常处理程序,处理器将模式转变为内核模式。
6.5.4.进程的上下文切换
在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换——①保存当前进程的上下文,②恢复某个先前被抢占的进程被保存的上下文,③将控制传递给这个新恢复的进程。
上下文切换一定发生在内核模式下。因此关于时间分片的实现大体上是这样的:由于每个系统都有某种产生周期性定时器中断的机制(一般为1ms或10ms),当hello进程在它的时间片(具体来说应该就是定时器的单个周期)里执行的时候,如果发生了定时器的中断,hello进程由于中断异常会变为内核模式,此时内核能够判断hello进程已经运行了足够长的时间,并会通过上下文切换切换到新的进程。
除了定时器中断,在内核代表用户进行系统调用的时候,也可能会发生上下文切换,切换到新的进程。总体而言,进程上下文切换过程如图6-1:
图6.5.4-1 进程上下文切换示意图
6.6 hello的异常与信号处理
6.6.1 正常输入
图6.6.1-1 正常输入
在终端执行./hello 2022111164 张文豪 18622315992命令,不干扰程序执行,即可完成一次hello的正常运行。
根据上述输入的命令行参数,hello正常运行时,每隔1秒将在屏幕上打印“Hello 2022111164 张文豪”字样,一共会打印8次。打印结束后,调用getchar()函数阻塞程序执行,等待用户输入。在用户输入回车之后,hello程序终止,Shell回收hello进程。
6.6.2 乱按键盘(不包括Ctrl-Z,Ctrl-C)
图6.6.2-1 乱按键盘
在终端执行./hello 2022111164 张文豪 18622315992命令,在程序输出时乱按键盘,会发现通过键盘输出的符号(包括空格)与程序本来的输出叠在一起,并没有影响程序的正常运行。
在乱按键盘的时候会发生中断异常,hello进程会进入内核模式,将控制转移给中断异常处理程序。键盘的中断处理程序,会从键盘控制器的寄存器读取扫描码并翻译成ASCII码,并存入键盘缓冲区。
6.6.3 Ctrl-C
图6.6.3-1 Ctrl-C
在hello程序正常执行时,输入Ctrl-C,会直接中断hello的执行。输入Ctrl-C会发送 SIGINT 信号给Shell,再由Shell将信号转发给前台进程组中的所有进程,终止前台进程组。
6.6.4 Ctrl-Z
图6.6.4-1 Ctrl-Z
在按下了Ctrl+Z时,hello进程会收到一个SIGTSTP的信号,结果是使hello进程停止。此时hello进程不再是前台,从而shell没有前台作业需要等待。我们可以在shell中继续输入命令。用ps命令可以查看当前的所有进程的进程号,用jobs命令查看所有的作业,用fg命令可以将指定的作业放在前台运行,此时会给指定的进程组发送SIGCONT信号,让挂起的进程重新运行。用kill命令可以向指定的进程组发送信号,kill -9表示发送SIGINT信号,会让进程组内每一个进程终止。
6.7本章小结
本章探讨了进程的概念与作用、壳Shell-bash的作用与处理流程、Hello的fork进程创建过程、Hello的fork进程创建过程、Hello的进程执行、Hello的异常以及信号处理等问题,开始真正体会hello的执行。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1.逻辑地址
逻辑地址指的是:程序经过编译后出现在汇编代码中的地址,是由程序产生的与段相关的偏移地址部分,也叫相对地址;一个逻辑地址由一个段和偏移量组成。表示为[CS:EA]。在实模式下:物理地址=CS*16+EA;在保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址,段地址+偏移地址=线性地址。
hello.o中所使用的即为逻辑地址,要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。
7.1.2. 线性地址
线性地址是逻辑地址到物理地址变换之间的中间层。代码会产生段中的偏移地址,加上相应段的基地址就生成了一个线性地址。线性地址实际上是非负整数地址的有序集合。在保护模式下,线性地址=段地址+偏移地址=线性地址。
7.1.3.虚拟地址
CPU启动保护模式之后,程序运行在虚拟地址空间中,虚拟地址空间是所有可能地址的集合,对于一个64位的机器而言,则集合中共有2^64种可能。
但并非所有程序均运行在虚拟地址当中,CPU在启动的时候是运行在实模式的,是直接使用物理地址的。
7.1.4. 物理地址
物物理地址是指存储器中以字节为单位存储信息时,每个字节单元与一个唯一的存储器地址一一对应的地址。这些地址也被称为实际地址或绝对地址,它们通过寻址总线传送。物理地址对应系统中实际的内存字节。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel平台下,逻辑地址以selector:offset的形式表示,其中selector是CS寄存器的值,而offset是EIP寄存器的值。通过将selector与全局描述符表(GDT)中相应的段基址相加,得到线性地址。这个过程被称为段式内存管理。
逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段,也就是段选择符。通过段选择符的前13位,可以直接在段描述符表中找到对应的段描述符。全局段描述符存放在全局段描述符表(GDT)中,而一些局部的段描述符则存放在局部段描述符表(LDT)中。
给定完整的逻辑地址(段选择符+段内偏移地址),通过检查段选择符的T1位(位13),可以确定要转换的是GDT中的段还是LDT中的段。然后根据相应的寄存器,可以得到段的地址和大小。通过在段描述符表中查找对应的段描述符,可以获取其基地址。最终,将基地址与偏移量相加得到线性地址。
图7.2-1 逻辑地址向线性地址转换
7.3 Hello的线性地址到物理地址的变换-页式管理
假设虚拟页和物理页的大小均为4k(12位),通过存储在物理内存中的一个数据结构页表,可以实现将线性地址转化成物理地址。页表是页表条目的数组,每一个页表条目(PTE),都由一个有效位和一个物理页号(PPN)组成。对于一个n位的虚拟地址,其低12位是页内偏移(VPO),其余位表示着其虚拟页号(VPN)。
CPU里面有一个控制寄存器PTBR(页表基址寄存器),指向当前页表。通过PTBR找到页表的首地址,再根据VPN的值可以得到对应的页表条目的地址(PTEA)。PTEA=%PTBR+VPN*页表条目大小。找到了页表条目后,如果有效位=1,说明该虚拟页缓存进了内存,从而根据PTE可以找到该虚拟页对应的物理页号。由于虚拟页和物理页大小相等,物理页中的页内偏移PPO=VPO。从而物理地址由PPN与VPO组合而成。(具体过程可见《深入理解计算机系统》图9-12,见下图)。如果有效位=0,则会发生缺页故障。
图7.3-1 利用页表进行地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
为了节约页表的内存存储空间,我们会使用多级页表。虽然多级页表节约了我们的存储空间,但是却存在问题:
原本我们对于只需要进行一次地址转换,只需要访问一次内存就能找到对应的物理页号,算出物理地址。现在我们需要多次访问内存,才能找到对应的物理页号。最终,虽然节约了空间,却带来了时间上的额外开销,变成了一个“以时间换空间”的策略,极大地限制了内存访问性能。
为了解决这种问题导致处理器性能下降的问题,现代 CPU 中都包含了一块缓存芯片TLB,全称为地址变换高速缓冲(Translation Lookaside Buffer),简称为“快表”,用于加速对于页表的访问。简单来说,TLB就是页表的Cache,属于MMU的一部分,其中存储了当前最可能被访问到的页表项。
图7.4-1 MMU中访问TLB
当CPU处理虚拟地址时,首先去TLB中根据标志Tag寻找页表数据,假如TLB中正好存放所需的页表,说明TLB命中,直接从TLB中获取该虚拟页号对应的物理页号。如果TLB不命中,需要从L1缓存中根据VA取出相应的PTE,计算出PA,并将该PTE存放在TLB中,可能会覆盖原先的条目。
图7.4-2 加入TLB后,通过虚拟内存访问数据的流程
在四级页表的参与之下,当TLB不命中时,将根据VPN1、VPN2…一层层的计算出下一级页表的索引,最后在L4页表中找到相应的PTE,计算出对应的PA,并将其添加至TLB之中。
图7.4-3 Core i7的四级页表示意图
7.5 三级Cache支持下的物理内存访问
对于一个物理地址PA,可以分为三部分:标记(CT)、组索引(CI)、块偏移(CO)。首先,根据组索引在L1缓存中选择相应的组。检查有效位是否为1,如果是,则将标记与该组中每一行的标记进行比较。如果匹配成功,则根据块偏移在命中的块中读取数据;如果未命中,则访问L2缓存。访问方式与之前相同:先检查组索引,然后有效位,最后匹配标记。如果命中,则读取数据;如果未命中,则继续访问下一级存储器。如果在三级缓存中都未命中,则需要从内存中读取数据,并将该块加入到L3缓存中。
7.6 hello进程fork时的内存映射
当Shell调用fork函数创建hello进程时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。
为了给hello进程创建虚拟内存,内核创建了当前进程的mm_struct、区域结构和页表的原样副本,将两个进程中的每个页面都标记为只读,并且把两个进程中的每个区域结构都标记为私有的写时复制。
当hello进程中fork返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。这两个进程的任一个后来进行写操作时,写时复制机制就会创建新页面,为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
当hello进程通过execve加载并运行hello程序时,execve函数有效地替代了当前程序。execve函数对内存映射执行以下操作:1.清除当前进程虚拟地址空间中已存在的用户部分区域结构。2.为hello程序的代码、数据、bss和栈区域创建新的区域结构。代码和数据区域被映射到hello可执行文件中的.text和.data节,bss区域初始化为二进制零,并映射到匿名文件,栈和堆区域也被初始化为二进制零,初始长度为0,并映射到匿名文件。3.映射共享区域。由于hello程序与共享对象(如libc.so共享库)链接,因此这些对象被动态链接到该程序,并映射到用户虚拟地址空间的共享区域内。
7.8 缺页故障与缺页中断处理
图7.8-1 缺页异常处理
缺页故障:一个虚拟页没被缓存在DRAM中,即DRAM缓存不命中被称为缺页。若程序想要访问某个虚拟页中的数据的时候,会产生一个虚拟地址。当CPU引用了一个页表条目中的一个字,而该页表条目并未被缓存在DRAM中,MMU(内存管理单元)在试图翻译这个虚拟地址的时候,会发现该地址所在的虚拟页没有缓存进内存(即PTE中有效位为0),必须从磁盘中取出,这时候就会触发缺页异常。
缺页中断处理:
- 处理器生成一个虚拟地址,并将它传送给MMU;
- MMU生成PTE地址,并从高速缓存/主存请求得到它;
- 高速缓存/主存向MMU返回PTE;
- PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序;
- 缺页处理程序确认出物理内存中的牺牲页,若这个页已经被修改了,则把它换到磁盘;
- 缺页处理程序页面调入新的页面,并更新内存中的PTE;
缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。
7.9动态存储分配管理
动态内存分配器维护着一个进程的堆,即虚拟内存区域。该分配器将堆视为一组不同大小的块的集合来管理。每个块都是一段连续的虚拟内存片段,可以是已分配的或空闲的。已分配的块明确保留供应用程序使用,而空闲块可用于分配,如果未被显式分配,则会保持空闲状态。
有两种主要风格的分配器,取决于如何释放已分配的块:
- 显式分配器要求应用程序显式释放任何已分配的块,例如C语言中的malloc和free;
- 隐式分配器要求分配器检查已分配的块何时不再被程序使用,然后释放该块。这种分配器也称为垃圾收集器,在Java语言中就依赖于垃圾收集来释放已分配的块;
对于实现显式分配器时,有几种方法和策略来组织、放置、分割和合并空闲块:
- 使用隐式空闲链表来组织空闲块(单向链表)。
放置策略:包括首次适配、下一次适配和最佳适配等;
分割策略:可以选择不分割,使用整个空闲块,或将其分成两部分,一部 分作为分配块,另一部分作为空闲块;
获取额外的堆内存以获得适当的空闲块;
合并空闲块的策略:包括立即合并(每次释放一个块时立即合并)、推迟 合并和带边界标记的合并;
- 使用显式空闲链表来组织空闲块(可以组织为双向链表)。
维护链表的顺序:可以按照后进先出的顺序或按地址顺序。
- 使用分离的空闲链表来维护多个空闲链表。
有两种基本方法:简单分离存储和分离适配。
7.10本章小结
本章讨论了hello的存储器地址空间、hello的存储器地址空间、Hello的线性地址到物理地址的变换-页式管理、TLB与四级页表支持下的VA到PA的变换、三级Cache支持下的物理内存访问、hello进程fork时的内存映射、hello进程fork时的内存映射、hello进程execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容,对hello程序运行过程中的存储情况进行详细剖析。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
在Linux中,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
(以下格式自行编排,编辑时删除)
8.2.1 Unix I/O接口
通过Unix I/O接口,所有的输入和输出都能以统一且一致的方式来执行:
(1)打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
(2)每个进程开始时都打开的三个文件
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)
(3)改变当前的文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。通过执行seek操作,能够显式地设置文件的当前位置为k。
(4)读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时,执行读操作会触发一个EOF条件。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(5)关闭文件
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
8.2.2 Unix I/O函数
int open(char *filename, int flags, mode_t mode);
应用程序通过调用open函数来打开一个已存在的文件或者创建一个新文件。
int close(int fd);
应用程序通过调用 close 函数关闭一个打开的文件。
ssize_t read(int fd, void *buf, size_t n);
应用程序通过调用read函数来执行文件的输入。
ssize_t write(int fd, const void *buf, size_t n);
应用程序通过调用write函数来执行文件的输出。
8.3 printf的实现分析
printf函数的函数体如图8-1所示
图8.3-1 printf函数的函数体
va_list是一个用于参数列表的字符指针的重新定义,即typedef char* va_list。在这个上下文中,(char*)((&fmt) + 4 )代表了第一个参数,这与栈的结构有关。*fmt存放在栈中,而后续的字符型指针也都存在于栈中。由于在32位的Linux系统中,一个指针的大小为4字节,而在使用m64编译命令时,一个指针的大小为8字节。因此,通过+4来获取第一个参数的位置。
之后调用了vsprintf函数,其函数体如下图所示:
图-8-2 vsprintf函数的函数体
vsprintf函数将格式化后的参数内容存储到buf中,并返回格式化数组的长度。然后,write函数将buf中的i个元素写入终端。整个过程涉及从vsprintf生成显示信息,到write系统函数,再到陷阱-系统调用int 0x80或syscall。
字符显示驱动子程序负责将字符从ASCII码转换为相应的字模库,然后将其显示在屏幕的VRAM中,其中存储了每个像素点的RGB颜色信息。显示芯片按照设定的刷新频率逐行读取VRAM,并通过信号线向液晶显示器传输每个像素点的RGB分量信息。
8.4 getchar的实现分析
进入getchar函数之后,进程会进入阻塞状态,等待外界的输入。系统开始检测键盘的输入。此时如果按下一个键,就会产生一个异步中断,这个中断会使系统回到当前的getchar进程,然后根据按下的按键,转化成对应的ASCII码,保存到系统的键盘缓冲区。
接下来,getchar调用了read函数。read函数会产生一个陷阱,通过系统调用,读取键盘缓冲区中存储的刚刚按下的按键信息,然后返回指定大小的字符串。
最后,getchar会将这个字符串保存在一个静态的缓冲区中,并返回其第一个字符。在下次调用getchar时,将直接从静态的缓冲区中取出字符并返回,而不是通过read再次进行读取,直到静态缓冲区为空,才再调用read进行读取。
8.5本章小结
本章介绍了Linux的IO设备管理方法,Unix IO接口及其函数,然后讨论了pirntf和getchar这两种函数的实现,对hello的一生进行了补充。
(第8章1分)
结论
至此hello已经走完了它的一生。作为我们每个程序员的“初恋”,hello出身高贵,一生坎坷。接下来让我们回顾一下hello的一生。以计算机系统的视角重温hello的一生。
- 编写hello.c的源程序,完成hello.c文件;
- 预处理:经过预处理器的作用,删去注释、插入头文件,产生了庞大的hello.i文本文件。
- 编译:对预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生成相应的汇编代码,完成了从hello.i->hello.s文件的转变。
- 汇编:汇编过程调用对汇编代码进行处理,生成处理器能识别的指令,保存在后缀为.o的目标文件中,即进行了从hello.s->hello.o的转变。
- 链接:hello.o与其它调用库函数所在的可重定位目标文件和动态链接库链接生成可执行文件hello,至此hello可以被加载入内存并运行;
- 创建进程:终端shell调用fork函数,创建一个子进程,在子进程中通过execve将hello加载到了内存,子进程为程序的加载运行提供虚拟内存空间等上下文;
- 加载程序:终端shell调用execve函数,启动加载器映射虚拟内存,之后开始载入物理内存,再进入main函数;
- 访问内存:通过TLB和多级页表,实现虚拟内存和物理内存的翻译,进而访问计算机的存储结构,访问内存;
- IO:hello输入输出与外界进行交互;
- 终止:hello向shell发送SIFCHLD信号,被父进程回收,内核收回为其创建的所有信息。
表面上简单的一个程序,从外界看来不过一刹那,但在CS中,你的一生光辉灿烂。程序终止了,人生尚未终止。关于人生的“Hello world”,仍需继续探索。
附件
(附件0分,缺失 -1分)
hello.c | C源文件 |
hello.i | C预处理文件,由hello.c预处理得到 |
hello.s | 汇编语言文件,由hello.i编译得到 |
hello.o | 可重定位目标文件,由hello.s汇编得到 |
hello_elf.txt | 由readelf生成的关于hello.o的ELF信息 |
hello_asm.txt | 由objdump生成的关于hello.o的反汇编信息 |
hello | 可执行文件,由hello.o链接得到 |
hello_exe_elf.txt | 由readelf生成的关于hello的ELF信息 |
hello_exe_asm.txt | 由objdump生成的关于hello的反汇编信息 |
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 《深入理解计算机系统》第3版
[2] PPT
[3] Ubuntu18.04系统下,gcc编译过程分析、命令参数介绍及ELF文件格式学习
Ubuntu18.04系统下,gcc编译过程分析、命令参数介绍及ELF文件格式学习_elf执行流程图-CSDN博客
[4] elf 文件格式0 - 可重定位目标文件
https://zhuanlan.zhihu.com/p/363487856
[5] 进程的介绍(概念、作用)
[6] https://www.cnblogs.com/pianist/p/3315801.html
[7] 虚拟地址、逻辑地址、线性地址、物理地址的区别
虚拟地址、逻辑地址、线性地址、物理地址的区别_线性地址和虚拟地址的区别-CSDN博客
[8] 操作系统-分段机制
https://www.cnblogs.com/mdumpling/p/8494806.html
[9] 一文读懂内存管理中TLB:地址转换后援缓冲器
https://zhuanlan.zhihu.com/p/480808324
[10] 256-Linux虚拟内存映射和fork的写时拷贝
256-Linux虚拟内存映射和fork的写时拷贝_当fork时,内核并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。-CSDN博客
(参考文献0分,缺失 -1分)