Pure C99 Ballistic Solver Engine for MicroPython based on bclibc/tiny_bclibc library
Warning
Experimental feature. This repository and the underlying tiny_bclibc C99 engine
are experimental. APIs, binary format, and build system may change without notice in
future releases. Do not use in production firmware without thorough validation on your
specific target.
This repository provides three integration modes for using tiny_bclibc from
MicroPython — choose the one that fits your target and deployment constraints:
| Approach | Location | Architectures | Module deployment |
|---|---|---|---|
natmod (.mpy) |
natmod/ |
x64, x86, armv6m–armv7emdp, xtensa, rv32/64imc | Copy .mpy to device filesystem |
| usermod (baked-in) | usermod/ |
any port with USER_C_MODULES support |
Built into firmware — no file to copy |
FFI (libtiny_bclibc.so) |
ffimod/ |
any unix port arch | import _tiny_bclibc from ffimod/ |
All three expose the same Python API: Shot, Request, Wind, Config,
integrate, integrate_stream, find_zero_angle, zero, aim, fire, find_apex, find_max_range,
and all flag / index constants.
.
├── src/ # Shared C + Python source
│ ├── tiny_bclibc_mp.c # MicroPython C extension (natmod + usermod)
│ ├── tiny_bclibc.py # Python API wrapper (frozen into firmware)
│ ├── bclibc_bcp.py # Ballistic Co-Processor app (frozen only with BCLIBC_BCP=1)
│ ├── drag_tables.h # Built-in G1/G7 drag curve tables
│ └── math_shim.c # math shim for x64/x86 natmod builds
│
├── natmod/ # Native module (.mpy) build
│ ├── Makefile # make ARCH=<x64|armv6m|xtensawin|…> dist
│ ├── ci/run_qemu.py # QEMU UART bridge for natmod CI tests
│ ├── examples/ # Usage examples
│ └── patches/ # MicroPython patches (if any)
│
├── usermod/ # Usermod (baked-into-firmware) build
│ ├── micropython.mk # Picked up by py.mk via USER_C_MODULES (Make ports)
│ ├── micropython.cmake # Picked up by CMake via USER_C_MODULES (RP2040 / pico-sdk)
│ ├── manifest.py # Freezes tiny_bclibc.py into firmware (release + CI),
│ # plus bclibc_bcp.py when BCLIBC_BCP=1
│ └── ci/run_qemu.py # QEMU UART bridge for usermod CI tests
│ # No usermod/Makefile — build directly against the
│ # port's own Makefile/CMakeLists, same as a7p's usermod.
│
├── ffimod/ # FFI-based access (any unix arch)
│ ├── _tiny_bclibc.py # MicroPython ffi wrapper for libtiny_bclibc.so
│ ├── ffi.py # ffi helpers
│ └── uctypes.py # uctypes shim for CPython test runner
│
├── tests/ # Test suite (shared across all modes)
│ ├── test_bclibc.py # Main test suite (natmod / usermod)
│ ├── test_ffi.py # FFI backend tests
│ ├── tiny_bclibc_bench.py # Benchmark script
│ ├── precision_compare.py # float32 vs float64 comparison (CPython runner)
│ └── precision_run.py # MicroPython worker for precision comparison
│
├── benchmarks/ # Extended benchmark results and scripts
│
├── bclibc/ # Git submodule → github.com/ballistics-lab/bclibc
│ # Contains tiny_bclibc C99 engine (include/, src/)
│
├── version.h.in # Version template (filled by Makefile)
├── CHANGELOG.md
└── LICENSE
Native module (.mpy) produced by mpy_ld.py. Deploy by copying .mpy files to the
device filesystem (or embedding them into firmware via FROZEN_MANIFEST).
Supported only on architectures that mpy_ld.py can link:
| Approach | Architectures | Requires |
|---|---|---|
Native .mpy natmod |
x64, x86, armv6m–armv7emdp, xtensa, rv32/64imc | mpy_ld.py linker support |
FFI (libtiny_bclibc.so) |
any unix port arch (aarch64, mipsel, …) | libffi, shared library build |
pip install pyelftools ar # required by mpy_ld.pyThe build system needs MicroPython v1.29 (for dynruntime.mk, mpy-cross,
and lib/libm). Pass MPY_DIR explicitly or place it at ../micropython-1.29.0:
wget https://github.com/micropython/micropython/releases/download/v1.29.0/micropython-1.29.0.tar.xz
tar xf micropython-1.29.0.tar.xz
export MPY_DIR=$(pwd)/micropython-1.29.0| Target | Package (Debian/Ubuntu) |
|---|---|
| x64 | gcc (host compiler, already installed) |
| x86 | gcc-i686-linux-gnu (MicroPython ≥ v1.29.0) |
| RP2040 / Cortex-M | gcc-arm-none-eabi libnewlib-arm-none-eabi |
| ESP32-C3/C6 (RISC-V 32/64) | gcc-riscv64-unknown-elf picolibc-riscv64-unknown-elf |
| ESP32 / ESP32-S3 | xtensa-esp32{s3}-elf-gcc (from ESP-IDF) |
sudo apt-get install gcc-arm-none-eabi libnewlib-arm-none-eabi \
gcc-i686-linux-gnu gcc-riscv64-unknown-elfAll commands are run from natmod/. There's a single make ARCH=<value> dist
entry point — no per-board alias targets to keep in sync. Always single precision now —
no sp/dp split and no precision flag to pick, on any ARCH (see natmod/Makefile's
own "Precision" header for why).
make ARCH=x64 dist # x64 → natmod/build/x64/
make ARCH=x86 dist # x86 → natmod/build/x86/
make ARCH=armv6m dist # Cortex-M0+ → natmod/build/armv6m/ — Raspberry Pi Pico
make ARCH=armv7m dist # Cortex-M3 → natmod/build/armv7m/ — generic Cortex-M3
make ARCH=armv7emsp dist # Cortex-M4F/M7, single-FPU → natmod/build/armv7emsp/ — STM32F4 (hard-float, dynruntime.mk default)
make ARCH=armv7emdp dist # Cortex-M7, double-FPU (hardware) → natmod/build/armv7emdp/ — STM32H7
make ARCH=xtensawin dist # ESP32/ESP32-S3 → natmod/build/xtensawin/
make ARCH=xtensa dist # ESP8266 → natmod/build/xtensa/
make ARCH=rv32imc dist # ESP32-C3/C6 (RISC-V 32) → natmod/build/rv32imc/
make ARCH=rv64imc dist # RISC-V 64 → natmod/build/rv64imc/Output per target: a single natmod/build/<arch>/tiny_bclibc.mpy — the native part
and src/tiny_bclibc.py are merged into one file (see natmod/Makefile's SRC), so only
one file needs to be copied to the device / uploaded as a release artifact.
bc.version() returns "1.1.3-sp".
The armv7emsp build above targets dynruntime.mk's own default
(-mfloat-abi=hard) — right for STM32F4 and for the "32-bit ARM Linux"
test route below, but wrong for RP2350: pico-sdk builds that firmware
-mfloat-abi=softfp (a pico-sdk quirk, not something this project
controls — see upstream
micropython#19279 /
#19661). A hard
natmod loads and runs fine on RP2350 but every float crossing the
natmod↔interpreter boundary comes back corrupted — no error, just wrong
numbers. Build RP2350's variant explicitly:
make ARCH=armv7emsp RP2350=1 dist # → natmod/build/armv7emsp/tiny_bclibc.mpy (softfp)Not part of the main package.json release manifest: the on-disk .mpy
format has no room to tag this ABI difference (see natmod/Makefile's own
comment above the RP2350 override for why), so mip could never choose
between two entries sharing one armv7emsp arch tag. Every tagged release
carries this build anyway, as its own separate asset + manifest pair
(tiny_bclibc.rp2350.mpy + package.rp2350.json — see release.yml's own
comment for how) — point mip straight at that manifest's release URL
instead of the default package.json:
mpremote mip install https://github.com/<owner>/<repo>/releases/download/<tag>/package.rp2350.jsonOr build it yourself locally with the command above.
Two of the four ARM ARCHes can be exercised on a real 32-bit ARM Linux
MicroPython, without an emulator and without a board — which is what CI's
test (armv7emsp | armv7emdp / 32-bit ARM Linux) jobs do on ubuntu-24.04-arm.
Useful locally too, if you have any ARM box to hand:
# a 32-bit ARM host interpreter; drop the -D for armv7emdp (double is the
# unix port's own default)
make -C /path/to/micropython-1.29.0/ports/unix VARIANT=standard \
BUILD=/tmp/mpy-armhf CROSS_COMPILE=arm-linux-gnueabihf- \
MICROPY_PY_FFI=0 MICROPY_PY_BTREE=0 \
CFLAGS_EXTRA=-DMICROPY_FLOAT_IMPL=MICROPY_FLOAT_IMPL_FLOAT
ln -sf ../natmod/build/armv7emsp/tiny_bclibc.mpy tests/tiny_bclibc.mpy
/tmp/mpy-armhf/micropython tests/test_bclibc.pyWhy it works: py/persistentcode.h gives a Thumb-2 host with a
double-precision FPU MPY_FEATURE_ARCH = MP_NATIVE_ARCH_ARMV7EMDP, and the
compatibility test is a range (ARMV6M <= x <= that), not an equality — so
every ARM natmod ARCH passes the header check on an armhf host.
Why only two: the arch check does not cover the float ABI. armv6m and
armv7m get no -mfloat-abi=hard from py/dynruntime.mk, so their floats
cross into the runtime in core registers while an armhf host expects them in
VFP registers. Those .mpys load and then return nonsense — find_zero_angle
came back 984.252 rad (the range in feet) instead of 0.002502. Silently
wrong, never a crash, so do not do it. armv7emsp and armv7emdp are
hard-float and line up, each against a host built with its own
MICROPY_FLOAT_IMPL — that governs mp_float_t on both sides of the
dynruntime call boundary.
What this does not replace: the QEMU Cortex-M3 and rp2040py legs. Those run firmware — no OS, the port's own libc, real flash layout. This runs Cortex-M code inside a Linux process, which proves the native module and its relocations are right on real ARM silicon and nothing more.
make clean # rm -rf natmod/build/ natmod/generated/Pushing a v* tag runs .github/workflows/release.yml, which builds every arch above
(by calling natmod.yml as a reusable workflow) and publishes a GitHub Release with one
tiny_bclibc_<arch>.native.mpy asset per architecture, plus a package.json
(see tools/build_release_assets.py). Each board picks the matching variant on its own,
via the optional per-entry native-code compatibility tag schema proposed upstream
(micropython/micropython#19532,
micropython/micropython-lib#1144;
see the discussion at micropython/micropython#19479).
Until that lands upstream, the mip already on your device (frozen into stock firmware,
or a stock mpremote) doesn't understand the tagged urls entries yet and will raise
ValueError: too many values to unpack on this package.json. Bootstrap a patched mip
first — tools/nmip.py is a drop-in copy of the micropython-lib#1144 branch, installed
under a different name (nmip) so it doesn't collide with (and doesn't touch) the frozen
mip module already on the device:
>>> import mip
>>> mip.install("github:ballistics-lab/micropython-bclibc/tools/nmip.py")
Downloading github:ballistics-lab/micropython-bclibc/tools/nmip.py to /home/murphy/.micropython/lib
Copying: /home/murphy/.micropython/lib/nmip.py
Done
>>> import nmip as mip
>>> mip.install("https://github.com/ballistics-lab/micropython-bclibc/releases/download/v1.2.1")
Installing https://github.com/ballistics-lab/micropython-bclibc/releases/download/v1.2.1/package.json to /home/murphy/.micropython/lib
Copying: /home/murphy/.micropython/lib/tiny_bclibc.mpy
DoneOnce the upstream PRs are merged and shipped in a MicroPython release, plain mip
(on-device) and mpremote (from the host) will handle this directly — no bootstrap needed:
mpremote mip install https://github.com/ballistics-lab/micropython-bclibc/releases/download/vX.Y.Z/package.json# or on-device:
import mip
mip.install("https://github.com/ballistics-lab/micropython-bclibc/releases/download/vX.Y.Z/package.json")make ARCH=armv6m MPY_DIR=/path/to/micropython-1.29.0# Build MicroPython unix binary (must match the .mpy version)
make -C "$MPY_DIR/ports/unix" VARIANT=standard
MPY="$MPY_DIR/ports/unix/build-standard/micropython"
# Build natmod (from natmod/)
make ARCH=x64 dist # → natmod/build/x64/tiny_bclibc.mpy
# Symlink the .mpy into tests/ so test_bclibc.py can import it
ln -sf ../natmod/build/x64/tiny_bclibc.mpy tests/tiny_bclibc.mpy
# Run tests (natmod)
$MPY tests/test_bclibc.py
# Run tests (ffi — calls libtiny_bclibc.so directly, no .mpy needed)
python3 tests/test_ffi.py # CPython
$MPY tests/test_ffi.py # MicroPython unixExpected output ends with === done === and all lines read PASS.
usermod/ compiles tiny_bclibc directly into the MicroPython firmware via
USER_C_MODULES. No .mpy file needs to be copied to the device — the module is always
available as a built-in. Use this approach when:
- natmod can't reach your target at all (aarch64, armhf, mipsel, wasm — no such
dynruntime.mkARCH; Windows — the port disables native code emit entirely, see below); for everything else (x64, x86, RP2040 on stock firmware, …) prefer natmod above — it needs no firmware rebuild - you need a fully static / standalone unix binary for deployment (any unix arch; CI links armhf/mipsel that way because those two need it to run at all)
- you need a genuine build+run test under an emulator (QEMU Cortex-M3, rp2040js)
There is no usermod/Makefile — same as a7p's own usermod integration. You build
directly against the target port's own Makefile/CMakeLists, pointing USER_C_MODULES at
this repo (for Make-based ports) or at usermod/micropython.cmake (for CMake-based
ports like RP2040). usermod/micropython.mk / usermod/micropython.cmake are the entire
"how to build with bclibc" story; there's nothing else to configure.
Always single precision here too — usermod/micropython.mk and usermod/micropython.cmake
both build tiny_bclibc single-precision unconditionally, with no knob to override it
(see natmod/Makefile's own "Precision" header for why).
BCLIBC_BCP=1 builds the Ballistic Co-Processor firmware: the same usermod plus a
frozen application (src/bclibc_bcp.py) that will serve tiny_bclibc calls to a host
over a framed command protocol — see BACKLOG.md. Today it is a placeholder
that only proves the build plumbing. Off by default; a plain build is unchanged.
One variable switches both halves: usermod/manifest.py freezes bclibc_bcp.py, and
usermod/micropython.mk / usermod/micropython.cmake compile the native module with
BCLIBC_BCP defined (which adds the _tiny_bclibc.BCP marker). Pass it on the make
command line or in the environment — GNU make exports command-line variables to the
recipes that run makemanifest.py and, on rp2/esp32, cmake:
# Make port
make -C ports/unix BCLIBC_BCP=1 \
USER_C_MODULES=/path/to/micropython-bclibc \
FROZEN_MANIFEST=/path/to/micropython-bclibc/usermod/manifest.py
# CMake port (rp2 Makefile wraps cmake)
make -C ports/rp2 BOARD=RPI_PICO2 BCLIBC_BCP=1 \
USER_C_MODULES=/path/to/micropython-bclibc/usermod/micropython.cmake \
FROZEN_MANIFEST=/path/to/micropython-bclibc/usermod/manifest.py
# cibuildmp (no generic env passthrough into its containers; extra-make-args is it).
# Replaces the config's extra-make-args, [override]s included.
CIBMP_EXTRA_MAKE_ARGS="BCLIBC_BCP=1" cibuildmp --build "v1.29.0-rp2-RPI_PICO2">>> import bclibc_bcp
>>> bclibc_bcp.BCP
TrueUse a fresh build directory when switching the flag: Make does not rebuild objects on
a CFLAGS change, and CMake reads the environment at configure time only. A build that
froze bclibc_bcp.py without the C half fails loudly at import bclibc_bcp
(can't import name BCP) rather than running half-built.
MICROPY_STANDALONE=1 LDFLAGS_EXTRA="-static" is what to reach for when the target is a
minimal Linux system that can't be assumed to have a matching ld.so/libc (see
micropython/micropython#17456).
MICROPY_STANDALONE=1 only adds lib/libffi to DEPLIBS; deplibs is its own Makefile
target and must be run as a separate step before the main build.
In CI, only armhf and mipsel are built that way, and there it is not a preference:
the arm64 runner has no armhf glibc and no /lib/ld-linux-armhf.so.3, and qemu-user
runs mipsel with no sysroot, so a dynamically linked binary cannot start at all.
aarch64 is a plain dynamic build against the system libffi. It used to be static on
the same deployability argument, and that argument does not survive contact with the
details: it runs natively on the machine that builds it, release.yml only publishes
natmod assets so nothing ships that binary, and a static glibc's dlopen still needs the
matching shared libraries at run time — so ffi.open() works on the machine you did not
need a static binary for, and stops working on the minimal board you did. Drop the two
options below if you want the same plain build locally.
cd /path/to/micropython-1.29.0
# 1. build lib/libffi as a static .a (needs autoconf/libtool/libtool-bin/libltdl-dev
# installed — no manual libtoolize/autoreconf, the port's own ./autogen.sh handles it)
make -C ports/unix MICROPY_STANDALONE=1 deplibs
# 2. build, with this repo's usermod pointed at via USER_C_MODULES
make -C ports/unix VARIANT=standard \
MICROPY_STANDALONE=1 LDFLAGS_EXTRA="-static" \
USER_C_MODULES=/path/to/micropython-bclibc \
FROZEN_MANIFEST=/path/to/micropython-bclibc/usermod/manifest.py
build-standard/micropython /path/to/micropython-bclibc/tests/test_bclibc.pyCross-compiling (armhf/mipsel): add CROSS_COMPILE=arm-linux-gnueabihf- or
CROSS_COMPILE=mipsel-linux-gnu- to both commands above.
In CI those two are no longer treated the same way. armhf is cross-built on
ubuntu-24.04-arm and then run on that runner's own CPU — a GitHub arm64
runner executes 32-bit ARM directly, measured on the runner rather than assumed.
That is also why it uses gnueabihf and not upstream's soft-float gnueabi:
armel baselines at ARMv5TE, whose SWP atomics ARMv8 removed. mipsel is still
emulated, because GitHub has no mips runner; the static binary runs directly
under qemu-user-static with no /usr/gnemul sysroot symlink needed — there's
no dynamic linking left to resolve.
armhf and mipsel link -static against glibc, and glibc warns on every
such link:
Using 'dlopen' in statically linked applications requires at runtime
the shared libraries from the glibc version used for linking
Using 'getaddrinfo' in statically linked applications requires at runtime
the shared libraries from the glibc version used for linking
Both are real: glibc resolves NSS and dlopen through shared objects it still
expects at run time, so a "static" glibc binary is not fully self-contained on
the minimal target it exists for. That is also half of why the aarch64 row
stopped linking static (see above). musl has no NSS and a stub dlopen, so the
same build against musl has neither caveat.
Measured, not assumed — a musl static build came back with zero link
warnings against glibc's two, ldd reporting not a dynamic executable, and
getaddrinfo working. The cost is two config knobs: MICROPY_PY_BTREE=0 and
MICROPY_PY_FFI=0. The second is not free here: ffimod/ exists precisely to
load libtiny_bclibc.so through ffi, so a musl build would be a natmod/usermod
host only.
Deliberately not implemented for now. Recorded here so the measurement is not lost and so the next person does not have to re-derive it.
make -C /path/to/micropython-1.29.0/ports/rp2 BOARD=RPI_PICO \
USER_C_MODULES=/path/to/micropython-bclibc/usermod/micropython.cmake \
FROZEN_MANIFEST=/path/to/micropython-bclibc/usermod/manifest.py
# flash build-RPI_PICO/firmware.uf2 to the board, then from the REPL:
# >>> import tiny_bclibc; tiny_bclibc.version()
# '1.1.3-sp'If the filesystem was not yet formatted after flashing custom firmware, format it once:
import vfs, rp2
bdev = rp2.Flash()
vfs.VfsLfs2.mkfs(bdev)
vfs.mount(bdev, '/')Runs test_bclibc.py on the release firmware in the
o-murphy/rp2040py Python emulator. test_bclibc.py
only needs tiny_bclibc importable (already frozen in by the release manifest.py), so it's
pushed straight from the host and run over the raw-REPL protocol - no separate test
manifest/firmware build needed:
# Prerequisites: cmake, gcc-arm-none-eabi
make -C /path/to/micropython-1.29.0/ports/rp2 BOARD=RPI_PICO \
USER_C_MODULES=/path/to/micropython-bclibc/usermod/micropython.cmake \
FROZEN_MANIFEST=/path/to/micropython-bclibc/usermod/manifest.py
pip install rp2040py[fs] # or: uv tool install rp2040py[fs]
rp2040py micropython \
--image /path/to/micropython-1.29.0/ports/rp2/build-RPI_PICO/firmware.uf2 \
tests/test_bclibc.pyThe one usermod target with no execution step, and not for want of trying:
there is no esp32 emulator to hand a firmware image to the way rp2040py takes
an RP2040 .uf2 or qemu-system-arm takes a Cortex-M3 .elf. What CI's
build (esp32 / ESP32_GENERIC) job proves is that tiny_bclibc compiles and
links into a real esp32 firmware under the ESP-IDF toolchain — a different
compiler, libc and config from everything else here — and nothing more.
Not a duplicate of natmod's xtensawin ARCH despite the shared ISA: that one
builds a .mpy against dynruntime and only borrows the compiler out of
ESP-IDF, deliberately skipping IDF's own submodules. A usermod is compiled
into the firmware, so it needs the full IDF.
ESP-IDF v5.5.2 is what ports/esp32/README.md names as recommended for
MicroPython v1.29.0 (5.3, 5.4, 5.4.1, 5.4.2, 5.5.1 and 5.5.4 are also supported). No precision
flag to pass — usermod/micropython.cmake has no MP_BCLIBC_PRECISION knob at all
any more, single precision unconditionally.
git clone --depth 1 --recursive --branch v5.5.2 \
https://github.com/espressif/esp-idf.git
./esp-idf/install.sh esp32
source esp-idf/export.sh
make -C /path/to/micropython-1.29.0/mpy-cross
make -C /path/to/micropython-1.29.0/ports/esp32 BOARD=ESP32_GENERIC \
USER_C_MODULES=/path/to/micropython-bclibc/usermod/micropython.cmake \
FROZEN_MANIFEST=/path/to/micropython-bclibc/usermod/manifest.pyIf a build fails here, note that idf.py redirects the compiler's own stderr
into build-*/log/idf_py_stderr_output_<pid> and prints only a one-line
summary — and that file holds idf.py's bookkeeping, not the diagnostic. The
reliable way to see it is to re-run ninja -C build-ESP32_GENERIC -v in the
same build directory: everything else is built already, so it recompiles just
the failing translation unit and prints both the command and the compiler's
output.
No FPU on Cortex-M3 — not that it matters, since single precision is unconditional now:
sudo apt-get install gcc-arm-none-eabi libnewlib-arm-none-eabi qemu-system-arm
make -C /path/to/micropython-1.29.0/ports/qemu BOARD=MPS2_AN385 \
USER_C_MODULES=/path/to/micropython-bclibc \
FROZEN_MANIFEST=/path/to/micropython-bclibc/usermod/manifest.py
python3 /path/to/micropython-bclibc/usermod/ci/run_qemu.py \
/path/to/micropython-1.29.0/ports/qemu/build-MPS2_AN385/firmware.elf \
/path/to/micropython-bclibc/tests/What this job actually guards. ports/qemu/Makefile:74 links -nostdlib
with libgcc alone: a usermod there has no libc and no libm at all. That
holds true for tiny_bclibc — no malloc, no math, no printf anywhere in
src/ or bclibc/tiny_bclibc/ — and this job exists to keep it true rather
than to assume it. A dependency on either would stop linking here, which is the
signal wanted. o-murphy/micropython-wasm3 cannot run this job for exactly that
reason: wasm3 allocates through the port's calloc(), and supplying its own
shims would link and then corrupt, because those shims allocate on the GC heap
while a usermod's globals sit in firmware .bss that gc_collect() does not
scan. Fixing that needs MP_REGISTER_ROOT_POINTER, whose hard part is that
MicroPython's GC only traces block-aligned pointers.
ports/esp8266 is worse than uncovered — do not reach for it. It would
build and then be silently wrong. ports/esp8266/posix_helpers.c:35 implements
malloc as gc_alloc, and, as above, a usermod's globals live in firmware
.bss, which gc_collect() never scans — so anything allocated at import time
becomes unreachable garbage the collector is free to reuse. Not a build error,
not a crash at first: wrong answers later. MP_REGISTER_ROOT_POINTER is the
prerequisite there too. The same applies to stm32, samd, nrf, alif,
zephyr and cc3200, which have no C heap at all. Deliberately not attempted;
recorded so nobody has to rediscover it from a corrupted trajectory.
JS/browser numbers are double-precision natively, but tiny_bclibc itself no longer builds
a double-precision variant at all (see natmod/Makefile's own "Precision" header) — single
here too, same as every other target.
VARIANT=pyscript, not the default standard variant: standard (-s ASYNCIFY)
is broken against modern emsdk releases -- see
micropython/micropython#19380.
pyscript doesn't use ASYNCIFY and isn't affected. Our own FROZEN_MANIFEST
below overrides pyscript's own (large, micropython-lib-heavy) default manifest,
so nothing extra gets pulled in from it:
make -C /path/to/micropython-1.29.0/ports/webassembly VARIANT=pyscript \
USER_C_MODULES=/path/to/micropython-bclibc \
FROZEN_MANIFEST=/path/to/micropython-bclibc/usermod/manifest.py
node build-pyscript/micropython.mjs /path/to/micropython-bclibc/tests/test_bclibc.pynatmod is not an option on this port at all, whatever ARCH the .mpy was built for:
ports/windows/mpconfigport.h sets MICROPY_EMIT_X64 (0), and py/persistentcode.c
gates .mpy native-code loading on MICROPY_EMIT_MACHINE_CODE — so there is nothing
for a native module to load into. usermod is how tiny_bclibc runs on Windows.
Built with MSYS2, the same way upstream MicroPython's own build-mingw CI job does:
MINGW32 for x86, MINGW64 for x64, CLANGARM64 for arm64. Single precision, same as
every other target. CI builds and runs all three natively — x86/x64 on an x64 runner
(WOW64 runs the 32-bit exe with no emulation layer), arm64 on windows-11-arm.
# In an MSYS2 shell (MINGW64 here), with: make git python3 mingw-w64-x86_64-gcc
make -C /path/to/micropython-1.29.0/mpy-cross
make -C /path/to/micropython-1.29.0/ports/windows \
USER_C_MODULES=/path/to/micropython-bclibc \
FROZEN_MANIFEST=/path/to/micropython-bclibc/usermod/manifest.py
build-standard/micropython.exe /path/to/micropython-bclibc/tests/test_bclibc.pyUnder CLANGARM64 four extra overrides are needed, all for MicroPython's own build
system rather than for anything in this repo — LDFLAGS_ARCH (lld rejects --cref),
COMPILER_TARGET (the gcc-compat wrapper's -dumpmachine doesn't say "mingw", which
both drops fmode.c from mpy-cross and drops the .exe suffix), STRIP="" /
SIZE="true" (that toolchain ships neither binary), plus CFLAGS_EXTRA=-Wno-error
because py/binary.c and shared/runtime/gchelper_generic.c don't survive the port's
gcc-tuned -Werror set under clang. See .github/workflows/usermod.yml for the full
reasoning on each. This repo's own sources are clean under that warning set — they are
compiled with -Wall -Wpointer-arith -Wdouble-promotion -Werror on the x86/x64 rows,
which keep it.
| natmod | usermod | |
|---|---|---|
| Module delivery | .mpy file on filesystem |
Built into firmware |
| Firmware re-flash needed | No | Yes (per build) |
| Architectures | mpy_ld.py supported only | Any port with USER_C_MODULES |
| Memory at import | Filesystem read + bytecode load | Instant (already in flash) |
| RP2040 support | armv6m .mpy |
cmake USER_C_MODULES |
| Unix port support | Yes | Yes (also produces a micropython binary) |
| Windows port support | No (port disables native emit) | Yes (x86 / x64 / arm64, MSYS2) |
On architectures where mpy_ld.py does not yet support native modules (aarch64, mipsel,
and others), MicroPython's built-in ffi module can call libtiny_bclibc.so directly.
Two entry points are available:
| Module | Location | Description |
|---|---|---|
_tiny_bclibc.py |
ffimod/ |
Drop-in module with the full public API |
test_ffi.py |
tests/ |
Runs full test suite against the FFI backend |
# 1. Build libtiny_bclibc.so for the target platform (native or cross)
cmake -B ../tiny_bclibc/build-shared \
-S ../tiny_bclibc \
-DTINY_BCLIBC_BUILD_SHARED=ON \
-DCMAKE_BUILD_TYPE=Release
cmake --build ../tiny_bclibc/build-shared
# 2. Build MicroPython unix port for the target (with ffi support)
make -C "$MPY_DIR/ports/unix" VARIANT=standard
MPY="$MPY_DIR/ports/unix/build-standard/micropython"
# 3. Run full test suite via the FFI module (sp or dp)
TINY_BCLIBC_SO=../tiny_bclibc/build-shared/libtiny_bclibc.so \
MP_BCLIBC_PRECISION=double \
$MPY tests/test_ffi.pyBoth skip automatically on 32-bit platforms (pointer size ≠ 8 bytes).
ffimod/_tiny_bclibc.py supports both single and double precision via
MP_BCLIBC_PRECISION and provides the same API as the natmod: Shot, Request,
Wind, Config, integrate, integrate_stream, find_zero_angle, zero, aim, fire, find_apex,
find_max_range, and all flag / index constants.
sudo apt-get install qemu-system-arm
pip install pyserial
# Build MicroPython cross-compiler and QEMU firmware (one-time)
make -C "$MPY_DIR/mpy-cross"
make -C "$MPY_DIR/ports/qemu" BOARD=MPS2_AN385
# Build natmod
make -C natmod ARCH=armv7m MPY_DIR="$MPY_DIR" dist
ln -sf ../natmod/build/armv7m/tiny_bclibc.mpy tests/tiny_bclibc.mpy
# Run tests through the QEMU pty bridge
python3 natmod/ci/run_qemu.py \
"$MPY_DIR/ports/qemu/build-MPS2_AN385/firmware.elf" \
tests/Cortex-M0/armv6m (RP2040) has no QEMU runtime test: MicroPython's MICROBIT QEMU board
firmware does not support loading native .mpy modules on Cortex-M0, and no other QEMU
ARM board emulates armv6m native modules. The natmod CI job still build-checks
ARCH=armv6m (link-only, not run); real hardware testing on RP2040 is outside the scope
of this repo's CI.
import tiny_bclibc as bc
from tiny_bclibc import Shot, Request, Wind, Config, DRAG_G1, DRAG_G7, DRAG_CUSTOM
bc.version() # → "1.2.3"
# ── Constructors ──────────────────────────────────────────────────────────────
shot = Shot(bc=0.310, weight_grain=168.0, muzzle_velocity_fps=2750.0)
req = Request(range_limit_ft=3000.0, range_step_ft=100.0)
# Wind: field access via ._s (zero-copy uctypes struct backed by ._buf)
w = Wind(velocity_fps=10.0, direction_from_rad=1.57)
w._s.velocity_fps # read field
w._s.direction_from_rad # read/write field
# Config: same pattern
cfg = Config(max_iterations=100)
cfg._s.step_multiplier # read/write field
# Shot with winds and custom config
shot = Shot(
bc=0.310, weight_grain=168.0, muzzle_velocity_fps=2750.0,
winds=[Wind(10.0, 0.0)],
config=Config(max_iterations=50),
)
# ── Trajectory integration — buffered ────────────────────────────────────────
rows, stop_reason = bc.integrate(shot, req)
# rows: list of 16-tuples — use T_* indices to access fields
# ── Trajectory integration — streaming (no per-point allocation) ─────────────
def on_point(row):
# called once per filtered output point; return truthy to stop early
print(row[bc.T_DISTANCE], row[bc.T_VELOCITY])
total, stop_reason = bc.integrate_stream(shot, req, on_point)
# ── Zero-angle search ─────────────────────────────────────────────────────────
elevation_rad = bc.find_zero_angle(shot, zero_distance_ft)
# ── High-level calculator helpers ────────────────────────────────────────────
elevation_rad = bc.zero(shot, zero_distance_ft)
vertical_hold_rad, windage_rad, point = bc.aim(shot, target_distance_ft)
rows, reason = bc.fire(shot, req)
# ── Maximum range (golden-section search over [low_rad, high_rad]) ────────────
range_ft, angle_rad = bc.find_max_range(shot, low_rad, high_rad)
# ── Single-point interpolation ────────────────────────────────────────────────
raw, full = bc.integrate_at(shot, bc.INTERP_POS_X, distance_ft)
# raw: 8-tuple (time, px, py, pz, vx, vy, vz, mach)
# full: same 16-tuple as integrate() rows
# ── Apex ──────────────────────────────────────────────────────────────────────
apex = bc.find_apex(shot) # → single trajectory row 16-tuple
# ── Trajectory flag constants ─────────────────────────────────────────────────
tiny_bclibc.TRAJ_FLAG_NONE # 0
tiny_bclibc.TRAJ_FLAG_ZERO_UP # 1 — rising zero crossing
tiny_bclibc.TRAJ_FLAG_ZERO_DOWN # 2 — falling zero crossing
tiny_bclibc.TRAJ_FLAG_ZERO # 3 — any zero crossing
tiny_bclibc.TRAJ_FLAG_MACH # 4 — Mach 1 crossing
tiny_bclibc.TRAJ_FLAG_RANGE # 8 — range-step output
tiny_bclibc.TRAJ_FLAG_APEX # 16 — apex
tiny_bclibc.TRAJ_FLAG_ALL # 31
tiny_bclibc.TRAJ_FLAG_MRT # 32 — max range trajectory
# ── Interpolation key constants ───────────────────────────────────────────────
tiny_bclibc.INTERP_TIME # 0
tiny_bclibc.INTERP_MACH # 1
tiny_bclibc.INTERP_POS_X # 2 — horizontal distance
tiny_bclibc.INTERP_POS_Y # 3 — height
tiny_bclibc.INTERP_POS_Z # 4 — lateral
tiny_bclibc.INTERP_VEL_X # 5
tiny_bclibc.INTERP_VEL_Y # 6
tiny_bclibc.INTERP_VEL_Z # 7
# ── Trajectory tuple field indices ────────────────────────────────────────────
tiny_bclibc.T_TIME # 0 — time (s)
tiny_bclibc.T_DISTANCE # 1 — horizontal distance (ft)
tiny_bclibc.T_VELOCITY # 2 — total velocity (fps)
tiny_bclibc.T_MACH # 3 — Mach number
tiny_bclibc.T_HEIGHT # 4 — height (ft)
tiny_bclibc.T_SLANT_HEIGHT # 5 — height relative to look angle (ft)
tiny_bclibc.T_DROP_ANGLE # 6 — trajectory angle minus look angle (rad)
tiny_bclibc.T_WINDAGE # 7 — windage + spin drift (ft)
tiny_bclibc.T_WINDAGE_ANGLE # 8 — windage angle (rad)
tiny_bclibc.T_SLANT_DISTANCE # 9 — slant distance (ft)
tiny_bclibc.T_ANGLE # 10 — trajectory angle (rad)
tiny_bclibc.T_DENSITY_RATIO # 11
tiny_bclibc.T_DRAG # 12 — drag coefficient
tiny_bclibc.T_ENERGY # 13 — kinetic energy (ft·lbf)
tiny_bclibc.T_OGW # 14 — optimal game weight (lb)
tiny_bclibc.T_FLAG # 15 — TRAJ_FLAG_* bitmaskSee src/tiny_bclibc.py for Shot, Wind, Config, Request constructors.
import tiny_bclibc as bc
shot = bc.Shot(
bc=0.310,
weight_grain=168.0,
muzzle_velocity_fps=2750.0,
diameter_inch=0.308,
twist_inch=11.0,
sight_height_ft=0.125, # 1.5 inch
)
req = bc.Request(range_limit_ft=3280.84, range_step_ft=328.084) # 1000 m / 100 m steps
rows, stop_reason = bc.integrate(shot, req)
for row in rows:
print(f"{row[bc.T_DISTANCE]:.0f} ft {row[bc.T_VELOCITY]:.1f} fps {row[bc.T_HEIGHT]:.3f} ft")zero is the simple equivalent of Calculator.set_weapon_zero: it writes the
solved barrel elevation directly into the existing zero-copy Shot buffer.
aim returns the vertical hold relative to that stored zero, windage, and the
terminal solver point without a second integration. fire is the high-level
name for calculating a request's trajectory.
import tiny_bclibc as bc
import math
shot = bc.Shot(
bc=0.310, weight_grain=168.0, muzzle_velocity_fps=2750.0,
diameter_inch=0.308, twist_inch=11.0, sight_height_ft=0.125,
)
# Step 1 — set zero at 100 m
zero_dist_ft = 100 / 0.3048 # 100 m → ft
zero_angle = bc.zero(shot, zero_dist_ft)
# Step 2 — calculate a hold at a target distance
target_dist_ft = 500 / 0.3048
vertical_hold_rad, windage_rad, point = bc.aim(shot, target_dist_ft)
# Step 3 — calculate the trajectory
req = bc.Request(range_limit_ft=500 / 0.3048, range_step_ft=500 / 0.3048)
rows, _ = bc.fire(shot, req)
# Step 4 — read correction from the last row
row = rows[-1]
# T_DROP_ANGLE = trajectory angle − look_angle (rad); negate to get hold/dial value
elev_mrad = -row[bc.T_DROP_ANGLE] * 1000 # positive → aim higher
wind_mrad = -row[bc.T_WINDAGE_ANGLE] * 1000 # positive → aim right
print(f"Elevation: {elev_mrad:.2f} mrad Windage: {wind_mrad:.2f} mrad")T_DROP_ANGLE is the ready-to-use angular correction — negate it to get the hold or
dial value. T_SLANT_HEIGHT gives the same information in linear units (ft above/below
the look-angle line).
barrel_elevation_rad is the total absolute angle from horizontal. When the target
is uphill or you apply a hold for a different distance, add to zero_angle:
look_angle_rad = math.radians(15) # target 15° uphill
hold_rad = 0.003 # +3 mrad hold for 500 m
shot._s.look_angle_rad = look_angle_rad
shot._s.barrel_elevation_rad = look_angle_rad + zero_angle + hold_radzero_angle stays constant (computed once at zeroing distance). Only
look_angle_rad and hold_rad change per shot — the same decomposition used
by py_ballisticcalc's look_angle + zero_elevation + relative_angle.
Cheaper than a full trajectory when only one distance matters:
_raw, point = bc.integrate_at(shot, bc.INTERP_POS_X, 500 / 0.3048)
print(f"At 500 m: {point[bc.T_VELOCITY]:.1f} fps drop={-point[bc.T_DROP_ANGLE]*1000:.2f} mrad")integrate_stream delivers one row at a time with no heap allocation for the trajectory:
def on_row(row):
energy = row[bc.T_ENERGY]
print(f"{row[bc.T_DISTANCE]:.0f} ft {energy:.0f} ft·lbf")
if energy < 500:
return True # stop early
total, stop_reason = bc.integrate_stream(shot, req, on_row)integrate |
integrate_stream |
|
|---|---|---|
| Returns | (list[tuple], reason) |
(total_count, reason) |
| Heap allocation | N × 16 floats per trajectory |
None |
| Python call per point | No | Yes (1 mp_call) |
| Random access to all rows | Yes | No — one at a time |
| Early stop | No | Yes — return truthy from callback |
| Best for | Post-processing, sorting, slicing, display of full table | Tight RAM (MCU), streaming to UART/display, early-exit on threshold |
Use integrate when you need the full result set after integration — e.g. print a table, compare rows, pass to another function.
Use integrate_stream when RAM is limited (RP2040 has ~200 KB free heap) or you want to process each point as it arrives — e.g. write to a display row by row, stop when energy drops below a threshold, or log to a file without buffering the entire trajectory.
The Python overhead of integrate_stream (one mp_call per filtered point) is negligible compared to the integration step itself — on RP2040 the call overhead is ~1–3 µs vs ~120 ms per full trajectory.
| ARCH | Precision | Math library | BSS |
|---|---|---|---|
| x64 / x86 | float | fdlibm single (bundled in MicroPython) | 0 |
| armv6m / armv7m / armv7emsp | float | newlib libm.a (via LINK_RUNTIME) | 0 |
| armv7emdp | float | newlib libm.a (via LINK_RUNTIME) | 0 |
| xtensawin / xtensa | float | newlib libm.a (via LINK_RUNTIME) | 0 |
| rv32imc / rv64imc | float | fdlibm single + libgcc soft-float | 0 |
Always float (single precision), on every ARCH — including armv7emdp, which has a real
double-precision FPU in hardware but no longer gets a wider-precision library variant for it
(see natmod/Makefile's own "Precision" header for why).
RISC-V note: picolibc triggers a
mpy_ld.pybug on current MicroPython. fdlibm is used as a workaround until the fix lands upstream. See natmod/RISC-V_picolibc.md for details and the patch.
BSS must be 0 — MicroPython natmod ABI does not allow uninitialized static data.
find_zero_angle uses a Golden-Section Search (GSS) to bracket the max-range angle,
then Ridder's method to find the zero angle. Each GSS iteration runs a full adaptive-integrator
trajectory, which is expensive on soft-float MCUs (Cortex-M0+, RISC-V without FPU).
TINY_BCLIBC_FAST_ZERO_FIND is always defined now (single precision is unconditional — see
above). It applies two optimisations that do not affect the final angle accuracy:
| Parameter | Default | Fast |
|---|---|---|
| GSS step multiplier | 1× | 8× coarser (fewer integrator steps per trajectory) |
GSS convergence h |
1e-5 rad |
1e-2 rad (~13 iterations vs ~25) |
Ridder's acc |
0.001 ft |
0.01 ft (3 mm — more than sufficient for float) |
The bracket bound (angle_at_max) is used only to constrain Ridder's search interval;
its precision does not affect the output. Ridder's method always uses the original
calc_step.
To build without FAST_ZERO_FIND, remove -DTINY_BCLIBC_FAST_ZERO_FIND from
CFLAGS_EXTRA in the Makefile.
See src/sincosf_shim.md for why src/math_shim.c is only compiled on x64/x86 and how to add it back for MCU targets if needed.
Measured on x64 host, MicroPython v1.28, G7 drag, 168 gr @ 2750 fps.
Each output row costs ~653 B of heap (allocated by tiny_bclibc.integrate()).
| Range step | Rows (3 km) | Heap delta |
|---|---|---|
| 100 m | 30 | ~19.5 KB |
| 50 m | 60 | ~39 KB |
| 25 m | 120 | ~78 KB |
| 10 m | 300 | ~197 KB |
| Board | MCU | Arch | Usable heap¹ | Max step @ 3 km |
|---|---|---|---|---|
| Raspberry Pi Pico | RP2040 | armv6m | ~192 KB | 10 m ✓ |
| Raspberry Pi Pico 2 | RP2350 | armv7emsp | ~480 KB | 10 m ✓ |
| STM32F401 (128 KB RAM) | Cortex-M4 | armv7emsp | ~64 KB | 50 m |
| STM32F405/F407 (192 KB RAM) | Cortex-M4 | armv7emsp | ~128 KB | 25 m |
| STM32H743 (1 MB RAM) | Cortex-M7 | armv7emdp | ~512 KB | 10 m ✓ |
| ESP32 | Xtensa LX6 | xtensa | ~200 KB | 25 m |
| ESP32-S3 | Xtensa LX7 | xtensawin | ~300 KB | 10 m ✓ |
| ESP32-S3 + PSRAM | Xtensa LX7 | xtensawin | ~8 MB | 1 m ✓ |
| ESP32-C3 | RISC-V | rv32imc | ~390 KB | 10 m ✓ |
| ESP32-C6 | RISC-V | rv32imc | ~490 KB | 10 m ✓ |
¹ Approximate free heap after MicroPython runtime starts. Actual value depends on firmware variant, frozen modules, and Wi-Fi stack (ESP32).
For MCUs where the result list must fit in constrained RAM, stream results row by row
using integrate_at() + a range loop instead of storing the full trajectory.
Historical. This comparison is what justified going single-precision-only —
tiny_bclibcno longer builds a double-precision variant at all (seenatmod/Makefile's own "Precision" header), so the reproduction steps below, which need both a_dpand a_spnatmod side by side, no longer apply to the current Makefile. The measurements themselves are unaffected by that and are kept here as the record for why single precision is safe everywhere this library targets.
The comparison ran the full trajectory integration twice — once with a float64 natmod
build and once with a float32 natmod build — and diffed the output row by row.
find_zero_angle was also compared between the two builds.
Important: range_step_ft in the Request is the output sampling step only.
The internal adaptive integrator uses its own base sub-step controlled by step_multiplier (default
0.5) and is completely independent of the output step. Changing the output step does not
affect integration accuracy.
On the MicroPython unix port, Python float is 64-bit (double), so both natmods return
full-width Python floats. The float32 values have float32 precision (significant bits
truncated by the C layer), while float64 values have full double precision — the comparison
is numerically valid.
Test conditions:
- Shot: G7, BC=0.310, 168 gr, dia=0.308", mv=2750 fps, sight=0.125 ft (1.5"), twist=11"
- Atmosphere: T=15°C, P=1013.25 hPa, RH=0.5, alt=0 ft
- Range: 0–3000 m, output step=25 m (120 sample points)
- Internal adaptive-integrator base step multiplier: 0.5 (default)
- Host: MicroPython v1.26 unix port, x64, Python float=64-bit
| Metric | Max deviation | At |
|---|---|---|
Vertical drop (height_ft) |
0.108 cm | 2975 m |
| Velocity | 0.0015 fps (0.0005 m/s) | 1125 m |
| Mach number | 1.32 × 10⁻⁶ | — |
find_zero_angle (300 m zero) |
5 × 10⁻¹⁰ rad (< 0.001 mrad) | — |
Drop deviation grows slowly with distance and changes sign around 1200–1300 m (float32 overshoots slightly, then undershoots). At 3000 m the accumulated error is ≈ 0.1 cm — negligible against any real-world uncertainty source (wind, BC spread, muzzle velocity variation). Float32 is sufficient for all supported MCU targets.
Not reproducible against the current natmod/Makefile — it no longer has a
double-precision variant to build for the _dp side of the comparison.
tests/precision_compare.py (CPython runner) and
tests/precision_run.py (MicroPython worker) are kept for
reference; both still expect a build/x64_sp/ and build/x64_dp/ pair on disk.
Warning
This library performs approximate simulations of complex physical processes. Therefore, the calculation results MUST NOT be considered as completely and reliably > reflecting actual behavior of projectiles. While these results may be used for educational purpose, they must NOT be considered as reliable for the areas where incorrect calculation may cause making a wrong decision, financial harm, or can put a human life at risk.
THE CODE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS.