news 2026/5/12 2:00:57

FPGA新手必看:5分钟搞定LCD1602驱动代码(附Verilog完整示例)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FPGA新手必看:5分钟搞定LCD1602驱动代码(附Verilog完整示例)

FPGA实战:从零构建LCD1602驱动模块的完整指南

如果你刚接触FPGA,面对一个简单的字符液晶屏,可能会觉得无从下手。数据手册里复杂的时序图、一堆控制指令、还有那个让人头疼的初始化流程,足以让新手望而却步。但我想告诉你的是,驱动LCD1602其实没有想象中那么难。我在最初接触时也踩过不少坑,比如时序没对齐导致屏幕乱码,或者初始化顺序搞错让屏幕完全不亮。经过几个项目的打磨,我总结出了一套既可靠又易于理解的实现方法。

这篇文章就是为你准备的。我不打算重复那些教科书式的原理讲解,而是直接带你动手,用Verilog构建一个真正能工作的驱动模块。我们会从最基础的引脚连接开始,一步步设计状态机,处理那些容易出错的时序细节,最后得到一个可以直接复用的代码模块。无论你是想快速验证硬件功能,还是为更大的项目集成显示模块,这套方案都能让你在短时间内看到结果。

1. 理解LCD1602:我们需要关注什么

很多人一上来就研究LCD1602的所有技术细节,这反而会让人迷失方向。对于FPGA驱动来说,我们只需要抓住几个核心要点就够了。

LCD1602本质上是一个并行接口的字符显示设备。它有16个引脚,但实际用FPGA控制时,我们主要操作其中的几个关键信号:

  • RS(寄存器选择):这个信号决定我们发送的是命令还是数据。简单来说,RS=0时我们配置屏幕工作模式,RS=1时我们发送要显示的字符。
  • RW(读写控制):大多数情况下我们都只向屏幕写入,所以可以直接将这个引脚接地,或者由FPGA固定输出0。
  • E(使能):这是最重要的时序控制信号。所有的命令和数据都在E信号的下降沿被锁存到LCD1602内部。
  • D0-D7(数据线):8位并行数据总线,既可以传输命令代码,也可以传输ASCII字符。

注意:有些教程会提到“忙标志”检测,即读取LCD状态来判断是否准备好接收下一条指令。但在实际FPGA实现中,我更推荐使用固定延时的方法。因为添加读操作会让状态机复杂很多,而LCD1602的指令执行时间相对固定,延时法更简单可靠。

LCD1602的初始化流程有个“坑”需要注意:它要求连续三次发送相同的模式设置指令(0x38),每次之间要有足够的延时。这个要求源于液晶屏的上电稳定特性,如果少了任何一步,屏幕就可能无法正常工作。

初始化步骤发送指令RS值关键延时说明
第一步0x380≥15ms上电后首次初始化
第二步0x380≥15ms第二次模式设置
第三步0x380≥15ms第三次模式设置
第四步0x080约40µs关闭显示
第五步0x010约1.64ms清屏
第六步0x060约40µs设置光标移动方向
第七步0x0C0约40µs打开显示,关闭光标

这个表格中的延时值是按照LCD1602数据手册的最坏情况给出的。在实际编写代码时,我们可以适当放宽这些延时,确保在各种环境下都能稳定工作。

2. 设计驱动模块的状态机

用FPGA控制外设,最清晰的方法就是设计一个状态机。状态机能够明确地划分不同阶段,让时序逻辑变得直观可控。

对于LCD1602驱动,我通常设计9个主要状态:

localparam [3:0] S_IDLE = 4'd0, // 初始等待状态 S_INIT1 = 4'd1, // 第一次初始化 S_INIT2 = 4'd2, // 第二次初始化 S_INIT3 = 4'd3, // 第三次初始化 S_DISPLAY_OFF = 4'd4, // 关闭显示 S_CLEAR = 4'd5, // 清屏 S_ENTRY_MODE = 4'd6, // 设置进入模式 S_DISPLAY_ON = 4'd7, // 打开显示 S_SET_ADDR = 4'd8, // 设置显示地址 S_WRITE_DATA = 4'd9; // 写入显示数据

状态转移的逻辑很简单:每个状态执行完毕后,等待足够的延时,然后跳转到下一个状态。这里的关键是延时计数器的设计。

假设我们的FPGA系统时钟是50MHz(周期20ns),那么15ms的延时需要多少个时钟周期呢?计算方法是:15ms ÷ 20ns = 750,000个周期。在Verilog中,我们可以这样实现:

reg [19:0] delay_counter; // 最大计数到1,048,575,足够用于各种延时 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin delay_counter <= 0; current_state <= S_IDLE; end else begin case (current_state) S_IDLE: begin if (delay_counter >= 20'd750000) begin // 15ms延时 current_state <= S_INIT1; delay_counter <= 0; end else begin delay_counter <= delay_counter + 1; end end S_INIT1: begin // 发送0x38指令 if (delay_counter >= 20'd750000) begin // 再次等待15ms current_state <= S_INIT2; delay_counter <= 0; end else begin delay_counter <= delay_counter + 1; end end // 其他状态类似... endcase end end

这种设计的好处是时序清晰、调试方便。如果某个状态出现问题,我们可以很容易地通过仿真波形定位到具体是哪个阶段的时序不符合要求。

3. 完整的Verilog驱动模块实现

下面是一个经过实际验证的LCD1602驱动模块。我尽量保持了代码的简洁性,同时加入了足够的注释,方便你理解每一部分的作用。

module lcd1602_driver ( input wire clk, // 系统时钟,假设50MHz input wire rst_n, // 低电平复位 input wire [7:0] data_in, // 要显示的数据输入 input wire data_valid, // 数据有效信号 output reg lcd_rs, // 寄存器选择 output reg lcd_en, // 使能信号 output reg [7:0] lcd_data, // 数据输出 output wire lcd_rw, // 读写控制,固定为写模式 output reg ready // 模块就绪信号 ); // 状态定义 localparam [3:0] S_IDLE = 4'd0, S_INIT1 = 4'd1, S_INIT2 = 4'd2, S_INIT3 = 4'd3, S_DISPLAY_OFF = 4'd4, S_CLEAR = 4'd5, S_ENTRY_MODE = 4'd6, S_DISPLAY_ON = 4'd7, S_READY = 4'd8, S_WRITE = 4'd9; reg [3:0] current_state, next_state; reg [19:0] delay_cnt; // 延时计数器 reg [10:0] en_counter; // 使能信号生成计数器 reg en_pulse; // 使能脉冲标志 // 固定RW为低电平,只写不读 assign lcd_rw = 1'b0; // 使能信号生成:产生符合时序要求的E脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin en_counter <= 0; lcd_en <= 1'b0; en_pulse <= 1'b0; end else begin en_counter <= en_counter + 1; // 每1000个时钟周期产生一个使能脉冲 if (en_counter == 11'd500) begin lcd_en <= 1'b1; en_pulse <= 1'b1; end else if (en_counter == 11'd1000) begin lcd_en <= 1'b0; en_counter <= 0; en_pulse <= 1'b0; end end end // 状态机主逻辑 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin current_state <= S_IDLE; delay_cnt <= 0; lcd_rs <= 1'b0; lcd_data <= 8'h00; ready <= 1'b0; end else begin current_state <= next_state; case (current_state) S_IDLE: begin if (delay_cnt >= 20'd750000) begin next_state <= S_INIT1; delay_cnt <= 0; end else begin delay_cnt <= delay_cnt + 1; next_state <= S_IDLE; end lcd_data <= 8'h38; // 8位接口,2行显示,5x8点阵 lcd_rs <= 1'b0; end S_INIT1: begin if (delay_cnt >= 20'd750000 && en_pulse) begin next_state <= S_INIT2; delay_cnt <= 0; end else begin delay_cnt <= delay_cnt + 1; next_state <= S_INIT1; end lcd_data <= 8'h38; lcd_rs <= 1'b0; end S_INIT2: begin if (delay_cnt >= 20'd750000 && en_pulse) begin next_state <= S_INIT3; delay_cnt <= 0; end else begin delay_cnt <= delay_cnt + 1; next_state <= S_INIT2; end lcd_data <= 8'h38; lcd_rs <= 1'b0; end S_INIT3: begin if (en_pulse) begin next_state <= S_DISPLAY_OFF; delay_cnt <= 0; end lcd_data <= 8'h38; lcd_rs <= 1'b0; end S_DISPLAY_OFF: begin if (delay_cnt >= 20'd2000 && en_pulse) begin // 40µs延时 next_state <= S_CLEAR; delay_cnt <= 0; end else begin delay_cnt <= delay_cnt + 1; next_state <= S_DISPLAY_OFF; end lcd_data <= 8'h08; // 关闭显示 lcd_rs <= 1'b0; end S_CLEAR: begin if (delay_cnt >= 20'd82000 && en_pulse) begin // 1.64ms延时 next_state <= S_ENTRY_MODE; delay_cnt <= 0; end else begin delay_cnt <= delay_cnt + 1; next_state <= S_CLEAR; end lcd_data <= 8'h01; // 清屏 lcd_rs <= 1'b0; end S_ENTRY_MODE: begin if (delay_cnt >= 20'd2000 && en_pulse) begin next_state <= S_DISPLAY_ON; delay_cnt <= 0; end else begin delay_cnt <= delay_cnt + 1; next_state <= S_ENTRY_MODE; end lcd_data <= 8'h06; // 光标右移,显示不移动 lcd_rs <= 1'b0; end S_DISPLAY_ON: begin if (delay_cnt >= 20'd2000 && en_pulse) begin next_state <= S_READY; delay_cnt <= 0; end else begin delay_cnt <= delay_cnt + 1; next_state <= S_DISPLAY_ON; end lcd_data <= 8'h0C; // 打开显示,关闭光标 lcd_rs <= 1'b0; end S_READY: begin ready <= 1'b1; // 模块初始化完成,准备接收数据 if (data_valid) begin next_state <= S_WRITE; lcd_rs <= 1'b1; // 准备写入数据 lcd_data <= data_in; end else begin next_state <= S_READY; end end S_WRITE: begin if (en_pulse) begin next_state <= S_READY; ready <= 1'b0; end end default: begin next_state <= S_IDLE; end endcase end end endmodule

这个模块的设计有几个值得注意的特点:

  1. 分离的使能信号生成:我单独用一个计数器生成lcd_en信号,这样状态机只需要关注在正确的时间设置数据和RS信号,使能时序由专门的逻辑保证。
  2. 灵活的延时控制:每个状态的延时计数器独立工作,可以根据需要调整延时时间。
  3. 清晰的接口:模块提供了data_in和data_valid信号,可以方便地与其他模块连接,实现动态显示内容更新。

4. 实际应用:显示自定义内容

驱动模块写好了,接下来我们看看怎么用它显示我们想要的内容。LCD1602的屏幕分为两行,每行16个字符,每个字符位置都有固定的地址。

第一行的地址从0x80开始,到0x8F结束;第二行从0xC0开始,到0xCF结束。要在特定位置显示字符,需要先发送地址命令,再发送字符数据。

下面是一个顶层模块的例子,它使用我们刚才写的驱动模块,在屏幕上显示两行信息:

module lcd1602_top ( input wire clk, input wire rst_n, output wire lcd_rs, output wire lcd_en, output wire [7:0] lcd_data, output wire lcd_rw ); reg [7:0] display_data; reg data_valid; wire driver_ready; // 字符显示序列 reg [4:0] char_index; reg [7:0] char_rom [0:31]; // 存储32个字符 // 初始化字符ROM initial begin // 第一行:"Hello, FPGA World!" char_rom[0] = 8'h48; // H char_rom[1] = 8'h65; // e char_rom[2] = 8'h6C; // l char_rom[3] = 8'h6C; // l char_rom[4] = 8'h6F; // o char_rom[5] = 8'h2C; // , char_rom[6] = 8'h20; // 空格 char_rom[7] = 8'h46; // F char_rom[8] = 8'h50; // P char_rom[9] = 8'h47; // G char_rom[10] = 8'h41; // A char_rom[11] = 8'h20; // 空格 char_rom[12] = 8'h57; // W char_rom[13] = 8'h6F; // o char_rom[14] = 8'h72; // r char_rom[15] = 8'h6C; // l char_rom[16] = 8'h64; // d char_rom[17] = 8'h21; // ! // 第二行:"LCD1602 Test OK" char_rom[18] = 8'h4C; // L char_rom[19] = 8'h43; // C char_rom[20] = 8'h44; // D char_rom[21] = 8'h31; // 1 char_rom[22] = 8'h36; // 6 char_rom[23] = 8'h30; // 0 char_rom[24] = 8'h32; // 2 char_rom[25] = 8'h20; // 空格 char_rom[26] = 8'h54; // T char_rom[27] = 8'h65; // e char_rom[28] = 8'h73; // s char_rom[29] = 8'h74; // t char_rom[30] = 8'h20; // 空格 char_rom[31] = 8'h4F; // O char_rom[32] = 8'h4B; // K end // 状态机控制字符发送顺序 reg [2:0] display_state; localparam [2:0] DS_IDLE = 3'd0, DS_SET_ROW1 = 3'd1, DS_WRITE_ROW1 = 3'd2, DS_SET_ROW2 = 3'd3, DS_WRITE_ROW2 = 3'd4, DS_DONE = 3'd5; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin display_state <= DS_IDLE; char_index <= 0; data_valid <= 1'b0; display_data <= 8'h00; end else begin case (display_state) DS_IDLE: begin if (driver_ready) begin display_state <= DS_SET_ROW1; display_data <= 8'h80; // 第一行起始地址 data_valid <= 1'b1; end end DS_SET_ROW1: begin if (driver_ready) begin display_state <= DS_WRITE_ROW1; char_index <= 0; display_data <= char_rom[0]; data_valid <= 1'b1; end else begin data_valid <= 1'b0; end end DS_WRITE_ROW1: begin if (driver_ready) begin if (char_index == 5'd16) begin // 写完第一行16个字符 display_state <= DS_SET_ROW2; display_data <= 8'hC0; // 第二行起始地址 data_valid <= 1'b1; end else begin char_index <= char_index + 1; display_data <= char_rom[char_index + 1]; data_valid <= 1'b1; end end else begin data_valid <= 1'b0; end end DS_SET_ROW2: begin if (driver_ready) begin display_state <= DS_WRITE_ROW2; char_index <= 18; // 从字符ROM的第18个开始 display_data <= char_rom[18]; data_valid <= 1'b1; end else begin data_valid <= 1'b0; end end DS_WRITE_ROW2: begin if (driver_ready) begin if (char_index == 5'd31) begin // 写完第二行 display_state <= DS_DONE; data_valid <= 1'b0; end else begin char_index <= char_index + 1; display_data <= char_rom[char_index + 1]; data_valid <= 1'b1; end end else begin data_valid <= 1'b0; end end DS_DONE: begin // 显示完成,保持当前内容 data_valid <= 1'b0; end endcase end end // 实例化LCD驱动模块 lcd1602_driver u_lcd_driver ( .clk(clk), .rst_n(rst_n), .data_in(display_data), .data_valid(data_valid), .lcd_rs(lcd_rs), .lcd_en(lcd_en), .lcd_data(lcd_data), .lcd_rw(lcd_rw), .ready(driver_ready) ); endmodule

这个顶层模块展示了如何组织显示内容。实际项目中,你可以根据需要修改char_rom中的内容,或者设计更复杂的内容更新逻辑,比如显示传感器数据、系统状态等信息。

5. 调试技巧与常见问题解决

即使有了完整的代码,实际硬件调试时还是可能遇到各种问题。这里分享几个我积累的调试经验。

问题一:屏幕完全不亮,没有任何显示

这是最常见的问题,可能的原因有:

  1. 对比度调节问题:LCD1602的VO引脚需要接一个可调电阻来设置对比度。如果对比度设置不当,即使屏幕在工作,你也看不到显示内容。

    // 硬件连接检查清单: // 1. VSS接GND,VDD接+5V // 2. VO通过10K电位器连接:一端接GND,一端接VDD,中间抽头接VO // 3. 背光LEDA通过限流电阻接VDD,LEDK接GND
  2. 初始化时序问题:确保三次0x38初始化指令都有足够的延时(每次至少15ms)。可以用示波器或逻辑分析仪检查lcd_en和lcd_data的波形。

  3. 电源问题:确保电源电压稳定在5V,电流足够(带背光时可能需要100mA以上)。

问题二:显示乱码或显示不正确字符

这种情况通常与数据时序有关:

  1. 使能信号E的时序:E信号的高电平脉冲宽度需要至少450ns,建立时间和保持时间也要满足要求。我们的代码中使能脉冲大约持续10µs,远大于最小要求。

  2. 数据建立时间:在E下降沿之前,数据需要提前准备好。建议在E变高之前就设置好数据,这样最保险。

    提示:如果你用逻辑分析仪抓取波形,重点关注E下降沿时刻的数据是否稳定。不稳定的数据会导致显示随机字符。

  3. 地址设置错误:如果你在错误的位置写入了字符,可能会看到字符出现在意想不到的位置。检查地址命令是否正确发送。

问题三:显示内容闪烁或不稳定

这可能是由于电源噪声或信号干扰造成的:

  1. 电源去耦:在LCD1602的电源引脚附近添加100nF的陶瓷电容,可以滤除高频噪声。
  2. 信号线长度:如果FPGA到LCD1602的连接线过长,可能会引入信号完整性问题。尽量缩短连接线,或者使用排线连接。
  3. 背光电流:背光LED的电流较大,可能会影响电源稳定性。确保背光限流电阻合适,或者考虑使用独立的电源为背光供电。

调试时,我习惯先用一个简单的测试模式验证基本功能。比如让屏幕显示全角字符或自定义图案,这样可以快速判断是数据问题还是控制信号问题。

// 简单的测试模式:显示棋盘格图案 reg [7:0] test_pattern; always @(*) begin case (char_index[2:0]) 3'b000: test_pattern = 8'b11111111; 3'b001: test_pattern = 8'b00000000; 3'b010: test_pattern = 8'b11111111; 3'b011: test_pattern = 8'b00000000; 3'b100: test_pattern = 8'b11111111; 3'b101: test_pattern = 8'b00000000; 3'b110: test_pattern = 8'b11111111; 3'b111: test_pattern = 8'b00000000; endcase end

如果测试模式能正确显示,说明硬件连接和基本时序是正确的,问题可能出在字符数据或地址设置上。

6. 性能优化与高级功能扩展

基础功能实现后,我们可以考虑一些优化和扩展,让这个驱动模块更加实用。

优化一:支持4位数据模式

为了节省FPGA的IO引脚,我们可以改用4位数据模式。这样只需要4根数据线,而不是8根。修改方法如下:

  1. 初始化时先以8位模式发送特殊序列
  2. 切换到4位模式
  3. 之后每次发送数据分两次:先高4位,后低4位
// 4位模式初始化序列 if (current_state == S_INIT1) begin lcd_data <= 8'b00110000; // 8位模式设置 end else if (current_state == S_INIT2) begin lcd_data <= 8'b00110000; // 再次8位模式设置 end else if (current_state == S_INIT3) begin lcd_data <= 8'b00110000; // 第三次8位模式设置 end else if (current_state == S_4BIT_SWITCH) begin lcd_data <= 8'b00100000; // 切换到4位模式 end // 4位模式下的数据发送 reg [3:0] lcd_data_4bit; reg send_high_nibble; always @(posedge clk) begin if (send_high_nibble) begin lcd_data <= {data_in[7:4], 4'b0000}; end else begin lcd_data <= {data_in[3:0], 4'b0000}; end end

优化二:添加忙标志检测

虽然延时法简单可靠,但在某些对时间敏感的应用中,忙标志检测可以提高效率。实现方法是在每个命令后尝试读取忙标志:

reg read_busy; reg [7:0] busy_data; always @(posedge clk) begin if (read_busy) begin lcd_rs <= 1'b0; lcd_rw <= 1'b1; // 读模式 // 在E高电平期间读取数据 if (lcd_en) begin busy_data <= lcd_data_in; // 需要将数据线设置为输入 end end end // 检查忙标志位(bit7) wire lcd_busy = busy_data[7];

扩展功能:支持自定义字符

LCD1602允许用户定义8个自定义字符(5x8点阵)。这在显示特殊符号、简单图标时非常有用。定义自定义字符的步骤:

  1. 设置CGRAM地址(0x40 + 字符编号*8)
  2. 连续写入8个字节的字模数据
  3. 在DDRAM中写入字符编号(0-7)来显示自定义字符
// 定义摄氏度符号 reg [7:0] custom_char [0:7]; initial begin custom_char[0] = 8'b00110; // 字模数据 custom_char[1] = 8'b01001; custom_char[2] = 8'b01000; custom_char[3] = 8'b01000; custom_char[4] = 8'b01001; custom_char[5] = 8'b00110; custom_char[6] = 8'b00000; custom_char[7] = 8'b00000; end

在实际项目中,我通常会把LCD驱动模块封装成一个带简单接口的IP核,这样在不同的项目中都可以直接调用。接口可以设计得更友好,比如提供类似printf的格式化输出功能,或者支持滚屏显示等高级特性。

硬件调试时最实用的工具其实是一台数字示波器或逻辑分析仪。通过观察实际波形,你可以直观地看到时序是否符合要求。有时候代码仿真完全正确,但实际硬件就是不行,往往是某些时序边界条件没处理好。比如在低温或高温环境下,LCD的响应速度可能会变化,这时候适当的时序余量就很重要了。

我建议在第一次成功驱动LCD1602后,尝试修改一些时序参数,看看屏幕的容忍度如何。比如把延时减少10%,或者增加50%,观察屏幕是否还能正常工作。这样的实验能帮助你理解时序要求的严格程度,为以后设计其他接口积累经验。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/18 8:13:37

3步掌握B站视频去水印:从批量下载到高效处理的全流程指南

3步掌握B站视频去水印&#xff1a;从批量下载到高效处理的全流程指南 【免费下载链接】downkyi 哔哩下载姬downkyi&#xff0c;哔哩哔哩网站视频下载工具&#xff0c;支持批量下载&#xff0c;支持8K、HDR、杜比视界&#xff0c;提供工具箱&#xff08;音视频提取、去水印等&am…

作者头像 李华
网站建设 2026/4/27 1:22:29

MedGemma实战:上传X光片获取AI分析结果的完整教程

MedGemma实战&#xff1a;上传X光片获取AI分析结果的完整教程 关键词&#xff1a;MedGemma、医学影像分析、X光片解读、AI医疗助手、多模态模型 摘要&#xff1a;本文将手把手教你如何使用MedGemma Medical Vision Lab系统&#xff0c;通过简单上传X光片获取AI分析结果。从环境…

作者头像 李华
网站建设 2026/4/18 22:04:56

5步搞定AWPortrait-Z部署:AI人像美化轻松上手

5步搞定AWPortrait-Z部署&#xff1a;AI人像美化轻松上手 1. 快速了解AWPortrait-Z&#xff1a;你的AI修图助手 你是不是也遇到过这样的烦恼&#xff1f;手机拍的照片总觉得不够高级&#xff0c;想要专业修图效果但又不会用复杂的PS软件。AWPortrait-Z就是为你准备的解决方案…

作者头像 李华
网站建设 2026/4/18 22:04:55

DASD-4B-Thinking实战:用chainlit打造智能问答前端

DASD-4B-Thinking实战&#xff1a;用chainlit打造智能问答前端 1. 引言&#xff1a;为什么需要智能问答前端&#xff1f; 想象一下&#xff0c;你有一个强大的AI模型&#xff0c;能够进行复杂的数学推理、代码生成和科学问题解答。但如果没有一个友好的界面&#xff0c;就像拥…

作者头像 李华
网站建设 2026/4/30 19:34:01

ChatGLM3-6B-128K惊艳效果展示:超长技术文档理解与结构化输出真实案例

ChatGLM3-6B-128K惊艳效果展示&#xff1a;超长技术文档理解与结构化输出真实案例 1. 长文本处理的革命性突破 在人工智能快速发展的今天&#xff0c;处理长文本内容一直是技术领域的难点和痛点。传统的语言模型往往受限于上下文长度&#xff0c;当面对几十页的技术文档、长篇…

作者头像 李华