# ARES 项目代码审查报告 > **审查日期**: 2025 年 > **审查范围**: `/App` 目录下全部业务代码(排除 `VL53L0X_API/core` ST 官方库) > **审查基准**: HANDOFF.md 中的设计描述与已修复 BUG 列表 > **审查重点**: 残留 BUG、潜在运行时风险、代码质量问题 --- ## 审查总结 | 严重等级 | 数量 | 说明 | |---------|------|------| | 🔴 严重 (可能导致功能错误/崩溃) | 4 | 运行时会导致错误行为 | | 🟠 中等 (潜在风险/隐患) | 6 | 特定条件下可能触发问题 | | 🟡 低 (代码质量/可维护性) | 7 | 不影响功能但应改善 | **总体评价**: 项目架构设计清晰,HANDOFF.md 中列出的 9 个严重 BUG 和 7 个质量问题均已正确修复。但仍存在若干残留问题,以下逐一分析。 --- ## 🔴 严重问题 ### S-1: VL53L0X 卡尔曼滤波 Q/R 参数硬编码,未使用 `robot_params.h` 配置 **文件**: `VL53L0X_API/platform/vl53_board.c:84` ```c /* 初始化卡尔曼滤波器:默认 Q=1.0, R=9.5 */ // ← 注释过期 vl53_kalman_init(&board->kf[i], 10.0f, 14.1f); // ← 硬编码值 ``` `robot_params.h` 中定义了 `PARAM_VL53_KALMAN_Q = 10.0f` 和 `PARAM_VL53_KALMAN_R = 14.1f`,但 `vl53_board.c` 没有 `#include "robot_params.h"`,而是直接写死了 `10.0f` 和 `14.1f`。虽然当前值恰好与参数一致,但**修改 `robot_params.h` 中的 `PARAM_VL53_KALMAN_Q/R` 不会生效**,与 BUG-2 (EKF 参数硬编码) 属于同类问题。 **修复建议**: ```c #include "robot_params.h" // ... vl53_kalman_init(&board->kf[i], PARAM_VL53_KALMAN_Q, PARAM_VL53_KALMAN_R); ``` --- ### S-2: `LASER_SIMPLE_GetSnapshot()` 返回指针无线程安全保护,存在数据撕裂风险 **文件**: `laser/laser_manager.c:277-279` ```c const laser_simple_snapshot_t *LASER_SIMPLE_GetSnapshot(void) { return &g_snapshot; // ← 返回原始指针,无拷贝、无临界区保护 } ``` 消费者 `AppTasks_RunLaserTestTask_Impl()` 调用此函数获取指针后直接传给 `Blackboard_UpdateLaser(snap)`。由于 `LaserTask`(内部10ms任务)在 `taskENTER_CRITICAL()` 中**逐通道写入** `g_snapshot.ch[i]`,而消费者拿到的是裸指针,读取可能发生在写入的间隙——比如前两个通道已更新、后两个通道还是旧值——构成**部分撕裂**。 虽然 `Blackboard_UpdateLaser` 内部有临界区保护,但问题出在**读 g_snapshot 的时候没有保护**。当前架构中 `laserTestTask` 以 50ms 周期读取、`LaserTask` 以 10ms 周期写入,两者优先级相同,可能发生抢占。 **影响等级**: 中高。实际撕裂概率取决于临界区外的读取窗口大小。由于 `laser_simple_snapshot_t` 只包含 4 个 `laser_simple_data_t`,单次 `memcpy` 耗时极短,实际风险较低。但从防御性编程角度应修复。 **修复建议**: ```c void LASER_SIMPLE_GetSnapshotCopy(laser_simple_snapshot_t *out) { taskENTER_CRITICAL(); *out = g_snapshot; taskEXIT_CRITICAL(); } ``` --- ### S-3: `snc_parse_odom_delta()` 在 ISR 中写 `odom_accum` 无临界区保护 **文件**: `Can/snc_can_app.c:148-173` (在 `SNC_CAN_RxFifo0Callback` 即 CAN ISR 中被调用) ```c static void snc_parse_odom_delta(const uint8_t *d) { // ... 直接写 g_snc_can_app.odom_accum 的各个字段 ... SNC_OdomDeltaAccum_t *acc = &g_snc_can_app.odom_accum; acc->fl_accum += (int32_t)snc_read_i16_le(d[0], d[1]); // ... if (acc->frame_count < 255U) { acc->frame_count++; } } ``` 而消费侧 `SNC_CAN_ConsumeOdomDelta()` 使用 `taskENTER_CRITICAL()` 保护读取和清零。问题是:**`taskENTER_CRITICAL()` 在 Cortex-M 上通过 BASEPRI 屏蔽中断,只屏蔽优先级不高于 `configMAX_SYSCALL_INTERRUPT_PRIORITY` 的中断**。如果 CAN FIFO0 中断的优先级高于此阈值(即数值更小),那么 `snc_parse_odom_delta()` 可能在消费者持有临界区期间执行,导致**竞态条件**:消费者清零后,ISR 立刻写入新数据,然后消费者返回的 `snapshot` 中 `frame_count=0` 但 `accum` 值已非零。 **影响等级**: 取决于 CAN 中断优先级配置。如果 CAN 中断优先级在 FreeRTOS 管理范围内(优先级数值 ≥ `configMAX_SYSCALL_INTERRUPT_PRIORITY`),则 `taskENTER_CRITICAL()` 可以正确屏蔽它,问题不存在。**需要检查 CubeMX 中 FDCAN1 中断优先级配置**。 **修复建议**: 确认 FDCAN1 中断优先级 ≥ `configMAX_SYSCALL_INTERRUPT_PRIORITY`。或在 `snc_parse_odom_delta()` 中也使用 `taskENTER_CRITICAL()`(但 ISR 中应使用 `taskENTER_CRITICAL_FROM_ISR()`)。 --- ### S-4: `corridor_msgs.h` 使用 `#pragma pack(push, 1)` 导致 EKF 矩阵和浮点运算性能损失及潜在对齐异常 **文件**: `preproc/corridor_msgs.h:8,61` ```c #pragma pack(push, 1) // ... typedef struct { float P[3][3]; // 36 字节的浮点矩阵,被强制 1 字节对齐 float innovation[3]; // 12 字节 float mahalanobis_d2; // ... } CorridorState_t; // ... #pragma pack(pop) ``` `CorridorState_t` 包含大量 `float` 和 `float[3][3]` 数组,被 `#pragma pack(push, 1)` 强制 1 字节对齐。在 Cortex-M7 上: 1. **性能损失**: 未对齐的 float 访问会触发硬件 unaligned access 处理,速度比对齐访问慢数倍。这个结构体在 navTask 的 20ms 循环中被高频读写,影响实时性能。 2. **FPU 指令风险**: 某些 FPU 指令(如 `VLDM`/`VSTM`)**要求 4 字节对齐**,不对齐会触发 UsageFault。虽然 GCC 通常会生成安全的加载指令,但编译器优化可能引入向量化或批量加载指令。 `CorridorObs_t` 和 `RawCmd_t` 也受影响,但内部结构更简单,风险较低。 **修复建议**: `#pragma pack(push, 1)` 仅用于需要匹配物理总线帧格式的结构体(如 `chassis_can_msg.h` 中的 CAN 帧,那是正确的用法)。`corridor_msgs.h` 中的结构体是内存中的数据传递,**不需要 pack(1)**,应删除。 --- ## 🟠 中等问题 ### M-1: IMU 帧解析状态机缺少帧类型 0x55 之后的 header byte 校验 **文件**: `IMU/hwt101.c:64-68` ```c if (s_frame_idx == 0 && byte != 0x55) continue; s_frame[s_frame_idx++] = byte; if (s_frame_idx >= 11) { ``` 当前状态机只检查首字节 `0x55`,然后无条件接收后续 10 字节。如果数据流中出现非帧头的 `0x55`(比如校验和恰好是 `0x55`),会导致帧错位。虽然最后有校验和检查可以过滤错误帧,但会**浪费 10 个字节的解析窗口**,在高频数据流中可能导致短暂的数据更新延迟。 **修复建议**: 在 `s_frame_idx == 1` 时检查第二字节是否为有效帧类型 (`0x51`/`0x52`/`0x53`),不匹配则 `s_frame_idx = 0` 重新寻帧。 --- ### M-2: `nav_script.c` 转向方向始终为正,不支持反向 (顺时针) 180° 转弯 **文件**: `nav/nav_script.c:202,229` ```c float target_delta = s_cfg.turn_target_angle; // 默认 PI (180度) // ... float turn_dir = (target_delta > 0.0f) ? 1.0f : -1.0f; ``` `turn_target_angle` 在 `app_tasks.c` 中始终被初始化为 `3.14159265f`(正值),所以 `turn_dir` 永远为 `1.0f`(逆时针)。如果因场地布局需要顺时针转弯,当前代码无法支持。更关键的是,**两次 180° 转弯方向相同**——第一次到端逆时针转,第二次到端又逆时针转,结果机器人回到出发方向。这可能是设计意图(转完 180° 后继续正向走),但如果走廊空间不对称,固定转向方向可能导致转弯时撞墙。 **修复建议**: 考虑根据当前方向或走廊几何自动选择转向方向,或至少在 `NavScriptConfig_t` 中加一个 `turn_direction` 参数。 --- ### M-3: `Odom_GetSpeed()` 在临界区外读取 `s_odom.vx` 和 `s_odom.wz` **文件**: `Contract/robot_odom.c:122-124` ```c void Odom_GetSpeed(float *out_vx, float *out_wz, OdomStatus_t *out_status) { if (out_vx != NULL) *out_vx = s_odom.vx; // ← 临界区外 if (out_wz != NULL) *out_wz = s_odom.wz; // ← 临界区外 if (out_status != NULL) { lock_odom(); // ← 只有 status 在临界区内 // ... unlock_odom(); } } ``` `s_odom.vx` 和 `s_odom.wz` 是 `float` 类型,在 Cortex-M7 上 32 位对齐的 float 读写是原子的,所以单独读取不会撕裂。但 `vx` 和 `wz` 之间不是原子的——可能读到旧的 `vx` 和新的 `wz`。不过此函数当前未被任何代码调用(速度通过 `Blackboard_UpdateOdom` 传递),所以实际影响为零。 **修复建议**: 将 `vx/wz` 的读取也放入 `lock_odom()` 中,或标注此函数为 `@deprecated`。 --- ### M-4: `SNC_CAN_SendHeartbeat()` 使用零长度数组 **文件**: `Can/snc_can_app.c:194` ```c HAL_StatusTypeDef SNC_CAN_SendHeartbeat(void) { uint8_t data[0]; // ← 零长度数组,C11 标准未定义行为 HAL_StatusTypeDef ret = snc_fdcan_add_tx_std(SNC_CAN_ID_HEARTBEAT, data, 0U); ``` 零长度数组 `uint8_t data[0]` 在 C11 标准中是未定义行为(C99 的 flexible array member 语法是 `[]` 且只能在结构体末尾)。虽然 GCC 作为扩展支持它,且 `snc_fdcan_add_tx_std` 传入 `dlc_bytes=0` 不会实际读取 `data`,但这仍属于未定义行为。 **修复建议**: 改为 `uint8_t data[1] = {0};` 或直接传 `NULL`(需确认 HAL 是否接受 NULL data)。 --- ### M-5: 安全状态机 (SegFsm) 在脚本覆盖模式下仍然生效,可能干扰转弯等特殊动作 **文件**: `app_tasks.c` navTask 流水线 Step 5-6 ```c /* Step 5: 控制律 */ if (script_out.use_override) { raw_cmd.v = script_out.override_v; // 脚本指定速度 raw_cmd.w = script_out.override_w; } else if (script_out.request_corridor) { CorridorCtrl_Compute(..., &raw_cmd); } /* Step 6: 段状态机 -> 安全仲裁 */ SegFsm_Update(&raw_cmd, &obs, &corridor_state, &fsm_out); ``` 当脚本处于 `TURN_AT_END` 阶段时,`use_override = true`,输出 `v=0, w=turn_omega`(原地转向)。但此时 EKF 因侧墙观测丢失,`conf` 可能降到 `< 0.1`,触发 `SegFsm` 进入 `E-STOP`,**强制将转向角速度归零**,导致机器人无法完成转弯。 虽然 `E-STOP` 有自动恢复机制(`conf >= 0.5` 时恢复),但在转弯过程中侧墙持续不可见,`conf` 不会恢复,造成**死锁**:转不了弯 → 永远看不到墙 → 永远 E-STOP。 **影响等级**: 实车中度风险。取决于转弯时 EKF 的 IMU 航向观测是否能维持 `conf >= 0.1`。如果 IMU 航向观测能阻止 `conf` 跌破阈值,则不会触发。但这是一个脆弱的依赖。 **修复建议**: 在 `SegFsm_Update()` 中增加一个 bypass 标志,当脚本处于 `use_override` 模式时跳过 E-STOP 判定;或仅在 `request_corridor` 为 true 时检查置信度。 --- ### M-6: `process_complementary_laser()` 中 ATK 距离值无上限校验 **文件**: `preproc/corridor_preproc.c:30-73` ```c static bool process_complementary_laser(const SensorItem_t *stp, const SensorItem_t *atk, float *out_m) { // ... float atk_m = atk->value / 1000.0f; // ATK 没有像 VL53 那样的量程校验 (0.02~2.0m) // 如果 ATK 吐出异常大值 (如 65535mm),会被当作有效数据 ``` `process_side_laser()` 对 VL53L0X 数据做了 `[0.02m, 2.0m]` 范围校验,但 `process_complementary_laser()` 对 ATK 和 STP 数据**没有上限校验**。如果 ATK 传感器吐出异常大值(如掉线前最后一帧的乱码),这个值会被当作有效距离传递给安全状态机,可能导致**该停车时不停车**。 **修复建议**: 添加 ATK/STP 的量程校验,如 `atk_m > 0.0f && atk_m <= 8.0f` (ATK 标称量程 4m,留双倍余量)。 --- ## 🟡 低优先级 / 代码质量问题 ### L-1: `PARAM_IMU_YAW_OFFSET` 声明但未使用 **文件**: `robot_params.h:100` ```c #define PARAM_IMU_YAW_OFFSET 0.0f // 声明了但代码中未使用 ``` HANDOFF.md CAL-4 已标注此问题。如果 IMU 安装有固定偏角,此参数应在 `HWT101_Process()` 或 `corridor_filter.c` 中使用。当前为死代码。 --- ### L-2: `corridor_filter.c` 中 `s_imu_yaw_ref_set` 在 Reset/Init 时未重置 **文件**: `est/corridor_filter.c:23-24` ```c static float s_imu_yaw_ref_rad = 0.0f; static bool s_imu_yaw_ref_set = false; ``` `CorridorFilter_Init()` 不会重置 `s_imu_yaw_ref_set`。如果比赛中途调用 `NavScript_Reset()` → `CorridorFilter_Init()` 重新初始化,旧的 `s_imu_yaw_ref_rad` 会保留,导致 IMU 航向观测使用过时的参考值。 **修复建议**: 在 `CorridorFilter_Init()` 末尾添加: ```c s_imu_yaw_ref_rad = 0.0f; s_imu_yaw_ref_set = false; ``` --- ### L-3: `retarget.c` 中 `_write()` 的忙等循环可能阻塞调用任务 **文件**: `retarget.c:44-64` ```c int _write(int file, char *ptr, int len) { while (1) { result = CDC_Transmit_FS((uint8_t *)ptr, (uint16_t)len); if (result == USBD_OK) return len; if ((HAL_GetTick() - start_tick) > 20U) return 0; if (osKernelGetState() == osKernelRunning) osDelay(1); } } ``` 如果 USB CDC 端口忙碌(Host 未连接或 buffer 满),`printf` 会阻塞当前任务最多 20ms。对于 `navTask`(20ms 周期)和 `canTxTask`(20ms 周期),一次 `printf` 阻塞就可能导致**整个控制周期跳过**。当前 `App_PrintStatus()` 已被注释掉,但如果未来取消注释或在其他任务中添加 `printf`,可能造成问题。 **修复建议**: 在实时任务中避免使用 `printf`,或将 `_write` 改为非阻塞(丢弃模式)。 --- ### L-4: 多个模块重复定义 `clampf()` 静态内联函数 **文件**: - `est/corridor_ekf.c:42` - `nav/corridor_ctrl.c:12` - `nav/nav_script.c:34` - `nav/segment_fsm.c:12` 四个文件各自定义了相同的 `static inline float clampf()`。虽然由于 `static` 链接属性不会导致链接错误,但违反 DRY 原则。 **修复建议**: 提取到公共头文件 `robot_params.h` 或新建 `utils.h`。 --- ### L-5: `CorridorObs_t.valid_mask` 类型为 `uint8_t` 但掩码使用 bit 0-5 共 6 位 **文件**: `preproc/corridor_msgs.h:31` 和 `corridor_preproc.h:10-15` 当前 6 个掩码位刚好在 `uint8_t` 范围内(最大 bit 5 = 0x20),但余量很小。如果未来添加更多传感器(如第二组 VL53),很容易溢出。不紧急但值得注意。 --- ### L-6: `vl53_board.c:84` 注释与实际值不一致 ```c /* 初始化卡尔曼滤波器:默认 Q=1.0, R=9.5 */ // ← 注释说 Q=1.0, R=9.5 vl53_kalman_init(&board->kf[i], 10.0f, 14.1f); // ← 实际值 Q=10.0, R=14.1 ``` 注释是旧版残留,与当前代码不符。 --- ### L-7: `nav_script.c:285` 使用 `exit_start_s == 0.0f` 判断是否已触发,但 `s` 初始值就是 0 ```c if (s_internal.exit_start_s == 0.0f) { s_internal.exit_start_s = state->s; } ``` 如果恰好在 `state->s ≈ 0.0` 时进入 EXIT 阶段(理论上不会,因为已经往返走过垄沟),`exit_start_s` 会被设为 0,然后下次循环 `== 0.0f` 再次为 true,重复赋值但不会出错(`state->s` 每次都差不多)。实际上因为 `memset(&s_internal, 0, ...)` 已将其初始化为 0.0,如果 EXIT 阶段被跳过再重入,可能出现意外行为。 **修复建议**: 使用 `bool exit_triggered` 标志代替浮点数零值判断。 --- ## 架构设计审查 ### 优点 1. **分层清晰**: 传感器驱动 → 黑板 → 预处理 → EKF → 控制 → 安全 → 指令槽,每层职责明确。 2. **线程安全设计合理**: 黑板的 `taskENTER_CRITICAL` + snapshot 模式有效防止了数据撕裂。 3. **传感器互补融合策略** (`process_complementary_laser`): STP+ATK 的互补逻辑考虑了盲区、单体故障、保守防撞等多种场景,设计周到。 4. **EKF 实现质量高**: 鲁棒 χ² 检验、分级观测更新(1/2/3 DOF)、协方差保护等机制完备。BUG-7(卡尔曼增益矩阵乘法)的修复正确。 5. **参数集中管理**: `robot_params.h` 作为唯一调参入口,大部分参数已正确引用(除 S-1 遗漏)。 6. **里程计累加器设计** (BUG-8 修复): ISR 累加 + 原子取走清零的设计有效解决了漏积分/重复积分问题。 ### 可改进方向 1. **缺少 Watchdog**: 系统没有硬件看门狗 (IWDG/WWDG)。如果任何任务死锁或 HardFault,MCU 将永久挂起而不会自动重启。建议在空闲任务中喂看门狗。 2. **缺少运行时错误日志**: 当前的错误处理主要是"静默忽略"或"返回零值"。建议增加一个轻量级错误计数器或环形日志 buffer,方便赛后分析。 3. **EKF 和安全状态机没有联动**: 如 M-5 所述,脚本覆盖模式下安全状态机可能干扰正常流程。建议在 `SegFsmOutput_t` 中增加 bypass 机制。 4. **`laserTestTask` 的 `osDelay(50)` 不精确**: 使用 `osDelay` 而非 `osDelayUntil`,任务执行时间会累加到周期中,导致实际周期 > 50ms。对于激光数据更新频率有影响。 --- ## 已修复 BUG 验证 | HANDOFF 编号 | 修复内容 | 验证结果 | |-------------|---------|---------| | BUG-1 | IMU wz deg→rad 转换 | ✅ `app_tasks.c:305` 使用 `PARAM_DEG2RAD()` | | BUG-2 | EKF Q/R/P0 从 params 读取 | ✅ `corridor_filter.c:50-66` 使用 `PARAM_EKF_*` | | BUG-3 | `SegFsm_Start()` 调用 | ✅ `app_tasks.c:382` | | BUG-4 | BACKWARD 段使用 d_front | ✅ `nav_script.c:254` | | BUG-5 | 超时使用配置参数 | ✅ `nav_script.c:157` | | BUG-6 | EXIT 速度独立参数 | ✅ `nav_script.c:273` 使用 `s_cfg.exit_v` | | BUG-7 | 完整矩阵乘法 K=PHT*S_inv | ✅ `corridor_ekf.c:576-607` 两步乘法 | | BUG-8 | 里程计累加器模式 | ✅ `snc_can_app.c` ISR 累加 + `ConsumeOdomDelta` 原子取走 | | BUG-9 | IMU yaw unwrap + 连续角度 | ✅ `hwt101.c` unwrap 逻辑 + `nav_script.c` 使用 IMU yaw | | Q-1~Q-7 | 各项代码质量修复 | ✅ 全部验证通过 | --- ## 修复优先级建议 | 优先级 | 编号 | 预估工时 | |--------|------|---------| | 立即修复 | S-4 (pack 对齐) | 5 分钟 | | 立即修复 | S-1 (VL53 KF 参数) | 5 分钟 | | 赛前修复 | M-5 (安全 FSM bypass) | 30 分钟 | | 赛前修复 | M-6 (ATK 量程校验) | 10 分钟 | | 赛前修复 | S-2 (快照线程安全) | 15 分钟 | | 赛前修复 | L-2 (IMU ref 重置) | 5 分钟 | | 确认配置 | S-3 (CAN 中断优先级) | 10 分钟 | | 建议改进 | 其余所有 | 按需 | --- > **审查结论**: 项目整体质量良好,架构设计规范,历史 BUG 修复彻底。上述 S-4 和 S-1 建议在下次烧录前立即修复;M-5 (安全状态机与脚本冲突) 是实车测试中最可能暴露的问题,建议优先验证。