That is what I've observed too. My impression is that people go to RTOS for libraries and dependency management, which you kinda just get out of the box with Rust, cargo and crates.io.
A lot of applications simply don't use the MPU. And then consider what you get from Rust memory safety and the reduction in overall complexity of the firmware without the RTOS.
One of the things not immediately apparent for people coming to Embassy is that you can mix and match RTIC with the Embassy HALs. So the more appropriate comparison is RTIC vs the Embassy async executors.
You might want to take a look at the embassy project for a unified stm32 HAL. The idea is that it defines the APIs per peripheral version as defined by STM (spi v1, spi v2, usart v1 etc). The advantage is that a given peripheral can be used across all stm32 families with that version. This makes maintaining a HAL and keeping a unified API much simpler.
The other part is the stm32-metapac (not specific to async) that generates the PAC for any stm32 chip.
Having distinct types for P0 and P1 is deliberate and is what is called "type state programming" in the embedded rust book [0]. The advantage is that you can prevent misconfiguration at compile time (ensuring that a given pin cannot be used in multiple places). In the Zig example, it seems to me (and I have zero knowledge of Zig, so sorry if this is inaccurate) that you can potentially introduce bugs where the same pin is used twice.
For a generic led driver, it should not use these types, but instead the trait types from the embedded_hal crate, such as "OutputPin" that is implemented by the different chip-specific HALs. There is an example of a generic led driver that uses these traits at [1].
In general I recommend everyone who wants to try out Rust on embedded to read the embedded rust book, because it clarifies a lot of the reasons and advantages of its approach.
Author here. I agree that the Rust embedded books are a nice read, and the idea of type state programming --- taking advantage of Rust's ownership and generics system to enforce at compile time transitions between logical states via "zero-sized types" --- is interesting and could be useful in some contexts.
However, that is not what is happening here.
P0 and P1 are distinct types because they are distinct hardware registers.
I think it's great that they're modeled as distinct types; the problem is simply that Rust makes it difficult to conceptually iterate over distinct types (regardless if such iteration occurs at runtime via a loop or at compile-time via an unrolled loop, as per Zig's `inline for`).
An aside about "type state programming": Microcontrollers have a lot of functionality packed into the pins (see the STM32 "Alternate Function" datasheet tables).
Trying to model all of that using ownership of zero-sized generic types would strike me as a "when all you have is a hammer"-type situation.
If a single pin switches between, for example, high-impedance, gpio low, and PWM output depending on what mode your firmware is in, I suspect it'd be a nightmare to pass owned types around Rust functions --- one would have a much easier time (and more likely to be correct) if they checked their design using something like TLA+ / Alloy or implemented the firmware using an explicit statecharts runtime like Quantum Leap's QP framework https://www.state-machine.com/.
Even if you didn't have Output Pin, couldn't you just declare a sum type?
enum MyPin { P0, P1 }
Edit: feel free to ignore, read your answer somewhere else about this
You would then have to pattern match when you read the value but I don't see a reason to reach for macros or anything more complicated.
That said, really enjoyed the read (and I'll definitely try zig at some point, if only for the speed / compile experience), even if my experience with Rust didn't match yours; my background is a bit different though, I worked with C++ and Haskell in the past, which definitely made rust feel almost natural.
Overall I'd say that the compiler helps me not to keep a lot of rust syntax in my mind and just try things until it works
>An aside about "type state programming": Microcontrollers have a lot of functionality packed into the pins (see the STM32 "Alternate Function" datasheet tables). Trying to model all of that using ownership of zero-sized generic types would strike me as a "when all you have is a hammer"-type situation.
I second this. The idea of checking that a pin is "only used in one place" doesn't really jive with how I think about microcontroller programming. It's very common for one pin to be used for multiple distinct purposes at different times.
There's also a lot of different ways of conceptually slicing pin states. For example, if you are charlieplexing LEDs than you'll switch pins between 'input' (high impedance) and 'output' modes, but at a higher level the pin is serving a single function.
> The idea of checking that a pin is "only used in one place" doesn't really jive with how I think about microcontroller programming.
The borrow checker is not checking that the pin is used in "only one place", it is checking that you don't use the same pin for two different purposes at the same time.
It make sure that you configure your pin as output pin before using it as an output pin, and that you reconfigure it to input pin when using it as such.
(And there are some escape hatch to use when the type system is not sufficient to express that different code paths are disjoint, like RefCell, with runtime check, or unsafe)
Borrow checker tracks who is using what over time. The can prevent concurrency and uncoordinated mutation, use after free type problems.
Type system checks how it is being used.
Both are tools and can used to help ensure a correct program. It really comes down to how these _tools_ are used to help the programmer and the team deconstruct and manage a system to solve a problem.
I think petgraph [1] is an excellent example of relaxing some of the constraints imposed by the tools (borrow checker, type system) to make a system that was easier to use and extend. These things are much more continuous than we realize, it isn't all or nothing.
In a lot of ways, I wish Rust's complexity was a little more gradual, or that we knew how to use it in a gradual way. Use of Rust features encourages C++ levels of complexity. Use of Zig features encourages C-ish levels of complexity.
Zig is to C
as
Rust is to C++
I also think the author had a much better model of the system and the hardware and what they wanted to accomplish during the rewrite and could better project the problem domain and the language together.
Learning Rust and the problem domain at the same time is extremely difficult and in a way leads to a perversion of both.
What do you think about modeling the hardware as a "Resource" register, port, memory, etc. Then modeling a type over a collection of resources.
The question that I would ask myself when trying to use Rust and the features it has out of the box is, "How much fine grain rigor do I want Rust to model for me?" For the keyboard scanning code, in asm or C, one might just have a function `get_keyboard_state(*keyboard_buffer)` but this exposes a sampling problem and would require the programmer to determine state transitions. So maybe a channel or an iterator would be better. Then we might nee to run it in an ISR, the hardware it uses might be multiplexed with other parts of the system, etc.
Every Rust feature needs to be weighed, or rather, given a complexity budget, every Rust feature needs to be purchased.
Zig is awesome BTW, but it doesn't make the same correctness guarantees that Rust can.
> Borrow checker tracks who is using what over time.
This is a very imprecise statement. Do you mean tracks at "compile time" or at "run time"?
A more accurate statement would be -- the borrow checker enforces a specific set of rules and constraints at _compile time_ only. But this set of constraints guarantees memory safety at run time (with the exception of unsafe code). In fact, Rust's runtime is minimal -- it handles panics but has no GC or other bells and whistles. The fancy things are in libraries.
Ah, I was going on what the OP said ("ensuring that a given pin cannot be used in multiple places").
That seems sensible, but also not particularly valuable. A lot of the time it makes sense both to 'read' and 'write' from a pin (e.g. if it's open-drain with a pullup).
> It's very common for one pin to be used for multiple distinct purposes at different times.
Anecdotal, but as someone who works in this space I haven't found this to be the case. In my experience, any particular pin is wired up for a specific purpose, and so the firmware usually just sets it to that mode as appropriate. Generally if it's found that the needed peripherals couldn't be multiplexed to pins without conflicts, it's time to move up to a package with more pins brought out.
I'm currently working on a relatively involved firmware for ATSAMD21 in Rust, and have mostly enjoyed the experience. While some of the language concepts have taken me a while to get comfortable with, and we're still figuring out parts of the ecosystem, it's quite usable and the tooling is a huge improvement over anything else I've seen.
I agree that iterating over types of a tuple is indeed not easy, but in that case, it should be trivial to iterate over an array of `&dyn OutputPin`. Why is that not working in this case?
Interesting write-up! I've barely used Rust but had/have a similar feeling. It's really more akin to C++ and really powerful but also pretty complex. For smaller MCU projects it just feels like overkill.
> Microcontrollers have a lot of functionality packed into the pins (see the STM32 "Alternate Function" datasheet tables). Trying to model all of that using ownership of zero-sized generic types would strike me as a "when all you have is a hammer"-type situation.
The whole idea of utilizing TLA+ for a system level check really does seem like something that would be awesome, even if it's unclear how much effort it'd require to instrument an entire project with TLA+.
> the problem is simply that Rust makes it difficult to conceptually iterate over distinct types (regardless if such iteration occurs at runtime via a loop or at compile-time via an unrolled loop, as per Zig's `inline for`).
Rust just brings a lot of incidental complexity along and still makes some things really difficult. Perhaps it's better in the long run but it's just harder to work with.
Similarly, I wanted a simpler language than Rust and started using Nim last summer for embedded projects. Primarily since it compiles to C which let's me target ESP32's and existing RTOS'es without rewriting everything or trying to get LLVM based tools to work with GCC ones. However, it also embraces `lazy` compilation approach to code and it's standard library.
I wanted to try your example in Nim. Here's roughly how your example would look in Nim (it appears to duck type fine as well):
var
# normally just import these from the C headers as-is
# but this lets us run it
p0* : RefPort0 = addr port0
p1* : RefPort1 = addr port1
var rows = (
( port: p1, pin: 0 ),
( port: p1, pin: 1 ),
( port: p1, pin: 2 ),
( port: p1, pin: 4 ),
)
var cols = (
(port: p0, pin: 13 ),
(port: p1, pin: 15 ),
...
(port: p0, pin: 2 )
)
proc initKeyboardGPIO() =
rows[0].port.pin[rows[0].pin].dir = output
for item in rows.fields:
item.port.pin[item.pin].dir = output
I've toyed with the thought of adding TLA+ hooks into Nim similar to Dr Nim (https://nim-lang.github.io/Nim/drnim.html) using the effect system. Not sure if Zig has an effect system for a similar setup.
> In the Zig example, it seems to me (and I have zero knowledge of Zig, so sorry if this is inaccurate) that you can potentially introduce bugs where the same pin is used twice.
Given the code in the blog post, yes. Here's a possible solution:
pub fn initKeyboardGPIO() void {
comptime checkPinsAreUnique(10, rows);
comptime checkPinsAreUnique(100, cols);
...
}
fn checkPinsAreUnique(max_pin: usize, elems: anytype) void {
var seen = [1]bool{false} ** (max_pin + 1);
inline for (elems) |x| {
if (x.pin > max_pin) {
@compileError("Found pin value higher than max pin");
}
if (seen[x.pin]) {
@compileError("Found duplicate pin!");
}
seen[x.pin] = true;
}
}
There's also other ways of approaching the implementation depending on the required level of dynamicism, I just hacked together the quickest solution I could think of.
Would it be correct to describe this as using comptime to enforce system level constraints? To my naive understanding it looks like comptime combined with type state programming gives one user definable type systems.
What is checked at compile-time in Zig is up to the Zig code. It's a little hard to explain because this doesn't work like Lisp (or Rust) macros, but, since Zig is so easy to learn -- despite this revolutionary design -- should mean it's not a problem. As a first approximation (somewhat inaccurate), you could think of Zig as an optionally typed dynamic language that can run introspect (and create) types freely, perform elaborate checks on them etc. (e.g. examine fields and their types, and compare them to other types' fields), and then the programmer gets to say: run these checks at compile-time and make errors compilation errors.
It's not about what Zig has but what it doesn't have. Because low-level programming is already complex, language simplicity is a super-important feature that few low-level languages have, and I would say none that are expressive and emphasise safety -- except Zig.
You could do those things in C++ with template games and in Rust with macros. But Zig lets you have immense expressivity with a simple, small and easy-to-learn language.
> You could do those things in C++ with template games and in Rust with macros. But Zig lets you have immense expressivity with a simple, small and easy-to-learn language.
const fn is (or seems to me to be) exactly what comptime is though. The difference is that rust's const syntax is still slowly allowing more things to be executed at compile time. Like for now, it still can't do any heap allocation.
Zig's unique power and killer feature isn't having comptime; it's having little else. That's a feature C++ or Rust or D or Nim simply can never, ever have, and it's an extremely important feature, especially in low-level programming. You can do in C++ anything you can do in Zig; but you can't do those things in a simple language you can quickly learn and fully grasp.
Take this with a grain of salt but from the little examples I've seen it looks like a nightmare for any type of large application. I would much rather have increased power in the type system rather than having arbitrary code run and fail builds in an ad-hoc fashion.
It isn't "arbitrary code." It is strictly less arbitrary and ad-hoc than Rust's macros. You can think of it more as a programmable type system, although that, too, is not very precise. As to maintenance of large codebases, it is far too early to tell, of course, but note that no low-level programming language has a great record on that front. I think it is because components in such languages are much more sensitive to the implementation details of others (i.e. all those languages have very low abstraction, i.e. the ability to hide and isolate internal implementation details), but low-level programmers know this comes with the territory, and is part of the price you pay for a high-level of control over low-level details.
> It's not about what Zig has but what it doesn't have. Because low-level programming is already complex, language simplicity is a super-important feature
This is what made me love Lua for embedded programming. The more inherent complexity (or "exposed complexity" might be a better phrase) in the system, the less inherent complexity you want in the language.
Doesn't sound like a problem that's worth trying to resolve at compile time through the type system to me. You complicate the common case for a relatively minor benefit.
In Zig I think you can get 99% of the benefit by setting up a framework where you allocate a resource (pin, ppi channel, etc) through a function call. We use this for a testing framework which gives you run-time errors. But with Zig you could probably write this in a way that gives you compile-time errors for statically allocated resources. That should give you a system that works in both compile and run time.
Yeah, you can't totally guarantee that a pin isn't allocated, since a programmer can use the pin without calling the resource allocation function. But I feel like that takes you from 99.99% safe to 99.9999% .. worth in in a few obscure applications, but not in most.
It's not like I've ever seen any issues from allocating a pin twice in embedded programming. On nRF I guess PPI channels is a more relevant use-case. But then you could very quickly find that you need a more dynamic system that can only detect errors at run-time anyway.
There's a tradeoff between catching errors at compile time as you describe, and code flexibility. For example, here's a line from a current project using one of the HALs:
The pin types here are due to this type of programming. They aren't used by the I2c peripheral; they're just for the check.
If you only use a peripheral struct (eg i2c here) in the main function, the type state system makes sense. If you pass it across function boundaries, or use statically like, this, it may not be. The rust HALs and tutorials that use this pattern tend to leave function boundaries etc (where you need to explicitly declare types) out of examples.
I'll add that the AMQP 1.0 spec (supported in Rabbit using a plugin) is a peer-to-peer protocol that supports both the traditional broker use case, 'direct' p2p messaging and opens some interesting uses of message routers like Apache Qpid Dispatch Router.
Re proton: I tried to use it in point-to-point mode, but haven't been able to figure out how; Javadoc reference is useless for that. There exist only Python examples but Python APIs don't map 1:1 to Java APIs.
For Java, have a look at vertx-proton which builds on top of proton-j and is a bit more intuitive (still not great) than proton-j for creating servers and clients. :)
A lot of applications simply don't use the MPU. And then consider what you get from Rust memory safety and the reduction in overall complexity of the firmware without the RTOS.