☰
迪文串口TFT屏通用驱动:STM32/GD32/ESP32三平台适配与协议解析
2026/9/25 3:41:54 网站建设 项目流程

简介:这份资源是面向AVR平台开发者的迪文串口TFT屏通用驱动程序,用标准C语言编写,帮助嵌入式工程师在工业控制、智能家居、物联网等项目中快速集成迪文显示屏,无需深入硬件细节即可完成图形与文本显示。压缩包为rar格式,共2个文件,包含1个c源文件和1个h头文件,整体约3KB,体积轻巧便于嵌入现有工程。源文件承载初始化、画点、画线、矩形、圆形及文本输出等核心实现,头文件则定义屏幕尺寸、颜色深度等配置结构体与对外API原型,两者配合构成完整驱动骨架。目前已有831人学习下载,适合具备一定AVR与串口通信基础、希望复用显示代码的开发者参考,可据此理解迪文屏指令交互方式,并在此基础上按项目需求调整通信参数与刷新策略。

1. 迪文串口TFT屏通用驱动:一块屏吃遍三家MCU的野路子

手头同时跑着 STM32、GD32 和一块 ESP32-S3 的板子,每块板子都挂着一块迪文串口 TFT 屏,最烦的不是接线,而是每换一个平台就得把驱动重写一遍。迪文屏的协议本身不复杂,就是 0x5A 0xA5 包头加变长数据帧,但各家 HAL 库的串口收发、DMA 搬运、中断优先级都不一样,写三份驱动等于给自己挖三个坑。所谓「通用驱动程序」,核心思路是把「协议解析」和「硬件收发」彻底解耦:协议层只认字节流,硬件层只负责把字节搬进搬出,中间用一层环形缓冲和状态机粘起来。这样换 MCU 时只改硬件层那几十行,协议层一行不动。这套方案适合手里有多平台、又不想被某家 HAL 绑死的嵌入式工程师,新手照着也能先把最小收发跑通,再逐步加触摸和变量存储。

2. 迪文协议拆到字节级:帧头、长度、CRC 到底怎么摆

2.1 迪文屏的帧结构不是「标准 Modbus」,别套错模板

很多人第一次接迪文屏,看到 0x5A 0xA5 就以为是 Modbus 的变种,直接拿 Modbus 的 CRC16 去校验,结果屏一点反应没有。迪文 DGUS 协议的帧结构是这样的:帧头 2 字节(0x5A 0xA5),然后是 1 字节长度(表示数据域长度,不含帧头和长度本身),接着是 1 字节指令类型(0x82 写变量、0x83 读变量、0x80 写寄存器等),再往后是数据域,最后是 1 字节 CRC 校验。注意这个 CRC 不是标准 CRC16,而是迪文自定义的累加和取反加一,只对长度、指令和数据域做运算。

我一般会先把帧结构写成一张表贴在工位上,省得每次翻手册:

字段字节数说明
帧头2固定 0x5A 0xA5
长度1数据域字节数,不含帧头、长度、CRC
指令10x82 写、0x83 读、0x80 寄存器
数据域N地址高字节、地址低字节、数据…
CRC1累加和取反加一

写变量时数据域是「地址高 + 地址低 + 数据高 + 数据低」,地址是迪文屏内部的变量地址,不是内存地址。读变量时数据域只有地址两字节,屏会回一帧带数据的响应。这里有个容易翻车的点:长度字段算的是数据域长度,不是整帧长度,我第一次写的时候把整帧长度填进去,屏直接当垃圾帧丢了。

2.2 用 Python 先把协议跑通,再往 MCU 上搬

在写 C 驱动之前,我习惯先用 Python 加串口调试助手把协议验证一遍,这样能排除是硬件问题还是协议问题。下面这段代码用 pyserial 发一帧写变量指令,把变量地址 0x1000 写成 0x1234:

import serial import struct def div_crc(data: bytes) -> int: """迪文自定义校验:累加和取反加一""" total = sum(data) & 0xFF return ((~total) + 1) & 0xFF def build_write_frame(addr: int, value: int) -> bytes: # 数据域:地址高、地址低、数据高、数据低 payload = struct.pack('>HH', addr, value) # 长度 = 指令(1) + 数据域长度 length = len(payload) + 1 frame = bytes([0x5A, 0xA5, length, 0x82]) + payload frame += bytes([div_crc(frame[2:])]) # CRC 从长度字节开始算 return frame ser = serial.Serial('COM3', 115200, timeout=0.5) frame = build_write_frame(0x1000, 0x1234) ser.write(frame) print('TX:', frame.hex(' ')) resp = ser.read(64) print('RX:', resp.hex(' ') if resp else 'no response')

逻辑说明:div_crc只对长度、指令、数据域做累加,不含帧头,这是迪文协议和 Modbus 最大的区别。build_write_frame里struct.pack('>HH', ...)用大端序,迪文屏内部就是大端,别用小端。参数上,波特率默认 115200,但迪文屏出厂可能是 9600,第一次连不上先确认屏的波特率,用串口调试助手发 0x5A 0xA5 0x03 0x80 0x01 0x00 这种读版本指令试探。

跑通之后,把这段逻辑原样翻译成 C,协议层就算定型了。后面换 MCU 时,这段 C 代码一个字不用改,只改串口收发那部分。

2.3 读变量和触摸上报的帧格式差异

写变量是主动发,读变量是发完等响应,触摸上报是屏主动发。触摸上报的指令类型是 0x83,数据域里带触摸控件地址和触摸状态。很多人把读变量和触摸上报搞混,因为都是 0x83,区别在于:读变量是你先发一帧请求,屏回一帧;触摸上报是屏自己发,你只管收。我一般会在协议层里用两个不同的回调函数处理,读变量走同步等待,触摸上报走异步事件,这样不会互相阻塞。

读变量的响应帧里,数据域是「地址高 + 地址低 + 数据高 + 数据低」,和写变量请求的数据域结构一样,所以解析函数可以复用。触摸上报的数据域是「控件地址高 + 控件地址低 + 触摸状态」,触摸状态 0x01 是按下,0x00 是抬起。这里有个坑:如果屏上同时有多个触摸控件,屏会连续发多帧,你的接收缓冲要能扛住突发,不然会丢帧。

3. 通用驱动的分层设计:协议层和硬件层怎么切

3.1 三层结构:协议层、缓冲层、硬件层

通用驱动的核心是分层,我一般切成三层。最上面是协议层,负责组帧、解帧、CRC 校验、回调分发,这一层纯 C,不依赖任何 HAL。中间是缓冲层,一个环形缓冲区加一个状态机,负责把串口收到的零散字节拼成完整帧。最下面是硬件层,负责串口初始化、DMA 配置、中断处理,这一层每个平台写一份。

这样切的好处是:协议层和缓冲层可以拿 PC 上的单元测试跑,不用上板子。我经常在 PC 上用一个假的串口收发函数喂数据给协议层,验证组帧解帧对不对,确认没问题再上 MCU。硬件层只做三件事:初始化串口、启动 DMA 接收、在中断里把字节塞进环形缓冲。换平台时只动这三件事。

环形缓冲的大小要算一下:迪文屏触摸上报突发时,一帧最多几十字节,但连续触摸可能一秒发十几帧,按 115200 波特率算,一秒最多 11520 字节,缓冲开 512 字节足够。如果开了 DMA 双缓冲,可以开到 1024。别开太小,我见过有人开 64 字节,结果快速滑动触摸时丢帧,查了半天以为是屏的问题。

3.2 环形缓冲加状态机的收帧实现

收帧不能靠「读一次串口就解析一次」,因为串口数据是流式的,一帧可能分几次到,也可能几帧粘在一起。正确做法是:中断里只往环形缓冲塞字节,主循环里跑状态机从缓冲取字节拼帧。下面是一个简化的状态机实现:

typedef enum { STATE_HEAD1, // 等 0x5A STATE_HEAD2, // 等 0xA5 STATE_LEN, // 读长度 STATE_BODY, // 读指令+数据域 STATE_CRC // 读校验 } frame_state_t; static frame_state_t state = STATE_HEAD1; static uint8_t buf[256]; static uint8_t idx = 0; static uint8_t need = 0; void protocol_feed(uint8_t byte) { switch (state) { case STATE_HEAD1: if (byte == 0x5A) state = STATE_HEAD2; break; case STATE_HEAD2: state = (byte == 0xA5) ? STATE_LEN : STATE_HEAD1; break; case STATE_LEN: need = byte; // 数据域长度 buf[0] = byte; idx = 1; state = STATE_BODY; break; case STATE_BODY: buf[idx++] = byte; if (idx >= need + 1) state = STATE_CRC; // 长度+指令+数据 break; case STATE_CRC: buf[idx++] = byte; if (div_crc(buf, idx - 1) == byte) { dispatch_frame(buf, idx); // 校验通过,分发 } state = STATE_HEAD1; break; } }

逻辑说明:need存的是长度字段,实际要收的字节是「长度 + 指令 + 数据域」,所以idx >= need + 1时进入 CRC 状态。div_crc(buf, idx - 1)对 buf 里除 CRC 外的所有字节做校验。参数上,buf开 256 字节,因为迪文屏单帧数据域一般不超过 128 字节,256 够用。如果做曲线刷新这种大数据量传输,要开到 512 以上。

这个状态机的关键点是:它不关心字节从哪来,你从环形缓冲取也好,从 DMA 缓冲取也好,喂给它就行。这样协议层和硬件层就彻底解耦了。

3.3 硬件层适配:STM32、GD32、ESP32 各写各的

硬件层每个平台写一份,但接口统一成三个函数:hw_uart_init()、hw_uart_send(buf, len)、hw_uart_on_rx(byte)。STM32 上用 HAL 库的话,串口接收中断里调hw_uart_on_rx,发送用HAL_UART_Transmit_DMA。GD32 的库和 STM32 很像,基本改个函数名就行。ESP32-S3 用 ESP-IDF 的话,串口驱动是另一套,但接口不变。

这里有个血泪经验:STM32 的 HAL 库串口接收中断里不要做任何耗时操作,只把字节塞进环形缓冲就退出。我见过有人在中断里直接解析帧,结果触摸一快就丢数据,因为解析耗时太长,下一个中断来了还没退出。DMA 接收更省事,配好 DMA 循环模式,半满和全满中断里把数据搬进环形缓冲,CPU 占用几乎为零。

ESP32-S3 上要注意,Arduino 框架的Serial.read()在高速率下会丢数据,建议用Serial.onReceive回调或者直接读寄存器。如果做产品,还是用 ESP-IDF 的 uart driver,配好事件队列,稳得多。

4. 避坑排查:迪文屏驱动最容易翻车的五个地方

4.1 现象:屏收到帧但不执行,串口助手看发送正常

原因:CRC 算错了。迪文的 CRC 只对长度、指令、数据域做累加取反加一,不含帧头。很多人习惯性把帧头也算进去,或者用了标准 CRC16。

解决:拿串口调试助手抓一帧已知正确的指令,手动算一遍 CRC 对比。我一般会在代码里加一个div_crc的单元测试,喂几个已知帧验证。

4.2 现象:触摸上报偶尔丢帧,快速滑动时尤其明显

原因:环形缓冲太小,或者中断里处理太慢。115200 波特率下,一秒最多 11520 字节,触摸突发时缓冲瞬间被填满。

解决:缓冲开到 512 字节以上,中断里只做搬运不做解析。如果还丢,检查中断优先级,串口中断别被其他高优先级中断打断太久。

4.3 现象:换到 GD32 后屏不响应,STM32 上正常

原因:GD32 的串口波特率寄存器配置和 STM32 有细微差异,尤其是小数分频部分。或者 GPIO 复用功能没配对。

解决:先用串口调试助手确认 GD32 能正常发数据,再查波特率误差。GD32 的库函数和 STM32 不完全兼容,别直接复制。

4.4 现象:读变量时收到响应,但数据域全是 0

原因:变量地址写错了。迪文屏的变量地址是屏内部地址,不是 MCU 内存地址,写错地址屏会回 0。

解决:用迪文官方的 DGUS 工具确认变量地址,或者发读版本指令确认通信正常后再读变量。

4.5 现象:ESP32-S3 上串口接收数据断断续续

原因:Arduino 框架的串口缓冲默认只有 256 字节,且Serial.read()在 loop 里轮询,速率一高就丢。

解决:改用 ESP-IDF 的 uart driver,配事件队列和环形缓冲。或者把Serial.setRxBufferSize调大,但治标不治本。

5. 进阶:用变量地址映射表把驱动变成「配置化」

5.1 变量地址映射表的设计

驱动跑通之后,最烦的是每次改 UI 都要改代码里的地址。我一般会做一张变量地址映射表,用宏或者结构体把「变量名」和「地址」绑起来。比如:

typedef struct { const char *name; uint16_t addr; uint8_t len; } var_map_t; static const var_map_t var_table[] = { {"temp_value", 0x1000, 2}, {"humidity", 0x1002, 2}, {"set_point", 0x1004, 2}, {"run_status", 0x1006, 1}, };

这样上层业务代码只认变量名,不认地址。改 UI 时只改这张表,驱动层和业务层都不用动。这张表还可以配合迪文官方的 DGUS 工具导出的配置文件自动生成,省得手抄地址抄错。

5.2 用回调注册机制处理触摸事件

触摸事件不要写死在驱动里,用回调注册。每个触摸控件地址对应一个回调函数,驱动收到触摸上报后查表调用。这样业务层想加什么交互就加什么,驱动层不用改。

typedef void (*touch_cb_t)(uint16_t addr, uint8_t pressed); static struct { uint16_t addr; touch_cb_t cb; } touch_handlers[16]; void touch_register(uint16_t addr, touch_cb_t cb) { for (int i = 0; i < 16; i++) { if (touch_handlers[i].addr == 0) { touch_handlers[i].addr = addr; touch_handlers[i].cb = cb; return; } } } void touch_dispatch(uint16_t addr, uint8_t pressed) { for (int i = 0; i < 16; i++) { if (touch_handlers[i].addr == addr && touch_handlers[i].cb) { touch_handlers[i].cb(addr, pressed); return; } } }

参数说明:touch_handlers数组开 16 个,一般 UI 不会超过 16 个触摸控件,不够就加大。touch_register在初始化时调用,touch_dispatch在协议层解析到触摸帧后调用。这样业务层只写回调函数,驱动层完全通用。

5.3 验证驱动稳定性的土办法

驱动写完别急着上产品,先做两个测试。第一个是压力测试:用脚本每秒发 100 帧写变量指令,连续跑一小时,看有没有丢帧或死机。第二个是触摸测试:手指在屏上快速画圈,持续五分钟,看触摸上报有没有丢。这两个测试能过,基本就稳了。

我一般还会在驱动里加一个统计计数器,记录收帧数、CRC 错误数、缓冲溢出数,跑测试时打印出来。CRC 错误数不为零说明有干扰或波特率不匹配,缓冲溢出数不为零说明缓冲太小或中断太慢。这些计数器平时不占资源,出问题时就是黑匣子。

最后说个习惯:每次换新平台,先把硬件层的三个函数实现完,然后用 PC 上的协议层测试用例跑一遍,确认协议层没问题再上板子。这样能把「协议问题」和「硬件问题」分开,省得两头查。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询