Error Handling & On-Device Logging¶
A laptop-connected script that crashes just prints a traceback and stops.
A weather station bolted to a fence post that crashes needs to recover on
its own, and needs to leave a trail so you can find out why it crashed
next time you visit. This module covers exception handling patterns for
unattended devices, sys.print_exception, rotating log files on flash,
capturing tracebacks across resets, and machine.reset_cause(). Logging
logic and file-rotation code run and are verified via python3;
machine.reset_cause()/watchdog specifics are reviewed against
MicroPython docs.
The unattended-device mindset¶
The core shift from a desktop script: nobody is watching. Every failure mode needs an automatic answer, not a human pressing Ctrl-C and reading a traceback. Three tiers, cheapest first:
- Catch it, keep going — the failure is local and recoverable (one bad sensor read).
- Catch it, log it, retry with backoff — the failure is probably transient (network, I2C bus glitch).
- Let it crash, but land in a state that recovers — reset the board and rely on a fresh boot, logging what happened first.
import time
def read_sensor_safe(sensor, default=None):
try:
sensor.measure()
return sensor.temperature(), sensor.humidity()
except OSError as e:
print("sensor read failed:", e)
return default
sys.print_exception: readable tracebacks without crashing the REPL¶
Bare except Exception: swallowing the traceback is the single worst
debugging habit in embedded Python — you lose exactly the information
you need. sys.print_exception prints a normal-looking traceback from
inside a try/except without re-raising:
import sys
try:
risky_operation()
except Exception as e:
sys.print_exception(e) # full traceback to stdout/serial
# then decide: retry, log, or let the device reset
To also capture that traceback as a string (for writing to a log file
instead of just printing it), redirect it into an io.StringIO:
import sys
import io
try:
risky_operation()
except Exception as e:
buf = io.StringIO()
sys.print_exception(e, buf)
log_text = buf.getvalue()
print(log_text)
# write log_text to flash — see below
Rotating log files on flash¶
Flash has finite write cycles and finite space — an unbounded log file will eventually fill the filesystem. A simple two-file rotation caps usage:
import os
_LOG_FILE = "app.log"
_LOG_FILE_OLD = "app.log.old"
_MAX_LOG_SIZE = 4096 # bytes
def log_write(message):
try:
size = os.stat(_LOG_FILE)[6]
except OSError:
size = 0
if size > _MAX_LOG_SIZE:
try:
os.remove(_LOG_FILE_OLD)
except OSError:
pass
os.rename(_LOG_FILE, _LOG_FILE_OLD)
with open(_LOG_FILE, "a") as f:
f.write(message + "\n")
This rotation logic is pure file/string operations and is fully testable
with python3 against a temp directory rather than real flash:
import os
def test_log_rotation(tmp_path):
log_file = os.path.join(tmp_path, "app.log")
# simulate writes exceeding _MAX_LOG_SIZE, then assert app.log.old exists
...
Include a timestamp (module 6) in every log line so entries are useful after the fact:
import time
def log_event(msg):
t = time.localtime()
line = "{:04d}-{:02d}-{:02d}T{:02d}:{:02d}:{:02d} {}".format(
t[0], t[1], t[2], t[3], t[4], t[5], msg
)
log_write(line)
Capturing tracebacks across a reset¶
The most useful crash log is the one written just before an unhandled
exception forces a reset. Wrap main() in a top-level catch-all that
writes the traceback to flash before resetting, then check for that file
on the next boot:
import machine
import sys
import io
def main():
... # your actual application
if __name__ == "__main__":
try:
main()
except Exception as e:
buf = io.StringIO()
sys.print_exception(e, buf)
log_write("CRASH: " + buf.getvalue())
time.sleep(1)
machine.reset() # hard reset — start clean
On the next boot, check machine.reset_cause() to know whether this was
a clean start or a recovery:
import machine
cause = machine.reset_cause()
if cause == machine.PWRON_RESET:
print("normal power-on")
elif cause == machine.HARD_RESET:
print("recovered from a crash reset — check app.log")
elif cause == machine.WDT_RESET:
print("watchdog fired — something hung")
The watchdog timer: a safety net for hangs¶
Some failures don't raise an exception at all — a hung I2C read can block
forever. machine.WDT resets the board if it isn't "fed" within a
timeout, catching hangs that plain try/except can't:
import machine
wdt = machine.WDT(timeout=10_000) # must feed within 10 s or reset
while True:
do_periodic_work()
wdt.feed() # reaching this line proves we're alive
time.sleep(2)
Once created, a WDT on most ports cannot be disabled — starting one
commits the program to feeding it regularly for the rest of its run, so
only enable it once the main loop structure is stable and you're
confident every code path (including error-handling branches) reaches a
feed() call reasonably often.
Error-handling traps¶
Bare except: hides the real bug
except: (or except Exception: without inspecting e) catches
everything, including bugs you actually want to know about (a typo'd
variable name raising NameError). Catch the specific exception type
you expect (OSError for hardware I/O) and let genuinely unexpected
exceptions propagate to your crash-logging wrapper.
Logging itself can fail
Writing to a full or corrupted filesystem raises OSError too — wrap
log-writing calls in their own try/except so a logging failure
doesn't mask (or crash out of) the original error you were trying to
record.
The watchdog can't be fed from inside a hang
wdt.feed() only helps if your main loop keeps running long enough to
reach it. A genuinely stuck i2c.readfrom_mem() call with no timeout
will still trigger the watchdog reset — which is the correct
outcome, but don't mistake "I added a watchdog" for "I fixed the
hang."
Reset-cause checks must run early, before anything else can crash
Put the machine.reset_cause() check at the very top of main.py,
before any sensor/network init that could itself raise — otherwise a
crash during startup keeps you from ever seeing why the previous
boot failed.
How It Actually Works¶
The tools in this module exist because MicroPython's exception machinery and
its watchdog are both genuinely minimal, C-level facilities — not the rich
introspection layer desktop Python's traceback module provides.
sys.print_exceptionexists because MicroPython's exception objects carry a much thinner traceback than CPython's. CPython builds a linked list of frame objects with full local-variable scopes as an exception propagates; MicroPython, to save RAM, stores a compact list of (bytecode-offset, line-number, function-name) tuples instead — enough to reconstruct a readable traceback text, but not a live, inspectable frame chain.sys.print_exception(e, buf)walks that compact representation and formats it into text, which is also why capturing it into anio.StringIOworks: you're getting a formatted string, not a picklable object graph the waytraceback.format_exceptionon the desktop can sometimes imply.- The watchdog timer (
machine.WDT) is a small hardware counter completely independent of the VM — this is precisely what lets it catch hangs no amount oftry/exceptcan. It's implemented as a dedicated countdown timer peripheral wired directly to the chip's reset line;wdt.feed()just writes a reload value into that peripheral's register. If the register isn't rewritten before the countdown reaches zero, the peripheral itself asserts a hardware reset — a mechanism operating entirely outside the Python interpreter, which is why it can recover from a genuinely stuck blocking C call (a hung I2C transaction with no timeout) that no Python-level exception handler could ever see, since the VM itself is the thing frozen. machine.reset_cause()reads a small set of status bits the boot ROM preserves specifically to answer "why did I just start running." Different reset sources (power-on brown-out detection, the watchdog peripheral firing, an explicitmachine.reset(), deep-sleep wake) each set a distinct value in a status register the ROM bootloader reads before handing off to the MicroPython firmware — this is genuinely the same mechanism deep sleep'swake_reason()(module 3) uses, generalized to cover every reset path, not just sleep/wake.- Log rotation on flash matters here for the same LittleFS reasons as Level 1 module 7 — repeated small appends fragment write cycles across flash sectors, and an ever-growing single file eventually can't find a contiguous run of blocks to extend into. A crash-log writer is a particularly aggressive log producer (every unhandled exception writes a full traceback, potentially many lines) which is exactly why pairing crash logging with a hard size cap and rotation is treated as non-optional here rather than a nice-to-have.
Cheat sheet¶
| Function / idiom | Purpose |
|---|---|
sys.print_exception(e) |
Print a full traceback without crashing |
sys.print_exception(e, io.StringIO()) |
Capture the traceback as a string |
rotating app.log / app.log.old |
Cap flash usage from logging |
machine.reset_cause() |
PWRON_RESET, HARD_RESET, WDT_RESET, DEEPSLEEP_RESET |
machine.reset() |
Force a clean reboot after logging a crash |
machine.WDT(timeout=ms) |
Reset the board if feed() isn't called in time |
wdt.feed() |
Prove the main loop is alive; call every iteration |
catch specific exceptions (OSError), not bare except: |
Don't hide real bugs |
Exercise¶
Build (and unit-test the pure-Python parts with python3) a small
logger.py module: log_write(message) with the size-based rotation
shown above, and log_event(msg) that prefixes a timestamp. Write tests
that create a temp file, write past _MAX_LOG_SIZE, and assert the
.old rotation happened and the newest file only contains post-rotation
entries. Then write main.py for a board: check machine.reset_cause()
at the top and log which kind of boot this was; wrap the rest of main()
in a try/except Exception that captures the traceback via
sys.print_exception(e, io.StringIO()), logs it as "CRASH: " + ...,
and calls machine.reset(). Add a machine.WDT(timeout=10_000) fed once
per loop iteration, and deliberately introduce a bug (an unhandled
ZeroDivisionError on some loop count) to confirm the crash gets logged
and the board comes back up cleanly.