如何写一个健壮且高效的串口接收程序?

学单片机的大概最先、最常写的通信程序应该就是串口程序了,但是如何写出一个健壮且高效的串口接收程序呢?接下来鱼鹰将根据多年的开发经验教你如何编写串口接收程序。

1、传入参数指针

2、互斥锁释放顺序

3、数据帧检查

4、串口空闲

5、通信吞吐量

为了更好的理解接下来的知识点,鱼鹰将设计一个串口框架,让道友心中有一个参考方向。

本篇重点在于解决如何写一个健壮、高效的串口接收数据,发送与接收处理过程略讲。

帧格式

先聊聊帧格式,一般来说,一个数据帧有以下几部分内容:

帧头

帧头用于分辨一个数据帧的起始,这个帧头必须足够特殊才行,因为它是分辨一个帧的起始,那么什么样的帧头是足够特殊的数据呢?保证这个数据在一个帧内最好只出现一次的数据,那就是帧头,比如 0x55、0xAA 之类的。而且最好有两个字节以上,这样帧头才更加独一无二。

但是数据域内的数据你是没办法保障不包含和帧头一样的数据。

那么如果不凑巧,除了帧头外其他部分也有这样的两个字节的帧头,那会出现什么问题?

几乎不会出现问题。因为一般来说数据都是一帧一帧发送的,只要你前面的数据帧传输正确,那么即使下一帧的数据中有和帧头一样的数据(包括帧头)也没有问题,因为帧头判断已经在开始就判断成功了,就不会继续判断后面的数据是否是帧头了。

那么为什么说是几乎,因为如果上一帧数据接收错误,那么程序必须再找一次帧头才行(单字节接收时是如此,采用空闲中断的话就不需要这么麻烦),这就导致找帧头的时候在帧头数据之外寻找了,很可能这些数据就有帧头。

但是即使帧头数据之外的假帧头真的存在,也没关系,还有第二重保障,那就是校验,即使找到了一个错误的帧头,那么数据校验这一关也很难过去,所以放宽心。

如果校验也凑巧通过了,那还有第三重保障:帧尾。应该到不了这里吧,毕竟这比中彩票还难。

又要上一帧数据接收错误,还要当前帧除了帧头之外还有帧头,另外你还能跳过校验的检查(还有功能字、长度信息的检查),太难了。所以只要通过了这些检查,你就可以认为这个数据帧是可用的了。所以一帧数据接收错误,导致的问题最多只是丢失了这帧数据,对后续接收是不会有影响的(前提是你这个接收程序设计的足够好),发送端在发送超时后再发送一次即可,所以重发机制很重要。

事实上,如果你采用串口空闲中断,帧头、帧尾都可以不用,但一般来说,帧头都会保留,帧尾可以不需要,这是为了当单片机没有串口空闲中断时考虑,当然也可能有其他考虑,所以帧头得保留。

功能字

功能字主要用于说明该数据帧的功能,当然也可以作为函数指针的索引,一个索引值代表了一个具体功能,据此可找到对应的功能函数。

比如,设计一个函数指针数组,通过功能字进行索引,进而跳转到对应的功能函数中处理。

特别注意的是,设计功能字的时候,要考虑兼容性,对数据帧的功能进行划分,不要想到一个算一个,功能字也不要随便安排,不然在以后增加数据帧的时候会很麻烦。

比如说,只有一个字节的功能字,前四位作为一个大类,后四位作为大类中具体类。这样就可以将系统数据通信帧分为 16 个大类,每个大类下有 16 个可用的具体类,当你增加功能字的时候,就可以根据你的设计来确定属于哪个大类了,然后再插入进去。这样在管理、维护这些通信数据时你会发现很方便。

这个思想其实在 ARM 内核的中断系统和设计 uCOS II 任务优先级的时候都有,而鱼鹰在设计项目的通信协议的时候就是运用了这些思想。

(图片来源于《权威指南》)

长度

长度信息也是一个非常关键的数据,别小看了它,因为它,鱼鹰用了将近一个星期的时间才把一个 HardFaul 问题解决了,虽然这个程序 bug 不是我写的(鱼鹰一直用的是串口空闲接收方式,这个 bug 自然而然就跳过了),但确实很容易出错。

因为它是决定了你这个数据域长度的关键信息(一般长度信息代表数据域的长度,而不包含其它部分长度),也是这个数据帧的长度信息(加上固定字节长度就是帧长度了),更是接收程序还要接收多少数据的关键信息(对于空闲中断接收方式不算关键,这里的不关键是指不会造成程序异常问题)。

比如说你的程序刚好将帧头、帧尾、功能字判断完毕,然后中断程序因为种种原因导致没有及时接收串口数据,那么你可能得到的就是错误的数据,然后这个错误的长度数据就可能导致你的栈帧或者全局变量被破坏(单字节接收情况下就可能出现,因为鱼鹰碰到过),这是很严重的事情。所以在接收数据域的数据之前一定一定要判断这个长度信息(空闲中断除外)是否合法,不合法的话及时扔掉这帧数据,开始下一帧的数据检查。

所以为了保证及时接收数据,最好采用 DMA 传输。

数据域

这个没啥好说的,就是整个帧你真正需要发送的数据。而为了让你的发送函数能接收各种类型的数据,那么把参数类型设置为 void * 会是不错的选择。

校验

一个数据在接收过程中可能会被干扰,导致接收到错误的数据,那么如何保证这帧数据的完整与准确性呢,就在校验这一关了。

校验有很多方式,和校验、CRC 校验等(奇偶校验是针对一个字节的,不是数据帧)。

和校验算法简单,CPU 运算量小,累加最后只取最低字节即可(注意不是高字节,想想为什么),或者保存累加和的变量就是一个字节空间,这样就不需要额外操作了。

CRC 校验,这个算法复杂,理解起来比较困难,但一般来说可以直接拿来用,因为它是对每一位(bit)进行校验,所以纠错率很高,几乎不存在发现不了的数据错误,但正因为对每一位进行检查,所以 CPU 运算量较大,但是有的单片机是可以硬件计算 CRC 校验值的(比如 stm32)。不过现在 CPU 运算速度都挺快的,软件运算也是可以接受的。

那么该怎么校验呢?是从帧头开始到数据域部分,还是说直接校验数据部分?其实都可以,区别就是运算量问题,不过问题不大(最好是从头开始校验,以保证整帧数据的准确性)。

帧尾

前面说了,帧尾在空闲中断中可以不用,RXNE 中断接收时其实也可以不用,当然也可以加上,好处就是当你用串口助手查看数据流时,可以观察出一帧数据是否发送完整了。

最后再说说为什么在数据域前面设计四个字节大小,除了协议本身需要外,还有一个原因就是强制类型转化需要,我们知道,一般来说,赋值时都有字节对齐的限制(实际上有的 CPU 可以不对齐进行赋值),stm32 是 32 位的,那么四字节对齐是最合适的,这样就可以直接将我们收到的数据转化为需要的数据类型了。

传输过程

聊完了帧格式,再从大的方向看串口的传输过程:

当发送端发送第一帧数据包时,接收端通过某种方式接收(串口接收非空 RXNE 中断、串口空闲 IDLE 中断),为了让串口能够触发空闲中断,必须在发送端两个发送帧之间插入一段空闲时间(就是在此时间内不发数据,红色部分),保证空闲中断的准确触发。

同理,为了让发送端也能正常接收接收端的数据,也需要控制接收端的发送,不能在返回一帧数据时立马发送下一帧数据,不然触发不了发送端的空闲中断。

事实上,有些程序员设计的发送、接收过程比这个简单一些。即只有当接收端接收到一帧数据并返回一帧数据之后,发送端才能继续发送数据,这样一来,我们只需要控制好接收端的频率,就可以控制整个通信过程,也能控制通信频率。

但为什么还要设计成第一种传输情况呢?这是为了充分利用串口,增大数据吞吐率(这个后面再说)。

另外,不知道你是否观察到图中的每个数据帧占用的时间是不一样的,这是因为每个数据帧不可能都是一样长的,它们是不定长的数据包,所以你的定时不能从发送开始定时,而是从发送完成后开始定时控制空闲时间。

软件设计

上面所有的内容都是设定一些条件、需求,那么该如何实现软件设计呢?毕竟说的再多,如果不能实现这些,又有什么用呢?talk is cheap, show me the code。

下图设计了三个数据帧:GetVision(),GetSN(),GetMsg()。

GetVision()用于获取硬件版本号、软件版本号。

GetSN()用于获取产品序列号,用于识别唯一设备。

GetMsg()用于获取消息,可以获取各种传感器数据,事实上,如果数据量多的话,根据传感器的不同,会根据需要设计各种不同的数据帧(功能字不同)。

在软件设计上一般都会对这些函数设计一个统一的函数类型,用函数指针数组统一管理。

既要统一,又要体现差异性,函数参数就显得很有必要了。

这里设计了两个参数,一个是 void* (无类型指针),一个是 length(长度)。

无类型指针主要是用于传递数据域的数据地址,而数据域的数据可能是整型、浮点型、结构体、枚举、联合体等,为了保证传入的各种数据类型在不通过强制转化情况下都能兼容,设计为 void * 就显得很有必要了。

实际上为了显得更专业性,加上 const 修饰会是不错的选择,因为这可以保证缓存数据不被修改(事实上只能保证不被程序员修改,而不能保证程序本身,这个后面会解释)。

长度,长度参数是一个很关键的参数,为了保证长度的准确性,建议使用 sizeof 获取。

有人觉得 sizeof 好像一个函数,会不会导致效率低下啊,毕竟每次通信都要计算一次长度,那你就大特大错了。事实上,只要你的类型定义定义好了(不管是内置的类型定义还是自定义的结构体、枚举、联合体),编译器都能确定 sizeof 最终的数据长度,根本不存在计算过程。

用 sizeof 的两个好处:

1、可以忽略字节对齐问题(不同平台不能忽略,比如 window 和单片机通信)。因为编译器为了数据读写效率更高,一般会对数据进行地址对齐,这样一来手工计算一个数据类型的长度变得麻烦(当然你可以说使用某些手段让数据不进行对齐,这个另说),而 sizeof 将智能且准确计算数据大小。

2、当你使用 sizeof 时,兼容性更强,也显得更专业。程序修修改改很正常,一个数据结构改来改去也很正常,特别是开发初期更是如此。但是不管你怎么改,只要在编译器看来是固定长度的数据类型,那么 sizeof 就能在链接程序前计算出来;并且即使你后来加了数据不对齐的限制(加了这个限制后,很可能数据大小变小),也能准确计算。别问为什么,就是这么任性。

所以为了减小出错的可能性、减少劳动量,sizeof 是不错的选择。

当接收到数据地址和长度信息后,就可以进行发送了。

因为只有数据域的数据,为了组成一帧完整的数据,就必须加入打包过程。加上数据帧头、功能字、数据长度、校验等数据。

当一帧数据打包好之后,就可以进行发送了,发送可以采用循环查询发送,也可使用发送空 TXE 中断,当然还是建议使用 DMA 发送,这样你可以还没等它发送完就可处理其它事情了。

以上就是发送过程,接收过程也是同理,根据功能字来调用相应的函数进行回复。

事实上,如果只是数据的传输过程,完全可以使用一个发送函数实现数据的特异性传输,这样就可以减少一层数据传递,但是有些通信帧不只是数据的传输,可能在接收、发送时作一些其他处理(比如清除、设置某些标志位),所以需要再增加一层,用于进行差异性处理。

以上就是本篇内容的基础内容了,你以为快完了?你错了,现在只是刚开始而已,鱼鹰写本篇笔记的最终目的还在后面。这只是前菜,正文才刚开始。

串口接收遇到的那些问题

以下内容不会用太多的笔墨描述如何写发送、接收函数,而是重点关注串口接收过程中可能遇到的一些问题,如果说描述到了发送、接收函数,别会错意,顺带的。

以下大部分问题都是因为采用 RXNE(接收不为空)中断方式导致的问题,只有一个问题是鱼鹰从前没有考虑到,也是 IDLE + DMA 方式不可忽略的问题。

这就是为什么鱼鹰建议采用 IDLE + DMA 的原因,不仅是因为效率问题,更因为它能避免很多问题,当然水平足够高的话,采用 RXNE 也是完全(“完全”就未必,里面有一个问题是 RXNE 方式难以避免的问题)没有问题。

事实上,即使鱼鹰采用 RXNE 方式接收数据,也能避免以下大部分的问题,因为鱼鹰的基础足够扎实,会在一开始编写代码的时候自然而然避免一些问题的出现。

但是看完以下内容后,相信各位道友写出一个高效且健壮的串口接收程序根本不是问题,因为这就是所谓的经验啊。

传入参数指针

前面鱼鹰已经提到了需要一个指针作为函数的参数,这里说说这个指针问题。

我们知道,为了维护方便,也是为了节省空间,一般都会将类似的功能整合成一个函数,比如串口经常要用的发送、接收功能,但是所发送的数据内存空间可能就处于五湖四海了,他们通过指针来指向将要发送的数据。

为了节省 RAM 空间或者其它不为人知的原因,传递给发送函数的指针就是实际发送数据的地址,并且在计算校验值的时候也是直接使用这个地址进行校验计算,然后采用循环查询的方式发送数据,这样一来,就不必拷贝一个数据的副本进行发送,而是直接从数据源的地址进行发送,节省了部分 RAM 空间。

但是这样真的好吗?

你是否考虑过在计算数据帧校验值的时候,数据源改变了的问题呢?

比如说你采用和校验,数据一开始是 0x55,计算数据帧的校验和值为 tx_sum,然后被中断程序或者 DMA 修改了这个数据源,变成了 0xAA,此时你再使用这个数据地址进行发送,接收端接收到了 0xAA,接收端计算校验和的时候是 rx_sum,那么 rx_sum 必然不等于 tx_sum,然后接收端就认为该数据帧是错误的,然后丢失这帧数据,而这种情况是比较少见的,但确实是会偶尔出现接收错误的情况(当时发现这个问题时始终不得其解,明明我发送的是这个校验值,为什么你计算的校验值是另一个?开始怀疑是校验函数的问题,但其他数据帧计算时没有问题,只有一种数据帧会出现问题,然后鱼鹰怀疑是数据源的问题,是的,鱼鹰很快就怀疑数据源的问题,但当时验证时,只改了校验那部分地址,发送时的地址还是使用源地址,导致问题还是没解决,过了好久之后才发现这个发送地址没改,囧。所以说,即使你的思路是对的,但如果你解决时错了,问题也很严重)。

如果说接收端(从机)具有重发机制,那么问题不是很大,丢失一帧数据而已,再重发就是,但事实是,一般串口设计成主从模式,主机会在没有接收到从机的应答数据时会进行重发,但是从机一般不会主动重发数据的,它无法判断主机是否成功接收,而从机一般会在成功发送完数据后开始清除一些标志位(比如键盘按键数据清空,不然主机下次获取按键信息时还是同一个按键数据),事实上这个动作必须在对方成功接收才能进行(否则这次按键信息就丢失了),从这个角度来看,我们必须设计一个机制用于判断主机是否成功接收。

I2C、CAN 总线都有应答信号,但这是这些是总线自带的特性,我们不可能在接收到一个字节后发送一个应答信号给主机,那么是否有其他办法呢。

人们很容易想到的一个办法就是在主机收到正确数据后,主动发送一帧专用数据帧用于清除这个标志(这个帧和普通帧一样,所以可以确保主机数据能准确送达从机,因为如果超时没有送达,会触发重发机制)。这样只要在获取完这帧数据后,再额外的发送一帧数据用于对方确认即可,从机接收到后,即可开始着手清除一些标志位。

但这样会有一个问题,因为这种特殊的需要从机确认的数据包(其他类型数据不需要确认是因为如果主机没有正确收到数据还可以继续获取,获取的数据是一样并没有关系,但这种需要从机确认,一旦从机认为发送成功了,数据就被清除了这种情况就需要确认,典型的就是按键信息了),我们需要额外处理并占用发送带宽。这是鱼鹰不愿忍受的。

那么是否有更好的办法?

或许我们可以从 USB 协议中获得启发(这是在写这篇笔记的时候想到的,当时写按键板代码的时候没有想到过,但因为当时测试时传输成功率 100%,所以就放弃处理这种情况了)。

USB 协议是典型的主从机制,主机不主动获取数据,从机是无法主动发送数据的。那么从机是如何确定对方成功接收数据了呢?

一个 bit 的翻转位。

每当主机成功发送一帧数据后再发送下一帧数据时,就会翻转这个位,从机就可以根据这个位判断主机是属于重发数据(重发数据表示主机接收失败了)还是新数据了,这样从机就能从下一帧数据确定上一帧数据是否成功发送了。

而主机发送的数据是由从机发送应答包确定的,和上面的串口协议类似,所以这个方向的数据是没有问题的。

那么我们该如何重新设计这个协议呢?可以尽可能的不改变原来协议的情况下实现吗?

或许可以从功能字出发。

为了保证对功能字的原有定义保持基本不变,使用最高 bit 作为这个特殊的位,这个 bit 开始是 0,之后主机每接收一个从机应答数据就进行翻转,如果因为没有接收到从机的应答数据,就会使用相同的翻转位重复发送;而从机也根据这个 bit 来确定自己的上一帧数据对方是否接收(对比上一帧数据的翻转位),如果主机没有成功接收,就不清除标志位(之后主机会重发数据再次获取),否则清除标志位,。

因为是鱼鹰刚想到的,就不多说了,仅提供一个思路。

现在回到指针那块的问题。

现在已经知道,如果你在计算校验和与发送的过程中出现源数据改变的情况,就会导致数据帧校验失败,那么有什么解决办法?

如果说你坚持使用查询方式发送来节省部分空间,那么只要在计算校验值之前拷贝一份源数据,然后用这份数据计算并发送即可。

另一种方法就是,直接把整帧数据拷贝到一个数据缓存中,使用 DMA 发送。

现在还有一个问题,那就是如果我想发送一个数据域为空的数据该怎么发送?

一般来说,在使用指针的时候,不会使用 NULL 空指针,但是在数据为空的情况下,就需要使用 NULL 指针了,并长度设置为 0,这个时候在检查指针的时候,不能看到空指针就退出函数,还要判断长度信息,当长度为 0 时在打包时就不拷贝源数据,但最终还是要发送数据帧的(当时别人写的代码将指针和长度判断同时放到了 for 循环的条件里面,鱼鹰觉得效率太低,导致修改代码是没考虑指针为空的情况,所以导致了一个小 bug)。

互斥锁释放顺序

现在考虑第二个问题:互斥锁释放顺序问题。

如果没有采用队列方式接收数据,而是主机发送完成后等待接收从机数据后再发送下一帧数据,那么该如何处理互斥锁的问题?

我们知道为了保证数据的同步,保证在接收到一帧数据进行处理时,不能被新的数据帧冲掉,这时就要加入一个互斥锁,表示我正在处理数据,下面的数据我接收不了,这样就能保证你正在处理的数据不会被新来的数据修改掉,从而进行正确的处理。

那么这个标志位(互斥锁)该什么时候清掉(释放掉)呢?

一般来说标志位,一般越早清掉越好,比如外部中断标志位,进入中断后,一般首先会清理标志位,这样即使你正在处理本次中断的程序,那么即使这时再来了中断,也不会丢失中断信息(有悬起标志位),这样就可以在处理完这次中断后,立马进行下一次中断的处理了(前提是优先级足够高)。

但是如果你清理太早或者清理太晚会怎样?

比如说你在接收到一帧数据后(数据帧所有检查完成),开始设置标志位,当主程序查询到这个标志时(一般数据处理不会放在中断中),如果马上开始清除这个标志……嗯,一般来说不会有问题。

那么什么时候会出现问题?当你的主程序查询到这这个标志时开始清除标志,然后处理、返回数据给主机,如果此时主机超时重发数据时,,因为这个时候你虽然在处理数据,但是因为你的标志位已经被清除了,所以接收程序就会开始往接收缓存区存数据了,当你存完之后再回到数据处理那里,你的缓存区可能就不是你想要的数据了。

可能你会说,既然是重发,那么数据应该是相同的吧?好吧,你赢了,鱼鹰编不下去了,这种情况(有重发机制)下清理太早好像是不会出现问题,但你怎么知道对方是采用副本进行重发数据的呢,如果重发时它又从源数据中拷贝后再进行重发会出现什么问题?比如时间信息,开始第一帧数据是 11:59,CPU 刚把 11 拷贝到用户空间,被串口中断程序打断,导致下一帧接收的数据是 12:00,此时回到主程序继续拷贝,拷贝 00,数据的完整性被破坏,这样导致的结果就是 11:00,而实际上时间是 12:00,这就是你打断数据处理过程的后果。

现在再说说清理太晚会怎么样。

当你的主程序查询到这个标志后,暂时不清除,而是等到从机发送完应答数据之后再清除标志,此时因为从机采用查询方式(查询方式表明从机发送完最后一个字节后后开始清除标志位,也就代表了主机就差最后一个字节没有接收了,这样发送和清除之间间隔时间较短,而采用 DMA 方式的话,发送和清除的间隔时间更短,因为可能 DMA 还没开始发送第一帧数据,清除工作就已经完成了),或者因为其他原因(比如中断处理)导致发送和清除之间的时间很长这种特殊情况,这样可能主机已经开始下发下一帧数据了,但是因为此时标志还没有清除,不能接收数据,所以主机这一帧数据就这样丢失了。

那么这个清除标志位最合适的时机是什么时候?

你锁定资源利用完的时候。

现在来看看,这个互斥锁锁定的是什么资源?对,就是接收缓存,那么接收缓存什么时候用完?当然是在数据处理完成之时。也就是说在数据处理完之前、发送数据之前清除最合适。

这样就不会因为处理其他事情而导致清除操作过晚而丢失下一帧数据了,因为此时主机还没收到从机上传的数据,也就不会马上开始下一帧数据的传输了。

数据帧检查

你是否会对接收的数据进行检查?如果不进行检查会发生什么?

我们知道一帧数据中,每个部分都有各自的含义,甚至有些部分可能在某些数据帧中不存在,比如数据域部分,我们需要根据长度信息来判断数据域部分是否存在。

但是你能保证你所接收的数据都是准确的吗?你能确保在工作环境下不会因为各种干扰导致数据长度信息由 0x05 变成 0x85(最高位翻转)吗?,如果出现了会导致什么后果?

假设你采用 RXNE 中断方式来一个、一个字节的接收数据,分析如下:

因为是单字节接收数据,所以你需要把所有接收的数据当成数据流,根据帧头信息来确定帧的开始,一旦确定帧头信息之后,你就可以根据接下来的一系列数据保证一帧数据的结束,同时开始新帧的接收……

初看这个接收流程没有问题,但是真的如此吗?

但是就像前面所说,你能保证你的数据没有问题吗?如果说你接收到一个长度信息,本来是 0x05,但是最终接收的数据是 0x85,这就意味着你接下来的数据域的长度是 0x85,根据你的接收流程,你需要再接收 0x85 个字节之后,才能判断校验字节是否正确。

可能你会说,虽然你的长度信息由 0x05 变成了 0x85,之后接收校验过程肯定是失败的,那么这帧数据就会被接收程序丢弃,从而导致接收程序进入重新寻找帧头的流程,这个过程不是挺正常的吗?按理说上述异常情况是能被接收流程处理掉的。

那么首先确认一点,上述异常能被接收流程处理吗?

答案是能!

既然上述异常是能被接收状态机处理的,那么还会有什么问题?

问题就出在这个错误数据本身!

因为你是根据错误数据来决定接下来需要接收多少数据,而一般来说,接收缓存大小设置为最大帧的长度,那么就出现一个问题,你的缓存够你接收 0x85 个字节吗?

如果说你开辟的接收缓存空间很大,足够接收这么多数据,那么就算遇上以上情况,也是没有任何问题,但是万一你比较节省资源,缓存不够大会出现什么情况?

这就涉及到内存分配问题:

你的串口缓存一般在 Data 区域,一旦你接收的数据超出了你开辟的空间,那么必然导致缓存空间溢出!

那么缓存空间溢出会导致什么危害?

我们通过上图可以知道,一旦缓存溢出,必然导致该缓存周围的数据出现异常(数据被篡改),如果你的其它代码刚好需要这个数据作为重要参考,而你在使用的时候又没有对这个数据的有效性进行检查,那么可能导致另一个灾难性后果,而这个后果又导致了其他后果,从而导致雪崩效应。

而你修复这个 bug 时,你以为修复了,但你只修复了表面,真正内在 bug 还存在!

所以,千万别太相信内存中的数据,每一个数据的输入都要进行严格检查,这个数据可以错误,但是不能导致程序崩溃!

所以千万别写能篡改别人数据的代码,这是很危险的事情,也是很难解决的 bug,因为你不知道它会在什么时候篡改哪里的数据!

再假如你的接收缓存放在栈中了呢(稍微有 C 语言常识的程序员都不会把串口接收缓存放在栈中,但鱼鹰偏偏遇到过这种代码,而为了解决这个 bug 整整花了一星期,这还是在 bug 复现率高的情况下)?

根据前面的图可知,栈一般存放在高地址,并且一般栈生长方向为向低地址生长。如果出现上述情况(接收的数据大于开辟的栈缓存空间)会发生什么?

栈帧被破坏!

灰色部分因为接收的数据太多,导致原本存在的栈数据被串口的接收的数据修改了(注意篡改的数据可能不是连续的,因为每一次进入时,开辟的那部分栈空间可能都不在同一个地址),假如这个数据是保存返回寄存器 LR 的,那么必然导致返回错误,极可能触发 HardFault 中断!

那么有什么办法解决栈被破坏的问题?

最有效的方式鱼鹰觉得是使用 ITM,如果无法在线调试,可以尝试 DMA 循环传输 PC 指针值(但是如何得到这个值?毕竟这个寄存器本身是没有地址概念的)到一块内存中,这样就可以得到最后正常执行的代码地址,从而定位错误代码的位置。

如果单片机不支持这些功能呢?鱼鹰现有的知识体系好像无法解决,只能佛系调 bug 了(看和 bug 之间的缘分),囧。

前面说了由于外部工作环境导致数据长度信息错误从而出现数组溢出这种情况,如果说你保证工作环境非常好,不可能出现这种干扰,是否还会出现问题?

当然会!

前面分析了外在原因,现在分析内在原因,你的接收程序能保证及时接收发送端发送过来的数据吗?如果不及时接收数据会出现什么问题?

我们知道,一个系统一般都有很多中断需要处理,如果说你的接收程序的中断优先级不是最高的,那么很可能出现接收程序无法及时接收的情况,即 RXNE 中断来临时,因更高优先级中断需要处理,而且处理时间较长,那么就会出现当前接收的字节因为没有接收完成而被后续的数据冲掉,即出现 ORE(溢出错误)。

这样会导致什么问题?

数据域信息(也可能是校验值等数据)当成了长度信息(为什么只讨论长度,而不讨论功能字之类的数据,难道他们不会出现 ORE 的情况吗?),这样一来,如果这个数据很大,接收程序就会以为接下来还需要接收很多数据才能完成一帧的接收,导致后果和前面分析的数据干扰一样严重。

那么采用 RXNE 接收方式时该怎么解决这种问题?

检查长度信息的合理性,只要长度信息不会导致缓存溢出即可。

但是上面的解决方案会导致什么问题?

因为你的程序设计问题(采用 RXNE 接收导致不能及时处理),使得原本能接收的数据无法及时处理(DMA 可以及时处理),最终使得当前这一帧数据无法正常接收(如果错误的长度信息够大的话,还有可能接下来很多帧数据都无法接收),这你能接受吗?

但是采用 DMA 为什么就不会有上述问题,除了 DMA 能自动接收数据提高效率之外,还有一点就是它不根据接收的数据来判断接下来还需要接收多少数据,而是根据设定的接收数据长度来接收的(如果加入 IDLE 中断,可以提前结束接收工作),这就避免了上述的缓存溢出和接收不及时问题。

最后再分析上述接收的另一个问题,那就是一帧数据中可能出现没有数据域的情况,这种情况该怎么处理?

只要根据长度信息分开处理即可。如果不对没有数据域的情况分开处理,那么你接收的下一个数据直接就是校验值,而你的接收流程却认为这是数据域的数据,必然导致校验失败。

现在总结使用 RXNE 方式接收的几个问题:

1、缓存溢出。

缓存溢出有两种可能,第一种就是环境干扰导致长度信息出错,从而出现缓存溢出情况;第二种情况就是因为接收不及时,导致数据错位,如果刚好是长度信息错误,并且这个长度信息太大,而你的代码未对长度进行检查,那么也会出现缓存溢出 bug,而这种 bug 一旦出现,很难发现。所以在代码中对数据的合理性检查是非常有必要的一件事。

2、中断及时处理。

如果中断不及时处理,会导致数据错位,轻则丢失至少一帧数据,重则缓存溢出!

3、状态机是否需要接收数据部分。

由于数据帧有可能没有数据域的情况,所以必须区别处理,保证代码接收的准确性,否则有可能把校验值当成数据了,这样必然无法通过校验,这一帧数据必然会丢失!

串口空闲

前面一直提到串口空闲,也大概明白串口的作用,但是一些细节问题还是需要好好说一下的。

第一个问题,如何清除串口空闲中断标志位?

很多人会使用 USART_ClearFlag 标准库函数进行清除,但是当你跳转到该函数原型时,你会看到如下说明:

你会看到很多标志位是无法通过该函数清除的。

那么该如何清除 IDLE 标志呢?其实上面的注释已经进行了说明。

PE、FE、NE、ORE、IDLE 标志位的清除是通过一个软件序列进行清除的:首先通过 USART_GetFlagStatus 读取 USART_SR 寄存器的值,然后通过 USART_ReceiveData 函数读取 USART_DR 的值即可。

那么这里就有一个问题,是否这些标志问题的清除都要单独编写清除序列呢?

答案是否定的。

因为这些标志位都是由同一种序列进行清除的,所以只要一个清除序列就会把所有的标志位都进行清除了(同样一旦执行了这个序列,也就意味着你无法再通过 USART_SR 寄存器获得标志位了)。

为了保证获取标志位,我们可以在清除序列之前把 USART_SR 寄存器的值保存到副本中,然后再读取 USART_DR 寄存器的值保存到副本来实现清除功能,注意该序列应该无条件执行(不在某个判断语句中)。这样后续我们就可以使用这个 USART_SR 的副本判断哪一个标志置位了,同样也可以使用 USART_DR 的副本获取串口数据,而为了实现以上效果,USART_GetFlagStatus 这个函数就不合适了,只能直接操作寄存器去实现。

第二个问题,在线调试时对空闲中断会有影响吗?

我们知道,KEIL 能够将一个结构体的数据全部读取出来,而库函数将串口模块的所有寄存器都封装在一个结构体中,这样就会出现一个问题,如果你的窗口是实时刷新的,当你使用 KEIL 读取串口模块寄存器的时候(不管是使用 peripheral 窗口还是 Watch 窗口),就会出现先读取 SR 再读取 DR 的情况, 这样就有可能出现 KEIL 和单片机 CPU 读取这两个寄存器冲突的情况。

如果全速运行时,KEIL 先执行了这个序列(通过调试器读取这两个寄存器的值),单片机 CPU 再读取 SR 寄存值,必然是无法读取到正确标志位的,因为这些标志位已经被 KEIL 的读取序列清除了(这个情况鱼鹰确实碰到过,当时明明下发了数据,但是单片机无法获取标志位),所以在调试串口时,注意不要让 KEIL 去读取这些寄存器(即关闭这些窗口,只有在必须的情况下才开启),防止出现莫名其妙的情况。

第三个问题,空闲中断能准确触发吗?

如果从接收端考虑的话,如果触发了空闲中断,那么必然满足了条件才触发的,而不是意外触发的(嗯,我们要相信 STM32),但从发送端考虑的话,有可能出现一帧数据断续发送,导致一帧数据触发多次空闲中断,所以如果是简单的 DMA+空闲中断方式接收是很有问题的(空闲出现就认为一帧结束了,就会把一帧数据当成两帧处理,这样肯定无法通过数据检查的)。

那么先来分析为什么会出现一帧数据多次触发空闲中断情况。我们知道 linux、windows 系统并不是实时系统,当应用程序需要发送一帧数据时,可能并没有连续发送,而是发送完一个字节后去处理其他事情后才发送下一个字节,这样一来,如果耽误的时间够长,就会触发串口的空闲中断,从而一帧数据当成两帧处理了。

那有什么方法可以解决呢?鱼鹰提供两种解决思路。

第一种,使用两个缓存空间,一个缓存空间专门用于接收串口数据,将接收到的数据存放到另一个缓存,这个缓存采用字节队列的方式进行管理,应用程序从缓存队列中一个字节一个字节的取出数据进行处理(注意检查数据有效性),这样就能保证及时处理。但是因为空闲中断不再可靠,所以空闲中断不再作为判断一帧数据结束的依据(根据长度信息判断),而是只在空闲中断中将已接收数据复制到字节队列缓存中,这样就可以处理意外的空闲中断。

第二种,还是一个缓存空间,还是 DMA+空闲方式处理,但是需要增加额外的条件。就是当进入空闲中断后,不再直接处理,而是获取当前接收时刻,然后在处理数据的时候根据这个时刻来判断是否达到足够的空闲时间,只有在进入空闲中断后并达到一定延时之后才认为一帧数据结束了,这样可以避免一些非常短的空闲时间。

以上问题是就是鱼鹰以前使用空闲中断从未考虑的问题,鱼鹰并不知道使用空闲中断还可能出现误触发的情况,但是既然知道了,就要想办法解决。但是为什么以前使用空闲中断时没有出现通信问题呢?

事实上不是没有问题,而是有可能把分散的一帧数据的两部分直接丢弃了而已,因为有重发机制,所以即使丢弃一帧数据,也能通信正常,而且这种一帧数据分散成两部分的概率还是挺低的,ubuntu(linux 系统)下大概千分之三左右的样子。

第四个问题,如果单片机没有空闲中断又该如何做?

当我们使用 RXNE 的同时其实我们也可以使用空闲中断,这样也能确定一帧数据的结束(但是要注意前面的误触发问题)。但是如果有些低端单片机(如 51 )没有空闲中断又该怎么办?

其实我们可以从 stm32 的空闲中断得到相应的启发。

所谓空闲中断,就是当串口接收到数据后,在应该接收数据的时刻,发送方并没有发送数据,所以串口模块置位空闲标志位,从而引起空闲中断。

那么我们是否可以软件模拟串口模块的这个功能,从而确定一帧数据的结束呢?

答案是肯定的(前提是每一帧数据之间有空闲时间)。

我们可以使用一个定时器,定时器向上计数。当接收到一个字节数据后,初始化计数器并启动定时器,这样一旦有一段时间没有接收到串口数据(也就不再初始化计数器),那么定时器溢出,进入溢出中断,而这个溢出中断就类似于串口的空闲中断(在溢出中断中关闭定时器以达到清除空闲中断标志的作用),这样就达到了串口空闲中断的效果(和前面问题的第二种解决方案类似)。

通信吞吐量

在以上分析过程中,都是采用主机发送,从机接收后再回复主机的方式进行通信,虽然通信正常,但实际上效率比较低下,单位时间传输的数据量较少,如下图所示:

红色部分就是必要的空闲时间,可以看到左右两张图的通信频率是有差异的,右图中从机必须等待前一帧数据发送完毕才能处理数据,而左图可以在接收当前帧时处理上一帧数据,类似 CPU 的指令执行流水线。

(图片来源于《权威指南》)

我们也可以将串口接收分为二级流水线:接收、处理,如此一来,我们最少需要两个缓存空间,当一个缓存在接收时,另一个缓存就进行数据处理。发送端可能不等接收端发送完应答数据,它就已经开始发送下一帧数据了,只要相邻两帧数据保证一定发送间隔,就能正常触发中断。

同理,因为接收端也不再慢悠悠的等待接收数据,而是可能有好几帧数据等着它处理,所以为了确保发送端能正常触发空闲中断,也需要控制发送间隔。

为了最大程度利用串口,我们可以使用队列管理很多缓存空间(当只有两个缓存时,可以直接使用异或运算进行缓存切换),比如 uCOS II 中我们可以利用系统的内存管理服务和队列服务实现有效管理,并且当有非常紧急的通信任务时,还可以插入到队头优先处理。

但是增大吞吐量时,比如对重发机制和从机数据的确认有一定影响,需要考虑清楚。

如果要用一句话总结本篇笔记内容,那就是使用 空闲中断+DMA+队列+内存管理+定时控制 方式接收串口数据会是不错的选择。

本文转载自: 鱼鹰谈单片机
免责声明:本文为转载文章,转载此文目的在于传递更多信息,版权归原作者所有。

如何写一个健壮且高效的串口接收程序?

http://www.kch8.top/2022/04/02/embedded-serial-receive/

发布于

2022-04-02

更新于

2023-08-18

许可协议

评论