简介:一份面向医学超声FPGA开发者的代码集合,收录了实际彩超设备中B型超声前端信号调理与波束形成滤波相关的FPGA实现,适合具备Verilog/VHDL基础、从事高速实时信号处理或医疗影像研发的工程师参考。压缩包共344个文件,以Verilog源文件(.v)为主,辅以VHDL(.vhd)、引脚约束(.ucf/.xdc/.sdc)、IP核配置(.ngc/.xco)、仿真网络表及Tcl/批处理脚本等,整体约3.77MB。内容覆盖ADC高速数据接口、可编程增益与动态范围控制、多通道时延加权波束合成、数字滤波算法硬件化等关键环节;文件按模块划分,虽非完整工程,但综合、布局布线及仿真所需脚本与约束大多齐备,可结合Vivado/Quartus直接定位参考。已有67人学习浏览,这些来自实际项目的代码片段能帮助读者快速理解波束形成与前端处理的工程实现路径,并为后续医学超声系统开发积累实践基础。
1. 波束形成与超声前置处理:先搞清这条信号链要做什么
波束形成是彩超/B 超成像前端最核心的数字信号处理环节,也是我拿到这套 FPGA 工程后第一眼看的东西。很多人以为难点在滤波、在插值,实际上真正卡住进度的往往是通道对齐、定点位宽和延时表的更新节奏,这三件事没想清楚,波形出来全是噪点。这套前置处理与波束形成滤波代码的特点是:链路完整,从 TGC 控制、ADC 数据捕获、数字波束形成到带通滤波和降采样都在同一份工程里,适合正在做多通道超声接收链路、或者想把波束形成从算法模型搬到 RTL 的人。你可以不关心成像算法,但必须懂信号链,否则连代码里的参数都改不对。
2. 前置处理链路:TGC 与 ADC 数据接口的设计约束
2.1 前置链路的结构:从模拟增益到数字通道打包
超声探头接收到的回波信号动态范围极大,近场强、远场弱,幅值差距可以达到 80dB 以上。前端模拟电路用 TGC(时间增益补偿)做粗补偿,FPGA 输出斜坡信号给模拟前端里的压控增益放大器,让浅层信号增益小、深层信号增益大。这样 ADC 入口处的信号幅度才被压到合适的动态范围内。
这份工程的前置链路是典型的四段式:探头 → LNA → VGA(受 TGC 控制)→ ADC。FPGA 在链路里的职责有两块:一是产生 TGC 控制波形,二是把多通道 ADC 的数据捕获进来并打包。多数设计里 TGC 的更新率和 ADC 采样率并不同步,比如采样率 40MSps,而 TGC 斜坡每 4~8 个采样点更新一档,需要在 FPGA 里做插值平滑。
我用一张表列出这条链路的常见参数约束:
| 链路环节 | 常用参数 | 说明 |
|---|---|---|
| 探头中心频率 | 3.5~5MHz | 决定 ADC 直采或带通采样方案 |
| ADC 采样率 | 40~50MSps | 典型是中心频率的 8~10 倍 |
| ADC 位宽 | 12~14bit | 位宽不足会直接压缩动态范围 |
| TGC 更新率 | 每 1~2μs 更新 | 需要和 ADC 采样时钟做跨时钟处理 |
| 通道数 | 16~64 | 通道越多,延迟对齐越关键 |
把采样率定在 40MSps、位宽 12bit 是这个工程里的默认配置。为什么不用更高的采样率?因为 5MHz 探头在 40MSps 下已经有足够的时间分辨率做延时插值,再往上采样只增加数据量和功耗,后端滤波的带宽收益却很小。这个取舍在医疗超声里很典型:资源不是用来看极限性能的,而是用来控制成本与发热的。
2.2 ADC 数据捕获与通道打包:Verilog 接口示例
多通道 ADC 数据进来后,第一步不是滤波,而是先按通道打包、对齐时钟域。数据接口形式常用双沿采样或 DDR 采样,代码里把 12bit 数据拼成总线,然后写入异步 FIFO 跨到波束形成时钟域。下面是我从工程里提炼出来的接口节选:
module adc_capture #( parameter CH_NUM = 16, parameter ADC_WIDTH = 12, parameter DW = 16 )( input logic clk_adc, // ADC 采样时钟 40MHz input logic clk_beam, // 波束形成时钟 200MHz input logic rst_n, input logic [ADC_WIDTH-1:0] adc_data, // 单通道串行输入 input logic adc_valid, output logic [CH_NUM*DW-1:0] chan_bus, // 打包后的通道总线 output logic chan_valid ); // 数据展宽:12bit -> 16bit,高位扩展减小后续运算进位风险 logic [DW-1:0] data_ext; assign data_ext = { {DW-ADC_WIDTH{1'b0}}, adc_data }; // 每通道独立缓冲,br 读侧指针轮询取出 logic [DW-1:0] buf [CH_NUM]; logic [$clog2(CH_NUM)-1:0] wr_idx, rd_idx; always_ff @(posedge clk_adc or negedge rst_n) begin if (!rst_n) begin wr_idx <= '0; end else if (adc_valid) begin buf[wr_idx] <= data_ext; wr_idx <= wr_idx + 1'b1; end end // 跨时钟:用打拍同步写指针,简化处理 logic [2:0] wr_ptr_sync; always_ff @(posedge clk_beam or negedge rst_n) begin if (!rst_n) wr_ptr_sync <= '0; else wr_ptr_sync <= {wr_ptr_sync[1:0], wr_idx}; end assign chan_bus = {<<DW{buf[rd_idx]}}; assign chan_valid = (wr_ptr_sync != rd_ptr_sync) && adc_valid; endmodule这段代码的思路是:ADC 时钟域按通道轮转写入缓冲,波束形成时钟域通过同步指针判断是否有新数据,然后把缓冲里的 16 个通道一次打包输出。chan_bus用了流操作符{<<DW{...}},把多通道数据按顺序排布,后端模块按固定位段取数即可。这里有个工程细节:12bit 扩展成 16bit,是为了给后续 FIR 滤波的中间运算留出进位余量,如果直接在 12bit 上做累加,很容易在动态聚焦时溢出。
实际遇到的一个问题是:ADC 时钟和波束形成时钟不同源时,指针同步只打两拍不够,部分通道会出现偶发丢数。我一般把写指针打三拍、读指针打两拍,一个拍子的延迟换稳定。
2.3 DC 偏置与通道间延迟对齐
ADC 输入端通常会有一个直流量,来源于模拟前端差分放大器的失调电压。这个直流如果不去掉,后面 12bit 扩张成 16bit 后,直流分量会直接占据信号的有符号表示范围,导致小信号被淹没。工程里的做法是:每个通道设一个 DC 偏置寄存器,做一次减法后再进入波束形成。
通道间延迟对齐更像是一个“玄学”问题,直到你被坑过一次才会重视它。PCB 走线长度差、ADC 采样相位差、FPGA 内部布线差异都会造成通道间几十 ps 到数 ns 的延迟差。波束形成对通道间延迟非常敏感,1ns 的误差在 5MHz 信号上就有大约 2° 的相位偏差,十几个通道叠加后旁瓣就抬起来了。所以在进入核心波束形成以前,最好先做一次全通道延迟校准:用同步脉冲激励所有通道,记录每个通道的到达时刻偏差,再在 FPGA 里给提前到达的通道加延迟。
3. 波束形成核心:动态聚焦延时与逐通道对齐的实现
3.1 动态聚焦延时:几何关系与查表
数字波束形成的本质是延迟求和(DAS)。每个阵元接收回波的时间不同,焦点越近,各通道之间的到达时间差越明显。焦点为 r,阵元间距为 d,声速为 c,那么第 n 个阵元相对参考阵元的延时近似为:
t_n = (r - sqrt(r² + d_n² - 2·r·d_n·cosθ)) / c
工程里通常不做实时三角函数计算,而是按焦点深度预先算好延时表,存入 RAM,成像时按深度查表。代码包里的核心模块用的就是查表方案,表深度一般 512~1024 点,每点对应一个成像深度,每个深度下存放各通道的延迟量。
动态聚焦的含义是:随着回波时间推移,焦点深度不断变化,延迟值也要跟着更新。更新节奏通常与成像线数、脉冲重复频率相关。一个 200mm 深度的成像,按 1540m/s 声速计算,回波时间约 260μs,每 1μs 更新一次延时表,整条扫描线需要更新大约 260 次。更新太频繁会消耗大量块内存,更新太慢会看到图像在深度方向上分层断裂。
3.2 粗延时与细延时:整数时钟 + 插值实现
延时量拆成两部分:整数个采样周期的粗延时和不足一个采样周期的细延时。粗延时直接用移位寄存器或者双口 RAM 读地址偏移实现,细延时用 FIR 插值或者线性插值补出来。下面这段 SystemVerilog 是延迟线模块的核心结构:
module delay_line #( parameter CH_ID = 0, parameter TBL_DEPTH = 1024, parameter FRAC_BITS = 8, parameter SAMP_WIDTH = 16 )( input logic clk, input logic rst_n, input logic [SAMP_WIDTH-1:0] data_in, input logic data_valid, input logic [FRAC_BITS-1:0] frac_delay, // 来自延时表 input logic [$clog2(TBL_DEPTH)-1:0] int_delay, output logic [SAMP_WIDTH-1:0] data_out, output logic out_valid ); logic [SAMP_WIDTH-1:0] shift_mem [2**10]; logic [$clog2(1024)-1:0] wr_ptr, rd_ptr; always_ff @(posedge clk) begin if (data_valid) begin shift_mem[wr_ptr] <= data_in; wr_ptr <= wr_ptr + 1'b1; end end // 读指针 = 写指针 - 整数延时,用减法避免同步问题 always_ff @(posedge clk) begin rd_ptr <= wr_ptr - int_delay; end // 细延时:两相插值,frac_delay 高 4bit 作为插值系数 logic [SAMP_WIDTH-1:0] cur_samp, prv_samp; logic [SAMP_WIDTH+8-1:0] interp; assign cur_samp = shift_mem[rd_ptr]; assign prv_samp = shift_mem[rd_ptr - 1'b1]; always_ff @(posedge clk) begin interp <= cur_samp - prv_samp; end always_ff @(posedge clk) begin data_out <= prv_samp + $signed(interp) * frac_delay >>> FRAC_BITS; end assign out_valid = data_valid; endmodule需要说明的是:rd_ptr用wr_ptr - int_delay实现,比直接维护一个读地址计数器更安全,因为这样读指针永远跟着写指针走,不会出现写还没到、读已经越界的情况。细延时部分用的是线性插值,这里frac_delay是 8bit,代表 0~1 个采样周期的分数延迟,interp先算相邻两个采样点的差值,再乘以分数系数并右移 8 位。
如果对成像质量要求更高,可以把线性插值换成 4 抽头的 FIR 插值,但代价是每通道会增加 3 个乘法器,16 通道就是 48 个乘法器,许多中端 FPGA 会吃不消。我的经验是:3.5MHz 探头 + 40MSps 采样率下,线性插值已经够用,插值误差主要体现在远场弱信号上,可以通过变迹窗抑制。
3.3 动态聚焦更新的时机与流水线
延时表在 FPGA 里通常做成双端口 RAM:一个端口由外部控制接口写入计算好的延迟数据,另一个端口由成像控制模块按深度读取。读地址随采样计数递增,当深度超过当前表项对应的范围时,自动换到下一组延迟值。
这里有一个很容易踩的坑:延时更新时间不能和采样数据流完全同步。因为同一时刻进入延迟线的数据是之前若干拍的数据,如果延时表在数据到达中途发生变化,会出现本通道聚焦点与其他通道不一致。工程里的解决办法是把延时表更新做成“软切换”:新的延时值写入 shadow 寄存器,等当前扫描线结束后的消隐期再统一生效。
流水线深度也要算清。ADC 数据 1 拍 + 延迟线读 1 拍 + 插值 2 拍 + 求和 2 拍,大约 6 拍的流水线延迟。这部分延迟在每条扫描线开始时是固定的,不会影响聚焦效果,但会影响后续的深度坐标计算,我一般会在后端深度计数里额外减掉这 6 个采样点。
4. 滤波与动态范围控制:带通 FIR、变迹与降采样
4.1 带通 FIR:中心频率与带宽的选择
波束形成输出的是宽带射频信号,既有探头中心频率附近的回波信号,也有带外噪声和 TGC 引入的低频摆尾。后端直接做包络检测会把这些噪声一起检出来,所以必须做带通滤波。滤波器参数要跟探头匹配:5MHz 探头通常选 2~8MHz 通带,或者 3~12MHz 宽带探头选 2~12MHz。
滤波器阶数和采样率直接相关。40MSps 采样率下做 2~8MHz 带通,过渡带大概 2MHz,需要用 64~128 阶 FIR 才能有 40dB 以上的阻带衰减。FPGA 里实现 128 阶对称 FIR,16 通道并行,每个通道一次乘加需要 64 个时钟周期。如果波束形成时钟是 200MHz,数据率是 40M,那么 5 个时钟周期内要跑完 64 次乘加,16 通道全并行需要 64 个 DSP 单元。这是硬资源,选型阶段就要算清楚。
4.2 从 MATLAB 到 Verilog:系数生成与量化
滤波系数我一般不用 Verilog 手工算,都是 MATLAB 设计好后量化导出。下面是生成系数并量化的脚本:
fs = 40e6; fpass = [2e6 8e6]; fstop = [1e6 9e6]; % 等波纹设计,通带纹波 0.1dB,阻带衰减 50dB d = designfilt('bandpassfir', ... 'StopbandFrequency1', fstop(1), ... 'PassbandFrequency1', fpass(1), ... 'PassbandFrequency2', fpass(2), ... 'StopbandFrequency2', fstop(2), ... 'StopbandAttenuation1', 50, ... 'PassbandRipple', 0.1, ... 'StopbandAttenuation2', 50, ... 'SampleRate', fs); % 量化为 16bit 定点 coeff = d.Coefficients; max_abs = max(abs(coeff)); coeff_q = round(coeff / max_abs * 32767); coeff_q(coeff_q > 32767) = 32767; coeff_q(coeff_q < -32768) = -32768; % 输出 Verilog 可用的十六进制格式 for i = 1:length(coeff_q) if coeff_q(i) < 0 fprintf('16''sh%04X,\n', coeff_q(i)+65536); else fprintf('16''sh%04X,\n', coeff_q(i)); end end这里有个细节容易被忽略:直接对浮点系数做round量化,会引入量化噪声,噪声频谱不是白噪声,而是在通带边缘加重。所以我习惯在量化前先把系数整体乘一个缩放因子,使最大系数接近 32767,这样量化步长最小,而且滤波后还要做增益归一化,整体缩放不影响最终输出幅度。系数对称 FIR 只需要存储一半系数,另一半镜像使用,能省一半的 ROM 资源。
如果发现量化和浮点仿真的滤波结果差异明显,先看两个地方:一是通带边缘的群延迟是否一致,二是阻带衰减是否下降了超过 5dB。这两处出问题基本都是系数位宽不够,把 16bit 提到 18bit 再看。
4.3 变迹、增益归一化与降采样
动态孔径扩展加变迹窗是抑制旁瓣的常用手段。浅层用小孔径,深度增加孔径逐渐增大。孔径增大后边缘通道离焦点远、入射角大,信号相关性差,直接用均匀权重会产生高旁瓣,所以孔径越大时边缘通道的权重越低,一般用汉明窗或者余弦窗做权重曲线。
变迹权重和延时表可以放在同一张表里,读取延时量的时候顺便把权重也读出来。实际工程里权重通常不是实时计算,是预先量化成 8bit 查表。求和后的输出幅度在不同深度差异巨大,还要做一次归一化,否则同一幅图像里浅层饱和、深层看不见。
降采样放在滤波之后。40MSps 数据包络检波之前降到 10MSps 左右,既能减后续运算量,又不影响图像分辨率。抽取可以用 CIC 滤波器,三阶 CIC、抽取率 4,实现代价小,但要注意 CIC 的通带纹波比较明显,如果做在带通 FIR 后面,级联通带会叠加。另一种做法是把抽取合并进 FIR 滤波器的输出端,直接每 4 个采样点输出一次,省掉独立的 CIC 级。
5. 常见问题排查:多通道波束形成翻车实录
5.1 单通道正常,合成后旁瓣猛增
现象:单独看每个通道的输出,波形都干净整齐;一旦延迟求和,图像边缘出现明显的重影和闪烁,旁瓣电平比仿真高 10dB 以上。
原因:通道间延迟没有对齐。PCB 走线长度差、ADC 采样时钟相位差、甚至电源纹波都会导致各通道在时间上不同步。仿真是理想的,板卡上没有。
解决:做一个全通道校准流程。发射端发一个短促脉冲,所有通道同时采集,在 FPGA 里统计每个通道脉冲前沿的到达时刻,把偏差值存入延迟表做补偿,并且每次上电都执行一遍。因为温度变化和器件老化会改变延迟特性,这个校准不能只做一次。
5.2 动态聚焦在深度切换处有水平断裂线
现象:图像在某个深度位置显出一条横线,线上清晰线下模糊,而且这条线的位置随探头移动变化。
原因:延时表切换时的跳变没有平滑过渡。我前面提过的软切换一旦被忽略,新的延迟值在数据流中途生效,处于切换点附近的采样点各通道聚焦点不一致,合成信号出现了瞬间发散。
解决:把延时表的更新从扫描线中间移到消隐期,并做一次“预加载”。在扫描线开始前,把所有深度的延迟值算好存入双口 RAM,扫描过程中读地址自然递增,不要在中途改写 RAM。如果一定需要动态调整,至少要做深度方向的插值,让延迟值在两个深度之间渐变,而不是一步跳变。
5.3 弱信号目标在图像上消失
现象:体模里的低回声目标在成像图上时有时无,把动态范围调大后整幅图像全是雪花点。
原因:定点位宽不够。这不是某一个模块的问题,而是链路累计的。ADC 12bit → 扩展 16bit → 延迟线插值乘法 → FIR 累加 → 求和的每一级都会损失低位,到后端时弱信号的几个 LSB 已经变成量化噪声了。
解决:逐级检查定点仿真和浮点仿真的差值。先在 MATLAB 里搭完整的定点模型,用同样的输入激励,逐级对比 FPGA 仿真波形和浮点波形的误差。我习惯在每级之间保留至少 2bit 余量:FIR 内部累加位宽做到 32bit,输出截断到 20bit,比最终输出多留 4bit。从最终输出倒是猛加位宽没用,要找到真正丢精度的那一级。
5.4 FIR 量化系数通带异常
现象:滤波输出高频段幅度正常,低频段比仿真低了 3dB,但看滤波器频谱图又没发现问题。
原因:量化后的系数零值过多,或者量化方式不对。当某个小系数绝对值小于 1/32768 时量化成 0,如果这些零值恰好分布在低频响应关键的抽头上,通带低频就会被削掉。
解决:不用逐级round,改用整体缩放量化,然后在输出端补偿缩放因子。可以参考第 4 章的 MATLAB 脚本——先按最大系数归一化再量化,这样可以避免小系数被截成 0。另外检查量化后系数是否存在连续多个 0,有的话需要调整滤波器阶数或改用最小二乘设计。
5.5 时序收敛差导致偶发毛刺
现象:综合后时序报告基本满足要求,但板级测试长时间运行后偶尔出现尖峰毛刺,用逻辑分析仪抓不到固定规律。
原因:多半是异步跨时钟处理不彻底。ADC 时钟域和波束形成时钟域之间只打了两拍,亚稳态概率虽小,但长时间运行后总有没被同步干净的跳变进入延迟线,导致偶尔读地址错误。
解决:所有跨时钟信号至少打三拍,数据总线用异步 FIFO,标志位单独同步。把时序约束里所有跨时钟路径显式声明为 false path 后会掩盖问题,我后来改成用set_max_delay约束关键跨时钟路径,让综合器重视这部分逻辑。
6. 工程验证技巧:让 FPGA 输出与 MATLAB 模型逐点对齐
拿到这套代码以后,我最建议你做的第一件事不是跑仿真,而是建立一个“模型对拍”流程。做法很简单:用 MATLAB 或 Python 生成一组模拟 RF 数据作为激励,同一份激励送给 MATLAB 浮点模型和 FPGA 仿真或板级执行,然后把两者的输出逐点做差。
测试向量要包含三种情况:短脉冲(看延迟线行为)、连续波(看 FIR 频率响应)、带噪信号(看位宽损失)。这三种测试向量下,浮点模型和 FPGA 定点输出的误差曲线能直接告诉你哪一级丢精度。比如你用短脉冲激励时,误差峰值出现在波形的上升沿,那么问题大概率在插值链;如果误差在整段信号上均匀分布,那就是滤波器系数量化问题。
我在这个工程里常用的对拍脚本核心段如下:
import numpy as np fs = 40e6 t = np.arange(1024) / fs # 模拟两个通道的回波:中心频率 5MHz,延迟差 0.3 个采样周期 sig_a = np.exp(-((t-5e-6)/2e-6)**2) * np.cos(2*np.pi*5e6*t) sig_b = np.roll(sig_a, 3) # 整数延时 3 个采样 # 小数延时用线性插值近似 frac = 0.3 sig_b_interp = sig_b * (1-frac) + np.roll(sig_b, 1) * frac # 把 sig_a 和 sig_b_interp 写入 testbench 可读取的 hex 文件 test_vector = np.round(sig_a * 32767).astype(np.int16) test_vector.tofile("ch0_data.hex")把两组数据送进 FPGA 仿真后,拉出同一段采样点,在 MATLAB 里和浮点模型做交叉相关,峰值位置偏差小于一个采样周期、幅度误差小于 5% 就算通过。超过这个范围就要回到第 5 章的检查清单逐项排查。
我习惯在每次改动参数后都强制走一遍这个流程:更新 MATLAB 模型 → 重新生成测试向量 → 跑 RTL 仿真 → 交叉对比。看起来多花半小时,实际上省掉的是上板以后对着逻辑分析仪盲猜的时间。从那以后我每次调滤波系数或者改延时表,都强制自己把对拍流程完整跑一遍,再上板验证,希望帮到你。
本文还有配套的精品资源,点击获取