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

4а. Keil: проект и структура программы

Раздел 4 заканчивается пустым проектом с одним main.c. Через месяц в прошивке десятки файлов, две платы, отладочная и боевая сборки, и всё это должно собираться у коллеги и на сервере так же, как у вас. Здесь — как разложить программу по слоям и как устроить под это проект µVision: группы, пути к заголовкам, цели, компоненты среды, закреплённые версии паков и сборка из командной строки.

Всё показанное — не снимки экрана, а два настоящих проекта, BluePill и Discovery F407, из одних и тех же исходников. Оба собраны самим µVision 5.43 из командной строки, числа — из его отчётов.

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

Оригинал Перевод
project, target проект, цель сборки — набор настроек
source group группа файлов в дереве проекта
Run-Time Environment (RTE) среда выполнения: подключаемые компоненты
software component программный компонент (CMSIS:CORE, Device:Startup)
software pack, device family pack (DFP) пак, пакет поддержки семейства
configuration file файл настроек компонента — копия в проекте
include path, preprocessor define путь к заголовкам, определение препроцессора
scatter file файл раскладки памяти для компоновщика
map file карта памяти после компоновки
static call graph статический граф вызовов
board support package (BSP) слой платы
hardware abstraction layer (HAL) слой, скрывающий железо

4а.1 Три слоя программы

Код прошивки делится на слои, и каждый знает только о том, что под ним:

Слой Папка Что знает Чего не знает
приложение App/ что делает прибор: мигает, считает, отвечает ни регистров, ни выводов
плата Board/ что к какому выводу подключено и как горит светодиод как устроены регистры серии
драйверы Drivers/ регистры: CRL/CRH у F1, MODER/PUPDR у F4 что подключено к выводу

Под ними — CMSIS и файлы камня: стартовый файл, system_*.c, заголовок stm32f10x.h или stm32f4xx.h. Их даёт пак (4а.4).

Три слоя прошивки: приложение, плата, драйверы и CMSIS под ними

Приложение говорит только с платой:

#include "board.h"
#include "app_blink.h"

int main(void)
{
    board_init();
    for (;;) {
        app_blink_step(board_millis());
    }
}

И в его логике тоже нет ни одного регистра:

void app_blink_step(uint32_t now_ms)
{
    static uint32_t last;
    static int on;
    uint32_t period = board_button() ? FAST_MS : PERIOD_MS;

    if (now_ms - last >= period) {      /* разность без знака переживает переполнение */
        last = now_ms;
        on = !on;
        board_led(on);
    }
}

Граница проходит по заголовку платы: board.h объявляет четыре функции и не включает заголовок камня — приложению регистры просто не видны. Это проверяется компилятором: main.c и app_blink.c собираются вообще без путей к заголовкам камня, а строка GPIOC->BSRR = 1u << 13; в том же слое не собирается — «use of undeclared identifier 'GPIOC'».


4а.2 Одна программа — две платы

Слой платы — единственное место, где записано, что к чему подключено. У BluePill светодиод стоит между питанием и выводом, у Discovery — между выводом и землёй, и эта разница живёт в одной строке:

#define LED_PORT GPIOC
#define LED_PIN  13u        /* светодиод к питанию: горит при 0 */

void board_led(int on)
{
    drv_gpio_write(LED_PORT, LED_PIN, !on);
}
#define LED_PORT GPIOD
#define LED_PIN  12u        /* зелёный LD4: горит при 1 */

void board_led(int on)
{
    drv_gpio_write(LED_PORT, LED_PIN, on);
}

Драйвер прячет разницу серий за одним заголовком. Какой заголовок камня включить, решает проект (4а.4):

#include "RTE_Components.h"
#include CMSIS_device_header        /* stm32f10x.h или stm32f4xx.h — решает проект */

/* Один интерфейс на обе серии; как это ложится на регистры — в f1/ и f4/. */
enum drv_gpio_mode { DRV_GPIO_OUT, DRV_GPIO_IN, DRV_GPIO_IN_PULLUP };

У F1 на вывод четыре бита в CRL/CRH, а подтяжку вверх выбирает ODR — странность, которую лучше спрятать в драйвере раз и навсегда:

    *cr = (*cr & ~(0xFu << shift)) | (cfg[mode] << shift);
    if (mode == DRV_GPIO_IN_PULLUP)
        port->BSRR = 1u << pin;     /* у F1 подтяжку вверх выбирает ODR = 1 */

У F4 режим, подтяжка и тип выхода — в разных регистрах:

    port->OTYPER &= ~(1u << pin);                                /* двухтактный выход */
    port->PUPDR = (port->PUPDR & ~(3u << shift)) | (pupdr << shift);
    port->MODER = (port->MODER & ~(3u << shift)) | (moder << shift);

Что это даёт, видно по карте памяти (4а.7) — байты кода каждого модуля в сборке Release:

Модуль BluePill (F103) Discovery (F407)
main.o 14 14
app_blink.o 56 56
board.o 120 104
drv_gpio.o 94 84
drv_systick.o 72 72

Приложение одинаково до байта. Меняются только плата и драйвер GPIO; drv_systick.c вообще один файл на обе серии — SysTick часть ядра, а не периферии ST.


4а.3 Папки на диске и группы в проекте

Дерево в окне Project — это группы µVision, а не папки: файл в группе хранит свой путь на диске. Правило простое — одна группа на одну папку, как здесь App, Board, Drivers. В файле проекта группа выглядит так:

<GroupName>Board</GroupName>
<Files>
<File>
<FileName>board.c</FileName>
<FileType>1</FileType>
<FilePath>.\Board\bluepill\board.c</FilePath>
</File>

Заголовки кладут в группы только ради навигации: компилятор ищет их по путям из Options for Target → C/C++ → Include Paths. Здесь это .\App;.\Board;.\Drivers — пути от файла проекта, поэтому проект открывается в любой папке и на любой машине. Абсолютный путь вида C:\Users\…\Drivers работает только у автора.

µVision 5.43 с проектом keil4a_f407: в окне Project цель F407 Debug и группы App, Board, Drivers, CMSIS, Device; в редакторе main.c; в Build Output — сборка цели F407 Debug: Program Size Code=1356, 0 Error(s), 0 Warning(s)


4а.4 Компоненты среды: CMSIS и стартовый файл

Для работы с регистрами в Manage Run-Time Environment хватает двух компонентов, как в разделе 4.4: CMSIS → CORE и Device → Startup. µVision копирует файлы настроек компонента в проект, в RTE\Device\<камень>\:

BluePill Discovery
стартовый файл startup_stm32f10x_md.s startup_stm32f407xx.s
системный файл system_stm32f10x.c system_stm32f4xx.c

Рядом с каждым — файл *.base@версия, нетронутый оригинал из пака: при обновлении пака µVision сравнивает с ним вашу копию и предлагает слить изменения. Правят копию в RTE, а не файл в каталоге пака — там правка пропадёт при первом же обновлении.

Для каждой цели µVision создаёт RTE\_<цель>\RTE_Components.h, в котором одна полезная строка — #define CMSIS_device_header "stm32f10x.h" у BluePill и "stm32f4xx.h" у Discovery. Поэтому драйвер пишет #include CMSIS_device_header и один и тот же заголовок собирается под обе серии. Определения камня — STM32F10X_MD и STM32F407xx — µVision берёт из описания камня в паке и передаёт компилятору сам: в отличие от сборки из раздела 4.8, руками их писать не нужно.


4а.5 Цели: Debug и Release

Цель — набор настроек под одним именем. В каждом проекте их две:

Debug Release
оптимизация -O0 — пошаговая отладка идёт по строкам -Oz — меньше кода
определение DEBUG=1 NDEBUG
файл HEX нет да
папки Objects\F103_Debug\, Listings\F103_Debug\ …\F103_Release\

Своя папка у каждой цели обязательна: с общей папкой сборка одной цели затирает объектные файлы другой. Что меняет оптимизация:

BluePill Debug BluePill Release Discovery Debug Discovery Release
код, байт 1718 1062 1356 872
наибольший стек, байт 72 28 72 24

Стек — из статического графа вызовов, который µVision кладёт рядом с прошивкой (Objects\<цель>\keil4a.htm, строка «Maximum Stack Usage»). Переключаются цели списком на панели инструментов, собрать все сразу — Project → Batch Build.


4а.6 Что µVision передаёт компилятору

Настоящая командная строка каждого файла лежит в Objects\<цель>\keil4a_<цель>.dep. Для main.c в цели «F407 Release» там, среди прочего:

Флаги Откуда
-mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard камень: ядро M4 с плавающей точкой
-std=c11 -Oz -Wall -Wextra вкладка C/C++ цели
-I ./App -I ./Board -I ./Drivers ваши пути к заголовкам
-I./RTE/_F407_Release папка с RTE_Components.h этой цели
пути в CMSIS/5.9.0 и STM32F4xx_DFP/2.17.1 паки, закреплённые за проектом (4а.9)
-DSTM32F407xx -D_RTE_ -D__UVISION_VERSION="543" описание камня и сама среда
-DNDEBUG ваше определение

У BluePill в списке есть ещё ./RTE/Device/STM32F103C8: компонент Device:Startup пака F1 кладёт туда заголовок настроек RTE_Device.h. У F407 заголовков в этой папке нет — нет и пути.

По этому списку сборку легко повторить вне µVision — например, в CMake из раздела 115.


4а.7 Раскладка памяти и карта

Options for Target → Target у цели F407 Debug: IROM1 с 0x8000000 размером 0x100000 и IRAM1 с 0x20000000 размером 0x20000 — из этих полей µVision пишет файл раскладки

Пока в Options for Target → Linker стоит галочка «Use Memory Layout from Target Dialog», µVision сам пишет файл раскладки из объёмов памяти камня. Для BluePill:

LR_IROM1 0x08000000 0x00010000  {    ; load region size_region
  ER_IROM1 0x08000000 0x00010000  {  ; load address = execution address
   *.o (RESET, +First)
   *(InRoot$$Sections)
   .ANY (+RO)
   .ANY (+XO)
  }
  RW_IRAM1 0x20000000 0x00005000  {  ; RW data
   .ANY (+RW +ZI)
  }
}

Таблица векторов (RESET) — первой по адресу 0x08000000, код и константы — следом, переменные — в ОЗУ с 0x20000000. Свой файл раскладки пишут, когда этого мало: место под загрузчик, область без обнуления, код в ОЗУ — раздел 11.3.

Карта памяти (Listings\<цель>\keil4a.map) после компоновки показывает, сколько стоит каждый модуль, — из неё таблица в 4а.2. Две строки стоит знать в лицо. Стартовый файл занимает 1536 байт ОЗУ — это стек 0x400 и куча 0x200, заданные в нём самом. Весь образ BluePill — 1352 байта Flash (Total ROM Size), Discovery — 1316.

Первая запись файла HEX подтверждает раскладку:

:10000000780600201D0300082503000827030008C8

Первое слово — 0x20000678, начальная вершина стека: 0x20000000 + 1656, ровно столько занимают переменные со стеком и кучей. Второе — 0x0800031D, адрес Reset_Handler с единицей в младшем бите: команды Thumb.

Файл HEX можно посмотреть и без платы — в STM32CubeProgrammer открыть его вкладкой «Open file». У цели «F407 Release» это один кусок с адреса 0x08000000 длиной 0x528 = 1320 байт — на 4 больше, чем Total ROM Size. Это заполнитель: за переменной SystemCoreClock компоновщик выравнивает следующую область ОЗУ на 8 байт и кладёт в образ 4 пустых байта (PAD в карте), а в сумму их не считает. В первой строке таблицы та же вершина стека 0x20000678, а Reset_Handler здесь — 0x080003A1:

STM32CubeProgrammer без подключённой платы (Not connected): открыт keil4a_f407.hex цели F407 Release — адрес 0x8000000, размер 0x528, в журнале один сегмент; первые слова 20000678 и 080003A1 — вершина стека и Reset_Handler


4а.8 Что класть в git

Файл или папка В git? Почему
*.uvprojx да цели, группы, пути, компоненты, версии паков
RTE\Device\… с файлами *.base@версия да ваши копии стартового и системного файлов
*.uvoptx по договорённости настройки отладчика и окон; общий ST-Link — да
*.uvguix.<имя> нет раскладка окон одного человека
RTE\_<цель>\ нет RTE_Components.h создаётся при сборке
Objects\, Listings\ нет результаты сборки
DebugConfig\ нет, пока не правили создаётся из пака при сборке

Файл .gitignore проекта:

Objects/
Listings/
DebugConfig/
RTE/_*/
*.uvguix.*
*.bak
build_*.log

4а.9 Закрепите версии паков

По умолчанию µVision берёт самые новые из установленных паков — какие бы версии ни были записаны в проекте. Опыт на тех же двух проектах, из которых убрано закрепление:

Error #540: 'Keil::Device:Startup@2.6.3' component is not available for target 'F407 Release', pack 'Keil::STM32F4xx_DFP@2.17.1' is not selected

Лечится в Project → Manage → Select Software Packs: снять «Use latest versions of all installed Software Packs» и отметить нужные версии. µVision запишет выбор в файл проекта:

<package name="STM32F4xx_DFP" schemaVersion="1.7.2" url="http://www.keil.com/pack/" vendor="Keil" version="2.17.1">
<targetInfos>
<targetInfo name="F407 Release" versionMatchMode="fixed" />
</targetInfos>
</package>

С закреплением оба проекта собираются с CMSIS 5.9.0 и своими пакетами семейства — у кого угодно и через год.


4а.10 Сборка из командной строки

Тот же µVision собирает проект без окна:

UV4 -r keil4a_f407.uvprojx -t "F407 Release" -j0 -o build.log

-r — пересобрать всё (-b — только изменённое), -t — цель, -j0 — без окна, -o — файл отчёта. В конце отчёта — те же строки, что в окне Build Output: Program Size: Code=872 RO-data=440 RW-data=4 ZI-data=1652 и 0 Error(s), 0 Warning(s). Код возврата:

Код Что было Строка в отчёте
0 чисто 0 Error(s), 0 Warning(s)
1 предупреждения warning: unused variable 'unused', 1 Warning(s)
2 ошибки error: use of undeclared identifier 'missing', 1 Error(s)
3 и больше 3 — фатальная ошибка; 11 — файл проекта не открыть на запись; 12 — камня с таким именем нет в базе (обычно не установлен пак); 13 — ошибка записи проекта; 15 — ошибка чтения XML при импорте; 20 — ошибка преобразования проекта —

На сервере сборки (раздел 110) провалом считают любой код, кроме 0 (или кроме 0 и 1, если предупреждения в проекте разрешены), и собирают с -r: полная пересборка не зависит от времён файлов.


4а.11 Грабли

Симптом Причина
у коллеги file not found на своём же заголовке в Include Paths абсолютный путь
правка стартового файла пропала после обновления пака правили файл в каталоге пака, а не копию в RTE\Device\
undefined symbol при компоновке, хотя файл в папке есть файл не добавлен в группу: µVision собирает только файлы из групп
прерывание не срабатывает, ошибок нет опечатка в имени обработчика: остался слабый символ из стартового файла (раздел 71.6)
вторая цель «портит» первую общая папка Objects у двух целей
чужой проект: «uses RTOS support that is part of a Legacy Pack» в настройках цели включена старая ОС RTX (Operating system: RTX Kernel)
у коллеги другая версия CMSIS или Error #540 версии паков не закреплены (4а.9)
прошивка на 60 % больше ожидаемой -O0 в цели Release: 1718 байт вместо 1062
перенос на другую плату — правка всего кода регистры в слое приложения

4а.12 Итоги

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

main.c — слой приложения: только плата и своя логика, ни одного регистра.

/* Раздел 4а: слой приложения. Ни одного регистра и ни одного вывода —
 * только плата (board.h) и своя логика (app_blink.h). */
#include "board.h"
#include "app_blink.h"

int main(void)
{
    board_init();
    for (;;) {
        app_blink_step(board_millis());
    }
}

app_blink.c — логика мигания: медленно или быстро в зависимости от кнопки.

#include "app_blink.h"
#include "board.h"

#define PERIOD_MS 500u
#define FAST_MS   100u

void app_blink_step(uint32_t now_ms)
{
    static uint32_t last;
    static int on;
    uint32_t period = board_button() ? FAST_MS : PERIOD_MS;

    if (now_ms - last >= period) {      /* разность без знака переживает переполнение */
        last = now_ms;
        on = !on;
        board_led(on);
    }
}

board.c (BluePill) — что к какому выводу подключено на плате BluePill (STM32F103C8).

/* Плата BluePill (STM32F103C8): что к какому выводу подключено. */
#include "board.h"
#include "drv_gpio.h"
#include "drv_systick.h"

#define LED_PORT GPIOC
#define LED_PIN  13u        /* светодиод к питанию: горит при 0 */
#define BTN_PORT GPIOA
#define BTN_PIN  0u         /* кнопка на землю: нажата — 0 */

void board_init(void)
{
    SystemCoreClockUpdate();
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPCEN;
    drv_gpio_mode(LED_PORT, LED_PIN, DRV_GPIO_OUT);
    drv_gpio_mode(BTN_PORT, BTN_PIN, DRV_GPIO_IN_PULLUP);
    board_led(0);
    drv_systick_start(SystemCoreClock);
}

void board_led(int on)
{
    drv_gpio_write(LED_PORT, LED_PIN, !on);
}

int board_button(void)
{
    return !drv_gpio_read(BTN_PORT, BTN_PIN);
}

uint32_t board_millis(void)
{
    return drv_systick_ms();
}

board.c (Discovery F407) — что к какому выводу подключено на плате Discovery F407.

/* Плата Discovery F407 (STM32F407VG): что к какому выводу подключено. */
#include "board.h"
#include "drv_gpio.h"
#include "drv_systick.h"

#define LED_PORT GPIOD
#define LED_PIN  12u        /* зелёный LD4: горит при 1 */
#define BTN_PORT GPIOA
#define BTN_PIN  0u         /* кнопка USER: нажата — 1, подтяжка вниз стоит на плате */

void board_init(void)
{
    SystemCoreClockUpdate();
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIODEN;
    drv_gpio_mode(LED_PORT, LED_PIN, DRV_GPIO_OUT);
    drv_gpio_mode(BTN_PORT, BTN_PIN, DRV_GPIO_IN);
    board_led(0);
    drv_systick_start(SystemCoreClock);
}

void board_led(int on)
{
    drv_gpio_write(LED_PORT, LED_PIN, on);
}

int board_button(void)
{
    return drv_gpio_read(BTN_PORT, BTN_PIN);
}

uint32_t board_millis(void)
{
    return drv_systick_ms();
}

drv_gpio.h — один интерфейс GPIO на обе серии.

#ifndef DRV_GPIO_H
#define DRV_GPIO_H

#include <stdint.h>
#include "RTE_Components.h"
#include CMSIS_device_header        /* stm32f10x.h или stm32f4xx.h — решает проект */

/* Один интерфейс на обе серии; как это ложится на регистры — в f1/ и f4/. */
enum drv_gpio_mode { DRV_GPIO_OUT, DRV_GPIO_IN, DRV_GPIO_IN_PULLUP };

void drv_gpio_mode(GPIO_TypeDef *port, uint32_t pin, enum drv_gpio_mode mode);
void drv_gpio_write(GPIO_TypeDef *port, uint32_t pin, int high);
int drv_gpio_read(const GPIO_TypeDef *port, uint32_t pin);

#endif

drv_gpio.c (F1) — GPIO для F1: четыре бита CNF и MODE на вывод.

/* GPIO для F1: на вывод четыре бита — CNF (два старших) и MODE (два младших). */
#include "drv_gpio.h"

static const uint32_t cfg[] = {
    [DRV_GPIO_OUT] = 0x2u,          /* CNF = 00, MODE = 10: выход 2 МГц */
    [DRV_GPIO_IN] = 0x4u,           /* CNF = 01, MODE = 00: вход без подтяжки */
    [DRV_GPIO_IN_PULLUP] = 0x8u,    /* CNF = 10, MODE = 00: вход с подтяжкой */
};

void drv_gpio_mode(GPIO_TypeDef *port, uint32_t pin, enum drv_gpio_mode mode)
{
    volatile uint32_t *cr = (pin < 8u) ? &port->CRL : &port->CRH;
    uint32_t shift = (pin % 8u) * 4u;

    *cr = (*cr & ~(0xFu << shift)) | (cfg[mode] << shift);
    if (mode == DRV_GPIO_IN_PULLUP)
        port->BSRR = 1u << pin;     /* у F1 подтяжку вверх выбирает ODR = 1 */
}

void drv_gpio_write(GPIO_TypeDef *port, uint32_t pin, int high)
{
    port->BSRR = high ? (1u << pin) : (1u << (pin + 16u));
}

int drv_gpio_read(const GPIO_TypeDef *port, uint32_t pin)
{
    return (int)((port->IDR >> pin) & 1u);
}

drv_gpio.c (F4) — GPIO для F4: режим, подтяжка и тип выхода в разных регистрах.

/* GPIO для F4: режим, подтяжка и тип выхода — в разных регистрах, по два бита на вывод. */
#include "drv_gpio.h"

void drv_gpio_mode(GPIO_TypeDef *port, uint32_t pin, enum drv_gpio_mode mode)
{
    uint32_t shift = pin * 2u;
    uint32_t moder = (mode == DRV_GPIO_OUT) ? 1u : 0u;          /* 01 — выход, 00 — вход */
    uint32_t pupdr = (mode == DRV_GPIO_IN_PULLUP) ? 1u : 0u;    /* 01 — подтяжка вверх */

    port->OTYPER &= ~(1u << pin);                                /* двухтактный выход */
    port->PUPDR = (port->PUPDR & ~(3u << shift)) | (pupdr << shift);
    port->MODER = (port->MODER & ~(3u << shift)) | (moder << shift);
}

void drv_gpio_write(GPIO_TypeDef *port, uint32_t pin, int high)
{
    port->BSRR = high ? (1u << pin) : (1u << (pin + 16u));
}

int drv_gpio_read(const GPIO_TypeDef *port, uint32_t pin)
{
    return (int)((port->IDR >> pin) & 1u);
}

Дальше: раздел 4б «STM32CubeIDE: проект, сборка, отладка и разбор сбоев».

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