CSAPP 大作业

Hello的自白

摘 要
  本文简述了Hello.c源程序的预处理、编译、汇编、链接、运行的主要过程,以及hello程序的进程管理、存储管理与I/O管理,通过hello.c这一程序的程序周期的描述,对程序的编译、加载、运行有个初步的了解。

关键词:Hello 预处理 编译 汇编 链接 加载 进程 execve fork I/O管理

目 录

第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程序在Linux系统下通过使用vim、Code:Blocks、sublime等类似Windows记事本的文档编辑器(或者是IDE)等应用程序编写代码程序所得到的。得到的程序名称叫做hell.c,为Program。
  P2P(From Program to Process):
  Linux下使用命令行:

gcc -m64 -no-pie -fno-PIC hello.c -o hello

  通过编译器驱动程序调动预处理器(cpp)、编译器(cl)、汇编器(as)、链接器(ld)四个程序获得二进制的可执行文件hello。
在这里插入图片描述

图1.1 hello程序编译过程

  在shell中,键入命令执行hello

./hello (学号) (姓名)

  shell解析命令行参数,并初始化环境变量等内容,获取argc、argc、envp,解析命令行参数发现该命令非内置命令,将其视为可执行文件,并以(学号)、(姓名)作为argv[1]、argv[2]的内容。加载和运行过程调用fork函数创建进程、execve函数运行函数,通过内存映射、分配空间等手段让hello拥有自己的空间和时间,与其他程序并发地运行。由于没有’&’等符号,于是程序不在后台运行,hello在前台运行。至此程序Program转换为进程Process,即P2P。
  O2O(From Zero-0 to Zero-0):
  Linux加载器execve()将程序计数器置为程序入口点,也就是_start函数的地址。这个函数是在系统目标文件ctrl.o中定义的。_start函数调用系统启动函数__libc_start_main,该函数定义在libc.so,它初始化执行函数,调用用户层的main函数,处理main函数的返回值,并在需要的时候把控制返回给内核。
通过段式管理、页式管理,各存储器联动,CPU为执行文件hello分配时间周期,执行逻辑控制流,每条指令在流水线上取值、译码、执行、访存、写回、更新PC。内存管理单元和CPU处理器在执行过程中通过L1、L2、L3高速缓存(SRAM)和TLB(翻译后备缓冲器)、多级页表在物理内存中存取数据、指令,通过I/O系统输入输出。当程序运行结束时(例如通过键盘中断Ctrl+C手段),shell回收进程,释放hello的内存并且删除有关进程上下文。hello从无倒有再到0的过程就是From Zero-0 to Zero-0,即O2O。

1.2 环境与工具

  硬件环境:
    X64 CPU;Intel Core i5 8300HQ; 2.30GHz; 8G RAM;
  软件环境:
    Windows10,Ubuntu 18.04.1LTS
  开发与调试工具:
    GCC,EDB,Hexedit,Objdump,readelf, Code:Blocks

1.3 中间结果

  hello.c:源程序
  hello.i:预处理后的文本文件
  hello.s:编译后汇编程序文本文件
  hello.o:汇编后的可重定位目标程序(二进制文件)
  hello1.txt:hello.o的反汇编文件
  hello2.txt:hello的反汇编文件
  hello1elf.txt:ELF格式下的hello.o
  hello2elf.txt:ELF格式下的hello
  hello:链接后的可执行目标文件

1.4 本章小结

  本章简述了hello.c源程序的程序周期:P2P和O2O的过程。并且介绍实验的硬软件环境、开发和调试工具,以及中间所生成的相关文件。

第2章 预处理

2.1 预处理的概念与作用

概念:
  预处理(pre-treatment),在程序设计领域,一般是指程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor)对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理为特定的单位。
  最常见的预处理是C语言和C++语言。ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase),通常前几个阶段由预处理器实现。
  预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,其中ISO C/C++要求支持的包括:

#if
#ifdef
#ifndef
#else
#elif
#endif(条件编译)
#define(宏定义)
#include(源文件包含)
#line(行控制)
#error(错误指令)
#pragma(和实现相关的杂注) 单独的#(空指令)

  预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
  预处理器在UNIX传统中通常缩写为PP,在自动构建脚本中C预处理器被缩写为CPP的宏指代。为了不造成歧义,C++(cee-plus-plus) 经常并不是缩写为CPP,而改成CXX。
作用:
  预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。如hello.c中的行:

#include <stdio.h>

  该命令告诉预处理器读取系统头文件stdio.h的内容,并且把它直接插入程序文本中。预处理器还会替换程序起始位置的宏。结果就得到了另一个C程序,通常是以.i作为文件扩展名。

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i

  或者

cpp hello.c > hello.i

  生成的预处理文件过程、结果如图2.1所示:
在这里插入图片描述

图2.1 预处理命令及生成文件

2.3 Hello的预处理结果解析

  查看hello.i文件,如图2.2所示:
  函数main代码体保持原样,而全局变量sleepsecs之前的头文件,被替换为长长的部分,约为3000多行,即扩展了#include文件,并在新的文件中也存在扩展新的#include引用。
在这里插入图片描述

图2.2 预处理文件main函数

  观察扩展头文件的部分,发现有大量对结构的定义,诸如typedef、struct、enum等等,对外部变量的引用,诸如extern,对引用目录的标注,诸如”/user/include/st dlib.h”。由于程序本身就无define类型的语句,我们此时看不出来define语句在预处理的替换。如图2.3所示:
在这里插入图片描述

图2.3 预处理文件结构定义

2.4 本章小结

  本章介绍了预处理阶段的相关概念、定义、应用以及方法,通过具体的hello实例说明预处理过程中对头文件stdio.h的解析、对头文件stdlib.h的解析、对头文件unistd.h的解析。浴池里过层中进行了头文件引用,define宏替换,删除注释。

第3章 编译

3.1 编译的概念与作用

  概念:
  编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。编译的过程实质上是把预处理文件进行词法分析、语法分析、语义分析、优化,从C语言等高级语言转换为成机器更好理解的汇编语言程序,转换后的文件仍为ASCII文本文件。
  作用:
  编译后生成的.s汇编语言程序文本文件比预处理文件更容易让机器理解、比.o可重定位目标文件更容易让程序员理解,是对于程序像机器指令的一步关键过程。

3.2 在Ubuntu下编译的命令

gcc -S hello.c -o hello.s
在这里插入图片描述
  生成的编译文件过程、结果如图3.1所示:

图3.1 编译命令及生成文件

3.3 Hello的编译结果解析

  阅读生成的hello.s文件,对比源程序hello.c:

3.3.1 数据

  ①. 常量
  C语言源程序如图3.2所示:
在这里插入图片描述

图3.2 C语言源程序

  可以知道printf中的格式串为常量:

Usage: Hello 学号 姓名!\n

Hello %s %s\n

  在汇编文本文件中,其被定义为.LC0,.LC1,如图所示,根据UTF-8的编码规则,汉字被编码为三个字节,其它英文、空格、换行等字符与ASCII规则相同,编码为一个字节,但汇编文件保留原字符形式。两字符串均存放在.rodata段中,如图3.3所示:
在这里插入图片描述

图3.3 printf格式串汇编编码

②. 变量
  C语言源程序如图3.2所示,显然sleepsecs为全局变量,i、argc、argv为局部变量。
  sleepsecs这一被初始化的全局变量,存放在.data节中,源程序声明时sleepsecs为int类型,赋初值为2.5,由于为int类型,我们可以发现在汇编代码中数据变为2。汇编代码要求4字节对齐,大小为4字节。如图3.4所示:
在这里插入图片描述

图3.4 sleepsecs条目

  argc这一函数参数要求与3比较,在汇编程序中存放在寄存器EDI中,比较过程中放在栈中,帧指针RBP-20的位置。argv地址放在了寄存器RSI中,使用时放在栈中,帧指针RBP-32的过程。如图3.5所示:
在这里插入图片描述

图3.5 argc、argv条目

  变量i作为循环计数器,先初始化为0,然后迭代每次与10比较判断循环是否结束,如图3.6所示:
在这里插入图片描述

图3.6 i条目
3.3.2 赋值

  如图3.2所示,对变量的赋值 ‘=’以供进行了两次:
  ①. 对sleepsecs的赋值,

int sleepsecs=2.5;

  如图3.4所示,sleepsecs在main函数之前就进行了赋值,赋值为2.要求4字节对齐。
  ②. 对i的赋值,在循环for语句中
  i=0 i++(等价于i=i+1)
在这里插入图片描述

图3.7 i的初始化

  如图3.7所示,使用movl语句对i进行初始化。

3.3.3 类型转换

  过程中sleepsecs进行了隐式类型转换。如图3.2所示,要求赋值2.5,但其为int类型,发生隐式类型转换,数据变为2,如图3.4所示。

3.3.4 算数操作

  对i进行了i++的运算操作,如图3.8所示,使用了addl语句:
在这里插入图片描述

图3.8 i++操作
3.3.5 关系操作

  程序进行了两次关系操作:
  ①. argc!=3
  使用了cmpl语句设置条件码,jxx语句根据条件码做出相应的是否跳转的选择,如图3.9所示:
在这里插入图片描述

图3.9 argc!=3

  ②. i<10
  如图3.10所示,每次进行i++后与9进行比较cmpl语句,若<=继续进行迭代jle语句:
在这里插入图片描述

图3.10 i<10
3.3.6 数组操作

  如图3.2所示,数组相关操作只在printf格式串中使用argv[1]、argv[2],如图3.5所示,argv地址放在了寄存器RSI中,使用时放在栈中,帧指针RBP-32的过程。当argc==3是在循环中引用了argv数组内容。如图3.11所示,对argv的引用:
在这里插入图片描述

图3.11 数组argv操作

  argv[0]地址从栈使用movl语句传递给rax,计算argv[2]地址,使用addq+16,加后的值是argv[2]地址,对其引用,argv[2]值存放在RDX,同理argv[1]的值存放在RSI中,存放在RDX,RSI中是方便printf的引用。

3.3.7 控制转移

  如图3.2所示,控制转移出现在if(argc!=3)和for(i=0;i<10;i++)
  ①. if(argc!=3)控制转移
  如图3.12所示,argc存在栈-20(%rbp)中与3比较,若是不相等继续顺序执行,调用exit;相等则跳转至.L2执行for循环。这一过程主要通过je进行:
在这里插入图片描述

图3.12 if(argc!=3)控制转移

  ②. for(i=0;i<10;i++)控制转移
  如图3.13所示,for循环在.L2初始化,迭代结束后每次判断在.L3所示部分通过cmpl比较,jle比较,若i<=9跳转至.L4执行循环中的迭代,否则继续顺序执行printf、sleep函数:
在这里插入图片描述

图3.13 for循环控制转移
3.3.8 函数操作

  ①. if(argc!=3)中对printf函数以及exit函数的调用
  如图3.14所示。函数首先调用printf函数,将.rodata节的.LC0的printf格式串的地址存进EDI,作为第一个参数方便函数printf调用;再将1存进EDI作为exit函数的第一参数以供调用:
在这里插入图片描述

图3.14 if(argc!=3)的函数操作

  ②. for循环中对printf、sleep的函数调用
  如图3.13,函数将argv[1]、argv[2]的地址存进RSI、RDX,将printf格式串.LC1地址存进EDI,调用printf函数;再将sleepsecs存进EDI,调用sleep函数。
  ③. 对函数getchar的调用
  如图3.15,函数直接调用getchar函数,调用后将存储main函数返回值的RAX寄存器置0,并返回结束main函数:
在这里插入图片描述

图3.15 getchar调用

3.4 本章小结

  本节介绍编译器通过编译将.i文件转换为汇编语言的.s文件的过程。同时解析了变量,相关运算,以及各类c语言的基本语句的汇编表示,更容易理解高级语言的底层表示方法。

第4章 汇编

4.1 汇编的概念与作用

  概念:
  汇编器(as)将hello.s文件翻译成二进制机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存到目标文件hello.o中。hello.o是一个二进制文件,包含着程序的指令编码,如果用文本编辑器打开,将看到一堆乱码。
  作用:
  汇编过程将汇编代码转换为计算机能够理解并执行的二进制机器代码,这个二进制机器代码是程序在本机器上的机器语言的表示。

4.2 在Ubuntu下汇编的命令

gcc -c -m64 -no-pie -fno-PIC hello.s -o hello.o

在这里插入图片描述

图4.1 汇编命令及生成文件

4.3 可重定位目标elf格式

  首先使用readelf命令查看hello.o的ELF格式,指令如下:

readelf -a hello.o > hello1elf.txt

在这里插入图片描述

图4.2 hello.o的elf格式的查看
4.3.1 ELF头

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

图4.3 hello.o的ELF头信息

  hello.o的ELF以一个16进制序列:
    7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  作为ELF头的开头。这个序列描述了生成该文件的系统的字的大小为8字节和字节顺序为小端序。
  ELF头的大小为64字节,目标文件的类型为REL(可重定位文件)、机器类型为Advanced Micro Devices X86-64即AMD X86-64、节头部表的文件偏移为0,以及节头部表中条目的大小,其数量为13。

4.3.2 节头部表(section header table)

  如图4.4所示,节头部表描述了13个节的相关信息,由表可以看出:

.text节:以编译的机器代码,类型为PROGBITS,意为程序数据,旗标为AX,即权限为分配内存、可执行

.rela.text节:一个.text节中的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。

.data节:已初始化的静态和全局C变量。类型为PROGBITS,意为程序数据,旗标为WA,即权限为可分配可写。

.bss节:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。类型为NOBITS,意为暂时没有存储空间,旗标为WA,即权限为可分配可写。

.rodata节:存放只读数据,例如printf中的格式串和开关语句中的跳转表。类型为PROGBITS,意为程序数据,旗标为A,即权限为可分配。

.comment节:包含版本控制信息。 .note.GNU_stack节:用来标记executable stack(可执行堆栈)。

.eh_frame节:处理异常。 .rela.eh_frame节:.eh_frame的重定位信息。

.shstrtab节:该区域包含节区名称。 .symtab节:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。

.strtab节:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部的节名字。

在这里插入图片描述

图4.4 节头部表相关信息
4.3.3 符号表

  一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。符号表有汇编器构造,使用编译器输出到汇编语言.s文件中的符号。每个符号表是一个条目的数组,每个条目包含如图4.5所示的部分:
在这里插入图片描述

图4.5 ELF符号表条目。type和binding字段每个都是4位

  如图4.6所示,hello.o程序的符号表包含编号Num、Value、Size、Type、Bind、Vis、Ndx、Name字段:
在这里插入图片描述

图4.6 hello.o符号表

  该符号表供16个符号,其中

sleepsecs是一个位于.data节(Ndx=3)偏移量(value)为0,大小为4个字节的全局符号,类型为变量。

main是一个位于.text节(Ndx=1)偏移量(value)为0,大小为125个字节的全局符号,类型为变量。

puts、exit、printf、sleep、getchar为NOTYPE未知类型,未定义(UND)符号。hello.c为文件,ABS表示不该被重定位的符号。

4.3.4 .rela.text节和.rela.eh_frame节

  如图4.7所示重定位条目包含以下信息:
在这里插入图片描述

图4.7 重定位条目信息

  重定位条目常见共2种:

R_X86_64_32:重定位绝对引用。重定位时使用一个32位的绝对地址的引用,通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。

R_X86_64_PC32:重定位PC相对引用。重定位时使用一个32位PC相对地址的引用。一个PC相对地址就是据程序计数器的当前运行值的偏移量。

  如图4.8所示,第一个、第四个重定位条目为绝对引用,其余均为相对PC引用。
  重定位PC相对引用重定位算法如下:

refaddr = ADDR(s) + r.offset;
*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr); 重定位绝对引用重定位算法如下:
*refptr = (unsigned) (ADDR(r.symbol) + r.addend);

  假设算法运行时,链接器为每个节(用ADDR(s)表示)和每个符号都选择了运行时地址(用ADDR(r.symbol))表示。
  以.rodata的重定位为例,它的重定位地址为refptr.则应先计算引用的运行时地址refaddr=ADDR(s)+ r.offset, .rodata的offset为0x16,ADDR(s)是由链接器确定的。然后,更新引用,refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr), .rodata的ADDR(r.symbol)也是由链接器确定的,addend查表可知为0,refaddr已经算出来了,所以,.rodata的重定位地址我们就可以算出来了。
在这里插入图片描述

图4.8 .rela.text节、.rela.eh_frame节信息

  需要注意的是计算结果是unsingned类型。

4.4 Hello.o的结果解析

  说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
  首先使用命令行:

objdump -d -r hello.o > hello1.txt

  获取hello.o的反汇编,如图4.9所示。
  获取hello.i即hello的汇编版本,如图4.10所示。
  对比两版本对hello.c源程序的转换,可以发现,对于具体的语句类型,汇编与反汇编几乎一致,不同点具体如下:
  ①. 格式上,hello.i前没有一串二进制数,即相应的机器码,而反汇编代码前面有与之对应的机器码。
  ②. 数据内容上,立即数在hello.i这一汇编语言文本文件中为十进制,而在反汇编代码中为十六进制。例如在申请栈这一方面,hello.i中的语句为:

pushq	%rbp
movq	%rsp, %rbp
subq	$32, %rsp

  而在反汇编文本文件中,申请栈的操作如下:

 0:	55                   	push   %rbp
 1:	48 89 e5             	mov    %rsp,%rbp
 4:	48 83 ec 20          	sub    $0x20,%rsp

  使用的数据分别为32和0x20,说明在hello.i中十进制,而在hello.o的反汇编代码版本中是十进制。
  ③. 跳转方式不同,在汇编代码中,代码直接声明具体的段存储位置,通过助记符如.LC0,.LC1存储在.rodata段中,跳转方式如下:

.L2:
		movl	$0, -4(%rbp)
		jmp		.L3

  jmp直接跳转至相应符号声明位置。
  而反汇编代码是依据地址跳转的,例如在反汇编代码中上述代码表述如下:

  f:	83 7d ec 03          	cmpl   $0x3,-0x14(%rbp)
 13:	74 14                	je     29 <main+0x29>

  74是机器指令中跳转的机器码,根据PC与跳转数决定跳转位置,跳转至29位置,此时PC为0x15,故跳转位置为0x15+0x14=0x29。
  ④. 重定位条目
  汇编代码仍然采用直接声明的方式,即通过助记符,例如

call  printf

  而反汇编代码采用重定向的方式进行跳转,机器代码在此处留下一些地址以供链接时重定向:

 55:	e8 00 00 00 00       	callq  5a <main+0x5a>
             56: R_X86_64_PC32	printf-0x4

  链接时根据标识、重定位条目自动填充地址。
在这里插入图片描述

图4.9 hello.o反汇编代码

  程序通过

R_X86_64_32:重定位绝对引用。重定位时使用一个32位的绝对地址的引用,通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
R_X86_64_PC32:重定位PC相对引用。重定位时使用一个32位PC相对地址的引用。一个PC相对地址就是据程序计数器的当前运行值的偏移量来进行重定位,需要注意的是,在编译时未采用了位置无关代码,否则反汇编代码中将常有RIP这一程序计数器,利用代码段到数据段的距离不变来重定位。

在这里插入图片描述

图4.10 hello.i

4.5 本章小结

  本章了解程序的汇编过程、阅读了程序的ELF条目,了解了汇编、反汇编这两种相近而不相同的程序表现形式,领会汇编过程的重要性。

第5章 链接

5.1 链接的概念与作用

  概念:
  链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接执行符号解析、重定位过程。
  作用:
  把可重定位目标文件和命令行参数作为输入,产生一个完全链接的,可以加载运行的可执行目标文件。使得分离编译成为可能。

5.2 在Ubuntu下链接的命令

  使用链接过程的命令行如下:

ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o

  此时链接结果如图5.1所示:
在这里插入图片描述

图5.1 链接过程及生成文件

5.3 可执行目标文件hello的格式

  首先使用readelf命令查看hello的ELF格式,指令如下:

readelf -a hello > hello2elf.txt

在这里插入图片描述

图5.2 hello的elf格式的查看
5.3.1 ELF头

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

图5.3 hello的ELF头信息

  hello.o的ELF以一个16进制序列:
    7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  作为ELF头的开头。这个序列描述了生成该文件的系统的字的大小为8字节和字节顺序为小端序。
  ELF头的大小为64字节,目标文件的类型为REL(可重定位文件)、机器类型为Advanced Micro Devices X86-64即AMD X86-64、节头部表的文件偏移为0,以及节头部表中条目的大小,其数量为25。

5.3.2 节头部表(section header table)

  如图5.4所示,节头部表描述了25个节的相关信息,与hello.o的节头部表相比,多出来的部分:

interp段:动态链接器在操作系统中的位置不是由系统配置决定,也不是由环境参数指定,而是由ELF文件中的 .interp段指定。该段里保存的是一个字符串,这个字符串就是可执行文件所需要的动态链接器的位置,常位于/lib/ld-linux.so.2。(通常是软链接)
dynamic段:该段中保存了动态链接器所需要的基本信息,是一个结构数组,可以看做动态链接下 ELF文件的“文件头”。存储了动态链接会用到的各个表的位置等信息。
dynsym段:该段与 “.symtab”段类似,但只保存了与动态链接相关的符号,很多时候,ELF文件同时拥有 .symtab 与 .synsym段,其中 .symtab 将包含 .synsym 中的符号。该符号表中记录了动态链接符号在动态符号字符串表中的偏移,与.symtab中记录对应。
dynstr段:该段是 .dynsym 段的辅助段,.dynstr 与 .dynsym 的关系,类比与 .symtab 与 .strtab的关系 hash段:在动态链接下,需要在程序运行时查找符号,为了加快符号查找过程,增加了辅助的符号哈希表,功能与 .dynstr 类似
rel.dyn段:对数据引用的修正,其所修正的位置位于 “.got”以及数据段(类似重定位段 “rel.data”)
rel.plt段:对函数引用的修正,其所修正的位置位于 “.got.plt”。

5.3.3 符号表

  一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
  如图5.5所示,hello程序的符号表包含编号Num、Value、Size、Type、Bind、Vis、Ndx、Name字段。

在这里插入图片描述

图5.4 节头部表相关信息

在这里插入图片描述

图5.5 hello符号表
5.3.4 程序头表

  内容不多就不一一赘述,如图5.6示:
在这里插入图片描述

图5.6 程序头表
5.3.5 Section to Segment mapping

  如图5.7所示:
在这里插入图片描述

图5.7 Section to Segment mapping
5.3.6 Dynamic section

  如图5.8所示
在这里插入图片描述

Dynamic section(1)

在这里插入图片描述

图5.8 Dynamic section(2)
5.3.7 重定位节(动态链接库)

  如图5.9所示:
在这里插入图片描述

图5.9 重定位节
5.3.8 版本信息

  如图5.10所示:
在这里插入图片描述

图5.10 版本信息

5.4 hello的虚拟地址空间

  使用edb加载hello,查看本进程的虚拟地址空间各段信息。如图5.11所示:
在这里插入图片描述

图5.11 edb查看hello的空间各段信息

  由图5.11可知,虚拟空间从0x400000开始。
  由图5.4可知,
  .interp段地址从0x4001c8,偏移量为0x1c8,大小为0x1c,对齐要求为1,故而.interp段是从地址0x4001c8开始,查看0x4001c8处的值。如图5.12所示:
在这里插入图片描述

图5.12 .interp段

  可以看出该段内保存有动态链接库的位置。
  .text段地址从0x4004d0,偏移量为0x4d0,大小为0x122,对齐要求为16,故而.text段是从地址0x4004d0开始,查看0x4004d0处的值。如图5.13所示:
在这里插入图片描述

图5.13 .text段

  与反汇编代码中拥有的机器码比较发现是一致的。
  .rodata段地址从0x400600,偏移量为0x600,大小为0x2f,对齐要求为4,故而.rodata段是从地址0x400600开始,查看0x400600处的值。如图5.14所示:
在这里插入图片描述

图5.14 .rodata段

  发现里面存的是printf的格式串。

5.5 链接的重定位过程分析

  使用命令行:

objdump -d -r hello > hello2.txt

在这里插入图片描述

图5.15 反汇编hello文件

  如图5.15,5.16所示:
  ①. 显然与图4.8中hello.o的反汇编代码相比,hello.o反汇编代码虚拟地址从0开始,而hello反汇编代码从0x400000开始。
  ②. hello.o反汇编代码就直接是.text段,然后为main函数,如图4.8所示:
而hello反汇编的结果中,由于链接过程中重定位而加入进来各种函数、数据。如开始的函数和调用的函数填充在main函数之前。所以main函数的位置发生了巨大的改变。
  ③. 而这些call函数,引用全局变量,和跳转模块值时地址也有所变化。
  可执行文件跳转和应用就是虚拟内存地址(相对或绝对)。
  hello.o反汇编的跳转的就是只要hello数据时对应的位置。
在这里插入图片描述

图5.16 hello反汇编代码(1)

在这里插入图片描述

图5.16 hello反汇编代码(2)

  由机器语言反汇编代码、重定位信息与程序头中显式的内存地址,得到:

ADDR(.text_main) = 0x4004fa
ADDR(.rodata) = 0x400600
Offset = 0x16
Addend = 0

  可以得到等式:
  *refptr = (unsigned)(ADDR(.rodata+0x4)+addend)
  =(unsigned)(0x400604 + 0)
  =(unsigned)(0x400604)
  如图5.17所示,其与机器代码的反汇编结果中的0x400604一致:
在这里插入图片描述

图5.17 rodata重定位

  由机器语言反汇编代码、重定位信息与程序头中显式的内存地址,得到:

ADDR(.text_main) = 0x4004fa
ADDR(.puts) = 0x400460
Offset = 0x1b
Addend = 0

  可以得到等式:
  *refptr = (unsigned)(ADDR(.puts)+addend - ADDR(.text_main)- Offset)
  =(unsigned)(0x400460 + 0 – 0x4004fa-0x1b)
  =(unsigned)(0xffffff47)
  如图5.17所示,其与机器代码的反汇编结果中的0xffffff47一致。
  同理可以得到其他重定位条目。

5.6 hello的执行流程

  函数调用如下表格所示:

名称地址
ld-2.23.so!_dl_start0x00007f8dec5b79b0
ld-2.27.so! dl_init0x00007f8dec5c6740
hello!_start0x004004d0
ld-2.27.so!_libc_start_main0x00400480
libc-2.27.so! cxa_atexit0x00007f8dec226280
hello!_libc_csu_init0x00400580
hello!_init0x00400430
libc-2.27.so!_setjmp0x00007f8dec221250
libc-2.27.so!_sigsetjmp0x00007f8dec221240
libc-2.27.so!__sigjmp_save0x00007fa8dec221210
hello_main0x004004fa
hello!puts@plt0x00400460
hello!exit@plt0x004004a0
hello!printf@plt0x00400470
hello!sleep@plt0x004004b0
hello!getchar@plt0x00400490
ld-2.23.so!_dl_runtime_resolve_avx0x00007f8dec5cd870
libc-2.27.so!exit0c00007f6002de35b0

5.7 Hello的动态链接分析

  动态链接项目中,查看dl_init前后项目变化。对于动态共享链接库中PIC函数,编译器加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略,将过程地址的绑定推迟到第一次调用该过程。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
  在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,初始时每个GOT条目都指向对应的PLT条目的第二条指令。如图5.18(a)所示:
在这里插入图片描述

(a)

在这里插入图片描述

(b)

在这里插入图片描述

(c)
图5.18 动态链接相关

  在dl_init调用之后, 0x6008c0和0x6008c0处的两个8字节的数据分别发生改变。
  和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址, GOT[2]是动态链接器ld-linux.so模块中的入口点。
  在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问时,GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数。
  在.dl.init前后设置断点,如图5.18(b)(c)所示,信息发生了变动。

5.8 本章小结

  本阶段完成了对hello.o的链接工作。使用Ubuntu下的链接指令可以将其转换为可执行目标文件,其中用到了rodata中的重定位条目,最终分析了程序如何实现的动态库链接。

第6章 hello进程管理

6.1 进程的概念与作用

  概念:
  进程的经典定义是一个执行中程序的实例,系统的每个程序都运行在某个进程的上下文。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存里的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。
  作用:
  通过进程,我们会得到一种假象,好像我们的程序是当前唯一运行的程序,我们的程序独占处理器和内存,我们程序的代码和数据好像是系统内存中唯一的对象。

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

  在计算机科学中,Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(命令解析器)。它类似于DOS下的command.com和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。同时它又是一种程序设计语言。作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令;作为程序设计语言,它定义了各种变量和参数,并提供了许多在高级语言中才具有的控制结构,包括循环和分支。
  Bash是一个命令处理器,通常运行于文本窗口中,并能执行用户直接输入的命令。Bash还能从文件中读取命令,这样的文件称为脚本。和其他Unix shell 一样,它支持文件名替换(通配符匹配)、管道、here文档、命令替换、变量,以及条件判断和循环遍历的结构控制语句。包括关键字、语法在内的基本特性全部是从sh借鉴过来的。其他特性,例如历史命令,是从csh和ksh借鉴而来。总的来说,Bash虽然是一个满足POSIX规范的shell,但有很多扩展。
  处理流程:
  第一步:用户输入命令。
  第二步:shell对用户输入命令进行解析,判断是否为内置命令。
  第三步:若为内置命令,调用内置命令处理函数,否则调用execve函数创建一个子进程进行运行。
  第四步:判断是否为前台运行程序,如果是,则调用等待函数等待前台作业结束;否则将程序转入后台,直接开始下一次用户输入命令。
   第五步:shell应该接受键盘输入信号,并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

  我们在shell上输入./hello,这个不是一个内置的shell命令,所以shell会认为hello是一个可执行目标文件,通过调用某个驻留在存储器中被称为加载器的操作系统代码来运行它。
  当shell运行一个程序时,父进程通过fork函数生成这个程序的进程。新创建的子进程几乎但不完全与父进程相同,包括代码、数据段、堆、共享库以及用户栈。父进程和新创建的子进程之间最大的区别在于他们有不同的PID。
在这里插入图片描述

图6.1 fork创建进程示意图

6.4 Hello的execve过程

  execve函数在当前进程的上下文中加载并运行一个新程序
  execve函数加载并运行可执行文件filename(hello),且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。
  当加载器运行时,它创建一个类似与图 6-2 的内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段,接下来,加载器跳转到程序的入口,_start函数的地址,这个函数是在系统目标文件ctrl.o中定义的,对所有的c程序都一样。_start函数调用系统启动函数,_libc_start_main,该函数定义在libc.so里,初始化环境,调用用户层的main函数,处理main函数返回值,并且在需要的时候返回给内核。
在这里插入图片描述

图6.2 进程的虚拟内存

6.5 Hello的进程执行

  多个流并发地执行的一般现象被称为并发。一个进程和其他进轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
  操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重启一个被抢占的进程所需得状态。
  在执行过程中,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这个决策称为调度。
  hello程序与操作系统其他进程通过操作系统的调度,切换上下文,拥有各自的时间片从而实现并发运行。所以其实hello在sleep时就是这样的切换。
  程序在进行一些操作时会发生内核与用户状态的不断转换。这是为了保持在适当的时候有足够的权限和不容易出现安全问题。
  简单看hello sleep进程调度的过程:当调用sleep之前,如果hello程序不被抢占则顺序执行,假如发生被抢占的情况,则进行上下文切换,上下文切换是由内核中调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,并进行1)保存以前进程的上下文2)恢复新恢复进程被保存的上下文,3)将控制传递给这个新恢复的进程,来完成上下文切换。
在这里插入图片描述

图6.3 进程上下文的剖析

  如上图所示,hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时(2secs)发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
  当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。

6.6 hello的异常与信号处理

6.6.1不停乱按,包括回车

  如果乱按过程中没有回车,这个时候只是把输入屏幕的字符串缓存起来,如果输入最后是回车,getchar读回车,并把回车前的字符串当作shell输入的命令,如图6.4所示。
在这里插入图片描述

图6.4 不停乱按,包括回车。
6.6.2 Ctrl-Z

  如下图,如果输入Ctrl+Z会发送一个SIGTSTP信号给前台进程组的每个进程,结果是停止前台作业,即我们的hello程序,如图6.5所示。
在这里插入图片描述

图6.5 Ctrl-Z
6.6.3 Ctrl-C

  如下图,如果在程序运行过程中输入Ctrl+C,会让内核发送一个SIGINT信号给到前台进程组中的每个进程,结果是终止前台进程,通过ps命令发现这时hello进程已经被回收。
在这里插入图片描述

图6.6 Ctrl-C
6.6.4 Ctrl-Z后可以运行ps jobs pstree fg kill 等命令

在这里插入图片描述

图6.7 Ctrl-Z后运行ps

在这里插入图片描述

图6.8 Ctrl-Z后运行jobs

  以命令fg为例,fg的功能是使第一个后台作业变为前台,而第一个后台作业是hello,所以输入fg 后hello程序又在前台开始运行,并且是继续刚才的进程,输出剩下的7个字符串。
在这里插入图片描述

图6.9 Ctrl-Z后运行fg

6.7本章小结

  本章介绍了进程的概念和作用,描述了shell如何在用户和系统内核之间建起一个交互的桥梁。讲述了shell的基本操作以及各种内核信号和命令,还总结了shell是如何fork新建子进程、execve如何执行进程、hello进程的上下文切换。

第7章 hello的存储管理

7.1 hello的存储器地址空间

  逻辑地址:
  在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。
  物理地址:
  在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。
  虚拟地址:
  CPU启动保护模式后,程序运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。
  线性地址:
  线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。

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

  一个逻辑地址由两部分组成,段标识符,段内偏移量。段标识符是一个16位长的字段组成,称为段选择符,其中前13位是一个索引号。后面三位包含一些硬件细节。
  索引号,这里可以直接理解成数组下标,它对应的“数组”就是段描述符表,段描述符具体描述了一个段地址,这样,很多段描述符就组成段描述符表。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
  这里面,我们只用关心Base字段,它描述了一个段的开始位置的线性地址。
  Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。
  GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
  首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
  看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
  拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
  把Base + offset,就是要转换的线性地址了。

7.3 Hello的线性地址到物理地址的变换-页式管理

  计算机利用页表,通过MMU来完成从虚拟地址到物理地址的转换。
在这里插入图片描述

图7.1 虚拟地址到物理地址

  线性地址即虚拟地址,用VA来表示。由图7.1所示,VA被分为虚拟页号(VPN)与虚拟页偏移量(VPO),CPU取出虚拟页号,通过页表基址寄存器(PTBR)来定位页表条目,在有效位为1时,从页表条目中取出信息物理页号(PPN),通过将物理页号与虚拟页偏移量(VPO)结合,得到由物理地址(PPN)和物理页偏移量(PPO)组合的物理地址。

7.4 TLB与四级页表支持下的VA到PA的变换

  每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。
  TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块。TLB通常有高的相联度,从虚拟地址中的页号提取出组选择和行匹配的索引和标记字段。
  因为所有的地址翻译都是在芯片上的MMU中进行的,因此非常快。
  多级页表:将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。如下图,VPN被分为k个部分,第一级VPN结合基址寄存器得到一个页表条目,其中存放下一级页表的基址,再结合VPN2,得到第三级页表基址,继续寻找,以此类推,直到最后确定对应的物理页号,与VPO结合,由图7.3,得到由PPN与PPO结合成的物理地址,用于物理地址寻址。
在这里插入图片描述

图7.2 TLB的组成

  如果是二级页表,第一级页表的每个PTE负责一个4MB的块,每个块由1024个连续的页面组成。二级页表每一个PTE负责一个4KB的虚拟地址页面。这样的好处在于,如果一级页表中有一个PTE是空,那么二级页表就不会存在,这样会有巨大的潜在节约,因为4GB的地址空间大部分都是未分配的。
在这里插入图片描述

图7.3 多级页表

  现在的64位计算机采用4级页表,36位的VPN被封为4个9位的片,每个片被用作一个页面的偏移,CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1PET的偏移量,这个PTE包含L2页表的基地址,VPN2提供一个到L2PTE的偏移量,以此类推。
  Core i7是四级页表进行的虚拟地址转物理地址。48位的虚拟地址的前36位被分为四级VPN区。结合存放在CR3的基址寄存器,由前面多级页表的知识,可以确定最终的PPN,与VPO结合得到物理地址。

7.5 三级Cache支持下的物理内存访问

  对于一个虚拟地址请求,首先将去TLB寻找,看是否已经在TLB中缓存。如果命中的话就直接MMU获取,没有命中的话就先在结合多级页表,得到物理地址,去cache中找,到了L1里面以后,寻找物理地址又要检测是否命中,不命中则紧接着寻找下一级cache L2,接着L3。这里就是使用到CPU的高速缓存机制了,一级一级往下找,直到找到对应的内容。

7.6 hello进程fork时的内存映射

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

图7.4 一个私有的写时复制对象

7.7 hello进程execve时的内存映射

  execve 函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:

删除已存在的用户区域。删除当前进程(shell)虚拟地址的用户部分中的已存在的区域结构。 映射私有区域。为hello的代码、数据、bss
和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data
区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。
映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so,
那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。 设置程序计数器(PC) 。execve
做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

   下一次调度hello进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

7.8 缺页故障与缺页中断处理

  虚拟内存中,DRAM缓存不命中称为缺页。如图7-13,CPU需要引用VP3中的一个字,通过读取PTE3,发现有效位为0,说明不在内存里,这时就发生了缺页异常。缺页异常发生时,通常会调用内核里的缺页异常处理程序,该程序会选择一个牺牲页,这里是存放在PP3的VP4,如果VP4已经被修改,内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在内存里。
   接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令。
  缺页处理程序不是直接就替换,它会经过一系列的步骤:
  虚拟地址是合法的吗?如果不合法,它就会触发一个段错误
  试图进行的内存访问是否合法?意思就是进程是否有读,写或者执行这个区域的权限
  经过上述判断,这时才能确定这是个合法的虚拟地址,然后才会执行上述的替换。
在这里插入图片描述

图7.5 VM缺页(之前)。对VP3中的字的引用会不命中,从而触发了缺页

在这里插入图片描述

图7.6 进入缺页异常处理程序

7.9动态存储分配管理

  动态内存分配器维护着一个进程的虚拟内存区 域,称为堆(heap)系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做"break"),它指向堆的顶部。
在这里插入图片描述

图7.7 堆

  分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块 保持空闲,直到它显式地被应用所分配。一个已分配 的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
  分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。

显式分配器(explicitallocator):要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做ma耳oc程序包的显式分配器。C程序通过调用malloc函数来
分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。
隐式分配器(implicitallocator):另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbagecollec­tor),而自动释放未使用的巳分配的块的过程叫做垃圾收集(garbagecollection)。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。

  动态内存分配主要有两种基本方法与策略:
  1.带边界标签的隐式空闲链表分配器管理
  带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成的。
隐式空闲链表:
在这里插入图片描述

图7.8 隐式空闲链表:

  在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。
在这里插入图片描述

图7.9 隐式空闲链表组织堆

  当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配合最佳适配。分配完后可以分割空闲块减少内部碎片。同时分配器在面对释放一个已分配块时,可以合并空闲块,其中便利用隐式空闲链表的边界标记来进行合并。
  2.显式空间链表管理
  显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。
  显式空闲链表:
  显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。
  显式空闲链表:
在这里插入图片描述

图7.10 使用双向空闲链表的堆块的格式

  在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。

7.10本章小结

  在本章中整理了有关内存管理的知识,讲述了在hello运行的64位系统中内存管理方法,虚拟内存和物理内存之间的关系,了解intel环境下的段式管理和页式管理、fork和exceve的内存映射,知道了缺页故障和缺页中断管理机制,了解了如何根据缓存或页表寻找物理內存。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

  所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。

设备的模型化:文件
设备管理:unix io接口

8.2 简述Unix IO接口及其函数

  Unix I/O接口统一操作:
  设备可以通过Unix I/O接口被映射为文件,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
  打开文件。 一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
  Linux shell创建的每个进程开始时都有三个打开的文件: 标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可以用来代替显式的描述符值。
  改变当前的文件位置。 对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
  读写文件。 一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的而文件,当k>=m时执行读操作会触发一个成为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
  关闭文件。 当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
  Unix I/O接口函数:
  ①. 进程是通过调用open函数来打开一个存在的文件或者创建一个新文件的,函数声明如下:
  int open(char *filename, int flags, mode_t mode);
  open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。mode参数指定了新文件的访问权限位。作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置成mode&~umask。
  ②. 进程通过调用close函数关闭一个打开的文件。函数声明如下:
  int close(int fd);
  成功返回0错误返回EOF
  ③. 应用程序是通过分别调用read和write函数来执行输入和输出的。函数声明如下:
  ssize_t read(int fd, void *buf, size_t n);
  ssize_t write(int fd, const void *buf, size_t n);
  read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
  ④. 通过调用lseek函数,应用程序能够显式地修改当前文件的位置。函数声明如下:
  off_t lseek(int handle, off_t offset, int fromwhere);

8.3 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;
}

  printf需要做的事情是:接受一个fmt的格式,然后将匹配到的参数按照fmt格式输出。printf用了两个外部函数,一个是vsprintf,还有一个是write。
  vsprintf函数作用是接受确定输出格式的格式字符串fmt(输入)。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
  write函数将buf中的i个元素写到终端。
  从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
  字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
  显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

int getchar(void)
{
	static char buf[BUFSIZ];//缓冲区
	static char* bb=buf;//指向缓冲区的第一个位置的指针
	static int n=0;//静态变量记录个数
	if(n==0)
	{
		n=read(0,buf,BUFSIZ); 
		bb=buf;//并且指向它
	}
	return(--n>=0)?(unsigned char)*bb++:EOF;
}

  getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。
  当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。
  异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
  getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

  本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数。

结论

  hello所经历的过程:

hello最初是一个C语言源程序hello.c,即program。
预处理:对带#的指令解析,生成hello.i文件,解释预处理指令。  编译:经历编译后,得到hello.s,即汇编语言级代码。
汇编:把汇编语言转换成机器代码,生成重定位信息,生成.o文件。  链接:与动态库链接,生成可执行文件hello。
创建进程:在shell利用./hello运行hello程序,父进程通过fork函数为hello创建进程
加载程序:通过加载器,调用execve函数,删除原来的进程内容,加载我们现在进程的代码,数据等到进程自己的虚拟内存空间。
运行hello时,内存管理单元MMU、翻译后备缓冲器TLB、多级页表机制、三级cache同舟共济,完成对地址的请求。
异常处理机制保证了hello对异常信号的处理,使程序平稳运行  Unix I/O让程序能够与文件进行交互。
结束:当hello运行完毕,shell父进程回收hello,内核删除为这个进程创建的所有数据结构。

  Hello的实现,经历了众多的过程,在操作系统、硬件与软件的参与下,完成对hello的执行。其中经历了很多阶段,这些复杂却又固定的流程,是优化过的更适合计算机的处理流程。可以从hello的执行过程看出hello计算机系统的惊喜甚至完美。

附件

hello.c:源程序
hello.i:预处理后的文本文件
hello.s:编译后汇编程序文本文件
hello.o:汇编后的可重定位目标程序(二进制文件)
hello1.txt:hello.o的反汇编文件
hello2.txt:hello的反汇编文件
hello1elf.txt:ELF格式下的hello.o
hello2elf.txt:ELF格式下的hello
hello:链接后的可执行目标文件

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值