Use any flight controller as a general-purpose STM32 experimenter board.
FCDuino is a stripped-down build of INAV that removes the flight control logic (PID loops, navigation, RC receiver) while keeping everything that makes INAV powerful for hardware development:
- Hundreds of supported boards — any board with an INAV 9.x target works
- Full sensor driver library — gyro, accel, barometer, compass, GPS, rangefinder, airspeed, ADC
- SPI / I2C / UART / USB — all bus abstractions available
- PWM output — drive servos, motors, or any PWM device
- Interactive CLI — connect a serial terminal and type
helpto inspect settings and sensor values - MSP protocol — use the INAV Configurator to view live sensor data without writing a GUI
- Parameter storage — settings survive reboot, stored in flash
Linux (Ubuntu/Debian):
sudo apt update && sudo apt install make ruby cmake gccWindows: use WSL2 with Ubuntu, then follow the Linux steps above. Full WSL2 setup guide: Building in Windows with WSL2
For other distros (Fedora, Arch) and cross-compiler version notes see: Building in Linux
git clone https://github.com/sensei-hacker/fcduino.git
cd fcduino
mkdir -p build && cd build
cmake .. -DTOOLCHAIN=ARM
make MATEKF405 -j$(nproc)Replace MATEKF405 with your board's target name. All targets are in src/main/target/.
Flash build/MATEKF405/MATEKF405.hex the same way you would flash regular INAV (DFU, ST-Link, etc.).
Edit src/main/user/user_code.c. Implement setup() and loop():
#include "user/user_code.h" // platform, log, LED, millis, EVERY_MS
#include "fc/runtime_config.h" // sensors()
#include "sensors/sensors.h" // SENSOR_GYRO etc.
#include "sensors/gyro.h"
void setup(void)
{
LOG_INFO(SYSTEM, "FCDuino ready");
}
void loop(void)
{
EVERY_MS(100) {
LOG_DEBUG(SYSTEM, "Gyro Z: %d dps", (int)gyroRateDps(2));
LED0_TOGGLE;
}
}Rebuild and reflash. That's it.
Ready-to-use examples are in src/main/user/examples/:
| File | What it does |
|---|---|
blink.c |
Blinks the LED — hello world |
gyro_read.c |
Prints gyro rates over USB serial |
servo_sweep.c |
Sweeps a servo back and forth |
motor_ramp.c |
Ramps a motor from idle to half throttle |
direction_tracker.c |
Reads accelerometer tilt and drives a servo to compensate |
Copy any example over user_code.c, rebuild, and flash.
FCDuino uses a cooperative task scheduler — the same one that runs INAV's sensor loops. Your loop() is one task among several; other tasks (serial processing, sensor reads, battery monitoring) run between calls to loop().
| Concept | Arduino | FCDuino |
|---|---|---|
| One-time init | setup() |
setup() |
| Repeating code | loop() |
loop() |
| Loop rate | As fast as possible | Configurable, default 1000 Hz |
| Serial output | Serial.print() |
LOG_INFO(SYSTEM, "fmt", ...) |
| LED control | digitalWrite(LED_PIN, HIGH) |
LED0_ON / LED0_TOGGLE |
setup() is called after:
- All hardware is initialized (serial ports, SPI/I2C buses, sensors detected)
- The IMU has been initialized (gyro bias calibration has been started)
setup() does not wait for calibration to complete, and it does not require the board to be level. Calibration runs in the background as part of the normal sensor task. You can check gyroIsCalibrationComplete() inside loop() if you need to wait.
The scheduler calls loop() as one task among many. delay() inside loop() blocks the entire CPU, preventing serial processing, sensor reads, and USB from running. Never use delay() inside loop().
Instead, use millis() and micros():
void loop(void)
{
static timeMs_t lastAction = 0;
if (millis() - lastAction >= 1000) { // every 1 second
lastAction = millis();
LOG_INFO(SYSTEM, "uptime: %lu s", (unsigned long)(millis() / 1000));
}
}For short periodic actions, use EVERY_MS():
void loop(void)
{
EVERY_MS(500) {
LOG_INFO(SYSTEM, "half-second tick");
LED0_TOGGLE;
}
EVERY_MS(100) {
// read sensor at 10 Hz while loop runs at 1000 Hz
LOG_DEBUG(SYSTEM, "Gyro Z: %d", (int)gyroRateDps(2));
}
}delay() is safe to call from setup() since the scheduler has not started yet.
Edit the TASK_USER_LOOP entry in src/main/fc/fc_tasks.c:
[TASK_USER_LOOP] = {
.taskName = "USER",
.taskFunc = taskUserLoop,
.desiredPeriod = TASK_PERIOD_HZ(1000), // 1000 Hz
.staticPriority = TASK_PRIORITY_LOW,
},FCDuino uses INAV's LOG_* macros for serial output. They accept standard printf-style format strings:
#include "common/log.h"
LOG_ERROR(SYSTEM, "error code: %d", code);
LOG_WARNING(SYSTEM, "voltage low: %d mV", getBatteryVoltage() * 10);
LOG_INFO(SYSTEM, "sensor detected: %s", "BMI270");
LOG_DEBUG(SYSTEM, "gyro X=%d Y=%d Z=%d",
(int)gyroRateDps(0), (int)gyroRateDps(1), (int)gyroRateDps(2));Output is prefixed with a timestamp: [ 1.234] Hello from FCDuino
Important: no %f float formatting. The tiny-printf implementation used on STM32 does not support %f to keep code size small. Cast to integer and scale instead:
float temp = 23.45f;
LOG_INFO(SYSTEM, "temp: %d.%02d C", (int)temp, (int)(temp * 100) % 100);
// prints: temp: 23.45 CLOG output is transmitted as MSP DEBUGMSG frames — not plain text. Run these commands once in the CLI, then save:
serial 20 32769 115200 115200 0 115200 # port 20 = USB VCP; 32769 = MSP(1) | LOG(32768)
set log_level = INFO
save
Port 20 is always the USB VCP in INAV. To read the output, run the included Python script:
python3 tools/read_log.py /dev/ttyACM0This decodes the MSP frames and prints plain text:
[ 1.234] gyro_read: waiting for gyro calibration...
[ 2.000] gyro_read: calibrating...
[ 3.000] roll: 0 pitch: 0 yaw: 1 (dps)
The tools/read_log.py script can also be used as a library — from read_log import read_log_lines yields decoded log strings from an open serial.Serial object.
#include "drivers/time.h"
timeMs_t now_ms = millis(); // milliseconds since boot (uint32_t)
timeUs_t now_us = micros(); // microseconds since boot (uint32_t)
delay(100); // block 100 ms — safe ONLY in setup()// Defined in common/time.h
EVERY_MS(interval_ms) { /* runs at most once per interval */ }
EVERY_US(interval_us) { /* same, microsecond resolution */ }Always put #include "user/user_code.h" first. It pulls in platform.h, stdbool.h, stdint.h, common/log.h, drivers/light_led.h, millis()/micros(), and EVERY_MS/EVERY_US — everything needed for a basic sketch. Add sensor-specific headers after:
#include "user/user_code.h" // always first
#include "sensors/gyro.h" // add what you need#include "fc/runtime_config.h" // sensors()
#include "sensors/sensors.h" // SENSOR_GYRO, SENSOR_BARO, etc.
#include "sensors/gyro.h"
// Check if gyro hardware was detected
bool present = sensors(SENSOR_GYRO);
// Raw rotation rate in degrees/second on each axis
int16_t roll_dps = gyroRateDps(0); // X
int16_t pitch_dps = gyroRateDps(1); // Y
int16_t yaw_dps = gyroRateDps(2); // Z
bool calibrated = gyroIsCalibrationComplete();
int16_t board_temp = gyroGetTemperature(); // degrees C * 10#include "sensors/barometer.h"
if (sensors(SENSOR_BARO)) {
int32_t alt_cm = baroGetLatestAltitude(); // cm above boot altitude
}#include "sensors/compass.h"
if (sensors(SENSOR_MAG)) {
// heading available after calibration
}#include "fc/config.h" // FEATURE_VBAT enum
#include "config/feature.h" // feature()
#include "sensors/battery.h"
if (feature(FEATURE_VBAT)) {
// Voltage in units of 10 mV (e.g. 1260 = 12.60 V)
uint16_t v = getBatteryVoltage();
LOG_INFO(SYSTEM, "battery: %d.%01d V", v / 100, (v % 100) / 10);
}#include "drivers/io.h"
IO_t pin = IOGetByTag(IO_TAG(PA5)); // board pin from target.h
IOInit(pin, OWNER_FREE, 0);
IOConfigGPIO(pin, IOCFG_OUT_PP);
IOWrite(pin, true); // HIGH
IOWrite(pin, false); // LOW
bool state = IORead(pin);#include "drivers/adc.h"
uint16_t raw = adcGetChannel(ADC_BATTERY); // 0–4095#include "user/pwm.h"
/* Servos — standard 50 Hz PWM, always available */
servoWrite(0, 90); // servo 0 to centre (0–180 degrees)
servoPulse(0, 1750); // servo 0, raw microseconds (1000–2000)
/* Motors — call motorArm() once in setup() before writing values */
motorArm(); // enable motor outputs
motorWrite(0, 1050); // motor 0 at idle throttle (1000–2000 µs)
motorDisarm(); // cut all motors immediatelyOutput indices are the board's labelled pads in order: S1=0, S2=1 … for servos and M1=0, M2=1 … for motors.
Motor protocol (standard PWM, DSHOT300, etc.) is set in the CLI:
set motor_pwm_protocol = DSHOT300
save
WARNING: remove propellers before running motor code.
Use the bus abstraction in drivers/bus.h. See drivers/barometer/barometer_bmp280.c as a worked example of an I2C device, or drivers/accgyro/accgyro_mpu6000.c for SPI.
Connect to the board's USB port (115200 baud) and type # to enter the CLI:
# help — list all commands
# status — show sensor status and detected hardware
# serial — show/configure serial ports
# tasks — show scheduler task CPU usage
# save — write settings to flash
# exit — return to normal operation
FCDuino speaks INAV's MSP protocol. Connect the INAV Configurator and use the Sensors tab to see live gyro, accelerometer, barometer, and GPS data — without writing any display code.
FCDuino works with any INAV 9.x target. To define a new board:
- Copy an existing target:
cp -r src/main/target/MATEKF405 src/main/target/MYBOARD - Edit
target.hto define your pin assignments andUSE_*flags - Build:
make MYBOARD
A minimal target.h only needs USE_IMU_*, bus definitions (USE_SPI, USE_UART*), and USE_BARO/USE_MAG for any extra sensors.
| Removed | Reason |
|---|---|
Navigation (navigation/) |
GPS waypoint / RTH / position hold |
PID controller (flight/pid.c) |
Rate and angle stabilization |
Mixer (flight/mixer.c) |
Motor/servo mixing |
IMU attitude estimator (flight/imu.c) |
Euler angle fusion — raw gyro/accel still available |
RC receiver (rx/) |
SBUS, CRSF, PPM, etc. |
Failsafe (flight/failsafe.c) |
RC signal loss handling |
OSD (io/osd*.c, cms/) |
Pilot on-screen display |
Telemetry (telemetry/) |
RC link telemetry (SmartPort, CRSF, etc.) |
VTX control (io/vtx*.c) |
Video transmitter control |
Logic conditions (programming/) |
In-flight scripting engine |
Blackbox (blackbox/) |
Flight data recorder |
All hardware drivers, sensor drivers, bus abstractions, the scheduler, EEPROM/settings, CLI, and MSP are retained.