摘  要

本文展现了linux系统下hello程序的整个生命周期,编写hello.c源代码,C预处理器(cpp)进行预处理生成hello.i文件,C编译器(ccl)进行翻译生成hello.s文件,汇编器(as)将其翻译成hello.o文件,最后hello.o和系统目标文件被链接器程序ld组合,创建可执行目标文件hello。shell接收./hello指令调用fork函数创建进程,execve加载hello进入内存,由CPU控制程序逻辑流的运行,中断,上下文切换和异常的处理,最后结束进程并由父进程进行回收,hello终结。

关键词:预处理;编译;汇编;链接;进程;存储;IO管理;                    

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分

目  录

第1章 概述

1.1 Hello简介

1.2 环境与工具

1.3 中间结果

1.4 本章小结

第2章 预处理

2.1 预处理的概念与作用

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

4.4 Hello.o的结果解析

4.5 本章小结

第5章 链接

5.1 链接的概念与作用

5.2 在Ubuntu下链接的命令

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

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.6 hello的执行流程

5.7 Hello的动态链接分析

5.8 本章小结

第6章 hello进程管理

6.1 进程的概念与作用

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

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.7本章小结

第7章 hello的存储管理

7.1 hello的存储器地址空间

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

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

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

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

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

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

7.9动态存储分配管理

7.10本章小结

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

8.2 简述Unix IO接口及其函数

8.3 printf的实现分析

8.4 getchar的实现分析

8.5本章小结

结论

附件

参考文献


第1章 概述

1.1 Hello简介

P2P:program to process,从程序到进程的简写。在Linux上,经过cpp预处理、ccl编译、as汇编、ld链接后,程序hello.c最终生成可执行的目标程序hello。 在shell中输入启动命令后,shell生成一个fork子程序,即创建新进程,从而实现了p2p。

020: online to offline,从在线到离线的简写。shell为此子进程执行execve,将其映射到虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。在程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。

1.2 环境与工具

1.硬件环境:X64 CPU;2GHz;4GRAM;256Disk

2.软件环境:Windows10 64位;Vmware 15;Ubuntu 20.04 LTS 64位

3.工具:vs code;gdb;Objdump;HexEditor

1.3 中间结果

hello.i:预处理生成的文本文件

hello.s:.i编译后得到的汇编语言文件

hello.o:.s汇编后得到的可重定位目标文件

hello:.o经过链接生成的可执行目标文件

hello_objdump.s:o经过链接反汇编的汇编语言文件

Disas_hello.s:.o经过反汇编生成的汇编语言文件

Disas_hello2.s:hello经过反汇编生成的汇编语言文件

elf.txt:.o的elf文本文件

hello.elf:hello的elf文件

hello1.elf:hello的elf文件

1.4 本章小结

本章介绍了hello程序“一生”的过程,介绍了P2P,O2O的整个过程,以及进行实验时的软硬件环境及开发与调试工具等基本信息。

(第1章0.5分)


第2章 预处理

2.1 预处理的概念与作用

概念:预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。预处理器执行以#开头的命令(宏定义、条件编译、读取头文件)、删除注释等来修改c程序生成.i文件

作用:C语言预处理程序的作用是根据源代码中的预处理指令修改你的源代码。预处理程序读入所有包含的文件以及待编译的源代码,然后生成源代码的预处理版本。在预处理版本中,宏和常量标识符已全部被相应的代码和值替换掉了。如果源代码中包含条件预处理指令(如#if),那么预处理程序将先判断条件,再相应地修改源代码。例如1、用实际值替换宏定义的字符串2、文件包含:将头文件中的代码插入到新程序中3、条件编译:根据if后面的条件决定需要编译的代码

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i

 

图2.2.1预处理命令

2.3 Hello的预处理结果解析

预处理得到.i文件打开后发现得到了扩展,由23行扩展到了3060行。原文件中的预处理指令被处理,头文件的内容得到引入,stdio.h unistd.h stdlib.h的源代码的依次展开,下图展示了扩充的hello.i的主程序,可以看到int main前的预处理命令消失。

 

图2.3.1hello.i

2.4 本章小结

了解了预处理的概念和作用及ubuntu下预处理命令,并分析了.i文件所包含的信息。

(第2章0.5分)


第3章 编译

3.1 编译的概念与作用

概念:编译是利用编译程序从预处理文本文件(.i)产生汇编程序(.s)的过程。

作用:将便于人编写、阅读、维护的高级语言所写作的源代码程序,翻译为计算机能解读、运行的低级语言的程序。

主要分为:①扫描(词义分析):将源代码程序输入扫描器,将源代码中的字符序列分割为一系列c语言中的符合语法要求的字符单元,这一部分可以分为自上而下的分析和自下而上的分析两种方式。②语法分析:基于词法分析得到的字符单元生成语法分析树。③语义分析:在语法分析完成之后由语义分析妻进行语义分析,主要就是为了判断指令是否是合法的c语言指令,这一部分也可以叫做静态语义分析,并不判断一些在执行时可能出现的错误,例如如果不存在IDE优化,这一步对于1/0这种只有在动态类型检查的时候才会发现的错误,代码将不会报错。④中间代码:中间代码的作用是可使使得编译程序的逻辑更加明确,主要是为了下一步代码优化的时候优化的效果更好。代码优化:根据用户指定的不同优化等级对代码进行安全的、等价的优化,这一行为的目的主要是为了提升代码在执行时的性能。⑤生成代码:生成是编译的最后一个阶段。在经过上面的所有过程后,在这一过程中将会生成一个汇编语言代码文件,也就是我们最后得到的hello.s文件,这一文件中的源代码将以汇编语言的格式呈现。

    

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s

 

图3.2.1编译命令

3.3 Hello的编译结果解析

本部分将按照的数据、赋值、类型转换、sizeof、算术操作、逻辑/位操作、关系操作、数组/指针/结构操作、控制转移、函数操作顺序解析hello.s,汇编程序如下图。

 

图3.3.1hello.s

3.3.1数据

①常量

数字常量:源代码中使用的数字常量储存在.text段,包括在比较的时候使用的数字变量3,在循环的时候使用的循环比较变量等数字常量,具体情况可以见如下截图:

 

图3.3.1数字常量

字符串常量:可以发现在printf等函数中使用的字符串常量是储存在.rotate段的,具体储存情况可以见如下截图:

 

图3.3.2字符串常量

②变量

全局变量:在代码中全局变量

局部变量:局部变量是储存在栈中的某一个位置的或是直接储存在寄存器中的局部变量共有三个循环变量i,以及argc和argv,对于i,我们发现它储存在栈中地址为-4(%rbp)的位置,对于i的操作可见如下截图:

 

图3.3.3局部变量i

对于局部变量argc,标志的是在程序运行的时候输入的变量的个数,可以发现它储存在栈中地址为-20(%rbp)的位置,对于它的操作主要是与4比较之后确定有一部分代码是否执行,具体汇编代码如下截图:

 

图3.3.4局部变量argc

对于局部变量argv,是一个保存着输入变量的数组,观察发现它储存在栈中,具体汇编代码段如下:

 

图3.3.5局部变量argv

3.3.2赋值

对于局部变量i,循环开始时赋值每次循环结束的时候都对齐进行+1操作,具体的操作汇编代码如下:

 

图3.3.6 i的赋值

3.3.3类型转换

hello.c中涉及的类型转换是:atoi(argv[3]),将字符串类型转换为整数类型。

 

图3.3.7 atoi类型转换

3.3.4算术操作

程序中算数操作有i++,由addl实现。

 

图3.3.8 i++

3.3.5关系操作

一共两处关系操作第一处是对于argc的判断,当等于4的时候将进行条件跳转另一处是在for循环中对于循环变量i的判断,这一段的汇编代码如下图所示,当循环变量i大于等于7的时候将进行条件跳转。

 

图3.3.9 argc比较

 

图3.3.10 i比较

3.3.6数组/指针/结构操作

这一段代码中出现的数组操作只有一个,也就是对于argv数组的操作,观察汇编代码可以发现argv储存的两个值都存放在栈中,argv[1]的储存地址是-24(%rbp),而argv[1]的储存地址是-16(%rbp),对于数组操作的汇编代码如下截图:

 

图3.3.11 数组操作

3.3.7函数操作

X86-64中,过程调用传递参数规则:第1~6个参数一次储存在%rdi、%rsi、%rdx、%rcx、%r8、%r9这六个寄存器中,剩下的参数保存在栈当中。

main函数:

参数传递:传入参数argc和argv[],分别用寄存器%rdi和%rsi存储。

函数调用:被系统启动函数调用。

函数返回:设置%eax为0并且返回,对应return 0 。

源代码:int main(int argc,char *argv[])

汇编代码:

 

图3.3.12 main汇编代码

printf函数:

参数传递:

传入了字符串参数首地址;

函数调用:

if判断满足条件后调用,

源代码: printf("用法: Hello 学号 姓名 秒数!\n");

汇编代码:

 

图3.3.13 printf汇编代码1

参数传递:

传入了格式字符串的地址、 argv[1]、argc[2]的地址。

函数调用:

在for循环的过程中调用。

源代码:printf("Hello %s %s\n",argv[1],argv[2]);

汇编代码:

 

图3.3.14 printf汇编代码2

exit函数:

参数传递:

传入参数1

函数调用:

if判断条件满足后被调用.

源代码:exit(1);

汇编代码:

 

图3.3.15 exit汇编代码

sleep函数:

参数传递:

传入参数atoi(argv[3])。

函数调用:

随着for循环被调用

源代码:

sleep(atoi(argv[3]));

汇编代码:

 

图3.3.15 sleep汇编代码

getchar函数:

函数调用:

在main中被调用

源代码:getchar();

汇编代码:call    getchar@PLT

3.4 本章小结

本章介绍了编译器如何处理c程序,将预处理文本文件.i翻译成.s。介绍了汇编语言的相关知识。

(第3章2分)


第4章 汇编

4.1 汇编的概念与作用

驱动程序运行汇编器as,将汇编语言的ascii码文件(这里是hello.s)翻译成机器语言的可重定位目标文件(hello.o)的过程称为汇编。

4.2 在Ubuntu下汇编的命令

as hello.s -o hello.o

 

图4.2.1 Ubuntu汇编命令

4.3 可重定位目标elf格式

4.3.1 命令

readelf -a hello.o > ./elf.txt 使用这一命令导出我们需要的elf的文件

 

图4.3.1 readelf命令

4.3.2 ELF头

ELF头以一个16字节的序列开始,这个序列描述了生成该文件系统下的字的大小以及一些其他信息。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。具体ELF头的代码如下:

 

图4.3 .2 ELF头

4.3.3 节头表

描述了.o文件中每一个节出现的位置,大小,目标文件中的每一个节都有一个固定大小的条目。具体内容如下图所示:

 

图4.3.3节头表

4.3.4 重定位节

重定位节中包含了在代码中使用的一些外部变量等信息,在链接的时候需要根据重定位节的信息对这些变量符号进行修改。链接的时候链接器会根据重定位节的信息对外部变量符号决定选择何种方法计算正确的地址,通过偏移量等信息计算出正确的地址。

本程序需要重定位的信息有:.rodata中的模式串,puts,exit,printf,slepsecs,sleep,getchar这些符号同样需要与相应的地址进行重定位。具体重定位节的信息如下图所示:

 

4.3.4重定位节

.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。例如本程序中的getchar、puts、exit等函数名都需要在这一部分体现,具体信息如下图所示:

 

4.3.5 符号表

4.4 Hello.o的结果解析

命令:objdump -d -r hello.o > Disas_hello.s

 

4.4.1 反汇编

反汇编代码如下:

1

  2 hello.o:     文件格式 elf64-x86-64

  3

  4

  5 Disassembly of section .text:

  6

  7 0000000000000000 <main>:

  8    0:   f3 0f 1e fa             endbr64

  9    4:   55                      push   %rbp

 10    5:   48 89 e5                mov    %rsp,%rbp

 11    8:   48 83 ec 20             sub    $0x20,%rsp

 12    c:   89 7d ec                mov    %edi,-0x14(%rbp)

 13    f:   48 89 75 e0             mov    %rsi,-0x20(%rbp)

 14   13:   83 7d ec 04             cmpl   $0x4,-0x14(%rbp)

 15   17:   74 16                   je     2f <main+0x2f>

 16  19:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 20 <main+0x20>

 17   1c: R_X86_64_PC32   .rodata-0x4

 18   20:   e8 00 00 00 00          callq  25 <main+0x25>

 19   21: R_X86_64_PLT32  puts-0x4

 20   25:   bf 01 00 00 00          mov    $0x1,%edi

 21   2a:   e8 00 00 00 00          callq  2f <main+0x2f>

 22   2b:   R_X86_64_PLT32  exit-0x4

 23   2f:   c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)

 24   36:   eb 48                   jmp    80 <main+0x80>

 25   38:   48 8b 45 e0             mov    -0x20(%rbp),%rax

 26   3c:   48 83 c0 10             add    $0x10,%rax

 27   40:   48 8b 10                mov    (%rax),%rdx

 28   43:   48 8b 45 e0             mov    -0x20(%rbp),%rax 

29   47:   48 83 c0 08             add    $0x8,%rax

 30   4b:   48 8b 00                mov    (%rax),%rax

 31   4e:   48 89 c6                mov    %rax,%rsi

 32   51:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 58 <main+0x58>

 33   54: R_X86_64_PC32   .rodata+0x22

 34   58:   b8 00 00 00 00          mov    $0x0,%eax

 35   5d:   e8 00 00 00 00          callq  62 <main+0x62>

 36   5e: R_X86_64_PLT32  printf-0x4

 37   62:   48 8b 45 e0             mov    -0x20(%rbp),%rax

 38   66:   48 83 c0 18             add    $0x18,%rax

 39   6a:   48 8b 00                mov    (%rax),%rax

 40   6d:   48 89 c7                mov    %rax,%rdi

 41   70:   e8 00 00 00 00          callq  75 <main+0x75>

 42   71: R_X86_64_PLT32  atoi-0x4

 43   75:   89 c7                   mov    %eax,%edi

 44   77:   e8 00 00 00 00          callq  7c <main+0x7c>

 45   78: R_X86_64_PLT32  sleep-0x4

 46   7c:   83 45 fc 01             addl   $0x1,-0x4(%rbp)

 47   80:   83 7d fc 07             cmpl   $0x7,-0x4(%rbp)

 48   84:   7e b2                   jle    38 <main+0x38>

 49   86:   e8 00 00 00 00          callq  8b <main+0x8b>

 50   87: R_X86_64_PLT32  getchar-0x4

 51   8b:   b8 00 00 00 00          mov    $0x0,%eax

 52   90:   c9                      leaveq

 53   91:   c3                      retq

分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。

hello.s 中的操作数以十进制表示,而 hello.o 中的反汇编代码中的操作数以十六进制表示。 解释机器语言结构与汇编语言的映射关系。 特别是机器语言操作数与汇编语言不一致,尤其是分支传递函数调用。

在控制传输中,hello.s 使用段名(如 .L2 和 .LC1)跳转,反汇编代码使用目标代码的虚拟地址跳转。 但是,重定位项当前是空的,跳转地址为零。 它将在链接后的正确位置填写。

函数调用中,hello.s直接调用函数名,反向汇编代码的调用成为目标虚拟地址。 但是和之前的地址一样,运行run的地址只能在链接后确定,当前目的地址全为0,留下重定位入口。

4.5 本章小结

本章介绍了从.s到.o的过程。通过汇编文件和elf格式与.s比较了解机器语言和汇编语言的映射关系。

(第4章1分)


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.2.1链接

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

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

命令:readelf -a hello > hello.elf

5.3.1 ELF头

包含内容与汇编中4.3.2节展示的类似,详细内容截图如下:

 

图5.3.1elf头

5.3.2节头

描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。详细内容如下:

 

图5.3.2 节头表部分内容

5.4 hello的虚拟地址空间

使用edb打开hello可执行文件,可以在edb的Data Dump窗口看到hello的虚拟地址空间分配的情况,具体内容截图如下:

 

图5.4.1 edb中的data dump视图

可以发现这一段程序的地址是从0x401000开始的,并且该处有ELF的标识,可以判断从可执行文件时加载的信息。接下来可以分析其中的一些具体的内容:其中PHDR保存的是程序头表;INTERP保存了程序执行前需要调用的解释器;LOAD记录程序目标代码和常量信息;DYNAMIC储存了动态链接器所使用的信息;NOTE记录的是一些辅助信息;GNU_EH_FRAME保存异常信息;GNU_STACK使用系统栈所需要的权限信息;GNU_RELRO保存在重定位之后只读信息的位置。

5.5 链接的重定位过程分析

命令:objdump -d -r hello > hello_objdump.s

 

图5.5 .1 objdump命令

 

图5.5.2 hello反汇编代码部分

hello与hello.o的不同:

1.在链接过程中,hello中加入了代码中调用的一些库函数,例如getchar,puts,printf,等,同时每一个函数都有了相应的虚拟地址。例如exit函数的虚拟地址如下图:

 

图5.6 exit链接后虚拟地址展示

2. hello中增加了.init和.plt节,和一些节中定义的函数。

3. hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。这是由于hello.o中对于函数还未进行定位,只是在.rel.text中添加了重定位条目,而hello进行定位之后自然不需要重定位条目。

4.地址访问:在链接完成之后,hello中的所有对于地址的访问或是引用都调用的是虚拟地址地址。例如下图所示:

 

图5.8 hello中的地址访问

5.6 hello的执行流程

使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。通过edb的调试,一步一步地记录下call命令进入的函数。

 

图5.6.1子函数名和地址(后6位)

 

图5.6.2 使用edb执行hello过程截图

5.7 Hello的动态链接分析

   共享链接库代码是动态的目标模块,在程序开始运行或者调用程序加载时,可以自动加载该代码到任意的一个内存地址,并和一个在目标模块内存中的应用程序链接起来,这个过程就是对动态链接的重定位过程。

动态的链接器在正常工作时采取了延迟绑定的链接器策略,由于静态的编译器本身无法准确预测变量和函数的绝对运行时地址,动态的链接器需要等待编译器在程序开始加载时再对编译器进行延迟解析,这样的延迟绑定策略称之为动态延迟绑定。在plt和got中分别存放着链接器的目标变量和函数的运行时地址。一个动态的链接器通过静态的过程偏移链接表plt+got链接器实现函数的动态过程链接,这样一来,它就已经包含了正确的绝对运行时地址。

使用edb查看时有如下发现:

 

图5.7.1 执行init之前的地址

 

图5.7.2 执行init之后的地址

5.8 本章小结

在链接过程中,各种代码和数据片段收集并组合为一个单一文件。利用链接器,分离编译称为可能,我们不用将应用程序组织为巨大的源文件,只是把它们分解为更小的管理模块,并在应用时将它们链接就可以完成一个完整的任务。

经过链接,已经得到了一个可执行文件,接下来只需要在shell中调用命令就可以为这一文件创建进程并执行该文件。

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

(第5章1分)


6hello进程管理

6.1 进程的概念与作用

概念:进程是执行中程序的抽象。

作用:在现代系统上运行一个程序时,我们会得到一个假象,好像我们的程序是系统中唯一运行的程序一样。我们的程序好像独占处理器和内存。处理器好像无间断地一条接一条执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象是通过进程的概念提供的。进程提供给应用程序的关键抽象:1)一个独立的逻辑控制流,提供一个程序独占处理器的假象;2)一个私有的地址空间,提供一个程序独占地使用内存系统的假象。

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

作用:shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并根据解析结果运行程序。

处理流程:

1.Shell首先从命令行中找出特殊字符(元字符),在将元字符翻译成间隔符号。元字符将命令行划分成小块tokens。Shell中的元字符如下所示:SPACE , TAB , NEWLINE , & , ; , ( , ) ,< , > , |

2. 程序块tokens被处理,检查看他们是否是shell中所引用到的关键字。

3. 当程序块tokens被确定以后,shell根据aliases文件中的列表来检查命令的第一个单词。如果这个单词出现在aliases表中,执行替换操作并且处理过程回到第一步重新分割程序块tokens。

4.Shell对~符号进行替换。

5.Shell对所有前面带有符号的变量进行替换。6.Shell将命令行中的内嵌命令表达式替换成命令;他们一般都采用符号的变量进行替换。6.Shell将命令行中的内嵌命令表达式替换成命令;他们一般都采用(command)标记法。

7.Shell计算采用$(expression)标记的算术表达式。

8.Shell将命令字符串重新划分为新的块tokens。这次划分的依据是栏位分割符号,称为IFS。缺省的IFS变量包含有:SPACE , TAB 和换行符号。

9.Shell执行通配符* ? [ ]的替换。

10.shell把所有从处理的结果中用到的注释删除,并且按照下面的顺序实行命令的检查:

A. 内建的命令

B. shell函数(由用户自己定义的)

C. 可执行的脚本文件(需要寻找文件和PATH路径)

11.在执行前的最后一步是初始化所有的输入输出重定向。

12.最后,执行命令。[1]

6.3 Hello的fork进程创建过程

父进程通过调用fork函数创建一个新的运行的子进程。调用fork函数后,新创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本,包括代码、数据段、堆、共享库以及用户栈,子进程获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。fork被调用一次,却返回两次,子进程返回0,父进程返回子进程的PID。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。

 

图6.3.1 fork执行

6.4 Hello的execve过程

exceve函数在当前进程的上下文中加载并运行一个新程序。exceve函数加载并运行可执行目标文件,并带参数列表和环境变量列表。只有当出现错误时,exceve才会返回到调用程序。所以,与fork一次调用返回两次不同,在exceve调用一次并从不返回。当加载可执行目标文件后,execve调用启动代码,启动代码设置栈,将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序,由此将控制传递给新程序的主函数。

 

图6.4.1 execve执行

6.5 Hello的进程执行

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

进程调度:即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一的对应于包含在运行时动态链接到程序的共享对象中的指令。这个PC的序列叫做逻辑控制流,或者简称逻辑流。进程是轮流适用处理器的,每个进程执行它的流的一部分,然后被抢占,然后轮到其他进程。

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。

内核模式转变到用户模式:操作系统内核使用上下文切换来实现多任务。内核为每个进程维持一个上下文,它是内核重启被抢占的进程所需的状态,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构的值。

进程执行到某些时刻,内核可决定抢占该进程,并重新开启一个先前被抢占了的进程,这种决策称为调度。内核调度一个新的进程运行后,通过上下文切换机制来转移控制到新的进程:1)保存当前进程上下文;2)恢复某个先前被抢占的进程被保存的上下文3)将控制转移给这个新恢复的进程。当内核代表用户执行系统调用时,可能会发生上下文切换,这时就存在着用户态与核心态的转换。

 

图6.5.1进程切换

6.6 hello的异常与信号处理

运行过程中可能出现的异常种类由四种:中断、陷阱、故障、终止。

中断:来自I/O设备的信号,异步发生。硬件中断的异常处理程序被称为中断处理程序。

陷阱:是执行一条指令的结果。调用后返回到下一条指令。

故障:由错误情况引起,可能能被修正。修正成功则返回到引起故障的指令,否则终止程序。

终止:不可恢复,通常是硬件错误,这个程序会被终止。

运行结果:

①正常运行

 

 

图6.6.1正常运行

②按下 ctrl-z

 

图6.6.2 Ctrl+z

输入ctrl-z默认结果是挂起前台的作业,hello进程并没有回收,而是运行在后台下,用ps命令可以看到,hello进程并没有被回收。

图6.6.3 ps

此时他的后台 job 号是 3,调用 fg 3 将其调到前台,此时 shell 程序首先打印 hello 的命令行命令, hello 继续运行打印剩下的 8 条 info,之后输入字串,程序结束,同时进程被回收,如下图。

 

图6.6.4 fg

③按下Ctrl+c

 

图6.6.5 Ctrl+c

在键盘上输入Ctrl+c会导致内核发送一个SIGINT信号到前台进程组的每个进程,默认情况是终止前台作业,用ps查看前台进程组发现没有hello进程,如图所示。

 

图6.6.6 ps

④不停乱按

 

图6.6.7 不停乱按

无关输入被缓存到stdin,并随着printf指令被输出到结果。

6.7本章小结

本章介绍了进程的相关概念,描述了hello子进程fork和execve的过程,介绍了shell的一般处理流程和异常与信号处理。(第6章1分)


7hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:逻辑地址指由程序产生的与段相关的偏移地址部分,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。从hello的反汇编代码中看到的地址,它们需要通过计算,通过加上对应段的基地址才能得到真正的地址,这些便是hello中的逻辑地址。

线性地址:是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生逻辑地址,hello的反汇编文件中看到的地址(即逻辑地址)中的偏移量,加上对应段的基地址,便得到了hello中内容对应的线性地址。

虚拟地址:有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。

物理地址:是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。在hello的运行中,在访问内存时需要通过CPU产生虚拟地址,然后通过地址翻译得到一个物理地址,并通过物理地址访问内存中的位置。

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

逻辑地址由段选择符和偏移量组成,线性地址为段首地址与逻辑地址中的偏移量组成。其中,段首地址存放在段描述符中。而段描述符存放在描述符表中,也就是GDT(全局描述符表)或LDT(局部描述符表)中。

段式管理特点:

1.段式管理以段为单位分配内存,每段分配一个连续的内存区。

2.由于各段长度不等,所以这些存储区的大小不一。

3.同一进程包含的各段之间不要求连续。

4.段式管理的内存分配与释放在作业或进程的执行过程中动态进行。

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

页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。

线性地址(虚拟地址)由虚拟页号VPN和虚拟页偏移VPO组成。首先,MMU从线性地址中抽取出VPN,并且检查TLB,看他是否因为前面某个内存引用缓存了PTE的一个副本。TLB从VPN中抽取出TLB索引和TLB标记,查找对应组中是否有匹配的条目。若命中,将缓存的PPN返回给MMU。若不命中,MMU需从页表中的PTE中取出PPN,若得到的PTE无效或标记不匹配,就产生缺页,内核需调入所需页面,重新运行加载指令,若有效,则取出PPN。最后将线性地址中的VPO与PPN连接起来就得到了对应的物理地址。

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

每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。

虚拟地址VA虚拟页号VPN和虚拟页偏移VPO组成。若TLB命中时,所做操作与7.3中相同;若TLB不命中时,VPN被划分为四个片,每个片被用作到一个页表的偏移量,CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,依次类推。最后在L4页表中对应的PTE中取出PPN,与VPO连接,形成物理地址PA。

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

MMU将物理地址发给L1缓存,缓存从物理地址中取出缓存偏移CO、缓存组索引CI以及缓存标记CT。若缓存中CI所指示的组有标记与CT匹配的条目且有效位为1,则检测到一个命中条目,读出在偏移量CO处的数据字节,并把它返回给MMU,随后MMU将它传递给CPU。若不命中,则在下一级cache或是主存中寻找需要的内容,储存到上一级cache后再一次请求读取。

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的pid。

为了给这个新进程创建虚拟内存,系统创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。

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

7.7 hello进程execve时的内存映射

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

(1)删除已存在的用户区域

(2)映射私有区域:为新程序hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。

(3) 映射共享区域:如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

(4) 设置程序计数器(PC) ,指向代码的入口点。

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

页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的:

处理器生成一个虚拟地址,并将它传送给MMU

MMU生成PTE地址,并从高速缓存/主存请求得到它

高速缓存/主存向MMU返回PTE

PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。

缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。

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

缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。

7.9动态存储分配管理

Printf会调用malloc,请简述动态内存管理的基本方法与策略。 定义:一种内存管理方法。对内存空间的分配、回收等操作在进程执行过程中进行,以便更好地适应系统的动态需求,提高内存利用率。

分配器的基本风格:

显示分配器:要求应用显示地释放任何已分配的块。

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

基本方法与策略:

带边界标签的隐式空闲链表分配器管理

带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、可能的额外 填充以及一个字的尾部组成的。

当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个符合大小的 空闲块来放置这个请求块。分配器有三种放置策略:首次适配、下一次适配合最 佳适配。在释放一个已分配块的时候需要考虑是否能与前后空闲块合并,减少系 统中碎片的出现。

显示空间链表管理

显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序 不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块 的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前 驱与一个后继指针。放置策略与上述放置策略一致。

7.10本章小结

本章主要介绍了hello进程在执行的过程中的虚拟内存与物理内存之间的转换关系,以及一些支持这些转换的硬件或软件机制。同时介绍了在发生缺页异常的时候系统将会如何处理这一异常。最后介绍了动态内存分配的作用以及部分方法与策略。(第7章 2分)


8hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件。所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入输出都被当做对相应文件的读和写来执行。

设备管理:unix io接口。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。

8.2 简述Unix IO接口及其函数

Unix IO接口:

打开文件:一个应用程序通过要求内核打开相应的文件,来S宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

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

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

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

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

Unix IO函数:

(1).打开文件:int open(char *filename, int flags, mode_t mode);

Open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程当中没有打开的最小描述符。Flags参数指明了进程打算如何访问这个文件,同时也可以是一个或者更多为掩码的或,为写提供给一些额外的指示。Mode参数指定了新文件的访问权限位。

(2).关闭文件:int close(int fd);

调用close函数,通知内核结束访问一个文件,关闭打开的一个文件。成功返回0,出错返回-1。

(3).读文件:ssize_t read(int fd, void *buf, size_t n);

调用read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示错误,返回值0表示EOF,否则返回值表示的是实际传送的字节数量。

(4).写文件:ssize_t write(int fd, const void *buf, size_t n);

调用从内存位置buf复制至多n个字节到描述符fd的当前文件位置。返回值-1表示出错,否则,返回值表示内存向文件fd输出的字节的数量。

8.3 printf的实现分析

printf函数:

int printf(const char *fmt, ...)

{

    int i;

    va_list arg = (va_list)((char *)(&fmt) + 4);

    i = vsprintf(buf, fmt, arg);

    write(buf, i);

    return i;

}

可以发现printf的输入参数是fmt,但是后面是不定长的参数,同时在printf内存调用了两个函数,一个是vsprintf,一个是write。

int vsprintf(char *buf, const char *fmt, va_list args)

{

    char *p;

    chartmp[256];

    va_listp_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);

    }

}

Printf执行流程:

vsprintf函数将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

getchar是读入函数的一种。它从标准输入里读取下一个字符,相当于getc(stdin)。返回类型为int型,为用户输入的ASCII码或EOF。getchar可用宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。 [2]

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

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章主要介绍了linux系统中的I/O设备基本概念和管理方法,同时简单介绍了printf和getchar函数的实现。第8章1分)

结论

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

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

hello程序的过程可总结如下:

1、编写代码:用高级语言写.c文件

2、预处理:从.c生成.i文件,将.c中调用的外部库展开合并到.i中

3、编译:由.i生成.s汇编文件

4、汇编:将.s文件翻译为机器语言指令,并打包成可重定位目标程序hello.o

5、链接:将.o可重定位目标文件和动态链接库链接成可执行目标程序hello

6、运行:在shell中输入命令

7、创建子进程:shell嗲用fork为程序创建子进程

8、加载:shell调用execve函数,将hello程序加载到该子进程,映射虚拟内存

9、执行指令:CPU为进程分配时间片,加载器将计数器预置在程序入口点,则hello可以顺序执行自己的逻辑控制流

10、访问内存:MMU将虚拟内存地址映射成物理内存地址,CPU通过其来访问

11、动态内存分配:根据需要申请动态内存

12、信号:shell的信号处理函数可以接受程序的异常和用户的请求

13、终止:执行完成后父进程回收子进程,内核删除为该进程创建的数据结构

至此,hello运行结束

(结论0分,缺失 -1分,根据内容酌情加分)


附件

hello.i:预处理生成的文本文件

hello.s:.i编译后得到的汇编语言文件

hello.o:.s汇编后得到的可重定位目标文件

hello:.o经过链接生成的可执行目标文件

hello_objdump.s:o经过链接反汇编的汇编语言文件

Disas_hello.s:.o经过反汇编生成的汇编语言文件

Disas_hello2.s:hello经过反汇编生成的汇编语言文件

elf.txt:.o的elf文本文件

hello.elf:hello的elf文件

hello1.elf:hello的elf文件


参考文献

为完成本次大作业你翻阅的书籍与网站等

[1] 兰德尔 E.布莱恩特,大卫 R.奥哈拉伦. 深入理解计算机系统. 机械工业出版社

[2][转]printf 函数实现的深入剖析 - Pianistx - 博客园 printf函数实现的深入剖析

[3]虚拟地址、逻辑地址、线性地址、物理地址_rabbit_in_android的博客-CSDN博客 关于逻辑地址、虚拟地址、线性地址、物理地址

[4]内存地址转换与分段_drshenlei的博客-CSDN博客 地址转换与分段(参考文献0分,缺失 -1分)

Logo

更多推荐