FPGA驱动LCD:从HD44780时序到Verilog状态机实战
1. 项目缘起为什么要在FPGA上驱动LCD如果你玩过单片机比如Arduino或者STM32驱动一块16x2的字符型LCD液晶显示屏几乎是入门必做的实验。网上有现成的库几行代码就能让屏幕亮起来显示“Hello World”。那么为什么我们要“大材小用”在Mojo v3这块FPGA开发板上做同样的事情呢这听起来就像用一台超级计算机去算11。我最初也有这个疑问直到我在一个需要极高实时性和确定性的嵌入式项目里碰了壁。那个项目里我需要一个显示模块来输出系统状态和调试信息但主控MCU的CPU时间已经被更关键的任务比如电机控制环占满了任何额外的中断或软件延时都会导致控制环路抖动。这时我想到了FPGA。FPGA的并行硬件逻辑可以独立运行不占用CPU资源生成LCD控制时序就像呼吸一样自然完全不会干扰主系统的软实时性。Mojo v3作为一款性价比高、生态友好的入门级FPGA板正是验证这个想法的绝佳平台。所以这个项目远不止是让LCD亮起来。它的核心价值在于理解如何用纯硬件逻辑Verilog去模拟一个常见的、依赖特定时序的串行外设接口。你将学到如何将一份数据手册上的时序图翻译成可综合的硬件描述语言代码并最终在真实的硬件上看到结果。这个过程是FPGA开发中最基础也最核心的技能之一。无论你未来是做高速通信、图像处理还是复杂控制这种“从协议到电路”的思维转换能力都至关重要。2. 硬件准备与接口原理剖析在动手写代码之前我们必须搞清楚两件事我们手头的“武器”是什么以及我们要驱动的“目标”有什么脾气。2.1 认识我们的硬件平台Mojo v3Mojo v3是一块基于Xilinx Spartan-6 XC6SLX9 FPGA的开发板对初学者非常友好。它内置了USB编程器和稳压电路你只需要一根Micro-USB线就能完成供电和程序下载。板上资源对于驱动LCD来说绰绰有余。我们最需要关注的是它的GPIO通用输入输出引脚。Mojo v3通过扩展接口Extension Headers引出了大量的3.3V LVCMOS电平的GPIO这正是我们与LCD模块通信的物理通道。注意Mojo v3的IO电压是3.3V而大多数5V供电的16x2 LCD模块的逻辑高电平阈值VIH通常在2.0V左右因此3.3V输出可以直接驱动无需电平转换。但务必确认你的LCD模块数据手册确保兼容性。2.2 解析16x2 LCD模块HD44780控制器市面上绝大多数16x2字符LCD模块都使用Hitachi的HD44780或其兼容芯片作为控制器。因此驱动LCD的本质就是驱动HD44780。核心接口模式4位并行为了节省FPGA的宝贵IO引脚我们通常采用4位数据总线模式而不是8位模式。这样我们只需要6根线就能完成控制RS (Register Select)寄存器选择。低电平0时选择指令寄存器发送命令高电平1时选择数据寄存器发送要显示的字符数据。R/W (Read/Write)读写选择。因为我们只向LCD写数据所以此引脚可以永久接地低电平设置为只写模式。E (Enable)使能信号。这是最重要的时序控制线。数据在E引脚的高电平脉冲下降沿被锁存到LCD控制器中。D4-D7 (Data Bus 4-7)4位双向数据总线。在4位模式下我们分两次先高4位后低4位发送一个完整的8位指令或数据。关键操作时序所有对LCD的操作无论是初始化命令还是写入显示数据都必须严格遵守HD44780数据手册规定的时序。主要有以下几个关键参数以芯片典型值为例t_{AS}(Address Setup Time): RS、R/W信号建立到E信号变高之前的最小时间约40ns。t_{PW-EH}(Enable Pulse Width): E信号高电平脉冲的最小宽度约230ns。t_{H}(Data Hold Time): E信号下降沿后数据需要保持的最小时间约10ns。t_{C}(Cycle Time): 两次操作之间的最小间隔时间约500ns。在FPGA设计中我们通常用一个状态机来产生这些精确的延时。例如假设我们的系统时钟是50MHz周期20ns那么一个230ns的E脉冲高电平宽度就需要至少230ns / 20ns 11.5即12个时钟周期。2.3 连接方案将Mojo v3与LCD模块连接起来。假设我们使用Mojo的扩展接口1P1上的引脚Mojo v3 (P1) 引脚连接至 LCD 引脚功能FPGA内部信号名示例Pin 1 (IO_L1P_0)D7数据位7lcd_data[3]Pin 2 (IO_L1N_0)D6数据位6lcd_data[2]Pin 3 (IO_L2P_0)D5数据位5lcd_data[1]Pin 4 (IO_L2N_0)D4数据位4lcd_data[0]Pin 5 (IO_L3P_0)E使能信号lcd_enPin 6 (IO_L3N_0)RS寄存器选择lcd_rsGNDR/W读写选择接地-3.3VVDD电源-GNDVSS地-电位器中间脚V0对比度调节-提示LCD的V0引脚对比度调节连接到一个10kΩ电位器的中间脚电位器两端分别接VDD和GND。通过调节电位器可以改变屏幕显示的深浅。如果上电后屏幕只有一排方块很可能是对比度没调好。3. Verilog驱动设计从状态机到模块化理解了硬件和协议我们就可以开始用Verilog构建驱动了。我们的目标是设计一个可重用的LCD驱动模块lcd_driver.v它接收要显示的命令或数据并负责生成所有符合HD44780时序的信号。3.1 顶层模块与状态机设计驱动核心是一个有限状态机FSM它负责按顺序执行初始化、发送数据等操作。状态机设计的好坏直接决定了代码的清晰度和可靠性。module lcd_driver ( input wire clk, // 系统时钟 (e.g., 50MHz) input wire rst, // 系统复位 input wire [7:0] data_in, // 要发送的8位数据指令或字符 input wire rs_in, // 对应的RS信号0指令1数据 input wire start, // 启动发送脉冲 output reg done, // 发送完成标志 output reg lcd_rs, // 输出到LCD的RS output reg lcd_en, // 输出到LCD的E output reg [3:0] lcd_data // 输出到LCD的D4-D7 ); // 状态定义 localparam [3:0] IDLE 4b0000; // 空闲 localparam [3:0] INIT_WAIT 4b0001; // 初始化等待上电延时 localparam [3:0] FUNC_SET_H 4b0010; // 发送功能设置高4位 localparam [3:0] FUNC_SET_L 4b0011; // 发送功能设置低4位 // ... 其他初始化命令状态 localparam [3:0] SEND_HIGH 4b1000; // 发送数据高4位 localparam [3:0] SEND_LOW 4b1001; // 发送数据低4位 localparam [3:0] FINISH 4b1010; // 完成等待稳定时间 reg [3:0] state, next_state; reg [19:0] delay_counter; // 用于产生各种延时 reg delay_en; reg [7:0] data_reg; // 锁存输入数据 reg rs_reg; // 锁存输入RS // 延时常数基于50MHz时钟20ns周期 localparam DELAY_15MS 20d750_000; // 15ms localparam DELAY_4100US 20d205_000; // 4.1ms localparam DELAY_100US 20d5_000; // 100us localparam DELAY_40US 20d2_000; // 40us localparam PULSE_WIDTH 20d12; // E脉冲宽度 ~240ns localparam CYCLE_TIME 20d25; // 周期时间 ~500ns // 状态转移逻辑 always (posedge clk or posedge rst) begin if (rst) begin state IDLE; delay_counter 0; data_reg 0; rs_reg 0; end else begin state next_state; // 延时计数器 if (delay_en) begin if (delay_counter ! 0) delay_counter delay_counter - 1; end else begin delay_counter 当前状态需要的延时值; // 根据状态加载不同值 end // 锁存输入 if (start state IDLE) begin data_reg data_in; rs_reg rs_in; end end end // 次态逻辑与输出逻辑 always (*) begin // 默认值 next_state state; lcd_en 1b0; lcd_rs 1b0; lcd_data 4b0000; done 1b0; delay_en 1b0; case (state) IDLE: begin done 1b1; if (start) next_state SEND_HIGH; // 非初始化流程 // 上电后应首先进入初始化流程这里简化实际需要一个上电初始化状态机 end SEND_HIGH: begin lcd_rs rs_reg; lcd_data data_reg[7:4]; // 先发送高4位 if (delay_counter 0) begin lcd_en 1b1; // 产生E脉冲 delay_en 1b1; delay_counter PULSE_WIDTH; next_state SEND_HIGH_PULSE; end end SEND_HIGH_PULSE: begin lcd_rs rs_reg; lcd_data data_reg[7:4]; lcd_en 1b1; if (delay_counter 0) next_state SEND_LOW_WAIT; end SEND_LOW_WAIT: begin lcd_rs rs_reg; lcd_data data_reg[7:4]; delay_en 1b1; delay_counter CYCLE_TIME - PULSE_WIDTH; // 等待周期结束 if (delay_counter 0) next_state SEND_LOW; end // ... SEND_LOW状态类似发送低4位 data_reg[3:0] FINISH: begin delay_en 1b1; delay_counter DELAY_40US; // 等待最后40us以上 if (delay_counter 0) begin done 1b1; next_state IDLE; end end default: next_state IDLE; endcase end endmodule这个状态机清晰地划分了发送一个字节的流程准备高4位 - 产生E脉冲 - 等待 - 准备低4位 - 产生E脉冲 - 等待完成。初始化过程则是按特定顺序发送一系列命令字节如功能设置、显示开关、输入模式设置、清屏等每个命令字节都走一遍上述发送流程。3.2 初始化序列的精确实现LCD上电后必须进行一段严格的初始化过程否则无法正常工作。这个过程通常包括长时间的等待15ms、发送特定的复位命令、设置4位模式、设置显示行数字体、打开显示、清屏、设置输入模式等。很多驱动失败的原因都源于初始化序列不完整或时序不对。在我的实现中我专门为初始化设计了一个独立的状态机或者作为主状态机的一部分。关键是要严格按照数据手册的推荐步骤上电后等待VCC稳定至少15ms。发送软件复位命令0x30功能设置8位模式三次每次间隔至少4.1ms。发送设置4位模式命令0x20实际上在4位模式下我们分两次发送0x28即0x2和0x8代表2行、5x8点阵。后续发送显示开关、清屏、输入模式设置等命令。踩坑实录我曾因为忽略了第三次0x30命令后的等待时间导致初始化随机性失败。后来在状态机中为每个命令后都加入了足够的延时DELAY_40US或更长问题才得以解决。在FPGA逻辑中“等待”必须用计数器显式实现不能靠“感觉”。3.3 上层控制器设计如何发送字符串LCD驱动模块只是一个“搬运工”它负责把字节按照协议送出去。我们需要一个上层模块比如叫lcd_controller.v来组织要显示的内容。这个控制器可以包含一个ROM或RAM里面存储要显示的字符串例如“Hello Mojo V3!”然后按顺序调用lcd_driver模块依次发送每个字符的ASCII码。更高级的控制器可以实现光标定位、滚动显示、从外部接收动态数据如传感器读数并格式化输出等功能。这体现了FPGA的灵活性你可以根据应用需求定制专属的显示逻辑硬件。4. 在Mojo v3上进行综合、约束与调试代码写完了但在烧录到板子之前还有关键几步。4.1 使用Vivado进行综合与实现Mojo v3的官方推荐开发环境是Xilinx ISE但鉴于ISE已停止维护且Vivado对现代工作流支持更好配合VSCode插件我们可以使用Vivado。需要安装Spartan-6的器件支持文件。创建项目选择正确的器件型号XC6SLX9-2TQG144C。添加源文件将你的lcd_driver.v、lcd_controller.v和顶层模块例如mojo_top.v添加到项目中。编写引脚约束文件 (.xdc)这是将Verilog中的信号名映射到实际物理引脚的关键步骤。根据之前的连接表约束文件内容大致如下# 时钟引脚 (50MHz 晶振) set_property PACKAGE_PIN P56 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] create_clock -period 20.000 -name clk [get_ports clk] # 复位按钮 set_property PACKAGE_PIN P38 [get_ports rst_n] # Mojo的复位按钮是低电平有效 set_property IOSTANDARD LVCMOS33 [get_ports rst_n] # LCD 接口引脚 set_property PACKAGE_PIN P94 [get_ports {lcd_data[3]}] # D7 set_property IOSTANDARD LVCMOS33 [get_ports {lcd_data[3]}] # ... 为 lcd_data[2], lcd_data[1], lcd_data[0], lcd_rs, lcd_en 添加类似约束综合与实现运行综合Synthesis和实现Implementation。检查报告确保没有警告如未约束的时钟和错误。生成比特流文件这一步会生成.bit文件。4.2 使用Mojo Loader进行编程Mojo v3使用基于AVR的USB编程器。你需要下载Mojo Loader工具或使用其Eclipse插件。将生成的.bit文件通过Mojo Loader烧录到FPGA中。由于FPGA是易失性的断电后程序会丢失。如果希望程序上电自启动需要将比特流文件转换为.hex格式并烧录到板载的串行配置Flash中。4.3 硬件调试与常见问题排查即使一切步骤都看似正确LCD屏幕也可能不亮。以下是一个系统的排查清单电源与背光首先确认LCD的VDD和GND连接正确电压是否在4.7V-5.3V之间对于5V模块。如果屏幕有背光检查背光引脚LED和LED-是否已连接通常LED-串联一个限流电阻接地。对比度这是最常见的问题调节连接在V0引脚上的电位器直到屏幕上的字符或方块清晰显示。如果对比度调至最低V0接近VDD屏幕会全白调至最高V0接近GND屏幕会全黑。初始化失败如果屏幕亮起但显示乱码或只有一行方块很可能是初始化序列没有正确执行。确保你的状态机完成了所有必需的步骤和等待时间。一个有效的调试方法是使用一个简单的测试反复发送清屏命令0x01。如果屏幕能清屏说明基本通信是通的问题可能在初始化或后续命令。时序问题用示波器或逻辑分析仪观察lcd_en、lcd_rs和lcd_data的波形是最直接的。检查E脉冲的宽度是否足够230ns数据在E下降沿前后是否稳定满足建立和保持时间。在50MHz时钟下一个常见的错误是E脉冲只持续了一个时钟周期20ns这远远不够。引脚连接错误再次仔细检查Mojo与LCD之间的每一根连线确保没有接错、虚焊或短路。特别是D4-D7的顺序高位和低位不能颠倒。5. 进阶应用与优化思路当基本的显示功能跑通后你可以考虑以下方向进行深化和优化这能让这个项目从一个简单的实验进化成一个实用的硬件模块。5.1 设计一个易用的字符发送接口目前的驱动模块可能还需要上层控制器精确控制发送时机。我们可以设计一个带FIFO先进先出队列的接口。上层应用只需要将字符数据写入一个FIFO驱动模块自动从FIFO中读取并发送实现“解耦”。这样系统的其他部分可以异步地更新显示内容而不用担心破坏LCD的时序。5.2 实现自定义字符CGRAMHD44780允许用户定义最多8个5x8点阵的自定义字符。你可以设计一个状态机在初始化后向LCD的CGRAM字符生成RAM写入特定的图案数据比如一个电池图标、一个信号强度符号。之后通过发送特定的字符码0x00-0x07就能显示这些自定义图案极大地丰富了显示内容。5.3 与软核处理器如MicroBlaze集成Mojo v3的FPGA资源足够运行一个轻量级的软核处理器比如Xilinx的MicroBlaze。你可以将LCD驱动模块作为处理器的一个外设通过AXI总线或简单的GPIO进行控制。这样你就可以用C语言来编写显示逻辑例如printf到LCD将硬件加速的驱动和灵活的软件编程结合起来这是FPGA嵌入式系统的典型应用。5.4 性能与资源优化时钟使能Clock Enable如果你的系统时钟很快如50MHz而LCD操作很慢us/ms级让状态机每个时钟周期都跳转会产生大量冗余动作。可以引入一个时钟使能信号例如由另一个较慢的计数器如1MHz产生让状态机只在使能有效时工作降低动态功耗。状态机编码使用独热码One-Hot编码可能比二进制编码在小型状态机上更节省资源且速度更快因为其译码逻辑简单。参数化设计将关键的延时计数值如DELAY_15MS、PULSE_WIDTH定义为模块参数这样同一个驱动模块只需在例化时传入不同的参数就能适配不同的系统时钟频率。驱动一个16x2 LCD模块对于Mojo v3 FPGA来说在硬件资源上只是“牛刀小试”但在学习价值上却是“麻雀虽小五脏俱全”。它强迫你深入理解时序、状态机、硬件描述语言和硬件调试的全流程。当你看到屏幕上如期显示出第一行字符时那种对硬件直接掌控的成就感是单纯在单片机上调库无法比拟的。这个项目为你打开了一扇门门后是并行处理、硬件加速和定制化数字系统的广阔世界。接下来你可以尝试用同样的思路去驱动OLED屏幕、VGA显示器或者实现一个基于LCD的简单菜单系统让FPGA与人的交互更加生动。