MicroPython for Microcontrollers Module 9 – Packages, .mpy files and custom firmware
Native modules in C: natmods and user C modules
Build a MicroPython native module from C, test its core on a computer, compare natmods with user C modules, and ship one per architecture with a fallback.
What you will learn
- Choose between Python, viper, a natmod and a user C module for a hot function
- Build a natmod from C with dynruntime.mk and mpy_ld.py for one architecture
- Test the C core of a natmod on a computer against its Python reference
- Deploy one natmod per architecture with a pure-Python fallback
Before you start
What you’ll learn · Before you start
What you will learn
- Choose between Python, viper, a natmod and a user C module for a hot function
- Build a natmod from C with dynruntime.mk and mpy_ld.py for one architecture
- Test the C core of a natmod on a computer against its Python reference
- Deploy one natmod per architecture with a pure-Python fallback
Before you start
On this page
Every Modbus RTU frame ends in a CRC: Cyclic redundancy check: a short value computed from a block of bytes by polynomial division, sent along with them, so that a receiver that computes it again can tell when bits were changed on the way. More in “Numbers, bytes and text on small chips”-16 that takes eight shift-and-test steps per byte: 2,048 steps for a 256-byte frame, each of them several bytecode instructions in Python. In C the same steps are a few machine instructions each, and MicroPython can load C as an .mpy file, a natmod: A native .mpy module: an .mpy file whose code is machine code compiled from C, built with MicroPython's dynruntime.mk and mpy_ld.py. A board imports it like any module, but only on the architecture it was built for., with no change to the firmware. The other way in is a user C module: C code compiled into a MicroPython firmware image, for example through c_module() in a manifest, and imported like a built-in module. It can call the port's whole C API, and every change needs a new firmware build.: the same C, compiled into the firmware itself.
This lesson weighs a natmod against its alternatives, builds one from plain C that is tested on your computer first, and ships it so that every board gets either the file for its processor or a Python version that gives the same result.
A natmod is an .mpy file whose code is compiled C
A hot function such as the CRC can run four ways on a board, and two of them are C:
- Plain Python: the function as you wrote it, as bytecode.
- Viper: Python with machine-word types, compiled to machine code by the board or by mpy-cross, as the lessons on emitters showed.
- A natmod: C compiled to an .mpy file of machine code, which a board imports like any module; the documentation says it needs no firmware build, and that it loads only on the architecture it was built for.
- A user C module: C compiled into the firmware itself, imported like a built-in module, with the whole C interface of the port; every change needs a new firmware.
The difference between the two C ways is where the code lives. A natmod is a file you can copy, delete and replace like any .mpy file. In exchange it can call only the functions of mp_fun_table: The fixed table of MicroPython's functions that a natmod calls through, instead of linking against the firmware itself. It is set when the firmware is built, so a natmod can use only the functions it lists., a table that the firmware hands it and that is fixed when the firmware is built, so no driver or system call of the port is in its reach. A user C module is part of the firmware build and can call anything the port has, at the cost of a firmware build per change. Answer three questions about your function:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Written as | Speed | Changing it | What it can call | Boards it runs on |
|---|---|---|---|---|
| Plain Python Delivered as: a .py or .mpy file | Poor bytecode: every bit of the CRC is several instructions | Good edit and copy a file | Good everything the firmware offers Python | Good every board |
| Viper Delivered as: an .mpy with machine code | Good machine code from Python, with machine-word ints | Good edit, compile with -march, copy | Mixed Python calls, viper types and pointers | Mixed builds with an emitter; one file per architecture |
| A natmod in C Delivered as: an .mpy per architecture | Good C compiled with -Os for the processor | Mixed cross-compile and link per architecture, then copy | Mixed only the functions of mp_fun_table | Mixed one .mpy per architecture |
| A user C module Delivered as: part of the firmware | Good C compiled with the rest of the firmware | Poor a new firmware build and a full flash | Good the whole C API of the port | Mixed only boards that run your firmware |
How the answers decide
- “Can you ship firmware that you build yourself?” No, the boards keep stock firmware: rules out A user C module. A user C module exists only in firmware you build; stock firmware cannot import it.
- “Does the C need firmware functions beyond Python objects, such as a driver or malloc?” Yes: rules out A natmod in C. A natmod links only against mp_fun_table, which has no driver or system calls in it.
- “Is the code already written in C, or best written in C?” Yes: adds a caveat to Viper. Viper means rewriting the C as Python with viper's types, and testing it again.
- “Is the code already written in C, or best written in C?” No, it is short Python: adds a caveat to A natmod in C and A user C module. Short Python that is hot is often fast enough in viper, without a C toolchain.
In short: viper first for short integer code; a natmod for code that is best in C, on stock firmware; a user C module for C that needs the port’s own functions, in firmware you build.
Checkpoint
Check: Python, viper, natmod or C module
A natmod is built from plain C, tested before it meets a board
Keep the algorithm in plain C, apart from MicroPython: here crc16_core.h, the CRC of the Modbus specification,
started at 0xFFFF, with 0xA001 folded in after each shift that drops a 1. The natmod wraps it in a few lines, and
the Makefile names the module, the source and the architecture. Step through one build for a Pico, then its import:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The source. natmod.c includes py/dynruntime.h from the MicroPython source tree and the plain C core. The Makefile names the module, the source and the architecture.
- The names. mpy_ld.py --preprocess reads the source for every MP_QSTR_ name, here MP_QSTR_crc16, and writes a header that maps each one to a slot the board fills in at import.
- Compile. The cross-compiler for the architecture, arm-none-eabi-gcc for armv6m, compiles position-independent code with -Os into an ELF object file.
- Link into an .mpy. mpy_ld.py reads the ELF object and resolves every call to the firmware through the slots of mp_fun_table, which the board supplies. It writes code, read-only data and the size of the zeroed globals.
- The file. The result starts with the header of any armv6m .mpy, 4d 06 13 1f, so the Pico's loader checks it like the files of the previous lesson.
- The import. On the Pico, import crc16_armv6m loads the code and calls mpy_init, which stores the crc16 function in the module. From then on crc16_armv6m.crc16(frame) runs the C core.
The tools come from the MicroPython source tree: py/dynruntime.mk holds the rules, and tools/mpy_ld.py, which
needs the pyelftools package, turns the compiled ELF object into an .mpy file. The compiler is the board’s own
cross-compiler, arm-none-eabi-gcc for ARCH=armv6m, xtensa-esp32-elf-gcc for xtensawin, and Clang works from
1.29.0. The file starts with the header of any armv6m .mpy file, 4d 06 13 1f, and the board checks it the same
way.
Because the core is plain C, any computer can test it before a cross-compiler is involved. This program runs the core on four inputs and prints the frame as it goes on the wire:
C · natmod/test_core.c
/* test_core.c: the C core of the natmod, run on a computer before it goes near a board.
It prints the CRC-16 of four inputs; crc16_ref.py prints the same four with the Python version. */
#include <stdint.h>
#include <stdio.h>
#include "crc16_core.h"
static void show(const char *label, const uint8_t *data, size_t len) {
printf("%-26s 0x%04X\n", label, (unsigned)crc16_core(data, len));
}
int main(void) {
const uint8_t check[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'};
const uint8_t request[] = {0x01, 0x03, 0x00, 0x00, 0x00, 0x02};
uint8_t ramp[256];
for (int i = 0; i < 256; i++) {
ramp[i] = (uint8_t)i;
}
show("\"123456789\"", check, sizeof check);
show("01 03 00 00 00 02", request, sizeof request);
show("bytes 0 to 255", ramp, sizeof ramp);
show("no bytes", ramp, 0);
uint16_t crc = crc16_core(request, sizeof request);
printf("the request on the wire: 01 03 00 00 00 02 %02X %02X\n", (unsigned)(crc & 0xFF), (unsigned)(crc >> 8));
return 0;
} Output
"123456789" 0x4B37 01 03 00 00 00 02 0x0BC4 bytes 0 to 255 0xDE6C no bytes 0xFFFF the request on the wire: 01 03 00 00 00 02 C4 0B
Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined test_core.c -o test_core && ./test_core
natmod/crc16_core.h
/* crc16_core.h: the CRC-16 that ends every Modbus RTU frame, in plain C with nothing from MicroPython. */
#ifndef CRC16_CORE_H
#define CRC16_CORE_H
#include <stddef.h>
#include <stdint.h>
static inline uint16_t crc16_core(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int bit = 0; bit < 8; bit++) {
if (crc & 1) {
crc = (uint16_t)((crc >> 1) ^ 0xA001);
} else {
crc >>= 1;
}
}
}
return crc;
}
#endif The first line is the check value of CRC-16/MODBUS in the catalogue of CRC parameters, 0x4B37, so the core is the
right CRC. The last line puts the CRC on the wire as the specification orders, the low byte first: the request ends in
C4 0B. The same four inputs through the Python version give the same four numbers:
Python · reference/check_ref.py
"""The four inputs of test_core.c, through the Python reference: the C core must print the same values."""
from crc16_ref import crc16
request = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x02])
for label, data in (('"123456789"', b"123456789"), ("01 03 00 00 00 02", request),
("bytes 0 to 255", bytes(range(256))), ("no bytes", b"")):
print(f"{label:26} 0x{crc16(data):04X}")
crc = crc16(request)
print("the request on the wire:", (request + bytes([crc & 0xFF, crc >> 8])).hex(" ").upper()) Output
"123456789" 0x4B37 01 03 00 00 00 02 0x0BC4 bytes 0 to 255 0xDE6C no bytes 0xFFFF the request on the wire: 01 03 00 00 00 02 C4 0B
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_ref.py
reference/crc16_ref.py
"""crc16 in Python: the reference the natmod must match, and the fallback for boards that have no natmod."""
def crc16(data):
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc 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
crc16_ref.py will do two jobs: it is the reference every build of the natmod is tested against, and the fallback a
board imports when no natmod fits it.
Testing the C only on the board
A wrong shift or starting value shows up on a board as frames the sensor quietly ignores. Test the core on a computer against known values, such as the check value, first.
Checkpoint
Check: Building and testing a natmod
Check yourself
The questions below cover natmods and their alternatives, one at a time.
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
The C wrapper, the import, and what a natmod cannot do Pro: opens with a Pro, Premium or Ultimate pass
The wrapper is short, but each line of it decides how Python and C meet. This part goes through it line by line, follows one call into the C core and back, and turns the limits of natmods into rules you can check in your C.
In this part:
- natmod.c and its Makefile, line by line
- What a natmod cannot do, and the fix for each
- A check for the two rules a test cannot catch
1 diagram · 3 runnable examples (C, Makefile, Python, crc16_core.h, crc16_table_bad.c, crc16_table_good.c) · 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).
One natmod per architecture, chosen at import, with a fallback Pro: opens with a Pro, Premium or Ultimate pass
A product with Picos and ESP32s needs one natmod per processor and one Python fallback, and every board must find the right one by itself. This part sets out the build matrix, writes the loader that chooses, and compares it with the same core built into the firmware as a user C module.
In this part:
- The build matrix, from each board’s _mpy
- The loader: the natmod for this board, or else Python
- The same core as a user C module
- Exercise: choose the module for a board
- Choose which crc16 module a board should import
- Results of the sample tests
2 runnable examples (C, Python, crc16_ref.py, micropython.cmake, micropython.mk) · 1 exercise with sample tests · about 5 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher · Warm-up
What is the difference between a natmod and a user C module in MicroPython?
Both run C code from MicroPython. A natmod is a native .mpy file: C compiled for one architecture and linked by mpy_ld.py into an .mpy file, which a board imports like any other module, with no change to its firmware. It loads only on the architecture it was built for, and it can call only the functions in mp_fun_table, the fixed table the firmware gives it. A user C module is compiled into the firmware itself, with USER_C_MODULES or c_module() in a manifest, and imported like a built-in module; it can call the port's whole C interface, but every change needs a new firmware build. Choose a natmod for self-contained algorithms on stock firmware, and a user C module when the C needs the port's own functions.
-
Mid to senior
A Modbus gateway on ESP32 boards with stock firmware spends most of its time computing CRC-16 over 256-byte frames in Python. Would you use viper, a natmod or a user C module, and how would you prove the change is correct and worth it?
-
Mid to senior
You port a C library to a natmod. It compiles and passes its tests on your computer, but the natmod build fails, pointing at the library's lookup tables and a global counter. What do natmods not support, and how do you change the C without changing its results?
-
Senior to staff
Your product ships a natmod for Pico, ESP32 and ESP32-C3 boards, with a Python fallback. Design the tests and the CI that keep every natmod and the fallback in agreement, and make a board that runs the fallback visible.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is a natmod?
An .mpy file of machine code compiled from C: a board imports it with no firmware build, only on the architecture it was built for, and it can call only the functions of mp_fun_table.
-
What does a user C module offer over a natmod, and at what cost?
It is compiled into the firmware, so it can call the port's whole C interface, drivers and system calls included; every change needs a new firmware build.
-
Which tools build a natmod?
py/dynruntime.mk for the rules, tools/mpy_ld.py, which needs pyelftools, to turn the ELF object into an .mpy file, and the board's cross-compiler, such as arm-none-eabi-gcc for ARCH=armv6m.
-
What is the check value of CRC-16/MODBUS, and in which byte order does the CRC travel?
0x4B37. The CRC starts at 0xFFFF, folds in 0xA001 after each shift that drops a 1, and goes on the wire low byte first.
-
Why keep a natmod's algorithm in plain C, apart from MicroPython?
Any computer can then test it against known values and a Python reference before a cross-compiler is involved, and the reference doubles as the board's fallback.
-
How does a board check a natmod built for a Pico?
Like any armv6m .mpy file: it starts with the header 4d 06 13 1f, and the board checks its version, sub-version and architecture.
Key takeaways
- A natmod is an .mpy file of machine code compiled from C: a board imports it with no firmware build, only on its
own architecture, and it can call only the functions of
mp_fun_table. - A user C module is compiled into the firmware and can call the port’s whole C interface, but every change needs a new firmware build.
- A natmod is built with
py/dynruntime.mkandtools/mpy_ld.py, onemake ARCH=…per architecture. - Keep the algorithm in plain C and test it on a computer against the Python reference and known values before any cross-compiler runs.
Terms in this lesson
- mp_fun_table
- The fixed table of MicroPython's functions that a natmod calls through, instead of linking against the firmware itself. It is set when the firmware is built, so a natmod can use only the functions it lists.
- natmod
- A native .mpy module: an .mpy file whose code is machine code compiled from C, built with MicroPython's dynruntime.mk and mpy_ld.py. A board imports it like any module, but only on the architecture it was built for.
- user C module
- C code compiled into a MicroPython firmware image, for example through c_module() in a manifest, and imported like a built-in module. It can call the port's whole C API, and every change needs a new firmware build.
References
- Native machine code in .mpy files (natmods) (MicroPython project)
- MicroPython external C modules (MicroPython project)
- MicroPython .mpy files (architectures and compatibility) (MicroPython project)
- MicroPython manifest files (c_module()) (MicroPython project)
- py/dynruntime.mk, the natmod build rules (v1.29.0) (MicroPython project)
- py/dynruntime.h, the functions a natmod calls (v1.29.0) (MicroPython project)
- tools/mpy_ld.py, the natmod linker (v1.29.0) (MicroPython project)
- The natmod examples (examples/natmod, v1.29.0) (MicroPython project)
- The user C module example (examples/usercmodule/cexample, v1.29.0) (MicroPython project)
- MicroPython v1.29.0 release notes (natmods with Clang, memset and memcpy) (MicroPython project)
- Maximising MicroPython speed (viper) (MicroPython project)
- MODBUS over Serial Line: Specification and Implementation Guide V1.02 (CRC generation) (Modbus Organization)
- Catalogue of parametrised CRC algorithms (CRC-16/MODBUS) (CRC RevEng)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress