Перейти к содержимому

Последовательный порт, прямой доступ к памяти и консоль … ↩

Последовательный порт, прямой доступ к памяти и консоль mod. Четверг, 15 октября 2026 22:55 . См. 10

BeeFC · Урок S7 из 33 · Уровень «Старт»

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

После этого урока BeeFC можно управлять с компьютера: консоль принимает команды status, get, set и bl. Через ту же консоль в уроке S10 будет управляться тяговый стенд.

Содержание

1. Цели урока

Продолжительность урока — четыре часа: 1,5 часа теории и 2,5 часа практики. После урока слушатель:

  • рассчитывает делитель скорости последовательного порта и оценивает погрешность;
  • настраивает приём по прерыванию и через контроллер прямого доступа к памяти;
  • реализует кольцевой буфер для передачи данных между обработчиком прерывания и задачей без запрета прерываний;
  • строит консоль с таблицей команд и проверяет разбор команд модульными тестами.
↑ К оглавлению

2. Термины и сокращения

ТерминОпределение
универсальный асинхронный приёмопередатчик (UART)блок последовательного обмена без общего тактового сигнала; в STM32 называется USART
бодединица скорости передачи: число символов в секунду, для UART — бит в секунду
прямой доступ к памяти (DMA)передача данных между периферией и памятью без участия процессора
кольцевой буфермассив, в котором запись и чтение идут по кругу, а индексы переходят с конца на начало
интерфейс командной строки (CLI)текстовая консоль для управления прошивкой
виртуальный последовательный порт (VCP)порт USB, который компьютер видит как последовательный
барьер компиляторауказание, запрещающее компилятору переставлять обращения к памяти
↑ К оглавлению

3. Последовательный порт

Формат кадра

В формате 8N1 каждый байт передаётся кадром из десяти битов: стартовый бит с низким уровнем, восемь битов данных начиная с младшего и стоповый бит с высоким уровнем. Линия в покое находится в высоком уровне. На скорости 115 200 бод передаётся 11 520 байт в секунду, то есть один байт примерно за 87 мкс.

Выход передатчика одного устройства соединяется со входом приёмника другого, общий провод обязателен. Уровни сигналов STM32 — 3,3 В. Преобразователь USB–UART должен работать с уровнями 3,3 В: подключение к выходу с уровнем 5 В может повредить вывод микроконтроллера, который не допускает такого напряжения.

Кадр 8N1 для байта 0x41: стартовый бит, восемь битов данных начиная с младшего и стоповый бит.
Кадр 8N1 для байта 0x41: стартовый бит, восемь битов данных начиная с младшего и стоповый бит.

Делитель скорости

При стандартной передискретизации в шестнадцать раз значение регистра BRR равно частоте тактирования порта, делённой на скорость, с округлением до целого.

\( BRR = \frac{84\,000\,000}{115\,200} \approx 729{,}17 \to 729, \quad f_{факт} = \frac{84\,000\,000}{729} \approx 115\,226\ \text{бод} \)

Погрешность составляет около 0,02 %. Приёмник UART допускает расхождение скоростей в несколько процентов, поэтому такая точность с запасом достаточна.

Файл hal/stm32f4/uart_stm32.c

void uart1_init(uint32_t baud)
{
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    RCC->APB2ENR |= RCC_APB2ENR_USART1EN;            /* USART1 сидит на APB2, 84 МГц */
    (void)RCC->APB2ENR;

    /* PA9 и PA10: альтернативная функция AF7 (USART1). */
    GPIOA->MODER = (GPIOA->MODER & ~(0xFu << 18)) | (0xAu << 18);
    GPIOA->AFR[1] = (GPIOA->AFR[1] & ~(0xFFu << 4)) | (0x77u << 4);

    rb_init(&g_uart_rx, rx_mem, sizeof rx_mem);

    USART1->BRR = (84000000u + baud / 2u) / baud;    /* делитель с округлением */
    USART1->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE | USART_CR1_UE;
    NVIC_SetPriority(USART1_IRQn, 5);
    NVIC_EnableIRQ(USART1_IRQn);
}

void USART1_IRQHandler(void)
{
    if (USART1->SR & USART_SR_RXNE) {
        uint8_t byte = (uint8_t)USART1->DR;          /* чтение DR сбрасывает флаг */
        if (!rb_push(&g_uart_rx, byte)) g_uart_rx_overflow++;
    }
}

void uart1_write(const char *s)
{
    while (*s) {
        while (!(USART1->SR & USART_SR_TXE)) { }     /* ждать освобождения передатчика */
        USART1->DR = (uint8_t)*s++;
    }
}
Plain text
Выводы порта на учебной плате: В примере используются выводы PA9 и PA10 порта USART1. На некоторых платах эти выводы заняты: в описании MAMBAF405_2022B для Betaflight вывод PA9 — выход на первый двигатель, а USART1 переназначен на выводы PB6 и PB7. Выводы порта берутся из описания своей платы, составленного в уроке S2.
↑ К оглавлению

4. Кольцевой буфер

Обработчик прерывания помещает принятый байт в буфер, задача консоли забирает его оттуда. Если запись выполняет только обработчик, а чтение — только задача, запрещать прерывания не нужно. Индекс записи head изменяет только производитель, индекс чтения tail — только потребитель.

Файл core/ringbuf.h

/* Кольцевой буфер «один писатель — один читатель».
 * Размер — степень двойки, поэтому вместо деления используется маска. */
typedef struct {
    uint8_t          *data;
    uint16_t          mask;    /* размер - 1 */
    volatile uint16_t head;    /* пишет только производитель (прерывание) */
    volatile uint16_t tail;    /* пишет только потребитель (основной цикл) */
} ringbuf_t;

bool     rb_init(ringbuf_t *rb, uint8_t *storage, uint16_t size);
bool     rb_push(ringbuf_t *rb, uint8_t byte);
bool     rb_pop(ringbuf_t *rb, uint8_t *byte);
uint16_t rb_count(const ringbuf_t *rb);
Plain text

Файл core/ringbuf.c

/* Барьер компилятора: запрещает переносить запись данных за изменение индекса. */
#define RB_BARRIER() __asm__ __volatile__("" ::: "memory")

bool rb_init(ringbuf_t *rb, uint8_t *storage, uint16_t size)
{
    if (size == 0 || (size & (size - 1u)) != 0) return false;  /* не степень двойки */
    rb->data = storage;
    rb->mask = (uint16_t)(size - 1u);
    rb->head = rb->tail = 0;
    return true;
}

uint16_t rb_count(const ringbuf_t *rb)
{
    return (uint16_t)(rb->head - rb->tail);     /* индексы растут без ограничения */
}

bool rb_push(ringbuf_t *rb, uint8_t byte)
{
    if (rb_count(rb) == (uint16_t)(rb->mask + 1u)) return false;   /* буфер полон */
    rb->data[rb->head & rb->mask] = byte;
    RB_BARRIER();
    rb->head++;                     /* индекс меняется только после записи данных */
    return true;
}

bool rb_pop(ringbuf_t *rb, uint8_t *byte)
{
    if (rb->head == rb->tail) return false;     /* буфер пуст */
    *byte = rb->data[rb->tail & rb->mask];
    RB_BARRIER();
    rb->tail++;                     /* место освобождается только после чтения */
    return true;
}
Plain text

Индексы растут без ограничения и переходят через ноль вместе с типом uint16_t. Разность head - tail при этом остаётся верной по той же причине, что и разность времени в уроке S6. Размер буфера — степень двойки, поэтому положение в массиве вычисляется маской, а не делением.

Порядок записи данных и индекса: Байт должен оказаться в массиве раньше, чем изменится индекс head. Иначе потребитель увидит новый индекс и прочитает старое содержимое ячейки. Квалификатор volatile у индекса не запрещает компилятору перенести запись в обычный массив за запись индекса. Поэтому между ними стоит барьер компилятора RB_BARRIER.

Файл tests/unit/test_level1.c

void test_ringbuf_wraps_and_reports_overflow(void)
{
    uint8_t mem[8], b;
    ringbuf_t rb;
    TEST_ASSERT_FALSE(rb_init(&rb, mem, 6));        /* 6 — не степень двойки */
    TEST_ASSERT_TRUE(rb_init(&rb, mem, 8));

    for (int round = 0; round != 100; round++) {    /* многократный переход через край */
        for (uint8_t i = 0; i != 8; i++) TEST_ASSERT_TRUE(rb_push(&rb, i));
        TEST_ASSERT_FALSE(rb_push(&rb, 99));        /* девятый байт не помещается */
        for (uint8_t i = 0; i != 8; i++) {
            TEST_ASSERT_TRUE(rb_pop(&rb, &b));
            TEST_ASSERT_EQUAL_UINT8(i, b);
        }
        TEST_ASSERT_FALSE(rb_pop(&rb, &b));
    }
}
Plain text
Кольцевой буфер приёма: обработчик прерывания или контроллер DMA пишет по индексу head, задача консоли читает по индексу tail.
Кольцевой буфер приёма: обработчик прерывания или контроллер DMA пишет по индексу head, задача консоли читает по индексу tail.
↑ К оглавлению

5. Прямой доступ к памяти

При приёме по прерыванию процессор отвлекается на каждый байт. На скорости 115 200 бод это происходит раз в 87 мкс, на скорости 420 000 бод, которая применяется для приёмников ExpressLRS в уроке P5, — раз в 24 мкс. Контроллер прямого доступа к памяти переносит байты из регистра данных порта в буфер сам, а процессор забирает их, когда ему удобно.

В STM32F405 приём USART1 обслуживает контроллер DMA2, поток 2, канал 4. В кольцевом режиме контроллер записывает байты по кругу, а регистр NDTR показывает, сколько байт осталось до конца буфера. По нему вычисляется позиция записи.

Файл hal/stm32f4/uart_stm32.c

static uint8_t dma_rx[256];     /* в SRAM, а не в CCM: у DMA нет доступа к CCM */
static uint16_t dma_rx_tail;

void uart1_dma_rx_init(void)
{
    RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN;
    (void)RCC->AHB1ENR;

    DMA2_Stream2->CR = 0;                            /* поток выключается перед настройкой */
    while (DMA2_Stream2->CR & DMA_SxCR_EN) { }
    DMA2_Stream2->PAR  = (uint32_t)&USART1->DR;      /* откуда: регистр данных USART1 */
    DMA2_Stream2->M0AR = (uint32_t)dma_rx;           /* куда: буфер в памяти */
    DMA2_Stream2->NDTR = sizeof dma_rx;
    DMA2_Stream2->CR = (4u << DMA_SxCR_CHSEL_Pos)    /* канал 4 потока 2 — USART1_RX */
                     | DMA_SxCR_MINC                 /* адрес в памяти увеличивается */
                     | DMA_SxCR_CIRC;                /* кольцевой режим; направление 00 — из периферии в память */

    USART1->CR1 &= ~USART_CR1_RXNEIE;                /* прерывание на каждый байт больше не нужно */
    USART1->CR3 |= USART_CR3_DMAR;                   /* USART передаёт принятые байты в DMA */
    DMA2_Stream2->CR |= DMA_SxCR_EN;
}

bool uart1_dma_rx_pop(uint8_t *byte)
{
    /* NDTR — сколько байт осталось до конца буфера; отсюда позиция записи DMA. */
    uint16_t head = (uint16_t)(sizeof dma_rx - DMA2_Stream2->NDTR);
    if (dma_rx_tail == head) return false;
    *byte = dma_rx[dma_rx_tail];
    dma_rx_tail = (uint16_t)((dma_rx_tail + 1u) % sizeof dma_rx);
    return true;
}
Plain text

Если задача консоли не забирает данные дольше, чем требуется для заполнения 256 байт, контроллер перезапишет непрочитанные данные. На скорости 115 200 бод это примерно 22 мс, что с большим запасом перекрывает период задачи консоли 10 мс.

↑ К оглавлению

6. Консоль

Разбор строки

Строка команды делится на слова на месте: пробелы заменяются нулевыми символами, а в массив argv записываются указатели на начала слов. Знак «=» выделяется в отдельное слово, поэтому записи set gyro_hz=1000 и set gyro_hz = 1000 разбираются одинаково.

Файл core/cli_parse.h

#define CLI_MAX_ARGS 6

/* Делит строку на слова на месте (пробелы заменяются нулями).
 * Знак «=» считается отдельным словом: «set a=1» и «set a = 1» равнозначны. */
int cli_tokenize(char *line, char *argv[], int max_args);
Plain text

Файл core/cli_parse.c

static char eq_token[] = "=";

int cli_tokenize(char *line, char *argv[], int max_args)
{
    int argc = 0;
    char *p = line;

    while (*p != '\0' && argc != max_args) {
        while (*p == ' ' || *p == '\t') *p++ = '\0';      /* пропуск пробелов */
        if (*p == '\0') break;
        if (*p == '=') {                                  /* «=» — отдельное слово */
            *p++ = '\0';
            argv[argc++] = eq_token;
            continue;
        }
        argv[argc++] = p;
        while (*p != '\0' && *p != ' ' && *p != '\t' && *p != '=') p++;
    }
    return argc;
}
Plain text

Файл tests/unit/test_level1.c

void test_cli_tokenize_set_with_and_without_spaces(void)
{
    char a[] = "set  gyro_hz=1000";
    char b[] = "set gyro_hz = 1000";
    char *va[CLI_MAX_ARGS], *vb[CLI_MAX_ARGS];
    TEST_ASSERT_EQUAL_INT(4, cli_tokenize(a, va, CLI_MAX_ARGS));
    TEST_ASSERT_EQUAL_INT(4, cli_tokenize(b, vb, CLI_MAX_ARGS));
    for (int i = 0; i != 4; i++) TEST_ASSERT_EQUAL_STRING(va[i], vb[i]);
    TEST_ASSERT_EQUAL_STRING("=", va[2]);
}
Plain text

Команды BeeFC

КомандаДействие
statusверсия прошивки, время работы, загрузка процессора, число опозданий задач
tasksтаблица задач: период, число запусков, наибольшее время выполнения
get имявывести значение параметра
set имя = значениеизменить значение параметра в ОЗУ
blперезагрузка во встроенный загрузчик для загрузки новой прошивки

Перезагрузка в загрузчик

Команда bl избавляет от необходимости нажимать кнопку BOOT. Прошивка записывает признак в область ОЗУ, которую код запуска не обнуляет, и выполняет программный сброс. После сброса код запуска находит признак, стирает его и передаёт управление загрузчику по адресу 0x1FFF0000: устанавливает указатель стека из первого слова загрузчика и переходит по адресу из второго слова.

↑ К оглавлению

7. Практика

Задание S7.1. Кольцевой буфер

Продолжительность — 30 минут, выполняется на компьютере.

Реализовать модуль core/ringbuf по интерфейсу из урока.

Критерий автоматической проверки: тест test_ringbuf_wraps_and_reports_overflow пройден; скрытый тест выполняет 70 000 записей, чтобы индексы перешли через ноль.

Задание S7.2. Разбор команд и таблица команд

Продолжительность — 40 минут, выполняется на компьютере.

Реализовать функцию cli_tokenize и таблицу команд из раздела «Команды BeeFC». Каждая команда — строка таблицы с именем, кратким описанием и функцией-обработчиком. Команда help выводит таблицу.

Критерий автоматической проверки: тесты test_cli_* пройдены; скрытые тесты проверяют неизвестную команду, пустую строку и строку длиннее буфера.

Задание S7.3. Консоль на плате

Продолжительность — 40 минут, требуется преобразователь USB–UART.

Подключить консоль к задаче планировщика с частотой 100 Гц. При запуске прошивка выводит строку приветствия с версией.

Критерий автоматической проверки: в модели Renode первая строка, принятая с USART1, начинается с BeeFC; на команду status приходит ответ не позднее чем через 20 мс.

Задание S7.4. Приём через прямой доступ к памяти

Продолжительность — 40 минут.

Перевести приём на контроллер DMA и добавить в планировщик задачу длительностью 2 мс. Убедиться, что консоль не теряет символы при вставке длинной строки из буфера обмена.

Критерий автоматической проверки: в модели Renode в порт передаётся 1000 байт подряд на скорости 115 200 бод, все байты приняты без потерь.

Задание S7.5. Виртуальный последовательный порт (дополнительное)

Продолжительность — по желанию слушателя.

Подключить консоль к порту USB платы через библиотеку TinyUSB, чтобы работать без преобразователя USB–UART. Задание не влияет на зачёт уровня.

↑ К оглавлению

8. Как это сделано в больших прошивках

  • В Betaflight общий код последовательных портов находится в serial_uart.c, а работа с регистрами и потоками DMA для F4 — в serial_uart_stm32f4xx.c. Консоль описана таблицей cmdTable[] в файле cli.c, команда bl в ней перезагружает плату в загрузчик.
  • В INAV устройство драйвера serial_uart.c такое же, как в Betaflight.
  • Драйвер UARTDriver.cpp в ArduPilot принимает данные через DMA в два промежуточных буфера rx_bounce_buf, память для которых выделяется с признаком MEM_DMA_SAFE, то есть заведомо не в CCM. Пока DMA заполняет один буфер, содержимое другого переносится в основной кольцевой буфер.
  • В PX4 последовательные порты обслуживает драйвер операционной системы NuttX, а класс Serial из Serial.cpp и его реализация SerialImpl.cpp дают модулям прошивки одинаковый интерфейс на любой платформе.
↑ К оглавлению

9. Проверьте себя

  1. Сколько времени занимает передача одного байта в формате 8N1 на скорости 115 200 бод?
  2. Почему кольцевой буфер с одним производителем и одним потребителем работает без запрета прерываний?
  3. Зачем в функции rb_push нужен барьер компилятора?
  4. Как определить позицию записи DMA в кольцевом режиме?
  5. Почему буфер приёма DMA нельзя размещать в CCM?
↑ К оглавлению

10. Чек-лист самопроверки

Результат: Все пункты выполнены. Можно переходить к уроку S8.
↑ К оглавлению

11. Источники

  1. Справочное руководство RM0090, раздел 10, контроллер DMA, и раздел 30, USART.
  2. Указания по применению AN4031: контроллер DMA в STM32F2, STM32F4 и STM32F7.
  3. Указания по применению AN2606: встроенный загрузчик STM32.
  4. Консоль Betaflight.
  5. Библиотека TinyUSB.
↑ К оглавлению

12. Что дальше

  • Следующий урок: S8. Таймеры и широтно-импульсная модуляция: первый запуск двигателя.
  • Полный перечень уроков приведён в статье «Курс BeeFC: содержание уровня „Старт“».

Проверено: 13.10.2026

↑ К оглавлению
  • мод. : Четверг, 15 октября 2026