计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 1190200527
班 级 1903002
学 生 王雨桐
指 导 教 师 郑贵滨
计算机科学与技术学院
2021年6月
摘 要
本文将跟随hello程序从编译、汇编、链接到运行时的进程管理、访存、IO管理的过程,详细地讨论计算机在程序运行的各个步骤中的细节性原理,以加深我们对于计算机系统各部分原理的理解,对计算机整体的体系结构有更加充分的认识。
关键词:预处理;编译;汇编;链接;进程;访存;系统I/O
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第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 -
结论............................................................................................................................ - 14 -
附件............................................................................................................................ - 15 -
参考文献.................................................................................................................... - 16 -
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
用户编写的代码hello.c经过预处理器(C Pre-Processor)处理生成hello.i,然后使用编译器(ccl)将文本文件hello.i编译成汇编文件hello.s,汇编器(as)将hello.s汇编为可重定位文件hello.o,最后使用链接器(ld)将hello.o链接和动态共享库等链接为可执行程序hello。执行文件时,在shell中会为hello创建一个子进程,然后加载并运行hello程序。
shell调用fork函数创建子进程,在子进程中调用execve函数加载并运行hello程序,进行虚拟内存映射。内核为hello进程分配时间片,并在时间片的两端进行上下文切换。MMU和四级页表负责将运行时的虚拟内存映射为实际的物理内存,利用三级高速缓存和主存可以进行访存。内核调用信号处理函数对各种信号进行处理。IO接口和IO函数保障IO功能的实现。hello进程结束后,shell父进程会将其回收。
X64 CPU, 2.21GHz 16GB RAM, 1024GHD Disk
Linux: VMware 16.1.0 Ubuntu18 虚拟机
Visual Studio Code、vim、edb、readelf、gcc、gdb
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 |
作用 |
hello.i |
预处理得到的文件 |
hello.s |
编译得到的文件 |
hello.o |
汇编得到的可重定位文件 |
hello.d |
对hello.o使用objdump得到的反汇编文件 |
hello |
链接后得到的可执行文件 |
hello_dump.d |
对hello使用objdump得到的反汇编文件 |
elf.txt |
对hello.o使用readelf生成的elf文件 |
hello_elf.txt |
对hello使用readelf生成的elf文件 |
本章主要介绍了P2P和O2O的概念以及对hello程序进行研究时用到的环境和工具,并记录了生成的中间文件的名字和作用。
(第1章0.5分)
预处理的概念:
最常见的预处理是C语言和C++语言。ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase) ,通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
预处理的作用:
预处理命令:
gcc -E hello.c -o hello.i
图1 预处理结果
通过观察预处理后产生的文件hello.i可以发现,预处理将将头文件stdio.h、unistd.h和stdlib.h进行展开。预处理器会寻找这些头文件的位置,然后将其标注在预处理文件中:
图2 Linux系统中stdio.h库文件所在的位置
然后,预处理器会打开这些库文件,发现其中含有#define定义的宏。而预处理的工作之一就是将宏定义的值进行展开和替换,直到不存在任何没有展开的宏定义为止。而经过一系列的展开和替换操作,程序最后也会被扩展到3066行。
阅读hello.i文件的最后位置可以发现,hello.c中的源码被放在了文件的结尾,并没有太大的变化。
图3 位于hello.i结尾的源码
本章主要介绍了预处理的概念和作用,在Linux中使用预处理指令对hello.c文件进行了预处理,通过观察预处理后得到的hello.i文件直观地了解到预处理阶段所进行的一系列工作。
(第2章0.5分)
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
编译的概念:
编译是指把文本文件.i翻译成含有汇编语言的文本文件.s的工作。
编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析、语法分析、语义检查和中间代码生成、代码优化、目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
编译的作用:
对文本进行解析和分割,生成编程语言允许的记号。若存在非法记号,则标注这些记号,并产生错误提示信息。
以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,方法分为两种:自上而下分析法和自下而上分析法。
语义检查分析语法分析产生的中间表示形式和符号表,源程序的语义必须与所用语言的静态语义属性相符。
对程序进行多种等价变换,得到功能等价但是运行时间更短或占用资源更少的等价中间代码。
将语法分析后或优化后的中间代码变换成目标代码(即汇编代码)
编译命令:
gcc -S hello.i -o hello.s
图4 编译产生的汇编文件hello.s
sleepsecs是一个整型的全局变量,其值被初始化为2(见3.3.3节的数据类型转换)。该值存储在.data节中。
首先,.text节中将sleepsecs声明为一个全局变量(global):
图5 sleepsecs变量在.text节中的声明
然后,在.data节中,记录了其对齐方式为4字节,类型为对象,大小为4个字节,并且类型为长字(注意:这里的long并不是指C语言中的长整型,而是计算机系统中的长字long word,其大小为4个字节,与C语言中的int大小相同),值为2。
图6 .data中的sleepsecs变量
argc是main函数的第一个参数,应该保存在寄存器edi中,一段从该寄存器中取出argc值的汇编代码如下:
图7 从寄存器中取出argc值的汇编代码
这段代码将argc的值从存储它的edi中取出并与3做比较,对应源码中的if(argc!=3)。
i是一个局部变量,它被保存在栈中。i的初值被赋为0,对应的汇编代码如下:
图8 将i初值赋为0对应的汇编代码
可以看到,局部变量i被存储在栈中比%rbp中存储的地址小4的位置。从汇编指令movl也可以看出,i是一个大小为4个字节的长字(long word)。
char *argv[]是一个指针数组,每个成员都是指向一个字符串起始地址的字符指针。该数组是main函数的第二个参数,其基地址保存在寄存器rsi中:
图9 将数组argv的基地址存入栈中的汇编代码
源程序中,printf函数中有两个参数argv[1]和argv[2],于是就先给基地址分别加8和16获得字符串argv[1] 和argv[2]起始地址的保存位置,然后对这两个内存位置进行访问,获得两个字符串的起始地址,将其作为参数分别用寄存器rsi和rdx送给printf函数。
图10 获取argv[0]和argv[1]的起始地址的汇编代码
因为sleepsecs是一个全局变量,所以这一赋值操作体现在汇编代码中就是.data节中将其值记录为2,类型记录为长字(long word,4字节)。
图11 对全局变量sleepsecs赋值的汇编代码
i是一个局部变量,保存在栈中,长度为4个字节,故对其赋值的汇编代码如下:
图12 对局部变量i赋值的汇编代码
对变量int sleepsecs赋值时,int sleepsecs = 2.5进行了隐式的类型转换。因为浮点数常量默认为double类型,而目标变量sleepsecs是int类型,所以赋值时会产生double到int的隐式类型转换。
浮点数向整数的类型转换的原则是丢弃小数位的数据,即向零舍入。因此,浮点数2.5会被转换为整数值2赋给sleepsecs。
汇编指令中的所有算术操作如下:
指令 |
效果 |
Leaq S,D |
D = &S |
INC D |
D = D+1 |
DEC D |
D = D-1 |
NEG D |
D = -D |
NOT D |
D = ~D |
ADD S,D |
D = D+S |
SUB S,D |
D = D-S |
IMUL S,D |
D = D*S |
XOR S,D |
D = D^S |
OR S,D |
D = D|S |
AND S,D |
D = D&S |
SAL k,D |
D=D< |
SHL k,D |
D = D< |
SAR k,D |
D = D>>k 算术右移 |
SHR k,D |
D = D>>k 逻辑右移 |
对i的自增运算使用了addl指令:
图13 i++;对应的汇编代码
该指令将栈指针rsp的值减少了32,目的是为main函数创建一个32字节大小的栈帧用来存放局部变量等数据。在虚拟内存中,栈是向下生长的,因此在栈中开辟一块空间需要对栈指针做sub运算。
图14 使用subq运算将栈顶指针下移的汇编代码
使用了加载有效地址指令leaq计算LC1的段地址%rip+.LC1,并将其存入寄存器rdi中作为参数传递给printf函数。
图15 使用leaq指令的汇编代码
汇编代码中比较argc和3的值,若等于,则将条件码ZF置为1。然后,条件跳转语句je根据条件码ZF决定是否跳转。
图16 汇编代码中的数值比较和条件跳转
汇编代码判断i与9的大小关系,若小于等于,则继续执行for循环。这与源代码中比较i与10的大小关系,若小于,则继续执行的语义虽然略有区别,但是在效果上是等价的。这样的效果应该是编译器选择的结果。
图17 汇编代码中的数值比较和条件跳转
main函数中的局部变量大部分存储在栈中。而栈空间实质上也是一段内存地址,要想从栈中获取这些局部变量的值,就要对内存地址进行访问,而这种访问就是基于指针操作的。
例如,movl %edi, -20(%rbp)就是将存放于寄存器edi中的argc中的值存入指针rbp所指地址减去20的内存位置。在汇编语言中,(%REG)(REG是寄存器的名字)指令是间接寻址,即访问指向寄存器中的指针所指向的内存位置。我们可以通过指针操作对这个位置进行读、写等操作。
图18 指针操作的一些实例
汇编代码层面,对数组的随机访问一般是靠访问数组基地址+偏移量位置的方法进行实现。比如在源代码中,程序想要访问argv[1]和argv[2]两个数组元素,则在汇编代码中需要访问基地址(保存于-32(%rbp)处的值)加上偏移量8和16。一下是具体的汇编指令:
图19 数组操作汇编代码实例
图20
是比较argc与3的大小关系,根据比较结果的条件码ZF,使用条件跳转指令je决定进行跳转还是不进行跳转。汇编代码如下:
图21
注意到源代码中的语义是若argc不等于3,则执行特定语句;而经过编译后,语义变为了若argc等于3,则跳转到特定位置。两个语义虽有不同,但是行为是等价的。这是编译器选择的结果,不影响程序的行为。
图22
判断i是否小于10。若小于,则循环继续执行;若不小于,则跳出循环。在汇编代码中,则是判断i和9的大小关系。若小于等于,则跳转到循环体的起始位置,否则继续运行,而不再跳回到循环体中。这里的语义同样与源码有一些出入,但不影响程序总体的行为。
图23
函数在传递参数时,前六个参数依次保存在%rdi、%rsi、%rdx、%rcx、%r8、%r9六个寄存器中,多出的参数使用栈进行传递。函数如果有返回值,会将其存放在%rax中。若传递的参数或返回的值是数组、结构体等数据结构,则%rax中存储的是这些数据结构的首地址。
main函数有两个参数,即int argc和char* argv[]。对于第一个参数,其长度为4个字节,故存储在%edi(即%edi的低4字节)中;而对于第二个参数,它是一个字符串数组,每个元素都是一个字符串的起始地址,故存储在%rsi中进行传递的是这个数组的起始地址,长度为8个字节(64位程序中)。
图24 存放main函数两个参数的寄存器
_start函数是操作系统执行C程序的起点。在_start函数开始运行时,命令行参数argc和argv都保存在栈中。程序将这两个参数从栈中取出,并通过寄存器%rsi和%rdx将其传递给__libc_start_main函数并调用它。在__libc_start_main函数中,将两个参数分别放进%edi和%rsi,然后调用main函数。
图25 反汇编代码中的_start函数和它调用__libc_start_main函数的代码
main函数的返回值为0。main函数返回前,会将0存入寄存器%eax中作为其返回值。然后,程序执行leave指令,恢复栈指针至调用main函数之前的位置,然后执行ret返回到之前调用main函数的下一条汇编指令。
该函数只有一个参数,即printf括号内的字符串“Usage: Hello 学号 姓名!\n”。这时在汇编代码中printf函数会被换成puts。该字符串常量被保存在.rodata段中。于是,调用gets函数之前会使用leaq .LC0(%rip), %rdi指令计算出字符串保存的地址并存储在%rdi中,作为参数传递给gets函数。
图26 调用puts函数之前进行的参数传递
在main函数中使用指令call puts@PLT进行调用(如上图)。
第一个参数是字符串常量的起始地址(类似上一个puts函数,使用指令leaq .LC1(%rip), %rdi计算地址),保存在%rdi中;第二个参数是argc,保存在%rsi中;第三个参数是argv,保存在%rdx中。
图27 调用printf函数之前进行的参数传递
在main函数中使用指令call printf@PLT进行调用(如上图)。
使用%rdi传递一个参数int sleepsecs。
图28 调用sleep函数之前进行的参数传递
在main函数中使用指令call sleep@PLT进行调用(如上图)。
该函数调用无需任何参数。
在main函数中使用指令call getchar@PLT进行调用。
图29 main函数中调用getchar函数的汇编指令
该函数存放在%eax中的返回值是读入字符的ASCII码(int类型)。
源代码中调用exit函数时传递的参数是常数1,意在表明程序非正常退出。于是在进行参数传递时,会将1保存在%edi中。
图30 图29 调用sleep函数之前进行的参数传递
在main函数中使用指令call exit@PLT进行调用。
exit函数不会返回。
本章将上一章预处理后得到的hello.i文件编译为hello.s文件,对于得到的汇编文件中的各种数据(整数、数组和字符串)以及操作(赋值、类型转换、算数操作、关系操作、数组、指针、结构操作、控制转移和函数操作)进行了详细的解析与阐述。相比于C语言这样的高级语言,汇编语言更多地关注了指令在硬件层面(如寄存器和内存地址)的实现过程。汇编语言还可以汇编成底层的机器语言,这两种语言的转换几乎是完全对应的。具体见下一章的解析。
(第3章2分)
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
把汇编语言翻译成机器语言的过程称为汇编,并将这些指令打包成可重定位目标程序,并将这个结果保留在.o文件中。这里的.o文件是二进制文件。
汇编的作用是将汇编语言文件转换成机器可以直接读取分析的机器语言文件,并将指令打包成可重定位目标程序。
应截图,展示汇编过程!
汇编命令:
gcc -c hello.s -o hello.o
图31 汇编产生的二进制文件hello.o
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
readelf -a hello.o > elf.txt
ELF头内容如下图,主要包含了以下内容:64位程序,数据表示方法为二进制补码(2's complement),字节顺序为小端序(little endian),目标文件的类型为REL可重定位文件(Relocatable file),机器类型为X86-64,节头部表开始于文件1232字节处,ELF头大小为64字节,节头部表大小为64字节、数量为14等等。
图32 ELF头的内容
节头部表中记录了.o文件中每个节的名称(Name)、类型(Type)、地址(Address)、偏移量(Offset)、大小(Size)、全体大小(EntSize)、标志(Flags)、链接(Link)、信息(Info)以及对其要求(Align)等信息。
图33 节头部表的内容
其中几个主要节的含义如下:
. text:已编译程序的机器代码
. rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表
. data:已初始化的全局和静态C变量
. bss:未初始化的全局和静态C变量
. symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息
. strtab:一个字符串表,其内容包括 .symtab 和 .debug节中的符号表,以及节头部中的节名字。
在程序的各个段中,会引用不同类型的外部符号,这些符号在连接时需要通过重定位来对其地址进行更改。而重定位节记录了这些需要重定位的项目,包括其符号名称、重定位类型等等。在我们研究的程序中,需要重定位的内容主要有:.rodata(保存有printf要打印的字符串常量)、全局变量sleepsecs以及程序中所调用的一些共享库函数,包括puts、exit、printf、sleep和getchar。
图34 重定位节的内容
符号表中记录了程序中各个符号的值(Value)、大小(Size)、类型(Type)、是局部还是全局符号(Bind)、名称(Name)等信息。
图35 符号表的内容
(以下格式自行编排,编辑时删除)
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
使用以下命令对hello.o文件进行反汇编,得到反汇编文件:
objdump -d -r hello.o > hello.d
图36 hello.o的反汇编文件
机器语言全部是由二进制数组成的。其与汇编语言的映射关系是一一对应的,即可以将机器语言翻译成唯一的汇编语言,也可以将汇编语言翻译成唯一的机器语言。这个映射关系与将C语言源代码编译为汇编语言不同,这一翻译过程包含了许多编译器的优化操作,同一段C代码使用不同的编译器、不同的优化等级,得到的汇编语言也不尽相同。由于机器代码对应唯一的汇编代码,所以可以将机器指令反汇编为汇编指令。使用objdump得到的反汇编文件时,为了方便表示,二进制的指令被转换为十六进制,同时在旁边标注了对应的汇编指令。
在hello.s文件中,操作数多表示为十进制,而在hello.o中,因为其为二进制文件,所以这些操作数也被转换为对应的二进制表示。经过反汇编处理,这些操作数又被转换为十六进制,因此得到的hello.d文件中操作数都是0x…的形式。
在hello.s中,跳转语句的操作数是诸如.L2一类的段名称而在hello.d中,这些位置处的操作数暂时被全部替换为0,并在下方标注了在链接阶段需要补上地址的重定位条目(在前面的重定位表中有这些条目的记录)。
在hello.s中,call指令后面是函数名,而hello.d中call指令后面是相对于执行到这条指令时PC值的偏移量,也暂时全部填为0,作为重定位条目,待链接时补上这些相对地址正确的值。
本章将hello.s文件汇编为二进制文件hello.o,对其ELF中的信息进行了详细的观察和解释。之后,我们又通过objdump指令获得了该二进制文件的反汇编文件,并将其与hello.s文件进行了对比,更加深入、直观地了解了汇编操作的作用和在一些细节方面如操作数的编码方式、分支转移和函数调用语句中所作的处理。二进制文件中得到的大量重定位条目,为下一章的链接操作埋下了伏笔。
(第4章1分)
注意:这儿的链接是指从 hello.o 到hello生成过程。
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可执行于编译时,也就是源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
将各种代码和数据片段收集并组合成为一个单一文件,使其可以被加载到内存并执行。链接将可重定位文件转换成可执行文件。
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
汇编命令:
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
图37 链接产生的可执行文件hello
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
输入指令readelf -a hello > hello_elf.txt获取hello的ELF。
节头表中记录了hello中各节的信息,包括每个节的名称(Name)、类型(Type)、地址(Address)、偏移量(Offset)、大小(Size)、全体大小(EntSize)、标志(Flags)、链接(Link)、信息(Info)以及对其要求(Align)等。
图38 节头表中的信息
其中几个主要节的含义如下:
. init:定义_init函数,该函数用来执行可执行目标文件开始执行时的初始化工作
. text:已编译程序的机器代码
. rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表
. data:已初始化的全局和静态C变量
. bss:未初始化的全局和静态C变量
. symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息
. strtab:一个字符串表,其内容包括 .symtab 和 .debug节中的符号表,以及节头部中的节名字。
.plt:每个动态链接的程序和共享库都有一个 PLT,PLT 表的每一项都是一小段代码,对应于本运行模块要引用的一个全局函数。程序对某个函数的访问都被调整为对 PLT 入口的访问。
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
在上面的节头表中显示.init节的起始地址为0x401000,而在gdb中,该地址处是_init函数代码的起始地址。而我们知道,.init节的作用就是定义_init函数,该函数用来执行可执行目标文件开始执行时的初始化工作,我们看到的信息验证了这一点。
图39 .init节中的内容
节头表中显示.text节的起始地址为0x4010d0。在edb中找到这个地址,发现这里是_start函数的起始地址,后面跟着main等函数的机器代码。回顾上一节,我们提到程序是从_start函数开始运行的,这也解释了它的机器代码位于.text节的最前面的原因。
图40 .text节的内容(部分)
节头表中显示.rodata节的起始地址为0x402000。在edb的Data Dump中观察这段内存地址,发现字符串常量“Usage: Hello 学号 姓名!\n”和“Hello %s %s\n”都存储在这段内存中。回顾之前在编译时,hello.s文件中就可以看到这些字符串常量保存的位置是.rodata节,这与我们观察到的信息一致。
图41 .rodata节中的内容(部分)
节头表中显示.plt节的起始地址为0x401020。在edb中观察这一地址,发现程序中调用的puts、printf、getchar、exit、sleep等共享库函数都位于这段内存中。上面提到,.plt节中每一项都是一小段代码,对应于本运行模块要引用的一个全局函数。这也解释了为什么我们调用的共享库函数会出现在这个位置。
图42 .plt节中的内容(部分)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
输入指令objdump -d -r hello > hello_dump.d得到hello的反汇编文件hello_dump.d,将其与链接前的hello.o的反汇编文件进行对比。
首先我们可以发现,在hello_dump.d文件的最开始,多出了一些节的信息,如.init、.plt、.plt.sec、.fini等等,而链接之前的hello.d中只有.text节的信息。而阅读.plt.sec节中的信息可以发现,程序所使用的共享库函数如puts、printf等的跳转指令均位于这个节中。
图43 hello_dump.d中的.plt.sec节
进一步观察.text节,发现hello.d的.text节中只有main函数的代码,而hello_dump.d中加入了_start等函数。
图44 .text节中新增的一些函数
我们还可以观察到,在hello.d中的那些重定位条目(包括全局变量的地址、字符串常量的地址、调用共享库函数的地址等),在hello_dump.d中全部被替换为具体的地址。也就是说,程序在运行的时候可以通过这些具体的地址找到各种符号的位置了。
在hello.d中,main函数指令的地址是从0开始的,各指令的地址实际上是相对于main起始位置的地址;而在hello_dump.d中可以看到,所有指令的地址都被表示为加载到内存中的绝对地址。
重定位使用以下算法进行计算:
图45 重定位算法
如对于第一个字符串常量(用string表示):
refaddr = ADDR(main) + string.offset = 0x401105 + 0x1c = 0x401121;
*refptr = ADDR(string.symbol) + r.addend -refaddr = 0x402004 + (-0x4) - 0x401121 = 0xedf
图46 重定位后的第一个字符串的PC相对地址
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
子程序名 |
程序地址 |
hello!_start |
4010d0 |
libc-2.31.so!__libc_start_main |
7fade25dafc0 |
libc-2.31.so!__cxa_atexit |
7fade25fdf60 |
libc-2.31.so!__libc_csu_init |
401190 |
_init |
401000 |
libc-2.31.so!__sigsetjmp |
7fade25f9d30 |
hello!main |
401105 |
hello!.plt+0x60 |
401080 |
hello!puts@plt |
401030 |
hello!printf@plt |
401040 |
hello!sleep@plt |
401070 |
hello!getchar@plt |
401050 |
hello!exit@plt |
401060 |
libc-2.31.so!exit |
7fade2ac65cbc0 |
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接是在可执行文件首次加载和运行时进行的,而现代操作系统不允许程序在运行时修改代码段,只能修改数据段,所以对于程序中调用的动态共享库函数,需要依靠GOT表和PLT表进行重定位和跳转。
GOT表:
概念:每一个外部定义的符号在全局偏移表(Global offset Table)中有相应的条目,GOT位于ELF的数据段中。
作用:把位置无关的地址计算重定位到一个绝对地址。程序首次调用某个库函数时,运行时连接编辑器(rtld)找到相应的符号,并将它重定位到GOT之后每次调用这个函数都会将控制权直接转向那个位置,而不再调用rtld。
PLT表:
过程连接表(Procedure Linkage Table),一个PLT条目对应一个GOT条目
当main()函数开始,会请求plt中这个函数的对应GOT地址,如果第一次调用那么GOT会重定位到plt,并向栈中压入一个偏移,程序的执行回到_init()函数,rtld得以调用就可以定位printf的符号地址,第二次运行程序再次调用这个函数时程序跳入plt,对应的GOT入口点就是真实的函数入口地址。
动态连接器并不会把动态库函数在编译的时候就包含到ELF文件中,仅仅是在这个ELF被加载的时候,才会把那些动态函库数代码加载进来,之前系统只会在ELF文件中的GOT中保留一个调用地址.
动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
图47 PLT和GOT工作原理简图
如果可执行文件调用的动态库函数很多时,那在进程初始化时都对这些函数做地址解析和重定位工作,大大增加进程的启动时间。所以Linux提出延迟重定位机制,只有动态库函数在被调用时,才会地址解析和重定位工作。即进程启动时,先不对GOT表项做重定位,等到要调用该函数时才做重定位工作。
下面是dl_init函数运行前后GOT内容的对比:
图48 dl_init运行前的GOT
图49 dl_init运行后的GOT
可以看到,GOT表中已经填入了调用函数的地址,之后的函数调用的时候先到PLT表中执行,然后访问got表得到函数地址,就可以直接跳转到相应的函数代码处了。
本章对链接前的可重定位文件hello.o和链接后的可执行文件hello的反汇编文件做了对比,从两个反汇编文件的不同之处反映了链接过程所作的工作。我们还分析了动态链接和延迟重定位机制,阐述了在这一过程中GOT表和PLT表之间的关系和工作原理。
(第5章1分)
进程,是指计算机中已运行的程序。进程曾经是分时系统的基本运作单位。在面向进程设计的系统(如早期的UNIX,Linux 2.4及更早的版本)中,进程是程序的基本执行实体;在面向线程设计的系统(如当代多数操作系统、Linux 2.6及更新的版本)中,进程本身不是基本运行单位,而是线程的容器。
程序本身只是指令、数据及其组织形式的描述,相当于一个名词,进程才是程序(那些指令和数据)的真正运行实例,可以想像说是现在进行式。若干进程有可能与同一个程序相关系,且每个进程皆可以同步(循序)或异步(平行)的方式独立运行。现代计算机系统可在同一段时间内以进程的形式将多个程序加载到存储器中,并借由时间共享(或称时分复用),以在一个处理器上表现出同时(平行性)运行的感觉。同样的,使用多线程技术(多线程即每一个线程都代表一个进程内的一个独立执行上下文)的操作系统或计算机体系结构,同样程序的平行线程,可在多CPU主机或网络上真正同时运行(在不同的CPU上)。
shell是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。实际上Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果。
bash是缺省的Linux shell,通常运行于文本窗口中,并能执行用户直接输入的命令。Bash还能从文件中读取命令,这样的文件称为脚本。和其他Unix shell 一样,它支持文件名替换(通配符匹配)、管道、here文档、命令替换、变量,以及条件判断和循环遍历的结构控制语句。包括关键字、语法在内的基本特性全部是从sh借鉴过来的。其他特性,例如历史命令,是从csh和ksh借鉴而来。总的来说,Bash虽然是一个满足POSIX规范的shell,但有很多扩展。
shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。
图50 新的程序开始时用户栈的组织结构
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需求的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包括有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个时间发生而阻塞,那么内核可以让当前的进程休眠,切换到另一个进程。在某一个时刻,内核会代表进程A在用户模式下执行命令,假设这时内核决定抢占当前进程,并重新开始一个先前被抢占了的进程,这时它就会代表进程A在内核模式下执行调度器代码完成上下文的切换,转为代表进程B在内核模式下执行指令。在切换结束后,内核就会转为代表B在用户模式下执行指令。
图51 上下文切换时用户态与核心态的转换
例如在我们的hello程序中会调用sleep函数,该函数会显示地请求让调用进程休眠。在进程休眠时,操作系统并不会一直等待休眠结束而什么都不做,而是通转而运行其他的进程。此时内核进入内核模式进行上下文的切换,恢复另一个进程的寄存器、用户栈等信息,然后回到用户模式执行另一个进程的指令。当hello程序的进程休眠结束后,会向内核发出一个信号,内核判定hello进程已经休眠了足够长的时间,于是再次进入内核模式恢复hello进程运行时的信息,然后回到用户模式继续执行hello中的指令。
时间片是从进程开始运行直到被抢占的时间现代操作系统(如:Windows、Linux、Mac OS X等)允许同时运行多个进程。时间片由操作系统内核的调度程序分配给每个进程。首先,内核会给每个进程分配相等的初始时间片,然后每个进程轮番地执行相应的时间,当所有进程都处于时间片耗尽的状态时,内核会重新为每个进程计算并分配时间片,如此往复。系统通过测量进程处于“睡眠”和“正在运行”状态的时间长短来计算每个进程的交互性,交互性越强的进程分配到的时间片也越长。
拿我们的运行hello程序的进程来说,它调用sleep函数的频率很高,也就是说,该进程在大多数时间是休眠的,交互性较弱。因此,该进程是一个趋向于处理器消耗型的进程,内核分配给它的时间片也较少。
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
图52 运行ps和jobs命令
图53 运行fg命令
图54 运行kill命令
图55 运行pstree命令
在进程运行时按下Ctrl-Z,会向内核发送一个SIGTSTP信号,随即系统将该进程停止。运行ps命令,显示当前各进程的信息,可以看到刚刚被挂起的hello进程。运行jobs命令可以看到当前的作业列表,其中包含hello进程。运行fg信号,被停止并转到后台的hello进程回到前台并恢复运行,直到运行结束,进程被回收。
在进程运行时按下Ctrl-C,会向内核发送一个SIGINT信号,随即系统将该进程终止。
图56 运行时按下Ctrl-C
在进程运行的过程中通过键盘输入文本,这些文本暂时没有得到响应。当hello进程结束后,shell开始尝试运行这些输入的文本。由此可见,在hello进程运行的过程中,输入的信息会被保存在输入缓冲区,直到返回shell进程后缓冲区中的内容将被释放到shell的输入中。
图57 运行时乱按
本章主要介绍了使用fork函数创建子进程以及使用execve函数加载并运行程序的详细过程,并阐述了shell在工作时的处理流程。然后,我们还探究了上下文切换、时间片、进程调度以及内核的用户态和核心态的概念。最后,在hello程序的实际运行过程中,直观地观察了进程的运行和切换规则以及一些异常和信号的处理过程。
(第6章1分)
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址,在计算机体系结构中是指应用程序角度看到的内存单元(memory cell)、存储单元(storage element)、网络主机(network host)的地址。存储单元的地址可以用段基址(段地址)和段内偏移量(偏移地址)来表示,段基址确定它所在的段居于整个存储空间的位置,偏移量确定它在段内的位置,这种地址表示方式称为逻辑地址,通常表示为段地址:偏移地址的形式。逻辑地址往往不同于物理地址(physical address),通过地址翻译器(address translator)或映射函数可以把逻辑地址转化为物理地址。
逻辑地址指的是地址中的段偏移地址部分,而线性地址指的是“段基址+段内偏移量”的这个整体地址。
比如我们的hello程序在运行过程中,所使用的内存空间是虚拟内存,这段内存被分成了不同的段。我们的程序可以在虚拟内存中通过线性地址的段基址部分找到相应段的起始位置,然后使用逻辑地址即段偏移地址找到对应数据的存放位置。
物理地址(英语:physical address),也叫实地址(real address)、二进制地址(binary address),它是在地址总线上,以电子形式存在的,使得数据总线可以访问主存的某个特定存储单元的内存地址。
如果处理器启用了MMU,CPU执行单元发出的内存地址将被MMU截获,从CPU到MMU的地址称为虚拟地址(Virtual Addres),而MMU将这个地址翻译成另一个地址发到CPU芯片的外部地址引脚上,也就是将虚拟地址翻译成物理地址。
如果是32位处理器,则内地址总线是32位的,与CPU执行单元相连,而经过MMU转换之后的外地址总线则不一定是32位的。也就是说,虚拟地址空间和物理地址空间是独立的,32位处理器的虚拟地址空间是4GB,而物理地址空间既可以大于也可以小于4GB。
在我们的hello程序中,程序是运行在一个虚拟内存空间中,访问的内存地址都是虚拟地址。在实际访问内存空间的过程中,虚拟地址会先经过MMU翻译成物理地址,然后再进行访存。
在段式存储管理中,将程序的地址空间划分为若干个段(segment),这样每个进程有一个二维的地址空间。在前面所介绍的动态分区分配方式中,系统为整个进程分配一个连续的内存空间。而在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
使用段式管理需要维护三个表:
描述组成进程地址空间的各段,可以是指向系统段表中表项的索引。每段有段基址(baseaddress),即段内地址。
图58 系统为每个进程创建的段映射表
系统所有占用段(已经分配的段)。
内存中所有空闲段,可以结合到系统段表中。
操作系统中存在着多种段寄存器,如CS(代码段寄存器)、DS(数据段寄存器)、SS(堆栈段寄存器)等。段寄存器共有16位,存放着段选择码:
图59 段寄存器的16位信息
内存的访问权限保存在全局段描述符表(GDT)和局部段描述符表(LGT)中。这两个表的表头地址存放在专门的表地址寄存器GDTR和LGTR当中。GDT的大致结构如下:
图60 GDT的结构
段寄存器DS/CS/SS里的高13位存放段选择码,接着从第14位确定是找GDT还是LDT,第14位为1,也就是找的GDT。从GDTR寄存器找到GDT,获得起始地址(起始地址为GDT[DS>>3].Addr),获得起始地址之后加上IP长度,看有没有超过最大值,如果没有,就获得了线性地址。最大值是1M或者4G,具体是什么,要看G的值。这个就是逻辑地址到线性地址映射的过程。
处理器查找内存中的段表,把段号作为索引找到段的首地址,将这个首地址与段内地址相加就得到了实际的物理地址。
图61 段式管理最终得到物理地址
将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内 存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
图62 页式管理
首先在被调进程的PCB中,取出页表的起始地址和页表大小,存入页表寄存器。然后将页号与页表寄存器的页表长度比较,若页号不小于页表长度,发生地址越界中断,停止调用,否则继续执行。由页号和页表的起始地址可以求出块号。最后,将块号和页内地址拼接起来,就可以得到物理地址。
图63 线性地址到物理地址的变换
以Core i7地址翻译为例。Core i7支持48位虚拟地空间和52位物理地址空间,且页表大小为4KB(每个表项大小为8B,因此共有512各表项)。在每级页表中,512个表项共需要9位地址来表示其索引。所以,VA的高36位被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址,VPN1提供一个到L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供一个L2 PTE的偏移量,以此类推。最后,L4的一个PTE被取出,这个PTE就是物理页号(PPN)。48位的虚拟地址中还剩低12位的虚拟页偏移(VPO),将这12位数据拼接在得到的PPN的低位后面,作为物理页偏移量(PPO)。得到的这个52位的地址,就是VA对应的PA。
图64 Core i7中VA到PA变换的过程图示
依然以Core i7为例。高速缓存L1、L2和L3是物理寻址的,块大小为64字节。L1和L2是8路组相联的,而L3是16路组相联的。在得到52位的物理地址后,先在L1中寻址。L1共有64组。将PA的低6位作为高速缓存的块偏移(CO),再取出6位作为其组索引(CI),剩余的40位作为标记位(CT)。使用CI找到L1中对应的组,在这组中,若有一行的标记位与CT相同,且有效位为1,则缓存命中,使用CO取出块中对应的字节即可;否则缓存不命中,需要依次到L2、L3甚至是主存中继续查找。
在L2和L3中查找的过程大体和L1相同,区别在于L2和L3的组数可能与L1不同,即CI的位数可能不同。
图65 Core i7中三级Cache下的物理内存访问
shell程序调用fork函数为将要运行的hello程序创建一个子进程。当fork函数被运行shell的进程调用时,内核为创建的新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记位私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任意一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有地址空间的抽象概念。
shell在创建的子进程中加载并执行hello程序时,调用以下函数:
execve(“hello”, myargv, NULL);
用hello程序有效地替代了当前程序。加载并运行hello程序时的步骤如下:
删除当前进程虚拟地址的用户部分中的已存在的区域结构。
为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
hello程序与共享对象(或目标)比如标准C库libc.so链接,这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
图66 加载器对用户地址空间进行映射
页面命中完全是由硬件来处理的,但如果发生缺页故障,处理缺页要求硬件和操作系统内核协作完成。以下是具体步骤:
图67 缺页中断处理过程示意图
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
而动态存储分配管理的过程中,一个实际的分配器必须考虑好以下几个问题:
隐式空闲链表中的每个块由头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。
图68 隐式空闲链表
在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,将空闲块的主体连为一个双向链表。
图69 双向空闲链表的堆块的格式
首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。
从上一次查询结束的地方开始搜索空闲链表,选择第一个合适的空闲块。
检查每个空闲块,选择适合所请求大小的空闲块。
每次一个块被释放时,就合并所有的相邻块。
等到某个稍晚的时候再合并空闲块。
本章分析了hello的存储器地址空间,阐述了Intel的段式管理和页式管理方式,并介绍了程序运行时取值利用四级页表将虚拟地址VA转换成物理地址PA的过程和使用三级Cache访存的过程。本章还介绍了在调用fork函数创建子进程和调用execve函数加载并运行程序时,内存映射的工作状态。针对访存时可能引发的缺页故障,我们介绍了缺页中断处理机制。最后,还对动态存储分配管理的基本方法与各种策略进行了介绍。
(第7章 2分)
设备的模型化:文件
设备管理:unix io接口
所有IO设备都模型化为文件,所有的输入输出都可以当作相应文件的读和写,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
函数原型:
int open(const char* path, int oflag, int perms);
函数参数:
path为要打开的文件的文件路径,oflag标记了文件的打开模式。
函数功能:
尝试打开一个文件若文件打开成功,则返回文件描述符;若打开失败则返回-1。
函数原型:
int close(int fd);
函数参数:
fd是待关闭文件的文件描述符。
函数功能:
关闭指定文件描述符的文件,关闭文件时还会释放该进程加在该文件上的所有的记录锁。
函数原型:
ssize_t read(int fd, void *buf, size_t nbytes);(需引入unistd.h库文件)
函数参数:
fd是待读取文件的文件描述符,buf是读取数据时的缓冲区,nbytes是将要读取的字节数。
函数功能:
从指定文件中读数据至缓冲区中。若读取成功,若读到EOF则返回0,否则返回读入的字节数;若读取失败,返回-1。
函数原型:
ssize_t write(int fd, const void* buf, size_t ntyes);(需引入unistd.h库文件)
函数参数:
fd是待写入文件的文件描述符,buf是写入数据的缓冲区,nbytes是将要写入的字节数。
函数功能:
将缓冲区中的数据写入到指定文件。若写入成功,返回写入的字节数;若写入失败,返回-1。
函数原型:
int lseek(int fd, off_t offset, int whence);
函数参数:
fd是将要打开文件的文件描述符,offset是打开文件时的偏移量,whence是模式标志变量。若whence为SEEK_SET,则将该文件的偏移量设置为距离当前文件开始处offset字节。若whence为SEEK_CUR,则将该文件的偏移量设置为距离当前偏移量加offset个字节,此时offset可正可负。若whence为SEEK_END,则将该文件的偏移量设置为当前文件长度加offser个字节,此时offset可正可负。
函数功能:
为打开的文件设置偏移量,具体模式取决于whence参数。若成功,返回文件的偏移量;若失败,则返回-1。
https://www.cnblogs.com/pianist/p/3315801.html
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 arg = (va_list)((char*)(&fmt) + 4);
将指针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);
}
该函数根据调用prinf时规定的各种格式fmt(如%x、%s等)用格式字符串对参数进行格式化,将要输出的数据保存在缓冲区buf中,返回buf中的字节数。
返回到printf中后,又调用了write函数,对buf中的数据进行写操作。以下是write函数的汇编代码(这里是Intel风格):
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
可以看到,这里向寄存器eax、ebx和ecx中存入了三个参数。其中,ecx中是要打印出的元素个数,ebx中的是要打印的buf字符数组中的第一个元素。
然后,int INT_VECTOR_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
该函数的功能主要是显示格式化了的字符串。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
#define getchar() getc(stdin)。
getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
本章主要介绍了Linux下的I/O设备管理方法、Unix I/O接口以及相应的I/O函数。本章还剖析了printf函数和getchar函数的底层实现方法,这些都是平时编程经常用到的函数,我们却很少在意其实现原理。对其进行了较为细致的分析之后,我们对其中的细节性原理有了更深刻的认识。
(第8章1分)
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello所经历的过程:
我的感悟:
在整个计算机系统中,下至底层硬件,上至高级语言和程序,每一个功能都是各部分充分协调、密切分工来进行实现的。计算机系统的体系庞大而精妙,这是无数的计算机科学家和工程师代代继承、革新而来的智慧结晶。作为计算机专业的学生,我们不能只会编代码、跑程序,更要明白其中的诸多原理和实现方法,这才是计算机的魅力和精髓所在
(结论0分,缺失 -1分,根据内容酌情加分)
文件名 |
作用 |
hello.i |
预处理得到的文件 |
hello.s |
编译得到的文件 |
hello.o |
汇编得到的可重定位文件 |
hello.d |
对hello.o使用objdump得到的反汇编文件 |
hello |
链接后得到的可执行文件 |
hello_dump.d |
对hello使用objdump得到的反汇编文件 |
elf.txt |
对hello.o使用readelf生成的elf文件 |
hello_elf.txt |
对hello使用readelf生成的elf文件 |
(附件0分,缺失 -1分)
(参考文献0分,缺失 -1分)