Встраиваемые системы и робототехника Периферия: GPIO, АЦП, ШИМ, таймеры — управляем железом из кода
0%

Периферия: GPIO, АЦП, ШИМ, таймеры — управляем железом из кода

Периферия: GPIO, АЦП, ШИМ, таймеры — управляем железом из кода

Главный сдвиг: периферия — это сопроцессоры, а не библиотеки

Программист «больших» машин привык к модели «я вызываю функцию — она делает работу». В прошивке эта модель ломается на первом же светодиоде. Здесь работает другая:

Периферийный блок — это отдельный автомат внутри кристалла, работающий параллельно ядру. Программа его не «вызывает», а настраивает и запускает; дальше блок живёт своей жизнью, а ядро либо спит, либо занято другим.

Ближайшая аналогия из серверного мира — offload на сетевой карте: вы не считаете контрольные суммы в цикле, вы включаете аппаратный TSO и получаете уведомление о завершении. Разница в том, что на микроконтроллере таких «карт» два десятка, и вся прошивка состоит из их настройки. Отсюда критерий качества: чем меньше ядро участвует в рутине, тем лучше прошивка. Мигание светодиодом в цикле с delay() — это ядро на 100 % загрузки ради одной ножки; то же самое через таймер — ноль тактов ядра и джиттер в десятки наносекунд вместо десятков микросекунд.

Второй сдвиг: регистры периферии живут в том же адресном пространстве, что и переменные. Указатель на регистр — буквально физический адрес, а запись по нему имеет побочный эффект в железе. Поэтому все объявления регистров помечены volatile: компилятору запрещено кэшировать, переупорядочивать и выбрасывать такие обращения. Почему volatile при этом не является средством синхронизации — разобрано в статье про C для встраиваемых систем; общая идея отображения устройств в память — та же, что в драйверах больших ОС (ввод-вывод и драйверы).

Работа с любым блоком идёт по одному сценарию:

Первый пункт — источник самой частой ошибки новичка. Без тактирования блок молчит: запись в регистр проходит, чтение возвращает ноль, ошибки нет. В отладчике это выглядит как «регистр не записывается».

/* STM32F4: без этой строки GPIOD физически отключён от шины,
   и вся последующая настройка уходит в пустоту */
RCC->AHB1ENR |= RCC_AHB1ENR_GPIODEN;
(void)RCC->AHB1ENR;   /* холостое чтение: на Cortex-M запись отложена, а нам нужно,
                         чтобы такт появился раньше обращения к регистрам блока */

В ESP-IDF ту же роль играет periph_module_enable(), на AVR — регистр PRR, на RP2040 — блок RESETS. Механика одна: сначала такт, потом настройка, потом запуск.

GPIO: одна ножка и десяток регистров

Кажется, что цифровой вывод — это «1 или 0». На самом деле за ножкой стоит небольшая аналоговая схема, и половина «мистических» багов объясняется именно ею.

Устройство вывода GPIO: регистры, мультиплексор, выходной каскад, подтяжки и защитные диоды

Режим Что происходит внутри Когда нужен
Вход без подтяжек оба ключа выключены, вход висит уровень задан внешней схемой
Вход с подтяжкой вверх ~40 кОм на VDD кнопка на землю — самый частый случай
Вход с подтяжкой вниз ~40 кОм на VSS кнопка на питание, датчик с активной единицей
Выход push-pull оба ключа активны, ножка жёстко тянет к VDD и VSS светодиоды, драйверы, обычный выход
Выход open-drain верхний ключ выключен: ножка умеет только тянуть вниз I2C, общее прерывание, согласование уровней
Альтернативная функция ножкой управляет блок: таймер, SPI, UART вся периферия, выходящая наружу
Аналог цифровой буфер отключён, ножка идёт на АЦП или ЦАП измерения, ЦАП, компаратор

Три следствия, которые стоят большинства ошибок на макетной плате. Висящий вход — не ноль: он ловит наводки от соседних дорожек и вашей руки, и кнопка без подтяжки «срабатывает сама». Open-drain — это способ построить общую шину, а не просто другой режим: несколько устройств тянут линию вниз, вверх её возвращает внешний резистор; в push-pull два устройства с разными уровнями дают короткое замыкание через выходные каскады — на этом принципе построен I2C (протоколы связи). Крутизна фронта — параметр, за который платят помехами: регистр вроде OSPEEDR не ускоряет программу, он меняет время нарастания; быстрый фронт на длинной дорожке даёт звон и излучение на десятки мегагерц, поэтому выбирают минимальную скорость, при которой сигнал ещё успевает.

Мигание светодиодом на уровне регистров

Стоит один раз написать это без библиотек — дальше любая HAL станет читаемой.

#include <stdint.h>

#define GPIOD_BASE   0x40020C00UL     /* адреса берутся из reference manual, а не выдумываются */
#define RCC_AHB1ENR  (*(volatile uint32_t *)0x40023830UL)
typedef struct {
    volatile uint32_t MODER;    /* 00 вход, 01 выход, 10 альт. функция, 11 аналог */
    volatile uint32_t OTYPER;   /* 0 push-pull, 1 open-drain */
    volatile uint32_t OSPEEDR;  /* крутизна фронта */
    volatile uint32_t PUPDR;    /* подтяжки */
    volatile uint32_t IDR;      /* вход: только чтение */
    volatile uint32_t ODR;      /* выход: чтение и запись */
    volatile uint32_t BSRR;     /* атомарная установка и сброс битов */
    volatile uint32_t LCKR;
    volatile uint32_t AFR[2];   /* номер альтернативной функции для ножки */
} GPIO_TypeDef;                 /* порядок полей повторяет карту регистров, ни байта лишнего */

#define GPIOD   ((GPIO_TypeDef *)GPIOD_BASE)
#define LED_PIN 12U

static void led_init(void)
{
    RCC_AHB1ENR |= (1UL << 3);                  /* такт на порт D */
    (void)RCC_AHB1ENR;
    GPIOD->MODER  &= ~(3UL << (LED_PIN * 2));   /* чистим оба бита поля */
    GPIOD->MODER  |=  (1UL << (LED_PIN * 2));   /* 01 — цифровой выход */
    GPIOD->OTYPER &= ~(1UL << LED_PIN);         /* push-pull */
}

static inline void led_on(void)     { GPIOD->BSRR = (1UL << LED_PIN); }
static inline void led_off(void)    { GPIOD->BSRR = (1UL << (LED_PIN + 16)); }

Здесь важен BSRR. Запись ODR |= mask — это три операции: чтение, изменение, запись; если между чтением и записью выполнится обработчик прерывания, тронувший соседнюю ножку того же порта, его изменение будет затёрто. BSRR — одна запись без чтения, поэтому атомарен по построению. Это тот же класс проблем, что и гонки в многопоточном коде, только вместо потоков — прерывания (прерывания и реальное время).

Цена абстракций здесь измерима: на AVR при 16 МГц digitalWrite() занимает порядка 3–4 мкс (около полусотни тактов: разбор номера ножки по таблицам, отключение ШИМ, запрет прерываний), прямая запись в порт — один такт, 62,5 нс. Для мигания разницы нет; для программной эмуляции протокола разница в пятьдесят раз решает, работает оно или нет. В C++ ту же прямую запись можно спрятать за типом, не заплатив ни такта:

template <std::uintptr_t Base, unsigned Pin>
struct OutputPin {
    static void high()      { regs()->BSRR = 1u << Pin; }
    static void low()       { regs()->BSRR = 1u << (Pin + 16); }
    static void set(bool v) { v ? high() : low(); }
private:
    static GPIO_TypeDef* regs() { return reinterpret_cast<GPIO_TypeDef*>(Base); }
};

using Led   = OutputPin<GPIOD_BASE, 12>;
using Relay = OutputPin<GPIOD_BASE, 13>;
/* с -O2 вызов Led::high() превращается ровно в одну инструкцию str: адрес и номер
   ножки известны на этапе компиляции. Абстракция бесплатна, а перепутать реле
   со светодиодом теперь мешает система типов. */

Чтение входа: дребезг контактов

Механический контакт при нажатии несколько миллисекунд «звенит» — замыкается и размыкается десятки раз. В цикле опроса это выглядит как пачка нажатий. Решение — не задержка (она блокирует всё остальное), а фильтр по времени с постоянным периодом опроса.

Псевдокод, который стоит держать в голове, и его цена: O(1) по времени на выборку, O(1) по памяти — один байт истории плюс бит состояния на кнопку, никаких буферов и таймстампов.

каждые T мс (T = 1..5, из прерывания таймера):
    history := (history << 1) OR текущий_уровень_ножки
    если (history AND маска_окна) == все_нули:     состояние := нажата
    если (history AND маска_окна) == все_единицы:  состояние := отпущена
    событие := переход между состояниями, а не само состояние
typedef struct {
    uint8_t history;      /* последние 8 выборок, младший бит — самая свежая */
    bool    stable;       /* устойчивое состояние: true — нажата */
    bool    edge_press;   /* однократный флаг события, читается и сбрасывается в main */
} button_t;
/* Кнопка на землю, вход с подтяжкой вверх: нажата -> 0. Вызывать из ISR таймера на 1 кГц. */
void button_poll(button_t *b, bool raw_high)
{
    b->history = (uint8_t)((b->history << 1) | (raw_high ? 1U : 0U));
    if (b->history == 0x00U && !b->stable) {        /* 8 мс подряд ноль */
        b->stable = true;
        b->edge_press = true;
    } else if (b->history == 0xFFU && b->stable) {  /* 8 мс подряд единица */
        b->stable = false;
    }
}

Аппаратная альтернатива — RC-цепочка на входе плюс триггер Шмитта, который в ножке уже есть; на практике используют оба: конденсатор гасит быстрый звон, программа — медленный. Отдельная ловушка — прерывание прямо на дребезжащей кнопке: каждое дребезжание вызывает ISR, и на плохом контакте вы получаете сотни вызовов вместо одного. Правильный шаблон: прерывание только будит систему, решение принимает фильтр по таймеру.

АЦП: как из напряжения получается число

В подавляющем большинстве микроконтроллеров стоит АЦП последовательного приближения (SAR): внутренний конденсатор заряжается до входного напряжения, затем схема за N шагов «взвешивает» его двоичным поиском относительно опорного. Отсюда сразу три практических вывода.

Число само по себе не означает вольты — результат есть отношение входа к опоре: V_вход = отсчёт * V_опор / 2^N. Для 12 бит и опоры 3,3 В один младший разряд равен 0,806 мВ. Если опора — это шумное питание платы, просевшее до 3,21 В при пуске мотора, все измерения уехали на 3 %, и никакой фильтр этого не исправит. Лечится либо внешним точным источником опоры, либо рациометрическим измерением: питайте датчик от той же опоры, и отношение станет нечувствительным к её дрейфу.

Входу нужно время на зарядку. Конденсатор выборки заряжается через сопротивление источника, и документация требует t_выборки >= (R_источника + R_ацп) * C_ацп * ln(2^(N+1)). Для STM32F4 (R ≈ 6 кОм, C ≈ 4 пФ, N = 12) при источнике 10 кОм это порядка 0,6 мкс. Датчик с выходным сопротивлением 100 кОм при коротком времени выборки даст заниженные и «прилипающие» к предыдущему каналу значения — симптом, который выглядит как «каналы влияют друг на друга». Лечение: увеличить время выборки или поставить буфер на операционном усилителе.

Выше половины частоты дискретизации сигнала не существует. Всё, что быстрее, отразится в вашу полосу как ложная низкая частота. Алиасинг лечится RC-фильтром перед входом, а не усреднением после.

Шум, разрядность и передискретизация

Заявленные 12 бит — разрядность преобразователя, а не точность измерения; реальную величину описывает ENOB, и на макетной плате с шумным питанием из 12 бит остаётся 9–10. Бесплатный способ вернуть часть — передискретизация с прореживанием: чтобы получить n дополнительных бит, усредните 4^n выборок и сдвиньте сумму вправо на n. Работает только при наличии шума хотя бы в один младший разряд: идеально тихий сигнал усреднять бесполезно.

/* 16 = 4^2 выборок дают +2 бита: из 12-битного АЦП получаем 14-битный результат.
   O(k) по времени (k обращений к АЦП), O(1) по памяти. */
uint16_t adc_oversample_14bit(void)
{
    uint32_t acc = 0;
    for (unsigned i = 0; i < 16U; ++i) acc += adc_read_raw();  /* 0..4095 */
    return (uint16_t)(acc >> 2);                               /* 0..16383 */
}

Арифметику удобнее проверять на большой машине, а в прошивку переносить уже результат:

"""Прототип обработки АЦП: пересчёт в вольты, выигрыш передискретизации, бюджет ошибки."""
VREF, BITS = 3.300, 12                 # реальное измеренное значение опоры и разрядность

def divider_error(vbat: float, r_top: float, r_bot: float, tol: float = 0.01) -> float:
    """Худший случай ошибки делителя напряжения на резисторах с допуском tol."""
    nominal = vbat * r_bot / (r_top + r_bot)
    worst = vbat * (r_bot * (1 + tol)) / (r_top * (1 - tol) + r_bot * (1 + tol))
    return abs(worst - nominal)

print(f"младший разряд: {VREF / (1 << BITS) * 1000:.3f} мВ")                  # 0.806 мВ
print(f"делитель 1 % на 4,2 В: {divider_error(4.2, 100e3, 100e3) * 1000:.1f} мВ")  # ~21 мВ

Последняя строка показывает неприятное: допуск резисторов делителя обычно вносит больше ошибки, чем весь шум АЦП. Бюджет ошибки считают до того, как начинают бороться за младший бит.

Продакшн-шаблон: таймер запускает АЦП, DMA складывает результат

Опрос АЦП в основном цикле с задержкой — учебный вариант. У него две беды: ядро занято ожиданием, а интервал между выборками плавает на величину всего, что происходит в цикле. Для обработки сигнала это фатально — спектр смазывается, фильтр с фиксированными коэффициентами перестаёт соответствовать реальной частоте дискретизации. Промышленный шаблон другой: таймер даёт точный триггер, АЦП по завершении просит DMA, DMA складывает отсчёты в кольцевой буфер и будит ядро только на половине и на конце буфера — сто раз в секунду вместо ста тысяч.

Три детали, о которые спотыкаются в проде. Буфер должен быть volatile и выровнен — DMA пишет его мимо ядра. На кристаллах с кэшем данных (STM32H7, Cortex-M7) нужна забота о когерентности: буфер размещают в некэшируемой области MPU либо зовут SCB_InvalidateDCache_by_Addr() перед чтением, иначе ядро увидит старые данные, и это выглядит как «DMA не работает». Порядок значений в буфере задаётся порядком сканирования, а не номерами ножек — перепутанные каналы это самая частая ошибка при добавлении четвёртого датчика.

Обратная операция — ЦАП, и он есть далеко не везде (в STM32F4 есть, в ESP32 два восьмибитных, в AVR и RP2040 нет вовсе). Заменяют его ШИМ плюс RC-фильтр: если частота ШИМ много выше частоты полезного сигнала, фильтр превращает скважность в среднее напряжение. Годится для подстройки смещений, опорных уровней и звука посредственного качества; не годится там, где нужна быстрая точная форма сигнала.

ШИМ и таймеры: главный вычислительный ресурс на плате

Таймер — самый недооценённый блок микроконтроллера. Это не «счётчик миллисекунд», а программируемый автомат: счётчик, делитель частоты, регистр верхней границы и несколько каналов сравнения или захвата. На среднем STM32 таких блоков 8–14, и грамотная прошивка отдаёт им всю работу, связанную со временем.

Диаграммы работы таймера: счёт вверх и вверх-вниз, регистр сравнения и форма сигнала на выходе

f_тик = f_тайм / (PSC + 1)                    такты счётчика
f_ШИМ = f_тайм / ((PSC + 1) * (ARR + 1))      частота на ножке
скважность = (CCR + 1) / (ARR + 1)            разрядность регулировки = log2(ARR + 1)

Ключевой компромисс виден сразу: частота и разрядность делят один и тот же тактовый ресурс. При таймере на 84 МГц и ШИМ 20 кГц остаётся ARR = 4199, чуть больше 12 бит регулировки. Захотите 200 кГц — останется 419 шагов, менее 9 бит. Захотите 16 бит на 20 кГц — понадобится таймер на 1,3 ГГц, которого нет.

Задача Разумная частота Почему именно так
Яркость светодиода 200 Гц – 2 кГц выше порога мерцания, в том числе для камеры
Коллекторный мотор, H-мост 16–25 кГц выше слышимого диапазона, ниже потерь на переключение
Серводвигатель хобби-класса 50 Гц, импульс 1–2 мс исторический стандарт RC-техники
Нагреватель, мощная нагрузка 1–100 Гц инерция большая, важнее не греть ключи
/* TIM3_CH1 на ножке PC6, шина APB1 работает на 84 МГц. Цель: 20 кГц, около 12 бит. */
void pwm_init_20khz(void)
{
    RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOCEN;
    GPIOC->MODER  = (GPIOC->MODER  & ~(3UL << 12))   | (2UL << 12);  /* PC6 -> альт. функция */
    GPIOC->AFR[0] = (GPIOC->AFR[0] & ~(0xFUL << 24)) | (2UL << 24);  /* AF2 = TIM3_CH1 */
    TIM3->PSC   = 0;                      /* делитель не нужен */
    TIM3->ARR   = 4199;                   /* 84e6 / 4200 = 20 кГц */
    TIM3->CCR1  = 0;                      /* стартуем с нулевой скважности */
    TIM3->CCMR1 |= (6UL << 4) | (1UL << 3); /* PWM mode 1 + предзагрузка CCR */
    TIM3->CCER  |= (1UL << 0);            /* разрешить выход канала 1 */
    TIM3->CR1   |= (1UL << 7);            /* ARPE: буферизация ARR */
    TIM3->EGR    = 1UL;                   /* UG: перенести теневые регистры */
    TIM3->CR1   |= (1UL << 0);            /* CEN: пуск, дальше ядро свободно */
}

/* Смена скважности — одна запись. Благодаря предзагрузке новое значение
   применится с началом следующего периода: импульс не «обрежется» посередине. */
static inline void pwm_set_duty_q12(uint16_t duty_0_4095)
{
    TIM3->CCR1 = (uint32_t)duty_0_4095 * 4200U / 4096U;
}

Про предзагрузку (OCxPE, ARPE) стоит сказать отдельно: без неё запись нового значения в середине периода порождает рваный импульс — на моторе это скачок тока, на светодиодной ленте видимая вспышка. Аппарат умеет применять значение атомарно на границе периода, надо лишь включить эту возможность. Серво считается так же, но через делитель: PSC = 84 - 1 даёт тик ровно 1 мкс, ARR = 20000 - 1 даёт период 20 мс, а CCR тогда читается прямо в микросекундах — 1000, 1500 и 2000 для двух краёв и нейтрали.

Ещё деталь: яркость светодиода нелинейна по восприятию, линейное изменение скважности выглядит как «сначала быстро, потом ничего не меняется». Таблица гамма-коррекции решает вопрос за 512 байт флеша:

def gamma_table(steps: int = 256, top: int = 4199, gamma: float = 2.2) -> list[int]:
    """Таблица «уровень восприятия -> CCR»: O(steps) один раз при сборке, O(1) в прошивке."""
    return [round(top * (i / (steps - 1)) ** gamma) for i in range(steps)]

print("static const uint16_t GAMMA[256] = {" + ",".join(map(str, gamma_table())) + "};")

Комплементарные каналы и мёртвое время

Для управления мостовой схемой нужны два сигнала: верхний и нижний ключ плеча. Они обязаны быть противофазны, но переключаться не одновременно — реальный транзистор закрывается медленнее, чем открывается, и на долю микросекунды оба ключа окажутся открыты. Это сквозной ток и мгновенная смерть моста. Продвинутые таймеры (в STM32 — TIM1 и TIM8) вставляют мёртвое время аппаратно и умеют отключать выходы по сигналу аварии (BRK) без участия программы.

Правило: если мёртвое время формируется программно, мост однажды сгорит. Программа не успеет — успеет только аппарат.

Захват: измеряем внешний мир временем

Обратная к сравнению функция — захват (input capture): по фронту на ножке аппарат копирует значение счётчика в регистр. Так измеряют период, частоту и длительность импульса с точностью до такта таймера, недостижимой для программного опроса.

/* Измерение периода: захват по нарастающему фронту, канал 1.
   Вычитание в uint16 корректно обрабатывает переполнение счётчика. */
static volatile uint32_t g_period_ticks;
void TIM4_IRQHandler(void)
{
    static uint16_t prev;
    if (TIM4->SR & TIM_SR_CC1IF) {
        uint16_t now = (uint16_t)TIM4->CCR1;   /* чтение CCR сбрасывает флаг */
        g_period_ticks = (uint16_t)(now - prev);
        prev = now;
    }
}
/* f_сигнала = f_тик / period_ticks: при тике 1 мкс и 1000 тиках это 1 кГц. Разрешение падает
   с ростом частоты, поэтому высокие частоты меряют иначе — считают импульсы за окно. */

Тот же блок в других режимах закрывает половину типовых задач:

Режим энкодера заслуживает отдельного упоминания: два сигнала квадратуры заводятся на таймер, и он сам считает вверх-вниз по фронтам, умножая разрешение на четыре. Программа просто читает CNT — ни одного прерывания на десятки тысяч импульсов в секунду. На программной обработке той же квадратуры мотор на высокой скорости съедает ядро целиком и всё равно теряет счёт. Как из этого счётчика получается одометрия — в статье про робототехнику.

DMA: как не платить тактами за перекладывание байтов

DMA — ещё один автомат, переносящий данные между памятью и периферией без ядра. На фоне остальной периферии он выглядит скучно, но именно он определяет, потянет ли прошивка нагрузку.

Задача Через ядро Через DMA
1000 отсчётов АЦП ~1000 прерываний, десятки тысяч тактов 2 прерывания на буфер
Обновление ленты WS2812 жёсткий цикл с запретом прерываний таймер + DMA прямо в регистр GPIO
Приём UART неизвестной длины прерывание на каждый байт DMA плюс событие «линия освободилась»

Пара приёмов выглядит как магия, но является штатным использованием. Таймер плюс DMA в регистр BSRR выдают произвольную последовательность уровней с точностью до такта — так работают драйверы адресных светодиодов и программные реализации нестандартных интерфейсов. Кольцевой DMA на приём UART снимает вечную проблему потери байтов при паузе в обработке. Оборотная сторона: каналов конечное число, они привязаны к блокам таблицей в документации, и нужного канала для нужного блока может просто не существовать — это выясняют при выборе кристалла, а не в конце проекта.

Разные платы: где что и чем это отличается

Периферия — главный фактор выбора кристалла, и различия здесь принципиальны, а не косметические.

Платформа GPIO АЦП ШИМ и таймеры Особенности
STM32 (F0/G0 … F4/H7) атомарный BSRR, часть ножек толерантна к 5 В 12 бит, до 5 Мвыб/с, триггер от таймера, DMA 8–14 таймеров, комплементарные каналы, мёртвое время, энкодер эталон по периферии; платят объёмом документации
ESP32 / C3 / S3 матрица коммутации: почти любая функция на почти любой ножке 12 бит, заметно нелинейный, нужна калибровка; ADC2 не работает вместе с Wi-Fi LEDC для светодиодов, MCPWM для моторов, RMT для импульсных протоколов часть ножек занята флешем, на GPIO34–39 нет подтяжек и вывода
RP2040 / RP2350 PIO — программируемые автоматы ввода-вывода 12 бит, 500 квыб/с, 4 канала плюс термодатчик 8 срезов ШИМ = 16 каналов PIO закрывает протоколы, которых нет в кристалле
AVR / Arduino Uno прямой доступ к портам, всё просто 10 бит, ~10 квыб/с, analogRead() ≈ 100 мкс 3 таймера, analogWrite() даёт 490 Гц отличная учебная модель, тесно в проде
Raspberry Pi (Linux) есть, но время не гарантировано АЦП нет вообще, нужен внешний по SPI (MCP3008) аппаратный ШИМ на паре ножек, остальное программно с джиттером пространство пользователя даёт джиттер в миллисекунды

Практический вывод, экономящий недели: точное время и аналог — на микроконтроллер, вычисления и сеть — на Linux-машину. Типовой робот так и устроен: STM32 крутит токовые петли и считает энкодеры с периодом 100 мкс, Raspberry Pi занимается зрением и планированием, между ними UART, CAN или Ethernet. Прототип на Python при этом остаётся полезным инструментом — на нём проверяют логику до переписывания на C:

# MicroPython на ESP32: потенциометр -> яркость светодиода. Логика проверяется за минуту;
# в прошивке на C это же станет связкой таймер + АЦП + DMA без участия ядра.
from machine import Pin, PWM, ADC
import time

pot = ADC(Pin(34)); pot.atten(ADC.ATTN_11DB)   # диапазон примерно до 3,1 В
led = PWM(Pin(2), freq=1000)
GAMMA = [round(65535 * (i / 255) ** 2.2) for i in range(256)]

def read_filtered(n: int = 16) -> int:
    """Среднее из n выборок: O(n) времени, O(1) памяти."""
    return sum(pot.read_u16() for _ in range(n)) // n

while True:
    led.duty_u16(GAMMA[read_filtered() >> 8])  # 16 бит -> индекс 0..255
    time.sleep_ms(20)

На стороне «большой» машины тот же датчик становится узлом ROS 2: прошивка отдаёт сырые отсчёты, а калибровка и фильтрация живут выше, где их легко менять и тестировать.

# ROS 2 (rclpy): мост «контроллер по UART -> топик», 50 Гц — темп верхнего уровня.
class RangeBridge(Node):
    def tick(self):
        msg = Range()
        msg.header.stamp = self.get_clock().now().to_msg()
        msg.range = int(self.port.readline()) * 0.000806 * CALIB  # отсчёты -> вольты -> метры
        self.pub.publish(msg)

Архитектурная сторона такого разделения — отдельная большая тема; на портале она разобрана в разделе НИР, где роботизированная система рассматривается целиком: определение и классификация роботизированных систем и программная реализация сервисов. Читать полезно после того, как станет понятно, что именно контроллер отдаёт наверх и с какой периодичностью.

Отладка периферии без единого принта

Периферия — область, где printf не просто неудобен, а вреден: вывод строки по UART на 115200 бод занимает сотни микросекунд, и любое явление быстрее миллисекунды ваш лог уничтожит. Отладка здесь приборная.

Осциллограф — единственный способ увидеть аналоговую правду: реальную амплитуду, время нарастания, звон после фронта, просадку питания при включении нагрузки, шум на входе АЦП. Двухканальный прибор на 100 МГц закрывает почти все задачи микроконтроллерной разработки. Первое, что стоит сделать после настройки ШИМ, — измерить его частоту и скважность: расхождение с расчётом мгновенно выдаёт неверное представление о частоте шины.

Логический анализатор — прибор про время и цифровые состояния: 8–16 каналов, запись на секунды, автоматическое декодирование UART, I2C, SPI, CAN. Он отвечает на вопросы «в каком порядке», «через сколько микросекунд» и «что именно было в кадре». Недорогие клоны вместе с открытым sigrok/PulseView стоят как ужин и экономят недели.

SWD или JTAG — отладочный интерфейс ядра: точки останова, пошаговое выполнение и, что важнее всего здесь, просмотр регистров периферии в человеческом виде. Отладчик с загруженным SVD-описанием кристалла показывает не «0x40020000 = 0x00001000», а расшифрованное MODER12 = Output; половина ошибок конфигурации видна на этом экране за секунды. Отдельно полезны точки останова по данным: «остановись, когда кто-нибудь запишет в этот регистр» — так находят чужой код, ломающий вашу настройку. Важная тонкость: остановка ядра не останавливает физику. Пока вы стоите на точке останова, таймер продолжает считать, мотор — вращаться, сторожевой таймер — приближать сброс, буфер приёма — переполняться. Для этого существуют управляющие биты (в STM32 — DBGMCU), замораживающие таймеры и watchdog вместе с ядром, а для вывода без остановки — SEGGER RTT, пишущий в кольцевой буфер ОЗУ за считанные микросекунды.

GPIO как измерительный прибор — приём, который стоит освоить в первый же день. Выделите свободную ножку, поднимайте её в начале интересующего участка и опускайте в конце:

void TIM2_IRQHandler(void)
{
    GPIOB->BSRR = (1UL << 5);        /* метка: вошли в обработчик */
    TIM2->SR = ~TIM_SR_UIF;
    control_loop_step();             /* полезная работа */
    GPIOB->BSRR = (1UL << (5 + 16)); /* метка: вышли */
}
/* Осциллограф покажет период вызова, длительность работы и джиттер — то есть ровно то,
   чего вы никогда не узнаете из логов. Накладные расходы: два такта. */
Симптом Чем смотреть Что обычно оказывается
Ножка «не шевелится» отладчик, регистры по SVD не подан такт на порт или неверный AFR
ШИМ не той частоты осциллограф неверное представление о частоте шины после PLL
АЦП шумит на младших битах осциллограф на входе земляная петля, отсутствие фильтра, длинный провод
Кнопка срабатывает дважды логический анализатор дребезг, обработанный прерыванием без фильтра
Сбой раз в час ножка-метка плюс анализатор с триггером переполнение буфера или гонка с прерыванием
Мотор дёргается при смене скорости осциллограф на выходе моста запись CCR без предзагрузки, рваный импульс

Систематический подход к измерениям — общая инженерная дисциплина, хорошо описанная в статье про измерения: гипотеза, прибор, повторяемость, и только потом вывод. Подробности про SWD, трассировку и производственное тестирование — в статье про отладку и производство.

Типичные ошибки

  1. Забыть включить тактирование блока. Симптом: регистры не записываются. Проверяется первым делом, всегда.
  2. ODR |= mask вместо BSRR. Работает, пока в системе одно прерывание; ломается ровно тогда, когда появится второе.
  3. Формировать сигнал через delay(). Точность на уровне процентов, ядро занято, любое прерывание сдвигает временную диаграмму.
  4. Считать отсчёт АЦП напряжением. Без учёта реальной опоры и делителя цифра означает только саму себя.
  5. Опрашивать АЦП «в цикле с задержкой». Плавающая частота дискретизации обесценивает любой последующий фильтр.
  6. Игнорировать время выборки при высокоомном источнике. Даёт заниженные значения и мнимое влияние каналов друг на друга.
  7. Менять скважность без предзагрузки. Рваный импульс на мосту — это бросок тока, а иногда и сгоревший ключ.
  8. Формировать мёртвое время программно. Аппарат делает это за наносекунды и без участия планировщика; программа не успеет.
  9. Вешать прерывание прямо на дребезжащую кнопку. Сотни вызовов ISR вместо одного события.
  10. Подключать нагрузку прямо к ножке. Ток одного вывода — единицы и десятки миллиампер, суммарный по порту ограничен отдельно; мотор, реле и лента требуют ключа или драйвера.
  11. Забыть про кэш данных при DMA на Cortex-M7 (ядро читает устаревшую копию — выглядит как неработающий DMA) или считать ножку свободной: на ESP32 часть выводов занята флешем, на платах — светодиодом и кварцем.

Мини-итог

Периферия превращает программу в устройство, и работа с ней подчиняется трём принципам. Блоки автономны: их настраивают и запускают, а не вызывают; хорошая прошивка отдаёт железу всю рутину и оставляет ядру решения. Физика не абстрагируется: подтяжка, крутизна фронта, время зарядки конденсатора выборки и опорное напряжение входят в поведение программы наравне с алгоритмом, и никакая библиотека их не спрячет. Время измеряется, а не предполагается: реальную частоту ШИМ, реальный период вызова обработчика и реальный шум на входе АЦП вы узнаете только прибором. План на ближайший вечер: мигните светодиодом через регистры и убедитесь осциллографом, что период именно такой, как вы посчитали; переведите мигание на таймер и сравните джиттер; заведите потенциометр на АЦП и посмотрите, сколько младших бит шумит; наконец, свяжите одно с другим — пусть таймер запускает АЦП, а результат меняет скважность. В этот момент у вас окажется законченная схема управления, в которой ядро почти ничего не делает, и это правильное ощущение от встраиваемой разработки.

Источники

Что дальше

Прерывания и реальное время: ISR, приоритеты, гонки, детерминизм — разберём, что происходит в момент, когда периферия дёргает ядро: как устроен вектор прерывания и контроллер NVIC, чем латентность отличается от джиттера, почему обработчик обязан быть коротким, как возникают гонки между ISR и основным кодом и какими средствами их устраняют, что такое детерминизм и как на практике оценивают худшее время выполнения.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов