☰
MCU与MATLAB串口通信帧格式设计:协议、状态机与联调实战
2026/10/16 6:06:20 网站建设 项目流程

1. 先说结论:为什么坚持用帧格式做MCU与MATLAB的串口通信

很多人第一次接触MCU与MATLAB通信时,最喜欢用的方案是MATLAB端直接开一个串口对象,然后fread或者read出一堆字节,再用MATLAB的解析函数去解。这样做在数据量小、格式固定、只做一次性验证的场景下确实可行,但一旦涉及连续采集、多类型数据、偶发错位,尤其是要从"能收到"过渡到"稳定可靠地收到",帧格式就是绕不开的必修课。

什么叫帧格式?简单说,就是你在串口线上传输的数据不是裸数据流,而是一段有结构的字节块,通常包含固定的帧头、长度字节、数据类型、数据区、校验字。MCU端按这个格式组包,MATLAB端按这个格式拆包,双方约定一套协议,谁也不会误解谁。这个思路其实和我们平时用快递包裹的道理一模一样——光给收件人寄一箱东西不行,得贴快递单,写清楚从哪里来、到哪里去、箱子里有几件、重量多少,收件人才能正确签收。帧头是快递单上的"发件地"标识,长度字段是"件数",校验字是"开箱验货"。

文章会覆盖的内容包括:帧协议怎么设计才不给自己挖坑、MATLAB端serialport接口的完整用法与接收缓存的坑、MCU端组帧发送与中断接收的代码思路、以及我在实际联调中踩过的浮点传输、字节序、波特率误差等一堆问题。如果你正在做传感器数据采集、控制指令下发、算法验证平台搭建这类工作,这篇应该能帮你少走不少弯路。

2. 帧协议设计:先想清楚这些字段,后面才不会改来改去

2.1 一个几乎所有场景都能用的最小帧结构

协议设计是整个通信系统的地基。地基歪了,后端的解析函数写一百遍也没用。我推荐的帧结构长这样:

| 帧头 2字节 | 长度 1字节 | 类型 1字节 | 数据区 N字节 | 校验 1字节 | | 0xAA | 0x55 | | | |

帧头固定为0xAA 0x55,两个字节的好处是可以大幅降低数据区内出现相同字节导致误判的概率。长度字段表示"从类型字节到数据区结束"的总长度,方便MATLAB端知道一帧到底有多长;类型字段用于区分不同业务信息,比如0x01表示温度数据、0x02表示电机转速、0x81表示上位机下发的控制指令;校验位我用的是简单的异或校验,对所有载荷字节做按位异或。

提示:帧头取两个字节是权衡后的选择。单个字节虽然也能用,但数据区里一旦出现与帧头相同的值,解析端就会出现抖动;三个字节帧头会更稳,但对短帧来说开销偏大。实战中两个字节是性价比较高的方案。

这个结构能覆盖70%以上的MCU与MATLAB通信需求。复杂协议如Modbus、CANopen虽然更严谨,但自己做板级联调时没必要一上来就上重型协议,轻量私有帧足够。

2.2 为什么我会选异或校验而不是CRC或累加和

做校验有三个常见选择:累加和、异或校验、CRC。很多人图省事用累加和,这在数据区较长时容易出问题,因为累加和可能溢出然后被截断,两个不同的错误字节可能产生相同的校验值。CRC精度最高,但MCU端代码量略大,如果通信速率不高、单帧数据量不大,用CRC属于杀鸡用牛刀。

异或校验的数学本质是"统计每一位上1的数量的奇偶性"。它比累加和更不容易出现校验值碰撞,代码实现又很简单——一个循环异或就完了。我带过的项目里,大部分帧长度都能控制在64字节以内,异或校验的漏检率完全在可接受范围内,而且排查问题的时候,人肉算校验值也方便:

uint8_t calc_xor(uint8_t *buf, uint16_t len) { uint8_t xor_sum = 0; for (uint16_t i = 0; i < len; i++) { xor_sum ^= buf[i]; } return xor_sum; }

2.3 协议落地前先画一张谁都能看懂的字段说明表

协议定完之后最容易被忽略的是文档化。不要只写在代码注释里,单独建一张字段说明表,把这个表发给所有参与联调的同事或同学。我吃过这样的亏:协议里长度字段到底是"包含长度本身"还是"不包含",因为没写清楚,MATLAB端解析多算了1字节,整整查了两小时。

字段字节数说明
帧头11固定0xAA
帧头21固定0x55
长度1从类型字节到数据区末尾的总字节数,不含帧头与校验
类型10x01温度 / 0x02转速 / 0x81控制指令
数据区N负载数据,长度可为0
校验1对长度、类型、数据区所有字节做异或

这张表写清楚,MATLAB解析代码里的dataLen怎么取值,MCU组帧的buf[2]怎么填,两边就不会各说各话。

3. MATLAB端:从"能收到数"到"稳定解析帧"的完整写法

3.1 用serialport对象而不是老式串口函数

MATLAB从R2019b开始推荐使用serialport接口替代老的serial+fopen+fread模式。新接口最大的好处是不再依赖fopen/fclose的句柄管理,而且内置了NumBytesAvailable属性,可以随时查看接收缓冲区里积压了多少字节,这对处理粘包、半包问题非常关键。

具体创建串口的代码如下:

baudRate = 115200; serialObj = serialport("COM3", baudRate, "Timeout", 1); % 顺手把缓冲区清一下,避免拿到历史残留数据 flush(serialObj);

这里有一个非常容易踩的坑:串口参数中数据位、停止位、校验位默认分别是8、1、无,如果你的MCU端也是默认配置,那不用额外设置;但如果你在MCU端初始化时手滑写了9bit模式或者偶校验,MATLAB端就必须同步配置。我建议两边都明确设置一遍,不能依赖默认值:

configureTerminator(serialObj, "LF"); serialObj.DataBits = 8; serialObj.StopBits = 1; serialObj.Parity = "none";

3.2 解析状态机:半包与粘包一起处理

这是MATLAB端最核心的部分。很多初学者上来就写一个大循环:读固定长度、解析、判断帧头,结果遇到两条帧粘在一起、或者一条帧被拆成两半到达的情况,程序直接崩溃或者产生数据错位。正确的思路是实现一个有限状态机,每拿到一个新字节就驱动状态迁移。

我的解析函数长这样,核心是维护一个byteBuffer数组,循环处理每个字节:

function parsedData = parseSerialFrame(serialObj) parsedData = []; % 持续读取直到能解析出一帧,或者超时 while true if serialObj.NumBytesAvailable < 4 continue; end byteBuffer = read(serialObj, serialObj.NumBytesAvailable, "uint8"); for i = 1:length(byteBuffer) byte = byteBuffer(i); % 状态机:找帧头 -> 找第二个帧头 -> 解长度 -> 攒数据 -> 校验 if state == 0 if byte == 0xAA state = 1; end elseif state == 1 if byte == 0x55 state = 2; else state = 0; % 帧头不连续,重新找 end elseif state == 2 frameLen = byte; payloadBuf = zeros(1, frameLen); payloadCtr = 0; state = 3; elseif state == 3 payloadCtr = payloadCtr + 1; payloadBuf(payloadCtr) = byte; if payloadCtr >= frameLen state = 4; end elseif state == 4 % 此时byte应该是校验值 xorCheck = bitxor(bitxor(payloadBuf(1), payloadBuf(2)), payloadBuf(3)); % ... 按类型解析数据 state = 0; end end end end

注意:上面的代码为了表达状态机的思路做了一定简化,实际使用时应该把state、payloadBuf声明为函数外的持久变量或利用MATLAB的嵌套函数特性,避免每次进入函数都重新初始化。

3.3 回调模式:数据到了再通知你,而不是傻等

多数场景下,MATLAB程序不会只做"接收数据"这一件事。你可能同时在跑界面刷新、参数计算、绘图更新。如果串口读取用while true死等,其他代码全得排队。这时我更推荐用configureCallback配置回调函数,让MATLAB在串口收到指定数量的字节后自动触发一段解析代码:

serialObj.UserData = struct('rxBuf', [], 'state', 0); configureCallback(serialObj, "byte", 1, @serialCallback); function serialCallback(src, ~) rawBytes = read(src, src.NumBytesAvailable, "uint8"); for i = 1:length(rawBytes) % 在这里调用状态机处理每个字节 processByte(src, rawBytes(i)); end end

回调模式还有一个额外好处:MATLAB的串口回调是在主线程事件池里触发的,你不必担心数据丢失,反而要注意回调函数里别做太重的解析工作——如果解析太慢,回调会阻塞后续数据分发。解决方案是回调里只做原始字节缓存和状态机推进,解析好的结构化数据放进src.UserData.resultQueue队列,真正的主程序定期去拿队列里的结果。

3.4 超时与断线重连:联调时最有用的自我保护

MCU端程序跑飞、调试器暂停、或者有人把USB线碰松了,都会让MATLAB端陷入"傻等数据"的僵局。serialport对象自带的Timeout属性正好解决这个问题:

serialObj.Timeout = 2; try data = read(serialObj, 64, "uint8"); catch ME disp("串口读取超时或出错: " + ME.message); end

无论如何,读操作要放在try-catch里。这在联调初期尤为重要,因为MCU端只要一复位的瞬间,MATLAB端读到的半截数据就会让解析状态机出现异常,如果你不捕获错误,整个脚本直接罢工,而这种罢工往往没有任何报错提示,只是后面所有数据都错位了。

4. MCU端实现:组帧发送与中断接收的代码怎么写才稳

4.1 组帧发送端的封装思路

MCU端发送数据的代码不是简单把所有字节一个接一个丢进串口。处理器的串口发送是一个字节一个字节往外吐,如果主程序在发送中途被更高优先级的中断打断,或是在两条帧之间插入了其他调试打印,接收端就会看到垃圾数据。所以组帧要一次性把完整帧放进发送缓冲区,让驱动层连续发送。

void send_frame(uint8_t type, uint8_t *data, uint8_t data_len) { uint8_t buf[64]; uint8_t i = 0; uint8_t xor_sum = 0; buf[i++] = 0xAA; buf[i++] = 0x55; buf[i++] = data_len + 2; // 类型1字节 + 数据区data_len字节 buf[i++] = type; for (uint8_t j = 0; j < data_len; j++) { buf[i++] = data[j]; xor_sum ^= data[j]; } xor_sum ^= type; xor_sum ^= (data_len + 2); buf[i++] = xor_sum; // 如果串口发送一次能处理完,直接发送 HAL_UART_Transmit(&huart1, buf, i, 100); }

这段代码里值得注意的地方是长度字段的赋值:data_len + 2,因为长度是从类型字节到数据区末尾的总长度,包含类型本身和数据区。这也和前面MATLAB解析的状态机逻辑对应上了。

4.2 中断接收:一帧一帧缓存,别把压力丢给主循环

接收端是MCU端最容易出问题的地方。如果主循环里做轮询接收,在波特率较高或数据帧较长时必然会丢字节。正确做法是开启串口接收中断,在中断服务函数里把字节放入环形缓冲区,主循环再从缓冲区里解析完整帧。你只要记住一条铁律:中断里尽量只做字节搬运,绝不做状态机解析和业务逻辑,因为那会拖长中断时间,影响其他实时任务。

volatile uint8_t rx_ring[128]; volatile uint8_t rx_head = 0; volatile uint8_t rx_tail = 0; void USART1_IRQHandler(void) { if (LL_USART_IsActiveFlag_RXNE(USART1)) { uint8_t byte = LL_USART_ReceiveData8(USART1); uint8_t next_head = (rx_head + 1) % 128; if (next_head != rx_tail) { rx_ring[rx_head] = byte; rx_head = next_head; } // 缓冲区满时选择丢弃,后续再加告警计数 } }

在主循环里,状态机解析函数逐字节取出环形缓冲区的数据,这和MATLAB端的processByte是一模一样的逻辑。你可以在MCU端实现一个等效的状态机,这样两端协议解析思路完全对齐,排查问题时两边对着看,逻辑非常清晰。

4.3 发送与接收的互斥处理

如果你的MCU使用的是像STM32 HAL库这类带互斥锁的驱动,或者操作系统中有多个任务会同时调用串口发送,就必须考虑"组帧"和"发送"之间的原子性。比如一个任务正在调用HAL_UART_Transmit发送传感器帧,另一个任务突然插入一个printf调试信息,接收端就会收到错乱的字节流。解决办法是定义一个发送队列,所有想发送数据的任务都先把数据扔进队列,由一个专门的发送任务或发送状态机统一处理。不推荐的做法是直接对HAL_UART_Transmit加全局中断屏蔽,那会让其他中断产生抖动。

5. 联调实战中的高频故障:完整排查链路复盘

5.1 第一个坑:MATLAB收到的字节全是0xFF

刚把MCU和MATLAB通过USB转串口连起来时,我遇到过MATLAB读到的全是0xFF的诡异情况。排查过程是这样的:先用示波器去看MCU TX引脚有没有波形,发现电平恒为高。进一步检查MCU初始化代码时,发现串口时钟初始化写在了引脚复用初始化之前,串口外设虽然配置成功,但引脚复用没有正确设置,导致数据根本没从TX引脚出来。这类问题用排查链路来看应该是:MATLAB读到固定值 -> 检查串口是否打开成功 -> 检查USB转串口驱动是否正常 -> 用示波器查MCU TX引脚输出 -> 检查MCU引脚复用与串口初始化顺序。

0xFF的典型原因是串口空闲态下的电平被误读为有效数据,而TX引脚恒高时MCU端如果误触发接收中断,读回来的字节自然就是0xFF。

5.2 第二个坑:半包和粘包导致解析错位的处理

用1200bps的低波特率调试时几乎见不到半包问题,但一换成921600bps,数据帧在MATLAB端经常被截成两段。原因很好理解:MCU端是把整帧一次性扔给串口外设的,但串口外设的发送移位寄存器是一个字节一个字节移出,USB转串口芯片再把这些字节经过USB bulk传输打包,上位机接收端Windows串口驱动收到时可能一次性给出全部字节,也可能分多次给。所以MATLAB端不能假设"每次read调用恰好返回一帧"。

正确思路就是文章前面提到了状态机。只要状态机能处理逐字节输入,无论底层怎么分包都能正确组帧。粘包问题类似:两帧数据紧紧挨在一起到达,状态机在处理完第一帧后会自然进入找帧头的状态,不会把第二帧的开头吞掉。

5.3 第三个坑:波特率误差在高速率下如何压垮通信

很多人用的都是9600或115200,觉得波特率误差无所谓。但如果你跑1M以上的高波特率,MCU端的外部晶振精度、内部RC振荡器漂移、USB转串口芯片的时钟误差都会产生影响。MCU输出时钟误差和USB转串口芯片误差累计后,如果超过UART接收端的容忍范围,就会产生不可恢复的乱码。

实战中我遇到过:MCU端初始化串口时用了非标准波特率参数,例如给USART_BRR寄存器手动赋值时算错了一次分频值,实际波特率变成了105234而不是115200。联调初期因为数据量小看不出问题,但连续传了10分钟之后数据开始周期性乱码。排查时用示波器测量单bit宽度,发现与理论值差出近6%。所以联调一开始就要用示波器量一下TX引脚的波形,确认位宽准确再继续。

5.4 第四个坑:浮点数的传输与字节序错乱

这个坑特别典型。MCU端如果直接发一个float变量,MATLAB端收到后强转成single,经常得到一个天文数字。原因在于MCU和MATLAB对浮点数的表示是IEEE 754,这本身没有分歧,问题出在字节序。小端模式下,MCU把float的4个字节按低地址到高地址依次送出,MATLAB端如果按大端方式组合,自然就错乱了。

我的做法是在协议层对浮点做显式转换,不依赖编译器默认行为。比如,将浮点数转成uint32的位模式,再按小端序拆成4个字节发送:

float temperature = 25.6f; uint32_t temp_bits; memcpy(&temp_bits, &temperature, 4); // 把float的位模式copy进uint32 uint8_t send_buf[4]; send_buf[0] = temp_bits & 0xFF; send_buf[1] = (temp_bits >> 8) & 0xFF; send_buf[2] = (temp_bits >> 16) & 0xFF; send_buf[3] = (temp_bits >> 24) & 0xFF;

MATLAB端再按小端序拼回去:

rxBytes = [0x9A, 0x19, 0xCC, 0x41]; % 示例字节 temp_bits = uint32(rxBytes(1)) + bitshift(uint32(rxBytes(2)), 8) + ... bitshift(uint32(rxBytes(3)), 16) + bitshift(uint32(rxBytes(4)), 24); temperature = typecast(uint32(temp_bits), 'single');

typecast的作用是保留位模式不变,只重解释数据类型,比cast安全得多。实际跑出来的25.6f在内存中是0x41CC199A,按小端发送后MATLAB端拼回来正好还原。

5.5 第五个坑:MATLAB串口读满缓存之后不自动刷新

这是MATLAB端最容易莫名其妙丢数据的坑。serialport对象内部有一个接收缓冲区,如果你没有及时读取,积压的数据超过缓冲区容量后会触发溢出,最早期版本的serialport接口会直接丢弃最旧的数据。虽然新版本默认行为是保留旧数据然后丢弃新数据,但如果你用的是老版本或者没有设置BytesAvailableFcn回调,数据满了之后解析会越来越滞后。

我的建议是在接收循环里每次把NumBytesAvailable所有字节全部读走,不要只按帧长度读。读出来后状态机逐字节处理,缓存自然就腾出来了。另外可以周期性检查serialObj.NumBytesAvailable,如果长期保持高位,说明MATLAB端处理速度跟不上,这时需要考虑降低波特率或者批量组帧,而不是单纯加大缓冲区。

6. 帧格式的进阶玩法:多帧合并、自动识别类型与离线回放

6.1 一帧数据里塞多条传感器读数:矩阵化传输

MCU端批量采集了100个加速度样本,如果一帧只发一个样本,每秒要传几百次帧,效率极低。这时候合理的做法是把100个样本的原始字节直接拼进帧的数据区,同时用两个额外字节表示"样本数",MATLAB端拿到后一次性重组成一个100×3的矩阵,解析效率大幅提升。

比如三轴加速度ax/ay/az每个是int16,100个样本就是600字节,数据区加2字节表示样本数,一帧总共602字节。这样在帧协议里只是数据区变长了,帧头、长度、类型、校验逻辑完全不变。MATLAB端解析时调用reshape或者直接把字节数组转成矩阵,比逐样本解析快一个量级。

6.2 用类型字段区分上行与下行,为控制闭环留好扩展位

做闭环控制时,MCU不仅仅要上传感数据,还要接收MATLAB下发的目标位置或PID参数。这两种信息用同一个帧结构可以,但类型字段必须分开。我习惯用0x01~0x0F表示上行数据,0x81~0x8F表示下行指令。这样在排查时,只要看到类型字段的最高位,就能立刻说出这帧是MCU发给MATLAB还是MATLAB发给MCU。实际解析时,上行帧在MATLAB端解析,下行帧在MCU端解析,两端的代码逻辑完全镜像。

6.3 离线回放模式:把bin文件丢给MATLAB当数据源

这个技巧让我省了很多事。纯在线联调时,每次都要发数据、断点、跑算法,效率很低。后来我把MCU端接收到的原始字节全部记录成一个.bin文件,然后MATLAB端写一个loadFrameFile函数,把.bin文件里的内容当成模拟的串口数据喂给同一个状态机。这样一来,不需要MCU在线也能验证解析算法、测试边界条件。

实现上,只需要把read替换成fread(fid, 'uint8')即可。状态机完全复用,解耦了"数据来源"和"解析逻辑"。有了离线数据之后,MATLAB的绘图、滤波、参数识别都能反复调,不用反复请求MCU重新发数据。

6.4 校验失败后的重传机制:从"显示错误"升级到"主动纠错"

前面提到的基本帧格式只有校验没有重传,这适合数据采集场景。但如果你要下发控制指令,丢了就麻烦了。我后来在下行链路里加了一个简单的序号字段应答机制:MATLAB发送带序号的指令帧,MCU收到并校验成功后回复一条相同序号的确认帧;如果MATLAB在超时时间内没收到确认,就重发一次。这个机制不复杂,但能解决控制指令丢失这个致命问题。

重传逻辑做在MATLAB端即可,MCU端只需在收到正确帧后回一条确认帧。注意序号字段要加在数据区或者类型之后的固定位置,不能只靠帧头去区分,否则MATLAB无法判断是哪一条指令被确认了。

7. 从联调中获得的一些实实在在的心得

真正把MCU和MATLAB用帧格式通信跑通之后,我有几个比较深的感受想分享。

第一个感受是关于协议的。协议这东西,看起来是死的、格式化的,实际上最考验人的统筹能力。你定帧头、定长度、定校验时做的每一个决定,都会在后续所有环节中放大。帧头定短了,误判率上升;长度字段含不含自身,直接决定两端代码是否一致;校验方式选重了,MCU端代码复杂度上去了,选轻了,心里不踏实。我的经验是:不要追求一次把协议定得完美,而是先定一个能被两端快速实现的最小版本,联调通过后再逐步增加字段功能。原因很简单,脱离开实际硬件环境谈协议优化都是空谈。

第二个感受是调试工具的优先级。不要一上来就用MATLAB的plot画实时曲线,先打十六进制字节流。我强烈建议联调过程中每隔一段时间,把MATLAB端收到的原始字节转成dec2hex打印出来,或者直接输出到文本文件。这样做虽然原始,但最直观——你能清楚地看到每个字节有没有错位、帧头有没有漂移。等原始字节流稳定了,再去画曲线、算频谱,事半功倍。

第三个感受是环形缓冲区的价值。无论MCU端还是MATLAB端,带状态机解析的通信系统都离不开缓冲区。MCU端用环形缓冲区接收,MATLAB端用numBytesAvailable和状态机,本质上都是"缓存 + 状态迁移"的套路。一套成熟的帧通信框架,值得花时间仔细打磨,它几乎可以复用到你未来所有的嵌入式上位机联调项目里。

最后一个特别实际的建议:联调时在MCU端留一个调试串口,把每一帧的原始十六进制数据同输出到调试串口。MATLAB端如果解析出错,可以直接对比两个串口的字节流。这个小技巧救过我很多次——有一次我死活查不出MATLAB为什么丢字节,最后发现是USB转串口芯片刚好有两个不同的串口节点,MATLAB连错了设备。这类问题用逻辑分析仪看总线数据根本发现不了,只有对比MCU调试串口和MATLAB读数才能定位。

MCU与MATLAB的帧格式通信,本质上就是确定双方都能理解的"字节组织规则"和"状态迁移逻辑"。规则明确,联调顺畅;规则模糊,后面的所有工作都会变成在错误堆上打补丁。希望这篇文章能帮你从一开始就把这套规则立稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询