裸机逻辑综合:把手表各部分接起来
驱动分别能运行以后,真正的问题变成:按着按钮时能不能继续采集?无线消息会不会堵住屏幕?声音计算时哪块数组还能被改写?这一篇把现有知识放进一个反复执行的主循环,最后说明它怎样过渡到任务调度。
第 1 节 · 一个主循环怎样照顾这么多事情?
裸机在这里指没有实时操作系统为多个任务安排执行:程序初始化后,主要靠一个 while(1) 不断巡查,各种中断在需要时打断它。中断仍然存在,DMA 也仍能搬运;“裸机”不等于把所有工作都用阻塞轮询来做。
当前仓库完整固件已经使用 FreeRTOS。本篇建立一份独立裸机教学流程:保留已学寄存器驱动与协议方法,主循环安排处理,回调短小地交接数据。仓库中带任务通知的麦克风部分,在第 5 节展示如何先简化成一个使用者。
| 工作 | 什么时候处理 | 本轮完成什么 |
|---|---|---|
| 按钮 | 每轮查看,等待电平稳定 | 识别一次按下,改变显示状态 |
| 无线接收 | 每轮处理已有字节 | 恢复并分发完整命令 |
| 两颗传感器 | 水位中断发出通知 | 取出一批,保留实际数量 |
| 麦克风 | 完整帧就绪 | 读取稳定帧,计算后归还 |
| 电池与上报 | 例如每五秒一次 | 测电压并发送一条消息 |
| 屏幕 | 状态改变或需要刷新时 | 只提交本轮所需更新 |
每轮做完能做的事,就继续下一轮。不要在按钮分支里一直等松手,也不要在没有声音帧时原地等待;否则其他工作都轮不到。这里先用具体变量和现有函数接起来,等出现重复或复杂状态,再决定是否提取函数或分文件。
第 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:分别对应声音帧、无线协议及应用逻辑。