写这个系列之前,我一直觉得FPGA搞显示是很劝退的一件事。数字逻辑写到能跑UART、能收发字符串,已经算入门了,结果一看需求:要把采集到的电压、温度、状态帧值显示在屏幕上,还得有几个按钮能切页面——很多人在这一步直接转投ARM了。直到我把USART_HMI串口屏引入项目,才发现FPGA做交互界面也可以这么省心。这篇是FPGA串口收发字符串系列的第四篇,专门把串口屏USART_HMI这块讲透,包括协议帧格式、FPGA侧指令发送模块怎么写、联调时会遇到哪些坑,以及后续可以往双向交互延伸的方向。手里已经跑通UART收发模块的朋友,这一章可以直接落地;就算前面几篇没读,只要懂串口字节怎么发,也能跟上。
1. 为什么FPGA项目里我会选串口屏来显示
1.1 显示方案的选型对比
很多FPGA工程师第一次面对"要给项目加显示"这个问题时,列出的候选方案大概跟我当年差不多:数码管、LCD1602/COG12864、SPI或者RGB接口的TFT屏,最后才轮到串口屏。我把实际对比结果放在一张表里,大家感受一下差异。
| 方案 | 显示能力 | 人机交互 | 开发工作量 | FPGA资源占用 | 适合场景 |
|---|---|---|---|---|---|
| 数码管 | 数字和少量字母,内容有限 | 基本无 | 低,动态扫描即可 | 低 | 电压、计数、状态指示 |
| LCD1602/COG12864 | 字符或简单图形,需自己取模 | 无或按键扩展 | 中,初始化时序和字库都要处理 | 中 | 简单文本显示 |
| SPI/RGB接口TFT | 色彩丰富,像素级控制 | 需要自己写触摸和GUI | 高,显存、字库、GUI框架全自己做 | 高,RGB屏还得外挂SRAM/SDRAM | 对显示刷新速度要求极高的场合 |
| USART_HMI串口屏 | 彩色显示,文字、图形、曲线、图片均可 | 自带触摸,事件上报 | 低,上位机做界面,FPGA只发指令 | 极低,占两个IO和一个UART | 设备状态显示、参数面板、人机交互 |
这里不是说数码管和TFT不好,而是工程上有性价比的考量。数码管适合那种"看一眼就够"的数据;TFT屏适合需要高频刷新画面的场景,但代价是FPGA里要维护一整套路显存和绘图库,开发周期以周为单位。串口屏把这块全包了,它内部有一颗主控芯片,专门负责解析指令、刷新LCD、扫描触摸,FPGA要做的只是按协议把字符串或者数值帧发过去。
1.2 USART_HMI解决的核心问题
用一句话概括串口屏的本质:它不是一块普通显示屏,而是一个带GUI引擎的UART从设备。你在上位机软件里画好界面,拖好文本控件、数字控件、按钮,分配好变量地址,烧录到屏幕里,然后FPGA这边只需要通过串口告诉它"把变量0x0001的内容改成多少多少",屏幕上对应的控件就会自动刷新。
这和传统TFT方案完全是两种思维方式。以前是FPGA逐像素去画,现在是FPGA只发命令,画面怎么渲染、字体怎么显示、按钮怎么响应,都是串口屏自己去处理。就像一个是自己调油漆刷墙,另一个是打电话让装修队干活,活一样能干完,但工作量根本不是一个量级。
我用串口屏之后最直观的感受是:FPGA侧的逻辑可以专注在信号采集、算法处理、状态控制这些真正核心的事情上,界面交互只是偶尔发一串字节的事。开发周期从几周缩短到一两天,而且改界面布局不用重新综合FPGA工程,只改串口屏上位机的工程再烧录就行,这在项目调试阶段非常救命。
1.3 什么场景不适合串口屏
串口屏不是银弹,它的短板也很明显,我这里把丑话说在前面。首先是刷新速度:以115200波特率为例,理论上一秒只能传约11520字节,一条简单的写变量指令大概10字节左右,也就是说一秒最多发一千多次,但实际考虑帧间隙和串口屏内部处理时间,稳定跑到几十赫兹就到头了。所以它适合展示状态数据和参数,不适合做高速波形输出之类需要像素级实时更新的界面。
其次是成本,串口屏的单价明显高于同等尺寸的裸屏加驱动方案,如果产品已经确定要大规模量产,而且显示内容极其固定,那还是老老实实自己驱动屏幕更划算。最后还有一点容易忽略:串口屏是带固件的黑盒,一旦遇到官方固件不支持的协议细节,你没法绕过它直接操作像素,灵活度确实比不上自己写驱动。理解了这个边界,你才知道什么时候该用它,什么时候不该用。
2. USART_HMI协议先看懂再动手
2.1 指令帧的基本结构
串口屏虽然界面华丽,但通信协议其实相当简洁,核心就是一帧一指令。USART_HMI的指令帧通常由帧头、数据长度、指令码、数据区和校验字段组成,大致是这个样子:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 1字节 | 固定值0xA5,表示一帧的开始 |
| 数据长度 | 1字节 | 从指令码到校验之前的字节数,用于接收端分帧 |
| 指令码 | 1字节 | 标识这条指令要做什么,比如写变量、读变量、跳页面 |
| 数据区 | N字节 | 变量地址、数据类型、具体的数据内容 |
| 校验 | 1字节 | 通常是对前面数据区做异或和,用于验证帧没有传错 |
做FPGA的人看到这种结构应该很亲切,它跟我们的UART状态机是天然匹配的。接收端先等帧头0xA5,收到之后再读数据长度,然后按长度把剩下的字节收满,最后做一遍校验,帧就完整还原了。这也是为什么串口屏特别适合跟FPGA配合——协议本身就对硬件逻辑友好,不需要PC上那种复杂的缓冲解析。
不同品牌和固件版本在字段定义细节上有差异,比如淘晶驰TJC系列、Nextion系列和迪文DGUS系列就是三套不同的体系,甚至同一品牌不同批次固件对长度字段的计数方式都可能不一样。我在项目里固定用淘晶驰的USART_HMI协议,本文也以它为例。动手之前一定要把自己手上屏的协议手册打印出来对照,别拿网上例程的帧直接抄。
2.2 最常用的几条指令
用串口屏控制界面,绝大多数时间就三条指令:写变量、读变量、跳页面。所谓"变量",就是你在上位机里给每个控件绑定的数据通道,比如文本控件t0绑定了变量v0,数字控件n0绑定了变量n0,这些变量在屏的内部存储里都有固定地址,FPGA只要往对应地址写数据,控件就会刷新。
写变量指令的语义是"往地址X写入数据Y"。数据Y可以是字符串,也可以是二进制数值,具体是哪一种由数据类型字段区分。举个例子,如果要在文本控件里显示"OK"两个字,指令帧的数据区就包含控件地址、字符串类型标识、以及字符O和K的ASCII码。FPGA要做的事情并不复杂,地址、类型、ASCII码都是确定的,算好长度和校验,把整帧发出去即可。
读变量指令是反过来,FPGA向屏请求读取某地址的数据,屏收到后会把当前值回传。这条指令在做双向交互时很有用,比如FPGA上电后要读取屏上保存的参数。跳页面指令就更直白了,告诉屏切换到某个页面ID,适合做多菜单、多界面切换。
在FPGA里设计发送模块的时候,我建议把这些指令统一封装成一个"指令序列器",每条指令是一个固定模板,需要变化的只是地址和数据区。模板加上动态参数,拼成一帧,再送到UART模块发出去。这样代码结构清晰,后面要加新指令只是加一个模板的事。
2.3 控件变量地址与数据类型的坑
用串口屏上位机拖控件的时候,很多人会忽略"地址分配"这件事。上位机软件的变量控件列表里,每个变量都有一个地址,一般是按创建顺序从0x0000开始编号。FPGA侧写指令时用的就是这些地址。如果你只记得控件名字不记得地址,那写指令时就会对不上。我一般会在上位机里把变量名和地址做成一张对照表,和FPGA代码放同一个工程目录下,改一次同步一次,不然调试时极容易晕。
数据类型字段是另一个容易出岔子的地方。字符串和数值的编码方式不一样:字符串直接用ASCII码字节流,数值可以用二进制直接传输,也可以转成ASCII形式的数字字符。串口屏接收不同数据类型时解析逻辑完全不同,一旦类型标识传错,屏上显示的就是乱码或者0。这个字段在上位机协议文档里有明确的取值表,写代码前要确认好。
还有一个细节容易被新手忽略:字符串长度和控件显示区域的关系。文本控件如果设置了最多显示4个字符,你一帧发8个字符进去,要么被截断,要么把旁边内容挤乱。所以FPGA侧发送字符串之前,要么自己做好截断,要么在上位机里把控件长度放宽。我自己写模块时会在指令序列器里加一个最大长度寄存器,配合控件属性一起设置,避免指令本身没问题但显示效果稀碎。
3. FPGA端串口指令发送模块怎么设计
3.1 模块划分思路
FPGA端驱动串口屏,不需要一个"万能协议栈",那反而把事情搞复杂了。我习惯把逻辑拆成三块:指令序列器、字节发送器、参数生成逻辑。
字节发送器说白了就是系列前几章写的UART TX模块,负责把一个字节变成串行波形发出去,带busy或者done信号。参数生成逻辑负责算地址、算长度、做校验,比如数值转ASCII、字符串拼接。指令序列器是大脑,负责按顺序把一帧里的字节逐个送给字节发送器,中间控制节奏,保证帧内字节连续、帧间留出间隔。
这个划分的好处是每一块都能单独测试。UART TX单独连电脑验证过;参数生成逻辑可以用Modelsim仿真对比输出字节流;指令序列器只要状态机不跑飞,整帧顺序就不会乱。等到最后联调时,问题范围能快速锁定到某一小块,不会满工程找bug。
3.2 状态机加FIFO的设计方式
指令序列器我一律采用"先组装到发送FIFO,再自动发送"的方式,而不是一字节一状态地硬憋。为什么?因为指令帧拼装过程中可能要插入动态参数,如果整个帧的字节都是一个时钟一个时钟地直接塞给UART,中间一旦被更高优先级的逻辑打断,帧就残了。用FIFO缓冲一层,组装过程可以分步完成,组装完毕后UART模块从FIFO里连续取字节发送,帧的原子性就有保证。
核心状态机示意代码大致是这样:
// 指令序列器核心状态机(节选) localparam IDLE = 3'd0; localparam ASSEMBLE = 3'd1; localparam WAIT_EMPTY = 3'd2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; end else begin case (state) IDLE: if (tx_start) begin byte_cnt <= 0; state <= ASSEMBLE; end ASSEMBLE: begin // 每个时钟写一个字节到FIFO // 先写帧头0xA5,再写长度、指令码、数据区、校验 fifo_wr_en <= 1'b1; fifo_wr_data <= frame_byte_gen(byte_cnt); if (byte_cnt == frame_total_len - 1) begin fifo_wr_en <= 1'b0; state <= WAIT_EMPTY; end else begin byte_cnt <= byte_cnt + 1'b1; end end WAIT_EMPTY: if (fifo_empty) begin // 整帧发送完成 tx_done <= 1'b1; state <= IDLE; end endcase end end实际工程里frame_byte_gen不会是一个简单函数,而是根据byte_cnt查表:0号字节固定是0xA5,1号字节是长度,2号字节是指令码,后面的字节可能是变量地址、数据类型、数据内容,最后是校验字节。动态参数部分在上层逻辑触发tx_start之前就预先算好,放在参数寄存器里,组装阶段只是把它们按顺序搬进FIFO。
UART模块那边从FIFO读字节、逐字节发送,发送完成信号把FIFO读指针推进。这个方案的妙处在于,FIFO天然隔离了两个时钟域(如果有的话)和两级逻辑,组装过程不占用UART发送时间,帧与帧之间还能方便地插入任意延时。
3.3 长度和校验的计算时机
长度和校验是FPGA工程师最容易写错的地方。长度字段指的是从这个字段自身后面开始、直到数据区结束的字节数。校验字段通常是对长度、指令码和数据区做异或,帧头0xA5不参与校验。这两者的计算必须放在组装阶段的开头,算出结果存进寄存器,随帧一起写入FIFO,而不是在UART发送过程中临时去算。
为什么强调时机?因为很多新手会在发送每一字节的组合逻辑里去动态计算校验,结果综合出来的电路出现奇怪的时序问题,帧内容对了但校验总不对。我现在的做法是:先算后发。帧长如果固定,长度字段就直接是一个常量;校验则在PREPARE阶段用组合逻辑异或树算好,或者更省事——因为一帧字节数不多,完全可以在写入FIFO的时候同步计算校验,最后一个字节写进去时校验值也已经算出来,直接跟着写进去。
这里还有个工程上的建议:第一版尽量把所有指令设计成定长帧。定长帧的长度字段是常量,地址偏移是常量,校验树也可以预先确定结构,调试难度会低很多。等定长帧跑通了,再改成变长帧,处理动态字符串就会从容很多。
4. FPGA变量如何变成屏幕上的数字和字符串
4.1 数值转ASCII的处理
FPGA内部处理的数据是二进制数,但串口屏要显示的是人类能读的十进制数字。所以中间必须有一个数值转ASCII的过程。最基础的方法是把二进制数转成BCD码,然后把每一位BCD码加上0x30就得到对应数字的ASCII码。比如二进制数27,转成BCD码是0010 0111,即十位2、个位7,加上0x30后得到0x32和0x37,对应字符'2'和'7'。
二进制转BCD常用的是加3移位算法,也叫移位加3法,一段循环逻辑就能实现,不需要除法器,非常适合FPGA。对于0到9999范围的数值,移位16次就可以完成转换,占用资源很少。如果你要显示的是有符号数,还得先处理符号位,在前面加一个负号0x2D。
我看到有些人在FPGA里直接用取模和除法运算符去转ASCII,仿真没问题,但综合出的除法器吃资源比较多,而且路径延迟大。在小规模显示应用里,加3移位法既省资源又容易控制时序,是我比较推荐的方案。
4.2 字符串指令帧的拼接实例
举个实际例子,假设要把温度值27度显示在屏上的文本控件,控件地址是0x0001。FPGA侧要发出的指令帧大致是这样的:先帧头0xA5,然后是长度、写变量指令码、地址高字节、地址低字节、数据类型标识,接着是'2'和'7'两个ASCII字节,最后是校验。整个过程在指令序列器里就是按byte_cnt依次输出这些字节。
数值转ASCII的结果怎么和固定帧头拼接呢?我一般用一个可变长度移位寄存器组方式:把固定部分、动态部分、校验部分分别存到三个小寄存器区,组装时按顺序选通。如果帧长固定,动态部分也只是长度固定,那整个帧甚至可以定义成一个寄存器数组,ASSEMBLE状态里按地址查表输出即可。
调试的时候有一个很实用的技巧:在UART TX的输出引脚上接逻辑分析仪,或者直接在FPGA里把那路信号引到一个空闲引脚,用示波器看波形。整帧字节的时序波形一目了然,哪里的字节不对,对照协议文档一比对就能定位到是拼接逻辑还是数值转换的问题。
4.3 显示刷新节奏与带宽估算
串口屏能接受的显示刷新速度没有你想象的那么高。我实测下来,状态类数据10到20Hz刷新就已经非常平滑了,没必要每毫秒无脑刷。原因有两个:一是串口带宽有限,115200波特率一秒钟才能传一万多字节,一条指令就是十几字节,刷新频率越高,留给其他指令的带宽就越少;二是串口屏内部解析帧、刷新LCD也需要时间,指令过来太密会出现排队甚至丢帧。
所以我一般在FPGA里给串口屏发送加一个"节流器",比如用一个计数器产生10ms或50ms的周期脉冲,每个脉冲最多触发一帧显示指令。这样既保证了人眼看起来连续,又不会把串口屏和串口带宽压到极限。我在做多通道数据采集显示时,用5ms周期更新一个页面,同时还要处理触摸事件上报,没有出现过串口拥堵的问题。记住一个原则:显示类的数据,宁可少刷,不要暴刷。
5. 联调实录:这几个坑我替你们踩过了
5.1 波特率误差带来的隐性故障
FPGA和串口屏之间的波特率如果对不上,表现不是完全不通,而是"时好时坏",尤其当误差积累到采样的临界点时会随机出乱码。这个坑隐蔽性很高,因为它不稳定复现,很容易让人怀疑是屏坏了或者代码逻辑错了。
正确的做法是先用频率计确认FPGA系统的基准时钟频率,再算波特率分频值。比如50MHz系统时钟跑115200波特率,分频值是50_000_000/115200,约等于434,取整后误差不到0.01%,基本可以忽略。但如果你的板子用的是24MHz或者12MHz晶振,分频后的误差就可能超过1%,这时候就要考虑换一个更合适的波特率,或者用小数分频器来处理。
我的习惯是FPGA侧和串口屏都固定用115200,这几乎是两者之间最稳的一个平衡点。9600虽然更抗干扰,但显示刷新有明显的"打字机感",不太适合做界面。另外要注意串口屏模块的默认波特率可能和它实际跑的波特率不一致,收到屏之后第一次通电就要用串口助手确认屏的配置。
5.2 帧被切断和指令被吞的问题
联调时最常遇到的现象是:屏上偶尔显示一次正确内容,然后连续发好几条指令都没反应,或者显示出来的是残缺数据。排查下来的典型原因,是FPGA侧的指令序列器在多帧连续发送时,帧间隙控制不好,或者处理器逻辑在发送过程中抢占了串口发送的优先级,导致帧头后面的数据没跟上。
解决思路就是我前面说的FIFO缓冲方案,让整帧组装成一个不可分割的整体。发送期间绝不允许其他逻辑往UART模块里插入字节,必须等当前帧的最后一个字节发完、FIFO清空之后,才允许下一帧开始组装。这个原子性设计不是可选项,而是必须项。
另外帧与帧之间要留出足够的间隔,让串口屏来得及解析上一帧。我实测淘晶驰系列的屏处理一帧普通指令大概需要几毫秒到十几毫秒不等,取决于指令类型。所以我在指令序列器里配置了一个帧间隔计数器,一般取5ms到10ms,如果连续快速切换页面时发现偶发不响应,就把这个间隔再拉大一些。
5.3 上电时序和初始化等待
FPGA和串口屏有一个很容易被忽略的时序关系:上电瞬间谁先就绪。串口屏内部有操作系统和GUI引擎,上电后需要几百毫秒甚至更长时间初始化,如果FPGA在这个窗口期就往外发指令,屏根本收不到。我见过最头疼的问题就是,FPGA启动后立刻发初始化指令配置界面,结果指令被吞,界面停留在默认状态,但单独用串口助手手动发指令又一切正常。
解决方案很简单也很粗暴:FPGA的串口屏初始化流程必须在一个延时后才开始。我在顶层状态机里加了一个上电等待计数器,一般取500ms到1秒。先让串口屏初始化完,再发第一条写变量或跳页面指令。如果有条件,更稳妥的做法是先用串口助手上电观察屏的返回信息,确认屏完全就绪后再让FPGA发指令。
如果做出来的产品还有"休眠唤醒"这类需求,那上电时序就更要谨慎了。串口屏进入休眠后不代表串口还能照常收数据,唤醒时序也得按手册来,不能默认它一直在线。
5.4 先用串口助手验证,再上FPGA
这个经验我每次带人都要强调一遍:不要一上来就拿FPGA和串口屏对调,先用USB转串口模块接电脑,用串口调试助手手工发指令,确认屏的界面和协议没问题。这个操作能帮你把问题分成两个阶段,非常省时间。
具体操作流程是:第一步,用串口助手连接屏幕,把上位机生成的指令示例逐条发过去,观察屏幕反应,确认地址、长度、校验都对;第二步,把电脑断开,把FPGA的UART TX引脚接到串口屏的RX引脚,先用FPGA发一条最简单的指令,用逻辑分析仪抓UART TX的波形,和串口助手里发过的指令字节流做对比;第三步,确认波形一致后,再让FPGA跑完整逻辑。
这三步看起来多花了一点时间,但实际上能省掉大量无头绪的排查。我遇到过很多人把问题定位在FPGA侧,结果是串口屏上位机工程里控件地址没分配对;也有人怀疑屏坏了,结果根本原因是FPGA的引脚电平不匹配。先验证屏自身没问题,是从源头排除故障面的好习惯。
6. 从这一章还能往哪走:双向交互与进阶玩法
6.1 触摸事件上报与FPGA接收解析
串口屏不光是显示设备,它还是一个完整的人机交互终端。当你用手指按下屏上的按钮时,串口屏会主动向主机发送一条事件上报帧,里面通常包含页面ID、控件ID和事件类型。这就意味着FPGA不光要发,还要收。
接收侧的核心也是那个熟悉的套路:状态机检测帧头0xA5,然后读长度字段,按长度收完整帧,做校验。解析到是一个触摸事件后,根据控件ID跳转到对应的处理逻辑。比如一个"启动/停止"按钮,FPGA接收到按下事件后,置位或清除一个控制寄存器,进而控制外设状态。
我建议接收和发送共用同一个FIFO缓冲思路,只不过方向反过来。串口屏发来的字节流被UART RX模块接收后先写进接收FIFO,FPGA主逻辑空闲时再去解析。这样即使一帧事件在接收过程中被其他逻辑打断,也不会丢字节。等这一块跑通,你的FPGA系统就真正有了"面板操作"的能力,而不只是单方向显示。
6.2 页面切换、曲线显示与后续扩展
USART_HMI协议里还有很多值得玩的功能。页面切换指令可以让你的FPGA设备实现菜单层级跳转,按下"设置"进入设置页,按下"返回"回到主页面。曲线控件可以把采集到的连续数据流动态画出来,虽然刷新率不如直接驱动屏幕高,但做温湿度历史曲线这类低频波形完全够用。图片推送功能甚至可以把FPGA采集的图像数据一帧一帧推到串口屏上显示,配合SD卡存储,可以做一个很轻量的数据记录仪。
如果项目到了一定规模,我建议把串口屏通信封装成一个独立模块,对外只留"写字符串变量""写数值变量""注册触摸回调"这几个接口。上层逻辑永远不直接拼帧,所有协议细节都隔离在模块内部。这样以后换屏幕品牌、升级协议版本,只需要替换这一个模块,顶层控制逻辑一行都不用改。
我现在项目里的分工基本是:FPGA专心做信号采集和算法处理,串口屏专心做人机界面,两者之间只有一组UART连接。这种把“界面”从“逻辑”里剥离开的思路,其实是FPGA工程成熟之后很自然的演进方向。串口收发字符串本身只是基本功,接上一个像USART_HMI串口屏这样真正能出活的终端,它才变成生产力。这大概是我写这个系列到现在最想留给你的东西。