串口(UART)是裸机开发中第一个能输出信息的设备——没有它,你不知道代码跑到了哪一步、寄存器里是什么值。这就是为什么操作系统内核在初始化显卡之前会先把所有日志通过串口输出。16550 UART 芯片兼容性好(QEMU 完美模拟),编程简单(几个 I/O 端口轮询即可),是裸机开发的”Hello World”起点。本章从端口初始化到字符输出,最终实现在 QEMU 裸机上打印 Hello, world from bare metal!。
核心理念:This is ONLY possible in assembly — out dx, al 将字符直接写入 UART 发送寄存器,不经过任何系统调用、库函数和操作系统。C 语言的 putchar() 走的是 libc → write() 系统调用 → 内核 tty 驱动的漫长路径。汇编的 out dx, al 是唯一能在一条指令里把字符从 CPU 打到物理串口线上的方式。
第一节:16550 UART 芯片与端口布局
1.1 什么是 UART
UART(Universal Asynchronous Receiver/Transmitter)是最古老的计算机外设之一——从 1960 年代的电传打字机到现在的嵌入式调试口,串口通信从未过时。16550 芯片是 IBM PC/AT 时代的标配,至今仍然是 x86 平台的”串口标准”,QEMU 默认模拟 16550A 型号。
在裸机上,串口是你唯一的”眼睛”。没有显示器驱动、没有文件系统、没有 printf——只有 out 指令向串口端口写字节,另一端(QEMU 的 -serial stdio)显示到你的终端。
; ============ serial_puts: 发送以 0 结尾的字符串 ============; 输入: esi = 字符串地址; 破坏: esi (前进了 strlen 个字节), alserial_puts: push ax push dx push si ; 保存 esi 的副本 (可选).loop: lodsb ; al = [esi], esi++ test al, al ; 检查是否到达 '\0' jz .done call serial_putchar ; 发送 al 中的字符 jmp .loop.done: pop si pop dx pop ax ret
4.1 辅助调试函数
; ============ serial_puthex8: 以十六进制输出 al ============serial_puthex8: push ax push bx mov bl, al shr al, 4 call .nibble ; 高 4 位 mov al, bl and al, 0x0F call .nibble ; 低 4 位 pop bx pop ax ret.nibble: cmp al, 10 sbb al, 0x69 das call serial_putchar ret; ============ serial_puthex32: 以十六进制输出 eax ============serial_puthex32: push eax push ecx mov ecx, 8.loop: rol eax, 4 ; 循环左移,每次取最高 4 位 push eax and al, 0x0F cmp al, 10 sbb al, 0x69 das call serial_putchar pop eax loop .loop pop ecx pop eax ret; ============ serial_putdec32: 以十进制输出 eax ============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: 输出 \r\n ============serial_newline: push ax mov al, 0x0D ; '\r' 回车 call serial_putchar mov al, 0x0A ; '\n' 换行 call serial_putchar pop ax ret
; kernel.asm — 裸机串口 Hello World; 编译: 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 -nographic -serial stdio;; 在这个极简裸机程序中:; 没有操作系统、没有 libc、没有 printf; 只有 in/out 指令操作 UART 端口; 你能看到 "Hello, world from bare metal!" 从串口输出bits 32; ──── Multiboot 头部 ────MBALIGN equ 1<<0MEMINFO equ 1<<1MBFLAGS equ MBALIGN | MEMINFOMAGIC equ 0x1BADB002CHECKSUM equ -(MAGIC + MBFLAGS)section .multibootalign 4 dd MAGIC dd MBFLAGS dd CHECKSUM; ──── 数据段 ────section .datahello_msg db 'Hello, world from bare metal!', 0x0D, 0x0A, 0info_msg db 'Serial UART initialized. COM1 115200 8N1.', 0x0D, 0x0A, 0test_msg db 'Testing all printable ASCII characters:', 0x0D, 0x0A, 0ready_msg db 'Stack pointer (ESP) = ', 0newline db 0x0D, 0x0A, 0separator db '---', 0x0D, 0x0A, 0section .textglobal _start_start: mov esp, stack_top ; 1. 初始化串口 call serial_init ; 2. 打印初始化信息 mov esi, info_msg call serial_puts ; 3. 打印 Hello World mov esi, hello_msg call serial_puts mov esi, separator call serial_puts ; 4. 打印 ESP 的值(演示 hex 输出) mov esi, ready_msg call serial_puts mov eax, esp call serial_puthex32 call serial_newline ; 5. 打印所有可打印 ASCII 字符 mov esi, test_msg call serial_puts mov al, ' ' ; 从空格开始.ascii_loop: cmp al, 0x7E ; 到 '~' 结束 jg .done_ascii call serial_putchar inc al cmp al, 0x80 ; 每 32 个字符换行 test al, 0x1F jnz .ascii_loop call serial_newline jmp .ascii_loop.done_ascii: call serial_newline mov esi, separator call serial_puts ; 6. 打印十进制数字演示 mov eax, 12345 call serial_putdec32 call serial_newline ; 7. 暂停(死循环——裸机没有"退出"概念) hlt jmp $ ; 如果 NMI 唤醒就继续停; ============ serial_init ============serial_init: push ax push dx ; 禁用中断 mov dx, 0x3F9 mov al, 0x00 out dx, al ; DLAB = 1 mov dx, 0x3FB mov al, 0x80 out dx, al ; 波特率 = 115200 (除数 = 1) mov dx, 0x3F8 mov al, 0x01 out dx, al mov dx, 0x3F9 mov al, 0x00 out dx, al ; 8N1, DLAB = 0 mov dx, 0x3FB mov al, 0x03 out dx, al ; 启用 FIFO,清空缓冲区 mov dx, 0x3FA mov al, 0xC7 out dx, al pop dx pop ax ret; ============ serial_putchar ============serial_putchar: push ax push dx mov dx, 0x3FD.wait: in al, dx test al, 0x20 ; LSR bit5: THR Empty? jz .wait mov dx, 0x3F8 mov al, [esp + 4] ; 恢复原始 al(栈中 push ax 的值) out dx, al pop dx pop ax ret; ============ serial_puts ============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_puthex32 ============serial_puthex32: push eax push ecx mov ecx, 8.hex_loop: rol eax, 4 push eax and al, 0x0F cmp al, 10 sbb al, 0x69 das call serial_putchar pop eax loop .hex_loop pop ecx pop eax ret; ============ serial_putdec32 ============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 ============serial_newline: push ax mov al, 0x0D call serial_putchar mov al, 0x0A call serial_putchar pop ax ret; ============ 栈空间 ============section .bssalign 16stack_bottom: resb 16384stack_top:
运行:
$ make run# 终端输出:# Serial UART initialized. COM1 115200 8N1.# Hello, world from bare metal!# ---# Stack pointer (ESP) = 00200000# Testing all printable ASCII characters:# !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~# ---# 12345
对比 C 语言:
// C 版本需要的事情:#include <stdio.h>int main() { printf("Hello, world from bare metal!\n"); // → libc → write() → 内核 TTY 驱动 → 串口驱动 return 0;}// 汇编版本只做的事:// 1. out 0x3FB, 0x80 (设置 DLAB)// 2. out 0x3F8, 1 (设置波特率)// 3. out 0x3FB, 0x03 (8N1)// 4. out 0x3F8, 'H' (发送字符)// 5. out 0x3F8, 'e' ...// That's it.
C 语言的 printf("Hello") 到最终串口输出经历了:libc → stdout 缓冲 → write() 系统调用 → VFS 层 → TTY 行规则 → 串口驱动 → out 指令。汇编删除了所有中间层——只有 out 指令。This is ONLY possible in assembly.