The kernel's PWM channels, through sysfs.
A channel has to be exported before its directory exists, after which it wants a period, a duty cycle — both in nanoseconds — and enabling. Duty is an absolute time rather than a fraction, so it is computed against the period.
The T113's controller offers eight channels on pwmchip0. The Beam Bots
add-on board uses four of them: 2 and 3 for one motor, 4 and 5 for the other.
None of them exist unless the board booted the nsk-balance-bot device tree.
Everything possible happens at open time
Setting a duty cycle is on the balance controller's hot path. A descriptor
opened once in :raw mode and rewritten with :file.pwrite/3 is far cheaper
than File.write/2, which goes through the file server — a port round trip per
call — and opens and closes the file every time.
So open/3 returns a struct holding the descriptor and the period, and
set_duty/2 does arithmetic and one pwrite. The struct also remembers what
it last wrote and skips the call when nothing changed, which matters more than
it sounds: driving steadily forwards leaves the reverse channel at zero, and
rewriting that zero two hundred times a second was half the cost of running.
What a write actually costs
Measured on the board, 2000 iterations, robot disarmed and otherwise idle:
| median | p95 | p99 | max | |
|---|---|---|---|---|
pwrite to duty_cycle | 0.108 ms | 0.265 | 1.278 | 2.415 |
pwrite to tmpfs, no driver | 0.066 ms | 0.179 | 1.139 | 18.567 |
The kernel is nearly free. Forty-two microseconds separate a write that reaches a hardware register from one that lands on tmpfs, and the cost does not move with the PWM period — measured at 20 kHz, 1 kHz and 100 Hz, a 200x range, the median varied by 41 us. The driver is not waiting for a period boundary to latch, so there is no point looking for savings there.
The sustained ceiling is about 8,600 writes a second. Both wheels at 200 Hz come to about 5% of a core.
The tail is the BEAM's, not sysfs's. Roughly 1% of writes take over a
millisecond, and the worst outlier of the whole run — 18.6 ms — was on tmpfs,
where no driver is involved at all. That is the async IO path: the dirty
scheduler handoff, garbage collection, or preemption. It is the most likely
explanation for the 1% of loop ticks that arrive late while the wheels are
driving, and it is not addressable without leaving :file for a NIF.
When re-measuring any of this, quote the median and the distribution. A hundred-sample mean of this operation is contaminated by that tail badly enough to come out ten times too high, which has happened.
Summary
Functions
Whether the kernel is offering PWM at all.
Stop a channel driving, without giving up the claim on it.
Claim a channel, set it running at frequency hertz with zero duty, and hold
a descriptor open on its duty cycle.
The period in nanoseconds for a frequency in hertz.
Set a channel's duty cycle, as a fraction of its period.
Types
@type duty() :: float()
A duty cycle from 0.0 to 1.0.
@type t() :: %BB.NSK.PWM{ duty: non_neg_integer(), file: File.io_device(), path: Path.t(), period: pos_integer() }
Functions
Whether the kernel is offering PWM at all.
False on the host, and false on a board that booted the stock
nerves-starter-kit device tree.
Stop a channel driving, without giving up the claim on it.
@spec open(non_neg_integer(), pos_integer(), String.t()) :: {:ok, t()} | {:error, term()}
Claim a channel, set it running at frequency hertz with zero duty, and hold
a descriptor open on its duty cycle.
Polarity is set explicitly rather than taken on trust: inverted, a duty of zero is a constant high rather than a constant low, which at a motor bridge is the difference between resting and driving. It can only be set while the channel is disabled, which is why the channel is disabled first.
Exporting a channel that is already exported is not an error — it survives a restart of the process that claimed it — so that case is expected rather than exceptional.
@spec period(pos_integer()) :: pos_integer()
The period in nanoseconds for a frequency in hertz.
Set a channel's duty cycle, as a fraction of its period.
Returns the channel, which remembers what it wrote.