07 · Deploying to a Microcontroller¶
The desktop program from Module 06 was the dress rehearsal; this module is opening night. We take the same model bytes and near-identical code to an ESP32 — the course's reference board (~$5, 520 KB RAM, 4 MB flash, 240 MHz, WiFi) — using the Arduino toolchain. You'll see what actually changes on-device (very little code, a lot of environment), how memory constraints bite in practice, and how far you can get with the free Wokwi browser simulator if you don't own hardware.
No board? Keep reading anyway
This module works as a guided tour: every constraint discussed here (arena sizing, flash layout, latency measurement) is a concept you already met on the desktop — here you see its on-device face. Wokwi can simulate the ESP32 and its Arduino toolchain in the browser; TFLM projects compile there too, though large library builds can be slow.
The Arduino + TFLM setup¶
- Install the Arduino IDE, then add
ESP32 support: Settings → Additional boards manager URLs →
https://espressif.github.io/arduino-esp32/package_esp32_index.json, then install esp32 in the Boards Manager. - Install a TFLM Arduino library. The maintained fork for ESP32 is
TFLite Micro for Espressif Chips (
esp-tflite-micro); Arduino's Library Manager also carries TFLM builds such as the Chirale_TensorFlowLite library for Arduino boards. Any of them provides the same headers you used in Module 06. - Create a sketch folder containing the
.inofile below plus yoursine_model_data.h/sine_model_data.ccfrom Module 06.
The sketch: Module 06's code in Arduino clothes¶
// sine_esp32.ino
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/micro_mutable_op_resolver.h"
#include "tensorflow/lite/schema/schema_generated.h"
#include "sine_model_data.h"
namespace {
constexpr int kArenaSize = 4 * 1024;
alignas(16) uint8_t tensor_arena[kArenaSize];
const tflite::Model* model = nullptr;
tflite::MicroInterpreter* interpreter = nullptr;
TfLiteTensor* input = nullptr;
TfLiteTensor* output = nullptr;
}
void setup() {
Serial.begin(115200);
model = tflite::GetModel(g_sine_model);
static tflite::MicroMutableOpResolver<1> resolver;
resolver.AddFullyConnected();
static tflite::MicroInterpreter static_interpreter(
model, resolver, tensor_arena, kArenaSize);
interpreter = &static_interpreter;
if (interpreter->AllocateTensors() != kTfLiteOk) {
Serial.println("AllocateTensors failed");
while (true) {} // halt: nothing sensible to do without a model
}
input = interpreter->input(0);
output = interpreter->output(0);
Serial.printf("arena used: %u bytes\n",
(unsigned) interpreter->arena_used_bytes());
}
void loop() {
static float x = 0.0f;
input->data.int8[0] = (int8_t) roundf(
x / input->params.scale + input->params.zero_point);
uint32_t t0 = micros();
interpreter->Invoke();
uint32_t dt = micros() - t0;
float y = (output->data.int8[0] - output->params.zero_point)
* output->params.scale;
Serial.printf("x=%.3f sin(x)~=%.3f (%u us)\n", x, y, (unsigned) dt);
x += 0.3f;
if (x > 6.2832f) x = 0.0f;
delay(200);
}
Compare with Module 06's main.cc: the model loading, resolver, arena,
quantize/invoke/dequantize logic are line-for-line identical. What
changed is packaging — setup()/loop() instead of main(), Serial
instead of printf, static objects so they outlive setup(). This is
the payoff of the desktop-first workflow: the hard parts were debugged
where you had a real debugger and instant rebuilds.
Select your board and port, click Upload, open the Serial Monitor at 115200 baud, and watch sine values stream out at ~15–40 µs per inference — your first neural network running on a $5 chip.
Where memory constraints actually bite¶
On the desktop you never thought about these; on-device they are the job:
- Flash layout. Your firmware + TFLM library + model array share flash.
The Arduino IDE prints usage after each build ("Sketch uses 412,318 bytes
(31%)…"). The TFLM library alone costs ~100–300 KB; each registered op
adds more — which is why
MicroMutableOpResolverexists instead of an everything-included resolver. - RAM partitioning. The ESP32's ~320 KB of usable data RAM must hold
the stack, WiFi buffers (if used, ~40 KB+), your app, and the tensor
arena. The desktop trick of "start with a huge arena" fails here — a
200 KB arena on top of WiFi may simply not link or will crash at boot.
Size it with
arena_used_bytes()+ ~10% headroom. - The model array placement.
constputs the model in flash-mapped memory, read in place; forgettingconstcopies ~all of it into RAM at boot. On our 2.5 KB sine model, invisible; on a 300 KB vision model, fatal. - Latency is now real.
micros()aroundInvoke()is the on-device equivalent of a profiler. The sine model runs in tens of microseconds; the MNIST CNN from Module 05 takes tens of milliseconds — numbers you'll budget against sensor sampling rates in Module 08.
Boards smaller than the ESP32
On an Arduino Uno (2 KB RAM) this course's runtime doesn't fit at all — classic TinyML boards start around the Arduino Nano 33 BLE Sense (256 KB RAM). If your board has under ~64 KB of RAM, check the TFLM documentation's supported-platforms list before buying anything else.
Trying it in Wokwi¶
Wokwi simulates ESP32 Arduino projects in the
browser, including third-party libraries via its libraries.txt project
file. Practical notes from the trenches:
- Start from an ESP32 template project, add your
.inoand the model files, and add the TFLM library tolibraries.txt. - Builds of TFLM-sized libraries are slow in the free tier — minutes, not seconds. Patience, or trim to the desktop path for iteration and use Wokwi only for the final demo.
- Serial output works exactly like the real Serial Monitor, so the sketch above runs unmodified.
- Wokwi also simulates sensors (potentiometers, MPU6050 accelerometer) — which becomes genuinely useful in Module 08 and the capstone.
Cheat sheet¶
| Task | How |
|---|---|
| Add ESP32 boards to Arduino | Boards Manager URL → install esp32 |
| TFLM library for ESP32 | esp-tflite-micro (Espressif) or Library Manager TFLM builds |
| Keep model in flash | alignas(16) const unsigned char g_model[] |
Interpreter objects that survive setup() |
declare them static |
| Measure inference latency | micros() before/after Invoke() |
| Check flash fit | Arduino IDE build output ("Sketch uses …") |
| Right-size arena | arena_used_bytes() + ~10% headroom |
| Fatal init error idiom | print, then while (true) {} |
| Serial output | Serial.begin(115200) + Serial Monitor |
| Browser simulation | wokwi.com ESP32 project + libraries.txt |
How It Actually Works¶
Why static inside setup() is required, not stylistic. Arduino's
setup() returns and its stack frame is torn down before loop() starts
running repeatedly; a MicroInterpreter (and the tensors/pointers it hands
back) built on that stack would become a dangling reference the instant
setup() returns. static gives the object static storage duration — it
lives in a fixed, non-stack memory region for the entire life of the
program, exactly like the file-scope tensor_arena — so interpreter,
input, and output remain valid pointers into that object for every
loop() iteration afterward. This is a C++ storage-class detail, but on an
MCU with no memory protection unit catching use-after-scope bugs, getting
it wrong doesn't crash predictably — it corrupts whatever memory the stack
happens to reuse next.
Why the ESP32's flash-mapped execution changes the const/RAM story
further than a typical MCU. The ESP32 doesn't just place const arrays
in a flash address range the CPU can read directly — it runs code and
constant data through the flash cache/MMU, which maps 4 MB (or more) of
external SPI flash into the CPU's addressable memory in fixed-size pages,
demand-cached the way a desktop OS pages virtual memory, just far simpler.
A const model array is read straight through this cache with no copy;
removing const forces the loader to place it in the .data section,
which — because SPI flash is not directly writable-in-place by ordinary
stores — must be copied into real SRAM at boot by the startup code,
which is where the "several-hundred-KB difference" the module warns about
actually comes from physically.
Why WiFi silently steals tens of KB of RAM before your model ever runs.
The ESP-IDF WiFi stack pre-allocates DMA-capable buffer pools for TX/RX at
WiFi.begin()-equivalent init — packet buffers, encryption scratch space,
and driver control structures — sized for worst-case throughput, not for
whatever your app actually needs. Because TFLM's tensor arena is also a
single static allocation requested at compile/init time, the two compete
for the same finite internal SRAM pool with no negotiation: if WiFi
allocates first and takes 40+ KB, the linker or runtime allocator simply
has less left to satisfy your arena request, which is precisely why sizing
an arena "the desktop way" (start huge, shrink later) fails on a
radio-equipped board in a way it never does on a bare Cortex-M eval board.
Exercise¶
- (Board or Wokwi) Get the sketch running and record: arena bytes used,
flash percentage from the build output, and average
Invoke()time over 100 runs. - Remove
constfrom the model array (if your Module 06 file had it), rebuild, and compare the IDE's reported dynamic memory usage. Put it back. Extrapolate: what would this mistake cost with a 300 KB model? - Deliberately set
kArenaSizeto 1024. What happens, and — referring back to Module 06 — why is this failure mode considered a feature of TFLM's design? - Paper exercise if simulating: your next model needs a 180 KB arena and the product needs WiFi (~45 KB of RAM) plus 30 KB of application buffers. Draw the ESP32 RAM budget and decide: does it fit in ~320 KB of usable data RAM, and with how much headroom?