简介:面向FPGA开发者的超声波与激光测距驱动整合包,以正点原子FPGA开发板为硬件平台,将HC-SR04超声波测距和激光测距模块的驱动集成在同一个Quartus工程中,支持通过按键切换传感器,并将当前位置数据实时显示在数码管上。该方案覆盖从传感器接口时序到顶层状态调度的完整流程,适合学习FPGA数字逻辑、接口驱动及多模块协同的初中级开发者。
压缩包共877个文件,大小约13.31MB,以cdb/hdb工程数据库、tdf/v源文件、bak备份文件以及其他配置/报告类文件为主,工程目录和生成中间文件较完整,便于重新编译、查看综合布局或二次修改。多个演示视频展示了0~4米、0~20厘米等不同量程下的实测效果,可直观对比激光测距与超声波的测量差异。已有416人学习下载。
通过学习该工程,可掌握激光测距模块的接口驱动与数据解码、HC-SR04回波时间测量逻辑、数码管动态扫描以及按键切换的实现思路;工程文件保留完整数据库,便于从零复现并迁移到其他开发板。
1. 基于FPGA的超声波测距和激光测距驱动程序:一份能直接落地的双传感器工程
这个工程把激光测距和超声波测距两套驱动整合在同一个 FPGA 顶层,激光模块走 I2C 读距离数据,HC-SR04 走回波计时,按键切换传感器并把结果显示到数码管。对做课程设计、准备 FPGA 创新大赛选题、或者想把 I2C 和时序控制一次练透的同学来说,这相当于一套现成的双传感器融合参考实现。我习惯拿到工程先看模块划分再管时序,这份代码结构清晰,激光链路、超声波链路、显示链路是分开的,改起来不牵扯。下面从顶层结构拆到两个传感器的驱动实现,再把最常见的几个坑列出来。
2. 先读懂工程再动手:顶层结构、模块分工与文件清单
2.1 文件清单背后的六个核心模块
打开工程目录会看到top_csb_jg_v0.vpr.ammdb、.root_partition.cmp.ammdb、.map.ammdb这类文件,它们是 Quartus 编译过程生成的映射数据库,不是源码,重编译时会自动重建,不用管。真正要关注的是下面这些.bak备份源文件,它们按功能分得很清楚:
| 文件名 | 模块职责 | 在工程里的作用 |
|---|---|---|
iic_driver_1607.v.bak | I2C 总线驱动状态机 | 负责激光测距模块的寄存器读写,包括起始、停止、ACK 采样、字节收发 |
csb_unit.v.bak | 超声波控制单元 | 把 Trig 触发、Echo 捕获、距离换算组织成一条完整链路 |
sonic_detect.v.bak | 回波检测模块 | 捕获 Echo 上升沿和下降沿,控制计数器的启动与停止 |
pwm_control.v.bak | PWM / 周期触发控制 | 产生 HC-SR04 需要的周期触发脉冲,控制每次测量的间隔 |
RecDecode.v.bak | 数据解码换算 | 把 I2C 读回的数据或回波计数值换算成厘米单位 |
seg_show.v.bak | 数码管段选显示 | 输出七段码,让数码管显示字符 |
sel_encode.v.bak | 数码管位选编码 | 动态扫描时决定当前点亮哪一位,配合段选完成多位数码管刷新 |
csb_unit和sonic_detect的分工容易混淆,我一开始也绕了一下:sonic_detect只管“测时间”,也就是 Echo 高电平持续时间;csb_unit管“用这个时间算距离”,再把结果交给上层。激光侧则反过来,iic_driver_1607.v只做 I2C 底层字节传输,距离解算在模块内部完成,FPGA 只负责读结果寄存器。这样划分的好处是,将来换一个测距传感器,只需要替换驱动层,显示和切换逻辑可以原样保留。
2.2 顶层例化:两条测距链路怎么并入一个显示总线
顶层文件top_csb_jg_v0把两个传感器链路例化到一起。以 50MHz 系统时钟为例,顶层结构大致如下:
module top_csb_jg ( input clk_50m, input rst_n, inout iic_scl, inout iic_sda, input echo, output trig, input key_switch, output [7:0] seg_data, output [7:0] sel_data ); wire [15:0] laser_dist; wire [15:0] sonic_dist; wire [15:0] display_dist; // 激光测距链路:I2C 主机驱动 + 结果锁存 iic_driver_1607 u_iic ( .clk(clk_50m), .rst_n(rst_n), .scl(iic_scl), .sda(iic_sda), .dist_value(laser_dist) ); // 超声波链路:触发 + 回波捕获 + 距离计算 csb_unit u_csb ( .clk(clk_50m), .rst_n(rst_n), .trig(trig), .echo(echo), .dist_value(sonic_dist) ); // 按键选择显示数据源 assign display_dist = key_switch ? laser_dist : sonic_dist; // 数码管动态扫描显示 seg_show u_seg ( .clk(clk_50m), .dist(display_dist), .seg_data(seg_data), .sel_data(sel_data) ); endmodule这里key_switch是电平信号,实际工程中建议在顶层模块里先做 20ms 按键消抖再进入选择逻辑,否则机械按键抖动会导致显示在两个传感器数据之间快速跳变。两个测距链路各自输出 16 位距离值,display_dist用key_switch做二选一,背后的思路是两个测量结果锁存在不同寄存器里,切换只是改了显示源,并不会打断另一路正在进行的测量。这样即使超声波还在等回波,激光侧的数据也不会丢。
2.3 按键切换背后的显示缓存设计
直接改display_dist选择信号会有一个问题:切换瞬间数码管上显示的数据可能来自不完整状态。更稳的做法是两路测距结果都先锁存到缓存寄存器,再交给显示,类似下面这样:
reg [15:0] laser_buf; reg [15:0] sonic_buf; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin laser_buf <= 16'd0; sonic_buf <= 16'd0; end else begin if (laser_valid) laser_buf <= laser_dist; if (sonic_valid) sonic_buf <= sonic_dist; end end assign display_dist = key_switch ? laser_buf : sonic_buf;laser_valid和sonic_valid分别是两路测量完成的有效标志。I2C 读一次激光距离可能需要几十毫秒,超声波从触发到回波结束也需要十几毫秒,两路数据的更新节奏天然不同步。用缓存寄存器把各自最新有效值锁住,数码管永远显示的是“最近一次正确结果”,而不是当前读到的瞬时值。这也是我在后续踩坑里反复强调的一点:显示层不要去碰驱动层的实时数据。
3. 激光测距驱动:I2C 时序状态机与寄存器读写
3.1 为什么激光模块必须用 I2C 驱动
淘宝上常见的激光测距模块,内部大多集成了测距芯片(比如 VL53L0X 方案),距离解算、温度补偿都在模块内部完成,主控只需要通过 I2C 读取结果寄存器。I2C 是两线半双工同步协议,SCL 提供时钟,SDA 传数据。硬件逻辑上有一个铁律:SDA 只能在 SCL 为低电平时改变,SCL 为高电平时 SDA 必须保持稳定,这就是“起始/停止条件”的时序基础。
很多新手把 I2C 通信失败归咎于地址不对,实际更常见的原因是 SDA 在 SCL 高电平期间被不小心改变了,导致从机状态机错乱。看这个工程的iic_driver_1607.v,它的状态机把起始、停止、写字节、读字节、ACK 判断拆得很细,这对学习 I2C 非常合适。我一般建议先不要改这个文件,而是先学会看懂它的状态跳转,再根据自己模块的地址去匹配。
3.2 I2C 主机状态机的关键代码段
下面是一段从 I2C 驱动中提炼的写单字节状态机,展示了 SCL 与 SDA 的配合方式:
reg [2:0] bit_cnt; reg [7:0] data_shift; reg sda_out; reg scl_out; localparam IDLE = 3'd0, START = 3'd1, SEND = 3'd2, ACK_WS = 3'd3; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin bit_cnt <= 3'd0; state <= IDLE; sda_out <= 1'b1; scl_out <= 1'b1; end else begin case (state) IDLE: begin // 总线空闲,等待触发 if (i2c_start_req) begin sda_out <= 1'b0; // 拉低 SDA state <= START; end end START: begin scl_out <= 1'b0; // 起始后拉低 SCL data_shift <= dev_addr_w; // 待发送地址+写位 bit_cnt <= 3'd7; state <= SEND; end SEND: begin // SCL 低电平期间改变 SDA sda_out <= data_shift[bit_cnt]; // SCL 拉高并延时,从机在上升沿采样 scl_out <= 1'b1; // 延时到高电平结束,拉低 SCL,移动到下一位 if (bit_done) begin scl_out <= 1'b0; bit_cnt <= bit_cnt - 1'b1; if (bit_cnt == 3'd0) state <= ACK_WS; end end ACK_WS: begin sda_out <= 1'bz; // 释放 SDA 等待从机应答 scl_out <= 1'b1; if (bit_done) begin ack_ok <= sda_in; // 采样 SDA,低电平为 ACK state <= IDLE; end end endcase end end这段代码配合 I2C 时钟分频使用。50MHz 系统时钟下,bit_done由分频计数器产生,常见做法是把 SCL 频率控制在 100kHz~400kHz。分频参数计算公式是F_clk / (4 × F_scl) - 1,50MHz 下要得到 100kHz,分频计数值约取 125。参数太小会让 SCL 太快,模块跟不上;参数太大则每次测量变慢,数码管刷新会显得迟钝。激光模块初始化时还要注意:芯片上电后需要几十毫秒启动时间,如果上电后立刻发地址,从机还没准备好,往往直接不回 ACK,之后所有操作都卡在等待应答上。我第一次调时在这上面卡了半天,后来在初始化状态机头部加了一个 100ms 的等待,问题就消失了。
3.3 激光模块上电时序与初始化顺序
VL53L0X 这一类的初始化顺序比较固定:先配置寄存器让芯片进入测距模式,再等待内部校准完成,最后循环读结果寄存器。工程里iic_driver_1607.v只负责底层字节级读写,寄存器配置和地址映射通常在它之上的一个初始化状态机里完成。读操作需要先发设备写地址和寄存器地址,再发重起始和读地址:
// 读单个寄存器:0x52 是写地址,0x53 是读地址 iic_start(); iic_write_byte(8'h52); // 设备写地址 iic_write_byte(8'h00); // 目标寄存器地址 iic_start(); // repeated start iic_write_byte(8'h53); // 设备读地址 data = iic_read_byte(1); // 读一字节,主机回 NACK 并停止 iic_stop();这里有两个容易翻车的点:一是地址必须左移一位,很多模块标称 7 位地址是 0x29,总线上的八位地址是 0x52/0x53,写错就直接 NACK;二是读操作不能漏掉 repeated start,如果发完寄存器地址后直接发读地址而不重新产生起始条件,从机不会进入读模式。我实际调试时惯用做法是先用逻辑分析仪抓 I2C 波形,确认 ACK 位置和字节顺序,再去追功能问题。工程演示视频里激光模块展示了 0~4 米和 0~20 厘米两段测量,核心差异在量程选择和近距离精度表现,驱动层面同一个 I2C 接口就能覆盖,不需要换硬件。
4. HC-SR04 超声波测距:回波计时、PWM 触发与计数器设计
4.1 HC-SR04 的测量过程与 FPGA 侧三件事
HC-SR04 的物理原理不复杂:Trig 引脚收到一个大于 10us 的高电平脉冲,模块内部发射 8 个 40kHz 超声波脉冲,然后 Echo 引脚输出一个高电平,这个高电平的持续时间就是声波从发射到碰到障碍物再返回的往返时间。距离 = 往返时间 × 声速 ÷ 2。FPGA 侧要做的事情拆开是三件:用pwm_control.v产生周期触发信号,用sonic_detect.v捕获 Echo 高电平并计数,用RecDecode.v把计数值换算成距离。这里最容易被忽视的是“往返”两个字,计算时漏除 2 会让所有距离翻倍,属于最常见的低级错误。
4.2 触发信号生成:pwm_control 的参数怎么调
pwm_control.v实际是一个周期脉冲发生器。HC-SR04 要求 Trig 高电平至少 10us,而两次触发之间要有足够间隔等回波回来。按 50MHz 时钟算,10us 就是 500 个时钟周期。我一般把触发周期设在 50ms 左右,这样每秒约更新 20 次距离值,数码管显示不会有明显迟滞:
localparam TRIG_CYCLE = 32'd2_500_000; // 50ms @50MHz localparam TRIG_WIDTH = 16'd500; // 10us 高电平 reg [31:0] trig_cnt; reg trig; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin trig_cnt <= 0; trig <= 1'b0; end else if (trig_cnt < TRIG_WIDTH) begin trig <= 1'b1; // 拉高触发 trig_cnt <= trig_cnt + 1'b1; end else if (trig_cnt < TRIG_CYCLE) begin trig <= 1'b0; // 保持低电平 trig_cnt <= trig_cnt + 1'b1; end else begin trig_cnt <= 0; end endTRIG_CYCLE是触发周期,TRIG_WIDTH是脉宽。把脉宽设成 500 个时钟周期,既满足 HC-SR04 的 10us 下限,也留了余量。周期不建议短于 30ms,因为声波在 5 米距离往返需要约 30ms,周期太短会截断上一次回波。如果产品对实时性要求高、测量距离又不超过 2 米,可以缩短到 20ms,但要用最大量程倒推周期上限,这个后面避坑章会再提到。
4.3 回波捕获状态机与 20 位计数器
Echo 高电平持续时间要转成距离,核心是捕获它的上升沿和下降沿。一个干净的回波捕获状态机如下:
localparam IDLE = 2'd0; localparam WAIT_HIGH = 2'd1; localparam COUNTING = 2'd2; localparam DONE = 2'd3; reg [1:0] echo_state; reg [19:0] echo_cnt; reg echo_d0, echo_d1; // 边沿检测:采用两级触发器消除亚稳态 always @(posedge clk_50m) begin echo_d0 <= echo; echo_d1 <= echo_d0; end always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin echo_state <= IDLE; echo_cnt <= 20'd0; end else begin case (echo_state) IDLE: begin if (echo_d1 == 1'b1) begin echo_state <= COUNTING; echo_cnt <= 20'd0; end end COUNTING: begin echo_cnt <= echo_cnt + 1'b1; if (echo_d1 == 1'b0) begin echo_state <= DONE; end end DONE: begin sonic_valid <= 1'b1; echo_state <= IDLE; end endcase end end这里的echo_cnt用 20 位是因为 16 位不够。50MHz 下一个计数周期是 20ns,HC-SR04 标称最大量程大约 4 米,对应往返时间约 23.5ms,算下来需要约 117 万个时钟周期,17 位才勉强够。工程里如果只有 16 位计数器,最大只能计数 65535 个周期,对应约 22cm,超过这个距离数据就溢出。这个细节直接决定了你复制工程后能测多远,我见过不少人说“超声波只能测 20 厘米”,其实不是传感器坏了,是计数器位宽不够。距离换算放在RecDecode.v:
// distance_cm = echo_cnt * 20ns * 340m/s / 2 // 简化为 = echo_cnt * 34 / 100000 reg [15:0] distance_cm; always @(posedge clk_50m) begin if (sonic_valid) begin distance_cm <= (echo_cnt * 34) / 100000; end endecho_cnt × 34 ÷ 100000这个式子是从物理公式直接推导的整数形式,乘以 34 再除以 100000 等效于把纳秒、声速、往返因子都合并了。实际工程里除法会消耗 LUT,也可以用查找表或右移近似代替。要验证换算是否正确,拿一把卷尺在 50cm 处放一块挡板,读到的值应该在 48~52cm 之间,偏差超过 5% 就去检查计数时钟频率是否真的是 50MHz,而不是先怀疑代码。
5. 常见问题与排查:按键切换失灵、数码管闪烁、数据跳变
5.1 现象一:I2C 总线忙,激光测距读回全是 0xFF
现象:数码管显示激光测距结果时,数值固定为 65535(十六进制 0xFFFF),或者 I2C 状态机卡在某个状态不再跳转。
原因:最常见是总线被意外占用,SDA 在 SCL 为高电平时发生了变化,从机退出通信;其次是模块上电时间不够,初始化第一条指令就收到 NACK;还有一种可能是设备地址不匹配,实际模块地址与代码不一致。
解决:先看初始化前是否给了足够的延时,建议至少等 100ms;再用逻辑分析仪抓 SDA/SCL,对照手册确认起始条件、ACK 位的位置。如果总线卡死,给一个超时计数器,超过 10ms 没有 ACK 就强制状态机回到 IDLE 并释放总线,这是工程里值得加的防护逻辑,否则一次通信异常会永久锁死后续所有测量。
5.2 现象二:超声波近距离数据乱跳,看不到稳定值
现象:把障碍物放在 2~3cm 处,LCD 或数码管上的数值逐位乱蹦,偶尔还出现几十厘米的异常值。
原因:HC-SR04 存在约 2cm 的盲区,近距离时回波信号与发射信号混叠,Echo 可能出现多次边沿或者幅度不稳定,导致计数结果随机。
解决:在RecDecode.v里加最小距离阈值,计算出的距离小于 2cm 时,显示值保持上一次有效结果而不是刷新成乱值。另一个有效手段是连续测 5 次取中值,能明显降低偶发跳变。血泪经验是不要试图用加大触发频率来“凑平均”,近距离盲区的错误值是有偏的,平均救不回来,必须从源头上丢弃无效测量。
5.3 现象三:数码管切换传感器时闪一下或显示旧值
现象:按切换键瞬间,数码管所有位亮度闪了一下,或者显示的数据明显是上一次测量周期的残留值。
原因:按键没有消抖,切换信号在电平抖动的过程中触发了多次选择翻转;同时显示缓存里保存的是旧的瞬时值,display_dist刚切换时还没锁存到新数据。
解决:按键加 20ms 延迟消抖,确认电平稳定后再做切换。更稳妥的做法是切换时先给显示模块一个清零脉冲,让数码管短暂显示 0,再填入新传感器的最新缓存值,虽然会有极短暂的闪烁视觉,但至少不会显示“张冠李戴”的数据。我习惯把这套逻辑做成一个小模块,按键状态、传感器有效位、显示缓存三者解耦,后续换传感器就不用再碰显示代码。
5.4 现象四:距离超过 20cm 后显示溢出或数值不再更新
现象:障碍物从 5cm 往远移,数码管跟随增长到 22cm 左右,再往远就突然变成 0 或最大值,怎么移都不变。
原因:超声波回波计数器位宽不足。16 位计数器在 50MHz 时钟下最大计到 65535,对应时间 1.31ms,按照声速往返换算成距离只有约 22.3cm,超过这个值计数溢出,后面的换算结果也就废了。
解决:把计数器和换算逻辑都改成 20 位以上。如果测距目标是 3 米,需要计数到 882000 左右,20 位最稳。一定要顺便检查换算表达式里的中间变量位宽,echo_cnt * 34在 16 位下会先溢出,再把结果直接赋给 20 位寄存器救不回来。
6. 移植到自己的板子:qsf 引脚约束与调试验证技巧
6.1 先看原理图再改引脚,顺序错了全是玄学
下载工程后最容易犯的错是直接打开代码找传感器引脚定义,结果换上自己的板子数码管乱闪、按键没反应,然后开始怀疑时序逻辑。其实这类问题绝大多数是引脚没对上。以正点原子或其他 Cyclone IV 开发板为例,数码管的位选和段选可能分布在不同的 Bank,甚至某些引脚被复用到了按键和 LED。我在 Quartus 里做的第一件事永远是打开 Assignment Editor,对照板子原理图,把seg_data[0]到seg_data[7]、sel_data[0]到sel_data[7]、key_switch、echo、trig、iic_scl、iic_sda全部按原理图逐一映射:
set_location_assignment PIN_C14 -to seg_data[0] set_location_assignment PIN_E15 -to seg_data[1] set_location_assignment PIN_D15 -to seg_data[2] set_location_assignment PIN_C13 -to sel_data[0] set_location_assignment PIN_E14 -to sel_data[1] set_location_assignment PIN_D14 -to key_switch set_location_assignment PIN_F14 -to echo set_location_assignment PIN_G14 -to trig这些引脚编号只是示意,具体要以你的板载原理图为准。正点原子不同型号的板子引脚差别很大,抄别人工程时把 qsf 文件一并拷过来是最不靠谱的做法。另外注意 I2C 的 SCL 和 SDA 是开漏信号,外部必须接上拉电阻,很多模块板载已经焊了,但如果你直接飞线接传感器,漏掉上拉会导致 I2C 通信时好时坏,这是硬件层最隐蔽的坑。
6.2 没有示波器也能定位 I2C 问题:把内部状态引到 LED
调试这类工程时我不太建议一开始就上 SignalTap,虽然它确实好用,但工程里传感器、数码管、按键逻辑交织在一起,信号多了反而难定位。我的做法是先在顶层额外引出几个调试 LED 信号,把 I2C 的 ACK 标志、超声波 Echo 的捕获标志、按键消抖后的状态都各拉一个出去:LED 长亮说明状态机卡住,LED 闪烁频率与触发周期一致说明链路在跑。用两个 LED 拼出 2 位总线状态,也能当简易逻辑分析仪用。这个习惯帮我省了大量时间,尤其是 I2C 这种时序敏感的信号,先确认通路再调逻辑,比闷头看代码高效得多。
还有一个小技巧:把超声波换算式子里除掉的“往返系数”临时改成不除 2,用已知距离反推计数器的实际时钟频率,反过来验证时钟配置是否正确。我上次移植到一块自制的板子上,测距始终偏大 10%,排查半天发现是 PLL 输出不是 50MHz 而是 55MHz,计数器按 50MHz 换算,自然全偏。从那以后我每次换板子都会用卷尺验证一下已知距离,再动任何逻辑。这是最简单也最实际的校准方法。希望帮到你。
本文还有配套的精品资源,点击获取