简介:这是一份基于FPGA的LED驱动设计资源,源于ICND2019/ICND2018课程实践,核心围绕PWM调光、RGB LED控制和BRAM存储配置展开,已适配Xilinx平台;若迁移至Intel平台,需要修改Block RAM接口。压缩包共16个文件,大小约11.11MB,涵盖Verilog/VHDL源码、MIF/COE初始化文件、XDC约束、DCP网表以及XCI/VHO等IP核生成文件,从源码、约束到仿真网表的完整链路均有覆盖,便于直接导入Xilinx工程或对照学习。资源已有994人学习,适合正在学习FPGA设计、Verilog硬件描述语言或LED显示控制的开发者,尤其是需要快速上手BRAM与PWM逻辑的进阶学习者。资源从BRAM寄存器配置到RGB灯效驱动均提供了可参考的工程实现,附带仿真网表与IP核封装,能帮助理解存储初始化到输出控制的完整流程;同时保留迁移到其他平台的适配思路,适合作为实验模板或课程设计参考。对于需要比较Xilinx与Intel平台BRAM配置差异的开发者,这份资源也能提供直观的工程样例。
1. 这个 LED 驱动工程在解决什么问题
大量 LED 显示模组使用恒流型灯驱芯片,比如标题里出现的 icnd2069、icnd2019。这类芯片本身只负责把串行移入的数据锁存到输出脚,灰度由外部 PWM 决定。真正费心思的是上游 FPGA 怎么把 RGB 数据从内存搬出来、对齐像素、按时钟移出去。这个文件夹里的代码解决的就是这条链路:bram_reg_config_rgb 负责缓存/初始化 RGB 数据,Verilog 完成串并转换与锁存时序,最终驱动 LED 闪灯/显示。适合正在写 LED 显示控制、想跑通真实恒流芯片时序的人参考;Vivado 工程打开后,关键模块可以直接搬。
2. BRAM 初始化文件与 bram_reg_config_rgb 的读出控制
2.1 从 .coe/.mif 看 BRAM 到底存了什么
bram_reg_config_rgb 在工程里是一个 Xilinx Block Memory Generator 的 IP,名字里的 rgb 已经把用途说透了:把 RGB 灯的配置和点屏数据放进 BRAM,再由 FPGA 读出发给灯驱芯片。压缩包里同时出现init_reg.coe和bram_reg_config_rgb.mif,这俩本质是同一份数据的两种外壳。Xilinx 的 IP 在生成 BRAM 时认.coe;Intel 的 ROM/RAM IP 则常用.mif。所以拿这份工程到 Xilinx 上跑,BRAM 会自动用.coe初始化,.mif更多是作者为跨平台预留的。
打开init_reg.coe,第一行是进制声明,第二行是数据向量。常见格式为:
memory_initialization_radix=16; memory_initialization_vector= 00FF0000, 00FF0000, 0000FF00, 0000FF00;这个例子里每个地址对应一个像素,24 位数据按{R,G,B}排列。BRAM 位宽如果是 24,一个地址就是一颗灯;如果工程按 32 位存储,[31:24]可能是使能位或亮度标志,读出来后要立刻截掉,否则会把0x00当有效数据打进灯驱芯片。
我一般拿到这类包先干两件事:第一,数init_reg.coe的行数,并和.xci里的 Memory Depth 对比;第二,确认每个值是不是 6 位十六进制。深度不一致时,Vivado 会在仿真阶段报Range check failed,上板后表现则是后段地址读到全零,整块画面变成前段重复。
2.2 读出 BRAM 的 Verilog 模板
Block Memory Generator 按单口 ROM 配置时,端口非常简洁,常用信号如下表。
| 信号 | 方向 | 位宽 | 作用 |
|---|---|---|---|
| clka | input | 1 | 读时钟,通常接系统时钟 |
| addra | input | ADDR_W | 读地址,从 0 开始 |
| ena | input | 1 | 读使能,拉高常开 |
| douta | output | DATA_W | 读出的数据 |
| rsta_busy | output | 1 | 复位忙,可悬空 |
下面是读取 BRAM 并输出连续 RGB 数据的最小模板。这段代码解决的是从“地址递增”到“拿到的数据可用”的时序问题:
// bram_rgb_reader.v module bram_rgb_reader #( parameter ADDR_W = 9, parameter DATA_W = 24 )( input wire clk, input wire rst_n, output reg [ADDR_W-1:0] rd_addr, input wire [DATA_W-1:0] rd_data, output reg [DATA_W-1:0] rgb_data, output reg rd_valid ); always @(posedge clk) begin if (!rst_n) rd_addr <= 0; else rd_addr <= rd_addr + 1'b1; // 顺序读地址 end always @(posedge clk) begin if (!rst_n) begin rgb_data <= {DATA_W{1'b0}}; rd_valid <= 1'b0; end else begin rgb_data <= rd_data; // 打一拍,过滤总线毛刺 rd_valid <= 1'b1; end end endmodule逻辑说明:第一个 always 块在时钟上升沿把地址加一,BRAM 收到地址后会在下一个或两个时钟沿把 douta 变稳定。第二个 always 块把 rd_data 再打一拍,得到 rgb_data。之所以要打这一拍,是因为直接拿 douta 当组合逻辑来源时,地址翻转造成的输出毛刺很容易被后级误采为有效像素;打一拍之后,rgb_data 在任何时钟沿都是稳定的。
参数说明:ADDR_W=9对应 512 深度;DATA_W=24对应 RGB888。如果你的工程用的是 RGB565,把 DATA_W 改 16,同时.coe里每行只有 4 位十六进制;如果 RGB 顺序是 BGR,这里不改宽度,但送入芯片前需要重排 bit,或者直接在写数据时调换。
2.3 读延迟和显示控制器的握手
Xilinx BRAM 默认没有输出寄存器时,从 addra 变化到 douta 有效是 2 个时钟周期:一个周期译码,一个周期输出。如果你在 Block Memory Generator 里勾了Primitive Output Register,延迟会变成 3 拍甚至 4 拍。很多第一次写 LED 驱动的人会在“数据总是慢半拍”这个问题上卡住,原因就在这。
注意:BRAM 的读延迟不是靠猜的,生成 IP 后打开
.xci里的 Read Output 选项,或者直接跑仿真看波形,确认是 2 拍还是 3 拍再改代码。
我习惯把 BRAM 读链路当成固定流水线:地址级、数据级、发送级。配置好 BRAM 之后,不要在同一个 always 块里既改地址又判断 douta,而是用一个rd_valid信号,把“这一拍的数据是有效的”明确标出来。上面的模板里rd_valid在复位后直接拉高,适合纯 ROM 连续读场景;如果后面接帧使能,应把rd_valid与帧开始信号做与逻辑,避免空读。
3. icnd 灯驱芯片的串行发送与 PWM 灰度
3.1 恒流驱动芯片的时序模型
icnd2069、icnd2019 这类灯驱芯片从外部看就是一个带锁存的移位寄存器。数据在 DCLK 上升沿依次移入,24 位或者 32 位后,LAT 的上升沿会把内容锁到输出寄存器,OE 引脚则控制恒流输出管脚的整体通断。RGB LED 的每个颜色通道由对应通道电流决定,灰度完全靠 OE 的 PWM 调出。所以 FPGA 驱动这类芯片,实际是在做三件事:
- 把 BRAM 读出的像素串行移出;
- 在正确时间给 LAT 一个脉冲;
- 用 OE 控制输出时间。
注意不同厂家对 DCLK 锁存沿的定义不一定相同。有的手册写“上升沿移入”,有的写“下降沿移入”,同一个芯片在不同工作模式下还区分第一级移位和二级锁存。拿到 ICND2069 的数据手册先翻串行时序图,别照抄别人的代码。
3.2 发送状态机的 Verilog 实现
下面这段代码把一像素发送过程分成 IDLE、SHIFT、LATCH、GRAY 四个状态。SHIFT 状态下每个主时钟周期移出 1 bit,移完 24 bit 后给出锁存脉冲,然后 OE 打开,计数器走完一个灰度周期。
module rgb_serial_out #( parameter PIXEL_W = 24, parameter GRAY_W = 8 )( input wire clk, input wire rst_n, input wire start, input wire [PIXEL_W-1:0] pixel, output reg sdi, output wire dclk, output reg lat, output reg oe ); localparam IDLE = 3'd0; localparam SHIFT = 3'd1; localparam LATCH = 3'd2; localparam GRAY = 3'd3; reg [PIXEL_W-1:0] shift_reg; reg [5:0] bit_cnt; reg [2:0] state; reg [GRAY_W-1:0] gray_cnt; assign dclk = (state == SHIFT) ? clk : 1'b0; always @(posedge clk) begin if (!rst_n) begin state <= IDLE; sdi <= 1'b0; lat <= 1'b0; oe <= 1'b0; end else begin case (state) IDLE: begin if (start) begin shift_reg <= pixel; bit_cnt <= 0; state <= SHIFT; end end SHIFT: begin sdi <= shift_reg[PIXEL_W-1]; shift_reg <= {shift_reg[PIXEL_W-2:0], 1'b0}; if (bit_cnt == PIXEL_W-1) begin state <= LATCH; end else begin bit_cnt <= bit_cnt + 1'b1; end end LATCH: begin lat <= 1'b1; state <= GRAY; end GRAY: begin lat <= 1'b0; oe <= 1'b1; if (gray_cnt >= (2**GRAY_W - 1)) begin gray_cnt <= 0; oe <= 1'b0; state <= IDLE; end else begin gray_cnt <= gray_cnt + 1'b1; end end endcase end end endmodule逻辑说明:SHIFT 状态里sdi在 posedge clk 更新,所以 DCLK 也是同一时钟沿变化。这个写法放在教学演示里没问题,用来评估状态切分、灰度计数器逻辑看得最清楚;但真正打板时,DCLK 和 SDI 之间会有约一个门的输出延迟,如果灯驱芯片对建立时间要求严格,就要把 SDI 的更新移到negedge clk,或者在 IO 里用 ODDR 原语把数据和时钟对齐。
参数说明:PIXEL_W=24按 RGB888 移 24 拍;GRAY_W=8表示 OE 高电平最大 255 拍。如果每颗灯要按 10bit 调亮度,把 GRAY_W 改成 10,GRAY 状态的计数上限变大。要注意2**GRAY_W的写法在 Verilog 里合法,但综合时会生成大量比较器;更常见的是定义一个GRAY_TOP参数,赋值(1 << GRAY_W) - 1,实现起来更直观。
3.3 灰度刷新率怎么算
整帧刷新率由三部分时间组成:串行移位时间、锁存时间和 OE 持续时间。假设系统时钟 50MHz,PIXEL_W=24,GRAY_W=8,点亮一像素需要24 + 1 + 256 = 281个主时钟周期,约 5.62us。如果一帧有 128 个像素,理论刷新率大约 50MHz / (128 * 281) ≈ 1.39kHz,这对 LED 显示来说偏低了。所以实际工程里不会每像素做一次 OE 灰度,而是把整行像素全部串行移入后,再统一锁存和统一 PWM。
换句话说,上面状态机适合单颗灯或者小规模验证;真正多像素点屏时,BRAM 读出的像素先拼成一个移位链,然后在 SHIFT 之后只进一次 LATCH,最后再进入 GRAY。此时移位时间变成像素数 * PIXEL_W,OE 时间仍然是 256 拍,刷新率会高很多。这个差异是 LED 驱动性能的瓶颈,也是从“能亮”到“不闪”的分水岭。
多像素级联时,BRAM 地址不能每像素都回绕到 LATCH。正确做法是在 IO 上把前一级芯片的 SDO 接到后一级 SDI,FPGA 只移出第一个像素,但像素计数器要一直走到 N-1 才锁存。此时状态机有 bit_cnt 和 pixel_cnt 两个计数器,pixel_cnt 在 bit_cnt 到边界时加一,同时 rd_addr 加一,保证 BRAM 供应下一个像素。有些工程会把 BRAM 读出的像素先放进 FIFO,再按连续位流发送;这是为了拉开 BRAM 读宽度和高 DCLK 的差距。LED 控制器对 FIFO 深度要求通常不大,一行的像素数就够。
4. XDC 约束、初始化校验和上板排错
4.1 先把时钟和管脚约束写对
工程里的bram_reg_config_rgb_ooc.xdc是 BRAM IP 的 OOC 约束,它只管 IP 内部时钟,真正让 FPGA 引脚对上灯驱芯片的,需要顶层约束。Vivado 工程里常见写法是:
create_clock -period 20.000 -name sys_clk [get_ports clk] set_property PACKAGE_PIN R4 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN T22 [get_ports sdi] set_property IOSTANDARD LVCMOS33 [get_ports sdi] set_property PACKAGE_PIN U21 [get_ports dclk] set_property IOSTANDARD LVCMOS33 [get_ports dclk]逻辑说明:create_clock必须写,否则时序分析从clk入口开始没有参考时钟,综合后时序报告全是灰色。set_property PACKAGE_PIN把顶层端口映射到封装引脚,IOSTANDARD要与板卡电平和驱动芯片供电电压一致;ICND 系列大多是 3.3V 供电,所以这里用 LVCMOS33。
参数说明:如果板卡时钟是差分 100MHz,周期应该写成 10.000,并且约束对象是get_ports clk_p;如果灯驱芯片要求 5V 电平,还要加电平转换或开漏驱动,不能直接把 3.3V FPGA 接 5V TTL,长时间跑容易烧 IO。
4.2 初始化数据校验
BRAM 初始化最常见的错误是.coe数据和 IP 参数不一致。Vivado 不会在综合时报致命错误,只在生成 bitstream 或跑仿真时给出警告。手工对比文件很累,我习惯用 Python 跑一遍:
import os coe_path = "init_reg.coe" depth = 1024 width_bits = 24 vals = [] with open(coe_path) as f: for raw in f: line = raw.strip() if not line or line.startswith("memory_initialization"): continue vals.extend(line.rstrip(',').split()) print(f"像素数: {len(vals)}") assert len(vals) <= depth, "coe 数据超出 BRAM 深度" for v in vals: assert len(v) * 4 <= width_bits, f"数据 {v} 超过位宽"逻辑说明:脚本把memory_initialization_radix和memory_initialization_vector两行跳过,只保留数据行。assert会在深度不足或数值位宽过大时直接抛错,适合在跑综合前加入持续集成流程。参数说明:depth要和.xci里 Memory Depth 一致;如果数据是 RGB565,width_bits改成 16,脚本里len(v)*4判断每个十六进制字符占 4bit。
4.3 常见故障表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| LED 全不亮 | OE 没有拉高,或拉高时间太短 | 用 ILA 抓oe和gray_cnt,确认 GRAY 状态进入 |
| 只有第一位亮 | DCLK 采样沿和数据不对齐 | 换下降沿输出 SDI,或检查 DCLK 相位 |
| 颜色完全错乱 | RGB 分量顺序或者 bit 顺序反了 | 把{R,G,B}改成{B,G,R}对比 |
| 后段全是 0 | .coe数据量小于 BRAM 深度 | 用脚本检查并补零 |
| 上板后不刷新 | 复位释放后没有给 start | 确认帧同步脉冲持续一拍以上 |
| 芯片发烫 | DCLK 在 IDLE 悬空或乱翻 | 按示例只在 SHIFT 时输出 DCLK |
这张表里最容易被忽略的是最后一行。很多芯片手册要求 DCLK 空闲时保持低电平或高电平,如果直接把 clk 接成 assign 到 dclk,IDLE 期间时钟也在翻跳,芯片内部移位寄存器会收到无意义数据,输出脚乱动,电流一直灌到 LED 上,发热就是必然。这也是为什么我在状态机里用dclk = (state == SHIFT) ? clk : 1'b0,而不是启动分频器后一直给时钟。
提示:ICND 芯片的 OE 极性在不同批次可能有差异,拿到新片子先用示波器量 OE 低电平期间输出是否关闭,再上大电流。
5. 移植到 Intel 平台时改 BRAM 的几条关键路径
把 Xilinx 工程移植到 Intel 平台,问题集中在 Block Memory Generator 生成的bram_reg_config_rgbIP 不能直接拿到 Quartus 里用。需要换成 Intel 的 ROM/RAM IP,并调整三处。
第一,初始化文件。Quartus 的 RAM:1-PORT 或 ROM:1-PORT 接受.mif,不接受.coe。下面脚本把init_reg.coe转成init_reg.mif:
def coe_to_mif(coe_path, mif_path, depth=1024, width=24): vals = [] for line in open(coe_path): line = line.strip() if not line or line.startswith("memory_initialization"): continue vals.extend(line.rstrip(',').split()) vals = vals[:depth] with open(mif_path, "w") as f: f.write(f"WIDTH={width};\n") f.write(f"DEPTH={depth};\n") f.write("ADDRESS_RADIX=HEX;\n") f.write("DATA_RADIX=HEX;\n") f.write("CONTENT BEGIN\n") for addr, val in enumerate(vals): f.write(f"{addr:02X} : {val};\n") f.write("END;\n")第二,端口名称和使能。Intel 的 ROM IP 通常提供address、data_out、clken,clken对应 Xilinx 的ena。如果不在 IP 设置里勾选,复位后默认一直使能,功能没错,但时序报表上会多一组不受控的使能信号。
第三,读延迟。Xilinx BRAM 默认 2 拍,Intel 的 RAM IP 在设置了 output register 后可能是 1 拍或 3 拍。移植后的状态机要让rd_valid跟着延迟走,否则第二像素之后所有 RGB 都会串位。最后用 quartus_map 和 ModelSim 跑一遍仿真,重点比较地址变化后 data_out 的第几个沿生效,顺手把 clken 按 BRAM 的 ena 接上,之后上板看到的亮灯行为就和 Xilinx 一致了。
本文还有配套的精品资源,点击获取