问题是这样的,今天一个简单的C程序,用gcc编译成汇编语言后,本来想在里面改点东西,结果运行时就报了“Segmetation fault”。它丫来的还真不是时候,刚好最近正好烦它呢,谁知自己倒送上门来了。OK,择日不如撞日,今儿就拿你开刀了。
源代码如下:
-
/*littletrick.c*/
-
#include <stdio.h>
-
int main()
-
{
-
int a = 100;
-
int b = 25;
-
if (a > b)
-
{
-
return a;
-
}
-
else
-
{
-
return b;
-
}
-
}
用gcc将其编译成汇编源文件:
还没来得及在里面做修改,三条命令下去,结果“Segmetation fault”了:
可能有些人曾经遇到过这样的问题,或许有些人将来可能会遇到。不知道大家对这个问题有什么感想?这里说说我自己的分析、追踪和解决过程,也都是一些片汤话,顺便和大家分享分享。
当初学C语言了,老师就说过main()函数是C语言的入口函数,所以你写的C程序里一定要以main()作为函数入口。注意这里说的是“main()函数是C库的入口函数”。
而在学习汇编语言的时候,老师又说过“汇编语言源程序的入口点是_start",所以当我们写汇编源程序时需要一个_start标记,指明程序的入口地方。
有了这两点基础知识,我们一定不会有main()或者_start就是进程入口点的错误观念了。关于main()函数之前,阿彬有两篇人气爆高、超经典的博文,想继续探究这些问题的盆友们请点“man函数之前”和“北极以北 main函数之前”。
回到我们的问题上来,我们是从C语言源程序里生成的汇编源代码的,因此gcc在将C文件编译成汇编语言源程序时就默认滴认为我们的程序最终是通过C库(不管是静态链接还是动态链接)来调用main()函数,所以看汇编出来的文件最末尾有两条指令leave和ret:
-
//省略部分代码
-
.L3:
-
leave
-
ret
-
.size main, .-main
-
.ident "GCC: (GNU) 4.4.7 20120313 (Red Hat 4.4.7-3)"
-
.section .note.GNU-stack,"",@progbits
leave指令其实和enter指令是配对使用,它们主要用来声明C语言风格的函数,其中enter是函数的“前言”(prologues),leave是函数的“结尾”(epilogues)。也就是说这两条指令是AT&T为C语言函数设计的“开头”和“结尾”的模板,并真心地希望每一个汇编源文件面的函数们开头都放一条enter指令,末尾都放一条leave指令。然而,这并不是一个强制的约定,所以很多时候你会看到一些汇编源代码的函数里只有leave没有enter,或者,即使人家是个函数,开头既没有enter,结尾也没有leave。这里我们看到,GCC也没有严格按照这样的风格来实现。想想当初提出这个机制的人心里会是何其的悲催啊。闲话不表,接下来是ret指令,我们都知道,在汇编语言里它通常都是和call指令配合使用,完成函数调用功能。这兄弟俩关系还算比较好,一对好基友。一般见到call的地方,在虚拟世界的某个地方大多数情况下(注意不是一定)都可以找到一个ret与它惺惺相惜,隔江相望。
(关于call和ret的更多故事,请继续关注本博客后续的相关系列博文)
要说明白我们遇到的这个问题的缘由还得稍微摆摆call和ret指令的故事。
call是汇编语言中函数调用最常见的指令,它通常会完成两件事:
第一:当call指令执行时(注意用词,不是执行前),它会首先将指令寄存器EIP的值保存在栈里,这一步是自动完成的。
第二:修改当前的EIP值,让它指向要跳转的函数地址处。也就是接下来立即是要调用的函数的入口地址处。
当被调用的函数执行完,返回时,其末尾通常都会有一句ret指令。而该指令的作用就是自动到栈上面将被call指令存入的EIP的值恢复到EIP寄存器里,使得进程可以继续往下执行。这里我们差不多可以猜到,段错误的原因肯定是EIP的值混乱所致,但这只是猜想,待会我们还要进一步分析,EIP是怎么混乱的?为什么会混乱?怎么解决的问题。
先反编译一下我们最终的可执行文件littletrick:
大家应该看出来了,我们最终的可执行文件并没有通过C库来启动,而是直接赤裸裸滴就只有一个可执行的代码段,而且指明该程序的入口点就是main。最后一句是ret但是死活找不到call在哪里,问题恰恰刚好出在没有call和ret配对这个关键点上。我们用GDB调试运行一下看看详细过程,是不是如我们猜测的那样。重新编译littletrick.s时加-gstabs(别忘了重新链接)参数以让其支持GDB调试。
程序刚开始运行时,我们在main的地方打个断点:
看看栈、还有各个寄存器里的值:
我们看到EIP的值0x8048074就是接下来要执行的指令,也就是main函数入口的地址。这和我们上面反编译出来的main函数在虚拟地址空间的值是一致的。此时,栈顶指针ESP的值是0xbffff7a0,说明从0xbffff7a0到0xbfffffff的栈空间里已经有些一些数据,简单看一下这些数据里前128字节都长啥样子:
至于这些数据是什么,以后的博文会详细解释,这里只要知道当进程run起来的时候,栈上已经有了部分初始化数据就OK了。我们一直往下执行:
在执行ret指令前,可以看到EIP和ESP的值分别是0x8048099和0xbffff7a0。对照反汇编的结果0x8048099刚好就是ret指令的虚拟地址,而当执行完前面的leave指令时,栈上的局部变量a和b都已经被“弹”出去了。也就是说此时栈又恢复到了进程执行时的初始模样。前面我们也说过,ret会自动到栈上去取原来的EIP的值把它设置到EIP寄存器里,而此时栈顶的位置由ESP里的值0xbffff7a0来指定,从该地址开始4字节(因为EIP是32位寄存器),小端字节序的值是0x00000001。所以,当ret执行完后EIP里的值就错误的被设置成了0x00000001:
很显然,对进程来说,这是一个非法的访问地址,操作系统不允许它直接访问,因此就像上一篇博文所说的,给了一个"Segmetation fault"的错误提示信息。这里,关于ret指令我们还明确到一点,那就是ret不是从栈上取(retrieve)数据,而是弹(pop)数据出来,这会影响栈顶寄存器ESP的值。
好的,到这里问题明白了,原因也清楚了:
某些版本的gcc在将C语言源程序编译成汇编源代码时,会在主函数main的末尾,放置一条ret指令。当我们想用gcc生成汇编模板时,如果用as命令(而不是用gcc提供的-c或者-o控制选项)去汇编*.s文件,然后用ld进行链接成可执行程序,运行时就一定会报“Segmetation fault”。至于GCC在通过C源代码生成汇编时在main函数末尾加不加ret这也和gcc的版本有关,有些版本的gcc关于C语言的return语句人家就用了exit系统调用来处理。如果你的GCC在C语言源程序编译出来的汇编代码里,在main函数末尾加了一个ret,而你也和我一样喜欢折腾,那么这里就需要注意了。
问题弄明白了,至于解决办法也就简单多了。既然问题是ret和call不配对所致,那么这里汇编出来的ret就是多余的,所以将它删掉就可以了。当然为了严紧起见,我们将它改成linux系统调用的exit函数,让它对人家操作系统总得有个交代才行。最后改过的版本:
新增的第一条movl指令是将系统调用的返回结果保存到ebx寄存器,在shell里我们可以通过检查变量"$?"来查看执行结果;第二条movl指令是将exit的系统调用号1送到eax寄存器里;第三条int $0x80就是陷入内核,执行Linux的exit系统调用。如果想深入了解系统调用的童鞋,请猛戳这里或者这里都行。
编译、链接再运行:
结果很愉快了,然后该干啥就干啥了。
PS:对从*.c生成的汇编语言源程序*.s,如果想继续用C库,那么你可以用“gcc -c”来编译*.s文件,然后用“gcc -o ”生成最终的可执行文件。这样一来,你就不会遇到本文所提到的烦人的"Segmetation fault"了。
阅读(324) | 评论(0) | 转发(0) |