Skip to content

gpio-motors: deliver sub-tick delays on HZ=100 kernels - #2247

Open
phedoreanu wants to merge 4 commits into
OpenIPC:masterfrom
phedoreanu:gpio-motors-subtick-delay
Open

gpio-motors: deliver sub-tick delays on HZ=100 kernels#2247
phedoreanu wants to merge 4 commits into
OpenIPC:masterfrom
phedoreanu:gpio-motors-subtick-delay

Conversation

@phedoreanu

@phedoreanu phedoreanu commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

What

Two changes to how gpio-motors paces the stepper sequence:

  1. Hold the sysfs value fds open for the whole run. gpio_set() used to do snprintf + fopen + fprintf + fclose per pin, 4× per micro-step — 2–5 ms of pure file churn per micro-step on these SoCs, more than the delay it was trying to honour. Stepping is now an lseek + 1-byte write on an fd opened once after export.
  2. Deliver sub-tick delays where the kernel cannot. On coarse-timer kernels (CONFIG_HIGH_RES_TIMERS off, HZ=100 — the Hi35xx/GK72xx boards here) every usleep rounds up to a 10 ms tick, so usleep(1500) waits ~10 ms and every micro-step has a hard 10 ms floor. delay_us() consults clock_getres(CLOCK_MONOTONIC) once (1 ns with hrtimers, one jiffy without) and keeps usleep() everywhere except the one case it cannot handle: a sub-tick delay on a coarse-timer kernel, where it polls CLOCK_MONOTONIC instead. Platforms with hrtimers keep their working sleep and never spin.

Also: the SELECT_PIN settle goes through delay_us() (as a plain usleep(100) it silently cost a whole tick on coarse-timer kernels), and delay arguments that would overflow the ms→µs conversion are rejected.

Why

Measured on a Hi3518EV200 (HZ=100, no hrtimers): 200 steps took 33 s at delay 15 and still 18 s at delay 4 — the requested delay barely matters because the tick floor dominates. @widgetii reproduced the same numbers on a GK7205V200. With the fd cache plus a delivered 1.5 ms delay, a micro-step drops from ~12 ms to ~1.6 ms.

The root fix would be CONFIG_HIGH_RES_TIMERS=y per board, but as the review measurements show it does not fit every kernel partition without per-board trims (hi3516ev300-lite has 203 bytes of headroom), so this stays a userspace accommodation that detects the kernel it got.

Note for existing installs on coarse-timer kernels: the 10 ms floor has been the de-facto pace, so moves tuned against it become faster once the requested delay is actually delivered. Callers that want the old pace can pass delay >= 10.

Verification

  • Both revisions compile clean with -Wall -Wextra.
  • Hi3518EV200 field data above; per-thread measurements in review replies.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

gpio-motors: add CLOCK_MONOTONIC busy-wait for sub-tick delays on HZ=100

🐞 Bug fix ✨ Enhancement 🕐 10-20 Minutes

Grey Divider

AI Description

• Make step delays below 10ms accurate on HZ=100, no-hrtimer kernels.
• Add a monotonic-clock spin delay for sub-tick waits while keeping usleep() for longer delays.
• Route axis micro-step pacing through the new delay helper to honor requested timing.
Diagram

graph TD
  A["axis_run()"] --> B["delay_us()"] --> C{">= 10ms?"}
  C -->|"yes"| D["usleep()"]
  C -->|"no"| E["clock_gettime(MONOTONIC)"] --> F["spin until target"]

  subgraph Legend
    direction LR
    _fn["Function"] ~~~ _dec{"Decision"} ~~~ _sys["Syscall/Clock"]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use clock_nanosleep(TIMER_ABSTIME) for sub-tick delays
  • ➕ Avoids burning CPU in a tight loop
  • ➕ Absolute-time sleeps can reduce drift across repeated delays
  • ➖ On no-hrtimer HZ=100 kernels it will still quantize to the tick, so it may not solve the core problem
  • ➖ More error handling and portability considerations than the current approach
2. Hybrid spin-with-yield (spin briefly, then sched_yield in loop)
  • ➕ Reduces CPU pressure during longer sub-tick waits
  • ➕ Still allows finer-than-tick timing if the runqueue is favorable
  • ➖ Timing becomes scheduler-dependent and can introduce jitter
  • ➖ More complex to reason about than a simple monotonic busy-wait
3. Move motor stepping into kernel/RT context (PWM/PIO/driver)
  • ➕ Best timing determinism and lowest userspace jitter
  • ➕ Better scalability if moves become longer or concurrent
  • ➖ Much higher implementation and maintenance cost
  • ➖ Likely out of scope for this project/repo and target devices

Recommendation: Keep the PR’s approach: a CLOCK_MONOTONIC busy-wait for <10ms is the most reliable way to achieve sub-tick delays on HZ=100 kernels without high-resolution timers, while preserving usleep() for longer waits to avoid unnecessary CPU burn. Alternatives that rely on nanosleep/clock_nanosleep are unlikely to improve resolution on these kernels, and scheduler-yield hybrids trade accuracy for reduced CPU usage in a way that can reintroduce jitter.

Files changed (1) +30 / -1

Bug fix (1) +30 / -1
gpio-motors.cAdd sub-tick delay helper and use it for micro-step timing +30/-1

Add sub-tick delay helper and use it for micro-step timing

• Introduces delay_us() that uses usleep() for delays >=10ms and a CLOCK_MONOTONIC busy-wait for smaller delays to avoid HZ=100 tick rounding. Replaces the axis_run() per-micro-step usleep(delay) call with delay_us(delay) and includes <time.h> for clock_gettime().

general/package/gpio-motors/src/gpio-motors.c

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. 32-bit elapsed overflow ✓ Resolved 🐞 Bug ☼ Reliability
Description
delay_us() computes elapsed nanoseconds in a signed long; on 32-bit platforms a multi-second
pause/preemption during the loop can trigger signed overflow (undefined behavior) and make the
busy-wait run incorrectly. Since axis_run() calls this per microstep, the motor move can be delayed
unpredictably and the process can burn CPU far longer than requested.
Code

general/package/gpio-motors/src/gpio-motors.c[R152-154]

+		clock_gettime(CLOCK_MONOTONIC, &now);
+		long elapsed = (now.tv_sec - start.tv_sec) * 1000000000L + (now.tv_nsec - start.tv_nsec);
+		if (elapsed >= target) {
Evidence
The PR introduces a busy-wait that computes nanoseconds in long and is invoked for every
microstep; on embedded 32-bit systems this arithmetic can overflow/UB if the thread is paused long
enough during the loop, breaking the wait behavior.

general/package/gpio-motors/src/gpio-motors.c[142-158]
general/package/gpio-motors/src/gpio-motors.c[160-183]
general/package/gpio-motors/Config.in[1-6]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`delay_us()` converts a `timespec` delta into nanoseconds using `long` math. On 32-bit targets, `(tv_sec_delta * 1e9)` can overflow after ~2 seconds, which is signed overflow (undefined behavior) and can break the exit condition of the busy-wait.
## Issue Context
This code runs on embedded SoCs where 32-bit `long` is common, and `delay_us()` is called once per microstep in `axis_run()`.
## Fix Focus Areas
- general/package/gpio-motors/src/gpio-motors.c[142-158]
### Suggested approach
- Include `<stdint.h>`.
- Use `int64_t` (or `uint64_t`) for `target_ns` and `elapsed_ns`:
- `int64_t target_ns = (int64_t)us * 1000;`
- `int64_t elapsed_ns = (int64_t)(now.tv_sec - start.tv_sec) * 1000000000LL + (now.tv_nsec - start.tv_nsec);`
- Consider guarding `us <= 0` with an immediate return.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Negative delay unthrottled ✓ Resolved 🐞 Bug ≡ Correctness
Description
delay_us() does not reject negative delays, so a negative CLI delay becomes a negative target and
the function effectively returns immediately, producing zero inter-microstep delay. This can drive
the motor much faster than intended for invalid input.
Code

general/package/gpio-motors/src/gpio-motors.c[R153-156]

+		long elapsed = (now.tv_sec - start.tv_sec) * 1000000000L + (now.tv_nsec - start.tv_nsec);
+		if (elapsed >= target) {
+			return;
+		}
Evidence
The CLI can produce a negative microsecond delay, and the new delay_us() logic will then return
immediately due to comparing elapsed time against a negative target. The kernel driver in this repo
shows a precedent for rejecting negative delay values.

general/package/gpio-motors/src/gpio-motors.c[142-156]
general/package/gpio-motors/src/gpio-motors.c[190-212]
general/package/gpiostep-openipc/src/gpiostep.c[88-93]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`delay_us()` treats negative delays as “done” (because `elapsed >= target` when `target` is negative), which results in unthrottled stepping if the CLI delay is negative.
## Issue Context
The CLI parses delay as milliseconds and multiplies by 1000 with no validation. The in-kernel equivalent driver explicitly rejects negative delays.
## Fix Focus Areas
- general/package/gpio-motors/src/gpio-motors.c[142-158]
- general/package/gpio-motors/src/gpio-motors.c[190-212]
### Suggested approach
- In `main()`, parse delay into a signed type and validate `delay_ms >= 0` before multiplying.
- In `delay_us()`, add `if (us <= 0) return;` as a defensive guard.
- (Optional) cap maximum delay to a sane bound and error out on overflow.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. clock_gettime errors unchecked 🐞 Bug ☼ Reliability
Description
delay_us() ignores clock_gettime() return values, so if either call fails the start/now timestamps
may be indeterminate and the busy-wait behavior becomes undefined. A failure should fall back to a
safe sleep or exit the delay path.
Code

general/package/gpio-motors/src/gpio-motors.c[R149-152]

+	clock_gettime(CLOCK_MONOTONIC, &start);
+	long target = us * 1000;
+	for (;;) {
+		clock_gettime(CLOCK_MONOTONIC, &now);
Evidence
The newly added delay_us() calls clock_gettime() twice but never checks for errors, leaving an
undefined-behavior path if the time query fails.

general/package/gpio-motors/src/gpio-motors.c[148-153]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`delay_us()` does not check `clock_gettime()` return values. On failure, `start`/`now` may contain indeterminate data and the elapsed-time calculation can misbehave.
## Issue Context
While `CLOCK_MONOTONIC` failures are uncommon on normal Linux systems, this is a new error path introduced by the PR and is easy to harden.
## Fix Focus Areas
- general/package/gpio-motors/src/gpio-motors.c[148-157]
### Suggested approach
- Check both `clock_gettime()` calls:
- If the initial call fails, fall back to `usleep(us)` (or `nanosleep`) and return.
- If the loop call fails, break and fall back to `usleep(0)` / `sched_yield()` / or just return to avoid an unbounded spin.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread general/package/gpio-motors/src/gpio-motors.c
Comment thread general/package/gpio-motors/src/gpio-motors.c Outdated
Comment thread general/package/gpio-motors/src/gpio-motors.c Outdated

@flyrouter flyrouter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks.
It looks like an interesting proposal for improvement.

@widgetii

Copy link
Copy Markdown
Member

Thanks for this — the diagnosis is correct and the measurements are good. I reproduced your numbers on lab hardware: on a Goke GK7205V200 (4.9.37, HZ=100), usleep(500/1500/4000) all return in ~9.99 ms and usleep(15000) in 20.0 ms. That matches your Hi3518EV200 field data exactly (1600 micro-steps × 20 ms = 32 s vs your 33 s at delay 15; × 10 ms = 16 s vs your 18 s at delay 4).

But the root cause is CONFIG_HIGH_RES_TIMERS, not HZ — and fixing it there removes the need to busy-wait at all.

The hardware was never the limit

/proc/timer_list on the stock boards already shows the clockevent in ONESHOT state with min_delta_ns = 1000 and a live set_next_event:

gk7205v200 : arch_sys_timer, mode 3, min_delta_ns 1000, arch_timer_set_next_event_phys
hi3516cv300: arm,sp804,      mode 3, min_delta_ns 1000, sp804_set_next_event

The one-shot machinery is already running. The only thing pinning usleep() to 10 ms is hrtimer_resolution = LOW_RES_NSEC, a pure consequence of # CONFIG_HIGH_RES_TIMERS is not set.

Measured, one symbol flipped

I rebuilt the hi3516ev300 kernel with CONFIG_HIGH_RES_TIMERS=y (auto-selects TICK_ONESHOT + SCHED_HRTICK). CONFIG_HZ=100 and CONFIG_HZ_PERIODIC=y unchanged. Flashed it, measured, restored stock:

stock HRT=y (HZ still 100)
clock_getres 10000000 ns 1 ns
usleep(500) 9.991 ms 0.556 ms
usleep(1500) 9.991 ms 1.559 ms
usleep(4000) 10.004 ms 4.064 ms
usleep(15000) 19.999 ms 15.085 ms

Overhead is ~56–85 µs (wakeup latency + the 50 µs default timer_slack_ns). Your 1.5 ms delay is delivered to ~4% by a normal sleeping process.

Why I'd rather not merge the spin

  1. It regresses platforms that are already fine. 30 of 81 board configs in-tree already have CONFIG_HIGH_RES_TIMERS=y — including all Sigmastar, all Ingenic (T21/T31), Grainmedia GM8136, Allwinner, Rockchip. Those are most of the "confirmed working" list in the package Readme, and usleep(1500) already delivers ~1.5 ms there. This change swaps a working sleep for a busy-wait.

  2. It buys ~nothing where it does apply. On gk7205v200 I measured sysfs open+write+close on a /sys/class/gpio attribute at 557 µs/call = 2.23 ms for the 4 pins of one micro-step. So HRT=y gives ~3.8 ms/micro-step and the spin gives ~3.7 ms — the same, except the spin pins the (single, non-SMP) core at 100% for the whole move while the encoder is running on it.

  3. clock_gettime() is a syscall on these boards, not a vDSO read — I measured 594–734 ns/call, because CONFIG_ARM_ARCH_TIMER_VCT_ACCESS is not set. That's ~2000–2500 syscalls per 1.5 ms spin, vs 1 for an hrtimer nanosleep.

The bigger win is orthogonal and free

gpio_set() does snprintf + fopen + fprintf + fclose per pin, 4× per micro-step. That's 2.2 ms (gk7205v200) to 5.3 ms (hi3516ev300) of pure sysfs overhead per micro-step — more than the 1.5 ms delay you're trying to deliver. Holding the four value fds open from gpio_export() and doing write()+lseek per step costs no CPU, needs no kernel change, and helps every platform. I'd take that patch today.

Heads-up if we go the Kconfig route

CONFIG_HIGH_RES_TIMERS=y grows the ev300 kernel by 2328 bytes, and the stock hi3516ev300-lite kernel has 203 bytes of headroom in its 2 MB mtd2. It overflows. I had to drop CONFIG_UBIFS_FS (NAND-only; these are NOR boards with squashfs + jffs2) to free ~72 KB. Same 2 MB kernel partition on gk7205v200. So this needs a paired trim per board, not a blanket flip.

Two more things

  • gpiostep-openipc has the identical bug and is enabled on the same three defconfigs that ship gpio-motors (gk7205v500_lite/_ultimate, gk7205v510_lite). gpiostep.c:65 uses usleep_range(), which is also hrtimer-backed and also quantises to the tick on these kernels — so its Config.in claim of "steadier timing than the userspace gpio-motors tool" isn't true for sub-tick delays today. In kernel context udelay()/ndelay() is the right primitive.

  • Whatever lands, note it's a behaviour change for existing installs: the 10 ms floor has been the de-facto behaviour forever, so PTZ presets tuned against it will suddenly move up to 8× faster — which is exactly the lurch/step-slip you mention. Worth landing the ramping first, or gating it.

Nit still open: int delay = delay_ms * 1000; overflows for delay_ms > 2147483 (signed overflow, UB) — cheap to clamp since you already validate < 0. And the usleep(100) SELECT_PIN settle at line 140 is sub-tick too and isn't routed through delay_us, so it's really ~10 ms today.

@phedoreanu

Copy link
Copy Markdown
Contributor Author

Hard to argue with a rebuilt kernel and measurements on both paths — thanks for doing that. The PR is reworked along your lines; two commits pushed.

Your fd-caching patch is in (55584c3). Each value file is opened once after export and gpio_set is an lseek + 1-byte write; cleanup closes the fds and still unexports through sysfs. That removes the 2.2–5.3 ms of per-micro-step sysfs churn on every platform, hrtimers or not.

The spin is now gated on clock_getres(CLOCK_MONOTONIC) (e072917). 1 ns → hrtimers → plain usleep, never spin. One jiffy → coarse kernel → usleep for anything of a tick or more, spin only for the one case with no working alternative: a sub-tick delay on a coarse-timer kernel. Against your three points:

  1. Regressing the ~30 HRT boards — they now keep their working sleep unconditionally; the spin path is unreachable there.
  2. Buys ~nothing where it applies — with the fd cache the arithmetic changes: a cv200 micro-step drops from ~12 ms (sysfs churn + quantised sleep) to ~1.6 ms. The spin is what makes the delay real once the sysfs overhead no longer masks it.
  3. Syscall cost of the spin — confined to coarse-timer kernels, where the alternative is not a cheaper wait but a ~6× longer one. If there is a sleeping primitive on a 3.4/4.9 non-HRT kernel that can deliver 1.5 ms, I will gladly swap it in.

Both nits fixed in the same commit: delay_ms > INT_MAX/1000 is rejected (the * 1000 was signed-overflow UB past 2147483), and the SELECT_PIN settle is routed through delay_us() — you are right that as a bare usleep(100) it silently cost a whole tick.

On CONFIG_HIGH_RES_TIMERS=y: agreed it is the root fix, and your own numbers are the argument for not attempting it here — 203 bytes of headroom on hi3516ev300-lite makes it a per-board kernel-trim campaign, not a flag flip. This PR stays a userspace accommodation that detects the kernel it got; when a board's kernel gains hrtimers, this code automatically stops spinning there with no further change.

Behaviour change: now called out in the PR body — on coarse-timer kernels, moves tuned against the de-facto 10 ms floor get faster once the requested delay is actually delivered; callers wanting the old pace can pass delay >= 10. There is no ramping in this tool today and I would rather not grow the scope here.

gpiostep: agreed — usleep_range() is hrtimer-backed and quantises identically on those kernels, so its Config.in claim is not true on the defconfigs that ship it today; udelay()/ndelay() is the honest primitive in kernel context. I can send that as a separate PR if wanted.

The delay argument has never actually worked below 10ms. These cameras
run HZ=100 kernels without high-resolution timers, so every usleep()
rounds up to a 10ms tick: usleep(1500) waits ~10ms, and 8 micro-steps
x 10ms puts a hard ~80ms floor under every step regardless of the
requested delay.

Measured on a Hi3518EV200 (28BYJ-48 steppers): 200 steps took 33s at
delay 15 and still 18s at delay 4 - the delay barely mattered, because
the tick rounding dominated. With a CLOCK_MONOTONIC spin for delays
below one tick the same 200 steps complete in ~4s at 1.5ms per
micro-step, and the delay argument finally means what it says.

Delays of 10ms and up still use usleep, so slow moves do not spin.
Busy-waiting below that is a deliberate trade: moves are short and
bounded, and a stepper mid-move needs the CPU for milliseconds, not
ticks.
Review follow-up:
- compute the elapsed time in long long: on 32-bit targets a long
  overflows after ~2.1s, which a preemption in the middle of the spin
  can reach, and signed overflow is undefined behavior
- reject a negative delay at the CLI and treat non-positive delays as
  zero in delay_us, instead of spinning unthrottled
- fall back to usleep if clock_gettime fails, so the wait stays bounded
Each gpio_set() did snprintf + fopen + fprintf + fclose, four times per
micro-step. On these SoCs a single sysfs open/write/close round trip
costs on the order of half a millisecond, so one micro-step spent
2-5ms on file churn alone - more than the step delay it was trying to
honour, and pure overhead on every platform.

Open each value file once after export and keep the fd for the run;
stepping is now an lseek + a 1-byte write per pin. Cleanup still goes
through the sysfs paths and closes the fds.
Roughly a third of the board kernels in this tree ship with
CONFIG_HIGH_RES_TIMERS=y, and there usleep() already delivers sub-tick
delays to within tens of microseconds - spinning on those platforms
would trade a working sleep for 100% CPU on a single-core SoC.
clock_getres(CLOCK_MONOTONIC) tells the two kernels apart at runtime
(1ns with hrtimers, one jiffy without), so consult it once and keep
usleep() everywhere except the one case it cannot handle: a sub-tick
delay on a coarse-timer kernel.

Also route the SELECT_PIN settle through delay_us() - as a plain
usleep(100) it silently cost a whole 10ms tick on coarse-timer kernels
- and reject delay arguments that would overflow the ms-to-us
conversion, since delay_ms * 1000 is signed-overflow UB past
INT_MAX/1000.
@phedoreanu
phedoreanu force-pushed the gpio-motors-subtick-delay branch from e072917 to f74b27d Compare August 12, 2026 08:39
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.

3 participants