SMARTWATCH 学习手册有 C 基础 · 嵌入式从零开始
裸机部分 / 逻辑综合与收束

裸机逻辑综合:把手表各部分接起来

驱动分别能运行以后,真正的问题变成:按着按钮时能不能继续采集?无线消息会不会堵住屏幕?声音计算时哪块数组还能被改写?这一篇把现有知识放进一个反复执行的主循环,最后说明它怎样过渡到任务调度。

前置:外设、无线与协议、DMA拓展知识可以按需回看本篇结束裸机部分

第 1 节 · 一个主循环怎样照顾这么多事情?

裸机在这里指没有实时操作系统为多个任务安排执行:程序初始化后,主要靠一个 while(1) 不断巡查,各种中断在需要时打断它。中断仍然存在,DMA 也仍能搬运;“裸机”不等于把所有工作都用阻塞轮询来做。

当前仓库完整固件已经使用 FreeRTOS。本篇建立一份独立裸机教学流程:保留已学寄存器驱动与协议方法,主循环安排处理,回调短小地交接数据。仓库中带任务通知的麦克风部分,在第 5 节展示如何先简化成一个使用者。

工作什么时候处理本轮完成什么
按钮每轮查看,等待电平稳定识别一次按下,改变显示状态
无线接收每轮处理已有字节恢复并分发完整命令
两颗传感器水位中断发出通知取出一批,保留实际数量
麦克风完整帧就绪读取稳定帧,计算后归还
电池与上报例如每五秒一次测电压并发送一条消息
屏幕状态改变或需要刷新时只提交本轮所需更新

每轮做完能做的事,就继续下一轮。不要在按钮分支里一直等松手,也不要在没有声音帧时原地等待;否则其他工作都轮不到。这里先用具体变量和现有函数接起来,等出现重复或复杂状态,再决定是否提取函数或分文件。

裸机主循环的六项具体工作主循环依次检查按键、处理无线字节、读取已通知的传感器、处理声音帧、检查周期上报和更新显示。中断与DMA提供事件,循环继续下一轮。主循环每轮巡查:有事就处理,暂时无事就继续检查按键只确认稳定的状态变化处理无线字节组帧并执行完整命令读取传感器收到水位通知才读一批处理声音帧只计算已经稳定的帧检查周期上报到期才测电池并发送更新显示状态按需要提交屏幕变化中断和 DMA 在需要时通知;主循环避免原地长时间等待。
图中的框表示实际工作顺序,不要求先创建一套框架或六个陌生的包装函数。

第 2 节 · 不用 delay,也能等按钮稳定

按键接点可能在切换时短暂抖动。GPIO 篇已经认识这个现象,现在通过“变化后过了多久”判断,而不是把整个主循环停住。先在主循环外准备这些变量,PC0 对应本项目的一路按键,按下为低:

GPIO_PinState last_level = GPIO_PIN_SET;
GPIO_PinState stable_level = GPIO_PIN_SET;
uint32_t changed_at = 0;
uint8_t display_on = 1;

每一轮先取 now = HAL_GetTick(),单位毫秒。发现新电平变化就重新计时;稳定超过 20 毫秒且与上次确认状态不同,才承认一次状态变化。20 毫秒是教学参数,可根据实际按键调整。

GPIO_PinState level = HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_0);
if (level != last_level) {
    last_level = level;
    changed_at = now;
}
if (level != stable_level && (uint32_t)(now - changed_at) >= 20) {
    stable_level = level;
    if (stable_level == GPIO_PIN_RESET) {
        display_on = !display_on;
        GC9A01_SetBacklight(display_on);
    }
}

GC9A01_SetBacklight(on) 接收 0 或非零,关闭或开启背光,不返回结果。按住按钮时,稳定状态不再变化,不会每轮重复翻转;主循环还能继续做下面的事情。这里操作背光,避免为解释调度先引入一整套页面框架。

周期检查也是同一个思路。在循环外记录上次执行时刻,在循环里用无符号差值判断到期。无符号毫秒计数会回绕,这种相减写法能处理通常的回绕情况;周期应远小于整个计数范围,也需要保证程序持续检查。

第 3 节 · 水位中断只通知,主循环再去读取

两颗传感器按联合采集篇的配置启动:运动传感器积累一批后,INT1 让 PB4 出现上升沿。裸机教学工程不释放任务信号量,先用一个标志告诉主循环“有一批需要处理”。在主循环外声明:

static volatile uint8_t sensor_ready = 0;
static int16_t acceleration[26 * 3], gyroscope[26 * 3];
static uint32_t red[32], infrared[32];

把独立裸机工程的对应 GPIO 回调写成下面这样。STM32U5 使用上升沿回调名称;原工程中已有的同名回调应替换为这份教学实现,不能在同一工程保留两份定义。

void HAL_GPIO_EXTI_Rising_Callback(uint16_t pin)
{
    if (pin == GPIO_PIN_4) sensor_ready = 1;
}

主循环读取并清除标志时,中断也可能写它。用很短的一段临界区完成交接:先保存原来的中断屏蔽状态,暂时禁止中断,取走标志,再恢复原状态。读取传感器不放在禁止中断的范围里。

uint32_t saved = __get_PRIMASK();
__disable_irq();
uint8_t need_read = sensor_ready;
sensor_ready = 0;
__set_PRIMASK(saved);

随后在正常主循环里调用已经学过的批量读取接口。两颗器件先前各自初始化成功才进入这段流程:

if (need_read) {
    uint8_t motion_count = LSM6DSOX_GetFifoBatch(
        acceleration, gyroscope, NULL, 26);
    uint8_t optical_count = MAX30102_GetFifoBatch(red, infrared, 32);
    /* 两个返回值分别表示本次实际读到多少组。 */
}

接下来按 联合采集篇 已解释的方法整理运动数据的采样时刻,再交给需要完整窗口的计算。每批读取数量不总刚好是名义值,不能把未写入的数组位置当作新数据。布尔通知也不能累计任意多个历史事件:处理长期不及时,仍需检查队列积压与溢出。

第 4 节 · 把无线解析和周期上报接进来

串口中断负责存字节,主循环每轮调用 ble_uart_process() 解析并分发。它没有参数;接收队列为空时很快返回。当前实现会尽量处理已有队列,负载变大以后还要测量这一段耗时。

ble_uart_process();

电池上报不需要每轮都做。循环外准备 uint32_t last_battery = HAL_GetTick();,每五秒读取一次。battery_read_pct() 会再次触发电池读取,所以这里对已经取得的毫伏值按当前项目的粗略区间换算,避免为了同一条消息连测两次:

if ((uint32_t)(now - last_battery) >= 5000) {
    last_battery = now;
    uint16_t mv = battery_read_mv();
    if (mv != 0) {
        uint8_t percent = mv <= 3700 ? 0 :
                          mv >= 4200 ? 100 : (mv - 3700) / 5;
        int sent = ble_uart_send_battery(mv, percent);
        /* sent 为 0 表示本次串口发送成功。 */
    }
}

last_battery 直接设成 now,表示晚处理时跳过错过的上报时刻,下次从本次重新计时。这样不会恢复后突然补发一大串旧电池消息。对需要连续采样的数据,则应依靠传感器队列和实际样本数量,不能使用相同的跳过办法。

上报仍使用当前阻塞串口接口,电池转换也要等结果,屏幕提交同样可能花时间。把它们放进不同分支,不会自动让函数变成异步;总执行时间必须短到足以满足麦克风缓冲和无线接收的处理期限。

第 5 节 · 麦克风先给一个使用者交出完整帧

仓库麦克风同时服务声音判断与关键词识别,包含任务通知和两份独立状态。裸机教学先只保留一个使用者:DMA 写工作数组,回调复制到一份稳定帧,主循环算完再释放。这样可以把“数据正在写”与“数据可以计算”分清楚,再迁移到原驱动。

在独立工程中准备两个持续有效的数组,以及两个状态。声音转换、DMA 配置和启动方法沿用 ADC 与麦克风篇;这一教学实现代替带实时操作系统通知的 spu0410.c 回调。

static uint16_t capture[256], audio_frame[256];
static volatile uint8_t audio_ready = 0;
static uint8_t first_half_copied = 0;

前半完成时,如果主循环已经拿着上一帧计算,就先保留它;否则复制新前半并记下来。memcpy 来自 string.h,最后一个参数单位是字节。

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *adc)
{
    if (adc->Instance != ADC1 || audio_ready) return;
    memcpy(audio_frame, capture, 128 * sizeof(uint16_t));
    first_half_copied = 1;
}

后半完成时,只有本轮前半也已经复制,才接上后半并报告整帧完成。__DMB 是内存屏障,让前面的数据写入先完成,再发布就绪标志。

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *adc)
{
    if (adc->Instance != ADC1 || audio_ready || !first_half_copied) return;
    memcpy(audio_frame + 128, capture + 128, 128 * sizeof(uint16_t));
    first_half_copied = 0;
    __DMB();
    audio_ready = 1;
}

初始化后,以 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)capture, 256) 启动,先检查返回 HAL_OK。主循环发现 audio_ready 后,audio_frame 在本次计算期间不会被回调改写;算完才清除标志。先用一帧均值去掉偏置,再求平均平方量:

if (audio_ready) {
    float mean = 0, energy = 0;
    for (int i = 0; i < 256; i++) mean += audio_frame[i];
    mean /= 256.0f;
    for (int i = 0; i < 256; i++) {
        float v = (float)audio_frame[i] - mean;
        energy += v * v;
    }
    energy /= 256.0f;
    __DMB();
    audio_ready = 0;
    /* 使用 energy 更新声音强弱显示或后续判断。 */
}

这一简化策略会在处理旧帧时跳过新帧,所以适合先理解单次声音强弱更新,不能直接声称连续音频无丢样。关键词识别需要连续窗口时,要进一步保证处理速度和连续性,再回看现有驱动怎样分别服务两个使用者。这些增加的管理都来自具体需求。

第 6 节 · 这些部分怎样组成主循环?

初始化阶段先完成时钟与 GPIO、串口、总线、屏幕、ADC 和 DMA,再初始化器件、配置电池注入通道、准备声音数组,最后开始连续采集与串口接收。每个初始化结果检查成功后才使用对应器件。

主循环按“取当前时间 → 看按键 → 处理已有串口 → 取走传感器通知并读取 → 处理已就绪的声音帧 → 检查电池上报期限”的顺序,把前面各段直接接进去。屏幕状态变化时更新需要的区域;已经学过的显示驱动或 LVGL 移植可以在这里接入,不用每轮都全屏重画。

先验证一起运行,再决定怎样拆任务

持续按住按钮时,传感器和无线应该继续工作;临时断开传感器后,有限超时应让读取结束,按钮和串口仍能继续。故意减慢声音计算,观察确实会跳帧,再对照 DMA 的覆盖期限解释原因。这样才能知道系统哪一段占时间,而不是只看到单个驱动各自能运行。

如果已经学习看门狗拓展,只有关键路径确实完成一轮工作后再安排喂狗,不能因为任意中断仍在发生就无条件刷新。看门狗用于从故障中恢复,不能代替解决长期处理不及时的问题。

裸机部分到这里结束。

已经能解释数据从器件进入、暂存、计算、显示和发送的完整过程,也能用中断与主循环完成协作。下一阶段进入 FreeRTOS:把这些不同等待条件和处理期限的工作交给任务调度,继续处理共享资源、通知和执行顺序。

实时操作系统不会自动修复过慢的算法、错误的缓冲规则或没有上限的等待。它改变工作怎样被安排;这一篇已经明确的数据路径和时间要求,在任务版里仍然成立。

对照项目

  • main.c:实际任务启动、传感器通知和联合采集;本篇采用独立裸机教学安排。
  • spu0410.c、ble_uart.c 与 app.c:分别对应声音帧、无线协议及应用逻辑。