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
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
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;
@synchronizedas a RAII spinlock;+initializecalled beforemain()viaSYS_INIT. __blockvariables are promoted to file scope (blocks themselves are shown above).- Fast enumeration —
for (id obj in collection); boxed and collection literals —@42,@[a, b],@{k: v}, and subscripting. - Lightweight generics for typed collections.
- Foundation —
OZObject,OZString,OZMutableString,OZArray,OZDictionary,OZQ31fixed 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.
OZQ31converts 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, -O2 | 60,761 | 43,645 | −28% |
Flash, -O2 | 50,900 | 35,040 | −31% |
RAM, -Os | 15,738 | 7,524 | −52% |
| Static / direct call | 12 | 12 | same |
| Virtual / vtable dispatch | 14 | 21 | +50% |
| Per-object allocation cost | 24 B | 16 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
| Operation | C++ | Objective-Z | Notes |
|---|---|---|---|
| Static / direct call | 12 | 12 | Both resolve at compile time |
| Virtual / vtable dispatch | 14 | 21 | OZ: const array; C++: vptr indirection |
| Slab alloc + init + release | 105 | 215 | C++ figure is placement-new from a slab |
| Atomic inc (retain) | 7 | 22 | Both inline atomics |
| retain + release pair | 17 | 44 | |
| Property get (nonatomic) | 12 | 12 | |
Property get (atomic, k_spinlock) | 12 | 10 | Same Zephyr primitive on both sides |
@synchronized (k_spinlock) | 15 | 266 | OZ: RAII OZSpinLock alloc + free |
| Block / lambda (non-capturing) | 12 | 12 | Both compile to function pointers |
std::function (int capture) | 16 | — | No Objective-Z equivalent |
Raw int32_t[] sum (10 elems) | 81 | 99 | Both raw C arrays, no boxing |
| String array loop + length (10) | 263 | 483 | Fair: both call a method per element |
| String iterator (virtual) | 211 | 341 | Fair: both virtual dispatch per step |
dynamic_cast / isKindOfClass | 12 | — | OZ introspection is a C API |
Memory per object
| Metric | C | C++ | Objective-Z |
|---|---|---|---|
Base object sizeof | 8 | 8 | 8 |
| Allocator overhead per object | 4–8 | 4–8 | 0 |
shared_ptr control block | — | 12 | 0 |
| GrandChild, allocated | 24 | 24 | 16 |
| 20 × GrandChild | 480 | 480 | 320 |
| String object | — | 12 | 20 |
Firmware footprint
| Benchmark | Metric | C++ | Objective-Z | Diff |
|---|---|---|---|---|
Speed (-O2) | text | 50,588 | 34,272 | −32% |
| data | 312 | 768 | +146% | |
| bss | 9,861 | 8,605 | −13% | |
| total | 60,761 | 43,645 | −28% | |
| Flash | 50,900 | 35,040 | −31% | |
| RAM | 10,173 | 9,373 | −8% | |
Memory (-Os) | text | 22,840 | 21,344 | −7% |
| data | 180 | 180 | 0% | |
| bss | 15,558 | 7,344 | −53% | |
| total | 38,578 | 28,868 | −25% | |
| Flash | 23,020 | 21,524 | −6% | |
| RAM | 15,738 | 7,524 | −52% |
Per-section results — allocation, dispatch, lifecycle, refcounting, collections — are in the README benchmark section.
Where C++ still wins the honest list
@synchronizedcosts 266 cycles against 15, because the RAIIOZSpinLockis allocated and freed. Usek_spinlockdirectly 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
initplus 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_DEFINEandDEVICE_DT_DEFINErely 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.