Page 1 of 1

A Couple Questions About the TSS on Protected Mode

Posted: Fri Sep 19, 2025 4:14 pm
by avcado
I've been working on a single-tasking kernel for the past month. I've felt this feeling deep inside me that my kernel sucks without multitasking, like it's not worth anything, however, that's besides the point. I want to implement some form of multitasking, either
hardware (considering I'm in 32-bit mode) or software.

I know I use the TSS for said hardware task switching but I have a couple questions:

Firstly, I read on this post that
Nobody uses hardware task switching, it's a leftover from the 286.
Why not? Is it due to it being slower than software? Secondly, how does adding tasks work? Is this just adding a new entry to the GDT?

Lastly, how does actually switching work? Can I do something like this in a timer interrupt handler (like round-robin)?

Code: Select all

if(timer_ticks % SOME_QUANTUM_VALUE == 0){
  save context of current task in TSS
  load context of next tass in TSS
  switch tasks.
}
Thanks in advance!

Re: A Couple Questions About the TSS on Protected Mode

Posted: Fri Sep 19, 2025 9:51 pm
by nullplan
avcado wrote: Fri Sep 19, 2025 4:14 pm Why not? Is it due to it being slower than software?
Before you think about speed, think about functionality. Hardware task switching is way less flexible than software. The only thing it does is to save and reload the GPRs and CR3. The first problem with this is that it doesn't allow you to save FPU and vector registers. The model of the time was to use lazy FPU switching, because "FPU use is rare". Well, it might have been in the early 90ies, but these days, GCC will emit SSE instructions even for integer operations, so now most processes use vector registers, so the whole thing just adds another interrupt latency and another level of complexity to task switching.

Second problem is that of course on an SMP system, CPU migration is made harder with lazy FPU switching. It is relatively easy to build a "pull" system, where a CPU notices that it is about to go to sleep, but there is a task on another CPU that's been runnable for some time but never got a chance to, and to then execute that task. With eager FPU saving at least, this is as easy as restoring the task on the new CPU. With lazy FPU saving, you now have to send an IPI to the other CPU to tell it to save. And then wait for it to be done. Those things are always fun to write.

Third problem is portability: Do you really want to shackle your OS to 32-bit mode? Hardware task switching is only available on 32-bit mode of x86 and not anywhere else, whereas software task switching only requires rewriting a few routines for each architecture. Do you know where your OS will go?

Fourth problem is lack of use. Hardware task switching went unused since Linux switched to software task switching in the mid-90ies. So I am sure there are some undiscovered bugs in current implementations of it somewhere.
avcado wrote: Fri Sep 19, 2025 4:14 pm Secondly, how does adding tasks work? Is this just adding a new entry to the GDT?
In hardware task switching, each task has a TSS. You can add them all to the GDT, but that limits the number of tasks you have total, so you can also consider the GDT as a noncoherent cache of TSSs. When loading a new task, you load its TSS into the GDT. Not sure how you trigger the actual task switch correctly. Was it a far jump to the TSS segment? Something like that. I had once thought of a system using an interrupt to switch tasks, but it was just way overcomplicated.
avcado wrote: Fri Sep 19, 2025 4:14 pm Lastly, how does actually switching work? Can I do something like this in a timer interrupt handler (like round-robin)?
Since I would always counsel for software task switching, I would tell you to design the system like this:
  1. Each task has its own kernel stack.
  2. You enable the timer only when you need it, i.e. when more tasks are runnable than CPUs exist in the system. And program the timer they way you need it! If you need an interrupt in 1ms, program it to interrupt you in 1ms.
  3. On task switch, you save all the nonvolatile registers to stack, switch stacks, and load all the nonvolatile registers from the other stack.
  4. One level higher than that function, you do all the higher level logic, like FPU saving and loading, CR3 saving and loading, maybe debug register saving and loading when you get to the point you actually have debugging APIs.
When creating a task, the initial stack only needs to synthesize a few registers and set the return pointer to the initial function. Notice that in the above model, from the point of view of the caller, the actual task switching function blocks until the task is resumed. So it is not appropriate to call in contexts which allow no blocking, like interrupt handlers. I have a thing called a late interrupt handler, which runs after all the interrupt stuff has been done, so it can block.

Re: A Couple Questions About the TSS on Protected Mode

Posted: Sun Sep 21, 2025 9:02 am
by avcado
nullplan wrote: Fri Sep 19, 2025 9:51 pm Third problem is portability: Do you really want to shackle your OS to 32-bit mode? Hardware task switching is only available on 32-bit mode of x86 and not anywhere else, whereas software task switching only requires rewriting a few routines for each architecture. Do you know where your OS will go?

Fourth problem is lack of use. Hardware task switching went unused since Linux switched to software task switching in the mid-90ies. So I am sure there are some undiscovered bugs in current implementations of it somewhere.
I had been thinking a lot about what architecture I wanted my kernel to run on. The idea was to "rejuvenate" some old hardware I own, and had the idea to write a 32-bit kernel. I may rewrite the kernel to support 64-bit, but for right now, I'm happy with 32-bit mode.
nullplan wrote: Fri Sep 19, 2025 9:51 pm Since I would always counsel for software task switching, I would tell you to design the system like this:
I had been thinking about writing a software task switcher (doing something like a red-robin), but I'm not entirely sure on how you save contexts.
From my understanding:
  • The list of tasks is a linked list.
  • On every n ticks of some timer interrupt, save the current task's context, load the next task's context (if task->next is null, use task->head) and return from the interrupt.
I had heard of using setjmp/getjmp to handle getting/setting task context but I was confused on the usage (Wikipedia has an article, but it confused me more), aswell as actually implementing it because I heard it was part of the C stdlib.

Thank you for your response though! :D :D

Re: A Couple Questions About the TSS on Protected Mode

Posted: Sun Sep 21, 2025 10:21 am
by nullplan
avcado wrote: Sun Sep 21, 2025 9:02 am I had been thinking a lot about what architecture I wanted my kernel to run on. The idea was to "rejuvenate" some old hardware I own, and had the idea to write a 32-bit kernel. I may rewrite the kernel to support 64-bit, but for right now, I'm happy with 32-bit mode.
If you designed your kernel to be portable and have decent architecture abstractions, you wouldn't need to rewrite it outright. There is a difference between supporting an architecture and shackling yourself to it.
avcado wrote: Sun Sep 21, 2025 9:02 am I had been thinking about writing a software task switcher (doing something like a red-robin), but I'm not entirely sure on how you save contexts.
From my understanding:
  • The list of tasks is a linked list.
  • On every n ticks of some timer interrupt, save the current task's context, load the next task's context (if task->next is null, use task->head) and return from the interrupt.
Separate the timing of the task switch from the mechanism. A lot of tasks will block waiting for some event before any time quota is up.

I'd put the logic for task switching itself into a function called "schedule()", which is a function returning nothing and taking no arguments. The function finds the next runnable task and switches to it, so for example:

Code: Select all

void schedule(void) {
  struct task *old = this_cpu()->current;
  struct task *new = find_next_runnable_task();
  if (new != old)
    arch_task_switch(old, new);
}
And then on i386, the task switch is something like

Code: Select all

void arch_task_switch(struct task *old, struct task *new) {
  if (old->flags & TIF_FPU)
    arch_save_fpu(old->fpusave_area);
  /* if new task is a kernel task, we only need to load its CR3 if old is condemned */
  if (!(new->flags & TIF_KERNEL) || (old->flags & TIF_CONDEMNED))
    arch_load_cr3(new->cr3);
  arch_ll_task_switch(&old->stack_bottom, new->stack_bottom);
  /* when we get here, the old task has been resumed */
  /* our cr3 has been loaded by whoever resumed us already. */
  this_cpu()->tss.esp0 = old->stack_top;
  if (old->flags & TIF_FPU)
    arch_load_fpu(old->fpusave_area);
  this_cpu()->current = old;
}
And the low level task switch:

Code: Select all

arch_ll_task_switch:
  movl 4(%esp), %eax
  pushl %ebp
  pushl %ebx
  pushl %esi
  pushl %edi
  movl %esp, (%eax)
  movl 24(%esp), %esp
  popl %edi
  popl %esi
  popl %ebx
  popl %ebp
  retl
So it just saves the nonvolatile registers to stack and loads the new stack. I wouldn't use the names setjmp() and longjmp() for this, because the compiler may have opinions on what those names are supposed to mean.

Now, how do you make a new task? Maybe something like this? Have this ready in assembler:

Code: Select all

start_task:
  andl $-16, %esp
  push $0
  push %ebx
  pushl %esi
  pushl %edi
  call start_task_c
And in C:

Code: Select all

_Noreturn void start_task_c(int (*func)(void *), void *arg, struct task *task) {
  current = task;
  task_exit(func(arg))
}
extern const char start_task[];
struct task *create_task(int (*func)(void *), void *, uintptr_t cr3) {
  char *mem = allocate_kernel_stack(); /* allocates one or two pages */
  if (!mem) return 0;
  struct task *rv = (void *)(((uintptr_t)mem + kernel_stack_size() - sizeof (struct task)) & -alignof(struct task));
  memset(rv, 0, sizeof (struct task));
  uint32_t *stack = (void *)((uintptr_t)rv & -16);
  rv->stack_top = (uintptr_t)stack;
  rv->cr3 = cr3;
  *--stack = (uintptr_t)start_task;
  *--stack = 0; //ebp
  *--stack = (uintptr_t)rv; //ebx
  *--stack = (uintptr_t)arg; //esi
  *--stack = (uintptr_t)func //edi
  rv->stack_bottom = (uintptr_t)stack;
  return rv;
}
I'm letting the main thread function return a value, but so far have no idea if I'd even use the return value. Exiting a thread is then just setting TIF_CONDEMNED and calling schedule() in an infinite loop. schedule() will never schedule in a condemned task. And somewhere you need code to collect all the condemned tasks and free them. Or maybe just put them in a pile for reuse later.

Now, you keep getting back to the timer interrupt. You can call schedule() from the timer interrupt handler, but be aware that that function blocks, so you can only do so after acknowledging the interrupt to the interrupt controller and possibly the timer. But with the above design, you can also call schedule() from a syscall handler, for example, while waiting for data.

The above code is entirely untested. I am merely trying to convey an idea. It doesn't contain the actual scheduling code, because that is your design problem to solve. It doesn't contain code to actually allocate memory for the FPU data, and to save it. I trust you to read up on that. There is an unpleasant amount of code doubling between start_task_c() and the second half of arch_task_switch(), but for now, you have a starting point.