Cores SDK & Firmware
This guide covers how to build firmware on Core tiles using the Cores SDK. From configuring your project to writing application code, everything here applies whether you're blinking an LED or orchestrating a dozen tiles across multiple buses. The SDK handles the low-level plumbing so you can focus on your application.
Get the Cores SDK
The SDK, code generator, and example projects.
Setup
Project Structure
The Cores repository contains the SDK, code generator, tile definitions, and reference examples. Your own firmware lives in projects/, which is gitignored — so it stays out of the SDK repo and you can version it however you like. Copy a starting point from examples/, then add three files you own plus a Makefile and the directory coregen generates for you:
project.jsonThis file declares your Core tile, clock source, pad assignments, bus configuration, and which tiles you're using. It is the single source of truth for the code generator.
main.cThis is your application entry point. It calls the generated init functions, sets up the tile driver bridges, and runs your logic.
tiles.hThis header collects tile driver includes and instance handles. It is generated initially by coregen, then maintained with smart markers — you can add your own defines outside the managed section.
Makefilegenerated onceThis file sets your tile target and delegates to the root build system, letting you run make from inside the project folder. It is generated once by coregen and is safe to edit.
coregen/auto-generatedThis directory contains the clock, pad, I2C, and board initialization code generated by coregen. These files are regenerated when project.json or the tile definition changes — never edit them directly.
The SDK
Outside your project directory, the repository provides the shared SDK that all projects build against:
sdk/ll/This layer provides low-level register access through thin wrappers around GPIO, RCC, SysTick, and other peripherals. It is architecture-specific (currently STM32).
sdk/hal/This layer contains peripheral drivers for I2C, SPI, timers, ADC, USB CDC, and the LED helper, all built on top of the LL layer.
kiln/This is the tile driver library, included as a git submodule. It provides platform-agnostic C drivers, HAL bridges, and tile hardware definitions for the entire tile catalog.
sdk/device/This directory holds startup assembly, linker scripts, and CMSIS headers — one set per chip family. These define the memory layout and boot sequence.
cores/
Makefile Root build system
examples/ Reference projects (read-only)
blink/
sdk-demo/
...
projects/ Your projects go here (gitignored)
my-firmware/
Makefile Project build shortcut
project.json Configuration (clock, pads, tiles)
main.c Application code
core.h Umbrella include (generated, next to main.c)
tiles.h Tile handles & includes (managed + yours)
build/ Compiled output (.elf, .bin, .hex)
coregen/ AUTO-GENERATED — never edit
core_init.h/.c Clock, pad, and I2C initialization
core_config.h Clock defines (SYSCLK_HZ, etc.)
core_pads.h Pad-to-GPIO mapping
core_board.h LED, power, debug defines
core_drivers.mk Tile driver source list for Make
sdk/ Cores firmware SDK
ll/ Low-level register access (per architecture)
hal/ Peripheral drivers (I2C, SPI, timers, USB, etc.)
device/ Startup code, linker scripts (per chip)
debug/ OpenOCD configs, GDB scripts
kiln/ Tile driver library (git submodule)
tiles.h + tiles_pal.h Framework headers
drivers/ One .h/.c pair per tile driver
hal/ Platform HAL implementations (Core, Arduino, ESP, STM32)
definitions/ Tile hardware definitions (.json)
tools/ Code generator (coregen) + templatescoregen/ are regenerated at build time whenever project.json or the tile's hardware definition changes. Any manual edits inside this folder will be lost on the next regeneration. Customized behavior should be done in main.c — the generated code is designed to be called, not modified. project.json
This is where you describe your hardware setup. The code generator reads it alongside the Core tile's hardware definition to produce initialization code that's correct for your specific configuration.
A Complete Example
A Core.W project with one I2C bus and a 9-axis IMU:
{
"project": {
"name": "sdk-demo",
"core": "Core-W-b",
"description": "Sense.I.9 driver validation on the Ring"
},
"clock": "low",
"pads": {
"4": "I2C3.CLK",
"5": "I2C3.DAT"
},
"interfaces": {
"I2C3": {
"speed": 100000,
"pullups": true
}
},
"tiles": [
{
"tile": "Sense.I.9",
"bus": "I2C3",
"instance": 0
}
]
}Section by Section
project — metadata. The core field specifies which Core tile JSON to build against (e.g., Core-W-b). The name becomes the output binary filename.clock — performance level. One of "low", "default", "high", or "max". Each Core tile defines what these mean in terms of clock source and frequency — coregen handles oscillator selection, PLL configuration, and everything else automatically. See Clock Configuration for details.pads — pad-to-function assignments. Keys are physical pad numbers on the Core tile; values are interface signals. The format is <INTERFACE>.<SIGNAL> — for example, "4": "I2C3.CLK" routes pad 4 to I2C3's clock line. Coregen resolves the GPIO port, pin number, and alternate function automatically from the Core tile's hardware definition.interfaces — bus configuration. Each interface you assign pads to should have a matching entry here. For I2C, specify the speed (100000 or 400000) and whether to enable internal pull-ups.debug — debug interface configuration. Specifies which physical debug port is in use ("swd" or "jtag") and whether those pads are permanently reserved or freed after a one-time flash. Coregen uses this to exclude debug pads from GPIO assignment. See Programming & Flashing for full details.isp — optional ISP method for production flashing without a debug probe. Specifying a method (e.g., "usb-dfu") records which bootloader path your project targets and, when present, the boot0_pad used to enter bootloader mode. Omit this key if you only flash via SWD.tiles — which tile drivers to include. Each entry names a tile (matching the Mosaic catalog name, e.g., Sense.I.9), the bus it's connected to, and an instance number. The instance number maps to a specific hardware address — instance 0 is the default address, instance 1 is the alternate, and so on. See the Driver Guide for how instance mapping works.What coregen Generates
When you build, the code generator (coregen) reads your Core tile's hardware definition and your project.json, then produces initialization code tailored to your exact configuration. Here's what each generated file does:
core.hUmbrella include — pulls in the init API, board defines, clock config, and SDK essentials. This is the only generated header you include directly.
core_init.h / core_init.cThe init functions: core_init() (does everything), or the individual core_clock_init() and core_pads_init() if you want to split the sequence (e.g., to blink an LED between steps). Also declares the I2C peripheral handles and includes a tile driver init recipe as comments.
core_config.hClock defines like SYSCLK_HZ, bus prescaler values, and flash wait states. Used internally by the init code and available to your application.
core_pads.h / core_board.hHardware mappings: pad-to-GPIO defines, LED pin, power control, debug interface. These come from the Core tile's hardware definition and are the same regardless of your project config.
The Init Recipe
When your project declares tiles, core_init.h includes a commented recipe showing exactly how to initialize the tile driver layer. This recipe uses the actual handle names from your project — copy it into main.c and adapt as needed:
/* core_init.h — generated recipe (shown as comments) */
/* ---- Tile driver init recipe ----
*
* Tile handles are declared in tiles.h. Driver initialization belongs
* in main.c where you control the sequence and error handling.
* Copy and adapt the following into your main():
*
* // Bridge each bus to the tile driver layer
* tiles_pal_core_cfg_t core_hal_i2c3_cfg = {
* .i2c = &core_i2c3,
* .buses = TILES_BUS_I2C,
* };
* tiles_pal_core_init(&core_hal_i2c3, &core_hal_i2c3_cfg);
*
* // Find and initialize each tile
* tile_sense_i_9_init(&core_hal_i2c3, 0, &tile_sense_i_9_0);
*/tiles.h and the Managed Section
The first time you build a project with tiles, coregen creates tiles.h with all the necessary includes and handle declarations. On subsequent builds, it updates only the section between the coregen:begin and coregen:end markers, leaving anything you've added outside them untouched:
/* tiles.h — generated by coregen, then yours to extend */
#ifndef TILES_H
#define TILES_H
/* ---- coregen:begin ---- */
#include "tiles_pal.h"
#include "tiles_pal_core.h"
#include "tile_sense_i_9.h"
/* Tile PAL handles (one per bus used by tiles) */
tiles_pal_t core_hal_i2c3;
/* Tile instance handles */
tile_t tile_sense_i_9_0;
/* ---- coregen:end ---- */
/* Your tile-related defines go here — coregen won't touch them */
#endif /* TILES_H */coregen:begin and coregen:end belongs to the code generator. Everything outside belongs to you. Add a tile to project.json, rebuild, and the managed section updates automatically.Firmware
Writing main.c
Your application starts with two includes and follows a straightforward pattern: initialize the core hardware, bridge the buses to the tile driver layer, initialize your tiles, then run your application loop.
#include "core.h"
#include "tiles.h"
int main(void)
{
core_init();
/* Bridge I2C3 to tile driver layer */
tiles_pal_core_cfg_t core_hal_i2c3_cfg = {
.i2c = &core_i2c3,
.buses = TILES_BUS_I2C,
};
tiles_pal_core_init(&core_hal_i2c3, &core_hal_i2c3_cfg);
/* Find and initialize Sense.I.9 */
tile_sense_i_9_init(&core_hal_i2c3, 0, &tile_sense_i_9_0);
if (!tile_is_ready(&tile_sense_i_9_0)) {
/* handle error */
}
while (1) {
int16_t accel[3];
tile_sense_i_9_get_raw_accels(&tile_sense_i_9_0, accel);
/* ... */
}
}core_init() sets up the clock tree, configures all pads (GPIO modes, alternate functions, pull-ups), and initializes I2C peripherals. After this call, the hardware is ready for communication.
The bus bridge (tiles_pal_core_init) connects a Core I2C peripheral to the platform-agnostic tile driver layer. You create one bridge per bus that has tiles on it. The config struct points to the I2C handle declared by coregen (e.g., core_i2c3).
Tile init probes the bus, verifies the device identity, configures it, and sets the handle's state to TILE_STATE_READY. Check the state before using the tile — if something went wrong (no device, wrong ID, config failure), the handle will reflect it.
Init Sequence
For simple projects, core_init() does everything in one call. But you can split it when you need visibility into what's happening at boot — particularly useful when debugging hardware bring-up:
#include "core.h"
#include "tiles.h"
/* Debug variables (readable via SWD) */
volatile int16_t dbg_accel[3];
volatile int16_t dbg_gyro[3];
int main(void)
{
/* Clock + LED first so we can see driver init status */
core_clock_init();
core_led_init();
core_led_blink(1, 200, 300);
/* Pads + I2C */
core_pads_init();
core_led_blink(1, 200, 300);
/* Bridge I2C3 to tile driver layer */
tiles_pal_core_cfg_t core_hal_i2c3_cfg = {
.i2c = &core_i2c3,
.buses = TILES_BUS_I2C,
};
tiles_pal_core_init(&core_hal_i2c3, &core_hal_i2c3_cfg);
/* Find and initialize Sense.I.9 */
tile_sense_i_9_init(&core_hal_i2c3, 0, &tile_sense_i_9_0);
if (!tile_is_ready(&tile_sense_i_9_0))
core_led_sos();
core_led_blink(3, 200, 300);
/* Live data loop */
while (1) {
int16_t accel[3], gyro[3];
tile_sense_i_9_get_raw_accels(&tile_sense_i_9_0, accel);
tile_sense_i_9_get_raw_gyros(&tile_sense_i_9_0, gyro);
dbg_accel[0] = accel[0];
dbg_accel[1] = accel[1];
dbg_accel[2] = accel[2];
dbg_gyro[0] = gyro[0];
dbg_gyro[1] = gyro[1];
dbg_gyro[2] = gyro[2];
int32_t mag = (int32_t)accel[0]*accel[0]
+ (int32_t)accel[1]*accel[1]
+ (int32_t)accel[2]*accel[2];
core_led_heartbeat(mag > 300000000 ? 50 : 500);
}
}By calling core_clock_init() and core_led_init() first, you get LED feedback before anything else runs. Each blink tells you which stage completed successfully. If the board hangs, the blink count tells you exactly where.
The core_led_sos() pattern (three short, three long, three short) is unmistakable — if you see it, the tile driver didn't reach READY state. Check your wiring, pull-ups, and bus speed.
The volatile debug variables are readable via SWD while the firmware is running — connect a debugger and inspect them live without adding any serial output.
core_led_blink() between init stages costs nothing and saves hours of guessing. Save SWD and UART for when you need actual data.Adding Tiles
When your project grows — more sensors, a haptic driver, a power management IC — adding a tile is three steps:
tiles.h with the new includes and handles. The init recipe in core_init.h updates too.tile_<family>_<name>_init(&hal_handle, instance, &tile_handle).Multi-Bus Example
Here's a project with four tiles across two I2C buses — two Drive.P haptic drivers, one on each bus, plus a sensor and a power management tile:
{
"pads": {
"10": "I2C1.CLK",
"11": "I2C1.DAT",
"4": "I2C3.CLK",
"5": "I2C3.DAT"
},
"interfaces": {
"I2C1": { "speed": 100000, "pullups": true },
"I2C3": { "speed": 100000, "pullups": true }
},
"tiles": [
{ "tile": "Sense.I.9", "bus": "I2C3", "instance": 0 },
{ "tile": "Power.L.1T", "bus": "I2C3", "instance": 0 },
{ "tile": "Drive.P", "bus": "I2C1", "instance": 0 },
{ "tile": "Drive.P", "bus": "I2C3", "instance": 1 }
]
}Coregen produces a tiles.h with handles for both buses and all four tiles:
/* ---- coregen:begin ---- */
#include "tiles_pal.h"
#include "tiles_pal_core.h"
#include "tile_sense_i_9.h"
#include "tile_power_l_1t.h"
#include "tile_drive_p.h"
/* Tile PAL handles (one per bus used by tiles) */
tiles_pal_t core_hal_i2c3;
tiles_pal_t core_hal_i2c1;
/* Tile instance handles */
tile_t tile_sense_i_9_0;
tile_t tile_power_l_1t_0;
tile_t tile_drive_p_0;
tile_t tile_drive_p_1;
/* ---- coregen:end ---- */And main.c bridges both buses, then initializes each tile on the correct one:
#include "core.h"
#include "tiles.h"
int main(void)
{
core_init();
/* Bridge I2C1 to tile driver layer */
tiles_pal_core_cfg_t core_hal_i2c1_cfg = {
.i2c = &core_i2c1,
.buses = TILES_BUS_I2C,
};
tiles_pal_core_init(&core_hal_i2c1, &core_hal_i2c1_cfg);
/* Bridge I2C3 to tile driver layer */
tiles_pal_core_cfg_t core_hal_i2c3_cfg = {
.i2c = &core_i2c3,
.buses = TILES_BUS_I2C,
};
tiles_pal_core_init(&core_hal_i2c3, &core_hal_i2c3_cfg);
/* Find and initialize tiles */
tile_sense_i_9_init(&core_hal_i2c3, 0, &tile_sense_i_9_0);
tile_power_l_1t_init(&core_hal_i2c3, 0, &tile_power_l_1t_0);
tile_drive_p_init(&core_hal_i2c1, 0, &tile_drive_p_0);
tile_drive_p_init(&core_hal_i2c3, 1, &tile_drive_p_1);
while (1) {
}
}Notice that the two Drive.P tiles have different instance numbers (0 and 1) even though they're on different buses. The instance number maps to the hardware address — instance 0 is always the default address for that tile, instance 1 is the alternate. The bus handle tells the driver where to look; the instance number tells it which device.
Clock Configuration
Rather than specifying oscillators and frequencies directly, you choose a performance level. Each Core tile defines four levels that map to sensible clock configurations for that specific hardware — coregen handles oscillator selection, PLL math, flash wait states, and voltage scaling automatically.
"low"Lowest power, no PLL
"default"Balanced — good starting point
"high"PLL'd for throughput
"max"Full speed, max performance
What Each Level Means Per Tile
| Core Tile | low | default | high | max |
|---|---|---|---|---|
| Core.W | HSI16 16MHz | HSE 32MHz | PLL 64MHz | PLL 100MHz |
| Core.U | HSI16 16MHz | HSI16 16MHz | PLL 48MHz | PLL 80MHz |
| Core.L | HSI16 16MHz | HSI16 16MHz | PLL 32MHz | PLL 32MHz |
| Core.H | HSI48 48MHz | PLL 150MHz | PLL 200MHz | PLL 250MHz |
The generated core_clock_init() handles the full sequence: enable the clock source, wait for it to stabilize, set flash wait states, configure PLL if needed, switch SYSCLK, set bus prescalers, and start SysTick for ll_delay_ms(). You don't need to touch any of this — just pick your level.
"low" is perfectly fine for development — it keeps power consumption minimal and avoids PLL complexity. When you need more throughput, change one string in project.json and rebuild.Practical
Building
Each project has its own Makefile generated by coregen — so you can build from inside the project folder with a plain make. Build artifacts go into build/ inside the project directory. You can also build from the repo root by passing the target explicitly.
# Build from inside your project folder cd projects/my-firmware make # Flash via ST-Link / J-Link (SWD) make flash # Flash via USB DFU (Core.U with bootloader installed) # Automatic — triggers DFU, downloads, reboots into new firmware: make flash-dfu # Clean build artifacts make clean # Or build from the repo root make TILE=Core-W-b PROJECT=my-firmware KILN_ENABLED=1
Reading the Size Output
Every successful build prints a size summary. Here's how to read it:
text data bss dec hex filename 3924 0 1672 5596 15dc sdk-demo.elf # text = code + constants (lives in flash) # data = initialized globals (copied from flash to RAM at startup) # bss = zero-initialized globals (RAM only) # dec = total footprint
The Cores SDK produces lean binaries — a full project with clock init, I2C, and a 9-axis IMU driver comes in under 4KB of flash. There's no RTOS, no HAL bloat, no startup overhead you didn't ask for.
Programming & Flashing
Every Core tile supports SWD debug as the primary programming interface during development. Some tiles also support ROM bootloader paths — USB DFU, UART, I2C, SPI — that let you flash in production without a debug probe. Core.U supports runtime USB DFU — once the bootloader is installed, just run make flash-dfu and the board updates automatically. BLE OTA (Core.W) is on the roadmap. Both concerns are declared in project.json under separate keys: debug for the live debug interface and isp for the production flashing method.
Options across the Core family
| Method | Core.L | Core.U | Core.W | Core.H | Notes |
|---|---|---|---|---|---|
| SWD (debug) | ✓ | ✓ | ✓ | ✓ | SWCLK + SWDIO. Always available. Requires ST-Link or J-Link. |
| JTAG (debug) | ✗ | ✓ | ✗ | ✓ | 4-wire (TCK/TMS/TDI/TDO). L and W are SWD-only silicon. |
| USB DFU (ROM) | ✗ | ✓ | ✗ | ✓¹ | ST ROM bootloader via USB. No probe needed. BOOT0 high at reset. |
| UART bootloader | ✓ | ✓ | ✓ | ✓ | ST ROM bootloader via USART. Universal. Needs USB-serial adapter. |
| I2C bootloader | ✓ | ✓ | ✗ | ✓ | ST ROM bootloader via I2C. Good for automated factory fixtures. |
| SPI bootloader | ✓ | ✓ | ✗ | ✓ | Fastest ROM bootloader path. WBA55 ROM does not include SPI. |
| BLE OTA | ✗ | ✗ | ⟳ | ✗ | Over-the-air via BLE GATT service. Core.W only. Roadmap item. |
| USB DFU (runtime) | ✗ | ✓ | ✗ | ⟳ | Custom bootloader + 1200-baud touch. No BOOT0 needed. Core.H coming soon. |
✓ available · ✗ not supported · ⟳ planned · ¹ Core.H USB support coming soon in the builder
SWD — Serial Wire Debug
SWD is the standard debug and programming interface across all Core tiles. It provides live breakpoints, memory inspection, real-time variable watching, and firmware flashing — all over two wires (SWCLK + SWDIO) from a standard ST-Link or J-Link probe.
By default, the SWCLK and SWDIO pads are reserved and excluded from pad assignment. If your design never needs a debug probe after initial production (e.g., you flash via USB DFU and never use live debug in the field), you can mark the pads as one-time / ISP in project.json — this frees them for use as general-purpose GPIO in your layout:
"debug": {
"interface": "swd",
"dedicated": false // pads released as GPIO after initial flash
}USB DFU — runtime (custom bootloader)
Core.U includes a custom DFU bootloader that lives in the first 8KB of flash. Once installed, firmware updates happen automatically — just run make flash-dfu. No BOOT0 pin, no debug probe, no manual steps.
How it works: The Makefile sends a 1200-baud “touch” to the USB serial port (the same convention Arduino uses). The CDC driver detects this and reboots into DFU mode. The host then downloads the new firmware and the board reboots into it. The whole process takes about 3 seconds.
Any app that calls core_usb_init() gets DFU support for free — the 1200-baud touch handler is built into the USB CDC driver. No extra code needed in your application.
# ---- One-time setup: install the bootloader ---- # Hold BOOT0 high while pressing reset, then: make flash-bootloader # Release BOOT0. The board enters DFU mode (no app yet). # Flash your first app: make flash-dfu # ---- Normal workflow (no BOOT0, no pins) ---- # Edit your code, then: make flash-dfu # That's it. The Makefile auto-triggers DFU via 1200-baud touch, # downloads the new firmware, and reboots into it. # ---- Fallback (bricked app, no USB serial) ---- # Hold BOOT0 high at reset to enter the ST ROM bootloader: dfu-util -a 0 -s 0x08000000 -D projects/bootloader/build/bootloader.bin dfu-util -a 0 -s 0x08002000 -D build/my-firmware.bin
BOOTLOADER=1 build flag handles linker scripts and vector table relocation automatically.USB DFU — ROM bootloader (fallback)
The ST ROM DFU bootloader is always available as a fallback. It lives in system memory (not user flash) and is activated by holding BOOT0 high at reset. Use it for initial bootloader installation or to recover from a bricked application. Flash using dfu-util with the DfuSe address flag.
{
"project": { "name": "my-firmware", "core": "Core-U-2-a" },
"clock": "default",
"debug": {
"interface": "swd",
"dedicated": true
},
"isp": {
"method": "usb-dfu",
"boot0_pad": "21"
},
"pads": { ... },
"interfaces": { ... },
"tiles": [ ... ]
}The boot0_pad value (pad 21 on Core.U.2, pad 11 on Core.U.1) is filled automatically by the Core Configurator.
isp method doesn't disable SWD — you can still connect a probe and debug normally. ISP is just a secondary path for production lines or field recovery.UART, I2C, SPI — ROM bootloader (coming soon)
All ST ROM bootloaders on L, U, and H family tiles also support UART, I2C, and SPI paths — useful for automated factory programming fixtures or any situation where USB isn't available. The Core.W (WBA55) ROM supports UART only. These paths work at the hardware level today; make flash-uart and the corresponding isp configuration in project.json are coming in a near-term SDK update.
OTA — roadmap
BLE OTA — Core.W (STM32WBA55)
The WBA55's 1MB flash can hold two firmware images. A small bootloader (~16KB) in the first flash sector validates slot B, copies or swaps it to slot A, then boots. New firmware is transferred over BLE using a GATT OTA service — the device stays in the field, no physical access needed. The SDK will provide the OTA service, image header format, linker layout, and VTOR configuration as a first-class feature.
Dual-bank seamless OTA — Core.H (STM32H5)
The H5 family has hardware dual-bank flash with atomic bank-swap — the running image stays live in bank A while bank B receives the update, then a single register write swaps them. This is the most robust OTA approach: crash-safe, zero-downtime, no custom bootloader required. Planned as part of the Core.H feature set.
Debugging
The SDK provides several debugging tools that work without adding serial output code:
LED Patterns
core_led_blink(n, on_ms, off_ms) — blink n times with specified timing
core_led_heartbeat(period_ms) — continuous heartbeat, vary speed to indicate state
core_led_sos() — unmistakable error pattern (short-long-short)
SWD Debug Variables
Declare volatile globals and write your sensor data into them. Connect a debugger (ST-Link, J-Link) and watch them live — no printf, no UART, no overhead. The compiler won't optimize them away because of the volatile qualifier.
Tile State
Every tile handle carries a lifecycle state. After init, check tile_is_ready() or inspect tile_state() for finer-grained status (FOUND but not configured, SLEEPING, ERROR). See the Driver Guide for details.
HAL APIs
Peripheral Drivers
Each HAL peripheral driver has its own reference page covering configuration, the full API, per-core notes, and worked examples.
Analog inputs, VREFINT millivolt conversion, die temperature, oversampling, DMA.
Serial TX/RX, baud configuration, blocking and interrupt-driven transfers.
Master mode, chip-select management, DMA transfers.
Master read/write, clock stretching, multi-master bus sharing.
Digital output, input with pull config, interrupt-on-change.
PWM output, input capture, one-shot and periodic modes.
Reference
SDK Implementation
Full feature support matrix across all Core variants — clocks, peripherals, HAL/TAL status, connectivity, and project generation. See at a glance what's hardware-verified vs. compile-tested.
SDK Implementation →Live