FPGA跑着跑着烫手,你却说不出它到底多少度,这在排查逻辑和供电问题时特别被动。其实Xilinx 7系列FPGA内部早就集成了一块双12位ADC,叫XADC,只要通过XADC IP核把内部温度寄存器读出来,就能实时监控芯片温度,甚至能驱动风扇调速、触发过热保护。这篇文章我会从IP核配置、Verilog状态机到仿真上板,完整走一遍读取FPGA内置温度传感器的全过程,代码可以直接抄,注释也按工程标准写好了,适合正在做FPGA监控系统或者刚接触XADC的同学。
1. 为什么我说XADC是每个FPGA开发者都该掌握的“自带仪表”
1.1 XADC到底能干什么
Xilinx 7系列FPGA在芯片内部集成了一颗双12位、最高1MSPS的ADC,也就是XADC。这颗ADC不仅能测外部模拟信号,还专门留出了几路内部监控通道,比如芯片结温、VCCINT核心电压、VCCAUX辅助电压、VCCBRAM存储电压。其中最有用的就是温度通道,因为在工程调试阶段,往往一跑大资源逻辑或者高速接口,芯片就开始明显升温,如果能在RTL里实时看到温度值,很多问题一下子就能定位到“是不是温度导致时序收敛变差”这种方向上去。
XADC内部温度测量用的是一片温度敏感二极管,温度变化会转化成电压变化,再由ADC量化成12位数据。这套测量链路的精度经过Xilinx出厂校准,在0到85摄氏度的范围内误差一般在正负几度以内,做工业级板卡的温度监控完全够用。更重要的是,这颗ADC是芯片自带的,不需要外挂传感器,也不占用额外的PCB面积,直接通过IP核或原语就能访问。
1.2 IP核和原语,到底选哪个
XADC本身是一个硬件原语,但直接在RTL里例化原语比较麻烦,因为它涉及到校准系数、配置寄存器、报警逻辑,还有一堆采样通道的控制时序。实际工程里我强烈建议直接用Vivado提供的XADC Wizard IP核,也就是xadc_wiz。IP核把底层寄存器、校准参数、报警阈值和可选的AXI接口都封装好了,你可以像用普通IP核一样例化它,省去大量手写配置寄存器的时间。
XADC IP核对外提供两种主接口:AXI4-Lite和DRP。如果是在Zynq里用ARM核读温度,走AXI4-Lite很自然;但如果你的项目是纯FPGA逻辑,比如温度监控、风扇调速、过温保护这类场景,用DRP接口会更轻量,不占AXI互联资源,控制时序也直观。我这边所有代码都是基于DRP接口写的,这也是很多国产FPGA兼容设计和参考工程里最常见的形式。
1.3 我最终采用的读取方案
整个工程我拆成了四个模块:XADC IP核、温度读取状态机、温度数值换算、数码管显示。IP核负责采集温度并产生EOC转换结束信号;状态机在EOC到来后发起一次DRP读操作,把温度寄存器值取回来;换算模块把这个原始值转成适合人类阅读的摄氏度;最后通过数码管扫描显示出来。
这里有一个很关键的思路:不要每时每刻都去读XADC。XADC在连续采样模式下大约每1微秒完成一次转换,我们只需要在EOC脉冲到来后读一次温度寄存器,其余时间让状态机休眠即可。这样既不会给DRP总线带来不必要的压力,也能保证你看到的数据永远是最近一次转换结果。
2. Vivado里配置XADC IP核的完整操作
2.1 新建XADC IP核时的关键选项
打开Vivado工程后,在IP Catalog里搜索“XADC”,找到XADC Wizard双击创建。组件名我习惯保持默认的xadc_wiz_0,方便后面直接复制代码。
Basic配置页有四个地方需要特别关注。第一,Interface选择DRP,不要选AXI4-Lite,否则后面还要多挂一组总线逻辑。第二,ADC Settings里把Sequencer Mode设为Continuous Sequence,这样XADC会自动循环采样所有使能的通道。第三,启用Temperature通道,其余通道按需勾选,我的例子里只用了温度通道。第四,DCLK Frequency里填入你实际的输入时钟频率,比如50MHz,IP核会根据这个频率自动计算内部时序参数。
Alarm选项卡可以设置温度报警阈值。默认是结温超过某个值就拉高alarm_out信号,我建议一开始先保留默认值,等能正常读到温度了再按自己的散热方案去调。Output Options里可以勾选生成Verilog例化模板文件,后面接信号时会用到。
2.2 例化模板里的信号怎么接
XADC IP核生成后,在Sources窗口里展开IP核并双击打开Example Design,能看到官方生成的例化代码。DRP模式下需要关注的信号主要有这些:
| 信号方向 | 信号名称 | 作用 |
|---|---|---|
| 输入 | daddr_in[6:0] | DRP寄存器地址 |
| 输入 | dclk_in | DRP工作时钟,必须和配置里声明的一致 |
| 输入 | den_in | DRP读/写使能脉冲 |
| 输入 | di_in[15:0] | DRP写数据 |
| 输入 | dwe_in | DRP写使能,读操作固定为0 |
| 输出 | do_out[15:0] | DRP读数据 |
| 输出 | drdy_out | DRP读数据有效信号 |
| 输出 | eoc_out | ADC转换完成脉冲 |
| 输出 | eos_out | ADC序列完成脉冲 |
| 输出 | alarm_out | 报警输出,含温度和电压报警 |
温度寄存器的DRP地址是7’h00,该寄存器高12位保存的就是ADC转换后的温度原始值,低4位固定为0。如果需要读取VCCINT,地址换成7’h01;VCCAUX对应7’h02;VCCBRAM对应7’h03。我们在状态机里可以直接把daddr固定为0,只读温度通道。
2.3 一个容易踩坑的时钟问题
XADC的DCLK输入不是随便给的。它的频率范围一般在1MHz到250MHz之间,但IP核配置界面里你填多少,内部时序就会按多少计算,实际给的时钟频率必须和配置完全一致,否则采样率和报警寄存器都会算错。我自己踩过一次:开发板时钟是50MHz,偏偏我当时图省事在IP核配置里填了100MHz,结果上板之后温度读数明显偏高,排查了很久才发现是DCLK配置和实际时钟不匹配。所以建IP的时候一定要看清楚时钟来源。
另外,如果XADC使用的是PLL产生的时钟,要确保PLL锁定之后再让XADC工作。翻遍Xilinx文档也没有强制要求,但实际电路里如果时钟在复位期间还在抖动,转换结果可能会出现一些毛刺。我习惯在复位释放之前等PLL locked信号,再启动温度读取状态机。
3. 核心Verilog代码逐段拆解
3.1 第一段:XADC温度读取状态机
温度读取的逻辑不复杂,核心是一个小状态机。系统空闲时等待EOC上升沿,这个边沿代表XADC刚完成一次转换;收到边沿后,我们向DRP接口发起一次读操作,地址指向温度寄存器;等到drdy为高时,把do_out上的数据锁存下来,同时点亮一个指针,提示上层模块数据已经更新。
我把这个模块单独写成xadc_temp_reader.v,方便后面复用:
module xadc_temp_reader ( input wire clk, // 系统时钟,50MHz input wire rst_n, // 低电平复位 input wire eoc, // XADC转换结束脉冲 input wire [15:0] do, // DRP读数据 input wire drdy, // DRP数据有效 output reg [6:0] daddr, // DRP寄存器地址 output reg den, // DRP读使能 output reg dwe, // DRP写使能,固定0 output reg [15:0] di, // DRP写数据,固定0 output reg [15:0] raw_temp, // 温度寄存器原始值 output reg temp_valid // 一次有效读取完成脉冲 ); reg [1:0] state; reg eoc_d0, eoc_d1; wire eoc_pos = eoc_d0 && ~eoc_d1; localparam IDLE = 2'd0; localparam START_READ = 2'd1; assign dwe = 1'b0; assign di = 16'h0; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; daddr <= 7'h00; den <= 1'b0; eoc_d0 <= 1'b0; eoc_d1 <= 1'b0; raw_temp <= 16'h0; temp_valid <= 1'b0; end else begin eoc_d0 <= eoc; eoc_d1 <= eoc_d0; case (state) IDLE: begin temp_valid <= 1'b0; if (eoc_pos) begin daddr <= 7'h00; // 温度寄存器地址 den <= 1'b1; // 启动一次DRP读 state <= START_READ; end end START_READ: begin den <= 1'b0; if (drdy) begin raw_temp <= do; temp_valid <= 1'b1; state <= IDLE; end end default: state <= IDLE; endcase end end endmodule这段代码里有一个细节我在工程中调整过多次:EOC边沿检测用两级触发器打拍,目的是消除亚稳态风险。XADC的eoc_out和系统时钟在理论上已经由IP核同步过,但为了仿真严谨和实际抗干扰,加两级触发器成本极低,值得保留。
状态机只有两个状态,等于让DRP读操作每次都从头开始。有人可能会问,为什么不连续发读请求?其实连续读完全没有必要,因为温度本身变化很慢,一秒钟能有几十次更新已经足够监控用了。EOC每次到来读一次,数据永远是最新的,逻辑又简单,这就是上面那句“让状态机休眠”的思路,也是最省资源、最不容易出错的做法。
3.2 第二段:温度原始值怎么换算成摄氏度
XADC温度寄存器的原始值是12位ADC码。根据UG480手册的线性关系,0摄氏度对应的ADC码大约在542左右,温度每升高1摄氏度,ADC码大约增加1/0.4846,也就是每大约2个码对应1摄氏度。为了在数码管上显示整数温度,我直接做了一个粗略的线性近似:
温度整数摄氏度约等于(adc_code - 542)除以2。这个公式在0到100摄氏度范围内误差在2摄氏度以内,做演示和监控完全够用。如果你要做精确温度采集,可以在FPGA里用定点乘法器实现更精确的换算公式,原理是一样的,只是要额外处理小数位和除法。
换算模块我单独写成temp_calc.v:
module temp_calc ( input wire [15:0] raw_temp, output reg [11:0] temp_int // 整数摄氏度 ); wire [11:0] adc_code = raw_temp[15:4]; // 取12位有效数据 always @(*) begin if (adc_code >= 12'd542) temp_int = (adc_code - 12'd542) >> 1; else temp_int = 12'd0; end endmodule为什么要取raw_temp的高12位?因为XADC的温度寄存器低4位是固定0,有效数据都在[15:4]。如果你把整个16位拿来算温度,结果会整整多出16倍,读出来动辄几千度,这个坑在论坛里反复被问到。
3.3 第三段:二进制数转成BCD码
数码管不能直接显示二进制,必须先把换算后的二进制温度转成BCD码。最常用的方法是“移位加3”算法,也叫Double Dabble。原理很简单:从二进制最高位开始逐位左移,每次左移后检查每个BCD位是否大于4,大于4就加3,经过足够的位移次数后,BCD区里留下的就是十进制结果。
这里直接给出一个12位二进制转16位BCD的模块:
module bin2bcd ( input wire [11:0] bin, output reg [15:0] bcd ); integer i; reg [27:0] tmp; always @(*) begin tmp = 28'd0; tmp[11:0] = bin; for (i = 0; i < 12; i = i + 1) begin if (tmp[27:24] > 4) tmp[27:24] = tmp[27:24] + 3; if (tmp[23:20] > 4) tmp[23:20] = tmp[23:20] + 3; if (tmp[19:16] > 4) tmp[19:16] = tmp[19:16] + 3; if (tmp[15:12] > 4) tmp[15:12] = tmp[15:12] + 3; tmp = tmp << 1; end bcd = tmp[27:12]; end endmodule这个模块是纯组合逻辑,综合后相当于一串加法和移位电路,在几十MHz下没有任何时序压力。注意for循环的次数要和输入位宽匹配,12位输入就循环12次,写多了会出错,写少了结果不对。
3.4 第四段:数码管扫描显示
温度值现在变成了BCD码,但一颗FPGA不可能为每一位数码管单独分配几十个引脚,所以实际开发板都会把数码管段选并在一起,用扫描方式轮流点亮。只要扫描频率高于人眼的闪烁阈值,看起来就是4位数同时亮着。
我写了一个通用的四位数码管扫描模块,输入就是上一步生成的16位BCD数据:
module seg_scan_display ( input wire clk, input wire rst_n, input wire [15:0] bcd_data, // 4位BCD数据 output reg [6:0] seg, // 段选,共阳极低有效 output reg [3:0] sel // 位选,低有效 ); reg [17:0] cnt; reg [1:0] scan; reg [3:0] current_data; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 18'd0; else cnt <= cnt + 1'b1; end assign scan = cnt[17:16]; always @(*) begin case (scan) 2'd0: {sel, current_data} = {4'b0111, bcd_data[3:0]}; 2'd1: {sel, current_data} = {4'b1011, bcd_data[7:4]}; 2'd2: {sel, current_data} = {4'b1101, bcd_data[11:8]}; 2'd3: {sel, current_data} = {4'b1110, bcd_data[15:12]}; default: {sel, current_data} = {4'b1111, 4'h0}; endcase end always @(*) begin case (current_data) 4'h0: seg = 7'b1000000; 4'h1: seg = 7'b1111001; 4'h2: seg = 7'b0100100; 4'h3: seg = 7'b0110000; 4'h4: seg = 7'b0011001; 4'h5: seg = 7'b0010010; 4'h6: seg = 7'b0000010; 4'h7: seg = 7'b1111000; 4'h8: seg = 7'b0000000; 4'h9: seg = 7'b0010000; default: seg = 7'b1111111; endcase end endmodule段码表我按最常见的共阳极数码管写的,段a到g分别对应bit6到bit0,小数点该灭就灭,不参与显示。如果你的开发板是共阴极数码管,把每段取反即可。
3.5 顶层模块把四块拼起来
顶层模块负责把XADC IP核和上面三个自定义模块串起来。我假定你的工程里已经添加了名为xadc_wiz_0的XADC IP核,并且开发板有一个50MHz系统时钟。
module xadc_top ( input wire sys_clk, input wire rst_n, output wire [6:0] seg, output wire [3:0] sel, output wire temp_alarm_led ); wire [6:0] daddr; wire den; wire dwe; wire [15:0] di; wire [15:0] do; wire drdy; wire eoc; wire eos; wire alarm_w; // XADC IP核 xadc_wiz_0 u_xadc ( .daddr_in (daddr), .dclk_in (sys_clk), .den_in (den), .di_in (di), .dwe_in (dwe), .do_out (do), .drdy_out (drdy), .eoc_out (eoc), .eos_out (eos), .alarm_out (alarm_w), .alarm_temp_out (), .alarm_power_out () ); // 温度读取状态机 wire [15:0] raw_temp; wire temp_valid; xadc_temp_reader u_reader ( .clk (sys_clk), .rst_n (rst_n), .eoc (eoc), .do (do), .drdy (drdy), .daddr (daddr), .den (den), .dwe (dwe), .di (di), .raw_temp (raw_temp), .temp_valid (temp_valid) ); // 温度换算 wire [11:0] temp_int; temp_calc u_calc ( .raw_temp (raw_temp), .temp_int (temp_int) ); // 二进制转BCD wire [15:0] bcd_data; bin2bcd u_bcd ( .bin (temp_int), .bcd (bcd_data) ); // 数码管扫描显示 seg_scan_display u_seg ( .clk (sys_clk), .rst_n (rst_n), .bcd_data (bcd_data), .seg (seg), .sel (sel) ); assign temp_alarm_led = alarm_w; endmodule这里有个细节值得提一下:我把xadc_wiz_0的alarm_out直接引到了LED上,这样即使不上数码管,也能通过LED快速判断芯片是否触发过温报警。做项目时我习惯给报警信号留一个LED或者GPIO,方便现场快速验证。
4. 仿真与上板验证:一步一步确认数据通路
4.1 Testbench怎么写
很多人拿到XADC工程后第一步就上板,结果发现数码管不亮或者数据不动,又不知道哪里出了问题。我建议先在Vivado里做一次行为仿真。XADC IP核的仿真模型会在仿真环境里自动模拟ADC转换,EOC、DRDY这些信号都能看到。
Testbench不需要写得太复杂,只要能拉高复位、给一个50MHz时钟,然后把顶层模块例化进去就行:
module tb_xadc_top; reg sys_clk; reg rst_n; wire [6:0] seg; wire [3:0] sel; wire temp_alarm_led; initial begin sys_clk = 0; rst_n = 0; #100 rst_n = 1; end always #10 sys_clk = ~sys_clk; xadc_top u_top ( .sys_clk (sys_clk), .rst_n (rst_n), .seg (seg), .sel (sel), .temp_alarm_led (temp_alarm_led) ); endmodule跑仿真时,重点观察EOC、DRDY和DO这几个信号。如果EOC周期性能到,每次EOC之后DRDY也出现高电平脉冲,DO上能看到一个非零的温度寄存器值,说明XADC IP核和读取状态机的工作时序已经接对了。
4.2 波形上要重点确认的三件事
第一,确认EOC上升沿出现后,DEN信号紧接着拉高,并且只保持一个周期。如果DEN被拉高超过一个周期,DRP接口可能识别成两次操作,需要回头检查状态机的状态切换。第二,确认DRDY信号到来时,DO上的数据和RAW_TEMP锁存值一致。这一步能直接判断寄存器地址是否写对了。第三,确认TEMP_VALID脉冲不是一直为高,而是每次读完数据才跳一下,否则说明状态机可能停留在某个状态没有正确回跳。
我通常在仿真里跑至少50微秒,因为XADC转换周期大约1微秒,50微秒能覆盖几十次完整读取,足以确认时序稳定。
4.3 上板实测记录
仿真通过后,把生成好的BIT文件下载到开发板,观察数码管。第一次上板我预期看到30到50摄氏度之间的数据,因为FPGA即使待机也会有一定温升。如果数码管显示的是个位数或者几百的大数,基本可以断定温度换算模块的取位有问题,最常见的就是没有右移4位直接用整个16位寄存器计算。
我有一次上板显示温度比实测低了差不多10度,最后发现是手不小心碰到芯片散热片,导致热传太快,读数自然偏低。后来我用热风枪在芯片上方吹风,温度读数会明显上升,再把手移开读数慢慢回落,才确认整个链路是真的在工作。如果你也想验证动态响应,这个方法很好用。
5. 常见问题速查与工程经验
5.1 常见问题速查表
| 故障现象 | 可能原因 | 排查方向 |
|---|---|---|
| 温度一直显示0 | XADC IP核未配置连续序列,或者DEN时序不对 | 检查配置里的Sequencer Mode,再看仿真波形中EOC是否存在 |
| 温度值偏大很多倍 | 用了整个16位寄存器做换算 | 确认是否取do[15:4] |
| 温度值负值或乱跳 | 温度寄存器地址写错 | 把daddr固定为7’h00重新测试 |
| 数码管全灭 | 位选信号极性错误 | 确认开发板数码管是共阳极还是共阴极 |
| 数码管亮度不均 | 扫描频率太慢 | 将扫描时钟分频系数改小,提高扫描频率 |
| XADC读出来一直不更新 | DCLK频率配置和实际不一致 | 严格保证IP核配置频率等于实际时钟 |
| 报警LED常亮 | 温度超过默认阈值 | 检查实际温度确认是否过温,或调高报警阈值 |
5.2 调试中的几个独家经验
第一个经验是千万不要忽略IP核配置界面里的DCLK频率参数。这个参数不仅影响DRP时序计算,还影响报警比较器和采样时钟分频。很多拿到模板就改时钟的人,烧进去以后读数就是不对,问题恰恰出在这里。
第二个经验是二进制转BCD的模块不要用组合逻辑硬扛大位宽。12位到16位没问题,但如果你把整个16位原始温度寄存器拿来转,或者后面要显示带小数的值,组合逻辑路径会变得很长,高主频下容易出现时序违例。遇到这种情况就改成多周期移位寄存器,用状态机一位一位移。
第三个经验是在工程里保留一个“直通测试”模式。我通常会在顶层加一个拨码开关,拨到直通模式时数码管直接显示12位ADC原始码的高四位,这样调试时能快速区分问题出在XADC读取还是后续显示换算。等确定原始数据没问题,再把开关拨到正常模式,问题范围一下就缩小了。
5.3 这个项目还能怎么扩展
读完温度之后,扩展方向其实很多。你可以把XADC IP核的VCCINT、VCCAUX、VCCBRAM三个电压寄存器都读出来,在数码管上轮流显示,做一个简易电源状态指示器。也可以把温度值和设定的阈值做比较,输出PWM波形控制散热风扇转速,实现一个简单的温控风扇系统。
如果以后要做Zynq平台,可以把这个DRP读取模块换成AXI4-Lite接口,用ARM核去读温度,软件里做更复杂的温度补偿和系统调度。但核心思路完全一样:EOC来了读一次寄存器,数据进ARM,剩下的就是软件逻辑了。
我在实际工程里最喜欢XADC的一点,就是它让FPGA真正有了“自感知”能力。系统过热前,你不仅能预判,还能主动响应,这在工业现场、电力电子、通信设备里都是很实用的能力。多花一晚上把这条链路跑通,后续项目会省下很多排查异常的时间和精力。