Skip to content

第7讲:数组、寻址方式与显存操作


一、数据库:db / dw / dd 是什么

汇编用三个伪指令定义不同宽度的数据:

asm
.386
data segment use16
xyz db 1, 2, 3, 4           ; db = define byte,每个数 1 字节
abc db "ABCD"                ; 字符串,"A" = 41h,"B" = 42h...
w   dw 1234h, 5678h, 9ABCh  ; dw = define word,每个数 2 字节(16 位)
d   dd 12345678h, 89ABCDEFh ; dd = define double word,每个数 4 字节(32 位)
data ends
伪指令对应 C 语言类型宽度
dbchar1 字节 (8 位)
dwshort int2 字节 (16 位)
ddlong int4 字节 (32 位)

二、内存长什么样?逐字节走一遍

程序运行时,内存是一长条连续的字节。data 段里的变量按你定义的顺序一个接一个排下去。

2.1 先画出每个字节

以 data 段的起始地址为偏移 0,我们把每个字节写出来:

偏移 0 ~ 3:xyz 数组(4 个 db,每 1 字节)

偏移0: 01h     ← xyz[0] = 1
偏移1: 02h     ← xyz[1] = 2
偏移2: 03h     ← xyz[2] = 3
偏移3: 04h     ← xyz[3] = 4

偏移 4 ~ 7:abc 字符串(4 个 db)

偏移4: 41h     ← 'A',ASCII 码是 41h
偏移5: 42h     ← 'B',ASCII 码是 42h
偏移6: 43h     ← 'C'
偏移7: 44h     ← 'D'

偏移 8 ~ 13:w 数组(3 个 dw)

现在每个数 2 字节。CPU 是 x86,使用**小端(Little-Endian)**存储:低位字节在低地址,高位字节在高地址

w[0] = 1234h 为例:

  • 低 8 位 = 34h
  • 高 8 位 = 12h
  • 存到内存:34h 在偏移 8,12h 在偏移 9
偏移8:  34h     ← w[0] 的低字节
偏移9:  12h     ← w[0] 的高字节    → 合起来 = 1234h
偏移A:  78h     ← w[1] 的低字节
偏移B:  56h     ← w[1] 的高字节    → 合起来 = 5678h
偏移C:  BCh     ← w[2] 的低字节
偏移D:  9Ah     ← w[2] 的高字节    → 合起来 = 9ABCh

小端的含义:把一个多字节数值存到内存时,「低 8 位放在低地址,高 8 位放在高地址」。1234h 的低 8 位是 34h,高 8 位是 12h,所以偏移 8 存 34h,偏移 9 存 12h。这跟人类写数字的习惯(高位在前)相反,所以要特别注意。

偏移 E ~ 15:d 数组(2 个 dd)

同理,每个数 4 字节,低位在低地址。以 d[0] = 12345678h 为例:

  • 最低 8 位 = 78h → 偏移 E
  • 次低 8 位 = 56h → 偏移 F
  • 次高 8 位 = 34h → 偏移 10
  • 最高 8 位 = 12h → 偏移 11
偏移E:  78h     ← d[0] 的字节0(最低)
偏移F:  56h     ← d[0] 的字节1
偏移10: 34h     ← d[0] 的字节2
偏移11: 12h     ← d[0] 的字节3(最高)  → 合起来 = 12345678h
偏移12: EFh     ← d[1] 的字节0(最低)
偏移13: CDh     ← d[1] 的字节1
偏移14: ABh     ← d[1] 的字节2
偏移15: 89h     ← d[1] 的字节3(最高)  → 合起来 = 89ABCDEFh

完整内存快照

偏移   值      属于哪个变量
 0     01h     xyz[0]
 1     02h     xyz[1]
 2     03h     xyz[2]
 3     04h     xyz[3]
 4     41h     abc[0] = 'A'
 5     42h     abc[1] = 'B'
 6     43h     abc[2] = 'C'
 7     44h     abc[3] = 'D'
 8     34h  ┐
 9     12h  ┘  w[0] = 1234h
 A     78h  ┐
 B     56h  ┘  w[1] = 5678h
 C     BCh  ┐
 D     9Ah  ┘  w[2] = 9ABCh
 E     78h  ┐
 F     56h  │
10     34h  │  d[0] = 12345678h
11     12h  ┘
12     EFh  ┐
13     CDh  │
14     ABh  │  d[1] = 89ABCDEFh
15     89h  ┘

三、访问数组时,"[]" 里的数字是字节偏移

这是最重要的一点,也是最容易踩坑的。

3.1 w[0]、w[1]、w[2] 到底读到什么

asm
mov ax, w[0]    ; w[0] = 从偏移 8 开始读 2 字节 → ax = 1234h ✓
mov ax, w[1]    ; w[1] = 从偏移 9 开始读 2 字节 → ax = 7812h ✗(不是 5678h!)
mov ax, w[2]    ; w[2] = 从偏移 A 开始读 2 字节 → ax = 5678h ✓

为什么 w[1] 不是 5678h?因为 [1] 的意思是偏移 1 个字节,不是「第 2 个元素」。

对照上面的内存快照:

  • w[1] = 从偏移 8+1=9 开始读 2 字节 → 偏移9=12h, 偏移A=78h → 读出来是 7812h(小端:低地址是低字节,所以 78 是低位)
  • w[2] = 从偏移 8+2=A 开始读 2 字节 → 偏移A=78h, 偏移B=56h → 读出来是 5678h
           w[0]         w[2]
          /   \        /   \
       34 12 |78 56| BC 9A
偏移:   8  9  A  B   C  D
             \   /
              w[1]
          (跨在中间!)

w[1] 读到的 7812h 实际上跨了两个 word 元素的边界:低字节来自 w[0] 的高字节(12h),高字节来自 w[1] 的低字节(78h)。在 C 语言里这就相当于:

c
short int w[] = {0x1234, 0x5678, 0x9ABC};
short int ax;
ax = *(short int *)((char *)w + 1);  // 跨在 w[0] 和 w[1] 边界上,ax = 0x7812

3.2 为什么 C 语言的数组不会出这种问题

在 C 语言里,w[1] 会自动乘以元素宽度。wshort int * 类型,sizeof(short int) = 2,所以 w[1] 实际上是 *(w + 1*2) = 偏移 2 字节 → 正确读到第二个元素。

但在汇编中,方括号内的数字是裸的字节偏移,编译器不会帮你乘任何东西。 你必须自己算好偏移。

3.3 d[4] 和 d[8]

asm
mov eax, d[0]    ; 从偏移 E 读 4 字节 → eax = 12345678h
mov eax, d[4]    ; 从偏移 E+4=12 读 4 字节 → eax = 89ABCDEFh

d[4] = 偏移 E + 4 = 12,正好是 d[1] 的起始地址。

3.4 跨宽度读取

因为汇编只看字节偏移,你甚至可以用 word ptr 去读原本是 byte 的数据:

asm
mov dx, word ptr abc[0]    ; 从偏移 4 读 2 字节
                           ; = 偏移4=41h, 偏移5=42h
                           ; dx = 4241h(小端:41 是低字节 = 'A',42 是高字节 = 'B')

mov cx, word ptr xyz[2]    ; 从偏移 2 读 2 字节
                           ; = 偏移2=03h, 偏移3=04h
                           ; cx = 0403h

汇编不会阻止你这样做。 它不关心你定义了什么类型,只按你给的宽度修饰(byte ptr / word ptr / dword ptr)去读内存。所以你要自己保证读的地址和宽度是对的。


四、直接寻址 vs 间接寻址

4.1 直接寻址:地址写死在代码里

asm
mov al, abc[0]          ; 等价于 mov al, ds:[abc+0]
mov al, ds:[abc]        ; 也等价
mov al, abc             ; 也等价(省略方括号和 ds:)

这三种形式编译后都是同一条指令:mov al, ds:[4](假设 abc 在偏移地址 4)。方括号里的地址是编译时就能确定的常数——这叫直接寻址。

直接寻址适合访问你事先知道位置的单个变量。但要遍历数组中的每个元素,每次都写 abc[0]abc[1]abc[2]... 太笨了。这时候需要间接寻址

4.2 间接寻址:地址放在寄存器里

把偏移地址放在一个寄存器里,每次循环时改寄存器的值,就能用同一行代码访问不同的数组元素

asm
mov bx, offset abc    ; bx = abc 的偏移地址(相当于 char *p = abc)
mov cx, 4
again:
   mov dl, ds:[bx]    ; 从 ds:bx 处读 1 字节 → dl
                      ; 第一次循环 bx=4, 读到 'A'
                      ; 第二次循环 bx=5, 读到 'B'
                      ; ...
   mov ah, 2
   int 21h            ; 输出这个字符
   add bx, 1          ; bx 后移 1 字节(指针++)
   sub cx, 1
   jnz again

核心:[bx] 里的 bx 是会变的,代码不变但地址会变。 这就是指针的本质。

4.3 只有这 4 个寄存器能放进方括号

asm
[bx]   [bp]   [si]   [di]       ← 只有这四个

ax、cx、dx 都不行。这是 CPU 硬件设计的限制,记住就好。

4.4 间接寻址的四种组合形式

形式例子用途
只有寄存器[bx], [si]简单遍历
寄存器 + 常数[bx+4], [bp-2], [si+10]访问结构体成员(基址 + 固定偏移)
基址 + 变址[bx+si], [bx+di]二维数组、两个可变偏移
基址 + 变址 + 常数[bx+si+10], [bp+di+12]结构体数组(基址 + 元素偏移 + 成员偏移)

第四种形式是最强大的。假设 C 语言里有:

c
struct st { char name[10]; short int score; };
struct st a[10];
// 要访问 a[3].score:
//   基址 = a 的首地址          → bx
//   变址 = 3 * sizeof(st) = 36 → si
//   常数 = offsetof(st, score) = 10
//   所以 a[3].score 的地址 = bx + si + 10
ax = *(short int *)((char *)a + 3 * sizeof(struct st) + 10);

汇编就是:

asm
mov ax, ds:[bx+si+10]

4.5 默认段寄存器规则

方括号里有没有 bp默认段寄存器
不含 bpds(数据段)
含有 bpss(堆栈段)
asm
mov dl, [bx]       ; 默认 ds:[bx]
mov dl, [bp+4]     ; 默认 ss:[bp+4]

如果需要其他段,手动写:mov dl, es:[bx]

4.6 用 bx+si 遍历数组

方式一(只用 bx):

asm
mov bx, offset abc    ; bx  = 基址
mov cx, 4
again:
   mov dl, ds:[bx]    ; 偏移 = bx
   mov ah, 2
   int 21h
   add bx, 1
   loop again          ; 这里用 loop 简化,实际 loop = dec cx + jnz

方式二(bx + si,等价):

asm
mov bx, offset abc    ; bx = 基址
mov si, 0             ; si = 偏移量
mov cx, 4
next:
   mov dl, ds:[bx+si] ; 偏移 = bx + si,效果同上
   mov ah, 2
   int 21h
   add si, 1
   sub cx, 1
   jnz next

两种写法效果一样,但 bx+si 在需要两个独立的可变分量时更方便(比如遍历二维数组的行和列)。


五、文本模式:直接往显存写字符

5.1 为什么不直接用 int 21h

之前我们用 mov ah,2 / int 21h 输出字符。那个方法可以输出字符,但不能控制颜色,也不能控制写在屏幕的哪个位置——只能顺序输出,每次写在光标后面。

要想在屏幕上任意位置显示彩色字符,需要直接往显存写

5.2 显存是什么

电脑启动时,BIOS 在自检(POST)过程中,会把一块主存地址和显卡内存建立映射关系。

说白了:往这段内存地址写数据,屏幕就会变。

  • 文本模式(80列×25行):主存 B800:0000 ~ B800:7FFF = 显存
  • 图形模式(320×200,256色):主存 A000:0000 ~ A000:FFFF = 显存

5.3 文本模式下每个字符占 2 字节

屏幕上一个字符位置需要 2 个字节:

字节含义
偶数偏移 (0, 2, 4, ...)要显示的字符的 ASCII 码
奇数偏移 (1, 3, 5, ...)颜色属性

5.4 屏幕上 (x,y) 位置对应的显存偏移

屏幕 80 列 25 行。要在第 y 行、第 x 列写字符:

那之前已经过了 y 整行,每行 80 个字符,每个字符 2 字节:
  偏移 = y × 80 × 2 + x × 2
       = (y × 80 + x) × 2

理解这个公式:y × 80 算出前面跳过了多少个字符位置,加上 x 得到第几个字符位置,再 × 2 因为每个位置占 2 字节。

5.5 实战:在 (40, 12) 显示彩色 A 和 B

asm
data segment
x dw 40
y dw 12
data ends

code segment
assume cs:code, ds:data
main:
   mov ax, data
   mov ds, ax

   ; === 第一步:算出偏移 ===
   mov ax, [y]        ; ax = 12(行号)
   mov cx, 80
   mul cx             ; dx:ax = ax × cx = 12 × 80 = 960
                      ; mul cx 的结果在 dx:ax 中,但 960 < 65536 所以 dx=0
   add ax, [x]        ; ax = 960 + 40 = 1000(这是第几个字符位置)
   shl ax, 1          ; ax = 1000 × 2 = 2000(这是显存中的字节偏移)
                      ; shl 左移1位 = 乘以2
   mov bx, ax         ; bx = 2000,作为显存偏移

   ; === 第二步:把 ds 切换到显存段,然后写 ===
   mov ax, 0B800h
   mov ds, ax         ; ds 现在指向显存(不再是原来的 data 段)
   mov byte ptr ds:[bx], 'A'       ; 偏移 2000:写字符 'A'
   mov byte ptr ds:[bx+1], 72h     ; 偏移 2001:写颜色属性
   mov byte ptr ds:[bx+2], 'B'     ; 偏移 2002:下一个字符位置,写 'B'
   mov byte ptr ds:[bx+3], 74h     ; 偏移 2003:写颜色属性

   mov ah, 4Ch
   mov al, 0
   int 21h
code ends
end main

逐行解释

  1. mul cxmul无符号乘法mul cx = dx:ax = ax × cx。16 位乘法结果占 32 位(dx 存高位,ax 存低位)。因为 12×80=960 远小于 65536,高位 dx=0,低位 ax=960。

  2. shl ax, 1:左移 1 位 = 乘以 2。shlmul 更快。

  3. mov ax, 0B800h + mov ds, ax:把 ds 从原来的 data 段切换到显存段 B800h。从此 ds:[bx] 不再是你的变量,而是屏幕上的一个字符位置。

  4. byte ptr ds:[bx]:告诉编译器「往这个地址写 1 字节」。因为这里是紧挨着的字符和属性,必须精确到字节。不能写成 word ptr——那样会把字符和属性一起当成一个 16 位值写,结果不是你想要的。

5.6 颜色属性字节怎么算

属性字节的高 4 位是背景色,低 4 位是前景色(字符本身的颜色):

bit:  7  6  5  4  3  2  1  0
      ├─背景色─┤ ├─前景色─┤
      闪烁

颜色编码:

颜色颜色
04
15
2绿6
37

72h 的二进制 = 0111 0010

  • 高 4 位 = 0111 = 7 = 白(背景)
  • 低 4 位 = 0010 = 2 = 绿(前景)
  • 白底绿字

74h 的二进制 = 0111 0100

  • 高 4 位 = 0111 = 7 = 白
  • 低 4 位 = 0100 = 4 = 红
  • 白底红字

六、图形模式:13h 画图

6.1 切换到图形模式

asm
mov ah, 0
mov al, 13h
int 10h      ; BIOS 视频中断:切换到 320×200、256 色图形模式

这是 BIOS 中断 int 10h(不是 DOS 的 int 21h)。ah=0 表示「设置显示模式」,al=13h 表示具体模式编号。

13h 模式的特点:

  • 分辨率:320 像素宽 × 200 像素高
  • 每个像素 1 字节(0~255,共 256 种颜色)
  • 显存映射到 A000:0000

画完记得切回来:

asm
mov ah, 0
mov al, 3
int 10h      ; 切回 80×25 文本模式

6.2 像素位置对应的显存偏移

屏幕上第 y 行、第 x 列的像素:

偏移 = y × 320 + x

理解:每行 320 个像素,每个像素 1 字节。第 y 行前面跳过了 y × 320 个像素,再加上第 x 个位置。

6.3 画 40×40 红色方块,逐行解释

asm
code segment
assume cs:code
main:
   mov ah, 0
   mov al, 13h
   int 10h             ; 切换到图形模式

   mov ax, 0A000h
   mov ds, ax          ; ds 指向图形显存段 A000h
   mov dx, 40          ; dx = 还要画多少行(行计数器)
   mov bx, 0           ; bx = 当前像素在显存中的偏移
   mov al, 4           ; al = 颜色编号(4 = 红色)

next_row:   
   push bx             ; ★ 保存本行的起始偏移,画完这行后要恢复

   mov cx, 40          ; cx = 本行还要画多少个像素
next_dot:   
   mov ds:[bx], al     ; 在偏移 bx 处写 1 字节(颜色 4 = 红色)
                       ; 这一步就在屏幕上画了一个点
   add bx, 1           ; 偏移 +1 = 下一个像素往右移 1 格
   sub cx, 1
   jnz next_dot        ; 本行还没画完 40 个 → 继续画

   pop bx              ; ★ 恢复本行的起始偏移
   add bx, 320         ; 偏移 +320 = 跳到下一行
                       ; 为什么是 320?因为屏幕一行有 320 像素
                       ; bx+320 = 同一列、下一行的第一个像素
   sub dx, 1
   jnz next_row        ; 还没画完 40 行 → 继续画

   mov ah, 0
   mov al, 3
   int 10h             ; 切回文本模式

   mov ah, 4Ch
   mov al, 0
   int 21h
code ends
end main

关键点理解

为什么需要 push bx / pop bx? 画完一行 40 个像素后,bx 已经变成了 本行起点 + 40。但下一行需要从 本行起点 + 320 开始(换行)。如果不用栈保存本行起点,画完后你就找不回起点了。

为什么是 +320 而不是 +40? 一行有 320 个像素。从当前行起点向下走一行,偏移量增加 320。比如从 (0,0) 画到 (0,39) 后,bx=40;要画 (1,0),需要 bx = 0 + 320 = 320;要画 (2,0),需要 bx = 0 + 640。

第 0 行: 偏移   0  1  2 ...  39  ← 画了 40 个点,bx 变成 40
第 1 行: 偏移 320 321 ... 359   ← 需要从 320 开始
第 2 行: 偏移 640 641 ... 679   ← 需要从 640 开始
...

push/pop 的机制:

  1. 画一行前:push bx 把起点(比如 0)压入栈
  2. 画完一行:bx 变成了 40
  3. pop bx 恢复 → bx 变回 0
  4. add bx, 320 → bx = 320(下一行的起点)
  5. 继续画下一行

6.4 扩展到任意位置任意大小的矩形

从 (x0, y0) 开始画宽 W 高 H 的矩形:

起始偏移 = y0 × 320 + x0

把上面代码改成:

  • mov bx, 起始偏移(而不是 0)
  • mov dx, H(而不是 40)
  • mov cx, W(而不是 40)

其余逻辑完全一样。


七、易错点总结

  1. w[1] 是「偏移 1 字节」,不是「第 2 个 word 元素」——这是汇编跟 C 语言最大的思维差异
  2. 小端规则导致多字节值的低字节在低地址,读 word/dword 时高位低位容易弄反
  3. 间接寻址只能用 bx/bp/si/di 四个寄存器,ax/cx/dx 不行
  4. 含 bp 时默认段是 ss(栈),不是 ds(数据)——忘记这一点会导致读到错误的内存
  5. 写显存前要把 ds 改成 B800h 或 A000h,否则写的是普通内存不是屏幕
  6. 文本模式下字符和属性是紧挨着的两个字节,忘记写属性会导致字符不显示或用上次残留的颜色
  7. 图形模式画完一定要 int 10h 切回文本模式,否则屏幕上全是花点