<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Smersh</title>
    <description>The latest articles on DEV Community by Smersh (@andrey_asx_6a2421d07b6d62).</description>
    <link>https://dev.to/andrey_asx_6a2421d07b6d62</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3629769%2F6b11f0cd-808e-472a-8533-2b57062d8c65.png</url>
      <title>DEV Community: Smersh</title>
      <link>https://dev.to/andrey_asx_6a2421d07b6d62</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andrey_asx_6a2421d07b6d62"/>
    <language>en</language>
    <item>
      <title>ZeroTier Without Native. Managed ZtSharp and native VL1-only ZtLite for ESP32</title>
      <dc:creator>Smersh</dc:creator>
      <pubDate>Mon, 24 Aug 2026 02:24:42 +0000</pubDate>
      <link>https://dev.to/andrey_asx_6a2421d07b6d62/zerotier-without-native-managed-ztsharp-and-native-vl1-only-ztlite-for-esp32-3dmg</link>
      <guid>https://dev.to/andrey_asx_6a2421d07b6d62/zerotier-without-native-managed-ztsharp-and-native-vl1-only-ztlite-for-esp32-3dmg</guid>
      <description>&lt;p&gt;&lt;strong&gt;Hello everyone.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I temporarily put the ESPB interpreter project on hold because interest in it turned out to be rather limited. So I approached the larger problem from another direction, and this time everything revolves around ZeroTier.&lt;/p&gt;

&lt;p&gt;I had been interested in ZeroTier for quite a while. At one point I worked with &lt;code&gt;libzt&lt;/code&gt; — sockets over the ZeroTier network. It is very convenient for applications whose endpoints are not necessarily on the same local network and may be located behind NAT.&lt;/p&gt;

&lt;p&gt;That raised an interesting question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What if we could use ZeroTier without depending on native libraries at all, and what if an ESP32 only needed the P2P part of ZeroTier rather than the entire virtual Ethernet stack?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This eventually resulted in three related projects:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ZtSharp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fully managed ZeroTier implementation in C#/.NET with its own TCP/IP stack over VL2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ZtLiteESP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Minimal VL1-only client for ESP32 for direct peer-to-peer messaging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ZtGenStorage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Generates ZeroTier identity data during provisioning instead of doing it on the MCU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  ZeroTier: Two Layers
&lt;/h2&gt;

&lt;p&gt;Conceptually, ZeroTier can be divided into two layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VL1 (Virtual Layer 1)&lt;/strong&gt; is the cryptographically addressed P2P layer. It provides identity, peer discovery, cryptography, path management, NAT traversal, and the exchange of control and application messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VL2 (Virtual Layer 2)&lt;/strong&gt; provides virtual Ethernet on top of VL1: virtual networks, Ethernet frames, network configuration, and IP connectivity.&lt;/p&gt;

&lt;p&gt;A simplified view looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ZeroTier
├── VL1
│    ├── identity
│    ├── discovery &amp;amp; crypto
│    ├── paths &amp;amp; NAT traversal
│    └── USER_MESSAGE
│
└── VL2
     ├── virtual Ethernet &amp;amp; frames
     ├── ARP / NDP
     └── IP connectivity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a conventional ZeroTier node, both layers are useful.&lt;br&gt;
But not every application actually needs VL2.&lt;/p&gt;


&lt;h2&gt;
  
  
  ZtSharp: ZeroTier Entirely in C
&lt;/h2&gt;

&lt;p&gt;Originally, I wanted to create a managed equivalent of &lt;code&gt;libzt&lt;/code&gt; without native binaries and without having to maintain P/Invoke wrappers for every operating system.&lt;/p&gt;

&lt;p&gt;That is where &lt;strong&gt;ZtSharp&lt;/strong&gt; started.&lt;/p&gt;

&lt;p&gt;ZtSharp is a fully managed implementation of a ZeroTier node in C#/.NET. The protocol core is based on the ZeroTier One &lt;code&gt;node/&lt;/code&gt; implementation, while the surrounding networking layer, APIs, storage, and managed infrastructure are implemented in C#.&lt;/p&gt;

&lt;p&gt;The project currently includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VL1/VL2 protocol logic;&lt;/li&gt;
&lt;li&gt;identity and peer management;&lt;/li&gt;
&lt;li&gt;peer discovery and path establishment;&lt;/li&gt;
&lt;li&gt;cryptographic functionality;&lt;/li&gt;
&lt;li&gt;relay and multipath/bonding support;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;USER_MESSAGE&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;Planet/Moon support;&lt;/li&gt;
&lt;li&gt;an embedded network controller;&lt;/li&gt;
&lt;li&gt;pluggable state storage;&lt;/li&gt;
&lt;li&gt;logging through &lt;code&gt;Microsoft.Extensions.Logging&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also includes a managed TCP/IP stack operating directly on the ZeroTier virtual Ethernet layer instead of using the operating system's native sockets.&lt;/p&gt;

&lt;p&gt;The stack includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ARP;&lt;/li&gt;
&lt;li&gt;NDP;&lt;/li&gt;
&lt;li&gt;ICMP/ICMPv6;&lt;/li&gt;
&lt;li&gt;IPv4;&lt;/li&gt;
&lt;li&gt;IPv6;&lt;/li&gt;
&lt;li&gt;UDP;&lt;/li&gt;
&lt;li&gt;TCP;&lt;/li&gt;
&lt;li&gt;a socket API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The overall architecture looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.NET application
       │
       ▼
    ZtSocket
       │
       ▼
 managed TCP/IP
       │
       ▼
 virtual Ethernet
       │
       ▼
  ZeroTier node
    VL1 + VL2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal was to make ZeroTier usable from managed .NET applications without requiring &lt;code&gt;libzt&lt;/code&gt;, P/Invoke, or platform-specific native bindings.&lt;/p&gt;

&lt;p&gt;Compatibility with the original &lt;code&gt;libzt&lt;/code&gt; has also been tested. A managed ZtSharp node was able to communicate with a native &lt;code&gt;libzt&lt;/code&gt; node over VL2.&lt;/p&gt;




&lt;h2&gt;
  
  
  USER_MESSAGE: Communication Without a Virtual Network
&lt;/h2&gt;

&lt;p&gt;While working with the ZeroTier protocol, I became particularly interested in &lt;code&gt;USER_MESSAGE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a VL1 application-level message addressed directly to a specific peer.&lt;/p&gt;

&lt;p&gt;At the API level it contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a destination address;&lt;/li&gt;
&lt;li&gt;a 64-bit &lt;code&gt;typeId&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;an arbitrary payload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;typeId&lt;/code&gt; can be used as an application-level message type. For example, it can distinguish telemetry from commands or separate different versions of an application protocol.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
     │
     ▼
USER_MESSAGE
(typeId + payload)
     │
     ▼
ZeroTier VL1
(identity / path / crypto)
     │
     ▼
Peer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No virtual network membership is required.&lt;/p&gt;

&lt;p&gt;You do not need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;to join a VL2 virtual network;&lt;/li&gt;
&lt;li&gt;a ZeroTier IP address;&lt;/li&gt;
&lt;li&gt;TAP/TUN;&lt;/li&gt;
&lt;li&gt;ARP/NDP;&lt;/li&gt;
&lt;li&gt;IP routing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is, however, an important limitation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;USER_MESSAGE&lt;/code&gt; is not a reliable message queue with guaranteed delivery. Delivery confirmation, sequence numbers, retransmission, duplicate detection, and application-level ACK semantics must be implemented by the application if they are required.&lt;/p&gt;

&lt;p&gt;That is exactly how I use it in the ESP32 test: the application sends messages such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hello from esp32:0
hello from esp32:1
hello from esp32:2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and receives corresponding responses from the remote peer.&lt;/p&gt;

&lt;p&gt;Those responses are application-level acknowledgements, not a built-in reliability mechanism provided by &lt;code&gt;USER_MESSAGE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For small telemetry and control messages, however, this is often enough.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why VL1-only on ESP32?
&lt;/h1&gt;

&lt;p&gt;For a server, the following architecture makes perfect sense:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VL1
 ↓
VL2
 ↓
Virtual Ethernet
 ↓
ARP / IPv4 / IPv6
 ↓
UDP / TCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But consider a much simpler use case:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The device sends its status to a server and occasionally receives a command.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For such a device, bringing the complete VL2 networking stack to the MCU may be unnecessary.&lt;/p&gt;

&lt;p&gt;This is where &lt;strong&gt;ZtLiteESP&lt;/strong&gt; comes in.&lt;/p&gt;

&lt;p&gt;It keeps only what is actually required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ESP32
  │
  ├── Identity
  ├── Discovery
  ├── WHOIS
  ├── Path
  ├── NAT traversal
  ├── Crypto
  └── USER_MESSAGE
          │
          ▼
      Application
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And intentionally does not include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VL2
TAP
ARP/NDP
ZeroTier IP
IP routing
network rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea is not to create a new ZeroTier protocol.&lt;/p&gt;

&lt;p&gt;Internally, ZtLite uses the platform-independent &lt;code&gt;node/&lt;/code&gt; core from ZeroTier One. The ESP32-specific layer provides the integration with ESP-IDF, FreeRTOS, and the UDP transport.&lt;/p&gt;

&lt;p&gt;The transport itself is separated from the protocol core, which also makes it possible to debug the same profile outside the physical device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 ZtLite core
                      │
           ┌──────────┼──────────┐
           ▼          ▼          ▼
         ESP32      QEMU       Win32
         (lwIP)   (OpenEth)   (Winsock)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Identity and Planet: Don't Generate Them on the Chip
&lt;/h1&gt;

&lt;p&gt;Generating a ZeroTier identity involves a memory-hard proof-of-work based on SHA-512 and Salsa20.&lt;/p&gt;

&lt;p&gt;On a PC, this operation is relatively inexpensive.&lt;/p&gt;

&lt;p&gt;On a microcontroller, doing it during the first boot is much less attractive.&lt;/p&gt;

&lt;p&gt;This is why &lt;strong&gt;ZtGenStorage&lt;/strong&gt; was created.&lt;/p&gt;

&lt;p&gt;It generates the ZeroTier identity in the required format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;identity.public
identity.secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and also prepares the required &lt;code&gt;planet&lt;/code&gt; data.&lt;/p&gt;

&lt;p&gt;The idea is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build / provisioning host
          │
          ├── generate identity
          ├── prepare planet
          │
          ▼
      ESP32 firmware
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MCU does not need to generate an identity during boot. The required data can be generated ahead of time and embedded into the firmware image.&lt;/p&gt;

&lt;p&gt;There is one critical rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One ZeroTier identity must belong to exactly one device.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If multiple devices are flashed with the same &lt;code&gt;identity.secret&lt;/code&gt;, they will collide on the network.&lt;/p&gt;

&lt;p&gt;For production provisioning, each device therefore needs its own generated identity.&lt;/p&gt;




&lt;h1&gt;
  
  
  Testing on a Real ESP32-C6
&lt;/h1&gt;

&lt;p&gt;After debugging the implementation in QEMU, I tested ZtLite on a real ESP32-C6.&lt;/p&gt;

&lt;p&gt;The test used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ESP-IDF 6.0.2;&lt;/li&gt;
&lt;li&gt;Wi-Fi;&lt;/li&gt;
&lt;li&gt;UDP transport on port &lt;code&gt;9995&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After connecting to Wi-Fi, the ESP32 received its IP address, started the ZeroTier node, and reached the &lt;code&gt;ONLINE&lt;/code&gt; state.&lt;/p&gt;

&lt;p&gt;The device then started looking for the remote peer.&lt;/p&gt;

&lt;p&gt;The peer identity was obtained through &lt;code&gt;WHOIS&lt;/code&gt;, after which ZeroTier established the required path.&lt;/p&gt;

&lt;p&gt;The sequence looked approximately like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ONLINE
   ↓
WHOIS → peer identity
   ↓
PATH up
   ↓
USER_MESSAGE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the connection was established, the ESP32 sent several &lt;code&gt;USER_MESSAGE&lt;/code&gt; packets and received replies from ZtSharp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hello from esp32:0
hello from esp32:1
hello from esp32:2
hello from esp32:3
hello from esp32:4
hello from esp32:5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The overall topology was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ESP32-C6
   │
   ▼
Wi-Fi / IP
   │
   ▼
ZeroTier VL1
   │
   ├── WHOIS
   ├── discovery
   └── path
   │
   ▼
USER_MESSAGE
   │
   ▼
ZtSharp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ESP32 did not join a virtual ZeroTier network and did not receive a ZeroTier IP address through VL2.&lt;/p&gt;

&lt;p&gt;For this communication model, VL1 and &lt;code&gt;USER_MESSAGE&lt;/code&gt; were enough.&lt;/p&gt;

&lt;p&gt;The bootstrap process still uses the standard ZeroTier Planet infrastructure for discovery. The ESP32 does not need to be configured as a member of a traditional ZeroTier virtual network.&lt;/p&gt;




&lt;h1&gt;
  
  
  Memory Usage
&lt;/h1&gt;

&lt;p&gt;Memory consumption is one of the important questions when bringing a protocol stack to an ESP32.&lt;/p&gt;

&lt;p&gt;During the test, the free heap changed approximately as follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;after Wi-Fi initialization: &lt;strong&gt;~303 KiB free&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;after transport initialization: &lt;strong&gt;~222 KiB&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;after creating the ZeroTier node: &lt;strong&gt;~192 KiB&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;after reaching &lt;code&gt;ONLINE&lt;/code&gt;: &lt;strong&gt;~183 KiB&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;minimum during the run: &lt;strong&gt;180,664 bytes&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;after completing the message exchanges: &lt;strong&gt;228,816 bytes free&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of the overall memory usage still comes from the ESP-IDF infrastructure itself, especially the Wi-Fi driver and lwIP.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;sizeprobe&lt;/code&gt; utility reports the sizes of individual C++ objects using &lt;code&gt;sizeof&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node          = 4208 bytes
World         =  192 bytes
Identity      =   80 bytes
Salsa20       =   64 bytes
InetAddress   =   28 bytes
Peer          = 1344 bytes
Path          =  376 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the runtime structures used in the test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Trace           =    48 bytes
Switch          = 12592 bytes
Topology        =  1824 bytes
SelfAwareness   =    32 bytes
--------------------------------
total           = 14512 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are &lt;code&gt;sizeof&lt;/code&gt; values and therefore do not represent the complete runtime heap footprint. Dynamic allocations, buffers, container storage, and fragmentation also contribute to the real memory usage.&lt;/p&gt;

&lt;p&gt;The largest structure is &lt;code&gt;Switch&lt;/code&gt;, which is not particularly surprising because it is a central component of VL1 traffic processing.&lt;/p&gt;

&lt;p&gt;At the end of the test, the main FreeRTOS task had &lt;strong&gt;5,680 bytes free out of 28,672 bytes&lt;/strong&gt; according to its stack high-water mark. The ZeroTier UDP task had almost &lt;strong&gt;10 KB of free stack&lt;/strong&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  Firmware Size
&lt;/h1&gt;

&lt;p&gt;The final firmware image for the test configuration was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1,177,048 bytes — approximately 1.12 MiB.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A simplified breakdown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Flash Code      1,063,670 B
.text             777,450 B
.rodata           247,096 B
.eh_frame          38,584 B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DIRAM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Used       140,830 B
Total      452,112 B
Usage       31.15 %
Free       311,282 B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LP SRAM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Used             24 B
Total        16,384 B
Usage          0.15 %
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These numbers describe the &lt;strong&gt;entire firmware&lt;/strong&gt;, not the ZeroTier VL1 implementation alone.&lt;/p&gt;

&lt;p&gt;The image contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ESP-IDF;&lt;/li&gt;
&lt;li&gt;Wi-Fi;&lt;/li&gt;
&lt;li&gt;lwIP;&lt;/li&gt;
&lt;li&gt;FreeRTOS;&lt;/li&gt;
&lt;li&gt;diagnostic code;&lt;/li&gt;
&lt;li&gt;the ESP32-specific ZtLite integration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the firmware size should not be interpreted as the size of the ZeroTier core itself.&lt;/p&gt;

&lt;p&gt;Still, combined with the runtime measurements, the result demonstrates an important point:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A VL1-only ZeroTier endpoint can realistically run on an ESP32-C6 while leaving a useful amount of RAM for the application.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  What Has Actually Been Achieved?
&lt;/h1&gt;

&lt;p&gt;The goal was not to create yet another incomplete "ZeroTier port for ESP32".&lt;/p&gt;

&lt;p&gt;Instead, the roles of the components are deliberately separated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ZtSharp&lt;/strong&gt; uses ZeroTier as the foundation for a complete managed network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VL1 + VL2 + managed TCP/IP stack + socket API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;ZtLiteESP&lt;/strong&gt; uses only the parts that an embedded device actually needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;identity
discovery
path establishment
cryptography
USER_MESSAGE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;strong&gt;ZtGenStorage&lt;/strong&gt; moves expensive identity/bootstrap preparation outside the MCU.&lt;/p&gt;

&lt;p&gt;The main conclusion is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ZeroTier can be used not only as a virtual Ethernet network, but also as a secure P2P transport layer.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a server, a complete managed TCP/IP stack can be built on top of it.&lt;/p&gt;

&lt;p&gt;For a small microcontroller, it can be enough to keep VL1 and exchange small messages directly between peers.&lt;/p&gt;

&lt;p&gt;The same protocol foundation can therefore serve both endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 ZeroTier VL1
                      │
          ┌───────────┴───────────┐
          │                       │
       ZtSharp                 ZtLiteESP
          │                       │
     VL1 + VL2                 VL1 only
          │                       │
   Managed TCP/IP            USER_MESSAGE
          │                       │
        .NET                   ESP32-C6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For applications that only need telemetry, commands, remote control, or small peer-to-peer messages, this can be significantly simpler than deploying a complete VPN/IP stack on the MCU.&lt;/p&gt;




&lt;h1&gt;
  
  
  Projects
&lt;/h1&gt;

&lt;p&gt;The source code and tools are available on GitHub:&lt;/p&gt;

&lt;h3&gt;
  
  
  ZtSharp
&lt;/h3&gt;

&lt;p&gt;Fully managed ZeroTier implementation for C#/.NET with VL1/VL2, a managed TCP/IP stack, and socket APIs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/smersh1307n2/ZtSharp" rel="noopener noreferrer"&gt;https://github.com/smersh1307n2/ZtSharp&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  ZtLiteESP
&lt;/h3&gt;

&lt;p&gt;Minimal VL1-only ZeroTier client for ESP32 with direct peer-to-peer &lt;code&gt;USER_MESSAGE&lt;/code&gt; communication.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/smersh1307n2/ZtLiteESP" rel="noopener noreferrer"&gt;https://github.com/smersh1307n2/ZtLiteESP&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  ZtGenStorage
&lt;/h3&gt;

&lt;p&gt;Tool for preparing ZeroTier identity and &lt;code&gt;planet&lt;/code&gt; data before flashing the device.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/smersh1307n2/ZtGenStorage" rel="noopener noreferrer"&gt;https://github.com/smersh1307n2/ZtGenStorage&lt;/a&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  Final Thoughts
&lt;/h1&gt;

&lt;p&gt;The interesting part of this experiment is not simply getting ZeroTier to run on an ESP32.&lt;/p&gt;

&lt;p&gt;The more interesting part is realizing that &lt;strong&gt;ZeroTier's VL1 can be useful as an independent secure P2P transport&lt;/strong&gt;, without necessarily bringing the complete virtual Ethernet and IP stack to every embedded device.&lt;/p&gt;

&lt;p&gt;For embedded systems that only need a secure path for telemetry and commands, this architecture can be much lighter than deploying a complete virtual network stack.&lt;/p&gt;

</description>
      <category>esp32</category>
      <category>zerotier</category>
      <category>csharp</category>
      <category>cpp</category>
    </item>
    <item>
      <title>ESPB: WASM-like bytecode interpreter for ESP32 with seamless FreeRTOS integration. Part 2: The JIT Compiler</title>
      <dc:creator>Smersh</dc:creator>
      <pubDate>Sun, 22 Feb 2026 13:00:50 +0000</pubDate>
      <link>https://dev.to/andrey_asx_6a2421d07b6d62/espb-wasm-like-bytecode-interpreter-for-esp32-with-seamless-freertos-integration-part-2-the-jit-4o41</link>
      <guid>https://dev.to/andrey_asx_6a2421d07b6d62/espb-wasm-like-bytecode-interpreter-for-esp32-with-seamless-freertos-integration-part-2-the-jit-4o41</guid>
      <description>&lt;p&gt;&lt;strong&gt;Hi.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Exactly 3 months have passed since the first publication.&lt;br&gt;
During this time, I’ve shaken things up significantly: I added a full-fledged JIT for Xtensa and RISC-V and implemented a heap of optimizations in the translator. I tested it on ESP32, ESP32-C3, and ESP32-C6 chips (the latter was tested on a residual basis—I only ran the main test; primary debugging was done on the first two).&lt;/p&gt;

&lt;p&gt;Here are the main innovations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Fast Symbols: Killing strcmp in Linking&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Among the new features, I added Fast Symbols in addition to the standard symbol tables. These are two tables: one for system functions (ESP-IDF) and another custom one for your convenience.&lt;br&gt;
The core idea is that we remove the string name of functions from the binary, leaving only the pointer. This approach requires strict coordination between the table and the translator so that the translator knows exactly which index to use for calling a function via libffi. This reduces the space occupied in flash memory and eliminates the slow strcmp during module loading.&lt;br&gt;
At runtime, linking turns into instant address retrieval from a flat array:&lt;br&gt;
But what about Kconfig?&lt;br&gt;
In ESP-IDF, modules can be disabled (for example, cutting out GPIO). If we simply remove a function from the array, all subsequent indices will shift, and the wrong function will be called. This issue is solved via a macro:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// idf_fast.sym
ESPB_SYM("printf", (const void*)&amp;amp;printf)
ESPB_SYM("vTaskDelay", (const void*)&amp;amp;vTaskDelay)
// If GPIO is disabled in menuconfig, the macro substitutes NULL but keeps the index!
ESPB_SYM_OPT(CONFIG_ESPB_IDF_GPIO, "gpio_set_level", (const void*)&amp;amp;gpio_set_level)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The array size and the order of indices remain absolutely stable regardless of the firmware configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. JIT Compiler&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The second feature is JIT. I decided that the best approach is to give the developer the ability to manually mark the specific functions in the code that need to be translated into machine code.&lt;br&gt;
ESPB is originally designed as a register machine (up to 256 virtual registers). All the complex mathematics (Graph Coloring, Register Allocation) are handled by the C# translator on the PC. The ESPB runtime on the microcontroller is left with the simplest task: generating Xtensa or RISC-V instructions.&lt;br&gt;
How it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the C/C++ script code, the developer marks a heavy function with the JIT_HOT macro.&lt;/li&gt;
&lt;li&gt;The translator sees this and sets the ESPB_FUNC_FLAG_HOT flag in the function header within the .espb file.&lt;/li&gt;
&lt;li&gt;When instantiating the module, the runtime allocates memory via heap_caps_malloc(size, MALLOC_CAP_EXEC) (memory where execution is permitted).&lt;/li&gt;
&lt;li&gt;The JIT engine generates the binary code and places the pointer in the table.&lt;/li&gt;
&lt;li&gt;Cold code (e.g., one-time initialization) remains in Flash memory as bytecode, saving expensive IRAM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;P.S. Surprisingly, implementing JIT for the Xtensa architecture was the hardest part due to its register window ABI and literal pools.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Moment of Truth: ESPB vs WAMR&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I went to the trouble of preparing a project for the wasm-micro-runtime (WAMR) from Espressif with an implementation of the Fibonacci(85) test, identical to the one I use for ESPB.&lt;/p&gt;

&lt;p&gt;Tests were conducted on an ESP32-C3 chip (160 MHz):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1dc4xvyd6d7qzc9ix9iy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1dc4xvyd6d7qzc9ix9iy.jpg" alt=" " width="800" height="195"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pure ESPB interpreter currently works slower than the WAMR interpreter. My efforts here weren't enough, and there is room for growth (ESPB currently lacks super-opcodes where multiple actions are baked into one instruction, and the .espb translator—as well as the interpreter—can still be optimized for a long time).&lt;br&gt;
But the good news is that hot code works, and here we are 2+ times faster than WAMR's best mode, judging by this single test, of course. By the way, WAMR for ESP32 generally does not have a JIT compiler, only Classic and Fast interpreter modes.&lt;br&gt;
It is evident that the team of programmers meticulously optimized the WAMR interpreters, which commands respect. The comparison currently isn't in favor of the creation of a suffering indie-coder, however.&lt;br&gt;
I am not considering the AOT mode in WAMR, as the main idea is to make a single bytecode work on all systems.&lt;br&gt;
By the way, another direction for development (besides optimization) is emerging here: I see it as "AOT on Device". That is, compiling all code into JIT, placing it in a partition on the flash, and subsequently executing it via XIP (Execute In Place). All of this needs to be generously diluted with a GOT (Global Offset Table) so that main firmware updates via OTA allow this AOT version to continue working. I need to conduct experiments first, but I think this direction should be viable. I'm actually considering this as the main mode if it works out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Battle for Memory&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I compiled five firmware variants for ESP32-C3: from an empty "Hello World" to "full option."&lt;/p&gt;

&lt;p&gt;Figures from the build report (idf.py size):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvbtfuudmr1wdc0a79tvg.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvbtfuudmr1wdc0a79tvg.jpg" alt=" " width="800" height="194"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we see:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Smallest Engine: The pure ESPB interpreter (No JIT) takes up less space in Flash memory than even the most basic WAMR Classic (~2.5 KB less).&lt;br&gt;
Note: The WASM was generated with Lib pthread, Libc builtin, Libc WASI, and Loader mode-normal options.&lt;br&gt;
Cost of JIT: Enabling the JIT compiler in ESPB increases the firmware size (Flash) by approximately 56 KB. Static RAM consumption (DRAM) does not change.&lt;br&gt;
DRAM: All runtimes add about 11–12 KB to RAM consumption relative to an empty project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Script Sizes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Size of .wasm file — 1277 bytes (uses LEB128 compression).&lt;/li&gt;
&lt;li&gt;Size of .espb file — 1511 bytes (fixed types for speed).&lt;/li&gt;
&lt;li&gt;Generated JIT code for two test functions occupied 2494 bytes in IRAM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;5. FFI: Death to "Glue Code"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Simple functions are easy to call everywhere. But the real pain begins when you need to use callbacks. Imagine a task: create a software FreeRTOS timer (xTimerCreate) that calls a function inside your script when triggered.&lt;br&gt;
Let's see how this is solved in WAMR and ESPB.&lt;br&gt;
WAMR: Architectural Pain&lt;br&gt;
WASM is isolated from the microcontroller's memory. You cannot simply pass a pointer to a function into FreeRTOS because the native code doesn't know where to look for this function inside the virtual machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1.&lt;/strong&gt; Write the script (Guest side).&lt;br&gt;
We cannot pass the function directly. We have to pass its index in the table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// WASM (Guest)
typedef void (*timer_cb_t)(uint32_t, uint32_t);

// Get function index (in wasm32 this is not an address, but an index!)
timer_cb_t cb_ptr = test_timer_cb;
uint32_t cb_func_idx = (uint32_t)(uintptr_t)cb_ptr;

// Call custom wrapper, passing index instead of pointer
xTimerCreate_native("tmr", 2000, 1, 0, cb_func_idx);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2.&lt;/strong&gt; Write the "Bridge" in firmware (Host side).&lt;br&gt;
This is the scary part. We need to create a context structure, write a native wrapper for timer creation, and a native callback adapter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Host (Firmware)

// 1. Context to pass arguments through
typedef struct {
    wasm_exec_env_t cb_exec_env;
    uint32_t        cb_func_idx; 
    // ... more fields for instance and handle
} wasm_timer_ctx_t;

// 2. Native callback adapter
static void native_timer_callback(TimerHandle_t xTimer) {
    wasm_timer_ctx_t *ctx = (wasm_timer_ctx_t *)pvTimerGetTimerID(xTimer);
    uint32_t argv[2] = { ctx-&amp;gt;wasm_handle, ctx-&amp;gt;timer_id };

    // Manual interpreter call
    wasm_runtime_call_indirect(ctx-&amp;gt;cb_exec_env, ctx-&amp;gt;cb_func_idx, 2, argv);
}

// 3. Wrapper over xTimerCreate
static uint32_t native_xTimerCreate(wasm_exec_env_t exec_env, 
                                    const char *name, uint32_t period, 
                                    uint32_t reload, uint32_t id, 
                                    uint32_t cb_idx) {
    // ... need to allocate context, save env, create timer ...
    // ... pass native_timer_callback instead of real callback ...
    return (uint32_t)handle;
}

// 4. Registration with scary signatures
static NativeSymbol native_symbols[] = {
    { "xTimerCreate_native", native_xTimerCreate, "($iiii)i", NULL }
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; ~100 lines of code just to start one timer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ESPB: Zero Glue Code&lt;/strong&gt;&lt;br&gt;
In ESPB, I solved this problem systemically. The translator knows that xTimerCreate accepts a callback. The runtime generates a trampoline on the fly via libffi in IRAM, which FreeRTOS sees as a standard C function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1. Write the script.&lt;/strong&gt;&lt;br&gt;
It's just standard C code. We pass the test_timer_cb function as is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ESPB (Script)
static void test_timer_cb(TimerHandle_t xTimer) {
    printf("Timer tick!\n");
}

void app_main(void) {
    // Call standard FreeRTOS API
    TimerHandle_t t = xTimerCreate("tcb", pdMS_TO_TICKS(2000), 
                                   pdTRUE, NULL, test_timer_cb);
    if (t) xTimerStart(t, 0);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2. Add to firmware.&lt;/strong&gt;&lt;br&gt;
We don't need wrappers. We simply export 3 functions: create timer, get ID (for context), and the command control (since xTimerStart is a macro over xTimerGenericCommand).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Host (Firmware) - Symbol Table
ESPB_SYM("xTimerCreate", (const void*)&amp;amp;xTimerCreate)
ESPB_SYM("pvTimerGetTimerID", (const void*)&amp;amp;pvTimerGetTimerID)
ESPB_SYM("xTimerGenericCommand", (const void*)&amp;amp;xTimerGenericCommand)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; 0 lines of glue code (only symbol registration). You write the script as if it were part of the firmware. The runtime itself understands that a function pointer was passed and creates a native closure for it.&lt;/p&gt;

&lt;p&gt;I uploaded the WAMR project to GitHub:&lt;br&gt;
&lt;a href="https://github.com/smersh1307n2/wamr" rel="noopener noreferrer"&gt;https://github.com/smersh1307n2/wamr&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Developer Experience: No "Header Hell"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Usually, development for custom VMs is painful. The IDE doesn't see system headers (FreeRTOS), autocompletion doesn't work, and to compile a script, you have to manually specify hundreds of paths to include directories.&lt;br&gt;
I solved this problem radically: using the standard ESP-IDF build system.&lt;br&gt;
You write script code in a normal C/C++ project inside VS Code. IntelliSense, code navigation, and error highlighting work because the project is configured as a legal ESP32 application.&lt;br&gt;
For bytecode compilation, I wrote a PowerShell script get-ir-cmake.ps1 that performs magic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pulls actual build flags directly from your project's CMake.&lt;/li&gt;
&lt;li&gt;Compiles script files using Clang into LLVM Bitcode (.bc).&lt;/li&gt;
&lt;li&gt;Links the result (llvm-link) into a single .bc file, ready to be sent to the translator.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You only have to write code and mark critical sections with JIT_HOT.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Translation (Desktop Client)&lt;/strong&gt;&lt;br&gt;
I created the ESPB Desktop Client. This is a lightweight utility that works in conjunction with a cloud translator.&lt;br&gt;
You simply feed the client the required files.&lt;br&gt;
The client sends them to the cloud, where the server performs register optimization, calculates metadata for FFI, replaces string function names with indices (Fast Symbols), and returns the finished .espb file to the specified location in the ESPB Desktop Client.&lt;/p&gt;

&lt;p&gt;On that note, allow me to take my leave.&lt;/p&gt;

&lt;p&gt;Online Translator: &lt;br&gt;
&lt;a href="http://espb.runasp.net/" rel="noopener noreferrer"&gt;http://espb.runasp.net/&lt;/a&gt;&lt;br&gt;
Interpreter Repository: &lt;br&gt;
&lt;a href="https://github.com/smersh1307n2/ESPB" rel="noopener noreferrer"&gt;https://github.com/smersh1307n2/ESPB&lt;/a&gt;&lt;br&gt;
Project for preparing LLVM IR: &lt;a href="https://github.com/smersh1307n2/ESP32_PRJ_TO_LLVM" rel="noopener noreferrer"&gt;https://github.com/smersh1307n2/ESP32_PRJ_TO_LLVM&lt;/a&gt;&lt;br&gt;
ESPB_Desktop_Client:&lt;br&gt;
&lt;a href="https://github.com/smersh1307n2/ESPB_Desktop_Client" rel="noopener noreferrer"&gt;https://github.com/smersh1307n2/ESPB_Desktop_Client&lt;/a&gt;&lt;br&gt;
I also recorded a video supplement:&lt;br&gt;
&lt;a href="https://www.youtube.com/watch?v=UbcuU-mabLs" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=UbcuU-mabLs&lt;/a&gt;&lt;/p&gt;

</description>
      <category>jit</category>
      <category>esp32</category>
      <category>interpretator</category>
    </item>
    <item>
      <title>ESPB: WASM-like bytecode interpreter for ESP32 with seamless FreeRTOS integration.</title>
      <dc:creator>Smersh</dc:creator>
      <pubDate>Wed, 26 Nov 2025 05:16:57 +0000</pubDate>
      <link>https://dev.to/andrey_asx_6a2421d07b6d62/espb-wasm-like-bytecode-interpreter-for-esp32-with-seamless-freertos-integration-4f19</link>
      <guid>https://dev.to/andrey_asx_6a2421d07b6d62/espb-wasm-like-bytecode-interpreter-for-esp32-with-seamless-freertos-integration-4f19</guid>
      <description>&lt;p&gt;&lt;strong&gt;Hi.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I want to present a project born from a long search for a way to dynamically load code onto a running ESP32 device. I think many have researched this direction.&lt;br&gt;
It all started with an applied task, again, as a "can I do it?" challenge. In simple terms, a device was developed and assembled to switch pumps based on operating hours and manage a make-up system for an individual heating unit. It connects to a phone for monitoring and configuration via Bluetooth. At some point, I wanted to be able to extend its logic with new control schemes directly from the phone, without recompiling or re-flashing the main core. And so it began...&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Agony of Choice: Why Not WASM, Lua, or Something Else?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I considered standard solutions but rejected them for various reasons. In the end, the concepts of an ELF Loader and WASM caught my attention.&lt;br&gt;
ELF Loader: This allows loading native code and executing it at maximum speed, with only a table of function pointers (let's call it a symbol table) on the firmware side. The resulting ELF file is tightly coupled to the architecture. Code compiled for an ESP32-S3 (Xtensa) will not run on an ESP32-C3 (RISC-V). I wanted universality—"one binary for the entire lineup."&lt;br&gt;
WebAssembly (WASM): A sufficiently fast and interesting technology whose bytecode is not tied to a specific architecture. However, anyone who has tried to call a native function like xTaskCreate from WASM and pass a callback to it knows what a pain it is. It requires writing a huge amount of "glue code" and manually registering imports/exports. I wanted to write standard C code using the standard ESP-IDF APIs and have it "just work." This is how the idea for ESPB (ESP Bytecode) was born.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is ESPB?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's an ecosystem consisting of a Translator (which turns your C/(possibly)C++ code into bytecode) and an Interpreter (a virtual machine running on the microcontroller).&lt;br&gt;
The main feature of the project is its seamless integration with the native API. By using a symbol table and a custom implementation of libffi, ESPB allows calling FreeRTOS functions (timers, tasks) directly from a loaded module without writing any wrappers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How It Works Under the Hood:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Translator (based on LLVM)&lt;br&gt;
I didn't invent my compiler from scratch; instead, I used LLVM. The process looks like this:&lt;br&gt;
You write code in a standard ESP-IDF project.&lt;br&gt;
You compile it with clang into LLVM Intermediate Representation (.bc file).&lt;br&gt;
The Translator analyzes this .bc file and generates .espb bytecode as output.&lt;br&gt;
The magic happens in the third step. The Translator performs complex work: it conducts a deep static analysis of the IR to understand the semantics of native function calls.&lt;br&gt;
For example, it sees a call to xTaskCreate and understands that:&lt;br&gt;
the first argument is a pointer to a function that will become the task body;&lt;br&gt;
the last argument is a pointer to a TaskHandle_t, meaning it's an output (OUT) parameter.&lt;br&gt;
Based on this analysis, the translator automatically generates special metadata:&lt;br&gt;
cbmeta section: Information for the interpreter on how to correctly create a "trampoline" for the callback (my_task).&lt;br&gt;
immeta section: Instructions for the interpreter on how to marshal OUT parameters—that is, how to safely copy the task handle from native memory back into the virtual machine's memory after the call.&lt;br&gt;
It is this automatic analysis that eliminates the need to write tons of "glue code" manually.&lt;br&gt;
What If the Automation Fails? The .hints Files&lt;br&gt;
I aimed to make the translator as "smart" as possible. As mentioned, it performs a deep static analysis of LLVM IR, trying to automatically determine the semantics of calls: which pointer is an output (OUT), where the callback function is, and where its user data is.&lt;br&gt;
Automatic analysis is not omnipotent. There will always be non-standard APIs or complex cases where heuristics can fail.&lt;br&gt;
This is precisely why I introduced .hints files. These are simple text files that can be "fed" to the translator along with the .bc file. They allow you to manually "hint" to the translator how to correctly handle a particular function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does it work?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Suppose you have a native function my_complex_api(char* out_buffer, int size, my_callback_t cb). If the translator couldn't automatically determine that out_buffer is an output parameter, you can simply add one line to a .hints file:&lt;br&gt;
Code: Select all&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File my_project.hints&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;my_complex_api: out 0, cb 2&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This entry tells the translator:&lt;br&gt;
"For the function my_complex_api...&lt;br&gt;
...the parameter at index 0 is an output (out).&lt;br&gt;
...and the parameter at index 2 is a callback function (cb)."&lt;/p&gt;

&lt;p&gt;This way, you get full control over the generation of FFI metadata, correcting any inaccuracies of the automatic analysis without changing a single line in the translator's source code.&lt;br&gt;
Interpreter (on the device)&lt;br&gt;
This is a virtual machine that executes the .espb file. It was designed from the ground up specifically for the ESP32 series of microcontrollers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key implementation features:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Custom libffi with "trampolines" placed in IRAM: I took the libffi library as a basis and adapted it to support the Xtensa and RISC-V architectures for this VM. A key feature of my adaptation is a special allocator that places the executable code of closures ("trampolines" for callbacks) in fast IRAM (Instruction RAM). This is critically important as it allows callbacks to be invoked (for example, from FreeRTOS timers).&lt;br&gt;
Register-based machine with a shadow stack: Unlike stack-based VMs, ESPB uses a register-based model. This is closer to the architecture of real processors and allows for the generation of more efficient bytecode. For maximum compactness, register indices in instructions are encoded in just one byte. All operations with the call stack and local variables of functions occur in a special "shadow stack"—a dedicated buffer in RAM, which ensures isolation and predictability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory Isolation:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Isolated Linear Memory and a Private Heap: For each ESPB module, the interpreter allocates a contiguous block of RAM—linear memory. This block becomes the full address space for the executed bytecode. This is where:&lt;br&gt;
Static data is copied: All global variables, string literals, and constant arrays from your C code are placed in this memory when the module is loaded.&lt;br&gt;
A private heap operates: When your ESPB code calls malloc, calloc, or free, it is actually accessing a memory manager (espb_heap_manager) that manages memory allocation within this same isolated block. This prevents fragmentation of the global ESP-IDF heap and increases system stability.&lt;br&gt;
Stack variables are placed: The alloca instruction also allocates memory in this area, emulating the behavior of a native stack.&lt;br&gt;
Vibecoding and the Role of Neural Networks&lt;br&gt;
This project is the result of so-called "vibecoding." It has been a long and rather difficult journey since May. Neural networks helped to implement this project. It was exclusively this symbiosis that allowed me to take on system programming at this level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to Try It?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I tried to make the process as similar as possible to standard ESP32 development. There is a template project, ESP32_PRJ_TO_LLVM. This is effectively a standard ESP-IDF project. You write your code in it, include libraries, and debug. The get-ir-cmake.ps1 script extracts the .bc file from the build system. This file is fed into the online translator (link below), which outputs a ready-to-use .espb file. The .espb file is placed in the firmware (or uploaded via Wi-Fi/UART) and executed by the interpreter. So far, I have only tested hard-coding the .espb file along with the .bin. To obtain the .bc, I used the clang version included with ESP-IDF 5.4. It's worth noting that the translator supports clang versions no higher than 20.1.2.&lt;br&gt;
Example of What Works "Out of the Box"&lt;br&gt;
The most interesting part is that you can write the following code, compile it into bytecode, and it will work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include &amp;lt;stdio.h&amp;gt;

while (true)
{
    vTaskDelay(1000 / portTICK_PERIOD_MS);
}

void my_task(void* pvParam) {
    while(1) {
        printf("Hello from dynamic code!\n");
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}


void app_main(int argc, char* argv[], char* envp[])
{
    xTaskCreate(my_task, "dyn_task", 4048, NULL, 5, NULL); 

    while (true)
    {
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example symbol table in the interpreter&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;static const EspbSymbol cpp_symbols[] = {
    { "printf", (const void*)&amp;amp;printf },         
    { "puts", (const void*)&amp;amp;puts },
    { "vTaskDelay", (const void*)&amp;amp;vTaskDelay },
    { "xTaskCreatePinnedToCore", (const void*)&amp;amp;xTaskCreatePinnedToCore },
    { "xTimerCreate", (const void*)&amp;amp;xTimerCreate },
    { "pvTimerGetTimerID", (const void*)&amp;amp;pvTimerGetTimerID },
    { "xTimerGenericCommand", (const void*)&amp;amp;xTimerGenericCommand },
    { "xTaskGetTickCount", (const void*)&amp;amp;xTaskGetTickCount },
    {"pvTimerGetTimerID", (const void*)pvTimerGetTimerID},
    { "vTaskDelete", (const void*)&amp;amp;vTaskDelete },
    // ... and other necessary functions
    ESP_ELFSYM_END
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tested on Hardware&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn't limit myself to simulators. The entire system was tested and debugged on real devices to ensure cross-architecture compatibility:&lt;br&gt;
ESP32 (dual-core Xtensa LX6)&lt;br&gt;
ESP32-C3 (single-core RISC-V)&lt;br&gt;
ESP32-C6 (single-core RISC-V)&lt;br&gt;
The same .espb file successfully launched and ran on all these platforms, confirming the main idea—the universality of the executable code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Status and Links&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The current implementation of the interpreter does not yet support JIT or AOT—it is a pure interpreter. The project is in an active Proof of Concept (PoC) stage but can already execute quite complex logic. Future plans include polishing, bug fixing, and optimization.&lt;/p&gt;

&lt;p&gt;Online Translator: &lt;a href="http://espb.runasp.net/" rel="noopener noreferrer"&gt;http://espb.runasp.net/&lt;/a&gt;&lt;br&gt;
Interpreter Repository: &lt;a href="https://github.com/smersh1307n2/ESPB" rel="noopener noreferrer"&gt;https://github.com/smersh1307n2/ESPB&lt;/a&gt;&lt;br&gt;
Project for preparing LLVM IR: &lt;a href="https://github.com/smersh1307n2/ESP32_PRJ_TO_LLVM" rel="noopener noreferrer"&gt;https://github.com/smersh1307n2/ESP32_PRJ_TO_LLVM&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the Online Translator, I need to add a translation statistics output to make it clear how the cbmeta and immeta sections are formed. The site is also in its infancy. Essentially, it's just for translating the .espb file for now and contains a generated description.&lt;br&gt;
I would be glad to receive any criticism and advice.&lt;/p&gt;

</description>
      <category>esp32</category>
    </item>
  </channel>
</rss>
