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 | 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 |
Code Style
Naming convention
The project strictly follows naming convention of the Zig language. You should study this if you are going to contribute to the project and are not yet familiar with it.
Additionally, the project has more specific naming rules. These rules govern the usage of terms and expressions, not the syntax.
There are many commonly used general terms for naming functions. The most frequent ones in our project are: init, deinit, alloc, free, new, delete, make. Functions with these keywords are found for almost every declared structure, in almost every file. Therefore, to avoid ambiguity in interpreting the meaning of these words, clear definitions of their meaning within the project context are provided.
init
- For kernel subsystems: it means to bring into an operational state, perform initial setup, make the subsystem valid within the system boot process.
- For various structures, such as
File,Inode,Process,Device, etc.: it initializes the structure by pointer. This function can take any additional arguments and return an error, a status code, or simplyvoid. Generally, such a function should not return an instance of the structure.
Example:
pub fn init(self: *Self) !void { ... }
deinit
- For kernel subsystems: it brings the subsystem into a correct state before a full or partial system shutdown.
- For structures: it deinitializes the structure, releases all resources used by it. After this, the state of the structure is undefined and its further use is prohibited unless explicitly stated otherwise.
alloc
The primary meaning is to allocate a resource for temporary use, usually implying the possibility of releasing such a resource via a call to free. The return value is preferably null/error or a resource, like ?MyResource/!MyResource.
If memory allocation is implied: it exclusively expects the allocation of an uninitialized memory region of a certain size with correct alignment.
free
Releases a resource previously allocated via alloc.
If used for freeing memory, the function must not perform deinitialization of the memory area, but only release that area.
new
General meaning: alloc + init.
delete
The reverse analog of new, meaning deinit + free.
make
Initializes and returns a local instance of a structure. This is an alternative to init that does not allocate memory but constructs the structure "on the stack". Also may return error or null.
Example:
pub fn make() Self { return .{} }
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.getVirtLmaare 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.alloccall.
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.MapFlagsstructure).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.matchto check device compatibility with the driver.removeto 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.classesEnables drivers to provide a high-level interface for working with devices.
-
Input/Output
dev.ioThis subsystem provides a convenient API for performing platform-independent read/write operations and working with device registers.
-
Interrupts
dev.intrManages resources involved in interrupts and provides a convenient API for working with them.
-
Registers API
dev.regsUseful 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.pciPCI bus driver.
-
ACPI
dev.acpiInterface 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.,u32orusize. -
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!AddrTypeThe 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_portsormmio.
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.requestcall. -
bus_width: defines the data size for read/write operations.Supported options are:
.byte: u8,.word: u16,.dword: u32;.qwordis 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.requestcall. -
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:comptimebase 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
IoMechanismimplements aninitfunction to pass thesizeparameter. -
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.Groupand is known at compile time:const/var my_regs = try MyGroup.init();The
initfunction returns a structure instance, and if theIoMechanismimplements its owninitfunction, 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
initBasefunction returns a structure instance, and if theIoMechanismimplements its owninitfunction, it is also called to initialize the access mechanism. In this case, the base returned by theIoMechanism.initfunction 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) RegIntTypeReads a register.
member: the register name from the enumeration, e.g.,.reg_name.
RegIntTypeis the register type defined inregs.Register.IoMechanismensures that data is read at a properly aligned address of the size defined byIoMechanism.DataType. However, the register itself may be larger or smaller, soRegIntTyperepresents the specific register type, and the reading process accounts for register offset and size.fn get(T: type, member) TReads 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
Tusing@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) voidWrites 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) voidWrites 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
datavalue is cast into the target register typeRegIntTypeusing@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
Typemust be an unsigned integer:u<x>, wherexis 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 wherexis the bit width.For
packedorexternstructs, use the postfixPorE: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_lowor.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
trueand only set it tofalsein exceptional cases where there are strong justifications. Iffalseis 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.Irqinterrupt.unbindIrq: unbinds theintr.Irqinterrupt.maskIrq: masks theintr.Irqinterrupt.unmaskIrq: unmasks theintr.Irqinterrupt.configMsi: configures and sets up theintr.Msiinterrupt.
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_structpointer_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.free(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.free(...) 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:
-
EarlyWriterUsed 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.
-
KernelWriterThe 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!