ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
ra8-firmware style guide

Authoritative reference for source-code conventions in this repository. Code review and the pre-commit hook expect everything below; CLAUDE.md restates the most-violated rules but this file is the source of truth.

Table of contents

  1. Source layout
  2. File-header Doxygen block
  3. Function documentation
  4. Comment formatting
  5. Naming
  6. Types and constants (C23)
  7. Program entry points
  8. Header guards
  9. Hardware register access
  10. HUM citations
  11. SOLID principles for C
  12. NASA Power of 10
  13. Backward compatibility (there is none)
  14. Character encoding
  15. Ring and World tagging

Source layout

Every first-party C-family build unit under apps/, examples/, libs/, port/, tests/, and tools/ uses the same structure:

  • src/ owns implementation files and may contain module-private *_internal.h headers beside their implementation.
  • inc/ owns interfaces and fixtures shared across translation units.
  • A unit's root contains build metadata, configuration, documentation, linker scripts, and data assets - never C-family source or header files.
  • A nested tests/ suite is a build unit too, so it has its own src/ and inc/ rather than placing source files directly at the test root.

Do not add root-path fallbacks or compatibility aliases when moving a file; update every CMake target and path consumer in the same change. Vendored and generated sources are exempt. scripts/checks/check_source_layout.py enforces the rule over the complete first-party tree in the pre-commit-checks gate.

File-header Doxygen block

Every .c and .h opens with the same Doxygen comment block, in the order below. The order itself is a readability convention: no tool parses it, and nothing breaks if you swap two tags. (This paragraph used to say "order matters -- the cite_check / world_tag scripts grep on it". Neither ever has. cite_check.py greps HUM Ch, check_world_tags.py greps the [Ring N / X] / {World: X} pair, and neither has ever read @file, @brief or @details. What the tags themselves are held to is in the table below, one enforcer named per row.)

Each row names the check that actually fails a build when the rule is broken. A row whose enforcer is -- rests on review, and says so rather than implying a guarantee that does not exist. The counts are a dated measurement (2026-07-28, 2121 first-party C files), not a live invariant – they are here so the Required column can be audited rather than believed.

Tag Required Enforced by Notes
@file Yes doxy_audit.py --style Must be present and must name this file: the bare basename (1648 files), the repo-relative path (456), or no argument at all (17, doxygen then infers it). A @file left naming the old location after a git mv fails here rather than becoming a doxygen warning nobody reads.
@brief Yes doxy_audit.py --style One sentence, ends without a period.
@par Tag Yes for Ring 3+ check_world_tags.py See Ring and World tagging.
@details Yes doxy_audit.py --style, strict Multi-paragraph explanation. The original debt is closed; every missing paragraph fails and no baseline remains.
Named @par <Name> Optional Use for PRCR sequencing, IRQ wiring, state machines, anything subtle.
@author Optional Review convention, not a rule: 104 of 2121 first-party C files carry one, and @copyright below already names the author. Keep it where it exists; a new file does not need it. This row said "Yes" for the life of the tree while 95% of it disagreed and nothing checked.
@date Optional ISO 8601 (YYYY-MM-DD) where present; 103 files carry one.
@copyright + SPDX Yes check-copyright.py The exact @copyright Copyright (c) 2026 Brighton Sikarskie line is immediately followed by SPDX-License-Identifier: MIT inside the @file block. A standalone attribution comment above the block is rejected.
@since Yes on public declarations check-since-version.py Semantic version the file first appeared at. Presence is gated only for ra8_* declarations in libs/ra8_*/inc/; the value of every @since anywhere in the tree must equal the top-level VERSION.

Hash-comment code files put # SPDX-License-Identifier: MIT and the exact copyright line at the start of the file, immediately after a shebang when one exists. A security-pinned shell or isolated-Python entry then puts its exact # SHEBANG-SECURITY: ... rationale after the attribution pair. The canonical protected order is therefore shebang, SPDX, copyright, security rationale; check-copyright.py and check_shebangs.py enforce the same preamble.

Function documentation

Every function – public or static – gets a full Doxygen block. The required tags are:

ra8_board_eth_pin_t pin
Pin.
ra8_err_codes_t ra8_err_t
Canonical error-return type used by every ra8-firmware API.
Definition ra8_err.h:546
ra8_port_t
Port indices for the RA8D2 IOPORT module.
ra8_level_t
Digital output / input level.
ra8_err_t ra8_gpio_output_init(ra8_port_pin_t pin, ra8_level_t init_level)
Configure a pin as a digital output and drive it to an initial level.
Definition gpio.c:88

Gated by doxy_audit.py --check (the pre-commit-checks gate and the pre-commit hook), over every function – including statics – in libs/ and port/:

  • @brief, @details, @param for every parameter, @return
  • At least 2 @pre and 2 @post (NASA Power of 10 Rule 5)
  • @retval for every distinct return value, on any non-void function
  • @note mentioning thread safety
  • @since semantic version

@see is a review convention, not a gate. It is worth writing where a reader would genuinely want the pointer, and nothing checks it: measured 2026-07-28, 3014 of the 3162 documented function blocks in the tree have none, and a rule that demanded one everywhere would be closed with filler cross-references rather than useful ones. The same applies to @warning (3142 without) and @par MC/DC: (3099 without) – write them where they say something.

@param direction tags are mandatory: @param[in], @param[out], @param[in,out]. Plain @param without the direction is rejected by doxy_audit.py --style, which reads every Doxygen comment in first-party C rather than only function blocks. That scope is the point: of the 55 directionless tags the rule found when it was first enforced, none sat on a function declaration – 53 documented function-like macros and 2 documented a callback typedef, and the function gate looks at neither. Any other bracket text (@param[inout], @param[i]) is rejected too; a typo that silently means nothing to doxygen is not an improvement on a missing tag.

Comment formatting

Spacing inside single-line block comments is enforced by scripts/checks/check_comment_format.py, which runs as part of just quality::local::format (applies) and just quality::local::check (verifies), and so gates pre-commit and CI through scripts/checks/format_code.sh. The rules:

  • One space after the opener. /*text -> /* text; the Doxygen member form gets /**< text (never /**<text).
  • One space before the closer. text.*/ -> text. */ (never text.*/).
  • Aligned */. Across a run of consecutive trailing comments that clang-format put in the same start column, the closing */ are padded so they line up under the longest comment in the run (the longest gets one space):

    uint16_t clock_stop_time;
    uint8_t clock_beforehand_time;
    uint8_t clock_keep_time;
  • One block, one alignment. A run of trailing comments must align as a single unit: one /**< column and one */ column. clang-format aligns a run to its widest code plus one space, but when that column would push the longest comment past column 100 it abandons the run and starts a fresh group mid-block, leaving one struct with two of each:

    /* WRONG -- one struct, two alignment groups */
    uint32_t cur_cluster;
    uint32_t walk_cache_idx;
    uint32_t size_bytes;
    Cached parse of one mounted FAT volume.

    That is canonical clang-format output, so the formatter will not object; the pass reports it instead. It cannot repair it – shortening prose is the author's call – so the remedy is yours: tighten the long comment, or move it to its own `/ ... */` block above the line it documents**, which ends the run there and stops one over-long declaration dragging the whole block right. A run ends at a blank line, a code-only line, a standalone comment, or an inline mid-code comment.

Division of labour: clang-format owns the comment start column (AlignTrailingComments: true aligns each /**< to the widest code in the block + one space) and never touches the comment interior (ReflowComments: false); the pass owns the interior + the */ column. Because the two never overlap, they reach a stable result together (a comment the pass tightens can free column budget that lets clang-format re-align the start, so just quality::local::format repeats both to a fixed point).

Left untouched: multi-line /** ... */ blocks, // line comments, decorative banners (/**** ... ****/), and inline mid-code comments (f(/*tag=*/x)) – clang-format owns the spacing around those.

Naming

Identifier kind Convention Example
Functions snake_case ra8_gpio_output_init
Public types snake_case_t ra8_port_pin_t
Private types snake_case_t ra8_drv_state_t
Macros / #define SCREAMING_SNAKE RA8_RETURN_ON_ERROR
Enum values k_<scope>_<name> k_ra8_ok, k_ra8_pin_led1
File-local (static) functions internal_<verb> internal_validate_freq
Cross-TU module-private functions priv_<verb> priv_unlock_pwpr
File-scope static data / constants s_<name> s_tag
Global variables (avoid) g_<name> g_ra8_vector_table_start
Linker symbols g_ra8_ls_<name> g_ra8_ls_stack_top

The g_ra8_ls_ prefix on linker symbols is mandatory: it keeps them out of the leading-underscore reserved namespace that ISO C and cert-dcl37-c reject.

The three private prefixes describe linkage; they are not interchangeable abbreviations. A file-local helper is declared RA8_INTERNAL static and uses internal_. A helper intentionally shared by multiple translation units in one libs/<module> or tools/<tool> is non-static, uses RA8_PRIV and the priv_ prefix, and is declared in that module's *_internal.h. The s_ prefix is data-only: it is never a function name and never names automatic or externally-linked data. Public functions use their published API name and no private linkage annotation; RA8_TEST_HELPER is the explicit test-only external-linkage exception. scripts/checks/check_annotations.py enforces these pairings against the AST rather than inferring linkage from spelling.

Types and constants (C23)

The project targets C23 with GNU extensions (-std=gnu23). Use the modern features:

// Always: typed enums with explicit underlying type.
typedef enum : uint8_t {
k_ra8_state_idle = 0,
k_ra8_state_busy = 1,
k_ra8_state_error = 2,
} ra8_state_t;
// Always: static_assert (C23 keyword) for compile-time invariants.
static_assert(sizeof(ra8_state_t) == 1, "tightly-packed enum");
// Always: RA8_ASSERT(cond, msg) from ra8_check.h for runtime programmer invariants.
// Never: assert() from <assert.h> in target firmware (pulls libc stdio/__assert_func).
// Always: zero-init with empty braces.
ra8_drv_state_t s = {};
// Never: stdbool.h. `bool`, `true`, `false` are C23 keywords.
// Never: _Static_assert. Use static_assert.
// Never: = {0} zero-init. Use = {}.
// Never: untyped enums. Always pick the underlying type.

Underlying-type choice:

  • uint8_t – values 0..255 (states, indices, small constants)
  • uint16_t – 256..65535 (timeouts in ms, medium constants)
  • uint32_t – > 65535 (large constants, bit masks)
  • uintptr_t – hardware register base addresses (mandatory)
  • int8_t/int16_t/int32_t/int64_t – signed values

uintptr_t for register bases is non-negotiable: on the 32-bit RA8D2 target it's uint32_t, on the 64-bit x86_64 unit-test host it's uint64_t. Using uint32_t for an address silently truncates on the test host and produces wrong pointer casts.

Standard C ABI and compiler runtime types

Standard C ABI primitives (memset, memcpy, memmove, memcmp, memchr, strlen, strnlen, strcmp, strncmp, strchr, strrchr, strstr, strcpy, strncpy, abs) are compiler-recognized library functions whose prototypes are fixed by ISO C and the target ABI:

void* memset(void* dst, int value, size_t n);
void* memcpy(void* restrict dst, const void* restrict src, size_t n);
void* memmove(void* dst, const void* src, size_t n);
int memcmp(const void* a, const void* b, size_t n);
void* memchr(const void* s, int c, size_t n);
size_t strlen(const char* s);
size_t strnlen(const char* s, size_t maxlen);
int strcmp(const char* s1, const char* s2);
int strncmp(const char* s1, const char* s2, size_t n);
char* strchr(const char* s, int c);
char* strrchr(const char* s, int c);
char* strstr(const char* haystack, const char* needle);
char* strcpy(char* restrict dst, const char* restrict src);
char* strncpy(char* restrict dst, const char* restrict src, size_t n);
int abs(int j);
int strncmp(const char *s1, const char *s2, size_t n)
Compare two strings up to a specified length.
void * memchr(const void *s, int c, size_t n)
Locate a byte in a memory area.
int strcmp(const char *s1, const char *s2)
Compare two null-terminated strings.
void * memset(void *dst, int value, size_t n)
Fill memory with a constant byte value.
char * strcpy(char *dst, const char *src)
Copy string to destination buffer.
int memcmp(const void *a, const void *b, size_t n)
Compare bytes in two memory areas.
void * memmove(void *dst, const void *src, size_t n)
Copy memory area between potentially overlapping regions.
char * strncpy(char *dst, const char *src, size_t n)
Copy bounded string to destination buffer.
size_t strlen(const char *s)
Calculate string length.
void * memcpy(void *dst, const void *src, size_t n)
Copy memory area between non-overlapping regions.
char * strstr(const char *haystack, const char *needle)
Locate substring in string.
char * strchr(const char *s, int c)
Locate first occurrence of character in string.
int abs(int j)
Compute absolute value of integer.
char * strrchr(const char *s, int c)
Locate last occurrence of character in string.
size_t strnlen(const char *s, size_t maxlen)
Calculate bounded string length.

These functions MUST preserve their standard-mandated types (int, size_t, const void*, char*) so compiler builtins, loop-idiom recognizers, and cross-module code generation match the target ABI. Using fixed-width nicknames like int32_t in place of standard int is forbidden for standard ABI functions. The project fixed-width integer rule applies to all project-defined types, interfaces, structures, and values.

Magic numbers are forbidden – every literal becomes a typed enum:

// CORRECT
typedef enum : uint8_t {
k_idx_high_byte = 0,
k_mask_byte = 0xFF,
} byte_layout_t;
buf[k_idx_high_byte] = (uint8_t)((val >> k_shift_byte) & k_mask_byte);
// WRONG
buf[0] = (uint8_t)((val >> 8) & 0xFF); // What is 0? 8? 0xFF?
@ k_shift_byte
Shift byte.
@ k_mask_byte
Mask byte.

Program entry points

main is spelled differently in the two build domains, because the two domains genuinely are different, and scripts/checks/check_entry_points.py holds each to its own contract.

Domain Where Signature
Hosted tests/, tools/ int main(void) or int main(int argc, char** argv)
Freestanding examples/, port/ void main(void)

Hosted code returns int because ISO C says so. These programs run under an operating system that reads the exit status, and both host compilers enforce the signature themselves.

Firmware returns nothing because there is nothing to return to. A bare-metal image is reached from Reset_Handler, not from a C runtime; there is no process and no exit status, and every call site discards the value. ISO C fixes main at int only for a hosted implementation – for a freestanding one (C23 5.1.2.1) the startup function's name and type are implementation-defined, and void main(void) is this project's definition.

A firmware entry point therefore:

  • returns void, and contains no return <value>; – there is nowhere to return a value to. A bare return; for an early exit is fine.
  • includes "ra8_boot_entry.h", which holds the one declaration of main. This is not decoration: GCC exempts main from -Wmissing-prototypes, so without the include nothing ever compares the definition against the declaration.
#include "ra8_boot_entry.h"
void main(void)
{
if (bringup() != k_ra8_ok) {
park();
return; /* bare -- never `return 1;` */
}
run_forever();
}
Boot entry points shared between a vector table and its startup code.
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
@ k_ra8_ok
Success – operation completed with all postconditions satisfied.
Definition ra8_err.h:119

Why this is a rule and not a preference

Firmware used to declare int32_t main(void). int32_t is not a type; it is a nickname for whichever type is 32 bits wide, and that is int on the host but long int on arm-none-eabi. The firmware entry point therefore had a different type on the chip than the same source had on the host, which is error: return type of 'main' is not 'int' under -Werror – silenced, at the peak, by 208 copies of #pragma GCC diagnostic ignored "-Wmain".

The declaration also lived in sixteen copy-pasted extern int32_t main(void); lines inside vector tables. A vector table is a different translation unit from the main.c it calls, so the compiler never saw the two together, and about thirty applications had drifted into declaring one type while defining another. They linked and ran only because both types happen to be 32 bits wide and returned in the same register.

The single declaration is what makes the compiler able to check this at all, and -ffreestanding on the firmware lane (cmake/ra8_add_app.cmake) is what makes void legal. The flag and the signature travel together – remove the flag and every firmware main.c stops compiling.

The declaration in ra8_boot_entry.h therefore sits behind #if __STDC_HOSTED__ == 0, the macro -ffreestanding clears and a hosted build sets. That guard is load-bearing, not decoration: ra8_core is also compiled natively for the host unit tests, and an unguarded void main(void); makes every hosted translation unit that reaches this header fail with conflicting types for 'main' against its own ISO entry point. The declaration exists exactly where its contract does. See issue #707.

Header guards

Use #pragma once. Traditional #ifndef/#define/#endif guards are rejected by code review.

/* file header doxygen block */
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
/* declarations */
#ifdef __cplusplus
}
#endif

Hardware register access

Use inline accessor functions, never macro-style register pointers:

// CORRECT
typedef enum : uintptr_t {
k_ra8_glcdc_base_addr = 0x40340000UL,
} ra8_glcdc_addr_t;
static inline volatile r_glcdc_regs_t* ra8_glcdc(void)
{
return (volatile r_glcdc_regs_t*)k_ra8_glcdc_base_addr;
}
// Usage
ra8_glcdc()->BG_PERI = 0x12345678U;
// WRONG
#define GLCDC_BASE ((volatile r_glcdc_regs_t*)0x40340000UL)
GLCDC_BASE->BG_PERI = 0x12345678U;
@ k_ra8_glcdc_base_addr
RA8 GLCDC base address.

The inline-accessor approach lets the host RA8_OFF_TARGET build intercept register writes by linking a different ra8_glcdc() body. Macros foreclose that.

HUM citations

Every register read or write – whether through an accessor or a raw volatile pointer – carries a comment immediately above citing the RA8D2 Hardware User's Manual:

/* HUM Ch 11.2.8 "MSTPCRC : Module Stop Control Register C" p 446 */
*ra8_mstp_reg32(k_ra8_mstpcrc_off) &= ~(1U << k_ra8_mstpc_glcdc_bit);

The format is:

/* HUM Ch X.Y "Section name" p NNNN */
/* HUM Ch X.Y "Section name", p NNNN */ (comma OK)
/* HUM Ch X.Y "Section name" p NNNN-MMMM */ (page range)

scripts/checks/cite_check.py walks every .c / .h and verifies that each cite's chapter exists in docs/reference/CHAPTER_MAP.md and the page falls within the chapter's range.

SOLID principles for C

The same SOLID principles that apply to OO carry over to procedural C with small adaptations. Each point also references the analogous structure in the STAR project for cross-pollination.

Single Responsibility (S)

  • One module = one purpose. ra8_pid would handle ONLY PID arithmetic – no motor control, no hardware. STAR's rx_pid follows the same rule.
  • One function = one action. ra8_pid_compute does math; ra8_pid_reset clears state. They do not share an entry point.
  • Separation of concerns. Configuration (ra8_pid_config_t) is a separate type from runtime state (ra8_pid_handle_t). The handle carries the cfg by value at init time so subsequent edits to the cfg struct don't ghost-update running PIDs.

Open/Closed (O)

  • Extensible without modification. Drivers configure via a _config_t struct passed to _init. Adding a new feature gates on a new field in the struct, not a new entry point.
  • Runtime tuning. _set_* setters allow updates without recompilation. STAR's rx_pid_set_gains is the canonical example.
  • Avoid hardcoded values. All limits live in the config: output_min, output_max, integral_min, integral_max.

Liskov Substitution (L)

  • Implementations are interchangeable. A bus manager accepts any bus type (I2C/SPI/1-Wire) through the same vtable shape.
  • Mocks substitute real implementations. Tests use ra8_fake_mmio in place of real hardware – the HAL's mock vs. prod selection is at link time, not source time.
  • Consistent error handling. All drivers return ra8_err_t with the same semantics. A caller can write a generic error handler that handles every driver uniformly.

Interface Segregation (I)

  • Small, focused interfaces. A bus interface splits into read(), write(), configure() – not a single fat do_anything(verb, args) entry point.
  • Separate read / write paths. Half-duplex consumers don't pay for full-duplex code.

Dependency Inversion (D)

  • High-level modules depend on abstractions, not implementations.
    typedef struct {
    ra8_err_t (*read)(void* ctx, uint8_t* data, uint32_t len);
    ra8_err_t (*write)(void* ctx, const uint8_t* data, uint32_t len);
    void* ctx;
    } bus_interface_t;
    -proof
  • Function pointers for late binding. This is the project's intentional deviation from NASA Power of 10 Rule 9 – the testability win is worth the relaxed pointer-discipline rule.
  • Inject mocks via the same interface. Test code links a mock vtable; production code links the real one.

NASA Power of 10

Safety-critical embedded conventions, per JPL "The Power of 10: Rules for Developing Safety-Critical Code". The project follows all 10 with a single intentional deviation:

# Rule This project
1 Simplify control flow – no goto, setjmp, recursion. Compliant.
2 All loops have fixed upper bounds. Compliant; main loops are while(1) with watchdog refresh, exempt by convention.
3 No dynamic memory after init. Compliant. Zero malloc/free in firmware. _sbrk traps any accidental use.
4 Functions ~60 lines max. Compliant. clang-tidy LineThreshold = 60 enforces. NOLINT only for legitimately linear HUM-spec init paths.
5 Two assertions per function. Compliant. Use RA8_CHECK_NULL_PTR for preconditions, output bounds checks for postconditions.
6 Smallest scope. Compliant. File-scope vars are static; loop counters live in the for-statement.
7 Check all return values. Compliant. RA8_RETURN_ON_ERROR macro propagates; (void) casts mark explicit ignores – but never at a TrustZone boot boundary. A C23 (void) cast suppresses [[nodiscard]] by ISO rule, so -Werror cannot police it; scripts/checks/check_tz_boundary_discard.py therefore bans (void)-cast discards of ra8_tz_secure_boot_*() calls everywhere and of any ra8_*() call inside a boot TU (a .c defining SystemInit / ra8_trustzone_init).
8 Limit preprocessor use. Compliant. C23 typed enums replace #define for constants; macros only for duplicated code, conditional compilation, build flags.
9 Restrict pointer use. Intentional deviation. Function pointers are allowed for Dependency Inversion.
10 Compile clean with max warnings. Compliant. -Wall -Wextra -Werror -fshort-enums; CI fails on any warning.

Backward compatibility (there is none)

This is a personal project with zero backward-compatibility requirements. There will never be public releases or versioned APIs.

Forbidden (rejected in code review):

  • Function aliases: #define old_name new_name
  • Deprecation macros: __attribute__((deprecated))
  • Wrapper functions for "compatibility"
  • Comments like // TODO: remove old API after migration
  • Keeping unused code "just in case"

Required:

  • Update ALL call sites in the same commit when changing APIs.
  • Delete old code immediately. No staged rollouts.
  • Rename types, functions, fields freely to improve clarity.
  • The dev integration branch must build successfully; main is the release branch.

Character encoding

All source files must be pure 7-bit ASCII (Unicode 0x00..0x7F). Applies to every .c, .h, .cpp, .hpp, .cmake, .md, .yml, .sh, .py file – including comments, documentation, and string literals.

Rationale: multi-byte UTF-8 breaks downstream toolchains – static analysers, MISRA checkers, code-coverage tools, Windows IDEs, and the embedded debugger console.

scripts/git/pre-commit rejects any commit containing non-ASCII in source files.

Ring and World tagging

See docs/RING_AND_WORLD.md for the full explanation. Short version: every Ring 3+ file gets a tag that declares its architectural ring and which TrustZone world it expects to run in:

scripts/checks/check_world_tags.py enforces it at commit time.