There’s been a lot written about what can be done to make Rust’s tooling, libraries and infrastructure better for embedded programming, but I’d like to cover a slightly different topic: what can be done to make Rust the language itself safer and easier to use for low-level programming?
I’m using the term “low-level programming” to refer to any type of programming where you are dealing with memory structures at the bit level and/or are dealing with memory-mapped I/O. This covers embedded programming and operating systems development, writing device drivers, network protocols, media codecs, file formats, and highly optimized data structures.
In my experience, Rust provides great tools for developers that are consuming these abstractions, but has a few gaps for those implementing them. Here are some rough edges along with how they are handled in other languages.
Ranged Types
Rust has a core set of primitive numeric types of width 8, 16, 32, 64 and maybe 128, but it’s very common to want to work with values that have ranges other than those. Typical cases might be
- a range of values from [0..2^n) where 2^n isn’t one of the natively supported sizes,
- a range of values from [0..n) for some arbitrary n, or
- a range of values from [a..b) for some arbitrary a and b.
Ada has support for all of these cases (see a description of the Ada type system here).
type Unsigned_13 is mod 2**13;
type Hour is range 0 .. 23;
type Month is range 1 .. 12;
What might this look like in Rust?
For the first case, it’s easy to imagine adding more built-in types to cover u1..u64 and i1..i64. These could have the same semantics as the existing primitive types support the same operations, perhaps with some acceptable loss in efficiency for some operations. I dream of being able to write something like:
fn set_prescaler(divider: u10) {
...
}
The second and third cases would require bigger changes. One possibility would be a new keyword that could define ranged types, maybe like this:
#[repr(u8)]
pub range Hour(0..23);
let hh: Hour = 12;
It’s possible right now to cover some of these use cases with some combination of macros, wrapper types, enums, and run-time checking; it will get easier once const generics are in place.
Still, these additional layers of abstraction are costly - maybe not at runtime, but certainly at compile time and from a discoverability / learnability standpoint. It’s easy to see why someone might prefer to implement ad-hoc run-time checks or a minimal wrapper instead of adding a possibly heavyweight external dependency.
Bit Fields
It’s very common in many types of programming to have structures in memory that contain fields that cannot currently be directly expressed in Rust. In some cases it’s because the fields are sizes that aren’t directly supported (i.e. single bits or intermediate widths such as u24), other times it’s because they aren’t byte-aligned. Common uses are memory-mapped hardware registers, descriptors, and network protocol headers.
Rust currently only supports accessing these fields through bit-manipulation operations, which is error-prone and repetitive. Common patterns are recognized by the compiler and turned into idiomatic bit-field assembly code, but there isn’t enough information for the compiler to know your intent: it can’t prevent you from using the wrong bitmask or shift, or forgetting to clear a field before setting it.
Ada has direct support for bitfields within records. For instance, a register definintion from an Ada embedded driver library:
for CR2_Register use record
FREQ at 0 range 0 .. 5;
Reserved_6_7 at 0 range 6 .. 7;
ITERREN at 0 range 8 .. 8;
ITEVTEN at 0 range 9 .. 9;
ITBUFEN at 0 range 10 .. 10;
DMAEN at 0 range 11 .. 11;
LAST at 0 range 12 .. 12;
Reserved_13_31 at 0 range 13 .. 31;
end record;
D also has a form of support:
struct A
{
int a;
mixin(bitfields!(
uint, "x", 2,
int, "y", 3,
uint, "z", 2,
bool, "flag", 1));
}
A obj;
obj.x = 2;
obj.z = obj.x;
C’s approach is fatally underspecified but not too difficult to understand:
struct Disk_Register
{
unsigned int ready:1 ; // 1 bit field named "ready"
unsigned int error:1 ; // 1 bit field named "error"
unsigned int wr_prot:1 ;
unsigned int dsk_spinning:1 ;
unsigned int command:4 ; // 4 bits field named "command"
unsigned int error_code:8 ;
unsigned int sector_no:16 ;
};
And Erlang has a sophisticated bit syntax:
-define(IP_VERSION, 4).
-define(IP_MIN_HDR_LEN, 5).
DgramSize = byte_size(Dgram),
case Dgram of
<<?IP_VERSION:4, HLen:4, SrvcType:8, TotLen:16,
ID:16, Flgs:3, FragOff:13,
TTL:8, Proto:8, HdrChkSum:16,
SrcIP:32,
DestIP:32, RestDgram/binary>> when HLen>=5, 4*HLen=<DgramSize ->
OptsLen = 4*(HLen - ?IP_MIN_HDR_LEN),
<<Opts:OptsLen/binary,Data/binary>> = RestDgram,
...
end.
This is a case where the Rust community should explore how to tackle this problem and come up with a solution that can eliminate a whole category of tedious, error-prone work. It’s analogous to going from
let mut i = 0;
while i < arr.len() {
let v = arr[i];
// do something with v
i += 1;
}
to
for i in 0..arr.len() {
let v = arr[i];
// do something with v
}
to
for v in arr.iter() {
// do something with v
}
Each step along the way there’s less code to write and fewer mistakes to make, and more importantly, the intent becomes clearer and the compiler can optimize more effectively.
By the way, this isn’t to put down any of the crates out there that attempt to solve the problem within the constraints of the language as it exists today. bit_field, bitfield and bitflags are great examples of tackling specific aspects of this problem.
Volatile Memory Access
Volatile memory access is a complicated topic in Rust. There is a long history of volatile
being misused and misunderstood in C, and the Rust team has chosen to make volatile reads and writes explicit using the read_volatile
and write_volatile
functions rather than implicit based on type or attribute. This leads to two issues:
- Reading and writing memory-mapped registers is much clumsier and more verbose than “regular” memory, particularly when using structs and assignment operators.
- Wrapper facades can be used to hide some of this, but are bulky and inefficient when not optimized, sometimes to the point of changing behavior or making it impossible to fit into available memory.
So idiomatic code in C that looks like this
SIM_SCGC1 |= SIM_SCGC1_UART5; // turn on clock, TODO: use bitband
…
CORE_PIN47_CONFIG = PORT_PCR_PE | PORT_PCR_PS | PORT_PCR_PFE | PORT_PCR_MUX(3);
CORE_PIN48_CONFIG = PORT_PCR_DSE | PORT_PCR_SRE | PORT_PCR_MUX(3);
looks like this in Rust:
write_volatile(SIM_SCGC1, read_volatile(SIM_SCGC1) | SIM_SCGC1_UART5);
…
write_volatile(CORE_PIN47_CONFIG, PORT_PCR_PE | PORT_PCR_PS | PORT_PCR_PFE | port_pcr_mux(3));
write_volatile(CORE_PIN48_CONFIG, PORT_PCR_DSE | PORT_PCR_SRE | port_pcr_mux(3));
It’s worse when structs are involved:
UART1.CR1 |= UART_CR1_ENABLE;
UART1.CR2 = UART_CR2_PARITY_EVEN | UART_CR2_STOP_BITS_1;
becomes
write_volatile(&mut UART1.CR1, read_volatile(&UART1.CR1) | UART_CR1_ENABLE);
write_volatile(&mut UART1.CR2, UART_CR2_PARITY_EVEN | UART_CR2_STOP_1);
The Rust version ends up with plenty of boilerplate that obscures the intent. Some of this can be mitigated through helper functions using closures or helper classes. The code I write in practice ends up looking like this:
UART1.with_cr1(|r| r.set_enable(1));
UART1.set_cr2(|r| r.set_parity_even(1).set_stop_bits(1));
which is better but still significantly more verbose than the C example.
I don’t know if the best way to address this is through type-level attributes, variable-level declarations or something completely different, but this is another case where the ergonomics could be much better. I wouldn’t mind
volatile {
UART1.CR1 |= UART_CR1_ENABLE;
UART1.CR2 = UART_CR2_PARITY_EVEN | UART_CR2_STOP_BITS_1;
}
Conclusion
None of these are critical issues that prevent good low-level Rust code from being written; they just make it harder and less fun. It’s going to be a multiple-year process to make progress in addressing these issues, just as it’s taken several years to improve error handling and asynchronous programming. 2018 is a good time to get started.