简介
串口以其使用便捷、成本低廉的特点,配合 RS485 芯片能轻松搭建远距离、抗干扰的局域网络,应用极为广泛。随着产品功能日益复杂,MCU 需要处理的任务激增,系统实时响应能力成了关键。绝大多数现代单片机(如 ARM7、Cortex-M3 系列)的串口外设都自带了硬件 FIFO 缓冲区。如果能充分利用它,在减少中断频率的同时提升收发效率,不就能一举两得了吗?在深入探讨之前,不妨先瞧瞧传统串口数据交互的几个短板:
- 每接收一字节便触发一次中断,完全无视硬件 FIFO 的存在,中断次数居高不下。
- 应答时采用阻塞式等待发送。串行传输的耗时远慢于 CPU 的处理速度,等待单字节发完再送下一个,无疑是对 CPU 资源的巨大浪费,系统整体响应变得迟钝(以 1200bps 为例,发送一字节约耗时 10ms,若连发几十个字节,CPU 就长时间空等)。
- 应答时采用中断发送。虽不浪费 CPU,却额外增加了中断源,整体中断事件变多,从系统可靠性角度看,中断源自然是越精简越好。
针对这些痛点,本文将结合一种常用的自定义通讯协议,给出一个从底层收发到上层帧打包的完整优化方案。
深入串口 FIFO
串口 FIFO 本质上是为串口定制的硬件缓存,遵循先进先出原则。接收与发送 FIFO 通常是两个独立的硬件单元。数据进入串口后,会先暂存于接收 FIFO;当 FIFO 内数据量达到设定阈值(常见为 1、2、4、8、14 字节)或虽未满但在约 3.5 个字符的传输时间内再无新数据,硬件便通知 CPU 产生一次接收中断。发送时,只需把数据写入发送 FIFO,只要 FIFO 非空,硬件就会自动将数据逐个移出并发送,CPU 无须干预。单次写入发送 FIFO 的字节数受其最大深度限制,通常不超过 16 字节。以上参数依芯片而异,开发前查阅对应数据手册是必不可少的步骤。
数据接收与高效打包
FIFO 能够缓存接收到的数据,这为我们降低中断频率提供了可能。以 NXP 的 lpc1778 芯片为例,其接收 FIFO 触发级别可配置为 1、2、4、8、14 字节,建议设为 8 或 14 字节,这也是 PC 串口接收 FIFO 的常见默认值。如此一来,连续接收大量数据时,每满 8 或 14 字节才产生一次中断(最后一包除外),中断次数相比单字节触发模式将大幅锐减。配置方式非常简单,在 lpc1778 上只需操作 UART FIFO 控制寄存器 UnFCR 即可。
接收数据必须遵循既定的通讯协议,二者相辅相成。通常我们需要根据协议将接收到的数据打包成完整的一帧,再递交给上层逻辑处理。下面展示一套自定义协议帧格式,并提供通用的成帧方法。
自定义协议格式如下图所示:

- 帧首:一般为 3~5 个
0xFF 或 0xEE
- 地址号:待通讯设备的地址编号,占 1 字节
- 命令号:用于区分不同功能,占 1 字节
- 长度:数据区的字节数,占 1 字节
- 数据:与具体命令号相关,数据区长度可为 0,整帧长度不应超过 256 字节
- 校验:可采用异或和校验(1 字节)或 CRC16 校验(2 字节),本例使用 CRC16
下文详细介绍如何将接收到的数据按此格式打包。
1. 定义数据结构
typedef struct
{
uint8_t * dst_buf; //指向接收缓存
uint8_t sfd; //帧首标志,为0xFF或者0xEE
uint8_t sfd_flag; //找到帧首,一般是3~5个FF或EE
uint8_t sfd_count; //帧首的个数,一般3~5个
uint8_t received_len; //已经接收的字节数
uint8_t find_fram_flag; //找到完整帧后,置1
uint8_t frame_len; //本帧数据总长度,这个区域是可选的
}find_frame_struct;
2. 初始化数据结构,一般放在串口初始化中
/**
* @brief 初始化寻找帧的数据结构
* @param p_fine_frame:指向打包帧数据结构体变量
* @param dst_buf:指向帧缓冲区
* @param sfd:帧首标志,一般为0xFF或者0xEE
*/
void init_find_frame_struct(find_frame_struct * p_find_frame,uint8_t *dst_buf,uint8_t sfd)
{
p_find_frame->dst_buf=dst_buf;
p_find_frame->sfd=sfd;
p_find_frame->find_fram_flag=0;
p_find_frame->frame_len=10;
p_find_frame->received_len=0;
p_find_frame->sfd_count=0;
p_find_frame->sfd_flag=0;
}
3. 数据打包程序
/**
* @brief 寻找一帧数据 返回处理的数据个数
* @param p_find_frame:指向打包帧数据结构体变量
* @param src_buf:指向串口接收的原始数据
* @param data_len:src_buf本次串口接收到的原始数据个数
* @param sum_len:帧缓存的最大长度
* @return 本次处理的数据个数
*/
uint32_t find_one_frame(find_frame_struct * p_find_frame,const uint8_t * src_buf,uint32_t data_len,uint32_t sum_len)
{
uint32_t src_len=0;
while(data_len--)
{
if(p_find_frame ->sfd_flag==0)
{ //没有找到起始帧首
if(src_buf[src_len++]==p_find_frame ->sfd)
{
p_find_frame ->dst_buf[p_find_frame ->received_len++]=p_find_frame ->sfd;
if(++p_find_frame ->sfd_count==5)
{
p_find_frame ->sfd_flag=1;
p_find_frame ->sfd_count=0;
p_find_frame ->frame_len=10;
}
}
else
{
p_find_frame ->sfd_count=0;
p_find_frame ->received_len=0;
}
}
else
{ //是否是"长度"字节? Y->获取这帧的数据长度
if(7==p_find_frame ->received_len)
{
p_find_frame->frame_len=src_buf[src_len]+5+1+1+1+2; //帧首+地址号+命令号+数据长度+校验
if(p_find_frame->frame_len>=sum_len)
{ //这里处理方法根据具体应用不一定相同
MY_DEBUGF(SLAVE_DEBUG,("数据长度超出缓存!\n"));
p_find_frame->frame_len= sum_len;
}
}
p_find_frame ->dst_buf[p_find_frame->received_len++]=src_buf[src_len++];
if(p_find_frame ->received_len==p_find_frame ->frame_len)
{
p_find_frame ->received_len=0; //一帧完成
p_find_frame ->sfd_flag=0;
p_find_frame ->find_fram_flag=1;
return src_len;
}
}
}
p_find_frame ->find_fram_flag=0;
return src_len;
}
使用例子:
定义数据结构体变量:
find_frame_struct slave_find_frame_srt;
定义接收数据缓冲区:
#define SLAVE_REC_DATA_LEN 128
uint8_t slave_rec_buf[SLAVE_REC_DATA_LEN];
在串口初始化中调用结构体变量初始化函数:
init_find_frame_struct(&slave_find_frame_srt,slave_rec_buf,0xEE);
在串口接收中断中调用数据打包函数:
find_one_frame(&slave_find_frame_srt,tmp_rec_buf,data_len,SLAVE_REC_DATA_LEN);
其中,tmp_rec_buf 是串口接收临时缓冲区,data_len 是本次接收的数据长度。
数据发送优化
如前文所述,传统等待发送浪费 CPU 资源,中断发送则增加中断源。在实际开发中,定时器中断几乎是每套系统都会用到的,我们完全可以借助定时器中断和硬件 FIFO 来驱动数据发送。经过合理设计,这套方案既能避免 CPU 空转,又不必新增中断源。不过,这种方法并非万能。如果你的应用根本没有开启任何定时中断,那自然无法使用;若定时中断的间隔较长而通讯波特率又极高,同样不太适用。这类场景下,还是需要回归 后端 & 架构 设计原则,根据吞吐需求在系统层面做好整体规划。我们目前项目常用的波特率较低(1200bps、2400bps),在此条件下,定时器间隔保持在 10ms 以下即可满足要求;若间隔缩短至 1ms 以下,理论上 115200bps 也能胜任。
本方案的核心思想是:每次定时器中断触发时,检查是否有待发数据。若有且满足发送条件,就将数据填入发送 FIFO(以 lpc1778 为例,一次最多 16 字节)。随后硬件会自动启动发送流程,CPU 无需再操心。下面以 RS485 总线的 lpc1778 为例介绍,代码与硬件操作强相关,但设计思路可以通用。
1. 定义数据结构
/*串口帧发送结构体*/
typedef struct
{
uint16_t send_sum_len; //要发送的帧数据长度
uint8_t send_cur_len; //当前已经发送的数据长度
uint8_t send_flag; //是否发送标志
uint8_t * send_data; //指向要发送的数据缓冲区
}uart_send_struct;
2. 定时处理函数
/**
* @brief 定时发送函数,在定时器中断中调用,不使用发送中断的情况下减少发送等待
* @param UARTx:指向硬件串口寄存器基地址
* @param p:指向串口帧发送结构体变量
*/
#define FARME_SEND_FALG 0x5A
#define SEND_DATA_NUM 12
static void uart_send_com(LPC_UART_TypeDef *UARTx,uart_send_struct *p)
{
uint32_t i;
uint32_t tmp32;
if(UARTx->LSR &(0x01<<6)) //发送为空
{
if(p->send_flag==FARME_SEND_FALG)
{
RS485ClrDE; // 置485为发送状态
tmp32=p->send_sum_len-p->send_cur_len;
if(tmp32>SEND_DATA_NUM) //向发送FIFO填充字节数据
{
for(i=0;i<SEND_DATA_NUM;i++)
{
UARTx->THR=p->send_data[p->send_cur_len++];
}
}
else
{
for(i=0;i<tmp32;i++)
{
UARTx->THR=p->send_data[p->send_cur_len++];
}
p->send_flag=0;
}
}
else
{
RS485SetDE;
}
}
}
其中,RS485ClrDE 为宏定义,将 RS485 设置为发送模式;RS485SetDE 同为宏定义,设置为接收模式。
使用例子:
定义数据结构体变量:
uart_send_struct uart0_send_str;
定义发送缓冲区:
uint8_t uart0_send_buf[UART0_SEND_LEN];
根据具体硬件串口,对定时处理函数进行二次封装:
void uart0_send_data(void)
{
uart_send_com(LPC_UART0,&uart0_send_str);
}
将封装函数 uart0_send_data() 放入定时器中断服务函数中。在需要发送数据的位置,设置串口帧发送结构体变量:
uart0_send_str.send_sum_len=data_len; //data_len为要发送的数据长度
uart0_send_str.send_cur_len=0; //固定为0
uart0_send_str.send_data=uart0_send_buf; //绑定发送缓冲区
uart0_send_str.send_flag=FARME_SEND_FALG; //设置发送标志
总结
本文完整地探讨了一套高效的串口数据收发策略,并附上了具体 C 代码实现。在 MCU 需并行处理繁杂任务的当下,这套方法占用资源极少,不失为一种提升系统整体响应性能的新思路。如果你也在嵌入式开发中追求更优雅的中断管理与数据吞吐,欢迎来 云栈社区 和众多开发者一起挖掘更多底层优化技巧。