简介:采用莱迪思小脚丫LCMXO2开发板与诺基亚5110液晶屏实现的数字时钟设计,是一份适合FPGA初学者、电子竞赛备赛者和数字逻辑课程实践的完整工程包,能解决在LCMXO2平台上驱动液晶屏、完成时分秒显示、按键调时等常见问题,也可作为课程设计与竞赛模板。压缩包共包含71个文件,大小约1.32MB,核心由Verilog源码、原理图PDF、设计文档以及工程配置文件构成;源码按功能拆分为时钟分频、按键消抖、液晶驱动和顶层连接等模块,工程内还包括约束文件、综合报告、位流文件和构建日志,可打开工程后直接从综合、布局布线并下载到小脚丫开发板运行。目前已有383人学习下载,目录结构清晰,各模块独立性强。配合原理图与说明文档阅读,既能理清开发板与液晶屏之间的引脚对应关系,也能借鉴其状态机与模块划分思路,为进一步扩展闹钟、温度显示或菜单交互等设计提供了清晰的可复用基础与二次开发空间。
1. 小脚丫LCMXO2驱动Nokia5110做数字时钟,到底在练什么
把小脚丫LCMXO2开发板和一块Nokia 5110屏接起来做数字时钟,看着是个“花活”,实际上把FPGA开发里最常踩的一整条链路都走了一遍:板载晶振分频成1Hz脉冲、BCD计数器维护时分秒、按键消抖校时、SPI时序驱动PCD8544控制器、字库点阵存储,最后还要在Lattice Diamond里做引脚约束并烧进片上Flash让它上电自启动。MCU方案里几行库函数就完成的工作,换到Verilog之后每一项都得自己用寄存器搭出来。这个项目很适合理清“硬件描述语言到底在描述什么”的阶段去练手,也顺便验证一块开发板的外部晶振、IO电平、下载链路是否都正常。
2. 先看原理图:小脚丫LCMXO2资源和Nokia5110接口分析
LCMXO2和Nokia5110都不是新东西,但各自的特性决定了这个项目的电路接法和约束写法。先把两者接口对齐,再去看PDF原理图,后面写Verilog才不会返工。
2.1 小脚丫LCMXO2上数字时钟用到的硬件资源
小脚丫LCMXO2开发板的核心是Lattice MachXO2系列里的LCMXO2-1200ZE,属于Flash-based FPGA。这意味着配置数据可以烧录到芯片内部的Flash里,上电直接从Flash加载,不需要像Xilinx或Intel的SRAM型FPGA那样外挂SPI Flash配置芯片。放在数字时钟这种需要“上电就跑”的场景里非常合适,断电再上电不用重新下载。
LCMXO2-1200ZE的逻辑资源并不算多,大概千余个LUT4和对应的寄存器,块RAM也只有几十Kbit级别,但驱动一块84x48分辨率的单色液晶屏绰绰有余。Nokia5110的显存是504字节,字库存几个ASCII字符也就一二百字节,显示逻辑全部用寄存器也能放得下。真正要注意的不是资源够不够,而是时钟从哪里来。
板上通常有一颗外部晶振,频率常见12MHz或者25MHz,原理图上丝印会写明具体数值。数字时钟的基准就是这颗晶振,分频参数必须和它对应。MachXO2内部还有一个可配置频率的OSCH振荡器,可以省掉外部晶振,但内部振荡器的频率精度和温漂都不如外部晶振,做时钟不建议用,否则一天下来误差会相当可观。
2.2 PCD8544接口分析:SPI模式0和命令时序
Nokia5110的驱动芯片是PCD8544,接口本质是SPI从机,但比普通SPI设备多了两根控制线。一共六根信号线,除了电源和背光之外分别是:
| 信号 | 方向 | 作用 |
|---|---|---|
| RST | 输入 | 复位,低电平有效,最少保持100ns低电平 |
| SCE | 输入 | 片选,低电平有效,整个传输过程必须保持低 |
| D/C | 输入 | 低电平写命令,高电平写显示数据 |
| DIN | 输入 | MOSI数据线,MSB先行 |
| SCLK | 输入 | 串行时钟,上升沿采样数据 |
PCD8544要求时钟空闲时保持低电平,数据在SCLK上升沿被采样,这就是标准的SPI Mode 0,即CPOL=0、CPHA=0。最大SCLK频率是4MHz,FPGA侧必须做个分频,不能直接把12MHz或25MHz时钟送给屏幕。
上电之后不能直接写显示数据,要先发一串初始化命令。关键命令序列是:
| 命令字节 | 含义 |
|---|---|
| 0x21 | 进入扩展指令集 |
| 0xB8 | 设置VOP对比度,范围0x80到0xBF |
| 0x04 | 设置温度系数 |
| 0x14 | 设置偏置电压 |
| 0x20 | 退回标准指令集 |
| 0x0C | 正常显示模式,0x08全灭,0x09全亮 |
初始化完成之后,D/C拉高写入的每个字节都直接映射到显存。PCD8544的显存按列寻址,逻辑上分成6行,每行8个像素高,列坐标从0到83。写满84乘48的屏幕需要连续发送504个数据字节,发完自然换行排列。
2.3 从PDF原理图提取引脚连接,整理引脚约束表
拿到别人整理的资料时,第一步不是打开Verilog源码,而是打开PDF原理图对照开发板的引脚命名。小脚丫LCMXO2的板级引脚定义在Lattice Diamond里体现为芯片封装上的物理引脚名,比如某个信号在原理图网络标号叫LCD_DIN,对应到芯片的SITE就是类似P23这样的封装位置。
常见的接法是把LCD六根控制线分配到FPGA的普通IO上,按键分配到一个带独立网络名的IO上,时钟和复位也都单独引出。参考接法如下:
| Nokia5110信号 | FPGA网络名示例 | 约束写法示例 |
|---|---|---|
| RST | lcd_rst | LOCATE COMP "lcd_rst" SITE "P20"; |
| SCE | lcd_sce | LOCATE COMP "lcd_sce" SITE "P21"; |
| D/C | lcd_dc | LOCATE COMP "lcd_dc" SITE "P22"; |
| DIN | lcd_din | LOCATE COMP "lcd_din" SITE "P23"; |
| SCLK | lcd_sclk | LOCATE COMP "lcd_sclk" SITE "P24"; |
注意上表引脚只是示例,实际必须以自己手头开发板的PDF原理图为准。尤其要确认板上哪些引脚已经被LED、按键、扩展排针之外的器件占用,比如LCMXO2的JTAG引脚在下载时会被占用,如果屏幕信号恰好接到这些脚上,下载后可能出现屏幕花屏或者完全无响应的问题。
3. 数字时钟的计时Verilog:分频器、BCD计数器和按键消抖
计时逻辑是整个数字时钟的骨架。LCMXO2上要做到秒级准确,核心是把板载高频时钟降成1Hz脉冲,再让BCD计数器消费这些脉冲。这里三个模块缺一不可:分频器、BCD计数器、按键消抖。
3.1 参数化Verilog分频器:把12MHz变成1Hz
分频不能直接生成50%占空比的1Hz方波,那会浪费大量寄存器。更常见的做法是生成一个单周期高电平的tick信号,每一个tick代表一秒。后续计数器只在tick_1hz为高的那个时钟周期里做一次累加。
module clk_div #( parameter CLK_HZ = 12_000_000 )( input wire clk, input wire rst_n, output reg tick_1hz ); localparam CNT_MAX = CLK_HZ - 1; reg [23:0] counter; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin counter <= 'd0; tick_1hz <= 1'b0; end else if (counter == CNT_MAX) begin counter <= 'd0; tick_1hz <= 1'b1; // 只拉高一个时钟周期 end else begin counter <= counter + 1'b1; tick_1hz <= 1'b0; end end endmoduleCLK_HZ是个可综合的参数,如果板载晶振是25MHz就把参数改成25_000_000,模块本身不用动。CNT_MAX是分频上限,计数到CLK_HZ - 1回零并输出一个高电平脉冲,这样每秒钟正好产生一个tick_1hz。
这里的实现刻意让tick_1hz在一个时钟周期内拉高,而不是一直保持到下一秒。原因在于后面接的BCD计数器要把这个信号当作使能而不是时钟,使用always @(posedge tick_1hz)这种写法在多级计数器中会产生隐式多时钟域,时序收敛困难,也容易在综合时产生警告。
3.2 BCD计数器的进位链和verilog计数器实现
数字时钟显示用BCD格式最方便,因为每位数字正好对应四个二进制位,送到字库模块直接作为索引。秒和分都是0到59,时是0到23。下面给出秒计数器的核心逻辑,分和时的结构完全一致,只是进制不同。
module bcd_counter_60 ( input wire clk, input wire rst_n, input wire tick_in, input wire load_en, // 校时写入使能 input wire [7:0] load_value, // 校时写入值,高四位十位,低四位个位 output reg [3:0] unit, output reg [3:0] ten, output reg carry ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin unit <= 4'd0; ten <= 4'd0; carry <= 1'b0; end else if (load_en) begin unit <= load_value[3:0]; ten <= load_value[7:4]; carry <= 1'b0; end else if (tick_in) begin carry <= 1'b0; if (unit == 4'd9) begin unit <= 4'd0; if (ten == 4'd5) begin ten <= 4'd0; carry <= 1'b1; // 秒满60,向分钟进一位 end else begin ten <= ten + 1'b1; end end else begin unit <= unit + 1'b1; end end end endmoduleunit是个位BCD,ten是十位BCD。当计数到“59”的下一个tick到来时,unit从9回到0,ten从5回到0,同时拉高carry一个周期。这个carry接到分钟计数器的tick_in上,分钟计数器再产生小时进位,就形成了完整的HH:MM:SS链路。
小时计数器不需要这么完整,因为24进制只在ten==2且unit==3时归零,其余情况按普通BCD累加。设计时可以把小时单独写一个模块,也可以用一个额外的if-else分支处理。实际项目里我更愿意把小时模块单独例化,参数化MAX_VALUE,这样既能显示24小时制,也能改成12小时制显示PM/AM。
3.3 按键消抖与校时逻辑
板上按键按下去的时候,机械触点会产生几毫秒的抖动,不处理的话一次按键可能被识别成好几次。常见做法是在FPGA内部做边沿检测加延时计数:检测到按键变化后开始计数,持续稳定一段时间才认为有效。
module key_debounce #( parameter CLK_HZ = 12_000_000, parameter DELAY_MS = 10 )( input wire clk, input wire rst_n, input wire key_in, output reg key_press ); localparam DELAY_CNT = CLK_HZ * DELAY_MS / 1000; reg [19:0] cnt; reg key_sync, key_stable; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync <= 1'b1; key_stable <= 1'b1; key_press <= 1'b0; cnt <= 'd0; end else begin key_sync <= key_in; if (key_sync != key_stable) begin key_stable <= key_sync; cnt <= 'd0; end else if (key_stable == 1'b0 && cnt < DELAY_CNT) begin cnt <= cnt + 1'b1; if (cnt == DELAY_CNT - 1) key_press <= 1'b1; // 稳定低电平超过10ms,确认按下 end else begin key_press <= 1'b0; end end end endmodulekey_press只会拉高一个时钟周期,所以在校时的状态机里可以直接用这个信号触发“当前调节位加一”。实际校时最多需要三个按键:一个进入设置模式,一个切换时分秒,一个加一。要是板子上只有两个按键,可以把切换和加一合并成“短按切换、长按加一”,但逻辑复杂度会明显上升,建议第一版先保证三键方案跑通。
4. Nokia5110的Verilog驱动:三段式状态机、字库和显示布局
LCD驱动是另一个大头。PCD8544要求一次完整的8位传输期间SCE保持低电平,SCLK按模式0翻转,D/C在整个传输过程中保持稳定。用Verilog实现时最稳妥的写法还是三段式状态机,把时序完全拆开。
4.1 SPI发送状态机:用verilog三段式状态机写PCD8544时序
下面这个模块负责往PCD8544发送一个字节,sclk在空闲时为低,每个位先是din输出数据,然后sclk拉高让屏幕采样,再拉低准备下一位。
module spi_tx_8 ( input wire clk, input wire rst_n, input wire start, input wire [7:0] tx_data, output reg sclk, output reg din, output reg done ); localparam IDLE = 3'd0, START_BIT = 3'd1, SCLK_HIGH = 3'd2, STOP = 3'd3; reg [3:0] state, next_state; reg [2:0] bit_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end always @(*) begin next_state = state; case (state) IDLE: if (start) next_state = START_BIT; START_BIT: next_state = SCLK_HIGH; SCLK_HIGH: if (bit_cnt == 3'd7) next_state = STOP; else next_state = START_BIT; STOP: next_state = IDLE; default: next_state = IDLE; endcase end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sclk <= 1'b0; din <= 1'b0; done <= 1'b0; bit_cnt <= 3'd0; end else begin done <= 1'b0; case (state) IDLE: begin bit_cnt <= 3'd0; din <= 1'b0; end START_BIT: begin din <= tx_data[3'd7 - bit_cnt]; // 高位先发 sclk <= 1'b0; end SCLK_HIGH: begin sclk <= 1'b1; // 上升沿,从机采样 bit_cnt <= bit_cnt + 1'b1; end STOP: begin sclk <= 1'b0; done <= 1'b1; end endcase end end endmodulesclk在每个数据位稳定后拉高一个周期,严格满足PCD8544的上升沿采样要求。tx_data[3'd7 - bit_cnt]把最高位先发出去,符合MSB First约定。这个状态机本身不带分频,实际使用时外层驱动模块用一个更低速的使能信号周期性触发start,保证SCLK频率不超过4MHz就可以。
4.2 字库ROM:用case存储数字和冒号的点阵
Nokia5110的显存一个字节对应一列8个像素,显示字符时最常用的做法是5x8字体:每个字符占5列,列与列之间空一列用来产生间隔。下面用case实现一个简单字符索引,输入索引输出40位点阵数据。
function [39:0] get_font; input [3:0] idx; begin case (idx) 4'd0: get_font = 40'h3E5149453E; // 0 4'd1: get_font = 40'h00427F4000; // 1 4'd2: get_font = 40'h4261514946; // 2 4'd3: get_font = 40'h2141494B36; // 3 4'd4: get_font = 40'h1814127F10; // 4 4'd5: get_font = 40'h2749494931; // 5 4'd6: get_font = 40'h3E4949497A; // 6 4'd7: get_font = 40'h0101016163; // 7 4'd8: get_font = 40'h3649494936; // 8 4'd9: get_font = 40'h264949493E; // 9 4'd10: get_font = 40'h0000363600; // : default: get_font = 40'h0000000000; endcase end endfunction这组点阵数据是5x8的字形,每个40位数字的高8位表示最左边一列。实际显示时每写一个字符要发送6个字节,前5字节是字形数据,第6字节固定写0,作为字符间距。数字字符本身正好在ASCII里从0x30到0x39连续排列,BCD计数器的值加上0x30就是对应的字符索引,冒号单独定义成索引10,布局起来非常顺手。
4.3 显示布局:HH:MM:SS居中排布
84像素的宽度下,用5x8字体加1像素间距,每个字符占6列。显示“HH:MM:SS”需要8个字符,正好48列。屏幕总宽84列,起点设在18列就能水平居中。
第二行可以显示日期或者星期。比如“04-18 FRI”也是8个字符,放在Y=1的bank里。PCD8544的Y坐标取值范围是0到5,每个Y对应一整个8像素行,所以两行文字分别写在Y=0和Y=1,彼此不会重叠。
写字符前最好先清屏。清屏不需要单独命令,连续往X=0到83、Y=0到5的每个地址写0x00即可。实际工程里可以做一个lcd_set_pos小模块,每次写字符前先发送X和Y的命令字节,再连续发送6个数据字节,这样布局函数只需要维护字符坐标。
5. 在Lattice Diamond里过一遍综合、LDC引脚约束和片上Flash下载
Verilog写完之后就要进Lattice Diamond做综合和下载。LCMXO2的开发流程跟Xilinx Vivado或者Intel Quartus有相似之处,但几个关键操作完全不同,尤其是配置方式。
5.1 器件选择与LSE综合设置
新建工程时唯一必须选对的就是器件型号。小脚丫LCMXO2开发板常见的是LCMXO2-1200ZE系列,封装是TQFP100或者QFN48,具体型号以板卡丝印为准。Lattice Diamond的工程向导里,Synthesis Tool选择LSE,这是软件自带的综合器,对这个项目完全够用。没必要选Synplify Pro,那个综合器更适合大工程,跑这个小设计反而耗时。
点击综合后如果出现语法错误,绝大多数问题出在端口宽度不匹配或者reg和wire混用。比如tick_1hz在分频器里声明成output reg,在顶层连接时挂在wire上,综合器对这类写法很敏感,报错信息会直接指向具体行号,按提示修改即可。
5.2 用LDC文件约束引脚和时钟
Lattice Diamond的引脚约束文件是LDC格式,内容包括引脚位置、IO电平标准和时钟频率。数字时钟项目里所有LCD信号都是3.3V电平,IO约束统一写LVCMOS33。
LOCATE COMP "clk" SITE "P3"; LOCATE COMP "rst_n" SITE "P11"; LOCATE COMP "key_set" SITE "P14"; LOCATE COMP "key_add" SITE "P15"; LOCATE COMP "key_mode" SITE "P16"; LOCATE COMP "lcd_rst" SITE "P20"; LOCATE COMP "lcd_sce" SITE "P21"; LOCATE COMP "lcd_dc" SITE "P22"; LOCATE COMP "lcd_din" SITE "P23"; LOCATE COMP "lcd_sclk" SITE "P24"; IOBUF PORT "clk" IO_TYPE=LVCMOS33; IOBUF PORT "rst_n" IO_TYPE=LVCMOS33; IOBUF PORT "key_set" IO_TYPE=LVCMOS33; IOBUF PORT "key_add" IO_TYPE=LVCMOS33; IOBUF PORT "key_mode" IO_TYPE=LVCMOS33; IOBUF PORT "lcd_rst" IO_TYPE=LVCMOS33; IOBUF PORT "lcd_sce" IO_TYPE=LVCMOS33; IOBUF PORT "lcd_dc" IO_TYPE=LVCMOS33; IOBUF PORT "lcd_din" IO_TYPE=LVCMOS33; IOBUF PORT "lcd_sclk" IO_TYPE=LVCMOS33; FREQUENCY NET "clk" 12 MHz;所有SITE后的引脚号都是示意值,实际工程中需要对着PDF原理图逐个填入。LOCATE COMP必须写在顶层模块里出现过的端口名上,写错一个字符综合也会通过,但布局布线后会报错。FREQUENCY NET那一行虽然不加也能跑,但加了以后软件会知道时钟约束,后续时序报告才有效。
5.3 Download到片上Flash,让时钟上电自启动
下载这一步是LCMXO2最容易被忽视的优势。打开Lattice Diamond的Programmer工具,识别到下载器之后,Operation选项通常可以选SRAM Programming和FLASH Programming。SRAM模式下载快但掉电丢失,只适合调试。FLASH模式把配置写入芯片内部Flash,掉电之后再上电,FPGA直接从内部加载程序,不需要额外配置芯片。
下载过程中如果提示找不到器件,先检查下载线是否插紧,再确认Programmer中选中的扫描链设备编号。综合前用示波器确认晶振有输出也很重要,很多“下载失败”问题其实出在目标板根本没起振。
| Programmer选项 | 适用场景 | 掉电后是否保留 |
|---|---|---|
| SRAM Programming | 临时调试 | 否 |
| FLASH Programming | 正式烧录 | 是 |
6. 把数字时钟走准:误差补偿、对比度调整和验证技巧
分频器写对了,时钟能走,但不一定走得准。板载晶振的实际频率跟标称值总有偏差,误差累积一天可能差出几秒到十几秒。
常用校准方法是实测日误差然后修正常数。记录一天下来时钟快了N秒,说明晶振实际频率比标称值高大约N/86400,把分频器里CLK_HZ的常数按比例调大即可。比如12MHz晶振每天快2秒,新的分频计数目标大约是12000000 * 86400 / (86400 + 2),取整后写成参数。这是最简单也最有效的校正方式,比在计数器里加跳秒逻辑更直接。
Nokia5110的显示对比度也是常调参数。初始化命令里的0xB8直接控制VOP电压,数值越大对比度越高。不同批次屏幕差异很大,有的0xB8就清楚,有的要0xBC才够深。调对比度时把命令字节改成0x80到0xBF之间的值,单独烧录测试,确认合适后再固定写入源码。
最后验证SPI时序时,示波器大概是最快的工具。先把SCLK和DIN同时接到示波器上,触发沿设在SCLK上升沿,上电后应能看到初始化命令序列,然后是连续不断的数据字节。如果DIN始终为低,先检查start信号有没有周期性拉高;如果DIN有数据但屏幕仍然白屏,多半是RST或者SCE的极性反了,回原理图确认那两个信号是否接在FPGA的普通IO上,而不是被板上其他外设占用。
本文还有配套的精品资源,点击获取