文章 12
评论 4
浏览 20912
C语言内存布局与函数调用栈

C语言内存布局与函数调用栈

简介

  经过数十年的发展,如今的计算机技术很多领域已经发展地相当成熟,从事计算机行业的门槛也大大降低。从编程语言的发展趋势就可以看出,现代编程语言的语法越来越丰富,封装的程度也越来越高。很多编程语言提供了一个介于程序员和操作系统之间的中间层,以java为例,程序员编写的java代码首先会被编译器编译成java虚拟机能识别的字节码,然后这些编译好的class文件会被java虚拟机加载并执行。这个过程中java虚拟机扮演着非常重要的角色,许多针对系统底层的操作通常十分繁琐,虚拟机为我们封装了这些操作并且隐藏了具体的细节。借助这种虚拟机机制,即使一个人对计算机系统结构知之甚少,也可以编写出想要的程序。

  得益于现代编程语言与众多开源社区的蓬勃发展,如今编程的门槛已经变得非常得低。但要想称为一名优秀的开发者,掌握计算机底层的知识是必不可少的条件。C语言作为一门经久不衰的经典编程语言,它的思想和编程范式对后来的编程语言产生了深远的影响。在众多高级语言中,C语言是最接近操作系统底层的一种,C语言的语法非常简单,但简单的语法意味着它提供的功能非常有限,编写出功能复杂的c语言程序并不是一件简单的事。C语言的语法结构和操作系统对寄存器和内存的操作十分相似,甚至可以认为它只是对汇编语言的一层封装,写这篇博客的目的就是通过了解C语言程序的运行细节来学习计算机操作系统底层的行为。

C语言程序内存布局

  对于一个C语言编写的程序,它在运行过程中的进程地址空间可以分为四个区域,即代码区、数据区、栈区和堆区,这四部分内存区域的作用如下:

  • 代码区:用于存放编译好的二进制程序代码。
  • 数据区:这个区域又可以分为全局区和常量区,其中全局区存放全局变量和局部静态变量(值得一提的是,初始化过的全局变量和静态变量和未初始化的并不在同一片区域),常量区主要存放常量,一般是字符串常量。
  • 栈区:这个区域主要存放程序中的局部变量和函数调用过程中某些寄存器的值,这个区域的内存由编译器自动分配和释放,具体细节对开发者透明。
  • 堆区:堆上的内存是在程序中动态申请的,一般使用C语言的malloc函数申请堆上的内存,但堆内存编译器只负责申请,释放操作需要手动在代码中定义。

在这四个区域中,栈和堆是我们主要关注的两个区域。

  在编写C语言程序过程中,应当谨慎考虑我们所声明的变量具体是在哪个内存区域上,否则会导致一些意想不到的后果,看下面的例子

int* add_array(int* a, int* b, int N)
{
    int i;
    int p[20];
    for (int i = 0; i < N; i++){
        p[i] = a[i] + b[i];
    }
    return p;
}

这个程序的行为非常简单,将传入函数的两个数组对应位置的元素相加然后存放到一个新的数组中,最后返回这个数组。逻辑上并没有问题,但程序显然无法通过这个返回的指针来访问这个新数组,其原因在于数组p是声明在这个函数作用域中的,那么它必然存在于这个函数的栈帧当中,程序离开函数的作用域后,函数体内所有局部变量就会被释放,所以指针p指向的是一片已经被释放的内存区域。要解决这个问题,只需要将数组声明在堆内存上即可

int* p = (int*)malloc(N * sizeof(int));

这样,即使离开函数的作用域,指针p指向的数组也不会被释放。当然,这就又引出了另一个问题,程序动态申请的堆内存区域使用完毕后应当记得释放,否则会造成内存泄漏。C++中提供了智能指针来帮助释放,但C语言中并没有类似的工具,所以大多数情况下还是需要我们自己记得去释放。

C语言函数调用栈

  讲完了C语言的内存布局,接下来介绍C语言中另一个非常重要的概念——函数调用栈。事实上,C语言程序的执行过程可以看作是函数调用的过程。程序启动时最先调用的就是main函数,然后在main函数中调用其他的函数执行相应的程序。

寄存器使用

  从计算机硬件层面看,程序执行的本质是CPU读取内存或cache中的机器指令,然后译码执行。这个过程中,CPU的寄存器扮演着非常重要的角色,程序访问的指令地址、运算的中间值等都会存放在寄存器中。在x86架构中,有几个寄存器与函数的调用过程息息相关。指令寄存器EIP(Instruction Pointer)指向处理器下条将要执行的指令地址,每次执行完相应的指令后EIP的值就会增加。堆栈指针寄存器ESP(Stack Pointer)存放执行函数对应栈帧的栈顶地址,栈帧基址指针寄存器ESP(Base Pointer)存放执行函数的栈帧的栈底地址。

  除了上面提到的几个寄存器外,函数调用过程还需要使用其他的通用寄存器来保存一些现场信息。虽然某一时刻只有一个函数在执行,但需要保证某个函数调用其他函数时,被调函数不会修改或覆盖主调函数会用到的寄存器值。为此,IA32采用一套统一的寄存器使用约定,所有函数的调用都必须遵守该约定。根据这个约定,寄存器%eax、%edx和%ecx是主调函数保存寄存器,如果函数调用时主调函数希望保存这些寄存器的值,那么必须显式地将其保存在栈中。寄存器%ebx、%esi和%edi为被调函数保存寄存器,被调函数在覆盖这些寄存器的值时,必须先将寄存器原值压入栈中保存,并在函数返回前恢复其值。

  一般来说,编写C语言时无需注意上述的寄存器使用约定,因为寄存器的使用和分配都由编译器完成,这些过程对程序编写者透明,然而编写x86汇编程序时就必须注意这个约定。

函数栈帧结构

  嵌套的函数调用是非常普遍的,这意味着同一时刻堆栈中会存在多个函数的信息,为了不引起内存的混乱,编译器会在栈上为每个函数分配一个独立的连续区域,称为函数的栈帧(stack frame)。从内存的行为看,程序调用某个函数时会将其栈帧压入堆栈中,函数返回时栈帧会从堆栈中弹出,函数的栈帧中存放着函数的参数、局部变量以及一些寄存器信息等。正如前文所述,栈内存上的变量都由编译器自动分配和释放,通过栈帧结构可以很方便地实现函数调用和局部变量的管理。借助栈帧还可以实现函数的递归调用,编译器只需为每次函数的递归调用单独分配一个新的栈帧而不用关心它具体调用的哪个函数。

  函数调用时有两个非常关键的寄存器,即栈帧基地址指针EBP和堆栈指针ESP,函数的栈帧是从栈空间的高地址向低地址增长的。EBP指针指向栈帧的底部(高地址),ESP指针指向栈帧的顶部(低地址)。在x86架构32位系统下,发生函数调用时一个典型的内存布局如下图(图源网络)

stack.jpg

从图中可以看出当主调函数调用被调函数时,被调函数会做这么几件事情:**1)将主调函数的EBP指针值压入栈中,并修改EBP指针为ESP指针的值(主调函数的栈顶即为被调函数的栈底);2)修改ESP指针的值为局部变量分配空间,并将参数也压入栈中;3)将此时的EIP寄存器值作为返回地址压入栈中。**当函数调用结束时,EBP指针的值会赋给ESP,并且将被调函数栈中保存的主调函数的栈基指针弹出,并将EBP指针修改为这个值,这样内存就回到了调用前的布局。

  可以看一个简单的例子,假设有一段C语言代码如下

#include <stdio.h>

int add(int a, int b)
{
    int s = a + b;
    return s;
}

int main()
{
    int x = 2;
    int y = 3;
    int s = 0;
    s = add(x, y);
    printf("%d\n", s);
    return 0;
}

以这一段简单的代码为例看一下它的函数调用过程,使用 gcc -S命令将其编译成汇编代码,add函数的汇编代码如下

add:
.LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    %edi, -20(%rbp)
        movl    %esi, -24(%rbp)
        movl    -20(%rbp), %edx
        movl    -24(%rbp), %eax
        addl    %edx, %eax
        movl    %eax, -4(%rbp)
        movl    -4(%rbp), %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc

需要指出的是,这段代码是在64位ubuntu系统上编译的,汇编代码中的rbp寄存器和前面提到的ebp寄存器在物理上是一致的,rbp表示64位的寄存器,32位系统下位ebp。可以看到,函数add的行为与前面所介绍的基本一致,即先使用push指令将rbp的值压入栈中,然后修改rbp指针为rsp指针的值,函数调用完毕后会将rbp值弹出。再来看main函数的汇编代码

main:
.LFB1:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        subq    $16, %rsp
        movl    $2, -12(%rbp)
        movl    $3, -8(%rbp)
        movl    $0, -4(%rbp)
        movl    -8(%rbp), %edx
        movl    -12(%rbp), %eax
        movl    %edx, %esi
        movl    %eax, %edi
        call    add
        movl    %eax, -4(%rbp)
        movl    -4(%rbp), %eax
        movl    %eax, %esi
        movl    $.LC0, %edi
        movl    $0, %eax
        call    printf
        movl    $0, %eax
        leave
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc

不难看出,main函数调用时的行为与add函数基本一致,main函数中多了一些定义局部变量的指令以及两条call指令,这与C语言代码一致。

总结

  本文简单介绍了C语言程序运行时在内存中的一些行为,事实上,如果从设计一款C语言编译器后端的角度看,前文所介绍的内容是远远不够的。C语言在编译时,编译器需要根据x86架构的标准以及相关ABI的标准为其生成相应的汇编代码,一个可行学习的方法是编写C语言代码时先用gcc将其编译成汇编代码,然后查看程序对应的汇编指令,这样可以进一步加深理解。


标题:C语言内存布局与函数调用栈
作者:coollwd
地址:http://coollwd.top/articles/2020/01/04/1578111115801.html

Everything that kills me makes me feel alive

取消