Page 1 of 1

Context switching issue.

Posted: Wed Jan 15, 2025 6:58 am
by vinnieashken
In the code below. task_1 gets executed but task_2 is never executed. The PIT_Handler function is an ISR that gets triggered by the Interval timer ticks. If I remove the infinite loops then both tasks get executed one after another.

Code: Select all

typedef enum {
    RUNNING,
    READY,
    WAITING,
    TERMINATED
} process_state_t;

typedef struct process {
    void (*func)();             // Pointer to the function to be executed
    process_state_t state;      // State of the process (RUNNING, READY, etc.)
    unsigned int stack_pointer; // Pointer to the stack for this process
    unsigned int stack_size;    // Size of the stack
    struct process *next;       // Pointer to the next process in the list
} process_t;

process_t *ready_queue = NULL; // A linked list of processes in the READY state
process_t *current_process = NULL; // The currently running process

extern void *malloc(size_t size);
extern void printf(const char *format, ...);
extern void free(void *ptr);

// Assembly function to perform the actual context switch
void perform_context_switch(unsigned int* old_esp, unsigned int new_esp) {
    __asm__ __volatile__ (
        "mov %%esp, %0\n\t"         // Save current ESP to *old_esp
        "mov %1, %%esp\n\t"         // Load new ESP
        "popa\n\t"                  // Restore general-purpose registers
        "iret\n\t"                  // Return from interrupt
        : "=m" (*old_esp)           // Output constraint for saving old ESP
        : "r" (new_esp)             // Input constraint for new ESP
        : "memory"                  // Memory clobber
    );

    // This line will never execute due to `iret`
}

void add_process(void (*func)(), unsigned int stack_size) {
    process_t *new_process = (process_t*)malloc(sizeof(process_t));
    if (new_process == NULL) return;

    new_process->func = func;
    new_process->state = READY;
    new_process->stack_size = stack_size;
    
    // Allocate stack
    void* stack_mem = malloc(stack_size);
    if (stack_mem == NULL) {
        free(new_process);
        return;
    }

    // Point to top of stack (stack grows down)
    unsigned int *stack = (unsigned int*)((unsigned int)stack_mem + stack_size);

    // Set up initial interrupt frame
    *(--stack) = 0x202;               // EFLAGS with interrupts enabled
    *(--stack) = 0x08;                // CS - assuming this is your code segment
    *(--stack) = (unsigned int)func;   // EIP - starting point

    // Save initial registers
    *(--stack) = 0;    // EAX
    *(--stack) = 0;    // ECX
    *(--stack) = 0;    // EDX
    *(--stack) = 0;    // EBX
    *(--stack) = (unsigned int)stack_mem + stack_size;  // ESP
    *(--stack) = 0;    // EBP
    *(--stack) = 0;    // ESI
    *(--stack) = 0;    // EDI

    new_process->stack_pointer = (unsigned int)stack;
    new_process->next = NULL;

    // Add to ready queue
    if (ready_queue == NULL) {
        ready_queue = new_process;
    } else {
        process_t *last = ready_queue;
        while (last->next != NULL) last = last->next;
        last->next = new_process;
    }
}

void switch_context(struct interrupt_frame* frame) {
    if (current_process == NULL) {
        if (ready_queue != NULL) {
            current_process = ready_queue;
            ready_queue = ready_queue->next;
            current_process->state = RUNNING;
            current_process->next = NULL;

            __asm__ __volatile__ (
                "mov %0, %%esp\n\t"
                "popa\n\t"
                "iret\n\t"
                : : "r" (current_process->stack_pointer)
            );
        }
        return;
    }

    // Save the current process state
    current_process->stack_pointer = frame->esp;
    current_process->state = READY;

    // Move current process to end of ready queue
    if (ready_queue == NULL) {
        ready_queue = current_process;
    } else {
        process_t *last = ready_queue;
        while (last->next != NULL) last = last->next;
        last->next = current_process;
    }
    current_process->next = NULL;

    // Get the next process from the ready queue
    if (ready_queue != NULL) {
        process_t *next = ready_queue;
        ready_queue = ready_queue->next;
        next->state = RUNNING;
        next->next = NULL;

        process_t *old = current_process;
        current_process = next;
        printf("  Process at %x (func: %x)\n", 
               (unsigned int)next, (unsigned int)next->func);

        perform_context_switch(&old->stack_pointer, next->stack_pointer);
    
    }


    __asm__ __volatile__ (
        "popa\n\t"
        "iret\n\t"
    );
}

#pragma GCC target("general-regs-only")
__attribute__((interrupt)) void PIT_handler(struct interrupt_frame* frame) {
   //
//    printf("PIT called\n");
   switch_context(frame);
   outb(0x20, 0x20);  
   outb(0xA0, 0x20); 
}
#pragma GCC reset_options

void task_1() {

    printf("This is PROCESS 1\n");

    while (1)
    {
        /* code */
    }
}

void task_2() {
    printf("This is PROCESS 2\n");
    while (1)
    {
        /* code */
    }
    
}

add_process(task_1, 1024);  // Task 1 with a stack size of 1024 bytes
add_process(task_2, 1024);  // Task 2 with a stack size of 1024 bytes
Image

Re: Context switching issue.

Posted: Wed Jan 15, 2025 12:59 pm
by MichaelPetch
You are asking for trouble by doing the task switching in inline assembly. With that being said, I think one problem you have is that you call `switch_context` before you send EOI. I don't see how the EOI can be run given you will be ireting back out. Without an EOI you won't get another interrupt raised which means you won't get another task switch. Now it is possible I am misreading your code which is very possible.

Re: Context switching issue.

Posted: Wed Jan 15, 2025 1:00 pm
by thewrongchristian
vinnieashken wrote: Wed Jan 15, 2025 6:58 am In the code below. task_1 gets executed but task_2 is never executed. The PIT_Handler function is an ISR that gets triggered by the Interval timer ticks. If I remove the infinite loops then both tasks get executed one after another.

Code: Select all

...

// Assembly function to perform the actual context switch
void perform_context_switch(unsigned int* old_esp, unsigned int new_esp) {
    __asm__ __volatile__ (
        "mov %%esp, %0\n\t"         // Save current ESP to *old_esp
        "mov %1, %%esp\n\t"         // Load new ESP
        "popa\n\t"                  // Restore general-purpose registers
        "iret\n\t"                  // Return from interrupt
        : "=m" (*old_esp)           // Output constraint for saving old ESP
        : "r" (new_esp)             // Input constraint for new ESP
        : "memory"                  // Memory clobber
    );

    // This line will never execute due to `iret`
}

This is bad form. I wouldn't recommend returning using inline assembly.
vinnieashken wrote: Wed Jan 15, 2025 6:58 am

Code: Select all

...

void task_1() {

    printf("This is PROCESS 1\n");

    while (1)
    {
        /* code */
    }
}

void task_2() {
    printf("This is PROCESS 2\n");
    while (1)
    {
        /* code */
    }
    
}

If these are the while loops being removed, I'm amazed anything worked.

Where are these functions returning to? Hint, they're "returning" to whatever random address is in memory following the stack, as you initialize only the top of the interrupt stack to "return" to your new thread function. You don't push a return address for that function to return to, and you'd actually want to return to a function that will unconditionally terminate the thread and never return otherwise.

I would recommend you abandon your current context switch, and perhaps read something like:

https://www.cs.uml.edu/~bill/cs516/cont ... se-pmt.pdf

This describes threading in a UNIX user context, but the same principles apply. Using the same methods in this paper, you can bootstrap and switch between threads using only setjmp/longjmp and a method to execute code on an arbitrary stack.

You basically do something like the following (untested code):

Code: Select all

struct thread_context {
	/* runtime registers */
	jmp_buf env;
	
	/* Thread details */
	void (*thread_func)(void * threadarg);
	void * threadarg;
	
	/* Thread stack bounds */
	uintptr_t * stacktop;
	uintptr_t * stackbot;
};

/* This is defined externally, probably in assembly */
extern run_on_stack(void (*bootstrap_func)(void * arg), void * arg, void * stacktop);

/* Assumes interrupts are disabled */
void bootstrap_thread(void * arg)
{
	struct thread_context * info = arg;
	
	/* We're operating in the context of the creating thread, but with the stack of the new thread */
	if (setjmp(info->env)) {
		/* We're now operating entirely in the context of the new thread */
		info->thread_func(info->threadarg);
		/* If we get here, the thread has finished without explicitly exiting */
		thread_exit();
		/* Not reached - something else has to dispose of the thread_context */
	} 
	/* We're still operating in the context of the creating thread, but with the stack of the new thread */
}



struct thread_context * thread_create(void (*thread_func)(void * threadarg), void * threadarg)
{
	struct thread_context * info = calloc(1, sizeof(*info));
	 /* Stack */
	info->stackbot = calloc(1, STACK_SIZE);
	info->stacktop = info->stackbot + STACK_SIZE/sizeof(*info->stackbot);
	
	/* Thread details */
	info->thread_func = thread_func;
	info->threadarg = threadarg;
	
	/* Assumes grow down stack - else we should pass info->stackbot */
	run_on_stack(bootstrap_thread, info, info->stacktop);
	
	return info;
}

/* Current thread - assumes single processor with a single thread */
static struct thread_context * current;
void thread_switch(struct thread_context * to)
{
	/* Assumes interrupts are disabled - this all needs to be atomic */
	if (current && setjmp(current->env)) {
		/* Returning to this context */
		return;
	} else {
		/* Switch to the new context - We'd also switch the page table here if we're supporting multiple address spaces */
		current = to;
		longjmp(current->env, 1);
	}
}

static struct thread_context * thread1;
static struct thread_context * thread2;

void task_1(void * ignored)
{
    printf("This is PROCESS 1\n");
    while (1)
    {
	interrupts_enable(1);
    	/* Do work */
	interrupts_enable((0);
    	thread_switch(thread2);
    }
}

void task_2(void * ignored)
{
    printf("This is PROCESS 2\n");
    while (1)
    {
	interrupts_enable(1);
    	/* Do work */
	interrupts_enable((0);
    	thread_switch(thread1);
    }
}

...
	/* Initialization code */
	thread1 = thread_create(task_1, NULL /* No argument */);
	thread2 = thread_create(task_2, NULL /* No argument */);
	/* switch to thread1 to resume that thread - it'll first come to life in bootstrap_thread() */
	interrupts_enable(0);
	thread_switch(thread1);
	/* If there was no current context before thread_switch, we shouldn't return here */
...
This would be co-operative thread switching, which is basically what you need especially as you're just starting out.

On top of this basic thread switching, you can build the thread scheduler to just switch to the next available thread instead of fixed threads.

Then you can build synchronization primitives, to co-ordinate the threads using locks and wait queues.

Then you start using timers to indicate when threads should be switched (the start pre-emptive thread scheduling), so that you can switch threads just before an interrupt return (not in the middle of interrupt handling!)

But crucially, I would recommend completely divorcing the mechanism of task switching (setjmp/longjmp in this example) from the mechanism of handling interrupts. You want to handle interrupts without switching tasks, and you want to switch tasks outside of handling interrupts (trying to lock an already locked mutex, for example).

Re: Context switching issue.

Posted: Wed Jan 15, 2025 10:49 pm
by Octocontrabass
thewrongchristian wrote: Wed Jan 15, 2025 1:00 pmyou can bootstrap and switch between threads using only setjmp/longjmp
It seems like a lot of work to build task switching around the setjmp/longjmp abstraction. Wouldn't something like this be a better fit?

Re: Context switching issue.

Posted: Thu Jan 16, 2025 9:43 am
by thewrongchristian
Octocontrabass wrote: Wed Jan 15, 2025 10:49 pm
thewrongchristian wrote: Wed Jan 15, 2025 1:00 pmyou can bootstrap and switch between threads using only setjmp/longjmp
It seems like a lot of work to build task switching around the setjmp/longjmp abstraction. Wouldn't something like this be a better fit?
More work than what? Copying it from Brendan's tutorial?

Probably. I'm not familiar with that tutorial, as I wrote all my task switching code long before that tutorial being available in the wiki, so I was basing my reply on my own experience.

But I wanted to make the point that task switching is not inherently related to interrupt handling, and is best off being split off and handled separately.

My kernel uses setjmp/longjmp for not just task switching, but also exception handling. setjmp/longjmp is a well know C construct that allows task switching with minimal assembly code (basically, the only assembly required is to switch to the new stack to bootstrap the new thread.)

Its use in GNU pth provides not only a real world implementation that can be used and learnt in user space, but also comes with good documentation and papers that explain in detail how it works.

Re: Context switching issue.

Posted: Thu Jan 16, 2025 2:05 pm
by rdos
I think Brendan's tutorial is okay, but I feel that registers should be saved in the thread control block, particularly since not all code that can be preempted will be C code.

To use something in the C library, like setjmp/longjmp, kind of limits you to C, too, which is not my idea of a robust task-switching mechanism.

Re: Context switching issue.

Posted: Thu Jan 16, 2025 4:43 pm
by thewrongchristian
rdos wrote: Thu Jan 16, 2025 2:05 pm I think Brendan's tutorial is okay, but I feel that registers should be saved in the thread control block, particularly since not all code that can be preempted will be C code.

To use something in the C library, like setjmp/longjmp, kind of limits you to C, too, which is not my idea of a robust task-switching mechanism.
Why would I worry about being limited to C? My kernel is written in C, as is Brendan's tutorial, so the only registers that need saving across the task switch call are the registers defined by the compiler's ABI as being callee saved.

The task switching is perfectly robust, I've not had any problems using setjmp/longjmp, and I specifically exclude the use of FPU registers from the compiled code.

I like C, though of course should I reimplement my kernel in another language, I'd have to follow that language's ABI instead.

Not sure why it's considered so controversial.

Re: Context switching issue.

Posted: Thu Jan 16, 2025 10:58 pm
by Octocontrabass
thewrongchristian wrote: Thu Jan 16, 2025 9:43 amMore work than what? Copying it from Brendan's tutorial?
I meant things like handling "if(setjmp(...))" control flow instead of a simple "switch_to_task(...)" call, or making sure you don't accidentally corrupt the saved context between setjmp and longjmp.
rdos wrote: Thu Jan 16, 2025 2:05 pmI think Brendan's tutorial is okay, but I feel that registers should be saved in the thread control block, particularly since not all code that can be preempted will be C code.
Registers are already saved when an interrupt occurs. Why would you save them twice?

Re: Context switching issue.

Posted: Fri Jan 17, 2025 12:18 pm
by rdos
Registers are not saved by interrupts. Only eflags, cs and eip are saved. The rest needs to be saved either by the task switching code or by the interrupt handler.

Re: Context switching issue.

Posted: Fri Jan 17, 2025 12:21 pm
by Octocontrabass
Interrupt handlers always save registers. They wouldn't work otherwise. Since interrupt handlers already save registers, you don't need to save them a second time in the task switching code.

Re: Context switching issue.

Posted: Sat Jan 18, 2025 10:20 am
by rdos
Octocontrabass wrote: Fri Jan 17, 2025 12:21 pm Interrupt handlers always save registers. They wouldn't work otherwise. Since interrupt handlers already save registers, you don't need to save them a second time in the task switching code.
Not at all. The CPU will only save flags, cs and eip. An assembly based interrupt handler will only save registers it uses. It's only C based handlers that save all registers if you use the interrupt directive.

Also note that my preemption interrupt will pop off flags, cs and eip, and also the ss and esp for user mode and save these in the task control block. When switching back, it will recreate the stack frame and lastly do an iretd. Blocking requests will save the registers of the blocking call. This way, all task switches are uniform, and it's easy to implement single stepping of code.