程序人生-Hello’s P2P(CSAPP大作业)
Posted BFnext
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了程序人生-Hello’s P2P(CSAPP大作业)相关的知识,希望对你有一定的参考价值。
本文介绍了程序Hello的一生。本文通过对Hello在Linux下的预处理、编译、汇编、链接等过程进行分析,详细讲解了一个程序由诞生到执行再到消亡的典型过程。虽然程序执行的过程在程序员眼中只是屏幕上的显示的字符串,但在短短几ms内,程序却经历了预处理,编译,汇编、链接,进程管理,IO管理,内存分配与回收等等一系列复杂的流程。同时也在本文中梳理了书本的知识,由hello的一生将整本书的内容连贯起来。回顾计算机系统所学内容,加深印象,增进对程序运行过程和计算机内部结构的了解。
关键词:hello程序,linux,链接,编译,汇编语言,IO,
第1章 概述
1.1 Hello简介
P2P:
P2P指的是程序由一个项目变成一个进程的过程.
- Program:Hello程序的诞生是程序员通过键盘输入得到hello.c
- Process:C语言源程序hello.c在预处理器(cpp)处理下,得到hello.i,通过编译器(ccl),得到汇编程序hello.s,再通过汇编器(as),得到可重定位的目标程序hello.o,最后通过链接器(ld)得到可执行的目标程序hello。在shell中键入运行命令后,shell调用fork函数为其创建子进程。
020:
020为程序“从无到有再到无”的过程。程序经过系统OS,shell为hello进程execve,映射虚拟内存,进入程序入口后程序开始载入物理内存。进入 main 函数执行目标代码,CPU为执行文件hello分配时间周期,执行逻辑控制流,每条指令在流水线上取值、译码、执行、访存、写回、更新PC。当程序运行结束后, shell 父进程负责回收 hello 进程,内核删除相关数据结构。Hello程序从无到有再到无的这一过程就是020。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk
软件环境:Windows10 64位;VirtualBox/Vmware 15以上;Ubuntu 16.04;
开发工具:CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
hello.c:程序Hello的c语言代码
hello.i:hello.c预处理之后的文本文件
hello.s:hello.i编译之后产生的汇编文件
hello.ld:链接后生成的文件
hello.o:可重定位的目标文件
hello:可执行文件
helloo.objdump:hello.o反汇编文件
hello.objdump:hello的反汇编文件
1.4 本章小结
本部分对hello从诞生到执行到消亡的P2P和020过程进行了简介,并对全文脉络进行梳理,并介绍了整个过程中所使用的环境和工具及生成的中间文件。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理是c语言的一个重要功能,由预处理程序负责完成,将源文件.c预处理成.i文件,主要处理#开始的预编译指令
预处理的作用:合理使用预处理功能编写的程序便于阅读,修改,调试,有利于模块化设计。
1.将源文件中include包含的文件复制到源文件中,例如#include<stdio.h>高速预处理器将文件stdio.h加入到源文件中。
2.用实际参数值替换宏#define值定义的字符串
3.根据#if,#ifdef等条件决定需要编译的代码
4.删除所有注释(/*……*/, //)
5.添加行号和文件标识
6.保留#pragma编译器指令
预处理名称有以下几种:
预处理名称 | 相应事件 |
#define | 宏定义 |
#undef | 撤销已定义的宏名 |
#include | 使编译程序将另一源文件嵌入到带有#includc的源文件中 |
#if | #if 的一般含义是如果#if后面的常量表达式为true,则编译它与#endif 之间的代码,否则跳过这些代码。命令#endif标识一个#if块的结束。#else命令的功能类似C语言中的else , #else建立另一选择(在# if失败的情况下)。#elif命令意义与else if类似,它形成一个if else-if阶梯状语句,可进行多种编译选择。 |
#else | |
#elif | |
#endif | |
#ifdef | 用#ifdef 与#ifndef命令分别表示“如果有定义”及“如果无定义”,是条件编译的另一种方法。 |
#ifndef | |
#line | 改变当前行数和文件名称,它们是在编译程序中预先定义的标识符命令的基本形式如下: |
#error | 编译程序时,只要遇到#error就会生成一个编译错误提示消息,并停止编译 |
#pragma | 为实现时定义的命令,它允许向编译程序传送各种指令例如,编译程序可能有一种选择,它支持对程序执行的跟踪。可用#pragma语句指定一个跟踪选择。 |
2.2在Ubuntu下预处理的命令
预处理指令:gcc -E hello.c -o hello.i
图2.2a Ubuntu下预处理命令与执行结果
图2.2b hello.i部分内容
2.3 Hello的预处理结果解析
由图2.2b可知,经过预处理后,hello.c的23行代码被扩展为3060行,其中main函数占第3047~3060行,main函数前为hello.c引用的stdio.h等头文件的内容。同时,注释内容被去除。
- 预处理过程中预处理器(cpp)识别到#include这种指令就会在环境中搜寻该头文件并将其递归展开。
- hello.c中开头的注释被删除,#include包含的几个文件被插入到hello.i中
2.4 本章小结
本章主要介绍了预处理的概念和作用,包括预处理的宏定义,文件包含,条件编译等方面的内容,并对hello.c经过预处理得到的hello.i文件进行了对比分析。
第3章 编译
3.1 编译的概念与作用
概念:编译器(ccl)将预处理生成的文件hello.i翻译成汇编文件文件hello.s。 它包含一个汇编语言程序。
作用:将高级语言编译为相应的机器语言,这里将C语言转化为intel x86汇编指令。
3.2 在Ubuntu下编译的命令
指令:gcc -S hello.i -o hello.s
图3.2 编译结果
3.3 Hello的编译结果解析
图3.3 hello.s部分内容
3.3.1数据
1.字符串
1)“Usage: Hello \\345\\255\\246\\345\\217\\267 \\345\\247\\223\\345\\220\\215\\357\\274\\201”
对应.c文件中"Usage: Hello 学号 姓名!\\n"
其中中文已被编码为UTF-8 格式 一个汉字占3个字节
2) “Hello %s %s\\n”
对应原c文件中"Hello %s %s\\n"第二个printf中的格式化参数
其中后两个字符串已在.rodata中声明
2.整数
1)int i(局部变量)
图3.3a hello.c部分代码
根据movl $0, -4(%rbp) 可以看出编译器将i存到了-4(%rbp) 中,且占4个字节。与addl $1, -4(%rbp)和cmpl $7, -4(%rbp)实现循环结构for(i=0;i<=u;i++)。
2)int argc
作为第一个参数被压栈pushq %rbp,传入main函数
3)立即数
程序中其他整型都是以立即数的形式出现
4)数组 char *argv[]
这是一个指针数组,由addq $, %rax可以看出,一个内容占8个字节,说明linux中一个地址的大小是8个字节。
3.3.2赋值
1) i=0
movl $0, -4(%rbp)
通过这局mov指令将0赋给了i
因i为int类型,占4个字节,故用后缀l。
3.3.3算术操作
1)i++
addl $1, -4(%rbp)
自增运算,每次运行时i增加1
3.3.4 关系判断
1)argc!=4
先将argc的值存在-20(%rbp) 的位置,与4比较。判断是否跳转L2
2)i<8
通过比较i与7的值,如果i小于等于7,则跳转L4继续执行循环里的内容否则退出循环
3.3.5控制转移
1)if(argc!=4)
比较4与-20(%rbp)中的值(即argc),若相等则跳转至L2,实现if(argc!=4)的功能。
2)for(i=0;i<8;i++)
使用 cmpl 进行比较,如果 i<=7,则跳入.L4 for循环体执行,否则说明循环结束,顺序执行for之后的逻辑。
3.3.6数组操作
argv[1],argv[2]
使用movq, addq,先后将栈上的argv[1],argv[2]存入寄存器。
3.3.7函数调用
函数的调用需要有以下过程:传递控制、数据传递、分配空间
1)main函数
main函数被系统启动函数 __libc_start_main 调用,call指令将main函数的地址分配给%rip,随后调用main函数。main函数的两个参数argc, *argv[](首地址)分别储存于%rdi 和%rsi 。正常运行时,函数以return 0为出口,将%eax 设置 0 返回。
程序使用%rbp 记录栈帧的底,函数分配栈帧空间,最后使用leave指令将栈恢复为调用之前的状态,ret返回将下一条指令地址设置为%rip
2)printf函数
第一处调用printf 第二处调用printf
Hello程序中有两处调用printf,第一处调用时,程序将%rdi设置为字符串"用法: Hello 学号 姓名 秒数!\\n "的首地址,第二处调用时将%rdi设置为"Hello %s %s\\n"的首地址,同时将argv[1],argv[2]分别存入%rsi,%rdx。
第一处调用printf时,字符串固定,只有一个参数,故用call put@PLT。第二处调用时,有三个参数,使用call printf@PLT
- exit函数
call exit@PLT call调用exit
- sleep函数
将%eax 内容储存至 %edi,之后call sleep@PLT调用sleep函数
- getchar 函数
call getchar@PLT调用getchar
3.4 本章小结
本章总结并分析了编译器是如何处理c语言的各个数据类型和各类操作,如变量处理、赋值、算数操作,关系判断和函数调用等,对应于书上与汇编语言相关的章节。经过该步骤hello.s是更加接近机器层面的汇编代码。
第4章 汇编
4.1 汇编的概念与作用
概念:驱动程序运行汇编器as,将汇编语言(hello.s)翻译成机器语言(hello.o)的过程称为汇编,hello.o是一个二进制文件,包含着程序的指令编码。
作用:将高级语言转化为机器可直接识别并执行的二进制机器代码。这个二进制机器代码是程序在本机器上的机器语言的表示。
4.2 在Ubuntu下汇编的命令
命令: gcc-c hello.,s -o hwwqql
汇编结果
4.3 可重定位目标elf格式
ELF头:
ELF头:包含信息为文件结构的说明信息:16字节的标识信息,文件类型,机器类型,节头表偏移,节头表的表项大小,表项个数,生成该文件的系统字大小和字节顺序
节头部表:
节头部表描述不同节的位置和大小,目标文件中1每个节都有一个固定大小的条目,相关信息包括节的名称,类型,地址,偏移量,对齐,旗标等。
由输出可知,Hello程序共有14个节。
重定位节:
重定位用于在汇编器生成目标模块时,对最终位置未知的目标引用生成一个重定位条目。链接器在链接生成可执行文件时可据此修改这个引用。
Rela.text和.rela.eh_frame中包含.text节中需要进行重定位的信息,在链接时需要修改这些信息的位置。
该程序重定位有R_X86_64_PC32,R_X86_64_PLT32两种基本类型,分别用于重定位使用32bitPC相对地址、绝对地址的引用
4.4 Hello.o的结果解析
Hello.o反汇编代码
hello.s汇编代码
运行objdump -d -r hello.o,获得hello.o的反汇编代码。由于反汇编代码从机器语言翻译形成,跳转时,地址一般以相对地址表示。同时,操作数一般以16进制表示。二者具体差异如下:
过程 | 差异 |
汇编语句 | hello.o的反汇编文件中,每条语句前都加上了具体的地址,同时在最前方加入了时钟周期信息。 |
操作数 | hello.s中操作数为十进制,反汇编代码中操作数为十六进制。 |
全局变量访问 | hello.s文件中对于全局变量的访问为.LC0(%rip),而在反汇编代码中是$0x0和0(%rip),这是由于全局变量的地址在运行时已得到确定。 |
分支跳转 | 反汇编代码中用相对偏移地址取代了hello.s中的标志位。 |
函数调用过程 | 反汇编代码中用相对于main函数的偏移地址表示函数地址,而不是hello.s中的函数名称。原因是函数在链接后运行的地址已得到确定。 |
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
4.5 本章小结
本章介绍了Hello程序汇编过程,并展示了ELF格式,最后分析了汇编代码与反汇编代码,对比了二者的区别,了解了汇编、反汇编这两种不相同的程序表现形式及原因,揭示了汇编语言到机器语言的转变过程。
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,链接执行符号解析、重定位过程。
作用:把可重定位目标文件和命令行参数作为输入,生成可以正常工作的可执行文件。这令分离编译成为可能,节省了工作空间。
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
运行结果如下:
5.3 可执行目标文件hello的ELF格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
Hello的ELF头:
与hello.o的ELF头相比,有以下几处不同:
- 文件的类型由可重定位文件变为可执行文件
- 程序的入口点、程序开始点、节头表偏移发生改变
- 共有27个节头表,增加了10个。
节头部表
程序头:
段节:
重定位节
5.4 hello的虚拟地址空间
使用edb加载hello,得到本进程的虚拟地址空间各段信息。其与反汇编代码中的虚拟地址可一一对应。
Hello程序头表在程序执行的时候被使用,作用是告诉链接器运行时应该加载的内容并提供动态链接的信息,提供了各段在虚拟地址空间和物理地址空间的大小,位置,标志,访问权限和对齐等信息。
5.5 链接的重定位过程分析
hello部分反汇编代码
运行objdump -d -r hello,得到上图所示反汇编代码,对比hello.o的反汇编代码,二者主要差异如下:
内容 | 差异 |
代码量 | hello的反汇编代码长度比hello.o大很多,这与链接后生成的hello中加入了hello.c中调用的其他函数(如printf,exit等)有关。 |
地址 | Hello反汇编代码中地址为虚拟内存地址,hello.o反汇编代码中为相对偏移地址。这是因为hello无需重定位。 |
代码内容 | hello的反汇编代码中增加了.init和.plt节以及节中定义的函数 |
重定位条目 | hello的反汇编代码无重定位条目。这是因为hello无需重定位。 |
由此可分析,链接包括以下4个过程:
- 判断输入文件是否为库文件,如果不是则是目标文件f,目标文件放入集合E中。
- 链接器解析目标文件中的符号,若出现未定义的符号则将其放入集合U,出现了定义但未使用的符号则放入集合D中
- 链接器读入crt*库1中的目标文件
- 接入动态链接库libc.so
重定位的步骤:
- 合并相同的节:链接器首先将所有相同类型的节合并成为同一类型的新节,例如所有文件的.data节合并成一个新的.data节,合并完成后该新节即为可执行文件hello的.data节。
- 确定地址:之后链接器再分配内存地址赋给新的聚合节和输入模块定义的节以及符号。地址确定后全局变量,指令等均具有唯一的运行时地址
5.6 hello的执行流程
图5.6a edb运行hello结果 图5.6b hello的执行流程
使用edb执行hello,结果如图5.6a所示。加载hello到_start,到call main,以及程序终止的过程如图5.6b所示。
5.7 Hello的动态链接分析
首先,根据节头部表找到GOT表地址,由图可知其在0x403ff0。
图5.7a _init前的Data Dump
图5.7b _init后的Data Dump
在edb中定位0x403ff0地址,并在_init前后设置断点,结果如图5.7a、图5.7b所示。由结果可知在dl_init前后,0x403ff0 处和0x404000 处的8bit数据分别由000000000000变为了c05fb1b3fe7e和90b1d2b3fe7e,GOT[1]指向重定位表(.plt节需要重定位的函数运行时地址),作用是确定调用函数的地址,GOT[2]指向动态链接器ld-linux.so运行时地址
5.8 本章小结
本章针对linux系统下链接的过程进行查看与介绍。链接是程序变成可执行文件的最后一步。通过链接,各代码段和数据段被整合到一起。本章通过在edb或终端查看hello的虚拟地址空间,对比hello.o和hello的反汇编代码等一系列过程,对重定位,执行流程和动态链接过程进行分析与概述。
第6章 hello进程管理
6.1 进程的概念与作用
作用:进程提供给应用程序的关键抽象;一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统
6.2 简述壳Shell-bash的作用与处理流程
shell的作用:提供一个界面,用户通过这个界面访问操作系统内核的服务。
处理流程:
1. 从终端读入输入的命令。
2. 将输入字符串切分获得所有的参数
3. 如果是内置命令则立即执行
4. 否则调用相应的程序为其分配子进程并运行
5. shell 应该接受键盘输入信号,并对这些信号进行相应处理
6.3 Hello的fork进程创建过程
执行hello后,bash解析此条命令,发现./hello不是bash内置命令,于是在当前目录尝试寻找并执行hello文件。此时bash调用fork函数创建一个子进程,子进程与父进程近似,并得到一份与父进程用户级虚拟空间相同且独立的副本——包括数据段、代码、共享库、堆和用户栈。二者间的PID不相同,fork函数会返回两次,在父进程中,返回子进程的PID,在子进程中,返回0。
6.4 Hello的execve过程
execve函数的原型为:
int execve(const char *filename,const charargv[],const char envp[])
execve()用来执行参数filename字符串所代表的文件路径,第二个参数利用指针数组来传递给执行文件,并且需要以空指针NULL结束,最后一个参数为传递给执行文件的新环境变量数组,其中每个指针指向一个环境变量字符串,每个串都是形如name=value的名字-值对。当execve加载了filename之后,它调用启动代码,启动代码设置栈,将控制传递给新程序的主函数main。当出现例如找不到filename的错误,execve将返回调用程序,与fork调用一次返回两次不一样,execve调用一次并从不返回。
execve开始执行hello有以下4个步骤
删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。即删除之前shell运行时已经存在的区域结构
映射私有区域。为hello的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text 和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
映射共享区域。hello程序与共享对象链接,比如标准C库1ibc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC)。最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的人口点
6.5 Hello的进程执行
6.5.1基本概念
时间片:指的是一个进程执行它的控制流的一部分的每一时间段,多任务也称为时间分片
用户模式和内核模式:处理器通过用某个控制寄存器的模式位实现限制一个应用可以执行的指令以及它可以访问的地址空间范围的功能,该寄存器描述了进程当前享有的特权,当设置模式位时,进程运行在内核模式中,一个运行在内核模式中的进程可以执行指令集的任何指令,并且可以访问系统中的任何内存位置。
当没有设置模式位时,进程运行在用户模式中,用户模式中的进程不允许执行特权指令,例如停止处理器,改变模式位,不允许进程直接引用地址空间中内核区的代码段和数据,此时用户程序必须通过系统调用接口间接访问内核代码和数据。
控制流:计算从加电开始,到断点位置,程序计数器的一系列PC的值的序列叫做控制流。
逻辑控制流:使用调试器单步执行程序时,会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。即逻辑控制流是一个进程中PC值的序列。
上下文:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。上下文切换示意图如下所示:
图6.5.1 上下文切换示意图
6.5.2 Hello的调度过程
图6.5.2 Hello程序调度过程
Hello调度过程如图6.5.2所示,具体如下:
Sleep调度:程序运行到sleep 函数时,sleep显式地请求让hello进程休眠。等到sleep的时间sleepsecs(这里为2秒,不是2.5秒)到达之后。从其他进程切换到hello继续执行。其过程如下:
- 从hello进程陷入内核模式
- 内核进行上下文切换,执行与hello并发的其他进程
- Sleep休眠时间结束
- 其他进程陷入内核
- 内核进行上下文切换,继续执行hello进程
正常调度:
当hello运行一定时间之后,虽然此时hello没有请求挂起自己,但系统会切换到其他进程,其他进程执行结束之后,hello再次被调度并继续接着上次的PC地址开始执行。调度的过程与sleep调度相似。
6.6 hello的异常与信号处理
正常运行:
图6.6a正常运行状态截图
程序完成被正常回收。
2)输入ctrl+z
图6.6b 输入ctrl+z后运行状态截图
这时输入命令PS查看进程,如下图所示:
图6.6c 输入ps后程序输出
使用jobs指令查看,得到以下结果,故可知,此时,hello进程没有结束,而是被暂时挂起,PID为45856。
图6.6d 输入jobs后程序输出
3)输入ctrl+c
图6.6e 输入ctrl+c、ps后程序输出
输入jobs,结果如下:
图6.6f 输入jobs后程序输出
输出为空,可以判断进程直接被终止,被回收.
4)输入kill
图6.6g 输入kill后程序输出
输入ps,结果如下:
当输入kill后进程被杀死,此时再输入ps指令后发现当前无进程执行。
5)随机输入
图6.6h随机输入后程序输出
在执行的程序中随机输入,可以看到输入被存入stdin中,按下回车键,在程序结束后会输出,但是对程序没有任何影响,如图6.6h所示:
6.7本章小结
本章结合示意图讲述了进程的概念与作用,简述了壳Shell-bash的作用与处理,介绍了hello的fork迸程创建过程以及execve过程,结合ps,jobs命令的输出介绍了hello的进程执行以及异常常与信号处理流程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
基本概念:
物理地址:物理地址是内存单元的绝对地址,与地址总线具有对应关系。无论CPU如何处理地址,最终访问的都是物理地址。CPU实模式下段地址+段内偏移地址即为物理地址,CPU可以使用此地址直接访问内存。物理地址的大小决定了内存中有多少个内存单元,物理地址的大小由地址总线位宽决定。
线性地址(虚拟地址):CPU在保护模式下,“段基址+段内偏移地址”为线性地址,如果CPU在保护模式下未开启分页功能,线性地址将被当成物理地址使用。若开启了虚拟分页功能,线性地址等同于虚拟地址,此时虚拟地址需要通过页部件电路转化为最终的物理地址。虚拟地址是CPU由N=2n个地址空间中生成的,虚拟地址即为虚拟空间中的地址
逻辑地址:无论cpu在什么模式下,段内偏移地址又称为有效地址/逻辑地址。Hello中的指令地址都是16位的虚拟地址,在程序中虚拟地址和逻辑地址没有明显的界限。逻辑地址转换成线性地址(虚拟地址),由段式管理执行的线性地址转换成物理地址,是由页式管理执行的
7.2 Intel逻辑地址到线性地址的变换-段式管理
概念:段式管理指的是把一个程序分成若干个段进行存储,每个段都是一个逻辑实体。段式管理是通过段表进行的,包括段号(段名),段起点,装入位,段的长度等。程序通过分段划分为多个块,如代码段,数据段,共享段等。
逻辑地址是程序源码编译后所形成的跟实际内存没有直接联系的地址,即在不同的机器上使用相同的编译器来编译同一个源程序则其逻辑地址是相同的,但在不同机器上生成的线性地址是不相同的。
逻辑地址转化为线性地址的过程:先检查段选择符的T1字段,该字段决定了段描述符保存在哪一个描述符表中,(比如转换的是GDT还是LDT中的段)。如果是GDT中的段(T1=0),分段单元从gdtr寄存器中得到GDT中的线性基地址。如果是LDT中的段(T1=1),分段单元从ldtr寄存器中得到GDT的线性基地址。之后再根据相应寄存器得到地址和大小。之后,由于一个段描述符字长为8bits,其在GDT或LDT中的相对地址是段选择符的最高13位的值×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的进程执行
系统中每个程序都运行在某个进程的上下文中。上下文是程序正确运行所需要的状态,由系统内核维持。
一个运行多个进程的系统,进程逻辑流的执行可能是交错的。每个进程执行它的流的一部分, 然后被抢占,轮到其他进程执行。一个逻辑流在时间上与另一个重叠ÿ
以上是关于程序人生-Hello’s P2P(CSAPP大作业)的主要内容,如果未能解决你的问题,请参考以下文章