MicroPython for Microcontrollers Module 7 – Memory limits, speed, native and viper code
Inline assembler: asm_thumb and asm_rv32
Write @micropython.asm_thumb functions for the Pico with registers, labels and loops, know what its Cortex-M0+ lacks, and where asm_rv32 and Xtensa fit.
What you will learn
- Write a small @micropython.asm_thumb function that takes arguments in r0 to r3 and returns r0
- Loop over a buffer with labels, conditional branches and byte loads, and count its cycles
- Identify which Thumb instructions the RP2040's Cortex-M0+ cannot run, and what to write instead
- Decide when hand-written assembler is worth its maintenance cost
Before you start
What you’ll learn · Before you start
What you will learn
- Write a small @micropython.asm_thumb function that takes arguments in r0 to r3 and returns r0
- Loop over a buffer with labels, conditional branches and byte loads, and count its cycles
- Identify which Thumb instructions the RP2040's Cortex-M0+ cannot run, and what to write instead
- Decide when hand-written assembler is worth its maintenance cost
Before you start
On this page
add(r0, r0, r1) is a whole MicroPython function body: under @micropython.asm_thumb it becomes one machine
instruction, and asm_add(40, 2) returns 42. The inline assembler: MicroPython's way of writing a function in the processor's own instructions, one Python-style call per instruction, under @micropython.asm_thumb, asm_rv32 or asm_xtensa. The compiler turns the calls into machine code when it compiles the module. lets a few lines
of a hot loop be written in the processor’s own instructions, each one a Python-style call, but a Pico runs only part
of the instruction set the documentation’s examples use.
Every assembler function below runs on a model of that part, written from MicroPython’s instruction definitions and the RP2040 datasheet. It refuses what the Pico cannot run, with MicroPython’s own message, and counts the cycles of what it can.
An assembler function is a list of instructions
An assembler function takes up to four arguments, which must be named r0 to r3 in that order, and returns
whatever is in r0 at the end, read as a signed integer. Each argument arrives in the
register: One of the small, fast storage places inside a processor that instructions work on directly, 32 bits wide on the RP2040. An asm_thumb function receives its arguments in r0 to r3 and returns the value left in r0. of its name: a small integer as its value, a bytes, bytearray or array as
the address of its data, a list or a tuple as the address of its items. A return type -> uint makes Python read
r0 as unsigned.
The functions here are written for the board, with @micropython.asm_thumb. On a computer, run_adders.py puts the
model, thumb_model.py, in place of the micropython module and calls them:
Python · thumb/run_adders.py
"""Run adders.py on the Thumb model, then try an instruction from a Pyboard tutorial."""
import sys
import types
import thumb_model
sys.modules["micropython"] = types.SimpleNamespace(asm_thumb=thumb_model.asm_thumb)
from adders import asm_add, sadd, uadd # noqa: E402 (after the stand-in is in place)
print("asm_add(40, 2) =", asm_add(40, 2), "in", asm_add.instructions, "instruction")
print("sadd(0x40000000, 0x40000000) =", sadd(0x40000000, 0x40000000))
print("uadd(0x40000000, 0x40000000) =", hex(uadd(0x40000000, 0x40000000)))
try:
@thumb_model.asm_thumb
def forty_two():
movw(r0, 42) # noqa: F821 (an instruction name, read by the assembler)
except SyntaxError as e:
print("movw(r0, 42) on a Pico: SyntaxError:", e) Output
asm_add(40, 2) = 42 in 1 instruction sadd(0x40000000, 0x40000000) = -2147483648 uadd(0x40000000, 0x40000000) = 0x80000000 movw(r0, 42) on a Pico: SyntaxError: unsupported Thumb instruction 'movw' with 2 arguments
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 run_adders.py
thumb/adders.py
"""Assembler functions for a Pico: written for the board, run on a computer through thumb_model.py."""
import micropython
@micropython.asm_thumb
def asm_add(r0, r1):
add(r0, r0, r1)
@micropython.asm_thumb
def sadd(r0, r1):
add(r0, r0, r1)
@micropython.asm_thumb
def uadd(r0, r1) -> uint:
add(r0, r0, r1) thumb/thumb_model.py
"""A model of @micropython.asm_thumb for CPython, with the ARMv6-M subset a Raspberry Pi Pico runs.
Decorate a function with asm_thumb from here and call it. Its body is read as a list of
instructions with the meanings MicroPython's documentation gives them, and run on eight 32-bit
registers and the N, Z, C and V flags; r0 is returned as a signed integer, or unsigned with
-> uint. A bytes or bytearray argument arrives as the address of its data, as on the board.
Instructions the RP2040 lacks are refused with MicroPython's own message. The model also counts
the instructions run and their cycles by Table 81 of the RP2040 datasheet (zero wait states,
without MicroPython's own call overhead). It does not check branch ranges.
"""
import annotationlib
import types
MASK = 0xFFFFFFFF
ARMV7M_ONLY = {"clz", "rbit", "mrs", "movw", "movt", "movwt", "ldrex", "strex", "sdiv", "udiv",
"it", "ite", "itt", "itte", "ittt", "itee", "ittee", "itttt"}
LOADS = {"ldrb": (1, 0x1F), "ldrh": (2, 0x3E), "ldr": (4, 0x7C)}
STORES = {"strb": (1, 0x1F), "strh": (2, 0x3E), "str": (4, 0x7C)}
TWO_REGS = {"mov", "cmp", "cmn", "tst", "and_", "orr", "eor", "bic", "mvn", "mul", "neg", "adc", "sbc",
"lsl", "lsr", "asr", "ror"}
CONDITIONS = {
"eq": lambda n, z, c, v: z, "ne": lambda n, z, c, v: not z,
"cs": lambda n, z, c, v: c, "cc": lambda n, z, c, v: not c,
"mi": lambda n, z, c, v: n, "pl": lambda n, z, c, v: not n,
"vs": lambda n, z, c, v: v, "vc": lambda n, z, c, v: not v,
"hi": lambda n, z, c, v: c and not z, "ls": lambda n, z, c, v: not c or z,
"ge": lambda n, z, c, v: n == v, "lt": lambda n, z, c, v: n != v,
"gt": lambda n, z, c, v: not z and n == v, "le": lambda n, z, c, v: z or n != v,
}
class Reg:
def __init__(self, n):
self.n = n
class Name:
"""Any other name in the body: an instruction when it is called, a label when it is passed."""
def __init__(self, name, record):
self.name, self.record = name, record
def __call__(self, *args):
self.record(self.name, args)
class Names(dict):
def __init__(self, record):
super().__init__({"r%d" % i: Reg(i) for i in range(8)})
self.record = record
def __missing__(self, key):
return Name(key, self.record)
def _reg(op, x, top=7):
if not isinstance(x, Reg) or x.n > top:
raise SyntaxError("'%s' expects at most r%d" % (op, top))
return x
def _imm(op, x, mask):
if not isinstance(x, int) or x & ~mask:
raise SyntaxError("'%s' integer 0x%x doesn't fit in mask 0x%x" % (op, x, mask))
return x
def _check(op, a):
"""The operands of one instruction, checked as MicroPython's assembler checks them."""
if op in ("mov", "cmp", "add", "sub") and len(a) == 2 and isinstance(a[1], int):
return (_reg(op, a[0]), _imm(op, a[1], 0xFF))
if op in ("add", "sub") and len(a) == 3:
third = _imm(op, a[2], 0x7) if isinstance(a[2], int) else _reg(op, a[2])
return (_reg(op, a[0]), _reg(op, a[1]), third)
if op in ("lsl", "lsr", "asr") and len(a) == 3:
return (_reg(op, a[0]), _reg(op, a[1]), _imm(op, a[2], 0x1F))
if op in LOADS or op in STORES:
base, offset = a[1]
return (_reg(op, a[0]), _reg(op, base), _imm(op, offset, (LOADS.get(op) or STORES.get(op))[1]))
if op in TWO_REGS and len(a) == 2:
return (_reg(op, a[0]), _reg(op, a[1]))
if op == "b" or (op.startswith("b") and op[1:] in CONDITIONS):
if len(a) != 1 or not isinstance(a[0], Name):
raise SyntaxError("'%s' expects a label" % op)
return (a[0].name,)
if op == "nop" and not a:
return ()
raise NotImplementedError("'%s' with %d arguments is not in this model" % (op, len(a)))
def asm_thumb(func):
code = func.__code__
params = code.co_varnames[:code.co_argcount]
if len(params) > 4:
raise SyntaxError("can only have up to 4 parameters to Thumb assembly")
if list(params) != ["r%d" % i for i in range(len(params))]:
raise SyntaxError("parameters must be registers in sequence r0 to r3")
program, labels = [], {}
def record(op, args):
if op == "label":
labels[args[0].name] = len(program)
elif op in ARMV7M_ONLY or op.endswith("_w"):
raise SyntaxError("unsupported Thumb instruction '%s' with %d arguments" % (op, len(args)))
else:
program.append((op, _check(op, args)))
types.FunctionType(code, Names(record))(*[Reg(i) for i in range(len(params))])
hints = annotationlib.get_annotations(func, format=annotationlib.Format.STRING)
return Function(func.__name__, program, labels, hints.get("return") == "uint")
class Function:
"""An assembled function: call it like the board would, then read instructions and cycles."""
def __init__(self, name, program, labels, unsigned):
self.__name__, self.program, self.labels, self.unsigned = name, program, labels, unsigned
self.instructions = self.cycles = 0
def __call__(self, *args):
regs, memory = [0] * 8, {}
for i, arg in enumerate(args):
if isinstance(arg, (bytes, bytearray)):
base = 0x20000000 + 0x10000 * (len(memory) + 1)
memory[base] = arg
regs[i] = base
else:
regs[i] = int(arg or 0) & MASK # None and False arrive as 0, True as 1
flag = {"n": False, "z": False, "c": False, "v": False}
def val(x):
return regs[x.n] if isinstance(x, Reg) else x
def find(addr, size):
for base, buf in memory.items():
if base <= addr and addr + size <= base + len(buf):
return buf, addr - base
raise IndexError("address 0x%08x is outside the buffers passed in" % addr)
def nz(r):
flag["n"], flag["z"] = bool(r >> 31), r == 0
return r
def add(x, y, carry=0):
r = (x + y + carry) & MASK
flag["c"], flag["v"] = x + y + carry > MASK, bool((x ^ r) & (y ^ r) & 0x80000000)
return nz(r)
def shift(op, x, s):
if s == 0:
return nz(x)
if op == "lsl":
flag["c"] = s <= 32 and bool(x >> (32 - s) & 1)
return nz(x << s & MASK)
if op == "lsr":
flag["c"] = s <= 32 and bool(x >> (s - 1) & 1)
return nz(x >> s & MASK)
signed = x - (1 << 32) if x >> 31 else x
flag["c"] = bool(signed >> (min(s, 32) - 1) & 1)
return nz(signed >> min(s, 32) & MASK)
pc = self.instructions = self.cycles = 0
while pc < len(self.program):
op, a = self.program[pc]
pc += 1
self.instructions += 1
self.cycles += 1
if self.instructions > 10_000_000:
raise RuntimeError("more than 10,000,000 instructions: a loop that never ends?")
if op in ("add", "sub", "cmp", "cmn"):
x, y = (val(a[0]), val(a[1])) if len(a) == 2 else (val(a[1]), val(a[2]))
r = add(x, y) if op in ("add", "cmn") else add(x, ~y & MASK, 1) # x - y is x + ~y + 1
if op in ("add", "sub"):
regs[a[0].n] = r
elif op in ("adc", "sbc"):
x, y = val(a[0]), val(a[1])
regs[a[0].n] = add(x, y if op == "adc" else ~y & MASK, int(flag["c"]))
elif op == "neg":
regs[a[0].n] = add(0, ~val(a[1]) & MASK, 1)
elif op == "mov":
regs[a[0].n] = val(a[1])
if isinstance(a[1], int): # mov of a number sets N and Z; between registers, no flag
nz(regs[a[0].n])
elif op in ("and_", "orr", "eor", "bic", "mvn", "mul", "tst"):
x, y = val(a[0]), val(a[1])
r = nz({"and_": x & y, "orr": x | y, "eor": x ^ y, "bic": x & ~y, "mvn": ~y,
"mul": x * y, "tst": x & y}[op] & MASK)
if op != "tst":
regs[a[0].n] = r
elif op in ("lsl", "lsr", "asr"):
if len(a) == 3:
s = a[2] if a[2] or op == "lsl" else 32 # an immediate 0 means 32 for lsr and asr
regs[a[0].n] = shift(op, val(a[1]), s)
else:
regs[a[0].n] = shift(op, val(a[0]), val(a[1]) & 0xFF)
elif op == "ror":
x, s = val(a[0]), val(a[1]) & 31
regs[a[0].n] = nz((x >> s | x << (32 - s)) & MASK)
elif op in LOADS:
size = LOADS[op][0]
buf, i = find(val(a[1]) + a[2], size)
regs[a[0].n] = int.from_bytes(buf[i:i + size], "little")
self.cycles += 1 # a load from RAM takes 2 cycles
elif op in STORES:
size = STORES[op][0]
buf, i = find(val(a[1]) + a[2], size)
buf[i:i + size] = (val(a[0]) & ((1 << 8 * size) - 1)).to_bytes(size, "little")
self.cycles += 1 # a store to RAM takes 2 cycles
elif op == "b" or op[1:] in CONDITIONS:
if op == "b" or CONDITIONS[op[1:]](flag["n"], flag["z"], flag["c"], flag["v"]):
pc = self.labels[a[0]]
self.cycles += 1 # a taken branch takes 2 cycles, one not taken 1
r0 = regs[0]
return r0 if self.unsigned or not r0 >> 31 else r0 - (1 << 32) Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
asm_add(40, 2) took one instruction. The same addition of 0x40000000 and 0x40000000 reads as −2,147,483,648 without
a return type, because bit 31 is set, and as 0x80000000 with -> uint, the case the documentation’s hints use. The
last line is the documentation’s own first example, movw(r0, 42), refused by the model as a Pico refuses it.
The model knows the instructions of this lesson: moves, additions and subtractions, comparisons, the logical and shift operations, multiplication, byte, halfword and word loads and stores, labels and branches. It runs them with the meanings MicroPython’s documentation gives, on eight registers and four condition flags: Four bits a processor sets from the result of most arithmetic: N for negative, Z for zero, C for carry and V for overflow. Conditional branches such as bne or bgt test them to decide whether to jump.. It is not the board: it skips the call overhead and checks no branch ranges, so a function that works there still needs a run on a Pico before it ships.
Passing a float to an assembler function
A float argument is converted to a whole number before it reaches the register, so asm_add(1.5, 2) returns 3 with
no error. Assembler sees only 32-bit words: convert readings to integers, or pass an array of floats by address.
Checkpoint
Check: Arguments in r0 to r3, the result in r0
The Pico runs the ARMv6-M subset
The RP2040’s two Cortex-M0+ cores implement the ARMv6-M architecture, which the datasheet describes as all of the
16-bit Thumb instructions of ARMv7-M except CBZ, CBNZ and IT, plus six 32-bit ones. The documentation’s assembler
pages describe the larger Thumb-2 set of ARMv7-M, with the Pyboard’s STM32 in mind, and MicroPython’s assembler checks
which set it is building for: on a Pico it refuses the ARMv7-M instructions, movw among them, with “unsupported
Thumb instruction”. Examples that use them fail at compile time, and their module with them.
Two other assemblers follow the same idea. @micropython.asm_rv32 writes 32-bit RISC-V code: added in 1.25, it is
enabled on the rp2 port when an RP2350 runs in RISC-V mode, and its arguments arrive in a0 to a3. A routine
written for asm_thumb cannot run there: asm_rv32 has its own registers and instructions, so each needs its own
code. The Xtensa assembler, with arguments in a2 to a5, is built into the ESP8266’s standard firmware; since 1.28
it also supports windowed cores such as the ESP32’s, but the esp32 port’s standard build leaves it switched off.
| Assembler | Decorator | Arguments | Where the standard firmware has it |
|---|---|---|---|
| Thumb | @micropython.asm_thumb |
r0 to r3 |
rp2 in Arm mode (ARMv6-M on the RP2040), and stm32 |
| RISC-V | @micropython.asm_rv32 |
a0 to a3 |
rp2 with an RP2350 in RISC-V mode |
| Xtensa | @micropython.asm_xtensa |
a2 to a5 |
the esp8266 port; on the ESP32 only in a custom build |
Checkpoint
Check: The ARMv6-M subset of the Pico
Check yourself
Arguments, return values and the Pico’s subset: each question explains the choice you make.
Practice that adapts to you
Practise this lesson
6 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Loops in Thumb assembler: labels, branches and loads, counted in cycles Pro: opens with a Pro, Premium or Ultimate pass
A loop is where assembler can pay, and where it is easiest to get wrong. This part builds a byte-sum loop from labels, flags and loads, steps through its registers, and counts its cycles before and after unrolling it.
In this part:
- Labels, branches and the flags they test
- A byte-sum loop, register by register
- Unrolling, counted: 7 cycles a byte against 4
1 diagram · 1 runnable example (Python, sums.py) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
What the Cortex-M0+ lacks, and when assembler is worth its cost Pro: opens with a Pro, Premium or Ultimate pass
Code written for the larger ARMv7-M set meets a smaller one on the Pico, and every one of the gaps has a known way around it. This part lists them, checks one of the workarounds on all 65,536 inputs it must handle, and ends with the questions to ask before any assembler is written.
In this part:
- Pyboard instructions a Pico refuses, and what to write instead
- When hand-written assembler pays
- Exercise: count the set bits of a word
- Count the set bits of a word in Thumb assembler
- Results of the sample tests
1 runnable example (Python) · 1 exercise with sample tests · about 7 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Mid to senior
An inline assembler routine that ran on a Pyboard fails on a Pico at import with SyntaxError: unsupported Thumb instruction 'movwt' with 2 arguments. Why, and how do you port it?
-
Senior to staff
A teammate wants to rewrite a viper filter in inline assembler for a 20 % gain on the filter. How do you decide, and what must come with the change if you accept it?
-
Mid to senior
Estimate how long a Thumb loop that sums 64 KB of bytes takes on a Pico at its default clock, and say what the estimate leaves out.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does an @micropython.asm_thumb function take arguments and return a value?
Up to four arguments, named r0 to r3 in that order, each in the register of its name; it returns what is in r0 at the end, read as signed unless the return type is uint.
-
What reaches the register when an assembler function is given a bytearray?
The address of its data. A list or a tuple gives the address of its items, and a small integer its value.
-
What happens to a float passed to an assembler function?
It is turned into a whole number before it reaches the register, with no error: asm_add(1.5, 2) returns 3.
-
Why does movw(r0, 42) fail on a Pico?
The RP2040's Cortex-M0+ cores run the ARMv6-M subset, and MicroPython's assembler refuses ARMv7-M instructions such as movw at compile time, as an unsupported Thumb instruction.
-
Where do @micropython.asm_rv32 and the Xtensa assembler run?
asm_rv32, added in 1.25 with arguments in a0 to a3, on an RP2350 in RISC-V mode. The Xtensa assembler, with arguments in a2 to a5, is in the ESP8266's standard firmware, while the standard ESP32 build leaves it off.
-
Can an asm_thumb routine run on an RP2350 in RISC-V mode?
No: asm_rv32 has its own registers and instructions, so each assembler needs code of its own.
Key takeaways
- An
@micropython.asm_thumbfunction takes up to four arguments inr0tor3and returnsr0, signed unless the return type isuint; buffers arrive as the address of their data. - The RP2040 runs the ARMv6-M subset, and MicroPython’s assembler refuses ARMv7-M instructions such as
movwthere at compile time. @micropython.asm_rv32is the RISC-V assembler of an RP2350 in RISC-V mode, and the Xtensa assembler is standard on the ESP8266 only.
Terms in this lesson
- condition flags
- Four bits a processor sets from the result of most arithmetic: N for negative, Z for zero, C for carry and V for overflow. Conditional branches such as bne or bgt test them to decide whether to jump.
- inline assembler
- MicroPython's way of writing a function in the processor's own instructions, one Python-style call per instruction, under @micropython.asm_thumb, asm_rv32 or asm_xtensa. The compiler turns the calls into machine code when it compiles the module.
- register
- One of the small, fast storage places inside a processor that instructions work on directly, 32 bits wide on the RP2040. An asm_thumb function receives its arguments in r0 to r3 and returns the value left in r0.
References
- Inline assembler for Thumb2 architectures (MicroPython project)
- Inline assembler, hints and tips (arguments, return values, uint) (MicroPython project)
- Inline assembler, branch instructions and condition codes (MicroPython project)
- Inline assembler, comparison instructions and the APSR flags (MicroPython project)
- Pyboard tutorial: inline assembler (up to 4 arguments, the value in r0) (MicroPython project)
- Quick reference for the RP2 (the default CPU frequency) (MicroPython project)
- py/emitinlinethumb.c, the Thumb inline assembler and its ARMv7-M checks (v1.29.0) (MicroPython project)
- py/emitinlinerv32.c, the RISC-V inline assembler (v1.29.0) (MicroPython project)
- py/emitinlinextensa.c, the Xtensa inline assembler (v1.29.0) (MicroPython project)
- py/objfun.c, how arguments reach an assembler function (v1.29.0) (MicroPython project)
- py/mpconfig.h, the inline Xtensa assembler off by default (v1.29.0) (MicroPython project)
- ports/esp8266/boards/ESP8266_GENERIC/mpconfigboard.h, the inline Xtensa assembler on (v1.29.0) (MicroPython project)
- ports/stm32/mpconfigport.h and ports/rp2/mpconfigport.h, the inline Thumb assembler on (v1.29.0) (MicroPython project)
- Maximising MicroPython speed (the order of optimisation steps) (MicroPython project)
- MicroPython v1.25.0 release notes (@micropython.asm_rv32) (MicroPython project)
- MicroPython v1.28.0 release notes (inline Xtensa assembler on windowed cores) (MicroPython project)
- RP2040 Datasheet (2.4 Cortex-M0+, Table 81 instruction cycle counts) (Raspberry Pi Ltd)
- General information about the RP2xxx port (RP2040 and RP2350 processors) (MicroPython project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress