帮一个朋友排查一块 DS1302 模块的时候对方很困惑代码明明按网上开源例程写的上电后时间就是不走写进去的秒数纹丝不动。我拿过来看了一眼问题出在很基础的地方——秒寄存器里的 CH 位没有清零。这类问题我在项目里遇到太多次了所以这篇 STM32 驱动 DS1302 的学习笔记不只是贴一份能用代码更想把芯片的时序逻辑、寄存器结构和那些说明书上不会写的坑一次讲清楚。无论你是刚接触 RTC 的新手还是被时序问题折腾过的老手应该都能从里面找到有用的东西。1. 为什么还选择DS1302——老芯片的生存逻辑1.1 内部RTC与外挂RTC的取舍做带时间记录功能的设备首先会想到用 MCU 内部 RTC。STM32 内部 RTC 模块确实能省掉一颗外挂芯片但在真实项目中我吃过几次亏内部 RTC 需要独立的 VBAT 引脚接电池很多板子设计时没预留或者电池座和 MCU 距离很远导致 PCB 走线复杂其次内部 RTC 的走时精度受外部低速晶振影响非常大晶振匹配电容没调好一天偏差几十秒都很正常更麻烦的是一旦代码里误配置了 RTC 寄存器或者电池通路断开时间数据会直接丢失而且很难排查。外挂 RTC 方案里DS1302 是绕不开的老熟人。它自带涓流充电电路可以给后备电池或超级电容补电三线接口只需要 CE、SCLK、IO 三个引脚随便找三个 GPIO 就能通信模块化后成本低到可以忽略学习用、产品预研用、做个桌面电子钟用都是省心的选择。所以我现在的原则是对时间敏感、要求走时稳定的项目优先考虑外部 RTC而 DS1302 就是够用、便宜、资料多的典型代表。1.2 和同类外挂RTC芯片的横向对比选型时总有人问我为什么不用 DS3231。这两款芯片的定位完全不同适合的场景也不一样。我整理了一张对比表可以看得更清楚对比维度DS1302内部RTC (以STM32为例)DS3231通信接口自定义三线类SPI内部总线I2C走时精度中等约5ppm受晶振影响受外置晶振影响大需校准高精度内置TCXO约2ppm掉电保持支持外部电池/超级电容涓流充电需VBAT引脚独立供电支持外部电池供电引脚数量3根CE/SCLK/IO0根内部2根SDA/SCL成本低无额外芯片成本较高实现复杂度需GPIO模拟时序需配置RTC寄存器和校准寄存器I2C协议驱动简单DS1302 的定位很清晰用最少的引脚、最低的成本拿到一个能独立跑时间的时钟。如果你要做的项目对时间精度要求非常高比如需要长期稳定到秒级同步的采集设备那 DS1302 确实不合适直接上 DS3231 更省事。但如果是电子钟、日志服务器、数据记录仪的本地时间戳DS1302 完全够用甚至可以说是性价比最优解。1.3 模块与兼容芯片的现实问题市面上能买到的 DS1302 模块很大一部分用的不是原厂芯片而是国产兼容型号。这些兼容芯片引脚定义和时序大体一致但我在实测中发现过不少差异有的芯片要求 SCLK 高电平最小脉宽更长有的芯片在快速连续读写时容易丢数据还有的芯片涓流充电寄存器默认值不同。所以拿到模块的第一步不是急着接线而是先确认芯片上的丝印然后观察 PCB 背面有没有焊接 32.768kHz 晶振。另一个特别容易踩的坑是晶振。有些简易模块为了省成本板子上压根没焊晶振或者焊了个假晶振实际上是个电容这种模块无论怎么初始化时间都是不会走的。我的排查习惯是模块上电后先用万用表量晶振两端看有没有起振电压或者直接用示波器抓 32.768kHz 波形。没有波形软件上再怎么折腾都是白费。2. 通信时序与GPIO模拟的底层逻辑2.1 为什么不用硬件SPI刚接触 DS1302 时我第一反应是这不就是个 SPI 吗CECS、SCLKSCK、IOMOSI/MISO 共用一根线然后试图用 STM32 的硬件 SPI 驱动的复用模式去读它结果发现数据完全是乱的。原因在于 DS1302 的时序并非标准 SPI数据是 LSB 先发送而 SPI 默认 MSB 先发送读写方向切换靠控制字的某一位决定而不是像 SPI 那样有独立的 MISO/MOSI 双向引脚分工时钟极性和相位也和 SPI Mode 0/1/3 对不上。硬蹭 SPI 协议反而要处理一大堆位序转换和方向切换问题得不偿失。GPIO 模拟时序想什么时候拉高就拉高想什么时候采样就采样完全跟着数据手册的时序图走三个引脚占用的资源也极少。这个选择不是技术落后而是芯片协议本来就是这么定义的顺毛捋才省心。2.2 写时序上升沿采样数据先行DS1302 的所有通信都从 CE 拉高开始而 CE 拉高之前要把 SCLK 置于低电平这是很多人容易漏掉的细节。拉高 CE 之后主机把一个字节的控制字按 LSB 先行的顺序放到 IO 脚上。在 SCLK 上升沿芯片内部从 IO 脚采样一个比特所以正确的操作顺序是先把 SCLK 拉低再把数据电平放到 IO 上然后拉高 SCLK让芯片采样如此循环 8 次。我用一个生活化的类比SCLK 就像一个照相机的快门每次刚抬起来的瞬间芯片给 IO 引脚拍一张照片读走上面的电平。所以你只能在快门落下的时候调整 IO 电平不能在快门抬起来的时候还在改来改去。写时序里数据要在上升沿到来之前稳定住这个建立时间虽然只需要几微秒但代码里如果不做延时GPIO 配置完的瞬间就立刻拉高 SCLK数据可能还没稳定到目标电平芯片就会采到错误的值。2.3 读时序下降沿输出低电平采样读到数据的过程和写正好相反。主机发送完控制字8 个上升沿之后第 8 个 SCLK 的下降沿会触发芯片把寄存器的最低位放到 IO 脚上。之后的每一个下降沿芯片都会更新 IO 脚上的数据相当于一个比特一个比特地吐出寄存器内容。主机必须在 SCLK 为低电平的时候去读 IO 脚因为芯片是在高电平期间准备下一个数据位的。这里有一个关键的坑读数据之前必须把 IO 引脚的方向从输出切换为输入。如果切换时机不对比如等 SCLK 下降沿来了再切换芯片此时已经在 IO 脚上输出了低电平而引脚还处于输出模式强拉高两个驱动源打架轻则读进来的数据错乱重则可能拉低电源电压导致芯片复位。我的做法是写完控制字的最后一位后立刻把 IO 配置为输入模式带上拉再拉低 SCLK 去触发芯片输出第一位这样能保证边沿来临时 IO 已经处于监听状态。2.4 时序参数汇总数据手册里给了一大堆 tCKL、tCKH、tCC 之类的参数实际写代码时不需要逐个去卡。我根据多块模块实测总结了一个保险的延时参考表参数手册典型要求实测建议值STM32 72MHzSCLK 低电平脉宽≥250ns2~5usSCLK 高电平脉宽≥250ns2~5us数据建立时间纳秒级提前在SCLK低电平期间设置好CE 高电平保持时间到整个通信结束读写完成后立即拉低CE 拉低后空闲时间≥4us4us起步不强求两次通信间隔无严格限制建议 1us防止上电不稳定GPIO 模拟在 STM32 上一次翻转本身需要几百纳秒到一微秒和库函数封装有关所以即使代码里不写延时时也能跑起来但加上延时可以大幅提高兼容性尤其是对接国产兼容芯片时。我测试过 3us 和 10us 两种延时在大多数模块上都能稳定工作但 2us 以下在个别兼容芯片上会出现偶发读取错误所以推荐 2~5us 这个区间。这个时序逻辑看起来很绕但本质只有一句话SCLK 上升沿采样写入的数据SCLK 下降沿更新读取的数据。参数怎么变都是这句话的延展。理解了这一点后面所有代码都能对号入座。3. 寄存器地图与BCD码换算3.1 七个时间寄存器秒寄存器里的CH位DS1302 的时间数据存在七个寄存器里每个寄存器 8 位数据以 BCD 码格式存储。我先列出最核心的地址映射表寄存器功能写地址读地址bit7bit6~bit4bit3~bit0秒0x800x81CH10秒位秒个位分0x820x83保留(0)10分位分个位时0x840x85保留(0)12/24小时制标志及10小时位小时个位日0x860x87保留(0)10日位日个位月0x880x89保留(0)10月位月个位星期0x8A0x8B保留(0)保留(0)星期值(1~7)年0x8C0x8D年十位年个位年个位秒寄存器最高位是 CH全称是 Clock Halt。这一位直接控制内部振荡器CH1 时振荡器停摆时间不走CH0 时时钟正常跑。芯片出厂后寄存器是随机值CH 位很可能是 1所以拿到新模块读时间永远不变大概率就是这个问题。小时寄存器的 bit6 也很关键。设置为 0 是 24 小时制bit5~bit4 表示十小时位设置为 1 则是 12 小时制bit5 表示 AM/PM。我踩过这个坑想当然地以为小时寄存器也是直接存 BCD结果写进去的 0x23 在 12 小时制下解析出了奇怪的小时数。所以驱动里一定要显式将小时寄存器 bit6 写 0。3.2 写保护寄存器与充电寄存器时间寄存器并不是想写就能写的。控制寄存器地址 0x8E/0x8F的最高位是 WP 写保护位WP1 时所有时间寄存器写入被禁止。初始化代码里设置时间之前必须先把 WP 清零写完时间后重新置 1。很多人发现写入成功但时间没变先检查的是数据算错没有其实多半是写保护没操作对。充电寄存器地址 0x90/0x91控制对后备电池的涓流充电行为。写前面四位必须是 1010TCS 安全码充电功能才会启用后面几位设置二极管个数和限流电阻阻值。这个功能默认是关闭的但有些模块上的兼容芯片上电默认值并不规范所以我建议初始化时明确写一次 0x00彻底关闭充电免得给纽扣电池长时间小电流充电导致电池鼓包。只有当你确定自己接的是可充电的镍氢电池或超级电容时才有必要开充电功能。3.3 BCD码换算与读写顺序BCD 码和十进制的换算规则很简单一个字节的高四位是十位低四位是个位。比如十进制 45BCD 码就是 0x45二进制 0100 0101。换算代码几乎每个人都会写但要提醒的是读写时间数据时不要直接拿 BCD 码当作十进制去比较或参与运算否则会出现时间卡在某些奇怪值的现象。在突发模式下时间数据有固定写入顺序秒、分、时、日、月、星期、年读出的顺序也一样。虽然寄存器地址都有各自的读写入口但用突发模式一口气读写全部时间数据代码更短事务性也更清晰还能避免多次启停 CE 带来的时序缝隙。4. 完整驱动代码从GPIO初始化到时间读写4.1 工程准备与GPIO配置先用 STM32CubeMX 建工程选三根 GPIO 用于驱动。我在测试板上用的是 PB0、PB1、PB2但你随意选。重点在于CE 和 SCLK 从始至终是输出模式IO 则需要动态切换输出和输入。CubeMX 里可以把 IO 初始化为输出模式代码中通过重新初始化 GPIO 来实现方向切换。#define DS1302_CE_GPIO_PORT GPIOB #define DS1302_CE_PIN GPIO_PIN_0 #define DS1302_SCLK_GPIO_PORT GPIOB #define DS1302_SCLK_PIN GPIO_PIN_1 #define DS1302_IO_GPIO_PORT GPIOB #define DS1302_IO_PIN GPIO_PIN_2 #define DS1302_CE_H() HAL_GPIO_WritePin(DS1302_CE_GPIO_PORT, DS1302_CE_PIN, GPIO_PIN_SET) #define DS1302_CE_L() HAL_GPIO_WritePin(DS1302_CE_GPIO_PORT, DS1302_CE_PIN, GPIO_PIN_RESET) #define DS1302_SCLK_H() HAL_GPIO_WritePin(DS1302_SCLK_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET) #define DS1302_SCLK_L() HAL_GPIO_WritePin(DS1302_SCLK_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET) #define DS1302_IO_H() HAL_GPIO_WritePin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN, GPIO_PIN_SET) #define DS1302_IO_L() HAL_GPIO_WritePin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN, GPIO_PIN_RESET) #define DS1302_IO_READ() HAL_GPIO_ReadPin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN)IO 方向切换必须用 GPIO 初始化结构体重新配置static void DS1302_IO_Mode_Output(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DS1302_IO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DS1302_IO_GPIO_PORT, GPIO_InitStruct); } static void DS1302_IO_Mode_Input(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DS1302_IO_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(DS1302_IO_GPIO_PORT, GPIO_InitStruct); }4.2 微秒级延时函数DWT计时HAL_Delay 的最细粒度是 1 毫秒无法满足 DS1302 的微秒级时序要求。这里推荐用 DWTData Watchpoint and Trace单元做微秒延时不占用定时器资源精度也足够。初始化一次之后就能直接调用。static void DS1302_Delay_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } static void DS1302_Delay_us(uint32_t us) { DWT-CYCCNT 0; while(DWT-CYCCNT us * (SystemCoreClock / 1000000U)); }注意DWT 在部分低端 Cortex-M0 内核 MCU 上不可用。如果你用的是那些芯片可以改用定时器比如 TIM2做微秒计时或者用几条 NOP 指令凑一个粗略延时。STM32F1/F4 系列的 Cortex-M3/M4 核基本都支持 DWT。4.3 底层时序函数读字节与写字节底层的字节读写是整个驱动的核心。写字节时先拉低 SCLK然后按 LSB 先行的顺序逐位设置 IO 电平再拉高 SCLK 让芯片采样static void DS1302_WriteByte(uint8_t dat) { uint8_t i; DS1302_IO_Mode_Output(); for(i 0; i 8; i) { DS1302_SCLK_L(); if(dat 0x01) { DS1302_IO_H(); } else { DS1302_IO_L(); } DS1302_Delay_us(2); DS1302_SCLK_H(); DS1302_Delay_us(2); dat 1; } }读字节时先把 IO 切到输入然后每个循环先拉低 SCLK让芯片在当前位输出数据再读 IO 电平。注意要先右移再置位保证 LSB 被放到最高位的位置。static uint8_t DS1302_ReadByte(void) { uint8_t i, dat 0; DS1302_IO_Mode_Input(); for(i 0; i 8; i) { dat 1; DS1302_SCLK_L(); DS1302_Delay_us(2); if(DS1302_IO_READ()) { dat | 0x80; } DS1302_SCLK_H(); DS1302_Delay_us(2); } return dat; }4.4 常用API时间设置、时间读取、突发模式先定义时间结构体typedef struct { uint8_t year; // 0~99 uint8_t month; // 1~12 uint8_t day; // 1~31 uint8_t week; // 1~7 uint8_t hour; // 0~23 uint8_t minute; // 0~59 uint8_t second; // 0~59 } DS1302_Time_t;BCD 换算函数static uint8_t DS1302_DEC2BCD(uint8_t dec) { return ((dec / 10) 4) | (dec % 10); } static uint8_t DS1302_BCD2DEC(uint8_t bcd) { return (bcd 4) * 10 (bcd 0x0F); }单字节寄存器读写void DS1302_WriteReg(uint8_t addr, uint8_t dat) { DS1302_CE_H(); DS1302_WriteByte(addr); DS1302_WriteByte(dat); DS1302_CE_L(); } uint8_t DS1302_ReadReg(uint8_t addr) { uint8_t dat; DS1302_CE_H(); DS1302_WriteByte(addr | 0x01); dat DS1302_ReadByte(); DS1302_CE_L(); return dat; }设置时间时必须先关写保护再以突发模式写入全部时间寄存器最后重新开启写保护void DS1302_SetTime(DS1302_Time_t *tm) { DS1302_WriteReg(0x8E, 0x00); // 关闭写保护 DS1302_CE_H(); DS1302_WriteByte(0xBE); // 写突发模式命令 DS1302_WriteByte(DS1302_DEC2BCD(tm-second) 0x7F); // 强制CH位为0 DS1302_WriteByte(DS1302_DEC2BCD(tm-minute)); DS1302_WriteByte(DS1302_DEC2BCD(tm-hour) 0xBF); // 强制24小时制 DS1302_WriteByte(DS1302_DEC2BCD(tm-day)); DS1302_WriteByte(DS1302_DEC2BCD(tm-month)); DS1302_WriteByte(tm-week); DS1302_WriteByte(DS1302_DEC2BCD(tm-year)); DS1302_CE_L(); DS1302_WriteReg(0x8E, 0x80); // 开启写保护 }读取时间时直接突发读最后解析 BCD 码void DS1302_GetTime(DS1302_Time_t *tm) { uint8_t hour_reg; DS1302_CE_H(); DS1302_WriteByte(0xBF); // 读突发模式命令 tm-second DS1302_BCD2DEC(DS1302_ReadByte() 0x7F); tm-minute DS1302_BCD2DEC(DS1302_ReadByte() 0x7F); hour_reg DS1302_ReadByte(); tm-hour DS1302_BCD2DEC(hour_reg 0x3F); tm-day DS1302_BCD2DEC(DS1302_ReadByte() 0x3F); tm-month DS1302_BCD2DEC(DS1302_ReadByte() 0x1F); tm-week DS1302_ReadByte() 0x07; tm-year DS1302_BCD2DEC(DS1302_ReadByte()); DS1302_CE_L(); }小时寄存器读取后要做掩码处理把 bit612/24 小时制标志和可能存在的 AM/PM 位过滤掉只保留 BCD 数值位。这样无论芯片内部怎么存都能正确解析出 0~23 的小时数。初始化函数这样写void DS1302_Init(void) { DS1302_Delay_Init(); DS1302_CE_L(); DS1302_SCLK_L(); DS1302_IO_Mode_Output(); DS1302_WriteReg(0x8E, 0x00); // 关写保护 DS1302_WriteReg(0x90, 0x00); // 关闭涓流充电 DS1302_WriteReg(0x8E, 0x80); // 开写保护 }拿到代码后先调用一次 DS1302_SetTime 写入当前时间之后每次上电只需要调用 DS1302_GetTime 读取就可以获得完整的运行时间。5. 实测踩坑记录与排查思路5.1 时间不走先确认CH位再怀疑晶振这是新手最容易碰到的问题也是最容易白忙活半天的排查场景。代码烧进去串口打印读回来的时间发现秒数永远不变。我建议的排查顺序是固定的第一步读取秒寄存器0x81把最高位的 CH 位打印出来。如果 CH1说明振荡器被停掉了调用一次 DS1302_SetTime 写入当前时间同时确保秒值 BCD 码的最高位为 0问题通常立刻解决。第二步如果 CH 位已经是 0时间还是不走才去怀疑晶振。用示波器或逻辑分析仪抓 DS1302 的 X1/X2 引脚看有没有 32.768kHz 波形。没有波形要么晶振没焊好要么根本没装晶振要么晶振负载电容失调导致停振。第三步如果晶振有波形但走时偏慢或偏快检查一下是不是模块上的负载电容和晶振不匹配。很多模块为了兼容各种芯片电容参数很随意走时偏差大只能忍或者外接可调电容手工校准。我最初在这类问题上耗了整整一下午。代码翻来覆去没问题最后拿放大镜一看模块背面的晶振引脚虚焊重新补焊后立刻恢复。所以排查时序问题前先用硬件手段排除硬件故障这能省一大半调试时间。5.2 读出来全是乱码IO方向切换的时机问题读到的数据一会儿是 0xFF一会儿是 0x00偶尔又正常一下这种随机乱码十有八九是 IO 方向切换时机不对。我见过很多驱动代码读字节函数里先切换成输入模式但切换动作发生在第一个 SCLK 下降沿之后芯片已经把 IO 脚驱动起来了此时两个驱动器在打架读到的电平完全不可控。正确的做法是在完成控制字最后一位的写入后立刻也就是在触发第一个数据位下降沿之前把 IO 切到输入模式。对应到上面的代码就是 WriteByte 函数内循环结束ReadByte 函数内第一次 SCLK 拉低之前这一小段时间间隔虽然只有几微秒但对方向切换至关重要。另外还有一点读模式下 IO 引脚最好使能内部上拉。因为芯片输出低电平时靠内部下拉驱动高电平时靠外部上拉或芯片内部的上拉能力。开了上拉后逻辑电平会更稳定不容易受线路寄生电容影响。5.3 写入不生效写保护与CE拉低的时序写入时间后读回来发现还是旧值这个坑通常由两处引起。第一处是写保护位没有处理好设置时间函数里一开始就写入 0x8E 地址把 WP 清零了可如果你用的例程是先备份 WP 值最后恢复中间某个环节把 WP 又置回 1写入操作就被静默吞掉了。所以我的 SetTime 函数里固定顺序先关 WP再写时间最后开 WP简单直接。第二处是 CE 拉低的时机太快。有些兼容芯片要求最后一个 SCLK 上升沿之后CE 高电平至少维持一小段时间几微秒级别才能拉低否则最后一个字节的数据没有锁存进内部寄存器。解决方法是写完最后一个字节后加一个不少于 4us 的延时再拉低 CE。这一招对很多国产兼容芯片效果立竿见影我甚至遇到过某型号不延时写入稳定失败、延时 10us 后百发百中的情况。5.4 走时偏差大晶振负载电容与模块质量如果时间在走但一天下来差了几十秒问题基本出在晶振和负载电容上。DS1302 本身精度取决于外部 32.768kHz 晶振的质量和负载电容匹配度。标准 32.768kHz 晶振的负载电容一般是 12.5pF在一些模块上PCB 寄生电容加上芯片引脚电容本身就接近这个值所以有些模块不焊负载电容也能走得不错反之某些模块上了大电容晶振起振困难或频率偏移走时误差就很夸张。一个实用的校准方法给模块断电后用频率计或者示波器的高精度测频模式测 X1 引脚输出频率开机跑状态下的频率应该在 32.768kHz 附近。偏差太大的话可以尝试更换不同容值的负载电容比如 6pF、10pF、15pF 逐个试或者在软件层做补偿——每秒按实测偏差调整一次时间值。软件补偿在低成本方案里非常实用尤其当你无法更换模块上的晶振时。最后想分享一点个人体会DS1302 这款芯片已经存在很多年了网上资料又多又杂很多代码甚至是从某个开源项目里反复搬运的反而没人去深究它为什么这么写。真正把它吃透之后你会发现三线时序、寄存器位、BCD 转换这些知识点是相通的以后再去驱 DS3231、PCF8563 之类的其他 RTC 芯片思路会非常顺。我后来做别的产品的时钟模块几乎都是基于这套驱动的思路改出来的无非是把 GPIO 模拟换成对应芯片的硬件接口寄存器映射重新查一下手册而已。所以这一篇笔记虽然讲的是 DS1302但它背后隐藏的是一套完整的外挂 RTC 芯片接入方法论值得花时间好好啃一遍。