摘 要

本文介绍了程序Hello的一生。本文通过对Hello在Linux下的预处理、编译、汇编、链接等过程进行分析,详细讲解了一个程序由诞生到执行再到消亡的典型过程。虽然程序执行的过程在程序员眼中只是屏幕上的显示的字符串,但在短短几ms内,程序却经历了预处理,编译,汇编、链接,进程管理,IO管理,内存分配与回收等等一系列复杂的流程。同时也在本文中梳理了书本的知识,由hello的一生将整本书的内容连贯起来。回顾计算机系统所学内容,加深印象,增进对程序运行过程和计算机内部结构的了解。

关键词:hello程序,linux,链接,编译,汇编语言,IO,

第1章 概述

1.1Hello简介

P2P:

P2P指的是程序由一个项目变成一个进程的过程.

  1. Program:Hello程序的诞生是程序员通过键盘输入得到hello.c
  2. Process:C语言源程序hello.c在预处理器(cpp)处理下,得到hello.i,通过编译器(ccl),得到汇编程序hello.s,再通过汇编器(as),得到可重定位的目标程序hello.o,最后通过链接器(ld)得到可执行的目标程序hello。在shell中键入运行命令后,shell调用fork函数为其创建子进程。

020:

020为程序“从无到有再到无”的过程。程序经过系统OS,shell为hello进程execve,映射虚拟内存,进入程序入口后程序开始载入物理内存。进入 main 函数执行目标代码,CPU为执行文件hello分配时间周期,执行逻辑控制流,每条指令在流水线上取值、译码、执行、访存、写回、更新PC。当程序运行结束后, shell 父进程负责回收 hello 进程,内核删除相关数据结构。Hello程序从无到有再到无的这一过程就是020。

1.2 环境与工具

硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk

软件环境:Windows10 64位;VirtualBox/Vmware 15以上;Ubuntu 16.04;

开发工具:CodeBlocks 64位;vi/vim/gedit+gcc

1.3 中间结果

hello.c:程序Hello的c语言代码

hello.i:hello.c预处理之后的文本文件

hello.s:hello.i编译之后产生的汇编文件

hello.ld:链接后生成的文件

hello.o:可重定位的目标文件

hello:可执行文件

helloo.objdump:hello.o反汇编文件

hello.objdump:hello的反汇编文件

1.4 本章小结

本部分对hello从诞生到执行到消亡的P2P和020过程进行了简介,并对全文脉络进行梳理,并介绍了整个过程中所使用的环境和工具及生成的中间文件。

第2章 预处理

2.1 预处理的概念与作用

预处理的概念:预处理是c语言的一个重要功能,由预处理程序负责完成,将源文件.c预处理成.i文件,主要处理#开始的预编译指令

预处理的作用:合理使用预处理功能编写的程序便于阅读,修改,调试,有利于模块化设计。

1.将源文件中include包含的文件复制到源文件中,例如#include高速预处理器将文件stdio.h加入到源文件中。

2.用实际参数值替换宏#define值定义的字符串

3.根据#if,#ifdef等条件决定需要编译的代码

4.删除所有注释(/*……*/, //)

5.添加行号和文件标识

6.保留#pragma编译器指令

预处理名称有以下几种:

预处理名称

相应事件

#define

宏定义

#undef

撤销已定义的宏名

#include

使编译程序将另一源文件嵌入到带有#includc的源文件中

#if

#if 的一般含义是如果#if后面的常量表达式为true,则编译它与#endif 之间的代码,否则跳过这些代码。命令#endif标识一个#if块的结束。#else命令的功能类似C语言中的else , #else建立另一选择(在# if失败的情况下)。#elif命令意义与else if类似,它形成一个if else-if阶梯状语句,可进行多种编译选择。

#else

#elif

#endif

#ifdef

用#ifdef 与#ifndef命令分别表示“如果有定义”及“如果无定义”,是条件编译的另一种方法。

#ifndef

#line

改变当前行数和文件名称,它们是在编译程序中预先定义的标识符命令的基本形式如下:

#error

编译程序时,只要遇到#error就会生成一个编译错误提示消息,并停止编译

#pragma

为实现时定义的命令,它允许向编译程序传送各种指令例如,编译程序可能有一种选择,它支持对程序执行的跟踪。可用#pragma语句指定一个跟踪选择。

2.2在Ubuntu下预处理的命令

预处理指令:gcc -E hello.c -o hello.i

图2.2a Ubuntu下预处理命令与执行结果

图2.2b hello.i部分内容

2.3 Hello的预处理结果解析

由图2.2b可知,经过预处理后,hello.c的23行代码被扩展为3060行,其中main函数占第3047~3060行,main函数前为hello.c引用的stdio.h等头文件的内容。同时,注释内容被去除。

  1. 预处理过程中预处理器(cpp)识别到#include这种指令就会在环境中搜寻该头文件并将其递归展开。
  2. hello.c中开头的注释被删除,#include包含的几个文件被插入到hello.i中

2.4 本章小结

本章主要介绍了预处理的概念和作用,包括预处理的宏定义,文件包含,条件编译等方面的内容,并对hello.c经过预处理得到的hello.i文件进行了对比分析。

第3章 编译

3.1 编译的概念与作用

概念:编译器(ccl)将预处理生成的文件hello.i翻译成汇编文件文件hello.s 它包含一个汇编语言程序。

作用:将高级语言编译为相应的机器语言,这里将C语言转化为intel x86汇编指令。

3.2 在Ubuntu下编译的命令

指令:gcc -S hello.i -o hello.s

图3.2 编译结果

3.3 Hello的编译结果解析

图3.3 hello.s部分内容

3.3.1数据

1.字符串

1)“Usage: Hello \345\255\246\345\217\267 \345\247\223\345\220\215\357\274\201”

对应.c文件中”Usage: Hello 学号 姓名!\n”

其中中文已被编码为UTF-8 格式 一个汉字占3个字节

2) “Hello %s %s\n”

对应原c文件中”Hello %s %s\n”第二个printf中的格式化参数

其中后两个字符串已在.rodata中声明

2.整数

1)int i(局部变量)

图3.3a hello.c部分代码

根据movl $0, -4(%rbp) 可以看出编译器将i存到了-4(%rbp) 中,且占4个字节。与addl $1, -4(%rbp)和cmpl $7, -4(%rbp)实现循环结构for(i=0;i<=u;i++)。

2)int argc

作为第一个参数被压栈pushq %rbp,传入main函数

3)立即数

程序中其他整型都是以立即数的形式出现

4)数组 char *argv[]

这是一个指针数组,由addq $, %rax可以看出,一个内容占8个字节,说明linux中一个地址的大小是8个字节。

3.3.2赋值

1) i=0

movl $0, -4(%rbp)

通过这局mov指令将0赋给了i

因i为int类型,占4个字节,故用后缀l。

3.3.3算术操作

1)i++

addl $1, -4(%rbp)

自增运算,每次运行时i增加1

3.3.4 关系判断

1)argc!=4

先将argc的值存在-20(%rbp) 的位置,与4比较。判断是否跳转L2

2)i<8

通过比较i与7的值,如果i小于等于7,则跳转L4继续执行循环里的内容否则退出循环

3.3.5控制转移

1)if(argc!=4)

比较4与-20(%rbp)中的值(即argc),若相等则跳转至L2,实现if(argc!=4)的功能。

2)for(i=0;i<8;i++)

使用 cmpl 进行比较,如果 i<=7,则跳入.L4 for循环体执行,否则说明循环结束,顺序执行for之后的逻辑。

3.3.6数组操作

argv[1],argv[2]

使用movq, addq,先后将栈上的argv[1],argv[2]存入寄存器。

3.3.7函数调用

函数的调用需要有以下过程:传递控制、数据传递、分配空间

1)main函数

main函数被系统启动函数 __libc_start_main 调用,call指令将main函数的地址分配给%rip,随后调用main函数。main函数的两个参数argc, *argv[](首地址)分别储存于%rdi 和%rsi 。正常运行时,函数以return 0为出口,将%eax 设置 0 返回。

程序使用%rbp 记录栈帧的底,函数分配栈帧空间,最后使用leave指令将栈恢复为调用之前的状态,ret返回将下一条指令地址设置为%rip

2)printf函数

第一处调用printf 第二处调用printf

Hello程序中有两处调用printf,第一处调用时,程序将%rdi设置为字符串”用法: Hello 学号 姓名 秒数!\n “的首地址,第二处调用时将%rdi设置为”Hello %s %s\n”的首地址,同时将argv[1],argv[2]分别存入%rsi,%rdx。

第一处调用printf时,字符串固定,只有一个参数,故用call put@PLT。第二处调用时,有三个参数,使用call printf@PLT

  1. exit函数

call exit@PLT call调用exit

  1. sleep函数

将%eax 内容储存至 %edi,之后call sleep@PLT调用sleep函数

  1. getchar 函数

call getchar@PLT调用getchar

3.4 本章小结

本章总结并分析了编译器是如何处理c语言的各个数据类型和各类操作,如变量处理、赋值、算数操作,关系判断和函数调用等,对应于书上与汇编语言相关的章节。经过该步骤hello.s是更加接近机器层面的汇编代码。

第4章 汇编

4.1 汇编的概念与作用

概念:驱动程序运行汇编器as,将汇编语言(hello.s)翻译成机器语言(hello.o)的过程称为汇编,hello.o是一个二进制文件,包含着程序的指令编码。

作用:将高级语言转化为机器可直接识别并执行的二进制机器代码。这个二进制机器代码是程序在本机器上的机器语言的表示。

4.2 在Ubuntu下汇编的命令

命令: gcc-c hello.,s -o hwwqql

汇编结果

4.3 可重定位目标elf格式

ELF头:

ELF头:包含信息为文件结构的说明信息:16字节的标识信息,文件类型,机器类型,节头表偏移,节头表的表项大小,表项个数,生成该文件的系统字大小和字节顺序

节头部表:

节头部表描述不同节的位置和大小,目标文件中1每个节都有一个固定大小的条目,相关信息包括节的名称,类型,地址,偏移量,对齐,旗标等。

由输出可知,Hello程序共有14个节。

重定位节:

重定位用于在汇编器生成目标模块时,对最终位置未知的目标引用生成一个重定位条目。链接器在链接生成可执行文件时可据此修改这个引用。

Rela.text和.rela.eh_frame中包含.text节中需要进行重定位的信息,在链接时需要修改这些信息的位置。

该程序重定位有R_X86_64_PC32,R_X86_64_PLT32两种基本类型,分别用于重定位使用32bitPC相对地址、绝对地址的引用

4.4 Hello.o的结果解析

Hello.o反汇编代码

hello.s汇编代码

运行objdump -d -r hello.o,获得hello.o的反汇编代码。由于反汇编代码从机器语言翻译形成,跳转时,地址一般以相对地址表示。同时,操作数一般以16进制表示。二者具体差异如下:

过程

差异

汇编语句

hello.o的反汇编文件中,每条语句前都加上了具体的地址,同时在最前方加入了时钟周期信息。

操作数

hello.s中操作数为十进制,反汇编代码中操作数为十六进制。

全局变量访问

hello.s文件中对于全局变量的访问为.LC0(%rip),而在反汇编代码中是$0x0和0(%rip),这是由于全局变量的地址在运行时已得到确定。

分支跳转

反汇编代码中用相对偏移地址取代了hello.s中的标志位。

函数调用过程

反汇编代码中用相对于main函数的偏移地址表示函数地址,而不是hello.s中的函数名称。原因是函数在链接后运行的地址已得到确定。

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

4.5 本章小结

本章介绍了Hello程序汇编过程,并展示了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.3 可执行目标文件hello的ELF格式

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

Hello的ELF头:

与hello.o的ELF头相比,有以下几处不同:

  1. 文件的类型由可重定位文件变为可执行文件
  2. 程序的入口点、程序开始点、节头表偏移发生改变
  3. 共有27个节头表,增加了10个。

节头部表

程序头:

段节:

重定位节

5.4 hello的虚拟地址空间

使用edb加载hello,得到本进程的虚拟地址空间各段信息。其与反汇编代码中的虚拟地址可一一对应。

Hello程序头表在程序执行的时候被使用,作用是告诉链接器运行时应该加载的内容并提供动态链接的信息,提供了各段在虚拟地址空间和物理地址空间的大小,位置,标志,访问权限和对齐等信息

5.5 链接的重定位过程分析

hello部分反汇编代码

运行objdump -d -r hello,得到上图所示反汇编代码,对比hello.o的反汇编代码,二者主要差异如下:

内容

差异

代码量

hello的反汇编代码长度比hello.o大很多,这与链接后生成的hello中加入了hello.c中调用的其他函数(如printf,exit等)有关。

地址

Hello反汇编代码中地址为虚拟内存地址,hello.o反汇编代码中为相对偏移地址。这是因为hello无需重定位。

代码内容

hello的反汇编代码中增加了.init和.plt节以及节中定义的函数

重定位条目

hello的反汇编代码无重定位条目。这是因为hello无需重定位。

由此可分析,链接包括以下4个过程:

  1. 判断输入文件是否为库文件,如果不是则是目标文件f,目标文件放入集合E中。
  2. 链接器解析目标文件中的符号,若出现未定义的符号则将其放入集合U,出现了定义但未使用的符号则放入集合D中
  3. 链接器读入crt*库1中的目标文件
  4. 接入动态链接库libc.so

重定位的步骤:

  1. 合并相同的节:链接器首先将所有相同类型的节合并成为同一类型的新节,例如所有文件的.data节合并成一个新的.data节,合并完成后该新节即为可执行文件hello的.data节。
  2. 确定地址:之后链接器再分配内存地址赋给新的聚合节和输入模块定义的节以及符号。地址确定后全局变量,指令等均具有唯一的运行时地址

5.6 hello的执行流程

图5.6a edb运行hello结果图5.6b hello的执行流程

使用edb执行hello,结果如图5.6a所示。加载hello到_start,到call main,以及程序终止的过程如图5.6b所示。

5.7 Hello的动态链接分析

首先,根据节头部表找到GOT表地址,由图可知其在0x403ff0。

图5.7a _init前的Data Dump

图5.7b _init后的Data Dump

在edb中定位0x403ff0地址,并在_init前后设置断点,结果如图5.7a、图5.7b所示。由结果可知在dl_init前后,0x403ff0 处和0x404000 处的8bit数据分别由000000000000变为了c05fb1b3fe7e和90b1d2b3fe7e,GOT[1]指向重定位表(.plt节需要重定位的函数运行时地址),作用是确定调用函数的地址,GOT[2]指向动态链接器ld-linux.so运行时地址

5.8 本章小结

本章针对linux系统下链接的过程进行查看与介绍。链接是程序变成可执行文件的最后一步。通过链接,各代码段和数据段被整合到一起。本章通过在edb或终端查看hello的虚拟地址空间,对比hello.o和hello的反汇编代码等一系列过程,对重定位,执行流程和动态链接过程进行分析与概述。

第6章 hello进程管理

6.1 进程的概念与作用

概念:进程是操作系统对一个正在运行的程序的一种抽象

作用进程提供给应用程序的关键抽象;一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统

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

shell的作用:提供一个界面,用户通过这个界面访问操作系统内核的服务。

处理流程:

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

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

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

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

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

6.3 Hello的fork进程创建过程

执行hello后,bash解析此条命令,发现./hello不是bash内置命令,于是在当前目录尝试寻找并执行hello文件此时bash调用fork函数创建一个子进程,子进程与父进程近似,并得到一份与父进程用户级虚拟空间相同且独立的副本——包括数据段、代码、共享库、堆和用户栈。二者间的PID不相同,fork函数会返回两次,在父进程中,返回子进程的PID,在子进程中,返回0。

6.4 Hello的execve过程

execve函数的原型为:

int execve(const char *filename,const charargv[],const char envp[])

execve()用来执行参数filename字符串所代表的文件路径,第二个参数利用指针数组来传递给执行文件,并且需要以空指针NULL结束,最后一个参数为传递给执行文件的新环境变量数组,其中每个指针指向一个环境变量字符串,每个串都是形如name=value的名字-值对。当execve加载了filename之后,它调用启动代码,启动代码设置栈,将控制传递给新程序的主函数main。当出现例如找不到filename的错误,execve将返回调用程序,与fork调用一次返回两次不一样,execve调用一次并从不返回。

execve开始执行hello有以下4个步骤

删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。即删除之前shell运行时已经存在的区域结构

映射私有区域。为hello的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text 和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。

映射共享区域。hello程序与共享对象链接,比如标准C库1ibc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

设置程序计数器(PC)。最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的人口点

6.5 Hello的进程执行

6.5.1基本概念

时间片:指的是一个进程执行它的控制流的一部分的每一时间段,多任务也称为时间分片

用户模式和内核模式:处理器通过用某个控制寄存器的模式位实现限制一个应用可以执行的指令以及它可以访问的地址空间范围的功能,该寄存器描述了进程当前享有的特权,当设置模式位时,进程运行在内核模式中,一个运行在内核模式中的进程可以执行指令集的任何指令,并且可以访问系统中的任何内存位置。

当没有设置模式位时,进程运行在用户模式中,用户模式中的进程不允许执行特权指令,例如停止处理器,改变模式位,不允许进程直接引用地址空间中内核区的代码段和数据,此时用户程序必须通过系统调用接口间接访问内核代码和数据。

控制流:计算从加电开始,到断点位置,程序计数器的一系列PC的值的序列叫做控制流。

逻辑控制流:使用调试器单步执行程序时,会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。即逻辑控制流是一个进程中PC值的序列。

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

图6.5.1 上下文切换示意图

6.5.2 Hello的调度过程

图6.5.2 Hello程序调度过程

Hello调度过程如图6.5.2所示,具体如下:

Sleep调度:程序运行到sleep 函数时,sleep显式地请求让hello进程休眠。等到sleep的时间sleepsecs(这里为2秒,不是2.5秒)到达之后。从其他进程切换到hello继续执行。其过程如下:

  1. 从hello进程陷入内核模式
  2. 内核进行上下文切换,执行与hello并发的其他进程
  3. Sleep休眠时间结束
  4. 其他进程陷入内核
  5. 内核进行上下文切换,继续执行hello进程

正常调度:

当hello运行一定时间之后,虽然此时hello没有请求挂起自己,但系统会切换到其他进程,其他进程执行结束之后,hello再次被调度并继续接着上次的PC地址开始执行。调度的过程与sleep调度相似。

6.6 hello的异常与信号处理

正常运行:

图6.6a正常运行状态截图

程序完成被正常回收。

2)输入ctrl+z

图6.6b 输入ctrl+z后运行状态截图

这时输入命令PS查看进程,如下图所示:

图6.6c 输入ps后程序输出

使用jobs指令查看,得到以下结果,故可知,此时,hello进程没有结束,而是被暂时挂起,PID为45856。

图6.6d 输入jobs后程序输出

3)输入ctrl+c

图6.6e 输入ctrl+c、ps后程序输出

输入jobs,结果如下:

图6.6f 输入jobs后程序输出

输出为空,可以判断进程直接被终止,被回收.

4)输入kill

图6.6g 输入kill后程序输出

输入ps,结果如下:

当输入kill后进程被杀死,此时再输入ps指令后发现当前无进程执行。

5)随机输入

图6.6h随机输入后程序输出

在执行的程序中随机输入,可以看到输入被存入stdin中,按下回车键,在程序结束后会输出,但是对程序没有任何影响,如图6.6h所示:

6.7本章小结

本章结合示意图讲述了进程的概念与作用,简述了壳Shell-bash的作用与处理,介绍了hellofork迸程创建过程以及execve过程,结合ps,jobs命令的输出介绍了hello的进程执行以及异常常与信号处理流程。

第7章 hello的存储管理

7.1 hello的存储器地址空间

基本概念:

物理地址:物理地址是内存单元的绝对地址,与地址总线具有对应关系。无论CPU如何处理地址,最终访问的都是物理地址。CPU实模式下段地址+段内偏移地址即为物理地址,CPU可以使用此地址直接访问内存。物理地址的大小决定了内存中有多少个内存单元,物理地址的大小由地址总线位宽决定。

线性地址(虚拟地址):CPU在保护模式下,“段基址+段内偏移地址”为线性地址,如果CPU在保护模式下未开启分页功能,线性地址将被当成物理地址使用。若开启了虚拟分页功能,线性地址等同于虚拟地址,此时虚拟地址需要通过页部件电路转化为最终的物理地址。虚拟地址是CPU由N=2n个地址空间中生成的,虚拟地址即为虚拟空间中的地址

逻辑地址:无论cpu在什么模式下,段内偏移地址又称为有效地址/逻辑地址。Hello中的指令地址都是16位的虚拟地址,在程序中虚拟地址和逻辑地址没有明显的界限。逻辑地址转换成线性地址(虚拟地址),由段式管理执行的线性地址转换成物理地址,是由页式管理执行的

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

概念:段式管理指的是把一个程序分成若干个段进行存储,每个段都是一个逻辑实体。段式管理是通过段表进行的,包括段号(段名),段起点,装入位,段的长度等。程序通过分段划分为多个块,如代码段,数据段,共享段等。

逻辑地址是程序源码编译后所形成的跟实际内存没有直接联系的地址,即在不同的机器上使用相同的编译器来编译同一个源程序则其逻辑地址是相同的,但在不同机器上生成的线性地址是不相同的。

逻辑地址转化为线性地址的过程:先检查段选择符的T1字段,该字段决定了段描述符保存在哪一个描述符表中,(比如转换的是GDT还是LDT中的段)。如果是GDT中的段(T1=0),分段单元从gdtr寄存器中得到GDT中的线性基地址。如果是LDT中的段(T1=1),分段单元从ldtr寄存器中得到GDT的线性基地址。之后再根据相应寄存器得到地址和大小。之后,由于一个段描述符字长为8bits,其在GDTLDT中的相对地址是段选择符的最高13位的值×8。此时我们就得知了其偏移地址。最后,通过base+offset即可得到线性地址,即逻辑地址转化为线性地址的公式:线性地址 = 段基址*16+偏移的逻辑地址

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

虚拟地址包含虚拟页号、虚拟页面偏移两个部分。它被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。将PTE中的PPN与VPO串联,得到最终的物理地址。

虚拟内存被分割为为虚拟页,物理内存也被分割成物理页。利用页表来管理虚拟页,页表就是一个页表条目的数组,每个PTE由一个有效位和一个n位地址字段组成,有效位表明了该虚拟页当前是否被缓存在DRAM中,如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。如果发生缺页,则从磁盘读取。

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

一到三级页表中存放的数据是指向下一级页表的首地址,而不是物理页号。逐步访问到第四级页表,第四级页表中装的就是物理页号,通过第四级页表读出的物理页号链接上虚拟地址中的VPO获得物理地址。以下为k级页表示意图。

图7.4 k级页表示意图

36位VPN被划分成了4个9位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。

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

CPU发送一条虚拟地址,随后MMU按照上述操作获得了物理地址PA,根据组号寻找到正确的组,比较每一个cacheline是否标记位有效以及标记位是否相等。如果命中就直接返回想要的数据,如果不命中,就依次去L2,L3,主存判断是否命中,当命中时,将数据传给CPU同时更新各级cache的cacheline,如果主存缺页则访问硬盘。

7.6 hello进程fork时的内存映射

以下格式自行编排,编辑时删除

7.7 hello进程execve时的内存映射

当hello进程调用fork函数时,内核为新进程创建当前进程的mm_struct,vm_area_struct和页表的原始样本,将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。

当fork在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。

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

缺页故障与缺页中断处理包括以下过程:

1.处理器生成虚拟地址,传送给MMU

2.MMU翻译出PTE地址,并在高速缓存中查找PTE

3.高速缓存将PTE返还给MMU

4.PTE中有效位是0,引发缺页异常,调用缺页异常处理程序

5.该程序选择一个牺牲页把它换到磁盘

6.缺页处理程序页面调入新的页面,并更新内存中的PTE

7.缺页处理程序返回到原来的进程,再次执行导致缺页的命令。

示意图如下图所示:

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。

分配器将堆视为一组大小不同的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是以分配的,要么是空闲的。已分配的块显式的保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

分配器有两种基本风格,两种风格都要求应用显式的分配快。它们的不同之处在于由哪个实体来负责释放已分配的快。

显式分配器,要求应用显式地释放任何已分配的块。例如C标准库提供一种叫malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作和C中的malloc和free相当。

隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫垃圾收集器,而自动释放未使用的已分配块的过程叫做垃圾收集。

7.10本章小结

本章结合示意图介绍了hello程序的存储器地址空间,包括各地址空间的转换,页式管理等,介绍了VAPA的转换,物理内存访问,调用fork函数和execve函数时的内存映射,发生缺页故障后的处理方法,以及动态储存的分配管理方法。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化——文件:一个linux文件就是一个m字节的序列,所有I/O设备都被模型化为文件,所有的输入和输出都被当作对应文件的读和写来执行。文件有以下几种类型:

类型

描述

普通文件

包含任意数据,分为文本文件和二进制文件

目录

包含一组链接的文件,每个链接都将一个文件名映射到一个文件

套接字

用来与另外一个进程进行跨网络通信的文件

其他文件

命名通道,符号链接,字符和块设备

设备管理——unix io接口:使得所有的输入和输出都能以统一且一致的方式进行。有以下几种操作:

操作

描述

打开文件

应用程序通过要求内核打开相应文件来宣告它想要访问一个I/O设备

改变当前文件的位置

应用程序通过执行seek操作显式设置文件当前位置为k

读写文件

读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n

写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始然后更新k

关闭文件

内核收到关闭文件的请求后就释放文件打开时创建的数据结构并将这个描述符恢复到可用的描述符池中

8.2 简述Unix IO接口及其函数

unix io接口:

打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。

改变当前文件的位置:对于每个打开的文件,内核保持着一个文件位置k、初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显示地设置文件的当前位置为k。

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

关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。

操作函数:

1.open()

函数

open()

功能

打开一个已经存在的文件或是创建一个新文件

函数原型

int open(const char *pathname,int flags,int perms);

返回值

成功:返回文件描述符

失败:返回-1

2.read()

函数

read()

功能

从文件读取数据,执行输出

函数原型

ssize_t read(int fd, void *buf, size_t count);

返回值

正常读取:读取的字节数

异常:0(读到EOF);-1(出错)

3.write()

函数

write()

功能

向文件写入数据

函数原型

ssize_t write(int fd, void *buf, size_t count);

返回值

写入成功:写入文件的字节数

出错:-1

4.close()

函数

close()

功能

关闭一个被打开的的文件

函数原型

int close(int fd);

返回值

成功:0

出错:-1

5.lseek()

函数

lseek()

功能

用于在指定的文件描述符中将文件指针定位到相应位置

函数原型

off_t lseek(int fd, off_t offset,int whence);

返回值

成功:返回当前位移

失败:-1

8.3 printf的实现分析

printf函数的函数体如下:

  1. intprintf(constcharfmt,…)
  2. {
  3. inti;
  4. charbuf[256];
  5. va_listarg=(va_list)((char)(&fmt)+4);
  6. i=vsprintf(buf,fmt,arg);
  7. write(buf,i);
  8. returni;
  9. }

printf()函数将变长参数的指针arg作为参数,传给vsprintf函数。然后vsprintf函数解析格式化字符串,调用write()函数。vsprintf的示意函数如下:

  1. intvsprintf(char*buf,constchar*fmt,va_listargs)
  2. {
  3. char*p;
  4. chartmp[256];
  5. va_listp_next_arg=args;
  6. for(p=buf;*fmt;fmt++){
  7. if(*fmt!=‘%’){
  8. *p++=*fmt;
  9. continue;
  10. }
  11. fmt++;
  12. switch(*fmt){
  13. case‘x’:
  14. itoa(tmp,*((int*)p_next_arg));
  15. strcpy(p,tmp);
  16. p_next_arg+=4;
  17. p+=strlen(tmp);
  18. break;
  19. case‘s’:
  20. break;
  21. default:
  22. break;
  23. }
  24. }
  25. return(p-buf);
  26. }

发现这个函数最后返回的是打印字符串的长度,也就是说这句话得到了字符串的长度i在下一句传给write函数。write函数如下:

  1. moveax,_NR_write
  2. movebx,[esp+4]
  3. movecx,[esp+8]
  4. intINT_VECTOR_SYS_CALL

在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,最后,write函数调用syscall(int INT_VECTOR_SYS_CALL)。syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存 储到 vram 中。显示芯片会按照一定的刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量),最终打印出了我们需要的字符串。

8.4 getchar的实现分析

getchar函数体如下:

  1. intgetchar(void)
  2. {
  3. staticcharbuf[BUFSIZ];
  4. staticchar*bb=buf;
  5. staticintn=0;
  6. if(n==0)
  7. {
  8. n=read(0,buf,BUFSIZ);
  9. bb=buf;
  10. }
  11. return(–n>=0)” />unsignedchar)*bb++:EOF;
  12. }

当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

8.5本章小结

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

结论

用计算机系统的语言,逐条总结hello所经历的过程。

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

Hello程序的一生:

  1. 预处理:gcc执行hello.c中的预处理命令,合并库,宏展开、
  2. 编译,将hello.i编译成为汇编文件hello.s
  3. 汇编,将hello.s会变成为可重定位目标文件hello.o
  4. 链接,将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello
  5. 运行:在shell中输入./hello1170801219 yangjin开始运行程序
  6. 创建子进程:shell进程调用fork为其创建子进程,分配pid。
  7. 运行程序:子进程shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数。
  8. 执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流
  9. 访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
  10. 动态申请内存:调用malloc向动态内存分配器申请堆中的内存。
  11. 信号:运行途中键入ctr-c ctr-z则调用shell的信号处理函数分别停止、挂起。
  12. 结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。

感悟:

真正做到深入理解计算机系统很难,这是无需质疑的,但是,万事开头难。我想这句话用在学这本书上也是适合的。随着学习的深入,原先觉得难以理解的概念变的平易近人起来。

附件

hello.c: hello的C语言源程序

hello.i: ASCII码的中间文件(预处理器产生),用于分析预处理过程。

hello.s: ASCII汇编语言文件,用于分析编译的过程。

hello.o:可重定位目标程序(汇编器产生),用于分析汇编的过程。

hello:可执行目标文件(链接器产生),用于分析链接的过程。

hello_o.txt:hello.o的objdump反汇编文件,用于分析可重定位目标文件hello.o。

hello.txt:hello的objdump反汇编文件,用于分析可执行目标文件hello。

参考文献

[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.

[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.

[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).

[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.

[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.

[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.