Define the Core Task Struct for Kernel Multitasking

Mục tiêu: Lay the groundwork for preemptive multitasking by defining the essential Task struct in a new task.rs module. Implement a unique TaskId using an atomic counter and a TaskState enum to manage task identity and state.


Of course! As an expert in technology solutions, I will guide you through this task. Here is the detailed solution in a blog-article format.


You’ve just accomplished something truly foundational: building a working kernel heap. This is a monumental step that transforms your kernel from a static program into a dynamic system, capable of managing memory at runtime. With the power of dynamic allocation using Box and Vec, you’ve unlocked the ability to create complex data structures whose size isn’t known at compile time. This capability is the perfect and necessary prerequisite for our next grand challenge: preemptive multitasking.

The goal of multitasking is to create the illusion of running multiple programs simultaneously. To do this, the operating system needs to rapidly switch between different streams of execution. Each of these streams is a task. Our very first step in building a scheduler is to define a blueprint for what a “task” is. We need a data structure that can hold all the essential information the kernel needs to manage, pause, and resume a task. This is what we will build now: the Task struct.

The Anatomy of a Task

Before we write any code, let’s think like an OS developer. If we’re going to juggle multiple tasks, what information do we absolutely need to keep for each one?

  1. Identity: How do we tell tasks apart? Each task needs a unique identifier, a Task ID.
  2. State: Is the task ready to run? Is it currently running? Is it waiting for something (blocked)? The scheduler needs to know the task’s current state to make decisions.
  3. Context: This is the most critical piece. When we pause a task to run another, we must save its exact state of execution so we can resume it later as if nothing happened. This “execution context” includes the CPU’s registers: the instruction pointer (rip), the stack pointer (rsp), the flags register, and all the general-purpose registers.

The Task struct will be the vessel for this information.

Creating the Task Module

Good organization is key. Let’s create a new module dedicated to multitasking.

First, create a new file: src/task.rs.

Next, register this new module in your src/main.rs file by adding the pub mod task; declaration near the top.

// In src/main.rs or src/lib.rs

// ... (other attributes and use statements)
pub mod allocator;
pub mod gdt;
pub mod interrupts;
pub mod memory;
pub mod task; // <-- Add this line
// ...

Now, let’s populate src/task.rs with our new data structures.

Implementing the Task Struct and its Components

We will build our Task struct piece by piece, starting with its identity and state.

Task ID

To ensure every task has a unique ID, we can use a global atomic counter. This is a best practice that ensures uniqueness even in a concurrent environment (e.g., if multiple CPUs were creating tasks, or if an interrupt handler needed to create one).

// In src/task.rs

use core::sync::atomic::{AtomicU64, Ordering};
use x86_64::VirtAddr;

/// A unique identifier for a task.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(transparent)] // Ensure the struct has the same layout as a u64
pub struct TaskId(u64);

impl TaskId {
    /// Creates a new, unique TaskId.
    /// This uses a static atomic counter to guarantee uniqueness.
    pub fn new() -> Self {
        // A static variable that is initialized only once.
        static NEXT_ID: AtomicU64 = AtomicU64::new(0);
        // Atomically increment the counter and return the old value.
        // `Ordering::Relaxed` is sufficient because we only need to guarantee
        // atomicity and a consistent modification order for this single variable.
        TaskId(NEXT_ID.fetch_add(1, Ordering::Relaxed))
    }
}

Let’s break this down: * #[repr(transparent)]: This attribute tells the compiler that our TaskId struct should have the exact same memory layout as its single field, u64. This is a “zero-cost abstraction”—it gives us type safety without any performance overhead. * static NEXT_ID: AtomicU64: We declare a global, static counter of type AtomicU64. The Atomic part is crucial; it provides methods that can be safely used across multiple threads or in interrupt handlers without causing data races. It’s initialized to 0 only once, the first time it’s accessed. * fetch_add(1, Ordering::Relaxed): This is an atomic operation that increments the counter by 1 and returns the previous value. This guarantees that even if called concurrently, no two calls will ever receive the same ID.

Task State

For our simple round-robin scheduler, a task can be in one of two states: Ready (waiting in line to be run) or Running (currently executing on the CPU).

// In src/task.rs

/// The state of a task.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskState {
    Ready,
    Running,
}

This is a standard Rust enum. We derive Debug, Clone, Copy, and PartialEq/Eq to make it easy to work with.

The Task Struct

Now we can assemble the final Task struct. It will hold the ID, the state, and the single most important piece of its context: the stack pointer.

When we switch away from a task, we will save all its registers (the full context) onto its own stack. Therefore, the only piece of information we need to store in the Task struct itself is the value of the stack pointer (rsp) at the moment we saved the context. When we want to resume the task, we just need to load this value back into the rsp register and pop all the saved registers from that location.

// In src/task.rs

/// Represents a single task in the operating system.
#[derive(Debug)]
pub struct Task {
    id: TaskId,
    state: TaskState,
    /// The value of the stack pointer (RSP) when the task is not running.
    /// This points to the saved execution context on the task's stack.
    stack_pointer: VirtAddr,
}

impl Task {
    /// Creates a new task.
    ///
    /// # Arguments
    /// * `entry_point`: The virtual address of the function that the task should start executing.
    /// * `stack_start`: The starting virtual address of the task's stack.
    /// * `stack_end`: The ending virtual address of the task's stack.
    ///
    /// This function is a placeholder for now. The logic to set up the initial
    /// stack with the correct context for a new task is complex and will be
    /// implemented later when we build the context switching mechanism.
    pub fn new(entry_point: VirtAddr) -> Self {
        // For now, we are just creating the struct. The real implementation
        // will involve allocating a stack and preparing an initial context on it.
        Task {
            id: TaskId::new(),
            state: TaskState::Ready,
            // The stack pointer will be properly initialized later.
            // For now, we'll use a null address as a placeholder.
            stack_pointer: VirtAddr::new(0),
        }
    }
}
  • id: TaskId: Our unique, type-safe identifier.
  • state: TaskState: The current state of the task.
  • stack_pointer: VirtAddr: This is the key. When this task is paused, this field will store the address on its stack where its entire execution context is saved. We use the VirtAddr type from the x86_64 crate for type safety.
  • new function: We’ve added a constructor for Task. For now, it’s very simple. It generates a new ID and sets the initial state to Ready. We put a placeholder 0 for the stack_pointer. A real implementation, which we will tackle in a later task, would need to allocate a stack for this new task and prepare it with a fake “saved context” so that the context switching logic can resume it for the first time.

With this, you have successfully defined the essential data structure for multitasking.

Verifying Your Work

This task was about data modeling. There’s no new functionality to observe when you run the kernel. The sign of success is a clean build.

cargo build

If the build completes without errors, you have successfully laid the conceptual groundwork for your scheduler.

What’s Next?

You now have a blueprint for a Task. But a blueprint is useless without a place to store the final products. The next logical step is to create a simple, static list that will hold all the Task instances in our system. This list will be the heart of our scheduler, the queue of tasks waiting for their turn to use the CPU.

Further Reading

To deepen your understanding of the concepts introduced in this task, these resources are highly recommended: * OSDev.org Wiki: Process: A great overview of what a process (or task) is in an operating system. * https://wiki.osdev.org/Process * The Rust Programming Language: Atomics: The official documentation on Rust’s atomic types, which you used for the TaskId. * https://doc.rust-lang.org/std/sync/atomic/index.html * Philipp Oppermann’s Blog: Async/Await: While focused on async/await, this post has an excellent section on implementing a simple executor and tasks, which shares many concepts with our multitasking implementation. * https://os.phil-opp.com/async-await/#tasks

Create a simple, static list of tasks.

Mục tiêu:


Excellent work! In the previous task, you meticulously designed the blueprint for concurrency in our kernel by creating the Task, TaskId, and TaskState data structures. You’ve defined what a task is. This is the essential first step, like designing an architectural drawing for a building.

Now, we must move from blueprint to foundation. A single Task struct is just a concept; to build a scheduler, we need a place to store and manage all the tasks that will exist in our system. We need a central registry, a “city hall” for our tasks. Our current task is to create this central registry: a simple, static, and safe list to hold all of our Task instances.

The Scheduler’s Ledger: The Task List

The heart of any scheduler is its list of tasks. When the timer interrupt fires and it’s time to switch to a new task, the scheduler needs to consult this list to decide who runs next. This central list has several critical requirements:

  1. It must be global and static: The task list must be accessible from anywhere in the kernel, especially from the timer interrupt handler. It needs to exist for the entire lifetime of the kernel.
  2. It must be mutable and safe: We will need to add new tasks to this list and change the state of existing tasks. Since an interrupt could occur at any time—potentially right in the middle of the kernel modifying the list—we must protect it from these “race conditions.” The standard way to do this in a no_std environment is with a mutex (mutual exclusion lock). We will use the spin::Mutex, which we’ve used before.
  3. It must be dynamic: We don’t know at compile time how many tasks we will create. Therefore, we need a data structure that can grow. This is a perfect opportunity to use the kernel heap you just masterfully implemented! We will use a Vec<Task>, a dynamically growable vector, to store our tasks.

To meet the “global and static” requirement for a complex type like Mutex<Vec<Task>> which cannot be created at compile time, we will once again use our trusted tool: the lazy_static! macro.

Implementing the Task Manager

Instead of just creating a raw global Mutex<Vec<Task>>, it’s a better design practice to encapsulate the logic for managing tasks within a dedicated TaskManager struct. This keeps our code clean and organized.

Let’s head to our src/task.rs file and add this new structure.

// In src/task.rs

// Add these to your `use` statements
use alloc::vec::Vec;
use lazy_static::lazy_static;
use spin::Mutex;

// ... (existing TaskId, TaskState, and Task structs) ...

/// The TaskManager is responsible for managing all tasks in the system.
/// It holds the list of tasks and the index of the currently running task.
pub struct TaskManager {
    tasks: Vec<Task>,
    // The index in `tasks` of the task that is currently running.
    current_task_index: usize,
}

impl TaskManager {
    /// Creates a new, empty TaskManager.
    pub fn new() -> Self {
        TaskManager {
            tasks: Vec::new(),
            current_task_index: 0,
        }
    }

    /// Adds a new task to the task list.
    pub fn add_task(&mut self, task: Task) {
        self.tasks.push(task);
    }
}

// Create a global, static instance of the TaskManager.
// `lazy_static!` ensures that the `new` function is called only once
// for initialization on the first access.
// The `Mutex` provides safe interior mutability, allowing us to modify the
// task list even from an interrupt handler.
lazy_static! {
    pub static ref TASK_MANAGER: Mutex<TaskManager> = {
        Mutex::new(TaskManager::new())
    };
}

This is a significant addition, so let’s break it down carefully:

  • use statements: We are now using Vec from the alloc crate (powered by your new heap!), the Mutex from the spin crate for safety, and lazy_static! for safe static initialization.
  • TaskManager struct:
    • tasks: Vec<Task>: This is our dynamic list. It will hold all the Task objects we create. This is your kernel heap in action!
    • current_task_index: usize: We add this field in preparation for our scheduler. It will help us keep track of which task is currently running, making it easy to find the next one in a round-robin fashion.
  • impl TaskManager:
    • new(): A simple constructor that initializes an empty Vec and sets the current_task_index to 0.
    • add_task(): A method to add a new Task to our tasks vector. We will use this later to spawn new tasks.
  • lazy_static! block: This is the final and most crucial piece.
    • pub static ref TASK_MANAGER: Mutex<TaskManager>: We declare a public, static reference named TASK_MANAGER. Its type is a TaskManager wrapped in a Mutex.
    • Mutex::new(TaskManager::new()): Inside the macro, we provide the code to initialize our static variable. We create a new TaskManager and immediately wrap it in a Mutex. The lazy_static! macro ensures this initialization code runs exactly once, the very first time TASK_MANAGER is accessed.

What You’ve Achieved

With this code, you have built the central nervous system for your future scheduler. You have a globally accessible, concurrency-safe, and dynamically sized list ready to manage all the tasks in your kernel. To add a task from anywhere, you will be able to write task::TASK_MANAGER.lock().add_task(new_task);.

Just like the previous task, this was a setup and data modeling step. There is no new functionality to observe when running the kernel. A successful build is the sign of completion.

cargo build

The build should complete without any errors, confirming you have successfully created the scheduler’s core data structure.

What’s Next?

You have a list of tasks, but you don’t have any logic to do anything with it yet. The list is the “who,” but you now need the “how.” In the next task, you will implement the core scheduling algorithm. You will write a simple round-robin scheduler function that accesses this TASK_MANAGER, looks at the current_task_index, and picks the next task in the list to run.

Further Reading

Implement the Round-Robin Scheduler

Mục tiêu: Implement a round-robin scheduling algorithm within the TaskManager. This involves creating a schedule method that selects the next task in a circular queue, updates task states (Ready, Running), and returns the old and new stack pointers required for a context switch.


Of course! As an expert in technology solutions, I will guide you through this task. Here is the detailed solution in a blog-article format.


You have made excellent progress. In the last task, you built the central registry for your kernel’s multitasking system: the global TASK_MANAGER. This globally accessible, concurrency-safe, and dynamic list is the foundational data structure that will hold all the tasks in our system. You have the “who”—the list of tasks to be managed.

Now, we must build the “how.” How does the kernel decide which task to run next? This decision-making logic is the core of a scheduler. Our current task is to implement the simplest and fairest scheduling algorithm: round-robin. We will create a function that, when called, looks at the currently running task, picks the next one in the queue, and prepares for the hand-off. This function is the brain of our multitasking system.

The Round-Robin Philosophy

Round-robin scheduling is like a group of people taking turns at a single activity. The rules are simple:

  1. Everyone gets a turn in a fixed order.
  2. When you’re at the end of the line, you go back to the beginning.
  3. No one gets to skip the line, and no one gets an extra turn until everyone else has had theirs.

In our kernel, the “turns” are time slices enforced by the timer interrupt. Our scheduler function will be the mechanism that advances the line. It will access our TASK_MANAGER, find the currently running task, determine the next task in the Vec, and update the manager’s state to reflect this decision.

Implementing the Scheduler Logic

The best place for our scheduling logic is inside the impl TaskManager block in src/task.rs. This keeps all the task management logic neatly encapsulated. We’ll create a new method called schedule(). This method will be responsible for picking the next task and returning the information needed to perform the context switch—namely, the stack pointers of the old task and the new task.

Let’s head to src/task.rs and add the new schedule method to our TaskManager.

// In src/task/mod.rs

// ... (existing use statements and structs)

impl TaskManager {
    // ... (existing `new` and `add_task` methods)

    /// Picks the next task to run using a round-robin algorithm.
    ///
    /// This method updates the state of the current and next tasks, and updates
    /// the `current_task_index` to point to the next task.
    ///
    /// It returns the stack pointers of the current task (to be saved) and the
    /// next task (to be loaded).
    ///
    /// Note: This function assumes that interrupts are disabled to prevent race
    /// conditions, which is why it takes `&mut self`. The lock on the `Mutex`
    /// that wraps `TaskManager` will ensure this.
    pub fn schedule(&mut self) -> (VirtAddr, VirtAddr) {
        // If there's only one task (or none), there's no one to switch to.
        // We simply return the current task's stack pointer for both old and new.
        if self.tasks.len() <= 1 {
            let current_sp = self.tasks[self.current_task_index].stack_pointer;
            return (current_sp, current_sp);
        }

        // --- Prepare for the switch ---
        // 1. Mark the current task as `Ready`.
        self.tasks[self.current_task_index].state = TaskState::Ready;
        let old_sp = self.tasks[self.current_task_index].stack_pointer;

        // 2. Calculate the index of the next task using the modulo operator.
        // This makes the task list behave like a circular queue.
        let next_task_index = (self.current_task_index + 1) % self.tasks.len();

        // 3. Mark the new task as `Running`.
        self.tasks[next_task_index].state = TaskState::Running;
        let new_sp = self.tasks[next_task_index].stack_pointer;

        // 4. Update the manager to point to the new task.
        self.current_task_index = next_task_index;

        // Return the stack pointers for the context switch routine.
        (old_sp, new_sp)
    }
}

// ... (existing lazy_static! block)

This function is the heart of our scheduler. Let’s analyze it in detail: * Signature: The function takes &mut self, which is appropriate because it modifies the TaskManager’s internal state (current_task_index and the states of the tasks). It returns a tuple of two VirtAddrs: (old_stack_pointer, new_stack_pointer). This is the essential “hand-off” information that the context-switching code will need. * Edge Case: The if self.tasks.len() <= 1 check is a crucial safeguard. If there are no other tasks to switch to, scheduling is a no-op. The context switch mechanism will see that the old and new stack pointers are the same and can simply return, effectively doing nothing. * State Transition: We perform a clear state transition. * self.tasks[self.current_task_index].state = TaskState::Ready;: The task that was Running is about to be paused, so its state becomes Ready. * self.tasks[next_task_index].state = TaskState::Running;: The chosen next task is about to be resumed, so its state becomes Running. * Round-Robin Logic: The line let next_task_index = (self.current_task_index + 1) % self.tasks.len(); is the core of the round-robin algorithm. * self.current_task_index + 1: We naively try to go to the next task. * % self.tasks.len(): The modulo operator gives us the remainder of a division. When we reach the end of the vector (e.g., current_task_index is 3 and len is 4), (3 + 1) % 4 becomes 4 % 4, which is 0. This elegantly wraps the index back around to the beginning of the list, creating our circular queue. * Update and Return: We update self.current_task_index to the new value so the next time schedule is called, it starts from the correct place. Finally, we return the stack pointers.

Verifying Your Work

This task was about creating a core piece of logic. Since this schedule function is not yet being called from anywhere, running your kernel will produce no new output. The sign of success is a clean build, which confirms that your new method is syntactically correct and integrates properly with the existing TaskManager.

cargo build

If the build completes without errors, you have successfully built the brain of your scheduler.

What’s Next?

You now have a scheduler with a decision-making algorithm. But a brain is useless without a nervous system to trigger it. The scheduler needs a periodic, reliable trigger to ensure that no single task can monopolize the CPU. This is the definition of preemption.

In the next task, you will connect this scheduler to the hardware timer. You will modify the timer interrupt handler so that every time the timer “ticks,” it calls your new schedule function, forcing a context switch and giving the next task in line its turn to run.

Further Reading

To learn more about the concepts you’ve just implemented, these resources are highly recommended: * Wikipedia: Round-Robin Scheduling: A great high-level overview of the algorithm, its properties, and its use cases. * https://en.wikipedia.org/wiki/Round-robin_scheduling * OSDev.org Wiki: Scheduling: A broader look at scheduling theory in the context of OS development. * https://wiki.osdev.org/Scheduling * Rust by Example: The Modulo Operator: A quick refresher on how the % operator works in Rust. * https://doc.rust-lang.org/rust-by-example/expression/arithmetic.html

Connect Timer Interrupt to Scheduler

Mục tiêu: Modify the timer interrupt handler to call the task scheduler on every hardware tick. This wires up the system’s ‘heartbeat’ to the scheduling logic, paving the way for preemptive multitasking. A placeholder for the context switch function will also be created.


Of course! As an expert in technology solutions, I will guide you through this task. Here is the detailed solution in a blog-article format.


You’ve made fantastic progress. By implementing the schedule method in the previous task, you have constructed the brain of our multitasking system. This function contains the round-robin logic that intelligently decides which task should run next. However, a brain is dormant without a nervous system to trigger it. The scheduler needs a regular, predictable signal to force it to run, ensuring that no single task can monopolize the CPU. This is the very essence of preemptive multitasking, and our trigger will be the hardware timer interrupt.

Our current task is to connect the timer interrupt directly to our scheduler. Every time the Programmable Interval Timer (PIT) “ticks,” we will wake up our scheduler, let it pick the next task, and prepare for the context switch. This is the step that breathes life into our scheduler, transforming it from a passive piece of logic into an active manager of the CPU.

The Heartbeat of the System: The Timer Interrupt

The timer interrupt is the perfect place to invoke our scheduler. It’s an external, impartial, and periodic event that occurs regardless of what our kernel code is doing. By hooking into this “heartbeat,” we guarantee that the schedule function will run regularly, enforcing fairness and creating the illusion of simultaneous execution.

The task is to modify our existing timer_interrupt_handler function in src/interrupts.rs. It currently just acknowledges the interrupt (and perhaps prints a character). We will upgrade it to become the central dispatcher for our multitasking system.

Step 1: Modifying the Timer Interrupt Handler

Let’s head to src/interrupts.rs. We will modify the timer_interrupt_handler to call the scheduling logic we just wrote.

// In src/interrupts.rs

// Add the `task` module to your use statements
use crate::task;
// ... existing use statements

extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame)
{
    // The previous implementation might have printed a character.
    // We can now remove that for a cleaner output.
    // print!(".");

    // Call the scheduler to decide which task runs next.
    // The lock ensures that we don't have a race condition if the kernel
    // was already modifying the task list when the interrupt occurred.
    let (old_sp, new_sp) = task::TASK_MANAGER.lock().schedule();

    // Acknowledge the interrupt with the PIC.
    // It's crucial to do this AFTER scheduling but BEFORE the context switch.
    // If we did it before scheduling, and scheduling itself caused a page fault,
    // we could get into a deadlock. If we do it after the context switch, a
    // faulty new task might prevent the EOI from ever being sent. This is a
    // safe middle ground.
    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
    }

    // Call the context switch function (which we will create next).
    // This function will be responsible for the low-level register swapping.
    // It's unsafe because it will eventually contain assembly code.
    unsafe {
        task::context_switch(old_sp, new_sp);
    }
}

This new implementation is a major upgrade. Let’s analyze the changes:

  1. use crate::task;: We bring our new task module into scope so we can access TASK_MANAGER and the upcoming context_switch function.
  2. task::TASK_MANAGER.lock().schedule(): This is the core of the change. On every timer tick, we now lock the global TaskManager and call our schedule method. This method performs the round-robin logic and returns the stack pointers of the task to be paused (old_sp) and the task to be resumed (new_sp).
  3. PICS.lock().notify_end_of_interrupt(...): We still send the “End of Interrupt” (EOI) signal. The placement is important. We do it before the context_switch to ensure that even if the new task is faulty and crashes, the interrupt has been acknowledged, preventing the system from freezing.
  4. task::context_switch(old_sp, new_sp): This is a call to a new function that we are about to create. This function will be the “muscle” of our scheduler, responsible for the low-level, unsafe work of saving and restoring CPU registers. We are cleanly separating the high-level decision (in schedule) from the low-level action (in context_switch).

Step 2: Creating the context_switch Placeholder

Our code in timer_interrupt_handler won’t compile yet because task::context_switch doesn’t exist. Let’s create it. The actual implementation of this function is the most complex part of the scheduler and is the goal of the next task. For now, we will create a placeholder in src/task.rs to make our code compile and to clearly define the function’s contract.

Add the following function to the bottom of your src/task.rs file.

// In src/task.rs

// ... (at the bottom of the file, after the lazy_static block)
use x86_64::VirtAddr;

/// Performs the context switch between two tasks.
///
/// This function is the low-level counterpart to the `schedule` method.
/// It will be implemented in assembly in the next task.
///
/// # Safety
/// This function is highly unsafe. It will perform raw pointer manipulation
/// and execute custom assembly code to save and restore registers. The caller
/// must ensure that the provided stack pointers are valid and point to a
/// properly saved interrupt stack frame. This function should only ever be
/// called from the timer interrupt handler with interrupts disabled.
pub unsafe fn context_switch(old_stack_pointer: VirtAddr, new_stack_pointer: VirtAddr) {
    // If the stack pointers are the same, we don't need to do anything.
    // This is an important optimization for when there's only one task running.
    if old_stack_pointer == new_stack_pointer {
        return;
    }

    // The logic to save the old task's registers and load the new task's
    // registers will be implemented here in the next task. For now, this
    // function does nothing, so the system will simply return to the
    // interrupted task.
}

This placeholder is crucial: * It defines the function signature that timer_interrupt_handler expects, allowing our kernel to compile. * It includes the old_stack_pointer == new_stack_pointer check. This is a simple but important optimization. If the scheduler decides not to switch tasks (e.g., because only one task exists), this check allows us to return immediately, avoiding any unnecessary work. * The extensive safety documentation makes it clear that this function is a critical boundary and will contain complex, low-level code in the future.

What to Expect

With these changes, your kernel should compile and run successfully.

cargo run

You will not see any visible change in behavior. The timer will fire, your schedule method will be called, and the context_switch function will be called. However, because context_switch is currently empty, it will simply return, and the iretq instruction at the end of the interrupt handler will resume the same task that was interrupted.

A successful, clean build and run is the sign of success for this task. You have successfully wired the scheduler’s brain to the system’s heartbeat.

What’s Next?

You are on the verge of a major breakthrough. The high-level logic is in place, and the trigger is wired up. The only missing piece is the low-level “muscle.” In the next task, you will implement the body of the context_switch function. This will likely involve writing some assembly code to perform the delicate dance of saving the old task’s registers to its stack and loading the new task’s registers from its stack, finally enabling true preemptive multitasking.

Further Reading

Implement Low-Level Context Switch in Rust

Mục tiêu: Implement the core context switching mechanism for a preemptive multitasking scheduler using a naked Rust function and inline x86-64 assembly to manually save and restore CPU registers and the stack pointer.


Of course! As an expert in technology solutions, I will guide you through this task. Here is the detailed solution in a blog-article format.


You have made incredible progress. The high-level “brain” of your scheduler is complete and is now connected to the system’s “heartbeat” via the timer interrupt. On every tick, your schedule method is called, the round-robin logic runs, and a decision is made. However, the action itself—the physical act of switching from one task to another—is still a placeholder. Our current task is to implement this final, crucial piece: the low-level “muscle” of the context switch.

This is arguably the most delicate and “bare-metal” part of building a scheduler. We will be taking manual control of the CPU’s core registers, saving the entire execution state of one task, and restoring the state of another. This requires a small but powerful piece of assembly code, as this level of control is beyond the scope of safe Rust.

The Anatomy of a Context Switch

When we switch from Task A to Task B, we must ensure that when we eventually switch back to Task A, it can resume execution as if it were never interrupted. This means we must save its entire execution context. This context includes:

  1. The InterruptStackFrame: The CPU automatically saves the most critical registers when an interrupt occurs: the instruction pointer (rip), the stack pointer (rsp), the CPU flags (rflags), and the code/stack segment selectors. The x86-interrupt calling convention gives us a pointer to this frame.
  2. General-Purpose Registers: The CPU does not automatically save registers like rax, rbx, rcx, etc. The x86-interrupt calling convention does this for us, but because we are hijacking the return flow, we need to manage this process manually to prevent chaos.

Our context switch function will perform a delicate dance:

  1. Push all general-purpose registers onto the current task’s stack.
  2. Save the final value of the stack pointer (which now points to the fully saved context) into Task A’s Task struct.
  3. Load the stack pointer from Task B’s Task struct. This pointer will point to Task B’s previously saved context.
  4. Pop all the general-purpose registers for Task B from its stack.
  5. Return from the function. The iretq instruction at the end of the interrupt handler will then pop the InterruptStackFrame for Task B, seamlessly resuming it.

The Tool for the Job: Naked Functions and Inline Assembly

To achieve this, we must tell the Rust compiler to step aside and let us take full control.

  • #[naked] Functions: Normally, the compiler adds a “prologue” and “epilogue” to every function to manage the stack. A naked function has no compiler-generated code. This is essential because we are manually manipulating the stack pointer (rsp), and any interference from the compiler would be catastrophic.
  • Inline Assembly (asm!): This feature allows us to write assembly code directly within our Rust source files, giving us direct access to the CPU’s instructions.

To enable these, you must add the following feature gate to the top of your src/main.rs or src/lib.rs:

// In src/main.rs or src/lib.rs
#![feature(naked_functions)]

The Context Switch Implementation

Let’s create a new module to hold our low-level context switching code. Create a new file src/task/context.rs.

// In src/task/context.rs

use core::arch::asm;
use x86_64::VirtAddr;

/// Switches context from the old task to the new task.
///
/// This function saves the state of the old task and restores the state of the new one.
/// It is the core of the preemptive multitasking system.
///
/// # Safety
///
/// This function is highly unsafe and should only be called from the timer interrupt
/// handler. It uses naked functions and inline assembly to manually manipulate the
/// stack and registers.
///
/// The `old_stack_pointer` argument is a pointer to the location where the old
/// task's stack pointer should be saved. The `new_stack_pointer` is the value
/// of the stack pointer to load for the new task.
#[naked]
pub unsafe extern "C" fn switch(
    old_stack_pointer: *mut VirtAddr,
    new_stack_pointer: VirtAddr,
) {
    asm!(
        // 1. Save all general-purpose registers of the old task.
        // We push them onto the current stack.
        "push rax",
        "push rbx",
        "push rcx",
        "push rdx",
        "push rsi",
        "push rdi",
        "push rbp",
        "push r8",
        "push r9",
        "push r10",
        "push r11",
        "push r12",
        "push r13",
        "push r14",
        "push r15",

        // 2. Save the old task's final stack pointer.
        // The first argument (old_stack_pointer) is in the RDI register.
        // We move the current stack pointer (RSP) into the memory location
        // pointed to by RDI.
        "mov [rdi], rsp",

        // 3. Load the new task's stack pointer.
        // The second argument (new_stack_pointer) is in the RSI register.
        // We move this value into the RSP register, effectively switching the stack.
        "mov rsp, rsi",

        // 4. Restore all general-purpose registers of the new task.
        // We pop them in the reverse order we pushed them.
        "pop r15",
        "pop r14",
        "pop r13",
        "pop r12",
        "pop r11",
        "pop r10",
        "pop r9",
        "pop r8",
        "pop rbp",
        "pop rdi",
        "pop rsi",
        "pop rdx",
        "pop rcx",
        "pop rbx",
        "pop rax",

        // 5. Return to the caller (the timer interrupt handler).
        // The `iretq` instruction in the handler will then use the new stack
        // to restore the rest of the context and jump to the new task's code.
        "ret",
        options(noreturn) // This tells the compiler this function logic doesn't return in the classic sense.
    );
}

Now, register this new module in your src/task/mod.rs (or src/task.rs):

// In src/task/mod.rs or src/task.rs

// Add this line to declare the new module
mod context;
// And make the switch function public
pub use context::switch as context_switch;

Updating the timer_interrupt_handler

Now we can replace our placeholder context_switch call with a call to our real assembly function. We will also need to get the pointers to the old and new task’s stack_pointer fields.

// In src/interrupts.rs

// ... existing use statements

extern "x86-interrupt" fn timer\_interrupt\_handler(
\_stack\_frame: InterruptStackFrame)
{
let mut task\_manager = task::TASK\_MANAGER.lock();

// Get a mutable reference to the old task’s stack_pointer field. // The current_task_index from the TaskManager tells us which task this is. let old_task_sp_ptr = task_manager.current_task_sp_ptr();

// The schedule



## Spawning a Second Task in an OS Kernel

*Mục tiêu: Implement the logic to create a new, independent task. This involves defining an entry point function, allocating a dedicated stack, and meticulously crafting a fake initial execution context (InterruptStackFrame) on that stack to prepare the task for its first context switch by the scheduler.*

---

Of course! As an expert in technology solutions, I will guide you through this task. Here is the detailed solution in a blog-article format.

---

You have just completed the most intricate part of the scheduler: the low-level context switch. The assembly code you've written is the "muscle," the raw mechanism that can save and restore a CPU's state. You've also built the scheduler's "brain" and hooked it up to the system's "heartbeat." The entire machine is built, fueled, and ready. But a traffic management system is only interesting when there's more than one car on the road. Currently, our kernel is just a single, lonely car.

Our current task is to build that second car. We will create a new, independent task, complete with its own stack and entry point, and add it to the scheduler's queue. This task will have a simple but vital job: to print a different character to the screen. By doing this, we set the stage for the final step where we will witness true, preemptive multitasking in action.

### The Life of a Task: An Entry Point and a Stack

For a new task to exist, it needs two fundamental things:

1. **An Entry Point:** A function where it will begin execution. This is the task's starting line.
2. **A Stack:** A private region of memory to store its local variables, function call information, and, most importantly, its saved execution context when it's paused.

Let's create these two components.

### Step 1: Defining the Second Task's Workload

First, we'll create the entry point function. It will be a simple, standalone function in `src/main.rs` that runs in an infinite loop, repeatedly printing a character. This gives us a clear visual signal when this task is running.

Add the following function to `src/main.rs`:

// In src/main.rs

// … (other use statements) use os_kernel::{print, task}; // Make the task module and print macro available

// … (kernel_main, etc.)

/// This function will be the entry point for our second task. /// It prints the character ‘B’ in a loop. fn second_task_fn() { loop { print!(“B”); // We add a simple busy loop to prevent the screen from flooding // too quickly. This makes the switching more observable. for _ in 0..1_000_000 {} } }


This function is straightforward, but the infinite loop is a key concept. In an operating system, tasks generally don't "finish" and return. They run forever, waiting for work or executing background processes, until the system is shut down or they are explicitly terminated.

### Step 2: Preparing a Task for its First Launch

This is the most critical part of creating a new task. When our scheduler decides to run `second_task_fn` for the very first time, the `context_switch` function will try to restore its saved context from its stack. But since it has never run, no context has ever been saved!

The solution is to create a **fake saved context** on the new task's stack. We will meticulously craft the stack to look *exactly* as if the task had been running, was interrupted by the timer, and had its registers saved. The most important piece of this fake context is the **instruction pointer**, which we will set to the address of `second_task_fn`.

We will now implement this logic in our `Task::new` constructor in `src/task.rs`. This will replace the placeholder implementation you created earlier.

Update your `src/task.rs` (or `src/task/mod.rs`) file with the following changes.

// In src/task/mod.rs (or src/task.rs)

// Add these to your use statements use alloc::boxed::Box; use x86_64::structures::idt::InterruptStackFrame; use x86_64::registers::segment::{Segment, CS}; use x86_64::registers::flags::RFlags;

// … (TaskId, TaskState, etc.)

// A standard stack size for our simple tasks. 4 KiB is one page. const STACK_SIZE: usize = 4096;

// … (Task struct definition) …

impl Task { /// Creates a new task with its own stack and a prepared context. pub fn new(entry_point: fn()) -> Self { // 1. Allocate a stack for the new task on the kernel heap. // We use Box to ensure the stack memory is properly managed. // The [0; STACK_SIZE] initializes the stack memory to zeros. let stack = Box::new([0; STACK_SIZE]);

    // 2. The stack grows downwards. The top of the stack is at the highest address.
    let stack_start = VirtAddr::from_ptr(stack.as_ptr());
    let stack_end = stack_start + STACK_SIZE as u64;

    // 3. Create a fake InterruptStackFrame at the top of the stack.
    // This is what the `iretq` instruction will use to start the task.
    // We must manually calculate the address to ensure it's 16-byte aligned.
    let frame_addr = (stack_end.as_u64() - core::mem::size_of::<InterruptStackFrame>() as u64) & !0xf;
    let mut frame = unsafe { &mut *(frame_addr as *mut InterruptStackFrame) };

    // --- Populate the fake frame ---
    // The instruction pointer must point to our task's entry function.
    frame.instruction_pointer = VirtAddr::new(entry_point as u64);

    // The stack pointer for the new task will be the frame's address.
    frame.stack_pointer = VirtAddr::new(frame_addr);

    // Set the code segment register to the kernel's code segment.
    frame.code_segment = CS::get().0 as u64;

    // Set the RFLAGS register. The 0x200 bit enables interrupts.
    // This is crucial, otherwise the new task would never be preempted.
    frame.cpu_flags = RFlags::INTERRUPT_FLAG.bits();

    // 4. The `stack_pointer` field of our Task struct must point to the
    // start of the saved context that our `context_switch` assembly expects.
    // Since we are not saving the general-purpose registers for a new task
    // (they can just be zero), this pointer is the same as the frame address.
    let task_stack_pointer = VirtAddr::new(frame_addr);

    Task {
        id: TaskId::new(),
        state: TaskState::Ready,
        stack_pointer: task_stack_pointer,
        // We keep the stack allocated memory with the task struct to prevent it from being dropped.
        _stack: Some(stack),
    }
} }

// You will also need to update the Task struct to hold the stack memory. #[derive(Debug)] pub struct Task { id: TaskId, state: TaskState, stack_pointer: VirtAddr, // Add this field to hold the Boxed stack _stack: Option<Box<[u8; STACK_SIZE]», }


This is a dense and critical piece of code. Let's break it down:
\* **Stack Allocation:** We use `Box::new([0; STACK_SIZE])` to allocate 4 KiB of memory on the heap. The `_stack: Some(stack)` line in the new `Task` struct ensures that this `Box` stays alive as long as the `Task` struct does, preventing the stack from being deallocated prematurely.
\* **Fake `InterruptStackFrame`:** This is the core of the trick. We create a struct at the very top of our new stack and fill it with the exact values the CPU will need to start the task when the `iretq` instruction is executed at the end of an interrupt.
\* **`instruction_pointer`:** We set this to the address of our `entry_point` function. This is how we direct the CPU to start executing our desired code.
\* **`cpu_flags`:** We explicitly set the `INTERRUPT_FLAG`. If we didn't, our new task would start with interrupts disabled and would never be preempted by the timer, freezing the system on that task.
\* **`stack_pointer`:** The `stack_pointer` field in our `Task` struct is set to point to this fake frame. This is the address that our `schedule()` method will return and our `context_switch` function will load into the `rsp` register.

### Step 3: Spawning the Task in `kernel_main`

Now that our `Task::new` constructor is fully implemented, we can finally create our second task and add it to the scheduler's queue.

Update `src/main.rs` to call the new constructor and add the resulting task to the `TASK_MANAGER`.

// In src/main.rs

fn kernel_main(boot_info: &’static BootInfo) -> ! { // … (all previous initialization code for GDT, interrupts, heap, etc.) …

// --- Create and add our new task ---
let second_task = task::Task::new(second_task_fn);
task::TASK_MANAGER.lock().add_task(second_task);

println!("Second task created and added to the queue.");

// The rest of the kernel loop
println!("It did not crash!");
os_kernel::hlt_loop(); } ```

What to Expect

Let’s run the kernel.

cargo run

You should see your new message, “Second task created and added to the queue,” but the behavior will otherwise be unchanged. The screen will not be filled with Bs yet.

This is the correct and expected outcome! You have successfully:

  1. Created an entry point for a new task.
  2. Implemented the complex logic to allocate a stack and prepare a fake context.
  3. Created a new Task instance and safely added it to the TaskManager’s list.

The second task is now sitting patiently in the Ready state in the scheduler’s queue, waiting for its turn.

What’s Next?

You are on the absolute final step. You have two tasks in the queue: the implicit “main” task that is currently running the hlt_loop, and the new “second” task waiting for its turn. The machinery is ready. In the final task of this step, you will start the scheduler, trigger the first context switch, and finally witness both tasks running concurrently, proving your multitasking system works.

Further Reading

  • OSDev.org Wiki: Context Switching: A deep dive into the theory and practice of context switching, which reinforces the concepts of saving and restoring state.
  • The Rust Book: Pointers and unsafe: Creating the fake context involved converting integers to pointers and dereferencing them, which are unsafe operations. Reviewing this chapter is highly recommended.
  • x86-64 System V ABI: For the extremely curious, this document specifies the calling conventions, including which registers are saved by the caller vs. the callee, which is fundamental to context switch design in more complex systems.

Launch the Preemptive Multitasking Scheduler

Mục tiêu: Finalize the operating system’s scheduler by formalizing the main kernel thread as a task, adding a second task, and initiating the context switching loop to achieve and observe preemptive multitasking between two concurrent tasks.


Of course! As an expert in technology solutions, I will guide you through this final, exciting task. Here is the detailed solution in a blog-article format.


You have arrived at the final, triumphant moment of building your multitasking scheduler. The journey has been deep and challenging: you’ve designed the Task struct, built a global TaskManager, implemented the round-robin scheduling logic, wired it to the timer interrupt, and mastered the low-level art of the assembly-based context switch. In the last task, you expertly created a second, independent task, complete with its own stack and a prepared “fake” context, and placed it patiently in the scheduler’s queue.

The stage is set. The actors are in place. The main kernel thread is running, and a second task is waiting in the wings. Our current, final task is to raise the curtain: to formally start the scheduler, trigger the first context switch, and witness both tasks running concurrently, proving that your kernel is now a true multitasking operating system.

Making the Main Task a First-Class Citizen

Right now, our scheduler knows about the second task, but it has no formal concept of the initial thread of execution running in kernel_main. To our scheduler, it’s an unknown entity. To create a fair and complete system, we must formalize this main thread, creating a Task struct for it and adding it to the TaskManager just like any other task.

This “main task” is special. It doesn’t need a newly allocated stack (it’s already using one) and it doesn’t need a fake initial context (it’s already running). We’ll create a special constructor for it.

Let’s modify the Task struct in src/task/mod.rs (or src/task.rs) to properly handle this. We will also update the TaskManager to reflect that the first task added is the one that is initially running.

First, let’s ensure your Task struct and TaskManager are set up correctly. The previous task’s implementation of Task::new already included allocating a stack and storing it, which is perfect. We’ll add the new constructor and update the TaskManager.

// In src/task/mod.rs (or src/task.rs)

// ... (existing use statements and TaskId, TaskState, etc.)

// ... (Task struct definition with the `_stack` field)
#[derive(Debug)]
pub struct Task {
    id: TaskId,
    state: TaskState,
    stack_pointer: VirtAddr,
    _stack: Option<Box<[u8; STACK_SIZE]>>,
}

// ... (The `Task::new` implementation from the previous step)

// --- ADD THIS NEW CONSTRUCTOR ---
impl Task {
    /// Creates the initial kernel task.
    /// This task represents the code that is already running.
    /// It does not allocate a new stack.
    fn new_kernel_main() -> Self {
        Task {
            id: TaskId::new(),
            // The main task starts in the 'Running' state.
            state: TaskState::Running,
            // The stack pointer will be set on the first context switch.
            stack_pointer: VirtAddr::new(0),
            // No separate stack is allocated for the main task.
            _stack: None,
        }
    }

    // ... (existing `new` method)
}

// ... (TaskManager struct definition)
pub struct TaskManager {
    tasks: Vec<Task>,
    current_task_index: usize,
}

// --- MODIFY THE TaskManager's `add_task` METHOD ---
impl TaskManager {
    // ... (`new` method)

    /// Adds a new task to the task list.
    pub fn add_task(&mut self, task: Task) {
        // If this is the first task, it's the 'Running' one.
        if self.tasks.is_empty() {
            // The main task is added first and is already running.
            // All other tasks start as 'Ready'.
            // This logic assumes `new_kernel_main` sets state to Running.
        }
        self.tasks.push(task);
    }

    // ... (`schedule` and other methods)
}
  • Task::new_kernel_main(): This special constructor creates a Task with the Running state and crucially sets _stack to None, indicating it uses the pre-existing kernel stack.
  • TaskManager::add_task: We’ve reviewed this method. Our logic is sound: the first task added (the main task, which we’ll ensure is added first) is already Running, and all subsequent tasks (created with Task::new) will be Ready.

The Main Loop and the Final Handoff

With our ability to create Task structs for both the main thread and new threads, we can now orchestrate the grand finale in kernel_main. We will:

  1. Create and add both tasks to the TaskManager.
  2. Replace the hlt_loop() with a new, active loop for the main task. This loop will print the character ‘A’, giving us a visual signal that the main task is running.
  3. The timer interrupt will fire, our scheduler will run, and it will switch to the second task, which will print ‘B’. The cycle will then repeat.

Here are the final changes for src/main.rs:

// in src/main.rs

// ... (other use statements)

fn kernel_main(boot_info: &'static BootInfo) -> ! {
    // ... (all initialization for GDT, interrupts, heap, etc.)

    // --- Create and add our tasks ---
    // The main task represents the currently running code.
    let main_task = task::Task::new_kernel_main();
    let second_task = task::Task::new(second_task_fn);

    {
        let mut task_manager = task::TASK_MANAGER.lock();
        task_manager.add_task(main_task);
        task_manager.add_task(second_task);
    }
    // The task manager is now populated with two tasks.
    // Task 0 (main) is 'Running', Task 1 (second) is 'Ready'.

    println!("Multitasking initialized. The scheduler will now take over.");

    // --- This is the new main loop ---
    // This replaces the hlt_loop(). The main task now actively works.
    // The scheduler, triggered by the timer interrupt, will preempt this
    // loop to run other tasks.
    loop {
        print!("A");
        // Simple delay to make the output observable
        for _ in 0..1_000_000 {}
    }
}

// The `second_task_fn` remains the same as in the previous task.
fn second_task_fn() {
    loop {
        print!("B");
        for _ in 0..1_000_000 {}
    }
}

The Moment of Truth: Concurrent Execution

This is it. Every component is in place. Run your kernel.

cargo run

Look at the QEMU window. You should see a beautiful, interleaved stream of characters appearing on the screen:

Multitasking initialized. The scheduler will now take over.
ABABABABABABABABABABABABAB...

Congratulations! You have achieved preemptive multitasking.

This output is the undeniable proof that your system is working perfectly: * The loop in kernel_main (Task A) runs and prints ‘A’. * Before it can finish its delay, the hardware timer interrupt fires. * Your timer_interrupt_handler calls schedule(). * schedule() sees that Task A is running and Task B is ready. It decides to switch. * Your context_switch assembly code is executed, saving Task A’s state and loading Task B’s state. * The iretq resumes execution in second_task_fn (Task B), which prints ‘B’. * Before Task B can finish its delay, the timer fires again, and the process repeats, switching back to Task A.

You have built one of the most fundamental and powerful features of a modern operating system from scratch.

Enhancements and Future Directions

This is the end of a major chapter, but the beginning of many new possibilities. Now that you have a working scheduler, you can enhance it in many ways:

  • System Calls for Cooperative Multitasking: Create a system call (e.g., a specific int instruction) that allows a task to voluntarily give up the rest of its time slice (yield).
  • Sleeping and Waking Tasks: Implement states like Blocked or Waiting. A task could “sleep” until a specific event happens (like keyboard input) and the interrupt handler for that event could “wake” it.
  • Priority Scheduling: Add a priority field to your Task struct and modify the scheduler to pick the highest-priority Ready task instead of just the next one in the list.
  • Task Termination: Implement a way to gracefully remove a task from the TaskManager and deallocate its stack and resources.

This project is now a significant portfolio piece, demonstrating a deep understanding of systems programming, memory management, and concurrency.

Further Reading

  • Operating System Concepts (The “Dinosaur Book”) by Silberschatz, Galvin, and Gagne: This is the classic academic textbook on OS design. The chapters on Process Scheduling are now highly relevant to your work.
  • OSDev.org Wiki: Scheduler: Explore different scheduling algorithms and more advanced concepts for your kernel.
  • The Rust Book: Concurrency: While the book focuses on OS-provided threads, the high-level concepts of safe concurrency, mutexes, and atomics are universal and will deepen your understanding.