树莓派Pico项目做多了,你迟早会撞上这个尴尬瞬间:MicroPython 代码跑得挺欢,串口输出一切正常,可一旦断电重启,日志里的时间突然回到 1970 年。原因说穿了不复杂——Pico 内部确实有 RTC(实时时钟),但它的电源完全依赖主板供电,掉电之后时间直接清零。要让设备随时拿得出正确时间,通常有两条路:一是外接带电池备份的 RTC 芯片,让时间在硬件层面持续走;二是联网后通过 NTP 协议做时间同步,让系统每次开机都能自动对表。这篇文章就把树莓派 Pico 的 RTC 控制方法和 NTP 时间同步实现完整拆解一遍,从内部 RTC 的基础操作到外接 DS3231,再到联网校时的工程化细节,都按我实际调过的代码来讲。
1. Pico 的时间困境:内部 RTC 能做什么,不能做什么
1.1 RP2040 内部 RTC 的能力边界
RP2040 芯片内部确实集成了一组 RTC 外设,支持年、月、日、星期、时、分、秒的计数,MicroPython 里通过machine.RTC就能操作。这与你在 Arduino 板上外接 DS1302 模块不一样,Pico 的 RTC 是芯片自带外设,不需要额外硬件就能用,这给许多项目带来了很大方便。在保持供电的状态下,它能持续走时,不会像纯软件计数那样漂移得离谱。
但问题出在“保持供电”这四个字上。Pico 开发板不像 DS3231 模块那样专门留了电池座(VBAT 引脚),也没有后备电源切换电路。只要主供电断掉,RP2040 内部 RTC 的所有寄存器就会丢失,上电后时间回到默认值。MicroPython 刚烧录完固件时,你调用rtc.datetime()拿到的通常是一个初始时间,或者上一次最近设置过的值,一旦断电全部归零。
所以可以这么理解:内部 RTC 适合在设备持续在线、短暂重启时避免时间跳变;它不适合做“掉电后依然能记住时间”的长期时钟。如果你做的是数据记录仪、离线气象站、定时控制器这类需要长期运行且可能断电的设备,只靠内部 RTC 是不够的。
1.2 MicroPython 里时间数据到底长什么样
MicroPython 的时间表示跟 PC 上不太一样。PC 上常用 Unix 时间戳,也就是从 1970 年 1 月 1 日 00:00:00 UTC 开始经过的秒数。MicroPython 也有time.time()返回这种时间戳,但同时设备还需要一套更直观的“年月日时分秒”结构,方便直接显示。
machine.RTC().datetime()返回的是一个 8 元组,顺序是:
(year, month, day, weekday, hours, minutes, seconds, subseconds)这里有个容易混的点:第四个元素是“星期几”,不是“一年中的第几天”。很多刚开始接触 MicroPython 的人把它当成time.localtime()的 yearday,结果一打印日期,星期和天数对不上。time.localtime()返回的元组结构则是:
(year, month, day, hour, minute, second, weekday, yearday)注意两种结构的字段顺序不同,尤其是 weekday 的位置不一样。这在我们后面做时间同步时是个必须处理的细节,因为如果直接把localtime()的结果塞进rtc.datetime(),星期字段就会错位,时间看起来对,星期几却是错的。
2. 操控 Pico 内置 RTC:接口、代码和实测误差
2.1 machine.RTC 的核心接口
MicroPython 的machine.RTC操作非常直接,核心就是两个动作:设置时间、读取时间。设置用datetime()方法,传入一个 8 元组;读取也用datetime(),不带参数时返回当前时间元组。
from machine import RTC rtc = RTC() # 设置时间:2025年6月1日,星期天,10点30分20秒 rtc.datetime((2025, 6, 1, 6, 10, 30, 20, 0)) # 读取时间 now = rtc.datetime() print(now)这里有两个细节要注意。
第一,weekday的取值在不同平台、不同固件版本上可能有差异。Pico 上通常按 0 到 6 表示,但“0 是周一还是周日”在不同底层 SDK 里的约定略有不同。如果你只是显示星期,建议自己定义一个数组做映射,并且在联调时用真实日期验证一次,不要想当然。
第二,最后一个亚秒字段在 Pico 上基本是固定值 0。也就是说,这个内部 RTC 并不能提供高精度的毫秒信息,它纯粹服务于“秒级”时间记录。如果你要做亚秒级时间戳,还是得靠time.ticks_us()和ticks_ms()这类高精度计数器。
2.2 读取与格式化输出的实用函数
日常开发中,我更习惯用time.localtime()来读取并格式化,因为用rtc.datetime()还要自己记住字段顺序。MicroPython 的time模块在初始化后会自动与 RTC 保持同步,所以读localtime()就等价于读 RTC。
import time def format_time(ts=None): if ts is None: ts = time.time() tm = time.localtime(ts) return "{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format( tm[0], tm[1], tm[2], tm[3], tm[4], tm[5] ) rtc = RTC() rtc.datetime((2025, 6, 1, 6, 10, 30, 20, 0)) print(format_time()) # 2025-06-01 10:30:20格式化时最好统一用"{:02d}"这种补零写法,否则秒数小于 10 的时候会输出10:30:5,打印日志非常难看。
2.3 内部 RTC 的精度到底行不行
我在室温环境下做过一次简单测试:设置好时间后让 Pico 连续跑 48 小时,再和电脑时间对比。结果大概慢了几秒钟,具体误差在 3 到 8 秒之间。这个精度对绝大多数场景够用,但如果设备连续运行几个月,误差会累积到几分钟量级。
影响 RTC 精度的核心因素是晶振频率偏差和温度漂移。RP2040 的开发板通常配 12MHz 晶振,它要经过内部逻辑分频后才能产生 RTC 所需的时钟,任何频率偏差都会直接反映到走时速度上。所以我的结论是:内部 RTC 只能满足“短期维持时间”的需求,不可能替代专业时钟芯片,更不能和 NTP 这类网络校时手段相提并论。
3. 联网自动对表:NTP 时间同步的完整实现
3.1 SNTP 协议原理和 MicroPython 的 ntptime
NTP 全称是 Network Time Protocol,用于在网络上同步设备时间。完整的 NTP 实现很复杂,需要处理延迟补偿、时钟漂移分析、多服务器选择等一堆东西。MicroPython 内置的ntptime模块实现的其实是 SNTP 简化版,思路只有一条:客户端向 NTP 服务器发送一个 UDP 报文,服务器回传当前时间戳,客户端拿这个时间戳设置本机 RTC。
这个模块使用起来极其简单:
import ntptime ntptime.host = "ntp.aliyun.com" ntptime.settime()sethost默认是"pool.ntp.org",但在国内网络环境下,这个域名有时连接很慢,我会换成国内公共时间服务器,比如ntp.aliyun.com或ntp.tencent.com。实际测试下来,阿里云的响应速度比较稳定,失败率也低。
ntptime.settime()内部的工作流程大致是:通过socket向目标服务器 123 端口发一个 SNTP 请求,解析返回的时间字段,再调用内部接口把时间写入 RTC。整个过程是阻塞式的,网络不好时可能卡住几秒到十几秒,在代码里要加异常处理,否则死机了都不知道为什么。
3.2 完整校时函数:获取、校验、设置本地时间
ntptime.settime()设置的是 UTC 时间,不是本地时间。对中国用户来说,东八区比 UTC 快 8 小时,所以只调用settime()的话,设备上显示的时间会比手表慢 8 个小时。
我的做法是:先调用ntptime.settime()拿到 UTC 基准,然后用time.time()读出时间戳,加上 8 小时偏移,最后把本地时间写回 RTC。
import time import ntptime from machine import RTC def sync_ntp(host="ntp.aliyun.com", tz_offset=8 * 3600, retries=3): rtc = RTC() for attempt in range(retries): try: ntptime.host = host ntptime.settime() # 此时 RTC 是 UTC 时间,读出来转本地时间 utc_ts = time.time() local_ts = utc_ts + tz_offset tm = time.localtime(local_ts) # 注意:time.localtime 的第 7 个元素才是 weekday rtc.datetime((tm[0], tm[1], tm[2], tm[6], tm[3], tm[4], tm[5], 0)) return True except (OSError, KeyboardInterrupt): print("NTP sync failed, retry {}/{}".format(attempt + 1, retries)) time.sleep(2) return False这里面最关键的一行是最后写回 RTC 时的字段映射:
(tm[0], tm[1], tm[2], tm[6], tm[3], tm[4], tm[5], 0)time.localtime()返回的 weekday 在索引 6,而rtc.datetime()需要的是第四个元素,所以必须从tm[6]取,不能直接切片,否则星期全错。我最初写代码时图省事,直接用local_ts对应的tm[0:7]拼给 RTC,结果年份、月份全对,星期在日志里乱跳,最后排查了很久才发现是字段顺序问题。
3.3 网络连接与校时状态的判定
NTP 同步的前提是先联网。Pico 的 MicroPython 固件里一般用network模块连接 WiFi。最基础的上电联网流程可以这样写:
import network import time wlan = network.WLAN(network.STA_IF) wlan.active(True) wlan.connect("你的SSID", "你的密码") for _ in range(20): if wlan.isconnected(): break time.sleep(0.5) if not wlan.isconnected(): print("WiFi connect failed") else: print("Network connected:", wlan.ifconfig()) sync_ntp()注意这里连接等待超时不能设太短。有些路由器在设备刚上电时响应很慢,20 次×0.5 秒也就是 10 秒,实际大部分项目够用了。如果等待时间太长,设备会一直卡在启动阶段,很不友好。更好的做法是把联网校时放到一个“尝试但不阻塞”的机制里:先启动主程序,用后台标志位记录时间是否已经同步。
3.4 为什么我建议把校时放在启动早期
时间同步这种事,越早做越好。原因很简单:代码里有太多地方会打时间戳,如果主业务跑起来之后还没校时,日志里的时间就是错的。而且 NTP 请求不可控,如果放到程序跑了一段时间才想起来同步,中间产生的所有数据都带着错误时间,后期整理数据非常痛苦。
我一般把启动流程设计成这样:
- 初始化 WiFi,尝试连接。
- 连接成功后立刻执行
sync_ntp()。 - NTP 成功则标记
time_synced = True。 - NTP 失败不阻塞启动,先读取外部 RTC 或者其他备用时间源。
- 主循环里定期检查
time_synced,每隔 6 到 12 小时再校准一次。
这样设备启动后可能在第一秒时间还不准,但几分钟内会完成校准。对大多数传感器采集、定时控制项目来说,这种短期“启动偏差”是可以接受的。
4. 断网也不怕:外接 DS3231 把时间固化在硬件上
4.1 DS3231 和 DS1307 怎么选
如果设备经常在没有 WiFi 的环境下运行,比如野外数据记录、大棚温控、离线工控,那光有 NTP 是不够的。这时候需要一块带电池备份的外部 RTC 芯片。
市面上最常见的两种是 DS1307 和 DS3231。DS1307 很便宜,但用的是普通 32.768kHz 晶振,误差大,温度变化时走时精度还会明显下降。DS3231 内置了温度补偿晶振(TCXO),在 0 到 40 摄氏度的范围内,年误差通常能控制在 1 到 2 分钟以内,比 DS1307 高好几个档次。
我的建议很直接:直接买 DS3231 模块,别在 DS1307 上省那几块钱。尤其你拿它做长期记录仪或者强调时间准确性的项目,DS3231 多出来的精度非常值得。
DS3231 通过 I2C 接口与 Pico 通信,地址是0x68。它内部有二十多个寄存器,日常使用主要关注这几个:
| 寄存器地址 | 功能 | 数据格式 |
|---|---|---|
| 0x00 | 秒 | BCD,bit7 为振荡器暂停标志 |
| 0x01 | 分 | BCD |
| 0x02 | 时 | BCD,bit6 表示 12/24 小时制 |
| 0x03 | 星期 | 1 到 7 |
| 0x04 | 日 | BCD |
| 0x05 | 月 | BCD,bit7 为世纪标志 |
| 0x06 | 年 | BCD,00 到 99 |
一个值得注意的地方:DS3231 的年寄存器只有两位,表示“相对于 2000 年的偏移”。比如 2025 年,存的是0x25。如果你要做跨世纪的项目,得自己在应用层加 2000 处理。
4.2 Pico 与 DS3231 的接线和 MicroPython 驱动
I2C 接线比我预想的还要简单。Pico 的 I2C0 默认引脚是 GP8(SDA)和 GP9(SCL),但也可以用其他引脚,只要初始化时指定。DS3231 模块一般引出 VCC、GND、SDA、SCL 四个引脚。
| DS3231 引脚 | Pico 引脚 |
|---|---|
| VCC | 3V3 (OUT) |
| GND | GND |
| SDA | GP8 |
| SCL | GP9 |
注意,不少 DS3231 模块板载了上拉电阻,如果你买的是裸芯片,就需要自己在 SDA 和 SCL 上各接一个 4.7kΩ 上拉电阻到 3.3V,否则 I2C 通信不稳定。
驱动核心就是 BCD 码转换和寄存器读写。下面是一个我常用的最小驱动:
from machine import I2C, Pin import time DS3231_ADDR = 0x68 def bcd2dec(b): return (b >> 4) * 10 + (b & 0x0F) def dec2bcd(d): return ((d // 10) << 4) | (d % 10) class DS3231: def __init__(self, i2c, addr=DS3231_ADDR): self.i2c = i2c self.addr = addr def set_time(self, year, month, day, weekday, hour, minute, second): data = bytearray([ dec2bcd(second) & 0x7F, dec2bcd(minute), dec2bcd(hour) & 0x3F, # 24小时制 dec2bcd(weekday), dec2bcd(day), dec2bcd(month), dec2bcd(year % 100) ]) self.i2c.writeto_mem(self.addr, 0x00, data) def get_time(self): data = self.i2c.readfrom_mem(self.addr, 0x00, 7) second = bcd2dec(data[0] & 0x7F) minute = bcd2dec(data[1]) hour = bcd2dec(data[2] & 0x3F) weekday = bcd2dec(data[3]) day = bcd2dec(data[4]) month = bcd2dec(data[5] & 0x1F) year = 2000 + bcd2dec(data[6]) return (year, month, day, weekday, hour, minute, second) i2c = I2C(0, scl=Pin(9), sda=Pin(8), freq=400_000) ds = DS3231(i2c) # 设置时间为 2025 年 6 月 1 日,星期天 10:30:20 ds.set_time(2025, 6, 1, 7, 10, 30, 20) # 读取时间 print(ds.get_time())设置时间时有几个坑。第一,秒寄存器最高位是振荡器暂停标志(OSF),正常写入时要用& 0x7F把这个位清掉,否则模块可能不启振。第二,小时寄存器如果 bit6 为 1,就是 12 小时制,MicroPython 驱动里最好强制用 24 小时制写入,也就是& 0x3F。第三,星期寄存器的值取决于你自己定义,1 到 7 对应周一到周日,这个和machine.RTC的 0 到 6 又不一样,做转换时要额外小心。
4.3 双时钟源的协同:内部 RTC 和外置 DS3231 怎么配合
有了 DS3231,不是就完全不需要内部 RTC 了。我常用的策略是:
- 上电时先读取 DS3231 的时间。
- 如果 DS3231 读出来年份小于某个阈值,比如 2020,说明电池没电或者芯片没初始化过,这时候再尝试 NTP 校准。
- 如果 DS3231 时间有效,直接把时间写进 Pico 内部 RTC,让
time.localtime()在启动后立刻可用。 - NTP 同步成功后,同时更新 DS3231,保证离线备份时间源也是准的。
这样的好处是:内部 RTC 负责给 MicroPython 的标准time模块提供时间,外部 DS3231 负责掉电记忆,两边各司其职。即使没有网络,设备重启后也能从 DS3231 恢复正确时间,误差极小。
5. 时间只能“准一次”是不够的:工程化落地的关键细节
5.1 定时任务、时间戳和 RTC 的冲突
新手最容易翻车的场景是:代码里用time.sleep()做定时,同时又依赖 RTC 做定时任务,结果两者互相干扰。time.sleep()是阻塞式延时,它不依赖 RTC,只是靠内部定时器计数。当系统正在执行长时间阻塞操作时,RTC 依然在后台走,这本身没问题,问题出在你想在精确时间点执行任务。
比如你希望每天早上 8 点采集一次数据,最直接的想法是主循环里不断读时间:
while True: now = format_time() if now[11:19] == "08:00:00": do_collect() time.sleep(60) time.sleep(1)这样写能跑,但有隐患。如果do_collect()执行时间超过了 1 秒,或者网络卡顿导致循环偏离,你可能错过 8 点整。更稳妥的做法是计算目标时间与当前时间的差值,用一次长时间time.sleep()睡到目标附近,再等待精确触发。或者用machine.Timer做周期性回调,在回调里判断时间。
我自己在项目里会封装一个等待到指定时刻的函数:
def wait_until(hour, minute, second=0): while True: now = time.localtime() current_total = now[3] * 3600 + now[4] * 60 + now[5] target_total = hour * 3600 + minute * 60 + second delta = target_total - current_total if delta < 0: delta += 24 * 3600 print("sleep {} seconds until target".format(delta)) time.sleep(delta)这个函数会把到点之前的碎片时间睡过去,避免主循环空转。
5.2 NTP 同步频率和并发请求的坑
很多教程只教你开机同步一次,但实际项目里 NTP 也应该定期校准。原因很简单:DS3231 再准,长期跑也有温漂;内部 RTC 更不用说,一天差几秒,积累一个月就是几分钟。我一般每 6 小时校准一次,频率太高考慮到服务器压力没必要,频率太低又起不到纠偏作用。
批量设备同时校时要特别注意:如果几十台设备都在同一时刻上电、同一时刻发起 NTP 请求,服务器端容易触发限流。我在设计固件时会加一个随机启动延时,让每台设备在开机后随机等待 0 到 60 秒再去请求 NTP,相当于给服务器端“削峰”。
5.3 我踩过的几个典型问题和解决办法
做一个时间相关功能的排错表,按我遇到的概率排序:
| 问题 | 现象 | 根因 | 解决办法 |
|---|---|---|---|
| 时间偏差 8 小时 | NTP 设置后,日志时间比本地晚 8 小时 | 没有做 UTC 偏移 | 在写回 RTC 前统一加上时区偏移 |
| 星期错误 | 日期正确,星期不对 | localtime()与rtc.datetime()的 weekday 索引不一致 | 转换字段时取tm[6]并验证默认值 |
| I2C 读不到 DS3231 | OSError: [Errno 19] ENODEV | SCL/SDA 接反或没接上拉电阻 | 使用i2c.scan()检查地址 0x68 是否存在 |
| NTP 请求卡死 | 程序无响应十几秒 | ntptime.settime()阻塞等待超时 | 包裹异常处理并设置多次重试 |
| DS3231 时间不更新 | 设置成功但读出来还是旧时间 | 写入时秒寄存器最高位未清 | 秒字节写入前做& 0x7F |
| 多个 I2C 设备地址冲突 | 扫描到多个地址 | 模块地址跳线或冲突 | 为不同设备分配不同 I2C 总线或改地址 |
还有一次比较隐蔽:我在 Pico 上同时接了 DS3231 和一个 OLED 屏幕,OLED 用的是 0x3C 地址,DS3231 是 0x68,按理说不会冲突。但 OLED 模块板载的上拉电阻太强,导致 SDA 波形变形,DS3231 偶尔读不到。后来我把 I2C 速度从 400kHz 降到 100kHz,问题立刻消失。这在长线连接时尤其明显,不能一味追求高频率。
5.4 把时间源设计成可切换的优先级链
到了项目后期,我总结出一套比较稳健的时间源优先级:
- NTP 网络校时:最准确,优先使用。
- DS3231 外部 RTC:断网时使用,精度高。
- 内部 RTC:启动瞬间和短期重启时使用,精度一般。
- 用户手动设置:实在没有网络、也没有外置电池模块时的兜底方案。
对应到代码里,就是做一个get_time()的统一入口,启动时给各个时间源“打标”,记录当前时间来自哪里。比如:
TIME_SOURCE_UNKNOWN = 0 TIME_SOURCE_NTP = 1 TIME_SOURCE_DS3231 = 2 TIME_SOURCE_INTERNAL = 3 time_source = TIME_SOURCE_UNKNOWN def init_time(): global time_source if sync_ntp(): time_source = TIME_SOURCE_NTP return True try: t = ds.get_time() if t[0] >= 2020: rtc.datetime((t[0], t[1], t[2], t[6], t[3], t[4], t[5], 0)) time_source = TIME_SOURCE_DS3231 return True except Exception: pass time_source = TIME_SOURCE_INTERNAL return False这样每个设备在任意时刻都知道自己的时间精度等级,日志里可以打一个字段标记。后期数据回传时,我能根据这个标记决定哪些记录可信、哪些需要重新校准。看似多写了几行,但对运维排查帮助很大。
如果你只是在桌面上跑一个温度显示小项目,用machine.RTC加上开机 NTP 同步就够了;如果你要做长期部署、离线运行、多设备协调的项目,DS3231 和定期 NTP 校准基本是标配。时间同步这块没有一劳永逸的银弹,把时间源做成优先级链,再想清楚每次校时失败后的退化方式,才是真正省心的方案。我在好几个项目里已经把这套逻辑固化成了模板,每次开始写业务代码之前,先初始化时间,后面所有日志、定时、数据标记就都有了共同且可靠的参考基准。