The BamOS

It is open-source operating system project with it's own kernel.

This documentation will help you better understand the functioning of the OS, the structure of the project, and its conceptual foundation.

In short

The main goal is to develop a fast and multifunctional OS that will be compatible with user applications built for other systems (primarily GNU/Linux and Windows). Despite this, it should remain as simple and concise as possible.

Other important aspects include the cleanliness and simplicity of the code itself, having good documentation, and maximum accessibility. These should become distinctive features of BamOS.

It is important to understand that this is an evolving project, and therefore, many aspects may change. As a result, the documentation may not always include information about the latest updates. To help navigate and track changes in the project, a separate page is provided.

Philosophy

The philosophy of BamOS is centered around the pursuit of simplicity and purity.

Discussions about code purity, performance, and other similar topics can go on endlessly, often without reaching any significant conclusion.

Therefore, when we speak of purity and simplicity within this project, it should be understood as an idealized, mathematical model—something we strive towards but will likely never fully achieve. This creates a continuous process of movement and change, allowing us to avoid stagnation.

BamOS is not about business or marketing; it’s about technology, rationality, and accessibility for everyone.

Premises

The field of information technology—from microchips and hardware to web applications—is filled with imperfect solutions and non-technical factors (AND THAT’S OK). Most software is proprietary and designed to quickly meet the needs of the end user with the goal of out-earning competitors. Business sets the rules, and in the end, quality in technology is often a strong recommendation but not a strict rule. It’s not just businesses that contribute to subpar software quality; open-source software is also far from perfect, but this doesn’t imply ill intent or laziness on the part of developers—every system has its sharp and smooth edges, and every project has its reasons.

There is a lot of software that could be and can be better, faster, more efficient, and more accessible. Even large and well-known software products are not always great in this regard. And this provides motivation: the opportunity to influence the current state of affairs, contribute to improvements, and maybe even change the world for the better—or perhaps become one of those hundreds of thousands of projects that gather dust across the internet, never seeing their moment of glory. No one knows where this will lead ;)

Current Development Progress

  1. Main subsystems and components
  2. Hardware drivers
  3. Software drivers

Main subsystems and components

FeatureStatusDocs
x86-64 SupportMost recent
Memory managementMost recentMost recent
SMPAlmost done
Device managementAlmost doneMost recent
I/O subsystemDoneMost recent
Interrupt handlingAlmost doneMost recent
Device classes subsystemAlmost doneMost recent
Virtual file system*In progress
Processes and threadsPlanned
System callsPlanned
Logging subsystemMost recent

Hardware drivers

DriverStatusDocs
PCI/PCI-EAlmost done
NVMeAlmost done
UARTDraft
AHCIPlanned
USB (XHCI)Planned

Software drivers

DriverStatusDocs
ext2*In progress
tmpfs*In progress
initramfs*In progress
ext4Planned
devfsPlanned
NTFSPlanned
FAT32Planned

Kernel

Architecture

The BamOS kernel is a monolithic kernel, aimed at providing a complete set of functions necessary for working with various types of devices, interacting with the file system, fast and simple memory management, process and thread handling, and other features.

The main goal of its design is to strike a balance between simple code and functionality, while ensuring the best possible performance. These factors are essential to consider when developing new components of the system and expanding existing ones.

Therefore, despite the kernel’s monolithic nature, the code is clearly divided according to its purpose. All available tools and techniques are used to organize the code:

  • Separation into files and folders.
  • Implementation of distinct structures to create a clear hierarchy.
  • Grouping of specific functions/constants/variables/types within code files.

This helps maintain code readability and scalability.

Rationale

Monolithic kernels are often more tightly coupled compared to microkernels, which can lead to increased code dependency and is often considered bad practice for large-scale software. However, despite potential drawbacks, the primary reason for choosing this approach is to avoid the dynamic code issues seen in microkernels: system components do not know the complete system configuration in advance or the availability of subsystems. This necessitates dynamic identification of individual modules, their dynamic loading, and other aspects that are primarily service-related and do not directly provide specific functionality. This complicates the system without adding significant new capabilities for device management.

Moreover, an OS is always aimed at performing common tasks: working with hardware, file systems, running and executing programs, managing memory, and more. This is especially relevant for general-purpose operating systems, which is what BamOS aspires to be.

Thus, it is logical to implement pre-required known components in one place, gaining all the benefits of compile-time optimization and more.

Structure

The kernel can be represented as a tree-like structure, starting with the main kernel subsystems and branching out with more details.

Thanks to the clear project organization, the subsystem hierarchy mostly mirrors the directory and file hierarchy of the source code.

note

The structure reflects the kernel's current development stage.

Subsystems:

The most significant ones are highlighted in bold. Some have documentation available via links.

Main Components:

The kernel also includes specific components that are quite self-contained and do not belong to any particular subsystem.

  • Main/Start

    The kernel's entry point.

  • Panic/Trace

    Kernel panic handling and call stack tracing based on debugging information.

  • Symmetric Multiprocessing (SMP)

    Functionality for working with individual processor cores.

Additional Information

For more information, feel free to explore the kernel’s source code, which also contains additional documentation in the form of comments.

Virtual Memory Management Subsystem

Subsystem: vm

The subsystem implements all necessary functionality for memory management within the system. It also provides implementations of various allocators and some APIs for working with page tables.

The system has some specific characteristics.

Mappings

  • Upper Half

    This portion of virtual memory is reserved for kernel needs. The kernel code and data are all located in the upper half.

  • Lower Half

    This is used for user space. The kernel does not store any data in this section.

Linear Memory Access Region (LMA)

This is an important feature of the kernel architecture.

The Linear Memory Access region is a large area of virtual memory in the upper half with a predefined address and size (specific to each platform), which is linearly mapped to physical pages starting from address 0x0. The region size should be large enough to cover the potential range of physical addresses. For the x86-64 architecture, it is 256 GiB.

The main idea is the ability to access any physical address without the need for additional page mapping.

Mapping virtual to physical addresses is a costly operation, which may also require additional page table allocations.

The LMA region solves this problem, allowing significant optimization of memory handling within the kernel. The region is mapped only once during kernel initialization.

To convert any physical address to virtual and vice versa, you can use:

vm.getVirtLma(address: anytype) @TypeOf(address)
  • phys: the physical address/pointer.
vm.getPhysLma(address: anytype) @TypeOf(address)
  • virt: the virtual address/pointer.

    Only addresses obtained from vm.getVirtLma are allowed.

Memory Allocation

All memory allocation starts with physical page allocation, managed by vm.PageAllocator.

Page Allocator vm.PageAllocator

note

The page allocator is thread-safe.

This allocator is fast and efficient, based on a buddy algorithm.

The main goal during its design was to achieve the highest performance and minimize memory overhead. Avoiding additional memory allocation for the allocator itself is impossible, but this allocation is a one-time event during system initialization and is minimized in size.

Due to the chosen algorithm, the implementation allows allocation of page quantities that are powers of two, designated as rank.

The only drawback is the need to remember the size of the allocated region for subsequent freeing.

API

note

More detailed information is available in the code documentation.

vm.PageAllocator.alloc(rank: u32) ?usize

Allocates a linear block of physical pages of the specified rank (size). Returns the physical address of the allocated pages, or null if allocation fails.

  • rank: Determines the number of pages as 2^rank.
vm.PageAllocator.free(base: usize, rank: u32) void

Frees a physical memory of the specified rank (size).

  • base: Physical address of the first page in a linear block returned by vm.PageAllocator.alloc.
  • rank: Determines the number of pages as 2^rank, must be the same as in the vm.PageAllocator.alloc call.

Object Allocator vm.ObjectAllocator

note

The object allocator is not thread-safe. However, there is a simple wrapper, vm.SafeOma, which includes utils.Spinlock and is thread-safe.

tip

The allocator uses the LMA region for fast physical page mapping. Therefore, vm.getPhysLma can safely be used for allocated objects.

The object allocator was introduced for very fast allocation of memory sections of the same size. It’s useful in cases of frequent allocation of identical structures, such as descriptors for processes, files, devices, and more.

This allocator is based on the allocation of arenas (i.e., large blocks of one or more pages), which are then divided into multiple objects. When freed, objects are added to a singly linked list, allowing immediate reallocation: complexity O(1).

Fragmentation is either non-existent or amounts to only a few bytes per arena, depending on the object size.

API

note

More detailed information is available in the code documentation.

vm.ObjectAllocator.init(T: type) vm.ObjectAllocator

Initializes an allocator for a specific object type. Can be used at comptime.

  • T: The type of objects to allocate.
vm.ObjectAllocator.deinit(self) void

Deinitializes the allocator, freeing all allocated memory.

vm.ObjectAllocator.alloc(self, T: type) ?*T

Allocates memory for an object and casts it to a pointer of type T. Returns a pointer to the allocated object, or null if allocation fails.

  • T: type of the pointer.
vm.ObjectAllocator.free(self, obj_ptr: anytype) void

Frees the memory of an object. Invalid object pointers cause undefined behavior.

  • obj_ptr: pointer to the object to free.

Universal Allocator vm.UniversalAllocator

note

The universal allocator is thread-safe.

tip

The allocator uses the LMA region for fast physical page mapping. Therefore, vm.getPhysLma can safely be used for allocated sections.

The universal allocator allows allocation of memory sections of various sizes. It’s not as efficient as the page allocator or object allocator. However, there is often a need to allocate memory sections of unknown size, such as strings or other arrays. In these cases, this allocator is very useful.

It is based on multiple object allocators with predefined different sizes. When allocating memory, the size is rounded up to the nearest fit, which may cause some local fragmentation.

For large memory sections, it directly uses vm.PageAllocator, allocating the required number of pages. Sizes are also rounded, so it is best to allocate blocks in powers of two, matching page sizes, to avoid fragmentation.

API

note

More detailed information about the allocator’s implementation and limitations is available in the code documentation.

The vm subsystem provides convenient shortcuts for direct calls:

vm.malloc(size: usize) ?*anyopaque
vm.free(ptr: ?*anyopaque)

It also has a shortcut for allocating a specific object:

vm.alloc(T: type) ?*T

tip

When working with Zig’s standard library (std), you can also use the ready-made std.mem.Allocator implementation based on vm.UniversalAllocator:

  • vm.std_allocator

Working with Page Tables

The subsystem aims to remove platform dependency. It provides a set of functions for interacting with page tables and mapping virtual to physical addresses.

vm.PageTable represents the platform-dependent page table structure defined in arch.vm.PageTable.

vm.allocPt() ?*vm.PageTable

Allocates a new page table and zeroes all entries. Returns null if allocation fails.

vm.newPt() ?*vm.PageTable

Allocates a new page table and maps all necessary kernel units. Returns null if allocation fails.

vm.freePt(pt: *vm.PageTable) void

Frees a page table.

vm.getPt() *vm.PageTable

Gets the current page table from a specific CPU register.

vm.setPt(pt: *const vm.PageTable) void

Sets the given page table to a specific CPU register.

vm.mmap(
  virt: usize,
  phys: usize,
  pages: u32,
  flags: vm.MapFlags,
  page_table: *PageTable
) vm.Error!void

Maps a virtual memory range to a physical memory range. Returns vm.Error if mapping fails.

  • virt: base virtual address to which physicall region must be mapped.
  • phys: region base physical address.
  • pages: number of pages to map.
  • flags: flags to specify (see vm.MapFlags structure).
  • page_table: target page table.
vm.getPhys(address: anytype) ?@TypeOf(address)

Translates the virtual address into a physical address via the current page table. Returns the physical address or null if the address isn’t mapped.

vm.getPhysPt(address: anytype, pt: *const vm.PageTable) ?@TypeOf(address)

Translates the virtual address into a physical address via a specific page table. Returns the physical address or null if the address isn’t mapped.


More detailed information is provided in the code documentation.

Device Management Subsystem

Subsystem: dev

One of the primary functions of any operating system is to simplify interactions with the hardware on which it operates.

  • For the end user, this is almost a seamless part of the OS, performing tons of critically important work.

  • For software/driver developers, this part of the system should provide a convenient, high-level interface for interacting with and managing devices, as well as functionality for working with them within the kernel itself.

Device management is divided into two levels:

  • Low-level
  • High-level

The low-level involves system interaction with drivers, providing drivers with basic information about devices. The high-level allows device drivers to offer a high-level interface for the system to interact with the device. This level is described in the Classes section.

The low-level is based on three main components.

1. Buses dev.Bus

Conceptually, buses allow devices and their drivers to be grouped, enabling more efficient matching and combining of necessary elements.

The bus stores a list of devices associated with it, as well as the drivers intended to work with devices on this bus. When new devices are registered on the bus, for example, through a bus driver, the bus's drivers are checked for compatibility with the device. If they match based on specific parameters, the bus driver calls the probe function of the driver for further compatibility checks by the driver itself, and if successful, the device is assigned to the driver.

When a device is removed, the reverse process occurs, first detaching the device from the driver and then from the bus.

If no suitable driver is found for a device, it remains unbound and will be checked again if a new driver is registered for that bus.

When a driver is added, the bus iterates over all unbound devices and performs the same compatibility checks with the added driver.


To register a new bus in the system, the driver must call dev.registerBus(...) and pass previously initialized dev.BusNode structure.

note

dev.BusNode must be a staticaly allocated.

var bus = dev.Bus.init(
    // Name
    "my-bus-name", 

    // Operations
    .{
        .match = match,
        .remove = remove
    }
);
  • name: bus name, must be known at compile time to allow device drivers to dynamically access the bus object. The driver developer can find the name of a specific bus in the documentation and then call dev.getBus.

  • ops: bus operations.

    • match to check device compatibility with the driver.
    • remove to free resources when the device is removed.

2. Devices dev.Device

Devices represent real or virtual components within the system, managed by the bus. They can be attached to a specific driver, which will implement certain functionalities for the device.

Essentially, they are just structures that can store various specific data required by the device driver or bus to interact with them.

Devices can be registered either by device drivers or by the bus driver, for example, by enumerating all devices on the bus or through hot-plug interrupt detection.


Device registration is performed by calling dev.registerDevice(...).

tip

If it is the bus driver, you can use dev.Bus.addDevice(...) method to register devices.

fn registerDevice(
    comptime bus_name: []const u8,
    name: Name,
    driver: ?*const Driver,
    data: ?*anyopaque
) !*Device
  • bus_name: name of the bus this is device belongs to. Example: "pci".

  • name: name of the device.

    Since a device's name may change during operation (e.g., from the moment of registration to when the driver takes control), it is dynamic and may require memory allocation. To simplify this process, the subsystem provides APIs:

    • dev.nameOf(str): sets the name based on a constant string.
    • dev.nameFmt(fmt, args): sets the name through string formatting, useful for dynamic naming, for example: pci:0000:00:00.00, pci:00fc:aa:20.01
  • driver: the driver managing the device.

    This is optional and can be set to null, in which case the bus is responsible for finding a suitable driver for the device.

    If the device driver registers a device that is already compatible with it, it can pass the driver object obtained during registration.

  • data: any specific bus/driver/device data that may be used by device driver.

3. Drivers dev.Driver

A driver is the functional element in the device system, adding new capabilities for each device and implementing specific functionality.

To work within the subsystem, the driver developer must implement the following functions:

fn (device: *dev.Device) dev.Driver.Operations.ProbeResult

The function can use various methods to check the driver’s compatibility with a specific device.

If successful, it initializes the device, performs the necessary setup for its further use, and returns success. Otherwise, it returns missmatched, or any other value defined in dev.Driver.Operations.ProbeResult enum.

fn (device: *dev.Device) void

Called when a device is removed, to detach it from the driver, allowing the driver to free up all resources allocated for this device.


Driver registration is performed by calling dev.registerDriver(...) and passing previously initialized dev.DriverNode structure.

note

dev.DriverNode must be a staticaly allocated.

var driver = dev.Driver.init(
    // Name
    "my-driver-name",

    // Operations
    .{
        .probe = .{ .universal = probe },
        .remove = remove
    }
);
  • name: the driver name, must also be known at compile time, as it is purely informational and not intended to change at runtime.
  • ops: operations implemented by the driver, such as probe, remove, etc.

Then, to register driver within the system:

try dev.registerDriver("bus-name", &driver);

Internal Subsystems

Device management requires the use of various mechanisms. Therefore, the dev subsystem also includes its internal systems:

More detailed information is available via the links.

  • Objects and classes dev.obj/dev.classes

    Enables drivers to provide a high-level interface for working with devices.

  • Input/Output dev.io

    This subsystem provides a convenient API for performing platform-independent read/write operations and working with device registers.

  • Interrupts dev.intr

    Manages resources involved in interrupts and provides a convenient API for working with them.

  • Registers API dev.regs

    Useful common API for work with devices registers.

Internal Implementations

The subsystem also includes various drivers and components for the most common hardware and standards.

Platform Bus

The first and special bus in the device subsystem is the platform bus: platform. It is provided for platform-specific devices that cannot be discovered without specific code, such as RTC or HPET.

The key feature here is that platform drivers themselves are responsible for adding devices to this bus, not the bus driver.

Thus, a special functionality is used for platform drivers.

When registering the driver, the platform bus must be passed as the target for the driver, and the probe function should be specified as the .platform member. The function is called when the driver is registered, so the driver object is passed to it upon invocation, as the driver module itself does not have prior access to the registered driver object.

The probe function should look as follows:

fn probe(self: *dev.Driver) dev.Driver.Operations.ProbeResult

As seen in the prototype, the platform driver object is passed to the function, not the device. The driver should check for the presence of devices it targets, and if found, register them in the system by calling the member function dev.Driver.addDevice(...), which automatically associates the added device with the bus and the driver.

Standards

  • PCI dev.pci

    PCI bus driver.

  • ACPI dev.acpi

    Interface for working with Advanced Configuration and Power Interface and various tables.

Built-in Drivers

Drivers that are part of the kernel are located at /dev/drivers.

Built-in drivers do not require dynamic linking and are initialized in the order specified in the dev.AutoInit structure.

Some bus drivers, such as dev.pci, are also considered built-in.

Input/Output Subsystem

Subsystem: dev.io (hereafter io)

To simplify and standardize input/output operations for devices, a specialized subsystem was implemented.

It offers a platform-independent API for working with MMIO space and I/O ports, as well as the ability to implement your own I/O mechanism.

Additionally, for security purposes, the system provides functionality for managing and monitoring the address ranges of input/output spaces, binding them to specific devices. This helps resolve conflicts that arise when multiple drivers attempt to access the same I/O addresses.

caution

Of course, you can bypass the I/O space reservation functions for your device and directly use the read/write functions. However, this is strongly discouraged and such code is generally not allowed in the kernel.

I/O Mechanism io.Mechanism

An I/O mechanism is an abstraction that allows you to conveniently use different access methods, abstracting all specific code.

Mechanisms are entirely comptime structures and do not add runtime overhead.

Mechanisms are useful for dynamically switching between different ways of working with a device. Often, devices may support both I/O ports and MMIO. Using mechanisms, you can write code that is independent of the I/O method and dynamically select the appropriate mechanism at runtime.

Another use case for mechanisms is with the Registers API.

Creating a mechanism (all parameters must be known at compile time):

io.Mechanism(AddrType, DataType, readFn, writeFn, ?initFn) type
  • AddrType: the address type used by the mechanism, e.g., u32 or usize.

  • DataType: the data type, used to define the size and alignment for read/write operations, e.g., u8, u16, u32, etc.

  • readFn: the read function implementation.

    Should look like:

    fn read(address: AddrType) DataType
    
  • writeFn: the write function implementation.

    Should look like:

    fn write(address: AddrType, data: DataType) void
    
  • initFn: an initialization function used to run during runtime when creating an instance of the structure. This is an optional parameter and can be set to null.

    Should look like:

    fn init(base: AddrType, size: AddrType) anyerror!AddrType
    

    The function initializes and must return the base address (potentially modified) if successful.

Example Usage

const MyIoType = io.Mechanism(u16, u8, read, write, init);

fn init(base: u16, size: u16) !u16 {
    _ = io.request("My I/O", base, size, .io_ports) orelse return error.IoBusy;
    return base;
}

fn read(addr: u16) u8 {
    // Read byte from I/O port
    return io.inb(addr);
}

fn write(addr: u16, data: u8) void {
    // Write byte to port
    io.outb(addr, data);
}

pub fn use() void {
    try MyIoType.init(0xCF8, 0x4);

    MyIoType.write(0xCF8, 0x8000_0000);
    _ = MyIoType.read(0xCFC);
}

I/O Management API

io.request(
    comptime name: [:0]const u8,
    base: usize,
    size: usize,
    comptime io_type: Type
) ?usize

Reserves a specific range of I/O space for device.

  • name: the name of the region, used for informational purposes only.
  • base: the base address of the I/O space.
  • size: the size of the space relative to the base address.
  • io_type: the type of space, which can be io_ports or mmio.

The function returns null if the request fails, for example, if the space is already occupied by another device.

io.release(base: usize, comptime io_type: Type) void

Releases the reserved range of I/O space.

  • base: the base address specified during io.request.
  • io_type: the I/O type specified during io.request.

I/O Ports

Mechanism

io.IoPortsMechanism(
    comptime name: [:0]const u8,
    comptime bus_width: io.BusWidth
) type

Returns a mechanism that implements I/O operations through I/O ports. Implements init for automatic io.request invocation.

  • name: the name used during mechanism initialization in the io.request call.

  • bus_width: defines the data size for read/write operations.

    Supported options are: .byte: u8, .word: u16, .dword: u32; .qword is not supported.

API Functions

Reading:

io.inb(address); // read one byte.
io.inw(address); // read one word.
io.inl(address); // read a double word.

Writing:

io.outb(address, data); // write one byte.
io.outw(address, data); // write one word.
io.outl(address, data); // write a double word.

MMIO Space

Mechanism

io.MmioMechanism(
    comptime name: [:0]const u8,
    comptime bus_width: io.BusWidth
) type

Returns a mechanism that implements I/O operations through MMIO space. Implements init for automatic io.request invocation and maps the provided MMIO base address to virtual address space. init returns a virtual address.

  • name: the name used during mechanism initialization in the io.request call.

  • bus_width: defines the data size for read/write operations.

    Supported options are: .byte: u8, .word: u16, .dword: u32, .qword: u64.

API Functions

Reading:

io.readb(address); // read one byte.
io.readw(address); // read one word.
io.readl(address); // read a double word.
io.readq(address); // read a quad word.

Writing:

io.writeb(address, data); // write one byte.
io.writew(address, data); // write one word.
io.writel(address, data); // write a double word.
io.writeq(address, data); // write a quad word.

Registers API

Subsystem: dev.regs (hereafter regs)

This API provides convenient abstractions for generalized work with device registers.

Interacting with registers is one of the primary tasks a driver developer faces. The API addresses common issues with choosing how to access and interpret register layouts in code.

Typically, developers define constants for register offsets relative to a base address, like:

const SOME_REG = 0x100;
const ANOTHER_REG = 0x1F0;

They then write specific read and write functions.

Since this is a common task, many aspects can be generalized and pre-implemented. To ensure uniform and clean code in drivers, this API is presented in the kernel.

Register Group regs.Group

A register group is a structure that abstracts working with a specific group of registers.

With it, you can define the method or mechanism (dev.io.Mechanism) for accessing registers, as well as the names, locations, and sizes of the registers.

regs.Group uses comptime calculations and does not add any runtime overhead.

API

fn regs.Group(
    comptime IoMechanism: type,
    comptime base: ?comptime_int,
    comptime size: ?comptime_int,
    comptime regs: []const Register
) type

Returns a register group type.

  • IoMechanism: type of mechanism used for interacting with registers.

  • base: comptime base address of the register group.

    This parameter is optional and can be null. It is used only if the base is always static and known at compile time, avoiding runtime address calculations.

  • size: size of the register group.

    This is also optional and can be null. The size can be calculated automatically based on the location and size of all the registers in the group.

    It is used if the IoMechanism implements an init function to pass the size parameter.

  • regs: a slice of registers contained in the group.

    Each register in the slice is of type regs.Register, see below.

Usage

To use, you need to create an instance of the group structure.

  • For a static group, where the base was specified in regs.Group and is known at compile time:

    const/var my_regs = try MyGroup.init();
    

    The init function returns a structure instance, and if the IoMechanism implements its own init function, it is also called to initialize the access mechanism.

  • For a dynamic base group, where the base is known at runtime:

    const/var my_regs = try MyGroup.initBase(base);
    
    • base: the base address of the group.

    The initBase function returns a structure instance, and if the IoMechanism implements its own init function, it is also called to initialize the access mechanism. In this case, the base returned by the IoMechanism.init function is used.

For a dynamic base group, the structure stores only one field of type IoMechanism.Address. If the base address is known at compile time, the group structure stores no fields and takes up no memory.

When io.Group is called, an enumeration of register names is generated, which is then used when calling read/write functions.

Interacting with registers

  • Reading:

    fn read(member) RegIntType
    

    Reads a register.

    • member: the register name from the enumeration, e.g., .reg_name.

    RegIntType is the register type defined in regs.Register.

    IoMechanism ensures that data is read at a properly aligned address of the size defined by IoMechanism.DataType. However, the register itself may be larger or smaller, so RegIntType represents the specific register type, and the reading process accounts for register offset and size.

    fn get(T: type, member) T
    

    Reads a register and converts the read value into type T.

    • member: the register name from the enumeration, e.g., .reg_name.

    • T: the return type.

      The read numeric value is cast into type T using @bitCast.

    This function is useful if you need to read a register and get its representation in a specific type, for example, to work with register bit fields.

  • Writing:

    fn write(member, data: RegIntType) void
    

    Writes to a register.

    • member: the register name from the enumeration, e.g., .reg_name.
    • data: the value to write.

    Similar to reading, writing takes register offset and size into account, allowing writes to misaligned or non-multiple-of-size registers.

    fn set(member, data: anytype) void
    

    Writes to a register, casting the data to a numeric value.

    • member: the register name from the enumeration, e.g., .reg_name.

    • data: the value to write.

      The data value is cast into the target register type RegIntType using @bitCast.

    This function is useful when you need to write an entire user-defined structure to a register, for example, after initially obtaining the structure using get.

Register regs.Register

A structure used to provide register information to io.Group. It allows you to specify some details.

fn regs.reg(
    comptime name: [:0]const u8,
    comptime offset: comptime_int,
    comptime Type: ?type, 
    comptime access: Access,
) Register
  • name: the register name.

  • offset: the offset relative to the base address of the group.

  • Type: the register type, used as RegIntType.

    This is optional and can be null.

    The Type must be an unsigned integer: u<x>, where x is the bit width.

  • access: access mode for the register, can be: .rw, .read, .write.

    Used to enhance security, preventing unauthorized read or write operations for the register. A compilation error is generated when an invalid operation is called.

    • rw: read and write.
    • read: read-only.
    • write: write-only.

Additionally, register information can be automatically generated based on a user-defined structure as a layout. Use the regs.from(...) function for this.

fn regs.from(comptime Layout: type) []const Register

Generates registers information and returns a slice of []const regs.Register.

  • Layout: the name of the user-defined structure.

    The layout can be a struct, packed struct, extern struct, or union.

    Each field in this structure is interpreted as a separate register. The register name corresponds to the field name, the register offset is the field's offset within the structure, and the field's type is the register type.

    Fields whose names start with an underscore _ are ignored.

    To specify access mode access, use special types for structure fields:

    • regs.ReadOnly(u<x>): for read-only registers.

    • regs.WriteOnly(u<x>): for write-only registers.

      u<x>: the type, an unsigned integer where x is the bit width.

      For packed or extern structs, use the postfix P or E: regs.ReadOnlyP(u32), regs.WriteOnlyE(u32), etc.

      By default, other registers are read-write.

If Layout is a union, then the Layout itself is interpreted as a group of Layouts. In this case, each union field allows you to define its own set of unique registers with their own names and offsets.

note

However, register names must not be duplicated.

Example:

const Layout = packed union {
    regs1: packed struct {
        address_reg: u64,
        control_reg: u16,
        id_reg: u16
    },
    regs2: packed struct {
        _rsrdv: u64, // Ignored

        data_reg: u32,
        status_reg: u32
    }
};

Examples

At first glance, the abstraction may seem overly complicated. However, it is quite simple to use in practice. To better understand, examples are provided below.

Manually Defining Registers

const reg = dev.regs.reg;

const uart_base = 0x3f8;

const UartRegs = dev.regs.Group(
    dev.io.IoPortsMechanism(
        "uart 8250/16450/16550",
        .byte
    ),          // IoMechanism
    uart_base,  // Base
    null,       // Size

    // Registers slice
    &.{
        // DLAB == 0
        reg("data",         0x0, null, .rw),
        reg("intr_enable",  0x1, null, .rw),

        // DLAB == 1
        reg("div_lo",       0x0, null, .rw),
        reg("div_hi",       0x1, null, .rw),

        reg("intr_id",      0x2, null, .read),

        reg("fifo_ctrl",    0x2, null, .write),
        reg("line_ctrl",    0x3, null, .rw),
        reg("modem_ctrl",   0x4, null, .rw),

        reg("line_status

",  0x5, null, .read),
        reg("modem_status", 0x6, null, .read),

        reg("scratch",      0x7, null, .rw),
    }
);

Automatic Registers Layout Based on a Structure

The code is similar to the manual register example.

const UartRegs = dev.regs.Group(
    dev.io.IoPortsMechanism(
        "uart 8250/16450/16550",
        .byte
    ),          // IoMechanism
    uart_base,  // Base
    null,       // Size

    // Registers slice
    dev.regs.from(UartRegsLayout)
);

// Layout `union`
const UartRegsLayout = packed union {
    dlab_0: packed struct {
        data: u8,
        intr_enable: u8,

        fifo_ctrl: dev.regs.WriteOnlyP(u8);

        line_ctrl: u8,
        modem_ctrl: u8,

        line_status: dev.regs.ReadOnlyP(u8),
        modem_status: dev.regs.ReadOnlyP(u8),

        scratch: u8
    },
    dlab_1: packed struct {
        div_lo: u8,
        div_hi: u8
    },

    intr: packed struct { // Just for `intr_id` register.
        _offset: u16,

        intr_id: dev.regs.ReadOnlyP(u8);
    },
};

Usage

This example shows how a previously defined group of registers can be used.

const regs = UartRegs{};

pub fn init() !void {
    // Call `init` at runtime to trigger `dev.io.request`
    // which is used by `dev.io.IoPortsMechanism`.
    _ = try UartRegs.init();

    regs.write(.intr_enable, 0x00); // Disable all interrupts

    regs.write(.line_ctrl, 0x80);   // Enable DLAB (set baud rate divisor)
    regs.write(.div_lo,    0x03);   // Set divisor to 3 (low byte) 38400 baud
    regs.write(.div_hi,    0x00);   //                   (high byte)

    regs.write(.line_ctrl, 0x03);   // 8 bits, no parity, one stop bit
    regs.write(.fifo_ctrl, 0xC7);   // Enable FIFO, clear it, with 14-byte threshold

    regs.write(.modem_ctrl, 0x0B);  // IRQs enabled, RTS/DSR set
}

pub fn deinit() void {
    // Don't forget to release I/O space.
    dev.io.release(uart_base, .io_ports);
}

Interrupts Subsystem

Subsystem: dev.intr (hereafter intr)

The interrupt subsystem provides a high-level interface for working with interrupts within drivers and other kernel components.

The core concept is similar to the interrupt handling in GNU/Linux, but this is only a superficial resemblance, and this implementation does not guarantee any compatibility.

Interrupts are set up, configured, and balanced across CPUs automatically. The kernel or driver developer only needs to call specific API functions to gain access and request the necessary resources for handling interrupts.

The interrupt system supports and distinguishes between two main types of interrupts: IRQ and MSI, allowing drivers to request the required ones.

IRQ

intr.requestIrq(
    pin: u8,
    device: *dev.Device,
    handler: Handler.Fn,
    tigger_mode: TriggerMode,
    shared: bool
) Error!void

Allows requesting and setting an interrupt handler for a specific pin.

  • pin: specifies the exact line number for which the handler is to be set.

    This line is referenced according to the device's documentation and, if necessary, automatically converted into the line number connected to the interrupt controller.

    For example: on the x86-64 architecture, IRQ-8 corresponds to pin 8.

  • device: the device object for which the interrupt is requested.

  • handler: the interrupt handler, see interrupt handler below.

  • trigger_mode: the trigger mode: .edge, .level_low or .level_high.

  • shared: whether other devices can use this line or if it must be dedicated to the current device.

    It is recommended to always set this parameter to true and only set it to false in exceptional cases where there are strong justifications. If false is used, no other device in the system will be able to use this line, and as a result, some devices may not have an interrupt handler at all.

intr.releaseIrq(
    pin: u8,
    device: *const dev.Device
) void

Removes the interrupt handler for the given device and frees the resources.

  • pin: the interrupt line number specified when calling intr.requestIrq.
  • device: the device for which the interrupt was requested.

MSI

note

This is a platform-independent MSI implementation, which does not work directly with devices but only configures the necessary components related to the CPU and interrupt controller. Therefore, drivers using MSI must also properly configure the message generated by the device, including its address and data, which are provided through intr.getMsiMessage, see below.

tip

To work with MSI on PCI devices, use the functionality provided by the PCI bus driver.

intr.requestMsi(
    device: *dev.Device,
    handler: Handler.Fn,
    trigger_mode: TriggerMode
) Error!u8

Used to request and configure MSI interrupts.

  • device: the device object for which the interrupt is requested.
  • handler: the interrupt handler, see interrupt handler below.
  • trigger_mode: the trigger mode: .edge, .level_low, .level_high.

The function returns a unique MSI interrupt number, which is used for further handling.

intr.releaseMsi(idx: u8) void

Removes the MSI interrupt handler and frees the resources.

  • idx: the unique MSI interrupt number obtained when calling intr.requestMsi.
intr.getMsiMessage(idx: u8) Msi.Message

Returns the intr.Msi.Message structure, allowing you to retrieve the necessary address and data for the message that the device will send when generating an interrupt.

  • idx: the unique MSI interrupt number obtained when calling intr.requestMsi.

Interrupt Handler

A high-level interrupt handler is a simple function like:

fn handler(device: *dev.Device) bool

It takes the device for which the interrupt was configured. The function returns a value because IRQ interrupts can be shared and may be triggered by multiple devices. It’s necessary to determine whether this interrupt was generated by your specific device.

The handler should check if the interrupt relates to its device. If not, it should simply return false; otherwise, it should handle the interrupt and return true.

Architecture

To achieve such simplicity in interaction, a specific architecture was introduced that separates platform-dependent code from the general implementation.

On the platform side, the architecture must provide functions to initialize the interrupt system and obtain an object representing the platform's interrupt controller.

This object is of type intr.Chip.

The intr.Chip structure must provide the following functions:

  • eoi: sends the End Of Interrupt signal to the interrupt controller.
  • bindIrq: sets up and binds the intr.Irq interrupt.
  • unbindIrq: unbinds the intr.Irq interrupt.
  • maskIrq: masks the intr.Irq interrupt.
  • unmaskIrq: unmasks the intr.Irq interrupt.
  • configMsi: configures and sets up the intr.Msi interrupt.

Currently, BamOS supports using only one interrupt controller at a time.

The platform code provides the intr.Chip structure via the arch.intr.init function during the interrupt system initialization, which is invoked by the high-level interrupt system intr.

Thus, all platform-dependent code is encapsulated within one structure. When writing platform code, it is strongly recommended to implement a unified interface for working with interrupt vector tables and other features common to all CPU architectures. Then, specific code can be written to work with the interrupt controller(s), dynamically selecting the most suitable/available controller during initialization.

This is the approach taken by the x86-64 architecture implementation.

Example Usage

fn probe(device: *dev.Device) dev.Driver.Operations.ProbeResult {
    ...

    // Request IRQ
    dev.intr.requestIrq(
        IRQ_NUM, device, intrHandler, .edge, true
    ) catch {
        ...

        return .failed;
    };

    ...
    return .success;
}

// IRQ Handler
fn intrHandler(device: *dev.Device) bool {
    ...

    return true;
}

Class-based High-Level Interface for Devices

Subsystem: dev.obj (hereafter obj)

This subsystem serves as an intermediary between device drivers and other system components. With this interface, drivers can provide a high-level implementation for interacting with devices within the system.

The device management system dev initially provides only low-level functionality for working with devices, specifically through dev.Bus buses and dev.Device structures. This enables linking the appropriate drivers with the appropriate devices.

However, a driver must also provide the system with an interface to work with the device. Therefore, a class/object system was developed to offer high-level access to devices.

Classes and Objects

To keep things simple, classes are straightforward: a class is just a standard type in Zig. The only requirement is that the type must be a structure.

Thus, any driver can implement its own class without issue, though this is only meaningful if another component can interact with that class, i.e., if other code also references the class structure used by the driver.

The kernel provides several classes that are already used by the system:

  • Source code: /dev/classes.
  • Import: dev.classes.

The main idea of class implementation is to define a virtual method table (vtable) so that the driver provides its own implementation of certain functions. At the same time, based on vtable functions, an API for convenient and straightforward device interaction can be implemented within the class itself.

This serves as a high-level interface that enables other system components to interact consistently with devices of specific classes.

Implementing the Interface in a Driver

What needs to be implemented depends on the specific class, as the obj subsystem does not impose restrictions.

As described above, it’s assumed that a class provides certain fields and a virtual table (vtable) so the driver can fill in the relevant values. Thus, the driver needs to implement specific methods for the vtable and set field values according to the class structure.

However, the driver may also need to store additional data for each object. Therefore, the class structure can be extended via obj.Inherit:

obj.Inherit(Base: type, Derived: type) type

Returns a new type that includes both the base class and the derived class.

Essentially, it’s a structure with fields:

  • base: Base;
  • derived: Derived.

This function safely merges two structure types into one, ensuring that:

  • pointer_to_base == pointer_to_new_inherit_struct
  • pointer_to_derived != pointer_to_new_inherit_struct

This allows you to create an instance of the new structure and safely cast it to the base type Base (but not to the Derived type).

This approach is necessary due to the way memory allocation and object registration are handled in this subsystem.

Object Registration

To register a high-level device object in the system, follow these steps:

  1. Allocate an object of the specified class (type).
  2. Initialize and configure the object’s fields (structure).
  3. Add the object to the object subsystem to make it accessible.

Object Allocation:

obj.new(T: type) Error!*T

Allocates an object of class T. Returns an NoMemory error if it fails.

  • T: the class type.

If necessary, you can also free the allocated object:

obj.delete(T: type, object: *T)
  • T: the class type.
  • object: a pointer to the object being freed.

Initialization:

Initialization depends solely on the specific class, so make sure you complete all necessary steps for the particular class and fill in the required structure fields.

Adding the Object

important

If you used obj.Inherit to extend the implemented class, you must specify the base class type when registering the object and pass a pointer to the base class structure as object.

obj.add(T: type, object: *T)

Registers an object of the specified class T in the object subsystem.

  • T: the class type.
  • object: a pointer to the object.

To remove an object from the system:

note

Resource deallocation occurs automatically when an object is removed. Calling obj.delete(...) is redundant.

obj.remove(T: type, object: *T)

Removes a previously registered object and frees the allocated resources.

  • T: the class type.
  • object: a pointer to the object being removed.

Logging Subsystem

The logging system involves collecting and outputting various messages provided by all parts of the kernel. To improve convenience and standardization, the system’s primary feature is its use of the existing logging interface in Zig's standard library: std.log.

Currently, logging is limited to distributing logs via the serial port and displaying messages graphically on the screen. After the file system implementation, it is planned to redirect log messages to the appropriate file(s) in line with the concept of Unix-like systems.

Implementation

The main component of the subsystem is the logger.

logger implements the defaultLog function in accordance with the std.log.defaultLog interface from the standard library. This replaces the default implementation, allowing the logger to be used directly via std.log.

Thread Safety

The logger considers multithreaded systems and has built-in locks to ensure thread-safe logging. The subsystem also handles complex scenarios, such as logging during system failures, and avoids deadlocks using specialized functions:

fn capture() void

This is used for safely capturing or locking the output stream. It helps avoid situations where the stream was locked by the current thread and, due to an exception or interrupt, attempts to capture it again.

fn release() void

Safely unlocks the log output stream.

This functionality is not recommended for direct use without specific reasons. It is primarily intended for the panic module, which requires capturing the stream during calls to @panic(...) or when an unhandled hardware exception occurs, using panic.exception(...) to log useful information for debugging.

Output Stream

Since kernel logging must always be available, including during various stages of system boot, not all required components may be accessible, such as a display for graphical output or a file system for saving logs. For this reason, the logging system uses the std.io.AnyWriter abstract interface from the standard library for log writing.

At different stages, the system uses different writer implementations:

  1. EarlyWriter

    Used during the very early stages of system boot.
    It provides a temporary buffer to store all logs recorded during these stages.

    Additionally, it uses the serial port to output logs directly.

  2. KernelWriter

    The kernel writer handles log output until the file system is mounted and user space is launched. It is used after the initialization of the main kernel subsystems and thus outputs logs not only via the serial port but also graphically on the display.

Usage

To perform logging, you simply need to use std.log.

However, it is recommended to specify a logging scope, allowing you and other developers to conveniently search for and review logs.

Example:

const std = @import("std");

const log = std.log.scoped(.my_scope);

fn someTemporaryFunction() {
    ...
    log.info("I'm here!", .{});

    ...

    if (someFail) {
        log.err("Bad! Really Bad!", .{});
    }
}

This would result in output similar to:

[INFO] my_scope: I'm here!
[ERROR] my_scope: Bad! Really Bad!