How Do You Prove a Rust Rewrite Matches the C Firmware It Replaces?
One industrial gateway written in C and again in Rust on the same STM32F407 and W5500, reconciled by the same 93-test e2e suite.
0
Project description
io-edge-hub-rust โ The Same Device Built Twice, Reconciled by 93 Tests
#Rust #embassy #W5500 #MACRAW #STM32F407 #MCUboot #ModbusTCP #littlefs
๐ Context: kabirz/io-edge-hub-rust โ a Rust/embassy rewrite of an industrial IO gateway firmware. Hardware is an STM32F407VET6 with a W5500 on SPI2 and a W25Q128 NOR on SPI1. Last pushed 2026-09-01, MIT declared in Cargo. โ Verification: Read from the repository at
main. The figures are its own, measured 2026-08-23, and were not reproduced here.
01 โ This is the third implementation of one device
The repository is not a new product. It is an existing device written again. io-edge-hub is an industrial acquisition node reading and driving 16 digital inputs, 8 digital outputs and 4 analogue inputs.
What the device exposes:
| Service | Port | Notes |
|---|---|---|
| UDP config | 8600 | config commands, v2 bulk upgrade channel, broadcast on 8601 |
| Modbus TCP | 502 | FC01-08/15/16, up to 2 masters |
| Modbus RTU | USART2 | DE on PA1, 9600 by default |
| HTTP | 80 | gzip SPA, JSON API, 2 connections |
| WebSocket | /ws | 1 s IO and registers, 10 s info push |
| FTP | 21 | RFC 959, 3 sessions |
| Shell | USART1 | 115200 |
History is written as data_MMDD_HHMMSS.raw in a 1 MB by 10 rotation, config and filesystem live on the W25Q128 NOR, and a 30 s IWDG watchdog runs. Firmware upgrades arrive over three channels: UDP, WebSocket and CAN.
The README names its references directly.
| Implementation | Role |
|---|---|
| C / FreeRTOS | reference firmware |
| Zephyr | protocol authority |
| Rust / embassy | this repository |
And all three drive the W5500 the same way. The first line of the feature table:
Network โ W5500 MACRAW โ embassy-net (smoltcp), static IP (default 192.168.12.101), MAC derived from the UID
crates/firmware/src/net.rs opens with //! W5500 MACRAW + embassy-net bring-up.
- Driver โ
chip::W5500fromembassy-net-wiznet = "0.3.0" - Bus โ SPI2 at 21 MHz on a DMA shared bus
- Addressing โ static IPv4, MAC from XOR-folding the STM32's 96-bit UID onto the WIZnet OUI
02 โ A rewrite you can swap in and out
Most rewrites stop at "rewritten". This one set out to keep the boundaries identical.
- Same MCUboot bootloader, same RSA-2048 signing key.
boot.binis untouched. - Same NOR partition layout on the W25Q128: IOCF A/B config (32K ร 2) plus littlefs from 0xF0000.
- It mounts what the C build wrote and keeps going. The comparison document records the Rust firmware continuing the same history file the C firmware had been writing.
- The upgrade channels work in both directions. The Rust firmware can be reflashed through its own UDP v2, WebSocket and CAN channels โ and can be flashed back to C.
So either build can go onto a field device with the disk and bootloader intact. That is what makes this a replaceable alternative rather than an experiment.
03 โ Reconciled against 93 tests
The verification method is the centre of this repository. The C repository's 93-item e2e suite was reused as-is: same hardware, same Windows/Python host, same list.
| Suite | Items | C | Rust |
|---|---|---|---|
| basic | 9 | pass | pass |
| udp (8600) | 8 | pass | pass |
| modbus_tcp | 14 | pass | pass |
| modbus_rtu | 3 | pass | pass |
| web (:80) | 17 | pass | pass |
| websocket | 3 | pass | pass |
| history (littlefs) | 5 | pass | pass |
| uart shell | 6 | pass | pass |
| ftp (:21, 3 concurrent) | 14 | pass | pass |
| stress | 10 | pass | pass |
| reboot | 3 | pass | pass |
| fw_upgrade | 3 | pass | pass |
| Total | 93 | 93 | 93 |
All 93 passed in a single pytest run of 5 minutes 18 seconds. The stress suite includes three concurrent FTP sessions, 1 MB transfers and a 30-second mixed load; fw_upgrade includes two real MCUboot swaps.
04 โ Measured side by side
From docs/comparison-c-vs-rust.md, dated 2026-08-23:
| Metric | C | Rust |
|---|---|---|
| Signed image size | 329,732 B | 205,708 B |
| FTP STOR 1 MiB | 42 KB/s | 14 KB/s |
| FTP RETR 1 MiB | 318 KB/s | 365 KB/s |
| 3 clients storing 128 KiB each | about 11 s | about 11 s |
| UDP v2 firmware push | 56 KB/s | 42 KB/s |
| MCUboot swap and reboot offline window | 24 s | 21 s |
The image is 38% smaller, which the author attributes to dropping mbedtls and RSA.
The losses are not hidden either. FTP upload runs at a third of C's rate, and the cause is written down: w25q::wait_not_busy polls on a fixed ~1 ms interval after each page program, inflating a 4 KiB page write to about 18 ms per page, where C polls more tightly. The note says a short spin before falling back to a delay should close the gap. Download is slightly faster in Rust, and three concurrent uploads tie because NOR writes are the bottleneck.
05 โ Four things the port turned up
The reliability section is the most useful material here. All four were hit and fixed during this port, each with its cause recorded.
- Do not sync littlefs on every record. It rewrites the whole inline file tag roughly every eight records and triggers directory compaction, producing a NOR erase storm that starves the storage task. The symptom was every FTP and storage operation timing out with the IWDG resetting in a loop. Restored to C's behaviour: buffered writes with an event-level flush.
- Do not leave the session-limit rejecter listening. It should only listen while the limit is reached and stand down after 50 ms of idle; a permanent listener steals the next legitimate connection.
- Clean up FTP slot file handles on the next open. After an abnormal transfer, both read and write handles are closed before the slot is reopened, preventing a re-entrant self-loop in littlefs's mlist.
- Never run NOR I/O inside a critical section. Erase and program take 10โ100 ms, and masking interrupts that long loses UART, W5500 and upgrade-window data. That applies directly to any design with an SPI-attached Ethernet controller.
06 โ What is still open
- The three implementations have not been cross-verified. The comparison document states that three-way interop with the Zephyr build was not done, as the repository has no Zephyr build artefacts. Only C and Rust are confirmed against each other.
- One CAN direction does not work. Device-to-PCAN produces no waveform on either firmware. The author treats it as a physical-layer issue and leaves the transceiver, termination and wiring to be checked. Both builds behave the same, so it is not a port regression.
- The README's reference paths are the author's local Windows paths, which nobody outside can follow.
07 โ Why it is worth reading
- Rewrite stories are common; matching the bootloader and the on-disk format so the two builds can flash each other is not.
- Publishing both the wins and the losses as numbers is rarer still.
- And all three implementations drive the W5500 in MACRAW. Zephyr's in-tree driver defaults to it and so does
embassy-net-wiznet. The same author wrote a separate Zephyr driver using the chip's hardware stack โ yet all three product firmwares still stand on MACRAW.
08 โ Similar Projects on WIZnet Makers
- How Does a Zephyr Driver Use W5500 Hardware TCP/IP When the In-Tree One Is MACRAW Only? is the Zephyr build of the same device by the same author โ the one this repository cites as its protocol authority. Read together, they show one io-edge-hub implemented three times, in C, Zephyr and Rust.
- How Much Does W5500 Hardware Offload Save Over MACRAW on the Same Board? answers in numbers the question this one leaves open. All three builds here are MACRAW; that post measures, on one board, what changes when the same chip runs its hardware stack instead.
- How Does an ESP32-S3 Modbus Gateway Serve Field Points as MCP Tools over W5500 Ethernet? is the closest in purpose โ field points published across W5500 Ethernet by a gateway. The difference is that this repository builds one device twice on different stacks and reconciles them.
Q&A
Q. How is the W5500 used here? embassy-net-wiznet 0.3.0's chip::W5500 opened in MACRAW, with embassy-net (smoltcp) above it. SPI2 runs at 21 MHz on a DMA shared bus, the static address defaults to 192.168.12.101, and the MAC is derived from the STM32's 96-bit UID.
Q. How closely does it match the C build? Same MCUboot and same RSA-2048 signing key, and the NOR partition layout is identical, so it mounts the littlefs the C build wrote and continues the same history file. Its upgrade channels can also flash C back.
Q. Is the Rust build better? It depends on the metric. The signed image is 38% smaller, and FTP download and the MCUboot swap window are slightly faster. FTP upload is well behind at 14 KB/s against 42 KB/s, and the author points to the NOR page-program polling interval as the cause.
Q. Are the three implementations interchangeable? C and Rust are confirmed. Three-way verification including the Zephyr build has not been done, as the comparison document states.
Original Link: https://github.com/kabirz/io-edge-hub-rust
Comparison report: docs/comparison-c-vs-rust.md ยท Per-module docs: docs/embassy.md ยท Zephyr build: kabirz/iot-zephyr-app