Си для STM32F103

МИФИ · МПС · STM32F103C8T6 · Keil µVision 5 и make

Си для STM32F103

Ты уже знаешь, как устроен чип: база + смещение, биты, EXTI, NVIC, таблица векторов. Си ничего из этого не отменяет. Он только берёт на себя рутину: складывает адреса, пишет LDR/ORR/STR, сохраняет регистры и заполняет таблицу векторов. Этот конспект про то, что именно он делает за тебя и где за ним надо следить.

Компиляторы armclang 6.22 (Keil), arm-none-eabi-gcc 16.2 (make) Заголовки Keil STM32F1xx_DFP 2.4.1, CMSIS 6.1.0 Источники сами файлы пакета — ссылки «файл:строка» открывают фрагмент

00 · главная мысль

Что меняется, а что нет

Вот твой обработчик PA3 из сданной ЛР2, тот же обработчик на Си и то, во что компилятор Keil превратил Си. Наведи на строку Си — подсветятся её инструкции.

Твой ассемблер (lab2.s)
A3BUT_HANDLER
    LDR   R0, =EXTI_PR
    LDR   R12, =(1 << 3)
    STR   R12, [R0]
    LDR   R0, =GPIOA_ODR
    LDR   R1, [R0]
    EOR   R12, R1, #(1 << 7)
    STR   R12, [R0]
    BX    LR
Си
void EXTI3_IRQHandler(void) {
    EXTI->PR = EXTI_PR_PR3;
    GPIOA->ODR ^= GPIO_ODR_ODR7;
}
Что сделал armclang (-O1)
MOV   r0,#0xfc08
MOV   r1,#0x80c
MOVT  r0,#0xffff
MOVT  r1,#0x4001
MOVS  r2,#8
STR   r2,[r1,r0]
LDR   r0,[r1,#0]
EOR   r0,r0,#0x80
STR   r0,[r1,#0]
BX    lr

Те же регистры, те же числа: 8 — бит 3 в EXTI_PR, 0x80 — бит 7 в ODR, и тот же BX lr на выходе. Компилятор даже схитрил: в r1 лежит адрес GPIOA_ODR = 0x4001080C, а в r0 — число 0xFFFFFC08, то есть −0x3F8. Их сумма — 0x40010414, это EXTI_PR. Одна загрузка адреса на оба регистра вместо двух.

Что остаётся за тобой, как и в ассемблере: какие регистры, какие биты, в каком порядке. Что берёт на себя компилятор: сложение базы и смещения, выбор регистров R0–R3/R12, сохранение остальных, BX LR и строку в таблице векторов.

Дизассемблер получен из armclang -O1 -mcpu=cortex-m3 (Keil MDK 5.40, ArmClang V6.22) и fromelf -c для этого файла. Имена EXTI_PR_PR3 и GPIO_ODR_ODR7 — .

01 · проект

Из чего собирается программа

зачемКто теперь пишет таблицу векторов и Reset_Handler

В ЛР2 весь чип описывал один твой файл: EQU с адресами, таблица DCD, Reset_Handler, код. В проекте на Си эти роли разложены по готовым файлам из пакета Keil, а твой — только main.c (и, если нужно, .s с обработчиком).

ФайлОткудаЧто делаетВ ЛР2 это было
startup_stm32f10x_md.sпакет Keil, компонент Device → Startupтаблица векторов на все 59 строк с именами, стек 0x400 байт, Reset_Handlerтвои DCD и Reset_Handler
system_stm32f10x.cтам жеSystemInit() — настраивает тактирование до main (72 МГц, глава 07)не было — работали от HSI 8 МГц
stm32f10x.hпакет Keilвсе регистры как структуры, имена битов, номера IRQnтвои EQU
core_cm3.hпакет CMSIS 6.1.0регистры ядра (NVIC, SCB, SysTick) и функции NVIC_…твои NVIC_ISER0 EQU …
main.cтынастройка и логикаостальной lab2.s

какПуть от сброса до main

строка 1Reset_Handler
startup
тактированиеSystemInit()
system_…c
библиотека Си__main
копирует данные,
обнуляет переменные
твой кодmain()

__main — служебная функция библиотеки Си. Она копирует начальные значения глобальных переменных из флеша в ОЗУ и обнуляет остальные. В ассемблере ты такого не делал, потому что не было переменных в ОЗУ. Сборка через make делает то же самое в своём стартовом файле tools/c/startup_md.c.

Какой стартовый файл брать, решает размер флеша: у C8 64 КБ — это medium density, файл _md. По той же причине Keil сам передаёт компилятору макрос STM32F10X_MD, по которому stm32f10x.h выбирает нужный набор прерываний.

Стек и таблица — , ; вызов SystemInit и __main — . Макрос для C8 — .

02 · регистры

Регистр — поле структуры

зачемБаза + смещение без ручного сложения

В stm32f10x.h каждый блок периферии описан как структура: список регистров в том порядке, в каком они лежат в памяти. Поле структуры занимает 4 байта, поэтому смещение каждого следующего поля на 4 больше. Это ровно таблица регистров блока из RM0008.

слова

struct
структура: несколько полей под одним именем, лежат в памяти подряд
EXTI_TypeDef
тип «блок EXTI»: поля IMR, EMR, RTSR, FTSR, SWIER, PR
EXTI
указатель на такую структуру, стоящую по адресу 0x40010400
->
«поле структуры, на которую указывает указатель»: EXTI->RTSR = регистр по адресу база + смещение поля
__IO
то же, что volatile (глава 04): каждое обращение к полю — настоящее чтение или запись в регистр

какТри строки заголовка — и адрес готов

typedef struct {
  __IO uint32_t IMR;    /* +0x00 */
  __IO uint32_t EMR;    /* +0x04 */
  __IO uint32_t RTSR;   /* +0x08 */
  __IO uint32_t FTSR;   /* +0x0C */
  __IO uint32_t SWIER;  /* +0x10 */
  __IO uint32_t PR;     /* +0x14 */
} EXTI_TypeDef;

#define PERIPH_BASE      ((uint32_t)0x40000000)
#define APB2PERIPH_BASE  (PERIPH_BASE + 0x10000)      /* 0x40010000 — начало APB2 */
#define EXTI_BASE        (APB2PERIPH_BASE + 0x0400)   /* 0x40010400 */
#define EXTI             ((EXTI_TypeDef *) EXTI_BASE)

Комментарии со смещениями дописаны для наглядности, в заголовке их нет — смещение получается из порядка полей. Дальше EXTI->RTSR компилятор превращает в адрес 0x40010400 + 0x08 = 0x40010408, тот же, что у тебя в EXTI_RTSR EQU 0x40010408. Цепочка PERIPH_BASE → APB2PERIPH_BASE → EXTI_BASE — это карта памяти из RM0008 Table 3, записанная макросами.

попробуйПоле → адрес

Ловушка: EXTICR[0]У AFIO четыре регистра выбора порта сделаны массивом EXTICR[4], а массив в Си нумеруется с нуля. Поэтому AFIO->EXTICR[0] — это EXTICR1 (линии 0–3), AFIO->EXTICR[1] — EXTICR2 (линии 4–7) и так далее. Правило из ЛР2 «регистр = линия // 4 + 1» в Си превращается в «индекс = линия // 4». Та же история, что с нумерацией строк таблицы векторов с нуля.

Структуры — , , ; базы — , ; указатель EXTI — .

03 · биты

Биты: четыре операции на все случаи

Всё, что ты делал тройками LDR → операция → STR, в Си — одна строка с составным оператором. Компилятор сам развернёт её в те же три команды.

НужноСиАссемблер ЛР2Где у тебя было
поставить битыREG |= маска;LDR / ORR / STRIMR, RTSR, EXTICR2, RCC_APB2ENR
сбросить битыREG &= ~маска;LDR / BIC / STREXTICR1 (биты 15:12), MAPR
переключитьREG ^= маска;LDR / EOR / STRODR в обработчиках
записать целикомREG = значение;STREXTI_PR, ISER (запись 0 ничего не делает)
проверить битif (REG & маска) { … }LDR / TST / IT NEIDR в ЛР1

маскиЧислом или готовым именем

Маску можно писать как раньше — 1 << 3, 0xF << 12, — а можно готовым именем из заголовка. Имена устроены по схеме БЛОК_РЕГИСТР_ПОЛЕ, в точности как в RM0008:

ИмяЗначениеЧто это
RCC_APB2ENR_AFIOEN0x00000001бит 0 RCC_APB2ENR — тактирование AFIO
RCC_APB2ENR_IOPAEN0x00000004бит 2 — порт A
EXTI_IMR_MR30x00000008бит 3 IMR — линия 3
EXTI_PR_PR30x00000008бит 3 PR
AFIO_EXTICR2_EXTI5_PB0x0010поле линии 5 в EXTICR2 = 0001 (порт B)
GPIO_ODR_ODR70x0080бит 7 ODR

Что выбрать — дело вкуса. Имена читаются лучше, числа ближе к тому, что ты считал в ЛР1 и ЛР2. На защите важно одно: суметь сказать, какой это бит и откуда он.

Ловушка: EXTI->PR |= …Флаг в PR сбрасывается записью единицы. Если написать EXTI->PR |= EXTI_PR_PR3;, процессор сначала прочитает PR — и получит единицы у всех линий, которые сейчас ждут обработки, — а потом запишет их обратно. Флаги чужих линий сбросятся, и их прерывания потеряются. Поэтому только EXTI->PR = EXTI_PR_PR3; — ровно так же, как ты делал STR маски без чтения.

Имена битов — , , .

04 · типы

Типы и volatile

слова

uint32_t
беззнаковое 32-битное число — слово, как DCD. Регистры периферии все такие
uint16_t, uint8_t
16 и 8 бит. uint8_t — один байт, как у регистров приоритета IPR (STRB)
volatile
«значение может измениться само»: компилятору запрещено выбрасывать или объединять обращения

какПочему без volatile пропадает задержка

Компилятор оптимизирует: если результат вычисления нигде не нужен, вычисление выбрасывается. Пустой цикл-задержка с точки зрения языка ничего не делает — и исчезает. Вот что armclang сделал из двух функций:

Без volatile
void delay_plain(void) {
  for (uint32_t i = 0; i < 1000000; i++) { }
}
/* armclang -O1: */
BX  lr          ; цикла нет вообще
С volatile
void delay_volatile(void) {
  for (volatile uint32_t i = 0; i < 1000000; i++) { }
}
/* armclang -O1, тело цикла: */
LDR   r1,[sp,#0]
ADDS  r1,#1
STR   r1,[sp,#0]   ; i живёт в стеке
LDR   r1,[sp,#0]
CMP   r1,r0

Поэтому в методичке и написано volatile uint32_t cnt. Та же причина, по которой все поля регистров объявлены как __IO — это просто другое имя volatile. Без него компилятор мог бы, например, прочитать GPIOA->IDR один раз и дальше считать, что значение не меняется.

Второе правило volatile: переменная, которую меняет обработчик прерывания, а читает main (флаг, счётчик тиков), должна быть volatile. Иначе main прочитает её один раз и будет крутиться со старым значением.

Сколько длится итерацияУ volatile-цикла итерация — это пять-шесть команд с обращениями к памяти, а не три, как у твоего SUBS/BNE. Точное время зависит от компилятора и уровня оптимизации. Для «нескольких секунд» из эксперимента 2 подбирай число на плате, а не по формуле.

__IO = volatile — . Дизассемблер — armclang -O1 для этих двух функций.

05 · прерывания

Обработчики прерываний

какОбработчик — функция с именем из таблицы

В стартовом файле строка таблицы векторов для EXTI3 выглядит так: DCD EXTI3_IRQHandler. А ниже эта метка объявлена «слабой» ([WEAK]) и указывает на заглушку Default_Handler — бесконечный цикл B .. Слабое имя значит «если где-то есть функция с таким же именем — возьми её».

Поэтому весь механизм — одна строка в main.c:

void EXTI3_IRQHandler(void) {       /* имя — точно как в startup_stm32f10x_md.s */
  EXTI->PR = EXTI_PR_PR3;             /* сброс флага — первым делом */
  GPIOA->ODR ^= GPIO_ODR_ODR7;
}

Компоновщик видит сильную функцию EXTI3_IRQHandler и кладёт её адрес в строку 25 вместо заглушки. Считать строки больше не нужно. Для твоих кнопок имена такие:

КнопкаИмя функцииIRQn в заголовкеСтрока таблицы
PA3EXTI3_IRQHandlerEXTI3_IRQn = 925
PB5EXTI9_5_IRQHandlerEXTI9_5_IRQn = 2339
PA10EXTI15_10_IRQHandlerEXTI15_10_IRQn = 4056
Опечатка в имениЕсли написать EXTI3_IRQhandler (маленькая h) или EXTI3_Handler, ошибки при сборке не будет: это просто ещё одна функция, которую никто не вызывает. А прерывание уйдёт в Default_Handler, и программа зависнет в B .. Если в отладчике программа стоит в Default_Handler — проверь имя по стартовому файлу.

регистрыКто теперь сохраняет R4–R11

В ЛР2 ты выяснил правило: ядро само сохраняет R0–R3, R12, LR, PC, xPSR, а R4–R11 — забота обработчика. Соглашение о вызовах Си (AAPCS) устроено так же: функция может портить R0–R3 и R12, а R4–R11 обязана вернуть как были. Поэтому любая функция на Си годится в обработчик без переделок: если ей понадобятся R4–R11, компилятор сам поставит PUSH/POP. И если она вызывает другую функцию, сам сохранит LR с EXC_RETURN.

Строка таблицы — ; слабое имя — ; номера — , (блок для STM32F10X_MD).

06 · ядро

NVIC и приоритеты по-сишному

Регистры ядра описаны в core_cm3.h так же структурами: NVIC->ISER[0], NVIC->IPR[9], SCB->AIRCR. Плюс CMSIS даёт короткие функции. Можно писать и так, и так — вот что за ними стоит:

Функция CMSISПрямая записьЧто сделал armclang
NVIC_EnableIRQ(EXTI3_IRQn);NVIC->ISER[0] = 1u << 9;MOV r1,#0x200 → STR в 0xE000E100
NVIC_SetPriority(EXTI9_5_IRQn, 2);NVIC->IPR[23] = 2 << 4;MOVS r3,#0x20 → STRB в 0xE000E417
NVIC_SetPriorityGrouping(5);SCB->AIRCR = 0x05FA0000 | (5 << 8);0x05FA0500 → STR в 0xE000ED0C

Сдвиг на 4 делает функция. NVIC_SetPriority(…, 2) пишет в байт 0x20, а не 0x02: у STM32 из восьми бит приоритета работают только старшие четыре (биты 7:4), и функция сама сдвигает число на 8 − __NVIC_PRIO_BITS = 4. Если пишешь NVIC->IPR[...] напрямую — сдвигай сам. Иначе 2 попадёт в неработающие младшие биты и превратится в приоритет 0.

Ключ 0x05FA подставляет функция. NVIC_SetPriorityGrouping читает AIRCR, очищает старый ключ и PRIGROUP, вставляет 0x5FA в биты 31:16 и новое значение в биты 10:8. Без ключа запись в AIRCR игнорируется.

IPR, а не IPВ CMSIS 6, который стоит в твоём Keil, массив приоритетов называется NVIC->IPR[]. В старых примерах из интернета и в CMSIS 5 он назывался NVIC->IP[] — такой код с твоим пакетом не соберётся. Функции NVIC_SetPriority и NVIC_EnableIRQ одинаковы в обеих версиях.

Структура NVIC — ; NVIC_SetPriority — ; NVIC_SetPriorityGrouping — ; NVIC_EnableIRQ — ; 4 бита приоритета — .

07 · тактирование

Частота: SystemInit и 72 МГц

В ЛР1 и ЛР2 чип работал от внутреннего генератора HSI, 8 МГц. В проекте на Си стартовый код до main вызывает SystemInit(), а она по умолчанию включает кварц HSE 8 МГц и умножитель PLL ×9: 72 МГц. Перед этим она ставит задержку флеша 2 такта — без неё флеш не успевает на такой частоте. Шина APB1 делится на 2 (36 МГц — её максимум), APB2 работает на 72.

Что это значит для тебя:

  • любой цикл-задержка идёт в 9 раз быстрее, чем на 8 МГц;
  • текущую частоту программа знает из переменной SystemCoreClock — ею удобно считать задержки и настройку SysTick;
  • на плате должен стоять кварц 8 МГц (на Blue Pill он есть). Если HSE не запустится, SystemInit тихо оставит HSI 8 МГц.
Хочу 8 МГц, как раньшеKeil кладёт копию system_stm32f10x.c в папку проекта (RTE/Device/STM32F103C8/), её можно править. Закомментируй строку #define SYSCLK_FREQ_72MHz — тогда SystemInit оставит HSI 8 МГц. Но если преподаватель задаёт частоту вариантом (как в ЛР по таймерам), лучше не трогать и считать от SystemCoreClock.

Выбор 72 МГц — ; задержка флеша — ; HSE × 9 — .

08 · вместе

Си + ассемблер в одном проекте

Так задумал преподаватель эксперимент 1: main на Си, обработчик на ассемблере. Обработчик — отдельный .s файл в проекте рядом с main.c:

        THUMB
        AREA    |.text|, CODE, READONLY
        EXPORT  EXTI3_IRQHandler      ; имя — как в стартовом файле
        IMPORT  presses               ; переменная из main.c
EXTI3_IRQHandler
        LDR     R0, =0x40010414       ; EXTI_PR
        MOV     R1, #(1 << 3)
        STR     R1, [R0]
        ...                           ; переключить светодиод, как в ЛР2
        LDR     R0, =presses          ; presses++
        LDR     R1, [R0]
        ADD     R1, R1, #1
        STR     R1, [R0]
        BX      LR
        END
  • Таблицы векторов в .s нет. EXPORT EXTI3_IRQHandler делает метку видимой, и компоновщик подставляет её вместо слабой заглушки — так же, как функцию на Си.
  • Общая переменная. В main.c: volatile uint32_t presses;. В .s: IMPORT presses и работа через её адрес LDR R0, =presses.
  • Регистры — только R0–R3 и R12, правило то же, что в ЛР2 и в AAPCS.
  • Своего Reset_Handler, STACK_TOP и AREA RESET быть не должно — это теперь делает стартовый файл. Если оставить, будет ошибка «multiple definition».

Проверено сборкой через make (tools/rules-c.mk): по адресу 0x64 в прошивке — адрес EXTI3_IRQHandler из .s, переменная presses — в ОЗУ по 0x20000000.

09 · инструменты

Сборка и отладка

keilНовый проект в µVision

  1. Project → New µVision Project, устройство STM32F103C8.
  2. Откроется Manage Run-Time Environment: отметь CMSIS → CORE и Device → Startup. Keil добавит startup_stm32f10x_md.s и system_stm32f10x.c.
  3. Add New Item → C File → main.c, в первой строке #include "stm32f10x.h". Для обработчика на ассемблере — Asm File.
  4. Options for Target → Debug → ST-Link, как в ЛР2. Build (F7), Download (F8).

linuxЧерез make

В папке проекта файл Makefile из одной строки include ../tools/rules-c.mk. Дальше make, make disasm, make flash — как для ассемблера. Собираются все .c и .s из папки; заголовки и system_stm32f10x.c берутся из пакетов Keil, так что main.c один и тот же.

отладкаЧто смотреть

  • Peripherals → System Viewer → EXTI / NVIC — те же регистры, что в ЛР2, с именами битов.
  • Watch — переменные Си по имени. Нужно, чтобы переменная была volatile или глобальной: иначе оптимизатор держит её в регистре, и Watch показывает «not in scope».
  • Уровень оптимизации (Options → C/C++ → Optimization). На -O0 каждая строка Си — свои инструкции, шаги в отладчике идут по порядку. На -O1 и выше код компактнее, но строки перемешиваются. Для отладки удобнее -O0.

10 · сводка

Ловушки

СимптомПричинаГлава
задержка не работает, светодиод «горит» сразусчётчик цикла без volatile — цикл выброшен04
задержка в 9 раз короче расчётаSystemInit включил 72 МГц07
после нажатия программа висит в Default_Handlerопечатка в имени обработчика05
настроил «EXTICR2», а линия 5 не на порту Bписал в EXTICR[2], а EXTICR2 — это EXTICR[1]02
иногда теряются прерывания другой кнопкиEXTI->PR |= … вместо =03
приоритет не действуетNVIC->IPR[n] = 2 без сдвига на 406
ошибка «IP is not a member»пример под CMSIS 5, в CMSIS 6 — IPR06
main «не видит» изменения из обработчикаобщая переменная без volatile04
«multiple definition of Reset_Handler»в .s остались AREA RESET / Reset_Handler из ЛР208
обработчик вызывается дваждысброс PR в конце обработчика — то же, что в ЛР205

11 · сам

Упражнение: эксперимент 1 на Си

Лучший способ перейти на Си — переписать то, что уже работает. Возьми свой lab2.s и переведи настройку в main.c блок за блоком, по справочнику ниже. Обработчики — либо на Си, либо оставь ассемблерными, как хочет преподаватель.

Проверь себя по списку:

Если всё заработает — эксперимент 2 это те же обработчики плюс задержка и две строки приоритетов из главы 06. Присылай код, разберу так же, как ЛР2.

Справочник

Ассемблер ЛР2 → Си

ЧтоАссемблер (твой lab2.s)Си
тактированиеLDR/ORR #0xC, #0x1/STR RCC_APB2ENRRCC->APB2ENR |= 0xD;
SWJ_CFGBIC #(7<<24) / ORR #(2<<24)AFIO->MAPR = (AFIO->MAPR & ~(7u<<24)) | (2u<<24);
порт A для линии 3BIC #(0xF<<12) в 0x40010008AFIO->EXTICR[0] &= ~(0xFu << 12);
порт B для линии 5ORR #(1<<4) в 0x4001000CAFIO->EXTICR[1] |= 1u << 4;
фронт и маскаORR биты 3, 5, 10 в RTSR и IMREXTI->RTSR |= 0x428; EXTI->IMR |= 0x428;
NVICORR #(1<<9), #(1<<23) в ISER0; #(1<<8) в ISER1NVIC->ISER[0] = (1u<<9) | (1u<<23); NVIC->ISER[1] = 1u << 8;
таблица векторов57 слов DCD, строки 25/39/56нет — имена функций EXTI3_IRQHandler, EXTI9_5_…, EXTI15_10_…
сброс флагаSTR #(1<<3) в EXTI_PREXTI->PR = 1u << 3;
переключить светодиодLDR / EOR #(1<<7) / STR GPIOA_ODRGPIOA->ODR ^= 1u << 7;
выход из обработчикаBX LR} — компилятор ставит BX LR
главный циклMain_Loop … B Main_Loopwhile (1) { }

1u — беззнаковая единица. Сдвиг обычной 1 на 31 бит — неопределённое поведение в Си; с 1u такого вопроса нет, поэтому для масок регистров удобно писать 1u << n всегда.