使用STM32F407ZG实现串口通信:固定帧头帧尾传输数据帧

STM32F407ZG开发板学习(4)

  • 串口简介
  • 通信接口
  • USART
  • 接线
  • 电平标准
  • 数据帧
  • 实验:固定帧头帧尾数据传输
  • 需求
  • 最终思路以及思考过程
  • 思路
  • 中断函数程序段长度的问题
  • 缓冲区数据结构的决定
  • 初始化配置
  • 中断服务函数
  • 队列溢出处理
  • 实验结果
  • 串口简介

    串口是MCU的重要外部接口,可以实现MCU与外部设备的互相通信,同时在软件开发中可以通过将MCU的数据传输到PC上查看以供程序员进行调试。STM32F407ZGT6最多提供六路串口,有分数波特率发生器、支持 同步 单线通信和半双工单线通讯、支持 LIN 、 支持调制解调器操作、 智能卡协议和 IrDA SIR ENDEC 规范 、具有 DMA 等。
    官方文档的介绍:

    通信接口

    通信接口
    USART:一个全双工通用同步/异步串行收发模块,该接口是一个高度灵活的串行通信设备。
    I2C:一种简单、双向二线制同步串行总线。它只需要两根线即可在连接于总线上的器件之间传送信息。
    SPI:串行外设接口(Serial Peripheral Interface)是一种同步外设接口,它可以使单片机与各种外围设备以串行方式进行通信以交换信息。外围设备包括Flash RAM,网络控制器、LCD显示驱动器、A/D转换器和MCU等。
    CAN:控制器域网 (Controller Area Network, CAN),属于总线式串行通信网络。
    USB:Universal Serial Bus(通用串行总线)是一个外部总线标准,用于规范电脑与外部设备的连接和通讯。是应用在PC领域的接口技术。

    USART

    接线

    接口通过三个引脚从外部连接到其它设备。
    任何USART双向通信均需要至少两个引脚:接收数据输入引脚 (RX) 和发送数据引脚输出 (TX):
    RX:接收数据输入引脚就是串行数据输入引脚。过采样技术可区分有效输入数据和噪声,从而用于恢复数据。
    TX:发送数据输出引脚。如果关闭发送器,该输出引脚模式由其 I/O 端口配置决定。如果使 能了发送器但没有待发送的数据,则 TX 引脚处于高电平。在单线和智能卡模式下,该 I/O 用于发送和接收数据(USART 电平下,随后在 SW_RX 上接收数据)。

    设备间接线
    TX和RX两个设备间交叉连接,这两条线的电平是相对于GND的,因此GND也是必须连接的。而VCC可以视情况连接,主要看各设备有无单独电源。若只连接设备1到设备2的TX -> RX,那么通信方式将从全双工变为单工,仅允许设备1发送设备2接收。

    电平标准

    电平标准用于规定数据1和0的表达方式,主要有以下几种:
    TTL电平:3.3/5V 表示1,0V 表示0。
    RS232:-3 ~ -15V 表示1,+3 ~ +15V 表示0。
    RS485:两线压差 +2 ~ +6V 表示1,-2 ~ -6V 表示0。(差分信号)

    数据帧

    USART为异步通信,数据帧在传输时需要添加帧头帧尾以检测区分,也就是启动位和停止位,同时需要规定波特率来确定检测电平的时间点以得到正确的数据,既不重复采样也不丢弃数据。
    如图,空闲时为高电平,启动位为低电平,检测到下降沿即开始准备接收一帧数据接收完一帧后会有一个停止位,即将电平置回空闲时的高电平。
    数据帧有9位长和8位长两种,9位长通常是8bit的数据加上1bit的奇偶校验位,而8位长通常不加校验位。
    数据帧

    实验:固定帧头帧尾数据传输

    需求

    1. 从PC输入数据,串口识别相应数据帧接收后发送回PC打印查看。
    2. 数据帧帧头0xEE,帧尾0xFF。

    最终思路以及思考过程

    思路

    接收时中断,判断是否有效,有效则存入缓冲区,存入完整一帧后,在主循环打印。

    中断函数程序段长度的问题

    在进行最开始的中断函数的设计时,我将打印数据的操作放在了中断函数之中,这意味着当接收到0xFF时,需要将缓冲区的完整一帧数据在此次中断完成打印,并清空缓冲区。这将导致中断函数运行的时间大大增加且分配不均,而在调试过程中还出现了在一次性输入多条数据帧时会有数据丢失的诡异现象。
    至今我也没有找到数据丢失这个问题原因,先认为是中断函数不宜有过长的程序段以及复杂的操作。
    于是转而把打印与存数据的操作分开来,中断函数中仅完成存数的操作。

    缓冲区数据结构的决定

    1. 一个数组,这种方式最为简单,将接收到的所有数据都存入缓冲区,这样中断函数甚至不需要判断帧头帧尾,但个人认为,当一条长数据帧的帧头不幸丢失,这会导致缓冲区极大的存储空间浪费。
    2. 一个数组、一个数据帧计数量、一个打印游标,在中断函数中实现帧头帧尾检测,只存入有用的数据,然后以0xFF作为每帧结尾存入缓冲区,这就解决了上面那种缓冲区浪费的情况。而且帧头帧尾判断也不过是对接收到的数据的if else判断是否存入缓冲区,实际上每次进入中断函数并不会有大量的代码段操作,它只会根据情况执行一些设置标志位和存数据的操作。
    3. 上一点的升级版——循环队列。 根据前两点的思考,又考虑到了新的一点。就是当数据帧计数量减小,打印游标往后移动,前面的数据不断打印出后,实际上就已经不再需要了,但是前面的空间却还是在被占用着。想要循环利用前面的这片空间,不由得想到循环队列这种数据结构。因为入队是在队尾,出队是在队头,这恰好与我们打印与接收的时序是一致的,在打印时只需要移动Front,在存数据时也只需要移动Rear,打印时的pop操作也将前面的空间“释放”出来以循环利用。

    1. 歪想法——链表。 循环队列的想法已经让我认为比较合理了,后续就是考虑溢出时的操作。而我个人又比较怕麻烦,就想能不能考虑一种不需要考虑溢出,即动态分配内存的方式来存数据呢。因为从需求来看,根本不需要随机访问,那么链表的数据结构看起来是一个不错的选择,它可以动态分配内存,数据帧有多少我就分多少来存,打印时只需要判断链表是否为空依次打印。然而,STM32的MCU是32位的,这代表一个地址需要4B的存储空间,链表中next指针的存在使得我存一个1B的数据反而多用了4B的开销!!! 再者,在STM32使用C标准库的malloc等动态分配内存,将导致本就不富裕的内存多了很多无法使用的碎片,这是很恐怖的。因此,存数据这种操作,尽量别用链表,别动态分配内存。

    因此,最后选择了循环队列的方式来作为缓冲区。

    初始化配置

    配置串口的主要步骤如下:

    1. 使能相应引脚所在GPIO的时钟和串口时钟。
    2. GPIO相应引脚复用为USART,使用 GPIO_PinAF_Config 函数。
    3. 用 GPIO_InitTypeDef 初始化GPIO。
    4. 用USART_InitTypeDef 初始化相应串口,包括波特率、流控制、校验位、数据长度、停止位的大小(0.5 / 1 / 1.5 / 2)等。
    5. 开启需要的中断并配置中断等级(NVIC_InitTypeDef),仅需要中断才配置。
    6. 使能串口,编写中断服务函数 USARTx_IQRHandler 。

    如图选择USART3,我的开发板是复用GPIOB的PB10和PB11
    原理图

    	GPIO_InitTypeDef GPIO_InitStructure;
        USART_InitTypeDef USART_InitStructure;
        NVIC_InitTypeDef NVIC_InitStructure;
        
        //使能USART3和GPIOB的时钟
        RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE);
        RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE);
        
        //复用引脚设置
        GPIO_PinAFConfig(GPIOB, GPIO_PinSource10, GPIO_AF_USART3); //TX
        GPIO_PinAFConfig(GPIOB, GPIO_PinSource11, GPIO_AF_USART3); //RX
        
        //USART3 TX RX
        GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; //复用
        GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; //复用推挽输出
        GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11; //PB10 PB11
        GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; //上拉
        GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; //50MHz
        GPIO_Init(GPIOB, &GPIO_InitStructure);
        
        //USART1 Init
        USART_InitStructure.USART_BaudRate = 115200; //波特率
        USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //流控制
        USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; //模式:发送/接收,可以用|连接
        USART_InitStructure.USART_Parity = USART_Parity_No; //校验
        USART_InitStructure.USART_StopBits = USART_StopBits_1; //终止符位数
        USART_InitStructure.USART_WordLength = USART_WordLength_8b; //字长,未选校验取8位
        USART_Init(USART3, &USART_InitStructure);
        
        USART_Cmd(USART3, ENABLE);
        
        USART_ClearFlag(USART3, USART_FLAG_TC);
        
        //配置中断
        USART_ITConfig(USART3, USART_IT_RXNE, ENABLE); //接收中断
        
        //NVIC配置
        NVIC_InitStructure.NVIC_IRQChannel = USART3_IRQn;
        NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
        NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
        NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
        NVIC_Init(&NVIC_InitStructure);
    

    中断服务函数

    首先贴一下在全局设置的缓冲区和标志位。
    缓冲区设置为循环队列,方便循环使用存储空间无需等存满后再整个清除。

    uint8_t USART_RX_BUF[USART_REC_LEN]; //缓冲区,循环队列
    uint8_t FRAME_NUM = 0; //有效帧计数
    uint8_t Front = 0;
    uint8_t Rear = 0;
    

    标志位,主要用于标记是否检测到0xEE,即是否进入一帧。

    //接收状态
    //bit 1,      是否已经接收到0xEE开头符
    //bit 0,      队列溢出的标志
    uint8_t USART_RX_STA = 0; //接收状态标记
    

    函数中逻辑比较简单,每当DR中接收到数据,RXNE置1代表数据接收器中有数据了,需要中断处理。
    逻辑
    代码如下,add_buf 函数是循环队列基本的入队操作,不再赘述。

    		if(USART_GetITStatus(USART3, USART_IT_RXNE) != RESET)
    		{
    			Res = USART_ReceiveData(USART3);//(USART3->DR);	//读取接收到的数据
                if(Res != 0xEE) //检测到非开头符
                {
                    if(USART_RX_STA & 0x02) //接收过0xEE
                    {
                        if(Res != 0xFF)
                        {
                            add_buf(Res);
                        }
                        else //检测到结束符
                        {
                            add_buf(0xFF);
                            USART_RX_STA &= 0xFD; //高标志位置0
                            FRAME_NUM++;
                        }
                    }
                    //没接收过0xEE不用操作
                }
                else //检测到开头符
                {
                    if(USART_RX_STA & 0x02) //非首次检测到0xEE
                    {
                        add_buf(Res);
                    }
                    else //首次则置1标志位
                    {
                        USART_RX_STA |= 0x02; //高标志位置1
                    }
                }
    		}
    

    如此一来,主循环的逻辑显而易见,即当 FRAME_NUM > 0 时打印数据直到遇到0xFF,最后再使FRAME_NUM自减,即可打印完整的一帧。代码较简单,用到 pop_buf 出队函数。

    		if(FRAME_NUM > 0) //pop操作仅出现在此处
            {
                temp_data = pop_buf();
                while(temp_data != 0xFF)
                {
                    USART_SendData(USART3, temp_data);
                    while(USART_GetFlagStatus(USART3,USART_FLAG_TC)!=SET);
                    temp_data = pop_buf();
                }
                printf("\r\n\r\n");
                FRAME_NUM--;
            }
    

    队列溢出处理

    队列溢出处理一开始想直接清空队列,令Front = Rear,但是由于FRAME_NUM的存在,这会导致主循环仍然继续打印之前存入的完整的帧并出队数据,导致Front的错乱。
    因此最后选择的方法是在中断函数判断是否溢出,溢出则代表缓冲区最后面存入了半帧数据,没有0xFF结尾符,则清掉这一半帧即可。

    		//判断队列是否溢出
            if(full_buf())
            {
                //循环队列牺牲一个字节用于队满判断
                printf("溢出,清除最后一帧的数据,请检查数据长度");
                printf("\r\n\r\n");
                /*
                 * 在一次性输入多条数据帧且最后一条导致缓冲区溢出时,
                 * 直接清空缓冲区,会使得FRAME_NUM非零导致主循环打
                 * 印一些不期望的数据,且令Front和Rear错乱。
                 */
                //Rear = Front;
                
                //只清掉未发完的那一帧
                cpy_rear = (Rear - 1) % USART_REC_LEN;
                while(cpy_rear != Front && USART_RX_BUF[cpy_rear] != 0xFF)
                {
                    cpy_rear = (cpy_rear - 1) % USART_REC_LEN;
                }
                //改变rear
                if(USART_RX_BUF[cpy_rear] == 0xFF)Rear = (cpy_rear + 1) % USART_REC_LEN;
                if(cpy_rear == Front)Rear = Front;
                
                USART_RX_STA = 0x00;
            }
    

    实验结果

    由实验结果来看,一次性输入多条数据帧也是可以正确打印的。
    输入:ff 41 ee 41 ff ee 42 43 ff 44 ee 45 46 ff 47 48 ee 49 ff 50 51
    期望输出:(16进制下GBK编码,0x41表示A)
    (帧1)41
    (帧2)42 43
    (帧3)45 46
    (帧4)49
    输出结果
    溢出时的结果:(为了方便缓冲区大小设置为了5)
    即使溢出,也还是可以把已经存入的完整帧打印出来。
    溢出

    。。。刚刚调试的时候又发现了溢出相关新的问题,就是如果上面的输入再多几个字节,那么有可能会触发多次溢出处理,导致多删掉几帧。那么就应该再设置一个是否处理过溢出的标志位予以识别。累了,具体后面再更新吧。
    更新:目前想的新策略是在一段时间内只丢弃数据,等待一部分帧打印后,这时缓冲区要么被一个半帧填满,要么并没有满,但是有半帧在最后的位置没有清除,这时再清除这个半帧即可。

    物联沃分享整理
    物联沃-IOTWORD物联网 » 使用STM32F407ZG实现串口通信:固定帧头帧尾传输数据帧

    发表评论