计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机学院
学 号
班 级
学 生
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
目 录
第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.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 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
下图描述了hello从c文件到最终生成可执行文件的大致过程。
文字流程说明如下:
程序员通过编辑器创建并保存的文件(名为hello.c)
è预处理器cpp的预处理(生成hello.i文件)
è编译器ccl的编译(生成hello.s文件)
è汇编器as的汇编(生成hello.o文件)
è最后使用链接器ld进行链接(生成可执行目标程序hello)。
020过程如下:
①在Shell中输入./hello[enter]。Shell会有两个动作:
1.通过一系列指令的调用将输入的字符读入到寄存器中,然后将Hello的代码和数据从磁盘转移到主存。
2.Shell会调用fork函数创建一个新的进程,保存上下文,将控制权交给这个新的进程。
②在hello加载完成后,这个程序交由处理器开始执行。
先翻译成机器语言è再翻译成指令编码è最后调用printf等进行链接。代码段 .text和数据段 .data被初始化为hello目标文件的内容。
加载器会从_start的地址开始,跳转到main 函数的地址,之后进入 main 函数执行目标代码,CPU 为运行的 hello 分配时间片执行逻辑控制流。
③执行阶段会把程序分解成几个阶段,分别执行对应的指令,最后输出字符串。输出的字符串先从主存复制到寄存器文件,再从寄存器文件复制到显示设备,最终显示到屏幕上。
④进程终止后,父进程回收这个进程,操作系统恢复Shell的上下文,控制权重回Shell,由Shell等待接受下一个指令的输入。
什么是P2P和020?
P2P: 即From Program to Process。P2P是Linux中,hello.c经过预处理器的预处理、编译器的编译、汇编器的汇编、链接器的链接,最终成为可执行目标程序hello,在Shell中键入启动命令后,Shell为其fork产生子进程的过程。
020:是execve在当前进程中载入并运行一个新程序,映射虚拟内存,程序开始载入物理内存,进入 main函数执行目标代码,CPU为运行的程序分配时间片执行逻辑控制流,等待程序运行结束后,Shell父进程负责回收hello进程,内核删除相关数据结构的过程。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:AMD Ryzen 7 4800U with Radeon Graphics
软件环境:Windows 11,Oracle VM VirtualBox,Ubuntu 20.04
开发与调试工具:gcc,as,ld,gdb,edb,readelf,codeblocks
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 文件作用 |
hello | 链接之后的可执行目标文件 |
hello.c | 源文件 |
hello.elf | hello的ELF格式 |
hello.i | 预处理产生的文本文件 |
hello.o | 汇编产生的可重定位目标文件 |
hello.objdmp | hello的反汇编代码 |
hello.s | 编译产生的汇编文件 |
hello.txt | hello.o的ELF格式 |
1.4 本章小结
本章主要简单介绍了hello的P2P,020过程,并且简要介绍了P2P和020是什么。在本章的最后,按照要求列出了本次实验信息、环境、中间结果,和该篇论文完成所需要生成的一些中间文件。
第2章 预处理
2.1 预处理的概念与作用
预处理的基本概念: 程序编译之前,预处理程序cpp进行预处理的过程。在这个过程中hello.c程序会被预处理为hello.i。
在C语言中常见的编译程序需要进行预处理的程序指令主要有:#define(宏定义),#include(源文件包含),#error(错误的指令)等。
预处理可以帮助一个程序可以在不同环境中被各种编译器方便的进行编译。预处理的主要作用如下:
(1):删除“#define”并展开所定义的宏;
(2):处理所有条件预编译指令;
(3):插入头文件到“#include”处,这个过程往往是递归的(插入头文件,头文件还有需要插入的头文件,故称之为递归)
(4):删除所有的注释;
(5):添加行号和文件名标识,以便编译时编译器产生调试用的行号信息。
因为编译器需要用,所以会保留所有#pragma编译指令。
2.2在Ubuntu下预处理的命令
命令:cpp hello.c > hello.i
2.3 Hello的预处理结果解析
这是原本的.c文件
这是预处理后的.i文件
打开.i文件,可以发现文件变成非常多行,hello.c的代码则在最后才出现,这是由于stdio.h,unistd.h,stdlib.h都进行了展开。我们打开/usr/include/stdio.h 寻找stdio.h,会发现其中依然使用了#define语句,如此递归展开。而且针对#开头的条件编译语句,预处理器会根据#if后面的条件决定需要编译的代码。
2.4 本章小结
本章主要介绍了预处理的定义与作用,并结合预处理之后的程序hello.i(有什么变化?为什么变长?)对预处理的过程和结果进行了解析。预处理是将程序在编译之前进行的一步处理,实则是一个通过宏展开、宏替换、插入头文件等操作,使得程序中的宏引用被递归地替换掉的过程。最后我们可以看到,程序从hello.c到hello.i。生成的程序hello.i还是一个可读的(文本)文件。
第3章 编译
3.1 编译的概念与作用
编译的概念:(广义的编译是说将某一种程序设计语言写的程序翻译成等价的另一种语言)这里指的编译,是把高级语言文本程序翻译成等价的汇编语言文本程序。此处的编译指的是:编译器把预处理后的 .i文件进行一系列语法分析及优化后生成相应的汇编语言文件.s文件的过程。
编译的作用:编译是把高级语言转化为机器二进制代码十分重要的一环。在编译之后,高级语言翻译成了更接近机器语言的汇编语言,使生成过程更加方便顺畅。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1.文件声明:
先介绍hello.s文件开头的部分:
.file:声明源文件(“hello.c”)
.text:代码节
.section: 区段
.rodata:只读代码段
.align:数据或者指令的地址对齐方式(8)
.string:声明一个字符串(.LC0,.LC1)
.global:声明全局变量(main)
.type:声明一个符号是数据类型还是函数类型(main—function)
3.3.2.数据:
hello.s文件中的主要数据类型有:整型、字符串、指针数组。
一、整型
(1)main的参数argc:
argc:表示输入的参数个数,存储在寄存器%edi中,然后又通过movl -20(%rbp)指令被存入-20(%rbp)。
(2)局部变量int i
函数内部的局部变量i会被编译器存储在寄存器或者程序栈中。如同上面的函数内部的局部变量没有标识符,也不需要被声明,直接使用即可。
- movl $0, -4(%rbp)
通过上句,可知i被存储在-4(%rbp)中,初始化为0。
二、字符串
在hello.c中使用的字符串类型,是函数根据参数的数量决定打印到屏幕上的字符串。如果输入./hello xxa xxb n,就隔n秒输出Hello xxa xxb。其他情况则输出原定好的字符串。看看下面的.LC0和.LC1,下面分别说明它们是什么意思:
这两个字符串的信息都被放在了.rodata节中 。
LC0表示的是argc(也即输入参数)不符合要求的输出:.LC0中包含汉字,每个汉字在utf-8编码中被编码为三个字节,这些函数表示过来应如下图。
(用法: Hello 学号 姓名 秒数!)
LC1表示的是argc符合要求的输出:Hello xxa xxb(每n秒输出一次,输出10次)
三:指针数组
argv[]作为参数,是命令行中输入的字符串。argv的首地址被存放在寄存器%rsi(按照顺序的第二个参数所用的寄存器,六个参数存放的顺序应是%rdi,%rsi,%rdx,%rsx,%r8,%r9)中,后来被存放在栈中空出寄存器,便于函数调用。如下图所示,用黑框框起的几个调用:
3.3.3.运算与操作:
(1)赋值
e.g. 在循环开始时将i赋值为0:movl $0,-4(%rbp)
(2)算术计算:
编译器将 i++ 翻译成addl $1
,
-4(%rbp)
。如图所示:
(3)关系操作
在hello.c中有两个地方用到了关系操作:
1. 判断argc的大小,因为argc是main函数的第一个参数,所以,他应该存放在%edi中,且结合前面的分析可知它又被存入[%rbp-20]中(也即-20(%rbp))。
在这里,它判断argc是否为4,若相等,就跳转到.L2。
2. 在for循环中,每次都会比较i的大小来判断循环是否结束。 jle .L4比较i和7的大小,如果小于等于(jle)就跳转,所以循环的条件是:i<=7
(4)数组/指针/结构操作
指针数组(char *argv[]):argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别指向我们从Shell终端输入的字符串。
通过addq $8,%rax addq $16,%rax addq $24,%rax分别得到argv[1]和argv[2] 和argv[3],如下图所示:
3.3.4.控制转移:
在程序中控制转移有两处:
(1)if(argc!=4)
下面截图所示的这句是为了根据参数argc的值决定是跳转还是继续执行。语句cmpl $4,-20(%rbp) 比较-20(%rbp)中的内容与4,并根据条件码选择跳转。
通过 je .L2 可知,如果argc==4,那么程序就往下顺序执行,否则就跳转到.L2。
条件码跳转表如图所示,其中条件中的ZF,SF,OF,CF是根据cmpl b,a 中计算 a-b的方式来得到的:
(2)for(i=0;i<=7;i++)
首先[%rbp-4]=i=0<7跳转到位于循环体.L4。在函数.L4中运行的结尾,addl $1,-4(%rbq) ,也即i++。这个循环中,如果i<=7,则跳转到.L4进行循环,否则顺序往下进行其他操作。
3.3.5.函数调用:
函数跳转到特定位置,执行调用的相应函数之后再返回来实现功能。调用函数一般是通过栈来实现的。
函数调用可分为如下过程(被调用者callee,调用者caller):
1,传递参数给被调用者callee:
参数传递在64位栈结构中是通过寄存器和内存共同实现的,按照:%rdi,%rsi,%rdx,%rcx,%r8,%r9的六个参数,按序传递。若有超过六个参数的,如第七个参数会放在调用者栈结构中。
2,call调用函数:
紧随call指令的下一条指令的地址。call指令会将返回地址压入栈中,并且将%rip的值设置为指向所调用函数的地址(等函数执行完之后调用ret弹出原来的%rip并且将栈帧结构恢复)。
3,函数进行操作:
函数在自己栈帧内进行操作,返回值存入%rax寄存器。
4,函数返回:
函数返回时,如果有返回值,则先将返回值存在%rax中,再用leave和ret等操作返回,控制权还给调用函数。
hello.c文件中调用的函数有:main()、printf()、atoi()、getchar()、exit()、sleep()。
下面对这些函数进行一一解析。
(1)main函数
参数传递:第一个参数是argc(类型int),第二个参数是argv[](类型char *),分别存放在寄存器%rdi和%rsi中;
Call调用:main函数被系统函数__libc_start_main调用,call指令将main函数的地址分配给%rip,随后调用main函数。
函数操作中的栈维护:main函数使用栈指针,同时使用栈帧%rbp来记录使用情况。main函数在进入时先将%rsp减去32产生栈空间。subq $32,%rsp,然后开始进行下面的其他操作。
函数返回:movl $0,%eax可以看到main函数的尾部将0压入到%eax中,然后调用了leave平衡栈帧,调用ret返回退出。
(2)printf函数
参数传递:call puts时只传入了argv的首地址。for循环中call printf@PLT时传入了 argv[1]和argv[2]的地址;
函数调用:第一个printf()由call puts@PLT调用,第二个printf()由call printf@PLT调用;
函数返回:从printf中返回。
(3):exit函数
参数传递:movl $1,%edi将1传给了%edi,完成参数传递。
函数调用:通过call exit@PLT函数,进行函数调用。
函数返回:从exit返回。
(4):sleep函数
参数传递:movl %eax,%edi将atoi的返回值%eax通过%rdi传递给sleep函数
Call调用:call sleep@PLT调用了sleep函数,将控制传送。
函数返回:从sleep中返回。
(5):atoi函数
参数传递:movq %rax,%rdi将argv[3](字符串)通过%rdi传递给atoi函数。
函数调用:通过call atoi@PLT函数,进行函数调用。
函数返回:从atoi中返回。
(6):getchar函数
参数传递:无;
函数调用:call getchar@PLT调用getchar函数;
函数返回:从getchar中返回
3.4 本章小结
本章主要说明了编译器是如何处理C语言的各个数据类型以及各类操作的,大体上是通过结合hello.c到hello.s汇编代码之间的映射关系来说明这个编译的过程,并详细解析汇编代码的逻辑关系,作出合理解释。可以看到,我们的编译器ccl将hello.i这个文本文件编译成了hello.s里的汇编代码。经过编译之后,我们的hello自C语言转换为了更加低级的汇编语言。
第4章 汇编
4.1 汇编的概念与作用
概念:将.s汇编程序翻译成机器语言,把这些机器语言指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,这个过程就叫做汇编。
作用:翻译生成机器语言,机器语言是计算机能直接识别和执行的一种语言。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
下面我们使用readelf获得hello.o的elf格式,命令readelf -a hello.o > hello.elf
。得到hello.elf
现在我们来分析ELF格式:
4.3.1:ELF头
ELF头部以一个16字节的序列(7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00)开始,描述生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器分析语法和解释目标文件的信息。
4.3.2:节头部表
节头部表描述了不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。具体的描述包括节的名称、类型、地址和偏移量等,如下图所示:
4.3.3:重定位条目
当as生成一个目标模块时,并不知道数据和代码最终将放在内存中的什么位置,也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。于是在 as遇到对最终位置未知的目标引用时,就会生成一个重定位条目,告诉ld在将目标文件合并成可执行目标文件时修改这个引用。
重定位条目和汇编后的机器代码都在可重定位目标(.o)文件中。
指令中引用的重定位条目放在.rela.text中,数据引用的重定位条目放在.rel_data中。
typedef struct{
long offset; /*需要被修改的引用的节偏移*/
long type:32, /*重定位类型*/
symbol:32; /*标识被修改引用应该指向的符号*/
long attend; /*符号常数,对修改引用的值做偏移调整*/
}Elf64_Rela;
e.g. IA32中两种最基本的重定位类型:
R_X86_PC32 :重定位一个使用32位PC相对地址的引用。
R_X86_32 :重定位一个使用32位PC绝对地址的引用。
4.3.4:符号表
.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
4.4 Hello.o的结果解析
接下来用objdump -d -r hello.o
分析hello.o的反汇编代码,并和hello.s对比。
上图是hello.o的反汇编代码
上图是hello.s文件
经过对比,我们可以发现不同之处大致一下几点:
(1)操作数:
hello.s中的操作数时十进制,hello.o反汇编代码中的操作数是十六进制。(20H=2*16=32)
上图是.s文件中的
.o文件的反汇编代码中则表示为$Ox20
(2)分支转移:
跳转语句之后,hello.s中是.L2和.L3等段名称,而反汇编代码中跳转指令之后是相对偏移的地址。(.L3è80 <main+0x80>)
上图是.s文件中的
上图是.o文件的反汇编代码中的
(3)函数调用:
hello.s中,call指令之后是函数名称。因为函数只有在链接之后才能确定运行执行的地址,在.rela.text节中添加了重定位条目,所以hello.o中call指令之后是相对偏移地址。
(4)指令:
汇编中mov、push、sub等指令都有表示操作数大小的后缀(b : 1字节、w :2 字节、l :4 字节、q :8字节),反汇编得到的代码中则没有。
汇编代码中有很多“.”开头的伪指令用来指导汇编器和链接器工作,反汇编得到的代码中则没有。
4.5 本章小结
本章节我们查看并总结了hello.o的elf文件格式的信息。通过查看反汇编代码,对比hello.s,看到了在函数调用、条件转移等过程中的区别。可以发现,编译之后的汇编将我们的程序向能够在计算机上运行的方向又推进了一大步。在汇编这步中,我们使其变成一个可重定向文件,这时候就可以进行下一步的链接了。
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
作用:当程序调用函数库(如标准C库,libc.a)中的一个函数printf,printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,链接器会将这个文件合并到hello.o程序中。在许多的链接处理之后,得到一个可执行目标文件hello,这是一个由系统执行、可以被加载到内存中的文件。由此可知,链接器扮演着一个关键的角色,它使得分离编译成为可能。
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
可见,我们现在得到了可执行文件hello
5.3 可执行目标文件hello的格式
我们先生成hello的ELF格式文件:
命令:readelf -a hello > hello1.elf
我们现在开始分析:
5.3.1.ELF头:
如下图所示,ELF头描述文件的总体格式。它还包括程序的入口点,即程序运行时要执行的第一条指令的地址。
5.3.2.节头:
Section Headers对hello中所有的节信息进行了声明,其中包括大小Size以及在程序中的偏移量Offset。根据节头中的信息我们就可以用定位各个节所占的区间(起始位置,大小)。
具体信息如下图:
5.3.3.程序头:
一共有8个段
(1)PHDR:包含程序头表本身
(2)INTERP:只包含了一个section,在这个节中,包含了动态链接过程中所使用的解释器路径和名称。【可以看到Requesting program interpret:/lib64/ld-linux-x86-64.so.2的字样】
(3)两个LOAD:第一个是代码段,第二个是数据段。在程序运行时需要映射到虚拟空间地址。
(4)DYNAMIC:保存了由动态链接器使用的信息。
(5)NOTE: 保存了辅助信息。
(6)GNU_STACK:堆栈段。
(7)GNU_RELRO:在重定位之后哪些内存区域需要设置只读。
5.3.4.段节:
5.3.5.重定位节:
5.4 hello的虚拟地址空间
用edb加载hello程序后,我们可以在Data Dump里看到hello的虚拟地址空间。
可见ELF被映射到了0x400000。
其余各段依据前面给出的程序头表依次映射,即。
5.5 链接的重定位过程分析
5.5.1 hello与hello.o的反汇编代码比较:
先objdump -d -r hello
查看hello的反汇编代码:
上面就是hello的反汇编代码
将它与hello.o文件的反汇编代码进行比较后,可得下面几处不同:
(1)地址的访问:
可以看到,hello.o中的相对地址到了hello中变成了虚拟内存地址。而hello.o文件中对于.rodata(只读数据)的访问,$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位。
(2)链接增加新的函数:在hello中链接入了在hello.c中用到的函数,如exit、sleep、printf、getchar等函数。
(3)增加的节:增加了.init和.plt节,和一些节中定义的函数。
(4)函数调用:
hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
5.5.2 hello的重定位过程分析:
重定位过程合并输入模块,并为每个符号分配运行时地址,将多个单独的代码节和数据节合并为单个节,将符号从它们的在.o文件的相对位置重新定位到可执行文件的最终绝对内存位置,更新所有对这些符号的引用来反映它们的新位置.
详细说明:
1.重定位节和符号定义。在这一步中,链接器将所有相同类型的节聚合为同一类型的新的节。比如所有的.data节被全部合并成一个节,这个节成为输出的可执行目标文件hello中的.data节;所有的.text节被全部合并成一个节,等等。
2.然后是将符号从它们的在.o文件的相对位置重新定位到可执行文件的最终绝对内存位置。连接器将运行时内存地址赋给新的聚合节,输入模块定义的每个节,以及输入模块定义的每个符号。当这一步完成时,hello中每条指令和包括.rodata、sleepsecs等全局变量都有唯一的运行时内存地址了。
3.重定位节中的符号引用。链接器依赖于hello.o中的重定位条目,修改代码节和数据节中对每个符号的引用,使得它们指向正确运行时的地址。
5.6 hello的执行流程
在edb中执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
利用edb的symbol以及debug查看:
名称 | 地址 |
ld-2.31.so!_dl_start | 0x7ffee5aca680 |
ld-2.31.so!_dl_init | 0x7f9f48629630 |
hello!_start | 0x0x4010f0 |
ld-2.31.so!_libc_start_main | 0x7f9f48249ab0 |
hello!printf@plt | 0x401040 |
hello!sleep@plt | 0x401080 |
hello!getchar@plt | 0x401050 |
libc-2.31.so!exit | 0x7fd6cd96fa40 |
5.7 Hello的动态链接分析
5.7.1 GOT表:
概念:每一个外部定义的符号在全局偏移表(Global offset Table)中有相应的条目,GOT位于ELF的数据段中,叫做GOT段。
作用:GOT把位置无关的地址计算重定位到一个绝对地址。程序首次调用某个库函数时,运行时连接编辑器(rtld)找到相应的符号,并将它重定位到GOT。之后每次调用这个函数都会将控制权直接转向那个位置,而不再调用rtld。
5.7.2 结果分析:
程序调用一个由共享库定义的函数,编译器没有办法预测函数的运行时地址,动态链接器在程序加载时再解析它,称为延迟绑定。
在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码,GOT存放的是PLT中函数调用指令的下一条指令地址
在函数调用时,首先跳转到PLT执行.plt中操作,第一次访问跳转时GOT地址为下一条指令,将函数序号入栈,然后跳转到PLT[0],之后将重定位表地址入栈,访问动态链接器,在动态链接器中使用在栈里保存的函数序号和重定位表计算函数运行时的地址,重写GOT,返回调用函数.之后如果还有对该函数的访问,就不用执行第二次跳转,直接参看GOT信息.
5.8 本章小结
本章主要介绍了链接的概念与作用、hello的ELF格式,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。ld在软件开发中扮演着一个关键的角色,因为它使得分离编译成为可能。当分离编译时,我们可以考虑把程序分解为更小、更好管理的模块,可以独立地修改和编译。这有助于我们解耦合的编程思想的思想。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程就是一个执行中的程序实例。每次用户通过向Shell 输入一个可执行目标文件的名字,并运行程序时, Shell 就会创建一个新的进程。
作用:进程为用户提供以下假象:程序好像是系统中当前运行的唯一程序一样,独占地使用处理器和内存,CPU仿佛无间断的执行程序中的指令,程序中的代码和数据仿佛是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个应用程序,在操作系统中提供了一个用户与系统内核进行交互的界面。处理过程一般是这样的:
1)从终端读入输入的命令。(例如:./hello[enter])
2)将输入字符串切分获得所有的参数
3)如果是内置命令则立即执行
4)否则调用相应的程序为其分配子进程并运行
5)Shell应该接受键盘输入信号(ctrl+c,ctrl+z),并对这些信号进行相应处理
6.3 Hello的fork进程创建过程
Shell(父进程)通过fork 函数创建一个新的运行的子进程。新的子进程与父进程十分相似(最大区别是PID),子进程有父进程虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程进程还获得与父进程任何打开文件描述符相同的副本。
6.4 Hello的execve过程
int execve(char *filename, char *argv[], char *envp[]);
execve函数在当前进程的上下文中加载并运行一个程序。加载并运行可执行目标文件filename(char *), 且带参数列表argv (char *)和环境变量列表envp (char *)。只有当出现错误时,例如找不到filename时, execve 才会返回到调用程序。fork 一次调用返回两次不同, 而execve 调用一次并从不返回。
execve会删除已存在的用户区域–>映射私有区域–>映射共享区域–>设置程序计数器。
- 调用驻留在内存中的被称为启动加载器的操作系统代码来执行程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。
- 新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。
- 最后加载器设置PC指向_start地址,_start最终调用main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。
- 直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
6.5 Hello的进程执行
首先介绍进程逻辑流、上下文信息、进程时间片、用户模式和内核模式等基本概念:
- 逻辑控制流:每个进程是个逻辑控制流。对于单处理器系统,进程会轮流使用处理器,即处理器的物理控制流由多个逻辑控制流组成。对于确定的数据集,某进程指令执行地址序列是确定的,称为进程的逻辑控制流。
- 时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
- 用户模式:用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;
- 内核模式:可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
- 上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态。
hello sleep进程调度的过程:调用sleep之前,若hello程序不被抢占则顺序执行,若发生被抢占的情况,则进行上下文切换,并进行如下操作:
(1)保存以前进程的上下文
(2)恢复新恢复进程被保存的上下文
(3)将控制传递给这个新恢复的进程 ,来完成上下文切换。
如上图所示:
①hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式è②内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时发送一个中断信号è③此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
6.6 hello的异常与信号处理
6.6.1 异常的分类
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
6.6.2 处理方法:
(1)中断处理:中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序。
(2)陷阱处理:陷阱是有意的,是执行一条指令后的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
(3)故障 (Faults):不是有意的,但可能被修复。Examples: 缺页(可恢复),保护故障(protection faults,不可恢复), 浮点异常(floating point exceptions,不恢复)。处理程序要么重新执行引起故障的指令(已修复),要么终止。
(4)终止处理:终止是不可恢复的致命错误造成的结果,Examples: 非法指令,奇偶校验错误(parity error),机器检查(machine check)。会中止当前程序
6.6.3:执行情况
下面展示hello进程面对的各种情况:
(1)正常执行。下图所示hello程序正常运行的结果,输入命令ps后发现程序后台并没有hello进程正在执行了,说明进程正常结束,已经被回收了。(由于原被告中出现姓名,所以这边进行了重新截图的处理)
(2)不停乱按:结果和前面的相同,不同之处在于Shell将乱输入的字符除了第一个回车按下之前的字符当做getchar的输入之外,其余都当做新的Shell命令,在hello进程结束被回收之后,将会在命令行中尝试解释这些命令。中间没有任何对于进程产生影响的信号被产生。
(第一个回车按下后没有字符输入的情况)
(第一个回车按下后仍有字符输入的情况)
(3)运行CTRL+Z:运行中按CTRL+Z之后,将会发送一个SIGTSTP信号给Shell。然后Shell将转发给当前执行的前台进程组,使hello进程挂起。
此时,我们输入ps命令,查看当前存在的进程
输入jobs命令:
输入pstree命令:以树状图显示进程间的关系
使用fg命令:将hello程序重新运行一次,使用CTRL+Z使其挂起,使用fg指令完成剩下的执行。
使用kill命令:运行hello程序,将其挂起一次,使用kill函数杀死它。
CTRL+C命令:在hello程序运行时输入CTRL+C会导致内核发送一个SIGINT信号到前台进程组的每个进程。默认情况下,结果是终止前台作业。
6.7本章小结
本章介绍了进程管理的一些简要信息,比如进程的概念作用,Shell的基本原理,Shell调用fork和execve,进程在执行时会遇到不同情况的不同反应(包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z,运行ps jobs pstree fg kill 等命令的处理)。还介绍了一些常见异常和其信号处理方法。在面对执行时的多样的复杂的环境,hello已经不能再单纯了。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序代码经过编译后出现在汇编程序中地址。逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。
线性地址(虚拟地址):
- 逻辑地址经过段机制后转化为线性地址(虚拟地址),是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
- 如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址;如果没有启用分页机制,那么线性地址直接就是物理地址。
物理地址:
- CPU地址总线传来的地址,由硬件电路控制(硬件是可编程的)
- 一个使用物理寻址的系统:在没有使用虚拟存储器的机器上,虚拟地址被直接送到内存总线上,使具有相同地址的物理存储器被读写;
- 一个使用虚拟寻址的系统:在使用了虚拟存储器的情况下,虚拟地址送到存储器管理单元MMU,把虚拟地址映射为物理地址。
在hello程序中,就表示为这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符、段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如下图:
索引号,是“段描述符”段描述符具体地址描述了一个段。段描述符表是很多个段描述符,就组成的数组。
通过段标识符的前13位,可以直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,由8个字节组成,如下图:
Base字段:它描述了一个段的开始位置的线性地址。
全局的段描述符,放在“全局段描述符表(GDT)”中;
局部的段描述符,放在“局部段描述符表(LDT)”中。
当段选择符中的T1字段=0,表示用GDT;若为1,表示用LDT。 GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。如下图:
首先,给定一个完整的逻辑地址 [段选择符:段内偏移地址]:
1、先看段选择符的T1=0还是1,就知道当前要转换是GDT中的段,还是LDT中的段。根据相应寄存器,得到其地址和大小。
2、再拿出段选择符中前13位,查找到对应的段描述符,得到基地址Base。
3、Base + offset=要转换的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理: 虚拟地址è物理地址
Linux系统的虚拟内存系统,如下图:
Linux将虚拟内存组织成一些段的集合。
- task_struct是内核为hello进程维护一个段的任务结构,其中条目mm指向一个mm_struct.
- mm_struct.描述了虚拟内存的当前状态;pgd指向第一级页表的基地址(结合一个进程一串页表);
- mmap指向一个vm_area_struct的链表。
- vm_area_struct一个条目对应一个段,链表相连指出了hello进程虚拟内存中的所有段。
分配内存时,帧是分配时的最小单位。在虚拟内存中,与帧对应的概念就是页(Page)。线性地址的表示方式是:【虚拟页号(VPN):虚拟页偏移(VPO)】
利用页表,通过MMU来完成从虚拟地址到物理地址的转换。
PTBR是cpu里的一个控制寄存器,指向当前页表,n位的虚拟地址包括p位的虚拟页面偏移VPO和n-p位的虚拟页号VPN。MMU通过VPN来选择适当的PTE,将页表条目中的PPN(物理页号)和虚拟地址的VPO串联起来,就得到相应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
CPU每传来一个虚拟地址,MMU就需要查询一次PTE(页表)。
如果没有命中,就需要再从内存中取得,这需要花费很多时间,通过TLB(翻译后备缓冲器)能够消除这些开销。
TLB是一个小的,虚拟寻址的缓存,在MMU里,其每一行都保存着一个单个PTE组成的块,TLB通常具有高度相联度。如图,是通过TLB来查询PTE的流程图:
压缩页表的常见方式是使用层次结构的页表。
二级页表: 第一级页表的每个PTE负责一个4MB的块,每个块由1024个连续的页面组成。二级页表每一个PTE负责一个4KB的虚拟地址页面。
64位计算机采用4级页表,36位的VPN被分为4个9位,每个9位视作一个页面的偏移,CR3寄存器包含L1页表的物理地址。
7.5 三级Cache支持下的物理内存访问
现代计算机的高速缓存层次结构如下图所示:
在获得了物理地址VA之后,会使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)。如果匹配成功且块的valid标志位为1,则命中,根据数据偏移量CO(后6位)取出数据返回。如果没有匹配成功或者匹配成功但是标志位是1,则不命中。向下一级缓存中查询数据(L2 CacheèL3 Cacheè主存)。
查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突,采用最近最少使用的策略(Least frequently used)进行替换,也就是替换掉最不经常访问的一次数据
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程分配唯一的PID。
为了给这个新进程创建虚拟内存,创建了当前进程的mm_struct、区域结构和页表的原样副本。将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制,当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。
当两个进程中的任一个来进行写操作时,写时复制就会创建新页面。由此,也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
当我们用./hello运行可执行文件hello时,Shell会先fork创建虚拟内存区域,当这个区域和父进程还是完全一样的,会调用execve(“hello”,NULL,NULL),加载并运行可执行文件hello,有效替换了当前程序,加载并运行hello的步骤如下:
- 删除已存在的用户区域
- 映射私有区域(为hello程序的代码,数据,.bss,栈区域创建新的区域结构),都是私有,写时复制的;
映射共享区域,如我们的hello需要与libc.so动态链接,那么这些对象动态链接到这些程序,然后再映射到用户虚拟地址空间中的共享区域.
7.8 缺页故障与缺页中断处理
第一步先确认是不是一个合法的地址——不断将这个地址与每个区域的vm_start&vm_end进行比对。
第二步确认访问权限是不是正确的——如果这一页是只读页,但是却要做出写这个动作,那明显是不行的。
第三步确认了是合法地址并且是符合权限的访问,那么就用某个特定算法选出一个牺牲页。如果该页被修改了,就将此页调出并且调入那个被访问的页,并将控制传递到触发缺页中断的那条指令,这条指令继续执行的时候就不会触发缺页中断,这样就可以继续执行下去。找到了要存储的页后,内核会从磁盘中将需要访问的内存VP3放入到之前已经操作过的PP3中,并且将PTE中的信息更新,这样就成功的将一个物理地址缓存在了页表中。当异常处理返回的时候,CPU会重新执行访问虚拟内存的操作,这个时候就可以正常的访问,不会发生缺页现象了。
7.9动态存储分配管理
7.9.1 动态内存分配的基本原理:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆在未初始化的数据区域后开始,并向更高的地址生长。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器: 要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如, Java,python之类的高级语言就依赖垃圾收集来释放已分配的块。
7.9.2 简单的放置策略:
(1)首次适配:从头搜索,遇到第一个合适的块就停止;
(2)下次适配:从链表中上一次查询结束的地方开始,遇到下一个合适的块停止;
(3)最佳适配:全部搜索,选择合适的块停止。
7.9.3 两种堆的数据结构组织形式:
- 隐式空闲链表:
块是由一个字的头部,有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小,以及这个块是已分配的还是空闲的。只需要内存大小的29个高位,而用剩余的3位来编码其他信息。所以用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
带边界标签的隐式空闲链表:
在隐式空闲链表堆块的基础上,在每个块的结尾处添加一个脚部,其中脚部是头部的一个副本。
隐式链表的好处是简单,易于操作,但是它的坏处是搜索时间太长了,如果采用首次适配算法的话内存利用率会低,但如果采用最佳适配的话需要对于一整个堆进行搜索。
2. 显式空间链表:
显式空闲链表结构将堆组织成一个双向空闲链表,在每个空闲块的主体中,都包含一个pred(前驱)和succ(后继)指针。使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。
7.10本章小结
本章主要介绍了hello的存储器地址空间、intel的段式管理、页式管理,TLB与四级页表支持下的VA到PA的变换、三级cache支持下物理内存访问, hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容。这部分内容十分重要,同时也很难以理解,需要花费较长时间去消化,但是这是值得的,因为它对应用程序的性能有着巨大的影响。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列:B0,B1,B2……Bm
所有的 IO 设备(如网路、磁盘、终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
- Unix IO接口:
Linux内核给出的一个简单、低级的应用接口,能够以统一且一致的方式执行 I/O操作,包括: 打开和关闭文件、 open() and close()、 读写文件、 read() and write()、 改变当前的文件位置、 指示文件要读写位置的偏移量、 lseek()
打开文件,内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。
读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k
关闭文件。当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中
Linux Shell创建的每个进程开始时都有三个打开的文件:
标准输入 (stdin)
标准输出 (stdout)
标准错误 (stderr)
标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。
- Unix I/O函数
open 函数:打开一个已存在的文件或者创建一个新文件。
函数原型:int open(char *filename, int flags, mode_t mode);
open 函数将filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。
flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
返回:若成功则为新文件描述符,若出错为-1。
close 函数:关闭一个打开的文件。
函数原型:int close(int fd);
返回:若成功则为0, 若出错则为-1。
read:执行输入。
ssize_t read(int fd, void *buf, size_t n);
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。
write :执行输出。
ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。
返回:若成功则为写的字节数,若出错则为-1。
8.3 printf的实现分析
首先看一下函数体:
参数采用了可变参数的定义, *fmt是一个char 类型的指针,指向字符串的起始位置。这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。
(1)printf调用的外部函数vsprintf。
这个函数接受一个格式化的命令,并把指定的匹配的参数格式化输出。
i = vsprintf(buf, fmt, arg);
由此句可知,vsprintf返回的是一个长度,就是要打印出来的字符串的长度。
write(buf, i);
由此句可知,write即写操作,会把buf中的i个元素的值写到终端。
vsprintf的作用就是格式化,接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
(2)write函数:
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
(3)syscall函数
ecx中是要打印出的元素个数 ,ebx中的是要打印的buf字符数组中的第一个元素,这个函数的功能就是不断的打印出字符,直到遇到:’\0’ 停止.
(4)综合分析,printf函数实现过程:
a)vsprintf的作用是格式化,接受确定输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出。
b)vsprintf的输出到write函数中。在Linux下,write通过执行syscall指令实现了对系统服务的调用,从而使内核执行打印操作。内核会通过字符显示子程序,根据传入的ASCII码到字模库读取字符对应的点阵,然后通过vram(显存)对字符串进行输出。
c)显示芯片将按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终实现printf中字符串在屏幕上的输出。
8.4 getchar的实现分析
getchar大致函数体如下图:
int getchar(void){
undefined static char buf[size];
static char* bb=buf;
If(n==0){
undefined n=read(0,buf,size);
bb=buf;
}
return(--n>=0)? (unsigned char) *bb++ :EOF;
}
getchar函数通过调用read函数来读取字符。
read函数由三个参数,第一个参数为文件描述符fd,fd为0表示标准输入;第二个参数为输入内容的指针;第三个参数为读入字符的个数。
read函数的返回值是读入字符的个数,若出错则返回-1。
当程序调用getchar时,程序就等着用户按键。
- 用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止
- 当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。
异步异常-键盘中断的处理:
键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回.
8.5本章小结
本章学习了Linux下IO设备的管理方法,了解Unix IO和Unix IO函数,深入分析了printf函数和getchar函数的实现。与此同时,了解了I/O 的重要性。有时我们必须使用Unix I/O。
结论
简单的hello程序背后所蕴含的知识包括了计算机系统的方方面面,本文较为详细的介绍了hello的坎坷一生。
最开始,hello要经过一系列的编译:
- 首先,在预处理器中,hello.c经过预处理,与所有的外部库合体成为了hello.i;
- 再在编译器中经过编译,成为了hello.s;
- 之后,汇编器又将hello.s转换为可重定位的目标文件hello.o;
- 最后,连接器会把hello.o进行链接,成为了可执行的目标程序hello。
紧接着运行程序,当我们在Shell中输入“./hello”,并敲下回车键时:
- Shell会新建一个进程,先fork一个子进程,清空当前进程的数据加载hello,开始执行;
- 在这过程中,hello可能会暂时的停下脚步(系统调用或者计时器中断),这时保留当前进度,并切换上下文,内核去处理别的进程。也可能输入信号终止或挂起hello进程;
- hello输出信息时需要调用printf和getchar,他们的实现需要调用Unix I/O中的write和read函数,这又需要借助系统调用I/O;
- 结束之后的Shell等到exit,作为hello的父进程回收hello。内核删除所有的数据,hello的此次旅途也就到达终点。回到磁盘,它开始等待下一次旅途。
我的感悟是,计算机虽然让人难以理解,但是这正是它的迷人之处。究其原因,我们若对计算机的组成、程序的过程实现一知半解,计算机的谜团就越显复杂。但越难得到的,当真正得到了之后总是越发让人振奋,这正是学习计算机的乐趣,同样也是研读CSAPP和学习这门课程的乐趣所在。
不仅如此,服务于学习与生活的各种程序最终还是要靠底层的各个程序配合实现,尝试去理解这些底层对于编写出最适合所用机器的代码,进而完成更优质的应用程序来说必不可少。
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.c:C语言编写的源程序文件。
hello.i:hello.c经预处理得到的ASCII码的中间文件。
hello.s:hello.i编译之后得到的一个ASCII汇编语言文件。
hello.o:hello.s汇编之后得到的一个可重定位目标文件。
hello:hello.s和标准的C库进行链接得到的可执行目标文件。
hello.o_objdump:hello.o的反汇编代码。
hello_objdump:hello的反汇编代码。
hello.elf:hello.o的elf格式文件。
hello1.elf:hello的elf格式文件。
参考文献
[1] Pianistx. (2013 年 9 月 11 日). [转]printf 函数实现的深入剖析.
检索来源: 博客园: https://www.cnblogs.com/pianist/p/3315801.html
[2] 柏 666. (2019 年 6 月 4 日 ). 02. 汇编指令 .
检索来源 : 简书 : https://www.jianshu.com/p/7ec425403779
[3] http://blog.csdn.net/xiaoguaihai/article/details/8705992
[4] C汇编Linux手册 http://docs.huihoo.com/c/linux-c-programming
[5] CMU的实验参考 http://csapp.cs.cmu.edu/3e/labs.html
[6] 网站与论坛 http://cn.ubuntu.com/ http://forum.ubuntu.org.cn/
[7] 《深入理解计算机系统》Randal E. Bryant David R.O`Hallaron