汇编语言:端口 I/O

逻辑左移和右移

shl 和 shr 分别对应逻辑左移和逻辑右移,移出的最后一位会存入 CF 标志位,它们的运行效果如下表所示:

指令 移动方向 移出位去向 空出位补
shl 向左 写入 CF 0
shr 向右 写入 CF 0

CPU 可以使用这两条指令实现乘除运算:

  • shl 左移一位:相当于数值乘以 2,如 00000001b → 00000010b,即 1 → 2。
  • shr 右移一位:相当于数值除以 2,如 00000100b → 00000010b,即 4 → 2。

由于硬件层面的约束,8086 CPU 对移位指令的移位次数有限。因为指令编码格式决定了移位计数只能占 1 位(0 或 1),大于 1 时只能通过寄存器传递,具体要求如下:

移位次数 操作 样例
移位 1 次 可以直接写在指令中 shl al, 1
移位大于 1 次 须将次数放入 CL 寄存器中 mov cl, 3 / shl al, cl

例如用加法和移位计算 (ax)*10,由于 \((ax) \times 10 = (ax) \times 2 + (ax) \times 8\),2 和 8 都是都是 2 的幂,因此相比于 mul 指令(在 8086 中较慢),使用 shl 实现更高效。

实现的代码如下,首先将 AX 左移 1 位得到 (ax)*2,然后将 ax 的值拷贝到 bx,并令左移 2 位得到 (ax)*8。最后将两者相加,得到计算结果。

assume cs:code

code segment
start:
    mov ax, 2          ; 示例:ax = 2

    shl ax, 1          ; ax = ax * 2
    mov bx, ax         ; bx 拷贝 ax 的值
    mov cl, 2          ; cl 设置移位次数
    shl bx, cl          ; bx = ax * 2 * 4
    add ax, bx         ; ax = (ax*2) + (ax*8) = ax*10

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

以 bx = 2 为例,shl ax, 1 得到 ax = 4h,shl bx, 2 得到 cx = 10h,add ax, bx 得到 ax = 14h。
image

shl 和 shr 在现代 x86 架构中依然是基础指令,但编译器会将简单的乘法(如 *10)自动转换为更高效的指令序列,可能包括 lea 指令或 imul。在嵌入式系统或内核底层代码中,手动控制移位在处理位掩码(bitmask)、硬件寄存器配置或加密算法时仍常用。

端口的读写

CPU 能直接交互的数据来源只有三个:内部寄存器、内存单元和端口。其中,虽然各种存储器、接口卡芯片、主板控制芯片内部的寄存器的物理位置是分散的,但从 CPU 的角度看,它们都被统一抽象为端口。各种芯片内部的寄存器被统一编址,形成一个独立的端口地址空间,与内存地址空间平行存在。

端口是专用资源,因此不能用操作内存的 mov、push、pop 指令来读写。CPU 提供了两条专用的指令:

端口访问指令 功能
in 读指令,从端口读取数据
out 写指令,向端口写入数据

系统中端口地址为 16 位,范围是 0~65535。在数据宽度方面,8 位端口用 al 读写,16 位端口用 ax 读写。不同长度的端口号的传递方式如下:

端口号 传递方式
0~255 可以直接写在指令中,如 in al, 20h
256~65535 需要存放在 dx 寄存器中,如 mov dx, 3f8h / in al, dx

例如读写 8 位端口,该端口位于 0~255 号,对应的指令如下:

in al, 20h      ; 从 20h 号端口读入一个字节到 AL
mov al, 'a'
out 20h, al     ; 将 'a' 写入 20h 号端口

例如读写 16 位端口或高号端口,它们位于 256~65535,对应的指令如下:

mov dx, 3f8h    ; 将端口号 3F8H(串口 1 的基址)放入 DX
in al, dx       ; 从 3F8H 端口读入一个字节
mov al, 55h
out dx, al      ; 向 3F8H 端口写入 55H

读写端口和读写内存在操作上都通过总线传输地址和数据,但控制线发出的命令类型不同,决定了数据是流向内存还是流向端口。例如 in al, 60h 指令的功能是从 60h 号端口读入一个字节,执行时与总线相关的操作如下:

  1. CPU 通过地址线将地址信息 60h 发出;
  2. CPU 通过控制线发出端口读命令,选中端口所在的芯片,并通知它将要从中读取数据;
  3. 端口所在的芯片将 60h 端口中的数据通过数据线送入CPU。

CMOS RAM 芯片

CMOS RAM 是一块用于存储实时时钟(RTC)和系统配置信息的芯片,它具备几个特性:

CMOS RAM 芯片特性 说明
靠电池供电 这使得关机后芯片的数据不会丢失,因此能保存系统时间和 BIOS 配置。
地址空间独立 它有 128 个字节,但不占用内存地址空间,只能通过端口访问。

CMOS RAM 的端口

CMOS RAM 有两个端口,如下表所示。对 CMOS RAM 芯片进行读写时,需要向 70h 写入目标单元的地址,然后从 71h 读取数据或向 71h 写入数据。

端口 存放内容
70h(地址端口) 存放要访问的 CMOS RAM 单元地址
71h(数据端口) 存放读取的数据或要写入的数据

以从 CMOS 的 2 号单元读取一个字节为例,该单元是 CMOS 时间相关字段。该操作需要先向 70h 写入地址 2,再从 71h 读取数据到 AL,程序如下所示:

assume cs:code

code segment
start:
    mov al, 2
    out 70h, al        ; 向地址端口写入要访问的单元号
    in al, 71h         ; 从数据端口读取 2 号单元的内容

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

每次操作 70h 后,71h 会自动指向刚刚指定的 CMOS 单元,这是硬件自动完成的寻址。执行后,AL 寄存器中保存的就是 CMOS 2 号单元的值。
image

以向 CMOS RAM 的 2 号单元写入 0 为例,该操作需要先向 70h 写入地址 2,再向 71h 写入数据 0。

assume cs:code

code segment
start:
    mov al, 2
    out 70h, al        ; 指定 2 号单元
    mov al, 0
    out 71h, al        ; 向 2 号单元写入0

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

执行后,CMOS 2 号单元的内容被修改为 0。在实际机器上,2 号单元通常存储时间信息,随意修改可能导致系统时间错乱。

现在的 CMOS 功能大多被整合进 RTC(Real Time Clock) 芯片和 UEFI BIOS 的 NVRAM(非易失性 RAM)中。虽然硬件接口变了,但通过索引端口寻址、通过数据端口读写的设计思想依然保留。例如,现代 ACPI(高级配置与电源接口)规范中的许多操作,依然能看到类似索引/数据端口对的实现。

芯片中的时间信息

在 CMOS RAM 中存储了时间信息,包括年、月、日、时、分、秒,它们对应的存储单元地址如下表所示:

时间单位 CMOS RAM 单元地址
秒 0 号单元
分 2 号单元
时 4 号单元
日 7 号单元
月 8 号单元
年 9 号单元

这些数据不是以二进制整数存储,而是采用 BCD 码 Binary-Coded Decimal 。BCD 码的本质是用 4 位二进制数表示 1 位十进制数,例如十进制数 26 的 BCD 码是 0010 0110,而不是二进制中的 0001 1010。BCD 编码方式的优势在于硬件解析简单,且符合人类阅读习惯,比如直接读取 0001 0100 就知道是 14,但代价是牺牲了部分运算效率。

例如要求在屏幕中间显示当前月份。首先读取 CMOS 月份数据,向地址端口 70h 写入 8,指定要访问 CMOS 的 8 号单元(月份)。然后从数据端口 71h 读取数据到 AL,此时读取到的是一个字节的 BCD 码。接着需要拆分 BCD 码,一个字节包含两个 BCD 码,高 4 位为十位,低 4 位为个位。可以使用 shr 和 and 指令将它们分离,使用 shr ah, cl(cl=4) 将高 4 位右移到低 4 位,得到十位数码;使用 and al, 00001111b 保留低 4 位,得到个位数码。因为 '0' 的 ASCII 是 30h,所以 BCD 码值直接加上 30h,即可得到该数字对应的 ASCII 码,直接向显存 B800:0000 写入字符即可。

assume cs:code

code segment
start:
    ; 读取 CMOS 8 号单元(月份)
    mov al, 8
    out 70h, al
    in al, 71h

    ; 拆分 BCD 码
    mov ah, al
    mov cl, 4
    shr ah, cl        ; AH = 月份十位的 BCD 码
    and al, 00001111b ; AL = 月份个位的 BCD 码

    ; 转换为 ASCII 码
    add ah, 30h
    add al, 30h

    ; 显示到屏幕中间(第 12 行,第 39 列)
    mov bx, 0b800h
    mov es, bx
    mov byte ptr es:[160*12 + 39*2], ah     ; 显示十位
    mov byte ptr es:[160*12 + 39*2 + 1], 02h
    mov byte ptr es:[160*12 + 39*2 + 2], al ; 显示个位
    mov byte ptr es:[160*12 + 39*2 + 3], 02h

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

程序运行后,在屏幕第 12 行第 39 列处显示当前月份。如果在 DOSBox 中测试,显示的月份取决于 CMOS 的模拟值,通常为当前系统时间。
image

因为占用更多存储空间且运算复杂,BCD 码在现代计算机中已不常用于通用计算,但它在金融系统、嵌入式设备(如电子表、电表)和硬件时钟(RTC)中依然广泛使用。BCD 码能避免二进制浮点数带来的精度误差,且硬件直接支持 BCD 调整指令(如 DAA)。现代操作系统读取 CMOS 时间后,通常会将其转换为 Unix 时间戳进行内部处理,但在 BIOS 层面 BCD 码依然是标准格式。

实验 14

实验的任务是通过端口读取 CMOS RAM 的时间数据,以“年/月/日 时:分:秒”的格式在屏幕中央显示当前系统时间。数据定义方面,用 str 指定预定义输出模板 yy/mm/dd hh:mm:ss,由于年、月、日等数据均为两位,程序只需将提取出的 ASCII 码覆盖 y、m、d 等占位符即可。row 指定定义时间单元地址表 [9,8,7,4,2,0],分别对应 CMOS 中的年、月、日、时、分、秒。

接着程序循环 6 次,每次处理一个时间单位。首先将 row 表中的单元地址送入 al,写入地址端口 70h,随后从数据端口 71h 读取 BCD 码格式的时间数据。然后利用 shr 和 and 指令,将 1 字节的 BCD 码拆分为十位和个位,将拆分后的 BCD 码加上 30h 转换为对应的 ASCII 字符。最后利用 ax 寄存器一次性将十位和个位的 ASCII 码写入 str 的对应位置,并调整指针 bx 跳过分隔符。

最后调用 BIOS int 10h(AH=2),将光标移动到屏幕第 12 行第 31 列(居中位置)。再调用 DOS int 21h(AH=9),显示已填充好的 str 字符串。

assume cs:code, ds:data

data segment
    str : db 'yy/mm/dd hh:mm:ss','$'
    row : db 9, 8, 7, 4, 2, 0
data ends

code segment
start:
    mov ax, data
    mov ds, ax

    mov bx, offset str
    mov si, offset row
    mov cx, 6           ; 循环 6 次
s:  
    push cx
    ; 读取芯片数据
    mov al, [si]
    out 70h, al
    in al, 71h
    ; 拆分 BCD 码并转换为 ASCII 码
    mov ah, al
    mov cl, 4
    shr al, cl        ; AL = 十位的 BCD 码
    and ah, 00001111b ; AH = 个位的 BCD 码
    add al, 30h
    add ah, 30h
    ; 写入字符串并更新索引
    mov [bx], ax
    add bx, 3
    inc si
    pop cx
    loop s

    ; 设置光标位置(借助 BIOS)
    mov ah, 2
    mov bh, 0
    mov dh, 12
    mov dl, 31
    int 10h

    ; 显示字符串(借助 DOS)
    mov dx, offset str ; DS:DX 指向字符串
    mov ah, 9
    int 21h

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

程序运行后,屏幕中央显示当前系统时间。
image

现代操作系统已不再允许用户程序直接访问 CMOS 端口,需通过内核驱动才能访问,但端口 I/O 操作依然广泛应用于嵌入式开发和驱动程序中。例如,单片机控制 LED 亮度、读取传感器数据,本质上都是通过类似的“地址端口 + 数据端口”机制完成的。此外,现代 BIOS/UEFI 虽然改用 ACPI 表提供时间信息,但其底层依然依赖 CMOS/RTC 硬件。

参考资料

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

posted @ 2026-08-23 16:51  乌漆WhiteMoon  阅读(14)  评论(0)    收藏  举报