Загрузчик

Программисты тоже люди, а им свойственно допускать ошибки. Часть из этих ошибок может быть обнаружена и исправлена в ходе тестирования, однако это не исключает возможность упустить некоторые из них и выпустить на рынок устройство с «глючной» прошивкой и багами (англ. bug, жук1). Какие-то будут не критичными, а другие резко понизят потребительские свойства. Разумеется, партию можно отозвать, перепрошить на заводе и отправить обратно. Такой подход практикуется: компания Toyota отзывала целые партии автомобилей. Это сильно бьет по бюджету и репутации. Некоторые компании и вовсе выпускают устройство с заведомо недоделанной прошивкой. Например, портретный режим в iPhone 7 был анонсирован на презентации, но в действительности эта функция стала доступна спустя пару месяцев с обновлением операционной системы.

Конечно, у конечного пользователя вряд ли окажется под рукой программатор, да и вверять ему такой критический процесс опасно. По закону Мёрфи в руках неопытного пользователя гарантированно что-то пойдет не так. Плюс ко всему, прошивку придется поместить в открытый доступ2, а значит, недобросовестные конкуренты смогут просто скопировать устройство.

Для упрощения процесса перепрошивки используется загрузчик (англ. bootloader, от bootstrap, петля сзади ботинка, + loader, загрузчик). С его помощью устройство способно само себя прошить, зачастую без разбора корпуса и даже без подключения каких-либо проводов, по воздуху. Загрузчик — это не что иное, как программа, которая загружается раньше основного приложения и принимает решение о необходимости обновляться (или чего-то еще, например, произвести диагностику оборудования3). Прошивку он может брать из разных источников: из памяти или по интерфейсу передачи данных (UART, SPI, I^2^C, CAN, USB и т.д.) от другого устройства или узла. В большинстве микроконтроллеров реализация загрузчика уже имеется — например, в Arduino (используется микроконтроллер AVR от компании Atmel) загрузчик позволяет заливать образ прошивки через UART, лишая при этом возможности отлаживать программу, т.е. вы не можете остановить выполнение программы и посмотреть состояние регистров, значение переменных и т.д. Микроконтроллеры STM32 могут загружаться из трех разных мест4, в зависимости от логических уровней на ножках boot0 и boot1. Если boot0 подтянута к земле, то загрузка начинается из флеш-памяти, начиная с адреса 0x08000000 (обычный режим работы). Если boot1 подтянута к земле, а boot0 к питанию, то загрузка начинается из системной области памяти, которую нельзя изменить. Там хранится записанный на заводе UART-загрузчик. Если обе ножки подтянуты к питанию, микроконтроллер попробует запуститься, считывая прошивку из оперативной памяти, начиная с 0x20000000.

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

На крайнем левом рисунке изображено классическое приложение, т.е. из таких, которые мы реализовывали до этого в главах про машину состояний и ОСРВ. Карта памяти посередине иллюстрирует работу с системным загрузчиком, который находится в области памяти, доступной только для чтения. И последняя картинка, справа, это тот случай, когда в программе используется самописный загрузчик.

Так как загрузчик располагается в памяти до самой прошивки, то его стоит делать настолько маленьким, насколько это возможно. Стоит, однако, обратить внимание: во флеш-памяти нельзя стереть произвольный бит. Особенности реализации позволяют стирать только всю страницу (англ. page) целиком, и в нашем конкретном случае это 1 Кб памяти, а в stm32l4, например, — это 2 Кб. Таким образом, минимальный размер загрузчика 1 Кб: даже если вы уместите его в 100 байт, остальные 924 байта вы использовать не сможете, не затерев при этом загрузчик. В случае если он не поместился в 1024 и занял 1025 байт, под загрузчик придется отвести уже 2 страницы.

Концептуально всё очень просто, но что бы написать загрузчик, придётся разобраться в процессе запуска микроконтроллера чуть детальнее. Перечитайте подглаву «Компоновщик», дабы освежить память. Как мы уже там упомянули, хоть формально точкой входа в программу служит функция main(), но на самом деле до неё выполняется обработчик сброса, Reset_Handler. Он выставляет адрес стека (MSP), _estack берётся из скрипта компоновщика, и передаёт управление функции LoopCopyDataInit.

LoopCopyDataInit инициализирует секцию .data и передаёт управление LoopFillZerobss.

LoopFillZerobssинициализирует секцию .bss, вызывает функцию SystemInit() из system_stm32f1xx.c (или тот который соответствует контроллеру вашего семейства), __libc_init_array предназначен для вызова конструкторов C++ классов и только затем управление переходит к main().

Важно отметить, что сама функция Reset_Handler объявлена как слабая,

т.е. она может быть переопределена в коде.

Следующее о чём нужно знать — это то как, компилятор должен уложить наш код, чтобы программа работала корректно. В самом начале прошивки лежит начальное значение указателя стека, который задаётся в Reset_Hadler, т.е. MSP, после чего укладывается таблица векторов прерываний от Reset_Handler далее вниз по списку определённого в startup-файле.

Чтобы убедиться в этом, загляните в файл компоновщика, а конкретно в начало определения секции FLASH:

KEEP указывает линковщику не оптимизировать данный участок и оставить его нетронутым. А затем, ещё и в startup-файл:

Зачем нам все это нужно? Для понимания последовательности действий, которую необходимо произвести перед переходом к участку кода расположенному в другой части флеш-памяти.

Во-первых, когда запускается Reset_Handler предполагается, что вся периферия находится в состоянии по умолчанию, значит: если в загрузчике использовалась какая-либо перифирия, то её нужно сбросить в начальное состояние. Во-вторых, нельзя допустить чтобы во время перехода к основной программе произошло какое-либо прерывание, т.е. все прерывания должны быть отключены5. В-третьих, таблица векторов прерываний — это ни что иное как массив указателей на функции-обработчики. Очевидно, в разных прошивках адреса могут и будут отличаться! Заливая прошивку в адрес отличный от 0x08000000 контроллер сам не поймёт откуда эти указатели нужно брать, а точнее будет пытаться их брать начиная с адреса 0x08000004 (начало флеш-памяти плюс 4 байта, .word, под _estack), где находится загрузчик. Поэтому, нужно перенести адрес таблицы векторов прерываний в новое место. В-четвёртых, так как ключевой частью выполнения какой-либо программы является стек, то следом нужно сбросить стек, задав новое положение (_estack). И только после этого, в-пятых, можно переходить на Reset_Handler из прошивки основной программы (так как точка входа она, а не main()).

Если используется библиотека HAL, сбросить периферию можно следующей функцией:

Отключить прерывание можно интринсик-функцией __disable_irq(). Их нужно будет включить обратно в основной прошивке через интринсик-функцию __enable_irq().

Для того чтобы указать новое положение таблицы векторов прерываний в Cortex-M0+/M1/M3/M4/M7 используется регистр VTOR, из System Control Block (заметим что в Cortex-M0 такого регистра нет, как перенести вектор в таком случае рассмотрим чуть позже).

Далее установим MSP.

Если вы специально ничего не меняете, то оба значения _estack, что в основной программе, что в загрузчике, будут эквивалентны, а значит и нужды забирать данные из начала основной программы не обязательно, можно взять из прошивки загрузчика. Смысл в том, чтобы сбросить стек, поэтому нужно не забыть вызывать эту функцию.

Осталось создать указатель на Reset_Handler основной прошивки, и вызвать её.

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

После вызова app() управление передаётся Reset_Handler другой прошивке, в которой... будет вызвана функция SystemInit() (файл system_stm32f1xx.c). И тут нужно посмотреть на её содержимое:

Выходит, стандартная реализация Reset_Handler затирает то значение VTOR, которое мы устанавливаем в загрузчике (т.е. его там можно и не устанавливать). Выхода два[^lib_editing], либо переопределить Reset_Handler, либо выставить значение VTOR заново. Значение адреса лучше взять из заголовочного файла с настройкой самого загрузчика. Так как мы его не создавали в конкретном примере, давайте лучше получим доступ к переменной, объявленной в файле линковщика!

Согласно комментарию к макросу VECT_TAB_OFFSET значение должно быть кратно 0x200 (512 байт, на некоторых МК адрес должен быть кратен сектору памяти). Но располагать значение посреди страницы не очень удобно. В нашем примере под загрузчик отводится 10 Кб, т.е. сама прошивка начинается с 10 страницы (0x08002800).

На этом программа загрузчика завершена, осталось лишь добавить логику — откуда-то взять новую прошивку, проверить её целостность6 и записать её во флеш по указанному адресу.

Перенос таблицы в Cortex-M0

Всё вышеперечисленное сработает на Cortex-M0+/M3/M4/M7, но не сработает на Cortex-M0 (прерывания работать не будут), так как в нём нет регистра VTOR. Что делать в этом случае? Решение есть. Дело в том, что на самом деле все работает с адреса 0x00000000, а не с 0x08000000 или 0x20000000. При запуске контроллер считывает состояние ножек BOOTx и ремапит (создаёт алиас) для адреса 0x000000000. Т.е. при загрузке с флеш, алиасом адресу 0x08000000 служит 0x00000000. Так как адреса в памяти — это просто числа и не важно где они хранятся, то выйти из ситуации можно просто скопировав таблицу из флеш в оперативную память и переключиться на неё.

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

Единственное, вам нужно посчитать количество прерываний и записать их в макрос N_HANDLERS. Далее, так как мы используем некоторую часть оперативной памяти, нужно сообщить линковщику, чтобы тот не использовал её для своих нужд.

Для запуска из оперативной памяти нужно подтянуть обе ножки BOOTx к питанию. Но... это проблематично, так как при переходе нам потребуется сбросить всю периферию, а значит GPIO для этого использовать не получится, да и считываться они не будут, так как самого сброса нет, есть только переход к Reset_Handler, а чтение происходит аппаратно до входа в обработчик сброса. К счастью, это лишь один из способов, в STM32 есть регистр отвечающий за ремапинг адресов — CFGR17, находящийся в блоке SYSCFG.

Таблица векторов в виде структуры

Во встраиваемых системах общепринято использовать структуры для работы с регистрами, посмотрите на любую периферию в CMSIS.

Здесь память приводится к структуре с известными смещениями (uint32_t), которые соответствуют положениям регистров. Точно такой же подход можно применить к таблице векторов прерываний.

Единая прошивка

Когда мы говорим о массовом производстве, то невольно встаёт вопрос рационализации процесса. Если до этого нужно было заливать одну прошивку, теперь придётся две. Если с одним устройством это, скажем, пару лишних операций, дополнительные 10 секунд, то вот когда вам нужно прошить 1000 устройств... Начинаются проблемы, ведь это уже не 10 секунд, а 10 тысяч секунд. Жизнь слишком коротка, чтобы тратить её на прошивку устройств, нужно две прошивки как-то объединить.

Компилятор на выходе генерирует *.efl-файл, в котором помимо прошивки хранятся адреса, а так же информация необходимая для отладки. Из этого файла можно сгенерировать либо *.bin-файл, в котором будет храниться только бинарный код, либо *.hex-файл, в котором помимо прочего хранится адрес куда нужно заливать прошивку. Если для получения нужного *.bin-файла придётся их сливать заполняя отсутствующие байты заглушками (англ. padding), то hex можно просто склеить8. В Linux достаточно использовать утилиту cat.

API загрузчика

One more thing… Часть кода можно вынести в прошивку загрузчика и не дублировать его в основной программе. Есть правда одно существенное ограничение: все используемые переменные, функции и т.д. должны использовать только известные адреса в памяти. Другими словами, функция не должна пытаться обратиться к внешней по отношению к ней переменной, так как в контексте основной прошивки её никто не создавал! Вы не можете вызвать, например, функцию HAL_Delay() из программы загрузчика, так как она работает с внешней переменной uwTickFreq, а так же вызывает функцию HAL_GetTick(), которая возвращает другую внешнюю переменную uwTick.

Функция может использовать переменные или массивы объявленные с модификатором const, потому что они хранятся в секции .rodata, во флеш-памяти, а значит их адреса известны и вшиты в код функции на этапе компиляции.

Чтобы пользоваться функциями в основной программе из загрузчика нам нужно создать собственную таблицу указателей на функции (по аналогии с таблицей векторов прерываний). Всё что нам нужно обеспечить — известный адрес для этого массива в основной программе. Выхода два: а) разместить массив указателей во флеш, например после таблицы векторов прерываний; б) сделать то же, что мы делали с Cortex-M0, т.е. поместить их в оперативную память. Второй способ проще, рассмотрим его и приведём весьма синтетический пример — помигаем светодиодом.

Во-первых, две константы, две функции для управления светодиодом:

Создадим массив указателей9. Удобнее всего это сделать через структуру:

Заполним её,

и модифицируем файл компоновщика, сместив положение начала оперативной памяти:

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

В основной программе приведём уже созданный тип (структуру) к началу SRAM и воспользуемся функциями.

Важно: отлаживать приложение не получится! При прошивке микроконтроллер сбрасывается, а значит сбрасывается и SRAM, в которой до этого хранились нужные адреса функций.


1 Этот термин, по одной из версий, вошёл в обиход после обнаружения Грейс Хоппер обгоревшего мотылька между замкнувшими контактами одной из плат компьютера Harvard Mark II (9 сентября 1946 года) при попытке найти ошибку в программе.
2 Как правило, крупные компании предоставляют прошивку своих устройств сервисным центрам по запросу.
3 Каждый раз включая свой компьютер, запускается BIOS, который начинает так называемю процедуру Power-On Self-Test или сокр. POST, т.е. самотестирование при включении. Вашему устройству наличие самопроверки при старте может быть тоже необходимо. Если вы управляете плавильной печью, то запускаться при неработающем термодатчике может быть опасно.
4 При наличии FMC (Flexible Memory Controller) из пяти: FMC NVM, FMC SDRAM.
5 Когда происходит запись (стирание) во флеш, контроллер памяти не позволит читать из неё (см. PM0075) и отправит программу в HardFault(). Так как код прерывания находится внутри флеша, то выполнить его в процессе стирания не возможно. Если по какой-то причине нужно обеспечить возможность их выполнения, то таблицу векторов, а так же код самих функций нужно вынести либо в оперативную память, либо во внешнюю.
6 В STM32 для этих целей можно использовать модуль CRC (англ. cyclic redundancy check).
7 В Cortex-M3/4/7 регистр называется MEMRMP.
8 Я не уверен что это правильно, т.к. внутри файла есть последовательность обозначающая конец файла, однако склеенный файл заливается успешно через CubeProgrammer.
9 Вы можете использовать и обычный массив, но в таком случае вы потеряете возможность контролировать их сигнатуру. Используя структуру, вы можете задавать произвольную сигнатуру, достаточно определить её тип.