简介:这是一份针对AVR平台的迪文串口TFT屏通用驱动程序,面向嵌入式开发者,解决在项目中快速集成迪文科技串口屏的显示与控制问题,适用于工业控制、智能家居及物联网等场景。资源压缩包内仅包含2个文件,含1个头文件和1个C源文件,整体约3KB,结构非常精简。C源文件实现了屏幕初始化、点线矩形圆形绘制与文本显示等核心逻辑,头文件则定义了配置结构体并声明对外API,如dwin_init、dwin_draw_pixel、dwin_print_string,调用起来清晰直接,方便上层应用快速开发。资源目前已有831人学习下载,对于需要在AVR平台上驱动迪文屏的开发者而言,这份采用标准C语言实现的驱动可以直接复用,代码可读性与可移植性较好;配合UART/SPI通信参数和GPIO连接要点,还能快速适配不同型号与分辨率的屏幕,帮助开发者将更多精力集中在应用逻辑而非底层显示细节上。
1. 不用驱动、直接写帧:串口屏只会回答懂协议的人
拿到一块迪文(DWIN)串口TFT屏,第一反应大多是先用串口助手发几个字节试试,能收到字库和页面之后才发现:真正的难点不是屏幕,而是让MCU和这块屏的DGUS协议对上话。很多开发者卡在同一个地方——屏的资料文档里讲了控件怎么配、页面怎么排,但串口协议的数据帧指令散落在不同章节,读起来像挤牙膏。我拆过不少串口屏项目,确认一件事:迪文屏本质上是一个黑匣子,你发给它的每一帧数据它都会回,但能不能正确响应,取决于你对指令格式、变量地址和校验算法的理解是否精确。这篇把驱动拆成「串口帧解析、变量读写、页面控制、应用层坑点」几个层面,配合可复用的驱动代码和调试思路,让这块屏不再玄学。
2. 先确认协议版本和串口参数:帧格式不对,后续全白写
迪文屏的串口协议在不同型号和固件版本上存在明显差异,最典型的区别是DGUS一代的旧指令集和新固件的指令集。驱动代码能不能通用,第一步不是写代码,而是确认屏的协议版本。
2.1 快速区分旧核和新核:看指令头
迪文屏的串口帧有两类常见形态。老一代的指令帧基本以 0xAA 作为帧头,后面跟着数据长度、命令字、数据和校验;新固件或部分型号会使用 0xA5 开头、双字节地址解析的格式。如果你手头已经有出厂配置,最快确认方式是用串口助手手动发一帧“读版本号”的指令,看返回帧的开头字节是 0xAA 还是 0xA5。
我在正式写驱动前,一般先查资料确认CPU内核版本。老核是 T2/T3 这一类,新核是 T5 这类,两者的帧格式差别很大。如果手头资料不完整,还有一种笨办法:用串口助手直接发指令,返回帧的帧头能区分大部分情况。以下是我常用的探测帧:
// 探测帧:读系统版本号(老核常见格式) uint8_t probe_frame[] = {0xAA, 0x03, 0x01, 0x00, 0x04};这段探测命令的含义是:帧头 0xAA,数据长度 0x03(从命令字到结束共 3 个字节),命令字 0x01 表示读版本号,后面的 0x00 和 0x04 是具体参数,最后加校验字节。把这段数据通过串口发出后,正常返回的帧头也会是 0xAA,然后跟着模块信息。如果返回的是 0xA5 开头,那就要换一套新核的指令集。注意,有的屏出厂时默认开启握手流程,盲发探测命令后屏不会响应,这时可以尝试先发一条空帧或者读完校准数据后再探测。
2.2 串口参数:别被默认波特率骗了
迪文屏的波特率不是硬件固定的,而是由工程配置时写进存储区域的出厂参数决定。常见的默认波特率有 115200 和 9600 两种,偶尔能遇到 57600。看到这里你可能会想“那驱动里把波特率配成 115200 就行”,但现实是所有翻车现场里,串口参数不匹配的占比最高。
我一般先在工程配置里确认组态软件里设置的波特率,如果没法确认,就直接在串口助手里用不同波特率轮询发送读版本号指令,能收到有规律响应时基本就锁定了。锁定波特率后,驱动代码里的串口初始化必须和屏端配置保持一致。下面是 STM32 HAL 库驱动里我常用的串口初始化片段:
void dwin_uart_init(UART_HandleTypeDef *huart) { // 波特率可通过宏配置,DIWIN_BAUDRATE 默认 115200 // 数据位 8,无校验,1 位停止位(8N1) // 迪文屏默认帧格式就是 8N1,这个极少变化 huart->Instance = USART1; huart->Init.BaudRate = DWIN_BAUDRATE; huart->Init.WordLength = UART_WORDLENGTH_8B; huart->Init.StopBits = UART_STOPBITS_1; huart->Init.Parity = UART_PARITY_NONE; huart->Init.Mode = UART_MODE_TX_RX; huart->Init.HwFlowCtl = UART_HWCONTROL_NONE; if (HAL_UART_Init(huart) != HAL_OK) { // 初始化失败时做本地标记,方便排查连线问题 dwin_status = DWIN_ERR_UART_INIT; } }这段代码里的参数说明:数据位、停止位和校验位基本不需要动,迪文屏默认就是 8N1;波特率建议通过宏定义统一管理,改屏端配置时只在头文件里动一个值。很多工程师在调试时直接改代码里的波特率,结果串口助手能通、屏程序死活不行,最后发现是屏的工程配置和驱动代码各写各的。我个人的习惯是:无论哪种屏工程,在固件里把波特率集中放在一个配置头文件里,整屏项目只允许在那一处修改,避免散落各处之后互相覆盖。
2.3 帧解析器的通用设计:先收后解,不要一个字节一处理
串口屏驱动最忌讳在接收中断里逐字节判断命令和拼帧。迪文屏返回的数据有时带前导字节,有时响应延迟几十毫秒,如果在中断里做复杂解析,很容易丢字节。我惯用的做法是维护一个环形缓冲区,接收中断只负责把字节存进去,主循环或定时器里再做帧解析。
#define DWIN_RX_BUF_SIZE 256 typedef struct { uint8_t data[DWIN_RX_BUF_SIZE]; uint16_t head; uint16_t tail; } dwin_ring_buf_t; void dwin_rx_isr(uint8_t byte) { uint16_t next = (dw_buf.head + 1) % DWIN_RX_BUF_SIZE; if (next != dw_buf.tail) { // 缓冲区未满时写入 dw_buf.data[dw_buf.head] = byte; dw_buf.head = next; } // 缓冲区满时丢弃新字节,同时置溢出标记供上层统计 }环形缓冲区的核心意义是把中断处理和协议解析解耦。接收中断里只做“放进缓冲区”这一个动作,时间开销极小;主循环中调用dwin_parse_frames()时,再按帧头、长度、校验的顺序去提取完整帧。这个结构在项目移植时几乎没有额外改动,换 MCU 只需要改一个字节的读取来源,不需要重写协议部分。特别是迪文屏在高波特率下连续返回大量数据(比如读整块曲线数据)时,如果中断里每收一个字节就调一次处理函数,几乎必然丢帧。
3. 变量读写是驱动的心脏:0x82 写、0x83 读的完整帧细节
很多教程直接把“写变量”理解成“往屏发一串数据短信”,听起来简单,实际上帧结构里藏着字节序、长度口径、地址范围三个坑。这章把读和写拆开讲清楚。
3.1 变量地址不是内存地址:先看 DGUS 变量区和系统寄存器区
迪文屏的变量地址可以理解为“屏内部数据的门牌号”。用户可用的变量地址一般从 0x1000 开始,而系统寄存器集中在 0x0000 到 0x0FFF 区间。比如页面号寄存器、背景光亮度、RTC 时间寄存器都在这个区。驱动设计里需要一个隔离层,把“业务变量”和“系统寄存器”分开管理,避免业务代码里误写系统区。
地址位宽是 16 位,帧里都是高字节在前、低字节在后,也就是大端序传输。但要注意,变量值的字节序反而是小端序居多。这两者经常被混淆,我在避坑章节里会再展开一次。
3.2 写变量帧拆解:0x82 指令的格式
写变量用的是 0x82 命令字,帧格式如下表:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 帧头 | 1 | 0xAA |
| 数据长度 | 1 | 从指令字节开始到数据结束的总字节数 |
| 指令字 | 1 | 0x82 |
| 变量地址 | 2 | 高字节在前,低字节在后 |
| 数据 | N | 要写入的变量值,按字小端序排列 |
| 校验 | 1 | 前面所有字节累加和取低八位 |
关键点在于数据长度字段的口径:它统计的是“命令字 + 地址 + 数据”的字节数,不包含帧头和校验字节。很多翻车现场就是把这个长度算成了纯数据长度,导致屏把后续数据当成帧头解析。写变量的驱动伪代码如下:
void dwin_write_variable(uint16_t addr, uint8_t *data, uint16_t len) { uint16_t i; uint8_t cs = 0; uint8_t frame[256]; // 数据长度 = 1(指令字) + 2(地址) + len(数据) frame[0] = 0xAA; frame[1] = 0x03 + len; frame[2] = 0x82; frame[3] = (uint8_t)(addr >> 8); // 地址高字节 frame[4] = (uint8_t)(addr & 0xFF); // 地址低字节 for (i = 0; i < len; i++) { frame[5 + i] = data[i]; } // 累加和校验:从帧头开始逐字节相加,只保留低八位 for (i = 0; i < 5 + len; i++) { cs += frame[i]; } frame[5 + len] = cs; dwin_uart_send(frame, 6 + len); }逻辑说明:函数把帧头、长度、指令、地址和业务数据按顺序填入缓冲区,最后计算校验累加和。这里len是实际数据字节数,对应到帧里就是数据区总字节数。参数说明:addr必须是 16 位地址;len的最大值受限于屏的单帧最大长度,一般不要超过 240 字节,如果业务上需要一次写入大块数据,建议分包写入,帧与帧之间加 5 到 10 毫秒延时。
3.3 读变量帧拆解:0x83 指令与返回帧解析
读变量用 0x83 命令字,帧里额外带一个“读取字数”字段,和 0x82 不太一样。读取的返回帧也不是你发什么它回什么,而是屏主动回一串实时数据。返回帧结构如下:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 帧头 | 1 | 0xAA |
| 数据长度 | 1 | 指令字+地址+长度+数据的总字节数 |
| 指令字 | 1 | 0x83 |
| 变量地址 | 2 | 原样返回请求地址 |
| 数据长度 | 1 | 返回的字数(不是字节数) |
| 数据 | N | 返回变量值,按字小端序 |
| 校验 | 1 | 累加和取低八位 |
这里最容易绕晕的是数据长度字段:0x83 返回帧里的数据长度既包含前面的指令字和地址,还包含“数据长度”字段本身,然后再加数据。对应驱动代码:
void dwin_read_variable(uint16_t addr, uint8_t word_cnt) { uint8_t frame[6]; uint8_t cs = 0; frame[0] = 0xAA; frame[1] = 0x04; // 指令字+地址+数据长度字段 = 4 frame[2] = 0x83; frame[3] = (uint8_t)(addr >> 8); frame[4] = (uint8_t)(addr & 0xFF); frame[5] = word_cnt; for (int i = 0; i < 6; i++) { cs += frame[i]; } frame[6] = cs; dwin_uart_send(frame, 7); }这段代码里,发送出去的请求帧长度是 7 字节,数据长度字段固定为 0x04,因为从指令字到数据长度字段正好四个字节。返回帧里会带上实际读到的数据,在解析返回帧时,不能假设返回的数据长度一定等于请求的字数——部分屏型号在地址越界时会返回空缺值或长度为 0 的帧,这需要在解析层做防御,至少判断返回帧长度和请求长度的一致性后再使用数据。
3.4 字符串变量的写入细节
迪文屏的文本控件通常通过变量区写入字符串,但字符串长度是“按字算的”。一个汉字占两个字节,算一个字;ASCII 字符占一个字节,但写之前要按字对齐。最常见的坑是把字符串的字节长度直接填入帧,结果屏上出现乱码或截断。驱动层面我会做一层转换接口:
void dwin_write_string(uint16_t addr, char *str) { uint16_t byte_len = strlen(str); uint16_t padded_len = byte_len; // 奇数长度时补一个空格字符实现字对齐 if (byte_len % 2 == 1) { padded_len = byte_len + 1; } uint8_t buffer[64]; memset(buffer, 0x20, padded_len); // 0x20 是空格 memcpy(buffer, str, byte_len); // 按字小端写入:迪文字符串在变量区按先低字节后高字节排列 dwin_write_variable(addr, buffer, padded_len); }这个接口的逻辑说明:它先统计实际字符串长度,再统一按字对齐填充空格,最后调通用写变量接口发送。参数说明:addr对应屏上文本控件绑定的数据变量地址,str是你要显示的字符串。这里的字对齐逻辑来自实践——迪文屏的文本控件在变量区寻址时按字计数,如果你的字符串长度是奇数,屏会从下一个字边界读取,导致最后几个字符错位或吞字。真正做产品项目时,还要确认屏端文本控件的“最大字符数”和“显示模式”是否和你写入的字节数匹配,这部分由屏端组态工程决定,驱动再怎么写也绕不开。
4. 控件交互与页面切换:让驱动从“发帧”变成“控制”
变量读写只是基础通道,真正让屏幕业务跑起来的是页面切换、控件联动、曲线刷新这些应用层动作。这一章把这些常用操作统一封装成驱动接口。
4.1 页面切换:写寄存器比按地址跳过更稳定
迪文屏的页面切换有多种方式,最常见的是把当前页面号写入系统寄存器 0x0084。这个寄存器可以在屏端配置控件跳转地址时绑定,也可以在外部通过串口直接写。它的数据格式是单字,写入的数值对应屏端组态工程里的页面 ID。
void dwin_set_page(uint16_t page_id) { uint8_t data[2]; data[0] = (uint8_t)(page_id & 0xFF); // 小端序低字节 data[1] = (uint8_t)(page_id >> 8); // 小端序高字节 dwin_write_variable(0x0084, data, 2); }逻辑说明:向系统寄存器 0x0084 写入目标页面 ID,底层调用通用写变量接口。我这里特意做了小端序,和前面讲的“地址大端序、数据小端序”规则保持一致。参数说明:page_id是从 0 开始计数的页面号,不是页面的名称字符串。需要注意,如果你用串口切换页面时屏没有响应,先确认工程里对应页面是否勾选了“允许外部切换”;有些版本固件要求页面切换时同时写 0x0084 和 0x0085 两个寄存器,0x0085 是返回页面号,项目里最好两个一起写,避免在某些机型上出现偶发跳页。
4.2 数据变量显示与录入控件的驱动思路
屏幕上的数字显示、文本输入框,在屏内部只是变量区的映射。数据变量显示控件绑定一个变量地址后,屏会自动更新显示;数据变量录入控件则是在用户点击后弹出键盘,输入结果也会写入对应变量地址。所以驱动层只需要做一件事——读和写这些变量地址。
这里有一个值得注意的边界:如果多个控件绑定同一个变量地址(比如一个数值同时用文本和曲线显示),只需要写一次地址,屏幕各个控件会同时刷新。驱动层不需要关心屏上有多少控件绑定这个地址,这既是好事也是坑——如果不小心把两个不同含义的控件绑在同一个地址上,数据会互相污染。
4.3 曲线和图标:把坐标和数据当成“寄存器”操作
曲线控件是迪文屏比较实用的功能,它内部维护一个数据缓冲区,外部可以通过写曲线通道的控制寄存器来追加数据。曲线控件的通道地址通常由组态工程配置,驱动没法完全通用,但有一个统一的写曲线数据的方式:找到曲线控件的 DAC 地址(通常在配置里显示),把数据连续写入即可。
void dwin_curve_append(uint16_t dac_addr, int16_t value) { uint8_t data[2]; // 曲线数据按字写入,有符号数直接按补码传 data[0] = (uint8_t)(value & 0xFF); data[1] = (uint8_t)((value >> 8) & 0xFF); dwin_write_variable(dac_addr, data, 2); }逻辑说明:曲线控件内部会按顺序采样更新,外部只需要把数值写入对应地址。参数说明:dac_addr是曲线通道的数据地址,value是有符号 16 位数。实际操作中发现,曲线通道的地址如果配置成自动增长,每次写入后屏内部的曲线游标会自动后移,所以驱动层不要重复写同一个地址,否则曲线会原地跳动而不是平滑前进。
4.4 RTC 同步:从外设读时间再写寄存器区
如果项目里要显示时间,屏自带 RTC 但掉电后时间可能不准。驱动层要做的就是从外部 RTC 读时间,然后把时间数据按 0x0090 起始的寄存器区写入。时间寄存器格式按“年、月、日、周、时、分、秒”排列,每个字段一个字节。
void dwin_sync_rtc(uint8_t year, uint8_t month, uint8_t day, uint8_t week, uint8_t hour, uint8_t min, uint8_t sec) { uint8_t time_data[7]; time_data[0] = year; // 例如 0x25 代表 2025 time_data[1] = month; time_data[2] = day; time_data[3] = week; time_data[4] = hour; time_data[5] = min; time_data[6] = sec; dwin_write_variable(0x0090, time_data, 7); }逻辑说明:RTC 寄存器从 0x0090 开始,7 个字节分别对应年月日周时分秒,写入后屏端时钟立刻更新,不需要额外指令。这组参数的顺序各家资料一致,但要注意年份寄存器存的是 BCD 码还是二进制。工程实践里,我见过把 0x25 直接当 25 年用的,也见过按 BCD 编码做0x25拆成2和5的,调试时先用屏上的时间显示控件对比一下写入值和结果,能省很多事。
5. 避坑指南:迪文屏驱动里最容易翻车的五个细节
这块屏的资料里有些细节写得模棱两可,实际项目里踩过一轮之后才发现是版本差异或字节序问题。把印象最深的几个坑记在这里,每条都是“现象、原因、解决”的结构。
5.1 数据长度字段到底算不算指令字
现象:写变量帧的结构明明和资料里一模一样,屏就是不刷新,串口助手上看屏返回了一个错误指令。
原因:数据长度字段的统计口径在不同型号上有差异。有的固件版本把“指令字+地址+数据”全部算进长度,有的则只算“地址+数据”,甚至还有把长度写成“数据的字节数+1”的,三种算法我都见过。
解决:写驱动时不要直接套一个死值。我一般把长度计算单独抽成一个宏,第一次联调时发一帧读变量的指令,看返回帧能否被正确解析——如果请求帧长度算错,屏通常不回任何帧;如果校验和错,屏回 0xAA 后直接丢弃后续数据。用这两种现象反推长度算法的口径,五分钟就能定位。
5.2 字节序:地址大端、数据小端,两个方向别混
现象:写入的变量值变成了“0x1234”和“0x3412”的区别,数字控件上看到完全错乱的值。
原因:变量地址在帧里是高字节在前,而变量数据在帧里是低字节在前。很多第一次接触迪文屏的开发者只记住了“大端序传地址”,就顺手把数据也按大端序排了。
解决:驱动层把地址和数据的字节序处理分开封装,分别在函数内部注释标注“ADDR_BE”和“DATA_LE”,不要混在一个处理函数里。遇到具体控件时,先在屏上放一个数字显示控件绑定 0x1000,然后手动写入 0x0001,如果屏显示 1 就说明序对了。
5.3 校验和:累加和不是异或,取模边界要一致
现象:驱动发的帧在官方调试软件上能通过,自己的屏幕程序死活不应答。
原因:迪文屏的校验是“累加和取低八位”,有些资料写成“校验和=所有字节逐位异或”,这是两个完全不同算法。如果驱动里用了异或,屏端做累加和校验必然不过。
解决:统一用累加和,且必须从 0xAA 帧头开始累加。注意,最终校验字节是只取低八位,不取反。不放心的话,发帧前在串口助手里用已知能过的帧对比校验字节,一算便知。
5.4 响应超时和帧粘连:读大块数据时不要连续发
现象:连续读多个变量地址时,返回的数据偶尔串位,前一次的返回帧跑到下一次的解析里。
原因:屏在收到读指令后并不是立刻返回,它内部有固定的处理周期,通常几毫秒到几十毫秒。驱动连续发送读指令,可能把上一次的返回帧夹在两次请求之间,造成帧粘连。
解决:读指令之间至少留 20 到 50 毫秒间隔;解析时以 0xAA 为新帧起点,万一遇到粘帧就丢弃从上一个帧头到下一个帧头之间的数据,不强求对齐。我一般会在驱动层维护一个简单的状态机,只有在“等待返回”状态时才允许发送下一条读指令。
5.5 变量地址越界:写了不存在的地址,屏会静默丢弃
现象:往某个“看起来合理的地址”写数据,没有任何报错,但屏就是不显示。
原因:工程组态里没配置对应的控件或变量区未初始化,地址超出当前工程的变量区范围。屏并不会像 MCU 那样产生硬件异常,只是静默丢弃帧。
解决:排查时优先确认屏端工程里确实有控件绑定了这一地址。一个实用的验证方法是:在屏端开一个数字显示控件,地址改成和要调试的地址一致,然后从驱动写一个固定值,看屏上的数值变不变。如果屏端显示变了而实际控件没反应,那就是你的业务逻辑里地址和控件不匹配,属于配置问题而非驱动问题。
6. 把驱动做成配置表:从“写死代码”到“通用件”
前面的代码能跑通一块屏,但还不算“通用驱动”。通用驱动的关键在于把地址、长度、校验规则拆成数据表,由项目配置文件驱动,而不是把业务地址散落在函数里。这一层的重构思路,是把屏端的“控件地址表”映射为驱动里的“数据点表”,写起来像这样:
typedef struct { uint16_t addr; uint16_t len; uint16_t offset; uint8_t active; } dwin_var_map_t; const dwin_var_map_t var_map[] = { {0x1000, 2, 0, 1}, // 温度值,2字节 {0x1010, 2, 1, 1}, // 目标温度,2字节 {0x1020, 1, 2, 1}, // 开关状态,1字节 {0x1030, 2, 3, 0}, // 预留,未启用 // 后续变量只追加表项,不改协议逻辑 };这个表的设计思路是:驱动代码只负责根据表的addr、len、active字段去发帧,业务层拿到的是offset索引,而不是裸地址。这样换成另一块屏工程时,只需要改这个表,不需要改动协议栈。实际项目中,我还会在这样的表上叠加一个“脏标记”机制:业务层先把要更新的值写进对应的数据缓冲,主循环统一处理所有脏条目,把多次零散写入合并成一次连续写地址。
最后一层是调试技巧:串口屏驱动出了问题,很多人直接上调试器,但屏那边看不到任何日志。我的习惯是在驱动里做一个“帧回放”缓冲,把发送出去和收到的每一帧按“方向+原始字节”存下来,出问题时一键导出到 PC 端串口助手比对。有一次项目联调,连续写变量偶尔丢更新,就是靠帧回放发现请求帧在特定节奏下会重复发送两次,屏端只响应第一次,第二次被忽略。从那以后我每次交付驱动前都会强制走一遍全量变量读写回放测试,把发送节奏和超时策略固化下来。希望这个习惯也能帮到你少踩几次坑。
本文还有配套的精品资源,点击获取