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).
Приложение говорит только с платой:
#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 работает только у автора.

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 → 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:

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 берёт самые новые из установленных паков — какие бы версии ни были записаны в проекте. Опыт на тех же двух проектах, из которых убрано закрепление:
- BluePill собрался, но уже с CMSIS 6.2.0 вместо 5.9.0 — это видно по путям
в
.dep. Сегодня разницы нет, завтра она может оказаться в любом макросе. - Discovery не собрался вовсе: самый новый пак F4, 3.1.1, устроен иначе
и компонента
Device:Startupв нём нет:
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.o14 байт,app_blink.o56. - Одна группа — одна папка, пути к заголовкам относительные:
.\App;.\Board;.\Drivers. CMSIS:COREиDevice:Startupкопируют стартовый и системный файлы вRTE\Device\<камень>\— править эти копии;#include CMSIS_device_headerвыбирает заголовок камня за вас.- Debug и Release — две цели со своими папками: у BluePill 1718 и 1062 байта кода, стек 72 и 28 байт.
- Раскладку памяти µVision пишет сам (
LR_IROM1 0x08000000,RW_IRAM1 0x20000000), карта показывает цену каждого модуля. - Версии паков закрепляют: без этого BluePill собрался с CMSIS 6.2.0, а Discovery не собрался вовсе.
UV4 -r … -j0 -o build.log: код 0 — чисто, 1 — предупреждения, 2 — ошибки.
Полные исходники
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: проект, сборка, отладка и разбор сбоев».