ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

树莓派Pico外接DS3231 RTC与NTP校时:构建不掉电的时间系统

2026/9/11 3:33:17 拓冰建站 浏览量
树莓派Pico外接DS3231 RTC与NTP校时:构建不掉电的时间系统 去年秋天我做了一个设备间温度监控的小项目树莓派 Pico 负责每隔一小时采集一次数据并写入 SD 卡日志里的每条记录都要带时间戳。设备连续跑了一周中途小区停电一次再上电时我傻眼了——所有新日志的时间戳全部回到了某个默认时间旧数据和新数据的时间线彻底错乱。排查到最后问题出在一个被无数教程一笔带过的细节上RP2040 芯片内部确实有 RTC 外设但它没有备用电池引脚只要主供电一断时间就归零。这篇文章就是把这件事从头到尾讲明白。主线是为什么 Pico 必须外接 RTC 芯片DS3231 模块怎么接线、MicroPython 驱动怎么写NTP 网络校时怎么实现以及最后怎么把 RTC 和 NTP 两套时间源组合成一份可靠的启动、校准、降级策略。适合正在做数据采集、定时控制、日志记录类 IoT 项目的开发者。无论你打算用 DS3231、DS1307还是只想靠 WiFi 校时里面的思路和代码都能直接抄走。1. 内部时钟掉电即失忆为什么必须外接 RTC1.1 RP2040 内部 RTC 的局限性先纠正一个很多新手容易产生的误解树莓派 Pico 的 MicroPython 固件里machine.RTC()是存在的能设置时间、能读取时间日常跑起来一切正常。但它的意义仅限于单次运行期间。RP2040 数据手册里 RTC 外设的全称是 Real Time Counter它依赖系统电源维持计数。手册里没有提供类似 STM32 的VBAT引脚也就没有让 32.768kHz 振荡器在主电源断开后继续工作的通路。所以实际行为是Pico 拔电RTC 内容丢失下次上电machine.RTC()返回的是固件默认值通常接近 epoch 时间。哪怕只是按一下复位键有些场景下 RTC 也会因为软复位逻辑回到默认状态。这点在只靠 USB 供电的桌面调试阶段完全看不出来一放到需要断电重启的现场环境就立刻暴露。此外内部 RTC 的精度依赖板上的晶振和系统时钟分频长期运行的漂移比较明显。我自己测过一块裸 Pico放着不校时一周下来快了大约 3~4 秒一个月就是十几秒。对日志类项目来说这误差可能还能忍但如果你要控制设备在特定时刻动作比如每天零点切换模式误差累积到一定程度就会出问题。1.2 DS1307、DS3231、PCF8523 怎么选外部 RTC 芯片做的是同一件事用一颗低功耗晶振持续计数再用电池或超级电容维持供电。常见的选择有这么几款芯片标称精度温度补偿备用电池价格区间典型特点DS1307±5ppm 级但模块晶振质量浮动大无支持3~8 元最便宜教程最多年漂移可能到分钟级DS3231±2ppm0°C~40°C内置 TCXO支持8~20 元精度高自带温度传感器我最推荐PCF8523±4ppm 典型部分型号支持支持5~15 元超低功耗适合电池供电设备RX8025T±5ppm 级有支持10~25 元日系方案工业设备里常见DS1307 的问题不是芯片设计不行而是市售模块的晶振品质太不稳定。它的精度对温度很敏感设备间温度一波动一天差个一两秒很常见。DS3231 把温度补偿晶振TCXO做到了芯片里0°C 到 40°C 范围内能维持 ±2ppm换算下来一天误差大约 0.17 秒一个月也就是 5 秒左右。对于绝大多数物联网项目这个精度已经绰绰有余。1.3 我的推荐硬件组合如果项目有网络可用我最常用的组合是 Pico DS3231 模块 CR2032 电池 WiFi 校时。DS3231 负责断电期间的守时NTP 负责上电后的绝对时间校准。没有网络的环境单靠 DS3231 也基本够用因为它的漂移很小。还有一种做法是给 DS3231 模块换超级电容替代电池超级电容充放电次数高、不怕过放适合设备长期在线、只是偶发断电的场景。不过超级电容自放电比 CR2032 快断电超过两三周可能撑不住看项目需求取舍。我见过有人试图只用 Pico 内部 RTC 硬撑靠每次上电时手动输入时间这在原型阶段很凑合一旦设备部署到现场就成了灾难。所以硬件选型这一关建议直接上 DS3231省下后面一堆时间校准的麻烦。2. 硬件连接与 I2C 初始化半小时跑通硬件层2.1 DS3231 模块引脚与接线表市售 DS3231 模块的引脚定义比较统一一般引出 VCC、GND、SDA、SCL、SQW 五个脚。SQW 是方波输出脚默认会输出 1Hz 方波做闹钟或者定时唤醒可以接但大部分采集项目用不上。Pico 这边硬件 I2C 控制器有两个I2C0 的默认引脚是 GP0SDA和 GP1SCLI2C1 的默认引脚是 GP6SDA和 GP7SCL。我用 I2C0 比较多接线表如下DS3231 模块引脚Pico 引脚VCC3V3(OUT) 物理脚 36GNDGND 物理脚 38SDAGP0物理脚 1SCLGP1物理脚 2注意一点模块上一般已经焊好了 4.7kΩ 上拉电阻这没问题。但有些精简版模块没有上拉电阻如果scan不到设备先别急着怀疑线序量一下 SDA 和 SCL 脚在空闲状态下是不是 3.3V 高电平。不是高电平就自己外接两颗 4.7kΩ 电阻到 3V3。2.2 I2C 初始化与设备扫描MicroPython 里初始化 I2C 只需要几行代码from machine import Pin, I2C i2c I2C(0, sclPin(1), sdaPin(0), freq400000) print(i2c) print(i2c.scan())freq400000是 400kHz 快速模式DS3231 支持。但如果你的杜邦线比较长或者用了面包板导致线路电容偏大400kHz 可能不稳定。我自己的经验是超过 10cm 的跳线就老老实实用 100kHz扫描结果更稳定。i2c.scan()会返回一个地址列表。DS3231 的 7 位地址是0x68如果你看到[104]十进制 104 0x68说明硬件链路已经通了。返回空列表的话按这个顺序排查先检查 SDA 和 SCL 是否接反再确认 VCC 是否真的供上了 3.3V最后量上拉电阻是否存在。2.3 一个容易忽略的供电问题DS3231 模块的逻辑电平跟随 VCC。有些模块支持 5V 供电但如果你把 VCC 接到 5V模块上的上拉电阻也会把 SDA/SCL 拉高到 5VPico 的 GPIO 是不耐 5V 的长期这么干有烧引脚的风险。所以 Pico 配 DS3231VCC 一定接 3V3不要贪图某个旧电源的 5V 输出。这条我反复跟人强调过因为确实见过有人因此把 Pico 的 I2C 引脚搞出永久性损伤。3. DS3231 驱动编写BCD 编码、寄存器读写与完整类实现3.1 寄存器映射与 BCD 编码原理DS3231 的寄存器从0x00开始前 7 个寄存器就是时间信息寄存器地址内容典型范围备注0x00秒0~59bit7 是时钟暂停位 CH0x01分0~590x02时0~23bit6 为 1 表示 12 小时制0x03星期1~71星期日具体映射由用户自定义0x04日期1~310x05月份1~12bit7 是世纪位0x06年0~99存的是 2000 年后的偏移0x0E控制寄存器—SQW 使能等0x0F状态寄存器—bit7 是 OSF 停振标志0x11~0x12温度10 位带符号单位 0.25°C这里最核心的知识点是 BCD 编码。DS3231 不是用二进制直接存数字的而是每个字节的高 4 位存十位低 4 位存个位。比如十进制 23在寄存器里存的是0x23也就是0b00100011。很多新人第一次读出来数值不对就是忘了做 BCD 和十进制的转换。转换逻辑很简单def bcd2dec(b): return (b 4) * 10 (b 0x0F) def dec2bcd(d): return ((d // 10) 4) (d % 10)写时间的时候还要注意小时寄存器如果 bit6 置 1芯片会进入 12 小时制模式那 bit5 就成了上午/下午标志。我建议初始化时强制把小时寄存器写成 24 小时制也就是写入的字节 bit6 保持 0免得低功耗模式下读回来的时间出现下午 3 点变成3 PM这类问题。3.2 完整驱动类代码基于上面的寄存器映射我整理了一个精简但完整的 DS3231 驱动类可以直接放进项目里用from machine import I2C, Pin DS3231_ADDR 0x68 class DS3231: def __init__(self, i2c): self.i2c i2c def _bcd2dec(self, v): return (v 4) * 10 (v 0x0F) def _dec2bcd(self, v): return ((v // 10) 4) (v % 10) def set_time(self, year, month, day, hour, minute, second, weekday): # 写入 7 个时间寄存器year 传四位数 data bytes([ self._dec2bcd(second), self._dec2bcd(minute), self._dec2bcd(hour), # 24 小时制 self._dec2bcd(weekday), self._dec2bcd(day), self._dec2bcd(month), self._dec2bcd(year - 2000) ]) self.i2c.writeto_mem(DS3231_ADDR, 0x00, data) def get_time(self): data self.i2c.readfrom_mem(DS3231_ADDR, 0x00, 7) second self._bcd2dec(data[0]) minute self._bcd2dec(data[1]) hour self._bcd2dec(data[2]) weekday self._bcd2dec(data[3]) day self._bcd2dec(data[4]) month self._bcd2dec(data[5]) year self._bcd2dec(data[6]) 2000 return (year, month, day, weekday, hour, minute, second) def get_temperature(self): # 0x11 是高字节0x12 低字节的高两位是小数部分 data self.i2c.readfrom_mem(DS3231_ADDR, 0x11, 2) raw (data[0] 2) | (data[1] 6) if raw 0x200: # 10 位有符号数判断符号位 raw - 1024 return raw / 4注意writeto_mem和readfrom_mem是 MicroPython 专门处理带寄存器地址的 I2C 设备的接口。它们的第一个参数是设备地址第二个是寄存器偏移第三个是数据。用起来比手动组织start、stop、read时序简单很多。3.3 用温度寄存器验证模块真伪get_temperature()这个功能不只是拿来读环境温度的它还有一个隐藏用途验证模块是不是真 DS3231。市面上有些模块标称 DS3231实际装的是 DS1307 或者别的兼容芯片它们往往没有温度补偿也没有温度寄存器。你调用get_temperature()如果读出来的温度在合理范围室温 10~40°C而且数值会随着环境温度变化那基本可以断定是真货。如果读出来永远是 0 或者一个离谱的负数那你大概率买到的是换标片。另一个验证方法是看状态寄存器0x0F的 bit7也就是 OSFOscillator Stop Flag。芯片因为掉电或者晶振停振导致时间丢失时这个位会被硬件置 1。你设置完时间后重新读它如果发现 OSF 始终为 1那说明 32.768kHz 晶振没有正常起振。常见原因是电池没装好、电池没电或者模块上的晶振本身是坏的。这个标志位在新模块第一次上电时是 1属于正常现象手动清 0 后还会置 1就要排查硬件了。4. NTP 时间同步协议原理与 MicroPython 实现4.1 NTP 报文格式与时间换算NTPNetwork Time Protocol是网络层的时间同步协议客户端向 NTP 服务器的 123 端口发一个 UDP 请求服务器回一个 48 字节的报文。报文里最关键的是第 32 字节开始的发送时间戳字段它用 32 位整数表示从 1900 年 1 月 1 日 0 时起算的秒数。而 MicroPython 的time模块遵循 Unix 时间戳是从 1970 年 1 月 1 日起算的。两个纪元之间差了 2208988800 秒换算时要把这个偏移减掉。import socket import struct import time NTP_DELTA 2208988800 # 1900-01-01 到 1970-01-01 的秒数 def ntp_timestamp(serverntp.aliyun.com, timeout3): addr socket.getaddrinfo(server, 123)[0][-1] request b\x1b 47 * b\x00 # 0x1b 版本 3 客户端模式 try: s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(timeout) s.sendto(request, addr) data, _ s.recvfrom(48) s.close() t struct.unpack(!12I, data)[10] - NTP_DELTA return t except Exception: return None请求报文第一个字节0x1b的二进制是001 011低 6 位其中前 3 位是 NTP 版本号这里用 3后 3 位是模式3 表示客户端。服务器返回的 48 字节里发送时间戳在偏移 32 字节处用 12 个无符号 32 位整数解析时它正好是第 11 个也就是data[10]。ntp_timestamp()返回的是 Unix 时间戳。要把它写入 Pico 的内部 RTC需要转成machine.RTC().datetime()需要的元组格式。这个函数需要(year, month, day, weekday, hour, minute, second, subsecond)其中 weekday 要求 1星期一而标准time.gmtime()返回的是 0星期一所以要加 1import time from machine import RTC def set_rtc_from_ntp(serverntp.aliyun.com): t ntp_timestamp(server) if t is None: return False gt time.gmtime(t) RTC().datetime((gt[0], gt[1], gt[2], gt[6] 1, gt[3], gt[4], gt[5], 0)) return True4.2 用 ntptime 模块一步校时MicroPython 官方固件自带ntptime模块它在内部封装了一套类似的 UDP 请求逻辑。正常联网后三行代码就能完成校时import ntptime ntptime.host ntp.aliyun.com ntptime.settime()ntptime.settime()成功后会直接修改machine.RTC()的时间。默认的ntptime.host是 pool.ntp.org在国内有些网络环境下连接不稳定所以我习惯改成国内服务器。下面这份服务器列表够用了服务器地址特点cn.pool.ntp.orgNTP 池中国区服务器数量多建议优先ntp.aliyun.com阿里云公共 NTP 服务响应快且稳定ntp.tencent.com腾讯公共 NTP 服务ntp.ntsc.ac.cn国家授时中心公共 NTP 服务time.windows.com微软维护Windows 自带同步源如果你的应用只是定时校准一次ntptime完全够用。但它有个小毛病超时时间写死了在无线网络信号弱的环境下可能卡住报OSError。所以我更推荐把 4.1 节那个带超时控制的自定义函数封装好配合异常处理使用。4.3 同步失败时的降级处理NTP 校时不是每次都能成功。WiFi 没连上、DNS 解析失败、UDP 被防火墙拦截都会导致异常。所以绝不能把 NTP 当作唯一的时间来源。我的习惯是先做一次 NTP 同步失败就退回读 DS3231再失败才用编译固件时记录的构建时间import ntptime from machine import RTC import rtc_ds3231 # 上文的驱动模块 def sync_time_from_network_or_rtc(ds3231): connected False try: # 假设这里有一个 connect_wifi()返回 bool connected True except Exception: connected False if connected: try: ntptime.host ntp.aliyun.com ntptime.settime() print(NTP sync OK) # 把校时结果回写到 DS3231 t RTC().datetime() ds3231.set_time(t[0], t[1], t[2], t[4], t[5], t[6], t[3]) return True except Exception as e: print(NTP sync failed:, e) return False def load_time_from_ds3231(ds3231): try: dt ds3231.get_time() RTC().datetime((dt[0], dt[1], dt[2], dt[3], dt[4], dt[5], dt[6], 0)) return True except Exception: return False这个降级链路的含义是NTP 是最权威的时间源一上线就能把设备时间拉到毫秒级准确DS3231 在设备断电期间仍然运行虽然可能有累计漂移但至少时间连续构建时间是最无奈的选择只能保证设备不至于从1970 年开始记日志。5. 让时间源可靠工作启动时序、时区转换与自动校准5.1 上电启动的优先级设计把上面的代码拼起来就形成了完整的启动时序。我推荐按这个顺序执行上电后先初始化 I2C 和 DS3231尝试连接 WiFiWiFi 连接成功后立即做 NTP 校时NTP 校时成功后把准确 UTC 时间回写 DS3231让电池备份的时间也同步刷新如果 NTP 失败读 DS3231 作为后备时间如果 DS3231 也读不出来用编译时写入的默认时间第 4 步很多人会漏掉。只做 NTP 校时而不回写 DS3231会导致 DS3231 里存的还是断电前的旧时间。下次设备断电网又没接通时DS3231 会继续从那个旧时间往后走误差越来越大。反过来每次 NTP 成功都顺手校准一次 DS3231相当于给它做定期对表即使长期断网DS3231 的守时误差也能控制在很小的范围内。5.2 时区换算的正确姿势NTP 服务器返回的时间是 UTCMicroPython 的machine.RTC()写入的也是 UTC。但绝大多数项目展示和记录日志时要用本地时间。这里有两个流派一种是把本地时间直接存进 RTC。比如国内项目固定比 UTC 快 8 小时set_time时直接在 UTC 上加 8 小时。这种做法读取方便显示直接但遇到跨时区部署或者夏令时切换的场景就麻烦了。另一种是 RTC 里始终存 UTC显示时再做偏移换算。这是我推荐的方案。换算代码很简单import time from machine import RTC def local_datetime(offset_hours8): t RTC().datetime() # 把 RTC 元组转成 Unix 时间戳再加时区偏移 ts time.mktime((t[0], t[1], t[2], t[4], t[5], t[6], 0, 0)) ts offset_hours * 3600 return time.localtime(ts)mktime的输入参数里 weekday 那一项会被忽略所以我随便填了 0不影响结果。加完偏移后再用localtime转回元组得到的年月日时分秒就是本地时间。国内固定东八区offset_hours8就够了。如果有人告诉你 MicroPython 能像 PC 一样自动处理时区数据库那是骗人的裸机环境下时区逻辑必须自己写在应用层。夏令时要不要考虑我的建议是除非项目明确部署在使用夏令时的地区否则不要给自己找这个麻烦。真要处理的话需要在应用层写一个判断 DST 生效区间的函数根据当前的月和星期动态调整偏移量复杂度会上一个台阶。5.3 长期运行的漂移补偿与 ticks 陷阱设备长期在线、但网络偶发中断时DS3231 的漂移会主导时间误差。前面说过 DS3231 精度约 ±2ppm一天误差不到 0.2 秒。但如果你希望它更准可以做一个简单的软件校准记录一段时间内 DS3231 与 NTP 的差值算出每 24 小时的漂移量然后每天固定时间把这个漂移量补偿回去。比如测得 DS3231 每天慢 0.5 秒那就在每天某个整点把时间往回拨 0.5 秒。这个思路比改芯片内部的 aging 寄存器简单直观代码也容易维护。还有一个容易被踩的坑NTP 校时成功后time.time()的值会发生跳变。如果你的程序里同时用了绝对时间的日志记录和相对时间的延时调度不要用time.time()的差值做延时。MicroPython 专门提供了单调时钟接口time.ticks_ms()和time.ticks_diff()它们不受系统时间跳变影响。调度周期、超时判断一律用 ticks只有打日志时才用绝对时间。6. 实战复盘那些耗掉我一下午的坑6.1 I2C 扫描无响应的定位过程有一次我焊接了一套传感器板DS3231 死活扫描不到地址。我先怀疑是模块坏了换了一块新的还是不行。后来用万用表量 SDA 和 SCL 的电平才发现我在接线时把 GP0 和 GP1 插反了。I2C 不像 UART 那样接反一定完全不通SCL 和 SDA 互换后有时还能碰巧正常传几个字节但通信极不稳定表现为扫描时有时无、偶尔能读到错误数据。所以遇到扫描异常第一步永远是确认 SDA/SCL 线序而不是换模块。换线之后还遇到过一次OSError: [Errno 5] EIO这是在readfrom_mem阶段报的。原因是我用了 400kHz 频率但我的杜邦线是从一个旧机箱里拆出来的长度接近 20cm线路电容太大。把freq降到 100000 之后问题消失。长线场景下宁可慢一点稳定最重要。6.2 OSF 标志位怎么用排查完线序和频率我又踩了第三个坑。新模块第一次上电我用get_time()读出来的时间非常离谱显示年份是 2000。这个年值就是 DS3231 出厂默认的 2000 年正常。我设置了正确时间后重新读发现 OSF 标志位仍然是 1。查了一圈原因竟是模块上的 CR2032 电池接触弹片和电池之间有一层保护膜我没有撕掉。电池没有真正供电芯片每次一断电就停振OSF 自然清不掉。这个经验可以总结成一条排查规则如果设置了时间之后读回的时间在断电后再次复位优先检查三件事——电池保护膜是否撕掉、电池槽弹片是否接触良好、电池电压是否大于 2.5V。而我见过不止一个人在这个问题上耗掉大半天最后发现就是电池问题。6.3 不要频繁请求公共 NTP 服务器NTP 虽然轻量但公共 NTP 服务器都有请求频率限制配合严格的访问策略。有些人写代码时图省事在主循环里每分钟调一次ntptime.settime()结果被服务器封禁 IP。就设备校时这个场景上电同步一次之后每隔 24 小时同步一次已经非常奢侈了。如果对时间精度要求苛刻可以考虑在本地局域网部署一个 NTP 服务器让所有设备向它请求既准又不打扰公共资源。6.4 关于更高精度的同步方向NTP 的精度对大多数物联网设备足够了但在工业控制场景里如果需要微秒级同步那就要转向 IEEE 1588 PTPPrecision Time Protocol了。PTP 通过硬件时间戳和最佳主时钟算法可以把同步误差压到亚微秒量级。不过 PTP 依赖网络交换机的硬件支持普通 WiFi 模块和交换机根本跑不出这个精度。MicroPython 生态里目前也没有成熟的 PTP 实现所以我的建议是认清需求边界——记录数据、定时控制用 NTP 完全够真到了需要分布式精准联动的程度应该考虑带硬件时间戳的专用芯片和实时操作系统而不是在 Pico 上硬撑。最后再分享一个可以往外扩展的思路。这套内部 RTC 保运行 外部 RTC 保断电 NTP 保绝对准确的三层时间架构其实不止适用于 Pico 和 MicroPython。把驱动代码换成 CircuitPython 的库或者换成 C 语言固件里的对应寄存器操作整体设计思路是完全可以平移的。我给同事做 ESP32 项目时就是把 DS3231 的寄存器操作换成了 ESP32 的machine.I2C接口其余时序和降级逻辑一行没改。下次你遇到任何自带 RTC 的单片机先别急着买带电池的扩展板想想是让它直接断电保存时间还是有外部 RTC 更省事——大多数场景下一颗几块钱的 DS3231 模块远比你在芯片内部校准上花的时间便宜。