文章目录
-
- 一、项目概述
-
- [1.1 背景与痛点](#1.1 背景与痛点)
- [1.2 方案选型](#1.2 方案选型)
- [1.3 系统架构](#1.3 系统架构)
- 二、核心原理
-
- [2.1 TCXO 温度补偿原理](#2.1 TCXO 温度补偿原理)
- [2.2 寄存器映射与 BCD 编码](#2.2 寄存器映射与 BCD 编码)
- [2.3 时间读写的 I2C 时序](#2.3 时间读写的 I2C 时序)
- [2.4 闹钟与方波的工作机制](#2.4 闹钟与方波的工作机制)
- 三、硬件选型清单
- 四、硬件接线
- 五、软件设计
-
- [5.1 CubeMX 配置(寄存器级解释)](#5.1 CubeMX 配置(寄存器级解释))
- [5.2 容易遗漏的步骤:硬件 I2C 首次上电 BUSY 死锁](#5.2 容易遗漏的步骤:硬件 I2C 首次上电 BUSY 死锁)
- [5.3 驱动代码实现](#5.3 驱动代码实现)
- [5.4 主程序逻辑](#5.4 主程序逻辑)
- 六、测试验证
-
- [6.1 功能测试](#6.1 功能测试)
- [6.2 时间精度实测(72 小时日误差)](#6.2 时间精度实测(72 小时日误差))
- [6.3 理论 vs 实测对照](#6.3 理论 vs 实测对照)
- [6.4 量化参数对比:方波频率档位扫描](#6.4 量化参数对比:方波频率档位扫描)
- 七、故障排查
-
- [7.1 I2C 通信失败、读不到 ACK(最常见)](#7.1 I2C 通信失败、读不到 ACK(最常见))
- [7.2 硬件 I2C BUSY 死锁(上电即卡死)](#7.2 硬件 I2C BUSY 死锁(上电即卡死))
- [7.3 秒数不动、时间不更新](#7.3 秒数不动、时间不更新)
- [7.4 BCD 解码错乱(时间显示乱值)](#7.4 BCD 解码错乱(时间显示乱值))
- [7.5 闹钟到点不触发](#7.5 闹钟到点不触发)
- [7.6 方波无输出](#7.6 方波无输出)
- [7.7 温度读数异常(负值乱码或恒为 0℃)](#7.7 温度读数异常(负值乱码或恒为 0℃))
- 八、总结
-
- [8.1 要点回顾](#8.1 要点回顾)
- [8.2 适用边界与局限](#8.2 适用边界与局限)
- [8.3 扩展方向](#8.3 扩展方向)
- 参考资料
嵌入式项目里日志时间戳、定时任务、数据记录都离不开一个"断电也能走"的时钟源,但 STM32 内部 RTC 受外部 LSE 晶振温漂影响,日误差经常到秒级,而且一断电时间就归零。本文基于 STM32F103C8T6 用硬件 I2C 驱动 DS3231 温补 RTC 芯片,实现时间读写、温度读取、双闹钟、方波秒脉冲与电池掉电保持。实测 72 小时累计误差 +0.9s(约 +3.5ppm,符合官方 0~40℃ ±2ppm 指标);1Hz 方波实测 0.999998 Hz,用作秒中断后时间更新抖动小于 1ms;400kHz I2C 突发读 7 字节实测 190μs,与理论值偏差 1.2%。文章完整覆盖 BCD 解码、闹钟掩码位、硬件 I2C 死锁恢复等高频踩坑点,附 CubeMX 配置与工程级代码。
一、项目概述
1.1 背景与痛点
很多嵌入式系统最终都要和"时间"打交道:设备日志需要时间戳、传感器数据需要按时间归档、定时采样需要精确的触发时刻。最初我在一个数据记录仪项目里直接用 STM32F103 的内部 RTC,用了一个 32.768kHz 的外部低速晶振(LSE)。常温下勉强能用,但问题在两点上暴露得特别明显:
一是精度受晶振个体和温度影响大 。普通 32.768kHz 晶振的频偏通常在 ±20ppm 到 ±100ppm 之间,换算成日误差就是每天 1.7~8.6 秒。夏天车间里温度一上来,日志里的时间戳肉眼可见地偏快。二是断电即丢。内部 RTC 的 VBAT 引脚虽然能接电池,但很多人(包括最初的我)根本没留备份电源,设备一断电,重新上电时间直接回到 2000 年,之前带时间戳的数据全乱套。
1.2 方案选型
要解决"精度 + 掉电保持",市面上常见的 RTC 芯片有三条路线,我做了个对比再决定:
| 方案 | 精度 | 晶振 | 掉电保持 | 价格 | 典型代表 |
|---|---|---|---|---|---|
| 内部 RTC + 外部晶振 | ±20~100ppm,温漂大 | 外置 32.768kHz | 需额外接 VBAT | 0(芯片自带) | STM32 内部 RTC |
| 普通外置 RTC | ±5~20ppm | 外置 32.768kHz | 有 VBAT 引脚 | 1~2 元 | DS1307 / PCF8563 |
| 温补 RTC(TCXO 集成) | ±2ppm(0~40℃) | 内部集成,免外部晶振 | 有 VBAT 引脚 | 5~10 元 | DS3231 |
选 DS3231 而不是 DS1307 的核心原因有两点:第一,它把 32.768kHz 晶振和温补电路全部集成在芯片内部,外部连一颗电容都不用加,消除了外置晶振的寄生电容和温漂问题------这属于"少一个元件就少一个故障点"的典型思路;第二,也是我最看重的,它内部每 64 秒自动测温一次并修正振荡频率,官方标称 0~40℃ 范围内精度 ±2ppm,换算成日误差约 ±0.17 秒,比内部 RTC 高一个数量级。DS3231 的 I2C 从机地址为 0x68,通信时序标准,驱动难度不大。
相关阅读:《STM32初学-外部RTC时钟芯片DS3231》 --- 对 RTC 原理和 DS3231 内部结构的入门级讲解,适合先建立整体印象。
1.3 系统架构
整套系统的数据流如下:STM32F103 通过硬件 I2C1 与 DS3231 交换时间/闹钟/温度数据;DS3231 的 INT/SQW 引脚配置为 1Hz 方波输出,接到 PA0 的 EXTI0 中断,每秒钟触发一次"读时间"动作;模块自带 CR2032 电池,主电源掉电后由 VBAT 继续供电走时。本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。
#mermaid-svg-WtYfChFwURt69V9p{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WtYfChFwURt69V9p .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WtYfChFwURt69V9p .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WtYfChFwURt69V9p .error-icon{fill:#552222;}#mermaid-svg-WtYfChFwURt69V9p .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WtYfChFwURt69V9p .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WtYfChFwURt69V9p .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WtYfChFwURt69V9p .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WtYfChFwURt69V9p .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WtYfChFwURt69V9p .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WtYfChFwURt69V9p .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WtYfChFwURt69V9p .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WtYfChFwURt69V9p .marker.cross{stroke:#333333;}#mermaid-svg-WtYfChFwURt69V9p svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WtYfChFwURt69V9p p{margin:0;}#mermaid-svg-WtYfChFwURt69V9p .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WtYfChFwURt69V9p .cluster-label text{fill:#333;}#mermaid-svg-WtYfChFwURt69V9p .cluster-label span{color:#333;}#mermaid-svg-WtYfChFwURt69V9p .cluster-label span p{background-color:transparent;}#mermaid-svg-WtYfChFwURt69V9p .label text,#mermaid-svg-WtYfChFwURt69V9p span{fill:#333;color:#333;}#mermaid-svg-WtYfChFwURt69V9p .node rect,#mermaid-svg-WtYfChFwURt69V9p .node circle,#mermaid-svg-WtYfChFwURt69V9p .node ellipse,#mermaid-svg-WtYfChFwURt69V9p .node polygon,#mermaid-svg-WtYfChFwURt69V9p .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WtYfChFwURt69V9p .rough-node .label text,#mermaid-svg-WtYfChFwURt69V9p .node .label text,#mermaid-svg-WtYfChFwURt69V9p .image-shape .label,#mermaid-svg-WtYfChFwURt69V9p .icon-shape .label{text-anchor:middle;}#mermaid-svg-WtYfChFwURt69V9p .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WtYfChFwURt69V9p .rough-node .label,#mermaid-svg-WtYfChFwURt69V9p .node .label,#mermaid-svg-WtYfChFwURt69V9p .image-shape .label,#mermaid-svg-WtYfChFwURt69V9p .icon-shape .label{text-align:center;}#mermaid-svg-WtYfChFwURt69V9p .node.clickable{cursor:pointer;}#mermaid-svg-WtYfChFwURt69V9p .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WtYfChFwURt69V9p .arrowheadPath{fill:#333333;}#mermaid-svg-WtYfChFwURt69V9p .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WtYfChFwURt69V9p .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WtYfChFwURt69V9p .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WtYfChFwURt69V9p .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WtYfChFwURt69V9p .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WtYfChFwURt69V9p .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WtYfChFwURt69V9p .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WtYfChFwURt69V9p .cluster text{fill:#333;}#mermaid-svg-WtYfChFwURt69V9p .cluster span{color:#333;}#mermaid-svg-WtYfChFwURt69V9p div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WtYfChFwURt69V9p .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WtYfChFwURt69V9p rect.text{fill:none;stroke-width:0;}#mermaid-svg-WtYfChFwURt69V9p .icon-shape,#mermaid-svg-WtYfChFwURt69V9p .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WtYfChFwURt69V9p .icon-shape p,#mermaid-svg-WtYfChFwURt69V9p .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WtYfChFwURt69V9p .icon-shape .label rect,#mermaid-svg-WtYfChFwURt69V9p .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WtYfChFwURt69V9p .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WtYfChFwURt69V9p .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WtYfChFwURt69V9p :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} DS3231 模块
STM32F103C8T6
SCL/SDA
1Hz 方波
硬件 I2C1
PB6=SCL, PB7=SDA
EXTI0 中断
PA0
USART1 串口
时间打印/调试
应用逻辑
时间解析/闹钟判断
DS3231 主芯片
内部 32.768kHz TCXO
每64s温补
内置温度传感器
分辨率0.25℃
双闹钟 A1/A2
INT/SQW 输出
CR2032 电池
二、核心原理
2.1 TCXO 温度补偿原理
DS3231 精度高的秘密在"温度补偿"这四个字上。普通晶振的振荡频率会随温度漂移,典型 32.768kHz 晶振在 -40℃ 到 +85℃ 范围内的频偏曲线近似一条抛物线,常温附近相对平缓,两头翘起,偏差最大能到 ±100ppm 以上。DS3231 的做法是:芯片内部集成一颗经过出厂校准的 32.768kHz 晶振和一个温度传感器,每 64 秒自动测量一次芯片温度,查表得到一个补偿系数,用这个系数去微调振荡器负载电容,把晶振拉回标称频率。
这个补偿是全自动的,软件层面完全无感,但有两个地方会误导人:一是很多人以为要自己读温度寄存器去"手动补偿",其实不用------温度寄存器读出来的值只是给你显示用的,补偿在芯片内部闭环完成;二是如果模块上的电池没装,或者控制寄存器 EOSC 位被意外置 1 导致振荡器停止,那么整个温补机制都无从谈起,这是后面故障排查部分的一个大坑。
2.2 寄存器映射与 BCD 编码
DS3231 的时间寄存器从 0x00 到 0x06,共 7 个字节,覆盖秒、分、时、星期、日、月、年。所有时间字段都以 BCD 码存储,这是第一个容易踩坑的地方。
| 地址 | 寄存器 | bit7 | bit6 | bit5 | bit4 | bit3 | bit2 | bit1 | bit0 | 取值范围 |
|---|---|---|---|---|---|---|---|---|---|---|
| 0x00 | 秒 | CH | 秒十位 | 秒十位 | 秒十位 | 秒个位 | 秒个位 | 秒个位 | 秒个位 | 00~59 |
| 0x01 | 分 | 0 | 分十位 | 分十位 | 分十位 | 分个位 | 分个位 | 分个位 | 分个位 | 00~59 |
| 0x02 | 时 | 0 | 12/24 | AM/PM 或 时十位 | 时十位 | 时个位 | 时个位 | 时个位 | 时个位 | 01~12 或 00~23 |
| 0x03 | 星期 | 0 | 0 | 0 | 0 | 0 | 星期值 | 星期值 | 星期值 | 1~7 |
| 0x04 | 日 | 0 | 0 | 日十位 | 日十位 | 日个位 | 日个位 | 日个位 | 日个位 | 01~31 |
| 0x05 | 月/世纪 | 世纪 | 0 | 0 | 月十位 | 月个位 | 月个位 | 月个位 | 月个位 | 01~12 |
| 0x06 | 年 | 年十位 | 年十位 | 年十位 | 年十位 | 年个位 | 年个位 | 年个位 | 年个位 | 00~99 |
BCD 编码的意思是:一个字节的高 4 位存十位数,低 4 位存个位数。比如"秒 = 47",寄存器里就是 0x47,而不是十进制的 0x2F。初次写驱动时如果直接按十六进制读出来当十进制打印,会看到"0x47"这样的怪值。
两个特别容易翻车的寄存器位:
- 0x00 秒寄存器的 bit7(CH 位):时钟停止位,默认上电为 1 表示振荡器停止,必须写成 0 才能走时。很多人初始化时只写了时间,没管这个位,结果秒数永远不动。
- 0x02 时寄存器的 bit6(12/24 位):1 表示 12 小时制,此时 bit5 是 AM/PM 标志;0 表示 24 小时制,bit5~bit4 才是小时的十位。如果把 12 小时制下 bit5 的 AM/PM 标志当成数据解析,下午的时间会整个错乱。
控制寄存器 0x0E 和状态寄存器 0x0F 的位定义直接影响闹钟和方波,稍后在软件设计部分展开。
2.3 时间读写的 I2C 时序
读时间推荐用突发读(burst read):先写目标寄存器地址 0x00,然后连续读 7 个字节,DS3231 内部地址会自动递增。这样做的意义在于,7 个字节一次性读回,能避免"读完秒寄存器后正好跨秒"导致的时间数据不一致问题。
DS3231 (Slave 0x68) STM32 (Master) DS3231 (Slave 0x68) STM32 (Master) #mermaid-svg-q2YVfaB3LM2GeDh4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-q2YVfaB3LM2GeDh4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-q2YVfaB3LM2GeDh4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-q2YVfaB3LM2GeDh4 .error-icon{fill:#552222;}#mermaid-svg-q2YVfaB3LM2GeDh4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-q2YVfaB3LM2GeDh4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-q2YVfaB3LM2GeDh4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-q2YVfaB3LM2GeDh4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-q2YVfaB3LM2GeDh4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-q2YVfaB3LM2GeDh4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-q2YVfaB3LM2GeDh4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-q2YVfaB3LM2GeDh4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-q2YVfaB3LM2GeDh4 .marker.cross{stroke:#333333;}#mermaid-svg-q2YVfaB3LM2GeDh4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-q2YVfaB3LM2GeDh4 p{margin:0;}#mermaid-svg-q2YVfaB3LM2GeDh4 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-q2YVfaB3LM2GeDh4 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-q2YVfaB3LM2GeDh4 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-q2YVfaB3LM2GeDh4 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-q2YVfaB3LM2GeDh4 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-q2YVfaB3LM2GeDh4 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-q2YVfaB3LM2GeDh4 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-q2YVfaB3LM2GeDh4 .sequenceNumber{fill:white;}#mermaid-svg-q2YVfaB3LM2GeDh4 #sequencenumber{fill:#333;}#mermaid-svg-q2YVfaB3LM2GeDh4 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-q2YVfaB3LM2GeDh4 .messageText{fill:#333;stroke:none;}#mermaid-svg-q2YVfaB3LM2GeDh4 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-q2YVfaB3LM2GeDh4 .labelText,#mermaid-svg-q2YVfaB3LM2GeDh4 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-q2YVfaB3LM2GeDh4 .loopText,#mermaid-svg-q2YVfaB3LM2GeDh4 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-q2YVfaB3LM2GeDh4 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-q2YVfaB3LM2GeDh4 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-q2YVfaB3LM2GeDh4 .noteText,#mermaid-svg-q2YVfaB3LM2GeDh4 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-q2YVfaB3LM2GeDh4 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-q2YVfaB3LM2GeDh4 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-q2YVfaB3LM2GeDh4 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-q2YVfaB3LM2GeDh4 .actorPopupMenu{position:absolute;}#mermaid-svg-q2YVfaB3LM2GeDh4 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-q2YVfaB3LM2GeDh4 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-q2YVfaB3LM2GeDh4 .actor-man circle,#mermaid-svg-q2YVfaB3LM2GeDh4 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-q2YVfaB3LM2GeDh4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} START + 0xD0 (写地址) + 寄存器0x00 STOP(Mem_Read 内部完成) START + 0xD1 (读地址) 秒 (0x00) 分 (0x01) 时 (0x02) 星期 (0x03) 日 (0x04) 月 (0x05) 年 (0x06) + NACK STOP
这里有一个细节:HAL 库的 HAL_I2C_Mem_Read 会把"写地址 + 读地址"的重复起始条件(repeated START)封装好,我们不需要自己拼 I2C 帧。但要注意从机地址是 7 位地址 0x68,函数内部会自动左移一位变成 0xD0(写)/ 0xD1(读),如果自己手动拼帧时写错了左移方向,就会出现一直收不到 ACK 的问题。
2.4 闹钟与方波的工作机制
DS3231 有 A1、A2 两个闹钟。A1 可以精确到秒(秒/分/时/日四个字段都比对),A2 只能到分(分/时/日三个字段)。每个字段前面都有一个掩码位 :A1M1A1M4、A2M2A2M4。掩码位的语义和直觉相反------置 1 表示忽略该字段,置 0 表示该字段参与匹配。
举个容易搞错的例子:要做一个"每天 8 点 30 分触发"的闹钟,A2 的三组掩码应该是 A2M4=1(忽略日期/星期)、A2M3=0(时参与匹配)、A2M2=0(分参与匹配)。如果理解反了,写成全 0,闹钟就会变成"只在某个特定日期的 8:30 触发一次",第二天就不响了。
闹钟触发后,状态寄存器 0x0F 的 A1F/A2F 标志置 1,同时 INT/SQW 引脚拉低(如果使能了中断输出)。注意这个标志位需要软件手动清零,而且清零的正确姿势是"读状态寄存器 → 把要清的位置 0 → 写回",不能直接写整个字节,否则会误清其他标志位。
INT/SQW 引脚还有一个坑:它默认配置为闹钟中断输出 (控制寄存器 INTCN=1),此时不会输出方波。想让它输出方波,必须把 INTCN 清 0,再通过 RS2/RS1 两位选择频率(1Hz / 1kHz / 4.096kHz / 8.192kHz)。同一时刻,INT/SQW 要么当闹钟中断,要么当方波输出,两者只能选一个------这个"二选一"约束是我在项目里踩过的最深的坑,详见故障排查第 6 节。
三、硬件选型清单
| 器件 | 型号/规格 | 数量 | 说明 |
|---|---|---|---|
| 主控板 | STM32F103C8T6 最小系统板 | 1 | 蓝板即可,APB1 时钟 36MHz |
| RTC 模块 | DS3231 模块(含 CR2032 电池座) | 1 | 模块已集成电池、上拉电阻 |
| 电池 | CR2032 纽扣电池 | 1 | 掉电保持供电 |
| 调试串口 | USB-TTL 模块 + 杜邦线 | 1 | 打印时间数据,115200-8-N-1 |
| 上拉电阻 | 4.7kΩ(模块自带上拉时可省) | 2 | 部分模块上拉已集成 |
| 面包板/杜邦线 | --- | 若干 | 搭建测试电路 |
选模块时留意两点:一是确认模块上 SCL/SDA 是否已经带上拉电阻 ,多数模块带了 4.7kΩ 上拉,这时 STM32 侧就不用重复加;二是确认电池座的接触簧片质量,便宜的模块电池座容易接触不良,直接导致掉电时间丢失------这个问题我们后面实测时真的遇到了。
四、硬件接线
DS3231 模块与 STM32F103C8T6 的接线如下:
| DS3231 模块引脚 | STM32 引脚 | 说明 |
|---|---|---|
| VCC | 3.3V | 模块标 3.3~5.5V,建议统一 3.3V 供电 |
| GND | GND | 共地 |
| SCL | PB6(I2C1_SCL) | 复用开漏输出,外部上拉 |
| SDA | PB7(I2C1_SDA) | 复用开漏输出,外部上拉 |
| INT/SQW | PA0(EXTI0) | 1Hz 方波输入,需外部上拉(开漏输出) |
| 32K | 悬空 | 32.768kHz 输出,本设计不用 |
两个接线要点:
- INT/SQW 引脚是开漏输出,必须外部上拉(模块上通常没给这个脚接上拉,需要自己加 10kΩ 到 3.3V),否则示波器上看不到方波、闹钟中断也拉不低。我第一次就是漏了这颗上拉电阻,排查了半天。
- VCC 和 VBAT 的关系:模块上的 VCC 接 3.3V 主电源,CR2032 接 VBAT。芯片内部有电源切换电路,主电源存在时优先用主电源,主电源掉电自动切到 VBAT。只要电池在位且电量正常,断电期间时间照走。
五、软件设计
5.1 CubeMX 配置(寄存器级解释)
1. I2C1 配置
在 CubeMX 里启用 I2C1,引脚自动分配 PB6/PB7。关键参数:
| 配置项 | 值 | 寄存器级解释 |
|---|---|---|
| I2C Speed Mode | Fast Mode (400kHz) | I2C_CCR 寄存器 F/S=1(快速模式);APB1=36MHz 时 CCR=30,实际由 CubeMX 自动计算 |
| Clock No Stretch Mode | Disabled | 允许从机时钟拉伸,DS3231 不支持拉伸但保持默认即可 |
| Duty Cycle | 2:1 | I2C_CCR DUTY=0,Fast Mode 下 Tlow=2×CCR×TPCLK、Thigh=CCR×TPCLK |
400kHz 速率下 CCR 的计算:Fast Mode(DUTY=0)时 SCL 周期 = 3 × CCR × T_PCLK1,目标周期 2.5μs,T_PCLK1 = 1/36MHz ≈ 27.78ns,解得 CCR = 2.5μs / (3 × 27.78ns) ≈ 30。CubeMX 生成的代码里就是 I2C1->CCR = I2C_CCR_FS | I2C_CCR_DUTY | 30。
相关阅读:《STM32F103C8T6驱动DS3231时钟模块:从CubeMX配置到HAL库I2C读写避坑指南》 --- 同为 F103 + DS3231 的 CubeMX 配置流程,可对照阅读。
2. GPIO 配置
PB6/PB7 由 CubeMX 自动配置为 I2C 复用开漏模式,对应寄存器 GPIOB->CRL 的 CNF1:0=10(复用开漏)、MODE1:0=11(50MHz 输出)。不要手动改成推挽输出------I2C 是线与总线,推挽输出会破坏 ACK 应答机制。GPIO 初始化代码会自动生成,无需手写。
3. PA0 外部中断(EXTI0)
PA0 配置为 GPIO_MODE_IT_FALLING(下降沿触发),对应 EXTI 的 EXTI0 通道,NVIC 使能 EXTI0_IRQn。寄存器级:EXTICR0 的 EXTI0 映射到 PA(EXTI0_CR=0000 表示 PA0),EXTIMR 使能 EXTI0 屏蔽位,EXTIFTSR 选择下降沿。
4. USART1
115200-8-N-1,重定向 printf 到串口,方便打印时间。
5.2 容易遗漏的步骤:硬件 I2C 首次上电 BUSY 死锁
这是 CubeMX 和 HAL 库不会自动处理 的一步,也是硬件 I2C 的经典坑:芯片复位后,如果 I2C 总线上 SDA 在复位瞬间恰好被拉低(比如 DS3231 模块比 MCU 先上电,SCL/SDA 上拉了而 MCU 的 I2C 外设还没初始化),I2C 外设的 BUSY 位(I2C_SR2 寄存器的 bit1)会被置 1,且永远不会自动清除 。表现就是第一次调用 HAL_I2C_Mem_Read 直接返回 HAL_BUSY,程序卡死在超时重试里。
为什么工具不自动生成这段代码?因为 HAL 库假设"总线在初始化时是干净的",而上电时序导致的 SDA 毛刺它管不着,属于硬件层面的问题,只能靠软件补救。补救的标准做法是:初始化时如果检测到 BUSY,把 PB6/PB7 临时切换成普通 GPIO 输出,手动给 SCL 敲 9 个脉冲把挂在总线上的从机"复位"掉,再切回 I2C 复用模式重新初始化。详细排查过程见故障排查第 2 节,这里先给出初始化代码里的处理:
c
static void I2C1_Bus_Release(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
/* 先把 PB6/PB7 切成普通推挽输出,接管总线 */
GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
/* 手动敲 9 个 SCL 时钟,让可能卡在传输中的从机释放 SDA */
for (uint8_t i = 0; i < 9; i++)
{
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET);
HAL_Delay(1);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET);
HAL_Delay(1);
}
/* 复位 I2C 外设并重新按复用开漏初始化 */
__HAL_I2C_DISABLE(&hi2c1);
GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7;
GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
__HAL_I2C_ENABLE(&hi2c1);
}
这段代码放在 MX_I2C1_Init() 之后、第一次读写之前调用。判断是否需要执行,就看 __HAL_I2C_GET_FLAG(&hi2c1, I2C_FLAG_BUSY) 是否置位。这个坑不填,硬件 I2C 方案基本没法稳定跑起来。
5.3 驱动代码实现
DS3231 驱动分三个部分:BCD 转换、时间/温度读写、闹钟与方波配置。核心代码基于 HAL 库,全部带返回值检查。
ds3231.h 关键定义:
c
#ifndef __DS3231_H
#define __DS3231_H
#include "main.h"
/* DS3231 I2C 从机地址(7 位地址 0x68,读写时 HAL 自动左移) */
#define DS3231_ADDR 0x68
/* 寄存器地址 */
#define DS3231_REG_SEC 0x00
#define DS3231_REG_MIN 0x01
#define DS3231_REG_HOUR 0x02
#define DS3231_REG_WDAY 0x03
#define DS3231_REG_DAY 0x04
#define DS3231_REG_MONTH 0x05
#define DS3231_REG_YEAR 0x06
#define DS3231_REG_CTRL 0x0E
#define DS3231_REG_STATUS 0x0F
#define DS3231_REG_TEMP_MSB 0x11
#define DS3231_REG_TEMP_LSB 0x12
/* 控制寄存器 0x0E 位定义 */
#define DS3231_CTRL_EOSC (1 << 7) /* 1=停止振荡(省电),0=正常振荡 */
#define DS3231_CTRL_BBSQW (1 << 6) /* 电池模式下方波输出使能 */
#define DS3231_CTRL_CONV (1 << 5) /* 1=触发一次温度转换 */
#define DS3231_CTRL_INTCN (1 << 2) /* 1=闹钟中断输出,0=方波输出 */
#define DS3231_CTRL_A2IE (1 << 1) /* 闹钟2中断使能 */
#define DS3231_CTRL_A1IE (1 << 0) /* 闹钟1中断使能 */
/* 状态寄存器 0x0F 位定义 */
#define DS3231_STATUS_OSF (1 << 7) /* 1=振荡器停止标志(上电默认1) */
#define DS3231_STATUS_EN32KHZ (1 << 3) /* 32kHz 引脚输出使能 */
#define DS3231_STATUS_BSY (1 << 2) /* 温度转换忙标志 */
#define DS3231_STATUS_A2F (1 << 1) /* 闹钟2触发标志 */
#define DS3231_STATUS_A1F (1 << 0) /* 闹钟1触发标志 */
typedef struct {
uint8_t sec; /* 0~59 */
uint8_t min; /* 0~59 */
uint8_t hour; /* 0~23(24小时制) */
uint8_t wday; /* 1~7(1=周日) */
uint8_t day; /* 1~31 */
uint8_t month; /* 1~12 */
uint8_t year; /* 0~99(2000 年偏移) */
} DS3231_Time_t;
uint8_t DS3231_Init(void);
uint8_t DS3231_ReadTime(DS3231_Time_t *t);
uint8_t DS3231_WriteTime(const DS3231_Time_t *t);
uint8_t DS3231_ReadTemp(float *temp_c);
uint8_t DS3231_ReadTempRaw(int16_t *temp_raw);
uint8_t DS3231_SetAlarm2(uint8_t hour, uint8_t min, uint8_t day_en, uint8_t wday_match);
uint8_t DS3231_ClearAlarmFlag(uint8_t alarm);
uint8_t DS3231_SetSquareWave(uint8_t freq_idx);
#endif
ds3231.c 核心实现:
c
#include "ds3231.h"
#include "i2c.h"
extern I2C_HandleTypeDef hi2c1;
/* BCD → 十进制。非法 BCD(任一 4 位 >9)返回 0xFF,由调用方丢弃该字段 */
static uint8_t bcd2dec(uint8_t bcd)
{
uint8_t lo = bcd & 0x0F;
uint8_t hi = (bcd >> 4) & 0x0F;
/* 防越界保护:BCD 高位/低位不可能大于 9,出现即视为数据异常 */
if (lo > 9 || hi > 9)
{
return 0xFF;
}
return hi * 10 + lo;
}
/* 十进制 → BCD,输入超过 99 直接截断保护 */
static uint8_t dec2bcd(uint8_t dec)
{
if (dec > 99) dec = 99; /* 防越界 */
return ((dec / 10) << 4) | (dec % 10);
}
/* 初始化:检查并清除振荡停止标志,确保芯片在走时状态 */
uint8_t DS3231_Init(void)
{
uint8_t status = 0;
/* 先读状态寄存器 */
if (HAL_I2C_Mem_Read(&hi2c1, DS3231_ADDR << 1, DS3231_REG_STATUS,
I2C_MEMADD_SIZE_8BIT, &status, 1, 50) != HAL_OK)
{
return 1; /* 返回值检查:I2C 读失败直接报错 */
}
/* OSF=1 说明振荡器曾停止(首次上电或掉电过),清零让它恢复走时 */
if (status & DS3231_STATUS_OSF)
{
status &= ~DS3231_STATUS_OSF; /* 读-改-写,不能直接整字节清零 */
if (HAL_I2C_Mem_Write(&hi2c1, DS3231_ADDR << 1, DS3231_REG_STATUS,
I2C_MEMADD_SIZE_8BIT, &status, 1, 50) != HAL_OK)
{
return 2;
}
}
return 0;
}
读时间用突发读 7 字节,一次性取回完整时间,避免跨秒窗口导致的时间不一致:
c
uint8_t DS3231_ReadTime(DS3231_Time_t *t)
{
uint8_t buf[7];
uint8_t hour = 0;
if (t == NULL) return 1;
/* 从 0x00 起突发读 7 字节(秒~年),地址自动递增 */
if (HAL_I2C_Mem_Read(&hi2c1, DS3231_ADDR << 1, DS3231_REG_SEC,
I2C_MEMADD_SIZE_8BIT, buf, 7, 100) != HAL_OK)
{
return 2;
}
t->sec = bcd2dec(buf[0] & 0x7F); /* 秒:bit7 是 CH 位,屏蔽掉 */
t->min = bcd2dec(buf[1] & 0x7F); /* 分:bit7 保留位,屏蔽 */
hour = buf[2];
t->wday = bcd2dec(buf[3] & 0x07); /* 星期:仅低 3 位有效 */
t->day = bcd2dec(buf[4] & 0x3F); /* 日:屏蔽 bit7~bit6 */
t->month = bcd2dec(buf[5] & 0x1F); /* 月:屏蔽 bit7(世纪位)和 bit6~bit5 */
t->year = bcd2dec(buf[6]);
/* 时寄存器:强制按 24 小时制解析(bit6=0),
若芯片处于 12 小时制(bit6=1),先换算避免 AM/PM 标志污染数据 */
if (hour & 0x40)
{
hour &= 0x3F; /* 屏蔽 12/24 位 */
if (hour & 0x20) /* bit5=PM */
{
hour = (hour & 0x1F) + 12; /* PM 小时 +12 */
if (hour == 24) hour = 12; /* PM12 → 12 */
}
else
{
hour = hour & 0x1F; /* AM 小时不变 */
}
}
else
{
hour &= 0x3F; /* 24 小时制,屏蔽 bit7~bit6 */
}
t->hour = bcd2dec(hour);
/* 非法 BCD 校验:任一字段解析出 0xFF 即视为读取异常 */
if (t->sec == 0xFF || t->min == 0xFF || t->hour == 0xFF ||
t->day == 0xFF || t->month == 0xFF || t->year == 0xFF)
{
return 3;
}
return 0;
}
写时间时,建议先写年/月/日,最后写秒。原因:如果先写秒,紧接着写分/时期间正好跨秒,芯片会用旧的分钟数据和新秒组合,产生 1 秒的不一致;把秒放最后写,则写入完成瞬间时间就立刻对齐:
c
uint8_t DS3231_WriteTime(const DS3231_Time_t *t)
{
uint8_t buf[7];
if (t == NULL) return 1;
if (t->year > 99 || t->month < 1 || t->month > 12 ||
t->day < 1 || t->day > 31 || t->hour > 23 || t->min > 59 || t->sec > 59)
{
return 2; /* 参数范围检查,防越界 */
}
buf[6] = dec2bcd(t->year);
buf[5] = dec2bcd(t->month) & 0x1F; /* 屏蔽 bit7(世纪位)~bit5,保持 BCD 合法 */
buf[4] = dec2bcd(t->day);
buf[3] = dec2bcd(t->wday) & 0x07; /* 星期仅低 3 位有效 */
buf[2] = dec2bcd(t->hour) & 0x3F; /* bit6 保持 0 = 24 小时制 */
buf[1] = dec2bcd(t->min);
buf[0] = dec2bcd(t->sec) & 0x7F; /* bit7(CH) 保持 0 = 振荡器运行 */
/* 注意写入顺序:从分开始连续写 6 字节(分/时/星期/日/月/年),秒最后单独写。
0x01 起 6 字节依次对应 分(0x01)、时(0x02)、星期(0x03)、日(0x04)、月(0x05)、年(0x06) */
if (HAL_I2C_Mem_Write(&hi2c1, DS3231_ADDR << 1, 0x01,
I2C_MEMADD_SIZE_8BIT, &buf[1], 6, 100) != HAL_OK)
{
return 3;
}
/* 最后单独写秒,保证跨寄存器写入的一致性 */
if (HAL_I2C_Mem_Write(&hi2c1, DS3231_ADDR << 1, DS3231_REG_SEC,
I2C_MEMADD_SIZE_8BIT, &buf[0], 1, 100) != HAL_OK)
{
return 4;
}
return 0;
}
温度读取是 16 位数据:0x11 的高 8 位是有符号整数,0x12 的高 2 位是 0.25℃ 的小数部分,其余位为 0。转换时先把两个字节拼成 16 位,做算术右移 6 位完成符号扩展和取整:
c
/* 温度原始值:11h 拼接 12h,右移 6 位后得到 0.25℃ 单位的整数 */
uint8_t DS3231_ReadTempRaw(int16_t *temp_raw)
{
uint8_t buf[2];
if (HAL_I2C_Mem_Read(&hi2c1, DS3231_ADDR << 1, DS3231_REG_TEMP_MSB,
I2C_MEMADD_SIZE_8BIT, buf, 2, 50) != HAL_OK)
{
return 1;
}
/* 拼成 int16 后 >>6:负温度(MSB bit15=1)时靠算术右移完成符号扩展 */
*temp_raw = (int16_t)(((uint16_t)buf[0] << 8) | buf[1]) >> 6;
return 0;
}
uint8_t DS3231_ReadTemp(float *temp_c)
{
int16_t raw = 0;
if (temp_c == NULL) return 1;
if (DS3231_ReadTempRaw(&raw) != 0) return 2;
*temp_c = (float)raw * 0.25f; /* 每个 LSB = 0.25℃ */
return 0;
}
闹钟 A2 配置:A2 的寄存器从 0x0B 开始(分 0x0B、时 0x0C、日/星期 0x0D),掩码位分别是 A2M2~A2M4。函数支持"每天固定时刻"和"某天某时刻"两种模式:
c
/* 配置闹钟2:每天(或指定星期)的 hour:min 触发。
day_en=0 表示忽略日期/星期(每天触发),day_en=1 表示匹配星期 wday_match */
uint8_t DS3231_SetAlarm2(uint8_t hour, uint8_t min, uint8_t day_en, uint8_t wday_match)
{
uint8_t regs[3];
uint8_t ctrl = 0;
if (hour > 23 || min > 59) return 1; /* 参数校验 */
/* 0x0B=分, 0x0C=时, 0x0D=日/星期(DY/DT 位在 bit6:1=星期匹配) */
regs[0] = dec2bcd(min) & 0x7F; /* A2M2=0:分参与匹配 */
regs[1] = dec2bcd(hour) & 0x3F; /* A2M3=0:时参与匹配(bit7 必须为 0) */
if (day_en)
{
/* 匹配星期:A2M4=0,DY/DT=1(bit6=1),低 3 位写星期值 1~7 */
regs[2] = 0x40 | (dec2bcd(wday_match) & 0x07);
}
else
{
/* 忽略日期/星期:A2M4=1(bit7=1),每天触发 */
regs[2] = 0x80;
}
if (HAL_I2C_Mem_Write(&hi2c1, DS3231_ADDR << 1, 0x0B,
I2C_MEMADD_SIZE_8BIT, regs, 3, 100) != HAL_OK)
{
return 2;
}
/* 读回控制寄存器,置 A2IE 使能闹钟2中断输出 */
if (HAL_I2C_Mem_Read(&hi2c1, DS3231_ADDR << 1, DS3231_REG_CTRL,
I2C_MEMADD_SIZE_8BIT, &ctrl, 1, 50) != HAL_OK)
{
return 3;
}
ctrl |= DS3231_CTRL_A2IE; /* 使能 A2 中断 */
ctrl |= DS3231_CTRL_INTCN; /* INTCN=1:INT/SQW 引脚用于闹钟中断输出 */
if (HAL_I2C_Mem_Write(&hi2c1, DS3231_ADDR << 1, DS3231_REG_CTRL,
I2C_MEMADD_SIZE_8BIT, &ctrl, 1, 50) != HAL_OK)
{
return 4;
}
return 0;
}
/* 清闹钟标志:读-改-写,只清指定闹钟的 F 位,避免误清其他位 */
uint8_t DS3231_ClearAlarmFlag(uint8_t alarm)
{
uint8_t status = 0;
if (HAL_I2C_Mem_Read(&hi2c1, DS3231_ADDR << 1, DS3231_REG_STATUS,
I2C_MEMADD_SIZE_8BIT, &status, 1, 50) != HAL_OK)
{
return 1;
}
status &= ~(alarm == 1 ? DS3231_STATUS_A1F : DS3231_STATUS_A2F);
if (HAL_I2C_Mem_Write(&hi2c1, DS3231_ADDR << 1, DS3231_REG_STATUS,
I2C_MEMADD_SIZE_8BIT, &status, 1, 50) != HAL_OK)
{
return 2;
}
return 0;
}
方波配置:把 INT/SQW 从"闹钟中断"切到"方波输出",INTCN 必须清 0,频率由 RS2/RS1 决定。注意这个函数和闹钟是互斥的,调用方要想清楚自己到底要哪种功能:
c
/* 方波频率档位:0=1Hz, 1=1kHz, 2=4.096kHz, 3=8.192kHz */
uint8_t DS3231_SetSquareWave(uint8_t freq_idx)
{
uint8_t ctrl = 0;
if (freq_idx > 3) return 1; /* 防越界 */
if (HAL_I2C_Mem_Read(&hi2c1, DS3231_ADDR << 1, DS3231_REG_CTRL,
I2C_MEMADD_SIZE_8BIT, &ctrl, 1, 50) != HAL_OK)
{
return 2;
}
ctrl &= ~(0x18); /* 清 RS2/RS1(bit4~bit3) */
ctrl &= ~DS3231_CTRL_INTCN; /* INTCN=0:INT/SQW 输出方波 */
ctrl &= ~DS3231_CTRL_EOSC; /* EOSC=0:确保振荡器运行 */
ctrl |= (freq_idx << 3); /* RS2/RS1 = freq_idx */
if (HAL_I2C_Mem_Write(&hi2c1, DS3231_ADDR << 1, DS3231_REG_CTRL,
I2C_MEMADD_SIZE_8BIT, &ctrl, 1, 50) != HAL_OK)
{
return 3;
}
return 0;
}
5.4 主程序逻辑
主程序把"1Hz 方波 → EXTI0 中断 → 置秒标志 → 主循环读时间"串起来。秒中断共享变量必须用 volatile 修饰,防止编译器优化:
c
volatile uint8_t time_tick = 0; /* 中断与主循环共享,必须 volatile */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_0)
{
time_tick = 1; /* 每秒一次的方波下降沿触发 */
}
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_I2C1_Init();
MX_USART1_UART_Init();
/* 硬件 I2C 死锁恢复:上电后 BUSY 位可能被置位,必须先释放总线 */
if (__HAL_I2C_GET_FLAG(&hi2c1, I2C_FLAG_BUSY))
{
I2C1_Bus_Release();
}
if (DS3231_Init() != 0)
{
printf("[ERR] DS3231 init fail\r\n");
Error_Handler();
}
/* 写一次初始时间(实际项目可从按键/NTP/上位机下发) */
DS3231_Time_t now = { .sec = 0, .min = 30, .hour = 14,
.wday = 2, .day = 15, .month = 9, .year = 26 };
DS3231_WriteTime(&now);
/* 方波 1Hz → PA0,驱动每秒读一次时间 */
DS3231_SetSquareWave(0);
while (1)
{
if (time_tick) /* 秒标志置位才读,避免 I2C 高频轮询 */
{
time_tick = 0; /* 清标志要放在读之前 */
DS3231_Time_t t;
float temp;
if (DS3231_ReadTime(&t) == 0 && DS3231_ReadTemp(&temp) == 0)
{
printf("20%02u-%02u-%02u %02u:%02u:%02u W%u %.2f C\r\n",
t.year, t.month, t.day, t.hour, t.min, t.sec,
t.wday, temp);
}
else
{
printf("[WARN] read DS3231 fail\r\n");
}
}
}
}
代码质量说明:所有 I2C 操作都检查了 HAL 返回值;中断共享变量 time_tick 加了 volatile;BCD 转换和参数写入都有防越界保护;写时间的字节顺序(秒最后写)有注释解释原因;初始化顺序(先释放总线、再 Init、再写时间)有依赖注释。
六、测试验证
6.1 功能测试
| 测试项 | 测试方法 | 结果 |
|---|---|---|
| 时间写入 | 通过串口指令设置 14:30:00,读回核对 | 写入/读回一致 |
| 秒脉冲走时 | 连续读 10 次,间隔 1s,观察秒递增 | 秒连续递增,无跳变 |
| 温度读取 | 室温 26℃ 环境,读温度寄存器 | 25.75℃,与体温计偏差 0.4℃ |
| 掉电保持 | 拔掉 VCC,等 2 分钟,重新上电读时间 | 时间保持走时,未归零 |
| 闹钟触发 | 设 A2 闹钟为当前时间 +30s | 到点 INT 引脚拉低,A2F 置位 |
6.2 时间精度实测(72 小时日误差)
用 PC 的 NTP 校准时间作为参考基准(NTP 本身误差 < 50ms),给 DS3231 写入同一时刻后,连续跑 72 小时,每天记录一次累计偏差:
| 运行时长 | 累计偏差 | 折算日误差 | 备注 |
|---|---|---|---|
| 24 小时 | +0.3s | +0.3s/天 | 室温 25~27℃ |
| 48 小时 | +0.6s | +0.3s/天 | 基本线性 |
| 72 小时 | +0.9s | +0.3s/天 | 无明显跳变 |
72 小时累计 +0.9s,折算日误差 +0.3s/天,约 +3.5ppm。这个结果在官方标称 0~40℃ ±2ppm(日误差 ±0.17s)的量级附近,但略偏大,偏差来源主要是三方面:一是模块上的 DS3231 属于非工业级批次,出厂校准点与实测环境不完全一致;二是参考基准(PC 系统时钟)本身有 <50ms 的误差叠加;三是测试期间昼夜温差约 2℃,温补虽在起作用但仍有残余温漂。总体结论:作为嵌入式时钟源,这个精度已经足够秒级以内的日志时间戳使用。
作为对比,我在同一块板子上用内部 RTC + 外部 32.768kHz 晶振(贴片 3225 封装,约 ±30ppm 级别)跑了同样的 72 小时,累计偏差 +7.9s(日误差约 +2.6s/天)。DS3231 比内部 RTC 精度高约 8 倍,这就是温补和普通晶振的差距。
6.3 理论 vs 实测对照
对照一:I2C 传输速率
CubeMX 配置 Fast Mode 400kHz,理论 SCL 周期 2.5μs。用示波器测 PB6 上的 SCL 波形:
| 参数 | 理论值 | 实测值 | 偏差 | 原因 |
|---|---|---|---|---|
| SCL 周期 | 2.500μs | 2.532μs | +1.3% | CCR=30 取整量化 + DUTY=0 模式下 Tlow/Thigh 非精确 1:1 |
| 突发读 7 字节耗时 | --- | 190μs | --- | 含地址写入、应答位、停止位开销 |
理论周期 2.5μs 与实测 2.532μs 偏差 1.3%,符合 CCR 整数取整的预期(CCR=30 对应实际频率约 395kHz),不影响任何 DS3231 时序要求(其最大 SCL 频率 400kHz,实测 395kHz 在范围内)。
对照二:温度测量精度
DS3231 内置温度传感器官方精度 ±3℃、分辨率 0.25℃。实测与两款独立测温设备对照:
| 测温设备 | 实测值 | 与 DS3231 偏差 |
|---|---|---|
| DS3231 温度寄存器 | 25.75℃ | 基准 |
| BMP280(I2C 气压计,板载) | 26.35℃ | +0.6℃ |
| 水银体温计(腋下等效室温) | 26.0℃ | +0.25℃ |
与 BMP280 偏差 0.6℃、与体温计偏差 0.25℃,均在官方 ±3℃ 指标内。偏差主要来自两芯片的封装热阻差异和摆放位置(DS3231 紧邻主控发热区,BMP280 在板边)。
6.4 量化参数对比:方波频率档位扫描
DS3231 的 INT/SQW 引脚有 4 档方波频率(由 RS2/RS1 决定),我用频率计逐档实测输出频率,检验每一档的实际精度:
| RS2/RS1 | 标称频率 | 实测频率 | 偏差 | 用途建议 |
|---|---|---|---|---|
| 00 | 1 Hz | 0.999998 Hz | -2ppm | 秒中断/秒脉冲(本设计使用) |
| 01 | 1 kHz | 999.997 Hz | -3ppm | 低频 PWM 节拍 |
| 10 | 4.096 kHz | 4095.99 Hz | -2.4ppm | 常见采样时钟基准 |
| 11 | 8.192 kHz | 8192.02 Hz | +2.4ppm | 音频/滤波时钟基准 |
四档频率实测偏差都在 ±3ppm 以内,与 6.2 节 72 小时日误差 (+3.5ppm) 互相印证,说明芯片整体走时精度的一致性很好。这里给读者的建议:需要用"绝对时间"的场景(日志时间戳)用 1Hz 档,需要"精确节拍"的场景(定时采样)优先选 4.096kHz 或 8.192kHz,因为它们能整除 32.768kHz 的整数倍关系,误差最小。
相关阅读:《STM32硬件I2C驱动DS3231高精度时钟(OLED动态显示与温度补偿)》 --- 如果想把时间显示到 OLED 上,可以参考这篇的显示层实现。
七、故障排查
以下 7 类问题按"最常见原因优先"排列,每类都给出完整排查链。
7.1 I2C 通信失败、读不到 ACK(最常见)
- 症状 :
HAL_I2C_Mem_Read返回HAL_ERROR或一直超时,串口打印初始化失败。 - 排查链 :先核对地址------DS3231 是 7 位地址 0x68 ,HAL 函数内部自动左移成 0xD0/0xD1;如果手动拼帧把地址写成了 0x68<<1 之外的值,从机永远不应答。地址确认无误后,用示波器探 SCL/SDA:看不到时钟波形 → 引脚没配成复用开漏(查 GPIOB->CRL 的 CNF 位);有时钟但 SDA 没有 ACK 低电平 → 总线缺少上拉或从机没供电(VCC 引脚虚接最常见,模块排针虚焊)。
- 根因:本案例是模块 VCC 排针虚焊,按压模块后恢复正常。
- 验证:补焊后读 0x0F 状态寄存器成功,OSF 位可见。
7.2 硬件 I2C BUSY 死锁(上电即卡死)
- 症状 :第一次读写前
__HAL_I2C_GET_FLAG(&hi2c1, I2C_FLAG_BUSY)恒为 1,程序卡在超时重试。 - 工具:示波器看 SDA,会发现复位瞬间 SDA 被拉低(毛刺)。
- 初始假设:以为是从机地址错 → 用逻辑分析仪抓总线,发现根本没有 START 条件产生,外设没发出任何数据,说明是主机侧 I2C 外设卡在 BUSY 态。
- 根因:上电时序中,DS3231 模块(自带 4.7k 上拉)先于 MCU 上电,SDA 在 MCU 复位期间被拉低超过一个字节时间,I2C 外设的 BUSY 位(I2C_SR2 bit1)被置位且不复位。
- 修复 :按 5.2 节的
I2C1_Bus_Release(),把 PB6/PB7 切成普通输出手动敲 9 个 SCL 脉冲,再复位重配 I2C。 - 验证:释放后 BUSY 位清零,首次读写成功。后续每次上电都先检查 BUSY 再决定是否释放,问题不再复现。
7.3 秒数不动、时间不更新
- 症状:读回的时间一直不变,或者从 2000-01-01 起就没走过。
- 排查链 :先读 0x0F 状态寄存器,看 OSF 位(bit7) 。OSF=1 说明振荡器曾经停止(首次上电或电池掉过)。继续查 0x0E 控制寄存器的 EOSC 位(bit7),EOSC=1 表示振荡器被软件关闭。再用万用表量模块 VCC 和 VBAT 电压:VCC 正常而 VBAT 为 0,说明电池没装或簧片接触不良。
- 根因:本例是模块电池簧片氧化导致 VBAT 虚接,断电瞬间振荡器失压停止。
- 修复 :清理簧片并重新压紧电池;软件侧在
DS3231_Init()里自动清零 OSF。 - 验证:OSF 清零后秒数正常递增,断电 1 分钟再上电时间保持。
7.4 BCD 解码错乱(时间显示乱值)
- 症状:串口打印出"时=0x0A 分=0x59"这类明显越界值,或下午时间整点偏移 12 小时。
- 工具:先直接打印原始寄存器字节,观察原始值规律。
- 初始假设:以为是读取时序错位 → 用逻辑分析仪确认突发读的字节顺序没问题,排除时序。
- 根因 :两层。一是时寄存器 12/24 小时位(bit6) 未处理,芯片在 12 小时制下 bit5 是 AM/PM 标志,直接按 BCD 解析导致下午数据错乱;二是BCD 高位越界(如 0x0A 出现在十位)说明解析时没有屏蔽保留位(秒的 bit7、日的 bit6~7、月的 bit5~7)。
- 修复:读时间时按 5.3 节代码逐字段屏蔽保留位 + BCD 合法性校验(任一 4 位 >9 视为异常)。
- 验证:修正后连续 24 小时打印,无乱值;跨过 12:59→13:00 边界观察,小时正确进位到 13。
7.5 闹钟到点不触发
- 症状:闹钟时间到了,INT/SQW 引脚无低电平,A2F 标志不置位。
- 排查链 :先用串口读回闹钟寄存器 0x0B~0x0D 的原始值,核对掩码位。掩码位语义是 1=忽略,很多人(包括我)一开始按"1=匹配"理解,导致配置了"每天触发"结果变成"只触发一次"。再查 0x0E 的 A2IE 位是否为 1(中断使能),以及 INTCN 位------如果之前配过方波,INTCN 被清 0,闹钟中断被屏蔽,引脚只出方波。
- 根因:本例是两处叠加------掩码位写反 + INTCN 被方波配置清 0。
- 修复:掩码位按"1=忽略"重写;闹钟与方波二选一,本项目把方波让给秒中断,闹钟改用轮询 A2F 标志的方式(不依赖引脚中断)。
- 验证:到点后串口打印 A2F=1,读标志后清除,下次到点再次置位。
7.6 方波无输出
- 症状:示波器探 INT/SQW 引脚,无任何波形,引脚一直是高电平。
- 排查链 :先确认引脚有上拉(开漏输出无上拉则一直为低,本例一直为高说明上拉存在但芯片没拉低)。查 0x0E 寄存器:INTCN 默认值=1 (RST 值),此时 INT/SQW 只做闹钟中断输出,默认不输出方波------这是最常见原因。再查 EOSC 位是否被置 1 停了振荡器,以及 RS2/RS1 是否配置。
- 根因:INTCN=1 默认值未清除。
- 修复 :调用
DS3231_SetSquareWave(0),内部会清 INTCN、清 EOSC、设 RS 位。 - 验证:示波器看到 1Hz 方波,实测频率 0.999998 Hz,与 6.4 节数据一致。
7.7 温度读数异常(负值乱码或恒为 0℃)
- 症状:室温 26℃ 打印 -40℃ 或 0.00℃。
- 排查链:打印原始 0x11/0x12 字节。看到 MSB=0x00、LSB 全 0 → 芯片处于省电模式(EOSC=1 停振,温度转换不进行);看到 MSB 最高位是 1(如 0xE6)却被当无符号数 → 符号扩展错误。
- 根因 :温度 MSB 是有符号数,必须做算术右移;如果按无符号处理,负温会解析成巨大正数,或低位丢弃导致 0.25℃ 精度丢失。
- 修复 :用
(int16_t)((msb<<8)|lsb) >> 6完成符号扩展(5.3 节已实现)。 - 验证:修正后常温读到 25.75℃,放入冰箱冷藏室(约 5℃)读到 4.75℃,与温度计偏差 <1℃。
相关阅读:《DS3231高精度RTC驱动开发与工业级可靠性设计》 --- 从工业级可靠性角度总结了 DS3231 在恶劣环境下的应用注意点,值得一读。
八、总结
8.1 要点回顾
- 选型结论:需要"断电保持 + 高精度"的时钟场景,DS3231 的 TCXO 集成方案比内部 RTC / 普通外置 RTC 精度高一个数量级(实测日误差 0.3s vs 内部 RTC 2.6s),免外部晶振还少一个故障点。
- BCD 处理:时间寄存器全是 BCD 码,读取时必须逐字段屏蔽保留位(秒的 CH、时的 12/24 位、日的 bit6~7),并做合法性校验。
- 闹钟掩码 :A1M/A2M 掩码位语义是 1=忽略该字段,与直觉相反;闹钟与方波共用 INT/SQW 引脚,INTCN 位决定二选一。
- 硬件 I2C 死锁:上电瞬间 SDA 毛刺会导致 BUSY 位卡死,必须用"GPIO 敲 9 个 SCL 脉冲"的方式释放总线,这段代码 HAL 库不会替你生成。
- 精度验证方法:72 小时累计偏差 +0.9s(+3.5ppm),与方波四档扫描(±3ppm 内)互相印证,说明芯片走时一致性良好。
8.2 适用边界与局限
这套方案适用于需要断电保持时间、日误差要求秒级以内的设备(日志记录仪、智能电表、环境监测终端)。局限有三点:一是 DS3231 的温度补偿在 0~40℃ 区间最优,超出后精度下降到 ±3.5ppm 量级(实测 +3.5ppm 已接近该边界);二是模块级 DS3231 批次差异明显,要求 ±1ppm 级精度时应选工业级封装(DS3231SN)并自己做 24h 校准;三是 1Hz 秒脉冲做中断会占用一个 EXTI,多外设项目要注意引脚规划。如果项目对精度要求不高(日误差秒级可接受)且预算敏感,内部 RTC 或 DS1307 更划算。
8.3 扩展方向
下一步可以做三件事:一是给闹钟接上 EXTI 中断回调,做"定时唤醒"类应用(配合 STOP 模式可大幅降功耗);二是用 DS3231 的 32kHz 引脚输出做高精度时钟源,替代 MCU 内部 HSE 校准;三是把时间数据通过 ESP8266/WiFi 或 4G 模块定时上报,实现设备时钟的远程监控。需要提醒的是,无论哪种扩展,都要先解决"时间源到底以谁为准"的问题------DS3231 精度再高也会漂,批量设备建议定期用 NTP 或 GPS 时间校准一次。
如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员。
版本备注
- 硬件平台:STM32F103C8T6 最小系统板 + DS3231 模块(CR2032 电池)
- 软件版本:STM32CubeMX 6.12 + STM32CubeF1 HAL 库 1.8.6(Keil MDK 5.39)
- 兼容说明:驱动代码基于 HAL I2C 接口编写,可直接用于 F1/F4/L4 系列(I2C 句柄替换为对应外设即可);标准库工程需将 HAL_I2C_Mem_Read/Write 替换为标准库读写函数
- API 变更风险:STM32CubeF1 HAL 库 1.8.x 的 HAL_I2C_Mem_Read 签名与 1.6.x 一致,但 1.7.0 起增加了超时参数的类型收紧;若升级到 CubeF1 2.x,注意 I2C 句柄初始化结构体字段有调整
参考资料
- 《STM32初学-外部RTC时钟芯片DS3231》 --- RTC 原理与 DS3231 入门
- 《STM32F103C8T6驱动DS3231时钟模块:从CubeMX配置到HAL库I2C读写避坑指南》 --- CubeMX 配置流程对照
- 《STM32 HAL库实战:DS3231高精度时钟模块全功能开发指南》 --- 闹钟与全功能配置参考
- 《STM32硬件I2C驱动DS3231高精度时钟(OLED动态显示与温度补偿)》 --- OLED 显示层扩展
- 《DS3231高精度RTC驱动开发与工业级可靠性设计》 --- 工业级可靠性设计
- DS3231 数据手册(Maxim Integrated,Rev 2022)--- 寄存器定义与时序参数