Ich hab mich schon öfter gefragt, was man alles tun muss, um einen JIT-Compiler zu schreiben. Oder anders gefragt, wie kann man zur Laufzeit Maschinencode generieren und ausführen.
Zunächst einmal benötigt man Speicher, der es überhaupt erlaubt, dass davon Code ausgeführt werden kann. Wenn man mit malloc Speicher alloziert, ist dieser nicht ausführbar. Intern verwendet malloc mmap und das kann auch einfach direkt genutzt werden. Dabei kann man direkt die Zugriffsrechte für den Speicher festlegen. Man könnte sie allerdings auch im nachhinein mit mprotect ändern. Mit mmap werden auch Dateien in den Speicher gemapped, durch das Flag MAP_ANONYMOUS
liefert der Kernel aber ganz ohne Datei den gewünschten Speicher.
void *execmem = mmap(NULL, len, PROT_EXEC | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0, 0);
Jetzt haben wir ausführbaren Speicher. Wenn dort Instructions reingeladen werden, können diese ausgeführt werden. Mein erster Versuch war einfach mit memcpy
von einem Function-Pointer dort ein paar Bytes rein zu kopieren. Das hat zwar funktioniert, auch wenn ich nicht wusste wie viele Bytes die Funktion eigentlich groß ist und ich einfach eine größere Menge kopiert habe, aber das wäre für diesen ohnehin schon etwas hackigen Blogartikel etwas zu unsauber.
Glücklicherweise hat Intel kürzlich eine Bibliothek für das decoden und encoden von x86-Maschinencode veröffentlicht. Damit ist es mir gelungen nur die Instructions der Funktion zu kopieren.
Um den Maschinencode zu decoden braucht man erstmal einen Pointer auf den Code, in meinem Fall einen Funktions-Pointer.
const xed_uint8_t *inst = (const xed_uint8_t*)func;
Einen Befehl decoden macht folgender Code:
xed_decoded_inst_t dec;
xed_error_enum_t error;
// init xed_decoded_inst_t struct
memset(&dec, '\0', sizeof(xed_decoded_inst_t));
xed_decoded_inst_set_mode(&dec, XED_MACHINE_MODE_LONG_64, XED_ADDRESS_WIDTH_64b);
// decode instruction
error = xed_decode(&dec, inst, 15);
Dies liest 15 Bytes (die Maximalgröße einer Instruction) und dekodiert die Instruction. Wie groß diese dann ist, kann mit xed_decoded_inst_get_length
abgefragt werden. Mit der Länge kann man dann zur nächsten Instruction springen.
Man kann diese auch als Assembler-String formatieren:
// print instruction
xed_format_context(XED_SYNTAX_ATT, &dec, buffer, 1024, 0, 0, 0);
printf("%s\n", buffer);
Hier ist das fertige Beispielprogramm. Kompiliere ich das ohne Optimierung und führe es aus ist die Ausgabe:
$ ./x86dec
copy function code:
pushq %rbp
mov %rsp, %rbp
movl %edi, -0x4(%rbp)
movl %esi, -0x8(%rbp)
movl -0x8(%rbp), %eax
movl -0x4(%rbp), %edx
add %edx, %eax
popq %rbp
retq
execute new code:
f(10, 50) = 60
Das ganze erfüllt jetzt natürlich keinen Zweck. Spannend wird es erst, wenn man eigenen Code generiert und den dann ausführt.
Was man noch erwähnen sollte ist, dass CPUs separate Data- und Instruction-Caches haben. Glücklicherweise muss man sich bei x86-CPUs keine Sorgen darüber machen, da dort erkannt wird, wenn Speicher modifiziert wird, der gerade auch im Instruction-Cache ist. Hingegen bei RISC-Architekturen, z.B. ARM, muss meistens der Instruction-Cache manuell aktualisiert werden.
Siehe auch: Self-Modifying Code and the Cache on Intel x86
Wenn man ein Verzeichnis liest und von allen enthaltenen Dateien die Extended Attributes erhalten will, gibt es zwei Möglichkeiten:
- Man fügt zum Verzeichnispfad den Dateinamen hinzu und nutzt den neu erhaltenen Pfad mit den Syscalls
listxattr
oder getxattr
.
- Mit dem Filedescriptor des Verzeichnisses und
openat
öffnet man die Dateien und nutzt dann flistxattr
und getxattr
.
Ich hab mich gefragt was schneller ist. Dazu habe ich ein kleines Testprogramm geschrieben. Dieses kann mit unterschiedlichen Preprocessor-Optionen kompiliert werden. So habe ich 4 Testprogramme erstellt. Für getxattr
und fgetxattr
jeweils ein Programm, das ein Attribut liest und eines das 32 Attribute liest.
Bei einem Verzeichnis mit 128.000 Dateien hab ich folgende Werte erhalten:
getxattr:1 getxattr:32 fgetattr:1 fgetattr:32
------------------------------------------------
246100055 654704421 456172044 749849574
230183311 663632162 457183706 769223423
247109480 654775136 440397212 743349119
Die Datei erst zu öffnen um dann fgetxattr
zu nutzen ist also langsamer. Erst als ich das Programm so modifiziert habe, dass es mehrere hundert Attribute liest, war es etwas schneller. Das ist jedoch ein eher unrealistisches Szenario. Allerdings war das ganze generell sehr schnell, so dass es eigentlich egal ist, welche Methode man anwendet.
Ein einfaches Tool für Benchmarks von Festplatten bzw. Dateisystemen ist bonnie++. Man startet es einfach und es werden im aktuellen Arbeitsverzeichnis verschiedene Tests durchgeführt.
Hier mal ein Beispiel, auch wieder auf einem Raspberry Pi:
$ bonnie++
Writing with putc()...done
Writing intelligently...done
Rewriting...done
Reading with getc()...done
Reading intelligently...done
start 'em...done...done...done...
Create files in sequential order...done.
Stat files in sequential order...done.
Delete files in sequential order...done.
Create files in random order...done.
Stat files in random order...done.
Delete files in random order...done.
Version 1.03e ------Sequential Output------ --Sequential Input- --Random-
-Per Chr- --Block-- -Rewrite- -Per Chr- --Block-- --Seeks--
Machine Size K/sec %CP K/sec %CP K/sec %CP K/sec %CP K/sec %CP /sec %CP
raspi.local 1G 3565 95 20890 44 9180 19 3822 97 24020 27 1060 23
------Sequential Create------ --------Random Create--------
-Create-- --Read--- -Delete-- -Create-- --Read--- -Delete--
files /sec %CP /sec %CP /sec %CP /sec %CP /sec %CP /sec %CP
16 3930 96 +++++ +++ 6338 106 3920 94 +++++ +++ 5559 96
raspi.local,1G,3565,95,20890,44,9180,19,3822,97,24020,27,1059.6,23,16,3930,96,+++++,+++,6338,106,3920,94,+++++,+++,5559,96
Die Tests mit putc()
und getc()
sind recht CPU-lastig, da hier Zeichen einzelnd geschrieben oder gelesen werden. Die block-basierten Tests hingegen zeigen gut den IO-Durchsatz.
Was auch auffällt, bei den Create-Tests fehlen die Werte für Read. Dies liegt daran, das in meinem Fall die Tests zu schnell fertig waren und dadurch keine genauen Ergebnisse berechnet werden können. Bonnie++ zeigt daher lieber keinen Wert an.
Mit iperf kann man sehr einfach sein Netzwerk benchmarken. Dazu startet man auf einem Computer einen iperf-Server:
$ iperf -s
------------------------------------------------------------
Server listening on TCP port 5001
TCP window size: 85.3 KByte (default)
------------------------------------------------------------
Und auf einem anderen Computer startet man den iperf-Client und gibt den Server an (in diesem Fall rpi):
$ iperf -c rpi
------------------------------------------------------------
Client connecting to rpi, TCP port 5001
TCP window size: 85.0 KByte (default)
------------------------------------------------------------
[ 3] local 192.168.178.170 port 39890 connected with 192.168.178.28 port 5001
[ ID] Interval Transfer Bandwidth
[ 3] 0.0-10.0 sec 114 MBytes 95.5 Mbits/sec
Man sieht, ein Raspberry Pi 1 kann sein 100 Mbit/s LAN gut auslasten. Allerdings hat das bei mir auch zu 75% CPU-Last geführt.