九轴解算航向角、俯仰角、滚转角输出f103主控,气压计bmp085,磁力计hmc5883l,dmp解算,卡尔曼滤波矩阵运算,多份代码

前阵子蹲在宿舍焊飞控的时候,突然发现之前抄的九轴解算代码总飘,哪怕把飞控放在桌子上不动,航向角也能自己转个360度,折腾了半天才搞明白是磁力计和气压计没对齐的锅。今天把折腾出来的东西整理一下,顺便把用到的代码片段掏出来唠唠。

首先得明确咱要干啥:用stm32f103当主控,攒一个带九轴姿态解算的小飞控,要输出俯仰角、滚转角、航向角,还要加个bmp085测高度。九轴指的是加速度计+陀螺仪+磁力计,这里我用的是mpu6050(accel+gyro)加hmc5883l磁力计,凑够九个轴。

一开始图省事,直接用了mpu6050自带的DMP解算,毕竟官方例程给得明明白白,复制粘贴就能跑。但问题也来了:DMP默认只用六轴数据,只靠加速度计和陀螺仪解算,陀螺仪本身有漂移,哪怕不动,时间长了角度也会飘。而且DMP输出的航向角完全没用到磁力计,只要转个身,过一会儿就歪到姥姥家。

先贴个当时没加磁力计时的DMP初始化代码:

// 简化的MPU6050 DMP初始化,只开了六轴
mpu_device_init();
// 只配置陀螺仪和加速度计的FIFO
mpu_set_sensors(INV_XYZ_GYRO | INV_XYZ_ACCEL);
mpu_configure_fifo(INV_XYZ_GYRO | INV_XYZ_ACCEL);
mpu_set_sample_rate(100);
// 加载DMP固件
dmp_load_motion_driver_firmware();
dmp_set_orientation(inv_orientation_matrix_to_scalar(gyro_orientation));
// 只开四元数输出,没加磁力计融合
dmp_enable_feature(DMP_FEATURE_6X_LP_QUAT);
dmp_set_fifo_rate(100);
mpu_set_dmp_state(1);

当时就是这么个代码,跑起来俯仰和滚转还行,但航向角飘得离谱,后来才知道要把hmc5883l的磁力计数据喂进去,让DMP做九轴融合,或者自己写卡尔曼滤波来融合三个传感器的数据。

接下来唠唠hmc5883l这个磁力计,当年我最蠢的坑就是直接按手册读x/y/z,结果航向角一直反的。后来才发现hmc5883l的输出寄存器顺序是x,z,y,不是x,y,z!贴个我后来改对的读取代码:

// 读取HMC5883L原始数据,注意输出顺序是x,z,y
uint8_t hmc5883l_read_data(int16_t *x, int16_t *z, int16_t *y) {
    uint8_t buf[6];
    // 0x03是数据输出寄存器地址,I2C_Read_Buf是自己写的通用I2C读函数
    I2C_Read_Buf(HMC5883L_ADDR, 0x03, buf, 6);
    *x = (buf[0] << 8) | buf[1];
    *z = (buf[2] << 8) | buf[3];
    *y = (buf[4] << 8) | buf[5];
    return 0;
}

而且还要先配置磁力计的量程和采样率,比如我一般配置成8高斯的量程,15Hz的采样率,不然读出来的数据要么饱和要么跳变太厉害。当年没配置的时候,读出来的x轴数据永远是正的,测出来的航向角全是错的。

九轴解算航向角、俯仰角、滚转角输出f103主控,气压计bmp085,磁力计hmc5883l,dmp解算,卡尔曼滤波矩阵运算,多份代码

然后是卡尔曼滤波,一开始我抄了网上的全量九轴卡尔曼代码,矩阵都是9x9的,编译完flash占了一大半,f103的64k flash差点不够用。后来改成了简化版的,把俯仰、滚转、航向角分开处理,每个轴用一个小的卡尔曼滤波器,反而跑起来更稳,还省空间。

贴个单轴的卡尔曼滤波代码,比如用来处理俯仰角的:

// 简化的单轴卡尔曼滤波器,融合陀螺仪和加速度计数据
typedef struct{
    float angle;  // 最终解算出来的角度
    float bias;   // 陀螺仪的零偏偏移
    float P[2][2]; // 协方差矩阵,用来估计误差
} KalmanFilter;

// 更新函数,dt是两次更新的时间间隔,单位秒
float Kalman_Update(KalmanFilter *kf, float gyro_rate, float acc_angle, float dt){
    // 第一步:预测,根据陀螺仪的角速度预测下一刻的角度
    float angle_pred = kf->angle + (gyro_rate - kf->bias) * dt;
    // 更新协方差矩阵的预测值
    float P00_pred = kf->P[0][0] + kf->P[0][1] * dt + kf->P[1][0] * dt + kf->P[1][1] * dt*dt + 0.01;
    float P01_pred = kf->P[0][1] + kf->P[1][1] * dt;
    float P10_pred = kf->P[1][0] + kf->P[1][1] * dt;
    float P11_pred = kf->P[1][1] + 0.001;

    // 第二步:更新,用加速度计的实际角度修正预测值
    float residual = acc_angle - angle_pred; // 预测和实际的残差
    float S = P00_pred + 0.1; // 观测噪声协方差
    float K0 = P00_pred / S;  // 卡尔曼增益
    float K1 = P01_pred / S;

    // 更新角度和陀螺仪偏置
    kf->angle = angle_pred + K0 * residual;
    kf->bias = kf->bias + K1 * residual;

    // 更新协方差矩阵
    kf->P[0][0] = P00_pred - K0 * P00_pred;
    kf->P[0][1] = P01_pred - K0 * P01_pred;
    kf->P[1][0] = P10_pred - K1 * P00_pred;
    kf->P[1][1] = P11_pred - K1 * P01_pred;

    return kf->angle;
}

这个代码里的0.01和0.1是调参调出来的,一开始我用的默认值,结果滤波要么太灵敏要么太迟钝,调了快一周才找到合适的数。而且这个滤波器只处理单轴,三个轴的话就初始化三个这样的结构体就行,比全量矩阵好写多了,f103跑起来也不卡。

接下来是bmp085气压计,一开始我以为这个只是用来测高度的,后来发现飞控里用来做高度锁的话,还要把气压转换成高度,而且要注意校准。比如每次开机的时候,要先测一下当前的大气压作为海平面基准,不然测出来的高度会飘。贴个简化的读取代码:

// BMP085读取温度和气压,省略了气压转高度的部分
uint8_t bmp085_read_data(float *temp, float *press){
    static uint8_t calibrated = 0;
    // 出厂校准参数,要先读出来存起来
    static int16_t ac1, ac2, ac3, b1, b2, mb, mc, md;
    static uint16_t ac4, ac5, ac6;
    if(!calibrated){
        ac1 = I2C_Read_16(BMP085_ADDR, 0xAA);
        ac2 = I2C_Read_16(BMP085_ADDR, 0xAC);
        ac3 = I2C_Read_16(BMP085_ADDR, 0xAE);
        ac4 = I2C_Read_16(BMP085_ADDR, 0xB0);
        ac5 = I2C_Read_16(BMP085_ADDR, 0xB2);
        ac6 = I2C_Read_16(BMP085_ADDR, 0xB4);
        b1 = I2C_Read_16(BMP085_ADDR, 0xB6);
        b2 = I2C_Read_16(BMP085_ADDR, 0xB8);
        mb = I2C_Read_16(BMP085_ADDR, 0xBA);
        mc = I2C_Read_16(BMP085_ADDR, 0xBC);
        md = I2C_Read_16(BMP085_ADDR, 0xBE);
        calibrated = 1;
    }
    // 读取温度
    I2C_Write_Byte(BMP085_ADDR, 0xF4, 0x2E);
    delay_ms(5); // 一定要等转换完成,不然读的是旧数据
    int32_t ut = (I2C_Read_16(BMP085_ADDR, 0xF6) << 8) | I2C_Read_16(BMP085_ADDR, 0xF7);
    // 计算温度,直接抄手册的公式就行
    int32_t x1 = (ut - ac6) * ac5 >> 15;
    int32_t x2 = (mc << 11) / (x1 + md);
    int32_t b5 = x1 + x2;
    *temp = (b5 + 8) >> 4;
    *temp /= 10.0f;
    // 气压读取省略了,和温度差不多,就是多几个步骤
    return 0;
}

当年我踩的坑是delay_ms(1),结果读出来的温度永远是25度,后来改成5ms就好了,因为bmp085的温度转换需要至少4.5ms的时间。

最后把这些东西串起来,整个主循环大概是这样的:

int main(void){
    HAL_Init();
    SystemClock_Config();
    // 初始化所有传感器
    I2C_Init();
    mpu6050_init();
    hmc5883l_init();
    bmp085_init();
    // 初始化卡尔曼滤波器
    KalmanFilter pitch_filter = {0, 0, {{1,0},{0,1}}};
    KalmanFilter roll_filter = {0, 0, {{1,0},{0,1}}};
    float pitch, roll, yaw;
    uint32_t last_time = HAL_GetTick();
    while(1){
        // 计算两次更新的时间间隔
        uint32_t now_time = HAL_GetTick();
        float dt = (now_time - last_time) / 1000.0f;
        last_time = now_time;

        // 读取各个传感器的数据
        int16_t ax, ay, az, gx, gy, gz;
        mpu6050_read_accel_gyro(&ax, &ay, &az, &gx, &gy, &gz);
        int16_t mx, my, mz;
        hmc5883l_read_data(&mx, &mz, &my);
        float temp, press;
        bmp085_read_data(&temp, &press);

        // 加速度计转角度,俯仰和滚转
        pitch = atan2(ay, sqrt(ax*ax + az*az)) * 180 / PI;
        roll = atan2(-ax, az) * 180 / PI;
        // 磁力计转航向角,这里要注意坐标系转换,不然角度还是反的
        yaw = atan2(my, mx) * 180 / PI;
        if(yaw <0) yaw += 360;

        // 用卡尔曼滤波融合数据
        pitch = Kalman_Update(&pitch_filter, gx, pitch, dt);
        roll = Kalman_Update(&roll_filter, gy, roll, dt);
        // 航向角的卡尔曼滤波同理,省略了

        // 把数据打印到串口
        printf("Pitch: %.2f, Roll: %.2f, Yaw: %.2f, Temp: %.2f\r\n", pitch, roll, yaw, temp);
        HAL_Delay(10);
    }
}

现在这个代码跑起来就稳定多了,放在桌子上不动的话,三个角度都能稳定在初始值附近,不会飘了。当然还有很多坑没唠,比如磁力计的硬铁补偿和软铁补偿,当年我没做这个的时候,放在桌子边靠近电脑的地方,航向角就会飘,后来加了校准代码才好。还有stm32f103的I2C速度,一开始用100kHz没问题,后来加了多个传感器之后,改成400kHz就会出现读错数据的情况,又改回100kHz了。

总的来说,九轴解算看起来简单,其实坑挺多的,抄代码容易,但要搞懂每个部分的原理,才能调对参数。现在我的小飞控终于能稳定悬停了,虽然比起大疆的还差远了,但自己焊的板子跑起来的感觉真的不一样。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐