Skip to content

Stepper-C Library

Stepper-C (github.com/AntonVynohradov/Stepper-C, local folder sfs-stepper-ctrl) is the control library of the system: C11, no heap, no RTOS, no global state, two numeric backends and two power stage topologies. This chapter explains how to add it to a firmware project, configure it for a motor and a board, call its loops from the interrupts and check that it works. The functions are listed in the API Reference; the algorithms are explained in Control Algorithms.

Repository layout

Path Content
sstep.h umbrella header: include only this
inc/sstep_config.h build-time configuration macros
inc/sstep_defs.h status codes, topologies, fault flags, states
inc/sstep_math.h numeric backend: types, saturating inline operations
inc/sstep_ctrl.h the API: configuration, callbacks, loops, commands
src/sstep_ctrl.c initialisation, loops, commands
src/sstep_bridge.c voltage limit and leg mapping
src/sstep_motion.c motion generator
src/sstep_math.c, src/sstep_sin_table.c sine/cosine, gain conversions (table generated by tools/sstep_gen_sin_table.py)
sstep.cmake, CMakeLists.txt, CMakePresets.json build
tests/unit/ GoogleTest suites with a motor model (sstep_test_plant.hpp)
tests/compile/sstep_api_check.c compile check of the API with the reference configuration

Adding the library to a project

With CMake (the library is a subdirectory or a submodule):

set(SSTEP_MATH_BACKEND "FLOAT" CACHE STRING "" FORCE)   # STM32G4 has an FPU
add_subdirectory(ext/sfs-stepper-ctrl)
target_link_libraries(firmware PRIVATE sstep)
CMake variable Values Effect
SSTEP_MATH_BACKEND FIXED (default), FLOAT defines SSTEP_CFG_MATH_BACKEND
SSTEP_CONFIG_DIR folder adds the folder to the include path and defines SSTEP_USER_CONFIG, so sstep_user_config.h is used
SSTEP_UNIT_TESTS, SSTEP_INTEGRATION_TESTS, SSTEP_EXAMPLES ON/OFF test and example targets

Without CMake: compile the five files of src/, add the repository root and inc/ to the include path, and define SSTEP_CFG_MATH_BACKEND=SSTEP_MATH_BACKEND_FLOAT (or leave it for fixed point). The library needs only <stdint.h>, <stdbool.h> and <stddef.h>.

Build-time configuration

Macro Default Range Effect
SSTEP_CFG_MATH_BACKEND SSTEP_MATH_BACKEND_FIXED …_FIXED, …_FLOAT Q15/Q31 integer arithmetic or single-precision float
SSTEP_CFG_TOPOLOGY_FULL_BRIDGE 1 0, 1 compile support for two H-bridges
SSTEP_CFG_TOPOLOGY_SHARED_LEG 1 0, 1 compile support for three legs with a shared leg
SSTEP_CFG_MICROSTEPS_MAX 256 power of two 1 … 256 largest accepted microsteps
SSTEP_CFG_SINE_TABLE_BITS 8 6 … 10 quarter-wave table size 2^bits + 1 (flash)
SSTEP_CFG_ARG_CHECKS 1 0, 1 0 removes the pointer checks from sstep_fast_loop() only

Every macro is range-checked with #error. Set them on the compiler command line or in sstep_user_config.h:

/* sstep_user_config.h: Stepper-C for an STM32G431 driving one ESC1 */
#define SSTEP_CFG_MATH_BACKEND SSTEP_MATH_BACKEND_FLOAT
#define SSTEP_CFG_TOPOLOGY_FULL_BRIDGE 0     /* only the shared leg is needed */
#define SSTEP_CFG_MICROSTEPS_MAX 64U

Which backend? Use FIXED on cores without a hardware FPU (Cortex-M0/M0+/M3, RISC-V RV32IMC); use FLOAT where single-precision float is in hardware (Cortex-M4F/M7/M33F, STM32G4). Both give the same control behaviour within the resolution of Q15 (the closed loop tests pass with the same limits in both).

Configuring a driver

The run-time configuration is one structure in integer engineering units, copied by sstep_init(). This is the reference configuration of the 17HS19-2004S1 on the B-G431B-ESC1 — the same values as in the simulation:

#include "sstep.h"

extern int board_pwm_write(void* ctx, const uint16_t compare[SSTEP_LEGS_MAX]);
extern int board_output_enable(void* ctx, bool enable);

const struct sstep_config motor_cfg = {
    .motor = {
        .r_mohm = 1600U,             /* 1.60 Ohm */
        .l_uh = 3000U,               /* 3.0 mH */
        .i_rated_ma = 2000U,
        .ke_uv_s_per_rad = 208600U,  /* Kt = 0.2086 V*s/rad: back-EMF feed-forward */
        .full_steps_per_rev = 200U,
    },
    .stage = {
        .topology = SSTEP_TOPOLOGY_SHARED_LEG,
        .vbus_nominal_mv = 24000U,
        .vbus_min_mv = 10000U,
        .vbus_max_mv = 30000U,
        .i_full_scale_ma = 4000U,    /* ADC full scale (placeholder: from the schematic) */
        .i_trip_ma = 3500U,
        .pwm_period_ticks = 4250U,   /* 170 MHz, centre-aligned, 20 kHz */
        .pwm_freq_hz = 20000U,
        .dead_time_ns = 133U,        /* effective dead time from ngspice */
        .adc_i_zero = 2048U,
        .adc_i_span = 2047,
        .adc_vbus_mv_full = 34320U,
        .adc_full = 4095U,
    },
    .ctrl = {
        .drive_mode = SSTEP_DRIVE_CURRENT,
        .fast_loop_hz = 20000U,
        .slow_loop_hz = 1000U,
        .microsteps = 16U,
        .current_bw_hz = 1000U,
        .i_run_ma = 2000U,
        .i_hold_ma = 1000U,
        .hold_delay_ms = 100U,
        .v_max = 16000U,             /* 5 rev/s */
        .accel = 160000U,            /* 50 rev/s^2 */
        .decel = 0U,                 /* same as accel */
    },
    .hal = {
        .context = NULL,
        .pwm_write = board_pwm_write,
        .output_enable = board_output_enable,
        .on_fault = NULL,
        .on_event = NULL,
    },
};

How to get each value:

Field How to obtain it
r_mohm, l_uh datasheet, better measured (R with 4-wire, L at 1 kHz)
ke_uv_s_per_rad back-EMF amplitude / speed of the spun motor (Motor Model); 0 disables the feed-forward
vbus_* supply; vbus_min_mv < nominal < vbus_max_mv or 0 to disable a trip
i_full_scale_ma, adc_i_zero, adc_i_span from the shunt, the amplifier gain and offset: span = I_full · R_shunt · G / V_ref · adc_full; negative adc_i_span for an inverting amplifier; measure the zero with the outputs off
adc_vbus_mv_full, adc_full bus voltage divider: the bus voltage that gives the largest ADC reading
pwm_period_ticks timer auto-reload of the centre-aligned PWM (compare for 100 %)
dead_time_ns effective dead time (Power Stage Model); stored for a future compensation
current_bw_hz 1 kHz is a good start at 20 kHz; at most fast_loop_hz / 4
v_max, accel microsteps per second (and per second²); with M = 16 one revolution is 3200 microsteps

Current measurement resolution

The simulation assumes a 4 A full scale (1.95 mA per ADC count). The current sense amplifiers of motor control boards are often designed for tens of amperes; compute the counts per ampere from the schematic before choosing the run current — a resolution coarser than about 1 % of the run current makes the current loop noisy.

sstep_init() checks every field and returns SSTEP_ERR_PARAM for a value out of range or a missing required callback, SSTEP_ERR_CONFIG for inconsistent values (for example i_run_ma above i_trip_ma or i_rated_ma, fast_loop_hz not a multiple of slow_loop_hz, current_bw_hz above fast_loop_hz / 4, a bus voltage trip on the wrong side of the nominal value) and SSTEP_ERR_UNSUPPORTED for a topology that is not compiled in.

Connecting the loops to the hardware

The library expects:

  1. A centre-aligned PWM with one compare channel per leg. Compare 0 means the low side is on for the whole period, pwm_period_ticks the high side. Entries above the leg count of the topology are 0.
  2. Current sampling at the counter peak, the middle of the period where all low-side switches conduct: with low-side shunts the phase current flows through the shunt only then, and the sample equals the period average of the ripple.
  3. sstep_fast_loop() once per PWM period, right after the ADC conversion, with the raw counts of phase A, phase B and the bus voltage. The computed compare values are written by pwm_write() and take effect at the next timer update — the one-period delay the controller is designed for.
  4. sstep_slow_loop() at slow_loop_hz, from a timer task or the main loop, never concurrently with the commands (they run in the same context).
#include "sstep.h"

/* Board layer (your HAL): timer and ADC are configured elsewhere */
extern void timer_set_compare(uint32_t channel, uint16_t value);
extern void gate_driver_enable(bool enable);
extern uint16_t adc_read_injected(uint32_t rank);

static struct sstep drv;

static int pwm_write(void* ctx, const uint16_t compare[SSTEP_LEGS_MAX])
{
    (void)ctx;
    timer_set_compare(1U, compare[0]);   /* leg U: phase A+ */
    timer_set_compare(2U, compare[1]);   /* leg V: phase B+ */
    timer_set_compare(3U, compare[2]);   /* leg W: common   */
    return 0;
}

static int output_enable(void* ctx, bool enable)
{
    (void)ctx;
    gate_driver_enable(enable);          /* also: timer main output enable */
    return 0;
}

/* ADC end-of-conversion interrupt, triggered at the counter peak (20 kHz) */
void adc_injected_isr(void)
{
    const struct sstep_sample s = {
        .i_a = adc_read_injected(1U),
        .i_b = adc_read_injected(2U),
        .vbus = adc_read_injected(3U),
    };
    (void)sstep_fast_loop(&drv, &s);
}

/* 1 kHz timer task */
void control_task_1khz(void)
{
    (void)sstep_slow_loop(&drv);
}

int motor_start(struct sstep_config* cfg)
{
    cfg->hal.pwm_write = pwm_write;
    cfg->hal.output_enable = output_enable;
    int r = sstep_init(&drv, cfg);
    if (r == SSTEP_OK)
    {
        r = sstep_enable(&drv);
    }
    return r;
}

On the ESC1 the three legs are driven by the advanced timer TIM1 (one complementary channel per leg); phase A of the motor is connected between U and W, phase B between V and W. The exact channel assignment, the dead time register and the ADC trigger are documented with the port (P3).

Commanding motion

#include "sstep.h"

static void on_event(void* ctx, enum sstep_event ev)
{
    (void)ctx;
    if (ev == SSTEP_EVENT_MOVE_DONE)
    {
        /* target reached: start the next move, signal the host... */
    }
}

void demo(struct sstep* m)
{
    struct sstep_status st;

    (void)sstep_set_position(m, 0);              /* homing: here is zero */
    (void)sstep_move_to(m, 6400);                /* two revolutions forward (M = 16) */
    (void)sstep_set_motion_limits(m, 32000U, 160000U, 0U);  /* from the next slow period */
    (void)sstep_run(m, -12800);                  /* turn backwards at 4 rev/s, smoothly */
    (void)sstep_stop(m);                         /* decelerate and hold */
    (void)sstep_get_status(m, &st);              /* state, position, currents, Vbus */
    (void)on_event;
}

Commands are accepted in HOLDING and MOVING; they return SSTEP_ERR_STATE when the driver is disabled or faulted. A new command replaces the running one without a velocity step. sstep_set_position() is only allowed at standstill and keeps the rotor where it is (only the numbering changes).

Memory, timing and reentrancy

Item Value
RAM one struct sstep per motor (configuration copy, gains, two setpoint buffers, controller and motion state); allocate it statically
Flash 8.2 kB Cortex-M4F fixed point, 9.0 kB float, 8.5 kB Cortex-M0+ (clang -Os, both topologies, table included)
Fast loop work fixed: one sine/cosine, two PI, one leg mapping, at most one division; no loops over data except the bounded power-of-two pre-scale
Fast loop time on the MCU not measured yet (P3)
Reentrancy instances are independent; one instance must not be used from two contexts except the documented split (fast loop in the interrupt, everything else in one task)
Fault callback may be called from the interrupt

Quality

Check Result
Unit tests (GoogleTest) 76 tests fixed point, 75 float: math (29), bridge (5), motion (10), API (24), closed loop with a motor model (8)
Coverage 100 % lines and branches of src/ in both backends (gcov)
Sanitizers clean under AddressSanitizer and UndefinedBehaviorSanitizer
Against the prototype current step overshoot 1.11 % (prototype 1.12 %), settling 0.40 ms; 8 rev/s current 1.862 A / 1.557 A (prototype identical); load angle at 0.25 N·m 37.21° (prototype 37.21°)
Motion generator bit-exact with the Scilab/Python twin over 3 052 885 periods
MISRA C:2012 followed in the code (no dynamic memory, single exit, no recursion, explicit casts, every if with else); the Cppcheck MISRA target misra-analyze is configured — a full report is pending

Porting checklist

  1. Measure R, L and the back-EMF constant of the motor; enter them.
  2. Compute the ADC scaling of the currents and of the bus voltage from the schematic; measure the current zero with the outputs disabled.
  3. Configure a centre-aligned PWM at pwm_freq_hz with the timer dead time; trigger the current ADC at the counter peak.
  4. Call sstep_fast_loop() from the ADC interrupt, sstep_slow_loop() at 1 kHz.
  5. Start with a current-limited supply, i_run_ma at half the rated current and a low v_max; check the phase currents with a current probe.
  6. Run the same configuration in the SIL diagram and compare.