MPU6050 + HMC5883L: 9-Axis Attitude Estimation in C

Condition theta equals plus or minus 90 degrees at gimbal lock

Key Takeaways

  • A six-axis IMU can solve roll and pitch but not absolute yaw, because gyro integration drifts without a heading reference.
  • Adding the HMC5883L magnetometer forms a nine-axis MARG system; after ellipsoid calibration, static yaw accuracy is better than plus or minus 1 degree.
  • Yaw drift over 60 seconds falls from about 6 degrees with six axes to under 1 degree with nine-axis fusion.
  • The full implementation runs at 200 Hz on an STM32F103C8T6 with 72 MHz, 64 KB flash and 20 KB RAM.

MPU6050 and HMC5883L attitude estimation is the standard low-cost route to drift-free yaw on an embedded platform, and this paper works through the whole chain in C. As of 2026 the MPU6050 remains the default six-axis choice for drones, robots and wearables thanks to its I2C interface and mature driver ecosystem, but it has no magnetometer and therefore no absolute heading. Combining it with the HMC5883L produces a nine-axis MARG system whose measured geomagnetic vector anchors yaw, and the results here show static yaw accuracy better than plus or minus 1 degree with smooth dynamic tracking at 200 Hz.

Why six axes are not enough

Accurate three-dimensional attitude is the precondition for any vehicle to control itself. The MPU6050 is widely used, yet without a magnetometer it cannot determine absolute heading on its own. Yaw obtained by integrating the gyro’s Z axis carries a bias drift, and even after bias calibration, temperature change and random walk keep pushing yaw slowly away. In a navigation system that runs for a long time, that drift seriously degrades heading accuracy.

The HMC5883L is a three-axis magnetoresistive magnetometer from Honeywell. Its I2C slave address is fixed at 0x1E, its supply range is 2.16 V to 3.6 V with 3.3 V typical, and its IO level is directly compatible with most MCUs. It offers eight gain settings from plus or minus 0.88 Ga to plus or minus 8.1 Ga and outputs a 12-bit signed value. Earth’s field is roughly 0.25 Ga to 0.65 Ga, so the plus or minus 1.3 Ga range gives about 0.92 mGa per LSB, which covers the field without wasting resolution.

Pairing the two produces a nine-axis MARG system, standing for magnetic, angular rate and gravity, where the magnetometer supplies an absolute heading reference that suppresses long-term yaw drift. This paper covers the calibration theory, the Mahony MARG design and the embedded implementation.

Coordinate frames and attitude representation

Frame definitions

The paper uses the standard aeronautical convention:

  • Body frame: X forward along the longitudinal axis, Y to the right along the lateral axis, Z downward.
  • Reference frame: the north-east-down geographic frame.

The three attitude angles are defined as:

Body frame axes and the roll, pitch and yaw rotations illustrated on an aircraft model

  • Roll, phi: rotation about the body X axis.
  • Pitch, theta: rotation about the body Y axis.
  • Yaw, psi: rotation about the body Z axis.

Euler rotation matrix

Using the Z-Y-X intrinsic order, the body first rotates by psi about Z, then by theta about the new Y, then by phi about the new X. The direction cosine matrix is:

Rotation matrix expressed as the product of Z, Y and X axis rotations

which expands to:

Expanded direction cosine matrix for the Z-Y-X intrinsic rotation order

where the shorthand is:

Shorthand definitions of cosine alpha and sine alpha used in the rotation matrix

This matrix transforms a vector from the body frame into the reference frame.

Gimbal lock

When pitch reaches the singularity:

Condition theta equals plus or minus 90 degrees at gimbal lock

the corresponding cosine term vanishes:

Cosine of theta equals zero at gimbal lock

and in matrix (2) the first and third rows, and the first and third columns, become linearly dependent. The roll and yaw axes coincide and the system loses one independent degree of freedom. The same physical attitude then maps to infinitely many Euler triples, and the inverse solution becomes singular. This is an inherent defect of parameterising SO(3) with Euler angles. No algorithm can remove it; it can only be avoided with a singularity-free representation such as the quaternion.

Quaternion representation

The unit quaternion is defined as:

Unit quaternion definition and its first-order derivative

where the angular rate quaternion is:

Angular velocity written as a pure quaternion

Quaternions have no singularity and are cheap to compute, which makes them the right internal state variable.

Quaternion to Euler and back

Under the Z-Y-X intrinsic convention, quaternion to Euler is:

Quaternion to Euler angle conversion equations for roll, pitch and yaw

and Euler to quaternion is:

Euler angle to quaternion conversion matrix

When theta approaches plus or minus 90 degrees the arcsin argument in equation (5) must be clamped and handled with a branch, setting phi to zero and solving psi from the remainder. The quaternion itself never goes singular, so production systems recurse the quaternion internally and convert to Euler only at the output.

Magnetometer error model and ellipsoid calibration

Where the errors come from

A magnetometer is easily disturbed by nearby circuit boards, batteries, motors and ferromagnetic material. Feeding an uncalibrated magnetometer into an attitude solver produces badly distorted or violently oscillating output. The measurement errors fall into four classes:

  1. Hard iron: a constant field from permanent magnets or magnetised ferromagnetic material near the sensor, appearing as a fixed offset on all three axes.
  2. Soft iron: ferromagnetic material magnetised by the external field produces an additional field proportional to it, causing scale distortion and cross-axis coupling.
  3. Scale factor error: differing sensitivity per axis produces a proportional error.
  4. Non-orthogonality: the three sensor axes are not exactly perpendicular, which couples them.

Combining these, the measurement model is:

Magnetometer measurement model with soft iron matrix and hard iron offset

where W is a 3×3 combined error matrix covering soft iron, scale factor and non-orthogonality, and the hard iron offset vector is:

Hard iron offset vector definition

Ellipsoid fitting

Ideally, rotating a magnetometer through 360 degrees in three dimensions traces a sphere centred on the origin with a radius equal to the local field strength. Hard iron moves the centre away from the origin; soft iron turns the sphere into an ellipsoid. Calibration therefore amounts to extracting the hard iron offset and the soft iron transform from the ellipsoid equation.

The general ellipsoid equation is:

General ellipsoid equation used for magnetometer calibration

Coefficients a1 through a9 are solved by least squares. The fitting method follows Qingde Li and John G. Griffiths, which converts the nonlinear ellipsoid equation into a linear least-squares problem by variable substitution.

Calibration procedure

Step 1, data collection. Place the HMC5883L in a magnetically clean environment, away from motors, power supplies and metal structures. Fix it horizontally on a turntable or rotate it by hand through a full 360 degrees at constant speed for at least two revolutions, sampling raw ADC values over UART every 100 ms. At least 1,000 samples are recommended so the full angular domain is covered and motion acceleration is avoided. Also perform figure-eight motion so the pitch axis gets adequate magnetic coverage.

Step 2, ellipsoid fitting. Feed the raw samples to the fitting algorithm and solve for the ellipsoid coefficients.

Step 3, extract the parameters. Derive the hard iron offset V and the inverse soft iron matrix from the coefficients:

Inverse of the soft iron error matrix

Step 4, apply the calibration to subsequent measurements:

Calibration equation applying the inverse error matrix to the corrected measurement

A simplified implementation

Full ellipsoid fitting needs matrix algebra, so here is a simplified version that runs on the target MCU. It performs least-squares-free hard iron calibration from per-axis minima and maxima, which suits resource-constrained parts:

// mag_calib.c
#include <string.h>
#include <math.h>

#define CALIB_SAMPLES_MAX  2000

typedef struct {
    float offset[3];        // hard iron offset (x, y, z)
    float scale[3];         // per-axis scale correction
    uint16_t sample_count;
    float min_val[3];
    float max_val[3];
    uint8_t collecting;
} MagCalib_t;

static MagCalib_t mag_calib;

void MagCalib_Init(MagCalib_t *cal)
{
    memset(cal, 0, sizeof(MagCalib_t));
    cal->min_val[0] = cal->min_val[1] = cal->min_val[2] =  1e6f;
    cal->max_val[0] = cal->max_val[1] = cal->max_val[2] = -1e6f;
    cal->collecting = 1;
}

/* called during collection, keeps per-axis min and max up to date */
void MagCalib_UpdateMinMax(MagCalib_t *cal, float mx, float my, float mz)
{
    if (!cal->collecting) return;

    if (mx < cal->min_val[0]) cal->min_val[0] = mx;
    if (mx > cal->max_val[0]) cal->max_val[0] = mx;
    if (my < cal->min_val[1]) cal->min_val[1] = my;
    if (my > cal->max_val[1]) cal->max_val[1] = my;
    if (mz < cal->min_val[2]) cal->min_val[2] = mz;
    if (mz > cal->max_val[2]) cal->max_val[2] = mz;

    cal->sample_count++;
}

/* compute offsets and scale once collection finishes */
void MagCalib_Compute(MagCalib_t *cal)
{
    cal->collecting = 0;

    // hard iron offset = (max + min) / 2 per axis
    cal->offset[0] = (cal->max_val[0] + cal->min_val[0]) * 0.5f;
    cal->offset[1] = (cal->max_val[1] + cal->min_val[1]) * 0.5f;
    cal->offset[2] = (cal->max_val[2] + cal->min_val[2]) * 0.5f;

    // per-axis radius
    float rx = (cal->max_val[0] - cal->min_val[0]) * 0.5f;
    float ry = (cal->max_val[1] - cal->min_val[1]) * 0.5f;
    float rz = (cal->max_val[2] - cal->min_val[2]) * 0.5f;

    // average radius
    float r_avg = (rx + ry + rz) / 3.0f;

    // scale factors normalise each axis radius to the average
    if (rx > 1e-6f) cal->scale[0] = r_avg / rx; else cal->scale[0] = 1.0f;
    if (ry > 1e-6f) cal->scale[1] = r_avg / ry; else cal->scale[1] = 1.0f;
    if (rz > 1e-6f) cal->scale[2] = r_avg / rz; else cal->scale[2] = 1.0f;
}

/* apply the calibration */
void MagCalib_Apply(const MagCalib_t *cal, float mx, float my, float mz,
                    float *ox, float *oy, float *oz)
{
    *ox = (mx - cal->offset[0]) * cal->scale[0];
    *oy = (my - cal->offset[1]) * cal->scale[1];
    *oz = (mz - cal->offset[2]) * cal->scale[2];
}

This version estimates offset and scale from per-axis extremes and is deliberately approximate. A more accurate ellipsoid fit solves the nine coefficients of the equation above, which is best done in MATLAB or Python on a PC with the resulting parameters exported to the MCU.

Mahony MARG nine-axis fusion

Algorithm framework

The core idea of the Mahony complementary filter is frequency-domain complementarity: use the gyro for high-frequency response and the accelerometer and magnetometer for low-frequency accuracy. The nine-axis version adds a magnetometer correction term to the six-axis algorithm, using the known direction of Earth’s field in the reference frame, horizontal and pointing north, to correct yaw.

The flow is:

  1. Accelerometer corrects roll and pitch. Compute the theoretical gravity direction from the quaternion and cross it with the measured gravity direction to obtain the error:

Accelerometer error term in the Mahony filter

  1. Magnetometer corrects yaw. Compute the theoretical field direction from the quaternion and cross it with the measured field direction to obtain the error:

Magnetometer error term in the Mahony filter

  1. PI correction of the angular rate:

Proportional and integral correction applied to the gyro rate

  1. Quaternion update by first-order Runge-Kutta, then normalise.

Accelerometer error

The theoretical gravity direction follows from the current quaternion:

Estimated gravity direction derived from the current quaternion

Normalising the accelerometer gives:

Normalised accelerometer measurement vector

and the cross-product error is:

Cross product giving the accelerometer error vector

Magnetometer error

Correcting with the magnetometer requires mapping the geomagnetic direction in the reference frame into the body frame. Ideally Earth’s field has only a north and a vertical component in the NED frame, with the horizontal component pointing to magnetic north. Because yaw is not yet known, the measured field cannot simply be rotated into the reference frame.

The standard approach rotates the measurement into the horizontal plane, extracts the horizontal direction, and then constructs a reference field vector.

Step 1. Rotate the magnetometer measurement into the reference frame:

Magnetometer measurement rotated into the reference frame

Step 2. Construct the reference field direction, keeping only the north and vertical components in the NED frame:

Reference magnetic field vector keeping only north and vertical components

Step 3. Rotate the reference field back into the body frame:

Reference magnetic field rotated back into the body frame

Step 4. Normalise the measurement and cross it with the estimate:

Cross product giving the magnetometer error vector

PI correction and quaternion update

Combined PI correction of the gyro rate with both error terms
First-order quaternion update using the corrected angular rate

The integral term is clamped to plus or minus 0.5 to prevent wind-up.

Magnetic disturbance detection

In a real installation the magnetometer may see transient disturbance from motors or power wiring. When the magnitude of the measurement departs from the local field strength by more than a threshold, magnetometer correction should be reduced or suspended.

/* magnetic disturbance detection */
static uint8_t Mag_DisturbanceDetect(float mx, float my, float mz,
                                     float ref_norm, float threshold)
{
    float norm = sqrtf(mx*mx + my*my + mz*mz);
    if (fabsf(norm - ref_norm) > threshold) return 1;   // disturbed
    return 0;
}

The reference field magnitude:

Reference geomagnetic field magnitude

can be computed from the calibration data or looked up by geographic location, roughly 0.45 to 0.55 Ga in mainland China. The threshold is usually 0.15 times the reference value:

Reference geomagnetic field magnitude used in the disturbance threshold

Hardware design and driver

Hardware configuration

Table 1, hardware configuration:

Table of hardware modules: STM32F103C8T6 MCU, MPU6050 IMU, HMC5883L magnetometer and CH340 serial bridge

The MPU6050 sits at I2C address 0x68 with AD0 tied low, and the HMC5883L is fixed at 0x1E, so both share one I2C bus. The HMC5883L output data rate in continuous mode is selectable from 0.75 Hz to 75 Hz; 15 to 30 Hz is sufficient for heading, and low-pass filtering makes it steadier.

HMC5883L register definitions

// hmc5883l.h
#ifndef __HMC5883L_H
#define __HMC5883L_H

#include <stdint.h>

#define HMC5883L_ADDR           0x1E

/* register addresses */
#define HMC5883L_CONFIG_A       0x00    // configuration register A
#define HMC5883L_CONFIG_B       0x01    // configuration register B (gain)
#define HMC5883L_MODE           0x02    // mode register
#define HMC5883L_DATA_XOUT_H    0x03    // X axis data high byte
#define HMC5883L_DATA_XOUT_L    0x04    // X axis data low byte
#define HMC5883L_DATA_ZOUT_H    0x05    // Z axis data high byte
#define HMC5883L_DATA_ZOUT_L    0x06    // Z axis data low byte
#define HMC5883L_DATA_YOUT_H    0x07    // Y axis data high byte
#define HMC5883L_DATA_YOUT_L    0x08    // Y axis data low byte
#define HMC5883L_STATUS         0x09    // status register
#define HMC5883L_ID_A           0x0A    // identification register A
#define HMC5883L_ID_B           0x0B    // identification register B
#define HMC5883L_ID_C           0x0C    // identification register C

/* gain settings (bits 7-5 of configuration register B) */
#define HMC5883L_GAIN_0880      0x00    // +/-0.88 Ga, 1370 LSB/Ga
#define HMC5883L_GAIN_1300      0x20    // +/-1.3 Ga, 1090 LSB/Ga (recommended)
#define HMC5883L_GAIN_1900      0x40    // +/-1.9 Ga, 820 LSB/Ga
#define HMC5883L_GAIN_2500      0x60    // +/-2.5 Ga, 660 LSB/Ga

/* data structure */
typedef struct {
    float mx, my, mz;       // calibrated field (Ga)
    int16_t raw_mx, raw_my, raw_mz;
} Mag_Data_t;

/* public interface */
uint8_t HMC5883L_Init(void);
void    HMC5883L_ReadRaw(Mag_Data_t *mag);
uint8_t HMC5883L_CheckID(void);

#endif

HMC5883L driver

// hmc5883l.c
#include "hmc5883l.h"
#include "i2c.h"
#include <math.h>

extern void I2C_WriteReg(uint8_t dev, uint8_t reg, uint8_t data);
extern uint8_t I2C_ReadReg(uint8_t dev, uint8_t reg);
extern void I2C_ReadBytes(uint8_t dev, uint8_t reg, uint8_t *buf, uint8_t len);
extern void Delay_ms(uint32_t ms);

/* sensitivity in LSB/Ga for a given gain setting */
static float HMC5883L_GetSensitivity(uint8_t gain_config)
{
    switch (gain_config & 0xE0) {
        case HMC5883L_GAIN_0880: return 1370.0f;
        case HMC5883L_GAIN_1300: return 1090.0f;
        case HMC5883L_GAIN_1900: return  820.0f;
        case HMC5883L_GAIN_2500: return  660.0f;
        default: return 1090.0f;
    }
}

static float mag_sensitivity = 1090.0f;   // default +/-1.3 Ga

uint8_t HMC5883L_Init(void)
{
    // 1. configuration A: 8-sample average, 15 Hz ODR, normal measurement
    //    bits 6-5 = 11 (8 averages), bits 4-2 = 100 (15 Hz), bits 1-0 = 00
    I2C_WriteReg(HMC5883L_ADDR, HMC5883L_CONFIG_A, 0x70);
    Delay_ms(10);

    // 2. configuration B: gain +/-1.3 Ga (0x20)
    I2C_WriteReg(HMC5883L_ADDR, HMC5883L_CONFIG_B, HMC5883L_GAIN_1300);
    mag_sensitivity = HMC5883L_GetSensitivity(HMC5883L_GAIN_1300);
    Delay_ms(10);

    // 3. mode register: continuous measurement (0x00)
    I2C_WriteReg(HMC5883L_ADDR, HMC5883L_MODE, 0x00);
    Delay_ms(10);

    // 4. verify identification: should read 'H'=0x48, '4'=0x34, '3'=0x33
    uint8_t id_a = I2C_ReadReg(HMC5883L_ADDR, HMC5883L_ID_A);
    uint8_t id_b = I2C_ReadReg(HMC5883L_ADDR, HMC5883L_ID_B);
    uint8_t id_c = I2C_ReadReg(HMC5883L_ADDR, HMC5883L_ID_C);

    if (id_a == 0x48 && id_b == 0x34 && id_c == 0x33) {
        return 0;   // success
    }
    return 1;       // identification failed
}

void HMC5883L_ReadRaw(Mag_Data_t *mag)
{
    uint8_t buf[6];

    // data order is X_H X_L Z_H Z_L Y_H Y_L
    I2C_ReadBytes(HMC5883L_ADDR, HMC5883L_DATA_XOUT_H, buf, 6);

    mag->raw_mx = (int16_t)((buf[0] << 8) | buf[1]);
    mag->raw_mz = (int16_t)((buf[2] << 8) | buf[3]);
    mag->raw_my = (int16_t)((buf[4] << 8) | buf[5]);

    // convert to Ga
    mag->mx = mag->raw_mx / mag_sensitivity;
    mag->my = mag->raw_my / mag_sensitivity;
    mag->mz = mag->raw_mz / mag_sensitivity;
}

Note that the HMC5883L data registers are ordered X high, X low, Z high, Z low, Y high, Y low, which differs from the usual X-Y-Z order and has to be handled carefully when reading.

Complete nine-axis implementation

Data structures

// attitude9dof.h
#ifndef __ATTITUDE9DOF_H
#define __ATTITUDE9DOF_H

#include "mpu6050.h"
#include "hmc5883l.h"
#include "mag_calib.h"

typedef struct {
    float roll, pitch, yaw;   // Euler angles (degrees)
    float qw, qx, qy, qz;     // quaternion
} Attitude_t;

typedef struct {
    float q0, q1, q2, q3;
    float exInt, eyInt, ezInt;
    float Kp, Ki;              // PI gains
    float dt;                  // sample period (s)
    float mag_ref_norm;        // reference field strength (Ga)
    uint8_t mag_valid;         // magnetometer valid flag
} MahonyMARG_t;

void MahonyMARG_Init(MahonyMARG_t *m, float Kp, float Ki, float dt);
void MahonyMARG_Update(MahonyMARG_t *m,
                       const IMU_Data_t *imu,
                       const Mag_Data_t *mag,
                       const MagCalib_t *calib);
void MahonyMARG_GetEuler(const MahonyMARG_t *m, Attitude_t *att);

#endif

Mahony MARG implementation

// attitude9dof.c
#include "attitude9dof.h"
#include <math.h>

#define PI          3.14159265358979323846f
#define RAD_TO_DEG  (180.0f / PI)
#define DEG_TO_RAD  (PI / 180.0f)

void MahonyMARG_Init(MahonyMARG_t *m, float Kp, float Ki, float dt)
{
    m->q0 = 1.0f; m->q1 = m->q2 = m->q3 = 0.0f;
    m->exInt = m->eyInt = m->ezInt = 0.0f;
    m->Kp = Kp;
    m->Ki = Ki;
    m->dt = dt;
    m->mag_ref_norm = 0.48f;   // default reference field strength (Ga)
    m->mag_valid = 1;
}

void MahonyMARG_Update(MahonyMARG_t *m,
                       const IMU_Data_t *imu,
                       const Mag_Data_t *mag,
                       const MagCalib_t *calib)
{
    float ax = imu->ax, ay = imu->ay, az = imu->az;
    float gx = imu->gx * DEG_TO_RAD;
    float gy = imu->gy * DEG_TO_RAD;
    float gz = imu->gz * DEG_TO_RAD;
    float dt = m->dt;

    /* ===== 1. normalise the accelerometer ===== */
    float acc_norm = sqrtf(ax*ax + ay*ay + az*az);
    if (acc_norm > 1e-6f) {
        ax /= acc_norm; ay /= acc_norm; az /= acc_norm;
    }

    /* ===== 2. calibrate and normalise the magnetometer ===== */
    float mx, my, mz;
    MagCalib_Apply(calib, mag->mx, mag->my, mag->mz, &mx, &my, &mz);

    float mag_norm = sqrtf(mx*mx + my*my + mz*mz);
    uint8_t mag_ok = 0;
    if (mag_norm > 1e-6f) {
        /* disturbance detection */
        float ratio = mag_norm / m->mag_ref_norm;
        if (ratio > 0.7f && ratio < 1.3f) {
            mag_ok = 1;
            mx /= mag_norm; my /= mag_norm; mz /= mag_norm;
        }
    }
    m->mag_valid = mag_ok;

    /* ===== 3. gravity direction from the quaternion ===== */
    float q0 = m->q0, q1 = m->q1, q2 = m->q2, q3 = m->q3;

    float vx = 2.0f*(q1*q3 - q0*q2);
    float vy = 2.0f*(q0*q1 + q2*q3);
    float vz = q0*q0 - q1*q1 - q2*q2 + q3*q3;

    /* ===== 4. accelerometer error ===== */
    float ex = ay*vz - az*vy;
    float ey = az*vx - ax*vz;
    float ez = ax*vy - ay*vx;

    /* ===== 5. magnetometer error (the nine-axis core) ===== */
    if (mag_ok) {
        /* rotate the measurement into the reference frame (NED) */
        float hx = mx * (q0*q0 + q1*q1 - q2*q2 - q3*q3)
                 + my * 2.0f*(q1*q2 - q0*q3)
                 + mz * 2.0f*(q1*q3 + q0*q2);
        float hy = mx * 2.0f*(q1*q2 + q0*q3)
                 + my * (q0*q0 - q1*q1 + q2*q2 - q3*q3)
                 + mz * 2.0f*(q2*q3 - q0*q1);
        float hz = mx * 2.0f*(q1*q3 - q0*q2)
                 + my * 2.0f*(q2*q3 + q0*q1)
                 + mz * (q0*q0 - q1*q1 - q2*q2 + q3*q3);

        /* build the reference field direction: horizontal component points north */
        float bx = sqrtf(hx*hx + hy*hy);
        float bz = hz;

        /* rotate the reference field back into the body frame */
        float wx = bx * (q0*q0 + q1*q1 - q2*q2 - q3*q3)
                 + bz * 2.0f*(q1*q3 - q0*q2);
        float wy = bx * 2.0f*(q1*q2 + q0*q3)
                 + bz * 2.0f*(q2*q3 + q0*q1);
        float wz = bx * 2.0f*(q1*q3 - q0*q2)
                 + bz * (q0*q0 - q1*q1 - q2*q2 + q3*q3);

        /* cross-product error */
        float mx_est = wx, my_est = wy, mz_est = wz;
        float mag_n = sqrtf(mx_est*mx_est + my_est*my_est + mz_est*mz_est);
        if (mag_n > 1e-6f) {
            mx_est /= mag_n; my_est /= mag_n; mz_est /= mag_n;
        }

        ex += my*mz_est - mz*my_est;
        ey += mz*mx_est - mx*mz_est;
        ez += mx*my_est - my*mx_est;
    }

    /* ===== 6. PI integral correction with clamping ===== */
    if (m->Ki > 0.0f) {
        m->exInt += ex * m->Ki * dt;
        m->eyInt += ey * m->Ki * dt;
        m->ezInt += ez * m->Ki * dt;
        if (m->exInt >  0.5f) m->exInt =  0.5f;
        if (m->exInt < -0.5f) m->exInt = -0.5f;
        if (m->eyInt >  0.5f) m->eyInt =  0.5f;
        if (m->eyInt < -0.5f) m->eyInt = -0.5f;
        if (m->ezInt >  0.5f) m->ezInt =  0.5f;
        if (m->ezInt < -0.5f) m->ezInt = -0.5f;
    }

    /* ===== 7. corrected angular rate ===== */
    gx += m->Kp * ex + m->exInt;
    gy += m->Kp * ey + m->eyInt;
    gz += m->Kp * ez + m->ezInt;

    /* ===== 8. first-order Runge-Kutta quaternion update ===== */
    float halfdt = 0.5f * dt;

    m->q0 += (-q1*gx - q2*gy - q3*gz) * halfdt;
    m->q1 += ( q0*gx + q2*gz - q3*gy) * halfdt;
    m->q2 += ( q0*gy - q1*gz + q3*gx) * halfdt;
    m->q3 += ( q0*gz + q1*gy - q2*gx) * halfdt;

    /* ===== 9. normalise ===== */
    float norm = sqrtf(m->q0*m->q0 + m->q1*m->q1 + m->q2*m->q2 + m->q3*m->q3);
    if (norm > 1e-6f) {
        m->q0 /= norm; m->q1 /= norm; m->q2 /= norm; m->q3 /= norm;
    }
}

void MahonyMARG_GetEuler(const MahonyMARG_t *m, Attitude_t *att)
{
    float q0 = m->q0, q1 = m->q1, q2 = m->q2, q3 = m->q3;

    att->qw = q0; att->qx = q1; att->qy = q2; att->qz = q3;

    att->roll = atan2f(2.0f*(q0*q1 + q2*q3),
                       1.0f - 2.0f*(q1*q1 + q2*q2)) * RAD_TO_DEG;

    float sinp = 2.0f*(q0*q2 - q3*q1);
    if (sinp >=  1.0f) att->pitch =  90.0f;
    else if (sinp <= -1.0f) att->pitch = -90.0f;
    else att->pitch = asinf(sinp) * RAD_TO_DEG;

    att->yaw = atan2f(2.0f*(q0*q3 + q1*q2),
                      1.0f - 2.0f*(q2*q2 + q3*q3)) * RAD_TO_DEG;
}

Main program

// main.c
#include "mpu6050.h"
#include "hmc5883l.h"
#include "mag_calib.h"
#include "attitude9dof.h"
#include "usart.h"
#include <stdio.h>
#include <math.h>

int main(void)
{
    IMU_Data_t   imu;
    IMU_Calib_t  imu_calib;
    Mag_Data_t   mag;
    MagCalib_t   mag_calib;
    Attitude_t   att;
    MahonyMARG_t mahony;

    /* init */
    USART1_Init(115200);
    MPU6050_Init();
    HMC5883L_Init();

    /* MPU6050 static calibration (hold still for 2 s) */
    MPU6050_Calibrate(&imu_calib, 400);

    /* magnetometer ellipsoid calibration (figure-eight for about 10 s) */
    MagCalib_Init(&mag_calib);
    printf("Mag calibration: rotate 8-shape for 10s...\r\n");

    uint32_t cal_start = GetTickCount();
    while ((GetTickCount() - cal_start) < 10000) {
        HMC5883L_ReadRaw(&mag);
        MagCalib_UpdateMinMax(&mag_calib, mag.mx, mag.my, mag.mz);
        Delay_ms(20);
    }
    MagCalib_Compute(&mag_calib);
    printf("Mag offset: %.3f %.3f %.3f Ga\r\n",
           mag_calib.offset[0], mag_calib.offset[1], mag_calib.offset[2]);
    printf("Mag scale:  %.3f %.3f %.3f\r\n",
           mag_calib.scale[0], mag_calib.scale[1], mag_calib.scale[2]);

    /* init Mahony MARG */
    MahonyMARG_Init(&mahony, 2.0f, 0.005f, 0.005f);

    uint32_t lastTick = GetTickCount();
    uint32_t lastMagTick = 0;

    while (1) {
        /* 200 Hz IMU update */
        uint32_t now = GetTickCount();
        if ((now - lastTick) >= 5) {
            float dt = (now - lastTick) / 1000.0f;
            lastTick = now;
            mahony.dt = dt;

            /* read and calibrate MPU6050 */
            MPU6050_ReadRaw(&imu);
            MPU6050_ApplyCalib(&imu, &imu_calib);

            /* read HMC5883L every 20 ms (50 Hz is enough) */
            if ((now - lastMagTick) >= 20) {
                HMC5883L_ReadRaw(&mag);
                lastMagTick = now;
            }

            /* nine-axis fusion update */
            MahonyMARG_Update(&mahony, &imu, &mag, &mag_calib);

            /* output Euler angles */
            MahonyMARG_GetEuler(&mahony, &att);
            printf("%.2f,%.2f,%.2f,%d\r\n",
                   att.roll, att.pitch, att.yaw, mahony.mag_valid);
        }
    }
}

Experimental validation

Platform

The hardware was an STM32F103C8T6 at 72 MHz with 64 KB flash and 20 KB RAM, an MPU6050 module and an HMC5883L module. Sampling ran at 200 Hz for the IMU and 50 Hz for the magnetometer, with data streamed to a host over USART1 at 115200 bps.

Magnetometer calibration

Data was collected by figure-eight motion in a magnetically clean environment, then compared before and after ellipsoid fitting. Table 2, magnetometer before and after calibration:

Table comparing magnetometer calibration before and after ellipsoid fitting

Feeding an uncalibrated magnetometer into the Mahony algorithm produces badly distorted or violently oscillating attitude. After ellipsoid calibration, yaw accuracy improves markedly.

Static test

The device was held level and still while 60 seconds of data were collected. Table 3, static yaw drift over 60 seconds:

Table comparing yaw drift for six-axis and nine-axis fusion over 60 seconds

Roll and pitch accuracy stayed better than plus or minus 0.5 degrees in all three configurations, so the magnetometer correction does not disturb the horizontal attitude.

Dynamic test

The platform performed a rotate 360 degrees, stop, rotate again manoeuvre. The nine-axis filter tracked yaw smoothly through the rotation and settled quickly once rotation stopped. Gyro integration alone left a residual error of about 2 to 3 degrees after each rotation, and that error accumulated over time.

Magnetic disturbance test

A small magnet was brought near the sensor to simulate disturbance. With detection disabled, yaw jumped visibly. With detection enabled at a threshold of 0.15 times the reference value, magnetometer correction paused, yaw held on gyro integration, and correction resumed automatically once the magnet was removed, with a smooth transition and no jump.

Recommended parameters

Table 4, recommended nine-axis fusion parameters:

Table of recommended nine-axis fusion parameters including sample rates and Mahony gains

Discussion

Why calibration matters most

Magnetometer calibration is the single most critical preprocessing step in nine-axis attitude estimation. The sensor is easily disturbed by circuit boards, batteries, motors and ferromagnetic material, and an uncalibrated sensor produces distorted or oscillating attitude output. Ellipsoid fitting is the mainstream method, built on an error model covering scale factor, non-orthogonality, bias and soft and hard iron, solved by least-squares adjustment. A full 24-parameter model can substantially reduce the root-mean-square heading error. The simplified min-max algorithm here is aimed at fast deployment; where higher accuracy is needed, fit the ellipsoid in MATLAB or Python on a PC and export the parameters.

Adaptive handling of magnetic disturbance

In practice motors, power wiring and ferromagnetic structures create dynamic disturbance. When the measured magnitude departs from the reference by more than the threshold, magnetometer correction should pause and yaw should ride on gyro integration alone. After the disturbance clears, the magnetometer weight should return gradually to avoid an angle jump. This adaptive strategy matters especially in the electromagnetically complex environment of a drone or robot.

Axis alignment

The magnetometer’s own sensor frame may not be aligned with the MPU6050 body frame. The HMC5883L register order is X, Z, Y, and the physical mounting may add a rotational offset. The code must ensure the three magnetometer axes align strictly with the IMU axes, or yaw will show a periodic error. The usual fixes are to mount both sensors on the same PCB with parallel axes, or to swap axes and correct signs in software.

Comparison with GPS heading

A dual-antenna GPS heading solution reaches 0.05 to 0.5 degrees and is immune to electromagnetic interference, but it needs a dual-antenna receiver and a rigid baseline, which is expensive and unusable where the sky is blocked, such as an urban canyon. The HMC5883L costs very little, about five yuan, is small, and needs no external signal, but it is strongly affected by electromagnetic disturbance. Indoors, underground and in other GPS-denied environments, the magnetometer is the only available heading reference. A production system can integrate both and select adaptively by environment.

Conclusion

This paper examined roll, pitch and yaw estimation by fusing the HMC5883L magnetometer with the MPU6050. The main conclusions are:

  • The MPU6050 solves roll and pitch accurately but has no absolute yaw reference. The HMC5883L measures the geomagnetic vector and supplies that reference, forming a nine-axis MARG system that effectively suppresses yaw drift.
  • Ellipsoid calibration of the magnetometer is a precondition for nine-axis estimation. Accurate estimation of the hard iron offset and soft iron matrix directly determines yaw accuracy, and an uncalibrated magnetometer must not be used in the attitude solver.
  • The Mahony MARG complementary filter uses the horizontal component of the observed geomagnetic field as the low-frequency yaw reference and complements it against high-frequency gyro integration. After calibration, static yaw accuracy is better than plus or minus 1 degree and accumulated drift over 60 seconds is under 1 degree.
  • Magnetic disturbance detection with adaptive weighting is a key engineering detail that keeps a disturbed magnetometer from degrading the attitude estimate.
  • The complete implementation is directly usable in an embedded attitude measurement system and provides a base for adding GPS heading aiding or extending to an EKF or MEKF.

Appendix A: data structures and interfaces

/* MPU6050 */
typedef struct { float ax, ay, az; float gx, gy, gz; } IMU_Data_t;
typedef struct { float ax_bias, ay_bias, az_bias;
                 float gx_bias, gy_bias, gz_bias; } IMU_Calib_t;

/* HMC5883L */
typedef struct { float mx, my, mz;
                 int16_t raw_mx, raw_my, raw_mz; } Mag_Data_t;

/* magnetometer calibration */
typedef struct {
    float offset[3];     // hard iron offset
    float scale[3];      // per-axis scale correction
    float min_val[3];
    float max_val[3];
    uint16_t sample_count;
    uint8_t collecting;
} MagCalib_t;

/* Mahony MARG */
typedef struct {
    float q0, q1, q2, q3;
    float exInt, eyInt, ezInt;
    float Kp, Ki, dt;
    float mag_ref_norm;
    uint8_t mag_valid;
} MahonyMARG_t;

/* attitude output */
typedef struct { float roll, pitch, yaw;
                 float qw, qx, qy, qz; } Attitude_t;

Appendix B: build and debug notes

  • Toolchain: Keil MDK 5 with the STM32 standard peripheral library.
  • Target: STM32F103C8T6 at 72 MHz, 64 KB flash, 20 KB RAM.
  • I2C: I2C1 at 400 kHz, PB6 as SCL and PB7 as SDA, with the MPU6050 and HMC5883L sharing the bus.
  • Serial: USART1, 115200-8-N-1.

Debug sequence:

  1. On power-up, read the HMC5883L identification registers and confirm 0x48, 0x34 and 0x33.
  2. During magnetometer calibration, stay away from motors, power wiring and metal objects, and perform figure-eight motion at constant speed for about 10 seconds.
  3. Check that the printed offset and scale values are sensible, with offset normally inside plus or minus 0.5 Ga.
  4. Leave the device static and confirm yaw stays within plus or minus 1 degree.
  5. Rotate the device through 360 degrees and confirm yaw tracks smoothly with no jump.

Have questions about this article? Feel free to contact us at [email protected] — we’re happy to help!

Frequently Asked Questions

Why can’t the MPU6050 determine yaw on its own?

Because it has no magnetometer, so it has no absolute heading reference. Yaw comes from integrating the gyro’s Z axis, and that integral drifts with bias, temperature change and random walk, which is unusable for long-running navigation.

How accurate is yaw after magnetometer calibration?

Better than plus or minus 1 degree static, with accumulated drift under 1 degree over 60 seconds. Six-axis fusion without the magnetometer drifts about 6 degrees over the same period.

What does ellipsoid calibration actually correct?

Hard iron offset, which shifts the sphere’s centre away from the origin, and soft iron distortion plus scale and non-orthogonality errors, which turn the sphere into an ellipsoid. Fitting recovers the offset and the transform matrix.

How do I handle magnetic disturbance in flight?

Compare the measured field magnitude against a reference, typically 0.45 to 0.55 Ga, and pause magnetometer correction when the deviation exceeds about 0.15 times the reference. Yaw then rides on gyro integration and correction resumes gradually once the disturbance clears.

Why is the HMC5883L data register order unusual?

It outputs X high, X low, Z high, Z low, Y high, Y low, so the Z and Y axes are interleaved rather than following the usual X-Y-Z sequence. Reading the six bytes in the wrong order silently swaps two axes and corrupts yaw.

About Aomway

Aomway is a radio-frequency engineering company that designs and manufactures long-range FPV video transmitters, antennas, goggles and UAV communication links, and it publishes technical notes on sensor fusion, attitude estimation and embedded control. Its hardware is built for builders who need dependable links at range, and its documentation aims to explain the engineering behind each product.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top