PWM
Here we are going to continue working on the Rust crate bbb_io.
In the earlier C implementation, PWM was controlled through the Linux sysfs PWM interface:
/sys/class/pwm/pwmchip0/pwm0/period
/sys/class/pwm/pwmchip0/pwm0/duty_cycle
/sys/class/pwm/pwmchip0/pwm0/enable
The Rust implementation in this chapter follows the same design:
Physical pin name -> PWM chip + channel -> sysfs paths -> Linux kernel -> PWM output
For example:
P9_14 -> pwmchip0, pwm0
P9_16 -> pwmchip0, pwm1
This PWM chapter uses sysfs, not libgpiod.
The earlier sysfs.rs module is useful here because PWM control is still performed by writing values into files.
-
You need to have finished:
-
Without the overlay you will not have access to
/sys/class/pwm/pwmchip0.
1. Creating the pwm.rs
The first step is to create the PWM module. This is the Rust equivalent of the bbb_pwm.c and bbb_pwm.h files from the C library.
-
Open
src/pwm.rsand add the following:Code: pwm.rs
use std::path::PathBuf; use std::thread; use std::time::Duration; use crate::{sysfs, BbbError, BbbResult}; const PWM_BASE_PATH: &str = "/sys/class/pwm"; const PWM_PERIOD_DEFAULT: u32 = 10_000_000; const PWM_DUTY_CYCLE_DEFAULT: u32 = 0; const PWM_ENABLE_DEFAULT: u8 = 0; const MAX_ADC: u32 = 4095; #[derive(Debug, Clone, Copy)] pub struct PwmPin { pub physical_pin: &'static str, pub chip: u8, pub channel: u8, } const PWM_PIN_MAP: &[PwmPin] = &[ PwmPin { physical_pin: "P9_14", chip: 0, channel: 0, }, PwmPin { physical_pin: "P9_16", chip: 0, channel: 1, }, ]; #[derive(Debug)] pub struct Pwm { pub physical_pin: &'static str, pub chip: u8, pub channel: u8, period_path: PathBuf, duty_cycle_path: PathBuf, enable_path: PathBuf, } fn lookup_pwm_pin(pin_name: &str) -> BbbResult<PwmPin> { PWM_PIN_MAP .iter() .copied() .find(|entry| entry.physical_pin == pin_name) .ok_or_else(|| BbbError::InvalidPin(pin_name.to_string())) } fn pwmchip_path(chip: u8) -> PathBuf { PathBuf::from(format!("{PWM_BASE_PATH}/pwmchip{chip}")) } fn pwm_path(chip: u8, channel: u8) -> PathBuf { pwmchip_path(chip).join(format!("pwm{channel}")) } fn ensure_exported(chip: u8, channel: u8) -> BbbResult<()> { let path = pwm_path(chip, channel); if path.exists() { return Ok(()); } let export_path = pwmchip_path(chip).join("export"); sysfs::write_u32(export_path, channel as u32)?; thread::sleep(Duration::from_millis(100)); Ok(()) } impl Pwm { pub fn init(pin_name: &str) -> BbbResult<Self> { let pin = lookup_pwm_pin(pin_name)?; ensure_exported(pin.chip, pin.channel)?; let base = pwm_path(pin.chip, pin.channel); Ok(Self { physical_pin: pin.physical_pin, chip: pin.chip, channel: pin.channel, period_path: base.join("period"), duty_cycle_path: base.join("duty_cycle"), enable_path: base.join("enable"), }) } pub fn cleanup(&self) -> BbbResult<()> { self.set_period(PWM_PERIOD_DEFAULT)?; self.set_duty_cycle(PWM_DUTY_CYCLE_DEFAULT)?; if PWM_ENABLE_DEFAULT == 1 { self.enable() } else { self.disable() } } pub fn set_period(&self, period_ns: u32) -> BbbResult<()> { sysfs::write_u32(&self.period_path, period_ns) } pub fn set_duty_cycle(&self, duty_cycle_ns: u32) -> BbbResult<()> { sysfs::write_u32(&self.duty_cycle_path, duty_cycle_ns) } pub fn enable(&self) -> BbbResult<()> { sysfs::write_string(&self.enable_path, "1") } pub fn disable(&self) -> BbbResult<()> { sysfs::write_string(&self.enable_path, "0") } } impl Drop for Pwm { fn drop(&mut self) { let _ = self.cleanup(); } } pub fn map_adc_to_duty_cycle(adc_value: u16, period_ns: u32) -> BbbResult<u32> { let adc_value = adc_value as u32; if adc_value > MAX_ADC { return Err(BbbError::InvalidValue(format!( "ADC value out of range: {adc_value}" ))); } Ok((adc_value * period_ns) / MAX_ADC) }This file gives the library a single PWM abstraction for the physical pins that support PWM.
Instead of writing:
/sys/class/pwm/pwmchip0/pwm0/periodwe can write:
let pwm = Pwm::init("P9_14")?;Explanation
Imports
use std::path::PathBuf; use std::thread; use std::time::Duration;-
PathBuf- used to build sysfs file paths safely
-
thread- used to briefly pause after exporting a PWM channel
-
Duration- used to express time clearly
Read more:
Reusing crate modules
use crate::{sysfs, BbbError, BbbResult};-
sysfs- contains helper functions for writing strings and integers to sysfs files
-
BbbError- our crate-specific error type
-
BbbResult-
shorthand for:
Result<T, BbbError>
-
This keeps
pwm.rsconsistent withadc.rs.
Constants
const PWM_BASE_PATH: &str = "/sys/class/pwm";This is the base directory for Linux PWM sysfs.
PWM channels are exposed under paths such as:
/sys/class/pwm/pwmchip0/pwm0
Default PWM values
const PWM_PERIOD_DEFAULT: u32 = 10_000_000; const PWM_DUTY_CYCLE_DEFAULT: u32 = 0; const PWM_ENABLE_DEFAULT: u8 = 0;These mirror the C defaults:
#define PWM_PERIOD_DEFAULT 10000000 #define PWM_DUTY_CYCLE_DEFAULT 0 #define PWM_ENABLE_DEFAULT 0They are used during cleanup so the PWM output is left in a predictable state.
Why underscores in numbers?
Rust allows numeric separators:
10_000_000This is the same value as:
10000000but easier to read.
The
PwmPinstructpub struct PwmPin { pub physical_pin: &'static str, pub chip: u8, pub channel: u8, }This is the Rust equivalent of the C mapping:
typedef struct { char physical_pin[10]; char pwm_chip_channel[10]; } PinMap;The Rust version stores the chip and channel separately rather than parsing a string such as
"0:0".-
physical_pin- BeagleBone header pin, for example
"P9_14"
- BeagleBone header pin, for example
-
chip- PWM chip number, for example
0forpwmchip0
- PWM chip number, for example
-
channel- PWM channel, for example
0forpwm0
- PWM channel, for example
So:
PwmPin { physical_pin: "P9_14", chip: 0, channel: 0 }means:
P9_14 -> /sys/class/pwm/pwmchip0/pwm0
Why
&'static str?pub physical_pin: &'static strThe pin names are fixed strings compiled into the program.
'staticmeans:this string lives for the entire lifetime of the program
Read more:
Why
#[derive(Debug, Clone, Copy)]?#[derive(Debug, Clone, Copy)]PwmPincontains only a string reference and small integers, so it is cheap to copy.-
Debug- allows developer-style printing with
{:?}
- allows developer-style printing with
-
Clone- allows explicit copying
-
Copy- allows automatic copying
Read more:
The PWM pin table
const PWM_PIN_MAP: &[PwmPin] = &[ ... ];This is equivalent in purpose to the C array:
PinMap phy_pin_map[] = { {"P9_14", "0:0"}, {"P9_16", "0:1"}, };However, the Rust version avoids parsing
"0:0"and stores the data directly as typed values.
The
Pwmstructpub struct Pwm { pub physical_pin: &'static str, pub chip: u8, pub channel: u8, period_path: PathBuf, duty_cycle_path: PathBuf, enable_path: PathBuf, }This is equivalent to the C
PWMstruct:typedef struct { char phy_pin[10]; char chip[4]; char channel[4]; char period_path[128]; char duty_cycle_path[128]; char enable_path[128]; } PWM;The important difference is that Rust stores file paths as
PathBuf.This avoids fixed-size buffers such as:
char period_path[128];and removes the risk of writing beyond the end of a buffer.
Public and private fields
pub physical_pin: &'static str, pub chip: u8, pub channel: u8, period_path: PathBuf, duty_cycle_path: PathBuf, enable_path: PathBuf,-
physical_pin,chip, andchannelare public- useful for printing and debugging
-
period_path,duty_cycle_path, andenable_pathare private- user code should not write directly to these
- user code should call
set_period,set_duty_cycle,enable, anddisable
This gives us encapsulation.
Read more:
-
2. PWM pin lookup and path construction
The next part of pwm.rs performs the same role as the pin parsing code in the C implementation.
fn lookup_pwm_pin(pin_name: &str) -> BbbResult<PwmPin> {
PWM_PIN_MAP
.iter()
.copied()
.find(|entry| entry.physical_pin == pin_name)
.ok_or_else(|| BbbError::InvalidPin(pin_name.to_string()))
}
fn pwmchip_path(chip: u8) -> PathBuf {
PathBuf::from(format!("{PWM_BASE_PATH}/pwmchip{chip}"))
}
fn pwm_path(chip: u8, channel: u8) -> PathBuf {
pwmchip_path(chip).join(format!("pwm{channel}"))
}
Explanation
Explanation
lookup_pwm_pin
fn lookup_pwm_pin(pin_name: &str) -> BbbResult<PwmPin>
This function searches the PWM pin table for a physical pin name.
Example:
let pin = lookup_pwm_pin("P9_14")?;
If the pin exists, it returns:
Ok(PwmPin { physical_pin: "P9_14", chip: 0, channel: 0 })
If the pin is unknown, it returns:
Err(BbbError::InvalidPin(...))
Iterator chain
PWM_PIN_MAP
.iter()
.copied()
.find(|entry| entry.physical_pin == pin_name)
This means:
-
.iter()- visit each PWM pin entry
-
.copied()- copy the
PwmPinvalue from the table
- copy the
-
.find(...)- return the first matching entry
Read more:
ok_or_else
.ok_or_else(|| BbbError::InvalidPin(pin_name.to_string()))
find(...) returns an Option:
Some(pin)
None
But our function returns a Result:
Ok(pin)
Err(error)
ok_or_else(...) converts:
Some(value) -> Ok(value)
None -> Err(...)
Read more:
Building paths
fn pwmchip_path(chip: u8) -> PathBuf {
PathBuf::from(format!("{PWM_BASE_PATH}/pwmchip{chip}"))
}
For chip 0, this creates:
/sys/class/pwm/pwmchip0
Then:
fn pwm_path(chip: u8, channel: u8) -> PathBuf {
pwmchip_path(chip).join(format!("pwm{channel}"))
}
For chip 0 and channel 0, this creates:
/sys/class/pwm/pwmchip0/pwm0
This replaces the C snprintf approach with safer path construction.
3. Exporting the PWM channel
Before we can write to:
/sys/class/pwm/pwmchip0/pwm0/period
the channel must exist. If it does not exist, it must be exported.
fn ensure_exported(chip: u8, channel: u8) -> BbbResult<()> {
let path = pwm_path(chip, channel);
if path.exists() {
return Ok(());
}
let export_path = pwmchip_path(chip).join("export");
sysfs::write_u32(export_path, channel as u32)?;
thread::sleep(Duration::from_millis(100));
Ok(())
}
Explanation
Explanation
What does export mean?
In sysfs PWM, a PWM channel may not appear until it is exported.
For example, if this path does not exist:
/sys/class/pwm/pwmchip0/pwm0
you can create it by writing the channel number to:
/sys/class/pwm/pwmchip0/export
Equivalent terminal command:
echo 0 > /sys/class/pwm/pwmchip0/export
Checking whether the channel exists
if path.exists() {
return Ok(());
}
If the channel is already exported, we do nothing.
Exporting the channel
let export_path = pwmchip_path(chip).join("export");
sysfs::write_u32(export_path, channel as u32)?;
This writes the channel number to the export file.
The ? operator means:
if writing fails, return the error immediately
Why sleep?
thread::sleep(Duration::from_millis(100));
After export, the kernel may take a short time to create:
/sys/class/pwm/pwmchip0/pwm0
The short delay gives the sysfs tree time to appear.
4. Implementing the PWM methods
The impl Pwm block contains the methods that correspond to the C library functions.
impl Pwm {
pub fn init(pin_name: &str) -> BbbResult<Self> {
let pin = lookup_pwm_pin(pin_name)?;
ensure_exported(pin.chip, pin.channel)?;
let base = pwm_path(pin.chip, pin.channel);
Ok(Self {
physical_pin: pin.physical_pin,
chip: pin.chip,
channel: pin.channel,
period_path: base.join("period"),
duty_cycle_path: base.join("duty_cycle"),
enable_path: base.join("enable"),
})
}
pub fn cleanup(&self) -> BbbResult<()> {
self.set_period(PWM_PERIOD_DEFAULT)?;
self.set_duty_cycle(PWM_DUTY_CYCLE_DEFAULT)?;
if PWM_ENABLE_DEFAULT == 1 {
self.enable()
} else {
self.disable()
}
}
pub fn set_period(&self, period_ns: u32) -> BbbResult<()> {
sysfs::write_u32(&self.period_path, period_ns)
}
pub fn set_duty_cycle(&self, duty_cycle_ns: u32) -> BbbResult<()> {
sysfs::write_u32(&self.duty_cycle_path, duty_cycle_ns)
}
pub fn enable(&self) -> BbbResult<()> {
sysfs::write_string(&self.enable_path, "1")
}
pub fn disable(&self) -> BbbResult<()> {
sysfs::write_string(&self.enable_path, "0")
}
}
Explanation
Explanation
Pwm::init
pub fn init(pin_name: &str) -> BbbResult<Self>
This is the Rust equivalent of:
int pwm_init(PWM *pwm, const char *pin_name)
In C, the caller creates a PWM struct and passes a pointer into pwm_init.
In Rust, Pwm::init creates and returns the Pwm value:
let pwm = Pwm::init("P9_14")?;
This is more natural in Rust because ownership of the PWM handle is returned to the caller.
Setup sequence
let pin = lookup_pwm_pin(pin_name)?;
ensure_exported(pin.chip, pin.channel)?;
let base = pwm_path(pin.chip, pin.channel);
This performs:
pin name -> lookup chip/channel -> export if needed -> build paths
Returning Self
Ok(Self {
physical_pin: pin.physical_pin,
chip: pin.chip,
channel: pin.channel,
period_path: base.join("period"),
duty_cycle_path: base.join("duty_cycle"),
enable_path: base.join("enable"),
})
Self means:
the type currently being implemented
Here, Self means Pwm.
Read more:
cleanup
pub fn cleanup(&self) -> BbbResult<()>
This mirrors the C function:
int pwm_cleanup(PWM *pwm)
It restores default values:
period -> 10,000,000 ns
duty cycle -> 0
enable -> 0
This leaves the PWM output in a safe state.
Setting the period
pub fn set_period(&self, period_ns: u32) -> BbbResult<()> {
sysfs::write_u32(&self.period_path, period_ns)
}
This writes a number to:
/sys/class/pwm/pwmchip0/pwm0/period
For example:
pwm.set_period(1_000_000)?;
means:
set period to 1,000,000 ns
which is 1 ms, or 1 kHz.
Setting the duty cycle
pub fn set_duty_cycle(&self, duty_cycle_ns: u32) -> BbbResult<()> {
sysfs::write_u32(&self.duty_cycle_path, duty_cycle_ns)
}
This writes to:
/sys/class/pwm/pwmchip0/pwm0/duty_cycle
The duty cycle must be less than or equal to the period.
For example:
pwm.set_period(1_000_000)?;
pwm.set_duty_cycle(500_000)?;
This gives a 50% duty cycle.
Enabling and disabling
pub fn enable(&self) -> BbbResult<()> {
sysfs::write_string(&self.enable_path, "1")
}
and:
pub fn disable(&self) -> BbbResult<()> {
sysfs::write_string(&self.enable_path, "0")
}
These are equivalent to:
echo 1 > /sys/class/pwm/pwmchip0/pwm0/enable
echo 0 > /sys/class/pwm/pwmchip0/pwm0/enable
5. Drop and automatic cleanup
Rust allows us to automatically clean up the PWM output when the Pwm value goes out of scope.
Explanation
Explanation
What is Drop?
Drop is Rust’s cleanup mechanism.
When a value goes out of scope, Rust automatically calls its drop method.
Read more:
C vs Rust cleanup
In C, cleanup must be called manually:
pwm_disable(&pwm);
pwm_cleanup(&pwm);
In Rust, cleanup can be attached to the value itself:
impl Drop for Pwm {
fn drop(&mut self) {
let _ = self.cleanup();
}
}
This means:
when pwm goes out of scope -> cleanup runs automatically
Why let _ = ...?
Drop::drop cannot return a Result.
So this line:
let _ = self.cleanup();
means:
try to clean up, but ignore any cleanup error
This is common in destructors and cleanup code.
RAII
This is the same pattern used in the GPIO chapter.
Resource Acquisition Is Initialisation
Meaning:
create object -> acquire/configure resource
object ends -> release/reset resource
6. Mapping ADC values to PWM duty cycle
The C library included:
unsigned int map(u_int16_t adc_value, unsigned int period_ns);
The Rust equivalent is:
pub fn map_adc_to_duty_cycle(adc_value: u16, period_ns: u32) -> BbbResult<u32> {
let adc_value = adc_value as u32;
if adc_value > MAX_ADC {
return Err(BbbError::InvalidValue(format!(
"ADC value out of range: {adc_value}"
)));
}
Ok((adc_value * period_ns) / MAX_ADC)
}
Explanation
Explanation
Purpose
This function maps an ADC reading into a PWM duty cycle.
ADC range: 0..4095
PWM range: 0..period_ns
So if:
adc_value = 2048
period_ns = 1,000,000
then the duty cycle will be approximately:
500,000 ns
or 50%.
Why return BbbResult<u32>?
The C version returns 1 if the ADC value is out of range.
The Rust version returns a proper error:
Err(BbbError::InvalidValue(...))
This makes failure explicit and consistent with the rest of the crate.
Why convert u16 to u32?
let adc_value = adc_value as u32;
The multiplication is done using u32 so that:
adc_value * period_ns
has enough range for typical period values.
Read more:
7. Build
Run:
If successful, you should see a compiled library under:
target/debug/libbbb_io.rlib
The libbbb_io.rlib is the Rust library that our standalone scripts can link against.
This is similar in purpose to a C static library such as:
libioctrl.a
8. Example: pwm_test.rs
Now we can make the Rust equivalent of the earlier pwm_test.c.
-
Create an examples directory if it does not already exist:
-
Create
examples/pwm_test.rs:Code: examples/pwm_test.rs
use std::thread; use std::time::Duration; use bbb_io::pwm::Pwm; fn main() -> bbb_io::BbbResult<()> { let pwm = Pwm::init("P9_14")?; let period = 1_000_000; // 1 ms = 1 kHz println!("Phy: {}", pwm.physical_pin); println!("Channel: {}", pwm.channel); println!("Chip: {}", pwm.chip); pwm.set_period(period)?; pwm.set_duty_cycle(1_000_000)?; pwm.enable()?; thread::sleep(Duration::from_secs(2)); pwm.set_duty_cycle(500_000)?; thread::sleep(Duration::from_secs(2)); pwm.set_duty_cycle(200_000)?; thread::sleep(Duration::from_secs(2)); pwm.set_duty_cycle(100_000)?; pwm.disable()?; pwm.cleanup()?; Ok(()) }Explanation
Imports
use std::thread; use std::time::Duration;These are used to pause the program between duty-cycle changes.
use bbb_io::pwm::Pwm;This imports the
Pwmtype from our crate.
Creating the PWM handle
let pwm = Pwm::init("P9_14")?;This is the Rust equivalent of:
PWM pwm; pwm_init(&pwm, "P9_14");It performs:
lookup P9_14 -> export pwm0 if needed -> build sysfs paths -> return Pwm handle
Period
let period = 1_000_000;This sets the PWM period to 1,000,000 ns.
1,000,000 ns = 1 ms = 1 kHz
Printing debug information
println!("Phy: {}", pwm.physical_pin); println!("Channel: {}", pwm.channel); println!("Chip: {}", pwm.chip);This is equivalent to printing the C struct fields.
Unlike the C version, the file paths are private fields, so they are not printed directly here.
Setting PWM parameters
pwm.set_period(period)?; pwm.set_duty_cycle(1_000_000)?; pwm.enable()?;This configures and enables the PWM output.
Note:
pwm.set_duty_cycle(1_000_000)?;with:
period = 1_000_000gives 100% duty cycle.
In the original C example, the comment said this was 50%, but that comment was incorrect.
For 50% duty cycle, use:
pwm.set_duty_cycle(500_000)?;
Changing duty cycle over time
thread::sleep(Duration::from_secs(2)); pwm.set_duty_cycle(500_000)?;This waits for two seconds, then changes the duty cycle.
The example steps through:
100% -> 50% -> 20% -> 10%
Cleanup
pwm.disable()?; pwm.cleanup()?;This manually disables and resets the PWM output.
Even if this were omitted,
Dropwould attempt cleanup automatically whenpwmgoes out of scope.Explicit cleanup is included here so students can clearly see the lifecycle.
-
Compile the example:
rustc examples/pwm_test.rs \ --edition=2021 \ -L target/debug \ --extern bbb_io=target/debug/libbbb_io.rlib \ -o pwm_testExplanation
-
rustc examples/pwm_test.rs- Compile this standalone Rust program.
-
--edition=2021- Selects the Rust language edition.
Read more:
-
-L target/debug- Tells
rustcwhere to look for compiled libraries.
- Tells
-
--extern bbb_io=target/debug/libbbb_io.rlib-
Links the program against the compiled
bbb_iocrate. -
This is why the program can write:
use bbb_io::pwm::Pwm;
-
-
-o pwm_test-
Names the output binary:
./pwm_test
-
-
We do not use
-l gpiodhere.GPIO needed
-l gpiodbecause it used a C library through FFI.PWM uses sysfs file writes, so it only needs the Rust crate.
-
-
Run the example:
You may need
sudobecause writing to/sys/class/pwm/...often requires elevated permissions.
9. Circuit
Wire the PWM output to an LED or appropriate load.
For a basic LED test:
P9_14 -> resistor -> LED anode
LED cathode -> GND
Use a suitable resistor, for example:
220Ω to 1kΩ
Do not drive high-current loads directly from a BeagleBone pin.
For motors, relays, or LED strips, use a transistor, MOSFET, motor driver, or appropriate interface circuit.
10. Expected behaviour
If everything is working, the output should step through different duty cycles:
100% -> 50% -> 20% -> 10% -> off
For an LED, this should appear as different brightness levels.
For a scope or logic analyser, you should see the high-time within each PWM period change.
12. Summary
This chapter translated the C PWM library into Rust.
The mapping is:
C function Rust method/function
------------------------------------------------
pwm_init Pwm::init
pwm_cleanup Pwm::cleanup / Drop
pwm_set_period Pwm::set_period
pwm_set_duty_cycle Pwm::set_duty_cycle
pwm_enable Pwm::enable
pwm_disable Pwm::disable
map map_adc_to_duty_cycle
The larger pattern is:
Physical pin name
v
PWM pin map
v
sysfs paths
v
Linux kernel PWM subsystem
v
PWM output
PWM is therefore a good contrast with GPIO:
GPIO -> libgpiod / /dev/gpiochipN
PWM -> sysfs / /sys/class/pwm