FPGA纯逻辑驱动SPI-LCD实战:Vivado 2018.3下的无软核显示方案
在如今的人机交互设备中,图形化界面早已不再是“加分项”,而是系统设计的基本刚需。从工业仪表到医疗终端,再到智能家电,一块能实时响应、稳定显示的小尺寸彩屏,往往是产品体验的关键一环。
但你有没有遇到过这样的窘境?
MCU资源已经跑满,还要分出时间片去刷屏;
SPI时序稍有抖动,LCD就花屏乱码;
为了一个屏幕多加一颗芯片,成本和布板空间都被拉高……
于是我们开始思考:既然FPGA本身就擅长并行与时序控制,为何不干脆让它自己搞定显示?
本文将带你深入一场“去MCU化”的技术实践——基于Xilinx Vivado 2018.3平台,使用纯Verilog逻辑实现SPI主控,直接驱动常见的ILI9341 TFT-LCD 模块。整个过程不依赖MicroBlaze等软核处理器,完全由硬件状态机协调完成初始化、GRAM写入与动态刷新。
这不是理论推演,而是一套经过验证、可移植、低资源占用的实战方案。无论你是想做定制HMI、高速数据可视化,还是单纯想搞懂FPGA如何独立掌控外设,这篇文章都能给你答案。
为什么选择SPI接口驱动LCD?
当你面对一块TFT-LCD模块时,通常会看到两种主流接口:并行8080 MPU接口和SPI串行接口。
| 对比维度 | 并行接口(如8080) | SPI接口 |
|---|---|---|
| 引脚数量 | ≥16(D0~D15 + RD/WR/RS/CS等) | 4~6(SCLK/MOSI/CS/DC/RST) |
| 最大传输速率 | 可达30MHz以上 | 典型10MHz,部分支持15MHz |
| FPGA资源消耗 | 高(需多位总线+复杂时序) | 极低(仅几个IO+简单状态机) |
| 设计复杂度 | 中高 | 低 |
| 适用场景 | 高刷新率、全动态视频 | 中小尺寸GUI、参数显示 |
显然,对于大多数中小尺寸(如2.4”、2.8”)的嵌入式显示屏来说,SPI是更优的选择:引脚少、接线简单、抗干扰能力强,特别适合BGA封装的小型FPGA器件(比如Artix-7系列)。
更重要的是——SPI协议结构清晰,非常适合用状态机精准控制时序,而这正是FPGA的强项。
ILI9341控制器的核心机制解析
我们以广泛应用的ILI9341为例,它是驱动SPI-LCD的事实标准之一。虽然它功能强大,但只要抓住几个关键点,就能快速上手。
显示流程三步走
复位与初始化
- 上电后必须发送一系列配置命令(共约20条),设置电源、伽马曲线、显示方向等;
- 必须严格遵守延时要求(如软复位后等待150ms);
- 错一步,可能黑屏或白屏。显存区域设定
- 使用CASET(Column Address Set)和PASET(Page Address Set)划定写入范围;
- 再发送RAMWR(0x2C)命令,后续所有数据自动写入该矩形区域;
- 支持局部刷新,避免全屏重绘带来的带宽压力。像素数据写入
- 数据格式为RGB565,每像素占2字节;
- 自动按行扫描写入内置GRAM,无需手动寻址;
- 指针到达边界后自动换行。
⚠️ 注意:ILI9341默认工作于SPI Mode 0(CPOL=0, CPHA=0),即空闲时钟为低,在上升沿采样。若时钟极性错误,通信将彻底失败。
关键控制信号一览
除了标准SPI四线(SCLK/MOSI/CS_N/MISO),ILI9341还引入两条关键控制线:
- DC(Data/Command):决定当前传输的是命令(0)还是数据(1)。这是区分“我要发指令”和“我要送图像”的开关。
- RST_N(Reset):低电平有效,用于重启LCD控制器。建议由FPGA可控拉低至少10ms。
- BLK(Backlight):背光使能,可通过PWM调节亮度,节省功耗。
这些信号虽非SPI协议一部分,却是确保LCD正常工作的“生命线”。
如何用FPGA实现SPI主控?状态机才是灵魂
很多人以为“SPI很简单”,但在实际工程中,尤其是面对严格的LCD时序规范时,软件模拟SPI往往不可靠:中断延迟、循环不确定性、编译优化干扰……都会导致bit对齐偏差。
而FPGA的优势就在于:你可以精确到每一个时钟周期地控制信号跳变。
下面是一个精简高效的SPI Master模块设计思路,已在XC7A35T上实测通过。
核心设计目标
- 支持Mode 0(CPOL=0, CPHA=0)
- 系统时钟50MHz → 分频生成约5MHz SCLK
- 支持单字节传输,高位先行(MSB First)
- 提供
is_cmd输入,自动控制DC引脚 - 输出
done信号,便于状态机联动
Verilog实现要点拆解
module spi_master ( input clk, input rst_n, input start, input [7:0] data_in, input is_cmd, // 1=data, 0=command output reg sclk, output reg mosi, output reg cs_n, output reg dc, output done );时钟分频与边沿控制
localparam CLK_DIV = 10; // 50MHz / 10 = 5MHz SCLK reg [3:0] clk_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) clk_cnt <= 0; else if (clk_cnt == CLK_DIV-1) clk_cnt <= 0; else clk_cnt <= clk_cnt + 1; end这里采用计数器方式生成SCLK,保证每个bit宽度恒定。注意不要使用divided_clock作为触发时钟,否则综合工具可能无法正确约束路径。
四状态机驱动传输流程
parameter STATE_IDLE = 2'd0, STATE_START = 2'd1, STATE_TRANSFER = 2'd2, STATE_STOP = 2'd3; reg [3:0] bit_cnt; reg [7:0] shift_reg; reg state; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= STATE_IDLE; sclk <= 1'b1; // Mode 0: idle high? cs_n <= 1; dc <= 1; mosi <= 0; bit_cnt <= 0; end else begin case (state) STATE_IDLE: if (start) begin cs_n <= 0; dc <= is_cmd ? 1'b0 : 1'b1; shift_reg <= data_in; bit_cnt <= 0; sclk <= 0; // 准备第一个上升沿 state <= STATE_START; end STATE_START: if (clk_cnt == CLK_DIV/2 - 1) // 半周期后上升沿 state <= STATE_TRANSFER; STATE_TRANSFER: if (clk_cnt == 0) begin mosi <= shift_reg[7]; shift_reg <= {shift_reg[6:0], 1'b0}; sclk <= ~sclk; // 翻转时钟 if (bit_cnt == 7 && sclk == 1) // 第8个上升沿后结束 state <= STATE_STOP; else if (sclk == 1) bit_cnt <= bit_cnt + 1; end STATE_STOP: if (clk_cnt == CLK_DIV/2) cs_n <= 1'b1; state <= STATE_IDLE; endcase end end assign done = (state == STATE_STOP) && (clk_cnt == CLK_DIV/2);这个状态机的设计非常关键:
- 在
STATE_START阶段等待半个周期,确保SCLK从低开始; - 数据在SCLK上升沿移出,符合Mode 0要求;
- CS_N在整个传输期间保持低电平,结束后拉高;
done信号可用于通知上级状态机“本字节已发完”。
初始化序列怎么写?别再手敲delay了!
最头疼的不是发数据,而是初始化时序中的各种延时。例如:
- 软复位(0x01)后要等150ms
- 发完电源配置命令后要延时120ms
- 开启显示前还需120ms
如果用C语言,可以用HAL_Delay()轻松解决。但在纯逻辑世界里,“延时”意味着计数器。
我们可以构建一个简单的Init FSM来顺序执行命令流:
// 状态定义 typedef enum { INIT_RST_LOW, INIT_RST_WAIT, INIT_SEND_CMD1, // 软复位 INIT_DELAY1, INIT_SEND_OFF, INIT_SEND_PWR1, INIT_DELAY2, ... INIT_DONE } init_state_t; reg [23:0] delay_counter; // 50MHz下1ms = 50,000 ticks每条命令发送完成后,进入对应延时状态,计数达到后再继续下一步:
INIT_DELAY1: begin if (delay_counter < 50_000 * 150) // 150ms delay_counter <= delay_counter + 1; else begin delay_counter <= 0; current_state <= INIT_SEND_OFF; end end同时,通过顶层使能信号控制spi_master.start,形成“发命令→等done→延时→发下一条”的闭环流程。
✅ 小技巧:把初始化命令打包成ROM表,用地址递增方式自动读取,大幅提升可维护性。
常见坑点与调试秘籍
即使逻辑写得再漂亮,也逃不过以下几个经典问题:
❌ 问题1:屏幕全白或全黑,但无内容
- 排查重点:检查
DC信号是否正确切换! - 很多初学者误以为“先发命令再发数据”就行,但如果DC始终为高,则所有命令都被当作数据处理,导致寄存器未配置。
👉 解法:用ILA抓波形,确认第一条命令(如0x01)发送时DC=0。
❌ 问题2:初始化成功,但写GRAM无反应
- 常见原因:未正确设置CASET/PASET区域,或者忘记发
0x2C - ILI9341不会默认指向(0,0),必须显式指定窗口
👉 示例:
send_command(0x2A); // CASET send_data(0x00); send_data(0x00); // X start = 0 send_data(0x01); send_data(0x3F); // X end = 319 send_command(0x2B); // PASET send_data(0x00); send_data(0x00); send_data(0x00); send_data(0xEF); // Y end = 239 send_command(0x2C); // RAMWR —— 此刻才能开始写像素!❌ 问题3:显示错位、偏移、颜色异常
- 大概率是RGB565字节顺序问题
- FPGA默认大端模式,但某些模块要求MSB在前还是LSB在前?
👉 解法:尝试交换两个字节顺序,或调整shift_reg拼接方式。
性能与资源评估:真的能在小FPGA上跑起来吗?
我们以XC7A35T-1CPG236C(Artix-7家族)为例进行综合:
| 模块 | LUTs | FFs | BRAM |
|---|---|---|---|
| SPI Master | ~45 | ~30 | 0 |
| Init FSM | ~60 | ~40 | 0 |
| Framebuffer (可选) | 0~28800 | - | 1~2 Block RAM (320×240×16bit) |
✅ 结论:
- 若仅显示静态图案(如logo),无需帧缓存,总资源不足1% Slice;
- 若需动态刷新,可用Block RAM实现双缓冲,支持流畅动画;
- 即使是最小封装的Artix-7,也能轻松容纳此设计。
进阶玩法:不只是刷图,还能做什么?
这套架构的扩展性远超想象:
- 接入XPT2046电阻触摸屏:共用SPI总线,通过CS切换设备,实现完整HMI;
- PWM背光调光:用8位计数器+比较器生成可变占空比,节能降功耗;
- 局部刷新优化:只更新变化区域,显著降低SPI负载;
- 字符引擎集成:预存ASCII字体表,实现文本输出;
- DMA辅助传输:结合AXI Stream,从外部存储批量读取图像数据。
未来甚至可以拓展为:
➡️ 多屏同步显示
➡️ 视频叠加层(Video Overlay)
➡️ FPGA+PS协同渲染(Zynq平台)
如果你正在做一个需要图形界面的项目,不妨试试让FPGA自己“扛起显示大旗”。你会发现,一旦掌握了这种“硬控外设”的能力,系统的实时性、稳定性与集成度都将迈上新台阶。
而这一切,只需要几根IO线、一段状态机、以及一点点耐心调试。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。