本文通过一个简单的加法的程序,从软硬件层面阐述计算机是如何进行程序执行调用、数值计算的。
1 概述
现代程序员学习的计算机程序语言一般是高级语言,是人类可以理解但计算机无法理解的,计算机只能识别高电平和低电平,即0/1二进制编码,也叫机器语言。
如上图,最上层绿色为高级语言(包括面向对象的语言、可视化语言、面向过程语言),是人类可以看懂的。中间蓝色为汇编语言,汇编语言虽然晦涩但人类依然可以看懂,它其实是机器语言的记号形式,是早期的"高级语言"。现代依然保留汇编语言,一部分原因是因为机器硬件设备的不同,所能读懂的机器语言也不尽相同,不可能有编译程序可以兼容所有厂商,把高级语言翻译成任意想要的机器语言,汇编语言就起到了一个桥的作用,所有高级语言按各自规则翻译成汇编语言,所有机器硬件厂商只需对接汇编语言将其翻译成机器语言,即图中黄色部分。机器语言由0/1二进制编码组成,最早的纸带机里,打孔和不打孔分别代表0和1,机器可以直接读懂,但对人类就异常不友好了。
1.1 高级语言到汇编语言的过程概述(预处理和编译)
下面为一个简单的加法程序:#define A 2 //宏定义常量A
#define B 3 //宏定义常量B
int add(int a,int b)
{
return a+b;
}
int main()
{
int a=A;
int b=B;
int result=add(a,b);
printf("a+b=%d",result);
return 0;
}
计算机首先会对程序进行预处理,预处理的作用是将宏定义、条件编译指令、特殊符号等替换掉,生成一个平铺直续的程序:
int add(int a,int b)
{
return a+b;
}
int main()
{
int a=2;//宏定义已经被替换
int b=3;//宏定义已经被替换
int result=add(a,b);
printf("a+b=%d",result);
return 0;
}
计算机的编译程序对上边的代码进行编译,获得如下汇编代码,汇编代码的每一行都是一次cpu的操作:
_add:
push %ebx #函数开始内存地址
mov %eax, [%esp+8] #从esp偏移量取到数据2
mov %ebx, [%esp+12] #从esp偏移量取到数据1
add %eax, %ebx #两数相加
pop %ebx #取到结果
ret #函数退出
_main:
push 2 #压入数据
push 3 #压入数据
call _add #调用函数
add %esp, 8 #回收内存
ret #函数退出
可能现在还不是很清楚上面一段汇编代码每一条的作用,但大概可以看到对2+3、对add函数调用等的一系列操作,以目前的知识也能猜出个大概。具体的执行过程一定要结合内存来看,因为现代计算机结构是冯诺依曼体系的存储程序结构。
1.2 汇编语言到机器语言的过程概述(汇编)
汇编语言其实本身就是机器语言的记号表现,例如汇编里的加法:
add 2, 3
机器语言可能是:
0x00001212 0x00000010,0x00000011
0x00001212就是机器语言的add,cpu可以识别出来它。不做cpu的研发,只是理解计算机时,可以简单地认为汇编就是最底层的组成部分了,这里不再赘述。
2 从汇编语言理解程序执行
基于上文的描述,我们可以从汇编语言来了解到cpu执行的具体流程。此外cpu只是处理器(运算器),并不能存储程序和数据,所以必须存储器来辅助。所以本章节会从存储模型、程序内存模型、汇编语言几个方面,循序渐进的来阐述汇编程序执行。
2.1 存储模型概述
![](https://i-blog.csdnimg.cn/blog_migrate/44e5864aabe2d6c539c672b5bb0e86ed.png)
如上图 计算机的存储模型图,从上到下依次为cpu寄存器(register)、缓存(cache)、内存(ram)、硬盘(HD),速度依次递减,容量和成本依次递增。
上文的加法程序编译后获得的可执行程序,我们可以直接以文件的形式直接存储在硬盘上,就如平时我们在windows的c盘里看到的a.exe。当我们要执行程序时,运行硬盘上的程序(a.exe),此时程序会装载到内存里,然后cpu从内存读取程序并执行。但cpu的运行速度远高于内存,cpu会吃不饱,因此cpu厂商会给自己加缓存,缓存一级可能还不够,又会有二级三级。但缓存毕竟是按照地址来存储的(cpu想取一个数据,必须要到一个地址去拿,有寻址的操作),寻址的时间仍然会拖累cpu的速度,所以cpu干脆又给自己加了一些专用的存储器——寄存器,寄存器每个有自己的名字,不需要寻址,直接叫它的名字就能获得数据,所以速度极快。而汇编的指令里,操作的就是寄存器!
2.1.1 寄存器
寄存器镶嵌在cpu上,是cpu直接可以获得数据的存储接口。早期的x86 cpu只有8个寄存器,这8个寄存器的大名也留了下来。而现代cpu已经发展到了上百个寄存器,也不再有什么专门的名称了。
EAX
EBX
ECX
EDX
EDI
ESI
EBP
ESP
上面8个著名寄存器中,前七个为通用的寄存器,最后一个ESP为专用寄存器,中文名字叫栈顶指针寄存器。栈(stack)是内存里用到的一个数据结构,会在下文里详解。这个ESP就是在程序的运行中一直指向栈顶的,也就是总是指向着cpu读取内存数据当前位置。一个寄存器可以存多少位的的数据,比如32位、64位,cpu也就是所谓的32位、64位,位数越多,cpu能处理数据的能力也会相应越强。
2.2 内存
寄存器只能存放很少量的数据,大多数时候,CPU 要指挥寄存器,直接跟内存交换数据。所以,除了寄存器,还必须了解内存怎么储存数据。
程序运行的时候,操作系统会给它分配一段内存,用来储存程序和运行产生的数据。这段内存有起始地址和结束地址,比如从0x1000到0x8000,起始地址是较小的那个地址,结束地址是较大的那个地址。如图:
内存分配好之后,不能随便乱用,得有一些分类,这样才会高效,如图:
如上图,操作系统为运行的程序分配了空间,最上面一块是代码段,存储执行过程中的指令,例如push、mov、add等等。下方包含栈(stack)和堆(heap),栈是函数运行时的存放局部变量的空间,生命周期基本和函数执行一致。堆里的数据生命周期不取决于函数,函数执行完了堆里数据仍然存在,除非程序员在代码中指定了要释放堆里的数据,他的生命周期才会完结,所以不当的内存使用是很有可能出现内存泄漏的,即程序在使用完内存后没有回收,导致可用内存越来越少直至衰竭、程序崩溃。
2.2.1 栈(stack)以及函数执行时栈的变化
本文里,我们可以先抛开堆(heap),因为程序里不主动申请,是用不到堆的,我们只要从栈(stack)就可以了解到代码执行的过程,而且函数调用也是又栈调用来实现的。下面我们详细介绍下栈(stack)。
栈,就像是一个桶,单向开口,里面的每个单元叫帧,帧一层层叠摞在一起。生成新的帧,叫做"入栈",英文是 push;栈的回收叫做"出栈",英文是 pop。Stack 的特点就是,最晚入栈的帧最早出栈(因为最内层的函数调用,最先结束运行),这就叫做"后进先出"的数据结构。每一次函数执行结束,就自动释放一个帧,所有函数执行结束,整个 Stack 就都释放了。如图:
上面的例子里E后进来,就可以早出去,而A最早push进来的,如果想pop出去,就一定要等上层的其他帧全部pop出去后才能出来,这就是“先进后出”的特点。这个特点可以完美的适用于函数调用:主函数先入栈,子函数后入栈,子函数执行完毕后,出栈,主函数才可以出栈。
回到我们上面的程序实例:
int add(int a,int b)
{
return a+b;
}
int main()
{
int a=2;//宏定义已经被替换 ---------------------------------1
int b=3;//宏定义已经被替换 ---------------------------------2
int result=add(a,b); ---------------------------------3
printf("a+b=%d",result); ---------------------------------4
return 0;
}
当程序开始执行,操作系统约定好先找main函数,此时push main进入栈(stack),压入第一帧:
当程序执行到第1行(int a=1;)时,压入a数据进入栈,此时栈变为:
当程序执行到第2行(int b=2;)时,压入b数据进入栈,此时栈变为:
当程序执行到第三行时(int result=add),调用add函数,此时栈变为:
当add函数内加法计算完毕,返回值返回后,add函数的帧出栈(pop),main函数的帧才能出栈。后面的过程和上面叙述刚好相逆。
2.3 汇编语言
现在我们回过头看看示例程序的汇编代码
_add:
push %ebx #函数开始内存地址
mov %eax, [%esp+8] #从esp偏移量取到数据2
mov %ebx, [%esp+12] #从esp偏移量取到数据1
add %eax, %ebx #两数相加
pop %ebx #取得结果
ret #函数退出
_main:
push 2 #压入数据
push 3 #压入数据
call _add #调用函数
add %esp, 8 #回收内存
ret #函数退出
上面的代码注释,是在之前的知识基础上,根据字面意思我们大概写出来的,现在我们了解了寄存器、内存模型、栈等方面的知识后,对每一条指令进行解释。
首先,根据约定,程序从_main标签开始执行,这时会在栈(Stack)上为main建立一个帧,并将栈顶所指向的地址,写入ESP寄存器。后面如果有数据要写入main这个帧,就会写在ESP寄存器所保存的地址。下面的汇编代码和上文中的代码一模一样,只是调整了代码顺序,将add函数的调用,顺序的写在了程序里。
_main:
push 2 #将2压入esp所指向的栈顶,同时esp指向新的栈顶,esp-4
push 3 #将3压入esp所指向的新栈顶,同时esp继续指向更新的栈顶,esp-8
call _add #调用函数add的代码,并在栈中建立add函数的栈帧。
_add:
push %ebx #这个寄存器存放后边加法计算出的和,所以先取出来放在栈里,esp-12
mov %eax, [%esp+8] #从esp偏移量8字节的位置,取到数据2,即往栈底偏移8个字节,取得数据后放入eax
mov %ebx, [%esp+12] #从esp偏移量12字节取到数据1,取得数据后放入ebx
add %eax, %ebx #两数相加,并把和放入eax
pop %ebx #从esp栈顶中取得数据放入ebx中,esp-8
ret #函数退出
add %esp, 8 #将esp+8,即esp返回到函数顶,相当于回收了所有栈空间
ret #函数退出
自此,一个简单的加法程序汇编指令执行完毕。
3 从软硬件层面理解数值计算
在前面章节中,我们的示例小程序进行了2+3的计算,具体的汇编指令我们可以看到是:
add %eax,%ebx
寄存器eax中存在着3,ebx中存放着2,相加的结果存储在eax中返回。
当然计算机是使用二进制的,即eax存储0011,ebx中存放着0010。计算机加法在计算机里是按照“异或求和、与求进位”的方式进行计算的,“异或”即“相同为0,相异为1”,“与”即“必须全1才为1”。这里我们先套用规则来做依次加法运算:
0011
0010
------
???1 //最低位:计算因子是1和0。计算和:1异或0,相同为0,相异为1,即和1;计算进位:1与0,为0,所以进位为0
??01 //次低位:计算因子是1和1。计算和:1异或1,相同为0,相异为1,即和0;计算进位:1与1,为1,所以进位为1
?101 //第三位:计算因子是0和0,但有进位1。计算和:0异或0异或1,相同为0,相异为1,即和1;计算进位:0与0与1,为0,所以进位为0
0101 //第四位:计算因子全是0,进位也是0,结果自然也是0了,不在详述
经过上面套规则的计算,我们发现得出结果是0101,即5。那么神奇的"异或"、"与"是如何实现的呢?这就需要知道模拟电路和数字电路相关的知识了。
这里可以参考这篇文章:点击打开链接