HIT2019计算机系统大作业hello的P2P过程

在这里插入图片描述

计算机系统

大作业

题 目 程序人生-Hello’s P2P
专 业
学   号
班   级
学 生
指 导 教 师

计算机科学与技术学院
2019年12月
摘 要

本文通过对hello程序P2P和020的整体介绍,阐述了hello经过多种处理后成为Process,并且hello进程创建直到回收的全部过程:从最开始的预处理和编译,到之后的汇编和链接;从被加载到内存,到系统为其申请进程;从默默自己运行,到我们对其发送信息对其产生影响;从一个简简单单的文字串,到活灵活现的程序展现…。以这些过程的分析为例,我们更好地说明了计算机的底层实现,并且更深地阐明了整个程序的生命周期
关键词:P2P,O2O,计算机系统,程序生命周期

目 录

第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 -

第1章 概述

1.1 Hello简介

Hello的一生从可读文本hello.c文件开始。
在这里插入图片描述

上图就描述了hello从.c文件到最终可执行文件的大致过程。

那什么是P2P和020?
P2P: From Program to Process 。linux中,hello.c经过cpp的预处理、ccl的编译、as的汇编、ld的链接最终成为可执行目标程序hello,在shell中键入启动命令后,shell为其fork产生子进程的过程。
020:之后shell为其execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。

1.2 环境与工具

硬件环境:X64 CPU(Core i7-8750H 2.2GHz),8GB RAM,512GB SSD
软件环境:Windows 10,Vmware Workstation 18.5 Pro,Ubuntu 64
开发与调试工具: 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过程,列出了本次实验信息:环境、中间结果。也列出了该篇论文完成所需要生成的一些中间文件,为后续实验提供基本思路。

第2章 预处理

2.1 预处理的概念与作用

概念:预处理器cpp根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件。

主要作用如下:
(1):删除“#define”并展开所定义的宏
(2):处理所有条件预编译指令,如“#if”,“#ifdef”, “#endif”等
(3):插入头文件到“#include”处,可以递归方式进行处理
(4):删除所有的注释“//”和“/* */”
(5):添加行号和文件名标识,以便编译时编译器产生调试用的行号信息
保留所有#pragma编译指令(编译器需要用)

2.2在Ubuntu下预处理的命令

$gcc –E hello.c –o hello.i 

或者

$cpp hello.c > hello.i

截图展示如下:
在这里插入图片描述
2.3 Hello的预处理结果解析

这是原本的.c文件
在这里插入图片描述

这是预处理后的.i文件
在这里插入图片描述
在这里插入图片描述

通过打开.i文件,我们可以看到:文件变得很大,有3000多行,而我们的代码在最后出现。这是由于:之前的stdio.h,unistd.h,stdlib.h都进行了展开,以stdio.h的展开为例,cpp到默认的环境变量下寻找stdio.h,打开/usr/include/stdio.h 发现其中依然使用了#define语句,cpp对此递归展开,所以最终.i程序中是没有#define的。并且我们还可以针对#开头的条件编译语句,cpp根据#if后面的条件决定需要编译的代码。

2.4 本章小结

本章主要介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析。预处理是将程序在编译之前进行的一步处理,实则是一个替换的过程。通过宏展开宏替换,插入头文件等操作,使得程序中的宏引用被递归地替换掉,从而从hello.c到hello.i。生成的程序在这时还是一个可读的C语言风格的文本文件。

第3章 编译

3.1 编译的概念与作用

编译的概念:

广义的编译是说将某一种程序设计语言写的程序翻译成等价的另一种语言。在这里指的是把高级语言文本程序翻译成等价的汇编语言文本程序:编译器ccl把预处理后的文本文件.i文件进行一系列语法分析及优化后生成相应的汇编语言文件.s文件的过程。.s文件中包含一个汇编语言程序。

编译的作用:

编译是把高级语言转化为机器二进制代码的必经之路。它把高级语言翻译成更接近机器语言的汇编语言,使生成过程更加方便顺畅。

过程:1.词法分析、2.语法分析、3.语义分析、4.源代码优化、5.代码生成目标代码优化

3.2 在Ubuntu下编译的命令

编译命令:gcc -S hello.i -o hello.s

截图展示如下:
在这里插入图片描述

3.3 Hello的编译结果解析

3.3.1.文件声明:
先介绍hello.s文件开头的部分:
在这里插入图片描述

.file
源文件名
.globl
全局变量
.data
数据段
.align
对齐方式
.type
指定是对象类型或是函数类型
.size
大小
.long
长整型
.section .rodata
下面是.rodata节
.text
代码段

再根据PPT中P4给出的参考C数据与操作,进行如下解析:
3.3.2.数据:
hello.s文件中的主要数据类型有:整型、字符串、指针数组。

一:整型
(1):main的参数argc:
在这里插入图片描述
argc是函数传入的第一个int型参数,用来表示输入的参数个数。argc作为第一个参数,存储在寄存器%edi中,然后又通过movl指令被存入-20(%rbp)。

(2):函数内部的局部变量int i
在这里插入图片描述
函数内部的局部变量i会被编译器存储在寄存器或者程序栈中,它没有标识符,也不需要被声明,而是直接使用。
movl $0, -4(%rbp)可知,i被存储在-4(%rbp)(即用户栈)中,初始化为0,占用4B大小。

二:字符串
在hello.c中,我们使用了字符串类型。我们的函数根据参数的数量决定打印到屏幕上的字符串是什么样的,如果参数数量是3,(即我们在./hello 后再输入两个字符串,我们的“./hello xxa xxb”就会形成三个参数)就输出“Usage:Hello xxa xxb!”。否则其他情况则输出原定好的字符串。
这两个字符串的信息都被放在了.rodata节中 。
如下图:LC0表示的是默认输出;第一个字符串.LC0包含汉字,每个汉字再utf-8编码中被编码为三个字节
LC1表示的是argc等于3的输出;
在这里插入图片描述

三:指针数组
argv[]这个数组作为参数存放我们在命令行中输入的字符串。hello.s的argv的首地址被存放在寄存器%rsi(按照顺序的第二个参数所用的寄存器)中,后来被存放在栈中空出寄存器,便于我们的后续调用其他函数使用。如下图所示:
在这里插入图片描述

3.3.3.运算与操作:
(1):赋值
例如:在循环开始时将i赋值为0,如下图所示,对给i赋值0是通过mov语句来实现的。
在这里插入图片描述

(2):算术计算:
如图所示,编译器将main.c的循环体内的i++翻译成addl $1, -4(%rbp)

在这里插入图片描述

    (3):关系操作

我们在hello.c中有两个地方用到了关系操作:
其一:判断argc的大小,因为argc是main函数的第一个参数,所以,他应该存放在%edi中,且结合前面的分析可知它又被存入-20(%rbp)
在这里插入图片描述
在这里,它判断argc是否为4,若相等,就跳转到.L2。

其二:在for循环中,每次都会比较i的大小来判断循环是否结束:
在这里插入图片描述
这里比较i和7的大小,如果小于等于就跳转,所以循环的条件是:i<=7

(4):数组/指针/结构操作
指针数组(char *argv[]):
argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别指向我们从shell终端输入的字符串。
如下图所示,通过语句addq $16,%rax以及addq $8,%rax分别得到argv[1]和argv[2]。(64位地址是8个字节)
在这里插入图片描述

3.3.4.控制转移:
在程序中控制转移有两处
(1):if(argc!=4)
根据参数argc的值决定是跳转还是继续执行。语句cmpl $4,-20(%rbp)比较-20(%rbp)中的内容(argc)与4,并根据条件码选择跳转
如果argc==4,那么我们的程序就往下顺序执行,否则就跳转到.L2
在这里插入图片描述
下面给出条件码跳转表
在这里插入图片描述

(2):for(i=0;i<8;i++)
首先无条件跳转到位于循环体.L4之后的比较部位,cmpl将其与9进行比较,,并设置条件码,如果i小于等于7,则跳转到.L4进行循环,否则的话顺序往下进行getchar等操作。
在这里插入图片描述
在这里插入图片描述

3.3.5.函数调用:
函数通过跳转到特定代码执行待定函数之后再返回来实现功能。函数一般是在栈中实现的,函数调用可分为如下过程:
1,传递参数给被调用者
参数传递在64位栈结构中是通过寄存器和内存共同实现的,按照:%rdi,%rsi,%rdx,%rcx,%r8,%r9的顺序传递参数,从第七个参数开始放在调用者栈结构中。

2,call调用函数
call指令会将返回地址压入栈中,并且将rip的值指向所调用函数的地址,等函数执行完之后调用ret弹出原来的rip并且将栈帧结构恢复

3,函数进行操作
函数在自己栈帧内进行操作,返回值存入RAX寄存器。

4,函数返回
函数返回时,如果有返回值,则先将返回值存在%rax中,再用leave和ret等操作返回,控制权还给调用函数。

hello.c文件中调用的函数有:main()、printf()、exit()、sleep()、atoi()、getchar()。
在这里插入图片描述
(1):main函数
参数传递::第一个参数是argc(int型),第二个参数是argv[](char *型),分别存放在寄存器%rdi和%rsi中;
Call调用:main函数被系统函数__libc_start_main调用,call指令将main函数的地址分配给%rip,随后调用main函数。
函数操作中的栈维护:main函数使用栈指针,同时使用栈帧%rbp来记录使用情况。如图main函数在进入时先将rsp减去32形成一个栈空间结构,然后开始进行各种操作。
在这里插入图片描述
函数返回:可以看到main函数的尾部将0压入到eax中,然后调用了leav平衡栈帧,调用ret返回退出。
在这里插入图片描述

(2):printf函数
参数传递:call puts时只传入了字符串参数首地址;for循环中call printf时传入了 argv[1]和argc[2]的地址;
在这里插入图片描述
函数调用:第一个printf()由call puts@PLT调用,第二个printf()由call printf@PLT调用;
函数返回:从printf中返回。

(3):exit函数
在这里插入图片描述
参数传递:将1传给了%edi,完成参数传递。
函数调用:通过call exit@PLT函数,进行函数调用。
函数返回:从exit返回。

(4):sleep函数
在这里插入图片描述
参数传递:将atoi的返回值%eax通过%rdi传递给sleep函数
Call调用:调用了sleep函数,将控制传送。
函数返回:从sleep中返回。

(5):atoi函数
在这里插入图片描述
参数传递:将argv[3](字符串)通过%rdi传递给atoi函数。
函数调用:通过call atoi@PLT函数,进行函数调用。
函数返回:从atoi中返回。

(6):getchar函数
在这里插入图片描述
参数传递:无;
函数调用:call getchar@PLT调用getchar;
函数返回:从getchar中返回

3.4 本章小结

本章主要阐述了编译器是如何处理C语言的各个数据类型以及各类操作的,基本都是先给出原理然后结合hello.c C程序到hello.s汇编代码之间的映射关系作出合理解释。

编译器将.i的拓展程序编译为.s的汇编代码。经过编译之后,我们的hello自C语言解构为更加低级的汇编语言。

第4章 汇编

4.1 汇编的概念与作用

概念:汇编器(as)将.s汇编程序翻译成机器语言,把这些机器语言指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,这个过程就叫做汇编.
作用:翻译生成机器语言,因为机器语言是计算机能直接识别和执行的一种语言.

4.2 在Ubuntu下汇编的命令

gcc -c hello.s -o hello.o

在这里插入图片描述

4.3 可重定位目标elf格式
在这里插入图片描述

下面我们使用readelf获得hello.o的elf格式,
命令readelf -a hello.o > hello.elf。得到hello.elf
在这里插入图片描述

现在我们来分析ELF格式:

4.3.1:ELF头
ELF头部以一个16字节的序列开始,描述生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器分析语法和解释目标文件的信息,其中包含ELF头大小、目标文件的类型、及其类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
在这里插入图片描述

4.3.2:节头部表
节头部表描述了不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。具体的描述包括节的名称、类型、地址和偏移量等,如下图所示:
在这里插入图片描述
在这里插入图片描述

4.3.3:重定位条目
当汇编器生成一个目标模块是,它并不知道数据和代码最终将放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。

ELF重定位条目的数据结构如下:

typedef struct{

long offset;        /*需要被修改的引用的节偏移*/
long type:32,     /*重定位类型*/
 symbol:32; /*标识被修改引用应该指向的符号*/
long attend;       /*符号常数,对修改引用的值做偏移调整*/

}Elf64_Rela;

两种最基本的重定位类型:

R_X86_64_PC32 :重定位一个使用32位PC相对地址的引用。
R_X86_64_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反汇编代码中的操作数是十六进制。
在这里插入图片描述
上图是.s文件中的
在这里插入图片描述
上图是.o文件的反汇编代码中的

(2)分支转移:
跳转语句之后,hello.s中是.L2和.L3等段名称,而反汇编代码中跳转指令之后是相对偏移的地址。
在这里插入图片描述
上图是.s文件中的
在这里插入图片描述
上图是.o文件的反汇编代码中的

(3)函数调用:
hello.s中,call指令之后直接是函数名称,而反汇编代码中call指令之后是函数的相对偏移地址。因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
在这里插入图片描述
上图是.s文件中的
在这里插入图片描述
上图是.o文件的反汇编代码中的

(4)指令:
汇编中mov、push、sub等指令都有表示操作数大小的后缀,比如q\l等,反汇编得到的代码中则没有。
汇编代码中有很多“.”开头的伪指令用来指导汇编器和链接器工作,反汇编得到的代码中则没有。

4.5 本章小结

本章节我们分析了hello.o的elf文件格式的信息,以及通过查看反汇编代码,对比函数调用,条件转移等过程查看了其与hello.s的区别。我们看到编译之后的汇编将我们的程序向机器又推进了一大步。在汇编中,我们进行了更多的处理,使其变成一个可重定向文件,可以进行下一步的链接了。

第5章 链接

5.1 链接的概念与作用

概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。

作用:当程序调用函数库(如标准C库)中的一个函数printf,printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个函数必须通过链接器(ld)将这个文件合并到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,因此根据Section Headers中的信息我们就可以用HexEdit定位各个节所占的区间(起始位置,大小)。其中Address是程序被载入到虚拟地址的起始地址。
具体信息如下图:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

5.3.3.程序头:
一共有8个段
(1)PHDR包含程序头表本身
(2)INTERP:只包含了一个section,在这个节中,包含了动态链接过程中所使用的解释器路径和名称。
(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.1hello与hello.o的反汇编代码比较:

objdump -d -r hello查看hello的反汇编代码:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
上面就是hello的反汇编代码

将它与hello.o文件的反汇编代码进行比较后,可得下面几处不同:

(1)地址的访问:
hello.o中的相对地址到了hello中变成了虚拟内存地址。而hello.o文件中对于.rodata的访问,是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全置为0,并且添加重定位条目。

(2)链接增加新的函数:
在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数。

(3)增加的节:hello中增加了.init和.plt节,和一些节中定义的函数。

(4)函数调用:
hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。

5.5.2hello的重定位过程分析:
重定位过程合并输入模块,并为每个符号分配运行时地址,主要有以下两步:

1.重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。包括hello.o在内的所有可重定位目标文件中的.data节被全部合并成一个节,这个节成为输出的可执行目标文件hello中的.data节。然后,连接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,hello中每条指令和包括.rodata、sleepsecs等全局变量都有唯一的运行时内存地址了。

2.重定位节中的符号引用。链接器依赖于hello.o中的重定位条目,修改代码节和数据节中对每个符号的引用,使得它们指向正确运行时的地址。

5.6 hello的执行流程

1.调用start函数,地址0x400500
2.调用__libc_start_main函数,地址0x400532
在这里插入图片描述
在这里插入图片描述
调用libc-2.27.so!__cxa_atexit 0x7fce 8c889430
在这里插入图片描述
4. 调用libc-2.27.so!__new_exitfn
5. 调用__libc_csu_init
在这里插入图片描述
6. 调用__libc_csu_init 0x4005c0
7. 调用_init函数 0x400488
在这里插入图片描述
8. 调用libc-2.27.so!_setjmp函数 0x7fce 8c884c10
在这里插入图片描述
9. 调用-libc-2.27.so!_sigsetjmp函数 0x7fce 8c884b70
10. 调用–libc-2.27.so!__sigjmp_save函数 0x7fce 8c884bd0
11. 调用main 0x400532
12. 调用puts 0x4004b0
在这里插入图片描述
13. 调用exit 0x4004e0
14. 调用ld-2.27.so!_dl_runtime_resolve_xsave 0x7fce 8cc4e680
15. 调用ld-2.27.so!_dl_fixup 0x7fce 8cc46df0
在这里插入图片描述
16. 调用–ld-2.27.so!_dl_lookup_symbol_x 0x7fce 8cc420b0
17. 调用libc-2.27.so!exit 0x7fce 8c889128

5.7 Hello的动态链接分析

5.7.1. GOT表:
概念:每一个外部定义的符号在全局偏移表(Global offset Table)中有相应的条目,GOT位于ELF的数据段中,叫做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的虚拟地址空间、重定位过程、执行流程、动态链接过程.
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能.我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块.当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件.

第6章 hello进程管理

6.1 进程的概念与作用

概念:进程就是一个执行中程序的实例.每次用户通过向shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程.
作用:进程为用户提供以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象.进程也被称为计算机科学中最伟大的创新。

6.2 简述壳Shell-bash的作用与处理流程

shell是一个应用程序,他在操作系统中提供了一个用户与系统内核进行交互的界面。他的处理过程一般是这样的:

1)从终端读入输入的命令。

2)将输入字符串切分获得所有的参数

3)如果是内置命令则立即执行

4)否则调用相应的程序为其分配子进程并运行

5)shell应该接受键盘输入信号,并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

Shell(父进程)通过fork 函数创建一个新的运行的子进程.新的子进程几乎但不完全与父进程相同.子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈.子进程进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork 时,子进程可以读写父进程中打开的任何文件.
在这里插入图片描述
6.4 Hello的execve过程

execve 函数加载并运行可执行目标文件filename, 且带参数列表argv 和环境变量列表envp .只有当出现错误时,例如找不到filename, execve 才会返回到调用程序.所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回.
execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段.新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容.最后加载器设置PC指向_start地址,_start最终调用main函数.除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制.直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存.
在这里插入图片描述
6.5 Hello的进程执行

首先介绍进程逻辑流、上下文信息、进程时间片、用户模式和内核模式等基本概念:

(1)逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
(2)时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。

(3)用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。

(4)上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。

下面我们简单看hello sleep进程调度的过程:
当调用sleep之前,如果hello程序不被抢占则顺序执行,假如发生被抢占的情况,则进行上下文切换,上下文切换是由内核中调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,并进行如下操作:
(1)保存以前进程的上下文
(2)恢复新恢复进程被保存的上下文
(3)将控制传递给这个新恢复的进程 ,来完成上下文切换。
在这里插入图片描述
详情见上图,hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时(2.5secs)发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
6.6 hello的异常与信号处理

6.6.1:异常的分类
在这里插入图片描述

6.6.2:处理方法:
(1)中断处理:中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序。
在这里插入图片描述
(2)陷阱处理:陷阱是有意的异常,是执行一条指令后的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
在这里插入图片描述
(3)故障处理:故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果故障处理程序能够修正这个错误,它就将控制返回给引起故障的指令,从而重新执行它,否则,处理程序返回到内核中的 abort 例程,abort 例程会终止引起故障的应用程序。
在这里插入图片描述
(4)终止处理:终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如 DRAM 或者 SRAM 位被损坏时发生的奇偶错误。终止处理程序不会将控制返回给应用程序。
在这里插入图片描述

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本章小结

本章简述了进程管理的一些简要信息,比如进程的概念作用,shel的基本原理,shell如何调用fork和execve我们的hello进程,我们的hello进程在执行时会遇到什么样的情况(包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令的处理),它对这些情况如何做出反应。又介绍了一些常见异常和其信号处理方法。显然面对执行时的多样的复杂的环境,我们的hello已经不能完全像预处理,编译汇编那样以自己为中心的生活了,它需要更多的关于信号的处理的知识.

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:程序代码经过编译后出现在 汇编程序中地址.逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址.

线性地址&虚拟地址:逻辑地址经过段机制后转化为线性地址(虚拟地址),是逻辑地址到物理地址变换之间的中间层.在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址.是一个32位无符号整数,可以用来表示高达4GB的地址,也就是,高达4294967296个内存单元.线性地址通常用十六进制数字表示,值得范围从0x00000000到0xfffffff)程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址.如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址.如果没有启用分页机制,那么线性地址直接就是物理地址.

物理地址:CPU地址总线传来的地址,由硬件电路控制(现在这些硬件是可编程的了)其具体含义.物理地址中很大一部分是留给内存条中的内存的,但也常被映射到其他存储器上(如显存、BIOS等).在没有使用虚拟存储器的机器上,虚拟地址被直接送到内存总线上,使具有相同地址的物理存储器被读写;而在使用了虚拟存储器的情况下,虚拟地址不是被直接送到内存地址总线上,而是送到存储器管理单元MMU,把虚拟地址映射为物理地址.
在hello程序中,他就表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。

7.2 Intel逻辑地址到线性地址的变换-段式管理

一个逻辑地址由两部份组成,段标识符、段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如下图:
在这里插入图片描述

索引号,是“段描述符(segment descriptor)”,段描述符具体地址描述了一个段。这样,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,由8个字节组成,如下图:
在这里插入图片描述

Base字段:它描述了一个段的开始位置的线性地址。 Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。
当段选择符中的T1字段=0,表示用GDT;若为1,表示用LDT。 GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。再看图7-3比起来要直观些:
在这里插入图片描述

首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],

1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。

2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。

3、把Base + offset,就是要转换的线性地址了。 还是挺简单的,对于软件来讲,原则上就需要把硬件转换所需的信息准备好,就可以让硬件来完成这个转换了。
7.3 Hello的线性地址到物理地址的变换-页式管理

页式管理: 虚拟地址->物理地址

首先Linux系统有自己的虚拟内存系统,其虚拟内存组织形式如下图,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
在这里插入图片描述

而物理内存被划分为一小块一小块,每块被称为帧(Frame)。分配内存时,帧是分配时的最小单位,最少也要给一帧。在虚拟内存中,与帧对应的概念就是页(Page)。
线性地址的表示方式是:前部分是虚拟页号后部分是虚拟页偏移。
在这里插入图片描述
CPU通过将逻辑地址转换为虚拟地址来访问主存,这个虚拟地址在访问主存前必须先转换成适当的物理地址。CPU芯片上叫做内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址。然后CPU会通过这个物理地址来访问物理内存。
页表就是一个页表条目(PTE)数组,虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个PTE。PTE是由一个有效位和一个n个字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。
MMU利用虚拟页号(VPN)来在虚拟页表中选择合适的PTE,当找到合适的PTE之后,PTE中的物理页号(PPN)和虚拟页偏移量(VPO)就会组合形成物理地址。其中VPO与PPO相同,因为虚拟页大小和物理页大小相同,所需要的偏移量位数也就相同。此时,物理地址就通过物理页号先找到对应的物理页,然后再根据物理页偏移找到具体的字节:
1.如果有效位是 0+NULL 则代表没有在虚拟内存空间中分配该内存;
2.如果是有效位 0+非 NULL,则代表在虚拟内存空间中分配了但是没 有被缓存到物理内存中;
3.如果有效位是 1 则代表该内存已经缓存在了物理内存中,可以得到其 物理页号 PPN,与虚拟页偏移量共同构成物理地址 PA。
在这里插入图片描述
7.4 TLB与四级页表支持下的VA到PA的变换

首先虚拟地址是由VPN和VPO组成的,VPN可以作为在TLB中的索引,如上图所示,TLB可以看作是一个PTE的cache,将常用的PTE缓存到TLB中,加速虚拟地址的翻译。TLB是具有高相连度的,应该是为了一次多存一些PTE。如果能够在TLB中找到与VPN对应的PTE,即为TLB hit,TLB直接给出PPN,然后PPO即为VPO,这样就构成了一个物理地址。
如果不能做到TLB hit就要到四级页表当中取寻址,在i7中VPN有36位,被分成了四段,从左往右的前三个九位的地址分别对应于在前三级页表当中的偏移,偏移在页表中所对应的页表条目指向某一个下一级页表,而下一个9位VPN就对应的是在这个页表中的偏移。最后一级页表中的页表条目存放的是PPN
比如VPN1在第一级页表中对应于一个页表条目,这个页表条目指向下一级页表中的某个页表,再依靠VPN2在这个页表中找到它对应的页表条目,同样,这个也表条目指向的是第三级页表中的某个页表,再依靠VPN3找到在这个页表中与之对应的页表条目,这个页表条目指向的是第四级页表中的某个页表,再依靠VPN4找出与之对应的页表条目,这个页表条目中存放的是PPN,在四级页表中最多可以存放512G的内存内容,显然一般是用不了那么多的。
最后再把VPO拿来当成PPO就能找到在对应的物理页上存放的内容了。
在这里插入图片描述
7.5 三级Cache支持下的物理内存访问

我们先看看现代计算机的高速缓存层次结构:
在这里插入图片描述
获得了物理地址VA之后,使用CI(倒数7-12位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中,根据数据偏移量CO(后6位)取出数据返回.
如果没有匹配成功或者匹配成功但是标志位是1,则不命中,向下一级缓存中查询数据(L2 Cache->L3 Cache->主存),查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU(Least frequently used)进行替换.也就是替换掉最不经常访问的一次数据,示意图如下:
在这里插入图片描述

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制,当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当两个进程中的任一个后来进行写操作时,写时复制就会创建新页面。因次,也就为每个进程保持了私有地址空间的概念。
在这里插入图片描述
在这里插入图片描述
7.7 hello进程execve时的内存映射

execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序.加载并运行hello需要以下几个步骤:

1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构.
2.映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的.代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零.
3.映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内.
4.设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点.
在这里插入图片描述
7.8 缺页故障与缺页中断处理

分三步:
第一步先确认是不是一个合法的地址,即通过不断将这个地址与每个区域的vm_start&vm_end进行比对,如果并不是在一个区域里的话,就给出segmentation fault,因为它引用了一个不合法的地址
第二步确认访问权限是不是正确的。即如果这一页是只读页,但是却要做出写这个动作,那明显是不行的。如果做出了这类动作,那么处理程序就会触发一个保护异常,默认行为是结束这个进程
第三步确认了是合法地址并且是符合权限的访问,那么就用某个特定算法选出一个牺牲页,如果该页被修改了,就将此页滑出(swap out)并且swap in那个被访问的页,并将控制传递到触发缺页中断的那条指令,这条指令继续执行的时候就不会触发缺页中断,这样就可以继续执行下去。
在这里插入图片描述

举课本中的例子:
对于一个访问虚拟内存的指令来说,如果发生了缺页现象,CPU就会触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,例如存放在PP3中的VP4,如果VP4已经被更改,那就先将他存回到磁盘中。
找到了要存储的页后,内核会从磁盘中将需要访问的内存,例如图7.8.1所示的VP3放入到之前已经操作过的PP3中,并且将PTE中的信息更新,这样就成功的将一个物理地址缓存在了页表中。当异常处理返回的时候,CPU会重新执行访问虚拟内存的操作,这个时候就可以正常的访问,不会发生缺页现象了。
在这里插入图片描述
7.9动态存储分配管理

7.9.1.动态内存分配的基本原理:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆.系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) .对于每个进程,内核维护着一个变量brk, 它指向堆的顶部.
分配器将堆视为一组不同大小的块的集合来维护.每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的.已分配的块显式地保留为供应用程序使用.空闲块可用来分配.空闲块保持空闲,直到它显式地被应用所分配.一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的.
显式分配器(explicit allocator):要求应用显式地释放任何已分配的块。
隐式分配器(implicit allocator): 要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块.隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集( garbage collection).例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块.

7.9.2:简单的放置策略:
(1)首次适配:从头搜索,遇到第一个合适的块就停止;
(2)下次适配:从头搜索,遇到下一个合适的块停止;
(3)最佳适配:全部搜索,选择合适的块停止。

7.9.3.两种堆的数据结构组织形式:

隐式空闲链表:
在这里插入图片描述
一个块是由一个字的头部,有效载荷,以及可能的一些额外的填充组成的.头部编码了这个块的大小,以及这个块是已分配的还是空闲的.如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零.因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息.在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的.

带边界标签的隐式空闲链表:
在这里插入图片描述
在隐式空闲链表堆块的基础上,在每个块的结尾处添加一个脚部(footer),边界标记),其中脚部就是头部的一个副本。
隐式链表的好处在于它简单,易于操作,但是它的坏处在于搜索时间实在是太长了,如果采用首次适配算法的话内存利用率会低,但如果采用最佳适配的话需要对于一整个堆进行搜索。

显式空间链表
根据定义,程序不需要一个空闲块的主体,所以实现空闲链表数据结构的指针可以存放在这些空闲块的主体里面。
显式空闲链表结构将堆组织成一个双向空闲链表,在每个空闲块的主体中,都包含一个pred(前驱)和succ(后继)指针。使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。
在这里插入图片描述
7.10本章小结

本章主要介绍了hello的存储器地址空间、intel的段式管理、页式管理,TLB与四级页表支持下的VA到PA的变换、三级cache支持下物理内存访问, hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容.
我们需要理解存储器层次结构,这部分内容十分重要,同时也很难以理解,需要我们花费较长时间去消化,但是这是值得的,因为它对应用程序的性能有着巨大的影响。
熟悉这一章节的内容,可以帮助我们以后编写一些对高速缓存友好的代码,或者说运行速度更快的代码,十分重要!

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:所有的IO设备都被模型化为文件,而所有的输入输出都被当作对相应文件的读和写来执行。
设备管理:将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
8.2 简述Unix IO接口及其函数

Unix IO接口:

打开文件,内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。

Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。

改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。

读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k

关闭文件。当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中

Unix I/O函数:

  1. open 函数:打开一个已存在的文件或者创建一个新文件的
    函数原型:int open(char *filename, int flags, mode_t mode);
    open 函数将filename 转换为一个文件描述符,并且返回描述符数字.返回的描述符总是在进程中当前没有打开的最小描述符.flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位.返回:若成功则为新文件描述符,若出错为-1.
  2. close 函数:关闭一个打开的文件.
    函数原型:int close(int fd);
    返回:若成功则为0, 若出错则为-1.
    3.read 和write 函数:执行输入和输出的.
    ssize_t read(int fd, void *buf, size_t n);
    read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf .返回值-1表示一个错误,而返回值0 表示EOF.否则,返回值表示的是实际传送的字节数量.
    返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1.
    ssize_t write(int fd, const void *buf, size_t n);
    write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置.图10-3 展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出.返回:若成功则为写的字节数,若出错则为-1.

8.3 printf的实现分析

首先看一下函数体:
在这里插入图片描述
参数中明显采用了可变参数的定义,可以看到*fmt是一个char 类型的指针,指向字符串的起始位置。

printf接受一个fmt的格式,然后将匹配到的参数按照fmt格式输出,fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。
(1)printf调用的外部函数vsprintf。
在这里插入图片描述

它接受一个格式化的命令,并把指定的匹配的参数格式化输出。
看看语句i = vsprintf(buf, fmt, arg);
vsprintf返回的是一个长度,其实就是要打印出来的字符串的长度
再看看printf中后面的一句: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’ 停止.

综合分析,printf函数实现过程:
(1):vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
(2):vsprintf的输出到write系统函数中。在Linux下,write通过执行syscall指令实现了对系统服务的调用,从而使内核执行打印操作。内核会通过字符显示子程序,根据传入的ASCII码到字模库读取字符对应的点阵,然后通过vram(显存)对字符串进行输出。
(3):显示芯片将按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终实现printf中字符串在屏幕上的输出。
8.4 getchar的实现分析

先看getchar大致函数体:
在这里插入图片描述
getchar函数通过调用read函数来读取字符。read函数由三个参数,第一个参数为文件描述符fd,fd为0表示标准输入;第二个参数为输入内容的指针;第三个参数为读入字符的个数。read函数的返回值是读入字符的个数,若出错则返回-1。
当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。

异步异常-键盘中断的处理:
键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回.

8.5本章小结

本章学会了linux下IO设备的管理方法,了解了Unix IO和Unix IO函数,深入分析了printf函数和getchar函数的实现。
与此同时,了解了Unix I/O 在系统中是一个什么样的存在,I/O 是系统操作不可或缺的一部分.有时你除了使用Unix I/O 以外别无选择.

结论

一个简单的hello程序背后所蕴含的知识包括了计算机系统的方方面面,本文较为详细的介绍了hello的坎坷一生:从诞生开始由I/O设备编写为hello.c,存储在计算机磁盘中,它将以hello.c的身份开始此次旅途。
最开始,hello要经过一系列的编译:首先,在预处理器中,hello.c经过预处理,与所有的外部库合体成为了hello.i;再在编译器中经过编译,成为了hello.s;之后,汇编器又将hello.s转换为可重定位的目标文件hello.o;最后,连接器会把hello.o进行链接,于是,可执行的目标程序hello就诞生了!
紧接着运行程序,当我们在shell中输入“./hello”时,bash会新建一个进程,先fork一个子进程,然后清空当前进程的数据并加载hello,从函数的入口进入,开始执行,由于各种各样的原因,我们的hello可能会暂时的休息(系统调用或者计时器中断),这时我们保留当前进度,并切换上下文,内核去处理别的进程,提高效率。我们还可以输入信号来终止或挂起hello进程,hello输出信息时需要调用printf和getchar,而printf和getchar的实现需要调用Unix I/O中的write和read函数,而它们的实现需要借助系统调用I/O,在最后结束之后bash等到exit,作为hello的父进程回收hello。随后,内核删除他所有的数据,hello的此次旅途也就到达终点。它仍在磁盘中等待下一次旅途的开始。

自我感悟:计算机不会骗人,但是它会让人难以理解,究其原因,是我们对计算机的组成、程序的process实现理解不透彻。而计算机系统确实值得我们花费时间深入探究的,即使这个过程并不容易,或者说蛮艰难的。
Program To Process ,服务于我们学习与生活的各种Program最终还是要靠底层的各个Process配合实现,尝试去理解这些底层Process对于程序员来说必不可少,唯此,方可编写出最适合所用机器的代码,进而完成更优质的应用程序。
十年磨一剑,我仍将继续。

附件
在这里插入图片描述

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] 《可执行文件(ELF)格式的理解》
检索来源 : http://www.voidcn.com/article/p-nucnoizt-pz.html
[4] 《聊聊Linux动态链接中的PLT和GOT(1)——何谓PLT与GOT》
检索来源 :https://blog.csdn.net/linyt/article/details/51635768
[5] BryantO’Hallaron, D.R.R.E.,. (2019). 深入理解计算机系统(第三版). 北京: 机械工业出版社.
[6] Pianistx. (2013 年 9 月 11 日). [转]printf 函数实现的深入剖析.
检索来源: 博客园: https://www.cnblogs.com/pianist/p/3315801.html.
[7] C 预处理器 | 菜鸟教程 .
检索来源 : 菜鸟教程 : https://www.runoob.com/cprogramming/c-preprocessors.html

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值