做嵌入式开发这些年,我一直坚持一个观点:凡是打算在设备上跑超过一个月的 MicroPython 项目,日志模块从来就不是“锦上添花”,而是“保命工具”。很多朋友图省事,从开发到量产全程用print打日志,结果现场出问题后对着串口海量输出翻半天,那种体验我经历过太多次了。后来我专门写了一个轻量日志模块 uLogLite,支持日志级别控制、文件轮转、按标签过滤,代码量不大,但项目里能顶大用。这篇文章我就把整个模块的设计思路和核心代码掰开揉碎讲一遍,你照着敲一遍,就能直接搬到自己的项目里用。
1. 日志模块到底在解决什么问题
1.1 从 print 到日志:嵌入式排障的痛点
先说说我为什么对print意见这么大。原型阶段用print调试确实爽,插上串口线什么都看得见,但项目一旦复杂起来,问题全冒出来了。
第一个痛点是日志没有级别区分。调试信息、运行状态、错误信息全混在一起,串口一刷屏,你真正想找的关键错误早被淹没。我记得有个网关项目,WiFi 模块每 5 秒打印一条 RSSI,传感器模块每 100ms 打印一条采集值,整个串口输出跟瀑布一样,后来排查一个偶发的 MQTT 断连问题,只能靠人眼去刷屏里找线索,效率极低。
第二个痛点是缺少时间戳。设备在客户那儿跑了一天,回头问“昨天下午这台设备崩没崩过”,你拿不出任何时间维度的线索。没有时间戳的日志,只能证明某件事发生过,但完全没办法还原时序,排查问题等于少了一条腿。
第三个痛点是无法按模块屏蔽。嵌入式项目里模块太多了,传感器、网络、显示、存储、协议栈,每个模块都有自己的输出,但排障时往往只关心其中一个。没有过滤机制,你只能把所有日志拉下来慢慢筛。
第四个点比较隐蔽——输出目标单一。串口是调试阶段的好帮手,但到了现场部署,串口线不可能永远插着。真正要回溯历史问题,必须把日志落盘,存到文件系统里。print默认输出到 stdout,想重定向到一个文件,在 MicroPython 里做起来很别扭。
所以一个像样的日志模块,至少应该具备这些能力:有日志级别,能控制什么等级的消息可以输出;有时间戳,记录每条日志的发生时刻;有来源标签,标明日志是哪个模块打的;支持输出到文件和串口;能按级别和标签做过滤;当文件过大时支持轮转。这就是 uLogLite 设计时的需求清单。
1.2 级别、轮转、过滤:日志三大件的设计逻辑
先聊日志级别。级别本质上是一组从小到大排列的数字,代表消息的“严重程度”。uLogLite 沿用了 Python 官方 logging 的划分方式:
| 级别 | 数值 | 适用场景 |
|---|---|---|
| DEBUG | 10 | 调试细节,比如每次读取的原始值 |
| INFO | 20 | 关键状态变更,比如开机、连接成功 |
| WARNING | 30 | 异常但还能恢复,比如重试 |
| ERROR | 40 | 功能失效,但系统还能跑 |
| CRITICAL | 50 | 系统级故障,无法继续工作 |
用数字而不是字符串的好处是判断起来非常高效。比如 logger 的当前级别是 INFO(20),那么低于 20 的 DEBUG 日志直接丢弃,一句话if level < self.level: return就完事了。
再聊过滤。级别过滤只能控制严重程度,但解决不了“我只想看网络模块日志”这种需求,所以还需要标签过滤。我给 uLogLite 设计的过滤方式很简单:每条日志在写入时可以带一个标签(比如network、sensor),然后通过set_filter(include_tags=['network'])指定只输出哪些标签,或者用exclude_tags排除某些标签。实现上就是两次集合成员判断,代码不复杂,但实战价值极高。
最后是轮转。嵌入式设备的 flash 空间有限,日志文件不能无限增长。轮转的思路是:当当前日志文件超过设定大小(比如 64KB)时,把当前文件改名为app.log.1,然后新开一个app.log继续写;下一次再超限,就把app.log.1改名为app.log.2,原来的app.log变成app.log.1,新的app.log重新开始。这样磁盘上最多保留 N 份历史日志,空间可控。
2. uLogLite 接口设计与现实约束
2.1 API 设计:一个类还是散装函数
在动手写代码之前,我想先聊一下接口设计的取舍。有人喜欢用全局函数,比如log_info("xxx"),觉得调用方便。但我建议用类封装。原因是嵌入式项目里往往有多个独立的业务模块,如果只用一组全局函数,就只能共享一个全局配置,没法做到“这个模块写文件、那个模块只写串口”的精细控制。
uLogLite 的 API 设计非常简单,核心就一个类,几个方法。初始化时传入名称、级别、日志文件路径、轮转大小、备份份数等参数,之后调用debug、info、warning、error、critical就能写日志。每个方法还可以额外传一个tag参数,用来标记这条日志的来源模块。
有人可能会问,既然每个方法都能传标签,那name参数还有什么用?我的设计思路是:name是这个 logger 实例的默认标签,如果调用时不传tag,就用name代替。这样大多数日志只需要写log.info("sensor init ok"),只有需要细分的场景才显式传tag,代码不会显得啰嗦。
2.2 嵌入式约束:内存、文件系统与 flash 寿命
写 MicroPython 日志模块,绕不开三个现实约束。
第一个是内存。以 ESP32 为例,跑完 MicroPython 和 WiFi 协议栈之后,可用 RAM 非常紧张。所以模块实现必须克制:不要在每条日志里创建大量临时对象,不要在构造函数里贪心地做太多初始化,更要避免把整个日志文件读进内存来判断大小。
第二个是文件系统行为差异。MicroPython 的open不会自动创建父目录,如果/sd/log/这个目录不存在,直接open('/sd/log/app.log', 'a')必然抛 OSError。还有一点很关键:MicroPython 的os.stat()返回的是一个元组,文件大小是第 7 个元素,索引是 6,很多人第一次用都会拿错索引。
第三个是 flash 寿命。几乎所有 MicroPython 开发板的文件系统都跑在 SPI flash 上,而 flash 的擦写次数是有限的。如果设备每秒钟往日志文件里写几十条数据,日志分区几个月就可能磨损报废。所以我在实际项目中,量产阶段通常会把日志级别从 DEBUG 调到 WARNING,高频落盘改成仅串口输出,只有当需要排障时才临时开 INFO 级文件日志。这个习惯能让设备的寿命长很多。
2.3 为什么不直接移植 PC 端 logging 库
有人会问:MicroPython 官方不是提供了一个logging模块吗,直接import logging不就行了?确实,micropython-lib 里有一个 logging 库,如果你用的固件恰好预装了它,直接用也是一种选择。我有段时间也是这么干的,但后来遇到几个问题。
一是不是所有固件都预装了这个模块。换一块开发板、换一个固件版本,可能import logging直接就 ModuleNotFoundError,你还得想办法把库文件塞进去。二是官方 logging 模块的设计目标是兼容 CPython 的 logging API,功能多、层级多、Handler 概念也多,在资源受限环境下显得有点臃肿。三是它默认不提供文件轮转能力,想要轮转还得自己扩展 FileHandler,本质上还是要写代码。
所以我最后决定自己写一个 150 行左右的实现,只保留三样核心能力——级别、轮转、过滤。这样整个模块的行为完全可控,出问题也能快速定位。如果你只是临时用用,官方 logging 没问题;但如果你想要一个长期维护、行为稳定、完全看得懂的日志基础组件,我推荐自己写一个 uLogLite 这样的轻量实现。
3. 核心代码实现:uLogLite 逐段拆解
3.1 完整代码先给你
先上完整代码。这个文件我是按照 MicroPython 的语法和标准库写的,不依赖任何第三方库,在 ESP32、RP2040 等主流开发板上都能跑。你新建一个uloglite.py,把下面这段复制进去即可。
""" uLogLite - 轻量级 MicroPython 日志模块 支持日志级别、文件轮转、标签过滤 """ import os import time class LogLevel: DEBUG = 10 INFO = 20 WARNING = 30 ERROR = 40 CRITICAL = 50 class uLogLite: _LEVEL_NAMES = { LogLevel.DEBUG: 'DEBUG', LogLevel.INFO: 'INFO', LogLevel.WARNING: 'WARNING', LogLevel.ERROR: 'ERROR', LogLevel.CRITICAL: 'CRITICAL', } def __init__(self, name='app', level=LogLevel.INFO, log_file=None, max_size=64 * 1024, backup_count=2, fmt='[{time}] [{level}] [{name}] {msg}'): self.name = name self.level = level self.log_file = log_file self.max_size = max_size self.backup_count = max(2, backup_count) self.fmt = fmt self.include_tags = None self.exclude_tags = None self._fh = None if self.log_file: try: self._fh = open(self.log_file, 'a') self._check_rotation() except OSError as e: print('[uLogLite] open log file failed:', e) self._fh = None def _format_time(self): now = time.localtime() return '%04d-%02d-%02d %02d:%02d:%02d' % ( now[0], now[1], now[2], now[3], now[4], now[5]) def _write(self, level, tag, msg): if level < self.level: return if self.include_tags and tag not in self.include_tags: return if self.exclude_tags and tag in self.exclude_tags: return line = self.fmt.format( time=self._format_time(), level=self._LEVEL_NAMES.get(level, str(level)), name=tag, msg=msg, ) + '\n' if self._fh: self._check_rotation() self._fh.write(line) self._fh.flush() else: print(line, end='') def _check_rotation(self): if not self._fh or not self.log_file: return try: size = os.stat(self.log_file)[6] except OSError: return if size < self.max_size: return self._fh.close() self._fh = None for i in range(self.backup_count - 1, 0, -1): src = '%s.%d' % (self.log_file, i) dst = '%s.%d' % (self.log_file, i + 1) try: os.remove(dst) except OSError: pass try: os.rename(src, dst) except OSError: pass dst = self.log_file + '.1' try: os.remove(dst) except OSError: pass try: os.rename(self.log_file, dst) except OSError: pass self._fh = open(self.log_file, 'w') def debug(self, msg, tag=None): self._write(LogLevel.DEBUG, tag or self.name, msg) def info(self, msg, tag=None): self._write(LogLevel.INFO, tag or self.name, msg) def warning(self, msg, tag=None): self._write(LogLevel.WARNING, tag or self.name, msg) def error(self, msg, tag=None): self._write(LogLevel.ERROR, tag or self.name, msg) def critical(self, msg, tag=None): self._write(LogLevel.CRITICAL, tag or self.name, msg) def set_level(self, level): self.level = level def set_filter(self, include_tags=None, exclude_tags=None): self.include_tags = include_tags self.exclude_tags = exclude_tags def close(self): if self._fh: self._fh.close() self._fh = None3.2 日志级别与过滤判定:一行 if 里的大学问
整个模块最核心的逻辑集中在_write方法里,它做了三件事:级别判断、标签过滤、组装输出。
级别判断是性能第一道关卡。if level < self.level: return这行代码看似简单,但它保证了当日志级别不够时,后续的时间格式化、字符串拼接、文件写入全部不会执行。这在高频日志场景下很重要。比如调试阶段开了 DEBUG 级,但现场运行级别只开 INFO,那些每 100ms 打一条的 DEBUG 日志会被直接丢弃,不会产生任何 CPU 和时间开销。
标签过滤是第二道关卡。include_tags和exclude_tags的语义要理清楚:include_tags是白名单,设置了之后只有标签在列表里的日志才会输出;exclude_tags是黑名单,设置在列表里的标签一律不输出。如果两个都设置了,先判白名单再判黑名单。这个顺序我是有意安排的,因为白名单筛掉的比例通常更高,先过滤可以少做一些无效判断。
组装输出阶段有一个细节值得注意:我用的是_LEVEL_NAMES.get(level, str(level))而不是if-elif判断。原因很简单,用字典查表比一长串 if-else 更清晰,而且将来要扩展自定义级别,只需要往字典里加一项就行。时间格式化方面,MicroPython 的time.localtime()返回一个 8 元组,前六个元素分别是年、月、日、时、分、秒,所以索引 0 到 5 直接取出来格式化。我建议用百分号格式化,因为 MicroPython 对str.format的一些高级格式语法支持不完整,而%04d这种百分号格式化是兼容性最稳的。
3.3 日志轮转:先关文件再重命名
轮转是整个模块里最容易写错的地方。我来说说_check_rotation里的几个关键细节。
首先是获取文件大小。os.stat(self.log_file)[6]这个写法,很多人会疑惑为什么是索引 6。MicroPython 的os.stat()返回一个元组,模仿的是 CPython 的 stat_result,其中第 7 个元素是st_size,也就是索引 6。如果不确定,可以先print(os.stat(self.log_file))看一遍再索引。还有一点要注意,如果文件被删了或者路径有问题,os.stat会抛 OSError,所以这里必须用 try-except 包住。
其次是轮转的顺序。为什么先关闭文件句柄再重命名?因为文件打开状态下,不同文件系统对 rename 的处理不一样,有些 VFS 实现会直接拒绝重命名一个正在打开的文件,或者导致数据没刷入。保险起见,一律先close(),再操作文件。
然后是重命名链的逻辑。假设backup_count = 3,轮转时要保留app.log、app.log.1、app.log.2三份文件。具体操作是:先删除最老的app.log.3,然后把app.log.2改名为app.log.3,把app.log.1改名为app.log.2,最后把app.log改名为app.log.1,再新开一个app.log。代码里for i in range(self.backup_count - 1, 0, -1)正好是从 2 到 1 的逆序遍历,完成的是“从旧到新”的搬运。
这里有一个 MicroPython 特有的坑:CPython 在 Unix 系统上,os.rename在目标文件存在时是可以覆盖的,但 MicroPython 的底层 VFS 实现五花八门,很多移植版遇到目标文件已存在会直接抛 OSError。所以正确姿势是:先os.remove(dst),再os.rename(src, dst)。不要嫌这两步麻烦,这是我在实际项目里踩过坑之后总结出来的稳妥方案。
最后一个细节:轮转完成后,重新打开文件用的是'w'模式而不是'a'模式。因为新文件本来就是空的,用哪个都行,但'w'有“从零开始”的语义,更符合直觉。每次写日志之后调用flush()也非常重要,MicroPython 的缓冲区策略在不同平台表现不一致,如果不主动 flush,掉电的时候很可能丢日志。
3.4 对外 API 与动态配置:让日志模块“可调”
五个对外方法debug、info、warning、error、critical本质上都是_write的薄封装,区别只在于传入的级别常量不同。我选择一口气暴露五个而不是用一个log(level, msg),纯粹是为了调用时的可读性。你写log.info("boot ok")和写log.log(20, "boot ok"),一眼看过去前者清晰得多。
set_level和set_filter这两个动态配置方法,是我在实际项目中用得很频繁的。比如设备出厂前把级别定在 WARNING,到了现场出问题后,我远程发一条指令把级别动态降到 INFO,日志就开始记录更详细的信息了。这个能力在 PC 端 logging 里都有,但在嵌入式环境里,因为资源紧张,很多人会忘记加。我的建议是:日志模块必须支持运行期动态调整级别,否则每次调日志参数都要重新烧录固件,现场排障的效率会低到让你怀疑人生。
close方法虽然简单,但它在两个场景下是必须的:一是 OTA 升级前要关闭文件句柄,确保日志数据完整落盘;二是某些文件系统在卸载 SD 卡之前,必须把所有打开的句柄都关掉,否则会报错。所以即使是一个看起来“跑完就不用管”的日志模块,也一定要留一个清理出口。
4. 项目接入实操:从抄代码到会调参
4.1 初始化与基础使用
把uloglite.py放进项目的根目录或者在main.py里 import,然后按下面的方式初始化:
from uloglite import uLogLite, LogLevel log = uLogLite( name='gateway', level=LogLevel.DEBUG, log_file='/sd/log/app.log', max_size=32 * 1024, backup_count=3, ) log.info('system boot ok') log.debug('free memory: %d bytes' % gc.mem_free()) log.warning('rssi is low: %d' % rssi) log.error('mqtt connect failed, retry later')如果你的设备暂时不需要落盘,只想往串口输出,那log_file就传None。此时 uLogLite 会自动退化成print模式,不管有没有文件句柄都能正常工作。这个特性在调试阶段非常有价值:先在串口确认逻辑没问题,再切换成文件模式做长稳测试,代码一行都不用改。
初始化之后,log.info(...)会生成类似这样的一行日志:
[2025-03-18 10:24:31] [INFO] [gateway] system boot ok方括号里的四个字段分别对应模板里的time、level、name、msg。如果你对格式不满意,可以通过fmt参数自定义,比如想加上线程名、去掉时间、或者调整字段顺序,都在初始化时传一个fmt就行。注意fmt里的花括号占位符必须使用{time}、{level}、{name}、{msg}这四个名字,因为_write里就是用str.format把这四个变量替换进去的。
4.2 轮转参数怎么定:容量估算与场景选择
轮转参数要选择,核心是理解每份日志能装多少信息。max_size决定单份文件的最大体积,backup_count决定保留几份历史文件,整体占用的磁盘空间就是max_size * backup_count。
我以一个典型的电池供电采集终端为例:每分钟写一条 INFO 日志,每条日志约 120 字节,一小时 7200 字节,一天约 173KB。如果max_size设为 64KB,那么大约 9 个小时轮转一次;如果设成 128KB,就是 18 个小时轮转一次。backup_count = 3时,总日志空间为 384KB,大概能覆盖 2-3 天的完整日志记录。对大多数 4MB flash 的开发板来说,这个占用比例可以接受。
我个人的选择习惯是:调试阶段用 32KB 加backup_count=2,保证日志不占太多空间;量产阶段用 128KB 加backup_count=3,因为现场出问题时需要更长的历史记录来回放。如果你的设备有 SD 卡,空间比较宽裕,可以把max_size调大一点,减少轮转频率,降低 flash 擦写次数。
有一点必须提醒:max_size的单位是字节,不是 KB。有些朋友写max_size=1024以为代表 1MB,结果日志文件写到 1KB 就被轮转,排查半天才发现是单位搞错了。
4.3 多模块下的过滤实战
假设你做了一个多功能传感器网关,代码分成三块:传感器采集、网络通信、蓝牙广播。每个模块在打日志时传不同的标签,就可以在排障时非常精准地过滤。
log = uLogLite(name='main', level=LogLevel.INFO, log_file='/sd/log/app.log') # 传感器模块 log.info('temp read ok: %.2f' % temp, tag='sensor') # 网络模块 log.error('wifi reconnect fail', tag='network') # 蓝牙模块 log.debug('adv packet sent', tag='ble')正常运行时,你只想知道整体的健康状态,级别设为 INFO 就够了。某天用户反馈网络频繁断开,你想看网络模块到底发生了什么,直接执行:
log.set_level(LogLevel.DEBUG) log.set_filter(include_tags=['network'])此时只有当标签是network的日志才会输出,而且 DEBUG 级也放开了,网络模块的每个细节都会落盘。其他模块的日志全部静音,不会干扰你的排查。这个操作甚至可以通过 MQTT 远程下发指令来触发,我之前的网关就是这么干的——用户报障后,我在后台发一条消息,远程把日志过滤打开,过半小时再拉日志文件分析,问题基本都能定位。
5. 现场排障:uLogLite 常见问题实录
5.1 日志文件写不了
很多人第一次跑起来就发现日志文件是空的,或者文件根本没创建。最常见的原因有三个:路径目录不存在、文件系统只读、存储空间已满。
MicroPython 的open不会自动创建父目录,所以如果路径是/sd/log/app.log,你得先确认/sd/log/这个目录存在,不存在就用os.mkdir('/sd/log')创建。第二个常见问题是 SD 卡或 flash 分区挂载成了只读,这种情况在异常断电后比较常见,检查方式是os.getcwd()或直接尝试手动创建文件。第三个是空间满,日志写进去返回成功,但实际没落盘,这时os.statvfs('/')看一下剩余空间就知道了。
还有一个容易忽略的点:如果你改了代码之后重新运行,但日志的内容还是旧的,大概率是文件缓冲没刷。uLogLite 里面每次写完都会flush(),可以避免这个问题。如果你的代码里自己调用了open写文件,也记得要 flush。
5.2 轮转不生效或备份丢
轮转不生效,先说最经典的一个原因:os.stat(self.log_file)[6]取错索引。MicroPython 在部分版本里os.stat()返回的元素个数和顺序可能让你意外,但st_size基本都在索引 6。如果实在不确定,先用print(os.stat(self.log_file))打印出来看看。
第二个原因是打开了文件但没写入。uLogLite 只在写入前检查轮转,如果你初始化之后一条日志都没打过,文件大小一直为 0,当然永远不会轮转。这不算 bug,但确实容易让人误以为轮转坏了。
第三个原因比较隐蔽:轮转时os.rename的目标文件已经存在。我前文提过,MicroPython 的 VFS 实现里,rename 遇到目标已存在有时会抛异常。uLogLite 的代码里已经用try: os.remove(dst)做了容错,但如果你的代码是参考别人的实现、没有这一步,那轮转到你设置的备份份数上限后就会不断失败,备份文件永远只有最新的一份,老文件都被覆盖了。排查的时候看一眼文件列表,如果只有app.log和app.log.1,没有app.log.2,多半就是这个问题。
5.3 时间不对,日志全变 2000 年
MicroPython 开发板上电之后,time.localtime()返回的时间默认是固件编译时间或者某个固定的初始时间,不会是真实时间。如果设备没有 RTC 模块也没联网同步时间,日志里的时间戳会显示成 2000-01-01,非常扎眼。
解决方式分三种:带 WiFi 的模块可以在联网后通过网络时间协议同步一次系统时间;带外部 RTC 模块的可以在启动时读取 RTC 值并machine.RTC().datetime(...)写入系统 RTC;如果设备纯粹离线运行,那就得在代码里配置一个编译时基准时间,再自己维护一个运行时长累加器。不管用哪种方式,只要最终系统时间是对的,uLogLite 的时间戳就没问题了。
还有个小技巧:调试阶段如果不想开文件日志,又想确认时间同步成功了,可以先用print(log._format_time())看一眼当前时间,确认没问题再开文件日志,省得一打开文件就是一堆错误时间戳。
5.4 内存暴涨与文件句柄泄漏
有些用户反映日志模块跑一段时间之后设备变卡,甚至死机。这往往不是日志模块本身的问题,而是使用方式不当。
一种常见情况是每次写日志都拼接大字符串。比如在日志里打印整个传感器数据缓冲区,几百个字节的字符串反复拼接、传递,MicroPython 的垃圾回收跟不上,内存碎片就会越来越严重。我的建议是:日志内容尽量精简,临时的格式化字符串用完即弃,不要保存在长生命周期变量里。
另一种情况是打开了日志文件却不关闭。如果你的代码在循环里创建新的uLogLite实例,却不调用close(),文件句柄就会一直累积。MicroPython 的文件描述符数量有限,超过阈值后所有文件操作都会失败。正确做法是:整个应用只创建一个 logger 实例,全局复用;如果确实要重建,先调用旧实例的close()释放句柄。
最后提醒一个很多人忽略的问题:日志模块占用文件句柄期间,如果程序要做 OTA 升级或文件系统操作,最好先关闭日志,升级完成后再重新打开。否则在某些文件系统实现上,按住句柄会导致升级失败或者文件系统损坏。
我在实际项目里长期用下来,uLogLite 这套设计最让我省心的就是两点:一是轮转逻辑足够稳,日志文件永远不会超限;二是标签过滤太好用了,排障时只开自己关心的模块,其他噪音全部关掉。如果你在项目里也遇到过 print 日志刷屏、排查困难的情况,不妨花一两个小时把 uLogLite 移植过去,后面省下的时间绝对不止这一两个小时。至于还能怎么扩展,后面你可以按需加远程日志上报、崩溃栈自动抓取,都是在这个骨架上长出来的功能。