第 14 讲 | 日志系统设计:RTT、串口日志与现场问题定位
【本讲要点】现场问题无法复现时,日志是你唯一的证据。这一讲设计一套"既不阻塞、又能掉电留痕"的日志系统。
一、日志是"第二双眼睛"
回到第 10 讲那个卡机案例。如果没有日志,我们的排查过程会是这样:
- 现场说"按了按键就没反应";
- 实验室复现不出来;
- 只能靠猜,改一版发一版,来回耗上一个月。
有了日志,我们才拿到那条关键信息:hb missing: 0x6。
日志的价值,不在于"平时好看",而在于"出事时能说话"。 这决定了日志系统的设计目标:
二、日志的四个层次
按"成本"和"用途"分层,各司其职:
| 编译期开关 | — | — | 无 | 量产版本砍掉调试日志 |
| RTT | 调试接口 | 否 | 极低 | 开发调试,实时性最好 |
| 串口 | UART | 否 | 中(需异步) | 现场接串口调试 |
| Flash 环形日志 | 片内 Flash | 是 | 低(异步) | 现场问题取证 |
| 云端 | 网络 | 是 | 低 | 批量统计与远程诊断 |
关键设计:日志的输出目标是"可插拔"的。
/* 日志后端注册机制 */
typedef struct log_backend {
void (*output)(const char *buf, uint16_t len);
struct log_backend *next;
} log_backend_t;
void log_register_backend(log_backend_t *b)
{
b->next = s_backends;
s_backends = b;
}
void log_output(const char *buf, uint16_t len)
{
for (log_backend_t *b = s_backends; b; b = b->next) {
b->output(buf, len);
}
}
RTT、串口、Flash、云端都只是"一个后端"。 开发时挂 RTT,现场挂 Flash + 云端,量产后只留云端。
三、分级日志设计
3.1 级别与编译开关
#define LOG_NONE 0
#define LOG_ERROR 1
#define LOG_WARN 2
#define LOG_INFO 3
#define LOG_DEBUG 4
#define LOG_VERBOSE 5
/* 发布版本设为 LOG_WARN,调试版本 LOG_DEBUG */
#ifndef LOG_LEVEL
#define LOG_LEVEL LOG_INFO
#endif
#define log_error(...) LOG_EMIT(LOG_ERROR, "E", __VA_ARGS__)
#define log_warn(...) LOG_EMIT(LOG_WARN, "W", __VA_ARGS__)
#define log_info(...) LOG_EMIT(LOG_INFO, "I", __VA_ARGS__)
#define log_debug(...) LOG_EMIT(LOG_DEBUG, "D", __VA_ARGS__)
/* 编译期消除:低于等级的日志在编译后完全不存在,零开销 */
#define LOG_EMIT(lvl, tag, ...) \\
do { \\
if ((lvl) <= LOG_LEVEL) { \\
log_printf(tag, __VA_ARGS__); \\
} \\
} while (0)
"编译期消除"比"运行时判断"重要得多: 前者让不需要的日志不占 Flash、不占 CPU;后者只是不输出,但代码还在。
3.2 模块标签
日志一多,分不清来源。加模块标签:
/* 每个模块定义自己的标签 */
#define LOG_TAG_WIFI "WIFI"
#define LOG_TAG_MQTT "MQTT"
#define LOG_TAG_MODBUS "MB"
#define LOG_TAG_OTA "OTA"
输出效果:
[0012345][I][WIFI] connect ok, rssi=-52
[0012468][I][MQTT] connack, session=0
[0015000][W][MB] crc_err=12 frame=3
有了时间戳 + 等级 + 模块,一眼就能看出问题在哪个模块、什么时候发生的。
四、异步输出:绝不阻塞
第 6 讲讲过原理,这里给完整的实现:
/* ==================== log_async.c ==================== */
#define LOG_RING_SIZE 512
static char s_log_ring[LOG_RING_SIZE];
static volatile uint16_t s_log_head = 0;
static volatile uint16_t s_log_tail = 0;
static volatile uint32_t s_log_drop = 0;
/* 任何上下文都可以安全调用:只入队,绝不阻塞 */
void log_printf(const char *tag, const char *fmt, ...)
{
char tmp[96];
int n;
/* 1. 先格式化到栈上小缓冲(注意栈开销) */
n = snprintf(tmp, sizeof(tmp), "[%s] ", tag);
if (n < 0) return;
va_list ap;
va_start(ap, fmt);
n += vsnprintf(tmp + n, sizeof(tmp) – n, fmt, ap);
va_end(ap);
if (n < 0) return;
/* 2. 追加换行 */
if (n < (int)sizeof(tmp) – 1) {
tmp[n++] = '\\n';
}
/* 3. 入队(满则丢弃并计数,绝不阻塞) */
for (int i = 0; i < n; i++) {
uint16_t next = (uint16_t)((s_log_head + 1) & (LOG_RING_SIZE – 1));
if (next == s_log_tail) {
s_log_drop++;
return;
}
s_log_ring[s_log_head] = tmp[i];
s_log_head = next;
}
}
/* 低优先级任务:把环形缓冲的内容吐给所有后端 */
void log_task(void *arg)
{
for (;;) {
while (s_log_tail != s_log_head) {
char c = s_log_ring[s_log_tail];
s_log_tail = (uint16_t)((s_log_tail + 1) & (LOG_RING_SIZE – 1));
log_output(&c, 1);
}
vTaskDelay(pdMS_TO_TICKS(20));
}
}
三个注意点:
五、Flash 环形日志:掉电留痕
这是本讲最有价值的部分。
设备在现场突然重启,RAM 里的日志全部消失。必须把关键日志写到 Flash 里。
5.1 设计要点
- 环形结构:定长槽位循环写,写满覆盖最旧的;
- 固定长度条目:避免变长带来的管理复杂度;
- 只在"关键事件"写:不能所有日志都落盘(Flash 寿命 + 速度);
- 掉电时不留半条:用"序列号 + CRC"保证条目完整性。
#define FLASH_LOG_SLOTS 64 /* 64 条 */
#define FLASH_LOG_BASE 0x08016000 /* 从参数区划出 */
#define FLASH_LOG_ENTRY_SZ 32 /* 每条 32 字节,必须对齐 */
typedef struct {
uint16_t seq; /* 序列号,用于判断新旧 */
uint8_t level; /* 等级 */
uint8_t tag_id; /* 模块 ID(用 ID 而不是字符串,省空间) */
uint32_t tick; /* 系统 tick */
uint32_t code; /* 事件码 / 关键数值 */
uint8_t reserved[18];
uint16_t crc; /* 本条 CRC */
} __attribute__((packed)) flash_log_entry_t;
只记录"事件码 + 数值",不记录字符串——32 字节存不下一句话,但能存下"事件 + 参数"。查问题时对照事件码表即可。
5.2 写入
void flash_log_write(uint8_t level, uint8_t tag_id, uint32_t code)
{
flash_log_entry_t e;
static uint16_t s_seq = 0;
e.seq = ++s_seq;
e.level = level;
e.tag_id = tag_id;
e.tick = xTaskGetTickCount();
e.code = code;
memset(e.reserved, 0, sizeof(e.reserved));
e.crc = crc16((uint8_t *)&e, sizeof(e) – 2);
/* 环形定位到下一个槽位 */
uint32_t slot = (s_seq % FLASH_LOG_SLOTS) * FLASH_LOG_ENTRY_SZ;
flash_erase_page(FLASH_LOG_BASE + slot); /* 先擦 */
flash_write(FLASH_LOG_BASE + slot, (uint8_t *)&e, sizeof(e));
}
注意:每写一条就擦一页,Flash 寿命消耗很快。 优化方案:
- 用"页内顺序追加"(一页写满再擦),寿命提升几十倍;
- 或者降低写入频率(只记关键事件)。
5.3 读取(现场取证)
/* 开机时或收到"上传日志"指令时,把 Flash 里的日志读出来 */
void flash_log_dump(void)
{
flash_log_entry_t e;
uint16_t max_seq = 0;
int newest_slot = –1;
/* 1. 先找出序列号最大的那条(环形的"头") */
for (int i = 0; i < FLASH_LOG_SLOTS; i++) {
if (flash_read(FLASH_LOG_BASE + i * FLASH_LOG_ENTRY_SZ,
(uint8_t *)&e, sizeof(e)) != 0) {
continue;
}
if (crc16((uint8_t *)&e, sizeof(e) – 2) != e.crc) {
continue; /* 跳过损坏条目 */
}
if (e.seq >= max_seq) {
max_seq = e.seq;
newest_slot = i;
}
}
if (newest_slot < 0) return;
/* 2. 从最新的往回读,按时间顺序输出 */
for (int k = 0; k < FLASH_LOG_SLOTS; k++) {
int i = (newest_slot – k + FLASH_LOG_SLOTS) % FLASH_LOG_SLOTS;
if (flash_read(FLASH_LOG_BASE + i * FLASH_LOG_ENTRY_SZ,
(uint8_t *)&e, sizeof(e)) != 0) continue;
if (crc16((uint8_t *)&e, sizeof(e) – 2) != e.crc) continue;
log_printf("HIST", "seq=%u tick=%u tag=%u code=0x%x",
e.seq, e.tick, e.tag_id, e.code);
}
}
“先找序列号最大的那条,再往回读”——这是环形日志回溯的标准做法。
六、现场取证流程
有了上面三套机制,一个完整的现场取证流程是:
1. 设备异常重启
↓
2. 开机读取复位原因(第 10 讲)→ 记录到 Flash
↓
3. 开机后 dump Flash 环形日志 → 得到"重启前最后 N 条"
↓
4. 把 [复位原因 + 历史日志 + 当前状态] 打包上报云端
↓
5. 工程师在云端看日志 → 定位问题
第 4 步是关键:把日志"传上来",工程师就不用去现场。
/* 上报诊断包 */
void report_diag(void)
{
uint8_t buf[256];
int n = 0;
n += pack_u8(buf + n, boot_cause_get());
n += pack_u32(buf + n, crash_pc_get());
n += pack_u32(buf + n, xPortGetFreeHeapSize());
n += pack_u16(buf + n, mb_stat_crc_err());
n += pack_flash_logs(buf + n, 8); /* 带上最近 8 条历史日志 */
iot_publish_diag(buf, n);
}
注意这 256 字节的诊断包,在 16KB RAM 里也是要精打细算的——所以用 pack_xxx 紧凑打包,而不是 JSON。 JSON 在 MCU 上又慢又占内存。
七、日志系统清单
八、小结
四句话:
1. 日志的价值在"出事时能说话",所以第一要求是"不阻塞 + 掉电留痕 + 能远程取回"。
2. 后端可插拔:开发用 RTT,现场用 Flash + 云端,量产只留云端。
3. 编译期开关比运行时判断更重要——不输出的日志应该连代码都不存在。
4. Flash 环形日志是现场取证的唯一手段;"序列号最大的那条往回读"是标准回溯法。
下一讲(第 15 讲),我们把视角从"单个设备"抬到"一批设备":量产测试与老化。出厂前那 8 项测试,决定你的售后成本是 1% 还是 30%。




