Embedded HandbookПриложения
Все разделы › XI. Датчики: подключение, схемы, драйверы
STM32F103 · уровень средний · 41 мин чтения
Открыть в интерактивном справочнике

103а. Пульсоксиметр MAX30102: фотоплетизмограмма по I2C, FIFO, выделение пульса и оценка SpO₂ — и почему это не медицинский прибор

В разделе 103 BH1750 мерил свет, который падает на датчик. MAX30102 свет светит сам — красным и инфракрасным светодиодами в палец — и мерит, сколько вернулось. С каждым ударом сердца крови в пальце чуть больше, и света возвращается чуть меньше: это фотоплетизмограмма, ФПГ. Из неё получают пульс, а из отношения двух цветов — то, что в пульсоксиметрах переводят в насыщение крови кислородом, SpO₂. Здесь — как это устроено, что внутри микросхемы, как подключить её к F103 и читать FIFO, как выделить удары и посчитать частоту, что такое отношение R и почему без калибровки на людях оно не превращается в проценты, и что делают с сигналом движение, свет и нажим пальца.

Это учебный пример, а не медицинское изделие. Код и числа раздела проверены только на модели. Для диагностики, контроля лечения и любых решений о здоровье он не годится.

Опоры: RM0008 (DocID13902 Rev 17): таблица режимов выводов, с. 160; AFIO_EXTICR3, с. 191; EXTI_FTSR, с. 211; приём больше двух байт ведущим, способ 2, с. 762–763; приём одного байта, с. 764; I2C_CCR, с. 783–784; I2C_TRISE, с. 784. DocID13587 Rev 17 (STM32F103x8/xB): PB6 — I2C1_SCL, PB7 — I2C1_SDA, оба FT, PB5 без FT, с. 32; PB8 — FT, с. 33; пороги входа, таблица 35, с. 62. Документа на MAX30102 в проекте нет: адрес, регистры, коды частоты, ширины импульса, диапазона и тока, поведение FIFO, флагов и вывода INT взяты из документации производителя и заложены в модель — по документации производителя, не проверено. Номера регистров, поля и коды совпадают с открытым драйвером родственной MAX30101 (у неё есть ещё зелёный светодиод) в Zephyr 4.4.2 на диске, но это не документ. Поэтому всё ниже проверяет согласие драйвера с моделью, а не с микросхемой и тем более не с пальцем. Все числа — прогон драйвера _verify/ppg103b/max103b.c и обработки ppg103b.c на компьютере (gcc 12.2.0) со своей моделью MAX30102 как ведомого I2C и синтетической ФПГ на Python 3.11; прошивка собрана armclang 6.24 под Cortex-M3.

Как это называется в документации

Оригинал Перевод
photoplethysmography (PPG) фотоплетизмограмма, ФПГ: сколько света вернулось из ткани
pulse oximeter, SpO₂ пульсоксиметр; насыщение артериальной крови кислородом
AC / DC component пульсирующая / постоянная составляющая
perfusion index (PI) индекс перфузии: AC/DC, обычно в процентах
ratio of ratios, R отношение (AC/DC) красного к (AC/DC) ИК
LED pulse amplitude (LEDx_PA) ток светодиода
pulse width (LED_PW) ширина импульса светодиода — она же время накопления и разрядность
ADC full-scale range (SPO2_ADC_RGE) диапазон АЦП: фототок, которому соответствует верх шкалы
FIFO almost full (A_FULL) FIFO почти полон: прерывание
FIFO rollover перезапись старых отсчётов при полном FIFO
ambient light cancellation (ALC) вычитание внешнего света
motion artifact артефакт движения
refractory period рефрактерный период: после удара новый не ищут
inter-beat interval (IBI) интервал между ударами

103а.1 Принцип: свет через палец

Светодиод светит в палец, фотодиод рядом ловит то, что рассеялось и вернулось. Большую часть света съедают кожа, мышцы, кость и венозная кровь, и эта часть от удара к удару не меняется — постоянная составляющая, DC. Артерии с каждым ударом чуть расширяются, крови на пути света становится больше, и вернувшегося света — меньше. Это пульсирующая составляющая, AC, и она маленькая: в модели глубина модуляции ИК — 2 % от постоянной. На выходе АЦП удар — не всплеск, а провал: кровь свет поглощает.

Пульсу хватило бы и одного цвета. Два нужны для SpO₂: гемоглобин с кислородом и без него поглощают красный и ИК свет по-разному. Отношение AC к DC в каждом канале убирает яркость светодиода, толщину пальца и цвет кожи — всё, что одинаково давит оба отсчёта. Отношение двух таких отношений

R = (AC_red / DC_red) / (AC_ir / DC_ir)

зависит от того, что в крови. Но как именно — физика не даёт готовой формулы: свет в ткани рассеивается, путь у разных цветов разный, длины волн у разных светодиодов разные. Связь R с процентами снимают опытом на людях — об этом 103а.5.

Свет здесь мерит не люксы, а фототок в нанамперах, и не освещённость комнаты, а палец: окружающий свет — помеха, которую микросхема старается вычесть (103а.6).


103а.2 Микросхема: светодиоды, АЦП и FIFO на 32 отсчёта

По документации производителя внутри MAX30102 — красный и ИК светодиоды со своими источниками тока, фотодиод, АЦП с вычитанием внешнего света, датчик температуры кристалла и FIFO на 32 отсчёта. Адрес на шине — 0x57 (байт в линии 0xAE для записи и 0xAF для чтения, 17.2). Регистры, которые нужны здесь (номера — как в драйвере MAX30101 у Zephyr):

Регистр Номер Что в нём
INT_STATUS1 0x00 A_FULL (бит 7), PPG_RDY (бит 6), ALC_OVF (бит 5), PWR_RDY (бит 0)
INT_ENABLE1 0x02 какие флаги опускают INT
FIFO_WR_PTR, OVF_COUNTER, FIFO_RD_PTR 0x04…0x06 указатель записи, счёт потерянных, указатель чтения
FIFO_DATA 0x07 отсчёты: по 3 байта на канал
FIFO_CONFIG 0x08 SMP_AVE [7:5], FIFO_ROLLOVER_EN [4], FIFO_A_FULL [3:0]
MODE_CONFIG 0x09 SHDN [7], RESET [6], MODE [2:0]: 2 — только красный, 3 — SpO2
SPO2_CONFIG 0x0A ADC_RGE [6:5], SR [4:2], LED_PW [1:0]
LED1_PA, LED2_PA 0x0C, 0x0D ток красного и ИК
PART_ID 0xFF 0x15

Три кода в SPO2_CONFIG решают почти всё:

Код 0 1 2 3
LED_PW: импульс 69 мкс, 15 бит 118 мкс, 16 бит 215 мкс, 17 бит 411 мкс, 18 бит
ADC_RGE: верх шкалы 2048 нА 4096 нА 8192 нА 16384 нА
SR: отсчётов в секунду 50 100 200 400

У SR кодов восемь: дальше 800, 1000, 1600 и 3200. Длинный импульс — больше накопленного света и больше бит, но не со всеми частотами он совместим (по документации производителя). Отсчёт всегда лежит в 18 битах, выровненный по старшему: при 15 битах три младших — нули. Драйвер Zephyr сдвигает его вправо, наш — оставляет в шкале 18 бит, тогда верх шкалы один на все ширины: 262 143.

Ток светодиода — код от 0 до 255, по документации производителя около 0,2 мА на единицу; в модели ровно 0,2: 0x24 — 7,2 мА, 0xFF — 51 мА (в описании драйвера Zephyr предел назван 50 мА). Ток задаёт и яркость, и то, куда в шкале АЦП встанет постоянная составляющая (103а.7).

Здесь — режим SpO2 (3), 100 отсчётов в секунду, 411 мкс и 18 бит, диапазон 4096 нА — 15,625 пА на единицу кода, ток 0x24 на обоих светодиодах, без усреднения в микросхеме. FIFO_A_FULL = 15: INT опускается, когда в FIFO осталось 15 свободных мест, то есть в нём 17 отсчётов. Без FIFO_ROLLOVER_EN полный FIFO новые отсчёты не берёт, а считает их в OVF_COUNTER (так это описано и в привязке драйвера Zephyr).


103а.3 Подключение к F103 и инициализация

Вывод модуля STM32F103 Назначение
VIN 3,3 В питание модуля
GND GND земля
SCL PB6 такт I2C1
SDA PB7 данные I2C1
INT PB8 прерывание, активный ноль, открытый сток

По документации производителя логика MAX30102 питается от 1,8 В, а светодиоды — от 3,3 В и выше. Модули ставят свой стабилизатор на 1,8 В, и у части модулей подтяжки SDA, SCL и INT идут к этим 1,8 В, а не к 3,3 В — смотрите схему своего модуля (не проверено). Чем это грозит F103, видно из таблицы 35 (DocID13587, с. 62): гарантированная единица на входе — 0,65 × VDD, при 3,3 В это 2,145 В, и она проверяется на производстве. Пороги ниже — 1,546 В у выводов FT и 1,833 В у обычных — получены моделированием, не испытанием. Единица в 1,8 В ниже гарантированной, а для обычного вывода ниже даже расчётной. Поэтому INT заведён на PB8 — он FT (с. 33), — а не на соседний PB5 без FT (с. 32). PB6 и PB7 тоже FT (с. 32). Надёжнее всего — подтяжки к 3,3 В или преобразователь уровней (95а).

I2C1 — как в 15б.2: 400 кГц, CCR = F/S | 30, TRISE = 11. PB8 — вход с подтяжкой (CNF = 10, MODE = 00, в ODR единица; RM0008, с. 160), EXTI8 по спаду: в AFIO_EXTICR3 (с. 191) код 0001 — порт B, EXTI_FTSR (с. 211) — спад:

/* INT: спад на PB8 — в FIFO 17 отсчётов */
void EXTI9_5_IRQHandler(void)
{
    EXTI->PR = 1u << 8;
    irq = 1;
}

static void irq_init(void)
{
    AFIO->EXTICR[2] = (AFIO->EXTICR[2] & ~0xFu) | 0x1u;     /* EXTI8 ← PB8 */
    EXTI->FTSR |= 1u << 8;
    EXTI->IMR |= 1u << 8;
    NVIC_EnableIRQ(EXTI9_5_IRQn);
}

Инициализация — проверить, что по адресу та микросхема, сбросить её, дождаться конца сброса, снять флаг включения и настроить. Режим пишется последним: с него начинаются отсчёты.

/* сброс, ожидание конца сброса, настройка; режим пишется последним — с него начинаются отсчёты */
int max_init(max_t *d, const max_cfg_t *c)
{
    uint8_t v = 0;
    int rc;
    d->leds = c->mode == MAX_MODE_SPO2 ? 2u : 1u;
    d->xfers = d->errors = d->lost = d->alc_ovf = 0;
    if ((rc = max_get(d, MAX_PART_ID, &v)) != MAX_OK)
        return rc;
    if (v != MAX_PART_ID_VAL)
        return MAX_E_ID;                                /* по адресу 0x57 не та микросхема */
    if ((rc = max_reg(d, MAX_MODE_CONFIG, MAX_MODE_RESET)) != MAX_OK)
        return rc;
    for (uint32_t i = 0;; i++) {                        /* RESET сбрасывается сам */
        max_port_delay_us(1000u);
        if ((rc = max_get(d, MAX_MODE_CONFIG, &v)) != MAX_OK)
            return rc;
        if (!(v & MAX_MODE_RESET))
            break;
        if (i + 1u >= MAX_RESET_TRIES)
            return MAX_E_RESET;
    }
    if ((rc = max_get(d, MAX_INT_STATUS1, &v)) != MAX_OK)  /* снять PWR_RDY — INT отпущен */
        return rc;
    /* без FIFO_ROLLOVER_EN: в полный FIFO новые отсчёты не пишутся, их считает OVF_COUNTER */
    if ((rc = max_reg(d, MAX_FIFO_CONFIG, (uint8_t)((c->ave & 7u) << 5 | (c->a_full & 0x0Fu)))) != MAX_OK)
        return rc;
    if ((rc = max_reg(d, MAX_SPO2_CONFIG, (uint8_t)((c->rge & 3u) << 5 | (c->sr & 7u) << 2 | (c->pw & 3u)))) != MAX_OK)
        return rc;
    if ((rc = max_reg(d, MAX_LED1_PA, c->led_red)) != MAX_OK)
        return rc;
    if ((rc = max_reg(d, MAX_LED2_PA, c->led_ir)) != MAX_OK)
        return rc;
    if ((rc = max_reg(d, MAX_INT_ENABLE1, MAX_INT_A_FULL)) != MAX_OK)
        return rc;
    return max_reg(d, MAX_MODE_CONFIG, (uint8_t)(c->mode & 7u));
}

В модели max_init — 10 обменов: чтение PART_ID, 09 40 (сброс), чтение MODE_CONFIG, чтение INT_STATUS1, 08 0F, 0A 27, 0C 24, 0D 24, 02 80 и 09 03. На шине 800 мкс, вместе с паузой после сброса — 1800 мкс. Чужой PART_ID (0x11 вместо 0x15) — отказ MAX_E_ID, −3. Чтение INT_STATUS1 здесь не лишнее: после включения питания в модели INT уже в нуле — флаг PWR_RDY, — и, пока его не прочитали, спада на PB8 не будет никогда (103а.7). После max_init INT в единице.

Вверху — подключение модуля MAX30102 к STM32F103: PB6 — SCL, PB7 — SDA на I2C1 на 400 кГц, адрес 0x57, INT модуля — на PB8, EXTI8 по спаду; модуль питается от 3,3 В, логика микросхемы — 1,8 В, подтяжки бывают к 1,8 В, а гарантированная единица на входе F103 — 2,145 В; светодиоды светят в палец красным и ИК, фотодиод ловит вернувшийся свет. Внизу — 3 с ИК-сигнала из прогона модели после ФВЧ и ФНЧ: удары перевёрнуты вверх, порог — половина высоты пиков, четыре найденных удара с интервалами 830, 800 и 850 мс, ЧСС 71,4 удара в минуту; второй горб каждой волны ниже порога


103а.4 Чтение FIFO: указатели, переполнение, время на шине

В FIFO два пятибитных указателя: куда микросхема запишет следующий отсчёт и откуда его прочитают. Их разность по модулю 32 — сколько ждёт. Беда в том, что 32 по модулю 32 — это 0: указатели равны и у пустого FIFO, и у полного. Различить помогает OVF_COUNTER: если отсчёты терялись, FIFO точно полон. Все три регистра читаются одним обменом — номер регистра растёт после каждого байта:

/* сколько отсчётов ждёт в FIFO: указатели записи и чтения по 5 бит; равны — пусто или полно */
int max_fifo_count(max_t *d, uint32_t *n, uint32_t *ovf)
{
    uint8_t reg = MAX_FIFO_WR_PTR, p[3];                /* WR_PTR, OVF_COUNTER, RD_PTR одним чтением */
    int rc = xfer_done(d, max_port_xfer(d->addr, &reg, 1, p, 3));
    if (rc != MAX_OK)
        return rc;
    *ovf = p[1] & 0x1Fu;
    *n = (uint32_t)(p[0] - p[2]) & (MAX_FIFO_DEPTH - 1u);
    if (*n == 0u && *ovf != 0u)
        *n = MAX_FIFO_DEPTH;                            /* отсчёты терялись — значит, FIFO полон */
    return MAX_OK;
}

Сами отсчёты — одним длинным чтением FIFO_DATA. Здесь номер регистра не растёт: каждые 6 байт (3 на красный, 3 на ИК) микросхема снимает из FIFO следующий отсчёт. Байты идут старшим вперёд, значащие — 18 младших бит:

/* всё, что есть в FIFO, одним чтением: по 3 байта на канал, в SpO2 сначала красный, потом ИК.
 * lost — сколько отсчётов не поместилось: они были ПОСЛЕ прочитанных */
int max_read_fifo(max_t *d, uint32_t *red, uint32_t *ir, uint32_t maxn, uint32_t *got, uint32_t *lost)
{
    uint8_t reg = MAX_FIFO_DATA, buf[MAX_FIFO_DEPTH * 6u];
    uint32_t n, ovf, k = 3u * d->leds;
    int rc;
    *got = *lost = 0;
    if ((rc = max_fifo_count(d, &n, &ovf)) != MAX_OK)
        return rc;
    if (n > maxn)
        n = maxn;
    *lost = ovf;
    d->lost += ovf;
    if (n == 0u)
        return MAX_OK;
    if ((rc = xfer_done(d, max_port_xfer(d->addr, &reg, 1, buf, n * k))) != MAX_OK)
        return rc;
    for (uint32_t i = 0; i < n; i++) {
        red[i] = max_unpack(&buf[i * k]);
        ir[i] = d->leds == 2u ? max_unpack(&buf[i * k + 3u]) : 0u;
    }
    *got = n;
    return MAX_OK;
}

OVF_COUNTER считает отсчёты, которые не поместились, — они были после тех, что лежат в FIFO. Поэтому порядок такой: сначала обработать прочитанные, потом ppg_skip(&ppg, n_lost) — сдвинуть счёт времени на потерянные. Фильтры дыру не заполнят, но интервалы между ударами останутся верными.

Приём на F103. Больше двух байт ведущий F103 принимает по способу 2 (RM0008, с. 762–763): когда осталось три байта, дождаться BTF, снять ACK, прочитать N−2, снова BTF, STOP, прочитать N−1 и после RXNE — N. Один байт — свой порядок (с. 764):

/* приём nrx байт после повторного старта: 1 байт — RM0008, с. 764; больше двух — способ 2, с. 762–763 */
static int rx_bytes(uint8_t *rx, uint32_t nrx)
{
    int rc;
    uint32_t i = 0;
    if (nrx == 1u) {
        I2C1->CR1 &= (uint16_t)~I2C_CR1_ACK;            /* ACK снять до сброса ADDR */
        (void)I2C1->SR2;
        I2C1->CR1 |= I2C_CR1_STOP;
        if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
            return rc;
        rx[0] = (uint8_t)I2C1->DR;
        I2C1->CR1 |= I2C_CR1_ACK;
        return MAX_OK;
    }
    (void)I2C1->SR2;                                    /* ADDR снят, ACK = 1 */
    for (; i + 3u < nrx; i++) {
        if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
            return rc;
        rx[i] = (uint8_t)I2C1->DR;
    }
    if ((rc = sr1_wait(I2C_SR1_BTF)) != MAX_OK)         /* осталось три: N−2 в DR, N−1 в сдвиговом, SCL прижат */
        return rc;
    I2C1->CR1 &= (uint16_t)~I2C_CR1_ACK;
    rx[i++] = (uint8_t)I2C1->DR;                        /* N−2; пошёл приём N с NACK */
    if ((rc = sr1_wait(I2C_SR1_BTF)) != MAX_OK)
        return rc;
    I2C1->CR1 |= I2C_CR1_STOP;
    rx[i++] = (uint8_t)I2C1->DR;                        /* N−1 */
    if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
        return rc;
    rx[i] = (uint8_t)I2C1->DR;                          /* N */
    I2C1->CR1 |= I2C_CR1_ACK;
    return MAX_OK;
}

Время на шине. В модели прерывание — каждые 17 отсчётов, раз в 170 мс, и обработчик делает три обмена: INT_STATUS1, три указателя и 102 байта отсчётов.

Обмен Тактов SCL 400 кГц 100 кГц
INT_STATUS1, 1 байт 39
указатели, 3 байта 57
17 отсчётов, 102 байта 948
всё прерывание 1044 2610 мкс 10 440 мкс
доля шины 1,54 % 6,14 %

Один отсчёт — 54 такта, 135 мкс на 400 кГц. За 60 с основного прогона обработчик сработал 352 раза, шина занята 1,53 % времени. После прерывания в FIFO ещё 15 свободных мест — 150 мс на ответ.

Опрос вместо прерывания. Если читать FIFO по таймеру, период решает всё. В модели 60 с при 72 ударах в минуту, ошибка ЧСС — средняя по отчётам:

Период опроса Чтений Потеряно отсчётов Насчитал OVF Ошибка ЧСС, со сдвигом / без
170 мс 352 0 0 0,42 / 0,42 уд/мин
250 мс 239 0 0 0,43 / 0,43 уд/мин
320 мс 187 2977 2884 1,96 / 51,44 уд/мин
330 мс 181 182 182 0,40 / 1,97 уд/мин
400 мс 150 1201 1201 0,47 / 17,66 уд/мин
1000 мс 59 4013 1829 46,45 / 28,27 уд/мин

320 мс — ровно 32 отсчёта, и тут ловушка равных указателей: драйвер видит 0, и каждое второе чтение пустое (103а.7). 400 мс без сдвига времени — 88,2 удара в минуту вместо 71,4: интервалы короче, потому что потерянные отсчёты выпали из счёта. На 1000 мс не спасает и сдвиг: OVF_COUNTER — пять бит, больше 31 он не считает. Прерывание по A_FULL всех этих бед не знает.


103а.5 Обработка: пульс, частота и отношение R

Цепочка на каждый канал — как в разделе 81 и 81а, только целыми числами (у F103 нет FPU). ФВЧ: постоянную составляющую ведёт экспоненциальное среднее с весом 1/32, его вычитают — срез около 100 / (2π · 32) = 0,5 Гц при 100 отсчётах в секунду. Результат переворачивают, чтобы удар шёл вверх. ФНЧ: скользящее среднее по 8 отсчётам — задержка 3,5 отсчёта, первый ноль на 12,5 Гц:

/* один канал: ФВЧ (минус медленное среднее), переворот, ФНЧ (среднее по 8) */
static int32_t ch_step(ppg_ch_t *c, uint32_t x, uint32_t pos, int first)
{
    int32_t xq = (int32_t)(x << PPG_Q);
    if (first)
        c->dc = xq;
    c->dc += (xq - c->dc) / (1 << PPG_DC_SHIFT);
    int32_t ac = c->dc / (1 << PPG_Q) - (int32_t)x;     /* кровь поглощает свет: удар — провал, переворачиваем */
    c->sum += ac - c->ma[pos];
    c->ma[pos] = ac;
    c->y = c->sum / (int32_t)PPG_MA_N;
    if (c->y < c->lo)
        c->lo = c->y;
    if (c->y > c->hi)
        c->hi = c->y;
    return c->y;
}

Пики. Порог — половина высоты пиков; высота забывается на 1/256 за отсчёт (за 2,56 с — примерно в e раз), чтобы после сильного удара порог вернулся вниз. Пока сигнал выше порога, ищется максимум; ушёл под порог — это кандидат в удары:

    p->amp -= p->amp / 256;                             /* высота пиков забывается за несколько секунд */
    p->thr = p->amp / 2;
    if (y > p->thr && y > 0) {
        if (!p->above || y > p->cand_y) {
            p->cand = idx;
            p->cand_y = y;
        }
        p->above = 1;
        return 0;
    }
    if (!p->above)
        return 0;
    p->above = 0;                                       /* спуск под порог: пик позади */
    return accept(p);

Кандидат ближе 300 мс к прошлому удару — не удар (рефрактерный период: 200 ударов в минуту — это 300 мс): так отсекается второй горб той же волны и мелкая помеха. Интервал длиннее 2 с — пульс потерян, считать заново. ЧСС — 60 · fs, делённое на медиану последних 8 интервалов; медиана не боится одного лишнего или пропущенного удара:

/* кандидат прошёл: интервал, ЧСС, R и перфузия по размаху с прошлого удара */
static int accept(ppg_t *p)
{
    uint32_t d = p->cand - p->last;
    if (p->have_last && d < p->refract)
        return 0;                                       /* второй горб того же удара или помеха */
    p->amp += (p->cand_y - p->amp) / 4;
    int32_t ac_r = p->red.hi - p->red.lo, ac_i = p->ir.hi - p->ir.lo;
    int32_t dc_r = p->red.dc / (1 << PPG_Q), dc_i = p->ir.dc / (1 << PPG_Q);
    if (p->have_last && d <= p->ibi_max) {
        push_last(p->ibi, &p->nibi, d);
        if (ac_i > 0 && ac_r > 0 && dc_r > 0 && dc_i > 0) {
            push_last(p->rr, &p->nr, (uint32_t)((uint64_t)ac_r * (uint32_t)dc_i * 1000u / ((uint64_t)ac_i * (uint32_t)dc_r)));
            p->pi100 = (uint32_t)((uint64_t)ac_i * 10000u / (uint32_t)dc_i);
        }
        if (p->nibi >= 3u) {
            uint32_t m = ppg_median(p->ibi, p->nibi);
            p->hr10 = (600u * p->fs + m / 2u) / m;
            p->r1000 = ppg_median(p->rr, p->nr);
        }
    } else {
        p->nibi = p->nr = 0;                            /* пауза больше 2 с: всё сначала */
        p->hr10 = p->r1000 = 0;
    }
    p->last = p->cand;
    p->have_last = 1;
    p->beats++;
    p->red.lo = p->red.hi = p->red.y;
    p->ir.lo = p->ir.hi = p->ir.y;
    return 1;
}

Прогон на синтетической ФПГ. В модели: 72 удара в минуту, интервалы гуляют на 3 %, дыхание 0,25 Гц, шум, R модели 0,6; удары считаются с 5-й секунды. Первая оценка ЧСС — через 4,24 с после начала отсчётов (секунда на фильтры и три интервала). За 60 с найдено 65 ударов из 65 и один лишний, пик удара находится на 30 мс позже настоящего (фильтры), а сам удар признаётся ещё через 60–70 мс — когда сигнал уходит под порог. Средняя ошибка ЧСС — 0,52 удара в минуту, наибольшая — 1,64.

ЧСС модели Первая оценка Найдено Лишних Ошибка средняя / наибольшая
45 уд/мин 6,28 с 41 из 41 0 0,28 / 0,75 уд/мин
72 уд/мин 4,41 с 66 из 66 1 0,38 / 1,52 уд/мин
120 уд/мин 2,71 с 110 из 110 0 1,06 / 3,75 уд/мин
180 уд/мин 2,20 с 162 из 163 2 1,84 / 8,1 уд/мин

На 180 ударах интервал — 333 мс, рядом с рефрактерными 300 мс, и 94,4 % отчётов уложились в 5 ударов в минуту. Если палец приложить не сразу: пока его нет, постоянная ИК в модели — 2396 единиц, меньше порога PPG_FINGER_MIN, и обработка молчит с флагом «нет пальца»; палец лёг на 2,99 с, первая ЧСС — на 7,47 с.

Отношение R. На каждом ударе обработка берёт размах сигнала с прошлого удара (это AC) и постоянную составляющую (DC) в обоих каналах и считает R × 1000 целыми, отчёт — медиана последних 8 ударов. В модели R — просто параметр: во сколько раз глубина модуляции красного больше, чем у ИК.

R модели 0,4 0,6 0,8 1,0 1,2
R по прогону, медиана 0,415 0,606 0,804 1,007 1,206
разброс отчётов 0,395…0,437 0,593…0,629 0,789…0,819 0,986…1,023 1,184…1,222

R вслед за моделью ходит, и фильтры его почти не портят: они одинаковы в обоих каналах. Индекс перфузии хуже: 1,69 % по прогону при 2 % в модели — ФНЧ срезает вершину удара.

Почему не проценты. Пульсоксиметр переводит R в SpO₂ по калибровочной кривой, которую для своей конструкции снимают на людях: у добровольцев меняют насыщение крови, сравнивают отсчёты прибора с анализом артериальной крови и строят зависимость. Она своя для каждой пары светодиодов, корпуса и места на теле. Формулы «R → %», которые ходят по примерам, сняты не для вашего модуля, и в этом разделе их нет намеренно. В модели R — число, придуманное моделью, и любой процент из него был бы выдумкой. Медицинский пульсоксиметр — это ещё и испытания, регистрация и контроль качества. У этого кода нет ничего из этого: он показывает, как устроена обработка, и не годится для оценки здоровья.


103а.6 Движение, внешний свет и нажим пальца

Движение. В модели палец шевелится на 20–30 с и 40–45 с: колебания 0,9, 1,7 и 2,9 Гц, в той же полосе, что пульс, и больше его по размаху.

Отрезок Найдено Лишних Ошибка средняя / наибольшая В пределах 5 уд/мин
движение и 4 с после 23 из 27 11 13,25 / 21,86 уд/мин 35,3 %
покой 38 из 38 0 0,40 / 2,48 уд/мин 100 %

Фильтр движение не уберёт: оно там же, где пульс. Хуже того, R во время движения — 1,008 против 0,604 в покое. В модели движение одинаковой долей меняет оба канала, а одинаковые доли дают R около 1 — значение, которое не значит ничего, кроме «палец шевелится». На деле обработке нужна оценка качества сигнала (например, акселерометр рядом, раздел 99) и право сказать «не знаю».

Внешний свет. По документации производителя микросхема перед каждым импульсом мерит свет без светодиода и вычитает его — пока он не выходит за предел, иначе ставит ALC_OVF. В модели лампа мерцает на 100,4 Гц, предел — 4000 нА (допущение модели).

Внешний свет ALC_OVF Найдено ударов ЧСС
нет 0 из 352 чтений 65 из 65 ошибка 0,41 уд/мин
2000 нА 0 из 352 65 из 65 ошибка 0,41 уд/мин
5000 нА 245 из 352 22 из 65 ни одной оценки

Пока вычитание справляется, свет не виден вовсе. Сверх предела мерцание лампы проходит в отсчёты и забивает пульс. Флаг ALC_OVF драйвер считает (dev.alc_ovf) — по нему прошивка может сказать «закройте палец от света».

Нажим. Прижали сильно — кровь выдавлена, пульсирующей составляющей меньше. В модели нажим уменьшает глубину модуляции:

Глубина модуляции Индекс перфузии по прогону Найдено Лишних Ошибка средняя / наибольшая
как есть 1,66 % 65 из 65 1 0,51 / 1,71 уд/мин
× 0,3 0,50 % 64 из 65 5 1,06 / 15,63 уд/мин
× 0,1 0,15 % 39 из 65 13 27,87 / 58,27 уд/мин

При ×0,1 пульс тонет в дыхании и шуме. Слабый нажим — обратная беда: палец шевелится и пропускает свет сбоку, это два предыдущих случая. Индекс перфузии и есть простейшая оценка качества: очень маленький — показаниям не верить.


103а.7 Грабли

Отсчёты скачут, ударов вдвое больше, чем есть. Перепутан порядок байт: младший принят за старший. В модели ИК вместо 119 936 стал 54 273, за 30 с найдено 74 «удара» при 29 настоящих, и ЧСС вышла 166,7 вместо 71,5. Старший байт первым — max_unpack.

ЧСС завышена при опросе по таймеру. FIFO переполняется, отсчёты теряются, а счёт времени этого не знает: опрос раз в 400 мс — 88,2 удара в минуту вместо 71,4. Читать по A_FULL или сдвигать время на OVF_COUNTER (103а.4). Сдвиг выручает, пока за чтение теряется не больше 31 отсчёта: в модели 330 и 400 мс — да, 1000 мс — нет.

В FIFO 32 отсчёта, а драйвер видит 0. Указатели равны: в модели после 32 отсчётов WR_PTR = RD_PTR = 0 и OVF_COUNTER = 0. Ещё два отсчёта — и OVF_COUNTER = 2, тогда драйвер читает 32. Не ждать до полного FIFO: прерывание при 17.

Постоянная упёрлась в верх шкалы, ударов нет. Ток светодиода слишком велик для диапазона:

Ток Диапазон Постоянная ИК в модели Насыщение Найдено ударов за 30 с
0x10, 3,2 мА 4096 нА 53 248 нет 30 из 30
0x24, 7,2 мА 4096 нА 119 808 нет 30 из 30
0x40, 12,8 мА 4096 нА 212 992 нет 30 из 30
0x50, 16 мА 4096 нА 266 240 да 23 из 30
0xFF, 51 мА 4096 нА 848 640 да 0 из 30
0xFF, 51 мА 16384 нА 212 160 нет 30 из 30

Выше 262 143 шкалы нет: верх удара срезан, при 0xFF срезано всё. Обработка ставит PPG_F_SAT. Ток подбирают так, чтобы постоянная стояла примерно на середине шкалы, или берут диапазон шире.

R около 1,67 вместо 0,6. Каналы перепутаны: первые три байта отсчёта — красный, вторые — ИК. В модели с перепутанными каналами R = 1,669 — почти ровно 1 / 0,6 = 1,667.

Модуль не отвечает или INT не читается. Подтяжки модуля к 1,8 В — ниже гарантированной единицы F103 2,145 В (103а.3). Вывод INT — на FT, а лучше подтяжки к 3,3 В.

Прерываний нет вообще. INT опущен с включения (PWR_RDY) или с прошлого раза, а EXTI ловит только спад. max_init читает INT_STATUS1; прошивка, кроме флага прерывания, проверяет и сам уровень PB8.

Палец лёг — первые секунды тишина. Так и должно быть: секунда на фильтры и три интервала на медиану, в модели первая ЧСС — через 4,24 с.


103а.8 Итоги

Полные исходники

max103b.h — регистры, биты, коды, порт и функции драйвера MAX30102.

/* max103b.h — раздел 103а: драйвер MAX30102 (красный и ИК светодиоды, фотодиод, АЦП, FIFO) по I2C
 * без регистров микроконтроллера. Драйвер говорит с микросхемой только через две функции порта
 * (max_port_*): в прошивке fw103b.c их делает I2C1 STM32F103, в стенде host103b.c — модель model103b.py.
 * Карта регистров и биты — по документации производителя; документа в проекте нет, номера регистров и
 * поля совпадают с открытым драйвером родственной MAX30101 в Zephyr 4.4.2; с микросхемой не сверено. */
#ifndef MAX103B_H
#define MAX103B_H
#include <stdint.h>

/* ── регистры ─────────────────────────────────────────────────────────── */
#define MAX_INT_STATUS1   0x00u         /* чтение снимает флаги и отпускает INT */
#define MAX_INT_ENABLE1   0x02u
#define MAX_FIFO_WR_PTR   0x04u         /* 0x04…0x06 — запись, переполнение, чтение: читаются подряд */
#define MAX_OVF_COUNTER   0x05u
#define MAX_FIFO_RD_PTR   0x06u
#define MAX_FIFO_DATA     0x07u         /* номер регистра при чтении не растёт: каждые 3 байта — следующий канал */
#define MAX_FIFO_CONFIG   0x08u         /* SMP_AVE [7:5], FIFO_ROLLOVER_EN [4], FIFO_A_FULL [3:0] */
#define MAX_MODE_CONFIG   0x09u         /* SHDN [7], RESET [6], MODE [2:0] */
#define MAX_SPO2_CONFIG   0x0Au         /* ADC_RGE [6:5], SR [4:2], LED_PW [1:0] */
#define MAX_LED1_PA       0x0Cu         /* ток красного светодиода */
#define MAX_LED2_PA       0x0Du         /* ток ИК светодиода */
#define MAX_PART_ID       0xFFu

/* ── биты ─────────────────────────────────────────────────────────────── */
#define MAX_INT_A_FULL    0x80u         /* в FIFO осталось FIFO_A_FULL свободных мест */
#define MAX_INT_PPG_RDY   0x40u         /* новый отсчёт */
#define MAX_INT_ALC_OVF   0x20u         /* вычитание внешнего света упёрлось в предел */
#define MAX_INT_PWR_RDY   0x01u         /* питание появилось */
#define MAX_MODE_SHDN     0x80u
#define MAX_MODE_RESET    0x40u         /* сам сбрасывается в 0 по окончании */
#define MAX_MODE_HR       0x02u         /* только красный */
#define MAX_MODE_SPO2     0x03u         /* красный и ИК */
#define MAX_FIFO_ROLLOVER 0x10u

#define MAX_ADDR          0x57u         /* семибитный: 0xAE запись, 0xAF чтение */
#define MAX_PART_ID_VAL   0x15u
#define MAX_FIFO_DEPTH    32u
#define MAX_FULL_SCALE    0x3FFFFu      /* 18 бит; при 15…17 битах младшие биты — нули */
#define MAX_RESET_TRIES   10u

enum { MAX_OK = 0, MAX_E_NACK = -1, MAX_E_BUS = -2, MAX_E_ID = -3, MAX_E_RESET = -4 };

typedef struct {
    uint8_t mode;                       /* MAX_MODE_HR или MAX_MODE_SPO2 */
    uint8_t sr;                         /* частота отсчётов, код 0…7: 50, 100, 200, 400, 800, 1000, 1600, 3200 Гц */
    uint8_t pw;                         /* ширина импульса, код 0…3: 69, 118, 215, 411 мкс — 15…18 бит */
    uint8_t rge;                        /* диапазон АЦП, код 0…3: 2048, 4096, 8192, 16384 нА */
    uint8_t ave;                        /* усреднение в микросхеме, код 0…5: 1…32 отсчёта */
    uint8_t a_full;                     /* прерывание, когда в FIFO осталось столько свободных мест */
    uint8_t led_red, led_ir;            /* ток светодиодов, код 0…255 */
} max_cfg_t;

typedef struct {
    uint8_t addr;
    uint8_t leds;                       /* каналов в отсчёте: 1 (HR) или 2 (SpO2) */
    uint8_t status;                     /* последний INT_STATUS1 */
    uint32_t xfers, errors;             /* обменов по I2C и ошибок */
    uint32_t lost;                      /* отсчётов потеряно — по OVF_COUNTER */
    uint32_t alc_ovf;                   /* сколько раз внешний свет не вычелся */
} max_t;

/* ── порт: в прошивке — I2C1, в стенде — строки к модели ──────────────── */
/* ntx байт записи, затем (если nrx) повторный старт и nrx байт чтения; 0, MAX_E_NACK или MAX_E_BUS */
int max_port_xfer(uint8_t addr7, const uint8_t *tx, uint32_t ntx, uint8_t *rx, uint32_t nrx);
void max_port_delay_us(uint32_t us);

/* ── драйвер ──────────────────────────────────────────────────────────── */
int max_reg(max_t *d, uint8_t reg, uint8_t v);
int max_get(max_t *d, uint8_t reg, uint8_t *v);
int max_init(max_t *d, const max_cfg_t *c);
int max_service(max_t *d);
int max_fifo_count(max_t *d, uint32_t *n, uint32_t *ovf);
uint32_t max_unpack(const uint8_t *b);
int max_read_fifo(max_t *d, uint32_t *red, uint32_t *ir, uint32_t maxn, uint32_t *got, uint32_t *lost);

#endif

max103b.c — драйвер: сброс и настройка, флаги INT, указатели FIFO, чтение отсчётов.

/* max103b.c — раздел 103а: драйвер MAX30102 по I2C. Один и тот же файл собирается в прошивку (armclang,
 * Cortex-M3) и в стенд на компьютере (gcc): микроконтроллера он не знает, только порт max_port_*. */
#include "max103b.h"

static int xfer_done(max_t *d, int rc)
{
    d->xfers++;
    if (rc)
        d->errors++;
    return rc;
}

int max_reg(max_t *d, uint8_t reg, uint8_t v)
{
    uint8_t b[2] = {reg, v};
    return xfer_done(d, max_port_xfer(d->addr, b, 2, 0, 0));
}

int max_get(max_t *d, uint8_t reg, uint8_t *v)
{
    return xfer_done(d, max_port_xfer(d->addr, &reg, 1, v, 1));
}

/* сброс, ожидание конца сброса, настройка; режим пишется последним — с него начинаются отсчёты */
int max_init(max_t *d, const max_cfg_t *c)
{
    uint8_t v = 0;
    int rc;
    d->leds = c->mode == MAX_MODE_SPO2 ? 2u : 1u;
    d->xfers = d->errors = d->lost = d->alc_ovf = 0;
    if ((rc = max_get(d, MAX_PART_ID, &v)) != MAX_OK)
        return rc;
    if (v != MAX_PART_ID_VAL)
        return MAX_E_ID;                                /* по адресу 0x57 не та микросхема */
    if ((rc = max_reg(d, MAX_MODE_CONFIG, MAX_MODE_RESET)) != MAX_OK)
        return rc;
    for (uint32_t i = 0;; i++) {                        /* RESET сбрасывается сам */
        max_port_delay_us(1000u);
        if ((rc = max_get(d, MAX_MODE_CONFIG, &v)) != MAX_OK)
            return rc;
        if (!(v & MAX_MODE_RESET))
            break;
        if (i + 1u >= MAX_RESET_TRIES)
            return MAX_E_RESET;
    }
    if ((rc = max_get(d, MAX_INT_STATUS1, &v)) != MAX_OK)  /* снять PWR_RDY — INT отпущен */
        return rc;
    /* без FIFO_ROLLOVER_EN: в полный FIFO новые отсчёты не пишутся, их считает OVF_COUNTER */
    if ((rc = max_reg(d, MAX_FIFO_CONFIG, (uint8_t)((c->ave & 7u) << 5 | (c->a_full & 0x0Fu)))) != MAX_OK)
        return rc;
    if ((rc = max_reg(d, MAX_SPO2_CONFIG, (uint8_t)((c->rge & 3u) << 5 | (c->sr & 7u) << 2 | (c->pw & 3u)))) != MAX_OK)
        return rc;
    if ((rc = max_reg(d, MAX_LED1_PA, c->led_red)) != MAX_OK)
        return rc;
    if ((rc = max_reg(d, MAX_LED2_PA, c->led_ir)) != MAX_OK)
        return rc;
    if ((rc = max_reg(d, MAX_INT_ENABLE1, MAX_INT_A_FULL)) != MAX_OK)
        return rc;
    return max_reg(d, MAX_MODE_CONFIG, (uint8_t)(c->mode & 7u));
}

/* обработчик INT: прочитать INT_STATUS1 — флаги снимаются, вывод отпускается */
int max_service(max_t *d)
{
    int rc = max_get(d, MAX_INT_STATUS1, &d->status);
    if (rc == MAX_OK && (d->status & MAX_INT_ALC_OVF))
        d->alc_ovf++;
    return rc;
}

/* сколько отсчётов ждёт в FIFO: указатели записи и чтения по 5 бит; равны — пусто или полно */
int max_fifo_count(max_t *d, uint32_t *n, uint32_t *ovf)
{
    uint8_t reg = MAX_FIFO_WR_PTR, p[3];                /* WR_PTR, OVF_COUNTER, RD_PTR одним чтением */
    int rc = xfer_done(d, max_port_xfer(d->addr, &reg, 1, p, 3));
    if (rc != MAX_OK)
        return rc;
    *ovf = p[1] & 0x1Fu;
    *n = (uint32_t)(p[0] - p[2]) & (MAX_FIFO_DEPTH - 1u);
    if (*n == 0u && *ovf != 0u)
        *n = MAX_FIFO_DEPTH;                            /* отсчёты терялись — значит, FIFO полон */
    return MAX_OK;
}

/* три байта канала: старший первым, значащие 18 младших бит */
uint32_t max_unpack(const uint8_t *b)
{
    return ((uint32_t)b[0] << 16 | (uint32_t)b[1] << 8 | b[2]) & MAX_FULL_SCALE;
}

/* всё, что есть в FIFO, одним чтением: по 3 байта на канал, в SpO2 сначала красный, потом ИК.
 * lost — сколько отсчётов не поместилось: они были ПОСЛЕ прочитанных */
int max_read_fifo(max_t *d, uint32_t *red, uint32_t *ir, uint32_t maxn, uint32_t *got, uint32_t *lost)
{
    uint8_t reg = MAX_FIFO_DATA, buf[MAX_FIFO_DEPTH * 6u];
    uint32_t n, ovf, k = 3u * d->leds;
    int rc;
    *got = *lost = 0;
    if ((rc = max_fifo_count(d, &n, &ovf)) != MAX_OK)
        return rc;
    if (n > maxn)
        n = maxn;
    *lost = ovf;
    d->lost += ovf;
    if (n == 0u)
        return MAX_OK;
    if ((rc = xfer_done(d, max_port_xfer(d->addr, &reg, 1, buf, n * k))) != MAX_OK)
        return rc;
    for (uint32_t i = 0; i < n; i++) {
        red[i] = max_unpack(&buf[i * k]);
        ir[i] = d->leds == 2u ? max_unpack(&buf[i * k + 3u]) : 0u;
    }
    *got = n;
    return MAX_OK;
}

ppg103b.h — параметры обработки и её состояние.

/* ppg103b.h — раздел 103а: обработка фотоплетизмограммы целыми числами (у F103 нет FPU).
 * Вход — отсчёты красного и ИК каналов MAX30102; выход — удары, ЧСС, отношение R и индекс перфузии.
 * Учебный пример: не медицинское изделие, для диагностики не годится. */
#ifndef PPG103B_H
#define PPG103B_H
#include <stdint.h>

#define PPG_DC_SHIFT   5u               /* ФВЧ: постоянная составляющая — среднее с весом 1/32 */
#define PPG_MA_N       8u               /* ФНЧ: скользящее среднее по 8 отсчётам */
#define PPG_Q          8u               /* постоянная составляющая хранится ×256 */
#define PPG_REFRACT_MS 300u             /* после удара 300 мс новых не ищем: это 200 ударов в минуту */
#define PPG_IBI_MAX_MS 2000u            /* интервал длиннее 2 с — пульс потерян, считать заново */
#define PPG_WARM_MS    1000u            /* первую секунду фильтры выходят на режим */
#define PPG_IBI_N      8u               /* ЧСС и R — медиана последних 8 ударов */
#define PPG_FINGER_MIN 20000u           /* постоянная ИК меньше — пальца нет */
#define PPG_SAT        0x3FF00u         /* отсчёт у верха шкалы 18 бит — насыщение */

enum { PPG_F_NOFINGER = 1, PPG_F_SAT = 2 };

typedef struct {
    int32_t dc;                         /* постоянная составляющая ×256 */
    int32_t ma[PPG_MA_N], sum;          /* окно скользящего среднего */
    int32_t y;                          /* пульсирующая составляющая, перевёрнута: удар — вверх */
    int32_t lo, hi;                     /* размах y с прошлого удара */
} ppg_ch_t;

typedef struct {
    ppg_ch_t red, ir;
    uint32_t fs, n, pos, start;         /* частота; номер отсчёта (с потерянными); место в окне; начало */
    uint32_t refract, ibi_max, warm;    /* в отсчётах */
    int32_t amp, thr;                   /* высота пиков и порог — её половина */
    int above, have_last, started;
    uint32_t cand, last;                /* кандидат в удары и последний удар — номера отсчётов */
    int32_t cand_y;
    uint32_t ibi[PPG_IBI_N], rr[PPG_IBI_N], nibi, nr;
    uint32_t beats, flags;
    uint32_t hr10;                      /* ЧСС ×10, 0 — оценки нет */
    uint32_t r1000;                     /* R = (AC_red/DC_red)/(AC_ir/DC_ir) ×1000 */
    uint32_t pi100;                     /* AC/DC ИК в сотых долях процента */
} ppg_t;

void ppg_init(ppg_t *p, uint32_t fs);
int ppg_push(ppg_t *p, uint32_t red, uint32_t ir);
void ppg_skip(ppg_t *p, uint32_t n);
uint32_t ppg_median(const uint32_t *v, uint32_t n);

#endif

ppg103b.c — обработка: ФВЧ, ФНЧ, пики, ЧСС, отношение R и индекс перфузии.

/* ppg103b.c — раздел 103а: ФВЧ, ФНЧ, пики с адаптивным порогом и рефрактерным периодом, ЧСС по
 * интервалам и отношение R. Тот же файл — в прошивке (armclang, Cortex-M3) и в стенде на компьютере (gcc).
 * Учебный пример: не медицинское изделие, для диагностики не годится. */
#include "ppg103b.h"

void ppg_init(ppg_t *p, uint32_t fs)
{
    *p = (ppg_t){0};
    p->fs = fs;
    p->refract = fs * PPG_REFRACT_MS / 1000u;
    p->ibi_max = fs * PPG_IBI_MAX_MS / 1000u;
    p->warm = fs * PPG_WARM_MS / 1000u;
}

/* медиана до 8 чисел: вставками в копию */
uint32_t ppg_median(const uint32_t *v, uint32_t n)
{
    uint32_t s[PPG_IBI_N];
    if (n == 0u)
        return 0;
    for (uint32_t i = 0; i < n; i++) {
        uint32_t j = i;
        for (; j > 0u && s[j - 1u] > v[i]; j--)
            s[j] = s[j - 1u];
        s[j] = v[i];
    }
    return s[n / 2u];
}

/* один канал: ФВЧ (минус медленное среднее), переворот, ФНЧ (среднее по 8) */
static int32_t ch_step(ppg_ch_t *c, uint32_t x, uint32_t pos, int first)
{
    int32_t xq = (int32_t)(x << PPG_Q);
    if (first)
        c->dc = xq;
    c->dc += (xq - c->dc) / (1 << PPG_DC_SHIFT);
    int32_t ac = c->dc / (1 << PPG_Q) - (int32_t)x;     /* кровь поглощает свет: удар — провал, переворачиваем */
    c->sum += ac - c->ma[pos];
    c->ma[pos] = ac;
    c->y = c->sum / (int32_t)PPG_MA_N;
    if (c->y < c->lo)
        c->lo = c->y;
    if (c->y > c->hi)
        c->hi = c->y;
    return c->y;
}

static void push_last(uint32_t *v, uint32_t *n, uint32_t x)
{
    if (*n == PPG_IBI_N) {
        for (uint32_t i = 1; i < PPG_IBI_N; i++)
            v[i - 1u] = v[i];
        (*n)--;
    }
    v[(*n)++] = x;
}

/* кандидат прошёл: интервал, ЧСС, R и перфузия по размаху с прошлого удара */
static int accept(ppg_t *p)
{
    uint32_t d = p->cand - p->last;
    if (p->have_last && d < p->refract)
        return 0;                                       /* второй горб того же удара или помеха */
    p->amp += (p->cand_y - p->amp) / 4;
    int32_t ac_r = p->red.hi - p->red.lo, ac_i = p->ir.hi - p->ir.lo;
    int32_t dc_r = p->red.dc / (1 << PPG_Q), dc_i = p->ir.dc / (1 << PPG_Q);
    if (p->have_last && d <= p->ibi_max) {
        push_last(p->ibi, &p->nibi, d);
        if (ac_i > 0 && ac_r > 0 && dc_r > 0 && dc_i > 0) {
            push_last(p->rr, &p->nr, (uint32_t)((uint64_t)ac_r * (uint32_t)dc_i * 1000u / ((uint64_t)ac_i * (uint32_t)dc_r)));
            p->pi100 = (uint32_t)((uint64_t)ac_i * 10000u / (uint32_t)dc_i);
        }
        if (p->nibi >= 3u) {
            uint32_t m = ppg_median(p->ibi, p->nibi);
            p->hr10 = (600u * p->fs + m / 2u) / m;
            p->r1000 = ppg_median(p->rr, p->nr);
        }
    } else {
        p->nibi = p->nr = 0;                            /* пауза больше 2 с: всё сначала */
        p->hr10 = p->r1000 = 0;
    }
    p->last = p->cand;
    p->have_last = 1;
    p->beats++;
    p->red.lo = p->red.hi = p->red.y;
    p->ir.lo = p->ir.hi = p->ir.y;
    return 1;
}

/* один отсчёт; 1 — найден удар (его номер — p->last) */
int ppg_push(ppg_t *p, uint32_t red, uint32_t ir)
{
    uint32_t idx = p->n++;
    int first = !p->started;
    int32_t y = ch_step(&p->ir, ir, p->pos, first);
    (void)ch_step(&p->red, red, p->pos, first);
    p->pos = (p->pos + 1u) % PPG_MA_N;
    if (first) {
        p->started = 1;
        p->start = idx;
    }
    p->flags = (red >= PPG_SAT || ir >= PPG_SAT) ? PPG_F_SAT : 0u;
    if ((uint32_t)(p->ir.dc / (1 << PPG_Q)) < PPG_FINGER_MIN) {
        p->flags |= PPG_F_NOFINGER;                     /* пальца нет: ни ударов, ни оценок */
        p->nibi = p->nr = 0;
        p->hr10 = p->r1000 = 0;
        p->above = p->have_last = 0;
        return 0;
    }
    if (idx - p->start < p->warm) {
        if (y > p->amp)
            p->amp = y;                                 /* первая оценка высоты пиков */
        return 0;
    }
    p->amp -= p->amp / 256;                             /* высота пиков забывается за несколько секунд */
    p->thr = p->amp / 2;
    if (y > p->thr && y > 0) {
        if (!p->above || y > p->cand_y) {
            p->cand = idx;
            p->cand_y = y;
        }
        p->above = 1;
        return 0;
    }
    if (!p->above)
        return 0;
    p->above = 0;                                       /* спуск под порог: пик позади */
    return accept(p);
}

/* потерянные отсчёты (OVF_COUNTER): время идёт, фильтры стоят */
void ppg_skip(ppg_t *p, uint32_t n)
{
    p->n += n;
}

fw103b.c — прошивка STM32F103: I2C1 на 400 кГц, приём по способу 2, INT на PB8 через EXTI8.

/* fw103b.c — раздел 103а: MAX30102 на STM32F103 (BluePill), 72 МГц, APB1 = 36 МГц.
 * I2C1: PB6 — SCL, PB7 — SDA (открытый сток, 400 кГц). INT модуля — на PB8 (допуск 5 В, вход с подтяжкой,
 * EXTI8 по спаду). SpO2: 100 отсчётов в секунду, 411 мкс (18 бит), диапазон 4096 нА, ток 7,2 мА, прерывание
 * при 17 отсчётах в FIFO. Результат — в переменных hr10, r1000, pi100 (смотреть отладчиком).
 * Учебный пример: не медицинское изделие, для диагностики не годится. Тактирование до 72 МГц — как в разделе 6. */
#include "stm32f10x.h"
#include "max103b.h"
#include "ppg103b.h"

#define CPU_HZ          72000000u
#define APB1_MHZ        36u
#define I2C_TIMEOUT_US  1000u           /* дольше любого флага на 400 кГц */

static max_t dev = {.addr = MAX_ADDR};
static ppg_t ppg;
static volatile uint8_t irq;
volatile uint32_t hr10, r1000, pi100, flags, bus_errors, lost;

static int expired(uint32_t t0, uint32_t us)
{
    return DWT->CYCCNT - t0 >= us * (CPU_HZ / 1000000u);
}

void max_port_delay_us(uint32_t us)
{
    uint32_t t0 = DWT->CYCCNT;
    while (!expired(t0, us)) {
    }
}

static void i2c1_init(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_IOPBEN | RCC_APB2ENR_AFIOEN;
    RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
    /* PB6, PB7 — альтернативная функция, открытый сток, 50 МГц (1111); PB8 — вход с подтяжкой (1000) */
    GPIOB->CRL = (GPIOB->CRL & 0x00FFFFFFu) | 0xFF000000u;
    GPIOB->CRH = (GPIOB->CRH & ~0xFu) | 0x8u;
    GPIOB->BSRR = 1u << 8;
    I2C1->CR1 = 0;                                      /* настраивать при PE = 0 */
    I2C1->CR2 = APB1_MHZ;
    I2C1->CCR = I2C_CCR_FS | 30u;                       /* DUTY = 0: 36 МГц / (3 × 30) = 400 кГц */
    I2C1->TRISE = APB1_MHZ * 300u / 1000u + 1u;         /* 300 нс × 36 МГц = 10,8 → 10 + 1 = 11 */
    I2C1->CR1 = I2C_CR1_PE;
}

static int sr1_wait(uint16_t mask)
{
    uint32_t t0 = DWT->CYCCNT;
    while (!(I2C1->SR1 & mask)) {
        if (I2C1->SR1 & I2C_SR1_AF)
            return MAX_E_NACK;                          /* адрес или байт не подтверждён */
        if (expired(t0, I2C_TIMEOUT_US))
            return MAX_E_BUS;
    }
    return MAX_OK;
}

static int fail(int rc)
{
    I2C1->CR1 |= I2C_CR1_STOP;
    I2C1->SR1 = (uint16_t)~I2C_SR1_AF;                  /* AF снимается записью нуля */
    I2C1->CR1 |= I2C_CR1_ACK;
    return rc;
}

/* приём nrx байт после повторного старта: 1 байт — RM0008, с. 764; больше двух — способ 2, с. 762–763 */
static int rx_bytes(uint8_t *rx, uint32_t nrx)
{
    int rc;
    uint32_t i = 0;
    if (nrx == 1u) {
        I2C1->CR1 &= (uint16_t)~I2C_CR1_ACK;            /* ACK снять до сброса ADDR */
        (void)I2C1->SR2;
        I2C1->CR1 |= I2C_CR1_STOP;
        if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
            return rc;
        rx[0] = (uint8_t)I2C1->DR;
        I2C1->CR1 |= I2C_CR1_ACK;
        return MAX_OK;
    }
    (void)I2C1->SR2;                                    /* ADDR снят, ACK = 1 */
    for (; i + 3u < nrx; i++) {
        if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
            return rc;
        rx[i] = (uint8_t)I2C1->DR;
    }
    if ((rc = sr1_wait(I2C_SR1_BTF)) != MAX_OK)         /* осталось три: N−2 в DR, N−1 в сдвиговом, SCL прижат */
        return rc;
    I2C1->CR1 &= (uint16_t)~I2C_CR1_ACK;
    rx[i++] = (uint8_t)I2C1->DR;                        /* N−2; пошёл приём N с NACK */
    if ((rc = sr1_wait(I2C_SR1_BTF)) != MAX_OK)
        return rc;
    I2C1->CR1 |= I2C_CR1_STOP;
    rx[i++] = (uint8_t)I2C1->DR;                        /* N−1 */
    if ((rc = sr1_wait(I2C_SR1_RXNE)) != MAX_OK)
        return rc;
    rx[i] = (uint8_t)I2C1->DR;                          /* N */
    I2C1->CR1 |= I2C_CR1_ACK;
    return MAX_OK;
}

int max_port_xfer(uint8_t addr7, const uint8_t *tx, uint32_t ntx, uint8_t *rx, uint32_t nrx)
{
    int rc;
    uint32_t t0 = DWT->CYCCNT;
    if (nrx == 2u)
        return MAX_E_BUS;                               /* драйвер читает 1, 3 или 6·n байт */
    while (I2C1->SR2 & I2C_SR2_BUSY) {                  /* SDA держит ведомый — раздел 17.6 */
        if (expired(t0, I2C_TIMEOUT_US))
            return MAX_E_BUS;
    }
    I2C1->CR1 |= I2C_CR1_ACK;
    I2C1->CR1 |= I2C_CR1_START;
    if ((rc = sr1_wait(I2C_SR1_SB)) != MAX_OK)
        return fail(rc);
    I2C1->DR = (uint8_t)(addr7 << 1);
    if ((rc = sr1_wait(I2C_SR1_ADDR)) != MAX_OK)
        return fail(rc);
    (void)I2C1->SR2;
    for (uint32_t i = 0; i < ntx; i++) {
        if ((rc = sr1_wait(I2C_SR1_TXE)) != MAX_OK)
            return fail(rc);
        I2C1->DR = tx[i];
    }
    if ((rc = sr1_wait(I2C_SR1_BTF)) != MAX_OK)
        return fail(rc);
    if (!nrx) {
        I2C1->CR1 |= I2C_CR1_STOP;
        return MAX_OK;
    }
    I2C1->CR1 |= I2C_CR1_START;                         /* повторный старт: номер регистра уже передан */
    if ((rc = sr1_wait(I2C_SR1_SB)) != MAX_OK)
        return fail(rc);
    I2C1->DR = (uint8_t)(addr7 << 1 | 1u);
    if ((rc = sr1_wait(I2C_SR1_ADDR)) != MAX_OK)
        return fail(rc);
    if ((rc = rx_bytes(rx, nrx)) != MAX_OK)
        return fail(rc);
    return MAX_OK;
}

/* INT: спад на PB8 — в FIFO 17 отсчётов */
void EXTI9_5_IRQHandler(void)
{
    EXTI->PR = 1u << 8;
    irq = 1;
}

static void irq_init(void)
{
    AFIO->EXTICR[2] = (AFIO->EXTICR[2] & ~0xFu) | 0x1u;     /* EXTI8 ← PB8 */
    EXTI->FTSR |= 1u << 8;
    EXTI->IMR |= 1u << 8;
    NVIC_EnableIRQ(EXTI9_5_IRQn);
}

static const max_cfg_t cfg = {.mode = MAX_MODE_SPO2, .sr = 1, .pw = 3, .rge = 1, .ave = 0, .a_full = 15,
                              .led_red = 0x24, .led_ir = 0x24};

int main(void)
{
    static uint32_t red[MAX_FIFO_DEPTH], ir[MAX_FIFO_DEPTH];
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
    i2c1_init();
    irq_init();
    while (max_init(&dev, &cfg) != MAX_OK)
        max_port_delay_us(100000u);                     /* нет ответа: провод, адрес, питание, уровни */
    ppg_init(&ppg, 100u);
    for (;;) {
        uint32_t got = 0, n_lost = 0;
        if (!irq && (GPIOB->IDR & (1u << 8)))
            continue;                                   /* INT в единице и спада не было — ждать */
        irq = 0;
        if (max_service(&dev) != MAX_OK || max_read_fifo(&dev, red, ir, MAX_FIFO_DEPTH, &got, &n_lost) != MAX_OK) {
            bus_errors++;
            continue;
        }
        for (uint32_t i = 0; i < got; i++)
            (void)ppg_push(&ppg, red[i], ir[i]);
        ppg_skip(&ppg, n_lost);                         /* потерянные — после прочитанных */
        hr10 = ppg.hr10;
        r1000 = ppg.r1000;
        pi100 = ppg.pi100;
        flags = ppg.flags;
        lost = dev.lost;
    }
}

Дальше: раздел 103б «Датчик цвета TCS34725 по I2C: каналы RGBC, время интегрирования и усиление, баланс белого, цветовая температура и освещённость» — цвет вместо пульса: четыре канала TCS34725 по I2C, баланс белого и узнавание карточек, люксы и кельвины по аппнотке DN40.

Открыть в интерактивном справочнике