深入理解栈内存与函数调用栈——以C语言为例

目录

前言

直接借用之前写的《为什么-128的补码是1000 0000?》的开头,毕竟动机相同。

这个问题并不是什么面试题,而是今晚(注:昨晚)刚上大一初学C语言的小辈问我的,一瞬间竟然有点发蒙,不知道该如何回答。好在最后还是理清了思路,顺便将这个非常基础(?)的知识点总结下吧。

问题的起源是如下的照片:

图0

第一眼看到它的时候,内心OS:

这玩意儿为什么会出现在大一上学期的教材里?


吐槽完了。那么今天就把函数调用栈(function call stack)的细节掰开揉碎,彻底弄清楚。本文基于通用的IA-32(即俗称的Intel 32位)指令集架构来研究,下面先来点预备知识。

作为数据结构的栈

栈(stack)是一种后进先出(LIFO)的线性数据结构,数据元素在逻辑上呈堆叠式存储。元素发生进出的一端叫做栈顶(stack top),相对的一端则叫做栈底(stack base)。栈支持压入(push)和弹出(pop)两种操作,如下图所示。

图1

可见,push操作就像是“穿烤串”,pop操作就像是“吃烤串”。

IA-32寄存器简介

寄存器(register)是CPU存储指令和数据的单元。下图示出IA-32架构中的部分寄存器,不包含与本文无关的状态寄存器和段寄存器。

图2

其中,EAX~EDX四个32位寄存器为通用寄存器(general purpose registers),理论上讲是存储什么都可以的。但是在指令集的发展过程中,有一部分指令会有特殊要求,需要以特定的通用寄存器来存储操作数或输出结果。所以,EAX经常被用作累加器(A=accmulator),EBX作为数据基地址的暂存器(B=base),ECX作为循环计数器(C=counter),EDX作为操作数或操作结果暂存器(D=data)。

为了方便存储较短的数据,通用寄存器都可以只使用低16位,此时称为AX~DX。低16位还可以进一步拆分为两个更小的8位寄存器,低8位称为AL~DL,高8位称为AH~DH。

ESI和EDI有时也被包括在通用寄存器内,但它们不能拆分,一般用作字符串操作的源指针和目的指针,不再赘述。那么剩下的ESP和EBP寄存器是做什么的呢?它们与栈内存密切相关,下面来看。

栈内存布局与栈帧

所有x86架构的实现都会在内存中划分堆区和栈区。堆区是支持动态内存分配的空间,由用户自行管理和利用,而栈区是由系统管理的内存空间,简要的布局如下图所示。

图3

可见,堆区是按照地址空间顺序生长的,栈区则是逆序生长的,栈底位于高地址,栈顶位于低地址。也就是说,栈区的实际组织方式可以想象成一个倒扣着的杯子(里面装的东西不会主动掉出来)。

那么系统如何知道栈顶现在在哪里?栈顶地址储存在图2所示的ESP寄存器内,SP就是“栈指针”(stack pointer)的缩写。向栈压入元素时,ESP指向的地址值会减小(IA-32中就是减小32位,4个字节),弹出元素则会增大。也就是说,如果我们将EAX寄存器中的数据入栈:

push eax

实际上等价于做了如下两步操作:

sub esp, 4                     ; 栈顶下移
mov DWORD PTR SS:[esp], eax    ; EAX的数据传送到新栈顶的地址

同理,如果将栈顶的数据弹出到EAX:

pop eax

就等价于:

mov eax, DWORD PTR SS:[esp]    ; 当前栈顶地址的数据传送到EAX
add esp, 4                     ; 栈顶上移

栈顶的问题解决了,还有一个新的问题:栈区在物理上是连续而大的空间,如果把它当做一整块区域来用,必然很不方便。所以栈区在逻辑上会组织成多个小的栈,每个小栈叫做栈帧(stack frame)。而EBP寄存器存储的就是位于栈区顶部那个栈帧的栈底地址,BP即“基指针”(base pointer)。这句话有点拗口,画图表示之。

图4

这样,通过EBP和ESP寄存器分别标识当前栈帧的头和尾,我们就可以自如地操作栈帧了。

栈区和栈帧的最主要用途就是保存(C语言的)函数调用栈——包括函数的调用过程与所有上下文信息,如参数、局部变量、返回地址等。下面我们结合实例来探索。

函数调用栈分析

用图0里的那段程序稍微改一改。

int func(int a, int b) {
  int x = 99, y = 77, z;
  z = (a - b) * x / y;
  return z;
}

void caller() {
  int m = 55, n = 33, k;
  k = func(m, n);
}

int main() {
  caller();
  return 0;
}

其中,函数caller()称为主调函数(英文也是caller),函数func()称为被调函数(callee)。执行如下命令,将C代码编译为32位汇编代码:

gcc -S -m32 main.c

查看生成的汇编文件main.s,节选main()函数→caller()函数→func()函数的调用链。

    .globl  _func                   ## -- Begin function func
    .p2align    4, 0x90
_func:                                  ## @func
    .cfi_startproc
## %bb.0:
    pushl   %ebp
    .cfi_def_cfa_offset 8
    .cfi_offset %ebp, -8
    movl    %esp, %ebp
    .cfi_def_cfa_register %ebp
    subl    $20, %esp
    movl    12(%ebp), %eax
    movl    8(%ebp), %ecx
    movl    $99, -4(%ebp)
    movl    $77, -8(%ebp)
    movl    8(%ebp), %edx
    subl    12(%ebp), %edx
    imull   -4(%ebp), %edx
    movl    %eax, -16(%ebp)         ## 4-byte Spill
    movl    %edx, %eax
    cltd
    idivl   -8(%ebp)
    movl    %eax, -12(%ebp)
    movl    -12(%ebp), %eax
    movl    %ecx, -20(%ebp)         ## 4-byte Spill
    addl    $20, %esp
    popl    %ebp
    retl
    .cfi_endproc
                                        ## -- End function
    .globl  _caller                 ## -- Begin function caller
    .p2align    4, 0x90
_caller:                                ## @caller
    .cfi_startproc
## %bb.0:
    pushl   %ebp
    .cfi_def_cfa_offset 8
    .cfi_offset %ebp, -8
    movl    %esp, %ebp
    .cfi_def_cfa_register %ebp
    subl    $24, %esp
    movl    $55, -4(%ebp)
    movl    $33, -8(%ebp)
    movl    -4(%ebp), %eax
    movl    -8(%ebp), %ecx
    movl    %eax, (%esp)
    movl    %ecx, 4(%esp)
    calll   _func
    movl    %eax, -12(%ebp)
    addl    $24, %esp
    popl    %ebp
    retl
    .cfi_endproc
                                        ## -- End function
    .globl  _main                   ## -- Begin function main
    .p2align    4, 0x90
_main:                                  ## @main
    .cfi_startproc
## %bb.0:
    pushl   %ebp
    .cfi_def_cfa_offset 8
    .cfi_offset %ebp, -8
    movl    %esp, %ebp
    .cfi_def_cfa_register %ebp
    subl    $8, %esp
    movl    $0, -4(%ebp)
    calll   _caller
    xorl    %eax, %eax
    addl    $8, %esp
    popl    %ebp
    retl
    .cfi_endproc
                                        ## -- End function

注意该代码中的形如d(%ebp)的表示方法,它是相对寻址,即以EBP为基地址,偏移量为d个字节的内存空间中的内容。另外,$d表示值为d的十进制立即数。看不太懂也没关系,下面直接给出发生函数调用时与栈相关的动作过程:

  1. 主调函数把被调函数需要的参数值(实参)从右至左入栈;
  2. 主调函数使用call指令正式调用被调函数,并把返回地址(即call指令的下一条指令的地址)入栈。返回地址入栈这一步是隐含的;
  3. 被调函数将主调函数的栈底地址(%ebp)入栈,并将该地址作为被调函数的栈顶地址(%esp);
  4. 将被调函数中定义的局部变量按照定义的自然顺序(从左至右)入栈;
  5. 被调函数执行完毕后,将(%esp)上移,找到之前主调函数的栈底地址,并将它出栈。最后使用ret指令返回主调函数。

由此可见,每个栈帧都会存储上一栈帧的EBP内存储的基地址,并且它是作为分界线:向被调函数传入的参数位于该EBP的栈底方向(较高地址),而被调函数自己的局部变量位于该EBP的栈顶方向(较低地址),进退自如。下面画图示出函数调用栈的全貌,小单元格表示4字节的内存空间。

图5

由于每个函数的栈帧都维护有上一级主调函数的EBP地址,这就保证被调函数调用完毕后,通过上移ESP即可废弃当前栈帧,并且通过弹出主调函数的EBP地址就能恢复主调函数的现场。通过不断扩展栈帧结构,就形成了多级函数的调用栈。

为了有更直观的感受,我们在上述程序中再加几句。

#define MEM_ADDR(x) printf("&"#x" = %p\n", &x)

int func(int a, int b, int c) {
  int x = 99, y = 77, z;
  z = (a - b + c) * x / y;
  MEM_ADDR(a);
  MEM_ADDR(b);
  MEM_ADDR(c);
  MEM_ADDR(x);
  MEM_ADDR(y);
  MEM_ADDR(z);
  return z;
}

void caller(int p) {
  int m = 55, n = 33, k;
  k = func(m, n, p);
  MEM_ADDR(p);
  MEM_ADDR(m);
  MEM_ADDR(n);
  MEM_ADDR(k);
}

int main() {
  caller(11);
  return 0;
}

定义了一个MEM_ADDR宏,用来输出各个变量的地址。看官可以用32位的GCC编译运行一下,看看输出的地址是否符合函数调用栈结构的预期。

The End

弄懂函数调用栈之后,可以极大地方便我们理解递归(recursion)与递归的特例——尾递归(tail recursion)。不过本文已经很长了,关于递归的细节就另外写文章吧。

晚安。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,928评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,192评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,468评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,186评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,295评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,374评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,403评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,186评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,610评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,906评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,075评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,755评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,393评论 3 320
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,079评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,313评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,934评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,963评论 2 351