ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

嵌入式串口日志系统架构设计:从printf到环形缓冲异步输出

嵌入式串口日志系统架构设计:从printf到环形缓冲异步输出 嵌入式开发里很多人把“能用 printf 打印”当成日志系统就绪了。等程序跑起来发现要么日志把实时逻辑拖垮要么关键信息被刷掉要么中断里打印直接死机。EPLAT 实践系列的第 1-2 节我们把串口日志从“零散打印”升级成一套分层架构模块。这篇文章会从接口定义、串口适配、环形缓冲、异步输出到 PC 端采集与分析完整拆一遍实现思路并给出可以搬到裸机或 RTOS 工程里的代码骨架。先说结论串口日志系统不是“会不会用 printf”的问题而是“日志关不掉、会阻塞业务、关键现场抓不到”的工程问题。日志一旦成为架构的一部分就必须具备分级开关、模块过滤、缓冲输出、低侵入调用、不阻塞业务这几个能力。本文按 EPLAT 的设计思路用一个可裁剪的 C 模块实现这套机制并提供串口波形、RAM/CPU 占用、批量采集等验证方法。如果你正在写裸机程序、RTOS 应用或者准备往嵌入式板子上接 AI 推理框架又不想每次调试都被日志问题绊住这篇文章可以直接照着做。1. 为什么嵌入式项目需要一套串口日志系统很多小项目最初只有几十行代码printf 直接往串口吐信息看起来没什么问题。但规模到一定程度比如接入传感器驱动、网络协议栈、文件系统或者跑一个轻量级 AI 推理任务时排查问题的难度就会迅速上升。零散 printf 最大的问题不是代码丑而是无法回答这几个问题当前运行的是正式版还是调试版Release 构建里日志能不能全部摘除某个模块日志太多能不能在运行时单独关掉这个模块传感器或协议栈每次上报几百字节日志会不会把任务调度时序打乱在中断服务函数里打印为什么会导致系统卡死设备跑飞或者看门狗复位后能不能拿到最后一段日志现场这些问题用零散 printf 基本都要靠“改代码 重新烧录 再跑一遍”来解决。日志系统要解决的是让开发者不频繁改业务代码就能观察、过滤、保存、复盘系统行为。从架构分层的角度看串口日志系统本质上是“业务代码”和“硬件输出设备”之间的一层中间件。业务代码只负责描述发生了什么由日志模块决定是否输出、输出到串口还是 Flash、同步发送还是异步发送、保留哪些级别。分层之后业务代码和物理输出之间的耦合就被切断了后续就算要把日志从串口换到网络或文件系统业务代码也不用改。这是 EPLAT 架构实践里偏“基础设施”的一课优先级高于很多花哨的功能模块。因为无论是裸机状态机、RTOS 任务还是板端 AI 推理日志都是观察系统运行状态的第一入口。传感器解析错了、推理帧率不对、任务长时间不调度没有日志就只能靠猜有日志则可以按时间线回放。2. EPLAT 串口日志系统的架构分层与核心能力EPLAT 架构里串口日志系统被拆成四层调用层、过滤与格式化层、缓冲与调度层、串口驱动适配层。调用层是开发者在业务代码里看到的接口通常是LOG_D、LOG_I、LOG_W、LOG_E这类宏隐藏了级别、tag、格式化细节。过滤与格式化层负责判断当前级别是否允许输出并组装成统一的日志行格式。缓冲与调度层解决“日志产生速度大于串口发送速度”的问题也是异步日志的关键。串口驱动适配层只做一件事把一个字节通过串口发送出去具体是轮询、中断还是 DMA由板级驱动决定。能力项设计目标运行环境裸机主循环、RTOS 任务均可接入输出端口串口为主适配层可扩展到 Flash、网络日志级别DEBUG、INFO、WARN、ERROR运行时开关过滤方式级别过滤 tag 标签可按模块扩展缓冲方式环形缓冲缓冲区满丢弃新日志对业务影响常规日志异步发送不长时间阻塞业务是否可裁剪宏开关控制Release 可整体关闭数据格式文本行带 tick 时间戳、级别、tag资源要求RAM 取决于环形缓冲通常 1KB 到 4KB核心能力可以概括成四句话调用简单、日志分级、异步发送、可整体裁剪。调用简单靠宏让业务代码一行就能打日志日志分级让运行现场不会因为信息过载而失去重点异步发送解决打印阻塞实时逻辑的问题整体裁剪保证正式版本不会残留调试代码路径。3. 环境准备硬件、串口工具与软件基础在开始写代码前先把验证环境准备好。串口日志系统本身对主控要求不高Cortex-M0/M3/M4 或者 Linux 嵌入式平台都能跑核心是串口外设、一个定时器 tick、以及足够的 RAM 用于环形缓冲。需要准备的项说明开发板带至少一路 UART 即可CH340、CP2102 类 USB 转串口均可串口工具Windows 用 SSCOM/XCOMLinux 用 minicom/screen代码工程裸机工程或 RTOS 工程均可需要有编译和烧录路径系统 tick提供毫秒级 tick用于时间戳串口驱动确认 PC 端能识别到 USB 转串口设备避免枚举失败调试阶段建议把串口波特率先固定到 115200。这个速率适合大多数日志场景波特率太高对线材和干扰会更敏感太低则吞吐不够用。打开串口助手后要确认能收到芯片复位时由 bootloader 或启动代码输出的原始字符这样可以排除硬件连接层面的问题。代码上日志模块只依赖以下能力一个毫秒 tick 获取函数例如eplat_get_tick()一个串口单字节发送函数例如eplat_uart_write_byte()头文件里能看到stdint.h、stdio.h、stdarg.h这类标准头文件。如果你的平台没有现成的 tick 接口可以用 SysTick 或者 RTOS 的系统时基直接封装一个。不要求日历时间只要知道从开机到现在过了多少毫秒就足够定位问题顺序。4. 从接口定义入手eplat_log.h 文件设计写日志系统的第一步不是写发送逻辑而是把调用接口固定下来。接口一旦稳定业务代码就只面向宏编程之后内部怎么改发送方式、怎么加过滤都不影响业务层。下面是 eplat_log.h 的骨架实现包含了级别定义、底层输出函数和调用宏。LOG_D、LOG_I、LOG_W、LOG_E分别对应调试、信息、警告、错误四个等级LOG_TAG用于标注模块名。#ifndef EPLAT_LOG_H #define EPLAT_LOG_H #include stdint.h /* 日志级别 */ #define LOG_LEVEL_DEBUG 0u #define LOG_LEVEL_INFO 1u #define LOG_LEVEL_WARN 2u #define LOG_LEVEL_ERROR 3u #define LOG_LEVEL_OFF 4u /* 单行日志内容缓冲区大小按 RAM 调整 */ #ifndef LOG_LINE_BUF_SIZE #define LOG_LINE_BUF_SIZE 128u #endif #ifndef LOG_TAG #define LOG_TAG main #endif void eplat_log_init(void); void eplat_log_set_level(uint8_t level); void eplat_log_output(uint8_t level, const char *tag, const char *fmt, ...); /* 文件内可用默认 tag也可以通过 _TAG 版本显式传入模块名 */ #define LOG_D(...) eplat_log_output(LOG_LEVEL_DEBUG, LOG_TAG, __VA_ARGS__) #define LOG_I(...) eplat_log_output(LOG_LEVEL_INFO, LOG_TAG, __VA_ARGS__) #define LOG_W(...) eplat_log_output(LOG_LEVEL_WARN, LOG_TAG, __VA_ARGS__) #define LOG_E(...) eplat_log_output(LOG_LEVEL_ERROR, LOG_TAG, __VA_ARGS__) #define LOG_I_TAG(tag, ...) eplat_log_output(LOG_LEVEL_INFO, tag, __VA_ARGS__) #define LOG_E_TAG(tag, ...) eplat_log_output(LOG_LEVEL_ERROR, tag, __VA_ARGS__) #endif在业务 C 文件里可以这样使用#define LOG_TAG sensor #include eplat_log.h void sensor_task(void) { int16_t temp 26; LOG_I_TAG(sensor, temperature%d, temp); LOG_E_TAG(sensor, read failed, err%d, -1); }为什么要用宏而不是直接调函数原因有两个。第一宏可以统一携带文件名、行号、函数名如果以后想打印定位信息只需要在宏定义里扩展__FILE__、__LINE__不需要改业务代码。第二Release 下可以通过把宏定义为空实现彻底移除日志代码路径。这里补充一点LOG_TAG在头文件里给了默认值但实际工程更推荐每个 C 文件顶部自定义LOG_TAG让日志天然带上模块归属感。标签命名建议用短小字符串比如sensor、motor、net、ai不要写一长串文件名否则会影响日志可读性。5. 串口输出实现与 printf 重定向接口定了以后下一步解决“日志怎么发出去”。串口发送从实现方式上分三种轮询、中断、DMA。轮询发送是最简单的每个字节等待发送寄存器空代码直观但在数据量大时会占用 CPU而且如果在中断里调用轮询函数会阻塞整个中断响应。中断发送通过发送完成中断逐字节搬移不阻塞 CPU但要处理环形队列和中断回调。DMA 发送不占 CPU但需要 DMA 外设支持并且缓冲区生命周期要小心管理。对日志系统来说第一版不建议一上来就做 DMA。先实现一个简单的串口写字节函数后面再优化。下面的示例使用 HAL 风格描述实际平台需要替换为自己的寄存器操作或 SDK 接口。/* 示例STM32 HAL 风格实现按实际平台替换 */ #include stm32xxxx_hal.h extern UART_HandleTypeDef huart1; void eplat_uart_write_byte(uint8_t byte) { /* 高波特率下轮询等待 TXE超时保护可根据平台增加 */ while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TXE) RESET) {} huart1.Instance-TDR byte; }如果你的业务代码里还有很多历史遗留的 printf又不想立刻全部改掉可以暂时把 printf 映射到同一个写字节函数让它走串口输出。但在正式日志模块上线后尽量把常规调试信息迁移到日志宏避免两条输出路径的格式不统一。ARMCC/AC5 环境下重定向fputc#include stdio.h int fputc(int ch, FILE *f) { eplat_uart_write_byte((uint8_t)ch); return ch; }arm-none-eabi-gcc 环境下重定向_write#include stdio.h int _write(int fd, char *ptr, int len) { int i; for (i 0; i len; i) { eplat_uart_write_byte((uint8_t)ptr[i]); } return len; }这里特别提醒printf 重定向后会直接往串口同步发送如果在中断、临界区、频繁执行的实时路径里调用可能对系统时序造成明显影响。所以 printf 重定向只适合启动阶段和低频率调试不能把它当成最终日志通道。6. 环形缓冲与异步输出日志不能阻塞业务串口是慢速设备115200 波特率下传一个字节大约需要 86.8 微秒传一行 100 字节的日志要接近 8.7 毫秒。如果业务代码直接同步等待发送完成一条 INFO 日志就可能让控制任务错过中断或打乱调度。工程上的做法是让日志先进入环形缓冲再由一个后台任务或者主循环里的低优先级逻辑把缓冲内容发送出去也就是“生产者—消费者”模型。环形缓冲在这里有两个作用一是削峰填谷允许业务代码短时间产生大于串口吞吐的日志只要平均速率不超过串口上限就不会丢失二是把发送动作从业务上下文剥离避免单条日志阻塞实时逻辑。下面给出一个简化但可跑的 eplat_log.c 核心实现。为了便于阅读省略了跨任务互斥假设日志产生的上下文是单生产者串口发送是单消费者。这种模型适合裸机主循环也适合只有一个低优先级日志任务消费的场景。#include eplat_log.h #include stdio.h #include stdarg.h #include string.h #define LOG_RING_BUF_SIZE 1024u #define LOG_RING_MASK (LOG_RING_BUF_SIZE - 1u) static uint8_t s_ring[LOG_RING_BUF_SIZE]; static volatile uint16_t s_head 0u; static volatile uint16_t s_tail 0u; static uint8_t s_level LOG_LEVEL_DEBUG; static const char s_level_char[] { D, I, W, E }; /* 底层依赖由平台提供 */ extern uint32_t eplat_get_tick(void); extern void eplat_uart_write_byte(uint8_t byte); static void ring_write(const uint8_t *data, uint16_t len) { uint16_t i; for (i 0u; i len; i) { uint16_t next (s_head 1u) LOG_RING_MASK; if (next s_tail) { /* 缓冲满丢弃新数据 */ break; } s_ring[s_head] data[i]; s_head next; } } void eplat_log_init(void) { s_head 0u; s_tail 0u; s_level LOG_LEVEL_DEBUG; } void eplat_log_set_level(uint8_t level) { if (level LOG_LEVEL_OFF) { s_level level; } } void eplat_log_output(uint8_t level, const char *tag, const char *fmt, ...) { char line[LOG_LINE_BUF_SIZE]; char head[32]; va_list args; uint16_t len; uint16_t hlen; uint32_t tick; if (level s_level) { return; } va_start(args, fmt); len (uint16_t)vsnprintf(line, sizeof(line), fmt, args); va_end(args); if (len (uint16_t)sizeof(line)) { len (uint16_t)sizeof(line) - 1u; } tick eplat_get_tick(); if (tag 0) { tag main; } /* 组前缀 [tick][级别][tag] */ hlen (uint16_t)snprintf(head, sizeof(head), [%lu][%c][%s] , (unsigned long)tick, s_level_char[level], tag); ring_write((const uint8_t *)head, hlen); ring_write((const uint8_t *)line, len); ring_write((const uint8_t *)\r\n, 2u); } /* 裸机主循环调用或 RTOS 低优先级任务循环调用 */ void eplat_log_service(void) { while (s_tail ! s_head) { eplat_uart_write_byte(s_ring[s_tail]); s_tail (s_tail 1u) LOG_RING_MASK; } }这个实现的关键点是把整行日志拆成“头部”和“正文”两段写入环形缓冲。头部写入 tick 时间戳、级别字符和 tag正文写入用户通过格式化产生的文本。消费端eplat_log_service不断从tail读取字节并发送到串口直到缓冲为空。如果日志会在多个任务或中断上下文中同时产生就必须在ring_write的 head 更新加锁常见做法是使用临界区或者在 RTOS 里使用互斥锁。但加锁粒度不能太大否则又会引入阻塞。针对 Cortex-M 平台早期实现可以用保存中断状态的临界区来保护 head 更新后续如果要上 RTOS再考虑用更细粒度的调度器锁。7. 时间戳与格式化输出效果有了时间戳和级别标签之后日志就不再只是无序文本而是一条可排序的时间线。在嵌入式环境里时间戳不一定来自 RTC用系统 tick 就能记录相对时间。这样做的好处是即使设备没有电池供电的 RTC也能判断每一步发生在开机后多少毫秒。eplat_get_tick()可以这样实现/* 假设 SysTick 每 1ms 中断一次 */ volatile uint32_t g_sys_tick_ms; void SysTick_Handler(void) { g_sys_tick_ms; } uint32_t eplat_get_tick(void) { return g_sys_tick_ms; }实际输出效果如下[ 123][D][sensor] temperature26 [ 125][I][sensor] sensor init ok [ 1000][W][net] connection timeout, retry1 [ 2003][E][ai] inference failed, err-12从格式上能看到几件事每条日志都有发生时间、级别、来源模块和正文。1203 毫秒时 sensor 初始化完成1000 毫秒时网络超时重试2003 毫秒时 AI 推理失败。如果设备异常结合这些时间戳可以快速判断是先断网还是先推理失败对定位问题顺序非常有价值。格式化方面嵌入式下尽量不要在日志里打印浮点数除非芯片有硬件 FPU 且工程完全启用浮点 printf。很多时候做一个整数转换就能绕开浮点库也能显著缩小固件体积。比如输出电压时直接把 mV 整数打出来而不是打印3.31V这样既避免浮点开销又保留精度。8. 工程接入裸机与 RTOS 使用示例日志模块编译进工程后有三个地方需要接好初始化、日志产生、日志消费。初始化在系统上电后尽早调用日志产生取决于业务需要在任何需要记录事件的地方调用日志宏日志消费则由主循环或 RTOS 任务循环调用eplat_log_service()。裸机工程的接入方式#include eplat_log.h void main_loop(void) { eplat_log_init(); LOG_I_TAG(app, system boot); for (;;) { /* 执行其他业务 */ app_task_poll(); /* 主循环末尾消费日志 */ eplat_log_service(); } }这里要特别注意eplat_log_service()是轮询发送如果日志量大会占用主循环时间。在裸机工程里可以把日志消费放到空闲态或者定时中断的较低优先级里。最理想的情况是有 DMA 发送日志任务只负责从缓冲取字节交给 DMACPU 几乎不参与逐字节发送。RTOS 工程下日志消费建议单独开一个低优先级任务void log_task(void *arg) { (void)arg; for (;;) { eplat_log_service(); osDelay(1); } }日志任务的优先级要低于控制任务避免日志大量输出时挤占关键任务时间。同时控制任务和中断里仍然可以调用日志宏因为此时日志只写环形缓冲不做串口等待阻塞时间远小于同步发送。业务代码是否可以直接在中断里调用日志技术上可以但必须满足两个前提一是环形缓冲的 head 更新已经做了临界区保护否则可能产生数据竞争二是中断里绝不能发生缓冲满后等待发送的逻辑。由于本文的简化实现丢弃新日志而不是阻塞等待所以从抢断安全上看相对保守。但还是建议普通场景避免在 ISR 里打日志ISR 只负责标记标志位由任务上下文输出事件内容这样排查起来更可控。9. PC 端日志采集与批量分析日志模块在板子上只是“发送”真正要高效复盘PC 端必须能批量接收和保存日志。串口助手可以满足最小查看需求但做批量分析时脚本化采集更可靠。Linux 下可以用 minicom 或 cat 直接读取串口设备# 查看串口设备例如 /dev/ttyUSB0 ls /dev/ttyUSB* # 使用 minicom 打开串口波特率 115200 minicom -D /dev/ttyUSB0 -b 115200Windows 下如果使用免安装的串口助手注意把波特率设置为 115200、数据位 8、停止位 1、无校验否则容易出现乱码。如果 PC 接上 USB 转串口后没有识别到端口要检查 CH340、CP2102 或 FTDI 驱动是否安装成功。Python 的 pyserial 适合做进一步处理。例如需要把日志保存到文件同时过滤 ERROR 和 WARN 级别import serial import datetime SERIAL_PORT COM3 # Windows 示例Linux 可写 /dev/ttyUSB0 BAUDRATE 115200 OUTPUT_FILE embedded_log.txt ser serial.Serial(SERIAL_PORT, BAUDRATE, timeout1) with open(OUTPUT_FILE, w, encodingutf-8) as f: while True: line ser.readline() if not line: continue text line.decode(utf-8, errorsignore).strip() timestamp datetime.datetime.now().strftime(%Y-%m-%d %H:%M:%S) f.write(f[{timestamp}] {text}\n) print(text) if [E] in text or [W] in text: print(f - 发现异常级别日志: {text})运行脚本时需要先安装 pyserialpip install pyserial采集到的日志文件如果达到了几百 KB 或几 MB不建议再用编辑器慢慢翻可以按关键词、时间段、级别做二次过滤。如果日志格式统一成[tick][level][tag] message用 grep、awk 或者 Python 正则都能快速筛选。比如 Linux 下统计 ERROR 出现次数grep -c \[E\] embedded_log.txt统计每个 tag 出现频次grep -o \[[a-zA-Z0-9_]*\] embedded_log.txt | sort | uniq -c | sort -nr这一步做完了基本上就能把串口日志系统当成“嵌入式版 ELK”的最小链路来用板端结构化输出日志PC 端批量保存本地脚本过滤数据。等数据量再大一些还可以考虑把日志文件导入 Loki、ELK 或自定义分析工具但那已经不是板端模块的职责了。10. 资源占用与性能观察方法日志系统最容易被忽略的是资源占用。一个只有功能逻辑但没有日志的项目编译出来的固件体积可能很小接入格式化日志后printf 系列函数会引入不小的代码量。因此在芯片选型时就要预估资源消耗。RAM 方面主要开销来自环形缓冲和单行格式化缓冲。上面示例里s_ring[1024]占用 1KBline[128]和head[32]在日志输出函数栈上额外占用 160 字节。如果芯片 RAM 不多可以把环形缓冲降到 512 字节单行缓冲区降到 96 字节前提是接受频繁的日志截断与更低吞吐。CPU 方面轮询发送的耗时可以直接按波特率估算。每字节约 10 个 bit115200 波特率下波特率每字节耗时每行 100 字节耗时115200约 86.8 us约 8.7 ms230400约 43.4 us约 4.3 ms921600约 10.9 us约 1.1 ms所以日志量每小时几百条、每条很短时随便选波特率都行。如果要打大量调试数据建议直接上 921600并启用 DMA 发送。否则 CPU 会被串口发送拖慢。观察 CPU 被日志影响最直接的办法是让某个 GPIO 在控制任务入口翻转电平用示波器或逻辑分析仪看波形。正常运行时分批执行接入大量日志后如果看到控制周期被拉长说明日志发送路径存在阻塞需要切换为异步发送。另一个办法是在eplat_log_service()里统计一秒钟实际发送了多少字节这个数字直接反映了日志负载。显存这类概念在这个话题里不适用但关注点其实是内存带宽和延迟。日志系统对 MCU 的实时性影响主要来自三块格式化开销、环形缓冲拷贝、串口逐字节发送。格式化可以用简化整数打印来减少开销缓冲拷贝不可避免但成本很低逐字节串口发送才是大头也是必须异步化的原因。11. 常见问题与排查方法
返回列表