Skip to content

Repository files navigation

FCDuino

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 help to 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

Quick Start

1. Install prerequisites

Linux (Ubuntu/Debian):

sudo apt update && sudo apt install make ruby cmake gcc

Windows: 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

2. Build

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/.

2. Flash

Flash build/MATEKF405/MATEKF405.hex the same way you would flash regular INAV (DFU, ST-Link, etc.).

3. Write your code

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.


The Programming Model

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

When is setup() called?

setup() is called after:

  1. All hardware is initialized (serial ports, SPI/I2C buses, sensors detected)
  2. 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.

Timing: use millis() instead of delay()

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.

Changing the loop rate

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,
},

Serial Output: LOG_*

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 C

Configuring the LOG port (one-time setup)

LOG 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/ttyACM0

This 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.


Available APIs

Timing

#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()

EVERY_MS / EVERY_US helper

// Defined in common/time.h
EVERY_MS(interval_ms) { /* runs at most once per interval */ }
EVERY_US(interval_us) { /* same, microsecond resolution */ }

Header conventions

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

Gyroscope

#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

Barometer

#include "sensors/barometer.h"

if (sensors(SENSOR_BARO)) {
    int32_t alt_cm = baroGetLatestAltitude();  // cm above boot altitude
}

Compass

#include "sensors/compass.h"

if (sensors(SENSOR_MAG)) {
    // heading available after calibration
}

Battery

#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);
}

GPIO

#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);

ADC

#include "drivers/adc.h"

uint16_t raw = adcGetChannel(ADC_BATTERY);   // 0–4095

Servos and Motors

#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 immediately

Output 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.

SPI / I2C

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.


CLI

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

INAV Configurator

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.


Adding a new board target

FCDuino works with any INAV 9.x target. To define a new board:

  1. Copy an existing target: cp -r src/main/target/MATEKF405 src/main/target/MYBOARD
  2. Edit target.h to define your pin assignments and USE_* flags
  3. 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.


What was removed from INAV

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.

About

Use any supported INAV flight controller as a general-purpose STM32 experimenter board — Arduino-style setup()/loop() API on top of INAV's sensor and hardware drivers

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages