计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2021111003
班 级 2103101
学 生 孙杨彬
指 导 教 师 刘宏伟
计算机科学与技术学院
2022年11月
本文以hello程序为中心,阐述了hello程序从预处理,编译,汇编,链接到进程管理的详细过程,并结合Linux虚拟机对其每一部分进行实际操作和分析,从hello的程序人生中系统地梳理了计算机系统课程的知识,加深了对于相关知识的理解和应用。
关键词:hello程序;预处理;编译;汇编;链接;进程管理;计算机系统
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 6 -
3.2 在Ubuntu下编译的命令............................................................................. - 8 -
4.2 在Ubuntu下汇编的命令........................................................................... - 12 -
5.2 在Ubuntu下链接的命令........................................................................... - 17 -
5.3 可执行目标文件hello的格式.................................................................. - 17 -
5.5 链接的重定位过程分析............................................................................... - 21 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 26 -
6.3 Hello的fork进程创建过程..................................................................... - 26 -
6.6 hello的异常与信号处理............................................................................ - 28 -
第1章 概述
1.1 Hello简介
P2P(From Program to Process):hello程序的生命周期是从一个高级C语言程序开始的,gcc编译器驱动程序读取源程序文件hello.c,并把它翻译为一个可执行文件hello。这个翻译过程分为4个阶段:
预处理阶段:预处理器cpp修改原始的C程序,得到hello.i。
编译阶段:编译器ccl将hello.i翻译成汇编语言程序hello.s。
汇编阶段:汇编器as将hello.s翻译成机器语言指令,并将指令打包成可重定位目标程序,保存在hello.o中。
链接阶段:链接器ld将可重定位目标文件链接成为可执行目标文件hello。
之后执行hello文件,shell调用fork函数创建子进程,调用execve函数加载进程,完成从program到process的过程。
020:hello程序执行前不占用内存空间,这是第一个0。子进程调用execve函数将hello加载到内存中并开始执行,hello执行完成后,被父进程回收,内核从系统中删除hello的相关内容,又回到了0。
1.2 环境与工具
硬件环境:CPU:11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
RAM:16GB
软件环境:Windows10 64位
VMware Workstation Pro16.2.4
Ubuntu20.04.4 64位
开发与调试工具:gcc,cpp,ccl,as,ld,gdb,edb,readelf
1.3 中间结果
hello.c:hello程序的源代码
hello.i:预处理后的C程序文本文件
hello.s:编译后的汇编语言程序文本文件
hello.o:汇编后的可重定位目标程序二进制文件
hello:链接后的二进制可执行目标文件
1.4 本章小结
在本章中,我们分析了hello程序的生命周期,列举了在撰写本文时用到的软硬件环境和开发调试工具以及我们在过程中所得到的中间文件。
第2章 预处理
2.1 预处理的概念与作用
1. 预处理的概念
预处理是在程序源代码被编译为二进制代码之前由预处理器对源代码进行处理,预处理器根据以字符#开头的命令,修改原始的C程序。
2. 预处理的作用
1. 将源文件中用#include形式声明的文件复制到新的程序中。
2. 用实际值替换用#define定义的字符串。
3. 根据#if后面的条件决定需要编译的代码。
2.2在Ubuntu下预处理的命令
gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i
图2-1 预处理
2.3 Hello的预处理结果解析
预处理后形成了文本文件hello.i,它将原来hello.c中用到的三个头文件stdio.h,unistd.h,stdlib.h中包含的文件插入到预处理后的文件中,并将源程序中的注释删除,预处理后的文件有3060行,其中最后的部分为原来的C文件中的源代码。
图2-2 预处理结果
2.4 本章小结
在本章中我们了解了预处理的作用和相关的指令,并结合hello.c程序的预处理过程对预处理进行了实践和分析。
第3章 编译
3.1 编译的概念与作用
1. 编译的概念
编译是将高级语言程序翻译为等价的汇编语言代码的过程。
2. 编译的作用
编译器会进行语法分析和词法分析,将高级语言程序转换为汇编语言,使计算机更容易理解和执行。
3.2 在Ubuntu下编译的命令
gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
图3-1 编译
3.3 Hello的编译结果解析
3.3.1 数据
1. 常量:
源程序中有两个字符串常量,在汇编代码中均被保存在只读数据段中,其中字符串”用法: Hello 学号 姓名 秒数!\n”以UTF-8的格式保存。
图3-2 常量
2. 变量:
源程序中有两个整型变量i和argc,i被保存在栈中,用movl进行赋值。
图3-3 变量
argc被保存在寄存器edi中,后被保存在栈中。
图3-4 变量
3.3.2 数组操作
源程序中有一个字符指针数组,其起始地址保存在寄存器rsi中,后被保存在栈中。
图3-5 数组
在循环中对数组元素进行访问时,采用基址加偏移量的方式进行,先将数组的起始地址保存在寄存器rax中,后通过加上偏移量的方式,得到对应的数组元素的地址,再通过得到的地址对数组元素进行访问。
图3-6 数组操作
3.3.3 算术操作
i++ :采用add指令来完成对i的加一操作。
图3-7 算术操作
3.3.4 关系操作
利用cmpl指令来完成数值的比较。
- argc!=4
图3-8 关系操作
- i<9
图3-9 关系操作
3.3.5 控制转移
1. if(argc!=4)
用跳转指令来实现条件判断,比较argc和4,根据结果设置条件码,通过条件码来决定跳转指令是否执行,从而实现控制的转移。
图3-10 控制转移
2. for(i=0;i<9;i++)
用跳转指令来实现循环控制,比较i和8,根据结果设置条件码,通过条件码来决定跳转指令是否执行,从而实现对循环控制的转移。
图3-11 控制转移
3.3.6 函数操作
1. printf
源程序中调用了两次printf,第一次调用时将字符串常量放在寄存器edi中作为参数传递给printf函数,其返回值保存在寄存器rax中。
图3-12 函数操作
第二次调用时同样将字符串常量放在寄存器edi中作为参数传递给printf函数,其返回值保存在寄存器rax中。
图3-13 函数操作
2. exit
调用exit函数时将1放在寄存器edi中作为参数传递给exit,其返回值保存在寄存器rax中。
图3-14 函数操作
3. atoi
调用atoi函数时将从argv数组中得到的第四个元素保存在寄存器rdi中作为参数将其传递给函数atoi,其返回值保存在寄存器rax中。
图3-15 函数操作
4. sleep
调用sleep函数时将atoi函数的返回值保存在寄存器edi中将其作为参数传递给函数sleep,其返回值保存在寄存器rax中。
5. getchar
getchar函数没有参数需要传递,直接对其进行调用即可,其返回值保存在寄存器rax中。
图3-16 函数操作
3.4 本章小结
在本章中我们了解了编译的概念和作用及其相关的指令,并通过对hello.s的汇编代码的具体分析,了解了C语言的各种数据如常量,变量,数组,结构,指针等在汇编代码中的实现方式,了解了C语言的各种操作如算数操作,逻辑操作,关系操作,条件控制,循环控制,函数调用中的参数传递与返回等在汇编代码中的具体实现方式,对汇编语言有了更进一步的了解。
第4章 汇编
4.1 汇编的概念与作用
1. 汇编的概念
汇编器将汇编语言程序翻译成机器语言指令,把这些指令打包成可重定位目标程序,并将结果保存在二进制可重定位目标文件中。
2. 汇编的作用
将汇编语言程序转换为机器语言指令,方便计算机可以直接执行。
4.2 在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -c -o hello.o hello.s
图4-1 汇编命令
4.3 可重定位目标elf格式
1. ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,剩下的部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小,目标文件的类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小和数量。
图4-2 ELF头
2. 节头部表
节头部表包含了每个节的信息,包括位置,大小等。
图4-3 节头部表
3. 重定位节
重定位节包含了需要被重定位的函数和全局变量的信息,包括需要被修改的引用的节偏移,被修改引用应该指向的符号,如何修改新的引用,被修改引用的值的偏移等信息,需要重定位的符号包括我们源程序中所使用的函数printf,atoi,exit,sleep,getchar等。
图4-4 重定位节
4. 符号表
符号表包含了汇编语言文件中各符号的信息,包括符号的地址和类型,目标的大小等信息。
图4-5 符号表
4.4 Hello.o的结果解析
1. hello.s中操作数是十进制的,反汇编代码中操作数是十六进制的。
2. hello.s中列出了每个段的段名,在分支转移中使用段名来进行跳转,反汇编代码中使用实际的地址来进行分支转移。
3. hello.s中的函数调用后直接跟着函数名,反汇编代码中的函数调用后跟着下一条指令的地址。
4. hello.s中对于printf语句中的全局变量字符串的使用直接通过段名进行,反汇编代码中则将其地址设置为0。
图4-6 反汇编代码
图4-7 hello.s
4.5 本章小结
在本章中我们了解了汇编的概念及其作用和相关的指令,并结合hello.o分析了可重定位目标文件的具体格式,同时通过对反汇编代码和原汇编代码的比较,进一步加深了对汇编过程的理解。
第5章 链接
5.1 链接的概念与作用
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
图5-1 链接
5.3 可执行目标文件hello的格式
1. ELF头
图5-2 ELF头
2. 节头表
图5-3 节头表
3. 重定位节
图5-4 重定位节
4. 符号表
图5-5 符号表
5.4 hello的虚拟地址空间
由图可知,hello的虚拟地址空间从0x0000000000401000开始,与5-3中的节头表对比可知两者所对应的节的名称和偏移量完全一致。
图5-6 虚拟地址空间
5.5 链接的重定位过程分析
1. hello.o与hello的不同
与hello.o的反汇编代码相比,hello的反汇编代码中增加了一些节:
.init:程序初始化需要执行的代码
.plt:动态链接-过程链接表
.fini:程序终止时需要的执行的指令
且在hello的反汇编代码中,对于函数调用,分支转移和全局变量访问的地址采用了虚拟内存的地址。
2. 链接的过程
在链接过程中,hello.o与crt1.o,crti.o,crtn.o,libc.so链接形成可执行文件,前三个库主要定义了程序入口_start,初始化函数_init,_start程序调用hello.c中的main函数。libc.so是动态链接共享库,其中定义了hello.c中用到的printf,sleep,getchar,exit,atoi函数和_start中调用的_libc_csu_init,_libc_csu_fini,_libc_start_main。链接器将这些函数加入可执行文件中。同时,链接器对需要进行重定位的函数和全局变量进行了重定位。
3. 重定位的过程
对于类型为R_X86_64_PLT32的重定位,即程序中用到的各个函数的重定位,采用相对引用进行重定位,此时动态链接库中的函数已经加入到了PLT中,.text与.plt节相对距离已经确定,链接器计算相对距离,将对动态链接库中函数的调用值改为PLT中相应函数与下条指令的相对地址,指向对应函数。
对于类型为R_X86_64_32的重定位,即程序中用到的两个全局变量字符串的重定位,采用绝对引用进行重定位,此时这两个字符串的存储位置已经确定,链接器将其对应的地址写入对于两个字符串引用的对应位置。
图5-7 hello的反汇编代码
5.6 hello的执行流程
使用edb单步执行hello,得到程序调用的子程序名:
ld-2.31.so!_dl_start
ld-2.31.so!_dl_init
hello!_start
libc-2.31.so!__libc_start_main
hello_printf@plt
hello!atoi@plt
hello_sleep@plt
hello!getchar@plt
libc-2.31.so!exit
5.7 Hello的动态链接分析
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。链接器采用延迟绑定的方法,将过程地址的绑定推迟到第一次调用该过程时,动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
在节头部表中可以看到,.got.plt的起始地址为0x404000。在dl_init前,0x404008后16个字节的内容都为00,而在dl_init后,其内容为0x7f2f522aa190和0x7f2f52293bb0。
图5-8 .got.plt
图5-9 dl_init前
图5-10 dl_init后
5.8 本章小结
在本章中,我们了解了链接的概念和作用及其相关的指令,根据对hello.o文件的链接过程和结果的分析,我们了解了可执行目标文件hello的格式,通过对链接前后的文件的反汇编代码的比较分析,我们了解了链接的过程及重定位的具体实现方式,同时借助edb工具,我们分析了hello程序的执行过程和动态链接的过程,让我们对链接的相关知识有了更深入的理解和应用。
第6章 hello进程管理
6.1 进程的概念与作用
1. 进程的概念
进程是一个执行中的程序的实例。系统中每一个程序都运行在某个进程的上下文中。上下文是程序正确运行所需的状态的组成的。这个状态包括存放在内存中的程序的代码和数据,程序栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
2. 进程的作用
进程给应用程序提供了关键抽象:
一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占的使用处理器。
一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
1. 壳Shell-bash的作用
Shell-bash是一个交互型应用级程序,代表用户运行其他程序,为用户提供了一个向Linux内核发送请求以便运行程序的界面系统级程序。Shell-bash能够接收用户输入的命令,并把它送入到系统内核中执行。
2. 壳Shell-bash的处理流程
1. 从终端读入输入的命令。
2. 将输入字符串切分获得所有的参数
3. 如果是内置命令则立即执行
4. 否则调用相应的程序执行
6.3 Hello的fork进程创建过程
在终端输入命令行./hello 2021111003 孙杨彬 1后,由于hello不是内置命令,因此shell执行当前文件夹中的可执行文件hello。shell调用fork创建一个新的子进程并在该进程下执行hello。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,这就意味着,当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的PID。
图6-1 hello的fork进程创建过程
6.4 Hello的execve过程
当创建了一个新运行的子进程后,子进程调用execve函数在当前子进程的上下文中加载并运行hello程序。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC指向_start地址,_start最终调用hello中的main函数。
6.5 Hello的进程执行
1. 上下文
内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器,浮点寄存器,程序计数器,用户栈,状态寄存器,内存栈和各种内核数据结构,比如描述地址空间的页表,包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
2. 进程时间片
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
3. 上下文切换
1. 保存当前进程的上下文
2. 恢复某个先前被抢占的进程被保存的上下文
3. 将控制传递给这个新恢复的进程
4. 进程调度
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中成为调度器的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。
5. 用户态与核心态转换
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断,故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
6.6 hello的异常与信号处理
Hello在执行过程中会出现4类异常:
1. 中断 处理器读取异常号,调用中断处理程序,返回下一条指令
2. 陷阱 陷阱处理程序在内核态中完成fork工作,返回syscall之后的指令
3. 故障 处理器执行故障处理程序,若修正故障则返回到引起故障的指令否则终止程序
4. 终止 Abort例程终止该程序
图6-2 乱按结果
乱码被认为是命令而进行执行。
图6-3 Ctrl-C结果
按下Ctrl-C后程序终止。
图6-4 Ctrl-Z结合其它指令结果
按下Ctrl-Z后程序暂时挂起。输入ps显示进程信息,输入pstree以树的形式显示进程信息,输入jobs显示作业信息,输入fg进程在前台继续执行,输入kill终止程序。
6.7本章小结
在本章中我们了解了进程和shell的相关知识,通过hello程序的运行我们了解了进程创建和加载相关的函数,对进程的相关知识有了更深入的理解和应用,同时通过对hello程序运行过程中可能出现的异常和信号的实践和分析,我们对异常和信号的相关知识有了更深入的理解。
结论
hello的一生从程序员敲出一个个字符形成hello.c源程序开始,预处理器cpp根据符号#开头的指令修改原始的C程序得到hello.i;汇编器ccl将hello.i高级语言翻译成汇编语言程序得到hello.s;汇编器as将hello.s汇编语言程序翻译成机器语言指令并打包成可重定位目标程序hello.o;链接器ld将hello.o同库函数文件等其他可重定位目标文件合并得到可执行目标文件hello,之后执行hello,shell调用fork函数创建一个子进程,子进程调用execve函数将hello加载到内存中并开始执行,执行完成后父进程回收子进程,内核将hello从内存中删除,hello的一生也随之结束。
尽管hello程序是一个简单的程序,但它的一生却反映出了计算机系统的复杂设计,通过对计算机系统课程的学习,我对hello程序背后的计算机系统的知识有了深入的学习和了解,但计算机中还有着更多的奥秘等待着我们去探索。
附件
hello.c:hello程序的源代码
hello.i:预处理后的C程序文本文件
hello.s:编译后的汇编语言程序文本文件
hello.o:汇编后的可重定位目标程序二进制文件
hello:链接后的二进制可执行目标文件
参考文献
[1] Computer Systems: A Programmer’s Perspective, Third Edition (CS:APP3e), Pearson, 2015 深入理解计算机系统 3-机械工业出版社