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
Main subsystems and components
Feature | Status | Docs |
---|---|---|
x86-64 Support | Most recent | |
Memory management | Most recent | Most recent |
SMP | Almost done | |
Device management | Almost done | Most recent |
I/O subsystem | Done | Most recent |
Interrupt handling | Almost done | Most recent |
Device classes subsystem | Almost done | Most recent |
Virtual file system | *In progress | |
Processes and threads | Planned | |
System calls | Planned | |
Logging subsystem | Most recent |
Hardware drivers
Driver | Status | Docs |
---|---|---|
PCI/PCI-E | Almost done | |
NVMe | Almost done | |
UART | Draft | |
AHCI | Planned | |
USB (XHCI) | Planned |
Software drivers
Driver | Status | Docs |
---|---|---|
ext2 | *In progress | |
tmpfs | *In progress | |
initramfs | *In progress | |
ext4 | Planned | |
devfs | Planned | |
NTFS | Planned | |
FAT32 | Planned |
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.
- Architecture
- Template
- x86-64
- Paging
- I/O
- Registers
- Interrupt system implementation
- APIC (LAPIC, I/O APIC), PIC
- IDT / Exceptions
- Devices
- Classes
- Standards
- ACPI
- PCI
- Built-in drivers
- Interrupt System
- High-level interrupt handling
- Resource management
- Input/Output System
- Registers
- Access mechanisms
- MMIO, PIO
- Virtual Memory
- Page table management
- Physical page allocator
- Various memory allocators
- Virtual File System
- Logging Subsystem
- Boot
- Video (may be removed in the future)
- Framebuffers
- Text output
- Utilities
- Data structures
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 as2^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 byvm.PageAllocator.alloc
.rank
: Determines the number of pages as2^rank
, must be the same as in thevm.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 (seevm.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 calldev.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 asprobe
,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
orusize
. -
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 tonull
.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 beio_ports
ormmio
.
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 duringio.request
.io_type
: the I/O type specified duringio.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 theio.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 theio.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 aninit
function to pass thesize
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 theIoMechanism
implements its owninit
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 theIoMechanism
implements its owninit
function, it is also called to initialize the access mechanism. In this case, the base returned by theIoMechanism.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 inregs.Register
.IoMechanism
ensures that data is read at a properly aligned address of the size defined byIoMechanism.DataType
. However, the register itself may be larger or smaller, soRegIntType
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 typeRegIntType
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 asRegIntType
.This is optional and can be
null
.The
Type
must be an unsigned integer:u<x>
, wherex
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
, orunion
.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 wherex
is the bit width.For
packed
orextern
structs, use the postfixP
orE
: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 Layout
s. 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 tofalse
in exceptional cases where there are strong justifications. Iffalse
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 callingintr.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 callingintr.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 callingintr.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 theintr.Irq
interrupt.unbindIrq
: unbinds theintr.Irq
interrupt.maskIrq
: masks theintr.Irq
interrupt.unmaskIrq
: unmasks theintr.Irq
interrupt.configMsi
: configures and sets up theintr.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:
- Allocate an object of the specified class (type).
- Initialize and configure the object’s fields (structure).
- 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:
-
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.
-
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!