☰
无人船STM32控制:PID闭环与定位算法核心解析
2026/10/27 17:43:47 网站建设 项目流程

简介:面向嵌入式开发者和无人船/无人艇研究者,这是一份基于STM32的无人船控制源码包,围绕远程数据收发、自主姿态稳定和定位导航展开,集成了PID算法、SI44 32无线数据传输、GY-273罗盘模块和北斗定位等模块,适合毕业设计、智能船舶竞赛及工程预研阶段做方案验证。包内共322个文件,以C源文件和头文件为主干,同时包含Keil工程配置、编译生成的.o/.axf/.hex等中间与可执行文件,以及PDF说明文档和批量脚本,压缩包仅4.33MB,可快速导入MDK工程查看代码结构。目前已有554人学习下载。代码中不仅给出底层的传感器驱动与PWM输出控制,还完整串起从数据采集、姿态计算到无线回传的链路,对于想深入理解无人船软件架构、移植STM32算法、联调定位与通信模块的开发者,都是现成的参考模板。

1. 拿到无人船代码包先别急着编译:PID、STM32、定位算法这条链路才是核心

拿到「无人船.zip_PID STM32_stm32 无人船代码_stm32 算法_stm32定位」这类压缩包,新手的第一反应通常是解压、编译、下载,然后期待船下水自己跑直线。真下过水的人会告诉你,最值钱的不是那几百行 main.c,而是把 STM32 定时器、PID 闭环和定位解算串成完整数据链路的组织方式。无人船本质是带动力和感知的水上机器人,核心三件事:STM32 采集传感器数据、PID 闭环控制电机转速与航向、定位算法把 GPS 坐标换算成期望航向。它能解决的实际问题,是让船在风浪和水流干扰下保持预设航线,适合毕业设计、水面巡检样机和竞赛船载控制器开发者。这篇笔记不复述源码,只按落地顺序把选型、参数和坑讲透。

2. 拆开无人船的控制闭环:传感器选型、STM32 外设分配与坐标约定

2.1 无人船和陆地小车三个本质差别:水流、惯性与控制延迟

无人船和常见的 STM32 智能车、平衡车看起来都是「电机 + 传感器 + 单片机」,控制写法却差很多。第一个差别是水环境没有「停车摩擦」。小车切断 PWM 后靠地面摩擦能很快停下来,船不行,水面阻力小、惯性大,切掉动力后船还会滑行好几米。这意味着无人船的 PID 输出变化必须更平缓,速度环的微分项不能给大,否则稍微一收油,船就在原地打转或者冲出航线。第二个差别是无人船通常是欠驱动系统。绝大多数无人船用双推进器差速或者单推进器加舵,几乎没有横移能力,能直接控制的只有前进推力和偏航力矩。落到代码上,控制量不是「转向角」,而是左右两个电机的转速差,航向环的输出要换算成左右 PWM 的差值,再叠加到速度环上。第三个差别是感知延迟。GPS 模块普遍只有 1Hz 到 10Hz 的更新率,磁罗盘和 IMU 虽然能到 100Hz 以上,但航向参考来源受限于最慢的 GPS。所以无人船代码几乎清一色采用「外环慢、内环快」的双环结构:定位解算跑 1~5Hz,航向环和速度环跑 100Hz。

这三点合在一起,决定了无人船代码的组织方式天然比陆地车复杂半级。很多从智能车转过来的开发者会把小车那套 20ms 主循环直接搬过来,结果 GPS 数据还没到,控制量已经在反复横跳。先想清楚这三点再去看代码里的定时器中断和 PID 结构,才不会一头扎进细节出不来。如果你是在对比机器人定位方案,可以顺带记住:陆地机器人有轮式里程计做短时定位,无人船在开阔水域基本只能靠 GPS 加航向参考,定位更新率和可靠性直接决定控制环能跑多快。

2.2 STM32 外设资源分配:一个可直接照抄的定时器、串口、ADC 规划表

一套能稳定跑闭环的无人船代码,外设分配往往是先于算法敲定的。主控用 STM32F103 还是 F407 都可以,F103 跑 100Hz 双环加 GPS 解析绰绰有余,除非还要同时跑摄像头或者激光雷达。下面这个分配表是我做过的一条双推进器无人船的控制板方案,适合直接照抄,再根据你的板子调整引脚。

外设用途关键配置
TIM1左电机 PWM20kHz,互补输出带死区
TIM8右电机 PWM20kHz,互补输出带死区
TIM3左电机编码器编码器模式,输入滤波 0x0F
TIM4右电机编码器编码器模式,输入滤波 0x0F
TIM6控制节拍10ms 中断,驱动速度环和航向环
USART1GPS NMEA 数据9600 或 115200,DMA 接收
USART2调试日志DMA 发送,二进制结构体
I2C1MPU6050 + 磁罗盘400kHz,轮询读取
ADC1电池电压与电流多通道扫描,用于欠压保护

选择理由里最关键的两条:PWM 用高级定时器 TIM1/TIM8 是为了驱动 H 桥,互补输出和死区在硬件上做,比软件翻转可靠得多;编码器用 TIM3/TIM4 是因为它们和 TIM1/TIM8 没有冲突,而且 STM32 的编码器模式只需要定时器通道,不额外占用 IO。ADC 这里只做电压电流监控,不做电流闭环,因为无人船的电流环对多数应用不是必需的,省一个中断源能让主循环干净不少。如果你打算加超声波测距做避障,建议挂在 TIM2 的输入捕获上,和现有外设不打架。

外设铺好之后,IO 分配上有一个容易忽略的点:编码器输入引脚要开启内部上拉,输入滤波一定要打开,否则电机转动时的机械抖动会让计数器乱跳,速度环读到的转速全是噪声。另外 GPS 的串口必须开 DMA,NMEA 报文最长 80 字节左右,1Hz 数据看似不密,但如果用中断逐字节解析,会频繁打断 100Hz 控制中断的实时性。

2.3 传感器数据链路:GPS 更新率低,内环必须快于外环

传感器数据链路的顺序是:GPS 给位置和地速,磁罗盘和 IMU 给航向和角速度,编码器给左右电机转速。这三个数据源更新率不一样,所以代码里典型做法是分成两个频率段。GPS 在主循环里按 1~5Hz 解析 NMEA,得到经纬度后走定位算法算出目标航向,写到一个全局变量target_heading;TIM6 的 10ms 中断里,航向环读当前航向和目标航向求误差,输出期望转速差;速度环再各自读编码器,把左右电机转速 PID 闭环到期望值。整体结构如下:

volatile uint8_t gps_update_flag = 0; volatile float target_heading = 0.0f; // 定位算法输出的目标航向,单位度 // TIM6 10ms 中断:速度环 + 航向环统一在这里跑 void TIM6_IRQHandler(void) { if (TIM_GetITStatus(TIM6, TIM_IT_Update)) { TIM_ClearITPendingBit(TIM6, TIM_IT_Update); float heading = Get_Heading(); // 磁罗盘 + IMU 融合后的当前航向 float err_h = Angle_Wrap(target_heading - heading); // 角度误差,单位度 float speed_diff = PosPID_Calc(&heading_pid, err_h); // 航向环输出:转速差 float rpm_l = base_speed + speed_diff; // 左电机期望转速 float rpm_r = base_speed - speed_diff; // 右电机期望转速 float pwm_l = IncPID_Calc(&speed_pid_l, rpm_l, Get_RPM_Left()); // 速度环 float pwm_r = IncPID_Calc(&speed_pid_r, rpm_r, Get_RPM_Right()); Set_Motor_PWM(pwm_l, pwm_r); } }

base_speed是巡航速度,speed_diff是航向环给的差速量。这个结构里航向环只输出差值,不影响前进速度,代码最直观,也最容易排查。gps_update_flag由 NMEA 解析函数置位,主循环检测到后更新目标航向。这里必须强调坐标约定要全工程统一:磁罗盘给的是 0° 指北、顺时针增大的航向角,而数学公式里的 atan2 输出定义在笛卡尔坐标系下,两者混用最常见的结果是船在小范围打转。我一般统一用「NED 右手系,yaw 以北为 0,顺时针为正,单位度」,GPS 转平面坐标后的 x 轴指东、y 轴指北,所有算法都按这个约定传参与返回。

3. 速度环与航向环的 STM32 实现:从增量式 PID 到裸机参数整定

3.1 位置式 PID 与增量式 PID:电机转速场景为什么选增量式

位置式 PID 的输出是u(k) = Kp*e(k) + Ki*Σe + Kd*[e(k)-e(k-1)],增量式算法输出的是Δu(k) = Kp*[e(k)-e(k-1)] + Ki*e(k) + Kd*[e(k)-2e(k-1)+e(k-2)]。两者本质差别是「输出绝对值」还是「输出增量」。无人船的电机速度环,我几乎不用位置式,原因有三个。

第一,增量式天然没有积分累积的历史包袱。位置式的积分项 Σe 在堵转或卡死时会一直累加,输出冲到限幅值,等障碍清掉之后船会猛窜一下,这就是积分饱和。增量式算的是每次增量,即使长时间饱和,只要误差缩小,输出增量自动回落,不会出现补偿过量。第二,增量式对执行机构更安全。它输出的是 PWM 变化量,就算算法某拍跑飞,单次输出也只是「变化一点」,位置式可能直接输出最大值,电机瞬态冲击很大。第三,无人船经常需要手动遥控和自动航线切换,增量式切回自动时把输出清零即可,不用重新初始化积分值。

但航向环我用的是位置式,因为航向环需要绝对输出——期望转速差是明确量,不是「在上次基础上加多少」。位置式配合积分限幅和输出限幅,写起来更直接。所以同一份无人船代码里速度环和航向环用不同 PID 形式完全正常,别被「统一用增量式」的教程带偏。这就是常说的增量式 PID 算法和位置式各自适合的场景,选型时看输出物理意义,不看流行程度。

3.2 编码器测速与 PWM 输出:10ms 控制周期的速度环代码

速度环的第一步是让定时器工作在编码器模式。下面这段初始化以 TIM3 为例,PA6 和 PA7 分别接电机编码器的 A、B 相:

// TIM3 编码器模式初始化:PA6->CH1, PA7->CH2 void Encoder_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; TIM_Encoder_InitTypeDef Encoder = {0}; __HAL_RCC_TIM3_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_PULLUP; // 内部上拉,防机械抖动误计数 GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; GPIO_InitStruct.Alternate = GPIO_AF2_TIM3; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); Encoder.EncoderMode = TIM_ENCODERMODE_TI1; // 只在 CH1 上升沿计数,简单稳定 Encoder.IC1Polarity = TIM_ICPOLARITY_RISING; Encoder.IC2Polarity = TIM_ICPOLARITY_RISING; Encoder.IC1Selection = TIM_ICSELECTION_DIRECTTI; Encoder.IC2Selection = TIM_ICSELECTION_DIRECTTI; Encoder.IC1Prescaler = TIM_ICPSC_DIV1; Encoder.IC2Prescaler = TIM_ICPSC_DIV1; Encoder.IC1Filter = 0x0F; // 输入滤波,吃掉机械抖动 Encoder.IC2Filter = 0x0F; HAL_TIM_Encoder_Init(&htim3, &Encoder); __HAL_TIM_ENABLE(&htim3); htim3.Instance->CNT = 0; }

编码器模式用TIM_ENCODERMODE_TI1只在 CH1 上升沿计数,虽然分辨率比四倍频低,但对无人船的调速足够,而且不容易受两相相位抖动干扰。IC1Filter = 0x0F是关键参数,滤波太弱读到的转速毛刺大,滤波太强会滞后,0x0F 在 72MHz 时钟下大约是几十纳秒级别的去抖,适合大多数电机编码器。速度换算在 10ms 中断里做,用两次读数的计数器差值推算转速:

int16_t last_cnt = 0; // 每个控制周期(10ms)调用一次,返回转速,单位 RPM float Get_Speed_RPM(void) { int16_t now_cnt = (int16_t)__HAL_TIM_GET_COUNTER(&htim3); int16_t delta = now_cnt - last_cnt; // 带符号差值,负值表示反转 last_cnt = now_cnt; // 编码器线数 13,四倍频每圈 52 个计数;减速比 30:1 // rpm = delta / (52*30) / 0.01s * 60s float rpm = (float)delta / (13.0f * 4.0f * 30.0f) / 0.01f * 60.0f; return rpm; }

delta必须用int16_t承接,因为定时器 CNT 是 16 位的,直接赋值给 int 再相减会在正反转切换时出错。速度环 PID 用增量式结构,输出直接叠加到 PWM 占空比上:

typedef struct { float Kp, Ki, Kd; float err_last, err_last2; float out; // 当前输出累计值 float out_limit; // 输出限幅 } IncPID_t; float IncPID_Calc(IncPID_t *pid, float target, float actual) { float err = target - actual; float d_out = pid->Kp * (err - pid->err_last) + pid->Ki * err + pid->Kd * (err - 2.0f * pid->err_last + pid->err_last2); pid->err_last2 = pid->err_last; pid->err_last = err; pid->out += d_out; // 增量累加 if (pid->out > pid->out_limit) pid->out = pid->out_limit; if (pid->out < -pid->out_limit) pid->out = -pid->out_limit; return pid->out; // 返回值范围 ±out_limit }

增量式 PID 算法里d_out是增量,out才是真正写入电机的 PWM 值。限幅放在累加之后,这很重要——如果先限幅再累加,下一次增量还会把输出顶回去,限幅形同虚设。PWM 初始化时把 ARR 设为72000000/20000-1,即 20kHz,死区时间寄存器按驱动器手册算,1us 死区对应 72 个时钟。

3.3 航向环的 D 项与角度环绕:陀螺仪数据先滤波再微分

航向环比速度环多一个门槛:角度是周期性量,0° 和 360° 是同一个方向。如果直接相减,目标 350°、当前 10° 得到的误差是 340°,PID 会驱动船走完 340° 而不是近路 20°。所以进入 PID 之前必须先做角度环绕处理:

// 角度误差环绕:把误差收敛到 (-180, 180] float Angle_Wrap(float err) { while (err > 180.0f) err -= 360.0f; while (err < -180.0f) err += 360.0f; return err; }

航向环的 D 项我建议直接用陀螺仪 Z 轴角速度,而不是用「误差差分」。误差差分在角度环绕场景容易在跳变点产生尖峰,而且放大量测噪声;陀螺仪角速度的物理意义就是航向变化率,噪声更小,还省一次差分运算。唯一要注意的是陀螺仪零漂,MPU6050 上电后静置一会儿再采零偏,然后对原始值做一阶低通滤波:

float gyro_filtered = 0.0f; // 10ms 控制周期里调用,alpha=0.5 时 -3dB 截止频率约 8Hz float LowPass_Gyro(float raw) { gyro_filtered = 0.5f * raw + 0.5f * gyro_filtered; return gyro_filtered; }

alpha 取 0.5 能滤掉电机振动和波浪引起的高频晃动,又不至于让相位滞后太大。航向环本体是位置式 PID,积分限幅务必加上,否则船在紧急转向时会因为积分累积过头而画圈:

typedef struct { float Kp, Ki, Kd; float integral, i_limit; float err_last; float out_limit; } PosPID_t; float PosPID_Calc(PosPID_t *pid, float err) { pid->integral += err; if (pid->integral > pid->i_limit) pid->integral = pid->i_limit; if (pid->integral < -pid->i_limit) pid->integral = -pid->i_limit; float d_err = err - pid->err_last; pid->err_last = err; float out = pid->Kp * err + pid->Ki * pid->integral + pid->Kd * d_err; if (out > pid->out_limit) out = pid->out_limit; if (out < -pid->out_limit) out = -pid->out_limit; return out; }

如果 D 项改用陀螺仪角速度,就把Kd * d_err换成Kd * gyro_filtered,两种写法对应不同的 Kd 量纲,整定时不要混用。

3.4 PID 参数整定步骤与初始值:先 Kp 后 Ki 再 Kd

参数整定最忌「拍脑袋三连改」。我习惯在裸机 PID 控制阶段按固定顺序来,每一步只看一个变量。第一步,把 Ki 和 Kd 清零,只用 Kp。给速度环一个固定目标转速(比如 300RPM),逐步加大 Kp,观察编码器读数,直到转速出现等幅振荡,记下此时的 Kp 和振荡周期。第二步,加 Ki 消除稳态误差,Ki 从 Kp 的 1/10 起步,慢慢加到转速偏差稳定收敛。第三步,加 Kd 抑制超调,同时把之前低通滤波的陀螺仪数据接入 D 项。速度环和航向环必须分层调:先手动给定固定 PWM 让船空转,调速度环;再固定base_speed = 0,用手拨动船头,观察航向环能不能把船头拉回目标角度。

新手容易在水面上直接对着整条船调参,一旦翻车连是哪个环的问题都分不清。正确做法是岸上先调速度环,再把船头绑在系缆桩上调航向环的响应。下面这组初始值可以作为起点,注意每套船体动力不同,必须按上述流程重调:

环路KpKiKd输出限幅备注
速度环(增量式)30~805~200~5±2000 PWM误差单位 RPM
航向环(位置式)1.0~2.50.05~0.150.5~1.5±300 RPM 差速误差单位度

航向环 Kp 是 1.0 左右时,90° 误差会输出约 90RPM 差速,船体缓慢转头,这个量级对大多数 1~2 米船体是安全的。如果上电后船头疯狂摆动,先把 Kp 砍到 0.5,确认方向正确再往上加。整套参数整定做完,记下临界振荡的 Ku 和 Tu,下次换船体时能省一半时间。

4. 定位与航线跟踪算法:把 GPS 坐标换算成期望航向

4.1 经纬度转平面坐标:等距圆柱近似的误差与 UTM 选择

定位算法第一步是把经纬度换成平面坐标,否则没法算距离和方位角。对 10km 以内的航线,用等距圆柱近似就够,代码短、好调试:

#define PI 3.14159265358979f #define DEG2RAD (PI / 180.0f) // 以起点 (lat0, lon0) 为平面原点,输出 x(东)、y(北),单位米 void GPS_To_XY(float lat, float lon, float lat0, float lon0, float *x, float *y) { float d_lat = lat - lat0; float d_lon = lon - lon0; float lat0_r = lat0 * DEG2RAD; *y = d_lat * 110540.0f; // 纬度每度约 110.54km *x = d_lon * 111320.0f * cosf(lat0_r); // 经度每度随纬度收缩 }

111320和110540是赤道附近的每度米数,cosf(lat0_r)修正经度线在不同纬度的收敛。这套近似以起点纬度为基准,在距离原点 10km 内误差在米级,对普通 GPS 模块来说足够。如果上了 RTK 高精度定位,或者航线跨几十公里,就用 UTM 投影,UTM 在数百公里范围内都能保持亚米级误差,但需要处理带号和中央经线。判断要不要换 UTM 的标准很简单:航线地图上画出来,东西跨度超过 10km 就换。

4.2 目标方位角计算与 0°/360° 环绕处理

有了平面坐标,下一步是算船到目标点的方位角。仍然可以用经纬度直接算,不转平面坐标也行,但平面坐标的方位角更好验证。用球面大圆公式算初始方位角:

// 从 (lat1,lon1) 到 (lat2,lon2) 的初始方位角,单位度,范围 [0,360) float Bearing_To_Target(float lat1, float lon1, float lat2, float lon2) { float lat1_r = lat1 * DEG2RAD; float lat2_r = lat2 * DEG2RAD; float d_lon = (lon2 - lon1) * DEG2RAD; float y = sinf(d_lon) * cosf(lat2_r); float x = cosf(lat1_r) * sinf(lat2_r) - sinf(lat1_r) * cosf(lat2_r) * cosf(d_lon); float brg = atan2f(y, x) * 180.0f / PI; if (brg < 0) brg += 360.0f; return brg; }

atan2f返回范围是 (-180, 180],加 360 归一化到 [0, 360)。这个方位角就是定位算法输出的期望航向,写入全局变量target_heading。注意 GPS 的 COG(对地航向)和这个目标方位角是两回事:COG 是船实际运动方向,目标方位角是期望运动方向,航向环误差应当用「目标方位角 - 当前航向角」,而不是「目标方位角 - COG」。如果用了 COG 作反馈,风浪大时船会左右摆。

4.3 直线航线跟踪:纯追踪前瞻点与交叉跟踪误差 XTE

只算方位角会让船走出一条「指向目标点」的直线,但遇到侧风和水流,船会被推离航线。工程上更稳的做法是纯追踪(Pure Pursuit):在航线上选一个前方固定距离的点作为目标,算它的方位角去跟踪。这样船会自然内切修正,不会在航线上来回横穿:

// 在航线点列表里找前瞻点:距离船超过 lookahead 的第一个线段终点 // path_pts[] 已转为平面坐标,单位米 int Find_Lookahead_Point(float boat_x, float boat_y, float lookahead, float *target_x, float *target_y) { for (int i = 1; i < path_pts_count; i++) { float x1 = path_pts[i-1].x, y1 = path_pts[i-1].y; float x2 = path_pts[i].x, y2 = path_pts[i].y; float abx = x2 - x1, aby = y2 - y1; float apx = boat_x - x1, apy = boat_y - y1; float proj = (apx * abx + apy * aby) / (abx * abx + aby * aby); if (proj < 0.0f || proj > 1.0f) continue; // 船不在当前线段投影范围内 float cx = x1 + proj * abx; // 垂足点 float cy = y1 + proj * aby; float dist = sqrtf((boat_x-cx)*(boat_x-cx) + (boat_y-cy)*(boat_y-cy)); if (dist < lookahead) { // 船已接近这段航线,取终点作前瞻点 *target_x = x2; *target_y = y2; return 1; } } return 0; }

lookahead是纯追踪的灵魂参数,和船速强相关。2m/s 的船速下,前瞻距 5~10m 比较合适;太小会让船头频繁摆动,太大则会在拐弯处切内圈。另一条辅助指标是交叉跟踪误差 XTE,表示船偏离航线的侧向距离,可以用它来做航段切换和超限报警:

// 返回船到线段的有符号距离:正值表示船在航线左侧 float Calc_XTE(float boat_x, float boat_y, float x1, float y1, float x2, float y2) { float abx = x2 - x1, aby = y2 - y1; float apx = boat_x - x1, apy = boat_y - y1; float cross = abx * apy - aby * apx; // 二维叉积 float len = sqrtf(abx * abx + aby * aby); return cross / len; // 单位米 }

实际航线跟踪里,我习惯把纯追踪和 XTE 合起来用:正常段用纯追踪输出目标方位角,XTE 超过阈值(比如 5m)时把目标点切换回最近航段的垂足点,先拉回航线再继续前瞻。这套组合逻辑能让船在侧风下走出一条「锯齿但不离航线」的路径,比单纯 PID 压方位角抗干扰能力强很多。

5. 无人船 STM32 代码避坑与排查:五个高频翻车点的现象、原因与解法

5.1 电机抖动不转:PWM 频率过低与死区配置错误

现象:上电后电机发出尖锐蜂鸣或者嗡嗡响,转速不稳定,低速时一顿一顿,甚至完全不动。

原因:PWM 频率太低,常见的是用 1kHz 甚至几百 Hz,电机绕组电流纹波太大,低速段扭矩不足;另一个高频原因是 H 桥互补 PWM 死区时间设为零,上下桥臂直通,驱动器触发过流保护,表现为「一加速就断电」。

解决:PWM 频率提到 15~20kHz,ARR 按72000000 / 20000 - 1计算;死区时间按驱动器手册设置,常见全桥驱动取 0.5~2us,寄存器值等于死区秒数乘 72MHz。如果板子没有硬件死区,必须用软件在翻转前后插入延时,绝不能省。

5.2 航向周期性跳变:磁罗盘未校准与地磁干扰

现象:船头缓慢转动时航向角不是平滑变化,而是在某几个角度突然跳变几十度,航向环跟着来回抽动。

原因:磁罗盘没做硬磁校准,电机磁钢、电池大电流走线和船体铁磁材料叠加出一个固定偏置磁场;或者罗盘离电机和电池太近,动态磁场直接叠加进测量值。

解决:上电后做原地旋转 360° 的硬磁校准,采集每个方向的磁场值求出偏置并减去;PCB 布局上把磁罗盘放在远离大电流走线和电机的位置,电机线用双绞线降低磁场干扰。校准完在岸上转一圈验证,如果还有跳变,检查是不是有铁磁性物体在附近移动。

5.3 GPS 坐标飞点:NMEA 解析缓冲区溢出与坐标格式混淆

现象:船静止时定位点偶尔飞出几十米,航线跟踪突然打一个大弯,日志里经纬度出现明显不连续。

原因:两个常见病根。一是 NMEA 缓冲区开太小,$GPRMC或$GPGGA报文被截断,解析到半行数据;二是把 NMEA 的度分格式ddmm.mmmm当成十进制度直接代入坐标换算公式。

解决:缓冲区开到 128 字节以上,DMA 接收后等完整换行符再解析,解析前做校验和验证;纬度解析先拆度分再合并:

// NMEA 纬度格式 ddmm.mmmm:2238.1234 表示 22°38.1234' float Parse_NMEA_Lat(float v) { int deg = (int)(v / 100.0f); float min = v - deg * 100.0f; return deg + min / 60.0f; }

再加一道位置跳变滤波:新坐标离上个点超过设定阈值(比如 30m)且船速低于 1m/s 时丢弃这一帧。这三层做完,飞点基本能被挡在控制环外面。

5.4 积分饱和导致大角度超调:输出限幅与积分限幅一个都不能少

现象:航向误差大(比如 90°)时,船快速转向却冲过了头,然后来回摆动好几次才稳定,日志里输出长时间顶在限幅值。

原因:位置式 PID 的积分项在整个大误差阶段持续累积,误差反转后积分值还很大,输出仍然朝原方向使劲,这就是积分饱和。

解决:积分限幅和输出限幅必须同时加,我的经验是积分限幅取输出限幅的一半;还可以在误差大于 30° 时冻结积分,误差大时只靠 Kp 和 Kd 把船头拉回来,进入小误差区间再让积分参与。速度环的增量式 PID 虽然没有积分累积问题,但输出限幅同样要设。

5.5 一下水就复位:看门狗喂狗位置与控制循环阻塞

现象:在岸上调试一切正常,船一放到水里几秒后就重启,反复如此,看门狗复位标志位被置位。

原因:下水后 GPS 报文、日志输出和 IMU 读取的时序发生变化,某个阻塞函数卡住超过看门狗超时时间。最常见的是串口发送等 TXE 标志、I2C 等 ACK,以及电机堵转时 ADC 中断和主循环抢 CPU。

解决:看门狗喂狗放在主循环最前面,不要放在中断里,中断里喂狗等于没喂;所有串口发送改成 DMA 非阻塞,I2C 读取加超时计数,禁止无限等待。这样即使某个传感器挂死,主循环也能继续转,复位前还能把故障信息打出来。

6. 把船调稳的三段验证法:日志回放、桌面仿真与水上试航

调无人船最怕「下水才知道坏没坏」,一趟试航几分钟,捞船半天。我的做法是永远先过三段验证,任何参数改动都从第一段重来。第一段是日志回放。控制中断里把关键量打包成二进制结构体,用串口 DMA 实时发出来:

typedef struct { uint32_t seq; // 中断序号,10ms 递增 float rpm_l, rpm_r; // 左右实际转速 float speed_ref_l, speed_ref_r; // 左右期望转速 float heading, target_heading; // 当前航向与目标航向 float pwm_l, pwm_r; // 实际 PWM 输出 float x, y, xte; // 平面坐标与交叉跟踪误差 } CtrlLog_t;

PC 端用 Python 解析并画曲线,一眼能看出是哪个环在振荡:

import struct import matplotlib.pyplot as plt fmt = '<I11f' # 小端:1 个 uint32 + 11 个 float,共 48 字节 size = struct.calcsize(fmt) recs = [] with open('log.bin', 'rb') as f: buf = f.read() for off in range(0, len(buf) - size + 1, size): recs.append(struct.unpack_from(fmt, buf, off)) times = [r[0] / 100.0 for r in recs] plt.plot(times, [r[6] for r in recs], label='heading') plt.plot(times, [r[7] for r in recs], label='target') plt.legend() plt.show()

第二段是桌面仿真。把录制的 GPS 坐标和实际航向喂给 PC 上的 PID 仿真脚本,参数在岸上粗调,省下水上试航的轮次。第三段才是水上试航,而且严格分三步:系留测试把船头绑在岸边,只调航向环;直线往返验证速度环和基本导航;最后布 3~4 个航点跑完整航线跟踪。如果想把日志推给远程端做无人船代码监控,可以再接巴法云这类 IoT 平台,把经纬度和航向定时上报,但前提是前三段验证全过。

我做第一条船的时候跳过系留测试直接下水跑航线,结果船在原地画了十几个圈,靠日志回放才发现是磁罗盘 Y 轴标定反了。后来学乖了,任何参数改动都先回放日志再下水,省下来的捞船时间远比调参时间值。希望这篇笔记里的闭环结构和避坑清单能帮到你少走这一段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询