you can safely turn off darkreader :)

Writing a Linux executable by hand

I’ve built a handful of toy programming languages over the years, purely for the fun of it. Interpreters are a fun weekend project, and byte-code VMs keep me busy for a couple of weeks. However, the closest I’ve come to building a compiler is a (very) simple front-end for LLVM, which quickly joined my project graveyard because I get burnt out too easily from C++.

Recently, I’ve been itching to give a compiler another shot, without LLVM. This got me thinking:

I don’t really understand executable files.

If I want to build a compiler, I should at least be familiar with what a compiler is supposed to output. All I knew before I started this journey was that executable files are binary, which means that I should be able to learn the binary format and produce executables that conform to it. Theoretically, I should even be able to do so manually, byte-by-byte, without a compiler.

Right?

As it turns out, yes! And honestly, it’s shockingly straightforward. This post is a retelling of how I learned what (almost) every byte in an executable means and how to create a working hello world executable without a compiler.

The target

Before we start slinging bytes at files, we need to talk about the target.

Executable files are famously not very portable. They are written for a specific type of OS version and CPU architecture. This is called the target.

Since I run the people’s OS on a Ryzen 9 7950X3D CPU, my target will be Linux x86_64.

If you’re also on Linux, or have access to Linux through something like WSL or a VM, and you have an x86_64 CPU, then you can follow along and write your own executable too. If you’re on a different CPU architecture, you’ll need to deviate in a few places. An LLM should be able to help you here.

If you’re neither on Linux nor on x86_64: I dunno, enjoy reading I guess.

Where baby elves come from

Executables on Linux use a format called ELF, as do shared libraries & relocatable objects. It’s very well and openly documented; Wikipedia alone is an amazing reference for the format.

The general structure of an ELF file is as follows:

  • one ELF header
  • a program header table
  • optional section header table
  • the binary data referenced by the above tables

The file begins with a section that is called the “ELF header”. It provides various details about the file, such as its format, target, and where the data can be found. On 64-bit systems, this header is just 64 bytes.

If you’re on Linux, you can use readelf -h <FILE> to read the ELF header of an executable and print the information in a readable format.

For example, here’s what readelf says about ls:

$ readelf -h /usr/bin/ls
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x54f0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          160680 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         14
  Size of section headers:           64 (bytes)
  Number of section headers:         28
  Section header string table index: 27

The first 16 bytes of the ELF header are known as the identification string (e_ident). It starts with the “magic number”, which is just 0x7F followed by the string “ELF” in ASCII: 0x45 0x4c 0x46.

If you ever need to programmatically check if a file is an ELF file, you only need to check the first 4 bytes!

I won’t bore you with a byte-by-byte explanation of the entire header. The ELF wikipedia page has a great table that you can reference, and in fact I encourage you to take a few minutes to skim through it to get an idea of what we’ll be doing in the next section. We learn by doing around here, so let the doing begin.

Let’s write some bytes

First, we need a way to write raw binary data directly to a file. A text editor won’t work, since that will encode everything as text. We could use a hex editor, but we’re programmers; we can write a program to do this for us.

Let’s start by writing the ELF header’s identification string to a file.

 1#include <stdio.h>
 2#include <stdint.h>
 3
 4int main() {
 5    FILE *f = fopen("my_elf", "w");
 6
 7    uint8_t ident[16] = {
 8        0x7F, 'E', 'L', 'F', // magic string
 9        2,                   // 64-bit file format
10        1,                   // little-endian
11        1,                   // ELF version
12        0,                   // no particular ABI
13        0,                   // ABI version
14        0, 0, 0, 0, 0, 0, 0, // 7 bytes of padding
15    };
16    fwrite(ident, sizeof(uint8_t), sizeof(ident), f);
17
18    fclose(f);
19}

Next is the object file type, which is two bytes. For executables, this should be a 0x0002.

 1#include <stdio.h>
 2#include <stdint.h>
 3
 4int main() {
 5    FILE *f = fopen("my_elf", "w");
 6
 7    uint8_t ident[16] = { /** omitted for brevity */ };
 8    fwrite(ident, sizeof(uint8_t), sizeof(ident), f);
 9
10    uint16_t type = 0x0002;
11    fwrite(&type, sizeof(uint16_t), 1, f);
12
13    fclose(f);
14}

Now, we could keep going and write the rest of the file like this - and that’s what I initially did - but as it turns out, there’s a much better way.

I don’t recall exactly what I was searching for, but I stumbled upon this ELF man page and was surprised to see it mention elf.h.

Is this a real C header file? And is it shipped with Linux?

$ find /usr/include -name elf.h
/usr/include/elf.h
/usr/include/asm/elf.h
/usr/include/sys/elf.h
/usr/include/linux/elf.h

Yes, and yes. Cool. What’s in it?

Oh, nothing much, just THE FREAKING TYPEDEFS for all ELF-related things!

 1typedef struct
 2{
 3    unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
 4    Elf64_Half    e_type;             /* Object file type */
 5    Elf64_Half    e_machine;          /* Architecture */
 6    Elf64_Word    e_version;          /* Object file version */
 7    Elf64_Addr    e_entry;            /* Entry point virtual address */
 8    Elf64_Off     e_phoff;            /* Program header table file offset */
 9    Elf64_Off     e_shoff;            /* Section header table file offset */
10    Elf64_Word    e_flags;            /* Processor-specific flags */
11    Elf64_Half    e_ehsize;           /* ELF header size in bytes */
12    Elf64_Half    e_phentsize;        /* Program header table entry size */
13    Elf64_Half    e_phnum;            /* Program header table entry count */
14    Elf64_Half    e_shentsize;        /* Section header table entry size */
15    Elf64_Half    e_shnum;            /* Section header table entry count */
16    Elf64_Half    e_shstrndx;         /* Section header string table index */
17} Elf64_Ehdr;

It contains struct types for the ELF header (seen above), program headers, constant definitions for the field values, and so much more.

The struct types in particular are incredibly helpful, since we can now instantiate and write a struct, rather than each byte procedurally.

We’ll start by creating just an ELF header for now. This should be as easy as importing the header file, creating a struct of type Elf64_Ehdr, and assigning a value to every field. The provided constants do a great job of increasing readability.

 1#include <elf.h>
 2#include <stdint.h>
 3#include <stdio.h>
 4
 5int main() {
 6    Elf64_Ehdr e_hdr = {
 7        .e_ident =
 8            {
 9                ELFMAG0,       // 0x7f
10                ELFMAG1,       // "E"
11                ELFMAG2,       // "L"
12                ELFMAG3,       // "F"
13                ELFCLASS64,    // 64-bit
14                ELFDATA2LSB,   // little-endian
15                EV_CURRENT,    // elf v1
16                ELFOSABI_NONE, // no particular ABI
17                0,             // ABI version
18            },
19        .e_type = ET_EXEC,                 // type = executable file
20        .e_machine = EM_X86_64,            // targeting x86_64 processors
21        .e_version = EV_CURRENT,           // always 1 (EV_CURRENT)
22        .e_entry = 0,                      // [*] memory address of 1st instruction
23        .e_phoff = sizeof(Elf64_Ehdr),     // [*] file offset of program header tables
24        .e_shoff = 0,                      // file offset of section header tables
25        .e_flags = 0,                      // always 0
26        .e_ehsize = sizeof(e_hdr),         // size of the ELF header
27        .e_phentsize = sizeof(Elf64_Phdr), // size of a program header
28        .e_phnum = 2,                      // [*] number of program headers
29        .e_shentsize = sizeof(Elf64_Shdr), // size of a section header
30        .e_shnum = 0,                      // number of section headers
31        .e_shstrndx = SHN_UNDEF,           // not important
32    };
33
34    FILE *f = fopen("my_elf", "w");
35    fwrite(&e_hdr, sizeof(e_hdr), 1, f);
36    fclose(f);
37}

The comments should help explain most of it, but I’ve also marked 3 fields with [*] to elaborate further:

  • The e_entry field is 0 for now since we don’t yet know the memory address of the first instruction. We will update this later.
  • The program header table is next after the ELF header in the file, so the offset e_phoff is just the size of a single ELF header. Makes sense?
  • The e_phnum field is 2 because we will need 2 program headers for our executable. I’ll explain why in the next section.

We will also be ignoring section headers for today, since we don’t need them. So we keep e_shoff and e_shnum set to 0.

And that’s the ELF header done!

Believe it or not, this is almost a valid ELF file. Try compiling and running the program to produce a my_elf file, then run readelf -h my_elf. You should also get a warning about the lack of program headers, since the file claims that it has 2 of them, when in fact it has none. Let’s address that next.

Program headers

Program headers tell the kernel how to set up the memory of the process. They describe a single memory segment, and tell the kernel which part of our binary file contains the data that we want to load into it.

For our hello world executable, we will need 2 memory segments:

  1. one that contains the data (the hello world string)
  2. one that contains the instructions

After we have written these 2 program headers to the file, we will then be writing the actual data and the instructions. Hopefully now you can start to see how the file will be coming together.

Let’s start with the program header for the data memory segment.

 1#define V_ADDR_BASE 0x400000
 2#define PAGE_SIZE 0x1000
 3#define EHDR_SIZE sizeof(Elf64_Ehdr)
 4#define PHDR_SIZE sizeof(Elf64_Phdr)
 5
 6unsigned char data[] = "Hello, world!\n";
 7unsigned long data_len = sizeof(data);
 8unsigned long data_offset = EHDR_SIZE + (2 * PHDR_SIZE);
 9unsigned long data_vaddr = V_ADDR_BASE + PAGE_SIZE + data_offset;
10
11Elf64_Phdr data_phdr = {
12    .p_type = PT_LOAD,       // type: loadable segment
13    .p_flags = PF_R,         // flags: readable
14    .p_offset = data_offset, // offset in file where the data is found
15    .p_vaddr = data_vaddr,   // virtual address of the memory segment
16    .p_paddr = data_vaddr,   // physical address - not important
17    .p_filesz = data_len,    // segment size in file
18    .p_memsz = data_len,     // segment size in memory
19    .p_align = PAGE_SIZE,    // segment alignment
20};

Again, I hope the comments help make most of the code self-explanatory, with perhaps only a few things needing further clarification:

The p_align field tells the kernel what byte alignment this segment expects. The key idea is that the segment should line up byte-wise the same way in the file as it does in memory, so p_offset and p_vaddr need to agree modulo p_align. In normal Linux executables this is usually 0x1000 (4 KiB), the memory page size, because memory is mapped in pages.

The p_offset field points to the actual data inside the ELF file. Recall that the file starts with an ELF header, then we’ll have 2 program headers, and then the contents of the memory segments. So the data_offset is just the size of the ELF header plus 2 program headers.

The p_vaddr field is the virtual memory address that we want to assign to this segment. Why 0x400000? From what I could find, this seems to be a convention. Don’t get too hung up on this - it’s just a virtual address. What’s important is that the data in memory is byte-aligned in the same way as it is in the file. We can easily guarantee this by just adding the page size and file offset.


Now let’s do the same thing for the instructions’ memory segment.

 1unsigned char instructions[] = {}; // we will populate this in the next section
 2unsigned long inst_len = sizeof(instructions);
 3unsigned long inst_offset = data_offset + data_len; // immediately after the data
 4unsigned long inst_vaddr = V_ADDR_BASE + PAGE_SIZE + inst_offset;
 5
 6Elf64_Phdr inst_phdr = {
 7    .p_type = PT_LOAD,       // type = loadable segment
 8    .p_flags = PF_R | PF_X,  // flags = readable & executable
 9    .p_offset = inst_offset, // offset in file where the data is found
10    .p_vaddr = inst_vaddr,   // virtual address of the memory segment
11    .p_paddr = inst_vaddr,   // physical address - not important
12    .p_filesz = inst_len,    // segment size in file
13    .p_memsz = inst_len,     // segment size in memory
14    .p_align = PAGE_SIZE,    // segment alignment
15};

Since this memory segment will contain instructions, we need to also include the PF_X flag to mark it as executable.

Note that this time the offset of the instructions is dependent on the previous memory segment. Since we will be writing the data first and the instructions second, the offset for the instructions needs to point to just after the end of the data, i.e. the data offset plus the data length.

This is also a good time to go back to the ELF header and update the e_entry field, since now we know what the memory address of the first instruction is: inst_vaddr. This means we will also need to move the ELF header struct below the instructions program header in the code, so that we can reference the inst_vaddr variable in the ELF header struct.

Machine code, beep boop

Now we get to the interesting part! We need to generate the machine code for a hello world program. The easiest way to do this is to start with assembly, and then assemble it.

Here’s what a hello world program might look like in assembly, using NASM syntax. I personally find NASM to be the most readable for beginners.

 1; write(stdout, &msg, msg_len)
 2mov    rax, 1    ; rax = 1 (write)
 3mov    rdi, 1    ; rdi = 1 (stdout)
 4movabs rsi, 0    ; rsi = &msg    (0 for now)
 5mov    rdx, 0    ; rdx = msg_len (0 for now)
 6syscall
 7
 8; exit(69)
 9mov rax, 60      ; rax = 60 (exit)
10mov rdi, 69      ; rdi = 69
11syscall

(Note: the above is not a complete NASM program. It lacks section labels, an entry point, and the message itself. But we’re only interested in the instructions here.)

To print hello world, our program needs to tell the kernel to write a string to its standard output stream (STDOUT). We do this by calling the write system call, which you can think of as kernel functions. When the syscall instruction is invoked, the kernel will look at the rax register to see what syscall we want to invoke. Some of the other registers, like rdi, rsi, and rdx, are used to pass arguments to syscalls.

We also need to explicitly tell the program to stop running with the exit syscall, which takes the exit code as argument. If we omit this syscall, the program will attempt to run whatever instruction is found next in memory after the write syscall, which may not be an instruction and cause undefined behavior. Always include an exit! Or don’t hand-roll executables!

There are many references for Linux syscalls. The Chromium project has a very extensive one that you can find here.

As you can see in our program, we call the write syscall with 3 arguments: 1 for STDOUT, 0 for the message pointer, and 0 for the message length. We will update the pointer and length later, once we have the compiled machine code.

To turn our assembly into machine code, go to x86_64 playground, switch the compiler to NASM (using the menu next to the Compile button), paste the above assembly code and click Compile.

We’re interested in the Disassembly section. Copy the second column of each instruction, up until the last syscall instruction. You should have something like this:

b801000000
bf01000000
48 be0000000000000000
ba00000000
0f 05
b83c000000
bf45000000
0f 05

This is the machine code equivalent of the hello world assembly. The next step is splitting the above code into bytes so that we can store it in our instructions array in the source code.

How do we do that? Easy. The above machine code is in hexadecimal. A single byte goes up to 255, which is ff in hex. That’s 2 hexadecimal characters. So every 2 characters in the machine code is a single byte. And in C, hex literals need to be prefixed with 0x.

So we end up with this:

 1unsigned char instructions[] = {
 2    0xb8, 0x01, 0x00, 0x00, 0x00, // mov    rax, 1 | "write" syscall
 3    0xbf, 0x01, 0x00, 0x00, 0x00, // mov    rdi, 1 | 1st arg: stdout
 4    0x48, 0xbe, 0x00, 0x00, 0x00, // movabs rsi, ? | 2nd arg: ptr to msg
 5    0x00, 0x00, 0x00, 0x00, 0x00, //               |
 6    0xba, 0x00, 0x00, 0x00, 0x00, // mov rdx, ?    | 3rd arg: msg length
 7    0x0f, 0x05,                   // syscall       | invoke syscall
 8    0xb8, 0x3c, 0x00, 0x00, 0x00, // mov rax, 60   | "exit" syscall
 9    0xbf, 0x45, 0x00, 0x00, 0x00, // mov rdi, 69   | 1st arg: exit code
10    0x0f, 0x05,                   // syscall       | invoke syscall
11};

There’s just one thing missing: we need to change the message pointer and length from zeroes to their real value.

Let’s identify where they should go. The message pointer is an operand of the movabs instruction (0x48 0xbe), on the 3rd row. So that’s index 12 with a length of 8 bytes (this instruction takes up 2 rows). The message length is the operand of the next instruction, on the 5th row. That’s index 21 with a length of 4 bytes.

The message pointer should point to the hello world string in the data memory segment, which has an address of data_vaddr. Since the segment only contains the string, the address to the string is also just data_vaddr. And we already know the length of the message: data_len.

So we just need to insert the values of data_vaddr and data_len into the instructions array, at indexes 12 and 21 respectively.

Let’s define a helper function to make this simple.

 1/**
 2 * Inserts `value` as bytes (in little-endian) into the `target` at `idx`. Stops
 3 * after `num` bytes.
 4 */
 5void insert_little_endian(uint64_t value, uint8_t *target, size_t idx, size_t num) {
 6    for (int i = 0; i < num; i++) {
 7        uint64_t mask = 0xff << (8 * i);
 8        uint8_t byte = (value & mask) >> (8 * i);
 9        target[idx + i] = byte;
10    }
11}

The function may look scary but there’s really not much going on here. We iterate num times, taking the next smallest byte from value and writing it to target at an offset of idx.

I should also mention that my system’s byte order is little-endian. You may recall that we already specified this when we wrote the ELF header. These days, you’d be hard-pressed to find a big-endian consumer CPU. But if you want to check your machine, you can run this command: lscpu | grep 'Byte Order'.

Anyway, now we can do the following:

1unsigned char instructions[] = {
2    // instructions omitted for brevity
3};
4insert_little_endian(data_vaddr, instructions, 12, 8);
5insert_little_endian(data_len, instructions, 21, 4);

And our machine code instructions are complete!

Put it all together

The final step is to write everything to a file (in the right order!) and mark that file as executable. Put it all together and you should have something like this:

  1#include <elf.h>
  2#include <stdint.h>
  3#include <stdio.h>
  4#include <sys/stat.h>
  5
  6#define V_ADDR_BASE 0x400000
  7#define PAGE_SIZE 0x1000
  8#define EHDR_SIZE sizeof(Elf64_Ehdr)
  9#define PHDR_SIZE sizeof(Elf64_Phdr)
 10#define SHDR_SIZE sizeof(Elf64_Shdr)
 11
 12// forward declaration
 13void insert_little_endian(uint64_t value, uint8_t *target, size_t idx, size_t num);
 14
 15int main() {
 16    unsigned char data[] = "Hello, world!\n";
 17    unsigned long data_len = sizeof(data);
 18    unsigned long data_offset = EHDR_SIZE + (2 * PHDR_SIZE);
 19    unsigned long data_vaddr = V_ADDR_BASE + PAGE_SIZE + data_offset;
 20
 21    Elf64_Phdr data_phdr = {
 22        .p_type = PT_LOAD,
 23        .p_flags = PF_R,         // flags = readable
 24        .p_offset = data_offset, // offset in file where data is found
 25        .p_vaddr = data_vaddr,   // virtual address of the memory segment
 26        .p_paddr = data_vaddr,   // physical address - not important
 27        .p_filesz = data_len,    // segment size in file
 28        .p_memsz = data_len,     // segment size in memory
 29        .p_align = PAGE_SIZE,    // segment alignment
 30    };
 31
 32    unsigned char instructions[] = {
 33        0xb8, 0x01, 0x00, 0x00, 0x00, // mov    rax, 1 | "write" syscall
 34        0xbf, 0x01, 0x00, 0x00, 0x00, // mov    rdi, 1 | 1st arg: stdout
 35        0x48, 0xbe, 0x00, 0x00, 0x00, // movabs rsi, ? | 2nd arg: ptr to msg
 36        0x00, 0x00, 0x00, 0x00, 0x00, //               |
 37        0xba, 0x00, 0x00, 0x00, 0x00, // mov rdx, ?    | 3rd arg: msg length
 38        0x0f, 0x05,                   // syscall       | invoke syscall
 39        0xb8, 0x3c, 0x00, 0x00, 0x00, // mov rax, 60   | "exit" syscall
 40        0xbf, 0x45, 0x00, 0x00, 0x00, // mov rdi, 69   | 1st arg: exit code
 41        0x0f, 0x05,                   // syscall       | invoke syscall
 42    };
 43
 44    insert_little_endian(data_vaddr, instructions, 12, 8);
 45    insert_little_endian(data_len, instructions, 21, 4);
 46
 47    unsigned long inst_len = sizeof(instructions);
 48    unsigned long inst_offset = data_offset + data_len;
 49    unsigned long inst_vaddr = V_ADDR_BASE + PAGE_SIZE + inst_offset;
 50
 51    Elf64_Phdr inst_phdr = {
 52        .p_type = PT_LOAD,       // type = loadable segment
 53        .p_flags = PF_R | PF_X,  // flags = readable & executable
 54        .p_offset = inst_offset, // offset in file where segment data is found
 55        .p_vaddr = inst_vaddr,   // The virtual address of the memory segment
 56        .p_paddr = inst_vaddr,   // The physical address - not important
 57        .p_filesz = inst_len,    // Segment size in file
 58        .p_memsz = inst_len,     // Segment size in memory
 59        .p_align = PAGE_SIZE,    // Segment alignment
 60    };
 61
 62    Elf64_Ehdr e_hdr = {
 63        .e_ident =
 64            {
 65                ELFMAG0,       // 0x7f
 66                ELFMAG1,       // "E"
 67                ELFMAG2,       // "L"
 68                ELFMAG3,       // "F"
 69                ELFCLASS64,    // 64-bit
 70                ELFDATA2LSB,   // little-endian
 71                EV_CURRENT,    // elf v1
 72                ELFOSABI_NONE, // no particular ABI
 73                0,             // ABI version
 74            },
 75        .e_type = ET_EXEC,                 // type = executable file
 76        .e_machine = EM_X86_64,            // targeting x86_64 processors
 77        .e_version = EV_CURRENT,           // always 1 or EV_CURRENT
 78        .e_entry = inst_vaddr,             // address of first instruction
 79        .e_phoff = EHDR_SIZE,              // offset of program headers
 80        .e_shoff = 0,                      // offset of section headers
 81        .e_flags = 0,                      // always 0
 82        .e_ehsize = EHDR_SIZE,             // size of the ELF header
 83        .e_phentsize = PHDR_SIZE,          // program header table entry size
 84        .e_phnum = 2,                      // number of program headers
 85        .e_shentsize = SHDR_SIZE,          // section header table entry size
 86        .e_shnum = 0,                      // number of section header table entries
 87        .e_shstrndx = SHN_UNDEF,           // not important
 88    };
 89
 90    FILE *f = fopen("my_elf", "w");
 91    fwrite(&e_hdr, EHDR_SIZE, 1, f);       // write ELF header
 92    fwrite(&data_phdr, PHDR_SIZE, 1, f);   // write data prog header
 93    fwrite(&inst_phdr, PHDR_SIZE, 1, f);   // write inst prog header
 94    fwrite(data, data_len, 1, f);          // write data
 95    fwrite(instructions, inst_len, 1, f);  // write instructions
 96    fchmod(fileno(f), 0755);
 97    fclose(f);
 98}
 99
100void insert_little_endian(uint64_t value, uint8_t *target, size_t idx, size_t num) {
101    for (int i = 0; i < num; i++) {
102        uint64_t mask = 0xff << (8 * i);
103        uint8_t byte = (value & mask) >> (8 * i);
104        target[idx + i] = byte;
105    }
106}

Now all that’s left is to compile this bad boy and run it to produce our custom ELF file.

$ gcc -o elfmaker main.c
$ ./elfmaker
$ ./my_elf
Hello, world!

And if you check the exit code:

$ echo $?
69

Nice.

If you’ve been following along:

Congratulations! You just built a compiler in less than a hundred lines!

True, it’s the world’s most pointless compiler, and if we’re being pedantic it’s technically just a code generator, since we’re not translating a source language. But hey, now you have something you can build upon. Here’s an exercise for you: swap out the hard-coded hello world string with a string that you read from a text file. That’s sort of a language, right?

By the way, if you want to admire your handiwork, run hexdump -C my_elf.

$ hexdump -C my_elf
00000000  7f 45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00  |.ELF............|
00000010  02 00 3e 00 01 00 00 00  bf 10 40 00 00 00 00 00  |..>.......@.....|
00000020  40 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |@...............|
00000030  00 00 00 00 40 00 38 00  02 00 40 00 00 00 00 00  |....@.8...@.....|
00000040  01 00 00 00 04 00 00 00  b0 00 00 00 00 00 00 00  |................|
00000050  b0 10 40 00 00 00 00 00  b0 10 40 00 00 00 00 00  |..@.......@.....|
00000060  0f 00 00 00 00 00 00 00  0f 00 00 00 00 00 00 00  |................|
00000070  00 10 00 00 00 00 00 00  01 00 00 00 05 00 00 00  |................|
00000080  bf 00 00 00 00 00 00 00  bf 10 40 00 00 00 00 00  |..........@.....|
00000090  bf 10 40 00 00 00 00 00  27 00 00 00 00 00 00 00  |..@.....'.......|
000000a0  27 00 00 00 00 00 00 00  00 10 00 00 00 00 00 00  |'...............|
000000b0  48 65 6c 6c 6f 2c 20 77  6f 72 6c 64 21 0a 00 b8  |Hello, world!...|
000000c0  01 00 00 00 bf 01 00 00  00 48 be b0 10 40 00 00  |.........H...@..|
000000d0  00 00 00 ba 0f 00 00 00  0f 05 b8 3c 00 00 00 bf  |...........<....|
000000e0  45 00 00 00 0f 05                                 |E.....|
000000e6

That, is a 230-byte hand-rolled Linux x86_64 executable. Everything the kernel needs to spawn a process. And it’s beautiful.

Gotta Go

When I had finished the first working version of this program, it took me a few hours of playing around with it to realize that, in my pursuit of learning more about compilers, I ended up building one.

But now it was time for me to move on from C. Don’t get me wrong, I like C. C is simple, and plenty powerful. Who doesn’t like a good sawed-off shotgun to blow your feet clean off?

These days, Go is my mistress. And as luck would have it, Go has an elf package that is essentially the Go equivalent of elf.h. So that’s where I’ll be continuing this little compiler adventure of mine.

Anyway, hope you learned something. Happy hacking :)