Modula-2 Bare Bones
|
WAIT! Have you read Getting Started, Beginner Mistakes, and some of the related OS theory? |
| Difficulty level |
|---|
Medium |
This tutorial will give some insights on how to create a Multiboot2 kernel in Modula-2 running on modern x86_64 machines.
Preface
Modula-2 is a language which is developed in 1970s to 80s. While it has been used to develop multiple historical OSes like Medos-2 and Excelsior, there is almost no new design. Therefore, this tutorial gives a complete modern design of a Modula-2 kernel, with the latest toolchains, Multiboot2 protocol, and targeting x86_64.
Prerequisites
The following are required for building the kernel:
1. A working GNU Modula-2 compiler targeting x86_64 and producing ELF objects. You may compile it from the official GCC source by adding m2 to the tag --enable-languages. A cross compiler is not required (and building a cross compiler for Modula-2 requires some efforts, and may fail). Other compilers like ADW Modula-2 are not tested and are likely not going to work.
2. A working x86_64-elf Binutils for the assembler "as" and linker "ld." This should be included in the cross-compiling toolchain. Using the linux-gnu version may produce errors.
3. (Optional) GNU Make if you don't want to write a shell script to deal with complex building steps.
The following are required for running the kernel:
1. A Multiboot2-compatible bootloader, including Grub2 and Limine.
2. Tools for creating disk or ISO images, like xorriso.
3. An emulator supporting x86_64 like QEMU. Or, you can directly boot the kernel on your computer.
Finally, the following assumptions are made:
1. You have sufficient knowledge about Modula-2. Modula-2 tutorial can be a good start.
2. You have sufficient knowledge about building a x86_64 kernel with Multiboot2 in C/C++, including how to set-up long mode, paging, GDT, Multiboot Tag, etc. We will not explain too much about that. We will only focus on the differences between a C/C++ kernel and Modula-2 kernel.
Modula-2 Runtime Libraries
GNU Modula-2 supplies several runtime libraries: m2pim, m2iso, m2min, m2log, and m2cor. While m2pim is by default included, it requires an operating system for memory and IO operations. Only m2min is usable for bare metal applications. However, it supports only a little features. GCC only provides 3 modules:
1. M2RTS: It includes runtime module management. The functions inside are all stubs that do nothing. 2. SYSTEM: A minimal version of the SYSTEM module. It only provides some type names like ADDRESS, WORD, BYTE, etc., and some constants. No function is defined. 3. libc: Provides two functions: abort and exit, which are infinite loops.
Until GCC 16.1.0, the M2RTS module of m2min library is buggy (has incompatible signatures). So we will create our own M2RTS stub. Also, for bare metal applications, abort and exit are not needed. So we only use the SYSTEM module. This means that, except language features and built-in functions, you can use almost nothing.
Name Mangling
GNU Modula-2 will perform name mangling to the functions. Therefore, the actual exported function name is ModuleName_FunctionName. This is important when you write assembly stubs and link object files. To prevent name mangling, define the module for "C" and then export unqualified procedures.
Files
1. stub.S: M2RTS stubs.
2. boot.S: Setting up Multiboot2, long mode, paging, etc., and jump to the Modula-2 kernel. It should be exactly the same for different languages. You may refer to the official example of Multiboot2.
3. Multiboot2.def: A Modula-2 translation of multiboot2.h in the official example.
4. Kernel.def & Kernel.mod: The main kernel, setting up the framebuffer, and outputs some color to the screen.
5. libc.def & libc.mod: Implementation of memcpy, memset, memmove, and memcmp. Sometimes gcc compiler will generate calls to these functions even if you don't call them explicitly.
6. linker.ld: Link script.
Code Implementation
- stub.S
.global m2min_M2RTS_RequestDependant
.global m2min_M2RTS_RegisterModule
.text
m2min_M2RTS_RequestDependant:
ret
m2min_M2RTS_RegisterModule:
ret
This file only defines two functions: RequestDependant and RegisterModule, in the m2min library. They do nothing but directly return from the function body. It provides as a runtime stub. In fact, you don't need them in bare bones, but it can make the linker work.
- boot.S
This file is fairly large. And it is almost the same as the C/C++ version except the jump target. Remember to change the jump target to the kernel entry you defined in the kernel. Don't forget about the name mangling. If you want to see the full code, you can refer to the "Example" section.
- libc.def
DEFINITION MODULE FOR "C" libc ;
FROM SYSTEM IMPORT ADDRESS ;
EXPORT UNQUALIFIED memcpy, memset, memmove, memcmp;
PROCEDURE memcpy (dest, src: ADDRESS; n: CARDINAL) : ADDRESS ;
PROCEDURE memset (s: ADDRESS; c: INTEGER; n: CARDINAL) : ADDRESS ;
PROCEDURE memmove (dest, src: ADDRESS; n: CARDINAL) : ADDRESS ;
PROCEDURE memcmp (s1, s2: ADDRESS; n: CARDINAL) : INTEGER ;
END libc.
Declaring the memory functions (memcpy, memset, memmove, memcmp) that the compiler generates calls to internally. All symbols are exported unqualified with C linkage so they satisfy both Modula-2 IMPORT and compiler-generated references.
- libc.mod
IMPLEMENTATION MODULE libc ;
FROM SYSTEM IMPORT ADDRESS, BYTE, CARDINAL64 ;
TYPE
BytePtr = POINTER TO BYTE ;
(*
memcpy — copy n bytes from src to dest (non-overlapping).
*)
PROCEDURE memcpy (dest, src: ADDRESS; n: CARDINAL) : ADDRESS ;
VAR
d, s: BytePtr ;
i: CARDINAL ;
BEGIN
d := VAL (BytePtr, dest) ;
s := VAL (BytePtr, src) ;
i := n ;
WHILE i > 0 DO
d^ := s^ ;
d := VAL (BytePtr, VAL (ADDRESS, VAL (CARDINAL64, VAL (ADDRESS, d)) + 1)) ;
s := VAL (BytePtr, VAL (ADDRESS, VAL (CARDINAL64, VAL (ADDRESS, s)) + 1)) ;
DEC (i) ;
END ;
RETURN dest ;
END memcpy ;
(*
memset — set n bytes starting at s to byte value c.
*)
PROCEDURE memset (s: ADDRESS; c: INTEGER; n: CARDINAL) : ADDRESS ;
VAR
p: BytePtr ;
i: CARDINAL ;
BEGIN
p := VAL (BytePtr, s) ;
i := n ;
WHILE i > 0 DO
p^ := VAL (BYTE, c) ;
p := VAL (BytePtr, VAL (ADDRESS, VAL (CARDINAL64, VAL (ADDRESS, p)) + 1)) ;
DEC (i) ;
END ;
RETURN s ;
END memset ;
(*
memmove — copy n bytes from src to dest (handles overlap).
*)
PROCEDURE memmove (dest, src: ADDRESS; n: CARDINAL) : ADDRESS ;
VAR
d, s: BytePtr ;
dAddr, sAddr: CARDINAL64 ;
i: CARDINAL ;
BEGIN
dAddr := VAL (CARDINAL64, VAL (ADDRESS, dest)) ;
sAddr := VAL (CARDINAL64, VAL (ADDRESS, src)) ;
IF dAddr < sAddr THEN
(* Copy forward *)
d := VAL (BytePtr, dest) ;
s := VAL (BytePtr, src) ;
i := n ;
WHILE i > 0 DO
d^ := s^ ;
d := VAL (BytePtr, VAL (ADDRESS, VAL (CARDINAL64, VAL (ADDRESS, d)) + 1)) ;
s := VAL (BytePtr, VAL (ADDRESS, VAL (CARDINAL64, VAL (ADDRESS, s)) + 1)) ;
DEC (i) ;
END ;
ELSE
(* Copy backward *)
d := VAL (BytePtr, VAL (ADDRESS, dAddr + VAL (CARDINAL64, n) - 1)) ;
s := VAL (BytePtr, VAL (ADDRESS, sAddr + VAL (CARDINAL64, n) - 1)) ;
i := n ;
WHILE i > 0 DO
d^ := s^ ;
d := VAL (BytePtr, VAL (ADDRESS, VAL (CARDINAL64, VAL (ADDRESS, d)) - 1)) ;
s := VAL (BytePtr, VAL (ADDRESS, VAL (CARDINAL64, VAL (ADDRESS, s)) - 1)) ;
DEC (i) ;
END ;
END ;
RETURN dest ;
END memmove ;
(*
memcmp — compare n bytes of s1 and s2.
Returns 0 if equal, <0 if s1 < s2, >0 if s1 > s2.
*)
PROCEDURE memcmp (s1, s2: ADDRESS; n: CARDINAL) : INTEGER ;
VAR
p1, p2: BytePtr ;
i: CARDINAL ;
BEGIN
p1 := VAL (BytePtr, s1) ;
p2 := VAL (BytePtr, s2) ;
i := n ;
WHILE i > 0 DO
IF p1^ # p2^ THEN
IF VAL (CARDINAL, p1^) < VAL (CARDINAL, p2^) THEN
RETURN -1 ;
ELSE
RETURN 1 ;
END ;
END ;
p1 := VAL (BytePtr, VAL (ADDRESS, VAL (CARDINAL64, VAL (ADDRESS, p1)) + 1)) ;
p2 := VAL (BytePtr, VAL (ADDRESS, VAL (CARDINAL64, VAL (ADDRESS, p2)) + 1)) ;
DEC (i) ;
END ;
RETURN 0 ;
END memcmp ;
END libc.
Implementation of memcpy, memset, memmove, and memcmp functions in Modula-2. These should be simple and straightforward. If you are not familiar with Modula-2, you can also use C language to implement these stubs in "libc.c," and then compile to an object file.
- Multiboot2.def
A direct translation of multiboot2.h to Modula-2. Some hints:
1. Replace "#define" with CONST definitions.
2. Replace "struct" with RECORD definitions.
3. Replace the type names (for example, CARDINAL64 for uint_64, etc.).
If you don't want to build wheels on your own, it is also in the example. Refer to the "Example" section for complete code.
- Kernel.def
DEFINITION MODULE Kernel;
PROCEDURE KMain(Magic, InfoAddr: LONGCARD);
END Kernel.
Definition of the kernel module. It exports Kernel_KMain function.
- Kernel.mod
IMPLEMENTATION MODULE Kernel;
FROM SYSTEM IMPORT BYTE, BITSET8, ADDRESS, CARDINAL8, CARDINAL32, CARDINAL64;
FROM Multiboot2 IMPORT
MULTIBOOT2_BOOTLOADER_MAGIC, MULTIBOOT_INFO_ALIGN, MULTIBOOT_TAG_ALIGN,
MULTIBOOT_TAG_TYPE_END, MULTIBOOT_TAG_TYPE_FRAMEBUFFER,
MULTIBOOT_FRAMEBUFFER_TYPE_RGB,
MultibootTag, MultibootTagPtr,
MultibootTagFramebuffer, MultibootTagFramebufferPtr;
CONST
COM1 = 3F8H;
TYPE
(* 32-bit BGRA pixel (matches VBE RGB framebuffer layout) *)
Pixel32 = RECORD
blue: BYTE;
green: BYTE;
red: BYTE;
alpha: BYTE;
END;
Pixel32Ptr = POINTER TO Pixel32;
(*
ByteAnd - returns a bitwise (left AND right)
*)
PROCEDURE ByteAnd (left, right: BYTE) : BYTE ;
BEGIN
RETURN VAL (BYTE, VAL (BITSET8, left) * VAL (BITSET8, right))
END ByteAnd ;
(* Half and Catch Fire *)
PROCEDURE HCF;
BEGIN
LOOP
ASM VOLATILE ("hlt");
END;
END HCF;
PROCEDURE OUTB(port: SHORTCARD; value: BYTE);
BEGIN
ASM VOLATILE ("outb %0, %1" : : "a"(value), "Nd"(port));
END OUTB;
PROCEDURE INB(port: SHORTCARD): BYTE;
VAR
value: BYTE;
BEGIN
ASM VOLATILE ("inb %1, %0" : "=a"(value) : "Nd"(port));
RETURN value;
END INB;
PROCEDURE SerialInit;
BEGIN
OUTB(COM1 + 1, 000H); (* Disable all interrupts *)
OUTB(COM1 + 3, 080H); (* Enable DLAB (set baud rate divisor) *)
OUTB(COM1 + 0, 003H); (* Set divisor to 3 (lo byte) 38400 baud *)
OUTB(COM1 + 1, 000H); (* (hi byte) *)
OUTB(COM1 + 3, 003H); (* 8 bits, no parity, one stop bit *)
OUTB(COM1 + 2, 0C7H); (* Enable FIFO, clear them, with 14-byte threshold *)
OUTB(COM1 + 4, 00BH); (* IRQs enabled, RTS/DSR set *)
END SerialInit;
PROCEDURE SerialWriteChar(c: CHAR);
BEGIN
WHILE ByteAnd(INB(COM1 + 5), 20H) = 0 DO
(* Wait for the transmit buffer to be empty *)
END;
OUTB(COM1, BYTE(c));
END SerialWriteChar;
PROCEDURE SerialWriteString(s: ARRAY OF CHAR);
VAR
i: CARDINAL;
BEGIN
i := 0;
WHILE (i < HIGH(s)) AND (s[i] # BYTE(0)) DO
SerialWriteChar(s[i]);
INC(i);
END;
END SerialWriteString;
PROCEDURE SerialWriteLongCard(n: LONGCARD);
VAR
buffer: ARRAY [0..10] OF CHAR;
i: CARDINAL;
BEGIN
IF n = 0 THEN
SerialWriteChar('0');
RETURN;
END;
i := 0;
WHILE n > 0 DO
buffer[i] := VAL(CHAR, n MOD 10 + VAL(LONGCARD, ORD('0')));
INC(i);
n := n DIV 10;
END;
WHILE i > 0 DO
DEC(i);
SerialWriteChar(buffer[i]);
END;
END SerialWriteLongCard;
PROCEDURE SerialWriteLongCardHex(n: LONGCARD);
VAR
buffer: ARRAY [0..8] OF CHAR;
i: CARDINAL;
digit: LONGCARD;
BEGIN
i := 0;
WHILE i < 8 DO
digit := n MOD 16;
IF digit < 10 THEN
buffer[7 - i] := VAL(CHAR, digit + VAL(LONGCARD, ORD('0')));
ELSE
buffer[7 - i] := VAL(CHAR, digit - 10 + VAL(LONGCARD, ORD('A')));
END;
n := n DIV 16;
INC(i);
END;
SerialWriteString("0x");
FOR i := 0 TO 7 DO
SerialWriteChar(buffer[i]);
END;
END SerialWriteLongCardHex;
PROCEDURE KMain(magic, infoAddr: LONGCARD);
VAR
tag: MultibootTagPtr;
fbTag: MultibootTagFramebufferPtr;
pixel: Pixel32Ptr;
fbAddr: LONGCARD;
pitch, width, height: CARDINAL32;
bpp: CARDINAL8;
row, col: CARDINAL32;
rowAddr: LONGCARD;
BEGIN
SerialInit;
SerialWriteString("Hello, World from Modula-2 Kernel!");
SerialWriteChar(BYTE(10)); (* Newline *)
(* Check if the magic number is correct *)
IF magic # MULTIBOOT2_BOOTLOADER_MAGIC THEN
SerialWriteString("Error: Invalid magic number! Expected: ");
SerialWriteLongCardHex(MULTIBOOT2_BOOTLOADER_MAGIC);
SerialWriteChar(BYTE(10)); (* Newline *)
HCF;
END;
(* Check alignment *)
IF infoAddr MOD MULTIBOOT_INFO_ALIGN # 0 THEN
SerialWriteString("Error: Info address is not properly aligned!");
SerialWriteChar(BYTE(10)); (* Newline *)
HCF;
END;
(* ---- Find the framebuffer tag in the multiboot2 info structure ---- *)
tag := VAL(MultibootTagPtr, VAL(ADDRESS, infoAddr + 8));
WHILE (tag # NIL) AND (tag^.tagType # MULTIBOOT_TAG_TYPE_FRAMEBUFFER) DO
IF tag^.tagType = MULTIBOOT_TAG_TYPE_END THEN
tag := NIL;
ELSE
(* Advance to next tag: align (addr + size) to 8 bytes *)
tag := VAL(MultibootTagPtr,
VAL(ADDRESS,
(VAL(LONGCARD, VAL(ADDRESS, tag))
+ VAL(LONGCARD, tag^.size) + 7) DIV 8 * 8));
END;
END;
IF tag = NIL THEN
SerialWriteString("Error: No framebuffer tag found!");
SerialWriteChar(BYTE(10));
HCF;
END;
(* ---- Read framebuffer parameters ---- *)
fbTag := VAL(MultibootTagFramebufferPtr, VAL(ADDRESS, tag));
fbAddr := VAL(LONGCARD, fbTag^.framebufferAddr);
pitch := fbTag^.framebufferPitch;
width := fbTag^.framebufferWidth;
height := fbTag^.framebufferHeight;
bpp := fbTag^.framebufferBpp;
SerialWriteString("Framebuffer: ");
SerialWriteLongCard(width);
SerialWriteString("x");
SerialWriteLongCard(height);
SerialWriteString("x");
SerialWriteLongCard(VAL(LONGCARD, bpp));
SerialWriteString(", pitch=");
SerialWriteLongCard(pitch);
SerialWriteString(", addr=");
SerialWriteLongCardHex(fbAddr);
SerialWriteChar(BYTE(10));
(* ---- Fill the framebuffer with blue (32 bpp only) ---- *)
IF bpp = 32 THEN
FOR row := 0 TO height - 1 DO
rowAddr := fbAddr + VAL(LONGCARD, row) * VAL(LONGCARD, pitch);
FOR col := 0 TO width - 1 DO
pixel := VAL(Pixel32Ptr,
VAL(ADDRESS, rowAddr + VAL(LONGCARD, col) * 4));
pixel^.blue := BYTE(0);
pixel^.green := BYTE(0);
pixel^.red := BYTE(0);
IF col < width DIV 3 THEN
pixel^.red := BYTE(255);
ELSIF col < 2 * width DIV 3 THEN
pixel^.green := BYTE(255);
ELSE
pixel^.blue := BYTE(255);
END;
pixel^.alpha := BYTE(255);
END;
END;
SerialWriteString("Framebuffer filled with RGB!");
ELSE
SerialWriteString("Framebuffer is not 32 bpp (got ");
SerialWriteLongCard(VAL(LONGCARD, bpp));
SerialWriteString(" bpp) — skipping fill");
END;
SerialWriteChar(BYTE(10));
HCF; (* Halt the CPU after printing the messages *)
END KMain;
END Kernel
This kernel initializes the serial output COM1 for debugging, and then set-up the frame buffer. Finally, it prints three colors to the screen: red, green, and blue. It is almost the same as writing a similar kernel in C/C++, except:
1. The language core does not support bit operations. SO YOU NEED TO IMPLEMENT IT ON YOUR OWN! You can refer to the m2log library. But some functions require a runtime. You need to rewrite them for bare bones.
2. Number values require explicit type convertion using VAL function. It does not have implicit integer type convertion has C. This can make some operations, especially pointer arithmetics, especially complex.
- linker.ld
/* linker.ld — Linker script for the multiboot2 x86_64 kernel */
OUTPUT_FORMAT(elf64-x86-64)
ENTRY(_start)
SECTIONS
{
/* Load at 1 MB physical address */
. = 0x100000;
.multiboot_header : {
*(.multiboot_header)
}
.text : {
*(.text)
*(.text.*)
}
.data : {
*(.data)
*(.data.*)
}
.bss : {
*(COMMON)
*(.bss)
*(.bss.*)
}
/DISCARD/ : {
*(.comment)
*(.note.*)
*(.eh_frame)
*(.debug_*)
}
}
Building
Build the kernel object file and libc with:
$ gm2 -fno-exceptions -fno-wideset -mcmodel=large -flibs=min -mno-red-zone -mno-mmx -mno-sse -O2 -c -o FILENAME.o FILENAME.mod
Where FILENAME should be libc and Kernel.
Build the assembly files with:
$ x86_64-elf-as -o FILENAME.o FILENAME.S
Where FILENAME should be boot and stub.
Finally, link then together with:
$ x86_64-elf-ld -T linker.ld -nostdlib -o kernel.elf boot.o stub.o Kernel.o libc.o
If all correct, it will produce kernel.elf, which can be loaded by bootloaders supporting Multiboot2, like GRUB2 or Limine.
Example
The following is the minimal kernel I have written for this tutorial: https://github.com/fzhwenzhou/ModOS-kernel/tree/99a2388935ad93745b4ba956a1494db2d508b3d9 . Any file that is not included in this tutorial can be found in this repository.