Linux内核分析(一)通过汇编代码,理解程序在计算机中是如何运行的

作者:于波

   声明:原创作品转载请注明出处
   来源:《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000

    首先说一下背景,这篇博文是网易云课堂中《Linux内核分析》课程的一个作业,要求通过分析汇编代码,解释清楚程序在计算机中是如何运行的;函数调用和返回的过程中,栈是如何变化的。
    这篇文章假设读者已经有了一定的汇编语言基础,能看懂基本的汇编指令。因此文章中并没有从零开始介绍每个汇编执行的细节。

一、 使用的示例程序
    我将使用下面的源程序作为分析的目标,其中的几个常量值选择了一些特殊的数值,是为了让我们在查看汇编代码和内存时能更容易的找到他们。

  1. int g(int x)
  2. {
  3.   return x + 0x3456789A;
  4. }
  5.  
  6. int f(int x)
  7. {
  8.   return g(x);
  9. }
  10.  
  11. int main(void)
  12. {
  13.   return f(0x12345678) + 127;
  14. }

  我们的程序很简单,main函数调用了函数f,并给函数f传递了一个十六进制的32位整数0x12345678,函数f调用函数g,同时也传递main传入的参数,函数g将传入的参数增加一个32位值0x3456789A 并作为计算结果返回,最后main函数拿到函数f的返回值,给返回值增加十进制的127并返回,程序结束。

    我们将上面的代码保存到一个C文件中,命名为main.c.

 

二、构造汇编代码

    上面的C程序计算过程很容易看清楚,但是只从C程序是无法了解这段程序是如何在计算机上执行的,我们需要把示例的C程序编译成汇编程序进行分析。

    gcc提供一个选项可以只执行编译成汇编指令,而不执行链接: gcc -S -o main.s main.c -m32

    -S表示仅把程序翻译成汇编代码,不执行链接;-o指定输出文件的名字,这里的输出指定为main.s, -m32表示将程序编译成32位的格式。

    然后我们就可以在main.s中得到我们的汇编代码了,查看该文件,应该像下面这个样子:    

 

其中以 ‘.’ 开头的行(上图黄色字体开头的行),是链接器会使用的一些标记,对我们人工分析汇编代码来说是没用的,为了排除干扰,可以全部删除它们。可以得到下面的更加清晰的汇编代码:
1. g:
2.        pushl   %ebp
3.        movl    %esp, %ebp
4.        movl    8(%ebp), %eax
5.        addl    $878082202, %eax
6.        popl    %ebp
7.        ret
8. f:
9.        pushl   %ebp
10.       movl    %esp, %ebp
11.       subl    $4, %esp
12.       movl    8(%ebp), %eax
13.       movl    %eax, (%esp)
14.       call    g
15.       leave
16        ret
17. main:
18.       pushl   %ebp
19.       movl    %esp, %ebp
20.       subl    $4, %esp
21.       movl    $305419896, (%esp)
22.       call    f
23.       addl    $127, %eax
24.       leave
25.       ret
    (顺带提一下的是,如果我们只拿到了别人的可执行程序,又想窥视一些程序内部的东西的时候,还可以使用objdump命令来执行反汇编操作,比如如果我们拿到一个可执行文件main, 可以执行objdump -d main > main.dump 将二进制的可执行文件反汇编,将反汇编结果存入文件main.dump中。这样得到的汇编程序格式会和上面的略微有些差别,会加入一些加载器使用的段信息和每行程序的标号信息等,但结果也可以用来进行程序运行过程的分析。)

三、分析程序执行过程
    
从上面的汇编代码我们可以看到,对应我们的C程序,汇编程序中可以找到三个函数,main,f和g,分别对应了C程序中定义的三个函数。下面就来详细分析汇编代码的执行过程。
3.1 - main函数入口
    程序都是从main函数开始执行的,因此我们的分析也从main函数开始,先来看main函数的前半部分:
  17. main:
  18.       pushl   %ebp
  19.       movl    %esp, %ebp
  20.       subl    $4, %esp
  21.       movl    $305419896, (%esp)
  22.       call    f    

    首先我们需要假设一些程序开始执行时候的初始值。这里假设刚开始的时候,栈底指针EBP和栈顶指针ESP相等,也就是从一个全新的空栈开始。同时我们假设这个初始值是1024 (真实情况下,在X86的CPU上,栈是向小地址方向变化的,它一般开始于一个比较大的值,如0xEFFFFFFF,这里为了人阅读的方便,取了十进制的1024)。
    18行,pushl指令将EBP寄存器中的32位整数值压栈,执行完成之后,栈顶指针将下移4字节,变为1020,同时在地址1020 ~1023的四字节内存中,保存下了EBP寄存器的值,即1024;
    19行,movl指令将ESP寄存器的值赋给EBP,即当前的栈底指针也下移了,指向了与ESP相同的位置:1020;
    20行,subl指令将ESP寄存器,也就是栈顶指针的值减掉4,所以栈顶指针ESP变成了1016;
    21行,将立即数305419896存入ESP寄存器指向的位置,也就是在栈顶保存这个立即数。
    22行,call指令调用函数f,也就是跳转到f函数,也就是汇编指令的第9行,来继续运行我们的程序。

    等等,这样列举下来,是在分析程序吗?好像完全lost的感觉。别急,下面应该会更清楚一点。
    
    我们重新回到上面五行汇编来看,但是这次分成两组,18到20行是一组,21和22行是一组。我们先来看21和22行。
    21行中出现了一个很奇怪的数字305419896,它是从哪里来的呢?做一下简单的计算就会发现,305419896刚好等于0x12345678,也就是我们的C程序13行中,调用函数f时传入的参数值。然后紧接着我们就用call指令转到了函数f。这下我们应该明白了,原来这两行是在调用新的函数并给函数传递参数。另外让我们仔细回忆一下call指令,他的功能是调用其他的函数,但是call指令又不同于简单的jump,jump是跳走就走了,没特别说明就不回来了;而call指令需要我们执行完指定的函数之后,继续运行call指令后面的指令。为了实现这一点,call指令需要保存他后面一条指令的地址,保存到哪里呢?也是栈上,所以call指令执行完之后,ESP又减了4,变成了1012,同时call指令的下一条指令的地址会被保存到内存地址1012~1015这四个字节上。call指令的下一条指令地址,这里我们可以简单的认为它就是汇编程序的行号:23,而实际在计算机中运行时,它会是第23行汇编指令加载到的内存的逻辑地址。
    然后我们再看18到20行,这三行在操作两个栈指针寄存器,EBP和ESP,它首先保存了老的栈底地址EBP到栈上,然后调整了栈底地址到了和原来的栈顶同样的位置,最后调整了栈顶指针ESP到新的位置。一般来说,每个函数都有自己的运行栈,而每个函数使用的栈的大小就由20行这样的指令来决定,这个值是编译器为我们算出来的,在我们的例子中,编译器认为main函数使用4字节的栈就够了,因为只需要传一个4字节的整数给被调用的函数f,除此之外,再没有需要用栈的地方。
    好了,总结一下main函数的前五行汇编代码,它其实做了两件事情:1. 给自己准备栈空间;2. 为被调用函数f准备参数并调用它。回头再看看他们,是不是觉得熟悉多了。下面可以跟随程序进入到f函数里了。

3.2 - f 函数入口
    和main函数一样,跟随程序的执行过程,我们也先看f函数的前半部分:
    8. f:
  9.        pushl   %ebp
  10.       movl    %esp, %ebp
  11.       subl    $4, %esp
  12.       movl    8(%ebp), %eax
  13.       movl    %eax, (%esp)
  14.       call    g

  仔细观察会发现,f函数的9到11行和main函数的18到20行是完全一样的,我们已经有了上面的分析,所以这里应该能很容易看明白,f函数的这三行也是在准备自己需要的栈空间,而且和main函数一样,它也只需要4字节的栈空间就够了;

   而第13,14行又和main函数的21,22行很相似,也是在给一个被调用的函数g准备传入参数,并用call指令来调用它。唯一不同的仅仅是,它不是在栈上放一个立即数,而是把寄存器EAX的值放到了栈上,作为传递给g的参数。EAX是什么呢?是在12行里从EBP寄存器偏移8的位置读出来的,也就是从当前函数的栈底在往回数8个字节的位置读取出来。而这个位置的值就是在main函数的第21行中存入的那个0x12345678.

    为了证明这一点,我们还需要再次回顾一下从main函数的21行开始,到f函数的12行为止,栈上又多存储过什么。

    首先是22行的call指令,它把call指令的下一条指令的地址压入栈中了;然后是f函数的第9行,它把上一个函数的栈底地址保存到了栈中。所以,在每个函数中,从自己的栈底往回数8字节,也就是两个32位整数,总是能找到上层函数传递给自己的第一个参数;如果函数还有更多的参数,可以分别从(%EBP)+12, (%EBP)+16 等位置取到。

    总结一下f函数前半部分,它做了三件事:1. 从9到11行,准备自己的栈空间; 2. 第12行,从栈上拿到上层函数传递给自己的参数;3. 第13,14行,调用函数g,并把拿到的传入参数再传给函数g。

3.3 - g函数   

   下面我们可以跟随程序来到g函数了。
  1. g:
  2.        pushl   %ebp
  3.        movl    %esp, %ebp
  4.        movl    8(%ebp), %eax
  5.        addl    $878082202, %eax
  6.        popl    %ebp
  7.        ret

    函数g的2,3行又是很熟悉的样子,和main函数跟f函数的前两行完全一样的,也是在准备自己的栈空间,但是这次并没有调整自己的栈顶地址,这是因为编译器发现这个函数根本不需要在栈上存任何东西。
    接下来的第4行,和函数f的12行是一样的,是取出了上层函数传递给自己的第一个参数,并把值放到了寄存器EAX中;然后第5行,给EAX寄存器中的值增加了878082202,简单计算一下也可以得到,这个值等于十六进制的0x3456789A,也就是C程序第三行中的常数值。
    接下来就是第6和第7行了,先来看popl %ebp,它的执行结果就是当前的栈顶,即ESP寄存器指向的内存地址的内容,弹出并赋值给寄存器EBP,也就是栈底指针变化了,变成了一个我们之前在栈上保存的一个值,在函数g中,往上找最后一次操作栈的地方,就会发现这个弹出的值其实就是在第2行中push进去的值;同时执行完成之后,ESP的值会增加4;那么新的栈顶上存的值是什么呢?再往回找,应该能想起来,这个地址上放的是14行中,call指令在栈上保存的cal指令的下一条指令的地址值,而这个值应该弹出并赋值给EIP,也就是我们的程序计数器,程序才能实现在执行完14行的调用函数g之后,继续执行第15行的功能,而这正是第7行的ret指令干的事情,它会将当前的栈顶元素弹出到EIP寄存器中。指令执行完成之后,ESP的值会在增加4,从而指向了栈的下一个元素。
    至此,我们的寄存器的状态应该是:EBP=栈上弹出的保存值,也就是函数f调用函数g之前的EBP值,而栈顶指针ESP弹出了两个4四字节整数,当前的值也刚好等于函数f在调用函数g之前的ESP值;而EIP被赋值了第14行的call指令保存进栈的call指令的下一行指令的地址,也就是第15行指令的执行地址。所以我们的程序在函数f调用完函数g之后,继续往下执行了,所有的现场数据都已经恢复成了调用g之前的值。所以接下来继续看函数f在调用完g之后的部分。

3.4 - 函数f的后半部分
    函数f在调用完函数g之后还有这么两句:
  15.       leave
  16        ret
  和g函数的结尾相比,不同之处在于popl %ebp 换成了leave,其实leave指令相当于下面这两条:
    movel %ebp, %esp
    popl %ebp
    所以,如果把leave指令展开在和函数g相比的话,就变成了返回语句中多了一条 movel %ebp, $esp. 而这条指令的作用是把当前的栈顶指针调整到栈底的位置。之所以有这样的区别是因为,前面说过,函数g是不需要在栈上存储任何东西的,所以他的函数开头部分只有两句调整栈指针的指令:
  pushl   %ebp
  movl    %esp, %ebp
而与之区别的函数f开头部分有三句:
  pushl   %ebp
  movl    %esp, %ebp
  subl    $4, %esp
所以,对函数发来说,要在返回之前把栈指针调整回main函数调用它时的样子,就需要多做一步,就是把栈顶地址先调整回去,然后再把栈上保存的栈底地址弹出到EBP寄存器中,而这正是leave指令完成的工作。
    类似函数g返回到函数f的过程,我们的f函数现在也可以返回到main函数了。

3.5 - 函数main的后半部分
    下面来到main函数在调用了函数f之后的处理部分:
    23.       addl    $127, %eax
  24.       leave
  25.       ret

    第24,25行就不必多说了,和函数f的返回过程是一样的,而23行,在EAX寄存器的值上加上了127,对应着我们的C程序的第13行的操作。而最终EAX中的值就存储了 f(0x12345678) + 127的最终结果。为什么用EAX呢?因为在X86的CPU中,函数的返回值默认就是用EAX寄存器进行传递的,这是X86 CPU的函数调用约定。还记得我们的函数g的第五行吗?它在计算 传入参数 + 0x3456789A 的时候就是把值存放在了EAX寄存器中的,然后就返回了;而函数f没有对函数g的返回值做其他的操作,而是直接返回了函数g的计算结果,所以也就不需要额外的处理;在main函数中,访问EAX寄存器就拿到了函数f的返回值。

四、 总结
    程序的执行过程分析完了,最后我们来对程序在32位X86 CPU上的执行过程做一下总结。
    每个函数都有自己的栈空间,由EBP寄存器指定栈底,而ESP指定栈顶,函数的开始部分会给自己把要使用的栈空间准备好,通过调整EBP和ESP的值,同时为了能在程序执行完成之后恢复上层函数的栈,在调整EBP之前会先把老的EBP值保存到栈上。
    一个函数调用另一个函数时,使用栈来传递参数。在每个函数中,在调整好自己要用的栈指针之后,就可以固定的从(%EBP) + 8 的位置取得上层函数传给自己的第一个参数,如果有更多的参数,继续从(%EBP) 开始的更大的偏移上去获得。
    函数执行完成之后,用EAX寄存器传递返回值给上层函数。
    每个函数执行完成之后,会用保存在栈上的EBP值恢复函数被调用之前的栈指针,用call指令保存在栈上指令地址值去恢复EIP寄存器值,使上层函数可以在调用完本函数之后继续向下执行。

    基本就是这样了,希望我解释清楚了。

猜你喜欢

转载自blog.csdn.net/yubo112002/article/details/82526881