前言:保护模式下的“黑暗时代”
在上一章成功切换到32位保护模式后,我们获得了一个强大的能力,同时也失去了一个重要的拐杖:BIOS中断彻底失效了。
这意味着什么?意味着当你的内核代码出现Bug导致Triple Fault时,你再也无法用 int 0x10 打印错误信息。你只能面对QEMU的黑屏或无限重启,靠肉眼猜测和GDB单步调试来定位问题。这种“盲调”状态是OS开发效率的杀手。
本章我们将为内核安装一套独立的“嘴巴”和“耳朵”:UART串口驱动。有了它,无论内核处于什么模式、是否崩溃,我们都能通过宿主机的终端实时观察内核的运行状态。这是后续所有章节高效开发的基石。
本章里程碑:
- ✅ 理解UART 16550寄存器模型与I/O端口编程
- ✅ 实现串口初始化、字符收发底层驱动
- ✅ 构建支持格式化输出的 kprintf 函数
- ✅ QEMU串口重定向到宿主机终端,实现实时日志
核心概念:UART 16550 与 I/O 端口
UART(Universal Asynchronous Receiver/Transmitter)是最古老、最可靠的硬件通信接口之一。x86 PC兼容机通常将COM1映射到I/O端口 0x3F8。与内存映射不同,访问UART需要使用专用的 in/out 指令。
关键寄存器(COM1, Base=0x3F8):
| +0 | RBR (接收缓冲) | THR (发送缓冲) | 数据读写端口 |
| +1 | IER (中断使能) | IER | 控制中断触发条件 |
| +2 | IIR (中断标识) | FCR (FIFO控制) | 读取中断原因/配置FIFO |
| +3 | LCR (线路控制) | LCR | 设置波特率、数据位、停止位 |
| +4 | MCR (调制解调器控制) | MCR | DTR/RTS等信号控制 |
| +5 | LSR (线路状态) | LSR | 最关键:检查发送就绪/接收就绪 |
⚠️ DLAB陷阱:当LCR的最高位(DLAB=1)时,+0和+1端口变为波特率分频器的低/高字节。初始化时必须先设DLAB=1写波特率,再清DLAB=0恢复正常读写。忘记这一步是串口不工作的最常见原因。
实战代码
串口驱动:serial.c
// serial.c – UART 16550 串口驱动
#include <stdint.h>
#define COM1_PORT 0x3F8
// 初始化 COM1: 9600 baud, 8N1, 无中断
void serial_init(void) {
// 禁用所有中断
outb(COM1_PORT + 1, 0x00);
// 启用 DLAB,设置波特率分频器
outb(COM1_PORT + 3, 0x80);
outb(COM1_PORT + 0, 0x0C); // 115200 / 9600 = 12 (0x0C)
outb(COM1_PORT + 1, 0x00);
// 关闭 DLAB,设置 8数据位、无校验、1停止位
outb(COM1_PORT + 3, 0x03);
// 启用 FIFO,清空收发缓冲区
outb(COM1_PORT + 2, 0xC7);
// 设置 MCR: DTR + RTS + OUT2
outb(COM1_PORT + 4, 0x0B);
}
// 等待发送缓冲区就绪
static int serial_is_transmit_empty(void) {
return inb(COM1_PORT + 5) & 0x20;
}
// 发送单个字符
void serial_putchar(char c) {
while (!serial_is_transmit_empty()) {
// 忙等待
}
outb(COM1_PORT + 0, c);
}
// 发送字符串
void serial_puts(const char *s) {
while (*s) {
if (*s == '\\n') serial_putchar('\\r'); // 串口需要 \\r\\n
serial_putchar(*s++);
}
}
// === I/O 端口操作内联汇编 ===
static inline void outb(uint16_t port, uint8_t val) {
__asm__ volatile ("outb %0, %1" : : "a"(val), "Nd"(port));
}
static inline uint8_t inb(uint16_t port) {
uint8_t ret;
__asm__ volatile ("inb %1, %0" : "=a"(ret) : "Nd"(port));
return ret;
}
格式化输出:kprintf.c
// kprintf.c – 极简 printf 实现(仅支持 %d, %x, %s, %c)
#include <stdarg.h>
extern void serial_putchar(char c);
extern void serial_puts(const char *s);
static void print_uint(uint32_t n, int base) {
char buf[12];
int i = 0;
const char *digits = "0123456789abcdef";
if (n == 0) { serial_putchar('0'); return; }
while (n > 0) {
buf[i++] = digits[n % base];
n /= base;
}
while (–i >= 0) serial_putchar(buf[i]);
}
void kprintf(const char *fmt, …) {
va_list args;
va_start(args, fmt);
for (const char *p = fmt; *p; p++) {
if (*p != '%') {
if (*p == '\\n') serial_putchar('\\r');
serial_putchar(*p);
continue;
}
switch (*++p) {
case 'd': print_uint(va_arg(args, uint32_t), 10); break;
case 'x': print_uint(va_arg(args, uint32_t), 16); break;
case 's': serial_puts(va_arg(args, const char*)); break;
case 'c': serial_putchar(va_arg(args, int)); break;
case '%': serial_putchar('%'); break;
default: serial_putchar('%'); serial_putchar(*p); break;
}
}
va_end(args);
}
Stage2 集成测试
在 stage2.asm 的32位代码段末尾,调用C语言入口:
; 在 start_protected_mode 中,初始化栈后添加:
extern serial_init
extern kprintf
call serial_init
push msg_hello
call kprintf
add esp, 4
; …
section .data
msg_hello: db "[Kernel] Serial initialized! Welcome to protected mode.%n", 0
📝 注意:你需要一个链接脚本(linker.ld)将 .text, .rodata, .data, .bss 正确排列,并确保C代码的入口地址与Stage2的跳转目标一致。完整示例见GitHub仓库。
Makefile 更新
# 编译 C 文件(交叉编译器,无标准库)
CC = x86_64-elf-gcc
CFLAGS = -m32 -std=c11 -ffreestanding -fno-pie -fno-stack-protector -Wall -O2
serial.o: serial.c
$(CC) $(CFLAGS) -c $< -o $@
kprintf.o: kprintf.c
$(CC) $(CFLAGS) -c $< -o $@
# 链接所有目标文件
kernel.bin: stage2.o serial.o kprintf.o linker.ld
$(CC) $(CFLAGS) -T linker.ld -nostdlib -o $@ $^ -lgcc
# QEMU 串口重定向到终端
run: os.img
$(QEMU) -drive format=raw,file=$<,index=0,media=disk \\
-serial stdio -display none
运行 make run,你的终端将直接显示:
[Kernel] Serial initialized! Welcome to protected mode.
关键细节解析
1. 为什么用 -ffreestanding 和 -nostdlib? 我们的内核没有glibc,没有malloc,没有printf。这两个标志告诉GCC:“不要假设任何标准库存在”。所有需要的函数必须自己实现或从libgcc获取(如64位除法)。
2. 为什么 serial_putchar 要忙等待? UART的发送缓冲区只有16字节(16550A)。如果不检查LSR的THRE位就连续写入,超出缓冲区的字符会被静默丢弃。忙等待虽然低效,但在早期开发阶段完全够用。后期可改为中断驱动。
3. -serial stdio 的魔力 这个QEMU参数将虚拟串口绑定到宿主机的标准输入输出。这意味着你的终端既是控制台又是调试窗口。配合 -display none 可以完全脱离图形界面,纯命令行开发体验极佳。
调试Checklist:串口无输出怎么办?
| 终端无任何输出 | QEMU未加 -serial stdio / 端口地址错误 | 确认Makefile中QEMU参数;验证COM1=0x3F8 |
| 输出乱码 | 波特率不匹配 / DLAB未正确切换 | 检查分频器值;确认LCR.7在写波特率后清零 |
| 只输出部分字符 | 发送前未检查THRE / FIFO未启用 | 确认 serial_is_transmit_empty() 逻辑 |
| GDB显示卡在outb | 端口不存在 / QEMU未模拟串口 | 检查QEMU启动参数是否有 -serial |
🔧 终极验证手段:如果怀疑串口驱动有问题,先用QEMU监控器(Ctrl+Alt+2)执行 info chardev 确认串口设备已创建,再用 chardev-send-break serial0 测试连通性。
本章小结与下一步
今天我们为内核赋予了最重要的基础设施:
- ✅ 实现了完整的UART 16550驱动
- ✅ 构建了freestanding环境下的 kprintf
- ✅ 建立了QEMU串口实时日志通道
从此以后,每一个Bug都有迹可循,每一次进步都清晰可见。串口调试是OS开发从“玄学”走向“工程”的分水岭。
下一章预告:《物理内存管理器:让内核拥有“分配”的能力》
有了调试能力,我们终于可以开始管理真正的资源了。下一章我们将探测E820内存布局,实现基于Bitmap的物理页帧分配器。内核将第一次能够动态申请和释放内存,为虚拟内存和进程管理奠定基础。
参考资料
- OSDev Wiki – Serial Ports
- OSDev Wiki – Text Mode Cursor / Printing
- Intel SDM Vol.3 Chapter 18 (I/O Port Programming)
- 本系列完整代码:[你的GitHub仓库链接](Commit: m0n1o2p)
📝 作者注:这是《从零手写操作系统》系列的第04篇。如果你之前一直在“盲调”,这一章会让你体验到质的飞跃。建议在每个新功能的入口处都加上 kprintf 日志,养成“可观测性优先”的开发习惯。下一章,我们开始管理内存!
![[操作系统]操作系统文件系统与输入输出:架构、行为与调优-171主机测评](https://www.171host.com/wp-content/uploads/2026/08/20260808030509-6a769ce596f70-220x150.png)





