内存分布和栈空间
Memory Layout And The Stack
原作者:Peter Jay Salzman
译者:刘瀚文
原文:Using GNU’s GDB Debugger Memory Layout And The Stack
导语
想要快速的学习使用GDB,你必须理解frames数据帧
的含义,数据帧
又叫做堆栈帧
,因为堆栈就是由数据帧构成的。想要学习堆栈的知识,我们需要了解一个运行中的程序在内存中是如何分布的。这篇文章讨论的主要是理论知识,但是为了让这篇文章读起来没有那么枯燥乏味,我们将引入关于在堆栈
和堆栈帧
上使用GDB的例子。
这篇文章虽然理论性较强,但是却能为我们提供十分有价值的学习目标:
- 想要使用典型的debugger比如说GDB,就必须理解堆栈。
- 通过学习程序的内存分布将有利于我们理解什么是
segmentation fault段错误
,并且明白为什么有时候会出现这个错误而有时候该出现的时候却没有出现(有时后者更关键)。总之,段错误
是最常见的将程序变成‘定时炸弹’的原因。 - 了解程序的内存空间分布常常让我们能不通过
printf
、编译器或甚至GDB就找到一些隐藏的非常深的错误。下一节的内容是由我们的客人、我的朋友——Mark Kim写的,我们能看到像福尔摩斯一样的侦探,Mark在冗长的代码中找到了一个隐藏的很深的bug,并且他仅仅用了5到10分钟。他找到这个bug全靠盯着代码研究和运用程序内存分布的知识,这个例子一定为让读者印象深刻的。
好了,我们就不绕弯子了,赶快来看看程序是怎么在内存中分布的吧!
Virtual Memory (VM)——虚拟内存
每当一个程序开始运行,系统内核就会提供一大块能被从头到尾访问的physical memory物理内存
。但是多亏了Virtual Memory虚拟内存
,程序本身认为自己已经拥有访问整个计算机内存空间的能力。你可能在有关硬件的文章中听过虚拟内存
这个概念,尽管那个也叫虚拟内存,
但是实际上我们要讨论的这个虚拟内存
不是那个当物理内存用光了之后才使用的虚拟内存
。我们讨论的虚拟内存
应该由以下的原则确定:
每一个进程拥有的物理内存块叫做这个进程的
虚拟内存
空间。进程不知道自己物理内存的实际结构,只能知道这个被分配给它的内存块的大小和这个块的地址空间是从0开始算的。
- 每一个进程没办法了解到其他进程拥有的
虚拟内存块
。 - 就算一个进程已经知道了其他
虚拟内存块
,但是仍然没有办法访问到那一块内存。
每当一个进程想要对内存进行读写,它都会从VM address虚拟内存地址
转换成physical memory address物理内存地址
。反之,当一个OS操作系统
内核想要访问一个虚拟内存
,就要把实际内存地址
转换为虚拟内存地址
。那么这里就会出现两个问题:
- 计算机要不断的访问内存,这个转换过程必定非常常见,所以这个转换过程就必须非常的迅速。
操作系统
怎么来保证一个进程不会‘踏入’另一个进程的虚拟内存块
中呢?
这两个问题的答案就是实际上操作系统
本身不会去管理每个进程的虚拟内存
,而是通过CPU的帮助。很多CPU拥有一个叫Memory Management Unit(MMU)
的内存管理单元。操作系统
和MMU
会共同管理虚拟内存
、进行物理内存地址
和虚拟内存地址
的转换、决定进程访问内存中某个位置的权限和决定进程在(甚至是本身的)虚拟内存
空间的读写权限
。
过去Linux只能运行在含有MMU
的CPU上(Linux没有办法运行在X286框架的CPU上)。然而在1998年时,Linux装载在了没有MMU
的68000
上。这也是嵌入式Linux和在类似于Palm Pilot
设备上的Linux的一大进步。
练习:
Memory Layout——内存分布
上面讲了虚拟内存
是怎么工作的。对于多数情况,每一个进程的虚拟内存
是按照一个相似的、可预测的方式分布的:
内存地址 | 内存结构 | 说明 |
---|---|---|
高地址 | Args and env vars | 命令行参数和环境变量 |
… | 堆栈(Stack) | |
… | ↓ | |
… | 未使用的内存空间 | |
… | ↑ | |
… | 堆(Heap) | |
… | 没有初始化的数据段(bss) | 运行时初始化为零 |
… | 初始化了的数据段 | 运行时通过程序源文件读取 |
低地址 | 文本段(Text Segment) | 运行时通过程序源文件读取 |
>
- 文本段(Text Segment):文本段包含了真实运行的代码,通常是可以共享的,所以许多情况下程序可以共享文本段来满足较低内存情况下的需求。这个段通常是标记为
只读
的,好让一个程序不能改变自己本身的结构。 - 初始化了的数据段(Initialized Data Segment):这个数据段包含了一开始就被初始化了的全局变量。
- 没有初始化的数据段(Uninitialized Data Segment):以前汇编程序中又称为
bss(block started by symbol)
。这个数据段包含了一系列没有被初始化的全局变量。所有在这里面的变量在执行前都被初始化为0
或者NULL pointers(空指针)
, - 栈(Stack):
栈
是包含了一系列下一部分会讲到的数据帧
,当一个新的数据帧
要被添加到栈里面的时候(比如说当一个构造函数被调用的时候),栈
会向下增长。 - 堆(Heap):大部分动态内存,无论是
C语言
的malloc()
还是C++
的new
之类的操作的变量都会从堆
上被发放到程序中。C语言
的库还会从堆
中获取动态内存来填充personal workspace(自己的工作空间)
。因为大量的内存需要被不断读写,所以整个堆
会往上增长。
对于给定的Object
文件或者executable(可执行)
文件,你都可以查明程序每一个部分的大小(注意我们这里并不是在讨论内存分布而是在讨论一个最终会被执行的硬盘中的文件)。
给定Makefile和hello_world-1.c
:
1 // hello_world-1.c
2
3 #include <stdio.h>
4
5 int main(void)
6 {
7 printf("hello world\n");
8
9 return 0;
10 }
编译这个文件并单独的通过以下命令进行link
:
$ gcc -W -Wall -c hello_world-1.c
$ gcc -o hello_world-1 hello_world-1.o
你可以使用size
命令来列出各个变量数据段的大小:
$ size hello_world-1 hello_world-1.o
text data bss dec hex filename
916 256 4 1176 498 hello_world-1
48 0 0 48 30 hello_world-1.o
数据段data
由已经初始化和未初始化的数据段混合而成。dec
和hex
字段则表示整个文件在二进制
和十六进制
下的大小。
你也可以使用objdump -h
和objdump -x
来得到object
文件各部分的大小:
$ objdump -h hello_world-1.o
hello_world-1.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000023 00000000 00000000 00000034 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 00000000 00000000 00000058 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 00000058 2**2
ALLOC
3 .rodata 0000000d 00000000 00000000 00000058 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .note.GNU-stack 00000000 00000000 00000000 00000065 2**0
CONTENTS, READONLY
5 .comment 0000001b 00000000 00000000 00000065 2**0
CONTENTS, READONLY
练习:
size
命令并没有列出hello_world
或hello_world.o
的堆
和栈
数据段,你觉得这是为什么呢?- 在
hello_world-1.c
文件中并没有定义全局变量。请解释为什么size
命令列出的结果中,object
文件的data段
和bss段
的长度为0,而在可执行文件中却不为0。 size
命令和objdump
命令给出了不同的text segment(文本段)
大小。你能猜到差异从何而来吗?(提示:这个差异有多大呢?看看在源文件中是否有这个大小的东西。)- 选做:读读关于
object
文件格式的相关知识。
Stack Frames And The Stack——数据帧和栈
我们刚刚学习了关于程序的内存分布,在程序的内存分布中有一个被stack frames(数据帧)
填满的数据段叫做stack(栈)
。每一个数据帧
都代表了一个function call(函数调用)
。当一个函数被调用的时候,数据帧
的数量增加然后整个栈空间
就增长了。反过来,当一个函数返回了,数据帧
的数量就减少了然后整个栈空间
就缩小了。这一节中我们会来学习什么是数据帧
,这一节会给出很详细解释,当然也会着重看看我们需要重点了解的部分。
一个程序被或多或少、互相调用的函数组成。每当一个函数被调用,一块内存被搁到一块儿放在一旁,这个内存块就是在栈
里的数据帧
。这一块内存空间有许多至关重要的信息,比如说:
- 为新调用的这个函数的变量提供存储空间。
- 当这个函数返回的时候应该返回的
line number(行号)
。 - 这个函数所调用的参数。
每一个函数调用都会得到一个自己私有的数据帧
。总的来说所有数据帧
组成了一个call stack(调用栈)
。这里提供一个hello_world-2.c来作为例子:
1 #include <stdio.h>
2 void first_function(void);
3 void second_function(int);
4
5 int main(void)
6 {
7 printf("hello world\n");
8 first_function();
9 printf("goodbye goodbye\n");
10
11 return 0;
12 }
13
14
15 void first_function(void)
16 {
17 int imidate = 3;
18 char broiled = 'c';
19 void *where_prohibited = NULL;
20
21 second_function(imidate);
22 imidate = 10;
23 }
24
25
26 void second_function(int a)
27 {
28 int b = a;
29 }
当这个程序开始运行就会有一个数据帧
,这个数据帧
属于主函数main()
。因为主函数并没有变量、参数和返回函数,所以这个数据帧
没有什么观察价值。下面是主函数还没有调用first_function()
的时候的内存分布:
内存分布 |
---|
main() 的数据帧 |
当程序调用first_function()
的时候,未使用的栈空间内存被用来为first_function()
创建一个新的数据帧
。这个数据帧由四个东西组成,分别为一个int
、一个char
、一个void*
和返回函数main()
的所在行号。下面是刚刚调用first_function()
时候的内存分布:
内存分布 |
---|
main() 的数据帧 |
first_function() 的数据帧:【int 】【char 】【void* 】【返回函数行号9 】 |
同样的,当我们调用second_function()
的时候,未使用的栈空间内存被用来为second_function()
创建一个新的数据帧
。这个数据帧
由三个东西组成,分别为:返回函数行号、一个int
、一个int
参数a
。下面是刚刚调用second_function()
时候的内存分布:
内存分布 |
---|
main() 的数据帧 |
first_function() 的数据帧:【int 】【char 】【void* 】【返回函数行号9 】 |
second_function() 的数据帧:【int 】【a 】【返回函数行号22 】 |
当second_function()
返回时,它的数据帧
会查明返回到哪里(first_function()
的22
行),然后返回并且该数据帧
出栈。这是刚刚返回了second_function()
时候的内存分布:
内存分布 |
---|
main() 的数据帧 |
first_function() 的数据帧:【int 】【char 】【void* 】【返回函数行号9 】 |
当first_function()
返回时,它的数据帧
会查明返回到哪里(main()
的9
行),然后返回并且该数据帧
出栈。这是刚刚返回了first_function()
时候的内存分布:
内存分布 |
---|
main() 的数据帧 |
练习
- 假如一个程序调用了5个函数,那么
栈
里面有多少数据帧
呢? - 会不会有可能在
栈
中间的数据帧
返回到了没有使用的内存空间中呢?如果有可能,那么这对于程序来说意味着什么呢? goto()
函数能让一个在栈
中部的数据帧
出栈吗?答案是不能,为什么呢?loogjump()
函数能让一个在栈
中部的数据帧
出栈吗?