173в. Открытая BMS на STM32: разбор Libre Solar — измерение ячеек, балансировка, защита и SOC
Раздел 173б берёг одну ячейку: зарядная микросхема, защита на ячейке и счёт кулонов в прошивке. Батарея из нескольких ячеек подряд требует большего: следить за каждой ячейкой, разряжать самые полные, выключать ключи заряда и разряда по отдельности и рассказывать о себе по шине. Это и есть BMS — система управления батареей.
Здесь разобрана открытая BMS проекта Libre Solar: что делает микросхема измерения, что — STM32 под Zephyr, какие пороги стоят по умолчанию, как устроены выравнивание и оценка заряда. Логика проекта собрана на ПК вместе с его драйвером bq769x0 и прогнана по сценарию на модели батареи 4S LFP. Прогон нашёл в драйвере несколько ошибок; они разобраны в 173в.7 — на них хорошо видно, где в BMS тонкие места.
Опоры: исходники Libre Solar BMS — github.com/LibreSolar/bms-firmware (коммит
ee3f15a), лицензия Apache-2.0 — ниже только короткие
фрагменты с путями. Список плат и микросхем — boards/ и
docs/src/supported_hardware.rst. Сборка — gcc в образе stm-sys:1
(логика и драйвер без изменений плюс заглушки Zephyr и модель
микросхемы), размеры кода — arm-none-eabi-gcc в stm-arm:1; каталог
_verify/bms173v. Приложение целиком не собрано: Zephyr взят версии
v4.4.2, как и требует west.yml (ветка v4.4), но библиотеки ThingSet
(thingset-zephyr-sdk) в сборку не входили. Даташиты bq769x0, BQ76952 и
ISL94202 не использовались: всё о микросхемах — из драйверов проекта.
Лицензия: Apache-2.0.
Как это называется в документации
Оригинал Перевод BMS (battery management system) система управления батареей AFE (analog front end), BMS IC микросхема измерения и защиты батареи cell balancing, bleeding выравнивание ячеек, разряд самых полных через резистор OV / UV (cell over-/undervoltage) перенапряжение / переразряд ячейки OCD / SCD (overcurrent / short circuit in discharge) перегрузка / короткое замыкание при разряде CHG / DSG FET ключ заряда / ключ разряда reset threshold, hysteresis порог возврата, гистерезис state of charge (SOC), OCV table заряд в процентах, таблица напряжения холостого хода coulomb counter (CC) счётчик кулонов, интегратор тока ThingSet открытый протокол данных устройства: чтение, запись, публикация
173в.1 Проект: платы, микросхемы и кто что делает
Libre Solar BMS — прошивка на Zephyr для пяти плат. В каждой батарею меряет и защищает отдельная микросхема, а микроконтроллер читает её по I²C, решает, какие ключи включить, считает заряд и отвечает по ThingSet.
Плата (boards/libresolar/) |
МК | Микросхема | Шунт, мкОм | Ток платы, А |
|---|---|---|---|---|
bms_5s50_sc |
STM32F072 | bq76920 | 1000 | 50 |
bms_15s80_sc |
STM32F072 | bq76930/40 | 1500 | 80 |
bms_16s100_sc |
STM32G0B1 | BQ76952 | 300 | 100 |
bms_8s50_ic |
STM32L452 | ISL94202 | 1000 (v0.1), 500 (v0.2) | 50 |
bms_c1 |
ESP32-C3 | BQ76952 | 300 | 100 |
Микросхемы по supported_hardware.rst: bq76920 — 3–5 ячеек, bq76930 —
6–10, bq76940 — 9–15, BQ76952 — 3–16, ISL94202 — 3–8. Для каждой в
drivers/bms_ic/ свой драйвер за общим интерфейсом bms_ic.h:
configure (записать пороги), read_data (прочитать ячейки, ток,
температуры, флаги), set_switches (ключи), balance, set_mode.
Прикладной код (app/src/) почти не знает, какая микросхема стоит —
исключение в 173в.4.
Разделение работы — как в 173б, только крупнее. Быстрая защита (порог
ячейки, перегрузка, короткое замыкание) — в микросхеме: она выключает
ключ сама, даже если программа зависла. Медленное — в программе:
температура, ток заряда у bq769x0, выравнивание, заряд в процентах и
решение, когда включить ключи обратно. Основной цикл main.c — каждые
CONFIG_BMS_IC_POLLING_INTERVAL_MS = 500 мс: прочитать всё, обновить
заряд, шаг автомата.
173в.2 Измерение: ячейки, ток, температура
Разберём на самом простом драйвере — bq769x0 (плата bms_5s50_sc).
Ячейки. АЦП микросхемы 14-битный: драйвер берёт adc_raw &= 0x3FFF.
Усиление и смещение у каждой микросхемы свои, записаны на заводе; драйвер
читает их при пуске из ADCGAIN1, ADCGAIN2, ADCOFFSET и пересчитывает
код в вольты: код × усиление (мкВ) + смещение (мВ). Каналы ниже 0,5 В
драйвер не считает подключёнными: в среднее и минимум они не входят. Так
в модели 4 ячейки стоят на пятиканальной микросхеме — пятый канал
читается как 0 В.
Ток — интегратор на шунте, знак «+» — заряд (bms_ic.h). Пересчёт в
драйвере:
int32_t current_mA = (int16_t)adc_raw * 8.44F / (dev_config->shunt_resistor_uohm / 1000.0F);
При шунте 1 мОм одна единица — 8,44 мА; всё, что меньше 10 мА по модулю, драйвер обнуляет.
Температура — термистор 10 кОм на выводе TS1, пересчёт по
бета-формуле, бета по умолчанию 3435 (привязка ti,bq769x0.yaml). В
прогоне термистор модели при 50 °C читается как 50,04 °C, при −3 °C — как
−2,98 °C.
Обмен с микросхемой. У bq769x0 бывают варианты с CRC на I²C. Драйвер пробует писать и читать с CRC, если не вышло — без. В прогоне модель ответила с CRC: 2917 записей, ни одной ошибки CRC.
Модель в прогоне: 4 ячейки LFP по 50 А·ч, внутреннее сопротивление 2 мОм, напряжение холостого хода — по таблице проекта (173в.6), за её краями — 0,2 В на процент; усиление АЦП 377 мкВ, смещение 0 мВ. Это допущения, а не данные микросхемы.
173в.3 Защита: пороги по умолчанию и что с ними делает драйвер
Пороги задаёт bms_init_config() в app/src/bms_common.c по типу ячеек
(Kconfig: LFP по умолчанию, ещё NMC и LTO) и ёмкости
(CONFIG_BAT_CAPACITY_AH, по умолчанию 50 А·ч):
| Порог ячейки, В | LFP | NMC | LTO |
|---|---|---|---|
перенапряжение cell_ov_limit |
3,80 | 4,25 | 2,85 |
заряд до cell_chg_voltage_limit |
3,55 | 4,20 | 2,80 |
возврат после OV cell_ov_reset |
3,40 | 4,05 | 2,70 |
выравнивать выше bal_cell_voltage_min |
3,30 | 3,80 | 2,50 |
возврат после UV cell_uv_reset |
3,10 | 3,50 | 2,10 |
разряд до cell_dis_voltage_limit |
2,80 | 3,20 | 2,00 |
переразряд cell_uv_limit |
2,50 | 3,00 | 1,90 |
Общие для всех химий: задержки OV и UV — 2000 мс; ток разряда и заряда — 1C, то есть 50 А при 50 А·ч, задержка 320 мс; короткое замыкание — 2C, 100 А, 200 мкс; температура разряда от −20 до 45 °C, заряда — от 0 до 45 °C, гистерезис 5 °C. Все они меняются по ThingSet.
Микросхема умеет не любые числа, и драйвер округляет: порог OV —
код OV_TRIP, задержки и токи — по таблицам в bq769x0_registers.h
(задержки OV 1, 2, 4, 8 с; UV 1, 4, 8, 16 с; порог короткого 44…200 мВ
на шунте). В прогоне с усилением 377 мкВ:
| Что | Задано | В микросхеме |
|---|---|---|
| OV | 3,80 В | OV_TRIP = 0x75 → 3794 мВ |
| UV | 2,50 В | UV_TRIP = 0x9F → 2503 мВ |
| перегрузка разряда | 50 А, 320 мс | 50 мВ на 1 мОм → 50 А, 320 мс |
| короткое | 100 А, 200 мкс | 89 мВ → 89 А, 200 мкс |
| задержка OV | 2000 мс | 8 с |
| задержка UV | 2000 мс | 16 с |
Две последние строки — ошибка единиц (173в.7): таблицы задержек в секундах, а сравнивает драйвер с миллисекундами.
Что остаётся программе. У bq769x0 в железе только OV, UV, перегрузка и короткое при разряде. Ток заряда и все четыре температурных порога драйвер проверяет сам при каждом чтении флагов, температуру — с гистерезисом: флаг, раз поднявшись, снимается только на 5 °C дальше порога. Прогон:
| Температура, °C | Флаги | Автомат |
|---|---|---|
| 50 | 0x0280: перегрев заряда и разряда | NORMAL → CHG → OFF |
| 42 | 0x0280 — гистерезис держит | OFF |
| 39 | 0 | OFF → DIS → NORMAL |
| −3 | 0x0100: холодно для заряда | NORMAL → DIS |
| 3 | 0x0100 — гистерезис держит | DIS |
| 6 | 0 | DIS → NORMAL |
173в.4 Автомат ключей
bms_state_machine() — четыре рабочих состояния: OFF (оба ключа
выключены), CHG (только заряд), DIS (только разряд), NORMAL (оба). Шаг
решает по двум вопросам: можно ли заряжать и можно ли разряжать.
return !bms_chg_error(bms->ic_data.error_flags & ~BMS_ERR_CHG_OFF) && !bms->full
&& bms->chg_enable;
bms_chg_error() смотрит OV, ток заряда, температуры заряда, обрыв
провода ячейки, перегрев микросхемы и отказ ячейки; bms_dis_error() —
UV, короткое, ток разряда, температуры разряда и те же общие.
chg_enable и dis_enable — ручные выключатели, по ThingSet это
wChgEnable и wDisEnable. Флаги full и empty в приложении нигде не
выставляются — только в тестах: окончание заряда на 3,55 В проект
оставляет зарядному устройству.
Ещё одна деталь для bq769x0 и ISL94202: в состоянии CHG ключ разряда включается, если через батарею течёт разрядный ток больше 0,5 А, и выключается ниже 0,1 А (в DIS так же с ключом заряда). Это «идеальный диод»: ток идёт через открытый канал, а не через встроенный диод транзистора.
Прогон — заряд 25 А зарядником, который не остановился на 3,55 В, затем разряд (исправленный драйвер, 173в.7):
| Событие | Время, с |
|---|---|
| пуск: OFF → DIS, затем DIS → NORMAL | 0,5 и 1,0 |
| самая полная ячейка выше 3794 мВ | 496,73 |
| микросхема: OV, ключ заряда выключен (через 7,99 с) | 504,72 |
| автомат: NORMAL → DIS | 505,0 |
| нагрузка 10 А: ячейка ниже 3,40 В | 828,61 |
| OV снят, DIS → NORMAL | 829,00 |
| разряд 40 А: самая слабая ячейка ниже 2503 мВ | 5077,96 |
| микросхема: UV (через 15,99 с), NORMAL → CHG | 5093,95 и 5094,0 |
| заряд 10 А: самая слабая выше 3,10 В, CHG → NORMAL | 6188,00 |
Из CHG батарея заряжается, но не разряжается — ровно то, что нужно после переразряда.
173в.5 Выравнивание
У bq769x0 выравнивание ведёт драйвер: работа balancing_work раз в
секунду. Условия из bq769x0.c: батарея в покое дольше
bal_idle_delay (ток меньше bal_idle_current = 0,1 А), самая полная
ячейка выше bal_cell_voltage_min (3,30 В у LFP), разброс больше
bal_cell_voltage_diff = 10 мВ. Тогда кандидаты — все ячейки выше
минимальной больше чем на 10 мВ, по убыванию напряжения; соседние
ячейки в одной пятёрке одновременно разряжать нельзя, и из пары
остаётся более полная. Номер ячейки — номер бита в CELLBAL1.
Прогон после заряда (покой):
| Ячейка | Напряжение, В | Выравнивается |
|---|---|---|
| 1 | 3,3432 | нет — соседка ячейки 2 |
| 2 | 3,3745 | да |
| 3 | 3,3138 | нет — самая низкая |
| 4 | 3,7662 | да |
CELLBAL1 = 0x0A — ячейки 2 и 4. Выравнивание началось через 2,29 с
после того, как ток прекратился, хотя по умолчанию задано 1800 с (30
минут) — это та же ошибка единиц, что и с задержками OV и UV.
У BQ76952 и ISL94202 выравнивает сама микросхема: драйверы переводят те же пороги в её регистры. Ток выравнивания в модели не учтён: при резисторах в десятки миллиампер и ячейках в 50 А·ч разница уходит часами, а в прогоне важно, какие ячейки и когда.
173в.6 Заряд в процентах и связь
Таблица. Напряжение холостого хода в bms_common.c — 21 точка через
5 %. У LFP (из прогона):
| % | 100 | 95 | 90 | 80 | 70 | 60 | 50 | 40 | 30 | 20 | 10 | 5 | 0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| В | 3,392 | 3,314 | 3,309 | 3,304 | 3,283 | 3,271 | 3,265 | 3,262 | 3,240 | 3,213 | 3,177 | 3,132 | 2,833 |
У NMC те же 21 точка от 4,198 до 2,800 В, у LTO — от 2,800 до 2,000 В
(источник таблицы LTO указан в коде: DOI 10.3390/batteries5010031).
Таблицу и ёмкость можно заменить по ThingSet: sOcvPoints_V,
sSocPoints_pct, sNominalCapacity_Ah.
Как проект считает заряд. При пуске — один раз по таблице:
bms_soc_reset(&bms, -1) берёт среднее напряжение ячеек. В прогоне
3,3105 В дали 91,53 % при истинных 91,25 %. Дальше — только счёт
кулонов, bms_soc_update() после каждого чтения тока: заряд копится в
мА·с и переносится в проценты, когда набегает больше 0,1 % — при 50 А·ч
это 180000 мА·с. Больше таблица не используется: нет поправки ни по
покою, ни по полному заряду (full не выставляется). Документация
проекта так и пишет: оценка заряда основана на счёте кулонов.
Почему по напряжению у LFP нельзя: таблица почти плоская.
| Напряжение, В | 3,260 | 3,270 | 3,280 | 3,290 | 3,300 |
|---|---|---|---|---|---|
| Заряд по таблице, % | 39,00 | 58,33 | 68,12 | 72,69 | 77,50 |
10 мВ между 3,26 и 3,27 В — это 19,33 % заряда. Для сравнения: у ячейки из 173б 10 мВ давали 2 %. В прогоне модель без ошибки датчика тока, и счёт держался: к переразряду 2,39 % против истинных 2,00 %. Смещение датчика и старение копятся так, как в разделе 121.
Связь. Все данные — объекты ThingSet в app/src/data_objects.c,
в четырёх группах: Device (производитель, версии, xShutdown,
xReset), Conf (все пороги из 173в.3, таблица, ёмкость, предустановки
xPresetLFP, xPresetNMC, xPresetLTO), Meas (rCellVoltages_V,
rPackCurrent_A, rSOC_pct, rErrorFlags, rBmsState,
rBalancingStatus и др.), Input (wChgEnable, wDisEnable). По
флагам в коде видна роль первой буквы: r — только чтение, s —
чтение и запись с сохранением в энергонезависимую память, w — запись
без сохранения, x — вызов функции. Транспорт: последовательный порт
(по документации 115200 бод, живые данные раз в секунду); CAN и ISO-TP
включены в .conf плат bms_8s50_ic, bms_16s100_sc и bms_c1
(500 кбит/с в devicetree; по документации адрес источника — младший
байт идентификатора кадра); Bluetooth — на bms_c1. На bms_5s50_sc CAN выключен: по .conf платы F072 не
хватает ОЗУ. После записи в Conf вызывается bms_ic_configure() —
пороги уходят в микросхему сразу. Формат кадров ThingSet — в библиотеке
thingset-node-c, здесь он не разобран. Про CAN как
шину для прикладных протоколов — раздел 19б.
Размер логики на Cortex-M0 (F072) и M0+ (G0B1), arm-none-eabi-gcc -Os,
без Zephyr и ThingSet: bms_common.c — 1536 байт кода, bms_soc.c —
272, helper.c — 252, драйвер bq769x0 — 4562; всего 6622 байта. В ОЗУ
логики — 168 байт: две рабочие таблицы по 21 числу float.
173в.7 Грабли
Первые восемь — в драйвере bq769x0 этой версии, прогон их показывает. Исправлять или нет — дело проекта; здесь они как типовые места, где BMS ошибается.
После перенапряжения батарея больше не заряжается. Обработчик
ошибок снимает OV, когда самая полная ячейка ниже
dev_data->ic_conf.cell_ov_reset, а UV — когда самая слабая выше
cell_uv_reset. Но в копию настроек драйвера эти два порога не
копируются ни в одной функции — там нули. OV не снимается никогда: в
прогоне ячейка опустилась ниже 3,40 В на 828,61 с, а на 1417,01 с флаг
всё ещё стоял. UV снимается сразу: под той же нагрузкой 40 А за 60 с
микросхема выключала ключ разряда 4 раза, ключ был открыт 31,70 с, и
ячейка ушла до 2,3710 В в покое. С двумя строками исправления (кэш
получает оба порога, fix173v.py) — одно срабатывание, 0,00 с, 2,5119 В.
Задержки в 4 раза и 8 раз длиннее. Таблицы задержек OV и UV в
секундах, а cell_ov_delay_ms = 2000 сравнивается с ними как есть:
выбирается наибольшая — 8 с и 16 с — и обратно в поле _ms пишется 8 и
16. В прогоне OV сработал через 7,99 с, UV — через 15,99 с.
Выравнивание через 2 с вместо 30 минут.
if (k_uptime_get() - dev_data->active_timestamp >= dev_data->ic_conf.bal_idle_delay
— слева миллисекунды, справа 1800 секунд. Выравнивание начинает
разряжать ячейки почти сразу после окончания тока, пока напряжение ещё
не успокоилось.
Пороги в вольтах превращаются в милливольты. После записи в
микросхему драйвер возвращает «фактический» порог:
ic_conf->cell_ov_limit = 3794, а не 3,794. По ThingSet
sCellOvervoltage_V покажет 3794. Хуже другое: любая запись в Conf
снова вызывает bms_ic_configure(), и из «3794 В» получается
OV_TRIP = 0xF2 — порог 4548 мВ для ячейки LFP, UV — 2955 мВ. Это на ПК
(long 64 бита); на Cortex-M long 32 бита, произведение переполняется, и
число будет другим — не проверено.
Смещение АЦП со знаком читается без знака.
dev_data->adc_offset = (signed int)adcoffset; — приведение uint8_t к
int знак не восстанавливает. Код в самом драйвере назван «2's
complement»; при ADCOFFSET = 0xFE (−2 мВ) драйвер берёт 254 мВ, и
ячейка 3,2646 В читается как 3,5207 В. Проверять на плате с
отрицательным смещением.
После перегрузки автомат в NORMAL, а ключ разряда выключен. Первый
проход обработчика снимает флаг короткого или перегрузки сразу (счётчик
секунд 0, а 0 делится на 60). В прогоне короткое 150 А на 20 мс
сработало и было снято в тот же шаг 10 мс, основной цикл флага не
увидел, автомат остался в NORMAL и ключ не включал; нагрузка 10 А после
этого — ток 0,00 А. Помогло только выключить и включить wDisEnable.
Так же с перегрузкой 60 А: срабатывание через 0,32 с, и снова ключ
выключен при NORMAL. Здесь есть допущение модели: микросхема при
UV, перегрузке и коротком сама выключает ключ разряда, при OV — ключ
заряда (по даташиту не сверено). У ISL94202 драйвер для такого случая
выставляет флаги BMS_ERR_DIS_OFF и BMS_ERR_CHG_OFF, у bq769x0 — нет.
Защита по току заряда дребезжит. Она в программе и без задержки
(/* ToDo: consider ic_conf->chg_oc_delay */): ток 55 А → флаг → ключ
заряда выключен → ток 0 → флаг снят → ключ включён. В прогоне — 20 смен
состояния за 10 с, каждые 0,5 с.
Состояние выравнивания показывает ноль. Работа выравнивания пишет
маску в ic_data->balancing_status, а каждое чтение данных затирает её
значением, которое меняет только ручное bms_ic_balance(). В прогоне из
1000 шагов, когда CELLBAL1 = 0x0A, rBalancingStatus показывал 0 в
510.
Заряд в процентах никто не поправляет. Таблица используется только при пуске. Если прибор перезапустился под нагрузкой — ошибка стартовой точки останется до следующего пуска: на плоской части LFP 10 мВ стоят почти 20 %.
173в.8 Итоги
- Libre Solar BMS — Zephyr, Apache-2.0, пять плат: STM32F072, STM32G0B1, STM32L452, ESP32-C3; микросхемы bq76920/30/40, BQ76952, ISL94202. Микросхема меряет и быстро защищает, МК раз в 500 мс читает, считает заряд и ведёт автомат OFF/CHG/DIS/NORMAL.
- Пороги LFP по умолчанию: OV 3,80, возврат 3,40, заряд 3,55, разряд 2,80, возврат 3,10, UV 2,50 В; ток 1C (50 А) с 320 мс, короткое 2C с 200 мкс; температура разряда −20…45, заряда 0…45 °C, гистерезис 5 °C.
- В микросхеме пороги округляются: OV 3794 мВ, UV 2503 мВ, короткое 100 → 89 А; задержки OV и UV из-за единиц — 8 и 16 с вместо 2 с.
- Выравнивание: разброс больше 10 мВ, самая полная выше 3,30 В, покой;
соседние ячейки не вместе — в прогоне
CELLBAL1= 0x0A, но начало через 2,29 с вместо 1800 с. - Заряд: таблица из 21 точки только при пуске (91,53 % против 91,25 %), дальше счёт кулонов с шагом 0,1 %; у LFP 10 мВ — 19,33 %.
- Прогон нашёл в драйвере bq769x0: OV не снимается, UV снимается сразу (4 срабатывания и 31,70 с разряда за 60 с против одного); пороги становятся милливольтами (повторная настройка — OV 4548 мВ); смещение 0xFE читается как 254 мВ (+256 мВ к ячейке); после короткого ключ выключен при NORMAL; ток заряда без задержки — 20 смен за 10 с.
- Логика — 6622 байта кода на Cortex-M0 без Zephyr и ThingSet.
Дальше: раздел 174 «Кварцы, генераторы и тактовые цепи».