汇编语言:数据段和栈段
字在内存中的存储
8086 是 16 位 CPU,其通用寄存器多为 16 位,因此它擅长处理字(2 字节)数据。在内存中存储一个字需要占用两个连续的字节单元,规则是低位字节存放在低地址单元,高位字节存放在高地址单元,这种存储方式也被称为小端序。例如,数值 4E20H 存放在 0 地址开始的内存中,20H(低位)存放在 0 号单元,4EH(高位)存放在 1 号单元。

字单元是由两个连续的内存单元组成,用来存放一个字型数据,通常把起始地址称为 N 地址字单元。需要注意的是,1 地址字单元指的是由 1 号和 2 号单元组成的字单元,而不是单指 1 号单元。
DS 与 [address]
段寄存器
CPU 读取内存需要段地址和偏移地址,8086 中 DS(Data Segment) 寄存器用来存放要访问数据的段地址,配合 DS 使用的是方括号 [] 表示的偏移地址。例如,要从 10000H 读取数据到 AL,需要使用以下指令序列:
mov bx, 1000H
mov ds, bx
mov al, [0]


8086 CPU 不允许直接将数据送入段寄存器,如 mov ds, 1000H 是非法的。由于 8086 硬件设计的限制,必须通过通用寄存器如 BX 来中转。mov al, [0] 中的 [0] 仅指明了偏移地址为 0,CPU 执行时会自动取 DS 中的数据作为段地址,合成物理地址 1000H * 16 + 0 = 10000H。反过来,如果想把 AL 的数据写入 10000H,指令为 mov [0], al。

通过 16 位数据总线,8086 可以一次性传送一个字。只要在 mov 指令中使用 16 位寄存器,就可以进行字操作。在执行字操作时,CPU 会自动处理两个内存单元的数据。低地址单元对应 AL/AH 的低 8 位,高地址单元对应 AH 的高 8 位。
mov ax, [0] ; 将 DS:0 处的字送入 AX
mov [2], bx ; 将 BX 中的字送入 DS:2 处
add 和 sub 指令同样支持内存操作数。例如 add ax, [0] 会将 AX 的值与内存中 DS:0 处的字相加,结果存回 AX。需要注意的是 16 位运算超过 FFFFH 会丢弃高位,8 位运算超过 FFH 也会丢弃高位。
数据段
通过 Debug 可以验证 mov 指令的更多合法形式,除了寄存器与数据之间的传送,它还支持段寄存器与寄存器、寄存器与内存、段寄存器与内存之间的双向传送。例如 mov ax, ds、mov [0], cs 都是合法的。
在编程时,我们可以将一组地址连续、起始地址为 16 倍数、长度不超过 64KB 的内存单元,逻辑上划分为一个数据段。访问数据段的范式是将段地址送入 DS,然后使用 [偏移地址] 访问具体单元。例如,累加数据段前 3 个字节:
mov ax, 123BH
mov ds, ax
mov al, 0
add al, [0]
add al, [1]
add al, [2]



如果是累加前 3 个字,则需要注意偏移地址的间隔是 2 个字节:
mov ax, 0
add ax, [0] ; 访问 0 地址字单元
add ax, [2] ; 访问 2 地址字单元
add ax, [4] ; 访问 4 地址字单元


在 Debug 中使用 d 命令查看内存时,中间的分隔符 - 将一行分为前 8 个和后 8 个字节。当使用 u 命令反汇编时,会发现指令长度不一。
栈机制
栈的 CPU 的实现
栈(stack)又名堆栈,它是一种运算受限的线性表,受限于该结构仅能在表尾进行元素的插入和删除操作。在允许进行插入和删除的一段被称之为栈顶,表的另一端被称为栈底,栈结构的插入操作被称为压栈,删除操作被称为退栈或出栈。栈最鲜明的特点就是先进后出,后进先出,出栈的元素一定是位于栈顶的元素,在栈顶的元素出栈之后,下一个元素就成为新的栈顶,当栈底的元素执行出栈操作之后栈就成为了空栈。

CPU 对栈逻辑的硬件实现,依赖于 SS(Stack Segment,栈段寄存器)和 SP(Stack Pointer,栈指针寄存器)。与数据段从低地址向高地址存放数据相反,栈顶是向低地址增长的。任意时刻 SS:SP 指向栈顶元素,例如我们将 10000H~1000FH 这段内存逻辑上定义为栈段,则需要初始化 SS=1000H、SP=0010H。目前是空栈状态,SS:SP 指向栈底下一个单元。

push 和 pop 指令
push 和 pop 指令是 8086 提供的基本入栈、出栈指令,这两个指令都以字为单位,只能操作 16 位数据(一个字)。push ax 的执行过程分为两步,首先在逻辑上移动栈顶,即 SP = SP - 2 使其指向新的空位。由于栈向低地址生长,所以是减 2。接着将 AX 中的内容送入 SS:SP 指向的内存单元,高位存入高地址,低位存入低地址。
例如执行 mov ax,0123H 和 push ax 后,SP 减 2 变为 000EH,AX 中的字数据 0123H 被存入 1000EH 和 1000FH 单元。低字节 23H 在 1000EH,高字节 01H 在 1000FH。接着执行 mov bx,2266H 和 push bx,SP 继续减 2 变为 000CH,2266H 存入 1000CH 和 1000DH。

最后执行 mov cx,1122H 和 push cx,SP 变为 000AH,1122H 存入 1000AH 和 1000BH。此时栈顶指针 SP=000AH,栈内自底向上依次存放着 0123H、2266H 和 1122H。

pop bx 的执行过程是顺序相反的两步,先将 SS:SP 指向的内存单元中的数据送入 BX,然后栈顶下移 SP = SP + 2 指向新的栈顶。

例如执行 pop ax 时,CPU 先将 SS:SP 指向的 1000AH 处的字数据 1122H 送入 AX,随后 SP 加 2 变为 000CH。执行 pop bx,从 1000CH 处读取 2266H 送入 BX,SP 变为 000EH。执行 pop cx,从 1000EH 处读取 0123H 送入 CX,SP 恢复为 0010H,栈重回空栈状态。

8086 CPU 只负责维护 SS:SP 的指向,它不检查栈顶是否超出了我们分配的栈空间范围。如果栈满了还执行 push,或者栈空了还执行 pop,就会发生栈顶超界。例如上文我们将 10000H~1000FH 定义为栈段,但是执行 pop 操作仍然可以运行,但是读取到的数据是不可控。这会导致栈外的数据,可能是代码、其他变量甚至系统数据被覆盖,引发程序崩溃或系统不稳定。因此,栈空间的大小需要程序员来主动维护。

除了寄存器,push 和 pop 还能操作段寄存器和内存单元,例如以下指令:
| 样例指令 | 功能 |
|---|---|
push ds |
将段寄存器 DS 的值入栈 |
pop es |
从栈中恢复数据到 ES |
push [0] |
将 DS:0 处的字单元入栈 |
pop [2] |
将栈顶数据出栈到 DS:2 处 |


如果要在特定地址(如 10000H)写入数据,但又不能使用 mov [地址], 寄存器 的指令,可以利用栈的特性。先设置 SS=1000H、SP=0002H,然后执行 push ax。CPU 会先将 SP 减 2 变为 0000H,再将 AX 写入 10000H。


栈段
与代码段和数据段一样,栈段也是编程时的一种逻辑安排。因为 SP 是 16 位,因此可以将一段地址连续、最大 64KB、起始地址为 16 倍数的内存定义为栈段。要让 CPU 认可这个栈段,只需在程序初始化时将段地址送入 SS,将栈顶偏移地址送入 SP 即可。
空栈时 SP 的设置容易出错,如果栈空间为 10000H~1000FH(16 字节),栈底字单元地址为 1000EH。栈空时 SS:SP 应该指向栈底单元的下一个单元,即 10010H。因为第一次执行 push 时,SP 需要先减 2 指向 1000EH,再写入数据。
目前为止主要接触了三种逻辑段:代码段、数据段和栈段,它们在内存中本身是中立的,同一段物理内存可以被赋予不同的角色。CPU 区分它们只看寄存器指向哪里:
| 段 | CPU 视角 |
|---|---|
| 代码段 | CPU 认为 CS:IP 指向的内存是代码,用于取指执行 |
| 数据段 | CPU 认为 DS:[address] 指向的内存是数据,用于读写 |
| 栈段 | CPU 认为 SS:SP 指向的内存是栈,用于 push/pop |
例如 10000H~1001FH 这段内存,可以把它当作代码段来执行,也可以在同一时刻(或者不同时刻)把它当作数据段来读取,或者当作栈段来压栈。关键在于程序中如何设置 CS、DS、SS 和 IP、SP 的值。
实验二
Debug 的机制
d 命令用于查看内存,它不仅支持 d 段地址:偏移地址 这种直接给出地址的形式,也支持 d 段寄存器:偏移地址 的形式(如 d ds:0)。Debug 在执行 d 1000:0 时,内部实际上也是先将 1000H 送入 DS,再进行读取。


与 d 命令类似,e、a 和 u 命令同样支持使用段寄存器来指定内存地址。例如 e ds:0 11 22 33 可以直接向数据段写入数据,a ds:0 则可以向数据段写入代码。


同一个 ds:0 既可以是数据也可以是代码,取决于用什么指令去操作。
修改 SS 指令的连带执行
在 T 命令运行以下代码时:
mov ax,2000
mov ss,ax
mov sp,0010
mov ax,3123

会发现一个反常现象:执行完 mov ss,ax 后,下一条即将执行的指令直接变成了 mov ax,3123,中间的 mov sp,0010 似乎凭空消失了。然而观察寄存器变化,却发现 SP 的值确实变为了 0010。


这是 8086 CPU 的硬件设计特性。当 CPU 执行任何修改 SS 寄存器的指令时,如 mov ss,ax、pop ss,为了保证栈段切换的完整性,CPU 会自动执行下一条指令,不会响应任何中断请求(包括单步中断)。在 Debug 中则是将 mov ss,ax 和 mov sp,0010 视为一个原子操作执行,因此 T 命令没有在 mov sp,0010 处停下。这个特性是为了防止在刚修改完 SS 还没来得及修改 SP 时发生中断,导致栈顶混乱。
栈与内存的综合操作
将 FFFF:0~FFFF:F 视为数据段存放原始数据,将 2200:0~2200:FF 视为栈段。首先通过如下指令进行初始化:
mov ax,ffff
mov ds,ax ; DS 指向 FFFF,作为数据段
mov ax,2200
mov ss,ax ; SS 指向 2200,作为栈段
mov sp,0100 ; 栈顶指向 2200:0100 (空栈)
mov ax,[0] ; AX = FFFF:[0] 处的字数据
add ax,[2] ; AX = AX + FFFF:[2]
mov bx,[4] ; BX = FFFF:[4] 处的字数据
add bx,[6] ; BX = BX + FFFF:[6]
运行后, AX 和 BX 分别变为两个 16 位数相加的结果。



接着进行入栈操作:
push ax ; 将 AX 入栈
push bx ; 将 BX 入栈
每执行一次 push,SP 减 2。执行 push ax 后 sp=00FE,修改的内存单元地址是 2200:00FE(低字节)和 2200:00FF(高字节),内容为 AX 的值。执行 push bx 后 sp=00FC,修改的内存单元地址是 2200:00FC 和 2200:00FD,内容为 BX 的值。
然后执行出栈操作:
pop ax ; 从栈顶弹出一个字到 AX
pop bx ; 从栈顶弹出一个字到 BX
每执行一次 pop,SP 加 2。由于栈是 LIFO,弹出的顺序与压入相反。执行 pop ax 后 sp=00FE,修改的内存单元地址是 2200:00FC 和 2200:00FD,内容为原 BX 的值(因为 BX 后入栈)。执行 pop bx 后 sp=0100,修改的内存单元地址是 2200:00FE 和 2200:00FF,内容为原 AX 的值。经过一轮 push/pop,AX 和 BX 的值发生了互换。

最后直接内存入栈:
push [4] ; 将 DS:[4](即 FFFF:4)处的字入栈
push [6] ; 将 DS:[6](即 FFFF:6)处的字入栈
可以直接将内存单元中的字压入栈中,无需经过寄存器中转。之前 SP 为 0100,两次 push 共减 4,执行后 sp=00FC。

2000:0 内存变化
以下代码将 2000:0~2000:F 设置为栈段:
mov ax,2000;
mov ss,ax;
mov sp,10;
mov ax,3123;
push ax;
mov ax,3366;
push ax;
在初始化栈之后查看 2000:0~2000:F 的内存内容,发现其中的数据发生了改变。数据不是初始的 00 或其他随机值,而是出现了其他数据。


这是 Debug 的 T 命令(单步调试)利用栈空间保存现场导致的。当使用 T 命令执行指令时,CPU 会触发单步中断。为了在执行中断服务程序前不破坏当前程序的运行状态,CPU 会自动将标志寄存器(FLAGS)以及 CS、IP 的值压入当前栈中,即 SS:SP 指向的栈。
在实验中,栈段正好设置在 2000:0~2000:F,SP 初始为 0010。当执行 T 命令引发单步中断时,CPU 将标志寄存器 FLAGS 入栈,然后依次将 CS、IP 入栈。这些数据写入了 2000:0~2000:F 这段内存中,因此内存变化实际上是 Debug 单步调试时,CPU 自动保存的断点现场(返回地址等)。
参考资料
《汇编语言(第四版)》,王爽 编著,清华大学出版社

浙公网安备 33010602011771号