I've been kicking around an idea for a while now to create a CAN bus communication stack for Rust, and now it is finally taking shape. It's still a work-in-progress, but I'm getting ready to publish a rough prototype, so I want to write about what I want it to be. I'm calling it Zencan, and it's an implementation of the CANOpen protocol, with a few planned extensions.
The repo for zencan is at https://github.com/mcbridejc/zencan.
An example project using zencan can be found at https://github.com/mcbridejc/can-io-firmware
Background
What's a CAN bus?
The Controller Area Network (CAN) bus, first popularized in automotive applications, is widely used for industrial automation and motion control. If you've ever used an OBD-II reader to read engine error codes, you were using a CAN bus. I've personally used it in UAVs and on rocket engine controllers, but I feel that it does not get as much use in the open-source world as it deserves, so I want to make CAN easy with Rust in hopes that it will encourage more use.
The CAN bus operates on a single differential pair, connected to all of the nodes. This means wiring multiple devices requires only two wires! It also means a single connection point can be used to plug in a computer and monitor and control all of the MCUs on the bus. It keeps wiring easy.
Many MCUs come with CAN controllers, and they handle a lot of the communications overhead without any CPU involvement. For example, bus arbitration, errors and re-transmission, prioritized message queuing, and received message filtering are all often supported by hardware.
CAN is fairly slow, with traditional CAN 2.0 busses running at a max bitrate of 1 Mbit/s. However, these days some MCUs are shipping with CAN-FD capable controllers, and I intend for Zencan to support CAN-FD. A CAN-FD bus allows for longer messages, and higher bitrates -- usually up to 5-8 Mbit/s, although I have heard of buses as fast as 20 Mbit/s.
The CAN protocol includes framing, so everything is sent as a message. A message has an ID, and a data payload. The ID is used to identify what's in the message, and also implicitly, who it's for (e.g. here is where message filtering comes in: if a node on the bus knows which messages it is interested in, it can setup hardware filters to drop any others). I think this can be confusing sometimes, so I will restate: generally, nodes on a CAN bus do not have addresses, or IDs as a node. Instead, the messages are tagged with IDs. If one wants to assign addresses to specific nodes, this has to be built on top of the CAN protocol!
Why I like CAN
- It is smaller and cheaper than Ethernet.
- On projects which have multiple MCUs, it is convenient to plug into a single bus and talk to all of them.
- There are standard tools for monitoring and plotting data on a CAN bus, with tools like SavvyCAN and the DBC file format one can easily plot data with no code.
- It is robust! With its low speed, smart controller hardware, and differential signalling, in my experience CAN is very tolerant of less-than-ideal wiring scenarios.
Zencan Goals
Rust
Zencan is built in Rust because that's what I'm into lately, and there's a lack of CANOpen stacks out there for Rust.
I've been writing C/C++ for embedded systems a long time, and although there are definitely some trade-offs, I see Rust as the path forward for me. Look, if you catch me at the wrong time -- like when I've just spent an hour appeasing the borrow checker, or am frustrated by difficulty viewing static variables in a debugger -- I might offer a different opinion. But on the whole...
Configurability
I want configurability! I want to be able to build a set of components, put them all on a CAN bus, and make them talk to each other, without having to change their firmware to do it. One way that a CAN bus has traditionally been designed is to keep a "master spreadsheet" of all the data that has to be transferred on the bus (motor temperature, motor current, velocity command, etc), cram these all into a set of messages, assign them each a CAN ID, and then write the appropriate software for each node to send and receive the relevant messages. This is fine, but I want to grab a generic motor controller board, and a generic IO board with an analog joystick plugged into it, and then configure the joystick to command the motor controller without modifying the software.
Observability
I want to standardize interactions one might want to have with MCUs on a bus so that I can solve them well once and re-use it. I want to plug into the bus and:
- Report all the devices on the bus
- Program any device with new firmware
- Trivially plot values being generated by that device over time, or log for plotting later
- View metadata about the device such as software versions, serial numbers, etc
Ease
A lot of code is required to get all of that, but I shouldn't have to think about it every time I start a new project. I should just be able to instantiate a node that works with a basic configuration file and a small amount of boilerplate.
Architecture
CANOpen
There is an existing protocol, built on top of CAN, which supports a LOT of what I want to do. It's called CANOpen. I think that Zencan is going to actually be a Rust implementation of a CANOpen stack, with some extra features layered onto it. I have some concerns about this, like the fact that CANOpen is managed by the CiA, which restricts access to the specification documents to members only. And even if you manage to find these documents, you may not find them as helpful or clear as you would like. But, the concept of the Object Dictionary and the Electronic Data Sheet (EDS) describing the objects in it, combined with the ability to map arbitrary object data into PDO messages, solves a lot of the configurability and observability requirements. CANOpen isn't exactly what I would design from scratch, but it is pretty close and has the benefit of being an established standard with existing devices and software tools. And, as far as I can find, there isn't yet a full-featured, mature CANOpen stack available for Rust. So I'm going to start here, and see if I can get everything I want out of it while maintaining CANOpen compatibility.
Specific Goals
Specific goals of the project are:
- Support creating nodes on embedded (
no_std
) or linux targets - No heap; all data statically allocated.
- Be CANOpen compatible
- Support device discovery
- Easy node configuration and object definition via a "device config" TOML file
- Support CAN-FD
- Support bulk data transfer -- e.g. a device I am targeting is an e-ink display, which requires transferring frames of pixels
- Standardized device software updates and version reporting
- A GUI and CLI interface for device management via socketcan
Configuration Files
I am still hashing this out, and it may change!
There is a file format called EDS, or Electronic Data Sheet, which is used to describe the objects in a CANOpen object dictionary. I initially started out using an EDS file as an input to the code generation, but found that this was not ideal. For one thing, the format is fairly denormalized/redundant, which means that it can have inconsistencies and be difficult to edit manually. For another, I want the ability to specify Zencan specific options, and had no (good) way to work that into an EDS file. So instead, I've created a TOML schema for device configuration files, and EDS files will be generated as an output to be used with tools which support them.
Static Data, shared between objects and threads, without heap
The first challenge was sharing data between different contexts. Maybe it's just code that doesn't
know each other (e.g. the zencan-node
crate, and user's application code) sharing access to the
object dictionary. Maybe it is really on other threads. In a situation with alloc
available, one
might simply use an Arc
to wrap the shared objects. Without Arc, we have to pass references. This
quickly can lead to lifetime hell. Instead of managing these lifetimes with lots of generics, I
decided to require many of the data structures to be static. In the embedded context, this is almost
always the desired case anyway, and only one instance of a node is expected to exist. In other
contexts, like hosting a node on linux, or in tests, this may not be the case. These can be
addressed using Box::leak
, to make a heap allocated object static.
Dynamic Code Generation
Some code will be generated in build.rs, using the zencan-build
crate, based on a TOML file in the
project. This is somewhat similar to the way that the CANOpenNode
C stack does it, but the EDS file, and more specifically the C#
application used to edit the EDS file generate a C
source and header file for inclusion in your application. I think that rust tooling will provide a
good mechanism to auto-generate this code as part of the build process. It is still auto-generated
code, and that has its downsides -- especially readability -- but I don't see how to get around it.
The generated code is saved to the compilation OUT_DIR, its name stored in an env var, so that it
can be included via a macro somewhere in the application. This concept is modeled after
slint, which does a similar thing for
including generated code from a .slint
file which defines the GUI.
In build.rs, the code is generated from the device config, with a name (e.g. 'EXAMPLE1'):
fn main() {
if let Err(e) = zencan_build::build_node_from_device_config("EXAMPLE1", "device_configs/example1.toml") {
eprintln!("Error building node from example1.toml: {}", e);
std::process::exit(1);
}
}
Then in your application, the generated code can be included wherever you like as:
zencan_node::include_modules!(EXAMPLE1);
The name allows multiple nodes to be instantiated in a single application, although I do not yet have a use in mind for this, other than tests.
Here's an example of what the generated code looks like, which might help understand what's being generated:
#[allow(dead_code)]
#[derive(Debug)]
pub struct Object1000 {
pub value: AtomicCell<u32>,
}
#[allow(dead_code)]
impl Object1000 {
pub fn set_value(&self, value: u32) {
self.value.store(value);
}
pub fn get_value(&self) -> u32 {
self.value.load()
}
const fn default() -> Self {
Object1000 {
value: AtomicCell::new(0i64 as u32),
}
}
}
impl ObjectRawAccess for Object1000 {
fn write(&self, sub: u8, offset: usize, data: &[u8]) -> Result<(), AbortCode> {
if sub == 0 {
if offset != 0 {
return Err(zencan_node::common::sdo::AbortCode::UnsupportedAccess);
}
let value = u32::from_le_bytes(
data
.try_into()
.map_err(|_| {
if data.len() < size_of::<u32>() {
zencan_node::common::sdo::AbortCode::DataTypeMismatchLengthLow
} else {
zencan_node::common::sdo::AbortCode::DataTypeMismatchLengthHigh
}
})?,
);
self.set_value(value);
Ok(())
} else {
Err(AbortCode::NoSuchSubIndex)
}
}
fn read(&self, sub: u8, offset: usize, buf: &mut [u8]) -> Result<(), AbortCode> {
if sub == 0 {
let bytes = self.get_value().to_le_bytes();
if offset + buf.len() > bytes.len() {
return Err(
zencan_node::common::sdo::AbortCode::DataTypeMismatchLengthHigh,
);
}
buf.copy_from_slice(&bytes[offset..offset + buf.len()]);
Ok(())
} else {
Err(AbortCode::NoSuchSubIndex)
}
}
fn sub_info(&self, sub: u8) -> Result<SubInfo, AbortCode> {
if sub != 0 {
return Err(AbortCode::NoSuchSubIndex);
}
Ok(SubInfo {
access_type: zencan_node::common::objects::AccessType::Const,
data_type: zencan_node::common::objects::DataType::UInt32,
size: 4usize,
pdo_mapping: zencan_node::common::objects::PdoMapping::None,
persist: false,
})
}
fn object_code(&self) -> zencan_node::common::objects::ObjectCode {
zencan_node::common::objects::ObjectCode::Var
}
}
pub static OBJECT1000: Object1000 = Object1000::default();
pub static NODE_STATE: NodeState<4usize, 4usize> = NodeState::new();
pub static NODE_MBOX: NodeMbox = NodeMbox::new(NODE_STATE.rpdos());
pub static OD_TABLE: [ODEntry; 31usize] = [
ODEntry {
index: 0x1000,
data: ObjectData::Storage(&OBJECT1000),
},
]
Each object gets a struct defined, and an implementation of the ObjectRawAccess
trait. All objects
are instantiated statically, and stored as a table in OD_TABLE
. This is the object dictionary.
NODE_STATE
includes some static state information used by the instantiated Node, and NODE_MBOX
provides a Sync data structure for pass received messages, so that messages can be received in an
IRQ handler.
Threading
The expectation is that a single thread will own the Node
object, and that most of the node
behavior will happen on this thread in the form of calls to Node::process
. A separate object, the
NodeMbox
allows for reception of messages on another thread. The expected use for this is to push
received messages into the mailbox object in an IRQ handler. The object dictionary is Sync, using
the critical_section crate for
protecting data access. Critical section will allow embedded applications to implement critical
sections by disabling interrupts, and linux applications to do so using global locks.
I tried to use crossbeam's AtomicCell for atomic access, but it does not currently support thumbv6 (i.e. Cortex M0) targets at all, because these targets lack CAS support, and I intend to use this on M0 targets.
The object dictionary, and all of the objects are Sync, as they may need to be accessed by various application code running in various thread contexts.
Object Storage vs Callback
Zencan supports two object types:
- Storage objects have statically allocated storage for their data and support simple read/write operations
- Callback objects rely on callback functions to implement their access, allowing for validation and dynamic data handling during read or write
Control tools
The zencan-cli
crate comes with a REPL-style shell for interacting with devices over socketcan in
real-time. A GUI version of this is planned as well.
Summary
I consider Zencan now as a prototype, and still evolving. It needs more examples, documentation, and I need to implement it in more devices to flesh things out. There are still important features missing, and I expect some churn on the architecture/API. I hope over the next few months to integrate it into a few more projects, while continuing to develop it into something that someone besides myself might want to use! There are a few loose ends to tie up before I push a first release to crates.io, but I expect to be doing that soon.