Skip to content

Mikrocontroller-Startup-Code einfach erklärt

Der Startup-Code eines Mikrocontrollers ist das erste Stück Binary, das nach einem Reset ausgeführt wird. Er führt die Mikrocontroller-spezifische Initialisierung sowie die Initialisierung des RAMs durch – des Weiteren die statische Initialisierung von ROM zu RAM und den Aufruf von Konstruktoren für statische Objekte vor dem Aufruf der main durch. Je nach programmatischem Ansatz sollten an dieser Stelle, d.h. idealerweise noch vor dem Main-Call Interrupts und Treiber aktiviert werden, sodass in der zu entwickelnden Hauptapplikation keinerlei weitere Low-Level-Zugriffe stattfinden müssen. Bei einem idealerweise hauptsächlich Interrupt-basierten Ansatz wird die main-Schleife dann nur noch durch die SysTicks oder externe Events wie Rx-/Tx-Interrupts oder ErrorHandler unterbrochen.

In den meisten IDEs wird der StartupCode meist in Assembler zur Verfügung gestellt und erfordert in der Regel detaillierte Kenntnisse über den spezifischen Mikrocontroller sowie die C/C++-Initialisierung. Hersteller von Mikrocontroller stellen daher Anwendungen unter As-Is-Einschränkungen und mit geringem Dokumentationsgrad bereit. Eine Mikrocontroller-Aufinitialisierung jedoch ist inbesondere bei standardisierten Baureihen wie Cortex-A oder Cortex-M risikoarm und unbedenklich. Eine C-Implementierung ist daher insbesondere unter Aspekten der Lesbarkeit und Nachvollziehbarkeit unbedenklich. Wichtig ist eine Synchronität mit den im Linkerscript bereitgestellten Symbolen.

Die folgende Grafik zeigt das „Pflichtenheft“ des Startup-Vorgangs für einen Mikrocontroller.

Ablaufdiagramm Startup

Ablaufdiagramm für den Mikrocontroller-Startup

Linkerscript

Eine ganze Reihe an Voraussetzungen sind damit in Richtung des Linkers zu stellen, nämlich die eines typischen ELF-Programmformats, die der Linker durchdefinieren muss.

  • Kopfinformationen (ELF header)
  • Programmkopf-Tabelle (program header table)
  • Sektionskopf-Tabelle (section header table)
  • die Sektionen (ELF sections)
  • die Segmente (ELF segment)

Unsere To-Do's sind zunächst an dieser Stelle:

  • Definition des Entrypoints
  • Providen von Symbolen für pre_init, init, fini Arrays sowie ctors und dtors
  • Providen von Symbolen für Anfangs- und Endadressen für Sections
  • Definition der Adressbereiche für Vektortabelle(n)

Folgender Snippet enthält beispielsweise den .data-Bereich mit definierten newlib-Symbolen. Im .data-Segment werden alle globalen Variablen (mit static oder extern) und lokalen (innerhalb einer Funktion definierten) statischen Variablen gespeichert, die definierte Anfangswerte ungleich Null haben. Die Größe des Segments wird durch die Größe der Variablen bestimmt und ist zur Kompilierungszeit bekannt. Dieses Segment kann weiter unterteilt werden in einen initialisierten schreibgeschützten Bereich und einen initialisierten beschreibbaren Bereich (der Daten speichert, die zur Laufzeit geändert werden können). Dieses Segment wird aus dem Flash (in dem es ursprünglich gespeichert war), in den RAM kopiert.


/*
 * initialise the .data section.
 */
_sidata = LOADADDR(.data);

/* Initialized data section. */
.data : ALIGN(4) {
  FILL(0xff)
  _sdata =.;
  __data_start__ =.;
  .= ALIGN(4);

  PROVIDE(__start_data_RAM =.);

  *(vtable) * (.ramfunc*)*(.data*)

                  .= ALIGN(4);
  /* preinit data */
  PROVIDE_HIDDEN(__preinit_array_start =.);
  KEEP(*(.preinit_array))
  PROVIDE_HIDDEN(__preinit_array_end =.);

  .= ALIGN(4);
  /* init data */
  PROVIDE_HIDDEN(__init_array_start =.);
  KEEP(*(SORT(.init_array.*)))
  KEEP(*(.init_array))
  PROVIDE_HIDDEN(__init_array_end =.);

  .= ALIGN(4);
  /* finit data */
  PROVIDE_HIDDEN(__fini_array_start =.);
  KEEP(*(SORT(.fini_array.*)))
  KEEP(*(.fini_array))
  PROVIDE_HIDDEN(__fini_array_end =.);

  .= ALIGN(4);

  /* All data end */
  _edata =.;
  __data_end__ =.;
}
> RAM AT > FLASH

Im Textbereich, also da, wo der Programmcode abgelegt wird, sollten Symbole für die Vektortabelle sowie Konstruktoren aufzufinden sein. Angenommen, es handelt sich um ein C-Programm ohne Initialisierung von Objekten, kann dieser Teil dennoch bestehen bleiben. Wenn die Symbole im Compiler nicht genutzt werden, funktioniert das Binary dennoch.

 


    .text :    {
        __ROM_Start = .;
        KEEP(*(.fixed_vectors*))
        __Vectors_End = .;
        *(.text*)
        KEEP(*(.version))
        KEEP(*(.init))
        KEEP(*(.fini))
        /* .ctors */
        *crtbegin.o(.ctors)
        *crtbegin?.o(.ctors)
        *(EXCLUDE_FILE(*crtend?.o *crtend.o) .ctors)
        *(SORT(.ctors.*))
        *(.ctors)
        /* .dtors */
        *crtbegin.o(.dtors)
        *crtbegin?.o(.dtors)
        *(EXCLUDE_FILE(*crtend?.o *crtend.o) .dtors)
        *(SORT(.dtors.*))
        *(.dtors)
        *(.rodata .rodata.* .constdata .constdata.*)
        __ROM_End = .;
    } > FLASH = 0xFF     

Das Linkerscript hat nun die Programmstruktur auf dem Memory festgelegt. Um nun die Symbole in einem C/C++-basierten Startup-Code zu verwenden, widmen wir uns wieder dem Ablaufdiagramm von oben und erarbeiten zunächst, was direkt nach dem Reset passieren soll.

Startup-Code
Vektortabelle

Der Linker hat bereits die Zuordnung der Vektortabelle zu einem fest definierten Speicherplatz übernommen bzw. eine Section exklusiv reserviert. Der Compiler muss an dieser Stelle den einzelnen Speicheradressen über ein Array einzelne Funktionspointer zuweisen. Als ersten Wert wird dem Controller der initiale Stackpointer übergeben. Die einzelnen Funktionen sind typischerweise mit dem weak-Attrubut vordefiniert und können dadurch beliebig an irgend einer Stelle des Codes redefiniert werden.

 


/* Vector table. */
const exc_ptr_t __Vectors[(16)] __attribute__((section(".fixed_vectors")))
__attribute__((__used__)) = {
    (exc_ptr_t)(&g_main_stack[0] + (0x400)), /*      Initial Stack Pointer */
    Reset_Handler,       /*      Reset Handler             */
    NMI_Handler,         /*      NMI Handler               */
    HardFault_Handler,   /*      Hard Fault Handler        */
    MemManage_Handler,   /*      MPU Fault Handler         */
    BusFault_Handler,    /*      Bus Fault Handler         */
    UsageFault_Handler,  /*      Usage Fault Handler       */
    SecureFault_Handler, /*      Secure Fault Handler      */
    0,                   /*      Reserved                  */
    0,                   /*      Reserved                  */
    0,                   /*      Reserved                  */
    SVC_Handler,         /*      SVCall Handler            */
    DebugMon_Handler,    /*      Debug Monitor Handler     */
    0,                   /*      Reserved                  */
    PendSV_Handler,      /*      PendSV Handler            */
    SysTick_Handler,     /*      SysTick Handler           */
};

Ein Reset-Handler sollte somit nichts anderes machen, als eine Start-Funktion zu callen und mögliche Function-Exits über einen Infinite-Loop abzufangen. Sollte, warum auch immer, das Programm hierhin gelangen, kann ein interner oder externer Watchdog an dieser Schleife einen Reset ziehen.


void Reset_Handler (void)
{
    _start ();
    while (1)
    {
        /* Infinite Loop. */
    }
}
RAM- und ROM-Startup

Zunächst geht es jetzt darum, den .bss-Bereich per zero-clear zu initialisieren. Das .bss-Segment steht für "block start by symbol" und ist der Speicherplatz für nicht initialisierte Variablen. Der Compiler optimiert für diesen Blockbereich damit die Codesize, erfordert jedoch, dass die nicht initialisierten Variablen im .bss-Bereich während des Startcodes mit Null belegt werden. Zero-Clear ist ziemlich straight-forward und lässt sich einfach über Pointer-Loop-Through von einer Anfangs- bis zu einer End-Adresse lösen:


inline void__attribute__((always_inline)) __initialize_bss(
    unsigned int* region_begin,
    unsigned int* region_end) {  // Iterate and clear word by word.
  unsigned int* p = region_begin;
  while (p < region_end) {
    *p++ = 0;
  }
}

Ebenfalls straight forward ist die ROM-zu-RAM-Initialisierung, die alle statischen und globalen Variablen betrifft, die vom Entwickler explizit initialisiert worden sind. Diese Variablen befinden sich in einem Abschnitt des Arbeitsspeichers, der als .data Segment bezeichnet wird. Da sowohl die .data als auch die RO-Abschnitte die gleiche Länge und interne Struktur haben, ist die ROM-zu-RAM-Initialisierung nur ein einfacher Kopiervorgang vom Flash in den RAM.


inline void__attribute__((always_inline)) __initialize_data(
    unsigned int* from, unsigned int* region_begin,
    unsigned int* region_end) {  // Iterate and copy word by word.
  unsigned int* p = region_begin;
  while (p < region_end) {
    *p++ = *from++;
  }
}

Final müssen globale Objekte initialisiert werden. ELF definiert für Initialisierungszwecke die Abschnitte .init_array, .fini_array und .preinit_array. Der Abschnitt .init_array erledigt alle normalen Initialisierungsaufgaben, einschließlich globaler C++-Objekte. Er wird vom Compiler automatisch gefüllt, wenn man globale Objekte konstruiert oder eine Funktion mit dem Konstruktor-Attribut im GCC markiert.

Der Abschnitt .fini_array wird nur selten verwendet (C++-Destruktoren werden separat behandelt), kann aber dazu verwendet werden, Callbacks zur Ausführung bei normaler Prozessbeendigung zu registieren. Programmtechnisch kann auf ihn über das Destruktor-Attribut in GCC zugegriffen werden. Die Destruktoren sollten dann durchlaufen werden, wenn aus welchen Gründen auch immer die main-Applikation nach Programmende verlassen wird. Schließlich gibt .preinit_array der ELF-Datei die Möglichkeit, Initialisierungs-Takes vor der Initialisierung gemeinsam genutzter Objekte auszuführen. Es wird in der Praxis selten verwendet und muss durch explizites Section Placement (über __attribute__) erreicht werden.

Prinzipiell werden die Funktionszeiger für diese Konstruktoren im Flash gespeichert und dem Linker über die Abschnitte preinit_array und init_array bekannt gegeben. Dies sind im Grunde Funktionszeiger-Arrays, bei denen jeder Eintrag auf einen Konstruktor für ein statisches oder globales Objekt verweist. Ein schleifenweises Dereferenzieren reicht somit um die Initialisierung abzuschließen.


inline void __attribute__((always_inline)) __run_init_array(void) {
  int count;
  int i;

  count = __preinit_array_end - __preinit_array_start;
  for (i = 0; i < count; i++) __preinit_array_start[i]();

  count = __init_array_end - __init_array_start;
  for (i = 0; i < count; i++) __init_array_start[i]();
}

Je nach Philosophie können Hardware-Initialisierungen vor dem Init-Array, nach dem Init-Array oder nach dem Main-Aufruf gecallt werden. Es hängt insbesondere bei objektorientiertem C++ stark davon ab, was in den Konstruktoren steht. Werden hier lediglich speicherrelevante Maßnahmen vollzogen, sollte die Hardware-Initialisierung vor dem Init-Array stattfinden. Auch sollten möglichst früh SysClock-Einstellungen vorgenommen werden, damit bspw. RAM/ROM-Initialsierungen bei voller Taktfrequenz vollzogen werden.

 

Zusammenfassung und Ausblick

Keine Angst vor Startup-Code. Gerade die Software von Embedded Systems hat einen ziemlich klaren Erwartungshorizont, was das Aufstarten der Software betrifft. Insbesondere für die Lesbarkeit, Wartbarkeit, Optimierbarkeit und Debug-Fähigkeit des Codes ist eine Lösung in C/C++ des Startups eine sehr gute Alternative zu den Hersteller-Varianten in Assembler. Dieser Artikel stellt dar, wie dies mithilfe weniger Codezeilen und durch sinnvolles Linker-Placement einfach und strukturell vollzogen werden kann.

Kommentieren