混沌初开
故事追溯到IA-32诞生之前, 在那时, 8086曾经主宰了计算机的一切. 那是一个16位的世界, 所有寄存器都是16位的, 貌似最多只能访问 2^16=64KB
的内存. 但事实并非如此, 8086通过引入一系列的段寄存器(segment register)来开辟更广阔的世界:
physical_address = (seg_reg << 4) + offset
其中 seg_reg
为某个段寄存器的值, offset
为寻址的偏移量, 由通用寄存器或者立即数给出. 例如当数据段寄存器DS的值为 0x8765
, AX寄存器的值为 0x1234
, 那么一次 [DS:AX]
的寻址将会访问物理地址
[DS:AX] = [0x8765:0x1234] = (0x8765 << 4) + 0x1234 = 0x87650 + 0x1234 = 0x88884
这条规则将每一次内存寻址都和某一个段寄存器绑定起来, 通过借助段寄存器的信息, 计算机可以访问1MB的内存, 当然, 8086需要有20根地址线. 后来人们把8086的这种寻址模式成为实模式(real mode), 因为程序能够感知到一次内存访问的真实物理地址.
然而, seg_reg
和 offset
并不是可以随便搭配的, 8086中有一些捆绑的约定
- 取指令时总是使用CS(code segment)寄存器和IP(instruction pointer)绑定
- 大部分的内存数据访问都是使用DS(data segment)寄存器, 例如
mov %ax, (%bx)
, 用寄存器传输语言(RTL)来描述就是
但也可以显式指定使用ES(extra segment)M[DS:BX] <- R[AX]
- 与堆栈相关的内存访问总是使用SS(stack segment)寄存器, 包括使用
push
和pop
指令, 或者使用SP(stack pointer)和BP(base pointer)进行寻址 - 一些字符串处理指令(例如
movsb
)会默认使用ES
这些约定一直沿用至今.
这样, 内存就被分成65536个有重叠的, 大小为64KB的段, 一个段的基地址由 (seg_reg << 4)
给出. 如果要求段之间不能相互重叠, 那就只有16个符合要求的段. 不过这对刚刚破壳而出的计算机技术来说, 已经是一个十分伟大的贡献了. 借助8086的分段机制, 计算机已经可以做很多事情, 你也许很难想象当年风靡全球的吃豆子游戏竟然可以在8086中跑起来.
在 seg_reg
和 offset
的搭配下, 总共有32位的信息(每个寄存器分别是16位), 而要访问1MB的内存只需要20位的信息就足够了, 浪费了其中12位的信息导致了段之间的重叠. 为什么不充分利用这12位的信息? 或者说让段寄存器都变成4位?
这是一个open problem, 尝试一下你会提出什么理由来支持8086的设计. 如果你对这个问题感兴趣, 你可以到互联网上搜索相关内容.
随着需求的增长, 程序需要使用越来越多的内存, 固定使用一个段的内存已经变得不可行了. 幸好8086允许程序改变段寄存器的值, 以达到使用更多内存的目的. 因此以前的程序员在编写规模稍大的程序时, 经常需要在不同的段之间来回切换. 虽然麻烦, 但总比没有好.
细心的你应该会发现, 8086这种段寄存器不加保护的做法毫无安全可言, 你甚至可以轻而易举地编写一个用来清除内存上除了自身以外所有数据的恶意程序. 由于当时的程序员大多都十分纯洁, 因此整个计算机时代也在8086下度过了一段十分和谐的时光. 但即使没有恶意程序的困扰, 1MB的内存却将要成为计算机性能的瓶颈. 当你听到了以前广为流传的"640KB内存已经足够", "4GB大得简直无法想象"这类说法时, 难免会呵呵地付诸一笑. 你能想象你现在盯着的那台无所不能的机器, 在30年前竟然连你现在电脑上的那张桌面墙纸都放不下吗?
目前NEMU的运行模式和8086的实模式有点类似, 程序使用的内存地址都是实际的物理地址, 不同的是, 寄存器和地址都是32位的, 而且没有分段机制. 需要说明的是, 这其实是KISS法则的产物, 只是让你可以在PA2中将精力集中在指令系统的实现, x86中并不存在这样的运行模式.