This is my first project in Rust and I'm kind of stranded. Excited, still, but loosing confidence. I've been digging in docs, writing chunks of code and trashing it right after, for a month... so... please... accept some ranting and wheening...
I'm trying to write a multi-architecture firmware (avr, rp2040, some stm32) and (as an example) I can't figure out how to write a piece of code using a Peripheral existing in both atmega-hal and rp2040-hal (ex: ADC). In C/C++ there are a few dozens of ways to do it (with wrappers, function pointers, void* data, inheritance, and so on). In Rust, I can't help myself.
The HAL crates theirselves aren't a type, so I can't just use a generic to specialize a struct using their PACs or HALs, ex:
struct MyPAC<rp2040-pac> { ... }
And I don't have an allocator... so I can't even use Box, Rc, Arc, Vec and all the other tools available to std developers.
How do I get an abstracted Peripheral entity (a struct?) built at compile time for AVR and RP2040, and then use it with methods common to both architectures? Traits work for methods but what about data? What's the prototype for a general Peripheral entity to be finalized into an ADC rather than a Timer or a GPIO pin? I tried to dig into existing PACs to figure out how they are made but they are generated by a svd2rust tool and I get lost in macros that pack registers into those types... once mama told me "avoid macros!!!" and I can't make her unhappy...
So, you have a bunch of kinds of peripherals you want to support. Each kind of peripheral has a crate that can be used to support it. You want to have a generic struct that has some common data fields and some peripheral-specific fields. Is this correct?
The way I'd do this is to create a new struct type for each kind of peripheral. Then, have a generic struct that contains these new structs. Something like this:
// Suppose that the kinds of peripherals are Foo and Bar.
struct FooData { .... }
struct BarData { .... }
trait PeripheralSpecificData { .... }
impl PeripheralSpecificData for FooData { .... }
impl PeripheralSpecificData for BarData { .... }
struct Peripheral<T> {
common_field_1: i32,
common_field_2: i32,
peripheral_specific: T
}
impl<T: PeripheralSpecificData> Peripheral<T> { .... }
I'd also use this approach, but potentially you don't need to define your own struct. Just defining a trait and implementing it for the HAL crates' structs should be enough. Don't the HAL crates already share some common traits already ?
I think you placed me on the right track; by searching The Book I got the "trait bounds" ("use traits to define functions that accept many different types" from chapter 10.2).
But still, on the above code I'm not mixing the "foo" (or "bar") variables with my own structs (having "clock" and "pin" variables); moreover the compiler says
Very little indeed. At least, it seems to me. The embedded-hal crate have 5 modules only, and a handful of traits each. The specialized crates (ex: rp2040-hal) have a ton of complex modules instead.
Moreover, the AVR pac (called "avr-device") and hal differ a lot from the ARM ones. The project is avr-hal, but then in code one can use arduino-hal or atmega-hal/attiny-hal. And in general the naming schema is different from the others.
It's a bit confusing indeed. I mean, on top of my total lack of experience with Rust.
Edit: there's also the problem of figuring out what types are generated via macros. Those PACs are generated using the svd2rust tool (or alike), so there's no code to read without digging in the macros (or write a bug and make the compiler complain for a type mismatch ).
What's wrong, as far as the compiler is concerned, is that if a single type implemented both AdcBase and GpioBase, it would be unavoidably ambiguous which new() to call. The only way to have duplicate associated function names is to define them in a trait or traits, where the trait name and generic parameters can be used to disambiguate.
In the code you show, they are identical except for bounds, so you could just remove the bounds and have only one new(). If you need both to exist, give them two different names (perhaps starting with “from_”). If you need to call them from generic code, you need to make them a function of a single trait.
Agreed, but this is just example code for me to understand my first application of trait bounds.
The real ADCs for AVR and RP2040 are different. In particular the 2 clocks are different types; in avr-hal it's an alias for a u32 if I remember well, and in rp2040-hal the clock is a complex type because rp2040 clocks are many, way more complex, and allow much more than the avr ones. So, the real code will need different types for "clock".
When I asked about simplify and make the code more readable I was thinking at some kind of type aliasing or ... go figure what...
let peripheral_adc_avr = Peripheral::<Adc<AvrAdc>>::new(0x2000, Adc::<AvrAdc>::new(AvrAdc { foo: 42 }, 16_000_000));
This kind of stuff tends too much to look like a Perl regexp. When I see all that, I KNOW there must be a better way to express the same thing.
type aAdc = Adc::<AvrAdc>;
type pAdc = Peripheral::<Aadc>;
let avr_adc = AvrAdc { foo: 42 };
let myadc = aAdc::new(avr_adc, 16_000_000);
let peripheral_adc_avr = pAdc::new(0x2000, myadc);
Ok, this is a substitute for the one-liner above. A bit more verbose but it seems to me easier to read.
In teory it should use the same amount of memory. Are there a handful of more bytes in the binary because of the 2 extra types?
No. Types don't exist at all at run time (except when things explicitly collect information about them, like Any, TypeId or derive macros), and type aliases (type X = Y) even more so because they are not themselves distinct types, just names for other types.
That stuff looks juicy: is it for introspection/reflection?
I tried to investigate introspection/reflection in the past days but I couldn't get a clue about it and gave up. I'd like for the mcu boards to report their capabilities to their host's counterpart; and the host being able to inject configuration or even code snipplets at runtime. In C/C++ I'd use an obnoxious and poisonous mix of preprocessor macros, RTTI and alike ... but in Rust it seems to be an highly debated topic and somewhat not fully in place. My current idea is to dig into Tock Embedded OS code, as it looks like its kernel made it possible. As soon as I'll have a bit more proficiency; currently I can't understand that code.
I need the adc.rs to be part of the general hal crate, to be built for different archs. And I need for the implementation to be in the atmega2560 lib in order to not include the arch-specific crates in the general hal crate. How do I solve this?
TypeId provides an opaque token which can be used to compare equality of concrete types. Any builds on TypeId to add dynamic-to-concrete downcasting support: if you have an &dyn Any, you can say “if this points to a String, give me a &String”.
You cannot downcast to a trait — in fact, you can almost never write any program that depends on answering the question “does this type implement this trait”, because that turns out to be hard to define in a way that gives a consistent answer (due to the possibility of generic bounds that depend on lifetimes, in particular).
Rust in general does not have general reflection / RTTI capabilities where you ask, at run time, what characteristics a type has. The typical substitute is derive macros, that look at the definition of a type at compile time and generate a trait implementation which exposes specific information about a type.
There are libraries that use traits and derive macros to build a more general reflection system, for types that opt in by implementing the trait — the one I have heard of is bevy_reflect, but I haven't used it myself.
The answer to problems like this is always to use a trait. And in this case, in order to satisfy trait coherence, you'll also have to create at least one type that is defined in youratmega2560 lib — this is to avoid conflicts where crates C and D both impl crate_a::Trait for crate_b::Type.
I feel like I should note that I don't have any experience with Rust embedded HALs. I've been giving you general Rust advice, but there's a lot of prior work on designing HALs that I can't tell you about because I haven't worked in that space.
Yes, I've spotted a few crates (and bevy_reflect as well) but it looked too complicated at this point in time. I'm already sinking in a ton of details I can't glue together still, and some of those are mission critical so... priorities...
From what I've seen I can easily write embedded code to send the host a string representation of some entities (types and variables, but not traits and functions/methods; if I remember well) but I couldn't realize how to do the opposite: receive a string representing a method to call... I'll see when it is time for it.
Can you elaborate a bit this, please? If I create a type (ie: integers, strings, tuples, structs or enums; right?) in the atmega2560 crate, then I must impl a trait from the same crate and ... I'm back at the starting point. No?
Regardless of how you set this up, in the end it will come down to a match or a HashMap, that maps strings to function pointers or closures. You could also perhaps find inspiration from how different Rust HTTP servers handle registering “routes”, though those are more complex matching.
If I create a type (ie: integers, strings, tuples, structs or enums; right?) in the atmega2560 crate, then I must impl a trait from the same crate
No, you can have impl Local for Foreign, impl Foreign for Local, and even impl Foreign<Local> for Foreign; the thing you cannot do is impl Foreign for Foreign. When I said you need to create a local type, I mean that you need to make a wrapper type so that the foreign type from your dependency is wrapped up a local type. The wrapper type might even have some use in itself, if there is any platform-specific additional state to carry (like you said 'the real code will need different types for "clock"').
But, (iirc), not impl Foreign<Foreign, Local> for Foreign; the orphan rules can get very confusing! Probably best not to get too complicated out of the gate.
If you want to add a method to a foreign type, it has to be a trait method, and either,
The trait is a local trait
The trait has type parameters and one of those parameters is a local type
You can also implement foreign traits for local types,[1] which is relevant here because new isn't really a method on the implementing type per se (it just has to create an value of a certain type). So you could perhaps[2] refactor things like so:
// crate atmega2560
pub struct AtmegaCreator;
pub type AtmegaAdc = Adc<atmega_hal::clock::MHz16>;
impl CreateAdc<AtmegaAdc> for AtmegaCreator {
fn create() -> MetaAdc<AtmegaAdc> {
let raw = todo!();
let clock = atmega_hal::clock::MHz16::FREQ;
MetaAdc::new(raw, clock)
}
}
Or maybe this Adc<_> shouldn't be a type and should instead be a trait bound on the Raw parameter of MetaAdc<Raw> where appropriate; it's hard to say without diving deeper. (Then atmega2560 could have their own local Adc-esque type).
That's also fine, unless Foreign is a generic type in the implementation. As long as the implementing type or a type parameter of the trait is a local type, and there are no conflicting blanket implementations by the trait owner, non-generic implementations are allowed.
Thanks, got it. In cheap words there must be some sort of local anchor to grant to the compiler peace of mind in its namespace.
Yep, just a couple of paths to fix and a 'pub' to add, and then it builds.
I need more time to figure out this; I need to sleep and I'm wiriting with one eye only.
I really feel I need to keep the code compact (ie: avoid uneeded types, traits, and so on) but from what I remember the atmega ADC must be polled so a single Fn read() is enough in the abstracted hardware trait. The rp2040 instead, have a "freewheeling" mode, dumping reads in a HW FIFO and shifting older values on new incoming values when the FIFO is full. So basically the rp2040 version needs extra logic in order to switch from the polled mode (ie: same of avr, Fn read() is enough) to the freewheeling mode (ie: start(), stop(), and a read() that reads from the FIFO; all this stuff is in the rp2040-hal but didn't look at details yet). Also the ADC's channels management differ and rp2040 have a round-robin feature... need to check all this in the datasheets tough.
In general: I can finally touch with my hands Rust's object-composing capabilities and it's awesome. I'm a bit concerned about all those tiny bits popping out everywhere (multiple crates, multiple types, multiple traits, ...); it makes me feel I will not be able to keep track of all those bits in my mind while working on the project. It's something I experienced with C and the reason why I've always switched to C++ whenever I could; Rust seems to have less kinds of bits, but more bits, apparently unrelated, and dancing everywhere in the code base.
I woke up this morning a bit nervous because of this stuff and I'm going to sleep happy thanks to you all. You made my day!
I've found that in Rust, it's very often useful to create more types (structs, enums) than I might in another language — but many of them can be kept tucked away in just the module that uses them, so they don't much affect the high-level complexity of the program. Still a tradeoff, of course, but implementation complexity and interface complexity are different things.