很实用的.s分析和gdb调试(转自中国linux论坛)
(2011-03-19 12:16:59)
标签:
杂谈
注:这篇文章原本打算整理的好一点再贴在这里,看到有的朋友急于想知道程序单步
执行的问题,所以先贴在这里再说。有问题大家再讨论,这里高手很多,欢迎批评。
C函数调用在GNU汇编中的实现
灵芯
对大部分程序员很少使用汇编。对他们来说,学习汇编主要有两个好处。
一个是理解高级语言的程序段对应什么样的汇编代码,另一个是在高级语言中插入
一些汇编代码以提高效率。从这个目的出发学习汇编,最简单的方法就是写一些简
单程序,然后分析这些程序的翻译结果。下面,我们通过几个简单程序,分析函数
调用过程的汇编实现。
程序c1.c只有一个函数:
int add1(int i) { return i+1; }
执行命令gcc -S c1.c,生成汇编程序c1.s:
.file "c1.c"
.text
.globl _add1
.def _add1; .scl 2; .type 32; .endef
_add1:
pushl �p
movl %esp, �p
movl 8(�p), �x
incl �x
popl �p
ret
分析:
以“.”开始的名字是汇编指示(assembler directive)。关于GNU汇编指示
的定义可以在[1]中找到。.file是新逻辑文件的开始,该语句可能在未来会取消。
.text是一个程序段(subsection)的开始。.globl表示_add1是一个全局标识符,严
格地说就是链接器可见的标识符。从.def开始到.endef之间的内容是调试信息。上
面程序是在cygwin下编译的,linux的编译结果不含.def段。
显然,从标号“_add1:”开始到“ret”之间的程序段是add1函数的程序体。
在进入函数之前,指向函数输入参数表的指针放在ESP中,进入函数之后,这个指针
转到EBP中,以后函数的输入参数通过EBP访问。进入函数之前,EBP指向调用函数的
参数表,进入函数之后EBP需要指向被调用函数的参数表。所以,进入函数之后的第
一步是把EBP的原值保存起来:
pushl �p
在函数执行结束之后再恢复:
popl �p
在英特尔的手册中,即查不到pushl,也查不到popl,只有push和pop。后面
的l是GNU汇编语言的特殊要求,表示32位字长操作。如果是字节操作,就变成pushb和
popb。
指令:
movl 8(�p), �x
把函数的第一个输入参数“i”的值放入寄存器EAX。GNU 汇编使用AT&T语
法,目标操作数在最右边,源操作数在左边。操作数的顺序同INTEL手册中的指令定
义恰好相反。(�p)表示EBP的所指的地址单元的内容,8(�p)表示位于存储器地
址8+�p上的数据。这是EBP指针所指的栈上第二个32位数据。按C语言函数调用规
则(C Calling Convention),第一个32位数据4(�p)是函数的返回地址。而(�p)自
身则存放进入函数时的esp值。关于C调用规则,下面还要继续分析,也可参考[3]。
在程序c1.c的基础上,我们再定义一个函数,该函数包含对前一函数的调
用,以及对函数输出值的使用。新程序c2.c源代码如下:
int add1(int i) { return i+1; }
int add2(int i) { return 1+add1(i); }
编译后得到的汇编程序c2.s如下:
.file "c2.c"
.text
.globl _add1
.def _add1; .scl 2; .type 32; .endef
_add1:
pushl �p
movl %esp, �p
movl 8(�p), �x
incl �x
popl �p
ret
.globl _add2
.def _add2; .scl 2; .type 32; .endef
_add2:
pushl �p
movl %esp, �p
subl $4, %esp
movl 8(�p), �x
movl �x, (%esp)
call _add1
incl �x
leave
ret
这个程序上半部分除文件名之外,与c1.s完全相同。下半部分由下面语句
开始:
.globl _add2
很明显,新增加的程序段是函数add2的定义。从标号_add2:开始的前两条
指令与_add1前两条指令相同。紧接下来的三条指令为后面调用add1准备参数。第一
步“subl $4, %esp”把ESP的值加4,这意味着在ESP的数据栈栈顶上分配一个新的
单元。指令“movl 8(�p), �x”把输入数据“i”放入EAX,指令“movl �x,
(%esp)”再把输入数据放入ESP所指单元,也就是当前的栈顶部单元。进入 add1只
后,就从这个位置取输入参数“i”。
call指令调用函数add1,后者把返回结果放在EAX中。call后面的指令把
返回结果加1。然后调用learve和ret退出。
上面的汇编代码没有经过充分的优化,add2对add1的调用过程代价太大。
在C程序中给add1加上inline,再进行汇编,产生的代码也没有变化。使用-O3编译
选项:
gcc -O3 -S c3.c
可以得到下面优化的add2代码,其中不含对add1的调用。函数体部分相当于
“i+2”。
_add2:
pushl �p
movl %esp, �p
movl 8(�p), �x
popl �p
addl $2, �x
ret
以上程序都不能单独执行,下面稍做修改,成为一个可独立执行的程序:
#include
int add1(int i) { return i+1; }
int add2(int i) { return 1+add1(i); }
int main() {
return add2(5);
}
这个程序编译后可以直接在操作系统下执行:
$ gcc -S c3.s
$ gcc -g c3.s -o c3
$ ./c3
$ echo $?
7
在汇编时使用了-g选项,以便运行GDB时能够显示汇编行。
为了对函数调用过程做深入的分析。下面使用GDB对c3中函数的进入和退出进
行考察。由于程序在链接之后加入了大量的与操作系统有关的代码,所以,第一步
是要找到用户程序代码。我们不想从main开始,因为main函数中已经插入了许多代
码。所以我们用break命令为函数test设置断点,然后运行(run),程序执行到test断
点处停止,并显示该行在原程序中的地址以及当前的汇编指令。使用where命令可以
显示当前函数和调用当前函数的函数在源程序中的位置。info program命令显示当
前指令所在的内存地址。反编译命令disas显示test在内存中的地址和内容。
$ gdb ./c3
GNU gdb 6.3.50_2004-12-28-cvs (cygwin-special)
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License,
and you
are
welcome to change it and/or distribute copies of it under certain
conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show warranty" for
details.
This GDB was configured as "i686-pc-cygwin"...
(gdb) break test
Breakpoint 1 at 0x401073: file c3.s, line 27.
(gdb) run
Starting program: /cygdrive/c/chen/software/asm/trans/c3.exe
Breakpoint 1, test () at c3.s:30
30 movl $5, (%esp)
Current language: auto; currently asm
(gdb) where
#0 test () at c3.s:30
#1 0x004010b0 in main () at c3.s:51
(gdb) info program
Using the running image of child thread 3056.0x900.
Program stopped at 0x401073.
It stopped at breakpoint 1.
(gdb) disas
Dump of assembler code for function test:
0x0040106d : push �p
0x0040106e : mov
%esp,�p
0x00401070 : sub
$0x4,%esp
0x00401073 : movl
$0x5,(%esp)
0x0040107a : call 0x401059
0x0040107f : leave
0x00401080 : ret
End of assembler dump.
当前的断点是test函数的第四条指令,从此处出发,恰好可以分析对add2调
用的全过程。mov指令把输入参数5放入ESP所指的内存地址。用stepi命令执行该指
令之后,ESP内容发生改变。用命令“x/xw $esp”显示出寄存器ESP的值是一个地址
“0x22ee64”,该地址所指的内存中的数据正是输入参数“0x00000005”。继续stepi执
行“call _add2”指令,该指令做三件事:第一,把程序执行点转向add2的入口;
第二,esp减4,变成“0x22ee60”,相当于在栈顶上分配了一个单元;第三,在此
单元内放入指令“call _add2”下面一条指令的地址“0x0040107a”。因此,在进
入函数之前,ESP指向函数的返回地址,ESP + 4指向第一个函数变量,依次类推。
(gdb) stepi
31 call _add2
(gdb) x/xw $esp
0x22ee64: 0x00000005
(gdb) stepi
add2 () at c3.s:15
15 pushl �p
(gdb) x/xw $esp
0x22ee60: 0x0040107f
(gdb) x/xw ($esp + 4)
0x22ee64: 0x00000005
在新位置上执行反汇编命令显示add2的内容。
(gdb) disas
Dump of assembler code for function add2:
0x00401059 : push �p
0x0040105a : mov
%esp,�p
0x0040105c : sub
$0x4,%esp
0x0040105f : mov
0x8(�p),�x
0x00401062 : mov
�x,(%esp)
0x00401065 : call 0x401050
0x0040106a : inc �x
0x0040106b : leave
0x0040106c : ret
End of assembler dump.
继续执行push操作,其结果是ESP减4,然后把当前EBP的内容保存在ESP所
指向的存储单元。函数执行结束时,要通过ESP恢复EBP的值。后面的mov指令再把新
的ESP值放入EBP。因此,在函数内部,EBP指向旧的EBP地址,EBP+4指向函数返回地
址,EBP+8指向函数第一个变元。
(gdb) stepi
16 movl %esp, �p
(gdb) p/x $ebp
$1 = 0x22ee68
(gdb) x/xw $esp
0x22ee5c: 0x0022ee68
(gdb) x/xw ($esp + 4)
0x22ee60: 0x0040107f
(gdb) x/xw ($esp + 8)
0x22ee64: 0x00000005
接下去,开始执行add2自身的指令。它首先把ESP继续减4,为栈增加一新
单元,然后,把输入参数放入EAX,同时也放入该单元。这两条指令可以合为一个,
编译器在这里没有做优化。然后继续调用add1函数。在进入add1之后,add2的EBP值
被保存起来,在函数结束时恢复。在add1体中,计算结果被放入EAX。add1退出之后,
add2从EAX中取得add1的返回值,继续进行计算:
(gdb) stepi
16 movl 8(�p), �x
19 movl �x, (%esp)
(gdb) stepi
20 call _add1
(gdb) stepi
add1 () at c3.s:6
6 pushl �p
(gdb) x/xw $esp
0x22ee54: 0x0040106a
(gdb) stepi
7 movl %esp, �p
(gdb) p/x $ebp
$3 = 0x22ee5c
(gdb) stepi
8 movl 8(�p), �x
(gdb) p/x $ebp
$4 = 0x22ee50
(gdb) x/xw $esp
0x22ee50: 0x0022ee5c
(gdb) stepi
9 incl �x
(gdb) stepi
10 popl �p
(gdb) x/xw $esp
0x22ee50: 0x0022ee5c
(gdb) stepi
add1 () at c3.s:11
11 ret
(gdb) x/xw $esp
0x22ee54: 0x0040106a
(gdb) p/x $ebp
$5 = 0x22ee5c
(gdb) stepi
add2 () at c3.s:21
21 incl �x
(gdb) x/xw $esp
0x22ee58: 0x00000005
(gdb) p/x $eax
$6 = 0x6
这段程序读者可以自己分析。特别是ret和leave指令的行为。这些指令的意义都可
以从INTEL手册和其他汇编语言教课书中找到。
小结
以上通过实际例子演示了C语言函数调用在GNU汇编中的实现。函数调用的
难点在于函数嵌套,以及环境的保护和恢复。实现的方法是使用一个栈来保存环境
变量,当一个函数调用另一个时,调用函数的有关变量需要放到栈中保存,被调用
函数执行完之后再从栈中恢复这些变量。
在上面的例子中,需要保存和恢复的是函数的输入参数表。栈的内容就是函
数的输入参数。每进行一次函数调用,就要把一组新的输入参数放在栈顶。进入函
数之后,把EBP寄存器设定为指向本函数的参数表的表头,在本函数继续调用下一个
函数之前,把ESP设定为指向新的参数表的表头,因此进入函数之后,要让EBP进栈,
并把ESP的值传给EBP,以便ESP继续用于进一步的函数调用。
指令call,push,pop,ret等,都会改变ESP的值,这一点需要注意。另外,在
进入函数,做完一些初始化工作之后,8(�p)将指向函数的第一个参数,
12(�p)指向第二个参数(如果有的话),其他依次类推。
分享:
喜欢
0
赠金笔
加载中,请稍候......
评论加载中,请稍候...
发评论
登录名: 密码: 找回密码 注册记住登录状态
昵 称:
评论并转载此博文
发评论
以上网友发言只代表其个人观点,不代表新浪网的观点或立场。