Chinaunix首页 | 论坛 | 博客
  • 博客访问: 1109676
  • 博文数量: 284
  • 博客积分: 8223
  • 博客等级: 中将
  • 技术积分: 3188
  • 用 户 组: 普通用户
  • 注册时间: 2008-12-01 13:26
文章分类

全部博文(284)

文章存档

2012年(18)

2011年(33)

2010年(83)

2009年(147)

2008年(3)

分类: C/C++

2009-08-27 10:31:58

c运行时的stack布局(初级)

环境:Gentoo Linux gcc 3.3.4

最近研究了一下c程序运行时的堆栈及相关问题,顺便做个总结。我的水平有限,疏漏之处在所难免,包涵则个。本文旨在抛砖引玉,希望能有更多的大侠出来发表自己的见解。

静下心来想想,发现c程序运行时和栈相关的东西还挺多,一时千头万绪,都不知从何下手:D

还是从最基本的说起吧,首先说说栈的布局。
运行在32位x86处理器上的linux可标识的最大地址是0xbfffffff,而这正是栈的起始地址,潜台词是:栈是从内存的高地址处往底地址生长的,所以下面所说的栈底其实是最高虚拟地址,而栈顶是由当前%esp所指定,其虚拟内存地址反而低于栈底。
程序加载后,栈的布局如下:

[quote]栈底的第一个字是零
接着是该程序的程序名(以null结尾的ascii码字符串)
程序的环境变量
命令行参数(C中可以通过main函数的argv访问)
命令行参数的个数(C中可以通过main函数的argc访问,程序运行是%esp就指向它)[/quote]

(注:main函数也是由其他函数调用的,理应也有返回地址(我会用程序来验证这一点),不知为何我参考的资料上并没有提起,有空时我会多查一些资料,力争把这些问题搞清楚)

程序运行时,栈顶及栈的内容是不断变化的。每个函数被执行时都有自己的栈帧(frame),用来存放函数中定义的局部自动变量,数组结构体,指针等。如果函数有参数的话,函数被调用之前参数就已经连同函数的返回地址被压入栈中。
举例来说,如果定义这样一个函数
[quote]void f(int arg1, int arg2)
{
int a;
int b;
}[/quote]
运行时则该函数的栈帧看起来会像这样(本文出现的栈一律高址在上(左),低址在下(右)):
[quote] .
.
.

+--------------+
|    arg2    | <== 12(%ebp)
+--------------+
|    arg1    | <== 8(%ebp)
+--------------+
|return addr | <== 4(%ebp)
+--------------+
|   old ebp    | <== %ebp
+--------------+
|        a       | <== -4(%ebp)
+--------------+
|        b       | <== -8(%ebp)
+--------------+
   .
   .
   .
   [/quote]
这样我们通过函数的参数或函数的局部自动变量就可以找出函数的返回地址(压入栈中的eip)及调用者的栈帧基址(即图中的old ebp)。(感谢思一克兄提供思路,更多的讨论可以参见cu上的帖子 ,感谢所有参加讨论的兄弟)
下面我们把改函数完善一下以实现我们刚才讨论的功能,顺便加上main函数,使程序可以运行。完整的程序如下:
[quote]#include ;

void
f(int arg1, int arg2)
{
       int a;
       int b;
//这里顺便验证一下我们前面提到的“栈底的第一个字是零”这句话
       if (*(int *)(0xbfffffff - 3) == 0)
            printf("NULL\n");
       else
            printf("Not NULL\n");
//用自动变量得到函数的返回地址和调用者的栈帧基址
       printf("Return addr: %p\nOld ebp: %p\n", *((int *)&a + 2), *((int *)&a + 1) );
//用参数得到函数的返回地址和调用者的栈帧基址
       printf("Return addr: %p\nOld ebp: %p\n", ((int *)&arg1)[-1], ((int *)&arg1)[-2] );
}


int
main(int argc, char **argv)
{

       f(1, 2);

       return 0;
}[/quote]
运行结果如下:
[quote]NULL                 #果然,栈底的第一个字是零
Return addr: 0x8048407
Old ebp: 0xbffff408
Return addr: 0x8048407
Old ebp: 0xbffff408     #两种方法结果一致![/quote]
更严谨通用的方法可以参考上面链接中Alligator27兄的方法(感谢Alligator27兄)

其实main函数除了不能由用户程序调用外,其他特性也和这里的函数f差相仿佛。用一个简单的程序验证一下就知道了。
[quote]#include ;

int
main(int argc, char **argv)
{
       int a;

       printf("Old ebp: %p\nReturn addr: %p\nNumber of args: %d\nAddr of args: %p\nValue of argv: %x\n", *((int *)&a + 1), *((int *)&a + 2), *((int *)&a + 3), *((int *)&a + 4), (int *)&a + 4);

       return 0;
}[/quote]
运行结果:
[quote]$ a.out
Old ebp: 0xbffff468
Return addr: 0x40042000     #main的返回低址
Number of args: 1
Addr of args: 0xbffff494 #命令行参数所在的位置
Value of argv: bffff414     #命令行参数指针所在的位置[/quote]
普通函数和main函数还有一个很大的不同,那就是当传递的参数是字符串时,传递给main函数的字符串是直接存放在栈上的,而传给普通函数的字符串是存放在数据段的,这不在本文的讨论范围之内,但可以把刚才的程序修改一下来验证这个事实。
修改后的程序:[quote]
#include ;
#include ;
#include ;

void
f(int arg1, char * arg2)
{
       int a;

       printf("Return addr: %p\nOld ebp: %p\nAddr of args: %p\nValue of argv: %x\n", *((int *)&a + 2), *((int *)&a + 1), *((int *)&a + 4), (int *)&a + 4 );

}


int
main(int argc, char **argv)
{

       f(1, "Linux");

       return 0;
} [/quote]
运行结果:[quote]
Return addr: 0x80483db
Old ebp: 0xbffff408
Addr of args: 0x8048531
Value of argv: bffff404[/quote]
注意Addr of args的输出,和上面的输出比一下,注意到区别了吗?

缓冲区溢出(本文只讨论栈溢出)
我想,经常在网上晃的兄弟一定不会对缓冲区溢出这个名词感到陌生吧。那么什么是缓冲区呢?c语言习惯把存储字符的数组叫做缓冲区。如果数组声明为局部自动类型的,则会在栈上分配一段内存空间,其大小由数组声明时所指定。其存放位置是由内存的低址向高址逐渐延伸的。下面以一个简单的程序作为示例:
[quote]#include ;
int
main(int argc, char **argv)
{
       char ar[6];
}[/quote]
这样的程序,在栈上的内存分配情况如下[quote]
                   +-----------------------------------------------------+
内存高址 ... | argv | argc   | ret addr | old ebp |          |....内存低址
               +------------------------------------------------------+
                           ^             ^                 ^             ^
               |                 |                |                 |
                参数    返回低址 调用者的栈帧基址 此处预留六个字节给数组ar
   [/quote]
上图中,字符串的存储顺序是自右向左的,如存储hello(hello可以作为命令行参数传给程序,存储可用strcpy,sprintf等库函数),则会在空白处存放'\0','o','l','l','e','h',这样刚好可以装下。问题是,如果所存储的字符串超过六个字节,c语言不会把多余的部分截掉呢?很遗憾,c语言没有提供检查数组是否越界的机制,所以这些都要由程序员来做,如果存储的字符串超过数组的大小,它只会盲目的往高址延伸,把原来的所有东西都冲掉,包括函数的返回低址。如果返回低址非法(什么样的低址算是非法?这也不是一两句话可以交代清楚的,有时间再写吧),就会出现segmentation fault之类的错误。但有些聪明的电脑高手,它可以通过精心计算,使程序不会出现segmentation fault,而是打乱原程序的执行流程,让函数返回到别的地方,去执行他自己的代码(善意的或恶意的),这就是缓冲区溢出的基本原理。所以,平时要慎用strcpy,sprintf等函数,可以考虑用strncpy,snprintf等替换。
     
许多初学c语言的兄弟常会犯这样的错误(包括我自己):在一个函数中声明了一个局部自动变量,然后把该变量的地址返回给调用者,企图通过调用者来正确的操作该内存单元,但这几乎总是会出错,为什么呢?还是以实际程序来说明:
[quote]#include ;

int *
f(void)
{
       int a = 100;
       return &
}


int
main()
{

       int *b = f();

       printf("%d\n", *b);
       char a[] = "hello";
       printf("%x\n", *b);
       return 0;
}[/quote]
运行结果:[quote]
100
40153ff4[/quote]
为什么同一语句,前后的执行结果会不相同呢(如果换一个编译器,可能的结果又会有所不同)?前面我们说过,程序运行时,栈是不断变化的,我们先画出函数f返回前栈的布局:[quote]
.
.
.

+--------------+
|        b       |
+--------------+ ------------从这一点往上是main函数的栈帧
|return addr | <== 4(%ebp)
+--------------+ ------------从这一点往下是f函数的栈帧
|   old ebp     | <== %ebp
+--------------+
|        a       | <== -4(%ebp)
+--------------+
   .
   .
   .[/quote]
可以看出把a的地址返回给b的话,b的值就是(&b - 12)了,这个值在重新对b赋值前是不会改变了(不考虑溢出等意外情况),但a当前所代表的地址单元的值却是有可能改变的。
当函数f返回时,此时f的栈帧已经不存在了,esp重新指向b的下一单元(注意:即使函数f在运行时,main的栈帧还是存在的,只是如果不用特殊手段f访问不到而已),如果main中继续调用别的函数或再次声明局部自动变量的话,a所代表的地址单元就有可能被覆盖了。所以读取b指向的单元的值可能是不确定的,这就是第二个printf的输出结果。但为什么第一个printf的输出结果会是正确的呢?我想,可能的原因是printf是库函数,库是被映射的进程的地址空间的另一个地方的缘故吧,如果换一个环境,有可能第一个printf的输出也是不确定的(更详细的答案我自己也不是十分清楚,希望知道的大侠不吝赐教啊)。
不管如何,我们都不应心存侥幸,不应该让这样的代码在我们的程序中出现,除非有特殊用途而你又知道自己在做什么。

函数的调用还有可能形成一种层次结构。考虑一下这样的程序:
[quote]
void
f2(void)
{   
       int c = 300;
}   

void
f1(void)
{
       int b = 200;
       f2();
}


int
main()
{
       int a = 100;
       f1();
       return 0;
} [/quote]
这样当调用到函数f时,就会出现几个栈帧同时存在,这时栈的布局会像这样:[quote]
.
.
.

+--------------+
|       a        |
+--------------+ -------------以上是main函数的栈帧
|return addr |
+--------------+
|   old ebp     |   f1的栈帧
+--------------+
|       b    |
+--------------+
|return addr |
+--------------+--------------以下为f2的栈帧
| old ebp | <== ebp
+--------------+
|        c       |
+--------------+
   .
   .
   .    [/quote]
为了更看的清楚,我们用gdb来跟踪一下。[quote]
(gdb) b f2       #在f2函数处设置断点
Breakpoint 1 at 0x804835a: file mm.c, line 8.
(gdb) r
Starting program: /home/leo/a.out

Breakpoint 1, f2 () at mm.c:8
8             int c = 300;
(gdb) bt    #用bt命令可以看出当前所有的栈帧
#0   f2 () at mm.c:8
#1   0x08048375 in f1 () at mm.c:15
#2   0x08048393 in main () at mm.c:23
(gdb) p c     #打印当前栈帧中的局部变量,c尚未初始化,值不定
$1 = 0
(gdb) p a     #a不在当前栈帧中,故出错
No symbol "a" in current context.
(gdb) frame 2 #切换到main函数的栈帧中
#2   0x08048393 in main () at mm.c:23
23              f1();
(gdb) p a    #现在可以打印a的值了
$2 = 100
(gdb) p c    #c不在main函数的栈帧中,故也不能打印
No symbol "c" in current context.[/quote]
看我各个命令及注释。当运行到c函数时,用bt可以找到当前的所有栈帧,每一个都有一个编号,当前的栈帧编号为零,其他的栈帧根据被调用的次序都有不同的编号。这样就可以用frame命令,在各个栈帧间切换。

天下无不散之筵席,文章也总要有结尾。其实关于栈,还有许多东西可讲,鉴于水平及精力,就到此为止吧。
最后,我出个题给大家做一下,算是练习巩固吧:)
程序如下:[quote]
#include ;
int
main()
{
       char str[] = "Hello World!";
       printf("%s");

       return 0;
}[/quote]
我的要求是,怎样做才能在不改变printf语句的条件下,正确打印出Hello World!呢?
哈哈,看你能想出几种方法^_^

参考:《Programming from the ground up》
阅读(1102) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~