内核能运行了,但代码还挤在一个文件里。每次改动都要在几百行代码中翻找,VGA 显存地址散落在各处,端口 I/O 内联汇编复制粘贴了三四遍,稍有不慎就引入新问题。这种状况无法持续,是时候把代码拆成分工明确的模块了。
代码现状与重构目标
当前内核代码有三个核心问题:硬编码的 VGA 显存地址和常量重复出现,没有统一接口;直接操作硬件寄存器,缺乏类型定义,可读性差;添加新功能需要修改多处代码,没有模块边界。
boot
mbr.S
loader.S
kernel
include
types.h
vga.h
io.h
ports.h
gdt.h
interrupt.h
memory.h
task.h
drivers
vga.c
kernel.c
link.ld
Makefile
模块化设计
基础类型定义系统
裸机环境没有标准库,stdint.h 不存在,uint8_t、uint16_t 这些类型统统不可用。直接写 unsigned short 有两个问题:可读性差(看不出是 16 位还是平台相关宽度),移植性差(换个编译器,unsigned short 可能不是 16 位)。内核代码对类型宽度极其敏感,GDT 描述符的段限长就是 20 位,差一个 bit 都会导致段描述符错乱。
解决思路是分层定义:先定义简短的内部类型名,再为它们创建兼容 stdint 风格的别名。
/* 固定宽度整数类型(便于可读性) */typedef unsigned char u8;typedef unsigned short u16;typedef unsigned int u32;typedef unsigned long long u64;
/* 兼容 stdint 风格的别名(便于代码移植) */typedef u8 uint8_t;typedef u16 uint16_t;typedef u32 uint32_t;typedef u64 uint64_t;
/* 指针相关与大小类型(针对 32-bit) */typedef unsigned int uintptr_t;typedef unsigned int size_t;
/* 物理/虚拟地址类型(便于代码可读性) */typedef uintptr_t phys_addr_t;typedef uintptr_t virt_addr_t;除了类型定义,还提供了大量实用宏:
/* 计算数组元素数量 */#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))
/* 位操作 */#define BIT(n) (1U << (n))
/* 最小/最大 */#define MIN(a, b) (((a) < (b)) ? (a) : (b))#define MAX(a, b) (((a) > (b)) ? (a) : (b))
/* 对齐:向上对齐到 align(align 必须是 2 的幂) */#define ALIGN_UP(x, align) ((((uintptr_t)(x)) + ((align) - 1)) & ~((uintptr_t)((align) - 1)))这些宏在内核开发中经常使用,例如内存对齐、位标志操作等。
有了类型定义的基础,接下来处理内核中重复最多的代码,VGA 显存操作。
VGA 驱动封装
直接操作 VGA 显存虽然简单,但缺乏光标管理(无法自动移动光标)、没有滚动支持(屏幕满时无法自动滚动)、颜色控制困难(无法方便地设置文本颜色)、接口不统一(每次输出都要重复相同的代码)。封装 VGA 驱动可以提供更高级、更易用的接口。
VGA 驱动采用分层设计,从底层硬件操作到高层格式化输出:
VGA 文本模式使用 80×25 的字符网格,每个字符占用 2 字节:第 1 字节是 ASCII 字符码,第 2 字节是颜色属性(背景色 4 位 + 前景色 4 位)。颜色属性结构:
| 位 | 7 | 6 - 4 | 3 - 1 | 0 |
|---|---|---|---|---|
| 字段 | BLINK | BG(背景色) | FG(前景色) | I(亮度) |
| 宽度 | 1 bit | 3 bit | 3 bit | 1 bit |
VGA 驱动使用静态变量维护当前状态:
static uint16_t *const VGA_BUF = (uint16_t *)VGA_MEMORY; // 显存地址(0xC00B8000)static uint8_t cursor_row = 0; // 当前行(0-24)static uint8_t cursor_col = 0; // 当前列(0-79)static uint8_t current_color = VGA_WHITE_ON_BLACK; // 当前颜色属性其中用到的颜色常量和硬件端口定义:
/* 颜色属性宏:前景色 fg(0-15)占低 4 位,背景色 bg(0-7)占高 4 位 */#define VGA_COLOR(fg, bg) ((bg << 4) | (fg))#define VGA_WHITE_ON_BLACK VGA_COLOR(15, 0)VGA 文本模式共 16 种颜色(0-15),亮色 = 暗色 + 8。颜色属性由 VGA_COLOR(fg, bg) 组合生成,例如 VGA_COLOR(15, 0) 表示白字黑底(即 VGA_WHITE_ON_BLACK)。需要其他配色时直接调用宏即可,无需为每种组合预定义具名常量。
#define VGA_MEMORY ((volatile uint16_t *)0xC00B8000)#define VGA_WIDTH 80#define VGA_HEIGHT 25
#define VGA_CRTC_ADDR 0x3D4#define VGA_CRTC_DATA 0x3D5#define VGA_CRTC_CURSOR_LOC_HI 0x0E#define VGA_CRTC_CURSOR_LOC_LO 0x0F光标同步是 VGA 驱动的基础操作,需要将软件维护的光标位置写入 VGA CRT 控制器的寄存器:
static void vga_sync_cursor(){ uint16_t cursorLocation = cursor_row * 80 + cursor_col; outb(VGA_CRTC_ADDR, VGA_CRTC_CURSOR_LOC_HI); outb(VGA_CRTC_DATA, cursorLocation >> 8); outb(VGA_CRTC_ADDR, VGA_CRTC_CURSOR_LOC_LO); outb(VGA_CRTC_DATA, cursorLocation);}将二维坐标(行、列)转换为一维位置,然后分两次写入 VGA CRT 控制器:先向地址端口 0x3D4 写入寄存器索引,再向数据端口 0x3D5 写入对应的数据。这种分两次写入的方式是因为 VGA CRT 控制器使用 16 位的光标位置寄存器,但只能通过 8 位端口访问。
字符输出与滚动是 VGA 驱动的核心逻辑:
void vga_putc(char c){ if (c == '\n') { cursor_row++; cursor_col = 0; } else { VGA_BUF[cursor_row * VGA_WIDTH + cursor_col] = (current_color << 8) | c; cursor_col++; if (cursor_col >= VGA_WIDTH) { cursor_col = 0; cursor_row++; } }
// 滚动处理 if (cursor_row >= VGA_HEIGHT) { // 将所有行上移一行 for (int i = 0; i < (VGA_HEIGHT - 1) * VGA_WIDTH; i++) { VGA_BUF[i] = VGA_BUF[i + VGA_WIDTH]; } // 清空最后一行 for (int i = (VGA_HEIGHT - 1) * VGA_WIDTH; i < VGA_HEIGHT * VGA_WIDTH; i++) { VGA_BUF[i] = (current_color << 8) | ' '; } cursor_row = VGA_HEIGHT - 1; } vga_sync_cursor();}换行符将光标移到下一行开头;普通字符写入显存,颜色属性在高 8 位,字符在低 8 位。当列号超过 79 时自动换行。当行号超过 24 时触发滚动:将第 1-24 行的内容复制到第 0-23 行,清空第 24 行,光标回到最后一行。这种滚动方式类似于终端的行缓冲,确保屏幕始终显示最新的 25 行内容。
有了 vga_putc,字符串输出只需遍历逐字符调用即可:
void vga_write(const char *str){ while (*str) vga_putc(*str++);}VGA 驱动封装好了,但调试内核时只靠逐字符输出远远不够,还需要格式化输出能力。
格式化输出实现
调试内核时,经常需要输出变量的当前值、内存地址、函数执行状态、错误信息。如果没有格式化输出函数,每次都需要手动转换类型,非常繁琐。vga_printf 提供类似标准 printf 的功能,方便调试工作。
通过 va_list 遍历可变参数列表,实现格式化输出:
void vga_printf(const char *fmt, ...){ char buf[32]; // 整数转字符串的临时缓冲区 va_list args; va_start(args, fmt); // 初始化参数列表
for (const char *p = fmt; *p; p++) { if (*p != '%') { vga_putc(*p); // 普通字符直接输出 continue; }
p++; // 跳过 '%'
switch (*p) { case 'c': // 字符 char c = (char)va_arg(args, int); vga_putc(c); break; case 's': // 字符串 const char *s = va_arg(args, const char *); vga_write(s); break; case 'd': // 十进制整数 int v = va_arg(args, int); vga_itoa(v, buf, 10); vga_write(buf); break; case 'x': // 十六进制整数 v = va_arg(args, int); vga_itoa(v, buf, 16); vga_write(buf); break; case '%': // 百分号 vga_putc('%'); break; } } va_end(args); // 清理参数列表}支持的格式符:%c(单个字符)、%s(字符串)、%d/%i(十进制整数)、%x/%X(十六进制整数)、%%(百分号)。
格式化输出的底层依赖整数转字符串函数 vga_itoa:
static void vga_itoa(int value, char *buf, int base){ char *p = buf; unsigned int v = (base == 10 && value < 0) ? -value : value;
// 生成逆序的数字字符串 do { int digit = v % base; *p++ = (digit < 10) ? '0' + digit : 'a' + (digit - 10); v /= base; } while (v);
// 处理负号 if (base == 10 && value < 0) *p++ = '-';
*p = '\0';
// 反转字符串 for (char *a = buf, *b = p - 1; a < b; a++, b--) { char tmp = *a; *a = *b; *b = tmp; }}如果是十进制且值为负数,先转为正数处理,最后添加负号。使用模运算逐位提取数字,由于从低位到高位提取,生成的字符串是逆序的,最后用双指针法反转。0-9 的数字用 '0' + digit 转换,10-15 用 'a' + (digit - 10) 转换。这个算法支持十进制(base=10)和十六进制(base=16)转换。
格式化输出用到了 outb 函数来同步光标,而这个函数本身也需要封装,接下来处理端口 I/O。
端口 I/O 封装
x86 架构使用独立的 I/O 地址空间(与内存地址空间分开),需要通过专用的 in 和 out 指令访问硬件端口。直接使用内联汇编虽然可行,但代码冗余(每次都要编写相同的汇编代码)、类型不安全(容易误用数据类型)、可读性差(汇编代码难以理解)。封装端口 I/O 操作可以提供类型安全、易于使用的接口。
利用 GCC 的扩展内联汇编语法,封装字节级的端口读写:
static inline uint8_t inb(uint16_t port){ uint8_t result; asm volatile( "inb %1, %0" // 输入字节指令 : "=a"(result) // 输出操作数(AL 寄存器) : "Nd"(port) // 输入操作数(立即数或 DX 寄存器) ); return result;}
static inline void outb(uint16_t port, uint8_t data){ asm volatile( "outb %0, %1" // 输出字节指令 : // 无输出 : "a"(data), "Nd"(port) // 输入操作数(AL 寄存器和端口) );}汇编模板中,%0、%1 是操作数占位符。"=a"(result) 是输出操作数,= 表示只写,a 表示使用 EAX/AX/AL 寄存器。"Nd"(port) 是输入操作数,N 表示立即数(0-255),d 表示 DX 寄存器。volatile 告诉编译器不要优化这段汇编。
运行与验证
编译运行
cd 06.refactoringmake all # 编译所有模块make run # 在 QEMU 中运行make clean # 清理编译产物预期输出
Started in 16-bit real mode (BIOS)Now in 32-bit protected mode (direct video)Now Enable PageHello, kernel world!Value: 1234, Hex: 0x4d2, Char: A, String: VGA printf OK!验证要点
-
类型系统:确保编译器正确识别自定义类型(如
uint8_t、uintptr_t) -
VGA 驱动:
- 文本正常显示,颜色正确
- 光标跟随输出移动
- 屏幕满时自动滚动
-
格式化输出:
- 整数正确显示
- 十六进制正确显示
- 字符串正确显示
-
端口 I/O:光标位置正确同步到硬件
踩坑记录
-
VGA 输出乱码:
- 原因:颜色属性或字符编码错误
- 解决方案:检查
(color << 8) | c的位操作,确保颜色在高位,字符在低位
-
格式化输出错误:
- 原因:可变参数处理不当,
va_start和va_end不匹配 - 解决方案:确保每个
va_start都有对应的va_end
- 原因:可变参数处理不当,
-
光标位置不正确:
- 原因:滚动后未更新光标位置,或未调用
vga_sync_cursor - 解决方案:在每次输出后调用
vga_sync_cursor,特别是滚动操作后
- 原因:滚动后未更新光标位置,或未调用
-
类型大小不一致:
- 原因:在不同平台上,类型大小可能不同
- 解决方案:使用
types.h中定义的固定宽度类型(如uint32_t),而不是原生类型
-
编译警告或错误:
- 原因:内联汇编语法不正确,或编译器不支持某些扩展
- 解决方案:确保使用 GCC 兼容的编译器,检查汇编约束语法
下一章将基于这个模块化架构,实现中断系统,让内核获得响应硬件事件的能力。
参考
- OSDev Wiki - Bare Bones - 内核开发入门教程
- VGA Text Mode - VGA 文本模式编程参考
- Printf Implementation - printf 格式化字符串规范
- x86 I/O Ports - x86 端口 I/O 编程指南
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






