maixcam,人脸识别、串口数据协议
串口通信协议设计与实现 摘要 本文详细介绍了嵌入式系统中串口通信协议的设计与实现方法,主要内容包括: 协议设计必要性 串口通信的"流式"特性导致数据边界模糊,需引入协议解决粘包问题 对比字符协议(ASCII)和字节协议(Binary)的优劣,推荐工业级二进制协议 核心技术要点 大小端(Endianness)问题解析及解决方案 Python struct模块的pack/unpac
maixcam,人脸识别、串口数据协议
摘要
本文是一份从零开始的 嵌入式 AIOT 串口通信实战指南,涵盖了从原理讲解、Python 发送端实现到 STM32 C 语言接收端的全流程。核心要点如下:
- 协议设计的必要性:
- 解释了串口通信的“流式”特性(像水流一样无界限),阐述了为什么必须引入**“帧头+长度+载荷+校验+帧尾”**的协议结构来解决粘包和数据错乱问题。
- 对比了字符协议(ASCII)与字节协议(Hex/Binary)的优劣,确立了工业级开发应遵循二进制传输标准。
- 核心技术难点攻关:
- 大小端(Endianness):图解了数据在内存中的存储差异,强调了 Python 端
<(小端)与 STM32 端保持一致的重要性,防止数据解析错误。 - Struct 模块:详解 Python
struct.pack/unpack的使用,如何将坐标(x, y, w, h)高效打包成二进制流。 - 位运算校验:通俗解释了
(sum & 0xFF)的数学含义(防止溢出截断),确保通信可靠性。
- 大小端(Endianness):图解了数据在内存中的存储差异,强调了 Python 端
- Python 端源码剖析:
- 深度拆解
Serial_protol_demo.py,针对小白常见的痛点(如切片索引计算、bytearray缓冲池机制、寻找帧头逻辑)进行了逐行解读。 - 提供了 MaixCam 进行人脸识别并发送坐标的完整业务代码。
- 深度拆解
- AI 赋能嵌入式开发:
- 展示了“新时代”开发模式:利用 AI(GPT-5.2 / Gemini 3 Pro)根据 Python 协议自动生成对应的 STM32 C 语言接收代码。
- 提供了基于 HAL 库 和 状态机(State Machine) 的标准 C 代码实现,完美解决了串口字节流解析难题。
第一部分:为什么串口传输需要“协议”?
这部分解释了引入“协议”的根本原因。串口(UART)本身只负责把一个个字节发出去,但它不知道这些字节代表什么意义。
1. 串口的“流式”数据传输
- 概念: 串口传输就像水流(Stream),数据是一个接一个流出来的,没有天然的“界限”。
- 图中的例子: 假设你用 MaixCam(一款AI摄像头)做图像识别,识别到一个矩形框,需要把它的坐标
1, 2, 3, 4发给单片机。 - 问题: 如果直接发
1234,单片机接收时可能会困惑:是1, 23, 4?还是12, 3, 4?或者因为干扰,数据分两次到达,先到了12,过一会才到34。单片机怎么知道这一组数据什么时候结束? - 结论: 必须制定规则,告诉接收方“哪里是开始,哪里是结束,这堆数据是什么”。这就是协议。
2. 典型字符协议 (ASCII)
- 原理: 把数据转换成人类可读的字符串,用特殊符号做分隔。
- 图中的例子:
"$1,2,3,4#"$:帧头(Start),表示数据开始了。,:分隔符,区分不同的数值。#:帧尾(End),表示这句话说完了。
- 优缺点:
- 优点: 简单,人眼能看懂,调试方便(直接用串口助手看)。
- 缺点: 效率低(数字
100要占3个字节,而二进制只要1个字节),解析代码(如C语言的sscanf或字符串处理)相对繁琐且慢。
3. 典型字节协议 (Hex/Binary)
- 原理: 直接传输二进制数值,更紧凑高效,是工业级开发的标准做法。
- 典型的包结构(图示):
帧头(1Byte):固定值(如0xAA),用于在混乱的数据流中找到包的起始位置。负载长度(2Byte):告诉接收方后面还有多少个字节是有效数据。负载(nByte):真正的数据内容(比如那4个坐标)。校验(1Byte):数学计算(如CRC或累加和),确保数据在传输中没有出错。帧尾(1Byte):可选,表示结束。
第二部分:实现协议组包/解包主要工具
这部分是图中的核心(蓝色框选部分),主要讲如何在代码层面处理二进制数据,特别是大小端问题。
1. 数据大小端概念 (Endianness) —— 重点 这是嵌入式通信中最容易踩坑的地方。计算机存储多字节整数(如 int, long)时,字节的顺序有两种方式:
-
小端 (Little-Endian): 低位字节存在低地址。
- 图示: 整形数值
1,十六进制是0x00000001。在小端模式下存储为01 00 00 00。 - 常见场景: 大部分现代CPU(Intel x86, ARM Cortex-M系列等)。
- 图示: 整形数值
-
大端 (Big-Endian): 高位字节存在低地址(符合人类阅读习惯)。
- 图示: 整形数值
1,存储为00 00 00 01。 - 常见场景: 网络传输标准(TCP/IP)、某些老式通信协议。
- 图示: 整形数值
-
代码示例:
int len = 0; memcpy(&len, buffer, 4);解读: 如果发送方用“大端”发了一个整数(
00 00 00 01),而接收方(单片机)是“小端”架构。直接用memcpy把这4个字节拷进内存,单片机读取时会把它当成0x01000000(十进制 16,777,216)。数据瞬间变大了1600万倍! 解决: 必须在协议中规定好是用大端还是小端,并在代码中做转换。
没看懂的话看看下面的例子:
大端序:
//对于一个整数 0x12345678,在内存中的存储顺序是:
地址: 0 1 2 3
数据: 12 34 56 78
//网络协议通常采用大端序(例如,TCP/IP)。
小端序:
//对于一个整数 0x12345678,在内存中的存储顺序是:
地址: 0 1 2 3
数据: 78 56 34 12
//大多数现代PC(如x86架构)使用小端序。
在内存中存放整型数值168496141 需要4个字节,这个数值的对应的16进制表示是0X0A0B0C0D,这个数值在用大端序和小端序排列时的在内存中的图示更易于理解:

有空的话可以看看这篇文章,我感觉他写的挺好的初识C语言(数据在内存中的存储) - 实践 - gccbuaa - 博客园
2. struct 模块, pack/unpack 方法
- 这是 Python 中处理二进制数据的神器(对应 C 语言的 Struct)。
- 示例:
struct.pack('<i', 1)<:代表强制使用 小端 模式。>:代表强制使用 大端 模式。i:代表 4字节整数 (int)。
- 作用: 它能自动把你的变量(如坐标
120)转换成符合协议要求的字节串(x78\x00\x00\x00),省去了手动移位的麻烦。
3. 缓冲区 (Buffer)
- 原因: 串口接收不是一次性完成的。可能你发了20个字节,单片机第一次中断只收到了5个,第二次收到了15个。
- 做法: 需要一个“蓄水池”(缓冲区
bytearray),先把收到的数据暂存起来,凑够完整的一包数据后再进行解析(解包)。
第三部分:实际代码讲解
话不多说,先看源码
技术支持请看视频【视觉模块MaixCam串口协议数据传输】https://www.bilibili.com/video/BV1JiYWeGEcp?vd_source=04ec85bac321be52c18d0e8083e4c6bc,这个up主会将解每一步
Serial_protol_demo.py
import struct #调用struct的pack和unpack函数
'''
协议数据格式:
包头(0XAA) + 数据域长度(小端序) + 数据域 + 校验和([长度字段]+[数据域所有字节]) + 包尾(0X55)
1字节 2 字节 n字节 1字节 1字节
'''
class SerialProtocol():
HEAD = 0XAA #包头
TAIL = 0X55 #包尾
def __init__(self) -> None:
pass
def _checksum(self,data:bytes) -> int:
'''
计算校验和
'''
check_sum = 0
for a in data:
check_sum = (check_sum + a) & 0XFF
return check_sum
def is_vaild(self,raw_data:bytes) -> tuple:
'''
判断数据是否合法
'''
bytes_redundant = 0 #计算到匹配到包头时前面出现的多余的数据 redundant = 多余的 / 冗余的 / 不必要的
index = 0 #索引值
for a in raw_data:
if a != SerialProtocol.HEAD :
index += 1
else :
break
bytes_redundant = index #计算到匹配到包头时前面出现的多余的数据
if(len(raw_data[index:]) < 3): #判断长度够不够三个,因为包头 + 长度域 是三个字节
return(-1,bytes_redundant)
payload_len = struct.unpack('<H',raw_data[index+1: index+3])[0] #获取数据域长度 # 返回的是一个 tuple:(payload_len,) ,所以所以才要加[0]
if(len(raw_data) - bytes_redundant < payload_len + 5): # payload_len + 5 : 5 是包头(0XAA) + 数据域长度(小端序) + 数据域 + 校验和([长度字段]+[数据域所有字节]) + 包尾(0X55) 中除去数据域长度后其他几个数据的字节和
return(-2,bytes_redundant)
if (raw_data[index + 3 +payload_len + 1] != SerialProtocol.TAIL) or (self._checksum(raw_data[index+1:index+ 2 + payload_len + 1])) != raw_data[index + 3 + payload_len]:
return(-3,bytes_redundant)
else:
return(0,bytes_redundant)
def get_length(self,raw_data:bytes)->int:
'''
取得有效数据包的整体长度
'''
if len(raw_data) < 5 or raw_data[0] != SerialProtocol.HEAD or raw_data[-1] != SerialProtocol.TAIL:
return -1
payload_len = struct.unpack('<H',raw_data[1:3])[0]
return (1+2+payload_len+1+1)
def data_encode(self,payload)->bytes : # payload = 数据域
'''
编码数据负载部分,添加帧头帧尾校验等部分
'''
frame = bytearray()
frame.append(SerialProtocol.HEAD)
frame.extend(struct.pack('<H',len(payload)))
frame.extend(payload)
frame.append(self._checksum(frame[1:]))
frame.append(SerialProtocol.TAIL)
return bytes(frame)
def data_decode(self,raw_data:bytes)->bytes:
'''
解码出数据负载的部分
'''
if len(raw_data) < 5 or raw_data[0] != SerialProtocol.HEAD or raw_data[-1] != SerialProtocol.TAIL:
return bytes()
payload_len = struct.unpack('<H',raw_data[1:3])[0]
return raw_data[3:3+payload_len]
if __name__ == '_main__':
payload = 'hello'
proto = SerialProtocol()
encoded = proto.data_encode(payload.encode()) # payload.encode() 将字符串变为字节数组 payload.encode()等价于:bytes(payload, encoding='utf-8')
print(encoded.hex())
encoded = bytes([0x01,0x02]) + encoded
vaild = proto.is_vaild(encoded)
print(vaild)
decoded = encoded[vaild[1]:]
decoded = proto.data_decode(decoded)
print(decoded.decoded())
5 个“拦路虎”
1. 神秘的字符:struct.pack('<H', ...) 和 unpack
这是整段代码最核心,也最容易让人懵圈的地方。
- 小白疑惑点:
struct是啥?结构体?'<H'是什么暗号?为什么有时候是<H,有时候是<iiii?- 为什么
unpack后面还要加个[0]?
- 通俗解读:
- 翻译官: Python 里的数字(比如
12)和 C 语言/单片机里的存储方式不一样。struct就是一个“翻译官”,把 Python 的数字变成单片机能听懂的二进制字节流。 - 暗号破解 (
<H): 这是一个格式模板。<:代表 小端序 (Little Endian)。意思是“低位在前”。比如数字1(hex:0x0001),变成字节是01 00,而不是00 01。单片机通常喜欢这种反直觉的格式。H:代表 unsigned Short (无符号短整型),占 2个字节。用来表示长度(因为数据通常不会超过 65535 字节)。- 之前的
<iiii:代表 4 个 int (整数),每个占 4 字节。
- 关于
[0]:struct.unpack总是假设你要解包好几个数据,所以它返回的是一个元组(Tuple),比如(16, )。为了拿到里面的数字16,必须用[0]取第一个元素。
- 翻译官: Python 里的数字(比如
2. 奇怪的数学:(check_sum + a) & 0xFF
出现在 _checksum 函数里。
- 小白疑惑点:
- 为什么加完了还要
& 0xFF?这是什么数学运算?
- 为什么加完了还要
- 通俗解读:
- 防止溢出(时钟原理): 单片机里的一个字节(Byte)最大只能存
255。如果校验和加起来变成了256,字节就存不下了。 & 0xFF的作用就像 时钟。当时钟指到 12 点再走一格,就回到 1 点,而不是变成 13 点。& 0xFF强制让计算结果只保留最后 8 位二进制,保证结果永远在0~255之间。这在专业术语叫“截断”。
- 防止溢出(时钟原理): 单片机里的一个字节(Byte)最大只能存
3. 复杂的切片索引:raw_data[index+1: index+ 2 + payload_len + 1]
出现在 is_vaild(注:源码单词拼错了,应该是 valid)函数里。
-
小白疑惑点:
- 这一串加减法看得人头晕,根本不知道它在切哪一段数据。
- 为什么 Python 的切片有时候含尾部,有时候不含?
-
通俗解读: 这是在在一堆字节里“画地图”。我们可以把协议包想象成一列火车:
[0]:火车头 (0xAA)[1:3]:告诉你是几节车厢 (长度)[3: ...]:乘客 (数据)[...]:检票员 (校验)[最后]:火车尾 (0x55)
代码里之所以写得那么复杂,是因为它在做动态计算。因为
payload_len(数据长度)是不固定的,所以必须用数学公式算出校验位和帧尾究竟躲在哪个位置。
4. 为什么要算 bytes_redundant(多余数据)?
出现在 is_vaild 函数开头。
- 小白疑惑点:
- 为什么不直接判断
raw_data[0]是不是0xAA? - 搞个
for循环找HEAD是图啥?
- 为什么不直接判断
- 通俗解读:
- 现实很残酷: 在真实的串口传输中,数据可能会出错,或者你接线的时候手抖了一下,导致前面接收了一堆乱码,比如
00 01 FF AA 05 ...。 - 如果直接看第0个字节,发现是
00(不是AA),程序就报错退出了,那后面的真数据AA就被丢弃了。 - 这段逻辑就像 “金属探测器”:它在一堆土(乱码)里一个个找,直到找到金子(
0xAA帧头),然后告诉程序:“前面的土都可以扔了(redundant),从这里开始才是真数据”。
- 现实很残酷: 在真实的串口传输中,数据可能会出错,或者你接线的时候手抖了一下,导致前面接收了一堆乱码,比如
5. bytearray vs bytes
出现在 data_encode 函数里。
- 小白疑惑点:
- 为什么一会用
bytearray(),一会又return bytes(frame)?这就好比一会用铅笔,一会用钢笔。
- 为什么一会用
- 通俗解读:
- 橡皮泥 vs 陶瓷:
bytearray是 橡皮泥。你可以随时往里面捏东西(append)、加东西(extend)。所以在拼装数据包的时候用它。bytes是 烧好的陶瓷。一旦生成就不能改了。Python 的网络和串口发送函数通常喜欢这种“定型”的数据,比较安全。
- 所以流程是:先用橡皮泥捏好形状(拼装包头、长度、数据),最后烧成陶瓷(转成
bytes)发出去。
- 橡皮泥 vs 陶瓷:
总结给小白的“读码心法”
如果你以后再看这种协议代码,抓住这三点:
- 找结构: 先看
struct.pack里的格式(如<H),这就知道了数据的骨架。 - 看地图: 任何协议都有“头、长、身、尾”,对照代码里的索引数字,画个图就能看懂。
- 懂位运算: 看到
& 0xFF、>> 8这种,通常都是为了把大数字塞进小字节里,不用深究数学原理,知道是“为了符合硬件胃口”就行。
串口协议代码「小白易卡点」总结
本总结从刚接触串口通信与协议设计的小白视角出发,归纳在阅读这段
SerialProtocol代码时最容易产生困惑的地方,并给出直观、易理解的解释方向。
一、最大前提误区:raw_data 不是“一整包数据”
小白常见误解
- 以为
raw_data一定是:包头 + 长度 + 数据 + 校验 + 包尾
实际真相
raw_data是:目前为止串口收到的所有字节拼在一起的结果
- 它可能是:
- 半包
- 两包粘在一起
- 前面夹杂无关数据(噪声)
关键认知
串口是“流式传输”,不是“一包一包传”
二、bytes_redundant 为什么存在?
小白疑问
- 为什么要“跳过前面的数据”?
- 为什么不直接从
raw_data[0]开始解析?
核心原因
- 串口接收可能:
- 从包中间开始
- 或夹杂历史残留字节
- 所以必须:
先找到真正的包头(0xAA)
bytes_redundant 的作用
- 表示:
在真正包头之前的“无效字节数量”
三、payload_len + 5 中的 “5” 从哪来?
小白困惑
- “5” 看起来像魔法数字
实际来源(协议结构)
包头(1) + 长度(2) + 校验(1) + 包尾(1) = 5 字节
判断逻辑含义
当前数据够不够拼成一个完整的数据包
四、校验判断那一行为什么看不懂?
问题本质
- 一行代码做了太多事:
- 截取数据
- 计算校验和
- 与接收到的校验值比较
建议教学方式
- 拆成三步写
- 明确:
校验的是「长度字段 + 数据域」
五、raw_data[-1] 为什么代表包尾?
小白常见卡点
- 不熟悉 Python 负索引
关键知识
- 在 Python 中:
-1表示最后一个元素
- 在协议中:
- 最后一个字节是 包尾(0x55)
六、为什么校验不包含包头?
小白疑问
- 为什么不把
0xAA也算进校验?
协议设计原则
- 包头作用:
- 同步帧起点
- 校验目的:
- 验证“可能被破坏的数据”
结论
校验规则是人为约定,只要通信双方一致即可
七、is_vaild() 返回值为什么是 tuple?
小白易犯错误
- 只关心
rc - 忽略
bytes_redundant
实际意义
- 返回值表示:
- 是否存在完整有效数据包
- 包头前有多少“垃圾数据”需要丢弃
一句话理解
is_vaild()不是“判断对不对”,
而是:
“当前这堆字节里,能不能找到一包完整数据?”
然后是protocol_test.py
'''
### 给小白的总结(核心逻辑)
1. **拍照**。
2. **找人脸**。
3. **如果找到了**:
- 把坐标 (x,y,w,h) **打包**成二进制。
- 加上协议头(为了防止传输错误)**发出去**。
- 因为你短接了线,数据马上**流回来**。
- 你**接收**数据,**验证**包没坏,然后**解包**变回坐标。
- 打印出来,证明这套“打包-发送-接收-解包”的流程是通的。
4. **显示画面**。
'''
from maix import camera,display,image,nn,app,uart
import struct
import sys
# 告诉程序:去 '/root/exam' 这个文件夹里找我自己写的代码
sys.path.append('/root/exam')
import Serial_protol_demo# 导入你那个负责“封包/解包”的助手
# 1. 装上大脑:加载人脸识别模型(Retinaface)
detector = nn.Retinaface(model="/root/models/retinaface.mud")
# 2. 睁开眼睛:初始化摄像头,分辨率跟模型要求的一样
cam = camera.Camera(detector.input_width(),detector.input_height(),detector.input_format())
# 3. 准备屏幕:初始化显示屏
dis = display.Display()
# 4. 请来秘书:初始化你写的那个协议助手
comm_proto = Serial_protol_demo.SerialProtocol()
# 5. 接通电话线:打开串口 (UART)
# "/dev/ttyS0" 是串口的名字,115200 是说话的语速(波特率)
device = "/dev/ttyS0"
serial = uart.UART(device,115200)
# 6. 准备个空桶:用来暂时存放接收到的数据
data_buffer = bytearray()
while not app.need_exit(): # 只要没按退出键,就一直跑
img = cam.read() # 【第一步】眼睛看:拍一张照片
#发送数据
# 【第二步】大脑想:在这张照片里找人脸
# conf_th 是自信度,iou_th 是重叠度(不用深究,就是调整灵敏度的)
objs = detector.detect(img,conf_th = 0.4,iou_th = 0.45)
# 如果找到了人脸(objs里有东西),就开始遍历每一张脸
for obj in objs:
img.draw_rect(obj.x,obj.y,obj.w,obj.h,color = image.COLOR_RED) # 在照片上给脸画个红框框
print(obj.x,obj.y,obj.w,obj.h) # 在终端打印一下坐标(给你自己看的)
# === 关键点:打包数据 ===
# struct.pack 是把 4 个整数 (x, y, w, h) 压缩成二进制流。
# '<iiii' 的意思是:
# '<' : 用小端模式(机器能听懂的顺序)
# 'iiii': 这里面有 4 个 int (整数)
payload = struct.pack('<iiii',obj.x,obj.y,obj.w,obj.h)
# === 加信封 ===
# 用你的协议助手,给数据加上“包头”和“校验码”。
# 就像写信要装进信封并封口,防止路上丢了或坏了。
encoded = comm_proto.data_encode(payload)
# === 寄出去 ===
# 通过串口把这包数据发出去
serial.write(encoded)
#--------------------------------------
#接收数据--将maixcam的Tx和Rx相接即可实现自发自收
length = serial.available() # 问串口:现在收到多少数据了?
if length > 0:
data = serial.read(length) # 全部读出来
data_buffer += data # 倒进之前的“空桶”里(因为数据可能分几次来,要拼起来)
# === 检查数据是否完整 ===
# rc: 结果代码, bytes_redundant: 多余的废数据长度
rc,bytes_redundant = comm_proto.is_vaild(data_buffer)
if bytes_redundant > 0: # 如果前面有一堆乱码(废数据),把它们切掉扔了
data_buffer = data_buffer[bytes_redundant:]
if rc >= 0: # 如果 rc >= 0 说明桶里至少有一个完整的数据包了
# === 拆信封 ===
# 把有效内容(payload)取出来
result = comm_proto.data_decode(data_buffer)
# === 翻译内容 ===
# 我们知道发出去的是 4 个 int,一共 16 个字节 (4字节 x 4)
if len(result) == 16:
# struct.unpack 是 pack 的反操作
# 把二进制变成人类能看懂的 x, y, w, h
x,y,w,h = struct.unpack('<iiii',result)
print('{},{},{},{}'.format(x,y,w,h))
# === 清理桶 ===
# 处理完这个包了,把它从桶里删掉,给后面的数据腾地方
packet_length = comm_proto.get_length(data_buffer)
data_buffer = data_buffer[packet_length:]
# 【最后一步】把画了框框的图显示在屏幕上
dis.show(img)
在MaixVision中我们还需要操作一下

将设备连接好,然后点击右边的文件管理器(一个像文件夹的东西)

这里我新建了一个名为exam的文件夹,然后进入

点击上传文件,将我们电脑中Serial_protol_demo.py的上传上去即可

然后我们就可以启动,开始跑了

大工告成
New Era(新时代)
现在已经是ai时代了,这里顺便教一下大家使用ai,现在我的maixcam端的代码已经没问题了,那么我们正常的话是maixcam和stm32通讯,那么我们是不是还差stm32端的代码,原来的话我们可能需要自己写,但是现在有了ai就很方便,我们来操作ai,这里我“随机”选了两个幸运助手-----gemini 3 pro 和 GPT5.2
(这里请你附上代码,最好把两个代码都上传,让他充分了解信息)如果我的maixcam用的是这个代码,然后串口和stm32相连接,那么我的stm32应该怎么样来解析接收的代码呢,请你给我写个stm32的示例代码
GPT5.2
太好了,你这一步已经把MaixCam 端 + 协议细节全部给齐了 👍
下面我严格按照你给出的 SerialProtocol 协议定义,给你写一份:
✅ STM32 端如何正确解析 MaixCam 发来的数据
✅ 完全一一对应你的 Python 协议代码
✅ 小白也能顺着看懂的版本
我会分成 4 个层次 来讲:
一、先统一“通信共识”(非常重要)
1️⃣ 你的协议格式(最终确认版)
你在 Python 里定义的协议是 👇
| HEAD | LEN_L | LEN_H | PAYLOAD | CHECKSUM | TAIL |
具体字节数:
| 字段 | 长度 | 说明 |
|---|---|---|
| HEAD | 1 | 0xAA |
| LEN | 2 | 小端,payload 长度 |
| PAYLOAD | N | struct.pack(‘<iiii’) → 16 字节 |
| CHECKSUM | 1 | (LEN + PAYLOAD) 所有字节之和 & 0xFF |
| TAIL | 1 | 0x55 |
👉 STM32 必须 100% 按这个规则解析,否则一定错
2️⃣ STM32 最终要拿到什么?
MaixCam 发的是:
struct.pack('<iiii', x, y, w, h)
所以 STM32 最终要得到:
int32_t x, y, w, h;
二、STM32 端整体解析思路(先用人话)
STM32 不会一次收到“一包完整数据”,
而是 一个字节一个字节地收
所以正确姿势是:
1️⃣ 串口中断接收 1 个字节
2️⃣ 用 状态机 判断现在在收哪一部分
3️⃣ 收齐一整包后
4️⃣ 校验
5️⃣ 解包 x,y,w,h
三、STM32 示例代码(HAL 库,可直接用)
假设你用的是
USART1
假设已经在 CubeMX 里打开 UART + 中断
1️⃣ 宏定义(和 Python 完全一致)
#define FRAME_HEAD 0xAA
#define FRAME_TAIL 0x55
#define RX_BUF_SIZE 64
2️⃣ 全局变量 & 状态机
uint8_t rx_byte; // 串口一次接收 1 字节
uint8_t payload_buf[RX_BUF_SIZE]; // 存 payload
uint16_t payload_len = 0;
uint16_t payload_index = 0;
uint8_t checksum_calc = 0;
uint8_t checksum_recv = 0;
typedef enum
{
WAIT_HEAD,
WAIT_LEN_L,
WAIT_LEN_H,
WAIT_PAYLOAD,
WAIT_CHECKSUM,
WAIT_TAIL
} RX_STATE;
RX_STATE rx_state = WAIT_HEAD;
3️⃣ 校验和函数(完全等价 Python _checksum())
uint8_t calc_checksum(uint8_t *data, uint16_t len)
{
uint8_t sum = 0;
for (uint16_t i = 0; i < len; i++)
{
sum += data[i];
}
return sum;
}
4️⃣ 串口中断回调(核心代码)
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
switch (rx_state)
{
case WAIT_HEAD:
if (rx_byte == FRAME_HEAD)
{
rx_state = WAIT_LEN_L;
}
break;
case WAIT_LEN_L:
payload_len = rx_byte;
rx_state = WAIT_LEN_H;
break;
case WAIT_LEN_H:
payload_len |= (rx_byte << 8);
payload_index = 0;
rx_state = WAIT_PAYLOAD;
break;
case WAIT_PAYLOAD:
payload_buf[payload_index++] = rx_byte;
if (payload_index >= payload_len)
{
rx_state = WAIT_CHECKSUM;
}
break;
case WAIT_CHECKSUM:
{
checksum_recv = rx_byte;
uint8_t len_bytes[2];
len_bytes[0] = payload_len & 0xFF;
len_bytes[1] = payload_len >> 8;
checksum_calc = calc_checksum(len_bytes, 2);
checksum_calc += calc_checksum(payload_buf, payload_len);
rx_state = WAIT_TAIL;
break;
}
case WAIT_TAIL:
if (rx_byte == FRAME_TAIL && checksum_calc == checksum_recv)
{
// 🎉 成功接收到一整包合法数据
process_payload(payload_buf, payload_len);
}
rx_state = WAIT_HEAD;
break;
}
// 继续接收下一个字节
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
}
}
5️⃣ 解包 payload(对应 struct.unpack(‘<iiii’))
void process_payload(uint8_t *data, uint16_t len)
{
if (len != 16) return;
int32_t x, y, w, h;
x = (int32_t)( data[0] | data[1]<<8 | data[2]<<16 | data[3]<<24 );
y = (int32_t)( data[4] | data[5]<<8 | data[6]<<16 | data[7]<<24 );
w = (int32_t)( data[8] | data[9]<<8 | data[10]<<16 | data[11]<<24 );
h = (int32_t)( data[12] | data[13]<<8 | data[14]<<16 | data[15]<<24 );
// 到这里,STM32 已经拿到了 MaixCam 的人脸坐标
printf("Face: x=%ld y=%ld w=%ld h=%ld\r\n", x, y, w, h);
}
6️⃣ main() 里启动接收(只需一次)
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
四、MaixCam ↔ STM32 对照表(帮你彻底吃透)
| MaixCam (Python) | STM32 © |
|---|---|
struct.pack('<iiii') |
位运算解包 |
data_encode() |
状态机 |
checksum() |
calc_checksum() |
bytearray() |
payload_buf[] |
is_vaild() |
HEAD / LEN / CS / TAIL |
五、一句话终极总结(你现在该记住的)
串口不是“收一包”,而是“收一条河”
协议的本质,就是在河里找到一条完整的鱼。
你现在这套代码已经是:
✅ 工业级协议思想
✅ AI → MCU 正确通信
✅ 完整闭环验证
✅ 非抄代码级别的理解
———————————————————————————————————————————
Gemini 3 Pro
协议深度解析 (基于源码)
根据 Serial_protol_demo.py,数据包的结构如下:
| 顺序 | 字段名称 | 长度 | Python 代码对应 | C语言处理逻辑 |
|---|---|---|---|---|
| 1 | 帧头 | 1 Byte | 0xAA |
检测到 0xAA 开始状态机 |
| 2 | 长度 | 2 Bytes | <H (小端 unsigned short) |
读出长度 N,并开始计算校验和 |
| 3 | 数据域 | N Bytes | <iiii (16字节) |
读取 N 个字节,持续累加校验和 |
| 4 | 校验和 | 1 Byte | sum(长度 + 数据域) & 0xFF |
对比计算出的校验和 |
| 5 | 帧尾 | 1 Byte | 0x55 |
确认结束,提取数据 |
STM32 完整代码 (HAL 库)
这段代码使用了状态机 (State Machine),这是处理这种带包头、包尾、校验和协议的最稳健方式。
1. 头文件与定义 (main.h 或 main.c 顶部)
#include <string.h>
#include <stdint.h>
// === 1. 定义数据结构 ===
// 对应 Python 的 struct.pack('<iiii', x, y, w, h)
// int32_t 刚好对应 Python 的 4字节 int
typedef struct {
int32_t x;
int32_t y;
int32_t w;
int32_t h;
} FaceData_t;
// === 2. 协议状态枚举 ===
typedef enum {
STATE_WAIT_HEADER = 0, // 等待包头 0xAA
STATE_WAIT_LEN1, // 等待长度低字节
STATE_WAIT_LEN2, // 等待长度高字节
STATE_WAIT_PAYLOAD, // 接收数据
STATE_WAIT_CHECKSUM, // 等待校验和
STATE_WAIT_TAIL, // 等待包尾 0x55
} DecodeState_t;
// 全局变量定义
FaceData_t g_face_data; // 存放解析出来的最终数据
volatile uint8_t g_data_valid = 0; // 如果收到新数据,置1
将以下代码复制到你的 main.c 中。
// 定义最大允许的负载长度,防止内存溢出
#define MAX_PAYLOAD_LEN 32
// 串口接收相关变量
uint8_t rx_byte; // 串口中断接收这一个字节
DecodeState_t p_state = STATE_WAIT_HEADER; // 当前状态
uint8_t payload_buf[MAX_PAYLOAD_LEN]; // 每一包的数据缓冲区
uint16_t p_payload_len = 0; // 这一包数据的计划长度
uint16_t p_cnt = 0; // 当前接收了多少字节
uint8_t cal_checksum = 0; // 我们计算出来的校验和
extern UART_HandleTypeDef huart1; // 确保这里的 huart1 对应你连接 MaixCam 的串口
/**
* @brief 串口接收回调函数
*/
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart->Instance == USART1) // 确认串口号
{
// === 状态机逻辑开始 ===
switch(p_state)
{
// 1. 找包头 0xAA
case STATE_WAIT_HEADER:
if(rx_byte == 0xAA) {
p_state = STATE_WAIT_LEN1;
cal_checksum = 0; // 重置校验和
}
break;
// 2. 读长度低字节 (Python: <H 的第一个字节)
case STATE_WAIT_LEN1:
p_payload_len = rx_byte; // 先存低位
cal_checksum += rx_byte; // 校验和包含长度字节
p_state = STATE_WAIT_LEN2;
break;
// 3. 读长度高字节 (Python: <H 的第二个字节)
case STATE_WAIT_LEN2:
p_payload_len |= (rx_byte << 8); // 加上高位,组成完整长度
cal_checksum += rx_byte; // 校验和包含长度字节
// 安全检查:如果长度太离谱,说明数据错了,重置
if(p_payload_len > MAX_PAYLOAD_LEN) {
p_state = STATE_WAIT_HEADER;
} else {
p_cnt = 0; // 准备计数数据域
p_state = STATE_WAIT_PAYLOAD;
}
break;
// 4. 读取数据域 (Payload)
case STATE_WAIT_PAYLOAD:
payload_buf[p_cnt++] = rx_byte;
cal_checksum += rx_byte; // 校验和包含数据字节
if(p_cnt >= p_payload_len) {
p_state = STATE_WAIT_CHECKSUM;
}
break;
// 5. 比对校验和
case STATE_WAIT_CHECKSUM:
// Python: (sum & 0xFF)
// C语言 uint8_t 溢出自动就是 & 0xFF,所以直接比对
if(cal_checksum == rx_byte) {
p_state = STATE_WAIT_TAIL;
} else {
// 校验失败,丢弃这一包
p_state = STATE_WAIT_HEADER;
}
break;
// 6. 检查包尾 0x55
case STATE_WAIT_TAIL:
if(rx_byte == 0x55) {
// === 成功收到完整一包!===
// 只有这里才把缓冲区的数据 搬运 到最终结构体
// 因为 STM32 也是小端模式,直接 memcpy 即可还原 x,y,w,h
if(p_payload_len == sizeof(FaceData_t)) {
memcpy(&g_face_data, payload_buf, sizeof(FaceData_t));
g_data_valid = 1; // 告诉主循环有数据了
}
}
// 无论是否成功,都回到初始状态等待下一包
p_state = STATE_WAIT_HEADER;
break;
default:
p_state = STATE_WAIT_HEADER;
break;
}
// === 状态机逻辑结束 ===
// 继续接收下一个字节
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
}
}
3. 主循环使用 (main 函数)
int main(void)
{
// ... HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); ...
// 【关键一步】开启中断,开始接收
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
while (1)
{
// 检查是否有新的人脸数据
if(g_data_valid == 1)
{
g_data_valid = 0; // 清除标志位
// 在这里处理数据,例如控制舵机追踪
// int error_x = g_face_data.x - 160;
// Control_Servo(error_x);
// 调试打印 (需要重定向 printf)
printf("Face: x=%d, y=%d, w=%d, h=%d\r\n",
g_face_data.x, g_face_data.y, g_face_data.w, g_face_data.h);
}
// 你的其他任务...
}
}
为什么这段代码能跑通?(核心点解释)
- 校验和算法对应:
- Python:
check_sum = (check_sum + a) & 0xFF(涵盖frame[1:]即长度+数据)。 - C代码:
cal_checksum += rx_byte(定义为uint8_t)。 - 原理:
uint8_t是 8 位无符号整数,当它超过 255 时会自动溢出回 0,这在数学上等价于& 0xFF。所以 C 代码不需要手动写& 0xFF。
- Python:
- 长度处理:
- Python:
struct.pack('<H', len)发送的是 2 字节小端。 - C代码: 先收
STATE_WAIT_LEN1(低位),再收STATE_WAIT_LEN2(高位),组合成p_payload_len。这完美还原了 Python 的逻辑。
- Python:
- 数据解包:
- Python:
struct.pack('<iiii'...)。 - C代码: 直接
memcpy到包含 4 个int32_t的结构体。这是最高效的做法,因为双方都是小端序,内存布局完全一致。
- Python:
硬件接线提醒
- MaixCam TX -> STM32 RX (例如 PA10)
- MaixCam RX -> STM32 TX (例如 PA9)
- MaixCam GND -> STM32 GND (一定要接!)
小总结
通过本文的学习,我们不仅打通了从 MaixCam(视觉 AI)到 STM32(运动控制)的数据链路,更掌握了一套通用的工业级串口通信方法论。
在这个过程中,有三个关键认知值得反复回味:
- 思维的转变:从看待数据的“字符视角”转变为“二进制视角”,理解了内存与字节流的本质转换,是进阶嵌入式开发的分水岭。
- 严谨的对齐:通信的本质是“约定”。无论是帧头的定义、大小端的选择,还是校验和的算法,发送端(Python)与接收端(C 语言)必须像齿轮一样精准咬合,任何一个字节的错位都会导致解析失败。
- 拥抱工具与 AI:从 Python 的
struct库到利用 AI 快速生成健壮的状态机代码,善用工具能极大降低底层开发的门槛。现在的开发不再是死记硬背语法,而是理解逻辑后指挥 AI 为我们落地实现。
sum = (check_sum + a) & 0xFF(涵盖frame[1:]` 即长度+数据)。
- C代码:
cal_checksum += rx_byte(定义为uint8_t)。 - 原理:
uint8_t是 8 位无符号整数,当它超过 255 时会自动溢出回 0,这在数学上等价于& 0xFF。所以 C 代码不需要手动写& 0xFF。
- 长度处理:
- Python:
struct.pack('<H', len)发送的是 2 字节小端。 - C代码: 先收
STATE_WAIT_LEN1(低位),再收STATE_WAIT_LEN2(高位),组合成p_payload_len。这完美还原了 Python 的逻辑。
- Python:
- 数据解包:
- Python:
struct.pack('<iiii'...)。 - C代码: 直接
memcpy到包含 4 个int32_t的结构体。这是最高效的做法,因为双方都是小端序,内存布局完全一致。
- Python:
硬件接线提醒
- MaixCam TX -> STM32 RX (例如 PA10)
- MaixCam RX -> STM32 TX (例如 PA9)
- MaixCam GND -> STM32 GND (一定要接!)
小总结
通过本文的学习,我们不仅打通了从 MaixCam(视觉 AI)到 STM32(运动控制)的数据链路,更掌握了一套通用的工业级串口通信方法论。
在这个过程中,有三个关键认知值得反复回味:
- 思维的转变:从看待数据的“字符视角”转变为“二进制视角”,理解了内存与字节流的本质转换,是进阶嵌入式开发的分水岭。
- 严谨的对齐:通信的本质是“约定”。无论是帧头的定义、大小端的选择,还是校验和的算法,发送端(Python)与接收端(C 语言)必须像齿轮一样精准咬合,任何一个字节的错位都会导致解析失败。
- 拥抱工具与 AI:从 Python 的
struct库到利用 AI 快速生成健壮的状态机代码,善用工具能极大降低底层开发的门槛。现在的开发不再是死记硬背语法,而是理解逻辑后指挥 AI 为我们落地实现。
最后,别忘了硬件调试的黄金法则:共地(GND)必接。希望这套“打包-发送-接收-解包”的闭环逻辑,能成为你构建更复杂 AIOT 系统的坚实基石。
更多推荐
所有评论(0)