Мигание светодиодом на STM32 без таймеров: как сделать blink-with-delay

Нужно моргнуть светодиодом на STM32, но хочется обойтись без HAL_Delay и таймеров? Это частая задача, когда пишешь первый проект или отлаживание не позволяет использовать системные таймеры. Расскажу, как реализовать простое мигание с точной задержкой, используя только циклы и знание частоты ядра.

Что значит «без таймеров»

Под этим обычно понимают отказ от аппаратных таймеров (TIM1, TIM2 и т.д.) и от системного таймера SysTick, который использует HAL_Delay. Остаются только программные задержки — циклы, которые выполняют бесполезные операции заданное количество тактов.

Как работает программная задержка

Идея проста: мы знаем частоту ядра (например, 72 МГц для STM32F103), значит, каждая инструкция выполняется за определённое время. Если выполнить много инструкций подряд, получим нужную паузу. Главное — не дать компилятору оптимизировать пустой цикл.

volatile uint32_t i;
for (i = 0; i < 500000; i++) {
    // ничего не делаем
}

Ключевое слово volatile заставляет компилятор выполнять каждую итерацию, а не выбросить цикл как бесполезный.

Расчёт длительности задержки

Если ядро работает на 72 МГц, один такт занимает около 13,9 нс. Но каждая инструкция в цикле занимает не один такт — нужно учитывать конвейер и доступ к памяти. На практике одна итерация цикла for занимает примерно 3–5 тактов (зависит от оптимизаций и архитектуры).

Для грубой задержки в 500 мс на 72 МГц:

void delay_ms(uint32_t ms) {
    uint32_t i;
    while (ms--) {
        for (i = 0; i < 8000; i++) {
            __asm("nop");
        }
    }
}

Здесь 8000 итераций дают примерно 1 мс при 72 МГц. Точное значение подбирается экспериментально — замеряйте осциллографом или логическим анализатором.

Реализация мигания

Теперь соберём полноценный код для мигания светодиодом на выводе PC13 (как на многих отладочных платах):

#include "stm32f1xx.h"

void delay_ms(uint32_t ms) {
    uint32_t i;
    while (ms--) {
        for (i = 0; i < 8000; i++) {
            __asm("nop");
        }
    }
}

int main(void) {
    // Включаем тактирование порта C
    RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;

    // Настраиваем PC13 как выход push-pull, максимальная частота 2 МГц
    GPIOC->CRH &= ~(0xF << 20);  // Очищаем биты CNF13[1:0] и MODE13[1:0]
    GPIOC->CRH |= (0x2 << 20);   // MODE13 = 10 (Output mode, max speed 2 MHz)

    while (1) {
        GPIOC->ODR ^= GPIO_ODR_ODR13;  // Инвертируем состояние PC13
        delay_ms(500);                  // Пауза 500 мс
    }
}

Этот код не использует HAL, только прямую работу с регистрами. Если вы работаете с HAL, замените настройку GPIO на соответствующий вызов, но суть задержки остаётся той же.

Подбор количества итераций под вашу частоту

Вот таблица примерных значений для разных частот ядра (задержка 1 мс):

Частота ядра Итерации цикла (примерно) Примечание
16 МГц (HSI) ~2000 Внутренний резистор без настройки
48 МГц (STM32F0) ~6000
72 МГц (STM32F1) ~8000
80 МГц (STM32L4) ~10000
168 МГц (STM32F4) ~21000

Эти значения — отправная точка. Реальная задержка зависит от оптимизаций компилятора, наличия кэша и других факторов. Всегда проверяйте осциллографом.

Как избежать оптимизации цикла

Компилятор может удалить пустой цикл, если не принять меры:

  • Используйте volatile для переменной цикла.
  • Вставляйте __asm("nop") внутрь цикла — это гарантирует выполнение каждой итерации.
  • Отключите оптимизацию для функции задержки с помощью прагмы (не рекомендуется для всего проекта).
#pragma GCC push_options
#pragma GCC optimize ("O0")
void delay_ms(uint32_t ms) { ... }
#pragma GCC pop_options

Когда такой подход уместен

Программные задержки хороши для:

  • Быстрого прототипирования, когда лень настраивать таймеры.
  • Обучения — чтобы понять, как работает тактирование и исполнительный конвейер.
  • Задач, где точность задержки не критична (мигание индикатором, опрос кнопки без ребунса).

Но помните: пока процессор крутится в цикле, он не может делать ничего другого. В реальных проектах лучше использовать таймеры и прерывания, чтобы освободить CPU.

Частые ошибки

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

Типичные промахи:

  • Забыли включить тактирование порта — светодиод не мигает.
  • Не указали volatile — компилятор удалил цикл.
  • Неправильно рассчитали количество итераций — мигает слишком быстро или медленно.
  • Использовали HAL_Delay внутри задержки — получаете непредсказуемое поведение.

Как проверить точность

Подключите осциллограф к выводу со светодиодом и измерьте период. Если он не совпадает с расчётным, подправьте количество итераций. Можно также использовать другой вывод для маркера — переключайте его в начале и конце задержки и смотрите длительность импульса.

Альтернатива: использование SysTick без HAL

Если всё-таки хочется более точную задержку без таймеров, можно настроить SysTick напрямую:

void SysTick_Init(void) {
    SysTick->LOAD = 72000 - 1;  // 1 мс при 72 МГц
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_ENABLE_Msk | SysTick_CTRL_CLKSOURCE_Msk;
}

void delay_ms(uint32_t ms) {
    while (ms--) {
        while (!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk));
    }
}

Это уже ближе к таймеру, но SysTick — часть ядра, а не периферийный таймер. Иногда такой компромисс приемлем.

Итог

Реализовать мигание светодиодом на STM32 без таймеров просто: используйте циклы с volatile и nop, подберите количество итераций под вашу частоту и обязательно проверяйте практикой. Для учебных целей и быстрой отладки этого достаточно. Для рабочих проектов переходите на таймеры и прерывания — это сэкономит ресурсы процессора и упростит код.

Radio-Blog.ru