最近接手一个小项目:一台桌面级设备要有人机界面,屏幕上能显示转速、能拖动条改目标值,屏幕后面还得驱动一路直流电机做调速。主控定了 STM32F103,界面用串口屏,开发环境是 STM32CubeMX + Keil。这套组合在成本敏感的产品里出镜率极高,看着简单,实际动手时中断接收丢帧、PWM 频率算错、屏幕指令死活不响应这类问题一个都不会少。这篇就按我自己的实操顺序,把 STM32CubeMX 的配置要点、串口屏的中断接收与帧解析、PWM 通道的参数计算和联动逻辑完整拆一遍,中间踩过的坑、量过的波形、改过的代码都放进去。适合刚上手 HAL 库的嵌入式新手,也适合做过 51 或标准库、现在要迁移到 CubeMX 这套流程的朋友参考。看完至少能少走两三天弯路。
1. 方案整体设计与三个关键取舍
动手配 CubeMX 之前,先把架构想清楚。这个项目的数据流向是双向的:MCU 往屏幕发显示指令,屏幕往 MCU 发触摸事件和数值输入。两边的速率完全不对等——显示指令动辄一次发几十字节,触摸事件一次只有 3 到 5 个字节,但后者要求实时响应,不能因为主循环在忙别的事就漏掉。这个不对称决定了后面所有的技术选型。
1.1 为什么是串口屏,而不是并口屏或 SPI 屏
选串口屏的第一个理由就是接线。并口屏动辄 16 根数据线加一堆控制线,PCB 布线复杂,还得考虑驱动芯片;SPI 屏虽然线少,但刷屏要靠 MCU 自己搬显存,一个 320×240 的 16 位色屏,一帧就是 150KB,STM32F103 这点 SRAM 根本存不下整屏缓冲,只能分区刷新,写起来很痛苦。串口屏把显示逻辑封装在屏幕自己的主控里,MCU 只发"把 t0 的文本改成多少"这种语义指令,屏幕内部自己去刷显存。这意味着 MCU 的负担从"逐像素搬运"降到了"发几条几十字节的命令"。
第二个理由是开发效率。串口屏配一套上位机编辑软件,拖控件、设属性、做页面跳转,全都是可视化的,改界面不用重新编译固件。产品迭代阶段界面经常改,这个效率差异非常明显。
代价也有,而且要提前想清楚。串口屏的刷新是异步的,你发完指令它不一定马上执行完,连续高频发指令会堵在屏幕的串口缓冲区里,表现为"卡顿"或者"反应慢半拍"。另外串口本身是低速链路,115200 波特率下每秒最多传 11.5KB,整屏刷新这种操作就别想了,只能做局部更新。这一点在后面的刷新策略里还会展开说。
1.2 中断接收 + 环形缓冲 + 状态机,三层拆开看
接收侧我采用的是三层结构,这个结构在资源受限的 MCU 上几乎是标准答案:
- 第一层:单字节中断。每收到一个字节就进一次中断,把数据塞进环形缓冲区,然后立刻重新开启接收。中断里干的活越少越好,绝不能在里面做字符串比较或者发指令。
- 第二层:环形缓冲区。解耦中断的"生产者"和主循环的"消费者"。缓冲区大小要按最大突发帧长度留余量,我一般给 256 到 512 字节。
- 第三层:状态机解析。主循环里从缓冲区取字节,按协议往前走状态。状态机的好处是天然支持"半帧到达"——数据还没收全就下一轮继续,不会因为一次读到一半就崩掉。
为什么要分三层?因为串口数据到达是完全异步的。你可能在主循环里正忙着算 PID,屏幕那边一口气回了 5 个字节,如果主循环去轮询,那这 5 个字节里前 3 个可能已经被下一个字节覆盖了。用中断 + 缓冲,硬件负责接收,软件按自己的节奏消费,两边互不干扰。
1.3 硬件连接与供电的坑
接线只有三根:屏幕 TX 接 MCU RX,屏幕 RX 接 MCU TX,GND 对 GND。屏幕的串口电平是 TTL 3.3V,和 STM32 的 IO 电平直接兼容,不需要电平转换芯片。但有个细节很多人忽略:屏幕的 TX 是推挽输出,电压摆幅接近 5V 供电电压,如果屏幕用 5V 供电,它的 TX 高电平可能到 4.8V 左右,超过 STM32 IO 的耐压(大部分引脚是 5V 容忍,但不是全部)。稳妥做法是串一个 100 到 220 欧的限流电阻,或者用分压。我实测多数型号的 TX 高电平在 3.3V 附近,但不同批次、不同型号有差异,加个电阻成本几分钱,别省。
供电是更大的坑。串口屏带背光,峰值电流轻松超过 300mA,5 寸以上的能到 500mA。如果你从 MCU 开发板上的 3.3V LDO 取电,轻则屏幕闪、重则 LDO 过热保护导致整个系统复位,而且这种复位是间歇性的,很难查。正确做法是屏幕用独立的 5V 电源,和 MCU 共地但电源分开走线。
注意:调试阶段屏幕和 MCU 分开供电时,一定要先接共地线再上电,否则两边参考电平不一致,串口会收到大量乱码,很容易误判成波特率问题。
2. STM32CubeMX 配置的关键细节
CubeMX 这部分看着是点点鼠标,实际上几个关键选项一旦选错,后面调试能耗掉一整天。我按配置顺序把容易出问题的地方标出来。
2.1 时钟树:定时器时钟为什么是 72MHz
先明确一点:STM32F103 的定时器时钟不是直接从 APB 总线时钟来的。当 APB 预分频系数不为 1 时,定时器时钟会自动乘以 2。这个规则写在参考手册的时钟章节里,但很多人配完就忘。
具体到 F103 的标准配置:外部 8MHz 晶振,PLL 倍频到 72MHz 作 SYSCLK,AHB 不分频(72MHz),APB1 二分频(36MHz),APB2 不分频(72MHz)。此时:
| 总线 | 频率 | 挂载外设 | 定时器实际时钟 | | AHB | 72 MHz | DMA、SRAM | - | | APB1 | 36 MHz | USART2/3、TIM2~TIM7 | 72 MHz(×2) | | APB2 | 72 MHz | USART1、TIM1、TIM8 | 72 MHz(×1) |
也就是说,TIM3 挂在 APB1 上,APB1 是 36MHz,但 TIM3 的实际计数时钟是 72MHz。CubeMX 在 TIM3 的配置界面里会直接显示 "APB1 Timer clocks: 72 MHz",以这个为准算参数,不要拿 36MHz 去算,否则频率会差一倍,而且这个错误在示波器上看不出来——除非你量了。
RCC 配置里有两处必须检查:一是 High Speed Clock 选 Crystal/Ceramic Resonator,二是 Clock Configuration 页面里 PLL Source 选 HSE。如果选成了 HSI,系统跑起来照样能工作,但实际频率会有偏差(内部 RC 精度只有 1% 左右),串口波特率误差累积到一定程度就开始丢字节,这种问题特别隐蔽。我现在习惯在 main 里加一句自检:读 RCC 的时钟源状态寄存器,确认 HSE 真的起振了。
2.2 USART1 配置与 NVIC 优先级
串口参数按屏幕的出厂配置来,一般默认 9600 8N1。我建议在屏幕的上位机工程里把波特率改成 115200 并保存到工程,MCU 侧也配 115200。理由很直接:9600 下每秒只能传 960 字节,屏幕回传一堆触摸事件时链路会很紧张;115200 是十倍带宽,误码率在短线上完全可控。
NVIC 里给 USART1 中断打勾,抢占优先级设成 1,子优先级 0。为什么不设成 0(最高)?因为如果系统里还有别的实时性要求更高的中断(比如编码器捕获),串口晚几个微秒响应完全没影响。反倒是把串口设成最高优先级,可能打断关键的控制周期计算。
还有一个细节:CubeMX 生成的代码里,MX_USART1_UART_Init()只是初始化外设,中断接收要靠HAL_UART_Receive_IT()手动启动。很多人以为配了 NVIC 就自动开始收了,结果一直收不到数据。这一句必须放在所有初始化完成之后、主循环之前。
2.3 TIM3 PWM 通道配置与频率计算
TIM3 的 Channel1 选 "PWM Generation CH1"。参数就三个:Prescaler(PSC)、Counter Period(ARR)、Pulse(初始占空比)。
计算公式是:
f_pwm = f_tim / ((PSC + 1) × (ARR + 1)) 占空比 = CCR / (ARR + 1)注意 ARR 和 PSC 都是"寄存器值",实际分频系数要加 1,这个 +1 是最常见的算错点。
驱动直流电机我选 20kHz,理由是人耳听觉上限约 20kHz,低于这个频率电机绕组会发出啸叫,高于这个频率开关损耗又会上升。代入公式,f_tim = 72MHz:
- 若 PSC = 0,则 ARR + 1 = 72e6 / 20000 = 3600,ARR = 3599
- 有效分辨率 = 1 / 3600 ≈ 0.028%
不同应用场景的参数我整理成了一张表,直接抄就行:
| 应用场景 | 目标频率 | PSC | ARR | CCR 满量程 | 有效分辨率 | | 直流电机调速 | 20 kHz | 0 | 3599 | 3600 | 约 0.028% | | LED 调光 | 1 kHz | 71 | 999 | 1000 | 0.1% | | 加热丝 / 电磁阀 | 100 Hz | 719 | 999 | 1000 | 0.1% | | 舵机信号 | 50 Hz | 71 | 19999 | 20000 | 0.005% |
LED 调光那行值得多说一句:1kHz 人眼看不到闪烁,但如果产品要上镜拍摄,摄像机的快门和 1kHz 可能产生拍频,画面出现横向暗带。遇到这种情况把频率提到 4kHz 以上,公式重算一下,PSC = 0,ARR = 17999 就是 4kHz。
CubeMX 里还有个 "auto-reload preload" 选项,建议使能。使能之后,改 ARR 的值会在下一个更新事件才生效,避免在计数中途改 ARR 导致这一周期出现异常宽或窄的脉冲。改 CCR 则不受影响,CCR 有影子寄存器,写进去立即在下一周期生效。
3. 串口屏通信协议与中断接收落地
协议这块是整篇文章的核心,也是最容易出问题的地方。串口屏的指令集本质上就是"文本命令 + 固定结束符",但回传数据的格式和指令格式不一样,得分开处理。
3.1 指令帧结构和结束符的真相
MCU 发给屏幕的指令是 ASCII 文本加三个 0xFF 结尾。举几个常用的:
/* 设置文本控件内容 */ t0.txt="RPM: 1200" \xFF\xFF\xFF /* 设置数字控件数值 */ n0.val=1200 \xFF\xFF\xFF /* 设置进度条(范围 0~100) */ j0.val=50 \xFF\xFF\xFF /* 切页面 */ page 1 \xFF\xFF\xFF /* 设置背光亮度(0~100) */ dim=80 \xFF\xFF\xFF三个 0xFF 是帧结束标志,屏幕靠它判断一条指令收完了。这里有个坑:如果你用printf发指令,很容易在字符串末尾带上\r\n,然后才是 0xFF。屏幕收到换行符会当成指令内容的一部分,导致解析失败。所以发指令必须用HAL_UART_Transmit按字节长度发,不要用 printf 直接怼。
另一个坑是字符串里的引号。t0.txt="abc"这里的双引号是协议的一部分,必须原样发出去,不能省略。如果字符串内容本身含中文,编码必须和屏幕字库一致——大多数型号默认字库是 GB2312,你在 Keil 里写 UTF-8 的中文字符串发过去,屏幕上显示的就是乱码方块。解决办法有两个:一是把源文件编码改成 GB2312,二是用屏幕的 Unicode 字库然后发 Unicode 编码。我一般选前者,简单。
3.2 单字节中断 + 环形缓冲区的实现
先定义环形缓冲区。用head和tail两个游标,中断里只动head,主循环只动tail,天然无锁:
/* hmi_ring.h */ #ifndef __HMI_RING_H #define __HMI_RING_H #include "main.h" #define RING_SIZE 512 typedef struct { uint8_t buf[RING_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_t; extern ring_t g_ring; extern volatile uint8_t g_rx_byte; void Ring_Init(ring_t *r); int Ring_Pop(ring_t *r, uint8_t *out); uint16_t Ring_Count(const ring_t *r); #endif/* hmi_ring.c */ #include "hmi_ring.h" ring_t g_ring; volatile uint8_t g_rx_byte; void Ring_Init(ring_t *r) { r->head = 0; r->tail = 0; } int Ring_Pop(ring_t *r, uint8_t *out) { if (r->head == r->tail) { return 0; } *out = r->buf[r->tail]; r->tail = (uint16_t)((r->tail + 1) % RING_SIZE); return 1; } uint16_t Ring_Count(const ring_t *r) { return (uint16_t)((r->head + RING_SIZE - r->tail) % RING_SIZE); }中断回调里做两件事:存字节、重新开启接收:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint16_t next = (uint16_t)((g_ring.head + 1) % RING_SIZE); if (next != g_ring.tail) { /* 留一个空位判满 */ g_ring.buf[g_ring.head] = g_rx_byte; g_ring.head = next; } /* 无论缓冲区满不满,都必须重开接收,否则链路死掉 */ HAL_UART_Receive_IT(&huart1, (uint8_t *)&g_rx_byte, 1); } }volatile这两个字不能省。head在中断里被修改,主循环里要读,编译器如果不加 volatile 可能把它缓存在寄存器里,导致主循环永远看到旧值——这种 bug 只在开了优化的时候出现,debug 版本跑得好好的,一开 -O2 就死机,非常难查。
注意:
HAL_UART_Receive_IT必须在回调里无条件重新调用。如果因为缓冲区满就跳过了这一句,下一次字节到达时硬件中断标志清零了但没人重新武装接收,整个链路就静默了,表现为"上电工作几秒然后彻底不响应"。
3.3 三类回传帧的识别与状态机解析
串口屏回传给 MCU 的数据主要有三类,格式各不相同:
第一类是触摸事件帧,长度固定。首字节是 0x65,后面跟页面号、控件 ID、事件类型(0 表示按下,1 表示松开),共 4 字节。
第二类是数值控件回传帧,首字节是 0x70,后面跟若干字节的小端数值。具体字节数不同固件版本可能不一样,常见是 4 字节,也有回 2 字节的。这一点务必用逻辑分析仪实际抓一次确认,别照抄别人的代码。
第三类是字符串回传,比如执行了get t0.txt指令后屏幕返回的文本,格式是文本内容加 0xFF 0xFF 0xFF 结尾,长度不固定。
三类混在一起,就得靠状态机分流。我的做法是先看首字节判断类型,再按类型走不同的分支:
/* hmi_proto.c 核心解析状态机 */ typedef enum { ST_IDLE = 0, ST_EVT_PAGE, /* 0x65 后第 1 字节:页面号 */ ST_EVT_ID, /* 0x65 后第 2 字节:控件 ID */ ST_EVT_TYPE, /* 0x65 后第 3 字节:事件类型 */ ST_NUM, /* 0x70 后的数值字节 */ ST_STR /* 文本帧累计 */ } parse_st_t; #define NUM_LEN 4 #define STR_MAX 64 static parse_st_t s_st = ST_IDLE; static uint8_t s_evt[3]; static uint8_t s_cnt = 0; static uint32_t s_num = 0; static char s_str[STR_MAX]; static uint8_t s_str_len = 0; static uint8_t s_ff_run = 0; /* 连续 0xFF 计数 */ void Proto_Reset(void) { s_st = ST_IDLE; s_cnt = 0; s_num = 0; s_str_len = 0; s_ff_run = 0; } void Proto_Poll(void) { uint8_t b; while (Ring_Pop(&g_ring, &b)) { switch (s_st) { case ST_IDLE: if (b == 0x65) { s_st = ST_EVT_PAGE; } else if (b == 0x70) { s_st = ST_NUM; s_cnt = 0; s_num = 0; } else { /* 其他首字节当作文本帧开始,先收着 */ s_str_len = 0; s_str[s_str_len++] = (char)b; s_ff_run = (b == 0xFF) ? 1 : 0; s_st = ST_STR; } break; case ST_EVT_PAGE: s_evt[0] = b; s_st = ST_EVT_ID; break; case ST_EVT_ID: s_evt[1] = b; s_st = ST_EVT_TYPE; break; case ST_EVT_TYPE: s_evt[2] = b; On_TouchEvent(s_evt[0], s_evt[1], s_evt[2]); Proto_Reset(); break; case ST_NUM: s_num |= ((uint32_t)b) << (8 * s_cnt); s_cnt++; if (s_cnt >= NUM_LEN) { On_NumberValue(s_num); Proto_Reset(); } break; case ST_STR: if (s_str_len < STR_MAX - 1) { s_str[s_str_len++] = (char)b; } if (b == 0xFF) { s_ff_run++; if (s_ff_run >= 3) { s_str_len = (s_str_len >= 3) ? (uint8_t)(s_str_len - 3) : 0; s_str[s_str_len] = '\0'; On_StringValue(s_str); Proto_Reset(); } } else { s_ff_run = 0; } break; default: Proto_Reset(); break; } } }这里解释几个设计取舍。为什么要单独用s_ff_run计数而不是判断"上一字节是不是 FF"?因为文本内容里可能真的出现单个 0xFF 字节(虽然是极端情况),必须确认连续三个才算帧结束。为什么ST_NUM里用移位拼接而不是 memcpy?因为小端序的 4 字节转 uint32 用移位更直观,也避免了指针类型转换的对齐警告。
On_TouchEvent这类回调就是业务分发:
void On_TouchEvent(uint8_t page, uint8_t obj_id, uint8_t evt_type) { if (evt_type != 0) { /* 只处理按下事件,松开忽略 */ return; } switch (obj_id) { case 1: /* 启动按钮 */ g_motor_run = 1; break; case 2: /* 停止按钮 */ g_motor_run = 0; g_ramp.target = 0; break; case 3: /* 换向 */ g_dir = !g_dir; break; default: break; } }3.4 发送侧的封装与阻塞取舍
发送我用的是阻塞式HAL_UART_Transmit,超时给 100ms。为什么不搞中断发送或者 DMA 发送?因为指令都很短(几十字节),115200 下传 40 字节也就 3.5ms,阻塞这点时间对控制周期影响可以忽略。反倒是搞成异步发送要维护发送队列和忙标志,复杂度和出错概率都上去了,收益不成正比。
/* hmi_cmd.c */ #include <string.h> #include <stdio.h> static const uint8_t CMD_TAIL[3] = {0xFF, 0xFF, 0xFF}; void HMI_Raw(const void *data, uint16_t len) { HAL_UART_Transmit(&huart1, (uint8_t *)data, len, 100); HAL_UART_Transmit(&huart1, (uint8_t *)CMD_TAIL, 3, 100); } void HMI_SetText(const char *obj, const char *txt) { char buf[96]; int n = snprintf(buf, sizeof(buf), "%s.txt=\"%s\"", obj, txt); if (n > 0 && n < (int)sizeof(buf)) { HMI_Raw(buf, (uint16_t)n); } } void HMI_SetNum(const char *obj, int32_t val) { char buf[48]; int n = snprintf(buf, sizeof(buf), "%s.val=%ld", obj, (long)val); if (n > 0 && n < (int)sizeof(buf)) { HMI_Raw(buf, (uint16_t)n); } } void HMI_SetPage(uint8_t page) { char buf[16]; int n = snprintf(buf, sizeof(buf), "page %u", (unsigned)page); if (n > 0) { HMI_Raw(buf, (uint16_t)n); } } void HMI_SetDim(uint8_t level) { if (level > 100) level = 100; char buf[16]; int n = snprintf(buf, sizeof(buf), "dim=%u", (unsigned)level); if (n > 0) { HMI_Raw(buf, (uint16_t)n); } }snprintf的返回值必须检查。当格式化结果超过缓冲区时返回值会大于缓冲区大小,这时候发出去就是被截断的残缺指令,屏幕那边会一直等结束符,表现出来就是"卡住不响应"。这个坑我踩过一次,排查了两个小时。
提示:每类控件的取值范围不一样。数字控件
.val一般是 32 位有符号整数,范围很大;但进度条j0.val是 0 到 100 的整数,直接塞 0 到 1000 的 PWM 值进去会溢出显示异常。中间要做一次映射,j0.val = pwm_permille / 10。
4. PWM 输出与屏幕联动的完整实现
接收链路通了,接下来是控制侧。PWM 这部分表面上是配个定时器调个比较值,但要做到转速平滑、界面同步、不抖不跳,还是有几个细节。
4.1 占空比映射与启停时序
先把 PWM 的抽象接口定好,统一用千分比(0 到 1000)作为内部单位,这样和百分比换算直观,跨不同 ARR 的定时器也好移植:
/* pwm_drv.c */ #include "main.h" extern TIM_HandleTypeDef htim3; #define PWM_FULL 1000 /* 对应 ARR+1 的量程 */ void PWM_Init(void) { HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0); } void PWM_SetPermille(uint16_t permille) { if (permille > PWM_FULL) { permille = PWM_FULL; } __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, permille); }注意这里的PWM_FULL和之前算的 ARR 对不上——20kHz 那组参数 ARR 是 3599,量程是 3600。这时候要么把内部单位改成 0 到 3599,要么在PWM_SetPermille里做一次比例换算。我选后者,因为业务层用千分比沟通起来更顺:
void PWM_SetPermille(uint16_t permille) { if (permille > 1000) permille = 1000; uint32_t ccr = ((uint32_t)permille * (htim3.Init.Period + 1)) / 1000; __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, ccr); }用htim3.Init.Period + 1而不是硬编码 3600,好处是以后改 ARR 不用改代码。除法在中断里做有开销,但这里在主循环调用,72MHz 下一个 32 位除法也就几十个周期,完全无所谓。
启停时序有个必须注意的点:电机从 0 突然加到高速,启动电流会是额定电流的 5 到 8 倍,不仅对电源是冲击,机械上也有冲击。所以启动必须先给一个较低的起始占空比(比如 10%),等转起来之后再往上加。这个"最低启动占空比"因电机而异,小直流电机一般在 8% 到 15% 之间,低于这个值电机只是嗡嗡响不转,绕组发热但没输出。
4.2 软件斜坡:让占空比慢慢爬
直接改 CCR 会让占空比阶跃变化,表现是电机"咯噔"一下,屏幕上的进度条也是一跳一跳的。加个软件斜坡,每 10ms 走一步,视觉和听觉上都舒服很多:
/* ramp.c */ typedef struct { uint16_t cur; uint16_t target; uint16_t step; } ramp_t; ramp_t g_ramp = {0, 0, 5}; /* 每次最多变 5‰,10ms 一步 */ void Ramp_SetTarget(uint16_t t) { if (t > 1000) t = 1000; g_ramp.target = t; } void Ramp_Tick(void) { uint16_t cur = g_ramp.cur; uint16_t tgt = g_ramp.target; if (cur == tgt) { return; } if (cur < tgt) { uint16_t d = (uint16_t)(tgt - cur); g_ramp.cur = (d > g_ramp.step) ? (uint16_t)(cur + g_ramp.step) : tgt; } else { uint16_t d = (uint16_t)(cur - tgt); g_ramp.cur = (d > g_ramp.step) ? (uint16_t)(cur - g_ramp.step) : tgt; } PWM_SetPermille(g_ramp.cur); }step = 5、10ms 一步,意味着从 0 加到满量程需要 1000 / 5 × 10ms = 2 秒。这个速度对小电机来说偏慢但很稳,想要更快把 step 调大或者把周期缩短。反过来的减速如果太快,电机反电动势会顶回来,可能触发过压保护,所以减速的 step 我一般设得比加速更小一点。
4.3 主循环的组织与刷新节流
把所有东西拼起来,主循环长这样:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM3_Init(); Ring_Init(&g_ring); Proto_Reset(); PWM_Init(); HAL_UART_Receive_IT(&huart1, (uint8_t *)&g_rx_byte, 1); HMI_SetPage(0); HMI_SetDim(80); uint32_t t_ramp = 0; uint32_t t_ui = 0; while (1) { /* 1. 协议解析,越快越好,不做耗时操作 */ Proto_Poll(); uint32_t now = HAL_GetTick(); /* 2. 每 10ms 走一次斜坡 */ if (now - t_ramp >= 10) { t_ramp = now; if (g_motor_run) { Ramp_SetTarget(g_target_duty); } else { Ramp_SetTarget(0); } Ramp_Tick(); } /* 3. 每 200ms 刷新一次界面,节流 */ if (now - t_ui >= 200) { t_ui = now; HMI_SetNum("n0", (int32_t)g_ramp.cur); char buf[24]; snprintf(buf, sizeof(buf), "j0.val=%u", (unsigned)(g_ramp.cur / 10)); HMI_Raw(buf, (uint16_t)strlen(buf)); } } }两个时间片的设计是有讲究的。斜坡 10ms 一次,保证动态响应够快;界面 200ms 一次,是因为屏幕本身刷新率有限,你 10ms 发一次它根本来不及处理,指令全堆在缓冲区里,反而让触摸事件排在后面延迟响应。200ms 这个值是我实测调出来的,低于 100ms 时屏幕上的数字会有明显的"追不上"的感觉,高于 500ms 又显得迟钝。
5. 常见问题排查与实战避坑
这部分是我踩过的坑的汇总,按现象分类整理成速查表,遇到问题直接对号入座。
5.1 通信类问题速查
| 现象 | 最可能原因 | 排查动作 | 处理方式 | | 屏幕完全无响应 | 波特率不一致 | 逻辑分析仪抓 MCU TX 脚,看字节间隔反推波特率 | 屏幕工程里改波特率并保存,或 MCU 侧改成 9600 | | 指令能收到但显示乱码 | 结束符缺失或多余 | 抓包看尾部是否有 0x0D 0x0A | 去掉 printf 的换行,按字节长度精确发送 | | 中文字符显示成方块 | 编码不匹配 | 确认屏幕字库编码 | 源文件存为 GB2312,或用 Unicode 字库 | | 收几条数据后彻底静默 | 回调里没重开接收 | 在回调打点计数 | 补HAL_UART_Receive_IT,且放在 if 外面 | | 数据偶发丢字节 | 环形缓冲溢出 | 读Ring_Count看峰值 | 缓冲区扩到 512 以上,缩短主循环阻塞时间 | | 上电正常但运行久了乱码 | HSE 未起振回落到 HSI | 读 RCC 时钟源状态 | 检查晶振焊接和负载电容,或改用外部有源晶振 | | 触摸事件延迟明显 | 主循环阻塞或缺节流 | 用 GPIO 翻转 + 示波器量主循环周期 | 减少阻塞调用,界面刷新降到 200ms |
挑两个最典型的展开说。
"收几条后静默"这个问题的成因我前面提过。这里补充一个诊断技巧:在中断回调里翻转一个空闲 GPIO,用示波器看这个引脚,正常情况下每收到一个字节就有一个窄脉冲。如果脉冲停了,就是接收没被重新武装。这个方法比加 printf 靠谱,因为 printf 本身会引入串口输出,和接收抢同一个外设,会干扰判断。
"运行久了乱码"这类问题最阴。我遇到过一次,设备在实验室跑一整天没事,客户现场当天下午就出问题。最后发现是外部晶振的负载电容选值偏小,起振裕量不足,温度升高后偶尔失振,系统自动切到内部 RC,波特率就偏了。判断方法很简单:在SystemClock_Config()之后读一下RCC->CFGR的 SWS 位,确认是 0b10(PLL 作系统时钟)还是 0b00(HSI)。如果是后者,说明 PLL 配置没生效。这个自检建议固化成代码,上电时如果发现不对就点个故障灯。
5.2 PWM 类问题排查
| 现象 | 可能原因 | 处理方式 | | 引脚完全无波形 | 没调HAL_TIM_PWM_Start| 在初始化里补上,注意传 CHANNEL 参数 | | 有波形但占空比恒为 0 | CCR 一直是 0,或写错了通道 | 用__HAL_TIM_GET_COMPARE回读验证 | | 占空比改不动 |__HAL_TIM_SET_COMPARE的通道宏和实际用的不一致 | 检查 CHANNEL_1 到 CHANNEL_4 是否对齐 | | 频率正好差一倍 | ARR 或 PSC 少加了 1 | 按 f = clk / ((PSC+1)×(ARR+1)) 重算 | | 波形有毛刺 | 输出脚配置成了普通 GPIO | CubeMX 里确认引脚模式是 Alternate Function Push Pull | | 高级定时器(TIM1/TIM8)无输出 | 主输出使能位 MOE 未开 | 调HAL_TIM_PWM_Start会自动开,若手动配置需置位 BDTR 的 MOE |
高级定时器那个坑单独说一下。TIM1 和 TIM8 有个刹车和死区控制寄存器 BDTR,里面有个 MOE 位(Main Output Enable),不置位的话通道配置再对也没有波形输出。用 HAL 库调HAL_TIM_PWM_Start()会自动置位,但如果你是先HAL_TIM_PWM_Start再改 BDTR 配置,可能把它清掉。这个是标准库时代遗留的老问题,HAL 下遇到的人少了,但一旦遇到能查半天。
5.3 一个容易被忽视的细节:屏幕和 MCU 的上电顺序
屏幕的主控启动需要时间,一般在上电后 200 到 500ms 内完成初始化。如果你在 MCU 初始化完成的第一时间就发指令,那时候屏幕可能还没准备好,指令会被丢掉。表现出来就是"每次上电屏幕上的初始值都不对,按下按钮之后才正常"。
解决办法是在main初始化串口之后加一个延时,等屏幕启动完:
HAL_Delay(500); /* 等屏幕启动 */ HMI_SetPage(0); HMI_SetNum("n0", 0);500ms 是保守值,实际多数型号 300ms 就够。更好的做法是屏幕启动完成后主动发一个握手信号给 MCU,但这需要在屏幕的上位机工程里加初始化事件。对于小项目,500ms 延时简单可靠,不用折腾。
另外,发送初始化的这几条指令之前,串口接收中断应该已经开启了。因为屏幕有可能在启动过程中回一些状态字节,这些字节如果不被及时读走,会占着硬件接收寄存器,导致后面真正的数据来的时候溢出。这个细节很微妙,但确实会导致"偶尔丢第一条触摸事件"。
6. 稳定性加固与后续扩展方向
功能跑通只是第一步。产品要交付,还得考虑异常情况:线松了、干扰了、屏幕死机了怎么办。这一节讲几个加固手段。
6.1 接收超时与帧重同步
状态机有个天然的弱点:如果中途丢了一个字节,整个状态就错位了,后面所有数据都按错误的偏移解析,可能连续解析出乱七八糟的值。比如 0x65 事件帧丢了页面号字节,那么接下来的控件 ID 会被当成页面号,事件类型被当成控件 ID,然后下一个帧的首字节被当成事件类型,从此全乱。
重同步的手段是加超时。在状态机里记录最后一次收到字节的时间,如果超过 100ms 还停在非 IDLE 状态,就强制复位到 IDLE:
static uint32_t s_last_rx_tick = 0; void Proto_Poll(void) { uint8_t b; int got = 0; while (Ring_Pop(&g_ring, &b)) { got = 1; s_last_rx_tick = HAL_GetTick(); /* ... 状态机处理 ... */ } if (s_st != ST_IDLE && (HAL_GetTick() - s_last_rx_tick) > 100) { Proto_Reset(); /* 半帧超时,丢弃重来 */ } }100ms 这个值要大于最长的合法帧传输时间。115200 下传 64 字节字符串也就 5.5ms,留 100ms 余量非常充足。这个机制能覆盖大部分单字节丢失场景,代价只是偶尔丢一帧,不影响整体。
再进一步可以在协议层加校验,但这需要屏幕端支持,大多数串口屏的指令集是固定的,改不了。所以实际项目里我一般靠"关键指令重发"来保证可靠性:比如设置目标转速这种重要指令,发完之后隔 50ms 用get指令读回来比对一次,不一致就重发。这套逻辑写起来有点啰嗦,但对可靠性要求高的场合值得。
6.2 中断接收与 DMA 空闲中断的取舍
单字节中断的方案在 115200 下完全够用,但有个理论上的问题:每收一个字节进一次中断,115200 下每秒最多 11520 次中断,每次中断进出加上 HAL 库的开销大概 2 到 3 微秒,累计占用 CPU 约 3%。这个开销不大,但如果系统里还有高频控制环,3% 也值得省。
DMA + 空闲中断(IDLE)的方案是把接收交给 DMA,只在总线空闲时进一次中断,中断次数从"每字节一次"降到"每帧一次"。配置方法是:
- USART1 的 DMA Request 里加 RX 通道,模式选 Normal(不是 Circular,因为要用 IDLE 判断帧尾)
- 开启 USART1 的全局中断,在
HAL_UART_IRQHandler之外还要处理 IDLE 标志 - 启动时用
HAL_UARTEx_ReceiveToIdle_DMA()(HAL 库较新版本提供)
这个方案的优势在数据量大、帧长的场合明显,缺点是配置复杂,而且 DMA 缓冲区和业务缓冲区要做数据搬运,代码量反而增加。我的判断标准是:如果单帧长度经常超过 32 字节,用 DMA;否则单字节中断更简单。这个项目里屏幕回传的都是短帧,所以选了中断方案。
6.3 参数掉电保存
用户设的目标转速、背光亮度这些参数,断电之后不该丢。STM32F103 内部有 Flash,可以划一页出来做参数存储:
#define PARAM_ADDR 0x0801FC00UL /* 最后一页起始地址,按实际型号调整 */ #define PARAM_MAGIC 0x5A5AA5A5UL typedef struct { uint32_t magic; uint16_t target_duty; uint8_t dim_level; uint8_t reserved; uint32_t crc; } param_t; void Param_Save(const param_t *p) { HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef er = {0}; uint32_t page_err = 0; er.TypeErase = FLASH_TYPEERASE_PAGES; er.PageAddress = PARAM_ADDR; er.NbPages = 1; HAL_FLASHEx_Erase(&er, &page_err); uint32_t *src = (uint32_t *)p; for (uint32_t i = 0; i < sizeof(param_t) / 4; i++) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, PARAM_ADDR + i * 4, src[i]); } HAL_FLASH_Lock(); }Flash 擦写寿命有限(一般十万次),所以不能每次改参数就写一次。我的做法是加一个"脏标志",只在参数变化且稳定 3 秒之后才落盘,同时用一个计数做磨损均衡——F103 中等容量型号每页 1KB,可以划 4 页轮着写。
读参数的时候必须先校验 magic 和 CRC,两个都对才认,否则用默认值。因为 Flash 擦除之后是 0xFF,如果直接按结构体读,magic 字段是 0xFFFFFFFF 不等于 0x5A5AA5A5,会正确判为无效。这个机制保证了首次上电或者掉电中断导致的半写入不会读到垃圾数据。
6.4 后续可以加的两个功能
一是屏幕端的曲线显示。串口屏一般支持"波形控件",MCU 只要按固定格式往波形控件的数据通道里推数据点,屏幕自己画曲线。这样不用 MCU 做任何图形运算,就能显示转速曲线。实现方式是每 200ms 往波形通道推一个当前转速值,比自己在 MCU 上算坐标点再发画线指令简单太多。
二是双向握手的通信健康监测。在主循环里维护一个计数器,每次成功解析出一帧就加一,如果连续 3 秒没有收到任何帧,就判定通信异常,触发屏幕上的故障提示页。这个机制在设备交付后非常有用,现场出问题时用户能直接看到"通信故障"而不是面对一个静止不动的界面瞎猜。
我个人在实际操作中的体会是,这类 MCU + 串口屏的项目,难点从来不在某一方的配置,而在两边协议的"对不上"。屏幕那边的文档写的是"数值以低位在前的方式回传",你这边代码写的是大端,编译没报错,跑起来就是数据乱七八糟,还以为是串口干扰。所以每次开新项目,我第一件事永远是先写个最简单的收发验证:MCU 只发一条page 0,屏幕上放个按钮,按下之后回传字节用逻辑分析仪全抓下来,先确认字节序和帧长,再往下写业务。这几十分钟的"笨功夫",能省掉后面几天的怀疑人生。
再分享一个小技巧:调试阶段可以在屏幕工程里专门留一个"调试输出"文本控件,把 MCU 解析出来的原始字节、状态机当前状态、错误计数全部打到这个控件上实时显示。这相当于用屏幕给你自己做了个可视化调试窗口,比抱着电脑连 SWD 看变量方便多了,尤其是整机装配好之后拆不开的场合。