ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Matlab GUIDE串口上位机开发实战:从配置到收发与实时曲线

Matlab GUIDE串口上位机开发实战:从配置到收发与实时曲线 做上位机、连下位机、控制硬件Matlab GUIDE在串口操作这一块可以说是很多老工程师的首选。虽然现在MathWorks官方主推的是App Designer但GUIDE作为Matlab图形界面开发的元老级工具仍然大量存在于现网项目、老设备维护和科研实验中。它最大的优势在于拖拽控件生成.fig和.m文件自动生成回调函数框架配合Matlab自带的serial/serialport串口对象可以在一个界面里同时完成参数配置、数据收发、曲线绘制和状态监控。这篇文章我会把GUIDE做串口上位机的完整思路、关键代码、调试经验全部分享出来适合刚接触GUI开发、又需要快速做一套串口调试工具的工程师和学生参考。1. GUIDE串口上位机的整体思路与选型1.1 为什么选GUIDE而不是App Designer或纯命令行先说结论如果你的项目还在维护老代码、依赖旧版MatlabR2016a之前或者只是临时做一个实验室调试工具GUIDE完全够用。它生成的界面代码结构清晰回调函数自动关联比用代码一行行拼Figure控件要直观得多。App Designer虽然界面更现代支持Grid布局、Tab组件但它的对象句柄体系和GUIDE完全不同老代码迁移成本高。而且很多高校实验室的Matlab版本可能还停留在R2016b、R2018a这个版本区间GUIDE是稳定工作的。纯命令行方式也不是不行但串口调试的核心痛点是数据是异步到达的、需要持续显示、需要人工干预发送。这种交互场景下没有GUI你会被逼疯。我自己早期做过一个用命令行fprintfplot的临时方案数据一来整屏刷单别说分析了连正常停止都费劲。GUIDE适合你的场景一般有三个特征一是界面控件不算复杂二三十个控件以内二是需要快速迭代今天加一个按钮明天加一个显示区域三是项目要能在不同电脑上跑起来GUIDE打包相对简单。1.2 串口上位机的核心功能拆解一套完整的GUIDE串口上位机至少要包含这几部分串口配置区端口号下拉框、波特率下拉框、数据位、停止位、校验位、打开/关闭按钮数据接收区实时显示接收到的数据文本或十六进制带清空按钮数据发送区输入框、发送按钮、可选的定时发送功能数据可视化区用axes绘制实时波形这是Matlab的强项纯C#或Python做起来要费不少劲状态栏显示串口连接状态、收发字节数、时间戳功能拆解阶段就要想清楚数据流下位机通过串口把数据帧发上来Matlab串口对象接收触发回调回调函数解析数据、更新界面显示、追加到缓冲区、刷新曲线。界面上的按钮则触发主动发送或参数配置。整个数据流是异步的代码必须围绕回调来设计这和写普通脚本的思路差异很大。2. 串口通信的关键参数与GUIDE中的对象模型2.1 串口五要素波特率、数据位、停止位、校验位、流控做串口操作之前这几个参数必须和下位机约定一致否则收到的就是一串乱码。波特率比较好理解就是每秒传输的比特数。常见的有9600、115200、460800等。不是越高越好高频下对线路质量要求高实验室短距离用115200没问题工业现场有时反而故意降到9600来保证稳定。数据位一般是8位老式设备可能用7位。停止位通常1位也有1.5位和2位实际基本都用1位。校验位有none、even、odd、space、mark五种绝大多数场景用none或者even。流控部分新手容易忽略。这里的流控指硬件流控RTS/CTS、DTR/DSR和软件流控XON/XOFF。在Matlab里FlowControl属性有none、hardware、software三档。默认none就够了因为当前大部分USB转串口设备并不强制接握手线。如果你使用的是DB9真串口而且下位机启用了流控那就要对应配置。注意配置这些参数时一定要先查看下位机程序源码或者设备手册不能靠猜。我遇到过一整个团队调试两天最后发现是下位机用的偶校验而上位机配置成了无校验。2.2 串口对象两个时代serial与serialportMatlab操作串口有两个对象体系。早期版本R2019b之前官方推荐serial后来R2019b引入了serialport属于新的基于system object风格的接口。两者用起来差别很大serial老接口的核心是配置回调函数属性比如BytesAvailableFcn、BytesAvailableFcnCount需要配合fopen/fclose来管理生命周期。好处是习惯了之后非常灵活坏处是属性名繁多新手容易迷失。serialport新接口更加面向对象直接把读取方法暴露为read、readline、write等也有configureCallback配置回调。但在GUIDE老项目里你不用特意抛弃serial它至今依然在常用工具集中可用。如果是从零开始新项目我建议优先用serialport代码更干净但如果是维护老GUIDE示例或老项目直接用serial即可不用来回迁移。为了保证这篇文章既兼容老项目又有扩展性下面的核心代码我以serial为主线关键的serialport用法我会单独标注。2.3 缓冲区与回调机制到底是怎么回事串口数据到达是不可预测的。如果主界面线程一直在执行别的操作数据来了没人管就会被系统丢弃或者积压在缓冲区。Matlab的解决思路是这样每个串口对象内部有一个接收缓冲区InputBuffer和发送缓冲区OutputBuffer数据到了之后先存在InputBuffer里。然后你设置一个触发条件当缓冲区内数据量达到阈值时Matlab自动调用你提前挂好的回调函数。常见的触发模式有两种按字节数触发设置BytesAvailableFcnMode为byteBytesAvailableFcnCount设为16那么缓冲区内每达到16字节就触发一次回调。按终结符触发设置BytesAvailableFcnMode为terminator当收到换行符等终结符时触发回调。适合处理ASCII码文本协议。回调函数的签名一般是function serialCallback(obj, ~, handles) data fread(obj, obj.BytesAvailable); % 处理数据 end这里obj就是触发回调的串口对象本身handles是GUI的句柄结构体。调用回调时Matlab会自动把串口对象传进来所以不需要再用全局变量。2.4 GUI数据传递有人被handles卡了一整天GUIDE中所有控件回调函数的第一个参数是hObject第二个是eventdata第三个是handles结构体。handles里保存着所有控件的Tag和对应的句柄例如handles.editReceive就是接收编辑框的句柄。要注意的是回调函数里对handles做了修改之后必须调用guidata(hObject, handles)才能把变更保存回GUI主数据区。否则你下一次回调里看到handles还是旧的。这个细节导致的问题非常隐蔽。很多人发现数据接收区半天不刷新问题往往出在这里。% 在回调中更新编辑框 set(handles.editReceive, String, receivedText); % 更新handles handles.lastData receivedText; guidata(hObject, handles); % 这句话必须调用3. GUIDE界面布局搭建一个串口上位机面板3.1 创建GUIDE工程与整体布局规划打开Matlab在命令行输入guide回车或者从主页标签页选择新建图形用户界面。选择Blank GUI模板GUIDE会生成一个未命名的.fig文件和一个同名的.m文件。.m文件里已经自动生成了OpeningFcn、OutputFcn和每个控件的回调框架我们只需要往里填逻辑。布局规划建议遵循从上到下、从左到右的信息流原则。我的常用布局方案是这样顶部横排串口参数配置区端口、波特率、打开按钮中部分成左右两块左侧接收数据编辑框右侧发送数据编辑框中下部或底部一个占大面积的axes坐标轴用来画波形最底部状态栏用一个text控件显示状态信息这种布局的好处是符合操作习惯而且当数据滚动更新时不容易误点其他控件。拖拽好控件之后一定要给每个控件设置一个有意义的Tag比如editPort、editBaudRate、btnOpenSerial、btnSendData、axesPlot。默认生成的Tag是edit1、edit2这种如果连续沿用几十个控件后期写代码你绝对会乱。3.2 用下拉框动态枚举串口列表GUIDE里加一个可编辑下拉框Popup Menu它的String属性可以填入一串候选值。但在实际电脑上串口列表是动态变化的拔插USB转串口设备之后COM号会变。所以要在OpeningFcn里动态填充所有可用串口% 在OpeningFcn中初始化 ports serialportlist(available); % serialport新版写法 % 如果是serial老接口可用 instrfind 和 instrhwinfo if isempty(ports) ports {COM1}; % 兜底 end set(handles.popPort, String, ports); set(handles.popPort, Value, 1);如果你还在使用老版本Matlab可以用instrhwinfo(serial)获取串口列表info instrhwinfo(serial); ports info.AvailableSerialPorts;instrhwinfo现在依然能用只不过MathWorks已经将其标记为不再继续增强新代码建议用serialportlist。3.3 添加数据发送区和显示区在GUIDE中拖入两个Edit Text控件一个Tag设为editReceive一个Tag设为editSend。editReceive要勾选Multiline属性同时将Max属性改为大于1的数比如2这样它才能显示多行内容。同理editSend也设置Max大于1但不需要太大两三行即可。显示区内码值会越来越多我习惯倒序显示最新数据把文本作为一个行堆栈来管理。一种简单办法是每次追加后用strsplit按换行拆分只保留最近500行function appendText(hEdit, newLine) old get(hEdit, String); if iscell(old) old char(old); end if ischar(old) allLines cellstr(old); else allLines {}; end allLines{end1} newLine; %#okAGROW if length(allLines) 500 allLines allLines(end-499:end); end set(hEdit, String, allLines, Max, length(allLines)1); drawnow; end这里调用了drawnow强制GUI刷新。如果你在循环里不断set字符串但忘了drawnow界面会假死不更新。4. 串口核心代码实现从打开到收发的完整闭环4.1 打开与关闭串口的封装这里涉及一个必须处理的坑Matlab关闭串口对象时默认并不会把端口释放给其他程序。如果上一次运行异常退出端口会处于被占用状态。因此打开串口之前最好用instrfind清理遗留对象。% —— 打开串口按钮回调 —— function btnOpenSerial_Callback(hObject, eventdata, handles) % 清理遗留串口对象 oldObjs instrfind(Type, serial); if ~isempty(oldObjs) fclose(oldObjs); delete(oldObjs); end % 读取下拉框配置 portCell get(handles.popPort, String); portIdx get(handles.popPort, Value); portName portCell{portIdx}; % 注意String可能是char数组 baudStr get(handles.popBaud, String); baudIdx get(handles.popBaud, Value); baudRate str2double(baudStr(baudIdx, :)); % popup的String是字符矩阵 s serial(portName, BaudRate, baudRate, ... DataBits, 8, StopBits, 1, Parity, none); s.InputBufferSize 8192; s.OutputBufferSize 8192; s.Timeout 2; s.FlowControl none; s.BytesAvailableFcnMode byte; s.BytesAvailableFcnCount 16; s.BytesAvailableFcn (src, evt) readSerialCallback(src, evt, handles); fopen(s); handles.serialObj s; handles.isSerialOpen true; guidata(hObject, handles); set(handles.btnOpenSerial, String, 关闭串口); set(handles.textStatus, String, [已连接 portName num2str(baudRate)]); end关闭串口的回调function btnCloseSerial_Callback(hObject, eventdata, handles) if isfield(handles, serialObj) ~isempty(handles.serialObj) fclose(handles.serialObj); delete(handles.serialObj); handles.serialObj []; end handles.isSerialOpen false; guidata(hObject, handles); set(handles.btnOpenSerial, String, 打开串口); set(handles.textStatus, String, 未连接); end这里我特意把打开和关闭逻辑分开写在两个按钮里实际操作时也可以合并成一个按钮用状态判断是打开还是关闭。逻辑更简单的方式是判断handles中是否有有效串口对象有则关闭无则打开。4.2 数据接收回调十六进制还是ASCII别搞混GUIDE中串口回调的触发是在后台进行的不需要用户干预。我们在这个回调里拿到的是原始字节流接下来说什么格式完全取决于下位机协议。如果下位机发的是可打印的ASCII字符串可以直接用fscanf读字符串function readSerialCallback(obj, ~, handles) if obj.BytesAvailable 0 rawStr fscanf(obj, %s); newLine [datestr(now, HH:MM:SS.FFF) rawStr]; appendText(handles.editReceive, newLine); end end但很多工业设备发的是二进制十六进制字节比如温度传感器的数据帧是AA 01 88 3C 55这种。这时就不能用fscanf了而是要改用fread读取二进制再转换成十六进制字符串显示function readSerialCallback(obj, ~, handles) bytesAvailable obj.BytesAvailable; if bytesAvailable 0 dataBytes fread(obj, bytesAvailable, uint8); hexStr upper(strtrim(sprintf(%02X , dataBytes))); newLine [datestr(now, HH:MM:SS.FFF) hexStr]; appendText(handles.editReceive, newLine); end end注意fread读出来默认是double类型的加uint8后返回uint8这对后续解析特别重要。如果不注意类型后续做位运算或者转数值时经常出现类型不一致报错。4.3 数据发送fprintf还是fwrite要看协议界面发送区一般有两种输入模式ASCII字符串和十六进制字符串。很多新手直接用fprintf(s, get(handles.editSend, String))这在发送纯ASCII文本时没问题比如发送ATSTART\r\n。但当你输入AA 01 03 55时fprintf会把AA这两个字符当作字符A和A发出去并不是发送十六进制0xAA。这就会导致设备根本不认数据。正确做法是先判断是否为十六进制模式如果是就用hex2dec解析后转成uint8再通过fwrite发送function btnSend_Callback(hObject, eventdata, handles) if ~isfield(handles, serialObj) || isempty(handles.serialObj) msgbox(请先打开串口); return; end sendStr get(handles.editSend, String); hexMode get(handles.checkHexSend, Value); if hexMode sendStr strtrim(sendStr); bytes hex2dec(strsplit(sendStr)); % strsplit自动按空格切分 fwrite(handles.serialObj, uint8(bytes), uint8); else fprintf(handles.serialObj, %s, sendStr); % 如果要加换行可以改成 fprintf(s, %s\r\n, sendStr); end newLine [TX sendStr]; appendText(handles.editSendLog, newLine); endhex2dec(strsplit(sendStr))这行代码有一个隐藏风险如果发送区末尾多了空格strsplit会空出一段导致报错。所以发送前要对字符串做一次strtrim并且过滤掉空cell。写个工具函数更稳妥function byteArray parseHexString(str) parts strsplit(strtrim(str), { , ,, :, ;}, CollapseDelimiters, true); parts(cellfun(isempty, parts)) []; if isempty(parts) byteArray uint8([]); return; end byteArray uint8(hex2dec(parts)); end4.4 serialport新接口下的收发示例如果你决定用新接口serialport那打开串口和接收数据的方式会稍微不一样s serialport(portName, baudRate); configureTerminator(s, LF); % 设置终结符 s.InputBufferSize 8192; flush(s); % 配置回调 configureCallback(s, byte, 16, (src, evt) readSerialCallback(src, evt, handles));在回调里读取数据function readSerialCallback(src, ~, handles) if src.NumBytesAvailable 0 dataBytes read(src, src.NumBytesAvailable, uint8); % 后续处理一样 end endserialport的read方法返回值类型可控比fread更直观。但要注意serialport对象不能被instrfind扫描到清理方式不同直接delete或者让对象变量被覆盖即可。5. 实战案例用GUIDE读取温控器数据并实时绘制曲线5.1 下位机帧格式定义假设我们要通信的下位机是一台温控器它每100ms主动上报一帧温度数据帧格式如下字节含义例值0帧头0xAA1设备地址0x012数据类型0x103数据高字节0x014数据低字节0x0C5校验和前5字节累加和取低8位6帧尾0x55数据的第3、4字节组成的16位有符号整数表示温度单位是0.1摄氏度。例如0x010C代表268也就是26.8摄氏度。这组协议是典型的自定义二进制协议解析不算复杂但要注意有符号数的处理。如果温度可能为零下那么数据字节要按有符号数转换不能无脑uint16。具体转换方式rawValue dataBytes(4) * 256 dataBytes(5); % 按位拼合 if rawValue 32768 rawValue rawValue - 65536; end temperature rawValue / 10.0;5.2 回调中的数据解析与曲线绘制在回调里先判断帧头帧尾和校验和再把数据存到一个固定的全局变量或者handles字段里然后刷新axes。function readSerialCallback(obj, ~, handles) if obj.BytesAvailable 0 return; end dataBytes fread(obj, obj.BytesAvailable, uint8); % 接收缓冲区累积 if isfield(handles, rxBuffer) rxBuffer [handles.rxBuffer, dataBytes]; else rxBuffer dataBytes; end % 查找帧头 idx find(rxBuffer 0xAA); if isempty(idx) handles.rxBuffer rxBuffer(end:end); % 保留最后一个防止半个帧头 guidata(obj, handles); return; end % 跳到最近的帧头 idx idx(1); rxBuffer rxBuffer(idx:end); while length(rxBuffer) 7 if rxBuffer(1) ~ 0xAA || rxBuffer(end) ~ 0x55 rxBuffer(1) []; continue; end frame rxBuffer(1:7); rxBuffer(1:7) []; % 校验和 checksum mod(sum(frame(1:5)), 256); if checksum ~ frame(6) % 校验失败丢弃 continue; end % 解析温度 rawValue frame(4) * 256 frame(5); if rawValue 32768 rawValue rawValue - 65536; end temp rawValue / 10.0; timestamp now; % 存储到handles if ~isfield(handles, tempHistory) handles.tempHistory []; handles.timeHistory []; end handles.tempHistory(end1) temp; handles.timeHistory(end1) timestamp; % 限制曲线点数 if length(handles.tempHistory) 500 handles.tempHistory(1) []; handles.timeHistory(1) []; end % 显示在编辑框 line sprintf(%s 温度%.1f C, datestr(timestamp, HH:MM:SS.FFF), temp); appendText(handles.editReceive, line); % 绘图 if isfield(handles, axesPlot) axes(handles.axesPlot); plot(handles.axesPlot, handles.timeHistory, handles.tempHistory, b.-); datetick(x, HH:MM:SS); xlabel(时间); ylabel(温度 (C)); grid on; title(实时温度曲线); end end handles.rxBuffer rxBuffer; guidata(obj, handles); end这段代码里我用handles.rxBuffer作为接收缓存。因为一帧可能跨多次回调到达不能假设每次回调都刚好包含一帧。先把字节追加到缓冲区再循环解析所有完整的帧。这属于串口数据处理中比较关键的一个细节。注意guidata(obj, handles)中的obj在回调里就是串口对象。这里不能写成guidata(hObject, handles)因为回调里没有hObject变量。这是很多把回调函数外置后常见的报错点。5.3 完整代码结构整合上述回调函数写在外置的readSerialCallback.m中格式要符合GUIDE调用规则。也可以直接把回调嵌套写在GUIDE的.m文件里需要这样引用s.BytesAvailableFcn (src, evt) readSerialCallback(src, evt, handles);如果你发现外置回调里有时候handles拿不到最新的串口对象一个快速解决方法是在更新串口对象后立即用guidata保存同时在外部回调里重新读取一次handles guidata(handles.figure1); % 从主窗口读取最新的handles不过需要注意当串口回调频繁触发时每一次都调用guidata刷新可能有性能开销。实测中如果波特率不高比如9600每秒数据量很小完全可以每次回调都取一次最新handles没太大性能压力。但在高波特率如921600下建议只在回调开头读一次后续只更新局部变量用全局变量或者应用数据来共享。6. 常见问题与排查技巧实录6.1 串口打开失败Port is not available这可能是端口被占用也可能是端口号不存在。排查顺序确认设备管理器里COM口确实存在。拔插USB转串口后COM号变了而界面上还是旧的选择确认其他软件串口助手、Keil调试器没占用该端口。即使关掉串口助手Windows有时也会短暂保留端口在打开串口前用instrfind清理Matlab自身遗留对象我碰到一次比较奇葩的情况设备明明在设备管理器里但是Matlab的serialportlist显示不到。后来发现是USB转串口芯片的驱动被Windows更新搞挂了重装驱动解决。这种问题很值得警惕别一上来就怀疑代码。6.2 收到数据全是乱码乱码优先检查波特率、数据位、校验位是否和下位机一致。其次检查是不是协议格式理解错了。ASCII模式下如果下位机发的是二进制界面显示当然是一堆乱码反之下位机发ASCII文本你用fread读取并转成hex再显示也会出现看起来“乱”但其实是正确十六进制值的情况。这里有个快速验证方法用系统自带的串口调试工具比如Putty或海豚串口助手直接连接设备对比同样数据在Matlab和串口助手中的显示差异。如果串口助手也乱那基本是下位机问题或接线问题如果串口助手正常而Matlab乱那就是你的Matlab代码配置或解析逻辑有问题。6.3 GUI界面卡死图形不刷新同样的现象原因可能有三种回调函数里做了阻塞操作比如fread一直等不够数据导致GUI主线程被卡住在循环里set文本但没调用drawnow界面消息得不到处理回调函数内部抛了异常陷入死循环关于第三种情况我建议在回调函数里加try-catch哪怕是简单地把错误信息显示到界面上function readSerialCallback(obj, evt, handles) try % 有效代码 catch ME fprintf(回调错误: %s\n, ME.message); end end这样错误不会莫名其妙把整个GUI卡死也方便定位。另外如果你在回调里调用了plot或者set批量更新文本控件十分耗时。当数据量很大时建议减少刷新频率比如每凑够20帧再刷新一次界面曲线也只绘制最新100个点避免一边接收一边疯狂重绘。6.4 写入串口后设备无响应先发送一个下位机肯定能识别的固定指令比如设备手册上的握手指令。如果能响应说明发送链路没问题问题在你们的协议细节。如果没有响应先用串口助手发同一指令测试。串口助手能通但Matlab不能请检查发送内容是否包含终结符以及fprintf默认附加的格式。用fprintf(s, %s\r\n, cmd)和用fwrite(s, uint8(cmd), uint8)发的字节可能并不一样。另外我习惯在发送回调里把实际发出的字节打印出来renamed get(handles.editSend, String); fprintf(实际发送ASCII: %s\n, renamed);这样可以直观看到Matlab是不是把引号、换行之类的字符也发了出去。6.5 回调触发次数太多导致数据错乱低波特率设备基本不会碰到这种问题但高波特率或者下位机连续大量上报时回调之间可能会出现简单的字节重叠。我的处理思路是增加一个互斥标志用tic/toc控制最小处理间隔if isfield(handles, lastProcTime) toc(handles.lastProcTime) 0.02 return; end handles.lastProcTime tic;这样即使系统资源不足导致回调积压也不会在短时间内反复处理给GUI留出刷新机会。7. 如何把GUIDE串口工具做得更顺手最后分享几个我用了很多年的小经验。第一串口配置区不要写死一定要动态读取。很多人为了方便把端口号写成COM3结果换台电脑直接崩。动态枚举端口虽然写起来多几行但一劳永逸。第二给接收区加一个暂停滚动的开关。调试高频数据时如果接收区一直在刷新你根本看不清楚最新内容。加一个checkbox控制是否自动回到底部手动查看旧数据时不会被打断。第三保留一份发送历史记录。用另一个编辑框或者listbox保存最近10条发送过的指令点击就能再次发送。这在调试指令交互型设备时效率翻倍。第四关闭程序时一定要关闭串口。在GUIDE的CloseRequestFcn或者Figure的DeleteFcn中加上function figure1_CloseRequestFcn(hObject, eventdata) handles guidata(hObject); if isfield(handles, serialObj) ~isempty(handles.serialObj) fclose(handles.serialObj); delete(handles.serialObj); end delete(hObject); end不然程序关了串口还被占用下次打开就会报端口不可用。第五如果你是在科研场景下用建议把接收到的原始数据同时追加写入一个本地txt或者mat文件作为实验记录备份。串口通信过程中如果上位机界面不小心关掉数据还能从文件里找回来避免重新跑一次实验。Matlab GUIDE做串口操作的门槛并不高但它把界面开发和通信逻辑揉在了一起很多问题浅看是通信问题实际是GUI编程问题。上面这些代码和排查思路基本覆盖了我日常调试中遇到的大部分情况。你拿去做一个自己的串口调试上位机先跑通再逐步优化界面和数据处理细节应该不会走太多弯路。
返回列表