C语言深处

    返回首页    发表留言
本文作者:李德强
          第二节 函数栈帧
 
 

一、局部变量

        先来看一下函数内部定义变量在内存中的存放位置。首先要说明的是一个C程序被操作系统载入内存时分为两个部分:代码段和数据段。代码段中存放了程序运行的代码内容。数据段则又分为两个部分堆和栈。我们常常所说的堆栈其实在内存中是具有不同作用的两部分区域。在本节我们只学习栈的相关内容,而关于堆的内容我们会在后续章节中学习。如果在一个函数内部定义一些变量,它们在内存中就是存放在了栈中,并且符合栈操作原理,即先进后出,后进先出。下面来看一个简单的例子:

#include <stdio.h>

int main(int argc, char **args)
{
	int m = 0x11111111;
	int n = 0x22222222;
	int o = 0x33333333;
	int p = 0x44444444;

	return 0;
}

        来看一下这个程序的反汇编程序:

080483cb <main>:
 80483cb:	55                   	push   %ebp
 80483cc:	89 e5                	mov    %esp,%ebp
 80483ce:	83 ec 10             	sub    $0x10,%esp
 80483d1:	c7 45 fc 11 11 11 11 	movl   $0x11111111,-0x4(%ebp)
 80483d8:	c7 45 f8 22 22 22 22 	movl   $0x22222222,-0x8(%ebp)
 80483df:	c7 45 f4 33 33 33 33 	movl   $0x33333333,-0xc(%ebp)
 80483e6:	c7 45 f0 44 44 44 44 	movl   $0x44444444,-0x10(%ebp)
 80483ed:	b8 00 00 00 00       	mov    $0x0,%eax
 80483f2:	c9                   	leave  
 80483f3:	c3                   	ret  

        在main函数被调用时首先将%ebp压栈(当然这里压栈的是%ebp,但在调用main函数的函数里%ebp已经被赋值为%esp,所以这个值实际上是调用函数的%esp,也就是这个函数的栈顶位置),然后%ebp被赋值为%esp,接下来向栈中申请0x10个字节(4个int型变量刚好是16个字节16==0x10),于是%ebp指向了main函数所使用栈可用空间的栈顶,最后将m、n、o、p这4个变量压栈。栈的数据图如下:


 

        这就是函数内部局部变量在内存中的存放方式。接下来我们来看一下多个函数调用时它们的局部变量存放方式:

#include <stdio.h>

void func_B(void)
{
	int e = 0x99999999;
	int f = 0xaaaaaaaa;
	int g = 0xbbbbbbbb;
	int h = 0xcccccccc;
}

void func_A(void)
{
	int a = 0x55555555;
	int b = 0x66666666;
	int c = 0x77777777;
	int d = 0x88888888;

	func_B();
}

int main(int argc, char **args)
{
	int m = 0x11111111;
	int n = 0x22222222;
	int o = 0x33333333;
	int p = 0x44444444;

	func_A();

	return 0;
}

        看一下程序的反汇编代码:

080483cb <func_B>:
 80483cb:	55                   	push   %ebp
 80483cc:	89 e5                	mov    %esp,%ebp
 80483ce:	83 ec 10             	sub    $0x10,%esp
 80483d1:	c7 45 fc 99 99 99 99 	movl   $0x99999999,-0x4(%ebp)
 80483d8:	c7 45 f8 aa aa aa aa 	movl   $0xaaaaaaaa,-0x8(%ebp)
 80483df:	c7 45 f4 bb bb bb bb 	movl   $0xbbbbbbbb,-0xc(%ebp)
 80483e6:	c7 45 f0 cc cc cc cc 	movl   $0xcccccccc,-0x10(%ebp)
 80483ed:	c9                   	leave  
 80483ee:	c3                   	ret    

080483ef <func_A>:
 80483ef:	55                   	push   %ebp
 80483f0:	89 e5                	mov    %esp,%ebp
 80483f2:	83 ec 10             	sub    $0x10,%esp
 80483f5:	c7 45 fc 55 55 55 55 	movl   $0x55555555,-0x4(%ebp)
 80483fc:	c7 45 f8 66 66 66 66 	movl   $0x66666666,-0x8(%ebp)
 8048403:	c7 45 f4 77 77 77 77 	movl   $0x77777777,-0xc(%ebp)
 804840a:	c7 45 f0 88 88 88 88 	movl   $0x88888888,-0x10(%ebp)
 8048411:	e8 b5 ff ff ff       	call   80483cb <func_B>
 8048416:	c9                   	leave  
 8048417:	c3                   	ret    

08048418 <main>:
 8048418:	55                   	push   %ebp
 8048419:	89 e5                	mov    %esp,%ebp
 804841b:	83 ec 10             	sub    $0x10,%esp
 804841e:	c7 45 fc 11 11 11 11 	movl   $0x11111111,-0x4(%ebp)
 8048425:	c7 45 f8 22 22 22 22 	movl   $0x22222222,-0x8(%ebp)
 804842c:	c7 45 f4 33 33 33 33 	movl   $0x33333333,-0xc(%ebp)
 8048433:	c7 45 f0 44 44 44 44 	movl   $0x44444444,-0x10(%ebp)
 804843a:	e8 b0 ff ff ff       	call   80483ef <func_A>
 804843f:	b8 00 00 00 00       	mov    $0x0,%eax
 8048444:	c9                   	leave  
 8048445:	c3                   	ret 

        当main函数调用函数func_A()时首先将%eip压栈,函数func_A在执行时先将%ebp压栈(其实是main函数中的%esp),将%esp减0x10申请栈空间,再将a、b、c、d这4个变量压栈。当func_A()调用func_B()时也是同样的原理。整个调用过程内存图如下:


 

 

        我们知道在函数内部定义的变量称作“局部变量”,它们的作用域只限于函数内部,原因是当一个函数执行结束之后就要执行ret指令来返回调用它的函数中继续执行,而在执行ret之前还要执行一个leave命令(相当于pop %ebp; mov %ebp, %esp;),也就是说要恢复调用函数的%esp。也就是说%esp已经恢复成了调用函数的栈区域,那么被调用函数的局部变量已经不能再使用了。

        下面我们再来看另外一个函数调用的例子:

#include <stdio.h>

void func_A(void)
{
	int a = 0x11111111;
	int b = 0x22222222;
	int c = 0x33333333;
	int d = 0x44444444;

	printf("%x\n%x\n%x\n%x\n\n", a, b, c, d);
}

void func_B(void)
{
	int e;
	int f;
	int g;
	int h;

	printf("%x\n%x\n%x\n%x\n\n", e, f, g, h);
}

int main(int argc, char **args)
{
	func_A();

	func_B();

	return 0;
}

        运行结果:

11111111
22222222
33333333
44444444

11111111
22222222
33333333
44444444

        很奇怪,为什么在函数func_B()中定义的局部变量e、f、g、h没有被初始化,也没有被赋值,但它们的值却与func_A()中的局部变量a、b、c、d一模一样呢?我们再来看一下在main函数分别调用func_A()、和func_B()时栈中的变量存储方式:


 

​        当main函数调用func_A()时,func_A将a、b、c、d初始化,并压栈;在func_A执行结束时出栈,%esp恢复到main函数的栈顶,而当main调用func_B()时,func_B将e、f、g、e压栈。值得注意的是func_A和func_B所使用的栈地址是一样的,执行压栈和出栈时只是修改了%esp的值而并不会修改栈中数据的内容。而在C语言中定义变量时如果不为其初始化,这些变量在内存中所在位置的数据是不会被清空的,也就是说这些内存地址中的值原来是多少就是多少,所以当func_B定义了4个变量e、f、g、h,并没有对它们初始化,但是它们的值就是原来使用它们这些地址时的值,也就是a、b、c、d的值。

        所以说定义了变量之后要对其初始化,否则这些变量的值是无法预计的,在使用的过程中会存在非常严重的隐患问题。

 

二、参数传递

        下面再来看一下带有参数的函数参数传递原理以及它们在栈中的存储方式:

#include <stdio.h>

void func(int e, int f, int g, int h)
{
	e = 0x55555555;
	f = 0x66666666;
	g = 0x77777777;
	h = 0x88888888;
}

int main(int argc, char **args)
{
	int a = 0x11111111;
	int b = 0x22222222;
	int c = 0x33333333;
	int d = 0x44444444;

	func(a, b, c, d);

	return 0;
}

        看一下这个程序的反汇编代码:

080483cb <func>:
 80483cb:	55                   	push   %ebp
 80483cc:	89 e5                	mov    %esp,%ebp
 80483ce:	c7 45 08 55 55 55 55 	movl   $0x55555555,0x8(%ebp)
 80483d5:	c7 45 0c 66 66 66 66 	movl   $0x66666666,0xc(%ebp)
 80483dc:	c7 45 10 77 77 77 77 	movl   $0x77777777,0x10(%ebp)
 80483e3:	c7 45 14 88 88 88 88 	movl   $0x88888888,0x14(%ebp)
 80483ea:	5d                   	pop    %ebp
 80483eb:	c3                   	ret    

080483ec <main>:
 80483ec:	55                   	push   %ebp
 80483ed:	89 e5                	mov    %esp,%ebp
 80483ef:	83 ec 10             	sub    $0x10,%esp
 80483f2:	c7 45 fc 11 11 11 11 	movl   $0x11111111,-0x4(%ebp)
 80483f9:	c7 45 f8 22 22 22 22 	movl   $0x22222222,-0x8(%ebp)
 8048400:	c7 45 f4 33 33 33 33 	movl   $0x33333333,-0xc(%ebp)
 8048407:	c7 45 f0 44 44 44 44 	movl   $0x44444444,-0x10(%ebp)
 804840e:	ff 75 f0             	pushl  -0x10(%ebp)
 8048411:	ff 75 f4             	pushl  -0xc(%ebp)
 8048414:	ff 75 f8             	pushl  -0x8(%ebp)
 8048417:	ff 75 fc             	pushl  -0x4(%ebp)
 804841a:	e8 ac ff ff ff       	call   80483cb <func>
 804841f:	83 c4 10             	add    $0x10,%esp
 8048422:	b8 00 00 00 00       	mov    $0x0,%eax
 8048427:	c9                   	leave  
 8048428:	c3                   	ret

        同样的,在带参数函数调用时也是使用栈机制来存放函数的参数,我们来看一下内存存储格式图:


 

 

        可以看到在main中定义的4个变量a、b、c、d被倒序压栈,也就是上图中灰色部分的e、f、g、h,也就是函数func(int e, int f, int g, int h)中的4个参数,这种参数被称作“形式参数”简称“形参”。当func执行时修改e、f、g、h时,并没有影响到main函数中a、b、c、d这4个变量的值。所以说,在函数调用过程中被调用函数修改参数的值并不会影响到调用函数中的传入变量的值。

         再来看下面的一个例子:

#include <stdio.h>

void func_C(int e, int f, int g, int h)
{
	int a = 0xcccccccc;
	int b = 0xdddddddd;
	int c = 0xeeeeeeee;
	int d = 0xffffffff;
}

void func_B(int e, int f, int g, int h)
{
	int a = 0x88888888;
	int b = 0x99999999;
	int c = 0xaaaaaaaa;
	int d = 0xbbbbbbbb;

	func_C(a, b, c, d);
}

void func_A(int e, int f, int g, int h)
{
	int a = 0x44444444;
	int b = 0x55555555;
	int c = 0x66666666;
	int d = 0x77777777;

	func_B(a, b, c, d);
}

int main(int argc, char **args)
{
	int a = 0x00000000;
	int b = 0x11111111;
	int c = 0x22222222;
	int d = 0x33333333;

	func_A(a, b, c, d);

	return 0;
}

        这次我们直接看一下内存栈中的格式:


        可以看到C语言中都是采用这种栈机制来实现局部变量和函数参数的。每一个函数所可以访问到的局部变量和形参被称作为C语言中函数的“栈帧”。

    返回首页    返回顶部
#1楼  ByXc  于 2017年04月29日14:27:12 发表
 
栈不是向下生长的嗎,所谓向下生长是指从内存高地址->低地址的路径延伸
#2楼  李德强  于 2017年04月29日18:40:28 发表
 
栈——不过只是一种数据结构,只要遵循先进后出、后进先出原则即可。至于内存地址向下增长还是向上增长并不重要,但是C语言的函数栈帧是由低地址向高地址增长的。
#3楼  ByXc  于 2017年04月30日21:25:43 发表
 
谢谢, 再请教楼主一个困扰了我好久问题。 在c primer plus中说到: “”c保证在给数组分配空间时,指向数组后面第一个位置的指针仍是有效的指针。”

怎么去理解, 看了好几篇博客都没找到答案。。
#4楼  匿名  于 2017年05月02日09:27:07 发表
 
支持问渠网
#5楼  李德强  于 2017年05月02日09:35:25 发表
 
我看了一下这本书关于数据的这一章,原话是:“计算机并不检查指针是否仍然指向某个数组元素。C保证指向数组元素的指针和指向数组后的第一个地址的指针都是有效的。但是如果指针在进行了增量或减量运算后超出了这个范围,后果将是未知的。另外,可以对指向一个数组元素的指针进行取值运算。但不能对指向数组后的第一个地址的指针进行取值运算,尽管这样的指针是合法的。”

我想作者的本意并不是这样的,作者是想表达这样一个事实:C的编译器和计算机并不会对指针所指向的内存地址做合理性校验。C采用的是信任程序员原则,认为程序员会合理的处理好所有问题,从而大大提高C程序的运行速度。数组变量实际上只是一个指向这个连续内存空间第一个地址的指针,这个指针可以通过下标的方式来对数组取值,例如:p[0],p[1] ... p[n] 。另外,也可以通过解引用的方式来取值,例如:*p, *(p + 1), ... , *(p + n) 。所有的这些操作编译器和计算机都不会对其做合法性校验。所以说如果我们定义了一个大小为10的数组,这样使用它们是合法的:p[-3], p[-2], p[10], p[11], p[12], *(p - 3), *(p - 2), *(p + 10), *(p + 11), *(p + 12)。但这样做数组的取值就超出了这个数组的内存空间,虽然这是合法的,但并不合理。

如果你对C语言定义变量内存存放空间有所了解,那么你可以这样使用它们。也就是说如果你知道内存中排列在这个数组前后的内存中内容是什么的话,完全可以这样使用(但并不推荐)。
#6楼  ByXc  于 2017年05月07日21:27:31 发表
 
好的,谢谢楼主解答。程序清单10.6就是一个很好的例子。 的确要知道指针类型才行, 要是个double的话又不知是什么结果了
  看不清?点击刷新

 

  Copyright © 2015-2023 问渠网 辽ICP备15013245号