My recent work has been around Cortex-M embedded development using Rust and RTIC. I'm using a handy little development board in the form of the STM Nucleo-F401RE. Unfortunately, it's handiness stops as soon as you want to communicate with TCP over WiFi, because it lacks WiFi.

There also exists another board (using an Xtensa chip) called an ESP8266.

The ESP is nice in that it contains a stock firmware that responds to Hayes AT commands (like a modem) and can do networky types of things. Then again, the ESP uses Hayes AT commands, which are ASCII-like, across a serial port, which is decidedly less networky feeling from the Rust end of the stick.

embedded-nal (or Drogue-Network)

There exists an unpublished Rust crate called embedded-nal. The "nal" stands for "Network Abstraction Layer". This crate acts as an API and contains Rust traits that can be backed by implementations. Consider it to be akin to the socket-related bits of the POSIX standard. By itself, it does nothing. But with an implementation, higher-level drivers can be written regardless of the underlying networking stack.

Since embedded-nal is not-yet-published, I've taken a non-agressive fork and published it as the drogue-network crate.

Let's look at the traits...

I'm initially only concerned wtih TCP, even though the crate also defines a UDP trait.

/// This trait is implemented by TCP/IP stacks. You could, for example, have an implementation
/// which knows how to send AT commands to an ESP8266 WiFi module. You could have another implemenation
/// which knows how to driver the Rust Standard Library's `std::net` module. Given this trait, you can
/// write a portable HTTP client which can work with either implementation.
pub trait TcpStack {
	/// The type returned when we create a new TCP socket
	type TcpSocket;
	/// The type returned when we have an error
	type Error: core::fmt::Debug;

	/// Open a new TCP socket. The socket starts in the unconnected state.
	fn open(&self, mode: Mode) -> Result<Self::TcpSocket, Self::Error>;

	/// Connect to the given remote host and port.
	fn connect(
		&self,
		socket: Self::TcpSocket,
		remote: SocketAddr,
	) -> Result<Self::TcpSocket, Self::Error>;

	/// Check if this socket is connected
	fn is_connected(&self, socket: &Self::TcpSocket) -> Result<bool, Self::Error>;

	/// Write to the stream. Returns the number of bytes written is returned
	/// (which may be less than `buffer.len()`), or an error.
	fn write(&self, socket: &mut Self::TcpSocket, buffer: &[u8]) -> nb::Result<usize, Self::Error>;

	/// Read from the stream. Returns `Ok(n)`, which means `n` bytes of
	/// data have been received and they have been placed in
	/// `&buffer[0..n]`, or an error.
	fn read(
		&self,
		socket: &mut Self::TcpSocket,
		buffer: &mut [u8],
	) -> nb::Result<usize, Self::Error>;

	/// Close an existing TCP socket.
	fn close(&self, socket: Self::TcpSocket) -> Result<(), Self::Error>;
}

Basically it boils down to being able to:

  • open
  • connect
  • write
  • read
  • close

As with many abstraction crates, this leaves some types relatively undefined, for the implementation to choose. In this case, the TcpSocket type and the Error type are both implementation-defined. From the point-of-view of the TcpStack trait, both of those types are opaque.

Interior mutability

As we know from Rust, methods that take &self are immutable, while those that take &mut self are mutable. This trait defines purely non-mutable (in relation to self) methods.
But surely the implementation needs to do some book-keeping when opening/connecting/closing sockets, which sounds like mutability.

This is a sure sign we probably need interior mutability.

Rust gives us the RefCell<T> wrapper that allows just that. Calling an immutable method on an immutable object is allowed, and internally the method, at runtime gets a mutable reference to something that does mutable work.

We'll return to that in a moment.

Let's talk to our board...

Before we can implement a TcpStack, we need to be able to just have a conversation with our ESP8266 as it sits connected to our serial USART pins.

As we discussed in our last post, this involves some board-specific setup, where we:

  • get the transmit and receive pins, and convince our F401RE that we want to use them for USART communication.
  • get our pins which are connected to the ESP's enable and reset pins, and convince our F401RE that we want to be able to push them high or pull them low.
  • use some of those pins to setup a Serial port for USART6 running at 115,200bps.
  • enable notifications for the RXNE (receive register not empty; data is ready for us) interrupt.
  • and then split the serial port into 2 halfs: transmit and receive.
// SERIAL pins for USART6
let tx_pin = pa11.into_alternate_af8();
let rx_pin = pa12.into_alternate_af8();

// enable pin
let mut en = gpioc.pc10.into_push_pull_output();
// reset pin
let mut reset = gpioc.pc12.into_push_pull_output();

let usart6 = device.USART6;

let mut serial = Serial::usart6(
    usart6,
    (tx_pin, rx_pin),
    Config {
        baudrate: 115_200.bps(),
        parity: Parity::ParityNone,
        stopbits: StopBits::STOP1,
        ..Default::default()
    },
    clocks,
).unwrap();

serial.listen(nucleo_f401re::hal::serial::Event::Rxne);
let (tx, rx) = serial.split();

But right now all we have is a generic serial port pushing bytes back and forth, without any semantics applied. Thankfully, we've created an ESP8266 driver, though, which can apply some semantics and gives us an easier-to-use way to interact. The driver crate gives us an initialize(...) free function which consumes both halves of the serial port, along with the enable and reset pins, plus two queues.

Why two queues?

The ESP communicates over the serial port in 2 ways:

  1. command/response
  2. unsolicited messages

These responses and messages will be created from within the interrupt handler from bytes that have arrived and been interpreted, but consumed elsewhere. Using a heapless Queue allows us to have lock-free Producer and Consumer to shuffle messages between the contexts.


static mut RESPONSE_QUEUE: Queue<Response, U2> = Queue(i::Queue::new());
static mut NOTIFICATION_QUEUE: Queue<Response, U16> = Queue(i::Queue::new());

let (adapter, ingress) = initialize(
    tx, rx,
    &mut en, &mut reset,
    unsafe { &mut RESPONSE_QUEUE },
    unsafe { &mut NOTIFICATION_QUEUE },
).unwrap();

Now we are holding two objects: an adapter which is the user-facing client for interacting with the esp8266 wifi adapter, an an ingress which can be used from interrupt service routines to process inbound bytes.

Wiring up the interrupts

As noted above, we're using RTIC. RTIC provides a place to do your initialization, and an easy way to wire up interrupt handlers and scheduled tasks, with priorities. It also provides a way to share resources between these different contexts. So at the end of our initializtion process, we stuff the objects into the shared-resources object and return it:

init::LateResources {
    adapter: Some(adapter),
    ingress,
}

Ingress bytes

When commands are transmitted (via our client adapter), the ESP8266 will trigger the USART6 interrupt for every byte that gets sent back to us.

Thankfully, our ingress object is designed to accept those bytes, so it's quick to wire it up to the interrupt:

#[task(binds = USART6, priority = 10, resources = [ingress])]
fn usart(ctx: usart::Context) {
    if let Err(b) = ctx.resources.ingress.isr() {
        info!("failed to ingress {}", b as char);
    }
}

With RTIC, the highest priority task using a resource can use it lock-free, because it can interrupt any other task. So we just call the isr() method on our ingress which reads a byte and adds it to an internal buffer. Interrupt service routines should be fast, because they might be called a lot. In this case, for every byte that arrives at potentially 115,200bps.

Process bytes

Since the ingressing of bytes needs to be fast, all it does is put it on a buffer and return. But at some point, we need to digest those bytes and determine if they are meaningful, or if we're still waiting on more.

For this digesting, we set up a recurring scheduled task, which we schedule the first time from our initialization, and then it infinitely reschedules itself.

const DIGEST_DELAY: u32 = 100;

#[task(schedule = [digest], priority = 2, resources = [ingress])]
fn digest(mut ctx: digest::Context) {
    ctx.resources.ingress.lock(|ingress| ingress.digest());
    ctx.schedule.digest(ctx.scheduled + (DIGEST_DELAY * 100_000).cycles())
        .unwrap();
}

It's using the same ingress resource, but at a lower priority than the USART, so when it fires, it could conceivably be interrupted by the USART interrupt. By locking the ingress object, we can disable that interrupt for a moment and call our digest() method.

The digest() method attempts to parse the internal buffer, and figures out if it represents a response to a previously-issued command or an unsolicited message, and if so, it builds a Response object and puts it on the appropriate Queue using its Producer.

Where's the WiFi, bucko?

Yeah, we're still not doing WiFi or sockets, are we?

Let's do that now.

In the idle portion of the app, we can use the adapter and magically transform it into a TcpStack implementation.

First, since we're going to be transforming our adapter into something else, we'll be mutating it. So we have to take() it from the Some(T) that is holding it on the shared resources, which replaces it with a None.

Next we use it directly to connect to our WiFi. Behind the scenes, calling 'join(...) for instance will transmit an AT command, and the response will come back through the USART interrupt and be digested by the digest task, seemingly in a multi-threaded sort of way. It's not really multi-threaded, the processor just keeps iterrupting our idle code and itself until a response occurs and our idle code is allowed to proceed.

Finally we can into_network_stack() our adapter, which consumes the adapter and gives us back a TcpStack implementation. Hooray!

#[idle(resources = [adapter])]
fn idle(ctx: idle::Context) -> ! {

    let mut adapter = ctx.resources.adapter.take().unwrap();

    let result = adapter.get_firmware_info();
    info!("firmware: {:?}", result);

    let result = adapter.join("oddly", "mywifipassword");
    info!("joined wifi {:?}", result);

    let result = adapter.get_ip_address();
    info!("IP {:?}", result);

    let network = adapter.into_network_stack();
    info!("network intialized");

    let socket = network.open(Mode::Blocking).unwrap();
    info!("socket {:?}", socket);

    let socket_addr = SocketAddr::new(
        IpAddr::from_str("192.168.1.245").unwrap(),
        80,
    );

    let mut socket = network.connect(socket, socket_addr).unwrap();

    info!("socket connected {:?}", result);

    let result = network.write(&mut socket, b"GET / HTTP/1.1\r\nhost:192.168.1.245\r\n\r\n").unwrap();

    info!("sent {:?}", result);

    loop {
        let mut buffer = [0; 128];
        let result = network.read(&mut socket, &mut buffer);
        match result {
            Ok(len) => {
                if len > 0 {
                    let s = core::str::from_utf8(&buffer[0..len]);
                    match s {
                        Ok(s) => {
                            info!("recv: {} ", s);
                        }
                        Err(_) => {
                            info!("recv: {} bytes (not utf8)", len);
                        }
                    }
                }
            }
            Err(e) => {
                info!("ERR: {:?}", e);
                break;
            }
        }
    }
}

Dig into the Implementation

We won't walk through all the bits, but to start, our NetworkStack is a simple struct, simply holding a RefCell of the previously "consumed" adapter:

pub struct NetworkStack<'a, Tx>
where
    Tx: Write<u8>,
{
    adapter: RefCell<Adapter<'a, Tx>>,
}

Since the TcpStack trait requires us to define our own TcpSocket type, here's ours:

/// Handle to a socket.
#[derive(Debug)]
pub struct TcpSocket {
    link_id: usize,
    mode: Mode,
}

The ESP8266 supports 5 concurrent connections, identified by a link_id, which we use to index into an array.

We've also defined our own SocketError error type:

#[derive(Debug)]
pub enum SocketError {
    NoAvailableSockets,
    SocketNotOpen,
    UnableToOpen,
    WriteError,
    ReadError,
}

And here's where we use our TcpSocket and SocketError and get into the interior mutability:

impl<'a, Tx> TcpStack for NetworkStack<'a, Tx>
where
    Tx: Write<u8>,
{
    type TcpSocket = TcpSocket;
    type Error = SocketError;

    fn open(&self, mode: Mode) -> Result<Self::TcpSocket, Self::Error> {
        let mut adapter = self.adapter.borrow_mut();
        Ok(TcpSocket {
            link_id: adapter.open()?,
            mode,
        })
    }

While open(...) takes an immutable self, we can borrow_mut() the adapter we're holding. We can then ask the adapter to open a socket for us (or it fails if all 5 are currently in-use) and we return our TcpSocket structure with the recently-opened link_id.

Summary

There's a lot going on to simply open a socket, but when you do embedded, you have to bring a lot to the table, and like an onion (or a parfait), there's layers upon layers, and thankfully as you move up the stack, they get simpler and more reusable.

Anyhow, if this seems interesting, here're some links: