汇编语言:端口 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。

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 号端口读入一个字节,执行时与总线相关的操作如下:
- CPU 通过地址线将地址信息 60h 发出;
- CPU 通过控制线发出端口读命令,选中端口所在的芯片,并通知它将要从中读取数据;
- 端口所在的芯片将 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 号单元的值。

以向 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 的模拟值,通常为当前系统时间。

因为占用更多存储空间且运算复杂,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
程序运行后,屏幕中央显示当前系统时间。

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

浙公网安备 33010602011771号