Oberon RTK

Program Startup

How a program gets up-and-running – from Cortex-M reset through to the program's own entry point

Hardware

General

All current Cortex-M processors come out of reset in the same way:

  • the initial stack pointer (SP) value is at offset 0 of the vector table;
  • the initial program counter (PC) value is at offset 4 of the vector table.

The vector table lives at the start of the program binary. The processor's hardware initialises the SP and PC registers from these two words; the first instruction the program executes is the one the PC value points at.

What differs between MCUs is whether a vendor bootloader runs between reset and "your code", and what that bootloader does.

RP2040

A read-only bootloader in ROM runs first. It looks at the start of flash (10000000H) for a stage-2 loader (boot2), which initialises the QSPI flash interface (XIP). With XIP up, the stage-2 loader transfers control to the program at SP from 10000100H and PC from 10000104H.

RP2350

A read-only bootloader in ROM runs first. It scans the first 4 kB of flash for an IMAGE_DEF metadata block, validates it, and initialises the boot environment accordingly. Control then transfers to the program at the SP and PC values as directed by IMAGE_DEF. See IMAGE_DEF for how this metadata block gets into the binary in the first place.

STM32

Unless the system bootloader is selected via option bytes (or the BOOT0 pin), STM32 follows the Cortex-M default directly: the vector table sits at the configured flash address, and SP and PC are initialised from the first two words. That is, your program's vector table is the first thing the processor sees – no vendor bootloader code runs in between, in contrast to the RP boards.

Astrobe Startup Sequence

At the lowest level, the Astrobe linker emits a sequence of bl.w calls to a Module..init procedure for every module in the program. These procedures are generated automatically – one per module body – and called in dependency order.

From example program BlinkPlus on RP2350:

.     7  10006104  F7FAF990  bl.w  LinkOptions..init
.     8  10006108  F7FAF996  bl.w  PPB..init
.     9  1000610C  F7FAF9D2  bl.w  Startup..init
.    10  10006110  F7FAF9E2  bl.w  BASE..init
.    11  10006114  F7FAF9E4  bl.w  EXC..init
.    12  10006118  F7FAFB12  bl.w  MemMap..init
.    13  1000611C  F7FAFBBC  bl.w  MAU..init
.    14  10006120  F7FAFBE0  bl.w  SIO_DEV..init
.    15  10006124  F7FAFCC8  bl.w  Cores..init
.    16  10006128  F7FBF828  bl.w  Memory..init

...

.    29  1000615C  F7FCFBCE  bl.w  UART..init
.    30  10006160  F7FCFCB2  bl.w  UARTstr..init
.    31  10006164  F7FDF896  bl.w  Texts..init
.    32  10006168  F7FDF964  bl.w  Out..init
.    33  1000616C  F7FDF9B8  bl.w  In..init
.    34  10006170  F7FDFA00  bl.w  SIOgpio..init
.    35  10006174  F7FDFA3A  bl.w  LED..init
.    36  10006178  F7FDFBB2  bl.w  RuntimeErrors..init
.    37  1000617C  F7FDFD76  bl.w  ProgData..init
.    38  10006180  F7FEF8F8  bl.w  Stacktrace..init
.    39  10006184  F7FEFD00  bl.w  RuntimeErrorsOut..init
.    40  10006188  F7FEFE88  bl.w  Console..init
.    41  1000618C  F7FEFED2  bl.w  FPU..init

.    42  10006190  F7FEFF32  bl.w  Main..init

.    43  10006194  F7FEFF38  bl.w  ASM..init
.    44  10006198  F7FEFF8A  bl.w  Coroutines..init
.    45  1000619C  F7FFF8C8  bl.w  Exceptions..init
.    46  100061A0  F7FFF91A  bl.w  SYSTK..init
.    47  100061A4  F7FFF934  bl.w  SysTick..init
.    48  100061A8  F7FFFDB4  bl.w  Kernel..init
.    49  100061AC  F7FFFDCA  bl.w  TIMER_DEV..init
.    50  100061B0  F7FFFE64  bl.w  TIMER..init
.    51  100061B4  F7FFFF96  bl.w  BlinkPlus..init

Observations:

  • LinkOptions..init is always the first call, if included in the program (which is typical).

  • BlinkPlus..init is the last call – this is where your program gets control.

  • Main..init has a special role in the program structure: it can be used to set up and configure the run-time environment in a systematic way that is re-usable across different programs. However, there are no hard rules enforced by the Astrobe tools as regards its contents: we could leave Main empty, and do all initialisation in the program module itself, or put the whole control program logic into Main and import it into the otherwise empty program – or any variation in between.

  • The only rule is: Main must be imported by the program (here: BlinkPlus).

  • Unlike with other SDKs, there is no "hidden" platform start-up and bootstrap code added to your program: you know your program boots with LinkOptions..init as the entry point. Each and every line of code is contained in the Astrobe and Oberon RTK frameworks, where it can be inspected, together with the corresponding assembly listings.

  • While you can find the above bl.w sequence by disassembling your program in Astrobe, you can also simply inspect the .map file produced by the linker: the modules are initialised exactly in the order listed there.

Astrobe Start-up Phases

The start-up sequence above divides into two phases, each with two sub-phases. We infer this structure from what we observe: the linker requires a program to import module Main, and Main..init consistently lands as a boundary between Main's imports (above) and the program's own (below). This is independent of the position of Main in the program's import list.

  • Phase 1 – Main and its imports:

    • 1a Module bodies of all modules imported by Main, in dependency order.
    • 1b Main's own body runs.
  • Phase 2 – the program and its imports:

    • 2a Module bodies of modules imported by the program but not already loaded in phase 1.
    • 2b The program's own body runs – and the control program starts.

Phases are strictly sequential: 1a completes before 1b, 1b before 2a, 2a before 2b.

An important characteristic of Main is that, since it is only imported by the program module, it can easily be customised by creating a copy in the program directory – the compiler and linker will find it there, and use it in lieu of the library version.

Oberon RTK Start-up Design

Overview

In Oberon RTK, the two-phase model and module Main are used to further systematise the start-up.

The Astrobe library and Oberon RTK provide a reasonable default for Main, but it can be used and configured to exactly match the requirements of your programs and projects: since Main is the "orchestrator" for the fundamental program initialisation at startup, you may want to extend it to bring up your own project-spanning and project-specific framework modules – modules that build on top of Oberon RTK (vertical extension), or add functionality (horizontal extension).

The framework's module Main provides two different types of customisations:

  • basic customisation for functionality that usually requires project-specific settings, such as clocks (frequencies, clock tree), static memory selection and allocation, or standard text I/O;

  • extended customisation for functionality such as dynamic memory allocation and run-time error handling.

To illustrate, here's Main from lib/v3.1 (RP2350):

MODULE Main;
  IMPORT (* keep first three imports in this order  *)
    Startup, MemMap, Memory, Clocks, Console,
    RuntimeErrors, RuntimeErrorsOut, FPU;

  PROCEDURE run;
  (* runs on core 0 *)
  BEGIN
    ASSERT(Startup.Done);
    ASSERT(MemMap.Done);
    ASSERT(Memory.Done);
    Clocks.Config;
    RuntimeErrors.Install;
    Console.Install(Console.SYSTERM0);
    RuntimeErrors.InstallErrorHandler(RuntimeErrorsOut.ErrorHandler);
    RuntimeErrors.EnableFaults;
    FPU.Enable
  END run;

  PROCEDURE ConfigC1*;
  (* to be called from core 1 *)
  BEGIN
    RuntimeErrors.Install;
    Console.Install(Console.SYSTERM1);
    RuntimeErrors.InstallErrorHandler(RuntimeErrorsOut.ErrorHandler);
    RuntimeErrors.EnableFaults;
    FPU.Enable
  END ConfigC1;

BEGIN
  run
END Main.

Note the procedure ConfigC1: it is specific for the dual-core MCUs. It replaces importing Main in the program for core 1. Imported modules' bodies always execute on core 0, so code that needs to be executed by core 1, eg. to access that CPU's Private Peripheral Bus, must explicitly be called from there: core 0 cannot enable the FPU or MCU faults of core 1.

See example program BlinkSync for how to use ConfigC1.

Main lives in its own directory <mcu>/main. This is deliberate: the Astrobe tools first look in the importer's directory before consulting the library search path, so segregating Main ensures the search path applies to all of its imports – and a project-local copy of any of them is picked up before the framework version.

Basic Customisations

For the basic customisations, Oberon RTK puts corresponding modules into a directory <mcu>/startup:

lib/v3.1/mcu/<vendor>/<mcu>/startup/
  Clocks.mod   -- clocks, bus dividers (incl. adjusting voltages and wait states)
  MemMap.mod   -- static memory-region descriptors
  Console.mod  -- system terminals, standard text I/O
  Startup.mod  -- VTOR, initial fault handlers, undo/change bootloader outcomes

To customise the effects of any of these modules, you create your version in the project directory. When you build your program, Main will import that customised module, and still import the unaltered framework modules from startup.

For this to work, there must be no imports among the modules in startup.

Extended Customisations

The dynamic memory allocator, as well as the run-time error handling, provide hooks for specific points of customisation.

  • Memory allocator: module MAU in the Astrobe framework provides MAU.SetNew and MAU.SetDispose to plug custom procedures that will be called via NEW and DISPOSE. Module Memory uses this facility to set dual-core-capable allocation and deallocation procedures. If you implement your own memory allocator module, use the functionality of MAU. Memory also implements allocation of stacks for kernel threads, hence your own module would need to provide these as well.

  • Run-time error handling: run-time error handling consists of two steps: 1) detect the error and collect all error data, and 2) handle the data. By default, as set up by module Main, the error data is used to create a stack trace, print the error data, CPU status, and trace to the serial terminal, and halt. You can plug your own error handler using RuntimeErrors.InstallErrorHandler, and you can plug a different output device than the serial terminal using RuntimeErrorsOut.SetWriter.

See also Run-time Errors.

In general, if you replace a module with your own version, eg. for even deeper customisations, it is most straightforward to use a different module name. This avoids any look-up issues during compilation and linking, but may have ripple effects within the framework's imports: you will need to adjust the import list to use your module using OldModule := NewModule.

See also Library Customisation.

Execution

The first three imported modules in Main do their work via their ..init body procedures, to ensure they run as early as possible in the start-up sequence, creating the foundation for the subsequent ..init calls. They are checked by corresponding ASSERT statements.

All other start-up modules are explicitly initialised (Config, Install, …), and they do have empty module bodies (see next).

Module Bodies

As a general rule, Oberon RTK's module bodies only initialise their own memory, but never touch peripheral hardware. This ensures that the modules are useable in a Secure/Non-secure segregation context: if a module's body touches peripheral hardware, importing it into a Non-secure (NS) program may silently not work or trigger a run-time fault when the S program has not released that hardware for NS use.

Example: the S program releases a set of GPIO pins to the NS program, hence the latter needs to import module GPIO. If module GPIO itself were to release its reset (RP) or enable its bus clock (STM) via GPIO..init, which are operations reserved for the Secure world by default, the program would not work or fault.

The Principle of No Surprises

Oberon RTK is a framework for control programs: programs that perform a defined set of tasks on a known hardware target, where reliability and predictability matter more than dynamic flexibility. The framework's design follows from that focus. All resources a program will need are assigned and initialised during start-up – peripheral devices, SRAM regions (including any dynamic memory used via NEW), thread stacks, interrupt handlers. After start-up, the program operates on resources it already owns; it does not discover them, contend for them, or run out of them.

Where a resource is shared between threads or cores, the sharing must be explicitly designed and synchronised – through a sharing protocol, a lock, a designated owner. Two run-time failures must never happen in a control program: (1) a resource is needed but unavailable, unless that case has been designed for in the program (eg. by a documented sharing protocol); or (2) the program runs out of dynamic memory. Either is a design defect, not a run-time event to recover from. The structured start-up – foundational set-up in Main (phase 1b), application set-up in the program's own body (phase 2b) – exists exactly so that the resource picture is fully resolved by the time the control loop starts.

The underlying design principle: let the surprises be in the controlled system, not the control system. A control program already has plenty to deal with from the world it monitors and acts on – sensors going noisy, actuators stalling, networks dropping packets, real things doing real-world things. Its own resource picture is one place where uncertainty can be designed out entirely.

References

Last updated: 21 May 2026