分段机制:逻辑结构与内存保护

分页机制很优雅,它把地址空间切成大小相同、完全同质的页,一举消灭了外部碎片。但如果你换到程序员的视角看自己的程序,会发现它压根不是一堆毫无区别的页——它天然是有结构的:这一块是代码、那一块是全局数据、那边是运行时增长的堆、还有一个不断伸缩的栈。

这些部分的长度不同、用途不同、需要的权限也不同:代码段应该是只读可执行的,数据段应该可读写但不可执行,栈段同样可读写。分页把它们全都拍平成同质的页,这种"程序内在的逻辑结构"就在地址空间里彻底消失了。

分页关心的是"物理上怎么放得高效",分段关心的是"逻辑上怎么组织得合理、怎么按结构施加保护"。

这一篇我们先把分段的运转机制讲透——逻辑地址怎么由段号和段内偏移组成、段表里存了什么、一次访问要经过哪几步检查和计算——看清这套机制后,再回答它为什么强调逻辑结构与保护边界,以及它和分页到底是什么关系。

一、先建立画面:什么是"段"

段(segment),就是按逻辑含义划分的一段地址空间。一个典型程序在分段视角下,地址空间不是一条从 0 到顶的扁平直线,而是若干个独立的段:

  • 代码段:存放指令,逻辑上应只读、可执行;
  • 数据段:存放全局/静态变量,可读写;
  • 堆段:动态分配的内存,可读写,向高地址增长;
  • 栈段:函数调用栈,可读写,通常向低地址增长。

关键区别在于:分页里每个页都一样大、没有语义;而每个段有自己的名字(用途)、自己的长度、自己的权限。段的长度是可变的——代码段可能 100KB,某个数据段可能只有 4KB,这取决于它实际装了多少东西,而不是被强行对齐到固定大小。

正因为段携带了"它是什么、有多大、能不能写、能不能执行"这些语义信息,分段才有能力做分页做不到的事:按程序的逻辑结构来施加精细的保护

二、逻辑地址的样子:段号 + 段内偏移

在分段机制下,一个逻辑地址不再是一个单一的线性数字,而是被明确地分成两部分:

逻辑地址 =(段号 segment number,段内偏移 offset)

  • 段号:指明这个地址属于哪一个段(代码段?数据段?……);
  • 段内偏移:指明在这个段内部,从段的起点往后数多少字节。

这里要特别注意它和分页的一个本质差别。分页里逻辑地址也分两部分(页号 + 页内偏移),但那个切分是纯二进制位的机械切割——因为页大小是 2 的幂,高位天然是页号、低位天然是偏移,页与页之间在逻辑地址上是连续编号的。

而分段不同:各个段在逻辑上是相互独立的,每个段都从自己的偏移 0 开始。段号更像是"选择哪一个独立空间",段内偏移则是在被选中的那个空间里定位。这种"二维"的地址结构,正是它能表达程序逻辑划分的根本原因。

三、段表:分段机制的心脏

既然段可以散落在物理内存的不同位置、各有长度和权限,那一定要有一个数据结构来记录这些信息,这就是段表(segment table)。每个进程一张。

段表也是一个数组,以段号为下标,每一项(段描述符)至少包含三样东西:

  • 段基址(base):这个段在物理内存中的起始物理地址;
  • 段长度(limit):这个段的大小,用于越界检查;
  • 保护位(protection bits):这个段的访问权限,如只读 / 可读写 / 可执行。

对比上一篇的基址/界限模型,你会有强烈的既视感:段表的每一项,本质上就是一对独立的"基址 + 界限"!区别在于——基址/界限模型给整个进程只配一对,所以进程必须整体连续;而分段给进程的每一个段各配一对,于是各个段可以彼此独立地散布在物理内存的不同地方。

分段可以理解为:把"一个进程一对基址/界限"扩展成"一个进程每段一对基址/界限"。这一步扩展,既带来了逻辑结构的表达力,也带来了段级的精细保护。

四、地址转换:一次访问要走完的完整流程

现在把机制串起来,看一个逻辑地址 (段号 s, 段内偏移 d) 是怎么变成物理地址、并在途中接受保护检查的。这是这一篇最核心的机制,请逐步跟一遍:

flowchart TD A["CPU 发出逻辑地址 (段号 s, 偏移 d)"] --> B["用段号 s 查段表<br/>取出该段的 基址/段长/权限"] B --> C{"d < 段长 limit ?"} C -->|"否, 越界"| X["触发越界异常<br/>(段错误)"] C -->|"是"| D{"本次访问类型<br/>符合权限位 ?"} D -->|"否, 如写只读段"| Y["触发保护异常"] D -->|"是"| E["物理地址 = 段基址 + d"] E --> F["访问物理内存"]

逐步拆解:

  1. 拆地址:CPU 给出逻辑地址,硬件分离出段号 s 和段内偏移 d
  2. 查段表:用段号 s 作下标查段表,取出这个段的基址、段长、权限位;
  3. 越界检查:判断 d < 段长 是否成立。如果偏移超过了段的实际长度,说明访问越出了这个段的边界,立即触发异常。这是分段保护的第一道闸;
  4. 权限检查:判断本次访问的类型是否被段权限允许——比如想写一个只读的代码段,或者想在数据段上执行指令,都会在这里被拦下,触发保护异常。这是第二道闸;
  5. 算物理地址:两道检查都通过后,物理地址 = 段基址 + 段内偏移,用它访问内存。

请特别注意第 5 步是 "基址 + 偏移"的算术相加,而不是分页里的"拼接"。这是分段和分页又一个本质区别:分页因为页与页框等大,偏移可以直接搬过去拼接;而分段的段长度可变、基址也不要求对齐到任何边界,所以只能老老实实做加法。

五、保护:为什么分段在"按结构保护"上是天生的强项

第四节那两道检查,正是分段最大的价值所在。我们单独把它点透。

分段的保护是以段为单位、按逻辑语义施加的,这非常符合程序的天然结构:

  • 越界保护:每个段有独立的段长,访问超出本段长度立刻被拦。注意它的精度——这不是"别越出整个进程的地盘",而是"别越出代码段/数据段各自的边界",粒度细得多;
  • 权限保护:每个段带独立权限位。代码段设为只读+可执行,恶意代码就改不动你的指令;栈段设为不可执行,攻击者即使把代码注入栈上也无法运行(这正是经典的栈溢出攻击防御思路 NX/DEP 的雏形);数据段设为不可执行,同理。

把保护建立在"段"这个有语义的单位上,意味着保护规则和程序的逻辑意图是对齐的。你想表达"代码不许被改写",直接给代码段设只读即可,干净直接。

分段把保护从"地址范围"提升到了"逻辑单元":你保护的不是一段冷冰冰的地址区间,而是"代码"“数据”"栈"这些有明确含义的东西。很多安全漏洞的本质,就是想方设法越过了某个本不该跨越的段边界。

六、看清优点后,必须正视分段的硬伤:碎片回来了

分段的逻辑表达力和保护能力都很迷人,但它有一个致命软肋,而且这个软肋我们在上一篇刚刚见过。

因为段的长度是可变的,每个段又要求在物理内存中连续存放,这就把连续分配的全部麻烦原封不动地搬了回来:

  • 给一个段分配物理内存,又变成了"找一块足够大的连续空闲区"的问题;
  • 段不断创建、销毁、增长,物理内存又会被切成大大小小不相邻的空闲块;
  • 外部碎片,卷土重来。

这正是分段与分页的根本对立:分页用"固定大小的页"消灭了外部碎片,但丢掉了逻辑结构;分段保住了逻辑结构和精细保护,却因为"可变长度 + 要求连续"重新背上了外部碎片的包袱。

分页和分段各自解决了对方解决不了的问题,也各自留下了对方没有的缺陷。分页擅长物理管理,分段擅长逻辑表达——它们是互补的,而非互相替代。

看到这里,一个非常自然的念头就会冒出来:能不能把两者结合,用段来表达逻辑结构和保护,用页来做物理分配,让段不再要求连续?这恰恰就是下一篇《段页式管理》的核心思路。分段在这里留下的外部碎片缺口,正是段页式要去填补的。

七、和工程实践 / 后端开发的联系

虽然现代主流系统(x86-64 下的 Linux)几乎把分段"虚化"了——段基址大多设为 0、主要靠分页来做地址管理,但分段的思想无处不在,理解它能帮你看懂很多现象。

  • 可执行文件的 Section/Segment:ELF 文件里的 .text(代码)、.data(已初始化数据)、.bss.rodata(只读数据)等,正是分段思想的直接体现。加载时它们被映射成不同权限的内存区域,这就是为什么 cat /proc/<pid>/maps 能看到 r-xprw-p 等不同权限的段。
  • 段错误(Segmentation Fault)的字面含义:这个词里的 “Segmentation” 就来自分段——它本意就是"越过了某个内存段的合法边界或权限"。当你写一个只读区域、或访问越界,硬件的段/页保护机制把它拦下并报错。
  • NX / DEP 安全机制:栈、堆被标记为不可执行,防止注入的 shellcode 运行,本质就是分段权限保护(“这块区域不可执行”)思想的现代落地。
  • 权限最小化设计:把代码与数据、可写与可执行严格分离,这种"按逻辑单元施加最小必要权限"的安全原则,和分段的保护哲学一脉相承。后端做权限系统、做沙箱隔离,思路其实是相通的。

八、动手做一个小实验:把程序结构映射成段

理解分段最直观的方式,是亲手把一个程序的逻辑结构画成段,并思考各自的权限。

拿一个最简单的 C 程序:

1
2
3
4
5
6
7
const char *msg = "hello";   // 字符串常量在只读数据段
int counter = 0; // 全局变量在数据段
int main() { // main 的指令在代码段
int local = 1; // 局部变量在栈段
char *buf = malloc(64); // buf 指向堆段
return 0;
}

动手做三件事:

  1. 画出四个段:代码段、(只读)数据段、堆段、栈段,标出每个段你认为合理的权限(只读/可读写/可执行);
  2. 编译后用 readelf -S a.out 查看真实的 section 列表,对照你画的段,看 .text.rodata.data 的权限标志是否符合预期;
  3. 运行程序,用 cat /proc/<pid>/maps 看这些段被映射成内存区域后的实际权限,验证"代码 r-xp、栈 rw-p"。

做完这一圈,第五节讲的"按逻辑单元施加保护"就从抽象概念变成了你亲眼验证过的事实。

学习这一部分最容易踩的坑

1. 把分段和分页当成"谁取代谁"的关系

它们不是替代关系,而是互补关系。分页解决物理管理(消灭外部碎片),分段解决逻辑表达与精细保护。各有所长也各有缺陷,所以才会有结合两者的段页式。

2. 以为分段一定比分页简单

分段强调逻辑意义,但实现并不更简单——可变长度的段重新引入了外部碎片,物理分配反而更麻烦。“逻辑上直观"不等于"实现上简单”。

3. 把地址转换记成"拼接"

分页是拼接(页框号 + 页内偏移),因为页等大;分段是相加(段基址 + 段内偏移),因为段长可变、基址不对齐。混淆这一点,手算地址必错。

4. 只记得段号偏移,忘了两道检查

分段地址转换不只是"查表 + 加法",中间还夹着越界检查和权限检查这两道闸。保护正是分段的灵魂,漏掉检查就丢掉了它最重要的价值。

总结

分段让我们看到:内存管理不仅是"分配空间",更是"表达程序结构、施加保护边界"。把机制和结论一起带走:

  • 是按逻辑含义划分的可变长度地址片段,各有名字、长度和权限,对应程序天然的代码/数据/堆/栈结构;
  • 逻辑地址是**(段号,段内偏移)**二维结构,每个段从自己的偏移 0 开始,彼此独立;
  • 段表每项含基址、段长、权限位,本质是"每段一对基址/界限",是基址/界限模型的扩展;
  • 地址转换流程是:查段表 → 越界检查 → 权限检查 → 段基址 + 偏移(相加,非拼接)
  • 分段的核心价值是按逻辑单元施加精细保护(只读代码、不可执行栈),这是分页做不到的;
  • 分段的硬伤是可变长度重新引入了外部碎片,这正是下一篇段页式要填补的缺口。

当你能说清"分段强在逻辑与保护、弱在外部碎片,分页恰好相反",你就抓住了这一篇的精髓。下一篇,我们就看操作系统如何把两者的优点合二为一。

参考资源

  • 《现代操作系统》(Andrew S. Tanenbaum)内存管理章节
  • 《操作系统导论》(Operating Systems: Three Easy Pieces)Segmentation
  • OSTEP - Segmentation
  • 《深入理解计算机系统》程序的链接与加载章节