Développement de système d'exploitation de type Unix - Multitâche et appels système (7)

Dans l'article précédent, nous avons appris à travailler avec l'espace d'adressage virtuel. Aujourd'hui, nous ajouterons le support multitâche.

Table des matières


Construisez le système (make, gcc, gas). Démarrage initial (multiboot). Lancez (qemu). Bibliothèque C (strcpy, memcpy, strext).

Bibliothèque C (sprintf, strcpy, strcmp, strtok, va_list ...). Construction de la bibliothèque en mode noyau et en mode application utilisateur.

Le journal système du noyau. Mémoire vidéo Sortie vers le terminal (kprintf, kpanic, kassert).
Mémoire dynamique, tas (kmalloc, kfree).

Organisation de la mémoire et gestion des interruptions (GDT, IDT, PIC, syscall). Exceptions
Mémoire virtuelle (répertoire de pages et table de pages).

Processus. Planificateur Multitâche. Appels système (kill, exit, ps).
Le système de fichiers du noyau (initrd), elf et ses composants internes. Appels système (exec).

Pilotes de périphériques de caractères. Appels système (ioctl, fopen, fread, fwrite). Bibliothèque C (fopen, fclose, fprintf, fscanf).

Shell comme programme complet pour le noyau.

Mode de protection utilisateur (ring3). Segment d'état de la tâche (tss).

Multitâche


Comme vous vous en souvenez, chaque processus doit avoir son propre espace d'adressage.

Pour ce faire, vous devez compter les pages utilisées par le processus.

Par conséquent, nous devons décrire la mémoire du processus:

/* * Process memory description */ struct task_mem_t { void* pages; /* task physical pages */ u_int pages_count; /* task physical pages count */ void* page_dir; /* page directory */ void* page_table; /* page table */ }; 

Les processus doivent en quelque sorte échanger des informations entre eux. Pour ce faire, nous introduisons le concept d'un message:

 struct message_t { u_short type; /* message type */ u_int len; /* data length */ u8 data[IPC_MSG_DATA_BUFF_SIZE]; /* message data */ }; 

Après cela, vous devez décrire le processus lui-même comme un élément d'une liste cyclique.

La liste cyclique ici est pratique car dans le planificateur, vous devez sélectionner la suivante dans chaque tâche jusqu'à ce que la liste soit vide.

Par cela, nous supprimons le cas dégénéré, ce qui signifie que nous simplifions la logique.

 /* * Process descriptor */ struct task_t { struct clist_head_t list_head; /* should be at first */ u_short tid; /* task id */ char name[8]; /* task name */ struct gp_registers_t gp_registers; /* general purpose registers */ struct op_registers_t op_registers; /* other purpose registers */ struct flags_t flags; /* processor flags */ u_int time; /* time of task execution */ bool reschedule; /* whether task need to be rescheduled */ u_short status; /* task status */ int msg_count_in; /* count of incomming messages */ struct message_t msg_buff[TASK_MSG_BUFF_SIZE]; /* task message buffer */ void* kstack; /* kernel stack top */ void* ustack; /* user stack top */ struct task_mem_t task_mem; /* task memory */ } attribute(packed); 

Nous attribuons un quota à chaque processus, le nombre d'interruptions de temporisation après lequel le processus sera reprogrammé.

 #define TASK_QUOTA 3 

Ensuite, vous devez écrire une fonction pour créer un nouveau processus.

Bien que nous n'ayons pas de niveau de privilège, nous utiliserons des sélecteurs de noyau.

Nous aurons besoin de 2 piles pour le futur, une pour le mode utilisateur et une pour le noyau.

Nous allons définir l'état initial sur TASK_UNINTERRUPTABLE afin que la tâche n'ait pas le temps de se terminer avant l'initialisation complète.

 /* * Api - Create new task */ extern bool task_create(u_short tid, void* address, struct task_mem_t *task_mem) { struct task_t* task; struct clist_head_t* entry; /* allocate memory */ entry = clist_insert_entry_after(&task_list, task_list.head); task = (struct task_t*)entry->data; task->kstack = malloc(TASK_KSTACK_SIZE); task->ustack = malloc(TASK_USTACK_SIZE); /* fill data */ task->tid = tid; task->name[0] = '\0'; task->status = TASK_UNINTERRUPTABLE; task->msg_count_in = 0; task->time = 0; memcpy(&task->task_mem, task_mem, sizeof(struct task_mem_t)); /* set flags */ *(u32*)(&task->flags) = asm_get_eflags() | 0x200; /* set general purpose registers */ memset(&task->gp_registers, 0, sizeof(struct gp_registers_t)); /* set other purpose registers */ task->op_registers.cs = GDT_KCODE_SELECTOR; task->op_registers.ds = GDT_KDATA_SELECTOR; task->op_registers.ss = GDT_KSTACK_SELECTOR; task->op_registers.eip = (size_t)address; task->op_registers.cr3 = (size_t)task_mem->page_dir; task->op_registers.k_esp = (u32)task->kstack + TASK_KSTACK_SIZE; task->op_registers.u_esp = (u32)task->ustack + TASK_USTACK_SIZE; printf(MSG_SCHED_TID_CREATE, tid, (u_int)address, task->kstack, task->ustack); return true; } 

Le processus sera supprimé par le planificateur si l'état du processus est TASK_KILLING.
Lors de la suppression, nous avons juste besoin de libérer les piles, les pages allouées aux sections de données et de code, et également de détruire le répertoire des pages de processus.

En général, pour une bonne pile d'utilisateurs, vous pouvez allouer via le gestionnaire de mémoire, mais pour plus de commodité, le débogage pendant que nous l'implémentons dans le tas du noyau, qui est toujours dans les répertoires de page.

 /* * Api - Delete task by id */ extern void task_delete(struct task_t* task) { printf(MSG_SCHED_TID_DELETE, (u_int)task->tid); assert(task != null); /* free stack memory */ free(task->kstack); free(task->ustack); task->kstack = null; task->ustack = null; /* free user pages memory */ if (task->task_mem.pages_count > 0) { mm_phys_free_pages(task->task_mem.pages, task->task_mem.pages_count); task->task_mem.pages = null; task->task_mem.pages_count = 0; } /* clear resources */ if (task->task_mem.page_dir != null) { mmu_destroy_user_page_directory(task->task_mem.page_dir, task->task_mem.page_table); } clist_delete_entry(&task_list, (struct clist_head_t*)task); } 

Vous devez maintenant écrire le planificateur lui-même.

Nous devons d'abord comprendre si la tâche en cours est en cours d'exécution ou s'il s'agit de la première exécution.
Si la tâche est en cours d'exécution, vous devez vérifier si son quota a été épuisé.
Si tel est le cas, vous devez enregistrer son état en spécifiant explicitement l'indicateur d'activation d'interruption.
Après cela, vous devez trouver la prochaine tâche à exécuter dans le statut TASK_RUNNING.
Ensuite, vous devez terminer la tâche en cours si elle est à l'état TASK_KILLING.
Après cela, nous préparons le cadre de pile pour la tâche suivante et changeons de contexte.

 /* * Api - Schedule task to run */ extern void sched_schedule(size_t* ret_addr, size_t* reg_addr) { struct task_t* next_task = null; /* finish current task */ if (current_task != null) { /* update running time */ current_task->time += 1; /* check quota exceed */ if (current_task->time < TASK_QUOTA && !current_task->reschedule) { return; /* continue task execution */ } /* reset quota */ current_task->time = 0; current_task->reschedule = false; /* save task state */ current_task->op_registers.eip = *ret_addr; current_task->op_registers.cs = *(u16*)((size_t)ret_addr + 4); *(u32*)(&current_task->flags) = *(u32*)((size_t)ret_addr + 6) | 0x200; current_task->op_registers.u_esp = (size_t)ret_addr + 12; current_task->gp_registers.esp = current_task->op_registers.u_esp; memcpy(&current_task->gp_registers, (void*)reg_addr, sizeof(struct gp_registers_t)); } /* pick next task */ if (current_task) { next_task = task_get_next_by_status(TASK_RUNNING, current_task); } else { next_task = task_get_by_status(TASK_RUNNING); tss_set_kernel_stack(next_task->kstack); } assert(next_task != null); /* whether should kill current task */ if (current_task && current_task->status == TASK_KILLING) { /* kill current task */ task_delete(current_task); } else { /* try to kill killing tasks */ struct task_t* task; task = task_find_by_status(TASK_KILLING); if (task) { task_delete(task); } } /* prepare context for the next task */ next_task->op_registers.u_esp -= 4; *(u32*)(next_task->op_registers.u_esp) = (*(u16*)(&next_task->flags)) | 0x200; next_task->op_registers.u_esp -= 4; *(u32*)(next_task->op_registers.u_esp) = next_task->op_registers.cs; next_task->op_registers.u_esp -= 4; *(u32*)(next_task->op_registers.u_esp) = next_task->op_registers.eip; next_task->gp_registers.esp = next_task->op_registers.u_esp; next_task->op_registers.u_esp -= sizeof(struct gp_registers_t); memcpy((void*)next_task->op_registers.u_esp, (void*)&next_task->gp_registers, sizeof(struct gp_registers_t)); /* update current task pointer */ current_task = next_task; /* run next task */ printf(MSG_SCHED_NEXT, next_task->tid, next_task->op_registers.u_esp, *ret_addr, next_task->op_registers.eip); asm_switch_ucontext(next_task->op_registers.u_esp, next_task->op_registers.cr3); } 

Il reste à écrire une fonction de changement de contexte de tâche.

Pour ce faire, vous devez télécharger un nouveau répertoire de pages et restaurer tous les registres généraux, y compris les indicateurs.

Vous devez également basculer vers la pile de la tâche suivante.

Ensuite, vous devez faire un retour de l'interruption, car nous avons formé un cadre de pile spécial pour cela.

Pour le fait que plus tard, nous en aurons besoin pour changer de niveau de privilège.

 /* * Switch context (to kernel ring) * void asm_switch_kcontext(u32 esp, u32 cr3) */ asm_switch_kcontext: mov 4(%esp),%ebp # ebp = esp mov 8(%esp),%eax # eax = cr3 mov %cr0,%ebx # ebx = cr0 and $0x7FFFFFFF,%ebx # unset PG bit mov %ebx,%cr0 mov %eax,%cr3 or $0x80000001,%ebx # set PE & PG bits mov %ebx,%cr0 mov %ebp,%esp popal sti iretl 

Nous implémentons le mécanisme de messagerie le plus simple entre les processus.

Lorsque nous envoyons un message à un processus, il doit être décongelé s'il est gelé car il attend des messages.

Mais lorsque nous recevons un message, nous devons geler le processus si la file d'attente de messages est vide.

Après cela, vous devez transférer le contrôle au planificateur.

 /* * Api - Send message to task */ extern void ksend(u_short tid, struct message_t* msg) { struct task_t* task; /* get target task */ task = task_get_by_id(tid); /* put message to task buffer */ task_pack_message(task, msg); /* whether task has frozen */ if (task->status == TASK_INTERRUPTABLE) { /* defrost task */ task->status = TASK_RUNNING; } } /* * Api - Receive message to task * This function has blocking behaviour */ extern void kreceive(u_short tid, struct message_t* msg) { struct task_t* task_before; /* before yield */ struct task_t* task_after; /* after yield */ /* get current task */ task_before = sched_get_current_task(); assert(tid == task_before->tid); assert(task_before->status == TASK_RUNNING); /* whether task has not incomming messages */ if (task_before->msg_count_in == 0) { /* freeze task */ task_before->status = TASK_INTERRUPTABLE; } /* wait fot message */ sched_yield(); /* get current task */ task_after = sched_get_current_task(); assert(task_after == task_before); assert(tid == task_after->tid); assert(task_after->status == TASK_RUNNING); /* fetch message from buffer */ task_extract_message(task_after, msg); assert(msg != null); } 

Vous devez maintenant écrire des appels système pour travailler avec les processus des applications utilisateur.

Là, nous lancerons des appels système pour envoyer et recevoir des messages.

 /* * Api - Syscall handler */ extern size_t ih_syscall(u_int* function) { size_t params_addr = ((size_t)function + sizeof(u_int)); size_t result = 0; struct task_t *current = sched_get_current_task(); printf(MSG_SYSCALL, *function, current->tid); asm_lock(); /* handle syscall */ switch (*function) { case SYSCALL_KSEND: { /* send message */ u_short tid = *(u_int*)params_addr; ksend(tid, *(struct message_t**)(params_addr + 4)); break; } case SYSCALL_KRECEIVE: { /* receive message */ u_short tid = *(u_int*)params_addr; kreceive(tid, *(struct message_t**)(params_addr + 4)); break; } case SYSCALL_KILL: { /* kill task */ u_short tid = *(u_int*)params_addr; struct task_t* task = task_find_by_id(tid); if (task != null) { assert(task->tid == tid); task->status = TASK_KILLING; task->reschedule = true; result = true; } else { result = false; } break; } case SYSCALL_EXIT: { /* exit task */ int errno = *(int*)params_addr; u_int tid = current->tid; printf(MSG_TASK_FINISHED, tid, errno); current->status = TASK_KILLING; sched_yield(); break; } case SYSCALL_TASK_LIST: { /* get tasks list */ result = (size_t)task_get_task_list(); break; } default: unreachable(); } printf(MSG_SYSCALL_RET, *function); asm_unlock(); return result; } 

Afin d'utiliser les appels système de notre bibliothèque C, nous devons écrire une fonction qui provoque une interruption du noyau. Par souci de simplicité, nous n'utiliserons pas de commandes Intel spéciales, car nous n'avons pas de niveau de privilège. Comme argument, nous passerons le numéro de la fonction système et les arguments dont elle a besoin.

 /* * Call kernel * void asm_syscall(unsigned int function, ...) */ asm_syscall: push %ebx push %ebp mov %esp,%ebp mov %ebp,%ebx add $8,%ebx # skip registers add $4,%ebx # skip return address push %ebx # &function int $0x80 mov %ebp,%esp pop %ebp pop %ebx ret 

C'est tout à fait suffisant pour implémenter le multitâche le plus simple sans le support d'anneaux de protection. Ouvrez maintenant le didacticiel vidéo et regardez tout dans l'ordre!

Les références


Détails et explications dans le didacticiel vidéo .

Le code source dans le référentiel git (vous avez besoin de la branche de leçon 7).

Les références


1. James Molloy. Faites rouler votre propre système d'exploitation jouet UNIX-clone.
2. Dents. Assembleur pour DOS, Windows, Unix
3. Kalachnikov. L'assembleur est facile!
4. Tanenbaum. Systèmes d'exploitation. Mise en œuvre et développement.
5. Robert Love. Noyau Linux Description du processus de développement.

Source: https://habr.com/ru/post/fr468057/


All Articles