Когда микроконтроллер получает питание или выходит из аппаратного сброса, выполнение программы начинается задолго до входа в main(). Сначала ядро Cortex-M3 загружает начальный указатель стека, затем берёт адрес обработчика сброса из векторной таблицы и только после этого запускает startup-код.

В минимальном bare-metal проекте без HAL и без CubeMX вся эта цепочка видна почти по шагам. Именно поэтому такой проект хорошо подходит для первого глубокого знакомства со STM32: становится понятно, что происходит в памяти, как работает линкер, зачем нужен startup и почему обычный C-код не может стартовать “сам по себе”.

В этой статье собирается минимальный проект с нуля:

  • собственный linker script;

  • startup-файл;

  • ручная инициализация .data и .bss;

  • настройка GPIO;

  • управление встроенным светодиодом на PC13;

  • запуск аппаратного таймера TIM2.

Цель здесь не просто заставить мигать светодиод. Цель — увидеть, как микроконтроллер стартует на самом низком уровне и как связаны между собой память, регистры и исполняемый код. Статья ориентирована на начинающих разработчиков. В проекте использовался микроконтроллер по цене чашки кофе STM32F103C8T6.

Что получится в итоге

После сборки и прошивки микроконтроллер будет выполнять простую последовательность:

  1. включать светодиод на PC13;

  2. ждать 1 секунду;

  3. выключать светодиод;

  4. снова ждать 1 секунду.

Структура проекта

Сразу учимся разделять код на несколько слоёв:

full_program_from_scratch/
├── inc/
│   ├── config.h
│   ├── GPIO.h
│   ├── LED.h
│   ├── main.h
│   └── Timers.h
├── src/
│   ├── config.c
│   ├── GPIO.c
│   ├── LED.c
│   ├── main.c
│   └── Timers.c
├── startup/
│   └── startup.c
├── myLinker.ld
├── firmware.elf
└── firmware.bin

Базовая идея bare-metal подхода

В bare-metal-проекте очень часто работа с периферией сводится к прямой записи в регистры по фиксированному адресу. Самый простой вид такой записи выглядит так:

*(volatile uint32_t*)0x40021018 |= (1 << 4);

Разберём эту строку по частям.

volatile

Ключевое слово volatile запрещает компилятору “умные” оптимизации вокруг этой переменной. Для обычной памяти это не всегда нужно, а для регистров периферии — обязательно. Компилятор может запомнить предыдущее значение регистра, и при следующем обращении к нему выдать пользователю то самое старое состояние. При этом регистр может измениться аппаратно, вне контроля программы, поэтому каждое обращение должно реально выполняться.

uint32_t

Регистр STM32F1 обычно имеет ширину 32 бита, поэтому используется именно uint32_t.

0x40021018

Это адрес регистра RCC_APB2ENR. Через него включается тактирование периферии на шине APB2.

(1 << 4)

Бит 4 соответствует порту GPIOC. Пока этот бит не установлен, периферия GPIOC формально существует в адресном пространстве, но не получает clock и не работает.

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

Позже этот стиль можно сделать читабельнее, если описывать регистры через структуру. Для таймера TIM2 именно так и сделано: вместо голых адресов используется типизированный указатель TIM2.

Linker script: карта памяти проекта

Linker script объясняет линкеру, где в памяти микроконтроллера должны лежать разные части программы. Именно он связывает логические секции .text, .data, .bss, .isr_vector с физической памятью STM32.

У linker script в этом проекте четыре основных задачи:

  • описать области памяти;

  • разложить секции программы по этим областям;

  • создать служебные символы для startup-кода;

  • обеспечить правильный старт программы после сброса.

    Предлагаю взглянуть на линкер файл:

_estack = = ORIGIN(RAM) + LENGTH(RAM);

MEMORY{
    FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 0x00010000
    RAM   (rwx): ORIGIN = 0x20000000, LENGTH = 0x00005000
}

SECTIONS{
    .isr_vector :
    {
        KEEP(*(.isr_vector))
    } > FLASH

    .text :
    {
        *(.text)
        *(.text*)
    } > FLASH

    .rodata :
    {
        *(.rodata)
        *(.rodata*)
    } > FLASH

    .data :
    {
        _sdata = .;  //Точный адрес в RAM
        *(.data)
        *(.data*)
        _edata = .;
    } > RAM AT > FLASH

    _sidata = LOADADDR(.data); //Точный адрес во FLASH памяти

    .bss :
    {
        _sbss = .;
        *(.bss)
        *(.bss*)
        _ebss = .;
    } > RAM
}

Области памяти

Для STM32F103C8T6 используются две основные области:

Область

Базовый адрес

Назначение

FLASH

0x08000000

код программы, константы, векторная таблица

RAM

0x20000000

переменные, стек, .data, .bss

В ссылке на стек используется символ:

_estack = ORIGIN(RAM) + LENGTH(RAM);

Он указывает на вершину RAM и становится первым элементом векторной таблицы. Именно это значение Cortex-M3 загружает в регистр SP при старте.

Секции

.isr_vector

Здесь лежит векторная таблица прерываний. Она должна обязательно остаться в итоговом бинарнике, поэтому используется KEEP(...).

.text

Секция исполняемого кода. Обычно она хранится во FLASH.

.rodata

Константные данные. Тоже размещаются во FLASH.

.data

Инициализированные переменные. После старта они должны находиться в RAM, но их начальные значения хранятся во FLASH, поэтому эта секция размещается как > RAM AT > FLASH.

.bss

Неинициализированные переменные. На старте они зануляются вручную.

Зачем нужны sidata, sdata, edata, sbss, _ebss

Эти символы создаёт линкер, а затем использует startup-код.

  • _sidata — адрес исходных данных во FLASH;

  • _sdata — начало .data в RAM;

  • _edata — конец .data в RAM;

  • _sbss — начало .bss;

  • _ebss — конец .bss.

Без этих меток startup-код не смог бы понять, что именно нужно копировать и что нужно занулять.

И теперь очень простым языком

*(.data) : Это поиск секций с точным именем .data. Сюда попадают обычные инициализированные глобальные переменные.

*(.data*) : Звездочка в конце означает «любое продолжение».

Представьте, что у вас есть два файла: main.c и sensor.c. Компилятор делает из них main.o и sensor.o. В каждом из них есть своя маленькая коробочка с надписью .data. Линковщик видит вашу инструкцию *(.data). Он идет в main.o, забирает оттуда содержимое .data, затем идет в sensor.o, забирает данные оттуда и склеивает их в одну большую секцию .data в итоговом бинарном файле.

В больших проектах может не существовать секции .data для конкретного .o файла. Эта секция может быть разбита на много маленьких. Например, секция может называться .data.a. Тогда, наблюдая только инструкцию *(.data),линковщик не найдёт точного совпадения. Именно поэтому пишем *(.data*).


Startup-файл: что происходит сразу после сброса

Startup-код — это первый код, который выполняется после подачи питания или аппаратного сброса. До вызова main() микроконтроллер ещё не готов к обычной работе, поэтому сначала нужно подготовить память.

Линкерные символы

В startup-файле обычно объявляются внешние символы:

extern uint32_t _estack;
extern uint32_t _sidata;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;

Это не переменные в обычном смысле. Это адреса, созданные линкером. Они позволяют C-коду понять, где находятся границы секций памяти.

Векторная таблица

Векторная таблица — это массив адресов обработчиков. Процессор не ищет обработчик по имени. Он просто берёт адрес из нужной позиции таблицы.

В минимальном варианте достаточно описать Reset_Handler, NMI_Handler и HardFault_Handler. Остальные обработчики можно пока направить в Default_Handler.

weak alias

Конструкция attribute((weak, alias("Default_Handler"))) означает: если отдельный обработчик не определён, вместо него будет использоваться Default_Handler.

Это удобно для минимального проекта. Не нужно реализовывать все обработчики сразу — неописанные прерывания просто уйдут в бесконечный цикл.

Reset_Handler

После сброса процессор выполняет Reset_Handler. В нём нужно сделать три обязательные вещи:

  1. скопировать .data из FLASH в RAM;

  2. занулить .bss.

  3. вызывать main().

    Здесь main() и есть та самая функция, которая фигурирует во всех наших проектах.

Если пропустить этот этап, глобальные переменные окажутся не в том состоянии, которое ожидает программа.

Полный startup.c

#include <stdint.h>

extern uint32_t _estack;
extern uint32_t _sidata;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;

int main(void);

void Reset_Handler(void);
void Default_Handler(void);

void NMI_Handler(void)       __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void)  __attribute__((weak, alias("Default_Handler")));

__attribute__((used, section(".isr_vector")))
const void* vector_table[] =
{
    &_estack,
    Reset_Handler,
    NMI_Handler,
    HardFault_Handler
};

void Reset_Handler(void){
    uint32_t* src = &_sidata; // Адрес начала данных во FLASH
    uint32_t* dst = &_sdata;  // Адрес начала данных в RAM

    while (dst < &_edata){
        *dst++ = *src++;      // Копируем данные из FLASH в RAM
    }

    dst = &_sbss;

    while (dst < &_ebss){  // Адрес начала секции .bss в RAM
        *dst++ = 0;        //Зануляем неинициализированные переменные
    }

    main();

    while (1){
    }
}

void Default_Handler(void){
    while (1){
    }
}

Заголовочные файлы: интерфейсы модулей

В inc/ лежат заголовочные файлы. Их задача — описать, какие функции и структуры доступны другим частям проекта.

Такой подход помогает не смешивать реализацию и интерфейс. Один .c-файл не должен “знать лишнего” о внутренностях другого .c-файла, если достаточно просто увидеть его прототипы.

При включении питания CPU смотрит в адрес 0x00000000, берёт оттуда указатель на стек. Далее CPU читает адрес 0x00000004 - это адрес Reset_Handler.После CPU начинает выполнять ResetHandler() и оттуда уже прыгает в main().

inc/GPIO.h

#ifndef GPIO_H
#define GPIO_H

#include <stdint.h>

void GPIO_init(void);

#endif

inc/LED.h

#ifndef LED_H
#define LED_H

#include <stdint.h>

void turnOnLED(void);
void turnOffLED(void);

#endif

inc/Timers.h

Именно здесь удобно описать структуру регистра таймера.

#ifndef TIMERS_H
#define TIMERS_H

#include <stdint.h>

void TIMERS_init(void);
void delayOneSecond(void);

typedef struct
{
    volatile uint32_t CR1;   // 0x00
    volatile uint32_t CR2;   // 0x04
    volatile uint32_t SMCR;  // 0x08
    volatile uint32_t DIER;  // 0x0C
    volatile uint32_t SR;    // 0x10
    volatile uint32_t EGR;   // 0x14
    volatile uint32_t CCMR1; // 0x18
    volatile uint32_t CCMR2; // 0x1C
    volatile uint32_t CCER;  // 0x20
    volatile uint32_t CNT;   // 0x24
    volatile uint32_t PSC;   // 0x28
    volatile uint32_t ARR;   // 0x2C
    volatile uint32_t RCR;   // 0x30
    volatile uint32_t CCR1;  // 0x34
    volatile uint32_t CCR2;  // 0x38
    volatile uint32_t CCR3;  // 0x3C
    volatile uint32_t CCR4;  // 0x40
    volatile uint32_t BDTR;  // 0x44
    volatile uint32_t DCR;   // 0x48
    volatile uint32_t DMAR;  // 0x4C
} TIM_TypeDef;

#define TIM2 ((TIM_TypeDef*)0x40000000)

#endif

Здесь TIM2 — это не “обычный объект” C, а типизированный доступ к блоку регистров по фиксированному адресу. В итоге запись вида TIM2->PSC = 7999; становится простой и читаемой.

inc/config.h

#ifndef CONFIG_H
#define CONFIG_H

#include "GPIO.h"
#include "Timers.h"

void MCU_init(void);

#endif

inc/main.h

#ifndef MAIN_H
#define MAIN_H

#include "config.h"
#include "LED.h"

#endif

Единая точка инициализации: MCU_init()

Когда проект растёт, удобно собрать все базовые настройки в одну функцию. В этом проекте такой точкой входа становится MCU_init().

#include "config.h"

void MCU_init(void)
{
    GPIO_init();
    TIMERS_init();
}

Такой подход делает main() короче и понятнее: в нём остаётся только прикладная логика, а детали инициализации уходят в отдельные модули.

Настройка GPIOC и ножки PC13

Теперь можно перейти к периферии. Первая задача — включить тактирование порта GPIOC и настроить вывод PC13.

Включение clock для GPIOC

Регистр RCC_APB2ENR находится по адресу 0x40021018. Бит 4 включает тактирование GPIOC.

Если этот бит не установить, регистры порта останутся доступны по адресу, но сама периферия не начнёт работать.

Настройка режима PC13

Регистр GPIOC_CRH находится по адресу 0x40011004. Он отвечает за ножки с 8-й по 15-ю.

Для PC13 используются биты [23:20]. Сначала они очищаются, затем записывается комбинация:

  • MODE = 10 — выход 2 МГц;

  • CNF = 00 — обычный push-pull output.

Полный GPIO.c

#include "GPIO.h"

void GPIO_init(void)
{
    //Бит 4-й регистра RCC_APB2ENR устанавливается в единицу
    *(volatile uint32_t*)0x40021018 |= (1 << 4);

    //Очищение битов [23:20] регистра GPIOC_CRH
    *(volatile uint32_t*)0x40011004 &= ~(0b1111 << 20);  

    //Запись битов [23:20] регистра   GPIOC_CRH
    *(volatile uint32_t*)0x40011004 |=  (0b0010 << 20);  
}

Управление светодиодом

На многих платах с STM32F103C8T6 встроенный светодиод на PC13 подключён по схеме active-low.

Это означает:

  • чтобы включить светодиод, нужно записать 0;

  • чтобы выключить — 1.

Из-за этого логика включения и выключения выглядит немного “наоборот”, но для платы это совершенно нормально.

Полный LED.c

#include "LED.h"

void turnOnLED(void)
{
    *(volatile uint32_t*)0x4001100C &= ~(1 << 13);
}

void turnOffLED(void)
{
    *(volatile uint32_t*)0x4001100C |= (1 << 13);
}

Адрес 0x4001100C — это GPIOC_ODR, то есть регистр данных выхода.

Таймер TIM2

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

Основные сокращения

Сокращение

Расшифровка

Смысл

TIM

Timer

аппаратный таймер

PSC

Prescaler

предделитель частоты

ARR

Auto-Reload Register

значение автоперезагрузки

CR1

Control Register 1

основной регистр управления

SR

Status Register

регистр состояния

UIF

Update Interrupt Flag

флаг события обновления

Частота таймера

После сброса STM32F103 обычно использует внутренний генератор HSI на 8 МГц.

Если отдельная настройка clock tree не выполняется, можно считать, что:

  • HCLK = 8 MHz;

  • PCLK1 = 8 MHz;

  • TIM2CLK = 8 MHz.

Формирование задержки 1 секунда

В проекте используются параметры:

TIM2->PSC = 7999;
TIM2->ARR = 999;

Формула работы таймера:

f_counter = f_tim / (PSC + 1)

Подставляем значения:

f_counter = 8 000 000 / (7999 + 1) = 1000 Hz

Это значит, что один тик счётчика длится 1 миллисекунду.

Дальше ARR = 999 даёт 1000 тиков, то есть ровно 1 секунду.

Важная деталь: включение clock для TIM2

Чтобы таймер реально начал считать, нужно включить его тактирование через RCC_APB1ENR, бит TIM2EN.

Адрес регистра: 0x4002101C

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

Бит TIM2->SR становится единицей после каждого переполнения таймера. Следовательно, выставлять его в ноль перед запуском таймера - стандартная практика.

Полный Timers.c

#include "Timers.h"

static void TIM2_init(void);

void TIMERS_init(void)
{
    TIM2_init();
}

static void TIM2_init(void)
{
    //Ставим единичку в регистр RCC_APB1ENR в бит TIM2EN
    *(volatile uint32_t*)0x4002101C |= (1 << 0);

    TIM2->SR  &= ~(1 << 0);
    TIM2->PSC  = 7999;
    TIM2->ARR  = 999;
    TIM2->CR1 |= (1 << 0);
}

void delayOneSecond(void)
{
    //Как только TIM2->SR бит UIF = 1, значит прошла 1 секунда
    while ((TIM2->SR & (1 << 0)) == 0){}

    //Сбрасываем Status Register, чтобы продолжить фиксировать переполнения
    TIM2->SR &= ~(1 << 0); 
}

Как работает delayOneSecond()

Функция построена на опросе флага UIF.

Сначала код ждёт, пока бит UIF в SR не станет равен 1. После этого флаг сбрасывается, и функция завершается.

Это блокирующая задержка: пока она выполняется, основной код не делает ничего другого. Для первого проекта это нормально, потому что такой вариант наглядно показывает сам принцип работы таймера.

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

Основной файл main.c

Когда инициализация уже вынесена в отдельные модули, main() остаётся очень короткой. И это хороший признак: прикладная логика читается сразу, без лишнего шума.

#include "main.h"

int main(void){
    MCU_init();

    while (1){
        turnOnLED();
        delayOneSecond();

        turnOffLED();
        delayOneSecond();
    }
}

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

Как получается firmware.elf и firmware.bin

На этапе сборки исходники превращаются сначала в ELF-файл, а затем в плоский бинарный образ.

firmware.elf

ELF (Executable and Linkable Format) содержит не только машинный код, но и информацию о секциях, символах и отладочных данных.

firmware.bin

BIN — это уже чистый бинарный образ без служебной структуры. Именно его обычно прошивают во FLASH микроконтроллера.

Пример сборки

arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -nostdlib -Iinc \
-T myLinker.ld startup/startup.c src/main.c src/config.c src/GPIO.c \
src/LED.c src/Timers.c -o firmware.elf

arm-none-eabi-objcopy -O binary firmware.elf firmware.bin

Сначала линкер собирает все объекты в firmware.elf, учитывая startup.c и myLinker.ld. Затем objcopy извлекает из ELF только полезный бинарный образ.

Словарь аббревиатур

Аббревиатура

Расшифровка

Значение в проекте

MCU

Microcontroller Unit

сам микроконтроллер

RCC

Reset and Clock Control

блок тактирования и сброса

GPIO

General Purpose Input/Output

обычные ножки ввода-вывода

ODR

Output Data Register

регистр выходных данных

TIM

Timer

аппаратный таймер

PSC

Prescaler

предделитель

ARR

Auto-Reload Register

верхняя граница счёта

CR1

Control Register 1

основной регистр управления таймером

SR

Status Register

регистр флагов состояния

UIF

Update Interrupt Flag

флаг обновления

APB

Advanced Peripheral Bus

шина периферии

PCLK1

Peripheral Clock 1

тактирование APB1

HSI

High Speed Internal

внутренний RC-генератор 8 МГц

FLASH

Flash memory

память программы

RAM

Random Access Memory

оперативная память

ELF

Executable and Linkable Format

файл результата компоновки

Полные исходники проекта

Все исходники проекта можете посмотреть на GitHub: https://github.com/dimchickka/codeForSTM32_fromScrath

Итог

Этот проект полезен тем, что в нём видно весь путь старта микроконтроллера: от векторной таблицы и linker script до первой реальной работы GPIO и таймера. Такой разбор хорошо помогает не просто “собрать пример”, а понять, почему микроконтроллер вообще начинает выполнять программу и как именно он доходит до main().

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


  1. zurabob
    13.04.2026 20:43

    Написано здорово, правда заголовку не соотаетствует. Но никогда, НИКОГДА, не надо использовать магические числа в коде программы, все константы через дефайны с понятным именем, зависимое от частоты - формулой от частоты, режимы пинов через макрос от номера пина и режима, заданного тоже енумом или дефайном…


    1. DimKa_exe Автор
      13.04.2026 20:43

      Спасибо! Полностью согласен, заголовок поправил


  1. Koyanisqatsi
    13.04.2026 20:43

    Насчёт регистров - думаю стоит упомянуть такую штуку как SVD (System View Description). Это XMLка с описанием всех регистров микроконтроллера, сделанная вендором, из которой получается заголовочный файл со всем, что нужно. Переписывать вручную регистры из Reference Manual не стоит - такой подход это источник ошибок.


    1. DimKa_exe Автор
      13.04.2026 20:43

      Спасибо за комментарий! Действительно, так на много лучше


  1. Albert2009Zi
    13.04.2026 20:43

    Чисто моё мнение, но для полноты, вместо просто команды кросскомпилятору:
    arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -nostdlib -Iinc \-T myLinker.ld startup/startup.c src/main.c src/config.c src/GPIO.c \src/LED.c src/Timers.c -o firmware.elfarm-none-eabi-objcopy -O binary firmware.elf firmware.bin

    я бы добавил абзац про сборку проекта через make. Мне кажется, статья, как пособие для начинающих, с этим смотрелась бы более целостно.


    1. pvvv
      13.04.2026 20:43

      не надо,

      к тому моменту, когда начинающие доберутся до сборки проектов таких размеров, которые имеет смысл пересобирать не полностью shell скриптом, а частично, походу и с системой сборки освоются, по мере необходимости


      1. DimKa_exe Автор
        13.04.2026 20:43

        Тоже так думаю


    1. Spider55
      13.04.2026 20:43

      Тогда уж сразу cmake. Зачем забивать людям голову этим кошмаром.


      1. aabzel
        13.04.2026 20:43

        Нежные. Make-a испугались.


  1. SeyKo4
    13.04.2026 20:43

    Теперь осталось написать как загрузить firmware.bin в указанную stm32. Короче, подробнее про загрузку.


    1. DimKa_exe Автор
      13.04.2026 20:43

      Принято!


    1. aabzel
      13.04.2026 20:43

      осталось написать как загрузить firmware.bin в указанную stm32. Короче, подробнее про загрузку.

      Есть такой текст

      https://habr.com/ru/articles/975880/
      Программатор из обломка платы Nucleo


  1. Sergey_12345
    13.04.2026 20:43

    Спасибо, очень хорошо все разложено по всем полочкам. Если бы встретил такую статью 15 лет назад (когда сам начинал), вот это было бы счастье !!!


  1. 5erG0
    13.04.2026 20:43

    Отличная статья.

    Спасибо!


  1. aabzel
    13.04.2026 20:43

    Структура проекта

    Сразу учимся разделять код на несколько слоёв:

    А где же в Вашем проекте самое главное - файлы для системы сборки?
    Ведь код сам собой не соберется в hex файл.


  1. aabzel
    13.04.2026 20:43

    inc/Timers.h

    Именно здесь удобно описать структуру регистра таймера.

    Зачем Вам этот аппаратный таймер?
    Подключите внутриядерный DWT таймер.
    Пуск DWT Таймера на ARM Cortex-M (или Ядерный Таймер)
    https://habr.com/ru/articles/1005622/


    1. DimKa_exe Автор
      13.04.2026 20:43

      Хорошо, учту ваши комментарии на будущее


  1. aabzel
    13.04.2026 20:43

    Когда проект растёт, удобно собрать все базовые настройки в одну функцию. В этом проекте такой точкой входа становится MCU_init().

    А может лучше собрать все указатели на функции init в массив указателей?
    Архитектура Xорошего Кода Прошивки (Массив-Наше Всё)
    https://habr.com/ru/articles/816589/


  1. aabzel
    13.04.2026 20:43

    Мне нравится, что startup написан на Си, а не на assembler. Поставил + тексту.
    Это делает код переносимее между компиляторами, так как asm-gcc и asm-iar разные, как снежинки.


  1. copitoch
    13.04.2026 20:43

    Крайне благодарен автору, наконец то нашел образец код-only, без всякой дебильной проперитарщины в виде CubeMX\CubeIDE


    1. aabzel
      13.04.2026 20:43

       без всякой дебильной проперитарщины в виде CubeMX\CubeIDE

      Да. Именно так.
      Почему Сборка с Помощью GUI-IDE — это Тупиковый Путь

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