Bachelorarbeit · 2017 · Hochschule Esslingen

Implementierung und Test einer ARM Cortex-A9 Low-Level SMP-Schnittstelle für das Echtzeitbetriebssystem eCos

Zwei Prozessorkerne, ein geteilter Speicher, und ein Betriebssystem, das davon noch nichts wusste. Diese Arbeit bringt eCos auf einem ARM Cortex-A9 Dual-Core dazu, beide Kerne wirklich gleichzeitig zu nutzen — vom Booten des zweiten Kerns bis zu Spinlocks auf Register-Ebene.

Auftraggeber
STAR ELECTRONICS GmbH & Co. KG
Zielplattform
Altera Cyclone V SoC
Prozessor
ARM Cortex-A9 MPCore, 2 Kerne
Zeitraum
März – Juli 2017
Der Betreuer bei STAR ELECTRONICS ist nach rund drei Monaten aus dem Unternehmen ausgeschieden — die zweite Hälfte der Implementierung, inklusive der tieferen Register- und Cache-Kohärenz-Arbeit, entstand eigenständig, ohne begleitende Betreuung vor Ort.

Worum es geht

eCos ist ein schlankes Echtzeitbetriebssystem für eingebettete Systeme. Es unterstützt SMP (Symmetric Multiprocessing) grundsätzlich auf Kernel-Ebene — aber ob das auf einer konkreten Hardware tatsächlich funktioniert, hängt von der Hardware Abstraction Layer (HAL) ab. Für den Cyclone V SoC existierte diese SMP-Anbindung schlicht noch nicht.

Diese Arbeit schließt genau diese Lücke: fünf ineinandergreifende Bausteine, jeder einzeln getestet, bevor der nächste darauf aufbaut.

01

Zweiten Kern starten

CPU1 steckt nach dem Reset fest. Ein minimales Trampolin an einer eigens gemappten Adresse holt ihn heraus und schickt ihn an den echten Einstiegspunkt.

02

Interrupt-Controller einrichten

Der GIC muss für jeden Kern einzeln aktiviert werden, bevor Software Generated Interrupts (SGIs) überhaupt zwischen den Kernen laufen können.

03

Kerne kommunizieren lassen

Reschedule- und TimeSlice-Signale werden als SGIs verschickt — die beiden Kerne können sich jetzt gegenseitig den Scheduler anstoßen.

04

Cache-Kohärenz herstellen

Zwei getrennte L1-Caches dürfen nicht unterschiedliche Wahrheiten über denselben Speicher haben. Die Snoop Control Unit übernimmt das — wenn MMU und Speicher-Attribute stimmen.

05

SpinLocks implementieren

Echte Hardware-Lock-Primitiven über ldrex/strex — die letzte Schicht, auf der sich kritische Bereiche zwischen beiden Kernen sicher schützen lassen.

Inkohärente Caches: der Horror ohne Reproduzierbarkeit

Von allem, was in dieser Arbeit schiefgehen konnte — das hier war das Schlimmste. Solange die Snoop Control Unit noch nicht korrekt konfiguriert war, hatten CPU0 und CPU1 für dieselbe Speicheradresse buchstäblich unterschiedliche Wahrheiten: jeder Kern sah den Wert aus seinemeigenen L1-Cache, nicht den tatsächlich aktuellen.

CPU0 schreibt X→CPU1 liest… manchmal X→manchmal den alten Wert→ ?nie vorhersagbar, welchen

Das Tückische: es war nicht "immer falsch", dann wäre es trivial gewesen. Es warmanchmal falsch — abhängig davon, wann genau welche Cache-Line evicted wurde. Ein Absturz trat auf, verschwand beim nächsten Versuch wieder, tauchte drei Durchläufe später an einer völlig anderen Stelle erneut auf. Im Debugger nachzusteppen half nicht wirklich — das Stepping selbst verändert das Timing und damit genau die Bedingung, die den Fehler überhaupt erst auslöst. Kein Stack-Trace, kein fester Reproduktionspfad, einfach zwei Kerne, die sich nicht einig waren, was im Speicher steht.

Aufgelöst erst durch das genaue Verständnis aus der SCU-Architektur: die Snoop Control Unit korrekt aktivieren, und den betroffenen Speicher explizit alsshareable markieren — fehlt eines von beiden, bleibt die Hardware-Kohärenz wirkungslos, egal wie richtig der Rest der Implementierung ist.

Drei Tage DS-5, ein Absturz bei 0x0

CPU1 verlässt den Reset-Zustand und muss zur echten Startadresse springen — eigentlich ein paar Zeilen Assembler. In der Praxis hieß das: drei Tage lang mit ARM DS-5 Instruktion für Instruktion durch den Boot-Code steppen, bis klar war, wo genau es schiefging.

Der Absturz selbst war ein Data-Abort bei Adresse 0x0 — und das war kein Zufall, sondern Absicht, nur an der falschen Stelle: die Boot-Region ist in der MMU bewusst als Fault gemappt, damit Zugriffe auf Nullpointer abgefangen werden. Genau dieses Mapping stand dem eigenen Trampolin-Code im Weg, der zu diesem Zeitpunkt noch versuchte, direkt bei 0x0 zu starten.

0x4000.0000SDRAM [Uncached] — hierhin kopiert
0x0010.0000Boot Region (1MB) — absichtlich [Fault]
0x0000.0000← Trampolin wollte hierhin, Data-Abort

Die Lösung war am Ende das CPU1-Trampolin: ein winziges Code-Fragment, das stattdessen über das zweite, nicht gecachte Mapping der SDRAM eingespielt wird, die echte Einstiegsadresse aus dem cpu1startaddr-Register lädt und erst von dort aus zum eigentlichen Code springt. Zwei ldr-Befehle — aber ohne die drei Tage im Debugger wäre nie klar geworden, warum CPU1 überhaupt nie ankam.

Der Bug, der SGIs für immer blockierte

Jeder Interrupt im GIC durchläuft eine feste Zustandsmaschine: Inactive → Pending → Active → Inactive. Für normale Peripherie-Interrupts reicht es, beim Bestätigen nur die Interrupt-ID zurückzuschreiben — das CPU-ID-Feld ist für sie ohnehin immer null.

Inactive→Pending→Active→ ✕(bleibt hängen)

Für SGIs ist das CPU-ID-Feld aber nie null — und eCos' bestehender Interrupt-Code schrieb beim Bestätigen trotzdem nur die unteren 10 Bits zurück. Ergebnis: ein SGI ließ sich genau einmal auslösen. Jeder weitere Versuch lief ins Leere, weil der GIC den Interrupt noch als "Active" führte.

GICC_IAR / GICC_EOIR — vorher wurde nur SGIINTID zurückgeschrieben
Reserved31–13
CPU ID12–10
Interrupt ID9–0

Die Lösung war am Ende minimal-invasiv: das komplette Register puffern statt nur die Interrupt-ID, und genau dieser volle Wert geht zurück ins EOI-Register. Zwei zusätzliche Assembler-Zeilen — aber ohne die präzise Diagnose der Zustandsmaschine wäre der eigentliche Fehler nie sichtbar geworden.

Register-Nachricht zwischen den Kernen

Ein Software Generated Interrupt entsteht einzig dadurch, dass ein Kern ein bestimmtes Register beschreibt —GICD_SGIR. Bit 17 geschrieben heißt "CPU0 an CPU1", Bit 16 heißt "CPU1 an CPU0".

GICD_SGIR (32 Bit)
Res31–26
TLF25
—24–23
CPUTargetList22–16
NSATT15
Reserved14–4
SGIINTID3–0

SGIINTID wählt den Nachrichtentyp — 0 für Reschedule, 1 für TimeSlice — statt beide Signale durch geteilten Speicher zu unterscheiden, was eine funktionierende Cache-Kohärenz vorausgesetzt hätte, noch bevor diese überhaupt eingerichtet war.