csapp 大作业 程序人生-Hello’s P2P

摘 要

本文通过对hello.c文件在linux系统下的生命周期的分析,简单地介绍计算机的整个运行过程。
结合GCC、objdump等工具,本文首先介绍hello.c的预处理、编译、汇编、链接过程,然后介绍可执行文件hello的加载、运行、IO设备交互、异常处理。
通过这样一个完整的分析过程,将课本知识落实到具体的操作。

关键词:CSAPP;HIT:大作业;hello的一生

第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

1.2 环境与工具

硬件环境:
英特尔 Core i7-7700HQ @ 2.80GHz 四核
一级数据缓存 4 x 32 KB, 8-Way, 64 byte lines
一级代码缓存 4 x 32 KB, 8-Way, 64 byte lines
二级缓存 4 x 256 KB, 4-Way, 64 byte lines
三级缓存 6 MB, 12-Way, 64 byte lines
软件环境
Ubuntu64 18.04
开发工具
Shell、vscode、readelf、objdump、ld、gcc

第2章 预处理

2.1 预处理的概念与作用

C 预处理器不是编译器的组成部分,但是它是编译过程中一个单独的步骤。简言之,C 预处理器只不过是一个文本替换工具而已,它们会指示编译器在实际编译之前完成所需的预处理。我们将把 C 预处理器(C Preprocessor)简写为 CPP。
所有的预处理器命令都是以井号(#)开头。它必须是第一个非空字符,为了增强可读性,预处理器指令应从第一列开始。
预处理器主要负责以下任务:

  1. 宏替换
  2. 删除注释
  3. 处理预处理指令,比如 #include #ifdef
2.2在Ubuntu下预处理的命令

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

2.3 Hello的预处理结果解析

预处理之后,预处理器将注释消除。执行#include的指令,将其使用的库函数内容展开。

2.4 本章小结

预处理器仅仅将代码进行中间翻译。不产生汇编代码或者二进制代码。其产生预处理文件之后将剩下的工作留给编译器。

第3章 编译

3.1 编译的概念与作用

编译程序(Compiler,compiling program)也称为编译器,是指把用高级程序设计语言书写的源程序,翻译成等价的机器语言格式目标程序的翻译程序。编译程序属于采用生成性实现途径实现的翻译程序。它以高级程序设计语言书写的源程序作为输入,而以汇编语言或机器语言表示的目标程序作为输出。编译出的目标程序通常还要经历运行阶段,以便在运行程序的支持下运行,加工初始数据,算出所需的计算结果。

3.2 在Ubuntu下编译的命令

命令: gcc -S -o hello.s hello.i -v

3.3 Hello的编译结果解析

在此,首先将hello.c的源代码给出(注释为语句涉及到的功能):

#include <stdio.h>
#include <unistd.h> 
#include <stdlib.h>

int sleepsecs=2.5; //全局变量
int main(int argc,char *argv[])
{
    int i;//局部变量
    if(argc!=3)//if控制语句
    {
        printf("Usage: Hello 学号 姓名!\n");//函数操作,数组常量
        exit(1);
    }
    for(i=0;i<10;i++)//赋值操作, ++算术操作, < 运算操作 for循环操作
    {
        printf("Hello %s %s\n",argv[1],argv[2]);//函数操作
        sleep(sleepsecs);//函数操作
    }
    getchar();//函数调用
    return 0;
}
3.3.1 全局变量

本程序中仅有1个全局变量:
int sleepsecs=2.5;
再汇编语言文件hello.s中,全局变量sleepsecs保存在段rodata 中,在程序运行时仅仅作为只读变量。这里与源代码中的定义稍有不同,源代码中sleepsecs是作为int整形可改变的变量,而在汇编文件中被翻译为只读变量,相当于加了const修饰。
其产生的原因是:是编译器察觉到sleepsecs在后面的程序中没有被任何语句赋值,于是将其优化为只读变量。
在汇编文件中有关sleepsecs 的代码

3.3.2 if()条件控制语句

if语句在汇编中被翻译为如下语句
在这里插入图片描述
其中,27是读取传入主函数的参数argc,29是条件判断,30则是跳转控制语句。语句30以下对应argc!=3的代码内容。.L2对应argc==3的内容。
在这里插入图片描述

3.3.3 局部变量

本程序中的局部变量只有1个:
int i;
且i是for循环的循环控制变量,只有在循环控制中才被赋值、更新值
在本程序的汇编文件中,i的值存储在栈中,初始化语句在.L2节中。
如下:
movl $0, -4(%rbp) //for循环初始化 i =0
然后在循环只用对i的引用都是通过栈中的数据进行访问。这一点与x86-64中通常使用寄存器保存局部变量的这一行为稍微有所不同。

3.3.4 for循环翻译

紧接3.3.2中的局部变量进行解释。for循环编译过程中被拆分为3个部分:初始化变量、条件判断语句、循环体。

  1. 初始化变量
    C语言中的变量初始化语句为: for(i=0;i<10;i++)中的 i=0。在汇编中对应为.L2小节的代码:
    在这里插入图片描述
    在这里,为局部变量i分配为空间,使用栈中 %rbp所指的地址之前的-4字节处为开头,存储一个4字节的0。
    由于栈是向着低位地址生长,这里使用的空间为main()函数调用产生的栈空间中。

  2. 条件判断语句
    C语言中的条件判断语句为:for(i=0;i<10;i++)中的i<10
    在汇编中对应为.L3小节的部分代码:
    在这里插入图片描述

这里引用之前为局部变量i分配的栈空间中的地址。从而达到访问局部变量i的目的。
将i于9进行比较。当i<=9时跳转到.L4小节中。这里对应于条件控制中的i<10。二者是完全等价的。其中,.L4小节为循环体。
这里通过.L3的两条汇编语句,实现了for循环中的条件判断功能。
3. 循环体
循环体的所有内容都保存在.L4节中。但是.L4中的汇编语句并非全部是循环体中的内容。在.L4的最后一句汇编语句:在这里插入图片描述
其功能为加1到i变量中,即实现for循环for(i=0;i<10;i++)中的 i++控制语句。

3.3.5 函数调用。

在这里,我们选择如下语句的函数调用来分析hello.c中的函数是如何进行调用的。
在这里插入图片描述
在这里,printf()函数有3个参数。x86-64汇编语言参数是通过寄存器加栈来传递参数的。根据其传输规则:
第一个参数放入%rdi寄存器、第二个放入%rsi,第三个放入%rdx中。
在这里插入图片描述
在汇编文件中,gcc产生的代码用如下形式构造与传递参数。
在这里插入图片描述
首先构造第三个参数,38、39句语句将栈中保存的argv[2]指针的地址保存到%rax中,然后40句将栈中保存的argv[2]的指针赋值给%rdx,从而构造好了第3个参数。

第二个参数的构造与第三个参数的构造方法完全相同。41、42、43、44句成功地构造第二个参数。

第三个参数使用到了一个字符常量 "Hello %s %s\n"。在汇编中,其保存为.LC1标签中。找到其汇编中的位置,确实是在C语言中的字符常量。
在这里插入图片描述
最后初始化其返回值%eax为0,48调用函数printf()

3.4 本章小结

编译是将高级语言翻译为汇编语言的过程,本章中列举了几个c语言的语句转化为汇编语言的例子。GCC等编译器将c语言的每一种结构(比如if条件控制、for循环)翻译为汇编中的代码。C语言中的变量名消失了,取而代之的是一些及存取数据、栈数据、内存数据。
由于本次编译使用的普通翻译,GCC的优化功能没有打开。在实际编译中,编译器可以根据代码的特点进行效率的优化。

第4章 汇编

4.1 汇编的概念与作用

概念:把汇编语言翻译成机器语言的过程称为汇编
作用:将文字形式的汇编代码转化为真正的机器可以执行的二进制代码。

4.2 在Ubuntu下汇编的命令

汇编命令:gcc -c hello.s -o hello.o -v

4.3 可重定位目标elf格式

在这里插入图片描述
关键信息:

  1. 数据采用补码表示,小端表示、
  2. 共有21个节头表。每个节头表占用空间64byte
    在这里插入图片描述
    在这里插入图片描述
4.4 Hello.o 的结果解析

这里仅仅分析部分机器语言与汇编语言中稍微有差距的语句。汇编中与机器 代码反汇编之后完全相同的不做讨论

  • je指令(其余跳转命令相对地址确定方式相同) 在这里插入图片描述
  • 全局变量引用在这里插入图片描述在这里插入图片描述
  • call指令(调用共享库函数) 在这里插入图片描述
4.5 本章小结

本章中对 hello.s 进行了汇编操作,生成可重定位文件 hello.o Hello.o 的 elf 信息进行了分析。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的格式在这里插入图片描述

关键信息:

  1. 被修改的信息用红色箭头指出。
  2. 文件的类型由可重定位文件变为可执行文件
  3. 程序的入口点、程序开始点、节头表偏移都发生了变化
  4. 共有33个节头表。比链接之前的22个多出11个
    在这里插入图片描述在这里插入图片描述
5.4 hello的虚拟地址空间

部分节的虚拟地址:

plt节在这里插入图片描述
PLT的数据内容从地址0x400418开始,这一点在edb中可以证实。在节头表中其也用offset表示出了PLT段的起始地址为0x400418在这里插入图片描述在这里插入图片描述
.text的数据内容从地址0x4004d0开始,这一点在edb中可以看到。在.text的节头表中也用offset表示出了.text段的起始地址为0x4004d0

5.5 链接的重定位过程分析
  • 全局变量引用(绝对地址)
    之前在汇编的过程中,全局变量.LC0的地址用了4byte的0来占位,在静态链接之后,其地址值在重定位过程中被修改。4个byte中填上了.LC0的虚拟地址(绝对地址)
    在这里插入图片描述
    汇编之后,.LC0没有确定的地址不仅在代码段留下的待填入的空格,还在重定位段生成了对应的重定位条目。如下在这里插入图片描述
    链接器根据重定位条目中的offset找到需要重定位的.LC0引用的地址空位,然后根据其Type信息,选择相应的计算方法算出虚拟地址(绝对或者相对)
  • 函数调用(相对地址)
    函数调用的指令为e8,在hello.o文件中,e8后面跟随着4 byte 0,链接之后,这4字节的0被替换为对应函数的虚拟地址(相对地址)。在这里插入图片描述
5.6 hello的执行流程

Hello的执行前,系统为创建子进程,从内存加载数据,然后跳转到_start()函数。
在这里插入图片描述在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

5.7 Hello的动态链接分析

对于共享库的调用:在程序中第一次执行共享库函数的时候才会调用动态链接器进行链接。以printf()函数为例子进行说明
首先使用gdb打开hello文件,在main处打上断点
在这里插入图片描述
然后输入命令 r 1170801219 yangjin开始运行程序。
之后程序停在断点main处。
在这里我们使用命令tui enable打开tui调试窗口,
然后为了方便观察反汇编命令,使用命令layout asm打开反汇编窗口在这里插入图片描述
使用si命令让程序继续执行,直到调用第一个共享库printf()函数之前
在这里插入图片描述
然后程序进入plt段的代码。plt段代码一共3条指令
在这里插入图片描述
第一条指令使用GOT表中的4个元素(即GOT[3]),跳转到GOT[3]中的地址所指向的代码。由于这是第一次调用printf()函数,GOT[3]中的值应该指向plt中的第二条指令。在这里插入图片描述
第二条指令将printf()函数的编号压入栈,然后第三条指令跳转到plt段的第1个plt条目的代码中。在这里插入图片描述
plt的第一个条目是调用动态连接器的条目.于是,控制被转入动态链接器。动态链接器将共享库函数载入内存,然后根据载入的库函数地址修改GOT表中的表项,这里是GOT[3]的内容.之后再调用库函数printf()
在这里插入图片描述
在这一次调用printf()之后,再次调用printf()函数时,仍然会执行第一条指令,但是由于之前已经调用过一次printf()函数,GOT[3]中保存已经加载入内存的printf()函数的地址.所以plt中的第一条指令会直接跳转到printf()函数.

5.8 本章小结

在链接的过程中,链接器将程序在汇编时留下的地址空槽填上虚拟地址。填入的值取决于对符号的引用类型。地址表示一共有2中类型,一是绝对地址、二是相对地址。比如:全局变量引用使用绝对地址;函数调用、函数内跳转使用PC相对地址。链接结束之后,程序就可以直接加载到内存中执行了。
执行时,Hello加载入内存之后系统会调用_start函数来启动hello函数。_start调用__libc_start_main函数,__libc_start_main为hello创建好运行环境,然后再调用hello的main函数。
在main函数执行过程中,第一次调用
main函数执行结束之后。返回__libc_start_main函数,然后__libc_start_main处理main 函数的返回值。并且返回_start函数。

第6章 hello进程管理

6.1 进程的概念与作用

概念: 进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用: 提供2个关键的抽象

  1. 独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
  2. 私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统
6.2 简述壳Shell-bash的作用与处理流程

作用:每次用户通过向shell输人一个可执行目标文件的名字,运行程序时, shell就会创建个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。
处理流程
在这里插入图片描述

6.3 Hello的fork进程创建过程

./hello输入命令行之后。Shell执行如下过程
在这里插入图片描述

  1. Shell解析命令./hello,然后调用fork函数创建子进程
  2. 子进程执行hello,父进程等待子进程结束
  3. 子进程结束,调用exit()退出,父进程结束等待。
  4. shell等待下一个命令的输入
6.4 Hello的execve过程

再执行hello时,shell调用execve函数开始执行hello,execve开始执行hello需要如下几个步骤

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

基本概念
控制流:计算从加电开始,到断点位置,程序计数器的一系列PC的值的序列叫做控制流。
逻辑控制流:使用调试器单步执行程序时,会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。即逻辑控制流是一个进程中PC值的序列
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
Hello的调度过程
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的异常与信号处理
  • Ctrl-Z:
    按下Ctrl-Z之后,shell收到SIGTSTP信号,将hello进程暂停,通过ps命令可以看到hello进程没有被回收,此时他的后台job号是1,调用fg 1将其调到前台,此时shell程序首先打印hello的命令行命令,hello继续运行在这里插入图片描述
  • Ctrl-C:
    按下Ctrl-C之后,shell收到信号SIGINT,然后结束进程hello。进程hello结束并且被回收。
  • 乱按:
    再hello执行过程中键入的信息,都会在hello的结尾处被getchar()函数读入,然后输出到终端。当键入的命令中有\n()回车时,前一段字符会作为命令行内容被执行。
    在这里插入图片描述
6.7本章小结

进程就是一个执行中的程序。他有独立的逻辑控制流和私有的地址空间。
在Shell中输入命令之后,shell就解析命令。
当其解析到输入的命令是需要执行程序hello时,shell调用fork函数创建一个子进程,并且在子进程中调用execve函数加载程序hello。
Hello加载完毕之后开始执行,在hello执行的过程中,内核会同时执行其他的进程,即hello与许多进程并发执行。Hello执行一定时间之后,内核调度其他进程执行。Hello也会调用sleep函数显式地要求内核将其挂起一段时间。
在hello执行时收到信号会触发相应地异常处理代码。

第7章 hello的存储管理

7.1 hello的存储器地址空间

物理地址(physical address)
用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
——这个概念应该是这几个概念中最好理解的一个,但是值得一提的是,虽然可以直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址,但是事实上,这只是一个硬件提供给软件的抽像,内存的寻址方式并不是这样。所以,说它是“与地址总线相对应”,是更贴切一些,不过抛开对物理内存寻址方式的考虑,直接把物理地址与物理的内存一一对应,也是可以接受的。也许错误的理解更利于形而上的抽像。
逻辑地址(logical address)
Intel为了兼容,将远古时代的段式内存管理方式保留了下来。逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址,即程序的机器代码中保存的地址。以上例,我们说的连接器为A分配的0x08111111这个地址就是逻辑地址。
线性地址(linear address)或也叫虚拟地址(virtual address)
跟逻辑地址类似,它也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址
Hello的地址转换
程序hello中将一个虚拟内存空间中的地址转换为物理地址,需要进行两步:首先将给定一个逻辑地址,CPU要利用其段式内存管理单元,先将为个逻辑地址转换成一个线程地址,再利用其页式内存管理单元,转换为最终物理地址。
在这里插入图片描述

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

在这里插入图片描述在这里插入图片描述
GDT:全局段描述符表。在整个系统中,全局描述符表GDT只有一张(一个处理器对应一个GDT),GDT可以被放在内存的任何位置,但CPU必须知道GDT的入口,也就是基地址放在哪里,Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址。
LDT:局部描述符表。局部描述符表可以有若干张,每个任务可以有一张。
转换过程
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、根据段选择符的T1选择GDT或者LDT。T1=0则选择GDT,T1=1则选择LDT。
2、根据段选择符中前13位,在GDT或者LDT中选择对应的段描述符,从段描述符中抽取处Base。
3、Base + offset就是最后的线性地址。
在这里插入图片描述
Linux下的段式管理
在Linux下,逻辑地址与线性地址总是一致的,即逻辑地址的偏移量字段的值与线性地址的值总是相同的。

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

由上一节,我们已经得到了hello运行过程中的线性地址,接下来就需要通过线性地址得出数据的物理内存。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

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

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

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

在这里插入图片描述

7.6 hello进程fork时的内存映射

在这里插入图片描述

7.7 hello进程execve时的内存映射

Hello的execve过程以及内存映射已经在6.4节予以阐述,这里不再赘述。

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

MMU在试图翻译某个虚拟地址A时,发现内存中没有A所在的那一页。此时触发一个缺页异常,这个异常导致控制转移到内核的缺页处理程序。
处理程序首先执行2个判断:

  1. 判断虚拟地址A是合法的。即A是否在某个区域结构定义的区域。为了回答判断A的合法性,缺页处理程序搜索区域结构的链表,把A和每个区域结构中的 vm start和vm end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。
  2. 对地址A进行的内存访问是否合法。即进程是否有读、写或者执行这个区域内页面的权限。如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。
    以上两个判断之后,内核确定可以对A进行访问。于是进行第3个步骤
  3. 选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了
    在这里插入图片描述
7.9动态存储分配管理

在这里插入图片描述
Printf会调用malloc,下面简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同, 但是不失通用性,堆紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做“ break”),它指向堆的顶部分配器将堆视为一组不同大小的块( block)的集合来维护。
每个块就是一个连续的虚拟内存片( chunk), 要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格:
显式分配器( explicit allocator):要求应用显式地释放任何已分配的块。
隐式分配器( implicit allocator):分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器( garbage collec tor),而自动释放未使用的已分配的块的过程叫做垃圾收集( garbage collection)。

7.10本章小结

Hello中涉及到的地址都保存在编码中,保存在编码中的地址成为逻辑地址。在CPU层面上,程序需要将逻辑地址交给CPU,然后得到线性地址。在这个过程中我们了解了intel 的段式管理
Linux中线性地址与虚拟地址一致,所以得到线性地址之后,需要经过变换得到物理地址。于是我们通过Linux的页式管理得到了数据的物理地址。当然,在这个过程中,我们首先查询TLB中有无PTE的缓存,若没有缓存时还需通过4级页表查找保存在内存中的PTE。通过PTE才能得到我们需要的物理地址。
得到物理地址之后先访问高速缓存,若miss,则继续向下一级高速缓存访问。如果向下知道主存仍未找到数据,则触发缺页中断。等到页面载入之后程序再次访问这一数据。
内核为系统中的每个进程维护一个单独的任务结构,当内核待用fork与execve时,内核未新进程准备好新的虚拟地址空间。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输人和输出都被当作对相应文件的读和写来执行。
设备管理:设备的模型化将设备映射为文件,这就允许 Linux内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行

8.2 简述Unix IO接口及其函数
Unix I/O接口统一操作
  1. 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
  2. Linux Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
  3. 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
  4. 读写文件
    a) 读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。
    b) 类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
  5. 关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O函数:
  1. int open(char* filename,int flags,mode_t mode)
    a) 进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。
    b) open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
  2. int close(int fd),fd是需要关闭的文件的描述符,close返回操作结果。
  3. ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
  4. ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析

printf()函数的源代码如下:(来源:Linux libc6)

int
__printf (const char *format, ...)
{
  va_list arg;
  int done;

  va_start (arg, format);
  done = vfprintf (stdout, format, arg);
  va_end (arg);

  return done;
}

可以看到,printf()函数将变长参数的指针arg作为参数,传给vfprintf函数。然后vfprintf函数解析格式化字符串,调用write()函数。
下面用一个函数说明格式化的过程。
Vsprintf的示意函数(仅格式化16进制字符)如下

int vsprintf(char *buf, const char *fmt, va_list args) 
   { 
    char* p; 
    char tmp[256]; 
    va_list p_next_arg = args; 
   
    for (p=buf;*fmt;fmt++) { 
    if (*fmt != '%') { 
    *p++ = *fmt; 
    continue; 
    } 
   
    fmt++; 
   
    switch (*fmt) { 
    case 'x': 
    itoa(tmp, *((int*)p_next_arg)); 
    strcpy(p, tmp); 
    p_next_arg += 4; 
    p += strlen(tmp); 
    break; 
    case 's': 
    break; 
    default: 
    break; 
    } 
    } 
   
    return (p - buf); 
   } 

从代码中可以得知,函数将格式化字符串与待填入字符相结合,从而产生真正的输出字符。库中真正的输出包含更多功能。
得到输出字符之后调用系统函数wirte():

   write: 
     mov eax, _NR_write 
     mov ebx, [esp + 4] 
     mov ecx, [esp + 8] 
     int INT_VECTOR_SYS_CALL 

这里制造了几个参数,然后进行sys_call。
syscall将字符串中的字节“Hello 1170801219 yangjin”从寄存器中通过总线复制到显卡的显存中,显存中存储着字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。然后字符就显示在了屏幕上。

8.4 getchar的实现分析

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求。CPU收到中断请求后,挂起当前进程,然后运行键盘中断子程序。键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符(\n)然后返回整个字串到stdin中。

8.5本章小结

Linux系统将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经历的一部分。在hello的一生中,我们可以看到计算机内部工作的严谨与精密。所有的函数、指令都一环扣一环,任何一个环节出错都将导致程序运行出错。

参考

http://www.runoob.com/cplusplus/cpp-preprocessor.html

https://baike.baidu.com/item/编译程序/8290180?fr=aladdin

https://baike.baidu.com/item/汇编/627224?fr=aladdin

https://images2017.cnblogs.com/blog/733013/201708/733013-20170823180406714-1520031644.png
https://www.cnblogs.com/zengkefu/p/5452792.html
http://life.chinaunix.net/bbsfile/forum/linux/month_0801/20080115_446d77416115bf806a6eolV4lJub7IYi.jpg
http://life.chinaunix.net/bbsfile/forum/linux/month_0801/20080115_23beaf30e70dfb78f97bukhHmAGFgIdM.jpg
https://blog.csdn.net/u014774781/article/details/47706213
https://www.cnblogs.com/zengkefu/p/5452792.html
https://www.cnblogs.com/pianist/p/3315801.html

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值