这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » Let'sdo2025年第2期—智能手环DIY脉搏血氧计的实现

共1条 1/1 1 跳转至

Let'sdo2025年第2期—智能手环DIY脉搏血氧计的实现

助工
2025-10-09 10:38:05     打赏

本文为最终成果贴,将基于LCD屏幕驱动工程实现脉搏血氧的显示

首先定义基本的IIC读写功能

static int i2c_write_reg(uint8_t reg, const uint8_t* data, int len)
{
    uint8_t tx[1 + 16];
    if (len > 16) return -1;
    tx[0] = reg;
    if (len) memcpy(&tx[1], data, len);
    mxc_i2c_req_t req = {
        .i2c = I2C_PORT,
        .addr = MAX30102_ADDR_7BIT,
        .tx_buf = tx,
        .tx_len = 1 + len,
        .rx_buf = NULL,
        .rx_len = 0,
        .restart = 0,
        .callback = NULL
    };
    return MXC_I2C_MasterTransaction(&req);
}

static int i2c_read_reg(uint8_t reg, uint8_t* data, int len)
{
    mxc_i2c_req_t req = {
        .i2c = I2C_PORT,
        .addr = MAX30102_ADDR_7BIT,
        .tx_buf = &reg,
        .tx_len = 1,
        .rx_buf = data,
        .rx_len = len,
        .restart = 1,
        .callback = NULL
    };
    return MXC_I2C_MasterTransaction(&req);
}

接着定义MAX30102的设备操作函数,包括软件复位、清空FIFO缓冲区、数据读取

/* ===================== MAX30102 设备操作 ===================== */
static void max30102_soft_reset(void)
{
    uint8_t v = 0x40; /* RESET=1 */
    (void)i2c_write_reg(REG_MODE_CONFIG, &v, 1);
    MXC_Delay(MXC_DELAY_MSEC(10));
}

static void max30102_clear_fifo(void)
{
    uint8_t z = 0x00;
    (void)i2c_write_reg(REG_FIFO_WR_PTR, &z, 1);
    (void)i2c_write_reg(REG_OVF_COUNTER, &z, 1);
    (void)i2c_write_reg(REG_FIFO_RD_PTR, &z, 1);
}

static void max30102_config_spo2_50sps(void)
{
    /* 1) 使能 FIFO Almost Full & PPG_RDY 中断(可选) */
    uint8_t ien1 = 0xC0; /* bit7=A_FULL_EN=1, bit6=PPG_RDY_EN=1 */
    (void)i2c_write_reg(REG_INT_ENABLE1, &ien1, 1);
    uint8_t ien2 = 0x00;
    (void)i2c_write_reg(REG_INT_ENABLE2, &ien2, 1);

    /* 2) FIFO:avg=1、rollover=0、almost_full=15 */
    uint8_t fifo_cfg = 0x0F;
    (void)i2c_write_reg(REG_FIFO_CONFIG, &fifo_cfg, 1);

    /* 3) 模式:SpO2(0b011) */
    uint8_t mode = 0x03;
    (void)i2c_write_reg(REG_MODE_CONFIG, &mode, 1);

    /* 4) SpO2配置:ADC Range=4096nA(01),SampleRate=50sps(001),PulseWidth=411us(11) */
    uint8_t spo2 = 0x27; /* 0b0010_0111 */
    (void)i2c_write_reg(REG_SPO2_CONFIG, &spo2, 1);

    /* 5) LED 电流:~10mA(0x32) */
    uint8_t ired = 0x32, iir = 0x32;
    (void)i2c_write_reg(REG_LED1_PA, &ired, 1); /* RED */
    (void)i2c_write_reg(REG_LED2_PA, &iir, 1);  /* IR  */

    /* 6) 清 FIFO 指针 */
    max30102_clear_fifo();
}

/* 读取一对样本(RED/IR),返回 1=成功,0=无数据 */
static int max30102_read_one_pair(void)
{
    uint8_t s1=0, s2=0;
    (void)i2c_read_reg(REG_INT_STATUS1, &s1, 1);
    (void)i2c_read_reg(REG_INT_STATUS2, &s2, 1);
    if ((s1 & 0x40) == 0) { /* PPG_RDY=bit6 */
        return 0; /* 暂无新样本 */
    }
    /* 读取 6 字节:RED[23:0], IR[23:0](高 2 位无效) */
    uint8_t d[6];
    if (i2c_read_reg(REG_FIFO_DATA, d, 6) != 0) return 0;
    uint32_t red = ((uint32_t)d[0] << 16) | ((uint32_t)d[1] << 8) | d[2];
    uint32_t ir  = ((uint32_t)d[3] << 16) | ((uint32_t)d[4] << 8) | d[5];
    red &= 0x03FFFF; /* 18-bit */
    ir  &= 0x03FFFF;

    /* 轻度阈值去噪(环境光/空读) */
    if (ir  <= 10000) ir  = 0;
    if (red <= 10000) red = 0;

    g_fifo_red = (uint16_t)(red & 0xFFFF);
    g_fifo_ir  = (uint16_t)(ir  & 0xFFFF);
    return 1;
}

血氧浓度的测量是较为容易的,心率的计算则需要用到一些算法和数据处理

为了能分析更多的心率信息,用环形缓冲区和滑动窗口实现更多数据的读取

/* ===================== 工具:滑动写入环形缓冲 ===================== */
static inline void ring_push(float red, float ir)
{
    s_ring_red[s_ring_widx] = red;
    s_ring_ir [s_ring_widx] = ir;
    s_ring_widx = (s_ring_widx + 1) % RING_N;
    if (s_ring_count < RING_N) s_ring_count++;
}

信号滤波:去除直流和带通滤波,其中带通滤波使用的是一阶高通滤波和一阶低通滤波串联

/* ===================== 滤波:去直流 + 带通(0.5~5 Hz) ===================== */
static void remove_dc_and_bandlimit(const float *x, float *y, int n)
{
    /* 高通:x_hp = x - LPF(x, fc=0.5Hz);再低通:LPF(x_hp, fc=5Hz) */
    const float dt = 1.0f / (float)SAMPLES_PER_SECOND;

    /* fc=0.5Hz -> RC=1/(2πfc) */
    const float RC_hp = 1.0f / (2.0f * (float)M_PI * 0.5f);
    const float alpha_hp = dt / (RC_hp + dt);

    /* fc=5Hz -> RC=1/(2πfc) */
    const float RC_lp = 1.0f / (2.0f * (float)M_PI * 5.0f);
    const float alpha_lp = dt / (RC_lp + dt);

    float lpf_dc = x[0];
    float lpf_bp = 0.0f;
    for (int i = 0; i < n; ++i) {
        /* DC 低通(跟踪基线)*/
        lpf_dc += alpha_hp * (x[i] - lpf_dc);
        float x_hp = x[i] - lpf_dc;

        /* 带通后再低通限制高频噪声 */
        lpf_bp += alpha_lp * (x_hp - lpf_bp);
        y[i] = lpf_bp;
    }
}

计算归一化自相关函数用于心率周期检测,其中x为信号数据、n为数据长度、energy为信号能量、L为滞后点数

/* ===================== 辅助:计算归一化自相关 R(L) —— 代替 C++ lambda ===================== */
static double sample_R(const float *x, int n, double energy, int L)
{
    if (L < LAG_MIN || L > LAG_MAX) return -1e9;
    double acc = 0.0;
    int Nuse = n - L;
    for (int i = 0; i < Nuse; ++i) acc += (double)x[i] * (double)x[i + L];
    return acc / energy;
}

通过自相关分析估计心率,其流程为:搜索最佳滞后点 -> 二次插值细化 -> 转换为BPM

/* ===================== 自相关 + 二次插值估计主周期 ===================== */
static float estimate_hr_bpm_from_acf(const float *x, int n)
{
    if (n < (LAG_MAX + 2)) return 0.0f;

    /* 归一化能量(简单 rms)用于自相关归一化 */
    double energy = 0.0;
    for (int i = 0; i < n; ++i) energy += (double)x[i] * (double)x[i];
    if (energy <= 1e-12) return 0.0f;

    /* 搜索最佳滞后 L */
    double bestR = -1e9;
    int    bestL = LAG_MIN;
    for (int L = LAG_MIN; L <= LAG_MAX; ++L) {
        double R = sample_R(x, n, energy, L);
        if (R > bestR) { bestR = R; bestL = L; }
    }

    /* 二次插值细化 —— 使用 L-1, L, L+1 */
    double R_prev = sample_R(x, n, energy, bestL - 1);
    double R_curr = sample_R(x, n, energy, bestL);
    double R_next = sample_R(x, n, energy, bestL + 1);

    double delta = 0.0;
    if (R_prev > -1e8 && R_next > -1e8) {
        double denom = (R_prev - 2.0 * R_curr + R_next);
        if (fabs(denom) > 1e-9) {
            delta = 0.5 * (R_prev - R_next) / denom;
            if (delta > 1.0)  delta = 1.0;
            if (delta < -1.0) delta = -1.0;
        }
    }
    double Lf = (double)bestL + delta;
    if (Lf < (double)LAG_MIN || Lf > (double)LAG_MAX) return 0.0f;

    double freq_hz = (double)SAMPLES_PER_SECOND / Lf;
    double hr_bpm  = 60.0 * freq_hz;
    if (hr_bpm < 30.0 || hr_bpm > 220.0) return 0.0f;
    return (float)hr_bpm;
}

基于比率比值法(SpO2线性映射)估计血氧饱和度

/* ===================== SpO₂ 估计(Ratio-of-Ratios) ===================== */
static float estimate_spo2_percent(const float *red, const float *ir, int n)
{
    if (n < 32) return 0.0f;

    /* DC:滑动平均;AC:RMS */
    double sum_red = 0.0, sum_ir = 0.0;
    for (int i = 0; i < n; ++i) { sum_red += red[i]; sum_ir += ir[i]; }
    double dc_red = sum_red / n;
    double dc_ir  = sum_ir  / n;

    double ac2_red = 0.0, ac2_ir = 0.0;
    for (int i = 0; i < n; ++i) {
        double xr = (double)red[i] - dc_red;
        double xi = (double)ir [i] - dc_ir;
        ac2_red += xr * xr;
        ac2_ir  += xi * xi;
    }
    double rms_red = sqrt(ac2_red / n);
    double rms_ir  = sqrt(ac2_ir  / n);

    /* 信号质量保护 */
    if (dc_red <= 1.0 || dc_ir <= 1.0) return 0.0f;
    if (rms_red/dc_red < 0.002 || rms_ir/dc_ir < 0.002) return 0.0f;

    double R = (rms_red / dc_red) / (rms_ir / dc_ir);
    double est = 104.0 - 17.0 * R; /* 简单线性近似 */
    if (est > 100.0) est = 100.0;
    if (est < 0.0)   est = 0.0;
    return (float)est;
}

主流程函数中,采集一窗口数据(50个样本)并填充环形缓冲区

static void acquire_window_and_fill_ring(void)
{
    int got = 0;
    while (got < WINDOW_SAMPLES) {
        if (!max30102_read_one_pair()) {
            MXC_Delay(MXC_DELAY_MSEC(2));
            continue;
        }
        /* 写入环形缓冲(原始 16-bit 转 float) */
        ring_push((float)g_fifo_red, (float)g_fifo_ir);
        got++;
    }
}

从环形缓冲区计算心率和血氧指标,其流程为:数据复制 -> 信号滤波 -> 心率估计 -> 血氧估计 -> 有效性检测

static void compute_metrics_from_ring(void)
{
    if (s_ring_count < RING_N/2) { /* 先累积至少 2 秒再输出有效值 */
        g_hr   = 0;
        g_spo2 = 0.0f;
        return;
    }

    /* 将环形缓冲复制成线性数组(时间顺序) */
    float red[RING_N], ir[RING_N];
    int n = s_ring_count;
    int start = (s_ring_widx - n + RING_N) % RING_N;
    for (int i = 0; i < n; ++i) {
        int idx = (start + i) % RING_N;
        red[i] = s_ring_red[idx];
        ir [i] = s_ring_ir [idx];
    }

    /* 仅用 IR 做心率估计(更稳健);RED/IR 用于 SpO₂ */
    float ir_bp[RING_N];
    remove_dc_and_bandlimit(ir, ir_bp, n);

    /* 自相关 + 二次插值估计心率 */
    float hr_bpm = estimate_hr_bpm_from_acf(ir_bp, n);

    /* SpO₂ 估计(RMS/均值比值) */
    float spo2 = estimate_spo2_percent(red, ir, n);

    /* 最终有效性校验 */
    if (hr_bpm < 30.0f || hr_bpm > 220.0f) hr_bpm = 0.0f;
    if (spo2   < 50.0f || spo2   > 100.0f) spo2   = 0.0f;

    g_hr   = (int)(hr_bpm + 0.5f);
    g_spo2 = spo2;
}

将上述数据采集和指标计算汇总成一个函数,便于后续调用

static void blood_Loop(void)
{
    acquire_window_and_fill_ring();  /* 约采 1 秒(50 点),写入滑动缓冲 */
    compute_metrics_from_ring();     /* 基于最近 4 秒数据输出一次结果 */
}

最后定义对外API函数,便于用户调用

int sensor_init(void)
{
    if (MXC_I2C_Init(I2C_PORT, 1, 0) != 0) return -1;
    MXC_I2C_SetFrequency(I2C_PORT, 400000);

    max30102_soft_reset();
    max30102_config_spo2_50sps(); /* 保持 50sps/411us 配置不变 */

    uint8_t part = 0;
    (void)i2c_read_reg(REG_PART_ID, &part, 1);
    if (part != 0x15) {
        printf("WARN: MAX30102 PartID=0x%02X (expect 0x15)\n", part);
    }

    g_hr = 0; g_spo2 = 0.0f;
    s_ring_widx = 0; s_ring_count = 0;
    memset(s_ring_red, 0, sizeof(s_ring_red));
    memset(s_ring_ir,  0, sizeof(s_ring_ir));
    return 0;
}

void sensor_run_once(void)
{
    blood_Loop(); /* 约采 1 秒并完成一轮计算 */
}

int   sensor_get_hr(void)   { return g_hr; }
float sensor_get_spo2(void) { return g_spo2; }

/* ===================== 可选:P0_19 下拉沿中断(演示三参 IntConfig) ===================== */
static void max30102_int_cb(void *cbdata) { (void)cbdata; /* 算法仍采用轮询 */ }
void GPIO0_IRQHandler(void) { MXC_GPIO_Handler(MXC_GPIO0); }

void sensor_int_init(void)
{
    mxc_gpio_cfg_t cfg = {
        .port  = MXC_GPIO0,
        .mask  = (1u << 19), /* P0_19 */
        .func  = MXC_GPIO_FUNC_IN,
        .pad   = MXC_GPIO_PAD_NONE,
        .vssel = MXC_GPIO_VSSEL_VDDIO
    };
    MXC_GPIO_Config(&cfg);
    MXC_GPIO_RegisterCallback(&cfg, max30102_int_cb, cfg.mask);
    MXC_GPIO_IntConfig(&cfg, MXC_GPIO_INT_FALLING);
    MXC_GPIO_EnableInt(MXC_GPIO0, (1u << 19));
    NVIC_EnableIRQ(GPIO0_IRQn);
}

观察脉搏血氧计传感器模块,可以看到共有5个引脚需要与评估板连接,其中INT引脚为中断引脚,下降沿触发,用于传感器数据就绪通知

image.png

在主函数中,初始化LCD和脉搏血氧计传感器,随后在屏幕上和串口输出当前血氧和心率值,如果手指离开传感器,则屏幕上两个参数均显示为0,串口输出相关警告

#include <stdio.h>
#include "mxc_device.h"
#include "mxc_delay.h"
#include "board.h"
#include "lcd_init.h"
#include "lcd.h"
#include "sensor.h"

int main(void)
{
    printf("\nMAX78000FTHR + MAX30102  SpO2/HR demo  (50 sps, window=50 ~1Hz)\n");

    // 初始化 LCD
    LCD_Init();
    LCD_Fill(0, 0, LCD_WIDTH, LCD_HEIGHT, BLACK);
   
    if (sensor_init() != 0) {
        printf("sensor_init failed.\n");
        while (1) { }
    }
    while (1) {
        /* 采满一窗并完成一次计算(~1 秒) */
        sensor_run_once();

        int   hr   = sensor_get_hr();
        float spo2 = sensor_get_spo2();
        uint8_t buffer[32] = {0};

        sprintf((char *)buffer, "MAXREFDES117#");
        LCD_ShowString(5, 10, (uint8_t *)buffer, WHITE, BLACK, 16, 0);
        sprintf((char *)buffer, "Oximeter");
        LCD_ShowString(5, 30, (uint8_t *)buffer, WHITE, BLACK, 16, 0);

        if (hr > 0 && spo2 > 0.0f) {
            printf("SpO2: %5.2f %%   HR: %3d bpm\n", spo2, hr);
            sprintf((char *)buffer, "SpO2: %5.2f %%     ", spo2);
            LCD_ShowString(5, 60, (uint8_t *)buffer, WHITE, BLACK, 16, 0);
            sprintf((char *)buffer, "HR:   %3d bpm      ", hr);
            LCD_ShowString(5, 80, (uint8_t *)buffer, WHITE, BLACK, 16, 0);
        } else {
            printf("SpO2/HR invalid (no finger or weak PPG)\n");
            sprintf((char *)buffer, "SpO2: 0           ");
            LCD_ShowString(5, 60, (uint8_t *)buffer, WHITE, BLACK, 16, 0);
            sprintf((char *)buffer, "HR:   0           ");
            LCD_ShowString(5, 80, (uint8_t *)buffer, WHITE, BLACK, 16, 0);
        }
    }
}

实现效果如下,由于传感器模块较小,焊接的排针不能让手指完全贴合传感器,因此最终的血氧值略微偏低,心率也不是很稳定

串口输出如下

屏幕截图 2025-10-08 170943.png

工程代码如下

sensor.zip




关键词: 脉搏     血氧     MAX30102    

共1条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]