跳至主要內容

汇编语言一发入魂 0x04 - 堆栈

未央大约 6 分钟汇编语言堆栈

上一篇文章中我们实现了数字各个位的分解并打印在屏幕上。他需要我们知道数字有多少个位,并且提前预留出内存空间保存每个位,这显然不是一个完美的解决方案。现在我们来学习一下堆栈,并使用堆栈来保存分解出的每个位,实现可以分解任意位的数字。

先解释一下堆栈。实际上这个堆栈堆(heap)并没有关系,只是一个纯粹的栈(stack),可能是堆栈这样叫起来更上口一点吧。

堆栈段和其他段一样,只是一段普通的内存空间,只是我们限制了对这部分内存空间操作的行为。我们只允许通过push(压栈)pop(出栈)这两个指令来操作堆栈段的内存空间,以此来实现一些算法。使用堆栈段之前需要先初始化段基址(ss)栈顶指针(sp),例如将ss初始化为0x0000sp初始化为0x7c00。此时堆栈段的逻辑地址为0x0000:0x00000x0000:0x7c00,对应的物理地址为0x000000x07c00

push指令用于将操作数压入栈中。在16位的处理器上,push指令的操作数可以是16位的寄存器或者内存单元。对于8086处理器来说,压栈的数据长度必须是一个字。处理器在执行push指令时,首先将堆栈指针寄存器sp的内容减去操作数的字长(以字节为单位的长度,在16位处理器上是2),然后,把要压入堆栈的数据存放到逻辑地址ss:sp所指向的内存位置。当sssp初始化为上述状态时,第一次执行push指令,sp先减去2,得到0x7bfe,然后将数据压入0x0000:0x7bfe对应的物理地址处。

pop指令用于将操作数从栈中弹出。在16位的处理器上,pop指令的操作数可以是16位的寄存器或者内存单元。pop指令执行时,处理器先取得ss:sp对应的物理地址处的数据。然后,将sp的内容加上操作数的字长,以指向下一个堆栈位置。

下面我们通过一小段代码来熟悉一下堆栈段的操作。

代码

.code16

movw $0xb800, %ax
movw %ax, %ds

xorw %ax, %ax
movw %ax, %ss

movw $0x7c00, %sp

pushw $'c' | 0x0a00
pushw $'b' | 0x0a00
pushw $'a' | 0x0a00

popw 0
popw 2
popw 4

jmp .

.org 510
.word 0xAA55

解释

3、4行我们让ds指向显存缓冲区,这样我们在后续将数据弹出到显存缓冲区时就不需要加段前缀了。

6、7行将ss设置为0x0000,实际上这不是必须的,因为ss在启动时就会被初始化为0x0000

9行将堆栈指针寄存器sp设置为0x7c00

11~13行将字符c、b、a及其显示属性0x0a一起压入栈中。因为后进先出的,所以出栈的顺序是a、b、c

15~17行将a、b、c依次出栈。因为我们直接指定了偏移地址0、2、4,这默认会使用数据段寄存器ds作为基地址,所以实际上表示将三个字符及其显示属性依次弹出到内存0xb800:0x00000xb800:0x00020xb800:0x0004处,实现字符的打印。

运行

$ as --32 stack_op.s -o stack_op.o
$ objcopy -O binary -j .text stack_op.o stack_op.bin
$ qemu-system-i386 stack_op.bin
stack_op
stack_op

调试

启动虚拟机:

$ qemu-system-i386 stack_op.bin -S -s

调试过程中有一些经常需要输入的指令,为了避免每次调试都要重复输入这些指令,老李已经将他们写到了.gdbinitopen in new window中了,大家直接使用就可以了。

$ gdb -q
warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
0x0000fff0 in ?? ()
The target architecture is assumed to be i8086
warning: A handler for the OS ABI "GNU/Linux" is not built into this configuration
of GDB.  Attempting to continue with the default i8086 settings.

warning: A handler for the OS ABI "GNU/Linux" is not built into this configuration
of GDB.  Attempting to continue with the default i8086 settings.

Breakpoint 1 at 0x7c00

Breakpoint 1, 0x00007c00 in ?? ()
1: x/i $cs*16+$pc
=> 0x7c00:      mov    $0xb800,%ax
(gdb)

12行提示我们已经在0x7c00处打好了断点,让我们看一下此时寄存器的状态。

(gdb) info registers
eax            0xaa55   43605
ecx            0x0      0
edx            0x80     128
ebx            0x0      0
esp            0x6f04   0x6f04
ebp            0x0      0x0
esi            0x0      0
edi            0x0      0
eip            0x7c00   0x7c00
eflags         0x202    [ IF ]
cs             0x0      0
ss             0x0      0
ds             0x0      0
es             0x0      0
fs             0x0      0
gs             0x0      0

ds0ss0sp0x6f04。向后执行5条指令再次查看寄存器内容。

(gdb) si 5
0x00007c0c in ?? ()
1: x/i $cs*16+$pc
=> 0x7c0c:      push   $0xa63
(gdb) info registers
eax            0x0      0
ecx            0x0      0
edx            0x80     128
ebx            0x0      0
esp            0x7c00   0x7c00
ebp            0x0      0x0
esi            0x0      0
edi            0x0      0
eip            0x7c0c   0x7c0c
eflags         0x246    [ PF ZF IF ]
cs             0x0      0
ss             0x0      0
ds             0xb800   47104
es             0x0      0
fs             0x0      0
gs             0x0      0

此时ds0xb800ss0sp0x7c00。再向后执行3条指令并查看寄存器内容。

(gdb) si 3
0x00007c15 in ?? ()
1: x/i $cs*16+$pc
=> 0x7c15:      popw   0x0
(gdb) info registers
eax            0x0      0
ecx            0x0      0
edx            0x80     128
ebx            0x0      0
esp            0x7bfa   0x7bfa
ebp            0x0      0x0
esi            0x0      0
edi            0x0      0
eip            0x7c15   0x7c15
eflags         0x246    [ PF ZF IF ]
cs             0x0      0
ss             0x0      0
ds             0xb800   47104
es             0x0      0
fs             0x0      0
gs             0x0      0

此时因为执行了3push指令,所以sp的值已经从0x7c00减到了0x7bfa,正好3个字,6个字节。我们来查看一下从0x7bfa开始的三个字的内容。

(gdb) x/3xh 0x7bfa
0x7bfa: 0x0a61  0x0a62  0x0a63

其中高字节是显示属性0x0a,低字节依次是0x61、0x62、0x63,对应字符a、b、c

下面给出分解并打印数字各位程序基于栈的实现。

.code16

.set DIVIDEND, 9527         # 被除数
.set DIVISOR, 10            # 除数

movw $0x07c0, %ax
movw %ax, %ds

movw $0xb800, %ax
movw %ax, %es

xorw %ax, %ax
movw %ax, %ss

movw $0x7c00, %sp

# 设置 32位 被除数
# 高 16位 在 %dx 中, 低 16位 在 %ax 中
# 因为 %ax 足够保存 9527, 所以将高 16位(%dx) 清空
xorw %dx, %dx
movw $DIVIDEND, %ax
movw $DIVISOR, %bx

# 分解位数的同时统计一共有多少位
# 显示的时候需要用位数控制循环次数
xorw %cx, %cx
split:
  incw %cx
  divw %bx
  orw $0xa30, %dx
  pushw %dx
  xorw %dx, %dx
  cmpw $0, %ax      # 商为零则分解完毕, 不为零则继续分解
  jne split

xorw %si, %si
putc:
  popw %es:(%si)
  addw $2, %si
  loop putc

jmp .

.org 510
.word 0xAA55

与上一篇中的方法不同的是,我们现在不需要预先知道数字有多少位并为每一位预留存储空间。取而代之,我们使用了,每计算出一位便将其与显示属性一同压入栈中,当计算完毕时,依次出栈即可。

26行,因为我们不知道数字的位数,所以需要在分解数字的时候做一下统计,每分解一位便将cx加一。使用cx保存统计数字的原因是稍后显示的时候需要用到循环,此时就将统计数字放在cx中的话到时就不需要再做一次数据转移操作。

另一个不同之处是这次我们判断分解结束的依据为ax等于0,即商为0时分解完成。使用jne条件转移指令配合比较指令cmp实现对程序的控制。cmp指令的功能类似减法指令,但不会将计算结果写入目的操作数,只是将比较结果反应在标志寄存器的标志位上。条件转移指令jne当结果不为0时转移。