Limine Bare Bones
|
WAIT! Have you read Getting Started, Beginner Mistakes, and some of the related OS theory? |
| Difficulty level |
|---|
Beginner |
| Kernel Designs |
|---|
| Models |
| Other Concepts |
The Limine Boot Protocol is the native boot protocol provided by the Limine bootloader. It is designed to overcome shortcomings of common boot protocols used by hobbyist OS developers, such as Multiboot.
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. It supports x86-64, aarch64, riscv64, and loongarch64.
This article will demonstrate how to write a small Limine-compliant x86-64 kernel in (GNU) C, and boot it using the Limine bootloader.
Additionally, it is highly recommended to check out this repository as it provides more complete, buildable, portable template code to go along with this guide.
Overview
For this example, we will create these 2 files to create the basic directory tree of our project:
- src/main.c
- linker.lds
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 src directory.
Obviously, this is just a bare bones example, and one should always refer to the Limine protocol specification for more details and information.
src/main.c
This is the kernel "main".
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include <limine.h>
// Set the base revision to 6, this is recommended as this is the latest
// base revision described by the Limine boot protocol specification.
// See specification for further info.
__attribute__((used, section(".limine_requests")))
static volatile uint64_t limine_base_revision[] = LIMINE_BASE_REVISION(6);
// 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, _and_ they should be accessed at least
// once or marked as used with the "used" attribute as done here.
__attribute__((used, section(".limine_requests")))
static volatile struct limine_framebuffer_request framebuffer_request = {
.id = LIMINE_FRAMEBUFFER_REQUEST_ID,
.revision = 0
};
// Finally, define the start and end markers for the Limine requests.
// These can also be moved anywhere, to any .c file, as seen fit.
__attribute__((used, section(".limine_requests_start")))
static volatile uint64_t limine_requests_start_marker[] = LIMINE_REQUESTS_START_MARKER;
__attribute__((used, section(".limine_requests_end")))
static volatile uint64_t limine_requests_end_marker[] = LIMINE_REQUESTS_END_MARKER;
// GCC and Clang reserve the right to generate calls to the following
// 4 functions even if they are not directly called.
// Implement them as the C specification mandates.
// DO NOT remove or rename these functions, or stuff will eventually break!
// They CAN be moved to a different .c file.
void *memcpy(void *restrict dest, const void *restrict src, size_t n) {
uint8_t *restrict pdest = dest;
const uint8_t *restrict psrc = 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 = 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 = dest;
const uint8_t *psrc = src;
if ((uintptr_t)src > (uintptr_t)dest) {
for (size_t i = 0; i < n; i++) {
pdest[i] = psrc[i];
}
} else if ((uintptr_t)src < (uintptr_t)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 = s1;
const uint8_t *p2 = s2;
for (size_t i = 0; i < n; i++) {
if (p1[i] != p2[i]) {
return p1[i] < p2[i] ? -1 : 1;
}
}
return 0;
}
// Halt and catch fire function.
static void hcf(void) {
for (;;) {
asm ("hlt");
}
}
// Scale an 8-bit colour channel value to the size the framebuffer gives the
// channel and move it into place within a pixel.
static uint32_t fb_channel(uint8_t value, uint8_t mask_size, uint8_t mask_shift) {
uint64_t max = ((uint64_t)1 << mask_size) - 1;
return (uint32_t)((value * max / 255) << mask_shift);
}
// Build a pixel from 8-bit red, green and blue values following the channel
// layout of the framebuffer.
static uint32_t fb_pixel(struct limine_framebuffer *fb, uint8_t red, uint8_t green, uint8_t blue) {
return fb_channel(red, fb->red_mask_size, fb->red_mask_shift)
| fb_channel(green, fb->green_mask_size, fb->green_mask_shift)
| fb_channel(blue, fb->blue_mask_size, fb->blue_mask_shift);
}
// Print a nice pattern to a framebuffer as an example.
static void fb_pattern(struct limine_framebuffer *fb) {
volatile uint32_t *fb_ptr = fb->address;
for (size_t y = 0; y < fb->height; y++) {
for (size_t x = 0; x < fb->width; x++) {
uint8_t nX = x * 255 / fb->width;
uint8_t nY = y * 255 / fb->height;
fb_ptr[y * (fb->pitch / 4) + x] = fb_pixel(fb, 0, nY, nX);
}
}
}
// The following will be our kernel's entry point.
// If renaming kmain() to something else, make sure to change the
// linker script accordingly.
void kmain(void) {
// Ensure the bootloader actually understands our base revision (see spec).
if (LIMINE_BASE_REVISION_SUPPORTED(limine_base_revision) == false) {
hcf();
}
// Ensure we got a framebuffer.
if (framebuffer_request.response == NULL
|| framebuffer_request.response->framebuffer_count < 1) {
hcf();
}
// Print the pattern to every framebuffer.
for (uint64_t i = 0; i < framebuffer_request.response->framebuffer_count; i++) {
struct limine_framebuffer *framebuffer = framebuffer_request.response->framebuffers[i];
// Ensure the framebuffer has 32-bit RGB pixels, the only kind we handle.
if (framebuffer->memory_model != LIMINE_FRAMEBUFFER_RGB || framebuffer->bpp != 32) {
hcf();
}
fb_pattern(framebuffer);
}
// We're done, just hang...
hcf();
}
linker.lds
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)
/* We want the symbol kmain to be our entry point */
ENTRY(kmain)
/* Define the program headers we want so the bootloader gives us the right */
/* MMU permissions; this also allows us to exert more control over the linking */
/* process. */
PHDRS
{
limine_requests PT_LOAD;
text PT_LOAD;
rodata PT_LOAD;
data PT_LOAD;
}
SECTIONS
{
/* We want to 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;
/* 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
/* Add a .note.gnu.build-id output section in case a build ID flag is added to the */
/* linker command. */
.note.gnu.build-id : {
*(.note.gnu.build-id)
} :rodata
/* Move to the next memory page for .data */
. = ALIGN(CONSTANT(MAXPAGESIZE));
.data : {
*(.data .data.*)
} :data
/* NOTE: .bss needs to be the last thing mapped to :data, otherwise lots of */
/* unnecessary zeros will be written to the binary. */
/* If you need, for example, .init_array and .fini_array, those should be placed */
/* above this. */
.bss : {
*(.bss .bss.*)
*(COMMON)
} :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.
# Nuke built-in rules.
.SUFFIXES:
# Delete the target of a failed recipe.
.DELETE_ON_ERROR:
# This is the name that our final executable will have.
# Change as needed.
override OUTPUT := myos
# User controllable toolchain and toolchain prefix.
TOOLCHAIN :=
TOOLCHAIN_PREFIX :=
ifneq ($(TOOLCHAIN),)
ifeq ($(TOOLCHAIN_PREFIX),)
TOOLCHAIN_PREFIX := $(TOOLCHAIN)-
endif
endif
# User controllable C compiler command.
ifneq ($(TOOLCHAIN_PREFIX),)
CC := $(TOOLCHAIN_PREFIX)gcc
else
CC := cc
endif
# User controllable linker command.
LD := $(TOOLCHAIN_PREFIX)ld
# Defaults overrides for variables if using "llvm" as toolchain.
ifeq ($(TOOLCHAIN),llvm)
CC := clang
LD := ld.lld
endif
# User controllable C flags.
CFLAGS := -g -O2 -pipe
# User controllable C preprocessor flags. We set none by default.
CPPFLAGS :=
# User controllable nasm flags.
NASMFLAGS := -g
# User controllable linker flags. We set none by default.
LDFLAGS :=
# Check if CC is Clang.
override CC_IS_CLANG := $(shell ! $(CC) --version 2>/dev/null | grep -q '^Target: '; echo $$?)
# If the C compiler is Clang, set the target as needed.
ifeq ($(CC_IS_CLANG),1)
override CC += \
-target x86_64-unknown-none-elf
endif
# Internal C flags that should not be changed by the user.
override CFLAGS += \
-Wall \
-Wextra \
-std=gnu11 \
-ffreestanding \
-fno-stack-protector \
-fno-stack-check \
-fno-lto \
-fno-PIC \
-ffunction-sections \
-fdata-sections \
-m64 \
-march=x86-64 \
-mabi=sysv \
-mno-80387 \
-mno-mmx \
-mno-sse \
-mno-sse2 \
-mno-red-zone \
-mcmodel=kernel
# Internal C preprocessor flags that should not be changed by the user.
override CPPFLAGS := \
-I src \
$(CPPFLAGS) \
-MMD \
-MP
# Internal nasm flags that should not be changed by the user.
override NASMFLAGS := \
-f elf64 \
$(patsubst -g,-g -F dwarf,$(NASMFLAGS)) \
-i src/ \
-Wall
# Internal linker flags that should not be changed by the user.
override LDFLAGS += \
-m elf_x86_64 \
-nostdlib \
-static \
-z max-page-size=0x1000 \
-z noexecstack \
--gc-sections \
-T linker.lds
# Check if the linker supports -no-pie and enable it if it does.
override LD_HAS_NO_PIE := $(shell ! $(LD) --help 2>/dev/null | grep -qE '(^|[[:space:]])--?no-pie([[:space:]]|$$)'; echo $$?)
ifeq ($(LD_HAS_NO_PIE),1)
override LDFLAGS += \
-no-pie
endif
# Use "find" to glob all *.c, *.S, and *.asm files in the tree and obtain the
# object and header dependency file names.
override SRCFILES := $(shell find -L src -type f 2>/dev/null | LC_ALL=C sort)
override CFILES := $(filter %.c,$(SRCFILES))
override ASFILES := $(filter %.S,$(SRCFILES))
override NASMFILES := $(filter %.asm,$(SRCFILES))
override OBJ := $(addprefix obj/,$(CFILES:.c=.c.o) $(ASFILES:.S=.S.o) $(NASMFILES:.asm=.asm.o))
override HEADER_DEPS := $(addprefix obj/,$(CFILES:.c=.c.d) $(ASFILES:.S=.S.d) $(NASMFILES:.asm=.asm.d))
# Default target. This must come first, before header dependencies.
.PHONY: all
all: bin/$(OUTPUT)
# Include header dependencies.
-include $(HEADER_DEPS)
# Link rules for the final executable.
bin/$(OUTPUT): GNUmakefile linker.lds $(OBJ)
mkdir -p "$(dir $@)"
$(LD) $(LDFLAGS) $(OBJ) -o $@
# Compilation rules for *.c files.
obj/%.c.o: %.c GNUmakefile
mkdir -p "$(dir $@)"
$(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@
# Compilation rules for *.S files.
obj/%.S.o: %.S GNUmakefile
mkdir -p "$(dir $@)"
$(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@
# Compilation rules for *.asm (nasm) files.
obj/%.asm.o: %.asm GNUmakefile
mkdir -p "$(dir $@)"
nasm $(NASMFLAGS) -MD $(@:.o=.d) -MP $< -o $@
# Remove object files and the final executable.
.PHONY: clean
clean:
rm -rf bin obj
limine.conf
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
# We use the Limine boot protocol.
protocol: limine
# Path to the kernel to boot. boot():/ represents the partition on which limine.conf is located.
path: boot():/boot/myos
Compiling the kernel
We can now build our example kernel by running make. This command, if successful, should generate, inside the bin directory, a file called myos (or the chosen kernel name). This is our Limine protocol-compliant kernel executable.
Compiling the kernel on macOS
If you are not using macOS, you can skip this section.
The macOS Xcode toolchain uses Mach-O binaries, and not the ELF binaries required for this Limine-compliant kernel. A solution is to build a GCC Cross-Compiler, or to obtain one from homebrew by installing the x86_64-elf-gcc package. After one of these is done, build using make TOOLCHAIN_PREFIX=x86_64-elf-.
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 and extract the latest Limine binary release.
curl -fL -o limine-binary.tar.gz https://github.com/Limine-Bootloader/Limine/releases/latest/download/limine-binary.tar.gz
gunzip < limine-binary.tar.gz | tar -xf -
# Build "limine" utility.
make -C limine-binary
# Create a directory which will be our ISO root.
mkdir -p iso_root
# Copy the relevant files over.
mkdir -p iso_root/boot
cp -v bin/myos iso_root/boot/
mkdir -p iso_root/boot/limine
cp -v limine.conf limine-binary/limine-bios.sys limine-binary/limine-bios-cd.bin \
limine-binary/limine-uefi-cd.bin iso_root/boot/limine/
# Create the EFI boot tree and copy Limine's EFI executables over.
mkdir -p iso_root/EFI/BOOT
cp -v limine-binary/BOOTX64.EFI iso_root/EFI/BOOT/
cp -v limine-binary/BOOTIA32.EFI iso_root/EFI/BOOT/
# Create the bootable ISO.
xorriso -as mkisofs -R -r -J -b boot/limine/limine-bios-cd.bin \
-no-emul-boot -boot-load-size 4 -boot-info-table -hfsplus \
-apm-block-size 2048 --efi-boot boot/limine/limine-uefi-cd.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-binary/limine bios-install image.iso
Creating a hard disk/USB drive image
In this example, we'll create an MBR partition table using sgdisk, containing a single FAT partition, also known as the ESP in EFI terminology, which will store our kernel, configs, and bootloader.
For this to work, we will need the sgdisk (usually from the gdisk or gptfdisk packages) and mtools utilities.
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.
# Size of the image, in MiB.
HDD_SIZE=64
# Geometry to format the partition with. Older mtools require one; 64 heads of
# 32 sectors make a cylinder exactly 1 MiB in size.
HDD_HEADS=64
HDD_SECTORS_PER_TRACK=32
HDD_CYLINDER_SECTORS=$(( HDD_HEADS * HDD_SECTORS_PER_TRACK ))
# Partition layout. sgdisk lays the partition out as GPT before converting the
# table to MBR, so the first and last cylinders are left to the GPT structures.
HDD_PART_START=$HDD_CYLINDER_SECTORS
HDD_PART_SECTORS=$(( (HDD_SIZE - 2) * HDD_CYLINDER_SECTORS ))
HDD_PART_END=$(( HDD_PART_START + HDD_PART_SECTORS - 1 ))
HDD_PART_OFFSET=$(( HDD_PART_START * 512 ))
# Create an empty zeroed-out image file.
dd if=/dev/zero bs=1024k count=0 seek=$HDD_SIZE of=image.hdd
# Create a partition table with a single partition.
PATH=$PATH:/usr/sbin:/sbin sgdisk image.hdd -n 1:$HDD_PART_START:$HDD_PART_END -t 1:ef00 -m 1
# Download and extract the latest Limine binary release.
curl -fL -o limine-binary.tar.gz https://github.com/Limine-Bootloader/Limine/releases/latest/download/limine-binary.tar.gz
gunzip < limine-binary.tar.gz | tar -xf -
# Build "limine" utility.
make -C limine-binary
# Install the Limine BIOS stages onto the image.
./limine-binary/limine bios-install image.hdd
# Format the partition as FAT. mtools are given the partition as a byte offset
# into the image, along with its size and geometry, which older releases cannot
# work out by themselves.
mformat -i image.hdd@@$HDD_PART_OFFSET -T $HDD_PART_SECTORS -h $HDD_HEADS -s $HDD_SECTORS_PER_TRACK ::
# Make relevant subdirectories.
mmd -i image.hdd@@$HDD_PART_OFFSET ::/EFI ::/EFI/BOOT ::/boot ::/boot/limine
# Copy over the relevant files.
mcopy -i image.hdd@@$HDD_PART_OFFSET bin/myos ::/boot
mcopy -i image.hdd@@$HDD_PART_OFFSET limine.conf limine-binary/limine-bios.sys ::/boot/limine
mcopy -i image.hdd@@$HDD_PART_OFFSET limine-binary/BOOTX64.EFI ::/EFI/BOOT
mcopy -i image.hdd@@$HDD_PART_OFFSET limine-binary/BOOTIA32.EFI ::/EFI/BOOT
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 a colour gradient filling the screen: black in the top left corner, turning green downwards and blue to the right.