BB.NSK.PWM (bb_nsk v0.1.2)

Copy Markdown View Source

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:

medianp95p99max
pwrite to duty_cycle0.108 ms0.2651.2782.415
pwrite to tmpfs, no driver0.066 ms0.1791.13918.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

Types

A duty cycle from 0.0 to 1.0.

t()

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

duty()

@type duty() :: float()

A duty cycle from 0.0 to 1.0.

t()

@type t() :: %BB.NSK.PWM{
  duty: non_neg_integer(),
  file: File.io_device(),
  path: Path.t(),
  period: pos_integer()
}

Functions

available?(chip \\ "pwmchip0")

@spec available?(String.t()) :: boolean()

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.

off(pwm)

@spec off(t()) :: t()

Stop a channel driving, without giving up the claim on it.

open(channel, frequency, chip \\ "pwmchip0")

@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.

period(frequency)

@spec period(pos_integer()) :: pos_integer()

The period in nanoseconds for a frequency in hertz.

set_duty(pwm, duty)

@spec set_duty(t(), duty()) :: t()

Set a channel's duty cycle, as a fraction of its period.

Returns the channel, which remembers what it wrote.