上周调试一个新的环境监测节点,凌晨一点半被一条异常日志折腾得够呛。目标板是 ESP32-C3,程序跑了一整天,Wi-Fi 偶尔断连但能重连,问题是过了几个小时之后传感器读数突然全部变成None。因为当初图省事,全项目到处是print(),重启后串口历史早就冲没了,根本分不清是哪一步出的问题。从那次之后我彻底明白了一件事:MicroPython 项目只要超过几千行或者挂了网络,就必须有一个正经的日志模块,而 uLogLite 这样的轻量级实现,才是 MCU 上真正能用、够用、不占内存的方案。
uLogLite 这个项目的核心就三个词:级别、轮转、过滤。级别让日志从“全量输出”变成“按需输出”,轮转让长期运行的设备不会把 flash 写爆,过滤让真正关注的信息在满屏输出里不被淹没。这篇文章把我实现同类模块的完整思路、代码细节、实测数据和踩坑记录都过一遍,适合那些已经开始用 MicroPython 做正经产品原型、或者想从print()调试升级到文件日志的朋友参考。
1. 先回答“为什么”:MicroPython 环境里日志问题被低估了
在 PC 上写 Python,日志直接logging模块一把梭,没人觉得这是个问题。到了 MicroPython 上情况完全不一样:解释器本身是精简过的,标准库凑不齐 CPython 的完整 API,内存往往只有几十到几百 KB,flash 擦写次数还有寿命限制。所以很多人的选择就是回到最原始的print(),靠 REPL 去盯现场。
1.1 从 print 到日志模块的痛点变化
print()的局限在短期调试时并不明显,一旦程序进入“长时间无人值守”的状态,问题就全出来了:
- 没有持久化能力。串口一断、板子一重启,历史输出全丢,排查偶发问题只能靠运气。
- 没有优先级概念。调试代码一旦写完忘记删,正常运行时整个串口全被 DEBUG 信息刷屏,真正有用的 ERROR 被淹没在几百行输出里。
- 没有文件管理思路。有人会把
print()的结果重定向到一个文件里,但缺少大小控制,几天下来 flash 就被写满了。flash 的擦写寿命本来就有限,长期高频写入一块固定区域,板子可能比预期更早报废。
1.2 三个核心需求的真实来源
关于“级别、轮转、过滤”这三个能力,不是凭空想出来的,是被实际场景逼出来的。
先说级别。大多数真实 MicroPython 项目,分为开发态和运行态。开发态需要巨量调试细节,比如每次 I2C 读取的原始字节;运行态只关心连接失败、传感器超时、看门狗复位这类异常事件。级别机制让同一份代码可以不做任何修改,通过一个配置字切换输出粒度。
再说轮转。MCU 上的文件系统不是 SSD,而是 SPI Flash 或嵌入式 flash,普遍存在擦写寿命限制,某一块固定区域如果反复擦写,坏块会提前出现。日志文件如果不限制大小,长期运行必然撑爆存储;而轮转就是“日志文件的滚动替换”,超过阈值自动归档、保留最近 N 份、最老的一份自动删除。
最后说过滤。嵌入式现场环境噪音非常大:Wi-Fi 扫描结果、传感器偶尔超时重试、RTC 校准的状态变化……这些消息单看每一条都有价值,但堆在一起会掩盖真正致命的错误。过滤机制不是简单按级别一刀切,还应该支持“只看某个模块的日志”“只搜包含某个关键词的日志”,打个比方就是:能从几百条信息里精准捞到那条“救命的”。
1.3 为什么不直接搬 CPython logging
很多人第一反应是 MicroPython 自带logging模块,直接用不行吗?
MicroPython 的确提供了一个logging的简化版 API,在 ESP32 固件上可以 import 到,基本接口Level、Logger、Handler都有。但我在实际使用中发现几个麻烦:
- 原生模块本身有内存/Flash占用,对几十 KB 内存的小板子不够友好;
- 扩展 Handler 的逻辑比较重,要自定义一个写入 flash 文件的 Handler,需要理解框架的生命周期,对于只想快速解决问题的开发者来说,学习成本偏大;
- 文件轮转和过滤不是默认能力,还得自己写。
与其在框架上做二次开发,不如用几十行代码自己实现一个足够精简、完全可控的小模块,这也是 uLogLite 这类“造轮子”方案的价值:核心逻辑透明、资源占用可预期、调试方便。做一个项目,工具越是自己能掌控的,越不容易在关键时刻给开发者“惊喜”。
2. 动手前的设计:uLogLite 拆成哪几块
写代码前,我先梳理了核心设计原则,避免写完一团乱麻。
2.1 最小功能集划定
uLogLite 的最少需求如下:
| 功能 | 说明 |
|---|---|
| 日志级别 | DEBUG / INFO / WARNING / ERROR / CRITICAL,支持全局级别阈值 |
| 日志格式 | 可配置,包含时间、级别、标签、消息 |
| 输出目的地 | 可选:仅串口 / 仅文件 / 同时输出 |
| 文件轮转 | 按大小阈值自动滚动,保留最近 N 个备份 |
| 过滤 | 支持按级别过滤、按标签过滤、按内容正则过滤 |
这里有一个取舍:要不要引入时间戳。很多 MicroPython 板子有 RTC,但要拿到完整的年月日时分秒需要调用time.localtime(),每一次调用都会产生一些开销。实测下来,开时间戳与不开时间戳的性能差距大约在几十微秒级别,对于大多数非超高频日志场景完全可接受。但它也会让输出长度变化,给格式化带来负担。我的设计是把格式做成可配置模板,默认输出[LEVEL] [tag] message,用户按需决定是否加入时间。
2.2 单体类还是可插拔组件
考虑到 MicroPython 的内存限制,我最终选择了单体类设计,而不是 CPython logging 那种 Handler/Formatter/Filter 的组件式架构。理由很现实:
- 组件式架构需要维护多个对象之间的关系,对象数量越多,内存占用越高;
- MicroPython 的特点是“解释执行 + 动态对象”,上百个对象在堆上存活,GC 压力非常大;
- 一个小模块做成单体类,接口简单,方法直接,使用起来基本是“初始化一次,到处调用”。
如果你后续要支持“日志写到 UART 的同时也写到文件”,再在类内部加一个_output_dest分支就行,不需要拆出两个 Handler。
2.3 常量与可配置项的拆分
MicroPython 里有一个很有用的micropython.const(),这个函数能把变量定义成编译期常量,减少运行时对象占用。我建议把所有级别定义都包一层const()。另外,模块的默认参数(文件路径、轮转阈值、备份数量)我选择放在初始化方法里,而不是模块顶部写死,这样不同组件可以用不同配置初始化各自的 logger 实例。
下面是我在项目中实际使用的模块骨架,去掉了一些无关业务代码,保留核心结构供参考:
# uloglite.py # MicroPython + ESP32 上实测可用,内存占用约 1KB 左右 import time import os from micropython import const _DEBUG = const(0) _INFO = const(1) _WARNING = const(2) _ERROR = const(3) _CRITICAL = const(4) _LEVEL_NAMES = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") class ULogLite: def __init__(self, tag="app", level=_INFO, filename=None, max_bytes=32 * 1024, backup_count=2, fmt="[{lvl}] [{tag}] {msg}"): self.tag = tag self.level = level self.filename = filename self.max_bytes = max_bytes self.backup_count = backup_count self.fmt = fmt self._filter_tags = None # 白名单,None 表示不过滤 self._filter_text = None # 关键词过滤 self._filter_regex = None # 正则过滤 self._stream = None self._check_counter = 0这个类在__init__时不立即打开文件流,而是延迟到第一次写入时打开。这样做的原因是:很多 MicroPython 设备的文件系统挂载时序不固定,太早在导入时打开文件可能失败。
有了骨架之后,接下来实现每个具体功能。
3. 日志级别与格式化输出:先让每一条消息有身份
级别设计是整个日志系统的基础,但实现起来也很直接:就是一个整数比较。复杂度主要出现在级别定义、过滤和格式化这三个环节如何配合。
3.1 级别定义的实现
我使用_DEBUG = const(0)的方式,而不是像 CPython 那样用枚举类。原因只有一个:MicroPython 的 enum 支持有限,const()常量在编译后不会生成多余对象,内存更省。级别值用整数,比较逻辑跑起来也更快。
对外暴露的接口是这样一组方法:
def debug(self, msg, *args): self._log(_DEBUG, msg, args) def info(self, msg, *args): self._log(_INFO, msg, args) def warning(self, msg, *args): self._log(_WARNING, msg, args) def error(self, msg, *args): self._log(_ERROR, msg, args) def critical(self, msg, *args): self._log(_CRITICAL, msg, args)所有入口汇聚到一个_log(),这样级别比较、过滤、格式化和输出只需要在一个地方处理,后续加字段也好扩展。
3.2 格式化的细节:别让字符串拼接成为性能瓶颈
MicroPython 的字符串格式化使用%还是format()?在资源受限环境,我更倾向于%。实测下来,%格式化的执行速度在小字符串场景明显更快,而且更省内存。原因在于%的实现直接在底层做类型转换和拼接,str.format()需要解析格式字符串和构造中间对象,开销更大。
消息模板默认是[{lvl}] [{tag}] {msg},但我在代码里实现的时候用的是%风格的占位:
def _format(self, level, msg): lvl_name = _LEVEL_NAMES[level] if self.fmt: try: return self.fmt.format( lvl=lvl_name, tag=self.tag, msg=msg) except (IndexError, KeyError): pass return "[%s] [%s] %s" % (lvl_name, self.tag, msg)注意这里保留了format()调用作为兼容层,但如果你确定模板就是默认格式,可以完全改成%,跑得更快。我在实际项目里就是直接把self.fmt那一段删了,只保留了%那行。
3.3 过滤前置:不要在进入格式化前浪费 CPU
很多日志模块是“先格式化再过滤”,这是错误的顺序。如果级别不够,消息内容连字符串都不用拼,直接返回。uLogLite 的_log第一步就是级别判断:
def _log(self, level, msg, args): if level < self.level: return if args: try: msg = msg % args except (TypeError, ValueError): pass if self._filter_tags: if self.tag not in self._filter_tags: return if self._filter_text and self._filter_text not in msg: return if self._filter_regex is not None: import ure if not ure.search(self._filter_regex, msg): return # 到这里才做格式化 line = self._format(level, msg) self._output(line)这个顺序在“全项目日志级别设为 ERROR”时效果最明显:每条 debug 记录只需要一次整数比较就返回,字符串拼接和文件写入统统不执行。对于频繁进入中断或高频采集循环的项目,这个优化能节省大量 CPU。
3.4 输出目的地:文件与串口并存
MicroPython 里最简单的文件写入方式就是:
def _output(self, line): if self.filename is None: print(line) return try: if self._stream is None: self._stream = open(self.filename, "a") self._stream.write(line + "\n") # 控制检查频率,减少 os.stat 开销 self._check_counter += 1 if self._check_counter >= 10: self._check_counter = 0 self._rotate_if_needed() except OSError: print(line)这里有两个关键细节:一是文件流延迟打开,二是不是每写一条都检查文件大小。因为os.stat()本身有系统调用开销,在日志高频场景下每一条都查会拖慢采集循环。我设置成每写 10 条检查一次,既保证轮转及时性,又把开销控制在可接受范围。
另外注意:文件模式要选择"a"追加写,而不是"w"覆盖写。否则板子意外重启后,之前的历史日志全部丢失。追加模式下文件指针自动定位到末尾,这是轮转日志能持续记录的前提。
4. 文件轮转的实现:处理不好会毁掉 flash 的那部分
日志轮转的算法本身不复杂,难的是和 MicroPython 文件系统的特性配合好。
4.1 轮转策略选择:按大小还是按时间
我最终选择了按大小轮转。原因很简单:
- 按时间轮转(比如每天一个文件)在断电路时会出现问题。设备可能连续运行 30 天后断电重启,时间基准在 RTC 没有电池备用时不可靠;
- 按大小轮转是纯粹的“当前文件写到多少字节”判断,不依赖系统时间,长期无人值守时表现更稳定。
大小阈值可以根据 flash 容量和日志量级来配置。比如一块 2MB 的 flash,系统占用后剩余约 400KB 空间,那么日志模块可以设置每份文件 64KB,保留 3 份备份,总计占用 192KB 左右,留出余量给配置和临时文件。这个比例是我常用的一组数值。
4.2 轮转算法的微调与实现
传统 PC 上的轮转逻辑是“把旧文件依次改名,新文件顶上”。比如当前文件名是app.log,轮转时先删除最老的app.log.2,再把app.log.1改名为app.log.2,把app.log.0改名为app.log.1,最终app.log改名为app.log.0,并创建一个新的app.log。
这套逻辑在 CPython 上没问题,但 MicroPython 的os.rename()行为有所不同:目标文件存在时,部分固件实现会直接覆盖,部分会报错。为了避免平台差异,我在改名之前先尝试删除目标文件,用try-except OSError吞掉不存在的情形:
def _rotate_if_needed(self): if self._stream is None: return try: size = os.stat(self.filename)[6] except OSError: size = 0 if size < self.max_bytes: return # 真正执行轮转 try: self._stream.flush() self._stream.close() self._stream = None except OSError: pass for i in range(self.backup_count - 1, 0, -1): src = "%s.%d" % (self.filename, i - 1) dst = "%s.%d" % (self.filename, i) try: os.remove(dst) except OSError: pass try: os.rename(src, dst) except OSError: pass # 后备名 .0,如果存在则移除再改名 try: os.remove(self.filename + ".0") except OSError: pass try: os.rename(self.filename, self.filename + ".0") except OSError: pass # 重新以写模式创建新文件 self._stream = open(self.filename, "w")我在测试中发现一个细节:os.stat()返回的是一个元组,文件大小在第 6 个索引位置(st_size)。有些 MicroPython 版本支持os.path.getsize(),但在资源受限的固件里接口不统一,所以用os.stat()是最通用的做法。
4.3 缓冲策略:不要频繁 open/close
刚开始写的时候,我试过“每写一条日志就 close,然后再 open”。理由是想确保文件内容及时落盘,防止突然断电丢数据。但测试结果非常不理想:每一次 open/close 都会带来文件系统开销和 flash 块擦写,日志一多,CPU 全耗在文件打开关闭上了。
后来我改成“打开后持续写入,只在轮转时关闭”。代价是断电时会丢失最后几条未落盘的日志,但对大多数监控场景来说,丢最后几条远比重启后完全无日志更好。如果你对可靠性要求非常高,可以在_output里加一个固定间隔的flush(),比如每写 20 条刷一次:
if self._check_counter % 20 == 0: try: self._stream.flush() except OSError: passflush 的代价明显低于 open/close,且能显著减少断电丢数据的范围。这是我综合权衡之后推荐的方案。
5. 过滤机制:在高噪音项目里只保留你关心的日志
过滤做得好不好,直接决定日志系统在实际项目里是“救命工具”还是“另一堆噪音”。uLogLite 的过滤分成三层:级别过滤、标签过滤、内容过滤。
5.1 级别过滤是省资源的基石
级别过滤是一个简单的整数比较。性能最快,应该作为所有过滤的第一道检查。这里有个容易被忽略的坑:级别值的大小关系和实际语义要一致。如果定义DEBUG=0, INFO=1, WARNING=2, ERROR=3, CRITICAL=4,那么level < self.level就意味着比阈值更低的级别全部丢弃。如果你习惯反着定义,一旦忘记比较符号,过滤行为会完全反过来,排查起来非常痛苦。
5.2 标签/模块过滤:白名单和黑名单
级别过滤解决的是“全局粗细”问题,但实际项目经常需要“局部放大”。比如只关心网络模块的日志,或者只想忽略传感器模块的调试噪音。
在 uLogLite 里,每个实例创建时可以指定一个tag:
net_log = ULogLite(tag="wifi", level=_INFO, filename="net.log") sensor_log = ULogLite(tag="sensor", level=_WARNING, filename="sensor.log")这样已经能实现基本的分文件输出。但如果希望一个 logger 实例处理多个标签,就需要用_filter_tags做白名单:
log = ULogLite(tag="app") log.set_tag_filter(["wifi", "ble"]) # 只保留 wifi 和 ble 模块的日志def set_tag_filter(self, tags): self._filter_tags = set(tags) def clear_tag_filter(self): self._filter_tags = None注意这里用set()集合。MicroPython 支持set,比较操作是哈希查找,在标签数量多时比列表线性遍历快得多。当然,如果标签数量少于 5 个,用列表也可以,因为创建set本身也有内存开销。
5.3 内容关键词过滤与正则过滤
除了按标签,真实场景里还需要“只搜包含某个关键词的日志”。例如设备偶发 Wi-Fi 断连,想抓一下断开前后 5 秒的所有相关日志:
log.set_text_filter("reconnect")这个实现就是简单的if self._filter_text not in msg,性能开销很小。如果你需要更复杂的模式匹配(比如同时匹配“Disconnect或Timeout”),可以用ure:
def set_regex_filter(self, pattern): import ure self._filter_regex = ure.compile(pattern) def clear_regex_filter(self): self._filter_regex = None不过在嵌入式设备上,正则引擎运行是纯 CPU 开销,且ure可能会创建编译后的模式对象,内存占用也不小。我建议把正则过滤当作“调试期的临时手段”,正常运行期最好关闭,或用多个白名单关键词近似替代。
5.4 运行时动态切换:一个实用技巧
过滤规则最有价值的使用方式是在运行中动态切换,而不是编译时写死。
我在项目中留了一个远程控制接口:当串口收到特定命令log level=error时,直接调用log.set_level(_ERROR);当需要临时排查时,通过log.set_level(_DEBUG)打开全量日志。因为过滤逻辑都在_log()内部判断,规则切换不需要重启设备,也不需要改动其他业务代码。
这个功能对“抓偶发 Bug”帮助极大。日志系统不是一层不变的配置,而是你远程排查问题的“仪表盘”,随时调灵敏度才是它的正确用法。
6. 集成与实测:在真实项目里跑起来的效果和参数
光说设计不够,我把 uLogLite 放到了 ESP32-S3 开发板 + MicroPython 1.21 的环境下实测了一轮,记录了一些关键数据。
6.1 实测内存和性能数据
| 指标 | 数据 |
|---|---|
| 模块加载后额外内存占用 | 约 1.1 KB(含类定义和对象状态) |
| 单条日志格式化耗时 | 约 120–180 µs(默认格式,不带时间戳) |
| 单条日志写入文件耗时 | 约 0.5–1.2 ms(含文件系统调用) |
| 每 10 条检查一次文件大小 | 单次检查耗时约 30–60 µs |
| 内存峰值增长 | 日志长文本场景下约 2–3 KB(GC 前) |
在 240MHz 主频下,如果项目每秒钟产生 20 条日志,日志模块占用的 CPU 比例大约是 2%–5%,对大多数传感器采集和网络上报业务完全可接受。但如果日志频率达到每秒 200 条以上,文件写入会成为明显的性能瓶颈,这时候就需要考虑二次缓冲或者降低日志频率。
6.2 集成到项目的标准姿势
把uloglite.py放在设备文件系统根目录或lib/目录后,在main.py里初始化一次:
# main.py from uloglite import ULogLite, _INFO log = ULogLite( tag="main", level=_INFO, filename="app.log", max_bytes=64 * 1024, backup_count=3, ) def main(): log.info("system boot, heap free=%d", __import__("gc").mem_free()) # 业务逻辑...如果要让其他模块也使用同一个日志实例,可以建立一个log.py模块,单例导出:
# log.py from uloglite import ULogLite, _INFO _log = ULogLite(tag="app", level=_INFO, filename="app.log", max_bytes=64*1024, backup_count=3) def get_logger(): return _log这样所有模块都from log import get_logger,保证全局只有一个日志实例和一个打开的文件流,避免多个文件流同时写同一文件导致写坏。
6.3 运行几小时后的输出形态
实际跑出来的日志文件大概长这样:
[INFO] [main] system boot, heap free=164520 [INFO] [wifi] connecting to AP... [INFO] [wifi] connected, ip=192.168.1.23 [DEBUG] [sensor] read temp: 25.123, hum: 60.5 [WARNING] [sensor] read timeout, retry=1 [ERROR] [main] task watchdog triggered, stack left=42等到app.log写满 64KB,自动变成app.log.0,新文件继续写。轮转两次后,app.log.0变成app.log.1,最老的数据最终自动删除。整个过程不需要人工干预,也不会撑爆 flash。
7. 我用 uLogLite 踩过的几个坑,提前帮你避雷
任何日志模块实现过程中都会遇到一些预料之外的问题。下面这几个坑我实际踩过,写出来帮你绕开。
7.1 轮转时文件改名顺序搞反
我第一版轮转代码是正序改名的,也就是从.0开始往.1改。结果文件内容互相覆盖,最后所有备份内容都一样。正确的顺序必须是从最老的备份开始往前改:先把.1删掉,然后把.0改成.1,最后把当前文件改成.0。原因是后面的文件要先腾出位置,否则前面的改名会覆盖掉还没备份的内容。这个顺序问题在日志轮转里是最经典的坑,没有之一。
7.2 频繁 open/close 会显著减少 flash 寿命
前面提过,我第一个版本是“每次写日志都重新 open 文件”,当时只是觉得效率低,没想到真正严重的是 flash 寿命。Flash 擦写次数通常在 10 万次左右,如果每秒钟打开关闭文件 10 次,一年就能磨掉几十万次擦写,板子基本必坏。改成“打开后持续写、轮转时才关闭”之后,一条日志对应一次 flash 追加写入,擦写次数大幅下降。如果要远程部署,务必使用这种模式。
7.3 MicroPython 的域名解析不是阻塞的,但文件系统可能是阻塞的
很多测友会忽略文件系统的阻塞特性。在并发或中断环境里,如果在中断回调里写日志,文件系统的系统调用可能引发OSError或长阻塞,直接影响实时性。
我在代码里专门把文件写入放到主循环或低优先级任务,不让它进入高优先级中断。如果业务中断必须记录时间点,我会用一个环形缓冲区暂存,等主循环空闲时再统一写入文件。uLogLite 本身不支持异步缓冲,但这种“先存内存、后落盘”的思路完全兼容,你可以在外部做一层。
7.4 文件系统未挂载时导入模块会挂
开局说过,__init__不立即打开文件,但有些开发者会在模块导入时就直接生成文件路径并open()。如果设备启动时 flash 文件系统还没挂载好,程序会在导入阶段直接崩溃。解决方法是把文件打开逻辑延迟到第一次写入,或者给ULogLite增加一个ensure_open()方法,由调用方在确认文件系统就绪后主动触发。这个细节在开发板上不一定复现,但在电池供电、SD 卡启动慢、或者外部 flash 挂载的板子上非常关键。
7.5 字符集问题:中文日志要不要用
MicroPython 的固件默认 UTF-8,理论上支持中文字符串。但文件系统在写入和读取时会把每个汉字当多个字节处理,日志文件大小计算会不一样,轮转阈值max_bytes可能比预期更早触发。另外,在串口终端里看中文日志如果编码不匹配,很容易乱码,排查问题反而更费力。我的建议是项目内部日志统一用英文或拼音缩写,最终对外展示再做国际化处理。
8. 后续扩展:从本机文件到远端日志
uLogLite 满足了本地日志的基本需求,但项目长期运行后,你可能希望把日志同步到 PC 或者云平台。这一步是很多“打印日志流”项目做不到的。
8.1 基于 UART 的日志导出
最简单的方式是提供一个独立方法,在调试端口输出当前日志文件的末尾内容:
def dump_tail(self, lines=50): if self.filename is None: return try: with open(self.filename, "r") as f: data = f.readlines() for line in data[-lines:]: print(line.rstrip()) except OSError: pass加上这个接口之后,你可以随时在 REPL 里调用log.dump_tail(20)查看最近 20 条日志,不必下载整个文件。
8.2 基于网络的上传模块
还有更进阶的玩法:写一个网络上传函数,把轮转生成的备份文件通过 HTTP POST 或 MQTT 推到服务端。关键点是不要在日志类内部直接写网络代码,而是把“日志文件路径列表”暴露给上层,由上层定期检查和处理。
常见做法是:
# 定时任务里执行 def upload_logs(): for name in ["app.log", "app.log.0", "app.log.1"]: try: data = open(name).read() except OSError: continue if data: mqtt_client.publish("device/logs", data) os.remove(name) # 上传成功再删本地,留有缓冲这个思路的好处是日志模块职责单一,网络模块沾不上文件系统的事,耦合低,出问题好排查。
日志系统这种东西,平时看起来不起眼,一旦真的遇到周期性 Bug、断电后无法复现、或者设备在客户那边跑三个月突然不响应,它就是唯一可靠的“现场目击证人”。uLogLite 用尽量少的代码把级别、轮转、过滤这几件核心事情做好,剩下的集成细节,文章里的代码和参数基本可以直接抄作业。按照你自己的业务调整一下max_bytes、backup_count和过滤规则,它就能在大多数 MicroPython 项目里稳定地帮你盯着现场。