Apparatus

Programming without the standard library

The C standard library is essential when writing C programs, or almost anything in any language for that matter. But can you do without? Sure! The standard library is just code, and like any code can be rewritten. But should you? Absolutely! In my opinion reinventing the wheel once in a while is both fun and instructive. Just don't do it in production!

In this post I'm rewriting the C runtime library and some syscall wrappers from scratch. The end result will be a statically linked executable interacting with the Linux kernel directly. Throughout these examples I'm running Ubuntu 19.10 on Intel x86-64, with the default GCC toolchain (version 9.2.1 with some distro specific patches). But because the final program doesn't depend on any external library (including any particular implementation of the standard library), it should be readily executable on any Linux running on x86-64.

Rewriting C runtime

Let's start with the good old hello world program.

#include <unistd.h>

const static char hello[] = "Hello, world!\n";

int main()
{
    write(1, hello, sizeof(hello) - 1);
    return 0;
}

The above program uses the low level write() API instead of the more familiar C standard IO functions. That's because it's straightforward to convert to code interfacing the kernel directly. The first argument is the file descriptor, in this case fd 1, a.k.a the standard output. The second and third arguments are the buffer and the nuber of bytes to write, respectively. Nothing surprising when compiled and run:

$ gcc -o hello hello.c
$ ldd hello
	linux-vdso.so.1 (0x00007fffabfaa000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ff8f565d000)
	/lib64/ld-linux-x86-64.so.2 (0x00007ff8f5875000)
$ ./hello 
Hello, world!

The output of the ldd command shows the dynamic libraries the hello executable is linked against. In addition to libc.so.6, the C standard library, there are libraries to handle loading the shared libraries to memory and making virtual system calls. An in-depth article on running a binary can be found in LWN.net.

The standard library shipped with a typical C compiler does more than just include the standard functions. It actually does stuff like initializing the runtime environment and finally ensuring that the main() function gets called with the correct arguments. When the main() function returns, it ensures that the program cleans after itself. The first part towards running a program without the standard library is decoupling our hello world program from the C runtime.

The actual starting point to a program is figured out by the linker when creating the final executable. With the GNU toolchain it defaults to the symbol _start.

#include <unistd.h>

const static char hello[] = "Hello, world!\n";

void _start()
{
    write(1, hello, sizeof(hello) - 1);
    _exit(0);
}

Note that our minimal runtime from scratch must explicitly exit the program. Returning from the main function is something that no longer exists at this level of abstraction. The program will happily keep executing instructions until requesting exiting from the kernel. Also note that we use the _exit() function instead of the more familiar exit() . The latter does more cleanup, and requires more services from the standard library we're working towards ditching.

Compiling the above program requires an extra command line argument -nostartfiles. It instructs GCC not to use its own startup routines that are included to every program by default.

$ gcc -nostartfiles -o hello2 hello2.c
$ ./hello2 
Hello, world!

Inlining syscalls

Next we need to understand how an userspace program like hello2 interacts with the Linux kernel. Fundamentally dangerous operations like writing bytes to a file, or terminating a running process, are handled by the kernel in a controlled way via system calls. System calls aren't invoked like regular functions. Functions like write and exit you see in typical C code aren't regular function calls, but wrappers around the actual system call interface. After all, a program cannot gain new privileges simply by calling function, i.e. adding a new call frame to the call stack and starting to execute code there.

The way that an userspace program yields to the kernel depends on the hardware and operating system. Linux running on x86 uses a particular interrupt that the process generates. When the kernel runs the dedicated syscall interrupt handler, it picks the system call number and arguments from the dedicated registers and takes it from there. x86-64 even has an instruction called syscall, which is named like that because it's the intended way to request a syscall.

Due to the platform specificness of the syscall interface, we must switch from C to assembly. It's helpful to understand about the basics of x86-64 calling conventions to understand the rest of the post. A good starting point to writing assembly is to let GCC generate the initial version for us. Running gcc -S hello2.c on the file above, and removing all unessential stuff (metadata, debugger aid etc.) from the result we get:

.text
  .section  .rodata
hello:
  .string   "Hello, world!\n"
  .text
  .globl    _start
_start:
  endbr64
  xorq      %rbp, %rbp   # This I added myself, see below
  movl      $14, %edx
  leaq      hello(%rip), %rsi
  movl      $1, %edi
  call      write        # syscall wrapper for write
  movl      $0, %edi
  call      _exit        # syscall wrapper for exit

The first thing the program does is set the value of the %rbp register to zero to indicate the bottom-most call frame. After that it just calls the two system call wrappers, storing the first, second and third arguments in registers %edi, %esi and %edx (or their 64-bit variants) respectively.

For this very simple program getting rid of the wrapper functions is extremely straightforward. Find a system call table, store the syscall number in the %eax register and replace the call instruction with the syscall instruction.

.text
  .section  .rodata
hello:
  .string   "Hello, world!\n"
  .text
  .globl    _start
_start:
  endbr64
  xorq      %rbp, %rbp
  movl      $14, %edx
  leaq      hello(%rip), %rsi
  movl      $1, %edi
  movl      $1, %eax
  syscall               # directly invoke sys_write (syscall #1)
  movl      $0, %edi
  movl      $60, %eax
  syscall               # directly invoke sys_exit (syscall #60)

And that's it! The similarity of passing arguments to the syscall wrapper function, and to the actual syscall suggests that the wrapper functions are very simple under the hood. After dropping the external function calls, the program can be compiled with the self-explanatory -nostdlib option.

$ gcc -nostdlib -o hello3 hello3.s
$ ldd hello3
	statically linked
$ ./hello3
Hello, world!

As you can see, the binary isn't linked with any other library yet it produced the friendly greeting with just a few lines of assembly.