每个C程序都由几个不同的内存区域组成,它们分别负责不同类型的数据存储
内存布局
代码段(Text Segment)
- 代码段用于存储程序的机器指令,即程序的执行代码
- 这个部分是只读的,放置程序以外修改指令,也称为只读段
- 在程序执行过程中,它存储着编译后的代码
- 在程序运行期间大小固定
- 可被多个进程共享(如共享库)
数据段(Data Segment)
数据段分为两个部分:已初始化数据段和未初始化数据段
- 已初始化数据段(Initialized Data Segment)
- 用于存储那些在程序中已经显式初始化的全局变量和静态变量
- 在编译时已确定初始值
- 生命周期为整个程序运行期间
- 未初始化数据段(Uninitialized Data Segment/BSS)
- 用于存储未初始化的全局变量和静态变量(未初始化/初始化为0)
- BSS = Block Started by Symbol
- 程序加载时由系统初始化为0
- 节省可执行文件大小(只记录大小信息)
例如
int globalVar = 5; // 存储在已初始化数据段
static int staticVar; // 存储在BSS段,默认为0
堆区(Heap)
- 堆是程序运行时动态分配内存的区域
- 通过函数如
malloc(),calloc(),realloc()进行内存分配 - 内存管理由程序员手动进行,程序员需要显式地使用
free()释放堆内存 - 堆内存的分配和释放不是按顺序进行的,堆的大小是动态变化的,取决于程序的运行需求
- 分配和释放时间不确定
- 从低地址向高地址增长
例如
int *p = (int*)malloc(sizeof(int) * 10); // 在Heap上分配10各int大小的内存
free(p); // 释放内存
栈区(Stack)
- 栈区用于存储局部变量和函数调用信息(如返回地址、参数等)
- 每当一个函数被调用时,栈会分配空间存储局部变量,当函数返回时,栈空间会被释放
- 栈是由操作系统自动管理的,不需要程序员干预
- 栈的大小一般有限,超出时会导致栈溢出
- 从高地址向低地址增长(与堆相反)
- 栈有大小限制,Linux默认约8MB,大数组应使用堆
例如
void func() {
int a = 10; // 存储在栈上
}
内存映射区(Memory Mapped Segment)
- 这个区域用于存储程序中使用的共享库或其他动态加载的文件(如共享对象
*.so) - 它可以通过操作系统的内存映射机制加载文件
保留区(Reserved Space)
操作系统可能会为一些特定用途(如系统调用、保留给操作系统使用等)保留一部分内存空间,通常在程序启动时就已经设定好
内存布局图示
高地址
┌─────────────────┐ 0xFFFF...
│ 内核空间 │ ← 操作系统内核专用
├─────────────────┤
│ 栈顶 │ ← 初始栈指针(ESP/RSP)
│ ↓ 栈增长方向 │
│ ┌─────────────┐│
│ │ 栈区 ││ ← 自动管理,存储局部变量
│ │ ││
│ └─────────────┘│
│ │
│ ...空闲... │
│ │
│ ┌─────────────┐│
│ │ 堆区 ││ ← 手动管理,动态分配
│ │ ↑增长方向 ││
│ └─────────────┘│
│ 堆起始 │ ← brk指针
├─────────────────┤
│ 共享库 │ ← 内存映射区(.so文件等)
├─────────────────┤
│ BSS段 │ ← 未初始化全局/静态变量
├─────────────────┤
│ 数据段 │ ← 已初始化全局/静态变量
├─────────────────┤
│ 代码段 │ ← 程序指令(只读)
└─────────────────┘ 0x0000...
低地址
内存布局调试工具*
使用size命令
size a.out
使用nm命令查看符号表
nm a.out | sort
使用objdump查看详细段信息
objdump -h a.out
内存布局的产生
程序并不是天生就知道如何分配内存的
C语言本身也没有内存管理机制,它只是提供了访问内存的能力(如指针和直接操作内存)。内存管理是通过操作系统和运行时库来实现的
操作系统通过分配和管理内存(例如通过系统调用、堆管理等),使得程序能够以一种抽象的方式使用内存
这种内存布局由三个方面共同决定
- 操作系统
- 编译器
- 程序员
操作系统的内存管理
OS在程序启动时负责分配内存给程序。具体来说,操作系统为每个运行的程序创建一个进程,每个进程拥有独立的虚拟地址空间,操作系统会将该虚拟地址空间分为不同的区域,如栈区、堆区、数据段等
- 操作系统会为每个进程分配一个固定的栈空间和堆空间大小
- 操作系统负责维护这些内存区域的边界,并确保程序只能访问分配给它的内存区域
- 如果程序试图访问栈以外的区域(例如栈溢出),操作系统会通过触发异常来防止访问非法内存
编译器的作用
编译器将源代码编译成机器代码时,会对程序的数据、代码进行整理,并为它们指定具体的内存布局。例如
- 代码段:编译器会将所有的函数代码(包括程序入口)放入代码段中
- 数据段:全局变量和静态变量在程序编译时就会被标记,并在数据段分配内存。编译器会决定它们的内存位置
- 堆与栈:栈的大小一般由编译器和操作系统的默认值决定,编译器会为每个函数调用生成栈帧布局,并确定参数传递、局部变量存储方式;而堆是动态分配的,编译器本身并不负责堆的管理。堆的分配和释放通常通过
malloc,free等函数来控制
编译器通常不直接处理堆的内存分配,但它会生成调用这些内存管理函数(如malloc)的代码。堆的内存分配是在运行时由程序控制的
程序员的角色
尽管操作系统和编译器负责大部分内存布局,但程序员在编写代码时会决定哪些数据存储在哪里。程序员可以显式指定
- 局部变量:存储在栈区
- 动态分配的内存:通过
malloc等函数分配,存储在堆区 - 全局变量:存储在数据段中
- 静态变量:也存储在数据段或BSS中
在很多情况下,程序员必须手动管理内存(特别是堆内存)。例如,当程序员使用malloc为数据分配内存时,操作系统通过系统调用将内存分配给程序,但程序员必须负责释放这块内存(通过free函数)
操作系统的内存分配机制
操作系统和C运行时使用多种算法管理堆内存
| 算法 | 原理 | 特点 |
|---|---|---|
| 首次适应 | 使用第一个足够大的空闲块 | 速度快,可能产生碎片 |
| 最佳适应 | 使用大小最接近的空闲块 | 减少浪费,增加搜索时间 |
| 最差适应 | 使用最大的空闲块 | 减少小碎片 |
| 伙伴系统 | 按2的幂次分配块 | 减少外部碎片,可能浪费空间 |
内存保护机制
内存保护机制是操作系统和硬件共同实现的一种技术,用于保证每个进程只能访问自己被授权访问的内存区域,从而防止非法的内存访问,确保程序和系统的安全性和稳定性
内存保护机制的主要目标
- 隔离不同进程的内存空间:防止进程间的内存泄漏或恶意篡改
- 防止程序访问不该访问的内存:避免程序因错误访问不属于它的内存区域而引起的崩溃或数据损坏
- 提高系统的稳定性:通过有效的内存访问控制,系统能更好地处理内存错误,降低系统崩溃的风险
内存保护的核心技术
虚拟内存
虚拟内存是现代操作系统内存管理的重要组成部分。操作系统将物理内存(RAM)抽象为虚拟地址空间,每个进程都拥有自己的独立虚拟地址空间,这样可以防止不同进程之间互相干扰。虚拟内存的工作原理包括
- 地址空间隔离:每个进程都拥有一个独立的虚拟地址空间,这些地址空间由操作系统的内存管理单元(MMU)进行映射到物理内存中。一个进程无法直接访问另一个进程的内存空间
- 页表和分页机制:操作系统通过页表将虚拟地址映射到物理地址。虚拟内存的分页机制可以将程序需要的内存页面加载到物理内存中,而无需全部加载,且当内存不再需要某些页面时,它们可以被交换到磁盘上,从而节省物理内存
内存访问权限
操作系统和硬件通过内存保护器(如x86架构的保护模式寄存器)来限制对内存的访问,分配不同的权限给程序的各个部分。常见的内存访问权限包括
- 可读(Read):内存区域可以被程序读取
- 可写(Write):内存区域可以被程序写入
- 可执行(Execute):内存区域中的代码可以被执行
- 不可访问(No Access):禁止访问内存区域
这些权限是通过内存保护单元(MPU)或内存管理单元(MMU)来管理的,当程序试图访问没有权限的内存时,操作系统会触发异常,通常导致程序崩溃或被终止
页面保护
在虚拟内存系统中,操作系统通过页表为每个内存页面设置访问权限。例如,某些区域(如堆和栈)可能会有写权限,但无法执行;代码段则会有执行权限,但无法写入。页面保护的常见类型有
- 只读:如代码段,防止程序意外或恶意修改指令
- 可执行:确保数据段不被执行,放置数据注入攻击(如缓冲区溢出攻击)
- 不可写:例如,操作系统通常会将某些内存区域设置为不可写,以防止修改系统关键数据
栈保护(Stack Protection)
栈保护技术用于防止栈溢出攻击。这些攻击利用程序的栈区溢出覆盖返回地址,从而执行恶意代码。现代操作系统和编译器通过以下方式防止栈溢出
- 栈的方向控制:在x86架构中,栈通常从高地址向低地址增长,堆则相反。操作系统可以通过栈溢出的检测机制来阻止栈溢出覆盖堆的区域
- 栈保护机制:编译器(如GCC)使用栈保护技术(如
canary)来防止栈溢出攻击。在函数调用过程中,会在栈上插入一个“哨兵值”,如果栈溢出覆盖了这个值,程序会检测到并终止执行 - 不可执行栈:操作系统可以通过设置栈区为不可执行,阻止恶意代码在栈上运行
地址空间布局随机化(ASLR)
地址空间布局随机化(ASLR, Address Space Layout Randomization)是操作系统用于增强安全性的一项技术,它通过随机化程序内存的加载位置,使得攻击者无法预测内存地址。ASLR的应用
- 程序地址随机化:堆、栈、共享库的加载位置都会随机化,增加攻击者的攻击难度
- 动态链接库(DLL)随机化:共享库(如
.so,.dll文件)的加载位置也是随机的,进一步增强了安全性
内存映射保护
内存映射区(例如共享库或动态加载的文件)也需要内存保护。操作系统会为这些区域设置只读或只写的权限,防止程序修改共享库中的代码。这对于防止DLL注入攻击至关重要
内存布局的操作系统实现
不同操作系统(如Linux, Windows等)可能有不同的内存管理策略。例如,Linux使用brk和mmap系统调用来分配堆内存,而Windows则通过堆管理器(Heap Manager)管理堆内存
Linux内存管理
在Linux中,内存管理机制较为灵活,支持多种内存分配策略。对于堆内存的管理,主要依赖于两种系统调用:brk和mmap
brk和sbrk系统调用
brk:是传统的堆内存分配方式。它通过增加或减少进程的数据段的结束位置(也就是heap的终点)来扩展或收缩堆的大小。具体而言,brk调用会修改程序的内存映射,从而直接操作程序的堆区域sbrk:是brk的变种,提供了一个增量式的堆扩展方式。sbrk允许你通过指定一个偏移量来调整堆的大小
这两种方法的优势在与它们直接操作进程的数据段,且速度较快。然而,它们有一些限制
- 空间分配问题:由于堆区空间是线性增长的,内存的碎片化问题较为严重,尤其是在多次分配和释放内存后,容易出现无法找到合适大小的内存块的情况
- 灵活性较差:
brk和sbrk的分配方式较为简单,无法应对更复杂的内存需求
mmap系统调用
mmap:是一种更现代的内存分配方式,除了用于内存映射文件外,还可以用来分配堆内存。mmap将请求的内存区段映射到进程的虚拟地址空间中,可以用于大块内存的分配。与brk不同,mmap不仅分配堆内存,还能为每个内存区域提供更多的控制,例如页面对齐、访问权限等- 优点
- 灵活性:
mmap可以为不同大小的内存区域分配不同的内存块,并支持更灵活的内存保护和访问控制 - 减少碎片:由于
mmap分配的是独立的内存块,因此减少了堆内存碎片问题 - 按需加载:
mmap支持懒加载,即内存页面只有在实际使用时才会映射到物理内存
- 灵活性:
- 使用场景
mmap通常用于大内存的分配,尤其是在需要大量动态内存或进行内存映射文件的情况下- 对于多线程程序,
mmap也可以用于为每个线程分配独立的堆内存
Windows内存管理
在Windows中,内存管理更加依赖于操作系统提供的堆管理器(Heap Manager),它为每个进程提供了一套完整的堆管理功能。Windows使用堆管理器为进程提供堆的分配和释放
Windows堆管理器
- 堆的概念:Windows的堆是一个由操作系统提供的内存区域,程序可以通过
HeapAlloc()等API来分配内存。每个进程可以有多个堆,操作系统会为每个堆分配一个控制结构,并在该堆中管理内存的分配和释放 - 堆的类型
- 私人堆(Private Heap):由程序自己创建并管理的堆,通常用于管理程序内部的数据结构
- 共享堆(Shared Heap):由多个进程共享的堆,通常用于进程间的通信(IPC)
堆分配与释放
- 堆分配:Windows通过
HeapAlloc()来为堆分配内存。该函数的实现通过堆管理器根据需求从可用内存区域中选择合适的内存块- 堆块的管理:操作系统使用链表、红黑树等数据结构来跟踪堆中的内存块。分配时,操作系统会找到何时的空闲块进行分配,分配完成后,系统会更新堆的管理结构
- 堆释放:当程序不再需要某块内存时,可以通过
HeapFree()来释放内存。堆管理器会将该内存标记为可用块,等待后续的内存分配
内存池机制(Memory Pool)
- 内存池:Windows堆管理器内部使用来内存池机制,通常会将堆内存分为小块的池,每个池处理相同大小的内存块。这可以加速内存的分配和释放,减少内存碎片
- 内存池的类型:Windows的堆管理器通常会为每种常用大小的内存分配一个内存池,并且将这些池结构存储在堆的控制区域。通过内存池机制,堆管理器能够快速提供合适大小的内存块
堆扩展与收缩
- 扩展堆:当堆内存不足时,Windows堆管理器会通过操作系统的内存管理模块来动态扩展堆的大小。这通常是通过
VirtualAlloc()等系统调用完成的,堆管理器会在进程的虚拟内存中增加新的内存页 - 收缩堆:当程序释放了大量内存后,堆可能会村子啊一些空闲区域。操作系统可以通过回收这些内存区域来收缩堆的大小,从而节省内存资源
| 特性 | Linux | Windows |
|---|---|---|
| 堆内存分配方式 | brk(传统) + mmap(现代) | 堆管理器(Heap Manager) |
| 堆扩展 | brk修改数据段结束位置,mmap动态映射内存 | 动态扩展堆,使用VirtualAlloc()扩展 |
| 内存管理机制 | 内存管理通过页表、MMU进行访问控制 | 通过堆管理器管理多个堆,并使用内存池加速分配 |
| 堆内存分配函数 | malloc(), free() | HeapAlloc(), HeapFree() |
| 内存池机制 | 无 | 使用内存池减少碎片和提高效率 |
| 分配与释放效率 | mmap适用于大块内存,brk适用于小块内存 | 堆管理器优化了内存分配,特别是小块内存 |
| 操作系统控制 | 通过系统调用(brk, mmap)管理内存 | 通过堆管理器自动管理内存,提供更高级的控制 |
实际内存分配实例
#include <stdio.h>
#include <stdlib.h>
int global_init = 10; // data segment
int global_uninit; // BSS
static int static_var = 20; // data segment
int main() {
int local_stack = 30; // stack
static int static_local; // BSS, init 0
int *heap_ptr = (int*)malloc(sizeof(int) * 100); // heap
printf("Text Segment: main function address = %p\n", main);
printf("Data Segment: global_init = %p, %d\n", &global_init, global_init);
printf("BSS: global_uninit = %p, %d\n", &global_uninit, global_uninit);
printf("Stack: local_stack = %p, %d\n", &local_stack, local_stack);
printf("Heap: heap_ptr = %p\n", heap_ptr);
free(heap_ptr);
return 0;
}
溢出
- 栈溢出(Stack Overflow)
- 堆溢出(Heap Overflow)