ClickCease Stack-Unwinding in AArch64-Prozessoren: Was ist das und wie funktioniert es?

Abonnieren Sie unseren beliebten Newsletter

Schließen Sie sich 4.500+ Linux- und Open-Source-Experten an!

2x im Monat. Kein Spam.

Stack-Unwinding in AArch64-Prozessoren: Was ist das und wie funktioniert es?

von

1. März 2023 - TuxCare PR Team

Die Linux-Kernel-Live-Patching-Software von KernelCare Enterprise unterstützt schon seit einiger Zeit neben x86_64 (Intel IA32/AMD AMD64) auch ARMv8 (AArch64). Um jedoch KernelCare auf ARM zum Laufen zu bringenzum Laufen zu bringen, benötigen Sie einen sogenannten Stack Frame Uninder.

Dieser Artikel erklärt, was sie sind, wofür sie verwendet werden und warum wir unseren eigenen Stack-Frame-Abwickler schreiben mussten.

  1. Stack Unwinders: Was sind sie, wofür werden sie verwendet, und ihre Geschichte
  2. Wie Stack Unwinding funktioniert: Stack Primer und seine Funktionsweise in AArch64-Prozessoren
  3. Anweisungen für Stack Unwinders: Anweisungen zum Springen, Adresse zum Springen und Fehlerbehebung

Teil A - Stapelabwickler

Was ist ein Stack Unwinder?

A Stapelabwickler ist eine Software, die die Adressen aller Funktionen auflistet, die sich derzeit auf dem aufrufenden Stack befinden. Er zeigt Ihnen, wo Sie sich in der Ausführung eines Programms befinden, aber was noch wichtiger ist, er zeigt Ihnen auch, wie Sie dorthin gekommen sind. Der Aufrufstapel ist die Liste aller Funktionen, die derzeit ausgeführt werden. Er wird Stack genannt, weil eine Funktion eine andere aufruft und diese neue Funktion oben auf dem Stack hinzugefügt wird (solange sie die aktuell ausgeführte Funktion ist). Wenn eine Funktion einen Wert zurückgibt oder eine Exit-Anweisung erreicht, wird sie vom Stack entfernt.

Ein effektiver Abwickler muss alle diese Eigenschaften aufweisen:

  • Schnell: damit die Bearbeitung schnell wieder aufgenommen werden kann (wenn möglich).
  • Günstig: damit es keine Systemressourcen beansprucht.
  • Genaue: damit Speicheradressen und Namensräume genau gemeldet werden.

Wofür wird ein Stack Unwinder verwendet?

  1. Zur Bereitstellung von Stack Traces bei Programmabstürzen. (In der Welt des Linux-Kernels werden solche Abstürze als "Oops" bezeichnet).
  2. Zur Unterstützung bei der Leistungsanalyse, um den Weg (welche Funktionen werden aufgerufen) eines Programms innerhalb eines Programms aufzuzeigen.
  3. Ermöglicht das Live-Patching des Linux-Kernels, d. h. die Behebung von Kernel-Fehlern, ohne das System anzuhalten (neu zu starten).

Geschichte des Stack Unwinding

In der Vergangenheit half die Stapelentfaltung den Entwicklern bei der Fehlersuche in Software. Schließlich weiß jeder, der schon einmal ein paar Programme geschrieben hat, dass Programme höchstwahrscheinlich Fehler enthalten. 

Einige Fehler sind leicht zu erkennen, während andere Fehler fast unbemerkt bleiben. Je größer das Programm ist, desto schwieriger wird es, ein Programm zu debuggen: große Programme sind fast unmöglich zu debuggen, wenn man nur die Quellcode-Analyse als einzige Debugging-Technik verwendet. 

Aus diesem Grund gibt es mehrere sekundäre Techniken, die den Debugging-Prozess erleichtern sollen:

  1. Protokollierung (d.h. Debugging-Ausgabe). Hier verwendet der Programmierer die Druckoperatoren der von ihm gewählten Sprache, um den Inhalt bestimmter Variablen an bestimmten Punkten des Programmablaufs anzuzeigen. So kann er feststellen, wo der Programmablauf vom erwarteten Szenario abweicht. Moderne Software stützt sich in hohem Maße auf die Protokollierung, verwendet aber verschiedene Protokollierungsebenen - Debug, Notice, Warning, Error usw. -, um einige weniger wichtige Protokollierungsmeldungen in der Produktion deaktivieren zu können. Normalerweise werden die Protokollierungsmeldungen in einer speziellen Protokolldatei oder alternativ in einem systemweiten Protokollspeicher oder einer Datei gespeichert.
  2. Debugging über Haltepunkte. Zur Erleichterung des Debugging-Prozesses verfügt fast jede moderne CPU-Architektur über einen speziellen Befehl, der als "Breakpoint"-Befehl bezeichnet wird (z. B. bkpt für ARM und int3 für x86). Der Zweck dieses Befehls besteht darin, eine spezielle Prozessorunterbrechung auszulösen. Die Hardware speichert dann den aktuellen Programmzähler und eventuell einige Allzweckregister, damit die Unterbrechungsroutine erfolgreich starten kann. Die Kontrolle wird dann an die Unterbrechungsroutine übergeben.

    Diese Routine extrahiert gespeicherte Inhalte von Allzweckregistern, um dem Programmierer zu helfen, bestimmte Programmvariablen an dem Punkt zu untersuchen, an dem das Programm angehalten wurde. Unmittelbar vor der Ausführung des Rückgabebefehls der speziellen Unterbrechungsroutine stellt diese Unterbrechungsroutine in der Regel den ursprünglichen Befehl wieder her, und nach der Rückkehr zum unterbrochenen Thread läuft das Programm so, als ob es überhaupt nicht berührt worden wäre.

    Die heutige Nutzung dieser Technik ist auf die Unterstützung durch den Betriebssystemkern angewiesen, da alle Unterbrechungsdienstroutinen ein Teil davon sind. Normalerweise stellt der Betriebssystemkern spezielle Systemaufrufe zur Verfügung (z. B. ptrace unter Linux), die von User-Space-Prozessen zur Durchführung von Debugging-Funktionen verwendet werden können. Der populäre Open-Source-Debugger von Linux, GDB, verwendet ptrace zur schrittweisen Fehlersuche (über Haltepunkte).

    Außerdem definieren einige CPU-Architekturen (z. B. x86-64) zusätzliche Fähigkeiten, die auf der ursprünglichen Idee beruhen. So kann es beispielsweise spezielle Systemregister geben, die die Adresse eines (virtuellen) Haltepunkts enthalten: Wenn der Programmzähler diese Adresse erreicht, wird sofort eine Unterbrechungsroutine aufgerufen, ohne dass der Programmcode geändert (und folglich wiederhergestellt) werden muss.
  3. Fehlersuche über Assertions. Die Assertion ist eine spezielle Funktion, die eine gegebene Bedingung prüft und, wenn diese Bedingung falsch ist, den Abbruch des Programms bewirkt. Assertions können von Programmierern verwendet werden, um sicherzustellen, dass interne Variablen in Ordnung sind. Assertions werden in der Regel in Produktionsprogrammen deaktiviert. Hier ist der interessanteste Fall der, in dem die Assertion falsch ist. Dieser Zustand signalisiert eindeutig, dass ein Programmfehler aufgetreten ist. Um ein Problem zu untersuchen, wird ein Stack-Unwinding durchgeführt. Durch Abwickeln des Programmstapels können wir einen "Ausführungspfad" ermitteln, der unser Programm zu dem Punkt führt, an dem es abgestürzt ist.

Fortgeschrittene Abwickler erlauben es, Parameter für jede Funktion innerhalb der Aufrufkette zu sehen.

Ursprünglich stammt das Stack Unwinding also aus dem Software-Debugging. Inzwischen gibt es aber auch andere Anwendungen, eine davon in Kernelcare Enterprise.

Warum das KernelCare Enterprise Team einen Unwinder für ARM brauchte

Um einen Linux-Kernel-Live-Patch zu installieren, muss die Patching-Software wissen, welche Funktionen sich im aktuellen Aufrufstapel befinden. Wenn eine Funktion, die sich im Aufrufstapel befindet, gepatcht wird, kann das System bei der Rückkehr abstürzen. Der Linux-Kernel verfügt bereits über einige Funktionen zum Abwickeln des Stacks. Hier ist ein kurzer Überblick über diese Funktionalität und die Gründe, warum sie nicht für Live-Patching verwendet werden kann:

  • Der 'Guess'-Abwickler: Er errät den Inhalt des Stacks. Er ist nicht genau und daher nicht für Live-Patching geeignet. Er ist nur für x86_64-Architekturen verfügbar.
  • Der 'Frame Pointer' Abwickler: Nur für x86_64 verfügbar
  • Der 'ORC'-Abwickler: Eingeführt in Linux-Kernel v4.14. ist "ORC" ein Akronym für "Oops Rewind Capability". Ursprünglich nur für x86_64 entwickelt, wurde es weiter verbessert, und es gibt Patches, die für die Aufnahme in den Kernel in Betracht gezogen werden (ab Linux Kernel 6.3), die die Unterstützung Unterstützung für ARMausweiten, sowie Zuverlässigkeitsprüfungen hinzufügen, um sicherzustellen, dass genaue Informationen zurückgegeben werden.

Teil B - Wie die Stapelabwicklung funktioniert

Stapel-Fibel

  • Wenn eine Funktion aufgerufen wird, wird ein Stapelrahmen die Argumente der Funktion sowie ihre Ein- und Ausstiegspunkte auf.
  • Einem Prozessorregister wird ein Stackpoint ("SP") zugewiesen, der auf das zuletzt auf dem Stack abgelegte Objekt verweist. Der Speicher, der diesen Stack implementiert, dehnt sich nach unten zu niedrigeren Speicheradressen aus (sog. "full-descending").
  • Der Stapelspeicher muss an Byte-Grenzen ausgerichtet sein (16 Byte für AArch64). Dies wird von der Hardware erzwungen (kann aber bei einigen ARM-Modellen deaktiviert werden).

Einzelheiten

Wie funktioniert der Stapelabbau in AArch64-Prozessoren?

Eine spezielle Kernel-Funktion führt den Stack-Unwinding-Vorgang durch. Beim Aufruf erhält die Funktion den Rahmenzeiger (FP) der aufrufenden Funktion. Der FP bezieht sich auf den Stapelrahmen, der durch die Struktur struct stack_frame dargestellt wird. Er enthält einen Zeiger auf den Stack-Frame der Funktion, die die aufrufende Funktion aufgerufen hat.

Das bedeutet, dass wir eine verknüpfte Liste von Stack-Frames haben, die endet, wenn die nächste erhaltene FP gleich 0 ist, gemäß AAPCS64 (dem Prozeduraufrufstandard für AArch64) endet. In jedem Stackframe können wir eine Rücksprungadresse abrufen, an die die aufrufende Funktion die Kontrolle delegieren sollte, nachdem sie ihre Arbeit beendet hat. Da eine Rücksprungadresse innerhalb der aufrufenden Funktion liegen sollte, können wir die symbolischen Namen aller Funktionen bis einschließlich des Punktes, an dem FP=0 ist, ermitteln.

Zu diesem Zweck werden die Namen der Funktionen und ihre Anfangs- und Endadressen gespeichert. Dies kann mit Hilfe des Linux-Kernel-Subsystems namens kallsyms implementiert werden.

Das Kernproblem lässt sich wie folgt zusammenfassen: Wie können wir den Programmzähler von einer Stelle zu einer anderen verschieben (ein Sprung) und die Verarbeitung ohne Probleme fortsetzen?

Diese Aktion kann in Assemblersprache wie folgt ausgedrückt werden:

Schauen wir uns diese im Detail an.

1. Anweisung zum Sprung

Prozeduren werden mit BL aufgerufen. Die 32-Bit-Anweisung kann wie folgt dargestellt werden:


31 30 29 28 27 26 25 (...) 2 1 0
1  0  0  1  0  1  imm26
op

Hier ist imm26 ein 26-Bit PC-Offset. Für einen erweiterten Einblick in dieses Beispiel sollten Sie sich die ARM-Dokumentation ansehen hier.

2. Adresse, zu der gesprungen werden soll

Wir berechnen, wohin wir springen müssen:


bits(64) offset = SignExtend(imm26:'00', 64)

The offset shifts by two bits to the left and converts to 64 bit (i.e. the high bits fill with 1 if imm26 < 0, and with 0, otherwise).

Die Adresse, zu der gesprungen werden soll, lautet dann:


Address = PC + offset

Der Offset wird beschriftet und in der BL-Anweisung verwendet. (Anweisungen in AArch64 benötigen immer 4 Bytes, was ein Vorteil gegenüber x86 ist). Das Register X30 (auch bekannt als Link-Register) wird auf PC+4 gesetzt. Dies ist die Rücksprungadresse für RET (standardmäßig X30, wenn nicht angegeben).

Für die ergänzende Anweisung RET genügt es also, den gespeicherten LR-Wert abzurufen, die Kontrolle an ihn zu übergeben und in die aufrufende Funktion zurückzukehren.

Das Problem: Was passiert, wenn die aufgerufene Funktion selbst eine andere Funktion aufruft?

Und hier haben wir ein Problem: Was passiert, wenn die aufgerufene Funktion selbst eine andere Funktion aufruft? Wenn wir nichts tun, wird der in LR gespeicherte Wert durch eine neue Rücksprungadresse ersetzt - es ist nicht möglich, zur ursprünglichen Funktion zurückzukehren, und das Programm wird höchstwahrscheinlich abgebrochen.

Die Lösung des Problems

Es gibt einige Möglichkeiten, dieses Problem zu lösen:

  1. Speichern des LR-Wertes in einem anderen Register
  2. Speichern des LR-Wertes im RAM

Der erste Fall ist sehr restriktiv, da die Anzahl der verfügbaren Register begrenzt ist (auf 31 Register). ARM verwendet die Load/Store-Architekturphilosophie der RISC-Architektur, die besagt, dass Speicheraufrufe über LD- (LOAD) und ST- (STORE) Befehle erfolgen, während arithmetische und logische Operationen auf Registern ausgeführt werden. Daher werden für die Programmausführung leere Register benötigt, und es bleibt uns nur die Möglichkeit 2, den LR-Wert im RAM zu speichern.

Aufgerufene Funktionen mit Rückgabeadressen im Stapel speichern

Eine in C implementierte Rahmenstruktur sieht wie folgt aus:


struct stack_frame {
    unsigned long fp;
    unsigned long lr;
    char data[0];
};

Mit anderen Worten, jede Funktion ordnet n Bytes im Stack zu und verringert den Stack-Pointer um n, wenn sie mit der BL-Anweisung die Kontrolle übernimmt. Der Inhalt der Register x29 (FP) und x30 (LR) wird entsprechend dem erhaltenen Stack-Pointer-Wert gespeichert - die aufrufende Funktion verwendet diese Werte. Danach wird der neue Wert SP dem Register x29 (genannt Rahmenzeiger (FP)) zugewiesen. Der verbleibende Platz im Stapelrahmen wird von den lokalen Variablen der Funktion genutzt. Und die Bedingung, dass sich der Frame Pointer (FP) und das Link-Register (LR) der aufrufenden Funktion immer am Anfang eines jeden Stack Frames befinden, ist immer erfüllt. Nach Beendigung ihrer Arbeit entnimmt die aufgerufene Funktion die gespeicherten Werte von FP und LR aus dem Stapelrahmen und erhöht den Stapelzeiger (SP) um n.

Wie der gcc Cross-Compiler mit AArch64 umgeht

Die Verwendung des Framepointers erfordert, dass der Linux-Kernel mit der gcc-Option -fno-omit-frame-pointer kompiliert wird. Diese Option weist gcc an, den Stack-Frame-Zeiger in einem Register zu speichern. (HINWEIS: Der Standardwert für gcc ist -fomit-frame-pointer, daher muss diese Option explizit gesetzt werden).

Bei AArch64 ist das Register X29. Es ist für den Stack-Frame-Zeiger reserviert, wenn die Option gesetzt ist. (Andernfalls kann es für andere Zwecke verwendet werden.) Der Cross-Compiler GCC, der zum Kompilieren von Linux unter AArch64 verwendet wird, setzt die folgenden Anweisungen vor den Funktionskörper:


ffffff80080851b8 :
ffffff80080851b8: a9be7bfd stp x29, x30, [sp, #-32]!
ffffff80080851bc: 910003fd mov x29, sp

Hierbei handelt es sich um die so genannte indirekte Adressierung mit Vorinkrement, bei der der Stapelzeiger (SP) zu Beginn um 32 verringert wird und dann x29, x30 nacheinander um den im ersten Befehl erhaltenen Wert im Speicher gespeichert werden.

In der Regel wird die Funktion wie folgt beendet:


ffffff80080851fc: a8c27bfd ldp x29, x30, [sp], #32
ffffff8008085200: d65f03c0 ret

Die indirekte Adressierung mit dem Post-Increment, bei dem die gespeicherten Werte x29, x30 aus dem Speicher auf den Stapelzeiger (SP) übernommen werden und SP dann um 32 erhöht wird. Die obigen Codebeispiele werden als Prolog und Epilog der Funktion bezeichnet. GCC erzeugt immer solche Prologe und Epiloge, wenn das Flag -fno-omit-frame-pointer gesetzt ist. Linux auf AArch64 wird mit diesem Flag kompiliert, so dass Stackframes wie normaler Code aussehen (außer Assembler-Code). Diese Tatsache ermöglicht es uns, den Stack einfach abzuwickeln, d.h. die Aufrufkette im Programm zu verfolgen.

Montage-Referenz:

Schlussfolgerung

Trotz seiner Nützlichkeit gibt es keinen einheitlichen Ansatz für das Stack Unwinding, der alle Architekturen und Systeme abdeckt. Für das Live-Patching des Linux-Kernels ist ein zuverlässiger und schneller Stack Unwinding-Ansatz unerlässlich. Für Linux, das auf ARM läuft, ist der Bedarf an einer robusten Stack-Unwinding-Lösung sogar noch dringender, da ARM auf dem Markt für IoT-Geräte und Edge-Cloud-Computing an Bedeutung gewinnt. Da KernelCare in beide Bereiche vordringt, mussten wir unsere eigenen Lösungen für das Entpacken von Kernel-Stacks untersuchen.

Weitere Lektüre

Über KernelCare Enterprise

KernelCare Enterprise vereinfacht das Patchen Ihrer Linux-Kernel für Server unter CentOS, Amazon Linux, RHEL, Ubuntu, Debian und andere Linux-Distributionen, einschließlich Poky (die Distribution des Yokto-Projekts) und Raspbian.

KernelCare sorgt für die Aufrechterhaltung der Kernel-Sicherheit durch automatische, rebootlose Updates ohne Unterbrechung oder Beeinträchtigung des Dienstes. Der Dienst liefert umgehend die neuesten Sicherheitspatches für verschiedene Linux-Distributionen, die automatisch in nur wenigen Nanosekunden auf den laufenden Kernel angewendet werden. KernelCare Enterprise funktioniert sowohl in Live- und Staging-Umgebungen als auch auf lokalen und On-the-Cloud-Systemen. Für Server, die sich hinter einer Firewall befinden, gibt es ein ePortal-Tool, das Sie bei der Verwaltung unterstützt.

KernelCare Enterprise verbessert die Compliance auf Hunderttausenden von Servern verschiedener Unternehmen, bei denen die Serviceverfügbarkeit und der Datenschutz die wichtigsten Bestandteile des Geschäfts sind: Finanz- und Versicherungsdienstleistungen, Anbieter von Videokonferenzlösungen, Unternehmen zum Schutz von Opfern häuslicher Gewalt, Hosting-Unternehmen und Anbieter öffentlicher Dienstleistungen.

Erfahren Sie mehr über KernelCare Enterprise und die Vorteile, die es bietet hier.

Zusammenfassung
Artikel Name
Stack-Unwinding in AArch64-Prozessoren: Was ist das und wie funktioniert es?
Beschreibung
Die Linux-Kernel-Live-Patching-Software von KernelCare Enterprise unterstützt (AArch64), aber was sie sind und wofür sie verwendet werden...Erfahren Sie mehr
Autor
Name des Herausgebers
TuxCare
Logo des Herausgebers

Erleben Sie die KernelCare-Vorteile selbst

Werden Sie ein TuxCare-Gastautor

E-Mail

Helfen Sie uns,
die Linux-Landschaft zu verstehen!

Füllen Sie unsere Umfrage zum Stand von Open Source aus und gewinnen Sie einen von mehreren Preisen, wobei der Hauptpreis mit 500 $ dotiert ist!

Ihr Fachwissen ist gefragt, um die Zukunft von Enterprise Linux zu gestalten!