3. 内存分配机制

好,我们进入第三章。这一章我打算聊聊进程和内存的关系。说白了,一个程序跑起来之后,它在内存里到底长什么样?为什么两个程序同时跑,不会互相干扰?这些问题搞不清楚,后面谈优化就是空中楼阁。

我记得刚入行那会儿,写了个C程序,里面有个全局变量和局部变量名字一样,结果调试了一整天。后来才明白,它们根本不在同一个内存区域。嗯,这就是我们今天要讲的核心——进程的内存布局。

3.1 进程的内存布局

一个进程在内存中,通常被划分为四个主要区域:代码段、数据段、堆和栈。每个区域各司其职,互不干扰。

核心概念:进程的内存布局是操作系统为每个进程分配的虚拟地址空间结构。它决定了代码、数据、动态内存和函数调用的存放位置。

3.1.1 代码段(Text Segment)

代码段存放的是程序的机器指令。它是只读的,防止程序在运行时意外修改自己的指令。我见过一些新手试图通过指针修改代码段里的内容,结果直接段错误——嗯,操作系统可不会让你这么干。

  • 特点:只读、可共享(多个进程可以共享同一份代码段,节省内存)
  • 存放内容:编译后的二进制指令
  • 地址范围:通常在虚拟地址空间的低地址区域

3.1.2 数据段(Data Segment)

数据段又分为两个部分:已初始化数据段和未初始化数据段(BSS段)。

类型 存放内容 生命周期 示例
已初始化数据段 全局变量、静态变量(有初始值) 程序启动到结束 int global = 42;
BSS段 全局变量、静态变量(未初始化或初始化为0) 程序启动到结束 static int count;

小技巧:BSS段在可执行文件中不占用磁盘空间,只在加载时分配内存。所以如果你有很多初始化为0的全局变量,它们不会让你的可执行文件变大。

3.1.3 堆(Heap)

堆是用于动态内存分配的区域。你在C语言里用malloc,在C++里用new,在Java里用new,分配的内存都来自堆。

堆的特点:

  • 大小不固定,可以动态增长(向高地址方向增长)
  • 需要手动管理(C/C++)或由垃圾回收器管理(Java/Go)
  • 分配速度相对较慢,因为需要查找空闲内存块

我个人习惯在嵌入式开发中尽量少用堆,因为堆碎片是个很头疼的问题。我曾经在一个长期运行的服务里遇到内存泄漏,查了三天才发现是某个模块每次请求都malloc但不free。嗯,从那以后我写代码都会格外注意配对。

3.1.4 栈(Stack)

栈用于存放函数调用时的局部变量、函数参数和返回地址。它的特点是后进先出(LIFO),向低地址方向增长。

栈的特点:

  • 自动管理:函数进入时分配,返回时释放
  • 分配速度极快:只需要移动栈指针
  • 大小有限:通常只有几MB(Linux默认8MB)

避坑指南:我曾经在递归函数里定义了一个大数组(比如 char buf[1024*1024]),结果递归深度一深,直接栈溢出。记住:栈空间很宝贵,大块数据请放到堆上。

3.2 进程的虚拟地址空间

你想想看,如果每个程序都直接操作物理内存,那会是什么场景?程序A写了地址0x1000,程序B也写了地址0x1000,不就乱套了吗?

为了解决这个问题,操作系统引入了虚拟地址空间。每个进程都以为自己独占了整个内存空间(比如32位系统下是4GB),但实际上这些虚拟地址会被映射到不同的物理地址上。

下面这张图展示了典型的进程虚拟地址空间布局:

进程虚拟地址空间布局 内核空间(Kernel Space) 用户代码不可直接访问,用于系统调用、驱动等 栈(Stack) 局部变量、函数参数、返回地址(向低地址增长) 堆(Heap) 动态分配的内存(向高地址增长) 数据段(Data Segment) 全局变量、静态变量(已初始化 + BSS) 代码段(Text Segment) 只读的机器指令 高地址 → 低地址 说明:栈和堆相向生长,中间是空闲区域。如果两者相遇,就会发生栈溢出或堆溢出。 32位系统下,用户空间通常为3GB,内核空间为1GB。

虚拟地址空间的好处很明显:

  • 隔离性:每个进程的虚拟地址互不干扰
  • 安全性:进程不能随意访问其他进程的内存
  • 灵活性:物理内存可以碎片化,但虚拟地址空间是连续的

3.3 进程间内存隔离

进程间内存隔离是操作系统安全性的基石。说白了,就是进程A不能直接读写进程B的内存。这是怎么做到的呢?

核心机制是页表(Page Table)。每个进程都有自己的页表,记录了虚拟地址到物理地址的映射关系。CPU在访问内存时,会通过MMU(内存管理单元)查询页表,把虚拟地址转换成物理地址。

关键点:进程A的页表里,根本不存在进程B的物理页映射。所以进程A即使猜到了进程B的虚拟地址,也无法访问到它的物理内存。

我曾经在做一个多进程系统时,遇到过一个问题:进程A通过共享内存和进程B通信,但忘记设置权限,结果进程B读到了脏数据。嗯,这就是隔离没做好。后来我加上了shmget的权限控制,问题就解决了。

进程间通信(IPC)的方式有很多,比如:

  • 管道(Pipe)
  • 消息队列(Message Queue)
  • 共享内存(Shared Memory)
  • 信号量(Semaphore)
  • 套接字(Socket)

但要注意,共享内存虽然效率高,但破坏了内存隔离。所以使用时要格外小心,必须配合同步机制(比如信号量)来避免竞态条件。

避坑指南:我曾经在一个项目中,两个进程通过共享内存传递大数据,但没有加锁。结果在高并发下,数据被撕成了碎片。从那以后,我只要用共享内存,就一定会配上信号量或互斥锁。

3.4 本章小结

这一章我们聊了进程的内存布局:代码段、数据段、堆、栈各自的作用和特点。虚拟地址空间让每个进程都以为自己独占内存,而页表机制实现了进程间的内存隔离。

你想想看,这些概念其实每天都在用。写代码时,全局变量放在数据段,局部变量放在栈上,动态分配的内存放在堆上。理解了这些,你就能更好地控制内存使用,避免那些让人头疼的bug。