整台车的控制功能被拆解到三个物理上独立的计算单元上,这种设计在嵌入式领域称为分布式控制。拆分的依据是:不同任务对实时性(毫秒级响应)、算力(能否跑复杂算法)与开发成本的要求不同,一块芯片同时承担所有任务往往顾此失彼。
| 单元 | 硬件 / 软件环境 | 承担的功能 | 核心约束 |
|---|---|---|---|
| 执行单元 | STM32F103 单片机 + FreeRTOS | 电机驱动、转速测量、闭环调速、急停保护 | 实时性最高:5ms 周期 |
| 指挥单元 | ESP32 双核 + ESP-IDF v6.0 | 八路循迹判断、姿态解算、OLED 显示、WiFi 联网、OTA 升级 | 算力与联网能力 |
| 监控单元 | 上位机 PC + Flask 网页 | 状态展示、手动控制、在线 PID 调参、固件管理 | 与硬件解耦,纯软件 |
链路①(主):上位机经 WiFi TCP 连上 ESP32 上的 tcp_bridge 桥组件,桥再把字节流原样转发到 STM32 的 UART——这条链上整车完全无线。链路②(备用,开发调试):上位机也可经 USB 转串口直接连 STM32,单独调试底盘,把问题隔离在最小范围。
第二章讲帧协议(链路①②共用同一套 COBS 帧),第三章讲上位机与 tcp_bridge,第四章讲 ESP32 的 OTA。
本系统在"芯片之间"与"上位机与芯片之间"统一采用一套帧约定:
帧 = [命令字 cmd 1字节] [载荷 payload] [校验和 checksum 1字节]
命令共 6 条:STOP 停车、SET_SPEED 设定车速、ESTOP 紧急停止、SET_PID 设定调速参数、PING 探活、STATUS 回传状态。载荷中的多元数据按小端字节序(低位字节在前)排列,两端严格一致。
0x00~0x0F,两个十六进制数正好写一个字节(如 0xFF=255=二进制 11111111)。文中 0x00 即"全零字节",0xFF 即"全一字节"。我们规定字节 0x00 是帧结束标志,因此帧内部绝不能出现 0x00。但业务数据(如车速为 0、角度为 0)里出现 0 字节是常有的事。COBS(Consistent Overhead Byte Stuffing,一致开销字节填充)解决此问题:
0x00 替换为一个 code 字节,该字节的数值表示"从当前位置往前数 $N$ 个字节都是非零"。0x00 精确插回原位,保证二进制数据(含负数补码)完全还原。该细节在 STM32、ESP32、上位机三端实现完全一致。传输中可能因电气干扰丢失或改写字节。我们在帧尾附加"整帧字节求和后取低 8 位"的校验和:
$$\text{checksum} = \left( \sum_{i=0}^{n-1} b_i \right) \bmod 256$$接收方重新求和,若与帧尾不一致,判定"这一帧脏了",整帧丢弃并累加一次坏帧计数 $e_{\text{rx}}$(rx_errors),随 STATUS 帧回传给上位机,使通信质量可观测。
| 机制 | 一句话说明 |
|---|---|
| 超时停车 | 200ms 未收到任何有效指令 → 自动清零车速(安全兜底) |
| 半帧重同步 | 帧接收中断超过 50ms → 丢弃残留半帧,防止与下一帧粘连 |
| 坏帧统计 | 校验失败次数经 STATUS 帧上报,通信质量可见 |
| PING 探活 | 发一帧命令,对方立即回一帧状态,验证链路与对端存活 |
| 越界防护 | 解码时限定输出缓冲上限,杜绝"越界写内存"类安全漏洞 |
执行单元的核心算法是增量式 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)$,待真机整定。
/static/ 目录提供,状态页用 ECharts 绘制实时曲线。GET /api/status,即"轮询机制"。| 页面 | 路径 | 功能 |
|---|---|---|
| 状态监控 | /dashboard | 轮速、PWM、姿态角、系统信息 + 实时曲线 |
| 手动控制 | /control | 方向键、急停大按钮、快捷速度档 |
| PID 调参 | /pid | 滑块在线修改 6 个 PID 参数,下发至 STM32 即时生效 |
| 固件烧录 | /firmware | 上传 .bin 固件到服务器、触发 OTA |
| 系统日志 | /logs | 查看/筛选后端运行日志 |
{{ 变量 }}、{% if %} 这样的占位符,后端把真实数据填进去再发给浏览器。效果就像"填空试卷"——试卷(模板)固定,每次填不同的答案(数据)就能生成不同内容的页面。它和 Flask 出自同一团队,常组合使用。后端内置一个 MOCK_STATUS 模拟数据源。读取状态时先判断"真机是否在线":
在线判定:后台线程每 0.5s 读一次链路,能持续收到状态帧即认为在线。这套"真实优先、模拟兜底"的设计使前端开发完全不等硬件。
device_link.py 是后端与芯片之间的"接线员",把两种物理传输方式抽象成同一套接口,上层协议处理(切帧→解码→校验→解析)完全复用:
| 模式 | 开启方式 | 适用场景 |
|---|---|---|
| 串口模式 | 默认,或设 LISAC_CONSOLE_PORT | USB 线直连 STM32,单独调试底盘 |
| TCP 模式(无线) | 设 LISAC_CONSOLE_HOST=ESP32 的 IP | 整车无线运行,通过 ESP32 桥接入 |
两种模式都维护一个独立后台线程:
读链路 → 按 0x00 切帧 → COBS 解码 → 校验和 → 解析为字典 → 更新状态缓存
发命令时把字典打包为 COBS 帧写入链路。传输循环与网页请求完全解耦,互不阻塞;传输层自动重连,掉线不影响网页服务。
纯无线调试需要一个把 WiFi TCP 转为 STM32 UART 的桥梁,它就是 ESP32 上的 tcp_bridge 组件(新端口,默认 5005):
实现参考:乐鑫官方示例 esp-idf/examples/protocols/sockets/tcp_server(socket 生命周期管理)与开源项目 mtoolstec/esp32-uart-bridge(Nagle 关闭),均已在 components/tcp_bridge/ 内注释注明。
| 步骤 | 执行者 | 动作 |
|---|---|---|
| 1 | 工程师 | 上位机"固件烧录"页上传新 .bin,服务器保存并登记版本号 |
| 2 | ESP32 | 周期轮询服务器,比较版本号($v_{\text{server}} > v_{\text{local}}$ 则触发) |
| 3 | ESP32 | 下载 .bin 到 Flash 的备份分区(A/B 双分区),边下载边校验 |
| 4 | ESP32 | 校验通过后标记分区有效 → 重启 |
| 5 | ESP32 | 引导新分区;若启动失败,自动回滚到旧分区 |
以下摘录本系统三端的关键代码片段,展示通信协议、控制算法、OTA 客户端三大核心模块的实际工程实现。语法高亮由 highlight.js 渲染,代码与仓库完全一致。
文件: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 高亮渲染)
文件: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 高亮渲染)
文件: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 高亮渲染)
文件: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 高亮渲染)
文件:LiSAC_Car_Console/device_link.py
def cobs_decode(data: bytes) -> bytes:
"""无损 COBS 解码:块间补回 0x00,读到帧尾 0x00 结束"""
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'' # 数据不足,非法帧
out += data[read_idx:read_idx + code - 1]
read_idx += code - 1
if code != 0xFF:
if read_idx >= n:
return b'' # 帧不完整
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('<6h', payload[:12])
self._status.update({
'left_speed': fields[0], 'right_speed': fields[1],
'left_pwm': fields[2], 'right_pwm': fields[3],
'left_target': fields[4], 'right_target': fields[5],
'status': payload[12],
'rx_errors': payload[13] | (payload[14] << 8),
'last_recv': time.time(),
})
def send_speed(self, left: int, right: int, mode: int = MODE_CLOSED) -> bool:
"""闭环模式单位 counts/5ms,开环模式单位 PWM %"""
payload = struct.pack('<hhB', int(left), int(right), mode)
return self._send(CMD_SET_SPEED, payload)
图 5-5:上位机侧 COBS 解码与状态解析(device_link.py)(highlight.js 高亮渲染)
文件: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 帧/校验和)为本项目自研,其余大量底层能力来自成熟的第三方开源组件。下表为工程实际使用到的开源库清单(均已保留原始版权声明)。
| 开源库 / 项目 | 来源 | 用途 | 许可证 |
|---|---|---|---|
| FreeRTOS | STM32 Middlewares/Third_Party/FreeRTOS | STM32 端实时操作系统(任务调度 / 定时器) | MIT |
| STM32 HAL / CMSIS | STMicroelectronics 官方(Drivers/) | STM32F103 外设驱动抽象与 Cortex-M3 内核定义 | BSD-3-Clause / Apache-2.0 |
| ESP-IDF | 乐鑫官方 SDK(v6.0) | ESP32 开发框架:UART / WiFi / HTTP / OTA API | Apache-2.0 |
| ESP_OTA | github.com/ParamtapKiri/ESP_OTA | ESP32 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 |
| 开源库 | 来源 | 用途 | 许可证 |
|---|---|---|---|
| Flask | Pallets Projects | 上位机 Web 后端框架(21 条路由) | BSD-3-Clause |
| Jinja2 | Pallets Projects | HTML 模板引擎(服务端渲染) | BSD-3-Clause |
| pyserial | Python Serial 项目 | 上位机 UART 串口读写(device_link.py) | BSD-3-Clause |
| ECharts | Apache Foundation | 状态页实时曲线渲染(static/js/vendor/echarts.min.js) | Apache-2.0 |
| highlight.js | highlightjs.org | 本报告代码块语法高亮 | BSD-3-Clause |
ota_client 组件(HTTP 客户端改用 ESP-IDF 官方 esp_http_client / esp_https_ota API),上层"连网 → 查版本 → 下载 → 切分区 → 重启"流程保持一致;HAL_UART_Transmit() 发一帧串口数据),库内部替你搞定寄存器配置。好处:代码可读、可移植、开发快;代价:比直接写寄存器多一层调用开销。这是 ST 官方推荐的开发方式。setup() / loop() 两个函数。ESP_OTA 这个开源项目最初就是用 Arduino 写的,我们把它迁移到更底层、更工程化的 ESP-IDF 上(见第四章)。以下是三个工程的主要目录结构(已略去 build 产物与厂商驱动细节,★ 标记核心自研模块)。
Core/:Src/ 下的 main.c(电机任务 5ms)、pid.c(增量式 PID)、protocol.c 与 cobs.c(帧协议与 COBS 编解码)是本系统的自研重点;Middlewares 内含 FreeRTOS 内核,Drivers 为厂商驱动。
| 交付项 | 状态 | 验证方式 |
|---|---|---|
| STM32 电机闭环 + PID + 协议固件 | 已编译通过 | GCC 编译 0 警告,资源占用实测 |
| ESP32 循迹决策 + motor_uart 组件 | 已编译通过 | idf.py 全量构建 0 警告 |
| 上位机 21 条路由 + 串口链路 | 可运行 | 页面 200、真实/模拟数据自动切换 |
| COBS 协议三端实现 | 测试通过 | 边界用例:空帧、全零、254/255 长帧、负值、新旧帧兼容 |
| 单元 | 存储区 | 占用 | 总量 | 占用率 |
|---|---|---|---|---|
| STM32 | 程序 Flash | 26.5 KB | 64 KB | 40.5% |
| 运行时 RAM | 13.7 KB | 20 KB | 66.7% | |
| ESP32 | 应用分区 | 0.95 MB | 2 MB | 45% |
| 内核 RAM(IRAM) | 92 KB | 128 KB | 70.4% |
STM32 Flash 余 60%、RAM 余 33%;ESP32 应用分区余 55%。均有充足升级与调试余量,无内存溢出风险,且足以支撑 OTA 双分区布局。
| 阶段 | 内容 |
|---|---|
| M1 · 已完成 | 三模块代码 + COBS 协议 + 单端测试全落地 |
| M2 · 已完成 | 通信鲁棒性(超时/重同步/坏帧统计)+ 三端编译 |
| M3 · 进行中 | IR8 端子改设计、整车重新接线 |
| M4 · 待执行 | 整机联调:上电 → 通信 → 电机闭环 → 循迹跑线 |
| M5 · 待执行 | PID 实地整定 + 循迹调参 + 全链路验收 |
本报告为 LiSAC 开发小组内部技术综述,用于组会汇报与研发管理。编译与资源数据为当前工程实测值,可能随开发迭代而变化。