Architecture
This page explains how no-OS is put together: the layers a no-OS application is built from, how the hardware-abstraction API stays portable across every supported MCU, and where each piece lives in the source tree. Read it after the Getting Started tutorial, when you want to understand why the build you ran is structured the way it is — and before you write a driver (no-OS drivers guide) or port to a new platform.
The big picture
A no-OS application is a stack of four required layers (plus an optional frameworks layer). Each layer depends only on the one below it through a stable interface, which is what lets the same driver run unchanged on a Maxim MCU, an STM32, an FPGA soft-core, or even on Linux for testing.
┌────────────────────────────────────────────────────────────┐
│ Application (projects/<name>/src/main.c) │
│ wires a driver to a platform and runs the program │
├────────────────────────────────────────────────────────────┤
│ Frameworks (iio/, network/, jesd204/, ...) [optional] │
│ reusable subsystems built on the no_os_* API │
├────────────────────────────────────────────────────────────┤
│ Device drivers (drivers/<class>/<part>/) │
│ device logic, hardware-agnostic │
├────────────────────────────────────────────────────────────┤
│ no_os_* HAL API (include/no_os_*.h, drivers/api/) │
│ SPI / I2C / GPIO / UART / IRQ / timer / ... abstractions │
├────────────────────────────────────────────────────────────┤
│ Platform layer (drivers/platform/<mcu>/) │
│ vendor-SDK-backed implementation of the HAL API │
└────────────────────────────────────────────────────────────┘
The rule that makes this work: a driver never calls a vendor SDK directly.
It calls no_os_spi_write_and_read(), not MXC_SPI_MasterTransaction() or
HAL_SPI_Transmit(). Only the platform layer touches vendor code. Move a
driver to a new MCU and nothing in the driver changes — only the platform
implementation underneath it does.
The layers
Platform layer
Lives in drivers/platform/<mcu>/. There is one directory per supported
backend:
drivers/platform/
├── aducm3029/ maxim/ pico/ stm32/ xilinx/ ...
Each platform provides the vendor-SDK-backed implementation of the HAL: the code that actually configures a peripheral and moves bytes.
HAL API layer
The public API is declared in include/no_os_*.h — one header per peripheral
class or utility:
Header |
Abstraction |
|---|---|
|
SPI bus master transfers |
|
I2C / I3C transfers |
|
GPIO configuration and level control |
|
UART / serial I/O |
|
Interrupt controller and handler registration |
|
Timers, PWM outputs, real-time clock |
|
DMA, on-chip flash, EEPROM |
|
Delays, allocation, helpers (bit ops, endianness, ...) |
The generic, platform-independent half of the API lives in drivers/api/
(no_os_spi.c, no_os_gpio.c, ...). These files contain no vendor code;
they validate arguments, take bus locks, and forward the call to the platform
implementation (see How dispatch works: the platform_ops pattern below).
Device drivers
Lives in drivers/<class>/<part>/, grouped by device class — adc,
dac, accel, imu, temperature, power, rf-transceiver,
and so on. A driver contains the logic for one ADI part (register maps,
conversion sequences, calibration) and reaches hardware exclusively through the
no_os_* API. See no-OS drivers guide for the structures and functions a
driver must define.
Frameworks (optional)
Higher-level subsystems built on top of the HAL that many projects reuse:
iio/— the Industrial I/O server (IIOD), device/attribute/buffer model, and triggers. This is what the Getting Started IIO Demo exposes.network/— TCP/IP and network transports.jesd204/— the JESD204 link-layer state machine for high-speed converters.
A project uses these only if it needs them; a minimal driver-plus-main.c
application does not.
Application (projects)
Lives in projects/<name>/. A project is the top of the stack: it selects a
driver, a platform, and any frameworks, supplies the initialization data that
binds them together, and provides main(). Its layout follows a consistent
pattern:
projects/<name>/
├── src/
│ ├── common/ application code + init data (platform-independent)
│ └── platform/ per-platform macros and pin/peripheral configuration
├── *.conf defconfig files — one per build variant
├── Kconfig project configuration options
└── CMakeLists.txt build entry point
The src/common vs src/platform split mirrors the whole architecture in
miniature: portable logic on one side, per-target configuration on the other.
How dispatch works: the platform_ops pattern
Portability hinges on one mechanism repeated across every peripheral class: a
function-pointer table (*_platform_ops) that a project selects at
configuration time and the HAL calls through at run time. It is C's version of
a virtual interface. SPI is the canonical example.
1. Each platform defines an ops table pointing at its own functions
/* drivers/platform/maxim/max32660/maxim_spi.c */
const struct no_os_spi_platform_ops max_spi_ops = {
.init = &max_spi_init,
.write_and_read = &max_spi_write_and_read,
.transfer = &max_spi_transfer,
.transfer_dma = &max_spi_transfer_dma,
.remove = &max_spi_remove,
};
The STM32 platform defines stm32_spi_ops, ADuCM defines its own, and so on
— each with the same members pointing at that platform's implementation.
2. The project points its init_param at the platform's ops
The application fills in a no_os_spi_init_param and sets its
platform_ops field to the table for the target it is building for. This is
the single line that binds a portable driver to a concrete MCU:
struct no_os_spi_init_param spi_ip = {
.device_id = 1,
.max_speed_hz = 1000000,
.platform_ops = &max_spi_ops, /* the target-specific table */
/* ... */
};
3. The driver calls the generic API — never the platform directly
/* inside a device driver — identical on every platform */
ret = no_os_spi_write_and_read(dev->spi_desc, buf, len);
4. The generic API forwards through the ops table
no_os_spi_init() copies platform_ops from the init_param into the
descriptor, and every subsequent call forwards through that pointer:
/* drivers/api/no_os_spi.c (simplified) */
int32_t no_os_spi_init(struct no_os_spi_desc **desc,
const struct no_os_spi_init_param *param)
{
if (!param || !param->platform_ops)
return -EINVAL;
if (!param->platform_ops->init)
return -ENOSYS;
ret = param->platform_ops->init(desc, param); /* -> max_spi_init */
if (ret)
return ret;
(*desc)->platform_ops = param->platform_ops; /* remember it */
return 0;
}
int32_t no_os_spi_write_and_read(struct no_os_spi_desc *desc, ...)
{
if (!desc->platform_ops->write_and_read)
return -ENOSYS;
no_os_mutex_lock(desc->bus->mutex);
ret = desc->platform_ops->write_and_read(desc, data, bytes_number);
no_os_mutex_unlock(desc->bus->mutex);
return ret;
}
The payoff: the driver's no_os_spi_write_and_read() call lands in
max_spi_write_and_read() on a Maxim build and in stm32_spi_write_and_read()
on an STM32 build — with no change to the driver. Swapping platforms means
swapping one platform_ops pointer at configuration time. The same pattern
governs no_os_gpio_platform_ops, no_os_uart_platform_ops,
no_os_i2c_platform_ops, and the rest.
The init_param / descriptor idiom
Every abstraction — and every driver — follows the same two-structure convention:
init_param (
*_init_param) — an input, filled in by the caller, describing what to create: bus IDs, speeds, pin numbers, device settings, and theplatform_opspointer.descriptor (
*_desc) — an opaque output, allocated by the_init()function, representing a live instance. The caller passes it to every subsequent call and finally to_remove().
struct no_os_spi_desc *spi;
ret = no_os_spi_init(&spi, &spi_ip); /* allocates + configures */
/* ... use spi ... */
no_os_spi_remove(spi); /* frees everything it owns */
The same shape scales up: a device driver's init_param embeds the bus and GPIO
init params it needs — for example adxl355_init_param carries a
no_os_spi_init_param (inside a comm_init union that also allows I2C) —
and its adxl355_dev descriptor holds the resulting communication descriptor
returned by the lower layer. Initialization composes downward; _remove()
tears down in reverse. Because init allocates on the heap and remove frees it, mind how often
you call them in a long-running application (see the notes on heap fragmentation
in no-OS drivers guide).
Configuration: Kconfig and defconfigs
Which drivers, platforms, and framework features a build includes is chosen
through a Kconfig system, the same mechanism the Linux kernel uses. Options
are declared in Kconfig files (at the repo root, under drivers/, and in
each project) and selected values are captured in per-variant defconfig
files — the .conf files you saw in the project directory during the
Getting Started tutorial. Each build variant is essentially a named
defconfig, which is why iio, iio_usb_uart, and iio_wifi are separate
variants of the same project: they enable different options.
Source tree map
no-OS/
├── include/ no_os_*.h — the public HAL API
├── drivers/
│ ├── api/ generic, platform-independent HAL implementation
│ ├── platform/ per-MCU HAL implementations (the only vendor code)
│ └── <class>/<part>/ device drivers, grouped by device class
├── iio/ IIO framework (IIOD server, buffers, triggers)
├── network/ networking stack and transports
├── jesd204/ JESD204 link layer
├── projects/ example applications (project + variants + boards)
├── util/ shared utilities (buffers, CRC, lists, ...)
├── tools/scripts/ build utility (no_os_build.py) and helpers
└── doc/ this documentation
Where to go next
Getting Started — build and run an application if you have not yet.
no-OS drivers guide — the structures, functions, and conventions for writing a driver on top of the HAL.
No-OS Build Guide — per-platform toolchain setup and build mechanics.
No-OS Doxygen Documentation — the exact signature and fields of every
no_os_*function and structure.