Device-driver toolkit
A toolkit to write better device drivers, faster.
This book aims to guide you to write your own device drivers using the device-driver toolkit. For runtime docs, visit docs.rs.
Device-driver uses a small custom language named DDSL (device driver specification language) as its input. It’s made so creating drivers is direct, easy and to-the-point.
Example of a DDSL register:
register SYNT {
address: 0x05,
reset: 0x42162762,
fields: fieldset _ {
size-bytes: 4,
byte-order: BE,
/// Set the charge pump current according to the XTAL frequency
/// (see Table 37. Table 34).
field PLL_CP_ISEL 31:29 -> uint,
/// Synthesizer band select. This parameter selects the out-of loop
/// divide factor of the synthesizer:
/// - false: 4, band select factor for high band
/// - true: 8, band select factor for middle band
/// (see Section 5.3.1 RF channel frequency settings).
field BS 28 -> bool,
/// The PLL programmable divider
/// (see Section 5.3.1 RF channel frequent settings).
field SYNT 27:0 -> uint
}
}
Note
The name
device-driverconsists of two parts:
driver: Code to enable the use of hardware.device: A chip or peripheral you can talk to over a bus.Examples of good targets for using this toolkit:
- An I2C accelerometer
- A SPI radio transceiver
- A screen/display with parallel bus
The driver is usable in any no-std context and can be made to work with the
embedded-halcrate or any custom interfaces.
Book overview:
The book contains documentation for multiple versions. Go to the version you’re using and continue to read there. If you’re new, then the most recent version is recommended.
The book has sections on usage, tutorials and language specs.
The addendums contain useful background information.
Caution
It’s hard to keep a book like this up-to-date with reality. Small errors might creep in despite my best effort. If you do find something out of place, missing or simply wrong, please open an issue or PR, even if it’s just for a typo! I’d really appreciate it and helps out everyone.
Known drivers using the toolkit:
It’s nice to have examples:
V2:
V1:
- Nordic nPM1300 Power Management IC
- iqs323 inductive/capacitive sensing controller
- AXP192 Power Management IC
- ONSEMI FUSB302B USB-PD PHY
- iC-Haus iC-MD 48bit quadrature counter
- STMicroelectronics LIS2DE12 3-axis accelerometer
- ISSI IS25LP128F 128Mbit SPI NOR Flash
- ON Semiconductor CAT25040 4kbit SPI EEPROM
- TI BQ27441 Battery Fuel Gauge IC
- TI BQ25887 2-Cell Battery Charger IC
- TI ADC Bus Expanders
Feel free to add to this list!
Intro
Important
We deserve better drivers. Rust has shown that we don’t need to stick to old principles and that we as an industry can do better.
Device-driver is a toolkit written in Rust that generates safe, documented interfaces for hardware devices, handling bit-packed registers and device commands through an expressive custom language.
While the Rust language provides many opportunities to improve the way we write drivers, it doesn’t mean those are easy to use. There are two issues:
- Creating good datastructures to represent the driver is hard
- Writing the definitions along with all its boilerplate takes a lot of thankless work
By using this toolkit, you get both 1 and 2 solved.
Number one is solved by getting the datastructures as part of this toolkit which has seen over 6 years of iteration and improvements. The second issue is solved by using code generation so you only need to take care of the things that make your driver unique.
Together, this delivers a really tight and precise way of authoring your device driver:
// device.ddsl
device MyDevice {
register-address-type: u8,
/// This is the Foo register
register Foo {
address: 0,
fields: fieldset _ {
size-bytes: 1,
/// This is a bool at bit 0!
field value0 0 -> bool,
/// Integrated enum generation
field value1 3:1 -> _ as enum GeneratedEnum {
A: _,
/// Variant B
B: _,
C: default _,
},
/// This is a 4-bit integer
field value2 7:4 -> uint,
}
},
}
// Generate and include
// `ddc build rust -s device.ddsl -o device.rs --rust-defmt-feature=defmt`
include!("device.rs");
// Or use the macro to compile at build-time
device_driver::compile!(
options: "--rust-defmt-feature=defmt", // Target options
manifest: "device.ddsl" // Link to definition
);
let mut device = MyDevice::new(device_interface);
device.foo().write(|reg| reg.set_value_1(GeneratedEnum::B)).unwrap();
Instantly we get a nice and familiar API that is well documented.
The goal
When you’re writing a driver, you just want to implement it and be done with it. Most of the time developing a driver is boring and repetitive.
To help you do less of the boring work and to create a higher quality driver at the same time, the goals are:
- Get a great driver for minimal effort
- Get a driver that is correct and hard to misuse
- (assuming the input spec is correct)
- Get a driver that is well documented
- (assuming the input spec gives docs)
These goals are met by:
- Using a dense and precise input language
- Having many options to deal with memory layout
- Having analysis steps and great error reporting to decrease the chance of common mistakes
- Separating the interface to the device from the definitions
- Allowing you to put docs on pretty much anything
How to continue
Simply read the rest of the book!
If you’re new, head over to one of the tutorial sections. If you’re looking for a language detail, go see one of the reference sections.
Looking at existing drivers and examples can also be very helpful.
Future plans
There are many more features this toolkit wishes to support. To get an up-to-date overview, check out the issue tracker.
Zooming out, the plans can be summarized to:
- LSP support for DDSL
- Output to typst/pdf/html for auto docs
- Add support for other languages like C/C++/Zig/TinyGo/MicroPython
- A simple general programming language in DDSL
- Allow interface definitions
- Allow simple routines to be implemented in DDSL (for init, sleep, read data)
- Allow the implementation of statemachines
- Support mixed read-write transactions
- Add string/byte array base types
- Add templates
If you feel strongly about any of this and have ideas/suggestions, feel free to reach out on the appropriate issues or in the matrix chat room.
Tutorial YM3812 (OPL2)
This is a simple introduction tutorial for people new to device-driver. It covers the basics of registers, blocks, interfaces and repeats.
Background
It’s hard to make computer make sounds and music. Or at least, it used to. The early personal computers of the late 70’s and early 80’s either couldn’t make sounds or only had a little PC speaker, the one that beeps when you boot a computer.
Later on, mostly for games, companies started creating more capable sound cards. You could plug them in your PC like you do a graphics card.
The chip we’re going to look at, the OPL2, is a famous chip from that time. It can’t play samples and only has 9 channels with which to make sounds. But those channels can all be configured individually with two operators which can perform ‘FM synthesis’.
The OPL2 could be found on two sound cards: The AdLib from 1987 and the Sound Blaster from 1989.
Want to know what computers sounded like back then? Then listen to this video. The OPL2 is the third variant shown, starting at 3:08.
Examining the hardware
Finding docs
First we need to know what we’re dealing with and find some documentation for the chip. This isn’t as easy as with modern chips, but luckily this chip is and was liked by hobbyists. There’s a website dedicated to OPL hardware: oplx.com.
On it we can find the following text file: adlib_sb.txt. It’s from someone in 1992 noticing people don’t have good docs and deciding he would fix that. So thank you Jeffrey S. Lee for the early open source spirit!
This document is made for people using soundblaster cards in their PCs. But I don’t have such a card and even if I did, it wouldn’t fit. Instead I have an earlier version of this board. Instead of having a parallel interface, there’s a shift register so we can use SPI to communicate with the board.
Now that we have all the resources we need, we can get started!
How does the chip work?
The OPL2 can’t make audio like we’re used to today. Modern audio devices can play samples that resemble the audio waves in the air which the device tries to recreate.
But these old devices aren’t capable of that, it was simply too advanced. Instead the OPL2 have multiple operators that can only make the following simple wave forms:
Later iterations of the OPL have more wave forms.
Luckily there’s all kinds of settings with which to edit these wave forms to make them more interesting. This tutorial isn’t about that though, so if you want to know more, this website has a nice overview and audio samples: cosmodoc.org/topics/adlib-functions.
Important
For us what’s most important to know now is that this chip has 9 channels with 2 operators each. Those channels can output the additive result of those 2 operators or they can be used for FM synthesis.
Register layout
The documentation we found helpfully lays out an overview of all registers:
Address Function
------- ----------------------------------------------------
01 Test LSI / Enable waveform control
02 Timer 1 data
03 Timer 2 data
04 Timer control flags
08 Speech synthesis mode / Keyboard split note select
20..35 Amp Mod / Vibrato / EG type / Key Scaling / Multiple
40..55 Key scaling level / Operator output level
60..75 Attack Rate / Decay Rate
80..95 Sustain Level / Release Rate
A0..A8 Frequency (low 8 bits)
B0..B8 Key On / Octave / Frequency (high 2 bits)
BD AM depth / Vibrato depth / Rhythm control
C0..C8 Feedback strength / Connection type
E0..F5 Wave Select
We can notice there are three kinds of registers:
- Single registers
- Repeated registers 0..=8
- Repeated registers 0..=21 (0x15)
The single registers are for global settings. The 9 repeated registers are one for each channel, which makes sense. But the registers that are repeated 22 times? Well, that’s where the chip is a little weird. These are for the operators, except there are only 18 operators in total (2 per channel).
When we read on in the documentation we find this table:
The groupings of twenty-two registers (20-35, 40-55, etc.) have an odd
order due to the use of two operators for each FM voice. The following
table shows the offsets within each group of registers for each operator.
Channel 1 2 3 4 5 6 7 8 9
Operator 1 00 01 02 08 09 0A 10 11 12
Operator 2 03 04 05 0B 0C 0D 13 14 15
Thus, the addresses of the attack/decay bytes for channel 3 are 62 for
the first operator, and 65 for the second. (The address of the second
operator is always the address of the first operator plus three).
This is annoying, but we’ll have to deal with it.
The interface
The board I have with the shift register doesn’t really spell out how to use it. But you can look at the code the author provided to see what has to happen.
Basically we have 4 relevant pins to use when writing data to the chip (through the shift register):
| name | function |
|---|---|
| Data | The bit value we’re shifting in |
| Shift | The clock signal. When transitioning from low to high, the value of the Data pin is shifted in. |
| Latch | When the latch is pulled low for 1us, the shifted in data is applied to the parallel bus. |
| A0 | When low, the data on the bus is seen as the address. When high the data on the bus is seen as register data. |
First the address needs to be written, then you must wait 4us, then the data needs to be written and then you must wait 23us.
Writing the driver
DDSL
First we create a file named ym3812.ddsl (or something else to your liking). Then we write the basic setup:
device Ym3812 {
// Our register address space is 0-255, and the shift register takes a byte,
// so use u8 for the address type
register-address-type: u8,
// Everything is read-write, so to save some typing, we set a default
default-access: RW,
}
With this we’ve told the compiler there’s a device and that it uses u8 as the address type for registers. Luckily that’s all the settings we need already out of the way, so we can continue with writing the registers.
Global registers
Let’s start simple and do the global registers first. There’s no good names given to these registers, so we’ll have to be a bit creative ourselves.
The docs for the first register is here:
Byte 01 - This byte is normally used to test the LSI device. All bits
should normally be zero. Bit 5, if enabled, allows the FM
chips to control the waveform of each operator.
7 6 5 4 3 2 1 0
+-----+-----+-----+-----+-----+-----+-----+-----+
| unused | WS | unused |
+-----+-----+-----+-----+-----+-----+-----+-----+
We can notice it’s located at address 1, is 1 byte in size and only uses bit 5.
In device-driver, this data is encoded with two objects: a register and a fieldset. The fieldset describes the data of the register and the register describes how it relates to the device.
Let’s define them in ddsl:
device Ym3812 {
register-address-type: u8,
default-access: RW,
/// Register containing the Waveform Select Enable and some test fields
register Enable_waveform_control {
address: 0x01,
fields: Enable_waveform_control,
},
fieldset Enable_waveform_control {
size-bytes: 1,
/// If clear, all channels will use normal sine wave.
/// If set, register E0-F5 (Waveform Select) contents will be used.
field WS 5 -> bool,
}
}
As you can see, we’ve defined the objects and put some doc comments on them too. The generated code will contain those docs as well, so they’re visible in your code editor.
The register and fieldset use the same name. This is allowed and they don’t clash. That’s because there’s separate namespacing for operations and types. However, having to define two objects for every device register is a bit bloated. To help with that, we can define the fieldset inline in the fields property of the register:
/// Register containing the Waveform Select Enable and some test fields
register Enable_waveform_control {
address: 0x01,
fields: fieldset _ {
size-bytes: 1,
/// If clear, all channels will use normal sine wave.
/// If set, register E0-F5 (Waveform Select) contents will be used.
field WS 5,
},
},
That’s much more concise! There are two additional changes you may notice that use two different auto features:
- We don’t specify the fieldset name and use an underscore. When defining inline types, this can be used to make the type take on the name of the node it’s being defined in.
- We don’t specify the field is a bool anymore. This is the same as if we wrote
field WS 5 -> _. There are some rules about what the so-called base type of the field will become (in order):- If the field contains a conversion (we’ll see that later in the tutorial), it will take on the base type of the conversion target.
- If the field is 1 bit in size, it will become a
bool. - If the field is multiple bits, it will become a
uint. (Theuintwill then become the smallest sized integer that fits the number of bits. So auintwith 11 bits becomes au16)
Alright, next register:
Byte 02 - Timer 1 Data. If Timer 1 is enabled, the value in this
register will be incremented until it overflows. Upon
overflow, the sound card will signal a TIMER interrupt
(INT 08) and set bits 7 and 6 in its status byte. The
value for this timer is incremented every eighty (80)
microseconds.
This one is more boring, so let’s just define it:
register Timer_1_Data {
address: 0x02,
fields: fieldset _ {
size-bytes: 1,
field value 7:0,
}
},
7:0 is the bit range. It’s high to low and it’s an inclusive range. Again we don’t specify the base type of the value field,
so it’ll become a u8 in this case.
Let’s skip some of the registers that you should be able to define yourself already now and go to the last global register that uses some new features:
Byte BD - Amplitude Modulation Depth / Vibrato Depth / Rhythm
7 6 5 4 3 2 1 0
+-----+-----+-----+-----+-----+-----+-----+-----+
| AM | Vib | Rhy | BD | SD | TOM | Top | HH |
| Dep | Dep | Ena | | | | Cym | |
+-----+-----+-----+-----+-----+-----+-----+-----+
bit 7 - Set: AM depth is 4.8dB
Clear: AM depth is 1 dB
bit 6 - Set: Vibrato depth is 14 cent
Clear: Vibrato depth is 7 cent
bit 5 - Set: Rhythm enabled (6 melodic voices)
Clear: Rhythm disabled (9 melodic voices)
bit 4 - Bass drum on/off
bit 3 - Snare drum on/off
bit 2 - Tom tom on/off
bit 1 - Cymbal on/off
bit 0 - Hi Hat on/off
All these fields could be bools. But that would be confusing for the fields that aren’t simple on/off fields. The other fields encode a value that’s distinct from true/false. So ideally we encode those values in a way so the user of our driver knows what they mean without looking at the documentation.
Luckily we can do that using enums! And once again, we can define and use them inline.
register rhythm_settings {
address: 0xBD,
fields: fieldset _ {
size-bytes: 1,
/// Tremolo (Amplitude Vibrato) Depth.
field tremolo_depth 7 -> _ as enum _ {
/// 1.0dB
Low: 0b0,
/// 4.8dB
High: 0b1,
},
/// Frequency Vibrato Depth. A "cent" is 1/100 of a semi-tone.
field vibrato_depth 6 -> _ as enum _ {
/// 7 cents
Low: 0b0,
/// 14 cents
High: 0b1,
},
field instrument_mode 5 -> _ as enum _ {
Melodic: 0b0,
Percussion: 0b1,
},
field bass_drum_on 4,
field snare_drum_on 3,
field tom_tom_on 2,
field cymbal_on 1,
field hi_hat_on 0,
}
}
The first three fields use the optional conversion syntax of the type specifier. The enums will have the same names as the fields.
Here we can use infallible conversion, which is always recommended when possible. But there exist situations where the enum can’t cover all possible bit patterns, at which point fallible conversion must be used. That would look like this:
field foo 0 -> _ as try enum _ { }
// +++
Channel settings
There are 3 registers per channel we need to be able to program. These control the frequency, whether they’re on or off and how the two operators are connected.
We could define each individually and that’d work ok. But we’re in the business of providing the best API to our users as possible. So we’re going to combine two powerful features: repeats and blocks.
Repeats
A repeat can be used to, well, repeat an object multiple times. It’s kind of like an array, so much so that the syntax looks like it too.
We can define a repeat using brackets, like this:
register foo[4 stride 2] { ... }
Here we’ve defined a register that is repeated four times. And with each repeat, the address is incremented by two. So if the start address is 10, then this register is present on addresses 10, 12, 14 and 16.
Repeats can use enums too instead of a length:
enum bar { a: 2, b: 3, c: 5, d: 7 },
register foo[bar stride 2] { ... }
This is incredibly useful for when there are gaps in the index. The stride is a multiplier on the values of the enum.
Blocks
A block is an object that groups subobjects together. And that’s very useful in our case because we can group the channel settings together.
block foo {
address-offset: 10,
register bar {
address: 10,
// ...
},
register quux {
address: 11,
// ...
}
}
Here we see registers bar and quux are part of block foo.
Important to know is that the block can specify an address offset which is then added to all child objects. So in reality bar and quux have addresses 20 and 21. It’s up to you to decide what makes sense. You can always set the offset to 0 if you want to use the global addresses.
Combined
For the driver we’re writing, we could do a repeat on every channel register. But instead let’s do the repeat on a block and put all channels settings in that block. That way it’s nice and organized. Here’s how it could be modeled (with some added comments for explanations):
enum Channel {
C1: _, // Use auto assignment. This starts at 0
C2: _, // This variant is auto-assigned value 1
C3: _, // 2
C4: _,
C5: _,
C6: _,
C7: _,
C8: _,
C9: _,
},
block ChannelGeneralSettings[Channel stride 1] {
// ^^^^^^^^^^^^^^^^^^
// Create the block with the channel enum as the repeat
// Each channel should offset the registers by 1, so we pick a stride of 1
// Let's not add a block offset so we can keep using the global addresses
// That simply makes most sense in this case
address-offset: 0,
register channel_settings0 {
address: 0xA0,
fields: fieldset _ {
size-bytes: 1,
field frequency_number_lsb 7:0,
}
},
register channel_settings1 {
address: 0xB0,
fields: fieldset _ {
size-bytes: 1,
/// Channel is voiced when set, silent when clear.
field key_on 5,
/// Octave (0-7). 0 is lowest, 7 is highest.
field block_number 4:2,
field frequency_number_high 1:0,
}
},
register channel_settings2 {
address: 0xC0,
fields: fieldset _ {
size-bytes: 1,
/// Feedback strength. If all three bits are set to
/// zero, no feedback is present. With values 1-7,
/// operator 1 will send a portion of its output back
/// into itself. 1 is the least amount of feedback,
/// 7 is the most.
field feedback 3:1,
field synthesis_type 0 -> _ as
/// How the operators interact.
/// Complex sounds are more easily created
/// if the algorithm is set to FrequencyModulation.
enum SynthesisType {
/// Operator 1 modulates operator 2.
/// In this case, operator 2 is the only one producing sound.
FrequencyModulation: 0b0,
/// Both operators produce sound directly.
AdditiveSynthesis: 0b1,
},
}
},
},
Note how the SynthesisType has doc comments on a newline. That’s how you add a description to inline objects.
Operator settings
The way the operator settings are done, is a little crazy on this chip. Even the documentation we have calls that out!
Remember, every channel has two operators.
The groupings of twenty-two registers (20-35, 40-55, etc.) have an odd
order due to the use of two operators for each FM voice. The following
table shows the offsets within each group of registers for each operator.
Channel 1 2 3 4 5 6 7 8 9
Operator 1 00 01 02 08 09 0A 10 11 12
Operator 2 03 04 05 0B 0C 0D 13 14 15
Thus, the addresses of the attack/decay bytes for channel 3 are 62 for
the first operator, and 65 for the second. (The address of the second
operator is always the address of the first operator plus three).
The channels have gaps in them and the second operator is always the first operator plus 3.
So, let’s use the same trick with the block and repeat again:
/// Enum to select the channel for the operator settings block
enum ChannelOperators {
C1: 0x00,
C2: 0x01,
C3: 0x02,
C4: 0x08,
C5: 0x09,
C6: 0x0A,
C7: 0x10,
C8: 0x11,
C9: 0x12,
},
/// Each channel has two operators
enum Operator {
O1: 0,
O2: 1, // We could pick 3 (stride 1) or 1 (stride 3)
// But this makes most logical sense
},
Then we use these to create the block with the operator registers. I’ll omit the register details since those clutter the code for this example.
Different from the channel settings is that we need to have two repeats. To be consistent, we’ll use the channel repeat on the block and then for each register we’ll have a repeat to select the operator for that channel.
/// Block containing all operator settings for a channel
block ChannelOperatorSettings[ChannelOperators stride 1] {
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^
address-offset: 0,
register operator_settings0[Operator stride 3] {
// ^^^^^^^^^^^^^^^^^^^
address: 0x20,
fields: fieldset _ {
// ...
}
},
register operator_settings1[Operator stride 3] {
address: 0x40,
fields: fieldset _ {
// ...
}
},
register operator_settings2[Operator stride 3] {
address: 0x60,
fields: fieldset _ {
// ...
}
},
register operator_settings3[Operator stride 3] {
address: 0x80,
fields: fieldset _ {
// ...
}
},
register operator_settings4[Operator stride 3] {
address: 0xE0,
fields: fieldset _ {
// ...
}
},
},
And that’s it for the DDSL code! We can now actually start using it.
Rust crate
Let’s turn this all into a driver we can use from Rust!
The best way to do it, is to make it into its own crate. We want to make it usable for embedded use, so we’ll make it no-std and to make it portable, we’ll use the embedded-hal traits.
Setting up the crate
We can make a crate using cargo:
cargo new ym3812 --lib
cd ym3812
There’s two styles in which we can architect the crate:
- Using the device-driver compile macro
- This is the easiest way to use device-driver and makes sense during development
- Downside is that the device-driver compiler becomes a dependency
- Using the cli to generate rust code ahead of time
- When you’re done with your driver and ready to publish, you might want to convert your crate to this style
- Downside is that it’s a little more setup and your generated rust code can be out of sync with your ddsl
For this tutorial, we’ll set up for using the compile macro.
Now that we’ve got our crate, we should do a couple of things:
- Place our ddsl file in the crate root. Let’s call it
ym3812.ddsl, but you can call it anything. - Add the required dependencies:
# The macros feature enables the compile macro cargo add device-driver --features macros cargo add embedded-hal # Only required for async drivers cargo add embedded-hal-async - Add a build script. This will make sure the crate will be recompiled when the ddsl is changed:
fn main() { println!("cargo:rebuild-if-changed=ym3812.ddsl"); }
Using the macro
Typically I like to make a separate module to put the driver definitions in and call it something like ll for low-level.
So let’s do that!
// lib.rs
mod ll;
To compile the ddsl code, simply use the compile macro:
// ll.rs
device_driver::compile!(
manifest: "ym3812.ddsl",
);
This will put all of the generated code at the site of the macro call.
Creating the interface
Device-driver doesn’t know how to talk with the device, so we need to teach it that. With Rust we’ve got traits for it!
Every type of operation has its own traits. We’ve only defined registers, so we only need to implement the RegisterInterface and/or AsyncRegisterInterface for the interface type we’re going to make.
If the blocking version is implemented, then blocking register reads and writes will be supported. Same with the async version. For this tutorial we’ll only implement the async version.
Our interface is just a struct, so let’s create it with all the IO it needs:
// ll.rs
use embedded_hal::digital::OutputPin;
use embedded_hal_async::{delay::DelayNs, spi::SpiBus};
/// Our hardware interface with the chip using the shift register that
/// is present on the opl2 audio board by Maarten Janssen
pub struct ShiftInterface<SPI, A, L, R, D>
where
SPI: SpiBus,
A: OutputPin,
L: OutputPin,
R: OutputPin,
D: DelayNs,
{
/// The spi interface we use to drive the shift register
spi: SPI,
/// The pin connected to the A0 input
address_pin: A,
/// The pin connected to the latch input of the shift register
latch_pin: L,
/// The pin connected to the reset input
reset_pin: R,
/// Some kind of delay provider
delay: D,
/// A copy of all the registers in memory.
///
/// We need this because we can't read the OPL registers.
/// By keeping track of this ourselves, we can still present
/// a read/write interface which is useful for modifying registers.
registers: [u8; u8::MAX as usize],
}
Now that we have our shift interface, we can start implementing the traits we need. First off is the base trait that defines address type and the error type. The address type must be the same as the one we set up in the ddsl code.
// ll.rs
use device_driver::RegisterInterfaceBase;
#[derive(Debug)]
pub enum InterfaceError {
AddressPinError,
LatchPinError,
ResetPinError,
CommunicationError,
}
impl<SPI: SpiBus, A: OutputPin, L: OutputPin, R: OutputPin, D: DelayNs> RegisterInterfaceBase
for ShiftInterface<SPI, A, L, R, D>
{
type Error = InterfaceError;
type AddressType = u8;
}
Now we’re ready to implement the main trait. This will look very different for almost every device. The requirements here have been discussed earlier in the tutorial.
Normally devices have sections in their datasheets about how the communication with the device work.
// ll.rs
impl<SPI: SpiBus, A: OutputPin, L: OutputPin, R: OutputPin, D: DelayNs> AsyncRegisterInterface
for ShiftInterface<SPI, A, L, R, D>
{
async fn write_register(
&mut self,
address: Self::AddressType,
data: &mut [u8],
_metadata: &device_driver::FieldsetMetadata,
) -> Result<(), Self::Error> {
// We know we've always got one byte since all registers are that size
let byte = data[0];
// Save in internal data store
self.registers[address as usize] = byte;
// Send the address
self.address_pin
.set_low()
.map_err(|_| Self::Error::AddressPinError)?;
self.spi
.write(&[address])
.await
.map_err(|_| Self::Error::CommunicationError)?;
// Apply the shift latch
self.latch_pin
.set_low()
.map_err(|_| Self::Error::LatchPinError)?;
self.delay.delay_us(1).await;
self.latch_pin
.set_high()
.map_err(|_| Self::Error::LatchPinError)?;
self.delay.delay_us(4).await;
// Send the data
self.address_pin
.set_high()
.map_err(|_| Self::Error::AddressPinError)?;
self.spi
.write(&[byte])
.await
.map_err(|_| Self::Error::CommunicationError)?;
// Apply the shift latch
self.latch_pin
.set_low()
.map_err(|_| Self::Error::LatchPinError)?;
self.delay.delay_us(1).await;
self.latch_pin
.set_high()
.map_err(|_| Self::Error::LatchPinError)?;
self.delay.delay_us(23).await;
Ok(())
}
async fn read_register(
&mut self,
address: Self::AddressType,
data: &mut [u8],
_metadata: &device_driver::FieldsetMetadata,
) -> Result<(), Self::Error> {
data[0] = self.registers[address as usize];
Ok(())
}
}
Let’s add some convenience methods too for construction and resetting the device.
// ll.rs
impl<SPI: SpiBus, A: OutputPin, L: OutputPin, R: OutputPin, D: DelayNs>
ShiftInterface<SPI, A, L, R, D>
{
pub const fn new(spi: SPI, address_pin: A, latch_pin: L, reset_pin: R, delay: D) -> Self {
Self {
spi,
address_pin,
latch_pin,
reset_pin,
delay,
registers: [0; _],
}
}
pub async fn reset(&mut self) -> Result<(), InterfaceError> {
// Set the pins to the default level
self.latch_pin
.set_high()
.map_err(|_| InterfaceError::LatchPinError)?;
self.reset_pin
.set_high()
.map_err(|_| InterfaceError::ResetPinError)?;
self.address_pin
.set_low()
.map_err(|_| InterfaceError::AddressPinError)?;
// Make a reset cycle
self.reset_pin
.set_low()
.map_err(|_| InterfaceError::ResetPinError)?;
self.delay.delay_ms(1).await;
self.reset_pin
.set_high()
.map_err(|_| InterfaceError::ResetPinError)?;
// Reset the internal registers
self.registers = [0x00; 0xFF];
self.write_register(0x00, &mut [0x00; 0xFF], &FieldsetMetadata::DEFAULT)
.await?;
Ok(())
}
}
With that we’ve done all the setup we need!
Using the driver
Let’s create an instance of the driver and explore how we can now use it.
// Create an instance of the interface we need to talk with the chip
// Get your SPI and gpio from your HAL
let interface = ShiftInterface::new(
Spi::new_txonly(p.SPI1, p.PB3, p.PB5, p.DMA1_CH1, Irqs, config),
Output::new(p.PB14, Level::Low, Speed::VeryHigh),
Output::new(p.PC4, Level::Low, Speed::VeryHigh),
Output::new(p.PD1, Level::Low, Speed::VeryHigh),
embassy_time::Delay,
);
// Create the driver
let mut ym3812 = Ym3812::new(interface);
// Access the interface to call its reset function
ym3812.interface().reset().await?;
Now we know the device is in a good state (reset) and ready to use.
Example from docs
Let’s make a sound, that’s the entire point after all. Again, the guide helps us!
| Making a Sound
|
| Many people have asked me, upon reading this document, what the proper
| register values should be to make a simple sound. Well, here they are.
|
| First, clear out all of the registers by setting all of them to zero.
| This is the quick-and-dirty method of resetting the sound card, but it
| works. Note that if you wish to use different waveforms, you must then
| turn on bit 5 of register 1. (This reset need be done only once, at the
| start of the program, and optionally when the program exits, just to
| make sure that your program doesn't leave any notes on when it exits.)
|
| Now, set the following registers to the indicated value:
|
| REGISTER VALUE DESCRIPTION
| 20 01 Set the modulator's multiple to 1
| 40 10 Set the modulator's level to about 40 dB
| 60 F0 Modulator attack: quick; decay: long
| 80 77 Modulator sustain: medium; release: medium
| A0 98 Set voice frequency's LSB (it'll be a D#)
| 23 01 Set the carrier's multiple to 1
| 43 00 Set the carrier to maximum volume (about 47 dB)
| 63 F0 Carrier attack: quick; decay: long
| 83 77 Carrier sustain: medium; release: medium
| B0 31 Turn the voice on; set the octave and freq MSB
|
| To turn the voice off, set register B0h to 11h (or, in fact, any value
| which leaves bit 5 clear). It's generally preferable, of course, to
| induce a delay before doing so.
So, let’s replicate that using our new driver. With device-driver we’re not fiddling with bits manually, but use named methods. But since our source is specified in addresses and bits, we’ll need to work backwards.
ym3812.enable_waveform_control()
.write_async(|w| w.set_ws(true))
.await
.unwrap();
let mut operator_settings = ym3812.channel_operator_settings(ChannelOperators::C1);
// Set operator 1 settings
operator_settings
.operator_settings_0()
.write_at_async(Operator::O1, |reg| {
reg.set_modulator_frequency_multiple(ModulatorFrequencyMultiple::AtSpecified)
})
.await?;
operator_settings
.operator_settings_1()
.write_at_async(Operator::O1, |reg| {
reg.set_output_level(0x10);
})
.await?;
operator_settings
.operator_settings_2()
.write_at_async(Operator::O1, |reg| {
reg.set_attack_rate(15);
reg.set_decay_rate(0);
})
.await?;
operator_settings
.operator_settings_3()
.write_at_async(Operator::O1, |reg| {
reg.set_sustain_level(7);
reg.set_release_rate(7);
})
.await?;
// Set operator 2 settings
operator_settings
.operator_settings_0()
.write_at_async(Operator::O2, |reg| {
reg.set_modulator_frequency_multiple(ModulatorFrequencyMultiple::AtSpecified)
})
.await?;
operator_settings
.operator_settings_1()
.write_at_async(Operator::O2, |reg| {
reg.set_output_level(0);
})
.await?;
operator_settings
.operator_settings_2()
.write_at_async(Operator::O2, |reg| {
reg.set_attack_rate(15);
reg.set_decay_rate(0);
})
.await?;
operator_settings
.operator_settings_3()
.write_at_async(Operator::O2, |reg| {
reg.set_sustain_level(7);
reg.set_release_rate(7);
})
.await?;
// Set channel settings
let mut channel = ym3812.channel_general_settings(Channel::C1);
channel
.channel_settings_0()
.write_async(|reg| reg.set_frequency_number_lsb(0x98))
.await?;
channel
.channel_settings_1()
.write_async(|reg| {
reg.set_key_on(true);
reg.set_frequency_number_high(1);
reg.set_block_number(4);
})
.await?;
ym3812.interface().delay.delay_ms(1000).await;
ym3812.channel_general_settings(Channel::C1)
.channel_settings_1()
.modify_async(|reg| {
reg.set_key_on(false);
})
.await?;
Cool, let’s check if this actually works:
It does!
We’ve got a working driver. Now the world, or at least this device, is our oyster. From here you can extend your driver as you like. Setting each register every time is bothersome, so you might want to create some rust functions that take an instrument and set all the registers required for that instrument.
If you want to make music, you’ll need to drive that too yourself, so you’ll want to make some sort of sequencer.
Exactly how you structure that is up to you. Generally I’ve found it good practise to view the driver as we have now as a ‘low level’ driver which we can wrap with a ‘high level’ driver. That’s similar to how a PAC and HAL relate on microcontrollers.
In the future, device-driver hopes to offer some more high level features. You’ll have to stay tuned for that and/or peruse the issue tracker on github. Suggestions are always welcome too!
If you want to see how I tackled that, take a look here: github
If things are unclear or could be improved for this tutorial, please send PRs, open issues or hit me up in the chatroom!
I’ll leave you with the song that’s played by the example through my terrible hacked up sequencer I made 6 years ago:
Language
Device-driver uses a simple, custom, declarative language called DDSL (device-driver specification language). It consists of only a few building blocks: Nodes, properties and expressions.
Node
The node is the foundation. A ddsl file must have one root node and everything else must be defined in it. A node defines an object and in many cases these terms are interchangeable. (It’s a node in the AST and an object in the MIR)
Nodes have a node type and a name. Additionally a node may have a repeat specifier, ‘short’ properties and a type specifier outside of the node body and ‘long’ properties and subnodes in the node body.
type name[repeat] <expressions> -> <type specifier> {
// Inside the curly's is the node body
// Long properties are named expressions
long-property-name: <expression>,
subnode-type subnode-name // ...
}
The node type determines the final shape of the node that’s accepted. There are a couple of defined node types:
Any node type not on this list is rejected by the compiler.
Properties
Properties come in two forms: ‘short’ and ‘long’.
Short
Short properties are anonymous (simple) expressions that appear between the node name and the type specifier, outside the node body. This makes them limited as the type of the expression determines what they mean.
In practice, short properties are mostly used for fields where compact notation is important.
Long
A long property is a named expression in the node body. The name and the expression are separated by a colon. They need to be written before any subnode in a node body.
The name of the property determines what the expression is used for. For all node types except enums, the name must match one of the defined properties for that node type. In enums, however, properties are used to define the enum variants and can take any name.
Namespacing
In DDSL there’s one global namespace that all* objects are part of. However, not all names will clash.
There are two buckets a name can be categorized into:
- Operation
- Type
An operation is something that’s done with a driver. Every operation becomes a method you can call on a device/block.
Meanwhile a type is a data definition or collection of operations and these become structs and enums in the generated code.
As an example, a register “Foo” is allowed to define a fieldset “Foo”. The register is an operation and the fieldset is a type.
Operations:
- Manifest
- Block
- Register
- Command
- Buffer
Types:
- Manifest
- Device
- Block
- Fieldset
- Enum
- Extern
Notice how manifests and blocks are part of both.
*: Enum variants and fields are sort of objects, but they are namespaced within their local defining enum/fieldset.
Formatting style guide
We stick close to Rust’s style guide. On its face that means:
- Indent with 4 space characters
- A node with a body opens the
{on the same line as the start of the node (so not on a separate line) - Use trailing comma’s everywhere
- Add a blank line to separate items that are distinct
- For example, between the node properties and the subnodes
- If you want to document an inline type definition, insert a newline before the subnode and increment the indent. For example:
field foo 0 -> _ as try /// Comments enum Foo { A: _ }, field bar 1,
There is no formatter yet to do this for you.
Tokens & AST
The language is first lexed into tokens, out of which an abstract syntax tree (AST) is parsed.
Tokens
The implementation of the lexer can be found at compiler\dd-lexer.
These are valid tokens in the language:
| Name | Pattern | Example |
|---|---|---|
| Whitespace (skipped) | r"[ \t\r\n]+" | |
| Comments (skipped) | r"//[^\n]*" | // ... |
| DocCommentLine | r"///[^\n]*" | /// ... |
| Ident | r"\p{XID_Start}[\p{XID_Continue}-]*" | Foo-bar |
| CurlyOpen | { | |
| CurlyClose | } | |
| BracketOpen | [ | |
| BracketClose | ] | |
| Comma | , | |
| Colon | : | |
| Underscore | _ | |
| Arrow | -> | |
| Star | * | |
| Try | try | |
| As | as | |
| Allow | allow | |
| Default | default | |
| CatchAll | catch-all | |
| Stride | stride | |
| Num | r"-?[0-9][_0-9]*" (decimal) | 01_23 |
| Num | r"-?0b[_0-1]+" (binary) | 0b11_00 |
| Num | r"-?0o[_0-7]+" (octal) | 0o01_23 |
| Num | r"-?0x[_0-9a-fA-F]+" (hexadecimal) | 0xAA_bb |
| Access | RW / RO / WO | |
| ByteOrder | BE / LE | |
| BaseType | uint / int / bool | |
| Integer | u8 / u16 / u32 / u64 / i8 / i16 / i32 / i64 | |
| AddressMode | mapped / indexed | |
| String | r#""[^"]*""# | "my string" |
The tokens are lexed using logos. The regexes are processed by the Rust Regex crate.
Direct tokens have priority over regexed tokens.
Abstract syntax tree
The implementation of the parser can be found at compiler\dd-parser.
The tokens are parsed through multiple sub-parsers into nodes. The AST is one node acting as the root.
The railroad diagrams and ebnf are generated by chumsky and is known to not be 100% correct/complete. Contributions there are encouraged!
The parsed numbers are parsed into a specific type of integer which is displayed in the diagrams. Their sizes are mostly implementation details, with the exception of numbers parsed as bytes.
Node
def_1 ::= ((((({ DocCommentLine } Ident) Ident|Underscore) [ repeat ]) { simple-expression }) [ type-specifier ]) [ node-body ];
def_1
Examples:
register Foo {
address: 0,
}
field Foo 7:0 RW -> _
Specific node types will have restrictions on what is and is not allowed or required. More about that can be found in the reference chapters for those node types as that’s part of the MIR and not the AST.
Repeat
(BracketOpen ((Num<NonZero<u32>>
| Ident) (Stride Num<i32>))) BracketClose
Examples:
[4 stride 2]
[Foo stride 2]
Simple-expression
range
| BaseType
| Integer
| Num<i128>
| Default (Num<i128>
| Underscore)
| CatchAll (Num<i128>
| Underscore)
| byte-array
| Allow
| Access
| ByteOrder
| Underscore
| String
| AddressMode
Type-specifier
(Arrow (BaseType
| Integer
| Underscore)) [ (As [ Try ]) (node
| Ident) ]
Examples:
-> u8 as try Foo
-> bool
-> _ as enum Foo { }
Node-body
(CurlyOpen [ (({ property [Comma]} Comma) { node [Comma]}
| { property [Comma]}
| { node [Comma]}) [ Comma ] ]) CurlyClose
Example:
{
property: _,
register Node {
},
}
Property
{ DocCommentLine } (Ident (Colon (simple-expression
| node
| Ident)))
Examples:
/// Docs
prop1: Foo
prop2: 7:0
Range
(Num<i128> Colon) Num<i128>
Example:
7:0
Byte-array
(BracketOpen ({ Num<u8> [Comma]} [ Comma ])) BracketClose
Example:
[0, 1, 2, 3, 4]
Manifest
The manifest is the root of a driver and is the input to the compiler. All objects that make up the driver are defined in it.
To save on boilerplate, if you only have one device in your driver, you can forego specifying the manifest and just have a device as the root object.
All config variables present on devices are available here too and serve as the default config for all devices. Devices can then override them again.
Example
/// doc comment line
manifest Example {
default-byte-order: LE,
register-address-type: i32,
command-address-type: i32,
buffer-address-type: i32,
word-boundaries: "bD:0B:_",
register-address-mode: mapped,
default-access: RW,
device node,
fieldset node,
enum node,
extern node,
}
Table
| Property | Value |
|---|---|
| Identifier namespace | Global |
| Supports repeat | no |
| Supports basetype | no |
| Supports conversion type | no |
| Supports short properties | no |
| Supports properties | yes, see below |
| Supports subnodes | yes, see below |
Long properties
These properties are specified in the node body.
default-byte-order
Sets the global default byte order used by fieldsets. This can be overridden per device and fieldset.
// byte order
default-byte-order: LE
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
register-address-type
Sets the global type used to address the registers for all devices. This can be overridden per device.
// integer type
register-address-type: i32
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
command-address-type
Sets the global type used to address the commands for all devices. This can be overridden per device.
// integer type
command-address-type: i32
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
buffer-address-type
Sets the global type used to address the buffers for all devices. This can be overridden per device.
// integer type
buffer-address-type: i32
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
word-boundaries
Sets the global word splitting rules for all objects. This can be overridden per device.
This option exists to aid in copying names from the datasheet. Those names are often not proper names for types and operations.
So by setting the rules, the compiler can split identifiers into good proper words and then convert them to the required casing.
The splitting is done with convert_case using their string representation for boundaries.
In short, place a colon (:) between every boundary. Then each boundary follows the expressed pattern.
For example aB will split words when a lower case letter is followed by an upper case letter.
Some symbols are also allowed as boundary, like - & _.
If not specified, this uses a reasonable default for splitting.
// string
word-boundaries: "bD:0B:_"
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
register-address-mode
Sets the global address mode for registers. This can be overridden per device.
When specified, the registers are assumed to share an address space:
- With the
mappedoption, that address space is a memory-mapped space where if registerAhas addressXand isYbytes big, then registerB(if it exists) will have the addressX+Y. - With the
indexedoption, that address space has one register per number where if objectAhas addressX, then objectB(if it exists) will have the addressX+1.
If this value is specified, then it permits bulk register reads and writes.
// address mode
register-address-mode: mapped
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
default-access
When set, all subobjects use this value as their access value (unless overridden) and don’t require an access specifier anymore
// access specifier
default-access: RW
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
Possible subnodes
Subnodes of the following types are allowed in the node body.
Device
A device models the target of the driver (typically a chip you can reach over e.g. SPI). A manifest can contain multiple devices. A driver will typically contain at least one device.
A device is spiritually the same as a block, except it can set some configs and serves as the root of the blocks.
Example usage:
// Create a device by giving it ownership of a compatible interface
let mut device = MyDevice::new(DeviceInterface::new());
// Use the operations defined on the device
device.foo().read()?;
// When supported, start bulk operations on the device (or any block)
use device_driver::Block; // Must import trait
let (foo, bar) = device
.bulk_read()
.with(|d| d.foo().plan())
.with(|d| d.bar().plan())
.execute()?;
Example
/// doc comment line
device Example {
default-byte-order: LE,
register-address-type: i32,
command-address-type: i32,
buffer-address-type: i32,
word-boundaries: "bD:0B:_",
register-address-mode: mapped,
default-access: RW,
address-offset: 0,
block node,
register node,
command node,
buffer node,
fieldset node,
enum node,
extern node,
}
Table
| Property | Value |
|---|---|
| Identifier namespace | Type |
| Supports repeat | no |
| Supports basetype | no |
| Supports conversion type | no |
| Supports short properties | no |
| Supports properties | yes, see below |
| Supports subnodes | yes, see below |
Long properties
These properties are specified in the node body.
default-byte-order
Sets the default byte order used by fieldsets in this device. This can be overridden per fieldset.
// byte order
default-byte-order: LE
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
register-address-type
Sets the type used to address the registers in this device.
// integer type
register-address-type: i32
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
command-address-type
Sets the type used to address the commands in this device.
// integer type
command-address-type: i32
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
buffer-address-type
Sets the type used to address the buffers in this device.
// integer type
buffer-address-type: i32
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
word-boundaries
Sets the word splitting rules for all objects defined in the device.
This option exists to aid in copying names from the datasheet. Those names are often not proper names for types and operations.
So by setting the rules, the compiler can split identifiers into good proper words and then convert them to the required casing.
The splitting is done with convert_case using their string representation for boundaries.
In short, place a colon (:) between every boundary. Then each boundary follows the expressed pattern.
For example aB will split words when a lower case letter is followed by an upper case letter.
Some symbols are also allowed as boundary, like - & _.
If not specified, this uses a reasonable default for splitting.
// string
word-boundaries: "bD:0B:_"
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
register-address-mode
Sets the address mode for registers in this device.
When specified, the registers are assumed to share an address space:
- With the
mappedoption, that address space is a memory-mapped space where if registerAhas addressXand isYbytes big, then registerB(if it exists) will have the addressX+Y. - With the
indexedoption, that address space has one register per number where if objectAhas addressX, then objectB(if it exists) will have the addressX+1.
If this value is specified, then it permits bulk register reads and writes.
// address mode
register-address-mode: mapped
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
default-access
When set, all subobjects use this value as their access value (unless overridden) and don’t require an access specifier anymore
// access specifier
default-access: RW
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
address-offset
Defines the global address offset of this device. All objects in the device are relative to this offset. If this is not specified, the address offset defaults to 0.
// number
address-offset: 0
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
Possible subnodes
Subnodes of the following types are allowed in the node body.
Block
A block helps with grouping objects in your driver and is ultimately a collection of other objects. This can be great to e.g. logically pool related registers together or to repeat them en masse.
Blocks have an address offset which is applied to all child objects. Keep the offset at 0 if you want to use the global addresses for the sub objects.
Blocks are accessed as an operation on the parent block or device it’s part of.
All objects are generated globally so child objects still need a globally unique name and are not generated in a module.
Example usage:
// MyDevice is the root block
let mut device = MyDevice::new(DeviceInterface::new());
// Foo is a block
let mut foo = device.foo();
// Access any operation defined on the block
foo.bar().dispatch()?;
// Or in one go
device.foo().bar().dispatch()?;
Example
/// doc comment line
block Example[8 stride 4] {
address-offset: 0,
default-access: RW,
block node,
register node,
command node,
buffer node,
fieldset node,
enum node,
extern node,
}
Table
| Property | Value |
|---|---|
| Identifier namespace | Global |
| Supports repeat | yes |
| Supports basetype | no |
| Supports conversion type | no |
| Supports short properties | no |
| Supports properties | yes, see below |
| Supports subnodes | yes, see below |
Long properties
These properties are specified in the node body.
address-offset
Defines the address offset of this block. All objects in the block are relative to the block. For example, a block with an address offset of 10 which has a register at address 5, will have defined the register at address 15. If this is not desired, then keep the address offset at 0.
// number
address-offset: 0
Info
- required:
yes - multiple allowed:
no - supports doc comments:
no
default-access
When set, all subobjects use this value as their access value (unless overridden) and don’t require an access specifier anymore
// access specifier
default-access: RW
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
Possible subnodes
Subnodes of the following types are allowed in the node body.
Register
A register is a singular piece of addressable memory stored on the device that can be written and/or read.
It defines an operation on the block it’s part of. Register functionality is implemented in the runtime through the RegisterOperation which can be used to read/write/modify the register.
Example usage:
let mut device = MyDevice::new(DeviceInterface::new());
device.foo().write(|reg| reg.set_bar(12345))?;
assert_eq!(device.foo().read()?.bar(), 12345);
Example
/// doc comment line
register Example[8 stride 4] {
address: 0,
access: RW,
address-overlap: allow,
reset: [12, 34],
fields: MyFieldset,
}
Table
| Property | Value |
|---|---|
| Identifier namespace | Operation |
| Supports repeat | yes |
| Supports basetype | no |
| Supports conversion type | no |
| Supports short properties | no |
| Supports properties | yes, see below |
| Supports subnodes | no |
Long properties
These properties are specified in the node body.
address
The address of the register.
// number
address: 0
Info
- required:
yes - multiple allowed:
no - supports doc comments:
no
access
Limits how the register can be accessed. Must be specified unless a default-access is set by a parent object.
// access specifier
access: RW
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
address-overlap
Allows addresses to overlap with other registers. This is not allowed by default to prevent copy-paste mistakes.
// allow
address-overlap: allow
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
reset
Defines the reset value of the register. When performing a write operation, this value loaded in by default.
The value can be expressed in two ways:
- Byte array: No byte order changes are done. The array will be loaded into the fieldset as is.
- Integer: Will be converted to a byte array with the specified byte order.
// [bytes]
reset: [12, 34],
// number
reset: 1234
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
fields
The fieldset that represents the data of the register. This can be a reference to an existing fieldset or a completely new inline fieldset.
// type reference
fields: MyFieldset,
// sub node
fields: fieldset MyFieldSet
Info
- required:
yes - multiple allowed:
no - supports doc comments:
no
Command
A command is a call to do something. This can be to e.g. change the chip state, do an RPC-like call or to start a radio transmission.
It defines an operation on the block it’s part of. Command functionality is implemented in the runtime through the CommandOperation which can be used to dispatch the command.
Tip
While registers could be modelled as a command, this would be spiritually wrong. A device is supposed to do something when a command is dispatched. It can do something on its own or based the input data. And when the action is done there may be an output.
Examples would be starting radio transmission or putting the device to sleep.
Example usage:
let mut device = MyDevice::new(DeviceInterface::new());
// Dispatch the foo command
device.foo().dispatch()?;
// Commands carry data when in and/or out fields are specified
let result = device.bar().dispatch(|data| data.set_val(1234))?;
assert_eq!(result.quux(), true);
Example
/// doc comment line
command Example[8 stride 4] {
address: 0,
address-overlap: allow,
fields-in: MyFieldset,
fields-out: MyFieldset,
}
Table
| Property | Value |
|---|---|
| Identifier namespace | Operation |
| Supports repeat | yes |
| Supports basetype | no |
| Supports conversion type | no |
| Supports short properties | no |
| Supports properties | yes, see below |
| Supports subnodes | no |
Long properties
These properties are specified in the node body.
address
The address of the command
// number
address: 0
Info
- required:
yes - multiple allowed:
no - supports doc comments:
no
address-overlap
Allows addresses to overlap with other commands. This is not allowed by default to prevent copy-paste mistakes.
// allow
address-overlap: allow
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
fields-in
The fieldset that represents the input data of the command. This can be a reference to an existing fieldset or a completely new inline fieldset.
// type reference
fields-in: MyFieldset,
// sub node
fields-in: fieldset MyFieldSet
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
fields-out
The fieldset that represents the output data of the command. This can be a reference to an existing fieldset or a completely new inline fieldset.
// type reference
fields-out: MyFieldset,
// sub node
fields-out: fieldset MyFieldSet
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
Buffer
Buffers are used to represent a stream of bytes on a device. This could for example be a fifo on a radio. It’s quite a simple construct and thus is limited in configuration options.
It defines an operation on the block it’s part of. Buffer functionality is implemented in the runtime through the BufferOperation which can be used to read and write from/to the buffer. This operation type also implements the embedded-io traits when the cargo feature is activated on the runtime.
Example usage:
let mut device = MyDevice::new(DeviceInterface::new());
device.foo().write_all(&[0, 1, 2, 3]).unwrap();
let mut buffer = [0; 8];
let len = device.bar().read(&mut buffer).unwrap();
Example
/// doc comment line
buffer Example {
access: RW,
address: 0,
}
Table
| Property | Value |
|---|---|
| Identifier namespace | Operation |
| Supports repeat | no |
| Supports basetype | no |
| Supports conversion type | no |
| Supports short properties | no |
| Supports properties | yes, see below |
| Supports subnodes | no |
Long properties
These properties are specified in the node body.
access
Limits how the buffer can be accessed. Must be specified unless a default-access is set by a parent object.
// access specifier
access: RW
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
address
The address of the buffer
// number
address: 0
Info
- required:
yes - multiple allowed:
no - supports doc comments:
no
Fieldset
A fieldset is a collection of fields that make up the data of a register, command input or command output.
Each fieldset defines a type where each of the fields are accessible through operations with the names of the fields.
Note
As a user you should not have to construct your fieldsets manually in normal use. But it’s available to you for special cases.
Example usage:
use device_driver::Fieldset;
let mut reg = MyFieldSet::ZERO;
reg.set_foo(1234);
let foo = reg.foo();
Fieldsets also implement all bitwise operators for easier manipulation. These operations are done on all underlying bits, even ones that are not part of a field.
There’s also an Into and From implementation to byte arrays of the same size of the fieldset.
All possible bitpatterns are legal.
Example usage:
let all_ones = !MyFieldSet::from([0x00, 0x00]);
let lowest_byte_set = MyFieldSet::from([0xFF, 0x00]);
let lowest_byte_inverted = all_ones ^ lowest_byte_set;
Example
/// doc comment line
fieldset Example {
size-bytes: 8,
byte-order: LE,
bit-overlap: allow,
default-access: RW,
field node,
}
Table
| Property | Value |
|---|---|
| Identifier namespace | Type |
| Supports repeat | no |
| Supports basetype | no |
| Supports conversion type | no |
| Supports short properties | no |
| Supports properties | yes, see below |
| Supports subnodes | yes, see below |
Long properties
These properties are specified in the node body.
size-bytes
The size of the fieldset in number of bytes.
// number
size-bytes: 8
Info
- required:
yes - multiple allowed:
no - supports doc comments:
no
byte-order
The byte order of the fieldset data.
// byte order
byte-order: LE
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
bit-overlap
Allows fields to overlap. This is not allowed by default to prevent copy-paste mistakes.
// allow
bit-overlap: allow
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
default-access
When set, all subobjects use this value as their access value (unless overridden) and don’t require an access specifier anymore
// access specifier
default-access: RW
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
Possible subnodes
Subnodes of the following types are allowed in the node body.
Enum
Enums work similarly to enums in most languages (like C & Rust) and generates a native enum as the output.
Each property in the node body defines a variant and at least one must be defined. When a variant doesn’t specify a number value, it will be incremented by one from the previous variant or be zero when it’s the first variant.
The bit size of the enum is automatically determined based on the variants of the enum and the base type. Any field that uses the enum as conversion type must have the same base type as the enum.
An enum that covers all bit patterns for its bit size can be used for infallible conversion. This is possible by having a variant for each bit pattern or by having a default or catch-all variant.
An enum with a default value will collapse the value into the default if the value is not expressed by any other variant. A catch-all catches the value and retains it. You should prefer using a default value and only use catch-all when you require reflexivity.
Example
/// doc comment line
enum Example -> uint {
/// doc comment line
Any: _,
}
Table
| Property | Value |
|---|---|
| Identifier namespace | Type |
| Supports repeat | no |
| Supports basetype | yes |
| Supports conversion type | no |
| Supports short properties | no |
| Supports properties | yes, see below |
| Supports subnodes | no |
Long properties
These properties are specified in the node body.
any name
Defines a variant for the enum. The name of the property becomes the variant name.
// auto
*any name*: _,
// number
*any name*: 0,
// default number
*any name*: default 0,
// default auto
*any name*: default _,
// catch-all number
*any name*: catch-all 0,
// catch-all auto
*any name*: catch-all _
Info
- required:
no - multiple allowed:
yes - supports doc comments:
yes
Extern
An extern allows users to provide their own custom types and use them as conversion types.
The extern must be available to the generated code with the name directly. No namespacing is applied.
By default extern types can only used fallibly and will have the bit size of the base type.
Example
/// doc comment line
extern Example -> uint {
infallible: allow,
size-bits: 8,
}
Table
| Property | Value |
|---|---|
| Identifier namespace | Type |
| Supports repeat | no |
| Supports basetype | yes |
| Supports conversion type | no |
| Supports short properties | no |
| Supports properties | yes, see below |
| Supports subnodes | no |
Long properties
These properties are specified in the node body.
infallible
Allows this type to be infallably converted to.
// allow
infallible: allow
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
size-bits
The size of the type in bits.
// number
size-bits: 8
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
Field
A field is a slice of bits within a fieldset.
Each field must specify their bit address and can be limited in access (RW, RO or WO). All fields must also specify a base type, which is a type that can be converted to and from a bit slice.
This raw type is not always desired, and so those types can be converted to enums and externs.
The try keyword here will mark the conversion as ‘fallible’ and is often required when the conversion can fail.
Interaction with the fields from code is done through setters and getters.
Example usage:
let mut reg = MyFieldSet::ZERO;
reg.set_foo(1234);
let foo = reg.foo();
reg.set_bar(MyEnum::A);
let bar = reg.bar()?;
Example
/// doc comment line
field Example[8 stride 4] 8:0 RW -> uint as try Foo
Table
| Property | Value |
|---|---|
| Identifier namespace | Local |
| Supports repeat | yes |
| Supports basetype | yes |
| Supports conversion type | yes |
| Supports short properties | yes, see below |
| Supports properties | no |
| Supports subnodes | no |
Short properties
These properties are specified inline in the node definition and are used without name.
address
The bit address of the field within the fieldset
// range
address: 8:0,
// number
address: 0
Info
- required:
yes - multiple allowed:
no - supports doc comments:
no
access
Limits how the field can be accessed. Must be specified unless a default-access is set by a parent object.
// access specifier
access: RW
Info
- required:
no - multiple allowed:
no - supports doc comments:
no
Compilation
Drivers written with device-driver need to be compiled.
There are multiple ways to compile a ddsl manifest:
- CLI, using the
ddccommand - Rust proc-macro
- Playground on the website
All of these share the same build options. Those are laid out below. Some ways of compiling have some options preselected. Check the tool specific chapters for that.
Build options
Build options are split in three:
- General
- Target specific
- Tool specific
The options are parsed using clap. For detailed help, use --help as an argument and clap will show the help text that’s shown below.
Any argument that starts with unstable is exempt from semver and thus should not be relied upon unless the version of device-driver is pinned. Their behavior and names may change at any point or the option might be removed too.
General
The general build options are available everywhere and change how the compiler acts.
The Rust compile macro always uses the Rust target, and so there these options don’t have a rust subcommand and include the Rust target options.
Usage: [OPTIONS] <COMMAND>
Commands:
rust Generate Rust code
help Print this message or the help of the given subcommand(s)
Options:
--unstable-ui-test-mode
Improves reproducibility across versions
--timings=<TIMINGS>
When enabled, a diagnostic is printed with information about the compiler performance. Exact format of the diagnostic is unstable
[default: off]
[possible values: off, show, verbose]
--unstable-mir-randomize-seed=<SEED>
The seed to use for randomization. If not specified, a random seed is used
--unstable-mir-randomize-passes
Randomize the order of the mir passes
--unstable-mir-check-assumptions
Run assumption checks for the passes
-h, --help
Print help
Target - Rust
All Rust target specific features start with --rust.
Usage: [OPTIONS]
Options:
--rust-defmt-feature=<FEATURE>
When specified, defmt implementations will be generated using this cfg feature flag
-h, --help
Print help
Tool - CLI
The cli can be installed using:
cargo install device-driver-cli
This gives access to the build command. If you want be able to convert formats to ddsl, then add the appropriate converter feature, like --features converter-dd-v1.
This is done to improve compile times.
It’s possible to use --all-features, but right now that only compiles using nightly.
In the future more methods of distribution may become available. (If you have experience with this, help is very much wanted!)
Once installed, you can use the compiler using the ddc command (device-driver compiler):
ddc --help
Commands
The command line compiler for the device-driver toolkit
Usage: ddc <COMMAND>
Commands:
build Compile DDSL to the target output
gen-docs Generate docs about the compiler
convert The format to convert into DDSL
help Print this message or the help of the given subcommand(s)
Options:
-h, --help
Print help
-V, --version
Print version
Build
The build command is the primary command for users and needs additional information about input and output. The rest of the options are those from the general section.
Usage: [OPTIONS] <COMMAND>
Commands:
rust Generate Rust code
help Print this message or the help of the given subcommand(s)
Options:
-s, --source <FILE>
Path to the input file
-o, --output <FILE>
Path to output location. Any existing file is overwritten. If not provided, the output is written to stdout
--unstable-ui-test-mode
Improves reproducibility across versions
--timings=<TIMINGS>
When enabled, a diagnostic is printed with information about the compiler performance. Exact format of the diagnostic is unstable
[default: off]
[possible values: off, show, verbose]
--unstable-mir-randomize-seed=<SEED>
The seed to use for randomization. If not specified, a random seed is used
--unstable-mir-randomize-passes
Randomize the order of the mir passes
--unstable-mir-check-assumptions
Run assumption checks for the passes
-h, --help
Print help
Gen-docs
Generates documentation that is used by this book about the compiler from the source.
This command is only available when the CLI is compiled with the gen-docs feature flag.
It’s not quite intended for normal use. The output is unstable.
Usage: --output <DIR>
Options:
-o, --output <DIR>
Path to output folder location
-h, --help
Print help
Converter
Convert from one of the supported formats to DDSL. Don’t rely on this in your main process since the output is unstable.
The format to convert into DDSL
Usage: [OPTIONS] <COMMAND>
Commands:
device-driver-v1 The v1 formats of device-driver (DSL, YAML, JSON & TOML)
help Print this message or the help of the given subcommand(s)
Options:
-s, --source <FILE>
Path to the input file
-o, --output <FILE>
Path to output location. Any existing file is overwritten. If not provided, the output is written to stdout
-h, --help
Print help
Tool - Rust proc-macro
To ease the compilation flow for Rust projects, a proc-macro is available.
When the macros feature is activated on the rust runtime device-driver crate, the macro will be exported.
device_driver::compile!(
options: "--rust-defmt-feature=defmt",
manifest: "path/to/manifest.ddsl",
);
With the options field you can pass the options described above, with the exception that the rust command is already given. (It would not make sense to compile to something other than Rust in a proc-macro.) Use --help there to see all the specific details.
The generated code is then emitted by the proc-macro and so the driver will be in the place where the macro is called.
For better UX, it’s recommended to add the manifest file to the build.rs:
fn main() {
println!("cargo:rebuild-if-changed=path/to/manifest.ddsl");
}
Important
Enabling the macro causes the crate to pull in the compiler as a dependency which increases compile times. It’s not huge, but it’s definitely present.
Tool - Playground
For the playground, go to https://device-driver.com/playground.
The playground supports all targets and there’s a text field where the options can be specified.
Using --help there works and will cause the help text to be printed to the bottom diagnistics panel.
Runtime
Device-driver intentionally doesn’t generate all code for a driver. The code depends on a small runtime.
The runtime is responsible for defining all the operations (e.g. register reads/writes), the interfaces and the bit manipulation routines.
Each compilation target has its own runtime (or doesn’t have any runtime in some cases). See the other chapters for more information about specific targets.
Rust runtime
The runtime for rust is the crate called device-driver and is available on crates.io.
Any rust driver should include the crate as a dependency in the Cargo.toml.
The documentation can be found on docs.rs.
Operations
The generated Rust code will make use of the various operation types. These are the main engine of getting your driver to do something.
An operation takes data from the driver and turns them into action. For example, the register operation is used when interacting with registers:
let mut device = MyDevice::new(interface);
// Get a register operation from the device
// This borrows the interface from the device
let mut operation = device.foo_register();
// Now use the operation, for example to read
let foo_fieldset = operation.read()?;
// Or to write
operation.write(|reg| reg.set_bar(12))?;
// Typically though, the operation is used transparently as it's cleaner
device.foo_register().read()?;
There are a bunch of different functions you can call on the operations. Check them out in the documentation.
Interfaces
Driver definition and interface definition decoupled.
Currently you define the driver in the DDSL code and the interface in Rust. Hopefully in the future the interfaces can be defined in DDSL too.
An interface determines how we talk with the device.
The interface always needs to be passed in when a driver instance is created. Depending on what traits are implemented on the interface object, different operation functions are able to be called.
There are different traits for each of the operation types.
For example, if the interface implements the register interface trait, then register operations can read and write. But only the blocking functions will work. For async functions there’s the async register interface trait.
So if you find something is not able to be called, check the trait implementations of your interface.
Bulk operations
Bulk operations are available for registers when the register-address-mode is set.
When available, bulk operations must be planned. Start a bulk operation by calling one of the multi-functions on the device (or any block):
let (foo, bar) = device
// Start a bulk read
.bulk_read()
// Plan to read the foo register
.with(|d| d.foo().plan())
// Plan to read the bar register after that
.with(|d| d.bar().plan())
// Perform the bulk read
.execute()?;
The runtime checks if the plan is allowed. Registers must follow each other up according to the address mode rules.
Bulk operations are possible for repeated registers too:
device.foo().write_array_at(0, |[a, b, c]| {
a.set_bar(1);
b.set_bar(2);
c.set_bar(3);
})?;
Rust API
The generated driver API follows the device/block layout of the driver specified in the DDSL source. The namespacing rules are also important to keep in mind.
Any object with the type namespace will generate a struct definition. Any object with the operation namespace will generate a method on a device or block.
device Foo {
register Bar { ... }
}
This source will generate the following shape of Rust code:
pub struct Foo { ... }
impl Foo {
pub fn bar() -> RegisterOperation { ... }
}
Even though it’s possible to define types like enums inside a device, in the generated code they’re always global:
device Foo {
enum Bar { ... }
}
pub struct Foo { ... }
pub enum Bar { ... }
Tip
To easily inspect the generated code, visit the playground!
Caution
The callable API is bigger than the public documented API. Use it at your own risk! Private/unstable APIs contain
#[doc(hidden)]attributes and are meant for internal use only.If you’re unsure, feel free to create an issue or to ask about it in the chat room.
Operations
All operations generate functions that return the various Operation types. Consult the rust docs for the exact available API:
Devices/blocks
Devices and blocks are very similar to each other in that they can both contain operations.
A device, however, is the root block. As such it always has an address-offset of 0 and can be constructed with an owned interface value using the generated new function.
To tear down the device and get back the interface, you can call the free function.
Both types implement the Block trait, which exposes the interface for cases where you need raw access to it and which allows you to start bulk operations.
Important
A bulk operation only has access to the device/block it was started on. If the bulk op needs access to the full device, that means you should probably start it on the device.
Fieldsets
Fieldsets are generated as structs that have the same byte size as specified in the DDSL source.
Each field in a fieldset gets a getter function if the field can be read and a setter function if the field can be written. The getter uses the name of the field and the setter uses the name too, except it prepends it with set_.
The Fieldset trait is implemented on all fieldsets which exposes some runtime metadata and a constant ZERO init value.
Fieldsets also implement Into & From for [u8; N], so they can be converted into byte arrays or constructed from byte arrays, as well as the Default trait which initializes a fieldset with all bits set to zero.
Fieldsets can be formatted using the Debug implementation or with the defmt::Format implementation if the appropriate rust compiler option flag is active.
Lastly, the And, Or, Xor and Not operator traits are implemented on the fieldsets which do bitwise operations on all of the bits of the fieldsets (including unused bits).
Enums
Enum objects generate into normal Rust enums. They take on the repr of the used base type and implement Into & (Try)From to the base type. TryFrom is always implemented and From is only implemented when all bit patterns of the base type are covered or if the enum contains a default or catch-all.
If the enum has a default variant, then it will implement the Default trait that defaults to the marked variant.
Enums can be formatted using the Debug implementation or with the defmt::Format implementation if the appropriate rust compiler option flag is active.
Externs
Extern types are not generated, but they are required to implement Into & (Try)From to their base type since those are used by the generated code.
If the extern allows infallible conversion, it’s expected the From trait is implemented.
Intro
Important
We deserve better drivers. Rust has shown that we don’t need to stick to old principles and that we as an industry can do better.
Device-driver is a Rust toolkit that generates safe, documented interfaces for hardware devices, handling bit-packed registers and device commands through an expressive macro DSL or config file.
While the Rust language provides many opportunities to improve the way we write drivers, it doesn’t mean those are easy to use. There are two issues:
- Creating good datastructures to represent the driver is hard
- Writing the definitions by hand takes a lot of thankless work
By using this toolkit, you get both 1 and 2 solved.
Number one is solved by getting the datastructures as part of this toolkit which has seen over 5 years of iteration and improvements. The second issue is solved by using code generation so you only need to manually take care of the things that make your driver unique.
Together, this delivers a really tight and precise way of authoring your device driver:
device_driver::create_device!(
device_name: MyDevice,
dsl: {
config {
type RegisterAddressType = u8;
}
/// This is the Foo register
register Foo {
const ADDRESS = 0;
const SIZE_BITS = 8;
/// This is a bool at bit 0!
value0: bool = 0,
/// Integrated enum generation
value1: int as enum GeneratedEnum {
A,
/// Variant B
B,
C = default,
} = 1..4,
/// This is a 4-bit integer
value2: uint = 4..8,
},
}
);
let mut device = MyDevice::new(device_interface);
device.foo().write(|reg| reg.set_value_1(GeneratedEnum::B)).unwrap();
Instantly we get a nice and familiar API that is well documented. There’s a bunch more features to discover like using YAML as the input and a bunch of analysis steps, so read on!
The goal
When you’re writing a driver, you just want to implement it and be done with it. Most of the time developing a driver is boring and repetitive.
To help you do less of the boring work and to create a higher quality driver at the same time, the goals are:
- Get a great driver for minimal effort
- Get a driver that is correct and hard to misuse
- (assuming the input spec is correct)
- Get a driver that is well documented
- (assuming the input spec gives docs)
These goals are met by:
- Using a dense and precise input language
- Having many options to deal with byte and bit ordering
- Having analysis steps to decrease the chance of common mistakes
- Separating the interface to the device from the definitions
- Allowing you to put docs on pretty much anything
How to continue
Simply read the rest of the book!
Looking at existing drivers and examples can also be very helpful.
Using the macro
The macro is the main way of generating a driver. It is defined in the device-driver-macros crate which is re-exported in the device-driver crate by default. You don’t have import the macros crate yourself.
The macro can be used in two forms.
Inline DSL
The first form is for writing the register definitions using the DSL right in the source of your project.
device_driver::create_device!(
device_name: MyTestDevice,
dsl: {
// DSL code goes here
}
)
It consists of two parts:
device_name: This will be the name of the root block that will take ownership of the device interface.- The name must be provided in PascalCase
- If you’re going to distribute this as the main part of your driver, then it’s recommended to use the name of the chip this driver is for. For example: ‘Lis3dh’
- If you’re going to write a higher level wrapper around it, then it’s recommended to name it something appropriate for a low level layer. For example: ‘Registers’ or ‘LowLevel’
dsl: This selects the option to write DSL code in the macro
Using the DSL in this way allows for nice error messages and keeps the definitions close to your code.
Manifest file
The second form uses an external manifest file.
device_driver::create_device!(
device_name: MyTestDevice,
manifest: "driver-manifest.yaml"
)
You can provide an absolute path or a relative path to the file. If it’s relative, then the base path is the value of the CARGO_MANIFEST_DIR environment variable. This is the same directory as your Cargo.toml is in.
The extension of the file determines which parser is used.
The options are:
- yaml
- json
- toml
- dsl
Output
Tip
The generated code is placed exactly where the macro is invoked. This means you can decide to contain everything in its own module. This is recommended to do, but not required.
Caution
Code in the same module as the generated code is able to access the private API of the generated code. It is discouraged to make use of the private API since it’s not considered as part of the SemVer guarantees and it’s designed in a way where you shouldn’t need to.
Note
If you feel part of the private API should be stabilized, then please open an issue to discuss it. If you really need to access the private API, consider pinning the exact device-driver versions and make sure to pin the sub crates too, including the generation and the macros crate.
Optimizing compile times
The device-driver crate has features for turning on the json, yaml and toml parsers. These are enabled by default for your convenience.
Once you’ve settled on a format, you can optimize the compile times for you and your dependents by disabling the default features and adding back the features you need.
Suggestions:
- When using the DSL (inline or as manifest)
default-features = falsefeatures = ["dsl"]
- When using yaml
default-features = falsefeatures = ["yaml"]
- When using json
default-features = falsefeatures = ["json"]
- When using toml
default-features = falsefeatures = ["toml"]
Tip
With these steps the compile times should be acceptable. However, they can be further optimized by getting rid of the macro altogether. This is explained in the cli chapter.
Using the cli
The cli is there to optimize compile times for your driver users. Instead of having to compile the device-driver macros and run them, you can generate the code ahead of time and then include! or make a module out of it.
Tip
During development using the proc macro will be lots easier since the code generation won’t go out of sync with the driver definitions. Then once the development is done, you may want to use the CLI as an optimization.
Installation
The cli can be installed using cargo:
cargo install device-driver-cli
This always supports all input formats.
Usage
The CLI is written with clap and has a minimal and simple interface.
To see all options, use:
device-driver-cli --help
To do the code generation three things are required:
-mor--manifest: The path to the manifest file-oor--output: The path to the to be generated rust file-dor--device-name: The name the toolkit will use for the generated device. This must be specified in PascalCase
Using the output
Exactly how you include the generated rust file is up to you. You could generate it into your /src folder and declare it a module, which would be nice for Rust analyzer but forces the generated code to be its own module. Or to include it in an existing module you can use the include! macro.
However you choose to include it, don’t forget to track the file in your git repo.
The generated code still depends on the device-driver crate, but since we don’t depend on the proc macro anymore we can turn off the default features. So in your Cargo.toml you can now import the toolkit as:
device-driver = { version = <VERSION>, default-features = false }
This makes it so all unused dependencies are gone.
Writing an interface
Important
The device-driver crate and the generated code don’t know anything about how to talk to your device. This means we need to teach it about the interface it has!
Let’s first create our device:
device_driver::create_device!(
device_name: MyDevice,
dsl: {
// ...
}
);
This generates a top-level block MyDevice which has a new function that takes ownership of an interface.
We have to create our own interface type that we can pass into it. This type will implement the logic to communicate with the device.
In this example, let’s assume a register ‘foo’ was defined and see what happens:
/// Our interface struct that owns the bus.
pub struct MyDeviceInterface<BUS> {
pub bus: BUS,
}
fn try_out() {
// Initialize the bus somehow. Your HAL should help you there
let bus = init_bus();
// Create our custom interface struct
let interface = MyDeviceInterface { bus };
// Create the device driver based on the interface
let mut my_device = MyDevice::new(interface);
// Try to read the foo register. This results in an error
let _ = my_device.foo().read();
// ERROR: ^^^^ method cannot be called due to unsatisfied trait bounds
//
// note: the following trait bounds were not satisfied:
// `DeviceInterface: RegisterInterface`
}
This example doesn’t compile and outputs an error. Luckily the compiler tells us what’s wrong. The problem is that we provided a device interface that doesn’t provide a way to read or write registers, but we ask the driver to read a register.
The error tells us the device interface should implement the RegisterInterface trait.
Important
Every kind of operation has its own trait. Find the up-to-date docs of them on docs.rs.
There’s an interface for register, command and buffer.
Of each of the traits there is an async version too. When implemented the async versions of the operations can be used. They’ve got the same name as the normal operations, except they end with _async. The register .read() then becomes .read_async().
Let’s make our example complete by implementing the RegisterInterface:
pub struct MyDeviceI2cInterface<BUS> {
pub bus: BUS,
}
// See the docs of the traits to get more up-to-date information about how and what to impl
impl<BUS: embedded_hal::i2c::I2C> device_driver::RegisterInterface for MyDeviceI2cInterface<BUS> {
// ...
}
// For the async I2C we can implement the async register interface
impl<BUS: embedded_hal_async::i2c::I2C> device_driver::AsyncRegisterInterface for MyDeviceI2cInterface<BUS> {
// ...
}
fn try_out_sync() {
let bus = init_sync_bus(); // Implements the I2c trait
let interface = MyDeviceI2cInterface { bus };
let mut my_device = MyDevice::new(interface);
let _ = my_device.foo().read();
}
async fn try_out_async() {
let bus = init_async_bus(); // Implements the async I2c trait
let interface = MyDeviceI2cInterface { bus };
let mut my_device = MyDevice::new(interface);
let _ = my_device.foo().read_async().await;
}
We’ve now covered how to create an interface type and implement the interface trait you need on it.
Some chips can have multiple interfaces, like both SPI and I2C or SPI and QSPI. You can choose to support them in one type or make separate types for them.
Tip
You can make your interface type(s) as complex or as simple as you need. It depends on your chip and your requirements what it should look like. It is good practice, though, to inform the driver users of this with docs and examples.
Defining the device
This toolkit brings three different kinds of concepts you can use to build various aspects of your driver.
- The register is some memory located at an address on the device. It contains fields, may have a reset value and could be restrictive in its read and write access.
- The command can model multiple things. It can be an event to send to the device so it changes state or it could be an RPC-like call. It can send data and receive back an answer.
- The buffer is anything that you’d like to have a
WriteorReadinterface to. A good example is a fifo buffer in a radio chip.
The registers, commands and buffers can be grouped into blocks.
Except for buffers all of them can be repeated and ref’ed. Repeats take the same object and repeat them for a repeat count with an address stride. A ‘ref’ object copies another object and allows to override some values like the address and access.
The registers, commands, buffers, blocks and refs are all called ‘objects’ in this project.
To configure the driver, there’s the global config. In it you can define the address types, various defaults for e.g. byte ordering and the method used for name normalization.
These concepts and how you can use them in your driver are described in more detail in their own chapter.
Global config
The global config exists to house three kinds of configs:
- Required
- Defaults
- Transformations
Note
A driver can only have one global config.
Below is a short overview for the DSL format and the manifest format of the global config and their defaults (or _ for no default). The last chapters describe the options in more detail.
Tip
Use the available default values to your advantage to cut back having to specify things on each individual register, command or buffer.
DSL
config {
type DefaultRegisterAccess = RW;
type DefaultFieldAccess = RW;
type DefaultBufferAccess = RW;
type DefaultByteOrder = _;
type DefaultBitOrder = LSB0;
type RegisterAddressType = _;
type CommandAddressType = _;
type BufferAddressType = _;
type NameWordBoundaries = [
Underscore, Hyphen, Space, LowerUpper,
UpperDigit, DigitUpper, DigitLower,
LowerDigit, Acronym,
];
type DefmtFeature = "my-feature";
}
Manifest
Note
Example is written in json, but works for yaml and toml too when literally translated.
"config": {
"default_register_access": "RW",
"default_field_access": "RW",
"default_buffer_access": "RW",
"default_byte_order": "_",
"default_bit_order": "LSB0",
"register_address_type": "_",
"command_address_type": "_",
"buffer_address_type": "_",
"name_word_boundaries": [
"Underscore", "Hyphen", "Space", "LowerUpper",
"UpperDigit", "DigitUpper", "DigitLower",
"LowerDigit", "Acronym"
],
"defmt_feature": "my-feature"
}
Required
register_address_type
Specifies the integer type used to represent the address of a register. It is required once a register has been defined.
The value is a string in manifest form or an integer type in DLS form.
Options are: u8, u16, u32, u64, i8, i16, i32, i64
command_address_type
Specifies the integer type used to represent the address of a command. It is required once a command has been defined.
The value is a string in manifest form or an integer type in DLS form.
Options are: u8, u16, u32, u64, i8, i16, i32, i64
buffer_address_type
Specifies the integer type used to represent the address of a buffer. It is required once a buffer has been defined.
The value is a string in manifest form or an integer type in DLS form.
Options are: u8, u16, u32, u64, i8, i16, i32, i64
Defaults
default_register_access
Provides a default to the access type of registers. Any register can override this.
The value is a string in manifest form or written ‘as is’ in the DSL.
Options are: RW (default), ReadWrite, RO, ReadOnly, WO, WriteOnly
default_field_access
Provides a default to the access type of fields. Any field can override this.
The value is a string in manifest form or written ‘as is’ in the DSL.
Options are: RW (default), ReadWrite, RO, ReadOnly, WO, WriteOnly
default_buffer_access
Provides a default to the access type of buffers. Any buffer can override this.
The value is a string in manifest form or written ‘as is’ in the DSL.
Options are: RW (default), ReadWrite, RO, ReadOnly, WO, WriteOnly
default_byte_order
Sets the global byte order. This is used for the register and command fieldsets. Any command or register can override it.
The value is a string in manifest form or written ‘as is’ in the DSL.
Options are: LE, BE
default_bit_order
Sets the global bit order. This is used for the register and command fieldsets. Any command or register can override it.
The value is a string in manifest form or written ‘as is’ in the DSL.
Options are: LSB0 (default), MSB0
Transformations
name_word_boundaries
All object, field, enum and enum variant names are converted to the correct casing for where it’s used in the generated code. This is because some of them have dual use like the object names which are used as struct names (PascalCase) and function names (snake_case).
This also aids when copying names from datasheets since they’re often weird, inconsistent, wrong or all three in regards to casing.
Important
To do proper casing, it must be known when a new word starts. The transition from one word to the next is called a boundary.
The conversions are done using the convert_case crate. With this config option you can specify the boundaries the crate uses to do the conversions.
Options are: [Boundary] or string
The available boundaries can be found in the docs of the crate. The boundary names should be specified as strings in the manifest and ‘as is’ in the DSL.
The string is converted to an array of boundaries using this function which is a really easy way to define it.
The default value is also provided by the crate from this function.
defmt_feature
When defined the generated code will have defmt implementations on the types gated behind the feature configured with this option.
The feature gate looks like: #[cfg(feature = "<VALUE>")].
This allows you, the driver author, to optionally include defmt support.
The value is a string in manifest form and also written as a string in the DSL.
Registers
A register is a piece of addressable memory stored on the device.
It is accessed as a function on the block it’s part of. The function returns a RegisterOperation which can be used to read/write/modify the register.
Example usage:
let mut device = MyDevice::new(DeviceInterface::new());
device.foo().write(|reg| reg.set_bar(12345)).unwrap();
assert_eq!(device.foo().read().unwrap().bar(), 12345);
Below are minimal and full examples of how registers can be defined. Only one field is shown, but more can be added. Details about the fields can be read in their own chapter.
DSL
Minimal:
register Foo {
const ADDRESS = 3;
const SIZE_BITS = 16;
value: uint = 0..16,
}
Full:
/// Register docs
#[cfg(feature = "bar")]
register Foo {
type Access = WO;
type ByteOrder = LE;
type BitOrder = LSB0;
const ADDRESS = 3;
const SIZE_BITS = 16;
const RESET_VALUE = 0x1234; // Or [0x34, 0x12]
const REPEAT = {
count: 4,
stride: 2
};
const ALLOW_BIT_OVERLAP = false;
const ALLOW_ADDRESS_OVERLAP = false;
value: uint = 0..16,
}
Tip
typeorconst, which one is it?
It’stypeif it’s overriding a global config andconstif it’s not.
Manifest
Note
The biggest differences with the DSL are the additional
typefield to specify which type of object this is and thefieldsfield that houses all fields.
Minimal (json):
"Foo": {
"type": "register",
"address": 3,
"size_bits": 16,
"fields": {
"value": {
"base": "uint",
"start": 0,
"end": 16
}
}
}
Full (json):
"Foo": {
"type": "register",
"cfg": "feature = \"foo\"",
"description": "Register docs",
"access": "WO",
"byte_order": "LE",
"bit_order": "LSB0",
"address": 3,
"size_bits": 16,
"reset_value": 4066, // Or [52, 18] (no hex in json...)
"repeat": {
"count": 4,
"stride": 2
},
"allow_bit_overlap": false,
"allow_address_overlap": false,
"fields": {
"value": {
"base": "uint",
"start": 0,
"end": 16
}
}
}
Required
address
The address of the register.
Integer value that must fit in the given address type in the global config and can be negative.
size_bits
The size of the register in bits.
Positive integer value. No fields can exceed the size of the register.
type (manifest only)
The type of the object.
For registers this field is a string with the contents "register".
Optional
cfg or #[cfg(...)]
Allows for cfg-gating the register.
In the DSL, the normal Rust syntax is used. Just put the attribute on the register definition. Only one attribute is allowed.
In the manifest it is configured with a string.
The string only defines the inner part: #[cfg(foo)] = "cfg": "foo",.
Warning
Check the chapter on cfg for more information. The cfg’s are not checked by the toolkit and only passed to the generated code and so there are some oddities to be aware of.
description or #[doc = ""]
The doc comments for the generated code.
For the DSL, use the normal doc attributes or triple slash ///.
Multiple attributes get concatenated with a newline (just like normal Rust does).
For the manifest, this is a string.
The description is added as normal doc comments to the generated code. So it supports markdown and all other features you’re used to. The description is used on the generated register struct and on the function to access the register.
access
Overrides the default register access.
Options are: RW, ReadWrite, WO, WriteOnly, RO, ReadOnly.
They are written ‘as is’ in the DSL and as a string in the manifest.
Anything that is not ReadWrite will limit the functions you can call for the registers. .write is only available when the register has write access, .read only when the register has read access and .modify only when the register has full access.
Note
This only affects the capability of a register being read or written. It does not affect the
accessspecified on the fields.This means you can have a register you cannot write, but does have setters for one or more fields.
That won’t be harmful or break things, but might look weird.
byte_order
Overrides the default byte order.
Options are: LE, BE.
They are written ‘as is’ in the DSL and as a string in the manifest.
When the size of a register is > 8 bits (more than one byte), then either the byte order has to be defined globally as a default or the register needs to define it.
bit_order
Overrides the default bit order. If the global config does not define it, it’s LSB0.
Options are: LSB0, MSB0.
They are written ‘as is’ in the DSL and as a string in the manifest.
reset_value
Defines the reset or default value of the register.
Can be a number or an array of bytes.
Warning
When specified as an array, this must be formatted as the bytes that are returned by the
RegisterInterfaceimplementation. This means that when the register has little endian byte order, the reset value number0x1234would be encoded as[0x34, 0x12]in the array form.
The same concern is there for the bit order.
It is used in the .write function. To reset a register to the default value, it’d look like .write(|_|()). When a zero value is desired instead of the default, you can use the .write_with_zero function instead.
repeat
Repeat the register a number of times at different addresses.
It is specified with two fields:
- Count: unsigned integer, the amount of times the register is repeated
- Stride: signed integer, the amount the address changes per repeat
The calculation is address = base_address + index * stride.
When the repeat field is present, the function to do a register operation will have an extra parameter for the index.
allow_bit_overlap
Allow field addresses to overlap.
This bool value is false by default.
allow_address_overlap
Allow this register to have an address that is equal to another register address. This calculation is also done for any repeat addresses.
Only exact address matches are checked.
This bool value is false by default.
fields (manifest only)
The fields of the register.
A map where the keys are the names of the fields. All values must be fields.
Commands
A command is a call to do something. This can be to e.g. change the chip state, do an RPC-like call or to start a radio transmission.
It is accessed as a function on the block it’s part of. The function returns a CommandOperation which can be used to dispatch the command.
Example usage:
let mut device = MyDevice::new(DeviceInterface::new());
device.foo().dispatch().unwrap();
// Commands can carry data too
let result = device.bar().dispatch(|data| data.set_val(1234)).unwrap();
assert_eq!(result.xeno(), true);
Below are minimal and full examples of how commands can be defined. Only one field is shown, but more can be added. Details about the fields can be read in their own chapter.
Note
A command can have only input, only output, both input and output, or no fields.
- When input fields are defined, the dispatch function will have a closure parameter to set up the input value.
- When output fields are defined, the dispatch function returns the data that was read back from the device.
DSL
Minimal without fields (with address 5):
command Foo = 5,
Minimal with in and out fields:
command Foo {
const ADDRESS = 5;
const SIZE_BITS_IN = 8;
const SIZE_BITS_OUT = 16;
in {
value: uint = 0..8,
},
out {
value: uint = 0..16,
}
},
Full:
/// Foo docs
#[cfg(feature = "blah")]
command Foo {
type ByteOrder = LE;
type BitOrder = LSB0;
const ADDRESS = 5;
const SIZE_BITS_IN = 8;
const SIZE_BITS_OUT = 16;
const REPEAT = {
count: 4,
stride: 2
};
const ALLOW_BIT_OVERLAP = false;
const ALLOW_ADDRESS_OVERLAP = false;
in {
value: uint = 0..8,
},
out {
value: uint = 0..16,
}
},
Tip
typeorconst, which one is it?
It’stypeif it’s overriding a global config andconstif it’s not.
Manifest
Note
The biggest difference with the DSL is the additional
typefield to specify which type of object this is and the absence of the super short hand.
Minimal with no fields (json):
"Foo": {
"type": "command",
"address": 5
}
Minimal (json):
"Foo": {
"type": "command",
"address": 5,
"size_bits_in": 8,
"fields_in": {
"value": {
"base": "uint",
"start": 0,
"end": 8
}
},
"size_bits_out": 16,
"fields_out": {
"value": {
"base": "uint",
"start": 0,
"end": 16
}
},
}
Full (json):
"Foo": {
"type": "command",
"cfg": "feature = \"blah\"",
"description": "Foo docs",
"byte_order": "LE",
"bit_order": "LSB0",
"address": 5,
"repeat": {
"count": 4,
"stride": 2
},
"allow_bit_overlap": false,
"allow_address_overlap": false,
"size_bits_in": 8,
"fields_in": {
"value": {
"base": "uint",
"start": 0,
"end": 8
}
},
"size_bits_out": 16,
"fields_out": {
"value": {
"base": "uint",
"start": 0,
"end": 16
}
},
}
Required
address
The address of the command.
Integer value that must fit in the given address type in the global config and can be negative.
size_bits_in & size_bits_out
The size of the command in bits for their respective field sets.
Positive integer value. No fields can exceed the specified size.
Only required when their respective field sets are defined.
type (manifest only)
The type of the object.
For commands this field is a string with the contents "command".
Optional
cfg or #[cfg(...)]
Allows for cfg-gating the command.
In the DSL, the normal Rust syntax is used. Just put the attribute on the command definition. Only one attribute is allowed.
In the manifest it is configured with a string.
The string only defines the inner part: #[cfg(foo)] = "cfg": "foo",.
Warning
Check the chapter on cfg for more information. The cfg’s are not checked by the toolkit and only passed to the generated code and so there are some oddities to be aware of.
description or #[doc = ""]
The doc comments for the generated code.
For the DSL, use the normal doc attributes or triple slash ///.
Multiple attributes get concatenated with a newline (just like normal Rust does).
For the manifest, this is a string.
The description is added as normal doc comments to the generated code. So it supports markdown and all other features you’re used to. The description is used on the generated command input and output structs and on the function to access the command.
byte_order
Overrides the default byte order.
Options are: LE, BE.
They are written ‘as is’ in the DSL and as a string in the manifest.
When the size of a command input or output is > 8 bits (more than one byte), then either the byte order has to be defined globally as a default or the command needs to define it.
The value is applied to both the input and output fieldsets.
bit_order
Overrides the default bit order. If the global config does not define it, it’s LSB0.
Options are: LSB0, MSB0.
They are written ‘as is’ in the DSL and as a string in the manifest.
The value is applied to both the input and output fieldsets.
repeat
Repeat the command a number of times at different addresses.
It is specified with two fields:
- Count: unsigned integer, the amount of times the command is repeated
- Stride: signed integer, the amount the address changes per repeat
The calculation is address = base_address + index * stride.
When the repeat field is present, the function to do a command operation will have an extra parameter for the index.
allow_bit_overlap
Allow field addresses to overlap.
This bool value is false by default.
allow_address_overlap
Allow this command to have an address that is equal to another command address. This calculation is also done for any repeat addresses.
Only exact address matches are checked.
This bool value is false by default.
in (dsl) or fields_in (manifest)
The input fields of the command.
- For the dsl, a list of fields.
- For manifest, a map where the keys are the names of the fields All values must be fields.
out (dsl) or fields_out (manifest)
The output fields of the command.
- For the dsl, a list of fields.
- For manifest, a map where the keys are the names of the fields All values must be fields.
Field sets
A field set is a collection of fields that make up the data of a register, command input or command output.
Each field set generates to a struct where each of the fields are accessible through functions with the names of the fields.
A field set can be created using the new function and will be initialized with the reset value (or zero if there is no reset value). When it’s desired to get an all-zero version of the field set, you can call new_zero.
When a ref object overrides the reset value, the field set will have an extra constructor new_as_<ref name> that will use the reset value override for the initial value.
Note
As a user you should not have to construct your field sets manually in normal use. But it’s available to you for special cases in the generated
field_setsmodule.
Example usage:
use field_sets::MyFieldSet;
let mut reg = MyFieldSet::new();
reg.set_foo(1234);
let foo = reg.foo();
Field sets also implement all bitwise operators for easier manipulation. These operations are done on all underlying bits, even ones that are not part of a field.
There’s also an Into and From implementation to the smallest byte array that can fit the entire field set.
Example usage:
use field_sets::MyFieldSet;
let all_ones = !MyFieldSet::new_zero();
let lowest_byte_set = MyFieldSet::from([0xFF, 0x00]);
let lowest_byte_inverted = all_ones ^ lowest_byte_set;
Below are minimal and full examples of how fields can be defined. There are three major variants:
- Base type
- Converted to custom type
- Converted to generated enum
The conversions can be fallible or infallible. When the fallible try option is used, reading the field will return a result instead of the type directly. For generated enums, even though they might not be generally infallible when converted from their base type, the toolkit uses extra range information to see if it can safely present an infallible interface regardless.
DSL
Simple (base type only):
foo: uint = 0..5,
bar: bool = 5,
zoof: int = 6..=20,
With attributes and access specifier:
/// Field comment!
#[cfg(blah)]
foo: WO uint = 0..5,
With conversion to custom type:
foo: uint as crate::MyCustomType = 0..16,
bar: int as try crate::MyCustomType2 = 16..32,
With conversion to generated enum:
foo: uint as enum GeneratedEnum {
A,
B = 5,
/// Default value
C = default,
D = catch_all,
} = 0..8,
Manifest
Simple (base type only) (json):
{
"foo": {
"base": "uint",
"start": 0,
"end": 5
},
"bar": {
"base": "bool",
"start": 5,
},
"zoof": {
"base": "int",
"start": 6,
"end": 21
}
}
With attributes and access specifier:
{
"foo": {
"cfg": "blah",
"description": "Field comment!",
"access": "WO",
"base": "uint",
"start": 0,
"end": 5
}
}
With conversion to custom type:
{
"foo": {
"base": "uint",
"conversion": "crate::MyCustomType",
"start": 0,
"end": 16
},
"bar": {
"base": "int",
"try_conversion": "crate::MyCustomType2",
"start": 16,
"end": 32
}
}
With conversion to generated enum:
{
"foo": {
"base": "uint",
"conversion": {
"name": "GeneratedEnum",
"A": null,
"B": 5,
"C": {
"description": "Default value",
"value": "default"
},
"D": "catch_all"
},
"start": 0,
"end": 8
}
}
Required
base
The base type denotes the primitive type used to convert the bits in the address range to a value.
Options:
- uint - unsigned integer
- int - two’s complement signed integer
- bool - low or high, only available for 1 bit values
The integer options will generate to the smallest signed or unsigned Rust integers that can fit the value. So a 10-bit uint will become a u16.
The value is specified as a string in the manifest format and is written ‘as is’ in the DSL.
start, end & address range
Every field must specified the bitrange it covers. The way this is done differs a bit between the DSL and the manifest but boil down to the same.
The DLS uses = <ADDRESS> as the syntax. Valid options for the address are:
- Exclusive range:
0..16 - Inclusive range:
0..=16 - Single address:
0- Only in combination with bool base types
The manifest has two fields start and end, both containing unsigned integers:
- The
startis the starting bit of the field - The
endis the exclusive end bit of the field- Not required for bool base types
The address must lie fully within the size of the defining object and no fields may overlap unless the defining object has the AllowBitOverlap property set to true.
Optional
cfg or #[cfg(...)]
Allows for cfg-gating the command.
In the DSL, the normal Rust syntax is used. Just put the attribute on the field definition. Only one attribute is allowed.
In the manifest it is configured with a string.
The string only defines the inner part: #[cfg(foo)] = "cfg": "foo",.
Warning
Check the chapter on cfg for more information. The cfg’s are not checked by the toolkit and only passed to the generated code and so there are some oddities to be aware of.
description or #[doc = ""]
The doc comments for the generated code.
For the DSL, use the normal doc attributes or triple slash ///.
Multiple attributes get concatenated with a newline (just like normal Rust does).
For the manifest, this is a string.
The description is added as normal doc comments to the generated code. So it supports markdown and all other features you’re used to. The description is used on the generated field getter and setter.
access
Overrides the default field access.
Options are: RW, ReadWrite, WO, WriteOnly, RO, ReadOnly.
They are written ‘as is’ in the DSL and as a string in the manifest.
If the specified access can do read, a getter is generated with the name of the field. If the specified access do write, a setter is generated with the set_ prefix followed by the name of the field.
Conversion
If the base type of a field is an integer, the value can be converted to a further higher level type. There are two options for this:
- Conversion to an existing type
- Conversion to an inline defined enum value
The conversion can be specified as infallible or fallible. When infallible, the field getter will call on the From<INTEGER> trait to convert the base value to the conversion value after which the value is returned. When fallible, the field getter will use the TryFrom<INTEGER> trait instead and will return the result value from it.
In the DSL the conversion is specified using the as <TARGET> or as try <TARGET> keywords for the infallible and fallible variants respectively.
The manifest has two possible fields conversion and try_conversion for the infallible and fallible variants respectively.
To existing type
When a type path is given as the DSL <TARGET> or as string in the manifest conversion field, the conversion will be done using the specified type.
The type path is used as is in the generated code, so you need to make sure that the type is in scope.
Due to how the generated modules are structured, the specified paths get super:: prepended to them.
To be able to still use extern crates and absolute paths this isn’t done when the path starts with :: or crate.
Furthermore the type must implement the From<INTEGER> or TryFrom<INTEGER> traits for the infallible or fallible conversions respectively when the field has read access. When the field has write access, the type must implement the Into<INTEGER> trait.
Tip
The existing type can also be a enum generated by the toolkit defined in another place by just using the name of that enum.
This has an added bonus that the toolkit still has the information for accepted input which means it can use the infallible conversion method instead of the
tryfallible one. This creates a nicer and cleaner API.
To generated enum
Instead of a custom type, the toolkit can also generate an enum inline.
In the DSL the format for <TARGET> is:
enum Foo {
A,
B = 5, // Also supports bit and hex specification
/// Comment
C
}
The enum is written pretty much as a normal Rust enum including setting the value of every variant and writing docs on every variant. In this example, the number value of C would be 6.
The generated enum will have the same docs as the field (if any).
In the manifest, the same enum would be specified like so:
"conversion": {
"name": "Foo",
"description": "Enum docs", // In manifest, enum can be separately documented
"A": null,
"B": 5,
"C": {
"description": "Comment",
"value": null
}
}
The values for each variant can be the following:
- Empty or null
- Use auto counting starting at 0 for the first variant and one higher than the previous variant
- Signed integer
- To manually specify the value
default- To specify a default value
- When the conversion is of a number that doesn’t match any variant, the default variant will be returned
- In DSL specified ‘as is’
- In manifest specified as a string
- Also implements the
Defaulttrait for the enum
catch_all- Similar to default, but makes the variant contain the raw value (like
Catch(u8)) - When the conversion is of a number that doesn’t match any variant, the catch all will be returned with the raw value
- In DSL specified ‘as is’
- In manifest specified as a string
- Similar to default, but makes the variant contain the raw value (like
When an enum contains both a catch all and a default, the catch all value is used to return unknown numbers.
A generated enum can be used infallibly when any of these properties hold:
- Any bitpattern of the field is covered by an enum variant
- The enum has a default value
- The enum has a catch all value
Buffers
A buffer is used to represent an stream of bytes on a device. This could for example be a fifo for a radio. It’s quite a simple construct and thus is limited in configuration options.
It is accessed as a function on the block it’s part of. The function returns a BufferOperation which can be used to read and write from/to the buffer. This operation type also implements the embedded-io traits.
Example usage:
let mut device = MyDevice::new(DeviceInterface::new());
device.foo().write_all(&[0, 1, 2, 3]).unwrap();
let mut buffer = [0; 8];
let len = device.bar().read(&mut buffer).unwrap();
Below are minimal and full examples of how buffers can be defined.
DSL
Minimal:
buffer Foo = 5,
Full:
/// A foo buffer
#[cfg(bar)]
buffer Foo: WO = 5,
Manifest
Minimal:
"Foo": {
"type": "buffer",
"address": 5
},
Full:
"Foo": {
"type": "buffer",
"cfg": "bar",
"description": "A foo buffer",
"access": "WO",
"address": 5
},
Required
address
The address of the buffer.
Integer value that must fit in the given address type in the global config and can be negative.
type (manifest only)
The type of the object.
For buffers this field is a string with the contents "buffer".
Optional
cfg or #[cfg(...)]
Allows for cfg-gating the buffer.
In the DSL, the normal Rust syntax is used. Just put the attribute on the buffer definition. Only one attribute is allowed.
In the manifest it is configured with a string.
The string only defines the inner part: #[cfg(foo)] = "cfg": "foo",.
Warning
Check the chapter on cfg for more information. The cfg’s are not checked by the toolkit and only passed to the generated code and so there are some oddities to be aware of.
description or #[doc = ""]
The doc comments for the generated code.
For the DSL, use the normal doc attributes or triple slash ///.
Multiple attributes get concatenated with a newline (just like normal Rust does).
For the manifest, this is a string.
The description is added as normal doc comments to the generated code. So it supports markdown and all other features you’re used to. The description is used on the generated buffer struct and on the function to access the buffer.
access
Overrides the default buffer access.
Options are: RW, ReadWrite, WO, WriteOnly, RO, ReadOnly.
They are written ‘as is’ in the DSL and as a string in the manifest.
Blocks
A block is a collection of other objects. This can be great to e.g. pool related objects together.
Blocks have their own address offset which is applied to all child objects. With this repeated and ref blocks are supported and can be used to great effect.
Tip
The generated code has one implicit root block with the name of the device that acts as the entry point of the driver. The only difference with other blocks is that it takes ownership of the interface instance and always has address offset 0.
It is accessed as a function on the parent block it’s part of.
All objects are generated globally so child objects still need a globally unique name and are not generated in a module.
Example usage:
// MyDevice is the root block
let mut device = MyDevice::new(DeviceInterface::new());
let mut child_block = device.foo();
child_block.bar().dispatch().unwrap();
// Or in one go
device.foo().bar().dispatch().unwrap();
Below are minimal and full examples of how blocks can be defined. There’s one child object defined as example.
DSL
Minimal:
block Foo {
buffer Bar = 0,
}
Full:
/// Block description
#[cfg(not(blah))]
block Foo {
const ADDRESS_OFFSET = 10;
const REPEAT = {
count: 2,
stride: 20,
};
buffer Bar = 0,
}
Manifest
Minimal:
"Foo": {
"type": "block",
"objects": {
"Bar": {
"type": "buffer",
"address": 0
}
}
}
Full:
"Foo": {
"type": "block",
"cfg": "not(blah)",
"description": "Block description",
"address_offset": 10,
"repeat": {
"count": 2,
"stride": 20,
},
"objects": {
"Bar": {
"type": "buffer",
"address": 0
}
}
}
Required
type (manifest only)
The type of the object.
For blocks this field is a string with the contents "block".
Optional
cfg or #[cfg(...)]
Allows for cfg-gating the block.
In the DSL, the normal Rust syntax is used. Just put the attribute on the block definition. Only one attribute is allowed.
In the manifest it is configured with a string.
The string only defines the inner part: #[cfg(foo)] = "cfg": "foo",.
Warning
Check the chapter on cfg for more information. The cfg’s are not checked by the toolkit and only passed to the generated code and so there are some oddities to be aware of.
description or #[doc = ""]
The doc comments for the generated code.
For the DSL, use the normal doc attributes or triple slash ///.
Multiple attributes get concatenated with a newline (just like normal Rust does).
For the manifest, this is a string.
The description is added as normal doc comments to the generated code. So it supports markdown and all other features you’re used to. The description is used on the generated block struct and on the function to access the block.
address_offset
The address offset used for all child objects specified as a signed integer.
The offset is applied to all addresses of the children. So when the offset is 5 and a child specifies address 7, then the actual used address will be 12.
If the offset is not specified, it is default 0.
repeat
Repeat the block a number of times at different address offsets.
It is specified with two fields:
- Count: unsigned integer, the amount of times the block is repeated
- Stride: signed integer, the amount the address offset changes per repeat
The calculation is offset = base_offset + index * stride.
When the repeat field is present, the function to access a block will have an extra parameter for the index.
objects (manifest only)
A map that contains all the child objects.
For the DSL the children are defined in the block directly.
Refs
A ref is a copy of another object where parts of that object are overridden with a new value.
For example, you may have two different registers that have the same fields but reside at different addresses. You may not want to use a repeat if they are not logically repeated.
Refs can target registers, commands and blocks. Buffers can’t be reffed because they’re so simple there’s nothing worth overriding. You also can’t ref other refs since that would open the gates of hell in the toolkit implementation.
Note
Using a ref is exactly the same as using the original, just with the new name. The only difference in API is that if the reset value of a field set is overridden, that fieldset gets an extra constructor with which you can initialize it with the overridden reset value.
The possible overrides are all of the object properties that don’t specify things about the field set. For example,
size_bits,fields,byte_orderand more can’t be overridden.
Below are minimal and full examples of how refs can be defined. The examples all override a register and its address.
DSL
Minimal:
register Foo {
const ADDRESS = 3;
const SIZE_BITS = 16;
value: uint = 0..16,
},
ref Bar = register Foo {
const ADDRESS = 5;
},
Full:
register Foo {
const ADDRESS = 3;
const SIZE_BITS = 16;
value: uint = 0..16,
},
/// This is a copy of Foo, but now with address 5!
#[cfg(feature = "bar-enabled")]
ref Bar = register Foo {
const ADDRESS = 5;
},
Manifest
Minimal:
"Foo": {
"type": "register",
"address": 3,
"size_bits": 16,
"fields": {
"value": {
"base": "uint",
"start": 0,
"end": 16
}
}
},
"Bar": {
"type": "ref",
"target": "Foo",
"override": {
"type": "register",
"address": 3,
}
}
Full:
"Foo": {
"type": "register",
"address": 3,
"size_bits": 16,
"fields": {
"value": {
"base": "uint",
"start": 0,
"end": 16
}
}
},
"Bar": {
"type": "ref",
"target": "Foo",
"description": "This is a copy of Foo, but now with address 5!",
"cfg": "feature = \"bar-enabled\"",
"override": {
"type": "register",
"address": 3,
}
}
Required
target (manifest only)
The (string) name of the reffed object.
type (manifest only)
The type of the object.
For refs this field is a string with the contents "ref".
override or { .. }
Contains the override fields of the ref.
This is formatted as an object normally is, but some fields will be rejected.
Optional
cfg or #[cfg(...)]
Allows for cfg-gating the ref.
In the DSL, the normal Rust syntax is used. Just put the attribute on the ref definition. Only one attribute is allowed.
In the manifest it is configured with a string.
The string only defines the inner part: #[cfg(foo)] = "cfg": "foo",.
Warning
Check the chapter on cfg for more information. The cfg’s are not checked by the toolkit and only passed to the generated code and so there are some oddities to be aware of.
description or #[doc = ""]
The doc comments for the generated code.
For the DSL, use the normal doc attributes or triple slash ///.
Multiple attributes get concatenated with a newline (just like normal Rust does).
For the manifest, this is a string.
The description is added as normal doc comments to the generated code. So it supports markdown and all other features you’re used to. The description is used on the generated ref struct and on the function to access the ref.
Dsl syntax
Caution
This doc is written manually. The implementation may differ. If it does, then either this doc is wrong or the implementation is wrong. In any case, them disagreeing is a bug. Please file an issue!
Warning
While something may be valid to be parsed, it may not be valid as a construct and may generate an error deeper down.
Top-level item is Device.
- ‘*’ is used to signal 0 or more instances.
- ‘?’ is used to signal 0 or 1 instances.
- ‘|’ is used as an ‘or’. One of the options in the chain can be used.
- ‘( )’ is used to group things together.
- Any
keywordor brackets in the grammar use backticks just like word ‘keyword’ on this line.
This doesn’t map perfectly on the YAML and JSON inputs, but they should be made as close as possible.
Device:
GlobalConfigList
ObjectList
GlobalConfigList:
(
config{GlobalConfig*})?
GlobalConfig:
(
typeDefaultRegisterAccess=Access;)
| (typeDefaultFieldAccess=Access;)
| (typeDefaultBufferAccess=Access;)
| (typeDefaultByteOrder=ByteOrder;)
| (typeDefaultBitOrder=BitOrder;)
| (typeRegisterAddressType=IntegerType;)
| (typeCommandAddressType=IntegerType;)
| (typeBufferAddressType=IntegerType;)
| (typeNameWordBoundaries=NameWordBoundaries;)
| (typeDefmtFeature=String;)
NameWordBoundaries: This specifies the input, not the output. Only applies to object and field names.
[Boundary*]
| String
ObjectList:
(Object(
,Object)*,?)?
Object:
Block
| Register
| Command
| Buffer
| RefObject
RefObject: An object that is a copy of another object. Any items in the object are overrides.
AttributeList
refIDENTIFIER=Object
AttributeList:
Attribute*
Attribute: Used for documentation and conditional compilation
(
#[doc=STRING])
| (#[cfg(ConfigurationPredicate)])
Block:
AttributeList
blockIDENTIFIER{BlockItemList ObjectList}
BlockItemList:
BlockItem*
BlockItem:
(
constADDRESS_OFFSET=INTEGER;)
| (constRepeat)
Register:
AttributeList
registerIDENTIFIER{RegisterItemList FieldList}
RegisterItemList:
RegisterItem*
RegisterItem:
(
typeAccess=Access;)
| (typeByteOrder=ByteOrder;)
| (typeBitOrder=BitOrder;)
| (constADDRESS=INTEGER;)
| (constSIZE_BITS=INTEGER;)
| (constRESET_VALUE=INTEGER | U8_ARRAY;)
| (constRepeat)
| (constALLOW_BIT_OVERLAP= BOOL;)
| (constALLOW_ADDRESS_OVERLAP= BOOL;)
Access:
(
ReadWrite|RW)
| (ReadOnly|RO)
| (WriteOnly|WO)
ByteOrder:
LE|BE
BitOrder:
LSB0|MSB0
FieldList:
(Field (
,Field)*,?)
Field:
AttributeList
IDENTIFIER:Access? BaseType FieldConversion?=FieldAddress
FieldConversion:
(
astry? TYPE_PATH)
| (astry?enumIDENTIFIER{EnumVariantList})
EnumVariantList:
EnumVariant(
,EnumVariant)*,?
EnumVariant:
AttributeList
IDENTIFIER (=EnumValue)?
EnumValue:
INTEGER|
default|catch_all
FieldAddress:
INTEGER
| (INTEGER..INTEGER)
| (INTEGER..=INTEGER)
BaseType:
bool|uint|int
Command:
AttributeList
commandIDENTIFIER CommandValue?
CommandValue:
(
=INTEGER)
| ({CommandItemList (in{FieldList},?)? (out{FieldList},?)?})
CommandItemList:
CommandItem*
CommandItem: Commands have data going in and out, so they need two separate data field types. If no in fields, then no data is sent. If no out fields, then no data is returned.
(
typeByteOrder=ByteOrder;)
| (typeBitOrder=BitOrder;)
| (constADDRESS=INTEGER;)
| (constSIZE_BITS_IN=INTEGER;)
| (constSIZE_BITS_OUT=INTEGER;)
| (constRepeat)
| (constALLOW_BIT_OVERLAP= BOOL;)
| (constALLOW_ADDRESS_OVERLAP= BOOL;)
Repeat:
REPEAT={count:INTEGER,stride:INTEGER,?};
Buffer:
AttributeList
bufferIDENTIFIER(:Access)? (=INTEGER)?
Manifest syntax
Caution
This doc is written manually. The implementation may differ. If it does, then either this doc is wrong or the implementation is wrong. In any case, them disagreeing is a bug. Please file an issue!
Warning
While something may be valid to be parsed, it may not be valid as a construct and may generate an error deeper down.
Top-level item is Device.
Anything marked like this denotes its own type specification.
These are the pre-defined types:
- bool
- uint
- int
- float
- string
- array
- Using
[]brackets. - If inner types are restricted, then signaled as e.g.
[float]
- Using
- map
- Using
{}brackets. - The keys are always text/string.
- Restrictions can be signaled as required by
? - Restriction syntax: `{ foo?, bar?: float, xen: bool, *: bool }
- Optional field
foowithout type restriction - Optional field
barwith float restriction - Required field
xenwith bool restriction 0..Nfields with any name with bool restriction
- Optional field
- Using
Further restriction can be denoted using oneof(), for example: int oneof(1, 2, 3, 4) or oneof(bool, int)
Device: The key of the object will become the name of it
{
config?: _GlobalConfig_,
*: _Object_
}
GlobalConfig:
{
default_register_access?: _Access_,
default_field_access?: _Access_,
default_buffer_access?: _Access_,
default_byte_order?: _ByteOrder_,
default_bit_order?: _BitOrder_,
register_address_type?: _IntegerType_,
command_address_type?: _IntegerType_,
buffer_address_type?: _IntegerType_,
name_word_boundaries?: _NameWordBoundaries_
defmt_feature?: string
}
Access:
string oneof("ReadWrite", "RW", "ReadOnly", "RO", "WriteOnly", "WO")
ByteOrder:
string oneof("LE", "BE")
BitOrder:
string oneof("LSB0", "MSB0")
IntegerType:
string oneof("u8", "u16", "u32", "i8", "i16", "i32", "i64")
NameWordBoundaries:
oneof([_Boundary_], string)
Object:
oneof(
_Block_,
_Register_,
_Command_,
_Buffer_,
_RefObject_
)
RefObject:
{
type: string oneof("ref"),
cfg?: string,
description?: string,
target: string,
override: _Object_,
}
Block:
{
type: string oneof("block"),
cfg?: string,
description?: string,
address_offset?: int,
repeat?: _Repeat_,
objects?: {
*: _Object_
}
}
Repeat:
{
count: uint,
stride: int
}
Register:
{
type: string oneof("register"),
cfg?: string,
description?: string,
access?: _Access_,
byte_order?: _ByteOrder_,
bit_order?: _BitOrder_,
address: int,
size_bits: int,
reset_value?: oneof(int, [uint]),
repeat?: _Repeat_,
allow_bit_overlap?: bool,
allow_address_overlap?: bool,
fields?: {
*: _Field_
}
}
Field:
{
cfg?: string,
description?: string,
access?: _Access_,
base: _BaseType_,
conversion?: _FieldConversion_,
try_conversion?: _FieldConversion_,
start: int,
end?: int,
}
BaseType:
string oneof("bool", "int", "uint")
FieldConversion:
oneof(
string,
{
name: string,
description?: string,
*: _EnumVariant_
}
)
EnumVariant:
oneof(
_EnumValue_,
{
cfg?: string,
description?: string,
value?: _EnumValue_
}
)
EnumValue:
oneof(
null, int, string oneof("default", "catch_all")
)
Command:
{
type: string oneof("command"),
cfg?: string,
description?: string,
byte_order?: _ByteOrder_,
bit_order?: _BitOrder_,
address: int,
repeat?: _Repeat_,
allow_bit_overlap?: bool,
allow_address_overlap?: bool,
size_bits_in?: int,
fields_in?: {
*: _Field_
},
size_bits_out?: int,
fields_out?: {
*: _Field_
},
}
Buffer:
{
type: string oneof("buffer"),
cfg?: string,
description?: string,
access?: _Access_,
address: int,
}
Cfg
Pretty much anywhere you can put docs/description, you can also put some cfg. They use the same syntax as the inside of the cfg attribute, e.g. feature = "blah".
Important
The cfg’s have no impact on the code generation other than forwarding the cfg’s as attributes on items.
This presents a couple of challenges:
- It’s quite hard to check whether a driver can compile with any combination of cfg’s.
- The cfg’s are resolved after code generation, so the toolkit can’t check anything.
- It’s hard to predict how the cfg attributes on various items interact.
So what does this all mean?
Caution
- The support for cfg’s are best effort only. Expect things to be weird or something to work against you.
- Some analysis may not be done on objects with cfg which can lead to weird errors in the generated code since problems are not caught beforehand.
Warning
- If you use cfg’s, check the generated code to see if everything looks alright.
- Use cfg’s only sparingly.
- Test all realistic cfg combinations, preferably even in CI.
If there is a problem and the toolkit can do better, then please make an issue!
Memory
Memory is quite easy. But assigning meaning to it is where all complexity comes from. This page describes all the different levels of memory and what this crate does. The goal is to leave you with a better understanding of how memory is handled and to serve as a quick reference if or when confusion ensues.
Concepts
Byte order
Also known as endianness. It describes what the first byte is in an array of bytes. There are two options generally:
- Little endian (LE)
- The smallest or first byte is at the front
- I.E.
[10, 11, 12, 13]where indexing at 0 would yield10 - Lower indices are in lower memory addresses, higher indices are in higher memory addresses
- Big endian (BE)
- The smallest or first byte is at the back
- I.E.
[10, 11, 12, 13]where indexing at 0 would yield13 - Lower indices are in higher memory addresses, higher indices are in lower memory addresses
Bit order
Note
V2 doesn’t support changing the bit order and is always LSB0. If you do encounter the rare MSB0 device, then swap the bits manually in the interface or let the hardware peripheral do it for you if possible
There is also order on the bit level. We get to decide which bit is the smallest of the 8 in a byte. There’s two options:
- Least significant bit 0 (LSB0)
- The bit at index 0 is the lowest bit
- I.E. the number
1is coded as0b0000_0001or0x01
- Most significant bit 0 (MSB0)
- The bit at index 0 is the highest bit
- I.E. the number
1is coded as0b1000_0000or0x80
Together
Important
Together, the bit and byte order determine where a given bit is in an array of bytes.
Bit 0 is defined as the 0th bit on the 0th byte.
Bit 10 is defined as the 2nd bit on the 1st byte.
Here are the options for when only bit 0 is high in a 2-byte array:
LE, LSB0:
[0b0000_0001, 0b0000_0000] or [0x01, 0x00]
^ ^ <- Bits 0
^^^^^^^^^^^ <- Byte 0
LE, MSB0:
[0b1000_0000, 0b0000_0000] or [0x80, 0x00]
^ ^ <- Bits 0
^^^^^^^^^^^ <- Byte 0
BE, LSB0:
[0b0000_0000, 0b0000_0001] or [0x00, 0x01]
^ ^ <- Bits 0
^^^^^^^^^^^ <- Byte 0
BE, MSB0:
[0b0000_0000, 0b1000_0000] or [0x00, 0x80]
^ ^ <- Bits 0
^^^^^^^^^^^ <- Byte 0
Here are the options for when only bit 10 is high in a 2-byte array:
LE, LSB0:
[0b0000_0000, 0b0000_0100] or [0x00, 0x04]
^ ^ <- Bits 2
^^^^^^^^^^^ <- Byte 1
LE, MSB0:
[0b0000_0000, 0b0010_0000] or [0x00, 0x20]
^ ^ <- Bits 2
^^^^^^^^^^^ <- Byte 1
BE, LSB0:
[0b0000_0100, 0b0000_0000] or [0x04, 0x00]
^ ^ <- Bits 2
^^^^^^^^^^^ <- Byte 1
BE, MSB0:
[0b0010_0000, 0b0000_0000] or [0x20, 0x00]
^ ^ <- Bits 2
^^^^^^^^^^^ <- Byte 1
The memories of device-driver
Important
Here’s the tricky part. The data of a register can be present in three places:
- On the device
- On the transport bus (e.g. while writing/reading it over SPI)
- In RAM on your microcontroller
The first two we don’t have any influence over. But we can create our own model with this crate that fits the device.
Let’s do some examples for real existing devices. If there’s a device that does things a bit different, feel free to PR this file!
Tip
If all registers have the same behaviour (which is the case usually), you can set the bit and byte orders in the global config too so it applies to all registers that don’t explicitly have it set.
Example LIS3DH - Multi-register LE, LSB0
The LIS3DH accelerometer is one byte per register, but there are some multi-register values that we may want to model as one two-byte register. This can be done because the address will auto increment after any reads/writes.
In the datasheet we find the registers:
| Name | Access | Address | Value |
|---|---|---|---|
| OUT_X_L | ro | 0x28 | X [0..8] |
| OUT_X_H | ro | 0x29 | X [8..16] |
And the transport schema:
CS : ¯¯\___________________________________________________________/¯¯¯¯
SPC: ¯¯¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯¯¯¯¯
SDI: ===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===
R!W DI7 DI6 DI5 DI4 DI3 DI2 DI1 DI0
M!S AD5 AD4 AD3 AD2 AD0
SDO: -------------------------------x===x===x===x===x===x===x===x===x---
DO7 DO6 DO5 DO4 DO3 DO2 DO1 DO0
Let’s analyze:
Byte order
- We will make one register out of the two starting at address 0x28
- The first byte will be from
OUT_X_Land the second byte will be fromOUT_X_H - So, low index is low byte and high index is high byte
- Thus this combined register is little endian (LE)
Bit order
- Depends on the hardware settings of the SPI. We set it to most significant bit first to match the datasheet.
- The 0th bit is the last and least significant one of the byte
- Thus this is Least Significant Bit 0 (LSB0)
And so we get our register definition:
// V2 DDSL
register OutX {
address: 0x68,
fields: fieldset _ {
size-bytes: 2,
byte-order: LE,
field value 15:0 -> int,
}
}
// V1 DSL
register OutX {
const ADDRESS = 0x68; // Including bit for multi-register ops
const SIZE_BITS = 16;
type ByteOrder = LE;
type BitOrder = LSB0;
value: int = 0..16,
}
Example s2-lp - Multi-register BE, LSB0
This is a radio chip and just like the LIS3DH can combine multiple registers in one read/write.
In the datasheet we find the registers:
| Name | Address | Bits | Value |
|---|---|---|---|
| SYNT3 | 05 | 7:5 | PLL_CP_ISEL |
| 4 | BS | ||
| 3:0 | SYNT[27:24] | ||
| SYNT2 | 06 | 7:0 | SYNT[23:16] |
| SYNT1 | 07 | 7:0 | SYNT[15:8] |
| SYNT0 | 08 | 7:0 | SYNT[7:0] |
And the transport schema (for writes):
CSn : ¯¯\_______________________________________________________________________________________________/¯¯¯¯
SCLK: ¯¯¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯\_/¯¯¯¯¯
MOSI: ---x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x===x---
A/C 0 0 0 0 0 0 W/R A7 A6 A5 A4 A3 A2 A1 A0 D7 D6 D5 D4 D3 D2 D1 D0
| header | address | data
Let’s analyze:
Byte order
- We will make one register out of this starting at address 0x05
- The first byte will contain
SYNT[27:24]and the last byte will containSYNT[7:0] - So, low index is high byte and high index is low byte
- Thus this combined register is big endian (BE)
Bit order
- Depends on the hardware settings of the SPI. We set it to most significant bit first to match the datasheet.
- The 0th bit is the last/least significant one
- Thus this is Least Significant Bit 0 (LSB0)
// V2 DDSL
register OutX {
address: 0x05,
fields: fieldset _ {
size-bytes: 4,
byte-order: BE,
field synt 27:0 -> uint,
field bs 28 -> bool,
field pll_cp_isel 31:29 -> uint
}
}
// V1 DSL
register OutX {
const ADDRESS = 0x05;
const SIZE_BITS = 32;
type ByteOrder = BE;
type BitOrder = LSB0;
synt: uint = 0..=27,
bs: bool = 28,
pll_cp_isel: uint = 29..=31
}
Example DW1000 - Single-register LE, LSB0
This chip doesn’t have multi register reads, but it does have registers bigger than a byte. So even a single register must take care of byte ordering.
Luckily for us, the user manual spells out the modes (along to the diagrams):
-
Note: The octets of a multi-octet value are transferred on the SPI interface in octet order beginning with the low-order octet.
- Diagram example: Register
0x00contains0xDECA0130and is sent as[0x30, 0x01, 0xCA, 0xDE]- Thus little endian (LE)
-
Note: The octets are physically presented on the SPI interface data lines with the high order bit sent first in time.
- Depends on the hardware settings of the SPI. We set it to most significant bit first to match the datasheet.
- Thus Least Significant Bit 0 (LSB0) (assuming your SPI master also sees the first bit as the LSB)
// V2 DDSL
register DevId {
address: 0x00,
fields: fieldset _ {
size-bytes: 4,
byte-order: LE,
field r_id_tag 31:16 -> uint,
field model 15:8 -> uint,
field ver 7:4 -> uint,
field rev 3:0 -> uint
}
}
// V1 DSL
register DevId {
const ADDRESS = 0x00;
const SIZE_BITS = 32;
type ByteOrder = LE;
type BitOrder = LSB0;
r_id_tag: uint = 16..32,
model: uint = 8..16,
ver: uint = 4..8,
rev: uint = 0..4
}
Migrating v1 to v2
If you want to migrate an existing v1 driver to v2, you’ll read some of the steps here and some of the issues you may encounter.
Convert
The ddc cli can do a lot of the mechanical conversion for you.
ddc convert device-driver-v1 --sub-format yaml -s ./device.yaml -o ./device.ddsl
Change out the paths as you like and change the subformat to the format you’ve used. If you’ve been using the create_device! with the inline DSL, first copy the DSL to a file and feed that to the tool.
If your install of ddc doesn’t have the convert command, you need to reinstall it with the --features converter-dd-v1 flag.
The convert command converts the formats very straightforwardly and often doesn’t do a perfect job, so some hand tuning is still required.
Cfg
Cfg doesn’t exist anymore and there’s currently no replacement. If you really need this, you may need to keep your driver on v1. There are plans to build template support in the language: issue, so if you really need this and have ideas, please contribute there!
The converter ignores cfg values.
Naming
In v2, the names of objects is a little stricter. You may find that before you had a register, field and enum all with the same name. This would now clash since a register now has a named fieldset. Enums and fieldsets are types and they must not have the same name. See the section on namespacing.
The converter copies the names as they were and does not fix them up. You’ll have to figure out alternative names.
Ref objects
Ref objects don’t exist anymore. Ref registers and ref commands have good alternatives, though.
Instead of having overrides, you simply copy the values as though it’s a fully separate register or command. But then instead of defining a new fieldset, you can reuse a fieldset. The converter does this for you.
For ref blocks you either have to do a bunch of copying manually, or use a (enum) repeat. The converter tool just copies everything (which will likely lead to name collisions you’ll have to fix).
Extern types
In v1 you could reference something that wasn’t defined in the manifest. The generated code would then just use the name as is. This must now be done much more explicit as everything must be self-contained in the manifest.
You must define an extern object for every type reference that used to refer to a rust object. The converter does not do this for you as it can’t know some of the metadata.
Formatting
The converter doesn’t know about number formatting and outputs everything in decimal.
Bit order
Bit order doesn’t exist anymore. It was a very hard feature to support for something that’s barely ever used. Everything is now lsb0.
If the converter finds something with msb0, it bails. Remove or change it in the v1 manifest and run the converter again.
Crate
Now that we’ve converted the v1 yaml, json, toml or DSL to DDSL, we can focus on our Rust crate that requires some changes too.
- Update the device-driver dependency to 2.0.0 (or newer)
- If you’re using the macro to compile the source, enable the
macrosfeature on the crate
- If you’re using the macro to compile the source, enable the
- If you’re using macro to compile the source, update the macro:
Note that the device name is now specified in the manifest and the defmt option at the compiler invocation.- device_driver::create_device!( - device_name: Device, - manifest: "device.yaml" - ); + device_driver::compile!( + options: "--rust-defmt-feature=defmt", + manifest: "device.ddsl" + ); - If you have a
build.rs, check it and update any reference to the old manifest to the new file name. - Update your interface types
- They now all have a base trait for the error and address types
- Size-bits is no longer given
- A metadata object is now given
- All buffers are now mutable for the case you need to do bit swaps
- Fieldsets are not generated into a module anymore. You may need to change some imports to remove the
field_setsmodule from the path. The Rust compiler will tell you where this needs to happen. - Fieldsets don’t have a constructor anymore. If you’ve called the
new_zero()on fieldsets before, you’ll now need to use theZEROassociated const (for which you’ll need to import theFieldsettrait from the device-driver crate) or thedefault()impl. - Repeat index parameter has moved. It used to be a parameter on the operation getter function. Now you specify it on the operation. For example:
- device.foo(index).read()?; + device.foo().read_at(index)?;