|
ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
|
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.
Every first-party C-family build unit under apps/, examples/, libs/, port/, tests/, and tools/ uses the same structure:
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.
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.
Every function – public or static – gets a full Doxygen block. The required tags are:
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/:
@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.
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:
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):
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:
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.
| 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.
The project targets C23 with GNU extensions (-std=gnu23). Use the modern features:
Underlying-type choice:
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 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:
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:
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:
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.
Use #pragma once. Traditional #ifndef/#define/#endif guards are rejected by code review.
Use inline accessor functions, never macro-style register pointers:
The inline-accessor approach lets the host RA8_OFF_TARGET build intercept register writes by linking a different ra8_glcdc() body. Macros foreclose that.
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:
The format is:
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.
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.
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. |
This is a personal project with zero backward-compatibility requirements. There will never be public releases or versioned APIs.
Forbidden (rejected in code review):
Required:
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.
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.