MATLAB :【11】一文带你读懂serialport串口收发原理与实现

碎碎念:

这周的主要工作还是集中于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’LF0x0A
Mac’\r’CR0x0D
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位无符号数。


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

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

  • 79
    点赞
  • 210
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 54
    评论
### 回答1: 您好!关于您的问题,我可以给出以下参考答案: CSharp中可以使用SerialPort实现串口的热插拔监听,当串口插拔事件触发时,就可以通过Win32_SerialPort获取串口名称。 具体的实现代码可以参考以下示例: ```CSharp using System; using System.IO.Ports; using System.Management; public class SerialPortMonitor { private ManagementEventWatcher portInsertWatcher; private ManagementEventWatcher portRemoveWatcher; public event EventHandler<string> PortInsertEvent; public event EventHandler<string> PortRemoveEvent; public void Start() { // 设置监听范围为Win32_SerialPort类 var query = new WqlEventQuery("SELECT * FROM __InstanceCreationEvent WITHIN 2 WHERE TargetInstance ISA 'Win32_SerialPort'"); portInsertWatcher = new ManagementEventWatcher(query); portInsertWatcher.EventArrived += PortInsertEventHandler; portInsertWatcher.Start(); query = new WqlEventQuery("SELECT * FROM __InstanceDeletionEvent WITHIN 2 WHERE TargetInstance ISA 'Win32_SerialPort'"); portRemoveWatcher = new ManagementEventWatcher(query); portRemoveWatcher.EventArrived += PortRemoveEventHandler; portRemoveWatcher.Start(); } public void Stop() { if (portInsertWatcher != null) { portInsertWatcher.Stop(); portInsertWatcher = null; } if (portRemoveWatcher != null) { portRemoveWatcher.Stop(); portRemoveWatcher = null; } } private void PortInsertEventHandler(object sender, EventArrivedEventArgs e) { var port = (string)e.NewEvent["TargetInstance"]["DeviceID"]; PortInsertEvent?.Invoke(this, port); } private void PortRemoveEventHandler(object sender, EventArrivedEventArgs e) { var port = (string)e.NewEvent["TargetInstance"]["DeviceID"]; PortRemoveEvent?.Invoke(this, port); } } ``` 以上是参考答案,希望能对您有所帮助。如果您有其他问题,可以继续向我提问。 ### 回答2: 在Windows 11系统中,可以使用C#编程语言实现串口的热插拔监听,并通过调用Win32_SerialPort类获取串口的名称。 首先,我们需要添加对System.Management命名空间的引用,该命名空间包含了用于管理Windows管理对象的类和接口。 接下来,在C#代码中,我们可以使用ManagementEventWatcher类来监听串口的插拔事件。创建ManagementEventWatcher对象时,我们需要指定一个查询语句,该查询语句用于过滤我们所关注的事件类型。 下面是一个示例代码,实现串口的热插拔监听,并通过Win32_SerialPort类获取串口名称: ```csharp using System; using System.Management; public class SerialPortListener { public static void Main() { // 创建查询语句,过滤串口插拔事件 string query = "SELECT * FROM Win32_DeviceChangeEvent WHERE EventType = 2"; // 创建ManagementEventWatcher对象,并指定查询语句 ManagementEventWatcher watcher = new ManagementEventWatcher(query); // 注册事件处理程序 watcher.EventArrived += new EventArrivedEventHandler(SerialPortEventArrived); // 开始监听事件 watcher.Start(); // 阻塞,防止程序退出 Console.WriteLine("Press any key to exit."); Console.ReadKey(); // 停止监听 watcher.Stop(); } private static void SerialPortEventArrived(object sender, EventArrivedEventArgs e) { // 获取Win32_DeviceChangeEvent事件的TargetInstance对象 ManagementBaseObject targetInstance = (ManagementBaseObject)e.NewEvent["TargetInstance"]; // 判断TargetInstance对象的类名是否为Win32_PnPEntity if (targetInstance != null && targetInstance.ClassPath.ClassName == "Win32_PnPEntity") { // 获取设备的名称 string name = (string)targetInstance["Name"]; // 判断设备名称是否包含"COM",用于过滤串口设备 if (name.Contains("COM")) { // 获取串口名称 int startIndex = name.IndexOf("(COM") + 1; int endIndex = name.IndexOf(")", startIndex); string serialPortName = name.Substring(startIndex, endIndex - startIndex); Console.WriteLine("Serial port {0} {1}.", serialPortName, e.NewEvent.ClassPath.ClassName); } } } } ``` 以上代码创建了一个SerialPortListener类,其中Main方法负责创建和启动ManagementEventWatcher对象,并注册了SerialPortEventArrived方法作为事件处理程序。 SerialPortEventArrived方法会在串口热插拔事件发生时被调用,根据事件对象获取串口设备的名称,并输出到控制台。 注意,该示例代码中的过滤条件是根据Win32_DeviceChangeEvent事件的EventType属性进行筛选,EventType等于2表示设备插入事件,等于3表示设备移除事件。如果你只关注特定的串口设备,可以进一步调整过滤条件。 希望以上回答对你有所帮助! ### 回答3: 在 Windows 11 中通过 C# 实现串口热插拔监听可以通过使用 ManagementEventWatcher 来监视系统对串口的操作。同时,可以通过调用 WMI (Windows Management Instrumentation) 来获取串口名称。 首先,我们需要引入 System.Management 命名空间,使用 ManagementEventWatcher 和 WMI 进行操作。在代码中,我们创建一个 ManagementEventWatcher 对象,并使用 WQL (Windows Management Instrumentation Query Language) 查询串口相关的 WMI 事件。 ```csharp using System; using System.Management; class Program { static void Main() { // 设置查询条件,筛选出串口变化和状态变化的事件 string query = @"SELECT * FROM __InstanceOperationEvent WITHIN 1 WHERE TargetInstance ISA 'Win32_PnPEntity' AND TargetInstance.Description LIKE 'Communications Port%'"; // 创建 ManagementEventWatcher 对象 ManagementEventWatcher watcher = new ManagementEventWatcher(new WqlEventQuery(query)); // 注册事件处理程序 watcher.EventArrived += new EventArrivedEventHandler(SerialPortChanged); // 启动监视 watcher.Start(); // 等待退出信号 Console.WriteLine("按任意键退出..."); Console.ReadKey(); // 停止监视 watcher.Stop(); } // 事件处理程序 static void SerialPortChanged(object sender, EventArrivedEventArgs e) { // 从事件参数中获取 TargetInstance 对象 ManagementBaseObject targetInstance = (ManagementBaseObject)e.NewEvent["TargetInstance"]; // 获取串口名称 string portName = (string)targetInstance["Caption"]; Console.WriteLine("串口变化:{0}", portName); } } ``` 上述代码中,我们使用 WMI 查询串口设备的变动,包括插入、拔出和状态变化。如果在监视状态下插入或拔出串口设备,事件处理程序 SerialPortChanged 将捕获到对应的事件,并获取串口的名称。你可以根据具体的需求对事件处理程序进行扩展,例如增加通知、记录日志等操作。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 54
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Alex-YiWang

不要打赏,想要一个赞

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值