Building a custom Arduino flight controller from scratch is the ultimate test of your embedded systems knowledge. You are combining high-speed I2C sensor polling, digital signal filtering, and a real-time PID control loop into a single ATmega328P microcontroller. While commercial flight controllers running Betaflight or ArduPilot are the standard for 2026, writing your own bare-metal stabilization code on an Arduino Nano teaches you the physics and math that black-box firmware hides.
This guide targets the Arduino Nano (ATmega328P, 5V/16MHz) paired with the ubiquitous MPU6050 6-axis IMU. We will bypass the bloated DMP (Digital Motion Processor) libraries and write a lean, raw-data complementary filter with a PID loop that runs reliably at 250Hz.
Spec Sheet & Parts List
| Component | Exact Variant / Spec | Why This Variant? | Est. Price |
|---|---|---|---|
| Microcontroller | Arduino Nano (ATmega328P, 16MHz) | Lightweight (7g), breadboard-friendly, 5V tolerant logic. | $12.00 |
| IMU Sensor | GY-521 Breakout (MPU6050) | Integrated 3-axis gyro + 3-axis accel. Cheap and well-documented. | $4.50 |
| Logic Level Shifter | BSS138 Bi-directional (4-channel) | Critical: Steps down Nano's 5V I2C to MPU6050's 3.3V logic. | $2.00 |
| ESCs | 30A SimonK or BLHeli_S (Opto-isolated) | Opto-isolated prevents motor noise from back-feeding the Arduino 5V rail. | $32.00 (x4) |
| Motors | 2212 920KV Brushless | Standard 450-class quadcopter motors, forgiving for PID tuning. | $36.00 (x4) |
| Power | 3S LiPo (11.1V, 2200mAh, 40C) | Provides ample current burst for stabilization without voltage sag. | $22.00 |
Pin Mapping & Wiring the IMU
The most common point of failure in DIY flight controllers is I2C bus noise and logic-level mismatch. The MPU6050 operates at 3.3V. Feeding it 5V from the Arduino Nano's VCC pin will fry the internal voltage regulator, and feeding 5V logic into its SDA/SCL pins will eventually degrade the I2C transceivers. Use a BSS138 logic level converter for the data lines.
| Arduino Nano Pin | Destination | Wire Color (Standard) | Notes |
|---|---|---|---|
| 5V | Level Shifter HV / ESC BEC | Red | Power the high side of the shifter. Do NOT power motors from this. |
| 3.3V | MPU6050 VCC / Shifter LV | Orange | Use the Nano's onboard 3.3V regulator for the IMU. |
| GND | Common Ground | Black | All grounds (Battery, ESC, Nano, IMU) must meet at one star point. |
| A4 (SDA) | Level Shifter HV1 -> LV1 -> MPU SDA | Blue | Keep I2C wires under 4 inches to prevent capacitance issues. |
| A5 (SCL) | Level Shifter HV2 -> LV2 -> MPU SCL | Yellow | Ensure 4.7k pull-up resistors are on the 3.3V side. |
| D9 (PWM) | ESC 1 (Front Right) | White | Timer1 pin. Standard servo pulse. |
| D10 (PWM) | ESC 2 (Front Left) | White | Timer1 pin. Standard servo pulse. |
| D11 (PWM) | ESC 3 (Rear Right) | White | Timer2 pin. |
| D3 (PWM) | ESC 4 (Rear Left) | White | Timer2 pin. |
The PID Control Loop (Complete Code)
This code implements a complementary filter to merge the noisy but drift-free accelerometer with the smooth but drifting gyroscope. It then runs a basic Rate-mode PID controller. We use the standard Arduino Wire library to keep dependencies minimal and avoid the memory bloat of third-party DMP libraries.
#include <Wire.h>
#include <Servo.h>
// --- PIN DEFINITIONS ---
#define MOTOR_FR 9
#define MOTOR_FL 10
#define MOTOR_RR 11
#define MOTOR_RL 3
// --- IMU CONSTANTS ---
#define MPU_ADDR 0x68
#define ACCEL_FS_SEL 0 // +/- 2g
#define GYRO_FS_SEL 1 // +/- 500 deg/s
// --- PID GAINS (Tune these on the bench first!) ---
float Kp_roll = 1.2, Ki_roll = 0.02, Kd_roll = 15.0;
float Kp_pitch = 1.2, Ki_pitch = 0.02, Kd_pitch = 15.0;
float Kp_yaw = 2.0, Ki_yaw = 0.01, Kd_yaw = 0.0;
// --- VARIABLES ---
Servo escFR, escFL, escRR, escRL;
float angle_roll, angle_pitch, angle_yaw;
float pid_error_roll, pid_error_pitch, pid_error_yaw;
float pid_i_mem_roll, pid_i_mem_pitch, pid_i_mem_yaw;
float pid_output_roll, pid_output_pitch, pid_output_yaw;
int throttle, esc_pulse_fr, esc_pulse_fl, esc_pulse_rr, esc_pulse_rl;
unsigned long loop_timer;
void setup() {
Serial.begin(115200);
Wire.begin();
TWBR = 12; // Set I2C clock to 400kHz
// Initialize MPU6050
Wire.beginTransmission(MPU_ADDR);
Wire.write(0x6B); Wire.write(0x00); // Wake up
Wire.endTransmission(true);
Wire.beginTransmission(MPU_ADDR);
Wire.write(0x1B); Wire.write(GYRO_FS_SEL << 3);
Wire.endTransmission(true);
Wire.beginTransmission(MPU_ADDR);
Wire.write(0x1C); Wire.write(ACCEL_FS_SEL << 3);
Wire.endTransmission(true);
escFR.attach(MOTOR_FR); escFL.attach(MOTOR_FL);
escRR.attach(MOTOR_RR); escRL.attach(MOTOR_RL);
// ESC Calibration Sequence (Wait for user to plug in battery)
Serial.println("Connect battery now... Waiting 5s.");
delay(5000);
loop_timer = micros();
}
void loop() {
// 1. Read IMU Data
Wire.beginTransmission(MPU_ADDR);
Wire.write(0x3B);
byte status = Wire.endTransmission(false);
if (status != 0) {
Serial.print("ERR: I2C NACK on address (Code ");
Serial.print(status);
Serial.println("). Check wiring.");
// Failsafe: Cut motors
setMotors(1000);
delay(100);
return;
}
Wire.requestFrom(MPU_ADDR, 14, true);
int16_t accel_x = Wire.read()<<8 | Wire.read();
int16_t accel_y = Wire.read()<<8 | Wire.read();
int16_t accel_z = Wire.read()<<8 | Wire.read();
Wire.read(); Wire.read(); // Skip temp
int16_t gyro_x = Wire.read()<<8 | Wire.read();
int16_t gyro_y = Wire.read()<<8 | Wire.read();
int16_t gyro_z = Wire.read()<<8 | Wire.read();
// 2. Complementary Filter (0.9996 weight to gyro, 0.0004 to accel)
float dt = 0.004; // 250Hz loop time
float gyro_x_dps = gyro_x / 65.5;
float gyro_y_dps = gyro_y / 65.5;
float gyro_z_dps = gyro_z / 65.5;
float accel_roll = atan2(accel_y, accel_z) * 57.296;
float accel_pitch = atan2(accel_x, sqrt(accel_y*accel_y + accel_z*accel_z)) * 57.296;
angle_roll = 0.9996 * (angle_roll + gyro_x_dps * dt) + 0.0004 * accel_roll;
angle_pitch = 0.9996 * (angle_pitch + gyro_y_dps * dt) + 0.0004 * accel_pitch;
angle_yaw += gyro_z_dps * dt;
// 3. Receiver Input (Simulated via Serial for bench testing)
throttle = 1400; // Base hover throttle
float setpoint_roll = 0, setpoint_pitch = 0, setpoint_yaw = 0;
if (Serial.available()) {
char cmd = Serial.read();
if (cmd == 'a') setpoint_roll = 15;
if (cmd == 'd') setpoint_roll = -15;
if (cmd == 'w') setpoint_pitch = 15;
if (cmd == 's') setpoint_pitch = -15;
}
// 4. PID Calculations
pid_error_roll = setpoint_roll - angle_roll;
pid_i_mem_roll += Ki_roll * pid_error_roll;
pid_i_mem_roll = constrain(pid_i_mem_roll, -400, 400);
pid_output_roll = Kp_roll * pid_error_roll + pid_i_mem_roll + Kd_roll * (gyro_x_dps);
pid_error_pitch = setpoint_pitch - angle_pitch;
pid_i_mem_pitch += Ki_pitch * pid_error_pitch;
pid_i_mem_pitch = constrain(pid_i_mem_pitch, -400, 400);
pid_output_pitch = Kp_pitch * pid_error_pitch + pid_i_mem_pitch + Kd_pitch * (gyro_y_dps);
pid_error_yaw = setpoint_yaw - angle_yaw;
pid_i_mem_yaw += Ki_yaw * pid_error_yaw;
pid_output_yaw = Kp_yaw * pid_error_yaw + pid_i_mem_yaw;
// 5. Motor Mixing (X-Configuration)
esc_pulse_fr = throttle - pid_output_pitch + pid_output_roll - pid_output_yaw;
esc_pulse_fl = throttle - pid_output_pitch - pid_output_roll + pid_output_yaw;
esc_pulse_rr = throttle + pid_output_pitch - pid_output_roll + pid_output_yaw;
esc_pulse_rl = throttle + pid_output_pitch + pid_output_roll - pid_output_yaw;
// Constrain and write to ESCs
escFR.writeMicroseconds(constrain(esc_pulse_fr, 1000, 2000));
escFL.writeMicroseconds(constrain(esc_pulse_fl, 1000, 2000));
escRR.writeMicroseconds(constrain(esc_pulse_rr, 1000, 2000));
escRL.writeMicroseconds(constrain(esc_pulse_rl, 1000, 2000));
// Maintain 250Hz loop frequency
while(micros() - loop_timer < 4000);
loop_timer = micros();
}
void setMotors(int pulse) {
escFR.writeMicroseconds(pulse);
escFL.writeMicroseconds(pulse);
escRR.writeMicroseconds(pulse);
escRL.writeMicroseconds(pulse);
}
Debugging: First Three Things to Check When It Fails
Flight controllers fail in spectacular ways. Before you strap a LiPo to the frame and risk your workbench, run through this diagnostic sequence.
1. The I2C Bus Lockup
Exact Error String: ERR: I2C NACK on address (Code 2) or WHO_AM_I register returned 0x00
Ranked Causes:
- Missing Pull-up Resistors: The GY-521 breakout has 4.7k pull-ups, but if you are using a bare MPU6050 chip or a defective clone board, the I2C lines will float. Add 4.7k resistors from SDA/SCL to 3.3V.
- AD0 Pin Floating: The I2C address is determined by the AD0 pin. If it is floating, it will bounce between 0x68 and 0x69. Solder AD0 directly to GND to lock it to 0x68.
- Capacitance Overload: If your I2C wires are longer than 10cm, the bus capacitance exceeds the MPU6050's drive strength. Shorten the wires or drop the I2C clock speed from 400kHz to 100kHz by changing
TWBR = 12;toTWBR = 72;.
2. Motor Output Jitter and Twitching
Symptom: Motors randomly twitch or beep erratically while the Arduino is powered, even when the IMU is perfectly flat.
Ranked Causes:
- Servo.h Timer Clash: The standard
Servo.hlibrary uses Timer1, which can occasionally interrupt the I2C hardware buffer on the ATmega328P if the PID loop runs too fast. Switch to theServoTimer2library or write direct OCR register manipulation for hardware PWM. - Vibration Aliasing: The accelerometer is picking up 100Hz+ motor harmonics. Check your foam dampening. If the raw accel Z-axis reads anything other than 16384 (+/- 200) at idle, your mount is too rigid.
- Brownouts: The ESC BEC is failing to supply steady 5V to the Nano when the receiver or servos draw current. Power the Nano from a dedicated 5V UBEC, not the ESC's red wire.
3. The "Toilet Bowl" Effect (Circular Drift)
Symptom: The quadcopter takes off, but immediately begins flying in expanding circles.
Ranked Causes:
- Incorrect Motor Mixing: You have a CW motor on a CCW pad, or your PID roll/pitch signs are inverted. Verify propeller directions and motor spin directions match the X-configuration mixing math in the code.
- Untuned D-Gain: The derivative term (
Kd) is reacting to high-frequency noise. DropKd_rollandKd_pitchto 0.0, get the craft hovering on P and I alone, then slowly increase D until oscillations stop.
Extending and Simplifying the Build
If this is your first embedded control loop, a 4-axis quadcopter might be overwhelming. To simplify: Strip the code down to a 1-axis self-balancing robot or a single-rotor helicopter tail gyro. Comment out the pitch and yaw mixing, lock two motors, and tune the Roll PID on a fixed pivot rig. This isolates the math and keeps your hardware intact.
To extend: Once you have stable rate-mode flight, the next logical step is adding altitude hold. Wire an MS5611 barometric pressure sensor to the I2C bus (address 0x77). Implement a cascaded PID loop where the outer loop reads the barometer's Z-velocity and outputs a throttle offset, while the inner loop handles the angular stabilization. For advanced navigation, look into integrating a proven PID tuning framework and adding a UBlox M10 GPS via UART for position hold.
Arduino Flight Controller FAQ
Can I use an Arduino Uno instead of a Nano for a flight controller?
Technically yes, as they share the exact same ATmega328P microcontroller and pinout. Practically, no. The Uno weighs 25g compared to the Nano's 7g, and its female headers create a massive center of gravity issue on a small quadcopter frame. The Uno is excellent for bench-testing your PID code, but you should swap to a Nano (or a Pro Mini) for the actual flight hardware to save weight and reduce vibration leverage.
Why does my MPU6050 return all zeros on the I2C bus?
If an I2C scanner finds the device at 0x68, but reading registers returns an array of zeros, the MPU6050 is likely stuck in sleep mode or the internal PLL has failed to lock. Ensure you are writing 0x00 to the PWR_MGMT_1 register (0x6B) during setup. If that fails, the 3.3V LDO on the GY-521 breakout board may have overheated and shut down. Check the voltage on the VCC pin with a multimeter; it must be exactly 3.3V.
How do I tune the PID values without crashing the quadcopter?
Never tune PID values in the air on a custom Arduino build. Use the "Tethered Tuning" method. Zip-tie the quadcopter to a heavy workbench using bungee cords, allowing it to tilt but preventing it from flying away. Start with I and D at zero. Increase P until the craft oscillates rapidly, then cut it in half. Slowly add D to dampen the oscillations, and finally add a tiny amount of I to correct steady-state drift. Consult established Arduino hardware limits to ensure your loop timing isn't bottlenecking the tuning process.
Is an Arduino flight controller better than a commercial Betaflight FC?
For actual flying, no. A $40 commercial flight controller (like a SpeedyBee F405) runs a 168MHz ARM Cortex-M4 with hardware floating-point math, dedicated DMA for I2C/SPI, and years of community-tuned filtering algorithms. An Arduino Nano runs at 16MHz with 8-bit integer math. However, for learning embedded systems, sensor fusion, and control theory, the Arduino flight controller is vastly superior because it forces you to understand every byte of the data pipeline, rather than just changing a dropdown menu in a configurator app.






