FPGA驱动DAC8531:Verilog实现、时序分析与工程实践

FPGA驱动DAC8531:Verilog实现、时序分析与工程实践 1. 项目缘起为什么要在FPGA上驱动DAC8531最近在做一个需要高精度模拟信号输出的项目选型时看中了TI的DAC8531。这是一颗16位、单通道、低功耗的数模转换器SPI接口用起来挺方便。项目主控用的是FPGA原因很简单系统里还有其他高速并行的逻辑需要处理用MCU或者DSP总觉得时序上不够直接布线也麻烦。FPGA搞这种并行的、定时的逻辑是它的老本行用硬件描述语言比如Verilog去实现一个SPI Master来驱动DAC不仅时序可以精确到纳秒级还能轻松地和其他逻辑模块无缝衔接。网上搜了一圈发现关于DAC8531的驱动代码Arduino、STM32的HAL库例子一抓一大把但专门针对FPGA、写成可综合的Verilog或VHDL模块的要么语焉不详要么就是个简单的“点灯”式demo只讲了怎么发数据没提工程里真正会遇到的问题比如电源和参考电压的配合、输出缓冲器的设置、代码的可配置性和可移植性、仿真验证怎么搞。所以我决定结合这次实际项目的调试过程把从芯片手册解读、接口时序实现、到上板测试踩过的坑系统地整理出来。目标不是给你一段只能照抄的代码而是让你理解背后的“为什么”以后遇到类似的SPI接口DAC甚至其他外设都能举一反三。2. DAC8531关键特性与硬件设计要点在动手写代码之前必须吃透芯片手册。DAC8531虽然接口简单但几个关键配置没搞对输出就可能不对或者噪声很大。2.1 核心电气特性与我们的设计选择DAC8531的核心是一个16位的DAC内核输出范围取决于你给的参考电压VREF。我们项目需要0-5V的输出所以选择了VREF 5.0V。这里第一个要点就来了芯片手册明确要求电源电压VDD必须至少比VREF高1.2V。也就是说如果你用5V参考那么VDD至少需要6.2V。我们最初图省事用了同一个5V电源给VDD和VREF供电结果DAC输出根本达不到满量程线性度也奇差无比。后来老老实实给VDD供了7V用的是LDO从12V转过来问题立刻解决。所以供电这块千万别将就。另一个容易忽略的是输出缓冲器。DAC8531内部有一个输出运放它的供电VSS可以接GND或者负电压。如果接GND输出范围就是0V到VREF。如果接-5V输出范围就可以是-5V到5V。我们项目只需要单极性输出所以把VSS接地了。这里要注意输出运放的驱动能力虽然手册说可以驱动2kΩ的负载但为了稳定性和减少建立时间我们在输出端接了一个电压跟随器用了一颗精密运放OPA277然后再给后级电路。这样DAC本身的负载很轻性能更好。2.2 SPI通信时序深度解读DAC8531的SPI模式支持Mode 0 (CPOL0, CPHA0) 和 Mode 1 (CPOL0, CPHA1)。我们选择最常用的Mode 0。时序图一定要看明白它并不是标准的“24位数据一次传输”。帧结构一次完整的传输需要24个时钟周期SCLK。这24位数据分为两部分高8位D23-D16控制位。用于设置芯片的各种工作模式如关闭输出缓冲器、进入省电模式等。对于我们最常用的“写数据到DAC寄存器并立即更新输出”这个操作这8位固定为0011 00000x30。这里的“0011”是指令“0000”是不关心位但我们通常设为0。低16位D15-D0这就是你要转换的16位数据。注意它是左对齐的。也就是说你的16位数字量比如0xFFFF代表满量程应该直接放在这16位上。DAC内部会自动处理。关键时序参数写代码时必须满足。t_CSSCS下降沿到第一个SCLK上升沿的时间最小10ns。我们的FPGA系统时钟是50MHz周期20ns只要在CS拉低后等一个时钟周期再开始发SCLK就绝对安全。t_SU数据建立时间和t_HD数据保持时间都是针对SCLK边沿的。手册要求最小3.5ns。在FPGA里我们通常采用在SCLK的低电平中点更新数据SDIN在SCLK的上升沿采样这样可以轻松满足建立和保持时间。后面代码部分会具体体现。t_CSHCS高电平时间最小33ns。两次写操作之间CS必须拉高至少这么长时间。理解这个帧结构和时序要求是写出正确驱动代码的基础。很多简单的驱动代码只关注发16位数据忽略了前面那8位控制字或者时序做得太随意在低速时可能工作一旦提高SPI时钟速率就出问题。3. FPGA驱动模块的Verilog实现与详解接下来是核心部分用Verilog硬件描述语言来实现这个SPI Master。我们的设计目标是模块要简洁、可配置、易于集成到更大的系统中。3.1 模块接口定义与状态机设计首先定义模块的接口。除了标准的SPI信号CS_N, SCLK, SDIN还需要与FPGA内部逻辑交互的接口。module dac8531_driver ( input wire clk, // 主时钟比如50MHz input wire rst_n, // 低电平复位 input wire [15:0] data_in, // 要转换的16位数据 input wire data_valid, // 数据有效脉冲上升沿触发一次转换 output reg dac_cs_n, // 连接到DAC8531的CS# output reg dac_sclk, // 连接到DAC8531的SCLK output reg dac_sdin, // 连接到DAC8531的SDIN output wire busy // 模块忙信号正在发送时拉高 );为什么需要busy信号因为一次SPI传输需要24个时钟周期不是瞬间完成的。当busy为高时外部逻辑应该保持data_in稳定并且不要发出新的data_valid脉冲。这是一个非常实用的流控信号。控制整个发送过程的是一个经典的状态机。这里我设计了一个简单清晰的三段式状态机推荐写法清晰且利于综合localparam S_IDLE 2d0; // 空闲状态 localparam S_LOAD 2d1; // 加载数据并准备发送 localparam S_SHIFT 2d2; // 正在移位发送 reg [1:0] state, next_state; reg [23:0] shift_reg; // 24位移位寄存器存放待发送的完整帧 reg [4:0] bit_cnt; // 位计数器0-23S_IDLE等待data_valid信号。S_LOAD当data_valid到来将控制字0x300000和输入数据组合成24位帧装入shift_reg。{8h30, data_in}这个拼接操作就实现了数据左对齐。这个状态只持续一个时钟周期用于锁存数据避免发送过程中数据变化。S_SHIFT在这个状态下生成SPI时钟SCLK并逐位将shift_reg的数据移到dac_sdin上。发送完24位后回到S_IDLE。3.2 SPI时钟生成与数据移位的实现细节如何在S_SHIFT状态下产生正确的SPI时序是关键。我们假设FPGA主时钟clk为50MHz周期20ns。如果我们想让SPI时钟dac_sclk为5MHz周期200ns那么每个SCLK周期就需要10个clk周期。这里采用一个计数器clk_div_cnt来产生SCLK的占空比。更关键的是数据对齐。为了满足建立和保持时间我采用如下策略always (posedge clk or negedge rst_n) begin if (!rst_n) begin dac_sclk 1b0; dac_sdin 1b0; shift_reg 24b0; bit_cnt 5b0; end else begin case (state) S_LOAD: begin shift_reg {8h30, data_in}; // 加载数据 bit_cnt 5d23; // 从最高位第23位开始发 clk_div_cnt 4d0; dac_sclk 1b0; // 确保SCLK起始为低电平Mode 0 end S_SHIFT: begin clk_div_cnt clk_div_cnt 1; // 在每个SCLK周期的中间低电平期间更新数据 if (clk_div_cnt 4d4) begin // 假设10分频第4个clk时 dac_sdin shift_reg[23]; // 输出最高位 end // 在SCLK的上升沿从低到高后移位 if (clk_div_cnt 4d9) begin // 第9个clk时产生上升沿 dac_sclk 1b1; shift_reg {shift_reg[22:0], 1b0}; // 左移 bit_cnt bit_cnt - 1; end // 在SCLK的下降沿从高到低准备下一个位 if (clk_div_cnt 4d0) begin // 计数器归零产生下降沿 dac_sclk 1b0; if (bit_cnt 5d0) begin // 所有位发送完毕 // 状态机在另一个always块里跳转到IDLE end end end endcase end end这段代码的逻辑是在S_SHIFT状态下一个clk_div_cnt循环是10个clk周期。当clk_div_cnt计数到4即SCLK低电平的中间点时更新dac_sdin的数据。这样在接下来的SCLK上升沿到来之前数据已经有足够长的建立时间t_SU。当clk_div_cnt计数到9时产生SCLK的上升沿。此时数据已经稳定DAC芯片会在上升沿采样SDIN。在上升沿之后我们立即将移位寄存器左移为发送下一位做准备。当clk_div_cnt归零时产生SCLK的下降沿。此时下一位数据已经更新到dac_sdin上等待下一个上升沿。这种“在低电平中点更新数据在上升沿采样”的模式是FPGA实现SPI Mode 0非常稳健的方法。它同时宽松地满足了芯片对建立时间和保持时间的要求。3.3 片选信号CS#的精确控制片选信号dac_cs_n的控制也有讲究。手册要求CS#在数据传输期间必须保持低电平在数据传输前后为高电平。always (posedge clk or negedge rst_n) begin if (!rst_n) begin dac_cs_n 1b1; // 空闲时CS#为高 end else begin case (state) S_IDLE: dac_cs_n 1b1; S_LOAD: dac_cs_n 1b0; // 一旦开始加载数据立即拉低CS# S_SHIFT: dac_cs_n 1b0; default: dac_cs_n 1b1; endcase end end注意我们在S_LOAD状态仅仅一个时钟周期就拉低了CS#比产生第一个SCLK上升沿要早。这确保了满足t_CSS的要求。当状态机从S_SHIFT跳回S_IDLE时CS#会被拉高结束本次传输。4. 仿真验证Modelsim中的测试平台搭建代码写完了绝不能直接上板子。用仿真验证是保证设计正确的第一道也是最重要的关卡。我们需要编写一个Testbench。4.1 编写全面的测试激励Testbench的主要任务是实例化我们的dac8531_driver模块并产生各种激励信号观察其输出波形是否符合SPI时序规范。timescale 1ns/1ps module tb_dac8531_driver(); reg clk; reg rst_n; reg [15:0] data_in; reg data_valid; wire dac_cs_n, dac_sclk, dac_sdin, busy; // 实例化驱动模块 dac8531_driver uut ( .clk(clk), .rst_n(rst_n), .data_in(data_in), .data_valid(data_valid), .dac_cs_n(dac_cs_n), .dac_sclk(dac_sclk), .dac_sdin(dac_sdin), .busy(busy) ); // 生成50MHz时钟 initial begin clk 0; forever #10 clk ~clk; // 周期20ns end // 产生测试激励 initial begin // 初始化 rst_n 0; data_in 16h0000; data_valid 0; #100; // 复位一段时间 rst_n 1; #200; // 测试1发送一个中间值 0x8000 (negedge clk); data_in 16h8000; data_valid 1; (negedge clk); data_valid 0; // 产生一个时钟周期的高脉冲 // 等待一次传输完成 wait(busy 0); #500; // 等待一段时间满足t_CSH // 测试2发送满量程值 0xFFFF (negedge clk); data_in 16hFFFF; data_valid 1; (negedge clk); data_valid 0; wait(busy 0); #1000; // 测试3快速连续发送测试busy信号的流控 // 这里可以设计一个在busy为高时尝试发送data_valid的场景看模块是否忽略它 // ... $stop; end endmodule4.2 关键时序的波形分析与断言检查在Modelsim中运行仿真后我们要重点观察几个地方CS#与SCLK的时序关系放大波形测量CS#下降沿到第一个SCLK上升沿的时间t_CSS。应该大于10ns。同样检查最后一个SCLK下降沿到CS#上升沿的时间。SDIN与SCLK的时序关系这是重中之重。找到任何一个SCLK的上升沿测量在这个上升沿之前SDIN数据是否已经稳定了至少3.5ns建立时间t_SU在这个上升沿之后数据是否保持了至少3.5ns保持时间t_HD。我们的设计策略在低电平中点更新数据应该能轻松满足。数据帧内容将SDIN波形按SCLK周期展开检查发送的24位数据是否正确。前8位应该是001100000x30后16位应该等于我们输入的data_in。busy信号检查busy信号是否在data_valid后拉高并在24位数据发送完毕后拉低。在busy为高期间如果再次产生data_valid模块不应开始新的传输。为了更自动化地检查可以在Testbench中加入SystemVerilog的断言Assertion。例如// 检查建立时间 t_SU (假设SCLK周期200ns上升沿在clk_div_cnt9时) property setup_time_check; (posedge clk) (clk_div_cnt 4d8) |- ##1 (dac_sdin $stable(dac_sdin)); endproperty assert_setup: assert property (setup_time_check) else $error(SDIN setup time violation!);这个断言的意思是在SCLK上升沿前一个主时钟周期此时数据应该早已稳定SDIN信号应该保持稳定。如果发生改变就会报错。通过仿真和波形分析我们能最大程度地在软件层面排除逻辑错误。5. 上板调试与实测中的“坑”与解决之道仿真通过后就可以进行上板调试了。这里才是“坑”最多的地方。5.1 电源与噪声问题排查我们第一次上电用FPGA发送一个固定的16位数据然后用示波器看DAC的输出发现波形不对噪声很大而且输出值不准确。问题1输出值偏差大。检查了代码和时序没问题。然后用万用表量VREF引脚发现只有4.92V而不是预期的5.00V。原因是给参考电压芯片例如REF5050的输入电源纹波太大且走线过长。解决方案在VREF芯片的输入和输出引脚就近放置大小电容比如10uF钽电容并联0.1uF陶瓷电容并且电源走线尽量粗、短。更换为更干净的LDO为REF芯片供电后VREF稳定在5.00V输出值立刻准确了。问题2输出噪声大。即使在输出固定电压时示波器上也看到有几十mV的高频毛刺。这些毛刺很可能来自数字部分FPGA的IO口翻转通过电源或地耦合到了模拟输出。解决方案布局布线隔离确保FPGA的电源和DAC的模拟电源是分开的即使都从同一个LDO出来也要用磁珠或0欧电阻进行隔离。地平面要用单点连接。去耦电容在DAC8531的VDD和VSS引脚尽可能靠近芯片管脚放置0.1uF和1uF的陶瓷电容。输出滤波在DAC的输出端或者我们外加的电压跟随器输出端增加一个简单的RC低通滤波器。例如一个100Ω电阻串联后面接一个0.1uF电容到地。这个滤波器的截止频率约16kHz对于很多应用来说足够了能有效滤除SPI时钟等数字噪声。IO口设置在FPGA的约束文件里将连接DAC的SPI引脚CS_N SCLK SDIN的IO标准设置为慢摆率Slew Rate SLOW并适当增加驱动电流。这可以减少信号边沿的振铃和对外辐射。5.2 时序问题的硬件排查与逻辑分析仪使用有时候仿真没问题但实际波形就是不对。比如SCLK的占空比不是50%或者CS#信号上有毛刺。这时逻辑分析仪是神器。我们将逻辑分析仪的探头连接到CS_N、SCLK、SDIN三个信号上以高采样率比如500MS/s捕获一次完整的传输过程。然后重点分析信号完整性看SCLK和SDIN的上升/下降沿是否干净有没有过冲或振铃。如果有可能需要调整FPGA IO的端接电阻如果有的话或者检查PCB走线是否过长、是否有过孔太多。时序测量用逻辑分析仪的测量工具直接测量t_CSS、t_SU、t_HD是否满足芯片手册要求。实测中我们发现当SPI时钟SCLK频率提高到10MHz以上时由于PCB走线延迟和FPGA内部路径延迟t_SU变得非常紧张接近极限值。这就是为什么我之前代码里选择在低电平中点更新数据就是为了给t_SU留出最大余量。如果逻辑分析仪显示时序违规就需要回头调整Verilog代码中数据更新的相位或者降低SPI时钟频率。另一个硬件相关的问题是上电顺序。DAC8531手册虽然没有严格规定但一般建议模拟电源VDD, VREF和数字电源IOVDD如果分开的话差不多同时上电或者模拟电源先上。要避免数字IO先上电而模拟部分还没供电的情况这可能引起闩锁效应Latch-up。在我们的设计中所有电源都来自同一个电源树上电顺序由电源芯片本身决定所以问题不大但这是需要留意的点。6. 代码优化与高级功能扩展基础功能稳定后可以考虑对驱动代码进行优化和功能扩展使其更健壮、更易用。6.1 参数化设计提升复用性最初的代码里SPI时钟分频比、控制字都是写死的。我们可以用Verilog的parameter和localparam来使其可配置。module dac8531_driver #( parameter CLK_FREQ 50_000_000, // 输入时钟频率单位Hz parameter SPI_FREQ 5_000_000 // 期望的SPI时钟频率单位Hz ) ( // ... 端口声明不变 ); // 根据频率参数计算分频系数 localparam DIVIDER CLK_FREQ / (SPI_FREQ * 2); // 因为SCLK高低各占一半 localparam DIVIDER_CNT_WIDTH $clog2(DIVIDER); reg [DIVIDER_CNT_WIDTH-1:0] clk_div_cnt; // ... 后续代码使用DIVIDER进行计算 endmodule这样同一个驱动模块只需要在例化时修改参数就能适配不同的FPGA主频和SPI速率需求复用性大大增强。6.2 实现同步与异步复位策略我们的代码使用了异步复位rst_n。这在很多情况下是可行的。但在一些对复位毛刺敏感或者需要跨时钟域的系统里可能需要同步复位。我们可以提供一个参数来选择parameter RESET_POLARITY LOW; // LOW for active-low, HIGH for active-high parameter RESET_TYPE ASYNC; // ASYNC or SYNC always (posedge clk or negedge rst_n) begin if (RESET_TYPE ASYNC) begin if (!rst_n) begin // 异步复位逻辑 end else begin // 正常逻辑 end end end // 或者为同步复位写一个单独的always块 always (posedge clk) begin if (RESET_TYPE SYNC) begin if (rst_sync_pulse) begin // 这是一个经过同步化处理的复位脉冲 // 同步复位逻辑 end else begin // 正常逻辑 end end end6.3 添加多通道支持与后台刷新逻辑如果一个系统里需要用到多个DAC8531或者一个DAC需要不同工作模式如关闭缓冲器我们可以扩展驱动模块。多片选支持可以将dac_cs_n改为一个向量dac_cs_n[3:0]并在输入端口增加一个chip_sel[1:0]信号。状态机在发送数据前根据chip_sel的值只拉低对应的CS#信号。这样就能用同一个SPI总线驱动多个DAC。后台刷新Ping-Pong Buffer对于一些需要连续、高速更新波形的应用比如信号发生器直接等待busy信号再送下一个数据可能来不及。我们可以设计一个双缓冲Ping-Pong Buffer机制。驱动模块内部有两个24位的缓冲区。当外部逻辑写入一个数据到“前台缓冲区”时驱动模块可以立即开始发送“后台缓冲区”的数据。发送完毕后两个缓冲区交换角色。这样数据准备和发送可以并行进行提高了吞吐率。这需要稍微复杂一点的状态机和缓冲区管理逻辑但对于高性能应用是值得的。7. 从DAC8531到其他SPI外设的通用化思考写完并调通这个DAC8531驱动其意义远不止于驱动这一颗芯片。它本质上是一个针对特定SPI从设备24位帧Mode 0MSB First的通用SPI Master控制器。我们可以从中抽象出更通用的模块。例如我们可以定义一个更顶层的SPI Master模块其接口如下module spi_master #( parameter DATA_WIDTH 8, parameter CPOL 0, parameter CPHA 0 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] tx_data, input wire tx_valid, output wire spi_miso, // 如果需要读数据 output reg spi_mosi, output reg spi_sclk, output reg spi_cs_n, output wire busy );这个通用模块负责产生SPI时钟、管理CS#、并按照指定的CPOL/CPHA模式串行收发数据。然后针对DAC8531我们只需要写一个很薄的“包装层”Wrappermodule dac8531_wrapper ( input wire clk, input wire rst_n, input wire [15:0] dac_data, input wire dac_valid, output wire spi_cs_n, output wire spi_sclk, output wire spi_mosi, output wire busy ); wire [23:0] spi_tx_data {8h30, dac_data}; spi_master #( .DATA_WIDTH(24), .CPOL(0), .CPHA(0) ) u_spi_master ( .clk(clk), .rst_n(rst_n), .tx_data(spi_tx_data), .tx_valid(dac_valid), .spi_mosi(spi_mosi), .spi_sclk(spi_sclk), .spi_cs_n(spi_cs_n), .busy(busy) ); endmodule这样一来spi_master模块可以复用于驱动SPI Flash、SPI ADC、数字电位器等各种SPI设备大大提高了代码的复用性和项目的开发效率。这次驱动DAC8531的经历最宝贵的收获正是这种“从特殊到一般”的设计思维训练。硬件描述语言编程绝不仅仅是让逻辑“跑起来”而是要构建清晰、可靠、可扩展的硬件电路模型。