ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MATLAB串口通信与多通道波形图绘制:从帧协议到实时显示

MATLAB串口通信与多通道波形图绘制:从帧协议到实时显示

简介:面向需要开展MATLAB串口通信与实时数据可视化的工程师或研究人员,资源通过RS232.m脚本完整示范了串口对象初始化、波特率数据位停止位校验位等参数配置、数据读取与帧解析,以及基于plot函数绘制多通道波形的方法,可直接迁移到嵌入式设备监控、实验数据采集与实时波形显示场景。资源包包含1个MATLAB源文件(.m),压缩后约1KB,体量精简但功能覆盖完整,适合有基础MATLAB使用经验并希望快速搭建串口采集与多通道波形显示原型的读者。目前已有1366人学习,脚本中依次展示了串口缓冲区与超时设置、循环读取数据、数据帧封装与通道分离、多曲线绘图及图例标注等关键思路,读者可依据自身硬件协议调整解析算法,也可补充校验与解码逻辑,直接用于实际项目验证。

1. 串口绘图为什么还要单独写一套:MATLAB 串口通信与多通道波形图的真实门槛

很多人以为串口绘图就是把串口读到的数字直接plot出来,真动手才发现:设备端一次发几十个字节,MATLAB 这边读到的却是断断续续的碎片,波形要么飞线要么错位。用 MATLAB 做串口通信并绘制多通道波形图,核心不是绘图函数,而是「字节流怎么切成一帧帧完整数据、多路信号怎么对齐到同一条时间轴、实时刷新怎么不卡」。这套方案解决的问题很具体:单片机或采集卡通过串口连续上报多路传感器数据,PC 端 MATLAB 负责接收、解析、实时显示波形,用于联调、数据验证和算法离线分析。适合做嵌入式调试、仪器数据采集、机器人状态监控的工程师和学生,尤其是已经写好了下位机、就差一个能看清多通道波形的上位机调试工具的人。

2. 串口拿到的不是波形:先定数据帧协议

2.1 从字节流到通道:为什么帧结构决定成败

串口是字节流,不是消息流。下位机send出去的几十个字节,到 MATLAB 这边可能一次读回 3 个、下一次读回 50 个,中间没有任何天然边界。如果直接把读到的字节按顺序硬塞给通道,结果就是通道错位、波形乱跳,而且这种错位是随机的,越调越玄学。

所以在写任何绘图代码之前,先把帧协议定死。帧协议解决三件事:这一帧从哪开始、这一帧有多长、这一帧的数据对不对。常见做法是「帧头 + 通道数 + 数据长度 + 数据体 + 校验字节」。帧头用两个固定字节(比如0xAA 0x55),告诉接收端「从这里开始算一帧」;通道数和长度告诉接收端「这帧该读多少字节」;校验字节用来过滤噪声和错位残帧。

通道数大于等于 2 时,帧里各通道数据按固定顺序排列,接收端解析时按同样的顺序切分即可。这里有一个容易被忽略的点:多通道数据是「时分复用」排列的,即一帧里先放通道 1 的全部采样点还是按采样时刻交叉排列?这两种方式对应完全不同的解析逻辑。最常见的做法是「按时刻交叉排列」,也就是一帧里依次放 [ch1_k, ch2_k, ch3_k],下一帧放 [ch1_k+1, ch2_k+1, ch3_k+1]。这样接收端每收到一帧就拿到所有通道在同一时刻的一个采样点,画图时各通道天然对齐。

2.2 一个能落地的帧格式:帧头、长度、校验与解析伪代码

我一般用下面这个帧格式,在模拟项目 X 里验证过,稳定性和可读性都够用:

字节偏移内容长度说明
0帧头 11 字节固定0xAA
1帧头 21 字节固定0x55
2通道数1 字节本次上报的通道个数,如0x03表示 3 通道
3数据长度1 字节数据体总字节数,单位是字节
4 ~ 4+L-1数据体L 字节各通道数据,int16 小端,按时刻交叉排列
4+L校验1 字节从帧头开始到数据体末尾的累加和,取低 8 位

int16 小端的意思是每个采样点占 2 字节,低字节在前。比如通道 1 的数值0x1234,在帧里就是0x34 0x12。这个字节序必须在 MATLAB 里指定,否则解析出来全是乱码。

数据长度字段为什么单独留 1 字节?因为这样可以支持非固定帧长。比如某次上报只带 2 个通道,下次带 4 个通道,接收端读数据长度字段即可知道该切多少字节,不用烧录一次固件改一次协议。如果通道数和采样率都固定,也可以省略长度字段,直接在代码里写死,但一旦后续需求变化又要改协议。我的建议是保留,成本只有 1 个字节。

校验算法用累加和最省事,下位机 C 代码和 MATLAB 都一行写完:

function isValid = checkSum(frameData) % 累加和校验:从帧头到数据体末尾逐字节相加,取低 8 位 sumBytes = mod(sum(uint16(frameData(1:end-1))), 256); isValid = (uint8(sumBytes) == frameData(end)); end

逻辑说明:frameData是完整的一帧,最后一字节是下位机算好的校验值。MATLAB 这边把前面所有字节累加,取低 8 位,和最后一字节比对。uint16是为了防止累加时单字节溢出变成 0,mod(..., 256)等效于取低 8 位。注意这里不能用uint8直接累加,MATLAB 的uint8加法会自动饱和到 255,而不是回绕,算出来的校验永远不对。参数说明:如果下位机用的是 CRC8,这里改成 CRC8 查表即可,但累加和已经能挡住绝大多数串口噪声和高位误码,够用。

3. MATLAB 串口通信:配置、读取与解析

3.1 用 serialport 打开串口:句柄配置与回调挂接

MATLAB 操作串口有两套接口:老的serial和新的serialport。新接口返回对象句柄,属性和读写方法更规范,读数据直接read,不用fread,也没有fopen那套繁琐流程。我用的是serialport,下面所有代码都是这个接口。

% 打开串口,波特率 115200,小端字节序 s = serialport('COM7', 115200, 'ByteOrder', 'little-endian'); s.Timeout = 1; % 清空接收缓冲区,避免旧数据混入新帧 flush(s); % 设置 ByteOrder 属性(也可以在构造时指定) configureTerminator(s, 'LF');

逻辑说明:serialport第一个参数是串口名,Windows 一般是COMx,Linux 是/dev/ttyUSBx,macOS 是/dev/tty.usbserial-xxx。ByteOrder必须和下位机一致,这里指定little-endian,对应 2.2 节的帧格式。flush(s)清掉串口缓冲里残留的旧字节,否则程序启动后第一次解析可能碰到半截残帧。configureTerminator设置终止符,如果下位机每帧末尾带换行符0x0A,这里设置'LF'后,可以用readline按行读取;如果不带终止符,就不用配置。

参数说明:波特率要和下位机一致,常见的 115200、460800、921600 都行。串口数据量不大时 115200 足够,一秒约 11.5 KB,8 通道、每通道 100Hz 采样、每个采样点 2 字节,一秒钟才 1.6 KB,远小于带宽。如果采样率提到 1kHz 且 16 通道,1 秒 32 KB,115200 波特率就开始吃力,这时候要么降采样率,要么换 460800 以上波特率。

3.2 字节缓冲与帧同步:解析函数怎么写才不丢帧

串口读到的数据是流式的,不能保证每次read都恰好拿到完整一帧。所以接收端必须维护一个字节缓冲:每次从串口读新数据,先追加到缓冲末尾,再从缓冲里找帧头、切帧、校验。

function [buffer, chData] = parseFrames(buffer, maxChannels) % buffer: uint8 行向量,累积的未处理数据 % chData: 解析出的最新一帧,按通道排列的 double 向量 % maxChannels: 最大通道数,防止异常数据导致数组越界 chData = []; frameFlag = false; while numel(buffer) >= 6 % 至少够帧头+通道数+长度+校验 % 找帧头:连续两个字节 0xAA 0x55 headIdx = find(buffer(1:end-1) == 0xAA & buffer(2:end) == 0x55, 1); if isempty(headIdx) % 缓冲里找不到完整帧头,保留最后一字节,等下一批数据 buffer = buffer(end); break; end % 丢弃帧头前的垃圾字节 buffer = buffer(headIdx:end); if numel(buffer) < 4 break; end numCh = double(buffer(3)); dataLen = double(buffer(4)); frameLen = 5 + dataLen; % 帧头2 + 通道数1 + 长度1 + 数据 + 校验1 if numel(buffer) < frameLen % 帧还没收全,等下一批数据 break; end % 校验 if checkSum(buffer(1:frameLen)) % 解析数据体:按通道数切分,每通道 dataLen/numCh/2 个采样点 payload = buffer(5:4+dataLen); values = typecast(payload, 'int16'); samplesPerCh = dataLen / (numCh * 2); % 取每个通道最新一个采样点 for ch = 1:numCh idx = (ch - 1) * samplesPerCh + samplesPerCh; chData(ch) = double(values(idx)); end frameFlag = true; % 移除已解析的帧 buffer = buffer(frameLen+1:end); else % 校验失败,跳过 1 字节继续找帧头 buffer = buffer(2:end); end end end

逻辑说明:这个函数的核心是「只处理完整帧,处理完立即从缓冲里移除」。find定位帧头,定位不到就只保留最后一个字节,防止帧头0xAA恰好落在缓冲末尾而0x55在下一批数据里。校验失败时逐字节跳过而不是整帧丢弃,是为了防止帧头误匹配导致的错位。typecast把字节流按int16小端重解释,效率远高于逐字节移位拼接。

参数说明:maxChannels是安全上限,防的是下位机异常上报导致samplesPerCh算出来的数组越界。samplesPerCh的意义是下位机一帧里每通道上报了几个采样点。如果下位机每帧每通道只发 1 个点,那dataLen = numCh * 2,samplesPerCh = 1,循环里取的就是唯一那一个点。

3.3 读写延迟与缓存水位:该调的两个参数

串口通信的延迟主要来自两个地方:MATLAB 读串口的轮询间隔,以及数据在系统驱动缓冲区的堆积时间。serialport有Timeout和NumBytesAvailable两个属性直接影响行为。

Timeout影响阻塞式read的等待时长。我把它设成 1 秒,意思是如果串口暂时没数据,read最多等 1 秒再返回空结果。设太大会让程序停顿感明显,设太短会频繁空转占用 CPU。NumBytesAvailable是当前驱动缓冲里可读的字节数,读之前先查这个值,避免read阻塞。常见做法是循环里先判断s.NumBytesAvailable > 0再读,读多少就read(s, s.NumBytesAvailable, 'uint8'),一次全读上来,交给parseFrames处理。

这里有一个最常见的性能陷阱:如果把解析函数放在while循环里、每轮都等待串口数据,CPU 占用会很高,而且 MATLAB 单线程执行,绘图会被阻塞。后面第 4 章会讲怎么把接收和绘制解耦,这里先记住一条纪律——读串口永远用非阻塞方式,循环里先查NumBytesAvailable,有数据才读。

4. 多通道实时波形绘制:从数据到曲线

4.1 双通道绘图的最小实现:line 句柄与滚动窗口

有了解析出的chData,下一步就是把它画出来。直接plot会不断追加新线条,画几千帧后图窗里全是对象,内存涨、刷新慢。正确做法是创建line对象,然后每帧更新它的XData/YData,让曲线在固定窗口内滚动。

% 初始化:在指定坐标区创建线条对象,初始数据为空 ax = axes('Parent', gcf); hLine1 = plot(ax, NaN, NaN, 'r-', 'LineWidth', 1.2); hLine2 = plot(ax, NaN, NaN, 'b-', 'LineWidth', 1.2); windowSize = 1000; % 滚动窗口:最多显示 1000 个采样点 xData = 1:windowSize; y1 = nan(1, windowSize); y2 = nan(1, windowSize); % 每来一帧,更新数据 y1 = [y1(2:end), chData(1)]; y2 = [y2(2:end), chData(2)]; set(hLine1, 'XData', xData, 'YData', y1); set(hLine2, 'XData', xData, 'YData', y2); drawnow('limitrate');

逻辑说明:NaN在plot里表示断点,初始曲线不显示;之后每帧把新采样点追加到末尾,同时丢弃最旧的一个点,形成滚动效果。set(hLine, 'XData', ...)比重新plot快得多,因为对象复用,绘制引擎只需要更新顶点数据。drawnow('limitrate')限制刷新率不超过 20 fps,避免高频数据把 GUI 线程拖死。

参数说明:windowSize是滚动窗口长度,设 1000 时 100Hz 采样率能显示 10 秒波形。窗口越长,波形细节越模糊;越短,越能看清瞬时波动。多通道场景下每个通道可以单独设窗口长度,比如看长时间趋势用 5000,看细节用 200。LineWidth设 1.2 是为了在屏幕缩放后仍然清晰,导出图片时建议设 1.5 以上。

4.2 多通道图怎么排:子图、归一化与波形重叠

通道数多于两个时,绘图排版影响可读性。最常见的是两种:多子图上下排列,或者单图重叠不同颜色。多子图的优点是每个通道有独立 Y 轴量程,单位不同也不冲突;缺点是图上面积小,通道路数多时每个子图高度不够。重叠图正好相反,所有通道共享时间轴,适合量程相近的信号(比如多路温度、多路电压),但量程差异大的通道会互相挤压,小信号被压成一条直线。

我一般按信号性质决定:同量纲、量程差异小于 10 倍的重叠画,否则子图画。子图用subplot或tiledlayout都行,前者是老接口,后者新一点但布局更整齐。

% 3 通道子图布局 t = tiledlayout(gcf, 3, 1); ax1 = nexttile(t); hold(ax1, 'on'); ax2 = nexttile(t); hold(ax2, 'on'); ax3 = nexttile(t); hold(ax3, 'on'); h(1) = plot(ax1, NaN, NaN, 'r-'); h(2) = plot(ax2, NaN, NaN, 'g-'); h(3) = plot(ax3, NaN, NaN, 'b-'); % 各通道独立 Y 轴,避免量程互相影响 ylim(ax1, [-5, 5]); ylim(ax2, [0, 100]); ylim(ax3, [-50, 50]);

逻辑说明:tiledlayout创建的布局对象可以用nexttile依次获取子图坐标区,比subplot更容易控制子图间距和整体比例。hold on后plot不会清空坐标区,配合初始NaN数据,后续直接用set更新即可。各子图ylim根据通道量程单独设置,是防止量程差异大时小信号看不见的关键一步。linkaxes不需要用,因为时间轴本身就是共用的,如果各子图采样率相同,XLim天然一致。

如果通道数到 8 个以上,子图高度会非常小,这时候优先考虑重叠图加透明度,或者把信号先做归一化再重叠。归一化用(x - min(x)) / (max(x) - min(x))映射到 [0,1],但这个做法会丢失绝对幅值信息,只适合观察波形形状和相位关系,不适合做幅值测量。所以归一化重叠图我一般只用于「快速巡检」,正式的测量看子图。

4.3 控制刷新率:绘制频率与数据积压的关系

实时绘图最容易翻车的点是:下位机数据源源不断进来,MATLAB 每帧都drawnow,结果绘图速度跟不上数据到达速度,串口缓冲越积越多,最终丢数据。这个问题的根源是绘制频率和数据采样率不匹配。

% 主循环里用计数控制绘制频率 % drawCount 每 5 帧刷新一次,即数据 100Hz 时绘图 20fps drawCount = drawCount + 1; if drawCount >= 5 drawnow('limitrate'); drawCount = 0; end

逻辑说明:drawnow('limitrate')本身有内部限速逻辑,大约每秒 20 帧上限,但在数据到达率远高于 20 帧时,每次调用仍会同步绘制,造成阻塞。手动加一个跳帧计数,实际绘制频率降到数据帧率的 1/5,能显著减少 GUI 线程压力。代价是波形有轻微视觉延迟,5 帧的数据延迟在 100Hz 采样下是 50ms,肉眼几乎无感。

如果数据到达率超过 1kHz,跳帧还不够,应该改成「定时器读取 + 主循环绘制」双线程模型。timer对象里只做串口读取和数据解析,把结果存到全局变量,主循环只管画。这样绘图卡顿时数据不会丢,串口缓冲也能保持低位。缺点是代码复杂度上升,但这是高频采集场景下唯一不掉数据的稳定方案。

5. 串口波形翻车现场:串口绘图常见的 5 个坑与排查

5.1 波形毛刺多:不是噪声,是校验位和缓冲溢出

现象:波形整体形状正确,但每个采样点附近有成簇的尖刺,看起来像叠加了高频噪声。原因:最常见的不是电磁干扰,而是下位机波特率不准确或串口驱动缓冲溢出导致字节丢失,校验不过关时整帧被丢弃,而丢弃的恰好是某个通道的某个点,波形上就出现单独跳变的尖刺。解决:先用示波器或上位机自发自收确认波特率误差在 2% 以内;然后在下位机发送端加小延时(比如每帧 1ms),降低瞬时写入压力;最后在 MATLAB 侧将校验失败的帧计数打印出来,如果高频尖刺和丢弃帧数成正比,基本可以定位是缓冲溢出而非噪声。

5.2 通道值互相串位:帧头误匹配

现象:通道 1 偶尔变成通道 2 的波形,几帧后又自己恢复,看起来像「串台」。原因:数据体里出现了和帧头相同的字节组合0xAA 0x55,解析逻辑把数据体中的两个字节误认为新帧头,切帧位置偏移,后面所有通道数据全错位。解决:加帧头转义机制,下位机发送时遇到0xAA后紧跟0x55就在中间插入填充字节0x00,接收端解析时删除填充字节;或者把帧头从 2 字节扩到 3 字节0xAA 0x55 0x01,降低误匹配概率。注意帧头字节不能和数据体可能出现的值过于接近,这是协议设计阶段就要考虑的问题。

5.3 一卡一卡不流畅:回调里绘图

现象:波形能显示但刷新有明显卡顿,像幻灯片。原因:如果用了BytesAvailableFcn回调,回调函数里直接做plot或drawnow,MATLAB 的图形刷新会阻塞串口回调线程,数据积压越来越多,卡顿越来越严重。解决:回调函数里只做读取和解析,把结果存到全局变量或 handle 对象属性中;主循环或者timer里只绘图不读串口。这条我在模拟项目 X 里的第一版就踩了,改成「回调存数据、主循环绘图」后帧率翻了不止一倍。

5.4 打开串口报错:端口被占用

现象:serialport('COM7', 115200)直接报错说无法打开。原因:COM7 被设备厂商自带软件、其他串口助手或上一次异常退出的 MATLAB 进程占用,也可能实际端口号不是 COM7。解决:先关掉所有串口工具,在 MATLAB 里执行instrfind查看所有串口对象:

instrfind

这里列出的每个对象如果对应的是被占用的串口,执行delete删除旧对象,再重新创建新的serialport。如果删完还是报错,去设备管理器确认端口号有没有变化,部分 USB 转串口模块每次插入可能分配不同 COM 号。最后检查驱动是否正常,设备管理器里端口是黄色感叹号的话,串口工具里能看到但打不开。

5.5 程序跑起来没数据:终止符和流控不匹配

现象:串口打开了,循环一直提示NumBytesAvailable为 0。原因:下位机每帧末尾没有换行符,而 MATLAB 配置了configureTerminator(s, 'LF');或者下位机和 MATLAB 的流控设置不一致(比如下位机开启了 RTS/CTS,MATLAB 默认没开)。解决:用s.PinStatus查看 CTS/DSR 状态,如果 CTS 一直是低电平,说明流控不匹配,把下位机关掉流控,MATLAB 侧用configureTerminator(s, '')清空终止符,改成纯字节流读取。还有一个小坑是 MATLAB 的readline必须配合终止符使用,如果只用read就不需要终止符,两者不要混用。

6. 把波形画得更顺手:数据对齐、时间戳与离线回放

多通道波形做好以后,下一步通常是对齐和回放。串口本身不携带绝对时间信息,MATLAB 里看到的 X 轴是采样点序号,不是真实时间。要换算时间,给每帧打时间戳是最实用的做法。

% 记录每帧到达的时刻(秒),计算实际采样间隔 tStamp(k) = toc(startTimer); if k > 1 actualDt = tStamp(k) - tStamp(k-1); end

逻辑说明:toc(startTimer)返回从启动到当前帧的秒数,存入数组后可以算出实际采样周期。下位机标称 100Hz,实际可能是 98Hz 或 103Hz,长时间运行后纯按序号画的时间轴会漂移,而按真实时间戳画就不会。这个差异在做频率分析、计算信号周期时非常关键,我在一个振动数据采集的联调里因为没打时间戳,FFT 峰值频率偏了 2%,排查了很久才发现是时间轴假设错误。

如果要在 MATLAB 里对多通道波形做离线分析,建议把解析出的原始数据连同时间戳一起存成 MAT 文件,而不是存图。图是给人看的,数据才是给算法用的。存成结构体save('waveData.mat', 't', 'chDataAll'),后续加载后可以用相同的时间轴重新绘图、滤波、或者做频谱分析,不用重新接硬件。形成一个习惯:先在脚本里把接收、解析、存储做成三个独立函数,绘图只是调用方之一,这样后面要加自动保存、异常报警、频谱显示都能快速接入。

离线回放是另一个值得提前做的功能。把录好的数据用for循环模拟实时更新YData,加一个pause(0.01)模拟帧间隔,就能复现当时的波形变化,而不用依赖硬件。调滚动窗口、调颜色、调 Y 轴量程都可以在回放模式里完成,不会因为没接设备而干等。

我做这类上位机调试工具积累了一条经验:先写一个「假数据源」函数,能生成已知波形(正弦、方波、随机噪声混合)的模拟帧,验证解析和绘图逻辑正确后再接真实串口。因为真实数据一进来你根本分不清是解析写错了还是传感器本来就那样,而模拟数据永远可以对照正确答案。现在每次新写串口绘图程序,我都会先把模拟数据这 20 行跑通,再接硬件,这个习惯帮我省掉了很多无效联调时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进