Clib-Horror
ein kleines Experiment zur Größe von C-Binaries

Geschrieben von 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 printf bietet 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 von printf wird das erzeugte Programm aufblähen.

putf()

Die Funktion puts nutzt wie printf das <stdio.h>-Ausgabesystem, braucht aber keine Formatierung. Das erzeugte Programm sollte also deutlich kleiner sein.

write()

Die Funktion write ist nur der Wrapper für einen Betriebssystem-Aufruf. Das erzeugte Programm sollte also sehr klein sein.

Bei diesem Programm nutzen wir eine eigene strlength-Funktion, um eine Verfälschung durch den Import von <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;
}
Programm 1 nutzt printf
#include <stdio.h>

int
main( int argc, const char** argv){

	(void) argc;

	puts( argv[0] );
	return 0;
}
Programm 2 nutzt 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;
}
Programm 3 nutzt 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.c

Achtung: 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.c

Wir 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.c

Diesmal 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.