简介:这是一套完整的基于AX301开发板与DS1302芯片的万年历FPGA工程,面向数字逻辑、RTC读写与FPGA综合开发学习者。工程覆盖年、月、日、时、分、秒、星期的读取与显示,结合四键交互实现校准:key1进入修改并控制闪烁位,key2在日期/星期与时间显示之间切换并执行加一,key3切换12/24小时制并执行减一,key4全局复位。设计采用状态机组织显示切换与按键处理,修改时对应字段闪烁提示,过程直观,可作为RTC驱动、按键消抖、动态扫描与闪烁显示设计的有益参考。包内共1383个文件,约10.15MB,以Quartus工程文件(qpf/qsf)、Verilog/AHDL源码(v/tdf)、时序约束(sdc)及可下载到开发板的sof配置文件为主,其余大量cdb/hdb为编译生成的中间数据库。已有705人学习下载,适合正在完成同类课程设计或希望对照工程细节理解FPGA开发流程的读者。
1. 用一块 AX301 把 DS1302 变成能掉电走时的万年历
手里有一块 AX301 开发板,想让它干点“有真实时间感”的事:上电自动显示今天星期几、几月几号、时分秒,断电再上电时间也不会回到初始状态——这就是“基于 AX301 与 DS1302 的万年历设计”要做的事。AX301 负责跑逻辑,DS1302 是一颗用三根线读写的实时时钟芯片,本身带 32.768kHz 晶振和电池供电,靠 BCD 码存时间,FPGA 这边要把它的时序摸透才能安全读写。这个方案适合刚学完 Verilog 语法、想在开发板上做点能持续使用的时序小项目的开发者,也适合想深入理解串行协议、状态机封装和上板调试的人。下面这条路线从协议图一路写到代码和踩坑,按这个流程做,半天到一天能见到稳定走动的万年历。
2. 先看懂 DS1302 的时序再写代码:命令字节、寄存器位和 24 小时制陷阱
2.1 AX301 板级资源与 DS1302 的接线思路
AX301 这块板上通常有 50MHz 主时钟、6 到 8 位数码管、几个独立按键,以及一排可用的扩展排针。DS1302 如果买的是现成模块,板上已经带 32.768kHz 晶振和电池座,FPGA 这边只需要接三根信号线加电源:CE(片选,也叫 RST)、SCLK(串行时钟)、I/O(双向数据线)。注意“三根线”很容易让人以为随便找三个 IO 就行,实际上 I/O 是双向的,端口约束必须定义成 inout,否则后面读数据全是高阻。
我一般这样接线:CE 和 SCLK 各占一个普通扩展 IO,I/O 也占一个 IO,但需要在外部把 I/O 通过 10kΩ 电阻上拉到 3.3V。DS1302 模块上的电池座如果已经接好,VCC 直接用板上 3.3V 供电即可,GND 和板子共地。引脚号不建议照抄网上任意工程,AX301 有不同版本,最稳的做法是打开你自己的板子原理图,挑三个没有被数码管、按键、时钟专用引脚占用的普通 IO 用,再把约束文件里的引脚名改成你选的位置。
| DS1302 信号 | AX301 侧建议 | 说明 |
|---|---|---|
| CE | 扩展排针任意普通 IO | 拉高片选,传输结束拉低 |
| SCLK | 扩展排针任意普通 IO | 由 FPGA 产生约 1MHz 时钟 |
| I/O | 扩展排针任意普通 IO + 10kΩ 上拉到 3.3V | 双向线,读写方向切换是关键 |
| VCC | 3.3V | 模块自带稳压和电池座则直接接 |
| GND | GND | 必须共地,否则时序全是乱的 |
2.2 命令字节和寄存器位定义:读秒为什么是 0x81
DS1302 的每个寄存器都要先发一个命令字节,再收发一个数据字节。命令字节的格式固定:最高位 bit7 必须是 1,bit6 固定为 0 表示操作时钟寄存器(置 1 是 RAM),bit5 到 bit1 是寄存器地址,最低位 bit0 是读写方向,1 表示读,0 表示写。所以“读秒”命令是 0x81 而不是 0x80,0x80 是“写秒”。这个细节第一次写几乎必错,因为很多例程只写了命令值,没说 bit0 是方向位,照抄完才发现读回来的是 0。
下表是万年历必须要操作的几个寄存器,数据和地址都是 BCD 码:
| 寄存器 | 读命令 | 写命令 | 内容 | 取值范围(十进制) |
|---|---|---|---|---|
| 秒 | 0x81 | 0x80 | bit7 是 CH 时钟暂停位 | 00~59 |
| 分 | 0x83 | 0x82 | 无特殊位 | 00~59 |
| 时 | 0x85 | 0x84 | bit7 是 12/24 小时制选择 | 00~23 或 01~12 |
| 日 | 0x87 | 0x86 | 无特殊位 | 01~31 |
| 月 | 0x89 | 0x88 | 无特殊位 | 01~12 |
| 星期 | 0x8B | 0x8A | 无特殊位 | 01~07 |
| 年 | 0x8D | 0x8C | 无特殊位 | 00~99 |
| 写保护 | 0x8F | 0x8E | bit7 是 WP 写保护位 | 0 或 1 |
秒寄存器里藏着第一个坑:bit7 是 CH 位,出厂或模块刚上电时它可能是 1,CH 为 1 表示内部振荡器暂停,这时候不管你怎么读,秒始终不动。写秒的时候必须保证这一位是 0。小时寄存器的 bit7 则是 12/24 小时制选择位:为 1 时是 12 小时制,bit6 还会变成上午/下午标志;写成 0 才是 24 小时制。很多人上板后下午时间乱跳,就是写小时时没把 bit7 清掉,把一天拆成了两段。
2.3 读时序和写时序:上升沿锁存,下降沿之后才能采数据
DS1302 是一个很典型的串行实时时钟,写数据在 SCLK 上升沿被芯片锁存,所以主机要在上升沿前把数据放到 I/O 线上;读数据则是芯片在 SCLK 下降沿之后更新 I/O 线上的电平。也就是说,命令字节和写入的数据字节都必须在 SCLK 低电平时把位准备好,等上升沿到来;读数据时不能在上升沿采样,要在下降沿过后、电平稳定时再采样。这个不对称非常容易在状态机里写反,写完看起来波形很漂亮,数据全是 0xFF。
另外还有一个被人忽略的边界条件:CE 拉高后不能立刻打 SCLK,要等至少 1 到 2us,让芯片内部完成准备;整次传输结束后,CE 拉低也要等 SCLK 处于低电平。DS1302 允许的 SCLK 最高频率和数据手册相关,常见参考值是 2MHz 以内,3.3V 供电时我一般控制在 1MHz 左右,留足建立时间。下面代码里的半周期参数就是围绕这个算的。
3. 用状态机写出可综合的 DS1302 驱动:单字节读写模块与调用方式
3.1 为什么不用延时函数,而要写成状态机
很多单片机例程里用延时函数加循环直接操作 GPIO 就能驱动 DS1302,但 FPGA 里不能这样写。Verilog 的综合工具要求时序逻辑由时钟驱动,#200这类延时只在仿真里有效,综合时会被忽略。所以在 AX301 上做这个项目,最稳的写法是把 50MHz 主时钟分出一路约 1MHz 的 SCLK,把“发命令 + 收/发一个数据字节”整个过程拆成状态机。
状态机要覆盖四个阶段:等待启动、发送命令字节的 8 个位、发送或接收数据字节的 8 个位、结束并把 CE 拉低。每个位周期内再细分两个半步:低电平准备数据和上升沿锁存数据。这种“半周期计数”的方式很直观,也方便调参数——只要改顶层的分频计数上限,就能整体改变 SCLK 频率,排查读回乱码时很有用。
3.2 单字节读写模块:完整代码和关键参数说明
下面这个模块是完整可综合的,它完成一次“发 1 个命令字节 + 收发 1 个数据字节”。读秒就是发 0x81 再读回 1 字节,写秒就是发 0x80 再写入 1 字节。代码里把读写方向用rw区分开。
// ds1302_bytes.v —— DS1302 单字节读写模块,50MHz 时钟可综合 module ds1302_bytes #( parameter HALF = 24 // 半周期计数:SCLK ≈ 50MHz / (2*24) ≈ 1.04MHz )( input wire clk_50m, input wire rst_n, input wire start, // 启动信号,单周期高有效 input wire rw, // 1=发命令后读数据,0=发命令后写数据 input wire [7:0] cmd, // 命令字节,如读秒=0x81、写秒=0x80 input wire [7:0] wdata, // rw=0 时写入的数据字节 output reg [7:0] rdata, // rw=1 时读回的数据字节 output reg busy, // 传输期间为 1 output reg sclk, output reg ce, inout wire dio ); localparam IDLE = 2'd0, CMD = 2'd1, DATA = 2'd2, DONE = 2'd3; reg [1:0] st; reg dio_dir; // 1=主机驱动 dio,0=释放 dio reg data_out; // 发送移位寄存器输出的当前位 reg [7:0] shift; // 命令或数据移位寄存器 reg [2:0] bit_cnt; // 当前处理到第几位 reg phase; // 0=SCLK 低半步,1=SCLK 高半步 reg [5:0] cnt; // 半步计数器 // 三态控制:只有在命令阶段和“写数据”阶段才驱动总线 assign dio = (dio_dir && ((st == CMD) || (st == DATA && !rw))) ? data_out : 1'bz; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin st <= IDLE; sclk <= 1'b0; ce <= 1'b0; busy <= 1'b0; dio_dir <= 1'b0; rdata <= 8'h00; phase <= 1'b0; cnt <= 1'b0; end else begin sclk <= phase; // phase 直接映射 SCLK 电平 case (st) IDLE: begin if (start) begin busy <= 1'b1; ce <= 1'b1; // CE 拉高,开始传输 st <= CMD; bit_cnt <= 3'd0; phase <= 1'b0; cnt <= HALF - 1; shift <= cmd; data_out <= cmd[7]; dio_dir <= 1'b1; // 命令阶段主机占用总线 end end CMD: begin if (phase == 1'b0) begin // SCLK 低半步:准备好当前数据位,等待上升沿锁存 data_out <= shift[7]; if (cnt != 0) cnt <= cnt - 1; else begin phase <= 1'b1; cnt <= HALF - 1; end end else begin // SCLK 高半步:上升沿已经锁存,准备下一个位 if (cnt != 0) cnt <= cnt - 1; else begin phase <= 1'b0; cnt <= HALF - 1; if (bit_cnt == 3'd7) begin // 命令字节 8 位发完,切换数据阶段 if (rw) begin dio_dir <= 1'b0; // 读模式:释放总线给 DS1302 st <= DATA; end else begin shift <= wdata; data_out <= wdata[7]; // 写模式:继续驱动 st <= DATA; end bit_cnt <= 3'd0; end else begin bit_cnt <= bit_cnt + 1'b1; shift <= {shift[6:0], 1'b0}; data_out <= shift[6]; end end end end DATA: begin if (rw) begin // 读数据:下降沿之后采样,取低电平中点最稳 if (phase == 1'b0) begin if (cnt == HALF / 2) shift <= {shift[6:0], dio}; if (cnt != 0) cnt <= cnt - 1; else begin phase <= 1'b1; cnt <= HALF - 1; end end else begin if (cnt != 0) cnt <= cnt - 1; else begin phase <= 1'b0; cnt <= HALF - 1; if (bit_cnt == 3'd7) begin rdata <= shift; st <= DONE; end else bit_cnt <= bit_cnt + 1'b1; end end end else begin // 写数据:和命令阶段一样,低电平放数据,上升沿锁存 if (phase == 1'b0) begin data_out <= shift[7]; if (cnt != 0) cnt <= cnt - 1; else begin phase <= 1'b1; cnt <= HALF - 1; end end else begin if (cnt != 0) cnt <= cnt - 1; else begin phase <= 1'b0; cnt <= HALF - 1; if (bit_cnt == 3'd7) st <= DONE; else begin bit_cnt <= bit_cnt + 1'b1; shift <= {shift[6:0], 1'b0}; end end end end end DONE: begin ce <= 1'b0; sclk <= 1'b0; // CE 拉低,SCLK 归零 dio_dir <= 1'b0; busy <= 1'b0; st <= IDLE; end endcase end end endmodule这个模块最核心的参数是HALF。50MHz 时钟下,一个完整 SCLK 周期是2 * HALF / 50MHz,HALF=24时约为 960ns,也就是 1.04MHz,符合 DS1302 在 3.3V 下的速度要求。如果上板后发现数据时对时不对,直接把这个参数加大到 32 或 40,把 SCLK 降到 0.6~0.8MHz 再看,往往能定位是时序余量不足还是别的问题。
读数据采样点写在cnt == HALF / 2,这是刻意放在低电平中点的。因为 DS1302 在 SCLK 下降沿之后才更新 I/O,采样点距离下降沿约 240ns,留给信号稳定,比一进低电平就采样可靠得多。写数据的建立时间更宽裕:低电平整个前半段都在放数据,上升沿到来时数据早已稳定。
3.3 上层怎么调用:200ms 轮询读一遍完整时间
单字节模块一次只能操作一个寄存器,万年历要读秒、分、时、日、月、星期、年一共 7 个寄存器。我通常的做法是每 200ms 触发一次读操作,每次按顺序读一个寄存器,7 次读完刷新一轮。200ms 而不是 1 秒,是为了让秒位的变化能及时更新,同时总线上平均负载很小,完全不会影响 DS1302 走时。
// 上层调用:每 200ms 启动一次单字节读,依次读 7 个时间寄存器 reg [2:0] rd_idx; reg [23:0] timer; reg start_pulse; reg busy_dly; wire rw_en; wire [7:0] rd_cmd; assign rw_en = 1'b1; // 读方向 always @(*) begin case (rd_idx) 3'd0: rd_cmd = 8'h81; // 秒 3'd1: rd_cmd = 8'h83; // 分 3'd2: rd_cmd = 8'h85; // 时 3'd3: rd_cmd = 8'h87; // 日 3'd4: rd_cmd = 8'h89; // 月 3'd5: rd_cmd = 8'h8B; // 星期 default: rd_cmd = 8'h8D; // 年 endcase end always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin rd_idx <= 3'd0; timer <= 24'd0; start_pulse <= 1'b0; busy_dly <= 1'b0; end else begin busy_dly <= busy; // busy 由 1 变 0,说明上一次传输结束,锁存结果 if (!busy && busy_dly) begin case (rd_idx) 3'd0: sec <= rdata; 3'd1: min <= rdata; 3'd2: hour <= rdata; 3'd3: day <= rdata; 3'd4: month <= rdata; 3'd5: week <= rdata; default: year <= rdata; endcase rd_idx <= rd_idx + 1'b1; // 指向下一个寄存器 end if (timer == 24'd9_999_999) begin // 200ms @ 50MHz timer <= 24'd0; if (!busy) start_pulse <= 1'b1; // 总线空闲才启动下一次 end else begin timer <= timer + 1'b1; if (start_pulse) start_pulse <= 1'b0; end end end注意一个小细节:启动信号start_pulse不能连续拉高,必须等上一个传输彻底结束,也就是busy变为 0 之后再发。计数器的边界值也不要写错,50MHz 下 200ms 是50_000_000 * 0.2 = 10_000_000个时钟周期,所以计数到9_999_999后归零刚好是 200ms。我这里把启动条件和读锁存分开处理,避免在总线正忙时再塞一个新的启动脉冲进去。
4. 万年历主体:BCD 处理、闰年简化判断和数码管扫描显示
4.1 BCD 拆位和闰年判断:别把 BCD 当十六进制用
DS1302 读回来的时间数据全部是 BCD 码,比如 31 秒,返回的是0x31,不是十进制的 49。高 4 位是十位 3,低 4 位是个位 1。数码管显示时直接拆高低 4 位分别送七段码译码即可,不需要转成二进制整数再除 10 求余,那样反而多绕一步。
// BCD 拆位:直接取高低 nibble 送段码 wire [3:0] sec_hi = sec[7:4]; // 十位 wire [3:0] sec_lo = sec[3:0]; // 个位 wire [3:0] hour_hi = hour[7:4]; wire [3:0] hour_lo = hour[3:0];比较隐蔽的问题是“日期合法性”校验。DS1302 内部自己会处理大小月和平闰年,也就是说你只要把日、月、年正确写进去,它就会自动从 2 月 28 号走到 3 月 1 号,FPGA 不需要重新实现整套日历算法。FPGA 里真正需要的是一层“防呆”:调时的时候判断用户写入的日期是否合理。常见的错误是有人把读回的0x31直接当十六进制的 49 参与闰年判断,然后整个日期逻辑全乱。
对 2000 年到 2099 年这个区间,闰年判断可以简化为只看年份后两位能不能被 4 整除。因为 2000 年是闰年,之后到 2099 年之间不存在整百年干扰,所以year[1:0] == 2'd0就是闰年:
// 简化闰年判断:仅适用于年份在 2000~2099 区间 wire is_leap = (year[1:0] == 2'd0); // 用月判断当月天数(BCD 月直接比较高低 nibble) wire [7:0] max_day; always @(*) begin case (month) 8'h02: max_day = is_leap ? 8'h29 : 8'h28; 8'h04, 8'h06, 8'h09, 8'h11: max_day = 8'h30; default: max_day = 8'h31; endcase end这个max_day在按键调日时可以拿来钳位,比如当前是 2 月且不是闰年,日一旦调到 29 就自动回 1。注意月寄存器的值范围是 1 到 12,星期寄存器的范围是 1 到 7,这本身也是校验点:读回来如果是0x00,说明初始化时写坏了。
4.2 数码管动态扫描:刷新率、消隐和高位处理
AX301 板载数码管一般是共阳或共阴的位选加段选结构,具体段码表要根据板子原理图确认,不能套一个网上随便找的数组。我这里只说动态扫描的写法。扫描频率很关键:每一位数码管每秒至少要刷新 50 次才不会闪烁,6 位数码管就是每位 2ms 左右,对应扫描周期 12ms,刷新率约 83Hz。
// 6 位数码管动态扫描:每位显示约 2ms reg [16:0] scan_cnt; reg [2:0] scan_pos; reg [3:0] cur_digit; reg [7:0] seg_out; parameter SEG_PERIOD = 17'd99_999; // 2ms @ 50MHz,计数值从 0 到 99999 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 17'd0; scan_pos <= 3'd0; end else begin if (scan_cnt >= SEG_PERIOD) begin scan_cnt <= 17'd0; scan_pos <= scan_pos + 1'b1; // 每 2ms 切到下一位 end else scan_cnt <= scan_cnt + 1'b1; end end // 根据模式和当前位置选择要显示的 BCD 位 always @(*) begin case (display_mode) 2'd0: begin // 时间模式 hh:mm:ss case (scan_pos) 3'd0: cur_digit = hour[7:4]; 3'd1: cur_digit = hour[3:0]; 3'd2: cur_digit = min[7:4]; 3'd3: cur_digit = min[3:0]; 3'd4: cur_digit = sec[7:4]; 3'd5: cur_digit = sec[3:0]; default: cur_digit = 4'hF; // 第 6 位之后消隐 endcase end 2'd1: begin // 日期模式 yy.mm.dd case (scan_pos) 3'd0: cur_digit = year[7:4]; 3'd1: cur_digit = year[3:0]; // 中间放两个分隔位,用段码里的横线或直接熄灭 3'd2: cur_digit = month[7:4]; 3'd3: cur_digit = month[3:0]; 3'd4: cur_digit = day[7:4]; 3'd5: cur_digit = day[3:0]; default: cur_digit = 4'hF; endcase end default: cur_digit = 4'hF; endcase end段码输出这里要特别注意“高 4 位为 0 的情况”。比如小时是0x09,拆出来的高位0和低位9都会显示,于是变成09,没问题;但如果是 12 小时制回绕,高位可能出现1之外的值,比如0x12显示成12还是正常的。真正要消隐的是高位的 0——如果你不想让十位显示 0,就单独加一个if (hour[7:4] == 4'd0) cur_digit = 4'hF;而不是全部拼进去。扫描位置超过实际位数时要给段码输出一个消隐码,否则最后一个数码管会一直重复显示第一位,看起来像“拖影”。
4.3 按键切模式与调时:写回之前先解写保护
万年历不只要会显示,还要能调整时间。AX301 上的按键我一般这样分配:一个按键切换时间/日期/星期三种显示模式,一个按键进入调时状态并在调时状态下做加一,另一个按键切换“正在调秒/分/时/日/月/年”的目标寄存器。按键处理都要先做 20ms 消抖,这和在单片机上的思路一样,FPGA 里用一个计数器加边沿检测即可。
调时完成后写回 DS1302 有一个固定的顺序:先写 0x8E 寄存器并写入0x00解除写保护,接着写对应的时分秒或者年月日,最后再写 0x8E 寄存器并写入0x80把写保护恢复。
// 写小时示例:必须在写保护解除之后进行 // 第一次触发:cmd=0x8E, wdata=0x00 解除写保护 // 第二次触发:cmd=0x84, wdata={2'b00, hour_set} 写入小时 // 第三次触发:cmd=0x8E, wdata=0x80 恢复写保护 reg [1:0] write_step; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) write_step <= 2'd0; else if (key_confirm) begin // 按确认键开始写回 write_step <= 2'd1; end else if (!busy && busy_dly) begin case (write_step) 2'd1: begin cmd <= 8'h8E; wdata <= 8'h00; // 第一步:解保护 write_step <= 2'd2; end 2'd2: begin cmd <= 8'h84; wdata <= {2'b00, hour[5:0]}; // 24 小时制 write_step <= 2'd3; end 2'd3: begin cmd <= 8'h8E; wdata <= 8'h80; // 第三步:重新保护 write_step <= 2'd0; end endcase end end写小时时我特意把wdata拼成了{2'b00, hour[5:0]},就是把 bit7 和 bit6 强制置 0,确保芯片一直工作在 24 小时制。这个习惯在最开始就养成的,就不会出现中午 12 点后时间回跳的怪问题。写回的过程中,busy信号会把每次传输分隔开,所以三个步骤天然串行,不需要再加额外的延时。
5. 上板避坑:DS1302 万年历最常见的 5 个翻车点与排查方法
5.1 读回来的时间全是 0xFF 或 0x00
现象:数码管显示的不是正常时间,而是满屏 f 或者全是 0,无论怎么初始化都不变。原因通常有两个:一个是 CE 拉高后没有等待足够时间就开始发 SCLK,DS1302 根本没进入通信状态;另一个是 I/O 三态没有正确释放,FPGA 一直在驱动总线,读数据时芯片的数据推不出来。解决方法是改状态机:在 IDLE 进入 CMD 之前,单独加一个计数等待,让 CE 先高电平保持至少 2us;同时检查dio的三态控制逻辑,读数据阶段要把dio_dir拉低,让三态门变成高阻。
5.2 秒能走,但小时在中午 12 点后完全错乱
现象:上午显示正常,一到下午 12 点以后,小时从 01 重新开始,月份日期也跟着不对。原因是小时寄存器的 bit7 被置成了 1,DS1302 工作在 12 小时制,下午从 1 重新计数,bit6 变成了上下午标志。解决:每次写小时之前把数据拼成{2'b00, hour[5:0]},从根上清掉 bit7 和 bit6。如果已经写坏了,先按 5.1 的方式正常读回小时,再写回格式化数据,不要只改显示逻辑,因为芯片内部走时用的就是 12 小时制。
5.3 写入的时间不生效,或者读回后日期被清空
现象:调时确认后,毫秒级显示正常,断电重启却又回到初始值,或者只写了一个寄存器,其它寄存器全部变成 0。原因基本是没写保护流程。DS1302 的写保护寄存器是 0x8E,只有先把它的 bit7 写成 0,其它寄存器才允许被写入。写完后如果不把 WP 恢复为 1,静电或者电源毛刺可能会把时间打乱。解决:写回顺序必须是“解保护 -> 写数据 -> 重新保护”,这个三步骤不能省,也不能只写第一步就断电。
5.4 数码管亮度不均匀,或者有位一直在闪
现象:某一两位特别亮,其它位明显偏暗,整体看上去在抖。原因是动态扫描刷新率不够,或者扫描位置切换和段码更新不同步。数码管每一位点亮时间是固定的,扫到某位时如果段码还没稳定,就会出现“鬼影”。解决:把扫描周期压到 2ms 左右,也就是每位每秒刷新 500 次以上,同时段码和位选必须用同一个时钟沿更新,先送段码、再切位选,或者用寄存器打一拍,避免组合逻辑竞争。段码表不要照抄其它板子的,共阳和共阴的极性不一样。
5.5 晶振停振导致时间不走或走得极快
现象:上电后秒位完全不跳,或者秒位每隔几秒跳几下,明显不是 1 秒一跳。用示波器去测 32.768kHz 晶振引脚时,可能探头一搭上去反而停了。原因是 DS1302 靠外部 32.768kHz 晶振振荡计秒,晶振负载电容不匹配、PCB 走线太长、电池座接触不良都会导致停振。解决:如果模块是自己搭的,晶振的两个负载电容按 DS1302 手册选 6pF 左右,走线尽量短且等长;如果是现成模块,优先检查电池和晶振焊点。注意不要用万用表或普通探头直接测晶振脚,寄生电容会让本来就微弱的振荡直接停掉,看起来像芯片坏了,实际是测量方式不对。
6. 验证与参数收敛:仿真断言、逻辑分析仪抓线和时延调整
上板之前先把驱动模块单独拉出来仿真一遍。写一个简单的 Testbench,只验证单字节读流程能否正常完成。因为没有真正的 DS1302 从机,仿真里可以给dio一个弱驱动的固定值,或者干脆观察状态机不卡死、busy能正常拉高拉低:
// 仿真单字节读:验证状态机能走完并回到 IDLE reg start_tb; wire [7:0] rdata_tb; ds1302_bytes u_tb( .clk_50m(clk), .rst_n(rst_n), .start(start_tb), .rw(1'b1), .cmd(8'h81), .wdata(8'h00), .rdata(rdata_tb), .busy(busy_tb), .sclk(sclk_tb), .ce(ce_tb), .dio(dio_tb) ); initial begin rst_n = 1'b0; #100; rst_n = 1'b1; start_tb = 1'b0; #200; @(posedge clk); start_tb = 1'b1; @(posedge clk); start_tb = 1'b0; wait (busy_tb == 1'b1); wait (busy_tb == 1'b0); $display("transfer finished"); $finish; end仿真能过说明状态机的迁移逻辑没有死锁,但不代表上板就通。上板后我最常用的验证手段是在线逻辑分析仪,把 SCLK、CE、DIO 三根信号拉进逻辑分析仪通道,抓一次读秒操作。重点看两个位置:命令字节 0x81 的 8 个位是否完整;进入读数据阶段后,DIO 释放为高阻后是否出现稳定的数据电平。如果抓到的数据位和秒寄存器实际值一致,说明时序方向完全正确,这时再把所有寄存器一起读齐。
如果读回的数据时对时不对,优先调HALF参数而不是改代码逻辑。我把HALF=24降到HALF=40,SCLK 从 1.04MHz 降到约 0.62MHz,往往就能消除边界采样问题。还有一个习惯:接线时让三根信号线尽量短,I/O 的上拉电阻放在 DS1302 模块侧而不是 FPGA 侧。逻辑分析仪抓线碰到 I/O 时也不要直接夹太紧,探头电容可能改变读写电平,这一点和量晶振是同一个道理。
我做这个项目时翻过最大的车,是第一天晚上写初始化代码时完全没看小时寄存器的 bit7,第二天中午发现时间从 01 重新数,才意识到是 12 小时制在作怪。后来我养成了先画一张“命令字节 + 寄存器位”小表的习惯,每个 bit 的含义都写清楚再动手写代码。把这个习惯复制到任何串行芯片的驱动上,都能省掉大量查线查波形的血泪时间。希望这篇笔记帮到你。
本文还有配套的精品资源,点击获取