This guide covers all C-Next language features in detail. For a quick introduction, see the README.
- Assignment vs Equality
- Fixed-Width Types
- Register Bindings
- Type-Aware Bit Indexing
- Slice Assignment
- Scopes
- Switch Statements
- Ternary Operator
- Bounded Strings
- Callbacks
- Atomic Variables
- Volatile Variables
- Critical Sections
- NULL for C Library Interop
- Startup Allocation
- Hardware Testing
Eliminates the if (x = 5) bug by design:
x <- 5; // assignment: value flows INTO x
if (x = 5) // comparison: single equals, just like math
u8, u16, u32, u64 // unsigned integers
i8, i16, i32, i64 // signed integers
f32, f64 // floating point
bool // boolean
Type-safe hardware access with access modifiers:
register GPIO7 @ 0x42004000 {
DR: u32 rw @ 0x00, // Read-Write
PSR: u32 ro @ 0x08, // Read-Only
DR_SET: u32 wo @ 0x84, // Write-Only (atomic set)
DR_CLEAR: u32 wo @ 0x88, // Write-Only (atomic clear)
DR_TOGGLE: u32 wo @ 0x8C, // Write-Only (atomic toggle)
}
Integers are indexable as bit arrays:
u8 flags <- 0;
flags[3] <- true; // Set bit 3
flags[0, 3] <- 5; // Set 3 bits starting at bit 0
bool isSet <- flags[3]; // Read bit 3
// .length property
u8[16] bufArray;
bufArray.length; // 16 (array element count)
flags.length; // 8 (bit width of u8)
Write-only registers generate optimized code:
GPIO7.DR_SET[LED_BIT] <- true; // Generates: GPIO7__DR_SET = (1 << LED_BIT);
Multi-byte serialization with compile-time validated, little-endian writes (Issue #234, #1081):
u8[256] bufArray;
u32 magic <- 0x12345678;
// Serialize 4 bytes of magic into bufArray at element 0
bufArray[0, 4] <- magic;
// Named offsets using const variables
const u32 HEADER_OFFSET <- 0;
const u32 DATA_OFFSET <- 8;
bufArray[HEADER_OFFSET, 4] <- magic;
bufArray[DATA_OFFSET, 8] <- timestamp;
Transpiles to explicit per-element little-endian writes, unrolled at compile time (bounds validated at compile time):
uint8_t bufArray[256] = {0};
uint32_t magic = 0x12345678U;
/* MISRA C:2012 Rule 21.15: slice copy unrolled to per-element writes (memcpy would pass incompatible pointer types: uint8_t* vs uint32_t*). */
const uint32_t _tmp0 = (uint32_t)(magic);
bufArray[0] = (uint8_t)(_tmp0);
bufArray[1] = (uint8_t)(_tmp0 >> 8U);
bufArray[2] = (uint8_t)(_tmp0 >> 16U);
bufArray[3] = (uint8_t)(_tmp0 >> 24U);The source is materialized into a single unsigned temporary before the writes,
so it is evaluated exactly once — even an impure source such as
bufArray[0, 4] <- readSensor() calls readSensor() a single time, not once per
byte. (A single-element slice, where the value is used only once, skips the
temporary.) Shifting the unsigned temporary also keeps every write MISRA C:2012
Rule 10.1-clean.
The Rule 21.15 comment is emitted only when an equivalent memcpy would
genuinely have passed incompatible pointer types: the source type is resolved
and differs from the destination element type (the comment names the two types),
and more than one element is written. A same-type slice such as u32[] <- u32
(where the pointer types would have matched), a single-element write, or a source
whose type cannot be resolved at compile time all omit the comment.
Key Features:
- Compile-time bounds checking prevents buffer overflows at compile time
- Offset and length must be compile-time constants (literals or
constvariables) - Silent runtime failures are now compile-time errors
- Works with struct fields:
bufArray[0, 4] <- config.magic - The source must be an integer value (float/struct sources are a compile error), and the slice length may not exceed the source's width in bytes
- Deterministic little-endian byte order on every target — the bytes are
written least-significant-first regardless of host endianness (Issue #1081).
Earlier versions emitted
memcpy, which copied in native byte order and (a) produced different output on big-endian targets and (b) violated MISRA C:2012 Rule 21.15 by passing incompatible pointer types - Offset and length use different units for wider arrays: the offset is an
element index (
u16[] arr; arr[2, 4] <- vstarts at element 2), while the length is always a byte count. Au16[]slice writes wholeuint16_telements, so the length must be a multiple of the element size. Bounds are checked as an element span (offset + length / elementSize <= capacity), so an in-bounds slice at a non-zero offset into au32[]/u64[]is accepted - Distinct from bit operations: array slices serialize a value into memory, scalar bit ranges use bit manipulation
Organize code with automatic name prefixing. Inside scopes, explicit qualification is available to avoid naming collisions:
this.Xfor scope-local membersglobal.Xfor global variables, functions, and registers
If the same name exists in local, scope, and global levels, the precedence is local, scope, global just like you are used to in other languages.
const u8 LED_BIT <- 3;
scope LED {
u8 brightness <- 100;
void on() { global.GPIO7.DR_SET[global.LED_BIT] <- true; }
void off() { global.GPIO7.DR_CLEAR[global.LED_BIT] <- true; }
u8 getBrightness() { return this.brightness; }
}
// Call as:
LED.on();
LED.off();
Transpiles to:
const uint8_t LED_BIT = 3;
static uint8_t LED__brightness = 100;
void LED__on(void) { GPIO7__DR_SET = (1 << LED_BIT); }
void LED__off(void) { GPIO7__DR_CLEAR = (1 << LED_BIT); }
uint8_t LED__getBrightness(void) { return LED__brightness; }Safe switch with MISRA compliance:
- Braces replace break (no colons needed)
- No fallthrough allowed
- Multiple cases with
||syntax - Counted
default(n)for enum exhaustiveness
enum EState { IDLE, RUNNING, STOPPED }
void handleState(EState state) {
switch (state) {
case EState.IDLE {
startMotor();
}
case EState.RUNNING || EState.STOPPED {
checkSensors();
}
}
}
Transpiles to:
switch (state) {
case EState__IDLE: {
startMotor();
break;
}
case EState__RUNNING:
case EState__STOPPED: {
checkSensors();
break;
}
}Safe conditional expressions with MISRA compliance:
- Parentheses required around condition
- Condition must be boolean (comparison or logical)
- No nesting allowed (use if/else instead)
u32 max <- (a > b) ? a : b;
u32 abs <- (x < 0) ? -x : x;
u32 result <- (a > 0 && b > 0) ? a : b;
// ERROR: Condition must be boolean
// u32 bad <- (x) ? 1 : 0;
// ERROR: Nested ternary not allowed
// i32 sign <- (x > 0) ? 1 : (x < 0) ? -1 : 0;
Safe, statically-allocated strings with compile-time capacity checking:
string<64> name <- "Hello"; // 64-char capacity, transpiles to char[65]
string<128> message; // Empty string, initialized to ""
const string VERSION <- "1.0.0"; // Auto-sized to string<5>
// Properties
u32 len <- name.length; // Runtime: strlen(name)
u32 cap <- name.capacity; // Compile-time: 64
// Comparison - uses strcmp
if (name = "Hello") { } // strcmp(name, "Hello") == 0
// Concatenation with capacity validation
string<32> first <- "Hello";
string<32> second <- " World";
string<64> result <- first + second; // OK: 64 >= 32 + 32
// Substring extraction with bounds checking
string<5> greeting <- name[0, 5]; // First 5 chars
All operations are validated at compile time:
- Literal overflow → compile error
- Truncation on assignment → compile error
- Concatenation capacity mismatch → compile error
- Substring out of bounds → compile error
Type-safe function pointers with the Function-as-Type pattern:
- A function definition creates both a callable function AND a type
- Nominal typing: type identity is the function name, not just signature
- Never null: callbacks are always initialized to their default function
// Define callback type with default behavior
void onReceive(const CAN_Message_T msg) {
// default: no-op
}
struct Controller {
onReceive _handler; // Type is onReceive, initialized to default
}
// User implementation must match signature
void myHandler(const CAN_Message_T msg) {
Serial.println(msg.id);
}
controller._handler <- myHandler; // OK: signature matches
controller._handler(msg); // Always safe - never null
Transpiles to:
void onReceive(const CAN_Message_T msg) { }
typedef void (*onReceive_fp)(const CAN_Message_T);
struct Controller {
onReceive_fp _handler;
};
// Initialization always sets to default
struct Controller Controller_init(void) {
return (struct Controller){ ._handler = onReceive };
}ISR-safe variables with hardware-assisted atomicity:
#pragma target teensy41
atomic u32 counter <- 0; // ISR-safe with LDREX/STREX
atomic clamp u8 brightness <- 100; // Combines atomic + clamp
void increment() {
counter +<- 1; // Lock-free atomic increment
}
Generates optimized code based on target platform:
- Cortex-M3/M4/M7: LDREX/STREX retry loops (lock-free)
- Cortex-M0/M0+: PRIMASK disable/restore (interrupt masking)
Target detection priority: --target CLI flag > platformio.ini > #pragma target > default
Prevent compiler optimization for variables that change outside normal program flow:
// Delay loop - prevent optimization
void delay_ms(const u32 ms) {
volatile u32 i <- 0;
volatile u32 count <- ms * 2000;
while (i < count) {
i +<- 1; // Compiler cannot optimize away
}
}
// Hardware register - reads actual memory
volatile u32 status_register @ 0x40020000;
void waitReady() {
while (status_register & 0x01 = 0) {
// Always reads from hardware
}
}
When to use:
- ✅ Delay loops that must not be optimized away
- ✅ Memory-mapped hardware registers
- ✅ Variables polled in tight loops
- ❌ ISR-shared variables (use
atomicinstead for RMW safety)
Key difference from atomic:
volatile= prevents optimization onlyatomic= prevents optimization + adds synchronization (ISR-safe)
Multi-statement atomic blocks with automatic interrupt masking:
u8[64] buffer;
u32 writeIdx <- 0;
void enqueue(u8 data) {
critical {
buffer[writeIdx] <- data;
writeIdx +<- 1;
}
}
Transpiles to PRIMASK save/restore:
void enqueue(uint8_t data) {
{
uint32_t __primask = __get_PRIMASK();
__disable_irq();
buffer[writeIdx] = data;
writeIdx += 1;
__set_PRIMASK(__primask);
}
}Safety: return inside critical { } is a compile error (E0853).
Safe interop with C stream functions that can return NULL:
#include <stdio.h>
string<64> buffer;
void readInput() {
// NULL check is REQUIRED - compiler enforces it
if (fgets(buffer, buffer.size, stdin) != NULL) {
printf("Got: %s", buffer);
}
}
Constraints:
- NULL only valid in comparison context (
!= NULLor= NULL) - Only whitelisted stream functions:
fgets,fputs,fgetc,fputc - Cannot store C pointer returns in variables
fopen,malloc, etc. are errors (see ADR-103 for future FILE* support)
Allocate at startup, run with fixed memory. Per MISRA C:2023 Dir 4.12: all memory is allocated during initialization, then forbidden. No runtime allocation means no fragmentation, no OOM, no leaks.
Verified on Teensy MicroMod, Teensy 4.0, and STM32 hardware:
# Build and flash with PlatformIO
cd test-teensy
pio run -t uploadSee examples/blink.cnx for the complete LED blink example.