"Если дом начинается с двери, то прошивка начинается с загрузчика."

Пролог

В этом тексте написано про то, как я написал загрузчик для российского микроконтроллера MIK32 (K1948BK018).

Определения

Чтобы понять текст надо вспомнить вот эти определения.

1--Прошивка (firmware) - содержимое энергонезависимой памяти электронного
устройства с микроконтроллером. В прошивке всегда есть код, а иногда ещё образ
файловой системы NVRAM, конфиги процессора.
Монолитная прошивка может содержать ещё и загрузчик.

2--Загрузчик - это отдельная прошивка, которая загружает другую прошивку.
Обычно загрузчик стартует сразу после подачи питания перед запуском приложения.
Это чисто системная часть кода.

3--EEPROM - electrically erasable programmable read-only memory.
Это энергонезависимая память, которая зиждется на самом кристалле.
В нашем случае 8kByte.

4--Принстонская архитектура — принцип совместного хранения команд и
данных в памяти компьютера. Подразумевает принцип хранения данных
и инструкций в одном разделе памяти.

5--Гарвардская архитектура — архитектура ЭВМ, отличительными признаками
которой есть два атрибута. Хранилище инструкций и хранилище данных представляют
собой разные физические устройства и канал (шина) инструкций и канал данных также
физически разделены.

Что надо из оборудования?

Оборудование

количество

Учебно-тренировочная электронная отладочная плата START-MIK32-V1

1

Кабель USB-A на USB micro

1

Перемычки гнездо-гнездо

4 (необязательно)

Переходник с USB на UART на ASIC CP2102

1 (необязательно)

Для того чтобы работать с загрузчиком надо соединить отладочную плату и LapTop PC согласно вот этой схеме.

Что надо из ПО?

Представленное ПО нужно не сколько для разработки сколько для проверки работоспособности загрузчика.

Название утилиты

Пояснение

1

Операционная система Windows 10

Для исполнения прикладного ПО

2

WinRar

для распаковки terminal 1.9b

3

Программа terminal 1.9b

для тестирования и отладки загрузчика

4

Консольная утилита FW-loader.exe

для нарезания hex файла с прошивкой и отправкой его на устройство

Постановка задачи

Общая задача такова, что надо обновить прошивку в off-chip SPI памяти при помощи
on-chip MIK32 загрузчиком по UART0. То есть обновить прошивку без программатора. Главная задача любого загрузчика - это дать возможность обновлять боевую flash и ни при
каких обстоятельствах не позволить устройству зависнуть.

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

1--Загрузчик должен помещаться в 8kByte памяти EEPROM программ.
Это обусловлено тем, что в микроконтроллере K1948BK018 просто нет больше on-chip
памяти, чем 8kByte EEPROM.

2--Загрузчик должен уметь прошивать по UART0. Это обусловлено тем, что в плату START-MIK32-V1 вмонтирован переходник с USB на UART на основе отдельного 8-ми битного E8051 совместимого микроконтроллера CH552T. Одновременно с этим, для интерфейса UART существует множество бесплатного диагностического софта для serial портов. Примерами таких программ являются утилиты Terminal 1.9b, Putty, TeraTerm и прочее.

3--Битовая скорость UART0 в загрузчике должна составлять 56000 бит/s. Это самая максимальная (из стандартных) битовая скорость, которую поддерживает заводская
прошивка внутри переходника на основе CH552T.

4--Загрузчик должен перед прыжком в flash настроить трансивер SPIFI в QUAD режим.

5--Загрузчик должен перед прыжком в flash включить кэш в SPIFI трансивере.

6--Желательно, чтобы загрузчик осуществлял прыжок в боевую flash самопроивзольно по истечении определенного timeout (40...70 секунд) c момента подачи электропитания или по таймауту от отсутствия входного трафика в шине UART0.

7--Загрузчик должен записывать прошивку по частям. По возможности перед записью фрагмента в ячейки SPI-Flash проверять контрольную сумму принятого фрагмента данных для памяти.

8--Загрузчик должен быть однопоточной NoRTOS прошивкой. Так можно уменьшить *.bin(арь) и упростить код загрузчика.

9--Желательно, чтобы была UART команда приказывать загрузчику прыгнуть в
приложение и начать исполнять код в SPI-Flash.

10--Желательно, чтобы загрузчик периодически раз в 10...20 секунд посылал в UART0 hello пакеты. Это позволит на стороне клиентского приложения автоматически убедится, что прошивка не зависла.

11--Загрузчик это не просто ещё одна прошивка в репозитории. Для загрузчика нужна инфраструктура и экосистема. В самом простом виде - это консольное Windows приложение (FW_Loader.exe) под Windows 10 PC для нарезания *.hex фйла и отправки фрагментов прошивки по последовательному COM порту.

12--Сам загрузчик записывать программатором по JTAG. На плате START-MIK32-V1 в качестве программатора выступает микросхема CH552T (U4).

Структура загрузчика

Данный состоит из следующих программных компонентов:

1-- Драйвер GPIO.
2-- Драйвер светодиода (LED).
3-- Драйвер SPIFI трансивера.
4-- Драйвер ASIC-а W25Q32JV.
5-- Драйвер UART трансивера.
6-- Программный компонент бинарного протокола передачи данных Trivial Binary Frame Protocol (TBFP).
7-- Простой кооперативный планировщик. По сути - суперцикл.
8-- Программный компонент FIFO.
9-- Программный компонент CRC8 (как дополнение).

LED нужен для того, чтобы показать пользователю, что прошивка загрузчика не зависла.
Тут логика простая. Если LED мигает, значит загрузчик должен отвечать на TBFP пакеты.
Если LED не мигает - значит что-то внутри прошивки заклинило. Это классический HeartBeat LED.

Команды загрузчика

В прошивке загрузчика заведён бинарный протокол обмена данными именуемый Trivial Binary Frame Protocol (TBFP). Обновление прошивки тоже предполагается через TBFP. Структура пакета для обновления прошивки показана на рисунке.

Это бинарный протокол поверх UART0. В качестве идентификатора полезной нагрузки выступает код 0xFC В диапазоне полезной нагрузки находится заголовок команды: чтение, запись, стирание, относительный адрес внутри микросхемы W25Q32JV, размер.
Номер ASICа в нашем случае должен быть равен единице. Поле данных заполняется только для операции записи.

Ввиду дефицита EEPROM памяти проверка контрольной суммы отключена. Также её можно отключить в флагах внутри заготовка TBFP пакета. В прерывании по UART0 RX происходит запись принятого байта в очередь для протокола TBFP. В прошивке есть конечный автомат, который обрабатывает входящие TBFP пакеты. Синтаксический разбор пакета происходит именно в суперцикле, никак не в прерывании. Прошивка в суперцикле сбрасывает TBFP парсер в ожидание преамбулы, если долгое время (2s) ничего не происходило на шине UART0 RX.

Механизм обновления

Обновление прошивки происходит на UART0 при битовой скорости 56000 бит/c.
Параметры кадра: два стоповых бита, нет проверки четности, один кадр - 8 бит.

Сеанс связи показан на временной диаграмме процесса обновления прошивки. Перед обновлением необходимо отправить TBFP пакет стирания содержимого микросхемы W25Q32JV.

После обновления можно принудительно прыгнуть исполнять код по адресу SPIFI (0x8000_0000) отправив в UART0 пакет A5 C1 01 00 04 00 01 00 00 00 80 E4

Отладка бинарного протокола

Сам протокол TBFP отлажен в составе консольного Windows PC приложения. На это есть модульные тесты внутри. В частности на чтение и запись по протоколу TBFP. Консольная Win утилита также умеет генерировать тестировочные TBFP пакеты, чтобы не набирать их вручную.

---------------------------------------------------------
Erase all contents of the SPI memory chip
-->tseg  
 StoreEraseFrame:
 A5C101000800FC000000000000030123
 A5C101000800FC 00000000 0000 03 01 23
 Insert for the program  terminal 1.9b: Erase all SPI memory chip
$A5$C1$01$00$08$00$FC$00$00$00$00$00$00$03$01$23

----------------------------------------------------
Generate a packet to read from the address (tsrg):
Usage: tsrg addr size
-->tsrg 0x00000000 16
Insert for the program  terminal 1.9b: read 0x00000000  16 byte
$A5$81$01$00$08$00$FC$00$00$00$00$10$00$01$01$59

-----------------------------------------------------------
Let's try to write something down
Usage: tswg addr size pattern
-->tswg 0x00000000 8 0x77 
Insert for the program  terminal 1.9b:  write 0x77 down at 0x00000000
$A5$C1$01$00$10$00$FC$00$00$00$00$08$00$02$01$77$77$77$77$77$77$77$77$29

-------------------------------
-->tgj 0x80000000
W,[TBFP] GenerateJumpToAddr:0x80000000 Packet
I,[TBFP] N:1,PRE:0xa5,IF:Stdio,RxMem:0047b8e0,RxSz:512,UART:85,SN:0,PrevFlow:0,CurFlow:0,MaxFlow:0,TornCnt:0,Lost:0,
I,[TBFP] PayLoadSize:4 byte
I,[TBFP] JumpFrame: A5C1010004000100000080E4
A5C10100040001 00000080 E4
Insert for the program  terminal 1.9b: jump to addr: 0x80000000
$A5$C1$01$00$04$00$01$00$00$00$80$E4

Эти пакеты с символом доллара предназначены для вставки в программу Terminal 1.9b.

Отладка на устройстве

Вы наверное удивитесь почему для проверки прошивки я выбрал такую старую утилиту, как Terminal v1.9b. Ответ прост. Из всего, что есть в открытом доступе (HTerm, TeraTerm, Putty, Herules, Serial KingDoom) только Terminal v1.9b позволяет устанавливать паузы между отправкой байт при трансляции бинарного массива. Вот такие пирожки с капустой.

В программе Terminal надо установить паузу между символами. Отладка производилась на значениях в диапазоне 20ms-50ms между байтами. Иначе прошивка может захлебнуться от плотного пучка входных байтов.

Как можно заметить, данные в самом деле прописываются и читаются.

Достоинства загрузчика

1--Загрузчиком можно обновлять прошивку без программатора. Это экономия на покупке отладчика.

2--Когда есть загрузчик, то можно делать DevOps.
Автоматически обновлять прошивки после сборки из репозитория.

Особенности данного загрузчика

1--TBFP - это самописный протокол. Полная спецификация на TBFP протокол находится в разработке. Для обновления прошивки payload_id будет всегда один и тот же: 0xFC.

2--Проверка CRC8 отключена так, как код вычисления CRC8 не поместился в EEPROM память.

3--Так как прошивку пришлось утрамбовывать в 8 kByte получилось так, что
в этом загрузчике можно только полностью стереть весь Flash. И только потом аккуратно писать страницы. Это паллиативное решение. Вот так выглядит пакет инициирующий процесс стирания SPI-Flash памяти:
A5 C1 01 00 08 00 FC 00 00 00 00 00 00 03 01 23

Что можно улучшить?
–Как вы могли заметить, в микроконтроллере K1948BK018 RAM памяти аж в два раза больше, чем ROM памяти. Получается, что можно собрать прошивку более функционального вторичного загрузчика и прописать её прямо в RAM память. При этом первичный EEPROМ загрузчик, напротив уменьшить до функции одной только записи RAM памяти через UART.
Первичный загрузчик из ROM принимает более объёмистый вторичный загрузчик в RAM, прыгает в него, а тот уже творит что хочет. Получится уже не гарвардская, а принстонская архитектура компьютера.

Итог

Удалось составить загрузчик, который обладает минимально необходимой системой команд
для загрузки прошивки в off-chip средствами SPIFI трансивера. Сам загрузчик исполняется из EEPROM. То есть из on-chip EEPROM памяти.

Чтобы соответствовать жестким требованиям по нехватке памяти загрузчик пришлось собрать с ключами -Os -flto. Из-за этого стала невозможной пошаговая отладка.

По мере отчаянного утрамбовывания бинаря следует выявлять те сорцы, которые требуют относительно много в сегменте text. Посмотреть размер прошивки по сегментам можно вот такой binutils командой.

riscv-none-elf-size.exe -Bdt  build/*.o
riscv-none-elf-size.exe -Bdt  build/*.o | sort
clear && riscv-none-elf-size.exe -Bdt  build/*.o | sort
readelf -a start_mik32_v1_eeprom_bootloader_m.elf  | grep -i fun | sort -k3
riscv-none-elf-readelf -a start_mik32_v1_eeprom_bootloader_m.elf | grep -i OBJECT | grep -i LOCAL | sort -k3

Тем не менее пошагово отлаживать прошивку можно светодиодом. LED - это, пожалуй, единственный вариант отладки, когда прошивка собрана с жёсточайшей оптимизацией. Даже, если Вы поставите точку останова, то из-за оптимизации курсор окажется в случайном месте. Таким образом пошаговая отладка не поможет. Вот и остаётся ставить себе подсказки при помощи GPIO.

Всю логику протокола TBFP я отладил отдельно в консольной Windows утилите, которая была собрана из тех же самых исходников, что и сама прошивка.

Акронимы

Акроним

Расшифровка

FW

FirmWare

DFU

device firmware update

SPI

Serial Peripheral Interface

SPIFI

Serial Peripheral Interface Flash Interface

UART

universal asynchronous receiver-transmitter

ISR

Interrupt Service Routine

KA

Конечный Aвтомат

TBFP

Trivial Binary Frame Protocol

CRC

Cyclic redundancy check

RAM

Random Access Memory

RISC-V

Reduced instruction set computer V

ASIC

application-specific integrated circuit

EEPROM

Electrically Erasable Programmable Read-Only Memory

Ссылки

#

Название

URL

0

Бинарь загрузчика

https://github.com/aabzel/Artifacts/tree/main/start_mik32_v1_eeprom_bootloader_m

1

Исходный код EEPROM загрузчика (по NDA)

---

2

Атрибуты Хорошего Канального Протокола Передачи Данных

https://habr.com/ru/articles/682292/

3

Атрибуты Хорошего Загрузчика

https://habr.com/ru/articles/754216/

4

NVRAM для микроконтроллеров

https://habr.com/ru/articles/706972/

5

NVRAM из EEPROM

https://habr.com/ru/articles/815639/

6

Микроконтроллер и Bootloader. Описание и принцип работы.

https://microtechnics.ru/mikrokontroller-i-bootloader-opisanie-i-princip-raboty/

7

Настройка ToolChain-а Cборки Прошивок для MIK32 (MIK32 + C+ GCC + GNU Make + OpenOCD)

https://habr.com/ru/articles/854050/

Контрольные вопросы

1-- Зачем нужен загрузчик во встраиваемых системах? Назовите минимум 3 его функции.

2-- Как загрузчик может обмениваться данными с приложением?

3-- В чем опасность вызова функций загрузчика из приложения?

4-- Как защитить микроконтроллер от загрузки чужеродного кода через загрузчик?

5-- Как загрузчику понять, что загрузчик принял в самом деле прошивку, а не набор случайных циферок с правильной CRC?

6-- Можно ли сделать так, чтобы загрузчик стартовал не с адреса начала Main Flash 0x0800_0000, а например с адреса 0x0806_0000?

7-- Вам прислали прошивки в *.bin файле. Как загрузить и запустить эту прошивку по произвольному отступу в on-chip Nor Flash памяти?

Комментарии (24)


  1. VelocidadAbsurda
    25.05.2025 12:25

    Странновато, что 8к оказалось впритык под такое, но, глядя на характеристики данного чипа, упоминающие 16к RAM, напрашивается другой классический путь - первичный загрузчик из ROM принимает более объёмистый вторичный загрузчик в RAM, прыгает в него, а тот уже творит что хочет. Дополнительные плюсы: можно тем же способом загружать и гонять любые сервисные вещи - тестер платы, «читальщик» flash для диагностики сбоев итд. Для уменьшения объёма вторичного загрузчика можно даже переиспользовать фкнкции из ROM (код вашего протокола, к примеру).


    1. aabzel Автор
      25.05.2025 12:25

      Спасибо за идею. Я бы до такого никогда не додумался.


    1. aabzel Автор
      25.05.2025 12:25

      первичный загрузчик из ROM принимает более объёмистый вторичный загрузчик в RAM, прыгает в него, а тот уже творит что хочет.

      Получится уже не гарвардская, а принстонская архитектура компьютера.


    1. aabzel Автор
      25.05.2025 12:25

      странновато, что 8к оказалось впритык под такое

      Вся абсурдность ситуации в том, что мы в 2025 году пытаемся собрать прошивку в 8kByte EEPROM. В Европе такое в последний раз было только в 1982 году. Получается отставание на 43 года!.


      1. okhsunrog
        25.05.2025 12:25

        Ну вы это бросьте, как будто в Европе не используют микроконтроллеры с минимальным объемом flash и не пишут для них загрузчики. Скажу, что видел загрузчик для STM32L0 по функционалу аналогичный вашему, разве что находится во внутренней flash, и прыгает в конце исполнения по другому адресу во внутренней флэш. Так вот, этот загрузчик умещается в 4 кБ


      1. jaha33
        25.05.2025 12:25

        Сейчас как раз таки в тренде супер дешевые контроллеры с малым количеством памяти. Видимо есть под них ниши. Помимо огромного количества китайцев в эту стезю пошли STM с STM32C0, TI с MSPM0C1104, Silabs с EFM8BB5.

        Правда в отличие от Амура все это стоит 0.05...0.20 $ :)


        1. aabzel Автор
          25.05.2025 12:25

          Их ниша- это умные снаряды для мортир .


      1. DrGluck07
        25.05.2025 12:25

        В Xmega был загрузчик 8К. Я в такой утрамбовывал работу с Wiznet 5300. Так что и 10-15 лет назад таким тоже занимались.


  1. almaz1c
    25.05.2025 12:25

    Исходный код EEPROM загрузчика (по NDA)

    Ну хоть не ссылка на телеграмм канал


  1. VM1989
    25.05.2025 12:25

    Terminal v1.9b. Да вы, батенька, из нас, из староверов! Похвально!


    1. aabzel Автор
      25.05.2025 12:25

      А какая ещё существует утилита, чтобы отправлять бинарные hex массивы и при этом позволять выставлять паузы между байтами?


      1. VT100
        25.05.2025 12:25

        Moxa COM-port toolkit. Единственно - не смотрел насчёт пауз. Но, если имеются в виду паузы для переключения направления 485, - то это драйвер порта, а не прикладное ПО.


    1. aabzel Автор
      25.05.2025 12:25

      У Terminal v1.9b аналогов нет.


      1. sami777
        25.05.2025 12:25

        coolterm - вот эта мне больше, чем Terminal v1.9b нравится


      1. rukhi7
        25.05.2025 12:25

        В программе Terminal надо установить паузу между символами. Отладка производилась на значениях в диапазоне 20ms-50ms между байтами. Иначе прошивка может захлебнуться от плотного пучка входных байтов.

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


  1. Antares1991
    25.05.2025 12:25

    У STM32 с libopencm3 в 8кБ флеша можно втиснуть USB MSC загрузчик. Причём штатно, без магии и прямой работы с регистрами.


    1. aabzel Автор
      25.05.2025 12:25

      Что -то верится с трудом.


      1. VelocidadAbsurda
        25.05.2025 12:25

        Вполне реально, на самом деле, ценой меньшей переносимости.

        Насколько вижу по https://github.com/aabzel/Artifacts/blob/main/start_mik32_v1_eeprom_bootloader_m/start_mik32_v1_eeprom_bootloader_m.elf, у вас высокие требования к переносимости кода, всё, что можно, абстрагировано в драйверы с интерфейсами в виде структур. Это в общем случае здорово (если приходится менять чипы итд), но ощутимо ограничивает компилятор в оптимизациях. Простой пример: ваш загрузчик общается через единственный и жёстко заданный UART, но адрес данного UART заносится в структуру UartHandle_t во время исполнения (в uart_mcal_init), оптимизатор не может докопаться до факта, что UART - один и тот же такой-то, в результате совсем низкоуровневая функция отправки массива в UART вынуждена постоянно жонглировать полями структуры (она вообще скомпилировалась так, что готова даже к изменению адреса UART посреди отправки массива). Остальной код выглядит примерно так же - львиная доля объёма (и времени выполнения!) приходится на манипуляции с полями структур драйверов, а не сам функционал загрузчика. Это, в целом, вопрос стиля кода, данный уровень абстракции вполне достижим и с учётом интересов оптимизатора (помнится, на Хабре был цикл статей по программированию микроконтроллеров на С++ с грамотным использованием шаблонов, позволявшим компилятору "разматывать" зависимости до удивительных глубин).

        Но если вам нужно как-то уместить туда CRC8 и забыть, достаточно выкинуть оптимизированные на скорость в сильный ущерб объёму библиотечные memcpy и memset, заменив их своими побайтовыми версиями, и простой CRC8 (без таблицы, побитовый цикл с XOR с полиномом) уж точно влезет в освободившееся место.


      1. pvvv
        25.05.2025 12:25

        https://kevincuzner.com/2018/06/28/building-a-usb-bootloader-for-an-stm32/ - 8кБ

        https://github.com/sfyip/STM32F103_MSD_BOOTLOADER - 13кБ

        у rp2040 bootrom 16кБ

        а если по вот этому основательно напильником пройтись

        https://github.com/adafruit/tinyuf2

        упихать думаю возможно


      1. VelocidadAbsurda
        25.05.2025 12:25

        Вдогонку: вообще странно, что оптимизатор не смог установить факт незименности UART. Доводилось смотреть бинарники на основе STM32 HAL, где вовсю практикуется подобный подход (структуры с контекстами периферии), и там оптимизатор распутывал вызовы наподобие HAL_GPIO_WritePin(&Pin123Handle, 0) вообще до замены вызова на inline запись нужного значения в нужный регистр.

        Сам UartInstance, часом, не как volatile объявлен? (вот этой информации в elf не сохраняется, не вижу). Если нет, можно попробовать перенести инициализацию базового адреса UART из uart_mcal_init (node->UARTx=&UART0) непосредственно в статический инициализатор UartHandle_t UartInstance = { ..., .UARTx=&UART0, ... } (вижу, что он у вас присутствует, некоторые поля UartInstance инициализированы статически). Для пущего эффекта можно даже полю UARTx навесить const.


  1. voughan
    25.05.2025 12:25

    Чем Ваше решение принципиально лучше вот этого?
    https://gitflic.ru/project/elron-tech/elbear_fw_bootloader

    Оно же используется с небольшими доработками и для платы СТАРТ.
    Не сказать, чтобы там всё волшебно, но оно работает, делает вроде бы всё то же самое и занимает около 3-4 кБ в контроллере.


  1. beeruser
    25.05.2025 12:25

    Получится уже не гарвардская, а принстонская архитектура компьютера.

    Не получится. Это аппаратное разделение памяти программ и данных.

    В каких-то девайсах код из ОЗУ не может выполняться. Тут может,

    но в теории должен выполняется медленее чем из ROM.

    Иначе такая арихтектура вообще не имеет смысла.


  1. GambitOZ
    25.05.2025 12:25

    Эх вспомнились времена, когда я писал свой загрузчик для Амура года полтора назад. Когда информации было мало и редкий народ на форуме разработчиков пытался писать костыли для этого процессора. Одним из них была именно проблема с прошивкой записанной в флеш память. Программа там работала с подвисаниями (из-за чего например уарт пропускал символы,...), т.к. флеш по умолчанию никак не инициализировалась. Придумали простой загрузчик в ЕЕпром который инициировал флеш в зависимости от типа памяти и прыгал на адрес загрузки из флеша. А потом уже и с загрузчиком по уарт сделали. Думал эта тема уже давно пройдена, а нет оказывается. Фирма elbear вроде как одна из первых официальный загрузчик по уарту написала. Там у них и терминал толи на базе питона или сишника с открытым кодом был.


  1. d1mk0
    25.05.2025 12:25

    Проверка CRC8 отключена так, как код вычисления CRC8 не поместился в EEPROM память

    Таблицу можно вычислить динамически. В особо тяжелом случае вместо crc можно сумму байт считать.

    В целом в бинарнике слишком много нолей. Вангую - глобальные переменные инициализируются так:

    typedef struct {
      u32 id;
      u8 buffer[32];
    } MegaController_t;
    
    MegaController_t MegaControllers[2] = {
      {
        .id = 0xdeadbeef
      },
      {
        .id = 0xc0febabe
      }
    };

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

    MegaController_t MegaControllers[2];
    
    void main() {
      MegaControllers[0].id = 0xdeadbeef;
      MegaControllers[1].id = 0xc0febabe;
    }