https://wiki.osdev.org/Limine_Bare_Bones

Limine Bare Bones

WAIT! Have you read Getting Started, Beginner Mistakes, and some of the related OS theory?

The Limine Boot Protocol is the native boot protocol provided by the Limine bootloader. Like the stivale protocols it supersedes, it is designed to overcome shortcomings of common boot protocols used by hobbyist OS developers, such as Multiboot, and stivale itself.

It provides cutting edge features such as 5-level paging support, 64-bit Long Mode support, and direct higher half kernel loading.

The Limine boot protocol is firmware and architecture agnostic. The Limine bootloader supports x86-64, IA32, and aarch64.

This article will demonstrate how to write a small x86-64 higher half Limine-compliant kernel in C, and boot it using the Limine bootloader.

It is also very recommended to check out this template project as it provides example buildable code to go along with this guide.
Contents

    1 Overview
    2 kernel.c
    3 linker.ld
    4 Building the kernel and creating an image
        4.1 GNUmakefile
        4.2 limine.cfg
        4.3 Compiling the kernel
        4.4 Creating the image
            4.4.1 Creating an ISO
            4.4.2 Creating a hard disk/USB drive image
    5 Conclusions
    6 See Also
        6.1 Articles
        6.2 External Links

Overview

For this example, we will create these 2 files and place them in the same directory:

    kernel.c
    linker.ld 

As one may notice, there is no "entry point" assembly stub, as one is not necessary with the Limine protocol when using a language which can make use of a standard SysV x86 calling convention.

Furthermore, we will download the header file limine.h which defines structures and constants that we will use to interact with the bootloader from here, and place it in the same directory as the other files.

Obviously, this is just a bare bones example, and one should always refer to the Limine protocol specification for more details and information.
kernel.c

This is the kernel "main".

#include <stdint.h>
#include <stddef.h>
#include <limine.h>
 
// The Limine requests can be placed anywhere, but it is important that
// the compiler does not optimise them away, so, usually, they should
// be made volatile or equivalent.
 
static volatile struct limine_terminal_request terminal_request = {
    .id = LIMINE_TERMINAL_REQUEST,
    .revision = 0
};
 
static void done(void) {
    for (;;) {
        __asm__("hlt");
    }
}
 
// The following will be our kernel's entry point.
void _start(void) {
    // Ensure we got a terminal
    if (terminal_request.response == NULL
     || terminal_request.response->terminal_count < 1) {
        done();
    }
 
    // We should now be able to call the Limine terminal to print out
    // a simple "Hello World" to screen.
    struct limine_terminal *terminal = terminal_request.response->terminals[0];
    terminal_request.response->write(terminal, "Hello World", 11);
 
    // We're done, just hang...
    done();
}

Note: Using the Limine terminal requires that the kernel maintains some state as described in the specification (https://github.com/limine-bootloader/limine/blob/trunk/PROTOCOL.md#x86_64-1).
linker.ld

This is going to be our linker script describing where our sections will end up in memory.

/* Tell the linker that we want an x86_64 ELF64 output file */
OUTPUT_FORMAT(elf64-x86-64)
OUTPUT_ARCH(i386:x86-64)
 
/* We want the symbol _start to be our entry point */
ENTRY(_start)
 
/* Define the program headers we want so the bootloader gives us the right */
/* MMU permissions */
PHDRS
{
    text    PT_LOAD    FLAGS((1 << 0) | (1 << 2)) ; /* Execute + Read */
    rodata  PT_LOAD    FLAGS((1 << 2)) ;            /* Read only */
    data    PT_LOAD    FLAGS((1 << 1) | (1 << 2)) ; /* Write + Read */
}
 
SECTIONS
{
    /* We wanna be placed in the topmost 2GiB of the address space, for optimisations */
    /* and because that is what the Limine spec mandates. */
    /* Any address in this region will do, but often 0xffffffff80000000 is chosen as */
    /* that is the beginning of the region. */
    . = 0xffffffff80000000;
 
    .text : {
        *(.text .text.*)
    } :text
 
    /* Move to the next memory page for .rodata */
    . += CONSTANT(MAXPAGESIZE);
 
    .rodata : {
        *(.rodata .rodata.*)
    } :rodata
 
    /* Move to the next memory page for .data */
    . += CONSTANT(MAXPAGESIZE);
 
    .data : {
        *(.data .data.*)
    } :data
 
    .bss : {
        *(COMMON)
        *(.bss .bss.*)
    } :data
 
    /* Discard .note.* and .eh_frame since they may cause issues on some hosts. */
    /DISCARD/ : {
        *(.eh_frame)
        *(.note .note.*)
    }
}

Building the kernel and creating an image
GNUmakefile

In order to build our kernel, we are going to use a Makefile. Since we're going to use GNU make specific features, we call this file GNUmakefile instead, so only GNU make will process it.

# This is the name that our final kernel executable will have.
# Change as needed.
override KERNEL := myos.elf
 
# Convenience macro to reliably declare overridable command variables.
define DEFAULT_VAR =
    ifeq ($(origin $1),default)
        override $(1) := $(2)
    endif
    ifeq ($(origin $1),undefined)
        override $(1) := $(2)
    endif
endef
 
# It is highly recommended to use a custom built cross toolchain to build a kernel.
# We are only using "cc" as a placeholder here. It may work by using
# the host system's toolchain, but this is not guaranteed.
$(eval $(call DEFAULT_VAR,CC,cc))
 
# Same thing for "ld" (the linker).
$(eval $(call DEFAULT_VAR,LD,ld))
 
# User controllable CFLAGS.
CFLAGS ?= -g -O2 -pipe -Wall -Wextra
 
# User controllable preprocessor flags. We set none by default.
CPPFLAGS ?=
 
# User controllable nasm flags.
NASMFLAGS ?= -F dwarf -g
 
# User controllable linker flags. We set none by default.
LDFLAGS ?=
 
# Internal C flags that should not be changed by the user.
override CFLAGS +=       \
    -std=c11             \
    -ffreestanding       \
    -fno-stack-protector \
    -fno-stack-check     \
    -fno-lto             \
    -fno-pie             \
    -fno-pic             \
    -m64                 \
    -march=x86-64        \
    -mabi=sysv           \
    -mno-80387           \
    -mno-mmx             \
    -mno-sse             \
    -mno-sse2            \
    -mno-red-zone        \
    -mcmodel=kernel      \
    -MMD                 \
    -I.
 
# Internal linker flags that should not be changed by the user.
override LDFLAGS +=         \
    -nostdlib               \
    -static                 \
    -m elf_x86_64           \
    -z max-page-size=0x1000 \
    -T linker.ld
 
# Check if the linker supports -no-pie and enable it if it does.
ifeq ($(shell $(LD) --help 2>&1 | grep 'no-pie' >/dev/null 2>&1; echo $$?),0)
    override LDFLAGS += -no-pie
endif
 
# Internal nasm flags that should not be changed by the user.
override NASMFLAGS += \
    -f elf64
 
# Use find to glob all *.c, *.S, and *.asm files in the directory and extract the object names.
override CFILES := $(shell find . -type f -name '*.c' | grep -v 'limine/')
override ASFILES := $(shell find . -type f -name '*.S' | grep -v 'limine/')
override NASMFILES := $(shell find . -type f -name '*.asm' | grep -v 'limine/')
override OBJ := $(CFILES:.c=.o) $(ASFILES:.S=.o) $(NASMFILES:.asm=.o)
override HEADER_DEPS := $(CFILES:.c=.d) $(ASFILES:.S=.d)
 
# Default target.
.PHONY: all
all: $(KERNEL)
 
# Link rules for the final kernel executable.
$(KERNEL): $(OBJ)
	$(LD) $(OBJ) $(LDFLAGS) -o $@
 
# Include header dependencies.
-include $(HEADER_DEPS)
 
# Compilation rules for *.c files.
%.o: %.c
	$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
 
# Compilation rules for *.S files.
%.o: %.S
	$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
 
# Compilation rules for *.asm (nasm) files.
%.o: %.asm
	nasm $(NASMFLAGS) $< -o $@
 
# Remove object files and the final executable.
.PHONY: clean
clean:
	rm -rf $(KERNEL) $(OBJ) $(HEADER_DEPS)

limine.cfg

This file is parsed by Limine and it describes boot entries and other bootloader configuration variables. Further information here.

# Timeout in seconds that Limine will use before automatically booting.
TIMEOUT=5
 
# The entry name that will be displayed in the boot menu
:myOS
    # Change the protocol line depending on the used protocol.
    PROTOCOL=limine
 
    # Path to the kernel to boot. boot:/// represents the partition on which limine.cfg is located.
    KERNEL_PATH=boot:///myos.elf

Compiling the kernel

We can now build our example kernel by running make. This command, if successful, should generate a file called myos.elf (or the chosen kernel name). This is our Limine protocol-compliant kernel executable.
Creating the image

We can now create either an ISO or a hard disk/USB drive image with our kernel on it. Limine can boot on both BIOS and UEFI if the image is set up to do so, which is what we are going to do.
Creating an ISO

In this example we are going to create a CD-ROM ISO capable of booting on both UEFI and legacy BIOS systems.

For this to work, we will need the xorriso utility.

These are shell commands. They can also be compiled into a script or Makefile.

# Download the latest Limine binary release.
git clone https://github.com/limine-bootloader/limine.git --branch=v4.x-branch-binary --depth=1
 
# Build limine-deploy.
make -C limine
 
# Create a directory which will be our ISO root.
mkdir -p iso_root
 
# Copy the relevant files over.
cp -v myos.elf limine.cfg limine/limine.sys \
      limine/limine-cd.bin limine/limine-cd-efi.bin iso_root/
 
# Create the bootable ISO.
xorriso -as mkisofs -b limine-cd.bin \
        -no-emul-boot -boot-load-size 4 -boot-info-table \
        --efi-boot limine-cd-efi.bin \
        -efi-boot-part --efi-boot-image --protective-msdos-label \
        iso_root -o image.iso
 
# Install Limine stage 1 and 2 for legacy BIOS boot.
./limine/limine-deploy image.iso

Creating a hard disk/USB drive image

In this example we'll create a GPT partition table using parted, containing a single FAT partition, also known as the ESP in EFI terminology, which will store our kernel, configs, and bootloader.

This example is more involved and is made up of more steps than creating an ISO image.

These are shell commands. They can also be compiled into a script or Makefile.

# Create an empty zeroed out 64MiB image file.
dd if=/dev/zero bs=1M count=0 seek=64 of=image.hdd
 
# Create a GPT partition table.
parted -s image.hdd mklabel gpt
 
# Create an ESP partition that spans the whole disk.
parted -s image.hdd mkpart ESP fat32 2048s 100%
parted -s image.hdd set 1 esp on
 
# Download the latest Limine binary release.
git clone https://github.com/limine-bootloader/limine.git --branch=v4.x-branch-binary --depth=1
 
# Build limine-deploy.
make -C limine
 
# Install the Limine BIOS stages onto the image.
./limine/limine-deploy image.hdd
 
# Mount the loopback device.
USED_LOOPBACK=$(sudo losetup -Pf --show image.hdd)
 
# Format the ESP partition as FAT32.
sudo mkfs.fat -F 32 ${USED_LOOPBACK}p1
 
# Mount the partition itself.
mkdir -p img_mount
sudo mount ${USED_LOOPBACK}p1 img_mount
 
# Copy the relevant files over.
sudo mkdir -p img_mount/EFI/BOOT
sudo cp -v myos.elf limine.cfg limine/limine.sys img_mount/
sudo cp -v limine/BOOTX64.EFI img_mount/EFI/BOOT/
 
# Sync system cache and unmount partition and loopback device.
sync
sudo umount img_mount
sudo losetup -d ${USED_LOOPBACK}

Conclusions

If everything above has been completed successfully, you should now have a bootable ISO or hard drive/USB image containing your 64-bit higher half Limine protocol-compliant kernel and Limine to boot it. Once the kernel is successfully booted, you should see "Hello World" printed on screen.
See Also
Articles

    Limine
    Multiboot 

External Links

    Limine protocol specification
    Buildable Limine Bare Bones project template 


