碎碎念:
这周的主要工作还是集中于FOC中,因为羡慕稚晖君做出的漂亮Qt面板,因此在利用MATLAB复刻过程中,学习了一下serialport的使用。FOC的GUI部分就在加班加点写作中啦,同时最近打算开一个新坑,大家可以期待一下哈哈哈。
欢迎大佬们点赞+收藏+关注~ o(* ̄▽ ̄*)ブ
目录
1 串口接收
2 串口发送
考虑到互联网中对MATLAB中最新的serialport的使用案例有些混乱,并且很多都是基于已经被淘汰的serial库,严重缺乏易用性,因此在本文中给出简单的串口收发模板,特别是串口回调函数的使用案例。
串口接收是指,开发板将数据发送给电脑,电脑读取数据并进行数据分析处理的过程。
想弄清楚怎么接收串口的数据,那你首先就需要知道串口的数据是怎么发送出来的。
试想这样的应用场景,我的开发板上安装了一个温度传感器,温度传感器采集的数据长度是3字节(24比特);我需要将开发板采集到的温度信息实时显示在屏幕上,我需要怎么做?
这其中需要注意的有下面几点:
针对问题1:
其实这也是初学者常遇到的问题,有时候串口发送的数据就像一个堵不住的水管,完全不知道要怎么处理。
由于串口协议的限定,导致其每次发送的只能是一个字节,对于多字节的数据【ABC】来说,就只能通过三个字节【A】、【B】、【C】来发送,如下图所示(最左端为最先接收到的字节):
这就显然会遇到问题,在任意一个时刻,我没办法确定接收到的数据到底处于【ABC】的哪一个位置;更致命的是,由于物理介质的影响,甚至可能会造成数据的丢失,这就更给数据的接受造成了影响。
如何解决这一问题呢?人们开始想到了“打包”的方式,也可以理解为我们常说的“帧”的概念。只要在每组数据的开头加一些标志,表示出这是数据的最开始位置不就好了,即为下图所示(最左端为最先接收到的字节):
假设我们设置的这个标志为【FF、FF】,当上位机检测到连续的两个【FF】时,就表示之后的三个字节分别为【A】、【B】、【C】。
这其实就解决了这个问题1,实现了对一帧中每一个字节的位置确定。
针对问题2:
解决问题1后,我们当然可以利用顺序执行的方式,来实现对串口数据的一次读取以及数据处理。但是如何实现当每一次检测到特定信号,就调用一次数据处理函数呢?
这就要先理解一下MATLAB中serialportlist的使用逻辑了,整体来说serialportlist是对serial的升级版本(在帮助页面也有提到),其通过构建SerialObject对象的方式,来实现串口参数的设置以及读写。
具体细节可以参考MATLAB文档serialport,太全面的参数设置过于冗余,不在本文讨论范围内。这里主要介绍两个比较重要的概念缓冲区以及回调函数。
缓冲区:
在serialport中,缓冲区是自动存在于SerialObject对象中,但是有时使用时(如本文)不需要针对性设置缓冲区的大小。可以理解为一个长度固定的FIFO队列,当检测到特定信号的时候,将串口传入的每一个字节的数据,按顺序保存在里面,当长度满了之后,就不再继续在里面添加新的数据了。
可能会使用到的函数为
flush(SerialObj)
可以用来清空缓冲区,常常用在串口对象初始化的时候。
回调函数:
这个是解决问题2的关键,回调函数可以理解为一个开关被触发后需要进行的操作(或者简单理解为单片机的中断处理函数);我们可以通过SerialObject的对象设置,来设置检测到什么信号(这个信号是作为一帧的结尾)的时候,执行回调函数。
举个方案A作为例子,我们可以设置检测到【FF FF】信号的时候,执行三个字节的数据读取。(尽管不这样用,后面会说为什么)
如上图所示,当我们按照上面方案A的方式,设置回调函数的触发条件,有什么问题呢?每当检测到【FF FF】的时候,就会触发回调函数。
看似没问题,但是此时一帧的组合已经从【FF FF A B C】变成了【A B C FF FF】,因为我们提到回调函数敏感的是一帧的结尾。检测到【FF FF】时,下一个字节显然就是【A】。这其实是不规范的,我们不能理所当然地认为每一帧都是传输正确的。
举个例子:
【 A B C FF FF】【 A B C FF FF】【 A B C FF FF】【 A B C FF FF】【 A B C FF FF】
中间红色的ABC表示因为数据线接触不良导致的传输错误,如果具有固定帧头的话,或许帧头也会出现错误,从而直接跳过这一帧错误的信号【 A B C FF FF】。
因此必须通过固定的帧头来确定此时传输的是否是完整的数据。
这就需要我们进一步对一帧的结构,进行修改了,让其完整地包含“帧头”与“帧尾”。在MATLAB中给出了configureTerminator的方法,可以编辑SerialObject需要检测到的帧尾信号。详细解释可以看configureTerminator官方文档,其中有这样的介绍:
configureTerminator(t,terminator) defines the terminator for both read and write communications with the remote host specified by the TCP/IP client t. Allowed terminator values are "LF" (default), "CR", "CR/LF", and integer values from 0 to 255. The syntax sets the Terminator property of t.
这里提到,我们可以设置需要检测帧尾信号为“LF”、“CR”、“CR/LF”或一个0-255的整数(刚好对应了8位无符号数,也就是一字节)。
按照上面的说明,我们可以对之前的帧进行下图的修改,加上帧尾(最左端为最先接收到的字节):
这样,我们就可以利用检测帧尾(橙色部分),来实现对回调函数的调用啦。但是新的疑问又诞生了:我理解0-255的数字怎么发送,但是这毕竟是单字节的,会不会造成数据读取混乱?上文提到的“LF”、“CR”、“CR/LF”这三个又是什么?(这也是困扰了我一段时间的问题)
“LF”、“CR”、“CR/LF”概念解释:
引用自:CR,LF详解_Berwyn丶的博客-CSDN博客_cr的16进制
从起源上来说,在计算机还没有出现之前,有一种叫做电传打字机(Teletype Model 33,Linux/Unix下的tty概念也来自于此)的玩意,每秒钟可以打10个字符。但是它有一个问题,就是打完一行换行的时候,要用去0.2秒,正好可以打两个字符。要是在这0.2秒里面,又有新的字符传过来,那么这个字符将丢失。
于是,研制人员想了个办法解决这个问题,就是在每行后面加两个表示结束的字符。一个叫做“回车”,告诉打字机把打印头定位在左边界;另一个叫做“换行”,告诉打字机把纸向下移一行。这就是“换行”和“回车”的来历,从它们的英语名字上也可以看出一二。
后来,计算机发明了,这两个概念也就被般到了计算机上。那时,存储器很贵,一些科学家认为在每行结尾加两个字符太浪费了,加一个就可以。于是,就出现了分歧。在不同的系统中,就出现了下面的状况:
注:这里并不是说在Windows系统中只能使用CR/LF作为帧尾,表格里说的是对应系统本文编辑器中的默认换行符。
系统 符号 名称 十六进制(ASCII) Linux ’\n’ LF 0x0A Mac ’\r’ CR 0x0D Windows ’\r\n’ CR/LF 【0x0D 0x0A】
是不是感觉豁然开朗?那我们就可以理所当然的将之前的图改为下面的样子(最左端为最先接收到的字节):
读到这里,我想读者朋友们已经逐渐理解了最开始所说的:想弄清楚怎么接收串口的数据,那你首先就需要知道串口的数据是怎么发送出来的。回想一下我们的思路,因为要实现多字节读取,所以需要给一个固定的帧头用来确定每个字节的位置;为了提供一个可以激活回调函数的信号,并且不影响帧头的存在,我们需要添加一个帧尾。结合configureTerminator中的设置信号,我们发现可以使用“LF”、“CR”、“CR/LF”或者0-255的数字作为帧尾让回调函数激活,通过查阅原来前面的三个“LF”、“CR”、“CR/LF”说的是换行符的ASCII码,我们可以使用开发板让他们发出对应的十六进制数据来表示。
至此,我们知道了数据从开发板上发送出来时的结构。对比四种帧尾,只有“CR/LF”是两个字节的,对于温度这种未知的数据信号来说,是最稳妥的,可以更好的避免出现雷同情况,导致读取错误。
举个例子:
当我们发送的数据是:【FF FF A B C 帧尾】。
当帧尾是1字节很有可能出现【C】与【帧尾】相同的情况,如果【帧尾】是两字节,【B C】与之雷同的情况则会概率减小很多。
因此我们选择在开发板中按照下图的方式来发送数据给上位机(最左端为最先接收到的字节),这需要先在开发板中定义好,本文默认读者已经完成了这部分,如果有需要的话,读者也可以留言给我,我会单独出一篇文章进行讲解:
那么现在就可以开始激动人心(bushi)的MATLAB编程环节啦,基于MATLAB文档serialport,下面给出一个简单的模板:
Port_List = serialportlist("available");
SerialObj = serialport("COM7",115200);
configureTerminator(SerialObj,"CR/LF");
flush(SerialObj);
SerialObj.UserData = struct("Data",[]);
configureCallback(SerialObj,"terminator",@readSerialData);
% 回调函数
function readSerialData(src, ~)
data = read(src,7,"uint8");
src.UserData.Data = data;
ShowTemp(src);
end
% 温度数据处理与展示
function ShowTemp(src)
if(src.UserData.Data(1:2) == [0xFF 0xFF])
Temperature = src.UserData.Data(3)*256*256 + src.UserData.Data(4)*256 + src.UserData.Data(5);
disp(Temperature);
end
end
下面对代码进行一下讲解:
Port_List = serialportlist("available");
展示出当前系统中可用的串口列表,与电脑设备管理器中的端口是对应的。
SerialObj = serialport("COM7",115200);利用serialport函数来构造一个串口对象SerialObj,设定对应的端口是COM7端口,波特率是115200。
configureTerminator(SerialObj,"CR/LF");设置需要检测到的帧尾是"CR/LF"。
flush(SerialObj);清空串口对象的接收缓冲区。
SerialObj.UserData = struct("Data",[]);通过查看SerialObj对象的属性,可以看到其中存在一个属性叫做UserData,可以用来存储数据,这里我们将其定义为一个结构体,里面自行定义只有一个叫做Data的数据。
configureCallback(SerialObj,"terminator",@readSerialData);指定回调函数,也就是第三个属性提到的readSerialData函数,表示检测到帧尾后需要进行的操作。“terminator”参数的意思是检测结束符,读者只需要修改最后一个参数readSerialData即可。
function readSerialData(src, ~)定义回调函数,src表示自定传入的对象,因此不需要进行修改。
data = read(src,7,"uint8");read函数表示从串口对象中读取7字节的数据,因为是从检测到结束符后面开始的也就是【FF FF A B C 0D 0A】这7个字节的内容。“uint8”表示读取的是8位无符号数。值得注意的是,这部分还有其他的函数可以使用,例如用来读取一行字符的readline函数,同样在MATLAB文档serialport有明确介绍。
这里其实就可以进一步理解CR/LF之所以是换行符的原因了,从一个换行符读取到另一个换行符之间,不就是读取一行(readline)的含义吗?
src.UserData.Data = data;将读取到的数据data存储到对象属性UserData里面的结构体下的Data中,实现数据的存储。数据的存储方式是一个长度为7的数组,可以直接利用索引1-7进行调用。
ShowTemp(src);
调用数据处理的函数,用来预处理和显示接收到的数据。
end
function ShowTemp(src)定义数据处理函数
if(src.UserData.Data(1:2) == [0xFF 0xFF])使用if语句,判断数据头是否是【FF FF】,确定是否有传输错误。
Temperature = src.UserData.Data(3)*256*256 + src.UserData.Data(4)*256 + src.UserData.Data(5);之后的三个字节是【A B C】,每个是8比特,因此要乘以它们的权值进行计算,获得原始的数据。
disp(Temperature);展示当前的数据到控制台。
end
end
串口发送是指,电脑将需要发送的数据(一般是指令或者参数设置信息)整合好,发送给开发板的过程。
相信有了前面串口接收的基础,这对大家来说就非常简单了,在这里,我们还是假设一个应用场景来进行讲解,由于很对读者会使用到GUI进行串口发送的测试,这里我们就以GUI中的文本输入框的数据格式为例。
在GUI中,我需要将一个十六进制字符串“FF 01 02 03 04”发送给开发板,我需要怎么做?GUI如下图所示,是“文本区域”类型的模块:
这里需要注意下面的问题:
这里由于三个问题相当明确且容易解决,因此我直接给出串口发送函数write的使用案例:
Port_List = serialportlist("available");
SerialObj = serialport("COM7",115200);
send_data = get(app.TextAreaTabSend, "value");
HEX = hex2dec(strsplit(cell2mat(send_data), " "));
write(app.SerialObject,HEX,"uint8");
下面对代码进行一下讲解:
Port_List = serialportlist("available");
展示出当前系统中可用的串口列表,与电脑设备管理器中的端口是对应的。
SerialObj = serialport("COM7",115200);利用serialport函数来构造一个串口对象SerialObj,设定对应的端口是COM7端口,波特率是115200。
send_data = get(app.TextAreaTabSend, "value");从GUI中获取当前TextArea中的值信息,返回的时cell类型的数据。
HEX = hex2dec(strsplit(cell2mat(send_data), " "));从内层到外层,依次完成cell2mat()将cell类型转为mat类型;strsplit()将mat类型按照空格进行分割;hex2dec()将字符串视为hex类型的数据转为十进制进行传输。
如果是单纯的字符串操作,则换为下面的函数即可:
HEX = hex2dec(strsplit(send_str, " "));
将字符串先进行分割,然后转为十进制的数组。
注意,这两种写法我都是默认,发送的信息必须每个字节之间使用空格进行分割处理,因为使用的时write函数,并且是uint8类型。
write(SerialObj ,HEX,"uint8");将数据HEX发送给SerialObj对象,实现发送。这里使用的是write函数,其实还有另一个函数writeline,读者可以参考MATLAB文档serialport进行查阅。
至此,就完成了全部的数据收发任务啦,当需要关闭串口时,只需要使用下面的函数,删除创建的对象即可。
delete(SerialObj); %通过删除对象来断开串口
最后再提及一下,为什么我都是使用的write以及read的uint8类型呢?一方面我们的应用环境还是数字的传输为主,字符串的传输这里并没有怎么涉及到。另一方面,逐个字节的收发,在我看来是更方便理解其中串口协议原理的,并且ASCII本身就是8位无符号数。
首次尝试这样的写作方式,希望本篇文章能够给读者一些帮助,同时由于本人水平有限,如果有一些问题的话,请务必留言指出,我一定虚心接受!
这就是本期的全部内容啦,如果你喜欢我的文章,不要忘了点赞+收藏+关注,分享给身边的朋友哇~