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
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.