Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Extreme MQTT on PicoRuby

Avatar for ryosk7 ryosk7
April 24, 2026

Extreme MQTT on PicoRuby

This is the presentation material from my talk on Day 2 of RubyKaigi 2026 in Hakodate, Hokkaido.

https://rubykaigi.org/2026/presentations/ryosk7.html
I spoke about the challenges of implementing MQTT and MQTTS (MQTT + TLS) based on picoruby-socket on an RP2040 board.
The library developed for this presentation can be found at:
https://github.com/ryosk7/picoruby-net-mqtt-femto/tree/rubykaigi-2026
Please feel free to check it out.

Avatar for ryosk7

ryosk7

April 24, 2026

More Decks by ryosk7

Other Decks in Programming

Transcript

  1. ˒ Ryosuke Uchida (@ryosk7) ˒ Freelancer ˒ Organizer of Roppongi.rb,

    a regional.rb for Rubyists. Hello, Hakodate!
  2. ˒ Message Queuing Telemetry Transport ˒ Lightweight pub/sub messaging protocol

    over TCP ˒ Designed for IoT: low bandwidth, unreliable network ˒ Central Broker decouples Publishers and Subscribers What is MQTT?
  3. picoruby-net-mqtt picoruby-net-mqtt-femto Implementation Pure Ruby C + lwIP bindings Target

    Any PicoRuby board RP2040 / Pico W only QoS 0 ✅ ✅ QoS 1 ✅ ✅ MQTTS ✅ ⚠ Experimental Portability ✅ RP2040 only
  4. ˒ A Ruby implemention for microcontrollers ˒ Part of the

    PicoRuby ecosystem, maintained by @hasumikin ˒ R2P2: Ruby shell system for RP2040 What is FemtoRuby?
  5. The challenge Fitting a network stack + Ruby VM into

    264 KB
 is normally considered impossible This session is about how we made it work — and pushed it further
  6. Layer RAM mruby/c VM baseline ~ 49 KB PicoRuby standard

    gems ~ 30 KB lwIP stack ~ 40 KB MQTT state machine ~ 10 KB Total ~ 129 KB / 264 KB
  7. What is lwIP? lwIP (~40 KB) Application (Ruby / C)

    apps/mqtt ← we use this altcp_tls + mbedTLS TCP / UDP IP / ICMP / DNS / DHCP Netif: CYW43439 WiFi (SPI)
  8. lwIP (~40 KB) Application (Ruby / C) apps/mqtt ← we

    use this altcp_tls + mbedTLS TCP / UDP IP / ICMP / DNS / DHCP Netif: CYW43439 WiFi (SPI) ˒ “lightweight IP” — an embedded 
 TCP/IP stack ˒ Provides TCP / UDP / DNS /
 DHCP + app clients
 (HTTP, MQTT…) ˒ Built into the Raspberry Pi Pico SDK
 — driven via cyw43_arch ˒ TLS through altcp_tls (mbedTLS backend)
  9. lwipopts.h build con fi guration lwIP MQTT driver ports/rp2040/mqtt.c mruby/c

    bindings src/mrubyc/mqtt.c Overview - 4 layers Ruby API mrblib/mqtt.rb
  10. ˒ Net::MQTT::Client - public API ˒ connect / disconnect /

    publish / subscribe ˒ Reconnect helpers, retained session state ˒ Keeps the Ruby side idiomatic Design - Ruby Layer mruby/c bindings src/mrubyc/mqtt.c Ruby API mrblib/mqtt.rb
  11. ˒ Thin bridge between mruby/c VM and C ˒ Type

    conversation: mrbc_value ⁶ C primitives ˒ NUL termination at every string boundary ˒ Error propagation into Ruby exceptions Design - C Bindings lwIP MQTT driver ports/rp2040/mqtt.c mruby/c bindings src/mrubyc/mqtt.c Ruby API mrblib/mqtt.rb
  12. ˒ lwIP’s built-in MQTT client ˒ TCP connection lifecycle ˒

    MQTT fi nite state machine —
 CONNECT / CONNACK / PUBLISH / PUBACK ˒ Callback dispatch back to C bindings Design - lwIP Layer lwipopts.h build con fi lwIP MQTT driver ports/rp2040/mqtt.c mruby/c bindings src/mrubyc/mqtt.c
  13. ˒ CYW43439 WiFi chip
 — separate from RP2040 ˒ Talks

    to RP2040 over SPI ˒ cyw43_arch_poll() pumps WiFi events into lwIP ˒ Polling timing is critical (see the bug later) Design — Hardware lwipopts.h build con fi guration lwIP MQTT driver ports/rp2040/mqtt.c
  14. MQTT Broker CYW43439 lwIP MQTT mruby/c Ruby client.connect cyw43_arch_poll() (outside

    lock) MQTT_connect_impl() TCP SYN (SPI) TCP handshake SYN-ACK WiFi event MQTT CONNECT CONNECT packet CONNACK WiFi event connection_cb() connected
  15. MQTT Broker CYW43439 lwIP MQTT mruby/c Ruby Entry — Ruby

    → mruby/c → lwIP client.connect cyw43_arch_poll() (outside lock) MQTT_connect_impl() TCP SYN (SPI) TCP handshake SYN-ACK WiFi event MQTT CONNECT CONNECT packet CONNACK WiFi event connection_cb() connected
  16. MQTT Broker CYW43439 lwIP MQTT mruby/c Ruby TCP handshake over

    SPI client.connect cyw43_arch_poll() (outside lock) MQTT_connect_impl() TCP SYN (SPI) TCP handshake SYN-ACK WiFi event MQTT CONNECT CONNECT packet CONNACK WiFi event connection_cb() connected
  17. MQTT Broker CYW43439 lwIP MQTT mruby/c Ruby MQTT CONNECT /

    CONNACK client.connect cyw43_arch_poll() (outside lock) MQTT_connect_impl() TCP SYN (SPI) TCP handshake SYN-ACK WiFi event MQTT CONNECT CONNECT packet CONNACK WiFi event connection_cb() connected
  18. MQTT Broker CYW43439 lwIP MQTT mruby/c Ruby Callback bubbles back

    up client.connect cyw43_arch_poll() (outside lock) MQTT_connect_impl() TCP SYN (SPI) TCP handshake SYN-ACK WiFi event MQTT CONNECT CONNECT packet CONNACK WiFi event connection_cb() connected
  19. Step 1: lwipopts.h — build con fi guration Miss any

    one of these and client.connect hangs silently.
  20. ˒ cyw43_arch_poll() reads WiFi events from CYW43 and dispatches them

    into lwIP ˒ Called outside the lwIP lock — calling inside causes deadlock ˒ CONNACK and PUBACK only arrive through this poll ˒ sys_check_timeout() (inside lock) advances lwIP’s internal timers poll loop
  21. Step 3: mruby/c bindings mruby/c strings can be GC’d soon

    as the binding returns. But lwIP keeps a pointer to client_id for the lifetime of the connections. Every Ruby-supplied string that lwIP holds onto must be copied into a
 context-owned buffer with a manual NUL terminator.
  22. poll_sleep_ms — not just sleep Without polling, PINGREQ / PINGRESP

    stops working.
 The broker closes the connection after keep_alive seconds of slience. poll_sleep_ms keeps the connection alive during idle periods.
  23. with_reconnect block On ConnectionError, reconnects and re-enters the block automatically.

    Previously subscribed topics are resubscribed on reconnect.
  24. RAM at the edge mruby/c heap pool 150 KB lwIP

    / USB / CYW43 ( .bss ) 90.8 KB Initialized data ( .data ) 12.9 KB Core stacks (core0 + core1) 6 KB C heap ( PICO_HEAP_SIZE ) 1 KB Total 260.7 KB / 264 KB = 98.8%
  25. MQTTS: the wall mbedTLS needs ≈ 40 KB for handshake

    and TLS record buffers. Current free RAM: 3.3 KB. Fix: shirink heap_pool 150 → 101 KB, raise PICO_HEAP_SIZE 1 → 50 KB
  26. ˒ MQTT 3.1.1 on RP2040 / Pico W ˒ IwIP-native

    C bindings — minimal overhead ˒ QoS 0 + QoS 1 ˒ Auto-reconnect with exponential backoff ˒ Same Net::MQTT API as picoruby-net-mqtt Summary