简介:这是一套面向嵌入式初学者与无人机控制爱好者的C语言开源项目源码,围绕微控制器展开:电机、GPS与遥控器模块的硬件驱动,加速度计、陀螺仪、气压计的姿态与位置采集,以及基于PID的飞行姿态、速度、高度控制,另含图像处理与无线通信模块,可用于课程设计、毕业设计或飞控入门参考。压缩包共1740个文件、约19.23MB,以278个.h头文件与258个.c源文件承载主要逻辑,120套工程配置适配多种IDE与调试环境,68个.ld链接脚本与52个.s汇编启动文件体现底层移植细节,另有hex固件、pdf文档与html页面辅助阅读。目前已有870人学习下载。代码注释丰富、模块划分清晰,读者可据此梳理从传感器采样、姿态解算到PID输出的完整飞控链路,理解STM32外设驱动与工程移植方法,并在此基础上裁剪或扩展自己的控制方案。
1. 一架自己焊的四轴,比任何教程都诚实
焊好的机架上电,电机顺序错了一对,解锁瞬间整台机器原地翻了个跟头,桨叶在地上刮出一圈白痕——很多做 C 语言 嵌入式 无人机控制的新手,第一次上电都是这么过来的。这份开源飞控源码加配套的设计与说明文档,价值就在于尽量帮你避开这些低级错误。它是一套完整的裸机工程:板级初始化、六轴驱动、四元数姿态解算、串级 PID、电机混控、遥控解析、参数存储、飞行日志,全部用 C 写,文档逐层说明职责和参数含义。适合两类人:想搞明白飞控内部怎么算的嵌入式开发者,和手上已经有自稳平台、想把控制律换成自己一套的二次开发者。
2. 工程骨架与构建链路:从复位向量到第一帧固件
拿到一份飞控源码,最怕上来就翻控制算法那几百行,翻完还是不知道程序从哪跑起来。我的习惯是先看目录,再看 main 的执行顺序,最后看构建脚本——这三样对上,整个工程的结构就清楚了。
2.1 目录结构:五层职责,改哪一层不牵连哪一层
这套工程的目录是按依赖方向摆的,越往下越贴近芯片,越往上越贴近飞行逻辑:
firmware/ ├── bsp/ # 板级支持:时钟树、GPIO、串口、看门狗 ├── hal/ # 硬件抽象:I2C/SPI/PWM 的统一读写接口 ├── drivers/ # 器件驱动:六轴 IMU、气压计、磁力计、遥控接收 ├── math/ # 四元数、向量、滤波、三角函数查表 ├── control/ # 姿态解算、串级 PID、混控 ├── flight/ # 飞行状态机、解锁条件、失控保护 ├── comm/ # 地面站协议、遥控帧解析、遥测上报 ├── params/ # 参数表、Flash 掉电存储、版本迁移 ├── log/ # 飞行日志与黑匣子 ├── app/ # main、调度器、任务注册 ├── ld/ # 链接脚本 └── docs/ # 设计说明、接线表、参数手册| 层 | 允许依赖 | 典型改动 |
|---|---|---|
| bsp | 芯片头文件 | 换主控、改时钟频率 |
| hal | bsp | 换总线实现,不动上层 |
| drivers | hal、math | 换 IMU 型号 |
| control | math、params | 调 PID、改控制律 |
| flight | control、comm | 加飞行模式、加保护 |
关键在于control层不允许包含任何芯片头文件。这条约束看着像洁癖,实际是后期能不能移植的分水岭:我见过不少工程把HAL_GetTick()直接写进 PID 里,换个板子整片控制代码全得重写。这套源码通过 hal 提供millis(),把时间源抽出去了,姿态解算和 PID 拿到的是纯浮点数组,搬到仿真环境里也能直接跑。
2.2 启动流程:从复位向量到第一次调度
main里的初始化顺序,基本就是一份硬件自检清单。顺序错了会出现“外设没使能就去读传感器”这类玄学问题:
int main(void) { board_init(); /* 时钟、电源、看门狗、调试串口最先就位 */ param_load(); /* 参数从 Flash 读,校验失败回落默认值 */ hal_init(); /* 总线与外设句柄,依赖时钟配置完成 */ sensor_init_all(); /* 逐个传感器自检,记录在线状态位 */ rc_init(); /* 遥控通道映射与中位校准 */ control_init(); /* 四元数归零、PID 状态清零、积分项复位 */ safety_init(); /* 解锁条件、失控保护状态机进入待机 */ log_init(); /* 挂载黑匣子区域 */ if (sensor_health() != SENSOR_ALL_OK) { /* 关键传感器缺失不进入飞行,只保留地面站通信 */ while (1) { telemetry_report_fault(); } } scheduler_run(); /* 死循环,按节拍派发任务,不再返回 */ }param_load()放在hal_init()之前是有意为之:有些板子的串口波特率、PWM 频率本身就存在参数区里,先读参数再初始化外设,可以避免“先用默认值初始化一遍、读完参数再改一遍”的双份逻辑。sensor_health()的返回位图也值得留意,它不返回 bool,而是每一位对应一个器件,这样地面站能直接显示“磁力计离线、气压计正常”,排查时不用猜。
scheduler_run()之后没有任何return,整个固件靠节拍驱动。这一点和跑 RTOS 的工程不一样:任务没有独立栈,所有函数共享主栈,所以每个任务函数里都不能写阻塞等待。文档里专门标注了这条约束,二次开发时往调度表里加任务,得先确认这个任务最坏执行时间不会挤占 1kHz 的姿态环。
2.3 构建与烧录:Makefile、产物与烧写命令
工程用 Makefile 而不是图形化 IDE,好处是 CI 里能一键出固件,坏处是第一次编译容易被工具链卡住。核心片段如下:
TARGET = flight_fw BUILD = build CROSS = arm-none-eabi- CC = $(CROSS)gcc OBJCOPY = $(CROSS)objcopy SIZE = $(CROSS)size CFLAGS = -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \ -O2 -g3 -Wall -Wextra -Wdouble-promotion \ -ffunction-sections -fdata-sections \ -Ibsp -Ihal -Idrivers -Imath -Icontrol -Iflight -Icomm -Iparams LDFLAGS = -Tld/link.ld -Wl,--gc-sections -Wl,-Map=$(BUILD)/$(TARGET).map \ -Wl,--print-memory-usage SRCS = $(wildcard app/*.c bsp/*.c hal/*.c drivers/*.c \ math/*.c control/*.c flight/*.c comm/*.c params/*.c log/*.c) OBJS = $(SRCS:%.c=$(BUILD)/%.o) all: $(BUILD)/$(TARGET).bin $(BUILD)/%.o: %.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) -c $< -o $@ $(BUILD)/$(TARGET).elf: $(OBJS) $(CC) $(CFLAGS) $^ $(LDFLAGS) -o $@ $(SIZE) $@ $(BUILD)/$(TARGET).bin: $(BUILD)/$(TARGET).elf $(OBJCOPY) -O binary $< $@参数逐条说:-mfloat-abi=hard必须和芯片是否带 FPU 对上,配错了要么链接报错,要么跑起来浮点运算慢一个数量级;-Wdouble-promotion是飞控工程里很值钱的一个警告,单精度芯片上不小心写个3.14字面量就会触发双精度软件模拟,姿态环里出现一次就是几百微秒的开销;--gc-sections配合-ffunction-sections把没调用到的函数裁掉,--print-memory-usage则在每次链接后打印 Flash 和 RAM 占用,改完代码一眼能看到涨了多少。
编译和烧录两条命令,我一般写进脚本里反复用:
# 清理重编,输出固件与内存占用 make clean && make -j8 # 用调试器烧写 ELF(带符号,方便后续 attach 调试) openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \ -c "program build/flight_fw.elf verify reset exit"烧录完先别急着上桨。串口连上地面站,看三件事:参数是否加载成功、传感器在线位图是否全绿、遥控各通道推杆时数值是否连续无跳变。这三项过了,才轮到电机。
3. 姿态解算与串级 PID:一个姿态指令从传感器走到电机
飞控真正难的不是写出公式,而是把公式放进 1kHz 的节拍里、用有限精度的浮点、在有振动的机架上跑稳。这一章按数据流走一遍:原始数据进,PWM 占空比出。
3.1 从六轴数据到四元数:先算准,再谈控
姿态解算用的是互补滤波加四元数积分。思路不复杂:陀螺仪高频准但有零漂,加速度计长期准但被振动污染,用加速度计观测到的重力方向去修陀螺的积分漂移:
/* 1kHz 固定周期调用,gyro 单位 rad/s,acc 单位 m/s^2 */ void attitude_update(const vec3f_t *gyro, const vec3f_t *acc, float dt) { vec3f_t a = vec3_normalize(acc); vec3f_t z_body = quat_get_axis_z(&att.q); /* 机体系 z 轴与重力方向的叉积,即为当前姿态误差 */ vec3f_t err = vec3_cross(&a, &z_body); /* 误差分两路:比例项修当前偏差,积分项消陀螺零漂 */ static vec3f_t err_i = {0.0f, 0.0f, 0.0f}; err_i = vec3_add(&err_i, vec3_scale(&err, ATT_KI * dt)); vec3f_t w = vec3_add(gyro, vec3_scale(&err, ATT_KP)); w = vec3_add(&w, &err_i); /* 四元数一阶积分:q += 0.5 * q ⊗ w * dt */ quat_t wq = { 0.0f, w.x, w.y, w.z }; quat_t qd = quat_mul(&att.q, &wq); att.q.w += 0.5f * qd.w * dt; att.q.x += 0.5f * qd.x * dt; att.q.y += 0.5f * qd.y * dt; att.q.z += 0.5f * qd.z * dt; att.q = quat_normalize(&att.q); /* 转欧拉角仅供遥测与串级 PID 外环使用,内部计算全走四元数 */ att.roll = quat_to_roll(&att.q); att.pitch = quat_to_pitch(&att.q); att.yaw = wrap_pi(att.yaw + gyro->z * dt); /* 航向暂用陀螺积分 */ }ATT_KP常见取值在 0.5 到 2.0 之间,ATT_KI在 0.001 到 0.01 量级,具体看陀螺零漂和机架振动。这两个参数调大了会怎样?加速度计的振动噪声被拉进姿态里,飞机在空中会高频抖动;调小了则收敛慢,起飞瞬间的倾斜要好几秒才修正回来,手感发飘。dt必须用调度器给出的名义周期,不要用实测耗时——四元数积分对周期抖动很敏感,用实测值反而引入额外噪声。
还有一个容易忽略的点:att.q归一化必须每帧做。浮点累加误差在 1kHz 下几分钟就能让四元数模长漂到 1.05,姿态角随之缓慢发散,表现出来就是“悬停五分钟开始自己歪”。这属于血泪经验,文档里也专门标了。
3.2 串级 PID:外环给期望角速度,内环出力矩
姿态控制是串级结构。外环输入角度误差,输出期望角速度;内环输入角速度误差,输出归一化的力矩指令。这么分层的好处是外环可以慢慢调、内环可以调得很硬,抗风扰动主要靠内环:
typedef struct { float kp, ki, kd; float i_term; /* 积分累加值 */ float i_limit; /* 积分限幅,防止地面停留时饱和 */ float out_limit; /* 输出限幅,防止微分项打满 */ float prev_err; } pid_t; static float pid_step(pid_t *p, float err, float dt) { /* 先积分再限幅,限幅值按最大持续力矩的 30% 左右给 */ p->i_term += p->ki * err * dt; p->i_term = clampf(p->i_term, -p->i_limit, p->i_limit); /* 微分用误差差分,机架振动大时这里要配低通 */ float d = (err - p->prev_err) / dt; p->prev_err = err; float out = p->kp * err + p->i_term + p->kd * d; return clampf(out, -p->out_limit, p->out_limit); } /* 外环:角度误差 -> 期望角速度,带宽低,约 20Hz */ float sp_roll = pid_step(&pid_att_roll, att.roll_sp - att.roll, dt); /* 内环:角速度误差 -> 力矩,带宽高,约 200Hz */ float tq_roll = pid_step(&pid_rate_roll, sp_roll - gyro->x, dt);pid_att_*和pid_rate_*是两组独立的 PID 实例,结构体里带自己的积分状态,千万不要共用一个prev_err。i_limit这个字段看着不起眼,实际是能不能安全落地的关键:飞机在地面被螺旋桨气流顶着、姿态始终差那么一两度时,积分项会一路累加,等到离地瞬间突然释放,表现就是“一离地就蹿”。文档里给的整定顺序是先把内外环的kp调到临界振荡再退 30%,然后加kd压超调,最后才给一点点ki。
D 项是另一个坑。(err - prev_err) / dt在 1kHz 下等于把误差放大了 1000 倍,机架上的电机振动会原样被放大成力矩指令,听起来像电机在“尖叫”。常见做法是给 D 项单独串一个一阶低通,截止频率设在 50 到 80Hz:
/* 微分项低通,alpha 由采样率和截止频率算出 */ p->d_filt = p->d_filt + PID_D_ALPHA * (d - p->d_filt); float out = p->kp * err + p->i_term + p->kd * p->d_filt;3.3 混控:把三个力矩拆给四个电机
PID 出来的三个力矩是抽象的,混控负责把它们按几何关系分配到每个电机。X 型四轴用一张符号表最清晰,也最好扩展:
/* 每行一个电机:M1 右前、M2 右后、M3 左后、M4 左前 */ static const float mix_x4[4][3] = { /* roll pitch yaw */ { -1.0f, 1.0f, 1.0f }, /* M1 右前 */ { -1.0f, -1.0f, -1.0f }, /* M2 右后 */ { 1.0f, -1.0f, 1.0f }, /* M3 左后 */ { 1.0f, 1.0f, -1.0f }, /* M4 左前 */ }; void mixer_x4(float thr, float roll, float pitch, float yaw, float out[4]) { for (int i = 0; i < 4; i++) { out[i] = thr + mix_x4[i][0] * roll + mix_x4[i][1] * pitch + mix_x4[i][2] * yaw; } mixer_normalize(out, 4, MOTOR_MIN, MOTOR_MAX); }thr是油门基准,三个力矩项叠加后可能超出 [0,1] 区间。mixer_normalize()不是简单截断,而是整体平移或等比压缩:
void mixer_normalize(float *v, int n, float lo, float hi) { float minv = v[0], maxv = v[0]; for (int i = 1; i < n; i++) { minv = fminf(minv, v[i]); maxv = fmaxf(maxv, v[i]); } if (maxv - minv > (hi - lo)) { /* 满油门时姿态指令还打架,只能等比压缩,保姿态不保油门 */ float k = (hi - lo) / (maxv - minv); for (int i = 0; i < n; i++) v[i] = lo + (v[i] - minv) * k; } else if (minv < lo) { for (int i = 0; i < n; i++) v[i] += (lo - minv); /* 整体上移 */ } else if (maxv > hi) { for (int i = 0; i < n; i++) v[i] -= (maxv - hi); /* 整体下移 */ } }为什么不用直接clampf?因为单独截断会破坏四个电机之间的力矩关系,姿态环算出来的修正量被削掉一部分,飞机就会缓慢往一边偏,而 PID 又继续加大输出,形成正反馈。这个细节在文档的混控章节里有单独一节,也是我见过最多的“抄了公式但飞不稳”的原因。
符号表这种写法还有一个好处:换机型只要换表。改成 H 型或者六轴,控制层代码一行不动,只改mix_*表和MOTOR_NUM。这算是这套工程给二次开发留的最大一个口子。
4. 实时调度、传感器时序与参数日志:让固件真的稳下来
控制算法写得再漂亮,跑在一个节拍飘忽的调度器上照样翻车。这一章讲三件“非算法但决定成败”的事。
4.1 SysTick 时间片调度器:1kHz 是怎么保住的
工程没有上 RTOS,用的是一个基于系统节拍的时间片轮询。核心就一张任务表和一个死循环:
typedef struct { void (*fn)(void); uint32_t period_ms; /* 任务周期 */ uint32_t next_tick; /* 下次执行的节拍点 */ uint32_t exec_cnt; /* 执行计数,地面站用来看负载 */ } task_t; static task_t tasks[] = { { attitude_task, 1, 0, 0 }, /* 1kHz:姿态解算 */ { control_task, 1, 0, 0 }, /* 1kHz:串级 PID + 混控 */ { motor_task, 1, 0, 0 }, /* 1kHz:PWM/DShot 输出 */ { rc_task, 10, 0, 0 }, /* 100Hz:遥控帧解析 */ { telemetry_task, 50, 0, 0 }, /* 20Hz:地面站上报 */ { log_task, 100, 0, 0 }, /* 10Hz:黑匣子写入 */ }; void scheduler_run(void) { for (;;) { uint32_t now = millis(); for (int i = 0; i < TASK_NUM; i++) { /* 有符号差比较,天然处理节拍计数器回绕 */ if ((int32_t)(now - tasks[i].next_tick) >= 0) { tasks[i].fn(); /* 累加而不是 now + period,避免任务延迟被永久累积 */ tasks[i].next_tick += tasks[i].period_ms; tasks[i].exec_cnt++; } } } }(int32_t)(now - next_tick) >= 0这个写法是防回绕的标准做法,直接用now >= next_tick在计数器溢出那天会集体罢工。next_tick += period而不是next_tick = now + period,是为了让长周期任务的相位保持稳定——后者会把每次执行延迟累积进去,日志任务的周期会越跑越偏。
这张表里所有任务的周期都是 1ms 的整数倍,因为millis()的分辨率就是 1ms。如果要跑更细的周期(比如 2kHz 的 DShot 输出),就得用硬件定时器中断,不能靠这个调度器。文档里把这条边界写得很明确:调度器负责毫秒级任务,微秒级时序交给外设。
exec_cnt这个字段是我最常用的调试入口。地面站上把它和理论值一比,就知道哪个任务在执行中卡住了。有一回日志任务每 10 秒左右跳一次计数,最后查出来是 Flash 擦除阻塞了将近 40ms,把 1kHz 的姿态环硬生生拖成了 25Hz——这个现象在代码里完全看不出来,只有计数对不上才暴露。
4.2 传感器总线时序:一次突发读解决轴间不同步
IMU 通过 SPI 读取,最忌讳分三次分别读加速度、角速度、温度。分次读之间隔了几十微秒,飞机在这段时间里已经转了一点角度,三个轴的数据不在同一采样点,融合出来的姿态会有偏差。标准做法是一次突发读:
/* 从 ACCEL_XOUT_H 开始连续读 14 字节,一次拿到全部轴数据 */ bool imu_read_burst(imu_sample_t *s) { uint8_t tx[15] = { IMU_REG_ACCEL_XOUT_H | 0x80 }; /* 最高位置 1 表示读 */ uint8_t rx[15] = { 0 }; if (!spi_transfer(tx, rx, sizeof(tx), IMU_SPI_TIMEOUT_US)) return false; /* 超时直接返回,由上层决定降级策略 */ int16_t ax = (int16_t)((rx[1] << 8) | rx[2]); int16_t ay = (int16_t)((rx[3] << 8) | rx[4]); int16_t az = (int16_t)((rx[5] << 8) | rx[6]); int16_t gx = (int16_t)((rx[9] << 8) | rx[10]); int16_t gy = (int16_t)((rx[11] << 8) | rx[12]); int16_t gz = (int16_t)((rx[13] << 8) | rx[14]); s->acc.x = ax * ACC_LSB_PER_G; s->acc.y = ay * ACC_LSB_PER_G; s->acc.z = az * ACC_LSB_PER_G; s->gyro.x = gx * GYRO_LSB_PER_DPS * DEG2RAD; s->gyro.y = gy * GYRO_LSB_PER_DPS * DEG2RAD; s->gyro.z = gz * GYRO_LSB_PER_DPS * DEG2RAD; return true; }ACC_LSB_PER_G和GYRO_LSB_PER_DPS由量程决定,换量程必须同步改这两个宏,否则姿态角会整体缩放。IMU_SPI_TIMEOUT_US这个超时值不能省——SPI 在电磁干扰下偶尔会丢时钟,没有超时的while等待会让整个调度器卡死,表现就是空中突然“僵住”,几秒后看门狗复位。返回 false 之后上层通常选择跳过本帧融合,继续用陀螺积分撑过几十毫秒,比直接停摆安全得多。
气压计和磁力计走 I2C,频率低得多(一般 20 到 50Hz),放在 100Hz 任务里读。这里有个比较隐蔽的问题:I2C 是共享总线,如果气压计和磁力计挂在同一条线上,两个任务并发访问会互相打断。工程里的做法是给每条 I2C 总线配一把软件锁,读之前拿锁、读完释放,超时时间设在 2ms 以内。
4.3 参数系统与飞行日志:把飞行数据留下来
参数区用两段备份加 CRC 的结构存 Flash,这是飞控里最朴素的可靠性设计:
typedef struct { uint16_t magic; /* 固定值,用来判断区域是否被擦除 */ uint16_t version; /* 结构版本,加字段必须递增 */ float pid_rate_kp; float pid_rate_ki; float pid_rate_kd; float att_kp; float att_ki; uint16_t crc; /* 覆盖前面所有字节 */ } param_blob_t; bool param_save(const param_blob_t *p) { param_blob_t tmp = *p; tmp.crc = crc16((uint8_t *)&tmp, offsetof(param_blob_t, crc)); /* 先写备份区,成功后再写主区,掉电时总有一份可用 */ if (!flash_write(PARAM_BAK_ADDR, &tmp, sizeof(tmp))) return false; return flash_write(PARAM_MAIN_ADDR, &tmp, sizeof(tmp)); }version字段是给结构升级用的,加参数时递增版本号,加载时发现版本不一致就走迁移函数把旧结构里的字段搬过来,而不是直接回落默认值——否则用户飞了几十架次调出来的 PID 会一夜清零。magic用于识别空白 Flash,刚出厂的芯片读出来全是 0xFF,CRC 校验也过不了,但报错信息会不一样,方便区分是“没存过”还是“存坏了”。
日志部分以二进制帧格式写入,每帧固定头加变长负载,帧头里有时间戳和类型。地面站解析时按类型分发,比纯文本省一半空间也快得多。日志任务必须做限流:写 Flash 时如果缓冲区满,宁可丢帧也不能阻塞调度器,丢帧计数在遥测里上报,事后看曲线时知道哪段数据有缺口。
5. 常见问题排查:上手这套源码最容易翻车的五处
前面讲的都是正常路径,实际调试时真正耗时间的是那些“代码看着没问题但飞机不听话”的时刻。下面五条是按我踩过的顺序排的。
5.1 解锁瞬间侧翻:电机顺序和转向对不上
现象:解锁后飞机没有起飞,而是立刻往一侧翻倒,桨叶刮地。
原因:混控符号表和实际电机接线不一致,或者某个电机转向装反。符号表假设 M1 在右前、顺时针转,如果实际把 M1 焊到了左后,姿态修正的方向就整个反了,PID 越修正越歪。
解决:拆桨,在调试模式里单独给每个电机 10% 油门,逐个确认位置和转向;把实际接线画成一张表,和mix_x4逐行核对。这一步不做完绝不上桨,后面所有参数整定都是白费。
5.2 悬停时缓慢往一边漂,微调怎么都不够
现象:姿态稳定,但飞机总往固定方向漂,遥控微调打满仍然压不住。
原因通常是三个叠加在一起:IMU 没有做水平校准(安装面和机架不平行,静态就有几度偏差)、重心偏离几何中心、或者外层角度环的积分被限幅卡住。
解决:先把飞机放在水平台面上跑一次加计校准,把零偏存进参数区;然后用细线吊起来找重心,电池位置调到前后左右都水平;最后才动 PID。顺序反了会陷入“越调越乱”的循环。
5.3 加油门后机身高频抖动,松油门就好
现象:悬停油门附近机身高频抖动,桨声发尖,降油门立刻安静。
原因:D 项把机架振动放大了,或者螺旋桨动平衡不好。1kHz 采样下,D 项对高频噪声的增益几乎是线性的,动平衡差的桨等于给控制系统喂噪声。
解决:先做机械侧的动平衡(贴配重、检查桨夹松紧),再给 D 项加低通,截止频率从 80Hz 往下试。直接砍kd也能压住抖动,但会让姿态响应变迟钝,属于治标。
5.4 飞一两分钟后突然掉高、遥控没反应,几秒后自己恢复
现象:飞行中短暂失控,落地后检查代码没有任何异常。
原因:看门狗复位。某个任务执行时间过长(常见是 Flash 擦除或日志写入),主循环没来得及喂狗,芯片重启,重启后重新进入待机。
解决:在调度器里加任务执行耗时统计,找出最慢的任务;日志和参数写入改成分片、放到低优先级任务里做;喂狗放在调度循环末尾而不是定时器中断里,这样能真实反映主循环是否卡住。
5.5 地面站能连上但收不到遥测数据
现象:串口打开成功,握手也回了,但姿态、电压数据一帧都不来。
原因:发送缓冲区被阻塞式写占满,或者上报任务的周期被 1kHz 任务挤没了。也有可能是协议里的长度字段算错,地面站收到帧头后一直等后续字节,最后超时丢弃。
解决:先把上报任务临时提到 1kHz,看数据是否恢复,用来区分是“任务饿死”还是“协议问题”;协议问题用串口助手抓原始字节,按帧头、长度、负载、校验逐段对。别在地面站软件里猜,抓原始数据最快。
6. 二次开发:改参数、加传感器、换机型该怎么落地
这套源码最值钱的地方不是算法多先进,而是分层分得干净,二次开发有明确的切入点。三条路径,从轻到重。
改控制参数是最轻的。参数区里所有 PID 系数、滤波截止频率、限幅值都能通过地面站读写,不用重新编译。我的验证方法是先在台架上把飞机用扎带固定,解锁后手动给姿态扰动,看角速度曲线的超调和收敛时间;台架上过了再上桨悬停,悬停稳了再试定点。跳过台架直接飞,翻车概率很高。
加一个传感器,要动的文件是固定的四个:drivers/下加驱动实现,hal/下确认总线接口够用,app/scheduler里注册一个读取任务,params/里加配置项并递增version。驱动里统一实现三个函数——初始化、自检、读取——上层只认这个接口,换型号不影响任何控制代码:
typedef struct { bool (*init)(void); bool (*health)(void); bool (*read)(void *out); } sensor_ops_t;换机型是改动最大的一类,但控制层依然不动。四轴改六轴,只需要改混控符号表、电机数量宏和输出通道映射:
/* 六轴 Y 型:三个力臂方向,符号由几何角度决定 */ static const float mix_y6[6][3] = { { 0.0f, 1.0f, 1.0f }, { -0.866f, -0.5f, -1.0f }, { 0.866f, -0.5f, 1.0f }, { 0.0f, 1.0f, -1.0f }, { -0.866f, -0.5f, 1.0f }, { 0.866f, -0.5f, -1.0f }, };改完先别飞,用一套离线验证脚本把混控表跑一遍:给定单位 roll、pitch、yaw 指令,检查六个输出里力矩和是否为 0、总推力变化是否在可接受范围。力矩和不归零,说明符号表有错,飞起来就是持续自旋。
从那以后我每次改完控制参数或者混控表,都强制先在台架上挂限位跑满三分钟,把曲线录下来对一遍才上桨。这习惯救过我不止一次——多花的十分钟,比修一次机架便宜太多。希望帮到你。
本文还有配套的精品资源,点击获取