汇编语言:数据段和栈段

字在内存中的存储

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

字单元是由两个连续的内存单元组成,用来存放一个字型数据,通常把起始地址称为 N 地址字单元。需要注意的是,1 地址字单元指的是由 1 号和 2 号单元组成的字单元,而不是单指 1 号单元。

DS 与 [address]

段寄存器

CPU 读取内存需要段地址和偏移地址,8086 中 DS(Data Segment) 寄存器用来存放要访问数据的段地址,配合 DS 使用的是方括号 [] 表示的偏移地址。例如,要从 10000H 读取数据到 AL,需要使用以下指令序列:

mov bx, 1000H
mov ds, bx
mov al, [0]

image
image

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

通过 16 位数据总线,8086 可以一次性传送一个字。只要在 mov 指令中使用 16 位寄存器,就可以进行字操作。在执行字操作时,CPU 会自动处理两个内存单元的数据。低地址单元对应 AL/AH 的低 8 位,高地址单元对应 AH 的高 8 位。

mov ax, [0]    ; 将 DS:0 处的字送入 AX
mov [2], bx    ; 将 BX 中的字送入 DS:2 处

addsub 指令同样支持内存操作数。例如 add ax, [0] 会将 AX 的值与内存中 DS:0 处的字相加,结果存回 AX。需要注意的是 16 位运算超过 FFFFH 会丢弃高位,8 位运算超过 FFH 也会丢弃高位。

数据段

通过 Debug 可以验证 mov 指令的更多合法形式,除了寄存器与数据之间的传送,它还支持段寄存器与寄存器、寄存器与内存、段寄存器与内存之间的双向传送。例如 mov ax, dsmov [0], cs 都是合法的。

在编程时,我们可以将一组地址连续、起始地址为 16 倍数、长度不超过 64KB 的内存单元,逻辑上划分为一个数据段。访问数据段的范式是将段地址送入 DS,然后使用 [偏移地址] 访问具体单元。例如,累加数据段前 3 个字节:

mov ax, 123BH
mov ds, ax
mov al, 0
add al, [0]
add al, [1]
add al, [2]

image
image
image

如果是累加前 3 个字,则需要注意偏移地址的间隔是 2 个字节:

mov ax, 0
add ax, [0]    ; 访问 0 地址字单元
add ax, [2]    ; 访问 2 地址字单元
add ax, [4]    ; 访问 4 地址字单元

image
image

在 Debug 中使用 d 命令查看内存时,中间的分隔符 - 将一行分为前 8 个和后 8 个字节。当使用 u 命令反汇编时,会发现指令长度不一。

栈机制

栈的 CPU 的实现

栈(stack)又名堆栈,它是一种运算受限的线性表,受限于该结构仅能在表尾进行元素的插入和删除操作。在允许进行插入和删除的一段被称之为栈顶,表的另一端被称为栈底,栈结构的插入操作被称为压栈,删除操作被称为退栈出栈。栈最鲜明的特点就是先进后出,后进先出,出栈的元素一定是位于栈顶的元素,在栈顶的元素出栈之后,下一个元素就成为新的栈顶,当栈底的元素执行出栈操作之后栈就成为了空栈。

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

push 和 pop 指令

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

最后执行 mov cx,1122Hpush cx,SP 变为 000AH1122H 存入 1000AH1000BH。此时栈顶指针 SP=000AH,栈内自底向上依次存放着 0123H2266H1122H
image

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

例如执行 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,栈重回空栈状态。
image

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

除了寄存器,pushpop 还能操作段寄存器和内存单元,例如以下指令:

样例指令 功能
push ds 将段寄存器 DS 的值入栈
pop es 从栈中恢复数据到 ES
push [0] 将 DS:0 处的字单元入栈
pop [2] 将栈顶数据出栈到 DS:2 处

image
image

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

栈段

与代码段和数据段一样,栈段也是编程时的一种逻辑安排。因为 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,再进行读取。
image
image

d 命令类似,eau 命令同样支持使用段寄存器来指定内存地址。例如 e ds:0 11 22 33 可以直接向数据段写入数据,a ds:0 则可以向数据段写入代码。
image
image
同一个 ds:0 既可以是数据也可以是代码,取决于用什么指令去操作。

修改 SS 指令的连带执行

在 T 命令运行以下代码时:

mov ax,2000
mov ss,ax
mov sp,0010
mov ax,3123

image

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

这是 8086 CPU 的硬件设计特性。当 CPU 执行任何修改 SS 寄存器的指令时,如 mov ss,axpop ss,为了保证栈段切换的完整性,CPU 会自动执行下一条指令,不会响应任何中断请求(包括单步中断)。在 Debug 中则是将 mov ss,axmov 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 位数相加的结果。
image
image
image

接着进行入栈操作:

push ax        ; 将 AX 入栈
push bx        ; 将 BX 入栈

每执行一次 push,SP 减 2。执行 push axsp=00FE,修改的内存单元地址是 2200:00FE(低字节)和 2200:00FF(高字节),内容为 AX 的值。执行 push bxsp=00FC,修改的内存单元地址是 2200:00FC2200:00FD,内容为 BX 的值。

然后执行出栈操作:

pop ax         ; 从栈顶弹出一个字到 AX
pop bx         ; 从栈顶弹出一个字到 BX

每执行一次 pop,SP 加 2。由于栈是 LIFO,弹出的顺序与压入相反。执行 pop axsp=00FE,修改的内存单元地址是 2200:00FC2200:00FD,内容为原 BX 的值(因为 BX 后入栈)。执行 pop bxsp=0100,修改的内存单元地址是 2200:00FE2200:00FF,内容为原 AX 的值。经过一轮 push/pop,AX 和 BX 的值发生了互换。
image

最后直接内存入栈:

push [4]      ; 将 DS:[4](即 FFFF:4)处的字入栈
push [6]      ; 将 DS:[6](即 FFFF:6)处的字入栈

可以直接将内存单元中的字压入栈中,无需经过寄存器中转。之前 SP 为 0100,两次 push 共减 4,执行后 sp=00FC
image

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 或其他随机值,而是出现了其他数据。
image
image

这是 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 自动保存的断点现场(返回地址等)。

参考资料

《汇编语言(第四版)》,王爽 编著,清华大学出版社

posted @ 2026-08-07 17:59  乌漆WhiteMoon  阅读(3)  评论(0)    收藏  举报