简介:面向FPGA初学者的LCD1602字符液晶驱动完整工程,基于Verilog实现,涵盖时序控制、数据发送、指令组织与按键交互等关键模块,便于在FPGA平台上显示自定义字符与动态数据。压缩包为rar格式,共95个文件,大小654KB。内容包含Verilog源码、Quartus完整工程文件以及综合适配生成的中间文件与报告(如.cdb、.hdb、.qmsg、.rpt等),另附README和Tcl脚本辅助理解与复现。已有480人学习下载,适合FPGA时序控制、状态机设计及LCD接口协议的学习者参考。通过该工程可快速掌握LCD1602模块化驱动写法,并基于源码扩展动态数值显示、光标控制等功能,节省从零编写驱动的时间。
1. 项目初衷与整体设计
1.1 为什么拿LCD1602练手最划算
我平时做FPGA项目,经常被新手问“第一个上板项目做什么好”。我的答案一直很固定:LCD1602。原因很直白:它把FPGA最核心的三个基本功全占了——状态机设计、时序约束、模块化拆分。你把这套东西跑通,之后去做SPI、I2C、UART,甚至DDR、MIPI、PCIe,底层思路是一模一样的。热搜词里那些“FPGA图像处理”“FPGA实现MIPI”“verilog计数器”,说白了都是在跟同一个东西打交道:怎么用硬件逻辑精确地把数据在正确的时刻送到正确的引脚上。
LCD1602不是简单地把数据扔到引脚上就完事。它内部那套HD44780(或兼容控制器)对时序有硬性要求:E引脚脉冲宽度、RS建立时间、数据建立保持时间,每一项都有下限。FPGA的好处是时钟可控,你可以把每一个纳秒都算清楚,这恰恰是Verilog最擅长的活。
1.2 方案选型:为什么要用FPGA直接驱动
很多人在网上搜到的是51单片机驱动LCD1602的代码,工程中确实大量使用,包括热搜里的“51单片机串口通信lcd1602原理图”。但MCU驱动和FPGA驱动是两种完全不同的玩法:
- MCU是顺序执行指令,用delay函数凑时序,代码看着简单,但时序精度只能到微秒级,而且CPU一旦被中断抢占,显示就会抖动。
- FPGA是全并行逻辑,用状态机精确控制每一个时钟周期,时序稳定到纳秒级,而且显示刷新不占用处理器资源,这在高吞吐场景下有本质优势。
本项目我选择8位数据总线直连方式,不用I2C转接板。原因后面细说,但对新手最友好的就是8位模式,GPIO够用、逻辑更直观,能让你把注意力全部集中在时序本身,而不是为了省引脚把自己绕晕。
1.3 功能目标与验证手段
我不建议上来就追求花哨效果。这个项目的目标就是:上电后第一行显示固定字符串,比如“Hello FPGA”,第二行显示一个实时递增的计数器值,同时用开发板上的按键控制清零。这样做的好处是,你能同时验证三个能力:
- 初始化时序是否正确(显示不出来基本都是初始化问题)
- 写指令和写数据两条通路是否都通(第一行和第二行地址映射不同)
- 动态数据更新能力(第二行计数器刷新,验证光标定位和重复写入)
等这个跑通了,再往里面加字符滚动、自定义字符、波形显示之类的东西,就是锦上添花的事。
2. LCD1602显示原理与关键信号拆解
2.1 引脚定义与数据通路
LCD1602模块引出的引脚一般有16个,核心控制引脚就5个:RS、RW、E、D0-D7(8位模式下)。我做了张表,方便你对照开发板原理图接线。
| 引脚 | 信号 | 方向 | 说明 |
|---|---|---|---|
| VSS / VDD | 电源 | - | 接地、+5V |
| VO | 对比度 | - | 需接可调电阻,通常10k-20k分压 |
| RS | 寄存器选择 | 输入 | 0:写指令 / 读忙标志;1:读写数据 |
| RW | 读写选择 | 输入 | 0:写;1:读 |
| E | 使能 | 输入 | E高脉冲期间锁存数据(下降沿生效) |
| D0-D7 | 数据总线 | 双向 | 8位数据/指令 |
| A / K | 背光 | - | 背光LED电源,常串电阻限流 |
这里有个关键点要提一下:很多开发板上把RW直接接地,只做写操作。这种做法完全可行,因为LCD1602正常使用场景就是主机往它里面写数据,读忙标志(BF位)用固定延时替代即可,后面我会演示这个延时长度的来源。但如果你的项目需要读取DDRAM里的数据做校验,就必须把RW接回FPGA控制。
2.2 时序参数:为什么“快”反而会翻车
HD44780这个控制器是上世纪80年代的设计,内部是一个低速微控制器,处理一条指令需要时间。实测下来大部分兼容芯片的指令执行时间是:
- 清屏(0x01):1.52ms
- 其它所有指令(功能设置、显示开关、模式设置、写数据等):37us
你以为初始化就是往寄存器里写几个值?错。关键是每条指令之后必须等它内部处理完,否则下一条指令就会被吞掉。这就像你和一个人对话,你说完一句话,对方还没消化完,你又说了下一句,结果必然是对牛弹琴。
再看写时序的硬性参数(以常见兼容ST7066U控制器数据手册为准):
| 参数 | 符号 | 最小值 | 备注 |
|---|---|---|---|
| E脉冲高电平宽度 | tPW | 450ns | 必须保证 |
| E使能周期 | tCYC | 1200ns | 两次写操作间隔 |
| RS建立时间(E上升沿前) | tAS | 40ns | - |
| RS保持时间(E上升沿后) | tAH | 10ns | - |
| 数据建立时间(E下降沿前) | tDSW | 80ns | 写模式 |
| 数据保持时间(E下降沿后) | tH | 10ns | - |
我之前踩过一个大坑:代码里E脉冲只拉高了几个时钟周期,50MHz时钟下一个周期20ns,5个周期也才100ns,远小于450ns的要求。结果屏幕时好时坏,换一块屏就失灵。后来用示波器抓E引脚波形才发现,脉冲宽度差了一个数量级。从那以后,我任何时候把E信号拉高,至少保持1us以上,宁可慢不可快。
2.3 字符显示原理:DDRAM、CGROM、CGRAM
LCD1602内部有一块显示数据RAM(DDRAM),共128字节,但屏幕上实际能看到的只有2行x16个字符,对应地址是:
- 第一行:0x00 - 0x0F
- 第二行:0x40 - 0x4F
你往0x00地址写一个字符的ASCII码,它就会显示在第一行最左边;往0x40地址写,就显示在第二行最左边。显示什么字符取决于控制器内置的字符发生器(CGROM),里面预置了ASCII字符集,比如字母A的ASCII码是0x41,你写0x41,屏幕就显示A。
有一点新手容易搞混:LCD1602内部没有“像素”的概念,你写的不是点阵数据,而是字符编号。所以想显示“Hello”,本质就是往DDRAM连续写:0x48、0x65、0x6C、0x6C、0x6F。CGRAM则是让你自定义8个特殊字符,用点阵数据填充,那个玩法等到基础功能跑通之后再研究。
3. Verilog代码架构与核心模块实现
3.1 模块划分:从顶层到底层的职责边界
写Verilog最忌讳把所有逻辑塞进一个module里。我一般这么拆:
lcd1602_top(顶层) ├── clk_div:产生LCD驱动所需的使能节拍(其实可以直接用系统时钟) ├── lcd_driver:状态机核心,负责产生RS、RW、E、D0-D7 └── display_data:显示内容逻辑(字符串ROM + 计数器)顶层只做例化和信号连接,真正的时序逻辑全部在lcd_driver里。display_data和lcd_driver之间用一组数据请求握手信号连接:display_data告诉lcd_driver“当前要写的地址和字符内容”,lcd_driver写完数据后回一个“ready”信号。这样做的好处是显示内容和底层时序完全解耦,以后你想从串口接收数据来显示,只需要改display_data这一层。
3.2 状态机设计:把初始化流程变成看得见的状态
LCD1602最关键的部分是初始化序列。我采用的是数据手册推荐的流程,实测最稳:
- 上电等待15ms以上
- 写0x30(8位模式指令),等待4.1ms
- 写0x30,等待100us
- 写0x30,等待40us
- 写0x38(8位模式、2行、5x8点阵)
- 写0x0C(开显示、无光标、不闪烁)
- 写0x01(清屏),等待1.52ms
- 写0x06(写入后地址自动加1)
这个流程在FPGA里实现,很多人会想用delay函数,但Verilog里没有delay,只有“计数”。所以状态机的核心思路是:每个状态干一件事,干完跳到下一个等待状态,用计数器计时,时间到了再跳下一步。
我用参数定义状态编号,增强可读性:
localparam S_RESET = 4'd0, S_WAIT_15MS = 4'd1, S_WR_30_A = 4'd2, S_WAIT_4MS = 4'd3, S_WR_30_B = 4'd4, S_WAIT_100US = 4'd5, S_WR_30_C = 4'd6, S_WAIT_40US = 4'd7, S_FUNC_SET = 4'd8, S_WAIT_40US2 = 4'd9, S_DISP_ON = 4'd10, S_WAIT_40US3 = 4'd11, S_CLEAR = 4'd12, S_WAIT_2MS = 4'd13, S_ENTRY_MODE = 4'd14, S_WAIT_40US4 = 4'd15;你没看错,我在每一条指令后面都加了一个等待状态。虽然很多指令执行只要37us,我统一给40us,清屏给2ms,确保余量充足。状态机写得繁琐一点,换来的是一眼就能看懂初始化走到哪一步了,调试的时候看仿真波形非常直观。
3.3 延时计数器与写时序实现
延时的本质就是数时钟周期。假设系统时钟是50MHz,一个周期20ns:
- 15ms = 15ms / 20ns = 750000个时钟周期
- 4.1ms = 205000个时钟周期
- 100us = 5000个时钟周期
- 40us = 2000个时钟周期
- 2ms = 100000个时钟周期
用一个22位计数器(最大可计4,194,303)就能全部覆盖。我习惯把延时目标值做成参数,并且兼容仿真,这部分在后面仿真章节专门展开。核心计数逻辑如下:
always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 22'd0; else if (cnt_en) cnt <= cnt + 22'd1; else cnt <= 22'd0; end wire cnt_done = (cnt >= DELAY_CNT);然后写时序部分,最关键的是E脉冲生成,我采用的顺序是:
- 先把RS和D0-D7数据准备好
- 等待几个时钟周期,让数据稳定
- 拉高E
- 保持至少1us(50MHz下就是50个时钟周期)
- 拉低E,完成一次写入
// 数据输出保持 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin lcd_rs <= 1'b0; lcd_db <= 8'd0; lcd_en <= 1'b0; end else begin case (state) // 写入指令时: lcd_rs = 0, 写入数据时: lcd_rs = 1 default: begin lcd_rs <= write_rs; lcd_db <= write_data; lcd_en <= 1'b1; // 数据就绪后拉高E end endcase end end实际工程中我会加一个e_wait计数器,专门控制E高电平保持时长。很多初学者写的代码没有这个保持时间,E脉冲宽度完全取决于状态机的跳变速度,这就容易触发前面说的450ns下限问题。正确姿势是:进入E高状态后,用独立计数器数到50(即1us)再跳变到E低状态。
3.4 显示数据的组织与双行刷写
初始化完成后,进入正常写显示状态。display_data模块会按顺序产生“地址+数据”对:
- 0x80 + 0x00 = 写DDRAM地址0x00,即第一行起始位置(注意写入地址指令时要加0x80,因为地址指令最高位固定为1)
- 随后连续写入字符串“Hello FPGA”
- 第二行同理,先发0x80 + 0x40,再写第二行内容
这里有个细节:显示位置更新计数器值时,我会在两次刷新动作之间加上50ms以上的间隔,不能太频繁地重写整屏。一方面LCD1602本身刷新率有限,过于频繁写会有闪烁感;另一方面FPGA写显示数据会占用总线和时序状态机,给其他逻辑留出空档更合理。实测50ms刷新一次人眼完全感觉不到卡顿,非常流畅。
4. Modelsim仿真验证
4.1 testbench搭建:没有它你真敢上板?
我见过不少新手,代码写完直接烧进FPGA,屏幕上没东西就开始怀疑硬件坏了。这是效率最低的调试路径。实际上Verilog开发和单片机开发有个巨大差异:仿真就是硬件逻辑的“调试器”,在你上板之前,就应该在Modelsim里把初始化时序、状态跳转路径、数据波形全部看完。
testbench的核心就是产生时钟和复位:
`timescale 1ns/1ps module lcd1602_top_tb; reg clk; reg rst_n; wire lcd_rs, lcd_rw, lcd_en; wire [7:0] lcd_db; // 50MHz时钟 initial clk = 1'b0; always #10 clk = ~clk; // 复位 initial begin rst_n = 1'b0; #100; rst_n = 1'b1; end // 实例化待测设计 lcd1602_top dut( .clk (clk), .rst_n (rst_n), .lcd_rs (lcd_rs), .lcd_rw (lcd_rw), .lcd_en (lcd_en), .lcd_db (lcd_db) ); endmodule4.2 仿真相对于上板的最大优势:时间可压缩
这里要分享一个非常实用的技巧。你在写代码时如果按真实延时参数写死,仿真就会非常痛苦——15ms、4.1ms这些长延时在仿真里意味着几十万个时钟周期,Modelsim跑起来要等好几分钟才能看到初始化结束。所以我的工程里会加一个仿真专用宏:
`ifdef SIM localparam DELAY_15MS = 22'd750; // 仿真时缩小1000倍 localparam DELAY_4MS = 22'd205; localparam DELAY_100US = 22'd50; localparam DELAY_40US = 22'd20; localparam DELAY_2MS = 22'd100; `else localparam DELAY_15MS = 22'd750000; localparam DELAY_4MS = 22'd205000; localparam DELAY_100US = 22'd5000; localparam DELAY_40US = 22'd2000; localparam DELAY_2MS = 22'd100000; `endif在ModelSIM的编译选项里加上+define+SIM,仿真就会使用缩小的延时;上板综合时不加这个宏,自动恢复真实延时。这样既能在毫秒级时间内跑完仿真,又保证实际硬件时序正确。这个技巧来自实践,非常管用。
在Modelsim里观察波形时,重点看三个地方:
- E引脚有没有一个完整的高电平脉冲,宽度要符合要求
- RS和DB是否在E上升沿之前就已经稳定(建立时间)
- 状态机是否按预期顺序走完初始化流程
5. 上板调试与常见问题排查
5.1 白屏、黑块、乱码,逐个对号入座
上板出现的现象就那几类,但原因千奇百怪。我把这几年遇到的典型问题整理出来:
| 现象 | 最可能原因 | 解决方法 |
|---|---|---|
| 屏幕完全白屏,背光亮 | VO对比度电压不对 | 调可调电阻,让VO接近0.4V左右,不同屏有差异 |
| 第一行显示黑方块 | 清屏指令没执行成功,DDRAM内容全是0xFF | 检查清屏后是否有足够等待时间(1.52ms以上) |
| 字符全乱,但位置对 | 数据引脚D0-D7接反或错位 | 对照原理图检查FPGA引脚约束 |
| 只有一个字符在闪 | 初始化顺序不对,模式没切进8位/双行 | 重新严格按初始化流程写代码 |
| 上电初期花屏,过几秒正常 | 复位时间不够或复位不彻底 | 延长复位有效时间,至少10us以上,或检查硬复位电路 |
| 第二行显示不出来 | DDRAM地址写错 | 确认第二行起始地址是0x40,且指令带0x80 |
让我重点解释一下“第一行显示黑方块”和“初始化失败”的关联。LCD1602上电后DDRAM里的值是不确定的,如果清屏指令没正确执行,屏幕上可能就是一排黑方块。这往往不是清屏那一步写错了,而是前面某个初始化指令的执行时间不够,控制器还没稳定,清屏指令根本没被接收。解决方法是把初始化流程前面所有等待时间都加长一倍,我实测这是最有效的“傻办法”,适用于绝大部分兼容屏。
5.2 上板前必查的3个硬件点
即使你代码仿真完全正确,上板也可能遇到硬件层面的坑。首先是VO对比度,很多开发板没焊可调电阻,VO直接接地,有的屏电压特性不同就显示不出来。正确的做法是找一颗10k或20k电位器,中间抽头接VO,两边分别接VDD和GND,边调边看对比度。
其次是背光限流电阻。很多模组已经内置了限流电阻,但如果你用的是裸屏,A和K之间直接接5V会把LED烧掉。建议串一个220欧姆到470欧姆的电阻。
最后是共地问题。如果你的FPGA开发板和LCD1602模块分开供电,必须确保共地。我遇到过客户调试半天,最后发现是两个电源各自接地没有连起来,信号完全无效。
5.3 从LCD1602到更复杂接口的迁移思路
做完了这个项目,不要急着丢掉。我建议顺着这个思路做两件事:第一,把LCD1602从8位模式改成4位模式,体会一下省引脚时付出的时序代价;第二,把状态机驱动的思路迁移到SSD1306 OLED或者SPI接口的TFT屏幕上。你会发现,你学会的写时序、状态分割、延时控制,这些全都是同一个方法论。
再进一步,热搜词里那些“fpga图像处理”“ddr3读写控制实现verilog”“fpga pcie”,它们的难点不在数据通路本身,而在时序控制。LCD1602项目里你练的“在多长周期里做什么、什么时候拉高、什么时候拉低”这套思维,恰好是所有高速接口的基石。我的经验是,扎实做完LCD1602,再去看I2C、SPI协议的Verilog实现,吸收速度会快很多。
最后再分享一个我自己的调试习惯:永远在状态机里加一个“当前状态”输出引脚,上板时序不对的时候,用逻辑分析仪或者直接把状态值引到开发板的LED上看,比对着波形猜效率高十倍。这个习惯一直沿用到现在做PCIe、DDR3调测,都救过我不少次。希望这篇内容能帮你在FPGA显示驱动的路上少走弯路,有问题欢迎在评论区交流讨论。
本文还有配套的精品资源,点击获取