简介:这是一份面向嵌入式开发与无人机控制学习者的C语言开源项目源码包,适合作为飞控入门、课程设计、毕业设计或二次开发的参考工程。内容围绕微控制器展开,包含电机、GPS与遥控模块的硬件控制,加速度计、陀螺仪、气压计的数据采集,以及基于PID的姿态、速度与高度控制,并涉及图像处理与无线通信模块,可帮助读者梳理从底层驱动到飞控任务调度的实现脉络。压缩包共1740个文件,以278个.h头文件和258个.c源文件为主,辅以launch配置、Keil/IAR/TrueSTUDIO工程文件、链接脚本、DFU固件及PDF说明文档,整体约19.23MB,目录按模块组织,便于检索、对照与移植。已有870人学习下载,配套设计与说明文档较完整,适合用来理解传感器数据采集、控制算法调试与工程结构组织,具有较高的参考和实用价值。
1. 从“能转起来”到“能飞稳”:一套可拆解的 C 语言嵌入式无人机控制工程
很多人接手 C 语言嵌入式无人机控制项目,第一反应是先把电机转起来,结果真到悬停时姿态发散,连是陀螺零偏没校准还是 PID 参数给大了都说不清。这套开源工程的价值不在“能让四轴离地”,而在于它把传感器采样、姿态解算、串级 PID、电机混控、状态机、地面站通信整条链路写成可读的 C 代码,并配了设计文档说明每个参数为什么取这个值。它适合三类人:做毕设课设、需要一套经得起追问细节的完整飞控的同学;写过单片机、想弄清飞控实时性怎么保证的嵌入式开发者;拿到现成固件想改控制律做二次开发的人。下面按需求、架构、算法、调度通信、避坑、验收的顺序拆到能照着搭。
2. 总体架构:从控制周期倒推硬件选型与四层模块划分
2.1 先算控制周期,再定主频和外设
选型不该从“手头有哪块板子”开始,而该从控制周期开始倒推。四轴的姿态环带宽通常在 10~20Hz,按工程惯例采样率取带宽的 20 倍以上,陀螺仪就得跑到 500Hz~1kHz。这套工程把 IMU 采样定在 1kHz、控制环定在 500Hz,两个数字不是拍脑袋来的:采样太低,角速度环相位滞后会把抖动放大;采样太高,I2C 总线先撑不住。
算一下 I2C 的账。读一次六轴原始数据要写 1 字节寄存器地址、读 12~14 字节,按每字节 9 个时钟位(含 ACK)算,400kHz 下传输时间约 15×9/400000 ≈ 340μs,加上起始、停止、器件地址重发和驱动层开销,实测在 400~450μs。若按 1kHz 采样,总线占用率接近 45%,一旦碰上中断抢占或总线仲裁,丢帧就是必然。通常的做法是降到 500Hz 采样并打开 DMA,或者干脆换 SPI(ICM20602 的 SPI 时钟可到 10MHz,同样数据量只要几十微秒)。工程里把这条写进了设计文档的“总线预算”一节,属于容易被忽略但直接影响飞行手感的部分。
主频方面,一次完整的控制周期要跑:六轴数据换算、互补滤波、两到三级 PID、四路混控、限幅和保护判断,浮点运算量在几千次量级。带硬件 FPU 的 Cortex-M4 跑 168MHz,实测占用不到 10%,剩下的余量留给日志和通信。RAM 也不是无所谓:姿态状态、PID 历史量、参数表、环形日志缓冲加起来 20KB 上下,选 128KB Flash / 64KB RAM 的型号能留出二次开发空间;如果打算上 RTOS 或加气压计定高,再往上留一档。
2.2 四层模块划分与数据流
工程按四层切:BSP(时钟、GPIO、定时器、串口、I2C/SPI)、驱动层(各传感器与电调输出)、控制层(姿态解算、PID、混控)、应用层(状态机、遥控解析、地面站、参数管理)。分层的目的很实际——换传感器时只动驱动层,换控制律时只动控制层,上层逻辑一行不改。
| 层级 | 主要文件 | 职责 | 调用周期 |
|---|---|---|---|
| BSP | bsp_*.c | 时钟树、DMA、中断优先级、看门狗 | 初始化一次 |
| 驱动层 | drv_icm20602.c、drv_pwm.c、drv_uart.c | 原始数据采集、量程换算、输出占空比 | 1kHz / 500Hz |
| 控制层 | ctrl_ahrs.c、ctrl_pid.c、ctrl_mixer.c | 姿态角、串级 PID、电机混控 | 500Hz |
| 应用层 | app_fsm.c、app_rc.c、app_mav.c | 状态机、遥控、地面站、参数存取 | 200Hz / 事件触发 |
数据流是单向的:IMU 原始值进姿态解算得到 roll/pitch/yaw,与遥控(或地面站)给的期望角做差,外环角度 PID 输出期望角速度,内环角速度 PID 输出三轴修正量,进混控矩阵叠加到油门基准上,最后变成四路 PWM。单向数据流的好处是排查时可以从任意一环往回切:姿态不对先看解算,解算对但响应慢就看外环,外环正常但机身高频抖就看内环和滤波。这套工程在设计文档里画了完整的信号流图和每一环的单位(度、度每秒、微秒),单位不统一是新手最容易埋的雷。
2.3 目录结构与构建配置
目录按层平铺,不用多级嵌套,交叉编译时路径短、心智负担小:
firmware/ ├── bsp/ # 启动文件、链接脚本、时钟与外设初始化 ├── drv/ # icm20602 / ms5611 / pwm / uart / flash ├── ctrl/ # ahrs / pid / mixer / filter ├── app/ # fsm / rc / mavlink / param / log ├── math/ # 向量、矩阵、快速三角函数 ├── docs/ # 设计说明、参数表、测试记录 └── Makefile构建用 Makefile 而不是 IDE 工程,原因是毕设答辩或团队协作时,别人拿到压缩包一条make就能出固件,不用装指定的 IDE 版本。关键片段:
CROSS = arm-none-eabi- CC = $(CROSS)gcc # 必须和芯片实际内核匹配,FPU 选项写错会退化成软件浮点,控制周期直接翻倍 CPU = -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard INC = -Ibsp -Idrv -Ictrl -Iapp -Imath # 用 -O2 而不是 -O3:-O3 会重排浮点中间量,台架整定好的参数换优化等级后手感会变 CFLAGS = $(CPU) -O2 -g3 -Wall -Wextra -ffunction-sections -fdata-sections $(INC) LDFLAGS = $(CPU) -Tbsp/link/stm32_flash.ld -Wl,--gc-sections -Wl,-Map=build/fw.map SRC = $(wildcard bsp/*.c drv/*.c ctrl/*.c app/*.c math/*.c)-ffunction-sections配合--gc-sections能把没调用的调试函数从固件里剔掉,链接时生成的.map文件在排查“Flash 怎么突然涨了 8KB”时特别有用。-g3保留调试信息,配合 OpenOCD 可以在线看变量,调 PID 时比打印快得多。
3. 姿态解算与串级 PID:飞控最核心的两段代码
3.1 采样、量程换算与零偏校准
原始数据先换算成物理量。ICM20602 陀螺仪设 ±2000dps 时灵敏度 16.4 LSB/(°/s),加速度计设 ±16g 时 2048 LSB/g,换算就是除法,但量程不能乱设:小量程分辨率高,可一旦机动时超量程,数据会直接截断成满量程,姿态瞬间错乱。工程默认 ±2000dps / ±8g,兼顾分辨率与余量。
比量程更容易翻车的是零偏。MEMS 陀螺上电零偏可达 1~3°/s,不校准的话积分十秒就是十几度的漂移。做法是上电后静止采 2000 个样本取平均,同时算方差,方差过大就判定“上电时飞机在动”,状态机拒绝解锁:
/* 上电零偏校准:静止采样求均值,同时用方差判断静止条件 */ int gyro_calibrate(icm20602_t *dev, vec3f_t *bias) { vec3f_t sum = {0}, sq = {0}; for (int i = 0; i < 2000; i++) { vec3f_t g; if (icm20602_read_gyro(dev, &g) != 0) return -1; sum.x += g.x; sum.y += g.y; sum.z += g.z; sq.x += g.x * g.x; sq.y += g.y * g.y; sq.z += g.z * g.z; delay_ms(1); /* 采样间隔均匀,避免均值被突发拉偏 */ } bias->x = sum.x / 2000.0f; bias->y = sum.y / 2000.0f; bias->z = sum.z / 2000.0f; /* 方差超过阈值说明上电时飞机被碰过,零偏不可信,必须重新校准 */ float var = sq.x / 2000.0f - bias->x * bias->x; return (var > 4.0f) ? -2 : 0; }参数说明:2000 个样本在 1kHz 下约 2 秒,正好是上电自检的等待窗口;方差阈值 4.0 对应标准差 2°/s,超过就认为没放稳。返回值分三档,状态机据此决定是继续自检还是报错闪灯。
3.2 互补滤波:为什么这套工程没用卡尔曼
姿态解算选了互补滤波而不是卡尔曼,理由不是“简单”,而是可解释性和计算量。互补滤波只有一阶,参数就是一个系数 α,等价于“相信陀螺的高频、相信加速度计的低频”,出问题时能一眼看出是哪个源坏了;卡尔曼要调 Q、R 矩阵,在飞控这种强振动环境下,模型噪声参数和真实振动对不上时反而更难定位。对毕设和课设来说,能被追问“α 和时间常数什么关系”比堆一个调不出来的滤波器更有价值。
/* 互补滤波:陀螺积分高频可信,加速度计低频可信,alpha 由时间常数反推 */ void ahrs_update(ahrs_t *a, const vec3f_t *gyro_dps, const vec3f_t *acc_g, float dt) { float norm = sqrtf(acc_g->x*acc_g->x + acc_g->y*acc_g->y + acc_g->z*acc_g->z); /* 模长偏离 1g 超过 15% 说明在机动,加速度计测的是合力不是重力,弃用 */ if (fabsf(norm - 1.0f) < 0.15f) { float roll_acc = atan2f(acc_g->y, acc_g->z) * RAD2DEG; float pitch_acc = atan2f(-acc_g->x, sqrtf(acc_g->y*acc_g->y + acc_g->z*acc_g->z)) * RAD2DEG; a->roll = a->alpha * (a->roll + gyro_dps->x * dt) + (1.0f - a->alpha) * roll_acc; a->pitch = a->alpha * (a->pitch + gyro_dps->y * dt) + (1.0f - a->alpha) * pitch_acc; } else { a->roll += gyro_dps->x * dt; /* 机动时纯陀螺积分,宁可慢漂也不吃错数据 */ a->pitch += gyro_dps->y * dt; } a->yaw += gyro_dps->z * dt; /* 无磁力计时 yaw 只能靠陀螺,会缓慢漂移 */ }α 和时间常数的关系是 τ = α·dt/(1−α)。dt 取 0.002s(500Hz)时,α=0.98 对应 τ≈0.1s,意味着加速度计大约用 0.1 秒把陀螺的漂移拉回来,这个量级对四轴刚好:再小会把机体振动灌进姿态角,表现为悬停时机身高频轻微抖动;再大会让姿态回正迟钝,松杆后机身回平慢半拍。工程文档里给了一张 α 与 τ 的对照表,改参数前先查表,比盲试快。
3.3 串级 PID:内环 500Hz、外环 250Hz 的分工
为什么不直接用单环角度 PID?因为单环对扰动响应慢,风一吹机身先偏,PID 才开始纠,纠的过程又容易过冲。串级把任务拆开:内环角速度环频率高,专门压制突发的角速度扰动;外环角度环频率低,负责跟踪期望姿态。工程里内环 500Hz(每个控制周期都跑),外环 250Hz(每两个周期跑一次),既保证内环带宽,又不让外环把噪声放大。
typedef struct { float kp, ki, kd; float i_limit; /* 积分限幅,直接决定抗饱和能力 */ float out_limit; /* 输出限幅,防止单轴指令过大导致混控饱和 */ float integ; float prev_meas; /* 微分先行用:保存上次测量值 */ float d_filt; /* 微分项低通,抑制陀螺噪声被放大 */ } pid_t; float pid_update(pid_t *p, float sp, float meas, float dt) { float err = sp - meas; p->integ += err * dt; if (p->integ > p->i_limit) p->integ = p->i_limit; /* 抗积分饱和 */ if (p->integ < -p->i_limit) p->integ = -p->i_limit; /* 微分先行:对测量值微分而不是对误差微分,遥控打杆时不会产生微分冲击 */ float d_raw = -(meas - p->prev_meas) / dt; p->prev_meas = meas; p->d_filt += 0.05f * (d_raw - p->d_filt); float out = p->kp * err + p->ki * p->integ + p->kd * p->d_filt; if (out > p->out_limit) out = p->out_limit; if (out < -p->out_limit) out = -p->out_limit; return out; }几个参数的实际取法:内环 kp 决定“跟杆”的硬度,偏小表现为打杆后机身慢悠悠转过去,偏大表现为高频抖;ki 用来消除稳态误差(比如重心偏一侧导致的持续小角度倾斜),但 i_limit 必须给,否则地面放置时姿态误差一直累积,一起飞就是瞬间大角度翻转。kd 在这套工程里只开很小一点,因为陀螺本身噪声大,微分会把噪声放大成电机异响。微分先行这一手是血泪经验:早期版本用误差微分,遥控快速回中时飞机会“点头”,改成对测量值微分后现象消失。
3.4 混控矩阵与电调输出
三轴修正量要分配到四个电机上,X 型布局的混控矩阵直接写成代码,比查表快也更直观:
/* X 型布局,电机编号 M1 前右、M2 后右、M3 后左、M4 前左,均按对角同向 */ void mixer_x(const float cmd[3], float throttle, float out[4]) { float roll = cmd[0], pitch = cmd[1], yaw = cmd[2]; out[0] = throttle - roll + pitch - yaw; /* M1 */ out[1] = throttle - roll - pitch + yaw; /* M2 */ out[2] = throttle + roll - pitch - yaw; /* M3 */ out[3] = throttle + roll + pitch + yaw; /* M4 */ for (int i = 0; i < 4; i++) { if (out[i] < 0.0f) out[i] = 0.0f; /* 下限截断,防止反向驱动电调 */ if (out[i] > 1.0f) out[i] = 1.0f; } }符号方向必须和实际电机转向、桨叶正反一一对上,这是首飞前最该拿在手里核对的一步:随便改一个符号,起飞瞬间就是侧翻。工程文档里配了一张俯视图,标了电机序号、转向、桨叶型号,按图接线基本不会错。输出侧用 1000~2000μs 的标准 PWM,定时器预分频按 400Hz 帧率配置,占空比分辨率能到 1μs 级别,对四轴够用;要更细腻可以换 DShot,但那属于二次开发范畴,基础工程先把 PWM 走通。
4. 调度与通信:主循环时基、状态机与地面站协议
4.1 1ms 时基与时间片调度
这套工程走的是裸机时间片而不是 RTOS。原因很实际:任务数量固定、周期明确、没有阻塞式等待,裸机调度器几十行就能覆盖,且中断响应确定性比带 RTOS 的更好排查。1kHz 定时器中断里只置标志、不干活,重活全在主循环:
volatile uint32_t g_tick; volatile uint8_t g_f_imu, g_f_ctrl, g_f_comm; void TIM5_IRQHandler(void) /* 1ms 周期 */ { g_tick++; g_f_imu = 1; /* IMU 采样 1kHz */ if ((g_tick % 2) == 0) g_f_ctrl = 1; /* 控制环 500Hz,与 dt=0.002 对应 */ if ((g_tick % 5) == 0) g_f_comm = 1; /* 通信 200Hz,够地面站刷新 */ } int main(void) { bsp_init(); drv_init(); ctrl_init(); app_init(); for (;;) { if (g_f_imu) { g_f_imu = 0; imu_sample(&g_imu); } if (g_f_ctrl) { g_f_ctrl = 0; control_step(); } /* 姿态→PID→混控→PWM */ if (g_f_comm) { g_f_comm = 0; comm_poll(); log_tick(); } watchdog_feed(); /* 主循环活着才喂狗 */ } }关键在于每个任务的dt必须和实际周期严格对应:控制环跑 500Hz,PID 里的dt就得是 0.002,写成 0.001 相当于把积分和微分全放大一倍,台架上看着还行,一飞就振。工程里把dt定义成宏并在初始化时用1.0f / CTRL_HZ算出来,避免两处手改不一致。看门狗放在主循环末尾,任何一环卡死(典型是 I2C 阻塞)都会在几百毫秒内复位,这是最后一道后悔药。
4.2 飞行状态机与解锁保护
状态机负责把所有“能不能起飞”的判断收口,不允许散落在各模块里:
typedef enum { ST_INIT = 0, ST_SELF_TEST, ST_DISARMED, ST_ARMED, ST_FLYING, ST_FAULT } fsm_t;迁移条件写死在fsm_update()里:自检要过传感器在线、零偏校准成功、电池电压高于阈值三项;从 DISARMED 到 ARMED 要求油门在最低位且解锁开关动作到位(防止上电瞬间误触);进入 FLYING 后持续监控低电压、失控、姿态角超限,任一触发就进 FAULT,执行缓降或切定高。失控保护的判定用“遥控帧超时”——连续 50ms 没收到有效帧就判失控,这个阈值来自遥控器 20ms 帧周期的 2.5 倍,太短容易误判,太长飞机已经飞远了。
提示:解锁逻辑里一定要有“油门低位”这一条。很多首飞翻车不是控制律的问题,而是上电时油门杆没回底,飞机直接以某个转速冲出去。
4.3 自定义帧协议与 CRC 校验
地面站通信用自定义二进制帧而不是直接上完整 MAVLink,一是省 Flash,二是帧结构简单、便于在示波器和串口助手上看:
/* 帧格式: 0xAA 0x55 | len | seq | msg_id | payload[len] | crc16_lo crc16_hi */ typedef struct { uint8_t len; uint8_t seq; uint8_t msg_id; uint8_t payload[32]; } frame_t; int frame_parse(uint8_t byte, frame_t *out) { static uint8_t buf[40]; static uint8_t idx = 0; buf[idx++] = byte; if (idx < 2) return 0; if (buf[0] != 0xAA || buf[1] != 0x55) { idx = 0; return -1; } /* 帧头错,重置状态机 */ uint8_t need = 2 + 3 + buf[2] + 2; /* 头+len/seq/id+载荷+CRC */ if (idx < need) return 0; uint16_t crc = crc16_ccitt(buf, need - 2); uint16_t got = buf[need - 2] | (buf[need - 1] << 8); if (crc != got) { idx = 0; return -2; } /* CRC 错,整帧丢弃 */ out->len = buf[2]; out->seq = buf[3]; out->msg_id = buf[4]; memcpy(out->payload, &buf[5], out->len); idx = 0; return 1; }逐字节喂进解析器、用静态状态变量保存进度,好处是不需要缓冲区缓存整包,RAM 占用恒定。CRC 用 CCITT 查表法,200Hz 下开销可忽略。实际调试时最常见的现象是“能收到数据但一直报 CRC 错”,八成是发送端把 CRC 高低字节顺序写反,或者结构体对齐导致多发了填充字节——后者在协议里显式按字节序列化就能规避。
4.4 参数存储与在线调参
PID 参数、滤波系数、电压阈值这些需要掉电保存,做法是占用一个 Flash 扇区,写两份互为备份,每份带魔数和 CRC:
typedef struct { uint32_t magic; /* 固定 0x5A5AA5A5,用于识别是否为有效参数区 */ uint16_t ver; param_t prm; uint16_t crc; } param_blk_t;读的时候先比魔数再比 CRC,两份都坏就加载代码里的默认参数并点亮告警灯,绝不允许在参数可疑的情况下起飞。在线调参走地面站下发msg_id=0x10的帧,收到后先写 RAM 生效、再延迟 2 秒落 Flash,避免调参时反复擦写把扇区寿命耗掉。
5. 避坑与排查:五类最容易翻车的问题
5.1 I2C 读传感器偶发卡死
现象:飞着飞着姿态突然冻结,主循环还在跑但姿态角几秒不动,严重时看门狗复位。原因通常是 I2C 从机在传输中被干扰,SDA 被拉低不放,主机死等 ACK 又没有超时。解决办法是给每次读加超时(基于g_tick计数,超过 3ms 返回错误),并在驱动里加总线恢复:把 SCL 手动拉低 9 个时钟再发 STOP,让从机退出异常状态。这一步不做,后面所有关于滤波和 PID 的讨论都没意义。
5.2 姿态角在 ±90° 附近跳变
现象:俯仰拉到接近 90° 时,roll 和 yaw 突然乱跳,飞机像被踢了一脚。原因是用欧拉角描述姿态本身有万向锁,pitch 接近 90° 时 roll 与 yaw 失去区分度,反三角函数的符号还容易取错。解决办法是解算内部用四元数或旋转矩阵,只在输出给地面站时转成欧拉角;如果坚持用欧拉角,至少把 pitch 限幅在 ±80°,并在混控里对超限姿态做保护。普通航拍和教学场景很少飞到 90°,但一旦翻车就是炸机级别。
5.3 积分饱和导致起飞瞬间侧翻
现象:地面静置时间越长,起飞瞬间越容易猛地朝一侧翻。原因是地面放置时机身并不绝对水平,角度环误差持续存在,积分项一路累加到限幅值,解锁瞬间输出直接顶满。解决办法是三重保险:积分项限幅、解锁瞬间清零积分、以及“未离地前禁用积分”(用油门低于阈值判断)。这三条同时上,基本不会再有起跳翻车。
5.4 电调行程未校准与电机转向错误
现象:推油门四轴不升反降,或者悬停时持续自旋。原因是电调行程没校准(认为 1100μs 才是最低点),或电机转向、桨叶正反装错。解决办法是首飞前单独做电调校准(上电即最高油门、听到确认音后拉到最低),然后用无桨台架逐个确认转向,桨叶装机前对着文档的俯视图核对一遍。这一步没有任何技术含量,却占了首飞事故的相当比例。
5.5 电压跌落引起主控复位
现象:大油门时飞机突然失去动力,姿态记录在中途断掉,重启后又正常。原因是主控和电调共用一路电源,大电流时电压瞬间跌到 MCU 的复位阈值以下。解决办法是主控单独用一路 BEC 供电,输入端并足够的电容(按每安培 220μF 起步),固件里配低电压检测并在电压低于阈值时主动降油门。这类问题在台架上永远复现不了,只能靠电源设计和欠压保护兜底。
6. 验收与答辩:用三段式测试和一张数据表证明它真能飞
飞控这种东西,代码写完只算完成一半,剩下的一半是拿证据证明它稳定。我一般按三段式做验收,每段都有明确判据,不通过绝不进下一段。
| 阶段 | 测试内容 | 判据 | 需要的工具 |
|---|---|---|---|
| 台架(无桨) | 电机转向、PWM 波形、控制周期抖动 | 四路转向正确;脉宽误差 <5μs;周期抖动 <50μs | 逻辑分析仪、示波器 |
| 系留(限位架) | 姿态跟踪、阶跃响应、失控保护 | 打杆后姿态跟随无明显滞后;阶跃超调 <20%,调节时间 <0.3s | 地面站曲线、限位架 |
| 限高悬停 | 悬停稳定性、低电压返航、遥控超时 | 悬停 30 秒 roll/pitch 波动在 ±3° 内;拔掉遥控器 50ms 内进保护 | 秒表、电压表、日志 |
台架阶段最容易被跳过,但它能挡掉一半的低级错误。用逻辑分析仪同时抓四路 PWM,一边看波形一边打杆,能立刻发现混控符号写反或者某一路定时器通道配错。系留阶段重点看阶跃响应曲线,地面站把期望角和实际角画在同一张图上,超调和调节时间一眼可见,比“感觉还行”靠谱得多。日志一定要开环形缓冲,落地后能导出最近 30 秒的完整数据,出了异常不用靠回忆。
答辩环节真正会被追问的,往往不是“用了什么算法”,而是“你怎么知道它稳”。这时候能拿出三样东西就够了:一份参数表(每个参数的含义、取值、整定依据)、一组阶跃响应曲线(改参数前后的对比)、一份故障注入记录(拔遥控、拉低电压、堵住一个传感器分别会怎样)。被问到“为什么控制环是 500Hz”,答案要能落到 I2C 总线预算和相位滞后上;被问到“积分限幅取多少”,要能说清是怎么从最大允许稳态误差反推的。这些都在设计文档里,写文档的时候嫌烦,答辩的时候真香。
从那以后我每次交付飞控,哪怕只是动了一个 PID 系数,都强制走一遍台架、系留、限高悬停的三段验收,中间任何一段数据对不上就退回上一段重来。飞控没有“应该没问题”,只有“测过没问题”。希望帮到你。
本文还有配套的精品资源,点击获取