简介:本资源是一套基于STM32的无人船嵌入式控制系统完整工程代码,面向嵌入式开发初学者、智能船舶项目开发者及高校电子/自动化专业实践者,聚焦无人船姿态控制、无线通信与高精度定位三大核心功能。代码集成SI4432无线数传模块驱动、GY-273电子罗盘数据融合、北斗GNSS定位解析及多环路PID航向与速度闭环控制算法,覆盖从传感器采集、数据解算到执行机构(如舵机、电机)PWM输出的全链路逻辑。压缩包含322个文件,主体为76个头文件(.h)与70个源码文件(.c),辅以编译中间文件(.o/.d)、Keil工程配置(.uvprojx/.uvoptx)及调试映射文件(.map/.axf),总大小4.33MB,结构符合标准STM32 HAL库工程规范。已有552人学习下载,可直接导入Keil MDK环境编译运行,配套注释清晰,便于理解PID参数整定、串口协议解析及多传感器时间同步等关键技术实现细节。 最近在整理之前做的无人船项目,看到那个“无人船.zip”的压缩包一直躺在硬盘里,里面是STM32的完整工程、PID控制代码、定位模块处理逻辑,还有一些调试时候的记录。回想起来,这个项目从零到下水跑通,踩了不少坑,也积累了不少可以直接用的经验。所以把核心内容整理出来,聊聊无人船这种水上机器人怎么做控制、怎么做定位、怎么把算法真正跑到单片机上。
这个项目适合两类人看:一类是刚接触STM32、想找个完整实战练手的学生或者爱好者;另一类是已经在做无人船/无人车/无人机,想参考PID整定、GPS定位解析、串口通信这些模块的开发者。我会把从硬件选型到代码实现的思路都拆开讲,重点说清楚为什么这么做,以及哪些地方容易踩坑。
1. 项目整体设计思路
1.1 为什么用STM32做无人船主控
无人船的控制不算特别复杂,但对实时性和外设接口有明确要求。你需要同时处理电机PWM输出、GPS串口数据、遥控器信号、传感器采集,可能还要跑PID计算和导航算法。当时我对比过Arduino、树莓派和STM32,最后选了STM32F103系列,核心原因有三点:
第一,实时性够且稳定。STM32是Cortex-M3内核,72MHz主频,跑PID这种毫秒级控制循环绰绰余。更重要的是它有硬件定时器、硬件串口、硬件I2C/SPI,不依赖软件模拟,实时性稳定可控。Arduino虽然上手快,但一旦外设多起来,中断优先级和定时器冲突会很麻烦。
第二,外设接口丰富。无人船需要至少2路PWM驱动电机、2个串口(一个接GPS、一个接遥控/数传)、可能还有I2C接IMU、ADC采集电压。STM32F103C8T6这种小封装芯片就能全部搞定,成本也低,一个板子十几块钱。
第三,生态成熟。ST的HAL库和标准库资料非常多,网上关于PWM、串口、DMA、ADC的例程一抓一大把,尤其“江科大STM32”那份教程对我早期帮助很大。出了问题容易查到解决方案,这一点在项目排期紧的时候特别重要。
1.2 系统架构与模块划分
这个无人船系统我按功能拆成了四个模块:控制模块、定位模块、通信模块、动力与电源模块。各模块之间尽量解耦,比如控制模块只负责读目标值和传感器值,输出PWM;定位模块只负责解析GPS数据和IMU数据,输出当前经纬度和航向。这样每个模块可以独立调试,出了问题也好定位。
具体硬件清单大概是:
- 主控:STM32F103C8T6最小系统板
- 定位:GPS模块(NEO-6M,串口输出NMEA协议)+ MPU6050六轴陀螺仪/加速度计(I2C接口)
- 动力:两个直流电机配电子调速器(电调),或用L298N电机驱动板直接驱动
- 通信:HC-12无线串口模块,用于遥控指令和遥测数据回传
- 电源:3S锂电池给电调供电,通过降压模块给主控板供电
整体数据流是:遥控器指令或预设航点作为目标值,GPS和IMU作为反馈值,STM32内部跑PID控制算法,输出PWM控制电机转速,实现直行、转向、保持航向等功能。通信模块负责把状态数据传回岸边的上位机,方便调试。
2. 核心算法:增量式PID与航向控制
2.1 PID控制基础与增量式/位置式怎么选
PID本身不是新东西,但在无人船上,选对“算法形式”很重要。先复习一下两种主流形式。
位置式PID输出的是执行机构的绝对控制量,公式是:
u(k) = Kp * e(k) + Ki * Σe(i) + Kd * [e(k) - e(k-1)]
这个形式直观,但有一个问题:它需要累加历史误差,也就是积分项Σe(i)。如果长时间存在稳态误差,或者某个时刻误差突然变大,积分项会累积到很大,导致输出饱和。在无人船上,如果电机输出饱和了,舵可能一直打满,船会一直转圈,非常危险。
增量式PID输出的是控制量的增量,公式是:
Δu(k) = Kp * [e(k) - e(k-1)] + Ki * e(k) + Kd * [e(k) - 2*e(k-1) + e(k-2)]
实际输出为:
u(k) = u(k-1) + Δu(k)
增量式的优势在于:不直接累加误差,不会有积分饱和问题;即使某些时刻计算异常,输出也只是增量变化,不会瞬间跳变到极限值。这对船这种惯性大、响应慢的执行对象特别友好。我最后在航向控制和电机调速里都用了增量式PID,稳定性和平滑性都更好。
那我是不是说位置式就没用?也不是。如果你执行机构是那种需要绝对位置的设备,比如舵机角度,位置式PID计算后直接给角度,反而简单。但我当时船是用双电机差速转向,类似于坦克和扫地机器人,每个电机需要单独调速,增量式更合适。
2.2 航向控制代码实现与参数整定
航向控制的目标是让船保持或者改变到一个指定航向角度。实际代码里,我用IMU的Z轴角速度积分得到当前航向角,再用GPS航向角做融合修正。PID的输入是目标航向和当前航向的差值,输出是左右两个电机的转速差。
这是增量式PID的核心实现,我做了个带输出限幅的版本:
typedef struct { float target; // 目标值 float actual; // 当前值 float err; // 本次误差 float err_last; // 上一次误差 float err_prev; // 上上次误差 float Kp, Ki, Kd; // PID参数 float output; // 输出增量 float output_lim; // 输出限幅 float out_last; // 上一次实际输出 } PID_TypeDef; void PID_Calc_Increase(PID_TypeDef *pid, float target, float actual) { pid->target = target; pid->actual = actual; pid->err = target - actual; // 增量式PID核心 pid->output = pid->Kp * (pid->err - pid->err_last) + pid->Ki * pid->err + pid->Kd * (pid->err - 2 * pid->err_last + pid->err_prev); // 更新历史误差 pid->err_prev = pid->err_last; pid->err_last = pid->err; // 输出限幅,防止增量过大 if (pid->output > pid->output_lim) pid->output = pid->output_lim; if (pid->output < -pid->output_lim) pid->output = -pid->output_lim; // 累积输出 pid->out_last += pid->output; }这里有几个关键点。输出限幅很重要,因为实际电机的PWM增量不能突变,否则电调容易过流,船体也会猛冲。误差历史值的更新顺序容易写错,必须先保存旧误差再更新当前误差,否则后面差分项全算错。
参数整定我推荐用“先P后I再D”的顺序。第一次下水时,把Ki和Kd设成0,只调Kp。从一个很小的值比如0.5开始,看船的反应:如果船缓慢靠近目标航向但不稳定,说明Kp太小;如果船在目标值附近来回振荡,说明Kp太大。找到临界振荡的Kp值后,乘上0.6左右作为安全Kp。然后加一点Ki去消除稳态误差,Ki一般取Kp的十分之一到五分之一就行。Kd用于抑制振荡,船在水上的惯性很大,Kd不能太大,通常先给Kp的十分之一,再微调。
这里还要提醒一句:PID参数和水域条件强相关。风浪大的水面,船受到扰动大,Kd可以适当加大一些;静水环境参数放得很尖,风浪一来就崩,这是正常的。我当时在湖里调好的参数,换到有波浪的水域就重新整定了一遍。
3. STM32定位模块与数据融合
3.1 GPS模块选型与NMEA数据解析
定位是无人船实现自动巡航的基础。我用的NEO-6M是比较常用的GPS模块,串口输出NMEA 0183协议。NMEA里最常见的语句是GPGGA和GPRMC,分别提供定位信息和推荐的定位数据。解析GPS数据的重点不是去理解所有字段,而是找对头、解析有效字段。
比如GPRMC语句的格式:
$GPRMC,083559.00,A,3105.5238,N,12123.2765,E,1.20,77.52,140124,,,A*63字段含义依次是:UTC时间、定位状态(A=有效)、纬度、纬度方向、经度、经度方向、速度(节)、航向角、日期。我主要解析状态位、经纬度、速度和航向。
常见的坑是NMEA经纬度是“度分”格式。比如3105.5238,表示31度05.5238分,必须先转成十进制度数才能做距离和方位角计算:
// 将NMEA的ddmm.mmmm格式转换为十进制度 float nmea_to_decimal(float nmea_coord) { int degree = (int)(nmea_coord / 100); float minute = nmea_coord - degree * 100; return degree + minute / 60.0f; }解析我用的是状态机+串口空闲中断,每一帧以$开头,以回车换行结尾。每次收到完整一行后,调用解析函数,提取需要的字段。这样比逐字符判断省事很多。
3.2 定位数据滤波与航向修正
GPS数据本身有噪声,尤其在船低速运动时,位置漂移明显,且航向角很不稳定。如果直接拿GPS航向去控制船,船会像喝醉了一样左右摇摆。所以需要做两层处理:第一层对GPS坐标做滤波,第二层把IMU航向与GPS航向融合。
对于坐标滤波,我实践下来滑动平均和中值滤波都够用。中值滤波对去除离群点比较有效,但会引入几个周期的延迟;滑动平均更平滑,但也会有滞后。无人船本身惯性大,控制周期不需要太高,100ms左右的滤波窗口是可以接受的。
代码实现一个简单的滑动平均滤波器:
#define FILTER_SIZE 5 typedef struct { float buffer[FILTER_SIZE]; uint8_t index; float sum; } MovingAvg; void MovingAvg_Init(MovingAvg *f) { memset(f->buffer, 0, sizeof(f->buffer)); f->index = 0; f->sum = 0; } float MovingAvg_Add(MovingAvg *f, float val) { f->sum -= f->buffer[f->index]; f->buffer[f->index] = val; f->sum += val; f->index = (f->index + 1) % FILTER_SIZE; return f->sum / FILTER_SIZE; }航向修正我用的是互补滤波思路:IMU的短时间航向变化非常快且平滑,但会积分漂移;GPS的航向长时间稳定,但短时间内噪声很大。所以用高通滤波处理IMU角速度,用低通滤波处理GPS航向,然后合并:
heading = alpha * (heading_last + gyro_z * dt) + (1 - alpha) * gps_headingalpha一般取0.7到0.9之间。这个公式很轻量,适合STM32F103这种MCU。如果你想要更精确的姿态解算,可以用Mahony或者Madgwick滤波,但注意STM32F103跑起来会有一定的CPU占用,需要优化一下采样频率。
关于航向角的跨0度问题:目标航向355度,当前航向5度,误差不是350度而是-10度。这个如果不处理,PID会朝着相反方向全力打舵,船会原地绕一个大圈。处理方法是把误差归一化到[-180, 180],代码写法:
float angle_norm(float err) { while (err > 180.0f) err -= 360.0f; while (err < -180.0f) err += 360.0f; return err; }这个函数在航向控制里属于“命根子”级别的细节,千万别省。
4. 实操过程与核心环节实现
4.1 电机驱动与PWM控制
无人船的动力系统,我用的是双电机差速驱动。左右两个电机分别由PWM控制转速,通过改变左右电机的转速差实现转向。STM32用定时器产生PWM波,我常用的方案是TIM2的CH1和CH2输出两路PWM,频率设置为50Hz或更高。电调对PWM信号有特定格式,常见的是50Hz,高电平脉宽1ms到2ms对应0%到100%油门。
具体初始化HAL库代码:
void PWM_Init(void) { TIM_HandleTypeDef htim2; TIM_OC_InitTypeDef sConfigOC; __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; // 72MHz/72=1MHz htim2.Init.Period = 2000 - 1; // 1MHz/2000=500Hz htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim2); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 1000; // 初始脉宽1ms sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_2); }在实际控制里,我直接把PID输出的转速差值叠加到基础油门里。基础油门让船维持一个最低速度,保证舵效,否则船停下来的时候打舵也没用。PID输出则用于微调左右电机,实现航向保持或转向。
还有一个容易被忽略的点:电机是强干扰源,一定要在电机两端并联续流二极管(如果是直流电机)或者依靠电调的电路设计,否则电机反电动势会影响STM32工作,重启或者程序跑飞都是可能的。我的第一版没加保护,一开电机主控就复位,后来并联了二极管、加了共地,才稳定下来。
4.2 串口通信与无线遥控
无人船需要和岸边的遥控器/上位机通信。我最初用的是蓝牙透传,但距离太短,后来换成HC-12无线串口模块,空旷环境下几百米没问题,而且完全透传,STM32那边只需要当普通串口用。
通信协议一定要自己定义,不要直接发裸数据。我用的协议很简单:
帧头(0xAA 0x55) + 数据长度(1字节) + 数据类型(1字节) + 数据(N字节) + 校验(1字节累加和)遥控器发送的指令包括左电机油门、右电机油门、控制模式切换、目标航点设置等。STM32收到控制指令后,判断模式,如果是手动模式就直接按遥控PWM控制,如果是自动模式就进入PID+定位控制逻辑。
串口空闲中断处理不定长数据是STM32开发里的高频需求。HAL库里面存在串口空闲中断的回调函数,在CubeMX里打开串口的空闲中断,然后重写HAL_UARTEx_RxEventCallback,它是接收不定长数据的标准姿势:
#define RX_BUFFER_SIZE 128 uint8_t rx_buf[RX_BUFFER_SIZE]; void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { // 处理收到的 Size 字节数据 Process_Remote_Data(rx_buf, Size); // 重新开始接收 HAL_UARTEx_ReceiveToIdle_IT(&huart1, rx_buf, RX_BUFFER_SIZE); } }CubeMX配置时,需要在USART1的NVIC设置中打开全局中断。这个回调在处理完数据后必须重新调用一次接收函数,不然就只收一次。
经验:不要把数据解析放在中断回调里做太多事,尤其是GPS解析那种长处理。我在回调里只是拷贝数据到环形缓冲区,然后设置一个标志位,主循环再解析。否则中断服务程序占用太长时间,会影响其他实时任务。
4.3 ADC多通道采样与电源电压监控
无人船在湖上跑,电池电压低了如果不及时返航,船可能直接停在湖中央,捞起来很费劲。所以一定要做一个电源电压监测。STM32的ADC多通道扫描+DMA采样是比较省CPU的方式。
我用的是ADC1的通道0和通道1,分别采集电池分压电压和电流检测放大后的电压。开启ADC连续扫描模式,DMA自动把数据搬到内存数组,主循环里直接读数组就行。CubeMX里配置ADC后,代码:
ADC_HandleTypeDef hadc1; DMA_HandleTypeDef hdma_adc1; uint16_t adc_values[2]; void ADC_DMA_Init(void) { __HAL_RCC_DMA2_CLK_ENABLE(); __HAL_RCC_ADC1_CLK_ENABLE(); hdma_adc1.Instance = DMA2_Channel1; HAL_DMA_Init(&hdma_adc1); __HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1); hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; hadc1.Init.ContinuousConvMode = ENABLE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; HAL_ADC_Init(&hadc1); // 配置两个通道,这里省略具体配置代码 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_values, 2); }电压计算要去查ADC的参考电压和分压电阻比例。比如电池12V经过两个10k电阻分压后,ADC输入约6V,而STM32的ADC输入最高3.3V,所以分压电阻要选大一些,比如用20k和6.8k,或者10k和3.3k,确保最高电压时ADC输入不超过3.3V。这个分压比在代码里做一个常数保存好,调试时用万用表校准一下。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| GPS无数据或长时间无定位 | 模块供电不足、天线朝下、串口波特率不匹配 | 给GPS独立稳压供电,天线朝上,确认波特率(默认9600) |
| GPS解析出现乱码 | 串口中断和主循环共用缓冲区没有加互斥 | 用环形缓冲区,或者关中断拷贝数据 |
| 电机不转但有PWM输出 | 电调需要先给低油门信号上电初始化 | 上电后延时几百毫秒,再输出PWM |
| 航向控制振荡严重 | PID的Kp过大或Kd过小 | 降低Kp,先不加Ki,加入Kd抑制 |
| 电源电压读数不准 | 分压电阻误差大 | 用万用表实测两点电压,校准分压比 |
| STM32程序跑飞 | 电机反电动势干扰或看门狗未喂 | 电机两端并联续流二极管,加独立电源,必要时开看门狗 |
| 串口接收丢字节 | 中断处理时间过长或有更高优先级中断打断 | 把耗时操作移出中断,降低中断优先级 |
| HAL_UARTEx_ReceiveToIdle_IT回调只触发一次 | 回调里没有重新调用接收函数 | 处理完数据后再次调用接收函数 |
5.2 调试与避坑经验
先说说最让我头疼的“Serial Port乱码”问题。GPS模块和HC-12都可能在同一串口线上出现电平不匹配,尤其是HC-12和GPS模块共用一个串口时,要确认是不是需要TTL电平,不要直接和RS232接。另外,GPS模块上电后首次定位很慢,在室内完全没有信号,必须拿到室外空旷的地方,把手或者板子尽量放高。有时候在水面上,GPS模块会因为船体晃动导致信号不稳定,建议加一个外部有源天线,放在船顶。
还有一个典型的“程序真的没法定位”的问题,不是GPS信号,而是STM32的Keil或者STM32CubeIDE报“无法打开源文件stdio.h”。这种一般是编译器头文件路径没设置好,需要在工程配置里把标准库头文件路径加上,或者检查是不是把MicroLIB开起来了。类似的坑我不止踩过一次,尤其是从别人那里拷贝工程的时候,编译环境不一致就会炸。
调试PID参数时,千万别直接下水试。我强烈建议先在“干跑”状态下调:把船放在支架上,让电机空转,通过上位机发送目标航向,看桨叶转速变化方向是否正确。然后再拿到水面做低速测试。每一步只改一个参数,记录下当前参数和现象。比如Kp=1.2时船摆动幅度大,Kp=0.5时响应太慢,记录下来就能画出一条响应曲线,比盲目试错高效得多。
最后再分享一个小技巧:给船上加一个简单的LED状态灯,用不同的闪烁频率表示不同状态。比如GPS没定位时慢闪,定位成功时常亮,自动模式时快闪。在水面上调试时看不清串口打印的内容,LED状态灯是最直接的反馈,能省下大量来回搬船的时间。
这个项目后续其实还有很多可以扩展的地方:比如加入避障模块、声呐/超声波测距、OpenMV摄像头识别浮标、把控制算法移植到FreeRTOS多任务系统,甚至加上远程图传做视觉巡航。如果你也在做类似的STM32无人船,建议先从手动遥控和PID航向保持做起,一步一个脚印,别一上来就想全自动,那样出了问题根本不知道是定位的问题、控制的问题还是通信的问题。稳扎稳打,上岸那天不会太远。
本文还有配套的精品资源,点击获取