C++ [ASM] Verständnisfragen zum Assemblercode von GDBs disassemble

psi24

Cadet 4th Year
Registriert
Dez. 2012
Beiträge
70
Hi,

ich beschäftige mich in letzter Zeit mit dem GNU Debugger (GDB) und versuche in diesem Rahmen ein Verständnis für Assembler zu erlangen.

Ich habe zu den Zwecken ein kleines Programm mit einer Funktion, die das Ergebnis der ersten Binomischen Formel zurückgibt, in C++ geschrieben:

Code:
int computeFirstBinom(int a, int b)
{
	int r = a*a + 2*(a*b) + b*b;
	return r;
}

Anschließend habe ich mit GDB die Funktion disassembled und versucht nachzuvollziehen, was dort passiert:


Code bei pastebin, besser lesbar

Code:
Dump of assembler code for function computeFirstBinom(int, int):
   0x000000000000092a <+0>:	push   rbp                      ; push <base pointer> to stack
   0x000000000000092b <+1>:	mov    rbp,rsp                  ; move <stack frame pointer> to <base pointer<
   
   0x000000000000092e <+4>:	mov    DWORD PTR [rbp-0x14],edi ; move the 4 bytes from register edi to rbp-0x14  --> a
   0x0000000000000931 <+7>:	mov    DWORD PTR [rbp-0x18],esi ; move the 4 bytes from register esi to rbp-0x18  --> b
   
   0x0000000000000934 <+10>:	mov    eax,DWORD PTR [rbp-0x14] ; eax = a
   0x0000000000000937 <+13>:	imul   eax,DWORD PTR [rbp-0x14] ; eax *= a --> eax = a*a
   
   0x000000000000093b <+17>:	mov    edx,eax                  ; edx = eax --> edx = a*a
   
   0x000000000000093d <+19>:	mov    eax,DWORD PTR [rbp-0x14] ; eax = a
   0x0000000000000940 <+22>:	imul   eax,DWORD PTR [rbp-0x18] ; eax *= b --> eax = a*b
   
   0x0000000000000944 <+26>:	add    eax,eax                  ; eax += eax --> eax = 2*a*b
   0x0000000000000946 <+28>:	add    edx,eax                  ; edx += eax --> edx = a*a + 2*a*b
   
   0x0000000000000948 <+30>:	mov    eax,DWORD PTR [rbp-0x18] ; eax = b
   0x000000000000094b <+33>:	imul   eax,DWORD PTR [rbp-0x18] ; eax *= b --> eax = b*b
   
   0x000000000000094f <+37>:	add    eax,edx                  ; eax += edx --> eax = a*a + 2*a*b + b*b
   
   0x0000000000000951 <+39>:	mov    DWORD PTR [rbp-0x4],eax  ; ?   move the value of register eax to rbp-0x4
   0x0000000000000954 <+42>:	mov    eax,DWORD PTR [rbp-0x4]  ; ?   move the value of rbp-0x4 to eax
   
   0x0000000000000957 <+45>:	pop    rbp                      ; pop rbp from stack
   0x0000000000000958 <+46>:	ret                             ; return to caller?
End of assembler dump.


Die Instruktionen +39 und +42 verstehe ich nicht so ganz; warum dieses Hin-und-Her?
Code:
   0x0000000000000951 <+39>:	mov    DWORD PTR [rbp-0x4],eax  ; ?   move the value of register eax to rbp-0x4
   0x0000000000000954 <+42>:	mov    eax,DWORD PTR [rbp-0x4]  ; ?   move the value of rbp-0x4 to eax

Und warum wird rbp vor dem Return wieder vom Stack entfernt?

Vielen Dank im Voraus.
 
Zuletzt bearbeitet:
Das hängt letztlich alles mit den sogenannten Calling Conventions zusammen, die festlegen, wie Parameter und Return-Werte übergeben werden und wie der Stack und die Register vor und nach dem Aufruf einer Funktion auszusehen haben.
Das pop rbp vor dem Return stellt den Base Pointer wieder auf den Wert zurück, den er vor Aufruf der Funktion hatte und der in der ersten Instruktion auf dem Stack gesichert wurde.
Das Hin und Her mit eax am Ende kann ich mir nur so erklären, dass erwartet wird, dass der Return-Wert sowohl in eax als auch auf dem Stack übergeben wird. In dem Zusammenhang wäre es interessant, welchen Compiler du benutzt und vor Allem, welche Optimierungen und sonsitige Compiler Optionen aktiv sind.
 
Hey,

vielen Dank für die schnelle Antwort.

Kompiliert wurde mit dem GNU C++ Compiler und dem Zusatzparameter für die Debug-Symbole (-g):
Code:
g++ -g gdbTest.cpp -o gdbTest

bezüglich der Optimierungen, nach denen du fragtest: wo fände ich denn solche Infos?

Woher weiß denn die main(), dass sie den Wert aus dem Register eax ziehen kann, nachdem die Subroutine beendet ist?

Und wieso wird rbp durch das pop wieder auf den ursprünglichen Wert zurückgesetzt?
Hätte man dann rbp nicht in einem Register oder in einer Adresse 'zwischenspeichern' müssen?
 
Zuletzt bearbeitet:
Ich tippe mal, [rbp-0x4] ist die Adresse der lokalen Variable r. Damit wäre +39 die Zuweisung des Rechenergebnisses an r, und +42 wäre die return-Anweisung. Du kannst das mal ausprobieren, indem du die Variable r weglässt und direkt 'return a*a + 2*(a*b) + b*b;' schreibst.

Optimierungen aktivierst du mit '-On', wobei n das Optimierungslevel (1-3) darstellt. Je höher das Level, desto aggressiver versucht g++, effizienteren Assemblercode zu erzeugen. Auch da kannst du ja mal ausprobieren, ab welchem Level er +39 und +42 von alleine wegoptimiert.

main() weiß, dass der Rückgabewert in eax steht, weil sie von dem gleichen Compiler erzeugt wird wie die aufgerufene Funktion und weil dieser Compiler Rückgabewerte immer in eax schreibt :)

Edit: Hier findest du eine Übersicht über die Parameter, mit denen du g++ aufrufen kannst: https://gcc.gnu.org/onlinedocs/gcc/Invoking-GCC.html#Invoking-GCC
 
Zuletzt bearbeitet:
Da muss ich NullPointer recht geben. Die erklärung mit der lokalen Variable ist deutlich plausibler als meine.

Mit den Calling Conventions kannst du auch ein wenig herumspielen, wenn es dich interessiert, indem du eine bestimmte Variante für deine Funktion erzwingst: https://gcc.gnu.org/onlinedocs/gcc/x86-Function-Attributes.html

Und rbp wird tatsächlich zwischengespeichert. Das ist ja gerade der Sinn der ersten Instruktion push rbp. Damit liegt der ursprüngliche Wert von rbp oben auf dem Stack und bleibt bis zum pop auch da.
 
Um die rbp/rsp-Geschichte noch einmal genauer zu behandeln: Hierbei handelt es sich um die Verwaltung der Stack-Frames. Jede Funktion erzeugt und benutzt einen eigenen Speicherbereich (sog. Frame) auf dem Stack, und wenn die Funktion sich beendet, dann wird der Frame wieder freigegeben.

rsp ist der Stack Pointer. Darin steht immer die Adresse der "Spitze" des Stacks. rbp ist der Base Pointer, der die Anfangsadresse des aktuellen Frames enthält. Der Frame ist also gerade der Bereich zwischen rbp und rsp, und das Anlegen und Freigeben von Frames ist effektiv nur ein Verschieben der beiden Pointer. In klassischem x86-Assembler sieht der Code für eine Funktion daher so aus:

Code:
; die Register hießen damals BP und SP und waren 16 Bit groß; RBP und RSP sind die 64-Bit-Versionen

; der Basepointer der letzten Funktion wird gesichert, um ihn später wiederherstellen zu können
PUSH BP
; der alte Stackpointer wird zum neuen Basepointer, d.h. der neue Stackframe beginnt dort, wo der alte aufhört
MOV BP, SP
; die Größe des Stackframes wird festgelegt. xyz ist dabei die Anzahl Bytes, die für die lokalen Variablen der Funktion benötigt wird. Beachte, dass es SUB ist und nicht ADD, weil der Stack von oben nach unten "wächst", also von größeren zu kleineren Adressen.
SUB SP, xyz

-- hier kommt der eigentliche Code der Funktion

; der alte Stackpointer wird wiederhergestellt
MOV SP, BP
; der alte Basepointer wird wiederhergestellt, wir befinden uns wieder im alten Stackframe
POP BP
; Rücksprung an die Stelle im Code, wo die Funktion aufgerufen wurde
RET

Im Code von deinem g++ fehlen sowohl das "sub rsp, xyz" als auch das "mov rsp, rbp". Anscheinend ist das heute nicht mehr nötig. Warum, kann ich dir aber auch nicht sagen - hab mich zu lange nicht mehr mit Assembler beschäftigt.
 
Zuletzt bearbeitet:
Sehr cool!

Ich hab mir mal den Spaß gemacht, noch eine main-Funktion dazuzuschreiben, damit man auch sieht, wie die Funktion aufgerufen wird: https://godbolt.org/g/hW3FHs

Der Unterschied zwischen -O0 und -O1 ist schon enorm. -O1 macht alles, was geht, in Registern, so dass computeFirstBinom() noch nicht mal einen eigenen Stackframe aufbauen muss. Dagegen sieht -O0 hoffnungslos umständlich und naiv aus. Und in -O3 wird die Funktion gar nicht mehr aufgerufen, sondern in main() geinlinet...
 
Vielen Dank für die ausführlichen Erklärungen :hammer_alt:

Zu deinem Code NullPointer:

Die Funktion ist 'inline' anscheinend zwischen Zeile 23 und 28 zu finden. Wenn ich das richtig verstanden hab ist rax die Erweiterung von eax(32-bit) auf 64-bit? Im Assemblercode wird mal auf eax, mal auf rax zugegriffen, ist das eine Entscheidung, die der Compiler trifft, weil zu erwarten ist, dass der Wert in dem Register 4 Byte überschreiten könnte, oder wo kommt das her?

Dann müsste der Compiler ja ungefähr einschätzen könne, wie groß ein Wert in einem Register werden kann und nach Möglichkeit immer die kleinste Größe eines Registers zu verwenden? Also für eine Variable vom Datentyp byte nur das Register al verwenden?

Vielen Dank nochmals an alle, die hier geholfen haben :)
 
Zuletzt bearbeitet:
Der Compiler weiß, wie groß die Werte werden können, anhand der Datentypen, die du benutzt. Zu beachten ist hierbei, dass short, int und long keine allgemein festgelegten Größen haben; ihre Größe hängt vom Compiler und der Zielarchitektur ab. (Es gibt auch Integer-Typen mit fester Größe, die heißen dann (u)intn_t; n = 8, 16, 32, 64 ist die Anzahl Bits, u für unsigned). Also ja, der Compiler könnte sehen, dass du Bytes benutzt, und dann mit kleinen Registern rechnen.

Ob das sinnvoll ist, ist allerdings eine andere Frage. Ich empfehle eine Referenz wie http://agner.org/optimize/instruction_tables.pdf - da ist für alle möglichen CPU-Architekturen aufgeführt, wie viele µops welcher Maschinenbefehl mit was für Argumenten braucht. Dabei stellt sich heraus, dass kleinere Argumente nicht immer von Vorteil sind. Beispiel Skylake (ab S. 231): MUL mit einem 16-Bit-Register braucht 4 µops, mit einem 32-Bit-Register noch 3 und mit einem 64-Bit-Register gar nur noch 2 µops. IDIV erfordert für 8-Bit-Register mysteriöserweise 1 µop mehr als für 16- und 32-Bit-Register. Bei anderen Anweisungen ist es vollkommen egal: ADD, SUB, CMP, PUSH und POP etwa performen für alle Argumente genau gleich, solange es Register sind.
 
fu54XsP.png
Moderne Compiler sind unheimlich clever darin, möglichst effizienten Maschinencode zu generieren, obwohl das alles andere als trivial ist; insbesondere ist eine Optimierung nur dann zulässig, wenn sich der neue Code bei Überläufen exakt verhält wie der "Rohcode". Dazu zählen eben auch die Verwendung der richtigen Register-Größen.
 
Okay, vielen Dank für die Erklärungen.
Ging mir jetzt nicht primär darum, meine Programme dahingehend zu optimieren, mit kleineren Datentypen ein paar ticks rauszukitzeln, sondern das Verständis, warum rax, eax, etc. so variabel im Code auftauchen.

Abschließend noch zwei Verständisfragen zum Stack:

Jedes Programm bekommt im Speicher verschiedene Bereiche zugewiesen (data, text) und auch einen heap und einen stack? Und die Größe des Stacks wird über die verwendeten Variabeln geregelt indem der Compiler 'schaut' wie viele Daten aus den Registern ausgelagert werden müssen?

Ich bin mal auf ein Prgramm gestoßen, das zur Berechnung der Ackermann-Funktion die Verwaltung des Stack 'selber' in die Hand nimmt Code auf pastebin (C#) (Quelle)
Ich gehe mal davon aus, dass die Zielsetzung eine erhebliche Performanzsteigerung ist, da ackermann() ja bekanntlich ein Ressourcenfresser ist. Aber wie praxisnah ist sowas und wie gängig ist eine solche Stack-Selbstverwaltung?

Gibt es zu dem Themenkomplex Heap/Stack und Debugging mit Assembler Literatur, die ihr empfehlen könnt?


Mit freundlichen Grüßen

psi24
 
Zuletzt bearbeitet:
Das Anordnung der einzelnen Speicherbereiche eines Prozesses ist betriebssystemabhängig. Allerdings ist es meist ähnlich zu dieser Grafik (Linux Prozess Speicherlayout)[1]:
linuxFlexibleAddressSpaceLayout.png
Die unteren Bereiche sind der Programmcode, globale Variablen, und der Heap, welcher nach oben wächst. Von oben aus liegt zunächst Kernelspeicher und dann kommt der Stack, welcher nach unten wächst. Beide Bereiche wachsen also zur Mitte hin. Aus verschiedenen Gründen hat ein Prozess (= Programm in Ausführung) dabei den gesammten Speicher zugeordnet (also alle möglichen Adressen), der sogenannte virtuelle Speicher. Die Übersetzung auf reelle Adressen wird transparent durch Betriebssystem und CPU gehandhabt.
Einer der Gründe ist, dass der Compiler nicht mit Sicherheit die benötigte Stackgröße vorhersagen kann, da diese beispielsweise auch von Userinput abhängt. Die Art und Weise wie der Stack (per Konvention) vom Programm gehandhabt wird (Zugriffe, RSP&RBP) erlaubt es, dass die Größe aber auch garnicht vorher bekannt sein muss, lediglich die Offsets der Variablen einzelner Funktionen müssen (sollten) fix sein (daher gibt es in C/C++ zB keine lokalen Arrays dynamischer Länge ohne den Heap zu nutzen).

@NullPointer: Die sub Instruktionen für die Modifikation des Stack pointers sind nicht nötig, da die Instruktionen für Speicherzugriff einen Offset haben können, so kann auf Adressen zugegriffen werden, die nicht direkt in einem Register stehen.

1: http://duartes.org/gustavo/blog/post/anatomy-of-a-program-in-memory/
 
Bei der Ackermann-Funktion ist das Problem ja, dass eine "naive", rekursive Implementierung schon bei kleinen Eingabewerten zu extremen Verschachtelungstiefen führt. Diese wiederum schaden nicht nur der Performance (weil jeder rekursive Funktionsaufruf extra Leistung frisst), sondern sorgen auch bald dafür, dass der Stack überläuft und die Berechnung fehlschlägt.

Eine rekursive Implementation vermeidet diese Probleme - zumindest für eine kurze Weile, bis die zu berechnenden Werte größer werden als die Anzahl der Atome im Universum (ack(4, 4) :p). Dafür braucht es eine Datenstruktur, um die schon berechneten Werte zwischenzuspeichern. Der hier verwendete "OverflowlessStack" ist zwar von der Form her ein Stack, hat mit "dem" Programm-Stack aber nichts zu tun - er liegt wie alle dynamisch belegten Speicherbereiche im Heap. Es handelt sich also nicht im eigentlichen Sinne um "Stack-Selbstverwaltung", sondern lediglich um die Benutzung einer speziellen Datenstruktur. Das ist je nach Problemstellung absolut realistisch.
 
Zurück
Oben