Write Objective-C.
Ship plain C.

An Objective-C transpiler for Zephyr RTOS. Classes, protocols, properties, blocks and ARC — lowered to C your team can read, on the compiler you already use.

No runtime, no objc_msgSend, no second compiler, no FFI bindings — and the whole Zephyr macro and devicetree API stays directly callable, because the file is still C.

  • −28% firmware vs C++
  • 12 cycles per call
  • 0 bytes of heap
  • 0 bindings to maintain
  • v0.5.99 · pre-1.0

What it emits

One file, and the C it becomes

Scroll the Objective-C on the left — or hover any part of it. The generated C keeps pace on the right, highlighting the matching lines and saying what you gain. Every line of it is the transpiler's own output for this one file.

thermostat.m you write

/*
 * Demo source for objective-z.org. Everything under generated/ is
 * oz_transpile's own output for this file.
 */
#import <Foundation/Foundation.h>

#include <zephyr/kernel.h>
#include <zephyr/zbus/zbus.h>

@protocol Sensor
- (int)read;
@end

@interface Thermometer : OZObject <Sensor> {
        int _offset;
}
- (int)read;
@end

@implementation Thermometer

- (int)read
{
        return 21 + _offset;
}

@end

@interface Hygrometer : OZObject <Sensor> {
        int _humidity;
}
- (int)read;
@end

@implementation Hygrometer

- (int)read
{
        return _humidity;
}

@end

@interface Thermostat : OZObject {
        int _setpoint;
        BOOL _heating;
        Thermometer *_probe;
        OZArray *_bank;
        OZTimer *_poll;
}
@property (atomic) int setpoint;
@property (nonatomic, getter=isHeating) BOOL heating;

- (instancetype)initWithProbe:(Thermometer *)probe pollEvery:(uint32_t)periodMs;
- (int)worstReading;
- (int)spotCheck;
- (BOOL)shouldHeat;
@end

@implementation Thermostat

@synthesize setpoint = _setpoint;
@synthesize heating = _heating;

- (instancetype)initWithProbe:(Thermometer *)probe pollEvery:(uint32_t)periodMs
{
        _probe = probe;
        _bank = @[ probe, [[Hygrometer alloc] init] ];

        _poll = [[OZTimer alloc]
                initWithUserData:self
                expiry:^(struct k_timer *t) {
                        Thermostat *me = (__bridge Thermostat *)
                                k_timer_user_data_get(t);
                        [me setHeating:[me shouldHeat]];
                }
                stop:nil];
        [_poll startAfter:periodMs period:periodMs];
        return self;
}

- (int)worstReading
{
        int worst = 0;

        for (id sensor in _bank) {
                int value = [sensor read];

                if (value > worst) {
                        worst = value;
                }
        }

        return worst;
}

- (int)spotCheck
{
        Thermometer *spare = [[Thermometer alloc] init];
        int reading = [spare read];

        return reading;
}

- (BOOL)shouldHeat
{
        return [_probe read] < _setpoint;
}

@end

struct msg_setpoint {
        int celsius;
};

/* A Zephyr macro, used verbatim in an Objective-C file. No binding layer. */
ZBUS_CHAN_DEFINE(chan_setpoint, struct msg_setpoint, NULL, NULL,
                 ZBUS_OBSERVERS(lis_setpoint), ZBUS_MSG_INIT(0));

static Thermostat *unit;

/* A plain C callback that talks to the object. */
static void on_setpoint(const struct zbus_channel *chan)
{
        const struct msg_setpoint *msg = zbus_chan_const_msg(chan);

        [unit setSetpoint:msg->celsius];
}

ZBUS_LISTENER_DEFINE(lis_setpoint, on_setpoint);

int main(void)
{
        Thermometer *probe = [[Thermometer alloc] init];

        unit = [[Thermostat alloc] initWithProbe:probe pollEvery:1000];
        [unit setSetpoint:21];

        OZLog("worst=%d heating=%d", [unit worstReading], [unit isHeating]);
        return 0;
}
How the pipeline fits together 3 passes, one Python step
The Objective-Z build pipeline Objective-C .m sources become a Clang JSON AST, which oz_transpile processes in three passes — collect, resolve, emit — producing .h and .c files that GCC compiles into the final binary. .m sources Clang JSON AST oz_transpile .h + .c GCC binary collect resolve emit

Clang parses your .m into a JSON AST. oz_transpile collects classes and protocols, resolves the hierarchy and classifies every dispatch, then emits per-class .h/.c plus the dispatch tables. GCC takes it from there. Objective-C is never compiled — it is translated.

The rest of the supported language categories, blocks, literals, generics
  • Categories merged at AST collection time; @synchronized as a RAII spinlock; +initialize called before main() via SYS_INIT.
  • __block variables are promoted to file scope (blocks themselves are shown above).
  • Fast enumerationfor (id obj in collection); boxed and collection literals@42, @[a, b], @{k: v}, and subscripting.
  • Lightweight generics for typed collections.
  • FoundationOZObject, OZString, OZMutableString, OZArray, OZDictionary, OZQ31 fixed point, OZHeap, OZSpinLock, OZTimer, OZDefer, OZLog.
  • Removed on purpose — KVO, swizzling, dynamic class creation, associated objects, weak references, message forwarding. Each needs unbounded runtime allocation.

Full reference in the project README.

What it will not do read this before committing a project
  • Non-capturing blocks only — capturing a local is a transpile-time error.
  • No typedef, no @try/@catch/@throw, no __weak (it panics at runtime).
  • Single inheritance — an Objective-C constraint, not a transpiler one.
  • No dynamic dispatch for non-protocol methods; everything resolves statically.
  • OZQ31 converts to int8/16/32 and float — no int64 or double.

Complete list in docs/LIMITATIONS.md.

Results

Smaller firmware than C++, same dispatch cost

Same benchmark application built both ways on an nRF52833 DK (Cortex-M4F @ 64 MHz), DWT cycle counter, overhead-calibrated.

Build C++ Objective-Z Diff
Total firmware, -O260,76143,645−28%
Flash, -O250,90035,040−31%
RAM, -Os15,7387,524−52%
Static / direct call1212same
Virtual / vtable dispatch1421+50%
Per-object allocation cost24 B16 B−33%

Bytes for firmware and allocation rows, cycles for dispatch rows. C++ template and STL inlining is what inflates text; the RAM saving comes from slab pools in .bss replacing a sys_heap.

Full benchmark tables speed, memory, footprint at both -O2 and -Os

Speed

Cycle counts — lower is better. Both sides -O2.
OperationC++Objective-ZNotes
Static / direct call1212Both resolve at compile time
Virtual / vtable dispatch1421OZ: const array; C++: vptr indirection
Slab alloc + init + release105215C++ figure is placement-new from a slab
Atomic inc (retain)722Both inline atomics
retain + release pair1744
Property get (nonatomic)1212
Property get (atomic, k_spinlock)1210Same Zephyr primitive on both sides
@synchronized (k_spinlock)15266OZ: RAII OZSpinLock alloc + free
Block / lambda (non-capturing)1212Both compile to function pointers
std::function (int capture)16No Objective-Z equivalent
Raw int32_t[] sum (10 elems)8199Both raw C arrays, no boxing
String array loop + length (10)263483Fair: both call a method per element
String iterator (virtual)211341Fair: both virtual dispatch per step
dynamic_cast / isKindOfClass12OZ introspection is a C API

Memory per object

Bytes. C and C++ use a dedicated 8 KB sys_heap; Objective-Z uses per-class k_mem_slab pools.
MetricCC++Objective-Z
Base object sizeof888
Allocator overhead per object4–84–80
shared_ptr control block120
GrandChild, allocated242416
20 × GrandChild480480320
String object1220

Firmware footprint

Bytes. Negative diff favors Objective-Z.
BenchmarkMetricC++Objective-ZDiff
Speed (-O2)text50,58834,272−32%
data312768+146%
bss9,8618,605−13%
total60,76143,645−28%
Flash50,90035,040−31%
RAM10,1739,373−8%
Memory (-Os)text22,84021,344−7%
data1801800%
bss15,5587,344−53%
total38,57828,868−25%
Flash23,02021,524−6%
RAM15,7387,524−52%

Per-section results — allocation, dispatch, lifecycle, refcounting, collections — are in the README benchmark section.

Where C++ still wins the honest list
  • @synchronized costs 266 cycles against 15, because the RAII OZSpinLock is allocated and freed. Use k_spinlock directly on a hot path.
  • Reference counting is slower — 22 cycles to retain vs 7 for a raw atomic_fetch_add; 44 vs 17 for a pair.
  • Placement-new from a slab is 2× faster — 105 cycles vs 215, which covers init plus the ARC release.
  • Object-array iteration is ~1.8× slower — 483 cycles vs 263 over ten elements.
  • No counterpart for capturing std::function, dynamic_cast, or multiple inheritance.
Why not Rust, Zig, Nim, Swift, Ada, Lua or Go? eight evaluations

Each fails for its own reason — a separate compiler (Rust, Zig, Swift, Ada), broken macro compatibility (all of them, C++ and Nim included), an unfamiliar language model (Rust, Nim, Ada), a hobbled subset (Swift), an interpreter with heap allocation (Lua), a garbage collector (Go) — but they share a root cause: each asks the embedded C team to leave C.

  • Rust — separate compiler, two-ABI friction, FFI bindings that break on upstream updates, kernel work effectively off-limits.
  • C++K_THREAD_DEFINE and DEVICE_DT_DEFINE rely on C preprocessor behavior; g++ changes the language under the macros. And C++03 vs C++20/23 is effectively a different language.
  • Zig — the most sympathetic "better C", but a separate compiler and no OOP at all: no classes, no inheritance, no dispatch.
  • Nim — also transpiles to C, but is a wholly different language whose FFI cannot pass Zephyr's macros; GC'd reference types by default and Nim-idiomatic output.
  • Swift — separate ABI, experimental embedded subset that disables the features making Swift feel like Swift.
  • Ada / SPARK — deserves respect for formal verification, but has no official Zephyr support and an enormous adoption cost.
  • Lua — interpreted, so WCET analysis is out, and the VM heap-allocates tables and closures.
  • Go / TinyGo — the garbage collector is a one-line disqualifier.

Long-form reasoning for each is in the README.

Quick start

Four files and one CMake call

west.yml

- name: objective-z
  url: https://github.com/rodrigopex/objective-z/
  revision: main
  path: objective-z

CMakeLists.txt

objz_transpile_sources(app src/main.m)

prj.conf

CONFIG_OBJZ=y

build

west update
west build -p -b mps2/an385 .

Needs the Zephyr SDK and west, Clang 20+ for AST analysis, and Python 3. Twelve runnable samples ship in samples/ — from hello_world to zbus pub/sub and a request-response service. Targets ARM Cortex-M, Cortex-A and RISC-V 32/64.