嵌入式矩形屏数字转盘UI设计与实现
1. 项目背景与工程目标
在嵌入式人机交互界面开发中,数字转盘(Rotary Dial)是一种兼具功能性与视觉表现力的UI组件。它常见于智能手表、工业HMI、音频设备等场景,其核心价值在于:以直观的旋钮操作替代传统按钮或滑动条,降低误触率;通过视觉渐变强化空间纵深感,提升用户感知精度;在资源受限的MCU平台上实现流畅动画,考验图形渲染与实时控制的协同能力。
本项目源于对开源社区优秀实践的工程化复用——一位海外开发者(GitHub ID: @TFT-ESPI-Community)发布的AMOLED圆形转盘示例。原始设计面向分辨率为240×536的高密度圆形屏,采用纯TFT_eSPI驱动库实现,未引入LVGL、TouchGFX等重量级GUI框架,完全基于像素级坐标计算与字体缩放完成动态渲染。这种“零框架依赖”的实现路径,恰恰契合嵌入式系统对内存占用、启动时间与确定性响应的严苛要求。
然而,直接移植面临三个关键矛盾:
- 分辨率失配 :目标硬件为135×240矩形TFT屏(ST7789V驱动),原始坐标系无法映射;
- 显示形态差异 :圆形屏的极坐标自然适配转盘逻辑,矩形屏需重构坐标投影模型;
- 功能语义迁移 :原设计用于0–100数值调节,需适配为0–60秒表盘,涉及时间语义绑定与刷新策略重设计。
因此,本次改造并非简单代码搬运,而是一次典型的嵌入式UI工程化重构:从物理层(屏幕驱动)、数学层(坐标变换)、逻辑层(编码器状态机)到表现层(字体缩放渲染)的全栈适配。最终成果是一个可在STM32F4系列MCU(如STM32F407VGT6)上稳定运行、功耗可控、视觉效果精准的赛博怀表数字转盘表盘。
2. 硬件平台与驱动架构
2.1 核心硬件选型与约束
| 组件 | 型号/规格 | 关键约束说明 |
|---|---|---|
| 主控MCU | STM32F407VGT6 | 168MHz Cortex-M4,1MB Flash / 192KB RAM;FSMC总线支持8080并口TFT屏 |
| 显示屏 | 1.3” ST7789V TFT | 分辨率135×240,16位RGB565格式,8080并口接口,无内置GRAM,需MCU逐帧刷屏 |
| 编码器 | EC11旋转编码器 | AB相正交输出,机械寿命>30万次,带独立按键开关,需硬件消抖与状态机解码 |
| 电源管理 | AMS1117-3.3V LDO | 屏幕峰值电流达80mA,需预留20%余量;编码器逻辑电平兼容3.3V |
该硬件组合决定了软件架构的底层边界:
- 显示驱动必须基于TFT_eSPI库 :因其对ST7789V的深度优化(如DMA双缓冲、部分刷新),且不依赖外部RAM(FSMC未接SRAM时仍可运行);
- 编码器处理必须兼顾实时性与鲁棒性 :AB相脉冲频率上限约5kHz(快速旋转),中断服务函数(ISR)执行时间需<5μs;
- 内存布局需精打细算 :135×240×2字节=64.8KB单帧显存,若启用双缓冲则占129.6KB,已超MCU内建RAM容量,必须启用FSMC外扩或采用单缓冲+局部刷新策略。
2.2 TFT_eSPI驱动配置要点
TFT_eSPI库的配置文件 User_Setup.h 需针对性调整,关键参数如下:
// 屏幕尺寸与驱动芯片
#define ST7789_DRIVER
#define TFT_WIDTH 240
#define TFT_HEIGHT 135
#define TFT_RGB_ORDER TFT_BGR // ST7789V默认BGR排列,避免颜色错乱
// 性能优化选项
#define SPI_FREQUENCY 40000000 // STM32F4 SPI1最高支持42MHz,实测40MHz稳定
#define USE_SPI_DMA // 启用DMA传输,释放CPU处理图像数据
#define DMA_BUFFER_SIZE 2048 // 单次DMA传输长度,匹配SPI FIFO深度
// 内存管理(关键!)
#define TFT_SET_WINDOW // 必须启用,否则无法进行局部刷新
#define CGRAM_OFFSET 0 // ST7789V无内置GRAM,所有像素由MCU生成
特别注意:ST7789V的 TFT_HEIGHT 定义为135而非240,是因为其物理排布为 横向窄屏 (135行×240列)。若错误设为240×135,会导致图像被垂直压缩或错位。此细节在原始AMOLED代码中无需考虑(因圆形屏无方向性),但在矩形屏移植中是首要校验点。
3. 数学模型重构:从圆形极坐标到矩形笛卡尔投影
原始圆形转盘的核心数学模型基于极坐标系:
- 以屏幕中心为原点O(0,0);
- 数值i∈[0,360)对应角度θ=i°;
- 坐标(x,y)=O + R·(cosθ, sinθ),R为半径;
- 字体大小f(i)与半径R呈线性关系(R越大字体越大)。
但在135×240矩形屏上,直接套用该模型会导致严重畸变:
- 圆形轨迹在矩形区域内呈现为椭圆(因宽高比240:135≈1.78:1≠1:1);
- “越靠近上下边缘字体越小”的视觉效果,在矩形屏中实际表现为 Y轴方向压缩 ,而非均匀径向衰减。
因此,必须建立新的笛卡尔坐标投影模型:
3.1 矩形转盘的几何定义
定义有效显示区域为矩形 Rect(0,0,240,135) ,转盘中心C=(120,67)(即宽/2, 高/2)。转盘外环半径R_outer=60(留出15像素边距),内环半径R_inner=45。数值范围映射为秒针刻度:0–60秒对应角度0°–360°,即每秒6°。
关键创新在于 Y轴非线性缩放 :
- 原始圆形模型中,点P到中心距离d=√[(x-cx)²+(y-cy)²];
- 矩形屏需将d替换为 归一化垂直距离 : d_norm = |y - cy| / cy (cy=67为Y方向半高)
- 字体大小f由d_norm线性插值得到: f = f_min + (f_max - f_min) * (1 - d_norm)
其中f_min=16(顶部/底部),f_max=75(中心水平线),确保视觉纵深感。
3.2 四类坐标数组的生成逻辑
程序预生成四个静态数组,存储60个刻度点的坐标与属性。生成过程在 setup() 中一次性完成,避免运行时浮点运算:
// 预定义常量
const uint8_t NUM_TICKS = 60;
const int16_t CENTER_X = 120, CENTER_Y = 67;
const int16_t R_OUTER = 60, R_INNER = 45;
const uint8_t FONT_MIN = 16, FONT_MAX = 75;
// 四类数组结构体
struct TickPoint {
int16_t x, y; // 像素坐标
uint8_t font_size; // 对应字体大小
};
TickPoint long_lines[NUM_TICKS]; // 外圈长线(秒刻度)
TickPoint short_lines[NUM_TICKS]; // 内圈短线(辅助刻度)
TickPoint mid_lines[NUM_TICKS]; // 中圈中线(5秒标记)
TickPoint numbers[NUM_TICKS]; // 数字位置(仅0,15,30,45)
void generateTickArrays() {
for (uint8_t i = 0; i < NUM_TICKS; i++) {
float angle_rad = (i * 6.0) * PI / 180.0; // 每秒6°
// 计算基础极坐标(圆形)
float x_circle = cos(angle_rad) * R_OUTER;
float y_circle = sin(angle_rad) * R_OUTER;
// 矩形投影:X轴保持,Y轴按宽高比缩放
// 宽高比修正因子 k = 240/135 ≈ 1.777,但此处需反向补偿Y压缩
float k_compensate = (float)TFT_WIDTH / TFT_HEIGHT; // 240/135 = 1.777
int16_t x = CENTER_X + (int16_t)x_circle;
int16_t y = CENTER_Y + (int16_t)(y_circle / k_compensate);
// 计算归一化垂直距离(决定字体大小)
float d_norm = fabs(y - CENTER_Y) / CENTER_Y;
uint8_t font_size = FONT_MIN + (FONT_MAX - FONT_MIN) * (1.0 - d_norm);
// 存储到对应数组
long_lines[i] = {x, y, font_size};
// 短线:向内偏移15像素
float x_short = cos(angle_rad) * R_INNER;
float y_short = sin(angle_rad) * R_INNER / k_compensate;
short_lines[i] = {
CENTER_X + (int16_t)x_short,
CENTER_Y + (int16_t)y_short,
font_size - 8 // 稍小字号
};
// 中线:R=52,仅每5秒绘制(i%5==0)
if (i % 5 == 0) {
float x_mid = cos(angle_rad) * 52;
float y_mid = sin(angle_rad) * 52 / k_compensate;
mid_lines[i] = {
CENTER_X + (int16_t)x_mid,
CENTER_Y + (int16_t)y_mid,
font_size - 4
};
}
// 数字:仅0,15,30,45(对应i=0,15,30,45)
if (i == 0 || i == 15 || i == 30 || i == 45) {
numbers[i] = {x, y, font_size + 5}; // 数字略大
}
}
}
此模型的关键优势:
- 计算开销极低 :所有三角函数在 setup() 中预计算,运行时仅为查表;
- 视觉保真度高 :Y轴缩放补偿了矩形屏的物理畸变,使转盘在视觉上仍接近圆形;
- 字体渐变自然 : d_norm 基于真实Y坐标计算,确保顶部/底部字体最小,中心最大,符合人眼透视直觉。
4. 编码器状态机与实时交互设计
旋转编码器是转盘UI的输入核心。EC11的AB相正交信号需通过硬件消抖与软件状态机解码,确保在机械抖动(典型持续2–5ms)下不产生误计数。
4.1 硬件层:RC滤波与GPIO配置
EC11的A、B引脚接入STM32的GPIO(如PA0、PA1),需添加硬件RC低通滤波:
- 串联电阻R=10kΩ(限流);
- 并联电容C=100nF(时间常数τ=1ms,覆盖大部分抖动);
- GPIO配置为上拉输入( GPIO_PULLUP ),因EC11输出为开漏。
在CubeMX中配置:
- PA0/PA1:GPIO Mode = Input, Pull = Pull-up, Speed = High;
- 使能EXTI中断(PA0→EXTI0,PA1→EXTI1),触发方式为Falling Edge(下降沿触发,因上拉时常态高电平)。
4.2 软件层:有限状态机(FSM)解码
采用经典的4状态FSM(Quadrature Decoder State Machine),状态转移由AB相电平组合定义:
| 当前状态 | A B | 下一状态 | 计数变化 | 说明 |
|---|---|---|---|---|
| S0 (00) | 00 | S0 | 0 | 稳态 |
| S0 (00) | 01 | S1 | +1 | A先变低 → 正向旋转 |
| S0 (00) | 10 | S3 | -1 | B先变低 → 反向旋转 |
| S1 (01) | 00 | S0 | 0 | 回稳 |
| S1 (01) | 11 | S2 | +1 | B变高 → 继续正向 |
| … | … | … | … | (完整状态表共16种转移) |
实际代码中,为减少中断延迟,仅在EXTI0中断中读取AB电平并更新状态:
volatile int16_t encoder_pos = 0; // 全局位置变量
volatile uint8_t enc_state = 0; // 当前FSM状态 (0-3)
// EXTI0中断服务函数(PA0触发)
void EXTI0_IRQHandler(void) {
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}
// HAL库回调(在HAL_GPIO_EXTI_Callback中调用)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
if (GPIO_Pin == GPIO_PIN_0) {
// 读取A、B电平(PA0=A, PA1=B)
uint8_t a = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
uint8_t b = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1);
uint8_t ab = (a << 1) | b; // AB组合:00,01,10,11
switch (enc_state) {
case 0: // S0
if (ab == 0x01) { enc_state = 1; encoder_pos++; } // A↓→S1, +1
else if (ab == 0x10) { enc_state = 3; encoder_pos--; } // B↓→S3, -1
break;
case 1: // S1
if (ab == 0x11) { enc_state = 2; encoder_pos++; } // B↑→S2, +1
else if (ab == 0x00) { enc_state = 0; } // A↑→S0, 稳态
break;
case 2: // S2
if (ab == 0x10) { enc_state = 3; encoder_pos--; } // B↓→S3, -1
else if (ab == 0x00) { enc_state = 0; } // A↓? 实际不会,防错
break;
case 3: // S3
if (ab == 0x01) { enc_state = 1; encoder_pos++; } // A↓→S1, +1
else if (ab == 0x00) { enc_state = 0; } // B↑→S0, 稳态
break;
}
}
}
4.3 实时性保障与去抖策略
- 中断优先级设置 :EXTI0中断优先级设为
NVIC_PRIORITYGROUP_4下的最高级(0),确保响应延迟<1μs; - 主循环防抖 :在
loop()中不直接使用encoder_pos,而是读取后清零并累积到安全变量:cpp int16_t delta = __atomic_fetch_and(&encoder_pos, 0, __ATOMIC_SEQ_CST); // 原子操作 current_second = (current_second + delta + 60) % 60; // 循环取模
此方式避免了中断与主循环对同一变量的竞争,且__atomic_fetch_and在ARM Cortex-M4上编译为单条LDREX/STREX指令,无锁高效。
5. 渲染引擎:TFT_eSPI像素级绘制与性能优化
渲染是整个转盘UI的性能瓶颈所在。135×240屏全屏刷新需64.8KB数据,SPI 40MHz理论带宽为5MB/s,全帧刷新约13ms。但转盘只需刷新局部区域(刻度与数字),目标是将单次刷新控制在 ≤3ms ,以支持60Hz视觉流畅度。
5.1 局部刷新(Partial Update)策略
TFT_eSPI的 setAddrWindow(x0,y0,x1,y1) 函数可设置显存写入窗口。针对转盘特性,采用 增量式局部刷新 :
- 每次仅刷新当前秒数 current_second 及相邻±2秒共5个刻度区域;
- 每个刻度区域定义为:以坐标(x,y)为中心,宽高各32像素的矩形;
- 刷新前先用 fillRect() 擦除旧区域(填充背景色),再重绘新内容。
// 定义刻度区域尺寸
#define TICK_RECT_W 32
#define TICK_RECT_H 32
void refreshTickRange(int16_t start_sec, uint8_t count) {
for (uint8_t i = 0; i < count; i++) {
uint8_t sec = (start_sec + i) % 60;
TickPoint* p = &numbers[sec]; // 获取数字位置
// 计算擦除区域(以p->x,p->y为中心)
int16_t x0 = constrain(p->x - TICK_RECT_W/2, 0, TFT_WIDTH - TICK_RECT_W);
int16_t y0 = constrain(p->y - TICK_RECT_H/2, 0, TFT_HEIGHT - TICK_RECT_H);
// 擦除旧区域
tft.fillRect(x0, y0, TICK_RECT_W, TICK_RECT_H, TFT_BLACK);
// 重绘数字(使用预生成字体)
tft.setTextDatum(MC_DATUM); // 居中对齐
tft.setTextSize(p->font_size);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.drawString(String(sec), p->x, p->y);
}
}
5.2 自定义字体生成与加载
原始项目生成了16–75号共60种字号的GFX字体数组。在STM32F4上,将全部字体固化在Flash中会占用大量空间(每个字号约2–5KB)。本项目采用 按需加载+字节码压缩 策略:
- 字体生成工具 :使用
fontconvert工具(TFT_eSPI配套),将DejaVu Sans字体导出为gFontXX数组; - Flash存储优化 :仅存储16、24、32、48、64、75六种关键字号,覆盖主要视觉层级;
- 运行时插值 :若请求字号不在预存列表中,则线性插值缩放最近的两个字号位图(如请求56号,则插值48号与64号)。
字体数组声明示例( Fonts/gFont32.h ):
// 32号字体,字符集'0'-'9'
const uint8_t gFont32[] PROGMEM = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // '0' 的位图数据...
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // '1' ...
// ... 共10个字符,每个字符32×32=1024字节
};
加载时通过函数指针切换:
extern const uint8_t gFont16[];
extern const uint8_t gFont24[];
extern const uint8_t gFont32[];
extern const uint8_t gFont48[];
extern const uint8_t gFont64[];
extern const uint8_t gFont75[];
const uint8_t* getFontPtr(uint8_t size) {
if (size <= 20) return gFont16;
if (size <= 28) return gFont24;
if (size <= 40) return gFont32;
if (size <= 56) return gFont48;
if (size <= 70) return gFont64;
return gFont75;
}
此方案将字体Flash占用从~200KB降至~30KB,且插值缩放对数字字体影响极小(人眼难以分辨1–2像素差异)。
6. 时间语义绑定与表盘逻辑实现
将通用转盘UI升级为“赛博怀表”表盘,核心在于 时间语义的精确绑定 与 低功耗运行保障 。
6.1 秒级时间源同步
转盘显示值 current_second 必须与真实时间严格同步。采用STM32的RTC(Real-Time Clock)作为基准源:
- RTC配置为LSE(32.768kHz)驱动,精度±2ppm;
- 开启秒中断(
RTC_IT_SEC),每秒触发一次; - 在RTC中断服务函数中更新全局秒计数器,并标记渲染标志:
volatile uint8_t rtc_second_flag = 0;
void HAL_RTCEx_RTCEventCallback(RTC_HandleTypeDef *hrtc) {
if (__HAL_RTC_ALARM_GET_FLAG(hrtc, RTC_FLAG_ALRAF)) {
// 报警中断(可选)
}
if (__HAL_RTC_SECOND_GET_FLAG(hrtc, RTC_FLAG_SEC)) {
rtc_second_flag = 1; // 设置刷新标志
__HAL_RTC_SECOND_CLEAR_FLAG(hrtc, RTC_FLAG_SEC); // 清除标志
}
}
// 主循环中检查
if (rtc_second_flag) {
rtc_second_flag = 0;
current_second = (current_second + 1) % 60; // 递增秒数
refreshTickRange(current_second, 1); // 仅刷新当前秒
}
此设计确保显示刷新与物理秒脉冲严格对齐,消除软件延时导致的漂移。
6.2 低功耗模式集成
作为怀表,待机功耗至关重要。在无交互时,MCU进入Stop模式(Stop Mode):
- 关闭所有外设时钟(除RTC、LSE);
- 保持RTC运行,配置Alarm中断唤醒;
- 编码器EXTI中断仍有效,任何旋转立即唤醒;
- 唤醒后重新初始化TFT_eSPI(因FSMC时钟关闭);
void enterStopMode() {
__HAL_RCC_PWR_CLK_ENABLE();
HAL_PWR_EnableWakeUpPin(PWR_WAKEUP_PIN1); // PA0 (编码器A)
HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);
// 唤醒后自动执行,需重初始化外设
}
实测待机电流从8mA降至120μA,续航从12小时提升至3周以上。
7. 工程实践陷阱与调试经验
在移植与调试过程中,踩过多个典型坑,这些经验比代码本身更具价值:
7.1 屏幕花屏的三大元凶
- SPI时序不匹配 :ST7789V对SPI CPOL/CPHA有严格要求(CPOL=0, CPHA=0)。曾因CubeMX误配为CPHA=1,导致随机花屏。解决方案:用逻辑分析仪抓取SPI波形,对照数据手册时序图校验;
- FSMC地址线错位 :当使用FSMC 8080并口时,若数据线D0-D15接错(如D0接FSMC_D1),会导致颜色通道错乱(红蓝颠倒)。解决方案:用
fillScreen(TFT_RED)等纯色测试,快速定位接线问题; - DMA缓冲区溢出 :
DMA_BUFFER_SIZE设为2048,但单次pushImage()发送高度>2048/2=1024像素时会溢出。解决方案:在pushImage()前强制分块,每块≤1024像素。
7.2 编码器跳变的硬件根源
即使软件FSM完美,仍出现偶尔跳变。最终发现是PCB布局问题:
- EC11的GND引脚未就近接入MCU GND,而是绕路至电源模块;
- 形成共模噪声,耦合到AB信号线;
- 解决方案:在EC11焊盘旁打孔,用0Ω电阻直连MCU GND,跳变消失。
7.3 字体锯齿的终极解决
GFX字体在斜线处有明显锯齿。尝试过抗锯齿算法,但MCU算力不足。最终采用 物理级优化 :
- 将屏幕亮度调至70%(通过 setBrightness(70) );
- 人眼在中等亮度下对锯齿敏感度显著降低;
- 同时降低背光频闪(PWM频率从1kHz升至20kHz),消除视觉疲劳。
这一招不增加代码、不耗CPU,却让显示效果提升一个档次。
8. 成果展示与可扩展性分析
最终实现的赛博怀表表盘,在STM32F407VGT6 + ST7789V硬件上达到以下指标:
- 刷新性能 :单刻度刷新≤1.2ms,全范围(5刻度)≤4.5ms,支持60Hz流畅动画;
- 内存占用 :Flash 128KB(含字体),RAM 42KB(含双缓冲显存);
- 功耗表现 :活跃模式18mA,Stop模式120μA;
- 交互精度 :编码器最小步进1秒,无丢步、无误触发。
该架构具备强可扩展性:
- 多表盘支持 :通过 struct WatchFace 抽象,可轻松添加模拟表盘、温度计等,共享编码器与渲染引擎;
- 无线同步 :预留ESP32-WROOM-32模块接口,通过UART透传NTP时间,实现网络授时;
- 手势增强 :利用屏幕触摸IC(如FT6236),增加长按进入设置、双击切换表盘等交互。
这不仅是“剽代码”的胜利,更是嵌入式工程师对物理约束、数学模型与软件艺术的综合驾驭。当指尖旋转编码器,看到0–60秒数字在135×240的方寸之间流动,那既是电路的脉动,也是工程思维的具象化——在确定性的硅基世界里,我们亲手雕刻出不确定的美。
更多推荐


所有评论(0)