起因

到目前为止每篇文章的"跑通标志"都靠串口 printf 亲眼盯着——工程上这不可持续。工业系统要的是:出事后能还原现场。这篇把散落各篇的"记日志"统一成一个系统:分级、双通道(串口+盘)、ISR 可写、崩溃可救。这也是合规审计的地基——合规篇的授权解锁、所有权转移、参数修改,全都要在这里留痕。

记日志在计算机系统里是门老手艺:1983 年 Eric Allman 给 sendmail 写的 syslog 定下了"设施.级别"的分级规矩,后来才标准化成 RFC 3164/5424。工业系统照搬不了文本行——ISR 里拼字符串是奢侈行为——但分级、轮转、留痕的概念全部继承,载体从文本行换成定长二进制记录,串口/盘双通道对应的是车间现场和事后取证两种读者。

一、四条硬需求

需求 设计回答
ISR 里能不能记 能,且必须无锁无分配
断电后还在不在 在——落盘策略 + 崩溃遗言
时间从哪来 TSC 时间戳 + 开机基准换算墙钟(APIC 篇
谁能看 串口(现场)、文件(事后)、网络(远程诊断)

四条串起来就是数据流:各上下文往自己的环里写 → 协作层 drain → 串口实时出 + 攒批落盘,崩溃时另有裸分区遗言这条路。

二、记录格式:定长头 + 变长载荷

一条记录 = 24B 头 + payload:

字段 类型 说明
magic u16 0x4C47(“LG”)
level u8 0 PANIC / 1 ERROR / 2 WARN / 3 INFO / 4 DEBUG
module u8 MOD_USB / MOD_NVME / MOD_FS / MOD_MOTION / MOD_GUI…
seq u32 单调流水号,缺号=丢日志,丢的比假的强
tsc u64 时间戳,展示层换算墙钟
len u16 载荷长度
crc32 u32 覆盖头后半 + 载荷

为什么二进制不定长文本:ISR 里格式化字符串既慢又要分配;文本留给显示/导出时转换。ISR 路径的载荷纪律是"短标签 + 少量定长参数"(u16/u32 直接附在后面),人读的句子是展示层把二进制翻译出来的事。level 支持编译期整级裁剪 + 运行期由参数表调整;module 枚举让"只看运动相关"这种过滤成为一次位测试。

1
2
3
4
// ISR 里一行(不格式化,参数按定长二进制附在后面):
log_isr!(WARN, MOD_USB, tag::PORT_RESET, port as u32);
// 协作层宽松些,可以用格式化helper(它内部走堆,只在服务核合法):
log_info!(MOD_FS, fmt!("mount ok: {}", dev));

三、无锁环形缓冲:正确与错误的做法

坑(本篇最重要的坑):通用的 CAS 型 MPMC 无锁队列在中断嵌套下会自锁。 低优先级 ISR 在"占好槽位、还没推进位置指针"的窗口被高优先级 ISR 打断,后者在同一个槽位上自旋等前者推进——而前者要等后者返回才能运行,同核死锁。中断优先级嵌套(0xF0 安全 > 0xE0 放电 > 0xD0 插补)恰恰是这个系统的常态。

正解:按向量类分环。安全/放电/插补/设备各一条 SPSC 环——同类中断同优先级不会互相打断,单生产者成立;消费者是同核协作段或服务核。环满就丢并给 overflow 计数 +1,日志永远不能反压实时层

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
pub struct LogRing {
    buf: [u8; 64 * 1024],            // 静态分配,初始化期定死
    head: CachePadded<AtomicU64>,    // 生产者,单调递增不回绕
    tail: CachePadded<AtomicU64>,    // 消费者,单调递增不回绕
    dropped: AtomicU32,
}
// 索引 = head % LEN。不用回绕式 head/tail 指针的原因:
// 回绕式 head==tail 分不清"空"还是"满",单调计数天生没这个歧义
// push:head - tail >= LEN → 满,丢,dropped+1;
//      否则拷贝记录,head.store(head + len, Release)
// pop :tail.load(Acquire) < head → 有数据,读完 tail.store(tail + len, Release)

head/tail 各自 CachePadded 是防伪共享:实时核和服务核各写各的原子量,挤在同一条缓存行上会乒乓,实时核每次 push 都被拖慢——一行 padding 换来的确定性,值。

通用多生产者的升级路线也留好:取号式(fetch_add 拿槽位,永不自旋)+ 发布序号,消费者跳过未发布的空洞——但默认方案就是分环,简单可证。

四、双通道输出

串口通道:16550 COM1(0x3F8),THR@+0、LSR@+5 bit5 空→写。初始化一次:

1
2
3
4
5
6
// 115200-8N1,纯轮询不开中断。除数 = 1843200 / 16 / 115200 = 1
outb(0x3F8 + 3, 0x80);   // LCR bit7=1,打开除数锁存
outb(0x3F8 + 0, 0x01);   // DLL = 1
outb(0x3F8 + 1, 0x00);   // DLM = 0
outb(0x3F8 + 3, 0x03);   // 8N1,bit7 归零
outb(0x3F8 + 1, 0x00);   // IER=0——日志不走串口中断,永远轮询

ISR 只入环,串口打印在协作层 drain 时做,drain 每周期设上限(比如 256 条),防日志风暴把同一个循环里的其他 step 饿死。

坑:在 ISR 里直接打印串口,一行日志就够把实时层打歪。 115200 波特率一字节 10 个 bit 约 87µs,一行 60 字节 5ms——100µs 级的插补节拍直接被打爆。打印永远是协作层的事。

盘通道:协作层攒批(64KB 或 1 秒先到者)→ 追加写日志文件 → NVMe flush(exFAT 篇的落盘纪律)。轮转:LOG0001.LOG~LOG0032.LOG 各 4MB 封顶,环形覆盖最旧,总占用恒定 128MB——十五年机器的硬盘不能被日志吃穿。

五、崩溃遗言:裸日志分区

panic 的特殊性:文件系统状态不可信(可能正是它把你弄崩的)、中断可能已经废了。方案:划一个专用裸日志分区(不经 exFAT 元数据,直接 LBA 追加):

  • 结构:首扇区 = 写指针(双副本 + gen,参数篇同款机制)+ 1MB 环形数据区
  • panic handler 里走最小依赖路径:NVMe 轮询模式(不开中断)写 1~2 扇区。轮询模式为什么还能用:NVMe 的完成检测本来就是"提交命令 → 轮询 CQ 的 phase 位翻转",中断只是优化不是依赖,NVMe 篇的提交路径原样能跑
  • 遗言内容按优先级塞:崩溃时刻(TSC + 换算的墙钟)、异常向量号、CR2(缺页地址)、RIP/RSP/RFLAGS、通用寄存器组、四个环里最近 32 条记录。一个扇区 512B 装不下全部就砍尾部,先保证时间和向量号在
  • 开机时:fsck-lite 扫尾 → 把裸分区新内容归档成 exFAT 文件 → 清指针

坑:这是"尽力而为"路径——如果崩的正是 NVMe 驱动本身,遗言写不进去就只剩串口。所以串口遗言永远先写,盘遗言是第二保险。

六、远程诊断

网络篇的 TCP 栈上开一个极简服务:连上发状态快照 + 尾随最近日志(文本转换后),现场排障不用拆机箱接串口;Modbus-TCP 寄存器映射报警码,接到客户现有的 SCADA 也行。这也是 4G 远程通道复用的底座。

总结

实战清单:24B 记录头 + ISR 载荷纪律(短标签+定长参数);按向量类分 SPSC 环、单调索引 + CachePadded + overflow 计数;串口一次初始化(除数锁存 115200-8N1、IER=0 纯轮询)+ drain 条数上限;协作层批量落盘 + 4MB×32 轮转;裸分区遗言(向量号/CR2/RIP/寄存器/最近 32 条)+ 开机归档;远程尾随。跑通标志:人为 panic 一次,断电重启后裸分区里能读到完整遗言并归档成文件;满负荷运行时日志系统 CPU 占用 <2%;日志级别 DEBUG 调 INFO 后重跑,环的丢包计数为 0。

下一篇:参数系统与断电恢复——日志解决"发生了什么",参数解决"配置与现场丢没丢"。

参考链接