C|堆和栈

堆(heap)和栈(stack)是非常重要的概念,当我们进行程序开发时理解它们非常重要,尤其是对于嵌入式系统开发。比如在嵌入式系统中,任务的栈通常都很小,可能也就几K字节。在这种情况下,我们就应当尽可能不要将占用内存大的变量分配在栈上,而是应当分配在堆上;此外,也尽量不要采用递归的方式来设计程序,否则很容易造成栈溢出。

从本质上说,堆和栈都是内存,那么我们只能从概念上对其进行区分了。为了方便说明,现在假设嵌入式软件是一个单体程序(这一术语并不是嵌入式系统开发中的专用术语,是我为了方便说明而使用的),也就是操作系统和我们的应用程序是被编译在同一个可执行程序当中的,比如,来自WindRiver的VxWorks就是采用这种方式的。我们知道一个可执行程序存在最为重要的三个段。.text段用于存放程序的代码,即放的是处理器的运行指令。.data用于存放初始化好的数据,当boot loader(请参见《什么是boot loader》)加载程序文件时,会将程序文件中的.data段拷贝到内存的VMA(Virtual Memory Address,在《熟悉binutils工具集》中有所提及,在嵌入式系统中绝大部分不用虚拟内存,因此,VMA就是实地址)处,从而完成变量的初始化操作。虽然,我们在C/C++程序中是对全局变量一个一个初始化的,但实际上boot loader是对所有的全局变量通过将程序文件中的.data段拷贝到内存中一次性的完成初始化的。.bss段用于存放没有初始化好的变量,程序文件中并不存放.bss段的具体内容,只是存有.bss段的起始地址和大小,当boot loader加载我们的嵌入式程序文件时,只是根据程序文件中的.bss信息对内存中的.bss块进行清零操作。

图 1示例了boot loader与我们的单体程序共存的一个内存和FLASH映射快照。其中我们假设内存的大小是8M字节。可以看出在FLASH上即存放了boot loader程序,又存放了我们的单体程序,至于FLASH上是否有文件系统我们在此并不用关心。在内存中你可以看出也存在一块boot loader区,这一块区是由FLASH中的boot loader将自己加载到内存中的,以便加快运行速度,可以想像这块内存区是最早被拷贝到内存中的。当内存中的boot loader运行时,其会读取FLASH中的单体程序,这一单体程序通常是ELF格式的。其中的ELF头指示了各段的VMA地址和大小以及各段内容在单体程序中的偏移地址。boot loader通过ELF头信息,将.texe段和.data段从FLASH拷贝到内存中。显然,boot loader和单体程序在内存中所占用的地址空间是不能重叠的,否则当boot loader将单体程序从FLASH拷贝到内存时,会将其自身的内容给覆盖掉,从而造成自己无法正常运行。地址的规划是我们设计boot loader时需要考虑到的。图中我们只示例了一个中断向量表,其实,很有可能boot loader内存区也有一个中断向量表,是供boot loader运行时用的。还有就是有一块临时的栈空间,这一空间可以是boot loader和单体程序共同使用的,对于共同使用,需要强调的是boot loader与单体程序并不会同时运行,当boot loader运行完了以后,会调转到单体程序的入口处开始运行,入口地址显然应当位于内存的.text段。而单体程序在一开始运行时(此时操作系统还没有起作用),仍是需要一块小的内存作为栈来使用,以便能进行函数调用。根据不同的设计,我们可以在单体程序运行的初始阶段使用与boot loader相同的栈,当然也可以使用不同的栈。这里我们假设使用相同的栈。从图1中,我们可以看出,内存中还存在很大的一块闲置区,这一块区暂时还没有使用用途。


图 1
一旦boot loader运行了我们的单体程序,我们说boot loader就不存在了,那此时boot loader所占用的内存空间也就释放出来了,如图 2所示。从图中可以看出内存中的闲置空间加大了。那闲置空间被我们的单体程序用来做什么呢?做堆!在单体程序中的操作系统部分,会提供一定的管理模块来管理这块堆,并提供API(Application Programming Interface,应用程序编程接口)让我们调用,从而实现从堆中分配或是释放内存,这些API类似于C语言中的malloc ()/free ()。堆在管理上有一个特点,从堆中分配出来的内存应当是以某一大小字节为边界的。比如,如果CPU中的double类型是占用内存最多的数据类型且是8字节,那么堆分配出来的内存就必须保证是以8字节为边界的。这一点请读者想一想为什么?除了采用动态的内存分配,在嵌入式系统中通常还会采用固定大小内存块的分配方法,这种分配方法的好处是非常的快,而且这种内存在使用的过程中不会产生内存碎片。

图 2
堆我们说过了,那接下来我们看一看如果我们的单体程序继续运行,会出现什么样的内存布局。我们知道,通常我们的单体程序在初始化时往往需要创建多个任务来实现其应用功能。对于每一个任务,它一块内存是私有的,那就是栈!当任务运行时,其需要用栈来做为函数调用时的参数传递空间,以及用栈来存储函数内的局部变量。假设我们的单体程序需要创建两个任务A和B,这需要通过调用操作系统中的任务创建函数来达到这一目的。操作系统所提供的任务创建API往往需要我们指定任务栈的大小,有的甚至可以指定栈内存空间。一旦任务创建的API被调用,那么操作系统会调用堆分配API为任务分配栈,此时的内存布局如图 3所示。任务创建完了以后,各任务就可以根据应用程序逻辑的需要审请堆空间以实现其业务逻辑。

图 3
【C|堆和栈】 对于堆我们已经知道了必须调用相应的API来分配内存,那从栈空间分配内存也需要调用API吗?答案是通常不需要,为什么是通常?因为,在有的平台上(Linux上就是)提供栈空间的分配API,即这种API被调用时,是从调用任务的栈空间中分配内存的。对于这一功能,在嵌入式系统中使用得非常的少,我也不建议大家使用。对于下面的代码,mem_main、mem_foo和mem_bar的大小是4K字节(假设int类型的大小是4字节),这些内存就是自动(注意是自动)分配在运行任务的栈上的。我们假设某个任务当前所使用的栈是零字节,当这一任务运行到main中且没有进入foo ()时,其所占用的空间大小是大约4K字节,之所以用大约这个词,是因为函数的调用还有其它的栈开销。一旦任务运行进入foo ()函数但没有进入bar ()函数,那么所占用的栈的大小就变为大约8K字节。同样的,如果程序运行进入bar ()函数,那么所占用的栈空间大约就是12K字节了。

  1. void bar ()
  2. {
  3. int mem_bar [1024];
  4. // application logic
  5. }

  6. void foo ()
  7. {
  8. int mem_foo [1024];
  9. bar ();
  10. }

  11. int main ()
  12. {
  13. int mem_main [1024];
  14. foo ();
  15. return 0;
  16. }
如果程序继续运行,从bar ()函数返回到foo ()函数中,那么其所占用的栈空间就从大约12K字节变成了大约8K字节了。相类似的是,如果程序从foo ()函数中返回到main ()函数,那么所占用的栈空间又变为大约4K字节了。对于嵌入式系统开发,由于任务栈通常都比较的小,那这告诉我们什么呢?我想有以下几点需要注意。
1)函数的调用深度越是深,由于每一级的函数通常都会有局部变量,那么所使用的栈空间也会累积得越大。
2)递归调用需要的栈空间会相对的大(视具体的情况),在嵌入式系统中也建议少用。
3)我们应当尽可能的不要在函数中定义占用内存空间较大的局部变量。

下面,我们总结一下堆与栈的区别,它们是:
1)堆是大家共享的。任务可以通过调用API来从堆中分配内存空间。
2)栈是任务所独有的。在嵌入式系统中,当一个任务创建起来后其栈空间的大小往往是定了的。函数中的局部变量是由编程语言自动从栈上分配的,我们不需要调用API进行空间分配。

最后我有一个问题留给读者您,这个问题是:
前面的讲解中,我们说任务的栈是由操作系统的任务创建API从堆中分配出来的,那栈是否也可以位于.data段或是.bss段中呢?为什么?

答案
由于堆从本质上说来就是一块内存,由于在C语言中一块内存可以从堆中分配,也可以从.data段或是.bss段中分配。因此,任务的栈也是可以从这三块内存中分配获得,也就是说最终的答案是:可以。

    推荐阅读