计算机程序执行和计算

本文通过一个简单的加法的程序,从软硬件层面阐述计算机是如何进行程序执行调用、数值计算的。

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 存储模型概述

    如上图 计算机的存储模型图,从上到下依次为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。那么神奇的"异或"、"与"是如何实现的呢?这就需要知道模拟电路和数字电路相关的知识了。

    这里可以参考这篇文章:点击打开链接



  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值