Rust Networking with the Raspberry Pi Pico W

Murray Todd Williams
24 min read2 days ago

--

All the steps we’ll go through to get wireless comms working

In this sixth installation of my blog series, we are finally going to use the “W” in our Raspberry Pi Pico W. Not only that, but we’re going to finally learn how to blink the onboard LED!

Up to this point, we’ve managed to get our development environment up and running, we’ve set up an external LED light for our initial “blinky” program (the embedded equivalent to “hello world”) and we’ve learned how to gather temperature sensor readings from analog (ADC) and digital (I²C) channels. This is all fine and good, but our project is not very portable yet. Apart from the blinking LED, we as humans are depending on messages printed to our debug log that is being observed via the probe-rs library and via our development system’s (i.e. laptop’s) USB cable.

I want to be able to move my Raspberry Pi Pico’s power to a battery (ideally one that can be powered with a solar panel!) and use my home’s Wifi network to convey my sensor readings to me.

If you look at the diagram above, you’ll see all the components and protocols that we will tackle in this article:

  1. Configure the PIO system so the RP2040 microcontroller can communicate with the CYW43 networking chip.
  2. Join our Wifi network, using WPA2 authentication
  3. Connect to the DHCP service, conveying our device’s name in the process (for discoverability), and getting our IP configuration information.
  4. Use DNS to find the IP address of our server where we will be sending our sensor telemetry

We are going to get up to the point that we are ready to send messages via UDP and/or TCP to our server, but the article has gotten too long and needs to be broken up.

By the way, if this is your first exposure to this series, you can find links to the previous articles here:

Murray's (Raspberry Pi Pico W) Embedded Rust Series

6 stories

DNS Resolution via our Home Routers

In this “client/server” application that we’re developing, I want our clients (the Raspberry Pi Pico) and our servers (either your development laptop or a full Raspberry Pi running Linux) to be able to refer to each other by name instead of a hard-coded IP address.

I don’t want to have to configure a standalone DNS server and somehow register our client server with it. (I think this is actually impractical with something as ephemeral as an embedded device.) We could try to turn to something like zeroconf / mDNS for our systems to “discover” each other, but that would actually involve adding another fairly heavy amount of complication to our little embedded device.

I’ve found that my own home (ASUS) router actually offers DNS resolution for local devices based on the names they used when connecting and registering with DHCP. So for example, I’ve got a Mac laptop named “Thor” and a Raspberry Pi 2B named “pi2b”. I’ve verified that from my laptop, I can ping pi2b:

Thor:~ murray$ ping pi2b
PING pi2b (192.168.50.135): 56 data bytes
64 bytes from 192.168.50.135: icmp_seq=0 ttl=64 time=6.361 ms
64 bytes from 192.168.50.135: icmp_seq=1 ttl=64 time=9.879 ms

From my Raspberry Pi, I can ping thor:

murray@pi2b:~ $ ping thor
PING thor (192.168.50.138) 56(84) bytes of data.
64 bytes from Thor (192.168.50.138): icmp_seq=1 ttl=64 time=6.60 ms
64 bytes from Thor (192.168.50.138): icmp_seq=2 ttl=64 time=261 ms

If you’re able to verify the same sort of thing with your own local network, everything should work just fine. If this doesn’t work for you, I’ll try to include some code showing how your client can connect to the server with a hardcoded IP address.

GIT branch and setting credentials

I’m putting the networking code in a new “networking” branch which is forked off of the original “blinky” starting point. I’ll be building from this, so the next few tags will not show any of our previous sensor-reading code. (That we’ll merge in later!) To see the codebase up to the end of this article, you’ll want to look specifically at the v0.5 tag of my project.

Once you pull this branch, you’ll want to make a change to the .cargo/config.toml file. Right now I have created placeholder environment variables for your WIFI network under the “env” section:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040 --protocol swd --speed 16000"

[build]
target = "thumbv6m-none-eabi" # Cortex-M0 and Cortex-M0+

[env]
DEFMT_LOG = "debug"
WIFI_SSID = "wifi-name" # replace with your own value
WIFI_PASSWORD = "put-pw-here" # same

You’ll want to enter your own credentials in here so that cargo can grab them at compile time. You’ll see how we grab those values in a bit, but it’ll look like this:

let wifi_ssid = env!("WIFI_SSID");
let wifi_password = env!("WIFI_PASSWORD");

Adding libraries and features

In Cargo.toml we’re going to make just a couple additions. First of all, our Embassy starter template had defined the following features for the embassy-net library: "defmt", "tcp", "udp", "hdcpv4", "medium-ethernet" and we want to add to that the new features "dns" so we can do our DNS lookups and dhcpv4-hostname so we can tell our DHCP server our Pico’s name as we get things set up. (This actually isn’t critical: our server doesn’t need to know the client’s name, but in the future we might want our Pico to be “findable” by other devices on the network.)

I’m also going to add the following libraries:

heapless = "0.8.0"
rand_core = "0.6.4"

With heapless, we’ll see how we can create String objects for libraries that expect them when you don’t have the memory (heap) allocation capabilities that normally would some from the std library. We’ll use rand_core to demonstrate how to use one of the RP2040’s internal hardware to generate random values. (It’s a little esoteric, but it took me a long time to figure out. Hopefully the search engines can help others trying to the same to find my solution!)

Downloading cyw43 firmware

There’s one more setup step we need before we proceed. There is some firmware that we will need to install in our CYW43 chip. Since that firmware is covered under a separate license, we’ll need to download it manually.

Go to this GitHub directory and download the contents to a folder named cyw43-firmware parallel to your embassy-rp-blinky directory. (You can put it somewhere else, but you’ll need to make the appropriate path change in the next “PIO” section.

Adding Networking, Step-by-step

I’m going write the networking steps out individually because that’s how I worked to solve and verify thing myself. I’m going to suggest you do your first exploratory development the same way—by adding proving out functionality piece-by-piece.

Setting up PIO communication with the CYW43

This may seem like an odd time to do this, but before we get networking working, we’re going to be adding our onboard LED to blinky. This may seem like a non sequitur, but as I’ve mentioned before, the Raspberry Pi Pico W had to steal some pins in order to enable communication with the CYW43 wifi chip, and in the process, the GPIO 25 that the regular “non-wifi” version of the Pico board used for the onboard LED had to go bye-bye.

So…. how do we light up at onboard LED? Well, it turns out the CYW43 is a relatively flexible chip of its own. As you saw in the previous section, we have downloaded firmware for this chip—about 230K of firmware for that matter—that “teaches” the chip to how to do whatever it does listening to and transmitting over the specific radio frequencies. In fact, a while after the Raspberry Pi Pico W was released—I think it was over a year later—there was an announcement that the CYW43 chip could be used to support the v5.2 spec of Bluetooth! BAM! All of a sudden your Wifi chip does Bluetooth as well! (Some time in the future I hope to use this blog series to play with that capability. Suffice to say I’m sure this will involve flashing an entirely different firmware to the chip!)

This flexible CYW43 chip also apparently has some of its own GPIO lines, and the Raspberry Pi Pico W designers decided to tie one of those CYW43 GPIO lines to the LED! It’s clever, but it’s also a little contorted to have us tell the RP2040 to tell the CYW43 to light the LED, but that what we have to do.

Now let me reiterate why we’re doing this: I both wanted to finally deliver our blinky example using the onboard LED and wanted the simplest possible code example to start talking to the CYW43. This may seem unnecessarily pedantic, but as it happens, talking to the CYW43 chip isn’t all that straightforward.

In my 3rd article, I talked about a variety of different communication protocols that the RP2040 supports, including I²C—which we’ve already used for our sensor readings—and another one called SPI. SPI is meant for high-performance communication between devices like CPUs, memory, etc. It may lack the flexibility of I²C (the ability to have multiple devices on the same channel) but it’s the standard for demanding applications like networking.

Unsurprisingly, the CYW43 chip is designed to use SPI as its communications protocol. Great, so we’ll just use the RP2040’s SPI capability to talk. Not so fast! Let’s look at the Pico W Pinout diagram again…

There an SPI0 and SPI1, and they are flexible enough so that each has some flexibility around which GPIO pins they can work with. But there are only two of them. That means if we dedicated one for networking, our Pico W board would have only one free SPI channel to communicate with external devices. What if we needed two? That would massively limit the usefulness of our Pico W board!

This is where the PIO subsystem of the RP2040 comes in. The microcontroller has essentially eight “state machines” which act a little bit like super-simple CPUs. They can run their own custom-written “programs” that use an extremely pared-down set of only nine machine instructions. These PIO state machines can be configured to work with GPIO pins and the DMA controller to perform special purpose tasks.

Specifically, the PIO makes it possible to simulate our known protocols—I²C, UART, SPI, or even some old, weird, legacy communication protocols—while not tasking the central functionality of the microprocessor itself. So if we need to “create a new interface”, we can use the PIO to do it. Here’s a diagram to show what we’re doing:

RP2040 integration with CYW43 Chip

To use the CYW43 chip, we’ll have the PIO system simulate/create a new SPI channel. That channel will involve some GPIO pins from the RP2040 chip itself, including that pin 25 that used to be used for the non-wifi Pico board’s onboard LED, for the communication. Using that communication, we’ll ask the CYW43 to light the onboard LED as well as do our important networking communications.

Now let’s finally do blinky!

Okay, enough background. We are going to execute blinky so you can see the minimum code needed to talk to the CYW43. For this, I went to the embassy-rp example code for a program called wifi-blinky.rs.

By the way, if you look carefully at that linked page, it’s the example repository at the embassy-rp-v0.1.0 tag. If you had just looked at the example code from the main branch, there would have been some lines that wouldn’t compile because some of the APIs have changed! As I’ve written before, there’s a lot of development going on, and things change regularly. This has tripped me up a few times, so I want to help you know what to keep an eye out for!

To see the code changes in place for this section, you can view (or checkout) my repository at the v0.4 tag.

We’ll start at the top of our program, like usual. The new library use statements will include:

use cyw43_pio::PioSpi;
...
use embassy_rp::bind_interrupts;
...
use embassy_rp::peripherals::{DMA_CH0, PIN_23, PIN_25, PIO0};
use embassy_rp::pio::{InterruptHandler as PioInterruptHandler, Pio};
...
use static_cell::StaticCell;

We see the types for the PioSpi device, the PIO0 hardware, and one of the DMA channels that will be needed to facilitate in-memory data transfers. We’ll also do the trick we leaned previously and rename the pio::InterruptHandler to PioInterruptHandler so that we won’t have type name conflicts when we re-merge with the sensor code that uses I²C and ADC systems to get readings.

We had used the bind_interrupts in a previous blog article for both the ADC and I²C, but that was in the sensors branch of my repository, and this new “networking” branch is starting over from main, so we’ll re-add it. Also, we’ll add the section where we bind our interrupts, just like we did with the sensor code:

bind_interrupts!(struct Irqs {
PIO0_IRQ_0 => PioInterruptHandler<PIO0>;
});

Next, we’re going to add an Embassy task to manage all inter-processor communications between the RP2040 and the CYW43:

#[embassy_executor::task]
async fn wifi_task(
runner: cyw43::Runner<
'static,
Output<'static, PIN_23>,
PioSpi<'static, PIN_25, PIO0, 0, DMA_CH0>,
>,
) -> ! {
runner.run().await
}

Let me stop for a moment and say how cool this is. (And what it means.) In laymen’s terms, we are defining an entirely separate process and establishes and maintains everything that needs to be done for these systems to work together! Using the async model, the CPU will be able to gracefully stop and handle any intermediate tasks when it needs to and then resume working on whatever tasks we want to focus on. Since our RP2040 happens to have two separate cores, this can help assure that bothersome things like networking won’t get in the way of our application’s performance!

In a way, the Embassy library is letting us create a very lightweight OS on top of our little Pico W board. Without it, our programs might have to do a lot of crazy juggling that could introduce errors or lead to unnecessary blocking.

In order for us to actually spawn this task in our main code, we are going to have to use the spawner object that had been passed to our async fn main function. It originally looked like this:

#[embassy_executor::main]
async fn main(_spawner: Spawner) { ... }

Following Rust best practices, when we have a variable that we define but aren’t ready to use yet, we explicitly put an underscore in front of the name, like we did with _spawner: Spawner here. We need to remove that underscore so we can use that variable in a moment.

Now we have some code that I essentially copied from the wifi_blinky.rs example to see how I configure the PIO and get the CYW43 chip working:

// Configure PIO and CYW43

let fw = include_bytes!("../../cyw43-firmware/43439A0.bin");
let clm = include_bytes!("../../cyw43-firmware/43439A0_clm.bin");
let pwr = Output::new(p.PIN_23, Level::Low);
let cs = Output::new(p.PIN_25, Level::High);
let mut pio = Pio::new(p.PIO0, Irqs);
let spi = PioSpi::new(
&mut pio.common,
pio.sm0,
pio.irq0,
cs,
p.PIN_24,
p.PIN_29,
p.DMA_CH0,
);

static STATE: StaticCell<cyw43::State> = StaticCell::new();
let state = STATE.init(cyw43::State::new());
let (_net_device, mut control, runner) = cyw43::new(state, pwr, spi, fw).await;
unwrap!(spawner.spawn(wifi_task(runner)));

I’ll give a basic summary of what’s going on here:

  • Our “program” binary is going to include the CYW43 firmware, which will need to be used to teach the chip how to do it’s wifi stuff.
  • We’re defining the pins used for our make-shift, PIO-driven SPI interface.
  • We’re also defining the PIO bank (pio0) and the DMA channel (DMA_CH0) to be used for the work.
  • As we create our cyw43 component, we get returned a tuple of three objects: (a) a net_device object that will be used for future networking, (b) a control object that we’ll use to give certain special-purpose instructions (like power management and LED blinking), and (c) an object that the wifi_task subsystem will use to manage all our asynchronous busy-work.
  • In the end, we spawn that wifi_task we had defined previously that I was raving about. Note that here we’re using that spawner variable we had been passed in the main function definition!

We finish up with a few final touches:

control.init(clm).await;
control
.set_power_management(cyw43::PowerManagementMode::PowerSave)
.await;

... // next section goes right before our main program loop

let delay = Duration::from_secs(1);

This is using that control object to tell the CYW43 to conservative with power, especially since we’re not using the wifi yet! I don’t know exactly what the clm data is that the control object is initialized with, but it was one of those two firmware files we downloaded. (About 4.6K of black box instructions.) We finally give a one second delay to let everything get ready before we try accessing the LED.

Finally, in the primary loop, we’ll set things up so that our external LED and the onboard LED light alternately:

loop {
info!("external LED on, onboard LED off!");
led.set_high();
control.gpio_set(0, false).await;
Timer::after(Duration::from_secs(1)).await;

info!("external LED off, onboard LED on!");
led.set_low();
control.gpio_set(0, true).await;
Timer::after(Duration::from_secs(1)).await;
}

Tada! We have now gone through all the boilerplate you’ll need to activate the “W” in your Pico W, and we’ve finally delivered that elusive onboard LED blinky program.

Here there Be Dragons! (StaticCell and portable-atomic)

There’s something I intentionally glossed over. In some of the code. We included the static_cell library and used it in the following code:

static STATE: StaticCell<cyw43::State> = StaticCell::new();
let state = STATE.init(cyw43::State::new());
let (_net_device, mut control, runner) = cyw43::new(state, pwr, spi, fw).await;

I think it’s worthwhile to stop and talk about Rust for a moment. In the embedded “no-std” world that we’re working in, we have a lot of objects associated with peripherals that (a) have a lifetime that persists across the entire application, i.e. a 'static lifetime, (b) there’s some mutable state for which we need to reserve space at compile time (since we don’t have a convenient heap-allocation tool like the Box type), and (c) we want to share references to these objects across different tasks.

As per the static-cell documentation:

This is useful in the following scenarios:

  • You need &'static T, but T can't be constructed in const context so you can't simply use a static.
  • You need &'static mut T, not just &'static T.

Being relatively new to Rust, I found this a little confusing but it’s worth keeping in mind because as I play around with various embedded device driver crates, I’ve run into this a few times.

There’s quite a bit more to this rabbit-hole. I’m not going to be exhaustive here (we need to get to the networking code!) but just to give you some breadcrumbs if you find yourself in the rabbit hole… The static-cell crate depends on the portable-atomic library, which is needed when you need to share data between threads and you don’t have the std library’s atomic types at your disposal. Remember, in Embassy, we’re doing lots of asynchronous stuff, and ideally we’re sharing work across our two RP2040 cores!

Also, it’s worth noting that in our Cargo.toml that we’ve explicitly included the portable-atomic library and specified the "critical-section" feature in it. This is important for our dual-core RP2040 because it’s telling portable-atomic that another library (our embassy-rp) that is providing an explicit method for safely sharing these objects between threads. Otherwise, libraries might try to make optimizations based on a single-core assumption.

You don’t need to understand all of this right now, but if (or when) you try to share a peripheral (i.e. an I²C bus) across two device drivers or you’re using a device driver with an async option, you can remember to come here as you’re trying to get things to compile.

Don’t Panic! (Actually, let’s panic!)

Sorry, I couldn’t help using a Hitchhiker’s Guide to the Galaxy reference, but I do want to talk a little bit about panicking from the standpoint of a person relatively new to Rust. The principle comes from the idea that not all error can and should be recoverable. Whenever you run into a Result<T,Error> return value that recognizes that something could have gone wrong, it’s worth figuring out whether it even makes sense to keep running your program.

Up until this point, it’s fair to assume that (a) if we wrote our code correctly and (b) our Raspberry Pi Pico W board doesn’t have a hardware fault, things are likely to be fairly deterministic.

Now we’re about to start working with networking, and whenever you move into the realm of I/O, you start encountering a universe where all sorts of things can, and do, go wrong. We might have the wrong WIFI password. Our router might not provide the DCHP network configuration functionality we were hoping for. We might not get a DNS resolution for the server we’ll want to connect to. Moreover, we’re entering a multi-step process where each step (join WIFI network, get DHCP configuration, query DNS, etc.) builds upon the previous one.

As we work to get our networking working, I want the code to be as clean and simple as possible, so at every step where an error will indicate there’s something wrong that we need to fix, we’re going to use the .unwrap() method to convert our Result<T, Error> directly into whatever that type T is. If the result we get is actually an error, this is going to cause the application to panic and crash with an immediate (and informative) error message.

On the surface, this might me look like a lazy coder, as I’m not bothering to use a match statement to separate the proper return value from the possible error. But right now I know my Raspberry Pi Pico W board is connected via my Debug Probe device to my laptop so I’ll get the feedback I need to diagnose and fix things.

But what about when I want to run my Raspberry Pi Pico W application in a remote, untethered configuration, i.e. in production? I might want to actually catch any network error and use the onboard LED to blink in some pattern that the user can use to diagnose the problem in the field! In a subsequent blog article, I will do exactly this. I will stop using the .unwrap() shortcut, but I’ll also explore how to keep my error-catching code clean (without care, it can turn into horribly ugly boilerplate!) and efficient.

Now for the Networking!

We’ve actually done a lot of the setup needed for our networking stack already, in terms of configuring the PIO resources and initializing the cyw43 driver. When we wrote this line:

let (_net_device, mut control, runner) = 
cyw43::new(state, pwr, spi, fw).await;

…notice that we were already passed our networking device as _net_device. Again, we had put the leading underscore because we weren’t ready to use that object, and the Rust complier complains if we have unused objects without explicitly marking them that way. Since we are going to use that device now, I’m going to ask you to remove that leading underscore.

By the way, again as a reference to see how I figured out how to do all of this, you may look at the wifi-tcp-server.rs file in the Embassy-rp repository’s examples directory. Note again, the link I just provided is to the embassy-rp-v0.1.0 tag which is linked to the library version I imported.

Before we add any more code, you will need to set three important values that will be unique to your own environment: the name and password for your Wifi network, and the name of the machine that will be used as your server. As I’d said before, it could be a Raspberry Pi (the bigger system running a full Linux distribution) or it could be your laptop. You just need to validate that you can type something like ping pi2b (pi2b is the name of my Raspberry Pi) in a terminal window and you should see successful pings.

Assuming you did this correctly, you can add code to pull these values using the env!() macro.

let wifi_ssid = env!("WIFI_SSID");
let wifi_password = env!("WIFI_PASSWORD");
const server_name: &str = "pi2b";
const client_name: &str = "picow";

Note here that you can add your server name directly in your code (the last line) or you could opt to put that also in the .cargo/config.toml file. I’m primarily doing this so that I don’t accidentally upload my network credentials up to my GitHub repository! I’ve also decided to add the name my little Raspberry Pi Pico W is going to use to report itself (picow)to the network.

A quick aside: using ROSC to generate random numbers!

Something I think is a little funny: when I was going through the wifi-tcp-server.rs example file, I saw the following line of code that made my head spin:

// Generate random seed
let seed = 0x0123_4567_89ab_cdef; // chosen by fair dice roll. guarenteed to be random.

I appreciated the sarcasm in the comment about randomness. I was worried that this might be a security risk and asked on the embassy-rs matrix.io board. Someone said it wasn’t a security issue but more something related to how machines agree on how to connect via ports. (In a nutshell, if your server reboots and chooses the same number, the server might refuse thinking it’s another machine or something like that.)

Anyway, I went down my own rabbit hole (led by curiosity) on how to generate a random number without the typical go-to features provided by the std library. Interestingly, our little RP2040 microcontroller has this thing called the Ring Oscillator Clock (ROSC) that can be used to generate random numbers! (Like so much of this stuff, I found that really cool!)

There are a couple little things we need to do. First, we have to add one more library to our Cargo.toml:

rand_core = { version = "0.6.4" }

This gives us a standardized API for random number applications. Then we’ll add some “use” commands at the top of the application:

use embassy_rp::clocks::RoscRng;
use rand_core::RngCore;

And then down in the body of the program where we’re configuring the networking, I’ll generate the seed:

let seed: u64 = RoscRng.next_u64();
info!("Random seed value seeded to {=u64:#X}", seed);

What I think is funny is that the current master branch of wifi-tcp-server.rs now uses the Ring Oscillator Clock to randomize the seed. Maybe I’m spending too much time on this, but again, if anyone wants to try to generate random numbers, maybe a web search can help them find this article. Regardless, this should also impress upon you how active the development of these libraries is right now and how constantly they’re working to improve things.

Adding the Type Inclusions, and a discussion about Core

For the next section, we’re going to add two inclusion use lines:

use core::str::FromStr;
use embassy_net::{Config as NetConfig, DhcpConfig, Stack, StackResources};
...
use embassy_time::{Duration, Instant, Timer};

Most of these types unsurprisingly belong to the embassy_net library. I’m adding embassy_time::Instant so that I can check the time it takes to complete some networking setup below.

The FromStr inclusion is worth a moment’s discussion. Rust developers who live in the more normal, non-embedded Rust-land are accustomed to using the fundamental functional building blocks in the std standard library.

In reality, Rust’s standard library is divided into three layers:

  • “Std” encompasses all of the inherent functionality you would get from a full-fledged OS, including all of the I/O functionality that really integrates with the OS to provide. (I.e. file and network access, stdin and stdout, etc.)
  • “Alloc” gives you everything that depends on heap memory allocation, like the standard collections and dynamically allocated strings.
  • The core library gives you any functionality that depends on nothing except the Rust language itself like the Option and Result types.

Non-embedded Rust developers rarely see these layers because std re-imports the alloc and core libraries, making them relatively invisible. We, however, will sometimes have to dig into core to get some standard functionality, such as using the FromStr function to derive a string from an in-code string literal.

To learn more about this, I suggest reading Chapter 12, “Rust Without the Standard Library” from Jon Gjenset’s Rust for Rustaceans book.

Preparing our networking stack

Our first inclusion will be a new asynchronous Embassy task: (We’ll put this just below the pre-existing wifi_task that we set up earlier to manage the RP2040-to-CYW43 behind-the-scenes communications.

#[embassy_executor::task]
async fn net_task(stack: &'static Stack<cyw43::NetDriver<'static>>) -> ! {
stack.run().await
}

In a similar manner, this task will essentially enable all of the networking I/O in an asynchronous manner so that this I/O doesn’t unnecessarily block our CPU. (Again, I think this is a pretty big deal, and it gives us an elegant way to take advantage of both of our processor cores to keep everything running smoothly.) After we get out networking stack configured, you’ll see where we spawn this task.

Now we’ll continue with the configuration code, including the seed and wifi constants we’d talked about earlier:

let seed: u64 = RoscRng.next_u64();
info!("Random seed value seeded to {=u64:#X}", seed);

let wifi_ssid = env!("WIFI_SSID");
let wifi_password = env!("WIFI_PASSWORD");
const SERVER_NAME: &str = "pi2b";
const CLIENT_NAME: &str = "picow";

let mut dhcp_config = DhcpConfig::default();
dhcp_config.hostname = Some(heapless::String::from_str(CLIENT_NAME).unwrap());
let net_config = NetConfig::dhcpv4(dhcp_config);

static STACK: StaticCell<Stack<cyw43::NetDriver<'static>>> = StaticCell::new();
static RESOURCES: StaticCell<StackResources<4>> = StaticCell::new(); // Increase this if you start getting socket ring errors.
let stack = &*STACK.init(Stack::new(
net_device,
net_config,
RESOURCES.init(StackResources::<4>::new()),
seed,
));
let mac_addr = stack.hardware_address();
info!("Hardware configured. MAC Address is {}", mac_addr);

unwrap!(spawner.spawn(net_task(stack))); // Start networking services thread

A couple things to note:

let mut dhcp_config = DhcpConfig::default();
dhcp_config.hostname = Some(heapless::String::from_str(CLIENT_NAME).unwrap());
let net_config = NetConfig::dhcpv4(dhcp_config);

We see how we needed to use the heapless library that we had imported to create a String object. (And note the from_str function that had required I’d imported the core::str::FromStr type.) Because we are working in low-level hardware with minimal memory resources, we don’t have the luxury of easy heap allocation for objects like Strings—remember that diagram above where the alloc layer lived between the standard library and the core library. We are essentially blocking out the space on the heap for this String object, which we can do easily enough because we already know the size of the string since it’s coming from a (constant) &str object.

static STACK: StaticCell<Stack<cyw43::NetDriver<'static>>> = StaticCell::new();
static RESOURCES: StaticCell<StackResources<4>> = StaticCell::new(); // Increase this if you start getting socket ring errors.

For these lines, we are again using that static-cell library to provision some inline memory that the networking driver will need in order to do its network management. We have to provide a lot of type information here so that StaticCell can get enough information to provision the appropriate amount of memory.

It’s also important to point out the StackResources<4> bit. When I was implementing my first sample code, I think I started with StackResources<2> which would work until I started doing something new—DNS requests, setting up a TCP or UDP socket, etc. All of a sudden my project would fail/panic with a mysterious “Socket Ring Error”. I would bump the 2 up to a 3, add more code until the same problem would rear itself up. Essentially, the more “networking stuff” you do, the larger number of “units” you need to feed into StackResources so the Embassy libraries can do their thing. I these resource units aren’t sequestering super-big chunks of data—from what I can tell looking at the code, it looks like it’s a handful of bytes to store states for a few DNS queries, an up-to-32 character hostname, and that sort of thing—so it might be reasonable to put in a bigger number like “10” and not worry about things.

The rest of the code is straightforward, as we initialize our networking stack. I added the step where I query and write the MAC address of our new networking device to the debugging log. At the bottom, you can see us spawning our networking service in the net_task we’d defined earlier!

At this point, I suggest you run the code and look in the debugging log for something like this:

1.264645 INFO  Hardware configured. MAC Address is Ethernet(Address([40, 205, 193, 14, 4, 146]))
└─ embassy_rp_blinky::____embassy_main_task::{async_fn#0} @ src/main.rs:101

Joining the WIFI network and getting DCHP Configuration

Once we’ve spawned the net_task to manage the networking infrastructure, we’re ready to interact with our network. The first task is twofold: we have to actually join the WIFI network, and then we need the DHCP service to assign us an IP address.

The first part is a simple one-liner:

control.join_wpa2(wifi_ssid, wifi_password).await.unwrap();

For the next part, we actually just have to wait for the network stack to configure itself. In the Embassy Net sample code, it was written this way:

while !stack.is_config_up() {
Timer::after_millis(100).await;
}

I didn’t like that because it represented a potentially endless loop that could result in our system hanging. If you want to keep your code simple, you can add the above, but as one of my personal rabbit holes, I decided to write something a bit more robust:

let start = Instant::now().as_millis();
loop {
let elapsed = Instant::now().as_millis() - t;
if elapsed > 10000 {
core::panic!("Couldn't get network up after 10 seconds");
} else if stack.is_config_up() {
info!("Network stack config completed after about {} ms", elapsed);
} else {
Timer::after_millis(10).await;
}
}

My goal was to break out of the loop as soon as possible, but also to cause a panic failure if we were waiting longer than 10 seconds. It’s probably overkill to be checking every 10 milliseconds, but I was wanted to know how long it took to get everything working, and in my home the answer was about 30 ms.

I added a little bit more (unnecessary) debugging code to show my configured IP address. I did add a check to make sure that the network didn’t come up without any usable IP assignment. (I’ve had plenty of misconfigured Windows machines end up in this state over the years!)

match stack.config_v4() {
Some(a) => info!("IP Address appears to be: {}", a.address),
None => core::panic!("DHCP completed but no IP address was assigned!"),
}

Finally, I want to look up the address of the server that I’m going to be sending data to:

let server_address = stack
.dns_query(SERVER_NAME, embassy_net::dns::DnsQueryType::A)
.await
.unwrap();

let dest = server_address.first().unwrap().clone();
info!(
"Our server named {} resolved to the address {}",
SERVER_NAME, dest
);

Hopefully your home network will allow you to address your server by its name. Otherwise, you can skip the DNS lookup process and just hardcode the address like this:

let dest = core::net::Ipv4Addr::new(192, 168, 1, 48);

By the way, one last note on this line:

let dest = server_address.first().unwrap().clone();

The stack.dns_query(...) function returned a vector of addresses. There are some situations where DNS can return multiple usable addresses for a server name—this is done for critical systems that want to employ load balancing or offer redundant servers in case any become non-responsive. We know that in our home network we’re only going to get at most one IP address. In fact, when we call .first() to ask for the first IP address in the Vector, we are being returned an Option<Address> and by calling .unwrap() on that, we’re instructing the system to panic if we didn’t get an IP address. If you had a fallback static IP address you wanted to use for the server, you could have used something like .unwrap_or_else(core::net::Ipv4Addr::new(192, 168, 1, 48) instead. I’m going to stick now with the early-panic approach in my code.

Finally, there’s the suffix .clone() that I have at the end. Without that, my dest variable would have a type of &Address and I would get potential ownership issues passing it to functions that aren’t going to want to deal with ownership issues down the road. Since an IPv4 address is only four bytes, copying these four bytes for later use is trivial.

Okay, that’s it for now! I had wanted to show off some actual networking, but this article is way past my desired length, so you’ll have to wait for the next one, which I promise to deliver during the week after the July 4th holiday. If you want to get a head start on things, get this code up and running (substituting a static IP address for your server if necessary) and also make sure your target server has Python installed. The sample server code is going to be generic enough to assure you won’t need to worry about Python versions or library availability. We’ll have a hello_tcp.py and/or hello_udp.py script that should work on any system.

--

--

Murray Todd Williams

Life-long learner, foodie and wine enthusiast, living in Austin, Texas.