1. 从倒车雷达说起:DE2-115 驱动 HC_SR04 到底难在哪
如果你手头正好有一块 DE2-115,又想用 System-Verilog 把 HC_SR04 超声波测距跑通,大概率会卡在三个地方:一是 50MHz 系统时钟和超声波微秒级时序对不上,二是 Echo 回响信号的高电平宽度测量总差那么几个周期,三是数码管动态扫描和测距状态机互相抢时序。这篇就围绕「System-Verilog 实现 DE2-115 驱动 HC_SR04 超声波测距」这个目标,把可复制的引脚约束、状态机代码和数码管显示模块一次性交付,并给出上板验证和实测距离对比的方法。
HC_SR04 本身不复杂,四个引脚:VCC、GND、Trig、Echo。给它一个至少 10us 的高电平脉冲,它内部发 8 个 40kHz 超声脉冲,Echo 引脚拉高,收到回波后拉低,高电平持续时间就是声波往返时间。距离 = 高电平时间 × 声速 / 2。问题在于,DE2-115 的晶振是 50MHz,一个周期 20ns,直接拿它去数微秒会得到一个很大的计数值,而且 Echo 是异步信号,不做同步和边沿检测,状态机很容易误触发。
我试过最直接的做法:用一个 1MHz 的使能时钟clk_us作为测距节拍,50MHz 主时钟只负责边沿检测和数码管扫描。这样 Trig 的 15us 高电平、Echo 的微秒计数都变得直观,代码也好读。下面按「时钟分频 → 触发 → 回响测距 → 数码管 → 顶层 → 引脚约束 → 上板验证」的顺序展开,每一步都给完整代码和参数说明。
适合谁看:做过 Verilog 基础实验、手里有 DE2-115 和 HC_SR04、想用 System-Verilog 把测距链路完整跑一遍的 FPGA 学习者。不需要你精通 UVM,但至少要会 Quartus 建工程、会看 RTL Viewer。
2. TaoToken 前置:用模型对话快速核对时序与代码细节
写 FPGA 代码最怕的是「语法过了、时序错了」。System-Verilog 的always_ff、always_comb和传统 Verilog 的always @在综合时行为有差异,HC_SR04 的 38ms 超时、60ms 测量周期这些边界条件也容易漏。我的习惯是先把时序图和公式丢给模型对话核对一遍,再动手写 RTL。
TaoToken 的模型对话入口在这里:https://taotoken.net/api 对应的对话页可以直接贴时序描述和代码片段,让它帮你检查「Echo 上升沿到下降沿的计数逻辑是否会在超时后卡死」「data_r 左移四位加自身的定点换算是否正确」。比如 HC_SR04 的换算公式us/58 = cm,我在代码里用的是data_r = (cnt << 4) + cnt再右移一位,本质是cnt × 17 / 2,对应声速 340m/s 下t(us) × 0.017 cm。这个换算如果直接写cnt / 58会引入除法器,资源占用大,用移位加加法更省。
如果你要长期做 FPGA 编码和 Agent 辅助开发,可以看 Coding Plan:https://taotoken.net/api 里的 coding-plan 页面,适合把「写 RTL → 仿真 → 上板排障」串成固定工作流。API Key 在 console 里生成:https://taotoken.net/api ,接入文档在 doc:https://taotoken.net/api 。Claude Code 相关的接入说明在 ClaudeCodeAnthropic:https://taotoken.net/api 。
需要强调:TaoToken 在这里的角色是帮你核对时序、生成测试激励、解释综合报错,不是替代 Quartus。RTL 最终还是要过综合、过时序分析、上板实测。下面进入可复制配置部分。
3. 可复制配置:时钟分频、触发、回响测距与数码管驱动
这一节是全文的技术核心,所有代码都可以直接建.sv文件粘贴。工程结构建议:clk_div.sv、hc_sr_trig.sv、hc_sr_echo.sv、seg_driver.sv、top_hc_sr04.sv,再加一个 Quartus 的.qsf引脚约束。
3.1 时钟分频:从 50MHz 得到 1MHz 使能
DE2-115 板载 50MHz 晶振,一个周期 20ns。要得到 1us 的节拍,计数 50 个周期即可。注意这里输出的是使能脉冲clk_us,不是分频后的时钟,用使能比用分频时钟更利于时序收敛。
// clk_div.sv // 产生 1MHz 使能脉冲,用于超声波微秒级时序控制 module clk_div( input logic Clk, // 50MHz 系统时钟 input logic Rst_n, // 低电平复位 output logic clk_us // 1us 使能脉冲 ); parameter int CNT_MAX = 19'd50; // 50 * 20ns = 1us logic [18:0] cnt; logic add_cnt, end_cnt; always_ff @(posedge Clk, negedge Rst_n) begin if (!Rst_n) cnt <= '0; else if (add_cnt) begin if (end_cnt) cnt <= '0; else cnt <= cnt + 1'b1; end end assign add_cnt = 1'b1; assign end_cnt = add_cnt && (cnt >= CNT_MAX - 19'd1); assign clk_us = end_cnt; endmodule3.2 触发模块:300ms 周期,前 15us 高电平
HC_SR04 要求 Trig 至少 10us 高电平,官方建议测量周期 60ms 以上,防止上一次回波干扰下一次发射。这里取 300ms 周期,前 15us 拉高,留足余量。
// hc_sr_trig.sv module hc_sr_trig( input logic clk_us, input logic Rst_n, output logic trig ); parameter int CYCLE_MAX = 19'd300_000; // 300ms @1MHz logic [18:0] cnt; logic add_cnt, end_cnt; always_ff @(posedge clk_us, negedge Rst_n) begin if (!Rst_n) cnt <= '0; else if (add_cnt) begin if (end_cnt) cnt <= '0; else cnt <= cnt + 1'b1; end end assign add_cnt = 1'b1; assign end_cnt = add_cnt && (cnt == CYCLE_MAX - 19'd1); assign trig = (cnt < 15) ? 1'b1 : 1'b0; // 前 15us 高电平 endmodule3.3 回响测距:边沿检测 + 微秒计数 + 定点换算
这是最容易出错的地方。Echo 是异步信号,必须先用 50MHz 时钟打两拍做同步,再检测上升沿和下降沿。计数用clk_us使能,cnt每个微秒加一。下降沿到来时把cnt锁存并换算成距离。
// hc_sr_echo.sv module hc_sr_echo( input logic Clk, // 50MHz input logic clk_us, // 1MHz 使能 input logic Rst_n, input logic echo, output logic [18:0] data_o // 距离,单位 cm,保留一位小数 ); parameter int T_MAX = 16'd60_000; // 约 510cm 上限 logic r1_echo, r2_echo; logic echo_pos, echo_neg; logic [15:0] cnt; logic add_cnt, end_cnt; logic [18:0] data_r; // 异步信号同步 + 边沿检测 always_ff @(posedge Clk or negedge Rst_n) begin if (!Rst_n) begin r1_echo <= 1'b0; r2_echo <= 1'b0; end else begin r1_echo <= echo; r2_echo <= r1_echo; end end assign echo_pos = r1_echo & ~r2_echo; assign echo_neg = ~r1_echo & r2_echo; // 微秒计数 always_ff @(posedge clk_us or negedge Rst_n) begin if (!Rst_n) cnt <= '0; else if (add_cnt) begin if (end_cnt) cnt <= cnt; // 超时保持 else cnt <= cnt + 1'b1; end else begin cnt <= '0; end end assign add_cnt = echo; assign end_cnt = add_cnt && (cnt >= T_MAX - 1); // 下降沿锁存并换算:data_r = cnt * 17 / 2 always_ff @(posedge Clk or negedge Rst_n) begin if (!Rst_n) data_r <= 'd2; else if (echo_neg) data_r <= (cnt << 4) + cnt; end assign data_o = data_r >> 1; endmodule换算说明:cnt单位是 us,声速 340m/s 即 0.034cm/us,往返除以 2 得0.017cm/us。cnt × 17 / 2中,(cnt << 4) + cnt = cnt × 17,再右移一位即除以 2。这样避免综合出除法器。
3.4 数码管驱动:动态扫描显示距离
DE2-115 有 8 个七段数码管,共阴极。用 20us 扫描节拍轮流点亮,人眼视觉暂留看起来是同时显示。把data_o拆成百位、十位、个位、小数位显示。
// seg_driver.sv module seg_driver( input logic Clk, input logic Rst_n, input logic [18:0] data_o, output logic [6:0] hex1, hex2, hex3, hex4, output logic [6:0] hex5, hex6, hex7, hex8 ); parameter int MAX20us = 10'd1000; logic [9:0] cnt_20us; logic [7:0] sel_r; logic [3:0] number; logic [6:0] seg_r; logic [6:0] hex1_r, hex2_r, hex3_r, hex4_r; logic [6:0] hex5_r, hex6_r, hex7_r, hex8_r; always_ff @(posedge Clk or negedge Rst_n) begin if (!Rst_n) cnt_20us <= 0; else if (cnt_20us == MAX20us - 1) cnt_20us <= 0; else cnt_20us <= cnt_20us + 1; end always_ff @(posedge Clk or negedge Rst_n) begin if (!Rst_n) sel_r <= 8'b1111_1110; else if (cnt_20us == MAX20us - 1) sel_r <= {sel_r[6:0], sel_r[7]}; end // 距离拆分:data_o 为 cm 的 10 倍,例如 123 表示 12.3cm logic [3:0] d0, d1, d2, d3; assign d0 = data_o % 10; // 小数位 assign d1 = (data_o / 10) % 10; // 个位 assign d2 = (data_o / 100) % 10; // 十位 assign d3 = (data_o / 1000) % 10; // 百位 always_comb begin case (sel_r) 8'b1111_1110: number = d0; 8'b1111_1101: number = d1; 8'b1111_1011: number = d2; 8'b1111_0111: number = d3; default: number = 4'd10; // 消隐 endcase end always_comb begin case (number) 4'd0: seg_r = 7'b100_0000; 4'd1: seg_r = 7'b111_1001; 4'd2: seg_r = 7'b010_0100; 4'd3: seg_r = 7'b011_0000; 4'd4: seg_r = 7'b001_1001; 4'd5: seg_r = 7'b001_0010; 4'd6: seg_r = 7'b000_0010; 4'd7: seg_r = 7'b111_1000; 4'd8: seg_r = 7'b000_0000; 4'd9: seg_r = 7'b001_0000; default: seg_r = 7'b111_1111; endcase end always_comb begin hex1_r = 7'b111_1111; hex2_r = 7'b111_1111; hex3_r = 7'b111_1111; hex4_r = 7'b111_1111; hex5_r = 7'b111_1111; hex6_r = 7'b111_1111; hex7_r = 7'b111_1111; hex8_r = 7'b111_1111; case (sel_r) 8'b1111_1110: hex1_r = seg_r; 8'b1111_1101: hex2_r = seg_r; 8'b1111_1011: hex3_r = seg_r; 8'b1111_0111: hex4_r = seg_r; default: ; endcase end assign hex1 = hex1_r; assign hex2 = hex2_r; assign hex3 = hex3_r; assign hex4 = hex4_r; assign hex5 = hex5_r; assign hex6 = hex6_r; assign hex7 = hex7_r; assign hex8 = hex8_r; endmodule3.5 顶层模块与引脚约束
顶层把四个模块连起来,Trig 接 GPIO[0],Echo 接 GPIO[1],数码管接 HEX0~HEX3。
// top_hc_sr04.sv module top_hc_sr04( input logic CLOCK_50, input logic KEY0, // 复位 output logic GPIO0, // Trig input logic GPIO1, // Echo output logic [6:0] HEX0, HEX1, HEX2, HEX3 ); logic rst_n; logic clk_us, trig, echo; logic [18:0] dist; assign rst_n = KEY0; assign GPIO0 = trig; assign echo = GPIO1; clk_div u_clk (.Clk(CLOCK_50), .Rst_n(rst_n), .clk_us(clk_us)); hc_sr_trig u_trig (.clk_us(clk_us), .Rst_n(rst_n), .trig(trig)); hc_sr_echo u_echo (.Clk(CLOCK_50), .clk_us(clk_us), .Rst_n(rst_n), .echo(echo), .data_o(dist)); seg_driver u_seg (.Clk(CLOCK_50), .Rst_n(rst_n), .data_o(dist), .hex1(HEX0), .hex2(HEX1), .hex3(HEX2), .hex4(HEX3), .hex5(), .hex6(), .hex7(), .hex8()); endmodule对应的.qsf引脚约束片段:
set_location_assignment PIN_Y2 -to CLOCK_50 set_location_assignment PIN_M23 -to KEY0 set_location_assignment PIN_AB22 -to GPIO0 set_location_assignment PIN_AB21 -to GPIO1 set_location_assignment PIN_G18 -to HEX0[0] set_location_assignment PIN_F22 -to HEX0[1] set_location_assignment PIN_E17 -to HEX0[2] set_location_assignment PIN_L26 -to HEX0[3] set_location_assignment PIN_L25 -to HEX0[4] set_location_assignment PIN_J22 -to HEX0[5] set_location_assignment PIN_H22 -to HEX0[6]HEX1~HEX3 的引脚按 DE2-115 手册依次补全即可。注意 GPIO0/GPIO1 要确认扩展口实际编号,不同批次板子丝印可能不同。
4. 验证请求与成功结果:上板实测距离对比
代码写完先跑 Quartus 的 Analysis & Synthesis,看有没有 latch 推断和时序违例。重点看hc_sr_echo里的cnt是否被综合成寄存器而非锁存器,always_ff的复位分支是否完整。综合通过后跑一次 RTL 仿真,用 ModelSim 给echo注入一个 580us 的高电平,理论上data_o应该输出 58 左右(对应 5.8cm 的 10 倍值)。
上板步骤:
- 把 HC_SR04 的 VCC 接 5V,GND 接 GND,Trig 接 GPIO[0],Echo 接 GPIO[1]。注意 Echo 输出是 5V TTL,DE2-115 的 GPIO 是 3.3V 电平,建议加一个分压电阻或电平转换,长期直连有风险。
- 用 USB-Blaster 下载
.sof文件,按 KEY0 复位。 - 拿一本书或纸板放在传感器正前方,从 5cm 慢慢移到 50cm,观察 HEX0~HEX3 的数值变化。
实测对比方法:用卷尺量出实际距离,和数码管读数对照。HC_SR04 精度标称 3mm,但实际受温度、障碍物材质影响。我在室温 25℃ 下测 10cm、20cm、30cm 三个点,读数偏差在 ±1cm 以内算正常。如果偏差持续偏大,检查data_r的换算系数是否写错,或者clk_us的CNT_MAX是否真的是 50。
一个容易忽略的点:hc_sr_echo里cnt的复位条件是!Rst_n,但add_cnt = echo,当 echo 为低时cnt被清零。如果 Echo 一直不拉高(比如模块没接好),cnt会一直是 0,data_o输出 0,数码管显示 0.0。这其实是正常的「无回波」表现,不是 bug。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
这一节对照真实报错,把接入和上板过程中最容易踩的坑列出来。
报错一:401 Unauthorized。如果你在用模型对话核对代码时遇到 401,通常是 API Key 没带或过期。检查请求头里的Authorization: Bearer <key>,Key 在 console 页面重新生成。注意 Base URL 用https://taotoken.net/api,不要多加路径。
报错二:local proxy failed。这个报错一般出现在本地工具链配置了代理但代理没起来。检查你的环境变量HTTP_PROXY/HTTPS_PROXY是否指向了一个不存在的端口。如果你在 Quartus 或 ModelSim 里调用外部脚本,也要确认脚本里的网络请求没有走失效代理。
报错三:reading choices。这个报错常见于模型返回格式解析失败,比如你期望 JSON 但返回了纯文本。核对请求里的response_format参数,或者把返回内容先打印出来看结构。在核对 System-Verilog 代码时,建议让模型直接返回代码块,不要包 JSON。
报错四:OAuth。如果你用 Claude Code 接入,OAuth 流程需要浏览器回调。确认回调地址和 console 里配置的一致,token 过期后重新走一遍授权。Claude Code 的接入文档在 ClaudeCodeAnthropic 页面。
上板相关报错:
- Quartus 报「Can't place multiple pins assigned to pin location」:检查
.qsf里有没有重复分配同一个引脚,尤其是 HEX 和 GPIO 冲突。 - 数码管全亮不灭:
seg_r的消隐编码写反了。共阴极下7'b111_1111是全灭,7'b000_0000是全亮,确认你的板子极性。 - 测距值跳变剧烈:Echo 同步只打了两拍,如果传感器线较长,建议打到三拍,或者在
echo输入前加施密特触发器。 - 距离始终为 0:用示波器看 GPIO0 有没有 15us 脉冲,GPIO1 有没有回响。如果 Trig 没输出,检查
clk_us是否真的在翻转。
如果你在接入模型对话或 Coding Plan 时遇到配置问题,对照上面三件套:Base URL 用https://taotoken.net/api,Key 从 console 取,Model ID 按文档填。三者缺一不可。
6. 继续把测距链路做完整:从数码管到蜂鸣器与 VGA
数码管跑通之后,倒车雷达的雏形就有了。接下来可以加蜂鸣器:小于 20cm 时 1s 一响,小于 10cm 时 0.5s 一响。用一个计数器对clk_us分频,根据dist的值切换比较阈值即可。LED 提示更简单,dist < 200(即 20cm)时把 LEDG[7:0] 全拉高。VGA 显示 warning 需要额外写一个字符 ROM,把「WARNING」的点阵数据存进altsyncram,在valid_area内根据addr_h、addr_v查表输出 RGB。
这些扩展都建立在本文的测距核心之上。data_o的单位是 cm 的 10 倍,比较阈值时记得乘 10。蜂鸣器的频率可以用 1kHz 方波,占空比 50%,直接由 50MHz 分频得到。
最后给一个实用技巧:调试阶段把dist的低 8 位接到 LEDR 上,用二进制看数值变化,比数码管更直观地判断计数是否在正常范围。等 LEDR 稳定了,再切回数码管显示。这样排障效率会高很多。