STM32串口控制收发模式总结
前言
公司的很多项目都是使用了串口通信,在我刚毕业来公司的时候,我的固件组同事都还是在使用轮询发送,串口中断接受的方式来进行串口收发,非常的低效。后面自己写项目程序,我开始查找一些串口收发的资料,实现了几种感觉比较好的控制方法,特此总结防止自己失忆。
开发环境
系统Ubuntu20.04lts;开发工具vscode + makefile + cortex-debug + openocd;固件库使用HAL库;
串口收发模式
发送
1.轮询发送
这没啥好说的,虽然方法很简单但确实很稳(我同事用这个方法很多年了),一般的操作系统在环境未准备之前似乎也是用这个方法打印日志的,目前我的固件在开机初始化的那段时间也是使用这个方法打印日志。HAL库已经实现这个函数 HAL_UART_Transmit。
2.中断发送
一般操作是定义一段缓存区,开启串口的发送完成中断,发送数据时将要发送的数据拷到缓存区,串口忙标志置位,然后发送一个字节,触发发送中断后,在中断判断是否继续发送,是的话在发送一个字节,直至发送完成清除串口忙标志。 相比轮询占用cpu更少,毕竟轮询是干等在白占资源,不过中断发送属于异步操作,实现起来稍微麻烦一点,不过HAL也已经实现了该函数 HAL_UART_Transmit_IT。
3.dma发送
dma好处不必多说,操作方法也很简单,定义一段缓存,开启串口的dma发送完成中断,发送数据时将要发送的数据拷到缓存区,串口忙标志置位,开启dma发送,触发中断后清除标志。 dma发送如果有os支持的话,配合信号量进行同步发送还是蛮香的,与中断发送相比,占用cpu更少,毕竟只要中断一下,而且还不怕被打断,是串口发送常用的方法。HAL实现了该函数 HAL_UART_Transmit_DMA。
4.dma发送+环形缓存区
在没有os支持的情况下,基本上是用dma进行异步发送,在一般场合下也足够用了,但是在间歇性发送大量数据特别是打印日志的时候效果就很差了,这时候就需要环形缓存区了(当然消息队列也可以,rtthread中这两种都有)。 环形缓存区是典型的生产者消费者模型,对于解决上述问题非常有效,下面是一个比较简单的环形缓存区代码。
#include <stdint.h>
#include <string.h>
struct ringbuf_handle
{
uint32_t r_pos : 31;
uint32_t r_mark : 1;
uint32_t w_pos : 31;
uint32_t w_mark : 1;
uint8_t *buf;
size_t size;
};
int ringbuf_init(struct ringbuf_handle *handle, uint8_t *buf, size_t size)
{
if (handle == NULL || buf == NULL || size > 0x7fffffff)
return -1;
handle->r_pos = 0;
handle->r_mark = 0;
handle->w_pos = 0;
handle->w_mark = 0;
handle->buf = buf;
handle->size = size;
return 0;
}
size_t ringbuf_data_size(struct ringbuf_handle *handle)
{
if (handle == NULL)
return 0;
return handle->w_mark == handle->r_mark ? handle->w_pos - handle->r_pos
: handle->size + handle->w_pos - handle->r_pos;
}
size_t ringbuf_space_size(struct ringbuf_handle *handle)
{
if (handle == NULL)
return 0;
return handle->w_mark == handle->r_mark ? handle->size - handle->w_pos + handle->r_pos
: handle->r_pos - handle->w_pos;
}
size_t ringbuf_write(struct ringbuf_handle *handle, const uint8_t *buf, size_t size)
{
size_t space_size;
if (handle == NULL || buf == NULL || size == 0)
return 0;
space_size = ringbuf_space_size(handle);
size = size < space_size ? size : space_size;
if (size + handle->w_pos < handle->size)
memcpy(&handle->buf[handle->w_pos], buf, size);
else
{
memcpy(&handle->buf[handle->w_pos], buf, handle->size - handle->w_pos);
memcpy(handle->buf, &buf[handle->size - handle->w_pos], size - handle->size + handle->w_pos);
handle->w_mark++;
}
handle->w_pos = (handle->w_pos + size) % handle->size;
return size;
}
size_t ringbuf_read(struct ringbuf_handle *handle, uint8_t *buf, size_t size)
{
size_t data_size;
if (handle == NULL || buf == NULL || size == 0)
return 0;
data_size = ringbuf_data_size(handle);
size = size < data_size ? size : data_size;
if (size + handle->r_pos < handle->size)
memcpy(buf, &handle->buf[handle->r_pos], size);
else
{
memcpy(&buf[0], &handle->buf[handle->r_pos], handle->size - handle->r_pos);
memcpy(&buf[handle->size - handle->r_pos], handle->buf, size - handle->size + handle->r_pos);
handle->r_mark++;
}
handle->r_pos = (handle->r_pos + size) % handle->size;
return size;
}
有了环形缓存区以后,就可以通过dma发送中断进行异步发送,大致操作是定义一个环形环形缓存区,开启dma发送完成中断,发送数据时,先往环形缓存区写数据,再判断dma是否在发送,若是,则返回,若否,则从环形缓存区读出部分数据,然后开启dma发送该部分数据,而在dma中断就只需要判断环形缓存区是否有数据,若有,则读出部分数据再开启dma,然后在下次中断继续重复这个操作,这样通过不停的接力就将数据全部发送出去。以下是演示代码。
static struct ringbuf_handle ringbuf;
static uint8_t buf[1024];
static void _uart_dma_output(UART_HandleTypeDef *huart)
{
static uint8_t dma_buf[128];
size_t size;
__disable_irq();
size = ringbuf_read(&ringbuf, dma_buf, sizeof(dma_buf));
__enable_irq();
if (size)
HAL_UART_Transmit_DMA(&huart1, dma_buf, size);
}
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
_uart_dma_output(huart);
}
int uart_output(const char *str)
{
size_t size = strlen(str) + 1;
__disable_irq();
if (size > ringbuf_space_size(&ringbuf))
{
__enable_irq();
return -1;
}
ringbuf_write(&ringbuf, (const uint8_t *)str, size);
__enable_irq();
if (huart1.gState == HAL_UART_STATE_READY)
_uart_dma_output(&huart1);
return 0;
}
void test1(void)
{
static const char *str_test1 = {"This is a uart test\r\n"};
static const char *str_test2 = {"\
123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789\r\n \
123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789\r\n \
123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789\r\n \
123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789\r\n \
123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789\r\n"};
static uint32_t last_tick;
uint32_t tick = HAL_GetTick();
if (last_tick != tick)
{
last_tick = tick;
if (tick % 100 == 0)
{
char str[30];
sprintf(str, "Current tick:%d\r\n", (int)tick);
uart_output(str);
}
if (tick % 1000 == 0)
{
uart_output(str_test1);
uart_output(str_test2);
}
}
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
ringbuf_init(&ringbuf, buf, sizeof(buf));
while (1)
{
test1();
}
}
以上测试程序为每隔100ms打印系统时间,每隔1000ms打印大量数据,实际仿真可以发现除去数据拷贝的时间,发送数据几乎不怎么占用cpu,而且使用也比较方便,即使是裸机的情况下,也能很容易的打印日志。以下为minicom接受的数据。
5.dma发送+环形缓存区(优化)
在上面的例程中,在每次开启dma之前,都需要重新读出数据,也就是说数据从串口发送出去需要拷贝两次,但实际上第二次可以省去,修改_uart_dma_output,直接用dma将ringbuf中的数据发送出去,程序如下。
static void _uart_dma_output(UART_HandleTypeDef *huart)
{
static size_t last_size = 0;
size_t data_size;
size_t size;
uint8_t *buf;
__disable_irq();
if(ringbuf.r_pos + last_size >= ringbuf.size)
ringbuf.r_mark++;
ringbuf.r_pos = (ringbuf.r_pos + last_size) % ringbuf.size;
data_size = ringbuf_data_size(&ringbuf);
buf = &ringbuf.buf[ringbuf.r_pos];
if (data_size + ringbuf.r_pos < ringbuf.size)
size = data_size;
else
size = ringbuf.size - ringbuf.r_pos;
last_size = size;
__enable_irq();
if (size)
HAL_UART_Transmit_DMA(&huart1, buf, size);
}
虽然这样略微提高了发送的效率,但是也相应的增加了模块间的耦合,还把ringbuf模块中的细节给暴露出来,所以没什么必要。
接收
1.轮询接收
使用场合较少,在裸机的情况下用的更少,HAL库已经实现这个函数 HAL_UART_Receive。
2.中断接收
以前自己做比赛进行自定义协议通信的一套惯用方法,即中断每接收一个字节,就进行一次协议解析,随后执行相应的命令,这应该是很多人用的方法,简单而且好用,但是缺点也很明显,中断讲究快进快出,如果执行一些耗时的事情不仅降低实时性,还容易丢命令,特别是对方连发命令的时候(我自己就经常这么干),HAL库已经实现这个函数HAL_UART_Receive_IT,但稍稍不一样,HAL库是接收到用户指定长度的字节数,才会中断(准确来说不是中断,是接收完成的回调),如果我们想要每接收一个字节就中断一次,需要在程序开始时执行一次HAL_UART_Receive_IT(&huart1, buf, 1);随后在中断再调用一次以发起下次的接收中断。
3.中断接收+环形缓存区
这应该是最普遍的做法了吧,完美解决了上面方法的问题。配合之前的dma发送,这里写一个简单回响程序(即将接收到的数据原模原样发回去)。
static struct ringbuf_handle tx_ringbuf;
static uint8_t tx_buf[1024];
static struct ringbuf_handle rx_ringbuf;
static uint8_t rx_buf[1024];
static uint8_t rx_data;
static void _uart_dma_output(UART_HandleTypeDef *huart)
{
static uint8_t dma_buf[128];
size_t size;
__disable_irq();
size = ringbuf_read(&tx_ringbuf, dma_buf, sizeof(dma_buf));
__enable_irq();
if (size)
HAL_UART_Transmit_DMA(&huart1, dma_buf, size);
}
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
_uart_dma_output(huart);
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
__disable_irq();
ringbuf_write(&rx_ringbuf, &rx_data, 1);
__enable_irq();
HAL_UART_Receive_IT(&huart1, &rx_data, 1);
}
size_t uart_output(uint8_t *buf, size_t size)
{
__disable_irq();
ringbuf_write(&tx_ringbuf, buf, size);
__enable_irq();
if (huart1.gState == HAL_UART_STATE_READY)
_uart_dma_output(&huart1);
return 0;
}
int main(void)
{
uint8_t buf[128];
size_t size;
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
ringbuf_init(&tx_ringbuf, tx_buf, sizeof(tx_buf));
ringbuf_init(&rx_ringbuf, rx_buf, sizeof(rx_buf));
HAL_UART_Receive_IT(&huart1, &rx_data, 1);
while (1)
{
__disable_irq();
size = ringbuf_read(&rx_ringbuf, buf, sizeof(buf));
__enable_irq();
uart_output(buf, size);
HAL_Delay(10);
}
}
由于没有os,不能使用互斥锁,所以程序有很多开关全局中断的地方,主要原因还是ringbuf写的还有瑕疵,不能完美做到一对一的无锁读写,这里用minicom测试一下效果,在终端输入“This is a echo demo”,可以看到返回了一模一样的数据。
4.dma接收
明明用到了dma,但这种方法却用的很少,主要原因是dma只有接收到了指定长度的数据才会中断,也就是说如果接收的数据是不定长的,就用不了。对应的HAL库函数HAL_UART_Receive_DMA。
5.dma接收+idle中断
idle中断,简单来说,就是当串口接收到了数据,内部就会开始计时,若在规定时间内没有再接收到数据,就会触发中断,也就是说通过这个中断,我们可以得知对方发送的数据是否结束,是个很牛逼的功能。再搭配上面的dma,在我们开启dma接收以后,我们只需要在idle中断读取接收到的数据和dma接收的长度,然后发起下次的dma接收,我们就可以接收不定长的数据了,当然HAL也实现了这个函数HAL_UARTEx_ReceiveToIdle_DMA,对应的接收回调函数为HAL_UARTEx_RxEventCallback,这里使用idle再来编写回响程序。
static struct ringbuf_handle tx_ringbuf;
static uint8_t tx_buf[1024];
static uint8_t rx_buf[1024];
static void _uart_dma_output(UART_HandleTypeDef *huart)
{
static uint8_t dma_buf[128];
size_t size;
__disable_irq();
size = ringbuf_read(&tx_ringbuf, dma_buf, sizeof(dma_buf));
__enable_irq();
if (size)
HAL_UART_Transmit_DMA(&huart1, dma_buf, size);
}
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
_uart_dma_output(huart);
}
size_t uart_output(uint8_t *buf, size_t size)
{
__disable_irq();
ringbuf_write(&tx_ringbuf, buf, size);
__enable_irq();
if (huart1.gState == HAL_UART_STATE_READY)
_uart_dma_output(&huart1);
return 0;
}
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
uart_output(rx_buf, Size);
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf));
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
ringbuf_init(&tx_ringbuf, tx_buf, sizeof(tx_buf));
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf));
while (1)
{
}
}
看起来似乎这是使用DMA进行串口接收的最好方式了,然后即使这样的方式仍然存在着问题,首先我们必须知道对方发送的数据的最大长度,否则就会缓存溢出,其次当对方接连发送多包数据的时候,由于每次接收回调都需要拷贝接收数据才能发起下次dma接收,这可能会导致下一包数据的丢失,当然我们也可以定义多个接收缓存区进行乒乓操作,即每次接收回调就切换成另一个缓存区进行接收,但是由于每个缓存的大小都必须大于对方发送数据的最大长度,因此会占用大量的内存。其实还有另一种方法解决上述问题,只不过实现起来比较麻烦,我们接着看第6种接收方法。
6.circle模式dma+idle中断
此方法是我上班摸鱼的时候偶然想到的,实现了一下发现还真的可以。经常用dma的应该知道,dma可以被配置为正常(normal)模式和循环(circle)模式,正常模式下dma在传输了指定长度的数据后就会停止,而循环模式下dma在传输了指定长度的数据并不会停止,其传输的地址将会复位,也就是会重新从头开始传输,并覆盖之前的数据,这个特性似乎满足环形缓存区中指针绕回的特性,那么是否可以将其作为一个ringbuf来使用呢。下面写一个程序测试一下,首先修改stm32f4xx_hal_msp.c文件,将dma模式修改成circle模式。 接下来仍然是修改回响程序,代码如下。
static struct ringbuf_handle tx_ringbuf;
static uint8_t tx_buf[1024];
static uint8_t rx_buf[1024];
static uint32_t read_index;
static void _uart_dma_output(UART_HandleTypeDef *huart)
{
static uint8_t dma_buf[128];
size_t size;
__disable_irq();
size = ringbuf_read(&tx_ringbuf, dma_buf, sizeof(dma_buf));
__enable_irq();
if (size)
HAL_UART_Transmit_DMA(&huart1, dma_buf, size);
}
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
_uart_dma_output(huart);
}
size_t uart_output(uint8_t *buf, size_t size)
{
__disable_irq();
ringbuf_write(&tx_ringbuf, buf, size);
__enable_irq();
if (huart1.gState == HAL_UART_STATE_READY)
_uart_dma_output(&huart1);
return 0;
}
size_t dma_ringbuf_data_len(void)
{
uint32_t write_index = sizeof(rx_buf) - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
return (write_index >= read_index) ? (write_index - read_index) : (write_index + sizeof(rx_buf) - read_index);
}
size_t dma_ringbuf_read(uint8_t *buf, size_t size)
{
size_t data_size;
data_size = dma_ringbuf_data_len();
size = size < data_size ? size : data_size;
if (size + read_index < sizeof(rx_buf))
memcpy(buf, &rx_buf[read_index], size);
else
{
memcpy(buf, &rx_buf[read_index], sizeof(rx_buf) - read_index);
memcpy(&buf[sizeof(rx_buf) - read_index], rx_buf, size - sizeof(rx_buf) + read_index);
}
read_index = (read_index + size) % sizeof(rx_buf);
return size;
}
int main(void)
{
size_t size;
uint8_t buf[128];
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
ringbuf_init(&tx_ringbuf, tx_buf, sizeof(tx_buf));
HAL_UART_Receive_DMA(&huart1, rx_buf, sizeof(rx_buf));
while (1)
{
size = dma_ringbuf_read(buf, sizeof(buf));
uart_output(buf, size);
}
}
可以看到程序就开启了一次HAL_UART_Receive_DMA,并且没有用到任何接收回调,就只在循环中读取DMA的COUNTER来判断dma已经写到了缓存的哪个位置,然后我们将其写入的数据读出在发送出去。用minicom测试一下代码,测试结果仍然ok,说明该方案可行。 但是这么奇葩的用法肯定是有问题,他最大的问题就是无法判断dma接收到的数据是否超过了缓存区的最大长度,因此,如果长时间不读取数据,等到数据溢出以后再去读取将会得到错误的数据,并且还无法判断,唯一的解决方法,就是定时的读取,一般我们串口的波特率顶天就是115200,然后是8位比特,1位停止,无校验,那么我们数据的最大传输速率就是 115200 / (1 (开始位)+ 8 + 1) = 11520 B/s, 如果我们的缓存区定成1024,那么数据溢出的时间就是 1024 / 11520 = 0.0888s, 所以我们只要以小于这个时间的周期读取,比如50ms读取一次,就能保证数据不会溢出。 当然我们还可以开启idle中断,在中断中读取数据,由于我们不会关闭dma,因此不用担心数据丢失的问题,代码如下。
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
size_t size;
uint8_t buf[128];
size = dma_ringbuf_read(buf, sizeof(buf));
uart_output(buf, size);
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
ringbuf_init(&tx_ringbuf, tx_buf, sizeof(tx_buf));
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf));
while (1)
{
}
}
利用dma进行串口接收,即兼顾效率和稳定的方法,我试了几种,目前这种是效果最好的,当然最稳妥的方法还是串口接收中断+ringbuf。
|