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 屏幕花屏的三大元凶

  1. SPI时序不匹配 :ST7789V对SPI CPOL/CPHA有严格要求(CPOL=0, CPHA=0)。曾因CubeMX误配为CPHA=1,导致随机花屏。解决方案:用逻辑分析仪抓取SPI波形,对照数据手册时序图校验;
  2. FSMC地址线错位 :当使用FSMC 8080并口时,若数据线D0-D15接错(如D0接FSMC_D1),会导致颜色通道错乱(红蓝颠倒)。解决方案:用 fillScreen(TFT_RED) 等纯色测试,快速定位接线问题;
  3. 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的方寸之间流动,那既是电路的脉动,也是工程思维的具象化——在确定性的硅基世界里,我们亲手雕刻出不确定的美。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐