FPGA驱动LCD:从HD44780时序到Verilog状态机实战
2026/8/4 3:45:16 网站建设 项目流程

1. 项目缘起:为什么要在FPGA上驱动LCD?

如果你玩过单片机,比如Arduino或者STM32,驱动一块16x2的字符型LCD(液晶显示屏)几乎是入门必做的实验。网上有现成的库,几行代码就能让屏幕亮起来,显示“Hello World”。那么,为什么我们要“大材小用”,在Mojo v3这块FPGA开发板上做同样的事情呢?这听起来就像用一台超级计算机去算1+1。

我最初也有这个疑问,直到我在一个需要极高实时性和确定性的嵌入式项目里碰了壁。那个项目里,我需要一个显示模块来输出系统状态和调试信息,但主控MCU的CPU时间已经被更关键的任务(比如电机控制环)占满了,任何额外的中断或软件延时都会导致控制环路抖动。这时,我想到了FPGA。FPGA的并行硬件逻辑可以独立运行,不占用CPU资源,生成LCD控制时序就像呼吸一样自然,完全不会干扰主系统的软实时性。Mojo v3作为一款性价比高、生态友好的入门级FPGA板,正是验证这个想法的绝佳平台。

所以,这个项目远不止是让LCD亮起来。它的核心价值在于理解如何用纯硬件逻辑(Verilog)去模拟一个常见的、依赖特定时序的串行外设接口。你将学到如何将一份数据手册上的时序图,翻译成可综合的硬件描述语言代码,并最终在真实的硬件上看到结果。这个过程是FPGA开发中最基础也最核心的技能之一。无论你未来是做高速通信、图像处理还是复杂控制,这种“从协议到电路”的思维转换能力都至关重要。

2. 硬件准备与接口原理剖析

在动手写代码之前,我们必须搞清楚两件事:我们手头的“武器”是什么,以及我们要驱动的“目标”有什么脾气。

2.1 认识我们的硬件平台:Mojo v3

Mojo 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根线就能完成控制:

  1. RS (Register Select):寄存器选择。低电平(0)时选择指令寄存器(发送命令),高电平(1)时选择数据寄存器(发送要显示的字符数据)。
  2. R/W (Read/Write):读写选择。因为我们只向LCD写数据,所以此引脚可以永久接地(低电平),设置为只写模式。
  3. E (Enable):使能信号。这是最重要的时序控制线。数据在E引脚的高电平脉冲(下降沿)被锁存到LCD控制器中。
  4. 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的扩展接口1(P1)上的引脚:

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_en
Pin 6 (IO_L3N_0)RS寄存器选择lcd_rs
GNDR/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 = 4'b0000; // 空闲 localparam [3:0] INIT_WAIT = 4'b0001; // 初始化等待(上电延时) localparam [3:0] FUNC_SET_H = 4'b0010; // 发送功能设置高4位 localparam [3:0] FUNC_SET_L = 4'b0011; // 发送功能设置低4位 // ... 其他初始化命令状态 localparam [3:0] SEND_HIGH = 4'b1000; // 发送数据高4位 localparam [3:0] SEND_LOW = 4'b1001; // 发送数据低4位 localparam [3:0] FINISH = 4'b1010; // 完成,等待稳定时间 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 = 20'd750_000; // 15ms localparam DELAY_4100US = 20'd205_000; // 4.1ms localparam DELAY_100US = 20'd5_000; // 100us localparam DELAY_40US = 20'd2_000; // 40us localparam PULSE_WIDTH = 20'd12; // E脉冲宽度 ~240ns localparam CYCLE_TIME = 20'd25; // 周期时间 ~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 = 1'b0; lcd_rs = 1'b0; lcd_data = 4'b0000; done = 1'b0; delay_en = 1'b0; case (state) IDLE: begin done = 1'b1; 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 = 1'b1; // 产生E脉冲 delay_en = 1'b1; 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 = 1'b1; 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 = 1'b1; 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 = 1'b1; delay_counter = DELAY_40US; // 等待最后40us以上 if (delay_counter == 0) begin done = 1'b1; next_state = IDLE; end end default: next_state = IDLE; endcase end endmodule

这个状态机清晰地划分了发送一个字节的流程:准备高4位 -> 产生E脉冲 -> 等待 -> 准备低4位 -> 产生E脉冲 -> 等待完成。初始化过程则是按特定顺序发送一系列命令字节(如功能设置、显示开关、输入模式设置、清屏等),每个命令字节都走一遍上述发送流程。

3.2 初始化序列的精确实现

LCD上电后必须进行一段严格的初始化过程,否则无法正常工作。这个过程通常包括长时间的等待(>15ms)、发送特定的复位命令、设置4位模式、设置显示行数字体、打开显示、清屏、设置输入模式等。很多驱动失败的原因都源于初始化序列不完整或时序不对。

在我的实现中,我专门为初始化设计了一个独立的状态机,或者作为主状态机的一部分。关键是要严格按照数据手册的推荐步骤:

  1. 上电后等待VCC稳定(至少15ms)。
  2. 发送软件复位命令0x30(功能设置,8位模式)三次,每次间隔至少4.1ms。
  3. 发送设置4位模式命令0x20(实际上,在4位模式下,我们分两次发送0x28,即0x20x8,代表2行、5x8点阵)。
  4. 后续发送显示开关、清屏、输入模式设置等命令。

踩坑实录:我曾因为忽略了第三次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的器件支持文件。

  1. 创建项目:选择正确的器件型号XC6SLX9-2TQG144C
  2. 添加源文件:将你的lcd_driver.vlcd_controller.v和顶层模块(例如mojo_top.v)添加到项目中。
  3. 编写引脚约束文件 (.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 添加类似约束
  4. 综合与实现:运行综合(Synthesis)和实现(Implementation)。检查报告,确保没有警告(如未约束的时钟)和错误。
  5. 生成比特流文件:这一步会生成.bit文件。

4.2 使用Mojo Loader进行编程

Mojo v3使用基于AVR的USB编程器。你需要下载Mojo Loader工具(或使用其Eclipse插件)。将生成的.bit文件通过Mojo Loader烧录到FPGA中。由于FPGA是易失性的,断电后程序会丢失。如果希望程序上电自启动,需要将比特流文件转换为.hex格式并烧录到板载的串行配置Flash中。

4.3 硬件调试与常见问题排查

即使一切步骤都看似正确,LCD屏幕也可能不亮。以下是一个系统的排查清单:

  1. 电源与背光:首先确认LCD的VDD和GND连接正确,电压是否在4.7V-5.3V之间(对于5V模块)。如果屏幕有背光,检查背光引脚(LED+和LED-)是否已连接(通常LED-串联一个限流电阻接地)。
  2. 对比度:这是最常见的问题!调节连接在V0引脚上的电位器,直到屏幕上的字符(或方块)清晰显示。如果对比度调至最低(V0接近VDD),屏幕会全白;调至最高(V0接近GND),屏幕会全黑。
  3. 初始化失败:如果屏幕亮起但显示乱码或只有一行方块,很可能是初始化序列没有正确执行。确保你的状态机完成了所有必需的步骤和等待时间。一个有效的调试方法是使用一个简单的测试:反复发送清屏命令(0x01)。如果屏幕能清屏,说明基本通信是通的,问题可能在初始化或后续命令。
  4. 时序问题:用示波器或逻辑分析仪观察lcd_enlcd_rslcd_data的波形是最直接的。检查E脉冲的宽度是否足够(>230ns),数据在E下降沿前后是否稳定(满足建立和保持时间)。在50MHz时钟下,一个常见的错误是E脉冲只持续了一个时钟周期(20ns),这远远不够。
  5. 引脚连接错误:再次仔细检查Mojo与LCD之间的每一根连线,确保没有接错、虚焊或短路。特别是D4-D7的顺序,高位和低位不能颠倒。

5. 进阶应用与优化思路

当基本的显示功能跑通后,你可以考虑以下方向进行深化和优化,这能让这个项目从一个简单的实验进化成一个实用的硬件模块。

5.1 设计一个易用的字符发送接口

目前的驱动模块可能还需要上层控制器精确控制发送时机。我们可以设计一个带FIFO(先进先出队列)的接口。上层应用只需要将字符数据写入一个FIFO,驱动模块自动从FIFO中读取并发送,实现“解耦”。这样,系统的其他部分可以异步地更新显示内容,而不用担心破坏LCD的时序。

5.2 实现自定义字符(CGRAM)

HD44780允许用户定义最多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_15MSPULSE_WIDTH)定义为模块参数,这样同一个驱动模块只需在例化时传入不同的参数,就能适配不同的系统时钟频率。

驱动一个16x2 LCD模块,对于Mojo v3 FPGA来说,在硬件资源上只是“牛刀小试”,但在学习价值上却是“麻雀虽小,五脏俱全”。它强迫你深入理解时序、状态机、硬件描述语言和硬件调试的全流程。当你看到屏幕上如期显示出第一行字符时,那种对硬件直接掌控的成就感,是单纯在单片机上调库无法比拟的。这个项目为你打开了一扇门,门后是并行处理、硬件加速和定制化数字系统的广阔世界。接下来,你可以尝试用同样的思路去驱动OLED屏幕、VGA显示器,或者实现一个基于LCD的简单菜单系统,让FPGA与人的交互更加生动。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询