Жизнь без CubeMX: Как прошить STM32, имея под рукой только блокнот

от автора

Когда микроконтроллер получает питание или выходит из аппаратного сброса, выполнение программы начинается задолго до входа в 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.elfarm-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().

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