本文通过对hello.c文件在linux系统下的生命周期的分析,简单地介绍计算机的整个运行过程。
结合GCC、objdump等工具,本文首先介绍hello.c的预处理、编译、汇编、链接过程,然后介绍可执行文件hello的加载、运行、IO设备交互、异常处理。
通过这样一个完整的分析过程,将课本知识落实到具体的操作。
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
硬件环境:
英特尔 Core i7-7700HQ @ 2.80GHz 四核
一级数据缓存 4 x 32 KB, 8-Way, 64 byte lines
一级代码缓存 4 x 32 KB, 8-Way, 64 byte lines
二级缓存 4 x 256 KB, 4-Way, 64 byte lines
三级缓存 6 MB, 12-Way, 64 byte lines
软件环境
Ubuntu64 18.04
开发工具
Shell、vscode、readelf、objdump、ld、gcc
C 预处理器不是编译器的组成部分,但是它是编译过程中一个单独的步骤。简言之,C 预处理器只不过是一个文本替换工具而已,它们会指示编译器在实际编译之前完成所需的预处理。我们将把 C 预处理器(C Preprocessor)简写为 CPP。
所有的预处理器命令都是以井号(#)开头。它必须是第一个非空字符,为了增强可读性,预处理器指令应从第一列开始。
预处理器主要负责以下任务:
预处理命令: gcc -E -o hello.i hello.c -v
预处理之后,预处理器将注释消除。执行#include的指令,将其使用的库函数内容展开。
预处理器仅仅将代码进行中间翻译。不产生汇编代码或者二进制代码。其产生预处理文件之后将剩下的工作留给编译器。
编译程序(Compiler,compiling program)也称为编译器,是指把用高级程序设计语言书写的源程序,翻译成等价的机器语言格式目标程序的翻译程序。编译程序属于采用生成性实现途径实现的翻译程序。它以高级程序设计语言书写的源程序作为输入,而以汇编语言或机器语言表示的目标程序作为输出。编译出的目标程序通常还要经历运行阶段,以便在运行程序的支持下运行,加工初始数据,算出所需的计算结果。
命令: gcc -S -o hello.s hello.i -v
在此,首先将hello.c的源代码给出(注释为语句涉及到的功能):
#include
#include
#include
int sleepsecs=2.5; //全局变量
int main(int argc,char *argv[])
{
int i;//局部变量
if(argc!=3)//if控制语句
{
printf("Usage: Hello 学号 姓名!\n");//函数操作,数组常量
exit(1);
}
for(i=0;i<10;i++)//赋值操作, ++算术操作, < 运算操作 for循环操作
{
printf("Hello %s %s\n",argv[1],argv[2]);//函数操作
sleep(sleepsecs);//函数操作
}
getchar();//函数调用
return 0;
}
本程序中仅有1个全局变量:
int sleepsecs=2.5;
再汇编语言文件hello.s中,全局变量sleepsecs保存在段rodata 中,在程序运行时仅仅作为只读变量。这里与源代码中的定义稍有不同,源代码中sleepsecs是作为int整形可改变的变量,而在汇编文件中被翻译为只读变量,相当于加了const修饰。
其产生的原因是:是编译器察觉到sleepsecs在后面的程序中没有被任何语句赋值,于是将其优化为只读变量。
if语句在汇编中被翻译为如下语句
其中,27是读取传入主函数的参数argc,29是条件判断,30则是跳转控制语句。语句30以下对应argc!=3的代码内容。.L2对应argc==3的内容。
本程序中的局部变量只有1个:
int i;
且i是for循环的循环控制变量,只有在循环控制中才被赋值、更新值
在本程序的汇编文件中,i的值存储在栈中,初始化语句在.L2节中。
如下:
movl $0, -4(%rbp) //for循环初始化 i =0
然后在循环只用对i的引用都是通过栈中的数据进行访问。这一点与x86-64中通常使用寄存器保存局部变量的这一行为稍微有所不同。
紧接3.3.2中的局部变量进行解释。for循环编译过程中被拆分为3个部分:初始化变量、条件判断语句、循环体。
初始化变量
C语言中的变量初始化语句为: for(i=0;i<10;i++)
中的 i=0
。在汇编中对应为.L2小节的代码:
在这里,为局部变量i分配为空间,使用栈中 %rbp所指的地址之前的-4字节处为开头,存储一个4字节的0。
由于栈是向着低位地址生长,这里使用的空间为main()函数调用产生的栈空间中。
条件判断语句
C语言中的条件判断语句为:for(i=0;i<10;i++)
中的i<10
。
在汇编中对应为.L3小节的部分代码:
这里引用之前为局部变量i分配的栈空间中的地址。从而达到访问局部变量i的目的。
将i于9进行比较。当i<=9时跳转到.L4小节中。这里对应于条件控制中的i<10
。二者是完全等价的。其中,.L4小节为循环体。
这里通过.L3的两条汇编语句,实现了for循环中的条件判断功能。
3. 循环体
循环体的所有内容都保存在.L4节中。但是.L4中的汇编语句并非全部是循环体中的内容。在.L4的最后一句汇编语句:
其功能为加1到i变量中,即实现for循环for(i=0;i<10;i++)
中的 i++
控制语句。
在这里,我们选择如下语句的函数调用来分析hello.c中的函数是如何进行调用的。
在这里,printf()函数有3个参数。x86-64汇编语言参数是通过寄存器加栈来传递参数的。根据其传输规则:
第一个参数放入%rdi寄存器、第二个放入%rsi,第三个放入%rdx中。
在汇编文件中,gcc产生的代码用如下形式构造与传递参数。
首先构造第三个参数,38、39句语句将栈中保存的argv[2]指针的地址保存到%rax中,然后40句将栈中保存的argv[2]的指针赋值给%rdx,从而构造好了第3个参数。
第二个参数的构造与第三个参数的构造方法完全相同。41、42、43、44句成功地构造第二个参数。
第三个参数使用到了一个字符常量 "Hello %s %s\n"
。在汇编中,其保存为.LC1标签中。找到其汇编中的位置,确实是在C语言中的字符常量。
最后初始化其返回值%eax为0,48调用函数printf()
编译是将高级语言翻译为汇编语言的过程,本章中列举了几个c语言的语句转化为汇编语言的例子。GCC等编译器将c语言的每一种结构(比如if条件控制、for循环)翻译为汇编中的代码。C语言中的变量名消失了,取而代之的是一些及存取数据、栈数据、内存数据。
由于本次编译使用的普通翻译,GCC的优化功能没有打开。在实际编译中,编译器可以根据代码的特点进行效率的优化。
概念:把汇编语言翻译成机器语言的过程称为汇编
作用:将文字形式的汇编代码转化为真正的机器可以执行的二进制代码。
汇编命令:gcc -c hello.s -o hello.o -v
这里仅仅分析部分机器语言与汇编语言中稍微有差距的语句。汇编中与机器 代码反汇编之后完全相同的不做讨论
本章中对 hello.s 进行了汇编操作,生成可重定位文件 hello.o Hello.o 的 elf 信息进行了分析。elf 中重要的几个节头表为代码段、数据段、 代码重定位段、数据重定位段、只读数据段、符号表段。 对汇编代码和机器代码中稍微有所差别的代码进行了分析。产生差别的主要 原因在于对符号的引用(变量或者函数)。
链接是将各种代码和数据片段收集组合成一个单一文件的过程。这个文件可以直接被加载到内存中执行。
链接可以发生在编译时、加载到内存时、运行时。
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
关键信息:
部分节的虚拟地址:
PLT的数据内容从地址0x400418开始,这一点在edb中可以证实。在节头表中其也用offset表示出了PLT段的起始地址为0x400418
.text的数据内容从地址0x4004d0开始,这一点在edb中可以看到。在.text的节头表中也用offset表示出了.text段的起始地址为0x4004d0
Hello的执行前,系统为创建子进程,从内存加载数据,然后跳转到_start()函数。
对于共享库的调用:在程序中第一次执行共享库函数的时候才会调用动态链接器进行链接。以printf()函数为例子进行说明
首先使用gdb打开hello文件,在main处打上断点
然后输入命令 r 1170801219 yangjin
开始运行程序。
之后程序停在断点main处。
在这里我们使用命令tui enable
打开tui调试窗口,
然后为了方便观察反汇编命令,使用命令layout asm
打开反汇编窗口
使用si命令让程序继续执行,直到调用第一个共享库printf()函数之前
然后程序进入plt段的代码。plt段代码一共3条指令
第一条指令使用GOT表中的4个元素(即GOT[3]),跳转到GOT[3]中的地址所指向的代码。由于这是第一次调用printf()函数,GOT[3]中的值应该指向plt中的第二条指令。
第二条指令将printf()函数的编号压入栈,然后第三条指令跳转到plt段的第1个plt条目的代码中。
plt的第一个条目是调用动态连接器的条目.于是,控制被转入动态链接器。动态链接器将共享库函数载入内存,然后根据载入的库函数地址修改GOT表中的表项,这里是GOT[3]的内容.之后再调用库函数printf()
在这一次调用printf()之后,再次调用printf()函数时,仍然会执行第一条指令,但是由于之前已经调用过一次printf()函数,GOT[3]中保存已经加载入内存的printf()函数的地址.所以plt中的第一条指令会直接跳转到printf()函数.
在链接的过程中,链接器将程序在汇编时留下的地址空槽填上虚拟地址。填入的值取决于对符号的引用类型。地址表示一共有2中类型,一是绝对地址、二是相对地址。比如:全局变量引用使用绝对地址;函数调用、函数内跳转使用PC相对地址。链接结束之后,程序就可以直接加载到内存中执行了。
执行时,Hello加载入内存之后系统会调用_start函数来启动hello函数。_start调用__libc_start_main函数,__libc_start_main为hello创建好运行环境,然后再调用hello的main函数。
在main函数执行过程中,第一次调用
main函数执行结束之后。返回__libc_start_main函数,然后__libc_start_main处理main 函数的返回值。并且返回_start函数。
概念: 进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用: 提供2个关键的抽象
作用:每次用户通过向shell输人一个可执行目标文件的名字,运行程序时, shell就会创建个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。
处理流程:
再执行hello时,shell调用execve函数开始执行hello,execve开始执行hello需要如下几个步骤
基本概念:
控制流:计算从加电开始,到断点位置,程序计数器的一系列PC的值的序列叫做控制流。
逻辑控制流:使用调试器单步执行程序时,会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。即逻辑控制流是一个进程中PC值的序列
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
Hello的调度过程:
Sleep调度:
程序运行到sleep 函数时,sleep显式地请求让hello进程休眠。等到sleep的时间sleepsecs(这里为2秒,不是2.5秒)到达之后。从其他进程切换到hello继续执行。其过程如下:
进程就是一个执行中的程序。他有独立的逻辑控制流和私有的地址空间。
在Shell中输入命令之后,shell就解析命令。
当其解析到输入的命令是需要执行程序hello时,shell调用fork函数创建一个子进程,并且在子进程中调用execve函数加载程序hello。
Hello加载完毕之后开始执行,在hello执行的过程中,内核会同时执行其他的进程,即hello与许多进程并发执行。Hello执行一定时间之后,内核调度其他进程执行。Hello也会调用sleep函数显式地要求内核将其挂起一段时间。
在hello执行时收到信号会触发相应地异常处理代码。
物理地址(physical address)
用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
——这个概念应该是这几个概念中最好理解的一个,但是值得一提的是,虽然可以直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址,但是事实上,这只是一个硬件提供给软件的抽像,内存的寻址方式并不是这样。所以,说它是“与地址总线相对应”,是更贴切一些,不过抛开对物理内存寻址方式的考虑,直接把物理地址与物理的内存一一对应,也是可以接受的。也许错误的理解更利于形而上的抽像。
逻辑地址(logical address)
Intel为了兼容,将远古时代的段式内存管理方式保留了下来。逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址,即程序的机器代码中保存的地址。以上例,我们说的连接器为A分配的0x08111111这个地址就是逻辑地址。
线性地址(linear address)或也叫虚拟地址(virtual address)
跟逻辑地址类似,它也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址
Hello的地址转换:
程序hello中将一个虚拟内存空间中的地址转换为物理地址,需要进行两步:首先将给定一个逻辑地址,CPU要利用其段式内存管理单元,先将为个逻辑地址转换成一个线程地址,再利用其页式内存管理单元,转换为最终物理地址。
GDT:全局段描述符表。在整个系统中,全局描述符表GDT只有一张(一个处理器对应一个GDT),GDT可以被放在内存的任何位置,但CPU必须知道GDT的入口,也就是基地址放在哪里,Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址。
LDT:局部描述符表。局部描述符表可以有若干张,每个任务可以有一张。
转换过程
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、根据段选择符的T1选择GDT或者LDT。T1=0则选择GDT,T1=1则选择LDT。
2、根据段选择符中前13位,在GDT或者LDT中选择对应的段描述符,从段描述符中抽取处Base。
3、Base + offset就是最后的线性地址。
Linux下的段式管理
在Linux下,逻辑地址与线性地址总是一致的,即逻辑地址的偏移量字段的值与线性地址的值总是相同的。
由上一节,我们已经得到了hello运行过程中的线性地址,接下来就需要通过线性地址得出数据的物理内存。
Hello的execve过程以及内存映射已经在6.4节予以阐述,这里不再赘述。
MMU在试图翻译某个虚拟地址A时,发现内存中没有A所在的那一页。此时触发一个缺页异常,这个异常导致控制转移到内核的缺页处理程序。
处理程序首先执行2个判断:
Printf会调用malloc,下面简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同, 但是不失通用性,堆紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做“ break”),它指向堆的顶部分配器将堆视为一组不同大小的块( block)的集合来维护。
每个块就是一个连续的虚拟内存片( chunk), 要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格:
显式分配器( explicit allocator):要求应用显式地释放任何已分配的块。
隐式分配器( implicit allocator):分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器( garbage collec tor),而自动释放未使用的已分配的块的过程叫做垃圾收集( garbage collection)。
Hello中涉及到的地址都保存在编码中,保存在编码中的地址成为逻辑地址。在CPU层面上,程序需要将逻辑地址交给CPU,然后得到线性地址。在这个过程中我们了解了intel 的段式管理
Linux中线性地址与虚拟地址一致,所以得到线性地址之后,需要经过变换得到物理地址。于是我们通过Linux的页式管理得到了数据的物理地址。当然,在这个过程中,我们首先查询TLB中有无PTE的缓存,若没有缓存时还需通过4级页表查找保存在内存中的PTE。通过PTE才能得到我们需要的物理地址。
得到物理地址之后先访问高速缓存,若miss,则继续向下一级高速缓存访问。如果向下知道主存仍未找到数据,则触发缺页中断。等到页面载入之后程序再次访问这一数据。
内核为系统中的每个进程维护一个单独的任务结构,当内核待用fork与execve时,内核未新进程准备好新的虚拟地址空间。
设备的模型化:所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输人和输出都被当作对相应文件的读和写来执行。
设备管理:设备的模型化将设备映射为文件,这就允许 Linux内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行
int open(char* filename,int flags,mode_t mode)
:int close(int fd)
,fd是需要关闭的文件的描述符,close返回操作结果。ssize_t read(int fd,void *buf,size_t n)
,read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。ssize_t wirte(int fd,const void *buf,size_t n)
,write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。printf()函数的源代码如下:(来源:Linux libc6)
int
__printf (const char *format, ...)
{
va_list arg;
int done;
va_start (arg, format);
done = vfprintf (stdout, format, arg);
va_end (arg);
return done;
}
可以看到,printf()函数将变长参数的指针arg作为参数,传给vfprintf函数。然后vfprintf函数解析格式化字符串,调用write()函数。
下面用一个函数说明格式化的过程。
Vsprintf的示意函数(仅格式化16进制字符)如下
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);
}
从代码中可以得知,函数将格式化字符串与待填入字符相结合,从而产生真正的输出字符。库中真正的输出包含更多功能。
得到输出字符之后调用系统函数wirte():
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里制造了几个参数,然后进行sys_call。
syscall将字符串中的字节“Hello 1170801219 yangjin”从寄存器中通过总线复制到显卡的显存中,显存中存储着字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。然后字符就显示在了屏幕上。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求。CPU收到中断请求后,挂起当前进程,然后运行键盘中断子程序。键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符(\n)然后返回整个字串到stdin中。
Linux系统将IO设备抽象为一个一个的文件,简化了程序的书写。
Printf函数与getchar函数都是通过系统调用将字符输入到终端的。
Hello的一生就此结束,以下为hello一生中的节点:
http://www.runoob.com/cplusplus/cpp-preprocessor.html
https://baike.baidu.com/item/编译程序/8290180?fr=aladdin
https://baike.baidu.com/item/汇编/627224?fr=aladdin
https://images2017.cnblogs.com/blog/733013/201708/733013-20170823180406714-1520031644.png
https://www.cnblogs.com/zengkefu/p/5452792.html
http://life.chinaunix.net/bbsfile/forum/linux/month_0801/20080115_446d77416115bf806a6eolV4lJub7IYi.jpg
http://life.chinaunix.net/bbsfile/forum/linux/month_0801/20080115_23beaf30e70dfb78f97bukhHmAGFgIdM.jpg
https://blog.csdn.net/u014774781/article/details/47706213
https://www.cnblogs.com/zengkefu/p/5452792.html
https://www.cnblogs.com/pianist/p/3315801.html