汇编语言:使用 BIOS 进行外设读写

键盘输入的处理

键盘是最基础的人机交互设备,其输入过程涉及硬件中断、BIOS 中断例程、系统数据区的协同。

int 9 例程处理键盘输入

当用户按下或松开一个键时,键盘处理器会向 60H 端口发送一个扫描码(Scan Code),然后触发 9 号中断(IRQ1),CPU 响应中断后执行 BIOS 提供的 int 9 中断例程。int 9 中断例程的功能有:

int 9 例程功能 说明
读取扫描码 从 60H 端口读取按键的扫描码
处理切换键状态 检测 Shift、Ctrl、Alt 等切换键的状态,并更新内存中特定的状态字节
写入键盘缓冲区 根据扫描码和当前切换键状态,将对应的 ASCII 码与扫描码配对,写入键盘缓冲区

BIOS 在内存 0040:001A~0030 区域维护了一个键盘缓冲区,从逻辑上看它由 16 个字单元组成,最多可存储 15 个按键信息(空出一个位置用于区分满与空)。缓冲区中的每个字单元用高位字节存扫描码、低位字节存 ASCII 码,例如按下 A 键对应扫描码 1E,同时 61 是小写 a 的 ASCII 码,所以写入的内容是 1E61H。int 9 中断例程在每次键盘中断时向缓冲区生产数据,如 DOS 的 int 21h 功能调用,或 BIOS 的 int 16h等用户程序从缓冲区“消费”数据。

BIOS 通过维护位于 0040:0017 的状态字节来记录 Shift、Ctrl、Alt、Caps Lock 等切换键的当前状态。键盘缓冲区中存储的是通码,即按下键时产生的扫描码。松开键时产生的断码主要用于更新状态字节,通常不会进入键盘缓冲区。例如以下按键序列A、B、C、D、E、左 Shift+A、A,缓冲区的变化如下:

操作步骤 按键动作 状态字节 (0040:17) 键盘缓冲区内容 (16字) 说明
1 按下 A Shift=0 1E61 无 Shift,存入小写 a (61H)
2 按下 B Shift=0 1E61 3062 存入小写 b (62H)
3 按下 C、D、E Shift=0 1E61 3062 2E63 2064 1265 依次存入小写 c(63H)、d(64H)、e(65H)
4 按下左 Shift Shift=1 1E61 3062 2E63 2064 1265 仅更新状态字节,不写入缓冲区
5 按下 A Shift=1 ... 1265 1E41 检测到 Shift 按下,存入大写 A (41H)
6 松开左 Shift Shift=0 ... 1265 1E41 仅更新状态字节,不写入缓冲区
7 按下 A Shift=0 ... 1E41 1E61 Shift 已松开,恢复存入小写 a (61H)

现代操作系统(如 Linux、Windows)在启动初期同样依赖 BIOS 或类似固件(UEFI)的键盘中断处理。系统进入保护模式后操作系统会接管 int 9 中断,构建键盘驱动和输入事件队列,如 Linux 的 input_event。8086 的键盘缓冲区使用的环形队列结构,也是现代操作系统内核中普遍使用的环形缓冲区(Ring Buffer)的雏形。

int 16h 例程读取键盘缓冲区

BIOS 提供的 int 16h 中断例程可以被程序员调用,其中 0 号功能用于从键盘缓冲区读取数据。调用方式如以下指令所示,执行后 CPU 将缓冲区中第一个字单元的内容读出,用 AH 存放扫描码,AL 存放 ASCII 码。同时该数据会从缓冲区中删除,后续的数据依次前移。

mov ah, 0
int 16h

例如当缓冲区中存放 1E61 3062 2E63 2064 1265 1E41 1E61 数据时,使用 int 16 例程进行读取的过程如下:

读取次序 调用 int 16h 前缓冲区内容 读出数据 (AH=扫描码, AL=ASCII) 调用后缓冲区内容
第 1 次 1E61 3062 2E63 2064 1265 1E41 1E61 1E / 61 (a) 3062 2E63 2064 1265 1E41 1E61
第 2 次 3062 2E63 2064 1265 1E41 1E61 30 / 62 (b) 2E63 2064 1265 1E41 1E61
第 3 次 2E63 2064 1265 1E41 1E61 2E / 63 (c) 2064 1265 1E41 1E61
第 4~7 次 ... ... 1E61
第 8 次 1E61 1E / 61 (a) 空

第 8 次读取完毕后缓冲区将清空,此时如果再执行 mov ah, 0 / int 16h,int 16h 例程会检测到缓冲区为空,于是进入循环等待状态。直到用户再次按下任意键,int 9 中断例程将该键数据写入缓冲区,int 16h 才会结束等待,读出数据并清空缓冲区。int 9h 和 int 16h 两个例程的功能总结如下:

BIOS 中断例程 功能
int 9 异步响应硬件,只要有键按下就写入缓冲区。
int 16h 同步响应程序,只有程序主动调用时才读取缓冲区,若为空则阻塞等待。

int 16h 在缓冲区为空时会进入循环等待,如果此时 CPU 不响应外部可屏蔽中断(即 IF=0),那么负责向缓冲区写数据的 int 9 中断例程将永远无法被执行。键盘数据无法写入,程序将陷入死循环。为了保证在等待键盘输入期间 CPU 能够响应键盘硬件中断(int 9),int 16h 例程必须在循环等待前或循环中显式地设置 IF=1(使用 sti 指令)。所以 BIOS 中断例程在设计时考虑了令阻塞型服务例程必须开放中断,以避免系统死锁。

以下下程序在启动后阻塞等待键盘输入,当用户按下 r、g 或 b 时,将屏幕上所有字符的前景色分别修改为红色、绿色或蓝色。程序开头执行了 mov ah, 1,此时 AH = 0000 0001b,接下来输入不同按键时执行的动作如下:

  • 输入 'r'(红色)时:程序跳转到 red 连续执行两次 shl ah, 1,左移一位相当于乘 2,AH 变为 0000 0100b,对应红色。
  • 输入 'g'(绿色)时:程序跳转到 green 执行一次 shl ah, 1,AH 变为 0000 0010b,对应绿色。
  • 输入 'b'(蓝色)时:程序直接执行 blue 处的代码,AH 保持 0000 0001b,对应蓝色。
assume cs:code

code segment
start:
    mov ah, 0
    int 16h                    ; 等待键盘输入,AL = ASCII码

    mov ah, 1                  ; AH置初值,用于后续颜色位计算
    cmp al, 'r'
    je red
    cmp al, 'g'
    je green
    cmp al, 'b'
    je blue
    jmp short sret             ; 输入其他键则直接退出

red:
    shl ah, 1                  ; AH = 0010b
green:
    shl ah, 1                  ; AH = 0100b (若为red) 或 AH = 0010b (若为green)
blue:                          ; AH = 0001b (若为blue)
    mov bx, 0b800h
    mov es, bx
    mov bx, 1                  ; 显存属性字节从奇地址开始
    mov cx, 2000               ; 80*25=2000个字符
s:
    and byte ptr es:[bx], 11111000b ; 清除原有的RGB位(0,1,2位)
    or byte ptr es:[bx], ah    ; 设置新的颜色位
    add bx, 2
    loop s

sret:
    mov ax, 4c00h
    int 21h
code ends
end start

随后,and 指令清除显存中原有的 RGB 位(低 3 位),or 指令将 AH 中设置好的颜色位写入显存。程序运行后,将对用户的按键执行对应的操作。
image

在现代图形界面操作系统(如 Windows、Linux X Window)中,int 16h 的阻塞读取模式已经被抛弃。现代程序不再主动轮询键盘,而是采用事件驱动模型。键盘驱动在 int 9 层面捕获按键后,将其封装为一个键盘事件放入系统消息队列,应用程序通过窗口过程(Callback)被动接收事件。

字符串的输入

如 DOS 命令行的实际场景中,用户需要处理的是字符串,一个字符串输入程序需要具备以下 3 个功能:

  1. 用户敲键的同时,字符要实时出现在屏幕上。
  2. 按下 Enter 键后,输入结束,字符串以 0 作为结尾符。
  3. 能够删除最后一个输入的字符。

为了实现这些功能,程序不能简单地把字符随便扔进内存。对于如下字符串的输入与删除过程,可见字符串输入具备后进先出(LIFO)的行为模式,因此可以用栈来管理字符串的存储空间。

空 -> 输入'a' -> "a" -> 输入'b' -> "ab" -> 输入'e' -> "abe" -> 删除 -> "ab" -> 删除 -> "a" -> 删除 -> ""

为了简化逻辑,以下代码将字符栈的 3 种操作封装为子程序 charstack,通过 ah 传递功能号:

  • ah = 0:入栈,al 存放待入栈的字符。
  • ah = 1:出栈,al 返回弹出的字符。
  • ah = 2:显示,dh/dl 指定屏幕显示的起始行/列。

程序通过直接定址表 table dw charpush, charpop, charshow 替代 cmp/je 分支。在 charshow 循环中,因为退格键只是减少了 top 指针,并没有擦除显存上的旧字符。如果不清空,输入 abc 再退格两次,屏幕上仍会残留 c。通过 mov byte ptr es:[di+2], ' ' 写入空格,可以保证显示内容与栈内数据一致。mov byte ptr es:[di], '_' 在字符串末尾显示一个下划线,模拟命令行光标。

; charstack 子程序,提供字符栈的入栈、出栈和显示功能
; 参数:
;   ah = 功能号 (0=入栈, 1=出栈, 2=显示)
;   ds:si = 字符栈空间 (strbuf)
;   0号功能: al = 入栈字符
;   2号功能: dh = 行号, dl = 列号
charstack:
    push bx
    push dx
    push di
    push es

    cmp ah, 2
    ja cs_ret                 ; 若功能号无效(>2),直接返回
    mov bl, ah
    mov bh, 0
    add bx, bx                ; 功能号 * 2,转化为查表偏移
    jmp word ptr cs:table[bx] ; 查直接定址表,跳转到对应子程序

; 入栈
charpush:
    mov bx, top
    mov [si][bx], al        ; 字符存入 ds:si[top]
    inc top                 ; 栈顶指针上移
    jmp cs_ret

; 出栈
charpop:
    cmp top, 0
    je cs_ret               ; 若栈空,直接返回
    dec top                 ; 栈顶指针下移(逻辑删除,不擦除内存数据)
    mov bx, top
    mov al, [si][bx]        ; 将弹出的字符送入 al
    jmp cs_ret

; 显示栈内所有字符
charshow:
    mov bx, 0b800h
    mov es, bx              ; ES 指向显存段地址
    mov al, 160
    mul dh                  ; 计算行偏移:行号 * 160
    mov di, ax
    mov dh, 0
    add di, dx              ; 加上列偏移,DI 指向显存起始位置

    mov bx, 0               ; BX 作为栈内字符索引,从 0 开始
    cmp bx, top
    je cs_empty             ; 若栈空,直接显示光标

cs_showloop:
    mov al, [si][bx]        ; 取栈中字符
    mov ah, 02h             ; 属性字节,设置绿色前景
    mov es:[di], ax         ; 向显存写入数据
    mov byte ptr es:[di+2], ' ' 
    mov byte ptr es:[di+3], 02h ; 清除下一个位置的残留字符
    inc bx
    add di, 2
    cmp bx, top
    jne cs_showloop

cs_empty:
    mov al, '_'
    mov ah, 02h             ; 光标也设置为绿色
    mov es:[di], ax         ; 在字符串末尾显示光标

cs_ret:
    pop es
    pop di
    pop dx
    pop bx
    ret

; 直接定址表,存放三个子功能的入口地址
table dw charpush, charpop, charshow

code ends
end start

getstr 子程序负责不断调用 int 16h 读取键盘,并根据按键类型分发任务。该程序用 int 16h 获取扫描码和 ASCII 码。ASCII 码小于 20H 的是控制字符(如 Ctrl 组合键),使用 cmp al, 20h 忽略过滤非打印字符,防止乱码。如果是可打印字符,调用 charstack(0) 入栈并显示。如果扫描码是 0Eh(退格键),调用 charstack(1) 出栈,然后重新显示。如果扫描码是 1Ch(回车键),将 0 入栈作为字符串结尾符,然后返回主程序。

assume cs:code, ds:data, ss:stack

stack segment
    dw 128 dup (0)
stack ends

data segment
    strbuf db 128 dup (0)   ; 字符栈的存储空间,实际作为字节数组使用
    top    dw 0             ; 栈顶指针(偏移量),0 代表栈空
data ends

code segment
start:
    mov ax, stack
    mov ss, ax
    mov sp, 128             ; 初始化栈段,用于 call 和 push/pop

    mov ax, data
    mov ds, ax

    mov si, offset strbuf   ; ds:si 指向字符栈的存储空间
    mov dh, 12
    mov dl, 30
    call getstr             ; 调用字符串输入子程序

    mov ax, 4c00h
    int 21h

; getstr 子程序,轮询键盘,根据按键类型分发到入栈、出栈或结束逻辑
; 参数:ds:si=缓冲区, dh=行号, dl=列号
; 返回:缓冲区中以 0 结尾的字符串
getstr:
    push ax
    push bx
    push cx
    push dx
    push si
    mov top, 0              ; 初始化栈顶指针,清空旧数据

getstrs:
    mov ah, 0
    int 16h                 ; BIOS 中断读取键盘,AL=ASCII, AH=扫描码

    cmp al, 20h
    jb nochar               ; ASCII < 20h 为控制键,不进入字符栈
    mov ah, 0               ; 功能号 0 字符入栈
    call charstack
    mov ah, 2               ; 功能号 2 刷新显示
    call charstack
    jmp getstrs

nochar:
    cmp ah, 0eh             ; 比较扫描码是否为退格键,Backspace: 0Eh
    je backspace
    cmp ah, 1ch             ; 比较扫描码是否为回车键,Enter: 1Ch
    je enter
    jmp getstrs             ; 其他控制键忽略,如Alt、Ctrl

backspace:
    mov ah, 1               ; 功能号 1 字符出栈
    call charstack
    mov ah, 2               ; 刷新显示以清除屏幕上的最后一个字符
    call charstack
    jmp getstrs

enter:
    mov al, 0
    mov ah, 0               ; 功能号 0 将 0 写入栈顶,作为字符串结尾符
    call charstack
    mov ah, 2               ; 最后一次刷新显示
    call charstack

    ; 恢复寄存器并返回
    pop si
    pop dx
    pop cx
    pop bx
    pop ax
    ret

编译链接后运行,输入字母数字后将实时显示在屏幕中,按 Backspace​ 将删除最后一个字符。按 Enter​ 结束输入,程序返回 DOS,此时 strbuf中就是你要的字符串。
image

int 13h 例程对磁盘读写

磁盘的实际访问由磁盘控制器进行,可以通过控制磁盘控制器来访问磁盘。在读写扇区的时候要给出面号、磁道号和扇区号,面号和磁道号从 0 开始,而扇区号从 1 开始。以 3.5 英寸软盘为例,其存储结构如下表所示,因此其标准容量为:\(2 \text{面} \times 80 \text{磁道} \times 18 \text{扇区} \times 512 \text{字节} = 1,474,560 \text{字节} \approx 1.44 \text{MB}\)。

维度 描述 取值范围 备注
磁头 (Head) 对应盘面 0~1 0 表示下表面,1 表示上表面
磁道 (Track/Cylinder) 半径相同的同心圆 0~79 0 表示最外圈
扇区 (Sector) 磁道上的弧段 1~18 注意:扇区号从 1 开始

BIOS 将复杂的磁盘控制器时序封装为 int 13h 中断例程,它只能以扇区(512B)为最小粒度进行读写,无法单独修改某个字节。int 13h 最常用的两个功能号是 2(读) 和 3(写)。读取扇区时需指定 ah=2,功能是将指定物理地址的扇区数据加载到内存,读取成功时 ah=0、al 为实际读取的扇区数,失败时 ah 为状态码。

mov ax, 0
mov es, ax
mov bx, 200h        ; es:bx,数据缓冲区 (0:200h)
mov al, 1           ; 读 1 个扇区
mov ch, 0           ; 磁道号 0
mov cl, 1           ; 扇区号 1,从1开始
mov dl, 0           ; 驱动器号 0=软驱A
mov dh, 0           ; 磁头号 0,0面
mov ah, 2           ; 功能号 2=读扇区
int 13h

当需要写入扇区时需指定 ah=3,功能是将内存中的数据写入指定物理地址的扇区,参数与读操作一致。驱动器号映射方面,软盘 0 = A盘,1 = B盘,硬盘 80h = C盘,81h = D盘。

mov ax, 0
mov es, ax
mov bx, 200h        ; es:bx -> 数据源地址
mov al, 1           ; 写 1 个扇区
mov ch, 0           ; 磁道号 0
mov cl, 1           ; 扇区号 1
mov dl, 0           ; 驱动器号 0=软驱A
mov dh, 0           ; 磁头号 0
mov ah, 3           ; 功能号 3=写扇区
int 13h

例如编程将当前 80x25 彩色文本模式下的屏幕内容保存到软盘,一屏字符需要占用 \(80 \times 25 \times 2 = 4000\) 字节。由于每个扇区 512 字节,\(4000 \div 512 = 7.8125\),因此需要跨越 8 个扇区,选用 0 面 0 道的 1~8 扇区来存储。

该程序的代码如下。mov ax, 0b800h / mov es, ax 将 ES 指向显存段,BIOS 的 int 13h 使用 es:bx 作为数据指针,因此直接复用该寄存器指向显存,无需中间内存拷贝。虽然屏幕数据只有 4000 字节,但程序请求写入 8 个扇区(4096 字节)。int 13h 会连续写入,多出来的 96 字节是显存中下一屏的无用数据(或边界数据)。ch=0, cl=1, dh=0 几条指令用于定位到 0 面 0 道 1 扇区,连续写入 8 个扇区。

assume cs:code

code segment
start:
    ; 设置显存源地址,这里直接用 ES:BX 指向显存
    mov ax, 0b800h
    mov es, ax
    mov bx, 0               ; ES:BX = 0B800:0,显存起始

    ; 设置 int 13h 写扇区参数
    mov al, 8               ; 写入 8 个扇区
    mov ch, 0               ; 磁道号 0
    mov cl, 1               ; 起始扇区号 1
    mov dl, 0               ; 驱动器号 0 (软驱 A)
    mov dh, 0               ; 磁头号 0 (0面)
    mov ah, 3               ; 功能号 3 = 写扇区
    int 13h

    mov ax, 4c00h
    int 21h
code ends
end start

注意 0 面 0 道 1 扇区是引导扇区 (Boot Sector),如果向该扇区写入错误数据,软盘或硬盘将无法启动。这将导致现有操作系统将无法识别文件系统,必须重新格式化才能使用。dl=80h 对应 C 盘,对硬盘进行 int 13h 写操作极有可能破坏分区表或操作系统文件,导致数据丢失且难以恢复。所以此类实验必须在 DOSBox 虚拟环境中配合空白映像文件进行,不能在物理机或包含重要数据的虚拟机中直接运行。

int 13h 的 CHS 寻址模式是早期 BIOS 使用的机制,随着硬盘容量突破 8GB,CHS 模式因寻址空间不足被淘汰。取而代之的是 LBA(逻辑块寻址) 模式的 int 13h 扩展,功能号 ah=42h 读 / ah=43h 写。现代操作系统(Windows/Linux)在启动初期可能短暂使用 int 13h 加载内核,一旦进入保护模式,操作系统就不会 BIOS 的中断服务,直接通过磁盘控制器驱动程序(如 AHCI 或 NVMe 驱动)与硬件交互,以获得更高的性能和异步处理能力。

实验 17

int 13h 要求程序员提供面号、磁道号和扇区号这三种物理参数,这种寻址方式不仅计算繁琐,而且容易出错。实验的目标是封装一个新的 int 7ch 中断例程,通过逻辑扇区号对软盘进行读写。对 3.5 英寸软盘的所有扇区进行统一编号,编号从 0 开始到 2879 结束。映射关系如下:

物理位置 逻辑扇区号
0 面 0 道 1 扇区 0
0 面 0 道 2 扇区 1
... ...
0 面 0 道 18 扇区 17
0 面 1 道 1 扇区 18
... ...
1 面 79 道 18 扇区 2879

由此可得物理地址到逻辑地址的转换公式为:

\[\text{逻辑扇区号} = (\text{面号} \times 80 + \text{磁道号}) \times 18 + \text{扇区号} - 1 \]

相应地,逻辑地址到物理地址的反向解析算法为:

物理地址 计算方式
面号 \(\text{int}(\frac{\text{逻辑扇区号}}{1440})\)
磁道号 \(\text{int}(\frac{\text{rem}(\text{逻辑扇区号}, 1440)}{18})\)
扇区号 \(\text{rem}(\text{rem}(\text{逻辑扇区号}, 1440), 18) + 1\)

新的 int 7ch 中断例程通过 ah 寄存器设置功能号指定不同的功能,功能 0 为读扇区,功能 1 为写扇区;dx 为要读写的逻辑扇区号,范围是 0~2879,es:bx 指向存储读出数据或写入数据的内存区。代码实现如下,首先检查 ah 是否为合法功能号,检查 dx 是否超出最大逻辑扇区号。然后使用直接定址表将自定义功能号(0/1)映射为 BIOS int 13h 的功能号(2/3),接着调用子程序 set,根据逻辑扇区号计算出 CHS 物理参数,最后调用 int 13h 完成实际的磁盘读写。

assume cs:code, ds:data, ss:stack

stack segment
    dw 128 dup (0)
stack ends

data segment
    db 'welcome to masm!'
data ends

code segment
; int 7ch 中断例程
lp7c:
    jmp short run
; 直接定址表:ah=0(读)->2, ah=1(写)->3
table db 2, 3

run:
    ; 保存寄存器状态
    push ax
    push cx
    push dx

    ; 功能号合法性检查 (0=读, 1=写)
    cmp ah, 1
    ja sret
    ; 逻辑扇区号范围检查 (0~2879)
    cmp dx, 2879
    ja sret

    push bx
    ; 根据 ah 查表得到 BIOS int 13h 的功能号
    mov bl, ah
    mov bh, 0
    mov ah, table[bx]

    mov al, 1               ; 读写 1 个扇区
    mov dl, 0               ; 驱动器 A
    call set                ; 计算 CHS 参数
    pop bx                  ; es:bx 指向内存缓冲区
    int 13h                 ; 调用 BIOS 磁盘中断

sret:
    ; 恢复寄存器状态
    pop dx
    pop cx
    pop ax
    iret

; 将逻辑扇区号转换为 CHS
; 输入: dx = 逻辑扇区号
; 输出: ch=磁道号, cl=扇区号, dh=磁头号
set:
    push ax
    push bx

    mov ax, dx              ; ax = 逻辑扇区号
    mov dx, 0               ; 准备 32 位被除数 (dx:ax)
    mov bx, 1440
    div bx                  ; ax=面号, dx=余数
    push ax                 ; 保存面号

    mov ax, dx              ; ax = 余数
    mov dx, 0
    mov bx, 18
    div bx                  ; ax=磁道号, dx=扇区内偏移
    mov ch, al              ; ch = 磁道号
    add dx, 1               ; 扇区号从 1 开始
    mov cl, dl              ; cl = 扇区号

    pop ax                  ; 恢复面号
    mov dh, al              ; dh = 磁头号

    pop bx
    pop ax
    ret

lp7cend:
    nop

; 安装
start:
    mov ax, stack
    mov ss, ax
    mov sp, 128

    ; 复制 int 7ch 中断例程到 0:200h
    mov ax, cs
    mov ds, ax
    mov si, offset lp7c
    mov ax, 0
    mov es, ax
    mov di, 200h
    mov cx, offset lp7cend - offset lp7c
    cld
    rep movsb

    ; 设置中断向量表: 7ch * 4 = 1F0h
    mov ax, 0
    mov es, ax
    cli
    mov word ptr es:[7ch*4], 200h
    mov word ptr es:[7ch*4+2], 0
    sti

    ; 将 data 中的数据写入逻辑扇区 1442
    mov ax, data
    mov es, ax
    mov bx, 0
    mov dx, 1442
    mov ah, 1               ; 功能 1 = 写
    int 7ch

    ; 从逻辑扇区 1442 读回
    mov ah, 0               ; 功能 0 = 读
    int 7ch

    mov ax, 4c00h
    int 21h

code ends
end start

参考资料

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

posted @ 2026-08-27 22:31  乌漆WhiteMoon  阅读(7)  评论(0)    收藏  举报