第7讲:数组、寻址方式与显存操作
一、数据库:db / dw / dd 是什么
汇编用三个伪指令定义不同宽度的数据:
.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 语言类型 | 宽度 |
|---|---|---|
db | char | 1 字节 (8 位) |
dw | short int | 2 字节 (16 位) |
dd | long int | 4 字节 (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] 到底读到什么
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 语言里这就相当于:
short int w[] = {0x1234, 0x5678, 0x9ABC};
short int ax;
ax = *(short int *)((char *)w + 1); // 跨在 w[0] 和 w[1] 边界上,ax = 0x78123.2 为什么 C 语言的数组不会出这种问题
在 C 语言里,w[1] 会自动乘以元素宽度。w 是 short int * 类型,sizeof(short int) = 2,所以 w[1] 实际上是 *(w + 1*2) = 偏移 2 字节 → 正确读到第二个元素。
但在汇编中,方括号内的数字是裸的字节偏移,编译器不会帮你乘任何东西。 你必须自己算好偏移。
3.3 d[4] 和 d[8]
mov eax, d[0] ; 从偏移 E 读 4 字节 → eax = 12345678h
mov eax, d[4] ; 从偏移 E+4=12 读 4 字节 → eax = 89ABCDEFhd[4] = 偏移 E + 4 = 12,正好是 d[1] 的起始地址。
3.4 跨宽度读取
因为汇编只看字节偏移,你甚至可以用 word ptr 去读原本是 byte 的数据:
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 直接寻址:地址写死在代码里
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 间接寻址:地址放在寄存器里
把偏移地址放在一个寄存器里,每次循环时改寄存器的值,就能用同一行代码访问不同的数组元素。
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 个寄存器能放进方括号
[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 语言里有:
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);汇编就是:
mov ax, ds:[bx+si+10]4.5 默认段寄存器规则
| 方括号里有没有 bp | 默认段寄存器 |
|---|---|
| 不含 bp | ds(数据段) |
| 含有 bp | ss(堆栈段) |
mov dl, [bx] ; 默认 ds:[bx]
mov dl, [bp+4] ; 默认 ss:[bp+4]如果需要其他段,手动写:mov dl, es:[bx]。
4.6 用 bx+si 遍历数组
方式一(只用 bx):
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,等价):
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
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逐行解释:
mul cx:mul是无符号乘法。mul cx=dx:ax = ax × cx。16 位乘法结果占 32 位(dx 存高位,ax 存低位)。因为 12×80=960 远小于 65536,高位 dx=0,低位 ax=960。shl ax, 1:左移 1 位 = 乘以 2。shl比mul更快。mov ax, 0B800h+mov ds, ax:把 ds 从原来的 data 段切换到显存段 B800h。从此 ds:[bx] 不再是你的变量,而是屏幕上的一个字符位置。byte ptr ds:[bx]:告诉编译器「往这个地址写 1 字节」。因为这里是紧挨着的字符和属性,必须精确到字节。不能写成word ptr——那样会把字符和属性一起当成一个 16 位值写,结果不是你想要的。
5.6 颜色属性字节怎么算
属性字节的高 4 位是背景色,低 4 位是前景色(字符本身的颜色):
bit: 7 6 5 4 3 2 1 0
├─背景色─┤ ├─前景色─┤
闪烁颜色编码:
| 值 | 颜色 | 值 | 颜色 |
|---|---|---|---|
| 0 | 黑 | 4 | 红 |
| 1 | 蓝 | 5 | 紫 |
| 2 | 绿 | 6 | 棕 |
| 3 | 青 | 7 | 白 |
72h 的二进制 = 0111 0010:
- 高 4 位 =
0111= 7 = 白(背景) - 低 4 位 =
0010= 2 = 绿(前景) - → 白底绿字
74h 的二进制 = 0111 0100:
- 高 4 位 =
0111= 7 = 白 - 低 4 位 =
0100= 4 = 红 - → 白底红字
六、图形模式:13h 画图
6.1 切换到图形模式
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
画完记得切回来:
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 红色方块,逐行解释
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 的机制:
- 画一行前:
push bx把起点(比如 0)压入栈 - 画完一行:bx 变成了 40
pop bx恢复 → bx 变回 0add bx, 320→ bx = 320(下一行的起点)- 继续画下一行
6.4 扩展到任意位置任意大小的矩形
从 (x0, y0) 开始画宽 W 高 H 的矩形:
起始偏移 = y0 × 320 + x0把上面代码改成:
mov bx, 起始偏移(而不是 0)mov dx, H(而不是 40)mov cx, W(而不是 40)
其余逻辑完全一样。
七、易错点总结
w[1]是「偏移 1 字节」,不是「第 2 个 word 元素」——这是汇编跟 C 语言最大的思维差异- 小端规则导致多字节值的低字节在低地址,读 word/dword 时高位低位容易弄反
- 间接寻址只能用 bx/bp/si/di 四个寄存器,ax/cx/dx 不行
- 含 bp 时默认段是 ss(栈),不是 ds(数据)——忘记这一点会导致读到错误的内存
- 写显存前要把 ds 改成 B800h 或 A000h,否则写的是普通内存不是屏幕
- 文本模式下字符和属性是紧挨着的两个字节,忘记写属性会导致字符不显示或用上次残留的颜色
- 图形模式画完一定要
int 10h切回文本模式,否则屏幕上全是花点