MATLAB中实现串口收发原理:一步一步带你了解

碎碎念:

这周的主要工作还是集中于FOC中,因为羡慕稚晖君做出的漂亮Qt面板,因此在利用MATLAB复刻过程中,学习了一下serialport的使用。FOC的GUI部分就在加班加点写作中啦,同时最近打算开一个新坑,大家可以期待一下哈哈哈。

欢迎大佬们点赞+收藏+关注~ o(* ̄▽ ̄*)ブ

目录

1 串口接收

2 串口发送


考虑到互联网中对MATLAB中最新的serialport的使用案例有些混乱,并且很多都是基于已经被淘汰的serial库,严重缺乏易用性,因此在本文中给出简单的串口收发模板,特别是串口回调函数的使用案例。

1 串口接收

串口接收是指,开发板将数据发送给电脑,电脑读取数据并进行数据分析处理的过程。

想弄清楚怎么接收串口的数据,那你首先就需要知道串口的数据是怎么发送出来的

试想这样的应用场景,我的开发板上安装了一个温度传感器,温度传感器采集的数据长度是3字节(24比特);我需要将开发板采集到的温度信息实时显示在屏幕上,我需要怎么做?

这其中需要注意的有下面几点:

  1. 温度传感器是3字节的,如何确定接收到的某一个字节位于三个字节中的哪个位置?
  2. 实时显示要求我需要对每次发送过来的数据做出响应,这种响应需要怎么做?

针对问题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秒里面,又有新的字符传过来,那么这个字符将丢失。

于是,研制人员想了个办法解决这个问题,就是在每行后面加两个表示结束的字符。一个叫做“回车”,告诉打字机把打印头定位在左边界;另一个叫做“换行”,告诉打字机把纸向下移一行。这就是“换行”和“回车”的来历,从它们的英语名字上也可以看出一二。

后来,计算机发明了,这两个概念也就被般到了计算机上。那时,存储器很贵,一些科学家认为在每行结尾加两个字符太浪费了,加一个就可以。于是,就出现了分歧。在不同的系统中,就出现了下面的状况:

系统 符号 名称 十六进制(ASCII)
Linux ’\n’ LF 0x0A
Mac ’\r’ CR 0x0D
Windows ’\r\n’ CR/LF 【0x0D 0x0A】

注:这里并不是说在Windows系统中只能使用CR/LF作为帧尾,表格里说的是对应系统本文编辑器中的默认换行符。

是不是感觉豁然开朗?那我们就可以理所当然的将之前的图改为下面的样子(最左端为最先接收到的字节):

读到这里,我想读者朋友们已经逐渐理解了最开始所说的:想弄清楚怎么接收串口的数据,那你首先就需要知道串口的数据是怎么发送出来的。回想一下我们的思路,因为要实现多字节读取,所以需要给一个固定的帧头用来确定每个字节的位置;为了提供一个可以激活回调函数的信号,并且不影响帧头的存在,我们需要添加一个帧尾。结合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

2 串口发送

串口发送是指,电脑将需要发送的数据(一般是指令或者参数设置信息)整合好,发送给开发板的过程。

相信有了前面串口接收的基础,这对大家来说就非常简单了,在这里,我们还是假设一个应用场景来进行讲解,由于很对读者会使用到GUI进行串口发送的测试,这里我们就以GUI中的文本输入框的数据格式为例。

在GUI中,我需要将一个十六进制字符串“FF 01 02 03 04”发送给开发板,我需要怎么做?GUI如下图所示,是“文本区域”类型的模块:

这里需要注意下面的问题:

  1. 如何从GUI中获取数据(仅限于GUI使用时,如果是脚本文件,则需要按照字符串来进行处理)。
  2. GUI中获取到的数据,实际上是cell类型,而不是单纯的字符串类型(仅限于GUI使用时,如果是脚本文件,则需要按照字符串来进行处理)。
  3. 如何将数据进行分割并发送。

这里由于三个问题相当明确且容易解决,因此我直接给出串口发送函数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位无符号数。


首次尝试这样的写作方式,希望本篇文章能够给读者一些帮助,同时由于本人水平有限,如果有一些问题的话,请务必留言指出,我一定虚心接受!

这就是本期的全部内容啦,如果你喜欢我的文章,不要忘了点赞+收藏+关注,分享给身边的朋友哇~

物联沃分享整理
物联沃-IOTWORD物联网 » MATLAB中实现串口收发原理:一步一步带你了解

发表评论