
简介这是一个将激光测距与超声波测距驱动整合于同一FPGA工程的驱动资源面向FPGA开发学习者和电子设计竞赛参赛者。工程支持按键切换将不同传感器测距结果显示在数码管上配套4段实操演示视频涵盖04米、020厘米及03米等量程测距模块分别采用HC-SR04超声波模块和激光测距模块开发平台为正点原子FPGA开发板。资源包共877个文件约13.31MB以cdb/hdb/tdf/v等Quartus工程与Verilog源文件为主另有sof配置文件和仿真相关文件便于直接打开工程分析代码结构。当前已有415人学习下载。内容除完整工程源码外还包括模块驱动设计、按键切换逻辑和数码管显示控制等实现细节适合用来对照演示视频理解测距原理或在此基础上二次开发。1. 一块 FPGA 同时伺候超声波和激光测距难点不在算法而在驱动不少项目里测距模块是单片机的「专属活」定时器发个脉冲、捕获回波I2C 读个寄存器就完事。一旦换成 FPGA很多人反而不会写了——倒不是 FPGA 不能干而是「驱动」这个词的含义变了。在 MCU 上是调用外设库函数在 FPGA 里则是用状态机画出时序波形再配上一组合适的引脚约束和时钟分频。超声波模块只有一根 Echo 回线激光模块却要走 I2C 或串口两者对资源、精度和实时性的要求截然不同。这篇文章就以这两类测距模块为载体把 FPGA 侧驱动设计的完整路径讲清楚从接口选型、电平匹配到状态机骨架再分别给出超声波和激光模块的可综合 Verilog 示例最后落到参数调节、仿真验证和实测排错。适合正在做 FPGA 小车避障、机器人测距或者想从 LED 流水灯进阶到真实时序控制的开发者——读完你手里会多一套「测距外设控制器」的通用设计方法。2. 驱动前先铺平物理接口引脚约束、电平标准与状态机骨架2.1 超声波与激光模块的接口差异决定驱动方式不同常见的超声波测距模块 HC-SR04 类只有三根信号线VCC、Trig、EchoTTL 电平典型高电平时间是 10us 以上回波脉宽与距离成正比。这类模块的驱动核心是一个「计时器」发一个足够宽的脉冲然后测回波高电平的持续时间。FPGA 侧不需要任何外部总线控制器普通 GPIO 加一个计数器就够了。激光测距模块则有另一套逻辑。以 VL53L0X 这种 ToF 芯片为例SPAD 阵列、TDC 和时间直方图都在芯片内部完成FPGA 拿到的只是测量结果通信接口一般是 I2C 或 SPI。另一类是 TFmini 这类自带 UART 的激光雷达模组内部已经算好距离FPGA 只需解析一帧串口数据。这两种激光模块的共同点是它们不像超声波模块那样「裸着」一条回波线等你测量而是有自己的协议栈驱动程序的主要工作量从计时转成了总线控制与帧解析。还有一个更隐蔽的差异超声波模块的测距周期通常在 20ms 以上期间回波可能被环境反射干扰而激光 ToF 模块单次测量只要几十毫秒甚至更短但启动时往往需要数十个寄存器的初始化序列。所以 FPGA 侧不能只写一个「读距离」的函数要按模块各自的状态机来组织超声波是「触发→等回波→计时→输出」激光是「上电配置→启动测量→读取结果→输出」。2.2 FPGA 引脚约束与 TTL 电平匹配set_input_delay 与 IOSTANDARD驱动代码写对了引脚约束错了照样白搭。超声波的 Echo 输出在模块端是推挽驱动的高电平FPGA 侧直接用 LVCMOS33 电平接收即可。但要注意HC-SR04 的 Echo 引脚在未触发时是低电平触发后如果没收到回波高电平会持续约 200ms 才拉低——这意味着 FPGA 的计数器可能溢出驱动代码必须设置超时阈值而不是死等。对于 I2C 接口的激光模块FPGA 的 IO 要配置成开漏并外加上拉电阻这和单片机的用法一样但在 Vivado 里容易漏掉。约束文件里要同时声明电平标准和驱动强度set_property -dict { PACKAGE_PIN U18 IOSTANDARD LVCMOS33 } [get_ports trig] set_property -dict { PACKAGE_PIN U19 IOSTANDARD LVCMOS33 } [get_ports echo] set_property -dict { PACKAGE_PIN U20 IOSTANDARD LVCMOS33 PULLUP true } [get_ports scl] set_property -dict { PACKAGE_PIN U21 IOSTANDARD LVCMOS33 PULLUP true } [get_ports sda]这段约束把 Trig 和 Echo 配成普通推挽/输入而把 SCL、SDA 加上 PULLUP因为 I2C 协议要求漏极开路加外部上拉。PULLUP true 在 Xilinx 系列里通常指使能内部弱上拉实际调试时如果 I2C 总线上挂的器件多、速率上到 400kHz建议外加 2.2kΩ 上拉电阻内部弱上拉只作辅助。另一个常见坑是时序约束回波信号相对系统时钟是异步的需要约束 set_input_delay 或直接用两级同步器打拍否则综合后时序收敛报告里会有一堆 violation。2.3 状态机骨架一段通用的测距外设控制流程不管哪种模块驱动逻辑都可以抽象成三段发起测量、等待完成、读取结果。这个骨架不仅适合测距SPI 传感器、AD 采集都适用。超声波是「等待一个电平边沿」激光是「等待一个寄存器位变化或一帧数据头」区别只在中间状态的数量。我一般会用带超时保护的 Moore 型状态机超时计数器独立跑避免模块不响应时整个设计卡死。状态转移里只放「开始计时」「停止计时」「断言 busy」这类动作具体的数据运算放到状态机外部用组合逻辑做这样仿真和上板调试时状态一目了然不会因为插了一段乘法器而看不清时序。3. 超声波测距驱动用 Verilog 状态机实现触发脉冲与回波计时3.1 HC-SR04 测距时序与计数时钟选择HC-SR04 的工作时序是标准的Trig 保持高电平至少 10us 后拉低模块自动发射 8 个 40kHz 方波然后 Echo 输出一个高电平脉冲脉宽从 150us 到 25ms 不等对应距离 2.5cm 到 4m。公式是距离cm Echo 高电平时间us/ 58。这个公式直接决定了计数器时钟的选法。如果用 50MHz 系统时钟计数1 个 tick 是 20ns换算成距离大约 0.0034cm精度足够但计数寄存器要能容纳最大 25ms 对应的一百二十五万个周期需要 21 位计数器。如果为了省寄存器用了 1MHz 时钟一个 tick 对应 0.017cm四次测量就有 1mm 误差近距离尚可远距离就不合适了。更坏的是时钟没选好会导致回波沿采样错位所以我会直接用系统时钟计数最后在输出端做除法而不是把计数器位宽将就着砍小。3.2 可综合的超声波测距模块 Verilog 代码下面的代码是完整可综合的驱动模块包含触发状态机、回波计时和距离换算。module ultrasonic_driver #( parameter CLK_FREQ 50_000_000, // 50MHz 系统时钟 parameter SYS_CLK_PERIOD_NS 20, // 时钟周期 20ns parameter TRIG_PULSE_CYCLES 1000, // 触发脉冲 20us (1000 * 20ns) parameter MEASURE_PERIOD_CYCLES 20_000_000, // 测量周期 200ms parameter TIMEOUT_CYCLES 1_500_000 // 超时 30ms对应约 5m 距离 )( input wire clk, input wire rst_n, input wire start_measure, input wire echo, // 来自超声波模块的回波信号 output reg trig, // 触发脉冲输出 output reg busy, // 测量进行中标志 output reg [15:0] distance_cm, // 距离值单位 cm output reg valid // 测量完成有效信号 ); // 数据信号要打两拍同步消除异步回波带来的亚稳态 reg echo_d1, echo_d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin echo_d1 1b0; echo_d2 1b0; end else begin echo_d1 echo; echo_d2 echo_d1; end end localparam IDLE 3d0, TRIGGER 3d1, WAIT_MEASURE 3d2, CAPTURE 3d3, FINISH 3d4; reg [2:0] state; reg [24:0] cycle_cnt; // 可以容纳 50MHz * 0.3s 的计数器 reg [23:0] echo_cnt; // 回波计时器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; trig 1b0; busy 1b0; valid 1b0; cycle_cnt 0; echo_cnt 0; distance_cm 0; end else begin valid 1b0; case (state) IDLE: begin trig 1b0; if (start_measure) begin busy 1b1; cycle_cnt 0; state TRIGGER; end end TRIGGER: begin trig 1b1; if (cycle_cnt TRIG_PULSE_CYCLES) begin trig 1b0; cycle_cnt 0; echo_cnt 0; state WAIT_MEASURE; end else begin cycle_cnt cycle_cnt 1b1; end end WAIT_MEASURE: begin // 在低电平等待回波上升沿期间也要做超时判断 if (echo_d2) begin state CAPTURE; end else if (cycle_cnt TIMEOUT_CYCLES) begin state FINISH; end else begin cycle_cnt cycle_cnt 1b1; end end CAPTURE: begin if (!echo_d2) begin // 回波结束计算距离 计时值 * 周期 / 58 distance_cm (echo_cnt * SYS_CLK_PERIOD_NS) / 58; state FINISH; end else if (echo_cnt TIMEOUT_CYCLES) begin distance_cm 0; state FINISH; end else begin echo_cnt echo_cnt 1b1; end end FINISH: begin busy 1b0; valid 1b1; state IDLE; end endcase end end endmodule这段代码的核心逻辑在 CAPTURE 状态Echo 拉高后每个时钟周期加一拉低时把计数值乘以 20ns 再除以 58得到厘米单位。为什么不直接除以 2.9因为 Verilog 的整数除法不支持小数所以先乘后除。如果时钟频率不是 50MHz记得把 SYS_CLK_PERIOD_NS 参数一起改掉。超时判断用了 TIMEOUT_CYCLES 参数默认 30ms超过 5m 直接置零避免无回波时卡住状态机。3.3 测距周期与精度三个必调参数实际项目里最常碰到的参数是 TRIG_PULSE_CYCLES、MEASURE_PERIOD_CYCLES 和 TIMEOUT_CYCLES。TRIG 脉冲在 HC-SR04 数据手册里要求 10us 以上给 20us 余量没问题但如果追求两次测量的间隔可以把周期缩短到 12us。最大的坑是 MEASURE_PERIOD_CYCLES首次触发后如果立刻再次触发模块可能来不及处理上一次的声波余震导致读数异常。建议测量周期不小于 60ms这样一秒钟最多 16 次测量超声波本身的物理特性限制了频率高周期只能靠多次采样取平均来提升精度。4. 激光测距模块驱动I2C 到 UART 的协议封装与不响应排查4.1 主流激光测距模块的控制面I2C、UART 与只读 PWM市面上常见的激光测距模块分三类。第一类是芯片级的 VL53L0X / VL53L1X内部集成了 SPAD 阵列和直方图处理引擎通过 I2C 访问内部寄存器来发起测量和读取结果典型的寄存器操作包括写入 0x00 触发测距、轮询 0x00 的 bit0 判断数据是否就绪。第二类是模组级的比如 TFmini串口输出 9 字节数据帧帧头是 0x59 0x59中间包含距离和信号强度FPGA 只需要一个 UART 接收器。第三类是纯 PWM 输出的模块直接用占空比对应距离这种最简单但精度一般。从 FPGA 开发量的角度看UART 模组最省事——几乎所有 FPGA 教程里都有一个现成的串口接收模块可以复用。I2C 芯片则要对寄存器配置足够熟悉因为上电后你得写一大串初始化序列这些序列在数据手册里有推荐值但值得注意的是不同生产批次可能略有差异最好把初始化做成参数表便于现场改。4.2 一个耐用的 FPGA I2C 控制器关键片段FPGA 上写 I2C 控制器常见的错误是忽略 ACK 时序。I2C 的 ACK 位是第九个时钟周期从机拉低 SDA 表示应答。很多初版代码在状态机里把 ACK 当成普通数据位处理结果读操作时从机不吐数据整个总线卡死。下面是我常用的 I2C 写字节状态机核心片段加了超时和重试机制reg [6:0] bit_cnt; reg sck_en; // 是否在 SCL 高电平期间有效 always (posedge clk or negedge rst_n) begin if (!rst_n) begin sda_out 1b1; scl_out 1b1; bit_cnt 0; end else begin case (i2c_state) START: begin // SDA 在 SCL 高电平期间拉低产生 START 条件 if (cnt QUARTER_CLK) begin sda_out 1b0; i2c_state SEND_BIT; bit_cnt 7; end end SEND_BIT: begin // 先发 MSB逐位移位 if (cnt HALF_CLK) begin sda_out tx_data[bit_cnt]; end if (cnt FULL_CLK) begin if (bit_cnt 0) i2c_state GET_ACK; else bit_cnt bit_cnt - 1; cnt 0; end end GET_ACK: begin sda_out 1bz; // 释放 SDA让从机控制 if (cnt FULL_CLK) begin ack_fail sda_in; i2c_state IDLE; end end endcase end end这个片段里 sda_out 设为 1bz实际综合时会把 IO 配置为三态配合外部上拉才能读到从机的 ACK。很多人把三态误解为「输出高阻就完事」却忘了内部使能信号也要切换正确做法是把三态控制独立成一个 output enable 信号而不是直接对 sda_out 赋 z 值。另一个排查点是时钟频率I2C 标准模式的 100kHz 和快速模式的 400kHz 对计数器分频要求不同FPGA 侧要在模块顶层把 clk 分频到 100kHz 以下再进控制器否则从机跟不上。4.3 ToF 直方图与 TDC为什么激光驱动比超声波更依赖芯片内部预处理热词里反复出现「fpga tdc 直方图」这不是偶然的。ToF 测距芯片的核心里TDC 负责把光子的飞行时间转成数字码SPAD 阵列会产生海量的事件芯片内部靠直方图统计来区分信号峰和环境噪声峰。这一整套在 FPGA 里实现也不是不可能但复杂度远高于普通驱动——你要做时间数字转换、直方图累加、峰值搜索光 TDC 就要用进位链做延迟线资源占用和时序收敛都是大工程。所以绝大多数项目选择「FPGA 只做协议驱动把测距算法交给芯片」。这个选择也带来了一个隐性成本初始化时如果寄存器没写对直方图的窗口、阈值和积分时间都会偏测出来的距离时好时坏。排查方法是用 ILA 抓 I2C 总线逐条核对写入值是否和手册一致。TLB 里看到没响应先查两件事——从机地址有没有移位7 位地址左移一位是标准写法以及测量结果寄存器 0x00 的 bit0 是否有被轮询清零很多芯片在读操作后会自己把状态位拉低代码如果死等某个固定值就会永久卡死。5. 实测排错与精度打磨从 ILA 抓波形到数据滤波5.1 上板前用仿真暴露时序问题上板后先用 ILA 看回波边沿超声波模块的仿真是跟着状态机走的用 testbench 模拟一个 1ms 高电平的 echo 信号检查 distance_cm 能不能算出约 34cm。仿真通过后上板先用 ILA 抓 echo 和 trig确认回波上升沿能稳定到来——最常见的问题是 Trig 和 Echo 接反或者模块工作电压 5V 而 FPGA 引脚是 3.3V回波高电平被拉低计数器完全数不到。回波沿采样错位则要看两级同步器的输出如果 ILA 里看到 echo_d2 有短毛刺检查引脚约束有没有加内部上拉没有则把该引脚改为 LVCMOS33 加弱上拉。5.2 距离数据后处理滑动平均与温度补偿单一测量值抖动是无法避免的超声波受声速随温度变化的影响20℃ 时声速约 343m/s温度升高 10℃ 声速增加约 6m/s对应 2m 处距离误差约 3.5cm。要做精确测量驱动里最好留一个温度补偿接口用温度传感器读数修正声速公式是 331.4 0.607 * T。更快的办法是 FPGA 内部做一个十六级滑动平均滤波器去掉极端值再平均——在 Verilog 里就是一组移位寄存器和累加器代价是几次测量才能出一个稳定值适合低速避障场景不适合需要实时响应的工业测距。5.3 同一个 FPGA 同时驱动两路测距仲裁与数据通道分离项目如果要求同时跑超声波和激光两个驱动模块的时钟域相同的情况下简单组合即可。wire [15:0] ultra_cm; wire ultra_valid; wire [31:0] laser_dist; wire laser_valid; ultrasonic_driver #(.CLK_FREQ(50_000_000)) u_ultrasonic ( .clk(clk), .rst_n(rst_n), .start_measure(measure_en), .echo(echo_pin), .trig(trig_pin), .busy(busy_ultra), .distance_cm(ultra_cm), .valid(ultra_valid) ); laser_i2c_driver u_laser ( .clk(clk), .rst_n(rst_n), .start_read(measure_en), .scl(scl_pin), .sda(sda_pin), .distance_mm(laser_dist), .valid(laser_valid) ); // 按 valid 信号仲裁两个模块互不阻塞 always (posedge clk or negedge rst_n) begin if (!rst_n) distance_out 0; else begin if (ultra_valid) distance_out ultra_cm * 10; // 统一成 mm 单位 else if (laser_valid) distance_out laser_dist; end end这段顶层代码里ultra_cm 与 laser_dist 分别来自两个独立的状态机各自维护自己的 busy 和 valid互不干扰。仲裁逻辑按 valid 的到达先后决定输出哪个超声波一个测量周期 60ms激光 I2C 读一次 30ms二者不会同时有效所以不会出现数据覆盖。重点是把两个驱动的 clk 分部参数比如 I2C 分频系数用 parameter 提出来单独调整时不用重新综合整个顶层。最后用 ILA 同时抓两个 valid 信号确认总线没有互相占用再开启整体系统。本文还有配套的精品资源点击获取