文章翻译——使用 GNU 的 GDB调试器,内存布局和栈——01
使用 GNU 的 GDB调试器,内存布局和栈
原文地址:http://www.dirac.org/linux/gdb/02a-Memory_Layout_And_The_Stack.php
我们会学些什么?
为了更高效的学习使用GDB,你必须了解帧,通常也成为栈帧,因为帧构成了栈。为了学习栈,我们需要了解可执行文件的内存布局。这里的讨论主要都是理论上的,但是为了使学习比较有趣,在本章结束之前我们将通过GDB来展现栈和栈帧的例子。
本章学习的东西似乎相当的理论化,但是对于达到以下目的来说却是非常有用的:
1. 理解栈对于使用GDB调试器是绝对有必要的
2. 了解一个进程的内存布局有助于我们理解什么是段错误和为什么会发生段错误(或者为什么有时候该发生段错误的时候却没有发生)。简略的说,段错误是一个程序崩溃最普通和最直接的原因。
3. 拥有程序内存空间的只是通常使得我们能够定位出隐藏得很好的程序错误,而不用使用print之类的打印语句,有时候甚至是编译器或者GDB的错误也能定位。下一部分是由我们的一个朋友Mark Kim写的,我们将能看到一些Sherlock Holmes式的侦探式的分析。Mark在一个很长的代码当中遇到了一个很不容易发现的问题。他运用所了解的关于程序内存空间的知识,只用了5到10分钟来看程序,就解决的问题。真是非常厉害!
多的就不说了,我们先来看看程序的内存布局。
虚拟内存:
当创建了一个进程后,内核会为它分配一块物理内存,这块物理内存可以在任何位置。然而,虚拟内存很神奇,使得一个进程认为自己独占了整个计算机内存空间。你也许听说过虚拟内存,那是在在内存被耗尽后,使用硬盘来提供内存空间的时候。这也叫做虚拟内存,但是这与我们这里谈到的虚拟内存无关。我们主要关注虚拟内存的以下几点:
- 每个进程分配的物理内存我们称为进程的虚拟内存空间。
- 进程不知道物理内存的细节(比如:物理内存的位置)。所有进程只知道内存块的大小和内存块的起始地址是地址0。
- 每个进程都不用知道其他进程的虚拟内存。
- 即使一个进程知道其他虚拟内存块,自己也会去访问那块内存块。
当进程想要读写内存的时候,该进程的操作请求必须从虚拟内存地址转换为物理内存地址。同样,当内核需要访问一个进程的虚拟内存的时候,内核必须把物理地址转换为虚拟地址。这个过程主要涉及两个问题:
- 计算机经常访问内存,使得地址访问工作非常普遍;因此必须很快完成。
- 操作系统如何能够保证一个进程不会侵犯到其他进程的虚拟内存。
这两个问题的答案都在于操作系统自己并不会亲自管理虚拟内存;操作系统从CPU那里获得帮助。很多CPU都拥有一个被称为内存管理单元(MMU)的设备。MMU和操作系统一起负责管理——虚拟内存,虚拟内存和物理内存之间的转换,授权每个进程可以访问的内存区域,授权在虚拟内存的段上的读写操作。
Linux只能安装在拥有MMU的计算机上(因此不能安装在x286上)。然而,在1998年,Linux被安装在了MC68000上,MC68000并没有MMU。这使得Linux能够走向嵌入式领域,比如Linux可以安装到Palm Pilot上。
练习
内存布局
这就是虚拟内存工作的方式。 在大多数情况下,每个进程的虚拟内存空间的布局都是类似并且是可预知的:
代码段:代码段包含了实际可执行代码。代码段通常是可共用的,因此多个程序可以共享代码段,这样可以降低内存消耗。代码段通常是只读的,因此一个程序不能修改其中的指令。
初始化了的数据段:这个段包含了由程序员初始化的全局变量
未初始化的数据段:也称为“bss” 段,这是以前编译器使用过的一个操作符。这个段包含了未初始化的全局变量。这个段中的所有的变量在程序执行之前都被初始化为0或者NULL 指针。
栈:栈是一系列栈帧的集合,将会在下一节中讲到。当需要增加一个帧(一个函数调用的结果),栈就会向下增长。
堆:大部分动态分配内存,无论是通过C语言的malloc分配还是通过C++的new分配,都是从堆上分配的。C语言库也是从堆上获得动态内存的。因为有更多的内存在运行时需要被分配,堆是向上增长的。
一个.o文件或者可执行文件,你能够确定其中每个段的大小(我们并不是在谈论内存布局,我们在讲的是一个磁盘文件最终会进入到内存中)。 看下面的hello_world-1.c 和Makefile:
// hello_world-1.c
#include <stdio.h>
int main(void)
{
printf("hello world\n");
return 0;
}
通过下面命令进行编译和链接:
$ gcc –W –Wall –c hello_world-1.c
$ gcc –o hello_world-1 helloworld-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 分别以10进制和16进制表示文件大小
也可以通过 “objdum -h”和 “objdum -x”来查看.o文件各段的大小:
$ 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_word或者 hello_wordl.o并没有列出栈或者堆所在的段,你怎么看?
- 在hello_world-1.c中没有全局变量,请解释为什么执行size命令的结果显示data和bss段对于.o文件来说长度为0,对于可执行文件来说长度不为0。
- 使用size和objdump 查看代码段的大小不同。你能猜猜其中的差别来自哪里吗?提示:这个差异有多大?看看源代码中任何这个长度的东西。
- 选作:阅读.o文件的格式link。
栈帧和栈
刚刚了解了一个进程的内存布局。内存布局的这样一个段叫做栈,这是一系列栈帧的集合。每个栈帧代表了一个函数调用。当一个函数被调用后,栈帧的数量就会增加,栈的大小增长。同样,当函数调用返回,栈帧的数量会减少,栈的大小会收缩。在这一部分,我们将学习什么是栈帧。更加详细的解释可以参见这里here,但我们还是有针对性的讨论。
一个程序是由相互调用的一个或多个函数构成。每次函数调用都会分配一定区域的内存块,该内存被叫做函数调用的栈帧。这块内存区域保存了一些重要的信息,比如:
- 新近调用的函数的所有本地变量的存储空间
- 函数调用结束后的返回地址
- 被调用函数的变量和参数
每个函数调用都会获得自己的栈帧。总的来说,所有栈帧构成了栈调用。下一个例子我们将使用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()的栈帧。由于main()函数没有局部变量,
没有参数,不会返回到其他函数,我们将不关心它的栈帧。下面是main()函数在调用first_function的之前的栈:
当调用第一个函数first_function(),未使用的栈内存被用来为first_function创建一个栈帧。栈帧保存了:一个 int ,一个 char,一个 void *,和返回语句。下面是在调用second_function之前的栈:
当调用second_function之后,形成了second_function的栈帧。该栈帧包含了:一个 int 型的数据,一个second_function 内的当前执行地址。当second_function返回后的栈:
当second_function返回后,它的栈帧决定了返回的位置(first_function的第22行),释放帧返回到栈。下面是second_function返回之后的栈:
当first_function返回后,其帧决定了返回的位置(main()的第九行),释放帧返回到栈。下面是first_function返回后的栈:
练习:
- 一个拥有5个函数调用的程序,栈上会有多少帧?
- 我们注意到栈线性向下增长,当一个函数返回后,栈上的最后一帧被释放并返回到没有使用过的内存。对于栈中间的某一个帧来说有没有可能返回到未使用的内存?如果可以,对于正在执行的程序来说意味着什么?
- 一个goto语句能造成栈中间的帧被释放吗?答案是不会,为什么呢?
- 一个longjmp()语句会造成栈中的帧被释放吗?