环形缓冲区与日志条目
概述
本文档详细介绍 Log 子系统的两个核心组件:无锁环形缓冲区(GlobalLogBuffer)和日志条目(LogEntry)。环形缓冲区负责高效地缓存日志,采用 MPSC(多生产者单消费者)模型支持多核并发写入;日志条目定义了日志数据的内存布局和序列化方式。
环形缓冲区(GlobalLogBuffer)
结构概览
GlobalLogBuffer 是一个固定大小的环形缓冲区,位于 os/src/log/buffer.rs。其核心设计是无锁 MPSC 模型,通过原子操作和票号系统实现多核并发安全。
内存布局
GlobalLogBuffer 结构体布局:
┌─────────────────────────────────────────────────────────────────┐
│ GlobalLogBuffer │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ writer: CachePadded64<WriterData> [64 字节对齐] │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ write_seq: AtomicUsize (写序列号) │ │ │
│ │ │ 当前值表示下一个可用的票号 │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ [填充至 64 字节,避免伪共享] │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ reader: CachePadded64<ReaderData> [64 字节对齐] │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ read_seq: AtomicUsize (读序列号) │ │ │
│ │ │ dropped: AtomicUsize (丢弃计数) │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ [填充至 64 字节,避免伪共享] │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ slots: [MaybeUninit<LogEntry>; MAX_ENTRIES] │ │
│ │ │ │
│ │ 环形槽位数组,每个槽位存储一个 LogEntry │ │
│ │ MAX_ENTRIES = BUFFER_SIZE / size_of::<LogEntry>() │ │
│ │ ≈ 16384 / 280 ≈ 58 个槽位 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ unread_bytes: AtomicUsize (未读字节计数) │ │
│ │ │ │
│ │ 记录所有未读日志格式化后的总字节数 │ │
│ │ 用于 SIZE_UNREAD 系统调用 │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
内存对齐说明:
- WriterData 和 ReaderData 各占 64 字节(一个缓存行)
- 避免伪共享:多个 CPU 写 write_seq 不会与读 read_seq 竞争同一缓存行
- slots 数组紧随其后,每个 LogEntry 约 280 字节
定义位于 os/src/log/buffer.rs:54-59。
MPSC 并发模型
环形缓冲区采用多生产者单消费者(MPSC)模型,这是日志系统的自然选择:
并发模型示意图:
多个生产者(写入者)
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ CPU0 │ │ CPU1 │ │ CPU2 │ │ CPU3 │
└───┬──┘ └───┬──┘ └───┬──┘ └───┬──┘
│ │ │ │
│ │ │ │
│ pr_info!() │ pr_err!() │ pr_warn!() │ pr_debug!()
│ │ │ │
└─────────────┴──────┬──────┴─────────────┘
│
│ 并发写入(使用原子操作协调)
│
┌────────────▼───────────┐
│ GlobalLogBuffer │
│ │
│ write_seq (原子递增) │
│ slots[...] │
│ read_seq │
└────────────┬───────────┘
│
│ 单一读取者(顺序读取)
│
┌──────▼──────┐
│ Log Reader │
│ (用户态工具) │
└─────────────┘
关键特点:
1. 多个 CPU 可以并发调用 pr_* 宏,无需等待锁
2. 写入者通过 write_seq.fetch_add() 获取独占的票号
3. 每个票号对应唯一的槽位,写入者互不干扰
4. 读取者单独访问 read_seq,无竞争
5. Release-Acquire 内存序保证数据可见性
票号分配系统
票号系统是无锁环形缓冲区的核心机制,通过原子递增操作分配唯一的序列号:
票号分配流程:
初始状态:
┌────────────────────────────┐
│ write_seq = 0 │
│ read_seq = 0 │
└────────────────────────────┘
时刻 T1:三个 CPU 同时请求写入
┌────────────────────────────┐
│ CPU0 执行 fetch_add(1) │ → 返回 0,write_seq 变为 1
│ CPU1 执行 fetch_add(1) │ → 返回 1,write_seq 变为 2
│ CPU2 执行 fetch_add(1) │ → 返回 2,write_seq 变为 3
└────────────────────────────┘
结果:
┌────────────────────────────┐
│ CPU0 获得票号 0 │ → 写入 slot[0 % MAX_ENTRIES]
│ CPU1 获得票号 1 │ → 写入 slot[1 % MAX_ENTRIES]
│ CPU2 获得票号 2 │ → 写入 slot[2 % MAX_ENTRIES]
│ write_seq = 3 │
└────────────────────────────┘
票号系统的保证:
1. 原子性:fetch_add 保证每个 CPU 获得唯一的票号
2. 公平性:先到达的 CPU 获得较小的票号
3. 顺序性:票号单调递增,读取者按顺序消费日志
4. 无等待:获得票号后立即写入,无需等待其他 CPU
实现位于 os/src/log/buffer.rs:128。
写入流程详解
每次写入日志遵循严格的五步流程,保证并发安全和数据完整性:
五步写入流程:
┌─────────────────────────────────────────────────────────────┐
│ 步骤 1:获取票号 │
│ ───────────────────────────────────────────────────────── │
│ seq = write_seq.fetch_add(1, Relaxed) │
│ │
│ · 原子地递增 write_seq 并返回旧值 │
│ · Relaxed 语义足够,因为票号本身是线程私有的 │
│ · 返回的 seq 是该日志的唯一标识 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤 2:计算槽位 │
│ ───────────────────────────────────────────────────────── │
│ idx = seq % MAX_ENTRIES │
│ │
│ · 将线性序列号映射到环形槽位索引 │
│ · MAX_ENTRIES 是编译期常量,取模可能优化为位与操作 │
│ · 多个序列号可能映射到同一槽位(当缓冲区绕一圈后) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤 3:检测并处理溢出 │
│ ───────────────────────────────────────────────────────── │
│ current_write = write_seq.load(Relaxed) │
│ current_read = read_seq.load(Relaxed) │
│ │
│ if (current_write - current_read) > MAX_ENTRIES: │
│ // 缓冲区满,需要覆盖旧数据 │
│ loop: │
│ old_read = read_seq.load(Relaxed) │
│ new_read = current_write - MAX_ENTRIES + 1 │
│ if read_seq.compare_exchange(old_read, new_read): │
│ dropped.fetch_add(new_read - old_read) │
│ break │
│ │
│ · CAS 循环推进 read_seq,保证只有一个 CPU 成功 │
│ · 增加 dropped 计数记录丢弃的日志数量 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤 4:拷贝日志数据 │
│ ───────────────────────────────────────────────────────── │
│ slot = &slots[idx] │
│ // 拷贝除 seq 字段外的所有数据: │
│ slot.level = entry.level │
│ slot.cpu_id = entry.cpu_id │
│ slot.timestamp = entry.timestamp │
│ slot.task_id = entry.task_id │
│ slot.length = entry.length │
│ slot.message.copy_from_slice(entry.message) │
│ │
│ · 注意:seq 字段暂不写入,它是同步标志 │
│ · 此时数据尚未"发布",读取者看不到 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤 5:发布数据 │
│ ───────────────────────────────────────────────────────── │
│ slot.seq.store(seq, Release) │
│ │
│ · 使用 Release 语义写入 seq 字段 │
│ · Release 保证之前的所有写入对后续的 Acquire 读取可见 │
│ · 写入 seq 是"发布"操作,标志槽位已就绪 │
│ · 读取者通过检查 seq 字段判断数据是否可读 │
└─────────────────────────────────────────────────────────────┘
完整实现位于 os/src/log/buffer.rs:128-177。
溢出处理机制
当写入速度超过读取速度时,环形缓冲区会满。此时采用 FIFO 策略覆盖最旧的日志:
溢出处理状态转换:
正常状态:
┌────────────────────────────────────────┐
│ write_seq: 10 │
│ read_seq: 5 │
│ 容量:MAX_ENTRIES = 58 │
│ 已用:10 - 5 = 5 条日志 │
│ 可用:58 - 5 = 53 个槽位 │
└────────────────────────────────────────┘
接近满状态:
┌────────────────────────────────────────┐
│ write_seq: 62 │
│ read_seq: 5 │
│ 已用:62 - 5 = 57 条日志 │
│ 可用:58 - 57 = 1 个槽位 │
│ 警告:缓冲区即将满 │
└────────────────────────────────────────┘
溢出检测:
┌────────────────────────────────────────┐
│ write_seq: 64 │
│ read_seq: 5 │
│ 已用:64 - 5 = 59 > MAX_ENTRIES │
│ 判定:缓冲区溢出! │
└────────────────────────────────────────┘
│
▼
溢出处理(CAS 循环):
┌────────────────────────────────────────┐
│ 计算需要推进的读序列号: │
│ new_read = 64 - 58 + 1 = 7 │
│ │
│ 尝试 CAS: │
│ read_seq.compare_exchange(5, 7) │
│ ├─ 成功:read_seq = 7 │
│ │ dropped += (7 - 5) = 2 │
│ │ 返回,继续写入 │
│ └─ 失败:其他 CPU 已推进 read_seq │
│ 重新加载 read_seq,重试 │
└────────────────────────────────────────┘
│
▼
恢复正常:
┌────────────────────────────────────────┐
│ write_seq: 64 │
│ read_seq: 7 │
│ 已用:64 - 7 = 57 条日志 │
│ 可用:58 - 57 = 1 个槽位 │
│ dropped: 2(记录丢弃了 2 条日志) │
└────────────────────────────────────────┘
关键点:
1. 只有写入者会推进 read_seq(在溢出时)
2. CAS 保证多个写入者中只有一个成功推进
3. dropped 计数精确记录被覆盖的日志数量
4. 被覆盖的日志是最旧的日志(FIFO 策略)
实现位于 os/src/log/buffer.rs:148-162。
读取同步机制
读取者通过检查槽位的 seq 字段判断数据是否就绪:
读取流程:
┌─────────────────────────────────────────────────────────────┐
│ 1. 加载当前读序列号 │
│ current_read = read_seq.load(Relaxed) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. 计算槽位索引 │
│ idx = current_read % MAX_ENTRIES │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. 检查槽位就绪(Acquire 同步点) │
│ slot_seq = slot.seq.load(Acquire) │
│ │
│ if slot_seq == current_read: │
│ // 数据已发布,可以安全读取 │
│ else: │
│ // 数据未就绪或已被覆盖,返回 None │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. 拷贝日志条目 │
│ // 从槽位拷贝到栈上的临时变量 │
│ entry = clone_from_slot(slot) │
│ │
│ · Acquire 保证能看到写入者的所有数据 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 5. 推进读序列号 │
│ read_seq.fetch_add(1, Relaxed) │
│ │
│ · 单消费者,无竞争,Relaxed 足够 │
└─────────────────────────────────────────────────────────────┘
为什么检查 slot_seq == current_read?
┌────────────────────────────────────────┐
│ 情况 1:slot_seq == current_read │
│ → 数据已发布,匹配期望的序列号 │
│ → 可以安全读取 │
├────────────────────────────────────────┤
│ 情况 2:slot_seq < current_read │
│ → 数据尚未写入(写入者还未到达) │
│ → 返回 None,等待写入者 │
├────────────────────────────────────────┤
│ 情况 3:slot_seq > current_read │
│ → 数据已被覆盖(缓冲区绕了一圈) │
│ → 返回 None,日志已丢失 │
└────────────────────────────────────────┘
实现位于 os/src/log/buffer.rs:179-201。
缓存行填充优化
WriterData 和 ReaderData 使用 CachePadded64 包装,避免伪共享:
伪共享问题(未优化):
假设缓存行大小为 64 字节:
┌────────────────────────────────────────────────────────────┐
│ 缓存行 │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ write_seq (8B) │ │ read_seq (8B) │ [其他数据] │
│ └──────────────────┘ └──────────────────┘ │
└────────────────────────────────────────────────────────────┘
↑ ↑
│ │
CPU0 频繁写入 CPU1 频繁读取
问题:
- CPU0 修改 write_seq → 缓存行失效 → CPU1 的缓存行被强制刷新
- CPU1 读取 read_seq → 导致 CPU0 的缓存行失效
- 两个 CPU 互相干扰,性能下降(伪共享)
优化后(CachePadded64):
┌────────────────────────────────────────┐
│ 缓存行 1 (64 字节) │
│ ┌──────────────────┐ │
│ │ write_seq (8B) │ [填充 56 字节] │
│ └──────────────────┘ │
└────────────────────────────────────────┘
↑
│
CPU0 独占此缓存行
┌────────────────────────────────────────┐
│ 缓存行 2 (64 字节) │
│ ┌──────────────────┐ │
│ │ read_seq (8B) │ [填充] │
│ │ dropped (8B) │ │
│ └──────────────────┘ │
└────────────────────────────────────────┘
↑
│
CPU1 独占此缓存行
优势:
- write_seq 和 read_seq 位于不同的缓存行
- CPU0 写入 write_seq 不会影响 CPU1 的缓存
- CPU1 读取 read_seq 不会影响 CPU0 的缓存
- 消除伪共享,提升并发性能
定义位于 os/src/log/buffer.rs:40-52。
非破坏性读取
除了传统的破坏性读取(read()),环形缓冲区还支持非破坏性读取,允许多次查看相同的日志而不删除它们。
peek 操作
非破坏性读取流程:
┌─────────────────────────────────────────────────────────────┐
│ 步骤 1:获取可读范围 │
│ ───────────────────────────────────────────────────────── │
│ start = read_seq.load(Acquire) │
│ end = write_seq.load(Acquire) │
│ │
│ · 获取当前的读写指针位置 │
│ · 可读范围为 [start, end) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤 2:验证索引 │
│ ───────────────────────────────────────────────────────── │
│ if (index < start || index >= end): │
│ return None // 索引越界 │
│ │
│ if (end >= start + MAX_ENTRIES): │
│ oldest_valid = end - MAX_ENTRIES │
│ if (index < oldest_valid): │
│ return None // 数据已被覆盖 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤 3:读取槽位 │
│ ───────────────────────────────────────────────────────── │
│ slot_idx = index % MAX_ENTRIES │
│ slot = &slots[slot_idx] │
│ │
│ · 计算环形槽位索引 │
│ · 获取槽位指针 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤 4:验证序列号 │
│ ───────────────────────────────────────────────────────── │
│ seq = slot.seq.load(Acquire) │
│ if (seq != index): │
│ return None // 数据尚未就绪或已被覆盖 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 步骤 5:克隆并返回 │
│ ───────────────────────────────────────────────────────── │
│ entry = (*slot).clone() │
│ return Some(entry) │
│ │
│ · 注意:不修改 read_seq,日志保留在缓冲区中 │
└─────────────────────────────────────────────────────────────┘
并发安全性:
peek()可以与write()和read()并发调用- 使用 Acquire 内存序保证能看到已发布的数据
- 不修改任何共享状态,完全无锁
使用场景:
SyslogAction::ReadAll- 非破坏性读取所有日志- 日志监控工具 - 周期性扫描而不删除日志
- 调试分析 - 反复查看相同的日志条目
实现位置:os/src/log/buffer.rs:330-381
精确字节计数
环形缓冲区实时维护未读日志的格式化字节数,用于支持 SyslogAction::SizeUnread 系统调用。
字节计数机制
字节计数维护流程:
写入时:
┌─────────────────────────────────────────────────────────────┐
│ calculate_formatted_length(entry) │
│ ↓ │
│ · 计算日志条目格式化后的精确字节长度 │
│ · 包括 ANSI 颜色代码、时间戳、上下文信息、消息内容 │
│ · 必须与 format_log_entry() 和 direct_print_entry() 一致 │
│ ↓ │
│ unread_bytes.fetch_add(formatted_len, Release) │
│ ↓ │
│ · 原子地增加未读字节计数 │
└─────────────────────────────────────────────────────────────┘
读取时:
┌─────────────────────────────────────────────────────────────┐
│ entry = read_log() │
│ ↓ │
│ calculate_formatted_length(&entry) │
│ ↓ │
│ unread_bytes.fetch_sub(formatted_len, Release) │
│ ↓ │
│ · 原子地减少未读字节计数 │
└─────────────────────────────────────────────────────────────┘
查询时:
┌─────────────────────────────────────────────────────────────┐
│ unread_bytes.load(Acquire) │
│ ↓ │
│ · O(1) 时间返回结果,无需遍历缓冲区 │
│ · 用于 syslog SIZE_UNREAD 系统调用 │
└─────────────────────────────────────────────────────────────┘
格式化长度计算
calculate_formatted_length() 函数精确计算日志条目格式化后的字节长度:
格式: "{color_code}{level} [{timestamp:12}] [CPU{cpu_id}/T{task_id:3}] {message}{reset}\n"
组成部分:
- ANSI 颜色代码: entry.level().color_code().len() // 开始
- 级别标签: entry.level().as_str().len() // "[INFO]" 等
- 时间戳: 14 字节 // " [{:12}]" = 2 + 12
- CPU ID: 5 + digit_count(cpu_id) // " [CPU<digits>"
- 任务 ID: 2 + digit_count_padded(task_id, 3) + 1 // "/T<digits>]"
- 消息内容: entry.message().len()
- ANSI 重置代码: entry.level().reset_color_code().len() // 结束
- 分隔符和换行: 3 + 1 字节 // 3个空格 + 1个换行
总字节数 = 所有部分之和
重要维护点:
如果修改日志输出格式,必须同步更新三处:
buffer::calculate_formatted_length()- 字节长度计算(os/src/log/buffer.rs:18-102)log_core::direct_print_entry()- 控制台输出格式(os/src/log/log_core.rs:223-240)log_core::format_log_entry()- syslog 字符串格式化(os/src/log/log_core.rs:277-286)
性能特性:
- 查询字节数:O(1) - 单次原子加载
- 写入时计算:O(1) - 固定计算,无循环
- 内存开销:一个
AtomicUsize(8 字节)
实现位置:
- 字节计数:
os/src/log/buffer.rs:18-102,os/src/log/buffer.rs:236-238,os/src/log/buffer.rs:302-305 - 查询接口:
os/src/log/buffer.rs:323-326
日志条目(LogEntry)
内存布局
LogEntry 定义了单条日志的内存布局,位于 os/src/log/entry.rs:20-30:
LogEntry 内存布局(总大小约 280 字节):
偏移量 字段 类型 大小 对齐 说明
──────────────────────────────────────────────────────────────────
0x0000 seq AtomicUsize 8 字节 8 同步标志,必须在首位
0x0008 level LogLevel 1 字节 1 日志级别 (0-7)
0x0009 [padding] - 3 字节 - 对齐填充
0x000C cpu_id usize 8 字节 8 记录日志的 CPU 核心 ID
0x0014 length usize 8 字节 8 消息实际长度
0x001C task_id u32 4 字节 4 记录日志的任务 ID
0x0020 timestamp usize 8 字节 8 时间戳(架构相关单位)
0x0028 message [u8; 256] 256字节 1 消息缓冲区
总计:约 280 字节(实际取决于编译器对齐)
关键设计:
┌────────────────────────────────────────────────────────────────┐
│ seq 字段必须在首位的原因: │
│ ─────────────────────────────────────────────────────────────│
│ 1. 作为同步标志,读取者首先检查 seq 判断数据是否就绪 │
│ 2. Acquire-Release 语义: │
│ · 写入者最后以 Release 写入 seq │
│ · 读取者首先以 Acquire 读取 seq │
│ · 保证 seq 之前的所有字段对读取者可见 │
│ 3. 固定偏移量 0,便于汇编优化和理解 │
└────────────────────────────────────────────────────────────────┘
内存表示(#[repr(C, align(8))]):
┌────────────────────────────────────────────────────────────────┐
│ +0x00 ┌───────────────────────────────────────────────────┐ │
│ │ seq: AtomicUsize │ │
│ +0x08 ├───┬───────────────────────────────────────────────┤ │
│ │lv │ [padding 3 bytes] │ │
│ +0x0C ├───┴───────────────────────────────────────────────┤ │
│ │ cpu_id: usize │ │
│ +0x14 ├───────────────────────────────────────────────────┤ │
│ │ length: usize │ │
│ +0x1C ├───────────────────────────────────────────────────┤ │
│ │ task_id: u32 │ │
│ +0x20 ├───────────────────────────────────────────────────┤ │
│ │ timestamp: usize │ │
│ +0x28 ├───────────────────────────────────────────────────┤ │
│ │ message: [u8; 256] │ │
│ │ 固定大小的消息缓冲区 │ │
│ │ UTF-8 编码,超长消息会被截断 │ │
│ +0x128 └───────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
消息截断策略
消息缓冲区固定为 256 字节(MAX_MESSAGE_LEN),超过长度的消息会被自动截断:
截断规则:
-
尊重 UTF-8 边界:截断时检查 UTF-8 字符边界,避免产生无效字符序列
- 如果第 256 字节是 UTF-8 多字节字符的中间位置,向前查找完整字符的起始位置
- 保证截断后的字符串是有效的 UTF-8
-
记录实际长度:
length字段记录消息的实际字节数(截断前的长度)- 读取者可以通过比较
length和MAX_MESSAGE_LEN判断是否被截断
- 读取者可以通过比较
-
静默截断:截断不会报错或警告,保证日志记录的可靠性
- 设计哲学:宁可记录部分信息,也不能因为消息过长而丢失整条日志
UTF-8 截断示例:
假设消息是 "Hello 世界!Extra text"(UTF-8 编码):
字节序列:
H e l l o 世 界 ! E x t r a ...
48 65 6c 6c 6f 20 e4b896 e7958c efbc81 45 78 74 72 61 ...
假设 MAX_MESSAGE_LEN = 20:
- 朴素截断:截取前 20 字节 → 可能在"界"字的中间(e7958c 被截断)
- 智能截断:检测到 0xe7 是三字节字符的开始,向前推到"世"字的结束位置
- 最终截断:"Hello 世界!E" (完整的 UTF-8 字符)
实现位于 os/src/log/entry.rs:96-114。
显示格式
日志条目实现了 core::fmt::Display trait,格式化输出为可读的字符串:
标准格式:
[时间戳] [级别] [CPU核心/任务ID] 消息内容
示例输出:
[000012345678] [INFO ] [CPU0/Task1] Kernel initialized successfully
[000012350123] [ERROR] [CPU2/Task5] Failed to allocate memory
[000012351000] [WARN ] [CPU1/Task3] High memory usage: 95%
[000012352456] [DEBUG] [CPU3/Task0] Entering function foo()
字段说明:
| 字段 | 格式 | 说明 |
|---|---|---|
| 时间戳 | [%012d] | 12 位十进制数,左填充零,单位取决于架构(通常是 CPU 周期或纳秒) |
| 级别 | [%-5s] | 5 字符宽,左对齐,使用 ANSI 颜色(如果控制台支持) |
| CPU核心 | CPU%d | CPU 核心 ID |
| 任务ID | Task%d | 任务 ID(如果未设置则为 0) |
| 消息 | UTF-8 字符串 | 实际的日志消息,可能被截断 |
颜色映射:
| 级别 | 颜色 | ANSI 代码 |
|---|---|---|
| Emergency | 亮红 | \x1b[91m |
| Alert | 亮红 | \x1b[91m |
| Critical | 亮红 | \x1b[91m |
| Error | 红色 | \x1b[31m |
| Warning | 黄色 | \x1b[33m |
| Notice | 青色 | \x1b[36m |
| Info | 绿色 | \x1b[32m |
| Debug | 默认 | 无颜色 |
实现位于 os/src/log/entry.rs:116-137。
性能特性
环形缓冲区性能
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 写入 | O(1) | fetch_add + 槽位写入,常数时间 |
| 读取 | O(1) | 槽位读取 + fetch_add,常数时间 |
| 溢出处理 | O(k) | k 为 CAS 循环次数,通常 1-3 次 |
| 级别检查 | O(1) | 原子加载,几纳秒 |
日志条目性能
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 创建条目 | O(n) | n 为消息长度,需要格式化和拷贝 |
| 拷贝条目 | O(1) | 固定大小(280 字节),memcpy 优化 |
| 格式化显示 | O(n) | n 为消息长度,需要格式化输出 |
内存使用
环形缓冲区总内存:
总大小 = WriterData (64B) + ReaderData (64B) + slots数组
slots数组大小 = MAX_ENTRIES × sizeof(LogEntry)
≈ 58 × 280 字节
≈ 16240 字节
总大小 ≈ 64 + 64 + 16240 ≈ 16368 字节 ≈ 16 KB
符合配置文件中定义的 BUFFER_SIZE = 16384 字节(os/src/log/config.rs:9)。
设计权衡
固定 vs 动态大小
当前设计:固定大小的消息缓冲区(256 字节)
优势:
- 日志条目大小确定,环形缓冲区容量可静态计算
- 无需堆分配,适合裸机环境
- 槽位对齐良好,访问效率高
劣势:
- 长消息会被截断
- 短消息浪费空间(平均日志可能只有几十字节)
权衡分析:对于内核日志系统,可预测性和可靠性优先于灵活性。固定大小设计更符合实时系统的需求。
MPSC vs MPMC
当前设计:MPSC(多生产者单消费者)
优势:
- 简化读取端同步,read_seq 无竞争
- 避免多消费者的活锁和优先级反转
- 性能更好(读取是冷路径,可接受单线程)
劣势:
- 只能有一个读取者
- 多个工具需要读取日志时需要额外协调
权衡分析:日志的自然特性是多写少读,MPSC 是最佳选择。如需多消费者,可在用户态实现分发。
Release-Acquire vs SeqCst
当前设计:Release-Acquire 语义用于 seq 字段同步
优势:
- 比 SeqCst 更弱的内存序,性能更好(特别是在 ARM/RISC-V 上)
- 足以保证无锁环形缓冲区的正确性
劣势:
- 需要更仔细的推理和验证
- 错误使用可能导致难以调试的并发 bug
权衡分析:经过仔细验证,Release-Acquire 是正确的选择。SeqCst 会带来不必要的性能开销。
未来改进方向
动态缓冲区大小
当前缓冲区大小在编译期固定。未来可考虑:
- 启动参数配置缓冲区大小
- 运行时动态扩展(需要复杂的迁移逻辑)
持久化支持
当前日志只存在内存中。未来可增加:
- 慢速持久化层(SPI Flash、磁盘)
- 后台线程定期刷新内存日志到持久化存储
- 崩溃恢复时读取持久化日志
压缩和去重
对于重复的日志,可以压缩:
- 记录重复次数而不是完整消息
- 使用哈希值检测重复
- 节省缓冲区空间
多缓冲区分区
针对不同模块使用不同的缓冲区:
- 减少竞争,提高并发性能
- 支持按模块过滤和分析
- 隔离故障(一个模块的日志洪水不影响其他模块)
这些改进在不破坏当前 API 的前提下都是可行的,得益于良好的模块化设计。