Learning goal
By the end of this chapter the board runs code you wrote. Not "in minutes": the dependency fetch is about 1.9 GB, there is a patch series to apply to it, and the build covers three cores. The last step is the point of the chapter — a first program that reads a real sensor and drives a real LED, in the shape your package takes.
The real firmware sequence
The commands are the shipped bootstrap, lifted from the README you actually unzipped (README.md:35-44) and cross-checked against the fuller dist-page README (release/dist/<variant>/README.md, not in the zip).
- The two packages, and everything that differs between them
| mtb-mpy | mtb-only |
| What it is | ModusToolbox + the MicroPython VM: a REPL over UART, /boot.py and /main.py, the extension modules, and the TACP link the BENTO IDE flashes through | Plain C on FreeRTOS. No VM, no REPL, no /main.py, no TACP |
| Extra prerequisite | micropython-psoc-edge-psoc-edge-main/ beside the template (154 MB, its own repository) | none |
| Liveness line | [MPY] GC heap u KB @ p in s, once at boot (mpy_main.c:552-554) | [HB] t=lus tasks=u, every 10 s (proj_cm33_ns/main.c:110-112) |
| Home screen badge | v<BENTOCLAW_VERSION>-mtb_mpy | v<BENTOCLAW_VERSION>-mtb_only |
| How it is flashed | make program over KitProg, or from the BENTO IDE | make program over KitProg only — the IDE speaks TACP over the REPL UART and this variant has neither (variants/mtb-only.mk:19-20, :29-31) |
| Your first program | a few lines at the REPL | one task in proj_cm33_ns/main.c |
You do not choose: each shipped zip already defaults to its own variant (common.mk:85 sets BENTO_VARIANT?=mtb-mpy and :87-89 rejects any other value; the copy inside the mtb-only zip says mtb-only). Never pass BENTO_VARIANT= on the command line for a downloaded package — passing it with a trailing space is a trap the Makefile guards against (common.mk:83-85). The only flag that reaches C code is BENTO_HAS_MPY (variants/mtb-only.mk:28, :33); every #if BENTO_HAS_MPY you will meet in the later chapters is that one switch.
Toolchain pin: ModusToolbox 3.6 specifically, and the ARM GCC that ships with it (in-zip README.md:189; dist mtb-mpy:28, mtb-only:29). The in-zip README says why newer is not better: a later Configurator regenerates the BSP from design.modus and emits notices that -Werror=cpp turns into errors in files you never touched.
Workspace layout — the zip is unpacked one level below the workspace:
<your workspace>/
mtb_shared/ created by make getlibs
bento-firmware-template-mtb-only/ unzip this here
Step-by-step
Step 1 — Unpack and verify what you were given
cd bento-firmware-template-mtb-only
(cd lib && ./verify.sh) # signature + digest of every shipped file
lib/verify.sh checks the signature and digest of every shipped prebuilt archive (dist README mtb-mpy:34, mtb-only:36). The generic in-zip README starts at the next step; the verify line is on the dist page.
- What you should observe
- verify.sh exits 0 and reports every archive under lib/ as verified. A non-zero exit means the package is not the one that was signed — stop.
Note what is not there: lib/mpy_secure is absent from this zip by design; the six lfs_wifi_creds_* names it would have provided come from bento_libs/claw/common/storage_c/lfs_wifi_creds_c.c as source (Chapter G1).
Step 2 — Doctor
./bento.sh doctor # toolchain + tree check (variant-aware)
In-zip README.md:35 ("is everything present?"); dist mtb-mpy:35, mtb-only:37. doctor checks the toolchain and the trees this template deliberately does not carry. The in-zip README's advice (:54-56): "Fix
whatever it reports before building; the errors you get otherwise are long
and unhelpful."
- What you should observe
- doctor names ModusToolbox 3.6 and its GCC. On a fresh workspace it reports mtb_shared/ missing — that is Step 3, not a failure. The dist comment says variant-aware: on this variant doctor does not ask for the MicroPython port.
Step 3 — Fetch dependencies, per project
for p in proj_cm33_s proj_cm33_ns proj_cm55; do (cd $p && make getlibs); done
In-zip README.md:37-41; dist mtb-mpy:37-38, mtb-only:39-40 ("dependencies are fetched PER PROJECT — there is no top-level getlibs"). The in-zip README records the consequence of running it in only one project: "running it only in proj_cm33_ns fetches 33 of the 41 assets and the build
then stops inside ninja on a missing optiga-trust-m file."* This is the 1.9 GB step; it takes as long as your connection takes.
- What you should observe
- ../mtb_shared/ now exists beside the template and is populated. Each of the three make getlibs runs ends without error. Note the command: this is make, run inside each project, not bento.sh.
Step 4 — Apply the third-party patch series and prove it landed
(cd ../mtb_shared \
&& for p in $(cat ../bento-firmware-template-mtb-only/third_party_patches/series); do
patch -p1 -F0 --forward < "../bento-firmware-template-mtb-only/third_party_patches/$p" || exit 1
done \
&& shasum -a 256 -c ../bento-firmware-template-mtb-only/third_party_patches/PATCHED.sha256)
Dist README mtb-mpy:40-45, mtb-only:42-48. The in-zip README describes the same requirement in prose (:51-53): local changes to assets under mtb_shared that getlibs does not provide — "one stops the build, the rest
fail silently, including the one that binds the OPTIGA key into the TLS
session". The patches are applied in the order listed in third_party_patches/series, with -F0 so a patch that does not apply cleanly stops the loop instead of fuzzing in. The shasum -c against PATCHED.sha256 is the proof: the build itself refuses to start if the patched assets are missing or wrong, and names which.
- What you should observe
- Every patch reports its hunks applied; shasum -a 256 -c prints OK for each listed file. A FAILED line here will become a build refusal in Step 5 naming the same file. If third_party_patches/ was not sent with your package, the in-zip README says to ask for it (:53).
Step 5 — Build all three cores
Dist README mtb-mpy:47, mtb-only:50. The in-zip README offers the same through the wrapper (./bento.sh build, README.md:43, "~10 minutes for a
clean build of all three cores"). Either is correct; the wrapper calls the same make.
- What you should observe
- Build complete for each of the three cores — CM33_S, CM33_NS, CM55 — and zero undefined reference lines. A linker error for a symbol that exists in source is a stale object, not a missing function: delete proj_cm55/build and rebuild. If the link complains about a MicroPython symbol on this variant, something re-introduced BENTO_HAS_MPY=1 — check common.mk:85 in your unpacked tree has not been edited.
Step 6 — Flash, open the console, power-cycle
Dist README mtb-mpy:48, mtb-only:51; in-zip ./bento.sh flash (README.md:44).
The board has exactly one text channel — the KitProg UART, owned by CM33_NS (proj_cm55/main.c:9-10: "CM55 must NOT use printf/retarget-io. CM33_NS owns
the UART for MicroPython REPL."). Open it at 115200 8N1 and leave it open before you power-cycle. The rate is stated in the mtb-only dist README (:57: "Expect on the UART at 115200"). The port enumerates as /dev/tty.usbmodem* on macOS, /dev/ttyACM* on Linux, and a COM port on Windows.
screen /dev/tty.usbmodem1103 115200
# or: picocom -b 115200 /dev/tty.usbmodem1103
# or: python3 -m serial.tools.miniterm /dev/tty.usbmodem1103 115200
- Warning
- Then power-cycle the board. A debugger reset leaves the display dark, which looks exactly like a failed flash (in-zip :47-49: "the
display backlight needs a cold 0→1 edge and stays dark otherwise, which looks
exactly like a failed flash"; dist mtb-mpy:51-52, mtb-only:54). A dark screen immediately after make program is the expected state, not a verdict on your image. Unplug the USB, plug it back in. This is Appendix X #21.
-
mtb-only addition — the cold-boot backlight can want a second unplug-replug. Dist mtb-only:55, verbatim: "the cold-boot backlight can
want a second unplug-replug." If the console shows [HB] beating but the panel is dark after the first power-cycle, unplug and replug once more before concluding anything about the image. This is Appendix X #22.
- What you should observe
- Flashing ends with a verified … bytes line from the programmer. Then, after the unplug-replug, on the serial console:
[HB] t=%lus tasks=%u
from proj_cm33_ns/main.c:110-112 — lu is seconds since boot, u the FreeRTOS task count. This is the only positive boot signal on this variant; every other successful step is silent. Silence on the storage: lines means the mount succeeded; failure is loud — storage: SMIF setup failed
0x%08lx (bento_storage.c:108) or storage: mount failed (d); volume left
untouched (:131), followed by ERROR: storage unavailable — config and WiFi
credentials will use defaults (main.c:306-308).
Then the screen: the Home card grid, with the version badge from the table above (proj_cm55/modules/page-components/_core/page_home.c:485-489). The comment above that label (:481-484) explains why it exists: an mtb-only board once "introduced itself on screen as mtb_mpy, and the only way to tell
the truth was a serial console."
Step 7 — Your first program
There is no REPL on this variant, so the first program is one task in proj_cm33_ns/main.c. That file already includes everything it needs — cybsp.h (:21), FreeRTOS.h and task.h (:26-27), and sensor_auto_task.h (:29).
- Example (authored — no shipped call site)
static void first_program_task(void *arg)
{
(void)arg;
for (;;) {
sensor_auto_bmi270_cache_t imu;
sensor_auto_get_bmi270(&imu);
if (imu.valid) {
printf("[APP] ax=%d ay=%d az=%d\r\n",
(int)(imu.ax * 100.0f),
(int)(imu.ay * 100.0f),
(int)(imu.az * 100.0f));
if (imu.ax > 3.0f || imu.ax < -3.0f) {
Cy_GPIO_Set(CYBSP_USER_LED1_PORT, CYBSP_USER_LED1_PIN);
} else {
Cy_GPIO_Clr(CYBSP_USER_LED1_PORT, CYBSP_USER_LED1_PIN);
}
}
vTaskDelay(pdMS_TO_TICKS(500));
}
}
sensor_auto_task_create();
xTaskCreate(first_program_task, "first", 512, NULL, 1, NULL);
sensor_auto_task_create() is already in main() at main.c:318 — that background task reads the sensors every cycle and caches the latest BMI270 values, so this program only reads the cache. sensor_auto_get_bmi270() is declared at sensor_auto_task.h:35 (the sensor_auto_bmi270_cache_t struct at :26-32, implementation at sensor_auto_task.c:1498), and the header says "lock-free, updated every ~100ms"* (:34) — which is why this task must not take sensor_i2c_lock() itself; the bus already has an owner (J1 — The sensor bus and its lock).
Cy_GPIO_Set is exactly what led.on() does on mtb-mpy (modgpio.c:288-291), so the LED is active-high; CYBSP_USER_LED1_PORT and CYBSP_USER_LED1_PIN are the BSP aliases led_table[] is built from (modgpio.c:38-50). Printing integers is deliberate: [HB] at main.c:110-112 prints integers too, and a printf("f") is not something this chapter will promise you.
- What you should observe
- An [APP] ax=… ay=… az=… line every half second, interleaved with [HB] every ten. At rest one axis reads about 980 and the other two near 0. Tilt the board so gravity falls along X and ax moves off zero and LED1 lights. If every axis stays 0, imu.valid is false — look at the bus failure lines from boot, not at this code.
- Warning
- Attaching the debugger to a running mtb-only board parks CM33 in a boot-ROM loop. The rationale comment above the heartbeat task creation records it (proj_cm33_ns/main.c:292-302): "attaching the debugger to a
running board parks CM33 in a boot-ROM loop (measured 2026-08-28; the mpy
variant tolerates the same attach, cause not established)." This variant has no REPL, so you debug the program above with printf and the LEDs. Full story in Chapter G2; this is Appendix X #16.
Traps
- Appendix X #21 — Power-cycle after every flash (Step 6). If you skip it you will "debug" a firmware that is fine.
- A dark screen after flashing = debugger reset, not failed flash. Same entry, restated because it is the trap most likely to cost an hour.
- Running make getlibs in one project only. 33 of 41 assets, then a ninja stop on a missing optiga-trust-m file (in-zip README.md:38-40).
- Skipping the patch series. The build refuses to start and names the missing patched asset — but only if PATCHED.sha256 disagrees. An unpatched cy_tls.c that happens to match nothing is the silent failure the README warns about: the OPTIGA key is never bound into TLS and mTLS fails much later, in Chapter C4.
- Passing BENTO_VARIANT= to a downloaded zip. Unnecessary; the zip's common.mk:85 already defaults correctly. A trailing space in the value is a documented trap (common.mk:83-85).
- Expecting a "boot OK" line. Success on CM33_NS is silent apart from the liveness line in the table above. The PSA/OPTIGA registration at proj_cm33_ns/main.c:237-244 prints only on failure. You will learn to read absence — the full inventory of what can print is Appendix W — The signal atlas.
- Reading a muted line as evidence. [WiFi-Boot], [WiFiIPC], [TESAIOT_CFG], [TESAIOT_IPC] and every BOOT_VERBOSE banner exist in source and never print. If a document tells you to wait for one of them, the document is stale.
- Appendix X #22 — Cold-boot backlight (mtb-only) — a second unplug-replug (Step 6).
- Appendix X #16 — Debugger attach parks a running mtb-only CM33 in a boot-ROM loop (main.c:292-302; Step 7).
- Expecting the BENTO IDE to flash this board. It cannot (variants/mtb-only.mk:19-20).
- Reading variants/README.md for build status. Its "does not build
yet, on purpose" claim and "Contributing to mtb-only" section are stale — all three listed blockers landed (variants/mtb-only.mk:1-26). Its "measured"* object/TU counts are unverifiable; do not repeat them.
Variant applicability
- Variant
- mtb-mpy and mtb-only. The command sequence is the same on both; every difference is in the table at the top of this chapter — the MicroPython port (mtb-mpy only), the liveness line, the screen badge, how the board is flashed, and the shape of your first program.