摘要 :本文基于 STM32H7 与迪文屏(DWIN DGUS)的联合开发实战,详细讲解如何利用 UART IDLE 中断 实现高效串口通信,如何设计状态机 管理多输入框交互,以及FreeRTOS 任务实现光标闪烁效果。文中包含完整的代码框架、踩坑记录与性能优化建议。
一、系统架构设计
1.1 硬件架构
-
主控:STM32H7 系列(480MHz,高性能场景)
-
显示屏:迪文屏 DGUS 串口屏(TTL 电平,115200bps)
-
通信:USART6 直连,使用 IDLE 中断机制
1.2 软件架构(分层设计)
┌─────────────────────────────────────────────┐
│ FreeRTOS 应用层 │
│ ├── vTask_CursorBlink (200ms 闪烁任务) │
│ └── 业务逻辑任务 │
├─────────────────────────────────────────────┤
│ 迪文屏协议层 (dealusart2.c) │
│ ├── 帧解析 (0x5A 0xA5 协议) │
│ ├── 状态机 (InputState) │
│ └── 发送封装 (Dealusart2_Sendfix) │
├─────────────────────────────────────────────┤
│ 硬件抽象层 │
│ ├── USART6 IDLE 中断驱动 │
│ └── RTC 实时时钟 (HAL 库) │
└─────────────────────────────────────────────┘
二、核心技术实现详解
2.1 IDLE 中断接收机制(高效串口通信)
痛点 :传统 HAL 库的 HAL_UART_Receive 在中断中使用阻塞方式(1000ms 超时),会导致系统卡顿甚至死机。
解决方案:直接操作寄存器,非阻塞方式:
void USART6_IRQHandler(void)
{
uint8_t receive_data = 0;
// RXNE 处理:直接读 RDR 寄存器(非阻塞)
if(__HAL_UART_GET_FLAG(&huart6, UART_FLAG_RXNE) != RESET) {
receive_data = (uint8_t)(huart6.Instance->RDR & 0xFF);
if(uart1_rx_len < UART1_RX_BUF_SIZE - 1) {
uart1_rx_buf[uart1_rx_len++] = receive_data;
}
}
// IDLE 中断:帧结束检测(关键!)
if(__HAL_UART_GET_FLAG(&huart6, UART_FLAG_IDLE) != RESET) {
__HAL_UART_CLEAR_IDLEFLAG(&huart6); // 必须立即清除标志
if(glrun && uart1_rx_len > 3) {
// 解析迪文屏协议帧...
}
uart1_rx_clear(); // 清空缓冲区准备下一帧
}
}
关键点:
-
IDLE 中断:当串口空闲超过 1 字节时间时触发,天然适合"帧结束检测"
-
零拷贝 :直接操作
RDR寄存器,避免 HAL 库的开销 -
双缓冲 :
uart1_rx_buf接收,g_uart_proc_buf处理(可扩展)
2.2 迪文屏协议解析引擎
迪文屏协议格式固定(帧头 0x5A 0xA5),但容易踩坑的是地址字节序(大端模式)。
// 帧解析核心逻辑(大端转小端)
if((*p0 == 0x5a) && (*(p0+1) == 0xa5)) {
cmdlen = *(p0+2); // 数据长度
cmd = *(p0+3); // 命令字(0x83 为触控返回)
// 地址解析(大端:高位在前)
tmp = (unsigned char *)&addr;
*tmp = *(p0+5); // 低字节
*(tmp+1) = *(p0+4); // 高字节(注意顺序!)
// 数据解析
tmp = (unsigned char *)&data;
*tmp = *(p0+8); // 低字节
*(tmp+1) = *(p0+7); // 高字节
}
踩坑记录 :曾误用 if((*p0=0x5a))(赋值而非比较),导致死循环,务必使用 ==。
2.3 多输入框状态机设计
时间设置界面包含 6 个输入框(年月日时分秒),采用状态机管理焦点:
typedef enum {
INPUT_NONE = 0,
INPUT_YEAR, // 4 位输入
INPUT_MONTH, // 2 位输入
INPUT_DAY, // 2 位输入
INPUT_HOUR, // 2 位输入
INPUT_MIN, // 2 位输入
INPUT_SEC // 2 位输入
} InputState;
volatile InputState g_active_input = INPUT_NONE;
volatile uint16_t g_input_buffer = 0; // 输入缓冲
volatile uint8_t g_input_digits = 0; // 已输入位数
输入处理逻辑(支持满位自动提交):
static void HandleDigitInput(uint8_t digit)
{
uint8_t max_digits = (g_active_input == INPUT_YEAR) ? 4 : 2;
if(g_input_digits < max_digits) {
g_input_buffer = g_input_buffer * 10 + digit;
g_input_digits++;
// 实时更新显示(迪文屏立即响应)
uint16_t vp_addr = VP_DISP_YEAR + (g_active_input - 1);
Dealusart2_Sendfix(vp_addr, g_input_buffer);
}
}
2.4 光标闪烁效果(FreeRTOS 任务)
光标采用图标变量(而非文本变量)控制,通过任务实现 200ms 周期性闪烁:
void vTask_CursorBlink(void *pvParameters)
{
while(1) {
vTaskDelay(pdMS_TO_TICKS(200));
if(g_active_input != INPUT_NONE) {
ToggleCursor(); // 在图标 0(隐藏)和 1(显示)间切换
}
// 页面切换延迟加载(解决时序问题)
if(g_time_page_entered) {
vTaskDelay(pdMS_TO_TICKS(150)); // 等待迪文屏页面就绪
SyncRTCVariables(); // 同步 RTC
LoadCurrentTimeToDisplay(); // 加载当前时间
HideAllCursors();
g_time_page_entered = 0;
}
}
}
设计亮点:
-
分离关注点:中断只负责接收,显示逻辑在任务中处理
-
延时加载:解决"页面切换时数据不加载"的时序问题(迪文屏页面刷新需要时间)
2.5 RTC 时间同步与显示
读取顺序陷阱 :STM32 RTC 必须先读 Time 再读 Date,否则日期会锁定在旧值。
static void SyncRTCVariables(void)
{
RTC_DateTypeDef sDate;
RTC_TimeTypeDef sTime;
// 关键:必须先 GetTime,再 GetDate!
HAL_RTC_GetTime(&hrtc, &sTime, RTC_FORMAT_BIN);
HAL_RTC_GetDate(&hrtc, &sDate, RTC_FORMAT_BIN);
rtc_year = sDate.Year + 2000; // 年份偏移量转换
rtc_month = sDate.Month;
// ...
}
static void LoadCurrentTimeToDisplay(void)
{
// 批量发送到迪文屏(VP 地址连续)
Dealusart2_Sendfix(VP_DISP_YEAR, rtc_year);
Dealusart2_Sendfix(VP_DISP_MONTH, rtc_month);
// ... 日时分秒
}
三、线程安全与信号量设计
由于中断和 FreeRTOS 任务都会调用串口发送,必须加互斥保护:
static SemaphoreHandle_t uart2sendlock = NULL;
int Dealusart2_Sendfix(unsigned short addr, unsigned short data)
{
// 中断上下文判断
if (__get_IPSR() != 0) {
// 中断中:使用 FromISR 版本
if(xSemaphoreTakeFromISR(uart2sendlock, pdTRUE) != pdTRUE) return 0;
} else {
// 任务中:普通 Take
if(xSemaphoreTake(uart2sendlock, 200) != pdTRUE) return 0;
}
// 组装帧:0x5A 0xA5 + 长度 + 0x82 + 地址(大端) + 数据(大端)
uart1_tx_buf[0]=0x5a;
uart1_tx_buf[1]=0xa5;
uart1_tx_buf[2]=0x5; // 长度 5
uart1_tx_buf[3]=0x82; // 写指令
// ... 地址和数据填充(大端序)
HAL_UART_Transmit(&huart6, uart1_tx_buf, 8, 0xffff);
// 释放信号量(根据上下文选择)
if (__get_IPSR() != 0) {
xSemaphoreGiveFromISR(uart2sendlock, pdTRUE);
} else {
xSemaphoreGive(uart2sendlock);
}
}
四、踩坑记录与解决方案
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
| 显示 1792 而非 2 | DGUS 控件配置为 4 字节,MCU 只发 2 字节 | DGUS 侧改为"2 字节整数",或 MCU 连续发送高 16 位+低 16 位 |
| 光标显示方块 | 混淆文本变量(ASCII)与图标变量(索引) | 图标变量发送 0x0000(图标 0),而非 0x0020(ASCII 空格) |
| 连续发送后显示混乱 | 迪文屏处理速率跟不上,帧丢失 | 每次发送后延时 50-100ms(osDelay) |
| 页面切换不加载时间 | 迪文屏页面刷新有延迟,MCU 发送过早 | 设置标志位,在任务中延时 150ms 后加载 |
| RTC 时间不同步 | GetDate 在 GetTime 之前,导致日期锁定 | 严格先 GetTime 后 GetDate |
| 中断死机 | HAL_UART_Receive 阻塞 1000ms 导致看门狗复位 |
改用寄存器直接读取 huart6.Instance->RDR |
五、VP 地址规划建议
合理的地址规划是维护的基础:
| 功能 | VP 地址 | 类型 | 说明 |
|---|---|---|---|
| 时间显示 | 0x1000-0x1005 | 2 字节整数 | 年月日时分秒,连续地址 |
| 光标控制 | 0x1100-0x1105 | 图标变量 | 0=隐藏,1=下划线 |
| 触控输入 | 0x2000-0x2005 | 按键返回 | 输入框激活地址 |
| 数字键盘 | 0x9150-0x9159 | 按键返回 | 0-9 数字键 |
| 页面切换 | 0x4000 | 按键返回 | 进入时间设置页 |
| RTC 设置 | 0x9040-0x904A | 按键返回 | 年/月/日/时/分/秒/设置 |
六、总结与展望
本文介绍的架构已成功应用于工业相机控制界面,稳定运行。核心设计思想:
-
中断轻量化:只接收不处理,避免阻塞
-
状态机解耦:输入逻辑与显示逻辑分离
-
延时补偿:充分考虑迪文屏的异步特性
后续优化方向:
-
改用 DMA + IDLE 中断,进一步降低 CPU 占用
-
实现迪文屏"批量写"(0x82 指令支持连续地址写入),减少通信次数
-
添加 CRC 校验,增强通信可靠性
附录:关键宏定义参考
#define VP_DISP_YEAR 0x1000
#define VP_CURSOR_BASE 0x1100
#define CURSOR_ICON_HIDE 0x0000
#define CURSOR_ICON_SHOW 0x0001
#define KEY_0 0x9150
#define RTC_Y 0x9040 // 年输入框触控地址
希望这篇博客能为您的嵌入式 GUI 开发提供参考!如有问题,欢迎在评论区交流。