
简介面向AVR平台的迪文DWIN串口TFT屏通用驱动程序以标准C语言实现具备良好的可读性与可移植性配套两个文件dwin-display.c负责屏幕初始化、点线矩形圆绘制及文本显示等核心功能dwin-display.h则定义屏幕尺寸、颜色深度等配置结构并开放简洁API开发者只需调用dwin_init、dwin_draw_pixel、dwin_print_string等接口即可完成显示控制无需纠结底层时序。压缩包采用rar格式共2个文件1个h、1个c整体仅3KB体量极小、便于直接嵌入AVR工程。该驱动兼容多种迪文屏型号与分辨率能适配工业控制、智能家居、物联网等场景同时通过阅读源码还可掌握迪文屏SPI/UART串口通信的参数配置及GPIO连接方法为后续性能优化和低功耗设计提供参考。目前已有831人学习下载适合嵌入式开发者快速上手。1. 迪文串口TFT屏通用驱动从改三次串口解析到一套代码通吃前两年做一个产线设备仪表项目主控从某51单片机换到STM32又因为芯片供应问题换到国产GD32每次换主控显示部分的串口解析都要从头改一遍。屏幕始终没换都是迪文DWIN的串口TFT但每版代码里拼帧、拆帧、等返回的逻辑都和业务混在一起越改越不敢动。后来我把这块屏的通信单独抽成一套通用驱动传输层只收发字节协议层只解析帧应用层只写业务。换主控时只改两个端口函数页面和变量地址一行不用动。如果你正在做工业HMI、仪器仪表、设备面板被迪文屏的DGUS协议、变量字节序和返回帧解析折磨过这套驱动和它的拆分思路可以直接抄走。2. 架构与目录设计把驱动拆成传输、协议、应用三层2.1 为什么必须分三层迪文屏本质上是带DGUS操作系统的显示终端。屏幕内部的串口接收、帧校验、字库和FLASH存储全部封装在屏里主控能控制的只有串口上的一帧一帧字节。所以驱动能不能复用取决于“发字节”“帧长什么样”“收到帧之后干什么”这三件事有没有被拆开。我见过比较多反例是直接把屏幕通信写在业务里测温函数里拼一个数组塞给串口按键回调里再写一段解析。页面一多全局数组和临时标志位缠在一起排查一个乱码要打好几个断点。分层之后驱动和MCU外设解耦也和应用业务解耦这是整个驱动设计的底线。顺带说一句回调不能放到串口中断里直接执行。因为回调里一旦出现耗时操作比如写日志、翻转继电器中断响应时间就会被拖长后续字节跟着丢。所以整个驱动的原则是中断只入FIFO业务回调全部放到 dwin_poll 里执行。这么做还有个衍生好处将来接RTOS时可以把 dwin_poll 单独放一个任务用户不用关心临界区。2.2 目录结构与模块定位驱动源码内部是这样组织的dwin_drv/ ├── dwin_config.h // 帧格式、波特率、校验开关、超时参数 ├── dwin_port.h // 移植层接口声明 ├── dwin_port.c // 串口收发、毫秒时间戳换平台只改这里 ├── dwin_fifo.c // 环形缓冲区串口中断到解析器的中转 ├── dwin_proto.c // 帧组装、帧解析状态机、校验 ├── dwin_core.c // 对外API、回调表、事件分发 └── demo/ ├── demo_stm32_uart.c └── demo_rtos_thread.cdwin_config.h 是整个驱动的参数总闸。帧模式旧版AA帧还是新版5A A5帧、是否启用校验、波特率、接收空闲超时时间都放在这里调屏幕型号时第一件事就是改这个头文件。dwin_fifo.c 是典型的环形缓冲区串口中断只做一步操作把字节压进FIFO主循环里的 dwin_poll() 再从FIFO取字节喂给状态机。这个设计保证中断里没有耗时逻辑也不丢字节。dwin_proto.c 是这个驱动最核心的文件。它负责把“写变量、读变量、写文本、跳页”请求拼成符合屏幕协议的一帧同时把屏幕返回的原始字节流还原成带指令号、地址、数据的数据包。dwin_core.c 站在更上层对外暴露简洁的API比如 dwin_write_uint16(0x1000, val, 1)内部先查配置再调协议层拼帧最后经端口层发出去。这样换屏幕型号时应用层调用的接口可以完全不变。2.3 对外接口设计6个函数覆盖90%场景dwin_core 对外提供的核心API如下表函数作用关键参数说明dwin_init初始化协议栈、清空回调表和FIFO无dwin_poll主循环周期调用处理FIFO中的字节无dwin_write_uint16向变量地址写1到N个字起始地址、数据指针、字数dwin_read_uint16向屏幕发出读变量请求起始地址、字数dwin_set_text给文本控件写入字符串地址、字符串、长度dwin_register_callback注册返回帧与按键上报回调事件类型、回调函数指针dwin_write_uint16 是使用频率最高的函数。温度、速度、计数值这类数据在DGUS工程里绑定到一个整数数据变量显示控件驱动往对应的变量地址写一个字屏幕自动刷新。dwin_set_text 适合给标签控件写单位或状态文字它内部会把字符串放到帧数据区。这里要多留一个心眼中文字符串必须按屏幕烧录时的编码一般是GB2312发送否则显示乱码。dwin_read_uint16 是异步的发出请求之后不能立刻拿到结果数据要等屏幕返回帧到了、回调被触发后才能拿到。这个异步语义很多第一次接触的人会踩坑所以驱动里专门做了回调注册机制。2.4 数据流向一次dwin_write_uint16调用经历了什么以写一个变量为例完整链路是应用层拿到数值 val125调用 dwin_write_uint16(0x1000, val, 1)dwin_core 把 125 拆成两个字节高位 0x00 在前、低位 0x7D 在后dwin_proto 的帧组装函数把这个数据连同指令码 0x82、地址 0x1000 拼成一帧最后 dwin_port_send 把整帧发给屏幕。由于串口是流式的所以高位先发。接收方向是异步的。屏幕收到读变量请求后返回一帧包含变量值的数据串口中断把字节塞进FIFO主循环调用 dwin_poll 时逐字节解析完整帧解析出来之后按指令号匹配回调。所以 dwin_poll 必须被周期性调用这一点恰恰是移植时最容易被忽略的只在初始化时调一次接收永远不会触发。在RTOS环境下dwin_poll 适合放在一个独立线程里循环调用用户回调也在该线程执行不会阻塞中断也不会和业务线程抢串口。3. 核心模块拆解帧组装、状态机解析与回调分发3.1 帧格式新旧两套协议并存迪文屏协议在不同固件版本间差异不小驱动里遇到最多的是两种旧版以 0xAA 开头的帧和新版以 5A A5 开头的帧。两者都是“帧头长度指令变量地址数据校验”的结构但长度字段包含的范围不同校验方式也可能不同。驱动通过编译期宏选择帧模式/* dwin_config.h */ #define DWIN_FRAME_MODE_NEW 1 /* 1: 5A A5帧; 0: 旧版AA帧 */ #define DWIN_FRAME_LEN_INCLUDE_CMD 1 /* 长度字段是否包含指令本身 */ #define DWIN_FRAME_TAIL_SUM 1 /* 帧尾是否附带求和校验 */先看 5A A5 模式的帧结构一个完整的写变量帧是这样字段字节数说明0x5A 0xA52帧头长度2从指令字节到数据末尾的字节数小端指令10x82写变量、0x83读变量、0x84写文本地址2DGUS变量地址高字节在前数据N实际数据校验1对指令、地址、数据求和取低字节需要提醒的是具体型号的固件对长度字段的含法、校验形式可能有差异。拿到屏之后要先看它随附的DGUS协议手册再决定 DWIN_FRAME_LEN_INCLUDE_CMD 置1还是置0。这个配置对不上时通常就是手册和代码没统一。3.2 发送端可变长帧组装函数帧组装函数是发送侧的核心。它把指令、地址、数据三个部分拼成完整帧并自动处理长度字段和校验。uint16_t dwin_build_frame(uint8_t *buf, uint8_t cmd, uint16_t addr, const uint8_t *data, uint16_t data_len) { uint16_t i 0; #if DWIN_FRAME_MODE_NEW buf[i] 0x5A; /* 帧头 */ buf[i] 0xA5; #else buf[i] 0xAA; #endif uint16_t len 0; len 1; /* 指令占1字节 */ len 2; /* 地址占2字节 */ len data_len; /* 数据区 */ if (DWIN_FRAME_LEN_INCLUDE_CMD) { /* 长度从指令开始算上面len已经包含指令 */ } else { len - 1; /* 长度不包含指令 */ } len DWIN_FRAME_TAIL_SUM ? 1 : 0; /* 校验字节计入长度 */ #if DWIN_FRAME_MODE_NEW buf[i] len 0xFF; /* 长度低字节在前 */ buf[i] (len 8) 0xFF; #else buf[i] len 0xFF; /* 旧版按单字节长度处理 */ #endif buf[i] cmd; /* 指令 */ buf[i] addr 8; /* 地址高字节 */ buf[i] addr 0xFF; /* 地址低字节 */ uint8_t sum cmd (uint8_t)(addr 8) (uint8_t)(addr 0xFF); for (uint16_t j 0; j data_len; j) { sum data[j]; buf[i] data[j]; } if (DWIN_FRAME_TAIL_SUM) { buf[i] sum; /* 求和校验 */ } return i; /* 返回整帧长度 */ }这段代码最需要注意的地方是长度字段的计算。它把指令、地址、数据三部分相加再根据宏决定是否去掉指令字节、是否加上校验字节。如果你在实测中发现屏幕对长度字段理解不一致问题几乎都出在这三个宏的组合上。数据区按顺序拷贝后校验字节使用最朴素的求和取低字节如果屏幕手册要求CRC只需把这一段替换成CRC函数。3.3 接收端状态机拆帧而不是等完整中断接收侧我习惯用一个轻量状态机而不是在串口中断里攒数组。状态机的优势是随时可以从噪声中重新同步到帧头不需要额外空闲定时器去判断“一帧结束了”。typedef enum { ST_WAIT_5A 0, ST_WAIT_A5, ST_WAIT_LEN_L, ST_WAIT_LEN_H, ST_WAIT_CMD, ST_WAIT_DATA } dwin_parse_state_t; typedef struct { dwin_parse_state_t state; uint16_t len; /* 帧体总长度含指令与数据 */ uint16_t idx; /* 已接收字节数 */ uint8_t buf[64]; /* 帧体缓冲 */ uint8_t cmd; uint32_t last_byte_tick; /* 上次收到字节的时间戳 */ } dwin_parser_t;解析函数逐字节输入每收到一个字节驱动状态机往前步进uint8_t dwin_parse_fsm(dwin_parser_t *p, uint8_t ch) { switch (p-state) { case ST_WAIT_5A: if (ch 0x5A) p-state ST_WAIT_A5; break; case ST_WAIT_A5: p-state (ch 0xA5) ? ST_WAIT_LEN_L : ST_WAIT_5A; break; case ST_WAIT_LEN_L: p-len ch; p-state ST_WAIT_LEN_H; break; case ST_WAIT_LEN_H: p-len | (uint16_t)ch 8; p-idx 0; p-state ST_WAIT_CMD; break; case ST_WAIT_CMD: p-cmd ch; p-buf[p-idx] ch; p-state ST_WAIT_DATA; break; case ST_WAIT_DATA: p-buf[p-idx] ch; if (p-idx p-len) { /* 收满整个帧体 */ p-state ST_WAIT_5A; return 1; } break; default: p-state ST_WAIT_5A; break; } return 0; }这里需要特别说明 ST_WAIT_CMD 和 ST_WAIT_DATA 的区别。ST_WAIT_CMD 只收一个字节也就是指令码ST_WAIT_DATA 则继续收剩余的帧体字节包括地址和数据。当 idx 累加到等于 len 时说明一帧完整到达此时返回1dwin_poll 会把帧体交给回调分发函数。如果中途插入噪声字节状态机会回到 ST_WAIT_5A 重新找帧头不会把错位数据当成有效帧。注意帧体缓冲 buf 默认64字节如果屏幕有超过60字节数据的返回帧记得把数组加大否则会越界覆盖。3.4 超时复位给状态机一条后悔药状态机虽然能自动找回帧头但有一种情况很让人抓狂屏幕回帧还没收完线缆抖了一下从此后续所有字节都错位。所以我加了一个接收超时机制。dwin_poll 每次被调用时会顺便检查当前帧是否太久没有新字节到达超时就强制复位。void dwin_poll(void) { dwin_parser_t *p g_parser; uint32_t now dwin_port_get_tick_ms(); if (now - p-last_byte_tick DWIN_RX_TIMEOUT_MS) { p-state ST_WAIT_5A; /* 超时状态机回到等帧头 */ p-idx 0; } while (!dwin_fifo_is_empty(g_fifo)) { uint8_t ch dwin_fifo_pop(g_fifo); p-last_byte_tick dwin_port_get_tick_ms(); if (dwin_parse_fsm(p, ch)) { dwin_dispatch(p); } } }超时时间我一般设5ms因为屏幕在115200波特率下发送几十字节的返回帧耗时都在1ms以内5ms足够宽裕又不会让噪声长时间影响状态机。移植到不同平台时dwin_port_get_tick_ms 是唯一需要新增的时间函数很多MCU的SysTick就能提供。这个“超时复位”解决的是串口屏项目里非常典型的玄学问题线缆质量不好时明明引脚没接错程序却间歇性收不到回调。3.5 回调分发把帧体翻译成业务语义完整帧进入 dispatch 后还需要从协议层视角翻译成业务事件。驱动维护一张回调表调用方可以针对不同事件注册处理函数typedef struct { uint8_t cmd; uint16_t addr; uint8_t *data; uint16_t len; } dwin_pkt_t; typedef void (*dwin_callback_t)(const dwin_pkt_t *pkt); static dwin_callback_t s_cb_table[8]; void dwin_register_callback(dwin_evt_id_t evt, dwin_callback_t cb) { if (evt 8) s_cb_table[evt] cb; } static void dwin_dispatch(dwin_parser_t *p) { dwin_pkt_t pkt; pkt.cmd p-buf[0]; pkt.addr (uint16_t)((p-buf[1] 8) | p-buf[2]); pkt.data p-buf[3]; pkt.len p-len - 3; /* 从总长度中去掉指令和地址 */ if (pkt.cmd DWIN_CMD_READ_ANSWER s_cb_table[DWIN_EVT_READ]) { s_cb_table[DWIN_EVT_READ](pkt); } }调用方使用时只需要关心“某个事件到了我该做什么”而不用关心帧是怎么收上来的。比如屏幕上的按键控件在DGUS工程里配置好上报功能后按下时屏幕会主动回一帧驱动解析出地址和值触发 DWIN_EVT_KEY 回调应用层在回调里判断地址属于哪个按键再执行对应逻辑。4. 运行指南从移植到点亮第一块屏4.1 移植层只改两个半函数驱动把和硬件相关的部分收拢在 dwin_port.c 里换平台时只需要实现四个简单函数初始化、发送字节数组、接收一个字节、获取tick。/* dwin_port.h */ void dwin_port_init(uint32_t baud); void dwin_port_send(uint8_t *buf, uint16_t len); uint8_t dwin_port_get_char(void); /* 从FIFO取一个字节无数据返回0 */ uint32_t dwin_port_get_tick_ms(void); /* 毫秒时间戳 */以STM32为例串口初始化用USART1115200波特率8位数据、无校验、一位停止位。发送函数就是循环写发送寄存器void dwin_port_send(uint8_t *buf, uint16_t len) { for (uint16_t i 0; i len; i) { while (!LL_USART_IsActiveFlag_TXE(USART1)); LL_USART_TransmitData8(USART1, buf[i]); } }接收方向不是轮询要走中断。串口中断里只做一件事把收到的字节压进FIFOvoid USART1_IRQHandler(void) { if (LL_USART_IsActiveFlag_RXNE(USART1)) { uint8_t ch LL_USART_ReceiveData8(USART1); dwin_port_on_rx_byte(ch); /* 内部就是 fifo_push */ } }dwin_port_get_char 则从同一个FIFO取字节。连接屏幕时三根线必须共地屏幕TXD接MCU的RXRXD接MCU的TX如果屏幕是5V逻辑而MCU是3.3V需要电平转换否则长时间运行可能出现随机错误帧。这个细节在项目现场踩了不止一次。注意屏幕供电别和主控共用带强干扰的电源电机启停时屏幕容易重启串口也会跟着丢数据。4.2 最小跑通让屏幕显示一个每秒变化的数驱动移植完成后最小可运行工程非常短。前提是你在DGUS软件里建好工程把某个数据变量显示控件绑定到变量地址 0x1000并把工程下载到屏幕。然后主循环里这样写#include dwin_drv.h int main(void) { dwin_port_init(115200); dwin_init(); uint16_t counter 0; while (1) { counter; dwin_write_uint16(0x1000, counter, 1); dwin_poll(); hal_delay_ms(1000); } }这段代码干的活就是每秒把 counter 的值通过串口写到0x1000地址屏幕上的数据变量显示控件读到新值后刷新显示。如果0x1000被当成字节地址而不是字地址显示就会错乱因为DGUS的变量地址是字地址一个地址对应两个字节。跑通这一步证明通信链路没问题。接下来再试读变量、写文本、处理按键上报就水到渠成。注意主循环里不能没有 dwin_poll()否则接收方向的按键上报和读变量返回永远不处理。4.3 先离线验证再上驱动代码每次拿到一块新屏我建议先别直接烧驱动先用USB转TTL加串口助手在PC上把屏幕的协议摸清楚。操作步骤是USB转TTL的TXD接屏幕RXDRXD接屏幕TXD共地串口助手选HEX发送波特率先设115200手动发一条读版本号指令观察返回帧内容。读版本号指令一般是 0x80 或 0x81不同固件定义不同。完全可以在串口助手先输入5A A5 04 80 00 00 00 01如果这条指令格式正确屏幕会回一帧包含固件版本的报文。这里的帧长度和校验要和屏幕手册逐字节核对核对通过后再把这套格式用到驱动配置里。很多人跳过了这个验证步骤直接拿驱动代码烧进去然后花两小时怀疑是波特率还是电平问题其实问题只是长度字段差了一个字节。4.4 运行配置参数一览配置项推荐值说明波特率115200或屏幕实际设置屏幕上的OS参数也要一致DWIN_FRAME_MODE_NEW按屏固件选新版固件用1DWIN_RX_TIMEOUT_MS5空闲超时状态机复位用FIFO大小128字节大于最长返回帧校验方式求和或按手册改不匹配时帧会被解析成噪声还有一个常见的误解是波特率越高越好。其实屏幕DGUS在处理某些长数据时发送间隔比波特率更影响稳定性。波特率拉到921600看上去很酷但批量写变量时丢帧概率明显上升因为屏幕端解析速度跟不上。工控场景我一般稳定在115200帧间隔留够比硬上高频更可靠。5. 避坑指南串口屏调试中的高频踩坑记录5.1 屏幕上电白屏串口收发毫无反应现象程序烧进去屏幕白屏或停在开机页串口助手发任何指令都不回显。原因最常见的是波特率不一致。屏幕内部OS和DGUS配置里的波特率与主控串口初始化不一致有的固件默认9600有的默认115200还有一部分屏在上电时必须先收到正确的帧头才开放通信。解决先用串口助手从9600到921600逐个波特率发送读版本指令看哪个波特率能收到返回帧。确认后再改 dwin_config.h 里的波特率配置并把屏幕端配置一并改到一致。如果是新屏可以在DGUS软件里检查工程和OS的波特率设置。5.2 显示正常但批量写变量时偶尔丢数现象单独写一个变量没问题一次性写十几个变量总有一两个变量没有被刷新。原因连续的帧间隔太短屏幕DGUS解析来不及处理。串口发送虽然是流式的但屏幕端需要时间把收到的帧解析、写变量、刷新显示尤其图库较大的页面处理耗时更长。解决驱动里在每帧发送之间加2到5ms延时并且把相邻地址的写入合并成一次写多个字。迪文屏的变量地址是连续的0x1000到0x1007这8个地址可以一次帧写8个16位字网络开销小很多也比分帧发送稳定。我在驱动里封装了按地址段批量写的接口遇到需要整块刷新的仪表盘数据时切到批量写。5.3 读回来的变量值和屏上看到的不一致现象屏幕上显示125驱动读回来的数值却是32000或者错开2个字节。原因DGUS变量地址是16位字地址读一个变量返回一个字两字节高字节在前。如果驱动按字节去解析比如把返回的两个字节当成两个8位数据就会错位。另一个常见原因是地址计算错误0x1000到0x1002差两个地址实际上是两个字中间隔4个字节。解决读变量时明确按字来申请数据解析统一用 uint16_t 数组高位在前。我在驱动里读变量回调用的是 uint16_t 指针不提供按字节解析的接口从源头避免这个错误。另外DGUS工程里变量地址通常从0x1000往上分配建议在项目文档里建一张变量地址分配表不然过两周就忘了0x1000是哪一路温度。5.4 状态机被干扰帧卡住回调忽然不再触发现象系统运行几小时后按键回调彻底不响应重新上电又好了。原因串口线或电源质量差导致某个噪声字节破坏了正在解析的半帧。状态机没有超时复位就一直卡在等数据的错误状态后续任何帧都跟不上。解决驱动里增加接收超时复位就是前面 dwin_poll 里那段逻辑超过5ms没有新字节强制回到等帧头状态。这个功能一定要在量产版本里保留否则现场电源波动一次机器就得断电重启这是工业环境里最典型的玄学故障之一。5.5 用DMA接收数据错乱中断逐字节模式却正常现象把串口接收改成DMA加空闲中断后返回帧偶尔顺序错乱甚至出现帧头被误判成数据。原因最常见的是调试时自发自收。USB转TTL的TXD和RXD接反或者把主控的TX接到了调试串口上导致主控自己发送的字节被DMA收进来。DMA方式和中断方式的区别在于中断模式会过滤掉一部分自发字节而DMA会把所有字节都收进缓冲区问题暴露得更明显。解决先确认物理接线三线共地DMA调试阶段在接收路径上加一个环回抑制发送期间关闭接收方向发送完成后恢复。如果屏幕和主控电平不匹配也会出现类似现象这时需要加电平转换电路而不是调大波特率。6. 二次开发从通用驱动长出自己的业务专属驱动6.1 扩展指令映射表驱动把指令号和业务语义解耦之后新增屏幕功能只需要扩展一张指令映射表和一个分发分支。我习惯把屏幕所有可用指令集中定义在一张表里typedef struct { uint8_t cmd; const char *name; } dwin_cmd_map_t; static const dwin_cmd_map_t s_cmd_map[] { { 0x82, write_var }, { 0x83, read_var }, { 0x84, write_text }, { 0x90, jump_page }, { 0x00, NULL } };增加新功能时比如要支持屏幕固件里的曲线数据上传只需要在表里加一条{ 0x91, upload_curve }然后在 dispatch 里加一个路由分支。好处是新人接手时扫一眼这张表就能知道驱动支持哪些操作不用翻代码。6.2 把驱动搬进RTOS线程如果项目里用FreeRTOSdwin_poll 可以放进一个独立线程接收中断仍然只负责字节进FIFOvoid dwin_task(void *arg) { for (;;) { dwin_poll(); osDelay(1); } }发送路径需要保护。驱动内部组装帧用的是静态缓冲跨线程调用时两个线程可能同时往缓冲区里写导致帧头和数据错开。我的做法是对 dwin_send 加一个互斥锁把“组装加发送”作为一个临界区。接收侧不受影响因为FIFO是单生产者单消费者模型中断是唯一生产者dwin_poll 是唯一消费者。6.3 验证方法先回环后联调二次开发每次改完协议层我都先做回环测试不接屏幕把主控的TXD直接接到自己的RXD自发自收一帧验证组装和解析能对上。这一步能分辨问题是出在驱动还是屏幕上。回环通过后再接屏幕跑一遍变量读写和全部回调。从那以后我每换一个屏型号第一件事永远是串口助手手动发读版本指令确认帧格式再动驱动代码这套流程帮我省掉的调试时间远比想象中多。希望这些拆分和调试习惯能帮到你。本文还有配套的精品资源点击获取