mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2181 字
6 分钟
代码重构:模块化架构设计
2024-03-28

内核能运行了,但代码还挤在一个文件里。每次改动都要在几百行代码中翻找,VGA 显存地址散落在各处,端口 I/O 内联汇编复制粘贴了三四遍,稍有不慎就引入新问题。这种状况无法持续,是时候把代码拆成分工明确的模块了。

代码现状与重构目标#

当前内核代码有三个核心问题:硬编码的 VGA 显存地址和常量重复出现,没有统一接口;直接操作硬件寄存器,缺乏类型定义,可读性差;添加新功能需要修改多处代码,没有模块边界。

06.refactoring

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_tuint16_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 驱动采用分层设计,从底层硬件操作到高层格式化输出:

graph TD A[vga_printf<br/>格式化输出] --> B[vga_write<br/>字符串输出] B --> C[vga_putc<br/>单字符输出] C --> D[硬件显存操作] D --> E[VGA 显存<br/>0xC00B8000] C --> F[vga_sync_cursor<br/>光标同步] F --> G[VGA CRT 控制器<br/>端口 0x3D4/0x3D5] C --> H[滚动处理<br/>屏幕内容上移]

VGA 文本模式使用 80×25 的字符网格,每个字符占用 2 字节:第 1 字节是 ASCII 字符码,第 2 字节是颜色属性(背景色 4 位 + 前景色 4 位)。颜色属性结构:

76 - 43 - 10
字段BLINKBG(背景色)FG(前景色)I(亮度)
宽度1 bit3 bit3 bit1 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 控制器的寄存器:

1
计算线性位置 row * 80 + col
2
写地址端口 VGA_CRTC_CURSOR_LOC_HI
3
写数据端口 位置高字节
4
写地址端口 VGA_CRTC_CURSOR_LOC_LO
5
写数据端口 位置低字节
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 驱动的核心逻辑:

flowchart TD A[调用 vga_putc] --> B{字符类型?} B -->|换行符| C[row++, col=0] B -->|普通字符| D["写入显存<br/>color &lt;&lt; 8 | char"] D --> E[col++] E --> F{"列&gt;=80?"} F -->|是| G[col=0, row++] F -->|否| H[检查滚动] G --> H C --> H H --> I{"row&gt;=25?"} I -->|是| J[屏幕上移一行] J --> K[清空最后一行] K --> L[row=24] I -->|否| M[同步硬件光标] L --> M M --> N[完成]
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 地址空间(与内存地址空间分开),需要通过专用的 inout 指令访问硬件端口。直接使用内联汇编虽然可行,但代码冗余(每次都要编写相同的汇编代码)、类型不安全(容易误用数据类型)、可读性差(汇编代码难以理解)。封装端口 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.refactoring
make all # 编译所有模块
make run # 在 QEMU 中运行
make clean # 清理编译产物

预期输出#

Started in 16-bit real mode (BIOS)
Now in 32-bit protected mode (direct video)
Now Enable Page
Hello, kernel world!
Value: 1234, Hex: 0x4d2, Char: A, String: VGA printf OK!

验证要点#

  1. 类型系统:确保编译器正确识别自定义类型(如 uint8_tuintptr_t

  2. VGA 驱动

    • 文本正常显示,颜色正确
    • 光标跟随输出移动
    • 屏幕满时自动滚动
  3. 格式化输出

    • 整数正确显示
    • 十六进制正确显示
    • 字符串正确显示
  4. 端口 I/O:光标位置正确同步到硬件

踩坑记录#

  1. VGA 输出乱码

    • 原因:颜色属性或字符编码错误
    • 解决方案:检查 (color << 8) | c 的位操作,确保颜色在高位,字符在低位
  2. 格式化输出错误

    • 原因:可变参数处理不当,va_startva_end 不匹配
    • 解决方案:确保每个 va_start 都有对应的 va_end
  3. 光标位置不正确

    • 原因:滚动后未更新光标位置,或未调用 vga_sync_cursor
    • 解决方案:在每次输出后调用 vga_sync_cursor,特别是滚动操作后
  4. 类型大小不一致

    • 原因:在不同平台上,类型大小可能不同
    • 解决方案:使用 types.h 中定义的固定宽度类型(如 uint32_t),而不是原生类型
  5. 编译警告或错误

    • 原因:内联汇编语法不正确,或编译器不支持某些扩展
    • 解决方案:确保使用 GCC 兼容的编译器,检查汇编约束语法

下一章将基于这个模块化架构,实现中断系统,让内核获得响应硬件事件的能力。

参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

代码重构:模块化架构设计
https://blog.souloss.cn/posts/os/refactoring-modular-architecture/
作者
Souloss
发布于
2024-03-28
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时