写这篇博客就是想要解释一个我自己在学习过程中比较困惑的问题:我们所写的C语言代码是如何在计算机中运行起来的?首先需要的知识储备就是计算机里有哪些硬件,这些硬件之间是怎么配合着执行一条指令的,然后再分析,我们所写的代码有哪些指令构成,这些指令是怎么执行的。
一、计算机的硬件结构
说明:这里cpu内我没有把寄存器单独画出来,而是将一部分主要的寄存器画在了各个硬件里
总的来说:主存是用来存储数据的,运算器是用来执行算术逻辑运算的,控制器可以控制各个硬件,负责从内存中取出指令,对指令进行分析,从而指挥指令有条不紊的执行。
具体来看各个部件:
1、运算器:运算器里主要执行算术、逻辑运算的是ALU,而ACC、MQ、X都是寄存器,用来存储参与运算的操作数或者ALU执行运算后的结果。(具体哪些可以用来存结果,哪些可以用来存操作数等知识这里不赘述)
2、控制器:控制器里对指令进行分析进而控制运行的是CU,PC是程序计数器,存放的是下一条要执行的指令的地址,会自动+1,IR是指令寄存器,存放具体的指令
3、主存:也就是通常所说的内存,除了用于存储数据的存储单元以外,还有MAR和 MDR两个寄存器,要想从内存中读取数据,需要将数据的地址放进MAR中,内存再将MAR中地址对应的数据放进MDR中(写操作类似)
二、各个硬件是怎样配合着执行一条指令的
首先,我们所写C/C++这样的代码会经过编译、链接产生一个可执行文件(二进制的机器指令)存在我们的磁盘中,在执行前会被加载到内存中(执行时二进制的机器指令和数据一样,都是存放在内存中的,每一条指令也有其对应的地址)
以执行这条指令为例,康康各个硬件执行时市怎样配合工作的:
y=a*b+c;
假设内存布局如下图:
与之对应得执行步骤为:
1、pc=0(指向第一条指令),将pc的内容赋给MAR寄存器,使得MAR=0,且控制器告诉内存,此次执行读操作
2、内存找到MAR存储的地址所对应的存储单元,将该单元内的数据放进MDR中,使得MDR=0000010000000101
3、MDR中所存储的内容赋给IR寄存器,使得IR=0000010000000101
前三步完成了取指令操作
4、IR将操作码部分(000001)传递给CU,由CU分析出该条指令是要进行取数
5、IR将操作数部分(0000000101)传递给MAR
6、内存找到与MAR存储的地址对应的存储单元,并将该存储单元的内容放进MDR,使得MDR=0000000000000010
7、将MDR中的内容放入ACC寄存器中
此时,第一条指令执行完,pc=1(指向第二条指令),下面的步骤与上面类似都是:取值、分析、在控制器的控制下执行,在此不再赘述。
三、函数栈帧的创建和销毁
上面分析了单独一条指令在计算机中是怎样执行的,但是我们平时所写的C代码都至少有一个主函数,函数在执行函数体指令之前,会创建函数栈帧,也就是说:我们所写的C代码,经过编译之后所产生的二进制代码,不仅仅包含函数体部分所对应的指令,还在函数体指令前面多出一部分指令,这一部分指令就是为函数创立栈帧用的,函数调用结束后创建的栈帧会自动销毁。VS编译器调试环境中的反汇编,可以通过汇编代码清楚的看到,函数栈帧创建和销毁动作是怎样完成的(本人使用的是VS2010,不同编译器可能存在略微差异)
下面将通过一个例子分析来说明,函数栈帧创建、销毁以及函数传参的过程:
补充:push是压栈操作,pop是出栈操作,ebp是栈底指针,esp是栈顶指针(会自动+1),可以通过下图中调用堆栈窗口发现,main()函数是由_tmainCRTStartup()调用的。
假设 _tmainCRTStartup()调用main()函数前,栈底指针和栈顶指针如下图:
开始执行main()函数之后,首先执行的三步是(汇编指令前的地址是该条指令的地址):
00641400 push ebp :将ebp内容压入栈帧
00641401 mov ebp,esp :将esp内容放入ebp中
00641403 sub esp,0E4h :esp减去0E4h
执行完这3步之后,内存布局如下图
00641409 push ebx
0064140A push esi
0064140B push edi :三个压栈操作
0064140C lea edi,[ebp-0E4h] :将三个压栈操作之前的栈顶指针加载到edi中
执行完这4步之后,内存布局如下图
00641412 mov ecx,39h
00641417 mov eax,0CCCCCCCCh
0064141C rep stos dword ptr es:[edi]
这3步的作用是将main函数栈帧空间内的数据都改为:0CCCCCCCCh,这也就是为什么我们定义一个局部变量,如果不进行初始化,里面的随机的为CCCCCCCC这样的值,执行完之后的内存如下图所示:
前面这些步骤执行完后,才开始真正执行我们所写的代码 (每句C代码下面是其对应的汇编代码)
int a=10;
0064141E mov dword ptr [a],0Ah
int b=20;
00641425 mov dword ptr [b],14h
int c=0;
0064142C mov dword ptr [c],0
这3步就是给a,b,c变量对应地址内的值改为变量的值(怎样为变量分配地址跟编译器有关,不同编译器可能不同,其实我的编译器两个变量之间地址是相差8个字节的,为了方便我画成了4个字节)
下面的代码是调用add函数,但是通过下面的汇编代码可以看出,在调用函数之前先传递参数,并且传参的顺序是由右向左
mov eax,dword ptr [b]
push eax
mov ecx,dword ptr [a]
push ecx
下面在调试环境下按F11逐语句执行发现:在进入 add函数之前,call指令会先把下一条指令的地址压栈,从我第一张截图可以看到,call指令的地址是:0064143B,call指令的下一条指令的地址是:00641440,也即call指令会把00641440这个地址压栈,然后进入add函数内,内存布局变为:
进入add函数后的汇编代码如下图:
函数体内 步骤与进入main()函数时一致,这里return z 这个操作是把z变量的值放入eax寄存器中,执行完之后,内存布局如下图:
006413E1 pop edi
006413E2 pop esi
006413E3 pop ebx :三个出栈操作
006413E4 mov esp,ebp :栈空间销毁
006413E6 pop ebp :出栈,内容放进ebp
006413E7 ret :来到call指令的下一条指令(之前将call指令的下一条指令地址压栈就是为了函数调用结束后能返回)
上述代码执行完之后的内存布局变为:
紧接着回到call指令的下一条指令依次执行,本代码中下一条指令为:
add esp,8 :销毁形参
mov dword ptr [c],eax:将eax中存放的z的值,放入变量c中
执行之后内存布局为:
接下来就是main()执行结束后,栈空间的销毁,跟前面add函数栈空间销毁的步骤一样,就不再赘述啦。