Embedded HandbookПриложения
Все разделы › I. Основы
STM32F103, STM32F407 · уровень базовый · 22 мин чтения
Открыть в интерактивном справочнике

4в. Сборка без среды: GCC, Makefile, стартовый код и свой скрипт компоновщика

Среда разработки удобна, но не обязательна. Прошивку для STM32 можно написать в любом редакторе, а собрать одной командой в терминале. Для этого нужны четыре файла: программа, стартовый код, скрипт компоновщика и Makefile. Раздел показывает все четыре с нуля — без HAL, без библиотеки C и без единой строки ассемблера. Главное здесь — скрипт компоновщика *.ld: как написать его самому и как проверить, что он сделал то, что задумано.

Всё проверено сборкой: arm-none-eabi-gcc 12.2, GNU Make 4.3, запуск в QEMU 7.2 на плате netduinoplus2 (STM32F405 — ядро и память как у F407), отладка GDB (_verify/bare4v/). Для F103 меняются только размеры памяти в скрипте и ключ ядра — об этом в 4в.9.

Как это называется в документации

Оригинал Перевод
toolchain набор инструментов: компилятор, ассемблер, компоновщик, утилиты
linker script скрипт компоновщика: где какая память и что куда кладётся
MEMORY, SECTIONS описание областей памяти и раскладка секций
output section, input section секция образа и секции из объектных файлов
VMA (virtual memory address) адрес, по которому секция работает
LMA (load memory address) адрес, по которому секция хранится в образе
location counter (.) текущий адрес внутри секции
KEEP, --gc-sections «не выбрасывать» и удаление неиспользуемых секций
NOLOAD секция без содержимого в образе
vector table, reset handler таблица векторов, обработчик сброса
weak alias слабый псевдоним: имя, которое можно переопределить
map file карта памяти после компоновки
freestanding программа без операционной системы и без полной библиотеки C

4в.1 Что происходит между исходником и прошивкой

Сборка без среды: gcc превращает main.c и startup.c в объектные файлы, ld по linker.ld раскладывает секции — векторы и код во флеш-память, .data работает в ОЗУ, а хранится во флеш-памяти, .bss без байтов в образе, стек сверху ОЗУ

Одна команда arm-none-eabi-gcc на самом деле запускает цепочку программ:

Шаг Программа Из чего Во что
препроцессор cpp (внутри gcc) .c с #include и #define чистый C
компилятор cc1 C ассемблер
ассемблер as ассемблер объектный файл .o
компоновщик ld (через collect2) все .o + скрипт .ld app.elf
преобразование objcopy app.elf app.bin или app.hex для прошивки

app.elf — полный образ с адресами, символами и отладочными данными: его грузит отладчик. app.bin — только байты флеш-памяти подряд: его пишет программатор. Среда вызывает те же программы, и больше ничего не делает.


4в.2 Программа без HAL

Программа печатает строку в USART1 и проверяет, что стартовый код отработал: переменная с начальным значением должна его получить, а массив без значения — оказаться нулями:

static uint32_t counter = 0x12345678;                  /* .data */
uint32_t zeros[4];                                     /* .bss; не static — иначе компилятор подставит 0 и секция опустеет */
static const char greeting[] = "bare metal: hello\n";  /* .rodata */

Регистры — прямо по адресам, как в разделе 8:

#define RCC_APB2ENR (*(volatile uint32_t *)0x40023844)
#define USART1_SR   (*(volatile uint32_t *)0x40011000)
#define USART1_DR   (*(volatile uint32_t *)0x40011004)
#define USART1_BRR  (*(volatile uint32_t *)0x40011008)
#define USART1_CR1  (*(volatile uint32_t *)0x4001100C)
    RCC_APB2ENR |= 1u << 4;                            /* такт USART1 */
    USART1_BRR = 0x008B;                               /* 115200 бод при 16 МГц: 16 000 000 / 115 200 = 138,9 */
    USART1_CR1 = (1u << 13) | (1u << 3);               /* UE — USART включён, TE — передатчик */

Эмулятору выводы не нужны, а на плате USART1 без настройки PA9 молчит. Перед ним включают порт: такт GPIOA — бит 0 в RCC_AHB1ENR (0x40023830); в GPIOA_MODER (0x40020000) для PA9 биты 19:18 = 10 — альтернативная функция; в GPIOA_AFRH (0x40020024) биты 7:4 = 7 — AF7, это USART1_TX (раздел 8).

Каждый регистр — с volatile (раздел 3.4). Без него ожидание флага TXE при -Os читает регистр один раз: arm-none-eabi-gcc 12.2 сделал из цикла ldr r1, [r2], lsls r1, r1, #24, bmi и переход сам на себя. Стоит прочитать TXE = 0 — и программа висит навсегда, а на плате вывод обрывается на первых символах. QEMU этого не показывает: передатчик у него свободен всегда, и прогон без volatile тоже проходит.

Про zeros без static — не случайность. С static компилятор при -Os видит весь файл, понимает, что в массив никто не пишет, и подставляет вместо чтения ноль: секция .bss оказывается пустой, и проверять нечего. У глобальной переменной другие файлы могут писать в неё, и компилятор обязан читать память.


4в.3 Скрипт компоновщика с нуля

Скрипт отвечает на три вопроса: какая память есть, что куда класть и откуда стартовому коду брать начальные значения. Ниже он идёт четырьмя кусками подряд — вместе они и есть весь linker.ld, со всеми скобками. Сначала память и точка входа:

ENTRY(Reset_Handler)                     /* с чего начинается программа — для отладчика и ELF */

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1024K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

_estack     = ORIGIN(RAM) + LENGTH(RAM); /* вершина стека: стек растёт вниз от конца ОЗУ */
_stack_size = 0x800;                     /* сколько ОЗУ оставить под стек — проверяется в ASSERT */

MEMORY задаёт области: имя, права (r — чтение, w — запись, x — исполнение), начало и размер. Суффикс K — килобайты. _estack — обычный символ скрипта: у него есть адрес, но нет памяти, и программа видит его как extern.

Дальше секции. Первой во флеш-памяти обязана лежать таблица векторов: ядро после сброса читает начальный SP и адрес Reset_Handler из первых двух слов по адресу 0x00000000. При BOOT0 = 0 туда отображена Flash с 0x08000000 (раздел 1.4), поэтому таблицу кладут в её начало:

SECTIONS
{
  .isr_vector :
  {
    KEEP(*(.isr_vector))                 /* таблица векторов — первой, с адреса 0x08000000 */
  } > FLASH

  .text :
  {
    *(.text .text.*)                     /* код */
    *(.rodata .rodata.*)                 /* константы и строки */
    . = ALIGN(4);
    _etext = .;
  } > FLASH

SECTIONS { открывается здесь, а закрывается в последнем куске, после .bss.

*(.text .text.*) — «секции .text и .text.что-угодно из всех объектных файлов». Имена вида .text.main получаются от ключа -ffunction-sections: каждая функция в своей секции, и компоновщик может выбросить неиспользуемые (--gc-sections). KEEP запрещает выбрасывать таблицу векторов: на неё не ссылается ни одна функция. Точка . — текущий адрес; . = ALIGN(4) выравнивает его на слово, _etext = . запоминает конец кода.

Самое важное — .data. У переменной с начальным значением два адреса: работает она в ОЗУ, а значение до включения питания хранится во флеш-памяти:

  .data :
  {
    _sdata = .;                          /* где .data работает — адрес в ОЗУ (VMA) */
    *(.data .data.*)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT > FLASH                     /* а хранится во флеш-памяти сразу за кодом (LMA) */
  _sidata = LOADADDR(.data);             /* откуда стартовый код копирует начальные значения */

> RAM — адрес работы (VMA), AT > FLASH — адрес хранения (LMA). LOADADDR(.data) отдаёт второй. .bss хранить не нужно — там одни нули:

  .bss (NOLOAD) :
  {
    _sbss = .;                           /* нули: во флеш-памяти не хранятся вовсе */
    *(.bss .bss.*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;
  } > RAM

  ASSERT(_ebss + _stack_size <= _estack, "RAM too small for .data + .bss + stack")
}

NOLOAD — у секции есть адреса, но нет байтов в образе. Строка *(.bss .bss.*) обязательна: без неё переменные без начального значения уйдут в секции-сироты, и стартовый код их не обнулит — та же ловушка, что с .data в 4в.10. ASSERT останавливает сборку, если данные и стек не помещаются в ОЗУ: ошибку видно при компоновке, а не при зависании платы.


4в.4 Стартовый код на C

Стартовый файл не обязан быть на ассемблере. Cortex-M сам загружает SP из таблицы векторов, поэтому Reset_Handler может быть обычной функцией C:

#include <stdint.h>

extern uint32_t _estack, _sidata, _sdata, _edata, _sbss, _ebss;   /* символы из linker.ld */
int main(void);

void Reset_Handler(void)
{
    uint32_t *src = &_sidata;
    uint32_t *dst = &_sdata;
    while (dst < &_edata)                /* .data: начальные значения из флеш-памяти в ОЗУ */
        *dst++ = *src++;
    for (dst = &_sbss; dst < &_ebss;)    /* .bss: обнулить */
        *dst++ = 0;
    main();
    for (;;) {
    }
}

До этих двух циклов ни одна глобальная переменная не имеет правильного значения. Поэтому Reset_Handler не читает глобальных переменных, а символы скрипта берёт только по адресу — &_sdata.

Из Reset_Handler возвращаться некуда: после сброса в LR лежит 0xFFFFFFFF. Если main вернётся, ядро перейдёт по этому адресу — в область, откуда команды не выполняются, — и попадёт в HardFault. В опыте без вечного цикла так и вышло: HFSR = 0x40000000, CFSR = 0x00000001 — нарушение выборки команды, поднятое до HardFault, адрес в кадре — 0xFFFFFFFE. Поэтому после вызова стоит вечный цикл.

Обработчики, которых программа не написала, — слабые псевдонимы одной функции-ловушки. Стоит определить в программе функцию SysTick_Handler, и компоновщик возьмёт её, а не псевдоним:

void Default_Handler(void)
{
    for (;;) {                           /* сюда попадает любое прерывание без своего обработчика */
    }
}

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

Таблица векторов — массив указателей в своей секции:

typedef void (*vector_t)(void);

__attribute__((section(".isr_vector"), used))
const vector_t vectors[16] = {
    [0] = (vector_t)&_estack,            /* начальное значение SP — не функция, а адрес */
    [1] = Reset_Handler,
    [3] = HardFault_Handler,
    [15] = SysTick_Handler,
};

Три листинга подряд — уже целый стартовый файл: он собирается и запускается с программой из 4в.2. Здесь 16 векторов ядра, из них заполнены четыре; в startup.c — десять: SP, Reset_Handler и восемь исключений ядра. У настоящей F407 за ними ещё 82 вектора периферии — в стартовом файле ST их 98 (раздел 4б).


4в.5 Makefile

CC      = arm-none-eabi-gcc
OBJCOPY = arm-none-eabi-objcopy
SIZE    = arm-none-eabi-size

CFLAGS  = -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
          -Os -g3 -Wall -Wextra -ffreestanding \
          -ffunction-sections -fdata-sections
LDFLAGS = -T linker.ld -nostdlib -Wl,--gc-sections -Wl,-Map=build/app.map -Wl,--print-memory-usage

build/app.bin: build/app.elf
    $(OBJCOPY) -O binary $< $@

build/app.elf: startup.c main.c linker.ld | build
    $(CC) $(CFLAGS) startup.c main.c $(LDFLAGS) -o $@
    $(SIZE) $@

build:
    mkdir -p build

clean:
    rm -rf build

.PHONY: clean
Ключ Зачем
-mcpu=cortex-m4 -mthumb ядро и набор команд Thumb — другого у Cortex-M нет
-mfpu=fpv4-sp-d16 -mfloat-abi=hard FPU F4 и передача float в его регистрах
-ffreestanding без ОС: компилятор не считает main и библиотеку особенными
-ffunction-sections -fdata-sections каждая функция и переменная в своей секции
-T linker.ld свой скрипт компоновщика
-nostdlib без библиотеки C и без стартовых файлов компилятора
-Wl,--gc-sections выбросить неиспользуемые секции
-Wl,-Map=…, --print-memory-usage карта памяти и строка заполнения областей

linker.ld стоит в зависимостях цели: правка скрипта пересобирает образ. | build — зависимость только по порядку: каталог создаётся до сборки, но его дата образ не пересобирает. make clean удаляет всё собранное. Команды в Makefile начинаются с символа табуляции, а не пробелов — в листинге перед каждой командой стоит именно табуляция. Пробелы вместо неё — частая ошибка при наборе в простом редакторе.


4в.6 Сборка и проверка: карта, секции, образ

Memory region         Used Size  Region Size  %age Used
FLASH:         480 B         1 MB      0.05%
RAM:          20 B       128 KB      0.02%
text       data     bss     dec     hex filename
476       4      16     496     1f0 build/app.elf

Флеш-память — 480 байт: 476 кода с константами и 4 байта начального значения counter. ОЗУ — 20 байт: 4 байта .data и 16 байт .bss. Секции с адресами работы (VMA) и хранения (LMA) — objdump -h:

.isr_vector 00000040 08000000 08000000
.text 0000019c 08000040 08000040
.data 00000004 20000000 080001dc
.bss 00000010 20000004 080001e0

У .data адреса разные: работает с 0x20000000, хранится по 0x080001dc — сразу за кодом. Символы скрипта сходятся:

08000044 T Reset_Handler
080001dc A _sidata
20000000 D _sdata
20000004 D _edata
20000014 B _ebss
20020000 R _estack

Таблица векторов — 0x40 = 64 байта: 16 слов. Первые два слова образа:

000000 20020000 08000045

Начальный SP — 0x20020000, конец ОЗУ. Адрес Reset_Handler — 0x08000045, хотя функция лежит по 0x08000044: младший бит 1 означает Thumb. Компоновщик ставит его сам, потому что в таблице — указатель на функцию. Запиши адрес числом 0x08000044, и ядро упадёт в HardFault на первой команде.

Программа заголовков ELF показывает, что именно попадёт в память:

Entry point 0x8000045
LOAD 0x001000 0x08000000 0x08000000 0x001dc 0x001dc R E 0x1000
LOAD 0x002000 0x20000000 0x080001dc 0x00004 0x00014 RW 0x1000

Второй сегмент — ОЗУ: 4 байта в файле (.data) и 0x14 = 20 байт в памяти (.data + .bss). Разницу, 16 байт нулей, заполняет стартовый код. app.bin — 480 байт, ровно размер флеш-памяти из отчёта.

Секции app.elf: что хранится во Flash и что стартовый код копирует и обнуляет в ОЗУ


4в.7 Запуск и отладка без среды

Запуск в эмуляторе и вывод программы:

qemu-system-arm -M netduinoplus2 -nographic -kernel build/app.elf -serial stdio
bare metal: hello
counter (.data) = 0x12345678
zeros (.bss) = 0x00000000
&counter = 0x20000000, greeting = 0x080001c6
done

Стартовый код скопировал 0x12345678, обнулил массив. Переменная работает в ОЗУ, строка осталась во флеш-памяти.

Отладка: эмулятор с ключами -S -gdb tcp::1234 ждёт отладчика, gdb подключается командой target remote :1234. С платой то же самое, только вместо QEMU — openocd или st-util, а порт — их:

Reset_Handler () at startup.c:11
sp 0x20020000 0x20020000
pc 0x8000044 0x8000044 <Reset_Handler>
0x8000000 <vectors>:    0x20020000  0x08000045  0x08000041  0x08000041
Breakpoint 1, main () at main.c:38
0x20000000 <counter>:   0x12345678
$1 = {0x0, 0x0, 0x0, 0x0}
sp 0x2001fff8 0x2001fff8

До первой команды ядро само взяло SP и PC из таблицы. На входе в main counter уже содержит значение, zeros — нули, а стек опустился на 8 байт. Вектора 2 и 3 — 0x08000041: NMI_Handler и HardFault_Handler — один и тот же Default_Handler.


4в.8 Две ошибки в скрипте, которые молчат или кричат

Мало памяти — кричит. Тот же проект со скриптом, где ОЗУ — 1 КБ:

RAM too small for .data + .bss + stack
collect2: error: ld returned 1 exit status

Сработал ASSERT: 20 байт данных и 0x800 байт стека в килобайт не лезут. Без этой строки сборка прошла бы, а программа затёрла бы свои переменные стеком.

Нет KEEP и ENTRY — молчит. Уберите из скрипта ENTRY(Reset_Handler) и KEEP у таблицы векторов — и соберите с --gc-sections:

без KEEP и ENTRY: text 0

Ни одной ошибки и пустой образ. Компоновщик ищет, до чего можно дойти по ссылкам от точки входа. Точки входа нет, на таблицу векторов никто не ссылается — и он выбросил всё. Прошивка из 0 байт зальётся на плату без возражений. Поэтому в каждом скрипте есть обе строки, а после сборки смотрят отчёт о памяти.


4в.9 F103 и другие камни

Для F103C8 (BluePill) меняется больше, чем кажется: ключи ядра -mcpu=cortex-m3 -mthumb без FPU, размеры в MEMORY (FLASH 64K, RAM 20K), адреса регистров (USART1 — 0x40013800, RCC_APB2ENR — 0x40021018, такт USART1 — бит 14, порта A — бит 2), делитель скорости (HSI у F1 — 8 МГц: 8 000 000 / 115 200 = 69,4, BRR = 0x45) и настройка PA9: в GPIOA_CRH (0x40010804) биты 7:4 = 1011 — альтернативный двухтактный выход 50 МГц. Если нужны библиотека C, printf и malloc, вместо -nostdlib добавляют --specs=nano.specs и пишут _sbrk и _write сами — так устроены sysmem.c и syscalls.c в проекте CubeIDE (раздел 4б). Тогда в скрипте появляются секции .init_array для конструкторов и символ end для кучи.


4в.10 Грабли

Симптом Причина
прошивка залита, плата молчит, образ 0 байт нет KEEP(*(.isr_vector)) или ENTRY, а --gc-sections включён
HardFault на первой команде в таблице адрес без младшего бита: 0x08000044 вместо указателя на функцию
глобальная переменная без начального значения .data без AT > FLASH или стартовый код не копирует её
в .bss мусор стартовый код не обнуляет от _sbss до _ebss
undefined reference to memcpy или __aeabi_… -nostdlib убрал и библиотеку, и libgcc; добавить -lgcc или свои функции
переменные портятся при глубоких вызовах стек налез на данные; нет ASSERT с запасом под стек
make: *** missing separator в команде Makefile пробелы вместо табуляции
в QEMU всё печатается, а на плате вывод обрывается на первых символах регистры объявлены без volatile: ожидание TXE прочитало флаг один раз и крутится вечно
правка .ld не пересобирает образ скрипт не указан в зависимостях цели
.bss пустая, хотя массив объявлен static-массив, в который никто не пишет, компилятор заменил нулём
переменная с начальным значением читается нулём или мусором в скрипте *(.data) без .data.*, а собрано с -fdata-sections: переменная ушла в секцию-сироту
HardFault на первой операции с float, CFSR = 0x00080000 FPU не разрешён в CPACR: ключи -mfpu блок не включают

Две последние строки — прогоном в QEMU. Скрипт, где в .data взяли только *(.data), с -fdata-sections собрался без единого предупреждения. Переменная counter = 0x12345678 ушла в отдельную секцию .data.counter; компоновщик поставил её в ОЗУ по адресу 0x20000000, но в .data она не вошла — _sdata и _edata совпали, и стартовый код ничего не скопировал. Программа напечатала counter = 0x00000000: в QEMU ОЗУ обнулено, на плате там будет то, что осталось после включения. Ключ компоновщика --orphan-handling=warn такие секции показывает, но среди десятка служебных.

Программа с float без записи в CPACR напечатала «before float» и упала: CFSR = 0x00080000 — бит NOCP, сопроцессор недоступен, HFSR = 0x40000000 — отказ поднят до HardFault. С записью CPACR |= 0xF << 20 и барьерами DSB/ISB перед первым float она дошла до «after float: 3». Ключи -mfpu=fpv4-sp-d16 -mfloat-abi=hard лишь разрешают компилятору команды FPU; включает блок стартовый код — в CMSIS это делает SystemInit.


4в.11 Итоги

Полные исходники

linker.ld — скрипт компоновщика с нуля для STM32F405/F407.

/* Раздел 4в: скрипт компоновщика с нуля для STM32F405/F407 — 1 МБ флеш-памяти, 128 КБ ОЗУ.
 * Каждая строка отвечает на один вопрос: где лежит, где работает, откуда берутся начальные значения. */
ENTRY(Reset_Handler)                     /* с чего начинается программа — для отладчика и ELF */

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1024K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

_estack     = ORIGIN(RAM) + LENGTH(RAM); /* вершина стека: стек растёт вниз от конца ОЗУ */
_stack_size = 0x800;                     /* сколько ОЗУ оставить под стек — проверяется в ASSERT */

SECTIONS
{
  .isr_vector :
  {
    KEEP(*(.isr_vector))                 /* таблица векторов — первой, с адреса 0x08000000 */
  } > FLASH

  .text :
  {
    *(.text .text.*)                     /* код */
    *(.rodata .rodata.*)                 /* константы и строки */
    . = ALIGN(4);
    _etext = .;
  } > FLASH

  .data :
  {
    _sdata = .;                          /* где .data работает — адрес в ОЗУ (VMA) */
    *(.data .data.*)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT > FLASH                     /* а хранится во флеш-памяти сразу за кодом (LMA) */
  _sidata = LOADADDR(.data);             /* откуда стартовый код копирует начальные значения */

  .bss (NOLOAD) :
  {
    _sbss = .;                           /* нули: во флеш-памяти не хранятся вовсе */
    *(.bss .bss.*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;
  } > RAM

  ASSERT(_ebss + _stack_size <= _estack, "RAM too small for .data + .bss + stack")
}

startup.c — стартовый код на C: таблица векторов и Reset_Handler.

/* Раздел 4в: стартовый код на C — таблица векторов и Reset_Handler без единой строки ассемблера. */
#include <stdint.h>

extern uint32_t _estack, _sidata, _sdata, _edata, _sbss, _ebss;   /* символы из linker.ld */
int main(void);

void Reset_Handler(void)
{
    uint32_t *src = &_sidata;
    uint32_t *dst = &_sdata;
    while (dst < &_edata)                /* .data: начальные значения из флеш-памяти в ОЗУ */
        *dst++ = *src++;
    for (dst = &_sbss; dst < &_ebss;)    /* .bss: обнулить */
        *dst++ = 0;
    main();
    for (;;) {
    }
}

void Default_Handler(void)
{
    for (;;) {                           /* сюда попадает любое прерывание без своего обработчика */
    }
}

void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void MemManage_Handler(void) __attribute__((weak, alias("Default_Handler")));
void BusFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void UsageFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SVC_Handler(void) __attribute__((weak, alias("Default_Handler")));
void PendSV_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void) __attribute__((weak, alias("Default_Handler")));

typedef void (*vector_t)(void);

__attribute__((section(".isr_vector"), used))
const vector_t vectors[16] = {
    [0] = (vector_t)&_estack,            /* начальное значение SP — не функция, а адрес */
    [1] = Reset_Handler,
    [2] = NMI_Handler,
    [3] = HardFault_Handler,
    [4] = MemManage_Handler,
    [5] = BusFault_Handler,
    [6] = UsageFault_Handler,
    [11] = SVC_Handler,
    [14] = PendSV_Handler,
    [15] = SysTick_Handler,
};

main.c — программа без HAL: печать в USART1 и проверка .data и .bss.

/* Раздел 4в: программа без HAL и без библиотеки C — печать в USART1 и проверка,
 * что стартовый код скопировал .data и обнулил .bss. */
#include <stdint.h>

#define RCC_APB2ENR (*(volatile uint32_t *)0x40023844)
#define USART1_SR   (*(volatile uint32_t *)0x40011000)
#define USART1_DR   (*(volatile uint32_t *)0x40011004)
#define USART1_BRR  (*(volatile uint32_t *)0x40011008)
#define USART1_CR1  (*(volatile uint32_t *)0x4001100C)

static uint32_t counter = 0x12345678;                  /* .data */
uint32_t zeros[4];                                     /* .bss; не static — иначе компилятор подставит 0 и секция опустеет */
static const char greeting[] = "bare metal: hello\n";  /* .rodata */

static void putc1(char c)
{
    while (!(USART1_SR & (1u << 7))) {                 /* TXE: передатчик свободен */
    }
    USART1_DR = (uint32_t)c;
}

static void puts1(const char *s)
{
    while (*s)
        putc1(*s++);
}

static void puthex(uint32_t v)
{
    static const char hex[] = "0123456789abcdef";
    puts1("0x");
    for (int i = 28; i >= 0; i -= 4)
        putc1(hex[(v >> i) & 0xF]);
}

int main(void)
{
    RCC_APB2ENR |= 1u << 4;                            /* такт USART1 */
    USART1_BRR = 0x008B;                               /* 115200 бод при 16 МГц: 16 000 000 / 115 200 = 138,9 */
    USART1_CR1 = (1u << 13) | (1u << 3);               /* UE — USART включён, TE — передатчик */
    puts1(greeting);
    puts1("counter (.data) = ");
    puthex(counter);
    puts1("\nzeros (.bss) = ");
    puthex(zeros[0] | zeros[1] | zeros[2] | zeros[3]);
    puts1("\n&counter = ");
    puthex((uint32_t)&counter);
    puts1(", greeting = ");
    puthex((uint32_t)greeting);
    puts1("\ndone\n");
    for (;;) {
    }
}

data_main.c — опыт из «Граблей»: переменная в секции-сироте .data.counter.

/* Подраздел 4в.10: переменная с начальным значением и -fdata-sections попадает в секцию
 * .data.counter. Скрипт с «*(.data)» её не берёт — она становится сиротой. */
#include <stdint.h>

#define RCC_APB2ENR (*(volatile uint32_t *)0x40023844)
#define USART1_SR   (*(volatile uint32_t *)0x40011000)
#define USART1_DR   (*(volatile uint32_t *)0x40011004)
#define USART1_BRR  (*(volatile uint32_t *)0x40011008)
#define USART1_CR1  (*(volatile uint32_t *)0x4001100C)

uint32_t counter = 0x12345678;                  /* .data.counter при -fdata-sections */

static void putc1(char c)
{
    while (!(USART1_SR & (1u << 7))) {
    }
    USART1_DR = (uint32_t)c;
}

static void puthex(uint32_t v)
{
    static const char hex[] = "0123456789abcdef";
    putc1('0');
    putc1('x');
    for (int i = 28; i >= 0; i -= 4)
        putc1(hex[(v >> i) & 0xF]);
}

int main(void)
{
    RCC_APB2ENR |= 1u << 4;
    USART1_BRR = 0x008B;
    USART1_CR1 = (1u << 13) | (1u << 3);
    const char *s = "counter = ";
    while (*s)
        putc1(*s++);
    puthex(counter);
    putc1('\n');
    for (;;) {
    }
}

fpu_main.c — опыт из «Граблей»: float без разрешения FPU в CPACR.

/* Подраздел 4в.10: FPU включён ключами компилятора, но не разрешён в CPACR.
 * Собирается со стартовым кодом и скриптом раздела 4в; с -DENABLE_FPU перед первым float
 * пишет CPACR. HardFault_Handler здесь свой: печатает CFSR и HFSR и останавливается. */
#include <stdint.h>

#define RCC_APB2ENR (*(volatile uint32_t *)0x40023844)
#define USART1_SR   (*(volatile uint32_t *)0x40011000)
#define USART1_DR   (*(volatile uint32_t *)0x40011004)
#define USART1_BRR  (*(volatile uint32_t *)0x40011008)
#define USART1_CR1  (*(volatile uint32_t *)0x4001100C)
#define SCB_CPACR   (*(volatile uint32_t *)0xE000ED88)
#define SCB_CFSR    (*(volatile uint32_t *)0xE000ED28)
#define SCB_HFSR    (*(volatile uint32_t *)0xE000ED2C)

static void putc1(char c)
{
    while (!(USART1_SR & (1u << 7))) {
    }
    USART1_DR = (uint32_t)c;
}

static void puts1(const char *s)
{
    while (*s)
        putc1(*s++);
}

static void puthex(uint32_t v)
{
    static const char hex[] = "0123456789abcdef";
    puts1("0x");
    for (int i = 28; i >= 0; i -= 4)
        putc1(hex[(v >> i) & 0xF]);
}

void HardFault_Handler(void)
{
    puts1("HardFault CFSR=");
    puthex(SCB_CFSR);
    puts1(" HFSR=");
    puthex(SCB_HFSR);
    puts1("\n");
    for (;;) {
    }
}

volatile float k = 1.5f;

int main(void)
{
    RCC_APB2ENR |= 1u << 4;
    USART1_BRR = 0x008B;
    USART1_CR1 = (1u << 13) | (1u << 3);
#ifdef ENABLE_FPU
    SCB_CPACR |= 0xFu << 20;                     /* CP10 и CP11 — полный доступ */
    __asm volatile("dsb\n isb");
#endif
    puts1("before float\n");
    volatile float x = k * 2.0f;                 /* первая команда FPU */
    puts1("after float: ");
    putc1((char)('0' + (int)x));
    puts1("\n");
    for (;;) {
    }
}

Дальше: раздел 5 «Три уровня абстракции ST» — CMSIS, HAL и LL.

Открыть в интерактивном справочнике