尧图精选

MicroPython日志模块uLogLite实战:级别、轮转与过滤

🕒 发布时间:2026/9/7 11:51:27 📁 来源:尧图网络
做嵌入式开发这些年我一直坚持一个观点凡是打算在设备上跑超过一个月的 MicroPython 项目日志模块从来就不是“锦上添花”而是“保命工具”。很多朋友图省事从开发到量产全程用print打日志结果现场出问题后对着串口海量输出翻半天那种体验我经历过太多次了。后来我专门写了一个轻量日志模块 uLogLite支持日志级别控制、文件轮转、按标签过滤代码量不大但项目里能顶大用。这篇文章我就把整个模块的设计思路和核心代码掰开揉碎讲一遍你照着敲一遍就能直接搬到自己的项目里用。1. 日志模块到底在解决什么问题1.1 从 print 到日志嵌入式排障的痛点先说说我为什么对print意见这么大。原型阶段用print调试确实爽插上串口线什么都看得见但项目一旦复杂起来问题全冒出来了。第一个痛点是日志没有级别区分。调试信息、运行状态、错误信息全混在一起串口一刷屏你真正想找的关键错误早被淹没。我记得有个网关项目WiFi 模块每 5 秒打印一条 RSSI传感器模块每 100ms 打印一条采集值整个串口输出跟瀑布一样后来排查一个偶发的 MQTT 断连问题只能靠人眼去刷屏里找线索效率极低。第二个痛点是缺少时间戳。设备在客户那儿跑了一天回头问“昨天下午这台设备崩没崩过”你拿不出任何时间维度的线索。没有时间戳的日志只能证明某件事发生过但完全没办法还原时序排查问题等于少了一条腿。第三个痛点是无法按模块屏蔽。嵌入式项目里模块太多了传感器、网络、显示、存储、协议栈每个模块都有自己的输出但排障时往往只关心其中一个。没有过滤机制你只能把所有日志拉下来慢慢筛。第四个点比较隐蔽——输出目标单一。串口是调试阶段的好帮手但到了现场部署串口线不可能永远插着。真正要回溯历史问题必须把日志落盘存到文件系统里。print默认输出到 stdout想重定向到一个文件在 MicroPython 里做起来很别扭。所以一个像样的日志模块至少应该具备这些能力有日志级别能控制什么等级的消息可以输出有时间戳记录每条日志的发生时刻有来源标签标明日志是哪个模块打的支持输出到文件和串口能按级别和标签做过滤当文件过大时支持轮转。这就是 uLogLite 设计时的需求清单。1.2 级别、轮转、过滤日志三大件的设计逻辑先聊日志级别。级别本质上是一组从小到大排列的数字代表消息的“严重程度”。uLogLite 沿用了 Python 官方 logging 的划分方式级别数值适用场景DEBUG10调试细节比如每次读取的原始值INFO20关键状态变更比如开机、连接成功WARNING30异常但还能恢复比如重试ERROR40功能失效但系统还能跑CRITICAL50系统级故障无法继续工作用数字而不是字符串的好处是判断起来非常高效。比如 logger 的当前级别是 INFO20那么低于 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, nameapp, levelLogLevel.INFO, log_fileNone, max_size64 * 1024, backup_count2, 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( timeself._format_time(), levelself._LEVEL_NAMES.get(level, str(level)), nametag, msgmsg, ) \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, tagNone): self._write(LogLevel.DEBUG, tag or self.name, msg) def info(self, msg, tagNone): self._write(LogLevel.INFO, tag or self.name, msg) def warning(self, msg, tagNone): self._write(LogLevel.WARNING, tag or self.name, msg) def error(self, msg, tagNone): self._write(LogLevel.ERROR, tag or self.name, msg) def critical(self, msg, tagNone): self._write(LogLevel.CRITICAL, tag or self.name, msg) def set_level(self, level): self.level level def set_filter(self, include_tagsNone, exclude_tagsNone): 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( namegateway, levelLogLevel.DEBUG, log_file/sd/log/app.log, max_size32 * 1024, backup_count3, ) 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_count2保证日志不占太多空间量产阶段用 128KB 加backup_count3因为现场出问题时需要更长的历史记录来回放。如果你的设备有 SD 卡空间比较宽裕可以把max_size调大一点减少轮转频率降低 flash 擦写次数。有一点必须提醒max_size的单位是字节不是 KB。有些朋友写max_size1024以为代表 1MB结果日志文件写到 1KB 就被轮转排查半天才发现是单位搞错了。4.3 多模块下的过滤实战假设你做了一个多功能传感器网关代码分成三块传感器采集、网络通信、蓝牙广播。每个模块在打日志时传不同的标签就可以在排障时非常精准地过滤。log uLogLite(namemain, levelLogLevel.INFO, log_file/sd/log/app.log) # 传感器模块 log.info(temp read ok: %.2f % temp, tagsensor) # 网络模块 log.error(wifi reconnect fail, tagnetwork) # 蓝牙模块 log.debug(adv packet sent, tagble)正常运行时你只想知道整体的健康状态级别设为 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 移植过去后面省下的时间绝对不止这一两个小时。至于还能怎么扩展后面你可以按需加远程日志上报、崩溃栈自动抓取都是在这个骨架上长出来的功能。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →