Embedded HandbookПриложения
Все разделы › XXV. Мини-проекты
STM32F103 · уровень средний · 27 мин чтения
Открыть в интерактивном справочнике

198а. Мини-проект: датчик на батарейке — год на одной CR2032

Термостат из раздела 196 и часы из раздела 198 живут от розетки. Четвёртый прибор — термометр-регистратор, который год живёт от одной «таблетки» CR2032: раз в минуту просыпается по будильнику RTC, измеряет температуру, копит журнал во Flash и снова засыпает. Весь раздел 10 здесь превращается из теории в статью расхода: каждый микроампер — это дни жизни прибора.

Главное в проекте — не код, а бюджет энергии: посчитать средний ток до того, как паять, и знать, какая строка бюджета главная. Спойлер: не вспышки работы, а ток сна — и утечки, которых не видно на схеме.

Файлы проекта — _verify/batt198a/: batt.h, batt.c (логика и кольцевой журнал), plat_f103.c (платформа BluePill), sim_host.c (модель Flash и датчика для компьютера), budget.py (бюджет с проверкой размерностей). Прошивка собрана для Cortex-M3, год работы прогнан на компьютере. Сверяет _verify/batt198a_check.py.

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

Оригинал Перевод
coin cell, button cell дисковый элемент питания, «таблетка»
nominal capacity (mA·h) номинальная ёмкость, миллиампер-часы
continuous drain, standard load постоянный ток разряда, стандартная нагрузка
pulse drain импульсный ток разряда
end (cut-off) voltage конечное напряжение разряда
internal resistance внутреннее сопротивление источника
shelf life срок хранения элемента
battery life срок службы от одной батарейки

198а.1 Что делает прибор

Функция Как
просыпаться раз в минуту будильник RTC от кварца 32 768 Гц (раздел 198.5), событие EXTI 17
измерять температуру NTC 10 кОм в делителе, таблица из 17 точек — как в разделе 196.3
не кормить делитель зря верхнее плечо запитано от вывода PA2 только на время измерения
помнить год журнал во Flash: кольцо из 8 страниц, запись раз в 10 минут
знать состояние батарейки напряжение питания по внутренней опоре VREFINT, пишется в журнал
отдавать журнал кнопка на PA0 будит камень, журнал уходит в USART1, 115 200 бод
спать всё остальное время Stop, регулятор в экономичном режиме, ≈15,4 мкА вместе с RTC

Схема датчика на CR2032: батарейка с керамическим конденсатором 100 мкФ, делитель NTC с питанием от вывода PA2, кнопка чтения журнала на PA0 и USB-UART

Батарейка подключена прямо к шине 3,3 В платы, мимо штатного стабилизатора: он и светодиод питания съели бы её за считаные дни (198а.7). Рабочий диапазон F103 — 2,0…3,6 В, так что CR2032 подходит без преобразователей: свежая даёт около 3 В, а конечное напряжение разряда «таблетки» — как раз 2,0 В.

⚠️ АЦП — только до 2,4 В. Питание камню разрешено до 2,0 В, но аналоговое питание с работающим АЦП нормировано от 2,4 В. Ниже прибор ещё живёт и пишет журнал, а точность измерений уже не обещана. Делитель при этом меряется верно дольше, чем кажется: он питается тем же напряжением, что и опора АЦП, и код от просевшего питания не зависит (логометрическое измерение, раздел 96.2).


198а.2 Бюджет: сначала посчитать, потом паять

Ёмкость CR2032 у известных производителей — типовые 220–235 мА·ч; в расчёте — консервативные 220. Честная оговорка: эти миллиампер-часы обещаны при разряде «ровным» током порядка 0,2 мА (стандартная нагрузка 15 кОм) до 2,0 В при комнатной температуре. Миллиамперные импульсы батарейке тяжелы: внутреннее сопротивление свежего элемента — десятки ом, к концу разряда — сотни, и вспышка тока просаживает напряжение.

⚠️ Вспышку отдаёт конденсатор, не батарейка. Керамический 100 мкФ у вывода питания: вспышка 4 мА × 5 мс возьмёт из него 0,2 В просадки (ΔU = I·t/C), а батарейка отдаст тот же заряд плавно, средним током. Без конденсатора та же вспышка на севшей батарейке с сотнями ом внутри уронит питание ниже сброса — прибор будет «умирать» при каждом измерении.

Все токи камня берутся не из головы, а из даташита STM32F103x8/B — таблицы «Supply current characteristics» (типовые значения при 25 °C):

Режим Ток Примечание
Run, HSI 8 МГц 4,0 мА периферия выключена
Sleep, HSI 8 МГц 0,6 мА периферия выключена
Stop, регулятор основной 24 мкА выход 3,6 мкс
Stop, регулятор экономичный 14 мкА 13,5 мкА при 2,4 В; выход 5,4 мкс; при 85 °C — до 180 мкА
Standby 2 мкА ОЗУ и регистры теряются, выход 50 мкс через сброс
кварц LSE + RTC 1,4 мкА 1,1 мкА при 2,4 В
АЦП включён (ADON = 1) +0,8 мА аналоговая часть

Выбор режима сна — по разделу 10.1. Standby в четыре раза экономнее Stop, но теряет ОЗУ: накопитель десяти минутных измерений и хвост кольца пришлось бы прятать в резервные регистры, а программа каждый раз стартовала бы с нуля. Для первой версии берём Stop с экономичным регулятором: 14 мкА + 1,4 мкА на RTC с кварцем = 15,4 мкА — и ОЗУ целое, и продолжение с той же строки. Выигрыш Standby посчитан в 198а.8.


198а.3 Цикл: проснулся → измерил → записал → уснул

Тактирование — HSI 8 МГц без ФАПЧ, как в разделе 196. Это не только простота: после выхода из Stop система и так оказывается на HSI (главная грабля раздела 10.3) — а мы с него и не уходили, восстанавливать нечего. Вход в сон — без обработчиков прерываний: будильник и кнопка заведены событиями EXTI, ядро будится парой __WFE:

static void enter_stop(void)
{
    EXTI->RTSR |= (1u << 17) | (1u << 0);
    EXTI->EMR |= (1u << 17) | (1u << 0);               /* события: обработчики прерываний не нужны */
    PWR->CR = (PWR->CR & ~PWR_CR_PDDS) | PWR_CR_LPDS | PWR_CR_CWUF;
    SCB->SCR |= SCB_SCR_SLEEPDEEP_Msk;
    __SEV();
    __WFE();                                           /* снять взведённый флажок события */
    __WFE();                                           /* уснуть по-настоящему */
    SCB->SCR &= ~SCB_SCR_SLEEPDEEP_Msk;
    /* система снова на HSI 8 МГц — мы с него и не уходили */
}

Главный цикл — три вопроса после каждого пробуждения:

    for (;;) {
        if (RTC->CRL & RTC_CRL_ALRF) {                 /* будильник: минутное измерение */
            RTC->CRL &= (uint16_t)~RTC_CRL_ALRF;
            uint32_t now = rtc_now();
            adc_on();
            batt_minute(&b, now);
            adc_off();
            rtc_write(&RTC->ALRH, &RTC->ALRL, now + BATT_PERIOD_S);
        }
        if (GPIOA->IDR & 1u) {                         /* кнопка: журнал в консоль */
            batt_dump(&b);
            while (GPIOA->IDR & 1u) {
            }
        }
        enter_stop();                                  /* ложное пробуждение безвредно: уснём снова */
    }

Измерение — как в 196, с одним отличием: делитель питается от вывода PA2 и включён только на время замера, иначе его 150 мкА съедали бы батарейку в десять раз быстрее самого сна:

    GPIOA->BSRR = GPIO_BSRR_BS2;                       /* подать питание на делитель */
    delay_ms(1);                                       /* установление: τ = 50 мкс при C = 10 нФ */
    uint32_t sum = 0;
    for (int i = 0; i < 16; i++)
        sum += adc_once(1);
    GPIOA->BSRR = GPIO_BSRR_BR2;                       /* снять питание: делитель не ест в сне */

Конденсатор у входа АЦП уменьшен со 100 нФ (раздел 196) до 10 нФ: узел должен зарядиться за миллисекунду паузы, а шум добирается усреднением 16 выборок. Выходной ключ вывода по даташиту держит 0,4 В при 8 мА — меньше 50 Ом; на токе делителя это 7,5 мВ, доли процента шкалы.

Теперь арифметика вспышки. Типовая длительность — около 2,6 мс: выход из Stop 5,4 мкс, миллисекунда на делитель, 20 выборок АЦП по 63 мкс (1,26 мс), изредка запись в журнал. В бюджет закладываем 5 мс — двойной запас на будущий код. Заряд одной вспышки:

q = 4 мА · 5 мс + 0,8 мА · 1,26 мс ≈ 20 + 1 ≈ 21 мкКл

Профиль тока датчика: вспышки 4 мА раз в минуту над уровнем сна 15,4 мкА — заряд вспышки 21 мкКл, средний ток около 16 мкА

Средний ток при опросе раз в минуту — формула из раздела 10.8 плюс две строки, о которых забывают: стирание страницы журнала (5 мА до 40 мс — но раз в 128 записей, в среднем 0,003 мкА) и саморазряд самой батарейки (порядка процента в год — ещё 0,25 мкА):

I_ср = 15,4 + 21 / 60 + 0,25 ≈ 16,0 мкА  →  220 000 мкА·ч / 16,0 ≈ 13 700 ч ≈ 1,6 года

Год — с полуторным запасом. И главный вывод бюджета — период опроса почти не важен:

Опрос Средний ток Срок службы
раз в 10 с 17,8 мкА 1,41 года
раз в 60 с 16,0 мкА 1,57 года
раз в 10 мин 15,7 мкА 1,60 года

Вспышки дают лишь 0,35 мкА из 16 — два процента расхода. Это зеркальное отражение примера из раздела 10.8, где всплеск съедал 72 %: там прибор спал при 20 мкА, но просыпался каждую секунду. У нашего прибора правит ток сна — и потому охота в 198а.7 идёт именно на утечки.


198а.4 Журнал во Flash: кольцо с износом по кругу

Запись — 8 байт, без выравнивающих дыр, времена — счётчик секунд RTC (раздел 198.2):

struct batt_rec {                                      /* 8 байт во Flash, без выравнивающих дыр */
    uint32_t t;                                        /* время RTC, секунды; 0xFFFFFFFF — пусто */
    int16_t  t_c10;                                    /* температура, десятые доли °C, среднее за 10 минут */
    uint8_t  vbat;                                     /* (напряжение в мВ − 1000) / 10: 1,00…3,55 В */
    uint8_t  crc;                                      /* CRC-8, полином 0x07, по первым 7 байтам */
};

Поминутные измерения копятся в ОЗУ, в журнал раз в 10 минут уходит среднее. Под журнал отданы последние 8 страниц Flash (0x0800E000, в скрипте компоновщика у прошивки осталось 56 КБ) — 8 × 128 = 1024 записи, история на 6,2–7,1 суток: страница, в которую пишем, стёрта целиком, поэтому хвост истории «дышит». Кольцо — тот же журнал записей, что для настроек в разделе 11.8, только целыми страницами:

    uint32_t page = b->rec_idx / LOG_RECS_PAGE;
    uint32_t off = b->rec_idx % LOG_RECS_PAGE * LOG_REC_BYTES;
    if (off == 0u)
        plat_log_erase(page);                          /* вошли в страницу — стереть её целиком */
    plat_log_write(page, off, &r, sizeof r);
    b->rec_idx = (b->rec_idx + 1u) % LOG_RECS;

Стирание и запись полусловами — рецепт раздела 11.5; куда класть калибровочные коэффициенты, если появятся, — раздел 202.5. После сброса хвост кольца ищется заново: самая свежая корректная запись — по наибольшему времени, а если следующая ячейка занята мусором оборванной записи, хвост шагает дальше: во Flash нельзя писать поверх не стёртого.

Износ по кругу — арифметика на салфетке, проверенная прогоном: за год 52 560 записей — 411 стираний на все 8 страниц, на каждую — не больше 52. Ресурс Flash у F103 — 10 000 циклов: журнал изнашивает её на полпроцента в год, хватит примерно на 190 лет. Хранение данных даташит обещает 30 лет — переживёт и батарейку, и прибор.


198а.5 Чтение журнала: кнопка и консоль

Приёмник UART в Stop глух: тактов нет, старт-бит никого не разбудит (у поздних серий это чинит LPUART, у F103 его нет). Поэтому журнал забирается кнопкой: PA0 к плюсу питания, внутренняя подтяжка вниз (30…50 кОм по даташиту), фронт — событие EXTI 0. Ток через подтяжку течёт только пока кнопка нажата.

Прибор не знает календаря — в журнале голые секунды его собственного счётчика:

t=31950940 T=24.8 U=2.60

Привязку к настоящему времени делает программа на компьютере в момент дампа: реальное время записи = сейчас − (t текущее − t записи). Полное кольцо, 1024 строки, уходит за 2,3 с — это 9,2 мКл, по цене десяти минут сна: жать кнопку хоть каждый день.

🔧 Напряжение батарейки — без делителя. Внешний делитель на измерение питания сам стал бы утечкой. Вместо него АЦП меряет внутреннюю опору VREFINT ≈ 1,20 В относительно питания: U = 1200 · 4096 / код, мВ. Разброс опоры 1,16…1,24 В даёт погрешность до ±0,1 В у 3 В — для вопроса «пора ли менять батарейку» хватает с запасом. В записи журнала напряжение занимает один байт: шкала 1,00…3,55 В шагом 10 мВ.


198а.6 Прогон на компьютере и сборка

QEMU режим Stop и токи не моделирует — на нём этот прибор не проверишь. Поэтому раздел держится на двух опорах: числа — из даташита пакета, логика — та же пара «логика + платформа», что в 196–198: batt.c не знает про регистры и собирается на компьютере с моделью Flash (стирание в 0xFF, запись только поверх стёртого, счётчик стираний на страницу) и датчика (суточная синусоида с шумом, батарейка садится с 3,05 до 2,60 В за год).

Что Результат
пробуждений за год 525 600, записей в журнал — 52 560
стираний страниц 411, по страницам 51…52 — износ ровный
кольцо в конце года 976 записей, 585 000 с истории (6,8 суток), разрывов времени — 0
хвост после сброса найден там же, где был
обрыв питания посреди записи половинная запись отброшена по CRC, хвост шагнул за неё, дамп цел
CRC-8 от 123456789 0xF4 — контрольное значение реализации
дамп после года 976 строк, первая t=31950940 T=24.8 U=2.60

Прошивка собрана arm-none-eabi-gcc -Os для Cortex-M3 без предупреждений:

Что Байт
вся прошивка (Flash) 1852
ОЗУ (переменные) 16
из них логика batt.c — код 632

16 байт ОЗУ — это один struct batt: журнал живёт во Flash, буферы — на стеке. Упаковка записи проверена на компьютере честно: и x86-64, и Cortex-M — little-endian, полей с дырами в структуре нет, поэтому байты во «Flash» модели и во Flash платы совпадают.


198а.7 Что съедает батарейку незаметно

Каждая строка — из бюджета budget.py; методика поиска таких утечек мультиметром — раздел 10.8.

Что забыли Лишний ток Батарейки хватит на
подтяжка 40 кОм на выводе, прижатом к земле 75 мкА 101 день
делитель NTC запитан от шины, а не от PA2 150 мкА 55 дней
«раз в минуту мигну светодиодом» — а он остался гореть 1 мА 9 дней
отладчик: биты DBG_* остались после отладки ≈0,6 мА 15 дней
электролит 100 мкФ вместо керамики ≈3 мкА 482 дня

Пояснения к строкам:


198а.8 Что улучшить


198а.9 Грабли

Прибор живёт две недели вместо полутора лет. После отладки остались биты DBG_* в DBGMCU->CR: камень в «Stop» продолжает тактоваться, ток — 0,6 мА вместо 15 мкА. Их не снимает даже кнопка сброса — только передёрнуть питание (раздел 10.8).

Ток сна 90 мкА, хотя в программе всё выключено. Одна забытая подтяжка на прижатом выводе — 75 мкА. Неиспользуемые выводы — в аналоговый режим, а замерить честно — по методике 10.8: мультиметр в разрыв питания камня, не всей платы.

Прибор не отвечает на команды по UART. В Stop приёмник глух: тактов нет, старт-бит — не источник пробуждения (раздел 10.6). Будить кнопкой, как здесь, или брать камень с LPUART, умеющим просыпаться от старт-бита.

Ближе к концу батарейки прибор перезагружается — раз в сутки, потом чаще. Это стирание страницы журнала: до 5 мА на десятки миллисекунд. На внутреннем сопротивлении севшей «таблетки» — сотни ом — такой ток роняет вольт: 5 мА × 200 Ом = 1,0 В, и питание ныряет под порог сброса. Конденсатор ёмче, стирание — только при бодром напряжении.

Температура стабильно занижена на градус-другой. Конденсатор у входа АЦП больше расчётного: узел делителя не успевает зарядиться за паузу после включения PA2. У 10 нФ постоянная времени 50 мкс и миллисекунды хватает; у 100 нФ из раздела 196 — уже нет. Либо ждать дольше, либо ёмкость меньше.

После смены батарейки журнал цел, а время — с нуля. Так и задумано: журнал — во Flash, а счётчик RTC жил от той же батарейки и обнулился вместе с ней. Привязка к календарю всё равно делается на компьютере в момент дампа — очередное чтение просто начнёт новую эпоху.


198а.10 Итоги

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

batt.h — интерфейс логики прибора и функций платформы.

/* Мини-проект «Датчик на батарейке»: логика прибора без железа.
 * Измерение, накопление и кольцевой журнал во Flash не знают про регистры:
 * всё железное — в функциях платформы (plat_*), которые на BluePill написаны
 * поверх регистров, а на компьютере — поверх модели Flash и термометра. */
#ifndef BATT_H
#define BATT_H

#include <stdint.h>

#define LOG_PAGES      8u                              /* журнал: 8 страниц по 1 КБ в конце Flash */
#define LOG_PAGE_BYTES 1024u
#define LOG_REC_BYTES  8u
#define LOG_RECS_PAGE  (LOG_PAGE_BYTES / LOG_REC_BYTES)   /* 128 записей на страницу */
#define LOG_RECS       (LOG_PAGES * LOG_RECS_PAGE)        /* 1024 записи в кольце */

#define BATT_PERIOD_S  60u                             /* будильник RTC: раз в минуту */
#define BATT_PER_REC   10u                             /* запись в журнал: раз в 10 измерений */

#define REC_EMPTY_T    0xFFFFFFFFu                     /* время в стёртой ячейке */

struct batt_rec {                                      /* 8 байт во Flash, без выравнивающих дыр */
    uint32_t t;                                        /* время RTC, секунды; 0xFFFFFFFF — пусто */
    int16_t  t_c10;                                    /* температура, десятые доли °C, среднее за 10 минут */
    uint8_t  vbat;                                     /* (напряжение в мВ − 1000) / 10: 1,00…3,55 В */
    uint8_t  crc;                                      /* CRC-8, полином 0x07, по первым 7 байтам */
};

struct batt {
    uint32_t rec_idx;                                  /* куда ляжет следующая запись, 0…LOG_RECS−1 */
    uint32_t n_acc;                                    /* сколько поминутных измерений накоплено */
    int32_t  sum_c10;                                  /* их сумма */
    uint16_t last_vdd_mv;                              /* последнее напряжение батарейки */
    int16_t  last_c10;                                 /* последняя температура */
};

/* платформа */
int16_t  plat_temp_c10(void);                          /* подать питание на делитель, измерить, снять */
uint16_t plat_vdd_mv(void);                            /* напряжение питания по внутренней опоре */
const uint8_t *plat_log_page(uint32_t page);           /* страница журнала для чтения */
void plat_log_erase(uint32_t page);
void plat_log_write(uint32_t page, uint32_t off, const void *src, uint32_t n);  /* n кратно 2 */
void plat_puts(const char *s);

/* логика */
uint8_t  batt_crc8(const void *p, uint32_t n);
void     batt_pack(struct batt_rec *r, uint32_t t, int16_t c10, uint16_t vdd_mv);
int      batt_rec_ok(const struct batt_rec *r);
void     batt_rec_str(const struct batt_rec *r, char *out);   /* «t=… T=… U=…», без printf */
void     batt_init(struct batt *b);                    /* найти хвост кольца после сброса */
void     batt_minute(struct batt *b, uint32_t now);    /* вызов по будильнику, раз в минуту */
uint32_t batt_dump(const struct batt *b);              /* журнал в консоль от старой записи к новой */

#endif

batt.c — логика: измерение, накопление, кольцевой журнал во Flash и дамп в консоль.

/* Мини-проект «Датчик на батарейке»: логика — измерение раз в минуту,
 * запись раз в десять минут, кольцевой журнал во Flash, дамп в консоль.
 * Ни одного регистра: собирается и для Cortex-M3, и для компьютера. */
#include "batt.h"

/* CRC-8, полином 0x07, начальное значение 0: от строки «123456789» даёт 0xF4 */
uint8_t batt_crc8(const void *p, uint32_t n)
{
    const uint8_t *b = (const uint8_t *)p;
    uint8_t c = 0;
    while (n--) {
        c ^= *b++;
        for (int i = 0; i < 8; i++)
            c = (uint8_t)((c & 0x80u) ? (unsigned)(c << 1) ^ 0x07u : (unsigned)(c << 1));
    }
    return c;
}

void batt_pack(struct batt_rec *r, uint32_t t, int16_t c10, uint16_t vdd_mv)
{
    r->t = t;
    r->t_c10 = c10;
    uint32_t mv = vdd_mv < 1000u ? 1000u : vdd_mv;     /* шкала 1,00…3,55 В шагом 10 мВ */
    uint32_t v = (mv - 1000u) / 10u;
    r->vbat = v > 255u ? 255u : (uint8_t)v;
    r->crc = batt_crc8(r, 7);
}

int batt_rec_ok(const struct batt_rec *r)
{
    return r->t != REC_EMPTY_T && batt_crc8(r, 7) == r->crc;
}

/* ── печать без printf: прошивка собирается с -nostdlib ── */
static char *put_u32(char *s, uint32_t v)
{
    char tmp[10];
    int n = 0;
    do {
        tmp[n++] = (char)('0' + v % 10u);
        v /= 10u;
    } while (v);
    while (n)
        *s++ = tmp[--n];
    return s;
}

void batt_rec_str(const struct batt_rec *r, char *out)   /* out не меньше 40 байт */
{
    char *s = out;
    *s++ = 't'; *s++ = '=';
    s = put_u32(s, r->t);
    *s++ = ' '; *s++ = 'T'; *s++ = '=';
    int32_t c = r->t_c10;
    if (c < 0) {
        *s++ = '-';
        c = -c;
    }
    s = put_u32(s, (uint32_t)c / 10u);
    *s++ = '.';
    *s++ = (char)('0' + (uint32_t)c % 10u);
    *s++ = ' '; *s++ = 'U'; *s++ = '=';
    uint32_t mv = 1000u + (uint32_t)r->vbat * 10u;
    s = put_u32(s, mv / 1000u);
    *s++ = '.';
    *s++ = (char)('0' + mv / 100u % 10u);
    *s++ = (char)('0' + mv / 10u % 10u);
    *s++ = '\n';
    *s = 0;
}

/* ── кольцо ── */
static const struct batt_rec *rec_at(uint32_t idx)
{
    const uint8_t *pg = plat_log_page(idx / LOG_RECS_PAGE);
    return (const struct batt_rec *)(const void *)(pg + (idx % LOG_RECS_PAGE) * LOG_REC_BYTES);
}

static int rec_blank(const struct batt_rec *r)
{
    const uint8_t *b = (const uint8_t *)(const void *)r;
    for (unsigned i = 0; i < LOG_REC_BYTES; i++)
        if (b[i] != 0xFFu)
            return 0;
    return 1;
}

/* Хвост кольца: следующая ячейка после записи с наибольшим временем.
 * Ячейка занята мусором (оборванная запись) — шагнуть дальше: во Flash
 * нельзя писать поверх не стёртого, нуль обратно в единицу не превратить. */
void batt_init(struct batt *b)
{
    uint32_t best = REC_EMPTY_T, tail = 0;
    int found = 0;
    for (uint32_t i = 0; i < LOG_RECS; i++) {
        const struct batt_rec *r = rec_at(i);
        if (batt_rec_ok(r) && (!found || r->t > best)) {
            best = r->t;
            tail = (i + 1u) % LOG_RECS;
            found = 1;
        }
    }
    if (found)
        while (tail % LOG_RECS_PAGE != 0u && !rec_blank(rec_at(tail)))
            tail = (tail + 1u) % LOG_RECS;             /* начало страницы решит стирание */
    b->rec_idx = found ? tail : 0;
    b->n_acc = 0;
    b->sum_c10 = 0;
    b->last_vdd_mv = 0;
    b->last_c10 = 0;
}

void batt_minute(struct batt *b, uint32_t now)
{
    b->last_c10 = plat_temp_c10();
    b->last_vdd_mv = plat_vdd_mv();
    b->sum_c10 += b->last_c10;
    b->n_acc++;
    if (b->n_acc < BATT_PER_REC)
        return;
    struct batt_rec r;
    batt_pack(&r, now, (int16_t)(b->sum_c10 / (int32_t)b->n_acc), b->last_vdd_mv);
    b->sum_c10 = 0;
    b->n_acc = 0;
    uint32_t page = b->rec_idx / LOG_RECS_PAGE;
    uint32_t off = b->rec_idx % LOG_RECS_PAGE * LOG_REC_BYTES;
    if (off == 0u)
        plat_log_erase(page);                          /* вошли в страницу — стереть её целиком */
    plat_log_write(page, off, &r, sizeof r);
    b->rec_idx = (b->rec_idx + 1u) % LOG_RECS;
}

uint32_t batt_dump(const struct batt *b)
{
    char line[40];
    uint32_t n = 0;
    for (uint32_t i = 0; i < LOG_RECS; i++) {
        const struct batt_rec *r = rec_at((b->rec_idx + i) % LOG_RECS);
        if (!batt_rec_ok(r))
            continue;                                  /* пустые и оборванные — молча мимо */
        batt_rec_str(r, line);
        plat_puts(line);
        n++;
    }
    return n;
}

plat_f103.c — платформа BluePill на регистрах CMSIS.

/* Мини-проект «Датчик на батарейке»: платформа BluePill (STM32F103C8), регистры через CMSIS.
 *   Питание — CR2032 прямо на шину 3,3 В платы, мимо штатного стабилизатора.
 *   PA0  — кнопка «прочитать журнал»: к 3,3 В, внутренняя подтяжка вниз, событие EXTI0
 *   PA1  — АЦП1, канал 1: средняя точка делителя NTC 10 кОм / 10 кОм (таблица — раздел 196)
 *   PA2  — питание делителя: высокий уровень только на время измерения
 *   PA9/PA10 — USART1, 115 200 бод: журнал по кнопке
 *   PC14/PC15 — кварц 32 768 Гц, RTC и будильник (раздел 198)
 *   Flash 0x0800E000…0x0800FFFF — кольцевой журнал, 8 страниц по 1 КБ
 * Такт — HSI 8 МГц: после выхода из Stop система и так на HSI, восстанавливать нечего. */
#include "stm32f10x.h"
#include "batt.h"

#define LOG_BASE 0x0800E000u
#define MAGIC 0xB007u                                  /* в BKP_DR1: RTC уже настроен */

/* ── задержка: SysTick без прерываний ── */
static void delay_ms(uint32_t n)
{
    SysTick->LOAD = 8000u - 1u;                        /* 8 МГц → 1 мс */
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_ENABLE_Msk;
    while (n--)
        while (!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk)) {
        }
    SysTick->CTRL = 0;
}

/* ── АЦП: включается на одно пробуждение ── */
static void adc_on(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_ADC1EN;
    ADC1->SMPR2 = 7u << 3;                             /* канал 1: 239,5 такта выборки */
    ADC1->SMPR1 = 7u << 21;                            /* канал 17 (VREFINT): тоже 239,5 — нужно ≥ 17,1 мкс */
    ADC1->CR2 = ADC_CR2_ADON | ADC_CR2_TSVREFE;        /* включить АЦП и внутреннюю опору */
    for (volatile int d = 0; d < 100; d++) {
    }
    ADC1->CR2 |= ADC_CR2_CAL;                          /* калибровка после каждого включения */
    while (ADC1->CR2 & ADC_CR2_CAL) {
    }
}

static void adc_off(void)
{
    ADC1->CR2 = 0;                                     /* ADON = 0: аналоговая часть обесточена */
    RCC->APB2ENR &= ~RCC_APB2ENR_ADC1EN;
}

static uint16_t adc_once(uint32_t ch)
{
    ADC1->SQR3 = ch;
    ADC1->CR2 |= ADC_CR2_ADON;                         /* повторная запись ADON запускает преобразование */
    while (!(ADC1->SR & ADC_SR_EOC)) {
    }
    return (uint16_t)ADC1->DR;
}

/* таблица NTC 10 кОм (B = 3950) — посчитана в разделе 196 */
static const int16_t T_C[17] = {-200, -150, -100, -50, 0, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600};
static const uint16_t CODE[17] = {355, 466, 600, 758, 939, 1140, 1357, 1585, 1817, 2048, 2270, 2481, 2676, 2854, 3014, 3155, 3280};

int16_t plat_temp_c10(void)
{
    GPIOA->BSRR = GPIO_BSRR_BS2;                       /* подать питание на делитель */
    delay_ms(1);                                       /* установление: τ = 50 мкс при C = 10 нФ */
    uint32_t sum = 0;
    for (int i = 0; i < 16; i++)
        sum += adc_once(1);
    GPIOA->BSRR = GPIO_BSRR_BR2;                       /* снять питание: делитель не ест в сне */
    uint32_t c = sum / 16;
    if (c < CODE[0])
        return T_C[0];                                 /* упор в край шкалы — виден в журнале */
    if (c > CODE[16])
        return T_C[16];
    int i = 0;
    while (c > CODE[i + 1])
        i++;
    return (int16_t)(T_C[i] + (int32_t)(T_C[i + 1] - T_C[i]) * (int32_t)(c - CODE[i]) / (CODE[i + 1] - CODE[i]));
}

uint16_t plat_vdd_mv(void)
{
    uint32_t sum = 0;
    for (int i = 0; i < 4; i++)
        sum += adc_once(17);                           /* VREFINT ≈ 1,20 В относительно VDDA */
    uint32_t code = sum / 4;
    return (uint16_t)(1200u * 4096u / (code ? code : 1u));
}

/* ── журнал во Flash: стирание страницы и запись полусловами (раздел 11.5) ── */
static void flash_unlock(void)
{
    FLASH->KEYR = 0x45670123u;
    FLASH->KEYR = 0xCDEF89ABu;
}

const uint8_t *plat_log_page(uint32_t page)
{
    return (const uint8_t *)(LOG_BASE + page * LOG_PAGE_BYTES);
}

void plat_log_erase(uint32_t page)
{
    flash_unlock();
    FLASH->CR |= FLASH_CR_PER;
    FLASH->AR = LOG_BASE + page * LOG_PAGE_BYTES;
    FLASH->CR |= FLASH_CR_STRT;
    while (FLASH->SR & FLASH_SR_BSY) {
    }
    FLASH->CR &= ~FLASH_CR_PER;
    FLASH->CR |= FLASH_CR_LOCK;
}

void plat_log_write(uint32_t page, uint32_t off, const void *src, uint32_t n)
{
    flash_unlock();
    FLASH->CR |= FLASH_CR_PG;
    const uint16_t *s = (const uint16_t *)src;
    volatile uint16_t *dst = (volatile uint16_t *)(LOG_BASE + page * LOG_PAGE_BYTES + off);
    for (uint32_t i = 0; i < n / 2u; i++) {
        dst[i] = s[i];
        while (FLASH->SR & FLASH_SR_BSY) {
        }
    }
    FLASH->CR &= ~FLASH_CR_PG;
    FLASH->CR |= FLASH_CR_LOCK;
}

void plat_puts(const char *s)
{
    for (; *s; s++) {
        if (*s == '\n') {
            while (!(USART1->SR & USART_SR_TXE)) {
            }
            USART1->DR = '\r';
        }
        while (!(USART1->SR & USART_SR_TXE)) {
        }
        USART1->DR = (uint8_t)*s;
    }
    while (!(USART1->SR & USART_SR_TC)) {              /* дожать последний байт до сна */
    }
}

/* ── RTC: как в разделе 198 — запись через RTOFF и CNF ── */
static void rtc_wait(void)
{
    while (!(RTC->CRL & RTC_CRL_RTOFF)) {
    }
}

static uint32_t rtc_now(void)
{
    uint16_t hi, lo;
    do {
        hi = RTC->CNTH;
        lo = RTC->CNTL;
    } while (hi != RTC->CNTH);
    return (uint32_t)hi << 16 | lo;
}

static void rtc_write(volatile uint16_t *rh, volatile uint16_t *rl, uint32_t v)
{
    rtc_wait();
    RTC->CRL |= RTC_CRL_CNF;
    *rh = (uint16_t)(v >> 16);
    *rl = (uint16_t)v;
    RTC->CRL &= (uint16_t)~RTC_CRL_CNF;
    rtc_wait();
}

static void rtc_init(void)
{
    RCC->APB1ENR |= RCC_APB1ENR_PWREN | RCC_APB1ENR_BKPEN;
    PWR->CR |= PWR_CR_DBP;                             /* запись в домен резервного питания */
    if (BKP->DR1 != MAGIC) {
        RCC->BDCR |= RCC_BDCR_BDRST;
        RCC->BDCR &= ~RCC_BDCR_BDRST;
        RCC->BDCR |= RCC_BDCR_LSEON;
        while (!(RCC->BDCR & RCC_BDCR_LSERDY)) {
        }
        RCC->BDCR |= RCC_BDCR_RTCSEL_LSE | RCC_BDCR_RTCEN;
        rtc_write(&RTC->PRLH, &RTC->PRLL, 32767u);     /* 32 768 / (32 767 + 1) = 1 Гц */
        rtc_write(&RTC->CNTH, &RTC->CNTL, 0u);
        BKP->DR1 = MAGIC;
    } else {
        RTC->CRL &= (uint16_t)~RTC_CRL_RSF;
        while (!(RTC->CRL & RTC_CRL_RSF)) {
        }
    }
}

/* ── Stop: регулятор в экономичный режим, будят события EXTI 17 (будильник) и 0 (кнопка) ── */
static void enter_stop(void)
{
    EXTI->RTSR |= (1u << 17) | (1u << 0);
    EXTI->EMR |= (1u << 17) | (1u << 0);               /* события: обработчики прерываний не нужны */
    PWR->CR = (PWR->CR & ~PWR_CR_PDDS) | PWR_CR_LPDS | PWR_CR_CWUF;
    SCB->SCR |= SCB_SCR_SLEEPDEEP_Msk;
    __SEV();
    __WFE();                                           /* снять взведённый флажок события */
    __WFE();                                           /* уснуть по-настоящему */
    SCB->SCR &= ~SCB_SCR_SLEEPDEEP_Msk;
    /* система снова на HSI 8 МГц — мы с него и не уходили */
}

static void hw_init(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN | RCC_APB2ENR_IOPCEN | RCC_APB2ENR_USART1EN;
    /* PA0 — вход с подтяжкой вниз (1000), PA1 — аналог (0000), PA2 — выход 2 МГц (0010),
     * PA3…PA7 — аналог: висящий вход с триггером Шмитта ест ток (раздел 10.8) */
    GPIOA->CRL = 0x00000208u;
    GPIOA->ODR = 0;                                    /* подтяжка PA0 — вниз */
    /* PA9 — альтернативный выход 2 МГц (1010), PA10 — вход (0100); PA8, PA11, PA12, PA15 — аналог;
     * PA13/PA14 (SWD) — не трогать, иначе отладчик теряет камень */
    GPIOA->CRH = (GPIOA->CRH & ~(0xFu | 0xFF0u | 0xF000u | 0xF0000u | 0xF0000000u)) | 0x4A0u;
    GPIOB->CRL = 0;
    GPIOB->CRH = 0;
    GPIOC->CRL = 0;
    GPIOC->CRH &= 0xFF000000u;                         /* PC13 в аналог; PC14/PC15 — кварц, не трогать */
    USART1->BRR = 69;                                  /* 8 МГц / 69 = 115 942 бод */
    USART1->CR1 = USART_CR1_UE | USART_CR1_TE;         /* приёмник не нужен: в Stop он всё равно глух */
}

int main(void)
{
    static struct batt b;
    hw_init();
    rtc_init();
    batt_init(&b);
    rtc_write(&RTC->ALRH, &RTC->ALRL, rtc_now() + BATT_PERIOD_S);
    for (;;) {
        if (RTC->CRL & RTC_CRL_ALRF) {                 /* будильник: минутное измерение */
            RTC->CRL &= (uint16_t)~RTC_CRL_ALRF;
            uint32_t now = rtc_now();
            adc_on();
            batt_minute(&b, now);
            adc_off();
            rtc_write(&RTC->ALRH, &RTC->ALRL, now + BATT_PERIOD_S);
        }
        if (GPIOA->IDR & 1u) {                         /* кнопка: журнал в консоль */
            batt_dump(&b);
            while (GPIOA->IDR & 1u) {
            }
        }
        enter_stop();                                  /* ложное пробуждение безвредно: уснём снова */
    }
}

Дальше: раздел 198б «Мини-проект: USB-самописец и программа на ПК».

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