CircuitPython数据记录实战:通过boot.py与storage模块实现微控制器文件写入
1. 项目概述为什么需要让微控制器自己写文件玩过CircuitPython的朋友都知道当你把一块兼容CircuitPython的开发板比如Adafruit的Feather M4、ItsyBitsy或者Circuit Playground Express插上电脑它会立刻弹出一个名为CIRCUITPY的U盘。这个设计简直太方便了你可以像编辑普通文本文件一样直接在电脑上修改code.py保存后板子自动重启运行新代码开发体验流畅得不像嵌入式。但不知道你有没有冒出过这样一个念头既然这个CIRCUITPY盘看起来就是个普通的U盘那我的CircuitPython程序能不能像电脑上的Python脚本一样直接往里面写文件呢比如我想做个温度记录仪每隔一分钟把CPU温度或者传感器读数存到一个log.txt里。直觉上这似乎理所当然但实际操作过你就会发现事情没那么简单——默认情况下你的CircuitPython程序是没法往CIRCUITPY盘里写东西的。这个限制背后其实是一个非常重要的安全设计。想象一下如果电脑作为USB主机和板子上的微控制器作为USB设备同时都能往同一个U盘里写数据那简直就是文件系统的灾难现场。两边同时修改同一个文件或者一个在写文件时另一个在更新文件分配表分分钟就会导致数据损坏、文件丢失甚至整个存储分区挂掉。为了避免这种“两头写”的混乱局面CircuitPython做了一个清晰的权责划分要么电脑能写要么CircuitPython能写二者不可得兼。默认状态下CIRCUITPY盘的读写权限是交给电脑的这就是为什么你能自由地编辑代码。而当我们想让CircuitPython程序接管写入权限进行数据记录时就需要一个“开关”来切换状态。这个“开关”就是本文要深入探讨的storage模块和那个神奇的boot.py文件。通过它们我们可以实现一个非常实用的功能让开发板在脱离电脑、独立运行时能够将传感器数据、系统日志等写入内置存储形成一个完整的数据记录器Data Logger。这对于物联网节点、环境监测站、设备运行状态记录等需要离线、长期采集数据的场景是必不可少的一环。2. 核心机制解析boot.py与storage.remount()要让CircuitPython获得文件系统的写入权限核心操作发生在启动的最初阶段这就要用到boot.py。理解boot.py和常规的code.py之间的区别是掌握整个技术的关键。2.1 boot.py vs. code.py启动顺序的奥秘在CircuitPython的根目录下你通常会看到两个主要的Python文件code.py和boot.py。它们虽然都是Python脚本但执行时机和目的截然不同。code.py这是你的主程序文件。当CircuitPython完成核心系统初始化、硬件驱动加载后才会去执行code.py。我们日常的编程逻辑比如读取传感器、控制LED、连接网络都写在这里。当你通过串口控制台按CtrlD进行软复位soft reset时系统会重新执行code.py但不会重新执行boot.py。boot.py这是一个特殊的启动脚本。它的执行时机要早得多在CircuitPython内核启动之后、挂载文件系统之前。这意味着boot.py中的代码有能力去影响文件系统如何被挂载包括设置它的读写权限。boot.py只在两种情况下运行硬件复位Hard Reset或重新上电Power Cycle。简单地保存文件或通过REPL进行软复位都不会触发boot.py。这种设计非常巧妙。它把系统级的、一次性的配置比如文件系统权限和应用程序级的、可反复运行的逻辑分开了。你可以把boot.py看作是系统的“启动配置项”。2.2 storage.remount()权限切换的钥匙storage模块是CircuitPython中用于管理内部存储的核心。其中最关键的函数就是storage.remount()。这个函数的作用是重新挂载根文件系统/并允许我们指定新的挂载参数。函数原型很简单storage.remount(/, readonly)/代表根文件系统也就是我们的CIRCUITPY盘。readonly一个布尔值参数。这里需要特别注意这个readonly参数指的是对CircuitPython而言的只读状态而不是对你的电脑这是一个最容易混淆的点我们用一个表格来厘清readonly参数值对 CircuitPython 而言对你的电脑而言典型应用场景readonlyTrue只读可读写开发模式。你可以用电脑自由编辑code.py、添加库文件。CircuitPython程序无法写入文件。readonlyFalse可读写只读数据记录模式。CircuitPython程序可以创建、写入文件如记录数据。电脑只能读取文件不能修改或删除防止意外操作破坏正在记录的数据。所以当我们在boot.py里调用storage.remount(/, readonlyFalse)时我们是在告诉系统“启动之后请把文件系统的写入权限交给CircuitPython程序同时把我的电脑锁在只读模式。”重要提示一旦设置为readonlyFalse你在电脑上会看到CIRCUITPY盘可以打开并复制里面的数据文件但无法在电脑上新建、修改或删除该盘内的任何文件。试图保存修改会提示“磁盘被写保护”。这是正常现象说明数据记录功能正在工作。2.3 物理开关的必要性如何切回开发模式既然boot.py只在硬复位或上电时运行并且会把电脑锁在只读状态那我们写完数据后怎么把权限还给电脑以便取出数据或修改代码呢总不能每次都重新刷固件吧这就需要引入一个物理开关。这个开关的本质是一个连接到特定GPIO引脚和地GND的电路。在boot.py中我们读取这个引脚的电平状态并据此决定remount的readonly参数。核心逻辑是如果引脚连接到GND低电平则让CircuitPython可写如果引脚悬空或接高电平则让电脑可写。这样我们就能通过一个硬件开关比如拨动开关、按钮甚至临时用一根杜邦线短接来动态控制板子的启动模式开发模式开关断开引脚通过内部上拉电阻为高电平 -readonlyTrue- 电脑可写方便编程。记录模式开关闭合引脚接地为低电平 -readonlyFalse- CircuitPython可写开始记录数据。当你需要停止记录并读取数据时只需安全弹出CIRCUITPY盘。断开板子的USB供电或按复位键进行硬复位。将物理开关拨回“开发模式”即断开与GND的连接。重新插上USB线。此时boot.py会检测到高电平以readonlyTrue模式挂载电脑重新获得写入权限你就可以自由拷贝数据文件了。3. 硬件准备与boot.py配置详解理解了原理我们来看具体如何实现。不同的Adafruit开发板其用于控制模式的引脚定义略有不同。3.1 选择你的控制引脚你需要根据自己手头的板子选择正确的引脚。通常我们会选择一个带有内部上拉电阻的GPIO引脚这样当开关断开时引脚会被拉至高电平省去外部电阻。以下是常见板型的推荐引脚及接线说明板型推荐引脚引脚特性说明物理开关建议通用型 (Gemma M0, Trinket M0, Metro M0/M4 Express, ItsyBitsy M0/M4 Express)D2数字IO支持内部上拉。使用一个拨码开关或自锁按钮一端接D2另一端接GND。Feather M0/M4 ExpressD5数字IO支持内部上拉。Feather板型引脚排列不同。同上连接D5和GND。Circuit Playground Express/BluefruitD7板载滑动开关。这是最方便的设计无需外接任何线路使用板子自带的滑动开关即可。向右滑动靠近耳朵图标为低电平记录模式向左滑动靠近音乐图标为高电平开发模式。对于需要外接开关的板子接线非常简单开发板 GPIO引脚 (如D2) --- 开关/跳线的一端 开发板 GND 引脚 --- 开关/跳线的另一端当开关闭合时GPIO引脚与GND导通电平被拉低。3.2 编写通用的boot.py文件下面是一个适配多种板型的、带有详细注释的boot.py示例。你可以直接复制使用并根据你的板子取消对应注释。# SPDX-FileCopyrightText: 2017 Limor Fried for Adafruit Industries # SPDX-License-Identifier: MIT CircuitPython 存储日志功能 - boot.py 文件 此文件在硬件启动时运行用于根据物理开关状态设置文件系统读写权限。 import board import digitalio import storage # 根据你的开发板选择正确的引脚 # 1. 对于 Gemma M0, Trinket M0, Metro M0/M4 Express, ItsyBitsy M0/M4 Express # 使用 D2 引脚。保持下面这行取消注释其他板型的代码注释掉。 switch_pin board.D2 # 2. 对于 Feather M0 Express 和 Feather M4 Express # 使用 D5 引脚。取消注释下面这行并注释掉上面的 switch_pin board.D2。 # switch_pin board.D5 # 3. 对于 Circuit Playground Express 和 Circuit Playground Bluefruit # 使用板载滑动开关 D7。取消注释下面这行并注释掉上面的 switch_pin board.D2。 # switch_pin board.D7 # 初始化引脚为输入模式并启用内部上拉电阻 switch digitalio.DigitalInOut(switch_pin) switch.direction digitalio.Direction.INPUT switch.pull digitalio.Pull.UP # 启用内部上拉电阻。开关断开时引脚为高电平(True)。 # 核心权限设置逻辑 # switch.value 返回值 # True (高电平通常为1): 开关断开或CPX开关拨到左边。电脑可写CircuitPython只读。 # False (低电平通常为0): 开关闭合接地或CPX开关拨到右边。CircuitPython可写电脑只读。 # # storage.remount(/, readonlyswitch.value) # 参数 readonly 是针对 CircuitPython 的。 # 当 switch.value 为 True 时readonlyTrueCircuitPython只读电脑可写。 # 当 switch.value 为 False 时readonlyFalseCircuitPython可写电脑只读。 storage.remount(/, readonlyswitch.value) # 提示信息可选通过串口查看 print(boot.py 执行完毕。) print(f开关状态: {switch.value} (True高电平/开发模式, False低电平/记录模式)) print(f文件系统对CircuitPython为: {只读 if switch.value else 可写})代码解读与注意事项内部上拉电阻 (pulldigitalio.Pull.UP): 这行代码至关重要。它启用了微控制器内部的电阻将引脚电压在没有外部连接时“拉”到高电平通常是3.3V。这样当开关断开时我们读取到的switch.value就是True无需外接任何电阻元件。逻辑关系storage.remount(/, readonlyswitch.value)这一行是灵魂。引脚高电平(True) -readonlyTrue- 开发模式。引脚低电平(False) -readonlyFalse- 记录模式。务必理解这个对应关系。Circuit Playground Express (CPX) 的特殊性对于CPX你不需要接任何线。板载的滑动开关已经连接到了D7和GND。开关拨到右侧靠近耳朵图标时D7接地switch.value为False进入记录模式。开关拨到左侧靠近音乐图标时D7被上拉为高电平switch.value为True进入开发模式。这是最优雅的实现。3.3 部署与测试boot.py将开发板连接到电脑确保CIRCUITPY盘出现。根据你的板型修改上述boot.py代码只保留正确的switch_pin定义。将修改好的boot.py文件保存到CIRCUITPY盘的根目录。重要此时由于还没有触发硬复位文件系统权限尚未改变。你仍然可以编辑CIRCUITPY盘里的文件。进行硬件复位对于有复位按钮的板子按下复位键。或者更稳妥的方法是在电脑上安全弹出CIRCUITPY盘然后拔掉USB线。设置硬件开关对于CPX将滑动开关拨到右侧记录模式。对于其他板子用杜邦线或开关将你选择的引脚如D2与GND短接。重新连接USB线到电脑。观察CIRCUITPY盘。你现在应该能看到它但尝试新建一个文本文件或修改boot.py系统会提示“磁盘被写保护”或类似错误。恭喜这说明boot.py已生效CircuitPython现在获得了写入权限4. 实战构建一个温度数据记录器现在文件系统已经准备好接收数据了。我们来创建一个经典的示例记录微控制器内部CPU的温度。虽然这个温度更接近芯片结温而非环境温度且精度有限例如nRF52840芯片分辨率是0.25°C但它非常适合演示数据记录流程且无需任何外部传感器。4.1 编写数据记录代码 (code.py)将以下代码保存为CIRCUITPY盘根目录下的code.py。请确保你已经按照上一节的步骤通过boot.py和硬件开关将板子设置为“记录模式”电脑只读否则这段代码在尝试写文件时会报错。# SPDX-FileCopyrightText: 2017 Limor Fried for Adafruit Industries # SPDX-License-Identifier: MIT CircuitPython 温度数据记录示例 - code.py 文件 此程序每秒读取一次CPU温度并追加记录到 temperature.txt 文件中。 通过LED闪烁状态指示运行情况。 import time import board import digitalio import microcontroller # 导入 microcontroller 模块以访问CPU温度传感器 # -------- LED初始化用于状态指示 -------- # 对于大多数有板载LED的CircuitPython板子通常是GPIO13 led digitalio.DigitalInOut(board.LED) # 对于 QT Py M0 等板子板载LED可能连接在SCK引脚上如果需要请取消下一行注释 # led digitalio.DigitalInOut(board.SCK) led.switch_to_output() # -------- 主数据记录循环 -------- try: # 以追加模式(a)打开文件。如果文件不存在则创建存在则在末尾添加。 with open(/temperature.txt, a) as data_file: print(开始记录温度数据...) while True: # 1. 读取CPU温度单位摄氏度 temp_c microcontroller.cpu.temperature # 2. 可选转换为华氏度 # temp_f temp_c * 9 / 5 32 # 3. 格式化数据并写入文件 # 使用 {0:f} 格式化为浮点数。每行一条记录方便后续处理。 data_line f{temp_c:.2f}\n # 保留两位小数 # 如果你想记录华氏度可以这样写 # data_line f{temp_c:.2f}, {temp_f:.2f}\n data_file.write(data_line) # 4. 立即刷新缓冲区确保数据写入存储避免因断电丢失 data_file.flush() # 5. 翻转LED状态表示一次记录完成心跳指示 led.value not led.value print(f记录温度: {temp_c:.2f} °C) # 在串口控制台输出可选 # 6. 等待1秒 time.sleep(1) except OSError as e: # 捕获文件系统操作错误最常见的是不可写或磁盘已满 print(f文件系统错误: {e}) delay 0.5 # 默认错误闪烁间隔0.5秒 # 错误码28代表 ENOSPC即设备无剩余空间 if e.args[0] 28: print(错误文件系统已满) delay 0.25 # 磁盘满时加快闪烁频率作为紧急提示 # 进入错误处理循环快速闪烁LED直到手动复位 while True: led.value not led.value time.sleep(delay)4.2 代码深度解析与优化建议文件打开模式 (a): 使用追加模式a是数据记录的最佳实践。它保证每次写入的数据都添加到文件末尾不会覆盖之前的历史数据。如果你希望每次启动都重新开始记录可以使用写入模式w但这会清空原有文件。data_file.flush()的重要性: 在嵌入式系统中写操作通常先进入内存缓冲区稍后才真正写入物理存储。调用flush()方法会强制立即将缓冲区数据写入存储介质。这对于数据记录器至关重要能最大限度避免在意外断电时丢失最近几次的记录数据。虽然会稍微增加写操作的开销和损耗但数据完整性优先。错误处理 (try...except OSError): 嵌入式环境不稳定必须考虑异常。最常见的错误是文件系统不可写例如boot.py没生效或开关在错误位置和磁盘空间满。我们通过捕获OSError并检查其错误码e.args[0]来区分处理。错误码28在大多数系统中表示“No space left on device”。状态指示: 利用板载LED进行闪烁是一种简单有效的“心跳”指示告诉你程序在正常运行。在出错时改变闪烁频率如变快能提供直观的故障诊断信息。数据格式: 我们选择每行记录一个温度值。这种纯文本格式.txt, .csv通用性最强可以直接用文本编辑器、Excel、Python的pandas等工具打开分析。你也可以轻松修改为CSV格式例如f{time.monotonic():.1f},{temp_c:.2f}\n同时记录时间戳。4.3 运行与数据收集确保硬件开关处于记录模式引脚接地或CPX开关在右侧。硬复位板子拔插USB或按复位键。观察板载LED它应该开始以1秒的间隔规律闪烁。此时电脑上的CIRCUITPY盘是只读的。你会看到根目录下逐渐出现或增大的temperature.txt文件但无法在电脑上修改它。让记录器运行一段时间几分钟或几小时。如何停止记录并读取数据首先在电脑上安全弹出CIRCUITPY盘。断开USB线或按硬件复位键。将硬件开关切换回“开发模式”断开与GND的连接或CPX开关拨到左侧。重新连接USB线。此时boot.py会以readonlyTrue模式挂载文件系统。现在你可以打开CIRCUITPY盘自由地复制temperature.txt到电脑或者用文本编辑器直接查看里面的数据了。5. 高级应用与避坑指南掌握了基础的数据记录后我们可以探索更实际、更复杂的应用场景并了解一些常见的“坑”。5.1 记录多种传感器数据在实际项目中你很可能需要记录来自多个传感器如温湿度、气压、光照强度的数据。以下是一个扩展示例模拟记录三组数据import time import board import microcontroller # 假设我们有以下模拟传感器读数函数 # from sensor_library import read_humidity, read_pressure def read_dummy_humidity(): 模拟读取湿度传感器返回百分比 # 实际项目中替换为真实的传感器读取代码如Adafruit_SHT31D return 45.0 (time.monotonic() % 10) * 0.1 # 模拟一个缓慢变化的湿度 def read_dummy_pressure(): 模拟读取气压传感器返回hPa # 实际项目中替换为真实的传感器读取代码如Adafruit_BMP280 return 1013.0 (time.monotonic() % 20) * 0.05 # 模拟一个缓慢变化的气压 try: with open(/sensor_log.csv, a) as f: # 使用.csv扩展名 # 如果是第一次运行可以写入表头可选 # if f.tell() 0: # 判断文件是否为空 # f.write(timestamp,temperature_c,humidity_%,pressure_hPa\n) while True: timestamp time.monotonic() # 获取自开机以来的时间秒单调递增 temp_c microcontroller.cpu.temperature humidity read_dummy_humidity() pressure read_dummy_pressure() # 写入CSV格式的一行数据 # 使用逗号分隔方便用电子表格软件直接打开 log_line f{timestamp:.1f},{temp_c:.2f},{humidity:.2f},{pressure:.2f}\n f.write(log_line) f.flush() # 关键立即写入 print(f记录: {log_line.strip()}) time.sleep(10) # 每10秒记录一次节省空间和功耗 except OSError as e: # ... 错误处理同上 ...要点CSV格式使用逗号分隔值第一行可以写表头便于数据分析。时间戳time.monotonic()提供稳定递增的时间适合计算间隔。如果需要真实时间则需要外接RTC实时时钟模块。记录频率根据传感器特性和应用需求调整time.sleep()的间隔。高频记录会产生大量数据很快占满存储空间。5.2 存储空间管理与文件轮转CircuitPython开发板的存储空间有限通常从2MB到8MB不等。长时间记录数据文件会越来越大。为了避免磁盘写满需要管理策略。策略一固定大小循环记录import os MAX_FILE_SIZE 100 * 1024 # 最大100KB LOG_FILE /data.log def check_and_rotate_file(): try: stat os.stat(LOG_FILE) if stat[6] MAX_FILE_SIZE: # stat[6] 是文件大小字节 print(日志文件过大准备轮转...) # 简单的轮转重命名旧文件新建一个空文件 # 注意这里操作文件系统需要确保在可写模式下 backup_name f/data_backup_{int(time.monotonic())}.log os.rename(LOG_FILE, backup_name) print(f已备份旧日志为: {backup_name}) except OSError: # 文件可能不存在这是第一次运行 pass while True: check_and_rotate_file() with open(LOG_FILE, a) as f: # ... 你的数据记录逻辑 ... time.sleep(1)策略二基于时间的文件分割import os import time LOG_DIR /logs LOG_DURATION 3600 # 每个文件记录1小时3600秒 def get_current_log_file(): # 创建一个logs目录如果不存在 try: os.mkdir(LOG_DIR) except OSError: pass # 根据当前时间计算文件块例如每小时一个文件 time_chunk int(time.monotonic() // LOG_DURATION) filename f{LOG_DIR}/log_{time_chunk}.csv return filename while True: log_file get_current_log_file() with open(log_file, a) as f: start_time time.monotonic() # 在一个时间块内持续记录 while int(time.monotonic() // LOG_DURATION) int(start_time // LOG_DURATION): # ... 记录数据 ... time.sleep(10)5.3 常见问题与排查技巧在实际操作中你可能会遇到以下问题。这里有一个快速排查指南问题现象可能原因解决方案运行code.py时报OSError: [Errno 30] Read-only filesystem1.boot.py文件不存在或不在根目录。2.boot.py中的引脚配置错误。3.硬件开关不在“记录模式”最常见。4. 没有进行硬件复位仅软复位不会执行boot.py。1. 检查CIRCUITPY根目录下是否有boot.py。2. 检查boot.py中switch_pin是否对应你的板子。3.确保开关已拨到记录模式引脚接地。4.弹出U盘 - 断电 - 拨好开关 - 重新上电。电脑无法向CIRCUITPY盘写入文件这是正常现象说明boot.py已生效且开关在“记录模式”。此时CircuitPython正在写入数据。需要读取数据时按流程操作安全弹出 - 断电 - 开关拨回“开发模式” - 重新上电。数据文件没有出现或没有更新1.code.py代码有语法错误未正常运行。2. 文件打开模式错误如用了w但路径不对。3. 存储空间已满。4. 程序因异常而终止。1. 通过串口控制台查看错误输出。2. 检查open()函数的文件路径是否正确以/开头。3. 检查磁盘剩余空间实现文件轮转逻辑。4. 加强try...except错误处理确保程序持续运行。LED不闪烁程序好像卡住了1. 可能陷入了某个阻塞操作如错误的传感器初始化。2. 电源不稳定。3. 代码中有死循环且没有time.sleep导致看门狗复位。1. 通过串口打印调试信息定位卡住的代码行。2. 确保使用稳定的5V USB电源供电。3. 在长循环中加入短暂的time.sleep(0.01)或使用microcontroller.watchdog。记录的数据格式混乱1. 写入字符串时未添加换行符\n。2. 多个任务同时写同一个文件需加锁。3. 在写入过程中意外复位导致文件损坏。1. 确保每条记录都以换行符结尾。2. 避免多线程/协程同时写文件。如果必须研究简单的文件锁机制。3. 每次写入后调用file.flush()并考虑使用更健壮的文件系统如LittleFS如果固件支持。一个关键的实操心得永远通过串口控制台进行调试。在code.py中加入print()语句输出开关状态、传感器读数、文件操作结果等信息。当板子处于“记录模式”电脑只读时你虽然不能修改文件但串口控制台通过Mu编辑器、VS Code插件或screen/putty等工具连接仍然是完全可用的。这是你洞察程序运行状态的唯一窗口。6. 超越基础扩展思路与项目构想掌握了基本的文件读写后你可以将这个功能融入到更复杂的项目中环境监测站结合DHT22温湿度、BMP280气压、BH1750光照等传感器制作一个能长期记录环境参数的数据记录盒并定期将数据文件通过SD卡或无线方式导出。设备运行日志为你制作的机器人、3D打印机或其他智能设备添加运行日志功能。记录关键事件如“电机启动”、“错误代码1023”、“到达目标位置”、传感器读数和时间戳便于后期故障诊断和性能分析。能量采集与低功耗记录对于电池供电的项目可以结合alarm和time模块的深度睡眠功能。让设备大部分时间休眠每隔一小时唤醒一次读取传感器并写入文件然后继续休眠极大延长续航。条件触发式记录不是持续记录而是当某个条件满足时如温度超过阈值、振动传感器被触发才开始高速记录一段时间类似于“黑匣子”或事件记录器。与无线传输结合使用Wi-FiESP32-S2/S3或LoRaRFM9x模块定期将记录的文件内容以小数据包的形式发送到服务器或另一台设备实现远程数据收集。最后一点关于文件系统的提醒CircuitPython默认使用的FAT文件系统简单通用但对意外断电的耐受性相对较弱。频繁的小文件写入和擦除也可能导致存储芯片的特定区块过早磨损。对于要求极高的数据完整性或长期频繁写入的应用可以考虑定期备份重要数据。实现写平衡算法如果自己管理Flash。探索是否可以使用其他更健壮的文件系统如LittleFS但这依赖于CircuitPython底层固件的支持。通过storage模块和boot.pyCircuitPython为你打开了在微控制器上进行可靠数据记录的大门。从简单的温度日志到复杂的多传感器数据采集系统这个基础而强大的功能是构建真正“独立”嵌入式智能设备的关键一步。