case 02 · Personal · 2025

STM32 quadcopter

A flight controller built from scratch on the STM32F446RE — sensor fusion, PID stabilization, PWM motor drive, all in C on bare metal.

C STM32F446RE MPU6050 LIS3MDL PID I²C PWM
github.com/Prateek2174/stm32-quadcopter

What it does

The firmware reads the IMU and magnetometer over I²C, runs a PID loop against a target orientation, and outputs four PWM signals to the ESCs. UART is used for telemetry and tuning. The whole thing runs bare-metal on an STM32 NUCLEO-F446RE.

Modules I wrote

Each peripheral and sensor has its own driver — header in Core/Inc, implementation in Core/Src:

Core/
  Inc/
    gpio.h  // pin setup
    i2c.h   // I²C transactions
    mpu6050.h// 6-axis IMU
    lis3mdl.h// magnetometer
    pid.h   // PID controller
    pwm.h   // timer-driven ESC outputs
    usart.h // telemetry UART

What was interesting

Most of the work isn't in the controller math — it's in keeping the sensor data honest. The MPU6050 is noisy and drifts; the LIS3MDL helps anchor yaw but adds its own bias. Getting a stable estimate before the PID even runs is most of the battle.

Tuning the PID gains on a real quad is its own thing. You start with conservative P, no I, no D, and inch up until you can hear the oscillation; the textbook says one thing and the physics says another.

Core/Src/pid.c C

float pid_update(PID_controller *pid, float setpoint, float measurement){

	//Error signal
	float error = setpoint - measurement;

	//Proportional
	float proportional = pid -> kp * error;

	//Integral
	pid -> integrator = pid -> integrator + 0.5f*(pid -> ki * pid -> Ts)*(error - pid -> prev_error);

	//Anti-wind-up with dynamic integrator clamping
	//clamp lower than physical limit
	float min_limit_integrator;
	float max_limit_integrator;

	//Compute integrator limits
	if(pid -> max_output_signal > proportional){

		max_limit_integrator = pid -> max_output_signal - proportional;

	}else{

		max_limit_integrator = 0.0f;
	}

	if(pid -> min_output_signal < proportional){

			min_limit_integrator = pid -> min_output_signal - proportional;
		}else{

			min_limit_integrator = 0.0f;
		}

	//Clamp Integrator
	if(pid -> integrator > max_limit_integrator){

		pid -> integrator = max_limit_integrator;

	}else if(pid -> integrator < min_limit_integrator){

		pid -> integrator = min_limit_integrator;
	}

	//Derivative (band-limited differentiator)
	pid -> differentiator = -(2.0f * pid->kd * (measurement - pid->prev_measurement) +
		    				(2.0f * pid->tau - pid->Ts) * pid->differentiator)
		    				/ (2.0f * pid->tau + pid->Ts);

	//Compute Output and Apply Limits
	pid -> output = proportional + pid -> integrator + pid -> differentiator;

	if(pid -> output > pid->max_output_signal){

		pid->output = pid->max_output_signal;

	}else if(pid->output < pid->min_output_signal){

		pid->output = pid->min_output_signal;
	}

	//Store error and measurement
	pid->prev_measurement = measurement;
	pid -> prev_error = error;

	return pid -> output;

}
MCU
STM32F446RE @ 180 MHz
IMU
InvenSense MPU6050 (6-axis)
Magnetometer
ST LIS3MDL (3-axis)
Motor drive
4× PWM @ 50 Hz / 400 Hz to ESCs
Comms
I²C (sensors) · USART (telemetry)
IDE
STM32CubeIDE

What's next

The firmware core is running — sensor fusion, PID loop, and motor outputs are all working. What's still left before it flies:

  • RC receiver — wire up a PWM/PPM receiver so it can be flown manually before any autonomous mode is attempted
  • Flight testing & PID tuning — get it on a real frame and iterate on gains; textbook values and the actual airframe will disagree
  • Battery monitoring — ADC on the battery rail with a low-voltage cutoff to protect the LiPo
  • Madgwick filter — replace the current complementary fusion with a proper attitude filter to cut drift on longer flights
  • Flight logging — stream IMU, PID state, and motor outputs over UART at high rate for post-flight analysis and tuning