Skip to content

Starting with Limine for x86_64

In order to start writing a kernel you need to be able to boot it, luckily the Limine Bootloader and the Limine Protocol makes it very easy to start having code run under ring 0 and start making your own kernel.

The Limine Protocol is what you can use with your kernel to have it be able to be booted by any bootloader implementing it such as Limine and it will provide you a way to get many things you need to get your kernel running with stuff like a basic framebuffer and a memory map and a way to load files into memory to be read by your kernel to be something like an Inital Ramdisk. This lets you get started making your kernel instead of slogging through making a bootloader.

The Easy Way

Limine provides templates for starting your kernel in C and C++ and using C with Meson if you dislike makefiles. You should be able to clone one of these repos and start working on your kernel and adding your own code to set everything up.

Please remember to read the Limine Protocol to know how the Limine Bootloader will leave the system and how you will need to set it up and what other features you are able to use while using the Limine Protocol.

The Hard Way

This section is mainly a retelling and slightly simpler setup of The Easy Way and that way is highly recommended, it is a much better starting point and you should be able to edit the structure of the template if it is not to your liking.

This is just provided for people who wish to DIY and learn more about how using the Limine Protocol works BUT you should use The Easy Way after getting this to work to have a much better environment working with your kernel. The template can also be adapted into an existing project if you have given up making your own bootloader because it was causing too many issues.

Main C File (main.c)

Your main C file with your entry point should minimally look something like this

#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include <limine.h>

/* Set the base revision to 3 as it is the latest */
__attribute__((used, section(".limine_requests")))
static volatile LIMINE_BASE_REVISION(3);

/*
 * The Limine requests can be placed in any .c file but
 * they should be marked as used and volatile and be put
 * in the proper linker section
 */
__attribute__((used, section(".limine_requests")))
static volatile struct limine_framebuffer_request framebuffer_request = {
    .id = LIMINE_FRAMEBUFFER_REQUEST,
    .revision = 0
};

/*
 * Define the start and end markers for the Limine requests.
 * These are able to be moved to any .c file if needed.
 */
__attribute__((used, section(".limine_requests_start")))
static volatile LIMINE_REQUESTS_START_MARKER;

__attribute__((used, section(".limine_requests_end")))
static volatile LIMINE_REQUESTS_END_MARKER;

/*
 * GCC & Clang may call these functions when optimising code
 * so they should be defined somewhere in your project; These
 * are able to be moved to any .c file if needed.
 */
void *memcpy(void *restrict dest, const void *restrict src, size_t n) {
    uint8_t *restrict pdest = (uint8_t *restrict)dest;
    const uint8_t *restrict psrc = (const uint8_t *restrict)src;

    for (size_t i = 0; i < n; i++) {
        pdest[i] = psrc[i];
    }

    return dest;
}

void *memset(void *s, int c, size_t n) {
    uint8_t *p = (uint8_t *)s;

    for (size_t i = 0; i < n; i++) {
        p[i] = (uint8_t)c;
    }

    return s;
}

void *memmove(void *dest, const void *src, size_t n) {
    uint8_t *pdest = (uint8_t *)dest;
    const uint8_t *psrc = (const uint8_t *)src;

    if (src > dest) {
        for (size_t i = 0; i < n; i++) {
            pdest[i] = psrc[i];
        }
    } else if (src < dest) {
        for (size_t i = n; i > 0; i--) {
            pdest[i-1] = psrc[i-1];
        }
    }

    return dest;
}

int memcmp(const void *s1, const void *s2, size_t n) {
    const uint8_t *p1 = (const uint8_t *)s1;
    const uint8_t *p2 = (const uint8_t *)s2;

    for (size_t i = 0; i < n; i++) {
        if (p1[i] != p2[i]) {
            return p1[i] < p2[i] ? -1 : 1;
        }
    }

    return 0;
}

/* Halt the CPU forever using the hlt instruction. */
void hcf() {
    for (;;) {
        asm("hlt");
    }
}

void kmain(void) {
    if (LIMINE_BASE_REVISION_SUPPORTED == false) {
        hcf();
    }

    if (framebuffer_request.response == NULL
     || framebuffer_request.response->framebuffer_count < 1) {
        hcf();
    }

    struct limine_framebuffer *framebuffer = framebuffer_request.response->framebuffers[0];

    for (size_t i = 0; i < 100; i++) {
        volatile uint32_t *fb_ptr = framebuffer->address;
        fb_ptr[i * (framebuffer->pitch / 4) + i] = 0xffffff;
    }

    hcf();
}
This is the basic minimal code to bring up a ring 0 application (soon to be kernel) but there are still some extra files needed to compile it.

Linker Script (x86_64.lds)

To compile your kernel properly you need a linker script to tell the linker how to format the final binary and have all the .o files properly put together

It should look something like this when you are starting out although it can be expanded

/* We want an x86-64 elf binary */
OUTPUT_FORMAT(elf64-x86-64)

/* set the function kmain() to be the entry point*/
ENTRY(kmain)

/* Add some program headers to make stuff be loaded properly */
PHDRS
{
    limine_requests PT_LOAD;
    text PT_LOAD;
    rodata PT_LOAD;
    data PT_LOAD;
}

SECTIONS
{
    /* Limine requires the loaded program to be put at the topmost 
     * 2GiB of virtual memory which is standard & good for kernels
     * setting "." to that makes this happen properly 
     */
    . = 0xffffffff80000000;

    /* Define a section to contain the Limine requests and assign it to its own PHDR */
    .limine_requests : {
        KEEP(*(.limine_requests_start))
        KEEP(*(.limine_requests))
        KEEP(*(.limine_requests_end))
    } :limine_requests

    /* Move to the next memory page for .text */
    . = ALIGN(CONSTANT(MAXPAGESIZE));

    .text : {
        *(.text .text.*)
    } :text

    /* Move to the next memory page for .rodata */
    . = ALIGN(CONSTANT(MAXPAGESIZE));

    .rodata : {
        *(.rodata .rodata.*)
    } :rodata

    /* Move to the next memory page for .data */
    . = ALIGN(CONSTANT(MAXPAGESIZE));

    .data : {
        *(.data .data.*)
    } :data


    /* This should be the last section in the linker script to not write unnecessary zeros to the final binary*/
    .bss : {
        *(.bss .bss.*)
        *(COMMON)
    } :data

    /* Discard .note.* and .eh_frame* since they may cause issues on some hosts. */
    /DISCARD/ : {
        *(.eh_frame*)
        *(.note .note.*)
    }
}

Compiling & Running in QEMU

You can use the following command to compile the kernel

gcc -march=x86-64 -mcmodel=kernel -mabi=sysv -ffreestanding -mno-red-zone -fno-stack-protector -fno-stack-check -fno-lto -fno-PIC -c main.c -o main.o
The flags used here do the following

  • -march=x86-64 [Tells GCC to output at x86-64]
  • -mcmodel=kernel [Tells GCC to apply settings specific to kernel applications]
  • -mabi=sysv [Tells GCC to use the System V ABI]
  • -ffreestanding [Tells GCC there is no libc (Bare Metal)]
  • -mno-red-zone [Tells GCC the red zone is unsupported]
  • -fno-stack-protector [Disables some stack protections as it requires libc support]
  • -fno-stack-check [Disables some stack protections as it requires libc support]
  • -fno-lto [Disables Link Time Optimization]
  • -fno-PIC [Disables generating Position Independent Code]

You can use this command to link the kernel into the final .elf file

ld -T x86_64.lds -m elf_x86_64 main.o -o kernel.elf
The flags used here do the following

  • -T x86_64.lds [Use the linker script that you made before]
  • -m elf_x86_64 [Tell the linker to output an x86_64 elf file]
  • main.o [Tells the linker all the .o files to link together. Put all .o files here]
  • -o kernel.elf [Specifies the output file]

You should then copy the Limine bootloader .efi file to /EFI/BOOT/BOOTX64.EFI in your project directory which can be found at /usr/share/limine/BOOTX64.EFI if you use an arch based distro with the Limine Package

As well with the EDK2 OVMF Package on an arch based distro you will need to copy /usr/share/edk2/x64/OVMF_CODE.4m.fd and /usr/share/edk2/x64/OVMF_VARS.4m.fd to your project folder

Then you need to make a basic limine.conf file as shown below

timeout: 10

/Your Kernel
    protocol: limine
    resolution: 1280x720
    path: boot():/kernel.elf

Finally you should be able to run the following QEMU command to boot your kernel (Under linux using KVM)

qemu-system-x86_64 \
    -machine q35,accel=kvm \
    -cpu host \
    -m 512M \
    -drive if=pflash,format=raw,readonly=on,file=./OVMF_CODE.4m.fd \
    -drive if=pflash,format=raw,readonly=on,file=./OVMF_VARS.4m.fd \
    -drive format=raw,file=fat:rw:. \
    -boot d
The flags used here do the following

  • -machine q35,accel=kvm [Virtualize a machine with a modern chipset and use KVM]
  • -cpu host [Virtualize a CPU that is equal to your host CPU]
  • -m 512M [Give the VM 512MB of memory]
  • -drive if=pflash,format=raw,readonly=on,file=./OVMF_CODE.4m.fd [Enable UEFI]
  • -drive if=pflash,format=raw,readonly=on,file=./OVMF_VARS.4m.fd [Enable UEFI]
  • -drive format=raw,file=fat:rw:. [Use the current directory as the VMs disk]
  • -boot d [Boot the first virtual disk]

Rust and Zig

There exist some third parting bindings for using the Limine Protocol with these two languages if you desire.

Writing a kernel in a language other than C is possible but this wiki as of now will only provide examples in C and if you are using another language you will have to translate the examples accordingly, But there is rust code in even the linux kernel and it is very possible to make a kernel using only rust (and assembly). So any alternative compiled languages should be able to be used to make your kernel if desired.

Note: These templates are not official and have not been tested by the wiki team but they should work fine and be ok