Embedded HandbookПриложения
Все разделы › I. Основы
STM32F103, STM32F407 · уровень средний · 25 мин чтения
Открыть в интерактивном справочнике

5а. HAL в работе: три режима обмена, состояния, обратные вызовы

Раздел 5 показал, во что HAL превращается компилятором. Этот — про то, как им пользоваться. У каждой функции обмена три версии, у каждого блока — дескриптор с автоматом состояний, и почти все загадочные отказы HAL сводятся к одному: код нарушил этот автомат, а HAL молча вернул код возврата, который никто не прочитал. Разбираться будем на UART — по исходнику stm32f4xx_hal_uart.c из пака; у I2C, SPI и АЦП устройство то же самое.

Как это называется в документации

Оригинал Перевод
handle дескриптор: структура с настройками и состоянием блока
polling / blocking mode блокирующий режим: функция ждёт конца обмена
interrupt mode (_IT) режим по прерыванию
DMA mode (_DMA) режим с DMA
callback обратный вызов
half-transfer callback обратный вызов на половине буфера
reception to IDLE приём до паузы в линии
tick, time base счётчик миллисекунд HAL и его источник
register callbacks обратные вызовы через указатели в дескрипторе
legacy weak callback прежний способ: слабые функции

5а.1 Дескриптор: настройки и состояние в одной структуре

Каждый блок в HAL представлен дескриптором — структурой вида UART_HandleTypeDef. В ней живёт всё, что HAL знает о блоке:

Поле Что хранит
Instance указатель на регистры: USART1, USART2… — тот самый адрес из CMSIS
Init параметры настройки: скорость, формат кадра, режим
pTxBuffPtr, TxXferCount буфер и счётчик идущей передачи
pRxBuffPtr, RxXferCount то же для приёма
hdmatx, hdmarx ссылки на дескрипторы каналов DMA, если они привязаны
Lock защёлка __HAL_LOCK (раздел 5.2)
gState состояние блока в целом и передачи
RxState состояние приёма
ErrorCode накопленные флаги ошибок HAL_UART_ERROR_*

Настройка выглядит так (это код опыта, который дальше поедет в QEMU):

UART_HandleTypeDef huart1;

huart1.Instance = USART1;
huart1.Init.BaudRate = 115200;
huart1.Init.WordLength = UART_WORDLENGTH_8B;
huart1.Init.StopBits = UART_STOPBITS_1;
huart1.Init.Parity = UART_PARITY_NONE;
huart1.Init.Mode = UART_MODE_TX_RX;
huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
huart1.Init.OverSampling = UART_OVERSAMPLING_16;
HAL_UART_Init(&huart1);

Почему дескриптор глобальный. Не по привычке и не потому, что так генерирует CubeMX. Во время обмена HAL хранит в дескрипторе указатель на буфер и счётчики, а обработчик прерывания — HAL_UART_IRQHandler(&huart1) — должен видеть ту же структуру, что и main. Локальная переменная умерла бы на выходе из функции, и прерывание продолжило бы писать по адресу, где её уже нет. Дескриптор обязан жить, пока живёт блок, — проще всего глобально.

Состояний два, и это важно. gState отвечает за блок в целом и за передачу, RxState — только за приём. Благодаря этому передача и приём идут одновременно и не мешают друг другу. Значения — из HAL_UART_StateTypeDef:

Значение Код Смысл
HAL_UART_STATE_RESET 0x00 HAL_UART_Init ещё не вызывалась
HAL_UART_STATE_READY 0x20 настроен и свободен
HAL_UART_STATE_BUSY 0x24 внутренняя операция (идёт Init)
HAL_UART_STATE_BUSY_TX 0x21 идёт передача (только gState)
HAL_UART_STATE_BUSY_RX 0x22 идёт приём (только RxState)
HAL_UART_STATE_TIMEOUT 0xA0 вышел таймаут
HAL_UART_STATE_ERROR 0xE0 ошибка

Код 0x23 (BUSY_TX_RX) в заголовке тоже есть, но это не отдельное состояние, а сумма битов TX и RX для отчёта наружу — ни gState, ни RxState его не принимают.

⚠️ Один блок — один дескриптор. Заведёте две структуры с Instance = USART1 — получите два независимых «состояния» одного железа: каждая будет считать порт свободным, и проверки HAL перестанут защищать. HAL этого не ловит.


5а.2 Одна передача в трёх видах

У каждой операции обмена в HAL три версии. Отличаются они не результатом — байты в линии во всех случаях идут одинаково, — а тем, чем занят процессор и когда возвращается функция.

Функция Возвращается Процессор занят Конец обмена
HAL_UART_Transmit когда всё ушло всю передачу возврат из функции
HAL_UART_Transmit_IT сразу прерывание на каждый байт прерывание TC → TxCpltCallback
HAL_UART_Transmit_DMA сразу только запуск DMA кончил → прерывание TC → TxCpltCallback

Блокирующая версия — цикл опроса. На каждый байт она ждёт флага TXE, и время меряет по HAL_GetTick (5а.6):

if (UART_WaitOnFlagUntilTimeout(huart, UART_FLAG_TXE, RESET, tickstart, Timeout) != HAL_OK)
{
  return HAL_TIMEOUT;
}

Таймаут здесь общий на всю посылку: tickstart берётся один раз до цикла, так что Timeout — это предел для всех байтов вместе, а не для каждого. После последнего байта функция ждёт ещё и флага TC — «сдвиговый регистр пуст» — и только тогда возвращает HAL_OK: линия действительно свободна.

Версия по прерыванию только раскладывает работу и уходит:

/* Enable the UART Transmit data register empty Interrupt */
__HAL_UART_ENABLE_IT(huart, UART_IT_TXE);

return HAL_OK;

Дальше каждый флаг TXE зовёт прерывание, обработчик перекладывает очередной байт из буфера в DR и выходит. На последнем байте обработчик пересаживается с TXE на TC:

if (--huart->TxXferCount == 0U)
{
  /* Disable the UART Transmit Data Register Empty Interrupt */
  __HAL_UART_DISABLE_IT(huart, UART_IT_TXE);

  /* Enable the UART Transmit Complete Interrupt */
  __HAL_UART_ENABLE_IT(huart, UART_IT_TC);
}

и уже прерывание TC ставит gState = READY и зовёт HAL_UART_TxCpltCallback.

Версия с DMA настраивает канал (раздел 9), ставит бит DMAT — и всё: байты в DR перекладывает контроллер DMA. Когда DMA сообщает «буфер пуст», HAL делает характерную вещь:

/* Disable the DMA transfer for transmit request by setting the DMAT bit
   in the UART CR3 register */
ATOMIC_CLEAR_BIT(huart->Instance->CR3, USART_CR3_DMAT);

/* Enable the UART Transmit Complete Interrupt */
ATOMIC_SET_BIT(huart->Instance->CR1, USART_CR1_TCIE);

DMA закончил раньше, чем последний байт ушёл из сдвигового регистра, — потому конец передачи объявляет не DMA, а USART: обратный вызов TxCpltCallback и здесь приходит из прерывания TC.

Одна передача тремя функциями HAL: блокирующая держит процессор весь кадр, версия по прерыванию — на каждый байт, DMA — только запуск и одно прерывание TC в конце

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

⚠️ Буфер на стеке + _IT или _DMA = мусор в линии.

c void bad_send(void) { char msg[32]; /* буфер на стеке */ int n = mk_report(msg); HAL_UART_Transmit_IT(&huart1, (uint8_t *)msg, (uint16_t)n); } /* msg умер, а передача только началась */

Функция вернулась сразу, массив перестал существовать, а прерывания ещё долго читают из него байты. Уйдёт то, что успело лечь на его место. Буферы для _IT и _DMA — статические или глобальные.


5а.3 HAL_BUSY: отказ без ошибки

Почти каждая функция обмена начинается одинаково:

/* Check that a Tx process is not already ongoing */
if (huart->gState == HAL_UART_STATE_READY)

а кончается так:

else
{
  return HAL_BUSY;
}

Никакого сообщения, никакого ErrorCallback, ни одного флага — только код возврата, один из четырёх в HAL_StatusTypeDef: HAL_OK = 0, HAL_ERROR = 1, HAL_BUSY = 2, HAL_TIMEOUT = 3. Вызвали HAL_UART_Transmit_IT, пока прошлая передача не дошла до TC, — функция просто ничего не сделала. Строка «теряется» без следа, и это самая частая жалоба на HAL: «отправляю два сообщения подряд — уходит одно».

Вторая причина того же кода — защёлка Lock (__HAL_LOCK, разобрана в 5.2): она закрывает короткое окно запуска, пока функция переписывает поля дескриптора. Состояние же занято всю операцию — от запуска до обратного вызова конца.

🔧 Рецепт. Код возврата HAL проверяется всегда — это не украшение:

if (HAL_UART_Transmit_IT(&huart1, buf, len) != HAL_OK) {
    /* порт занят: в очередь, счётчик потерь — но не делать вид, что ушло */
}

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


5а.4 Приём по прерыванию: одна порция на запуск

HAL_UART_Receive_IT(&huart1, buf, N) — это не «включить приём навсегда», а «принять ровно N байт». Функция запоминает буфер, ставит RxState = BUSY_RX, включает прерывание RXNE и возвращается. Каждый пришедший байт обработчик перекладывает в буфер, а на N-м делает вот что:

/* Disable the UART Data Register not empty Interrupt */
__HAL_UART_DISABLE_IT(huart, UART_IT_RXNE);

/* Disable the UART Parity Error Interrupt */
__HAL_UART_DISABLE_IT(huart, UART_IT_PE);

/* Disable the UART Error Interrupt: (Frame error, noise error, overrun error) */
__HAL_UART_DISABLE_IT(huart, UART_IT_ERR);

/* Rx process is completed, restore huart->RxState to Ready */
huart->RxState = HAL_UART_STATE_READY;

— выключает приёмные прерывания совсем и только потом зовёт HAL_UART_RxCpltCallback. Отсюда два следствия.

Первое: раз RxState уже READY, перезапуск приёма прямо внутри обратного вызова законен — и обязателен, если байты идут потоком:

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        push_byte(rx_byte);              /* в очередь; разбор — в основном цикле */
        HAL_UART_Receive_IT(huart, (uint8_t *)&rx_byte, 1);   /* RxState уже READY */
    }
}

Второе: если перезапуска нет, порт глохнет молча. Следующий байт железо примет в DR, но прерывание выключено — обратных вызовов больше не будет. Ещё один байт — и это уже переполнение: флаг ORE (раздел 16.4).

Опыт в QEMU (машина netduinoplus2, STM32F405; собран настоящий HAL из пака — файлы и скрипт в _verify/hal5a/). Программа запускает приём одного байта, тут же пробует запустить второй раз, потом хост шлёт два байта — A и B:

r1=0x00000000 rxstate=0x00000022 r2=0x00000002
rx_count=0x00000001 byte=0x00000041
sr=0x000000e0 rxneie=0x00000000 rx_count=0x00000001
stuck=0x00000042 r3=0x00000000

Первый запуск — HAL_OK, состояние 0x22 (BUSY_RX), повторный тут же — HAL_BUSY (2), молча. Пришёл байт 0x41 (A), обратный вызов сработал один раз — и всё: в SR = 0xE0 горит RXNE (бит 5), но RXNEIE снят, счётчик замер на единице. Байт 0x42 (B) так и лежит в DR — приложение его не получило, пока программа не забрала его руками. После этого RxState снова READY, и новый запуск приёма вернул 0.

Приём по прерыванию: пока идёт порция — HAL_BUSY, после последнего байта приёмник выключен, и без перезапуска в RxCpltCallback следующий байт остаётся в DR

Про ORE и ErrorCallback. Ошибки приёма HAL делит на две группы. Помеха, ошибка кадра — «неблокирующие»: HAL_UART_ErrorCallback вызывается, но приём продолжается. Переполнение — «блокирующее»:

dmarequest = HAL_IS_BIT_SET(huart->Instance->CR3, USART_CR3_DMAR);
if (((huart->ErrorCode & HAL_UART_ERROR_ORE) != RESET) || dmarequest)

в этой ветке HAL сначала останавливает приём — снимает прерывания и ставит RxState = READY — и лишь потом зовёт ErrorCallback. Причём байт, который успел прийти вместе с ошибкой, обработчик забирает из DR до вашего обратного вызова — данные не пропадают, пропадает запущенный приём. Поэтому пустой ErrorCallback — это порт, который после первого же переполнения замолкает навсегда. Минимальный правильный вариант:

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        HAL_UART_Receive_IT(huart, (uint8_t *)&rx_byte, 1);   /* после ORE приём поднимают заново */
    }
}

5а.5 🔧 Приём любой длины: ReceiveToIdle

Принимать по одному байту через Receive_IT — это прерывание на каждый байт и перезапуск в каждом обратном вызове; на 115200 это ещё терпимо, на мегабитных скоростях — уже нет. В 16.5 та же задача решена регистрами: пауза в линии взводит флаг IDLE, DMA складывает байты без процессора. У HAL для этой связки есть готовая обёртка:

uint8_t rx_dma[256];

void rx_start(void)
{
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_dma, sizeof(rx_dma));
    __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);   /* вызов на половине буфера не нужен */
}

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1) {
        parse(rx_dma, Size);             /* Size — сколько байт лежит от начала буфера */
        rx_start();                      /* и снова: запуск — порция — запуск */
    }
}

HAL_UARTEx_ReceiveToIdle_DMA запускает обычный приём в DMA и дополнительно включает прерывание IDLE. Обратный вызов у него свой — HAL_UARTEx_RxEventCallback, и приходит он в трёх случаях:

Событие Когда Size
пауза в линии (IDLE) пакет кончился раньше буфера сколько уже принято
половина буфера DMA прошёл середину половина буфера
буфер полон DMA дошёл до конца весь буфер

Канал DMA должен быть настроен и привязан к hdmarx заранее — CubeMX делает это в MX_DMA_Init. Есть и родственники: HAL_UARTEx_ReceiveToIdle (блокирующий, с таймаутом) и HAL_UARTEx_ReceiveToIdle_IT.

⚠️ Size — это позиция, а не приращение. Обратный вызов на половине буфера сообщит Size = 128, следом придёт IDLE с Size = 150 — из них новых байт только 22. В режиме Normal, как в рецепте выше, проще всего перезапускать приём после каждого пакета и всегда разбирать буфер с нуля. Вызов на половине буфера в этой схеме только мешает — потому он и выключен строкой __HAL_DMA_DISABLE_IT. В кольцевом режиме DMA придётся вести свой указатель «докуда разобрано».


5а.6 HAL_GetTick: часы всей библиотеки

Всё, что в HAL называется Timeout, меряется одним счётчиком:

__weak uint32_t HAL_GetTick(void)
{
  return uwTick;
}

uwTick растёт на единицу в прерывании: HAL_Init при старте настраивает SysTick на 1 мс, а сгенерированный SysTick_Handler из stm32f4xx_it.c зовёт HAL_IncTick. Приоритет тика задаёт TICK_INT_PRIORITY в stm32f4xx_hal_conf.h, и по умолчанию это 0x0F — самый низкий из шестнадцати (раздел 7).

Отсюда классическая ловушка. HAL_Delay — это цикл ожидания по тому же счётчику:

__weak void HAL_Delay(uint32_t Delay)
{
  uint32_t tickstart = HAL_GetTick();
  uint32_t wait = Delay;

  /* Add a freq to guarantee minimum wait */
  if (wait < HAL_MAX_DELAY)
  {
    wait += (uint32_t)(uwTickFreq);
  }

  while((HAL_GetTick() - tickstart) < wait)
  {
  }
}

Позовите его — или любую блокирующую функцию HAL с таймаутом — из обработчика прерывания с приоритетом выше или равным SysTick, и uwTick перестанет расти: прерывание тика не может вытеснить ваше. Цикл станет вечным. Варианта два: не звать HAL из обработчиков (правильный) или поднять приоритет тика (терпимый: TICK_INT_PRIORITY в конфигурации).

Заодно из листинга видно: wait += uwTickFreq — к задержке всегда прибавляется один тик, иначе HAL_Delay(1), вызванный за микросекунду до прерывания тика, вернулся бы почти сразу. Так что HAL_Delay(1) — это «от 1 до 2 мс», а не «ровно 1 мс».

Тайм-база на TIM6. SysTick — законная собственность ОС: FreeRTOS забирает его себе (разделы 23–24). Чтобы у HAL остались свои часы, CubeMX при включённой ОС предлагает перенести тайм-базу на таймер, обычно TIM6. В паке лежит готовый образец stm32f4xx_hal_timebase_tim_template.c: он переопределяет слабую HAL_InitTick, разгоняет TIM6 на счёт 1 МГц с периодом 999 — прерывание раз в миллисекунду, — и HAL_IncTick переезжает в TIM6_DAC_IRQHandler. Для кода разницы нет: HAL_GetTick как возвращал миллисекунды, так и возвращает.


5а.7 🔧 Шаблоны на каждый день

Четыре фрагмента, закрывающие большинство бытовых задач. Все они собраны компилятором (файл _verify/hal5a/templates5a.c); подробности каждого блока — в его разделе.

I2C: чтение из EEPROM одной функцией (раздел 17). Mem_Read сам делает всю транзакцию: старт, адрес микросхемы, адрес ячейки, повторный старт, чтение с NAK в конце.

#define EEPROM_ADDR (0x50 << 1)          /* HAL ждёт 7-битный адрес уже сдвинутым */

uint8_t page[32];

void eeprom_read(void)
{
    if (HAL_I2C_Mem_Read(&hi2c1, EEPROM_ADDR, 0x0000, I2C_MEMADD_SIZE_16BIT,
                         page, sizeof(page), 100) != HAL_OK)
        error();
}

Сдвиг << 1 — не украшение: HAL кладёт параметр в линию как есть, только подставляя в младший бит направление. Передадите несдвинутый 0x50 — на линии окажется чужой адрес 0x28, и устройство не ответит.

SPI: обмен с ручным CS (раздел 18). SPI всегда дуплекс, поэтому основная функция — TransmitReceive; выводом CS HAL не управляет:

uint8_t tx[4] = {0x9F, 0, 0, 0}, rx[4];
HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET);
HAL_SPI_TransmitReceive(&hspi1, tx, rx, sizeof(tx), 100);
HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);

АЦП по кругу через DMA (раздел 14):

uint16_t adc_buf[16];

void adc_start(void)
{
    HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 16);   /* длина — в преобразованиях */
}

Приведение (uint32_t *) обманчиво: данные не 32-битные, просто сигнатура у функции одна на все разрядности, а ширину переноса задаёт настройка канала DMA. Длина — в преобразованиях, не в байтах.

ШИМ: канал молчит, пока его не запустили (раздел 13):

HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 500);

MX_TIM3_Init только настраивает: включает выход и счёт именно HAL_TIM_PWM_Start. Скважность на ходу меняют макросом __HAL_TIM_SET_COMPARE — это прямая запись в CCR1, без переинициализации.

У каждой из этих функций есть братья _IT и _DMA со своими обратными вызовами: HAL_I2C_MemRxCpltCallback, HAL_SPI_TxRxCpltCallback, HAL_ADC_ConvCpltCallback, HAL_TIM_PeriodElapsedCallback — схема из 5а.2 повторяется в каждом блоке дословно.


5а.8 Свои обратные вызовы: слабые функции и указатели

По умолчанию обратные вызовы — слабые функции (механика и главная ловушка, опечатка в имени, разобраны в 5.7). В stm32f4xx_hal_uart.c их одиннадцать: MspInit, MspDeInit, по паре «готово»/«половина» на передачу и приём, ErrorCallback, три варианта завершения Abort и RxEventCallback. Слабая функция одна на все экземпляры блока — потому внутри принято разбирать, кто пришёл, по huart->Instance.

У этого способа есть предел: обработчик выбирается на этапе компоновки, один и навсегда. Второй механизм — указатели в дескрипторе — включается в stm32f4xx_hal_conf.h:

#define  USE_HAL_UART_REGISTER_CALLBACKS        1U /* UART register callback disabled      */

(комментарий в шаблоне описывает значение по умолчанию — 0). Тогда в UART_HandleTypeDef появляются поля TxCpltCallback, RxCpltCallback и остальные, а привязка делается на ходу:

HAL_UART_Init(&huart1);              /* Init ставит во все указатели слабые заглушки */
HAL_UART_RegisterCallback(&huart1, HAL_UART_RX_COMPLETE_CB_ID, on_rx_done);

Порядок важен: регистрация разрешена в состоянии READY, то есть после HAL_UART_Init (до неё — только MspInit/MspDeInit, пока gState равен RESET). Выгода — разные обработчики на разные порты и замена на лету; цена — лишний уровень косвенности и указатели, которые надо не забыть зарегистрировать. В одиночных проектах слабых функций хватает; указатели нужны библиотекам, которые не могут занять глобальное имя HAL_UART_RxCpltCallback у пользователя.

StdPeriph в старом коде. Примеры до ≈2014 года написаны на SPL (раздел 5.1) — её видно сразу по паре «функция + флаг» и структурам с приставкой блока:

USART_InitTypeDef u;
u.USART_BaudRate = 115200;
/* ... */
USART_Init(USART1, &u);
USART_Cmd(USART1, ENABLE);

USART_SendData(USART1, 'A');
while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET) { }

Дескрипторов, состояний и обратных вызовов в SPL нет — это тонкие обёртки над регистрами, по духу ближе к сегодняшнему LL. Читать такой код можно буквально: USART_SendData — запись в DR, USART_GetFlagStatus — чтение SR. Переносить его в новый проект стоит либо на регистры (раздел 16), либо на HAL целиком; смешивать SPL и HAL в одном проекте нельзя — они по-разному распоряжаются одними и теми же регистрами.


5а.9 Грабли

Код возврата не проверен — сообщения «теряются». Второй вызов по занятому порту молча возвращает HAL_BUSY. Проверять возврат, очередь — из TxCpltCallback (5а.3).

Буфер для _IT/_DMA на стеке. Функция вернулась сразу, массив умер, прерывания досылают мусор. Буферы — статические (5а.2).

Приём принял порцию и замолк. Receive_IT — одна порция на запуск: на последнем байте RXNEIE выключается. Перезапуск — прямо в RxCpltCallback (5а.4, опыт в QEMU).

Пустой ErrorCallback — порт умирает после первого ORE. Переполнение — «блокирующая» ошибка: HAL останавливает приём и ждёт, что вы его поднимете (5а.4).

HAL_Delay в обработчике прерывания. SysTick с приоритетом 0x0F не вытеснит ваш обработчик — счётчик замрёт, цикл станет вечным (5а.6).

Size в RxEventCallback — позиция, а не приращение. И не забудьте про вызов на половине буфера, если он вам не нужен (5а.5).


5а.10 Итоги

Полные исходники

templates5a.c — рабочие фрагменты раздела одним файлом — передача, приём, ReceiveToIdle, шаблоны.

/* Раздел 5а: рабочие фрагменты из текста — файл собирается armclang целиком
 * с HAL из пака STM32F4xx_DFP 2.17.1 (проверка hal5a_check.py). Листинги
 * подразделов 5а.2, 5а.4, 5а.5 и 5а.7 — вырезки отсюда. */
#include "stm32f4xx_hal.h"

I2C_HandleTypeDef hi2c1;
SPI_HandleTypeDef hspi1;
ADC_HandleTypeDef hadc1;
TIM_HandleTypeDef htim3;
UART_HandleTypeDef huart1;
DMA_HandleTypeDef hdma_usart1_rx;

#define CS_GPIO_Port GPIOA
#define CS_Pin GPIO_PIN_4

void error(void);
int mk_report(char *dst);
void push_byte(uint8_t b);
void parse(const uint8_t *data, uint16_t len);

/* ── 5а.2: буфер обязан пережить передачу ──────────────────────────────── */
void bad_send(void)
{
    char msg[32];                        /* буфер на стеке */
    int n = mk_report(msg);
    HAL_UART_Transmit_IT(&huart1, (uint8_t *)msg, (uint16_t)n);
}                                        /* msg умер, а передача только началась */

/* ── 5а.3: код возврата проверяется всегда ─────────────────────────────── */
void tx_try(uint8_t *buf, uint16_t len)
{
    if (HAL_UART_Transmit_IT(&huart1, buf, len) != HAL_OK) {
        /* порт занят: в очередь, счётчик потерь — но не делать вид, что ушло */
    }
}

/* ── 5а.4: перезапуск приёма в обратных вызовах ────────────────────────── */
volatile uint8_t rx_byte;

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        push_byte(rx_byte);              /* в очередь; разбор — в основном цикле */
        HAL_UART_Receive_IT(huart, (uint8_t *)&rx_byte, 1);   /* RxState уже READY */
    }
}

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        HAL_UART_Receive_IT(huart, (uint8_t *)&rx_byte, 1);   /* после ORE приём поднимают заново */
    }
}

/* ── 5а.5: приём пакетов любой длины ───────────────────────────────────── */
uint8_t rx_dma[256];

void rx_start(void)
{
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_dma, sizeof(rx_dma));
    __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);   /* вызов на половине буфера не нужен */
}

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1) {
        parse(rx_dma, Size);             /* Size — сколько байт лежит от начала буфера */
        rx_start();                      /* и снова: запуск — порция — запуск */
    }
}

/* ── 5а.7: I2C — чтение из EEPROM одной функцией ───────────────────────── */
#define EEPROM_ADDR (0x50 << 1)          /* HAL ждёт 7-битный адрес уже сдвинутым */

uint8_t page[32];

void eeprom_read(void)
{
    if (HAL_I2C_Mem_Read(&hi2c1, EEPROM_ADDR, 0x0000, I2C_MEMADD_SIZE_16BIT,
                         page, sizeof(page), 100) != HAL_OK)
        error();
}

/* ── 5а.7: SPI — обмен с ручным CS ─────────────────────────────────────── */
uint8_t jedec_id[3];

void flash_read_id(void)
{
    uint8_t tx[4] = {0x9F, 0, 0, 0}, rx[4];
    HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET);
    HAL_SPI_TransmitReceive(&hspi1, tx, rx, sizeof(tx), 100);
    HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);
    jedec_id[0] = rx[1];                 /* rx содержит и байты, пришедшие под команду */
    jedec_id[1] = rx[2];
    jedec_id[2] = rx[3];
}

/* ── 5а.7: АЦП по кругу через DMA ──────────────────────────────────────── */
uint16_t adc_buf[16];

void adc_start(void)
{
    HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 16);   /* длина — в преобразованиях */
}

/* ── 5а.7: ШИМ — канал молчит, пока его не запустили ───────────────────── */
void pwm_start(void)
{
    HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 500);
}

templates5a_cb.c — обратные вызовы через указатели в дескрипторе (5а.8).

/* Раздел 5а.8: обратные вызовы через указатели в дескрипторе. Файл собирается
 * armclang с USE_HAL_UART_REGISTER_CALLBACKS = 1U в stm32f4xx_hal_conf.h —
 * проверка hal5a_check.py подкладывает правленый conf. */
#include "stm32f4xx_hal.h"

UART_HandleTypeDef huart1;

static void on_rx_done(UART_HandleTypeDef *huart)
{
    (void)huart;
}

void uart_setup(void)
{
    HAL_UART_Init(&huart1);              /* Init ставит во все указатели слабые заглушки */
    HAL_UART_RegisterCallback(&huart1, HAL_UART_RX_COMPLETE_CB_ID, on_rx_done);
}

uart5a.c — опыт 5а.3 и 5а.4 на настоящем HAL в QEMU: HAL_BUSY и приём по прерыванию.

/* Раздел 5а: настоящий HAL (пак Keil STM32F4xx_DFP 2.17.1) в QEMU netduinoplus2 (STM32F405).
 * Опыт из 5а.3 и 5а.4:
 *  1) пока приём по прерыванию идёт, повторный HAL_UART_Receive_IT возвращает HAL_BUSY (2),
 *     а RxState равен HAL_UART_STATE_BUSY_RX (0x22);
 *  2) после RxCpltCallback приём выключен: второй байт остаётся в DR, RXNE горит,
 *     RXNEIE снят, счётчик принятых не растёт — байт до приложения не доходит.
 * Печать — прямой записью в USART1 (как в rmw5), приём — целиком через HAL. */
#include "stm32f4xx_hal.h"

/* Три определения из system_stm32f4xx.c — весь файл здесь не нужен */
uint32_t SystemCoreClock = 16000000; /* HSI после сброса */
const uint8_t AHBPrescTable[16] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 6, 7, 8, 9};
const uint8_t APBPrescTable[8]  = {0, 0, 0, 0, 1, 2, 3, 4};

UART_HandleTypeDef huart1;
volatile uint32_t rx_count;
volatile uint8_t rx_buf[4];
volatile uint8_t rx_last;

void SysTick_Handler(void)
{
    HAL_IncTick();
}

void USART1_IRQHandler(void)
{
    HAL_UART_IRQHandler(&huart1);
}

void HAL_UART_MspInit(UART_HandleTypeDef *huart)
{
    (void)huart;
    __HAL_RCC_USART1_CLK_ENABLE();      /* в QEMU область RCC — заглушка, но порядок вызовов настоящий */
    HAL_NVIC_SetPriority(USART1_IRQn, 5, 0);
    HAL_NVIC_EnableIRQ(USART1_IRQn);
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    (void)huart;
    rx_last = rx_buf[0];
    rx_count++;                          /* перезапуска приёма нарочно НЕТ — в этом и опыт */
}

static void uputs(const char *s)
{
    for (; *s; s++) {
        while (!(USART1->SR & USART_SR_TXE)) {
        }
        USART1->DR = (uint32_t)*s;
    }
}

static void uhex(const char *k, uint32_t v)
{
    static const char h[] = "0123456789abcdef";
    char b[12] = "=0x";
    uputs(k);
    for (int i = 0; i < 8; i++)
        b[3 + i] = h[(v >> (28 - 4 * i)) & 0xF];
    b[11] = 0;
    uputs(b);
    uputs(" ");
}

static void spin(volatile uint32_t n)
{
    while (n--) {
    }
}

int main(void)
{
    HAL_Init();

    huart1.Instance = USART1;
    huart1.Init.BaudRate = 115200;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;
    huart1.Init.StopBits = UART_STOPBITS_1;
    huart1.Init.Parity = UART_PARITY_NONE;
    huart1.Init.Mode = UART_MODE_TX_RX;
    huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    huart1.Init.OverSampling = UART_OVERSAMPLING_16;
    HAL_UART_Init(&huart1);

    /* 1. Первый запуск приёма одного байта — HAL_OK, состояние BUSY_RX */
    uint32_t r1 = HAL_UART_Receive_IT(&huart1, (uint8_t *)rx_buf, 1);
    uint32_t st = huart1.RxState;
    /* 2. Повторный запуск, пока приём идёт, — молча HAL_BUSY */
    uint32_t r2 = HAL_UART_Receive_IT(&huart1, (uint8_t *)rx_buf, 1);
    uhex("r1", r1);
    uhex("rxstate", st);
    uhex("r2", r2);
    uputs("\n");

    /* 3. Хост шлёт два байта: 'A' и 'B'. Первый принимается по прерыванию. */
    uint32_t guard = 0;
    while (rx_count == 0 && ++guard < 2000000000u) {
    }
    uhex("rx_count", rx_count);
    uhex("byte", rx_last);
    uputs("\n");

    /* 4. Приём никто не перезапустил. Второй байт уже пришёл в DR — но RXNEIE снят. */
    spin(2000000);
    uhex("sr", USART1->SR);              /* бит 5 (RXNE) горит: байт ждёт в DR */
    uhex("rxneie", (USART1->CR1 >> USART_CR1_RXNEIE_Pos) & 1u);
    uhex("rx_count", rx_count);          /* так и остался 1 — обратный вызов больше не приходил */
    uputs("\n");

    /* 5. Достаём застрявший байт напрямую и убеждаемся, что это 'B' */
    uint32_t stuck = USART1->DR;
    uhex("stuck", stuck);
    /* 6. Теперь приём свободен (RxState = READY) — запуск снова разрешён */
    uint32_t r3 = HAL_UART_Receive_IT(&huart1, (uint8_t *)rx_buf, 1);
    uhex("r3", r3);
    uputs("\ndone\n");
    for (;;) {
    }
}

Дальше: раздел 6 «Тактирование (RCC)» — дерево тактов, PLL, настройка на 72 МГц вручную и почему неверный HSE_VALUE ломает все задержки.

Открыть в интерактивном справочнике