C/C++中,函数内部的一切变量(函数内部局部变量,形参 )都是在其被调用时才被分配内存单元。子函数运行结束时,所有局部变量的内存单元会被系统释放。形参和函数内部的局部变量的生命期和作用域都是在函数内部(
static变量的生命期除外)。
在C中,函数被调用时的传参方式有两种形式:传值和传址。
传址的好处:
(1)能在函数内部通过实参地址间接地改变实参的值。
(2)当所传实参内容比较庞大时,传址只是复制了整个实参的地址过去,指针依据同一个地址访问实参变量。而传值就会将实参内容整个拷贝过去,形参会跟实参占一样大的内存,栈空间是有限的。当然了,在弱小的程序中,传址的这个优点不会被体现出来。
在函数中,可以随意的返回一个局部变量。但如果返回一个局部变量的地址(指针 ),编译器就会给出警告(编译器也不可能那么完美能够彻底的检查出段错误)。在函数内部返局部指针这的确是一个危险的操作。鄙人的笔记先将用return返回值(指针为地址值)的机制搞清楚后再分析一下。
1函数内部返回局部变量过程
1.1结论
Linux等的C语言中return返回值的机制为:将返回值存入eax寄存器中,然后系统再将eax中的值赋给变量(i)。
(1)编写一个简单的C源程序
在linux 下敲一个简单的函数调用的程序:
Figure1:C中函数调用的简单例子
涉及到局部变量存储问题时先查了2个概念:
堆栈:堆栈其实是两种数据结构。 堆:由程序员分配和释放。如在C/C++中程序员使用malloc/new分配堆空间,使用free/delete释放所申请的堆空间。特点:释放内存块顺序随意。 栈:栈是由系统自动分配和回收的内存。如一个子函数被调用时,系统会将函数内的局部变量的内存单元分配到栈上,当函数执行完毕时系统自动释放所分配的栈地址单元。特点:释放栈内存顺序为后进先出。 |
(2)分析子函数调用的过程
【1】当程序执行到第8行调用子函数child_fun,程序转到到child_fun子函数入口地址处。
【2】程序进入child_fun子函数(即此子函数开始运行 ),执行到”return
1;”时,系统将返回的1存入寄存器eax中,然后经‘}’标志后函数运行完毕。若子函数中有形参和局部变量,则在函数开始运行时,系统自动为局部变量分配栈空间,待函数运行完毕时系统自动释放在栈中为局部变量分配的内存单元中的数据。
【3】child_fun子函数执行完毕,函数返回到调用子函数的地方即第8行处继续执行,将保存在寄存器eax中的值即1赋给变量i。
1.2汇编验证
如何验证所总结的return机制呢?
(1)汇编C源文件
在linux字符界面下,将以上提到的那段C语言程序编译成与之对应的汇编代码:gcc –S var_return_in_fun.c
得到var_return_in_fun.s文件,打开文件查看汇编代码:vi var_return_in_fun.s:
Figure2:C语言对应的汇编代码
编译C语言源文件时可不为gcc添加加-O2优化参数,不然在汇编代码中会看不到子函数调用的call指令。
(2)分析汇编代码
当初学习RAM汇编指令的时候没有清晰的动过手,对于这段汇编代码也是只认识push、move之系列英语单词,但是不会可以学习一下:
【1】由于不同的CPU的汇编格式不一样,故首先了解一下当前操作系统使用的什么汇编格式。比如windows下采用的Intel的汇编格式,linux采取的是AT&T汇编格式。
【2】收索一下AT&T汇编指令,浏览一下。明白一些基本指令的含义和编写格式后,只抓这个汇编代码的关键部分进行跟踪:
[1]在main函数中,调用子函数child_fun之前的汇编代码就不用看了,是依函数地址,初始化栈、代码段之类的含义。从13行的”call [2]27行前的代码就不用看了,也根据将子函数地址初始化栈之类的。请我对照C语言源代码,第27行的代码”move1 &1, [3]14行代码”movel %eax, -8(%ebp)”的含义是将寄存器eax的值载入”-8(%ebp)”所寻址之处,而且这个地址就是变了i的地址( |
2函数内部返回局部指针过程
在最开始的未明白return机制前可能还是要纳闷:在子函数中返回一个局部变量,等子函数运行结束时,此局部变量会被释放掉。当在子函数中返回一个指针时,等子函数运行结束时,此地址中的值会被释放掉。有点找不出其中被释放的差别。根据返回局部变量的经验,
可以这么分析:在执行return语句时,首先将return后面的地址值返回存入到比如eax寄存器中,然后系统再将eax中的地址值给接收函数返回地址的指针变量。这看起来都没什么问题,但问题在于两个方面: [1]接收函数返回地址值的指针变量要访问此地址中的内容。 [2]子函数运行结束后,一切有关于局部变量的内存都已经释放回收。那么在用这个地址来操作就很危险:根本没有这个地址或者是地址中没有内容[没有内容是对的 |
但真的是像分析的这样么?(是的)。只有写程序来验证了。
2.1返回局部指针也没出错问题的情况
有的程序就能够将局部变量的地址放回回来,甚至在编译时警告都没有。例如以下程序例子:
Figure3:返回局部地址
Figure4:编译运行
这个令人吃惊的结果不禁让人怀疑自己最开始对栈内存释放的理解。这个例子最起码验证了在子函数执行完毕后,原存在栈中的内容是没有被释放掉的。那么栈由系统自动分配和回收到底是怎么个情况呢?再整个不能输出正确结果的例子。
2.2栈内容被释放掉的例子
代码:
Figure5:栈内容被释放的例子
Figure6:图5的执行结果
分析:
根据程序代码和执行结果可见正如标题那个样子:栈内存还在,只是栈内存中的值被释放掉了。它不在被程序所占用。
因为在子函数执行完毕时毕竟还是将栈内存(即局部变量的地址)返回到了父函数中。但是内存中的值已经被释放掉了。但是为什么第一个值依旧没有被释放掉呢?是正确的呢?可能是首地址所以一直都会给其它程序留个好印象吧。
所以最后的结论是:子函数中的局部地址是能够被return到父函数中去的。只是在父函数中用这个地址去访问内容时,此地址中的内容已经被系统清除掉。这是很危险的操作:在父函数中用此地址访问其内容时,有可能刚被释放掉的这块栈内存又被系统分配另外的局部变量了,而此时你所访问的结果只是会导致程序结果不正确而已;但如果此地址中的内容还是不定状态,访问得到的值跟Figure
6一般。
3内存分配 释放/回收的含义
栈的分配和释放可以这样子理解:栈内存块在计算机中不可能会移动,它的地址已经被固定。系统分不分配它,它就在那里。当为局部变量分配栈内存时,系统就将局部变量存入到栈的某个内存块中;当子函数运行结束局部变量应当被释放时,系统再将这些存入局部变量的栈内存中的数据清除掉,恢复原来没有被初始化的状态。
4总结
(1)return
不管是返回指针还是返回值,return将return之后的值存到eax寄存器中,回到父函数再将返回的值赋给变量。
(2)局部地址
在函数内返回一个指针会出错的原因:子函数运行完毕时,存局部变量的所有栈地址的内容已经被释放。若在父函数中再访问这些地址中的内容时,因为这些地址的内容已经被释放,所访问到的值可能是乱的、不定的。
(3)分配/释放内存
分配内存,就是将某变量存入到某块内存中的一个地址中;释放内存,就是将此内存中的内容清除掉,恢复内存未被初始化的状态。
(4)return可以返回形参地址的原因:形参是实参的拷贝,当返回形参地址时,实际上是返回指向实参的内容的地址的(这里与利用形参改变实参内容不是同一个话题),再次观察函数调用时形参的位置(实际上位于调用函数中)实例:
最简C代码分析
为简化问题,来分析一下最简的c代码生成的汇编代码:
# vi test1.c
int main()
{
return 0;
}
编译该程序,产生二进制文件:
# gcc -o start start.c
# file start
start: ELF 32-bit LSB executable, Intel 80386, version 1(SYSV), for GNU/Linux 2.2.5, dynamically linked (uses shared libs), notstripped
start是一个ELF格式32位小端(Little Endian)的可执行文件,动态链接并且符号表没有去除。这正是Unix/Linux平台典型的可执行文件格式。
用gdb反汇编可以观察生成的汇编代码:
[wqf@15h166 attack]$ gdb start
GNU gdb Asianux (6.0post-0.20040223.17.1AX)
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General PublicLicense, and you are welcome to change it and/or distribute copies of it undercertain 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"i386-asianux-linux-gnu"...(no debugging symbols found)...Using hostlibthread_db library "/lib/tls/libthread_db.so.1".
(gdb) disassemble main --->反汇编main函数
Dump of assembler code for function main:
0x08048310 <main+0>: push %ebp --->ebp寄存器内容压栈,即保存main函数的上级调用函数的栈基地址
0x08048311 <main+1>: mov %esp,%ebp ---> esp值赋给ebp,设置main函数的栈基址
0x08048313 <main+3>: sub $0x8,%esp --->通过ESP-8来分配8字节堆栈空间
0x08048316 <main+6>: and $0xfffffff0,%esp --->使栈地址16字节对齐
0x08048319 <main+9>: mov $0x0,%eax ---> 无意义
0x0804831e <main+14>: sub %eax,%esp ---> 无意义
0x08048320 <main+16>: mov $0x0,%eax ---> 设置函数返回值0
0x08048325 <main+21>: leave --->将ebp值赋给esp,pop先前栈内的上级函数栈的基地址给ebp,恢复原栈基址.
0x08048326 <main+22>: ret --->main函数返回,回到上级调用.
0x08048327 <main+23>: nop
End of assembler dump.
注:这里得到的汇编语言语法格式与Intel的手册有很大不同,Unix/Linux采用AT&T汇编格式作为汇编语言的语法格式,如果想了解AT&T汇编可以参考文章 Linux 汇编语言开发指南.
问题一:谁调用了 main函数?
在C语言的层面来看,main函数是一个程序的起始入口点,而实际上,ELF可执行文件的入口点并不是main而是_start。
gdb也可以反汇编_start:
(gdb)disass _start --->从_start的地址开始反汇编
Dump of assembler code for function _start:
0x08048264 <_start+0>: xor %ebp,%ebp
0x08048266 <_start+2>: pop %esi
0x08048267 <_start+3>: mov %esp,%ecx
0x08048269 <_start+5>: and $0xfffffff0,%esp
0x0804826c <_start+8>: push %eax
0x0804826d <_start+9>: push %esp
0x0804826e <_start+10>: push %edx
0x0804826f <_start+11>: push $0x8048370
0x08048274 <_start+16>: push $0x8048328
0x08048279 <_start+21>: push %ecx
0x0804827a <_start+22>: push %esi
0x0804827b <_start+23>: push $0x8048310
0x08048280<_start+28>: call 0x8048254<__libc_start_main>
--->在这里调用了main函数
0x08048285 <_start+33>: hlt
0x08048286 <_start+34>: nop
0x08048287 <_start+35>: nop
End of assembler dump.
问题二:为什么用EAX寄存器保存函数返回值?
实际上IA32并没有规定用哪个寄存器来保存返回值。但是,如果反汇编Solaris/Linux的二进制文件,就会发现,都用EAX保存函数返回值。
这不是偶然现象,是操作系统的ABI(Application Binary Interface)来决定的。
Solaris/Linux操作系统的ABI就是Sytem V ABI。
概念三:SFP (Stack Frame Pointer) 栈帧指针
正确理解SFP必须了解:
IA32 的栈的概念
CPU 中32位寄存器ESP/EBP的作用
PUSH/POP 指令是如何影响栈的
CALL/RET/LEAVE 等指令是如何影响栈的
如我们所知:
1) IA32的栈是用来存放临时数据,而且是LIFO,即后进先出的。栈的增长方向是从高地址向低地址增长,按字节为单位编址。
2) EBP是栈基址的指针,永远指向栈底(高地址),ESP是栈指针,永远指向栈顶(低地址)。
3) PUSH一个long型数据时,以字节为单位将数据压入栈,从高到低按字节依次将数据存入ESP-1、ESP-2、ESP-3、ESP-4的地址单元。
4) POP一个long型数据,过程与PUSH相反,依次将ESP-4、ESP-3、ESP-2、ESP-1从栈内弹出,放入一个32位寄存器。
5) CALL指令用来调用一个函数或过程,此时,下一条指令地址会被压入堆栈,以备返回时能恢复执行下条指令。
6) RET指令用来从一个函数或过程返回,之前CALL保存的下条指令地址会从栈内弹出到EIP寄存器中,程序转到CALL之前下条指令处执行。
7) ENTER是建立当前函数的栈框架,即相当于以下两条指令:
pushl %ebp
movl %esp,%ebp
8) LEAVE是释放当前函数或者过程的栈框架,即相当于以下两条指令:
movl ebp, esp
popl ebp
原来编译器会自动在函数入口和出口处插入创建和释放栈框架的语句。
函数被调用时:
1) EIP/EBP成为新函数栈的边界
函数被调用时,返回时的EIP首先被压入堆栈;创建栈框架时,上级函数栈的EBP被压入堆栈,与EIP一道行成新函数栈框架的边界。
2) EBP成为栈帧指针STP,用来指示新函数栈的边界
栈帧建立后,EBP指向的栈的内容就是上一级函数栈的EBP,可以想象,通过EBP就可以把层层调用函数的栈都回朔遍历一遍,调试器就是利用这个特性实现backtrace功能的。
3) ESP总是作为栈指针指向栈顶,用来分配栈空间
栈分配空间给函数局部变量时的语句通常就是给ESP减去一个常数值,例如,分配一个整型数据就是 ESP-4。
4) 函数的参数传递和局部变量访问可以通过STP即EBP来实现
由于栈框架指针永远指向当前函数的栈基地址,参数和局部变量访问通常为如下形式:
+8+xx(%ebp) :函数入口参数的的访问
-xx(%ebp) :函数局部变量访问
假如函数A调用函数B,函数B调用函数C ,则函数栈帧及调用关系如下图所示:(重点注意形参与局部变量的各自位置)
+----------------------+----> 高地址 | EIP (上级函数返回地址) | +----------------------+ +--> | EBP (上级函数的EBP) | --+ <------ 当前函数A的EBP (即STP框架指针) | +----------------------+ +-->偏移量A | | Local Variables | | | | .......... | --+ <------ ESP指向函数A新分配的局部变量,局部变量可以通过A的ebp-偏移量A访问 | f +----------------------+ | r | Arg n(函数B的第n个参数) | | a +----------------------+ | m | Arg .(函数B的第.个参数) | | e +----------------------+ | | Arg 1(函数B的第1个参数) | | o +----------------------+ | f | Arg 0(函数B的第0个参数) | --+ <------ B函数的参数可以由B的ebp+偏移量B访问 | +----------------------+ +--> 偏移量B | A | EIP (A函数的返回地址) | | | +----------------------+ --+ +--- | EBP (A函数的EBP) |<--+ <------ 当前函数B的EBP (即STP框架指针) +----------------------+ | | Local Variables | | | .......... | | <------ ESP指向函数B新分配的局部变量 +----------------------+ | | Arg n(函数C的第n个参数) | | +----------------------+ | | Arg .(函数C的第.个参数) | | +----------------------+ +--> frame of B | Arg 1(函数C的第1个参数) | | +----------------------+ | | Arg 0(函数C的第0个参数) | | +----------------------+ | | EIP (B函数的返回地址) | | +----------------------+ | +--> | EBP (B函数的EBP) |---+ <------ 当前函数C的EBP (即STP框架指针) | +----------------------+ | | Local Variables | | | .......... | <------ ESP指向函数C新分配的局部变量 | +----------------------+----> 低地址 frame of C
概念四:Stack aligned 栈对齐
那么,以下语句到底是和作用呢?
subl $8,%esp
andl $0xfffffff0,%esp --->通过andl使低4位为0,保证栈地址16字节对齐
表面来看,这条语句最直接的后果是使ESP的地址后4位为0,即16字节对齐,那么为什么这么做呢?
原来,IA32 系列CPU的一些指令分别在4、8、16字节对齐时会有更加的运行速度,因此gcc编译器为提高生成代码在IA32上的运行速度,默认对产生的代码进行16字节对齐.
andl $0xf0,%esp 的意义很明显,那么 subl $8,%esp 呢,是必须的吗?这里假设在进入main函数之前,栈是16字节对齐的,那么,进入main函数后,EIP被压入堆栈后,栈地址最末4位必定是0100,esp-8则恰好使后4位地址为0。看来,这也是为保证栈16字节对齐的。
如果查一下gcc的手册,就会发现关于栈对齐的参数设置:
-mpreferred-stack-boundary=n ---> 希望栈按照2的n次的字节边界对齐, n的取值范围是2-12.
默认情况下,n是等于4的,也就是说,默认情况下,gcc是16字节对齐,以适应IA32大多数指令的要求。
让我们利用-mpreferred-stack-boundary=2来去除栈对齐指令:
(gdb) disass main
Dump of assembler code for function main:
0x08048310 <main+0>: push %ebp
0x08048311 <main+1>: mov %esp,%ebp
0x08048313 <main+3>: mov $0x0,%eax
0x08048318 <main+8>: leave
0x08048319 <main+9>: ret
0x0804831a <main+10>: nop
0x0804831b <main+11>: nop
End of assembler dump.
可以看到,栈对齐指令没有了,因为,IA32的栈本身就是4字节对齐的,不需要用额外指令进行对齐。
问题五:栈框架指针STP是不是必须的呢?
[wqf@15h166attack]$ gcc -mpreferred-stack-boundary=2-fomit-frame-pointer start.c -o start
[wqf@15h166attack]$ gdb start
(gdb) disass main
Dump of assembler code forfunction main:
0x08048310 <main+0>: mov $0x0,%eax
0x08048315 <main+5>: ret
0x08048316 <main+6>: nop
0x08048317 <main+7>: nop
End of assembler dump.
由此可知,-fomit-frame-pointer 可以去除STP。
去除STP后有什么缺点呢?
1)增加调式难度
由于STP在调试器backtrace的指令中被使用到,因此没有STP该调试指令就无法使用。
2)降低汇编代码可读性
函数参数和局部变量的访问,在没有ebp的情况下,都只能通过+xx(esp)的方式访问,而很难区分两种方式,降低了程序的可读性。
去除STP有什么优点呢?
1)节省栈空间。
2)减少建立和撤销栈框架的指令后,简化了代码。
3)使ebp空闲出来,使之作为通用寄存器使用,增加通用寄存器的数量
4)以上3点使得程序运行速度更快。
概念六:Calling Convention 调用约定和ABI (Application Binary Interface) 应用程序二进制接口。
函数如何找到它的参数?
函数如何返回结果?
函数在哪里存放局部变量?
哪一个硬件寄存器是起始空间?
哪一个硬件寄存器必须预先保留?
Calling Convention 调用约定对以上问题作出了规定。CallingConvention也是ABI的一部分。因此,遵守相同ABI规范的操作系统,使其相互间实现二进制代码的互操作成为了可能。
例如:由于Solaris、Linux都遵守System V的ABI,Solaris10就提供了直接运行Linux二进制程序的功能。
3. 小结
本文通过最简的C程序,引出以下概念:
STP 栈框架指针
Stack aligned 栈对齐
Calling Convention 调用约定 和 ABI (Application Binary Interface) 应用程序二进制接口
今后,将通过进一步的实验,来深入了解这些概念。通过掌握这些概念,使在汇编级调试程序产生的core dump、掌握C语言高级调试技巧成为了可能。