Всем привет! Относительно недавно, закончив ВУЗ, я попал в небольшую компанию, которая занималась разработкой электроники. Одна из первых задач с которой я столкнулся — необходимость в реализации Modbus RTU Slave протокола с использованием STM32. С грехом пополам я её тогда написал, однако этот протокол начал встречаться мне из проекта в проект и я решил зарефакторить и оптимизировать либу с использованием FreeRTOS.

Введение


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

  • Прием/отправку с использованием DMA
  • Возмоность аппаратного расчета CRC
  • Возможность аппаратной поддержки RS485
  • Определение конца посылки через аппаратные возможности USART, без использования таймера

Сразу оговорюсь, тут я не описываю спецификацию протокла Modbus и как с ним работает мастер, об этом можно почитать тут и тут.

Файл конфигурации


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

ModbusRTU_conf.h
#ifndef MODBUSRTU_CONF_H_INCLUDED
#define MODBUSRTU_CONF_H_INCLUDED
#include "stm32f30x.h"

extern uint32_t SystemCoreClock;

/*Registers number in Modbus RTU address space*/
#define MB_REGS_NUM             4096
/*Slave address*/
#define MB_SLAVE_ADDRESS        0x01

/*Hardware defines*/
#define MB_USART_BAUDRATE       115200
#define MB_USART_RCC_HZ         64000000

#define MB_USART                USART1
#define MB_USART_RCC            RCC->APB2ENR
#define MB_USART_RCC_BIT        RCC_APB2ENR_USART1EN
#define MB_USART_IRQn           USART1_IRQn
#define MB_USART_IRQ_HANDLER    USART1_IRQHandler

#define MB_USART_RX_RCC         RCC->AHBENR
#define MB_USART_RX_RCC_BIT     RCC_AHBENR_GPIOAEN
#define MB_USART_RX_PORT        GPIOA
#define MB_USART_RX_PIN         10
#define MB_USART_RX_ALT_NUM     7

#define MB_USART_TX_RCC         RCC->AHBENR
#define MB_USART_TX_RCC_BIT     RCC_AHBENR_GPIOAEN
#define MB_USART_TX_PORT        GPIOA
#define MB_USART_TX_PIN         9
#define MB_USART_TX_ALT_NUM     7

#define MB_DMA                  DMA1
#define MB_DMA_RCC              RCC->AHBENR
#define MB_DMA_RCC_BIT          RCC_AHBENR_DMA1EN

#define MB_DMA_RX_CH_NUM        5
#define MB_DMA_RX_CH            DMA1_Channel5
#define MB_DMA_RX_IRQn          DMA1_Channel5_IRQn
#define MB_DMA_RX_IRQ_HANDLER   DMA1_Channel5_IRQHandler

#define MB_DMA_TX_CH_NUM        4
#define MB_DMA_TX_CH            DMA1_Channel4
#define MB_DMA_TX_IRQn          DMA1_Channel4_IRQn
#define MB_DMA_TX_IRQ_HANDLER   DMA1_Channel4_IRQHandler

/*Hardware RS485 support
1 - enabled
other - disabled 
*/  
#define MB_RS485_SUPPORT        0
#if(MB_RS485_SUPPORT == 1)
#define MB_USART_DE_RCC         RCC->AHBENR
#define MB_USART_DE_RCC_BIT     RCC_AHBENR_GPIOAEN
#define MB_USART_DE_PORT        GPIOA
#define MB_USART_DE_PIN         12
#define MB_USART_DE_ALT_NUM     7
#endif

/*Hardware CRC enable
1 - enabled
other - disabled 
*/  
#define MB_HARDWARE_CRC     1

#endif /* MODBUSRTU_CONF_H_INCLUDED */


Наиболее часто, на мой взгляд, меняются следующие вещи:

  • Адрес устройства и размер адресного простарнства
  • Частота тактирования и параметры пинов USART(pin, port, rcc, irq)
  • Параметры каналов DMA(rcc, irq)
  • Включение/отключение аппаратного CRC и RS485

Конфигурация железа


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

Начнем с настройки USART:

USART configure
    /*Configure USART*/
    /*CR1:
    -Transmitter/Receiver enable;
    -Receive timeout interrupt enable*/
    MB_USART->CR1 = 0;
    MB_USART->CR1 |= (USART_CR1_TE | USART_CR1_RE | USART_CR1_RTOIE);
    /*CR2:
    -Receive timeout - enable
    */
    MB_USART->CR2 = 0;

    /*CR3:
    -DMA receive enable
    -DMA transmit enable
    */
    MB_USART->CR3 = 0;
    MB_USART->CR3 |= (USART_CR3_DMAR | USART_CR3_DMAT);

#if (MB_RS485_SUPPORT == 1)
    /*Cnfigure RS485*/
     MB_USART->CR1 |= USART_CR1_DEAT | USART_CR1_DEDT;
     MB_USART->CR3 |= USART_CR3_DEM;
#endif

     /*Set Receive timeout*/
     //If baudrate is grater than 19200 - timeout is 1.75 ms
    if(MB_USART_BAUDRATE >= 19200)
        MB_USART->RTOR = 0.00175 * MB_USART_BAUDRATE + 1;
    else
        MB_USART->RTOR = 35;
    /*Set USART baudrate*/
     /*Set USART baudrate*/
    uint16_t baudrate = MB_USART_RCC_HZ / MB_USART_BAUDRATE;
    MB_USART->BRR = baudrate;

    /*Enable interrupt vector for USART1*/
    NVIC_SetPriority(MB_USART_IRQn, configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY);
    NVIC_EnableIRQ(MB_USART_IRQn);

    /*Enable USART*/
    MB_USART->CR1 |= USART_CR1_UE;


Тут есть несколько моментов:

  1. В семействе F3, как и во многих других например F0, присутствует функция настраиваемого таймаута при тишине на линии, данный таймер отсчитывает от последнего принятого стоп-бита и обнуляется если был принят следующий фрейм. Прерывание по таймауту мы и будем использовать для определения конца посылки. Кстати, в F1 серии такой функции не было, поэтому приходилось использовать аппаратный таймер. Включаются прерывания битом USART_CR1_RTOIE в регистре СR1. Важно отметить, что не все USART на борту могут иметь эту функцию, так что внимательней читайте RM!
  2. Таймаут настраивается через регистр RTOR. В него заносится значение таймаута в битах, то есть длина 3.5 символа, которая означает конец посылки соответствует значению 35 (1 символ — 8 бит + 1 старт бит + 1 стоп бит). Для скоростей больше 19200 бод/с позволяется использовать интервал 1.75 мс, который тоже можно выразить в длинах символов:
    MB_USART->RTOR = 0.00175 * MB_USART_BAUDRATE + 1;
  3. Мы будем использовать прерывание по таймауту для определения конца посылки и пробуждения таска OC, поэтому приоритет прерывания нужно указывать минимум как configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY или выше, так как в этом прерывании используется функция FreeRTOS типа FromISR и если указать приоритет выше, могут случиться нехорошие вещи вплоть до полной блокировки таска. Этот дефайн обычно определен в файле FreeRTOS_Config.h, почитать можно тут
  4. RS485 настраивается двумя битфилдами: USART_CR1_DEAT и USART_CR1_DEDT. Эти битфилды подволяют задать время снятия и установки сигнала DE до и после отправки в размерностях 1/16 или 1/8 бита в зависимости от параметра oversampling модуля USART. Остается только включить функцию в регистре CR3 битом USART_CR3_DEM, обо всем остальном позаботится железо.

Настройка DMA:

Настройка DMA
    /*Configure DMA Rx/Tx channels*/
    //Rx channel
    //Max priority
    //Memory increment
    //Transfer complete interrupt
    //Transfer error interrupt
    MB_DMA_RX_CH->CCR = 0;
    MB_DMA_RX_CH->CCR |= (DMA_CCR_PL | DMA_CCR_MINC | DMA_CCR_TCIE | DMA_CCR_TEIE);
    MB_DMA_RX_CH->CPAR = (uint32_t)&MB_USART->RDR;
    MB_DMA_RX_CH->CMAR = (uint32_t)MB_Frame;

    /*Set highest priority to Rx DMA*/
    NVIC_SetPriority(MB_DMA_RX_IRQn, 0);
    NVIC_EnableIRQ(MB_DMA_RX_IRQn);

    //Tx channel
    //Max priority
    //Memory increment
    //Transfer complete interrupt
    //Transfer error interrupt
    MB_DMA_TX_CH->CCR = 0;
    MB_DMA_TX_CH->CCR |= (DMA_CCR_PL | DMA_CCR_MINC | DMA_CCR_DIR | DMA_CCR_TCIE | DMA_CCR_TEIE);
    MB_DMA_TX_CH->CPAR = (uint32_t)&MB_USART->TDR;
    MB_DMA_TX_CH->CMAR = (uint32_t)MB_Frame;

     /*Set highest priority to Tx DMA*/
    NVIC_SetPriority(MB_DMA_TX_IRQn, 0);
    NVIC_EnableIRQ(MB_DMA_TX_IRQn);


Так как Modbus работает в режиме запрос-ответ, мы используем один буфер, как для приема так и для передачи. В буфер получили, там же обработали из него же отправили. Во время обработки входные данные не принимаются. Rx канал DMA кладет данные из регистра приема USART (RDR) в буфер, Tx канал DMA наоборот из буфера в регистр отправки(TDR). Прерывание Tx канала нам нужно, чтобы определить, что ответ ушел и можно переключиться в режим приема.

Прерывание Rx канала по сути не нужно, ведь мы предполагаем, что посылка Modbus не может быть больше 256 байт, но, что если на линии шум и кто-то беспорядочно шлет байты? Для этого я сделал буфер размером 257 байт, и если прерывание от Rx DMA случится, значит, кто-то «мусорит» в линию, а мы перекидываем Rx канал в начало буфера и слушаем снова.

Обработчики прерываний:

Interrupt handlers
/*DMA Rx interrupt handler*/
void MB_DMA_RX_IRQ_HANDLER(void)
{
    if(MB_DMA->ISR & (DMA_ISR_TCIF1 << ((MB_DMA_RX_CH_NUM - 1) << 2)))
        MB_DMA->IFCR |= (DMA_IFCR_CTCIF1 << ((MB_DMA_RX_CH_NUM - 1) << 2));
    if(MB_DMA->ISR & (DMA_ISR_TEIF1 << ((MB_DMA_RX_CH_NUM - 1) << 2)))
        MB_DMA->IFCR |= (DMA_IFCR_CTEIF1 << ((MB_DMA_RX_CH_NUM - 1) << 2));
    /*If error happened on transfer or MB_MAX_FRAME_SIZE bytes received - start listening*/
    MB_RecieveFrame();
}

/*DMA Tx interrupt handler*/
void MB_DMA_TX_IRQ_HANDLER(void)
{
    MB_DMA_TX_CH->CCR &= ~(DMA_CCR_EN);
    if(MB_DMA->ISR & (DMA_ISR_TCIF1 << ((MB_DMA_TX_CH_NUM - 1) << 2)))
        MB_DMA->IFCR |= (DMA_IFCR_CTCIF1 << ((MB_DMA_TX_CH_NUM - 1) << 2));
    if(MB_DMA->ISR & (DMA_ISR_TEIF1 << ((MB_DMA_TX_CH_NUM - 1) << 2)))
        MB_DMA->IFCR |= (DMA_IFCR_CTEIF1 << ((MB_DMA_TX_CH_NUM - 1) << 2));
    /*If error happened on transfer or transfer completed - start listening*/
    MB_RecieveFrame();
}

/*USART interrupt handler*/
void MB_USART_IRQ_HANDLER(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    if(MB_USART->ISR & USART_ISR_RTOF)
    {
        MB_USART->ICR = 0xFFFFFFFF;
        //MB_USART->ICR |= USART_ICR_RTOCF;
        MB_USART->CR2 &= ~(USART_CR2_RTOEN);
        /*Stop DMA Rx channel and get received bytes num*/
        MB_FrameLen = MB_MAX_FRAME_SIZE - MB_DMA_RX_CH->CNDTR;
        MB_DMA_RX_CH->CCR &= ~DMA_CCR_EN;
        /*Send notification to Modbus Handler task*/
        vTaskNotifyGiveFromISR(MB_TaskHandle, &xHigherPriorityTaskWoken);
        portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
    }
}


Обработчики DMA достаточно простые: все отправил — почисти флаги, переходи в режим приема, принял 257 байт — ошибка фрейма, чисти влаги, переходи в режим приема снова.

Обработчик USART говорит нам, что пришло какое-то количество данных и дальше была тишина. Фрейм готов, определяем количество принятых байт (максимальное количество байт приема DMA — количество которое осталось принять), выключаем прием, будим таск.

Один нюанс, раньше для пробуждения таска я использовал бинарный семафор, однако разработчики FreeRTOS рекомендуют использовать TaskNotification:
Unblocking an RTOS task with a direct notification is 45% faster and uses less RAM than unblocking a task with a binary semaphore
Иногда в FreeRTOS_Config.h бывает не включена в сборку функция xTaskGetCurrentTaskHandle(), в таком случае нужно добавить строку в этой файл:

#define INCLUDE_xTaskGetCurrentTaskHandle 1

Без использования семафора прошивка похудела почти на 1 кБ. Мелочь конечно, но приятно.

Функции отправки и приема:

Send and Receve
/*Configure DMA to receive mode*/
void MB_RecieveFrame(void)
{
    MB_FrameLen = 0;
    //Clear timeout Flag*/
    MB_USART->CR2 |= USART_CR2_RTOEN;
    /*Disable Tx DMA channel*/
    MB_DMA_RX_CH->CCR &= ~DMA_CCR_EN;
    /*Set receive bytes num to 257*/
    MB_DMA_RX_CH->CNDTR = MB_MAX_FRAME_SIZE;
    /*Enable Rx DMA channel*/
    MB_DMA_RX_CH->CCR |= DMA_CCR_EN;
}

/*Configure DMA in tx mode*/
void MB_SendFrame(uint32_t len)
{
    /*Set number of bytes to transmit*/
    MB_DMA_TX_CH->CNDTR = len;
    /*Enable Tx DMA channel*/
    MB_DMA_TX_CH->CCR |= DMA_CCR_EN;
}

Обе функции переинициализируют каналы DMA. При приеме включается функция отслеживающая таймаут в регистре CR2 битом USART_CR2_RTOEN.

CRC


Переходим к хардварному расчету CRC. Всегда мозолила мне эта функция контроллера глаза, но всегда как-то не складывалось, в какой то серии нельзя было задать произвольный полином, в какой-то нельзя было менять размерность полинома и так далее. В F3 же все хорошо, и полином задавай и размер меняй, но приседание пришлось одно сделать:

uint16_t MB_GetCRC(uint8_t * buffer, uint32_t len)
{
    MB_CRC_Init();
    for(uint32_t i = 0; i < len; i++)
        *((__IO uint8_t *)&CRC->DR) = buffer[i];
    return CRC->DR;
}

Оказалось, что просто так побайтно закидывать в регистр DR нельзя — считать будет неправильно, надо использовать byte-access. Такие «выкрутасы» у STM я уже встречал с модулем SPI в который хочется писать побайтно.

Таск


void MB_RTU_Slave_Task(void *pvParameters)
{
    MB_TaskHandle = xTaskGetCurrentTaskHandle();
    MB_HWInit();
    while(1)
    {
        if(ulTaskNotifyTake(pdTRUE, portMAX_DELAY))
        {
            uint32_t txLen = MB_TransactionHandler(MB_GetFrame(), MB_GetFrameLen());
            if(txLen)
                MB_SendFrame(txLen);
            else
                MB_RecieveFrame();
        }
    }
}

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

/*Handle Received frame*/
static uint32_t MB_TransactionHandler(uint8_t * frame, uint32_t len)
{
    uint32_t txLen = 0;
    /*Check frame length*/
    if(len < MB_MIN_FRAME_LEN)
        return txLen;
    /*Check frame address*/
    if(!MB_CheckAddress(frame[0]))
        return txLen;
    /*Check frame CRC*/
    if(!MB_CheckCRC(*((uint16_t*)&frame[len - 2]), MB_GetCRC(frame, len - 2)))
        return txLen;
    switch(frame[1])
    {
        case MB_CMD_READ_REGS : txLen = MB_ReadRegsHandler(frame, len); break;
        case MB_CMD_WRITE_REG : txLen = MB_WriteRegHandler(frame, len); break;
        case MB_CMD_WRITE_REGS : txLen = MB_WriteRegsHandler(frame, len); break;
        default : txLen = MB_ErrorHandler(frame, len, MB_ERROR_COMMAND); break;
    }
    return txLen;
}

Сам обработчик не представляет особого интереса: проверка длины фрейма/адреса/CRC и формирование ответа или ошибки. Данная реализация поддерживает три основные функции: 0x03 — Read Registers, 0x06 — Write register, 0x10 — Write Multiple Registers. Обычно, мне достаточно этих функций, но при желании можно без проблем расширить функционал.

Ну и запуск:

int main(void)
{
    NVIC_SetPriorityGrouping(3);
    xTaskCreate(MB_RTU_Slave_Task, "MB", configMINIMAL_STACK_SIZE, NULL, tskIDLE_PRIORITY + 1, NULL);
    vTaskStartScheduler();
}

Для работы таска достаточно стека размером в 32 x uint32_t (или 128 байт) именно такой размер у меня выставлен в дефайне configMINIMAL_STACK_SIZE. Для справки: изначально я ошибочно предполагал, что configMINIMAL_STACK_SIZE задается в байтах, если не хватало добавлял еще, однако, работая с F0 контроллерами, где RAM поменьше, один раз пришлось посчитать стек и оказалось, что configMINIMAL_STACK_SIZE задается в размерностях типаportSTACK_TYPE, который определен в файле portmacro.h
#define portSTACK_TYPE    uint32_t

Заключение


Данная реализация Modbus RTU оптимально использует аппаратные возможности микроконтроллера STM32F3xx.

Вес выходной прошивки вместе с ОС и оптимизацией -o2 составил: Program size: 5492 Байта, Data size: 112 байт. На фоне 6 кБ похудение на 1 кБ от семафоров, выглядит существенно.

Перенос на другие семейства возможен, например F0 поддерживает таймаут и RS485, однако там есть проблема с аппаратным CRC, так что можно обойтись софтовым методом расчета. Также могут быть различия в обработчиках прерываний DMA, где-то они бывают совмещенными.

Ссылка на гитхаб

Возможно кому-то пригодится.

Полезные ссылки: