Хорошая техника для уменьшения размера прошивки — перенос функций в бутлоадер. Если в коде есть какие-то большие функции, которые не будут изменяться в будущем (например, настройка периферии или инициализация массивов) — имеет смысл навсегда спрятать их в бутлоадер и не передавать их снова при каждой перепрошивке.

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

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

Чем больше выделяемый кусок кода — тем выгоднее. Вынос lwIP в бутлоадер экономит вам 55 килобайт, которые можно использовать для других задач, или взять кристалл с меньшим объёмом памяти, а то и обеспечить хранение трёх прошивок в памяти (текущая, новая и резервная) для увеличения надёжности.

Пример

Давайте для примера возьмём инициализацию периферии для UART. Эта небольшая функция будет включать модули GPIOA и USART1, и настраивать оба эти модуля: включать нужные ножки GPIO на вход и выход, выбирать скорость USART1 9600 бод и включать прерывания USART.

void init_HW()
{
	RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_AFIOEN | RCC_APB2ENR_USART1EN;

	GPIOA->CRH &= ~GPIO_CRH_CNF9;
	GPIOA->CRH |=  GPIO_CRH_CNF9_1 | GPIO_CRH_MODE9_0 | GPIO_CRH_CNF10_0;

	USART1->BRR = USART_BRR;
	USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;

	USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
	NVIC_EnableIRQ(USART1_IRQn);

	__enable_irq();
}

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

void main()
{
	init_HW();
	while(1);
}

Давайте скомпилируем этот код с оптимизацией Medium. MAP-файл резюмирует:

init_HW 0x080003b5 0x4a Code Gb main.o [1]

Размер функции — 74 (0x4a) байта, немного, но в какой-то ситуации может оказаться что именно эти байты не дают впихнуть прошивку в небольшой кристалл.

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

init_HW 0x0800015c 0x4a Code Gb bootloader.o [1]

Теперь нужно научиться её вызывать из другой программы. Пару слов об использовании всего этого дела: конечно, две программы должны располагаться в разных частях флеша, и обязательно в разных страницах флеша (это регулируется настройками линкера). Сначала вы прошиваете бутлоадер (в адрес 0x00000000), а потом основную программу, например начиная с третьей страницы (в адрес 0x00000800). Программатор сотрёт только ту часть флеша, которую собирается использовать, поэтому первые две страницы останутся нетронутыми.

Такую операцию нужно будет проводить при любом изменении бутлоадера, а при работе с основной прошивкой можно прошивать только её — бутлоадер останется с прошлого раза.

Для вызова «чужой» функции нам потребуются указатели на функцию — особое объявление функции вместе с указанием её адреса. Для такой простой функции без аргументов и возвращаемого значения указатель будет очень прост:

void (*init_HW)(void) = (void(*)(void))0x0800015c;

Небольшая сложность заключается в том, что мы должны явно указать адрес функции, сначала узнав его из мэп-файла, или запустив программу и посмотрев адрес в отладчике. Автоматизировать это тяжело, разве что написанием pre-build скриптов.

Теперь можно вызывать функцию по указателю:

(*init_HW)();

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

uint32_t init(uint8_t I2C_own_address, uint16_t *dataBuf_RX, uint16_t *dataBuf_TX)

то объявление указателя нужно будет написать так:

uint32_t (*init)(uint8_t, uint16_t*, uint16_t*) = (uint32_t(*)(uint8_t, uint16_t*, uint16_t*))0x0800015c;

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

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

Заключение

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

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

В дополнение, этот файл с функцией можно собрать с более высокой или низкой степенью оптимизации, или вообще на другом языке (C/C++, а то и на Rust :)), в этом отношении такой подход полностью копирует поведение технологии DLL.