Clib-Horror
ein kleines Experiment zur Größe von C-Binaries
Geschrieben von Wolf am .
Dynamisch gelinkte Binaries.
Jedes der drei Programme gibt seinen Namen aus, welcher der C-Funktion main()
als erstes Element des Arrays argv übergeben wird.
Dabei werden drei verschiedene Funktionen zur Ausgabe der Zeichenkette genutzt:
printf()-
Die Funktion
printfbietet umfangreiche Möglichkeiten zur formatierten Ausgabe von Zeichenketten und Zahlen. Da die gewünschten Formate bei der Übersetzung und beim Linken nicht bekannt sind (das Format könnte z. B. aus einer Datei gelesen werden), müssen alle Formatierfunktionen eingebunden werden. Die Nutzung vonprintfwird das erzeugte Programm aufblähen. putf()-
Die Funktion
putsnutzt wieprintfdas<stdio.h>
-Ausgabesystem, braucht aber keine Formatierung. Das erzeugte Programm sollte also deutlich kleiner sein. write()-
Die Funktion
writeist nur der Wrapper für einen Betriebssystem-Aufruf. Das erzeugte Programm sollte also sehr klein sein.Bei diesem Programm nutzen wir eine eigene
-Funktion, um eine Verfälschung durch den Import vonstrlength<string.h>zu vermeiden.
Die Programme:
#include <stdio.h>
int
main( int argc, const char** argv){
(void) argc;
printf( "%s\n", argv[0] );
return 0;
}
printf#include <stdio.h>
int
main( int argc, const char** argv){
(void) argc;
puts( argv[0] );
return 0;
}
puts#include <unistd.h>
#define OUT_FILEHANDLE (1l)
static size_t
stringlen( const char * s ){
size_t len = 0;
while( *s ) { ++len; ++s; }
return len;
}
int
main( int argc, const char** argv){
(void) argc;
const size_t argv_0_len = stringlen( argv[0] );
int result;
result = write( OUT_FILEHANDLE, argv[0], argv_0_len );
result = write( OUT_FILEHANDLE, "\n" , 1 );
(void) result;
return 0;
}
writeÜbersetzt wird jeweils mit den Parametern:
gcc -Werror -Wall -Wextra -pedantic -O2 -march=x86-64 -fno-stack-protector -o {prog} {prog.c}
strip {prog}
So kompiliert erhalten wir diese Dateigrößen.
| Größe | Binary |
|---|---|
| 6120 | hello-printf-dynamic |
| 6120 | hello-puts-dynamic |
| 6120 | hello-write-dynamic |
Die Programme sind gleich groß, oder besser klein. Das war zu erwarten, weil die Implementierung der Funktionen erst zur Laufzeit als dynamische Laufzeitbibliothek nachgeladen wird.
Statisch gelinkte Binaries.
Als nächstes linken wir statisch, indem wir dem Compiler-Aufruf ein -static
beifügen. Mit diesem zuerst überraschenden Ergebnis:
| Größe | Binary |
|---|---|
| 774760 | hello-printf-static |
| 774760 | hello-puts-static |
| 774760 | hello-write-static |
Obwohl wir nur kleine und dazu unterschiedliche Teile der Clib nutze, sind die erzeugten Binaries sehr und dazu gleich groß.
Was geht hier vor?
Statisch gelinkte Binaries ohne Aufruf einer Funktion der Clib.
Möglicherweise hat der Aufruf von write irgendwie Komponenten der Clib nachgezogen.
Um das auszuschließen, implementieren wir den Betriebssystem-Aufruf selbst und tilgen so alle
Referenzen auf die Clib:
#include <string.h>
#include "syscall.h"
#define OUT_FILEHANDLE (1l)
static size_t
stringlen( const char * s ){
size_t len = 0;
while( *s ) { ++len; ++s; }
return len;
}
int
main( int argc, const char** argv){
(void) argc;
const size_t argv_0_len = stringlen( argv[0] );
(void) do_syscall( SYS_WRITE, OUT_FILEHANDLE, argv[0], argv_0_len );
(void) do_syscall( SYS_WRITE, OUT_FILEHANDLE, "\n", 1 );
return 0;
}
hello-syscall.cAchtung: Die folgen beiden Code-Schnipsel passen nur zu einem Linux mit x64-Prozessor.
#define SYS_WRITE ( 1l)
#define SYS_EXIT (60l)
extern long
do_syscall( long number, ... );
syscall.h#include "syscall.h"
__attribute__ (( noinline ))
long
do_syscall( long number, ... ){
(void) number;
__asm__(
"mov %rdi,%rax \n" // Syscall#
"mov %rsi,%rdi \n" // 1st Arg
"mov %rdx,%rsi \n" // 2nd Arg
"mov %rcx,%rdx \n" // 3rd Arg
"mov %r8,%r10 \n" // 4th Arg
"mov %r9,%r8 \n" // 5th Arg
"mov 0x8(%rsp),%r9 \n" // 6th Arg
"syscall \n"
"retq \n"
);
// not reached
return -1;
}
syscall.cWir compilieren:
gcc -Werror -Wall -Wextra -pedantic -O2 -march=x86-64 -fno-stack-protector -o hello-syscall-static -static hello-syscall.c syscall.c
strip hello-syscall-static
Und werden enttäuscht, es hat sich nichts getan:
| Größe | Binary |
|---|---|
| 774760 | hello-syscall-static |
Verzicht auf crt0.o.
Nun ruft das Betriebssystem nicht main auf, sondern den Programm-Einsprungpunkt
_start. Und der wird von der Startdatei crt0.o
bereitgestellt, die der Linker automatisch unserem Programm hinzufügt.
Wollen wir dies nicht, können wir das dem Linker über die Option -nostartfiles
mitteilen:
gcc -Werror -Wall -Wextra -pedantic -O2 -march=x86-64 -fno-stack-protector \
-o hello-syscall-static-tinystartfile \
-static hello-syscall.c syscall.c starter-callexit.c -nostartfiles
Dann müssen wir aber den Einsprungpunkt _start selber bereitstellen.
Achtung: Dieser Code-Schnipsel passt nur zu einem Linux mit x64-Prozessor.
#include <unistd.h>
// _start is the entry point known to the linker
__attribute__((force_align_arg_pointer))
void
_start( void ){
//--------------------------------------------------------------
// on entry:
//--------------------------------------------------------------
// [sp] n # -> argc
//--------------------------------------------------------------
// [sp + 8] &arg_0 @ -> argv
// &arg_1
// [..]
// &arg_n-1
// [sp + 8 + n*8] null
//--------------------------------------------------------------
// [sp +16 + n*8] &env_0 @ -> envp
// &env_1
// [..]
// &env_m-1
// null
//--------------------------------------------------------------
__asm__(
"xor %ebp, %ebp \n" // mark end of stack frames
"mov (%rsp), %edi \n" // register edi = 1st arg of main (argc) <- n
"lea 8(%rsp), %rsi \n" // register rsi = 2nd arg of main (argv) <- address of arg_0
"lea 16(%rsp,%rdi,8), %rdx \n" // register rdx = 3rd arg of main (envp) <- address of env_0
"xor %eax, %eax \n" // register eax <- 0 (ABI)
"call main \n" // main( %edi, %rsi, %rdx )
"mov %eax, %edi \n" // register edi = 1st arg of _exit <- return value of main()
"xor %eax, %eax \n" // register eax = 0 (ABI)
"call _exit \n" // _exit() terminates program
// not reached
"hlt \n" // illegal instruction in user mode
);
}
starter-callexit.cDiesmal ist das Ergebnis positiv: Das so erzeugte Binary ist massiv geschrumpft:
| Größe | Binary |
|---|---|
| 1352 | hello-syscall-static-tinystartfile |
Offensichtlich ruft das crt0.o eine Menge von Funktionen der Clib
auf, die wir für unser einfaches Programm überhaupt nicht brauchen. ☹
Wenn wir in unserer Start-Datei den Aufruf von _exit durch einen direkten Aufruf des
Betriebssystems ersetzen, sparen wir noch ein paar Byte:
#include "syscall.h"
// _start is the entry point known to the linker
__attribute__((force_align_arg_pointer))
void
_start( void ){
//--------------------------------------------------------------
// on entry:
//--------------------------------------------------------------
// [sp] n # -> argc
//--------------------------------------------------------------
// [sp + 8] &arg_0 @ -> argv
// &arg_1
// [..]
// &arg_n-1
// [sp + 8 + n*8] null
//--------------------------------------------------------------
// [sp +16 + n*8] &env_0 @ -> envp
// &env_1
// [..]
// &env_m-1
// null
//--------------------------------------------------------------
__asm__(
"xor %ebp, %ebp \n" // mark end of stack frames
"mov (%rsp), %edi \n" // register edi = 1st arg of main (argc) <- n
"lea 8(%rsp), %rsi \n" // register rsi = 2nd arg of main (argv) <- address of arg_0
"lea 16(%rsp,%rdi,8), %rdx \n" // register rdx = 3rd arg of main (envp) <- address of env_0
"xor %eax, %eax \n" // register eax <- 0 (ABI)
"call main \n" // main( %edi, %rsi, %rdx )
"mov %eax, %edi \n" // register edi = 1st arg of syscall <- return value of main()
"movl $60, %eax \n" // register eax = syscall-number <- EXIT(60)
"syscall \n"
);
}
starter-syscall.c| Größe | Binary |
|---|---|
| 1088 | hello-syscall-static-tinystartfile-nouseexit |
Wenn wir in den letzten beiden Versuchen zur Ausgabe write
statt syscall aufrufen, wird unser Binary wieder deutlich größer:
| Größe | Binary |
|---|---|
| 1352 | hello-syscall-static-tinystartfile |
| 1088 | hello-syscall-static-tinystartfile-nouseexit |
| 1920 | hello-syscall-static-tinystartfile |
| 1784 | hello-syscall-static-tinystartfile-nouseexit |
Es ist erschreckend, wie viel Code ein einfacher Wrapper erzeugt.
Résumé.
Statisch gelinkte Binaries sind größer, als sie technisch sein müssten, weil Komponenten eingebunden werden, die nicht benutzt werden. Dies ist unnötig, denn der Linker hat, wie statische Initialisierer zeigen, durchaus die Fähigkeit, auch indirekt genutzte Komponenten nur bei Bedarf einbinden.
Offensichtlich gehen die Designer der (GNU-)Clib davon aus, dass:
- große Teile der Clib immer gebraucht werden und/oder
- Programmgröße keine Rolle spielt und/oder
- eh dynamisch gelinkt wird.
Schade.
Sie können ein Paket mit allen verwendeten Dateien als clib-horror.zip herunterladen.