HITICS大作业:程序人生-Hello‘s P2P

打印 上一主题 下一主题

主题 976|帖子 976|积分 2928

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

x


哈尔滨工业大学



计算机体系


大作业



题     目  程序人生-Hello’s P2P 
专       业    计算机科学与技术   
学     号       2022113573      
班     级         2203101       
学       生         张宇杰      
指 导 教 师          史先俊       






计算机科学与技术学院

2023年12月

摘  要

hello作为程序语言初学者都能读懂并执行的程序,其有着简单的外表,但有着丰富的内涵。本论文借由hello从预处理到IO管理的整个过程,联合《深入理解计算机体系》,简单介绍并分析了其中涉及的计算机体系的知识与内容,并对其中涉及到的一些机制进行了深入的分析。

关键词:CSAPP;hello;                            



目  录


第1章 概述... - 4 -
1.1 Hello简介... - 4 -
1.2 环境与工具... - 4 -
1.3 中心效果... - 5 -
1.4 本章小结... - 5 -
第2章 预处理... - 6 -
2.1 预处理的概念与作用... - 6 -
2.2在Ubuntu下预处理的下令... - 6 -
2.3 Hello的预处理效果解析... - 7 -
2.4 本章小结... - 8 -
第3章 编译... - 9 -
3.1 编译的概念与作用... - 9 -
3.2 在Ubuntu下编译的下令... - 9 -
3.3 Hello的编译效果解析... - 9 -
3.4 本章小结... - 16 -
第4章 汇编... - 17 -
4.1 汇编的概念与作用... - 17 -
4.2 在Ubuntu下汇编的下令... - 17 -
4.3 可重定位目标elf格式... - 17 -
4.4 Hello.o的效果解析... - 23 -
4.5 本章小结... - 25 -
第5章 链接... - 26 -
5.1 链接的概念与作用... - 26 -
5.2 在Ubuntu下链接的下令... - 26 -
5.3 可执行目标文件hello的格式... - 27 -
5.4 hello的虚拟地址空间... - 29 -
5.5 链接的重定位过程分析... - 30 -
5.6 hello的执行流程... - 33 -
5.7 Hello的动态链接分析... - 34 -
5.8 本章小结... - 35 -
第6章 hello进程管理... - 36 -
6.1 进程的概念与作用... - 36 -
6.2 简述壳Shell-bash的作用与处理流程... - 36 -
6.3 Hello的fork进程创建过程... - 36 -
6.4 Hello的execve过程... - 37 -
6.5 Hello的进程执行... - 37 -
6.6 hello的异常与信号处理... - 38 -
6.7本章小结... - 41 -
第7章 hello的存储管理... - 42 -
7.1 hello的存储器地址空间... - 42 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 42 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 43 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 45 -
7.5 三级Cache支持下的物理内存访问... - 46 -
7.6 hello进程fork时的内存映射... - 47 -
7.7 hello进程execve时的内存映射... - 47 -
7.8 缺页故障与缺页中断处理... - 48 -
7.9动态存储分配管理... - 49 -
7.10本章小结... - 52 -
第8章 hello的IO管理... - 53 -
8.1 Linux的IO装备管理方法... - 53 -
8.2 简述Unix IO接口及其函数... - 53 -
8.3 printf的实现分析... - 54 -
8.4 getchar的实现分析... - 56 -
8.5本章小结... - 57 -
结论... - 58 -
附件... - 59 -
参考文献... - 60 -



第1章 概述

1.1 Hello简介

我们通过键盘,向计算机输入一行行代码,这串代码组合成了一个C源文件,也就是我们的主角hello.c。
接下来,hello.c颠末了预处理器cpp,编译器cc1,汇编器as,链接器ld这些家伙一顿好生折腾,最终生成一个可以加载到内存执行的可执行目标文件hello。
然后,我们在Shell中执行下令“./hello 2022113573 张宇杰 1”,Shell通过fork函数创建一个新的进程,然后在子进程里通过execve函数将hello程序加载到内存。虚拟内存机制通过mmap为hello进程规划了一片虚拟空间,调度器为hello进程规划进程执行的时间片,使其能够与其他进程一起公道利用CPU与内存的资源。hello完成了其P2P(From Program to Process)的过程。
之后,CPU一条条的从hello的.text段取指令,寄存器们的值随着程序的执行而不断变化着,异常处理程序监视着键盘的输入。hello中的syscall体系调用会使进程触发陷阱,让内核接办进程,执行write函数,将一串字符传递给屏幕IO的映射文件。
映射文件对传入数据进行分析,读取VRAM,然后在屏幕上将字符显示出一行行字符串“Hello 2022113573 张宇杰”。
末了,hello程序运行结束,Shell通过waitpid函数通知内核回收hello进程,hello进程消失。至此,hello完成了其程序执行的一生,从不带来什么,也不带走什么,是真正的O2O(From Zero to Zero)。
1.2 环境与工具

硬件环境:
12th Gen Intel(R) Core(TM) i5-12500H   3.10 GHz
RAM 16.0 GB
NVIDIA GeForce RTX 3050 Ti Laptop GPU

软件环境:
Windows 11 家庭中文版 22H2
VMware Workstation 16.2.4 build-20089737
Ubuntu 22.04.1
Linux version 6.2.0-37-generic
gcc version 11.4.0

调试工具:
GNU gdb 12.1
edb 1.4.0
1.3 中心效果

列出你为编写本论文,生成的中心效果文件的名字,文件的作用等。
hello.c
C源文件
hello.i
C预处理文件,由hello.c预处理得到
hello.s
汇编语言文件,由hello.i编译得到
hello.o
可重定位目标文件,由hello.s汇编得到
hello_elf.txt
由readelf生成的关于hello.o的ELF信息
hello_asm.txt
由objdump生成的关于hello.o的反汇编信息
hello
可执行文件,由hello.o链接得到
hello_exe_elf.txt
由readelf生成的关于hello的ELF信息
hello_exe_asm.txt
由objdump生成的关于hello的反汇编信息

1.4 本章小结

       本章形貌了hello从出生到殒命的完整过程,以及本人在撰写论文时所使用的软硬件环境,测试使用的调试工具,和产生的中心文件。


第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念
预处理,指预处理器根据预处理器指令,修改C源程序的过程。C预处理器不是编译器的构成部门,但是它是编译过程中一个单独的步调。简而言之,预处理器只不外是一个文本更换工具而已,它们会指示编译器在实际编译之前完成所需的预处理。我们把C预处理器(C Preprocessor)简写为CPP。

2.1.2 预处理的作用
全部的预处理器下令都是以井号(#)开头,其大致有以下种类:
1、#define,#undef
界说宏,进行单纯的字符串更换,以及取消已界说的宏
2、#include
包含一个源代码文件,将包含文件的内容插入源代码中
3、#ifdef,#ifndef,#if,#else等
条件编译,根据条件的真假,选择性地编译随后的代码
4、#error
      生成编译错误提示消息,并制止编译
5、#pragma      
设置编译器的状态,或者指示编译器完成特定动作
2.2在Ubuntu下预处理的下令

可用以下下令进行预处理:
gcc -m64 hello.c -E -o hello.i

预处理过程如图:
​​​​​图2.2-1 在Ubuntu下预处理的过程

2.3 Hello的预处理效果解析

使用文本编辑器打开生成的hello.i与源文件hello.c,对二者进行对比。容易发现,hello.c本来只有24行,但生成的hello.i文件有3092行,其内容被大大扩充了。
对比发现,原来hello.c中的main函数,在hello.i中位于文件末端的位置,且内容与hello.c中的千篇一律。

图2.3-1 hello.i (左) 与 hello.c (右) 中的main函数内容相同

      
对比发现,除了main函数,hello.c中的表明部门遭到了删除。


图2.3-2 hello.c中的表明在hello.i中不见踪影


对比发现,hello.c中的#include部门不见了,取而代之的是相应的头文件的内容,如stdio.h,unistd.h与stdlib.h。



组图2.3-3 hello.i中多出的相干头文件的内容


除此之外,hello.i中还多出了原先并没有被直接包含在hello.c中的头文件的内容,它们是由hello.c中引用的头文件stdio.h,unistd.h与stdlib.h所间接应用的,均被递归地插入到了hello.i之中。
​​​​​​​
图2.3-4 hello.i中被递归插入的部门头文件信息

2.4 本章小结

本章介绍了预处理的概念,以及预处理的几个主要的功能:宏界说及其取消,头文件的包含,条件编译,#error实现的编译期断言,#pragma调整编译器状态等。
之后,使用gcc -m64 hello.c -E -o hello.i下令,在Ubuntu下对hello.c进行了预处理,得到了hello.i文件。
末了,将hello.i与hello.c两者的内容进行了对比,发现了预处理对表明的丢弃以及对头文件的包含。

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念
在这里,编译是指编译器(cc1)将文本文件.i翻译成汇编语言程序文件.s的过程。
3.1.2 编译的作用
在编译的过程中,会对预处理之后的.i文件进行词法分析、语法分析、语义分析等一系列工作,还会根据编译选项对代码进行适当优化。
3.2 在Ubuntu下编译的下令

可用以下下令进行编译:
gcc -m64 hello.i -S -o hello.s

编译过程如图:

图3.2-1 在Ubuntu下编译的过程


       之后,本文使用gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.i -S -o hello.s生成的效果进行分析。
3.3 Hello的编译效果解析

3.3.1 数据
1、整形常量
在hello.c中出现的整形常量在hello.s中都有对应出现。编译器将整形常量编译为立刻数。

  • 整型变量argc与整型常量4的不等比力,其中的整型常量4以立刻数$4的情势出现

图3.3.1-1.1 比力中的整型常量翻译为立刻数



  • exit(1)的调用,其中的整型常量1以立刻数$1的情势出现

图3.3.1-1.2 传参中的整型常量翻译为立刻数



  • 对整型变量i的赋值i=0,其中的整型常量0以立刻数$0的情势出现

图3.3.1-1.3 赋值中的整型常量翻译为立刻数


2、字符串常量
在hello.c中出现的字符串常量在hello.s中都有对应出现。编译器将字符串常量存入了 .rodata节之中。

图3.3.1-2.1 存储在 .rodata节的字符串常量

       在使用字符串常量的时候,编译器将字符串常量的地址根据传参规则赋值至对应寄存器中,具体如图所示:

图3.3.1-2.2  puts传参中的字符串常量



图3.3.1-2.3  printf传参中的字符串常量


3、局部变量
hello.c中仅有一个局部变量,即循环变量int i。其被编译器翻译为了对寄存器 %ebp的相应操纵。

图3.3.1-3.1  %ebp与i的行为对照


3.3.2 赋值
hello.c中仅出现了一次对整型变量i的赋值,翻译为了mov语句

图3.3.2  对整型变量的赋值翻译为了mov语句


3.3.3 范例转换
hello.c中有语句:sleep(atoi(argv[3]));
atoi返回范例为int,sleep吸收范例为unsigned int的参数。发生了隐式范例转换。
但分析其对应汇编语句后发现,atoi将返回值生存在%eax中,随后直接将%eax作为参数赋值给%edi,便开始调用sleep,好像所谓的范例转换根本不存在一样。
​​​​​​​
图3.3.3  范例转换——什么都不做 (int转unsigned int)


3.3.4 算术运算
hello.c中仅出现了一次整型变量i自增的算术运算i++。其翻译为了对应寄存器 %ebp的加1操纵。

图3.3.4  被翻译为add操纵的自增运算


3.3.5 关系操纵
对于关系操纵,编译器一样平常会将其翻译为cmp操纵,随后通过je、jne、ja、jb、jg、jl等跳转下令实现分支结构。在hello.c中,出现了两处关系操纵:


  • 整型变量argc与整型常量4的不等比力,翻译为cmp + jne

图3.3.5-1 不等比力翻译为cmp + jne



  • 整型变量i与整型常量8的小于比力,翻译为cmp + jle
(对于整数,小于8等价于小于等于7)

图3.3.5-2 小于比力翻译为cmp + jle


3.3.6 数组操纵
       编译器对数组操纵每每翻译为对地址的加减操纵,取值时使用mov操纵,取地址时使用lea操纵。在hello.c中,出现了对数组argv的操纵,使用下标运算符 [] 对argv数组中的元素进行访问。
​​​​​​​
图3.3.6-1 hello.c中对数组argv的操纵


       分析编译效果,hello.c中对数组argv的下标访问,在hello.s中翻译为了在对数组argv的首地址(在这里是%rbx)的加法操纵后的mov操纵。
       printf原语句为printf("Hello %s %s\n",argv[1],argv[2]);
       argv[1]作为printf的第二个实参,对应操纵为movq 8(%rbx), %rsi
       argv[2]作为printf的第三个实参,对应操纵为movq 16(%rbx), %rdx
       argv数组中元素的范例为char*,在64位中占8字节,故上述对%rbx的操纵中的增量为8。
       对atoi的有关分析也是类似的。

图3.3.6-2 hello.s中对数组argv的操纵


3.3.7 控制转移
1、if
hello.c中通过argc!=4以判断输入参数的正确与否,并使用if进行分支处理。在hello.s中,这是通过cmp与jne的组合实现的(由于编译器的缘故原由,此处编译出了无条件跳转jmp)。


图3.3.7-1.1 hello.s中的分支控制



图3.3.7-1.2 hello.s中分支控制的表示图


2、for
hello.c中使用了for循环来进行对语句的重复执行。在hello.s中,这主要是通过往回跳转的跳转语句实现的。
​​​​​​​
​​​​​​​
图3.3.7-2.1 hello.s中的for循环


​​​​​​​
图3.3.7-2.2 hello.s中for循环的表示图


3.3.8 函数操纵
hello.c中一共出现了6次函数调用,具体见下:


  • 对printf的调用,仅进行字符串的简单输出,被优化为了对puts的调用

图3.3.8-1 第一次函数调用——puts



  • 对exit的调用
​​​​​​​
图3.3.8-2 第二次函数调用——exit



  • 对printf的调用,进行了字符串的格式化输出

图3.3.8-3 第三次函数调用——printf



  • 对atoi的调用,将字符串转换为整型

图3.3.8-4 第四次函数调用——atoi



  • 对sleep的调用,进行程序的阻塞。其将atoi的返回值作为了输入实参。
​​​​​​​
图3.3.8-5 第五次函数调用——sleep



  • 对getchar的调用,进行程序的阻塞。
​​​​​​​
图3.3.8-6 第六次函数调用——getchar

3.4 本章小结

本章主要介绍了编译的的概念以及作用,其作用包罗将高级程序语言翻译为汇编语言,并根据编译选项进行适当优化。
之后,使用gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.i -S -o hello.s下令,在Ubuntu下对hello.i进行编译,得到了hello.s文件。
末了,根据要求中列出的C语言的数据与操纵,从数据、赋值、范例转换、算术运算、关系操纵、数组操纵、控制转移、函数操纵这几个角度分析了hello.c源程序中的语句是怎样被编译器转化为hello.s中的汇编语言的。


第4章 汇编

4.1 汇编的概念与作用

4.1.1 汇编的概念
在这里,汇编是指汇编器(as)将.s文件翻译成二进制的呆板语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,将效果生存在目标文件.o中的过程。
4.1.2 汇编的作用
汇编将人尚能看懂,呆板无法辨认的汇编语言,翻译为了人看不懂,但呆板可以直接辨认的呆板语言。
4.2 在Ubuntu下汇编的下令

可用以下下令进行汇编:
as hello.s -o hello.o

汇编过程如图:

图4.2 在Ubuntu下汇编的过程

4.3 可重定位目标elf格式

使用readelf -a hello.o下令,可在终端查看hello.o的ELF格式。也可使用readelf -a hello.o > hello_elf.txt将效果重定向至文本文件hello_elf.txt中,便于查看。

4.3.1 ELF头
ELF 文件头位于目标文件最开始的位置,含有整个文件的一些基本信息。文件头中含有整个文件的结构信息,包罗一些控制单元的大小。
​​​​​​​
图4.3.1  hello.o的ELF头


可以在elf.h源码中找到64位 ELF的文件头的数据结构,其结构体界说如下:
   

  • typedef struct  
  • {  
  •   unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
  •   Elf64_Half    e_type;         /* Object file type */
  •   Elf64_Half    e_machine;      /* Architecture */  
  •   Elf64_Word    e_version;      /* Object file version */
  •   Elf64_Addr    e_entry;        /* Entry point virtual address */
  •   Elf64_Off e_phoff;        /* Program header table file offset */
  •   Elf64_Off e_shoff;        /* Section header table file offset */        
  •   Elf64_Word    e_flags;        /* Processor-specific flags */               
  •   Elf64_Half    e_ehsize;       /* ELF header size in bytes */  
  •   Elf64_Half    e_phentsize;        /* Program header table entry size */  
  •   Elf64_Half    e_phnum;        /* Program header table entry count */  
  •   Elf64_Half    e_shentsize;        /* Section header table entry size */  
  •   Elf64_Half    e_shnum;        /* Section header table entry count */  
  •   Elf64_Half    e_shstrndx;     /* Section header string table index */  
  • } Elf64_Ehdr;  
  
以下将按照顺序介绍Elf64_Ehdr结构体中的成员。

  • e_ident (ELF Header-Identification)
这16个字节含有 ELF 文件的辨认标记。作为一个数组,它的各个索引位置的字节数据有固定的含义,提供一些用于解码和解析文件内容的数据,是不依赖于具体操纵体系的。
ELF 格式提供的目标文件框架可以支持多种处理器,以及多种编码方式。针对差别的体系结构和编码格式,ELF 文件的内容是会大相径庭的。如果不知道编码格式,体系将无法知道怎么去读取目标文件;如果体系结构与本机差别,也将无法解析和运行。这些信息必要以独立的格式存放在一个默认的地方,全部体系都约定好从文件的同一个地方来读取这些信息,这就是 ELF 标识的作用。
e_ident中每个元素对应的内容见下表。

名称

位置

说明

EI_MAG0

0

文件标识(0x7f)

EI_MAG1

1

文件标识(E, 0x45)

EI_MAG2

2

文件标识(L, 0x4C)

EI_MAG3

3

文件标识(F, 0x46)

EI_CLASS

4

指明文件位数,取值:0-非法,1-32位,2-64位

EI_DATA

5

数据编码格式,取值:0-非法,1-小端,2-大端

EI_VERSION

6

ELF 文件头的版本

EI_OSABI

7

指明ELF文件操纵体系的二进制接口的版本标识符。

值为0:指明为UNIX System V ABI

EI_ABIVERSION

8

ABI 版本

EI_PAD

9~15

补齐字节,一样平常为0

表4.3.1-1  e_ident中元素对应内容的说明



  • 剩余成员
别的成员的对应含义可见下表:
成员
作用
e_type
表明本目标文件属于哪种范例
1:重定位文件;值为2:可执行文件;值为3:动态链接库文件
e_machine
指定该文件实用的处理器体系结构
e_version
指明目标文件的版本
e_entry
指明程序入口的虚拟地址
e_phoff
指明程序头表开始处在文件中相对于 ELF 文件初始位置的偏移量
e_shoff
指明节头表开始处在文件中的偏移量
e_flags
处理器特定的标记位
e_ehsize
表明ELF文件头的大小,以字节为单位
e_phentsize
表明在程序头表中表项的大小,以字节为单位
e_phnum
表明程序头表中的表项数
e_shentsize
表明在节头表中表项的大小,以字节为单位
e_shnum
表明节头表中的表项数
e_shstrndx
表明节头表中与节名字表相对应的表项的索引,存放着节的名字
表4.3.1-2  ELF头中剩余成员对应内容的说明


4.3.2 节头表
       ELF文件中含有浩繁的节,这些节携带了ELF文件的全部信息。每一个节又对应有一个节头,节头中有着对节的形貌信息,将这些节头构造在一起的数据结构就是节头表(section header table)。

​​​​​​​
图4.3.2  hello.o的节头表


       节头表中的每一项都对应了一个结构体,其界说如下:
   

  • typedef struct  
  • {  
  •   Elf64_Word    sh_name;     /* Section name (string tbl index) */    // 4 bytes
  •   Elf64_Word    sh_type;     /* Section type */                         
  •   Elf64_Xword   sh_flags;    /* Section flags */                      // 8 bytes
  •   Elf64_Addr    sh_addr;     /* Section virtual addr at execution */  // 8 bytes
  •   Elf64_Off sh_offset;   /* Section file offset */                // 8 bytes  
  •   Elf64_Xword   sh_size;     /* Section size in bytes */  
  •   Elf64_Word    sh_link;     /* Link to another section */  
  •   Elf64_Word    sh_info;     /* Additional section information */  
  •   Elf64_Xword   sh_addralign;    /* Section alignment */  
  •   Elf64_Xword   sh_entsize;  /* Entry size if section holds table */  
  • } Elf64_Shdr;  
  


各个成员的含义见下表:
成员
含义
sh_name
一个偏移量,指向本节的名字
sh_type
指明本节的范例
sh_flags
指明本节的属性
sh_offset
指明本节的位置
sh_size
指明该节的大小,以字节为单位
sh_link
指向节头表中本节所对应的位置
sh_info
指明该节的附加信息
sh_addralign
指明该节内容对齐字节的数量
sh_entsize
指明该节对应的每一个表项的大小
图4.3.2-1  节头表项各个数据成员的含义


4.3.3 重定位节
重定位是连接符号引用与符号界说的过程。比方,程序调用函数时,关联的调用指令必须在执行时将控制权转移到正确的目标地址。可重定位文件必须包含说明如何修改其节内容的信息。重定位节即包含了这些用于重定位的数据信息。

       在hello.o中,重定位节有两个,即 .rela.text节与 .rela.eh_frame节。

图4.3.3  hello.o的重定位节


       上述重定位节中的每一个表项都对应了一个结构体,其界说如下:
   

  • typedef struct {  
  •         Elf64_Addr      r_offset;  
  •         Elf64_Xword     r_info;  
  •         Elf64_Sxword    r_addend;  
  • } Elf64_Rela;  
  
r_offset成员指定了应用可重定位操纵的位置。对于可重定位文件,该值表示节偏移值。
r_info成员指定了必须对其进行重定位的符号表索引以及要应用的重定位范例。
r_addend成员指定了一个常量加数,用于计算将存储在可重定位字段中的值。

通过对r_info成员施用宏
#define ELF64_R_TYPE_ID(info)   (((Elf64_Xword)(info)<<56)>>56)
可以得到其对应的重定位范例,即图4.3.3中的“范例”一项。在图4.3.3中,我们见到了三种ELF中最为基本的重定位范例:

  • R_X86_64_32 重定位一个使用32位绝对地址的引用。
  • R_X86_64_PC32 重定位一个使用32位PC相对地址的引用。
  • R_X86_64_PLT32 过程链接表耽误绑定。
它们的具体含义在链接一章再展开说明。

       4.3.4 符号表
符号表生存了程序实现或使用的全部全局变量和函数,如果程序引用一个自身代码未界说的符号,则称之为未界说符号。这类引用必须在静态链接期间用其他目标模块或库解决,或在加载时通过动态链接解决。

图4.3.4  hello.o的符号表


符号表的每一个表项都对应了一个结构体,其界说如下:
   

  • typedef struct {  
  •     Elf64_Word  st_name;  
  •     unsigned char   st_info;  
  •     unsigned char   st_other;  
  •     Elf64_Half  st_shndx;   
  •     Elf64_Addr  st_value;  
  •     Elf64_Xword st_size;  
  • } Elf64_Sym;  
  

各个成员的含义见下表:
成员
含义
st_name
目标文件的符号字符串表的索引
st_info
指明白符号范例与绑定属性
st_others
符号的可见性
st_shndx
表明该符号对应的节头在节头表中的偏移
st_value
相干符号的值。根据上下文,这可以是绝对值,地址等。
st_size
符号的大小
图4.3.4-1  符号表项中各个数据成员的含义


       我们特别关注一下图4.3.4中的Ndx这一列(即结构体中的st_shndx成员)。符号main,.text,.rodata.str1.1与 .rodata.str1.8这些符号的对应项是有值的。如main对应值为1,对应了节头表中的 .text节,.rodata.str1.8对应值为5,对应了节头表中的 .rodata.str1.8节,等等。而我们先前讨论过的6个调用的函数(puts, exit, printf, atoi, sleep, getchar)的对应值都是UND,即该符号未界说。将这些符号的界说找到即为下一章“链接”时的工作。
4.4 Hello.o的效果解析

使用objdump -d -r hello.o下令可以在终端查看hello.o对应的反汇编代码。其中 -d指disassemble,反汇编,-r 指reloc,显示文件的重定位入口。
也可使用objdump -d -r hello.o > hello_asm.txt将效果重定向至文本文件hello_asm.txt中,便于查看。

颠末hello_asm.txt与hello.s二者之间的对比,发现了以下的差别:
1、没有了汇编指示符
在hello.s中时常出现的汇编指示符.cfi_***没有在hello_asm.txt中出现。

图4.4.1  汇编指示符的消失


       2、操纵数的进制差别
       hello.s中操纵数是十进制的,而在hello_asm.txt中,操纵数以十六进制表示。

图4.4.2  操纵数进制的差别


       3、分支转移的差别
hello.s中为汇编语言代码的一些行增长了标签(如 .L3,.L6),分支转移时,在跳转指令后用对应标签名称表示跳转位置。
而在hello_asm.txt中,每一行反汇编都有着明白的地址,跳转指令后用相应的地址表示跳转位置。

图4.4.3-1  分支转移的差别


       有须要指出,hello_asm.txt中出现的寻址是相对寻址。
       如 [d:   75 0a    jne 19 <main+0x19>] 这行。当程序运行至这行时,其 %rip指向下一行指令的地址0xf,要从0xf处跳转至0x19处,有增量0x19 - 0xf = 0xa,对应了呆板码75 0a中的0a。

图4.4.3-2  分支转移中的相对寻址


       4、字符串常量的引用情势差别
hello.s中用标签对字符串常量进行引用,而在hello_asm.txt中,使用字符串常量的虚拟地址进行引用。不外这里尚未进行重定向的工作,故地址值都先用0进行占位。

图4.4.4  字符串常量引用情势的差别


       5、函数调用的差别
hello.s中直接使用函数名对函数进行引用,而在hello_asm.txt中,应使用相对下一行指令的地址的偏移值对函数进行引用(与先前第3点“分支转移的差别”中所述内容同等)。不外这里尚未进行重定向的工作,故地址偏移值都先用0进行占位。

图4.4.5  函数调用的差别

4.5 本章小结

本章主要介绍了汇编的概念与作用。汇编语言程序颠末编译器(as)转化为呆板语言,并把这些指令打包成可重定位目标程序的格式,并生存在目标文件.o中,成为呆板可以直接辨认的程序。
然后,使用as hello.s -o hello.o下令,在Ubuntu下对hello.s进行汇编,得到了hello.o文件。
之后,使用readelf -a hello.o > hello_elf.txt下令,对hello.o文件的可重定位目标ELF格式从ELF头、节头表、重定位头、符号表四个内容进行了简单的分析。
末了,使用objdump -d -r hello.o > hello_asm.txt下令,得到了hello.o的反汇编文件,并将其与hello.s进行对比,从汇编指示符、操纵数进制、分支转移、字符串常量、函数调用五个角度进行了差别之处的分析。

第5章 链接

5.1 链接的概念与作用

5.1.1 链接的概念
链接是将各种代码和数据片断网络并组合为一个单一文件的过程,所得到的文件可以被加载到内存之中并执行。链接由链接器(ld)程序执行,链接执行的机遇可以是编译时,即源代码被翻译成呆板码的时候;以及加载时,即程序被加载器加载到内存并执行的时候;甚至是运行时,即在应用程序执行链接下令的时候。
5.1.2 链接的作用
链接的两个主要任务是符号解析和重定位。符号解析将目标文件中的每个全局符号都绑定到一个唯一的界说,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。链接的存在也使得分离式编译成为了可能。
5.2 在Ubuntu下链接的下令

在Ubuntu下,使用ld进行链接的下令如下:
ld -plugin /usr/lib/gcc/x86_64-linux-gnu/11/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/11/lto-wrapper -plugin-opt=-fresolution=/tmp/ccbaDZBp.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -z relro /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/crt1.o /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/11/crtbegin.o -L/usr/lib/gcc/x86_64-linux-gnu/11 -L/usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/11/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/11/../../.. hello.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/11/crtend.o /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/crtn.o -o hello

链接过程如图:

图5.2 在Ubuntu下链接的过程

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

使用readelf -a hello.o下令,可在终端查看hello的ELF格式。也可使用readelf -a hello > hello_exe_elf.txt将效果重定向至文本文件hello_exe_elf.txt中,便于查看。

       下图为hello的ELF头(左)与hello.o的ELF头(右):

图5.3-1  hello与hello.o的ELF头对比


       通过查看hello_exe_elf.txt中的节头表,可以看出各段的基本信息。第一列按地址顺序列出了各段的名称及大小,第三列有着各段的起始地址。

​​​​​​​
​​​​​​​
图5.3-2  hello的节头表

5.4 hello的虚拟地址空间

       使用edb --run hello下令在edb中加载hello。

图5.4-1  在edb中加载hello


通过edb的Memory Regions可以查看每块内存地域的读写权限:

图5.4-2  edb中的Memory Regions

       根据5.3中的节头表的内容,我们可以在edb的Data Dump中跳转至对应地址,从而查看某段的原始数据。比方,下图在edb中对 .dynstr段进行了查看:

图5.4-3  在edb中查看.dynstr段


再如,下图在edb中对 .rodata段进行了查看:

图5.4-4  在edb中查看.rodata段

5.5 链接的重定位过程分析

使用objdump -d -r hello下令可以在终端查看hello对应的反汇编代码。也可使用objdump -d -r hello > hello_exe_asm.txt将效果重定向至文本文件hello_exe_asm.txt中,便于查看。

5.5.1 差别之处
颠末hello_exe_asm.txt与hello_asm.txt二者之间的对比,发现了以下的差别:

1、文件行数差别
       hello_asm.txt中仅有main函数的相干内容,为46行。hello_exe_asm.txt还多出了许多差别,总共有199行。

图5.5.1-1  文件行数的对比


       2、指令地址差别
       hello_asm.txt中的指令地址是从0开始的,而hello_exe_asm.txt中,每行指令都被分配了相应的虚拟地址。

图5.5.1-2  指令地址的对比


       3、插入了用于调用共享库中的函数的指令

图5.5.1-3  插入了用于调用共享库中的函数的指令


       4、字符串常量的引用差别
       在hello_asm.txt中,尚未分配虚拟地址,对字符串常量的引用都用0进行代替。而在hello_exe_asm.txt中,由于虚拟地址已经分配,对字符串常量的引用直接使用字符串常量虚拟地址。

图5.5.1-4  字符串常量的引用差别

5、控制转移的差别
       在hello_asm.txt中,尚未分配虚拟地址,对指令地址的引用都使用相对偏移进行代替。而在hello_exe_asm.txt中,由于虚拟地址已经分配,对指令地址的引用直接使用字符串常量虚拟地址。表现在控制转移中就是跳转指令之后的参数变为了虚拟地址。(呆板码中为相对寻址)

图5.5.1-5  控制转移的差别


6、函数调用的差别
       在hello_asm.txt中,尚未分配虚拟地址,对函数的引用都使用0进行代替。而在hello_exe_asm.txt中,由于虚拟地址已经分配,使用函数对应的虚拟地址对函数进行引用。(呆板码中为相对寻址)

图5.5.1-6  函数调用的差别


       5.5.2 链接过程
       链接的过程主要分为符号解析和重定位这两步:
      
1、符号解析。链接器解析符号引用的方法就是将每个引用与它对应的可重定位目标文件的符号表中的一个确定的符号界说关联起来。
对于局部符号及静态局部变量解析比力简单:只必要保证每个模块中的每个局部符号只有一个界说。对于全局符号的解析:当编译器碰到一个不是在当前模块界说的符号时,会假设该符号时在其他某个模块中界说的,生成一个链接器符号表条目,并交给链接器处理;如果链接器在任何输入的模块中都找不到该界说就报错并且制止。

2、重定位。重定位将每个符号引用和符号界说关联起来,并且为每个符号分配运行时地址。
重定位包罗:重定位节和符号界说:链接器将全部相同范例的数据节归并为同一范例的聚合节,并且将运行时的内存地址赋值给新的聚合节及每个模块界说的符号;重定位节中的符号引用:链接器修改代码节和数据节中对每个符号的引用,使得其指向正确的运行地址。

       5.5.3 重定位过程分析
由于在hello.s中仅出现了R_X86_64_32与R_X86_64_PLT32两种重定位范例,故接下来,将借由具体例子,分析这两种重定位范例的重定位过程。
1、重定位R_X86_64_32
重定位绝对引用是相称简单的,只要确定了该符号的虚拟地址,那么,对该符号的引用就是其虚拟地址。如图,该字符串的虚拟地址为0x402008,那么直接将先前填充的0改为0x402008即可,以小端序填充。

图5.5.3-1  R_X86_64_32的重定位


2、重定位R_X86_64_PLT32
       固然R_X86_64_PLT32与R_X86_64_PC32有很多差别之处,但在call的相对偏移计算上二者是同等的。
       计算式为 (unsigned) (ADDR(r.symbol) + r.addend - refaddr)
   其中r.symbol = 0x401090, r.addend = -4, refaddr = 0x4011f5

图5.5.3-1  R_X86_64_PLT32的重定位

5.6 hello的执行流程

hello!_start:0x4010f0
libc.so.6!__libc_start_main:0x7f649f829dc0
libc.so.6!__cxa_atexit:0x7f649f8458c0
hello!_init:0x401000
hello!frame_dummy:0x4011d0
hello!register_tm_clones:0x401160
hello!main:0x4011d6
未正确输入下令行参数:
hello!puts@plt:0x401030
hello!exit@plt:0x401060
libc.so.6!exit:0x7f46864455f0
正确输入下令行参数:
hello!__printf_chk@plt:0x401050
hello!strtol@plt:0x401040
hello!sleep@plt:0x401070
(重复多少次)
hello!getc@plt:0x401080
libc.so.6!exit:0x7f46864455f0
5.7 Hello的动态链接分析

我们知道,动态库是在进程启动的时候加载进来的。加载后,动态链接器必要对其作一系列的初始化,如重定位,这些工作是比力费时的,特别是对函数的重定位。那么我们能不能把对函数的重定位耽误进行呢?这个改进是很有意义的,毕竟很多时候,一个动态库里可能包含很多的全局函数,但是我们每每可能只用到了其中一小部门而已,而且在这用到的一小部门里,很可能其中有些还压根不会执行到,因此完全没须要把那些没用到的函数也过早进行重定位。具体来说,就是应该比及第一次发生对该函数的调用时才进行符号绑定。而这就是所谓的耽误绑定。
通过观察.got.plt节的变化,就能观察到动态链接的过程。
通过readelf找到.got.plt节在地址为0x404000的地方开始,大小为0x48。因此,结束地址为0x40400047,这两个地址之间部门便是.got.plt的内容。
​​​​​​​
图5.7-1  .got.plt节信息


       在edb的Data Dump中找到该地址的内容,观察发现,在dl_init前后.got.plt节发生了变化。这些变化的内容分别对应.got[1]和.got[2]的位置。其中,.got[1]包罗动态链接器在解析函数地址时使用的信息,而.got[2]则是动态链接器ld-linux.so模块中的入口点。

图5.7-2  变化的.got.plt节内容


当程序必要调用一个动态链接库内界说的函数时(比方printf) ,call指令并没有让控制流直接跳转到对应的函数中去,由于耽误绑定的机制,还不知道printf的确切位置。取而代之的是,控制流会跳转到该函数对应的plt表中,然后通过plt表将当前将要调用的函数的序号压入栈中。接下来,调用动态链接器。动态链接器会根据栈中的信息忠实的执行重定位,将真实的printf的运行时地址写入got表,取代了got原先用来跳转到plt的地址,变为了真正的函数地址。
5.8 本章小结

       本章主要介绍了链接的概念及作用,其主要指是将各种代码和数据片断网络并组合为一个单一文件的过程。
之后,在Ubuntu下将hello.o文件经由链接器生成了可执行目标文件hello。
随后,通过readelf列出了其各节的基本信息,包罗起始位置、大小等信息。
然后,用edb查看了hello的虚拟地址空间,同时查看了各节的起始位置与大小。
尔后,通过objdump对hello进行反汇编,得到了其反汇编程序hello_exe_asm.txt,并与hello.o的反汇编程序hello _asm.txt进行了多方面的比力。
之后,分析了hello的重定位过程与执行过程。
末了,通过edb,分析了hello程序的动态链接项目在dl_init前后的内容变化。

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1 进程的概念
进程是一个执行中程序的实例,是一段程序的执行过程。

6.1.2 进程的作用
进程提供了独立的逻辑控制流,好像我们的程序是独占地使用着处理器;也提供一个私有的地址空间,好像我们的程序独占地使用着内存体系。通过进程逻辑控制流,可以使CPU被科学有用地划分成多个部门以并行地运行多个进程。
6.2 简述壳Shell-bash的作用与处理流程

6.1.1 Shell的作用
Shell是一个交互型应用级程序,可代表用户运行其他程序。
Shell最告急的功能是下令表明,从这种意义上说,Shell是一个下令表明器。Linux体系上的全部可执行文件都可以作为Shell下令来执行。

6.1.2 Shell的处理流程
当用户提交了一个下令后,Shell首先判断它是否为内置下令,如果是就通过Shell内部的表明器将其表明为体系功能调用并转交给内核执行;如果外部下令或应用程序就试图在硬盘中查找该下令并将其调入内存,再将其表明为体系功能调用并转交给内核执行。
执行外部下令时,Shell创建会通过fork创建一个子进程,并通过execve加载并运行该外部下令(可执行目标文件),当该进程执行结束时在信号处理子程序中用waitpid下令对其进行回收,从内核中将其删除。
当执行的进程为前台进程时,Shell会阻塞下令的输入直到前台进程制止运行。
6.3 Hello的fork进程创建过程

hello的执行是通过在终端中输入下令 ./hello来完成的。
当我们在终端中输入下令 ./hello时,Shell会先判断发现这个参数并不是内置的下令,从而把这条下令当作一个可执行程序的名字实验执行。
接下来,Shell会执行fork函数,创建一个子进程。我们的hello将会在这个进程中执行。
fork函数的作用是创建一个与当进步程平行运行的子进程。内核会将父进程的上下文,包罗代码,数据段,堆,共享库以及用户栈,甚至于父进程打开的文件的形貌符,都创建一份副本。然后利用这个副本执行子进程。从这个角度上来说,子进程与父进程直到执行完fork的刹时都是完全相同的。
6.4 Hello的execve过程

在父进程执行fork函数后,父进程将继续运行Shell的程序,而子进程将通过execve加载用户输入的程序,即我们的hello。由于hello是前台运行的,所以Shell会阻塞下令输入,等待hello运行结束。
execve函数加载并运行可执行目标文件。只有当出现错误时,execve才会返回到调用程序,否则execve调用一次而从不返回。
在execve加载了hello之后,它会调用内核提供的启动代码。内核会将原上下文更换为hello的上下文,然后将控制传递给新程序的程序入口。
值得一提的是,execve只是简单的更换了本身所处进程的上下文,并没有改变进程的pid,也没有改变进程的父子归属关系。
6.5 Hello的进程执行

当hello进程创建之时,操纵体系会为hello进程分配时间片,让hello进程得以运行。若一个操纵体系中运行着多个进程,处理器的一个物理控制流就被分成了多个逻辑控制流,分别交替执行这几个进程。逻辑流的执行是交织的,它们轮流使用处理器,会存在并发执行的现象。其中,一个进程执行它的控制流的一部门的每一时间段叫做时间片。
hello进程在内存中执行的过程中,并不是一直占用着CPU的资源。因为当内核代表用户执行体系调用时,可能会发生上下文切换,如执行hello中的sleep函数时,或者当操纵体系以为hello进程了运行足够久的时候。在这时候,程序将由用户态转换至核心态,内核中的调度器执行上下文切换,将当前的上下文信息生存到内核中,恢复某个先前被抢占的进程的上下文,然后再由核心态转换至用户态,将控制传递给这个先前被抢占的进程。
这样的控制转移将一直存在,直到hello进程运行结束。
​​​​​​​
图6.5  进程上下文切换的图解(来自CSAPP原书)

6.6 hello的异常与信号处理

6.6.1正常运行
在终端执行./hello 2022113573 张宇杰 1下令,不干扰程序执行,即可完成一次hello的正常运行。
根据上述输入的下令行参数,hello正常运行时,每隔1秒将在屏幕上打印“Hello 2022113573 张宇杰”字样,一共会打印8次。打印结束后,调用getchar()函数阻塞程序执行,等待用户输入。在用户输入回车之后,hello程序制止,Shell回收hello进程,由于不在有前台作业,Shell将等待用户输入下一条下令。

图6.6.1  正常运行的hello


6.6.2随意输入(不包罗Ctrl-Z,Ctrl-C)
       在hello程序执行时,在键盘进行随意的输入(不包罗Ctrl-Z,Ctrl-C),按下的字符串会直接显示,但不会干扰程序的运行。

图6.6.2  随意乱按时的hello


6.6.3 Ctrl-C
在hello程序执行时,输入Ctrl-C,会中断hello的执行。输入Ctrl-C会发送 SIGINT 信号给Shell,再由Shell将信号转发给前台进程组中的全部进程,制止前台进程组。用ps下令进行查看,找不到hello进程。

图6.6.3  输入Ctrl+C时的hello


6.6.4 Ctrl-Z
在hello程序执行时,输入Ctrl-Z,会将hello进程挂起。输入Ctrl-Z会发送 SIGTSTP 信号给Shell,再由Shell将信号转发给前台进程组中的全部进程,挂起前台进程组。用ps下令进行查看,会找到被挂起的hello进程,其状态显示为S(休眠)。

图6.6.4-1  输入Ctrl+Z时的hello


       在将hello进程挂起后,使用jobs下令可以看到被挂起的hello进程的jid及状态标识。

图6.6.4-2  jobs查看hello进程


       在将hello进程挂起后,使用pstree下令可以查看hello进程的继承关系。在这里,hello进程的继承路径为systemd→systemd→gnome-terminal-→bash→hello

图6.6.4-2  pstree查看进程树


在将hello进程挂起后,使用fg + hello进程对应的jid即可将挂起的hello进程重新回到前台执行,打印剩余内容,并进行正常的程序退出。
​​​​​​​
图6.6.4-3  fg下令恢复hello进程的执行


       在将hello进程挂起后,使用kill下令可对hello进程发送信号。通过ps查看hello的PID为10683,使用kill -9 10683向hello进程发送SIGKILL信号,将其杀死。之后,再使用ps下令就看不到hello进程了。

图6.6.4-4  kill下令向hello进程发送SIGKILL信号

6.7本章小结

本章介绍了进程的概念和作用,同时简述了Shell的作用及执行流程。
之后,解析了hello的fork过程与execve过程,通过调用fork()函数与execve()来实现。同时,联合了进程上下文信息、进程时间片、用户态与核心态转换等内容,介绍了hello的进程执行流程。
末了,通太过析了在hello执行过程中不绝乱按,Ctrl-Z,Ctrl-C,在Ctrl-Z后运行ps、jobs、pstree、fg、kill等下令所造成的现象,说明白hello中异常与信号的处理。

第7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1 逻辑地址
逻辑地址是用户编程时使用的与段有关的偏移地址,分为段基址和段偏移量两部门,这是程序员可以见到的地址。
比方,在hello_exe_asm.txt中出现的mov $0x402008, %edi中的地址$0x402008即为逻辑地址,必要加上相应的DS数据段基址才气得到对应的线性地址。

7.1.2 线性地址
指虚拟地址到物理地址变换的中心层,是处理器可寻址的内存空间(称为线性地址空间)中的地址。hello_exe_asm.txt中出现的地址是逻辑地址,加上相应段基址就成了一个线性地址。
在Linux中,全部的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从0开始,长度为4G,这样 线性地址 = 逻辑地址 + 0,也就是说,逻辑地址在数值上等同于线性地址了。

7.1.3 虚拟地址
虚拟地址是指由程序产生的由段选择符和段内偏移地址构成的地址。颠末CPU页部件转换成具体的物理地址,进而通过地址总线访问内存。在Linux中,虚拟地址在数值上等同于线性地址。

7.1.4 物理地址
主存被构造成一个由M个连续的字节大小的单元构成的数组,其中每个字节都被赋予了一个唯一的物理地址。进程在运行时指令的执行和数据的访问末了都要通过将虚拟地址转换为物理地址来对主存进行存取。
7.2 Intel逻辑地址到线性地址的变换-段式管理

逻辑地址分为段选择符 / 段基址和段偏移量两部门
在保护模式下,段选择符并不直接指向段基址段选择符,而是指向段形貌符表中界说段的段形貌符。
段选择符的3个字段分别是:请求特权级RPL(Requested Privilege Level),表指示标记TI(Table Index),与索引值(Index)。

图7.2-1  段选择符的结构


根据段选择符,首先根据TI判断应该选择全局形貌符表还是局部形貌符表,从GDT与LDT所对应的寄存器GTDR和LDTR获取GDT与LDT的首地址,将段选择符的索引字段的值乘8,加上GDT或LDT的首地址,就能得到当前段形貌符的地址。

得到段形貌符的地址后,可以通过段形貌符中BASE字段得到段的基地址。将其与段偏移量相加,即可得到线性地址。

​​​​​​​
图7.2-2  线性地址的求解流程

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

线性地址(虚拟地址)由虚拟页号VPN和虚拟页偏移VPO构成。

由线性地址到物理地址的变换通过以下步调进行:首先从页表基址寄存器PTBR中,得到hello进程的页表地址。同时根据线性地址前n-p位,即虚拟页号,在页表中找到与之对应的索引项,得到物理页号PPN。末了将物理页号与线性地址中末了p位,即偏移量,将它们相加,就可以得到物理地址。

图7.3-1  虚拟地址到物理地址的转换(图来自ppt)


当引用内容时,首先内存管理单元从线性地址中抽取出虚拟页号,检查高速缓存/主存,看它是否缓存于高速缓存/主存中。若命中,将缓存的内容返回给处理器。若不命中,即必要的内容不在物理内存中,则产生缺页中断,必要从虚拟内存所给出对应的磁盘的内容重新加载到物理内存中。

图7.3-2  页面命中的地址翻译流程(图来自ppt)



图7.3-3  页面不命中的地址翻译流程(图来自ppt)

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

为了节约页表的内存存储空间,我们会使用多级页表。固然多级页表节约了我们的存储空间,但是却存在题目:
原本我们对于只必要进行一次地址转换,只必要访问一次内存就能找到对应的物理页号,算出物理地址。现在我们必要多次访问内存,才气找到对应的物理页号。最终,固然节约了空间,却带来了时间上的额外开销,变成了一个“以时间换空间”的策略,极大地限定了内存访问性能。
为相识决这种题目导致处理器性能下降的题目,当代 CPU 中都包含了一块缓存芯片TLB,全称为地址变换高速缓冲(Translation Lookaside Buffer),简称为“快表”,用于加速对于页表的访问。简单来说,TLB就是页表的Cache,属于MMU的一部门,其中存储了当前最可能被访问到的页表项。

图7.4-1  MMU中访问TLB(图来自ppt)


当CPU处理虚拟地址时,首先去TLB中根据标记Tag寻找页表数据,假如TLB中正好存放所需的页表,说明TLB命中,直接从TLB中获取该虚拟页号对应的物理页号。如果TLB不命中,必要从L1缓存中根据VA取出相应的PTE,计算出PA,并将该PTE存放在TLB中,可能会覆盖原先的条目。


图7.4-2  参加TLB后,通过虚拟内存访问数据的流程(图来自csapp原书)


在四级页表的到场之下,当TLB不命中时,将根据VPN1、VPN2…一层层的计算出下一级页表的索引,末了在L4页表中找到相应的PTE,计算出对应的PA,并将其添加至TLB之中。

图7.4-2  Core i7的四级页表表示图(图来自ppt)

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

在MMU计算出物理地址PA之后,将其发送至L1缓存,缓存从PA中取出标记、组索引信息进行匹配。如果匹配成功,且有用位为1,则Cache命中,根据块偏移取出数据返回给CPU。如果Cache不命中,继续向下一级缓存或主存查询,按照L1-L2-L3-主存的顺序。查找成功后,将数据返回CPU,并将相应的块根据更换策略缓存在当前的Cache中。

图7.5  读Cache表示图(图来自ppt)

7.6 hello进程fork时的内存映射

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

图7.6  写时复制表示图(图来自ppt)

7.7 hello进程execve时的内存映射

       当Shell在fork出的hello进程中使用execve,在hello进程中加载并运行包含在可执行目标文件hello中的程序时,必要执行以下步调:

1、删除已存在的用户地域

删除当进步程虚拟地址的用户部门中的已存在的地域结构。


2、映射私有地域

为hello程序的代码、数据、bss和栈区创建新的地域结构。全部这些新的地域都是私有的、写时复制的。

代码和数据地域被映射为 hello文件中的.text和.data区。

bss地域是请求二进制零的,映射到匿名文件,其大小包含在hello中。

栈和堆地域也是请求二进制零的,初始长度为零。


3、映射共享地域

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


4、设置程序计数器

execve做的末了一件事情就是设置当进步程上下文中的程序计数器,使之指向代码地域的入口点。


图7.7  execve执行后的内存映射(图来自ppt)

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

当DRAM 缓存不命中时,就发生了缺页。
以下是一个缺页处理的例子。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有用位推断出VP3未被缓存,并且触发一个缺页异常。
缺页异常调用内核中的缺页异常处理程序,该程序会选择一个捐躯页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种环境,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一究竟。

图7.8-1  缺页处理之前(图来自ppt)


接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。

图7.8-2  缺页处理之后(图来自ppt)

7.9动态存储分配管理

7.9.1 动态内存分配器的基本原理
动态内存分配器维护着一个进程中称为堆(heap)的虚拟内存地域。堆是一个请求二进制零的地域,它紧接在未初始化的数据地域(.bss)后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组差别大小的块的聚集来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲块则可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被开释,这种开释要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,显式分配器与隐式分配器。两种风格都要求应用显式地分配块。它们的差别之处在于由哪个实体来负责开释已分配的块。
显式分配器要求应用显式地开释任何已分配的块。比方,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来,分配一个块,并通过调用free函数来开释一个块。C++中的new和delete运算符与C中的malloc和free相称。
与之相反,隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就开释这个块。隐式分配器也叫做垃圾网络器,而主动开释未使用的已分配的块的过程叫做垃圾网络。比方,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾网络来开释已分配的块。

7.9.2隐式空闲链表分配器原理
在隐式空闲链表的环境中,一个块是由一个字的头部、有用载荷,以及可能的一些额外的填充构成的。头部编码了这个块的大小(包罗头部和全部的填充),以及这个块是已分配的还是空闲的。
有用载荷背面是一片不使用的填充块,其大小可以是恣意的。必要填充有很多缘故原由。好比,填充可能是分配器策略的一部门,用来对付外部碎片。或者也必要用它来满足对齐要求。
​​​​​​​
图7.9-1  简单的堆块格式(图来自ppt)


堆被构造为一个连续的已分配块和空闲块的序列。

图7.9-2  用隐式空闲链表来构造堆(图来自ppt)


其中,阴影部门是已分配块。没有阴影的部门是空闲块。头部标记为(大小(字节)/ 已分配位)。
称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中全部的块,从而间接地遍历整个空闲块的聚集。留意,我们必要某种特殊标记的结束块,在这个示例中,就是一个设置了已分配位而大小为零的制止头部。
隐式空闲链表的优点是简单。明显的缺点是任何操纵的开销,比方放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。

7.9.3显式空闲链表分配器原理
相比于隐式空闲链表,一种更好的方法是将空闲块构造为某种情势的显式数据结构。比方,堆可以构造成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针。在使用双向空闲链表的环境下,有两种维护链表的方式:
​​​​​​​
图7.9-3  显式空闲链表中的堆块格式(图来自ppt)


一种方法是用后进先出(LIFO)的顺序维护链表,将新开释的块放置在链表的开始处。使用LIFO的顺序和初次适配的放置策略,分配器会开始检查最近使用过的块。在这种环境下,开释一个块可以在常数时间内完成。如果使用了边界标记,那么归并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种环境下,开释一个块必要线性时间的搜索来定位符合的前驱。平衡点在于,按照地址排序的初次适配比LIFO排序的初次适配有更高的内存利用率,靠近最佳适配的利用率。
一样平常而言,显式链表的缺点是空闲块必须足够大,以包含全部必要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地进步了内部碎片的水平。
7.10本章小结

本章介绍了hello的存储器地址空间。联合了hello,说明白逻辑地址、线性地址、虚拟地址、物理地址的概念,以及它们的区别与联系,互相转化的方法。
叙述了在段式管理之下,逻辑地址到线性地址(虚拟地址)的变换是如何完成的。
叙述了在页式管理之下,线性地址到物理地址的变换是如何完成的。
分析了TLB与四级页表支持下的VA到PA的变换。以四级页表为例,介绍了多级页表的层次、工作流程以及节流空间的优点。而为了增补页表速率上的缺点,引入了高速地址变址缓存TLB。
介绍了三级Cache支持下的物理内存访问的流程,之后以hello进程为例,分析了fork与execve时的内存映射。
介绍了缺页故障与缺页中断的处理,并使用一个简单例子,形貌了缺页中断的处理流程。
末了,分析了动态存储分配管理。从动态内存管理的基本方法与动态内存管理的策略两个方面临动态内存管理进行介绍。

第8章 hello的IO管理

8.1 Linux的IO装备管理方法

在Linux中,全部的I/O装备(比方网络、磁盘和终端)都被模型化为文件,而全部的输入和输出都被当尴尬刁难相应文件的读和写来执行。
这种将装备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得全部的输入和输出都能以一种统一且同等的方式来执行。
8.2 简述Unix IO接口及其函数

8.2.1 简述Unix I/O接口
通过Unix I/O接口,全部的输入和输出都能以统一且同等的方式来执行:

1、打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O装备。内核返回一个小的非负整数,叫做形貌符,它在后续对此文件的全部操纵中标识这个文件。内核记录有关这个打开文件的全部信息。应用程序只需记取这个形貌符。

2、每个进程开始时都打开的三个文件
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(形貌符为0)、标准输出(形貌符为1)和标准错误(形貌符为2)

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

4、读写文件
一个读操纵就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增长到k+n。给定一个大小为m字节的文件,当k≥m时,执行读操纵会触发一个EOF条件。类似地,写操纵就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。

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

8.2.2 Unix I/O函数

int open(char *filename, int flags, mode_t mode);
应用程序通过调用open函数来打开一个已存在的文件或者创建一个新文件。

int close(int fd);
应用程序通过调用 close 函数关闭一个打开的文件。

ssize_t read(int fd, void *buf, size_t n);
应用程序通过调用read函数来执行文件的输入。

ssize_t write(int fd, const void *buf, size_t n);
应用程序通过调用write函数来执行文件的输出。
8.3 printf的实现分析

以下是printf函数的界说:
   

  • int printf(const char *fmt, ...)  
  • {  
  •     int i;  
  •     char buf[256];  
  •   
  •     va_list arg = (va_list)((char *)(&fmt) + 4);  
  •     i = vsprintf(buf, fmt, arg);  
  •     write(buf, i);  
  •   
  •     return i;  
  • }  
  在printf的形参列表中,可以看到,const char *fmt之后的参数都用了“...”来代替。这个是可变形参的一种写法,当传递参数的个数不确定时,就可以用这种方式来表示。
在printf的函数体中,有句:
   

  • va_list arg = (va_list)((char *)(&fmt) + 4);  
  其中,va_list界说为:
   
typedef char *va_list;  

  (char*)(&fmt) + 4) 表示的是“…”中的第一个参数的地址。这是因为,在C语言中,参数压栈的方向是从右往左的。第一个参数fmt将在栈顶的位置,而栈顶是往地址减小的方向增长的。在32位中,第一个参数const char *fmt的大小为4字节,将fmt的地址加上4后,指针向栈底方向移动,指向“…”中的第一个参数。
之后的下一句:
   

  • i = vsprintf(buf, fmt, arg);  
  中,调用了vsprintf函数,其简单实现为:
   

  • 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);  
  • }  
  其执行流程为,扫描格式串fmt,如果没有碰到%(格式占位符),则将字符原封不动的输出至buf中。如果碰到了%,根据厥背面接着的字母来判断必要进行格式化输出的范例,从而解读出p_next_arg的真实数据范例,再调用对应的具体函数进行格式化字符串的生成。
比方,格式占位符是%d,则将p_next_arg(char*)解读为int*范例(强转范例转换),解引用得到实际的参数int,再调用itoa等函数将int格式化为字符串,输出至buf中。输出完这个参数之后,让p_next_arg加上这个参数的大小,使之指向下一个参数。
在得到格式化字符串buf后,printf调用write进行输出:
   

  •     write(buf, i);  
  其中的i是buf中格式化字符串的长度,由vsprintf返回。
我们看一下write的实现:
   

  • write:  
  •      mov eax, __NR_write  
  •      mov ebx, [esp + 4]  
  •      mov ecx, [esp + 8]  
  •      int INT_VECTOR_SYS_CALL  
  在write中,给寄存器传递了参数,之后int INT_VECTOR_SYS_CALL,通过体系来调用sys_call这个函数。
末了,我们看一下sys_call的实现:
   

  • sys_call:  
  •      call save  
  •      push dword [p_proc_ready]  
  •      sti  
  •      push ecx  
  •      push ebx  
  •      call [sys_call_table + eax * 4]  
  •      add esp, 4 * 3  
  •      mov [esi + EAXREG - P_STACKBASE], eax  
  •      cli  
  •      ret  
  这里的call [sys_call_table + eax*4](调用的是sys_call_table[eax])中, sys_call_table是一个函数指针数组,每一个成员都指向一个函数,用以处理相应的体系调用。在这个实例中,此时的eax为4(即__NR_write的体系调用号),从而对内核中的write进行调用。
接下来,体系已经确定了所要显示在屏幕上的符号。根据每个符号所对应的ASCII码,体系会从字模库中提取出每个符号的VRAM信息。
显卡使用的内存分为两部门,一部门是显卡自带的显存称为VRAM内存,另外一部门是体系主存称为GTT内存。在嵌入式体系或者集成显卡上,显卡通常是不自带显存的,而是完全使用体系内存。通常显卡上的显存访存速率数倍于体系内存,因而许多数据如果是放在显卡自带显存上,其速率将明显高于使用体系内存的环境。
显示芯片按照刷新频率逐行读取VRAM,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析

进入getchar函数之后,进程会进入阻塞状态,等待外界的输入。体系开始检测键盘的输入。此时如果按下一个键,就会产生一个异步中断,这个中断会使体系回到当前的getchar进程,然后根据按下的按键,转化成对应的ASCII码,生存到体系的键盘缓冲区。
接下来,getchar调用了read函数。read函数会产生一个陷阱,通过体系调用,读取键盘缓冲区中存储的刚刚按下的按键信息,然后返回指定大小的字符串。
末了,getchar会将这个字符串生存在一个静态的缓冲区中,并返回其第一个字符。在下次调用getchar时,将直接从静态的缓冲区中取出字符并返回,而不是通过read再次进行读取,直到静态缓冲区为空,才再调用read进行读取。
8.5本章小结

本章介绍了linux体系下的IO的基本知识,讨论了Linux体系中Unix I/O的情势以及实现的模式函数。末了,对printf和getchar两个函数的实现进行了深入的探究。

结论

我们通过键盘,向计算机输入一行行代码,这串代码组合成了一个C源文件,也就是我们的主角hello.c。
接下来,hello.c颠末了预处理器cpp,编译器cc1,汇编器as,链接器ld这些家伙一顿好生折腾,最终生成一个可以加载到内存执行的可执行目标文件hello。
然后,我们在Shell中执行下令“./hello 2022113573 张宇杰 1”,Shell通过fork函数创建一个新的进程,然后在子进程里通过execve函数将hello程序加载到内存。虚拟内存机制通过mmap为hello进程规划了一片虚拟空间,调度器为hello进程规划进程执行的时间片,使其能够与其他进程一起公道利用CPU与内存的资源。hello完成了其P2P(From Program to Process)的过程。
之后,CPU一条条的从hello的.text段取指令,寄存器们的值随着程序的执行而不断变化着,异常处理程序监视着键盘的输入。hello中的syscall体系调用会使进程触发陷阱,让内核接办进程,执行write函数,将一串字符传递给屏幕IO的映射文件。
映射文件对传入数据进行分析,读取VRAM,然后在屏幕上将字符显示出一行行字符串“Hello 2022113573 张宇杰”。
末了,hello程序运行结束,Shell通过waitpid函数通知内核回收hello进程,hello进程消失。至此,hello完成了其程序执行的一生,从不带来什么,也不带走什么,是真正的O2O(From Zero to Zero)。

hello的一生结束了,而我们的计算机之路才刚刚开始。相识完hello的一生之后,相信我们学到了许多的知识,相信我们也产生了更多的迷惑。固然hello的一生结束了,但执行它的CPU还在不绝运转,内存中的比特海洋仍在波涛汹涌。这些迷惑,就让我们用一生来解开吧!

附件

hello.c
C源文件
hello.i
C预处理文件,由hello.c预处理得到
hello.s
汇编语言文件,由hello.i编译得到
hello.o
可重定位目标文件,由hello.s汇编得到
hello_elf.txt
由readelf生成的关于hello.o的ELF信息
hello_asm.txt
由objdump生成的关于hello.o的反汇编信息
hello
可执行文件,由hello.o链接得到
hello_exe_elf.txt
由readelf生成的关于hello的ELF信息
hello_exe_asm.txt
由objdump生成的关于hello的反汇编信息


参考文献


  • 《深入理解计算机体系》第3版
  • 老师的PPT
  • C 预处理器
C 预处理器 | 菜鸟教程

  • 程序具体编译过程(预处理、编译、汇编、链接)
程序具体编译过程(预处理、编译、汇编、链接) - 知乎

  • bss、data和rodata区别与联系
bss、data和rodata区别与联系_static rodata data-CSDN博客

  • 64位ELF文件头格式介绍
64位ELF文件头格式介绍 - 简书

  • ELF 文件解析 1-前述+文件头分析
ELF 文件解析 1-前述+文件头分析 - 知乎

  • ELF 文件解析 2-节
ELF 文件解析 2-节 - 知乎

  • 程序的链接
https://www.cnblogs.com/shuqin/p/12012906.html

  • elf(5) — Linux manual page
elf(5) - Linux manual page

  • 虚拟地址、逻辑地址、线性地址、物理地址的区别
虚拟地址、逻辑地址、线性地址、物理地址的区别_虚拟地址和逻辑地址的区别是-CSDN博客

  • 操纵体系-分段机制
https://www.cnblogs.com/mdumpling/p/8494806.html

  • 一文读懂内存管理中TLB:地址转换后援缓冲器
一文读懂内存管理中TLB:地址转换后援缓冲器 - 知乎

  • CPU 与 Memory 内存之间的三级缓存的实现原理
CPU 与 Memory 内存之间的三级缓存的实现原理_m和 cpu 中摘入 cache-CSDN博客

  • 256-Linux虚拟内存映射和fork的写时拷贝
256-Linux虚拟内存映射和fork的写时拷贝_linux fork 内存拷贝-CSDN博客

  • [转]printf 函数实现的深入分析
https://www.cnblogs.com/pianist/p/3315801.html


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

徐锦洪

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表