FPGA驱动DS1302实现万年历:从三线时序到状态机设计
发布时间:2026/9/9 19:41:21 锦皓数字建站

简介面向FPGA入门与进阶学习者的一份工程资源包围绕AX301开发板与DS1302实时时钟芯片实现带按键调时的万年历系统。工程采用状态机控制显示切换支持日期/星期显示、时间修改闪烁提示、12/24小时制切换以及全局复位覆盖数码管动态扫描、按键消抖和RTC时序读取等典型FPGA应用。资源包共1383个文件以Quartus工程文件为主包含cdb/hdb编译过程文件、tdf/v设计源文件、sof比特流配置文件、sdc约束文件、txt说明文档等压缩包仅10.15MB目录结构清晰便于按模块快速检索与复用。已有703人学习下载。通过这套完整工程可快速理解FPGA与DS1302的时序读写逻辑掌握状态机编码、按键处理与显示刷新策略可直接用于课程设计、电子竞赛或实验室深入学习。 万年历这种项目在单片机上做几乎没什么门槛但换到FPGA上很多人做到一半就卡住了。我当年拿到AX301开发板板上是Cyclone IV系列的EP4CE6F17C8逻辑资源不算多但做一个万年历绰绰有余。真正卡住我的是DS1302这颗小小的时钟芯片——三线接口看着简单实际用FPGA读写时时序、方向、BCD码处理一个环节出错显示的就是一副“丧尸脸”。这篇文章把我从硬件连线到状态机驱动、从显示乱码到走时不准的完整过程写下来给同样在AX301上折腾DS1302的人一个参考。1. 选型不是随便选的AX301上做万年历资源和接口刚好够用很多人拿到开发板第一时间想的是“我能不能用FPGA自己计时不挂外部RTC芯片”。理论上是能的用计数器把50MHz时钟分频到1Hz就能做秒脉冲再往上累加年月日。但有一个致命问题FPGA本身没有电池备份一断电所有计时状态全部清零。万年历如果没有“断电保持”这个属性实用性就大打折扣所以外部RTC芯片是必须的。DS1302是Dallas公司现在归Maxim出的实时时钟芯片支持2.0V到5.5V供电自带涓流充电功能可以接一个CR2032纽扣电池做后备电源。主电源断电后自动切换到电池供电时钟继续走。这是它成为万年历项目首选芯片的重要原因。和DS3231这类带温度补偿的高精度RTC相比DS1302胜在便宜、接口简单、资料多非常适合拿来练手。再看AX301这块板子。核心芯片是EP4CE6F17C8资源大概是6000个左右逻辑单元做万年历这种中小规模逻辑绰绰有余。板载资源里和本项目直接相关的有这几样晶振板上自带50MHz有源晶振作为FPGA主时钟。数码管板载4位数码管部分版本为6位以实物丝印为准用于显示时间。按键板载多个独立按键可以用来做校时、切换显示界面。GPIO排针DS1302模块通过排针连接或者自己拿洞洞板搭一个。实际连接方式很简单DS1302的CE也叫RST、SCLK、I/O三根线分别接FPGA的三个GPIOVCC接3.3VGND共地。这里必须特别注意一点DS1302的数据手册上工作电压范围很宽但如果你把它接到5V上那么I/O线上的高电平就是5V而FPGA的IO银行一般是3.3V电平标准直接连可能要出事。我建议直接把DS1302的VCC接到3.3V这样三根控制线和FPGA的IO电平完全匹配不用加电平转换电路。1.1 开发板上容易被忽略的备用电池设计DS1302的数据手册里有一个VCC1引脚是接备用电池用的。AX301开发板不一定预留这个电池座所以实际项目中我们一般用DS1302最小系统模块模块上自带了电池座和两个二极管做电源切换。如果你是自己买芯片搭电路记得VCC1接电池正极电池负极接地。这里有个小坑DS1302的涓流充电功能默认是关闭的但如果你的电路设计不小心把Trickle Charge寄存器地址0x90写入了不该写的值它就会往电池充电。充CR2032不可充电锂电池轻则电池鼓包重则有安全隐患。所以除非你用的是可充电的镍氢电池否则千万别去碰这个寄存器。我在第一次调试时手滑写过一次后来换了一颗新电池才放心。1.2 三线接口到底好在哪DS1302的三线接口本质上是一种类SPI协议SCLK提供时钟I/O是双向数据线CE是片选使能。和标准SPI相比它没有单独的数据输入和输出引脚读写共用一根线靠时序来控制方向。这跟很多温湿度传感器比如DHT11的单总线协议还不太一样DS1302是有独立时钟的同步串行协议数据线方向由主机控制。这个设计在电路上省引脚但对FPGA来说双向IO和三态门的处理是第一个要过的关卡。单片机上可以用软件模拟时序把引脚切来切去问题不大。FPGA里所有逻辑都在时钟沿下工作IO方向切换的时机如果没算准很容易读到错误数据。2. 读懂DS1302的脾气三线时序、寄存器映射和BCD码DS1302的寄存器映射并不复杂但细节非常容易出错。它的命令字节格式是这样的Bit7Bit6Bit5~Bit1Bit0固定为11时钟寄存器, 0RAM寄存器地址1读, 0写比如要写秒寄存器秒寄存器的地址是0所以写命令就是0x80读秒寄存器命令是0x81。分钟寄存器地址是1写命令0x82、读命令0x83以此类推。完整列表如下寄存器地址写命令读命令数据范围BCD秒00x800x8100~59bit7CH为时钟暂停位分10x820x8300~59时20x840x8500~23或01~12取决于12/24小时位日30x860x8701~31月40x880x8901~12星期50x8A0x8B01~07年60x8C0x8D00~99写保护70x8E0x8Fbit71时禁止写任何时钟寄存器这里有两个初始化时绝对不能忽略的地方。第一秒寄存器最高位是CHClock Halt位如果这一位是1DS1302内部的32768Hz振荡器会停止秒不走动。新出厂的芯片这一位状态不确定所以上电后第一步必须向秒寄存器写一个CH位为0的值比如0x00把时钟启动起来。第二写保护寄存器0x8E默认值是0x80也就是WP位为1此时所有时钟寄存器都是只读的。你往秒、分、时里写数据根本没反应因为写保护把门锁死了。正确操作流程是先向0x8E写入0x00解除写保护再写时间寄存器最后再向0x8E写回0x80重新打开写保护。这个顺序我在第一次做的时候没注意结果调了半天时间数据死活写不进去。2.1 BCD码而不是二进制DS1302寄存器里的数据全是BCD码Binary-Coded Decimal一个字节的高4位是十位低4位是个位。比如秒钟走到45秒读出来的字节是0x45而不是二进制的0x2D。如果你把读到的字节直接当成二进制去换算或者直接把0x45按二进制显示到数码管就会看到几位数字乱跳。FPGA里处理BCD码反而比单片机更方便因为数码管显示就是一位一位分开的直接用bit切片就能取出十位和个位assign sec_tens bcd_sec[7:4]; // 秒的十位 assign sec_ones bcd_sec[3:0]; // 秒的个位但如果你要拿时间去算星期几、做日期加减就得先把BCD转成二进制数。好在万年历这种场景不需要做复杂计算直接把BCD拆位送去译码显示就行这也是选DS1302的一个隐藏优势。2.2 读写时序的差异点DS1302写数据的节奏是CE拉高后第一个SCLK上升沿开始逐位写入命令字节命令字节发完后紧接着的8个SCLK上升沿写入数据字节。读数据则是命令字节发完后在SCLK的下降沿DS1302会把数据从I/O引脚输出主机在下降沿之后的某个时间点采样。很多人在这里栽跟头是因为把写时序的“上升沿锁存”习惯带到了读数据上。FPGA里如果用always (posedge sclk)去采样I/O会发现在上升沿采到的是DS1302还没来得及翻转的旧数据读出来永远是错的。我在调试时就把读数据采样点放在下降沿之后、下一个上升沿之前用系统时钟打一拍再采问题马上解决。DS1302数据手册给出的SCLK最高频率是2MHz但实际工程我建议不要跑满100kHz到500kHz之间比较稳。因为FPGA的IO引脚有建立保持时间要求DS1302内部电路对边沿也有响应延迟SCLK太快了数据和时钟的相位余量就不够了。3. FPGA侧的状态机驱动从命令字节到可靠读写FPGA驱动DS1302核心工作就是把上面说的读写时序翻译成状态机。我的做法是写一个独立的驱动模块对外提供“读秒、写秒”这种简单的接口内部自己管CE和SCLK。模块的对外接口设计成这样module ds1302_driver( input wire clk, // 50MHz系统时钟 input wire start, // 启动一次操作单脉冲 input wire rw, // 1读0写 input wire [4:0] addr, // 寄存器地址 input wire [7:0] wdata, // 写数据 output wire [7:0] rdata, // 读数据 output reg busy, // 忙标志 output reg done, // 操作完成单脉冲 output reg ce, // 连DS1302 CE output reg sclk, // 连DS1302 SCLK inout wire io // 连DS1302 I/O );3.1 用半周期计数器控制SCLKSCLK不能直接从50MHz时钟派生必须先做分频。我的办法是用一个半周期计数器HALF 250这样SCLK周期是500个系统时钟周期也就是100kHz。状态机在每个半周期结束时翻转SCLK数据则在翻转前的某个时刻准备好。localparam HALF 250; reg [8:0] half_cnt; reg sclk_reg; always (posedge clk) begin if (state IDLE) begin half_cnt 9d0; sclk_reg 1b0; end else if (half_cnt HALF - 1) begin half_cnt 9d0; sclk_reg ~sclk_reg; end else begin half_cnt half_cnt 1b1; end end这个设计的好处是SCLK的高电平和低电平时间完全对称而且在任何一个时钟沿都能精确计算出“过多少拍SCLK会翻转”方便安排IO方向和数据的切换点。3.2 命令阶段和数据阶段的状态切换整个读写流程可以用四个状态概括空闲、发命令、数据读写、结束。我简化一下核心转移逻辑是这样的always (posedge clk) begin case (state) IDLE: begin busy 1b0; ce 1b0; if (start) begin ce 1b1; busy 1b1; state CMD; bit_cnt 3d0; shift_reg {8h80, addr, rw}; // 构造命令字节 end end CMD: begin // 每个SCLK上升沿把shift_reg的最高位送到io // 8位发完后切换状态 end DATA_W: begin // 写模式下继续把wdata逐位移出 end DATA_R: begin // 读模式下在SCLK下降沿后采样io end FINISH: begin ce 1b0; done 1b1; state IDLE; end endcase end命令字节构造这里有个细节地址是5位还是5位以内的寄存器地址拼接时需要对齐。DS1302地址是bit5~bit1这5位加上bit6的时钟/RAM选择位。我给addr分配了5位宽度构造时把bit6固定为1选中时钟寄存器bit0根据读写信号来。如果你用{1b1, 1b1, addr, rw}这个写法要确认addr刚好是5位。3.3 三态门的方向控制I/O引脚是双向的在发命令和写数据阶段FPGA要驱动I/O在读数据阶段FPGA必须释放总线把I/O设为高阻输入。三态门在Verilog里用assign io io_dir ? io_out : 1bz;来实现关键在于方向信号io_dir的切换时机。我实测下来的经验是写完命令字节的最后一位之后不要立刻切方向而是等当前SCLK周期结束后、下一个SCLK下降沿到来之前再切。因为DS1302在命令字节的最后一位写完到数据第一位输出之间需要一小段时间准备数据。如果你切太早总线处于不确定状态容易读出毛刺切太晚DS1302已经把第一位数据放到I/O上了而FPGA还在强力驱动两根输出打架读到的数据必然不对。方向切换的代码如下assign io io_dir ? io_out_r : 1bz; assign rdata_reg io; // 在状态机里 // 命令发完后把io_dir拉低释放总线 // 读数据最后一个bit采样完成后把io_dir拉高恢复输出注意用1bz的时候综合工具会推断出三态缓冲区。FPGA内部的引脚是三态的但如果你在FPGA内部逻辑里也用1bz去驱动普通wire那在综合时往往会被当作高阻或者直接优化掉。这个三态门必须放在顶层模块的顶层引脚上中间逻辑不要用高阻。4. 数据从芯片到屏幕动态扫描、显示与校时驱动DS1302能读到时间数据只是万里长征走了一半。另一半是怎么把数据稳定地显示到数码管上并且能通过按键调整时间。4.1 数码管动态扫描的刷新节奏AX301板载数码管是共阳还是共阴不同批次可能不同但动态扫描的原理一致每一时刻只点亮一位数码管用超过人眼闪烁频率的速度轮流点亮各位。我用的刷新周期是1ms切换一位4位数码管一轮下来4ms相当于250Hz的帧率肉眼看不出闪烁。数码管动态扫描在FPGA里的经典写法是段码和位码分两个寄存器控制reg [3:0] scan_cnt; // 当前扫描位 reg [3:0] show_data; // 当前位的BCD值 reg [7:0] seg_data; // 查表后的段码 always (posedge clk) begin case (scan_cnt) 4d0: begin show_data sec_ones; end 4d1: begin show_data sec_tens; end 4d2: begin show_data min_ones; end 4d3: begin show_data min_tens; end endcase end段码表根据数码管是共阳还是共阴来定这个表每个项目都要重新核对。很多新手直接抄网上的段码表结果“8”字少一横、“6”和“9”乱跳大概率就是段码表和实际数码管极性不匹配。4位数码管只能同时显示4个字符所以完整万年历需要分屏显示。我的方案是用一个按键在“时分秒”和“年月日”两个界面之间切换。比如默认显示时分秒秒表上时分秒用“时-分-秒”排列中间的小数点或冒号指示灯做分隔按一下按键切到年月日界面。如果你有6位数码管甚至可以用“年月日时分秒”轮流显示两组每3秒切一次效果更接近真正的万年历。4.2 校时逻辑写保护、BCD转写、按键消抖万年历不能调时间就没灵魂。校时的基本思路是按“设置”键进入校时模式再按“选择”键切换当前要调整的项秒、分、时、日、月、星期、年按“加”和“减”键调整数值最后按“设置”键退出。按键输入必须消抖。机械按键在按下和松开的瞬间会有几毫秒到几十毫秒的抖动如果不处理一次按键会被当成十几次触发。最简单的做法是检测到按键边沿后延时20ms再读取电平确认稳定后才算有效。校时写入DS1302的代码逻辑上要注意先把写保护寄存器0x8E写入0x00解除保护然后逐个写入你要修改的秒、分、时寄存器如果通过连续地址快速写Burst模式可以一次写完8个寄存器最后再把0x8E写回0x80开启保护。这里有个小坑你只改了分钟但退出时把其他寄存器也重写了一遍这些重写的值可能来自上一次读到的缓存如果缓存和当前芯片状态不一致会把没想改的时间也改了。我的做法是进入校时状态时才一次性读出所有时间寄存器缓存调整过程中只改缓存退出校时时把完整缓存全部写回。4.3 12/24小时制与星期几的处理DS1302的小时寄存器bit7是12/24小时制选择位。如果bit71表示12小时制bit5是AM/PM标志如果bit70就是24小时制。我在项目里统一用24小时制初始化时把小时寄存器写入的值控制在0x00到0x23之间并且高位不会被意外置1。星期寄存器的值是1~7对应周日到周六。DS1302本身不负责计算星期几需要你写入时自己算好。万年历初始化时可以通过一个已知日期反推星期比如2000年1月1日是星期六用蔡勒公式或查表法算出来之后写入。这个写在单片机里是常规操作FPGA里要写一个计算模块但因为它只在校时和初始化时执行一次不需要很高的性能状态机慢慢算就行。5. 现场复盘读回0xFF、乱码和秒不走字的排查笔记调试DS1302的过程中我踩过的坑基本可以写成一排。这里挑几个典型问题把完整排查链路写出来如果你也遇到类似现象可以直接按这个思路走。5.1 读回全是0xFF现象任何寄存器读回来都是0xFF。排查过程先用逻辑分析仪抓CE、SCLK、I/O三根线的波形。发现命令字节发送波形正常但进入读数据阶段后I/O一直是高电平。第一反应是DS1302没有正确收到命令于是检查命令字节的bit6是否置1。我用的地址构造是{1b1, 1b1, addr, rw}看起来没问题。接着查CE时序发现CE是在命令字节发送完、数据阶段开始后才拉高而DS1302要求CE必须在第一个SCLK上升沿之前拉高并保持稳定。修正后CE在整个读写过程中保持高电平命令字节完整发送I/O在下降沿之后确实出现了数据。所以读回0xFF的第一排查点是CE使能时序第二排查点是命令字最高位有没有置1第三是时钟寄存器和RAM寄存器的选择位bit6有没有写对。5.2 读到的数据和写进去的不一样现象写入0x45读出来变成0x0D或者0x2D。排查这类错误大概率是BCD码和二进制混用了。我一开始用在线调试工具ObservabilitySignalTap去看读回的寄存器值发现读回的数据按十六进制看完全正确但显示模块把它当二进制拆位于是出现0x45显示成“69”这种离谱结果。这个问题不在DS1302而在自己的数据处理链路上。显示模块要做的是把4位BCD码直接转成数码管段码而不是先转二进制再转十进制。很多刚开始接触FPGA的同事习惯性地用C语言思维拿到一个数就想转二进制在FPGA里反而多此一举。5.3 时间写进去了但秒钟不走现象上电后能读到初始时间但秒数一直不变或者走了几秒就停。排查这是CH位秒寄存器bit7被置1了。DS1302在初始化时如果写秒寄存器时不小心把bit7写成1振荡器就会停止。我的问题出在校时代码里调整“秒”时直接对读出的BCD码做加减法当秒从59进位到00时减法逻辑把bit7也翻转了。解决办法对秒寄存器做加减时先把它和0x7F做与运算把CH位屏蔽掉调整完再加回0x00而不是0x80。这一步看似小实际是万年历项目里最常见的隐性Bug。5.4 走时不准一天能差几十秒DS1302走时精度取决于外接32.768kHz晶振。普通晶振的频率准确度在±20ppm左右折算下来一天误差大概1.7秒一个月累计50秒。但如果你选用了劣质晶振或PCB布局不好误差可能达到一天几十秒甚至一分钟。这个问题的处理分硬件和软件两个层面。硬件上DS1302的晶振引脚旁边的负载电容要按数据手册选一般6pF到12.5pF太小了频率偏高太大了偏低。软件上可以做一个自动校准记录一个固定时间窗口内DS1302走时和标准时间的差值算出每天快或慢多少秒然后在每天某个固定时刻比如凌晨3点对秒寄存器做一次补偿修正。这个功能在FPGA里实现起来不复杂但需要额外一个比较精确的时间基准做出来之后体验会好很多。下面把常见的几个坑汇总成表现象根因解决思路读回全0xFFCE时序不对或命令字bit6没置1先抓波形确认CE提前拉高显示乱码BCD码和二进制混淆显示模块直接拆BCD位秒不走字秒寄存器CH位被置1操作秒寄存器时屏蔽bit7时间写不进写保护寄存器未关闭先写0x8E0x00再写时间走时明显偏差晶振频率不准换优质晶振做软件校准6. 扩展玩法把万年历从“能看”做到“好用”基础版本跑通后这个项目有很多自然的扩展方向而且每个方向都能学到不同的东西。换LCD1602显示是第一个值得做的升级。4位数码管的信息量太有限年月日星期和时分秒需要分屏体验比较割裂。LCD1602可以两行显示一行显示年月日星期一行显示时分秒所有信息一屏看全。DS1302输出BCDLCD1602接收ASCII码中间只需要做一个BCD到ASCII的转换逻辑量不大。这个升级能把FPGA里状态机的复杂度再提升一个档次因为你还要处理LCD的初始化时序和总线读写时序。第二个升级方向是加温度采集。DS1302没有温度传感器但如果你想让万年历显示温度最简单的是挂一颗DS18B20。DS18B20是单总线协议和DS1302的双向IO处理方式有点类似但时序要求更苛刻特别是复位时序和读写时隙的时间窗口都很窄。把它和DS1302放在同一个工程里可以对比两种不同协议在FPGA里的实现思路收获很大。第三个方向是整点报时和闹钟。FPGA板载有蜂鸣器实现整点报时只需要判断分钟寄存器是否为0、秒钟寄存器是否为0然后用一个定时器驱动蜂鸣器响几声。闹钟功能同理设置一个目标时间实时比较当前时分秒相等时触发报警。这个功能能让万年历从“显示设备”变成“带交互功能的小家电”。我在实际调试中最后保留了一个习惯每次上电后先自动从DS1302读一次完整时间然后用这个时间去初始化FPGA内部的一个节拍计数器让FPGA在运行过程中也能独立计时。这样做的目的是万一DS1302的SCLK在高速读写时受到干扰FPGA还能靠内部计数器维持显示不跳变直到下一次刷新周期重新同步。这个冗余设计在正常使用中感知不到但从可靠性角度来看确实是值得做的。万年历这个项目看起来很“经典”似乎已经被做烂了但真到自己动手的时候还是会遇到各种意想不到的问题。它最大的价值不在于结果而在于逼你把时序协议、状态机设计、外设驱动、显示扫描、人机交互这些基础功踏踏实实过一遍。把这些东西搞通再去做SPI接口的ADC、I2C接口的传感器甚至高速接口思路都是相通的。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。