ICS大作业论文

在这里插入图片描述


文章目录


摘 要

本文以Hello程序为中心,阐述了Hello程序在Linux系统的生命周期,分析了从hello.c源代码文件经过预处理、编译、汇编、链接、执行以至终止的全过程,并结合课程所学知识说明了Linux操作系统如何对Hello程序进行进程管理、存储管理和I/O管理。

关键词:Hello程序;程序生命周期;操作系统管理;计算机系统课程


第1章 概述

1.1 Hello简介

用户在文本编辑器中编写代码得到hello.c,通过调试运行将会输出“Hello 学号 姓名 ” 。从程序运行到结束,大致有如下过程:
P2P(From Program to Process):用高级语言编写得到.c文件,再经过编译器进行预处理,把#include的文件写入文件,并且进行宏替换,生成hello.i;然后通过编译器ccl对其进行编译得到.s汇编语言文件;此后通过汇编器将.s文件翻译成机器语言,将指令打包成可重定位的.o目标文件,再通过链接器与库函数链接得到可执行文件hello,执行此文件,操作系统会为其fork产生子进程,再调用execve函数加载进程。至此,P2P结束。

020(From Zero-0 to Zero-0):操作系统调用execve函数(执行程序函数)后映射虚拟内存,先删除当前虚拟地址的数据结构并未hello创建新的区域结构,进入程序入口后载入物理内存,再进入main函数执行代码。执行完成后,父进程回收hello进程,内核删除相关数据结构,操作系统释放虚拟空间。所以进程从0到了0。

1.2 环境与工具

硬件环境:Intel Core i5 11300H CPU;16.00GB RAM
软件环境:Windows 10 64位;Ubuntu 16.04 LTS 64位
开发工具:CodeBlocks 64位;Visual Studio;GCC; EDB;

1.3 中间结果

文件名 文件作用
Hello.i 预处理后的文本文件
Hello.s 编译后汇编程序文本文件
Hello.o 汇编后的可重定位目标程序
Hello 链接后的可执行目标文件
elf.txt hello.o的ELF格式
elf_2.txt hello的ELF格式
objdump.txt hello.o的反汇编代码
objdump_2.txt hello的反汇编代码

1.4 本章小结

本章简要介绍了hello的P2P,O2O过程,并列出了本次实验的环境和中间结果。

第2章 预处理

2.1 预处理的概念与作用

2.1.1预处理的概念

预处理是指程序在编译一个c文件之前,会调用cpp将所有#起始的行解释为预处理指令,并且对源代码进行相应的转换,包括加入包含的源文件,处理宏定义或者处理条件语句等等,预处理阶段也会删除注释等等

2.1.2预处理的作用

预处理的作用主要可分为以下三部分:
(1) 宏展开:预处理程序中的“#define”标识符文本,用实际值(可以是字符串、代码等)替换用“#define”定义的字符串;
(2) 文件包含复制:预处理程序中用“#include”格式包含的文件,将文件的内容插入到该命令所在的位置并删除原命令,从而把包含的文件和当前源文件连接成一个新的源文件,这与复制粘贴类似;
(3) 条件编译处理:根据“#if”和“#endif”、“#ifdef”和“#ifndef”后面的条件确定需要编译的源代码。

2.2在Ubuntu下预处理的命令

以下两条命令均可实现预处理(效果等价,输出至文件hello.i):
 gcc -E hello.c -o hello.i
 cpp hello.c > hello.i
预处理命令处理截图:
在这里插入图片描述

图2.1 预处理指令一处理截图

在这里插入图片描述

图2.2 预处理指令二处理截图

2.3 Hello的预处理结果解析

原本的23行hello.c文件经过预处理环节,扩展成了3060行的ASCII码中间文本文件hello.i。具体解析如下:
首先是源代码文件等相关的一些信息(第1 ~ 7行),如下图:

在这里插入图片描述

图 2.3 预处理结果截图1(源代码文件信息)
随后是预处理扩展的内容(第13 ~ 3041行),部分内容见下图(图 2.4至图 2.6):

文件包含信息:

在这里插入图片描述

图2.4 预处理结果(文件包含信息)

类型定义信息:

在这里插入图片描述

图2.5 预处理结果截图(类型定义)
函数声明信息:

在这里插入图片描述

图2.6 预处理结果(函数声明)
预处理的具体过程如下(以stdio.h为例说明): 作为hello.c中包含的头文件,stdio.h是标准库文件(非标准库文件包含时一般使用双引号,cpp会在当前目录下进行查找),cpp到Linux系统的环境变量下寻找stdio.h,打开文件/usr/include/stdio.h,发现其中使用了“#define”、“#include”等,故cpp对它们进行递归展开替换,最终的hello.i文件中删除了原有的这部分;对于其中使用的“#ifdef”、“#ifndef”等条件编译语句,cpp会对条件值进行判断来决定是否对此部分进行包含。最终得到如上图(2-4包含信息、2-5类型定义及2-6函数声明)等部分。

在这里插入图片描述

图2.7 头文件路径
上图为hello.c包含的头文件在系统中的路径位置,cpp从这里读取、复制和处理这些头文件,将它们添加至hello.i。 最后的部分是hello.c中的源代码(第3043 ~ 3065行),除注释和“#include”语句被删除外,内容保持基本不变,如下图:

在这里插入图片描述

图2.8 预处理结果(保留的原码)

2.4 本章小结

本章主要介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析。C语言预处理一般由预处理器(cpp)进行,主要完成四项工作:宏展开、文件包含复制、条件编译处理和删除注释及多余空白字符,为之后的编译等流程奠定了基础。

第3章 编译

3.1 编译的概念与作用

3.1.1编译的概念

编译是将高级语言转换为汇编语言的过程。程序编译之前,C语言编译器会进行词法分析、语法分析(-fsyntax-only),接着会把源代码翻译成中间语言,即汇编语言。

3.1.2编译的作用

1.语法检查:检查源程序是否合乎语法。如果不符合语法,编译程序要指出语法错误的部位、性质和有关信息。编译程序应使用户一次上机,能够尽可能多地查出错误。
2.调试措施:检查源程序是否合乎设计者的意图。为此,要求编译程序在编译出的目标程序中安置一些输出指令,以便在目标程序运行时能输出程序动态执行情况的信息,如变量值的更改、程序执行时所经历的线路等。这些信息有助于用户核实和验证源程序是否表达了算法要求。
3.修改手段:为用户提供简便的修改源程序的手段。编译程序通常要提供批量修改手段(用于修改数量较大或临时不易修改的错误)和现场修改手段(用于运行时修改数量较少、临时易改的错误)。

3.2 在Ubuntu下编译的命令

编译命令:gcc -S hello.i -o hello.s
编译截图
在这里插入图片描述

图3.1 编译指令执行及得到汇编程序

3.3 Hello的编译结果解析

3.3.1 数据与赋值

(1) 常量数据
a) printf函数中用到的格式字符串、输出字符串被保存在.rodata段
 源程序代码:
第14行:printf(“用法: Hello 学号 姓名!\n”);
第18行:printf(“Hello %s %s\n”,argv[1],argv[2]);
 汇编代码(第3 ~ 8行):
.section .rodata
.align 8
.LC0:
.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”
.LC1:
.string “Hello %s %s\n”
b) if条件判断值、for循环终止条件值在.text段,运行时使用
 源程序代码:
第13行:if(argc!=4)
第17行:for(i=0;i<8;i++)
 汇编代码:
第24行:cmpl $4, -20(%rbp)
第53行:cmpl $7, -4(%rbp)

(2) 变量数据
局部变量i(4字节int型)在运行时保存在栈中,使用一条movl指令进行赋值,使用一条addl指令进行增一。
 源程序代码:
第11行:int i;
第17行:for(i=0;i<10;i++)
 汇编程序代码:
第31行(初始化):movl $0, -4(%rbp)
第51行(增一):addl $1, -4(%rbp)
由此可见,局部变量i在赋初值后被保存在地址为%rbp-4的栈位置上。

3.3.2 算术操作

在for循环体中,对循环变量i的更新使用了++自增运算,汇编代码翻译成addl指令(4字节int型对应后缀“l”):
源程序代码(17行):for(i=0;i<8;i++)
汇编代码对应操作(第51行):addl $1, -4(%rbp)

3.3.3 数组、指针、结构操作

主函数main()的第二个参数是char *argv[](参数字符串数组指针),在argv数组中,argv[0]为输入程序的路径和名称字符串起始位置,argv[1]和argv[2]为其后的两个参数字符串的起始位置。汇编代码中相关的指令如下:
.LFB6代码块中(第23行):movq %rsi, -32(%rbp)
这条指令将main()的第二个参数从寄存器写到了栈空间中。
.L4代码块中(第34 ~ 39行):

	movq	-32(%rbp), %rax
	addq	$16, %rax
	movq	(%rax), %rdx
	movq	-32(%rbp), %rax
	addq	$8, %rax
	movq	(%rax), %rax

这6条指令从栈上取这一参数,并按照基址-变址寻址法访问argv[1]和argv[2](由于指针char*大小为8字节,分别偏移8、16字节来访问)。

3.3.4 关系操作及控制转移

(1) 程序中if条件判断处的关系操作与控制转移:
源程序代码(第13行):if(argc!=4)
汇编代码对应操作(第24、25行):

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

je使用cmpl设置的条件码(ZF),若ZF = 0,说明argc等于4,条件不成立,控制转移至.L2(for循环部分,程序主体功能);若ZF = 1,说明argc不等于4(即执行程序时传入的参数个数不符合要求),继续执行输出提示信息并退出。
(2) 程序中for循环终止条件判断涉及的关系操作与控制转移:
源程序代码(第17行):for(i=0;i<8;i++)
汇编代码对应操作(第53、54行):

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

与(1)类似,此处jle使用cmpl设置的条件码(ZF SF OF),若(SF^OF) | ZF = 1,说明循环终止条件不成立(变量i的值小于或等于7),控制转移至.L4,继续执行循环体;若(SF^OF) | ZF = 0,则循环终止条件成立(变量i的值达到8),不再跳转至循环体开始位置,继续向后执行直至退出。
值得注意的是,源程序代码的逻辑与编译器翻译生成的逻辑有细微的差别。源代码中判断i<8,而编译器将其调整为判断i<=7,但实际上二者等价。

3.3.5 函数操作

源代码中的函数有main()函数,printf()函数(第一处被编译器优化为puts函数),exit()函数,sleep()函数,getchar()函数,以下为对每个函数的具体分析。
(1) main()函数:
a) 参数传递:int argc, char *argv[]
相关汇编代码:
.LFB6代码块中(第22、23行):

    movl    %edi, -20(%rbp)
     movq    %rsi, -32(%rbp)

由此可见,第一个参数通过寄存器EDI传递,第二个参数通过寄存器RSI传递,这一步将两个参数写入栈空间。
b) 函数调用:
被启动函数调用,hello.s中没有体现,但为汇编器进行相关处理提供了信息。
相关汇编代码(第12 ~ 20行):

main:
.LFB6:
    .cfi_startproc
    endbr64
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6

此部分汇编指令标记了程序入口等信息,应该是提供给汇编器。
c) 函数返回:正常情况返回0,参数个数不正确返回1。
正常情况相关汇编代码(第56、57行):

            movl    $0, %eax
            leave

返回1情况(调用exit()函数,第28、29行):

            movl    $1, %edi
            call    exit@PLT

(2) printf()函数:
a) 参数传递:需要输出的字符串(可能含格式)。
源代码(第14,18行):

	printf("用法: Hello 学号 姓名!\n"); 			
	printf("Hello %s %s\n",argv[1],argv[2]);

及对应汇编代码(第26 ~ 27、34 ~ 43行):

           leaq    .LC0(%rip), %rdi
            call    puts@PLT
            movq    -32(%rbp), %rax
            addq    $16, %rax
            movq    (%rax), %rdx
            movq    -32(%rbp), %rax
            addq    $8, %rax
            movq    (%rax), %rax
            movq    %rax, %rsi
            leaq    .LC1(%rip), %rdi
            movl    $0, %eax
            call    printf@PLT

注:从栈空间取argc[1]、argc[2],从只读数据段取格式/输出字符串,作为参数传递给printf()进行输出。
b) 函数调用:主函数通过call指令调用。
c) 函数返回:返回值被忽略。
(3) exit()函数:
a) 参数传递:退出状态值(int类型)
源代码(第15行):

	exit(1);

对应汇编代码(第28、29行):

            movl    $1, %edi
            call    exit@PLT

注:使用寄存器EDI传递参数(整数值1),调用exit()函数以状态1退出。
b) 函数调用:主函数通过call指令调用。
c) 函数返回:函数不返回,直接退出程序。
(4) atio()函数:
a) 参数传递:需要转换的字符(char)
b) 源代码(第24 行):

	sleep(atoi(argv[3]));

c) 对应汇编代码(第44 ~ 48行):

	movq	-32(%rbp), %rax
	addq	$24, %rax
	movq	(%rax), %rax
	movq	%rax, %rdi
	call	atoi@PLT

d) 函数调用:主函数通过call指令调用。
e) 函数返回:返回值又作为参数被sleep()函数调用。
注:从栈空间取argc[3],作为参数传递给atoi()进行转换。
(5) sleep()函数:
a) 参数传递:休眠时间(int类型)
源代码(第24行):

		sleep(atoi(argv[3]));

对应汇编代码(第48~ 50行):

	call	atoi@PLT
	movl	%eax, %edi
	call	sleep@PLT

注:调用函数atoi( )将char型的argv[3]转换为int型作为参数调用sleep()函数。
b) 函数调用:主函数通过call指令调用。
c) 函数返回:返回值被忽略(返回的是实际休眠时间)。
(6) getchar()函数:
a) 参数传递:无。
b) 函数调用:主函数通过call指令调用,相关汇编代码如下(第55行):
call getchar@PLT
c) 函数返回:返回char类型值,在此程序中被忽略。

3.4 本章小结

本章围绕hello.i经编译器处理得到hello.s的过程,介绍了编译的概念、过程并具体分析了Hello程序的编译结果。编译阶段分析检查源程序,确认所有的语句都符合语法规则后将其翻译成等价的汇编代码(中间代码)表示。完成本章内容的过程加深了我对编译阶段的理解, 

第4章 汇编

4.1 汇编的概念与作用

4.1.1汇编的概念

驱动程序运行(或直接运行)汇编器as,将汇编语言程序(这里指hello.s)翻译成机器语言指令,并将这些指令打包成可重定位目标文件(hello.o)的过程称为汇编,hello.o是二进制编码文件,包含程序的机器指令编码。

4.1.2汇编的作用

汇编过程将汇编程序转化为机器可直接识别执行的机器语言程序。

4.2 在Ubuntu下汇编的命令

以下两条命令效果等价:
 gcc -c hello.s -o hello.o
 as hello.s -o hello.o
汇编的命令执行及结果截图:
在这里插入图片描述

图4.1 汇编命令一执行结果

在这里插入图片描述

图4.2 汇编命令二执行结果

4.3 可重定位目标elf格式

4.3.1 readelf命令

使用命令:readelf -a hello.o > elf.txt
将hello.o中ELF格式相关信息重定向至文件elf.txt。
命令执行截图:

在这里插入图片描述

图4.3 readelf命令执行及结果

4.3.2 ELF头

此部分内容(elf.txt文件第1 ~ 20行)如下:
在这里插入图片描述

图4.4 ELF头
分析: (1) Magic用于标识ELF文件,7f 45 4c 46分别对应ASCII码的Del、字母E、字母L、字母F,操作系统在加载可执行文件时会确认是否正确,如果不正确则拒绝加载,其余标识位数、小/大端序、版本号等,后九个字节未定义; (2) 根据头文件的信息,可知该文件是可重定位目标文件,有14个节,其余部分的信息此处不再一一列举说明。

4.3.3 节头目表

此部分列出了hello.o中的14个节的名称、类型、地址、偏移量、大小等信息。具体内容(elf.txt文件第22 ~ 52行)如下:

在这里插入图片描述

图4.5 节头目表
分析: (1) 由于是可重定位目标文件,所以每个节都从0开始,用于重定位; (2) .text段是可执行的,但是不能写; (3) .data段和.rodata段都不可执行且.rodata段不可写; (4) .bss段大小为0。

4.3.4 重定位节

重定位节记录了各段引用的符号相关信息,在链接时,需要通过重定位节对这些位置的地址进行重定位。链接器会通过重定位条目的类型判断如何计算地址值并使用偏移量等信息计算出正确的地址。
具体内容(elf.txt文件第65 ~ 78行)如下:
在这里插入图片描述

图4.6 重定位节

分析:本程序需要重定位的符号有:.rodata,puts,exit,printf,sleepsecs,sleep,getchar及.text等。注意到重定位类型仅有R_X86_64_PC32(PC相对寻址)和R_X86_64_PLT32(使用PLT表寻址)两种,而未出现R_X86_64_32(绝对寻址)。

4.3.5 符号表

符号表(.symtab)存放在程序中定义和引用的函数和全局变量的信息。
具体内容(elf.txt文件第82 ~ 101行)如下:
在这里插入图片描述

图4.7 符号表

4.4 Hello.o的结果解析

命令:objdump -d -r hello.o > objdump.txt
在这里插入图片描述

图4.8 反汇编操作及结果

 反汇编代码(objdump.txt):


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


Disassembly of section .text:

0000000000000000 <main>:
   0:	f3 0f 1e fa          	endbr64 
   4:	55                   	push   %rbp
   5:	48 89 e5             	mov    %rsp,%rbp
   8:	48 83 ec 20          	sub    $0x20,%rsp
   c:	89 7d ec             	mov    %edi,-0x14(%rbp)
   f:	48 89 75 e0          	mov    %rsi,-0x20(%rbp)
  13:	83 7d ec 04          	cmpl   $0x4,-0x14(%rbp)
  17:	74 16                	je     2f <main+0x2f>
  19:	48 8d 3d 00 00 00 00 	lea    0x0(%rip),%rdi        # 20 <main+0x20>
			1c: R_X86_64_PC32	.rodata-0x4
  20:	e8 00 00 00 00       	callq  25 <main+0x25>
			21: R_X86_64_PLT32	puts-0x4
  25:	bf 01 00 00 00       	mov    $0x1,%edi
  2a:	e8 00 00 00 00       	callq  2f <main+0x2f>
			2b: R_X86_64_PLT32	exit-0x4
  2f:	c7 45 fc 00 00 00 00 	movl   $0x0,-0x4(%rbp)
  36:	eb 48                	jmp    80 <main+0x80>
  38:	48 8b 45 e0          	mov    -0x20(%rbp),%rax
  3c:	48 83 c0 10          	add    $0x10,%rax
  40:	48 8b 10             	mov    (%rax),%rdx
  43:	48 8b 45 e0          	mov    -0x20(%rbp),%rax
  47:	48 83 c0 08          	add    $0x8,%rax
  4b:	48 8b 00             	mov    (%rax),%rax
  4e:	48 89 c6             	mov    %rax,%rsi
  51:	48 8d 3d 00 00 00 00 	lea    0x0(%rip),%rdi        # 58 <main+0x58>
			54: R_X86_64_PC32	.rodata+0x22
  58:	b8 00 00 00 00       	mov    $0x0,%eax
  5d:	e8 00 00 00 00       	callq  62 <main+0x62>
			5e: R_X86_64_PLT32	printf-0x4
  62:	48 8b 45 e0          	mov    -0x20(%rbp),%rax
  66:	48 83 c0 18          	add    $0x18,%rax
  6a:	48 8b 00             	mov    (%rax),%rax
  6d:	48 89 c7             	mov    %rax,%rdi
  70:	e8 00 00 00 00       	callq  75 <main+0x75>
			71: R_X86_64_PLT32	atoi-0x4
  75:	89 c7                	mov    %eax,%edi
  77:	e8 00 00 00 00       	callq  7c <main+0x7c>
			78: R_X86_64_PLT32	sleep-0x4
  7c:	83 45 fc 01          	addl   $0x1,-0x4(%rbp)
  80:	83 7d fc 07          	cmpl   $0x7,-0x4(%rbp)
  84:	7e b2                	jle    38 <main+0x38>
  86:	e8 00 00 00 00       	callq  8b <main+0x8b>
			87: R_X86_64_PLT32	getchar-0x4
  8b:	b8 00 00 00 00       	mov    $0x0,%eax
  90:	c9                   	leaveq 
  91:	c3                   	retq   

	hello.s中对应的汇编代码(第12~ 60行):
main:
.LFB6:
	.cfi_startproc
	endbr64
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$32, %rsp
	movl	%edi, -20(%rbp)
	movq	%rsi, -32(%rbp)
	cmpl	$4, -20(%rbp)
	je	.L2
	leaq	.LC0(%rip), %rdi
	call	puts@PLT
	movl	$1, %edi
	call	exit@PLT
.L2:
	movl	$0, -4(%rbp)
	jmp	.L3
.L4:
	movq	-32(%rbp), %rax
	addq	$16, %rax
	movq	(%rax), %rdx
	movq	-32(%rbp), %rax
	addq	$8, %rax
	movq	(%rax), %rax
	movq	%rax, %rsi
	leaq	.LC1(%rip), %rdi
	movl	$0, %eax
	call	printf@PLT
	movq	-32(%rbp), %rax
	addq	$24, %rax
	movq	(%rax), %rax
	movq	%rax, %rdi
	call	atoi@PLT
	movl	%eax, %edi
	call	sleep@PLT
	addl	$1, -4(%rbp)
.L3:
	cmpl	$7, -4(%rbp)
	jle	.L4
	call	getchar@PLT
	movl	$0, %eax
	leave
	.cfi_def_cfa 7, 8
	Ret

比较分析:
二者总体相同,但也有一些细微的差异:
(1) 分支控制转移不同:对于跳转语句跳转的位置,hello.s中是.L2、.LC1等代码块的名称,而反汇编代码中跳转指令跳转的位置是相对于main函数起始位置偏移的地址(相对地址);
(2) 函数调用表示不同:hello.s中,call指令使用的是函数名,而反汇编代码中call指令使用的是待链接器重定位的相对偏移地址,这些调用只有在链接之后才能确定运行时的实际地址,因此在.rela.text节中为其添加了重定位条目;
(3) hello.s中的全局变量、printf字符串等符号被替换成了待重定位的地址;
(4) 数的表示不同:hello.s中的操作数均为十进制,而hello.o反汇编代码中的操作数被转换成十六进制;
(5) hello.s中提供给汇编器的辅助信息在反汇编代码中不再出现,可能是在汇编器处理过程中被移除,如“.cfi_def_cfa_offset 16”等。

4.5 本章小结

本章对汇编的概念、作用、可重定向目标文件的结构及对应反汇编代码等进行了较为详细的介绍。经过汇编阶段,汇编语言代码转化为机器语言,生成的可重定位目标文件(hello.o)为随后的链接阶段做好了准备。完成本章内容的过程加深了我对汇编过程、ELF格式以及重定位的理解。

第5章 链接

5.1 链接的概念与作用

5.1.1 链接的概念

链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时、执行时甚至运行时。在现代系统中,链接是由叫做链接器的程序自动执行的。
在此处,链接是指将可重定向目标文件hello.o与其他一些文件组合成为可执行目标文件hello。

5.1.2 链接的作用

链接使分离编译成为可能,我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解成更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。

5.2 在Ubuntu下链接的命令

ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o hello.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -o hello

在这里插入图片描述

图5.1 命令执行及结果
### 5.3 可执行目标文件hello的格式 使用命令:readelf -a hello > elf_2.txt 将hello中ELF格式相关信息重定向至文件elf_2.txt。

在这里插入图片描述

图5.2 readelf命令执行及结果
各段的基本信息(起始地址、大小等)记录在节头部表中,具体内容(hello1_elf.txt第22 ~ 78行)如下:

在这里插入图片描述

图5.3elf_2节头表

5.4 hello的虚拟地址空间

5.4.1 edb加载hello

在这里插入图片描述

图5.4 edb加载hello

5.4.2 与5.3对照分析说明

通过edb左上方选项卡 “ Plugins - SymbolViewer”查看本进程的虚拟地址空间各段信息,与5.3对照分析可知一致 ;
Symbol窗口截图如下:
在这里插入图片描述

图5.5 Symbol窗口

5.5 链接的重定位过程分析

命令:objdump -d -r hello > objdump_2.txt
在这里插入图片描述

图5.6 命令执行及结果
分析:

(1) hello的反汇编代码比hello.o的反汇编代码多了一些节(如.init, .plt, .plt.sec等),详细内容见附件objdump.txt与objdump_2.txt。

(2) hello中加入了一些函数,如_init(),_start()以及一些主函数中调用的库函数,详细内容见附件objdump.txt与objdump_2.txt。

(3) hello中不再存在hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址,详细内容见附件objdump.txt与objdump_2.txt。

根据以上分析我们可以看出,链接过程会扫描分析所有相关的可重定位目标文件,并完成两个主要任务:首先进行符号解析,将每个符号引用与一个符号定义关联起来;随后进行重定位,链接器使用汇编器产生的重定位条目的详细指令,把每个符号定义与一个内存位置关联起来。最终的结果是将程序运行所需的各部分组装在一起,形成一个可执行目标文件。

5.6 hello的执行流程

使用edb执行hello,可以直接观察到各个子程序名或程序地址(如下图红色箭头标出)

图5.7 edb 加载hello

子程序名地址
hello!_init0x00401000
hello!puts@plt0x00401030
hello!printf@plt0x00401040
hello!gentchar@plt0x00401050
hello!atoi@plt0x00401060
hello!exit@plt0x00401070
hello!sleep@plt0x00401080
hello!_start0x004010f0
hello!_dl_relocate_static_pie0x00401120
hello!main0x00401125
hello!_libc_csu_init0x004011c0
hello!_libc_csu_fini0x00401230

5.7 Hello的动态链接分析

下图为调用dl_init之前.got.plt段的内容:

在这里插入图片描述

图 5-8 调用dl_init之前.got.plt段的内容
下图为调用dl_init之后.got.plt段的内容:

在这里插入图片描述

图 5-9 调用dl_init之后.got.plt段的内容
可以很明显地看出第2、3行的变化。 实际上,这是书上(P490)提到的动态链接器的延迟绑定的初始化部分。延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)的协同工作实现函数的动态链接,其中GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。 在此之后,程序调用共享库函数时,会首先跳转到PLT执行指令,第一次跳转时,GOT条目为PLT下一条指令,将函数ID压栈,然后跳转到PLT[0],在PLT[0]再将重定位表地址压栈,然后转进动态链接器,在动态链接器中使用两个栈条目确定函数运行时地址,重写GOT,再将控制传递给目标函数。以后如果再次调用同一函数,则通过间接跳转将控制直接转移至目标函数。

5.8 本章小结

本章围绕可重定位目标文件hello.o链接生成可执行目标文件hello的过程,首先详细介绍、分析了链接的概念、作用及具体工作。随后验证了hello的虚拟地址空间与节头部表信息的对应关系,分析了hello的执行流程。最后对hello程序进行了动态链接分析。

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1 进程的概念

狭义定义:进程是正在运行的程序的实例(an instance of a computer program that is being executed)。
广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

6.1.2 进程的作用

1)在现代计算机中,进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序 一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行 我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
2)每次用户通过向shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
3)进程提供给应用程序两个关键抽象:一个独立的逻辑控制流;一个私有的地址空间。

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

6.2.1 Shell-bash的作用

Shell-bash是一个交互型应用级程序,代表用户运行其他程序。它是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。

6.2.2 Shell-bash的处理流程

shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。
简化处理流程:
(1) 从终端读入输入的命令;
(2) 将输入字符串切分获得所有的参数;
(3) 如果是内置命令则立即执行;
(4) 若不是则调用相应的程序执行;
(5) shell应该随时接受键盘输入信号,并对这些信号进行相应处理。

6.3 Hello的fork进程创建过程

执行中的进程调用fork()函数,就创建了一个子进程。其函数原型为pid_t fork(void);对于返回值,若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。
根据shell的处理流程,键入命令后,shell判断其不是内部指令,即会通过fork函数创建子进程。子进程与父进程近似,会得到一份与父进程用户级虚拟空间相同且独立的副本——包括数据段、代码、共享库、堆和用户栈等,父进程打开的文件,子进程也可读写。二者之间最大的不同在于PID的不同。
如果画出进程图
------------------------------->新的子进程
|
---------+------------------------------>父进程
fork()

6.4 Hello的execve过程

调用函数fork创建新的子进程之后,子进程会调用execve函数,在当前进程的上下文中加载并运行一个新程序hello。execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。只有出现错误时(例如找不到可执行目标文件hello),execve才会返回到调用程序(这里与调用一次返回两次的fork函数不同)。
execve函数在加载了hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数(即main函数),该主函数原型如下:
int main(int argc, char **argv, char *envp)
execve函数的执行过程会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。
注:exceve过程中具体的内存映射可见本文7.7节。

6.5 Hello的进程执行

6.5.1上下文

内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。

6.5.2 进程上下文切换

在内核调度了一个新的进程运行时,它就抢占当前进程,并使用一种上下文切换的机制来控制转移到新的进程。具体过程为:①保存当前进程的上下文;②恢复某个先前被抢占的进程被保存的上下文;③将控制传递给这个新恢复的进程。

6.5.3 进程时间片

一个进程执行它的控制流的一部分的每一个时间段叫做时间片(time slice),多任务也叫时间分片(time slicing)。
进程调度:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这种决策称为调度,是由内核中的调度器代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
hello程序调用sleep函数休眠时,内核将通过进程调度进行上下文切换,将控制转移到其他进程。当hello程序休眠结束后,进程调度使hello程序重新抢占内核,继续执行。

6.5.4 用户态与核心态的转换:

为了保证系统安全,需要限制应用程序所能访问的地址空间范围。因而存在用户态与核心态的划分,核心态拥有最高的访问权限,而用户态的访问权限会受到一些限制。处理器使用一个寄存器作为模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,一定程度上保证了系统的安全性;如进程hello初始运行在用户模式中,直到它通过执行系统调用函数sleep或者exit时便陷入到内内核。

在这里插入图片描述

图6.1 进程执行示意图

6.5.5 hello程序进程执行及示意图

接下来分析 hello 的进程调度,hello的一个上下文切换是调用sleep函数时,hello 显式地请求休眠,控制转移给另一个进程,此时计时器开始计时,当计时器到达argv[3],即1s时,它会产生一个中断信号,中断当前正在进行的进程,进行上下文切换,恢复 hello 在休眠前的上下文信息,控制权回到 hello 继续执行。当循环结束后,hello 调用 getchar 函数,之前 hello 运行在用户模式下,在调用 getchar 时进入内核模式,内核中的陷阱处理程序请求来自键盘缓冲区的 DMA传输,并执行上下文切换,并把控制转移给其他进程。当完成键盘缓冲区到内存的数据传输后,引发一个中断信号,此时内核从其他进程切换回 hello 进程,然后 hello执行 return,进程终止。

在这里插入图片描述

图6.2 hello程序执行示意图

6.6 hello的异常与信号处理

6.6.1 hello执行过程中会出现的异常

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

  1. 中断是来自 I/O 设备的信号,异步发生,中断处理程序对其进行处理,返 回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
    在这里插入图片描述
图 6.3 中断处理
2. 陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指 令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。

在这里插入图片描述

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

在这里插入图片描述

图 6.5 故障处理
4. 终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程 序会将控制返回给一个 abort 例程,该例程会终止这个应用程序。 ### 6.6.2 hello执行过程中对各种异常和信号的处理。 (1) 正常运行 程序正常执行,总共循环8次每次输出提示信息之后等待我们从命令行输入的秒数,最后需要输入一个字符回车结束程序。

在这里插入图片描述

图6.6 hello正常执行

(2) 中途按下ctrl-z
内核向前台进程发送一个SIGSTP信号,前台进程被挂起,直到通知它继续的信号到来,继续执行。当按下fg 1 后,输出命令行后,被挂起的进程从暂停处,继续执行。
在这里插入图片描述

图6.7 hello执行中途按下ctrl-z

(3) 中途按下ctrl-c
内核向前台进程发送一个SIGINT信号,前台进程终止,内核再向父进程发送一个SIGCHLD信号,通知父进程回收子进程,此时子进程不再存在
在这里插入图片描述

图6.8 hello执行中途按下ctrl-c

(4) 运行途中乱按
运行途中乱按后,只是将乱按的内容输出,程序继续执行,但是我们所输入的内容到第一个回车之前会当做getchar缓冲掉,后面的输入会简单的当做我们即将要执行的命令出现在shell的命令行处。
下图中;为方便输入,将sleep时间设为2依次输入:
ads(后面没回车)
axs(后面有一个回车)
回车
会得到如图输出,印证上面分析。

在这里插入图片描述

图6.9 hello执行中途乱按
(5) 打印进程树 详细显示从开机开始的各个进程父子关系,以一颗树的形式展现。

在这里插入图片描述

在这里插入图片描述

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

图6.10 进程树
(6) Jobs列出当前的任务 Jobs:打印进程状态信息 ![在这里插入图片描述](https://img-blog.csdnimg.cn/c24a47266b7b47a28c18a0f7d1ef10b1.png#pic_center)
图6.11 jobs打印进程状态信息
(7) 输入kill kill之后会根据不同的发送信号的值,以及要发送的进程的pid发送相应的信号,这里我们将hello杀死。

在这里插入图片描述

图6.12 用kill杀死hello进程

6.7本章小结

本章介绍了hello的进程管理。进程是一个执行中的程序的实例,它提供两个关键抽象:好像我们的程序独占地使用处理器,好像我们的程序独占地使用内存系统。每个进程都处在某个进程的上下文中,每个进程也都有属于自己的上下文,用于操作系统通过上下文切换进行进程调度。用户通过shell和操作系统交互,向内核提出请求,shell通过fork函数和execve函数来运行可执行文件。

第7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1物理地址(physical address)

物理地址用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相相应。是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。

7.1.2逻辑地址(Logical Address)

逻辑地址是指由程式产生的和段相关的偏移地址部分。表示为 [段标识符:段内偏移量]。

7.1.3线性地址(Linear Address)

线性地址是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。

7.1.4虚拟地址(Virtual Address)

虚拟内存为每个程序提供了一个大的、一致的和私有的地址空间。其每个字节对应的地址成为虚拟地址。虚拟地址包括 VPO(虚拟页面偏移量)、VPN(虚拟页号)、TLBI(TLB 索引)、TLBT(TLB 标记)。

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

为了运用所有的内存空间,Intel 8086设定了四个段寄存器,专门用来保存段地址:CS(Code Segment):代码段寄存器;DS(Data Segment):数据段寄存器;SS(Stack Segment):堆栈段寄存器;ES(Extra Segment):附加段寄存器。当一个程序要执行时,就要决定程序代码、数据和堆栈各要用到内存的哪些位置,通过设定段寄存器CS,DS,SS来指向这些起始位置。通常是将DS固定,而根据需要修改CS。所以,程序可以在可寻址空间小于64K的情况下被写成任意大小。所以,程序和其数据组合起来的大小,限制在DS所指的64K内,这就是COM文件不得大于64K的原因。段寄存器是因为对内存的分段管理而设置的。计算机需要对内存分段,以分配给不同的程序使用(类似于硬盘分页)。在描述内存分段时,需要有如下段的信息:1.段的大小;2.段的起始地址;3.段的管理属性(禁止写入/禁止执行/系统专用等)。
逻辑地址空间表示:段地址:偏移地址。段地址+偏移地址=线性地址
 保护模式(如今大多数机器已经不再支持):
段寄存器的唯一目的是存放段选择符,其前13位是一个索引号,后面3位包含一些硬件细节(还有一些隐藏位,此处略)。
寻址方式为:以段选择符作为下标,到GDT/LDT表(全局段描述符表(GDT)和局部段描述符表(LDT))中查到段地址,段地址+偏移地址=线性地址。
 实模式:
段寄存器含有段值,访问存储器形成物理地址时,处理器引用相应的某个段寄存器并将其值乘以16,形成20位的段基地址,段基地址·段偏移量=线性地址。
逻辑地址 CS:EA=CS*16+EA 物理地址

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

Linux下,虚拟地址到物理地址的转化与翻译是依靠页式管理来实现的,虚拟内存作为内存管理的工具。概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。磁盘上数组的内容被缓存在物理内存中(DRAM cache)这些内存块被称为页 (每个页面的大小为P = 2p字节)。
而分页机制的作用就是通过将虚拟和物理内存分页,并且通过MMU建立起相应的映射关系,可以充分利用内存资源,便于管理。一般来说一个页面的标准大小是4KB,有时可以达到4MB。而且虚拟页面作为磁盘内容的缓存,有以下的特点:DRAM缓存为全相联,任何虚拟页都可以放置在任何物理页中需要一个更大的映射函数,不同于硬件对SRAM缓存更复杂精密的替换算法太复杂且无限制以致无法在硬件上实现DRAM缓存总是使用写回,而不是直写。
虚拟页面地集合被分为三个不相交的子集:已缓存、未缓存和未分配。

页表实现从虚拟页到物理页的映射,依靠的是页表,页表就是是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。这个页表是常驻与主存中的。

在这里插入图片描述

下图展示了页式管理中虚拟地址到物理地址的转换:
在这里插入图片描述

图7.1 页式管理中虚拟地址到物理地址的转换
下图a展示了当页面命中时,CPU硬件执行的步骤: 第1步:处理器生成一个虚拟地址,并把它传送给MMU; 第2步:MMU生成PTE地址,并从高速缓存/主存请求得到它; 第3步:高速缓存/主存向MMU返回PTE; 第4步:MMU构造物理地址,并把它传送给高速缓存/主存; 第5步:高速缓存/主存返回所请求的数据字给处理器

在这里插入图片描述

处理缺页如图b所示:
第1~3步:和图a中的第1步到第3步相同;
第4步:PTE中的有效位是零,所以MMU触发了一次异常,传给CPU中的控制到操作系统内核中的缺页异常处理程序;
第5步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘;
第6步:缺页处理程序页面调入新的页面,并更新内存中的PTE;
第7步:缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,在MMU执行了图b中的步骤之后,主存就会将所请求字返回给处理器。

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

7.4.1 TLB:

每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降到1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。
多级页表:
多级页表为层次结构,用于压缩页表。这种方法从两个方面减少了内存要求。第一,如果一级页表中的一个PTE是空的,那么相应的二级页表就根本不会存在;第二,只有一级页表才需要总是在主存中,虚拟内存系统可以在需要时创建、页面调出或调入二级页表,最经常使用的二级页表才缓存在主存中,减少了主存的压力。

7.4.2 VA到PA的变换:

对于四级页表,虚拟地址(VA)被划分为4个VPN和1个VPO。每个VPN i都是一个到第i级页表的索引。对于前3级页表,每级页表中的每个PTE都指向下一级某个页表的基址。最后一级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE。和只有一级的页表结构一样,PPO和VPO是相同的。
示意图如下(Core i7为例):
在这里插入图片描述

图 7.2 VA到PA的变换示意图

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

下图为通用的高速缓存存储器(Cache)组织结构示意图:
在这里插入图片描述

图 7.3 高速缓存存储器组织结构示意
(1) 根据PA、L1高速缓存的组数和块大小确定高速缓存块偏移(CO)、组索引(CI)和高速缓存标记(CT),使用CI进行组索引,对组中每行的标记与CT进行匹配。如果匹配成功且块的valid标志位为1,则命中,然后根据CO取出数据并返回数据给CPU。

(2) 若未找到相匹配的行或有效位为0,则L1未命中,继续在下一级高速缓存(L2)中进行类似过程的查找。若仍未命中,还要在L3高速缓存中进行查找。三级Cache均未命中则需访问主存获取数据。

(3) 若进行了(2)步,说明至少有一级高速缓存未命中,则需在得到数据后更新未命中的Cache。首先判断其中是否有空闲块,若有空闲块(有效位为0),则直接将数据写入;若不存在,则需根据替换策略(如LRU、LFU策略等)驱逐一个块再写入。

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的页面标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。
在这里插入图片描述

图7.4进程间的内存映射

7.7 hello进程execve时的内存映射

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

  1. 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的 区域结构。
  2. 映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构, 所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
  3. 映射共享区域,hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到 这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
  4. 设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下 文的程序计数器,使之指向代码区域的入口点。
    7.8 缺页故障与缺页中断处理
    DRAM缓存不命中称为缺页,即虚拟内存中的字不在物理内存中。CPU引用了虚拟页的一个字,地址翻译硬件从内存中读取了该虚拟页对应的页表条目,从有效位推断出该页未被缓存,这样就触发了一个缺页异常,缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,把要缓存的页缓存到牺牲 页的位置。如果这个牺牲页被修改过,就把它交换出去。当缺页处理程序返回时, CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。
    下图对VP3的引用不命中,从而触发缺页。
    在这里插入图片描述
图7.5 对VP3的引用不命中
缺页之后,缺页处理程序选择VP4作为牺牲页,并从磁盘上用VP3的副本取代它。在缺页处理程序重新启动导致缺页的指令之后,该指令将从内存中正常地读取字,而不会再产生异常

在这里插入图片描述

图7.6 缺页异常处理

下图为缺页异常处理过程示意图:
在这里插入图片描述

图 7.7 缺页异常处理过程示意

7.9动态存储分配管理

动态内存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种风格——显式分配器和隐式分配器。C语言中的malloc程序包是一种显式分配器。显式分配器必须在一些相当严格的约束条件下工作:①处理任意请求序列;②立即响应请求;③只使用堆;④对齐块(对齐要求);⑤不修改已分配的块。在以上限制条件下,分配器要最大化吞吐率和内存使用率。
常见的放置策略:
 首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块。
 下一次适配:类似于首次适配,但从上一次查找结束的地方开始搜索。
 最佳适配:选择所有空闲块中适合所需请求大小的最小空闲块。
这里简要介绍一些组织内存块的方法:
(1) 隐式空闲链表:空闲块通过头部中大小字段隐含连接,可添加边界标记提高合并空闲块的速度。
(2) 显式空闲链表:在隐式空闲链表块结构的基础上,在每个空闲块中添加一个pred(前驱)指针和一个succ(后继)指针。
(3) 分离的空闲链表:将块按块大小划分大小类,分配器维护一个空闲链表数组,每个大小类一个空闲链表,减少分配时间同时也提高了内存利用率。C语言中的malloc程序包采用的就是这种方法。
(4) 红黑树等树形结构:按块大小将空闲块组织为树形结构,同样有减少分配时间和提高内存利用率的作用。

7.10本章小结

虚拟内存是对主存的一个抽象。支持虚拟内存的处理器通过使用一种叫做虚拟内存寻址的间接形式来引用主存。处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。
虚拟内存提供三个功能:简化了内存保护;简化了内存管理;在主存中自动缓存最近使用的存放在磁盘上的虚拟地址空间的内容。
地址翻译的过程必须和系统中的所有的硬件缓存的操作集成在一起。
内存映射为共享数据、创建进程以及加载程序提供了一种高效的机制。
动态内存分配器直接操作内存,无需类型系统的很多帮助。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

8.1.1设备的模型化——文件

所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件。
例如:/dev/sda2文件是用户磁盘分区,/dev/tty2文件是终端。

8.1.2设备管理——Unix IO接口

将设备模型化为文件的方式允许Linux内核引入一个简单、低级的应用接口,称为Unix IO,这使得所有的输入和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

8.2.1 Unix IO接口:

(1) 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息,应用程序只需要记住这个描述符。
(2) Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h> 定义了常量STDIN_FILENO、STOOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
(3) 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
(4) 读写文件:一个读操作就是从文件复制 个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当 时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测这个条件。在文件末尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制 个字节到一个文件,从当前文件位置k开始,然后更新k。
(5) 关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

8.2.2 Unix I/O 函数:

  1. int open(char *filename, int flags, mode_t mode);
    open 函数将 filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符,flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
  2. int close(int fd);
    关闭一个打开的文件。
  3. ssize_t read(int fd, void *buf, size_t n);
    read 函数从描述符为 fd 的当前文件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0 表示 EOF,否则返回值表示的是实际 传送的字节数量。
  4. ssize_t write(int fd, const void *buf,size_t);
    write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件 位置。
  5. lseek()函数:
    通过调用此函数,应用程序能够显式地修改当前文件的位置。此部分不在教材的讲述范围之内。
    8.3 printf的实现分析
    8.3.1printf函数
     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;
}

 分析:
a) printf函数调用了vsprintf函数,最后通过系统调用函数write进行输出;
b) va_list是字符指针类型;
c) ((char *)(&fmt) + 4)表示…中的第一个参数。
8.3.2 printf调用的vsprintf函数
 Veprintf函数体:

int vsprintf(char *buf, const char *fmt, va_list args)
{
    char *p;
    chartmp[256];
    va_listp_next_arg = args;
    for (p = buf; *fmt; fmt++)
    {
        if (*fmt != '%')
        {
            *p++ = *fmt;
            continue;
        }
        fmt++;
        switch (*fmt)
        {
        case 'x':
            itoa(tmp, *((int *)p_next_arg));
            strcpy(p, tmp);
            p_next_arg += 4;
            p += strlen(tmp);
            break;
        case 's':
            break;
        /* 这里应该还有一些对于
        其他格式输出的处理 */
        default:
            break;
        }
        return (p - buf);
    }
}

 分析:
vsprintf 的功能就是将 printf 的参数按照各种各种格式进行分析,将要输出的字符串存在 buf 中,最终返回要输出的字符串的长度。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出写入buf供系统调用write输出时使用。
8.3.3write系统调用:
 Write函数:

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

 分析:
这里通过几个寄存器进行传参,随后调用中断门int INT_VECTOR_SYS_CALL
即通过系统来调用sys_call实现输出这一系统服务。
8.3.4 sys_call部分内容:
 函数

sys_call:
     /* 
      * ecx中是要打印出的元素个数
      * ebx中的是要打印的buf字符数组中的第一个元素
      * 这个函数的功能就是不断的打印出字符,直到遇到:'\0'
      * [gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
      */
     xor si,si
     mov ah,0Fh
     mov al,[ebx+si]
     cmp al,'\0'
     je .end
     mov [gs:edi],ax
     inc si
    loop:
     sys_call
   
    .end:
     ret 

 分析:
通过逐个字符直接写至显存,输出格式化的字符串。

8.3.5 字符显示驱动子程序

从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

8.3.6 输出打印

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。而我们要传输的“hello 120L021620 王立亮”就会被打印输出在显示器上。

8.4 getchar的实现分析

当程序运行至getchar函数时,程序通过系统调用read等待用户键入字符并按回车键(通知系统输入完成),一种getchar函数的实现如下:
#include "sys/syscall.h"
#include <stdio.h>
int getchar(void)
{
    char c;
    return (read(0,&c,1)==1)?(unsigned char)c:EOF
    //EOF定义在stdio.h文件中
}

当用户键入回车之后,getchar通过系统调用read从输入缓冲区中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1。
异步异常——键盘中断(用户输入)的处理:键盘中断处理子程序接受按键扫描码并转成ASCII码,保存在系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接收到回车键才返回。

8.5本章小结

本章主要关注Linux系统中的I/O管理,阐述了Linux操作系统的IO设备管理方法——设备被模型化为文件并使用Unix IO接口进行文件操作,最后还分析了printf函数和getchar函数的实现。从此章的内容中我们能体会到Unix IO接口在Linux系统中的重要作用,同时也了解了作为异步异常之一的键盘中断的处理。

结论

hello程序历程总结

hello程序虽然是一个简短的C语言程序,但它的产生、执行、终止和回收离不开计算机系统各方面的协同工作,具体过程如下:

  1. hello.c源代码文件通过C语言预处理器的预处理,得到了调整、展开后的ASCII文本文件hello.i;
  2. hello.i经过编译器的编译得到汇编代码文件hello.s;
  3. hello.s经过汇编器的汇编得到可重定向目标文件hello.o;
  4. hello.o经过链接器的链接过程成为可执行目标文件hello;
  5. 用户在shell-bash中键入执行hello程序的命令后,shell-bash解释用户的命令,找到hello可执行目标文件并为其执行fork创建新进程;
  6. fork得到的新进程通过调用execve完成在其上下文中对hello程序的加载,hello开始执行;
  7. hello作为一个进程运行,接受内核的进程调度(调用sleep后内核进行上下文切换,调度其他进程执行);
  8. hello执行的过程中,可能发生缺页异常等故障、系统调用等陷阱以及接收到各种信号,这些都需要操作系统与硬件设备的协同工作进行处理;
  9. hello执行的过程中会访问其虚拟空间内的指令和数据,需要借助各种硬件、软件机制来快速、高效完成;
  10. hello运行时要调用printf、getchar等函数,这些函数的实现与Linux系统IO设备管理、Unix IO接口等息息相关;
  11. hello程序运行结束后,父进程shell-bash会进行回收,内核也会清除在内存中为其创建的各种数据结构和信息。

感悟:

1,计算机系统学习过程感觉是:由厚到更厚到更更厚到薄。内容虽然繁多,但逻辑结构清晰、层次分明。还要多打磨多学习
2,计算机系统学问很深。尤其是当我看到一个简简单单的hello的printf函数就包含了如此多的知识后,真的是麻雀虽小五脏俱全!
3. CSAPP这本教材很好,引领我从程序员的角度第一次系统地、全面地认识了现代操作系统的各种机制、设计和运行原理。真不愧是“奇(qi)书”。

附件

文件名文件作用
Hello.i预处理后的文本文件
Hello.s编译后汇编程序文本文件
Hello.o汇编后的可重定位目标程序
Hello链接后的可执行目标文件
elf.txthello.o的ELF格式
elf_2.txthello的ELF格式
objdump.txthello.o的反汇编代码
objdump_2.txthello的反汇编代码

参考文献
[1] GCC online documentation. http://gcc.gnu.org/onlinedocs/
[2] Randal E. Bryant, David R. O’Hallaon. 深入理解计算机系统. 第三版. 北京市:机械工业出版社[M]. 2018: 1-737
[3] ELF文件头结构. CSDN博客. https://blog.csdn.net/king_cpp_py/article/details/80334086
[4] 逻辑地址、线性地址与物理地址. GitHub Blog. https://vosamo.github.io/2016/01/VA2PA/
[5] 深入理解计算机系统-之-内存寻址(三)–分段管理机制(段描述符,段选择子,描述符表). CSDN博客.
https://blog.csdn.net/gatieme/article/details/50647000
[6] printf函数实现的深入剖析. 博客园. https://www.cnblogs.com/pianist/p/3315801.html
[7] read和write系统调用以及getchar的实现. CSDN博客. https://blog.csdn.net/ww1473345713/article/details/51680017
[8] 酷勤网. C语言预处理命令之条件编译. 2009:08-16. http://www.kuqin.com/language/20090806/66164.html
[9] 乐于其中. CSDN. 编译器工作流程详解. 2014:04-27. https://blog.csdn.net/u012491514/article/details/24590467
[10] 网络用户. 阿里云. ELF格式文件符号表全解析及readelf命令使用方法. 2018:07-19. https://www.aliyun.com/zixun/wenji/1246586.html
[11] ORACLE.链接程序和库指南. https://docs.oracle.com/cd/E38902_01/html/E38861/chapter6-54839.html#gentextid-15180

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值