Когда микроконтроллер получает питание или выходит из аппаратного сброса, выполнение программы начинается задолго до входа в main(). Сначала ядро Cortex-M3 загружает начальный указатель стека, затем берёт адрес обработчика сброса из векторной таблицы и только после этого запускает startup-код.
В минимальном bare-metal проекте без HAL и без CubeMX вся эта цепочка видна почти по шагам. Именно поэтому такой проект хорошо подходит для первого глубокого знакомства со STM32: становится понятно, что происходит в памяти, как работает линкер, зачем нужен startup и почему обычный C-код не может стартовать “сам по себе”.
В этой статье собирается минимальный проект с нуля:
-
собственный linker script;
-
startup-файл;
-
ручная инициализация
.dataи.bss; -
настройка GPIO;
-
управление встроенным светодиодом на PC13;
-
запуск аппаратного таймера TIM2.
Цель здесь не просто заставить мигать светодиод. Цель — увидеть, как микроконтроллер стартует на самом низком уровне и как связаны между собой память, регистры и исполняемый код. Статья ориентирована на начинающих разработчиков. В проекте использовался микроконтроллер по цене чашки кофе STM32F103C8T6.
Что получится в итоге
После сборки и прошивки микроконтроллер будет выполнять простую последовательность:
-
включать светодиод на PC13;
-
ждать 1 секунду;
-
выключать светодиод;
-
снова ждать 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 |
|
код программы, константы, векторная таблица |
|
RAM |
|
переменные, стек, |
В ссылке на стек используется символ:
_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. В нём нужно сделать три обязательные вещи:
-
скопировать
.dataиз FLASH в RAM; -
занулить
.bss. -
вызывать
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/