首页 / 资讯中心 / 文章详情

C语言扫地机器人开源固件:分层架构、PID里程计与状态机实战

C语言扫地机器人开源固件:分层架构、PID里程计与状态机实战 ★ FEATURED ARTICLE
简介这份扫地机器人项目开源代码面向嵌入式开发学习者、机器人爱好者及课程设计或毕业设计参考者核心在于提供一套相对完整的整机控制代码帮助理解移动机器人从底层驱动到上层逻辑的组织方式。压缩包共849个文件约25.95MB以429个C源文件和236个头文件为主体辅以71个汇编文件、46个链接配置及工程文件、算法库和说明文档覆盖启动代码、外设驱动、传感器数据处理与工程构建等环节。已有815人学习浏览具备一定的参考与交流价值。读者可据此梳理电机控制、传感器融合、路径执行与任务调度等模块的代码结构结合Keil工程和配置文件快速定位入口适合用于二次开发、模块拆解和排错思路积累也能为相似嵌入式项目提供目录组织与工程管理样例。1. 一套 C 语言的扫地机器人开源代码能拿来干什么第一次拆这份扫地机器人开源代码我以为是那种挂在树莓派上、跑个 SLAM 库的演示工程翻开才发现是纯 C一个 C 文件都没有也不依赖任何机器人框架全部是寄存器级硬件抽象加几个算法文件。它的定位相当明确给一台差速轮扫地机提供从传感器采样、轮速闭环、清扫路径生成到自动回充的完整控制链路编译产物是个几十 KB 的固件烧进 MCU 机器就能动。它适合两类人手上有台能拆的扫地机、想把控制策略换成自己那套的嵌入式开发者还有想在真实约束下练状态机和覆盖算法的人。这不是能直接量产的商业固件但骨架完整分层清楚改起来不别扭。2. 目录结构与分层从编码器脉冲到轮速输出的一条主线判断一份嵌入式开源代码值不值得改我一般不看它算法多花哨先看目录怎么分。分得乱的代码你加一个功能要动五个文件分得好的改一处就能编译验证。这份代码属于后者它把算法和寄存器硬隔开了algo/目录下的文件不 include 任何芯片头文件可以直接丢到 PC 上跑单元测试这一条就省了我大量调试时间。2.1 顶层目录怎么分各管什么目录大致是这个结构我按自己的理解补了注释sweeper-fw/ ├── app/ # 业务层清扫任务、状态机、模式调度 │ ├── fsm_robot.c # 主状态机所有模式切换都走这里 │ ├── task_clean.c # 清扫任务入口负责把状态机跑起来 │ └── path_zigzag.c # 弓字形/螺旋等覆盖路径生成 ├── algo/ # 纯算法不碰寄存器可在 PC 上单独编译 │ ├── pid.c # 增量式 PID │ ├── filter.c # 中值 一阶低通 │ ├── odom.c # 里程计积分 │ └── grid_map.c # 栅格地图记录走过/障碍 ├── bsp/ # 板级支持具体引脚、外设初始化 │ ├── bsp_motor.c # PWM 方向脚 │ ├── bsp_encoder.c # 正交编码器计数 │ ├── bsp_ir.c # 红外避障、悬崖检测 │ ├── bsp_imu.c # 陀螺仪读取 │ └── bsp_battery.c # 电量采样 ├── hal/ # 纯头文件定义接口屏蔽芯片差异 │ ├── hal_motor.h │ ├── hal_ir.h │ └── hal_time.h ├── lib/ # 环形缓冲、CRC16、定点数学 ├── config/ │ └── robot_cfg.h # 轮径、轮距、减速比、PID 参数全在这 └── main.c分层规则只有一句话app/只允许 includehal/*.h和algo/*.h绝不允许直接 includebsp/*.h。这条规则听起来像形式主义实际价值在换板子的时候体现得最明显——把机器从一款 MCU 换到另一款只要重写bsp/五个文件app/和algo/一行不用动。我见过太多项目把GPIO_ReadInputDataBit直接写进状态机里换芯片等于重写。config/robot_cfg.h是第二个值得先看的文件所有机械参数集中在这改轮子不用翻代码/* robot_cfg.h —— 机械参数与限幅集中管理换机型只改这个文件 */ #define WHEEL_DIA_MM 65.0f /* 驱动轮直径卡尺实测别信标称值 */ #define WHEEL_BASE_MM 320.0f /* 左右轮中心距 */ #define ENCODER_PPR 11 /* 编码器线数 */ #define GEAR_RATIO 30.0f /* 减速比电机轴转 30 圈轮子转 1 圈 */ #define TICK_PER_REV (ENCODER_PPR * GEAR_RATIO) /* 330 脉冲/轮圈 */ #define MOTOR_PWM_MAX 1000 /* PWM 定时器满量程 */ #define WHEEL_SPEED_MAX 300.0f /* mm/s机械上限超过会丢步 */ #define CLIFF_THRESHOLD 1200 /* 悬崖 ADC 阈值实车标定后填 */参数说明WHEEL_DIA_MM和WHEEL_BASE_MM决定了里程计和运动学解算的准确性轮径差 1mm跑十米就能积累几厘米的偏差TICK_PER_REV是后面所有速度换算的分母改编码器或减速箱必须同步改这里MOTOR_PWM_MAX要和定时器实际 ARR 值对上对不上 PID 输出会一直饱和表现为轮子疯转但速度上不去。2.2 一次控制周期的数据流任务怎么错开嵌入式新手最容易犯的错是把所有东西塞进一个while(1)采样、计算、决策串成一条线结果决策一慢采样就漏。这份代码用的是软定时器加前后台调度把不同频率的活拆开1ms 采编码器和红外电平10ms 跑里程计和 PID20ms 跑状态机和路径100ms 跑电量与故障检测。频率不是拍脑袋定的编码器在 300mm/s 时每毫秒约 1.6 个脉冲1ms 采一次不会溢出计数PID 10ms 一次对应 100Hz 控制频率对差速轮够用状态机没必要跑那么快20ms 已经能保证沿边时不错过碰撞信号。/* main.c —— 前后台调度骨架用软定时器把任务按频率错开 */ #include hal_time.h #include app/task_clean.h #include algo/odom.h #include algo/pid.h static volatile uint32_t g_tick_1ms 0; /* 1ms 定时器中断只做最短的采样绝不在这里跑浮点运算 */ void TIM_IRQHandler(void) { hal_time_clear_flag(); bsp_encoder_sample(); /* 读计数并锁存增量 */ bsp_ir_sample(); /* 只读电平不做滤波 */ g_tick_1ms; } int main(void) { hal_init(); /* 时钟、GPIO、定时器、串口 */ bsp_motor_init(); bsp_encoder_init(); bsp_ir_init(); task_clean_init(); /* 状态机复位参数装载 */ uint32_t t10 0, t20 0, t100 0; while (1) { uint32_t now g_tick_1ms; if (now - t10 10) { /* 100Hz里程计 轮速闭环 */ t10 now; int32_t dl, dr; bsp_encoder_fetch(dl, dr); odom_update(g_odom, dl, dr, 0.010f); motor_speed_loop(dl, dr); /* 内部跑 PID输出 PWM */ } if (now - t20 20) { /* 50Hz状态机与路径决策 */ t20 now; task_clean_tick(now); /* 读传感器、切状态、发目标速度 */ } if (now - t100 100) { /* 10Hz慢任务 */ t100 now; bsp_battery_poll(); fault_check(); /* 堵转、悬崖、跌落统一在这判断 */ } hal_watchdog_feed(); /* 喂狗放在主循环末尾 */ } }这段代码有三个细节值得注意。第一中断里只做锁存不做计算bsp_encoder_sample把正交计数读进缓冲区就返回浮点滤波和积分留给主循环中断执行时间控制在几个微秒不会影响 PWM 输出。第二所有时间判断用now - tXX N而不是now tXX前者能容忍任务偶尔超时后者一旦错过就永远卡死。第三喂狗放在主循环末尾而不是定时器中断里狗的意义就是抓主循环卡死放中断里等于自己给自己发免死金牌。2.3 编译、烧录和 PC 端仿真怎么搭工程同时维护两个构建目标交叉编译的固件和本地编译的仿真程序。仿真目标把bsp/换成一套桩实现编码器脉冲从回放文件读电机输出写进日志好处是调路径算法时不用每次都把机器搬出来跑。# 交叉编译实机固件输出 build/sweeper.bin make MCUstm32f103 -j8 # 编译 PC 端仿真algo/ 与 app/ 原样复用只换 bsp 桩 make sim -j8 # 回放一段实车日志检查路径是否收敛 ./build/sim --replay log/run_01.csv --dump-trajectory out.csv参数说明MCU决定链接脚本和启动文件工程里通常预置两三个型号改这个变量就能切换--replay指定日志文件格式是每行时间戳,左脉冲,右脉冲,红外位图,悬崖位图--dump-trajectory输出积分出来的坐标序列可以直接用表格软件画图看轨迹有没有画歪。我一般先用仿真把弓字形跑通确认转向时机和步进宽度没问题再上实车能省掉一半的电池。提示仿真里的algo/和实车是同一份源码如果仿真跑出来正常、实车跑出来画圈问题基本锁定在bsp/的编码器方向或者电机接线别再去改 PID。3. 传感器与运动控制编码器、PID 和里程计这条链清扫策略再怎么设计最终落到执行层就是两件事知道自己走了多远、拐了多少度以及让轮子按给定速度转。这两件事任何一件不准弓字形就会越扫越歪走十分钟开始原地打转。这一章把从原始脉冲到轮速输出的链路拆开看顺便说说实车调参时我踩过的那些坑。3.1 从 ADC 毛刺到可信数据采样与滤波红外避障和悬崖检测的输出不是干净的数字量。悬崖传感器本质是个红外反射式测距地面材质从瓷砖换到地毯ADC 读数能差几百阳光斜射进屋子的时候读数会整体抬高。直接在中断里拿阈值一比较就置标志位结果就是机器在深色地板上频繁以为要掉下去原地后退。代码里用的是两级处理先在采样层做中值滤波去掉偶发毛刺再在决策层做一阶低通平滑趋势最后加一个连续 N 次超阈才确认的计数器。/* filter.c —— 中值滤波去毛刺一阶低通压抖动 */ #define MED_WIN 5 static uint16_t med_buf[MED_WIN]; static uint8_t med_idx; static float lpf_out; /* 中值滤波窗口内排序取中间值专门对付单点尖刺 */ uint16_t filter_median(uint16_t raw) { med_buf[med_idx % MED_WIN] raw; uint16_t tmp[MED_WIN]; for (int i 0; i MED_WIN; i) tmp[i] med_buf[i]; /* 窗口只有 5插入排序比快排划算 */ for (int i 1; i MED_WIN; i) { uint16_t k tmp[i]; int j i - 1; while (j 0 tmp[j] k) { tmp[j 1] tmp[j]; j--; } tmp[j 1] k; } return tmp[MED_WIN / 2]; } /* 一阶低通alpha 越小越平滑但响应越慢悬崖场景别低于 0.3 */ float filter_lpf(float in, float alpha) { lpf_out lpf_out alpha * (in - lpf_out); return lpf_out; } /* 连续超阈确认避免单次误判直接触发后退 */ uint8_t cliff_confirm(uint16_t v, uint16_t thr, uint8_t *cnt) { if (v thr) { if (*cnt 10) (*cnt); } else { *cnt 0; } return (*cnt 5); /* 连续 5 个周期100ms才认 */ }逻辑说明filter_median的窗口开 5是因为悬崖传感器在 1ms 中断里采样5 个点对应 5ms既能滤掉电机换向带来的电源尖峰又不会引入肉眼可见的延迟。filter_lpf的alpha取 0.3 是一条血泪经验——调到 0.1 时曲线确实漂亮但机器从瓷砖冲上地毯那一刻滤波输出要 200ms 才反应过来已经冲下去半个轮子了。cliff_confirm的计数器放在决策层而不是采样层采样层只管存值判断逻辑集中在一处后面加左右悬崖分别计数也不用改采样代码。参数调整的顺序建议是先把机器放在最容易被误判的深色地板上读串口打印的原始 ADC 曲线确定正常读数范围再把阈值设在正常范围下沿往下留 20% 余量最后调alpha和确认次数要求是从桌面推到边缘机器停住时轮子还没完全越过边。3.2 差速轮运动学与增量式 PID上层给的是以 200mm/s 前进、同时以 30°/s 左转这样的指令执行层要把它变成左右轮的目标线速度再用 PID 让实际转速跟上。运动学逆解是中学几何但写错符号的人不在少数/* algo/kinematics.c —— 车体速度分解到左右轮 */ void chassis_to_wheels(float v_mm_s, float w_rad_s, float *v_left, float *v_right) { /* 差速模型v_l v - ω·L/2v_r v ω·L/2 */ float half WHEEL_BASE_MM * 0.5f; *v_left v_mm_s - w_rad_s * half; *v_right v_mm_s w_rad_s * half; /* 限幅任一轮超上限就整体等比缩放保住转向半径 */ float max_abs fmaxf(fabsf(*v_left), fabsf(*v_right)); if (max_abs WHEEL_SPEED_MAX) { float s WHEEL_SPEED_MAX / max_abs; *v_left * s; *v_right * s; } }参数说明v_mm_s是车体中心线速度w_rad_s是逆时针为正的角速度half是半轮距。限幅那段很关键很多人直接对左右轮各自限幅结果左轮被砍、右轮没砍实际转向半径比预期小弓字形扫出来是一排斜线。整体等比缩放能保证v_r - v_l的比例不变转向半径就不会跑偏。轮速闭环用增量式 PID比位置式省积分饱和的麻烦/* pid.c —— 增量式 PID输出是这一次比上一次多给多少 PWM */ typedef struct { float kp, ki, kd; float err1, err2; /* 前两次误差 */ float out; float out_max; } pid_inc_t; float pid_inc_calc(pid_inc_t *p, float target, float actual) { float err0 target - actual; float du p-kp * (err0 - p-err1) p-ki * err0 p-kd * (err0 - 2.0f * p-err1 p-err2); p-out du; if (p-out p-out_max) p-out p-out_max; if (p-out -p-out_max) p-out -p-out_max; p-err2 p-err1; p-err1 err0; return p-out; }参数说明kp决定响应速度ki消除稳态误差轮子压到地毯阻力变大时靠它补kd抑制超调。增量式的输出是累加值所以out必须持久化在结构体里不能每次调用清零这是最常见的翻车点——清零之后轮子会一顿一顿地走。out_max一般设成MOTOR_PWM_MAX的 90%留一点余量给堵转补偿。调参顺序我固定是先把ki、kd置零kp从 5 开始往上加加到轮子开始轻微振荡再退回一档然后加ki从 0.1 开始观察机器从静止到 200mm/s 的阶跃响应稳态误差进到 5% 以内就停最后kd一般给kp的十分之一左右用来压住沿边时的过冲。别一上来三参数一起调那是玄学。3.3 里程计积分与航向角漂移的抑制里程计是把编码器增量换算成位姿。这段代码不长但错一个符号整张地图就废了/* odom.c —— 用中点法积分比直接取左轮位姿准得多 */ void odom_update(odom_t *o, int32_t dl_tick, int32_t dr_tick, float dt) { const float mm_per_tick (float)M_PI * WHEEL_DIA_MM / TICK_PER_REV; float dl dl_tick * mm_per_tick; float dr dr_tick * mm_per_tick; float dc (dl dr) * 0.5f; /* 中心位移 */ float dtheta (dr - dl) / WHEEL_BASE_MM; /* 航向增量 */ /* 中点法用本次转角的一半修正位移方向 */ float mid o-theta dtheta * 0.5f; o-x dc * cosf(mid); o-y dc * sinf(mid); o-theta dtheta; if (o-theta M_PI) o-theta - 2.0f * M_PI; if (o-theta -M_PI) o-theta 2.0f * M_PI; o-v dc / dt; /* 给上层用的实时速度 */ }逻辑说明mm_per_tick就是轮子每来一个脉冲走多少毫米TICK_PER_REV来自配置。中点法的意义在于如果直接用积分起点朝向算位移每次转角误差都会被放大用转了一半的朝向来算误差会小一个量级这是数值积分里的老技巧。theta归一化到 [-π, π] 是必须的不然跑久了浮点数会掉精度。里程计跑十分钟后航向角必然漂纯编码器的角度误差主要来自两处轮径不一致和打滑。抑制办法有两种代码里都留了接口。一是陀螺仪融合bsp_imu.c每 10ms 给一个角速度用互补滤波把编码器角度和陀螺仪积分加权theta 0.98 * (theta gyro_z * dt) 0.02 * theta_odom。系数 0.98 是常见做法长期靠陀螺仪、短期靠编码器因为陀螺仪没有累积打滑误差但有零偏。二是标定轮径让机器沿直线跑 3 米测实际距离和里程计读数按比例修正WHEEL_DIA_MM一般能消掉一半以上的航向漂移。注意标定轮径要在满电和半电各做一次。电池电压下降后电机转速特性会变同一套 PID 参数在低压下响应明显变慢这是很多人调完参觉得第二天就不准了的真实原因。4. 清扫策略与状态机弓字形、沿边和回充怎么切执行层搞定之后上层要回答的问题是先扫哪、什么时候换模式、没电了怎么回去。这部分代码是整个项目里最像业务的地方也是最容易被改坏的地方——加一个模式不难难的是加完之后所有模式之间还能正常切换。这份代码用一张显式状态机表把切换关系固定下来谁跳到谁、跳之前要做什么全表都能看见。4.1 主状态机与模式切换的判定状态机定义了六个状态待机、螺旋、弓字、沿边、回充、故障。每个状态有进入动作、周期动作、退出条件用函数指针表组织加状态只需要在表里多写一行不用去改一大坨 switch。/* fsm_robot.c —— 状态表驱动切换条件集中在一处 */ typedef enum { ST_IDLE 0, ST_SPIRAL, ST_ZIGZAG, ST_EDGE, ST_DOCK, ST_FAULT, ST_MAX } state_t; typedef struct { void (*on_enter)(void); state_t (*on_tick)(uint32_t now_ms); /* 返回下一个状态返回自身表示保持 */ void (*on_exit)(void); } state_entry_t; static state_t zigzag_tick(uint32_t now) { /* 电量低于 20% 优先回充优先级最高 */ if (battery_percent() 20) return ST_DOCK; /* 卡住检测目标速度不为零但连续 500ms 实际速度接近零 */ if (is_stuck(now, 500)) return ST_EDGE; /* 先去沿边走一圈脱困 */ if (path_is_finished()) return ST_EDGE; /* 区域覆盖完转沿边补漏 */ path_step(now); /* 推进弓字形路径 */ return ST_ZIGZAG; } static const state_entry_t g_fsm[ST_MAX] { [ST_IDLE] { idle_enter, idle_tick, idle_exit }, [ST_SPIRAL] { spiral_enter, spiral_tick, spiral_exit }, [ST_ZIGZAG] { zigzag_enter, zigzag_tick, zigzag_exit }, [ST_EDGE] { edge_enter, edge_tick, edge_exit }, [ST_DOCK] { dock_enter, dock_tick, dock_exit }, [ST_FAULT] { fault_enter, fault_tick, fault_exit }, }; void fsm_run(uint32_t now_ms) { static state_t cur ST_IDLE; state_t next g_fsm[cur].on_tick(now_ms); if (next ! cur) { g_fsm[cur].on_exit(); cur next; g_fsm[cur].on_enter(); } }逻辑说明on_tick返回下一个状态而不是直接修改全局变量好处是切换路径可测——给每个on_tick写单测喂不同的传感器状态断言返回值不用把机器搬出来。zigzag_tick里的判断顺序就是优先级电量最高其次脱困最后才是正常推进。这个顺序如果写反会出现机器快没电了还在坚持扫完最后一行的尴尬场面。is_stuck的判定不是看电流而是看给了目标速度但实际速度上不去因为堵转电流阈值受电池电压影响大速度判据更稳。4.2 弓字形覆盖与沿边补偿弓字形是扫地机最经典的覆盖方式沿一个方向走直线碰到边界平移一个车宽反向再走。难点不在直线在于怎么知道到边界了和该平移多少。/* path_zigzag.c —— 简化版弓字直线段 边界平移 */ #define LANE_WIDTH_MM 280.0f /* 平移宽度略小于机身宽度留重叠 */ static void zigzag_step(uint32_t now) { switch (g_path.phase) { case PHASE_STRAIGHT: chassis_set_speed(g_path.dir * CLEAN_SPEED, 0.0f); /* 边界判据碰撞、悬崖、或者里程计走满一行 */ if (bump_hit() || cliff_hit() || g_path.traveled g_path.lane_len) { chassis_stop(); g_path.phase PHASE_SHIFT; g_path.shift_start odom_distance(); } break; case PHASE_SHIFT: /* 垂直平移用航向闭环走固定距离不靠时间估算 */ chassis_turn_to(g_path.base_heading M_PI_2); if (odom_distance() - g_path.shift_start LANE_WIDTH_MM) { chassis_stop(); g_path.dir -g_path.dir; /* 反向 */ g_path.phase PHASE_STRAIGHT; g_path.traveled 0; } break; } }参数说明LANE_WIDTH_MM取 280 是基于机身宽度 300mm 左右留 20mm 重叠重叠太少会漏扫出条纹太多则重复清扫浪费电。CLEAN_SPEED一般 180~220mm/s太快惯性大边界停不住太慢整个屋子扫不完。g_path.dir在每次平移后取反这是弓字形成型的关键一行把它写成固定值机器就会一直朝一个方向撞墙。chassis_turn_to用的是航向闭环而不是转固定时间。时间开环在电池电压下降后会明显偏小转 90° 实际只转了 80°几行下来整片区域就歪了。闭环的做法是读里程计航向算出角误差用比例控制给差速误差小于 2° 就停。沿边模式是弓字形的补丁。弓字形因为要留重叠墙根总会剩一条扫不到沿边就是贴着障碍走一圈。实现上不复杂一侧红外持续有信号就直行信号消失就朝那个方向小角度转直到重新贴上。坑在于家具腿这种小障碍红外会忽有忽无机器就开始原地抽搐。代码里加了个滞回信号消失后要连续 3 个周期都消失才转向重新贴上的判定放宽到 1 个周期用这种不对称来压抖动。4.3 回充、断点续扫和电量策略回充是整个项目里最容易做砸的功能因为它同时依赖红外、里程计和状态机三样东西任何一样不准都回不去。常见做法是红外引导加里程计粗定位充电座发两组红外信号机器在远处靠信号强弱判断大致方向靠近到一定距离后靠左右两个接收头的差值对准。/* dock.c —— 红外引导回充分远近两段策略 */ typedef enum { DOCK_FAR, DOCK_NEAR, DOCK_ALIGN, DOCK_DONE } dock_phase_t; state_t dock_tick(uint32_t now) { switch (g_dock.phase) { case DOCK_FAR: /* 远处旋转扫描找到最强信号方向后直行 */ if (ir_dock_strength() DOCK_SIGNAL_MIN) { g_dock.phase DOCK_NEAR; } else { chassis_spin_search(60.0f); /* 60°/s 慢速扫描 */ } break; case DOCK_NEAR: /* 近处左右信号差值做比例控制始终保持居中 */ { int16_t d ir_dock_left() - ir_dock_right(); chassis_set_speed(DOCK_SPEED, d * DOCK_KP); if (ir_dock_left() DOCK_NEAR_TH ir_dock_right() DOCK_NEAR_TH) { g_dock.phase DOCK_ALIGN; } } break; case DOCK_ALIGN: /* 最后一段低速直行靠充电极片接触信号收尾 */ chassis_set_speed(DOCK_SLOW, 0.0f); if (charge_contact()) { chassis_stop(); g_dock.phase DOCK_DONE; return ST_IDLE; } break; } return ST_DOCK; }参数说明DOCK_SIGNAL_MIN是能看见充电座的最低信号强度设太低会在隔壁房间就被误导设太高则机器要贴着座才能发现。DOCK_KP是左右差值的比例系数一般给 0.5 左右给大了机器会对准过头左右摆。DOCK_SPEED和DOCK_SLOW分两档远端快、近端慢最后 10cm 一定要慢撞上充电座虽然不至于坏但会把对准角度撞偏接触不上就得重来。断点续扫的逻辑很轻进入回充前把当前lane_index、base_heading、dir存进一块不被初始化的 RAM 或者外部存储充满电后读回来从断点那一行继续。这里有个容易忽略的细节——存的时候要连栅格地图一起存不然续扫时机器不记得哪些地方扫过会重复扫一大片。电量策略上代码设了两级阈值20% 主动回充10% 强制回充并禁用沿边沿边耗电高。20% 这个值不是随便定的要保证机器从屋子最远角落回到充电座还有余量具体数值按你家户型和回充路径长度调。提示调试回充时先把DOCK_SPEED砍到一半用串口打印左右红外强度和航向角确认整条曲线平滑再提速。带速度调回充撞座是家常便饭。5. 避坑与排查实车跑偏、悬崖误触发和看门狗复位代码在仿真里跑得再顺上实车照样翻车。这一章记的都是我自己或者身边人真遇到过的现象按现象 → 原因 → 解决写能省掉你几个晚上。5.1 运动控制与传感器上的坑现象一沿直线走三米横向偏出十几厘米越走越偏。原因不是 PID 没调好是轮径标定错了。出厂标称轮径和实际磨损后的直径能差 2mm左右轮再差一点里程计积分出来就是一条弧线。解决让机器沿地板缝走三米量实际距离和odom_distance()的比值按比例修WHEEL_DIA_MM左右轮分别标定各改各的可以在配置里拆成左右两个宏。标定完之后同样跑三米偏差能压到 2cm 以内。现象二深色地板上频繁触发悬崖机器一直后退扫不完一块区域。原因是悬崖传感器靠红外反射深色地板吸光接收端读数偏低落到阈值以下就被判成悬空。解决不要用固定阈值改成自适应——开机前 3 秒让机器原地不动采样当前地面读数作为基准阈值设成基准的 60%。这个做法在纯色地毯上也有用代价是开机时要多等三秒。另外中值滤波窗口别超过 7窗口太大在真实悬崖边缘会延迟触发。现象三机器扫到地毯边缘开始原地画圈左右轮转速明显不一致。原因是地毯阻力大一侧轮子负载重增量式 PID 的积分项累积到饱和退出地毯后积分还没退完轮子继续猛转。解决加抗饱和输出限幅的同时冻结积分——if (out out_max err 0) 不累加 ki 项。另外把is_stuck的判定加进去速度长时间上不去先停机 200ms 再重试比一直硬顶强。现象四沿边模式在家具腿旁边来回抽搐。原因前面提过红外信号忽有忽无。除了不对称滞回还可以加最小直行距离约束——每次转向后至少直行 150ms 才允许再次判断边界用时间换稳定。这个参数在代码里叫EDGE_MIN_FORWARD_MS调大到 300ms 抽搐基本消失代价是绕过细腿时会多走一小段。5.2 系统稳定性与工程配置上的坑现象五机器跑十几分钟突然停住串口无输出重新上电又正常。八成是看门狗复位。常见原因有两个一是在中断里喂狗主循环卡死了狗照样被喂但这里是反过来——主循环里某个while等硬件标志位死等超时后狗复位。解决所有硬件等待循环加超时计数超过 50ms 就返回错误码喂狗位置保持在主循环末尾把复位原因寄存器在启动时读出来打到串口if (RCC-CSR IWDG_FLAG)这类判断能直接告诉你是狗咬的还是掉电。现象六仿真跑得好好的实机编译出来行为完全不对。典型的浮点精度和优化等级问题。algo/里用了float做积分PC 上是 64 位双精度中间结果MCU 上是单精度跑久了误差不一样-O2优化还可能把某些volatile变量优化掉。解决仿真构建也强制-fsingle-precision-constant跟实机对齐所有被中断修改的变量必须加volatilerobot_cfg.h里的浮点常量全部带f后缀别让编译器偷偷提升成 double。现象七改了配置里的轮距编译通过但机器转向角度全错。原因通常是robot_cfg.h被两个地方 include其中一处是旧版本的副本改了一个另一个没改链接时以先编译的为准。解决配置头文件只允许在main.c和bsp/里 includeapp/层通过函数取值而不是直接读宏构建脚本里加一条检查搜索工程里是否存在重复的robot_cfg.h文件名。现象八OTA 升级后机器变砖只能拆机。升级流程里没有校验。常见做法是双分区加 CRC新固件写进备份区校验 CRC16 通过才切换启动标志校验失败保留旧固件。代码里lib/crc16.c已经有实现关键是升级流程里真的去调它别嫌麻烦跳过。注意排障时优先看串口日志的时间戳很多随机故障其实是固定周期出现的。时间戳一打是 10ms 任务超时还是 100ms 任务超时一眼就能看出来。6. 二次开发加一个自定义清扫模式并验证它改这份代码最典型的需求是加清扫模式比如只扫一个房间或者重点清扫某块区域。加模式本身十分钟能写完麻烦的是别把已有状态机搞乱。我的做法是严格走三步先在state_t里加枚举再写三个回调最后在状态表里注册任何一步都不去动已有状态的on_tick。/* 新增 SPOT 模式以起点为中心螺旋外扩覆盖半径可配 */ static state_t spot_tick(uint32_t now) { /* 复用已有的电量与脱困判定别重写一遍 */ if (battery_percent() 20) return ST_DOCK; if (is_stuck(now, 500)) return ST_ZIGZAG; float r odom_distance_from(g_spot.origin); if (r g_spot.radius_mm) return ST_EDGE; /* 覆盖完转沿边收尾 */ /* 螺旋线速度恒定角速度随半径增大而减小保持螺距均匀 */ float w SPOT_V / (r SPOT_R0); chassis_set_speed(SPOT_V, w); return ST_SPOT; } /* 注册进状态表只加一行 */ [ST_SPOT] { spot_enter, spot_tick, spot_exit },SPOT_R0是防止半径为零时角速度除爆的偏置取值 50mm 左右SPOT_V一般给 150mm/s比正常清扫慢螺旋才均匀radius_mm从配置读默认 1500。注意spot_enter里要把起点存进g_spot.origin并且清空栅格地图的访问标记否则上一轮的地图会影响脱困判断。验证我一般分三层从不跳步层级输入检查点通过标准单元测试人工构造脉冲序列spot_tick返回值半径超限时切到ST_EDGE日志回放旧实车日志run_01.csv轨迹 CSV螺旋螺距均匀无自交实车空跑空房间不放障碍串口航向与半径3 圈内不碰墙收尾进沿边日志回放这层最容易被跳过但它性价比最高。拿一段之前录的实车数据喂给仿真几分钟就能看出新模式的路径形状对不对不用等电池充满。等仿真里的螺旋画得圆了再上实车通常一次就能过。有个细节值得单独说新模式如果复用了chassis_set_speed一定要确认它内部对w_rad_s的符号约定和你的预期一致。我被这个坑过一次——螺旋写成顺时针外扩结果机器往里缩越转越小最后原地打转查了半天以为是半径计算错了实际是角速度符号反了。从那以后我每次加新模式都强制先跑一遍仿真回放再上实车哪怕改动只有三行。这套流程看着笨但比拆机省时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站