简介:一套基于RT-Thread实时操作系统与STM32的激光雷达避障小车完整项目,来自高分通过的毕业设计/课程设计,面向计算机、电子、自动化等专业正在做毕设或需要项目实战练习的学生,也适用于教师、科研人员与公司开发者借鉴参考。项目围绕“感知—决策—控制”主线,包含激光雷达数据读取、障碍检测与避让策略、多任务调度等嵌入式核心环节,配套文档说明清楚,便于从整体架构到模块代码逐步拆解,也可直接用于课设作业或二次开发。压缩包共2000个文件,约77.98MB,以C语言头文件(.h)与源文件(.c)为主,配合HTML说明页面、Python辅助脚本、Markdown笔记、PDF参考文档及少量C++/Shell脚本,既有完整工程代码,也有阅读指引和常用工具。目前已有187人学习下载,适合希望快速上手RT-Thread并完成机器人避障项目的学习者。
1. 激光雷达避障小车选RT-Thread,不只是因为它开源
裸机靠定时器中断加全局标志位也能让一辆小车动起来,可一旦把激光雷达点云解析、动态避障策略和双路电机PID闭环放进同一个系统,中断嵌套和标志位很快会把时序拖垮。激光雷达的帧间隔是毫秒级,避障决策必须在这个间隔内完成,否则障碍物已经冲进急停区,指令还在排队。RT-Thread的价值就在这:抢占式实时内核加标准设备驱动框架,让雷达数据、避障决策和电机控制各守其位。下面这套方案按可落地的做法展开,适合有STM32或类似MCU开发经验、想在避障小车上建立实时系统思维的人。无论你是要读透一份现成的源码包,还是从零拼一辆,核心链路都是这四件事:读雷达、做决策、控电机、调参数。
2. RT-Thread下激光雷达驱动的串口接入与数据解析
2.1 先确认激光雷达的测距原理与数据协议
拿到一个雷达模块,先不急着接线。常见的小车激光雷达分两大类:三角测距和TOF。三角测距模块(很多低成本单线雷达都采用这种方案)内部有CMOS成像器件,通过激光斑点在成像面上的偏移计算距离,近距精度不错,但光照强烈的环境容易受影响。TOF模块通过飞行时间测距,抗环境光能力强,但成本更高。避障小车通常工作在室内或校园道路,三角测距足够用。
接线上,这类雷达的对外接口基本是串口或USB转串口,串口波特率常见115200或256000。雷达上电后会自动旋转并持续向外发送数据帧,不需要主动下发指令,这点在驱动设计上省了很多事。数据帧格式各厂家略有差异,但核心内容一致:帧头、转速、起始角度、采样点数量、各采样点的角度和距离值、校验位。写驱动前先拿串口调试工具抓一段字节流,对着厂家数据手册把帧结构解析清楚,这一步比写代码更花时间。顺带说一句,雷达安装角度和车头方向的偏差属于标定问题,最好在结构设计时让雷达零角正对车头,后续代码能少一个角度偏移量。
2.2 用RT-Thread serial设备框架接收雷达数据流
RT-Thread的设备框架把底层串口驱动封装成统一接口,应用层用rt_device_find、rt_device_open就能拿到设备句柄,不需要直接操作寄存器。雷达这种持续高速数据流,建议打开中断接收模式,数据到达后由驱动层放入RT-Thread为设备维护的接收缓冲区,应用层通过接收回调感知数据到来。一般做法是给串口设置一个接收回调,在回调里用信号量通知解析线程取数据。
#include <rtthread.h> #include <rtdevice.h> #define RADAR_UART "uart3" #define RADAR_FRAME_SIZE 64 /* 按你的雷达帧长调整 */ static rt_device_t radar_dev = RT_NULL; static rt_sem_t frame_sem = RT_NULL; static rt_err_t radar_uart_rx_ind(rt_device_t dev, rt_size_t size) { /* 收到数据后释放信号量,通知解析线程取数据 */ rt_sem_release(frame_sem); return RT_EOK; } int radar_device_init(void) { rt_err_t ret; struct serial_configure cfg = RT_SERIAL_CONFIG_DEFAULT; radar_dev = rt_device_find(RADAR_UART); if (radar_dev == RT_NULL) return -RT_ERROR; cfg.baud_rate = 115200; /* 与雷达模块波特率一致 */ cfg.data_bits = DATA_BITS_8; cfg.stop_bits = STOP_BITS_1; cfg.parity = PARITY_NONE; cfg.bufsz = 1024; /* 接收缓冲区,至少容纳两帧 */ rt_device_control(radar_dev, RT_DEVICE_CTRL_CONFIG, &cfg); ret = rt_device_open(radar_dev, RT_DEVICE_FLAG_INT_RX); if (ret != RT_EOK) return ret; rt_device_set_rx_indicate(radar_dev, radar_uart_rx_ind); frame_sem = rt_sem_create("rdfrm", 0, RT_IPC_FLAG_PRIO); return RT_EOK; }这段代码做了三件事:配置串口参数、以中断接收方式打开设备、注册接收回调。RT_SERIAL_CONFIG_DEFAULT默认波特率是115200,如果雷达是256000,记得在配置里改掉。bufsz建议设成两帧以上,防止高速数据时接收缓冲区溢出。回调里只释放信号量,把耗时操作留到解析线程去做,这是实时系统的通用写法——中断处理越短越好。
2.3 点云解析与角度-距离数据对齐
解析线程的核心职责是把字节流切开,还原成角度和距离的对应关系。单线雷达一圈返回多组采样点,每个采样点包含当前角度、距离毫米值和信号强度。避障决策不关心全部点,一般只取前向120度范围内的点云,减少计算量。
static void radar_parse_thread_entry(void *param) { rt_uint8_t buf[RADAR_FRAME_SIZE]; rt_int32_t nbytes; while (1) { rt_sem_take(frame_sem, RT_WAITING_FOREVER); nbytes = rt_device_read(radar_dev, 0, buf, sizeof(buf)); /* 这里做帧头匹配和角度距离提取 */ for (int i = 0; i < nbytes; i++) { if (buf[i] == FRAME_HEADER) { uint16_t angle = (buf[i+2] | (buf[i+3] << 8)) / 64; uint16_t dist = buf[i+4] | (buf[i+5] << 8); if (dist < 8000 && angle < 180) { radar_points[angle] = dist; } } } } }角度值在不同雷达里编码方式不同,有的直接是两位角度,有的是角度步进值需要换算。上面代码里除以64是某类雷达的实际编码,你拿到手后要按自己的手册换算。解析时顺手做一个距离上限过滤,超过8米的点直接丢弃,因为避障只关心近场障碍物。解析完成后,角度和距离就成了一一对应的数组,radar_points放在互斥量保护的区域,或者直接封装成快照用消息队列交给决策线程。这里不建议多个线程同时访问同一个数组,不加锁的点云数据在决策线程读到一半被其他线程修改,会产生“幽灵障碍物”。
3. 动态避障小车的决策线程设计与阈值策略
3.1 线程划分:雷达解析、避障决策、电机控制各自拆分
整个小车的软件系统可以拆成三个常驻线程:雷达解析线程、避障决策线程、电机控制线程。加上一个FinSH交互线程用于调试信息输出,一共四个。这样的划分逻辑是让每个线程只做一件有时限要求的事,互不阻塞。
优先级从高到低这样排:避障决策 > 电机控制 > 雷达解析 > FinSH。为什么决策最高?因为避障决策是安全相关的任务,越晚执行,留给电机动作的时间越短。雷达解析虽然数据量大,但少解析一帧、等到下一帧来也影响不大,所以优先级放低。电机控制线程负责执行决策指令,主要做PWM输出和编码器读取,这个任务的实时性要求同样高,但它在决策之后才有意义,所以比决策低一级。
任务看起来简单,实际操作要处理的问题是多线程下的数据流向。雷达解析线程产出一个点云缓冲,决策线程需要消费它。这两个线程之间用消息队列是最稳妥的:解析线程每次发送一份快照结构体或指针,决策线程阻塞接收,没有数据时自动休眠,不浪费CPU。
3.2 动态避障的窗口划分与阈值换避让策略
小车避障不一定要上A*或者DWA这类动态避障小车路径规划算法。以STM32为主控、雷达点云仅几百个点、MCU主频有限为背景,最可靠的是“窗口阈值换避让”策略——把雷达探测范围按角度分成几个窗口,对每个窗口内的距离值取最小值,再根据这些最小值决定小车怎么动。
#define FRONT_WIN_START (-60) /* 前向窗口起始角度 */ #define FRONT_WIN_END (60) /* 前向窗口结束角度 */ #define LEFT_WIN_START (30) #define LEFT_WIN_END (90) #define RIGHT_WIN_START (-90) #define RIGHT_WIN_END (-30) #define SAFE_DIST 300 /* 安全距离,单位mm */ #define DANGER_DIST 150 /* 急停距离 */ typedef enum { GO_STRAIGHT, TURN_LEFT, TURN_RIGHT, STOP_BACK, } motion_cmd_t; motion_cmd_t obstacle_avoid_decision(uint16_t *points) { uint16_t front_min = get_window_min_distance(points, FRONT_WIN_START, FRONT_WIN_END); uint16_t left_min = get_window_min_distance(points, LEFT_WIN_START, LEFT_WIN_END); uint16_t right_min = get_window_min_distance(points, RIGHT_WIN_START, RIGHT_WIN_END); if (front_min < DANGER_DIST) return STOP_BACK; if (front_min < SAFE_DIST) { if (left_min > right_min) return TURN_LEFT; else return TURN_RIGHT; } return GO_STRAIGHT; }窗口角度以车头朝向为零度,正方向为逆时针,实际用的时候要以雷达安装角度为准做一个偏移校准。get_window_min_distance是遍历窗口内角度取最小距离的辅助函数,实现时注意处理角度跨零(如-10度到10度)的边界条件。窗口宽度的选取直接决定避障灵敏度:窗口越窄,越容易发现夹缝中的障碍物,但也越容易被单点噪声误导;窗口越宽,越鲁棒,但可能漏掉窄障碍。
| 参数 | 建议区间 | 调大 | 调小 |
|---|---|---|---|
| SAFE_DIST | 200~500 mm | 提前减速,但频繁转向 | 贴近障碍才反应 |
| DANGER_DIST | 100~200 mm | 急停更早,安全性提升 | 容易擦碰,制动距离不足 |
| 窗口宽度 | 60°~90° | 抗噪更强,漏检窄障碍 | 识别灵敏,误报增加 |
| 队列深度 | 4~8 帧 | 抗突发,但决策滞后 | 丢帧概率上升 |
上面这个决策逻辑只用了三段判断,是动态避障中最简的做法:前方近距离障碍就停车后撤,前方中等距离障碍就按左右窗口比较选空旷一侧转向。这个策略没有历史状态,不会记忆上次避障方向,所以遇到死胡同时会左右摇摆。需要加一个“上次转向方向”的状态位来打破对称,在决策函数入口读取,优先转向与上次相反的方向。
3.3 决策落地:消息队列与互斥量的配合
决策线程不能直接用点云数组,因为解析线程随时在写。常见的做法是在解析线程里维护一个环形缓冲,每帧数据解析完成后,把“当前帧的最小距离集合”拷贝到一个轻量结构体,用消息队列发给决策线程。决策线程拿到的是一份快照,不会读到半帧数据。RT-Thread的消息队列本身做值拷贝,对这种几个uint16_t组成的小结构体开销很小,比传指针更安全。
typedef struct { uint16_t front_min; uint16_t left_min; uint16_t right_min; uint32_t timestamp_ms; } radar_frame_snapshot_t; /* 解析线程中发送 */ radar_frame_snapshot_t snap; snap.front_min = front; snap.left_min = left; snap.right_min = right; snap.timestamp_ms = rt_tick_get(); rt_mq_send(radar_mq, &snap, sizeof(snap)); /* 决策线程中接收 */ rt_mq_recv(radar_mq, &snap, sizeof(snap), RT_WAITING_FOREVER); motion = obstacle_avoid_decision(snap); motor_set_motion(motion);决策完成后直接调用电机接口,不需要加互斥量,因为电机控制线程本身就是“PWM设置”的执行者,它读到的状态是决策线程单次写入的最新值,底层用原子变量或关中断保护一下寄存器写入即可。这个方案的边界条件很清楚:当雷达帧率下降或点云大量丢帧时,消息队列长时间为空,决策线程阻塞在rt_mq_recv上,小车会保持上一次的电机指令继续行驶——这在实际测试中非常危险。所以要加一个看门狗逻辑:决策线程每次收到数据刷新一个时间戳,主循环检查时间戳如果超过300ms没更新,就把电机指令置为停止并报警。
4. 电机PWM控制与避障小车的转速闭环调整
4.1 PWM输出与差速转向的底层实现
激光雷达只负责“看”,真正执行动作的是电机驱动板。避障小车一般用两个直流减速电机,配合TB6612或DRV8833这类驱动芯片。RT-Thread下用定时器的PWM通道控制转速,用两个普通GPIO管脚控制方向,就能完成基本动作。
#define PWM_LEFT_DEV "pwm3" #define PWM_RIGHT_DEV "pwm4" #define PWM_PERIOD 20000 /* 20ms周期,50Hz */ static struct rt_device_pwm *pwm_left, *pwm_right; void motor_init(void) { pwm_left = (struct rt_device_pwm *)rt_device_find(PWM_LEFT_DEV); pwm_right = (struct rt_device_pwm *)rt_device_find(PWM_RIGHT_DEV); rt_pwm_set(pwm_left, 1, PWM_PERIOD, 0); rt_pwm_set(pwm_right, 1, PWM_PERIOD, 0); rt_pwm_enable(pwm_left, 1); rt_pwm_enable(pwm_right, 1); } void motor_set_speed(int left, int right) { /* left 为正前进、为负后退 */ if (left >= 0) { rt_pin_write(PIN_LEFT_DIR1, PIN_HIGH); rt_pin_write(PIN_LEFT_DIR2, PIN_LOW); } else { rt_pin_write(PIN_LEFT_DIR1, PIN_LOW); rt_pin_write(PIN_LEFT_DIR2, PIN_HIGH); left = -left; } rt_pwm_set(pwm_left, 1, PWM_PERIOD, left); /* right 方向控制与 left 同理,此处省略 */ }PWM周期不需要设置得太高,直流减速电机一般50Hz到200Hz之间就能平稳转动,太高了驱动芯片发热严重,太低了电机会有啸叫。这里给的20ms周期对应50Hz,是通用值。占空比数值范围取决于定时器位宽,如果是16位定时器,周期给20000,占空比就填0到20000之间的值。方向引脚的电平定义没有标准,以自己驱动板手册为准。
4.2 编码器反馈与增量式PID闭环
开环PWM控制在小车平地上够用,但避障时地面摩擦力不一致,两个电机的实际转速会明显不同,导致小车直线走偏。更严重的是,障碍物近在眼前时PID如果响应慢几毫秒,就多前进了好几厘米。激光雷达避障小车必须要转速闭环:用编码器测实际转速,把目标转速和实测转速的偏差送给PID控制器,输出修正后的PWM占空比。
增量式PID因为只算变化量,不需要累加误差的绝对值,不容易积分饱和,适合这种MCU上的电机控制场景。下面是工程上常用的一种简化增量式写法,I项直接取当前偏差乘以Ki,等效于把积分效果揉进增量输出里,对小型直流电机足够用。
typedef struct { float kp, ki, kd; float prev_err; float prev_prev_err; } pid_ctrl_t; float pid_update(pid_ctrl_t *pid, float target, float actual) { float err = target - actual; float p_term = pid->kp * (err - pid->prev_err); float i_term = pid->ki * err; float d_term = pid->kd * (err - 2 * pid->prev_err + pid->prev_prev_err); float delta = p_term + i_term + d_term; pid->prev_prev_err = pid->prev_err; pid->prev_err = err; return delta; }实际调参时,先只给P给到0.3到0.5,观察转速是否出现振荡:如果电机转速忽快忽慢、声音呈周期性嗡鸣,说明P偏大,减半再试。P调稳后,再加上I,I的作用是消除稳态误差,但过大会导致低速时抖动。典型起始参数范围参考下表,不同车体传动比差距很大,必须实测调整。PID执行频率和编码器采样频率要匹配,建议放在定时器中断或者一个1kHz的高优先级线程里。
| 参数 | 起始参考值 | 偏小现象 | 偏大现象 |
|---|---|---|---|
| Kp | 0.4 | 启动慢,跟不上目标转速 | 转速振荡,电机嗡鸣 |
| Ki | 0.02 | 左右轮存在恒定转速差 | 低速频繁调整,抖动 |
| Kd | 0.05 | 超调明显,收敛慢 | 高频抖动,噪声放大 |
4.3 避障机动中的速度斜坡与状态机
避障决策给出来的指令是“左转”“右转”“急停”这类离散动作,真正执行时不能直接跳到目标速度,否则电机电流瞬间拉高,小车会打滑,轮子空转后编码器反馈失真,PID也会跟着紊乱。所以电机控制线程要做一个斜坡加载:每10ms把当前速度往目标速度方向调整一格,斜坡步长约等于最大速度的2%到5%。
static motion_state_t motion_state = STRAIGHT; float ramp_towards(float current, float target, float max_step) { if (target > current) return (target - current) > max_step ? current + max_step : target; else return (current - target) > max_step ? current - max_step : target; } void motor_control_thread_entry(void *param) { while (1) { switch (motion_state) { case STRAIGHT: left_speed = ramp_towards(left_speed, VELOCITY_MAX, RAMP_STEP); right_speed = ramp_towards(right_speed, VELOCITY_MAX, RAMP_STEP); break; case TURN_LEFT: left_speed = ramp_towards(left_speed, VELOCITY_MAX * 0.8, RAMP_STEP); right_speed = ramp_towards(right_speed, VELOCITY_MAX * 0.2, RAMP_STEP); break; case STOP_BACK: left_speed = ramp_towards(left_speed, -VELOCITY_BACK, RAMP_STEP); right_speed = ramp_towards(right_speed, -VELOCITY_BACK, RAMP_STEP); break; } motor_set_speed((int)left_speed, (int)right_speed); rt_thread_mdelay(10); } }状态量和目标速度可以定义成全局变量,由决策线程写入,电机控制线程每个周期读取并限幅。这个10ms的执行周期意味着斜坡到达最快速约需要0.5秒,正好给雷达留出下一帧数据的时间,让避障决策能在速度变化过程中重新评估环境。急停场景要特殊处理:判断到危险距离小于DANGER_DIST时,不走斜坡直接给占空比归零,同时拉高反向制动一段时间,这是安全优先的取舍。
5. 避障小车实测:三组关键参数验证与FinSH调参技巧
5.1 第一组参数:安全距离与急停距离的边界验证
实测中先验证雷达距离阈值的合理性。在无遮挡的走道上放置一个纸箱,分别从100cm、50cm、30cm、20cm开始向纸箱推进,观察小车在哪个距离真正停下来。如果小车在DANGER_DIST之外就触发急停,说明雷达测距偏小;如果在DANGER_DIST内还没停,说明决策线程空转或电机响应太慢——这时要检查消息队列是否堆积,而不是直接调大阈值。
5.2 第二组参数:PID的Kp与Ki配合
在平地让小车直线空跑,用编码器数据画出实际转速曲线。Kp偏小的特征是启动慢、到达目标转速后围绕目标值上下飘;Kp偏大的特征是转速过冲后振荡,声音周期抖动。先固定Ki为0,单独调Kp找到临界值,再往回退30%作为工作点,最后加入Ki消除静差。调好的标准不是曲线完全平直,而是回退和转向过程中的超调不超过10%。
5.3 第三组参数:斜坡步长与转向窗口
斜坡步长决定了小车在避障机动中的稳定性。步长太大,前后轮会明显“点头”;步长太小,转向响应迟钝。可以把RAMP_STEP设为最大速度的5%到10%之间逐步试。转向窗口和斜坡步长是一对联动参数:窗口越窄、探测到障碍越晚,就需要更快的斜坡响应。用FinSH命令可以显著压缩调参周期,RT-Thread的FinSH允许在运行中动态修改全局变量:
#include <finsh.h> static int set_param(int argc, char **argv) { if (argc == 3) { safe_dist = atoi(argv[1]); danger_dist = atoi(argv[2]); rt_kprintf("safe=%d, danger=%d\n", safe_dist, danger_dist); return 0; } return -1; } MSH_CMD_EXPORT(set_param, set safe and danger distance);编译烧录后,在FinSH控制台输入set_param 300 150就能在线调整阈值,不需要反复烧固件。调参过程建议每次只改一个变量,记录一组小车行为描述后恢复默认值再改下一个,避免参数耦合把问题掩盖。
5.4 无雷达时的决策逻辑自测
最后,在没有雷达模块的情况下,可以用一个测试线程伪造一份雷达快照塞进消息队列,验证决策和电机状态机是否按照预期动作。手动构造三种典型输入:直道无遮挡、正前方有障碍、左右均无通行空间。观察小车分别执行直行、转向、后撤-停车的状态切换,如果状态切换正确,整个软件链路基本就通了。做这一步的意义是隔离问题域:雷达解析和决策验证分开,先把软件逻辑跑通,再回到雷达硬件上,省掉一半的排错时间。
本文还有配套的精品资源,点击获取