LiSAC 智能循迹小车:软件系统技术综述

三个计算单元(ESP32 / STM32 / 上位机)的工程实现与通信协议
组会技术汇报 · 2026-08-04

一、系统总体架构:三个计算单元的分工

整台车的控制功能被拆解到三个物理上独立的计算单元上,这种设计在嵌入式领域称为分布式控制。拆分的依据是:不同任务对实时性(毫秒级响应)、算力(能否跑复杂算法)与开发成本的要求不同,一块芯片同时承担所有任务往往顾此失彼。

分布式控制把一套系统的功能分散到多个处理器上,各处理器通过网络/串口交换信息、协同工作。好处:每个处理器只干一类活,实时性有保证、坏了一个模块不影响其他模块、可以单独开发测试。生活中例子:快递分拣场不是一个机器人干所有事,而是一条流水线上多个机器人各司其职。
STM32(单片机)意法半导体(ST)公司生产的 32 位单片机(Microcontroller Unit, MCU)——把"处理器 + 内存 + 各种外设接口"集成在一颗芯片上的微型计算机。与电脑的 CPU 不同,它不跑操作系统桌面环境,而是直接执行底层控制代码,专门用来"驱动硬件"。本系统用的 STM32F103 主频 72 MHz,只有 64 KB 程序存储,但足够完成电机调速这种实时任务。同类产品还有:Arduino(AVR)、ESP32 本身。
FreeRTOS一个开源的实时操作系统(RTOS),给单片机提供"多任务"能力。没有它,单片机的代码通常是"一个大循环"(一遍遍顺序执行,一件事卡住全卡住);有了它,可以把"收指令""算速度""控电机"拆成多个独立任务,系统按优先级轮流执行,每个任务都能保证按时完成(实时)。本系统在 STM32 上用它让 5ms 的调速周期稳定不变。
单元硬件 / 软件环境承担的功能核心约束
执行单元STM32F103 单片机 + FreeRTOS电机驱动、转速测量、闭环调速、急停保护实时性最高:5ms 周期
指挥单元ESP32 双核 + ESP-IDF v6.0八路循迹判断、姿态解算、OLED 显示、WiFi 联网、OTA 升级算力与联网能力
监控单元上位机 PC + Flask 网页状态展示、手动控制、在线 PID 调参、固件管理与硬件解耦,纯软件
ESP32(无线 SoC)乐鑫(Espressif)公司生产的芯片,本质也是一颗单片机,但比 STM32 强大:双核 CPU(可同时干两件事)、内置 WiFi 与蓝牙模块(STM32 没有联网能力)、内存更大。本系统让它"联网 + 决策 + 显示",跑的是 ESP-IDF 操作系统。
ESP-IDF乐鑫官方提供的 ESP32 开发框架(一个"SDK + 工具链 + 编译系统"的集合)。SDK 提供现成的库函数:连 WiFi、发 HTTP 请求、读写串口、定时器、任务调度等,我们不用从寄存器层面自己写。它内部集成了 FreeRTOS、lwIP 网络协议栈。本系统版本为 v6.0。
上位机工业界习惯称呼:上位机 = 负责"监控、操作、人机交互"的那台电脑/软件(通常有界面);下位机 = 直接连接硬件的设备(这里指 STM32/ESP32)。我们的上位机是一台 PC 上跑的一个网页程序,工程师在网页上点点按钮,就能监控和控制小车。
IR8(八路循迹模块)本系统的"眼睛"之一:一块装有 8 个红外探头的传感器板,探头向下照射地面,黑色(吸光)与白色(反光)反射率不同,因此能检测出"黑线压在哪几个探头下面",进而算出车相对黑线的横向偏差。它通过 I2C 总线与 ESP32 通信,返回一个字节(8 个 bit 对应 8 个探头)。循迹原理就是"跟黑线走",类似机场的引导机器人。
FlaskPython 生态里最流行的轻量级 Web 框架。用一小段代码就能写一个 Web 服务:浏览器请求什么路径,它就返回什么内容。选它的原因:简单、无数据库负担、正好满足"一个上位机页面 + 几个 API 接口"的需求。同类:Django(更重)、FastAPI。
UART(串口)最基础的两线制通信接口,把字节按位逐个"排着队"发送(一般用 3 根线:发送 TX、接收 RX、地 GND)。就像两人用摩斯电码对讲,一根线发、一根线收。速度用波特率表示(本系统 115200,即每秒 115200 位)。STM32 与 ESP32 之间就是用它相连。
WiFi / TCP / IP三者的关系可以类比寄信:IP 是"地址体系"——每台设备在局域网里有一个唯一地址(如 192.168.1.100),信封上写收件人地址;TCP 是"可靠的快递协议"——保证信按顺序送到、丢件会重发,让上层感觉像"一条畅通的电话线";WiFi 是"无线送信通道"——物理上用无线电波代替网线。ESP32 连上 WiFi 获得 IP,上位机用 TCP 连它的地址,就能建立可靠的双向字节流。

1.1 为什么这样分工

PWM(脉宽调制)Pulse Width Modulation——用"一秒钟开关很多次、每次开多久可调"的方式控制电压平均值,从而控制电机转速(占空比 50% 约等于半速)。因为它开关频率远高于电机响应速度,电机感知到的是平滑的等效电压。调速、调 LED 亮度都靠它。本系统 PWM 频率约 20 kHz(人耳听不到,避免电机啸叫)。
编码器(Encoder)装在电机轴上的"转速测量器":电机每转一圈,它输出若干个脉冲(本系统每圈约 500 个)。数脉冲就能精确算出当前转速,把"电机到底转多快"这个真实数据告诉控制器。没有它,系统只能"瞎猜"车速。
闭环控制(闭环/开环)控制系统的两种模式:开环="命令 50 就输出 50% 电压,不管实际转多快"(受电池电压、负载影响,实际速度会漂);闭环=测量真实转速、与目标比较、自动调整输出,直到"实际≈目标"。闭环才能保证车走直线、速度准,代价是必须有一个反馈回路(编码器测量 → 比较 → 调整)。PID 就是"如何根据误差调整"的算法。

1.2 单元之间的"对话链"

操作人员 浏览器(网页) Flask 后端 WiFi (TCP,局域网) ESP32 tcp_bridge 桥 UART 串口 + COBS 帧 STM32
STM32 UART 串口 + COBS 帧 ESP32(逐字节透传) WiFi (TCP/IP) Flask 后端 浏览器

链路①(主):上位机经 WiFi TCP 连上 ESP32 上的 tcp_bridge 桥组件,桥再把字节流原样转发到 STM32 的 UART——这条链上整车完全无线。链路②(备用,开发调试):上位机也可经 USB 转串口直接连 STM32,单独调试底盘,把问题隔离在最小范围。

第二章讲帧协议(链路①②共用同一套 COBS 帧),第三章讲上位机与 tcp_bridge,第四章讲 ESP32 的 OTA。


二、单元间通信协议:COBS 帧与校验和

一句话背景:串口通信是"把字节一个接一个地从一根线上发过去"。接收方必须知道哪里是一帧的开始、哪里是一帧的结束——协议要解决的就是"在连续的字节流里正确地切帧(定界)并发现错误(纠错)"。

2.1 帧(Frame)结构

本系统在"芯片之间"与"上位机与芯片之间"统一采用一套帧约定:

帧 = [命令字 cmd 1字节] [载荷 payload] [校验和 checksum 1字节]

命令共 6 条STOP 停车SET_SPEED 设定车速ESTOP 紧急停止SET_PID 设定调速参数PING 探活STATUS 回传状态。载荷中的多元数据按小端字节序(低位字节在前)排列,两端严格一致。

帧(Frame)通信协议里对"一次完整消息"的称呼。就像寄包裹要把货物打包、贴单号一样,通信数据也被打包成固定格式的"帧":开头标明这是什么命令,中间装数据,结尾附校验。接收方按照约定好的格式把它拆开,就能知道这条消息是什么意思。
命令字 / 载荷帧的两个组成部分:命令字(cmd)是一个字节编号,告诉接收方"这条消息要干什么"(1 号=设速度,2 号=急停……);载荷(payload)是随命令携带的具体数据(例如设速度命令带左轮、右轮各 2 字节的目标值)。
校验和(checksum)一种最简易的"防错"手段。发送方把所有字节相加,只保留低 8 位附加在帧尾;接收方重新相加,若与帧尾不一致,说明传输中被干扰改动了,直接丢弃这帧。相当于"包裹上贴的称重单"——到手称一下重量不对,就知道包裹被人动过。
小端字节序一个整数在内存/串口里"先发低字节还是先发高字节"的约定。比如十六位整数 0x1234:小端先发 0x34 再发 0x12。电脑芯片(x86/ARM)和大多数通信协议都用小端。收发双方必须约定一致,否则数字会被读反。
PING(探活)源自"乒乓球"的比喻:A 发一个 PING,B 必须立刻回一个响应(PONG)。只要 B 还能应答,就说明它活着、链路是通的。本项目里 PING 命令用于验证 STM32 是否在线、链路是否正常,效果类似"你好,在吗?"。

2.2 COBS 定界:如何保证帧内不出 0x00

字节 / 十六进制(Byte / 0x)计算机最小的"地址单位"是一个字节(8 个 bit,可表示 0~255)。为书写方便,程序员用十六进制表示:0~15 写成 0x00~0x0F,两个十六进制数正好写一个字节(如 0xFF=255=二进制 11111111)。文中 0x00 即"全零字节",0xFF 即"全一字节"。

我们规定字节 0x00帧结束标志,因此帧内部绝不能出现 0x00。但业务数据(如车速为 0、角度为 0)里出现 0 字节是常有的事。COBS(Consistent Overhead Byte Stuffing,一致开销字节填充)解决此问题:

2.3 校验和(checksum):如何发现错误帧

传输中可能因电气干扰丢失或改写字节。我们在帧尾附加"整帧字节求和后取低 8 位"的校验和:

$$\text{checksum} = \left( \sum_{i=0}^{n-1} b_i \right) \bmod 256$$

接收方重新求和,若与帧尾不一致,判定"这一帧脏了",整帧丢弃并累加一次坏帧计数 $e_{\text{rx}}$(rx_errors),随 STATUS 帧回传给上位机,使通信质量可观测。

坏帧计数(rx_errors)接收方累计"收到多少帧因校验失败被丢弃"。它随每帧状态上报给上位机,让工程师能直观看到通信质量:长期为 0 说明链路干净;持续增长说明有干扰/接触不良/波特率不匹配。类比汽车仪表盘上的"故障灯"——本身不解决问题,但能告诉你去查什么。

2.4 工程鲁棒性机制(不止"能通",还要"可靠")

机制一句话说明
超时停车200ms 未收到任何有效指令 → 自动清零车速(安全兜底)
半帧重同步帧接收中断超过 50ms → 丢弃残留半帧,防止与下一帧粘连
坏帧统计校验失败次数经 STATUS 帧上报,通信质量可见
PING 探活发一帧命令,对方立即回一帧状态,验证链路与对端存活
越界防护解码时限定输出缓冲上限,杜绝"越界写内存"类安全漏洞

2.5 速度闭环中的 PID 公式(附)

PID(比例-积分-微分)控制工业上最常用的自动调速算法,核心思想:"根据偏差(误差)的三个特性来调整输出"。比例 Kp:误差越大输出越大,像"离目标差多少就使多大劲";积分 Ki:误差长期存在时慢慢加力,消除稳态偏差(如电池电压不足导致的长期掉速);微分 Kd:探测误差变化的快慢,在误差要变大之前提前刹车,抑制过冲与震荡。三者的比例即为 PID 参数,是需要现场整定的"调味料"。

执行单元的核心算法是增量式 PID,在 5ms 控制周期内由编码器测得实际转速 $v(n)$,与目标 $v^*$ 比较得误差 $e(n) = v^* - v(n)$:

$$u(n) = u(n-1) + K_p \left[ e(n) - e(n-1) \right] + K_i \, e(n) + K_d \left[ e(n) - 2e(n-1) + e(n-2) \right]$$

其中 $u(n)$ 为输出 PWM 占空比对应的控制量,$K_p, K_i, K_d$ 为可在线调参的三项增益。初始参数 $(0.5, 0.05, 0.01)$,待真机整定。


三、上位机网页与 Flask 后端

两个基础概念(非计算机背景也建议先记):
B/S 架构:浏览器(Browser)向服务器(Server)索要页面与数据,服务器是我们自己写的一段 Python 程序。
HTTP 请求:浏览器对服务器说"给我 /dashboard 页"(GET 读),或"把车速设为 50"(POST 写)。服务器根据路径 + 请求方法决定返回什么——这就是"路由"(route)一词的由来。

3.1 架构:Flask 后端 + Jinja2 模板 + 静态资源

MPU6050(姿态传感器)InvenSense 公司生产的六轴惯性传感器:内部有 3 轴加速度计 + 3 轴陀螺仪(合称 6 轴 IMU)。它知道自己"正着还是歪着、转没转",配合芯片内置的 DMP 可直接输出偏航/俯仰/横滚三个姿态角(见第六章 DMP 词条)。小车用它测量车身姿态,为后续全向运动与云台控制打基础。本系统通过 I2C 与 ESP32 相连。
ECharts百度开源(现 Apache 基金会)的网页图表库,几十行 JS 就能画出折线图、仪表盘等。本系统用它画"左右轮速度实时曲线":页面每秒拿到新数据,图表自动滚动更新。

3.2 五个页面(路由表)

页面路径功能
状态监控/dashboard轮速、PWM、姿态角、系统信息 + 实时曲线
手动控制/control方向键、急停大按钮、快捷速度档
PID 调参/pid滑块在线修改 6 个 PID 参数,下发至 STM32 即时生效
固件烧录/firmware上传 .bin 固件到服务器、触发 OTA
系统日志/logs查看/筛选后端运行日志
Jinja2(模板引擎)Python 的 HTML 模板工具:HTML 里允许写 {{ 变量 }}{% if %} 这样的占位符,后端把真实数据填进去再发给浏览器。效果就像"填空试卷"——试卷(模板)固定,每次填不同的答案(数据)就能生成不同内容的页面。它和 Flask 出自同一团队,常组合使用。
服务端渲染把"页面内容如何拼装"这项工作放在服务器上完成(服务端生成好完整 HTML 再发给浏览器),与之相对的是"客户端渲染"(浏览器拿原始数据自己拼)。本系统页面结构简单,服务端渲染更直接、首次加载更快。
API(接口)Application Programming Interface。这里指"程序与程序之间的调用约定":前端页面需要数据时,向服务器发一个带路径的 HTTP 请求(如 GET /api/status),服务器返回一段 JSON 数据(一种结构化文本格式,类似"键:值"清单)。页面拿到 JSON 后自己渲染成图表/数字。好处:页面展示逻辑与数据来源解耦。
轮询(Polling)页面定时(本系统每 1 秒)主动向服务器问一次"有更新吗?"。实现最简单,缺点是实时性受周期限制、有 1 秒最大延迟。更高级的方案是 WebSocket"长连接推送"(服务器主动把数据推过来),本项目先用轮询保证简单可靠。

3.3 后端数据逻辑:真实数据优先 + MOCK 降级

后端内置一个 MOCK_STATUS 模拟数据源。读取状态时先判断"真机是否在线":

在线判定:后台线程每 0.5s 读一次链路,能持续收到状态帧即认为在线。这套"真实优先、模拟兜底"的设计使前端开发完全不等硬件。

MOCK(模拟数据)开发术语"假数据"。硬件还没接好时,先用一段预先写好的假数据让页面"看起来有内容",把前端和后端调试好,硬件来了直接替换数据源即可。等于"先排演,再上台",软件工程里称为 mock(打桩/模拟)。

3.4 帧链路 device_link.py:双传输模式(串口 / TCP)

device_link.py 是后端与芯片之间的"接线员",把两种物理传输方式抽象成同一套接口,上层协议处理(切帧→解码→校验→解析)完全复用:

模式开启方式适用场景
串口模式默认,或设 LISAC_CONSOLE_PORTUSB 线直连 STM32,单独调试底盘
TCP 模式(无线)设 LISAC_CONSOLE_HOST=ESP32 的 IP整车无线运行,通过 ESP32 桥接入

两种模式都维护一个独立后台线程:

读链路 → 按 0x00 切帧 → COBS 解码 → 校验和 → 解析为字典 → 更新状态缓存

发命令时把字典打包为 COBS 帧写入链路。传输循环与网页请求完全解耦,互不阻塞;传输层自动重连,掉线不影响网页服务。

3.5 无线桥组件 tcp_bridge:ESP32 上的"透传桥"

纯无线调试需要一个把 WiFi TCP 转为 STM32 UART 的桥梁,它就是 ESP32 上的 tcp_bridge 组件(新端口,默认 5005):

实现参考:乐鑫官方示例 esp-idf/examples/protocols/sockets/tcp_server(socket 生命周期管理)与开源项目 mtoolstec/esp32-uart-bridge(Nagle 关闭),均已在 components/tcp_bridge/ 内注释注明。

Socket(套接字)操作系统给上层应用程序提供的"网络接口"。程序像操作文件一样调用 read/write 就能收发网络数据,不用关心底层网络细节。ESP-IDF 提供与 PC 上几乎一样的 socket 接口(BSD Socket),因此这一段代码可以照搬参考官方的 PC 示例。
Nagle 算法TCP 的一个历史默认行为:为了省网络流量,它会"攒小包"——如果有很多个 1~2 字节的小数据块要发,先攒起来合并成一个大包再发,最多能延迟约 40ms。这在传网页时是好事,但对小车控制是灾难(40ms 延迟远超 20ms 的控制周期要求),所以必须用 TCP_NODELAY 选项关掉它。
Keepalive(保活)TCP 的"心跳检测"。正常情况下如果一方突然断电、拔网线、休眠,另一方可能永远不知道,一直傻等。Keepalive 每隔几秒发一个探测包:收不到回应就判定对方已死,主动关闭连接并清理资源。这里用于让 ESP32 及时发现上位机掉线,把"死连接"回收掉。

四、空中升级 OTA(Over-The-Air Update)

一句话讲清 OTA:传统改程序需要把设备拆下、用 USB 线/调试器连接电脑烧录。OTA 把「下载新程序 → 校验 → 重启切换」整个过程交给设备自己,在正常联网状态下完成,无需开盖接线。

4.1 为什么要做 OTA

HTTP / HTTPS(超文本传输协议)浏览器与服务器之间说话的语言——规定"请求要带什么格式、回应要带什么格式"。本质是一条文本消息:第一行写请求方法(GET 拿数据 / POST 提交)和路径,后面跟一堆"头"和可选的内容。HTTPS 是加了一层加密的版本。本系统中:浏览器 ↔ Flask 用 HTTP;ESP32 轮询版本、下载固件也用 HTTP(OTA 服务器即 Flask 提供)。

4.2 一次 OTA 的完整流程(5 步)

步骤执行者动作
1工程师上位机"固件烧录"页上传新 .bin,服务器保存并登记版本号
2ESP32周期轮询服务器,比较版本号($v_{\text{server}} > v_{\text{local}}$ 则触发)
3ESP32下载 .bin 到 Flash 的备份分区(A/B 双分区),边下载边校验
4ESP32校验通过后标记分区有效 → 重启
5ESP32引导新分区;若启动失败,自动回滚到旧分区

4.3 关键技术点

SHA / CRC(完整性摘要)给一段数据算出一个"指纹"的算法:哪怕文件只改 1 个字节,指纹也完全不同。SHA(安全散列算法)算出的指纹很长、几乎不可伪造,适合校验几十 KB 的固件包;CRC(循环冗余校验)更短更快,适合校验一帧通信数据。OTA 下载时边下边算指纹,最后与官方发布的指纹比对:一致 = 文件完好,不一致 = 丢弃。
Flash(闪存)单片机/手机里"掉电不丢"的存储器,相当于它的硬盘:程序就存在这里,上电后读出来运行。STM32 有 64 KB,ESP32 有 4 MB。它也能像 U 盘一样被改写,OTA 的本质就是"在线改写 Flash 里的程序区"。
A/B 双分区(分区表)把 Flash 划分成若干个有名字的区域叫"分区表"。A/B 双分区 = 程序区复制成两份(A 区和 B 区),设备永远运行其中一份,更新时写另一份。好处:更新失败也不会弄坏正在运行的程序;想回滚只需标记"切回 A"。这是汽车 OTA、手机系统升级普遍采用的做法。
看门狗(Watchdog)一个"独立计时器",程序必须周期性地喂它(重置计时)。如果程序死机/跑飞导致不再喂狗,计时器到点就会强制重启芯片——相当于"自动按复位键"。用途:即使新固件写坏了、启动后死机,看门狗也能让它重启,从而触发回滚,设备不会变砖。
Bootloader(引导程序)芯片上电后最先运行的一小段程序,它的工作类似"电脑的 BIOS":检查该从哪个分区启动、启动哪个固件。OTA 的回滚/切换分区决策,最终由 bootloader 依据启动标记执行。

五、关键代码实现(源码高亮)

以下摘录本系统三端的关键代码片段,展示通信协议、控制算法、OTA 客户端三大核心模块的实际工程实现。语法高亮由 highlight.js 渲染,代码与仓库完全一致。

highlight.js一个前端 JS 库:自动识别代码块的语言(C、Python…)并给关键字、变量、字符串上不同颜色,让代码更好读。它在本报告中只负责页面展示,与小车系统本身无关——是"报告排版工具"层面的依赖。

5.1 STM32 端速度闭环:位置式 PID(pid.c)

文件:stm32f103v1.0/Core/Src/pid.c


static float clampf(float v, float limit)
{
    if (v > limit)  return limit;
    if (v < -limit) return -limit;
    return v;
}

/* 位置式 PID:P + 积分(带限幅,防 windup)+ 微分 */
float pid_update(pid_t *pid, float setpoint, float measurement)
{
    const float error = setpoint - measurement;

    const float p_term = pid->kp * error;

    /* 积分项:先累加再限幅,防止积分饱和后停车猛冲 */
    pid->integral += pid->ki * error * pid->dt;
    pid->integral = clampf(pid->integral, pid->integral_limit);

    const float d_term = pid->kd * (error - pid->last_error) / pid->dt;
    pid->last_error = error;

    /* 输出同样限幅,保护电机驱动器 */
    return clampf(p_term + pid->integral + d_term, pid->output_limit);
}

图 5-1:STM32 端速度闭环:位置式 PID(pid.c)(highlight.js 高亮渲染)

5.2 STM32 端 COBS 编解码(cobs.c)

文件:stm32f103v1.0/Core/Src/cobs.c


/* 编码:帧内每个 0x00 用 code 字节替代,0x00 只留给帧尾 */
size_t cobs_encode(const uint8_t *src, size_t src_len, uint8_t *dst)
{
    size_t read_idx = 0, write_idx = 1, code_idx = 0;
    uint8_t code = 1;

    if (src_len == 0) { dst[0] = 0x01; dst[1] = 0x00; return 2; }

    for (;;) {
        if (read_idx >= src_len) {          /* 数据结束:回填 code + 终止符 */
            dst[code_idx] = code;
            dst[write_idx] = 0x00;
            return write_idx + 1;
        }
        if (src[read_idx] == 0x00) {        /* 遇 0x00:结束本块,重新计数 */
            dst[code_idx] = code;
            code_idx = write_idx++;
            code = 1;
            read_idx++;
        } else {
            dst[write_idx++] = src[read_idx];
            read_idx++;
            if (++code == 0xFF) {           /* 连续 254 个非零,强制终结 */
                dst[code_idx] = code;
                code_idx = write_idx++;
                code = 1;
            }
        }
    }
}

/* 解码:块间补回隐藏的 0x00(无损变体),越界即返回 0 */
size_t cobs_decode(const uint8_t *src, size_t src_len,
                   uint8_t *dst, size_t dst_cap)
{
    size_t read_idx = 0, write_idx = 0;

    while (read_idx < src_len) {
        const uint8_t code = src[read_idx++];
        if (code == 0x00) break;                    /* 帧终止符 */
        if (read_idx + (code - 1) > src_len) return 0;
        if (write_idx + (code - 1) > dst_cap ||
            (code != 0xFF && write_idx + code > dst_cap))
            return 0;                               /* 输出越界防护 */
        for (uint8_t i = 1; i < code; i++)
            dst[write_idx++] = src[read_idx++];
        if (code != 0xFF) {
            if (read_idx >= src_len) return 0;      /* 帧不完整 */
            if (src[read_idx] == 0x00) break;       /* 真正的帧尾 */
            dst[write_idx++] = 0x00;                /* 补回隐藏 0x00 */
        }
    }
    return write_idx;
}

图 5-2:STM32 端 COBS 编解码(cobs.c)(highlight.js 高亮渲染)

5.3 STM32 端帧接收与周期任务(protocol.c)

文件:stm32f103v1.0/Core/Src/protocol.c


/* 逐字节接收:0x00 是帧终止符,连同该字节一起 COBS 解码 */
void proto_rx_byte(uint8_t b)
{
    rx_last_byte_ms = HAL_GetTick();

    if (b == 0x00) {
        if (rx_len == 0) return;                    /* 空帧丢弃 */

        rx_buf[rx_len++] = 0x00;                    /* 帧尾是编码帧一部分 */

        uint8_t raw[64];
        const size_t raw_len = cobs_decode(rx_buf, rx_len, raw, sizeof(raw));

        if (raw_len >= 2 &&
            calc_checksum(raw, raw_len - 1) == raw[raw_len - 1]) {
            handle_cmd(raw[0], &raw[1], raw_len - 2, rx_last_byte_ms);
        } else {
            g_state.rx_errors++;                    /* 坏帧计数 */
        }
        rx_len = 0;
        return;
    }

    if (rx_len >= RX_BUF_MAX - 1) {                 /* 溢出重同步 */
        rx_len = 0;
        g_state.rx_errors++;
        return;
    }
    rx_buf[rx_len++] = b;
}

/* 5ms 周期任务:半帧超时重同步 + 通信超时停车 + PING 应答 */
void proto_periodic_5ms(uint32_t now_ms)
{
    if (rx_len > 0 && (now_ms - rx_last_byte_ms) > PROTO_RX_RESYNC_MS) {
        rx_len = 0;                                 /* 丢弃残留半帧 */
        g_state.rx_errors++;
    }

    if (g_state.last_cmd_ms != 0 &&
        (now_ms - g_state.last_cmd_ms) > PROTO_TIMEOUT_MS) {
        g_state.left_target = 0;                    /* 200ms 无指令停车 */
        g_state.right_target = 0;
        g_state.status = PROTO_STATUS_TIMEOUT;
        pid_reset(&g_pid_left);
        pid_reset(&g_pid_right);
        g_state.last_cmd_ms = 0;
    }

    if (ping_pending) {                             /* PING 立即应答 */
        ping_pending = 0;
        proto_send_status();
    }
}

图 5-3:STM32 端帧接收与周期任务(protocol.c)(highlight.js 高亮渲染)

5.4 ESP32 端 UART 接收任务与状态解析(motor_uart.c)

文件:esp32ev1.0/components/motor_uart/motor_uart.c


/* 接收任务:UART 事件驱动,逐字节喂给协议栈 */
static void uart_rx_task(void *arg)
{
    uart_event_t ev;
    QueueHandle_t uart_queue = (QueueHandle_t)arg;
    uint8_t byte;

    for (;;) {
        if (xQueueReceive(uart_queue, &ev, portMAX_DELAY)) {
            if (ev.type == UART_DATA) {
                while (uart_read_bytes(CONFIG_MOTOR_UART_PORT, &byte, 1, 0) > 0)
                    rx_byte(byte);
            } else if (ev.type == UART_FIFO_OVF || ev.type == UART_FRAME_ERR ||
                       ev.type == UART_PARITY_ERR || ev.type == UART_BREAK ||
                       ev.type == UART_DATA_BREAK) {
                /* 硬件错误:清空 FIFO 重新同步(ESP-IDF v6.0 拆分类) */
                uart_flush_input(CONFIG_MOTOR_UART_PORT);
                rx_len = 0;
                g_status.rx_errors++;
            }
        }

        /* 半帧超时重同步:50ms 内没等到帧尾 0x00 */
        if (rx_len > 0) {
            const uint32_t now_ms = esp_timer_get_time() / 1000;
            if ((now_ms - rx_last_byte_ms) > 50) {
                rx_len = 0;
                g_status.rx_errors++;
            }
        }
    }
}

/* STATUS 帧解析:15 字节 payload = 6×int16 + status + uint16 rx_errors */
static void parse_status(const uint8_t *payload, size_t plen)
{
    if (plen < 15) return;

    const int16_t fields[6] = {
        (int16_t)(payload[0] | (payload[1] << 8)),
        (int16_t)(payload[2] | (payload[3] << 8)),
        (int16_t)(payload[4] | (payload[5] << 8)),
        (int16_t)(payload[6] | (payload[7] << 8)),
        (int16_t)(payload[8] | (payload[9] << 8)),
        (int16_t)(payload[10] | (payload[11] << 8)),
    };
    g_status.left_speed  = fields[0];
    g_status.right_speed = fields[1];
    g_status.left_pwm    = fields[2];
    g_status.right_pwm   = fields[3];
    g_status.left_target = fields[4];
    g_status.right_target = fields[5];
    g_status.status      = payload[12];
    g_status.rx_errors   = (uint16_t)(payload[13] | (payload[14] << 8));
}

图 5-4:ESP32 端 UART 接收任务与状态解析(motor_uart.c)(highlight.js 高亮渲染)

5.5 上位机侧 COBS 解码与状态解析(device_link.py)

文件:LiSAC_Car_Console/device_link.py


def cobs_decode(data: bytes) -> bytes:
    &quot;&quot;&quot;无损 COBS 解码:块间补回 0x00,读到帧尾 0x00 结束&quot;&quot;&quot;
    out = bytearray()
    read_idx, n = 0, len(data)

    while read_idx < n:
        code = data[read_idx]
        read_idx += 1
        if code == 0x00:
            break                                  # 帧终止符
        if read_idx + (code - 1) > n:
            return b&#x27;&#x27;                             # 数据不足,非法帧
        out += data[read_idx:read_idx + code - 1]
        read_idx += code - 1
        if code != 0xFF:
            if read_idx >= n:
                return b&#x27;&#x27;                         # 帧不完整
            if data[read_idx] == 0x00:
                break                              # 真正的帧尾
            out.append(0x00)                       # 补回隐藏 0x00
    return bytes(out)


def _parse_frame(self, encoded: bytes):
    raw = cobs_decode(encoded)
    if len(raw) < 2:
        return
    if (sum(raw[:-1]) & 0xFF) != raw[-1]:          # 校验和
        return
    cmd, payload = raw[0], raw[1:-1]

    if cmd == CMD_STATUS and len(payload) >= 15:   # STATUS: 6×int16 + ...
        fields = struct.unpack(&#x27;<6h&#x27;, payload[:12])
        self._status.update({
            &#x27;left_speed&#x27;: fields[0], &#x27;right_speed&#x27;: fields[1],
            &#x27;left_pwm&#x27;: fields[2], &#x27;right_pwm&#x27;: fields[3],
            &#x27;left_target&#x27;: fields[4], &#x27;right_target&#x27;: fields[5],
            &#x27;status&#x27;: payload[12],
            &#x27;rx_errors&#x27;: payload[13] | (payload[14] << 8),
            &#x27;last_recv&#x27;: time.time(),
        })


def send_speed(self, left: int, right: int, mode: int = MODE_CLOSED) -> bool:
    &quot;&quot;&quot;闭环模式单位 counts/5ms,开环模式单位 PWM %&quot;&quot;&quot;
    payload = struct.pack(&#x27;<hhB&#x27;, int(left), int(right), mode)
    return self._send(CMD_SET_SPEED, payload)

图 5-5:上位机侧 COBS 解码与状态解析(device_link.py)(highlight.js 高亮渲染)

5.6 ESP32 端 OTA 版本检测(ota_client.c)

文件:esp32ev1.0/components/ota_client/ota_client.c


static const char *CURRENT_VERSION = "1.0.0";

/* 向服务器查询最新版本号,与本地对比决定是否触发升级 */
static esp_err_t check_version(char *server_version, size_t len)
{
    char url[128];
    snprintf(url, sizeof(url),
             "http://%s:%d/version",
             CONFIG_OTA_SERVER_IP, CONFIG_OTA_SERVER_PORT);

    esp_http_client_config_t config = {
        .url = url,
        .timeout_ms = 5000,
    };
    esp_http_client_handle_t client = esp_http_client_init(&config);
    if (client == NULL) {
        ESP_LOGE(TAG, "Failed to init HTTP client");
        return ESP_FAIL;
    }

    esp_err_t err = esp_http_client_open(client, 0);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "HTTP open failed: %s", esp_err_to_name(err));
        esp_http_client_cleanup(client);
        return err;
    }

    /* 读取服务器返回的版本串,与 CURRENT_VERSION 做字符串比较 */
    int len_recv = esp_http_client_read_response(client, server_version, len);
    esp_http_client_cleanup(client);

    if (len_recv <= 0) return ESP_FAIL;
    if (strcmp(CURRENT_VERSION, server_version) >= 0)
        return ESP_ERR_NOT_FOUND;                  /* 本地已是最新 */
    return ESP_OK;                                 /* 有新版本可升级 */
}

图 5-6:ESP32 端 OTA 版本检测(ota_client.c)(highlight.js 高亮渲染)

六、开源库与第三方依赖

本系统并非全部从零实现:三端的协议(COBS 帧/校验和)为本项目自研,其余大量底层能力来自成熟的第三方开源组件。下表为工程实际使用到的开源库清单(均已保留原始版权声明)。

6.1 芯片 / 固件层面

开源库 / 项目来源用途许可证
FreeRTOSSTM32 Middlewares/Third_Party/FreeRTOSSTM32 端实时操作系统(任务调度 / 定时器)MIT
STM32 HAL / CMSISSTMicroelectronics 官方(Drivers/)STM32F103 外设驱动抽象与 Cortex-M3 内核定义BSD-3-Clause / Apache-2.0
ESP-IDF乐鑫官方 SDK(v6.0)ESP32 开发框架:UART / WiFi / HTTP / OTA APIApache-2.0
ESP_OTAgithub.com/ParamtapKiri/ESP_OTAESP32 OTA 无线升级——由 Arduino 生态移植到 ESP-IDF(见第四章)MIT
I2Cdev 库github.com/jrowberg/i2cdevlib(Jeff Rowberg)ESP32 端 I2C 抽象(components/i2cdev)MIT
MPU6050 驱动I2Cdevlib 附带的 MPU6050 设备类ESP32 端六轴姿态传感器与 DMP 解算(components/mpu6050)MIT
SSD1306 驱动ESP-IDF 官方驱动(components/ssd1306)ESP32 端 OLED 屏幕 I2C/SPI 驱动MIT

6.2 上位机(Python / 前端)层面

开源库来源用途许可证
FlaskPallets Projects上位机 Web 后端框架(21 条路由)BSD-3-Clause
Jinja2Pallets ProjectsHTML 模板引擎(服务端渲染)BSD-3-Clause
pyserialPython Serial 项目上位机 UART 串口读写(device_link.py)BSD-3-Clause
EChartsApache Foundation状态页实时曲线渲染(static/js/vendor/echarts.min.js)Apache-2.0
highlight.jshighlightjs.org本报告代码块语法高亮BSD-3-Clause

6.3 关于移植与合规

HAL(硬件抽象层)Hardware Abstraction Layer。厂商推荐的编程方式:程序员不直接操作芯片寄存器(硬件细节),而是调用统一的库函数(如 HAL_UART_Transmit() 发一帧串口数据),库内部替你搞定寄存器配置。好处:代码可读、可移植、开发快;代价:比直接写寄存器多一层调用开销。这是 ST 官方推荐的开发方式。
CMSISCortex Microcontroller Software Interface Standard,ARM 官方定义的 Cortex-M 芯片标准编程接口:规定同一型号的芯片 (如 STM32F103) 内核部分(启动、寄存器定义、中断)都有统一写法。HAL 库就构建在 CMSIS 之上——CMSIS 管"内核",HAL 管"外设"。
I2C(总线)芯片之间通信的一种串行总线,只需 2 根线(SDA 数据、SCL 时钟),设备靠地址区分,适合"一块板上挂多个传感器"。系统中 OLED 屏幕、MPU6050 姿态传感器、八路循迹模块三个设备共用同一条 I2C 总线,靠不同地址(0x3C / 0x68 / 0x12)区分。CPU 没有 I2C 的话,也可以用 GPIO 模拟(本项目 ESP32 用的是硬件 I2C)。
DMP(数字运动处理器)Digital Motion Processor——MPU6050 芯片内部自带的一个"微型处理器"。惯导数据融合(把加速度计+陀螺仪数据算成姿态角)通常需要较复杂的四元数解算,DMP 可以完全在芯片内部完成,把现成的姿态角吐出来,主控只需读结果,几乎不占 CPU。本系统姿态解算就是这么做的。
开源许可证(MIT / BSD / Apache)开源的"使用与再分发规则",决定你能怎么用这段代码。三者都是宽松型:可自由使用、修改、商用,主要义务是"保留出处声明"(引用开源没版权风险)。区别很细微:MIT 最简洁;BSD 要求保留原版权声明;Apache 额外对专利做出承诺。选宽松许可证的组件,意味着基本可以放心移植进商用产品。
Arduino全球最流行的"入门单片机平台"(硬件 + 软件)。它的编程非常友好——用户只写 setup() / loop() 两个函数。ESP_OTA 这个开源项目最初就是用 Arduino 写的,我们把它迁移到更底层、更工程化的 ESP-IDF 上(见第四章)。

七、各工程源码结构

以下是三个工程的主要目录结构(已略去 build 产物与厂商驱动细节,★ 标记核心自研模块)。

7.1 执行单元:stm32f103v1.0/

STM32 工程目录树
图 1:STM32 工程结构。核心代码集中在 Core/Src/ 下的 main.c(电机任务 5ms)、pid.c(增量式 PID)、protocol.c 与 cobs.c(帧协议与 COBS 编解码)是本系统的自研重点;Middlewares 内含 FreeRTOS 内核,Drivers 为厂商驱动。

7.2 指挥单元:esp32ev1.0/

ESP32 工程目录树
图 2:ESP32 工程采用组件化(components/)组织。motor_uart(与 STM32 的帧通信)、ir8_sensor(八路循迹)、ota_client(OTA 客户端)为核心组件;partitions.csv 定义含 A/B 分区的 Flash 分区表。

7.3 监控单元:LiSAC_Car_Console/

上位机工程目录树
图 3:上位机工程为纯 Python/前端:server.py(21 条路由 + MOCK 数据源)、device_link.py(串口帧链路)、templates/(5 个页面)、static/(样式与页面脚本)、uploads/(固件暂存)。

八、开发进度与资源占用实测

8.1 已完成交付项

交付项状态验证方式
STM32 电机闭环 + PID + 协议固件已编译通过GCC 编译 0 警告,资源占用实测
ESP32 循迹决策 + motor_uart 组件已编译通过idf.py 全量构建 0 警告
上位机 21 条路由 + 串口链路可运行页面 200、真实/模拟数据自动切换
COBS 协议三端实现测试通过边界用例:空帧、全零、254/255 长帧、负值、新旧帧兼容

8.2 资源占用(编译期实测)

单元存储区占用总量占用率
STM32程序 Flash26.5 KB64 KB40.5%
运行时 RAM13.7 KB20 KB66.7%
ESP32应用分区0.95 MB2 MB45%
内核 RAM(IRAM)92 KB128 KB70.4%

STM32 Flash 余 60%、RAM 余 33%;ESP32 应用分区余 55%。均有充足升级与调试余量,无内存溢出风险,且足以支撑 OTA 双分区布局。

IRAM / Flash / RAM芯片两种主要存储器的用途分工:Flash 掉电不丢,存程序本体;RAM(运行内存)断电即清,存程序运行时产生的变量,速度比 Flash 快得多。ESP32 的 RAM 进一步分两类:普通 RAM(可缓存)和 IRAM(指令 RAM,CPU 直接执行最快的存储区,运行高效任务/中断必需)。嵌入式开发要盯紧 RAM 占用——RAM 不够程序直接跑不起来,没有硬盘可借用。

8.3 测试策略


九、当前风险与后续工作

9.1 风险与处置

风险一:IR8 传感器排线端子不匹配。 循迹模块排线端子不是常见 XH2.54 型号,需改连接器设计。处置:已把 IR8 从主监控页暂时隐藏(后台数据仍在用),接线图纸改好后恢复显示,不影响循迹代码。
风险二:控制参数需真机整定。 PID 参数当前为初值 $(0.5, 0.05, 0.01)$,循迹增益为经验值。上位机"手动控制 + PID 在线调参"两页即为真机整定而设计,可点亮后直接调整。
风险三:预留接口仍带编译警告。 ESP32 侧有 4 个未调用的初始化函数(DMP 姿态、OLED 驱动等),属预留接口,不影响功能;接入功能时移除警告即可。

9.2 后续里程碑

阶段内容
M1 · 已完成三模块代码 + COBS 协议 + 单端测试全落地
M2 · 已完成通信鲁棒性(超时/重同步/坏帧统计)+ 三端编译
M3 · 进行中IR8 端子改设计、整车重新接线
M4 · 待执行整机联调:上电 → 通信 → 电机闭环 → 循迹跑线
M5 · 待执行PID 实地整定 + 循迹调参 + 全链路验收

本报告为 LiSAC 开发小组内部技术综述,用于组会汇报与研发管理。编译与资源数据为当前工程实测值,可能随开发迭代而变化。