写在内存布局之前
一个c语言编写的程序,是由一行行代码组成的文件,一般称为“源文件”。这些代码里面,包含有常量、变量,以及对这些数据进行处理的指令,比如输入指令、输出指令、数据复制指令、算术运算指令等等。
考虑到阅读这篇文章时读者,可能每个人的程序能力存在不同,简单解释一下,有助于理解后面的内容。比如下面这段非常简单的代码:
#include
#include
#define REAL 1
int global ;
static int file = 2 ;
const int flawed = 3;
int* func(int);
int main(){
global = 4;
int x = 5,y = 6;
int z = file + x;
int *p = func(8);
p[0] = x;
p[1] = y;
p[2] = z;
for(int i=0;i<3;i++)
printf("%d\n",p[i]);
free(p);
p = NULL;
return 0;
}
int* func(int n){
int *p = (int*)malloc(sizeof(int)*n);
return p;
}
这段程序非常简单,但是包含了我们后面所需要的很多情况。比如,程序中有常量REAL,未初始化的全局变量global、初始化的文件静态变量file、只读变量flawed。
main函数的局部变量x、y、p,func函数有局部变量p,函数参数n也作为函数的局部变量对待。
除了这些不同种类的数据外,还有指令部分。比如给变量初始化:static int file = 2,给变量赋值: global = 4,两个变量的加法运算 file + x,循环、分配动态内存、释放动态内存等。
源文件是给人看的,所以清晰易懂。但是操作系统是不认识的,它无法执行这一行行的代码。必须要将这些代码翻译成它能懂的语言:由0和1组成的二级制机器码,它才能正确执行这些代码,并给出我们想要的结果。
所以,我们通常会将编写好的源程序,比如foo.c文件,让C语言编译器将这份源文件编译成操作系统能“看懂”的可执行文件。一般要经过预处理、编译、汇编、链接四个阶段,一个foo.c才能变成foo可执行文件。
那么,这个可执行文件到底由哪些内容组成的呢?一般来说,编译器会对这个foo.c源文件进行分析。
代码段
通过分析,它会将所有的操作指令放置在一起,这样操作系统执行这个程序时,就可以顺序执行这些指令,通常是从main函数的第一条指令开始依次执行。
这些指令所在的区域,我们通常称为代码段,或文本段。即code segment,或 text segment。一般写为 .code 或 .text 表示。
代码段是只读的,因为存储的是指令,也不需要修改,因此程序加载到内存中时,代码段会被操作系统保护起来。
只读段
接下来,在代码段后面,编译器会将所有的立即数、常量、用static修饰的const变量、或者全局的const变量,都存在在一起,这个区域和代码段一样,也是只读的。
立即数就是字面量,也就是具体的数字,比如2,3这样。int x = 3;这里的3就是立即数,或者说是字面量。
而用预处理指令#define定义的常量,我们一般称为符号常量或宏常量。而枚举类型中的常量,一般称为枚举常量。而用枚举类型定义的变量,称为枚举变量。枚举变量是变量,但是给它赋值的确实常量,所以枚举类型是一种特殊的类型,此处不做剖析。
而const变量则比较特殊。如果它是全局的const变量,或者用static修饰的局部const变量,都是真正的只读变量。这两种const变量会和常量、立即数一起被放入到这个只读段中。
如果只是局部的econst变量,则不能算是真正的只读变量,后面会详细的解释,所以不放入到这个区域中。
我们一般将这个区域称为“只读段”,即 .rodata segment。ro是readonly的简写,rodata就是只读数据的意思了。一般写为 .rodata 。
不同的操作系统,不同的文件系统,可能会有一些差别。
比如有的编译器会将立即数直接嵌入到指令中,放入到代码段。有的编译器会在代码段的指令区域后面,开辟一段区域,存放常量、立即数。有的编译器,在代码段只存放指令,在代码段后面用.rodata存放常量、字面量等等。
总之,无论哪种情况都不重要,我们只要知道,常量、字面量(立即数)、全局的const变量、static修饰的const变量,这些我们不期望被修改的数据,所在的区域都是只读的,假定都是在.rodata段即可。
数据段
接下来,编译器会将所有的初始化了的全局变量放到这个区域,比如上文代码中的global变量,虽然是全局变量,但是因为没有初始化,所以它就不会被放在这个区域。
所有初始化了的静态变量,也会被存放在这个区域。比如上文代码中的file变量是初始化了的静态全局变量,就会被放在这个位置。
这个区域我们一般称为数据段。数据段,即data segment,一般写为 .data 。
数据段的所有数据都是可以被修改的,可读写的。
BSS段
编译器会把所有未被初始化的全局变量和静态变量,统一存放在这个位置。
我们一般把这个区域称为BSS段。BSS段,即Block Started by Symbol segment。一般写为 .bss 。
即使BSS存放的是未被初始化的数据,但当程序被加载执行时,BSS段未初始化的数据会被自动清零(也就是用0初始化)。
虽然上一个区域被称为数据段,这个区域被称为BSS段,但实际上,这两个区域都是“数据段”的范畴。因为这两个区域存放的数据具有相同的性质。
首先,未被初始化的数据在在程序时会自动清零,相当于初始化了。
其次,这两个段存放的都是全局变量和静态变量。全局变量,是从程序开始执行,就存在的,一直持续到程序结束,可以在程序的任何位置,甚至不同文件之间都可以访问这个变量。
而静态变量则是从创建那一刻,一直存活到程序结束,但是它只能被它同一个作用域内部的变量,或者它所在作用域包含的内层作用域内的变量所访问到。
数据段和BSS段的位置一般是和代码段一样固定不变的。
从这个角度来看,数据段和BSS段起始存放的都是同一情况的数据,并且这两个区域内的数据都是可修改的。
所以我们有时候把BSS段和数据段都作为数据段对待。
实际上,除了代码段和只读段不允许修改外,后面的“段”(segment)都必须是可读写的状态。
堆区
在BSS段之后,编译器会将程序中所有需要通过动态内存分配函数创建的数据,无论是否初始化,都会放放在这个区域。
我们一般把这个区域称为“堆”,或堆区。堆的英文是 heap, 一般表示为 heap。
堆区的大小和位置一般会动态改变的。而且堆区内的变量使用完毕后,一定要由程序员手动的通过free函数来释放掉。
不及时释放的话,堆区内变量所占用的内存空间,将无法被其他变量所使用,而它被使用完毕之后又不再被使用,这块内存空间,将会称为“内存碎片”,随者这样的内存碎片越来越多,将会出现内存泄漏。
栈区
紧接着,我们会把所有函数(包括main函数)内部的局部变量,只要是未用static修饰的,无论是否初始化,都存放在堆区后面的e同一个区域,这e个区域我们称为“栈”,或栈区。(Stack,或者Stack Segment)。
这个栈段,就是数据结构中的栈,具备栈的后进先出的特性。
这些数据中,有一些特别的数据,比如函数的参数,当这个参数是值类型传入时,就会生成一个函数外数据的副本,作为函数的局部变量对待,按照访问数据的先后顺序存放在栈上。
当这个函数参数是指针类型时,其指向的数据按照其自身特性,在对应的区域内存放。比如其指向的是static变量,就存放在数据段。如果指向的是全局只读变量,就存放在只读区域,等等。
而指针变量如果只是普通的变量,则也会生成一份副本,作为函数的局部变量存放在栈上对应的位置(按入栈的顺序),如果是全局变量,或静态变量,则存放在数据段,如果是只读指针(类似 int * const ptr),则会被保存在只读区域。
另外,对于const修饰的变量,情况稍微稍微有点复杂。
如果变量只被const修饰,且是局部变量(在任何一个函数体内,包括main函数),那么它将被分配在栈上,在代码编辑阶段,试图直接修改它时,会被编译器阻止(这是const的语法层面的作用)。
但是,如果通过其他方式,仍然可以改变const变量的值。因为const在c语言中,并不像c++一样,把const变量完全作为常量对待,放在只读区域中。
它所在的区域仍然是可读写的,只是不能直接对该变量进行修改,这是语法方面的限制,但是在其他情形下,只能是语义方面的作用了,所谓“语义”,就是告诉你“const变量不能修改它”,但是你非要修改它,编译器也拦不住你,注意,这是除了直接修改它之外的情形。
再次强调,直接修改它,是语法层面的约束。举例如下:
#include "stdio.h"
int main(){
const int x = 520;
printf("const变量原始值为:%d\n",x);
//1.无法直接修改const变量:
// x = 502;//error
//2.通过数组越界修改const变量:
int array[1];
array[1] = 502;
printf("数据越界修改为:%d\n",x);
//3.通过指针修改const变量:
int *p = &x;
*p = 250;
printf("指针方式修改为:%d\n",x);
return 0;
}
下面是运行结果的截图。const变量x的初始值是520,结果硬是被改成了502和250。
注意,这是在CLion2023,c11,clang中编译,且必须是c后缀的源文件。如果用vs2022,c11,MSVC编译器中,就会有更严格检查,同样的代码,不一样的编译结果,如图所示:
刚才说到,const的局部变量放在栈中,栈是可读写的,没有专门的只读区域保护const变量,所以存在风险,(规避的办法在我的其他文章中有讲到)。
如果const变量,再修饰成static属性,即static const 属性,(这样做的目的是整个程序运行期间,无论函数被调用多少次,都只初始化一次,并且其值是恒定的)。那么这个变量将被放在.rodata段。
注意,这时候它的作用域虽然是局部变量,但是生命周期却是持续到持续结束。局部变量强调的是作用域,全局强调的是不仅是作用域,还有持续的生命周期。
如果这个const变量是全局数据,和静态的const变量一样,都被存放到.rodata段中。
这时候,顺带说一下,为什么我们一般会对全局的const变量,用static修饰。
如果不用static修饰const全局变量,那么同一个项目的其它源文件也可以访问它。如果我们只想让这个全局的const变量只在当前文件内可见,就需要用static修饰。
如果你对static的用法还不是太清楚,可以看我专栏的其他文章,有非常详细的讲述作用域、生命周期的文章。
总结
讲到这儿,到目前为止,编译器把程序中的所有指令,以及不同特性的数据,都做了处理。按照先后顺序做了不同区域的划分和存放。
我们稍微总结下:为了让操作系统能够“识别”我们的程序,编译器会将程序的源文件编译成几个“段”,每个段存放的内容,都有各自的特点。
因为不同的操作系统,不同的文件系统会有点差别,我们在这篇文章中,只考虑通用的情况。
一般的,程序的可执行文件,会被划分成代码段、只读段、数据段、堆、栈。即.text(.code)、.rodata、.data、.bss、.heap、.stack。
代码段一般按指令的执行顺序,依次存放所有的指令语句。
只读段,会将所有的常量、全局的只读变量、静态的只读变量存放到这儿,和代码段一样不允许修改。
数据段存放所有的可以修改的且被初始化的全局变量、静态变量。
BSS段,存放所有未被初始化的全局变量、静态变量,程序执行时被自动初始化为0。
堆区和栈区的大小和位置是容易变动,堆区是动态内存配的,栈区具备后进先出的特性。
目前为止,所谓的内存模型,或者内存分布,都只是在讲解可执行文件的结构。
编译器将程序中的指令和数据,按先后顺序,划分好不同的“段”后,存放在可执行文件中。而程序被执行时,可执行文件中的每条指令和数据是要被加载到真实内存中。如果被加载到真实内存中,每个数据就要有具体的内存地址来标明其存储位置,不然指令如何读取和修改所需要的数据呢?
但是可执行文件毕竟只是文件,在没被读取到真实内存中,是没有实际的内存地址来对应数据的存放位置的。
因为编译器就将可执行文件假设为一个“虚拟的内存”,内存起始地址为0,从代码段开始,由低向高,栈段处于最高地址区域,每个指令和数据,都有一个虚拟的内存地址。数据和指令之间,是靠相对地址进行访问的。比如一条指令的虚拟地址(标准叫法是逻辑地址,到那时我是为了大家更好懂,就不考虑严谨)是100,它要访问的数据的地址是157,那么该指令只要通过偏移量57,就可以访问到该数据了。
为了不深入展开,我们就认为将可执行文件中的所有段内的所有指令和数据,通过一个完整的“虚拟内存空间”,使得互相之间,都可以通过相对地址(偏移量)访问所需要的数据即可。
下一篇,我们将详细解剖,可执行文件的内存布局,被加载到真实内存中后,如何和真是内存是如何对应的,如何能让我们的指令正确执行,并正确访问到对应的数据地址的。
段誉,2024年2月3日,写于合肥。