hit csapp大作业 程序人生-Hello’s P2P
Posted 羲901
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了hit csapp大作业 程序人生-Hello’s P2P相关的知识,希望对你有一定的参考价值。
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2021112810
班 级 2103103
学 生 肖芩芩
指 导 教 师 刘宏伟
计算机科学与技术学院
2022年5月
本文遍历了hello.c在Linux下生命周期,借助Linux下系列开发工具,通过对其预处理、编译、汇编等过程的分步解读及对比来学习各个过程在Linux下实现机制及原因。并由操作系统进行进程管理、存储管理和I/O管理的全过程。以此将CSAPP课程中的内容进行全面地总结和梳理,加深对计算机系统的理解。
关键词:1.编译系统;2.Hello程序;3.进程;4.信号与异常;5.虚拟内存;6.I/O管理。
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:From Program to Process。编辑完成的hello.c程序先经过cpp预处理器的预处理得hello.i文件,ccl编译器将其编译获得hello.s文件,as汇编器再将其翻译为机器语言指令获得hello.o文件,再经过ld链接器进行链接得可执行文件hello。shell输入执行命令后,进程管理为其fork()一个子进程。即完成了P2P的过程。
020:From Zero to Zero。进程管理给hello进行execve操作,进行mmap操作将其映射到内存中,接着给运行的hello分配时间片来执行逻辑控制流。当程序运行结束后,父进程会回收hello进程,内核删除相关的数据。即完成了020的过程。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发与调试工具:gcc,gdb,vim,edb,readelf,HexEdi,objdump,ldd等
1.3 中间结果
文件名称 | 文件作用 |
hello.i | 预处理之后文本文件 |
hello.s | 编译之后的汇编文件 |
hello.o | 汇编之后的可重定位目标执行 |
hello | 链接之后的可执行目标文件 |
helloo_objdmp | Hello.o 的反汇编代码 |
helloo.elf | Hello.o 的 ELF 格式 |
hello_objdmp | Hello 的反汇编代码 |
hello.elf | Hellode ELF 格式 |
1.4 本章小结
本章对hello做了总体的介绍,简述了hello的p2p和020过程,列出并介绍了本次实验的环境和工具,阐明了这次实验中产生了中间产物,是本次实验的总领部分,后文将依据本章做详细展开。
第2章 预处理
2.1 预处理的概念与作用
概念:C语言的预处理器在源代码编译之前对其进行一些文本性质的操作。它的主要任务包括删除注释、插入被#include指令包含的文件内容、定义和替换由#define指令定义的符号,同时确定代码的部分内容是否应该根据一些条件编译指令进行编译。
作用:预处理可以在在将c程序转化为s的汇编程序之前对于宏定义处理,方便后续的代码转化,并且对于在汇编中无用的注释进行处理,删去无用部分对后续操作做准备。
- 宏定义:#define 指令定义一个宏,#undef指令删除一个宏定义。
- 文件包含:#include指令导致一个指定文件的内容被包含到程序中。
- 条件编译:#if,#ifdef,#ifndef,#elif,#else和#dendif指令可以根据编译器可以测试的条件来将一段文本包含到程序中或排除在程序之外。
2.2在Ubuntu下预处理的命令
预处理命令:gcc -E hello.c -o hello.i
图1.预处理结果
2.3 Hello的预处理结果解析
图2.hello.i的代码截图
预处理后,文件变成3062行的文本文件,前面为头文件<stdio.h> <unistd.h> <stdlib.h> 的内容被复制插入进代码中,消除include宏,随后是原本的代码。可以看到,预处理后的文件更加完整和清晰,也没有了多余的注释等无用的部分。
2.4 本章小结
本章说明了P2P过程中的预处理部分,深入了解预处理器(cpp)将hello.c进行预处理,生成hello.i文件的过程,并分析其i文件的内容。了解了预处理的大致过程,也分析明确了预处理的重要性。
第3章 编译
3.1 编译的概念与作用
概念:编译就是将源语言经过词法分析、语法分析、语义分析以及经过一系列优化后生成汇编代码的过程。其以高级程序设计语言书写的源程序作为输入,而以汇编语言或机器语言表示的目标程序作为输出。编译器将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。
作用:
1.语法分析:编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,方法分为两种:自上而下分析法和自下而上分析法。
2.中间代码:源程序的一种内部表示,或称中间语言。中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码。
3.代码优化:指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。
4.目标代码:生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。此处指汇编语言代码,须经过汇编程序汇编后,成为可执行的机器语言代码。编译出的目标程序通常还要经历运行阶段,以便在运行程序的支持下运行,加工初始数据,算出所需的计算结果。
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
图3.编译过程
3.3 Hello的编译结果解析
3.3.1 hello.s文件中的伪指令
伪指令用于指导汇编器和链接器的工作。
内容 | 含义 |
.file | 源文件声明 |
.text | 代码节 |
.section .rodata | 只读代码段 |
.align | 指令或者数据的存放地址进行对齐的方式 |
.global | 声明全局符号 |
.type | 声明符号是数据类型或函数类型 |
3.3.2 rodata节数据
图4.rodata节数据
在.rodata段,我们有两个数据。.LC0存储的是第一条printf语句打印的字符串"用法: Hello 学号 姓名 秒数!\\n"。.LC1存储的是第二条printf语句所打印的字符串,其中的%s是占位符。
3.3.3局部变量
在hello.c程序main函数中定义了局部变量i,用于进行遍历操作。
编译处理会将它存储到用户栈中,可见i被赋予初值0存储在栈中-4(%rbp)的位置。
3.3.4数组
数组char *argv[]是main函数的第二个形式参数,来源于命令行输入的数据,argv是存放char指针的数组。argv数组中一个元素大小为8个字节,我们可以看到在hello.s中2次指令movq (%rax), %rdx与movq (%rax), %rax,是为了解析终端输入的命令参数。
3.3.5 赋值操作
使用数据传送命令,我们可以进行赋值操作。最简单形式的数据传输类型是MOV类,MOV有movb,movw,movl,movq。分别操作1、2、4、8字节的数据。mov操作的源操作数可以是:立即数、寄存器、内存。目的操作数可以是:寄存器、内存。x86-64规定两个操作数不能都指向内存。
3.3.6 算术运算
在循环操作中,使用了++操作符。对应的汇编代码为,对i自加,栈上存储变量i的值加1。
3.3.7 控制转移
在hello.c中,两次输出中包含常量字符串。编译处理时,由于常量字符串被提前声明,在这里输出时使用了控制转移。
图10(1)为argc与4的比较,如果相等则跳转到L2,在原程序里体现为不进入if语句,如果不相等,不跳转继续执行,体现为进入if语句。
图10(2)为for的循环条件,当i小于等于8时,跳转到L4,L4实现的时输出语句的相关操作。
3.3.8 循环
汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。
图11.循环控制条件
初始时,i为0,存放在栈中-4
3.3.9 类型转换
在语句sleep(atoi(argv[3]));中存在隐式类型转换。atoi函数的返回值是int型,而sleep函数的参数类型是unsigned long,存在着数据由int型向unsigned long型的转换。
3.3.10函数操作
main函数:
参数传递:传入参数argc和argv[],分别用寄存器%rdi和%rsi存储。
函数返回:设置%eax为0并且返回,对应return 0 。
main函数中调用其他函数
图12.函数调用
call表示函数调用,如上图所示,main函数调用了puts函数,exit函数,printf函数,atoi函数,sleep函数。其中exit参数是1,atoi函数的参数是argv[3],sleep函数的参数是atoi(argv[3])的返回值。
3.4 本章小结
本章说明了P2P过程中的编译部分,通过hello.s分析了c语言如何转换成为汇编代码。并对生成的汇编程序中涉及到的C语言各种数据类型和各类操作做了说明。
第4章 汇编
4.1 汇编的概念与作用
汇编概念:驱动程序运行汇编器as,将汇编语言的ascii码文件(这里是hello.s)翻译成机器语言的可重定位目标文件(hello.o)的过程称为汇编。hello.o是二进制文件。
汇编的作用:将汇编代码转变为机器指令,生成目标文件。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
图13.汇编过程
4.3 可重定位目标elf格式
1.ELF头
命令行输入:$ readelf -h hello.o
readelf可以显示ELF文件的相关内容,-h选项表示只显示header信息。
图14.elf头信息
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table) 的文件偏移,以及节头部表中条目的大小和数量。在上述表中,可以看出关于ELF header的长度这里也给出了,一共是64个字节。
2.section表
命令行输入:$ readelf -S hello.o 其中-S选项表示打印整个section表的信息
图15.section表信息
.text 已编译程序的机器代码。
.rodata 只读数据,比如printf语句中的格式串和开关语句的跳转表。
.data 已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。
.bss 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率,在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。
.symtab: 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,symtab符号表不包含局部变量的条目。
.rel.text 一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。
.rel.data 被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
.debug 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表。
.line 原始C源函数程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。
.strtab 一个字符串标,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以NULL结尾的字符串的序列。
描述目标文件的节 节头部表 不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
3.符号表
命令行输入$ readelf -s hello.o
符号表(.symtab)存放程序中定义和引用的函数和全局变量的信息。name是符号名称,对于可冲定位目标模块,value是符号相对于目标节的起始位置偏移,对于可执行目标文件,该值是一个绝对运行的地址。size是目标的大小,type要么是数据要么是函数。Bind字段表明符号是本地的还是全局的
图16.符号表信息
。
4.重定位节
命令行输入$ readelf -r --relocs hello.o
图17.重定位节信息
重定位节(.rela.text): .text 节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把目标文件和其他文件组合时,需要修改这些位置。
重定位节中各项符号的信息:
- Offset偏移量:需要被修改的引用节的偏移
- Info信息:包括符号和类型两个部分,符号在前面四个字节,类型在后面四个字节
- Sym.Value符号值:标识被修改引用应该指向的符号,
- Type类型:重定位的类型
- Addend加数:一个有符号常数,一些重定位要使用它对被修改引用的值做偏移调整
- Sym.Name符号名称:重定向到的目标的名称。
4.4 Hello.o的结果解析
反汇编命令:objdump -d -r hello.o
图18.反汇编代码(左)和汇编代码(右)部分内容
可以观察到,二者十分相似。但是许多地方不相同。
- 反汇编代码中不再有汇编代码中的伪节。
- 反汇编代码省去了汇编代码中标志操作数的字节大小的符号
- 在跳转时,汇编代码直接访问了.rodata节的数据,直接按函数名调用了函数,而反汇编代码中二者均是相对偏移地址,需要进行重定位得到绝对地址。
图19.反汇编部分代码
机器语言指的是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合。图19中的左侧数字序列就是每一条指令的机器代码。上图所示的机器代码是十六进制的。
4.5 本章小结
本章利用汇编操作将汇编语言转化为机器语言,可重定位目标文件已经完成,为下一步链接生成可执行文件做好准备。
第5章 链接
5.1 链接的概念与作用
链接的概念:hello程序调用了printf函数,它存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。连接器(ld)就负责处理这种合并。结果就得到了hello文件,它是一个可执行目标文件 (或者称为 可执行文件 ),可以被加载到内存中,由系统执行。链接程序运行需要的一大堆目标文件,以及所依赖的其它库文件,最后生成可执行文件。
作用:链接时会将可重定位目标文件实现重定位,生成最终的可执行文件。
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
图20.链接过程
5.3 可执行目标文件hello的格式
输入命令:readelf -a hello查看可执行文件中的信息内容
1.ELF 头部表
图21.ELF Header
hello与hello.o的ELF头大致相同,不同之处在于hello的类型为EXEC可执行文件,表明hello是一个可执行目标文件,有25个字节。
2.节头部表
图22.Section Header
节头部表是描述目标文件的节,各节的基本信息均在其中进行了声明,包括名称,大小,类型,全体大小,地址,旗标,偏移量,对齐等信息等。
3.重定位节
图23.重定位节
4.符号表
图24.符号表
5.4 hello的虚拟地址空间
命令行输入:edb --run hello
观察edb的Data Dump窗口。窗口显示虚拟地址由0x401000开始,到0x402000结束
图25.edb .Data Dump窗口
5.5 链接的重定位过程分析
hello反汇编代码中跳转地址是虚拟地址,完成了重定位(如图26),而hello.o反汇编代码中的是相对偏移地址,未完成重定位的过程。(如图27)
链接的重定位的过程:
重定位节和符号定义链接器将相同类型的节合并,生成ELF节。链接器将运行时的内存地址分配给生成的节,此时程序中每条指令和全局变量都有唯一的运行时地址。要合并相同的节,确定新节中所有定义符号在虚拟地址空间中的地址,还要对引用符号进行重定位,修改.text节和.data节中对每个符号的引用,需要用到在.rel_data和.rel_text节中保存的重定位信息。
首先,我们观察hello.o的反汇编代码。可以观察到,有许多地方并没有填入正确的地址,正等待进行链接。R_X86_64_PLT32表示puts函数需要通过共享库进行动态链接。在hello文件的反汇编代码中,我们发现之前的重定位地址已经被填入了正常的地址。观察elf文件信息,而401090.plt节。
hello是如何进行重定位的:
①首先计算需要被重定位的位置
refptr = .text + r.offset
②然后链接器计算出运行时需要重定位的位置:
refaddr = ADDR(.text) + r.offset
③然后更新该位置
*refptr = (unsigned) (ADDR(r.symbol) + r.addend-refaddr)
5.6 hello的执行流程
0x00007ffff7e16e20 <_init>
0x0000000000401090<_start>
0x0000000000401150<in __libc_csu_init>
0x0000000000401000<_init>
0x00000000004010c5<main>
0x0000000000401030<puts>
0x0000000000401070<exit>
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要为其添加重定位记录,并等待动态链接器处理。为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT和全局偏移量表GOT实现函数的动态链接。其中GOT 中存放函数目标地址,PLT使用 GO T中地址跳转到目标函数。
dl_init函数调用前后GOT信息变化截图,经动态链接,GOT条目已经改变:
5.8 本章小结
本章介绍了链接的概念及作用,对hello的elf格式进行了详细的分析,介绍了hello的虚拟地址,分析了hello的重定位过程、执行流程、动态链接过程,详细阐述了hello.o链接成为一个可执行目标文件的过程
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个执行中程序的实例。是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
作用:进程的概念为我们提供这样一种假象,就好像我们的程序是系统中当前运行的唯一程序一样,我们的程序好像是独占地使用处理器和内存,处理器好像是无间断地一条接一条地执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell俗称壳,是指"为使用者提供操作界面"的软件。同时它又是一种程序设计语言。作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令。它作为用户操作系统与调用其他软件的工具。
处理流程:
(1)从终端读入输入的命令。
(2)将输入字符串切分,分析输入内容,解析命令和参数。
(3)如果命令为内置命令则立即执行,如果不是内置命令则创建新的进程调用相应的程序执行。
(4)在程序执行期间始终接受键盘输入信号,并对输入信号做相应处理
6.3 Hello的fork进程创建过程
在终端输入命令./hello运行hello程序,由于hello不是一个内置命令,故解析后执行当前目录下的可执行目标文件hello,shell作为父进程通过fork函数为hello创建一个新的进程作为子进程。通过fork函数,子进程得到与父进程用户级虚拟地址空间相同但独立的一份副本,包括代码和数据段、堆、共享库、用户栈。hello进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程还可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
子进程创建后,shell调用execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。之后当出现错误时,例如找不到hello,execve才会返回到调用程序。
在execve加载了hello后,它调用启动代码,启动代码设置栈,并将控制转移给新程序的主函数main,此时用户栈已经包含了命令行参数和环境变量,进入main函数后开始逐步运行程序。
6.5 Hello的进程执行
1.逻辑控制流:
一系列程序计数器 PC 的值的序列叫做逻辑控制流。由于进程是轮流使用处理器的,同一个处理器每个进程执行它的流的一部分后被抢占,然后轮到其他进程。
2.用户模式和内核模式:
处理器使用一个寄存器提供两种模式的区分。用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据;内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
3.上下文:
上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。初始时,控制流再hello内,处于用户模式。调用系统函数sleep后,进入内核态,此时间片停止。2s后,发送中断信号,转回用户模式,继续执行指令。
4.调度的过程:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
以执行sleep函数为例,sleep函数请求调用休眠进程,sleep将内核抢占,进入倒计时,当倒计时结束后,hello程序重新抢占内核,继续执行。
5.用户模式与内核模式转换:
为了能让处理器安全运行,不至于损坏操作系统,必然需要先知应用程序可执行指令所能访问的地址空间范围。因此,就存在了用户态与核心态的划分,核心态拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
按下Ctrl+Z:进程收到 SIGSTP 信号, hello 进程挂起。用ps查看其进程PID,可以发现hello的PID是6649;再用jobs查看此时hello的后台 job号是1,调用 fg 1将其调回前台。
Ctrl+C:进程收到 SIGINT 信号,结束 hello。在ps中查询不到其PID,在job中也没有显示,hello已经被彻底结束。
中途乱按:将屏幕的输入缓存到缓冲区,乱码被认为是命令。
Kill命令:挂起的进程被终止,在ps中无法查到到其PID。
6.7本章小结
本章简述了进程、shell的概念与作用,分析了hello程序使用fork创建子进程的过程以及使用execve加载并运行用户程序的过程,运用上下文切换、用户模式、内核模式、内核调度等知识,分析了hello进程的执行过程,最后分析了hello对于异常以及信号的处理并进行了实际操作
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.物理地址(physical address)
用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
2.逻辑地址(logical address)
逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。如Hello中sleepsecs这个操作数的地址。
3.线性地址(linear address)或也叫虚拟地址(virtual address)
跟逻辑地址类似,它也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式内存管理方式就是直接将逻辑地址转换成物理地址,也就是CPU不支持分页机制。其地址的基本组成方式是段号+段内偏移地址。
在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中
程序人生——Hello‘s P2P(HIT CSAPP大作业)
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2021111282
班 级 2103101
学 生 张诚玮
指 导 教 师 刘宏伟
计算机科学与技术学院
2022年5月
本文从hello.c的视角出发,论述了hello.c从被程序员写出来到被预处理、编译、汇编、链接的经过以及其经过进程管理等一系列过程的经过与发生的变化。本文将联系实际操作的结果与计算机系统的知识对结果进行分析,找出输出是这样的原因,并且通过对程序从被写出来到可执行文件过程的分析,更进一步地认识和了解计算机的工作原理,程序在计算机当中的经过。能够更进一步地认识到如何才能写好代码。
关键词:预处理;编译;汇编;链接;进程管理。
目 录
6.2 简述壳Shell-bash的作用与处理流程 - 25 -
第1章 概述
1.1 Hello简介
1.1.1 P2P:From Program to Process
Program: 由程序员在编辑器(CodeBlocks、VS、VSC等)中输入代码形成的,一般被保存为xxx.c的程序。
Process: C程序经过预处理器(cpp)的预处理,编译器(ccl)的编译、汇编器(as)的汇编、链接器(ld)的链接最终形成的二进制可执行目标文件hello。通过往shell中输入启动命令的方式,shell将其fork成进程来运行。
图1.P2P的大体流程
1.1.2 O2O:From Zero-0 to Zero-0
shell通过execve在fork产生的子进程中加载hello,先删除当前虚拟地址的用户部分已存在的数据结构,为hello的代码段、数据、bss以及栈区域创建新的区域结构,然后映射虚拟内存,设置程序计数器,使之指向代码区域的入口点,进入程序入口后程序开始载入物理内存,而后进入main函数,CPU为hello分配时间片执行逻辑控制流。hello通过Unix I/O管理来控制输出。hello执行完成后shell父进程会回收hello进程,并且内核会从系统中删除hello所有痕迹,至此,hello完成O2O的过程[2]。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2.30GHz;16G RAM;512GHD Disk
1.2.2 软件环境
Windows 10 64位;Vmware 16.2.4;Ubuntu 20.04
1.2.3 开发工具
vi/vim/gedit+gcc
1.3 中间结果
文件名称 | 文件作用 |
hello.i | 预处理之后的文本文件 |
hello.s | 编译之后的汇编文件 |
hello.o | 汇编之后的可重定位目标执行 |
hello | 链接之后的可执行目标文件 |
helloo.objdmp | hello.o的反汇编代码 |
helloo.elf | hello.o的ELF格式 |
hello.objdmp | hello的反汇编代码 |
hello.elf | hello的ELF格式 |
表1.各文件名称及其作用
1.4 本章小结
本章介绍了P2P、020的概念以及实验过程之中所用到的软硬件环境和开发与测试工具,总体地描述了通过高级语言C语言编写的代码hello.c经过预处理、编译、汇编、链接等流程的中间产物,以及对hello.c的P2P过程进行分析与处理时生成的.elf,.objdmp文件,从大体上介绍了本次实验。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——预处理记号(preprocessing token)用来支持语言特性[3]。
2.1.2 预处理的作用
预处理器主要实现下面四种功能:
1) 宏定义: #define指令定义一个宏,#undef指令删除一个宏定义。
2)文件包含: #include指令导致一个指定文件的内容被包含到程序中。
3) 条件编译:#if, #ifdef, #ifndef, #elif, #else 和 #dendif指令可以根据编译器测试的条件来将一段文本包含到程序中或者排除在程序之外。
4) 删除注释:不编译注释内容。
2.2在Ubuntu下预处理的命令
在Ubuntu的终端输入gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i以执行预处理操作,生成hello.i文件,如图2所示:
图2.预处理命令
生成的hello.i文件一共有3060行,只截取其中的一部分如图3所示:
图3.部分hello.i代码
2.3 Hello的预处理结果解析
如图3所示,经过预处理器的预处理,代码的行数增多了,由原来的24行转变为现在的3060行,而原来编写的代码中的注释被删去,包括的头文件变成了具体的指令,但是,hello.i并没有变成二进制文件,它只是对源文件文本的内容进行了一定程度的扩充与删减,使之从程序员能轻松理解的代码转变为机器更容易理解的代码,但是,本质上仍然是源代码的文本文件。
2.4 本章小结
本章讲述了程序P2P的第一个步骤,也就是程序预处理的步骤。本章详细地阐述了预处理的概念、作用,并且通过gcc指令在Ubuntu下生成了程序通过预处理后的文件hello.i并对结果进行了解析,大体上了解了系统预处理的原因,方式以及结果。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
1、利用编译程序从源语言编写的源程序产生目标程序的过程。
2、用编译程序产生目标程序的动作。 编译就是把高级语言变成计算机可以识别的2进制语言,计算机只认识1和0,编译程序把人们熟悉的语言换成2进制的。
3、编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息[4]。
3.1.2 编译的作用
将高级语言程序代码(C语言代码)翻译为汇编语言代码,把代码转化为汇编指令。把程序员更能理解的代码转变为机器更能理解的代码,并且以二进制的形式(显示为16进制)给出对应的指令、操作数等,有利于机器的执行。利用编译程序从源语言编写的源程序产生目标程序,把高级语言变成计算机可以识别的2进制语言。
3.2 在Ubuntu下编译的命令
在Ubuntu的终端输入gcc -m64 -no-pie -fno-PIC -S hello.c -o hello.i以执行编译操作,生成hello.s文件,如图4所示:
图4.hello.s
生成的hello.s文件共80行,现截取其中一部分代码,如图5所示:
图5.hello.s的部分代码
3.3 Hello的编译结果解析
3.3.1 代码的声明
如图5所示即为hello.s的开头代码,亦即为代码的部分声明,如表2所示:
代码 | 声明 |
.file | 源文件 |
.text | 代码段 |
.section .rodata | 只读数据段 |
.align | 对指令或者数据的存放地址进行对齐的方式 |
.string | 字符串 |
.global | 全局变量 |
.type | 制定函数类型或对象类型 |
表2.代码的部分声明
3.3.2 数据
3.3.2.1 常量
图6.rodata段
图6所示的即为汇编程序的只读数据段(.rodata)。其中,LC0和LC1分别对应了代码中的两个printf函数所对应的输出的文本的内容。由于第一个printf输出的为中文字符串,而hello.s是ASCII的文本文件,因此,不支持中文的显示,所以显示出来的是中文的UTF-8编码。
除此之外,hello.s中的其他常量一般以立即数的形式,在执行的过程之中被寄存器调用。
3.3.2.2 变量
观察hello.c源代码,不难发现,程序之中的变量有且仅有三种,分别是输入主函数的两种:int argv,char *argc[],主函数之中定义的一种:int i。其中,标准的用法下,argc有4个数据,因此,总共有6个局部非静态变量,分别为argv,argc[0],argc[1],argc[2],argc[3],i。
图7.main函数的汇编代码
图8.栈中的位置
观察图7,edi传递第一个参数,也就是argv,rsi传递第二个参数,也就是argc数组,其中,argc[0]中保存的是程序的路径以及名称,因此,没有必要再在栈中保存,argc与argv在栈中的位置如图8所示。
图9.循环变量i
由图9可以看出,通过比较立即数8与-4(%rbp)来对代码进行跳转的操作,可以看出,循环变量i被存放在-4(%rbp)的位置,被存放在栈中。这是因为i是在循环外定义的变量,若是放在循环内定义(比如for(int i = 0 ; i < 9;i ++)),则i会被放在寄存器中进行循环。
3.3.3 赋值操作
图10.赋值
如图10所示,对于变量i的赋值,一般采用直接将立即数赋值到相应的栈中的位置。用mov指令来对变量i进行赋值。
3.3.4 类型转化
在本函数中,只运用了一种类型的转化,那就是字符串向整形的转化,通过函数atoi来实现,如图11所示:
图11.类型的转化
3.3.5 算术操作
在本函数之中,只运用了一种算术操作那就是i++,实现的指令如图12所示:
图12.i++操作
通过add指令来对变量i所在位置进行+1操作。
3.3.6 关系操作
在本函数之中,运用了两次关系操作,分别是argc!=4和i<9。
在汇编语言中,关系操作一般是通过cmp指令与jmp指令的协作下来共同进行实现的,如图13,14所示,其分别为C语言中的两个表达式的语句:argc!=4,i<9,可以看到,在汇编之中,<优化成了<=。
图13.argc!=0
图14.i<9
在汇编语言中,一般通过两者相减来判断大小,根据相减的结果设置相应的条件码如ZF,SF等。
3.3.7 数组操作
在汇编语言之中,数组的表示其实就是变量数据的表示,只不过,数组表示的数组是一连串的连续的地址空间,因此,表现在栈中就是数组的数据被连续地压入栈中,而寄存器中记录的是数组的首地址。要访问其他的数组成员,则是根据数组的首地址加上偏移量后进行访问,如图15所示:
图15.循环体
如图是循环体内的操作(只有循环体内调用了argv数组),可以看到,通过movq -32(%rbp),%rax操作将数组的首地址赋值给了rax寄存器,再通过addq操作来控制数组的下标,从而读取对应的数据。
3.3.8 控制转移
在本函数之中,只出现了两种控制转移。一种是if语句,另外一种是for循环。两种语句都与关系操作相关,因此,相关的图被放在了3.3.6关系操作之中。
3.3.8.1 if判断
如图13所示是函数的if判断语句。根据关系操作后的条件码从而可以对je进行判断,从而实现语句的跳转,如果不相等,那么跳转到对应的函数体来进行语句的执行。
3.3.8.2 for循环
如图14所示是函数的for循环判断语句。根据关系操作后的条件码从而可以对jle进行判断,从而实现语句的跳转,如果小于等于,那么跳转到对应的函数体来进行语句的执行。
3.3.9 函数操作
函数的参数在汇编中通过寄存器来实现传递,其中返回值存储在eax中,参数则存储在rdi, rsi, rdx, rcx, r8, r9这六个寄存器,这六个寄存器分别表示第一、第二一直到第六个参数,如果六个寄存器无法满足函数的调用需求,则还需要利用栈帧将第七个及以上的参数存入栈中,函数要调用就从栈中进行调用,但本函数中没有用到。调用函数时用到的汇编语句是call,返回时则用ret。图11所示就是典型的对atoi函数的调用。在本程序之中,使用的函数包括printf,atoi,sleep,getchar等函数。
对于函数的调用,大都相似,即先将参数放在指定的寄存器中(或放在栈中),然后再通过call指令调用函数,转移到函数所在的地址进行操作,在操作过程中,返回值被赋值到了eax之中,最后通过ret函数进行返回。
3.4 本章小结
本章解释了程序编译过程中“编译”步骤的概念和作用,并通过hello.s这一例子展示了编译得到的汇编语言代码,从中可以看到从高级语言代码到汇编代码的翻译方式,也可以看出编译器并不是直接死板地翻译高级语言,而是要经过修整以适合机器的运作规律,对代码进行了一定程度的优化,从而使下一步翻译成二进制机器语言更加方便快捷。
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编器(as)将hello.s翻译为机器语言,产生可重定位目标程序,生成hello.o文件。hello.o文件是二进制文件。需要注意的是,此时的hello.o还未进行链接,所以不可直接运行。
4.1.2 汇编的作用
将hello.s 编译得到hello.o,再汇编得到机器语言二进制程序。
4.2 在Ubuntu下汇编的命令
在Ubuntu的终端输入gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o以执行编译操作,生成hello.o文件,如图16所示:
图16.hello.o
4.3 可重定位目标elf格式
如图17所示,是可重定位目标elf的格式:
图17.可重定位目标的格式
4.3.1 elf头
图18.hello.o的elf头
Elf头开始是一个16字节序列,前四个字节是elf格式固定的开头,然后的三个字节依次代表64位、小端序和文件头版本。Elf头含有文件的最基本信息,是在链接时读取并理解这个文件所必不可少的。
4.3.2 节头部表
图19.hello.o的节头部表
节头部表列出了各节的大小、类型、地址、偏移量等信息,方便查找各节。
4.3.3 符号表
图20.hello.o符号表
显示符号表段中的项。
4.3.4 可重定位段
图21.hello.o中的可重定位段
文件中有一些内存地址或引用,这些地方在链接前是待定的,需要视链接的情况指定确切的地址。因此,需要对这些地址进行重定位。每个代码段或数据段都对应一个重定位表,记录了段中的这些位置,方便对它们进行查找和操作。
4.4 Hello.o的结果解析
如图22所示,是hello.o文件的反汇编:
图22.hello.o的反汇编
与hullo.s相比,hello.o的反汇编主要有如下几点区别:
- 立即数。hello.s中的立即数都以十进制的形式进行保存,而hello.o的反汇编中的立即数大多都以十六进制的方式显示;
- 分支转移。在hello.s之中,跳转的分支以L2,L3之类的方式进行表示,而在hello.o的反汇编文件之中,大多采用以偏移量表示的地址,比如5e: R_X86_64_PLT32 printf-0x4;
- 函数调用。在hello.s之中,函数的调用一般是call puts@PLT之类的形式,而在hello.o的反汇编文件之中,一般是以地址加偏移量的形式表示的,比如callq 62 <main+0x62>。
这些区别,主要是由于机器码中没有符号的概念,所有的符号都要变成具体可查的数字,以供机器的理解与执行。但内存地址又是偏移量而不是具体的数值,这是因为还没有对文件进行链接,无法确定使用的内存地址,这部分要留给重定位来解决。
4.5 本章小结
本章解释了高级语言(C语言)程序编译过程中“汇编”步骤的概念和作用,并以hello.o的elf格式和反汇编代码为例展示了二进制机器语言文件的格式和特性。从汇编代码到二进制机器语言的过程是有迹可循、有理可依的,它为下一步的链接又提供了更方便的条件,为程序员所写的代码被机器所能理解打下了坚实的基础,迈出了重要的一步。
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是结合多个不同的可重定位目标文件、得到具有统一内存地址,能够运行的可执行程序的过程。一个复杂软件分为很多的模块,人们把每个模块独立地编译,然后按需组装起来的过程就是链接。链接将不同文件中的数据和程序段结合统一起来,在编程时方便由各个小文件组成大型程序,条理清晰,使得更加分散化、模块化的编程成为可能。链接主要包括地址和空间分配、符号决议(也叫符号绑定、名称绑定、地址绑定)、重定位等步骤。
5.1.2 链接的作用
链接的存在可以让程序分离编译,然后链接就将分离的目标文件、启动代码、库文件等链接成可执行文件。
5.2 在Ubuntu下链接的命令
通过往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
的命令对hello.o进行链接,如图23所示:
图23.对hello.o进行链接
5.3 可执行目标文件hello的格式
通过指令readelf -a hello > hello.elf 将hello的elf格式输出到文件hello.elf之中,如图24所示:
图24.输出到hello.elf
5.3.1 Elf头
如图25所示是hello的elf格式的Elf头:
图25.Elf头
5.3.2 节头部表
如图26所示为hello的elf格式的部分节头部表信息:
图26.节头部表信息
5.3.2 程序头表
如图27所示为hello的elf格式的程序头表信息:
图27.程序头表信息
5.3.2 符号表
如图28所示为hello的elf格式的部分符号表信息:
图28.部分符号表信息
5.4 hello的虚拟地址空间
命令行输入edb,打开后,将hello.ld可执行文件拖入edb界面,查看左下角Data Dump一栏,如图29所示,即为edb界面的Data Dump栏,如图30所示,即为edb界面的memory regions界面:
图29.edb界面的Data Dump栏
图30.edb界面的Memory Regions
可以看出,程序在0x00401000 ~ 0x00402000段中,虚拟地址从0x00401000开始,到0x00401ff0结束。
5.5 链接的重定位过程分析
使用指令:objdump -d -r hello > hello.objdump将重定位项目的内容写到文件hello.objdump当中。图31为部分hello.objdump,图32为部分helloo.objdump。
图31.hello.objdump
图32.helloo.objdump
主要有以下两项区别:
- 以0开头的虚拟地址变成了具体的内存地址;
- 函数的调用也变成了内存地址;增加了.init和.plt节;增加了getchar等库函数。
由此可知,链接的过程就是将不同.o文件的内容按合理顺序拼接在一起使得彼此能够配合的过程。在重定位时,链接器需要整理符号表中的条目,分配出内存地址。先将每个同类节合并成同一个节,然后为它们赋予内存地址,使指令和变量有唯一的内存地址。最后将重定位节中的符号引用改为内存地址。
5.6 hello的执行流程
函数调用如下表格所示:
地址 | |
ld-2.23.so!_dl_start | 0x00007f8dec5b79b0 |
ld-2.27.so! dl_init | 0x00007f8dec5c6740 |
hello!_start | 0x004004d0 |
ld-2.27.so!_libc_start_main | 0x00400480 |
libc-2.27.so! cxa_atexit | 0x00007f8dec226280 |
hello!_libc_csu_init | 0x00400580 |
hello!_init | 0x00400430 |
libc-2.27.so!_setjmp | 0x00007f8dec221250 |
libc-2.27.so!_sigsetjmp | 0x00007f8dec221240 |
libc-2.27.so!__sigjmp_save | 0x00007fa8dec221210 |
hello_main | 0x004004fa |
hello!puts@plt | 0x00400460 |
hello!exit@plt | 0x004004a0 |
hello!printf@plt | 0x00400470 |
hello!sleep@plt | 0x004004b0 |
hello!getchar@plt | 0x00400490 |
ld-2.23.so!_dl_runtime_resolve_avx | 0x00007f8dec5cd870 |
libc-2.27.so!exit | 0c00007f6002de35b0 |
表3.函数的调用
5.7 Hello的动态链接分析
动态链接项目中,查看dl_init前后项目变化。对于动态共享链接库中PIC函数,编译器加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略,将过程地址的绑定推迟到第一次调用该过程。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,初始时每个GOT条目都指向对应的PLT条目的第二条指令。
图33.Data Dump
在dl_init调用之后, 0x6008c0和0x6008c0处的两个8字节的数据分别发生改变。
和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址, GOT[2]是动态链接器ld-linux.so模块中的入口点。
在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问时,GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数。
5.8 本章小结
本章简述了链接的概念与作用,分析了经过链接生成的hello文件的结构以及与之前经过链接的hello.o文件的异同,分析了hello文件的运行流程,使用edb探索了动态链接的过程。经过链接,hello.c已经从程序员所写的源代码程序转变成了机器可以理解并且执行的可执行文件。至此,就是Hello的诞生。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
1.进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
2.进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。
6.1.2 进程的作用
进程能够提供给应用程序一些关键抽象:
1) 一个独立的逻辑控制流。进程使得我们感觉好像在独占处理器。
2) 一个私有地址空间。进程使得我们感觉好像独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell的概念与作用
Linux系统中,Shell是一个交互型应用级程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程)。
6.2.2 Shell的处理流程
其基本功能是解释并运行用户的指令,重复如下处理过程:
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
(3)检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令
(3)如果不是内部命令,调用fork( )创建新进程/子进程
(4)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(5)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait...)等待作业终止后返回。
(6)如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
在命令行输入 ./hello执行 hello 程序时,由于hello不是内部命令,所以shell会fork一个子进程并进行后续操作。
新建的子进程几乎和父进程相同。子进程拥有与父进程用户级虚拟地址空间相同且独立的一份副本,与父进程任何打开的文件描述符相同的副本。
使用fork()函数来创建一个子进程,fork函数的原型为:pid_t fork(void)
fork()函数有以下特点:
1)调用一次,返回两次。一次返回至父进程,返回的是子进程的pid;一次返回至子进程返回值为0。
2) 并发执行。父子进程是并发运行的独立进程。
3) 相同但独立的地址空间。子进程创建时,两个进程具有相同的用户栈、本地变量、堆、全局变量、代码。但是二者对这行的改变都是相互独立的。
4) 共享文件。
6.4 Hello的execve过程
使用fork创建进程后,子进程便会使用execve加载并运行hello程序,且带参数列表argv以及环境变量envp。execve调用一次,从不返回。
图34.参数列表与环境变量列表
观察可知,argv指向一个指针数组,这个指针数组中的每一个指针指向一个参数字符串。其中argv[0]使我们所运行的程序的名字。envp指向一个指针数组,这个数组里面的每一个指针指向一个环境变量的字符串。环境变量字符串的格式为”name = value”。使用getenv函数获取环境变量,setenv、unsetenv来设置、删除环境变量。
execve会调用启动加载器。加载器会删除子进程现有的虚拟内存段,创建一组新的代码、数据、堆、栈。新的栈和堆被初始化为0。通过虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码数据初始化。最后,跳转到_start地址,最终调用main函数。
6.5 Hello的进程执行
系统中每个程序都运行在某个进程的上下文中。上下文是程序正确运行所需要的状态,由系统内核维持。
一个运行多个进程的系统,进程逻辑流的执行可能是交错的。每个进程执行它的流的一部分, 然后被抢占,轮到其他进程执行。一个逻辑流在时间上与另一个重叠ÿ
以上是关于hit csapp大作业 程序人生-Hello’s P2P的主要内容,如果未能解决你的问题,请参考以下文章