От задержки к событию таймера

В проекте f401_led_blink программа включала LD2, выполняла цикл с __NOP(), выключала светодиод и снова ждала. Длительность такой паузы задавалась числом итераций, а не единицами времени. Во время ожидания процессор продолжал выполнять инструкции цикла.

В новом проекте f401_led_blink_systick настройка вывода PA5 осталась прежней. Изменился способ отсчёта времени: системный таймер сообщает программе о каждом прошедшем миллисекундном интервале. После настройки основной цикл может ждать следующего прерывания, не выполняя холостую задержку.

Что такое SysTick и прерывание

SysTick — системный таймер ядра Cortex-M4. Его счётчик отсчитывает заданное число тактов, после чего начинает отсчёт заново. Таймер можно настроить так, чтобы при каждом завершении отсчёта он запрашивал прерывание. Это позволяет выполнять короткое действие через одинаковые интервалы, не проверяя счётчик постоянно в main().

Когда приходит прерывание SysTick, процессор сохраняет состояние текущего выполнения в стеке и переходит в функцию SysTick_Handler(). Закончив её, он восстанавливает состояние и продолжает работу с прерванного места. Программа не вызывает обработчик из main(), и второй поток не создаётся: переход выполняет сам процессор.

Переход от ожидания прерывания к обработчику SysTickОсновной цикл ждёт в __WFI(). Через одну миллисекунду SysTick вызывает прерывание, процессор выполняет SysTick_Handler() и возвращается к основному циклу.основной цикл:SysTick:обработчик:__WFI()1 мс → прерываниеSysTick_Handler()__WFI()
При каждом тике SysTick процессор выполняет обработчик и возвращается к ожиданию в основном цикле.

SysTick относится к системным исключениям ядра. Позже мы встретим прерывания периферийных устройств STM32; общий принцип перехода к обработчику у них похож, но источник события другой.

Как процессор находит обработчик

В файле f401_led_blink_systick/Startup/startup_stm32f401retx.s находится таблица векторов — адреса функций, к которым процессор переходит при разных исключениях и прерываниях. Для SysTick в ней предусмотрена запись:

.word SysTick_Handler

Startup-файл также задаёт для этого имени слабое определение, ведущее в Default_Handler. Оно действует, пока программа не предоставит собственную функцию. В Sources/main.cpp она определена так:

extern "C" void SysTick_Handler()
{
    // Код обработки каждого тика.
}

extern "C" сохраняет имя символа SysTick_Handler в том виде, в каком его ожидает startup-файл. Без этого C++ изменил бы имя функции при компиляции, и запись в таблице векторов не указывала бы на наш обработчик.

Настраиваем тик в одну миллисекунду

После настройки GPIO функция main() обновляет значение частоты ядра и передаёт его функции CMSIS:

constexpr std::uint32_t kMillisecondsPerSecond = 1000U;

SystemCoreClockUpdate();
if (SysTick_Config(SystemCoreClock / kMillisecondsPerSecond) != 0U) {
    for (;;) {
    }
}

SystemCoreClockUpdate() считывает текущие настройки тактирования и записывает частоту ядра в переменную SystemCoreClock. Сама эта функция частоту не меняет. В нашем проекте после сброса используется внутренний генератор HSI с номинальной частотой 16 МГц; проект не переключает системную частоту на другой источник.

SysTick_Config() получает число тактов между двумя прерываниями и выбирает в качестве источника частоту ядра. При 16 МГц выражение SystemCoreClock / 1000U даёт 16000 тактов, то есть один тик SysTick приблизительно каждую миллисекунду. Функция CMSIS записывает в регистр перезагрузки значение на единицу меньше — 15999, сбрасывает текущий счётчик и включает таймер вместе с его прерыванием.

Считаем тики и переключаем LD2

Теперь посмотрим на переменные и настоящий обработчик из Sources/main.cpp:

constexpr std::uint32_t kBlinkIntervalMs = 500U;
std::uint32_t g_elapsed_ms = 0U;
bool g_led_on = false;

extern "C" void SysTick_Handler()
{
    g_elapsed_ms += 1U;
    if (g_elapsed_ms == kBlinkIntervalMs) {
        g_elapsed_ms = 0U;
        g_led_on = !g_led_on;
        GPIOA->BSRR = g_led_on ? GPIO_BSRR_BS5 : GPIO_BSRR_BR5;
    }
}

Каждый вызов обработчика увеличивает g_elapsed_ms на единицу. Когда счётчик достигает 500, программа обнуляет его, меняет логическое состояние g_led_on и одной записью в GPIOA->BSRR устанавливает или сбрасывает PA5. Как и в прошлом уроке, бит BS5 включает LD2, а BR5 выключает его.

Временная диаграмма переключения светодиода LD2LD2 выключен при 0 мс, включён при 500 мс, выключен при 1000 мс и включён при 1500 мс. Каждое состояние длится 500 мс, затем последовательность продолжается.Время после запуска0 мс500 мс1000 мс1500 мс…LD2ВКЛВЫКЛвыключенвключёнвыключенвключён500 мс500 мс500 мс500 мс
LD2 меняет состояние через каждые 500 мс.

Светодиод меняет состояние каждые 500 мс. Полный цикл от одного включения до следующего занимает 1 секунду. Короткий обработчик успевает только обновить счётчик и, раз в 500 вызовов, переключить вывод.

Что делает основной цикл

В main() программа включает тактирование GPIOA, задаёт начальный низкий уровень на PA5 и переводит вывод в режим выхода. После настройки SysTick бесконечный цикл выглядит так:

for (;;) {
    __WFI();
}

__WFI() означает Wait For Interrupt — ожидание прерывания. Процессор не крутит цикл задержки. При очередном тике он выполняет SysTick_Handler(), возвращается в основной цикл и снова переходит к ожиданию. Это происходит каждую миллисекунду, хотя LD2 переключается лишь на каждом пятисотом тике.

В этом примере g_elapsed_ms и g_led_on используются только внутри обработчика. Основной цикл их не читает, поэтому помечать эти переменные volatile для обмена между обработчиком и main() не требуется.

Почему обработчик должен быть коротким

Пока работает SysTick_Handler(), процессор занят обработкой прерывания. Если добавить в него длинную задержку или объёмные вычисления, это может задержать обработку других событий. Запись в регистр светодиода и обновление небольшого счётчика здесь занимают мало времени, поэтому помещаются прямо в обработчике.

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

После запуска этого проекта LD2 должен включаться и выключаться с интервалом около половины секунды. Настройка запуска и отладчика остаётся такой же, как в предыдущем уроке.

Исходный код

GitHub: Stm32Course2026. Проект этого урока — f401_led_blink_systick, путь: Stm32Course2026/f401_led_blink_systick.