Skip to content

JeeH

JeeH is a hardware abstraction layer and multitasker library for STM’s 32-bit ARM Cortex microcontrollers.

This documentation is work in progress, just like everything else in JeeH ...

Introduction

Our life is frittered away by detail. Simplify, simplify. – Henry David Thoreau

JeeH is my attempt to reduce the amount of code needed for practical applications on low-end µCs. JeeH is implemented in C++17, because it's mature (but also under-appreciated, and often mis-used ... IMNSHO).

Programmers tend to keep adding more and more code to a project. To implement new features in the best scenario, but also to work around bugs and deal with unexpected special cases. Well ... I like removing stuff.

PlatformIO

PlatformIO ("PIO") is a software development environment. It's open source, it's implemented and extensible in Python, it supports a wide range of microcontrollers and runtimes, and it takes care of all toolchain- and library installations. PlatformIO works equally well in Visual Studio Code and from the command line.

JeeH uses PIO for development, debugging, and deployment. The latest released version is here:

PlatformIO Registry

Although JeeH is just C++ source code, it relies on PIO's Python scripting its code generation to deal with the pesky hardware differences across chip architectures and variants. All code examples, build configurations, and test environments in JeeH are built around the functionality provided by PIO.

PlatformIO has an extensive documentation site and discussion forum.

Getting started

  1. Pick your code editor. Anything goes. Get familiar with it.

  2. If you chose VSCode: go here, else go here to install PIO (if you haven't done so already).

  3. Place the following two text files in an empty folder to create a small example:

    This C++ code blinks an LED connected to pin PC13:

    #include <jee.h>
    using namespace jeeh;
    
    int main () {
        Pin led ("C13","P");
        while (true) {
            led.toggle();
            for (volatile auto i = 0; i < 10'000; ++i) {}
        }
    }
    

    It toggles the pin and then uses a busy loop to consume CPU cycles (just to keep the example minimal).

    This project file tells PIO how to build the firmware:

    [platformio]
    src_dir = .
    
    [env:myapp]
    platform = ststm32
    framework = cmsis
    board = bluepill_f103c8
    lib_deps = jcw/JeeH@^7.0.5
    
    The above settings are for an STM32F103 "Blue Pill" board.

  4. To build this example from the command line, type: pio run. Sample output:

    Processing myapp (platform: ststm32; framework: cmsis; board: bluepill_f103c8)
    --------------------------------------------------------------------------------
    Library Manager: Installing jcw/JeeH @ ^7.0.5
    [... lots more ...]
    Checking size .pio/build/myapp/firmware.elf
    Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
    RAM:   [          ]   2.0% (used 400 bytes from 20480 bytes)
    Flash: [          ]   4.3% (used 2824 bytes from 65536 bytes)
    Building .pio/build/myapp/firmware.bin
    ========================= [SUCCESS] Took 0.50 seconds =========================
    

  5. That's it. Uploading and debugging is beyond the scope of the current article.

Code generation + details

If only everything were always so simple ...

JeeH ties directly into a microcontroller's hardware peripherals and these can vary considerably across different µC families and models. Even just the location (i.e. memory address) of a peripheral will be different for each µC type. Each µC's "Reference Manual" provides this information in great detail.

Most information is also present in ARM's "CMSIS System View Description" files included in PIO (not always complete or accurate, but that's another story). JeeH uses the board name given in the platformio.ini file to find a matching *.svd file and extract details from its XML content.

A few header files in JeeH contain //CG text markers, which are used to insert µC hardware specifics: a custom-built code generator alters these header file as part of JeeH's build process, by inserting additional C++ code.

Here is a minimal example of a code generator insertion:

//CG svd ioregs
//CG[ svd ioregs
...
constexpr IoReg<0x4001'1000> GPIOC;
...
//CG]

A new constant GPIOC of type IoReg<T> has been added, with the address of the hardware registers associated with the GPIO "C" peripheral, as needed for the example above to deal with pin PC13.

The templated IoReg<T> type is defined elsewhere in JeeH and parsed before the generated definition.

Only files with the .hpp extension can be modified by the code generator. All changes are idem-potent: running the code generator again simply updates whatever was inserted before (the original //CG ... line is still there to indicate what needs to be inserted). This is important when building for a different µC type: the header is always adjusted to match what is currently being built.

The code generator lets JeeH support most "STM32" µC families and variants. As long as a matching SVD file is found, its settings will be available for the application as well as for drivers implemented in JeeH itself.

Build configuration

Code generation can also be used to configure hardware drivers and other aspects of an application. When there is more than one build environment in a project INI file, the settings can affect how each of them is built. The variations could be for different target µCs, different board versions, or just different application features.

Here is a PIO project file which defines two build targets:

[env]
platform = ststm32
framework = cmsis
lib_deps = jcw/JeeH@^7.0.5

[env:blue]
board = bluepill_f103c8
build_leds = C13

[env:nucleo]
board = nucleo_f103rb
board_leds = A5

These boards are similar, but the µC's have different memory sizes ("8" vs "b") and the on-board LED is connected to different pins.

To use this information in application code and trigger the code generator, create a defs.hpp file with this content:

//CG board leds
//CG1 board leds
#define LED  "A5"

Then in the application code itself, add #include "defs.hpp" and define the LED pin as follows:

    Pin led (LED,"P");

The led definition will now always match the board it is used for.

There are many more ways in which settings in the INI file and JeeH's code generator work together. All build_* settings in the INI file can be used for code generation. See the Code generator details page for more information.

General-purpose I/O

Nature is pleased with simplicity. And nature is no dummy. ― Isaac Newton

The most basic interface to the outside world is controlling the package pins of a µC, or "GPIO pins". On STM32, pins come in groups of 16, tied to ports named "A", "B", "C", etc. STM32's pins are named "PA9", "PC13", etc.

Each GPIO port is a simple hardware peripheral. It's controlled by manipulating bits in a port-specific address range. All built-in peripherals work this same way: a range of "registers" which software can read and write.

Hardware registers

The code generator parses a µC-specific SVD file to figure out the base address associated with each peripheral. It then saves this in header files included from jee.h, JeeH's top-level header file. GPIO is just one example: there are many more peripherals, each with their own specific registers and conventions.

For GPIO port "C", there will be a definition for GPIOC:

constexpr IoReg<0x4001'1000> GPIOC;

These IoReg<N> definitions support an array-like access style:

  • x = GPIOC[0x0C]; read the 32-bit register at GPIO "C", offset 0x0C
  • GPIOC[0x0C] = x; store x as 32-bit value at GPIO "C", offset 0x0C
  • x = GPIOC[0x0C](13); read bit 13 of the register at GPIO "C", offset 0x0C
  • GPIOC[0x0C](13) = x; set bit 13 of the register at GPIO "C", offset 0x0C to x

If there's an LED attached to pin PC13, then GPIOC[0x0C](13) = 1; will turn it on and GPIOC[0x0C](13) = 0; will turn it off.

Tip

To clarify this code, it helps to define enum { ODR=0x0C }; and rewrite the above as x = GPIOC[ODR];, etc ("ODR" is the Output Data Register on STM32F1 µCs).

Bit ranges

There's also a concise notation to get or set a range of bits in a register:

  • x = GPIOC[0x0C](3,2); reads bits 3 and 4 of the register at offset 0x0C
  • GPIOC[0x0C](3,2) = x; sets bits 3 and 4 of the register at offset 0x0C to x

The notation used above is: ( starting-bit, number-of-bits ).

Accessing something like GPIOC[0x0C](30,4) is meaningless, as there are only 32 bits in a word (0..31): bits 32 and 33 don't exist. And as you may have guessed, GPIOC[0x0C](13) is shorthand for GPIOC[0x0C](13,1).

It's all C++ trickery

These notations don't look like "normal" C++, because they really aren't: the notation GPIO[0x0C] returns a special C++ object with various operator overloads to implement the actual word, bit, and bit-range functionality.

The statement uint32_t x = GPIOA[0x0C]; first creates that special object, representing the proper address (i.e. GPIOA base + 0x0C) and then a cast operator defined for that special object performs the actual register access and returns the result.

In the case of GPIOC[0x0C](13), the special object has an overloaded call operator which creates yet another special C++ object, representing not just the address, but also the bit ot bit range inside it.

Caveats

All this C++ trickery works like a charm and greatly simplifies the notation to access registers as well as individual bits inside those registers. Due to the way this is implemented and the capabilities of modern C++ compilers, most of the generated code ends up being generated inline: the overhead is truly minimal, it's all dealt with at compile time. No special objects are likely to be created on the stack (and then destroyed after use).

But there are two gotcha's to be aware of:

Caveat #1

The code auto x = GPIO[0x0C]; will NOT read the current value of the specified hardware register. Instead, it will store a copy of that special object in x. Such that later on, uint32_t x2 = x; will read that value. In other words, this x is not an integer value: it's a special object representing a potential register read or write.

Likewise for the GPIO[0x0C](13) and GPIO[0x0C](2,3) notations. With auto, there is no register access. These too merely store a copy of some sort of special object.

Caveat #2

The second pitfall is somewhat related: printf("%d", GPIO[0x0C]); will NOT print the value of the specified hardware register, because the compiler is not told to cast the special object to an integer. The printf call is defined as a varargs function and therefore its arguments are not typed.

The solution in both cases is to force a cast by prefixing the expression with +, i.e. +GPIO[0x0C] instead of GPIO[0x0C]. Now the compiler is forced to return the value read from this register.

Pin configuration

The simplest way to use GPIO pins is as Pin objects:

Pin backlight ("B10");
backlight.mode("P");
backlight = 1;

In prose:

  1. define a backlight object, associated with pin PB10 (the "P" is never included in JeeH)
  2. set the pin mode to a push-pull output
  3. set the pin output level to high, i.e. +3.3V

Pin names are specified as <letter A-Z> + <number 0-15>. Pin modes are somewhat more complicated:

  • the main mode is one of P, O, F, or A (push-pull, open-drain, floating, or analog)
  • drive strength (optional): L, N, H, or V (low, normal, high, or very high) - the default is N
  • an optional weak pull-up or pull-down: U = pull-up, D = pull-down
  • to switch the pin to a specific alternate mode: 0 .. 15
  • to set to pin output to high right away, add a + flag

These can be combined in any order, but the alternate mode must come last: VP+, O4, FD, HOU12, etc.

Info

Pin names and pin modes are interpreted at compile time when possible: there is no parsing overhead at runtime. These strings do not end up in the generated code: more efficient single-byte constants will get compiled-in instead.

Bulk configuration

Pins can also be configured in bulk. This is convenient when many pins share the same configuration:

Pin::config("A1:HP,A2,A3,B10:O4,B11");
Pins A1, A2, and A3 are set to high-speed push-pull, while B10 and B11 are set to open-drain, alternate mode 4.

You can also configure and set up pin objects at the same time:

Pin pins [5];
Pin::config("A1:HP,A2,A3,B10:O4,B11", pins, sizeof pins);
Now pins[2] can be used set set A3 high or low, for example.

UART, I2C, and SPI

Life is really simple, but we insist on making it complicated. – Confucius

The three most common serial communication interface protocols are: UART for asynchronous serial I/O, I2C as 2-wire low-speed bus for multiple external peripherals, and SPI as high-speed point-to-point 4-wire bus for nearby high-speed connections to memory, displays, and such.

Although somewhat similar, they differ in fundamental ways:

  • UART communication is asynchronous: input data can arrive at any time
  • I2C is a bus, with possibly more than one device connected to it
  • SPI is usually point-to-point, but with one extra pin per device it can also handle more devices

JeeH has drivers for each of these, but only as bus "master" for I2C and SPI (UART is a symmetric protocol). There is currently no driver to run as a slave device on either I2C or SPI.

There are several implementation variants for each protocol: fully implemented in software ("bit-banged"), by constant polling of the hardware registers ("polled"), blocking with transfers via DMA ("synchronous"), and non-blocking with transfers via DMA ("triggered"). Each variant has its trade-offs, with bit-banged being the most flexible but slower, and triggered being the most efficient but more complex to use.

Devices

Since I2C and sometimes also SPI are bus-oriented, with support for more than one peripheral, there needs to be a way to distinguish which one to talk to. This is done with Dev objects:

i2c::Poll<...> i2cBus;
Dev sensor1 (i2cBus, 0x40);
Dev sensor2 (i2cBus, 0x60);

Now sensor1.read(...) will read from I2C address 0x40 and sensor2.write(...) will write to I2C address 0x60.

Bit-banged I/O

This mode is supported for I2C and SPI, there is no bit-banged version for UART. This variant is the most flexible because it can be used on any GPIO pins. It's very easy to set up, here's an example for I2C on pins A1 and A2:

  • add build_i2c = P:A1,A2 to use A1 and A2 for the SDA and SCL signals, respectively
  • in the defs.hpp header, include a line with //CG board i2c
  • in that same header or in the main app, add i2c::Gpio<I2C_CONF> i2cBus;
  • in the initialisation code, add i2cBus.init() to set up the bus

That's it. The rest of the code, i.e. setting up devices and using them, is the same as with all other modes.

Polled hardware I/O

Polled mode is faster because the work is done by a hardware peripheral, i.e. in silicon, which can reach much higher speeds than toggling pins in software. It's also more limited because there needs to be a suitable peripheral and it can only be used on specific pins in alternate-mode.

Polled mode is supported for all three communcation types: UART, I2C, and SPI.

Here is an example of INI settings for UART:

board_uart = N:USART1 P:A9:U1,A10 F:72
This is an example for I2C:
board_i2c = N:I2C1 P:B7:OH1,B6 F:36
And this example is for SPI:
board_spi = N:SPI2 P:B15:H5,B14,B13,B12 F:36

UART and I2C use 2 pins, SPI uses 4. The pin choices have to match what the peripherals support (I2C1 and SPI2, in this case). And the proper alternate-mode must be specified to tie these pins to their corresponding peripheral.

Lastly, the system bus freqency in MHz has be specified, so that JeeH can figure out what clock dividers to use for specific communication speeds. This value is usually either the system clock, or half the system clock, depending on the AHB / APB bus connection to the internal peripheral.

Setting up the device object is similar to the bit banged case, but with different names, i.e. Poll iso Gpio:

uart::Poll<UART_CONF> serial;
i2c::Poll<I2C_CONF> i2cBus;
spi::Poll<SPI_CONF> spiDev;
The rest of the setup and use for these devices is the same as for bit-banged mode.

Sync: blocking DMA

Synchronous mode is as easy to use as polled mode, but much more efficient: it uses a built-in DMA channel to perform the transfers, with the CPU no longer looping to transfer each individual byte, but simply idling for DMA to complete. It's still blocking: the I/O request, and therefore the caller, will block until finished.

Use of this mode is slightly more complicated, as you have to figure out from the Reference Manual which DMA channel can be used and what all its configuration parameters are. On low-end µCs, there may not be enough DMA channels, or they may not support the combination of devices you need them for.

As in polled mode, only specific pins can be used for synchronous use.

Here are example INI file settings for UART, I2C, and SPI, respectively:

board_uart = N:USART2 P:A2:U7,A3         F:170 D:1 L:CH+ T:1 R:2 C:27,26
board_i2c  = N:I2C1   P:B7:OH4,A15       F:170 D:1 L:CH+ T:3 R:4 C:17,16
board_spi  = N:SPI1   P:B5:5,B4,B3,A11:P F:170 D:1 L:CH+ T:5 R:6 C:11,10
This is for the STMG431 at 170 MHz, which has a flexible "DMA multiplexer" supporting more DMA channel combinations than the older chip families.

Setting up device objects is similar to the polled case, but again with different names, i.e. Sync iso Poll:

uart::Sync<UART_CONF> serial;
i2c::Sync<I2C_CONF> i2cBus;
spi::Sync<SPI_CONF> spiDev;
The rest of the setup and use for these devices is the same as for bit-banged mode.

It looks simple, and in fact it is when compared to other frameworks, but the devil is in the detail for this mode: any mistake in settings leads to a non-functional driver setup. There are some subtle interactions between the hardware peripheral and the DMA engine to make this work.

But the end result is usually worth it: sync mode is often by far the fastest transfer mode, and it's also the lowest-power option because the CPU itself enters sleep mode until the requested DMA transer is complete.

Note

JeeH does not implement another common driver mode: interrupt-based transfers. There really is little point when DMA transfers are available, as IRQ-based transfers tend to add a lot of overhead for all the extra interrupts being serviced.

Trig: event-based DMA

The final mode is "triggered". This is similar to synchronous mode, in that all transfers are handled through DMA. The difference is that the driver does not wait and sleep until the transfer is finished: it returns immediately and generates a " completion event" once the transfer is done. Triggered mode is non-blocking, i.e. asynchronous.

The settings in the INI file are exactly the same as for synchronous mode:

board_uart = N:USART2 P:A2:U7,A3         F:170 D:1 L:CH+ T:1 R:2 C:27,26
board_i2c  = N:I2C1   P:B7:OH4,A15       F:170 D:1 L:CH+ T:3 R:4 C:17,16
board_spi  = N:SPI1   P:B5:5,B4,B3,A11:P F:170 D:1 L:CH+ T:5 R:6 C:11,10

Setting up device objects is similar to the synchronous case, but with different names, i.e. Trig iso Sync:

uart::Trig<UART_CONF> serial;
UART_TRIGGER(serial)

i2c::Trig<I2C_CONF> i2cBus;
I2C_TRIGGER(i2cBus)

spi::Trig<SPI_CONF> spiDev;
SPI_TRIGGER(spiDev)
The one difference is that the interrupt handlers now also need to be defined. This is done with the TRIGGER_* macros shown above. Note that there is no trailing semicolon (these macros define top-level C functions and the compiler doesn't like to see semicolons at the end of those).

The rest of the setup for these devices is the same as for synchronous mode, but their use is very different since they rely on JeeH's tasks and events.

Tasks and events

Simplicity is a great virtue but it requires hard work to achieve it and education to appreciate it. And to make matters worse: complexity sells better. – Edsger Wybe Dijkstra

A Real-Time Operating System (RTOS) adds concurrent operation: the ability to progress with different activities as needed. While waiting for a slow process to complete, the CPU can switch to another one and do some work there. This can be useful when dealing with various communication channels: read out sensors while reporting the results over some communication link(s). When the CPU has nothing to do but wait for completion of transfers, it can switch to a lower-power sleep mode. This approach is based on context-switching, with a stack area associated with each task: a switch saves the current CPU state (i.e. registers) on the current stack, switches to another stack, restores that CPU state, and resumes.

A drawback of such an approach is that each task needs space reserved for its stack and sized to match the maximum amount of space needed at any point in time. If a task calls functions which briefly need a lot of stack space, then the stack needs to be sized accordingly for the entire lifetime of that task.

Multi-tasking on a µC with limited RAM space can severly limit the number of tasks you can use, somewhat defeating the whole point of multi-tasking.

Stack nesting

JeeH implements a simpler form of multi-tasking: each task has a fixed priority and only higher-priority tasks can interrupt lower-priority ones. Furthermore, when a task starts (or is resumed), it must do its work and then return to the caller or interrupted lower-priority task.

Tasks are "nested" instead of "switched". A single stack is used, increasing and decreasing in size as needed. Inactive tasks do not consume any stack space. All the unused stack space is wvailable to any task.

Interrupts and events

When the triggered version of a driver initiates a transfer of some kind, it also sets up the interrupt handler to be called when the transfer completes.

These interrupt handlers will be called while the CPU is back to doing something else (or sleeping), possibly at a very inconvenient moment. Interrupt handlers should do as little as possible to limit the interruption:

  1. clear the interrupt in the peripheral ("ok, I've heard you")
  2. optionally: initiate a new transfer, if one has been postponed
  3. send a completion event to the initiating task

Note

Interrupt handlers will usually avoid altering any of their task's data structures, since they might get triggered while that task is also active. Implementing interrupt-driven device drivers is an art ... there are many ways to "get it right", but bugs in this type of code tend to be fiendishly hard to find and fix.

Completion events are efficient 32-bit "signals" managed by JeeH to start up the corresponding task. If that task has a higher priority than the current one, it will immediately be started, otherwise the event is placed in that task's internal queue and picked up as soon as the task priority drops far enough.

Events consist of the task id they are intended for, a tag which indicates what it's for (e.g. "the send IRQ just happened"), and a 16-bit value for a minimal amount of event-related detail.

Events act as the software equivalent of hardware interrupts: they happen at various time, and tell various parts of an app what just happend or what is being requested. They are queued until the time is right to process them.

In JeeH, events travel in two directions:

  • send: a lower-priority task requesting some action from a higher priority one
    • example: requesting a delayed resume from the Ticker task
  • reply: an interrupt handler or a higher-priority task reporting a result
    • example: resuming the originating task when the delay request has expired

Since send events are always "upwards" to a higher priority task, then are by definition synchronous: the caller will be suspended and the callee task will start running right away. It is up to the callee to either handle the request or save it somewhere for later.

Reply events are always "downwards": there is no other way to deal with them than to store them in a queue area managed by JeeH, and activate the corresponding task when the current priority level allows it (perhaps right away, but that's not always possible).

Atomicity and latency

This event + task priority design has several consequences:

  • tasks can be held up while interrupts are processed
  • tasks cannot interrupt themselves, nor be aborted

In terms of race conditions and guards, this means that all data managed within a task is atomic: there is no interference from the rest of the application. This changes of course once a reference this data gets passed around.

Internally, all the tricky atomicity guards w.r.t. task switches and event queues are handled using ARM's lock-free machine-code instructions. There is no IRQ-disabling code anywhere in JeeH except for Cortex M0+ CPUs, which don't have the necessary lock-free instructions. The only case where hardware interrupts can be delayed (i.e. have some latency) is when another interrupt handler is currently active. As with any other RTOS, careful configuration of hardware IRQ priority levels can deal with really critical timing cases.

Run to completion

Compared to other RTOS designs, JeeH's distinguishing feature is that it is based on tasks "running to completion": each task needs to do its work and then return to whatever called it. Tasks are allowed to block as they wish, but the consequence will be that any lower-priority task will be blocked as well.

This design is less flexible than traditional "multi-stack" multi-tasking, but applications can be set up with more task-based modularity, because stack memory use is no longer such a constraining issue on small microcontrollers.

Low-power sleep

A consequence of run-to-completion tasks, is that applications can automatically drop back to their top-level WFI loop in main().

Depending on which hardware peripherals needs to remain active (e.g. a UART expecting incoming data), the optimal low-power level can be selected and all unnecessary peripheral hardware can be turned off to achieve minimal power consumption. If an application only does periodic work, switching to standby mode with just the independent watchdog or RTC running can reduce power consumption to less than 1 µA.

This has not yet been explored in the current implementation of JeeH.

Implementation details

It is not a daily increase, but a daily decrease. Hack away at the inessentials. – Bruce Lee

The rest of this document is about some development aspects of JeeH itself.

Directory structure

The JeeH code is located in the following directories:

├── include
│   ├── arch
│   └── jee
│       ├── dev
│       └── util
├── make
└── src
  • include/arch/ contains the architecture specific code, as well as the sys.h and task.h header with moch of JeeH's core functionality
  • include/jee/ has all the core driver implementations
  • include/jee/dev/ has a few drivers for devices connected via I2C or SPI
  • include/jee/util/ has header files which didn't fit anywhere else
  • make/ contains files needed for the build process, e.g. the code generator
  • src/ has the source files which implement all non-header core functions

Each application will normally include the following boilerplate at the start of its main entry point:

#include <jee.h>
#include <jee/hal.h>
#include <jee/dev/...>     // additional devices not included in hal.h
#include <jee/util/...>     // utility code, if needed ...
using namespace jeeh
#include "defs.hpp"

...

int main () {
    initBoard();
    ...
}

By convention, initBoard() will be defined in defs.hpp, where all necessary //CG code generator directives should also be placed.

Git repository

This command will check out the entire JeeH git repository:

git clone https://codeberg.org/jcw/jeeh.git

The repository has considerably more source code than the releases distributed via PIO, e.g. a boards/ area where various small apps are located for numerous µC variants. A command such as cd boards/g431k/ && pio run will compile a whole slew off small applications for the Nucleo-G431KC development board, for example.

Not all the code in git is still meaningful or even valid. Everyhing evolves ...

C++ templates

As of May 2026, the JeeH source code is still quite small: 35 source files with ≈ 5000 lines of code. One reason for this is the use of code generation for µC-specifics from SVD file, but another reason is the careful use of C++ templates.

When used without restraint, templates can generate huge swaths of code, as each type instantiation gets expanded and compiled into its own version.

But when used with care, templates can in fact lead to smaller code. One reason is that templates often reduce the need for virtual methods: why include a runtime dispatch mechanism, when the compiler has all the information it needs to generate code for one specific case?

Without virtual function calls, modern compilers have more static type information at their disposal and can optimise code further than dispatching through virtual method tables. Without virtual calls, more code can be inlined and this can drasatically improve the generated code. Not just in size, also in performance.

The trick is to find the right balance. There are some virtual methods in JeeH, but most of the driver code is implemented using templates. For example: when a UART driver is defined in JeeH, it is tied to the specific hardware. The driver for USART1 is different from the driver for USART2. Each of them is highly optimised, but yes: if you define multiple UARTs, then you'll get some extra code.

The same carries through to driver implementations built on top: an LCD display connected via SPI1 will be implemented with SPI1-specific code. The reasoning is that most applications will probably have just that one LCD attached anyway.

Templates lead to a level of flexibility which can only be achieved otherwise through the use of virtual methods: that LCD display can be tied to any of the four SPI driver variants in JeeH: bit-banged, polled, synchronous, or event-based. Without changing anything in the LCD code itself. Whichever choice is made, the compiler will be able to optimise everything for that particular combination.

Naming conventions

Namespaces reduce the need to prefix identifiers with additional text to specify which context is meant. Inside a namespace, a read() function name is fine - no need to call it i2c_read(), polled_i2c_read(), etc. It seems like a minor detail, but such differences do add up in terms of legibility. The same applies to namespace-like constructs, i.e. enums, structs, and classes: inside a class Gpio, for example, there is no need to call a method Gpio_init(), when init() can do the job.

Where possible, definitions in JeeH are placed inside the ::jeeh namespace. Or deeper, such as ::jeeh::i2c, ::jeeh::dev, etc. One exception is with external linkage to "C" functions, e.g. interrupt handlers.

The other exception is due to the proliferation of preprocessor macros: this "relic from the C era" has no lexical scope, so macros tend to be NAMED_WITH_SOME_LONG_PREFIX (and to add insult to injury: in upper case to distinguish macros from scoped identifiers).

JeeH minimises the use of macros, but they do help simplify conditional compliation with #ifdef, #if, and such.

As for upper / lower case and other identifier conventions, here's how JeeH uses them ... most of the time:

Context Examples Notes
local variable i, idx, lastTime short names, occasional interCaps
function parameter p, ptr, numBytes similar to local variables
global variable SytemCoreClock avoid (this one comes from the CMSIS runtime)
simple types using IoVec = ... start with uppercase, then interCaps
struct & class struct DateTime same as simple types
namespace namespace dev lower case, short if used often
enum enum TAG all upper case, as these are constants
constexpr constexpr auto SIZE = ... all upper case, as these are constants
function & method int onRead () lower case, interCaps if needed

Coding conventions

The JeeH source code follows a bunch of coding and style conventions.

  • A slightly controversial style choice perhaps, is that JeeH favours conciseness: short local variable names, no braces around single statements, and (shudder) no lengthy "code documentation" comment sections before each variable or function declaration. The semantics is in the source code itself, not any textual description(s) of it.
  • Having said that: clarifying comments are always added, as needed.
  • Declarations and definitions are clearly distinguished from uses and calls:
    • int abc (int xyz) ... is a declarion or definition of abc
    • v = abc(xyz) ... is a call of abc: no space between abc and (
    • int v [3] is a declartion of a vector v with 3 elements
    • x = v[3] is an index access of the 3rd item of v (again: no space after v)
  • The above distinctions are very useful for simple text searches.
  • Opening braces are placed at the end of the preceding line, not the start of the next one.
    (because more code fits on a single screen that way)
  • For the same reason, occasionally an entire if or while is placed on one line.
  • Switch statements are indented ad-hoc. There's too much variation to pick one style.
  • Indentation matters: it's based on 4 spaces per level.

Most of these conventions are applied most of the time. Nobody is perfect. Things change ...