Project Config
Every project carries a config.json — the single source of truth for its hardware setup. You declare the Core, a clock level, what each pad does, and the tiles you’re wiring; coregen turns it into all the C init code. This is the field-by-field authoring reference.
The mental model
The Core’s definition (in definitions/) is the menu: it lists which pads exist, what each pad can do, the available clock levels, and the on-board peripherals. Your config.json is the order. coregen validates the order against the menu, then does the wiring — resolving alternate-function numbers, solving the PLL, computing I²C timing, and emitting handles.
The single most important idea: most things are derived, not declared. You almost never name a peripheral directly. You assign pad functions, and coregen infers the peripherals from them:
| You write in pads… | coregen infers… |
|---|---|
"I2C1.CLK" + "I2C1.DAT" | an I2C1 bus (tuned by interfaces.I2C1) |
"SPI1.CLK" + "SPI1.MOSI" … | an SPI1 bus (tuned by interfaces.SPI1) |
"USART2.TX" + "USART2.RX" | a USART2 UART (tuned by interfaces.USART2) |
"TIM2.1" | a PWM/timer channel on TIM2 (1 kHz; change it at run time) |
"ADC5" / "ADC_IN3" | an ADC input channel |
"DAC1.OUT" | the DAC |
interfaces section only tunes a bus that a pad function already brought into existence. If you put I2C1 there but no pad is set to an I2C1.* function, coregen warns and emits nothing.Quickstart
Smallest valid file — LED blink, no peripherals:
{
"core": "Core.ST.L4.2",
"clock": "max",
"pads": {}
}A real project — one I²C tile on a USB-powered L4:
{
"core": "Core.ST.L4.2",
"clock": "max",
"usb": { "enabled": true },
"pads": {
"4": "I2C1.CLK",
"5": "I2C1.DAT"
},
"interfaces": {
"I2C1": { "speed": 400000 }
},
"tiles": [
{ "tile": "Sense.I.6P6", "bus": "I2C1", "instance": 0 }
]
}After editing, regenerate with make generate (coregen also runs automatically on a normal make). Never hand-edit generated files between coregen:begin/coregen:end markers — re-running coregen clobbers them. Change the config instead.
Top-level fields
coregen reads a fixed allowlist of keys. Anything not listed here is silently ignored — a typo’d key is not an error, it just does nothing.
| Key | Type | Required | Default | Read? |
|---|---|---|---|---|
core | string | yes | — | ✅ |
clock | string | no | tile's default | ✅ |
pads | object | no | {} | ✅ |
interfaces | object | no | {} | ✅ (tuning) |
gpio | object | no | {} | ✅ |
pullups | array | no | [] | ✅ (on-tile I²C pull-ups) |
tiles | array | no | [] | ✅ |
usb | object | no | disabled | ✅ |
bootloader | string | no | "none" | ✅ |
iwdg | object | no | disabled | ✅ |
ble | object | no | disabled | ✅ (Core.ST.W5 radio + GATT contract) |
thread | object | no | disabled | ✅ (Core.ST.W5 Thread network) |
timer | object | no | none | ✅ (Studio) |
pins | object | no | — | ✅ (legacy alias for pads) |
scope | object | no | enabled | read by the Makefile |
probe | object | no | self-powered | read by the flash tooling |
debug, isp, programming | object | no | — | ❌ ignored |
core — pick the MCU/core (required)
Selects definitions/<Core>.json. Prefer the public name (the DB stem also works).
| Public name | MCU | Max SYSCLK | USB? |
|---|---|---|---|
Core.ST.L0 | STM32L011 | 32 MHz | no |
Core.ST.L4 | STM32L422 | 80 MHz | yes |
Core.ST.L4.2 | STM32L422 | 80 MHz | yes |
Core.ST.W5 | STM32WBA55 | 100 MHz | no |
Core.ST.H5 | STM32H523 | 248 MHz | yes |
clock — performance level
A clock-level name the Core defines (typically "low", "medium", "high", "max"), not a frequency. "default" resolves to the tile’s schema default. coregen solves the PLL and applies any required voltage scaling. An unknown level is a hard error. Note the clock also gates bus speeds — see I²C below.
Core.ST.W5, enabling ble or thread with "clock": "low" is a build error: the 2.4 GHz radio needs voltage range 1. Some Cores also adjust low: on the L4 Cores and the H5 it runs at 16 MHz, because USB is always on there.Pads & functions
The heart of the file. Key = pad number as a string ("4", not 4); value = a function the Core definition lists for that pad. coregen validates every entry — a bad pad or unavailable function prints the available options and exits.
| Family | Form | Examples |
|---|---|---|
| GPIO | GPIO.OUT / GPIO.IN | "GPIO.OUT" |
| I2C | I2C<n>.CLK / .DAT | "I2C1.CLK", "I2C3.DAT" |
| SPI | SPI<n>.CLK/MOSI/MISO/CS | "SPI1.CLK", "SPI1.CS" |
| USART | USART<n>.TX / .RX | "USART2.TX" |
| Timer/PWM | TIM<n>.<ch> (ch 1–4) | "TIM2.1", "TIM15.2" |
| ADC | ADC / ADC<n> / ADC_IN<n> [+] | "ADC1", "ADC5", "ADC_IN3", "ADC7+" |
| DAC | DAC1.OUT | "DAC1.OUT" |
| USB | USB.DP / USB.DM | "USB.DP" |
- SPI CS is software-managed.
SPI*.CSpads become plain GPIO outputs driven byhal_spi_set_cs()— not hardware NSS. Not configurable. - ADC is single-ended positive only. Differential negative inputs aren’t emitted; coregen emits one
core_adc1handle today. - A bus needs all its pads. An I2C bus needs both
CLKandDAT; SPI needsCLK+MOSI+MISO.
Interfaces — tune the buses
Keyed by peripheral name (I2C1, SPI1, USART2, TIM2, …). Only tunes a bus a pad already created.
I²C
| Field | Type | Default | Allowed |
|---|---|---|---|
speed | int | 400000 | 100000, 400000, 1000000 |
pullups | bool | true | enable internal pad pull-ups |
Core.ST.W5 and Core.ST.L0 the I²C kernel clock is fixed at HSI16 (16 MHz) whatever the SYSCLK; on the L4 Cores and the H5 it follows SYSCLK, so there a too-low clock can make a fast speed illegal.On-tile pull-ups. Core.ST.L4 and Core.ST.W5 have I²C pull-up resistors on pads 4 and 5 that a GPIO switches on. Name the ones you want at the top level, and core_init() turns them on. At 400 kHz or more without them, coregen warns: the MCU’s internal pull-up is too weak.
"pullups": ["pad4", "pad5"]I3C (Core.ST.H5). Add "i3c": true (and optionally i3c_speed, default 12500000) to an I²C bus and coregen runs it as I3C, emitting core_i3c1 instead of core_i2c1. It does so only when the MCU, both pads and every tile on the bus support I3C; otherwise the bus stays I²C and a NOTE says why. See I3C.
SPI
| Field | Type | Default | Allowed |
|---|---|---|---|
mode | int | 0 | 0–3 (CPOL = bit1, CPHA = bit0) |
prescaler | int | 8 | 2,4,8,16,32,64,128,256 |
bit_order | string | "msb" | "msb" | "lsb" |
cs_polarity | string | "active-low" | "active-low" | "active-high" |
On the L4 Cores and the W5, coregen also checks SCK (SYSCLK / prescaler) against the datasheet limit: over it is an error.
USART / UART
| Field | Type | Default | Notes |
|---|---|---|---|
baud | int | 115200 | any rate |
rx_interrupt | bool | false | true = interrupt-driven RX with a ring buffer |
Timer / PWM
Each timer a TIM<n>.<ch> pad uses gets a handle (core_tim<n>) and starts at 1 kHz, shared by every channel on that timer. There is no config key for the frequency: no Core lists its timers as interfaces, so interfaces.TIM2 fails validation. Change it at run time with core_timer_set_freq(&core_tim2, hz), and set the duty with core_timer_pwm_set() (per mille, 0-1000). There is no pwm, capture, or timers section.
GPIO & tiles
gpio — per-pin electrical details
Optional refinements for pads set to GPIO.IN/GPIO.OUT (key = pad number string).
| Field | Default | Allowed / meaning |
|---|---|---|
pull | "none" | "none" | "up" | "down" |
output_type | "push-pull" | "push-pull" | "open-drain" |
speed | "medium" | slew rate (passed through) |
exti | none | "rising" | "falling" | "both" |
default | none | "high" | "low" (initial output state) |
tiles — attach drivers to buses
| Field | Type | Required | Meaning |
|---|---|---|---|
tile | string | yes | a name in coregen’s TILE_DRIVER_MAP |
bus | string | yes | must match a configured I2C*/SPI* interface |
instance | int | no (0) | I2C: address slot · SPI: per-CS index |
cs_pad | string | SPI only | pad number of this tile's chip-select |
bus2 | string | dual-bus tiles | the second bus (the other protocol) |
cs_id | int | no | a dual-bus tile's chip-select id on its SPI bus |
coregen emits a per-tile handle like tile_sense_i_6p6_i2c1_0 and adds the driver to the build. Valid tile names are the TILE_DRIVER_MAP dict in coregen.py.
USB, bootloader & watchdog
usb
| Field | Default | Notes |
|---|---|---|
enabled | false | true adds core_usb.h to core.h. USB-CDC itself starts in core_init() on every L4 / H5 project either way, to keep the 1200-baud DFU touch working. |
vid / pid | SDK default | "0x1209" or 4617 |
product / manufacturer / serial | SDK default | descriptor overrides; CMSIS-DAP hosts match "CMSIS-DAP" in product |
USB requires a USB-capable MCU (L4 or H5); on other cores it’s a no-op/warning.
bootloader
| Value | Layout | Cores | Needs USB |
|---|---|---|---|
"none" | app at 0x08000000, SWD only (default) | all | no |
"custom" | 8 KB DFU at 0x08000000, app at 0x08002000 | L4 / H5 — parked | yes |
"rom" | ST ROM DfuSe, app at 0x08000000 | L4, H5 | yes |
Sets the BOOTLOADER / ROM_DFU Make variables. See Bootloading. timer.tick_ms feeds the Studio simulation tick only — not a hardware tick.
"rom" is what Studio and the templates use. The build refuses "custom" unless you also pass CUSTOM_BOOTLOADER_ACK=1, because flashing a custom-layout image onto a board that’s in ROM-DFU layout writes at 0x08002000 and corrupts the resident app.On Core.ST.H5 the ROM’s DFU “leave” starts the new image without a reset, and the peripherals the ROM secured (TIM1-TIM8, ADC, I2C1/2, LPUART1 and others) can’t be clocked until the next reset. USB, SPI, I2C3 and USART1-3 are not affected.
iwdg
An independent watchdog started inside core_init(). Opt-in, so regenerating an existing project never surprises it with resets:
"iwdg": { "enabled": true, "timeout_ms": 5000 }Once enabled, your loop must call core_watchdog_feed() or the Core resets. Pair it with "bootloader": "rom": repeated watchdog resets then escalate into ROM DFU rather than a soft-brick, which is the whole point. coregen warns if you enable it without ROM mode, since an un-fed watchdog would just reset-loop with no way out.
BLE, Thread, scope & probe
ble (Core.ST.W5)
"ble": {
"enabled": true,
"name": "MY-DEVICE",
"tx_power": "medium",
"adv_interval_ms": 100,
"pairing": false,
"contract": "ble_contract.json"
}| Field | Default | Notes |
|---|---|---|
enabled | false | link the BLE stack and generate the radio settings |
name | project name | advertised name, BLE_DEVICE_NAME |
tx_power | SDK default | "low" | "medium" | "high" or 0-2 |
adv_interval_ms | SDK default | 20-10240 ms, or {min, max} |
pairing | false | Just Works pairing + bonding; every characteristic then needs an encrypted link |
contract | none | the GATT contract, inline or a file next to config.json; coregen generates ble_contract.{c,h} |
Needs clock medium or higher. Include core_ble.h (and ble_contract.h with a contract) in main.c: core.h does not. See BLE.
thread (Core.ST.W5)
"thread": {
"enabled": true,
"channel": 15,
"panid": "0x1234",
"network_key": "<32 hex digits>",
"network_name": "tiles-dev",
"provisioning": false,
"ble_provisioning": false
}The node joins on boot. Also read: role ("ftd" only), ext_panid and mesh_local_prefix. Absent keys take development defaults that every node shares, so set your own network for anything real. With provisioning on, a network stored at run time wins over this one; ble_provisioning adds an open BLE service for that (development only). With ble enabled too, core_ble_init() starts Thread, not core_init(). See Thread.
scope
"scope": { "enabled": false }The production switch for core_scope. Absent or true leaves it available (an unused module costs nothing); false compiles every core_scope_* call to nothing. Read by the Makefile, not coregen (make SCOPE_ENABLED=0 overrides it once).
probe
"probe": { "target_power": "5v" }How a CoreProbe powers this board while flashing over SWD: off (the default, self-powered) or 5v. Read by make flash-coreprobe and Studio, never coregen. The logic level is sensed, not declared, and a board that already has power is never fed.
Keys that are silently ignored
coregen reads a fixed allowlist; the raw config is never handed to the templates. These keys are read by nothing and have no effect, even though older docs described some of them. Because unknown keys are ignored rather than rejected, using them is harmless but does nothing.
| Ignored key | What people expect | Reality |
|---|---|---|
debug | emit SWD/JTAG defines | No-op. |
isp / programming | boot0_pad, methods… | No-op. |
timers | per-timer freq/tick | No-op. Timers run at 1 kHz; set the frequency at run time. |
pwm | per-channel duty | No-op. Duty is a runtime API call. |
capture | input-capture mapping | No-op (not config-driven). |
Validation & errors
coregen is mostly fail-fast — a bad config exits before any compile, so it won’t silently generate wrong code.
- Exits on: a pad that doesn’t exist or a function not available on it; an unknown
clocklevel; an I²Cspeedout of set or its kernel clock too low; an SPImode/prescalerout of range; an unknown tile, a tile on an unconfigured bus, or an SPI tile whosecs_paddoesn’t resolve; an invalidbootloader; aninterfaceskey the Core doesn’t list (timers included);bleorthreadout of range, off Core.ST.W5, or withclock: "low"; an unknownpullupsname. - Warns (non-fatal) on: a
corename not matching the definition; aninterfacesentry with no backing pads; a USB request on a non-USB Core. - Silent: unknown/typo’d top-level keys, and omitted optional fields (they take their defaults).
Recipes
GPIO in + out, falling-edge interrupt with pull-up
{
"core": "Core.ST.L0",
"clock": "max",
"pads": { "11": "GPIO.OUT", "8": "GPIO.IN" },
"gpio": { "8": { "pull": "up", "exti": "falling" } }
}Two I²C buses at different speeds, three tiles
{
"core": "Core.ST.L4",
"clock": "max",
"pads": {
"4": "I2C1.CLK", "5": "I2C1.DAT",
"2": "I2C3.CLK", "8": "I2C3.DAT"
},
"interfaces": {
"I2C1": { "speed": 400000 },
"I2C3": { "speed": 100000 }
},
"tiles": [
{ "tile": "Display.RGBW", "bus": "I2C1", "instance": 0 },
{ "tile": "Sense.I.6P6", "bus": "I2C3", "instance": 0 },
{ "tile": "Sense.T.C", "bus": "I2C3", "instance": 0 }
]
}SPI tile with software CS (SPI3 on pads 2/6/7, CS on pad 3)
{
"core": "Core.ST.W5",
"clock": "high",
"pads": {
"2": "SPI3.CLK", "6": "SPI3.MOSI", "7": "SPI3.MISO", "3": "SPI3.CS"
},
"interfaces": { "SPI3": { "mode": 0, "prescaler": 8 } },
"tiles": [
{ "tile": "Store.O.128", "bus": "SPI3", "instance": 0, "cs_pad": "3" }
]
}PWM on two channels (1 kHz each; change with core_timer_set_freq())
{
"core": "Core.ST.L4.2",
"clock": "max",
"pads": { "7": "TIM15.1", "8": "TIM2.1" }
}ADC + UART logging
{
"core": "Core.ST.L0",
"clock": "high",
"pads": { "6": "ADC1", "7": "USART2.TX", "2": "USART2.RX" },
"interfaces": { "USART2": { "baud": 115200 } }
}USB CDC + ROM-DFU bootloader + watchdog
Core.ST.L4.2’s USB is on pads 16/17 and always on, so it needs no pads.
{
"core": "Core.ST.L4.2",
"clock": "max",
"bootloader": "rom",
"usb": { "enabled": true },
"iwdg": { "enabled": true, "timeout_ms": 5000 },
"pads": {}
}
