定时器 PIT 实战:可编程时钟 (PIT: Programmable Timer)


章节概述

Intel 8253/8254 PIT(Programmable Interval Timer)是 x86 平台最古老的外设之一——从 IBM PC/XT(1981 年)到现在,它一直是系统时钟的硬件基础。PIT 的晶体振荡器以 1,193,182 Hz 的频率振动,经过一个可编程除数分频后产生周期性的 IRQ0 中断。本章从 PIT 端口编程到编写定时器中断处理器(ISR),最终实现 sleep(ms)——裸机上的毫秒级精确延时。这是操作系统调度器的硬件根基:时间片轮转全靠 PIT 定时触发。

核心理念:This is ONLY possible in assembly — out 0x43, al(向 PIT 模式寄存器写入命令字节)是特权 I/O 指令,用户态 C 程序被禁止执行。更重要的是,sleep() 在 C 中是阻塞式系统调用——操作系统帮你切换进程等时间到达。但汇编裸机 sleep() 的每一微秒都是你直接控制的:写除数 → 等 IRQ0 → 计数到目标 → 返回。你能精确知道 CPU 经过了多少个 PIT 时钟周期。


第一节:8253/8254 PIT 架构


1.1 PIT 在系统中的位置

graph TD
    XTAL["1.193182 MHz 晶振"] --> PIT["8253/8254 PIT"]
    PIT -->|"IRQ0"| PIC["PIC"] -->|"INTR"| CPU["CPU INTR"]
    PIT -->|"OUT2"| SPKR["PC 扬声器"]
    PIT -->|"OUT1"| DRAM["DRAM 刷新 (已废弃)"]

PIT 有三个独立的 16 位计数器通道:

通道端口典型用途IRQ 连线
Channel 00x40系统时钟——连到 IRQ0IRQ0 → PIC → CPU
Channel 10x41DRAM 刷新(现代已废弃)
Channel 20x42PC 扬声器(蜂鸣器)
命令0x43模式/命令寄存器(只写)

1.2 关键参数

参数
输入频率1,193,182 Hz (~1.193182 MHz)
计数器位宽16 位(最大除数 65536)
最小频率1,193,182 ÷ 65536 ≈ 18.2 Hz
最大频率1,193,182 ÷ 1 = 1.193182 MHz
默认频率1,193,182 ÷ 65536 ≈ 18.2 Hz(约 55ms 间隔)

1,193,182 这个”奇怪”数字来源于 NTSC 彩色副载波频率的 4 倍(3.579545 MHz ÷ 3 = 1.193182 MHz)——IBM PC 的设计利用了电视工业已有的廉价晶振。

小节练习

判断题 1

PIT 的三个通道使用不同的命令端口(0x43~0x45)。 ( )

  • 正确

  • 错误


第二节:PIT 编程


2.1 命令字节格式

写入端口 0x43 的命令字节结构:

 7 6 5 4 3 2 1 0
+----+---+--------+---+------+
| SC1|SC0| RW1|RW0|Mode | BCD |
+----+---+--------+---+------+
名称含义
7:6SC00选择通道 0
01选择通道 1
10选择通道 2
11读回命令(特殊)
5:4RW00计数器锁存命令
01只读写低字节
10只读写高字节
11先读写低字节,再读写高字节
3:1Mode010Mode 2: 比率发生器
011Mode 3: 方波发生器
000Mode 0: 终端计数中断
0BCD016 位二进制计数
14 位 BCD 计数(不常用)

2.2 常用操作模式

Mode 2(比率发生器 / Rate Generator):最常用。计数器从 N 递减到 1,输出一个负脉冲,然后重装 N 继续递减。像节拍器——每 N 个晶振周期产生一个脉冲。

晶振脉冲: |_||_||_||_||_||_||_||_||_||_||_||_||_||_||_||_||_||_||_|
计数器值: N N-1 N-2 ... 3 2 1 N N-1 ...
IRQ0 信号: _____________________| ̄|____________________| ̄|__________
 ↑ 脉冲 ↑ 脉冲

Mode 3(方波发生器 / Square Wave Generator):输出对称方波(若 N 为偶数)或近似方波(若 N 为奇数)。IRQ0 方波的上升沿触发中断。

绝大多数操作系统使用 Mode 2 或 Mode 3 来驱动系统时钟。Mode 2 的负脉冲更适合边缘触发的 IRQ 线路。

2.3 编程步骤

; ============ pit_init: 初始化 PIT 通道 0 ============
; 输入: ebx = 期望频率 (Hz)
; 例如: 1000 → 1000 Hz (每毫秒一次中断)
; 100 → 100 Hz (每 10 毫秒一次中断)
; 1 → 1 Hz (每秒一次中断)
;
; 算法: divisor = 1193182 / frequency
; 如果 divisor > 65535 → 错误 (频率太低)
pit_init:
 push eax
 push ebx
 push edx
 
 ; 计算除数
 mov eax, 1193182
 xor edx, edx
 div ebx ; eax = divisor, edx = 余数
 
 ; 检查除数范围
 cmp eax, 65535
 ja .error_divisor ; 除数 > 65535 → 频率太低,不合法
 
 mov ebx, eax ; ebx = divisor
 
 ; 步骤 1: 发送命令字节到 0x43
 mov al, 0x36 ; 0x36 = 00_11_010_0
 ; SC = 00 → 通道 0
 ; RW = 11 → 先低后高
 ; Mode = 010 → Mode 2 (比率发生器)
 ; BCD = 0 → 16 位二进制
 out 0x43, al
 
 ; 步骤 2: 发送除数(先低字节后高字节)
 mov al, bl ; 除数低 8 位
 out 0x40, al
 mov al, bh ; 除数高 8 位
 out 0x40, al
 
 jmp .done
.error_divisor:
 ; 频率太低,除数超出范围
 ; 可以设置最大除数 65536(写作 0)
 mov al, 0x36
 out 0x43, al
 xor al, al ; 除数 = 0 → 硬件解释为 65536
 out 0x40, al
 out 0x40, al
.done:
 pop edx
 pop ebx
 pop eax
 ret

常用频率对应的除数:

期望频率除数结果频率(精确)误差应用场景
18.2 Hz65536 (0)~18.2 Hz0%BIOS 默认
100 Hz11932100.00 Hz~0%10ms 时钟嘀嗒
500 Hz2386500.08 Hz0.016%2ms 精度睡眠
1000 Hz11931000.67 Hz0.067%1ms 精度睡眠
10000 Hz11910026.74 Hz0.27%0.1ms 精度睡眠

除数 0 在硬件上被解释为 65536(而非除以零)。这是设计好的特性——用 0 表示最大除数。

小节练习


第三节:定时器中断处理器(IRQ0 ISR)


定时器 ISR 是最简单的 IRQ 处理器——没有硬件数据需要读取,只需要递增一个全局计数器并发送 EOI:

; 全局计数器(.bss 或 .data 中)
section .bss
pit_ticks: resd 1 ; 32 位系统 tick 计数器
global_ticks: resd 1 ; 永不重置的全局计数器(可用于 uptime)
 
; ============ isr_timer: PIT IRQ0 处理器 ============
; 每 (1193182/divisor) 次晶振周期触发一次
; 如果 divisor=11932 → 每 10ms 触发一次
isr_timer:
 pusha
 
 inc dword [pit_ticks] ; 系统启动以来的总 tick 数
 
 ; 可选:如果有独立的分频 tick,在这里累加
 ; 例如我们要用 1ms 精度(1000 Hz),tick 计数器每 tick 加 1
 ; 以 1ms 为单位累积时间:
 inc dword [global_ticks]
 
 ; 发送 EOI 给主 PIC
 mov al, 0x20
 out 0x20, al
 
 popa
 iret

3.1 中断频率权衡

频率每次间隔CPU 开销延时精度典型用途
~18.2 Hz55ms极低55msBIOS 默认、极小系统
100 Hz10ms10msLinux 2.4 及之前(HZ=100)
250 Hz4ms4msLinux 2.6 默认(HZ=250)
1000 Hz1ms1msLinux 桌面/低延时系统(HZ=1000)

每秒钟 1000 次中断 = 每毫秒都要执行一次 ISR。在 1 GHz CPU 上,每次 ISR 约消耗 0.5 ~ 1 μs,这相当于 0.1% 的 CPU 时间——通常值得换取毫秒级精度的定时能力。

3.2 读取 PIT 当前计数值(精确计时)

; ============ pit_get_count: 读取通道 0 的当前计数值 ============
; 输出: eax = 当前计数值(0 ~ divisor-1)
; 用途: 实现亚 tick 级别的时间测量
pit_get_count:
 push dx
 
 ; 锁存通道 0 的计数(防止读取期间计数变化)
 mov al, 0x00 ; SC=00(Ch0), RW=00(锁存), Mode=000, BCD=0
 out 0x43, al
 
 ; 读取低字节
 in al, 0x40
 mov ah, al ; 暂存低字节
 
 ; 读取高字节
 in al, 0x40
 xchg al, ah ; eax = 完整 16 位计数值
 
 pop dx
 ret

锁存命令(RW=00)让 PIT 在读取期间”冻结”当前计数值到内部锁存器,确保你读到的低字节和高字节来自同一个计数瞬间。不锁存的话,低字节读到 0xFF 时可能刚好计数值从 0x0100 变成 0x00FF,导致拼接出错误的 0xFF00。

小节练习

判断题 1

定时器 ISR 必须读取端口 0x40 来清除中断标志。 ( )

  • 正确

  • 错误


第四节:实现 sleep(ms)


sleep 的核心思路:记录当前 tick 计数,然后不断检查 tick 是否增长了足够的数量。

; ============ sleep_ms: 以毫秒为单位睡眠 ============
; 输入: eax = 睡眠毫秒数
; 前提: PIT 配置为 1000 Hz (1 tick = 1 ms)
;
; 原理: 轮询 pit_ticks 直到增量达到目标
sleep_ms:
 push eax
 push ebx
 
 mov ebx, eax ; ebx = 目标毫秒数
 test ebx, ebx
 jz .done ; sleep(0) → 立即返回
 
 ; 读取当前 tick 值
 mov eax, [pit_ticks]
 add ebx, eax ; ebx = 目标 tick 值
 
.wait:
 cmp [pit_ticks], ebx ; 如果当前 tick < 目标 tick
 jb .wait ; 继续等待
 
.done:
 pop ebx
 pop eax
 ret

注意:上述 sleep_ms 依赖 pit_ticks 单调递增且不会被溢出中断。计数器溢出到 0 时(约 49.7 天后 @1000Hz),对溢出的处理需要额外逻辑。但这对于裸机学习足够了——你不会在 QEMU 中连续运行 49 天。

4.1 高精度 sleep_ms(处理 tick 溢出)

; ============ sleep_ms_safe: 处理 tick 溢出 ============
sleep_ms_safe:
 push eax
 push ebx
 push ecx
 
 mov ecx, eax ; ecx = 目标毫秒数
 
 ; 禁用中断以保证原子读取
 cli
 mov ebx, [pit_ticks] ; ebx = 起始 tick
 sti
 
 ; 计算目标,处理 32 位回绕
 mov eax, [pit_ticks]
 sub eax, ebx ; eax = 已经过的 tick 数
 cmp eax, ecx
 jae .done ; 已经过了足够时间
 
 ; 还需等待: ecx - eax 毫秒
 sub ecx, eax
 add ecx, [pit_ticks] ; ecx = 绝对目标 tick(回绕安全)
 
.wait:
 mov eax, [pit_ticks]
 sub eax, ebx ; eax = 从起始时刻经过的总 tick
 cmp eax, ecx ; 但需要以起始时刻为基准
 jb .wait
 
.done:
 pop ecx
 pop ebx
 pop eax
 ret

对比 C 语言:usleep(1000) 的背后是 nanosleep() 系统调用 → 内核将进程放入等待队列 → 调度器选择其他进程 → PIT 中断触发 → update_process_times() → 检查等待队列 → 发现时间到 → 唤醒进程 → 返回用户态。汇编 sleep 就是 cmp [ticks], target; jb .wait 两条指令——没有任何内核参与。This is ONLY possible in assembly.

小节练习


第五节:完整 QEMU 裸机示例


以下是一个完整可运行的程序:PIT 100Hz 配置、IDT + PIC 设置、定时器 ISR 驱动 tick 计数器、sleep 函数实现、VGA 实时显示和串口调试日志。

文件结构:

07_定时器pit/
├── kernel.asm
├── link.ld
└── Makefile

Makefile:

ASM = nasm
LD = ld
ASFLAGS = -f elf32
LDFLAGS = -m elf_i386
 
kernel.elf: kernel.o link.ld
	$(LD) $(LDFLAGS) -T link.ld -o $@ $<
 
kernel.o: kernel.asm
	$(ASM) $(ASFLAGS) -o $@ $<
 
run: kernel.elf
	qemu-system-x86_64 -kernel kernel.elf -serial stdio
 
run-gdb: kernel.elf
	qemu-system-x86_64 -kernel kernel.elf -serial stdio -s -S
 
clean:
	rm -f kernel.o kernel.elf
 
.PHONY: run run-gdb clean

link.ld 与前面章节相同(加载地址 1M,标准 multiboot 布局)。

kernel.asm(核心代码):

; kernel.asm — PIT 定时器完整演示
;
; 功能:
; - PIT 通道 0 编程 (1193 除数 → ~1000 Hz, 1ms 精度)
; - PIC 重映射 + IDT 设置
; - 定时器 ISR: 递增 tick 计数器
; - sleep_ms(ms): 毫秒级精确睡眠
; - VGA 实时显示 tick 计数
; - 串口调试日志
;
; 编译: nasm -f elf32 kernel.asm -o kernel.o
; 链接: ld -m elf_i386 -T link.ld kernel.o -o kernel.elf
; 运行: qemu-system-x86_64 -kernel kernel.elf -serial stdio
 
bits 32
 
; ──── Multiboot 头部 ────
MBALIGN equ 1<<0
MEMINFO equ 1<<1
MBFLAGS equ MBALIGN | MEMINFO
MAGIC equ 0x1BADB002
CHECKSUM equ -(MAGIC + MBFLAGS)
 
section .multiboot
align 4
 dd MAGIC
 dd MBFLAGS
 dd CHECKSUM
 
; ──── 常量 ────
VGA_BASE equ 0xB8000
PIT_FREQ equ 1000 ; 期望 1000 Hz (1ms tick)
IDT_COUNT equ 256
 
; ──── IDT 表项设置宏 ────
%macro IDT_SET_ENTRY 2
 mov eax, %2
 mov word [idt + %1*8], ax
 mov word [idt + %1*8 + 2], 0x08
 mov byte [idt + %1*8 + 4], 0x00
 mov byte [idt + %1*8 + 5], 0x8E
 shr eax, 16
 mov word [idt + %1*8 + 6], ax
%endmacro
 
; ──── BSS 段 ────
section .bss
align 16
idt:
 resb IDT_COUNT * 8 ; 256 项 × 8 字节 = 2048 字节
 
pit_ticks:
 resd 1 ; 系统启动以来的 tick 数 (32位)
seconds_elapsed:
 resd 1 ; 运行秒数
 
vga_row:
 resb 1
vga_col:
 resb 1
vga_color:
 resb 1
 
align 16
stack_bottom:
 resb 16384
stack_top:
 
; ──── 数据段 ────
section .data
align 4
idt_desc:
 dw IDT_COUNT * 8 - 1
 dd idt
 
msg_title db 'PIT Timer Demo - 1000 Hz (1ms precision)', 0
msg_ticks db 'Ticks: ', 0
msg_seconds db 'Seconds: ', 0
msg_sleep db 'Sleeping 2 seconds...', 0
msg_wake db 'Awake!', 0
msg_done db 'Demo complete. Timer continues...', 0
 
; 串口日志
log_init db '[PIT] System timer initialized at ~1000 Hz.', 0x0D, 0x0A, 0
log_tick db '[PIT] First tick received.', 0x0D, 0x0A, 0
log_sleep db '[PIT] Entering sleep(2000)...', 0x0D, 0x0A, 0
log_wake db '[PIT] Woke up after 2000 ms.', 0x0D, 0x0A, 0
log_irq0 db '[PIT] IRQ0 tick #', 0
 
first_tick_flag:
 db 0 ; 0 = 还未收到第一个 tick
 
section .text
global _start
 
_start:
 mov esp, stack_top
 
 ; ─── 1. 串口初始化 ───
 call serial_init
 
 mov esi, log_init
 call serial_puts
 
 ; ─── 2. VGA 初始化 ───
 call vga_clear
 mov byte [vga_color], 0x0F
 
 mov byte [vga_row], 0
 mov byte [vga_col], 0
 mov esi, msg_title
 call vga_puts
 
 ; ─── 3. 中断系统: IDT + PIC ───
 call idt_fill_default
 IDT_SET_ENTRY 0x20, isr_timer ; IRQ0 → 定时器 ISR
 call pic_remap
 
 lidt [idt_desc]
 
 ; 开放 IRQ0 (时钟), 屏蔽其他
 mov al, 0xFE ; 1111 1110 — 只开放 IRQ0
 out 0x21, al
 
 ; ─── 4. 初始化 PIT ───
 ; 1000 Hz: divisor = 1193182 / 1000 ≈ 1193
 mov al, 0x36 ; Ch0, 低+高字节, Mode 3 (方波), 二进制
 out 0x43, al
 mov ax, 1193 ; divisor = 1193 → ~1000.67 Hz
 out 0x40, al ; 低字节
 mov al, ah
 out 0x40, al ; 高字节
 
 ; ─── 5. 显示初始化信息 ───
 mov byte [vga_row], 3
 mov byte [vga_col], 0
 mov esi, msg_ticks
 call vga_puts
 
 mov byte [vga_row], 4
 mov byte [vga_col], 0
 mov esi, msg_seconds
 call vga_puts
 
 ; ─── 6. 开中断! ───
 sti
 
 ; 等待第一个 tick 到来(确保 PIT 正常工作)
.wait_first_tick:
 cmp byte [first_tick_flag], 1
 jne .wait_first_tick
 
 mov esi, log_tick
 call serial_puts
 
 ; ─── 7. sleep 演示 ───
 mov byte [vga_row], 7
 mov byte [vga_col], 0
 mov byte [vga_color], 0x0E ; 黄色
 mov esi, msg_sleep
 call vga_puts
 
 mov esi, log_sleep
 call serial_puts
 
 mov eax, 2000 ; sleep(2000 ms) = 2 秒
 call sleep_ms
 
 mov byte [vga_color], 0x0A ; 绿色
 mov byte [vga_row], 8
 mov byte [vga_col], 0
 mov esi, msg_wake
 call vga_puts
 
 mov byte [vga_color], 0x0F ; 恢复白色
 mov esi, log_wake
 call serial_puts
 
 ; ─── 8. 循环更新屏幕 + 串口定期报告 ───
 mov byte [vga_row], 10
 mov byte [vga_col], 0
 mov esi, msg_done
 call vga_puts
 
 mov dword [seconds_elapsed], 0
 
.main_loop:
 ; 更新 VGA 上的 tick 计数
 mov byte [vga_row], 3
 mov byte [vga_col], 8 ; "Ticks: " 后面
 mov eax, [pit_ticks]
 call vga_print_dec32_simple
 
 ; 每秒报告一次串口日志
 mov eax, [pit_ticks]
 xor edx, edx
 mov ecx, 1000 ; 1000 Hz → 1000 ticks = 1 秒
 div ecx
 cmp eax, [seconds_elapsed]
 je .tick_done
 
 mov [seconds_elapsed], eax
 
 ; 串口: "[PIT] Second: N"
 push eax
 mov esi, log_irq0
 call serial_puts
 pop eax
 call serial_putdec32
 call serial_newline
 
 ; 更新 VGA 上的秒数显示
 mov byte [vga_row], 4
 mov byte [vga_col], 10
 mov eax, [seconds_elapsed]
 call vga_print_dec32_simple
 
.tick_done:
 ; 短暂延迟后重新更新(让 CPU 休息——hlt)
 hlt
 jmp .main_loop
 
; ═══════════════════════════════════════════
; ISR: 定时器 (IRQ0 → 向量 0x20)
; ═══════════════════════════════════════════
isr_timer:
 pusha
 
 inc dword [pit_ticks]
 mov byte [first_tick_flag], 1
 
 ; 发送 EOI
 mov al, 0x20
 out 0x20, al
 
 popa
 iret
 
isr_default:
 iret
 
; ═══════════════════════════════════════════
; sleep_ms: 毫秒级精确延迟
; ═══════════════════════════════════════════
sleep_ms:
 push eax
 push ebx
 
 mov ebx, eax
 test ebx, ebx
 jz .done
 cli
 mov eax, [pit_ticks]
 sti
 add ebx, eax
 
.wait:
 hlt ; 暂停 CPU 直到下一个中断(省电)
 cmp [pit_ticks], ebx
 jb .wait
 
.done:
 pop ebx
 pop eax
 ret
 
; ═══════════════════════════════════════════
; PIC 重映射
; ═══════════════════════════════════════════
pic_remap:
 mov al, 0x11
 out 0x20, al
 out 0xA0, al
 call io_wait
 
 mov al, 0x20
 out 0x21, al
 mov al, 0x28
 out 0xA1, al
 call io_wait
 
 mov al, 0x04
 out 0x21, al
 mov al, 0x02
 out 0xA1, al
 call io_wait
 
 mov al, 0x01
 out 0x21, al
 out 0xA1, al
 call io_wait
 
 mov al, 0xFE ; 只开放 IRQ0
 out 0x21, al
 mov al, 0xFF ; 屏蔽所有从 PIC IRQ
 out 0xA1, al
 ret
 
io_wait:
 out 0x80, al
 ret
 
; ═══════════════════════════════════════════
; IDT 填充
; ═══════════════════════════════════════════
idt_fill_default:
 push eax
 push ecx
 push edi
 mov ecx, IDT_COUNT
 mov edi, idt
.loop:
 mov eax, isr_default
 mov word [edi], ax
 mov word [edi+2], 0x08
 mov byte [edi+4], 0x00
 mov byte [edi+5], 0x8E
 shr eax, 16
 mov word [edi+6], ax
 add edi, 8
 loop .loop
 pop edi
 pop ecx
 pop eax
 ret
 
; ═══════════════════════════════════════════
; 串口函数
; ═══════════════════════════════════════════
serial_init:
 push ax
 push dx
 mov dx, 0x3F9
 mov al, 0x00
 out dx, al
 mov dx, 0x3FB
 mov al, 0x80
 out dx, al
 mov dx, 0x3F8
 mov al, 0x01
 out dx, al
 mov dx, 0x3F9
 mov al, 0x00
 out dx, al
 mov dx, 0x3FB
 mov al, 0x03
 out dx, al
 mov dx, 0x3FA
 mov al, 0xC7
 out dx, al
 pop dx
 pop ax
 ret
 
serial_putchar:
 push ax
 push dx
 mov dx, 0x3FD
.wait:
 in al, dx
 test al, 0x20
 jz .wait
 mov dx, 0x3F8
 mov al, [esp + 4]
 out dx, al
 pop dx
 pop ax
 ret
 
serial_puts:
 push ax
 push si
.loop:
 lodsb
 test al, al
 jz .done
 call serial_putchar
 jmp .loop
.done:
 pop si
 pop ax
 ret
 
serial_putdec32:
 push eax
 push ebx
 push ecx
 push edx
 mov ecx, 0
 mov ebx, 10
.div_loop:
 xor edx, edx
 div ebx
 push edx
 inc ecx
 test eax, eax
 jnz .div_loop
.print_loop:
 pop eax
 add al, '0'
 call serial_putchar
 loop .print_loop
 pop edx
 pop ecx
 pop ebx
 pop eax
 ret
 
serial_newline:
 push ax
 mov al, 0x0D
 call serial_putchar
 mov al, 0x0A
 call serial_putchar
 pop ax
 ret
 
; ═══════════════════════════════════════════
; VGA 函数(精简版)
; ═══════════════════════════════════════════
vga_putchar:
 push eax
 push ebx
 push edi
 
 cmp al, 0x0A
 je .newline
 cmp al, 0x0D
 je .cr
 jmp .draw
 
.newline:
 mov byte [vga_col], 0
 inc byte [vga_row]
 cmp byte [vga_row], 25
 jl .done
 dec byte [vga_row]
 jmp .done
 
.cr:
 mov byte [vga_col], 0
 jmp .done
 
.draw:
 movzx edi, byte [vga_row]
 imul edi, 80
 movzx ebx, byte [vga_col]
 add edi, ebx
 shl edi, 1
 add edi, VGA_BASE
 mov ah, [vga_color]
 mov [edi], ax
 
 inc byte [vga_col]
 cmp byte [vga_col], 80
 jl .done
 mov byte [vga_col], 0
 inc byte [vga_row]
 cmp byte [vga_row], 25
 jl .done
 dec byte [vga_row]
 
.done:
 call vga_update_cursor
 pop edi
 pop ebx
 pop eax
 ret
 
vga_puts:
 push ax
 push si
.loop:
 lodsb
 test al, al
 jz .done
 call vga_putchar
 jmp .loop
.done:
 pop si
 pop ax
 ret
 
vga_clear:
 push eax
 push ecx
 push edi
 mov edi, VGA_BASE
 mov ecx, 80 * 25 / 2
 mov eax, 0x0F200F20
 cld
 rep stosd
 mov byte [vga_row], 0
 mov byte [vga_col], 0
 call vga_update_cursor
 pop edi
 pop ecx
 pop eax
 ret
 
vga_update_cursor:
 push eax
 push ebx
 push edx
 movzx eax, byte [vga_row]
 mov bl, 80
 mul bl
 movzx ebx, byte [vga_col]
 add eax, ebx
 mov ebx, eax
 mov dx, 0x3D4
 mov al, 0x0E
 out dx, al
 mov dx, 0x3D5
 mov al, bh
 out dx, al
 mov dx, 0x3D4
 mov al, 0x0F
 out dx, al
 mov dx, 0x3D5
 mov al, bl
 out dx, al
 pop edx
 pop ebx
 pop eax
 ret
 
; 简化的 VGA 数字打印(不跟踪光标——直接写入给定行列)
vga_print_dec32_simple:
 push eax
 push ebx
 push ecx
 push edx
 push edi
 
 ; 计算 VGA 地址
 movzx edi, byte [vga_row]
 imul edi, 80
 movzx ebx, byte [vga_col]
 add edi, ebx
 shl edi, 1
 add edi, VGA_BASE
 
 mov ecx, 0
 mov ebx, 10
.div_loop:
 xor edx, edx
 div ebx
 push edx
 inc ecx
 test eax, eax
 jnz .div_loop
.print_loop:
 pop eax
 add al, '0'
 mov ah, 0x0F
 mov [edi], ax
 add edi, 2
 loop .print_loop
 
 pop edi
 pop edx
 pop ecx
 pop ebx
 pop eax
 ret

运行效果:

$ make run
# 终端输出(串口):
# [PIT] System timer initialized at ~1000 Hz.
# [PIT] First tick received.
# [PIT] Entering sleep(2000)...
# [PIT] Woke up after 2000 ms.
# [PIT] Second: 1
# [PIT] Second: 2
# [PIT] Second: 3
# ...
 
# QEMU 窗口(VGA):
# PIT Timer Demo - 1000 Hz (1ms precision)
#
# Ticks: 1234567 ← 实时递增,每秒约涨 1000
# Seconds: 1234 ← 每秒更新一次
#
# Sleeping 2 seconds... ← 黄色
# Awake! ← 绿色(2 秒后出现)
#
# Demo complete. Timer continues...

hlt 的优势:sleep_ms 中的 hlt 指令让 CPU 进入低功耗状态,直到下一个中断唤醒。如果没有 hlt(而用纯 cmp/jb 忙等),CPU 会以 100% 占用率疯狂轮询。hlt 是汇编能发出的特权指令之一——用户态程序无法执行,操作系统才能用它来省电。

小节练习

判断题 1

hlt 指令在用户态程序中可以正常执行。 ( )

  • 正确

  • 错误


第六节:RTC(Real-Time Clock)简介


PIT 提供了周期性的”节拍”,但它不知道”现在是几点”。RTC(Real-Time Clock)通过 CMOS 提供挂钟时间:

端口用途
0x70CMOS 地址端口(bit7 控制 NMI 屏蔽)
0x71CMOS 数据端口

读取 RTC 时间:

; ============ rtc_read_time: 读取当前时间 ============
; 输出: ch = 时, cl = 分, dh = 秒 (BCD 格式或二进制)
rtc_read_time:
 push ax
 push dx
 
.wait_update:
 ; 等待 RTC 不处于更新中
 mov al, 0x0A
 out 0x70, al
 in al, 0x71
 test al, 0x80 ; UIP bit (Update In Progress)
 jnz .wait_update
 
 ; 读取小时 (寄存器 0x04)
 mov al, 0x04
 out 0x70, al
 in al, 0x71
 mov ch, al
 
 ; 读取分钟 (寄存器 0x02)
 mov al, 0x02
 out 0x70, al
 in al, 0x71
 mov cl, al
 
 ; 读取秒 (寄存器 0x00)
 mov al, 0x00
 out 0x70, al
 in al, 0x71
 mov dh, al
 
 pop dx
 pop ax
 ret

RTC 返回的值默认为 BCD 格式(如 0x59 表示 59)。CMOS 寄存器 0x0B 的 bit2 可以切换到二进制模式。QEMU 默认输出 BCD。

RTC 中断(IRQ8):RTC 也可以通过 PIC 从片 IRQ8 产生周期性中断(频率可设为 2~8192 Hz),作为 PIT 的替代品。但 RTC 中断频率只有 15 个离散档位(2 的幂次),灵活性不如 PIT。

小节练习


章节测试

一、判断题

判断题 1

PIT 通道 0 的数据端口是 0x40。 ( )

  • 正确

  • 错误

判断题 2

PIT 的晶振频率是 1,000,000 Hz(1 MHz)。 ( )

  • 正确

  • 错误

判断题 3

PIT 命令字节中,RW=11 表示”只写高字节”。 ( )

  • 正确

  • 错误

判断题 4

sleep_ms 中的忙等循环(cmp [ticks], target; jb .wait)依赖中断来推进 tick 计数。 ( )

  • 正确

  • 错误

判断题 5

PIT 通道 1 和 2 也可以通过 I/O 端口编程,与通道 0 的操作方式相同。 ( )

  • 正确

  • 错误


动手练习题

练习题 1:修改 PIT 频率

难度: 简单

将 PIT 频率从 1000 Hz 改为 100 Hz(除数 11932)。观察 VGA 上 tick 计数增长速度的变化(应变为原来的 1/10)。将 sleep_ms(2000) 改为 sleep_ms(2000 / 10) 使睡眠时间保持 2 秒不变,验证程序行为。

练习题 2:实现精确的 stopwatch(秒表)

难度: 简单

利用 PIT 1000 Hz 和亚 tick 读取(pit_get_count),实现一个高精度秒表。stopwatch_start() 记录起始 tick + 起始计数值,stopwatch_elapsed_us() 计算从起始到当前经过的微秒数。

elapsed = ((current_ticks - start_ticks) * 1000) + 
((start_count - current_count) * 1000000 / PIT_FREQ)

在 VGA 第 20 行实时显示 elapsed 值(微秒 → 毫秒)。

练习题 3:PC 扬声器 —— 让 PIT 弹奏音符

难度: 简单

PIT 通道 2 连接到 PC 扬声器(通过 0x61 端口的 bit0 和 bit1 控制门控)。编程通道 2 输出不同频率的方波来演奏简单的音符:

音符频率对照:
C4 = 261.63 Hz → 除数 ≈ 4560
D4 = 293.66 Hz → 除数 ≈ 4063
E4 = 329.63 Hz → 除数 ≈ 3620
F4 = 349.23 Hz → 除数 ≈ 3416
G4 = 392.00 Hz → 除数 ≈ 3044
A4 = 440.00 Hz → 除数 ≈ 2712
B4 = 493.88 Hz → 除数 ≈ 2416

每个音符播放 500ms(用 sleep_ms),然后播放下一个。播放前先通过 in al, 0x61; or al, 0x03; out 0x61, al 打开扬声器门控。QEMU 中 -audiodev pa,id=audio0 -machine pcspk-audiodev=audio0 可以让声音输出到宿主机的 PulseAudio。如果没有声音设备,观察 QEMU Monitor 中的 PC Speaker 状态。

练习题 4:实现基于时间的任务调度器

难度: 简单

定义一个”任务”结构(函数指针 + 周期 + 上次执行 tick),在 ISR 中检查是否有任务需要执行(类似操作系统调度器)。实现 task_create(func_ptr, period_ms)task_scheduler()(在 ISR 中调用)。测试:创建三个任务:

  • Task 1: 每 1000ms → 通过串口打印 “Task 1: 1 second”
  • Task 2: 每 3000ms → 通过串口打印 “Task 2: 3 seconds”
  • Task 3: 每 500ms → 更新 VGA 第 15 行的颜色闪烁

下一章[[08_键盘输入实战|键盘输入实战]] — PS/2 8042 控制器、扫描码解析、中断驱动 vs 轮询。

前置章节[[04_中断与IDT|中断与 IDT]] [[05_串口UART实战|串口 UART]] [[06_VGA文本模式实战|VGA 文本模式]]

练习

本章实践性强,请用动手练习题自检(频率修改、秒表实现、PC扬声器)。