C++23 Embedded Template Library for AVR 8-bit microcontrollers.
ETL is a header only template library geared towards the size and performance constraints of embedded applications. Its main objective is to take advantage of the efficiency of generic programming and to produce the fastest possible binaries.
ETL provides compile-time abstractions whose strongly typed definitions can leverage high-level information and relations about the mcu's peripherals.
For example, type information about the PORT to which belongs a given PIN makes compile-time optimization of PORT manipulation possible.
template <typename PinRed, typename PinGreen, typename PinBlue>
class RGBLed {
enum class bitColor : uint8_t { Red=1<<0, Green=1<<1, Blue=1<<2 };
public:
void SetColor(uint8_t color) const {
if (color & bitColor::Red) pinRed::Set(); else pinRed::Clear();
if (color & bitColor::Green) pinGreen::Set(); else pinGreen::Clear();
if (color & bitColor::Blue) pinBlue::Set(); else pinBlue::Clear();
}
};If we create an RGBLed object connected to pins D7, B0 and B1 and call SetColor :
RGBLed<PinD7, PinB0, PinB1> myLed;
myLed.SetColor(0b101);the compiler will produce the following assembly code :
5f 9a sbi 0x0b, 7 ; 2 cycles
28 98 cbi 0x05, 0 ; 2 cycles
29 9a sbi 0x05, 1 ; 2 cyclesAnd if another RGBLed is connected to pins B2, B3 and B4 :
RGBLed<PinB2, PinB3, PinB4> myLed2;
myLed2.SetColor(0b101);almost the same output will be produced :
sbi 0x05, 2 ; 2 cycles
cbi 0x05, 3 ; 2 cycles
sbi 0x05, 4 ; 2 cyclesFor this second RGBLed, a good hand-optimization would have used a global PORTB manipulation considering the fact that all pins belong to the same PORTB.
This optimization can easily be performed programmatically by testing at compile-time if the pins belong to a same port, using std::is_same_v.
template <typename PinRed, typename PinGreen, typename PinBlue>
class RGBLed {
public:
void SetColor(uint8_t color) const {
if (std::is_same_v<pinRed::Port, pinGreen::Port> &&
std::is_same_v<pinRed::Port, pinBlue::Port>) {
pinRed::Port::ChangeBits(pinRed::bitmask() | pinGreen::bitmask() | pinBlue::bitmask(),
(color & bitColor::Red ? pinRed::bitmask() : 0) +
(color & bitColor::Green ? pinGreen::bitmask() : 0) +
(color & bitColor::Blue ? pinBlue::bitmask() : 0));
}
else {
if (color & bitColor::Red) pinRed::Set(); else pinRed::Clear();
if (color & bitColor::Green) pinGreen::Set(); else pinGreen::Clear();
if (color & bitColor::Blue) pinBlue::Set(); else pinBlue::Clear();
}
}
};which now produces this code when all the pins belong to the same PORT, providing a +50% acceleration :
85 b1 in r24, 0x05 ; 1 cycle
8c 77 andi r24, 0x7c ; 1 cycle
86 60 ori r24, 0x06 ; 1 cycle
85 b9 out 0x05, r24 ; 1 cycleETL now targets a single C++23 baseline across the repository:
- public headers require a C++23-capable compiler
- host tests build with GCC 13+ and are validated in CI with GCC 13, 14, 15, 16 and Clang 18
- AVR smoke builds target avr-gcc 14.2+ and are validated in CI with GCC 14.2 (modern-avr), 15.2 and 16.1 (ZakKemble)
- the AVR smoke targets build without libstdc++, relying only on avr-libc and the bundled
libstd; the 15.2 and 16.1 CI jobs check this, as their toolchains ship no libstdc++ - the default build system baseline is CMake 3.25+
The bundled libstd directory remains a focused compatibility layer for the subset of standard-library facilities ETL uses on embedded targets; it is not a full reimplementation of the entire C++23 standard library. In particular, <string> now means a small owning string subset with string_view interop, append/resize/reserve support, and explicit non-goals around full hosted std::string parity.
Within libstd, ETL now distinguishes between supported, experimental, and placeholder headers. The detailed support matrix lives in libstd/readme.md; in short, low-level headers ETL already depends on are maintained, vector/random/thread are experimental, and string/tuple are placeholders not yet fit for use.
The repository is header-only, but it ships a host-side Catch test runner for validating the mock architecture and bundled libstd implementation.
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failureUseful test runner commands:
./build/TestsETL --list-tests
./build/TestsETL '[GPIO]'
./build/TestsETL '[libstd]'
./build/TestsETL '[thread]'
./build/TestsETL 'Scenario: std::tuple'The primary workflow is now an open, cross-platform AVR toolchain rather than Atmel Studio / Microchip Studio.
Recommended tools:
- host GCC 13+ (or Clang 18+) for local host builds and tests
- CMake 3.25+
avr-g++14.2+ for AVR validation, for example:- ZakKemble/avr-gcc-build (GCC 15.2 or 16.1, no libstdc++)
- modern-avr/toolchain (GCC 14.2, ships libstdc++)
- Microchip AVR 8-bit Toolchain 4.x (GCC 15.1)
avrdudefor flashing
This repository includes an AVR smoke target that validates the headers with a real avr-g++ build:
cmake -S . -B build-avr \
-DCMAKE_TOOLCHAIN_FILE=cmake/avr-gcc-toolchain.cmake \
-DETL_BUILD_HOST_TESTS=OFF \
-DETL_BUILD_AVR_SMOKE=ON \
-DETL_AVR_MCU=atmega328p \
-DETL_AVR_F_CPU=16000000UL
cmake --build build-avrThe repository CI builds that path with three pinned toolchains (archives verified by SHA-256): modern-avr/toolchain v14.2.0 and ZakKemble avr-gcc 15.2.0 and 16.1.0. Each job reports the avr-size of the smoke binaries in its summary.
That produces:
build-avr/etl_avr_smoke.elfbuild-avr/etl_avr_smoke.hexbuild-avr/etl_avr_libstd_smoke.elfbuild-avr/etl_avr_libstd_smoke.hexbuild-avr/etl_avr_user_isr_smoke.elf/.hex(user-defined USART ISR next to ETL)build-avr/etl_avr_uart0_smoke.elf/.hex(Uart0with the ETL interrupt handlers)build-avr/etl_avr_multi_tu_smoke.elf/.hex(ETL included from two translation units)build-avr/etl_avr_custom_new_smoke.elf/.hex(user replacement ofoperator new/operator delete)build-avr/etl_avr_interrupt_smoke.elf/.hex(handlers attached withetl::Interrupt)build-avr/etl_avr_isr_equivalence_etl.elf/etl_avr_isr_equivalence_libc.elf(same handlers withetl::Interruptand avr-libcISR(), compared by CI)
You can override ETL_AVR_MCU to validate another supported MCU such as attiny84.
AVR smoke builds also build the two exported runtime profile targets, ETL::StdMinimal
and ETL::StdFull. Package version 0.2.0 follows the packaging milestone (#129).
During 0.x, CMake compatibility is limited to the same minor release (SameMinorVersion):
a request for 0.2 does not accept a future 0.3 or 0.9 package. To build and install the package
independently of the examples:
cmake -S . -B build-std \
-DCMAKE_TOOLCHAIN_FILE=cmake/avr-gcc-toolchain.cmake \
-DETL_AVR_MCU=atmega328p -DETL_BUILD_HOST_TESTS=OFF -DETL_BUILD_STD=ON
cmake --build build-std
cmake --install build-std --prefix /tmp/etlstd --component ETLStd
ctest --test-dir build-std -R etlstd-profile --output-on-failureConsumers configure with the AVR toolchain, ETL_AVR_MCU, and CMAKE_PREFIX_PATH=/tmp/etlstd,
then select a profile through its target:
find_package(ETLStd 0.2 CONFIG REQUIRED)
target_link_libraries(application PRIVATE ETL::StdMinimal) # or ETL::StdFullFor build-tree consumption, set ETLStd_DIR to build-std instead. Installed headers live
under include/ETLStd/c++/v1 and platform headers under include/ETLStd/vendor; runtime archives
live under lib/ETLStd/<GCC-multilib>. A package can serve another MCU with the same GCC multilib.
Each install prefix describes one multilib only: ETLStdTargets.cmake and the configured
multilib are replaced on installation. Build and install a different multilib into a distinct
prefix, then point consumers at that prefix. Installing it over an existing prefix changes its
package selection, even if older archive files remain. The install prefix can be moved after installation.
Both targets select C++23, freestanding compilation and the canonical headers, and exclude the
compiler's C++ headers and libstdc++. Minimal disables exceptions and RTTI; full enables their
compiler options, applied only to C++ sources in mixed C/C++ targets.
StdFull selects the compiler/profile configuration; it does not supply an exception or RTTI
runtime. Code using throw, exception handling or runtime type information must supply the
required ABI runtime separately; otherwise it can fail at link time. The target names follow the
package contract in #129/#130. These profiles do not expand the component support described in
the libstd support matrix.
The generated internal configuration rejects incompatible flags; linking translation units from
both profiles fails on __etl_profile_mismatch_guard, even with --gc-sections.
Profile references and marker variables are retained by the package's linker script in the
non-allocated .etl.profile INFO section. They add ELF metadata only: no constructor entries,
startup calls, flash or SRAM bytes, and no Intel HEX payload. The existing host etl target remains
independent, and host builds do not enable ETL_BUILD_STD by default.
Profile smoke binaries are etl_avr_std_minimal_smoke.{elf,hex,map} and
etl_avr_std_full_smoke.{elf,hex,map}. Build a single profile with
cmake --build build-avr --target etl_avr_std_minimal_smoke_check (or full).
The minimal smoke checks non-allocating standard headers independently of <etl.h>; the full
smoke includes <etl.h> for the current device-specific allocator and exercises std::unique_ptr.
A separate object-only target, etl_avr_std_full_options_smoke, compiles try/catch, throw and
dynamic_cast with ETL::StdFull. It is a dependency of the full check target on every MCU and
toolchain. Its objects are never linked into the executable, so this checks compiler selection
without requiring an exception/RTTI ABI runtime.
The check targets reject missing artifacts and maps containing libstdc++, GLIBCXX, or the
opposite profile archive. Both avr-smoke and avr-profiles use the pinned toolchains from
tools/avr_toolchains.json. CI builds all 26 combinations on each of modern-avr GCC 14.2,
ZakKemble GCC 15.2 and 16.1 (78 combinations in total), retaining all three artifacts for each.
Profile jobs share the toolchain cache and run at most six concurrently. Both AVR jobs install
that toolchain through the shared setup action.
Uploaded profile artifacts are retained for 14 days.
Package/profile checks cover build-tree and relocated install-tree
consumption with mixed C/C++ sources, flash/SRAM equivalence to unprofiled binaries,
multilib/archive diagnostics, 0.x version compatibility, subdirectory integration without
activating CTest, incompatible flags and mixed-profile link failures. In CI, etlstd-profile
runs only in avr-smoke: ATmega328P and ATtiny84 on all three toolchains. The remaining eleven
devices receive compile/link/artifact checks in avr-profiles, including the object-only full
options probe; package installation/relocation tests are not repeated for each device.
Matrix checks validate documentation and modeled devices and emit
the matrix consumed by CI.
The CI profile matrix is generated from tools/avr_matrix.json. Both profiles compile and link
for every device below; python3 tools/check_avr_matrix.py checks this table against the matrix
and ETL's device dispatcher. These are compile/link checks, without simulator execution.
ETL_BUILD_AVR_SMOKE=ON accepts only the 13 modeled devices in this table; selecting an unlisted
MCU (for example atmega2560) now fails at configuration time, even with an explicit clock.
The matrix's default F_CPU is recomputed on every configure and is not cached. An explicit
-DETL_AVR_F_CPU=... remains a persistent user override.
Migration notes for existing build and install directories:
- Clock cache: master already cached
ETL_AVR_F_CPU=16000000UL. Existing build directories from master or earlier revisions of this branch therefore retain that value as an override, including after changing MCU. Its origin cannot be distinguished from an intentional user setting. Runcmake -S . -B <build> -UETL_AVR_F_CPUto resume MCU-derived defaults, or pass-DETL_AVR_F_CPU=...to select the intended clock explicitly. - Supported devices: smoke configurations now reject MCUs outside the 13 modeled devices, including with an explicit clock. This is a configuration-time compatibility break.
- Install prefixes: use a separate ETLStd prefix for each GCC multilib; installing a second multilib into the same prefix replaces its exported package selection.
Artifact checks (--artifacts DIR [--profile minimal|full]) inspect outputs without reading the
manifest, README or device dispatcher. Matrix/documentation drift is checked separately by CI
and CTest. Artifact checking and matrix-output options are mutually exclusive.
| Device | F_CPU |
|---|---|
atmega32u4 |
8000000UL |
atmega48a |
8000000UL |
atmega48pa |
8000000UL |
atmega88a |
8000000UL |
atmega88pa |
8000000UL |
atmega168a |
8000000UL |
atmega168p |
8000000UL |
atmega168pa |
8000000UL |
atmega328 |
8000000UL |
atmega328p |
16000000UL |
atmega644a |
8000000UL |
atmega644p |
8000000UL |
attiny84 |
8000000UL |
-
Configure your project to compile with C++23.
-
Add the include path
.../ETL/include. -
On AVR, add the bundled standard library as a system include directory, so that it is searched before the compiler's own C++ directories:
-isystem .../ETL/libstd/include/c++/v1. ETL headers include standard headers by their standard names (<memory>,<array>, ...). On a host build, do not add it: ETL then uses the compiler's standard library. -
Add
#include <etl.h>in your source file.
Interrupt handlers are attached to the vectors that the device headers declare on each peripheral, without macros:
static volatile uint8_t ticks;
static void onOverflow() { ticks = ticks + 1; }
[[gnu::naked]] void onCompareMatchB() { // saves what it uses and ends with reti
asm volatile("sbi %0, 0\n\treti" :: "I"(_SFR_IO_ADDR(GPIOR0)));
}
int main() {
etl::Interrupt<etl::Timer0::Overflow>::attach<onOverflow>();
etl::Interrupt<etl::Timer0::CompareMatchA>::attach<[] { /* ... */ }, etl::IsrKind::NonBlocking>();
etl::Interrupt<etl::Timer0::CompareMatchB>::attach<onCompareMatchB, etl::IsrKind::Naked>();
etl::Interrupt<etl::PinChangeIRQ0::Trigger>::attach<[] { /* ... */ }>();
// ... enable the interrupt sources, then sei()
}attach()binds the handler at link time and generates no code: the vector is installed as soon as the program contains the call. It does not enable the interrupt source.IsrKind::Blocking(default) andIsrKind::NonBlockingmatch avr-libcISR()andISR_NOBLOCK. A lambda, or astaticorinlinefunction, is inlined into the interrupt code, which is then identical to anISR()with the same body (checked by CI). A function with external linkage is called, as from anISR().IsrKind::Nakedreaches a[[gnu::naked]]function through one jump from the vector.- Attaching the same handler from several source files is fine. Attaching another handler, or defining an avr-libc
ISR(), for the same vector is a build error (symbol '__vector_16' is already definedormultiple definition of '__vector_16'). - Vector names:
TimerN::Overflow,TimerN::CompareMatchA/B/C/D,TimerN::Capture,PinChangeIRQN::Trigger. Any other vector can be named withetl::InterruptVector<USART_RX_vect_num>. - On the host,
Interrupt<V>::raise()runs the attached handler, so that handlers can be unit-tested.
The hardware UART drivers (Uart0, and Uart1 on the ATmega644) use the same mechanism: start() attaches their handlers, so a program that does not start a UART keeps the USART vectors free for its own handlers.
ETL is header-only and can be included from any number of translation units.
etl.h provides the global operator new / operator delete (including the sized and array forms) on top of etl::FreeStore. They are weak definitions: to use your own allocator, define these functions in a source file that does not include etl.h, and they take precedence. The free store is set up on its first use, so a program that never allocates pays nothing for it.
If your project uses the freestore helpers, ETL provides:
etl::FreeStore::GetMemorySize()
etl::FreeStore::GetMemoryFragmentation()
etl::FreeStore::GetFreeMemory()
An optional freestore trace log can be activated by defining ETL_FREESTORE_LOG_DEPTH before including etl.h:
#define ETL_FREESTORE_LOG_DEPTH 64
#include <etl.h>
This exposes:
uint8_t etl::FreeStore::FreeStoreDebugTrace::log_counter;
Operation etl::FreeStore::FreeStoreDebugTrace::log_operation[];
void* etl::FreeStore::FreeStoreDebugTrace::log_address[];
Once you have built a .hex file, a typical flashing command looks like:
avrdude -c <programmer> -p <mcu> -U flash:w:build-avr/etl_avr_smoke.hex:i