
搞FPGA的兄弟基本都绕不过串口这个坎。前几篇我们把手写uart_tx、uart_rx、字符串拆帧过了一遍数据能发也能收了但新的问题马上冒出来收到的一堆数字和字符串怎么看总不能每次调试都插一根USB线开个串口助手盯着十六进制发呆。后来我试了一圈方案最后被串口屏救了命——尤其是USART_HMI这类“发文本指令就能显示”的屏幕在调试FPGA状态、显示传感器数值、做小型人机界面的时候体验相当舒服。串口屏解决的核心问题很简单FPGA擅长老老实实按节拍跑时序但不擅长做GUI、存大图片、渲染字体串口屏正好把这些活儿全包了你只需要像给老朋友发短信一样把一串字符从UART发出去屏幕自己就会把界面画出来。所以这篇适合已经能独立写完uart发送模块、正想给板子加显示功能的FPGA学习者也适合做测试工装、数控面板这类“显示信息不复杂但求开发快”的工程师。接下来我会从“为什么选择串口屏”开始把USART_HMI的协议、FPGA端状态机设计、常见坑一次讲透。1. 为什么FPGA项目要用串口屏1.1 FPGA显示的几种常见路子先说结论没有一种显示方案是万能的串口屏只是“开发效率和功能丰富度”之间平衡得比较好的那个。对FPGA工程师来说能选的显示方案大致有四类数码管。成本最低动态扫描写起来也顺手显示几个温度、频率、计数值完全够用。但缺点很明显只能显示数字和少量自定义字符想显示“hello”这种字符串都得翻字模显示中文基本没戏。调试时想看一个变量名数码管只会给你一串数字看了还得自己对应。点阵屏。8x8、16x16甚至32x32拼接起来可以显示汉字和简单图形但驱动起来是纯体力活取模、行扫、列扫、帧缓冲全得自己写逻辑。做一块能看的东西不难做一套能用的界面极难。直接驱动TFT屏。比如ILI9341这类经典控制器SPI或8080并口接起来驱动能力足够。但问题在于一张界面上要显示几个不同字体、不同颜色的文字你得自己管理字库、坐标、图层图片资源还得想办法塞进Flash。这些活放在单片机上已经很累放在FPGA上更是灾难。串口屏。屏幕自带一个微控制器和完整的UI运行时用户用上位机软件把界面画好、控件排好、字库烧好然后MCU/FPGA只需要通过UART发指令。串口屏把“图形界面开发”这件事整体抽离出去了对FPGA侧的要求压缩到只剩一个词发对字符串。1.2 串口屏的原理一个带“脑子”的屏幕串口屏模块长什么样正面是一块液晶屏背面通常焊了一颗MCU也有一些用专用显示芯片。这颗MCU承担了所有脏活累活屏幕刷新、字体渲染、触摸扫描、控件状态维护甚至内部脚本逻辑。用户拿到电路后第一步是在PC端组态软件里画界面拖几个文本控件、数值控件、按钮控件设置好坐标和属性第二步编译生成一个固件文件通过SD卡或者串口烧到屏内Flash第三步屏幕自己运行起来你在外部只需要通过串口发指令控制控件的属性。对FPGA来说串口屏是一个“只认串口指令的从机设备”。你不需要关心分辨率、色深、字库、图层叠加唯一需要操心的就是把指令字节按顺序、按波特率发出去。我经常把这几种方案比作吃饭直接控制TFT是买菜、洗菜、切菜、开火、调味全自己来好是好但一顿饭能折腾两小时串口屏就是点外卖你只需要把菜名报给厨房等着出锅就行。FPGA这种“工科直男型”选手做UI这种精细活不擅长点外卖反而最合适。2. USART_HMI的指令协议先学会和屏幕说话2.1 一条指令长什么样USART_HMI虽然叫“HMI”但从通信角度看它就是一个标准的异步串口从机。你给它发一串ASCII字符它解析后执行操作它给你发一串数据代表触摸事件、页面切换事件或者某个控件数值变化。先看FPGA向屏幕发指令的经典格式指令内容 结束符指令内容是一串可见ASCII字符比如修改page0页面上名为txt的文本控件内容指令就是page0.txthello[FF FF FF]这里[FF FF FF]就是结束符物理层上发送三个0xFF字节。整条指令在串口线上的实际字节流是70 61 67 65 30 2E 74 78 74 3D 22 68 65 6C 6C 6F 22 FF FF FF可以数一下正好19个字节。前16个字节是可见ASCII后3个是0xFF结束符。为什么用三个0xFF做结束符原因很简单0xFF在ASCII表里不是可打印字符正常字符串内容里几乎不会连续出现三个0xFF。如果只用一个0xFF当串口线有噪声、或者某一帧数据对不齐时很容易把半个指令误判为一条完整指令。三个0xFF形成了一种廉价但实用的“帧边界”这也是USART_HMI协议很经典的一个设计。需要注意新的屏幕固件和组态软件通常允许自定义帧头帧尾甚至可以用固定帧头CRC校验。但默认的“3个0xFF结束”是最通用的方式我建议刚开始接触时先用这个最稳妥的配置跑通了再研究高级模式。2.2 控件与属性把“发字符串”变成“改界面”USART_HMI的指令核心语法是控件名.属性值这里“控件名”是你在组态软件里给每个控件起的名字。比如我在页面上放了一个文本控件命名为txt那么修改它的文本就是txt.txthello[FF FF FF]命令里的第一个txt是控件名第二个txt是控件属性文本内容整体意思就是“把txt控件的文本内容设置为hello”。如果页面里有一个数值控件叫val那么给它赋值为123就是val.val123[FF FF FF]这种语法和单片机里的“赋值语句”非常像所以搞FPGA的人学起来很快。实际操作中有几个属性特别常用文本类控件修改显示字符串对应属性一般是txt。数值类控件修改数值显示对应属性一般是val。颜色类控件修改背景色、文字颜色属性一般是pco、bco、tco。页面控件切换页面对应指令通常是page page1。这里要专门提醒一下“发字符串”和“发数值”的区别。数值控件本质上内部有一个变量你写一个数字进去屏幕把数字渲染成字符显示出来文本控件则完全接收一串字节按字库映射显示。前者对FPGA来说发送的是“数字转成的十进制ASCII字符串”后者发送的是“原始字符串内容”。无论哪种FPGA端都要做字符串拼接和ASCII编码工作不能直接把二进制值丢给串口屏否则屏幕会把二进制当成ASCII码来解析显示出来的全是乱码。2.3 字符串长度与存储空间指令本身也是字符串如果指令内容里包含双引号、反斜杠这些特殊字符处理起来会有点绕。比如我想让文本控件显示他说你好在屏端协议里通常需要对双引号做转义具体转义规则不同固件版本略有差异务必以你手里那个型号的指令手册为准。另一个让我踩过坑的是“字符串长度上限”。串口屏内部给每个文本控件分配的显示缓冲区是有限的常见的有32字节、64字节、128字节。如果FPGA发送的字符串超过缓冲区长度屏幕通常不是报错而是直接截断显示你会看到界面上一半有字一半是空的。所以我一般会在工程里定义一个MAX_STR_LEN参数在拼接指令时就做长度检查避免发出去一条超长指令屏幕上什么都不出现你还以为是时序问题。3. FPGA串口发送字符串的状态机设计3.1 从“发一个字节”到“发一串字节”前面几篇我们写过uart_tx模块功能是给它一个8位数据和起始脉冲它按设定波特率把这一帧数据发出去发送期间tx_busy信号拉高。现在串口屏需要的是“发一串字节”如果我们只是简单地在外部循环触发uart_tx很容易出问题上一字节还没发完你又把新数据写到数据线上就会丢字符、窜位。所以这里的关键是设计一个字符串发送器它自己维护一个指针按照串口屏指令的格式从ROM或RAM中取出一个个字节喂给底层的uart_tx并且每一次都要等tx_busy拉低之后才取下一个字节。这里可以用一个很形象的说法底层uart_tx是“单线程的邮差”每次只能送一封信字符串发送器是“调度员”它把一串信按顺序排好等邮差送完一封再递下一封送完主体内容后还要记得补上三封写满FF的“信封封口”。3.2 状态机实现一个可直接参考的例子下面给一个简化的字符串发送器verilog代码重点看状态机的编排思路module str_sender( input wire clk, input wire rst_n, input wire start, // 高脉冲触发一次发送 input wire [1:0] cmd_id, // 选择预存的指令模板 input wire [7:0] value, // 要插入的动态ASCII字符可选 output reg tx, output reg busy // 发送忙指示 ); parameter IDLE 3d0; parameter LOAD 3d1; parameter WAIT 3d2; parameter NEXT 3d3; parameter TAIL 3d4; reg [2:0] state; reg [9:0] rom_addr; reg [7:0] rom_data; reg [7:0] cnt; reg [2:0] tail_cnt; reg [7:0] tx_data; reg uart_wr; wire uart_busy; // 底层uart发送模块前一篇已经讲过 uart_tx u_tx( .clk(clk), .rst_n(rst_n), .data(tx_data), .wr(uart_wr), .tx(tx), .tx_busy(uart_busy) ); // 指令ROM实际工程中建议用$readmemh从文件加载 reg [7:0] cmd_rom [0:31]; initial begin // 假设cmd_id0: page0.txt cmd_rom[0] p; cmd_rom[1] a; cmd_rom[2] g; cmd_rom[3] e; cmd_rom[4] 0; cmd_rom[5] .; cmd_rom[6] t; cmd_rom[7] x; cmd_rom[8] t; cmd_rom[9] ; cmd_rom[10] \; end // 固定长度这里默认10字节实际工程可以扩展 parameter CMD_LEN 8d10; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; uart_wr 1b0; busy 1b0; end else begin uart_wr 1b0; case (state) IDLE: begin busy 1b0; if (start) begin busy 1b1; rom_addr (cmd_id 2d0) ? 10d0 : 10d16; cnt 8d0; tail_cnt 3d0; state LOAD; end end LOAD: begin rom_data cmd_rom[rom_addr]; rom_addr rom_addr 1b1; state WAIT; end WAIT: begin if (!uart_busy) begin tx_data rom_data; uart_wr 1b1; // 触发底层发送 state NEXT; end end NEXT: begin if (cnt CMD_LEN - 1) begin // 指令主体部分结束进入结束符阶段 if (tail_cnt 3) begin busy 1b0; state IDLE; end else begin tail_cnt tail_cnt 1b1; tx_data 8hFF; uart_wr 1b1; // 发送一个0xFF state WAIT; end end else begin cnt cnt 1b1; state LOAD; end end default: state IDLE; endcase end end endmodule这个代码是一个典型的简化版但状态机的骨架是完整的。我来说几个实现时的关键点LOAD状态只做一件事情从ROM里取数。它不直接触发发送而是进入WAIT等uart_busy拉低之后才真正把数据给底层。这避免了新数据覆盖还没发完的旧数据。NEXT状态决定下一步是继续取下一个字节、还是进入结束符发送。结束符这里是3个0xFF用一个计数器控制。busy信号在上层很关键。比如你每500ms刷新一次屏幕刷新逻辑想启动发送前必须检查busy如果还忙着就跳过本次刷新或者等下一个周期再试。不能直接不管不顾地拉start信号否则状态机可能被重复触发指令被切断。3.3 动态字符串怎么拼数值转ASCII如果只发固定字符串那用上面的ROM方案就够了。但实际项目里FPGA经常要把“测得的数据”显示到屏幕上比如温度、电压、计数频率。假设我们有一个16位二进制数要显示必须先把二进制转成十进制ASCII字符串再拼到指令里。最直接的办法是“除10取余”。比如一个数值345345/1034余5得到最低位字符534/103余4得到43/100余3得到3。把余数依次存到一个临时数组里最后倒序输出。这个过程用Verilog实现有两种思路状态机除10循环。适合位数不太多的情况缺点是每个周期只能除一次16位数据可能要跑5~6个周期。查表法。先把16位数值按BCD编码转换再把每一位BCD码加0x30得到ASCII。这个更常用因为BCD转换可以调用现成IP核也可以自己写一个“加3移位”算法。我建议做FPGA显示时优先考虑串口屏的“数值控件”。数值控件的指令是val.val123你只需要把二进制值转成十进制ASCII字符串屏幕负责渲染。这样比显示一长串文本要简单也不容易触发文本缓冲长度限制。但如果你确实想显示“温度25.3℃”这样的混合字符串那只能老老实实拼字符。一个可行的做法是把数值转成ASCII后先暂存在一个FIFO或寄存器数组里然后按“固定前缀 数字ASCII 固定后缀”的顺序依次发送。整个过程依然是上一节那个状态机只是在LOAD阶段不是从ROM取而是根据当前指针决定取前缀、数字还是后缀。4. 实操中最容易翻车的几个点4.1 波特率误差与时钟偏差串口屏是最典型的“看似简单但波特率能坑死人”的设备。我见过很多人的屏幕一接FPGA就乱码拿到串口助手上单独测试却正常问题基本出在波特率上。波特率的本质是每一位的时长。假设系统时钟是50MHz串口波特率是115200那么一位的持续时间应该是1/115200秒约8.68微秒。在FPGA里通常用计数器把系统时钟按比例分频分频系数为时钟频率/波特率50MHz/115200434.0278。计数器只能取整数取434实际一位的时间变成了434/50MHz8.68微秒误差约0.006%完全没问题。但如果你用的时钟是12MHz、想跑115200分频系数是104.17取104后实际波特率变高了0.16%。0.16%在单字节8个数据位上累积误差不到1.3%看起来还能对付但一帧带起始位、停止位、可能的校验位是10个bit长时间传输误差会累积屏幕就会出现“第一个字对后面越来越多乱码”。这种问题在调试时非常隐蔽因为一开始的指令恰好能认出来多几个字符就乱了。解决思路有两条一是优先选择让分频系数接近整数的组合比如25MHz时钟跑115200分频系数217.01误差极小二是用PLL把时钟整形成波特率的整倍数频率。还有一个土办法实际测试时先让FPGA连续发送0x55二进制01010101用逻辑分析仪测量每个高电平或低电平的宽度看是不是精确等于1/115200秒。不用逻辑分析仪就用示波器方法一样打一眼就能确认波特率是否准确。4.2 指令被拆成半条忙信号控制是关键另一个高频问题是指令发到一半被别的逻辑打断。很多FPGA初学者习惯于“只要想让模块工作就拉一个高脉冲”但串口发送是有速度上限的一帧字节传输时间在毫秒级别如果上层逻辑在发送过程中又拉了一次start整个状态机可能被重置指令就从中间断开了。在我设计的例子里busy信号就是干这个用的。发送期间busy1上层逻辑必须等它拉低才能发下一条。如果你用现成串口库或者例程也要确认模块暴露的忙信号是不是真实的“发送完成”指示而不是“正在装载数据”指示。两者差一个时钟周期放在长指令上可能差好几个字节后果完全不同。这个坑最容易出现在“定时刷新”场景。比如你写了一个每500ms刷新一次数值的逻辑第一次刷新刚发送到一半定时器又触发了一次这时候如果代码里没有判断busy第二条指令直接顶上来屏幕就会收到一段拼接错乱的字节流。4.3 特殊字符和中文编码串口屏指令本质是ASCII字节流所以发送空格、标点、双引号都要用对应的ASCII码。最容易漏的是空格比如一条指令里控件名和赋值符号之间有没有空格不同固件版本要求不一样。有的屏幕严格按字符匹配解析你多给一个空格它也可能不接受所以最好统一用一种格式并仔细看手册确认。中文显示是另一个大坑。串口屏内置中文字库时FPGA发送中文不能直接发“温度”这两个字的Unicode字符而要发送它在屏幕字库里的编码常见的有GBK、GB2312部分新屏支持UTF-8。一般流程是把中文字符串在PC端转成目标编码的十六进制字节比如“温度”转成GBK后是CE C2 B6 C8然后FPGA把这些字节作为普通数据发出去屏幕才能正确显示。如果你直接按UTF-8编码发送而屏幕是GBK字库结果就是乱码。还有一个很容易忽略的点串口屏开机初始化需要时间少则几十毫秒多则几百毫秒。如果FPGA一上电就开始发指令很可能前几个字节被屏幕当成无效数据丢弃后续数据又因为缺了开头而解析失败。我的做法很简单开发初期固定延时200ms再发第一条指令或者在开机后先发一条最简单的指令试探确认收到屏幕的“启动完成”字节流后再进入正式流程。后者需要写接收解析复杂一些但在工业项目里更可靠。4.4 常见问题速查表现象可能原因处理办法屏幕完全黑屏无反应串口屏供电不足、屏幕未下载固件检查电源电流重新烧录固件能显示但内容是乱码波特率不匹配、编码格式错误测波特率位宽核对中文字库编码只显示前几个字符FPGA字符串长度计数错误或屏幕控件缓冲区过小检查发送状态机的CMD_LEN查看控件最大字符数刷新一次后不再更新start信号未触发或busy信号没拉低用逻辑分析仪抓start与busy时序按钮触发没反应屏端固件未配置按钮数据上传、协议格式不对、结束符缺失先用串口助手直接发按钮事件指令确认屏端能上报再比对FPGA发送字节流触摸屏坐标偏移未进行触摸校准、屏幕参数不对重新校准触摸检查屏的横竖屏参数4.5 调试工具与流程说句贴心话串口屏调试顺序非常影响排错效率。我的一般流程是先用USB转TTL模块接屏幕在电脑上用串口调试助手手动发几条指令确保屏幕本身工作正常、指令格式正确然后FPGA先只发一条仅含固定文本的指令比如page0.txtOK看能不能点亮最后再逐步接入真实业务逻辑发送动态字符串。走完这三步如果出了问题基本就能判断是屏幕配置、波特率还是逻辑代码的问题不用像无头苍蝇一样乱猜。USB转TTL模块还有一个用途监听FPGA发给屏幕的字节流。把模块的RX接在FPGA的TX线上电脑上串口助手能看到FPGA实际发出的所有字节拿它和正确的指令字节流对比很快就能发现是少了结束符、还是字符串拼错、还是波特率偏差。这比在FPGA里写ILA逻辑探针省事得多。5. 不同串口屏方案怎么选5.1 主流方案USART_HMI与DGUS在串口屏领域现在大家用得最多的是两大路线一类是淘晶驰以及类似采用USART_HMI协议的产品强调“文本指令直控控件”另一类是迪文DGUS系统强调“变量地址映射显示”。这里用一个表格说清楚差异对比项淘晶驰USART_HMI迪文DGUS开发方式组态软件拖控件给控件命名主机发类似自然语言的指令DGUS软件配置变量地址控件绑定变量主机读写变量地址指令风格page0.txthello易读易排错指令更接近寄存器读写效率高但调试时要对着地址表学习门槛低适合新手、快速原型、教学中高适合有批量生产要求的工程师显示刷新效率控件级刷新适合中小项目变量映射显存操作适合大批量数据刷新典型场景仪器仪表、调试面板、小型工控工业组态、车载显示、医疗设备等复杂界面选哪条路线核心要看开发效率和数据刷新量。我个人做FPGA调试、测试工装、临时显示面板的时候特别爱用USART_HMI这种自然语言协议因为我可以直接在草稿纸上写出要发送的指令对照字节表逐字节排错。但如果是做带严格UI规范的产品涉及大量页面、多语言切换、图片轮播DGUS这类变量地址映射机制反而更直接它可以提前把界面资源全部封装好运行时只需要改变变量值屏幕自己把界面刷新出来。5.2 什么时候不要用串口屏串口屏虽香但不是万能的。我通常会在下面几类场景明确劝退一、数据刷新率要求极高。比如要实时刷新动态波形、频谱图一秒钟几十帧串口屏内部渲染能力跟不上你会看到画面撕裂、闪烁。这种场景建议FPGA直驱TFT甚至用HDMI输出到显示器。二、显示内容需要频繁切换且数据量巨大。比如上位机软件界面那种几百个控件互相联动的场景串口帧很长一次刷新要传几千字节即使波特率跑到921600一帧也要几十毫秒操作体验会很拖沓。三、成本极度敏感的大批量产品。串口屏模块比裸TFT屏加驱动的方案贵不少如果产品只需要显示三五个数字数码管或者段码LCD仍然是更省钱、更稳定的选择。做方案选型时我的习惯是先把需求量化显示多少个变量、最多多少页面、刷新周期是多少毫秒、界面复杂度到什么程度。把这些数字列出来之后就会发现“该不该用串口屏”往往不需要纠结多久。在实际工程里的一些固化打法我自己做FPGA串口屏项目时已经形成了一套固定套路分享出来供参考。第一第一次接入串口屏一定先用电脑串口助手调通指令再写FPGA代码。很多问题在电脑端就能暴露比如格式不对、缺结束符如果直接上FPGA你还要额外排查FPGA逻辑问题凭空多出一层变量。第二FPGA端发送模块一定要把busy信号引出来。这不仅是为了逻辑上“不能打断正在发送的指令”更是为了调试时不至于两眼一抹黑。如果你有逻辑分析仪把busy接到一个空闲IO上抓一抓波形一眼就能看到每次发送的时长是不是符合预期指令之间有没有意外重合。第三把每条串口屏指令当成一个“协议数据包”来设计。用状态机而不是一堆if else触发发送因为状态机天然适合“多字节按顺序输出”的场景。哪怕你只发一条指令我也建议写成完整的状态机因为后面业务扩展时你会感谢自己留了一个清晰的扩展点。第四不要忽视上电时序。串口屏的MCU启动比FPGA配置慢这是板上钉钉的事实。稳妥的工程做法是设计一个“握手”FPGA持续监听串口屏上电后主动上报一段启动信息FPGA收到后再开始发送业务指令。如果不做握手至少也要保证启动延时足够长并且第一条指令是一条无副作用的测试指令比如设置页面号、设置一个无效控件的属性值用它来试探链路是否就绪。串口屏真正帮你省掉的是界面开发的复杂度但它不能帮你省掉通信协议的严谨性。把指令字节流当成一个正式协议来对待认真设计状态机、严格检查字节数和结束符、留好调试接口这是我从几次翻车经历里攒下的血泪经验。如果你正准备在FPGA项目里接入串口屏照这个思路走一遍大概率能少走好几周弯路。下一篇我打算写写“FPGA如何解析串口屏按钮上报数据”把“屏按按钮、FPGA干活”这条路补完到时候接着聊。