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:
- A centre-aligned PWM with one compare channel per leg. Compare 0 means the low
side is on for the whole period,
pwm_period_ticksthe high side. Entries above the leg count of the topology are 0. - 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.
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 bypwm_write()and take effect at the next timer update — the one-period delay the controller is designed for.sstep_slow_loop()atslow_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¶
- Measure R, L and the back-EMF constant of the motor; enter them.
- Compute the ADC scaling of the currents and of the bus voltage from the schematic; measure the current zero with the outputs disabled.
- Configure a centre-aligned PWM at
pwm_freq_hzwith the timer dead time; trigger the current ADC at the counter peak. - Call
sstep_fast_loop()from the ADC interrupt,sstep_slow_loop()at 1 kHz. - Start with a current-limited supply,
i_run_maat half the rated current and a lowv_max; check the phase currents with a current probe. - Run the same configuration in the SIL diagram and compare.