你的位置:首页 > 操作系统

[操作系统]从C到汇编:栈是计算机工作的基础

 
       作者:r1ce
       原创作品转载请注明出处
      《Linux内核分析》 MOOC课程http://mooc.study.163.com/course/USTC-1000029000
 
       关于计算机是如何工作的,这是一个容易概括却难以详解的问题。大家非常清楚的冯诺依曼体系,以存储程序为最重要的特性,实际上就是CPU像一个大管家一样,通过种种方式在浩如烟海的内存中,找出需要执行的指令,和需要使用的数据。那么CPU如何区分指令和数据,如何知道确定指令执行的顺序呢?
       我们先从上至下来看计算机。普通用户使用计算机上的软件,软件是由程序员编写的,一般使用高级语言,如Python、C、Java等,这些语言易于人类理解、阅读和编写,但是计算机却不能直接识别。无论是Python还是C,前者需要通过解释器来执行,后者需要编译器编译为可执行文件。计算机最底层的实现是基于电路实现0和1的识别,这也是可执行文件的真貌——一大堆0和1的表示。那么高级语言到0和1之间,看起来好像隔着很大的一条鸿沟,于是汇编语言作为二者的中介,便显得十分重要了。向上而言,高级语言可以用汇编语言表示;向下而言,每一个汇编语言的指令都可以用二进制0和1表示,从而被计算机CPU识别。理解了汇编语言的操作过程,也就能够理解计算机究竟是如何工作的。
       汇编语言究竟是什么东西呢?想要理解汇编语言,要先理解计算机的组成。为了简化,只提CPU和内存。CPU是处理器,内存存放着指令和数据,处理器就像一个管家,从内存中取指令执行,对数据进行出来,并将数据储存起来。对于CPU来说,每一个程序的执行要解决三个问题:1. 待处理的数据在哪里;2. 如何处理数据;3. 处理好的数据放在哪里。为了解决这三个问题,CPU需要借助一些工具的帮助,这些工具就是各种寄存器。汇编语言实际上就是对这些寄存器进行处理,通俗点说,就是把一大堆数据在寄存器和内存倒腾过来倒腾过去,做一些复制和加加减减的运算。其实学习汇编语言很简单,只要记住十几条汇编指令和各种寄存器以及堆栈的用法就可以了。
       在这篇文章中,我们通过对一个简单的C程序反汇编得到汇编代码,分析汇编代码来了解计算机工作的基础。
       这段C程序是这样的:
 1 int a(int x) 2 { 3    return x + 5; 4 } 5  6 int b(int x) 7 { 8    return a(x); 9 }10 11 int main(void)12 {13    return b(5) - 2;14 }

       可以看到程序中有很多函数的调用和返回。为什么要这样设置呢?因为程序中的函数调用时计算机工作运行的关键,分析函数调用的具体实现能够帮助理解计算机运行的原理。
       我们将上述代码写入main.c文件中。然后使用
gcc -S -o main.s main.c -m32

       命令生成汇编代码。结果如下图。后面加-m32是为了让其按照32位的方式反汇编。

 

       我们只需要看汇编代码的关键部分,可以把点开头的语句全部删去,得到如下的汇编指令。

 1 a: 2  3   pushl  %ebp 4   movl  %esp, %ebp 5   movl  8(%ebp), %eax 6   addl  $5, %eax 7   popl  %ebp 8   ret 9 10 b:11 12   pushl  %ebp13   movl  %esp, %ebp14   subl  $4, %esp15   movl  8(%ebp), %eax16   movl  %eax, (%esp)17   call  a18   leave19   ret20 21 main:22 23   pushl  %ebp24   movl  %esp, %ebp25   subl  $4, %esp26   movl  $5, (%esp)27   call  b28   subl  $2, %eax29   leave30   ret

       接下来我们分析C代码和汇编程序究竟是如何对应起来的,以及汇编语言是如何工作的。
       我们先看C程序,从main函数看起,它返回了一个函数b再进行运算的结果。那么我们来看函数b,它返回的是函数a的结果,而函数a的作用是将传递给它的参数x加5。所以对于这个程序,最后得到的数值应该是5+5-2=8。
       再来看汇编代码,我们还是从main函数看起。一看到push,我们就知道这是在对栈进行操作。ebp是栈顶指针,esp是栈当前位置指针,栈是自上向下生长的,后进先出。先把ebp压栈,实际上是先将esp-4再将ebp放到栈当前位置。这是第1条指令。
       第2条指令将esp的值放到ebp中,也就是说现在ebp的指向改变为esp的指向。第3条指令将esp-4。第4条指令将5移入esp指向的地址中。第5条指令调用函数b,这里等于两个操作,一个是先将现在的eip入栈,此时eip应为subl $2,%eax这条指令的位置,我们记为28。另一个操作是将b函数的地址放入eip,也就是说此时程序要从10开始执行。
       第6条指令为pushl %ebp,之前已经讲过了,与7、8条一起不再赘述。第9条movl 8(%ebp), %eax,是将ebp的值+8指向的内容放入eax,实际上就是eax = 5。第10条指令将eax的内容放入现在esp指向的内容中。第11条指令调用函数a,与前面的步骤类似。第12条指令是a函数的pushl %ebp,与13、14条一同省略。第15条将eax中的值+5得到10。第16条将现在esp指向的内容放入ebp,esp+4,所以现在ebp=4。第17条指令是ret,即popl %eip,也就是现在的eip更改为18,回到函数b,从leave开始执行。第18条指令leave,表示两条指令,movl %ebp,%esp和popl %ebp。第19条指令ret回到main函数,从28处执行。第20条指令,将eax中的内容-2,即8。第21条和第22条如图所示。从图中我们可以看到,栈又回到了初始的位置。

             至此,汇编代码就分析完了。
       从上面的过程可以看出,计算机最本质的工作原理,是对存储的数据进行处理,并把结果保存,然后不断循环这个处理数据的过程。指令就是对数据进行处理的依据。具体的方法就是借助CPU中的寄存器,以及内存中的栈,依据一个约定的步骤对数据进行操作。计算机其实很简单,它是一个认死理的家伙,只要确定了每一步要做什么,它就会严格地按照步骤把操作完成,绝对不打折扣。因此,相比与人打交道,与计算机打交道可是要轻松多了。