本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

手持血氧与吸氧差压监测仪

这是一台手持设备:用光电容积脉搏波测心率和血氧,用差压传感器判断“有没有在吸气”, 再据此开关电磁阀,OLED 显示结果,锂电池供电并自己做电量估算。 主控是 HC32F460,跑 FreeRTOS,五个任务各管一件事。 这篇记录的重点有两块:一是传感器算法该怎么用、哪些不是我写的; 二是这个项目里我犯的一个很典型的错误——把内置 Flash 当成参数区,每次写都整扇区擦。

HC32F460 MAX30102 差压传感器 FreeRTOS 信号稳定性 已发布
已发布 · 个人学习项目

手持血氧 / 心率 + 吸氧差压监测仪

做这台设备的出发点是我想把两件完全不同的事放进同一台手持机里:一件是光学测量 (用 LED 和光电二极管看血液容积的变化,算出心率和血氧),另一件是气压测量 (用差压传感器看管路里的压力波动,判断吸气动作并开关阀门)。 主控用 HC32F460(Cortex-M4F),跑 FreeRTOS,五个任务分管呼吸检测、心率血氧、 显示、电源与系统。 结论是:测量链路都跑通了,稳定性判据和呼吸判定这两处我自己加的逻辑效果不错; 但参数存储那一块我写得很糟——每次保存都擦掉一整个扇区,且没有校验,这是个明确的隐患。

PLATFORMHC32F460KCTA / Cortex-M4F
STACKFreeRTOS + 光电容积脉搏波 + 差压采集 + OLED
TOOLSKeil MDK + 示波器
STATUS个人学习项目 · 功能已实现

一、这台手持设备要测什么

设备要回答两个问题。第一个是“人的心率和血氧大概是多少”: 指尖贴在光学传感器上,两路不同波长的 LED 交替发光,光电二极管接收透射/反射光, 得到的是随心跳起伏的光强波形——也就是光电容积脉搏波(PPG)。 第二个是“现在有没有在吸气”:管路里串了一个差压传感器, 吸气时压力会出现一个负向的尖峰,把这个尖峰识别出来,就能在吸气的同时打开电磁阀。

剩下的都是外围:OLED 显示实时数值和历史曲线、锂电池充放电管理、 按键与自动息屏、看门狗。整机是手持的,所以低功耗与“误动作少”比“功能多”更重要。

器件接口在这台设备里的作用
MAX30102软件 I²C(SCL/SDA 各一根 GPIO)+ 一路外部中断脚心率 / 血氧的 PPG 传感器,FIFO 里读回红光与红外两路原始数据
XGZP6897D软件 I²C(另一组 GPIO)差压(呼吸压力)传感器,读 24 位压力码并换算成 Pa
OLED 模块软件 I²C 或并口(由显示模块驱动)显示心率、血氧、压力、电量与充电页面
片内 ADC(3 通道)内部通道 0 接吸氧传感器系数电位器,通道 1 接充电电压,通道 2 接电池电压
电磁阀GPIO检测到吸气时开阀,呼气或超时后关阀
传感器电源域使能GPIO不用传感器时断电,降低静态功耗
充电管理2 路 GPIO 控制 + 1 路充电状态输入锂电池充电使能与充电状态检测
唤醒按键GPIO + 外部中断开关机与唤醒
片内看门狗内部主循环与任务里定期喂狗

我特意把两个传感器放在两组独立的软件 I²C 上。原因不是我想要两个总线, 而是我不想让“差压读取时关中断”影响光学传感器的 FIFO 读取节奏—— 软件 I²C 必须靠临界区保护时序,两条总线分开之后,两边的临界区互不干扰。 代价是多占两个 GPIO 和一份几乎一样的 I²C 代码。

主控居中,外接:光电容积脉搏波传感器、差压传感器、显示屏、电池与电量采集、按键
图 1 · 手持设备框图

二、传感器与信号链

光学那一路:PPG

MAX30102 内部有 LED 驱动、光电二极管、18 位 ADC 和一个 32 点 FIFO。 我用到它的时候,配置流程是固定的几步:先复位并检查器件 ID,再配 FIFO 的 平均与滚动方式、配 SpO₂ 模式下的 ADC 分辨率与采样率,然后设两路 LED 的驱动电流, 最后开中断。数据不主动读寄存器,而是等 FIFO 里攒够样本后一次性读回, 分别放进红光缓冲和红外缓冲两个数组。

寄存器层面我用到的并不多,主要是:中断状态与使能两组、 FIFO 写指针 / 溢出计数 / 读指针三个(这三个是判断“有没有丢样本”的关键)、 FIFO 数据寄存器、FIFO 配置、模式配置、SpO₂ 配置、两路 LED 电流、 以及器件 ID 与版本寄存器。调试时我最常看的是溢出计数—— 如果它一直在涨,说明我的读取任务没跟上采样率,算法输入里就有空洞。

气压那一路:差压

差压传感器是个更“笨”的器件:必须自己触发测量、自己轮询状态、自己拼数据。 我用到的完整流程是这样的:

  1. 读配置寄存器,把表示“输出原始数据”的那一位清掉,选择输出校准后的数据;
  2. 往控制寄存器写一个组合测量命令,让它先测温度、再测压力;
  3. 轮询控制/状态寄存器,等表示“转换完成”的那一位(bit3)置位;
  4. 读三个压力寄存器,拼成一个 24 位有符号数;
  5. 把这个数除以 8192 得到 Pa(也就是 8192 个码对应 1 Pa), 最高位为 1 时按负数处理;
  6. 最后加一个固定的正向偏置再向上传递(偏置值属于换算链参数,本文不公开), 让“零压”落在正数区间,避免负数在显示与滤波里到处做符号判断。
/* 差压一次完整读取:写配置 → 触发组合测量 → 轮询就绪 → 拼 24 位 → 换算 Pa
   (按个人理解重写的最小片段;命令值与掩码用具名符号表示,不给具体字节) */
int32_t xgzp_read_pa(void)
{
    write_reg(REG_CFG, read_reg(REG_CFG) & MASK_CALIBRATED);   /* 选校准后数据 */
    write_reg(REG_CTRL, CMD_MEASURE_T_AND_P);                  /* 组合测量 */
    do {
        st = read_reg(REG_CTRL);                   /* 轮询转换完成位 */
        if (st & STATUS_ERROR) return SENSOR_ERR;  /* 异常标志:提前退出,避免死等 */
    } while ((st & STATUS_READY) == 0u);
    code24 = ((uint32_t)read_reg(REG_PD_H) << 16)
           | ((uint32_t)read_reg(REG_PD_M) << 8)
           |  (uint32_t)read_reg(REG_PD_L);
    return scale_to_pa(code24) + ZERO_BIAS;        /* 标度与偏置见正文说明 */
}

这个流程我拆成了三个函数:一个只发转换命令、一个只轮询状态、一个只读结果。 拆开的原因不是为了好看,而是为了控制临界区的长度—— 软件 I²C 的时序必须靠关中断保护,但“发命令 + 等转换 + 读结果”整个过程可能几十毫秒, 全放进临界区会让系统失去响应。拆开之后,只有真正翻 GPIO 的那几段在临界区里。

三、数据流:100 Hz 采样、500 点缓冲、算法输入

光学那一路的算法对输入长度有硬性要求:采样率固定 100 Hz, 缓冲长度等于“采样率 × 5 秒”,也就是 500 点。这两个数字不是我选的, 是算法本身要求的——它需要至少几秒的波形才能找出足够的脉搏周期。

所以数据流是这样的:传感器以 100 Hz 往自己的 FIFO 里放样本, 我的心率任务被中断唤醒后把 FIFO 读空,把红光与红外样本分别追加到两个 500 点缓冲里, 每凑满一整屏 500 点就调用一次算法,算出心率与血氧,然后整体前移窗口继续。

500 点 × 2 路 × 每个样本 4 字节,两个缓冲就是 4 KB RAM。 这台芯片的 RAM 够用,但对任务的栈分配就有影响: 心率任务分到的栈是几个任务里最大的(系统任务除外), 因为算法里有好几层嵌套调用和中间数组;具体的栈深度属于工程配置,本文不列。

任务职责触发方式说明
呼吸检测任务差压采集、两级中值、开门与关门判定固定周期触发首帧之前先延时等传感器稳定
心率 / 血氧任务读空 FIFO、维护缓冲、凑满一屏后调用算法由传感器中断唤醒栈深度是几个任务里最大的
显示任务刷新页面与数值按页面状态刷新—
电源任务电量换算、充电状态、息屏超时由软件定时器驱动几档超时用同一个时基分频得到
系统任务最高优先级占位,空转让出空转让出优先级与栈深度属于工程配置,本文不列
启动任务创建其余任务动态创建创建完成后自删

除了任务,我还用了两个软件定时器:一个负责“心跳”,一个作为电源与显示时基。 时基里我没有给每个功能单独建定时器,而是用模运算分频: 计数值对某个常数取模等于 0 就代表一个短超时(充电页自动息屏), 对另一个常数取模等于 0 就代表一个长超时(结束充电开关窗口)。 这样就避免了为每个超时建一个定时器,代价是这些超时的精度只有时基的量级—— 对这两个场景来说完全够用;两档超时各是多长,是按使用习惯定的产品设定值,本文不列。

还有个小细节:呼吸检测任务在第一次采样之前先延时一小段(等待时间按实测定)。 因为传感器上电后需要一段时间输出才稳定,如果上电就采, 前几帧的数据会带着一个大偏移,很容易被误判成一次“吸气”。

顺便说一句,这台设备是纯本地的:没有总线、没有 Modbus、没有远程上报, 两个传感器都挂在本地 I²C 上,唯一的串口只用来打印调试信息。 如果要把这些测量值对外暴露出去,我大概会照 《Modbus RTU 从站实现笔记》 里的做法挂成一张寄存器表,让上位机轮询——那套“把中间量也暴露出来”的思路 在远程排查时很省事。

从传感器开始:采样 → 环形缓冲 → 算法处理 → 稳定性判定 → 显示与提示
图 2 · 数据流图

四、算法从哪来:如实说明移植来源

这一节我想写清楚一点,因为它是这个项目里最容易含混过去的地方。

心率与血氧的计算算法不是我写的。它来自传感器厂商公开的一份参考设计 (Maxim 的 MAXREFDES117#),我把它移植进了自己的工程。 这段代码在源文件头部保留了原作者的版权声明与项目名称,我没有删掉它, 也不应该删掉它——参考设计以 MIT 风格的宽松开源许可发布, 条款要求保留版权与许可声明,具体内容以厂商原始发布为准。

算法本身做了这些事:对 500 点红外波形做直流分量去除与滤波, 检测脉搏峰值并剔除过近的峰,算出相邻峰的间隔得到心率; 同时用红光与红外两路的交流/直流比值算出 R 值,再用一张查表 把 R 值映射成血氧百分比。这张表是用一个二次多项式生成的 (二次多项式有三个系数,具体值属于厂商参考设计里的参数,本文不转抄), 头文件里把它作为注释保留着,方便需要时重新生成。

我自己做的是这些:

  • 把算法任务化:在裸机参考设计里它是一个大循环,我把它改成“中断唤醒 + 凑满 500 点调用一次”的任务结构;
  • 把传感器的寄存器配置与 FIFO 读取按自己的软件 I²C 驱动重写;
  • 加了稳定性判据(下一节),参考设计里没有这一层;
  • 加了手指检测与失败判定,把“没放好手指”和“信号不稳”区分开;
  • 做了显示、页面状态机与两次测量的结果管理;
  • 把算法需要的中间数组放进任务栈并核算了栈深度。

我把这条界线写清楚,一是因为许可要求,二是因为我觉得“移植”和“原创”混在一起讲, 对自己没有好处:真正学到东西的是移植过程中被迫理解的每一步—— 为什么必须 100 Hz、为什么窗口要 5 秒、为什么先滤直流再找峰。

顺带说一句许可之外的现实问题:参考设计的代码风格是“一个函数干完所有事”, 放在 MCU 上就是一大段连续计算。我把它放进了 FreeRTOS 任务里, 就必须重新考虑栈和优先级——这是移植里最容易出事的地方, 也是我为什么坚持给它单独分配一个大栈。

五、用“均方差”判断信号稳不稳定

算法每一轮都会给出一个心率和一个血氧值,但这些值会跳。 手指按得松一点、动一下、环境光变一下,结果就可能差好几个单位。 如果直接把每一轮结果刷到屏幕上,屏幕上的数字会一直在跳,既不好读,也不可信。

我加的做法是:维护两个长度 9 的滑动窗口,分别存最近 9 次的血氧值和心率值。 每来一轮新结果,先算这 9 个点的均值,再算这 9 个点相对均值的离散程度 (源码里我把它叫“均方差”),只有这个离散程度小于我实测定下的门限, 才认为这一轮结果“稳”,才把它刷新到屏幕并置检测状态为成功。 窗长是固定的 9 点;除了窗长以外的常数(也就是那个门限)本文不给具体值。

/* 稳定性判据:固定长度滑动窗先求均值,再求"相对均值的离散程度";
   离散程度小于门限才认为这一轮结果可信。门限是我实测定下来的,本文不给具体值。
   (按个人理解重写的最小片段,门限用具名符号表示) */
#define AVG_BUF_SIZE   9          /* 窗长:最近 9 轮结果 */

static int result_is_stable(const uint32_t *buf, uint32_t *out_avg)
{
    uint32_t sum = 0u, var = 0u, avg, diff;
    int i;

    for (i = 0; i < AVG_BUF_SIZE; i++) { sum += buf[i]; }
    avg = sum / AVG_BUF_SIZE;
    *out_avg = avg;

    for (i = 0; i < AVG_BUF_SIZE; i++) {
        diff = (buf[i] > avg) ? (buf[i] - avg) : (avg - buf[i]);
        var += diff * diff;
    }

    return ((var / AVG_BUF_SIZE) <= STABLE_LIMIT_MEASURED) ? 1 : 0;
}

如果一直不稳怎么办?我给了一个上限:连续若干轮不稳定就判定这次检测失败, 提示重新测量,而不是无限等下去。这个轮数上限是我实测出来的—— 正常情况下手指放稳后通常两三次就稳了,超过上限还稳不下来,基本就是没放好或者信号太差。

另外我还单独做了一条“手指检测”:如果红外的原始直流值低于一个门限, 说明手指没盖住或者没放好。这个门限是我在这套光学结构上实测定下来的,本文不给具体值。 这条判定同样不是单次生效——要连续累计若干次才判失败。 单次判定太容易误报:手抖动一下、或者传感器刚上电,都会瞬间掉到门限以下。

这两条判据加起来的效果是:屏幕上的数字不再是“每一轮的瞬时值”, 而是“最近几秒内稳定下来的值”。我个人感觉这比单纯加一个低通滤波更对症—— 因为问题的本质不是“噪声大”,而是“这一轮结果可不可信”。

六、呼吸检测:阈值、超时与状态推进

呼吸那一路的逻辑看起来简单,写起来要考虑的边界却不少。 传感器给出的是带正向偏置的压力值(单位 Pa),零压落在偏置值附近, 吸气时压力往下走(负向),呼气或静止时回到偏置值附近或略微向上。

我用的判定是双门限加超时:

  • 开启阈值:压力低于我实测定下的开启阈值(相对零点)就认为“开始吸气”—— 清零计数、置“正在使用”标志并开始计时、打开电磁阀;
  • 关闭阈值:压力高于我实测定下的关闭阈值就认为“这一口气结束了”—— 清零计数并关闭电磁阀;
  • 超时关阀:如果压力卡在两个门限之间(既没低到开启阈值,也没高到关闭阈值), 就累计“中间态”计数,累计到一定次数(超时约几百毫秒)就主动关阀。 这个次数与超时同样是实测整定出来的,本文不给具体数值。
条件计数动作阀门计时状态
压力 ≤ 开启阈值(我实测定下的开阈值)中间态计数清零开阀置“使用中”,开始计时
压力 ≥ 关闭阈值(我实测定下的关阈值)中间态计数清零关阀保持
两个门限之间且累计达到我定下的次数(超时约几百毫秒)计数继续累计关阀保持
“使用中”置位后连续若干秒没有吸气(时长由我实测定)—关阀停止计时

为什么要有“中间带”而不是一个门限就够?因为我一开始就是单门限(低于开阈值就开、回到开阈值就关), 结果是压力在门限附近抖动时阀门跟着“哒哒哒”地开关。 加了中间带之后,阀门只在两个明确的边界动作;中间带里的抖动交给计数与超时处理, 迟滞的效果立刻就出来了。

那条“无吸气停计时”的保护是另一个独立判断:“已经开始使用但一直没有新的吸气动作” 说明人已经离开了,这时候如果还继续计时,就会算出一段没有意义的时长。 所以置位“使用中”之后会起一个计数器,数到某个上限还没有新的吸气,就停掉计时; 这个上限是实测定下来的,本文只给量级(秒级)。

滤波:两级中值,而不是均值

压力信号进判定之前会先过两级中值滤波:第一级是对连续的 5 个采样点做排序取中间值, 第二级是每 3 次采样再取一次中值。两级都是中值,没有一个地方用均值。

原因是呼吸波形是“脉冲型”的:一次吸气就是压力曲线上一个比较窄的尖峰。 均值滤波会把尖峰削平,峰值变低之后就可能达不到开启阈值,于是漏检。 中值滤波保留尖峰、只削掉孤立的野点,正适合这种波形。 这个取舍我在另外一篇笔记里也写过: 《采样数据的处理:20 点环形缓冲、中值滤波与迟滞控制》 用在采集通道上时,同样是“先想清楚信号形状,再选滤波器”。

/* 5 点中值:冒泡排序取中间那个,用来削掉差压波形上的单点野点,
   又不像均值滤波那样把吸气尖峰削平(教科书级通用算法,按个人理解重写) */
static uint32_t median5(uint32_t *v)
{
    uint32_t tmp; int i, j;
    for (i = 0; i < 5; i++) {
        for (j = 0; j < 4 - i; j++) {
            if (v[j] > v[j + 1]) { tmp = v[j]; v[j] = v[j + 1]; v[j + 1] = tmp; }
        }
    }
    return v[2];
}

零点与系数补偿

差压传感器最麻烦的是零点。器件个体差异、温度变化、管路装配应力, 都会让“没有气流时”的读数偏离理论零点。我做了两层处理:

  1. 零点偏置:换算时先减掉零点基准,把结果变成有正有负的值, 再做判定。原始读数还要经过一个“必须超过某个起步门限才当作有效”的判断, 避免刚上电、读数还没稳定时把零点当成一次吸气。零点基准与起步门限都是实测整定值。
  2. 系数电位器补偿:板上留了一个电位器接在 ADC 通道 0 上, 以一个基准码值为中心,每偏离若干码就补偿 1 Pa。 为了防止电位器噪声导致数值抖动,只有偏离超过一个门限才认定“系数被调过”, 否则按不补偿处理。基准码、补偿比例与门限都是实测整定值,本文不列具体数字。

这段补偿逻辑不优雅,但它解决了一个很现实的问题: 装配完之后每台设备的零点都不一样,如果没有一个现场可调的补偿手段, 就只能改固件重新烧。我宁可留一个电位器和一个看起来“很土”的补偿公式。

七、电池与电量显示

电量显示这件事我一开始以为很简单,做起来才发现“电压→百分比”并不是一条直线。 我用的是最朴素的做法,但把几个边界处理清楚了:

  • 电池电压通过 ADC 通道 2 采集,采集值按 ADC 满量程折算成千分比刻度;
  • 把两个端点定义为常量:一个对应电池的最低可用电压,一个对应充满电压。 这两个端点值是按 ADC 参考电压与满量程反算、再和实测放电曲线对齐后定下来的, 属于产品标定值,本文不列具体数字;
  • 在两端点之间做线性插值,得到千分比刻度,并做上下限钳位, 避免电压超出范围时显示出大于 100% 或者负数;
  • 充电状态单独判定:充电检测通道的值超过一个门限就认为“正在充电”, 否则认为“未充电”,并据此控制充电使能脚(控制是低有效的)。这个门限同样是实测定的。

为什么用千分比而不是百分比?因为中间计算全是整数, 千分比能多留一位精度,显示时再除以 10 就行,不需要引入浮点。 整个电源任务里没有一处浮点运算,这对一颗带 FPU 但主要跑整型逻辑的芯片来说更省事。

充电页面还有两个超时,都是用前面说的那个时基分频实现的: 进入充电页经过一个短超时后自动息屏,避免一直亮着; “充电开关窗口”打开后经过一个长超时没有操作就自动结束,回到普通页面。 这两个时间常数都是按使用习惯定的,不是算出来的,属于产品设定值,本文不列具体秒数。

八、问题与解决

同样按“现象 → 原因 → 办法”来记。

1. 零点漂移导致静息状态被误判成吸气

现象:没有气流时设备偶尔自己开阀。
原因:把传感器的绝对读数当成了压力值,而器件的零点本身有偏移, 还会随温度缓慢漂移。
办法:换算时先减去零点基准、把结果变成有正有负; 再加“读数必须超过起步门限才有效”的判断;最后用系数电位器做现场补偿 (以实测基准码为中心按偏离量补偿,偏离超过我定下的门限才生效)。

2. 手指检测误报

现象:测量过程中偶尔弹“手指未放好”,但手其实没动。
原因:单次采样判定,任何一次瞬时抖动都会触发。
办法:改成累计计数——连续若干次低于门限才判失败。 这类“单次采样就下结论”的错误我在别的项目里也犯过, 后来形成了一个习惯:凡是判定,先想清楚它会被多快的噪声骗到。

3. 心率与血氧数值一直跳

现象:屏幕上的数字每轮都变,读数没有意义。
原因:算法输出的是“这一轮窗口的估计值”, 它本身就有波动,直接显示等于把噪声展示给用户。
办法:加 9 点滑动窗口,先算均值再看离散程度, 离散程度小于我实测定下的门限才认为这一轮可信,并把它当作显示值; 连续若干轮不可信则判定本次检测失败,提示重测。

4. 软件 I²C 被任务切换打断

现象:差压读数偶尔读回全 0 或者全 1,读卡时序乱掉。
原因:软件 I²C 靠 GPIO 翻转模拟时序,如果翻到一半被高优先级任务抢占, 时序就被拉长了,传感器不认。
办法:用临界区包住真正翻 GPIO 的段落(发命令、读结果), 但把中间的状态轮询循环留在临界区外—— 轮询里有 10 ms 的延时,放进临界区会让整个系统几十毫秒不响应中断。 我最后把它拆成“发命令 / 等就绪 / 读结果”三个函数,就是为了让临界区的边界清楚。

5. 上电头几帧数据不可用

现象:刚开机时压力值有一个大偏移,偶尔误开阀。
原因:传感器上电后需要一段稳定时间,而且第一帧数据本身可能还没准备好。
办法:呼吸任务在进入循环前先延时一小段(等待时间按实测定); 同时给轮询加了异常退出条件——如果状态寄存器返回异常标志, 立刻退出本次读取并丢弃这帧,而不是一直等下去。

6. 充电页与显示的超时越写越多

现象:每个功能都要自己的超时,定时器越建越多。
原因:把“超时”当成了独立功能,而不是时间基上的分频。
办法:统一用一个软件定时器,在回调里用取模分频出 一个短超时与一个长超时两档(具体秒数属于产品设定值,本文不列)。 代价是精度只有时基的量级,收益是定时器数量从多个变成一个。

7. 工程骨架被复用后留下的“外来 API”

现象:后来我把这台设备的工程骨架拿去做另一个测试工装, 过了很久才发现里面有调用另一个 RTOS 的延时函数(rt_thread_mdelay), 而这个工程跑的是 FreeRTOS。
原因:复用工程目录时没有清理干净,那段代码当时恰好在一条没有被编译到的路径上, 所以既没报错也没人发现。
办法:复用工程时做一份检查清单:RTOS API、时钟配置、 调试打印开关、看门狗、引脚定义,一项一项过。 这类问题的可怕之处在于它不会报错,只会在某天被编译到时突然出现。

九、这个项目最大的教训:参数存储的反面教材

这一节我写得详细一点,因为这是我在这台设备上犯的最明确的一个错, 而且它是一个不会立刻报错、只会在现场慢慢暴露的错。

我没有给这台设备加外置 EEPROM,而是直接用 MCU 的内置 Flash 当参数区。 动机很简单:省一颗芯片、省两条 I²C、省一点板子面积。 写参数的函数大概是这样几步:解锁 Flash、使能编程、等就绪标志、 擦除整个扇区、写入这一个字、上锁。 读参数的函数甚至更短,就是把地址强制转换成指针然后取值。

问题在于这套实现里有四个缺陷,而且是叠在一起的:

缺陷为什么危险什么情况下会出事
每次写都整扇区擦擦除是扇区级操作,一次擦除会连带清掉同一个扇区里的其他参数只要有两个参数落在同一扇区,写其中一个就会把另一个抹掉
没有读-改-写写之前不把同扇区的其他数据读出来、擦完再写回参数区里只要超过一个变量,就会互相破坏
没有 CRC 或校验和读回来时无法判断内容是有效数据还是擦除后的空值掉电、写失败、误擦之后,程序把 0xFFFFFFFF 当成合法参数继续跑
没有磨损均衡每次保存都擦同一个扇区,寿命集中消耗在同一个物理块上参数频繁保存的设备,先坏的就是那一块

还有一个接口设计上的坑:那个写函数只有一个地址参数, 这个地址同时被当作“要写的地址”和“要擦的扇区地址”。 也就是说,调用者只要传错一个偏移,擦掉的可能就是完全不相干的一段 Flash—— 包括程序代码本身所在的区域。这不是理论风险,而是这种接口迟早会遇到的事。

正确的做法是什么

我后来复盘,如果重做这块,参数存储至少要满足五条:

  1. 双区交替写:把参数区分成 A、B 两份。 每次保存写“当前无效”的那一份,写完校验通过再把它标记为有效, 另一份自动降级为备份。这样任何时刻至少有一份是完整的。
  2. 每条记录带记录头:序号、长度、CRC。 读取时先校验 CRC,再比较序号取最新的那一份; 校验失败就回退到另一份。永远不要读没有校验的数据。
  3. 先写后擦:擦除动作放在新数据写成功之后, 而不是之前。这样“擦除过程中掉电”最坏的结果是两份都还在。
  4. 把参数按修改频率分组:经常改的(比如累计值、校准进度) 和不常改的(比如设备编号、标定系数)分到不同扇区, 避免“改一个动全身”。
  5. 磨损均衡:在扇区内轮转写入位置,或者干脆用两个扇区轮流当“当前区”, 把擦除次数摊开。

这些做法我在另一篇笔记里写得比较完整: 《Flash 参数双区备份与掉电保护》, 包括记录头的字段安排和读写流程。那篇里的方案就是照着上面五条来的。

十、局限与还没做完的部分

  • 算法是移植的,我没有能力评价它的医学准确性。 我能确认的是“代码在我的板上跑出了与预期趋势一致的结果”, 至于这个结果在生理意义上有多准确,需要专业设备与临床方法去验证,那超出我的能力范围。
  • 这台设备不是医疗器械,没有做过任何精度、合规或临床验证, 也没有资格用于任何诊断用途。它的价值对我来说只有“学习信号链与实时任务划分”这一条。
  • 呼吸判定的阈值是实测出来的经验值。 开启阈值、关闭阈值、中间态超时、无吸气停计时的时长, 这四个数是在我这套管路与这个传感器上试出来的,本文只给量级、不给具体数值。 换一根管子、换一个传感器批次,很可能要重新标定。
  • 参数存储我还没有重做。第五节列出的五条做法到现在仍然只是“我知道该怎么做”, 这台设备上的固件还是那个每次整扇区擦的版本。这是我明确欠下的技术债。
  • 没有做长时间的功耗与稳定性测试。 低功耗设计里有一堆位域开关(自动休眠、显示休眠、按键限制、传感器电源域), 它们之间的组合我只验证了常用的几种,没有穷举。
  • 没有做多台设备的一致性对比。 我手上只有一套样机,零点、电位器系数、LED 电流这些需要批量才能看出分布的量, 我只有一个样本。
  • 电量显示的线性插值只是够用。 锂电池的放电曲线并不是直线,中段平台区尤其平,所以中段的百分比变化会显得“卡住不动”。 更准确的做法是查表或做开路电压估计,我没做。

如果重做一遍,优先级最高的不是加功能,而是把参数存储换成双区备份 + CRC 的版本, 再补一份“掉电注入”的测试清单——也就是在擦除中、写入中断电,看参数区能不能自动恢复。 这类测试方法我在液位与温度采集小板 那篇的验证小节里开过头,但没有在这台设备上真正做完。 另外,几乎同一时期我还在做一套多板卡的 IC 卡脱机交易系统, 那边对“交易不能重复、不能丢”的要求更硬,我把整套一致性机制写在了 《IC 卡读写与脱机交易系统》里; 两篇对照着看,一个是“没有机制所以靠运气”,一个是“机制堆得有点多”, 中间的平衡点大概就是我接下来想找的东西。

十一、参考资料与说明

  • 心率 / 血氧算法:来自传感器厂商公开的光学心率监测参考设计 (Maxim MAXREFDES117#)。该参考设计以 MIT 风格的宽松开源许可发布, 源码中附有原作者的版权与许可声明;本文所述算法逻辑属于该参考设计, 不是我的原创作品,具体许可条款以厂商原始发布为准。
  • 传感器公开数据手册:MAX30102 光学传感器与 XGZP6897D 差压传感器的 寄存器定义、测量流程、24 位数据格式与灵敏度(8192 码对应 1 Pa)均来自厂商公开的数据手册。 本文只引用其中的寄存器地址与换算关系,不涉及任何厂商内部资料。
  • 实时操作系统:FreeRTOS,按其官方开源许可使用(MIT 许可)。 本文涉及的任务划分、优先级与栈深度均为个人工程中的配置。
  • 文中涉及的芯片与器件型号(HC32F460KCTA、MAX30102、XGZP6897D 等) 仅用于说明技术方案,与相关厂商无隶属或授权关系。
  • 示例代码为按个人理解重写的最小片段,用于说明流程与取舍, 不代表任何产品或交付代码; 移植自第三方的算法部分已在正文中明确标注来源,未作为个人原创发表。
  • 本文为个人学习记录,不构成任何技术建议,也不构成任何医疗建议。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。