ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

嵌入式串口屏通信框架设计:状态机、DMA与变量映射表的工程实践

嵌入式串口屏通信框架设计:状态机、DMA与变量映射表的工程实践 做嵌入式这么多年串口屏项目我接过不下二十个从最早的迪文 DGUS 用到后来的淘晶驰、大彩踩坑踩到最后问题基本都集中在同一个地方不是屏不会用而是主控端和串口屏之间的通信代码越写越乱换个屏、换个项目就要推倒重来。后来我花几个周末把这些收发逻辑抽出来整理成一套可复用的 HMI串口屏框架才发现原来这活可以干得这么清爽。这篇东西不聊屏厂官方的点灯教程聊的是屏之外的那层“壳”主控端怎么设计通信协议层、怎么把串口收包做成状态机、怎么把迪文和淘晶驰的指令差异关在同一个接口后面以及框架里每一块代码到底是为什么存在的。适合正在用 STM32 等 MCU 接串口屏做项目的嵌入式工程师也适合刚接触 HMI 串口屏、想从一开始就把代码组织得有条理的初学者。我把这套框架从设计思路到落地代码完整拆给你看你照着搭一遍后面再做串口屏项目会顺非常多。1. 为什么串口屏项目需要一套框架1.1 裸调串口屏的三宗罪我见过太多项目代码里满屏都是HAL_UART_Transmit(huart1, buf, len, 100);后面跟着一堆魔法数0x5A 0xA5 0x05 0x82之类注释也不写读起来全靠猜。这种写法的第一个问题是不可读。两个月后你回来看代码根本不知道 0x0082 这个地址对应的是温度显示还是转速表盘改一个数值变量得把整个文件翻个底朝天。第二个问题是不可靠。串口是异步的屏不是所有时候都等着回你数据裸调的时候经常遇到“发过去没回应、读回来的数据对不上”的情况一旦没有超时重传机制整个系统就僵在那里等看门狗咬人。第三个问题是不可换。今天用迪文明天客户指定淘晶驰指令集完全不一样业务代码全得跟着改几千行的改动成本没人想背。这三个问题本质上不是串口屏本身的毛病而是主控端缺少一层统一的软件抽象。你仔细观察就会发现串口屏的协议虽然各家不同但做的事情高度相似都是“我要往某个控件或地址写什么数据”“我想知道某个变量的当前值”“用户按了哪个按钮需要触发什么动作”。把这些共性抽出来就是框架的第一动力。很多朋友觉得串口屏开发就是发指令、收指令没必要小题大做但真等你接手一个几千行的裸调工程时你就会明白当初省下的那点设计时间后面都会加倍还回去。1.2 框架到底管了哪些事我设计的这套框架职责分三层。最底层是硬件驱动层管的是串口外设的初始化、DMA 收发、环形缓冲这一层跟屏没关系换个屏这层一行都不用动中间是协议层负责组指令、解析应答、校验拆包这一层跟屏强相关但改动被严格限制在一个文件里最上面是应用层业务代码只跟“变量表”打交道比如hmi_write_u16(温度, 36)至于这条指令是5A A5 05 82开头还是淘晶驰的print开头应用层完全不用关心。这样分层的价值在于每一层都可以单独测试。我用串口调试助手先验协议层再把协议层和硬件层连起来跑回环最后才接真屏调应用。不要把屏一插上就开始写业务代码那是赌运气。真正需要花心思打磨的是中间那一层也就是帧的设计与解析。帧不隔层、不越层这一层做稳了上层加多少页面、多少个变量都不会心虚。反过来说如果这一层写得稀烂后面每加一个功能都要重新跟协议搏斗一遍项目越做越痛苦。2. 框架的核心模块与通信协议设计2.1 串口底层的环形缓冲与DMA收包串口屏的返回数据不是连续的屏收到指令后隔几毫秒才回一帧中间还夹杂着触摸事件主动上报的数据。如果每收到一个字节就调用一次回调会把上层逻辑活活打断。所以我第一件事就是做一个环形缓冲区配合串口空闲中断或者 DMA 接收把数据先囤下来在主循环里统一消化。以 STM32 HAL 库为例我一般用HAL_UARTEx_ReceiveToIdle_DMA让硬件在串口空闲时自己产生中断DMA 把一串数据搬到内存里然后在中断回调里把数据写进环形缓冲。代码大致长这样#define RING_BUF_SIZE 256 typedef struct { uint8_t buf[RING_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ringbuf_t; void ringbuf_push(ringbuf_t *rb, uint8_t *data, uint16_t len) { for (uint16_t i 0; i len; i) { rb-buf[rb-head] data[i]; rb-head (rb-head 1) % RING_BUF_SIZE; } } uint16_t ringbuf_pop(ringbuf_t *rb, uint8_t *out, uint16_t max_len) { uint16_t cnt 0; while (rb-tail ! rb-head cnt max_len) { out[cnt] rb-buf[rb-tail]; rb-tail (rb-tail 1) % RING_BUF_SIZE; } return cnt; }环形缓冲区的大小要按最大报文长度算我一般取最大帧长的 2 到 3 倍。迪文 DGUS 最长一条读指令回包大概十几个字节淘晶驰的文本回包可能到几十个字节256 字节足够用。如果项目里既有屏又有别的设备共用一块缓冲区那就再往上提别省这点内存。处理这个模块时还要注意 head 和 tail 必须加volatile修饰因为中断和主循环会同时访问这两个变量。提示DMA 接收模式一定要处理“数据恰好填满缓冲区”的边界情况否则 DMA 不会再触发空闲中断后续数据全部卡死。我的做法是在 DMA 传输完成回调里再手动调用一次接收函数让它继续候着这样既能处理满缓冲也不影响空闲中断的触发。2.2 报文解析状态机解决粘包和半包串口数据流是没边界的你无法保证一次收到一整帧。可能是半包可能是两帧粘在一起还有可能中间混进屏端主动上报的乱序数据。所以协议层至少需要两种解析方式按定长时间切片或者按帧头结尾做状态机。我更推荐后者稳定性更好尤其是屏端指令带\xff\xff\xff这种特殊结尾时状态机远比“等 50ms 再读缓冲区”靠谱。我习惯用一个简单的四态机找帧头、读长度、读数据、校验。以迪文 DGUS 指令为例帧头是5A A5紧接着是数据长度后面是指令码和数据校验可选。实现如下typedef enum { ST_IDLE, ST_HEADER1, ST_HEADER2, ST_LEN, ST_DATA, ST_TAIL } frame_st_t; static uint8_t parse_state ST_IDLE; static uint8_t rx_buf[128]; static uint16_t rx_len 0; static uint16_t rx_cnt 0; void hmi_protocol_parse(uint8_t byte) { switch (parse_state) { case ST_IDLE: if (byte 0x5A) parse_state ST_HEADER1; break; case ST_HEADER1: parse_state (byte 0xA5) ? ST_LEN : ST_IDLE; break; case ST_LEN: rx_len byte; rx_cnt 0; parse_state (rx_len 0) ? ST_DATA : ST_TAIL; break; case ST_DATA: if (rx_cnt sizeof(rx_buf)) rx_buf[rx_cnt] byte; if (rx_cnt rx_len) parse_state ST_TAIL; break; case ST_TAIL: hmi_on_frame(rx_buf, rx_len); // 交给上层处理 parse_state ST_IDLE; break; default: parse_state ST_IDLE; break; } }注意几个细节。一是状态机的每个分支都必须有break否则状态会串这是新手最容易犯的错。二是缓冲区溢出要有保护代码里rx_cnt sizeof(rx_buf)就是为了防止屏端发来一条超长数据把内存写爆。三是收到完整帧后要立即把状态恢复到ST_IDLE不然下一帧的第一个字节会被当成当前帧的尾巴丢掉。四是不要在任何中断里直接调用hmi_on_frame做业务处理只负责把事件记下来具体逻辑放到主循环里跑否则中断反复嵌套很容易搞出死锁。2.3 指令封装层与品牌差异隔离状态机接收解决的是“怎么收”指令封装解决的是“怎么发”。这一层我给它起了个名叫hmi_port里面只暴露四个接口写变量、读变量、刷新页面、发送原始帧。不同品牌的屏在这个文件里各自实现一份编译时用条件编译或者函数指针切换。比如迪文 DGUS 写一个 16 位变量协议是5A A5 05 82 地址高 地址低 数据高 数据低。淘晶驰则是通过串口发print 温度,n0加三个结束字节。大彩是AA 设备地址 长度 指令 数据 校验。这几套东西如果散落在业务代码里项目基本没法维护。放进hmi_port后业务层只调hmi_write_u16()换屏等于换一个.c文件这个收益是立竿见影的。有个细节值得重点说指令的“确认”机制。屏不是指令收发机有些指令发出去屏并不应答有些则回 ACK。我的框架里把指令分成两类一类是“发完即走”的比如清屏、切页发完不用管另一类是“必须等到应答”的比如读变量。第二类指令要挂进发送队列带超时和重试超过 100ms 没等到就重发重发三次仍无回应就报错给应用层。这套机制看着简单实际工程里能救你很多次尤其是屏固件版本不同导致个别指令没响应的时候有了超时重传系统至少不会永久卡死。2.4 变量映射表让业务代码不碰协议协议层搞定之后应用层还面临一个问题业务代码怎么知道“温度”这个逻辑量和屏上地址 0x0082 的对应关系我在这里用了一张静态配置表typedef struct { uint16_t addr; uint8_t type; // HMI_VAR_U8 / HMI_VAR_U16 / HMI_VAR_STR uint16_t max_len; } hmi_var_desc_t; static const hmi_var_desc_t var_tbl[] { { 0x0082, HMI_VAR_U16, 2 }, // 温度 { 0x0084, HMI_VAR_U16, 2 }, // 转速 { 0x0090, HMI_VAR_STR, 16 }, // 设备编号 };应用层写值的时候先按逻辑名查表拿到地址和类型再统一走hmi_port_write()。这样以后屏端工程里挪了变量地址只需要改这一张表业务代码一行不动。常见做法是把地址定义放到一个单独的头文件里或者干脆做成数组配合上位机工具批量生成。我在项目里就是拿 Excel 维护这份映射表再用脚本转成 C 数组非常省心变量多了以后这个优势尤其明显。变量映射表还有一个隐藏的好处它天然就成了项目文档。新人接手时不用去翻几百页的屏端组态截图看这张表就知道哪些变量在通信、什么类型、什么用途。我甚至会在表里加一列备注把协议指令样例也写进去团队协作的时候效率提升非常明显。3. 基于STM32的框架集成实操3.1 硬件连接与串口参数先泼一盆冷水很多人第一步就翻车不是因为代码而是因为电平。串口屏的串口分 TTL、RS232、RS485 三种STM32 的 UART 引脚是 3.3V TTL 电平。如果你的屏是 RS232 接口直接拿杜邦线连 STM32 的 PA9、PA10多半不是乱码就是烧东西。我做项目前会先查屏的规格书确定电平是 TTL 还是需要外接 RS232 转换芯片再决定接线。TTL 屏要注意 TX 接 RX、RX 接 TX两边必须共地不然参考电平不一致数据收不回来。波特率我统一用 1152008 数据位、无校验、1 停止位也就是常说的 8N1。有些老式屏默认是 9600待机功耗低的场合 9600 也够用但项目维护周期长的话建议全部在屏端工程里设成 115200主控端和屏端保持严格一致。这里有个很容易踩的坑改完屏端波特率后屏要重新上电才生效不是点一下保存就行。我吃过一次亏改完设置以后直接发指令屏毫无反应排查了半天才发现是屏没重启。3.2 框架代码结构我的工程里与串口屏相关的文件固定是五个按模块分得清清楚楚文件职责ringbuf.c/h环形缓冲区管串口数据暂存hmi_uart.c/hSTM32 串口外设初始化与 DMA 收发hmi_protocol.c/h状态机解析、帧校验、粘包拆包hmi_port.c/h品牌指令封装对外统一接口hmi_app.c/h变量表、业务回调、周期刷新任务为什么这样拆因为每个文件都能单独测试。ringbuf不依赖任何硬件可以在 PC 上跑单元测试hmi_protocol只需要喂字节流用串口助手也能验证真正绑定 STM32 的只有hmi_uart一个文件。你把硬件相关的东西压缩到最小范围将来移植到别的 MCU 或者从裸机换 RTOS成本都极低。我刚把框架从 STM32F103 搬到 GD32F407 的时候只改了hmi_uart.c其余四个文件一行没动两天完成移植。3.3 核心代码实现以 STM32 HAL 为例串口初始化这样写void hmi_uart_init(void) { MX_USART1_UART_Init(); // 115200 8N1 HAL_UARTEx_ReceiveToIdle_DMA(huart1, dma_buf, DMA_BUF_LEN); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t size) { if (huart-Instance USART1) { ringbuf_push(rb, dma_buf, size); // 重新开启 DMA 接收循环待命 HAL_UARTEx_ReceiveToIdle_DMA(huart1, dma_buf, DMA_BUF_LEN); } }主循环里再跑一个轻量任务把缓冲区里的字节一个个喂给状态机void hmi_task(void) { uint8_t byte; while (ringbuf_pop(rb, byte, 1)) { hmi_protocol_parse(byte); } }要注意HAL_UARTEx_RxEventCallback里那个重新开启 DMA 的动作必不可少漏了它系统只会收到第一次数据。这个坑我帮不少人填过十有八九都是这种“回调里忘记重新使能”的问题。另外dma_buf的长度要和 DMA 配置一致别定义成 64 字节但 DMA 配置里写 256那样回调收到的 size 和你预期的完全对不上。发送端我额外加了一个简单的发送队列因为有些业务会一瞬间触发多条指令比如切页、刷新三个数值。如果逐条阻塞发送整个任务会被串口拖慢。我的方案是用一个uint8_t tx_queue[4][32]先进先出发送完一条再发下一条发送完成中断里检查队列。要控制每条指令之间的间隔吗大部分屏不要求但迪文的某些老固件会对连续指令有最小间隔要求稳妥起见我在两条指令之间留了 2ms实测不会影响刷新速度。3.4 应用层对接主动刷新与事件回调应用层写法我是这样约定的需要周期性显示的数据比如温度、转速这类实时量在hmi_app里注册一个 100ms 定时刷新任务循环调用hmi_write_u16()需要响应用户操作的比如按钮切换页面则在协议层解析到屏端上报数据后通过回调通知应用层。举个例子温度传感器每 200ms 更新一次显示逻辑就放到系统 tick 里做读取传感器、查变量表、写屏一气呵成。而用户按了屏上的“启动”按钮屏端会上报一条数据帧状态机解析出来后我在HMI_EVENT_BTN_START这个事件里做真正的业务逻辑比如开电机、切状态。这样应用层代码完全不知道串口协议长什么样只关心“用户做了什么”和“我要显示什么”可读性瞬间上一个台阶。这里有个经验不要在中断上下文直接跑业务。协议层只负责把事件标志抛出来具体业务放主循环或者单独的 task 里处理。否则触摸屏上报频率一高在中断里执行电机逻辑很容易把实时性搞崩。我早期就干过这种事按键稍微快点整个系统就卡死后来把所有事件都改成标志位加主循环轮询世界就清净了。4. 常见问题排查与多品牌适配实录4.1 乱码、收不到、偶发丢包串口屏调试里出现的怪问题我先说几个最典型的场景和对应的排查顺序。第一乱码。先去检查波特率把屏端软件里设置的波特率和主控端初始化对上屏端改完务必断电重启。然后检查电平TTL 屏别接 RS232 的信号线RS232 屏必须过转换芯片两边电平不匹配怎么调波特率都没用。最后检查地线屏和主控不共地时偶尔能收到数据但数据经常是花屏或随机丢字节共地后问题立减。第二完全收不到。按顺序查四件事TX 和 RX 有没有接反、屏端串口是不是被屏的组态软件占用了、屏有没有上电完成、主控串口有没有被别的外设抢占。这四件事里被组态软件占用是最容易被忽略的很多屏厂的上位机工具会一直占着 COM 口不放你程序怎么发都石沉大海。第三偶发丢包。多半是中断优先级问题串口接收中断被别的高优先级中断频繁打断缓冲区又小数据直接丢失。我一般把串口和 DMA 的中断优先级提到仅次于系统 tick 的位置并把缓冲区从 64 提到 256丢包概率明显下降。最后建议接一个逻辑分析仪抓串口波形一发一收都看得清清楚楚比对着代码猜高效太多。逻辑分析仪不贵几十块的就够用关键时刻能省你半天时间。4.2 粘包与半包的处理前面写了状态机但实际中还会遇到两类特殊场景。第一类是“粘包”屏端在一次事件里连续回了两帧数据比如一帧是按钮事件、一帧是数值更新。如果解析器在收完第一帧后直接回到ST_IDLE第二帧其实也能正常解析因为状态机会重新找帧头。真正会出问题的是那些没有明确帧头的协议比如淘晶驰的部分文本指令只能靠结束符\xff\xff\xff来切帧。这时我就把状态机改成“每收一个字节都检查是否连续收到三个 0xFF”满足就认为一帧结束再用超时兜底超过 100ms 没有结束符也强制清空缓冲区。两种策略同时用能覆盖绝大多数情况。第二类是“半包”就是一帧被拆成两次收到。这恰恰是状态机天然能处理的优势它不关心一帧是分几次进串口字节来了就往后走状态到哪算哪。所以只要状态机写对半包根本不是问题。千万别用阻塞接收去等一帧完整数据屏幕一升级上报频率整个系统都会卡住。我在一个项目里见过同事用HAL_UART_Receive加超时等一帧结果屏端上报稍微密集一点主控这边就不断重进等待状态前后台逻辑全乱套。4.3 迪文屏内存卡与刷写细节迪文屏因为性价比高工业项目里用得很多但它的工程文件刷写机制坑最多。迪文屏的工程是通过 TF 卡刷写的不是说把文件拖进去就行。我总结下来有几条硬性经验一是内存卡必须格式化成 FAT16 或 FAT32有些迪文老型号对 FAT16 支持更好建议先试 FAT32不行就换 FAT16。二是内存卡容量别太大16GB 及以下的卡兼容性最好我用过 64GB 的卡屏直接不识别。三是卡里只能放当前工程的固件和组态文件别把其他无关文件混进去工程目录结构要跟官方 DWIN DGUS Tool 生成的保持一致。四是刷写时屏要先断电、插卡、再上电上电过程中看到屏蓝屏或者进度条才有反应烧完断电拔卡再上电。插着卡直接上电刷失败的案例我遇到很多多半是卡格式不对或者屏固件版本和工程版本不匹配。还有一点迪文屏的变量地址和屏端组态里的“显示变量”是一一对应的两边地址必须精确到字节。我自己吃过亏主控端写的 0x0082屏端组态里放的是 0x0084屏幕上看起来就是“有时有数有时没数”查了半天才发现是地址错位。这种问题用串口助手抓屏端返回报文最容易定位别只盯着屏上的现象猜。4.4 淘晶驰、大彩与迪文的框架适配差异换个屏框架还在但协议层要动的地方比想象中少。我用这三个牌子对比一次大家就清楚差异隔离怎么做。淘晶驰 USART HMI 走的是类脚本指令比如page 0、n0.val100、get n0文本指令直白但它没有统一的二进制帧结构结束符是三个0xFF而且很多指令不返回应答读变量要主动发起。框架里我把它单独实现成一个解析方向按文本行匹配加结束符切帧。淘晶驰对新手非常友好调试时可以直接用它的上位机工具在线预览不过要在项目里用起来它的指令散落问题反而更需要框架约束不然代码里全是裸的printf拼指令。大彩串口屏的指令集风格接近迪文但帧格式是AA 目标地址 长度 指令 数据 校验变量地址机制也比迪文复杂支持直接操作控件 ID。它的优势是带 Lua 脚本很多逻辑可以直接在屏端跑减少主控端的负担。框架里我把大彩并到二进制帧解析这一类跟迪文共用状态机框架只换指令码和校验算法。迪文 DGUS 则是标准的5A A5帧带地址读写指令最接近“主控是主机、屏是从机”的模式。总体来看框架的协议层只要抽象出“帧头识别方式、结束方式、指令码表、校验算法”这四个配置点三家屏都能套。我在hmi_port.c里用宏定义把这四样收敛到一个配置文件换屏时只改配置不动状态机代码实测从迪文换到淘晶驰两天就能全部切换完。最后再分享一个小技巧。框架搭好之后我通常会写一个简单的自检模式主控上电后在串口上循环发送“读版本号”之类的通用指令如果收到预期应答就说明硬件、参数、协议三层全部正常然后再进入正常业务。这个自检逻辑放到产线测试里特别有用屏有没有接好、线有没有接反几秒钟就能判断出来省去了很多在客户现场现查的麻烦。我自己靠这个自检模式至少少跑了三次冤枉路。
返回列表