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

Extreme MQTT on PicoRuby

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
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