STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平

雨飞工作室 2026-10-01 07:53

一、串口发送数据

1.串口发送数据最直接的方式就是标准调用库函数 。

void USART_SendData(USART_TypeDef* USARTx, uint16_t Data);

第一个参数是发送的串口号,第二个参数是要发送的数据了。但是,用过的朋友应该觉得不好用,一次只能发送单个字符,所以我们有必要根据这个函数加以扩展:

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图1

该函数的形参就是我们调用该函数时要发送的字符串:通过循环调用USART_SendData来依次发送我们的字符串。

while(USART_GetFlagStatus(USART1,USART_FLAG_TC )==RESET);

这句话有必要加,是用于检查串口是否发送完成的标志,如果不加这句话会发生数据丢失的情况。这个函数只能用于串口1发送。有些时候根据需要,要用到多个串口发送那么就还需要改进这个程序。如下:

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图2

这样就可实现任意的串口发送。但有一点,我在使用实时操作系统的时候(如UCOS,Freertos等),需考虑函数重入的问题;也可以简单的实现把该函数复制一下,然后修改串口号也可以避免该问题。这个函数不能像printf那样传递多个参数,还可以再改进:

void USART_printf(USART_TypeDef * USARTx, char * Data, ...){const char *s;int d;   char buf[16];va_list ap;va_start(ap, Data);while (* Data != 0) //判断是否到达字符串结束符{                              if(* Data == 0x5c)  //'\'{    switch (*++Data){    case 'r': //回车符USART_SendData(USARTx, 0x0d);Data ++;    break;    case 'n': //换行符USART_SendData(USARTx,0x0a); Data ++;    break;    default:Data ++;    break;}    }  else if ( * Data == '%'){    switch ( *++Data ){     case 's': //字符串s = va_arg(ap, const char *);     for ( ; *s; s++) {USART_SendData(USARTx,*s);     while(USART_GetFlagStatus(USARTx, USART_FLAG_TXE) == RESET);}    Data++;     break;    case 'd': //十进制d = va_arg(ap, int);    itoa(d, buf, 10);     for (s = buf; *s; s++) {USART_SendData(USARTx,*s);     while(USART_GetFlagStatus(USARTx, USART_FLAG_TXE) == RESET);}    Data++;break;  default:Data++;     break;    }   }  elseUSART_SendData(USARTx, *Data++);  while (USART_GetFlagStatus (USARTx, USART_FLAG_TXE) == RESET);  }}

该函数就可以像printf使用可变参数,方便很多。通过观察函数但这个函数只支持了%d,%s的参数,想要支持更多,可以仿照printf的函数写法加以补充。


2.直接使用printf函数

STM32要直接使用printf不行的,需要加上以下的重映射函数:

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图3

如果不想添加以上代码,也可以勾选以下的Use MicroLI选项来支持printf函数使用:

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图4

二、串口接收数据

串口接收应有一定的协议,如发送一帧数据应该有头标志或尾标志,也可两个标志都有。这样在处理数据时既能能保证数据的正确接收,也有利于接收完后我们处理数据。这里我以串口2接收中断服务程序函数且接收的数据包含头尾标识为例:

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图5

数据的头标识为“\n”即换行符,尾标识为“+”。该函数将串口接收的数据存放在USART_Buffer数组中,然后先判断当前字符是不是尾标识,如果是说明接收完毕,然后再来判断头标识是不是“+”号,如果还是那么就是我们想要的数据,接下来就可以进行相应数据的处理了:如果不是,就让Usart2_Rx=0重新接收数据。这样做的有以下好处:1)可以接收不定长度的数据,最大接收长度可以通过Max_BUFF_Len来更改;2)可以接收指定的数据;3)防止接收的数据使数组越界。这里我的把接收正确数据直接打印出来,也可以通过设置标识位,然后在主函数里面轮询再操作。


以上的接收形式,是中断一次就接收一个字符,这在UCOS等实时内核系统中频繁的中断,非常消耗CPU资源,在有些时候我们需要接收大量数据时且波特率很高的情况下,长时间中断会带来一些额外的问题。所以,以DMA形式配合串口的IDLE来接受数据将会大大的提高CPU的利用率,减少系统资源的消耗;还是先看代码:

#define DMA_USART1_RECEIVE_LEN 18void USART1_IRQHandler(void)                                 {     u32 temp = 0;      uint16_t i = 0;            if(USART_GetITStatus(USART1, USART_IT_IDLE) != RESET)  {  USART1->SR;  USART1->DR; //通过先读SR(状态寄存器)和DR(数据寄存器)来清USART_IT_IDLE标志    
DMA_Cmd(DMA1_Channel5,DISABLE);  temp = DMA_USART1_RECEIVE_LEN - DMA_GetCurrDataCounter(DMA1_Channel5); //接收的字符串长度=设置的接收长度-剩余DMA缓存大小         for (i = 0;i < temp;i++)  {  Uart2_Buffer[i] = USART1_RECEIVE_DMABuffer[i];                  }          //设置传输数据长度  DMA_SetCurrDataCounter(DMA1_Channel5,DMA_USART1_RECEIVE_LEN);  
DMA_Cmd(DMA1_Channel5,ENABLE);  //打开DMA}        } 

之前的串口中断是一个一个字符的接收,现在改为串口空闲中断,就是一帧数据过来才中断进入一次。而且接收的数据时候是DMA来搬运到我们指定的缓冲区(即程序中的USART1_RECEIVE_DMABuffer数组),是不占用CPU时间资源的。


DMA的发送:

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图6

需要注意下:DMA_Cmd(DMA1_Channel4,DISABLE)函数需要在设置传输大小之前调用一下,否则不会重新启动DMA发送。


有了以上的接收方式,对一般的串口数据处理是没有问题的了。而在ucosiii中,使用信号量+消息队列+储存管理的形式来处理我们的串口数据。先来说一下这种方式对比其他方式的一些优缺点。一般对串口的处理形式是"生产者"和"消费者"的模式,即本次接收的数据要马上处理,否则当数据大量涌进的时候,就来不及"消费"掉生产者(串口接收中断)的数据,那么就会丢失本次的数据处理。所以使用队列就能够很方便地解决这个问题。在下面的程序中,对数据的处理是先接收再处理,如果在处理的过程中,有串口中断接受数据,那么就把它依次放在队列中,队列的特征是先进先出,在串口中就是先处理先接受的数据,所以根据生产和消费的速度,定义不同大小的消息队列缓冲区就可以了。缺点就是太占用系统资源,一般51单片机是没可能了。


下面是从某项目中截取过来的程序:

OS_MSG_SIZE  Usart1_Rx_cnt; //字节大小计数值unsigned char Usart1_data; //每次中断接收的数据unsigned char * Usart1_Rx_Ptr; //储存管理分配内存的首地址的指针unsigned char * Usart1_Rx_Ptr1; //储存首地址的指针void USART1_IRQHandler(){OS_ERR err;OSIntEnter();if(USART_GetFlagStatus(USART1,USART_FLAG_RXNE) != RESET) //中断产生 {   USART_ClearFlag(USART1, USART_FLAG_RXNE);     //清除中断标志Usart1_data = USART_ReceiveData(USART1);     //接收串口1数据到buff缓冲区if(Usart1_data =='+') //接收到数据头标识{//   OSSemPend((OS_SEM*  )&SEM_IAR_UART, //   //这里请求信号量是为了保证分配的存储区,但一般来说不允许//   (OS_TICK  )0, //在终端服务函数中调用信号量请求但因为//   (OS_OPT   )OS_OPT_PEND_NON_BLOCKING,//设置为非阻塞//   (CPU_TS*  )0,//   (OS_ERR*  )&err); //   if(err==OS_ERR_PEND_WOULD_BLOCK) //检测到当前信号量不可用//   {//     printf("error");//   }    Usart1_Rx_Ptr=(unsigned char *) OSMemGet((OS_MEM*)&UART1_MemPool,&err);//分配存储区Usart1_Rx_Ptr1=Usart1_Rx_Ptr; //储存存储区的首地址}if(Usart1_data == 0x0a) //接收到尾标志{                    *Usart1_Rx_Ptr++=Usart1_data;Usart1_Rx_cnt++; //字节大小增加OSTaskQPost((OS_TCB    *  )&Task1_TaskTCB,(void *  )Usart1_Rx_Ptr1,    //发送存储区首地址到消息队列(OS_MSG_SIZE  )Usart1_Rx_cnt,(OS_OPT       )OS_OPT_POST_FIFO,  //先进先出,也可设置为后进先出,再有地方很有用(OS_ERR    *  )&err);         Usart1_Rx_Ptr=NULL; //将指针指向为空,防止修改Usart1_Rx_cnt=0; //字节大小计数清零}else{*Usart1_Rx_Ptr=Usart1_data;//储存接收到的数据Usart1_Rx_Ptr++;Usart1_Rx_cnt++;} }    OSIntExit();}

上面被注释掉的代码是为了防止当分区中没有空闲的存储块时加入信号量,打印出报警信息。当然我们也可以将存储块直接设置大一点,但是还是无法避免当没有可有存储块时会程序会崩溃现象。


下面是串口数据处理任务:删去了其他代码,只把它打印出来了。

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图7

三、串口发送函数到底要怎么写?

串口发送可以通过轮询、中断或者DMA方式,这里讨论轮询发送方式。串口发送有两个关键的标志位:Transmit Data Register Empty Flag和Transmit Complete Flag,各家MCU寄存器名字可能略有不同,但是其实都是一个含义。比如ST MCU叫TXE和TC:

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图8

TXE为1表示发送数据寄存器为空,数据已经发送到了移位寄存器里,这时就可以发送数据寄存器里写数据了。TC为1表示发送数据寄存器为空,移位寄存器数据也为空,整个发送过程都完成了。

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图9

网络上有一篇文章还专门针对以下5种串口发送函数的写法做了分析和好坏讨论:

写法1:先判断TXE,再写数据

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图10

写法2:先判断TC,再写数据

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图11

写法3:先写数据,后判断TXE

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图12

写法4:先写数据,后判断TC

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图13

写法5:先判断TXE,再写数据,最后判断TC

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图14

最后结论:写法5最好,真的是这样吗?写法5本身没问题,但如果按照下面这种写法呢?

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图15

也就是每个字节之间只判断TXE标志位,最后一个字节写完之后判断TC标志位。这种写法相比上述5写法完成的功能一样的,但是减少了字节之间的时间间隔,字节和字节之间不需要去判断TC,只需要判断TXE就可以做到字节间无间隔发送。比如波特率为9600bps,发送时字节之间不加TC,可以看到两个字节之间时间为1.041ms。

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图16

当加上TC标志判断之后,两个字节数据之间时间为1.145ms。

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图17

两者相差的0.104ms(1.145ms-1.041ms=0.104ms)是因为加入了TC标志位判断,导致最后高电平时间增加了104us。

STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图18

至于所有字节都发送完之后,要不要去判断一下TC,这个取决于实际应用。不是任何情况都需要等待TC标志,比如循环往多个串口发送字符串,等待TC会拉长操作时间。第一个串口写完字符串,可以马上往第二个串口写,如果等待第一个串口最后一个字符写完,会拉长时间。如果调用完串口发送函数后,紧接着就有关闭串口或进入低功耗的动作,那么就需要判断TC标志位,否则就可能会导致最后一字节数据没有正确发出去。或者其它一些应用要求必须要等串口数据实际发送完成后才执行其他动作,就得加上TC标志位的判断。


附:代码常见写法,及其接收数据效果

1.这种写法在不是特殊(不掉电、不待机等)情况下,问题不大,USART数据会成功发送出去。但是在特殊情况下,问题就来了,代码只将数据放到了发送缓冲区,而没有发送出去就掉电或待机了,这个时候其实最后两个字符是没有发送出去的。
STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图19
STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图20
2.这种写法达到的效果和上面存在不同的就是倒数第二个数据发送出去了,也就是只有最后一个字符是没有发送出去的。
STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图21
STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图22
3.这种写法达到的效果和上面两种写法又不一样,发送了10个字符。
STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图23
STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图24
4.这种写法按理说可以实现功能,但实际多次试验结果确实第一字节数据丢失了。
STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图25
STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图26
5.这种写法是比较完整,为了保守起见,在特殊情况下使用该写法。
STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图27
STM32串口收发别再乱写了!从printf重映射到DMA+IDLE,TXE/TC的坑一次填平图28


关于科技区角:国内科技展会垂直内容策划服务商,提供从论坛内容全案策划、会展市场化IP打造到精准专业观众一站式邀约服务,以产业内容吸引高质量B端人群,打通展会从议题设计、演讲嘉宾邀约、宣传预热、精准邀观到供需对接全链路。
声明:内容取材于网络,仅代表作者观点,如有内容违规问题,请联系处理。
more
美企AI成本焦虑加剧,中国开放模型成降本新宠
别再轻信AI了!它给你的答案,可能都是广告买来的
凯文·曼迪亚再获2.5亿美元融资,Armadin估值超25亿重塑AI时代安全防御
从电网到核心,英飞凌完善AI数据中心供电全链路布局
踩刹车,但OpenAI要再融2000亿
教皇利奥十四世发声:算法缺乏“人性火花”,AI艺术面临本体论拷问
重磅!OpenAI 牵手 EDA 巨头开发芯片设计大模型,芯片设计要变天?
ACC2026来了!|AI创业者“出道”、无限token build、爽逛skill跳蚤市场,还有更多神秘大奖!
谷歌“太阳捕手”首星入轨,太空AI算力基建迈出关键一步
AI预言成真?特朗普密会马斯克,Grok成美军“战场参谋”
Copyright © 2025-成都区角科技有限公司
蜀ICP备2025143415号-1
  
川公网安备51015602001305号