简介:鲲鹏是一套基于Arduino IDE开发的双旋翼无人机完整开源方案,飞控采用两颗ESP32芯片,兼顾Wi-Fi/蓝牙通信与多核算力,适合无人机爱好者、嵌入式开发者及机器人方向学生。资源覆盖硬件设计、嵌入式源码到装配模型全链路:共95个文件,含28个h与26个cpp源码、15个STL三维打印模型、5个pcblib及多个PCB工程文件,另有物料清单Excel和README说明,压缩包仅18.01MB。功能上除基础飞行控制外,还具备人脸/颜色识别与二维码检测等AI视觉能力,并兼容ROS、DJI Mobile SDK、STM32等扩展平台。包内主板与扩展板源码分离,PCB工程可直接查看修改,STL模型便于二次打印,配合详细文档和分步教程,可大幅缩短从原理理解到整机组装调试的周期。已有112人学习,对想要入门无人机飞控或研究双旋翼构型的开发者具有较高参考价值。
1. 双旋翼无人机的控制难点与两颗 ESP32 的分工逻辑
双旋翼无人机在气动结构上和四旋翼完全不在一个难度级。四旋翼靠四个转速独立的螺旋桨就能同时产生升力和三轴控制力矩,但双旋翼只有两个推力点,静态悬停情况下系统是欠驱动的,必须给电机加倾转舵机,让推力矢量变成控制变量。飞控计算的内容其实不算复杂,难点在于实时性:传感器读取、姿态解算、PID计算和PWM刷新必须在一个紧凑的循环里完成,稍有抖动就会表现为明显的机械振荡。用 Arduino IDE 配合两颗 ESP32 做飞控芯片,是目前 DIY 社区里比较容易复制的一条路线。一颗 ESP32 专跑陀螺仪读数、互补滤波和电机/舵机输出,另一颗负责无线遥控、日志和 OTA 升级。这样既能避免 WiFi 协议栈抢占实时任务,也能把调参和飞控逻辑分离开,适合从固定布局的练习机开始改到自己控制算法的工程师。
2. 用 Arduino IDE 搭建双 ESP32 飞控的最小工程环境
2.1 为什么选 Arduino IDE 而不是 ESP-IDF
Arduino IDE 在 ESP32 开发里经常被低估,尤其是做飞控这种对时序敏感的场景。ESP-IDF 功能完整,但工程组织、编译系统和分区管理对刚接触飞控的人来说成本偏高;Arduino IDE 的优势在于库生态成熟,MPU6050、舵机库、ArduinoOTA 这些组件都有现成封装,两个小时能把一版可手动解锁的电机驱动跑起来。
配置时先在 IDE 的“首选项”里把 ESP32 板卡索引加进去。这个地址是 Espressif 官方维护的,之后在“开发板管理器”中搜索 esp32 就能安装。常见做法是把下面这段填进“附加开发板管理器地址”:
https://espressif.github.io/arduino-esp32/package_esp32_index.json安装完成后选择 “ESP32 Dev Module”,默认 Flash 大小选 4MB。要注意的是,如果打算在后面做 OTA 升级,这里不要选 “No OTA”,否则编译出来的固件没有 OTA 分区。
如果只是验证传感器读取和协议状态机,用 Wokwi 仿真平台 Arduino 模式也能把逻辑先跑通,再上真实开发板。真正的电机 PWM 刷新必须依赖 ESP32 的 LEDC 外设,这部分不能靠仿真,但协议和混合算法可以。很多人会用 VSCode 替代 Arduino 编辑器来做代码管理和 Git 协作,这不影响本工程结构,Arduino 核心依然在底层。
2.2 引脚分配与最小硬件接线表
双旋翼无人机的最小硬件包括:一片 IMU、两个电调、两个倾转舵机、两个 ESP32 开发板。IMU 一般是 MPU6050 或 ICM 系列,接 I2C;电调接 LEDC PWM 引脚;倾转舵机也走 LEDC,但信号频率统一按 50Hz 处理。
以下是我常用的引脚分配。飞控 ESP32 负责传感器和控制输出,通信 ESP32 通过 UART2 与飞控交换数据:
| 飞控 ESP32 引脚 | 目标设备 | 说明 |
|---|---|---|
| GPIO21 | IMU SDA | I2C 数据线,可加上拉电阻 |
| GPIO22 | IMU SCL | I2C 时钟线 |
| GPIO13 | 左侧 ESC 信号线 | 50Hz / 16bit PWM |
| GPIO14 | 右侧 ESC 信号线 | 50Hz / 16bit PWM |
| GPIO27 | 左倾转舵机信号线 | 舵机中位约 1.5ms |
| GPIO26 | 右倾转舵机信号线 | 舵机中位约 1.5ms |
| GPIO16 | 通信 ESP32 RX | UART2 数据交叉连接 |
| GPIO17 | 通信 ESP32 TX | UART2 数据交叉连接 |
接线时最重要的一件事是共地。两个 ESP32、电调、IMU 的 GND 必须连在一起,否则 UART 和 I2C 会出现偶发乱码。电调供电部分不要直接从 ESP32 的 VIN 取大电流,最好用独立 BEC 或电池直供。
2.3 LEDC 初始化、PWM 通道和电调校准
ESP32 的 analogWrite 和 Arduino Uno 不一样,它走的是 LEDC 通道,需要先 setup 再 attachPin。统一用 50Hz 频率和 16bit 分辨率,能让电调和舵机都工作在标准脉宽习惯下:1ms 油门对应 3277,2ms 油门对应 6553,1.5ms 舵机中位对应 4915。
#include <Arduino.h> #define ESC_LEFT_PIN 13 #define ESC_RIGHT_PIN 14 #define SERVO_LEFT_PIN 27 #define SERVO_RIGHT_PIN 26 #define PWM_FREQUENCY 50 #define PWM_RESOLUTION 16 #define PWM_MIN 3277 // 1ms脉宽,电调关断 #define PWM_MAX 6553 // 2ms脉宽,电调最大油门 #define SERVO_MID 4915 // 1.5ms脉宽 void setupPwmChannels() { ledcSetup(0, PWM_FREQUENCY, PWM_RESOLUTION); ledcAttachPin(ESC_LEFT_PIN, 0); ledcSetup(1, PWM_FREQUENCY, PWM_RESOLUTION); ledcAttachPin(ESC_RIGHT_PIN, 1); ledcSetup(2, PWM_FREQUENCY, PWM_RESOLUTION); ledcAttachPin(SERVO_LEFT_PIN, 2); ledcSetup(3, PWM_FREQUENCY, PWM_RESOLUTION); ledcAttachPin(SERVO_RIGHT_PIN, 3); ledcWrite(0, PWM_MIN); ledcWrite(1, PWM_MIN); ledcWrite(2, SERVO_MID); ledcWrite(3, SERVO_MID); }这段代码里,LEDC 通道编号是 0 到 3,每个通道独立对应一个定时器模块。50Hz 是电调初始化最稳的参数,很多 ESC 在 500Hz 下校准不一定识别,50Hz 反而避免误启动。16bit 分辨率意味着每微秒脉宽大约对应 3.28 步,足够平滑。
2.4 用按键进入校准模式而不是上电强行校准
电调校准常见做法是“油门上电,听到两次提示音后拉低油门”。这个操作在飞控上必须做成显式流程,否则每次上电都可能进入错误状态。我一般把校准逻辑挂在 BOOT 按键上:按住 BOOT 上电,飞控进入校准流程,松开后才允许输出油门。
bool armed = false; bool calibrateMode = false; void checkBootButton() { // ESP32-C3 等部分开发板的 BOOT 引脚为 GPIO9 pinMode(0, INPUT_PULLUP); if (digitalRead(0) == LOW) { calibrateMode = true; } } void calibrateESC() { if (!calibrateMode) return; ledcWrite(0, PWM_MAX); ledcWrite(1, PWM_MAX); delay(2000); ledcWrite(0, PWM_MIN); ledcWrite(1, PWM_MIN); delay(2000); calibrateMode = false; armed = true; }校准模式必须放在螺旋桨拆除之后执行。参数上,延迟 2000ms 是为了匹配常见 BLHeli 电调的进入校准和存储时间;如果电调型号不同,第一个延时可能需要调长到 3000ms。这个流程本质上把“人为操作”转换成“可控状态机”,后续加解锁条件也更安全。
3. 姿态解算与双旋翼推力矢量混控:PID 参数怎么定
3.1 一阶互补滤波:先让姿态数据能信
飞控最怕的不是算法复杂,而是传感器数据不可信。MPU6050 的陀螺仪短时间积分很准,但长时间会漂移;加速度计没有漂移,却容易受飞行振动和线性加速度干扰。两块数据合起来,最简单可靠的办法是一阶互补滤波。
#include <Wire.h> #include "MPU6050.h" MPU6050 mpu; int16_t ax, ay, az, gx, gy, gz; float roll = 0.0f, pitch = 0.0f; const float alpha = 0.97; void readIMU(float dt) { mpu.getMotion6(&ax, &ay, &az, &gx, &gy, &gz); float accelRoll = atan2(ay, az) * 180.0 / PI; float accelPitch = atan2(-ax, sqrt(ay * ay + az * az)) * 180.0 / PI; float gyroRoll = gx / 131.0; float gyroPitch = gy / 131.0; roll = alpha * (roll + gyroRoll * dt) + (1 - alpha) * accelRoll; pitch = alpha * (pitch + gyroPitch * dt) + (1 - alpha) * accelPitch; }代码里gx / 131.0对应 MPU6050 默认量程 ±250dps,数字量和角速度之间是 131 LSB/dps。alpha决定陀螺仪和加速度计的信任比例,0.97 表示陀螺仪权重高,适合大多数室内场景。如果机架振动大,把 alpha 降到 0.92 会让姿态更平滑,但响应会慢一点。
3.2 双旋翼的混控模型:电机转速与倾转舵机的映射
双旋翼和四旋翼最大的差别在于,油门不能只映射到两个电机,还需要同时参与滚转和偏航控制。我常用的是左右对称双倾转布局:两个电机分别装在左右倾转舵机上,舵机同步前倾产生俯仰,差动倾转产生偏航,左右电机差速产生滚转。
#define ROLL_MIX 0.8 #define PITCH_MIX 0.6 #define YAW_MIX 0.3 void updateMixer(float throttle, float rollPid, float pitchPid, float yawPid) { // 电机差速控制滚转 float leftMotor = throttle + rollPid * ROLL_MIX; float rightMotor = throttle - rollPid * ROLL_MIX; // 舵机同向控制俯仰,差动控制偏航 float leftServo = SERVO_MID - pitchPid * PITCH_MIX - yawPid * YAW_MIX; float rightServo = SERVO_MID - pitchPid * PITCH_MIX + yawPid * YAW_MIX; ledcWrite(0, constrain(leftMotor, PWM_MIN, PWM_MAX)); ledcWrite(1, constrain(rightMotor, PWM_MIN, PWM_MAX)); ledcWrite(2, constrain(leftServo, 2700, 7100)); ledcWrite(3, constrain(rightServo, 2700, 7100)); }这里的ROLL_MIX、PITCH_MIX、YAW_MIX不是固定常量,要按实际力臂测量。左右电机距离越宽,差速产生的滚转力矩越大,ROLL_MIX就可以取得越小。舵机行程受限,YAW_MIX一般不超过推力通道的一半,否则会在满油门时出现舵机饱和。PID 输出会直接叠加在 PWM 值上,所以单位要和 PWM 步进一致。
注意,这个混控模型是简化的静态映射。真实飞行中还应该加入电机动态延迟补偿和舵机速度限制,否则高频的偏航 PID 会被慢速舵机拖成振荡。
3.3 PID 初始参数表与调试顺序
PID 参数不要硬抄,但初始值可以从下面这套开始。控制周期建议固定在 250Hz 到 400Hz,dt 必须是实际循环周期,不能简单写 delay(5)。
| 控制环 | P 初值 | I 初值 | D 初值 | 说明 |
|---|---|---|---|---|
| 滚转 | 4.0 | 0.03 | 0.1 | 电机差速响应快,P 小一点 |
| 俯仰 | 4.0 | 0.03 | 0.2 | 舵机同步倾转有延迟,D 适当增加 |
| 偏航 | 3.0 | 0.02 | 0.0 | 舵机行程有限,I 必须限幅 |
PID 计算时最重要的工作不是写公式,而是做好积分限幅和微分限幅。下面是一个滚转通道的示例循环:
float rollIntegrated = 0.0f; float lastRollErr = 0.0f; void runPidLoop(float dt) { readIMU(dt); float rollErr = 0.0f - roll; rollIntegrated += rollErr * dt; rollIntegrated = constrain(rollIntegrated, -10.0f, 10.0f); float dTerm = (rollErr - lastRollErr) / dt; lastRollErr = rollErr; float rollPid = 4.0f * rollErr + 0.03f * rollIntegrated + 0.1f * dTerm; updateMixer(throttleCmd, rollPid, pitchPid, yawPid); }积分限幅取 ±10,是为了防止飞机侧倾太久后积分累积过大,导致一侧电机直接满油门。D 项用(err - lastErr) / dt计算,会放大传感器噪声,建议后续加上一阶低通滤波。调试顺序上,先调 P 到机身能回正但不抖,然后再加 D 抑制过冲,最后加 I 消除稳态偏差。P 如果已经让机身震荡,I 加了只会更糟。
4. 双 ESP32 的串口协作、日志通道和 OTA 升级
4.1 分工边界:飞控芯片只管飞,通信芯片管其他
一颗 ESP32 理论上能同时跑飞控和 WiFi,但实际开发中很痛苦。WiFi 协议栈和 ArduinoOTA 的回调会在后台占用 CPU,TCP 重传或者蓝牙扫描可能导致控制循环阻塞几毫秒。几毫秒对普通传感器任务无所谓,对双旋翼飞控就是一次明显抖动。
所以两颗 ESP32 的边界是:飞控芯片运行 IMU 读取、姿态解算和混控输出,代码里尽量关闭 WiFi 和蓝牙;通信芯片运行航模遥控接收机解析、ArduinoOTA 升级、串口日志转发。两颗芯片之间只用一对 UART 线连接,数据包足够小时,不会干扰控制时序。
4.2 自定义 UART 协议:从机到主机传姿态,主机到从机传遥控
UART 通信最容易出问题的点是“粘包”和“错位”。常见做法是定义一个固定帧头加校验和。下表是通信 ESP32 发给飞控 ESP32 的遥控通道帧:
| 偏移 | 长度 | 内容 |
|---|---|---|
| 0 | 2 | 帧头 0xAA 0x55 |
| 2 | 1 | 消息 ID,0x01 为遥控帧 |
| 3 | 1 | 负载长度,遥控帧为 4 |
| 4 | 2 | 油门通道,0-2000 |
| 6 | 2 | 模式通道,0-2000 |
| 8 | 1 | 校验和,累加后取低 8 位 |
飞控端的解析可以用一个简单的状态机,避免每次只读一个字节就处理:
uint8_t rxIndex = 0; uint8_t rxBuffer[16]; uint16_t throttleChan = 1000; uint16_t modeChan = 1000; void handleUartByte(byte b) { if (rxIndex == 0 && b != 0xAA) return; if (rxIndex == 1 && b != 0x55) { rxIndex = 0; return; } rxBuffer[rxIndex++] = b; if (rxIndex < 9) return; uint8_t sum = 0; for (uint8_t i = 0; i < 8; i++) sum += rxBuffer[i]; if (sum != rxBuffer[8]) { rxIndex = 0; return; } if (rxBuffer[2] == 0x01) { throttleChan = (rxBuffer[4] << 8) | rxBuffer[5]; modeChan = (rxBuffer[6] << 8) | rxBuffer[7]; } rxIndex = 0; }接收侧的关键参数是波特率。飞控串口长时间高负载最容易丢字节,这里建议用 115200 而不是 921600。921600 速率虽然快,但 ESP32 的 UART 中断处理和 WiFi 共存时,缓冲区溢出概率明显上升。
飞控回传姿态给通信芯片时,可以直接把整数度放大 100 倍再传,避免浮点数协议带来的校验麻烦:
void sendAttitude(float roll, float pitch) { int16_t rollInt = (int16_t)(roll * 100.0f); int16_t pitchInt = (int16_t)(pitch * 100.0f); uint8_t out[9]; out[0] = 0xAA; out[1] = 0x55; out[2] = 0x02; out[3] = 4; out[4] = (uint8_t)(pitchInt >> 8); out[5] = (uint8_t)(pitchInt & 0xFF); out[6] = (uint8_t)(rollInt >> 8); out[7] = (uint8_t)(rollInt & 0xFF); out[8] = (uint8_t)(out[0] + out[1] + out[2] + out[3] + out[4] + out[5] + out[6] + out[7]); Serial2.write(out, 9); }浮点数即使在相同架构下,也尽量不要直接跨协议传输。把角度放大为整数后,0.01 度精度对调参已经足够,而且字节序可控,后续移植到其他平台也不用担心浮点格式差异。
4.3 ESP32 OTA 升级怎么部署在通信芯片上
双旋翼系统里,OTA 升级应该放在通信 ESP32 上,飞控 ESP32 只保留一个固定监控端口。原因是飞控固件一旦升级失败,飞机可能完全失控;通信芯片升级失败最多失去远程调参能力。
通信芯片的 OTA 升级可以直接用 ArduinoOTA,代码很简洁:
#include <WiFi.h> #include <ArduinoOTA.h> const char* ssid = "FC_DEBUG"; const char* password = "fc-debug-net"; void setup() { WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); ArduinoOTA.setHostname("kunkun-fc"); ArduinoOTA.setPassword("ota-pass"); ArduinoOTA.begin(); } void loop() { ArduinoOTA.handle(); }使用 OTA 时需要一块同时包含 OTA 分区和至少 2MB 应用分区的 Flash。烧录前的分区表如果选择 “Default 4MB with spiffs”,OTA 功能可能无法使用;在 Arduino IDE 的 Tools->Partition Scheme 里选择带 OTA 的选项即可。OTA 过程中通信芯片会短暂断开串口转发,飞控芯片不受影响,所以飞机理论上还能保持姿态,但这只适合悬停测试,不建议在动力电池还接着的状态下升级飞控周边逻辑。
5. 悬停验证与调参捷径:串口绘图仪、吊绳测试和急停开关
双旋翼悬停前的验证,不要直接用手托飞机去试。第一步要先把飞机绑在一根吊绳上,限制水平和垂直位移,然后让油门慢慢推上去。这样能看出滚转和俯仰有没有明显发散,同时不会瞬间摔坏机架。吊绳长度建议在机架半径的 2 倍左右,太短会影响真实振动,太长又起不到保护作用。
调参时我习惯把姿态输出直接打在串口绘图仪里,而不是只看串口终端数值。Arduino IDE 的 Serial Plotter 能实时画出多条曲线,用来观察 roll 和 pitch 是否收敛非常直观。唯一的技巧是每行只输出一对数据,并保持固定的打印周期:
Serial.print("roll:"); Serial.print(roll); Serial.print(",pitch:"); Serial.println(pitch);打印波特率要和串口监视器一致,同时循环里不要加 delay,要按任务调度的真实 dt 来运行。如果发现 roll 曲线高频抖动,先用 D 项低通滤波或降低 PID 频率,不要急着加大 P。P 太大产生的抖动能被听到,那是电机转速变化周期过快,此时应该减 P 而不是调 I。
还有一个容易被忽略的问题:ESP32 在复位瞬间,所有引脚输出会被释放为高阻态,部分电调会把这种电平解释成油门信号波动,造成电机瞬间启动。所以飞机不能只靠代码里的急停标志位,必须在供电线上加一个实体总闸。代码里的急停可以作为第二层保护:
if (channelMode > 1800 && !armed) { armed = true; } if (channelMode < 1200 || !armed) { ledcWrite(0, PWM_MIN); ledcWrite(1, PWM_MIN); ledcWrite(2, SERVO_MID); ledcWrite(3, SERVO_MID); }这段逻辑里,遥控器模式通道低于 1200 时强制进入油门最低和舵机中位状态。即使 PID 输出已经堆积了很大的积分量,只要急停条件成立,输出也会被直接覆盖。调参时可以把模式通道中位设成急停,低位设成悬停解锁,高位留给定点模式。这样每次试飞前只需要把遥控拨杆拨到底,再拉回中位,就能完成一次干净的自检和上锁循环。
本文还有配套的精品资源,点击获取