程序人生-Hello’s P2P


 

 

 

计算机系统

 

大作业

 

      程序人生-Hellos P2P 

        计算机基础学部          

学        1190201610              

     级   1903009                 

         谭方舟               

   吴锐                    

 

 

 

 

 

 

计算机科学与技术学院

2021年5月

 

摘要:本次论文细致地讲述了hello程序从一个简单的“代码婴儿”在预处理、编译、链接、内存映射……这些必经的成长之路中,如何逐步成长为一个有作用、有贡献的、人民所需要的好可执行程序青年。将程序是怎样炼成的展现给大家看。

关键词:hello程序;计算机系统的组成;处理器体系结构;存储器体系结构;进程的控制。

 

 

1章 概述

1.1 Hello简介

1.2 环境与工具

1.3 中间结果

1.4 本章小结

2章 预处理

2.1 预处理的概念与作用

2.2Ubuntu下预处理的命令

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

6hello进程管理

6.1 进程的概念与作用

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

6.3 Hellofork进程创建过程

6.4 Helloexecve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.7本章小结

7hello的存储管理

7.1 hello的存储器地址空间

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

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

7.4 TLB与四级页表支持下的VAPA的变换

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

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

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

7.9动态存储分配管理

7.10本章小结

8helloIO管理

8.1 LinuxIO设备管理方法

8.2 简述Unix IO接口及其函数

8.3 printf的实现分析

8.4 getchar的实现分析

8.5本章小结

结论

附件

参考文献

 


1章 概述

1.1 Hello简介

Hello程序的整个从无到有的生命进程就是从一个Hello.c的源文件开始的。对于这个Hello.c文件的来历,大多是程序员通过各种编辑器(比如Vim)或者IDE(比如Code::Blocks)来编写的。

大多数编译系统,提供了编译器驱动程序。驱动程序首先运行预处理器,它将Hello.c翻译成一个ASCII码的中间文件Hello.i。接下来,驱动程序运行C编译器(ccl),它将Hello.i翻译成一个ASCII汇编语言文件Hello.s文件。然后驱动程序运行汇编器(as),它将Hello.s翻译成一个可重定位目标文件Hello.o。最后,它运行链接器程序ld,将Hello.o和一些必要的所需文件以及系统目标文件组合起来,创建一个可执行目标文件Hello。

然后在shell中键入./Hello,系统开始将目标文件中的代码和数据从磁盘复制到内存,并且调用fork函数在shell中创建一个新的子进程。这个就是P2P的过程。

Shell的进程管理给hello进行execve操作,进行mmap操作将其映射到内存中,接着给运行的Hello分配时间片来执行逻辑控制流。当程序运行结束后,父进程会回收Hello进程,内核删除相关的数据。这就是020过程

Hello文件中的数据与指令被加载器加载到内存中,然后利用虚拟寻址的方式,将内存中的地址于是实际的地址关联起来,然后利用流水线的方式执行指令中的各种运算或者是跳转。

Hello进程终止后,操作系统恢复shell进程的上下文,并将控制权传回shell,shell进程等待下一个命令行的输入

1.2 环境与工具

硬件环境:Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz 8G RAM

软件环境:Windows 10 64  Ubuntu 20.04 64位

开发工具:gcc+、gdb、Clion、Vim、HexEdit

1.3 中间结果

hello.i :hello.c预处理后生成文件

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

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

hello.d:hello.o的反汇编文件

hello :链接后生成的可执行文件

Hello.elf :hello.o的elf格式文件

Hello1.elf:hello的elf格式文件。

Hello1.d:hello的反汇编的文本文件。

1.4 本章小结

本章对Hello进行了简单的介绍,简单的分析了它的P2P和020的过程,列出了本次任务的软硬件的环境和开发工具,并且列出了任务过程中出现的中间产物及其作用。

(第1章0.5分)
2章 预处理

2.1 预处理的概念与作用

概念:预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。

作用:ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase),通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive),其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。

2.2在Ubuntu下预处理的命令

  1. gcc -E hello.c -o hello.i  

2.3 Hello的预处理结果解析

头部:

  1. # 1 "hello.c"  
  2. # 1 ""  
  3. # 1 ""  
  4. # 31 ""  
  5. # 1 "/usr/include/stdc-predef.h" 1 3 4  
  6. # 32 "" 2  
  7. # 1 "hello.c"  

中部部分:

  1. # 1 "/usr/include/stdio.h" 1 3 4  
  2. # 27 "/usr/include/stdio.h" 3 4  
  3. # 1 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 1 3 4  
  4. # 33 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 3 4  
  5. # 1 "/usr/include/features.h" 1 3 4  
  6. # 461 "/usr/include/features.h" 3 4  
  7. # 1 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 1 3 4  
  8. # 452 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 3 4  
  9. # 1 "/usr/include/x86_64-linux-gnu/bits/wordsize.h" 1 3 4  
  10. # 453 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 2 3 4  
  11. # 1 "/usr/include/x86_64-linux-gnu/bits/long-double.h" 1 3 4  
  12. # 454 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 2 3 4  
  13. # 462 "/usr/include/features.h" 2 3 4  
  14. # 485 "/usr/include/features.h" 3 4  
  15. # 1 "/usr/include/x86_64-linux-gnu/gnu/stubs.h" 1 3 4  
  16. # 10 "/usr/include/x86_64-linux-gnu/gnu/stubs.h" 3 4  
  17. # 1 "/usr/include/x86_64-linux-gnu/gnu/stubs-64.h" 1 3 4  
  18. # 11 "/usr/include/x86_64-linux-gnu/gnu/stubs.h" 2 3 4  
  19. # 486 "/usr/include/features.h" 2 3 4  
  20. # 34 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 2 3 4  
  21. # 28 "/usr/include/stdio.h" 2 3 4  

尾部:

  1. int i;  
  2. if(argc!=4){  
  3. printf("用法: Hello 学号 姓名 秒数!\n");  
  4. exit(1);  
  5. }  
  6. for(i=0;i<8;i++){  
  7. printf("Hello %s %s\n",argv[1],argv[2]);  
  8. sleep(atoi(argv[3]));  
  9. }  
  10. getchar();  
  11. return 0;  

经过驱动程序的预处理器的预处理,就会生成如上所示的hello.i文件(因为hello.i文件被拓展为3845行,所以这里不全部展示了)。hello.i文件中不再包含#include的代码,取而代之的是将其展开。预处理器在相应的位置找到文件并展开后,如果发现仍然有#include来引用其它文件,就继续递归展开,直到将所有文件全部展开,然后将所有展开的内容就插入到hello.i文件中。在文本最后仍然是hello.c的内容,只不过#开头的命令已经被预处理器展开,就没有出现。

2.4 本章小结

这一章节阐述了驱动程序的预处理器(cpp)是通过怎样的方式将hello.c进行预处理,生成hello.i文件的,同时交代了hello.i文件的部分内容和意义。

(第2章0.5分)


3章 编译

3.1 编译的概念与作用

概念:1、利用编译程序从源语言编写的源程序产生目标程序的过程。 2、用编译程序产生目标程序的动作。简单地说,编译就是把高级语言变成计算机可以识别的2进制语言。这里的编译就是指编译器(ccl)将预处理生成的后缀为.i的文件进行识别和转换,生成后缀为.s文件的过程。

作用:编译阶段,编译器首先检查我们的程序中是否有语法错误,如果有语法错误,那么编译将不会成功,之后直接可以反馈出错误信息;如果编译成功,编译器会生成一个过渡的代码,也就是汇编代码。

3.2 在Ubuntu下编译的命令

  1. gcc –S hello.i –o hello.s  
  2. .file   "hello.c"  
  3. .text  
  4. .section    .rodata  
  5. .align 8  
  6. .LC0:  
  7. .string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201"  
  8. .LC1:  
  9. .string "Hello %s %s\n"  
  10. .text  
  11. .globl  main  
  12. .type   main, @function  

3.3 Hello的编译结果解析

先来介绍hello.s的开头的部分,解释每一个字段所代表的含义:

.file

源文件名

.text

代码段

.section  .rodata

只读数据段

.string

字符串

.align

对齐方式

.globl

全局变量

.type

指明对象类型或者是函数类型

之后再根据PPT的指示对编译结果进行更加详细的解释:

3.3.1局部整型变量

main函数中我们定义了一个局部的整型变量i来进行循环,在汇编代码中,局部整型变量一般储存在栈中:

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

3.3.2数组

main函数的参数之中有char指针数组char *argv[]。在编译时,编译器会将这个参数储存在栈中:

  1. subq    $32, %rsp  
  2. movl    %edi, -20(%rbp)  
  3. movq    %rsi, -32(%rbp)  

首先压栈,将首地址是第二个参数,所以在%rsi传入,先开辟32个字节的新的栈空间,然后将两个参数传入栈中,其中有一个就是数组的首地址,之后再次用到该数组时,从栈中读出即可:

  1. .L4:  
  2. movq    -32(%rbp), %rax  
  3. addq    $16, %rax  
  4. movq    (%rax), %rdx  
  5. movq    -32(%rbp), %rax  
  6. addq    $8, %rax  
  7. movq    (%rax), %rax  
  8. movq    %rax, %rsi  
  9. leaq    .LC1(%rip), %rdi  
  10. movl    $0, %eax  
  11. call    printf@PLT  
  12. movq    -32(%rbp), %rax  
  13. addq    $24, %rax  
  14. movq    (%rax), %rax  
  15. movq    %rax, %rdi  
  16. call    atoi@PLT  
  17. movl    %eax, %edi  
  18. call    sleep@PLT  
  19. addl    $1, -4(%rbp)  

因为将数组的首地址储存在-32(%rbp)中,所以当我们需要用到这个数组的时候直接取出该数组的首地址,将其储存在%rax中。

3.3.3字符串

main函数中我们有需要根据不同的情况打印出不同的信息:printf("用法: Hello 学号 姓名 秒数!\n");和printf("Hello %s %s\n",argv[1],argv[2]);,所以在main函数中是需要字符串操作的,在汇编代码中会在开头将两个字符串常量提取出来,声明在.rodata节中:

  1. .LC0:  
  2. .string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201"  
  3. .LC1:  
  4. .string "Hello %s %s\n"  

3.3.4比较操作

main函数当中,有两处涉及到数值比较的地方分别是条件分支和循环判断条件的地方:if(argc!=4)和for(i=0;i<8;i++),编译器在编译的时候是利用cmp来处理这些比较关系的:

  1. cmpl    $4, -20(%rbp)  
  2. cmpl    $7, -4(%rbp)  

其中第二条指令是用来判断i小于8的。

3.3.5控制转移

hello.c的文件中,到底是输出什么样的字符串,是根据控制转移来判断的:

if(argc!=4)和for(i=0;i<8;i++),因为汇编代码在一开始的时候就已经将argci储存在相应的位置,所以直接拿相应的变量和相应的数值利用cmp进行比较,然后根据结果进行跳转:

  1. cmpl    $4, -20(%rbp)  
  2. je  .L2  

这是判断argc是否等于4的时候,进行的跳转。

  1. cmpl    $7, -4(%rbp)  
  2. jle .L4  

这是循环条件,判断i是否小于8的时候进行的跳转。

3.3.6算术操作

main函数中的循环操作中for(i=0;i<8;i++),每执行一次循环体,就要执行一次i++,这一个操作被编译器编译为:

  1. addl    $1, -4(%rbp)  

这里的-4%rbp)中储存的就是i既是源地址也是目标地址,前面的$1是一个操作立即数,这一个汇编代码将i的值加一。同时算数操作不仅出现在数值的算术上,在栈操作的过程中,也需要用到算数操作:

  1. subq    $32, %rsp  

这里将栈顶寄存器中储存的地址值减去32,这一操作是新开辟出一个32个字节的栈空间。

3.3.7函数操作

main函数中,调用了printfexitsleepgetchar函数:在调用printf函数的时候,将字符串的首地址储存在寄存器中作为打印的参数,然后再利用call来调用printf函数:

  1. leaq    .LC0(%rip), %rdi  
  2. call    puts@PLT  

在调用exit函数的时候,同样也是将参数1传入寄存器作为参数,然后用call调用exit函数:

  1. movl    $1, %edi  
  2. call    exit@PLT  

在调用sleep函数的时候,将我们自己定义的暂停的时候存入寄存器中作为参数,然后再调用sleep

  1. movl    %eax, %edi  
  2. call    sleep@PLT  
  3. call    getchar@PLT  

在调用getchar函数的时候,直接利用call函数调用getchar函数:

3.4 本章小结

 本章节展示了P2P过程中的编译阶段,并且化整为零,逐一对hello文件中的C语言操作对应的Assembly代码进行详细的分析。但是通过hello.i文件生成hello.s文件是驱动程序中的汇编器的任务。

(第32分)


4章 汇编

4.1 汇编的概念与作用

概念:汇编器(as)将hello.s文件中的汇编语言翻译成机器语言指令,并且将这些机器指令语言生成一个可重定位目标文件,这个可重定位目标文件是一个二进制文件并且后缀为.o。

作用:Assembly语言虽然晦涩难懂,但是毕竟也是人可以理解的语言,机器是不能直接理解的,所以需要汇编这一个阶段,将汇编代码转换为机器可以识别的二进制文件——一个可重定位目标文件,然后这个文件经过链接器就可以被机器识别并且执行。

4.2 在Ubuntu下汇编的命令

  1. gcc -c hello.s -o hello.o  

4.3 可重定位目标elf格式

readelf -a hello.o > hello.elf  

上面这段命令行可以利用hello.o生成hello.elf文件, 开始是一段elf头,其中包含了机器的相关信息和.o文件的相关信息。这些信息可以帮助链接器完成接下来的工作。

  1. ELF 头:  
  2. Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00   
  3. 类别:                              ELF64  
  4. 数据:                              2 补码,小端序 (little endian)  
  5. Version:                           1 (current)  
  6. OS/ABI:                            UNIX - System V  
  7. ABI 版本:                          0  
  8. 类型:                              REL (可重定位文件)  
  9. 系统架构:                          Advanced Micro Devices X86-64  
  10. 版本:                              0x1  
  11. 入口点地址:               0x0  
  12. 程序头起点:          0 (bytes into file)  
  13. Start of section headers:          1384 (bytes into file)  
  14. 标志:             0x0  
  15. Size of this header:               64 (bytes)  
  16. Size of program headers:           0 (bytes)  
  17. Number of program headers:         0  
  18. Size of section headers:           64 (bytes)  
  19. Number of section headers:         14  
  20. Section header string table index: 13  

紧接着的是节头部表,目标文件中的每一个节都有一个固定的条目体现在这个表中,指明了名称、类型、起始地址和偏移量在内的各节的信息:

  1. 节头:  
  2. [号] 名称              类型             地址              偏移量  
  3. 大小              全体大小          旗标   链接   信息   对齐  
  4. [ 0]                   NULL             0000000000000000  00000000  
  5. 0000000000000000  0000000000000000           0     0     0  
  6. [ 1] .text             PROGBITS         0000000000000000  00000040  
  7. 00000000000000bf  0000000000000000  AX       0     0     1  
  8. [ 2] .rela.text        RELA             0000000000000000  000003d0  
  9. 0000000000000108  0000000000000018   I      11     1     8  
  10. [ 3] .data             PROGBITS         0000000000000000  000000ff  
  11. 0000000000000000  0000000000000000  WA       0     0     1  
  12. [ 4] .bss              NOBITS           0000000000000000  000000ff  
  13. 0000000000000000  0000000000000000  WA       0     0     1  
  14. [ 5] .rodata           PROGBITS         0000000000000000  00000100  
  15. 0000000000000033  0000000000000000   A       0     0     8  
  16. [ 6] .comment          PROGBITS         0000000000000000  00000133  
  17. 000000000000002b  0000000000000001  MS       0     0     1  
  18. [ 7] .note.GNU-stack   PROGBITS         0000000000000000  0000015e  
  19. 0000000000000000  0000000000000000           0     0     1  
  20. [ 8] .note.gnu.propert NOTE             0000000000000000  00000160  
  21. 0000000000000020  0000000000000000   A       0     0     8  
  22. [ 9] .eh_frame         PROGBITS         0000000000000000  00000180  
  23. 0000000000000038  0000000000000000   A       0     0     8  
  24. [10] .rela.eh_frame    RELA             0000000000000000  000004d8  
  25. 0000000000000018  0000000000000018   I      11     9     8  
  26. [11] .symtab           SYMTAB           0000000000000000  000001b8  
  27. 00000000000001c8  0000000000000018          12    10     8  
  28. [12] .strtab           STRTAB           0000000000000000  00000380  
  29. 000000000000004f  0000000000000000           0     0     1  
  30. [13] .shstrtab         STRTAB           0000000000000000  000004f0  
  31. 0000000000000074  0000000000000000           0     0     1  

然后就是重定位节,这里指明了每一个变量的重定位详细信息:

  1. 重定位节 '.rela.text' at offset 0x3d0 contains 11 entries:  
  2. 偏移量          信息           类型           符号值        符号名称 + 加数  
  3. 00000000001e  000c00000004 R_X86_64_PLT32    0000000000000000 signal - 4  
  4. 00000000002d  000c00000004 R_X86_64_PLT32    0000000000000000 signal - 4  
  5. 00000000003c  000c00000004 R_X86_64_PLT32    0000000000000000 signal - 4  
  6. 000000000049  000500000002 R_X86_64_PC32     0000000000000000 .rodata - 4  
  7. 00000000004e  000d00000004 R_X86_64_PLT32    0000000000000000 puts - 4  
  8. 000000000058  000e00000004 R_X86_64_PLT32    0000000000000000 exit - 4  
  9. 000000000081  000500000002 R_X86_64_PC32     0000000000000000 .rodata + 22  
  10. 00000000008b  000f00000004 R_X86_64_PLT32    0000000000000000 printf - 4  
  11. 00000000009e  001000000004 R_X86_64_PLT32    0000000000000000 atoi - 4  
  12. 0000000000a5  001100000004 R_X86_64_PLT32    0000000000000000 sleep - 4  
  13. 0000000000b4  001200000004 R_X86_64_PLT32    0000000000000000 getchar - 4  

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

  1. typedef struct{  
  2. long offset;  
  3. long type : 32,  
  4. symbol : 32;  
  5. long addend;  
  6. }Elf64_Rela;  

上面是ELF重定位条目的格式,其中offset是需要被修改的引用的节偏移。symbol标识被修改引用应该指向的符号。type告知链接器如何修改新的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。

ELF定义了32种不同的重定位类型,有些相当隐秘。我们只关心其中最基本的重定位类型:R_X86_64_PC32重定位一个使用32PC相对地址的引用;R_X64_64_32重定位一个使用32位绝对地址的引用。对于上述两种重定位类型,重定位算法分别是:

  1. foreach section s{  
  2. foreach relocation entry r{  
  3. refptr=s+r.offset;  
  4. if(r.type==R_X86_64_PC32){  
  5. refaddr=ADDR(s)+r.offset;  
  6. *refptr=(unsigned)( ADDR(r.symbol)+r.addend-refaddr);  
  7. }  
  8. if(r.type=-R_X86_64_32)  
  9. *refptr=(unsigned)(ADDR(r.symbol)+r.addend);  
  10. }  
  11. }  

其中ADDR(s)ADDR(r.symbol)分别为节和符号的运行时地址,从上表中获取的重定位信息结合算法即可完成重定位操作。

 最后还有一个符号表.symtab的信息。这个节中存放了在程序中定义和引用的函数和全局变量的信息。

  1. Symbol table '.symtab' contains 19 entries:  
  2. Num:    Value          Size Type    Bind   Vis      Ndx Name  
  3. 0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND   
  4. 1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS hello.c  
  5. 2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1   
  6. 3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3   
  7. 4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4   
  8. 5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5   
  9. 6: 0000000000000000     0 SECTION LOCAL  DEFAULT    7   
  10. 7: 0000000000000000     0 SECTION LOCAL  DEFAULT    8   
  11. 8: 0000000000000000     0 SECTION LOCAL  DEFAULT    9   
  12. 9: 0000000000000000     0 SECTION LOCAL  DEFAULT    6   
  13. 10: 0000000000000000   191 FUNC    GLOBAL DEFAULT    1 main  
  14. 11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_  
  15. 12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND signal  
  16. 13: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND puts  
  17. 14: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND exit  
  18. 15: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf  
  19. 16: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND atoi  
  20. 17: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND sleep  
  21. 18: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND getchar  

Size保留的是它们的大小、Name是符号的名字、type保留的是符号的类型、bind保留的是符号的可见性,即作用域、Ndx表示所属SectionValue表示相对Ndx的偏移值Vis因为还没有链接所以没有显示有用的信息。

4.4 Hello.o的结果解析

  1. objdump -d -r hello.o  

上述命令可以直接在终端中查看反汇编代码,但是为了反复查看的方便,使用下面的命令行直接生成文本文件:

  1. objdump -d hello.o > hello.d  

下面就是反汇编文件的源码:

  1. hello.o:     文件格式 elf64-x86-64  
  2. Disassembly of section .text:  
  3. 0000000000000000 
    :  
  4. 0:   f3 0f 1e fa             endbr64   
  5. 4:   55                      push   %rbp  
  6. 5:   48 89 e5                mov    %rsp,%rbp  
  7. 8:   48 83 ec 20             sub    $0x20,%rsp  
  8. c:   89 7d ec                mov    %edi,-0x14(%rbp)  
  9. f:   48 89 75 e0             mov    %rsi,-0x20(%rbp)  
  10. 13:   be 01 00 00 00          mov    $0x1,%esi  
  11. 18:   bf 02 00 00 00          mov    $0x2,%edi  
  12. 1d:   e8 00 00 00 00          callq  22   
  13. 22:   be 01 00 00 00          mov    $0x1,%esi  
  14. 27:   bf 14 00 00 00          mov    $0x14,%edi  
  15. 2c:   e8 00 00 00 00          callq  31   
  16. 31:   be 01 00 00 00          mov    $0x1,%esi  
  17. 36:   bf 03 00 00 00          mov    $0x3,%edi  
  18. 3b:   e8 00 00 00 00          callq  40   
  19. 40:   83 7d ec 04             cmpl   $0x4,-0x14(%rbp)  
  20. 44:   74 16                   je     5c   
  21. 46:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 4d   
  22. 4d:   e8 00 00 00 00          callq  52   
  23. 52:   bf 01 00 00 00          mov    $0x1,%edi  
  24. 57:   e8 00 00 00 00          callq  5c   
  25. 5c:   c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)  
  26. 63:   eb 48                   jmp    ad   
  27. 65:   48 8b 45 e0             mov    -0x20(%rbp),%rax  
  28. 69:   48 83 c0 10             add    $0x10,%rax  
  29. 6d:   48 8b 10                mov    (%rax),%rdx  
  30. 70:   48 8b 45 e0             mov    -0x20(%rbp),%rax  
  31. 74:   48 83 c0 08             add    $0x8,%rax  
  32. 78:   48 8b 00                mov    (%rax),%rax  
  33. 7b:   48 89 c6                mov    %rax,%rsi  
  34. 7e:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 85   
  35. 85:   b8 00 00 00 00          mov    $0x0,%eax  
  36. 8a:   e8 00 00 00 00          callq  8f   
  37. 8f:   48 8b 45 e0             mov    -0x20(%rbp),%rax  
  38. 93:   48 83 c0 18             add    $0x18,%rax  
  39. 97:   48 8b 00                mov    (%rax),%rax  
  40. 9a:   48 89 c7                mov    %rax,%rdi  
  41. 9d:   e8 00 00 00 00          callq  a2   
  42. a2:   89 c7                   mov    %eax,%edi  
  43. a4:   e8 00 00 00 00          callq  a9   
  44. a9:   83 45 fc 01             addl   $0x1,-0x4(%rbp)  
  45. ad:   83 7d fc 07             cmpl   $0x7,-0x4(%rbp)  
  46. b1:   7e b2                   jle    65   
  47. b3:   e8 00 00 00 00          callq  b8   
  48. b8:   b8 00 00 00 00          mov    $0x0,%eax  
  49. bd:   c9                      leaveq   
  50. be:   c3                      retq     

通过与hello.s的对比,发现有以下的不同之处;

1、操作数在hello.s中为十进制,在hello.d为十六进制:

  1. subq    $32, %rsp 
  2. sub    $0x20,%rsp  

2、分支转移地址表示,hello.s上为".L1"的段名称,在hello.d为相对偏移的地址:

  1. movl    $0, -4(%rbp)  
  2. jmp .L3  
  3. movl   $0x0,-0x4(%rbp)  
  4. jmp    ad   

3、函数调用时,hello.s上call后面是函数名,在hello.dcall指令后是调用函数的相对偏移地址:

  1. movl    $1, %edi  
  2. call    exit@PLT  
  3. mov    $0x1,%edi  
  4. callq  5c   

4、访问全局变量时,hello.s上是通过".LC1(%rip)"的形式访问,在hello.d是以"0x0(%rip)"的形式访问,添加了重定位条目:

  1. leaq    .LC0(%rip), %rdi  
  2. lea    0x0(%rip),%rdi   

从上面的对比可以看得出来:机器语言是简单的二进制语言,在hello.d表现为十六进制的形式,不同的操作二进制数可以对应不同的操作指令、变量、寄存器等等,机器语言中的数值都是十六进制数(二进制数),汇编语言中可以是十进制数,机器语言中分支跳转使用的是绝对地址或者是相对地址,而汇编语言中可以直接利用要跳转地方的标记。

4.5 本章小结

通过汇编操作,把机器无法理解的汇编语言转化为机器语言,虽然机器语言可读性比较差,但是hello.o已经非常接近一个机器可以执行的文件了,hello.o可重定位目标文件为还要需要最后的链接。通过对比hello.s和反汇编代码的区别,更加清晰的展示了汇编语言和机器语言的关联和不同,
                      5 链接

5.1 链接的概念与作用

概念:链接(Link其实就是一个打包的过程,它将所有二进制形式的目标文件(这里指的是所有的可重定位目标文件)和系统组件组合成一个可执行文件。完成链接的过程也需要一个特殊的软件,叫做链接器(Linker)。

作用:当程序调用函数库(如标准C库)中的一个函数printfprintf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个函数必须通过链接器(ld)将这个文件合并到hello.o程序中,结果得到hello文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。另外,链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。

5.2 在Ubuntu下链接的命令

  1. ld -o hello -dynamic-linker  

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

  1. readelf -a hello > hello1.elf  

利用上述的命令行可以获得helloelf格式文件:elf头不仅给出了机器的基本信息还给出了程序的入口点,程序头等,表示已经完成了链接:

  1. ELF 头:  
  2. Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00   
  3. 类别:                              ELF64  
  4. 数据:                              2 补码,小端序 (little endian)  
  5. Version:                           1 (current)  
  6. OS/ABI:                            UNIX - System V  
  7. ABI 版本:                          0  
  8. 类型:                              EXEC (可执行文件)  
  9. 系统架构:                          Advanced Micro Devices X86-64  
  10. 版本:                              0x1  
  11. 入口点地址:               0x401110  
  12. 程序头起点:          64 (bytes into file)  
  13. Start of section headers:          14256 (bytes into file)  
  14. 标志:             0x0  
  15. Size of this header:               64 (bytes)  
  16. Size of program headers:           56 (bytes)  
  17. Number of program headers:         12  
  18. Size of section headers:           64 (bytes)  
  19. Number of section headers:         27  
  20. Section header string table index: 26  

  然后是节头,有各节的信息。这些节已经被重定位至最终运行时的地址。

  1. 节头:  
  2. [号] 名称              类型             地址              偏移量  
  3. 大小              全体大小          旗标   链接   信息   对齐  
  4. [ 0]                   NULL             0000000000000000  00000000  
  5. 0000000000000000  0000000000000000           0     0     0  
  6. [ 1] .interp           PROGBITS         00000000004002e0  000002e0  
  7. 000000000000001c  0000000000000000   A       0     0     1  
  8. [ 2] .note.gnu.propert NOTE             0000000000400300  00000300  
  9. 0000000000000020  0000000000000000   A       0     0     8  
  10. [ 3] .note.ABI-tag     NOTE             0000000000400320  00000320  
  11. 0000000000000020  0000000000000000   A       0     0     4  
  12. [ 4] .hash             HASH             0000000000400340  00000340  
  13. 000000000000003c  0000000000000004   A       6     0     8  
  14. [ 5] .gnu.hash         GNU_HASH         0000000000400380  00000380  
  15. 000000000000001c  0000000000000000   A       6     0     8  
  16. [ 6] .dynsym           DYNSYM           00000000004003a0  000003a0  
  17. 00000000000000f0  0000000000000018   A       7     1     8  
  18. [ 7] .dynstr           STRTAB           0000000000400490  00000490  
  19. 0000000000000063  0000000000000000   A       0     0     1  
  20. [ 8] .gnu.version      VERSYM           00000000004004f4  000004f4  
  21. 0000000000000014  0000000000000002   A       6     0     2  
  22. [ 9] .gnu.version_r    VERNEED          0000000000400508  00000508  
  23. 0000000000000020  0000000000000000   A       7     1     8  
  24. [10] .rela.dyn         RELA             0000000000400528  00000528  
  25. 0000000000000030  0000000000000018   A       6     0     8  
  26. [11] .rela.plt         RELA             0000000000400558  00000558  
  27. 00000000000000a8  0000000000000018  AI       6    21     8  
  28. [12] .init             PROGBITS         0000000000401000  00001000  
  29. 000000000000001b  0000000000000000  AX       0     0     4  
  30. [13] .plt              PROGBITS         0000000000401020  00001020  
  31. 0000000000000080  0000000000000010  AX       0     0     16  
  32. [14] .plt.sec          PROGBITS         00000000004010a0  000010a0  
  33. 0000000000000070  0000000000000010  AX       0     0     16  
  34. [15] .text             PROGBITS         0000000000401110  00001110  
  35. 0000000000000175  0000000000000000  AX       0     0     16  
  36. [16] .fini             PROGBITS         0000000000401288  00001288  
  37. 000000000000000d  0000000000000000  AX       0     0     4  
  38. [17] .rodata           PROGBITS         0000000000402000  00002000  
  39. 000000000000003b  0000000000000000   A       0     0     8  
  40. [18] .eh_frame         PROGBITS         0000000000402040  00002040  
  41. 00000000000000fc  0000000000000000   A       0     0     8  
  42. [19] .dynamic          DYNAMIC          0000000000403e50  00002e50  
  43. 00000000000001a0  0000000000000010  WA       7     0     8  
  44. [20] .got              PROGBITS         0000000000403ff0  00002ff0  
  45. 0000000000000010  0000000000000008  WA       0     0     8  
  46. [21] .got.plt          PROGBITS         0000000000404000  00003000  
  47. 0000000000000050  0000000000000008  WA       0     0     8  
  48. [22] .data             PROGBITS         0000000000404050  00003050  
  49. 0000000000000004  0000000000000000  WA       0     0     1  
  50. [23] .comment          PROGBITS         0000000000000000  00003054  
  51. 000000000000002a  0000000000000001  MS       0     0     1  
  52. [24] .symtab           SYMTAB           0000000000000000  00003080  
  53. 00000000000004e0  0000000000000018          25    30     8  
  54. [25] .strtab           STRTAB           0000000000000000  00003560  
  55. 000000000000016c  0000000000000000           0     0     1  
  56. [26] .shstrtab         STRTAB           0000000000000000  000036cc  
  57. 00000000000000e1  0000000000000000           0     0     1  

5.4 hello的虚拟地址空间

使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。   

  1. 程序头:  
  2. Type           Offset             VirtAddr           PhysAddr  
  3. FileSiz            MemSiz              Flags  Align  
  4. PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040  
  5. 0x00000000000002a0 0x00000000000002a0  R      0x8  
  6. INTERP         0x00000000000002e0 0x00000000004002e0 0x00000000004002e0  
  7. 0x000000000000001c 0x000000000000001c  R      0x1  
  8. [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]  
  9. LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000  
  10. 0x0000000000000600 0x0000000000000600  R      0x1000  
  11. LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000  
  12. 0x0000000000000295 0x0000000000000295  R E    0x1000  
  13. LOAD           0x0000000000002000 0x0000000000402000 0x0000000000402000  
  14. 0x000000000000013c 0x000000000000013c  R      0x1000  
  15. LOAD           0x0000000000002e50 0x0000000000403e50 0x0000000000403e50  
  16. 0x0000000000000204 0x0000000000000204  RW     0x1000  
  17. DYNAMIC        0x0000000000002e50 0x0000000000403e50 0x0000000000403e50  
  18. 0x00000000000001a0 0x00000000000001a0  RW     0x8  
  19. NOTE           0x0000000000000300 0x0000000000400300 0x0000000000400300  
  20. 0x0000000000000020 0x0000000000000020  R      0x8  
  21. NOTE           0x0000000000000320 0x0000000000400320 0x0000000000400320  
  22. 0x0000000000000020 0x0000000000000020  R      0x4  
  23. GNU_PROPERTY   0x0000000000000300 0x0000000000400300 0x0000000000400300  
  24. 0x0000000000000020 0x0000000000000020  R      0x8  
  25. GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000  
  26. 0x0000000000000000 0x0000000000000000  RW     0x10  
  27. GNU_RELRO      0x0000000000002e50 0x0000000000403e50 0x0000000000403e50  
  28. 0x00000000000001b0 0x00000000000001b0  R      0x1  

这里展示了elf文件中的程序头部表,描述了可执行文件连续的片和连续的内存段的映射关系。

PHDR

程序头表

INTERP

程序执行前需要调用的解释器

LOAD

程序目标代码和常量信息

DYNAMIC

动态链接器使用信息

NOTE

保存辅助信息

GNU_EH_FRAME

保存异常信息

GNU_STACK

标志栈是否可用的标志信息

GNU_RELRO

保存在重定位之后只读信息的位置

以起始地址为0x00400040查看,对比elf头所给出的信息是一致的:

  1. ELF 头:  
  2. Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00   
  3. objdump -d -r hello  

5.5 链接的重定位过程分析

上述的命令行可以直接在终端中显示反汇编代码,但是为了反复查看反汇编代码,用下述的命令行直接生成文本文件hello1.d

  1. objdump -d hello > hello.d  

展示开头的一部分反汇编代码:

  1. hello:     文件格式 elf64-x86-64  
  2. Disassembly of section .init:  
  3. 0000000000401000 <_init>:  
  4. 401000:   f3 0f 1e fa             endbr64   
  5. 401004:   48 83 ec 08             sub    $0x8,%rsp  
  6. 401008:   48 8b 05 e9 2f 00 00    mov    0x2fe9(%rip),%rax        # 403ff8 <__gmon_start__>  
  7. 40100f:   48 85 c0                test   %rax,%rax  
  8. 401012:   74 02                   je     401016 <_init+0x16>  
  9. 401014:   ff d0                   callq  *%rax  
  10. 401016:   48 83 c4 08             add    $0x8,%rsp  
  11. 40101a:   c3                      retq     

接下来分析hello1.dhello.d之间存在的不同之处:

1、hello汇编代码中出现了许多我们没有自己构造的函数,hello.o的汇编代码中只给出了main函数,而hello文件中用到了像init这样的函数。这说明在hello文件里面许多被用到的函数都被链接进入了hello的可执行文件之中。

401012: 74 02                   je     401016 <_init+0x16> 

2、在函数调用方面,hello.o在调用函数的时候给出的是重定位信息,但是在hello中给出了具体的地址,采用的PC相对寻址:

  1. 2c: e8 00 00 00 00          callq  31   
  2. 401171: e8 5a ff ff ff          callq  4010d0   

3、在数据调用阶段,hello.o在调用数据(比如数组)的时候,给出的是重定位信息,但是在hello中,给出的是具体的数据的地址,采用的是绝对寻址:

  1. 46: 48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 4d   
  2. 118b:   48 8d 3d 76 0e 00 00    lea    0xe76(%rip),%rdi        # 402008 <_IO_stdin_used+0x8>  

通过上述的对比可知,在可重定位目标文件文件中,对于函数和数据的引用不会直接给出地址,而是给出重定位信息,在链接之后,链接器会根据可重定位目标文件文件中的重定位信息来计算出函数和数目的地址。

5.6 hello的执行流程

hello在执行的过程中一共要执行三个阶段,分别是载入、执行和退出。载入过程的作用是将程序初始化,等初始化完成后,程序才能够开始正常的执行。由于hello程序只有一个main函数,所以在程序执行的时候主要都是在main函数中。又因为main函数中调用了很多其它的库函数,所以在main函数执行的过程中,会出现很多其他的函数。

载入

ld-linux-x86-64.so!_dl_start

ld-linux-x86-64.so!_dl_init

 

开始执行

hello!_start

hello!__libc_csu_init

hello!_init

libc.so!_setjmp

 

 

 

main函数执行

hello!main

hello!signal@plt

hello!puts@plt

Hello!atoi@plt

ld-linux-x86-64.so!_dl_runtime_resolve_xsave

ld-linux-x86-64.so!_dl_fixup

ld-linux-x86-64.so!_dl_lookup_symbol_x

hello!exit@plt

结束

libc.so!exit

hello!_fini

5.7 Hello的动态链接分析

hello程序的动态链接项目:global_offset表,这是在do_init之前的状态。

这是在do_init之后的global_offset表的状态:

经过对比发现:原先的global_offset表是全0的状态,在执行过_dl_init之后被赋上了相应的偏移量的值。这说明dl_init操作是给程序赋上当前执行的内存地址的偏移量,对比信息可见链接器解析函数的地址信息然后加入到信息中去

5.8 本章小结

链接是将各种代码和数据片段收集并组合为一个单一文件的过程。这一章通过对链接过程的层层解析,使得对链接器的工作内容了有了深刻理解,对重定位的详细解释,也更加掌握了其中的原理。同时,链接可以发生在编译时、可以发生在加载时、可以发生在执行时,甚至可以发生在执行时。

(第51分)


6 hello进程管理

6.1 进程的概念与作用

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

作用:进程的概念提供给我一种假象:在现代系统上运行一个程序时,就好像我们的程序是系统当前唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条一条地执行我们的程序中的指令。最后,我们程序中的代码好像是系统内存中唯一的对象。

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

作用:Shell是一个命令行解释器,它解释由用户输入的命令并且把它们送到内核Shell在操作系统中为用户与内核之间提供了一个交互的界面,用户可以通过这个界面访问操作系统的内核服务。

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

(2)将输入字符串切分,分析输入内容,解析命令和参数。

(3)如果命令为内置命令则立即执行,如果不是内置命令则创建新的进程调用相应的程序执行。

(4)在运行期间,监控shell界面内是否有键盘输入的命令,如果有需要作出相应的反应。

6.3 Hello的fork进程创建过程

  1. ./hello 谭方舟 1190201610 2  

shell中输入上述的命令,但是因为./hello不是系统内置的命令行,所以shell会认为hello是一个可执行性文件。shell作为父进程通过fork函数为hello创建一个新的进程作为子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着 当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别就在于它们有着不同的PIDfork函数只被调用一次,却返回两次:在父进程中,返回子进程的PID,在子进程中返回0.

6.4 Hello的execve过程

shell中输入6.3节中的命令行,shell通过某个驻留在储存器中被称为加载器的操作系统代码来运行它。任何Linux程序都可以通过调用execve函数来调用加载器。Evecve函数加载并运行可执行目标文件hello,并且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到helloexevce才会返回到调用程序。

exceve加载了hello之后,它调用启动代码,启动代码设置栈(如下图),并将控制传递给新程序的主函数,该主函数有如下的原型:

int main(int argc ,char *argv[], char *envp[]) 

此时用户栈已经包含了命令行参数和环境变量,进入main函数后开始逐步运行程序。

6.5 Hello的进程执行

hello执行时存在逻辑控制流,多个进程的逻辑控制流在时间上可以交错,进程是轮流执行它的流的一部分,然后被抢占然后轮到其它进程。对于一个运行在这些进程之一的上下文中的程序,它看上去就像是在独占地使用处理器。进程控制权的交换需要上下文切换。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计时器、用户栈……

Hello执行的某些时刻,比如sleep函数,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度。当内核调度了一个新的进程运行后,它就抢占Hello进程,并且使用上下文切换机制来将控制转移到新的进程。

 

这里将sleep的时间输入为2秒.hello程序在执行到sleep函数的时候,切换到内核模式,将hello进程挂起,然后切换到用户模式执行其它进程。当到了2秒之后,发生一个中断,切换到内核模式,继续运行之前被挂起的进程。最后切换回用户模式,继续运行hello进程。

6.6 hello的异常与信号处理

在源文件框架的要求中说程序运行的时候可以随便乱按键盘,包括按CTRL+CCTRL+Z,但是在报告中却又要求按下CTRL+Z可以实现将程序暂时挂起,所以在编写C文件的时候首先将SIGINTSIGTSTPSIGQUIT的默认行为改为忽略,然后在打印完所有的信息之后就恢复SIGINTSIGTSTP的默认行为。

会出现四种异常:中断、陷阱、故障、终止。

会出现的信号:SIGSTP、SIGCONT、SIGKILL、SIFGINT等。

中断是来自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。

陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。

故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。

终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。

(1)运行中不停乱按,(包括CTRL+Z/C、回车),运行中回车,会将按键操作的输入放到缓存区中,程序运行结束执行。

(2)在恢复SIGINT的默认行为之后,再按下CTRL+C:

(3)在回复SIGTSTP的默认行为之后,再按下CTRL+Z

(4)在执行SIGTSTP信号之后,再发送fg指令继续运行进程:

(5)在执行SIGTSTP信号之后,使用ps获取helloPID,再使用kill指令杀死进程:

6)ctrl-z后运行jobs、pstree命令,输出相关信息。jobs命令输出当前已启动的任务状态;pstree命令输出进程间的树状关系。

6.7本章小结

本章介绍了进程的概念和作用,介绍了shell是如何为进程使用forkexecve函数的,同时也分析和实践了进程执行过程中异常和信号的处理问题。至此,可执行目标文件成功被加载至内存并执行。

(第61分)


7 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元(memory cell)、存储单元(storage element)、网络主机(network host)的地址。 逻辑地址往往不同于物理地址(physical address),通过地址翻译器(address translator)或映射函数可以把逻辑地址转化为物理地址。

线性地址:线性地址是一个32无符号整数,可以用来表示高达4GB的地址,也就是,高达4294967296个内存单元。线性地址通常用十六进制数字表示,值的范围从0x000000000xffffffff)。程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。当采用4KB分页大小的时候,线性地址的高10位为页目录项在页目录表中的编号,中间10位为页表中的页号,其低12位则为偏移地址。如果是使用4MB分页机制,则高10位页号,低22位为偏移地址。如果没有启用分页机制,那么线性地址直接就是物理地址

虚拟地址:现代系统提供了一种对主存的抽象概念,叫做虚拟内存。使用虚拟寻址,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送到内存之前先转换为适当的物理地址。将一个虚拟地址转换为物理地址的任务叫地址翻译。

物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有一个物理地址。第一个字节的地址为0,接下来的字节地址为1,再下一个为2,以此类推。给定这种简单的结构,CPU访问内存的最自然的方式就是物理寻址。

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

如果将段基址赋值为0 ,将段跳过了。现在对于每个进程来讲可用的空间为4G,对于存储空间来讲分成了:内核的数据段、内核的代码段、用户的数据段、用户的代码段。对于段的具体寻找是通过LDT、GDT、LDTR、GDTR、段寄存器来查找的。

GDT: 存放全局的代码段、全局的数据段的描述符
  LDT: 存放各个程序的代码段和数据段的描述符
  GDTR:全局段的描述副指针和指向LDT的描述符:32位基址 和 16位的段限长
  LDTR:16 有效偏移量
  如果段在LDT(TI=1)中,从GDTR中获取GDT的32的基址,GDT基址和LDTR中高12的索引结合找到LDT的描述符的指针(某个程序LDT的基址),LDT的基址和段寄存器的索引结合找到任务的私有段。

如果段在GDT中(即TI=0),从GTDT中找到GDT的基址和段寄存器的索引值结合,找到公共的数据段或者代码段

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

线性地址到物理地址的转换是通过页的这个概念完成的。线性地址被分为以固定长度为单位的组,称为页。

分页机制是实现虚拟存储的关键,位于线性地址与物理地址的变换之间设置。虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘上的数据被分割成块,这些块作为磁盘和主存之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页为大小固定的块来处理这个问题。每个虚拟页的大小固定。类似地,物理内存被分割为物理页,大小与虚拟页相同。

同任何缓存一样,虚拟内存系统必须用某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM,替换这个牺牲页。

这些功能是由软硬件联合提供的,包括操作系统软件、MMU中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将一个虚拟地址转换为物理地址时读取页表。操作系统负责维护页表中的内容,以及再磁盘与DRAM之间来回传送页。

CPU芯片上有一个专门的硬件叫做内存管理单元(MMU),这个硬件的功能就是动态的将虚拟地址翻译成物理地址的。下图就展示了MMU是如何工作的。N为的虚拟地址包含两个部分,一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE(页表条目)。接下来在对应的PTE中获得PPN(物理页号),将PPN与VPO串联起来,就得到了相应的物理地址。

 

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

上面的示意图展示了Core i7 MMU如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN被划分为四个九位的片,每个片都用作到一个页表的偏移量。CR3寄存器包含了L1页表的物理地址。VPN1提供到一个L1PET的偏移量,这个PET包含了L2页表的基地址。VPN1提供一个L1 PET的偏移量,这个PET包含L2页表的基地址。VPN2提供一个L2 PET的偏移量,以此类推。

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

得到物理地址PA后,通过其访问物理内存,物理地址由CI(组索引)、CT(标记位)、CO(偏移量)组成。首先使用CI进行组索引,每组8路,对8路的块分别匹配标记位CT,如果匹配成功且块的有效位为1则命中,根据数据偏移量CO取出数据返回。如果没有匹配成功则不命中,向下一级缓存中查询数据,顺序是L1缓存到L2缓存到L3缓存到主存。查询到数据后,放置策略是如果映射到的组有空闲块则直接放置,否则产生冲突,采用最近最少使用策略驱逐块并替换新块进入。

 

7.6 hello进程fork时的内存映射

fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本(如下图所示)。它将两个进程中的每个页表都标记为只读,并将两个进程中的每一个区域结构都标记为私有的写时复制。

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

7.7 hello进程execve时的内存映射

假设在当前进程中的程序执行了如下的exceve调用:exceve("hello",NULL,NULL);exceve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello代替代替当前程序。加载并运行hello需要以下几个步骤:

·删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。

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

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

·设置程序计数器(PC) execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

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

在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页(page fault) 下左图展示了在缺页之前我们的示例页表的状态。CPU引用了VP 3中的一个字,VP 3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE 3,从有效位推断出VP 3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP 3中的VP 4。如果VP 4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP 4的页表条目,反映出VP 4不再缓存在主存中这一事实。

接下来,内核从磁盘复制VP 3到内存中的PP 3,更新PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送到地址翻译硬件。但是现在,VP 3已经在缓存在主页中了,那么页命中也能命中也能由地址翻译硬件正常处理了。上右图展示了在缺页之后我们的示例页表的状态。

7.9动态存储分配管理

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

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

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

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

·隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,诸如LispML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。

隐式空闲链表

任何实际的分配器都需要一些数据结构,允许它来区别边界,以及区别已分配块和空闲块,大多数分配器将这些信息嵌入块本身。一个简单的方法如下图所示:

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

头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。

假设块的格式如上图所示,我们可以将堆组织为一个连续的已分配块和空闲块的序列,如下图所示。

显式空闲链表

一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,下图所示。

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。

一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。

另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。

一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。

7.10本章小结

本章介绍了hello的存储地址和存储空间,展示了虚拟内存相关知识和各种地址表示之间的转化。并分析了进程的内存映射、缺页故障和缺页故障处理,同时还程序了动态内存分配通常采用的块的结构和策略。
               8 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

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

8.2 简述Unix IO接口及其函数

Unix I/O中所有的输入和输出都可以一种统一且一致的方式来执行:

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

·Linux shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出和标准错误。头文件定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可以用来代替显式的描述符值。

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

·读写文件。一个读操作就是从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file的条件,应用程序能够检测到这个条件。在文件结尾处并没有明确的EOF符号

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

·关闭文件。当应用程序完成了对文件的访问后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。

Unix I/O 函数:

  1. int open(char* filename,int flags,mode_t mode)//打开一个已经存在的文件或者创建一个新的文件  
  2. int close(fd)//关闭一个打开的文件  
  3. ssize_t read(int fd,void *buf,size_t n)//从当前文件位置复制字节到内存位置  
  4. ssize_t wirte(int fd,const void *buf,size_t n)//从内存复制字节到当前文件位置 

8.3 printf的实现分析

printf需要做的事情是:接受一fmt的格式,然后将匹配到的参数按照fmt格式输出。下图printf的代码,可以发现,他调用了两个外部函数,一个是vsprintf,还有一个是write

  1. int printf(const char *fmt, ...) {  
  2. int i;  
  3. char buf[256];  
  4. va_list arg = (va_list)((char *)(&fmt) + 4);  
  5. i = vsprintf(buf, fmt, arg);  
  6. write(buf, i);  
  7. return i;  
  8. }  

其中,va_list是一个字符指针数据类型,代码中的赋值表示省略参数中的第一个参数,arg变量定位到了第二个参数,也就是第一个格式串。

vsprintf函数的实现代码如下:

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

从上述的vsprintf函数可以看出,这个函数的作用是将所有的参数内容格式化后存入buf,然后返回格式化数组的长度。

write函数是将buf中的i个元素写到终端的函数,它的汇编代码如下图:

  1. mov eax, _NR_write  
  2. mov ebx, [esp + 4]  
  3. mov ecx, [esp + 8]  
  4. int INT_VECTOR_SYS_CALL  

综上所述,printf函数实现流程是:vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80syscall,syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中储存的是字节的ASCII码。syscall的实现如下:

  1. sys_call:    
  2. call save     
  3. push dword [p_proc_ready]    
  4. sti         
  5. push ecx     
  6. push ebx     
  7. call [sys_call_table + eax * 4]     
  8. add esp, 4 * 3     
  9. mov [esi + EAXREG - P_STACKBASE], eax     
  10. cli     
  11. ret  

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成ASCII码,保存到系统的键盘缓冲区之中。

getchar()函数的实现代码如下图所示:

  1. int getchar(void)  
  2. {  
  3. static char buf[BUFSIZ];  
  4. static char *bb=buf;  
  5. static int n=0;  
  6. if(n==0)  
  7. {  
  8. n=read(0,buf,BUFSIZ);  
  9. bb=buf;  
  10. }  
  11. return (-n>=0)?(unsigned char)*bb++:EOF;  
  12. }  

bb是缓冲区的开始,int变量n初始化为0,只有在n为0的情况下从缓冲区读入BUFSIZ个字节。返回时如果n大于0,那么返回缓冲区的第一个字符。否则返回EOF

getchar调用了一个read函数,这个read数是将整个缓冲区都读到了buf里面,然后将返回值是缓冲区的长度。我们可以发现,如果buf长度为0getchar才会调用read函数,否则是直接将保存的buf中的最前面的元素返回。

8.5本章小结

这一章节通过讲述IO设备管理方法、简述IO端口及其函数并且深入解释了printf函数和getchar函数的具体实现和原理,展示了一个程序的运行中Unix I/O在其中发挥了怎样的作用。

结论

分析到这里,hello程序的一生已经从头到尾展现在面前,我们回顾一下,hello程序的形成到最后的终止:

1)编写:我们可以通过各种文本编辑器(比如Vim)或者IDE(比如code::blocks)来编写hello程序的源码文件hello.c

2)预处理:预处理器(cpp)将hello.c被预处理为hello.i文件。

3)编译:编译器(ccl)将hello.i编译为hello.s汇编文件。

4)汇编:汇编器(as)将hello.s被汇编可重定位目标文件hello.o

5)链接:链接器(ld)将可重定位目标文件hello.o和外部文件链接成为可执行文件hello

6)创建进程:用户在shell中输入执行hello的命令行,shell进程调用fork函数为hello创建新进程,并调用execve函数运行hello

7)访问内存:通过MMU将需要访问的虚拟地址转化为物理地址,并通过缓存系统访问内存。

8)动态申请内存:hello运行过程中会调用malloc函数在堆中动态申请内存空间。

9)异常:hello运行过程中会产生各种异常和信号,系统会针对出现的异常和收到的信号执行异常处理程序和信号处理程序。

10)终止:用户可以直接CTRL+C终止hello,也可以运行结束后被父进程回收,内核删除相关数据。
附件

hello.i

hello.c预处理后生成文件

hello.s 

hello.i编译后生成的汇编文件

hello.o

hello.s汇编后生成的可重定位目标文件

hello.d

hello.o的反汇编文件

hello

链接后生成的可执行文件

hello.elf

 

 

hello.o的elf格式文件

Hello1.elf

helloelf格式文件

hello1.d

hello的反汇编的文本文件


参考文献

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

[2] LINUX 逻辑地址、线性地址、物理地址和虚拟地址[]

[3] 百度百科 线性地址

[https://baike.baidu.com/item/线性地址/9013682?fr=aladdin]

[4] 博客园 linux 逻辑地址   线性地址的转

[blog.sina.com.cn/s/blog_821c736301012c47.html]

相关