FreeRTOS: работа двух задач с UART и защита общего ресурса с помощью Mutex

В этом уроке мы разберём одну из типичных проблем при работе с FreeRTOS: две независимые задачи пытаются использовать один и тот же UART. Сначала специально запустим обе задачи без какой-либо защиты и посмотрим, как их данные начинают перемешиваться. После этого добавим Mutex — «светофор», который разрешит доступ к UART только одной задаче.

В проекте созданы две задачи — Uart1 и Uart2. Обе они используют один и тот же физический интерфейс USART2.

1. Работа двух задач без Mutex

Первая задача будет постоянно передавать строку:

UART1 start and work_UART1 start and work_UART1 start and work

Вторая задача будет передавать:

UART2 start and work_UART2 start and work_UART2 start and work

Для того чтобы хорошо увидеть переключение между задачами, данные будем передавать не всей строкой сразу, а по одному символу. После передачи каждого символа вызывается функция osThreadYield().

Первая задача

void Uart1(void *argument)
{
  const char msg[] =
      "UART1 start and work_UART1 start and work_UART1 start and work\r\n";

  for (;;)
  {
    for (uint32_t i = 0; i < sizeof(msg) - 1; i++)
    {
      HAL_UART_Transmit(&huart2,
                        (uint8_t *)&msg[i],
                        1,
                        HAL_MAX_DELAY);

      osThreadYield();
    }
  }
}

Вторая задача

void Uart2(void *argument)
{
  const char msg[] =
      "UART2 start and work_UART2 start and work_UART2 start and work\r\n";

  for (;;)
  {
    for (uint32_t i = 0; i < sizeof(msg) - 1; i++)
    {
      HAL_UART_Transmit(&huart2,
                        (uint8_t *)&msg[i],
                        1,
                        HAL_MAX_DELAY);

      osThreadYield();
    }
  }
}

2. Что происходит без защиты

Предположим, первой начала выполняться задача Uart1. Она передала один символ и выполнила osThreadYield().

Планировщик FreeRTOS может передать управление задаче Uart2. Она также передаст один символ в тот же UART и снова уступит процессор.

Uart1 → передала один символ
             ↓
       osThreadYield()
             ↓
Uart2 → передала один символ
             ↓
       osThreadYield()
             ↓
Uart1 → передала следующий символ
             ↓
            ...

Таким образом, одна задача может начать вывод своей строки, затем управление будет передано второй задаче и она начнёт передавать свои данные в тот же UART.

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

В терминале вместо двух нормальных сообщений можно получить, например:

UUAARRTT12  ssttaarrtt  aanndd  wwoorrkk...

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

3. Почему возникает проблема

В данном примере UART является общим ресурсом. Обе задачи имеют доступ к одному и тому же периферийному модулю, но никак не согласовывают между собой момент начала передачи.

FreeRTOS не знает, что USART2 должен использоваться только одной задачей в определённый момент времени. Для операционной системы это просто две независимые задачи.

Поэтому защиту общего ресурса должен организовать программист.

Такая проблема может возникать не только при работе с UART. Общим ресурсом может быть:

  • UART;
  • SPI;
  • I2C;
  • SD-карта;
  • дисплей;
  • общий буфер данных;
  • общий участок памяти;
  • другая периферия.

4. Добавляем Mutex — «светофор»

Для решения этой проблемы будем использовать Mutex. В нашем проекте его дескриптор называется nextHandle.

Работу Mutex удобно представить как обычный светофор. Если ресурс свободен, задача получает разрешение работать с ним. Если ресурс уже занят другой задачей, новая задача должна ждать.

Алгоритм получается следующим:

  1. Задача хочет начать передачу через UART.
  2. Она захватывает Mutex.
  3. Передаёт всю свою строку.
  4. Освобождает Mutex.
  5. После этого UART может получить другая задача.
Uart1 → получает Mutex → передаёт всю строку → освобождает Mutex
                             ↑
                             │
                         UART занят

Uart2 → ждёт освобождения Mutex

Важно понимать, что Mutex не запрещает FreeRTOS переключать задачи. Планировщик по-прежнему может передать процессор другой задаче.

Но если вторая задача попытается получить тот же Mutex, она не сможет продолжить работу с UART до тех пор, пока первая задача его не освободит.

5. Первая задача с Mutex

void Uart1(void *argument)
{
  const char msg[] =
      "UART1 start and work_UART1 start and work_UART1 start and work\r\n";

  for (;;)
  {
    osMutexAcquire(nextHandle, osWaitForever);

    for (uint32_t i = 0; i < sizeof(msg) - 1; i++)
    {
      HAL_UART_Transmit(&huart2,
                        (uint8_t *)&msg[i],
                        1,
                        HAL_MAX_DELAY);

      osThreadYield();
    }

    osMutexRelease(nextHandle);

    osThreadYield();
  }
}

6. Вторая задача с Mutex

void Uart2(void *argument)
{
  const char msg[] =
      "UART2 start and work_UART2 start and work_UART2 start and work\r\n";

  for (;;)
  {
    osMutexAcquire(nextHandle, osWaitForever);

    for (uint32_t i = 0; i < sizeof(msg) - 1; i++)
    {
      HAL_UART_Transmit(&huart2,
                        (uint8_t *)&msg[i],
                        1,
                        HAL_MAX_DELAY);

      osThreadYield();
    }

    osMutexRelease(nextHandle);

    osThreadYield();
  }
}

7. Разбираем osMutexAcquire()

osMutexAcquire(nextHandle, osWaitForever);

Функция osMutexAcquire() пытается захватить Mutex.

Первый параметр:

nextHandle

Это дескриптор нашего Mutex.

Второй параметр:

osWaitForever

означает, что если Mutex уже занят другой задачей, текущая задача будет ждать его освобождения без ограничения по времени.

Если Mutex свободен, задача сразу его получает и продолжает работу.

8. Разбираем передачу данных через UART

HAL_UART_Transmit(&huart2,
                  (uint8_t *)&msg[i],
                  1,
                  HAL_MAX_DELAY);

Первый параметр:

&huart2

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

Следующий параметр:

(uint8_t *)&msg[i]

указывает адрес текущего символа в строке.

Значение:

1

означает, что за один вызов функции передаётся один байт.

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

9. Что делает osThreadYield()

osThreadYield();

Эта команда не создаёт задержку.

Текущая задача добровольно сообщает планировщику FreeRTOS, что готова уступить процессор другой готовой задаче с таким же приоритетом.

Без Mutex после передачи одного символа задача выполняет osThreadYield(), и управление может сразу перейти второй задаче.

Поэтому без защиты символы двух сообщений могут перемешиваться.

После добавления Mutex переключение задач всё ещё возможно, но вторая задача не сможет начать работу с UART, пока первая задача удерживает Mutex.

10. Что делает osMutexRelease()

osMutexRelease(nextHandle);

После того как задача закончила передачу всей строки, она освобождает Mutex.

Теперь Mutex может получить другая задача и начать свою работу с UART.

Очень важно: osMutexRelease() должен находиться после цикла передачи всей строки. Если освобождать Mutex после каждого символа, то защита всей операции пропадёт.

11. Работа программы после добавления Mutex

Теперь последовательность работы выглядит следующим образом:

1. Uart1 получает Mutex

2. Uart1 полностью передаёт:
   UART1 start and work_UART1 start and work_UART1 start and work

3. Uart1 освобождает Mutex

4. Uart2 получает Mutex

5. Uart2 полностью передаёт:
   UART2 start and work_UART2 start and work_UART2 start and work

6. Uart2 освобождает Mutex
Теперь одна строка не может вклиниться внутрь другой. Пока одна задача работает с UART, вторая задача ждёт освобождения Mutex.

12. Итог

В этом уроке мы увидели, к чему может привести одновременная работа нескольких задач FreeRTOS с одним общим ресурсом.

Без Mutex обе задачи независимо передавали данные через один UART, в результате чего символы могли перемешиваться.

После добавления Mutex одна задача получает исключительный доступ к UART на время выполнения всей операции. Вторая задача может продолжить работу с этим ресурсом только после его освобождения.

Без Mutex:

Task 1 ─────┐
            ├────→ UART
Task 2 ─────┘


С Mutex:

Task 1 ───→ [ Mutex ] ───→ UART
               ↑
               │
Task 2 ───── ждёт

В реальной программе строку обычно передают целиком, а osThreadYield() после каждого символа не используют. В данном примере это сделано специально, чтобы наглядно показать проблему совместного доступа к одному ресурсу и принцип работы Mutex.