maixcam,人脸识别、串口数据协议

摘要

本文是一份从零开始的 嵌入式 AIOT 串口通信实战指南,涵盖了从原理讲解、Python 发送端实现到 STM32 C 语言接收端的全流程。核心要点如下:

  1. 协议设计的必要性
    • 解释了串口通信的“流式”特性(像水流一样无界限),阐述了为什么必须引入**“帧头+长度+载荷+校验+帧尾”**的协议结构来解决粘包和数据错乱问题。
    • 对比了字符协议(ASCII)与字节协议(Hex/Binary)的优劣,确立了工业级开发应遵循二进制传输标准。
  2. 核心技术难点攻关
    • 大小端(Endianness):图解了数据在内存中的存储差异,强调了 Python 端 <(小端)与 STM32 端保持一致的重要性,防止数据解析错误。
    • Struct 模块:详解 Python struct.pack/unpack 的使用,如何将坐标 (x, y, w, h) 高效打包成二进制流。
    • 位运算校验:通俗解释了 (sum & 0xFF) 的数学含义(防止溢出截断),确保通信可靠性。
  3. Python 端源码剖析
    • 深度拆解 Serial_protol_demo.py,针对小白常见的痛点(如切片索引计算、bytearray 缓冲池机制、寻找帧头逻辑)进行了逐行解读。
    • 提供了 MaixCam 进行人脸识别并发送坐标的完整业务代码。
  4. 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,这个数值在用大端序和小端序排列时的在内存中的图示更易于理解:

image-20260122101006680

有空的话可以看看这篇文章,我感觉他写的挺好的初识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] 取第一个元素。

2. 奇怪的数学:(check_sum + a) & 0xFF

出现在 _checksum 函数里。

  • 小白疑惑点:
    • 为什么加完了还要 & 0xFF?这是什么数学运算?
  • 通俗解读:
    • 防止溢出(时钟原理): 单片机里的一个字节(Byte)最大只能存 255。如果校验和加起来变成了 256,字节就存不下了。
    • & 0xFF 的作用就像 时钟。当时钟指到 12 点再走一格,就回到 1 点,而不是变成 13 点。
    • & 0xFF 强制让计算结果只保留最后 8 位二进制,保证结果永远在 0~255 之间。这在专业术语叫“截断”。

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)发出去。

总结给小白的“读码心法”

如果你以后再看这种协议代码,抓住这三点:

  1. 找结构: 先看 struct.pack 里的格式(如 <H),这就知道了数据的骨架。
  2. 看地图: 任何协议都有“头、长、身、尾”,对照代码里的索引数字,画个图就能看懂。
  3. 懂位运算: 看到 & 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 字节

判断逻辑含义

当前数据够不够拼成一个完整的数据包


四、校验判断那一行为什么看不懂?
问题本质
  • 一行代码做了太多事:
    1. 截取数据
    2. 计算校验和
    3. 与接收到的校验值比较
建议教学方式
  • 拆成三步写
  • 明确:

    校验的是「长度字段 + 数据域」


五、raw_data[-1] 为什么代表包尾?
小白常见卡点
  • 不熟悉 Python 负索引
关键知识
  • 在 Python 中:
    • -1 表示最后一个元素
  • 在协议中:
    • 最后一个字节是 包尾(0x55)

六、为什么校验不包含包头?
小白疑问
  • 为什么不把 0xAA 也算进校验?
协议设计原则
  • 包头作用:
    • 同步帧起点
  • 校验目的:
    • 验证“可能被破坏的数据”
结论

校验规则是人为约定,只要通信双方一致即可


七、is_vaild() 返回值为什么是 tuple
小白易犯错误
  • 只关心 rc
  • 忽略 bytes_redundant
实际意义
  • 返回值表示:
    1. 是否存在完整有效数据包
    2. 包头前有多少“垃圾数据”需要丢弃
一句话理解

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中我们还需要操作一下

image-20260122103000703

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

image-20260122103119517

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

image-20260122103214637

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

image-20260122103305807

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

image-20260122103554450

大工告成

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.hmain.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
  1. 接收逻辑核心 (main.c)

将以下代码复制到你的 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);
        }
        
        // 你的其他任务...
    }
}

为什么这段代码能跑通?(核心点解释)
  1. 校验和算法对应:
    • Python: check_sum = (check_sum + a) & 0xFF (涵盖 frame[1:] 即长度+数据)。
    • C代码: cal_checksum += rx_byte (定义为 uint8_t)。
    • 原理: uint8_t 是 8 位无符号整数,当它超过 255 时会自动溢出回 0,这在数学上等价于 & 0xFF。所以 C 代码不需要手动写 & 0xFF
  2. 长度处理:
    • Python: struct.pack('<H', len) 发送的是 2 字节小端。
    • C代码: 先收 STATE_WAIT_LEN1 (低位),再收 STATE_WAIT_LEN2 (高位),组合成 p_payload_len。这完美还原了 Python 的逻辑。
  3. 数据解包:
    • Python: struct.pack('<iiii'...)
    • C代码: 直接 memcpy 到包含 4 个 int32_t 的结构体。这是最高效的做法,因为双方都是小端序,内存布局完全一致。
硬件接线提醒
  • MaixCam TX -> STM32 RX (例如 PA10)
  • MaixCam RX -> STM32 TX (例如 PA9)
  • MaixCam GND -> STM32 GND (一定要接!)

小总结

通过本文的学习,我们不仅打通了从 MaixCam(视觉 AI)到 STM32(运动控制)的数据链路,更掌握了一套通用的工业级串口通信方法论。

在这个过程中,有三个关键认知值得反复回味:

  1. 思维的转变:从看待数据的“字符视角”转变为“二进制视角”,理解了内存与字节流的本质转换,是进阶嵌入式开发的分水岭。
  2. 严谨的对齐:通信的本质是“约定”。无论是帧头的定义、大小端的选择,还是校验和的算法,发送端(Python)与接收端(C 语言)必须像齿轮一样精准咬合,任何一个字节的错位都会导致解析失败。
  3. 拥抱工具与 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
  1. 长度处理:
    • Python: struct.pack('<H', len) 发送的是 2 字节小端。
    • C代码: 先收 STATE_WAIT_LEN1 (低位),再收 STATE_WAIT_LEN2 (高位),组合成 p_payload_len。这完美还原了 Python 的逻辑。
  2. 数据解包:
    • Python: struct.pack('<iiii'...)
    • C代码: 直接 memcpy 到包含 4 个 int32_t 的结构体。这是最高效的做法,因为双方都是小端序,内存布局完全一致。
硬件接线提醒
  • MaixCam TX -> STM32 RX (例如 PA10)
  • MaixCam RX -> STM32 TX (例如 PA9)
  • MaixCam GND -> STM32 GND (一定要接!)

小总结

通过本文的学习,我们不仅打通了从 MaixCam(视觉 AI)到 STM32(运动控制)的数据链路,更掌握了一套通用的工业级串口通信方法论。

在这个过程中,有三个关键认知值得反复回味:

  1. 思维的转变:从看待数据的“字符视角”转变为“二进制视角”,理解了内存与字节流的本质转换,是进阶嵌入式开发的分水岭。
  2. 严谨的对齐:通信的本质是“约定”。无论是帧头的定义、大小端的选择,还是校验和的算法,发送端(Python)与接收端(C 语言)必须像齿轮一样精准咬合,任何一个字节的错位都会导致解析失败。
  3. 拥抱工具与 AI:从 Python 的 struct 库到利用 AI 快速生成健壮的状态机代码,善用工具能极大降低底层开发的门槛。现在的开发不再是死记硬背语法,而是理解逻辑后指挥 AI 为我们落地实现。

最后,别忘了硬件调试的黄金法则:共地(GND)必接。希望这套“打包-发送-接收-解包”的闭环逻辑,能成为你构建更复杂 AIOT 系统的坚实基石。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐