Files
..

n_signer Teensy 4.1 Firmware

Status: In progress — Phase 0 + SD + TFT + touch all verified on hardware (toolchain, blink, USB CDC serial, 4-bit SDMMC card read, ST7796S 480×320 TFT landscape fill + text, XPT2046 touch calibrated + live dot-draw verified). See plans/teensy41_bringup.md for the bring-up log, firmware/teensy41/WIRING.md for the display wiring, and plans/teensy41_signer_port.md for the full port plan. Next: exFAT re-test on the 1 TB SDXC card, then port the CYD display/touch/UI code.

The Teensy 4.1 (NXP i.MX RT1062, Cortex-M7 @ 600 MHz) is the high-capacity OTP pad target. Its built-in SD slot supports 1 TB SDXC cards (exFAT) via PJRC's SdFat library, making it the ideal hardware signer for large one-time-pad storage.

Why the Teensy 4.1

Concern Teensy 4.1 CYD (ESP32) Feather S3 TFT (ESP32-S3)
MCU 600 MHz Cortex-M7 240 MHz Xtensa LX6 240 MHz Xtensa LX7
SRAM 1 MB + 16 MB PSRAM 512 KB (no PSRAM) 512 KB + quad PSRAM
SD slot 4-bit SDMMC, exFAT, up to 2 TB 1-bit SDSPI, FAT32, up to 32 GB
USB Hi-Speed (480 Mbps) device + host CH340 UART only Full-Speed USB
WiFi None Yes (unused) Yes (unused)
Ethernet 10/100 PHY (optional)
PQ crypto speed ~1-2 s SLH-DSA-128s 5-30 s SLH-DSA-128s 5-30 s SLH-DSA-128s

SD card size support

Card type Size Works?
SDSC ≤ 2 GB Yes (FAT16/32)
SDHC 2 32 GB Yes (FAT32)
SDXC 32 GB 2 TB Yes (exFAT via SdFat)
SDUC 2 128 TB No

1 TB SDXC cards work — SdFat has native exFAT support, and the Teensy's 4-bit SDMMC bus runs at ~20-40 MB/s. No reformatting needed.

Display + touch

4.0" ST7796S 480×320 SPI TFT with XPT2046 resistive touch (Hosyond or equivalent, $12-15). Specs: 4-wire SPI, RGB 65K, 3.3V5V (works at the Teensy's 3.3V logic), XPT2046 resistive touch, includes touch pen + on-module SD slot.

Resistive touch is the right choice for a hardware signer (deliberate physical activation, stylus-compatible, simple driver). The XPT2046 driver ports from the CYD's touch.c with 480×320 resolution constants. The ST7796S display driver is new code (different init sequence than the CYD's ILI9341).

Wiring: TFT SPI on pins 11/12/13 (hardware SPI0), CS=5, DC=7, RESET=6, BL=8; touch shares the SPI bus with T_CS=9, T_IRQ=2. See WIRING.md for the full pin table and wire-by-wire chart.

Calibrated values & settings (verified on hardware)

These are the values to use when porting the CYD display/touch/UI code to the Teensy 4.1. They were measured on the actual Elecrow 4.0" ST7796S + XPT2046 module during bring-up (see plans/teensy41_bringup.md).

Display

Setting Value Notes
Orientation Landscape 480×320 Wider than tall.
tft.init() tft.init(320, 480) Pass the native (portrait) dims so the library takes its zero-offset branch. Do NOT use init(480, 320) — that computes a negative _colstart and offsets the image.
tft.setRotation() setRotation(1) Produces landscape 480×320 from the init(320, 480) base.
SCREEN_W / SCREEN_H 480 / 320 Use these constants for all drawing coordinates. Do not use tft.width() / tft.height() — the ST7796_t3 accessors are broken and always return 480×320 regardless of rotation.
Library ST7796_t3 (in Teensy's ST7735_t3 library) Hardware SPI0.
drawPixel() Avoid near edges Use fillRect(x, y, 1, n, c) / fillRect(x, y, n, 1, c) for crosshairs and thin lines — drawPixel mispositions near the right/bottom edges.

Touch (XPT2046)

Setting Value Notes
T_CS pin 9 Touch chip select (active low).
T_IRQ pin 2 Touch interrupt. Must not be pin 10 — pin 10 is SPI0 CS0, and pinMode(10, INPUT) corrupts the SPI engine and reverts the display to the wrong orientation.
SPI clock 2 MHz XPT2046 max is ~2.5 MHz; drop the clock before every touch read (SPI.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0))).
Axis swap Yes In landscape rotation 1, raw Y → screen X, raw X → screen Y. The calibration struct + raw_to_screen() handle this (see below).
Control bytes 0xD0 = X, 0x90 = Y, 0xB0/0xC0 = Z1/Z2 Same as the CYD's touch.c.
Pressure threshold 80 Reject readings with pressure < 80 (stylus not pressing hard enough). Same as CYD.

Touch calibration constants (measured 2026-07-26)

static TouchCal s_cal = {
  /* x_min = */ 207,
  /* x_max = */ 1909,
  /* y_min = */ 168,
  /* y_max = */ 1798,
  /* invert_x = */ 1,
  /* invert_y = */ 1,
};

With the axis swap, x_min/x_max is the range of raw Y that maps to screen X, and y_min/y_max is the range of raw X that maps to screen Y. The raw_to_screen() function swaps accordingly:

*sx = map_clamped((int)raw_y, s_cal.x_min, s_cal.x_max,
                  s_cal.invert_x ? (SCREEN_W - 1) : 0,
                  s_cal.invert_x ? 0 : (SCREEN_W - 1));
*sy = map_clamped((int)raw_x, s_cal.y_min, s_cal.y_max,
                  s_cal.invert_y ? (SCREEN_H - 1) : 0,
                  s_cal.invert_y ? 0 : (SCREEN_H - 1));

These constants are for the specific panel we calibrated. If you swap a display module, re-run firmware/teensy41/touch_cal/touch_cal.ino and paste the new constants.

Build

# Arduino CLI / Teensyduino — uses the custom linker script
bash firmware/teensy41/build_signer.sh           # compile only
bash firmware/teensy41/build_signer.sh --flash    # compile + upload
bash firmware/teensy41/build_signer.sh --test     # compile + upload + run tests

The build uses a custom linker script (signer/imxrt1062_t41_flashmem.ld) that routes crypto and SdFat code to FLASH (off-chip QSPI) instead of ITCM (tightly-coupled RAM), freeing the limited FlexRAM for stack. See the memory management section below for details.

See the port plan: plans/teensy41_signer_port.md.

Memory management

The Teensy 4.1's NXP i.MX RT1062 has a unique FlexRAM architecture that makes memory budgeting the central engineering challenge of this firmware. This section documents the layout, the custom linker script, and the build-time stack gauge that catches overflows before they become mystery crashes.

The FlexRAM problem

The i.MX RT1062 has three RAM regions:

FLASH    8 MB   (off-chip QSPI, @ 0x60000000)  — code + rodata, slow but huge
FLEXRAM  512 KB (on-chip, split ITCM/DTCM)     — fast, tiny, THE BOTTLENECK
RAM2     512 KB (OCRAM, @ 0x20200000)          — DMAMEM statics + malloc heap
ERAM     0 MB   (PSRAM pads empty)             — not populated on the Teensy 4.1

The 512 KB of FlexRAM is divided into 16 banks of 32 KB that are split between ITCM (instruction tightly-coupled memory, runs code at zero wait state) and DTCM (data tightly-coupled memory, holds .data + .bss + the stack). The split is computed at boot time by the Teensy boot ROM from a formula in the linker script:

_itcm_block_count = (SIZEOF(.text.itcm) + SIZEOF(.ARM.exidx) + 0x7FFF) >> 15;
_estack           = ORIGIN(DTCM) + ((16 - _itcm_block_count) << 15);

Every 32 KB bank given to code is taken away from the stack. If ITCM code grows past a 32 KB boundary, a whole bank is stolen from DTCM, and the stack shrinks by 32 KB. The linker cannot detect this because the split happens at reset, not link time — an over-committed DTCM links cleanly and then hard-faults on boot.

The custom linker script

signer/imxrt1062_t41_flashmem.ld routes specific code and data sections to FLASH to keep ITCM small and DTCM large:

  1. Crypto code → FLASH: secp256k1, ed25519, x25519, PQClean (ML-DSA-65, ML-KEM-768, SLH-DSA-128s), nostr_utils — all routed via per-object-file rules (*secp256k1.c.o(.text*), etc.). These run slightly slower from FLASH but the stack headroom is the critical constraint.

  2. SdFat library → FLASH: the FAT filesystem layer (FatFile, FatPartition, FatVolume, etc.) is routed to FLASH. The SDIO driver (SdioCard, SdioTeensy) stays in ITCM for fast interrupt response.

  3. .rodata → FLASH (v0.1.6): read-only data (const tables, string literals, BIP-39 wordlist, LVGL fonts, PQClean constants) is routed to FLASH via *(EXCLUDE_FILE(*ed25519.c.o) .rodata*). This reclaimed 124 KB of DTCM (.data went from 131 KB to 6.8 KB), increasing free stack from 5,984 bytes to 130,912 bytes.

    The EXCLUDE_FILE(*ed25519.c.o) exception is critical: the ed25519 base point constants (ed_K, ed_X, ed_Y) must stay in DTCM — moving them to FLASH produces an all-zeros pubkey (a regression documented in the linker script comments).

Build-time stack gauge

check_stack.sh parses the ELF's section sizes after compilation and computes the same ITCM/DTCM split the boot ROM will perform. It fails the build if free stack would be below 16 KB:

=== Teensy 4.1 FlexRAM stack gauge ===
  .text.itcm   :  355056 bytes  -> 11 banks (360448 bytes)
  .data (DTCM) :    6848 bytes
  .bss  (DTCM) :   26080 bytes
  DTCM total   :  163840 bytes  (5 banks)
  FREE STACK   :  130912 bytes   (threshold: 16384)
  ✅ OK

This converts every future "mysterious boot crash" into a build error with a number. The gauge is wired into build_signer.sh and runs automatically after every compile.

Current memory layout (v0.1.6)

FLEXRAM 512 KB — 16 banks
┌──────────────────────────────────────────────┬────────────────────────────────────────┐
│ ITCM  11 banks = 352 KB                      │ DTCM  5 banks = 160 KB                 │
│ code: 355 KB (signer + SDIO driver)          │ .data:   6.8 KB (writable globals)     │
│                                               │ .bss:   25.4 KB (zero-init globals)    │
│                                               │ FREE STACK: 130.9 KB ✅                │
└──────────────────────────────────────────────┴────────────────────────────────────────┘

RAM2 / OCRAM 512 KB
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ .bss.dma: 413.6 KB (LVGL draw buffers, crypto DMAMEM workspaces)   heap: 110.7 KB free   │
└──────────────────────────────────────────────────────────────────────────────────────────┘

FLASH 7936 KB
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ Used: ~1.6 MB (crypto code + SdFat + .rodata + ITCM/DTCM load images)   Free: ~6.3 MB    │
└──────────────────────────────────────────────────────────────────────────────────────────┘

History

The memory budget was the direct cause of six versions of crash-fixing (v0.1.1v0.1.6). The v0.1.6 .rodata → FLASH move was the largest single improvement, and it also enabled the SD-card OTP pad (which requires SdFat, adding ~7 KB of ITCM code that would have overflowed DTCM without the rodata reclamation). See plans/teensy41_memory_evaluation.md for the full analysis.