Всем привет! Относительно недавно, закончив ВУЗ, я попал в небольшую компанию, которая занималась разработкой электроники. Одна из первых задач с которой я столкнулся — необходимость в реализации Modbus RTU Slave протокола с использованием STM32. С грехом пополам я её тогда написал, однако этот протокол начал встречаться мне из проекта в проект и я решил написать зарефакторить и оптимизировать либу с использованием FreeRTOS.
Введение
В текущих проектах я часто использую связку STM32F3xx + FreeRTOS, поэтому решил максимально использовать аппаратные возможности данного контроллера. В частности:
- Прием/отправку с использованием DMA
- Возмоность аппаратный расчета CRC
- Возможность аппаратной поддержки RS485
- Определение конца посылки через аппаратные возможности USART, без использования таймера
Сразу оговорюсь, тут я не описываю спецификацию протокла Modbus и как с ним работает мастер, об этом можно почитать тут и тут.
Файл конфигурации
Для начала, я решил номного упростить задачу переноса кода между проектами, хотя бы в рамках одного семейства контроллеров. Поэтому я решил написать небольщой 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:
/*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;
Тут есть несколько моментов:
- В семействе F3, как и во многих других например F0, присутствует функция настраиваемого таймаута при тишине на линии, данный таймер отсчитывает от последнего принятого стоп-бита и обнуляется если был принят следующий фрейм. Прерывание по таймауту мы и будем использовать для определения конца посылки. Кстати, в F1 серии такой функции не было, поэтому приходилось использовать аппаратный таймер. Включаются прерывания битом USART_CR1_RTOIE в регистре СR1. Важно отметить, что не все USART на борту могут иметь эту функцию, так что внимательней читайте RM!
- Таймаут настраивается через регистр RTOR. В него заносится значение таймаута в битах, то есть длина 3.5 символа, которая означает конец посылки соответствует значению 35 (1 символ — 8 бит + 1 старт бит + 1 стоп бит). Для скоростей больше 19200 бод/с позволяется использовать интервал 1.75 мс, который тоже можно выразить в длинах символов:
MB_USART->RTOR = 0.00175 * MB_USART_BAUDRATE + 1;
- Мы будем использовать прерывание по таймауту для определения конца посылки и пробуждения таска OC, поэтому приоритет прерывания нужно указывать минимум как configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY или выше, так как в этом прерывании используется функция FreeRTOS типа FromISR и если указать приоритет выше, могут случиться нехорошие вещи вплоть до полной блокировки таска. Этот дефайн обычно определен в файле FreeRTOS_Config.h, почитать можно тут
- RS485 настраивается двумя битфилдами: USART_CR1_DEAT и USART_CR1_DEDT. Эти битфилды подволяют задать время снятия и установки сигнала DE до и после отправки в размерностях 1/16 или 1/8 бита в зависимости от параметра oversampling модуля USART. Остается только включить функцию в регистре CR3 битом USART_CR3_DEM, обо всем остальном позаботится железо.
Натсройка 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 канал в начало буфера и слушаем снова.
Обработчики прерываний:
/*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 кБ. Мелочь конечно, но приятно.
Функции отправки и приема:
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, где-то они бывают совмещенными.
Возможно кому-то пригодится.
Полезные ссылки:
Автор: Artyom Styazhkin