Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Clang 21.0.0 and GCC 16.2.0
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: A natmod is an .mpy file whose code is compiled C · A natmod is built from plain C, tested before it meets a board · Check yourself. With a pass: 2 in-depth parts and 3 interview answers, about 12 minutes.

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:

Interactive · answer and compare Which way should a hot function go? Answer three questions about the function and your boards; the table marks each way of running it.
Can you ship firmware that you build yourself?
Does the C need firmware functions beyond Python objects, such as a driver or malloc?
Is the code already written in C, or best written in C?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

A hot function in Python, in viper, in a natmod and in a user C module compared
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

  1. Question 1 of 2 What is the difference between a natmod and a user C module?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A natmod is an .mpy file a board imports; a user C module is compiled into the firmware

    A natmod is a file you copy like any .mpy file and needs no firmware build; a user C module is part of the firmware image, and changes only with it.

  2. Question 2 of 2 The boards keep the stock firmware, and a CRC loop in C is the hot spot. Which way fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A natmod, one .mpy file per architecture

    Stock firmware rules out anything built into the image. A natmod, or viper for short integer code, are the ways to machine code that a stock board imports.

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:

Interactive · stage by stage From natmod.c to an .mpy a Pico imports Step through one make ARCH=armv6m, then the import of the file it writes.
From natmod.c to an .mpy a Pico imports, step 6 of 6. Showing natmod.c, mpy_ld.py --preprocess, arm-none-eabi-gcc, mpy_ld.py links, crc16_armv6m.mpy, import on the Pico. In focus: import on the Pico.natmod.cwith crc16_core.hmpy_ld.py --preprocessfinds the names, as qstrsarm-none-eabi-gcc-fpic -Os, to an ELF objectmpy_ld.py linksagainst mp_fun_tablecrc16_armv6m.mpyheader 4d 06 13 1fimport on the Picompy_init runs once

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. Compile. The cross-compiler for the architecture, arm-none-eabi-gcc for armv6m, compiles position-independent code with -Os into an ELF object file.
  4. 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.
  5. 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.
  6. 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:

The C core of the natmod, run on a computer

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:

The Python reference on the same inputs

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

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.

CRC Calculator (CRC-8/16/32/64) Work out the CRC-16/MODBUS of your own frame and compare it with the board's.

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

  1. Question 1 of 2 Why keep the CRC in crc16_core.h, apart from the natmod wrapper?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: So that any computer can compile and test it, with no MicroPython headers or cross-compiler

    Plain C can be compiled, run and checked anywhere, with two compilers if you like. The wrapper is the only part that needs MicroPython's build.

  2. Question 2 of 2 A natmod built with make ARCH=armv6m starts with which four bytes?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 4d 06 13 1f, like any armv6m .mpy file

    A Pico checks a natmod like any other file of machine code, so a natmod built for another architecture fails with incompatible .mpy arch.

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.

  1. Question 1 of 6 How does a board load a natmod?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: Like any .mpy file, with import, from a folder on sys.path

    import crc16_armv6m finds crc16_armv6m.mpy in /lib, checks its header like any .mpy file and runs its mpy_init.

  2. Question 2 of 6 Your C must call a driver function of the rp2 port. Which way can do it?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: A user C module, built into your firmware

    mp_fun_table is fixed when the firmware is built and holds the runtime's functions, not the port's. C that needs the port goes into the firmware.

  3. Question 3 of 6 A ten-line Python function of integer maths is the hot spot, and you have no cross-compiler set up. What should you try first?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: Viper, compiled by the board or by mpy-cross

    Viper exists for exactly this kind of code. C pays when the code is C already, or best written in C.

  4. Question 4 of 6 Which tool turns the compiled ELF object of a natmod into an .mpy file?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: tools/mpy_ld.py, which needs the pyelftools package

    py/dynruntime.mk runs mpy_ld.py twice: once to find the qstrs in the source, once to link the object into the .mpy file.

  5. Question 5 of 6 Which compiler does make ARCH=xtensawin call by default?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: xtensa-esp32-elf-gcc, the cross-compiler for the ESP32

    Each architecture has its own compiler, so the build matrix of a product is one make per architecture, each with its toolchain installed.

  6. Question 6 of 6 Your C version of CRC-16/MODBUS returns 0x29B1 for the bytes of "123456789". What does that tell you?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: It is not CRC-16/MODBUS; the catalogue's check value is 0x4B37

    0x29B1 is the check value of CRC-16/IBM-3740, which has another polynomial and no bit reflection. Testing the core against the check value catches such a mix-up before a sensor quietly ignores every frame.

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.

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

  1. 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.

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.mk and tools/mpy_ld.py, one make 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.