计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2022113363
班 级 2203101
学 生 张渊
指 导 教 师 史先俊
计算机科学与技术学院
2023年4月
追踪了hello程序完整的一生,从预处理、编译、汇编、链接生成可执行文件,到其执行过程中的进程创建、内存管理、异常与信号处理、IO等等过程,体会了hello程序P2P、020的含义,感受简单的程序在计算机系统上运行的复杂过程和精妙设计,综合梳理计算机系统的知识和程序的执行过程。
关键词:计算机系统;C语言;程序运行;进程
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
7.1 hello的存储器地址空间............................................................................ - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理....................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 11 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 11 -
7.6 hello进程fork时的内存映射.................................................................. - 11 -
7.7 hello进程execve时的内存映射.............................................................. - 11 -
7.8 缺页故障与缺页中断处理........................................................................... - 11 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
第1章 概述
1.1 Hello简介
hello.c程序从一个C语言源文件经过一系列过程被生成为一个可执行程序,再通过被创建为一个进程而完成执行,其数据被加载又被清除,即为Hello的P2P和020的过程。其过程依次为:预处理器处理预处理指令而生成预处理后的程序hello.i;然后编译器将其编译为汇编程序hello.s;接着汇编器将其汇编为机器指令,生成目标文件hello.o;最后通过链接器产生最终的可执行文件hello。由此hello.c便真正从一个源文件变成了一个可以运行的程序。接着通过shell来运行hello,shell调用fork函数来创建子进程并用execve来加载程序,hello程序便在这个进程上下文中顺利运行,在运行结束后其资源被回收,进程被清除,由此hello没有留下痕迹,完成了从0到0的过程。
1.2 环境与工具
硬件环境为Intel(R) Core(TM) i5-8300H CPU @ 2.30GHz,我的Linux环境为Ubuntu 22.04 LTS,使用gcc来对程序进行编译,并通过objdump、edb等工具对程序进行调试。采用Visual Studio Code来编写代码、查看文本文件,使用的shell为oh my zsh。
1.3 中间结果
hello.i:预处理后的程序代码文件;
hello.s:编译后的汇编代码文件;
hello.o:汇编后的程序目标文件;
hello.elf:hello.o的elf输出结果;
hello.asm:hello.o的反汇编;
hello:可执行程序;
hello2.elf:hello的elf输出结果;
hello2.asm:hello的反汇编;
1.4 本章小结
简要介绍了Hello的完整历程,阐述了P2P和020的含义,同时说明了实验环境、工具和产生的中间结果。
第2章 预处理
2.1 预处理的概念与作用
预处理一般是指由预处理器对程序源代码文本进行处理的过程,得到的结果再由编译器进一步编译,通常会执行相应的以“#”开头的预处理指令,完成一系列处理功能。
作用:
- 宏替换,将程序中的宏替换为定义的结果;
- 将include的文件插入到程序中;
- 处理一系列条件指令;
- 删除掉程序中的注释。
2.2在Ubuntu下预处理的命令
图 1 预处理指令
采用要求的参数选项,通过指定-E参数来实现预处理操作,生成处理后的hello.i文件。
2.3 Hello的预处理结果解析
可通过文本编辑器查看hello.i文件的内容,开头包含了一些基本信息,之后是所include的一系列头文件的基本信息:
图 2 头文件信息
接着包含了一些对于基本数据类型的定义,将int、double等等数据类型明确定义为了指定大小的类型:
图 3 类型定义
之后便为所插入的头文件具体内容,最后在文件的末尾即为程序主体部分,与hello.c的内容基本相同,但是注释已经被预处理器删除:
图 4 程序主体
2.4 本章小结
将hello.c经过预处理生成了hello.i文件,并且观察了生成的文件的内容,明确了预处理器所完成的一系列工作。
第3章 编译
3.1 编译的概念与作用
编译过程将C语言代码转换为一条条汇编指令,完成了从高级语言到面向机器的底层指令的转换过程中最重要的一步,产生一个汇编代码文件hello.s。
作用:通过语法分析解释理解C语言的代码并将其转换为汇编指令,为计算机的执行奠定基础。同时也可以在这个过程中实现一些优化方法。
3.2 在Ubuntu下编译的命令
通过gcc的-S参数选项完成编译过程,生成hello.s文件:
图 5 编译命令
3.3 Hello的编译结果解析
3.3.1 数据
常量:在hello.c中与argc比较的常量4被作为指令的一部分存储在了代码区:
图 6 常量
字符串常量:在程序中输出的两个字符串常量则存储在.rodata节中:
图 7 字符串常量
在汇编代码中movl指令通过$.LC1标识该字符串地址并将其作为参数保存在edi中以实现之后的printf调用的参数传递。
图 8 字符串常量作为参数传递
局部变量:控制循环的局部变量i被存储在栈中(%rbp)-4的位置。
图 9 局部变量
3.3.2 赋值
如上图,movl指令实现了将0赋值给变量i。mov指令的后缀b、w、l、q由数据的大小决定,分别为1、2、3、4字节。
3.3.3 算术操作
汇编指令由一系列算术操作指令来完成对数据的算术运算,在hello中,变量i由addl指令来完成递增的操作,进而实现对循环次数的控制。
图 10 算术运算——递增
3.3.4 控制转移
汇编代码通过一系列比较指令来进行比较操作,并根据比较结果来控制分支循环等控制转移。具体来说,比较指令将比较结果存储在相应标志位寄存器中,之后跳转指令依据标志位决定是否跳转到目标位置,实现程序控制的转移。
if:cmpl指令实现对argc和常数4的比较,联合je指令实现条件判断与跳转,若argc不为4则程序输出参数数量提示信息:
源代码:
图 11 分支判断代码
汇编指令:
图 12 分支判断指令
循环:通过cmpl指令实现局部变量i与常数7的比较,联合jle实现对循环次数的控制。
源代码:若i小于等于7则执行循环体
图 13 循环代码
汇编指令:
图 14 循环指令
3.3.5 数组
编译器将源代码中对数组下标的访问转换为对地址的偏移操作,例如在hello中的argv指针数组,通过计算偏移单位8的个数来实现相应元素的访问,在hello中,汇编指令通过-20(%rbp)和-32(%rbp)来访问argv下标为1和2的两个元素。
图 15 数组
3.3.6 函数操作
以程序中对printf的调用为例,首先将参数保存在寄存器中来实现传递,接着通过call指令完成对printf函数的调用。在具体的调用过程中包括参数传递、保存返回地址、入栈、保存返回值、出栈、返回控制权等一系列过程。
图 16 printf调用
程序中的另一条对printf的调用只是单纯输出了一个字符串,因此被编译器优化为对puts函数的调用,进一步提高效率。将字符串的地址保存在edi寄存器中实现参数传递,紧接着使用call指令调用puts函数。
图 17 printf优化为puts
返回值传递:atoi函数有一个返回值,其在调用后将返回值保存在了eax寄存器中供后续代码取出,在hello中该返回值立即被复制到edi中作为sleep函数的参数进行传递,之后程序调用sleep函数。
3.4 本章小结
介绍了编译的概念和作用,通过gcc实现了对hello.i的编译过程并分析了所产生的汇编代码hello.s,分析了C语言中包括各种变量、数组、运算、赋值、控制转移和函数调用等数据与操作特性的实现方法,简单认识了C语言代码的汇编实现形式。
第4章 汇编
4.1 汇编的概念与作用
将编译产生的汇编代码进一步转换成机器指令,产生一个二进制目标文件,实现从汇编到机器语言的转变,打包为一个可重定位目标程序的形式。
作用:将汇编代码转换成计算机能够执行的机器语言。
4.2 在Ubuntu下汇编的命令
使用-c参数完成汇编过程,生成hello.o目标文件。
图 18 汇编命令
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
使用readelf导出hello.o的ELF格式:
图 19 导出hello.o的ELF
ELF头:ELF头中描述了文件类别、数据存放方式、版本号、操作系统等信息,并且给出了入口点地址和程序头起点以及节头表的偏移。
图 20 ELF头
节头:节头表中描述了文件中各个节的类型、大小、地址、偏移、读写访问权限等信息。
图 21 节头
重定位节:描述各个段引用的外部符号,需要在链接时对这些地址进行修改。该重定位节包含两个部分:.rela.text和.rela.eh_frame,其中.rela.text节包含.text节中的位置的列表,含有该.text中所需要进行重定位操作的信息。
图 22 重定位节
符号表:提供程序中函数和全局变量的信息。
图 23 符号表
4.4 Hello.o的结果解析
使用objdump生成反汇编结果并保存在hello.asm中:
图 24 反汇编hello.o
与汇编语言的不同:
- 反汇编结果中包含机器码的十六进制表示;
- 反汇编结果中数字的表示都为十六进制;
- 控制转移指令不再使用段名称进行跳转,而是跳转到虚拟地址,同时需要额外注意的是字符串等属于重定位条目,目前其地址为0,等待链接时填写正确的地址。
- 函数调用也调用目标函数的虚拟地址。
图 25 hello.o的反汇编代码
4.5 本章小结
通过汇编过程将汇编代码生成为目标文件,并分析了其ELF格式的内容,最后利用objdump生成了反汇编文件,将其内容与汇编代码进行比较,得出目标文件的内容特点和作用。
第5章 链接
5.1 链接的概念与作用
链接是指连接器将多个目标文件链接组合为一个可执行程序,这个可执行程序才真正能被加载到内存中运行。
作用:将目标文件hello.o及其他若干目标文件进行组合,生成最终的可执行文件,链接有助于程序的模块化开发。
5.2 在Ubuntu下链接的命令
使用ld命令对hello.o和其他目标文件进行链接,生成可执行程序hello:
图 26 链接命令
5.3 可执行目标文件hello的格式
使用readelf生成hello的ELF格式:
图 27 生成hello的ELF
ELF头:ELF头中描述了文件类别、数据存放方式、版本号、操作系统等信息,并且给出了入口点地址和程序头起点以及节头表的偏移。
图 28 ELF头
节头:节头表中描述了文件中各个节的类型、大小、地址、偏移、读写访问权限等信息。
图 29 节头 part1
图 30 节头 part2
重定位节:已经经历了链接过程,不再是引用的外部符号,而是需要动态链接的函数。
图 31 重定位节
符号表:提供程序中函数和全局变量的信息。
图 32 符号表
5.4 hello的虚拟地址空间
使用edb加载hello并查看虚拟地址空间各段信息:与ELF格式所展示的信息相对应
图 33 虚拟地址空间各段信息
5.5 链接的重定位过程分析
目标文件反汇编中用0替代的字符串常量地址被替换为了具体地址,说明链接为其分配了虚拟地址。每一条指令也都拥有了自己的虚拟地址。程序中的指令跳转、函数调用都改为了虚拟地址而非偏移量。
图 34 可执行程序的反汇编
链接器的重定位即把每个符号定义与虚拟地址关联,将所有对符号的应用修改为新的虚拟内存地址。
5.6 hello的执行流程
使用edb执行hello,调用与跳转的各个子程序名或程序地址:
- <_init>
- <.plt>
- <puts@plt>
- <printf@plt>
- <getchar@plt>
- <atoi@plt>
- <exit@plt>
- <sleep@plt>
- <_start>
- <_dl_relocate_static_pie>
- <main>
- <_libc_scu_init>
- <_libc_csu_fini>
- <_fini>
图 35 使用edb追踪hello的流程
5.7 Hello的动态链接分析
通过ELF格式查看.got.plt首地址为404000:
图 36 .got.plt首地址
dl_init前:404008和404010处为空
图 37 dl_init前
dl_init后:加载时动态连接器重定位这两个条目,写入了正确的地址。
图 38 dl_init后
5.8 本章小结
介绍了链接的基本概念和作用,并分析了可执行hello程序的反汇编代码、查看了其ELF格式内容;比较了反汇编代码与hello.o反汇编的不同。通过edb运行hello程序,观察了动态链接器对地址的加载过程,追踪了hello程序执行的完整历程。
第6章 hello进程管理
6.1 进程的概念与作用
一个执行中的程序的实例称为一个进程。程序运行在进程的上下文当中。
作用:创建了程序运行的独特上下文,提供了两个抽象:
1. 提供独立的逻辑控制流,好像我们的程序独占地使用处理器;
2. 提供一个私有的地址空间,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
提供一个访问内核服务的交互界面,可以通过这个界面来运行程序。
处理流程:
- 读取从键盘输入的命令
- 判断命令是否正确,且将命令行的参数改造为系统调用execve内部处理所要求的形式
- 终端进程调用fork来创建子进程,自身则用系统调用wait来等待子进程完成
- 当子进程运行时,它调用execve根据命令的名字指定的文件到目录中查找可行性文件,调入内存并执行这个命令。
6.3 Hello的fork进程创建过程
在shell中输入命令执行hello程序后shell通过fork来创建一个子进程,其几乎与父进程相同,得到独立的一份虚拟地址空间副本,被赋予不同于父进程的PID。
6.4 Hello的execve过程
shell调用execve函数在新建的子进程上下文中加载运行hello程序,接下来删除已存在的用户区域并映射私有区和共享区,然后跳转到hello的入口点,将控制权转移到子进程。
6.5 Hello的进程执行
hello的进程拥有自己独特的上下文,如寄存器数据、栈空间等等,这些信息在该进程被切换时被保存,并且在恢复时重新载入。进程的调度由操作系统内核完成,由系统决定挂起进程、恢复进程,管理每个进程相应的上下文。
进程默认运行于用户模式,当发生故障、中断、异常时发生用户态与核心态之间的转换,由异常处理程序获得控制权,切换为内核权限来实现特殊处理。之后即会返回到用户模式继续正常运行。通过这种方式既能实现良好的权限管理,又能实现必要的系统调用和异常处理等。
6.6 hello的异常与信号处理
hello执行过程中会出现中断、陷阱、故障、终止这四种异常,会产生中断和终止信号。其中中断信号SIGSTP会导致进程暂停直到SIGCONT信号使其继续运行;SIGINT信号则会终止程序。
不停乱按:输入存储到缓冲区,程序正常运行。
图 39 乱按
Ctrl-C:程序收到SIGINT信号终止。
图 40 Ctrl-C
Ctrl-Z:程序收到SIGSTP信号暂停。
图 41 Ctrl-Z
ps命令:显示了暂停中的hello进程的信息。
图 42 ps
pstree命令:显示进程树。
图 43 pstree
jobs命令:显示hello作业信息。
图 44 jobs
fg命令:恢复前台运行,程序顺利运行退出。
图 45 fg
Ctrl-Z暂停后使用kill命令杀死进程,发送SIGKILL信号。
图 46 kill
6.7本章小结
探讨了进程的基本概念和作用,展现了进程的创建过程和执行过程,以及这个过程中的异常处理和进程调度。通过实验验证了hello程序的异常和信号处理行为,体现进程在hello执行过程中的重要作用。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:与段有关的偏移地址,是一个相对地址,如hello汇编代码中的地址。
线性地址:逻辑地址的偏移加上段的基地址得到的地址,是逻辑地址转换为物理地址的中间步骤。
虚拟地址:与实际物理内存无关的地址,包括逻辑地址。
物理地址:主存上的实际地址,将主存看做为连续的数组,每个字节被赋予一个唯一的物理地址,可以通过这个地址直接访问该位置的数据。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个地址由段选择符和段内偏移量两部分组成,段选择符长16位,通过段选择符确定开始位置的线性地址,然后与段内偏移量相加便可得到线性地址。
图 47 段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
分为静态和动态两种,将虚拟空间分为若干个长度相等的页,然后建立页表来管理:页表是一个页表条目的数组,将虚拟页地址映射到物理页地址。
图 48 页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
若TLB命中,则取出相应PTE并得到物理地址,请求访问对应数据;
否则触发缺页异常,由缺页处理程序确定牺牲页并替换为新的请求页面,返回原来的进程并再次执行指令。
通过四级页表的机制,逐级查找节省了页表空间的同时保证了较低的访问次数,提高了寻址效率。
7.5 三级Cache支持下的物理内存访问
MMU由虚拟地址获得相应物理地址PA,将其分为标记位CT、组索引CI和块偏移CO,依次去各级缓存及主存寻找数据及其是否有效,判断是否命中,命中则直接得到数据,否则去下一层继续查询,并根据最近最少访问策略进行替换。
7.6 hello进程fork时的内存映射
1. 虚拟内存和内存映射解释了fork函数如何为每个新进程提供私有的虚拟地址空间;
2. 为新进程创建虚拟内存:
创建当前进程的的mm_struct, vm_area_struct和页表的原样副本;
两个进程中的每个页面都标记为只读;
两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)。
3. 在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存;
4. 随后的写操作通过写时复制机制创建新页面。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行新程序a.out的步骤:
- 删除已存在的用户区域
- 创建新的区域结构(私有的、写时复制;代码和初始化数据映射到.text和.data区(目标文件提供);.bss和栈堆映射到匿名文件,栈堆的初始长度为0)
- 共享对象由动态链接映射到本进程共享区域
- 设置PC,指向代码区域的入口点,Linux根据需要换入代码和数据页面
图 49 hello的execve步骤
7.8 缺页故障与缺页中断处理
缺页故障:
当指令引用一个相应物理界面不在内存中的虚拟地址时会触发缺页故障。
处理方式:
由内核中的缺页处理程序选择一个牺牲页,如果这个牺牲页被修改过,那么就将它交换出去,换入新的页并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次访问即会命中。
7.9动态存储分配管理
动态内存分配是指在程序运行时使用动态内存分配器获得虚拟内存的机制,动态内存分配器维护着一个被称为堆的虚拟内存区域。堆被分配器视为一组不同大小的块的集合,每个块为“已分配”和“空闲”两种状态之一。
分配器分为显式分配器和隐式分配器两种基本风格和方式,显式分配器要求应用显式地释放任何已分配的快;隐式分配器则由分配器检测一个已分配块是否不再使用并自动释放,称为垃圾收集。此外分配器可采用两种链表,其中隐式链表堆中的空闲块通过头部中的大小字段隐含地连接;显式链表在每个空闲块中,都包含一个前驱与后继指针,从而减少了搜索与适配的时间。
7.10本章小结
分析了hello程序运行时存储背后的复杂机制,特别是虚拟内存的实现机制和各种异常发生时的处理方法,虚拟内存的应用使得进程的内存管理更加简单高效。最后简要讨论了动态内存分配的基本概念。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:将所有IO设备都视作文件,使用文件读写来完成设备的输入输出,大大简化了模型。
设备管理:提供统一的Unix I/O接口来完成输入输出的操作,进而对IO设备进行管理。
8.2 简述Unix IO接口及其函数
Unix I/O接口可实现打开文件、关闭文件、修改位置、读写等多种基本操作。
open函数:打开指定路径的文件,创建标识文件的描述符。返回值标识打开是否成功,成功返回0,失败返回-1。
close函数:关闭文件,释放相应内存资源。返回值同open函数。
read函数:从指定文件指定位置读取指定字节数到buf中,并返回成功读取的字节数,-1代表错误,0代表EOF。
write函数,从buf写入指定字节数到文件的当前位置,返回写入数量,-1表示错误。
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;
}
其中通过…的写法实现了不确定个数参数的传递。
其进一步调用了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);
}
vsprintf函数处理格式化字符串之后调用write函数进行写入操作,将字符串写入终端。write进行系统调用syscall将字节信息复制到显卡的显存内,显卡按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar调用read函数,read函数通过syscall系统调用,当键盘按下时执行中断处理程序,将键盘端口的扫描码转换为字符编码并最终写入输入缓冲区。直到读入了回车符,将输入缓冲区的内容整体读入。
8.5本章小结
阐述了系统IO的基本机制,描述了hello如何通过输入输出接口与IO设备通信并实现将信息输出在终端上、如何从键盘接收用户的输入。此外,简单分析了printf函数和getchar函数的工作原理,展现了IO函数如何层层合作最终实现简单易用的输入输出处理。
结论
hello所经历的过程:
- 编写C语言源代码;
- 预处理;
- 编译:将C语言代码转换为汇编代码;
- 汇编:将汇编程序转换为机器语言的目标文件;
- 链接:将多个目标文件链接为一个可执行文件;
- 创建进程:shell通过创建进程并加载运行hello可执行程序;
- 建立虚拟内存,为进程分配内存资源;
- CPU执行程序指令,访问内存;
- 通过异常与信号的处理实现IO功能及其他基本操作;
- 程序结束,回收进程。
对计算机系统的设计与实现的深切感悟,创新理念:
计算机系统经过数十年的发展,无数人为其投入了精力与智慧使其以这般精妙高效的方式运作,最终构成了我们如今丰富多彩的数字信息世界。小小的hello程序也有着这样复杂的实现机制和生命历程,囊括了可执行文件的生成、虚拟内存、异常与信号处理、IO等等复杂的主题,让我不得不感慨计算机系统世界的奇妙多彩,也对系统的复杂多了一分敬畏。我们立足于当今五彩斑斓的信息化时代,享受着计算机技术带来的便利,若能驻足感受一下哪怕小小的计算机程序的精妙与其经历的复杂生命历程,毫无疑问能极大激发我学习计算机系统、了解这个璀璨世界的热情。
此外,这次经历着重从旁观者的角度观察记录了hello的一生,但无疑一个程序的一生也会充满风险和危机,若能增加对hello或其他程序中隐藏的风险漏洞的体验将会非常有趣。
附件
hello.i:预处理后的程序代码文件;
hello.s:编译后的汇编代码文件;
hello.o:汇编后的程序目标文件;
hello.elf:hello.o的elf输出结果;
hello.asm:hello.o的反汇编;
hello:可执行程序;
hello2.elf:hello的elf输出结果;
hello2.asm:hello的反汇编;
参考文献
[1] https://www.cnblogs.com/pianist/p/3315801.html
[3] 深入理解Linux内核信号处理机制原理(含源码讲解) - 知乎
[4] https://www.ruanyifeng.com/blog/2018/01/assembly-language-primer.html