简介:基于STM32的无人船工程代码包,完整涵盖PID闭环控制、SI4432无线数据传输、GY-273电子罗盘姿态解算与北斗定位模块接入等核心功能,面向嵌入式、物联网及无人船相关方向的开发者和毕设学生,可作为自主航行、遥控通信和定位导航类项目的整机参考方案。压缩包共322个文件、约4.33MB,主要包含70个C源代码和76个H头文件,辅以Keil工程配置(uvprojx、uvoptx、dep等)、编译中间产物(o、axf、hex等)及A51启动文件,目录结构清晰,便于直接打开工程阅读源码、跟踪编译与调试链路。已有554人学习下载,适合具备一定STM32基础的开发者快速切入无人船综合控制场景;包内代码按底层驱动、无线通信、姿态解算、定位算法等模块分区组织,对理解PID参数整定流程、无线链路搭建及多传感器融合具有实际参考价值。
1. 无人船项目到底在解什么题:STM32 平台上的 PID 与定位,不是简单“遥控船”
一条无人船,最核心的坎不是“能跑”,而是“按规划跑”。花几百块买了遥控船、拆掉外壳、塞进 STM32 开发板,这只是开始。真正让从业者留过夜的,是让船顶着水流和风浪,在 GPS 刷新只有 10Hz 的情况下,仍然沿着预定航迹走直线、转直角弯。这个 zip 标题里的PID、STM32、定位,与其说是一个代码包,不如说是一道控制题:用廉价传感器做闭环,用有限算力扛住水面扰动。这篇文章不聊遥控,只讲怎么让船自己找路,适合那些已经会点 STM32 外设、想从“能遥控”跨到“能自主”的人。下面按我的项目习惯,把硬件、控制、定位和测试一条线写透。
2. 无人船的硬件与软件骨架:从电机驱动到传感器选型
2.1 最小硬件系统:STM32、电机、电调、GPS/IMU 怎么配
做无人船的常见做法是拿现成电动船体改,电机驱动部分要么保留原厂有刷电机,要么换无刷加电调。STM32 负责算,电机驱动负责出力,传感器负责感知。这里有个容易翻车的点:很多人一开始就上 STM32F407,其实 F103 的 72MHz 跑 PID 加 GPS 解析完全够,F407 的优势在于浮点运算和更多串口,如果计划后期接摄像头或者画像声呐,才值得多花几十块。
我一般建议的最小清单:
- 主控:STM32F103C8T6 或 F407VET6,前者便宜,后者算力余量大。
- 电机:两个 12V 有刷电机(双桨差速转向),或者一个无刷加舵机。差速方式和车模一样,代码上容易做。
- 电调/驱动:有刷用 L298N 或大电流 MOS 驱动板,无刷用普通电调(注意有无刷电调要先校准油门行程)。
- 定位模块:ublox NEO-6M 或 NEO-M8N,后者能同时收 GPS+北斗,漂移明显小。
- 姿态模块:MPU6050(三轴陀螺+加速度)加 HMC5883L 磁力计,注意磁力计要尽量远离电机金属轴。
- 电源:船用 3S 锂电(11.1V),ST 板子用 5V BEC 降压,GPS 和传感器需要单独滤波。
先给一个简单的 GPIO 和 PWM 初始化片段,对应差速驱动左右电机:
// main.c 关键初始化:PWM 驱动电机,串口接 GPS,I2C 接 IMU void Motor_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef timbase; TIM_OCInitTypeDef oc; // 开启 TIM2 和 PA0-PA3 时钟(这里用 PA0/PA1 作左电机 P+方向,PA2/PA3 作右电机) RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); // F407 是 AHB1,F103 是 APB2 GPIO_PinAFConfig(GPIOA, GPIO_PinSource0, GPIO_AF_TIM2); GPIO_PinAFConfig(GPIOA, GPIO_PinSource1, GPIO_AF_TIM2); // ... 复用引脚配置略 TIM_TimeBaseStructure->Period = 8400 - 1; // 84MHz / 8400 = 10kHz PWM TIM_OC1Init(TIM2, &oc); // 左电机 PWM 输出 TIM_OC2Init(TIM2, &oc); // 右电机 PWM 输出 }这段代码的逻辑说明:Period决定 PWM 频率,10kHz 对直流有刷电机是合理区间,太低会听到电机啸叫,太高反而增加 MOS 开关损耗。电机方向控制一般不用 PWM 负占空比,而是额外两个 GPIO 接 H 桥方向引脚,所以Motor_SetSpeed(speed_left, direction_left)里要同时改 GPIO 和 CCR 寄存器。参数上,8400是 84MHz 主频下的重装值,如果用 F103,需要改成7200 - 1。
有人会问:为什么不把电机驱动直接并到主控板上?水面场景下,电机启动瞬间的电流尖峰会拉低 MCU 电压,轻则复位,重则 GPS 串口乱码。所以驱动部分要有独立退耦电容,传感器供电用 LC 滤波。这是硬件上最值得先做的事。
2.2 代码工程的目录结构与任务调度:裸机跑还是上 RTOS
拿到一份“无人船代码”,先看目录结构。常见做法是分四块:App(任务逻辑)、Control(PID 和导航)、Sensor(GPS/IMU 驱动)、Sys(系统时钟、串口收发)。没有用 RTOS 时,主循环就是一个大状态机,按 1ms、10ms、100ms 分级执行。
如果只有 PID 和 GPS 解析,裸机完全够;要加数传、SD 卡日志、遥控接管,FreeRTOS 会让代码干净得多。但 RTOS 在无人船上有个坑:任务优先级没设好,GPS 解析这种长任务会卡断 PWM 输出,导致电机瞬间丢转。所以裸机写轮询反而是最稳的入门方案。
下面是一份常见的裸机主循环骨架:
volatile uint32_t tick_1ms = 0; int main(void) { System_Init(); GPS_Init(); // 串口 DMA 接收 IMU_Init(); // I2C 读取 Motor_Init(); while (1) { if (tick_1ms % 2 == 0) { IMU_Update(); } // 500Hz 姿态刷新 if (tick_1ms % 10 == 0) { Control_Update(); } // 100Hz 控制环 if (tick_1ms % 100 == 0) { GPS_Poll(); } // 10Hz 位置更新 if (GPS_NewDataReady()) { Nav_Update(); } // 有解析到完整帧才执行 } } void SysTick_Handler(void) { tick_1ms++; }逻辑说明:tick_1ms由 SysTick 中断累加,主循环按整数取模分配任务。为什么用这种时间片而不是延时函数?因为Delay(10)会阻塞整个循环,GPS 串口数据可能在阻塞期间被覆盖。姿态更新放到 2ms 的槽位,是因为 MPU6050 的 DMP 输出频率最高也就 200Hz,多刷反而浪费时间。
这里要注意一个参数边界:Control_Update放在 10ms 槽,也就是 100Hz 的控制频率。对大多数水面船来说,100Hz 控制已经溢出,因为螺旋桨和船体在水中就像一个低通滤波器,高频抖动传不到船体,只会浪费电机寿命。若后面发现船有高频颤振,先把控制频率降到 50Hz 试试,而不是加滤波。
如果上 FreeRTOS,任务优先级建议:控制环 > IMU 采集 > GPS 解析 > 日志输出。切记不要赶在 GPS 串口空闲中断里做浮点运算,要知道 F103 的浮点是软件模拟,一次 NMEA 解析要几毫秒,放中断里会让 1ms 节拍乱掉。
3. 让无人船走直线:位置环与速度环的 PID 实现
3.1 串级 PID 结构:内环角速度、外环航向
无人船要在有水流的环境里保持航向,单级 PID 往往压不住。常见做法是用串级 PID:内环是角速度环,外环是航向环。外环输出“目标角速度”,内环把这个角速度稳定住。为什么需要内环?因为船体的角运动受水阻力影响,非线性很强,内环能把这个被控对象近似成线性,外环参数才好调。
用公式表示:外环误差e_heading = target_heading - current_heading,外环 PID 输出target_yaw_rate;内环误差e_yaw_rate = target_yaw_rate - gyro_z,内环 PID 输出左右电机差速量。
对应代码:
typedef struct { float Kp, Ki, Kd; float integral; float last_error; float out_max; } PID_t; float PID_Update(PID_t *pid, float error, float dt) { float derivative = (error - pid->last_error) / dt; pid->integral += error * dt; // 抗积分饱和:限制积分项范围 if (pid->integral > pid->out_max) pid->integral = pid->out_max; if (pid->integral < -pid->out_max) pid->integral = -pid->out_max; float out = pid->Kp * error + pid->Ki * pid->integral + pid->Kd * derivative; pid->last_error = error; if (out > pid->out_max) out = pid->out_max; if (out < -pid->out_max) out = -pid->out_max; return out; }这段代码是所有 PID 的基础。需要注意dt必须由调用者传入,不能内部自己读时钟,否则在调试时你无法重放日志。out_max是输出限幅,放在最后,防止大航向误差时输出占空比超过 100%。integral的限幅放在积分累加后,这个顺序很关键,先限幅再算输出,能避免积分项被后续比例微分项顶爆。
串级结构里,外环输出是内环的目标。所以外环 PID 的输出限幅应该设成内环能接受的角速度范围,比如±90°/s,而不是电机 PWM 的范围。很多项目死在把外环输出直接当成 PWM 差速,再接内环时才发现死循环。
3.2 PID 参数整定步骤与经验初值
整定无人船 PID,老实按“先内环后外环”的顺序,别上来就两个环一起调。第一步,把外环去掉(目标角速度直接由遥控或定值),只调内环:先给一个很小的Kp,看角速度响应,慢慢加到船体出现轻微振荡,记下临界值。第二步,加少量Ki消除静差,注意水面电机和螺旋桨的摩擦力会让角速度达不到目标,I 项能补。第三步,加一点Kd抑制超调,但水面信号噪声大,Kd 太大会把电机抖成筛子。
我的经验初值(差速双桨,船体约 1 米长,3S 电池):
| 环 | Kp | Ki | Kd | 控制频率 | 输出范围 |
|---|---|---|---|---|---|
| 内环角速度 | 0.12 - 0.25 | 0.02 - 0.05 | 0.01 - 0.03 | 100Hz | ±90°/s |
| 外环航向 | 1.2 - 2.5 | 0.05 - 0.15 | 0.2 - 0.5 | 50Hz | ±90°/s |
参数解释:内环输出单位是“目标角速度”,所以 Kp 量级在 0.1 左右;外环输出是“目标角速度”,Kp 量级在 1 左右。如果船体更重,内环 Kp 要下调,因为转动惯量大,同样的 Kp 更容易振荡。外环 Kp 和航向传感器噪声相关性很高,GPS 航向(由两点位置推算)噪声大,Kp 得降;用磁力计航向则噪声小,可以适当加 Kd。
这组参数只是起点,现场必须微调。调参时建议把目标航向设成 0°,船头偏差记录成一条曲线,观察振荡周期:如果振荡周期大且缓慢收缩,加 Kd;如果船来回摆但幅度不减小,加 Kp。如果船始终朝一个方向偏,加 Ki。
3.3 定位融合:GPS + 磁力计 + 陀螺仪的“伪组合导航”
真正的组合导航需要卡尔曼滤波加惯性导航解算,但在低成本的 STM32 无人船上,常见做法是用“磁力计测航向 + 陀螺仪做角速度 + GPS 纠正位置”的松耦合结构。磁力计容易受电机磁场干扰,所以航向不能直接信它,要做一阶互补滤波:高频成分信陀螺,低频成分信磁力计。
航向融合代码:
#define FILTER_ALPHA 0.02f // 互补滤波系数,较小则更信任陀螺积分 float yaw_deg = 0.0f; void Yaw_Fusion_Update(float gyro_z_rad_s, float mag_yaw_deg, float dt) { float gyro_yaw_integral = yaw_deg + gyro_z_rad_s * 180.0f / 3.14159f * dt; // 正常化角度差到 [-180, 180],否则互补滤波会在 360 边界跳变 float angle_diff = mag_yaw_deg - gyro_yaw_integral; while (angle_diff > 180.0f) angle_diff -= 360.0f; while (angle_diff < -180.0f) angle_diff += 360.0f; yaw_deg = gyro_yaw_integral + FILTER_ALPHA * angle_diff; }这个滤波器的参数FILTER_ALPHA是核心。它表示磁力计对最终航向的修正权重,0.02 表示磁力计信息要累积很多次才能拉回来,适合磁力计噪声大但不会长期漂移的场景。如果船在水面上频繁转向,dt小且陀螺零漂明显,alpha 要调到 0.05-0.1;如果水面平稳,0.02 能让航向很平滑,但磁力计校正滞后会有 10° 左右误差。
GPS 定位部分,我们通常只在任务层用航向和坐标,不做高精度惯导。
// 把 WGS-84 经纬度换算成平面坐标(近似墨卡托,用于短距离导航) void GPS_ConvertToXY(float lat, float lon, float lat_ref, float lon_ref, float *x, float *y) { float EARTH_RADIUS = 6378137.0f; float scale = cosf(lat_ref * 3.14159f / 180.0f); *y = (lat - lat_ref) * 3.14159f / 180.0f * EARTH_RADIUS; *x = (lon - lon_ref) * 3.14159f / 180.0f * EARTH_RADIUS * scale; }逻辑说明:lat/lon是当前点,lat_ref/lon_ref是起始点基准。短距离(几公里内)用球面近似完全够,但要注意 GPS 精度 2-3 米,所以这个转换后的坐标会有米级噪声。在 PID 导航中,不要直接对坐标位置用微分项,因为 GPS 坐标的跳变会造成Kd项毛刺,通常做法是只对航向和距离误差做 PID。
位置控制常见做法是直线追踪引导:计算目标点到当前位置的投影,得到横向偏差和纵向距离,用横向偏差做 PID,输出期望航向,再交给航向外环。这样 GPS 噪声不直接进 PID 微分,而是先经过航向融合平滑。
4. 避坑/常见问题:无人船 STM32 代码里最常见的 5 个坑
4.1 现象:船一启动电机,GPS 定位点就开始漂移
在实际下水里,只要螺旋桨一转,GPS 显示的船位往往会在水上随机跳 10-20 米,甚至直接进入“无效定位”。很多人第一反应是 GPS 模块质量问题。原因有两个:一是电机换向瞬间产生的高频电磁干扰,通过电源线污染了 GPS 模块供电;二是 GPS 天线距离电机金属轴太近,天线被金属反射影响。
解决:给 GPS 模块用独立的 LDO(低压差稳压器)和 LC 滤波,天线的接地面朝上并远离电机线 30 厘米以上。在代码里,还要做“定位有效性”判断,不能只看 NMEA 帧头,要检查GNGGA里的定位质量标识位。质量位非 1 或 2 时,直接不更新位置,让 PID 用最后一次有效坐标继续工作。
4.2 现象:航向 PID 输出饱和,船疯狂绕圈
现象是船不停原地打转,看日志发现内环目标角速度长期在上限附近,积分项已经顶到最大值。原因是外环 PID 输出上限设得太大,内环拼命追一个不切实际的目标角速度;或者水流干扰太大,内环本身就追不上。
解决:检查外环输出限幅是否和内环可承受范围匹配。例如内环只能输出 ±90°/s,外环输出就不能超过这个值。另外给积分项加“条件积分”——只有误差小于某个阈值(比如 30°)才允许积分累加,否则清零。这可防止船在远离目标航向时让积分饱和,等到接近目标时再被一个巨大的积分项推过头。
if (fabs(angle_error) < 30.0f) { pid->integral += angle_error * dt; } else { pid->integral = 0.0f; }4.3 现象:磁力计读数随电机转速变化,航向自动偏转
现象是船加速时航向显示慢速旋转,实际船是直的。原因是磁力计和电机驱动线束距离太近,电机电流的磁场叠加到地磁信号上。这个坑在代码里几乎无解,必须从布局上解决。
解决:把 HMC5883L 或 QMC5883L 放到船体外壳的最高处,远离电机电池组;电机线缆尽量双绞;在代码启动时做一次静态校准(求椭圆校准系数)。如果船还要变向,动态校准不现实,最好加装一根非金属桅杆把磁力计抬高。
4.4 现象:STM32 硬件 I2C 读取 IMU 偶发卡死,程序死机
现象是跑几分钟后程序锁死,看门狗复位也不起作用(中断都被 I2C 占住)。原因是 MPU6050 的硬件 I2C 在 STM32 上容易遇到“总线挂死”问题:SDA 被从设备拉低,SW 复位后总线仍然繁忙。
解决:最常见做法是改用 GPIO 模拟 I2C,信号质量由软件控制,容错好;或者开启 I2C SMBus 超时,并每次通信前检测总线空闲。我在无人船项目里直接不用硬件 I2C,用软件模拟后问题消失,代价是 CPU 占用率上升 3% 左右,影响可忽略。还有一条:STM32F1 的 I2C 外设设计有争议,很多老工程师建议你直接避坑。
4.5 现象:GPS 串口数据解析乱码,航向跳变
现象是串口打印偶现乱码,GPS 解析得到的位置偶尔跳到海里。原因是不定长 NMEA 帧没有用 DMA 加空闲中断,主循环在中断里逐字节解析,某个字节丢失后帧错位,后续所有解析都失效。
解决:用串口空闲中断把一帧数据收进 DMA 缓冲,然后主循环里通过帧头$和*号检查校验。没有 DMA 时,可以用如下方式:
// USART1 中断里只收字节,放到缓冲区 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { gps_buf[gps_cnt++] = USART_ReceiveData(USART1); if (gps_cnt >= 512) gps_cnt = 0; } }解析完整帧时,必须先做 NMEA 校验和,再更新位置。很多代码直接按逗号分割就完事,结果一个误码就画出百万公里级航线。校验和是$和*之间的字符异或。
for (int i = 1; i < sum_pos; i++) { checksum ^= gps_buf[i]; } if (checksum == hex_from_ascii(gps_buf[sum_pos + 1])) { GPS_ParseFrame(gps_buf); }校验和代码放在串口解析前,能过滤掉绝大多数错帧。GPS 模块波特率默认 9600,很多项目改成 115200 后没保存,重启又变回 9600,也会产生乱码。建议保留默认波特率,单独用日志时间戳做时间同步。
5. 从仿真到下水:在桌面验证算法,再上船测试
5.1 用 MATLAB/Simulink 搭一条虚拟无人船
没条件天天跑水的人,先在仿真环境里验证 PID 参数。无人船模型不用太精确,简化为一个带阻力的积分环节加一阶滞后即可。船在水里的转动惯量和阻力系数,决定了内环参数的大致范围。
一个很常用的仿真参数表:
| 物理量 | 符号 | 经验值 | 单位 |
|---|---|---|---|
| 船体质量 | m | 5.0 | kg |
| 转动惯量 | J | 0.8 | kg*m² |
| 横向阻力系数 | C_d | 12.0 | N*s/m |
| 螺旋桨推力系数 | K_t | 50.0 | N/占空比 |
| 响应滞后 | tau | 0.2 | s |
在 MATLAB 里用传递函数建模:
% 船转向模型简化为:角速度对差速指令的一阶惯性 J = 0.8; C = 12.0; tau = 0.2; G_yaw = tf(1, [J/C 1]); % 转向阻力环节 G_lag = tf(1, [tau 1]); % 执行器滞后 G_total = G_yaw * G_lag;这个模型的物理含义:J/C是时间常数,约 0.067 秒,说明船转向响应很快;tau = 0.2代表了螺旋桨和水的滞后,实际可能更长。把 PID 参数代入仿真环境,能使参数整定时间从现场的两小时压缩到十分钟。
仿真跑通后再看控制频率。如果仿真中用 50Hz 控制就不发散,现场却发散,那多半是传感器噪声或执行器死区,不是 PID 数值问题。死区补偿通常在代码里加一句if (fabs(out) < deadband) out = 0;,仿真里也要模拟这个死区,否则现场会差很多。
5.2 代码层面的离线回放与日志分析
无人船调试最头疼的是下水时间短,船跑十分钟就要捞上来看数据。没有日志回放,PID 调节完全靠感觉。常见做法是把关键变量打包成一条日志,按 10Hz 写到 SD 卡或通过数传发到岸上,格式是简单的 CSV。
我一般在船上记录这些字段:time, target_heading, current_heading, target_yaw_rate, current_yaw_rate, lat_err, lon_err, motor_left, motor_right。回岸后用 Python 脚本快速画图:
import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv('log.csv') df.plot(x='time', y=['target_heading', 'current_heading'], title='Heading Response') plt.show() df['heading_error'] = df['current_heading'] - df['target_heading'] print(df['heading_error'].mean()) # 打印平均偏差,直观判断积分项是否够逻辑说明:画target_heading和current_heading两条线,能看出超调量和稳定时间;打印平均偏差,如果始终是正数,说明外环积分项不够或者存在水流推力。用这个脚本,我能在两分钟内判断一组参数是否可用,而不是靠眼睛盯水面。
日志要特别记录dt的抖动。PID 的积分项用了error * dt,如果dt不稳定,积分的贡献会忽大忽小。我在代码里用TIM计数值记录相邻两次控制调用的间隔,如果发现dt偏差超过 20%,先优化任务调度,再谈 PID。
5.3 下水测试前的 10 项检查
下水前按这个清单一条条勾,缺一项都别下水:
- GPS 模块天线的接地面是否朝上,附近有没有金属遮挡。
- 磁力计周围有没有电机线、金属舵机,静态转动船体查看航向是否平滑。
- 左右电机转向是否一致:同时正转时船是前进不是原地画圈。
- 电机堵转保护是否开启:螺旋桨被水草卡住时电流能否被切断。
- 遥控器通道是否映射到“紧急切换到手动模式”,优先级要高于自动模式。
- 电源电压检测:低于 10V 时自动减速返航,防止电池耗尽漂流。
- 控制频率是否稳定,用示波器或定时器计数验证
Control_Update间隔。 - 超标量检查:外环 PID 输出限幅是否匹配内环可执行范围。
- GPS 定位质量确认:下水前等待
GGA帧的定位质量位为 1,再启动自动导航。 - 日志记录是否开启:SD 卡写入失败时报警,别跑完一条船还没数据。
第十项经常被忽略。有一次我同事没查 SD 卡是否插好,漂了一小时回来,内存卡是坏的,全程参数无从调起。这些检查听着琐碎,每一条都对应一次真实的翻车经历。
6. 让 PID 更聪明:模糊 PID 与自适应调参的实践
固定的 PID 参数在静水里没问题,一旦遇到风浪或水草阻力变化,船的姿态会突然垮掉。常见做法是做模糊 PID:根据当前误差大小和误差变化率,动态调整 Kp 和 Ki。模糊控制不需要精确模型,只需要一张经验表,这正是它适合水面这种变工况场景的原因。
| 误差 | 误差变化率 | Kp 调整 | Ki 调整 |
|---|---|---|---|
| 大 | 大 | 大减 | 清零 |
| 大 | 小 | 中减 | 小减 |
| 小 | 大 | 小增 | 中减 |
| 小 | 小 | 保持 | 保持 |
这张表的逻辑是:误差大且变化快时,减小 Kp 防超调,清零 Ki 防积分饱和;误差小且变化慢时,保持参数并让积分收敛。具体调表要在测试后根据日志修订,不是一次能成功的。自适应 PID 比模糊 PID 更彻底,比如在船上实时辨识“转向阻力系数 C”,再用它反推 Kp,但 STM32 上做实时辨识需要矩阵运算,一般建议放到上位机离线做。
我自己的项目经验:模糊 PID 对无人船的航向控制帮助不大,因为内环角速度的响应足够线性;但对外环位置控制有用,因为横向偏差的噪声大,模糊规则能避免 Kp 越来越大。如果你时间紧,先把固定参数调好再上模糊也不迟。
回到开头那个 zip:真正值钱的不是代码文件,而是你从代码里拿走的控制思路。带 PID 和定位的无人船,核心是让系统在不确定性里呆得住。我的习惯是每次下水都只改一个参数,改完就写进注释,记录当时的水流和风速。这样跑了十趟,你才会慢慢摸到水面这条“玄学”曲线的脾气。希望这篇能帮你少走几趟冤枉水路,祝顺利下水。
本文还有配套的精品资源,点击获取