- PVSM.RU - https://www.pvsm.ru -
Когда микроконтроллер получает питание или выходит из аппаратного сброса, выполнение программы начинается задолго до входа в 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-проекте очень часто работа с периферией сводится к прямой записи в регистры по фиксированному адресу. Самый простой вид такой записи выглядит так:
*(volatile uint32_t*)0x40021018 |= (1 << 4);
Разберём эту строку по частям.
Ключевое слово volatile запрещает компилятору “умные” оптимизации вокруг этой переменной. Для обычной памяти это не всегда нужно, а для регистров периферии — обязательно. Компилятор может запомнить предыдущее значение регистра, и при следующем обращении к нему выдать пользователю то самое старое состояние. При этом регистр может измениться аппаратно, вне контроля программы, поэтому каждое обращение должно реально выполняться.
Регистр STM32F1 обычно имеет ширину 32 бита, поэтому используется именно uint32_t.
Это адрес регистра RCC_APB2ENR. Через него включается тактирование периферии на шине APB2.
Бит 4 соответствует порту GPIOC. Пока этот бит не установлен, периферия GPIOC формально существует в адресном пространстве, но не получает clock и не работает.
Такой способ записи выглядит грубовато, зато он очень хорошо показывает сам принцип: код не вызывает “магическую функцию библиотеки”, а меняет конкретный бит в конкретном регистре микроконтроллера.
Позже этот стиль можно сделать читабельнее, если описывать регистры через структуру. Для таймера TIM2 именно так и сделано: вместо голых адресов используется типизированный указатель TIM2.
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 при старте.
Здесь лежит векторная таблица прерываний. Она должна обязательно остаться в итоговом бинарнике, поэтому используется KEEP(...).
Секция исполняемого кода. Обычно она хранится во FLASH.
Константные данные. Тоже размещаются во FLASH.
Инициализированные переменные. После старта они должны находиться в RAM, но их начальные значения хранятся во FLASH, поэтому эта секция размещается как > RAM AT > FLASH.
Неинициализированные переменные. На старте они зануляются вручную.
Эти символы создаёт линкер, а затем использует 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-код — это первый код, который выполняется после подачи питания или аппаратного сброса. До вызова 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.
Конструкция attribute((weak, alias("Default_Handler"))) означает: если отдельный обработчик не определён, вместо него будет использоваться Default_Handler.
Это удобно для минимального проекта. Не нужно реализовывать все обработчики сразу — неописанные прерывания просто уйдут в бесконечный цикл.
После сброса процессор выполняет Reset_Handler. В нём нужно сделать три обязательные вещи:
скопировать .data из FLASH в RAM;
занулить .bss.
вызывать main().
Здесь main() и есть та самая функция, которая фигурирует во всех наших проектах.
Если пропустить этот этап, глобальные переменные окажутся не в том состоянии, которое ожидает программа.
#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().
#ifndef GPIO_H
#define GPIO_H
#include <stdint.h>
void GPIO_init(void);
#endif
#ifndef LED_H
#define LED_H
#include <stdint.h>
void turnOnLED(void);
void turnOffLED(void);
#endif
Именно здесь удобно описать структуру регистра таймера.
#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; становится простой и читаемой.
#ifndef CONFIG_H
#define CONFIG_H
#include "GPIO.h"
#include "Timers.h"
void MCU_init(void);
#endif
#ifndef MAIN_H
#define MAIN_H
#include "config.h"
#include "LED.h"
#endif
Когда проект растёт, удобно собрать все базовые настройки в одну функцию. В этом проекте такой точкой входа становится MCU_init().
#include "config.h"
void MCU_init(void)
{
GPIO_init();
TIMERS_init();
}
Такой подход делает main() короче и понятнее: в нём остаётся только прикладная логика, а детали инициализации уходят в отдельные модули.
Теперь можно перейти к периферии. Первая задача — включить тактирование порта GPIOC и настроить вывод PC13.
Регистр RCC_APB2ENR находится по адресу 0x40021018. Бит 4 включает тактирование GPIOC.
Если этот бит не установить, регистры порта останутся доступны по адресу, но сама периферия не начнёт работать.
Регистр GPIOC_CRH находится по адресу 0x40011004. Он отвечает за ножки с 8-й по 15-ю.
Для PC13 используются биты [23:20]. Сначала они очищаются, затем записывается комбинация:
MODE = 10 — выход 2 МГц;
CNF = 00 — обычный push-pull output.
#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.
Из-за этого логика включения и выключения выглядит немного “наоборот”, но для платы это совершенно нормально.
#include "LED.h"
void turnOnLED(void)
{
*(volatile uint32_t*)0x4001100C &= ~(1 << 13);
}
void turnOffLED(void)
{
*(volatile uint32_t*)0x4001100C |= (1 << 13);
}
Адрес 0x4001100C — это GPIOC_ODR, то есть регистр данных выхода.
В этой задаче таймер работает в режиме обычного счётчика: мы настраиваем предделитель, верхнюю границу счёта и ждём флаг переполнения.
|
Сокращение |
Расшифровка |
Смысл |
|---|---|---|
|
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.
В проекте используются параметры:
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 секунду.
Чтобы таймер реально начал считать, нужно включить его тактирование через RCC_APB1ENR, бит TIM2EN.
Адрес регистра: 0x4002101C
Без этого шага таймер не будет обновлять счётчик, и ожидание флага UIF превратится в бесконечный цикл.
Бит TIM2->SR становится единицей после каждого переполнения таймера. Следовательно, выставлять его в ноль перед запуском таймера - стандартная практика.
#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);
}
Функция построена на опросе флага UIF.
Сначала код ждёт, пока бит UIF в SR не станет равен 1. После этого флаг сбрасывается, и функция завершается.
Это блокирующая задержка: пока она выполняется, основной код не делает ничего другого. Для первого проекта это нормально, потому что такой вариант наглядно показывает сам принцип работы таймера.
Стоит отметить, что таймер после переполнения сбрасывается автоматически. Выставленная в бите UIF единица, не мешает таймеру продолжать считать.
Когда инициализация уже вынесена в отдельные модули, main() остаётся очень короткой. И это хороший признак: прикладная логика читается сразу, без лишнего шума.
#include "main.h"
int main(void){
MCU_init();
while (1){
turnOnLED();
delayOneSecond();
turnOffLED();
delayOneSecond();
}
}
Здесь видно главное: сначала инициализация, затем бесконечный цикл, в котором выполняется только сценарий работы устройства.
На этапе сборки исходники превращаются сначала в ELF-файл, а затем в плоский бинарный образ.
ELF (Executable and Linkable Format) содержит не только машинный код, но и информацию о секциях, символах и отладочных данных.
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 [1]
Этот проект полезен тем, что в нём видно весь путь старта микроконтроллера: от векторной таблицы и linker script до первой реальной работы GPIO и таймера. Такой разбор хорошо помогает не просто “собрать пример”, а понять, почему микроконтроллер вообще начинает выполнять программу и как именно он доходит до main().
Автор: DimKa_exe
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/mikrokontrollery/449549
Ссылки в тексте:
[1] https://github.com/dimchickka/codeForSTM32_fromScrath: https://github.com/dimchickka/codeForSTM32_fromScrath
[2] Источник: https://habr.com/ru/articles/1022976/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1022976
Нажмите здесь для печати.