4. 实现VGA字符输出函数

任务目标

在BIOS开发环境中,实现两个核心字符输出函数:putchar(输出单个字符到VGA屏幕)和printf(格式化字符串输出)。本案例聚焦于16位实模式下的VGA文本模式操作,直接操作显存地址0xB8000。

环境与依赖

  • NASM汇编器(用于编译启动扇区)
  • QEMU虚拟机(用于运行验证)
  • 目标平台:x86 16位实模式,VGA文本模式(80×25,黑底白字)

核心实现

以下代码包含完整的putchar和printf实现。光标位置通过BIOS中断INT 0x10维护,字符写入直接映射到显存。格式化支持%d(十进制)、%x(十六进制)、%s(字符串)和%c(字符)。

;------------------------------------------
; VGA字符输出函数 - 完整实现
; 汇编:nasm -f bin vga_print.asm -o vga_print.bin
; 运行:qemu-system-x86_64 -drive format=raw,file=vga_print.bin
;------------------------------------------
BITS 16
ORG 0x7C00

start:
    ; 初始化段寄存器
    xor ax, ax
    mov ds, ax
    mov es, ax
    
    ; 清屏(通过滚动窗口实现)
    mov ax, 0x0600
    mov bh, 0x07        ; 黑底白字
    xor cx, cx
    mov dx, 0x184F      ; 80x25
    int 0x10
    
    ; 设置光标到起始位置
    mov ah, 0x02
    xor bh, bh          ; 页码0
    xor dx, dx          ; 行0列0
    int 0x10
    
    ; 测试printf
    mov si, msg_hello
    call printf
    
    mov si, msg_num
    call printf
    mov ax, 12345
    push ax
    mov si, fmt_dec
    call printf
    add sp, 2
    
    mov si, msg_hex
    call printf
    mov ax, 0xABCD
    push ax
    mov si, fmt_hex
    call printf
    add sp, 2
    
    ; 死循环
    cli
    hlt

;------------------------------------------
; putchar - 输出单个字符到VGA
; 输入:AL = 字符ASCII码
; 破坏:AH, BX, CX, DX
;------------------------------------------
putchar:
    pusha
    ; 获取当前光标位置
    mov ah, 0x03
    xor bh, bh
    int 0x10            ; DH=行, DL=列
    
    ; 处理换行符
    cmp al, 0x0A
    je .newline
    cmp al, 0x0D
    je .carriage_return
    
    ; 写入字符到显存
    push ax
    mov ax, 0xB800
    mov es, ax
    pop ax
    
    ; 计算显存偏移 = (行*80 + 列) * 2
    movzx bx, dh
    imul bx, bx, 80
    movzx cx, dl
    add bx, cx
    shl bx, 1
    
    mov ah, 0x07        ; 属性:黑底白字
    mov [es:bx], ax
    
    ; 光标右移
    inc dl
    cmp dl, 80
    jb .set_cursor
    mov dl, 0
    inc dh
    jmp .set_cursor
    
.newline:
    inc dh
    mov dl, 0
    jmp .set_cursor
    
.carriage_return:
    mov dl, 0
    jmp .set_cursor
    
.set_cursor:
    ; 检查是否需要滚动
    cmp dh, 25
    jb .update_cursor
    call scroll_screen
    mov dh, 24
    
.update_cursor:
    mov ah, 0x02
    xor bh, bh
    int 0x10
    popa
    ret

;------------------------------------------
; scroll_screen - 向上滚动一行
;------------------------------------------
scroll_screen:
    pusha
    mov ax, 0x0601      ; AL=1 表示滚动一行
    mov bh, 0x07
    xor cx, cx
    mov dx, 0x184F
    int 0x10
    popa
    ret

;------------------------------------------
; printf - 格式化输出
; 输入:SI = 格式字符串地址
;       参数通过栈传递(从右到左压栈)
; 支持:%d(十进制), %x(十六进制), %s(字符串), %c(字符)
;------------------------------------------
printf:
    pusha
    mov bp, sp
    add bp, 18          ; 跳过pusha的8个寄存器+返回地址
    
.next_char:
    lodsb               ; 加载字符到AL
    test al, al
    jz .done
    
    cmp al, '%'
    jne .print_char
    
    ; 处理格式说明符
    lodsb
    cmp al, 'd'
    je .format_dec
    cmp al, 'x'
    je .format_hex
    cmp al, 's'
    je .format_str
    cmp al, 'c'
    je .format_char
    jmp .print_char
    
.format_dec:
    mov ax, [bp]        ; 获取参数
    add bp, 2
    call print_dec
    jmp .next_char
    
.format_hex:
    mov ax, [bp]
    add bp, 2
    call print_hex
    jmp .next_char
    
.format_str:
    mov si, [bp]        ; 字符串指针
    add bp, 2
    call print_str
    jmp .next_char
    
.format_char:
    mov al, [bp]
    add bp, 2
    call putchar
    jmp .next_char
    
.print_char:
    call putchar
    jmp .next_char
    
.done:
    popa
    ret

;------------------------------------------
; print_dec - 输出十进制数
; 输入:AX = 数值
;------------------------------------------
print_dec:
    pusha
    mov cx, 0           ; 数字计数
    mov bx, 10
    
.div_loop:
    xor dx, dx
    div bx
    push dx             ; 保存余数
    inc cx
    test ax, ax
    jnz .div_loop
    
.print_loop:
    pop ax
    add al, '0'
    call putchar
    loop .print_loop
    
    popa
    ret

;------------------------------------------
; print_hex - 输出十六进制数
; 输入:AX = 数值
;------------------------------------------
print_hex:
    pusha
    mov cx, 4           ; 输出4个十六进制位
    
.hex_loop:
    rol ax, 4
    push ax
    and al, 0x0F
    cmp al, 10
    jb .digit
    add al, 'A' - 10
    jmp .print
.digit:
    add al, '0'
.print:
    call putchar
    pop ax
    loop .hex_loop
    
    popa
    ret

;------------------------------------------
; print_str - 输出字符串
; 输入:SI = 字符串地址
;------------------------------------------
print_str:
    pusha
.str_loop:
    lodsb
    test al, al
    jz .done
    call putchar
    jmp .str_loop
.done:
    popa
    ret

;------------------------------------------
; 数据段
;------------------------------------------
msg_hello   db 'Hello from VGA!', 0x0D, 0x0A, 0
msg_num     db 'Decimal: ', 0
msg_hex     db 'Hex: 0x', 0
fmt_dec     db '%d', 0x0D, 0x0A, 0
fmt_hex     db '%x', 0x0D, 0x0A, 0

times 510-($-$$) db 0
dw 0xAA55

编译与运行

  1. 保存代码为 vga_print.asm
  2. 编译:nasm -f bin vga_print.asm -o vga_print.bin
  3. 运行:qemu-system-x86_64 -drive format=raw,file=vga_print.bin

验证方法

QEMU窗口应显示以下内容(预期输出,由代码逻辑直接推出):

Hello from VGA!
Decimal: 12345
Hex: 0xABCD

验证通过条件:

  • 三行文本正确显示在QEMU屏幕左上角
  • 十进制数12345正确转换并显示
  • 十六进制数0xABCD以大写字母显示
  • 光标停留在最后一行末尾

关键说明

  • 光标位置通过BIOS中断INT 0x10的AH=0x03(读取)和AH=0x02(设置)维护,确保字符写入位置正确
  • 显存偏移计算公式:(行号 × 80 + 列号) × 2,每个字符占2字节(低字节为ASCII码,高字节为颜色属性)
  • 参数传递约定:printf从栈中按从右到左的顺序取参数,调用前需将参数压栈,调用后由调用者清理栈
  • 换行处理:0x0A(LF)使行号加1并回到列0;0x0D(CR)仅回到列0