Skip to content

perf(f051): reduce CPU load on F051 and other low-end MCUs#1

Closed
dakejahl wants to merge 12 commits into
mainfrom
f051-perf
Closed

perf(f051): reduce CPU load on F051 and other low-end MCUs#1
dakejahl wants to merge 12 commits into
mainfrom
f051-perf

Conversation

@dakejahl

Copy link
Copy Markdown

Summary

Clean reimplementation of AlexKlimaj's am32-firmware#379 as a reviewable series: 11 atomic commits, each building green on its own, with our review fixes folded in where they logically belong (the volatile audit lands before LTO is enabled, the Keil scatter-file placement is part of the RAM-function commit, and the comparator filter-window explanation rides with the inline that shrank it). Intended to be read commit by commit; staged against this fork's main so we can iterate before opening upstream.

Called out explicitly because it is a real trade: enabling LTO costs ~2.7 KB flash (+12%) on the F051 (measured 22.6 K → 25.4 K on SEQURE_4IN1) in exchange for the cross-TU inlining. The worst-case ARK_4IN1_F051 sits at ~92.5% of the 27 K app region; the no-LTO fallback still fits at ~82%, so an escape hatch exists if a future feature needs the space back.

Problem

The F051 (Cortex-M0, 48 MHz, no hardware divider, 1WS flash) runs out of CPU cycles at high eRPM with bidirectional DShot. The big sinks: soft divisions in the 20 kHz interrupt, recursive map() calls at input rate, a triple-pass DShot decode, redundant NVIC priority writes every loop, all ISR code paying the flash wait state on every branch, and cross-TU call overhead in the commutation path. Fixing the last item with LTO is only safe after fixing what LTO exposes (mismatched extern declarations, one a real half-width read on g431) and what it removes (the accidental cross-TU reload barriers that non-volatile ISR-shared globals were relying on — the main loop never returns, so caching its reads or sinking its stores is a legal optimization once the compiler sees the whole program).

Solution

Commit order is dependency order: extern-mismatch fixes and the volatile audit first, then five independent algorithmic changes (Q16 duty scaling, Q16 throttle slopes, single-pass DShot decode, compile-time current-filter window, NVIC dedup), then LTO with CAN/E230 opt-outs, then the F051-specific changes (RAM-resident hot path for gcc and Keil, inlined comparator read, SysTick tick removal).

Verified: one target per ARM family plus the worst-case F051 and a CAN config build warning-free under -Werror at every commit; disassembly shows the comparator ISR fully RAM-resident with zero flash calls and the 20 kHz per-tick path division-free (remaining divisions are the arming one-shot and the 1 kHz PID block); Q16 results are within one timer count of the exact division and the DShot decode is bit-exact including the inverted-CRC case; worst-case RAM keeps 2.1 KB headroom above the heap/stack reserve. Outstanding: one Keil build on Windows to confirm scatter-loading of .ramfunc (flagged in its commit), V203 not built locally (no RISC-V toolchain, only an explicit-cast DMA address is touched), and the bench items from am32-firmware#379 — the comparator filter window change (~5 µs → ~1.2 µs at filter_level 12) is the one to watch at high current.

dakejahl added 11 commits June 12, 2026 18:55
Three extern declarations disagreed with their definitions. The g431
one is a real bug: commutation_interval is a volatile uint32_t but the
interrupt handler declared it uint16_t, so the early-zero-cross guard
read only half the variable. The a153 aTxBuffer extern had the wrong
sign and size, harmless today because only the address is taken, and
the dshot.c zero_crosses extern was unused with the wrong type.

Surfaced by LTO type merging while preparing the LTO change later in
this series; they are standing bugs with or without it.
The main loop never returns, so for any global whose only other
accessors are interrupt handlers the optimizer may legally cache reads
in a register forever or sink stores out of the loop. Today the huge
loop body and cross translation unit calls happen to force reloads,
but that is luck, not a contract, and the LTO change later in this
series removes the cross-TU opacity entirely. The vulnerable set is
what the main loop reads that ISRs write (failsafe compares could
never fire) and what the main loop writes that ISRs read (updates
could never become visible).

- signaltimeout: ISR-written, main loop signal-loss failsafe compare
- input, running, forward, tenkhzcounter, commutation_intervals,
  PROCESS_ADC_FLAG: ISR-written, read by main loop logic
- e_com_time, tim1_arr, duty_cycle_maximum, zero_input_count,
  old_routine: main-loop-written, read by ISRs
- dma_buffer: written by DMA hardware, there is no code writer at all
- a153 externs for inputSet/zero_input_count: missing the volatile
  qualifier the definitions already have

ISR-to-ISR sharing needs nothing: handlers are anchored by the vector
table in startup assembly, so every invocation reloads at the function
boundary. All accesses are naturally aligned 8/16/32-bit loads and
stores, so volatile adds no tearing hazard, only honest memory
accesses.
(duty_cycle * tim1_arr) / 2000 and its prop-brake/active-brake
variants each ran a soft division in the 20khz interrupt, 50+ cycles
apiece on cortex-m0 which has no hardware divider. Replace them with a
Q16 reciprocal multiply against pwm_to_arr_scale_q16; the main loop
recomputes the scale factor at idle priority only when tim1_arr
actually changes (first pass forced via last_tim1_arr = 0).

The result is within one timer count of the exact division for every
duty value and ARR (floor-of-floor error is bounded by duty/65536 < 1),
verified exhaustively host-side up to ARR 6012, the max with the 8khz
PWM setting. The scale factor is volatile like the rest of the
ISR-shared state; tim1_arr and the scale update non-atomically but the
only ISR path reading both is prop brake, and tim1_arr is pinned
constant whenever prop brake can be active (motor stopped pins the
variable-pwm logic to its 250-interval branch).
The two hot map() calls in setInput run at input frame rate (up to
dshot frame rate). map() is recursive binary interpolation, roughly
ten nested calls per evaluation, and deviated up to 5/2000 (0.25%
throttle) from a true straight line. Replace them with Q16 slopes
computed once in main() after minimum_duty_cycle reaches its final
value; the replacement is the exact line through the old endpoints, so
this is both faster and more linear. Servo input and other low-rate
map() callers are unchanged.

minimum_duty_cycle never changes after the bake point today (the
runtime dshot settings-save path does not reload settings), so the
baked slope cannot go stale; if a runtime reload is ever added the
bake must move into loadEEpromSettings.
computeDshotDMA unpacked the 16 pulses into a 16-entry int array and
then walked it three more times to assemble the CRC nibbles and the
11-bit value. Pack the pulses into one uint16_t as they are measured
(bit 15-i is pulse i) and derive everything with shifts and masks:
checkCRC = frame & 0xF, calcCRC = ((frame>>4)^(frame>>8)^(frame>>12))
& 0xF, value = frame >> 5, telemetry request = frame & 0x10. The
inverted-CRC bidirectional case (~checkCRC) & 0xF equals the old
~checkCRC + 16 for all nibble values. Bit-for-bit equivalent to the
old decode and frees the 64 byte dpulse array.
numReadings was a const qualified variable, not a constant expression,
so total / numReadings emitted a soft division on every 1khz current
sample. As a #define the compiler folds the divide-by-50 into a
multiply and shift sequence. Window size and behavior are unchanged.
The dshot telemetry priority swap wrote three or four NVIC registers
on every main loop iteration even though the scheme flips rarely.
Track the current scheme in input_priority_is_high and skip the writes
when nothing changed. The 0xFF boot sentinel forces the first pass to
write, preserving the old boot behavior exactly.
Inline hot helpers across translation units (comStep, commutate, the
LL wrappers and friends), which matters most on cortex-m0 where call
overhead in the commutation interrupts is high. The whole firmware is
already compiled and linked in a single gcc invocation, so -flto needs
no build restructuring, no archives are involved and no objects
persist between builds. An mcu makefile can opt out by clearing
LTO_FLAGS_<MCU>.

Opt-outs: DroneCAN builds, because gcc 10 LTO trips Werror
maybe-uninitialized false positives in the canard/dsdl code and CAN
capable targets have ample flash anyway; E230, whose 27k flash cannot
fit the -O3 LTO inlining growth.

filename[] needs __attribute__((used)) to survive into its .file_name
section now that LTO can see nothing references it.

On the F051, LTO costs about 2.7KB flash (+12%) in exchange for the
inlining; the earlier volatile and extern-mismatch commits in this
series are prerequisites, since whole-program visibility removes the
cross-TU reload barriers non-volatile shared globals relied on and
turns declaration mismatches into wrong-code risks.
The F051 executes flash at one wait state at 48MHz and SRAM at zero.
The prefetch buffer hides the wait state on straight line code but
every taken branch and interrupt entry pays it, and the commutation
interrupts are exactly that kind of branchy code. Tag the hot path
RAM_FUNC: the ADC1_COMP/TIM6_DAC/TIM14 handlers, interruptRoutine,
PeriodElapsedCallback, commutate, comStep, tenKhzRoutine, getBemfState
and the comparator helpers, about 2.9KB of code.

For gcc the .ramfunc input section sits inside .data so the existing
startup data init copies it, and the linker generates the flash/RAM
veneers. For Keil/armclang the scatter file selects .ramfunc into
RW_IRAM1 so armlink scatter loading does the same copy before main;
toolchains must not differ in hot path timing. The Keil path still
needs one build on Windows to confirm.

RAM_FUNC is empty on every other MCU. Worst case F051 target keeps
2.1KB of RAM headroom above the 1.5KB heap/stack reserve.
getCompOutputLevel was an out of line cross translation unit call
inside the comparator ISR confirmation loop, up to filter_level (12)
calls per zero cross at 15-20 cycles each; as a static inline register
read each sample costs about 4. LTO would inline it for release gcc
builds anyway, but the header inline also covers non-LTO debug builds
and Keil, which have no LTO.

This shrinks the wall clock sampling window of the confirmation loop
from roughly 5us to 1.2us at filter_level 12: same sample count, less
immunity to noise bursts longer than the window. That is the main
thing high current bench testing needs to watch, and the loop now
carries a comment saying so; the filter_level mapping may want
retuning.
SysTick_Config enabled the 1khz SysTick interrupt whose handler is
empty, paying interrupt entry and exit every millisecond for nothing.
Program LOAD/VAL/CTRL directly so the counter keeps running for
anything that polls it, without TICKINT. Nothing on the F051 uses the
tick: the utility timebase is TIM17 and the empty handler stays in the
vector table as a safety net.
The BlueJay custom startup melody (playBlueJayTune and its helpers) only
runs when a user has flashed a custom tune blob into the eeprom tune[]
area, which ARK boards never do, yet LTO still links the whole engine on
every build.

Compile it out behind DISABLE_CUSTOM_TUNE, defined for the flash-tight
ARK_4IN1_F051 / ARK_4IN1_RAMP_F051 targets; playStartupTune() falls back
to the fixed three-beep startup. Other boards are unaffected and keep the
custom-tune feature.

ARK_4IN1_F051: 25404 -> 24980 B (92.6% -> 91.1% of the 27 KB app region).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant