Как мы ускоряли прошивку ESP (спойлер: совы не то, чем кажутся)

от автора

Началось всё с жалобы от производства: модуль WB-MGE v.3 (шлюз Modbus↔Ethernet на ESP32) слишком долго прошивается на тестовом стенде, и это тормозит линию. Цифры были такие: модбас-часть прошивается за 9,3 секунды, а ESP-часть — ещё за 33. Итого 42 секунды на устройство.
Надо оптимизировать.

Главный герой статьи: плата с контроллером ESP32

У нас там ESP32-U4WDH, плюс CH343. У него 4 МБ встроенного (прямо в корпусе чипа) флеша, разбитого на три раздела: factory-загрузчик на 512 КБ (поднимает Wi-Fi/eth и позволяет обновить прошивку или сбросить настройки, даже если основная прошивка отказывается загружаться) плюс два OTA-раздела по 1728 КБ под основную прошивку — активный и резервный (плюс по мелочи служебные: 16 КБ NVS под настройки, 2×4 КБ для otadata и 4 КБ phy).

diagram.drawio.svg

ESP32 прошивается через UART… специфично. Обычно это делают через ROM-загрузчик поверх UART: там есть команда «принять блоб в оперативку (IRAM) и прыгнуть на него». Поэтому для прошивки там загружается маленькая программка, т. н. stub, с которой уже esptool общается по UART-у и пересылает ей данные, которые пишутся ей во флеш. Более того, по UART ходят ещё и сжатые данные — esptool сжимает их deflate (тот же, что в zip и gzip), а стаб разжимает на лету, на тот случай, если вы пересылаете кучу нулей. Там, конечно, есть --no-stub, который пишет напрямую, но на моей памяти им никто не пользовался.

Замер базового уровня: пять вызовов, ~33 секунды

Теперь сам baseline — как пишет стенд: на каждый модуль идёт пять отдельных вызовов esptool подряд, все на 921600:

1. verify_fuses (espefuse summary)                wall  2,7 с2. is_device_new (read_flash 32 КБ)               wall  3,5 с   (опц) erase_flash, если чип уже был прошит     wall 16,8 с   ← обычно пропускается3. factory (522064 B → 0x0)                       wall  8,6 с4. main    (1184304 B → 0x90000)                  wall 16,8 с5. otadata switch (8 КБ → 0xd000)                 wall  1,8 с────────────────────────────────────────────────────────────ИТОГО ESP-часть                                   wall ~33 с

Тут первый интересный сигнал: esptool сжимает прошивку перед отправкой, и основная прошивка (1,18 МБ) уходит по проводу как 839 КБ сжатого за 13 секунд (в таблице 16,8, но это вместе с накладными расходами, чистое время передачи 13 с) — это 64,5 КБ/с. А 921600 бод — это 92 КБ/с (делим на 10, а не на 8, потому что 8N1 — 8 бит, плюс стартовый и стоповый бит).

diagram.drawio.svg

То есть линия загружена лишь на ~70%, возможно, дело не в скорости передачи, надо бы проверить.

Ок, у нас есть otadata — служебный раздел 8 КБ (два сектора по 4 КБ) с записью, из какого OTA-слота грузиться: номер слота + счётчик + CRC, два сектора для надёжности при сбое питания. Он пишется 1,8 секунды. 8 КБ за 1,8 секунды. Как-то маловато, это скорость 4,4 КБ/с, а у нас образы плюс-минус на 65 КБ/с пишутся, разница больше, чем на порядок. Дело, скорее всего, не в скорости передачи, а в том, что залить прошивальщик, загрузить его и переключить скорость на рабочую (esptool стартует на дефолтных 115200, на них заливает stub, а потом командой переводит обе стороны на быстрый baud — это и есть «Changing baud rate… Changed» в логе) тоже занимает время.
Проверить чистое время коннекта можно, исполнив, например, команду flash_id — она подключается, заливает stub, читает идентификатор флеша (это сильно меньше 8 КБ) и перезагружает чип.

$ esptool.py --chip esp32 -p /dev/ttyACM0 -b 921600 flash_id...Uploading stub...Running stub...Stub running...Changing baud rate to 921600Changed....---- wall=1.813493421s ----

Ага, вот и понятно, что тут на самом деле мы тратим 1,8 с на каждый вызов, пять раз, а могли бы один. А сама запись 8 КБ в случае с otadata занимает пренебрежимо малое время — около 100 мс.

Можно, как минимум, шить сразу factory+main+otadata, esptool принимает несколько пар «адрес файл» в одной команде:

$ esptool.py --chip esp32 \-p /dev/ttyACM0 -b 921600 \--before=default_reset \--after=hard_reset  \write_flash \--flash_mode dio \--flash_freq 40m \--flash_size 4MB \0x0 factory_mge.bin \               ← сразу несколько блоков0x90000 mge_main.bin \              ← сразу несколько блоков0xd000 otadata_mainapp_ota0.bin     ← сразу несколько блоков

Оп, не получилось, «Detected overlap at address: 0xd000». esptool не умеет комбинировать перекрывающиеся файлы, они должны быть отдельными.

А у нас образ factory физически занимает диапазон 0x0..0x7F750, а это перекрывает 0xd000, куда пишется otadata. Можно было бы пересобрать factory без перекрытия, но проще собрать единый плоский файл: вшить otadata-блок прямо внутрь factory-образа по смещению 0xd000, а main положить на 0x90000 с паддингом 0xFF между ними. Как-то так:

fac  = bytearray(open('factory_mge.bin','rb').read())   # 0x0..0x7F750ota  = open('otadata_mainapp_ota0.bin','rb').read()     # 8 КБmain = open('mge_main.bin','rb').read()fac[0xd000:0xd000+len(ota)] = ota                       # otadata внутрь factoryimg = bytearray(b'\xff' * 0x90000)img[0:len(fac)] = facimg += main                                             # main с 0x90000open('combined.bin','wb').write(img)                    # 1 774 128 B

Один вызов write_flash 0x0 combined.bin, и мы записываем и factory, и прошивку, и селектор, откуда грузиться.

Вариант

Время прошивки

+в мс по сравнению с предыдущим

+в процентах по сравнению с предыдущим

+в процентах по сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300

−10%

−10%

Baud почти не помогает

Итак, лишние вызовы убрали. Как бы нам ещё увеличить скорость прошивки? Может быть… увеличить скорость? Мы и так на 921600, но, возможно, надо быстрее?

baud

main, запись

эфф. скорость

921600

13,0 с

730 кбит/с

2 000 000

11,8 с

803 кбит/с

3 000 000

11,1 с

855 кбит/с

4 000 000

10,7 с

884 кбит/с

Нет, облом. До 2 Мбод ещё можно, а дальше не имеет смысла: рост baud в 4,3 раза (921600 → 4M) дал прирост скорости всего в 1,2 раза.

Для наглядности я прогнал варианты всех скоростей на маленьком куске данных (а то долго даже на 19200-то мегабайт слать):

На низких скоростях время уменьшается почти вдвое за каждое удвоение baud — тут ограничивает именно скорость потока. Но уже к 921600 кривая ложится на полку ~1–2 секунды: это уже потолок записи флеша, а не линии. Каждое удвоение baud экономит вдвое меньше предыдущего: 19200→38400 срезало 35 секунд, 460800→921600 — полторы, 2M→4M — доли секунды. Увеличивать больше ~2 Мбод почти бессмысленно.

Ну ок, ещё 5% выиграли.

Вариант

Время прошивки

+в мс по сравнению с предыдущим

+в процентах по сравнению с предыдущим

+в процентах по сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300

−10%

−10%

Скорость 2 000 000

28,3 с

−1850

−6%

−15%

Однако тут остаётся вопрос — а почему же скорость прошивки растёт, хоть и меньше, чем скорость канала? Если бы мы не упирались в канал, то уже на 921600 с его 70% утилизации мы бы должны были поймать потолок. А на замерах не подтверждается, даже после 2М скорость прошивки растёт, хоть и мало… Ладно, неважно, надо прыгать, а не думать.

Три тупика подряд: flash_freq, OTA, low-latency ttyACM

Не помогло: flash_freq 80m и QIO. Поднял только частоту флеша 40→80 МГц (--flash_freq 80m) — main как писался за 11,7 с, так и пишется. Сменил режим SPI dio→qio (--flash_mode qio) — те же 11,7 с. И обе вместе — те же 11,7. Ни одна, ни в паре ничего не дают.

Причина в том, что эти флаги правят лишь 8-байтный заголовок образа (как прошивка читает флеш в рантайме), но не трогают частоту, с которой stub пишет во время прошивки.

OTA по Ethernet. Ещё одна идея — что, если основную прошивку лить не по UART, а по сети? Типа, по UART шьём только factory-прошивку, она поднимает веб-интерфейс, и основную заливаем уже по сети через него:

>>> OTA http_code=200 size_up=1184304 time_total=12.785766s speed_up=92632B/s

12,8 секунды при 92,6 КБ/с. А по UART тот же main писался за 13,0 секунды — это ~91 КБ/с. Обе дороги сошлись к ~90 КБ/с. Мне кажется, не совпадение: и UART, и Ethernet упираются в одно и то же — и это точно скорость записи во флеш самого ESP32, прям сто пудов. OTA не быстрее, зато тащит за собой возню с сетью и перезагрузками. Мимо.

Ещё беглый гуглинг подкинул флаг ASYNC_LOW_LATENCY для /dev/ttyACM0 — у FTDI-мостов это срезает задержку с 16 мс до 1 мс и помогает. Проверил, можно ли его вообще выставить на нашем CH343:

TIOCSSERIAL ok                       → after (same open): LOW_LATENCY=Falsepyserial set_low_latency_mode: ok    → after pyserial:    LOW_LATENCY=False

Драйвер cdc_acm принимает ioctl, возвращает «ок», но флаг не хранит — даже в пределах одного открытия порта читается False. Ну, не особо надеялись, latency_timer — это фича FTDI.

Лирическое отступление: инкрементальная запись, круто, но не про нас

У esptool есть два механизма «не писать то, что уже записано».

--skip-flashed. Перед записью esptool просит стаб посчитать MD5 региона флеша и сравнивает с MD5 нового образа; совпало — запись пропускается целиком. MD5 считается со скоростью ~0,5 МБ/с, а read_flash ~0,2 МБ/с, так что проверка впятеро дешевле, чем запись нового. На повторной прошивке той же версии (при CI-прогонах, например, где возможен перезапуск стенда на уже прошитом модуле) --skip-flashed действительно сэкономит.
Но у нас с завода модуль пустой, а пишем мы (ну, как правило) туда не пустоту, а какую-то прошивку, поэтому пропуск не срабатывает и команда только зря тратит время на вычисление хеша.

--diff-with (differential / fast reflashing). Тут интереснее. Можно дать esptool файл со старой прошивкой — той, что предположительно уже во флеше, — он диффит его с новым образом по 4-КБ секторам и стирает-пишет только изменившиеся. Ключевое слово — «предположительно»: тут флеш читаться не будет, ни полностью, ни по md5, работа строится на вашей гарантии, что вы точно знаете содержимое прошлой записи и новой.
Правда, это предположение всё же проверяется после записи, так же, через MD5 всего нового образа. Не сошлось — значит, во флеше было что-то другое, и esptool запишет его полностью заново.
Но тоже не для нас. Мы содержимое чипа не знаем (ну, наверное, там будет 0xFF, но не всегда), и даже если принять, что у нас всегда 0xFF, то дифф у нас будет исключать, ну, возможно, несколько секторов с FF.

Любовь к трём загрузчикам

«Запись во флеш» — это не только сама память, но и SPI-контроллер и цикл записи, которые крутятся на CPU чипа. А на какой частоте работает CPU во время прошивки?
Нашёл тикет esptool #779: в загрузчике CPU во время прошивки крутился на ~20 МГц, и его разгон до максимума давал почти двукратное ускорение. Кажется, это рычаг? Пошли копаться в загрузчиках подробнее.

Сюрприз, загрузчиков ТРИ (и более того, --stub-version это скрытая опция в esptool, т. е. выбор загрузчика Espressif вообще не считает публичным API).

1) Легаси-версия, написана на Си, —stub-version 1, которая по умолчанию была в esptool до версии 5.3: https://github.com/espressif/esptool-legacy-flasher-stub
2) Новая версия esptool, написанная на Rust, —stub-version 2 (но только до версии 4.11, и у нас на стенде как раз она), https://github.com/esp-rs/esp-flasher-stub — сейчас заброшена
3) Новая версия esptool, переписанная с Раста на Си, по дефолту с версии 5.3, https://github.com/espressif/esp-flasher-stub

Т. е. в какой-то момент загрузчик переписали на Расте, а потом ещё раз переписали с Раста на Си.

Окей, го тестировать все три:

стаб

репозиторий

язык

Версии esptool

combined.bin

v1

espressif/esptool-legacy-flasher-stub

C

v4.0–v4.7.0

906 кбит/с · 15,7 с

v2-rs

esp-rs/esp-flasher-stub @ v0.3.0

Rust

v4.8.0–v4.11.0

997 кбит/с · 14,2 с

v2-c

espressif/esp-flasher-stub

C

v5.0.0–сейчас

938 кбит/с · 15,1 с

Круто, можно поменять загрузчик и получить −10% времени. У нас новый рекорд!

Вариант

Время прошивки

+в мс по сравнению с предыдущим

+в процентах по сравнению с предыдущим

+в процентах по сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300

−10%

−10%

Скорость 2 000 000

28,3 с

−1850

−6%

−15%

esp-flasher-stub (v2-rs)

25,4 с

−2900

−10%

−24%

Остаётся непонятным, правда, почему на свежем espressif/esp-flasher-stub это не воспроизводится. Снова лезем в код.

Ага, нашли! Вот для v2-rs:

clocks_ll::esp32_rtc_bbpll_enable();clocks_ll::esp32_rtc_bbpll_configure(xtal_freq, pll_freq);   // PLL 480M (для 240)clocks_ll::set_cpu_freq(cpu_clock_speed);...ClockControl {    _private: clock_control.into_ref(),    desired_rates: RawClocks {        cpu_clock: cpu_clock_speed.frequency(),        apb_clock: HertzU32::MHz(80),  // Частота APB 80 МГц (но это на самом деле просто декларация, насколько я понял, частота зависит от PLL)        xtal_clock: HertzU32::MHz(40),        i2c_clock: HertzU32::MHz(80),        ...    },}

А вот что в Си-версии:

//esp-stub-lib/src/target/esp32/src/clock.c:26-33 <==== камень-зависимая функция, конкретная реализация именно для нашего чипа#define CPU_FREQ_MHZ 160void stub_target_clock_init(void){    REG_SET_FIELD(RTC_CNTL_REG, RTC_CNTL_DIG_DBIAS_WAK, RTC_CNTL_DBIAS_1V25); ...    WRITE_PERI_REG(SPI_CLOCK_REG(0), 0);                                     // SPI-клок флеша = APB (быстрый)    REG_SET_FIELD(RTC_CNTL_CLK_CONF_REG, RTC_CNTL_SOC_CLK_SEL, 1U);          // SOC_CLK_SEL = PLL → APB 80 МГц}//esp-stub-lib/src/clock.cvoid stub_lib_clock_init(void){    stub_target_clock_init();    // Wait for the clocks to stabilize. Minimum delay is highly target-dependent    // (e.g. ESP32-P4 ECO7 needs more than other revisions).    stub_lib_delay_us(10000);}// stub_lib_clock_init() increases CPU frequency which benefits both USB and UART transfers.// Currently only enabled for USB transfers due to concerns about instability (observed on ESP32-S3),// because DBIAS voltage not being set. This needs investigation and potentially enabling for all transport types.if (transport == TRANSPORT_USB_OTG ||    transport == TRANSPORT_USB_SERIAL_JTAG ||    transport == TRANSPORT_SDIO) { // <===== но зовется эта функция не всегда    stub_lib_clock_init();}

Всё дело в настройке тактирования APB — это Advanced Peripheral Bus, шина периферии, на ней висит контроллер флеша, поэтому поднятие её частоты и ускоряет прошивку.

stub_lib_clock_init, который настраивает частоту APB, в Си-версии есть, но включён только для USB-транспортов, а для обычного UART (TRANSPORT_UART) — нет. А у нас внешний мост CH343, для самого ESP32 это обычный UART, и разгон не срабатывает, APB остаётся на базовой частоте. Причём в комментарии прямо написано, что выключили из-за нестабильности на ESP32-S3 «потому что не выставлен DBIAS» (напряжение питания ядра).
Смотрим в реализацию stub_target_clock_init для нашего ESP32 — а там DBIAS выставляется, всё нормально:

// esp-stub-lib/src/target/esp32/src/clock.c#define CPU_FREQ_MHZ 160void stub_target_clock_init(void) {    REG_SET_FIELD(RTC_CNTL_REG, RTC_CNTL_DIG_DBIAS_WAK, RTC_CNTL_DBIAS_1V25);  // <==== DBIAS выставлен    ...}

Ок, давайте включим stub_lib_clock_init и для UART-транспорта:

- if (transport == TRANSPORT_USB_OTG || transport == TRANSPORT_USB_SERIAL_JTAG || transport == TRANSPORT_SDIO) {+ if (transport == TRANSPORT_USB_OTG || transport == TRANSPORT_USB_SERIAL_JTAG || transport == TRANSPORT_SDIO || transport == TRANSPORT_UART) {    stub_lib_clock_init();}

Упс, лоадер загружается, но флеш после не прошивается.

A fatal error occurred: Invalid head of packet (0xBC): Possible serial noise or corruption.

От APB тактуется не только SPI для флеша, но и UART, и мы только что подняли его скорость вдвое, никого не предупредив. USB-транспорты этим не страдают — их тактирование от APB не зависит, потому им норм.
Починка тривиальна: сразу после разгона пересчитать делитель под текущую скорость рукопожатия (115200) уже от новой частоты APB — в stub-библиотеке для этого есть готовая stub_lib_uart_rominit_set_baudrate, она берёт актуальный APB и считает делитель сама. Дальше рабочий baud (2–4M) esptool выставит уже сам, тоже от новой частоты, так что всё сойдётся. Просто дописываем ещё проверку:

if (transport == TRANSPORT_USB_OTG || transport == TRANSPORT_USB_SERIAL_JTAG || transport == TRANSPORT_SDIO) {    stub_lib_clock_init();}+if (transport == TRANSPORT_UART) {+    stub_lib_clock_init();+    stub_lib_uart_rominit_set_baudrate(UART_NUM_0, 115200); +}

А почему в Раст-версии всё работало? А там другая последовательность просто:

fn change_baud_internal(&self, baudrate, clock_source, clocks: &Clocks) {    let clk = match clock_source {        ClockSource::Apb  => clocks.apb_clock.to_Hz(),   // = 80_000_000 (уже разогнанный)        ClockSource::Xtal => clocks.xtal_clock.to_Hz(),        ...    };    ...    let divider = (clk << 4) / (baudrate * clk_div);     // тот же (clk<<4)/baud, что и в C-стабе}

change_baud_internal, во-первых, считает уже сам, а во-вторых, зовётся уже после инициализации и настройки частоты, так что автоматом получает нужные данные о частоте.

Ладно, что там со скоростью?

Вариант

Время всей прошивки

По сравнению с прошлым рекордом

По сравнению с прошлым рекордом

По сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300 мс

−10%

−10%

Скорость 2 000 000

28,3 с

−1850 мс

−6%

−15%

esp-flasher-stub (v2-rs)

25,4 с

−2900 мс

−10%

−24%

esp-flasher-stub (v2-с) без патча

25,8 с

−1900 мс

+1,6%

−23%

esp-flasher-stub (v2-с) с патчем

23,5 с

−2300 мс

−9%

−30%

Так, ещё выиграли ощутимо скорости, правда, ценой патча. Заодно, спустя 4 года, сделали «needs investigation» из того тикета 2022 года.

Патч — это не так страшно (в одном из других проектов мы форкнули qemu, например), но всё же дополнительные сложности по поддержке. Впрочем, судя по тому, что до последнего времени мы спокойно сидели на очень старой легаси-версии, часто обновлять загрузчик мы не будем, а значит, и тратить время на синхронизацию с мейнлайном тоже. Достаточно не забыть, что же именно мы делали (а думаете, зачем я пишу эту статью?)

Новая идея: размер блока

У нас обмен идёт блоками, пишем блок, отдаём прошивальщику, он шьёт, как заканчивает, пишем ещё. На самом деле там два буфера, и пока один шьётся (это медленно, и нет необходимости в процессоре), второй пишется. Но блоки всё равно есть, а на блоки есть накладные расходы. А если увеличить блок, то может быть, получится уменьшить эти расходы? esptool шлёт сжатые данные блоками по 16 КБ, каждый подтверждается. Для нашего образа это ~50 round-trip по USB. Удвоим блок до 32 КБ — будет 25 вместо 50.

Полез менять размер буфера в stub (FRAME_BUFFER_SIZE) — и при 64 КБ сборка упала:

ld: section `.bss' will not fit in region `dram'ld: region `dram' overflowed by 93756 bytes

Оперативка, доступная stub во время прошивки, намеренно небольшая — окно 80 КБ, чтобы гарантированно работать на любых чипах (у нас там ниже, например, BT-bss, который stub-у не нужен, но всё же). А буфер ещё и двойной, так что 64-КБ блок требует ~128 КБ. А ещё есть ограничение в протоколе самого esptool, и 64-КБ блок мы физически не можем в нём переслать за один раз. Но можем 63 КБ!

Правда, пришлось править ld-файл, чтобы расширить окно DRAM stub, и мы всё-таки залезли на BT-bss, но кому он нужен в лоадере, правильно? Собираем, тестируем:

Вариант

Время всей прошивки

Мс по сравнению с предыдущим рекордом

Проценты по сравнению с предыдущим рекордом

Проценты по сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300

−10%

−10%

Скорость 2 000 000

28,3 с

−1850

−6%

−15%

esp-flasher-stub (v2-rs)

25,4 с

−2900

−10%

−24%

esp-flasher-stub (v2-с) с патчем APB

23,5 с

−2300

−9%

−30%

esp-flasher-stub (v2-с) с патчем APB и блоком 32 КБ

22,98 с

−520

−2,2%

−31%

esp-flasher-stub (v2-с) с патчем APB и блоком 48 КБ

23,02 с

+40 (по сравнению с 32 КБ)

+0,2%

−31%

esp-flasher-stub (v2-с) с патчем APB и блоком 63 КБ

22,91 с

−70 (тоже по сравнению с 32 КБ)

−0,3%

−31%

Интересно. В блоке 32 КБ есть какой-то смысл, но дальше уже особой разницы нет, причём 48 КБ медленнее, чем 32, но 63 КБ быстрее, чем 32. Это не шум, мы гоняли его полчаса (5,41σ, епта), и это довольно стабильно. Какие-то спецэффекты, связанные с границами блоков у флеша, может, хз. Чуть покопались на тему не выравнивание ли это по границам блоков, оказалось, что нет. Ну лан.

Несколько вызовов

Ладно, где ещё можно подрезать секунд? Вот у нас естьerase_flash — на стенде иногда перешиваются уже зашитые устройства, и их стоит стирать полностью. Сколько оно стоит?

$ esptool.py --chip esp32 -b 2000000 --port /dev/ttyACM0 erase_flashChip erase completed successfully in 14.6 seconds.---- wall=16.794385196s ----

16,8 секунды, дофига. Возможность сэкономить? Нет, мы уже проверяем, нулевое устройство или нет, и стираем только те, в которых уже было что-то записано.

Но! Сам вызов is_device_new, который читает 32 КБ флеша, стоит 3,5 с, и большая часть этого времени — накладные расходы на загрузку-запуск лоадера.

Если отказаться от вызова esptool через ОС — щас, там, по сути, из питон-скрипта зовётся esptool (который тоже питон), и который на каждый свой вызов загружает stub, делает что нужно, а потом перезагружает контроллер, то можно сэкономить дофига времени. Но для этого надо перейти на прямые вызовы методов, как внутри самого esptool, чтобы сначала загрузить stub, потом прочитать фьюзы, потом прочитать память, потом загрузить флеш (тут мы, кстати, можем избавиться от combined-варианта и шить разные файлы, потому что всё равно это будет в рамках одной сессии), и в конце уже перезагрузиться.

Было у нас как-то так (код условный):

def _esptool(port, *args):    cmd = (f"esptool.py --chip esp32 -p {port} -b 921600 "           f"--before=default_reset --after=hard_reset " + " ".join(args))    return run_command(cmd, "Работа esptool...", timeout=180, use_spinner=True)def verify_fuses(port):    """Отдельный процесс espefuse — свой коннект."""    return run_command(f"espefuse.py summary -p {port} --chip {CHIP}", timeout=30)def is_device_new(port, tmp_path):    """Отдельный процесс esptool — читает 32 КБ, свой коннект."""    _esptool(port, "read_flash", "0x1000", "0x8000", tmp_path)    return all(b == 0xFF for b in open(tmp_path, "rb").read())def flash_image(port, offset, path):    """Отдельный процесс esptool на каждый образ — свой коннект и своё стирание."""    _esptool(port, "write_flash",             "--flash_mode", "dio", "--flash_freq", "40m",             "--flash_size", "4MB", offset, path)def flash_complete_firmware(port, factory_file, ota_file, otadata_file):    """Как сейчас на стенде: пять отдельных запусков = пять коннектов."""    verify_fuses(port)                                  # 1) коннект: фьюзы    if not is_device_new(port, tempfile.mktemp(".bin")):  # 2) коннект: чтение 32 КБ        _esptool(port, "erase_flash")                   # (+ коннект, только если б/у)    flash_image(port, "0x0",     factory_file)          # 3) коннект: factory    flash_image(port, "0x90000", ota_file)              # 4) коннект: основная прошивка    flash_image(port, "0xd000",  otadata_file)          # 5) коннект: otadata (выбор ota_0)

А можно сделать вот так:

import zlib, hashlib, esptoolfrom esptool.targets.esp32 import ESP32StubLoaderdef flash_complete_firmware_oneshot(self, factory_file, ota_file, otadata_file):    ESP32StubLoader.FLASH_WRITE_SIZE = 0x4000  # размер блока, который принимает stub        files = [        (0x00000, factory_file), # Порядок важен: сначала factory, otadata — последней.         (0x90000, main_file),     # otadata по адресу 0xd000 лежит внутри диапазона factory         (0x0d000, otadata_file), # и должна перезаписать его, чтобы выбрать слот ota_0.    ]    # --- открываем линк один раз: reset + detect + заливка stub + устанавливаем скорость ---    esp = esptool.detect_chip(self.port, connect_attempts=3)    esp = esp.run_stub()                 # залить и запустить stub-прошивальщик один раз    esp.change_baud(2_000_000)           # 2 Мбод вместо 921600    EFUSE_BASE = 0x3FF5A000    fuses = [esp.read_reg(EFUSE_BASE + off) for off in range(0, 0x80, 4)] #verify_fuses по тому же коннекту    self._check_fuses(fuses)            head = esp.read_flash(0x1000, 0x8000, None) # is_device_new по тому же коннекту    if not all(b == 0xFF for b in head):        esp.erase_flash()                # если модуль уже шился, то стираем, как и раньше    # --- пишем все три образа в одной сессии ---    for addr, path in files:        img = open(path, "rb").read()        if len(img) % 4: img += b"\xff" * (4 - len(img) % 4)          comp = zlib.compress(img, 9) #запаковываем вручную        esp.flash_defl_begin(len(img), len(comp), addr)         seq, buf = 0, comp        while buf:            esp.flash_defl_block(buf[:ESP32StubLoader.FLASH_WRITE_SIZE], seq) #указываем что блок сжатый            buf = buf[ESP32StubLoader.FLASH_WRITE_SIZE:]            seq += 1        if esp.flash_md5sum(addr, len(img)) != hashlib.md5(img).hexdigest():            raise ESP32FlashingError(f"MD5 ERROR @ {addr:#x}")    esp.flash_defl_finish(reboot=False)  # выходим из deflate-режима один раз, в конце    esp.hard_reset()                     # перезагружаемся, прошивка закончена

Иииии, получаем аж почти 7 секунд выигрыша!

Вариант

Время всей прошивки

Мс по сравнению с предыдущим рекордом

Проценты по сравнению с предыдущим рекордом

Проценты по сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300

−10%

−10%

Скорость 2 000 000

28,3 с

−1850

−6%

−15%

esp-flasher-stub (v2-rs)

25,4 с

−2900

−10%

−24%

esp-flasher-stub (v2-с) с патчем APB

23,5 с

−2300

−9%

−30%

esp-flasher-stub (v2-с) с патчем APB и блоком 32 КБ

22,98 с

−520

−2,2%

−31%

verify_fuses + blank-check одной сессией

16,2 с

−6780

−30%

−51%

Вот и сказочке конец, а кто слушал — молодец. Мы тоже молодцы и сделали хорошо производству, сократив время прошивки одного устройства в два раза. На самом деле даже больше, потому что там последовательно шьётся STM-контроллер, а потом ESP, и делая это параллельно, мы экономим аж 9 с, что превышает наш выигрыш тут, но они суммируются, так что мы всё равно молодцы. Всем спасибо, все свободны.

А может быть, всё было совсем наоборот?

Я, конечно, задачу закрыл, отчитался, всё хорошо. Но мне покою не давала одна мысль: а почему, если мы упёрлись в скорость записи флеша, скорость записи всё-таки растёт при росте скорости передачи? И почему она превышает, хоть и немного, в этом случае скорость OTA (у которой скорость передачи вообще с огромным запасом), хотя по идее при таком раскладе OTA должна была уткнуться в скорость флеша?
И наше «Обе дороги сошлись к ~90 КБ/с, и UART, и Ethernet упираются в одно и то же — и это точно скорость записи во флеш самого ESP32, прям сто пудов» — так ли оно на самом деле?

Давайте заново пройдёмся по цепочке.

diagram.drawio.svg

На каждом из этих уровней может быть задержка.
Например, на первом — это всякое уровня «запуск питон-скрипта». Измерил, ≈0,6 с однократно и всегда. Ну, не то чтобы кто-то надеялся.
На втором — та самая последовательность «ребутнуть-загрузить-согласовать» перед каждой загрузкой. Но тоже однократно и стабильно ≈1,8 с.
Третий — это мы на компе сжимаем прошивку. Это вообще смех и слезы, даже арм в WB8 жуёт с огромным запасом.
Транспорт — это один из подозреваемых. Эмуляция последовательного порта поверх USB (который сам по себе useless universal serial bus).
Разархивация — тоже подозреваемый. Процессор слабенький, ему может быть тяжело.
Накладные расходы внутри МК — это всякие переключения контекста, задержки, ожидания чего-нибудь.
Ну и запись во флеш — это уже непосредственно сама запись, флеш медленный, это ни для кого не секрет.

Что у нас есть на подозреваемых? Ну вот транспорт мы измерили.

Сначала время записи (синяя линия) уменьшается быстро, но потом всё меньше и меньше. Утилизация канала спадает, но тоже как-то вяленько. Вот, чтобы было понятнее, два графика для ситуаций, где у нас узкое место только флеш и только транспорт (обратите внимание, что время у нас на логарифмической шкале теперь, так нагляднее):

А у нас (серые графики) чёт ни то, ни се. Вроде и скорость передачи не даёт нормального ускорения, но вроде и влияет, хоть и немного. Причём, если смотреть на начальный этап, то с увеличением скорости кривая времени замечательно совпадает с идеальным случаем, когда упираемся в канал. Это у нас, по-прежнему, сумма «Транспорт → Разархивация → Накладные расходы МК → Запись во флеш»

Хорошо, как бы нам отделить теперь мух от котлет. Что мы можем убрать из цепочки? Транспорт и разархивацию — достаточно записать мегабайт нулей: они отлично сожмутся, их можно очень быстро передать и очень быстро разжать.

0x00@921600:  7,9 с  → ~132 КБ/с0x00@4M:      7,9 с  → ~133 КБ/с

Ага, пропала зависимость от скорости, значит, можно считать, что мы «выключили» транспорт и разархивацию. Вот эти 133 КБ/с — это то, как быстро МК может писать на флеш. Окей, а чистый рандом, который не сжимается?

random@921600: 14,7 сrandom@4M:      9,6 с

А вот тут есть разница. Значит, тут уже играет роль транспорт. Хорошо, у нас есть флаг --no-compress, который сжатие отключает:

рандом со сжатием @4M:    9,6 с рандом без сжатия @4M:    9,9 с 

Без сжатия даже чуть дольше. Ок, разархивацию из цепочки можно выкинуть, она не влияет. Транспорт пока исключить не получается, потому что без него (и без сжатия, но сжатие мы уже отбросили) мы получаем 7,9 с, и всё, что выше, — это что-то другое. Так же?

Ещё один эксперимент: давайте отправим не нули, а единички.

0xFF@4M:   6,0 с0x00@4M:   7,8 с

О-па. Интересно. Сжимается оно одинаково, размер один, транспорт исключён… Но 0xFF быстрее. Почему?

Есть два стула две гипотезы. Первая — это stub умный и пропускает после стирания FF-страницы, потому что они и так FF. Поэтому если писать FF, то мы только стираем, но не пишем. А если нули, то сначала стираем, а потом пишем.

Вторая гипотеза — это уже физика самого флеша, ну, т. е. в целом то же самое, но на уровне не stub-а, а микросхемы флеша. Дело в том, что микросхема не пишет единицы вообще. Никак. Не умеет. Писать можно только нули, а единицы — это то, куда не записали нули после стирания. Запись единиц микросхема флеша просто пропускает. А стирать можно только блоком.

Как можно эти гипотезы разделить? Можно сдвинуть данные. Страница у нас 256 Б (правда, блок стирания 4 КБ). Тогда мы делаем файл, в котором 0x00 и 0xFF будут чередоваться каждые 256 Б, измеряем, а потом начинаем его сдвигать, добавляя в начало байты. Можно их отрезать с конца, можно оставлять так, на мегабайтном файле влияние хвоста будет мало. Тогда у нас на выровненном файле должно быть какое-то среднее время записи, а на сдвинутом — полное, потому что пропускать мы можем только полную страницу. А если и там, и там среднее время — значит, оно зависит только от соотношения 0/1 и всё.

сдвиг 0:    6,9 ссдвиг 64:   6,9 ссдвиг 128:  6,9 с   (тут каждая страница наполовину FF, наполовину 00)сдвиг 192:  6,9 ссдвиг 256:  6,9 с

Идеально плоско, среднее время, значит, не stub, а сама запись. Нули записывать дорого, единицы достаются бесплатно, если мы стираем страницы.
Постойте, а если единицы достаются нам бесплатно, то почему запись FF у нас всё же занимает целых 6 секунд? Почему разница такая маленькая, всего 1,8 секунды? Неужели это именно так, как выглядит: стирание у нас занимает 6 секунд, а запись 50% нулей — 0,9 с?

Почему вообще стирание такое дорогое?

Оооооо. А тут мы погружаемся в кроличью нору, которая частично состоит из физики, а частично из экономики. Можно Iceberg Meme нарисовать.

И вот на нём то, что я рассказывал выше, про «пук-среньк, я не могу записать единицу» — будет где-то вверху, и оно, честно говоря, примерно как школьное «на ноль делить нельзя». Прихо́дите в универ — и оказывается, что можно. И корень из минус единицы тоже можно.

Вот нарисовали вам две картинки. Плавающий затвор (Floating gate) — это то, где хранится информация. Вокруг него изоляция, он, по сути, конденсатор, поэтому заряд в нём может держаться годами. Вопрос в том, как этот заряд а) туда доставить, б) оттуда убрать. Это два разных физических процесса.

Запись — это инжекция горячих электронов. На управляющий затвор транзистора подаётся напряжение побольше (~+9 В), на сток — поменьше (~+4 В). Транзистор открывается, электроны летят от истока к стоку, и некоторая их часть, разогнавшись, перепрыгивает барьер оксида и оседает на плавающем затворе. Электроны на плавающем затворе экранируют поле управляющего — теперь, чтобы открыть транзистор, нужно заметно большее напряжение, чем дают при чтении. Транзистор молчит — это 0, мы записали ноль.

КПД этого действа ужасен: до затвора долетает примерно один электрон на сотни тысяч пролетевших мимо. Зато процесс быстрый — микросекунды на байт. Расплата — большой ток (ну, относительно, сотни микроампер) через канал, а ток этот на повышенном напряжении генерирует встроенный на кристалле charge pump, у которого сил хватает только на несколько байт одновременно. Поэтому пишется быстро, но мелкими порциями, сразу много ячеек записать нельзя.

«Перезаписать» ячейку слишком сильно не страшно: чем больше электронов уже сидит на затворе, тем сильнее они отталкивают новые — процесс сам себя ограничивает. Ячейка просто надёжнее закрывается.

А вот стирание… Мы электронов добавили, а убрать как? У нас нет проводника, который можно прижать к земле, чтобы всё стекло. Поэтому применяется другой физический эффект под названием «туннелирование Фаулера–Нордхайма»: туннельный ток через очень тонкий изолятор (меньше 15 нанометров) резко, экспоненциально растёт при увеличении поля. Фишка в том, что поле мы можем создать снаружи, вообще не имея контакта с плавающим затвором: минус на word line, плюс на подложку (p-well) — и электроны туннелируют с плавающего затвора в подложку.

Ток тут не нужен почти совсем — наноамперы на ячейку, поэтому мы можем сразу зарядить и держать под полем дофига ячеек, т. е. стирать можно массово, в отличие от записи. Но у этого есть обратная сторона: сделать поле сильнее нельзя, потому что в какой-то момент оно пробьёт слой окисла, и он деградирует. Он и так деградирует от каждого цикла — именно поэтому у NOR-флеша ресурс ~100 тысяч циклов. Поэтому напряжённость поля при стирании держат не очень большой. И на таком поле ячейки стираются медленно, миллисекунды.

Второй нюанс в том… Блин, нет, сначала надо рассказать про чтение. Вся идея дешёвого флеша — сократить количество проводки. В NAND ячейки вообще вешают цепочкой на один провод (поэтому она и AND: для чтения бита надо открыть все остальные транзисторы в цепочке). У нас в NOR по-другому, но борьба та же: чем меньше проводников и транзисторов, тем больше места для памяти, а чем больше места для памяти, тем больше чипов с вафли и тем больше килобайт на чип. И вот тут для выбора ячейки на чтение и на запись используется один и тот же провод — word line. Для записи на него подаём напряжение побольше, для чтения — поменьше. Заряженная ячейка (0) при читающем напряжении остаётся закрытой — заряд плавающего затвора экранирует поле. Разряжённая (1) — открывается и проводит ток.

Так вот, второй нюанс: состояния «ноль электронов» не существует. Есть только «больше» и «меньше». Можно сказать «на затворе меньше», нельзя — «на затворе ноль». Следствие: затвор можно разрядить слишком сильно, стирание, в отличие от записи, само себе не ограничивает. Такая перестёртая ячейка становится открытой всегда — проводит даже без напряжения на word line. А ячейки одного столбца висят параллельно на общей битовой линии, и одна такая утечкой роняет чтение всего столбца.

Поэтому сильно разряжать нельзя, надо добиться паритета. Но ячейки-то разряжаются только пачкой — под ними натурально большой-большой электрод (p-well) на весь блок. А некоторые из них заряженные, а некоторые нет. И как нам не разрядить разряжённые ещё больше? Поэтому… Внимание… Первые ряды, я вижу, уже догадались? Поэтому разряжённые ячейки мы сначала ЗАРЯЖАЕМ (pre-program). А потом разряжаем все вместе. Более того, разряжаем несколькими импульсами с контролем «пока все ячейки точно не разрядятся, но не более того» — erase-verify. И более того: раз мы дожимали до самой упрямой ячейки, некоторые разрядились слишком сильно — и что мы делаем? Правильно, именно эти ячейки ещё немного дозаряжаем обратно (soft-program).

Поэтому в даташитах указываются страшные цифры:

«ну, обычно весь чип стирается за 5 с, но может и за 50». Вот это всё оттуда — у нас не формализованное «за 5 нс весь заряд стекает», а «целый контроллер гоняет заряд-разряд по кругу, пока все не станут примерно равны» — выравнивание сектора, несколько циклов стирания, починка over-erase. Вот у NAND (напоминаю, у нас NOR) ячейки соединены в цепочку, и невыбранные при чтении принудительно открываются повышенным напряжением — поэтому утечка перестёртой ячейки там ничего не роняет, и этой проблемы нет (там есть другие, и вы не хотите лезть ещё и в эту кроличью нору, поверьте мне).

И ещё один уровень вниз. P-well — тот самый электрод стирания — общий на целый физический блок (порядка мегабайта), а стираем мы всё равно секторами поменьше, 4–64 КБ. Как? Для стирания надо подать минус ещё и на word line’ы, и именно декодер word line’ов определяет гранулярность стирания. То есть технически можно было бы стирать хоть построчно. Но сам многоступенчатый процесс стирания настолько геморроен и долог, что мелкие стирания почти не экономят время. А ещё стирание блока портит заряд соседей, поэтому контроллер делает ещё и refresh-program для них, и стирание мелкими блоками приведёт к увеличению времени на побочные расходы. В общем, размер блока стирания — это компромисс между скоростью стирания и удобством использования.

Кстати, знаете, как делают OTP-память? Которая однократно программируемая? Это такая же память, просто там диэлектрик толще и обвязки для стирания нет. Один раз электроны засунул, и всё. (ну ок, ок, в OTP-видах есть ещё antifuse и eFuse, но они, скорее, про «если мы хотим чуть-чуть памяти на кристалле на чистом CMOS-процессе»).

И, в общем-то, классическая OTP-память — это практически неизменившийся EPROM, который с кварцевым окошком и стирался УФ. Только OTP без окошка и в пластике. И там, и там оксид под плавающим затвором, как я уже говорил, толстый, потому что это не имеет значения — электрон в затворе сидит, потому что у перехода кремний-оксид потенциальный барьер выше его энергии, ~3,2 эВ. А УФ фигачит фотонами на 254 нм, это ~4,9 эВ, и этого хватает, чтобы электрон возбудился, прошёл барьер, попал в зону проводимости оксида и его вытолкнуло бы на подложку или в затвор. Так что OTP можно стереть, если приспичит, просто надо корпус как-то открыть (так иногда ломают защиту у контроллеров).

Кому интересно подробнее, вот замечательный аппноут Renesas AN500 «NOR Flash Memory Erase Operation». Там и про размер p-well, и про порчу соседей.

Собираем бюджет времени

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

  • Стирание, ок, измерили напрямую через erase_region3,7 с.

  • 0xFF (стирание + «программирование, где гасить нечего») = 6,0 с.
    Значит, «программирование страниц без единого гашения бита» = 6,0 − 3,7 = 2,3 с.
    Это цена «пройтись по 4096 страницам и на каждой выполнить команду записи, даже если данные — сплошные единицы».

  • 0x00 (стирание + программирование с гашением всех бит) = 7,8 с.
    Значит, сама зарядка ячеек = 7,8 − 6,0 = 1,7 с на полностью нулевой мегабайт. На реальных данных эта добавка пропорциональна соотношению нулей/единиц

diagram.drawio.svg

Складываем и проверяем модель на всех точках:

  • FF = 3,7 + 2,3 + 0 = 6,0 

  • 00 = 3,7 + 2,3 + 1,7 = 7,8

  • смесь (половина нулей) = 3,7 + 2,3 + 0,85 = 6,9

Сходится! Это и есть «из чего состоит запись».

Хорошо, а что разгоняет патч APB? Откуда взялись те +17%? Если стирание и зарядка ячеек — это аналоговая физика кристалла, она от частоты процессора зависеть не должна. Давайте проверим:

                 без разгона   с разгономстирание            3,6 с        3,6 с      → 0%0xFF (база)         5,9 с        5,0 с      → −0,9 с0x00 (база+нули)    7,6 с        6,8 с      → −0,8 с

Тут у нас аж два вывода:

  • Стирание разгон не ускоряет вообще (0%). Значит, стирание — на 100% внутренняя операция кристалла, процессору тут делать нечего: он подал команду и ждёт ~15 мс, пока кристалл сам отработает. Эти 15 мс на сектор — на три порядка больше, чем работа процессора по выдаче команды, поэтому ускорять процессор бессмысленно.

  • А вот программирование ускоряется — и одинаково (~0,85 с) на FF и на 00. Это тонкий момент: у FF гашения бит нет, у 00 — максимум, а выигрыш от разгона одинаковый. Значит, разгон ускоряет не зарядку ячеек (она и есть разница между FF и 00, и она не изменилась, и не могла измениться, мы не трогаем память, только контроллер), а общую часть — ту самую «базу 2,3 с» выше.
    То есть база программирования наполовину процессорная: выдача команд по SPI, опрос «готов ли чип» в цикле. Её разгон и ускоряет. А физическая зарядка ячеек (1,7 с) — уже железо на стороне микросхемы памяти, на него не повлиять

diagram.drawio.svg

Итоговая раскладка базы: из 2,3 с «прохода по страницам» примерно 0,9 с — процессорная работа (её разгоняем), 1,4 с — аппаратный минимум записи страницы (не разгоняется). Почему процессорная часть тут вообще заметна, в отличие от стирания? Потому что запись одной страницы занимает ~0,5 мс — это время того же порядка, что и работа процессора на эту страницу, поэтому на 4096 страницах процессорные издержки набегают и видны. А стирание сектора (15 мс) настолько длиннее процессорных издержек, что они теряются.

Теперь понятно, почему увеличение скорости помогает: запись — это на самом деле две стадии, идущие одновременно. Одним потоком привозим данные (зависит от скорости и от того, сколько данных осталось после сжатия), вторым потоком пишем (тут зависит уже от того, какое соотношение 0/1 в разжатых данных). Поэтому общее время — это время, которое занимает самая длительная из двух стадий.

Значит, наше «линия загружена лишь на ~70%», не означает «есть 30% запаса», а значит всего лишь «стадия транспорта занимает 70% времени, остальные 30% провод ждёт, пока допишется флеш». Разница в том, что мы никогда не сможем добиться 100% загрузки линии, нас всегда будет ограничивать запись.

Про OTA

Но подождите, мы же упёрлись в скорость флеша в OTA?

Ніт. Совы не то, чем кажутся. Мы получили на скорости 921600 значение в 90 КБ/с и на OTA тоже получили 91 КБ/с и радостно решили, что это и есть скорость записи во флеш. А на самом деле это просто совпадение: на 921600 транспорт занимает ощутимо больше времени, поэтому скорость меньше возможного, а у OTA… а OTA просто медленный, потому что там несколько слоёв стека и вообще операционка и задачи крутятся параллельно. А запись у нас наполовину завязана на процессор, поэтому голодающая по процессору запись роняет скорость.

А результат?

— Это было познавательно. А выхлоп-то будет? — спрашивают меня коллеги, которые наблюдали, как я убил день на удовлетворение своего любопытства.

Второй раунд. Fight!

Что мы выяснили? Что у нас запись а) завязана на процессор и транспорт сильнее, чем мы думали; б) что стирание очень медленное. Что мы можем с этим сделать?

Скорость процессора

Давайте для начала всё-таки разгоним процессор до 240 МГц (хотя формально это не разгон). Это будет посложнее, чем APB, но в целом решаемо.

@@ src/main.c:+#ifdef ESP32+extern void esp_rom_set_cpu_ticks_per_us(uint32_t ticks_per_us);+extern void rom_i2c_writeReg(uint32_t block, uint32_t host_id, uint32_t reg_add, uint32_t data);++#define STUB_RTC_CNTL_CLK_CONF_REG  0x3ff48070U   // SOC_CLK_SEL [28:27]: 0=XTAL, 1=PLL+#define STUB_SOC_CLK_SEL_MASK       (0x3U << 27)+#define STUB_DPORT_CPU_PER_CONF_REG 0x3ff0003cU   // CPUPERIOD_SEL [1:0]: 0/1/2 = 80/160/240++static inline void stub_spin(uint32_t n) { while (n--) { __asm__ volatile ("nop"); } }++static void stub_esp32_cpu_set_240mhz(void)+{+    volatile uint32_t *clk_conf = (volatile uint32_t *)STUB_RTC_CNTL_CLK_CONF_REG;+    volatile uint32_t *dport    = (volatile uint32_t *)STUB_DPORT_CPU_PER_CONF_REG;++    *clk_conf &= ~STUB_SOC_CLK_SEL_MASK;   // Переключаемся на XTAL, чтобы менять настройки PLL+    stub_spin(2000); //ждем переключения++    // настраиваем BBPLL на 480 МГц, константы для 40-МГц кварца (из IDF rtc_clk.c)+    rom_i2c_writeReg(0x66, 4, 11, 0xc3);   // I2C_BBPLL_ENDIV5+    rom_i2c_writeReg(0x66, 4, 9,  0x74);   // I2C_BBPLL_BBADC_DSMP+    rom_i2c_writeReg(0x66, 4, 2,  0x00);   // I2C_BBPLL_OC_LREF+    rom_i2c_writeReg(0x66, 4, 3,  0x1c);   // I2C_BBPLL_OC_DIV_7_0  (28)+    rom_i2c_writeReg(0x66, 4, 5,  0xc6);   // I2C_BBPLL_OC_DCUR+    stub_spin(4000);                       // дать PLL перезахватиться++    *dport = (*dport & ~0x3U) | 2U;                                // устанавливаем делитель CPUPERIOD_SEL=2 -> 480/2=240мгц+    *clk_conf = (*clk_conf & ~STUB_SOC_CLK_SEL_MASK) | (1U << 27); // переключаем CPU на PLL обратно+    stub_spin(2000);++    esp_rom_set_cpu_ticks_per_us(240);     // синхронизировать ROM-овые us-задержки+}+#endif //ESP32

Измеряем…

CPU80:  1020.3 кбит/с ± 0.6   wall 13.910 сCPU240: 1041.8 кбит/с ± 0.8   wall 13.624 с+21.5 кбит/с (+2.1%), −0.287 с/прошивку

Упс, облом. Ну как облом, результат какой-то есть, но маловато. Впрочем, это хорошее подтверждение модели: мы выше насчитали, что процессорная часть записи составляет 0,9 с, значит, разгон CPU с 160 до 240 МГц должен срезать ровно треть: 0,9 × (1 − 160/240) = 0,3 с. А у нас −287 мс.

Остальное — это именно транспорт влияет, а он сидит на APB, которую мы уже разогнали. Т. е. улучшение от разгона можно получить хорошее, но нюанс в том, что мы его уже получили раньше, наугад что-то разогнав.
Ну допустим, 300 мс тоже неплохо.

Вариант

Время всей прошивки

Мс по сравнению с предыдущим рекордом

Проценты по сравнению с предыдущим рекордом

Проценты по сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300

−10%

−10%

Скорость 2 000 000

28,3 с

−1850

−6%

−15%

esp-flasher-stub (v2-rs)

25,4 с

−2900

−10%

−24%

esp-flasher-stub (v2-с) с патчем APB

23,5 с

−2300

−9%

−30%

esp-flasher-stub (v2-с) с патчем APB и блоком 32 КБ

22,98 с

−520

−2,2%

−31%

verify_fuses + blank-check одной сессией

16,2 с

−6780

−30%

−51%

CPU 240 МГц

15,9 с

−290

−1,8%

−52%

А что, если не стирать?

У нас была гипотеза, что stub умный и пропускает после стирания FF-страницы, потому что они и так FF, да?
Она оказалась нерабочей, но ведь рациональное-то зерно в ней было. --diff-with вот отлично работает же, просто не у нас, у нас свежие чипы всегда FF, диффать не с чем. Но мы можем не диффать, мы уже умеем писать код под загрузчик, мы можем проверять уже на этапе загрузчика, не FF-страницы ли. И мы можем делать это очень быстро, потому что а) это чтение; б) это на уровне загрузчика, не надо никуда ничего отсылать; в) это не md5, а просто тупая проверка в цикле == 0xFF.

Т. е. логика такая: нам приходит блок записи, раньше мы стирали сектор, а потом туда писали новые данные, а теперь мы сначала читаем сектор, и если он весь FF, то не стираем его, а сразу пишем. А если там есть не-FF данные, то стираем. Минус тут только в том, что мы получим пенальти (чтение тоже занимает время) на контроллерах, которые уже чем-то зашиты, но так как это загрузчик для производства, наверное, пофиг…

diagram.drawio.svg

Но можно же добавить небольшую эвристику — «если мы встретили хоть один не-FF байт, то мы считаем, что чип уже шился и дальше пропускаем чтение и всегда стираем сектора перед записью». Пенальти на уже прошитом контроллере снизится до лишнего времени чтения одного блока, а это мелочи.

Пишем что-то вроде:

static bool s_dirty_seen = false;static int s_sector_is_blank(uint32_t addr, uint32_t sector_size, bool *is_blank){    static uint32_t buf[1024];    *is_blank = true;  // считаем пустым, пока не найдём байт, отличный от 0xFF     for (uint32_t off = 0; off < sector_size; off += sizeof(buf)) {         uint32_t chunk = (sector_size - off < sizeof(buf)) ? (sector_size - off) : (uint32_t)sizeof(buf);        if (stub_lib_flash_read_buff(addr + off, buf, chunk) != STUB_LIB_OK) return RESPONSE_FAILED_SPI_OP;        for (uint32_t i = 0; i < chunk / sizeof(buf[0]); i++) {            if (buf[i] != 0xFFFFFFFFU) { //сравниваем                 *is_blank = false;       // нашли данные -> сектор грязный                 return RESPONSE_SUCCESS; // ранний выход, остальное читать незачем             }        }    }    return RESPONSE_SUCCESS; /* просканировали весь сектор, всё ещё 0xFF -> пустой */}static int s_conditional_erase_next(void){    if (s_flash_state.erase_remaining == 0) return RESPONSE_SUCCESS; // В этой операции стирать больше нечего.    }    if (!s_dirty_seen) {        stub_lib_flash_config_t config;        stub_lib_flash_get_config(&config);        bool blank = true;        int r = s_sector_is_blank(s_flash_state.next_erase_addr, config.sector_size, &blank);        if (r != RESPONSE_SUCCESS) return r; // пробрасываем ошибку чтения         if (blank) { // Пустой сектор -> НЕ стираем. Двигаем курсор на один сектор            uint32_t step = (s_flash_state.erase_remaining < config.sector_size)                            ? s_flash_state.erase_remaining : config.sector_size;            s_flash_state.next_erase_addr += config.sector_size;            s_flash_state.erase_remaining -= step;            return RESPONSE_SUCCESS;        }        s_dirty_seen = true; // Первый непустой сектор: чип использованный -> прекращаем проверять    }    int result = stub_lib_flash_start_next_erase(&s_flash_state.next_erase_addr, //Стираем один сектор на курсоре                                                 &s_flash_state.erase_remaining, 0);     if (result != STUB_LIB_OK && result != STUB_LIB_ERR_TIMEOUT) { //Результат TIMEOUT ожидаем и     * нормален - он просто значит, что стирание ещё идёт в фоне.        return RESPONSE_FAILED_SPI_OP;    }    return RESPONSE_SUCCESS;}

Ну и в нужных местах заменяем stub_lib_flash_start_next_erase на наше s_conditional_erase_next:

s_init_flash_operation() // — сброс эвристики + первое условное стирание-int result = stub_lib_flash_start_next_erase(&s_flash_state.next_erase_addr,-                                             &s_flash_state.erase_remaining, 0);-if (result != STUB_LIB_OK && result != STUB_LIB_ERR_TIMEOUT) {-    return RESPONSE_FAILED_SPI_OP;+s_dirty_seen = false;+int result = s_conditional_erase_next();+if (result != RESPONSE_SUCCESS) {+    return result; }s_flash_defl_data_post_process() // — фоновое стирание во время распаковки тоже условное-int result = stub_lib_flash_start_next_erase(&s_flash_state.next_erase_addr,-                                             &s_flash_state.erase_remaining, 0);-if (result != STUB_LIB_OK && result != STUB_LIB_ERR_TIMEOUT) {-    return RESPONSE_FAILED_SPI_OP;+int result = s_conditional_erase_next();   /* skip-if-blank */+if (result != RESPONSE_SUCCESS) {+    return result; }

Собираем, запускаем…

BLANK (conditional_erase skips): 8.136s ± 0.024 (n=6)DIRTY (full erase):              13.664s ± 0.017conditional_erase saving = DIRTY - BLANK = 5.529s

Ух! Вот это приятный результат всего этого исследования второго раунда. Ещё бы чуть-чуть, и время сравнялось с временем прошивки STM:

Вариант

Время всей прошивки

Мс по сравнению с предыдущим рекордом

Проценты по сравнению с предыдущим рекордом

Проценты по сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300

−10%

−10%

Скорость 2 000 000

28,3 с

−1850

−6%

−15%

esp-flasher-stub (v2-rs)

25,4 с

−2900

−10%

−24%

esp-flasher-stub (v2-с) с патчем APB

23,5 с

−2300

−9%

−30%

esp-flasher-stub (v2-с) с патчем APB и блоком 32 КБ

22,98 с

−520

−2,2%

−31%

verify_fuses + blank-check одной сессией

16,2 с

−6780

−30%

−51%

CPU 240 МГц

15,9 с

−290

−1,8%

−52%

conditional_erase

10,4 с

−5500

−35%

−69%

Что забавно, наш патч — это комплементарный к —diff-with: он не работает там, где кристалл чистый, а наш исключительно там, и наоборот, от нашего патча толку ноль, если вы хоть раз прошили память, но зато вот после этого-то —diff-with и будет отлично работать. Ещё целых пять секунд выиграли, весомое улучшение.

Ну кисонька, ну ещё капельку

Может быть, можно как-то ещё ускорить? Ну на полсекунды хотя бы. А? У нас запас есть ещё полторы-две секунды, когда мы ещё заняты, а прошивка STM уже закончила.

Вы будете смеяться, но можно. Опять посмотрим на кусок кода, в котором мы пишем всё в одной сессии:

    for addr, path in files:        img = open(path, "rb").read()        if len(img) % 4: img += b"\xff" * (4 - len(img) % 4)          comp = zlib.compress(img, 9) #запаковываем вручную        esp.flash_defl_begin(len(img), len(comp), addr)         seq, buf = 0, comp        while buf:            esp.flash_defl_block(buf[:ESP32StubLoader.FLASH_WRITE_SIZE], seq) #указываем что блок сжатый            buf = buf[ESP32StubLoader.FLASH_WRITE_SIZE:]            seq += 1        if esp.flash_md5sum(addr, len(img)) != hashlib.md5(img).hexdigest():            raise ESP32FlashingError(f"MD5 ERROR @ {addr:#x}")

Видите, там есть esp.flash_md5sum? Так вот, этот вызов говорит загрузчику «а посчитай мне md5 вот этого куска данных». Знаете, сколько эта команда занимает времени? 1,1 секунды!

Сначала у меня были идеи «а давайте мы откажемся от подсчёта md5 во время прошивки, а потом его посчитаем в рантайме параллельно с тестами, и заведём его сравнение с правильным как результат теста, чтобы всё упало, если он всё же не совпадает…». А оказалось, что всё гораздо проще.
Загрузчик (не тот, который stub, а который грузит код при старте, OTA) уже считает хеш при старте образа, и если он не совпадает, он уходит на factory-раздел. В нём у нас нет обвязки для тестов, и тесты упадут и так. Так что верификацию записанного можно смело пропускать.

На всякий случай проверяем, намеренно портим байт где-то в середине прошивки:

head -c 4096 /dev/zero > /tmp/b.binesptool.py --chip esp32 -p /dev/ttyACM0 -b 2000000 --after no_reset write_flash 0x120000 /tmp/b.bin

Загружаемся…

I (86)  boot: Starting firmware from partition: OTA_0E (509) esp_image: Checksum failed. Calculated 0xa6 read 0x81 E (511) boot: OTA app partition slot 0 is not bootable <====== Корректно падаем на проверке и не грузим битый образI (516) esp_image: segment 0: paddr=00010020 ...I (692) boot: Loaded app from partition at offset 0x10000 <====== Вместо этого грузим factory-образI (712) app_init: Project name:     factory_mge_v3I (717) app_init: App version:      1.0.0

Всё хорошо. Ну как хорошо — падает. Но корректно падает. Расчёт MD5 выкидываем.

Вариант

Время всей прошивки

Мс по сравнению с предыдущим рекордом

Проценты по сравнению с предыдущим рекордом

Проценты по сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300

−10%

−10%

Скорость 2 000 000

28,3 с

−1850

−6%

−15%

esp-flasher-stub (v2-rs)

25,4 с

−2900

−10%

−24%

esp-flasher-stub (v2-с) с патчем APB

23,5 с

−2300

−9%

−30%

esp-flasher-stub (v2-с) с патчем APB и блоком 32 КБ

22,98 с

−520

−2,2%

−31%

verify_fuses + blank-check одной сессией

16,2 с

−6780

−30%

−51%

CPU 240 МГц

15,9 с

−290

−1,8%

−52%

conditional_erase

10,4 с

−5500

−35%

−69%

Без md5

9,2 с

−1170

−11%

−72%

Ух, хорошо-то как.

А константа ли константа?

Кажется, мы сорвали все низковисящее (хотя я бы не сказал, что патч стаба на отключение стирания был таким уж низковисящим), и пришла пора оптимизировать что-нибудь ещё. Например, на те самые 1,8 с заливки стаба, количество которых мы оптимизировали в начале статьи. Саму 1,8 с мы как-то походя приняли за константу, типа ну вот так всегда было и так всегда будет, но, кажется, настало время посмотреть на неё подробнее. Профилируем чо там происходит внутри:

detect_chip, суммарно                         621 мсrun_stub (заливка стаба, 13 КБ @115200)      1262 мсchange_baud 115200 → 2M                        57 мс─────────────────────────────────────────────────────ИТОГО                                        ~1,9 с

Больше половины — тупо заливка лоадера. Esptool стартует на дефолтных 115200, на них заливает stub, а потом переводит обе стороны на нужную скорость — мы это выяснили ещё в самом начале статьи. Вот он на 115200 и заливает его, а только потом переключается на наши 2М. А ROM-загрузчик ведь и сам умеет команду CHANGE_BAUD — можно же сначала ускориться, а потом уже грузить стаб?

Меняем, на 2М ROM теряет поток («Serial data stream stopped»), на 1,5М — ошибка контрольной суммы. А вот на 921600 стаб заливается целиком, но падает потом:

Invalid head of packet (0x00): Possible serial noise or corruption.

Где-то мы это уже видели, правда? В истории с разгоном APB. Собственно, то же самое: стаб после старта должен поздороваться с хостом (буквально прислать «OHAI») — и он здоровается, просто на 115200. Потому что при инициализации принудительно перенастраивает UART на 115200: его же всегда грузят на этой скорости. Можно пропатчить стаб, чтобы он оставлял значение скорости, которое уже есть в чипе (что в целом логично, если его залили, оно должно быть норм), но можно проще, залить стаб и после этого опять переключиться на 115200, получить от него «OHAI», потом как обычно дальше, ставим 2M (почему-то лоадер в кремнии не умеет ничего выше 921600, а стаб уже умеет, поэтому стаб на 921600, а прошивка на 2M).

Заливка стаба сжалась сразу с 1262 мс до 205 мс. Целую секунду с нифига получили!

Ок, а в connect 621 мс на что уходят?

detect_chip, суммарно                         621 мс ├ открытие порта                               7 мс ├ подготовка, list_ports.comports()           58 мс ├ reset-танец DTR/RTS (паузы 100+50 мс)      157 мс ├ чтение boot-лога ROM                         1 мс ├ SYNC №1 → таймаут (…почему?)               103 мс ├ sleep перед второй попыткой                 50 мс ├ SYNC №2 → ответ                             22 мс ├ get_chip_id → таймаут (что, опять?!)       216 мс └ чтение magic-регистра                        5 мсrun_stub (заливка стаба, 13 КБ @2M)           205 мсchange_baud 115200 → 2M                        57 мс─────────────────────────────────────────────────────ИТОГО                                        ~0,9 с

Какие-то синки и таймауты. Что такое вообще SYNC? А это противоположная система — детекция скорости для входящего потока стаба. Т. к. у нас нет синхросигнала, а частота кварцев у разных девайсов может плавать (и не все они могут от кварцев тактоваться, будем честны), то контроллеру надо точно измерить скорость, чтобы понять, на какой именно скорости его долбают данными в UART.
И вот этот SYNC № 1 — это именно предназначенный для этого блок. Он уходит в никуда, всегда, потому что там приёмник ещё не синхронизирован. Он, собственно, и служит исключительно для синхронизации, он выглядит как b"\x07\x07\x12\x20" + 32 * b"\x55” 32 байта 0х55, которые в бинарном виде выглядят как b01010101 — чтобы контроллеру было из чего измерять длительности нулей и единиц. Там есть Baudrate_Detect, который измеряет самый узкий импульс и кладёт в регистры UART_LOWPULSE/UART_HIGHPULSE. Это логично и законно. Нелогично и незаконно — это то, что у нас после него зачем-то таймаут и ожидание, хотя отвечать на этот пакет никто не собирался.

Ну, можно, собственно, так и вылечить — честно сказать, что первый пакет у нас жертвенный, отправить его, дать ещё 10 мс на «дожевать пакет и установить скорость» и уже после этого отправлять штатный SYNC.

Второй таймаут — почему-то на get_chip_id. Это потому, что наш контроллер уже легаси: там в новых чипах есть команда GET_SECURITY_INFO, которую старые (ESP32, ESP8266, S2) не понимают, поэтому игнорируют, esptool ждёт таймаута, и только потом идёт читать magic-регистр (0x40001000, в котором записано «что за чип»), определяет по нему ESP32 и понимает, какой из бинарников стаба туда заливать. Поэтому мы можем сделать так же, как делали раньше с последовательностью загрузки — выкинуть родные высокоуровневые команды и переписать всю цепочку инита самим, без GET_SECURITY_INFO, а просто с чтением регистра (читать его нужно, потому что нам надо удостовериться, что перед нами всё-таки «наш» ESP32, а не что-то другое), благо это займёт всего 5 мс.

До кучи мы можем срезать паузы reset-а (дефолтные 100+50 мс — это запас под чужие платы с жирными конденсаторами на EN, нашей хватает 10+40).

Было:

import esptoolesp = esptool.detect_chip(port, baud=115200, connect_attempts=3)  # 621 мсesp = esp.run_stub()                    # заливка стаба на 115200: 1262 мсesp.change_baud(2_000_000)              # 57 мс# дальше — flash_defl_begin/block/finish, как обычно

Стало:

import time, json, base64from esptool.targets import ESP32ROMfrom esptool.loader import FatalErrorfrom esptool.reset import UnixTightReset# SYNC-команда в SLIP-обёртке: 0x07 0x07 0x12 0x20 + 32×0x55 — та самая «линейка»SYNC_FRAME = (b"\xc0\x00\x08\x24\x00\x00\x00\x00\x00"              b"\x07\x07\x12\x20" + 32 * b"\x55" + b"\xc0")def fast_connect(esp, attempts=3):    port = esp._port    esp.cache["usb_pid"] = 0        # пропускаем медленный list_ports.comports()    port.reset_input_buffer()    r = UnixTightReset(port, 0)     # DTR+RTS одним ioctl (на CH343 иначе никак)    r._setDTRandRTS(False, False)    r._setDTRandRTS(True, True)    r._setDTRandRTS(False, True)    # EN=LOW: чип в ресете    time.sleep(0.01)                # 10 мс хватает нашей плате (возможно не универсально)    r._setDTRandRTS(True, False)    # EN=HIGH, IO0=LOW: грузимся в download-режим    time.sleep(0.04)                # ROM стартует и допечатывает баннер (~38 мс)    r._setDTRandRTS(False, False)    r._setDTR(False)    port.write(SYNC_FRAME)          # жертвенный кадр: его съест автобод-детектор    port.flush()    time.sleep(0.01)                # даём ROM защёлкнуть скорость    # detecting=True выключает верификацию чипа внутри connect    # (это те самые 216 мс неподдерживаемого get_chip_id)    esp.connect(mode="no_reset", attempts=attempts, detecting=True, warnings=False)    if esp.read_reg(esp.CHIP_DETECT_MAGIC_REG_ADDR) != esp.MAGIC_VALUE:  # 5 мс        raise FatalError("это не ESP32")class JsonStub:    """Образ стаба из esptool-овского json (text/data/entry)."""    def __init__(self, path):        d = json.load(open(path))        self.text = base64.b64decode(d["text"]); self.text_start = d["text_start"]        self.data = base64.b64decode(d["data"]) if d.get("data") else None        self.data_start = d.get("data_start"); self.entry = d["entry"]def upload_stub_fast(esp, stub, low_baud=115200):    for data, offs in ((stub.text, stub.text_start), (stub.data, stub.data_start)):        if data is None:            continue        blocks = (len(data) + esp.ESP_RAM_BLOCK - 1) // esp.ESP_RAM_BLOCK        esp.mem_begin(len(data), blocks, esp.ESP_RAM_BLOCK, offs)        for seq in range(blocks):            esp.mem_block(data[seq*esp.ESP_RAM_BLOCK:(seq+1)*esp.ESP_RAM_BLOCK], seq)    esp.mem_finish(stub.entry)    esp._port.baudrate = low_baud   # стаб на старте сбросит свой UART на 115200 —    esp.flush_input()               # возвращаемся, чтобы услышать «OHAI»    if esp.read() != b"OHAI":        raise FatalError("стаб не запустился")    return esp.STUB_CLASS(esp)      # дальше это обычный ESP32StubLoader# --- весь коннект целиком ---esp = ESP32ROM(port, 115200)        #   6 мс: просто открыть портfast_connect(esp)                   # ~107 мс: reset + прайминг + sync + magicesp.change_baud(921_600)            #  67 мс: потолок заливки в ROM — 921600esp = upload_stub_fast(esp, JsonStub("esp32_cerase.json"))  # 205 мсesp.change_baud(2_000_000)          #  57 мс: стабу 2М уже по зубам# дальше тот же flash_defl_begin/block/finish — объект полностью совместим

Ну, стоит сказать, что теперь весь путь опирается на приватные, хоть и годами стабильные API esptool (_setDTRandRTS, esp.cache, mem_*). Поэтому надо всё же обернуть это в try/except: любая ошибка — и прошивка откатывается на штатный detect_chip() + run_stub().

И по фазам:

Фаза

Было

Стало

reset + sync + опознание чипа

621 мс

113 мс

смена скорости в ROM (→ 921600)

67 мс

заливка стаба

1262 мс

205 мс

смена скорости стаба (→ 2М)

57 мс

57 мс

Итого

1940 мс

441 мс

«Однократно 1,8 с», ага. Однократно 0,44!

Вариант

Время всей прошивки

Мс по сравнению с предыдущим рекордом

Проценты по сравнению с предыдущим рекордом

Проценты по сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300

−10%

−10%

Скорость 2 000 000

28,3 с

−1850

−6%

−15%

esp-flasher-stub (v2-rs)

25,4 с

−2900

−10%

−24%

esp-flasher-stub (v2-с) с патчем APB

23,5 с

−2300

−9%

−30%

esp-flasher-stub (v2-с) с патчем APB и блоком 32 КБ

22,98 с

−520

−2,2%

−31%

verify_fuses + blank-check одной сессией

16,2 с

−6780

−30%

−51%

CPU 240 МГц

15,9 с

−290

−1,8%

−52%

conditional_erase

10,4 с

−5500

−35%

−69%

Без md5

9,2 с

−1170

−11%

−72%

Быстрый коннект

7,7 с

−1500

−16%

−77%

Семьдесят семь процентов! Особенно драматично смотрится это, если сравнить с общим временем прошивки (42 секунды), добавив то, что две прошивки заливаются теперь параллельно, и время у нас становится 9,3 секунды общее.
42 vs 9,3 — это в четыре с половиной раза быстрее! Кажется, любопытство окупилось.

Стенд ждет нового кода

А может быть, всё было опять совсем наоборот?

Я, конечно, задачу закрыл, отчитался, всё хорошо. Но мне покою не давала одна мысль: а почему OTA такой медленный? Вот наше «а OTA просто медленный, потому что там несколько слоёв стека и вообще операционка и задачи крутятся параллельно», это вообще правда или нет?
Нет, не то, чтобы он не мог быть медленным: у ESP настолько хитровыстраданная архитектура, что я совершенно не удивляюсь уже ничему.

Следите за руками: память на кристалле делать сложно, потому что им нужны разные техпроцессы. Поэтому памяти на кристалле нет, только оперативка. Память у нас на отдельном кристалле (или на отдельной микросхеме вообще), которая подключена по QSPI — это похожая на SPI штука, только с 4 линиями данных и полудуплексная, что увеличивает скорость.

Вот так выглядит память в одном корпусе с контроллером. Это, правда, GD32, а не ESP, но смысл понятен

Правда, хоть QSPI и увеличивает скорость, её всё равно недостаточно, чтобы достаточно быстро исполнять команды. Поэтому у нас есть ещё контроллер кэша и сам кэш в SRAM, в котором контроллер держит горячие участки кода. Если исполняемый код найден, он выполняется из SRAM, а если не найден, то процессор встаёт на паузу, а контроллер кэша подгружает его из SPI-Flash. Плюс у нас ещё гарвардская архитектура, есть IRAM (для кода) и DRAM (для данных), а ещё Mask-ROM для всяких внутренних функций типа чтения флеша, такой себе «микрокод».

diagram.drawio.svg

И вот тут какая фишка: канал общения с SPI-флешем у нас один, физически.

И если мы что-то пишем во флеш, то в этот момент можем исполнять только то, что лежит в IRAM изначально (есть флаг IRAM_ATTR, которым помечаются функции, которые должны жить в IRAM). Любой вызов функции из флеша — обращение к остановленному контроллеру и падение. Но IRAM у нас дефицит, она и так занята ядром и всякими вспомогательными функциями (просто потому что так быстрее, вынести всё горячее не в кэш, а в память).

Так что, в общем-то, я бы махнул рукой, сказал: «ну, OTA медленный», и забил бы на это, если бы не эта статья. Мне казалось, что абзац про OTA звучит как-то вяло, и я захотел туда вставить цифры, насколько же она медленная.

Оказалось, что запись прошивки по OTA занимает 13,2 с, что даёт нам эффективную скорость в 92 КБ/с. Мало? Да.
Нюанс в том, из чего складывается эта скорость. Вот стадии OTA:

фаза

Размер

время

доля

Скорость

передача

1184 КБ

1,45 с

11%

817 КБ/с

стирание

1728 КБ

6,74 с

53%

256 КБ/с

программирование

1184 КБ

3,70 с

29%

320 КБ/с

чек-сумма

1184 КБ

0,9 с

7%

1316 КБ/с

всего

1184 КБ

12,79 с

100%

92 КБ/с

А вот те же стадии программирования через UART (уже со всеми нашими фиксами):

фаза

Размер

время

доля

Скорость

передача

1104 КБ

1,64 с

11%

673 КБ/с

декомпрессия

1104 КБ → 1732 КБ

0,25 с

2%

6928 КБ/с

стирание

1732 КБ

6,61 с

46%

262 КБ/с

программирование

1732 КБ

5,97 с

41%

290 КБ/с

всего

1732 КБ

14,47 с

100%

120 КБ/с

Казалось бы, 120 КБ/с vs 92 КБ/с, даже смысла не имеет думать. Но тут скрыты две неожиданные цифры. Сможете их найти? Рекомендую вам попробовать, прежде чем читать дальше.

Тут надо уточнить, что таблицы эти — синтетические. У них с реальной прошивкой совпадает общее время/размер/скорость, но данные разных фаз у них сняты в изолированных условиях, потому что нельзя достаточно точно получить, например, скорость записи и не занести туда скорость передачи. Мы выше уже считали скорость записи разной математикой, тут примерно так же, просто добавились ещё некоторые эксперименты типа генерации рандома на самом контроллере и записи его на флеш, чтобы измерить реальную скорость записи. Так что таблицы хорошо отражают состав фаз, но не являются записью какой-либо отдельной прошивки.

Первая — это размер данных на каждой фазе в OTA. Прошивка занимает 1184, а раз она не сжимается, то в отличие от UART будет одинаковая во всех фазах… А, нет, в стирании почему-то стоит «1728 КБ». Ошибка? Нет. Просто стирается всегда весь образ, а не только то, сколько нам надо прошить. Поэтому мы стираем больше, чем пишем, а с учётом того, что стирание — это 50% бюджета времени — это серьёзно влияет на итоговый расчёт эффективности скорости прошивки. Потому что если стирать только нужное, то стирание займёт 4,62 с, а эффективная скорость вырастает до 111 КБ/с. +20%! Мы уже быстрее, но можно оптимизировать OTA.

Вторая странная цифра — это скорость программирования в OTA. 320 КБ/с. Это чистое программирование без стирания/верификации/передачи и т. д. А у нас в загрузчике аналогичная цифра — 290. А почему? А как так? Опять погружаемся в код.

Вот реализация функции записи SPI-флеша из ROM:

components/esp_rom/patches/esp_rom_spiflash.c:194,  esp_rom_spiflash_program_page_internal()#define ESP_ROM_SPIFLASH_BUFF_BYTE_WRITE_NUM  32while (temp_bl > 0) {    esp_rom_spiflash_enable_write(spi);                    // WREN на каждой итерации    if (temp_bl >= ESP_ROM_SPIFLASH_BUFF_BYTE_WRITE_NUM) { // >= 32        WRITE_PERI_REG(PERIPHS_SPI_FLASH_ADDR,            (temp_addr & 0xffffff) | (ESP_ROM_SPIFLASH_BUFF_BYTE_WRITE_NUM << ESP_ROM_SPIFLASH_BYTES_LEN)); // 32 byte a block        for (i = 0; i < (ESP_ROM_SPIFLASH_BUFF_BYTE_WRITE_NUM >> 2); i++)   // 32/4 = 8 слов            WRITE_PERI_REG(PERIPHS_SPI_FLASH_C0 + i*4, *addr_source++);        temp_bl   -= 32;        temp_addr += 32;    } else { ... хвост < 32 ... }    WRITE_PERI_REG(PERIPHS_SPI_FLASH_CMD, SPI_FLASH_PP);   // page-program    while (READ_PERI_REG(PERIPHS_SPI_FLASH_CMD) != 0) {}   // ждем}

А вот та же реализация из IDF, которую использует OTA, чтобы записать во флеш:

spi_flash/memspi_host_driver.c:209#define SPI_FLASH_HAL_MAX_WRITE_BYTES 64 //(components/hal/include/hal/spi_flash_hal.h:27)int memspi_host_write_data_slicer(spi_flash_host_inst_t *host, uint32_t address, uint32_t len,                                  uint32_t *align_address, uint32_t page_size){    ...    uint32_t end_bound = (align_addr/page_size + 1) * page_size;    // Shouldn't program cross the page, or longer than SPI_FLASH_HAL_MAX_WRITE_BYTES    uint32_t max_len = MIN(end_bound - align_addr, SPI_FLASH_HAL_MAX_WRITE_BYTES);     *align_address = align_addr;    return MIN(max_len, len);}

Разница в том, какими кусками флеш пишется. В ROM-функции это 32 байта, а в IDF — 64.

Дорогая, я опять увеличил скорость записи

Ага, значит, чем бо́льшим куском пишем, тем быстрее? А как оно вообще пишется-то на флеш?

У ESP есть два SPI-контроллера, SPI0 и SPI1. Первый используется при выполнении кода из флеша, а второй — для пользовательских инструкций чтения-записи. Они внутри подключены через аппаратный арбитр, который не даёт им работать одновременно. Именно поэтому во время чтения-записи флеша пользователем мы не можем использовать код из этого флеша, только из IRAM.

diagram.drawio.svg

Для записи нам надо выставить адрес и число байт в SPI_ADDR_REG, заполнить нужными данными SPI_Wxx, потом установить WREN (разрешение записи) в SPI_CMD_REG, потом PP (запись), потом командой RDSR в цикле читать статус — он попадает в SPI_RD_STATUS_REG, в котором бит WIP показывает статус, который отдаёт контроллер флеша, делает он что-то (ну, последнюю команду) или уже закончил.

Вот в SPI_Wxx-то всё и упирается. Их 16, в каждом 32 бита, и всего получается 64 байта — больше записать за одну транзакцию нельзя. Ок, 64 так 64, пишем функцию, которая заменяет функцию в ROM, которая почему-то могла делать это только 32-байтными блоками.

int stub_target_flash_write_buff(uint32_t addr, const void *buffer, uint32_t size, bool encrypt){    if (encrypt) { /* шифрованную запись оставляем ROM-функции */        int res = esp_rom_spiflash_write_encrypted(addr, buffer, size);        return (res == ESP_ROM_SPIFLASH_RESULT_OK) ? STUB_LIB_OK : STUB_LIB_ERR_FLASH_WRITE;    }    const uint8_t *src = (const uint8_t *)buffer;    /* Дождаться конца стирания перед первым PP, иначе flash_defl_begin     * ACK'ает erase, пока WIP ещё висит и первый PP дропался (первые 64 байта 0xFF). */    { uint32_t g = 8000000U; while (stub_target_flash_is_busy() && (--g != 0U)) {}       if (g == 0U) return STUB_LIB_ERR_FLASH_WRITE; }    REG_SET_FIELD(SPI_USER1_REG(FLASH_SPI_NUM), SPI_USR_ADDR_BITLEN, 23);    while (size > 0U) {        uint32_t chunk = MIN(size, 64U);                 // <-- 64 байта за раз (было 32)        uint32_t page_left = 256U - (addr & 0xFFU);      // не пересекать 256-байтную страницу        if (chunk > page_left) chunk = page_left;        stub_target_spi_wait_ready();        /* залить до 64 байт в SPI_W0..W15 (16 слов), слово за словом, alignment-safe */        uint32_t left = chunk; const uint8_t *p = src;        for (uint32_t i = 0U; left > 0U; i++) {            uint32_t word = 0U, wl = MIN(left, 4U);            for (uint32_t b = 0U; b < wl; b++) word |= ((uint32_t)p[b]) << (8U * b);            WRITE_PERI_REG(SPI_W0_REG(FLASH_SPI_NUM) + (i * 4U), word);            p += wl; left -= wl;        }        uint32_t guard;        WRITE_PERI_REG(SPI_CMD_REG(FLASH_SPI_NUM), SPI_FLASH_WREN); /* Write-Enable */        guard = 4000000U; while ((READ_PERI_REG(SPI_CMD_REG(FLASH_SPI_NUM)) != 0U) && (--guard != 0U)) {}        if (guard == 0U) return STUB_LIB_ERR_FLASH_WRITE;        /* адрес + счётчик байт в старшем байте (конвенция flash_pp) */        WRITE_PERI_REG(SPI_ADDR_REG(FLASH_SPI_NUM), (addr & 0xFFFFFFU) | (chunk << 24));        WRITE_PERI_REG(SPI_USER_REG(FLASH_SPI_NUM), READ_PERI_REG(SPI_USER_REG(FLASH_SPI_NUM)) & ~(uint32_t)SPI_USR_DUMMY);        /* запуск hardware Page-Program + ждём конца SPI-транзакции */        WRITE_PERI_REG(SPI_CMD_REG(FLASH_SPI_NUM), SPI_FLASH_PP);        guard = 4000000U; while ((READ_PERI_REG(SPI_CMD_REG(FLASH_SPI_NUM)) != 0U) && (--guard != 0U)) {}        if (guard == 0U) return STUB_LIB_ERR_FLASH_WRITE;        /* ждём снятия WIP перед следующей страницей */        guard = 8000000U; while (stub_target_flash_is_busy() && (--guard != 0U)) {}        if (guard == 0U) return STUB_LIB_ERR_FLASH_WRITE;        addr += chunk; src += chunk; size -= chunk;    }    return STUB_LIB_OK;}

stub_target_flash_write_buff — weak-функция самого стаба, в дефолтном варианте она просто вызывает esp_rom_spiflash_write из ROM. Но мы написали strong-версию только для ESP32 (потому что у нас на него завязаны регистры и прочее), поэтому при сборке с таргетом ESP32 линкер возьмёт нашу реализацию, а для всех других — родную, и лоадер не сломается. Собираем, измеряем:

стаб ДО фиксов (ROM, 32Б): 290 КБ/сстаб ПОСЛЕ (PP, 64Б): 314 КБ/сOTA (esp_flash, on-device flashbench): 320 КБ/с

Куда-то делись 6 КБ/с, но, честно говоря, мне уже лень их искать (возможно, это просто артефакт измерения). Измеряем всю прошивку:

Вариант

Время всей прошивки

Мс по сравнению с предыдущим рекордом

Проценты по сравнению с предыдущим рекордом

Проценты по сравнению с базовым

Базовый

33,4 с

0

0

0

combined.bin

30,1 с

−3300

−10%

−10%

Скорость 2 000 000

28,3 с

−1850

−6%

−15%

esp-flasher-stub (v2-rs)

25,4 с

−2900

−10%

−24%

esp-flasher-stub (v2-с) с патчем APB

23,5 с

−2300

−9%

−30%

esp-flasher-stub (v2-с) с патчем APB и блоком 32 КБ

22,98 с

−520

−2,2%

−31%

verify_fuses + blank-check одной сессией

16,2 с

−6780

−30%

−51%

CPU 240 МГц

15,9 с

−290

−1,8%

−52%

conditional_erase

10,4 с

−5500

−35%

−69%

Без md5

9,2 с

−1170

−11%

−72%

Быстрый коннект

7,7 с

−1500

−16%

−77%

SPI 64b

7,25 с

−450

−5,8%

−78%

7,25 секунды! Сла-а-авная была охота!

Ну, кажется, вы только что наблюдали, что может сделать инженер со скоростью прошивки контроллера, если ему втемяшилось в голову найти ВСЕ узкие места, а потом ещё и написать про это статью (потому что как минимум в три места в этом исследовании я полез именно потому, что мне показалось, что в статье нужный абзац смотрится как-то слишком просто и недостаточно имеет циферок и объяснения).

И да, ESP теперь шьётся 7,25 секунды, а STM-часть — 9,3.
Ну, вы поняли, чем я займусь на следующих выходных.

ссылка на оригинал статьи https://habr.com/ru/articles/1061982/