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

100г. Лазерный дальномер VL53L1X: до 4 м, бюджет времени и межизмерительный период, режимы дальности, зона интереса (ROI), статус измерения и несколько датчиков на одной шине

В разделе 100б VL53L0X мерил миллиметры временем пролёта: как свет летит пикосекунды, что такое SPAD, зачем XSHUT и GPIO1, как включить три датчика на одной шине. В 100в APDS-9960 ловил взмах руки по свету без миллиметров. VL53L1X — следующий датчик ST того же рода: тот же адрес 0x29, те же XSHUT и GPIO1, но матрица SPAD побольше, дальность до метров, и у матрицы можно выбрать часть — зону интереса. Принцип времени пролёта здесь не повторяется: только то, чем L1X отличается.

Здесь: что изменилось против L0X; 16-битные адреса регистров, слово 0xEACC и блок настроек из 91 байта — откуда он и что в нём; режимы Short, Medium и Long, бюджет времени и период по таймеру, их пересчёт в регистры; шум и дальность в модели; статус измерения и обёртка фазы; зона интереса и грубая «картинка» из 16 зон; прерывание по окну дальности; стекло перед датчиком и две калибровки; три датчика на одной шине и сон.

Опоры: API ST для VL53L1X 2.4.5 (STSW-IMG007) — его копия лежит в модуле hal_st проекта Zephyr (github.com/zephyrproject-rtos/hal_st, коммит 7a79288, BSD-3-Clause): карта регистров, форматы, пресеты режимов, формулы времени, правила статусов, калибровки. Драйвер vl53l1x в Zephyr 4.4.2 (github.com/zephyrproject-rtos/zephyr, коммит dccb095, Apache-2.0) и его привязка — время загрузки, полярность GPIO1, таблица дальностей по режимам, нумерация ROI. Открытая библиотека vl53l1x в наборе скриптов OpenMV (github.com/openmv/openmv, MIT) — блок настроек по умолчанию с комментариями и слово 0xEACC. Документа производителя (datasheet) и драйвера ULD в проекте нет: поле зрения 27°, пределы бюджета 15…500 мс, таблица дальностей, питание и токи — по документации производителя, не проверено. Модель датчика на Python — своя, числа шума и дальности говорят о модели, а не о датчике. Стенд — наш драйвер tof100e.c по регистрам, собранный gcc и прогнанный против модели; формулы времени и номера SPAD сверены с функциями API, взятыми дословно; тот же код и прошивка fw100e.c собраны armclang под STM32F103 и на плате не запускались. RM0008 (DocID13902 Rev 17): EXTI1…EXTI3 — позиции 7…9 в таблице векторов 63, с. 203. DocID13587 Rev 17: PB6 — I2C1_SCL, PB7 — I2C1_SDA, оба FT, с. 32. I²C — раздел 17, прерывания от вывода — 7.5, подключение модулей — 95.

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

Оригинал Перевод
ULD (ultra lite driver) облегчённый драйвер ST: функции прямо поверх регистров
region of interest, ROI centre SPAD зона интереса; номер SPAD в её центре
distance mode: Short, Medium, Long режим дальности: пара периодов лазера и окно фазы
timing budget (TB) время на одно измерение по ULD и API L1X
inter-measurement period (IMP) шаг измерений по таймеру, в тиках медленного генератора
timing A / timing B два чередующихся тайминга с разными периодами лазера
phase wraparound, wrap target fail обёртка фазы: дальняя цель видна близкой; отказ по обёртке
range valid, no wrap check годно, но обёртка не проверена — первое после пуска
stream count счётчик измерений с пуска
DSS (dynamic SPAD selection) автоматический выбор числа включённых SPAD
crosstalk plane offset (kcps) помеха стекла на один SPAD, вычитается датчиком
part-to-part range offset поправка смещения дальности конкретного экземпляра
distance threshold / window interrupt прерывание по порогу или окну дальности
hardware standby аппаратный сон по XSHUT

100г.1 Чем L1X отличается от L0X

То же, что в 100б: время пролёта, лавинные фотодиоды, адрес 0x29 после включения, вход XSHUT с активным нулём и выход GPIO1 «готово». Отличия — в таблице.

VL53L0X (100б) VL53L1X
приёмник опорные и сигнальные SPAD без выбора матрица 16×16 SPAD, зона интереса от 4×4 до 16×16
дальность в модели 100б белая — до 953 мм (обычный профиль) в таблице из драйвера Zephyr: Long — 360 см в темноте (по документации производителя, не проверено)
поле зрения не названо 27° на всю матрицу (по документации производителя, не проверено)
адрес регистра 8 бит 16 бит, старшим байтом вперёд
чем пользоваться только API (библиотека на Си) API или ULD; здесь — свой драйвер по регистрам
бюджет от 20 мс 15…500 мс по ULD (не проверено); у нас — те же пределы
режимы профили пределов Short, Medium, Long — разные периоды лазера

Главная новинка — фаза. Датчик меряет задержку отражения в периодах PLL и складывает это в фазу; в API её переводит в миллиметры VL53L1_range_maths: разница фаз × период PLL × скорость света. Период PLL считается из частоты быстрого генератора в заводской памяти (регистр 0x0006, формат 4.12). Если она меньше 0x1000, API подставляет 0xBCCC — 11,8 МГц, ×64 — 755,2 МГц, такт 1,3241 нс. С этим числом единица фазы — 198,4 мм дальности.

Импульсы лазера идут не каждый такт, а раз в период: 16, 24 или 32 такта PLL в зависимости от режима. Фаза поэтому круговая: цель дальше, чем полпериода пути света, даёт ту же фазу, что и близкая. Против этого у датчика два тайминга, A и B, с разными периодами; соседние измерения идут то с одним, то с другим, и датчик сравнивает их фазы (100г.5).

Вверху — STM32F103 и три VL53L1X на одной шине I2C1 на 400 кГц: PB6 — SCL, PB7 — SDA, XSHUT датчиков на PB12, PB13, PB14, их GPIO1 на PA1, PA2, PA3 с EXTI по спаду; после включения у всех адрес 0x29, по одному они получают 0x30, 0x31, 0x32 записью в регистр 0x0001, затем блок по умолчанию из 91 байта в 0x2D…0x87; адрес регистра — 16 бит старшим байтом, ID 0xEACC по 0x010F. Слева внизу — матрица 16×16 SPAD, вся видит 27° по описанию, зона интереса 4×4 в центре — 6,75°, центр — SPAD 199, клетка (8, 8); регистр 0x7F — центр, 0x80 — размер; сигнал пропорционален числу SPAD. Справа внизу — режим Short: окно 1389 мм, обёртка тайминга B на 2381 мм и A на 3175 мм; цель на 3300 мм первое измерение показало как 134 мм со статусом 6, второе — 928 мм, A и B не сошлись, статус 7; единица фазы 198,4 мм. Внизу — периоды лазера Long 32 и 28 тактов, окно 4564 мм, Medium 24 и 20, 2976 мм, Short 16 и 12


100г.2 Подключение, 16-битные регистры, слово 0xEACC и блок из 91 байта

Вывод Что это Куда в прошивке
SCL, SDA I²C, адрес 0x29 после включения PB6, PB7 — I2C1, 400 кГц
XSHUT сброс и аппаратный сон, активный ноль PB12, PB13, PB14 — открытый сток
GPIO1 «готово» или «сработало окно»; полярность задаёт регистр 0x30 PA1, PA2, PA3 — EXTI по спаду
питание, земля по описанию модуля —

Шина та же, что у L0X, и код I2C1 в прошивке тот же (100б, полные исходники ниже). Разница — в адресе регистра: два байта, старший первым. Данные многобайтных регистров — тоже старшим байтом вперёд.

int l1_wr(l1_t *d, uint16_t reg, const uint8_t *p, uint32_t n)
{
    uint8_t b[2u + L1_MAX_WR];
    if (n > L1_MAX_WR)
        return 1;
    b[0] = (uint8_t)(reg >> 8);
    b[1] = (uint8_t)reg;
    memcpy(b + 2, p, n);
    d->xfers++;
    d->bytes += n + 2u;
    return l1_i2c_write(d->addr, b, n + 2u);
}
int l1_rd(l1_t *d, uint16_t reg, uint8_t *p, uint32_t n)
{
    uint8_t a[2] = {(uint8_t)(reg >> 8), (uint8_t)reg};
    d->xfers++;
    d->bytes += n + 2u;
    return l1_i2c_wr_rd(d->addr, a, 2, p, n);
}

Перепутать порядок байт адреса легко: чтение «слова ID» с адресом, отправленным младшим байтом вперёд, уходит в регистр 0x0F01, и модель вернула 0x0000 — не ошибку, а нули. Поэтому слово ID — первая проверка после включения.

Слово 0xEACC. Это два байта подряд: 0xEA по 0x010F — номер модели (значение по умолчанию в карте регистров API) — и 0xCC по 0x0110 — тип модуля (в API: «VL53L1 = 0xCC, VL53L3 = 0xAA»). Читаем словом и сравниваем с 0xEACC, как библиотека OpenMV.

Загрузка. После отпускания XSHUT датчик грузит свою прошивку; в драйвере Zephyr на это отведено «до 1,2 мс», а API ждёт бита 0 регистра 0xE5. Наш драйвер опрашивает этот бит каждые 200 мкс; пока датчик грузится, он не отвечает, и это тоже «ещё не готов».

/* Отпустить XSHUT, дождаться загрузки (бит 0 регистра 0xE5), проверить слово 0xEACC и перевести датчик на свой
 * адрес. Пока датчик грузится, он не отвечает — это тоже «ещё не готов». */
int l1_boot(l1_t *d, uint8_t idx, uint8_t addr7)
{
    uint8_t st = 0;
    uint16_t id = 0;
    uint32_t k;
    memset(d, 0, sizeof *d);
    d->idx = idx;
    d->addr = L1_ADDR_DEFAULT;
    l1_xshut(idx, 1);
    for (k = 0; k < L1_BOOT_TRIES; k++) {
        l1_delay_us(L1_BOOT_POLL_US);
        if (l1_rd(d, L1_FW_STATUS, &st, 1) == 0 && (st & 1u))
            break;
    }
    if (k == L1_BOOT_TRIES)
        return 1;
    if (l1_rd16(d, L1_MODEL_ID, &id) || id != L1_ID)
        return 2;
    if (addr7 != L1_ADDR_DEFAULT) {
        if (l1_wr8(d, L1_I2C_ADDR, addr7))          /* семибитный адрес; API делит 8-битный на 2 сам */
            return 3;
        d->addr = addr7;
        id = 0;
        if (l1_rd16(d, L1_MODEL_ID, &id) || id != L1_ID)
            return 4;
    }
    return 0;
}

В модели загрузка длится 1,2 мс: первое подтверждение пришло через 1337,5 мкс после XSHUT, на 7-м обращении; всё включение одного датчика — 7 транзакций, 22 байта, 400 мкс шины.

Блок из 91 байта. У VL53L1X нет описания регистров в открытом доступе, как и у L0X; API ST грузит настройки из пресетов, а облегчённый драйвер ULD — одной записью готового блока в регистры 0x2D…0x87: 0x87 − 0x2D + 1 = 91 байт. Этот блок есть в библиотеке OpenMV; его комментарии ссылаются на функции ULD — VL53L1X_init(), SetROI(), StartRanging(), — так что происхождение у него оттуда (по документации производителя, не проверено). Что в нём, по комментариям и карте регистров API:

Регистры В блоке Что это В пресетах API
0x30 0x01 GPIO1: выход прерывания, бит 4 = 0 — активна единица 0x11 — активен ноль
0x46 0x20 прерывание на каждое новое измерение то же
0x5E…0x63 0x01DB, 15, 0x01F1, 13 таймауты и периоды лазера A и B — режим Long Long: 15 и 13
0x64 0x0168 предел σ, 14.2: 90 мм 360 → 90 мм
0x66 0x0080 предел сигнала, 9.7: 1,0 MCPS 192 → 1,5 MCPS
0x69 0xB8 верх окна фазы, 5.3: 23,0 Long: 0xB8
0x6C 0x00000F89 период по таймеру ставится отдельно
0x7F, 0x80 0xC7, 0xFF центр ROI — SPAD 199, размер 16×16 то же
0x81 0x9B шаги измерения: без MM2 0xDB — с MM2
0x86, 0x87 0x01, 0x40 снять прерывание; пуск по таймеру —

Последний байт — ловушка: с 0x40 в 0x87 измерения пошли бы сразу при загрузке блока. У нас там 0x00, пуск — отдельно. При частоте 0xBCCC таймауты блока — 42 857 и 41 259 мкс, то есть бюджет около 43 мс. После блока — то, что делают API и OpenMV: смещение из 0x22 × 4 в 0x1E (шаги MM выключены, работает только оно), затем полярность и частоты генераторов.

/* Загрузить блок по умолчанию одной записью, перенести заводское смещение, сделать GPIO1 активным нулём,
 * прочитать частоты генераторов. После этого — режим Long, бюджет 33 мс, ROI 16×16. */
int l1_init(l1_t *d)
{
    uint16_t v = 0;
    if (l1_wr(d, L1_CFG_FIRST, L1_DEFAULT_CFG, L1_CFG_LEN))
        return 10;
    if (l1_rd16(d, L1_OUTER_OFFSET_MM, &v) || l1_wr16(d, L1_OFFSET_MM, (uint16_t)(v * 4u)))   /* как в OpenMV */
        return 11;
    if (l1_wr8(d, L1_GPIO_MUX, 0x11u))               /* в блоке 0x01 — активна единица; пресеты API: 0x11 */
        return 12;
    if (l1_rd16(d, L1_OSC_FREQ, &d->osc) || d->osc == 0)
        return 13;
    if (l1_rd16(d, L1_OSC_CAL, &v))
        return 14;
    d->osc_cal = v & 0x3FFu;
    d->budget_us = 33000u;
    if (l1_set_mode(d, L1_LONG))
        return 15;
    return 0;
}

Включение по блоку — 13 транзакций, 138 байт, 3537,5 мкс шины; из них 2120 мкс — одна запись блока.

Три датчика на одной шине включают так же, как L0X в 100б.5: все XSHUT вниз, затем по одному — отпустить, дождаться загрузки, проверить ID по 0x29, записать новый адрес. Отличие одно: адрес лежит в регистре 0x0001, и пишется туда семибитный адрес — функция API VL53L1_SetDeviceAddress сама делит переданный восьмибитный на 2, как у L0X. На стенде три датчика получили 0x30, 0x31, 0x32 за 27 транзакций, 1912,5 мкс шины и 5512,5 мкс вместе с загрузкой. Отпущенные разом — все три уехали на 0x30.

Сон и питание. Привязка Zephyr описывает XSHUT так: ноль — аппаратный сон, если питание подано, или полное выключение без питания. После сна адрес снова 0x29 и блок надо грузить заново. Между измерениями по таймеру датчик сам простаивает; при бюджете 33 мс и периоде 100 мс он меряет треть времени. Токи в каждом состоянии и питание модуля — по документации производителя, не проверено; модель их не считает.


100г.3 Режимы Short, Medium, Long; бюджет и период по таймеру

Режим — это пара периодов лазера для таймингов A и B и верх окна фазы. Числа взяты из пресетов API standard_ranging_short_range, standard_ranging и standard_ranging_long_range; окно и обёртка в миллиметрах — пересчёт через 198,4 мм на единицу фазы.

Режим Период A / B, такты PLL A / B, нс Окно фазы Окно, мм Обёртка A / B, мм Таблица Zephyr: темно / ярко
Short 16 / 12 21,19 / 15,89 7,0 1389 3175 / 2381 136 / 135 см
Medium 24 / 20 31,78 / 26,48 15,0 2976 4762 / 3968 290 / 76 см
Long 32 / 28 42,37 / 37,08 23,0 4564 6349 / 5556 360 / 73 см

Комментарии API к пресетам называют окна 1,4, 3,0 и 4,6 м — совпадает с пересчётом. Таблица в правом столбце процитирована в драйвере Zephyr из описания датчика (не проверено): в темноте Short и Medium упираются в своё окно, Long — в сигнал.

/* Режимы — как пресеты API standard_ranging_short_range / standard_ranging / _long_range: периоды лазера A и B,
 * верх окна фазы, окна и начальные фазы сигма-дельты. После смены периода таймауты пересчитываются. */
int l1_set_mode(l1_t *d, l1_dist_t m)
{
    static const uint8_t va[3] = {0x07u, 0x0Bu, 0x0Fu}, vb[3] = {0x05u, 0x09u, 0x0Du};
    static const uint8_t ph[3] = {0x38u, 0x78u, 0xB8u}, ip[3] = {6u, 10u, 14u};
    uint8_t b[4];
    if (m < L1_SHORT || m > L1_LONG)
        return 1;
    d->vcsel_a = va[m - 1];
    d->vcsel_b = vb[m - 1];
    if (l1_wr8(d, L1_VCSEL_A, d->vcsel_a) || l1_wr8(d, L1_VCSEL_B, d->vcsel_b) || l1_wr8(d, L1_PHASE_HIGH, ph[m - 1]))
        return 2;
    b[0] = d->vcsel_a;
    b[1] = d->vcsel_b;
    b[2] = ip[m - 1];
    b[3] = ip[m - 1];
    if (l1_wr(d, L1_WOI_SD0, b, 4))                     /* 0x78…0x7B одной записью */
        return 3;
    return put_timeouts(d) ? 4 : 0;
}

Бюджет в регистры. Время датчик считает в макропериодах: 2304 периода лазера (окно фона 256 и окно дальности 2048). В Long макропериод тайминга A — 97,625 мкс, B — 85,421 мкс. Таймаут пишется в формате «(младший байт сдвинуть влево на старший) + 1». API для режима без шагов MM вычитает из бюджета 1000 мкс запаса и пишет остаток в оба тайминга; мы делаем то же.

/* ── время: те же формулы, что VL53L1_calc_macro_period_us и VL53L1_calc_encoded_timeout в API ── */
uint32_t l1_macro_q12(uint16_t osc, uint8_t vcsel_code)
{
    uint32_t pll = (1u << 30) / osc;                    /* период PLL, мкс, 0.24 */
    uint32_t m = (2304u * pll) >> 6;                    /* макропериод — 2304 периода лазера */
    m *= (uint32_t)((vcsel_code + 1u) << 1);            /* период лазера в тактах PLL */
    return m >> 6;                                      /* мкс, 12.12 */
}

uint16_t l1_encode_timeout(uint32_t us, uint32_t macro_q12)
{
    uint32_t mclks = ((us << 12) + (macro_q12 >> 1)) / macro_q12;
    uint32_t ls;
    uint16_t ms = 0;
    if (mclks == 0)
        return 0;
    ls = mclks - 1u;                                    /* формат (LSB << MSB) + 1 */
    while (ls & 0xFFFFFF00u) {
        ls >>= 1;
        ms++;
    }
    return (uint16_t)((ms << 8) | (ls & 0xFFu));
}

Что получилось в Long (частота 0xBCCC):

Бюджет, мс 0x5E (A) 0x61 (B) Таймаут A, мкс Таймаут B, мкс
15 0x008E 0x00A3 13 960 14 009
20 0x00C2 0x00DD 19 037 18 964
33 0x01A3 0x01BB 31 923 32 033
50 0x01FA 0x028F 48 910 48 946
100 0x02FD 0x0390 98 894 98 491
200 0x03FE 0x0491 198 471 198 263
500 0x059F 0x05B6 496 811 497 580

Калибровка фазы — 1000 мкс, как в пресетах: в 0x4B уходит 10 макропериодов в Long, 14 в Medium, 20 в Short. Все 21 пара кодов (три режима, семь бюджетов) и номера SPAD для всех 256 клеток стенд сверил с функциями API, взятыми дословно (VL53L1_calc_macro_period_us, VL53L1_calc_encoded_timeout, VL53L1_encode_row_col): совпало 21 из 21 и 256 из 256. Бюджет 14 и 501 мс наш драйвер не принимает — пределы ULD. API пустил бы больше: остаток после запаса — до 550 000 мкс.

Подряд и по таймеру. 0x20 в 0x87 — измерения подряд, 0x40 — по таймеру, 0x10 — одно. В модели измерения подряд шли с шагом 33 498 и 33 608 мкс при бюджете 33 мс (тайминги A и B немного разные), при 20 и 100 мс — 20 539 / 20 612 и 100 066 / 100 469 мкс. Период по таймеру — миллисекунды × число тиков медленного генератора на миллисекунду из 0xDE (биты 9:0), так делает VL53L1_set_inter_measurement_period_ms. API требует, чтобы период был не меньше бюджета + 4 мс; наш драйвер тоже: 36 мс при бюджете 33 — отказ.

/* Период по таймеру: миллисекунды × тики медленного генератора (VL53L1_set_inter_measurement_period_ms). */
int l1_set_imp(l1_t *d, uint32_t ms)
{
    if (ms < d->budget_us / 1000u + L1_IMP_GUARD_MS)
        return 1;
    return l1_wr32(d, L1_IMP, ms * d->osc_cal) ? 2 : 0;
}

В модели генератор даёт 40 тиков на миллисекунду (допущение), и 100 мс записались как 0x00000FA0; результаты шли через 99 890 и 100 110 мкс.


100г.4 Шум и дальность: что даёт бюджет и режим (модель)

Как датчик считает σ, API не раскрывает: она приходит готовой в регистре 0x92. Поэтому в модели шум — своя формула: σ = c · 8 нс / 2 · √(Ns + W·Na) / Ns, где Ns и Na — отсчёты сигнала и фона за таймаут, 8 нс — ширина импульса из настроек API, W — период лазера к 16 нс (ширина окна фона там же). Белая мишень на 3600 мм даёт на всей матрице ровно 1,0 MCPS — так модель подогнана к 360 см из таблицы; сигнал падает как 1/d², а при коротком периоде лазера импульсов больше и сигнала больше. Свыше 20 MCPS датчик выключает часть SPAD (цель DSS из пресетов). Это допущения, не датчик.

По 100 измерений на точку, разброс годных и средняя σ, которую «датчик» кладёт в 0x92:

Бюджет Long, белая 1 м Long, серая 0,2 на 1,2 м Short, серая 0,2 на 1,2 м
15 мс 2,65 (2,75) 8,48 (7,25) 4,44 (5,00)
20 мс 2,05 (2,38) 6,49 (6,25) 3,98 (4,25)
33 мс 1,88 (1,75) 4,68 (4,88) 3,16 (3,25)
50 мс 1,28 (1,50) 3,71 (3,88) 2,84 (2,62)
100 мс 1,01 (1,00) 2,92 (2,75) 1,95 (1,88)
200 мс 0,69 (0,75) 1,81 (2,00) 1,33 (1,38)
500 мс 0,53 (0,50) 1,21 (1,25) 0,95 (0,88)

Вчетверо больше времени — примерно вдвое меньше разброс, как у L0X. В каждой сотне годных 99: первое измерение после пуска приходит со статусом 6 (100г.5).

Дальность белой мишени — наибольшая, где из 40 измерений не меньше 90 % годны и не дальше 100 мм от правды (бюджет 33 мс):

Режим Модель: темно Таблица: темно Модель: яркий свет Таблица: ярко
Short 1350 мм 136 см 1190 мм 135 см
Medium 2950 мм 290 см 870 мм 76 см
Long 3600 мм 360 см 690 мм 73 см

В темноте Short и Medium модель останавливает окно фазы — это не подгонка, а пересчёт из пресетов, и он лёг рядом с таблицей. Яркий свет в модели — 0,9 MCPS фона на SPAD, подобран так, чтобы Long кончался рядом с 73 см; Short на том же свету держит 1190 мм, Medium — 870. Порядок тот же, что в таблице, числа для Medium расходятся — модель грубее датчика. Вывод для практики из таблицы, а не из модели: на солнце Long не дальнобойнее Short.


100г.5 Статус измерения и обёртка фазы

Результат — 17 байт с 0x89: код датчика (биты 4:0), отчёт, счётчик измерений, число SPAD (8.8), сигнал и фон (9.7, MCPS), σ (14.2, мм), фаза (5.11), дальность в миллиметрах и сигнал после вычета помехи стекла. Код датчика переводится в статус по правилам API (SetSimpleData и ConvertStatusLite):

Код датчика Статус API Что значит Что делать
9 0 годно брать
9 при счётчике 0 6 годно, но обёртка не проверена не брать: сравнить не с чем
6 1 σ выше предела выбросить
4 2 сигнал слабый, цели нет «ничего не видно»
5 4 фаза вне окна цель дальше окна: режим подлиннее
7 7 фазы A и B не сходятся — обёртка выбросить, сменить режим
8 3 обрезано по минимальной дальности по задаче
12 9 сигнал не выше помехи стекла калибровать стекло
1, 2, 3, 17 5 аппаратный сбой перезапустить
/* Код датчика → статус API (SetSimpleData и ConvertStatusLite): первое измерение после пуска (счётчик 0)
 * обёртку фазы не проверяло — статус 6, а не 0. */
static uint8_t api_status(uint8_t raw, uint8_t stream)
{
    switch (raw) {
    case 1: case 2: case 3: case 17: return 5;      /* аппаратный сбой */
    case 13: return 13;                             /* ROI вне матрицы */
    case 18: return 10;                             /* синхронизация */
    case 9: return stream == 0 ? 6 : 0;             /* годно; без проверки обёртки — 6 */
    case 6: return 1;                               /* σ выше предела */
    case 4: return 2;                               /* сигнал слабый */
    case 8: return 3;                               /* обрезано по минимальной дальности */
    case 5: return 4;                               /* фаза вне окна */
    case 7: return 7;                               /* фазы A и B не сходятся — обёртка */
    case 12: return 9;                              /* сигнал не выше помехи стекла */
    default: return 255;
    }
}

API ещё правит отрицательную дальность: до −30 мм — ноль, ниже — статус 14. Поправочный множитель API (2011/2048, LITE_RANGING_GAIN_FACTOR) мы не применяем, как и OpenMV, — какой путь верен для живого датчика, не проверено.

Модель, по 50 измерений подряд:

Сцена Режим Статусы Первые дальности, мм
пусто Long 2 — все 166, 192, 700, 835
белая на 5000 мм Long 2 — все 5014, 5001, 5012, 5009
белая на 2000 мм Short 4 — все 2004, 2009, 2011, 2009
белая на 3300 мм Short 6 — первое, 7 — остальные 134, 928, 139, 927
отражение 3,0 (втрое ярче белой) на 5000 мм Medium 6 — первое, 7 — остальные 238, 1035, 244, 1041

Обёртка фазы — главная ловушка L1X. Белая стена на 3300 мм в Short: это больше обёртки тайминга A (3175 мм), и первое измерение показало 134 мм. Сравнить его было не с чем — счётчик 0, и код 9 стал статусом 6, а не 0. Второе измерение (тайминг B, обёртка 2381 мм) дало 928 мм, фазы разошлись — статус 7, и так до конца. Фильтр, который берёт только статус 0, этой ошибки не пропускает; фильтр «всё, кроме явных отказов» — пропустит 134 мм вместо 3,3 м. Светлые дальние цели за окном опасны именно тем, что сигнала у них хватает.


100г.6 Зона интереса, прерывание по окну, стекло и калибровки

ROI. Центр — номер SPAD в 0x7F, размер — в 0x80: старшая тетрада — высота − 1, младшая — ширина − 1. Номер SPAD нумеруется не по строкам, а по правилу VL53L1_encode_row_col; по умолчанию центр — SPAD 199, клетка (8, 8). API не принимает зону уже 4 SPAD (ширина − 1 меньше 3).

/* ── ROI: номер SPAD центра — как VL53L1_encode_row_col (y — строка, x — столбец) ── */
uint8_t l1_spad(uint8_t x, uint8_t y)
{
    if (y > 7u)
        return (uint8_t)(128u + (x << 3) + (15u - y));
    return (uint8_t)(((15u - x) << 3) + y);
}

/* Прямоугольник w×h SPAD с левым нижним углом (x0, y0); центр — как в VL53L1_SetUserROI. */
int l1_set_roi(l1_t *d, uint8_t x0, uint8_t y0, uint8_t w, uint8_t h)
{
    uint8_t b[2];
    if (w < 4u || h < 4u || x0 + w > 16u || y0 + h > 16u)
        return 1;
    b[0] = l1_spad((uint8_t)(x0 + w / 2u), (uint8_t)(y0 + h / 2u));
    b[1] = (uint8_t)(((h - 1u) << 4) | (w - 1u));
    return l1_wr(d, L1_ROI_CENTRE, b, 2) ? 2 : 0;       /* 0x7F и 0x80 подряд */
}

Узкая зона — узкий взгляд и меньше сигнала. Модель (поле зрения делится поровну, 27° по описанию — не проверено), стена в 1500 мм и коробка 4×4 SPAD на 600 мм в центре:

ROI Угол SPAD Сигнал на 1 м, MCPS Дальность белой Коробка: показано
16×16 27° 256 13,89 3600 мм 1245 мм
8×8 13,5° 64 3,47 1800 мм 902 мм
4×4 6,75° 16 0,87 900 мм 610 мм

Вся матрица видит коробку и стену разом и показывает среднее с весом сигнала — 1245 мм, которых нет ни там, ни там. Зона 4×4 видит коробку (610 мм с нашим сдвигом модели +9 мм), но белую стену — только до 900 мм: сигнал в 16 раз слабее.

«Сканирование». Шестнадцать зон 4×4 по очереди дают грубую картинку 4×4. ROI следующей зоны пишем, пока датчик ждёт снятия прерывания, — новое измерение начнётся уже с ней.

/* «Сканирование» 16 зонами 4×4 SPAD, построчно сверху вниз: ROI следующей зоны пишется, пока датчик ждёт
 * снятия прерывания, — следующее измерение начнётся уже с ней. */
int l1_scan(l1_t *d, int16_t mm[16], uint8_t st[16])
{
    l1_meas_t m;
    if (l1_set_roi(d, 0, 12, 4, 4) || l1_start(d, 0x20u))
        return 1;
    for (uint8_t z = 0; z < 16u; z++) {
        uint8_t n = (uint8_t)((z + 1u) & 15u);
        if (wait_irq(d) || l1_read(d, &m))
            return 2;
        mm[z] = m.mm;
        st[z] = m.status;
        if (l1_set_roi(d, (uint8_t)(4u * (n & 3u)), (uint8_t)(12u - 4u * (n >> 2)), 4, 4) || l1_clear(d))
            return 3;
    }
    return l1_stop(d) ? 4 : 0;
}

Стена на 800 мм, коробка на 400 мм в левой верхней четверти, Short, 33 мс (дальность / статус):

столбец 0 1 2 3
строка 0 412 / 6 409 / 0 810 / 0 813 / 0
строка 1 409 / 0 408 / 0 811 / 0 808 / 0
строка 2 811 / 0 814 / 0 802 / 0 807 / 0
строка 3 808 / 0 805 / 0 806 / 0 809 / 0

Кадр — 538,1 мс, транзакций — 51. Первая зона пришла со статусом 6 — первое измерение после пуска. В модели проверка обёртки сравнивает только измерения с одной ROI; как живой датчик сверяет фазы при смене ROI на ходу — не проверено.

Прерывание по окну. Регистр 0x46: биты 1:0 — условие (0 — ниже нижнего порога, 1 — выше верхнего, 2 — вне окна, 3 — внутри), бит 5 — «на каждое измерение»; пороги в миллиметрах — 0x72 (верхний) и 0x74 (нижний).

/* Прерывание по окну дальности или на каждое измерение. Пороги — в 0x72 (верхний) и 0x74 (нижний). */
int l1_set_window(l1_t *d, l1_win_t win, uint16_t low_mm, uint16_t high_mm)
{
    uint8_t b[4] = {(uint8_t)(high_mm >> 8), (uint8_t)high_mm, (uint8_t)(low_mm >> 8), (uint8_t)low_mm};
    if (win != L1_EVERY && l1_wr(d, L1_THRESH_HIGH, b, 4))
        return 1;
    return l1_wr8(d, L1_INT_CONFIG, (uint8_t)win) ? 2 : 0;
}

Человек подходит от 1500 до 300 мм за 5 с и уходит обратно, измерение по таймеру раз в 100 мс, 10,4 с:

Прерывание Прерываний Транзакций Шина, мкс
на каждое измерение 104 208 59 800
ниже 500 мм 15 30 8625
внутри 400…700 мм 26 52 14 950

Процессор спит, пока ничего не случилось; по окну «ниже 500 мм» шина занята в 6,9 раза меньше. Условие проверяется на каждом измерении: пока человек внутри окна, прерывание приходит каждые 100 мс, а не один раз на входе.

Стекло. Крышка отражает часть импульса в приёмник; датчик смешивает это отражение с целью, и дальность тянет к нулю — тем сильнее, чем слабее цель. В модели стекло — 0,01 MCPS на SPAD. Две калибровки по образцу API:

  1. Смещение (VL53L1_PerformOffsetSimpleCalibration): мишень на известной дальности, первое измерение выбросить, из 50 годных — среднее; поправка = дальность − среднее. Пишем её в 0x22 и, × 4, в 0x1E.
  2. Помеха стекла (SingleTargetXTalkCalibration): мишень на 600 мм (в API по умолчанию), 50 измерений без компенсации; помеха на SPAD = сигнал / число SPAD × (1 − измерено / 600). В 0x16 — кило-отсчёты в формате 7.9.
/* Калибровка помехи стекла — как SingleTargetXTalkCalibration в API: мишень на известной дальности, n измерений
 * без компенсации; помеха на SPAD = сигнал / SPAD · (1 − измерено / мишень). В 0x16 — кило-отсчёты в 7.9. */
int l1_cal_xtalk(l1_t *d, uint16_t target_mm, uint8_t n, uint16_t *kcps)
{
    uint32_t sum_mm = 0, sum_sig = 0, sum_sp = 0, got = 0, sig, sp, mm;
    l1_meas_t m;
    if (l1_wr16(d, L1_XTALK_KCPS, 0) || l1_wr32(d, L1_XTALK_X_GRAD, 0))
        return 1;
    if (l1_start(d, 0x20u) || wait_irq(d) || l1_clear(d))   /* первое измерение не в счёт */
        return 2;
    for (uint8_t i = 0; i < n; i++) {
        if (wait_irq(d) || l1_read(d, &m) || l1_clear(d))
            return 3;
        if (m.status == 0 && m.mm > 0) {
            sum_mm += (uint32_t)m.mm;
            sum_sig += m.signal;
            sum_sp += m.spads >> 8;
            got++;
        }
    }
    l1_stop(d);
    if (!got)
        return 4;
    sig = sum_sig / got;
    sp = (sum_sp + got / 2u) / got;
    mm = (sum_mm + got / 2u) / got;
    *kcps = 0;
    if (sp && mm < target_mm)                           /* MCPS 9.7 → кило-отсчёты 7.9: · 1000 · 512 / 128 */
        *kcps = (uint16_t)((sig * 4000u / sp) * (target_mm - mm) / target_mm);
    return l1_wr16(d, L1_XTALK_KCPS, *kcps) ? 5 : 0;
}

Средняя ошибка годных, мм (по 30 измерений, Long, 33 мс):

150 мм 300 мм 600 мм 1000 мм 2000 мм без цели
без калибровок +9 +4 −28 −147 все — статус 7 статус 0
после смещения 0 −5 −38 −156 все — статус 7 статус 0
после помехи стекла 0 −1 0 −3 −20 статус 2

Калибровка смещения на 100 мм нашла −9 мм — свой сдвиг модели — за 108 транзакций и 1711,5 мс. Калибровка стекла нашла 0,00987 MCPS на SPAD при заданных 0,01 (код 5051) за 1711,3 мс. Без неё дальняя цель занижена на сотни миллиметров, на 2000 мм тайминги A и B расходятся и все измерения — статус 7, а без цели датчик «видит» стекло в нескольких миллиметрах со статусом 0. После — без цели статус 2, как и должно быть. Порядок — сначала смещение, потом стекло, со стеклом на месте.


100г.7 Грабли

Прерывание пришло один раз, новых данных нет. Не сняли прерывание записью 0x01 в 0x86. В модели следующее измерение ждёт снятия: за секунду — одно прерывание, счётчик 0, GPIO1 стоит внизу; после снятия счётчик пошёл 1…5.

ID читается как 0x0000. Адрес регистра отправлен младшим байтом вперёд — ушло в 0x0F01. Два байта адреса, старший первым.

Стена в 3,3 м показана в 134 мм. Обёртка фазы в Short, а фильтр взял статус 6. Годно только 0; для дальних светлых целей — Medium или Long.

На солнце Long видит хуже Short. По таблице из описания — 73 см против 135; в модели — 690 против 1190 мм. На свету — Short.

Через стекло дальность занижена, а без цели датчик «видит» что-то у самого носа. Нет калибровки помехи стекла. Смещение, затем помеха, со стеклом на месте.

GPIO1 не даёт спада. Блок по умолчанию оставляет 0x30 = 0x01 — активна единица; EXTI по спаду с подтяжкой ждёт активного нуля. Писать 0x11.

Измерения начались сами сразу после загрузки блока. В блоке OpenMV последний байт — 0x40 в 0x87. У нас — 0x00.

Первое измерение после пуска «не годится». Статус 6: обёртка не проверена. Это нормально; его и калибровки API выбрасывают.

Период по таймеру не ставится. Меньше бюджета + 4 мс API и наш драйвер не принимают.

Узкая ROI «не видит» дальнюю стену. 4×4 SPAD — в 16 раз меньше сигнала: в модели белая стена — до 900 мм вместо 3600.

Второй датчик на чужом адресе. XSHUT отпущены разом — запись в 0x0001 получили все.


100г.8 Итоги


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

Драйвер по регистрам и прошивка на три датчика. Стенд host100e.c, модель model100e.py, сверка с API ref100e.c и выдержка функций API st_ref.c — в _verify/tof100e/.

tof100e.h — регистры, константы, шина и состояние драйвера.

/* Раздел 100г: VL53L1X прямо по регистрам — без библиотеки ST. Адреса регистров и форматы — из карты регистров
 * API ST VL53L1X 2.4.5 (vl53l1_register_map.h, модуль hal_st Zephyr); блок настроек по умолчанию — из открытой
 * библиотеки vl53l1x (набор скриптов OpenMV), его комментарии ссылаются на функции ULD. Шину и выводы даёт
 * прошивка (fw100e.c — I2C1 STM32F103) или стенд (host100e.c — строки к модели model100e.py). */
#ifndef TOF100E_H
#define TOF100E_H
#include <stdint.h>

/* ── адреса регистров: 16 бит, на шину — старшим байтом вперёд ── */
#define L1_SOFT_RESET        0x0000u
#define L1_I2C_ADDR          0x0001u   /* [6:0] — семибитный адрес */
#define L1_OSC_FREQ          0x0006u   /* частота быстрого генератора, 4.12, МГц — из заводской памяти */
#define L1_XTALK_KCPS        0x0016u   /* помеха стекла на один SPAD, кило-отсчётов/с, 7.9 */
#define L1_XTALK_X_GRAD      0x0018u
#define L1_XTALK_Y_GRAD      0x001Au
#define L1_OFFSET_MM         0x001Eu   /* смещение дальности, мм, 11.2 */
#define L1_INNER_OFFSET_MM   0x0020u
#define L1_OUTER_OFFSET_MM   0x0022u
#define L1_GPIO_MUX          0x0030u   /* бит 4: 1 — GPIO1 активен нулём (так в пресетах API) */
#define L1_GPIO_STATUS       0x0031u   /* бит 0 — уровень GPIO1 */
#define L1_INT_CONFIG        0x0046u   /* [1:0] — окно по дальности, бит 5 — «новое измерение» */
#define L1_PHASECAL_TIMEOUT  0x004Bu
#define L1_TIMEOUT_A         0x005Eu   /* таймаут дальнометрии, тайминг A: (LSB << MSB) + 1 макропериодов */
#define L1_VCSEL_A           0x0060u   /* период лазера A: (код + 1) · 2 такта PLL */
#define L1_TIMEOUT_B         0x0061u
#define L1_VCSEL_B           0x0063u
#define L1_SIGMA_THRESH      0x0064u   /* предел σ, мм, 14.2 */
#define L1_MIN_RATE          0x0066u   /* предел сигнала, MCPS, 9.7 */
#define L1_PHASE_LOW         0x0068u   /* окно фазы, 5.3 */
#define L1_PHASE_HIGH        0x0069u
#define L1_IMP               0x006Cu   /* период между измерениями: мс · OSC_CAL, 32 бита */
#define L1_THRESH_HIGH       0x0072u   /* пороги прерывания по дальности, мм */
#define L1_THRESH_LOW        0x0074u
#define L1_WOI_SD0           0x0078u
#define L1_WOI_SD1           0x0079u
#define L1_INIT_PHASE_SD0    0x007Au
#define L1_INIT_PHASE_SD1    0x007Bu
#define L1_ROI_CENTRE        0x007Fu   /* номер SPAD центра ROI */
#define L1_ROI_SIZE          0x0080u   /* старшая тетрада — высота − 1, младшая — ширина − 1 */
#define L1_INT_CLEAR         0x0086u
#define L1_MODE_START        0x0087u   /* 0x10 — однократно, 0x20 — подряд, 0x40 — по таймеру, 0 — стоп */
#define L1_RESULT            0x0089u   /* 17 байт: статус, отчёт, счётчик, SPAD, сигнал, фон, σ, фаза, дальность… */
#define L1_OSC_CAL           0x00DEu   /* [9:0] — тиков медленного генератора на миллисекунду */
#define L1_FW_STATUS         0x00E5u   /* бит 0 — прошивка датчика загрузилась */
#define L1_MODEL_ID          0x010Fu   /* слово 0xEACC: модель 0xEA и тип модуля 0xCC */

#define L1_ADDR_DEFAULT  0x29u
#define L1_ID            0xEACCu
#define L1_CFG_FIRST     0x002Du   /* блок по умолчанию: 0x2D…0x87 */
#define L1_CFG_LEN       91u
#define L1_BOOT_POLL_US  200u      /* опрос загрузки — наше значение */
#define L1_BOOT_TRIES    20u
#define L1_GUARD_US      1000u     /* запас API для LITE без MM1/MM2: бюджет − 1000 мкс = таймаут дальнометрии */
#define L1_TB_MIN_MS     15u       /* пределы бюджета по ULD — по документации производителя, не проверено */
#define L1_TB_MAX_MS     500u
#define L1_IMP_GUARD_MS  4u        /* API: период по таймеру не меньше бюджета + 4 мс */
#define L1_PHASECAL_US   1000u     /* таймаут калибровки фазы в пресетах API */

/* ── шина и выводы: реализует прошивка или стенд ── */
int  l1_i2c_write(uint8_t addr7, const uint8_t *buf, uint32_t n);                    /* 0 — всё подтверждено */
int  l1_i2c_wr_rd(uint8_t addr7, const uint8_t *wr, uint32_t nw, uint8_t *rd, uint32_t nr);
void l1_delay_us(uint32_t us);
void l1_xshut(uint8_t idx, int run);   /* run = 1 — отпустить XSHUT, 0 — прижать к земле */
int  l1_idle(void);                    /* ждать прерывания: прошивка — WFI, стенд — до фронта GPIO1; 0 — не дождались */

typedef enum { L1_SHORT = 1, L1_MEDIUM = 2, L1_LONG = 3 } l1_dist_t;      /* нумерация как у API и Zephyr */
typedef enum { L1_BELOW = 0, L1_ABOVE = 1, L1_OUTSIDE = 2, L1_INSIDE = 3, L1_EVERY = 0x20 } l1_win_t;

typedef struct {
    int16_t mm;             /* дальность из 0x96 — без поправочного множителя API */
    uint8_t raw;            /* код датчика, биты 4:0 байта 0x89 */
    uint8_t status;         /* статус по правилам API: 0 годно, 1 σ, 2 сигнал, 4 вне окна, 6 без проверки обёртки… */
    uint8_t stream;         /* счётчик измерений 0x8B */
    uint16_t signal;        /* MCPS, 9.7 */
    uint16_t ambient;       /* MCPS, 9.7 */
    uint16_t sigma;         /* мм, 14.2 */
    uint16_t spads;         /* число SPAD, 8.8 */
} l1_meas_t;

typedef struct {
    uint8_t addr;           /* адрес на шине, 7 бит */
    uint8_t idx;            /* номер вывода XSHUT и GPIO1 */
    uint16_t osc;           /* 0x0006: быстрый генератор, 4.12 */
    uint16_t osc_cal;       /* 0x00DE & 0x3FF */
    uint8_t vcsel_a, vcsel_b;
    uint32_t budget_us;
    volatile uint8_t irq;   /* GPIO1 сработал — ставит обработчик EXTI */
    uint32_t xfers, bytes;  /* транзакции и байты на шине */
} l1_t;

extern const uint8_t L1_DEFAULT_CFG[L1_CFG_LEN];

int  l1_rd(l1_t *d, uint16_t reg, uint8_t *p, uint32_t n);
int  l1_wr(l1_t *d, uint16_t reg, const uint8_t *p, uint32_t n);
int  l1_rd16(l1_t *d, uint16_t reg, uint16_t *v);
int  l1_wr8(l1_t *d, uint16_t reg, uint8_t v);
int  l1_wr16(l1_t *d, uint16_t reg, uint16_t v);
int  l1_wr32(l1_t *d, uint16_t reg, uint32_t v);
void l1_sleep_all(uint8_t n);
int  l1_boot(l1_t *d, uint8_t idx, uint8_t addr7);
int  l1_init(l1_t *d);
uint32_t l1_macro_q12(uint16_t osc, uint8_t vcsel_code);
uint16_t l1_encode_timeout(uint32_t us, uint32_t macro_q12);
int  l1_set_mode(l1_t *d, l1_dist_t m);
int  l1_set_budget(l1_t *d, uint32_t ms);
int  l1_set_imp(l1_t *d, uint32_t ms);
uint8_t l1_spad(uint8_t x, uint8_t y);
int  l1_set_roi(l1_t *d, uint8_t w, uint8_t h, uint8_t x, uint8_t y);
int  l1_set_window(l1_t *d, l1_win_t win, uint16_t low_mm, uint16_t high_mm);
int  l1_start(l1_t *d, uint8_t mode);
int  l1_stop(l1_t *d);
int  l1_clear(l1_t *d);
int  l1_read(l1_t *d, l1_meas_t *m);
int  l1_cal_offset(l1_t *d, uint16_t target_mm, uint8_t n, int16_t *ofs);
int  l1_cal_xtalk(l1_t *d, uint16_t target_mm, uint8_t n, uint16_t *kcps);
int  l1_scan(l1_t *d, int16_t mm[16], uint8_t st[16]);
#endif

tof100e.c — драйвер: блок по умолчанию, включение, режимы и бюджет, ROI, окно, статусы, калибровки, сканирование.

/* Раздел 100г: драйвер VL53L1X по регистрам. Общий для прошивки и стенда. */
#include <string.h>
#include "tof100e.h"

/* Блок по умолчанию для 0x2D…0x87 — как в открытой библиотеке vl53l1x (OpenMV), кроме последнего байта:
 * там 0x40 (измерения стартуют сразу после загрузки), у нас 0x00 — стартуем сами. */
const uint8_t L1_DEFAULT_CFG[L1_CFG_LEN] = {
    0x00, 0x00, 0x00, 0x01, 0x02, 0x00, 0x02, 0x08, 0x00, 0x08, 0x10, 0x01, 0x01, 0x00, 0x00, 0x00, /* 0x2D */
    0x00, 0xFF, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0B, 0x00, 0x00, 0x02, 0x0A, 0x21, /* 0x3D */
    0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x00, 0x38, 0xFF, 0x01, 0x00, 0x08, 0x00, /* 0x4D */
    0x00, 0x01, 0xDB, 0x0F, 0x01, 0xF1, 0x0D, 0x01, 0x68, 0x00, 0x80, 0x08, 0xB8, 0x00, 0x00, 0x00, /* 0x5D */
    0x00, 0x0F, 0x89, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0F, 0x0D, 0x0E, 0x0E, 0x00, /* 0x6D */
    0x00, 0x02, 0xC7, 0xFF, 0x9B, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00,                               /* 0x7D */
};

/* ── обращение к регистру: два байта адреса старшим вперёд, затем данные ── */
#define L1_MAX_WR 96u

int l1_wr(l1_t *d, uint16_t reg, const uint8_t *p, uint32_t n)
{
    uint8_t b[2u + L1_MAX_WR];
    if (n > L1_MAX_WR)
        return 1;
    b[0] = (uint8_t)(reg >> 8);
    b[1] = (uint8_t)reg;
    memcpy(b + 2, p, n);
    d->xfers++;
    d->bytes += n + 2u;
    return l1_i2c_write(d->addr, b, n + 2u);
}

int l1_rd(l1_t *d, uint16_t reg, uint8_t *p, uint32_t n)
{
    uint8_t a[2] = {(uint8_t)(reg >> 8), (uint8_t)reg};
    d->xfers++;
    d->bytes += n + 2u;
    return l1_i2c_wr_rd(d->addr, a, 2, p, n);
}

int l1_rd16(l1_t *d, uint16_t reg, uint16_t *v)
{
    uint8_t b[2] = {0, 0};
    int e = l1_rd(d, reg, b, 2);
    *v = (uint16_t)((b[0] << 8) | b[1]);             /* данные тоже старшим байтом вперёд */
    return e;
}

int l1_wr8(l1_t *d, uint16_t reg, uint8_t v)
{
    return l1_wr(d, reg, &v, 1);
}

int l1_wr16(l1_t *d, uint16_t reg, uint16_t v)
{
    uint8_t b[2] = {(uint8_t)(v >> 8), (uint8_t)v};
    return l1_wr(d, reg, b, 2);
}

int l1_wr32(l1_t *d, uint16_t reg, uint32_t v)
{
    uint8_t b[4] = {(uint8_t)(v >> 24), (uint8_t)(v >> 16), (uint8_t)(v >> 8), (uint8_t)v};
    return l1_wr(d, reg, b, 4);
}

/* ── включение по одному и смена адреса ── */
void l1_sleep_all(uint8_t n)
{
    for (uint8_t i = 0; i < n; i++)
        l1_xshut(i, 0);
    l1_delay_us(L1_BOOT_POLL_US);
}

/* Отпустить XSHUT, дождаться загрузки (бит 0 регистра 0xE5), проверить слово 0xEACC и перевести датчик на свой
 * адрес. Пока датчик грузится, он не отвечает — это тоже «ещё не готов». */
int l1_boot(l1_t *d, uint8_t idx, uint8_t addr7)
{
    uint8_t st = 0;
    uint16_t id = 0;
    uint32_t k;
    memset(d, 0, sizeof *d);
    d->idx = idx;
    d->addr = L1_ADDR_DEFAULT;
    l1_xshut(idx, 1);
    for (k = 0; k < L1_BOOT_TRIES; k++) {
        l1_delay_us(L1_BOOT_POLL_US);
        if (l1_rd(d, L1_FW_STATUS, &st, 1) == 0 && (st & 1u))
            break;
    }
    if (k == L1_BOOT_TRIES)
        return 1;
    if (l1_rd16(d, L1_MODEL_ID, &id) || id != L1_ID)
        return 2;
    if (addr7 != L1_ADDR_DEFAULT) {
        if (l1_wr8(d, L1_I2C_ADDR, addr7))          /* семибитный адрес; API делит 8-битный на 2 сам */
            return 3;
        d->addr = addr7;
        id = 0;
        if (l1_rd16(d, L1_MODEL_ID, &id) || id != L1_ID)
            return 4;
    }
    return 0;
}

/* Загрузить блок по умолчанию одной записью, перенести заводское смещение, сделать GPIO1 активным нулём,
 * прочитать частоты генераторов. После этого — режим Long, бюджет 33 мс, ROI 16×16. */
int l1_init(l1_t *d)
{
    uint16_t v = 0;
    if (l1_wr(d, L1_CFG_FIRST, L1_DEFAULT_CFG, L1_CFG_LEN))
        return 10;
    if (l1_rd16(d, L1_OUTER_OFFSET_MM, &v) || l1_wr16(d, L1_OFFSET_MM, (uint16_t)(v * 4u)))   /* как в OpenMV */
        return 11;
    if (l1_wr8(d, L1_GPIO_MUX, 0x11u))               /* в блоке 0x01 — активна единица; пресеты API: 0x11 */
        return 12;
    if (l1_rd16(d, L1_OSC_FREQ, &d->osc) || d->osc == 0)
        return 13;
    if (l1_rd16(d, L1_OSC_CAL, &v))
        return 14;
    d->osc_cal = v & 0x3FFu;
    d->budget_us = 33000u;
    if (l1_set_mode(d, L1_LONG))
        return 15;
    return 0;
}

/* ── время: те же формулы, что VL53L1_calc_macro_period_us и VL53L1_calc_encoded_timeout в API ── */
uint32_t l1_macro_q12(uint16_t osc, uint8_t vcsel_code)
{
    uint32_t pll = (1u << 30) / osc;                    /* период PLL, мкс, 0.24 */
    uint32_t m = (2304u * pll) >> 6;                    /* макропериод — 2304 периода лазера */
    m *= (uint32_t)((vcsel_code + 1u) << 1);            /* период лазера в тактах PLL */
    return m >> 6;                                      /* мкс, 12.12 */
}

uint16_t l1_encode_timeout(uint32_t us, uint32_t macro_q12)
{
    uint32_t mclks = ((us << 12) + (macro_q12 >> 1)) / macro_q12;
    uint32_t ls;
    uint16_t ms = 0;
    if (mclks == 0)
        return 0;
    ls = mclks - 1u;                                    /* формат (LSB << MSB) + 1 */
    while (ls & 0xFFFFFF00u) {
        ls >>= 1;
        ms++;
    }
    return (uint16_t)((ms << 8) | (ls & 0xFFu));
}

static int put_timeouts(l1_t *d)
{
    uint32_t r = d->budget_us - L1_GUARD_US;
    uint32_t pc = ((L1_PHASECAL_US << 12) + (l1_macro_q12(d->osc, d->vcsel_a) >> 1)) / l1_macro_q12(d->osc, d->vcsel_a);
    if (l1_wr8(d, L1_PHASECAL_TIMEOUT, (uint8_t)(pc > 0xFFu ? 0xFFu : pc)))
        return 1;
    if (l1_wr16(d, L1_TIMEOUT_A, l1_encode_timeout(r, l1_macro_q12(d->osc, d->vcsel_a))))
        return 1;
    return l1_wr16(d, L1_TIMEOUT_B, l1_encode_timeout(r, l1_macro_q12(d->osc, d->vcsel_b)));
}

/* Режимы — как пресеты API standard_ranging_short_range / standard_ranging / _long_range: периоды лазера A и B,
 * верх окна фазы, окна и начальные фазы сигма-дельты. После смены периода таймауты пересчитываются. */
int l1_set_mode(l1_t *d, l1_dist_t m)
{
    static const uint8_t va[3] = {0x07u, 0x0Bu, 0x0Fu}, vb[3] = {0x05u, 0x09u, 0x0Du};
    static const uint8_t ph[3] = {0x38u, 0x78u, 0xB8u}, ip[3] = {6u, 10u, 14u};
    uint8_t b[4];
    if (m < L1_SHORT || m > L1_LONG)
        return 1;
    d->vcsel_a = va[m - 1];
    d->vcsel_b = vb[m - 1];
    if (l1_wr8(d, L1_VCSEL_A, d->vcsel_a) || l1_wr8(d, L1_VCSEL_B, d->vcsel_b) || l1_wr8(d, L1_PHASE_HIGH, ph[m - 1]))
        return 2;
    b[0] = d->vcsel_a;
    b[1] = d->vcsel_b;
    b[2] = ip[m - 1];
    b[3] = ip[m - 1];
    if (l1_wr(d, L1_WOI_SD0, b, 4))                     /* 0x78…0x7B одной записью */
        return 3;
    return put_timeouts(d) ? 4 : 0;
}

int l1_set_budget(l1_t *d, uint32_t ms)
{
    if (ms < L1_TB_MIN_MS || ms > L1_TB_MAX_MS)
        return 1;
    d->budget_us = ms * 1000u;
    return put_timeouts(d) ? 2 : 0;
}

/* Период по таймеру: миллисекунды × тики медленного генератора (VL53L1_set_inter_measurement_period_ms). */
int l1_set_imp(l1_t *d, uint32_t ms)
{
    if (ms < d->budget_us / 1000u + L1_IMP_GUARD_MS)
        return 1;
    return l1_wr32(d, L1_IMP, ms * d->osc_cal) ? 2 : 0;
}

/* ── ROI: номер SPAD центра — как VL53L1_encode_row_col (y — строка, x — столбец) ── */
uint8_t l1_spad(uint8_t x, uint8_t y)
{
    if (y > 7u)
        return (uint8_t)(128u + (x << 3) + (15u - y));
    return (uint8_t)(((15u - x) << 3) + y);
}

/* Прямоугольник w×h SPAD с левым нижним углом (x0, y0); центр — как в VL53L1_SetUserROI. */
int l1_set_roi(l1_t *d, uint8_t x0, uint8_t y0, uint8_t w, uint8_t h)
{
    uint8_t b[2];
    if (w < 4u || h < 4u || x0 + w > 16u || y0 + h > 16u)
        return 1;
    b[0] = l1_spad((uint8_t)(x0 + w / 2u), (uint8_t)(y0 + h / 2u));
    b[1] = (uint8_t)(((h - 1u) << 4) | (w - 1u));
    return l1_wr(d, L1_ROI_CENTRE, b, 2) ? 2 : 0;       /* 0x7F и 0x80 подряд */
}

/* Прерывание по окну дальности или на каждое измерение. Пороги — в 0x72 (верхний) и 0x74 (нижний). */
int l1_set_window(l1_t *d, l1_win_t win, uint16_t low_mm, uint16_t high_mm)
{
    uint8_t b[4] = {(uint8_t)(high_mm >> 8), (uint8_t)high_mm, (uint8_t)(low_mm >> 8), (uint8_t)low_mm};
    if (win != L1_EVERY && l1_wr(d, L1_THRESH_HIGH, b, 4))
        return 1;
    return l1_wr8(d, L1_INT_CONFIG, (uint8_t)win) ? 2 : 0;
}

/* ── измерения ── */
static int wait_irq(l1_t *d)
{
    while (!d->irq)
        if (!l1_idle())
            return 1;
    d->irq = 0;
    return 0;
}

int l1_start(l1_t *d, uint8_t mode)
{
    uint8_t b[2] = {0x01u, mode};                       /* 0x86 — снять прерывание, 0x87 — пуск, одной записью */
    d->irq = 0;
    return l1_wr(d, L1_INT_CLEAR, b, 2);
}

int l1_stop(l1_t *d)
{
    return l1_wr8(d, L1_MODE_START, 0x80u);             /* ABORT — так останавливает API */
}

int l1_clear(l1_t *d)
{
    return l1_wr8(d, L1_INT_CLEAR, 0x01u);
}

/* Код датчика → статус API (SetSimpleData и ConvertStatusLite): первое измерение после пуска (счётчик 0)
 * обёртку фазы не проверяло — статус 6, а не 0. */
static uint8_t api_status(uint8_t raw, uint8_t stream)
{
    switch (raw) {
    case 1: case 2: case 3: case 17: return 5;      /* аппаратный сбой */
    case 13: return 13;                             /* ROI вне матрицы */
    case 18: return 10;                             /* синхронизация */
    case 9: return stream == 0 ? 6 : 0;             /* годно; без проверки обёртки — 6 */
    case 6: return 1;                               /* σ выше предела */
    case 4: return 2;                               /* сигнал слабый */
    case 8: return 3;                               /* обрезано по минимальной дальности */
    case 5: return 4;                               /* фаза вне окна */
    case 7: return 7;                               /* фазы A и B не сходятся — обёртка */
    case 12: return 9;                              /* сигнал не выше помехи стекла */
    default: return 255;
    }
}

int l1_read(l1_t *d, l1_meas_t *m)
{
    uint8_t r[17];
    if (l1_rd(d, L1_RESULT, r, sizeof r))
        return 1;
    m->raw = r[0] & 0x1Fu;
    m->stream = r[2];
    m->status = api_status(m->raw, m->stream);
    m->spads = (uint16_t)((r[3] << 8) | r[4]);
    m->ambient = (uint16_t)((r[7] << 8) | r[8]);
    m->sigma = (uint16_t)((r[9] << 8) | r[10]);
    m->mm = (int16_t)((r[13] << 8) | r[14]);
    m->signal = (uint16_t)((r[15] << 8) | r[16]);   /* сигнал после вычета помехи стекла */
    if (m->status == 0 && m->mm < -30)              /* как API: ниже −30 мм — недостоверно, до −30 — ноль */
        m->status = 14;
    else if (m->status == 0 && m->mm < 0)
        m->mm = 0;
    return 0;
}


/* Калибровка смещения — как VL53L1_PerformOffsetSimpleCalibration: мишень на известной дальности, первое
 * измерение выбросить, из годных взять среднее; смещение = дальность мишени − среднее. */
int l1_cal_offset(l1_t *d, uint16_t target_mm, uint8_t n, int16_t *ofs)
{
    int32_t sum = 0;
    uint32_t got = 0, tries = 0;
    l1_meas_t m;
    if (l1_wr16(d, L1_OFFSET_MM, 0) || l1_wr16(d, L1_INNER_OFFSET_MM, 0) || l1_wr16(d, L1_OUTER_OFFSET_MM, 0))
        return 1;
    if (l1_start(d, 0x20u) || wait_irq(d) || l1_clear(d))   /* первое прерывание — мимо */
        return 2;
    while (got < n && tries < n + n / 2u) {
        if (wait_irq(d) || l1_read(d, &m) || l1_clear(d))
            return 3;
        tries++;
        if (m.status == 0) {
            sum += m.mm;
            got++;
        }
    }
    l1_stop(d);
    if (got < 1u + n / 2u)
        return 4;
    *ofs = (int16_t)((int32_t)target_mm - (sum + (int32_t)got / 2) / (int32_t)got);
    if (l1_wr16(d, L1_OUTER_OFFSET_MM, (uint16_t)*ofs) || l1_wr16(d, L1_OFFSET_MM, (uint16_t)(*ofs * 4)))
        return 5;
    return 0;
}

/* Калибровка помехи стекла — как SingleTargetXTalkCalibration в API: мишень на известной дальности, n измерений
 * без компенсации; помеха на SPAD = сигнал / SPAD · (1 − измерено / мишень). В 0x16 — кило-отсчёты в 7.9. */
int l1_cal_xtalk(l1_t *d, uint16_t target_mm, uint8_t n, uint16_t *kcps)
{
    uint32_t sum_mm = 0, sum_sig = 0, sum_sp = 0, got = 0, sig, sp, mm;
    l1_meas_t m;
    if (l1_wr16(d, L1_XTALK_KCPS, 0) || l1_wr32(d, L1_XTALK_X_GRAD, 0))
        return 1;
    if (l1_start(d, 0x20u) || wait_irq(d) || l1_clear(d))   /* первое измерение не в счёт */
        return 2;
    for (uint8_t i = 0; i < n; i++) {
        if (wait_irq(d) || l1_read(d, &m) || l1_clear(d))
            return 3;
        if (m.status == 0 && m.mm > 0) {
            sum_mm += (uint32_t)m.mm;
            sum_sig += m.signal;
            sum_sp += m.spads >> 8;
            got++;
        }
    }
    l1_stop(d);
    if (!got)
        return 4;
    sig = sum_sig / got;
    sp = (sum_sp + got / 2u) / got;
    mm = (sum_mm + got / 2u) / got;
    *kcps = 0;
    if (sp && mm < target_mm)                           /* MCPS 9.7 → кило-отсчёты 7.9: · 1000 · 512 / 128 */
        *kcps = (uint16_t)((sig * 4000u / sp) * (target_mm - mm) / target_mm);
    return l1_wr16(d, L1_XTALK_KCPS, *kcps) ? 5 : 0;
}

/* «Сканирование» 16 зонами 4×4 SPAD, построчно сверху вниз: ROI следующей зоны пишется, пока датчик ждёт
 * снятия прерывания, — следующее измерение начнётся уже с ней. */
int l1_scan(l1_t *d, int16_t mm[16], uint8_t st[16])
{
    l1_meas_t m;
    if (l1_set_roi(d, 0, 12, 4, 4) || l1_start(d, 0x20u))
        return 1;
    for (uint8_t z = 0; z < 16u; z++) {
        uint8_t n = (uint8_t)((z + 1u) & 15u);
        if (wait_irq(d) || l1_read(d, &m))
            return 2;
        mm[z] = m.mm;
        st[z] = m.status;
        if (l1_set_roi(d, (uint8_t)(4u * (n & 3u)), (uint8_t)(12u - 4u * (n >> 2)), 4, 4) || l1_clear(d))
            return 3;
    }
    return l1_stop(d) ? 4 : 0;
}

fw100e.c — прошивка F103: I2C1 на 400 кГц, XSHUT и GPIO1 трёх датчиков, EXTI1…EXTI3, главный цикл.

/* fw100e.c — раздел 100г: три VL53L1X на одной шине I2C1 STM32F103 (BluePill), 72 МГц.
 * I2C1: PB6 — SCL, PB7 — SDA, 400 кГц, как в 100б. XSHUT — PB12, PB13, PB14, открытый сток; GPIO1 — PA1, PA2, PA3,
 * входы с подтяжкой, EXTI1…EXTI3 по спаду: драйвер ставит GPIO1 активным нулём (0x30 = 0x11).
 * Датчики по одному получают адреса 0x30, 0x31, 0x32, режим Long, бюджет 33 мс, измерение по таймеру раз в 100 мс.
 * Тактирование до 72 МГц — как в разделе 6 (здесь не повторяется); APB1 = 36 МГц. */
#include "stm32f10x.h"
#include "tof100e.h"

#define CPU_HZ 72000000u
#define APB1_MHZ 36u
#define I2C_FAST_CCR (APB1_MHZ * 1000000u / (3u * 400000u))   /* F/S = 1, DUTY = 0: Tlow = 2 Thigh */
#define I2C_FAST_TRISE (APB1_MHZ * 300u / 1000u + 1u)         /* фронт до 300 нс */
#define I2C_SPIN 20000u                                        /* предел ожидания флага, витков */
#define BUDGET_MS 33u
#define PERIOD_MS 100u

_Static_assert(I2C_FAST_CCR == 30u, "CCR = 30: 36 МГц / (3 · 30) = 400 кГц");
_Static_assert(I2C_FAST_TRISE == 11u, "TRISE = 11");
_Static_assert(PERIOD_MS >= BUDGET_MS + L1_IMP_GUARD_MS, "период по таймеру — не меньше бюджета + 4 мс");

static l1_t T[3];
static const uint8_t ADDR[3] = {0x30u, 0x31u, 0x32u};
volatile int16_t dist_mm[3];                   /* последняя годная дальность; отрицательное — шаг, где встали */
volatile uint8_t last_status[3];

/* ── I2C1, ведущий, опросом; на каждом ожидании — предел ── */
static int wait1(uint32_t mask)
{
    for (uint32_t n = 0; n < I2C_SPIN; n++) {
        uint32_t sr = I2C1->SR1;
        if (sr & I2C_SR1_AF) {                 /* нет подтверждения */
            I2C1->SR1 = (uint16_t)~I2C_SR1_AF;
            return 1;
        }
        if (sr & mask)
            return 0;
    }
    return 2;
}

static void stop(void)
{
    I2C1->CR1 |= I2C_CR1_STOP;
    while (I2C1->CR1 & I2C_CR1_STOP) {
    }
}

static int start(uint8_t a8)
{
    I2C1->CR1 |= I2C_CR1_START;
    if (wait1(I2C_SR1_SB))
        return 1;
    I2C1->DR = a8;
    if (wait1(I2C_SR1_ADDR)) {
        stop();
        return 1;
    }
    return 0;
}

static int send(const uint8_t *buf, uint32_t n)
{
    (void)I2C1->SR1;
    (void)I2C1->SR2;                           /* ADDR снят: SR1, затем SR2 (17.4) */
    for (uint32_t i = 0; i < n; i++) {
        if (wait1(I2C_SR1_TXE))
            return 1;
        I2C1->DR = buf[i];
    }
    return n ? wait1(I2C_SR1_BTF) : 0;
}

int l1_i2c_write(uint8_t addr7, const uint8_t *buf, uint32_t n)
{
    if (start((uint8_t)(addr7 << 1)))
        return 1;
    int e = send(buf, n);
    stop();
    return e;
}

/* Два байта адреса регистра, повторный старт, приём по RM0008: один байт — ACK снять до снятия ADDR; два — POS;
 * больше двух — последние три через BTF, ACK снять перед предпоследним. */
int l1_i2c_wr_rd(uint8_t addr7, const uint8_t *wr, uint32_t nw, uint8_t *buf, uint32_t n)
{
    if (start((uint8_t)(addr7 << 1)))
        return 1;
    if (send(wr, nw) || start((uint8_t)((addr7 << 1) | 1u))) {
        stop();
        return 1;
    }
    int e = 0;
    if (n == 1) {
        I2C1->CR1 &= ~I2C_CR1_ACK;
        (void)I2C1->SR1;
        (void)I2C1->SR2;
        I2C1->CR1 |= I2C_CR1_STOP;
        e = wait1(I2C_SR1_RXNE);
        buf[0] = (uint8_t)I2C1->DR;
    } else if (n == 2) {
        I2C1->CR1 |= I2C_CR1_POS;
        I2C1->CR1 &= ~I2C_CR1_ACK;
        (void)I2C1->SR1;
        (void)I2C1->SR2;
        e = wait1(I2C_SR1_BTF);
        I2C1->CR1 |= I2C_CR1_STOP;
        buf[0] = (uint8_t)I2C1->DR;
        buf[1] = (uint8_t)I2C1->DR;
    } else {
        (void)I2C1->SR1;
        (void)I2C1->SR2;
        uint32_t i = 0;
        for (; i < n - 3u && !e; i++) {
            e = wait1(I2C_SR1_RXNE);
            buf[i] = (uint8_t)I2C1->DR;
        }
        e |= wait1(I2C_SR1_BTF);               /* N-2 в DR, N-1 в сдвиговом */
        I2C1->CR1 &= ~I2C_CR1_ACK;
        buf[i++] = (uint8_t)I2C1->DR;
        I2C1->CR1 |= I2C_CR1_STOP;
        buf[i++] = (uint8_t)I2C1->DR;
        e |= wait1(I2C_SR1_RXNE);
        buf[i] = (uint8_t)I2C1->DR;
    }
    while (I2C1->CR1 & I2C_CR1_STOP) {
    }
    I2C1->CR1 = (I2C1->CR1 & ~I2C_CR1_POS) | I2C_CR1_ACK;
    return e;
}

/* ── время, выводы, сон ── */
void l1_delay_us(uint32_t us)
{
    uint32_t t0 = DWT->CYCCNT, n = us * (CPU_HZ / 1000000u);
    while (DWT->CYCCNT - t0 < n) {
    }
}

void l1_xshut(uint8_t idx, int run)
{
    if (run)
        GPIOB->BSRR = 1u << (12u + idx);       /* открытый сток: 1 — отпустить */
    else
        GPIOB->BRR = 1u << (12u + idx);
}

static int any_irq(void)
{
    return T[0].irq | T[1].irq | T[2].irq;
}

/* Проверить флаги и уснуть атомарно: прерывание между проверкой и WFI всё равно разбудит. */
int l1_idle(void)
{
    __disable_irq();
    if (!any_irq())
        __WFI();
    __enable_irq();
    return 1;
}

void EXTI1_IRQHandler(void)
{
    EXTI->PR = EXTI_PR_PR1;
    T[0].irq = 1;
}

void EXTI2_IRQHandler(void)
{
    EXTI->PR = EXTI_PR_PR2;
    T[1].irq = 1;
}

void EXTI3_IRQHandler(void)
{
    EXTI->PR = EXTI_PR_PR3;
    T[2].irq = 1;
}

static void board_init(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN | RCC_APB2ENR_AFIOEN;
    RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;

    /* PB6, PB7 — альтернативная функция, открытый сток, 50 МГц (11 11) */
    GPIOB->CRL = (GPIOB->CRL & ~0xFF000000u) | 0xFF000000u;
    /* PB12…PB14 — выход с открытым стоком, 2 МГц (01 10); сначала 0 — все датчики в сбросе */
    GPIOB->BRR = 7u << 12;
    GPIOB->CRH = (GPIOB->CRH & ~0x0FFF0000u) | 0x06660000u;
    /* PA1…PA3 — вход с подтяжкой (10 00), ODR = 1 — к питанию */
    GPIOA->CRL = (GPIOA->CRL & ~0x0000FFF0u) | 0x00008880u;
    GPIOA->BSRR = 7u << 1;

    I2C1->CR1 = I2C_CR1_SWRST;
    I2C1->CR1 = 0;
    I2C1->CR2 = APB1_MHZ;
    I2C1->CCR = I2C_CCR_FS | I2C_FAST_CCR;
    I2C1->TRISE = I2C_FAST_TRISE;
    I2C1->CR1 = I2C_CR1_PE | I2C_CR1_ACK;

    /* EXTI1…3 от порта A (AFIO_EXTICR1 = 0 после сброса), по спаду */
    AFIO->EXTICR[0] &= ~(AFIO_EXTICR1_EXTI1 | AFIO_EXTICR1_EXTI2 | AFIO_EXTICR1_EXTI3);
    EXTI->FTSR |= EXTI_FTSR_TR1 | EXTI_FTSR_TR2 | EXTI_FTSR_TR3;
    EXTI->PR = EXTI_PR_PR1 | EXTI_PR_PR2 | EXTI_PR_PR3;
    EXTI->IMR |= EXTI_IMR_MR1 | EXTI_IMR_MR2 | EXTI_IMR_MR3;
    NVIC_EnableIRQ(EXTI1_IRQn);
    NVIC_EnableIRQ(EXTI2_IRQn);
    NVIC_EnableIRQ(EXTI3_IRQn);
}

static int bring_up(uint8_t i)
{
    int rc = l1_boot(&T[i], i, ADDR[i]);
    if (!rc)
        rc = l1_init(&T[i]);
    if (!rc && l1_set_budget(&T[i], BUDGET_MS))
        rc = 20;
    if (!rc && l1_set_imp(&T[i], PERIOD_MS))
        rc = 21;
    if (!rc && l1_start(&T[i], 0x40u))
        rc = 22;
    return rc;
}

int main(void)
{
    board_init();
    l1_sleep_all(3);
    for (uint8_t i = 0; i < 3; i++) {
        int rc = bring_up(i);
        if (rc)
            l1_xshut(i, 0);                    /* не ответил — обратно в сброс, чтобы не занял 0x29 */
        dist_mm[i] = (int16_t)(rc ? -rc : 0);
    }
    for (;;) {
        l1_idle();
        for (uint8_t i = 0; i < 3; i++) {
            l1_meas_t m;
            if (!T[i].irq)
                continue;
            T[i].irq = 0;
            if (l1_read(&T[i], &m) || l1_clear(&T[i]))
                continue;
            last_status[i] = m.status;
            if (m.status == 0)                 /* 6 — первое после пуска, без проверки обёртки: мимо */
                dist_mm[i] = m.mm;
        }
    }
}

/* ── вектора и запуск (armclang + microlib): __main заполняет .data и .bss и вызывает main ── */
#define STACK_TOP 0x20005000u                  /* конец 20 КБ ОЗУ F103C8 */
__asm(".global __initial_sp\n.set __initial_sp, 0x20005000");   /* microlib берёт вершину стека отсюда */
extern void __main(void);

void Reset_Handler(void)
{
    __main();
}

static void Default_Handler(void)
{
    for (;;) {
    }
}

__attribute__((section("RESET"), used)) static void (*const vectors[16 + 43])(void) = {
    [0] = (void (*)(void))STACK_TOP, [1] = Reset_Handler,
    [2 ... 16 + 6] = Default_Handler,
    [16 + 7] = EXTI1_IRQHandler, [16 + 8] = EXTI2_IRQHandler, [16 + 9] = EXTI3_IRQHandler,
    [16 + 10 ... 16 + 42] = Default_Handler,
};
_Static_assert(EXTI1_IRQn == 7 && EXTI2_IRQn == 8 && EXTI3_IRQn == 9, "EXTI1…3 — прерывания 7…9");

Дальше: раздел 101 «Ток и напряжение: INA219 и ACS712» — шунт и калибровочный регистр, датчик Холла и его ноль посередине шкалы.

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