03.05.2025
The RP2350 is a nice microcontroller from Raspberry Pi. Recently I thougt about a new project, where a lot of PWM channels would be needed. The RP2350B has 24 PWM channels, which is a lot - but not enough. I would need at least 33 PWM channels, and also some more GPIOs, so the RP2350 alone isn't enough. So I thought about a port expander... But what about using a second RP2350 as a port expander?
The chip is relatively cheap (about $0.90), needs few components around it, and... firmware? Well, that's not an ideal solution. Dealing with different firmwares on multiple controllers on the same board can be challenging. Also I really like the idea of having a simple port expander and not needing to deal with version checks and incompatibility breaks between different firmware versions.
So I read the datasheet of RP2350 (Chapter 5.8). And the RP2350 not only comes with an USB bootloader, which is most likely how most people program their chips, but also an UART bootloader (a new RP2350 feature over the RP2040!). UART is a lot easier to implement on a microcontroller than USB, and it's also the way to go for communication between the chips.
I made a YouTube-Video about this topic, watch it if you're interested!
The theory
The process to load the firmware on the chip via UART is relatively simple: Just do some "unlocking" with a magic pattern, and then send the firmware image in 32-byte-chunks to the chip. After that's done, just run it.
By the way, here is the bootloader code of the RP2350.
Of course there are downsides:
- The firmware needs to run from SRAM, consuming space in SRAM. But the RP2350 comes with 520 kiB, that should be enough for a port expander, right? (It is!)
- It takes some time on every boot. My first tests show, that it takes something below a second to send the image via UART (depends on the size). Not a problem in my application, but could be one.
A short Google search showed me, that not many people on the internet already tried that, so let's go!
The hardware change to enable the UART-bootloader is simple: On the interface to the flash, CSn needs to be driven low (which also enables USB bootloader), and in addition drive SD1 high.
When the chip is coming up strapped like this, it will enable a 1MBaud UART on pins SD2 (TX) & SD3 (RX).
Unfortunately I don't have a Pico 2, but I have a custom board (which will be explained in detail in the future), where I could make those modifications very easily, since I used a very big flash package:

Now we have the following goals:
- Get a RP2350 binary running from SRAM
- Get the binary on the board via UART
- Embed it in another microcontroller's firmware
- Booting from there
- Think about a reliable connection, even with longer cables
A binary running from SRAM
That's actually not very hard. Just go to your makefile and add the line set(PICO_NO_FLASH 1)
(Actually, according to the SDK documentation, chapter 6.4, set(PICO_DEFAULT_BINARY_TYPE no_flash)
should be equivalent. But that doesn't work for now.)
And that's it. As soon as you compiled it, you will have a bin-file in the subfolder "build":

That is the file to send via UART. To test it, you could use the also generated uf2-file and copy it via USB to the RP2350. But be aware that it will run from RAM and it will be gone after a power-cycle.
Firmware-over-UART
To start, I just wrote a quick Python-script. It sends the firmware, and I also implemented checking it. With a 7.3 KiB firmware, the sending took ~160ms, the verification ~150ms.
That's not especially bad, but let's quickly do the math.
Baud-to-byte
The UART mode here is 8n1
: One start bit, eight data bits, no parity and one stop bit. That makes 10 bit per symbol, but only eight of them are user data. So our speed is 1000000 Baud/s / 10 (Bits per symbol) * 8 (User data bits per symbol) / 8 (Bits per Byte) = 100 kB/s (or 0.1 MB/s)
My test binary is exactly 7256 Bytes long, but we need to make 32-byte chunks, so the last chunk will be filled with zeros and we get a total length of 7264 Bytes. The write command before every chunk makes one more byte overhead, so we have 227 bytes overhead. Those 7491 Bytes should now we downloaded in 74.91ms.
I won't elaborate why my Python script took double of that (maybe I did a horrible mistake?). Obviously due to anything, not the complete possible time is used to transfer the data. I think it might be due to the Linux and USB-stuff that is in between, but I think it's not very relevant, so lets jump to the interesting part!
Binaries in binaries
To be able to boot our RP2350 from another microcontroller, we want the firmware for the first embedded in the firmware of the latter.
We could use one of the various online tools that creates a C header with a large array containing the binary's data.
But I wanted a more automated way, and it looks like at least with C++23, this feature was implemented as #embed preprocessor directive.
However, I couldn't get it running with the version 2.1.0 of the pico SDK. Maybe I'm just dumb, but setting set(CMAKE_CXX_STANDARD 23)
did still result in an error. Maybe it's GCC, that as far as I can see, did not yet implement C++23 #embed?
But I found a nice semi-automatic way at Stackoverflow. It just needed some tweaking, but it works (see my code here).
Kickstarting the RP2350 from another microcontroller
Now we're all set: We have a Python proof-of-concept, we have the firmware binary embedded in the binary of another microcontroller. I just ported the Python code more-or-less to C, and some infinite loops later... It works! See my code here.
Let's finally check the speed. The script gives the following output:
Hello, world!
Device is there!
Start addr is 0x20001055
Finished sending FW, 7264 bytes were written!
The end. This took 74750 us
That's 0.2 ms faster then the expected maximum speed. I have to think about how that's possible, but I won't complain ;-)
Missing features
I noticed, that the pins the bootloader uses, since they reside in another GPIO bank, can with the current SDK not be used as UART in your program. But as the internet is a great place, soon after I submitted that issue on GitHub, someone already sent me a link to a solution. I tested it, and it works perfectly. I guess it will eventually land in the SDK, so keep an eye on that issue!
A reliable connection
UART isn't known for being robust over long cables. I think we'll have a problem with signal integrity the longer the cables get, a lot more than one meter will be a problem.
The solution to that problem is very simple: We can convert the single-ended UART signal to a differential signal. I used TI's THVD1450 to translate the UART to RS-485. I made a simple PCB with two transceivers and a RJ-45 connector. RS-485 usually uses 120-Ohm cables, but I just used 100-Ohm ethernet cables, since they're just very easy to get and cheap.

With that board on both ends, I could boot the remote RP2350 over a ~10m cable, which was the longest I have at home. I guess it should work up to 100m, but 10m is more than enough for me.

Since the board works transparent, as if there would be a direct connection between the boards, it worked out of the box and very reliable. So for my use-case, I will go with that solution. Stay tuned!
If you have questions or comments, just contact me via mail, or write a comment at the YouTube-Video.