1. 引言:为什么内核需要「会说话」

在操作系统内核开发中,最令人沮丧的时刻莫过于系统启动后屏幕上只有一片漆黑,没有任何输出,开发者只能依靠猜想来定位问题。对于 Cinux 这样一个从零开始构建的操作系统内核项目而言,让内核「会说话」是开发过程中必须迈出的第一步。这里所说的「会说话」,指的是内核能够通过串口输出调试信息,将内部状态、错误信息、函数调用轨迹等内容实时传递给开发者。

串口输出之所以成为内核调试的首选方案,是因为它足够简单、足够可靠。在图形界面尚未初始化、文件系统尚未挂载、甚至内存管理尚未完全就绪的早期启动阶段,串口几乎是唯一可用的输出通道。通过 UART 控制器将字符逐个发送到宿主机上的终端模拟器,开发者就能实时观察内核的一举一动。

而 kprintf 则是内核日志系统的核心。它类似于用户态 C 库中的 printf,但专门为内核环境设计。kprintf 不仅要支持可变参数、格式化输出,还必须在关闭中断、持有自旋锁等极端环境下安全运行。一个设计良好的 kprintf 实现,是整个内核调试体系的基石。

「双轨测试」则是本文提出的另一个重要概念。它指的是将串口日志输出与自动化测试框架结合起来,形成两条相互补充的验证轨道:第一条轨道通过串口日志提供运行时可见性,让开发者能够实时追踪内核行为;第二条轨道通过结构化测试框架提供可重复的验证能力,让内核的正确性得到系统化保障。这两条轨道相辅相成,共同构成了 Cinux 项目的质量保障体系。

本文将从串口通信的硬件原理讲起,逐步深入到串口驱动的实现、kprintf 的完整设计与编码、双轨测试框架的构建,最后通过实战案例展示这套体系如何帮助开发者在真实场景中快速定位和解决问题。全文包含大量可运行的 C 语言代码示例,所有代码均针对 Cinux 内核的实际需求设计,读者可以直接在自己的内核项目中参考使用。

2. 串口通信基础:UART 的工作原理

在动手编写串口驱动之前,理解 UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)的工作原理是至关重要的。UART 是一种异步串行通信协议,它不需要时钟信号线,发送方和接收方通过预先约定的波特率来同步数据传输。

UART 的数据帧格式由以下几个部分组成:起始位、数据位、可选的奇偶校验位和停止位。数据线在空闲状态下保持高电平,当需要发送数据时,发送方首先将数据线拉低一个比特周期,这就是起始位。接收方检测到起始位的下降沿后,会按照约定的波特率在后续的比特周期中点采样数据线,依次读取数据位。

一个典型的 UART 数据帧包含 1 个起始位、8 个数据位和 1 个停止位,总共 10 个比特。如果波特率设定为 115200,那么每个比特的持续时间约为 8.68 微秒。这意味着发送一个字节大约需要 86.8 微秒。这个速度虽然远低于现代网络传输,但对于内核调试日志来说已经完全足够。

在 x86 架构的 PC 平台上,串口通常使用 16550 UART 芯片。这款芯片提供了一组内存映射或端口映射的寄存器,通过读写这些寄存器,CPU 可以控制串口的行为。对于 Cinux 这样运行在 x86 平台上的内核来说,使用 x86 的 I/O 端口指令来访问这些寄存器是最直接的方式。

16550 UART 的寄存器布局相对简单。在 COM1 端口的默认基地址 0x3F8 上,各个关键寄存器的偏移量如下:数据寄存器(RBR/THR)偏移 0,中断使能寄存器(IER)偏移 1,中断标识寄存器(IIR)偏移 2,线路控制寄存器(LCR)偏移 3,调制解调器控制寄存器(MCR)偏移 4,线路状态寄存器(LSR)偏移 5。其中 LSR 的第 5 位(THR Empty)指示发送保持寄存器是否为空,这是轮询发送时需要检查的关键状态位。

线路控制寄存器(LCR)的配置对于串口初始化至关重要。LCR 的第 0 位和第 1 位共同决定了数据位的长度,00 表示 5 位,01 表示 6 位,10 表示 7 位,11 表示 8 位。第 2 位控制停止位的数量,0 表示 1 个停止位,1 表示 1.5 或 2 个停止位。第 3 位到第 5 位控制奇偶校验。第 7 位是 DLAB(Divisor Latch Access Bit)标志,当该位为 1 时,对地址 0 和 1 的访问会映射到分频寄存器的低字节和高字节。

波特率的计算需要通过分频寄存器来完成。分频值由以下公式计算:分频值 = 输入时钟频率 / (16 × 目标波特率)。对于标准的 16550 UART,输入时钟频率为 1.8432 MHz。如果目标波特率为 38400,则分频值为 1.8432e6 / (16 × 38400) = 3。如果目标波特率为 115200,分频值则为 1。这些计算在串口初始化代码中非常重要。

理解这些硬件细节之后,我们就可以开始编写 Cinux 的串口驱动了。接下来的章节将展示如何将这些寄存器操作封装成简洁易用的接口,为后续的 kprintf 实现打下坚实的基础。

3. 串口驱动实现:从寄存器到 API

在 Cinux 内核中,串口驱动被设计为分层结构。最底层是硬件抽象层,直接操作 I/O 端口和寄存器;中间层提供数据收发的基本函数;最上层则是面向内核其他模块的高级接口。这种分层设计使得串口驱动可以在不同硬件平台之间移植,也便于后续扩展对多个串口设备的支持。

3.1 端口 I/O 封装

x86 架构提供了专门的 I/O 端口指令来访问外设寄存器。在 C 语言中,这些指令通常通过内联汇编或编译器内置函数来封装。Cinux 定义了以下两个基本的端口操作函数:

#include <stdint.h>

static inline void outb(uint16_t port, uint8_t value) {
    __asm__ volatile("outb %0, %1" : : "a"(value), "Nd"(port));
}

static inline uint8_t inb(uint16_t port) {
    uint8_t ret;
    __asm__ volatile("inb %1, %0" : "=a"(ret) : "Nd"(port));
    return ret;
}

这两个函数分别用于向指定端口写入一个字节和从指定端口读取一个字节。`outb` 使用 `outb` 指令将 AL 寄存器中的值写入 DX 指定的端口,`inb` 使用 `inb` 指令从端口读取数据到 AL 寄存器。这里的 `volatile` 关键字告诉编译器不要优化掉这些 I/O 操作,因为它们有副作用。

需要注意的是,端口号是 16 位的整数。在 `__asm__` 语句中,`"Nd"` 约束表示操作数必须是一个立即数或 DX 寄存器。当端口号是编译期常量时,编译器会直接生成 `outb %al, $0x3F8` 这样的指令;当端口号是变量时,编译器会将端口号加载到 DX 寄存器中,生成 `outb %al, %dx` 指令。

3.2 串口初始化

串口初始化的目标是配置波特率、数据格式和中断行为,使串口处于可用的工作状态。Cinux 的串口初始化函数如下:

#define COM1_PORT 0x3F8

#define UART_DATA_REG          (COM1_PORT + 0)
#define UART_IER_REG           (COM1_PORT + 1)
#define UART_IIR_REG           (COM1_PORT + 2)
#define UART_LCR_REG           (COM1_PORT + 3)
#define UART_MCR_REG           (COM1_PORT + 4)
#define UART_LSR_REG           (COM1_PORT + 5)

void serial_init(void) {
    /* 关闭所有中断 */
    outb(UART_IER_REG, 0x00);
    
    /* 启用 DLAB,设置波特率分频值 */
    outb(UART_LCR_REG, 0x80);
    outb(UART_DATA_REG, 0x03);  /* 低字节 */
    outb(UART_IER_REG, 0x00);   /* 高字节 */
    
    /* 8 位数据位、1 个停止位、无奇偶校验 */
    outb(UART_LCR_REG, 0x03);
    
    /* 启用 FIFO,清空缓冲区,14 字节触发阈值 */
    outb(UART_IIR_REG, 0xC7);
    
    /* 启用 DTR、RTS 和 OUT2 */
    outb(UART_MCR_REG, 0x0B);
}

这段代码首先关闭串口的所有中断,然后通过设置 DLAB 位来访问分频寄存器。分频值 0x0003 对应 38400 波特率,这是 Cinux 在开发阶段默认使用的波特率。选择这个波特率的原因是在 QEMU 等模拟器中,38400 波特率可以提供稳定可靠的传输,不会出现数据丢失。

接下来,线路控制寄存器被配置为 0x03,表示 8 个数据位、1 个停止位、无奇偶校验。这是最常见、最简洁的配置。FIFO 控制寄存器被设置为 0xC7,启用了发送和接收 FIFO,并将接收 FIFO 的触发阈值设为 14 字节。启用 FIFO 可以显著减少中断频率,提高串口传输效率。

最后,调制解调器控制寄存器被设置为 0x0B。这个值启用了数据终端就绪(DTR)、请求发送(RTS)和 OUT2 信号。在 PC 架构中,OUT2 信号控制着串口的中断线路,如果不启用 OUT2,串口将无法向中断控制器发出中断请求。

3.3 字符发送与接收

在轮询模式下,发送一个字符需要先检查线路状态寄存器的发送保持寄存器空标志,然后才能写入数据。接收一个字符则需要检查数据就绪标志。Cinux 实现了以下两个基础函数:

static int serial_transmit_empty(void) {
    return inb(UART_LSR_REG) & 0x20;
}

static int serial_received(void) {
    return inb(UART_LSR_REG) & 0x01;
}

void serial_putc(char c) {
    while (serial_transmit_empty() == 0) {
        /* 等待发送保持寄存器为空 */
    }
    outb(UART_DATA_REG, c);
}

char serial_getc(void) {
    while (serial_received() == 0) {
        /* 等待数据到达 */
    }
    return inb(UART_DATA_REG);
}

`serial_putc` 函数在发送字符前会不断轮询线路状态寄存器的第 5 位。当该位为 1 时,表示发送保持寄存器为空,可以安全地写入新数据。这种轮询方式简单可靠,但在高速输出大量日志时会占用 CPU 资源,因为 CPU 需要等待串口完成发送。

对于内核调试场景,这种轮询等待通常是可接受的。内核日志的输出量通常不会大到让串口成为瓶颈。如果确实需要更高性能的输出,可以考虑使用中断驱动的发送方式,或者增加内存缓冲区来批量发送。

`serial_getc` 函数的行为类似,它等待线路状态寄存器的第 0 位变为 1,表示接收缓冲区中有数据可用。这个函数通常用于等待用户从宿主机发送命令,例如在交互式调试会话中读取开发者的输入。

3.4 字符串输出

有了字符发送函数之后,字符串输出就变得非常简单:

void serial_write(const char *str) {
    while (*str) {
        if (*str == '\n') {
            serial_putc('\r');  /* 回车 */
        }
        serial_putc(*str++);
    }
}

这个函数会遍历字符串中的每个字符并逐个发送。需要注意的是换行符的处理。在串口终端中,换行通常需要同时发送回车符(CR,ASCII 13)和换行符(LF,ASCII 10),否则光标只会下移而不会回到行首。Cinux 在这里自动将换行符转换为 CRLF 组合,确保日志在终端中正确显示。

对于二进制数据的输出,Cinux 还提供了带长度的发送函数:

void serial_write_n(const char *data, size_t len) {
    for (size_t i = 0; i < len; i++) {
        serial_putc(data[i]);
    }
}

这个函数在输出调试数据块、内存转储等场景中非常有用。与字符串输出不同,它不会自动处理换行符,而是原样输出所有字节。

3.5 中断驱动的串口接收

虽然轮询模式对于发送日志已经足够,但接收用户输入时,轮询会浪费大量 CPU 时间。更好的方式是使用中断驱动的接收。Cinux 在中断控制器初始化完成后,会启用串口接收中断,并安装相应的中断处理程序。

#define COM1_IRQ 4

void serial_enable_interrupt(void) {
    /* 启用接收数据可用中断 */
    outb(UART_IER_REG, 0x01);
}

void serial_irq_handler(void) {
    while (serial_received()) {
        char c = inb(UART_DATA_REG);
        serial_input_buffer_push(c);
    }
}

启用串口中断后,每当接收 FIFO 中有数据达到触发阈值时,串口就会向中断控制器发出 IRQ 4 中断请求。CPU 响应中断后,会调用 `serial_irq_handler` 函数。该函数会持续读取数据寄存器,直到接收缓冲区为空。

读取到的字符被推入一个环形缓冲区,供内核其他部分异步消费。这种设计避免了中断处理程序中的长时间阻塞,也避免了字符丢失。环形缓冲区的大小可以根据实际需求配置,Cinux 默认使用 256 字节的缓冲区,对于交互式命令输入来说已经足够。

4. kprintf 核心实现:从可变参数到格式化输出

kprintf 是内核日志系统的核心组件。它承担着将格式化字符串和可变参数转换为最终输出文本的职责。一个健壮的 kprintf 实现需要处理整数、无符号整数、十六进制数、指针、字符串、字符、百分号转义等多种格式说明符,并且必须在没有 C 标准库支持的内核环境中独立工作。

4.1 可变参数机制

在 x86-64 架构上,函数参数通过寄存器和栈传递。前六个整数或指针参数分别使用 RDI、RSI、RDX、RCX、R8、R9 寄存器,后续参数则压入栈中。可变参数机制需要能够遍历这些参数。Cinux 使用 GCC 和 Clang 都支持的内置类型和宏来实现可变参数访问:

typedef __builtin_va_list va_list;

#define va_start(ap, last) __builtin_va_start(ap, last)
#define va_arg(ap, type)   __builtin_va_arg(ap, type)
#define va_end(ap)         __builtin_va_end(ap)
#define va_copy(dest, src) __builtin_va_copy(dest, src)

这些宏直接封装了编译器的内置功能,避免了手工处理寄存器保存区和栈访问的复杂性。`va_start` 初始化参数指针,`va_arg` 按类型读取下一个参数并推进指针,`va_end` 清理状态。这种做法的好处是完全依赖编译器的 ABI 实现,无需针对不同架构编写不同的访问代码。

需要特别注意的是,调用 `va_start` 时传入的 `last` 参数必须是可变参数列表之前的最后一个固定参数。对于 kprintf 来说,这个固定参数就是格式化字符串指针。

4.2 格式化引擎设计

kprintf 的格式化引擎采用状态机设计。它逐个扫描格式化字符串中的字符,普通字符直接输出,遇到百分号时进入格式说明符解析状态。格式说明符的解析需要考虑标志、宽度、精度和长度修饰符,但为了保持代码简洁,Cinux 的初版实现只支持最常用的格式。

以下是 kprintf 的主体框架:

void kprintf(const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);
    kvprintf(fmt, ap);
    va_end(ap);
}

void kvprintf(const char *fmt, va_list ap) {
    while (*fmt) {
        if (*fmt != '%') {
            serial_putc(*fmt++);
            continue;
        }
        
        fmt++;  /* 跳过 % */
        
        /* 解析格式说明符 */
        switch (*fmt) {
        case 'd':
        case 'i':
            print_signed(va_arg(ap, int), 10);
            break;
        case 'u':
            print_unsigned(va_arg(ap, unsigned int), 10);
            break;
        case 'x':
            print_unsigned(va_arg(ap, unsigned int), 16);
            break;
        case 'X':
            print_unsigned_upper(va_arg(ap, unsigned int), 16);
            break;
        case 'o':
            print_unsigned(va_arg(ap, unsigned int), 8);
            break;
        case 'p':
            serial_write("0x");
            print_unsigned((uintptr_t)va_arg(ap, void *), 16);
            break;
        case 's': {
            char *str = va_arg(ap, char *);
            serial_write(str ? str : "(null)");
            break;
        }
        case 'c':
            serial_putc((char)va_arg(ap, int));
            break;
        case '%':
            serial_putc('%');
            break;
        default:
            serial_putc('%');
            serial_putc(*fmt);
            break;
        }
        fmt++;
    }
}

这个实现覆盖了内核日志中最常用的格式说明符。`%d` 和 `%i` 用于有符号十进制整数,`%u` 用于无符号十进制整数,`%x` 和 `%X` 用于十六进制,`%o` 用于八进制,`%p` 用于指针,`%s` 用于字符串,`%c` 用于字符,`%%` 用于输出百分号本身。

对于无法识别的格式说明符,默认行为是输出百分号和该字符本身。这种宽容的处理方式避免了因格式字符串中存在特殊字符而导致输出中断。在调试日志中,偶尔会因为拼写错误产生无效的格式说明符,宽容处理可以让日志继续输出后续内容。

4.3 数字格式化

数字格式化是 kprintf 中最复杂的部分。它需要将二进制数字转换为可读的十进制或十六进制文本,并且要正确处理负数、零和特殊值。

static void print_unsigned(uint64_t value, uint32_t base) {
    char buf[32];
    int i = 31;
    
    if (value == 0) {
        serial_putc('0');
        return;
    }
    
    while (value > 0) {
        uint64_t digit = value % base;
        buf[i--] = (digit < 10) ? ('0' + digit) : ('a' + digit - 10);
        value /= base;
    }
    
    for (i++; i < 32; i++) {
        serial_putc(buf[i]);
    }
}

static void print_signed(int64_t value, uint32_t base) {
    if (value < 0) {
        serial_putc('-');
        print_unsigned((uint64_t)(-value), base);
    } else {
        print_unsigned((uint64_t)value, base);
    }
}

`print_unsigned` 函数使用一个 32 字符的临时缓冲区,从后向前填充数字的各个位。对于 64 位数字的十六进制表示,最多需要 16 个字符;对于二进制表示,最多需要 64 个字符。32 字符的缓冲区对于十进制和十六进制来说绰绰有余。

除法和取模运算在数字转换中不可避免。对于 64 位数字使用这些运算,编译器会生成运行时库调用,例如 `__udivdi3`。在裸机内核环境中,这些函数需要自行实现或者链接相应的编译器运行时库。Cinux 在构建脚本中包含了 libgcc 的链接,确保这些 64 位运算能够正常工作。

对于性能敏感的日志输出路径,可以考虑使用查表法优化十进制转换,或者一次性输出多个字符。但 Cinux 的初版实现优先保证正确性和可读性,性能优化留待后续迭代。

4.4 格式修饰符支持

虽然初版 kprintf 已经能够满足大部分调试需求,但在实际使用中,宽度和填充控制往往能让日志的表格格式更加整齐。Cinux 在后续迭代中增加了对基本格式修饰符的支持,包括宽度、零填充和长度修饰符。

#define PRINTF_STATE_NORMAL  0
#define PRINTF_STATE_LENGTH  1
#define PRINTF_STATE_LENGTH2 2

static void kvprintf_extended(const char *fmt, va_list ap) {
    while (*fmt) {
        if (*fmt != '%') {
            serial_putc(*fmt++);
            continue;
        }
        
        fmt++;
        
        int width = 0;
        int zero_pad = 0;
        int left_align = 0;
        int long_type = 0;
        int long_long = 0;
        
        /* 解析标志 */
        while (*fmt == '0' || *fmt == '-') {
            if (*fmt == '0') zero_pad = 1;
            if (*fmt == '-') left_align = 1;
            fmt++;
        }
        
        /* 解析宽度 */
        while (*fmt >= '0' && *fmt <= '9') {
            width = width * 10 + (*fmt - '0');
            fmt++;
        }
        
        /* 解析长度修饰符 */
        if (*fmt == 'l') {
            long_type = 1;
            fmt++;
            if (*fmt == 'l') {
                long_long = 1;
                fmt++;
            }
        }
        
        /* 处理格式说明符 */
        switch (*fmt) {
        case 'd':
        case 'i': {
            int64_t val = long_long ? va_arg(ap, long long) :
                         (long_type ? va_arg(ap, long) : va_arg(ap, int));
            print_signed_padded(val, 10, width, zero_pad, left_align);
            break;
        }
        case 'u': {
            uint64_t val = long_long ? va_arg(ap, unsigned long long) :
                          (long_type ? va_arg(ap, unsigned long) : va_arg(ap, unsigned int));
            print_unsigned_padded(val, 10, width, zero_pad, left_align);
            break;
        }
        case 'x': {
            uint64_t val = long_long ? va_arg(ap, unsigned long long) :
                          (long_type ? va_arg(ap, unsigned long) : va_arg(ap, unsigned int));
            print_unsigned_padded(val, 16, width, zero_pad, left_align);
            break;
        }
        case 's': {
            char *str = va_arg(ap, char *);
            print_string_padded(str ? str : "(null)", width, left_align);
            break;
        }
        default:
            serial_putc(*fmt);
            break;
        }
        fmt++;
    }
}

这个扩展版本增加了对 `%08x`、`%-10s`、`%ld`、`%llu` 等格式的支持。宽度指定了最小输出宽度,零填充标志决定不足部分填充的是空格还是零,左对齐标志决定内容在字段中的对齐方向。这些修饰符让日志中的表格和十六进制转储输出更加整洁。

长度修饰符 `l` 和 `ll` 分别对应 `long` 和 `long long` 类型。在 64 位内核中,`long` 是 64 位的,但 `int` 仍然是 32 位。正确处理长度修饰符对于输出 64 位地址、文件大小等大数值至关重要。

4.5 线程安全与中断安全

内核日志可能在任何上下文被调用,包括中断处理程序、自旋锁持有期间、多处理器环境下的临界区等。因此,kprintf 的线程安全和中断安全设计至关重要。

Cinux 使用一个全局自旋锁来保护串口输出的原子性。由于自旋锁在持有期间会关闭本地中断,这保证了输出过程中不会被中断打断,从而避免了多个上下文交错输出造成的乱码。

static spinlock_t print_lock;

void kprintf(const char *fmt, ...) {
    va_list ap;
    char buffer[1024];
    
    va_start(ap, fmt);
    int len = vsnprintf_internal(buffer, sizeof(buffer), fmt, ap);
    va_end(ap);
    
    spin_lock(&print_lock);
    for (int i = 0; i < len; i++) {
        serial_putc(buffer[i]);
    }
    spin_unlock(&print_lock);
}

这个实现先将格式化结果写入一个栈上的缓冲区,然后在自旋锁的保护下批量发送。这样做有两个好处:一是减少了持锁时间,格式化操作在锁外完成;二是保证了单条日志的原子性,不会被其他 CPU 上的日志输出打断。

缓冲区大小为 1024 字节,对于单条内核日志来说已经足够。如果需要输出更长的内容,可以增加缓冲区大小,或者使用动态分配。在极早期启动阶段,内存分配器尚未初始化,栈缓冲区是唯一可靠的选择。

需要注意的是,如果日志输出本身发生在自旋锁已经持有的上下文中,使用同一把自旋锁可能会导致死锁。Cinux 对此进行了特殊处理:在关中断环境中调用 kprintf 时,会检测当前是否已经持有所述锁,如果已经持有则直接输出而不重复加锁。这种递归锁的简化版本在调试日志场景中非常实用。

5. 日志系统设计:让信息有序流动

有了串口驱动和 kprintf 这两个基础设施之后,下一步是构建一个完整的日志系统。日志系统负责管理日志级别、统一输出格式、控制日志过滤,并提供给内核各模块使用的便捷接口。

5.1 日志级别

Cinux 定义了五个日志级别,从低到高依次是 DEBUG、INFO、WARN、ERROR 和 FATAL。每个级别都有明确的语义和使用场景:

#define LOG_LEVEL_DEBUG  0
#define LOG_LEVEL_INFO   1
#define LOG_LEVEL_WARN   2
#define LOG_LEVEL_ERROR  3
#define LOG_LEVEL_FATAL  4

#define LOG_DEBUG(fmt, ...) log_printf(LOG_LEVEL_DEBUG, "DEBUG", fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...)  log_printf(LOG_LEVEL_INFO,  "INFO ", fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...)  log_printf(LOG_LEVEL_WARN,  "WARN ", fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) log_printf(LOG_LEVEL_ERROR, "ERROR", fmt, ##__VA_ARGS__)
#define LOG_FATAL(fmt, ...) log_printf(LOG_LEVEL_FATAL, "FATAL", fmt, ##__VA_ARGS__)

DEBUG 级别用于最详细的调试信息,包括函数入口退出、变量值变化、状态机转移等。在开发阶段,DEBUG 级别的日志量可能非常大,通常会通过编译开关进行裁剪。INFO 级别记录重要的生命周期事件,如模块初始化、配置加载、服务启动等。WARN 级别表示非致命但需要关注的情况。ERROR 级别表示操作失败但系统仍可继续运行。FATAL 级别表示致命错误,通常会导致系统停止或重启。

日志级别的过滤通过编译时和运行时两个层面来控制。编译时过滤通过条件编译来实现,在发布版本中可以完全移除 DEBUG 级别的日志,减少代码体积和运行时开销。运行时过滤通过一个全局的日志级别阈值来控制,低于阈值的日志不会被输出。

5.2 统一格式

统一的日志格式对于后续的日志解析和自动化分析非常重要。Cinux 的日志格式包含了时间戳、日志级别、模块名称和消息内容:

void log_printf(int level, const char *level_str, const char *module,
                const char *fmt, ...) {
    if (level < current_log_level) {
        return;
    }
    
    kprintf("[%d.%06d] [%s] [%s] ",
            (int)(uptime_seconds),
            (int)(uptime_microseconds),
            level_str,
            module);
    
    va_list ap;
    va_start(ap, fmt);
    kvprintf(fmt, ap);
    va_end(ap);
    
    kprintf("\n");
}

时间戳使用内核启动以来的运行时间,而不是壁钟时间。在系统启动的早期阶段,实时时钟可能尚未初始化,使用单调递增的正常运行时间更加可靠。时间戳的格式为秒加微秒,精度足以区分相邻的日志条目。

模块名称帮助开发者在日志中快速过滤和定位问题来源。每个内核模块在初始化时注册自己的名称,后续的日志调用都会自动携带该模块名。这种设计让日志在大量输出时仍然保持可读性。

5.3 环形缓冲区

串口输出是实时的,但有时问题发生在开发者无法实时观察的瞬间。为了回溯历史日志,Cinux 维护了一个内核日志环形缓冲区。所有日志在发送到串口的同时,也会被写入这个缓冲区。

#define LOG_BUFFER_SIZE 65536  /* 64KB */

static char log_buffer[LOG_BUFFER_SIZE];
static size_t log_buffer_head = 0;
static size_t log_buffer_tail = 0;

static void log_buffer_write(const char *data, size_t len) {
    for (size_t i = 0; i < len; i++) {
        log_buffer[log_buffer_head] = data[i];
        log_buffer_head = (log_buffer_head + 1) % LOG_BUFFER_SIZE;
        if (log_buffer_head == log_buffer_tail) {
            log_buffer_tail = (log_buffer_tail + 1) % LOG_BUFFER_SIZE;
        }
    }
}

void log_buffer_dump(void) {
    spin_lock(&print_lock);
    size_t idx = log_buffer_tail;
    while (idx != log_buffer_head) {
        serial_putc(log_buffer[idx]);
        idx = (idx + 1) % LOG_BUFFER_SIZE;
    }
    spin_unlock(&print_lock);
}

环形缓冲区使用两个指针来跟踪读写位置。写入时,数据被放置在头指针位置,头指针前移。当缓冲区满时,最旧的数据被覆盖,尾指针前移。这种设计保证了最近 64KB 的日志始终可用,无论系统运行了多久。

`log_buffer_dump` 函数将缓冲区中的全部内容输出到串口。这在调试时可以快速获取最近的日志历史,无需重新触发问题。如果开发者需要在 QEMU 中记录完整日志,还可以将串口输出重定向到宿主机文件。

6. 双轨测试框架:从手动调试到自动化验证

串口日志和 kprintf 提供了第一条轨道的可见性,但仅靠人工观察日志来验证内核正确性是远远不够的。随着 Cinux 项目的功能不断增长,手动测试的成本越来越高,出错的概率也越来越大。双轨测试框架的第二条轨道,就是自动化测试。

6.1 测试理念

内核测试与用户态应用测试有很大的不同。内核运行在特权模式下,没有进程隔离,没有标准库,也没有测试框架的运行时支持。因此,Cinux 的测试框架必须内嵌到内核中,在启动时自动执行,并通过串口报告结果。

双轨测试的核心思想是:日志轨迹和断言轨迹并行运行。日志轨迹通过 kprintf 提供运行时的详细描述,帮助开发者理解代码的执行路径。断言轨迹通过测试框架提供自动化的结果判定,让代码的正确性可以被机器验证。两条轨迹的输出都通过串口传递给宿主机,开发者可以实时观察测试进度。

在实际调试中,开发者首先关注断言轨迹,快速确定哪些测试失败。然后转向日志轨迹,深入分析失败测试的详细执行过程。这种两条轨道的配合,比单一的日志输出或单一的测试框架都更加高效。

6.2 测试框架架构

Cinux 的测试框架采用轻量级设计,核心数据结构是测试用例注册表:

typedef struct test_case {
    const char *name;
    const char *module;
    void (*test_func)(void);
    struct test_case *next;
} test_case_t;

static test_case_t *test_list_head = NULL;
static test_case_t *test_list_tail = NULL;
static int test_passed = 0;
static int test_failed = 0;

void test_register(const char *module, const char *name, void (*func)(void)) {
    test_case_t *tc = kmalloc(sizeof(test_case_t));
    if (!tc) return;
    
    tc->module = module;
    tc->name = name;
    tc->test_func = func;
    tc->next = NULL;
    
    if (!test_list_head) {
        test_list_head = tc;
    } else {
        test_list_tail->next = tc;
    }
    test_list_tail = tc;
}

测试用例通过链表组织。每个测试用例包含模块名称、测试名称和测试函数指针。模块开发者在模块初始化时注册测试用例,框架在测试执行阶段遍历链表并逐一执行。

注册测试用例通常使用宏来简化代码:

#define TEST_CASE(module_name, test_name) \
    static void test_##module_name##_##test_name(void); \
    static void test_##module_name##_##test_name##_register(void) __attribute__((constructor)); \
    static void test_##module_name##_##test_name##_register(void) { \
        test_register(#module_name, #test_name, test_##module_name##_##test_name); \
    } \
    static void test_##module_name##_##test_name(void)

这个宏利用 GCC 的 `constructor` 属性在模块加载时自动注册测试用例,无需手动编写注册代码。测试函数体直接跟在宏展开之后,保持了代码的简洁性。

6.3 断言机制

断言是测试框架的核心。Cinux 实现了丰富的断言宏,使测试代码能够清晰表达预期条件:

#define TEST_ASSERT(cond) \
    do { \
        if (!(cond)) { \
            kprintf("    ASSERT FAILED: %s, line %d\n", #cond, __LINE__); \
            test_current_failed = 1; \
            return; \
        } \
    } while (0)

#define TEST_ASSERT_EQ(a, b) \
    do { \
        if (!((a) == (b))) { \
            kprintf("    ASSERT EQ FAILED: %s != %s (line %d)\n", \
                    #a, #b, __LINE__); \
            kprintf("    actual: %ld, expected: %ld\n", \
                    (long)(a), (long)(b)); \
            test_current_failed = 1; \
            return; \
        } \
    } while (0)

#define TEST_ASSERT_NE(a, b) \
    do { \
        if (!((a) != (b))) { \
            kprintf("    ASSERT NE FAILED: %s == %s (line %d)\n", \
                    #a, #b, __LINE__); \
            test_current_failed = 1; \
            return; \
        } \
    } while (0)

#define TEST_ASSERT_NULL(ptr) \
    do { \
        if ((ptr) != NULL) { \
            kprintf("    ASSERT NULL FAILED: %s is not NULL (line %d)\n", \
                    #ptr, __LINE__); \
            test_current_failed = 1; \
            return; \
        } \
    } while (0)

#define TEST_ASSERT_NOT_NULL(ptr) \
    do { \
        if ((ptr) == NULL) { \
            kprintf("    ASSERT NULL FAILED: %s is NULL (line %d)\n", \
                    #ptr, __LINE__); \
            test_current_failed = 1; \
            return; \
        } \
    } while (0)

这些断言宏都遵循相同的模式:检查条件,如果条件不满足则输出失败信息、设置失败标志并立即返回。`TEST_ASSERT_EQ` 额外输出了实际值和期望值,方便快速定位数值不匹配的问题。

在断言失败时立即返回的做法,避免了在错误状态下继续执行导致级联失败。每个测试用例函数应当独立运行,失败不应影响后续测试。

6.4 测试执行器

测试执行器遍历测试注册表,执行每个测试用例并统计结果:

static int test_current_failed = 0;

void test_run_all(void) {
    test_case_t *tc = test_list_head;
    int total = 0;
    
    kprintf("\n========== CINUX TEST SUITE ==========\n");
    
    while (tc) {
        total++;
        test_current_failed = 0;
        
        kprintf("[TEST] %s::%s ...", tc->module, tc->name);
        
        tc->test_func();
        
        if (test_current_failed) {
            test_failed++;
            kprintf(" FAILED\n");
        } else {
            test_passed++;
            kprintf(" PASSED\n");
        }
        
        tc = tc->next;
    }
    
    kprintf("\n========== TEST SUMMARY ==========\n");
    kprintf("Total: %d, Passed: %d, Failed: %d\n", total, test_passed, test_failed);
    kprintf("=====================================\n\n");
}

执行器在运行每个测试前重置失败标志,然后调用测试函数。测试函数的断言会设置失败标志并提前返回。执行器根据失败标志输出 PASSED 或 FAILED,并更新统计计数。

测试完成后,执行器输出汇总信息。这些信息通过串口传递给宿主机,可以用脚本自动解析。CI 系统可以根据汇总信息中的失败数量决定构建是否通过。

6.5 示例测试用例

下面是一个针对内存分配器模块的示例测试:

TEST_CASE(mem, test_kmalloc_basic)
{
    int *ptr = kmalloc(sizeof(int));
    TEST_ASSERT_NOT_NULL(ptr);
    
    *ptr = 42;
    TEST_ASSERT_EQ(*ptr, 42);
    
    kfree(ptr);
}

TEST_CASE(mem, test_kmalloc_many)
{
    void *ptrs[100];
    
    for (int i = 0; i < 100; i++) {
        ptrs[i] = kmalloc(64);
        TEST_ASSERT_NOT_NULL(ptrs[i]);
    }
    
    for (int i = 0; i < 100; i++) {
        kfree(ptrs[i]);
    }
}

TEST_CASE(mem, test_kmalloc_zero_size)
{
    void *ptr = kmalloc(0);
    TEST_ASSERT_NULL(ptr);
}

这三个测试用例分别验证了基本的内存分配释放、批量分配释放和边界条件处理。每个测试都通过断言验证预期行为,并通过日志输出提供执行上下文。

当 `test_kmalloc_zero_size` 测试失败时,输出会清晰地指出断言失败的位置和原因。结合日志轨迹,开发者可以快速判断是检查逻辑错误还是返回值处理不当。

6.6 与串口日志的整合

双轨测试的关键在于日志和断言的无缝整合。Cinux 在测试执行期间自动提升日志级别到 DEBUG,确保所有内部日志都被输出:

void test_run_all_with_verbose(void) {
    int old_level = current_log_level;
    current_log_level = LOG_LEVEL_DEBUG;
    
    test_run_all();
    
    current_log_level = old_level;
}

这样,在测试执行期间,每个模块的 DEBUG 日志都会被输出到串口。当某个测试失败时,开发者可以在完整的日志上下文中分析失败原因,而不是仅仅看到一条断言失败的消息。

此外,Cinux 在测试失败时还会自动输出更多的上下文信息,包括当前中断状态、CPU 寄存器状态和栈回溯。这些信息对于调试内核中的内存错误和死锁问题非常有价值。

7. 实战案例:用双轨测试定位内存分配器 Bug

理论探讨之后,让我们通过一个真实的案例来展示双轨测试框架的实际价值。在 Cinux 项目的早期开发中,内存分配器曾经出现过一个隐蔽的 Bug,导致在某些特定分配模式下出现内存损坏。这个案例生动地展示了日志轨迹如何帮助定位问题的具体来源。

7.1 问题描述

在开发虚拟内存管理模块时,开发者注意到系统在运行一段时间后会出现随机崩溃。崩溃的时间点各不相同,有时在内存映射操作后立即崩溃,有时则在运行数分钟后才出现问题。仅凭崩溃时的寄存器状态和栈回溯,无法确定问题的根本原因。

为了系统化地排查这个问题,开发者编写了一个压力测试:

TEST_CASE(vm, test_page_alloc_stress)
{
    #define STRESS_ITERATIONS 10000
    
    for (int i = 0; i < STRESS_ITERATIONS; i++) {
        void *page1 = page_alloc();
        void *page2 = page_alloc();
        
        TEST_ASSERT_NOT_NULL(page1);
        TEST_ASSERT_NOT_NULL(page2);
        
        /* 模拟内存映射操作 */
        vm_map_page(page1, 0x100000 + i * 0x1000);
        vm_map_page(page2, 0x200000 + i * 0x1000);
        
        /* 验证映射正确性 */
        TEST_ASSERT_EQ(*(uint32_t *)page1, 0);
        TEST_ASSERT_EQ(*(uint32_t *)page2, 0);
        
        vm_unmap_page(0x100000 + i * 0x1000);
        page_free(page1);
        
        vm_unmap_page(0x200000 + i * 0x1000);
        page_free(page2);
    }
}

这个测试循环执行 10000 次页面分配、映射、验证和释放操作。在测试运行到大约 3427 次迭代时,断言失败,发现页面内容不为零。这为问题的定位提供了重要线索。

7.2 日志轨迹分析

测试失败后,开发者查看串口日志,找到了失败前后的完整记录:

[INFO] [vm] mapping page 0x0000000100342000 to vaddr 0x00357000
[INFO] [vm] mapping page 0x0000000100343000 to vaddr 0x00358000
[DEBUG] [mem] alloc_page: returning page 0x0000000100344000
[DEBUG] [mem] free_page: returning page 0x0000000100342000
[DEBUG] [mem] alloc_page: returning page 0x0000000100342000
[DEBUG] [mem] zeroing page 0x0000000100342000
[ERROR] [vm] page content check failed at vaddr 0x00357000
[ERROR] [vm] expected 0x00000000, got 0xDEADBEEF

日志清晰地展示了问题的根源:页面 0x0000000100342000 在被释放后又立即被重新分配,但释放后没有正确清零。更关键的是,释放和重新分配之间没有执行清零操作,导致残留数据被重复使用。

进一步分析日志还发现,`page_free` 函数在释放页面时只是将页面标记为空闲,并没有清空页面内容。这本身并不一定是 Bug,因为内核通常假设分配者会负责初始化。但 `page_alloc` 函数在分配时也没有清零,两者叠加就导致了问题。

7.3 修复与回归测试

修复方案很明确:在 `page_alloc` 函数中增加清零操作。但直接修改分配器代码可能会影响性能,因为并非所有分配都需要清零。经过讨论,团队决定增加一个新的 `page_alloc_zero` 函数,并在需要保证内容干净的场景中使用它。

void *page_alloc_zero(void) {
    void *page = page_alloc();
    if (page) {
        memset(page, 0, PAGE_SIZE);
    }
    return page;
}

修复之后,原来的压力测试改用 `page_alloc_zero` 重新运行,10000 次迭代全部通过。此外,团队还增加了针对性的回归测试:

TEST_CASE(vm, test_page_alloc_zero)
{
    /* 分配并写入 */
    void *page = page_alloc();
    TEST_ASSERT_NOT_NULL(page);
    memset(page, 0xAA, PAGE_SIZE);
    page_free(page);
    
    /* 重新分配并验证清零 */
    page = page_alloc_zero();
    TEST_ASSERT_NOT_NULL(page);
    
    uint32_t *words = (uint32_t *)page;
    for (int i = 0; i < PAGE_SIZE / 4; i++) {
        TEST_ASSERT_EQ(words[i], 0);
    }
    
    page_free(page);
}

这个回归测试精确复现了原问题:分配、写入、释放、再分配,然后验证新分配的页面已经清零。测试在小规模下快速执行,可以在每次构建后自动运行,确保问题不会再次出现。

这个案例完美体现了双轨测试的价值:断言轨迹快速定位失败,日志轨迹提供深入分析的上下文,两者配合使得复杂的内存问题在几分钟内被定位和修复。

8. 性能优化:让日志输出更快

随着 Cinux 项目的复杂度增长,日志输出的性能开销逐渐成为需要关注的问题。虽然串口波特率固定,但格式化操作、锁竞争和缓冲区拷贝仍然可以优化。本章讨论几个关键的优化方向。

8.1 减少格式化开销

在原始实现中,每个字符都直接写入串口,意味着格式化过程中的每个 `serial_putc` 调用都要检查发送缓冲区的状态。对于长字符串的格式化输出,这会造成大量的 I/O 端口访问。

优化方案是在格式化完成后一次性批量发送:

void kprintf_optimized(const char *fmt, ...) {
    char buffer[2048];
    va_list ap;
    
    va_start(ap, fmt);
    int len = vsnprintf_buffer(buffer, sizeof(buffer), fmt, ap);
    va_end(ap);
    
    /* 批量发送 */
    for (int i = 0; i < len; i++) {
        while (!serial_transmit_empty()) continue;
        outb(UART_DATA_REG, buffer[i]);
    }
}

批量发送减少了函数调用开销和参数传递成本。在 16550 UART 启用 FIFO 的情况下,可以先将多个字符写入发送 FIFO,再等待 FIFO 为空。这显著减少了 CPU 在等待串口发送上的时间。

8.2 非阻塞输出

在一些高频日志场景中,如果每次日志输出都要等待串口完成发送,会显著拖慢内核的运行速度。非阻塞输出的思路是将日志写入一个大的环形缓冲区,由单独的后台任务或中断处理程序负责从缓冲区取数据并发送到串口。

#define BLOCKING_LOG_BUFFER_SIZE 65536

static char blocking_log_buffer[BLOCKING_LOG_BUFFER_SIZE];
static size_t bl_head = 0;
static size_t bl_tail = 0;

static void log_write_nonblocking(const char *data, size_t len) {
    for (size_t i = 0; i < len; i++) {
        blocking_log_buffer[bl_head] = data[i];
        bl_head = (bl_head + 1) % BLOCKING_LOG_BUFFER_SIZE;
        
        if (bl_head == bl_tail) {
            /* 缓冲区满,丢弃最旧的数据 */
            bl_tail = (bl_tail + 1) % BLOCKING_LOG_BUFFER_SIZE;
        }
    }
}

void serial_tx_irq_handler(void) {
    while (bl_tail != bl_head && serial_transmit_empty()) {
        outb(UART_DATA_REG, blocking_log_buffer[bl_tail]);
        bl_tail = (bl_tail + 1) % BLOCKING_LOG_BUFFER_SIZE;
    }
}

非阻塞日志将写日志的延迟从串口发送时间降低为内存拷贝时间。串口发送由硬件中断驱动,在发送保持寄存器为空时自动触发。这种设计在高频日志场景下可以将内核的运行开销降低数倍。

代价是增加了内存占用和代码复杂度。对于 Cinux 当前的发展阶段,非阻塞输出在调试构建中启用,在稳定构建中可以选择关闭以简化调试。

8.3 编译时日志裁剪

最彻底的日志开销消除方式是在编译时直接移除不需要的日志代码。Cinux 通过条件编译控制 DEBUG 级别日志的生成:

#ifdef CINUX_DEBUG_BUILD
#define LOG_DEBUG(fmt, ...) log_printf(LOG_LEVEL_DEBUG, "DEBUG", "core", fmt, ##__VA_ARGS__)
#else
#define LOG_DEBUG(fmt, ...) ((void)0)
#endif

在发布构建中,`LOG_DEBUG` 宏展开为空操作,编译器会完全移除对应的日志代码。这不仅消除了运行时开销,还减少了内核镜像的大小。代价是发布版本缺乏详细的调试信息,当用户报告问题时,开发者可能需要在调试构建中重现问题。

对于 INFO 级别及以上的日志,Cinux 保留在发布构建中。这些日志量较少,但对排查生产环境问题至关重要。运行时日志级别阈值仍然可以通过启动参数或运行时接口调整。

9. 结论与展望

本文详细讲解了 Cinux 内核的串口驱动、kprintf 实现和双轨测试框架。这三个部分构成了 Cinux 的基本调试和质量保障体系。串口驱动提供了硬件层的基础通信能力;kprintf 在此基础上实现了灵活、可靠、线程安全的格式化输出;双轨测试框架则将日志输出与自动化测试结合起来,形成了系统化的验证能力。

回顾整个开发过程,最核心的感悟是:内核「会说话」不只是一个技术问题,更是一种工程态度的体现。当内核能够清晰地报告自己的状态时,开发者在面对复杂系统问题时就不再是盲人摸象。日志输出的每一个字节,都是系统诊断能力的重要组成部分。

展望未来,Cinux 的日志和测试体系还有多个演进方向。首先是结构化日志的支持,通过输出 JSON 等结构化格式,让日志可以被机器自动解析和分析。其次是性能测试和基准测试的集成,在功能正确性之外验证时序特性。第三是模糊测试的引入,通过随机化输入发现边界条件和协议漏洞。最后是远程调试协议的支持,允许宿主机通过串口连接调试器进行断点、单步和内存检查。

在操作系统内核开发的漫长道路上,工具链和基础设施的完善程度往往决定了项目的成败。串口、kprintf 和双轨测试这三样东西虽然看似基础,却是所有后续复杂功能开发的坚实基石。当你的内核能够开口说话时,它就已经迈出了从玩具到系统的第一步。

10. 附录:完整串口驱动和 kprintf 源码

为了让读者能够快速上手,本节提供完整的串口驱动和 kprintf 源码。代码已经过 Cinux 项目验证,可直接用于 x86 架构的内核开发。

10.1 串口驱动头文件 serial.h

#ifndef CINUX_SERIAL_H
#define CINUX_SERIAL_H

#include <stdint.h>
#include <stddef.h>

#define COM1_PORT 0x3F8
#define COM2_PORT 0x2F8
#define COM3_PORT 0x3E8
#define COM4_PORT 0x2E8

void serial_init(void);
void serial_putc(char c);
char serial_getc(void);
void serial_write(const char *str);
void serial_write_n(const char *data, size_t len);
void serial_enable_interrupt(void);

#endif /* CINUX_SERIAL_H */

10.2 串口驱动实现 serial.c

#include "serial.h"

#define UART_DATA_REG   (COM1_PORT + 0)
#define UART_IER_REG    (COM1_PORT + 1)
#define UART_IIR_REG    (COM1_PORT + 2)
#define UART_LCR_REG    (COM1_PORT + 3)
#define UART_MCR_REG    (COM1_PORT + 4)
#define UART_LSR_REG    (COM1_PORT + 5)

static inline void outb(uint16_t port, uint8_t value) {
    __asm__ volatile("outb %0, %1" : : "a"(value), "Nd"(port));
}

static inline uint8_t inb(uint16_t port) {
    uint8_t ret;
    __asm__ volatile("inb %1, %0" : "=a"(ret) : "Nd"(port));
    return ret;
}

static inline int serial_transmit_empty(void) {
    return inb(UART_LSR_REG) & 0x20;
}

static inline int serial_received(void) {
    return inb(UART_LSR_REG) & 0x01;
}

void serial_init(void) {
    outb(UART_IER_REG, 0x00);
    outb(UART_LCR_REG, 0x80);
    outb(UART_DATA_REG, 0x03);
    outb(UART_IER_REG, 0x00);
    outb(UART_LCR_REG, 0x03);
    outb(UART_IIR_REG, 0xC7);
    outb(UART_MCR_REG, 0x0B);
}

void serial_putc(char c) {
    while (!serial_transmit_empty()) {
        /* 等待 */
    }
    outb(UART_DATA_REG, c);
}

char serial_getc(void) {
    while (!serial_received()) {
        /* 等待 */
    }
    return inb(UART_DATA_REG);
}

void serial_write(const char *str) {
    while (*str) {
        if (*str == '\n') {
            serial_putc('\r');
        }
        serial_putc(*str++);
    }
}

void serial_write_n(const char *data, size_t len) {
    for (size_t i = 0; i < len; i++) {
        serial_putc(data[i]);
    }
}

void serial_enable_interrupt(void) {
    outb(UART_IER_REG, 0x01);
}

10.3 kprintf 头文件 kprintf.h

#ifndef CINUX_KPRINTF_H
#define CINUX_KPRINTF_H

#include <stdarg.h>

void kprintf(const char *fmt, ...);
void kvprintf(const char *fmt, va_list ap);

#endif /* CINUX_KPRINTF_H */

10.4 kprintf 实现 kprintf.c

#include "kprintf.h"
#include "serial.h"
#include "spinlock.h"
#include <stdint.h>

static spinlock_t print_lock = SPINLOCK_INIT;

static void print_unsigned(uint64_t value, uint32_t base) {
    char buf[32];
    int i = 31;
    
    if (value == 0) {
        serial_putc('0');
        return;
    }
    
    while (value > 0) {
        uint64_t digit = value % base;
        buf[i--] = (digit < 10) ? ('0' + digit) : ('a' + digit - 10);
        value /= base;
    }
    
    for (i++; i < 32; i++) {
        serial_putc(buf[i]);
    }
}

static void print_signed(int64_t value, uint32_t base) {
    if (value < 0) {
        serial_putc('-');
        print_unsigned((uint64_t)(-value), base);
    } else {
        print_unsigned((uint64_t)value, base);
    }
}

void kvprintf(const char *fmt, va_list ap) {
    while (*fmt) {
        if (*fmt != '%') {
            serial_putc(*fmt++);
            continue;
        }
        
        fmt++;
        
        switch (*fmt) {
        case 'd':
        case 'i':
            print_signed(va_arg(ap, int), 10);
            break;
        case 'u':
            print_unsigned(va_arg(ap, unsigned int), 10);
            break;
        case 'x':
            print_unsigned(va_arg(ap, unsigned int), 16);
            break;
        case 'p':
            serial_write("0x");
            print_unsigned((uintptr_t)va_arg(ap, void *), 16);
            break;
        case 's': {
            char *str = va_arg(ap, char *);
            if (str) {
                serial_write(str);
            } else {
                serial_write("(null)");
            }
            break;
        }
        case 'c':
            serial_putc((char)va_arg(ap, int));
            break;
        case '%':
            serial_putc('%');
            break;
        default:
            serial_putc('%');
            serial_putc(*fmt);
            break;
        }
        fmt++;
    }
}

void kprintf(const char *fmt, ...) {
    va_list ap;
    
    spin_lock(&print_lock);
    va_start(ap, fmt);
    kvprintf(fmt, ap);
    va_end(ap);
    spin_unlock(&print_lock);
}

10.5 测试框架头文件 test.h

#ifndef CINUX_TEST_H
#define CINUX_TEST_H

#include "kprintf.h"

typedef struct test_case {
    const char *name;
    const char *module;
    void (*test_func)(void);
    struct test_case *next;
} test_case_t;

void test_register(const char *module, const char *name, void (*func)(void));
void test_run_all(void);
int test_get_pass_count(void);
int test_get_fail_count(void);

#define TEST_CASE(module_name, test_name) \
    static void test_##module_name##_##test_name(void); \
    static void __attribute__((constructor)) test_##module_name##_##test_name##_register(void) { \
        test_register(#module_name, #test_name, test_##module_name##_##test_name); \
    } \
    static void test_##module_name##_##test_name(void)

#define TEST_ASSERT(cond) \
    do { \
        if (!(cond)) { \
            kprintf("    ASSERT FAILED: %s, line %d\n", #cond, __LINE__); \
            test_mark_failed(); \
            return 0; \
        } \
    } while (0)

#define TEST_ASSERT_EQ(a, b) \
    do { \
        if (!((a) == (b))) { \
            kprintf("    ASSERT EQ FAILED: %s != %s (line %d)\n", #a, #b, __LINE__); \
            kprintf("    actual: %ld, expected: %ld\n", (long)(a), (long)(b)); \
            test_mark_failed(); \
            return 0; \
        } \
    } while (0)

#define TEST_ASSERT_NOT_NULL(ptr) \
    do { \
        if ((ptr) == NULL) { \
            kprintf("    ASSERT NULL FAILED: %s is NULL (line %d)\n", #ptr, __LINE__); \
            test_mark_failed(); \
            return 0; \
        } \
    } while (0)

#endif /* CINUX_TEST_H */

11. 常见问题与调试技巧

在串口驱动和 kprintf 的开发和调试过程中,开发者经常会遇到一些典型问题。本节总结这些问题及其解决方案。

11.1 串口无输出

这是最常见的问题。如果串口初始化后没有任何输出,首先检查以下几个方面:串口基地址是否正确,波特率配置是否匹配,线路控制寄存器是否配置正确。在 QEMU 中,可以使用 `-serial stdio` 参数启用串口输出到标准输出。

还需要检查是否在启动的足够早的阶段就调用了串口初始化。如果内核在初始化串口之前就发生了崩溃,自然不会有任何输出。

11.2 输出乱码

输出乱码通常意味着波特率不匹配。发送方和接收方必须使用完全相同的波特率,否则采样点会偏移,导致数据解读错误。确认宿主机终端和内核中配置的波特率一致,通常选择 38400 或 115200。

另一个可能原因是线路控制寄存器的配置错误,例如数据位长度不一致。确保发送方和接收方都使用 8 个数据位、1 个停止位、无奇偶校验。

11.3 输出截断

如果长字符串的输出被截断,可能是发送缓冲区溢出的问题。在轮询发送模式下,每个字符都在发送前等待,所以不应该发生截断。截断更可能发生在中断驱动的发送模式中,如果发送缓冲区太小,后续数据会被丢弃。

增大发送缓冲区的大小通常可以解决这个问题。对于日志输出,建议使用至少 4KB 的缓冲区。

11.4 格式化错误

当 kprintf 输出与预期不符时,首先检查格式字符串中的格式说明符是否与参数类型匹配。不匹配的类型会导致未定义行为,可能读取错误的寄存器或栈位置。使用编译器的 `-Wformat` 警告可以帮助发现这类问题。

对于自定义的 kprintf 实现,编译器可能无法直接给出格式警告。可以通过封装函数使其使用标准的 `printf` 格式属性:

void kprintf(const char *fmt, ...) __attribute__((format(printf, 1, 2)));

这个属性告诉编译器 `kprintf` 的第一个参数是格式字符串,格式检查应该从第二个参数开始。这样,编译器就会对 `kprintf` 的调用进行格式检查,提前发现类型不匹配的问题。

12. 扩展阅读与资源

为了进一步深入理解串口通信、内核日志和测试框架,以下资源值得参考。

在串口通信方面,参考 16550 UART 的官方数据手册可以了解所有寄存器的详细定义和时序要求。在操作系统内核中实现串口驱动时,这份手册是最权威的参考资料。

在内核日志设计方面,参考 Linux 内核的 printk 实现可以学到很多设计经验。Linux 的 printk 同样经过多年演进,支持日志级别、环形缓冲区、控制台重定向等丰富特性。

在内核测试方面,参考 Google 的 KUnit 测试框架可以提供现代化的测试思路。KUnit 是 Linux 内核的单元测试框架,展示了如何在受限的内核环境中构建有效的测试基础设施。

最后,推荐读者使用 QEMU 配合 GDB 进行内核调试。QEMU 的串口模拟非常完善,支持将串口重定向到文件、TCP 端口或标准终端。GDB 的远程调试功能可以让开发者在宿主机上设置断点、单步执行和检查内核内存。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐