简介:针对LCD1602字符液晶在FPGA平台上的驱动需求,这套资源提供了一种基于Verilog的完整实现方案,定位为数字逻辑初学者、FPGA自学者和电子设计竞赛备赛者的参考项目。设计从液晶显示原理切入,依次处理初始化序列、RS/RW/E控制信号生成、数据总线并行写入、分频刷新,以及独立按键对显示内容的切换,并涉及字符到LCD内部ASCII码的转换组织,帮助阅读者贯通软件编程与硬件时序的配合要点。压缩包共95个文件,主体是Quartus工程设置、Verilog源文件和可下载固件,同时附有综合/适配报告、Tcl脚本与README说明,大量中间文件由Quartus自动生成,核心资源仅654KB且脉络清楚。源码中的LCD1602.v、分频模块gen_divd.v、按键检测key_check.v可以直接参考复用,配合报告文件还能检查时序约束是否收敛;用户可在这些代码基础上修改显示内容或扩展刷新逻辑。目前已有481人学习下载,对于需要快速验证字符型液晶驱动方案的课程设计或竞赛开发场景,具有不错的参考价值。
1. LCD1602的FPGA Verilog实现:先从一块老屏的时序说起
“LCD1602的FPGA Verilog实现”这个标题听上去简单,但它其实是把FPGA学习从“点灯”推进到“真实外设驱动”最合适的一条路。LCD1602是一块16列2行的字符液晶屏,内部集成HD44780兼容控制器,对外只有RS、RW、E和8根数据线,通讯协议是一套对时间有明确要求的异步写时序。FPGA要驱动它,不是简单地给引脚赋值,而是必须在精确的时间窗口内把命令和数据送到总线上。这篇文章会从时序参数怎么读开始,给出可综合的Verilog驱动模块写法,并集中讨论初始化、显示控制和调试中反复遇到的几个故障。新手可以照着把工程跑通,有经验的人也能看到参数化延时设计和时序余量处理上的取舍。
2. LCD1602时序协议拆解:先把写时序的时间参数算清楚
2.1 引脚分工与8位数据通道:RS、RW、E如何配合
LCD1602对外接口里,真正决定通讯行为的只有三个控制引脚加8根数据线。RS是寄存器选择,RS=0表示总线上是命令,RS=1表示总线上是字符数据;RW是读写方向,对FPGA驱动来说基本只用到写,也就是RW恒为0;E是使能脉冲,数据在E的下降沿被LCD锁存。理解了这一点,就明白为什么写时序里“E高电平的持续时间”是硬指标——E拉高的时间太短,控制器可能还没完成内部采样。
DB0到DB7支持两种接法:8位模式一次传输一个完整字节,4位模式只用DB7到DB4,一个字节要分两次传输。FPGA的IO资源通常不紧张,我一般直接选8位模式,让状态机少一半状态,也少很多和“先高四位后低四位”有关的边界bug。4位模式的意义在于省IO,通常用在单片机或GPIO受限的场景,FPGA项目里除非引脚非常紧张,否则不建议为了省4个IO去增加时序复杂度。
E引脚在空闲时必须保持低电平,这是新手最容易忽略的。如果上电后E一直处于高阻或者不定态,LCD内部逻辑可能把乱码当命令锁存,表现为屏幕出现随机方块。所以驱动模块复位后第一件事就是把E拉低、把RS拉低、把数据总线置成全0,等初始化序列真正开始后再按部就班地拉高E。
2.2 写时序的五个时间参数与50MHz时钟的计数预算
HD44780兼容控制器的数据手册里,写时序的参数很多,但真正需要自己在代码里控制的只有五个:RS和RW的建立时间tAS、数据建立时间tDSW、E高电平宽度tPW、E脉冲周期tCYC,以及数据保持时间tH。以一块常见的1602字符屏为例,典型的最小要求如下表。
| 参数 | 含义 | 最小要求 | 我的设计取值 |
|---|---|---|---|
| tAS | RS/RW在E有效沿之前的建立时间 | 40ns | 100ns |
| tDSW | 数据在E下降沿之前的建立时间 | 80ns | 100ns |
| tPW | E高电平保持时间 | 450ns | 500ns |
| tCYC | E两次脉冲之间的周期 | 1200ns | 1300ns |
| tH | 数据在E下降沿之后的保持时间 | 10ns | 100ns |
把这五个值放到具体时钟频率下,就变成了计数器预算。假设FPGA系统时钟是50MHz,一个时钟周期20ns,那么100ns就是5个时钟周期,500ns就是25个周期。我的做法是把一次完整的写事务拆成四个阶段:SETUP阶段100ns用于RS和数据总线稳定,EHIGH阶段500ns让E保持高电平,ELOW阶段600ns保证E低电平宽度和周期下限,HOLD阶段100ns作为数据保持和总线释放余量。四段相加1300ns,满足tCYC不小于1200ns的要求。
这里有个容易被误解的点:数据并不是在E上升沿锁存,而是在E下降沿。也就是说,真正危险的时间窗口是“E由高变低”的那一瞬间。只要数据总线在下降沿之前满足tDSW、在下降沿之后满足tH,LCD拿到的数据就是可靠的。这也是为什么代码里必须等E彻底拉低并且保持一段时间后,才允许释放数据总线或拉高busy信号。
2.3 初始化序列:为什么是多次0x30,以及固定延时的取舍
LCD1602上电后不会自动进入可用的8位模式,控制器需要一段初始化序列。这段序列的正确性比后续写字符更重要,因为初始化一旦错位,后续所有命令都会被解释错。对于8位接口模式,标准做法是上电后先等待电源稳定,然后重复发送0x30三次,再发送功能设置命令0x38。
| 步骤 | 写入值 | 等待时间 | 目的 |
|---|---|---|---|
| 0 | (无) | 40ms | 等待电源稳定 |
| 1 | 0x30 | 4.1ms以上 | 第一次设置8位接口 |
| 2 | 0x30 | 100us以上 | 第二次,确保总线进入8位模式 |
| 3 | 0x30 | 40us以上 | 第三次,消除残余不定态 |
| 4 | 0x38 | 40us以上 | 8位、双行、5x8点阵 |
| 5 | 0x08 | 40us以上 | 关闭显示 |
| 6 | 0x01 | 1.64ms以上 | 清屏 |
| 7 | 0x06 | 40us以上 | 地址自增、显示不移动 |
为什么0x30要发三次?因为LCD刚上电时接口模式未知,有可能处在4位模式,甚至可能已经接收了半个字节。连续三次0x30是数据手册里保证“无论之前处于什么状态,最后都能回到8位命令模式”的做法。如果只发一次,就会出现某些批次屏幕正常、另一些批次屏幕全黑的随机现象。
初始化期间还有一个取舍:要不要轮询忙标志BF。BF是DB7在RS=0时反馈的忙状态,为1表示内部正在处理命令。单片机驱动LCD时经常用轮询BF来缩短等待时间,但FPGA做这件事需要额外设计读时序状态机和三态总线控制,复杂度上升不少。在实际工程中,固定延时的可靠性已经足够,因为FPGA的时钟是确定的,只要延时给够,永远不会出现“忙标志读取失败导致误操作”的问题。固定延时的代价就是每个命令要多等几十微秒,对字符屏应用来说完全无所谓。
3. 从时序图到Verilog代码:可综合的LCD写驱动模块
3.1 模块接口划分:用户侧请求和LCD物理侧分开
设计驱动模块时,我习惯把接口分成两侧。用户侧只关心“我要写命令还是写数据,有没有写完”,不关心E脉冲怎么拉;物理侧直接连接LCD引脚,负责把一次写请求变成满足时序的总线操作。这样做的好处是上层逻辑可以独立测试,也方便将来把8位模式改成4位模式而不影响显示内容控制部分。
模块的接口定义如下:wr_en是写请求脉冲,rs_sel为1表示写数据、为0表示写命令,wr_data是待写入的8位数据。busy信号在写事务期间拉高,上层模块必须等busy拉低之后才能发起下一次写请求。lcd_rs、lcd_e、lcd_db直接接到LCD物理引脚,lcd_rw固定接地。
module lcd1602_drv #( parameter integer CLK_FREQ_HZ = 50_000_000 )( input wire clk, input wire rst_n, // 用户侧请求 input wire wr_en, input wire rs_sel, input wire [7:0] wr_data, output reg busy, // LCD物理侧 output reg lcd_rs, output reg lcd_e, output wire lcd_rw, output reg [7:0] lcd_db ); assign lcd_rw = 1'b0; // 只写模式物理侧特意把lcd_rw做成wire,因为整个设计只需要写操作。如果以后要扩展成读写双方向,就需要把lcd_db改成inout三态总线,并增加一个读时序状态机。但在目前这个阶段,保持只写让代码简单得多,也避免了三态逻辑在综合时带来的额外约束。
3.2 状态机驱动:一次写事务的四段状态跳转
写事务的核心是一个四段状态机。S_IDLE等待写请求,S_SETUP完成RS和数据总线的建立,S_EHIGH把E拉高并保持足够时间,S_ELOW把E拉低,最后S_HOLD做数据保持释放。每个阶段的停留时间由计数器cnt_r控制,计数值在模块开头用localparam换算好。
localparam integer NS_TICK = 1_000_000_000 / CLK_FREQ_HZ; localparam integer CNT_SETUP = ( 100 + NS_TICK - 1) / NS_TICK; localparam integer CNT_EHIGH = ( 500 + NS_TICK - 1) / NS_TICK; localparam integer CNT_ELOW = ( 600 + NS_TICK - 1) / NS_TICK; localparam integer CNT_HOLD = ( 100 + NS_TICK - 1) / NS_TICK; localparam [2:0] S_IDLE = 3'd0, S_SETUP = 3'd1, S_EHIGH = 3'd2, S_ELOW = 3'd3, S_HOLD = 3'd4; reg [2:0] state_r; reg [6:0] cnt_r; reg [7:0] buf_data_r; reg buf_rs_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state_r <= S_IDLE; cnt_r <= 0; lcd_e <= 1'b0; lcd_rs <= 1'b0; lcd_db <= 8'h00; busy <= 1'b0; end else begin case (state_r) S_IDLE: begin if (wr_en) begin lcd_rs <= rs_sel; lcd_db <= wr_data; buf_data_r <= wr_data; buf_rs_r <= rs_sel; cnt_r <= 0; busy <= 1'b1; state_r <= S_SETUP; end end S_SETUP: begin cnt_r <= cnt_r + 1'b1; if (cnt_r >= CNT_SETUP - 1) begin cnt_r <= 0; lcd_e <= 1'b1; state_r <= S_EHIGH; end end S_EHIGH: begin cnt_r <= cnt_r + 1'b1; if (cnt_r >= CNT_EHIGH - 1) begin cnt_r <= 0; lcd_e <= 1'b0; state_r <= S_ELOW; end end S_ELOW: begin cnt_r <= cnt_r + 1'b1; if (cnt_r >= CNT_ELOW - 1) begin cnt_r <= 0; state_r <= S_HOLD; end end S_HOLD: begin cnt_r <= cnt_r + 1'b1; if (cnt_r >= CNT_HOLD - 1) begin cnt_r <= 0; busy <= 1'b0; state_r <= S_IDLE; end end default: state_r <= S_IDLE; endcase end end这里有个细节值得注意:RS和数据总线在S_IDLE里就已经更新,早于E拉高的时间至少一个S_SETUP阶段。也就是说,即使RS建立时间取不到100ns,实际电路上RS也比E先变化,满足tAS。数据总线同理,在E上升沿之前早已稳定,而E下降沿才是锁存边沿,数据保持时间天然满足。
CNT_SETUP等参数在模块开头按当前时钟频率计算,而不是写成固定数字。这样做的直接好处是换一块不同主频的开发板时,不需要手动改状态机里的计数常量。计算式里用“加上NS_TICK减1再整除”的方式做向上取整,保证实际延时不会小于设计值。
3.3 延时计数器的换算规则与参数化写法
RTL代码里不能写延时函数,也不能依赖#延时语法,因为那只是仿真语句,综合后会直接忽略。所有延时都要靠计数器“数时钟周期”。换算规则很简单:延时值除以时钟周期,结果向上取整。例如100ns在50MHz下是5个时钟周期,在100MHz下是10个周期,在10MHz下是1个周期。
计数器位宽则要按最大延时来选。上面的驱动模块单次写事务最长只有600ns,7位计数器足够。但初始化状态机里有40ms的延时,50MHz下一共200万个时钟周期,所以初始化部分的计数器至少要21位,我习惯直接定义成24位,避免算错余量。位宽宁大勿小,寄存器在FPGA里并不贵,因为计数器溢出导致的时序错误却非常难查。
参数化的写法还有一个容易被忽略的坑:如果CLK_FREQ_HZ取10MHz,NS_TICK等于100,CNT_SETUP的算式变成(100+100-1)/100=1,也就是1个时钟周期100ns,刚好满足tAS。但如果时钟再降到5MHz,NS_TICK变成200,(100+200-1)/200=1,仍然只有1个周期,实际延时200ns,也满足100ns的要求。这说明向上取整的算法在低速时钟下仍然是保守安全的。反过来,如果写成普通整除,10MHz下CNT_SETUP会变成0,状态跳转瞬间完成,建立时间完全无法保证。这就是为什么要特意写成向上取整。
4. 顶层集成与显示控制:把字符和滚动效果搬上屏幕
4.1 顶层例化:初始化状态机与命令封装
驱动模块只负责单次写事务,它不知道LCD需要初始化,也不知道用户要显示什么。顶层模块要做的就是两件事:上电后按顺序执行初始化序列,初始化完成后接收上层的写命令和写数据请求。这里用一个初始化状态机配合一个等待计数器,初始化完成后把控制权交给用户侧。
初始化状态机的写法是:每发出一条命令后,等驱动模块的busy信号拉低,再按表查下一跳延时。固定延时的长度直接按50MHz折算成计数值,并通过参数与CLK_FREQ_HZ绑定。
reg [3:0] init_step; reg [23:0] wait_cnt; reg [7:0] init_data; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin init_step <= 0; wait_cnt <= 0; ... end else begin case (init_step) 4'd0: if (wait_cnt >= CLK_FREQ_HZ * 40 / 1000 - 1) begin wait_cnt <= 0; init_step <= 1; end else wait_cnt <= wait_cnt + 1; 4'd1: begin init_data <= 8'h30; init_step <= 2; end 4'd2: if (!busy) begin wait_cnt <= 0; init_step <= 3; end 4'd3: if (wait_cnt >= CLK_FREQ_HZ * 41 / 100 - 1) begin ... end ... endcase end end这段代码没有把每个状态都写全,实际工程中我会用一个大case把0x30三次、0x38、0x08、0x01、0x06全部列出来,每条命令之间插入对应的等待段。值得强调的是,等待段不仅要等busy拉低,还要确保额外的命令执行时间已经过去。比如清屏命令0x01,busy拉低只代表“总线写过程结束”,不代表LCD内部清屏完成,必须在busy拉低后再等一个较长延时,否则下一条命令可能和清屏操作撞车。
init_step等于某个终点值时,顶层拉高一个ready信号,用户侧逻辑才开始发起写命令。这个握手关系要清清楚楚,否则用户侧一上来就写数据,很容易把数据当成命令发出去。
4.2 DDRAM地址映射:第二行为什么是0x40
LCD1602显示内容来自内部的DDRAM,地址0x00到0x0F对应第一行16个字符位置,0x40到0x4F对应第二行16个位置。要往某个位置写字符,必须先发送一条“设置DDRAM地址”命令,命令格式是0x80加上目标地址,然后再以RS=1的方式连续写入ASCII码。
第二行起始地址是0x40而不是0x10,这一点特别容易搞错。早期有些1602模块在4位模式下第二行起始地址可能是0x08或0x14,但8位双行模式下标准值就是0x40。如果初始化用的是0x38,那么第二行地址必须按0x40处理。
// 写第一行第0列:地址 0x00 user_cmd <= 8'h80 | 8'h00; user_rs <= 1'b0; // 写第二行第3列:地址 0x43 user_cmd <= 8'h80 | 8'h40 | 8'h03; user_rs <= 1'b0; // 写字符 'A' user_cmd <= 8'h41; user_rs <= 1'b1;地址自增模式下,连续写完16个字符后,DDRAM地址会自动跨过0x0F进入0x10,而0x10并不是第二行的0x40,屏幕上会表现为第二行开头出现看不见的字符,或者第一行尾部多出奇怪内容。所以如果需要精确控制两行显示,建议每次换行都重新设置DDRAM地址,而不是依赖地址连续递增跨行。
显示固定文本时,我习惯在顶层用一个只读的字符映射表。用一个reg类型的索引作为case条件,把位置编号映射到对应的ASCII码。这样做比存RAM简单,综合后是一个常数查找逻辑,不占块RAM资源。
4.3 用滚动起点实现第二行文本轮播
滚动显示的核心思路不是移动已经写入DDRAM的内容,而是改变每次写入的起点位置。LCD1602本身有显示移位命令0x18,但那个移位会让整屏内容一起动,而且需要反复维护显示状态。我更推荐的做法是:每隔一段固定时间重新刷新第二行,每次刷新时从滚动起点开始取字符。
假设要在第二行循环显示16个字符的一串文本,滚动起点scroll_head从0到15循环。刷新时,第二行第col列显示第(scroll_head+col)%16个字符。代码骨架如下。
reg [3:0] scroll_head; reg [31:0] tick_cnt; reg [4:0] col_idx; reg [1:0] disp_state; localparam TICK_MAX = CLK_FREQ_HZ / 2; // 每0.5秒滚动一格 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scroll_head <= 4'd0; tick_cnt <= 0; disp_state <= 2'd0; end else begin if (tick_cnt >= TICK_MAX - 1) begin tick_cnt <= 0; scroll_head <= scroll_head + 1'b1; end else begin tick_cnt <= tick_cnt + 1'b1; end case (disp_state) 2'd0: begin // 设置第二行起始地址 user_cmd <= 8'h80 | 8'h40; user_rs <= 1'b0; col_idx <= 5'd0; disp_state <= 2'd1; end 2'd1: begin // 连续写16个字符,按滚动起点取模 user_cmd <= char_table[(scroll_head + col_idx) % 16]; user_rs <= 1'b1; if (col_idx == 5'd15) disp_state <= 2'd2; else col_idx <= col_idx + 1'b1; end 2'd2: if (!lcd_busy) disp_state <= 2'd0; endcase end end这段代码里有一个不太显眼但很实用的地方:刷新第二行时每次都是从0x40地址写起,而不改动第一行。第一行的内容完全不受影响。滚动速度可以通过TICK_MAX调整,比如改成CLK_FREQ_HZ/10就是每秒滚动2格。如果文本长度超过16个字符,就把取模的分母改成实际文本长度,同时把scroll_head的范围放大。
5. 避坑排查:FPGA驱动LCD1602最容易翻车的五个实测问题
5.1 现象:上电后屏幕全黑或整屏方块
这是最常遇到的问题,现象是初始化结束后屏幕没有任何字符,或者整排显示黑色方块。原因几乎都是初始化序列没有真正执行:电源稳定等待不够40ms就发第一条命令,或者0x30重复次数不够,或者E引脚在复位期间处于高电平。
解决方法是先检查顶层状态机复位后是否把E拉低,再检查初始化步骤里是否留了足够的上电等待时间。有些开发板的LCD供电是从FPGA板载LDO出来,FPGA固件加载本身就需要几十毫秒,如果初始化在固件加载瞬间就开始,LCD可能根本没上电。把上电等待从40ms加到100ms是最简单的后悔药。
5.2 现象:复位后出现乱码,再次复位又正常
表现为屏幕能显示内容,但字符错乱,比如“ABCD”变成“B@#A”,再按一次复位又正常。这通常是E高电平宽度不够,或者数据建立时间不足。LCD内部采样边沿在E下降沿,如果E高电平只有200多纳秒,某些批次模块在临界点会锁存到正在变化的总线数据。
解决方法是把E高电平阶段加大到500ns以上,同时确认S_SETUP阶段确实发生在E拉高之前。不要试图把时序压到刚好满足手册最小值,留出30%到50%的余量,特别是在量产或换屏的情况下。
5.3 现象:文本位置整体错位,清屏后偶发
文本能显示但位置不对,比如第一行内容出现在第二行,或者字符从第3列开始。这种问题大多出在清屏和地址设置之间。清屏命令0x01执行时间约1.64ms,如果在清屏未完成时就发送地址设置命令,LCD会把地址命令丢弃或者错误解释。
解决方法是把0x01后等待时间至少放到2ms以上,并且在每次刷新显示前先发0x02光标归位命令,再设置DDRAM地址。0x02同样需要约1.64ms,两个大延时不能省。
5.4 现象:运行中字符偶发“花掉”
程序稳定运行一段时间后,某一行字符偶尔变成乱码或与其他字符重叠。这类问题往往发生在数据总线在E下降沿前后被改动。比如滚动显示刷新时,新的数据提前输出到了lcd_db,而上一轮E的低电平保持还没结束,LCD可能把新数据当成旧命令锁存。
解决方法是严格遵守驱动模块的HOLD阶段:E拉低之后至少保持100ns再更新lcd_db,更新的动作只允许在S_HOLD结束后的IDLE阶段发生。所有用户侧数据应在wr_en为高时才提交,不能直接绕过驱动模块去改lcd_db。
5.5 现象:换开发板或换屏后显示异常
同一段代码在一套板子上正常,换到另一套就显示异常,这是最让人头疼的玄学问题。原因通常有三个:新板子时钟频率不同但延时参数没跟着换算;LCD对比度引脚VO的分压电阻不同导致显示太淡或全黑;不同批次LCD模块内部时序余量有差异。
解决方式是把驱动模块里的所有延时都按CLK_FREQ_HZ参数化,换时钟只改一个参数。同时用万用表量VO引脚电压,通常1V到4V之间,调整到显示底色刚好变淡、字符清晰的临界点。最后在换屏之后重新跑一遍上电、复位和长时间滚动测试,不要只看一次显示正常就说没问题。
6. 进阶技巧:把LCD延时参数化并用波形验证时序余量
驱动模块从能显示到能稳定显示,中间隔着一次波形验证。我习惯在FPGA内部直接例化一个逻辑分析仪IP,把lcd_e、lcd_rs、lcd_db[7:0]和一个触发条件接进去。触发条件可以设为第一次写0x38命令的时刻,这样抓到的波形正好覆盖初始化后面几个关键脉冲。
抓到的波形重点看两点。第一点是E高电平宽度,用逻辑分析仪的光标量一下从拉高到拉低的时间是否到了500ns。第二点是数据变化边沿和E下降沿的相对位置,正常情况下数据应在E下降沿前至少100ns变化,下降沿后还能保持一段时间。如果两条边沿几乎重合,说明建立时间不够,这个屏早晚会出问题。
把延时数值参数化之后,还要养成一个习惯:每次改时钟频率,重新计算一次所有延时的实际纳秒数。我见过有人把50MHz的工程整体改到100MHz,只改了约束文件,延时节拍数没改,结果E高电平从500ns缩水到250ns,屏幕在低温时就开始乱码。这属于典型的“代码没变但时序变了”。
我自己做LCD驱动的最后一步,是加一个简单的仿真testbench,用虚拟时钟跑一遍完整初始化序列,在波形窗口里检查命令顺序。这个环节不花什么时间,但能提前拦住一半以上的低级错误。每次拿到新批次的屏幕,我也会把初始化等待时间从标准值往上调10%再测一轮。希望这篇笔记能帮你少走几步弯路,也祝你调试顺利。
本文还有配套的精品资源,点击获取