Scope (live streaming)
Register a variable once and call one function in the loop: core_scope snapshots it, frames it, and streams it over whichever link the Core has. Studio’s Scope panel plots the stream live; so does anything else that speaks the protocol, because the stream describes its own channels.
Overview
#include "core_scope.h"
static int32_t distance_mm;
static float temp_c;
static int16_t accel[3];
int main(void)
{
core_init();
core_scope_watch(distance_mm); // name and type come from the variable
core_scope_watch(temp_c);
core_scope_watch_array(accel, 3); // channels accel[0], accel[1], accel[2]
while (1) {
... update the variables ...
core_scope_update(); // snapshot + send; never blocks
}
}That is the whole API for most programs. Open the project in Studio, build and flash, and the Scope pane in the firmware inspector plots distance_mm, temp_c and the three accelerometer axes as they change. The DSL’s scope(...) statement is the same thing from Blocks.
Nothing is sent while nobody is listening (no terminal on USB, no subscriber on Bluetooth), so a scope left in production firmware costs one branch per loop pass. When the link cannot keep up, whole samples are dropped rather than blocking the loop, and the host is told how many.
Watching variables
core_scope_watch(var) is a macro: it stringifies the variable’s name for the channel label, takes its address, and picks the wire type from the C type with _Generic. Any integer up to 32 bits, float and bool work; a double or a 64-bit integer is a compile error, because the wire has no 8-byte type. Narrow types travel narrow: an int16_t costs two bytes per sample, which matters over Bluetooth.
The module keeps a pointer and re-reads the variable on every sample, so it has to outlive the loop: a global, a static, or a field of one. A local variable will not do. core_scope_watch_as("name", var) picks a different label, and core_scope_add() is the plain function underneath for anything the macro cannot express. Up to 32 channels; an array counts per element.
core_scope_set_interval_ms(10) throttles a fast loop to one sample every 10 ms. It is a gate, not a timer: a loop that already runs slower streams at its own pace.
Sampling from an interrupt
A main loop samples whenever it happens to get round to it, so the plot carries the loop’s jitter. Data that arrives on a timer or a sensor’s data-ready interrupt should be sampled there. Split the two halves:
core_scope_watch_array(accel, 3);
core_scope_declare_rate_hz(1000); // a promise: sample() runs at exactly 1 kHz
void my_timer_isr(void)
{
read_imu(accel);
core_scope_sample(); // ISR-safe: copies the values into a ring, nothing else
}
while (1) {
core_scope_pump(); // frames and sends from the main loop
}With a declared rate the stream carries a sample counter instead of a millisecond timestamp per sample. That is smaller (rows of samples share one header), exact above 1 kHz, and a dropped sample shows up on the plot as a gap of exactly the right width. core_scope_declare_rate_millihertz() keeps rates that are not a whole number of hertz exact (an IMU at 416.667 Hz is 416667).
core_scope_sample() from one context only, and not alongside core_scope_update(), which samples too. A declared rate assumes continuous sampling; for capture-then-idle bursts use the timestamped mode.USB and Bluetooth
The link is chosen at compile time: USB CDC on the Cores that have it, Bluetooth LE on a Core.ST.W5 built with the radio on, and none on a Core.ST.L0 (calls are inert there until core_scope_set_link() is given something to write to; a UART link is a few lines).
USB shares the port with core_usb_print text. Frames are queued whole through the non-blocking core_usb_try_write(), ahead of any later print, so text never lands inside a frame and a host separates the two by the frame marker and CRC. Opening the port asserts DTR, which is what starts the stream.
Bluetooth adds a GATT service of its own, Studio Link (0x5C00) with one notify characteristic, Studio Scope (0x5C01), beside whatever the project declares. Subscribing starts the stream. Frames are batched into 180-byte notifications, sent when full or 30 ms old, because a connection only moves data every 15–50 ms; every sample carries device time, so a batch plots correctly. The first core_scope_* call must come before core_ble_init(): a service cannot be added once the stack is running.
core_scope_watch(counter); // registers Studio Link
core_ble_set_conn_params(15, 30, 0, 4000); // the tightest window Apple hosts accept
core_ble_init();Cost and production builds
While streaming, a sample is a gather of the watched variables into a ring; the framing and link I/O happen in core_scope_pump(). A 6-channel frame is 35 bytes; at 1 kHz that is 35 kB/s, comfortably inside full-speed USB and too much for Bluetooth, where narrow types and a declared rate bring an IMU stream down to about 13 kB/s. On a Core.ST.L4 the module costs about 3.8 KB of flash and 1.8 KB of RAM when used, and nothing when it is not: the linker drops it.
For a production build, "scope": { "enabled": false } in config.json compiles every call to nothing: no flash, no RAM, and no Studio Link service in the GATT table. core_scope_enable(0) is the run-time pause for a debug button.
The protocol
Every frame is A5 53 type seq len payload crc8. A schema frame carries the channel names and types and is re-sent every second, so a host that attaches mid-run learns the channels from the device within a second; data frames carry only values. A decoder is about a hundred lines in any language, and the reference one, tools/scope_decode.py in the SDK, has no dependencies and prints a capture as CSV. The full byte-level spec with worked example frames is docs/scope-protocol.md.
Cross-architecture support
Plain C over the USB and BLE Core APIs; builds on every Core. Verified on the host against the reference decoder and compile-checked on all four Cores; hardware verification is pending.
See the implementation status for the full matrix.
API reference
void core_scope_init(void);void core_scope_set_link(const core_scope_link_t * link);int core_scope_add(const char * name, const volatile void * ptr, core_scope_type_t type);int core_scope_add_array(const char * name, const volatile void * ptr, core_scope_type_t type, uint8_t count);void core_scope_set_interval_ms(uint32_t ms);void core_scope_declare_rate_hz(uint32_t hz);void core_scope_declare_rate_millihertz(uint32_t millihertz);void core_scope_sample(void);void core_scope_pump(void);void core_scope_update(void);void core_scope_enable(int on);int core_scope_active(void);uint32_t core_scope_dropped(void);Generated from core_scope.h — tiles@83c9540.

