Lab 103 (AI KIT): Reading the BMI270 and BMM350 to Compute Pitch, Roll, and Yaw
Introduction
In this lab, we combine the two motion sensors on the PSoC 6 AI Evaluation Kit — the Bosch BMI270 6-axis IMU (accelerometer + gyroscope) and the Bosch BMM350 3-axis magnetometer — to compute the board's attitude angles: Pitch, Roll, and Yaw.
Reading raw sensor data is only the first step. To turn raw numbers into stable, trustworthy attitude angles, we must also solve three practical problems that every real-world motion system faces:
- Sensor calibration — removing the gyroscope bias and magnetometer hard-iron offset;
- Axis alignment — the BMI270 and BMM350 are two independent chips whose axes do not necessarily point in the same direction on the PCB;
- Deterministic sampling — replacing software polling with the sensor's data-ready hardware interrupt so that the integration time step Δt is constant.
The attitude estimation pipeline developed in this lab serves as the foundation for a drone or RC airplane flight controller. Every flight controller on the market performs the same sequence: calibrate at power-up, align the sensor axes to the body frame, sample at a fixed rate, and fuse accelerometer, gyroscope, and magnetometer data into attitude angles. After completing this lab, you will understand why a drone asks you to "keep the aircraft still" before it arms.
Prerequisites
Hardware:
- Infineon CY8CKIT-062S2-AI PSoC 6 AI Evaluation Kit
- USB Type-C cable (connected to the KitProg3 port)
Software:
- ModusToolbox IDE
- Termite, Tera Term, or any serial terminal (115200, 8N1)
Reading Materials
- PSoC 6 AI Kit: CY8CKIT-062S2-AI
- Datasheet:
Background and Theory of Operation
Bosch BMI270 6-Axis IMU Sensor
Bosch BMI270 6-Axis IMU Sensor
Overview of the BMI270 Sensor:
The BMI270 is a 6-axis Inertial Measurement Unit (IMU) that contains a 16-bit triaxial accelerometer and a 16-bit triaxial gyroscope. This kit communicates with the PSoC 6 MCU via the I2C interface, using a default I2C address of 0x68 (the SDO pin is tied to GND on the board).
Step 1: Device Initialization (Config File Upload)
The BMI270 is unusual among IMUs: after power-up or soft reset, an 8-KB configuration blob (bmi270_config_file[], provided by Bosch) must be uploaded into the chip before any sensor can be enabled. This blob is the firmware of the internal Feature Engine — a small processor inside the sensor that later labs will use for step counting and gesture detection:
- Soft Reset: write 0xB6 to the CMD register (0x7E), then wait 2 ms.
- Disable Advanced Power Save: write 0x00 to PWR_CONF (0x7C), then wait at least 450 µs.
- Write 0x00 to the INIT_CTRL register (0x59) to prepare for the configuration load.
- Burst-write the initialization array (bmi270_config_file) to the INIT_DATA register (0x5E) in chunks. Before each chunk, program the word address (byte index / 2) into INIT_ADDR_0/1 (0x5B/0x5C).
- Write 0x01 to the INIT_CTRL register (0x59) to complete the configuration load.
- Wait at least 20 ms, and then read the INTERNAL_STATUS register (0x21). Bits [3:0] must equal 0b0001 to confirm successful initialization.
Step 2: Enable Sensors and Configure Normal Power Mode
To continuously read both the acceleration and gyroscope data, you must switch the device to normal power mode and enable the corresponding sensors.
- Enable the sensors: Write 0x0E to the PWR_CTRL register (0x7D). This enables the acquisition of acceleration, gyroscope, and temperature sensor data while disabling the auxiliary interface.
- Keep advanced power save disabled: Write 0x02 to the PWR_CONF register (0x7C). This disables the advanced power save bit but leaves the FIFO self-wakeup enabled.
Step 3: Configure Output Data Rates (ODR) and Filters
Next, set the sample rates and bandwidth filters for the sensors:
- Accelerometer Configuration: Write 0xA8 to the ACC_CONF register (0x40). This enables the accelerometer performance filter, sets the bandwidth to normal mode, and sets the Output Data Rate (ODR) to 100 Hz. Write 0x00 to ACC_RANGE (0x41) for ±2 g (16384 LSB/g).
- Gyroscope Configuration: Write 0xA8 to the GYR_CONF register (0x42). This enables the gyroscope performance filter, sets the bandwidth to normal mode, and sets the ODR to 100 Hz. Write 0x00 to GYR_RANGE (0x43) for ±2000 dps (16.4 LSB/dps).
Step 4: Reading Acceleration and Gyroscope Data
The 16-bit data for each axis is split into an LSB (lower) and an MSB (upper). You must always read the LSB register first, because doing so locks the MSB register to ensure data integrity (known as the shadowing procedure). A burst read handles this automatically.
- Acceleration Data is stored in registers DATA_8 to DATA_13 (0x0C to 0x11).
- Gyroscope Data is stored in registers DATA_14 to DATA_19 (0x12 to 0x17).
Reading operation: To capture all 6 axes of data at once, perform a burst read of 12 bytes starting at register address 0x0C.
- Bytes 0-5 will contain the X, Y, and Z acceleration data.
- Bytes 6-11 will contain the X, Y, and Z gyroscope data.
Bosch BMM350 3-Asix Magnetic Sensor
Bosch BMM350 3-Asix Magnetic Sensor
Critical I2C protocol difference: every I2C read from the BMM350 returns 2 dummy bytes before the real data. To read N valid bytes, the master must clock in N + 2 bytes and discard the first two. If the dummy bytes are not handled, even the Chip ID check will fail. (The Bosch SensorAPI handles this internally; a bare-metal driver must handle it explicitly.)
Board-specific note: on the CY8CKIT-062S2-AI, the ADSEL pin of the BMM350 is tied high, so the I2C slave address is 0x15 — not the datasheet default of 0x14. This is documented in the kit schematic (sheet 10).
Overview of the BMM350 Sensor
The BMM350 is a 3-axis magnetic sensor (magnetometer) for sensing geomagnetic field direction and strength. This kit communicates via the I2C interface, using a default I2C address of 0x15.
Step 1: Device Initialization
After the device boots, it starts in suspend mode and downloads compensation coefficients from the One-Time Programmable (OTP) memory. To complete initialization:
- Soft reset: write 0xB6 to the CMD register (0x7E), then wait 24 ms. During boot, the device automatically downloads its compensation coefficients from OTP memory.
- Verify the Chip ID: register 0x00 must read 0x33 (remember the 2 dummy bytes).
- Magnetic reset: write the FGR command (0x05) to PMU_CMD register (0x06) and wait 18 ms, then write the BR command (0x07) and wait 14 ms. This clears any residual magnetization of the sensing element.
- Write 0x80 to the OTP_CMD_REG register (0x50). This terminates the boot phase and makes the OTP inaccessible until the next reset.
Step 2: Configure ODR / Averaging, Enable Sensors, and Configure Normal Power Mode
Before taking measurements, you must enable the specific sensing axes and transition the device into normal power mode:
- Configure ODR and Averaging: Write to the PMU_CMD_AGGR_SET register (0x04). Bits [3:0] define the ODR, and bits [5:4] define the averaging factor. For example, writing 0x14 sets the ODR to 100 Hz (0x04) and sets the averaging to 2 samples, or 0x24 sets averaging = 4 and ODR = 100 Hz.
- Apply the Update: Whenever you change the ODR or averaging settings, you must write the UPD_OAE command (0x02) to the PMU_CMD register (0x06) and wait 1 ms for the new configuration to take effect.
- Enable the axes: Write 0x07 to the PMU_CMD_AXIS_EN register (0x05) to enable all three magnetic channels (X, Y, and Z axes).
- Enter Normal Mode: Write 0x01 to the PMU_CMD register (0x06). This transitions the sensor from suspend mode to normal mode, where the internal oscillator automatically triggers conversions.
Step 3: Reading Magnetometer Data
The magnetic data for the X, Y, and Z axes are stored in adjacent registers from 0x31 to 0x39. Each axis uses three 8-bit registers (XLSB, LSB, MSB) to form a 24-bit register that represents data in a 21-bit signed-integer format.
- X-axis Data is stored in registers MAG_X_XLSB to MAG_X_MSB (0x31 to 0x33).
- Y-axis Data is stored in registers MAG_Y_XLSB to MAG_Y_MSB (0x34 to 0x36).
- Z-axis Data is stored in registers MAG_Z_XLSB to MAG_Z_MSB (0x37 to 0x39).
Reading operation:
- You must perform a single burst read of 9 (+2 dummy) bytes starting at register address 0x31. Burst reading is strictly required because it halts data register updates during the readout process, preventing inconsistent or corrupted data.
- Note on data: The digital data read from these registers is uncompensated. The raw data is uncompensated; a fixed scale factor of approximately 0.00707 µT/LSB (X/Y) and 0.00718 µT/LSB (Z) is sufficient for heading computation, because the heading is a ratio-based calculation. To ensure accurate magnetic field readings and account for temperature effects and sensitivity errors, it is highly recommended to use the provided API functions (e.g., BMM350_read_mag_data_and_compensate) to process the raw values.
From Raw Data to Attitude Angles
From Raw Data to Attitude Angles
Pitch and Roll from gravity. When the board is not accelerating, the accelerometer measures the gravity vector. The tilt angles follow directly from its components:
pitch = atan2( −ax, √(ay² + az²) ) , roll = atan2( ay, az )
Because both formulas are ratios of axis readings, the accelerometer scale factor cancels out — the raw LSB values can be used directly.
Why can't the accelerometer give Yaw? Yaw is a rotation about the gravity axis; rotating the board on a level table does not change the gravity vector. A second reference vector is required: Earth's magnetic field, measured by the BMM350.
Tilt-compensated Yaw (compass heading). The magnetic field components must first be mathematically "rotated back" to the horizontal plane using the pitch and roll angles:
Xh = mx·cosθ + my·sinφ·sinθ + mz·cosφ·sinθ
Yh = my·cosφ − mz·sinφ
yaw = atan2( −Yh, Xh )
where θ is the pitch angle, and φ is the roll angle.
Gyroscope bias and drift. A real gyroscope outputs a small non-zero value even when perfectly still (the bias). If the angular rate is integrated over time without removing the bias, the resulting angle drifts continuously. The bias differs from chip to chip and changes with temperature, so it must be measured at every power-up — by averaging N samples while the board is stationary. This is exactly what a drone does during its arming sequence.
Axis alignment. The BMI270 and BMM350 are independent chips; the "+X" direction printed in each datasheet depends on how each package is oriented on the PCB. Before the tilt-compensation formula can work, the magnetometer axes must be remapped (sign flips and/or axis swaps) into the same body frame as the IMU. In this lab, remapping is performed in software using three macros; a later lab performs the same operation in hardware using the SensorAPI's axis-remap feature.
Schematic Circuit and Pin Configuration
Both sensors share the same I2C bus (SCB0). All peripherals in this lab are configured at runtime using the cyhal API — the Device Configurator is not used.
| Function | PSoC 6 Pin | Peripheral | Note |
| I2C SCL | P0.[2] | SCB0 | Shared sensor bus |
| I2C SDA | P0.[3] | SCB0 | Shared sensor bus |
| BMI270 | — | I2C addr 0x68 | SDO tied to GND |
| BMM350 | — | I2C addr 0x15 | ADSEL is tied high on this board |
| BMI270 INT1 | P1.[5] | GPIO interrupt | Data-ready interrupt (Experiment C4) |
| BMI270 INT2 | P0.[4] | GPIO | Not used in this lab |
| BMM350 INT | P1.[0] | GPIO | Not used in this lab |
| Debug UART TX | P5.[1] | SCB5 | KitProg3 USB-UART, 115200 8N1 |
| Debug UART RX | P5.[0] | SCB5 | KitProg3 USB-UART |
| User LED1 | P5_3 | GPIO | Heartbeat indicator |
| User Button | P5.[2] | GPIO | Active low, internal pull-up |
The firmware follows the modular architecture used throughout this lab series:
- Board_Config.h — all physical pin definitions in one place
- I2C_Bus.c/.h — I2C_ReadBytes() / I2C_WriteBytes() primitives
- UART_Console.c/.h — printf-free UART output functions
- GPIO_Control.c/.h — LED and button
- BMI270.c/.h, BMM350.c/.h — register-level sensor drivers
Lab Experiments
Experiment #1: Read Raw IMU Data and Compute Pitch and Roll
Initialize the BMI270, burst-read the 12 data bytes at 50 Hz, and compute the two tilt angles:
float ax = (float)imu.ax, ay = (float)imu.ay, az = (float)imu.az;
float pitchRad = atan2f(-ax, sqrtf(ay * ay + az * az));
float rollRad = atan2f( ay, az);
UART_PrintString("Pitch: ");
UART_PrintNumber((double)(pitchRad * RAD_TO_DEG));
UART_PrintString(" Roll: ");
UART_PrintNumber((double)(rollRad * RAD_TO_DEG));
UART_PrintNewLine();
Verification: with the board flat on the table, both angles should read close to 0°. Tilt the board forward/backward and left/right; the corresponding angle should follow smoothly. Shake the board briefly — observe how linear acceleration corrupts the tilt estimate. This observation motivates the complementary filter in Experiment #5.
Experiment #2: Tilt-Compensated Yaw from the Magnetometer, with Axis Alignment
Initialize the BMM350 and add the tilt-compensation computation. Because the two chips may not share the same axis orientation on the PCB, route all magnetometer data through three alignment macros before using it:
/* Adjust these three lines until yaw behaves correctly */
#define MAG_ALIGN_X(m) ( (m).mx )
#define MAG_ALIGN_Y(m) ( (m).my )
#define MAG_ALIGN_Z(m) ( (m).mz )
float mx = MAG_ALIGN_X(mag), my = MAG_ALIGN_Y(mag), mz = MAG_ALIGN_Z(mag);
float cp = cosf(pitchRad), sp = sinf(pitchRad);
float cr = cosf(rollRad), sr = sinf(rollRad);
float xh = mx * cp + my * sr * sp + mz * cr * sp;
float yh = my * cr - mz * sr;
float yaw = atan2f(-yh, xh) * RAD_TO_DEG;
if (yaw < 0.0f) { yaw += 360.0f; }
Verification procedure: keep the board level and rotate it slowly through a full circle while comparing against a phone compass. If yaw rotates in the wrong direction or jumps when the board is tilted, modify the sign/swap in the alignment macros (for example, (-(m).my)) until the behavior is correct. Record the final mapping in your lab report — it is a property of this PCB layout.
Experiment #3: Static Calibration — Gyroscope Bias and Magnetometer Hard-Iron Offset
Part A — gyroscope bias. At startup, while the board is stationary, average N samples of each gyro axis are stored as the bias. Subtract it from every subsequent reading:
#define CAL_SAMPLES (200u)
static float s_gyroBiasZ = 0.0f;
void Calibrate_GyroBias(void)
{
BMI270_Data_t d;
int32_t sumZ = 0;
UART_PrintString("Calibrating... keep the board still!\r\n");
for (uint32_t i = 0; i < CAL_SAMPLES; i++) {
if (BMI270_ReadAccelGyro(&d)) { sumZ += d.gz; }
cyhal_system_delay_ms(10);
}
s_gyroBiasZ = (float)sumZ / (float)CAL_SAMPLES;
UART_PrintString("Gyro Z bias = ");
UART_PrintNumber((double)s_gyroBiasZ);
UART_PrintString(" LSB\r\n");
}
Part B — magnetometer hard-iron offset. Press the user button to start a 15-second calibration window; rotate the board through a full circle in the horizontal plane. Record the min/max of each axis; the offset is the midpoint (max + min) / 2, which is then subtracted from every reading.
Measurement task: integrate the Z-axis gyro into a yaw angle with and without the bias subtraction, leave the board untouched for 60 seconds, and record the drift in degrees for both cases. Typical uncorrected drift is several degrees per minute; after correction, it should drop by an order of magnitude.
Connection to real systems: this two-part procedure is exactly what a flight controller does. "Do not move the drone while arming" = Part A. "Rotate the aircraft in a figure-eight" during compass calibration = the 3-D version of Part B.
Experiment #4: Data-Ready Interrupt and Fixed-Δt Sampling
Polling with cyhal_system_delay_ms(20) gives an unstable time step: the loop period is 20 ms plus the I2C transfer time plus the computation time. An unstable Δt directly degrades gyro integration. The fix is to let the BMI270 tell us when a new sample is ready, through its INT1 pin (wired to P1.[5] on this board).
Configure the BMI270 interrupt output (two register writes added to the driver init):
/* INT1_IO_CTRL (0x53): output enable | active high | push-pull */
WriteReg(0x53u, 0x0Au);
/* INT_MAP_DATA (0x58): map data-ready to INT1 (bit 2) */
WriteReg(0x58u, 0x04u);
Configure P1.[5] as a GPIO interrupt with the cyhal API:
static volatile bool s_dataReady = false;
static cyhal_gpio_callback_data_t s_cbData;
static void IMU_INT1_Handler(void *arg, cyhal_gpio_event_t event)
{
(void)arg; (void)event;
s_dataReady = true; /* ISR: set flag only */
}
void IMU_Interrupt_Init(void)
{
cyhal_gpio_init(PIN_IMU_INT1, CYHAL_GPIO_DIR_INPUT,
CYHAL_GPIO_DRIVE_NONE, false);
s_cbData.callback = IMU_INT1_Handler;
cyhal_gpio_register_callback(PIN_IMU_INT1, &s_cbData);
cyhal_gpio_enable_event(PIN_IMU_INT1, CYHAL_GPIO_IRQ_RISE,
CYHAL_ISR_PRIORITY_DEFAULT, true);
}
The main loop now waits for the flag instead of a fixed delay:
for (;;) {
if (s_dataReady) {
s_dataReady = false;
BMI270_ReadAccelGyro(&imu); /* dt is now exactly 1/ODR */
/* ... attitude computation and UART output ... */
GPIO_LED_Toggle();
}
}
With ODR = 100 Hz, the loop now runs at exactly 100 Hz and Δt = 10 ms exactly. Note the ISR design rule: the interrupt handler only sets a flag — all I2C traffic and floating-point math stay in the main loop.
Verification: toggle the LED inside the data-ready path and measure it (logic analyzer or oscilloscope on P5.[3]): the toggle rate must be a rock-stable 50 Hz square wave (100 Hz toggle). Compare against the polling version.
Experiment #5: (Optional): Complementary Filter
Exp#1 showed that the accelerometer angles are noisy during motion but drift-free over time; Exp#3 showed that gyro integration is smooth but drifts. The classic fix fuses the two — trust the gyro over short time scales and the accelerometer over long time scales:
#define ALPHA (0.98f) // gyro weight
// gyro rate in deg/s (bias already removed), dt = 1/ODR
float gyroRateX = ((float)imu.gx - s_gyroBiasX) / GYRO_LSB_PER_DPS;
rollFused = ALPHA * (rollFused + gyroRateX * dt)
+ (1.0f - ALPHA) * rollAccel;
Verification: shake the board while watching the raw accelerometer roll and the fused roll side by side on the terminal. The fused value should stay calm during the shake and settle to the accelerometer value within a second or two afterward. Experiment with α = 0.90, 0.98, and 0.999 and describe the trade-off. This filter is the doorway to the Mahony/Madgwick filters used in real flight controllers.
Q&A / Troubleshooting
Q1. The BMM350 Chip ID reads 0x00 or a wrong value.
Two usual causes: (1) the 2 dummy bytes were not discarded — the "Chip ID" you read is actually a dummy byte; (2) wrong slave address — on this board, the BMM350 answers at 0x15, not 0x14.
Q2. BMI270 INTERNAL_STATUS (0x21) stays at 0x00 or 0x02 after the config upload.
The 8-KB blob upload failed. Check that INIT_ADDR_0/1 is programmed with the word address (byte index / 2) before every chunk, that the chunk size divides 8192 evenly, and that PWR_CONF was set to 0x00 (with the 450 µs wait) before starting the upload.
Q3. Yaw rotates in the wrong direction, or shifts when the board tilts.
This is the axis-alignment problem from Exp#2. Adjust the sign/swap in the MAG_ALIGN_x macros. If yaw is stable but offset by a constant angle, that is the hard-iron offset — run the Exp#3 Part B calibration.
Q4. Yaw slowly rotates even when the board is untouched.
If yaw comes from gyro integration, this is normal bias drift — apply Exp#3 Part A. If yaw comes from the tilt-compensated magnetometer and still wanders, check for magnetic interference: USB cables, steel desks, and speakers all bend the local field.
Q5. The INT1 interrupt never fires.
Verify the two register writes: INT1_IO_CTRL (0x53) must have the output-enable bit set (0x0A), and INT_MAP_DATA (0x58) must map data-ready to INT1 (0x04). Also, confirm the GPIO event is CYHAL_GPIO_IRQ_RISE and that the callback was registered before the event was enabled.
Q6. No UART output at all.
Use the heartbeat LED as a divide-and-conquer tool: if the LED blinks but the terminal is silent, the problem is the UART or the terminal settings (115200, 8N1, correct KitProg3 COM port). If the LED never blinks, the program is stuck in sensor initialization — usually Q1 or Q2, as shown above.
Q7. Readings freeze at a constant value.
Almost always a broken burst read — reading MSB before LSB (BMI270 shadowing) or issuing single-register reads on the BMM350 instead of one burst starting at 0x31.