2015年3月8日星期日

网易云课堂Linux内核课程作业1:一个简单汇编代码的分析

关于:豆丁老豆  原创作品转载请注明出处 《Linux内核分析》MOOC课程 http://mooc.study.163.com/course/USTC-1000029000
题目要求在Linux下反汇编下面一段c代码:
int g(int x) 
{
    return x + 7; 

int f(int x) 
    return g(x); 
int main(void) 
    return f(11) + 3; 
}

通过实验楼这个网站,可以使用虚拟的linux环境,截图如下:


反汇编编译代码,执行命令:

gcc -S -o main.s main.c -m32

得到包含汇编代码的main.s文件,如下(忽略了开头以“.”开始的无关信息,第1列是行号):

1 g: 2 pushl %ebp 3 movl %esp, %ebp 4 movl 8(%ebp), %eax ------------(8) 5 addl $7, %eax ---------------(9) 6 popl %ebp 7 ret -----------------------------(10) 8 f: 9 pushl %ebp 10 movl %esp, %ebp 11 subl $4, %esp 12 movl 8(%ebp), %eax -------------(5) 13 movl %eax, (%esp) --------------(6) 14 call g -----------------------(7) 15 leave ---------------------------(11) 16 ret ---------------------------(12) 17 main: 18 pushl %ebp 19 movl %esp, %ebp ---- (1) 20 subl $4, %esp ------- (2) 21 movl $11, (%esp) -----(3) 22 call f --------------(4) 23 addl $3, %eax --------(13) 24 leave 25 ret --------------------(14)

为了了解计算机是如何工作的,我们可以模拟cpu、内存、寄存器等是如何解释执行的这段汇编代码的:

(1) 从main开始,pushl、movl的2行语句是进入一个新函数时对函数调用堆栈进行的例行操作。这里详细解释下:
(1.1) pushl负责占用新内存用来保存原始ebp,执行时又分为subl和movl 2个子操作步骤:subl指令中esp寄存器所指向的栈顶增长(占用新的内存空间),movl指令中保存调用main时在ebp寄存器中保存的调用者堆栈底地址(如果是在Shell中执行main程序,这个堆栈可能属于调用main的shell程序用的)。
(1.2) molv负责初始化ebp,将ebp修改为保存在esp寄存器中的栈顶内存地址,作为main函数的堆栈的底部,此时main函数的堆栈是空的,所以栈顶和栈底都是一个相同的地址。



(2)subl代码将esp中的地址减4个字节(共32 bits),由于堆栈在内存中是向下增长的(内存地址越小,堆栈中的位置越高),所以减小地址,就意味着栈顶增长,增加堆栈的长度,在这里增加了32个bits,看来是准备存放一个long int的数据大小了(long是32位,我们是翻译为32位计算机的汇编代码,因此内存地址都是32位的,图中的“内存地址”实际上应该乘以32,并且降序排列才对,这里简化下,默认+1代表加4个字节,升序标记)。



(3)movl代码将“立即数”11作为一个32位整数放到了 esp 寄存器所指向的内存地址里。对应C代码“return f(11)+3”中,将f函数的参数11保存起来。




(4)call语句调用f函数,因为一个call语句实际上可以分解为push和move语句,其中push如上所述包含2个子指令,负责将eip寄存器中的地址保存到函数调用堆栈中(eip值自动指向下一行第23行,本文用eip(23)代表),栈顶继续增长,然后move指令修改eip寄存器为f的地址(用行号9代表)。由于计算机CPU总是从eip寄存器中获取下一条指令的地址(这就是冯诺依曼的存储程序计算机结构的伟大之处),因此修改eip后,下面CPU将开始执行保存在f函数里的汇编指令了,CPU从准备执行第23行改为执行第9行。



(5)这一步给eax赋值,一直到mov语句之前的代码刚都解释过了,原理和(1)是一样一样的,保存函数调用入口,栈底指向f函数的堆栈底,栈顶升高,在堆栈中准备好保存一个数值,这个数值在下面第(6)步中进行保存。现在看mov语句的赋值,这里mov语句使用“变址寻址”的方式,将ebp值加8(往堆栈的底部方向数8个字节)赋给eax寄存器,eax就定位到了刚才在第(3)步保存的参数11。至于为什么是8个字节,只要知道如果只数4个字节,就是刚才第(4)步中call代码保存的函数入口地址了,再往堆栈底部数4个字节才是那个long int类型的数值11。另外,这里用到了eax寄存器(累加寄存器)来暂存数值11的地址,这行mov代码用伪C代码可以表示为:

eax = *(int32_t *)(ebp + 8)

int32_t的指针类型数值所指向的内存地址的内容,c的指针表示,简单吧!反正听课的时候我是晕晕的 #:-p 就差点儿浆糊了。




(6)这里就是把eax中的值(数字11的内存地址)放到esp栈顶寄存器所指向的内存空间中。这样我们的f函数的堆栈里面就有“11”这个数字可以供函数内部随时使用了。

(7)调用g函数(类似前述修改eip寄存器来实现)




(8)mov和之前的代码都解释过了,保存函数调用入口,找到之前在f函数的堆栈里保存的参数“11”,并暂时放在eax寄存器里。


(9)add语句将11加7,eax = 18




(10)pop语句前面push语句保存函数调用入口的逆过程,这是cpu准备从g函数回到f函数的节奏了。pop语句会将esp寄存器所指向栈顶的值出栈,具体这条代码是将其存放到了ebp寄存器中,同时栈顶降低(加4个字节),堆栈长度缩短。


ret语句继续进行出栈操作,把之前第(7)步call语句在esp中保存的eip地址拿出来赋给eip寄存器,CPU就回到第(7)步call语句的下一句去执行了。

(11)leave语句是回到上一个函数的堆栈中,可分解为mov和pop语句,即先将当前执行函数f的栈底move放到栈顶寄存器中,然后再出栈pop,pop的原理在第(10)步已说明,这里将esp栈顶中保存的上一个函数的栈底地址放回到ebp寄存器中,这样ebp就指向了上一个函数(调用f的g函数)的堆栈栈底,然后栈顶降低,,至此,我们就离开了f函数的堆栈,它可以被OS回收了。堆栈寄存器esp、ebp们都恢复到了调用f前的g函数堆栈使用情况。

f函数使用“leave”和g函数使用“pop ebp”返回上一个调用函数的区别,leave相对多了一个把ebp的值mov到esp里的操作(即修改栈顶,指向栈底),因为g函数没有对栈顶进行修改,所以离开g的时候不需要leave,直接pop出f函数的栈底就好,而f函数对栈顶做了修改(保存参数等操作),所以要用leave先恢复栈顶指向栈底,然后才pop出main函数的栈底。

(12)同第(10),让cpu跳转到main函数。


(13)把eax加3,eax = 18 + 3 = 21


(14)同第(11)和(12),用leave和ret这一组动作就是作废main函数的堆栈,cpu跳转返回到调用main函数的程序去。其中OS会把eax中的值21,作为main函数默认返回值返回。因此如果你在shell里执行main后,再执行 echo $?,shell应该是输出21。

总结:从上面的分析可以看到,cpu是代码的执行者,代码都放在一段内存中(CS段),用eip寄存器去自动指向下一条待执行的指令,这些指令包括mov、add、sub等基本操作和pop、push、leave、ret、call等复合操作,它们对堆栈和寄存器的值进行存取和加减运算,这些操作可以实现c等相对汇编的高级语言中的语义,比如函数调用、加减运算、返回调用者等,堆栈随着函数调用而增长,随着函数使用到的参数而增长,函数完成计算后,中间结果保存到类似eax这样的寄存器中,然后出栈操作使堆栈又缩短还原,直到最后的main函数也执行完毕,堆栈归零。









没有评论: