2023哈工大计算机系统大作业

摘  要

        弹指间千万计算,一刹那Hello再见。本文运用计算机系统的知识,围绕一个简单的Hello.c程序,介绍了在Linux下一个C语言源文件到可执行目标程序再到进程再到进程结束的完整生命周期,包括预处理、编译、汇编、链接、进程管理、存储管理、I/O管理这7部分,最后总结结论。本文的内容基本设计了计算机系统的各个方面,展现了计算机系统复杂又精巧的设计。

关键词:计算机系统,Linux,Hello,P2P,O2O



第1章 概述

1.1 Hello简介

Hello的P2P过程:P2P,即From Program to Process。Hello的生命周期是从一个C语言程序的源文件开始的,首先预处理器cpp对源文件进行预处理,生成文本文件Hello.i,然后编译器ccl编译Hello.i生成汇编程序Hello.s,接着汇编器as对Hello.s进行汇编,生成可重定位文件Hello.o,最后链接器ld将Hello.o与它引用到的库链接,生成可执行文件Hello,但到此只是得到了可执行程序。

当用shell输入命令行执行这个可执行程序时,系统会创建一个新的进程,接着删除已存在的用户区域,然后映射私有区域,为Hello程序的代码段/只读内存段和数据段/读写内存段以及堆和栈创建新的区域结构,再将控制权转移给动态链接器(如果程序连接了动态库),映射共享区域,符号重定位,最后控制跳转到程序的入口点。自此一个进程创建完成,完成了From Program to Process。

Hello的020的过程:020,即From 0 to 0。程序的进程通常都有一个在主存中从无到有再到无的过程。简介的第二段就讲述了从无到有的过程。进程终止后,Hello进程的父进程回收Hello进程(如果父进程还在主存中存在的话),释放Hello进程占用空间,这个进程又从主存中消失。这就是Hello的O2O过程。

1.2 环境与工具

硬件环境:x86-64 CPU 3.30GHz, RAM 16GB, 2TSSD

软件环境:Windows10-64bit,WMware Workstation Pro,Ubuntu 22.04.3 LTS-64bit

开发与调试工具:vim,,gedit,Visual Studio Code,gcc,gdb

1.3 中间结果

Hello.i 预处理产生的文本文件,是编译的输入

Hello.s 编译产生的汇编代码文本文件,是汇编的输入

Hello.o 汇编产生的二级制的可重定位目标文件,是链接的输入

Hello.o.objdump 用objdump对Hello.o反汇编输出的文件,用于观察代码中需重定位的地方

Hello.o.txt 以Hello.o为输入,readelf -a 输出的文本文件,用于了解可重定位文件的结构

Hello 用ld链接Hello.o以及需要用到的动态库和.o文件生成的可执行目标文件,用于运行程序,观察动态链接过程。

Hello.objdump 用objdump对Hello反汇编输出的文件,与Hello.o.objdump一起观察重定位的影响

hello 用命令gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC Hello.c -o hello生成的可执行目标文件

1.4 本章小结

       简述Hello,说明环境工具和中间结果。


第2章 预处理

2.1预处理的概念与作用

2.1.1预处理的概念

预处理是在编译之前使用预处理器cpp,根据源代码中的预处理命令对代码进行插入和修改,得到一个适合编译的文本文件(默认以.i为后缀)的过程,除此之外,一些预处理指令还会影响之后编译器的行为。

2.1.2预处理的作用

预处理的作用很大,一下列举4中:

  1. 通过#include命令省去了手动复制头文件的过程, 预处理器会自动把这条命令从文本中删除,在并在该位置插入命令要求的头文件文本。
  2. 方便代码切换(使用#define和#if等,实现代码的快速切换)
  3. 利用宏定义,使用适合本系统的代码。在标准库的源文件中,有很多根据宏定义选择适合该系统的代码的文本。
  4. 让预处理时有报错。#error,当预处理器到#error,就会报错。可以用于一些宏变量的检查
  5. #pragma,使用标准化方法,发布特殊的命令到编译器中。例如,在for循环语句前加上#pragma omp for parallel num_thread(6),可以使编译器对这个循环做特殊的处理,汇编链接得到可执行程序在执行时,会用6个线程完成这个for循环。

2.2在Ubuntu下预处理的命令

       可以直接使用cpp Hello.c Hello.i,也可以用gcc -E Hello.c > Hello.i。

图 1 使用cpp Hello.c Hello.i预处理

2.3 Hello的预处理结果解析

用vim打开Hello.i,Hello.i总共有3092行,如图 2。观察发现,其中的注释已经消失,#include已经消失,main函数之前的文本被替换成3000多行的内容。经比较文本发现,所include的头文件中的内容被添加到Hello.i中,如图 3。

2 Hello.i的部分内容

 3 Hello.i与stdlib.h对比

2.4 本章小结

本章首先介绍了预处理的概念与作用,接着以Hello.c为例,演示了在Ubuntu下如何预处理程序,并对结果进行分析。

预处理是程序员的好帮手。预处理省去了手动复制粘贴的麻烦;通过宏变量和预处理的条件判断语句使程序能自动和环境适配,也提供了更细致地与编译器交流的方式。


第3章 编译

3.1 编译的概念与作用

3.1.1编译的概念

C语言的编译是编译器cc1将预处理得到的.i文件翻译成汇编代码.s文件的过程。

在编写C语言语境下,在口语交流中的编译通常是广义的编译,包括了预处理、编译、汇编、链接四个过程。本章讨论的是狭义的编译。

C语言的编译通常包括词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成等步骤[1]。

3.1.2编译的作用

       编译的作用有很多,以下列举三条:

  1. 编译器将用高级语言书写的源程序转换为汇编代码。
  2. 现在的编译器大多有优化功能,可以提高程序的性能。
  3. 编译器在编译的过程中会发现一些潜在的问题,可以帮助程序员找到bug。

3.2 在Ubuntu下编译的命令

可以使用gcc隐式调用cc1:gcc -S Hello.c -o Hello.s。

也可以直接使用cc1(通常不是bash的内置命令,需要以可执行程序的形式调用)

图 4 gcc隐式调用cc1

      图 5 直接调用cc1编译

3.3 Hello的编译结果解析

以下对得到的汇编代码进行分析

3.3.0 cc1生成的汇编文件中,常见的伪指令

伪指令

含义

.section

用于指定代码段、数据段等的起始和结束位置

.global

声明一个全局符号(变量或函数),使其可以被链接器访问

.local

声明一个局部符号,限制其作用范围在当前模块内

.data

指示接下来的数据声明应该放在数据段

.text

指示接下来的指令应该放在代码段

.align

用于内存对齐,指定下一个数据的地址应该是多少字节对齐

.word

定义一个字数据

.byte

定义一个字节的数据

.string

定义一个以null结尾的字符串

.skip

在数据段中分配指定字节数的空间,但不初始化

3.3.1数据

Hello.c用到的数据类型(与C语言中数据类型的定义不完全相同)有:字符串、整数、数组

字符串有两个,分别是"用法: Hello 学号 姓名 秒数!\n"和"Hello %s %s\n",而且都是常量字符串。在.s文件中的表现形式如下图。由图可知,汉字在Hello.s文件中,是以UTF-8的编码形式记录的。由于这两个字符串都是常量字符串,这两个字符串都被放在了.rodata只读数据节中。

图 6 Hello.s中字符串的表现形式

int局部变量i:一般来说,过程通过减小%rsp的值为局部变量申请空间。汇编你代码中,%rsp被一次性减32,根据代码的上下文可知,从地址R[rrbp]-4到地址R[rrbp]的这段4Byte空间被用来存放int局部变量i。由此可知在本段汇编代码中,通过基于%rbp计算有效地址的方式实现对int类型的局部的引用。

int类型参数argc通过寄存器%edi传入main函数,之后movl将其拷贝到栈帧的局部变量区。

代码中还有一些整型以立即数的形式出现,这些立即数记录在代码区。

图 7 汇编代码引用i的指令

程序中涉及数组的是:char *argv[]。Hello.c中argv被视为数组首地址,C语言代码中对argv使用了下标操作,argv数组的元素类型时char*,在命令行参数输入正确的情况下。argv数组的前4个元素是已经分配好空间的字符串的首地址。第五个元素是NULL。在汇编代码中,下标索引借助了%rax寄存器,如图 8所示。

8 对argv下标索引相关汇编指令的解析

3.3.2赋值操作

       C语言代码中的一个显式的赋值操作(i=0)在汇编代码中通过mov指令完成(图 9),由于i是int类型,长度是双字,故后缀是l。还有一些隐式的操作,除了使用mov之外,还是用了leaq实现赋值操作。

9 两处典型赋值操作的批注 

3.3.3类型转换

       sleep的参数类型时unsigned int,而atoi的返回值是int,如图 10,在此处会进行隐式类型转换。而由于只是从signed的变成unsigned,位数并没有改变,所以在C语言中,只是会变成用有符号数的方式解释这些位。而解释这些位在sleep函数内,故汇编代码中只是将数传给了%edi,没有做额外的操作,如图 11。
10 查看C语言代码

11 隐式类型转换涉及的汇编代码,代码中确实没有额外的操作

3.3.4算术运算

       常见的算术运算指令有

  1. ADD、ADC、AAA、DAA: 加法
  2. INC:加“1”
  3. SUB、SBB、AAS、DAS:  减法
  4. DEC:减“1”
  5. CMP:比较;(对两个操作数做减法,改变标志位,不修改被减数)
  6. NEG:求补指令
  7. MUL、IMUL、AAM:乘法
  8. DIV、IDIV、AAD:除法

在该汇编代码中,只有一个算术操作:add,如下图。此处没有直接使用INC。

图 12 i++对应的汇编指令

3.3.5关系操作

       常见的关系操作指令有:

指令

效果

描述

CMP S1,S2

S2-S1

比较-设置条件码

TEST S1,S2

S1&S2

测试-设置条件码

SET**  D

D=**对应的条件吗的值

按照**用条件码设置D

       本C语言代码中有两处关系操作:argc!=4和i<8(在编译时被改成i≤7)。这两次关系操作通过设置条件码(图 13),控制后续的条件跳转语句。

13 两处关系操作对应的汇编指令

3.3.6数组操作

数组操作的主要操作是通过下标索引需要元素的值,下标索引操作离不开汇编中的内存引用操作。

在3.1.1中对argv的分析中对下标分析进行了详细的分析,图 8中对数组操作进行了分析。。

3.3.7控制转移

       该代码涉及了分支结构和for循环结构。

       C语言的分支结构依靠if,else和switch等。本代码中使用了if,在汇编代码层面,if通常由cmp指令和条件跳转指令配合完成。如图 14,执行cmpl时,如果-20(%rbp)==4,ZF会被set,即设置为1,,否则ZF被reset,即设置为0。执行je时,如果ZF==1,就跳转到.L2处的代码,否则,不跳转,执行下一条指令。

14 分支结构的汇编代码

       C语言的for循环结构的实现也离不开跳转指令,也离不开关系操作。如图 15所示,这个for循环首先由一个无条件跳转,在刚开始for循环时跳转到条件判断处.L3,然后如果满足条件,就跳转到.L4。当循环体的代码执行完之后,又顺序执行到条件跳转。

15 for循环结构的汇编代码

3.3.8函数操作

函数是过程的一种,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。P中调用函数Q包含以下动作:

  1. 控制传递:要执行过程Q,就要在开始调用Q后将程序计数器PC设置为Q的代码的起始地址;过程Q结束之后,要把控制权转移给过程P,于是在返回后,要把程序计数器设置为P中调用Q的指令的下一条指令的地址。
  2. 传递数据:P要能够向Q提供0个、一个或多个参数,Q通常会给P返回一个值(通常通过%rax/%eax/%ax%al寄存器返回)。
  3. 分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。

在x86-64中,前6个参数可以通过寄存器传递,其余的参数要通过栈传递。数据传送对寄存器的使用是有顺序的,如表格 1。

表格 1 传参使用寄存器的顺序

操作数的大小(bit

参数数量

1

2

3

4

5

6

64

%rdi

%rsi

%rdx

%rcx

%r8

%r9

32

%edi

%esi

%edx

%ecx

%r8d

%r9d

16

%di

%si

%dx

%cx

%r8w

%r9w

8

%dil

%sil

%dl

%cl

%r8b

%r9b

       程序中涉及的函数操作列举如下(x86-64系统):

  1. main函数:
    1. 传递控制:系统启动函数__libc_start_main使用call指令调用main函数,call指令将下一条指令的地址压栈,然后将%rip的值设置为main函数指令的起始地址。
    2. 传递数据:__libc_start_main向main函数传递参数argc和argv,分别使用%edi(argc的类型是int)和%rsi存储,main函数的return 0对应于汇编中的三条指令,将%eax设置0,然后ret(夹在中间的leave稍后分析),其中,ret从栈中弹出返回地址,将这个地址赋给%rip。
    3. 分配和释放内存:使用%rbp记录对应栈帧的最高地址-8的值,通过减小%rsp的值为函数在栈中分配空间,程序结束时,调用leave指令,leave将%rbp的值赋给%rsp(释放局部变量占用的空间),然后从栈中弹出一个4字长的值给%rbp(这个值其实就是__libc_start_main%rbp的值),恢复栈空间为调用main函数之前的状态。
  2. printf函数:
    1. 传递数据:第一次printf将%rdi设置为"用法: Hello 学号 姓名 秒数!\n"字符串的首地址。第二次printf设置%rdi为"Hello %s %s\n"的首地址,设置%rsi为argv[1],%rdx为argv[2]。
    2. 控制传递:第一次printf因为只有一个字符串参数,所以call puts@PLT;第二次printf使用call printf@PLT。
  3. exit函数:
    1. 传递数据:将%edi设置为1。
    2. 控制传递:call exit@PLT。
  4. atoi函数
    1. 传递数据:将%rdi设置为argv[3]。
    2. 控制传递:call atoi@PLT。
  5. sleep函数:
    1. 传递数据:将%edi设置为&eax(即atoi函数返回的值)。
    2. 控制传递:call sleep@PLT。
  6. getchar函数:
    1. 控制传递:call gethcar@PLT

 3.3.9 Hello.s完整代码

3.4 本章小结

本章介绍了编译的概念和作用,并着重分析了编译生成的汇编代码。

汇编代码是低级语言,机械难懂,但是它是很多高级语言的基础。它更接近于CPU,这使得它的代码的可移植性差,但也使得它可以提供更多操作CPU的方法(C语言只是使用了CPU的指令集的一个子集)。


第4章 汇编

4.1 汇编的概念与作用

4.1.1汇编的概念

汇编是汇编器(as)将.s汇编程序翻译成机器语言指令,然后输出包含二进制形式的机器语言指令的可重定位目标程序的过程。

4.1.2汇编的作用

       汇编得到的是机器可以理解的二进制指令序列。

4.2 在Ubuntu下汇编的命令

as Hello.s -o Hello.o或者gcc -c Hello.s -o Hello.o。编译过程如下图

图 16 直接使用as汇编

图 17 使用gcc汇编

4.3 可重定位目标elf格式

4.3.1典型的可重定位目标elf格式[2][3]

  1. ELF头以16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型(如可执行、可重定位或者共享的)、机器类型、节头部表的文件偏移以及节头部表中条目的大小和数量。
  2. .text:已编译程序的机器代码。
  3. .rodata:只读数据。
  4. .data:已初始化的非0的全局变量和静态变量。
  5. .bss:未初始化的全局变量和局部静态变量,以及所有被初始化为0的全局变量和静态变量(仅是占位符,该节在目标文件中不占据实际的空间)。
  6. .symtab:符号表(与编译器中的符号表不同),存放存放程序中定义和引用的函数和全局变量信息,不包括局部变量。
  7. .rel.text:代码的重定位条目的列表,用于在重定位的时候,重新修改代码段的指令中的地址信息。
  8. .rel.data:已初始化数据的重定位条目的列表(比如,有个全局变量a,它的初值在汇编时无法确定,.rel.data就会有一个与a有关的重定位条目)
  9. .debug:调试符号表,只有以-g方式调用编译器驱动程序时,才会得到这张表。
  10. .line:原始C源程序中的行号和.text节中机器指令之间的映射。
  11. .strtab节:字符串表,包括.symtab和.debug节中的符号表以及节头部表的节名。
  12. 节头部表:节头部表描述了这个elf文件中每个 节的位置与大小。

图 18 典型可重定位ELF文件格式

4.3.2对Hello.o的ELF格式的分析

  1. 分析节头部表:使用readelf -S Hello.o查看节头部表,如下图。                                                       图 19 Hello.o的节头部表
  2. 分析符号表:用readelf -s Hello.o查看符号表。如下图,Name为符号名称,Value是符号相对于目标节的起始位置偏移,Size为目标大小,Type是类型,数据或函数,Bind表示是本地还是全局。                                                                                                                图 20 Hello.o 符号表
  3. 分析文件头:用readelf -h指令可以查看Hello.o的ELF头信息,如下图21.

    Class:64位ELF文件格式

    Data:数据表示形式是二进制补码,且是小端字节序(低位字节存储在低内存地址)。

    Version:版本为1

    OS/ABI:操作系统为UNIX SYSTEM V

    TYPE:REL表明这是一个可重定位文件

    Machine:这个ELF文件是为AMD的64位x86架构(X86-64)而编译的。

    Entry point address:程序的入口地址为0x0

    Start of program headers:0表示没有程序头表

    Start of section headers:节头部表的起始位置为1056字节处

    Size of section headers:每个表项64个字节

    Number of section headers:该可重定位文件共14个表

    Section header string table index:13为.strtab在节头表中的索引                                                 21 Hello.o的ELF头

  4. 分析重定位条目:使用readelf -r Hello.o查看重定位条目(图 22)。

    .rela.data / .rel.data 节在本程序中没有。

    .rela.eh_frame节通常是与异常处理框架(Exception Handling Frame)相关的重定位条目。

    .rela.text中,Offset表示要修改的引用的地址与.text节的首地址的差。Info的前部分表示在符号表中的索引,后部分表示重定位的类型(经观察,Info显示的并不是显示出所有的8字节,符号表中的索引只有低2字节被显示出来)。Type只是将重定位的类型编码翻译成对程序员比较友好的字符串。Sym.Value和Sym.Name是用在符号表中查找得到的。Addend是为了修正PC的误差(由于历史原因,PC的更新会在每条指令执行之前进行)。重定位是链接的一部分,将在第五章阐述。                                                                            22 Hello.o的重定位条目

4.4 Hello.o的结果解析

       使用objdump -d -r Hello.o反汇编(图 23)。与Hello.s相比,原来的伪指令消失了。

在函数调用处,标准库中的puts,exit,printf,atoi,sleep消失了,被用全0的值替代。

两个字符常量的引用被改成了一个有效地址计算,但是立即数都被设置成全0。上述两个现象都是因为汇编器还无法计算出这些量和函数的运行时地址。

       另外,分支转移不再以标签为操作数,改成使用长度为一个字节的PC相对地址。例如倒数第五条指令jle的操作数为0xaf,将PC的地址0x8c(原因同上述的自动更新)与0xaf相加,得到0x13b,根据补码的特点,只保留前8bit,于是得到0x3b正好是之前Hello.s的.L4指向的地方。

       再者,原本十进制的立即数都变成了二进制。这个很好理解,输出的文件是二进制的,对于objdump来说,直接将二进制转化为十六进制比价方便,也有利于程序员以字节为单位观察代码。

       最后,我们在代码中还看到了一些重定位条目的信息。

23 Hello.o使用Objdump反汇编结果

4.5 本章小结

本章介绍了汇编的概念和作用,在Ubuntu下使用as进行汇编,结合Hello.o介绍了典型的ELF格式,对Hello.o的结果进行了分析。


5章 链接

5.1 链接的概念与作用

5.1.1链接的概念

链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被编译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器加载到内存并执行时;甚至于运行时(run time),也就是由应用程序来执行。链接是由叫做链接器的程序执行的。

链接的两个主要步骤是符号解析和重定位。

5.1.2链接的作用

链接使得分离编译成为可能,方便了大程序的构造;动态库链接技术减少了程序在运行时对物理内存的占用; 动态库链接技术衍生出了库打桩技术。

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

链接过程如下图所示。

图 24 使用ld命令链接

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

       图 25展示了典型的ELF可执行目标文件的结构

25 典型的ELF可执行目标文件

       可执行目标文件与可重定位文件在格式上稍有不同,可执行目标文件中ELF头中Entry Piont Address给出执行程序时的第一条指令的地址,而在可重定位文件中,此值没有意义(可重定位文件通常无法加载到内存中直接运行,所以就没有Entry Piont Address)也无法确定,此值被设为0。

可执行目标文件的ELF头中的程序头部表有了实际的意义,是一个结构体数组,记录着将程序加载到主存中要用到的信息。

可执行目标文件还多了一个.init节,用于定义init函数,该函数用来执行可执行目标文件开始执行时的初始化工作。

没有动态链接的可执行目标文件不需要重定位,可执行目标文件少了.rel节。

使用readelf -a Hello命令查看Hello各段的信息(图 26)。其中Offset表示在目标文件中的偏移,Size 表示该段的大小,Flags表示运行时访问权限。Address表示在逻辑地址中各段首地址。

       例如.text段在该目标文件的偏移是0x4010f0,大小是0xcd,Flags是AX,即可分配,可执行。由于进行了动态链接,所以第一段为.interp。

26 Hello的节头部表的信息

5.4 hello的虚拟地址空间

       使用edb查看虚拟地址。代码段的首地址的确0x400000(0x3fffff就到达不了,如图 27所示)。由Data Dump(图 27)显示的内容可知,代码段是以ELF开始的,开头的字节与Magic(如图 28所示)相同。

27 edb中的Data Dump

28 用readelf查看ELF头

如下图所示,进程中.interp的首地址和5.3节中显示的0x4002e0一样(由这里对应了动态链接器的路径可知)。

图 29 edb查看.interp

如下图所示,进程中.text的首地址和5.3节中显示的0x4010f0一样(_start函数是程序的入口点)。

图 30 edb参看0x4010f0

如下图所示,进程中.plt的首地址和5.3节中显示的0x401020一样(此处以后得汇编代码恰好是PLT中汇编代码的格式)。

图 31 edb参看0x401020

5.5 链接的重定位过程分析

执行objdump -d -r Hello > Hello.objdump与objdump -d Hello.o > Hello.o.objdump。对比Hello.objdump与Hello.o.objdump。发现,了一些不同。

首先,Hello.o.objdump只有main过程,而Hello.objdump中不止有main过程了,还多了其他过程。这一变化与链接分不开。

在使用ld命令链接的时候,指定了动态链接器为64位的/lib64/ld-linux-x86-64.so.2。crt1.o、crti.o、crtn.o中主要定义了程序入口_start、初始化函数_init,_start程序调用Hello.c中的main函数,libc.so是动态库,其中定义了Hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入到目标文件中,这使得Hello中多了好几个过程。

加入这些函数是在符号解析的时候发生的。

其次,Hello中的代码的各个字节有了运行时的虚拟地址,而在Hello.o只有节偏移的信息。

在符号解析之后,链接器对输入的目标模块的代码节和数据节进行重定位。

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。

结合hello.o的重定位项目,分析hello中对其怎么重定位的。静态库的符号的重定位是由链接器进行的,动态库符号的重定位是由动态链接器完成的。此处先分析静态链接中的重定位。

以第一个常量字符串的重定位为例。通过重定位条目,链接器知道这个重定位的类型。由于难以得到符号解析后,重定位之前的重定位条目。使用gdb,objdump,edb等,经过反推,得到了第一个常量字符串的重定位条目,设为r。

r.offset = 0x51,

r.addend = -4.

ADDR(.text) = 0x4010f0

ADDR(r.symbol) = 0x402008 (第一个常量字符串的首地址,并不是.rodata的首地址)

addr(.text)表示.text节在文件中的首地址。

编译器会计算在文件中要修改的首地址refptr=addr(.text) + r.offset;再计算运行时PC相对地址,然后将这个地址写入以refptr为首地址的连续四个字节:*refptr = ADDR(r.symbol) + r.addend – (ADDR(.text) + r.offest) = 0x402008+(-0x4)-0x401141 = 0xec3。如下图所示,代码中确实是0xec3。

图 32 Hello.objdump的片段

5.6 Hello的执行流程

Linux程序通过调用execve函数调用加载器(loader),加载器建立Hello中的代码和数据从磁盘到主存的映射,然后通过跳转到程序的第一条指令来运行该程序,如果链接了动态库,会先跳转到动态链接器,接着动态链接器跳转到程序的第一条指令。

当_start到exit之间的会调用到的函数列举如下,_start之前会call ld-linux-x86-64.so.2中的函数,尝试完全跟踪,但是“这个库的水太深了”,甚至在exit函数中也会调用到它,main函数在正常返回后,也会调用exit函数,exit函数中回调用动态库,动态库中还会调用线程锁等等,如图 33所示。所以,只列出了_start和exit之间的函数。

函数

地址

_start

0x00000000004010f0

__lib_start_main(于libc.so.6)

0x00007fbed3e29dc0

__cxa_atexit(于libc.so.6)

0x00007fbed3e458c0

(libc.so.6,不知其名)

0x00007fbed3e456d0

_init

0x0000000000401000

main

0x0000000000401125

puts@plt

0x401090

exit@plt

0x4010d0

getchar@plt

0x4010b0

printf@plt

0x4010a0

atoi@plt

0x4010c0

sleep@plt

0x4010d0

exit (于libc.so.6)

0x00007ff9a7c455f0

33 动态连接器中调用线程锁

5.7 Hello的动态链接分析

动态链接器在重定位函数时采取了延迟绑定(lazy binding)的策略,将过程地址的绑定推迟到第一次调用该过程时。

以下以printf函数为例,分析在dl_init前后,GOT和PLT中内容变化。个人Ubuntu环境下的PLT表的结构与教科书中的略有差别,如图 34。图 34中绿箭头指向的指令是跳转到动态连接器,动态连接器根据栈中的参数修改GOT中对应的一个条目的地址,这样的话,下一次跳转到蓝色高亮的那条指令就直接跳转到printf函数的地址了。

34 edb查看PLT

       调用动态链接器之前,0x404020处8个字节构成的值是0x00401040(图 35),调用之后,变成了0x7fea1e0606f0恰好是printf的地址(图 36)。

图 35 调用动态连接器重定位之前

36 调用动态连接器重定位之后

5.8 本章小结

本章讲述了链接的概念和多用,重点对静态链接和动态链接进行了分析,还理清了程序的加载过程。


6章 hello进程管理

6.1 进程的概念与作用

6.1.1进程的概念

       一个进程是一个执行中程序的实例。

6.1.2进程的作用

       进程简化了用户的内存操作的工作,提高了程序的通用性,是多个过程并发执行的基础,是计算机科学中最深刻,最成功的概念。

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

shell是一个交互型应用级程序,代表用户运行其他程序。如Windows下的命令行解释器,cmd、powershell,图形界面的资源管理器。Linux下的Terminal/tcsh、bash等等,也包括图形化的GNOME桌面环境。Shell是信号处理的代表,负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等。Shell是人在操作系统中的代表。

处理流程:shell执行一系列的读/求值步骤。读步骤读取用户的命令行,求值步骤解析命令,代表用户运行。shell在求值一个命令行时会创建一个进程组。一个进程组对应一个作业,一个作业是为了求这个命令行的值而创建的进程。

6.3 Hello的fork进程创建过程

C语言中,进程的创建采用fork函数:pid_t fork(void); 创建的子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程fork时,子进程可以读取父进程中打开的任何文件。

父进程与创建的子进程之间最大的区别在于它们有不同的PID。子进程中,fork返回0。因为子进程的PID总是为非零,返回值就提供一个明确的方法分辨程序是在父进程还是在子进程中。

在这里,父进程为fish(friendly interface shell,是shell的一种),在输入./Hello以及参数之后,首先fish会对我们输入的命令进行解析,然后fish会调用fork()创建一个子进程,如下图所示。

图 37 fork示意图

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序。execve函数加载并运行可执行目标文件Hello,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序,调用成功不会返回。与fork不同,fork一次调用两次返回,execve一次调用从不返回。

加载过程在 5.6 hello的执行流程 已有阐述。

6.5 Hello的进程执行

逻辑控制流:一系列程序计数器PC的值的序列叫做进程的逻辑控制流,PC的值要么对应可执行目标文件中的指令,要么对应运行时动态链接到程序的共享对象中的指令。进程的机制是的进程似乎是独占CPU的。从逻辑上看,逻辑控制流是连续没有中断的。

时间片:一个进程执行它的逻辑控制流的一部分的每一时间段叫做时间片。进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行一段时间后会被抢占(暂时挂起),然后轮到其他进程。

用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当这个寄存器的值对应用户模式时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;当这个寄存器的值对应内核模式时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。

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

简单看Hello进程调度的过程(假设CPU只有一个核):假设Hello进程正在运行,当内核调度新的进程运行后,抢占Hello进程,使用上下文切换的机制将控制转移到新的进程: 1)保存以前进程的上下文2)恢复新恢复进程被保存的上下文,3)将控制传递给这个新恢复的进程 。

Hello调用sleep的过程比较特殊。如下图,Hello初始运行在用户模式,在Hello进程调用sleep之后陷入内核模式,内核处理休眠请求并将Hello进程从运行队列中移出,加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到点时,定时器发出一个中断信号。内核将Hello进程从等待队列中移出重新加入到运行队列,在判定其他进程运行的时间足够之后,就执行上下文切换,将控制权转移给Hello进程。

图 38 Hello进程调用sleep过程中的简单示意

6.6 hello的异常与信号处理

       hello在执行时可能会出现四类异常:中断、陷阱、故障和终止。

       hello进程会在终止或停止时回个父进程发送SIGCHLD信号等。

       中断通常通过中断处理程序处理,信号通常通过信号处理程序处理。

如图 39是正常执行hello程序的结果,当进程终止之后,进程被回收。

图 39 正常执行hello

如图 40,是在程序输出2行之后按下CTRL-Z的结果,当按下CTRL-Z之后,shell父进程收到SIGSTP信号,信号处理函数的逻辑是打印屏幕回显、将hello进程挂起。通过ps命令我们可以看出hello进程没有被回收,此时他的后台job号是1。调用fg 1将其调到前台,此时shell程序首先打印hello的命令行命令,hello继续运行打印剩下的6条info,之后输入字串,进程终止,进程被回收。

40 运行时按下CTRL-Z

如图 41是在程序输出3行之后按下CTRL-C的结果,当按下CTRL-C之后,shell父进程收到SIGINT信号,信号处理函数的逻辑是终止前台作业,此处即终止hello进程,并回收hello进程。

41 运行时按下CTRL-C

如图 42是在程序运行中途乱按的结果,可以发现,乱按只是将屏幕的输入缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做shell命令行输入。

 如图 43,当发送SIGKILL给hello进程时,由于进程挂起,其信号处理程序没法执行,故要通过fg或者bg让其运行才内最终终止这个程序。

43 CTRL-Z后使用kill命令

CTRL-Z后jobs查看

图 44 CTRL-Z后jobs查看

CTRL-Z后pstree查看

图 45 CTRL-Z后pstree查看 第一部分

图 46 CTRL-Z后pstree查看 第二部分

图 47 CTRL-Z后pstree查看 第三部分

6.7本章小结

       本章主要介绍了程序如何从可执行文件到进程的过程。介绍了shell的处理流程和作用。也介绍了fork函数和execve函数,及上下文切换机制等。


7章 hello的存储管理

7.1 hello的存储器地址空间

物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。

逻辑地址:程序代码经过编译后出现在汇编程序中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述符的选择符)和偏移量(偏移部分)组成。

线性地址:线性地址空间是一个非负整数的集合。逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。在调试hello时,gdb中查看到的就是线性地址,或者虚拟地址。

虚拟地址空间是0到N的所有整数的集合(N是正整数),是线性地址空间的有限子集。分页机制以虚拟地址为桥梁,将硬盘和物理内存联系起来。

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

最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和总线的位宽,intel引入了段寄存器,8086 地址总线的位宽是20bit,通过将段寄存器的数值左移4位加上偏移地址就可得到20位地址。

上述逻辑地址到线性地址的计算方法是在实模式下的计算方法。现在大多数非单片机计算机是运行在保护模式的。该模式下,段寄存器每个元素不再只是地址。如图 48一个段选择符有16位,低2位是当前特权级,第3位TI说明查哪一个段描述符表,高13位是索引。

索引说明该段在描述符表中的下标。

TI为0时,选择全局描述符表,为1时选择局部描述符表。

RPL=00,说明当前位于内核态,有内核的权限。RPL=11,说明当前位于用户态。

段描述符表中的每一项记录着一个段的段基址,段限(由于说明段的最大大小)和存取权限。

如图 49,内核选定一个段选择符,用其中的索引找到对应的段描述符,从短描述符中得到段基址,段基址与偏移地址(图 49中的有效地址)相加,其和就是线性地址。

48 段选择符的数据结构

49 逻辑地址到线性地址的转换的示意图

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

页式管理在某种程度上可以看成对硬盘的高速缓存,即主存DRAM,的管理机制。

线性地址(VA)到物理地址(PA)之间的转换通过分页机制完成。分页机制类似主存和Cache之间的分块机制,分页机制对虚拟地址和物理内存进行分页,页的大小通常是4KB到2M(因时而异,时过境迁,页的大小有所不同)。在x86-64机器上,虚拟地址空间的N是2的48次方,有256TB,比正常的硬盘大得多。

在分页机制中,硬盘空间的每个字节到虚拟地址空间的每个字节存在映射关系,且这个映射是单射。虚拟地址空间和硬盘空间都以字节为单位,从0开始编地址号。设硬盘空间为H,虚拟地址空间为V,设他们之间的映射关系为f: H->V,f是单射,则H与f(H)之间存在双射g,且g是f的子集。于是知道了物理地址中某个地址所在页与虚拟空间的页的对应关系,也就知道了物理地址中某个地址所在页与硬盘中某个页的对应关系。

物理地址中某个地址所在页与虚拟空间的页的对应关系要通过什么来记录呢?分页机制中使用一个叫做页表的数据结构来记录这些关系,页表也是存储在内存中的,是由操作系统维护的。其实DRAM到Cache中也是类似机制,只不过DRAM到Cache的高速缓存机制是用硬件实现的。

每个进程都有一个页表,页表中的每一项记录着该对应的虚拟地址空间的那一页是否有效(即是否有对应的物理内存上的页),物理页的起始位置或磁盘地址,访问权限等信息。

为了说理清晰,我么假设页表条目(PTE)是由一个有效位和一个n位地址字段组成的。如图 50当有效位为1时,该页对应着物理内存上的一个页,地址字段表示DRAM中相应物理页的起始地址的高位的一部分,即物理页号。当有效位为0时,分两种情况。如果地址字段全0,即null,则该虚拟页是未分配的;如果地址字段不全为0,则该虚拟页是未加载的,地址字段中存储的是该虚拟页在磁盘上的起始地址的高位的一部分。

50 页表示意图[2]

m位的虚拟地址包含两个部分:,一个n位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,一个m-n位的虚拟页面偏移(VPO)。例如VPN 0选择PTE 0。根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的PPO串联起来就得到一个相应的物理地址,其中VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理程序将磁盘的虚拟页加载到内存中,然后再重新执行这个导致缺页的指令。

上面说明了分页的机制,程序中使用的地址是线性地址,而CPU是通过物理地址访问内存的,于是在CPU访问内存之前,要把线性地址转换为物理地址,这个工作可以由CPU干,但是这个工作简单繁琐,所以,一个专门的单元MMU(memory manage unit)被设计出来,完成这个地址翻译工作。

当hello要访问内存时,hello提供的线性地址要先经过地址翻译得到物理地址,地址翻译过程中,MMU根据页表中的内容翻译出物理地址,得到物理地址后,CPU通过地址线,将地址信息传输给DRAM。

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

然而每个进程的完整的页表其实是很大的,例如一台计算机的硬盘是4T,,页大小是4K,那计算机中一个进程的完整页表有1G,假设页表的一项占用4Btye的空间,那一个页表页占了4G的内存!另外,地址翻译由于要频繁进行,有个程序性能拖后腿的风险。

因此系统采用了多级页表的结构来记录这个页表,如图 51。在硬件上加入TLB提高地址翻译的速度,如图 52和图 53。

51 使用k级页表的地址翻译[2]

52 TLB存储内容的元素的结构[2]

53 TLB工作示意图[2]

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

为了讲清楚,下文有以下前提:只讨论L1 Cache的寻址细节,L2与L3Cache原理相同。L1 Cache是8路64组相联。块大小为64B。

解析前提条件:共64组,需要6bit CI进行组寻址;块大小为64B,需要6bit CO表示数据偏移位置;因为VA共52bit,所以CT共40bit,如图 54。

54 三级Cache支持下的物理内存访问示意图

我们已经获得了物理地址VA,使用CI(后六位再后六位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。

如果没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。

7.6 hello进程fork时的内存映射

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

7.7 hello进程execve时的内存映射

execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序。加载并运行hello需要以下几个步骤:

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

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

缺页故障是一种常见的故障,当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。其处理流程遵循 55所示的故障处理流程。

55 故障处理示意图

7.9动态存储分配管理

printf函数会调用malloc,以下简述动态内存管理的基本方法与策略:

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

分配器分为两种基本风格:显式分配器、隐式分配器。

显式分配器:要求应用显式地释放任何已分配的块。例如C语言中的freeC++中的delete

隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。Java,ML等高级程序设计语言依赖垃圾收集来释放已分配的块。

显示分配器需要一些数据结构来记录块的边界,块的位置和块的状态(空闲还是已分配)。一种简单的方法是使用隐式空闲链表,一种比较通用的方法是使用显示空闲链表。显示空闲链表对块的排序策略有两种:一种是用后进先出的顺序维护链表,一种是按照地址顺序维护链表。

隐式分配器的垃圾收集器将内存视为一张有向可达图,图中的节点分为根节点和堆节点。堆节点对应堆中的已分配块,根节点包含指向堆的指针。根节点的位置可以是寄存器,栈里的变量,或者虚拟内存中读写数据区域的全局变量。一个堆是垃圾,当且仅当从任意根节点到这个堆的堆节点都不可达。

7.10本章小结

本节介绍了几种地址空间的概念;介绍了逻辑地址到线性地址的转化,介绍了线性地址与物理地址之间的转化(分页机制);介绍了分页机制的原理和硬件优化;介绍了fork和execve中有关虚拟地址的操作;介绍了缺页故障及其处理,最后简述了动态存储分配管理。


8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行.

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

8.2 简述Unix IO接口及其函数

8.2.1 Unix I/O接口统一操作[2]:

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

Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。

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

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

关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。无论一个进程因为何种原因终止,内核都会在这个进程终止时关闭所有打开的文件并释放它们的内存资源

8.2.2 Unix I/O函数[2]:

进程通过调用open函数来打开一个存在的文件或是创建一个新文件的,函数原型是int open(char* filename,int flags,mode_t mode);。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。

int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。

ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。如果发生错误,返回值-1;返回值0表示EOF;其他情况返回值表示的是实际传送的字节数量。

ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。

8.3 printf的实现分析

       Linux中,printf的源码大致是这样的(表格 2)。通过va_start获取可变参数列表,实质也是通过地址操作,然后调用vsprintf,把格式化字符串根据参数列表生成实际显示用的字符串,最后使用调用write去调用Unix I/Owrite函数。

表格 2 printf的大致源码

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

    {

     va_list args;

     int i;

  

     va_start(args, fmt);

     write(1,printbuf,i=vsprintf(printbuf, fmt, args));

     va_end(args);

     return i;

    }

其中的vsprintf的源码大概是这样的(表格 3),这里只实现了打印16进制数的功能。 

表格 3 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);

   }

Unix I/O的write函数的汇编代码如下[4],其中INT_VECTOR_SYS_CALL是系统中断号,在x86中,对应0x80,CPU在接收到这个中断后,调用中断处理程序然后执行syscall。

    write:

     mov eax, _NR_write

     mov ebx, [esp + 4]

     mov ecx, [esp + 8]

         int INT_VECTOR_SYS_CALL

syscall的代码大致如下[4],其功能是将字符串写入显存vram中。

    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

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

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

8.4 getchar的实现分析

       getchar的简化版实现如下。

int getchar(void) {

    // 在标准输入流上读取一个字符

    unsigned char c;

    if (read(0, &c, 1) != 1) {

        // 处理读取错误

        return EOF;  // 表示文件末尾或错误

    }

    return (int)c;  // 返回读取的字符

}

getchar等调用read系统函数,通过系统调用读取键盘缓冲区的按键ascii码,直到接受到回车键才返回。键盘缓冲区的按键ascii码由键盘中断的处理程序输入。

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

8.5本章小结

本章简述了LINUX的IO设备管理,UNIX IO,对printf和getchar的实现进行了分析。


结论

在人类的感知中,从Hello.c到运行Hello最后结束Hello,只是弹指间的事情,但是这个一瞬间,经历了很多的计算。再次列出Hello人生的大事:

  1. 使用文本编辑器编写Hello.c
  2. 预处理器预处理Hello.c得到Hello.i
  3. 编译器编译Hello.i得到Hello.s
  4. 汇编器汇编Hello.s得到Hello.o可重定位目标文件
  5. 链接器把Hello.o与一些可重定位目标文件和库连接,得到可执行目标文件Hello
  6. 在shell输入./Hello,shell对命令行求值。
  7. shell调用fork函数创建新进程
  8. 新进程调用execve,execve删除已存在的用户区域,映射私有区域,映射共享区域,设置程序计数器。Hello进程开始运行
  9. 内核为Hello进程分配时间片,Hello被调度为运行状态,执行代码段的指令。
  10. Hello进程运行了时间片的时间,被内核挂起。。。
  11. 在Hello运行的过程中,MMU将虚拟地址翻译成物理地址。
  12. Hello进程在运行途中收到信号,如果是CTRL-Z就被挂起,如果是CTRL-C,就被终止。
  13. Hello进程终止后,shell作为其父进程收到SIGCHLD信号,回收Hello进程。

附件

Hello.i 预处理产生的文本文件,是编译的输入

Hello.s 编译产生的汇编代码文本文件,是汇编的输入

Hello.o 汇编产生的二级制的可重定位目标文件,是链接的输入

Hello.o.objdump 用objdump对Hello.o反汇编输出的文件,用于观察代码中需重定位的地方

Hello.o.txt 以Hello.o为输入,readelf -a 输出的文本文件,用于了解可重定位文件的结构

Hello 用ld链接Hello.o以及需要用到的动态库和.o文件生成的可执行目标文件,用于运行程序,观察动态链接过程。

Hello.objdump 用objdump对Hello反汇编输出的文件,与Hello.o.objdump一起观察重定位的影响

hello 用命令gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC Hello.c -o hello生成的可执行目标文件


参考文献

[1] Alfred V. Aho, Monica S. Lam, Ravi Sethi, Jeffrey D. Ullman. Compilers: inciples, Techniques, and Tools -2nd ed[M]. Pearson Addison Wesley, 2006

[2] 兰德尔·E,布莱恩特等. 深入理解计算机系统[M]. 北京:机械工业出版社,2016.7(2022.1重印)

[3] Randal E. Bryant, David R. O'Hallaron. Computer System A Programmer's Perspective -3rd ed[M]. Pearson, 2016

[4] 一篇博客园博客https://www.cnblogs.com/pianist/p/3315801.html

首次发表,时间紧张,如有纰漏还请指正。 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值