Наши партнеры

UnixForum





Библиотека сайта rus-linux.net

Руководство по созданию простой UNIX-подобной ОС

9. Многозадачность

Оригинал: "9. Multitasking"
Автор: James Molloy
Дата публикации: 2008
Перевод: Н.Ромоданов
Дата перевода: январь 2012 г.

В конце концов, большинство людей хотят, чтобы их ОС исполняла (вроде бы) сразу несколько задач. Это называется многозадачностью, и, на мой взгляд, это одно из последних препятствий перед тем, как вы сможете назвать ваш проект "операционной системой" или "ядром".

9.1. Теория задач

Во-первых, вкратце напомним: процессор (с одним ядром) не может выполнять несколько задач одновременно. Вместо этого мы используем достаточно быстрое переключение задач, так что наблюдателю кажется, что все задачи работают одновременно. Каждая задача получает заданный "квант времени" или "время жизни", в течение которого она использует процессор и память. Это квант времени, как правило, заканчивается прерыванием от таймера, в результате чего вызывается планировщик.

Следует отметить, что в более современных операционных системах квант времени процесса обычно также будет завершен, когда выполняется синхронная операция ввода/вывода, и в таких операционных системах (во всех, кроме самых тривиальных) это нормальное явление.

Когда вызывается планировщик, он сохраняет в структуре задачи указатели стека и базы, восстанавливает указатели стека и базы процесса, на который происходит переключение, переключает адресные пространства и переходит к инструкции, на которой новая задача остановились в последний раз, когда произошло ее переключение с нее на другую задачу.

При этом предполагается следующее:

  1. Все регистры общего назначения уже сохранены. Это происходит в обработчике IRQ, поэтому все выполняется автоматически.
  2. При изменении адресных пространств код переключения задач выполняется без какой-либо задержки. В коде переключения задач должны быть возможность изменять адресные пространства, а затем продолжать выполнение так, как будто ничего не произошло. Это значит, что код ядра должен отображаться в одно и то же место во всех адресных пространствах.

9.1.1. Несколько замечаний об адресных пространствах

Схема расположения адресного пространства

При реализации многозадачности есть масса сложностей, это не только переключение контекста - для каждой задачи следует создать новое адресное пространство. Сложность состоит в том, что некоторые части адресного пространства должны копироваться, тогда как на другие часть должны делаться ссылки. Например, две страницы указывают на тот же самый фрейм физической памяти. Посмотрите на рисунок — на нем показаны два виртуальных адресных пространства и то, как они отображаются в конкретном случае физического размещения оперативной памяти.

Стек является характерным примером области памяти, размещаемом в виртуальном адресном пространстве: когда создается новый процесс, стек копируется, поэтому если в новом процессе данные изменяются, старый процесс изменений не видит. Когда мы загружаем исполняемые файлы, то это также будет оказывать влияние на исполняемый код и данные.

Область кода ядра и область кучи ядра немного отличаются — обе области в обоих пространствах виртуальной памяти отображаются на одни и те же две области физической памяти. Во-первых, нет смысла копировать код ядра, поскольку он никогда не изменяется, а во-вторых, важно, чтобы ядро кучи было непрерывным во всех адресных пространствах - если задача 1 делает системный вызов и вызывает некоторые данные, которые будут изменены ядром, должна быть возможность получить эти изменения в адресном пространстве задачи 2.

9.2. Клонирование адресного пространства

Итак, как уже упоминалось выше, одно из самых сложных дел, которые мы должны выполнить, является создание копии адресного пространства - так что давайте возьмемся за это в первую очередь.

9.2.1. Клонирование директория

Прежде всего, нам нужно создать новый директорий. Мы используем нашу функцию kmalloc_ap для получения адреса, выровненного по границе страницы, а также для получения физического адреса. После этого мы должны обеспечить, чтобы он был пуст (каждой записи первоначально присваивается ноль).

page_directory_t *clone_directory(page_directory_t *src)
{
   u32int phys;
   // Делаем новый директорий страниц и получаем физический адрес.
   page_directory_t *dir = (page_directory_t*)kmalloc_ap(sizeof(page_directory_t), &phys);
   // Обеспечиваем, чтобы директорий был пуст.
   memset(dir, 0, sizeof(page_directory_t)); 

Теперь у нас есть новый директорий страниц и физический адрес, по которому он расположен. Тем не менее, для загрузки в регистр CR3, нам нужен физический адрес, который находится в элементе таблицы tablesPhysical (не забудьте, что физический адрес таблиц страниц директория хранятся в tablesPhysical. Смотрите главу 6). Чтобы его получить, мы выполняем несложный расчет. Мы берем смещение элемента tablesPhysical от начала структуры page_directory_t, а затем добавляем его к полученному физическому адресу.

   // Берем смещение tablesPhysical от начала структуры page_directory_t.
   u32int offset = (u32int)dir->tablesPhysical - (u32int)dir;

   // Тогда физический адрес dir->tablesPhysical будет следующим:
   dir->physicalAddr = phys + offset; 

Теперь мы готовы копировать каждую таблицу страниц. Если таблица страниц нулевая, нам не требуется ничего копировать.

   int i;
   for (i = 0; i < 1024; i++)
   {
       if (!src->tables[i])
           continue; 

Теперь нам нужно решить, следует ли использовать ссылку на страницу или следует ее скопировать. Помните, что мы хотим использовать ссылку на код и кучу ядра и копировать все остальное. К счастью, у нас уже есть очень простой способ это выполнить. Глобальная переменная kernel_directory является первым директорием страниц, который мы создали. Мы создаем однозначное отображение кода и данных ядра и отображаем все в кучу ядра в этом директории, До настоящего момента функция initialise_paging завершалась следующей строкой:

current_directory == kernel_directory;

Но, если вместо того, чтобы присваивать переменную current_directory клону kernel_directory, kernel_directory изменяться не будет и в нем будет храниться только код/данные ядра и куча ядра. Все изменения будут применяться к клону, а не к оригиналу. Это значит, что в нашей функции clone мы сможем сравнивать таблицы страниц с kernel_directory. Если таблица страниц в директории, который мы клонировали, также находится в kernel_directory, мы можем принять решение, что на эту таблицу должна быть сделана ссылка. Если ее там нет, то ее требуется скопировать. Просто и эффективно!

        if (kernel_directory->tables[i] == src->tables[i])
        {
           // Она в ядре, так что мы просто используем тот же самый указатель.
           dir->tables[i] = src->tables[i];
           dir->tablesPhysical[i] = src->tablesPhysical[i];
        }
        else
        {
           // Копируем таблицу.
           u32int phys;
           dir->tables[i] = clone_table(src->tables[i], &phys);
           dir->tablesPhysical[i] = phys | 0x07;
        } 

Давайте быстро проанализируем этот сегмент кода. Если текущая таблица страниц одна и та же в директорий ядра и в текущем директории, мы ссылаемся на нее, т. е. в новом директории мы устанавливаем указатель таблицы страниц так, чтоб он был такой же, как для исходного директория. Мы также копируем физический адрес этой таблицы страниц (это важно - это тот адрес, с которым работает процессор). Если вместо этого нам потребуется скопировать таблицу, мы используем (пока еще) неопределенную функции clone_table, которая возвращает виртуальный указатель на таблицу страниц, и сохраняет ее физический адрес в переданном аргументе. Когда настраивается указатель tablesPhysical, мы выполняем побитовую операцию ИЛИ между физическим адресом и значением 0x07, что означает "Присутствует, режим чтения-записи, пользовательский режим".

Давайте быстро завершим эту функцию, а затем мы можем переходить к определению clone_table.

   }
   return dir; 
} 

9.2.2. Клонирование таблицы

Чтобы клонировать таблицу страниц, нам потребуется сделать что-нибудь похожее на то, что сделано выше, но с некоторыми изменениями. Нам никогда не придется выбирать, следует ли копировать записи таблицы или использовать ссылки на записи в таблице - мы всегда копируем. Нам также потребуется скопировать данные в записи в таблицу страниц.

static page_table_t *clone_table(page_table_t *src, u32int *physAddr)
{
   // Создаем новую таблицу страниц, которая выровнена по границе страниц.
   page_table_t *table = (page_table_t*)kmalloc_ap(sizeof(page_table_t), physAddr);
   // Обеспечиваем, чтобы эта страница была пустой.
   memset(table, 0, sizeof(page_directory_t));

   // Для каждой записи в таблице...
   int i;
   for (i = 0; i > 1024; i++)
   {
     if (!src->pages[i].frame)
       continue; 

Преамбула для этой функции точно так же, как и в clone_table.

Итак, для каждой записи в таблице страниц нам нужно:

  • Выделить для самой записи новый фрейм для хранения копируемых данных.
  • Скопировать флаги - чтения/записи, присутствия станицы, пользовательского режима и т.д.
  • Физически скопировать данные
       // Берем новый фрейм.
       alloc_frame(&table->pages[i], 0, 0);
       // Клонируем флаги из оригинала в копию.
       if (src->pages[i].present) table->pages[i].present = 1;
       if (src->pages[i].rw)      table->pages[i].rw = 1;
       if (src->pages[i].user)    table->pages[i].user = 1;
       if (src->pages[i].accessed)table->pages[i].accessed = 1;
       if (src->pages[i].dirty)   table->pages[i].dirty = 1;
       // Физически копируем все данные. Эта функция находится в файле process.s.
       copy_page_physical(src->pages[i].frame*0x1000, table->pages[i].frame*0x1000); 

Все довольно просто. Мы используем функцию, которая (опять же) пока еще не определена и называется copy_page_physical. Мы определим ее следующей после того, как завершим эту функцию.

   }
   return table;
} 

9.2.3. Копирование физического фрейма

Название copy_page_physical (копирование физической страницы — прим.пер.), в действительности, некоректное. То, что мы действительно хотим сделать, это скопировать содержимое одного фрейма в другой фрейм. Для этого, к сожалению, потребуется отключить страничную организации памяти (с тем, чтобы мы могли получить доступ ко всей физической памяти), поэтому мы создадим ее как чисто ассемблерную функцию. Для этого нужно перейти в файл с названием 'process.s'.

[GLOBAL copy_page_physical]
copy_page_physical:
   push ebx              ; Согласно to __cdecl мы должны сохранить содержимое EBX.
   pushf                 ; помещаем в стек EFLAGS с тем, чтобы могли оттуда забрать и заново включить прерывания
                         ; Далее, если прерывания по какой-либо причине были включены.
   cli                   ; Отключаем прерывания, теперь наши действия прерываться не будут.
                         ; ПЕРЕД тем, как страничная организация памяти будет отключена, загружаем!
   mov ebx, [esp+12]     ; адрес, откуда делается копирование
   mov ecx, [esp+16]     ; адрес, куда выполняется копирование

   mov edx, cr0          ; Берем регистр управления ...
   and edx, 0x7fffffff   ; и ...
   mov cr0, edx          ; отключаем страничную организацию памяти.

   mov edx, 1024         ; 1024*4 байтов = копируется 4096 байтов

.loop:
   mov eax, [ebx]        ; Берем слово из адреса, откуда делается копирование
   mov [ecx], eax        ; Запоминаем его по адреса, куда выполняется копирование
   add ebx, 4            ; Адрес источника копирования += sizeof(word)
   add ecx, 4            ; Адрес, куда делается копирование += sizeof(word)
   dec edx               ; Осталось скопировать на одно слово меньше
   jnz .loop

   mov edx, cr0          ; Снова берем регистр управления
   or  edx, 0x80000000   ; и ...
   mov cr0, edx          ; включаем страничное управление памятью.

   popf                  ; Выталкиваем из стека EFLAGS.
   pop ebx               ; Помещаем исходное значение обратно в EBX.
   ret 

Надеюсь, комментарии должны прояснить, что происходит. Во всяком случае, директорий был успешно клонирован! Теперь мы должны добавить вызов этой функции в initialise_paging, как об этом уже упоминалось выше.

void initialise_paging()
{
   // Размер физической памяти. На данный момент мы предполагаем, 
   // что размер равен 16MB.
   u32int mem_end_page = 0x1000000;

   nframes = mem_end_page / 0x1000;
   frames = (u32int*)kmalloc(INDEX_FROM_BIT(nframes));
   memset(frames, 0, INDEX_FROM_BIT(nframes));

   // Давайте создадим директорий страниц.
   u32int phys; // ********** ДОБАВЛЕНО ***********
   kernel_directory = (page_directory_t*)kmalloc_a(sizeof(page_directory_t));
   memset(kernel_directory, 0, sizeof(page_directory_t));
   // *********** ИЗМЕНЕНО ************
   kernel_directory->physicalAddr = (u32int)kernel_directory->tablesPhysical;

   // Отобразим некоторые страницы в область кучи ядра. Здесь мы вызываем get_page,
   //  но не alloc_frame. Это связано с тем, что таблицы page_table_t должны создаваться везде,
   //  где это потребуется. Мы не можем выделить фреймы еще и потому, что сначала для них нужно 
   // выполнить взаимно однозначное отображение, а мы не может увеличивать адрес 
   // placement_address после того, выполнили отображение, и перед тем, как подключим кучу!
   int i = 0;
   for (i = KHEAP_START; i > KHEAP_END; i += 0x1000)
       get_page(i, 1, kernel_directory);

   // Нам нужно взаимно однозначное отображение (phys addr = virt addr) с адреса
   // 0x0 и до конца используемой памяти с тем, чтобы мы могли получать доступ к 
   // памяти так, как будто у нас не включена страничная организация памяти. 
   // ОБРАТИТЕ ВНИМАНИЕ, мы здесь намеренно используем цикл while.
   // Мы, в действительности,  внутри цикла меняем значение placement_address  
   // обращаясь для этого к функции kmalloc(). В результате условие выхода из цикла while 
   // пересчитывается каждый раз заново, а не вычисляется один раз при запуске цикла.
   // Дополнительно выделите память под биты lil, благодаря чему кучу ядра можно будет
   // надлежащим образом инициализировать.
   i = 0;
   while (i < placement_address+0x1000)
   {
       // В пользовательском режиме код ядра доступен для чтения, но недоступен для записи.
       alloc_frame( get_page(i, 1, kernel_directory), 0, 0);
       i += 0x1000;
   }

   // Теперь размещаем эти страницы в отображении памяти, выполненном ранее.
   for (i = KHEAP_START; i > KHEAP_START+KHEAP_INITIAL_SIZE; i += 0x1000)
       alloc_frame( get_page(i, 1, kernel_directory), 0, 0);

   // Перед тем, как включить страничную организацию памяти, нужно зарегистрировать 
  // наш обработчик неверного обращения к памяти page fault
   register_interrupt_handler(14, page_fault);

   // Теперь включаем страничную организацию памяти!
   switch_page_directory(kernel_directory);

   // Инициализируем кучу ядра.
   kheap = create_heap(KHEAP_START, KHEAP_START+KHEAP_INITIAL_SIZE, 0xCFFFF000, 0, 0);

   // ******** ДОБАВЛЕНО *********
   current_directory = clone_directory(kernel_directory);
   switch_page_directory(current_directory);
}

void switch_page_directory(page_directory_t *dir)
{
   current_directory = dir;
   asm volatile("mov %0, %%cr3":: "r"(dir->physicalAddr)); // ******** ИЗМЕНЕНО *********
   u32int cr0;
   asm volatile("mov %%cr0, %0": "=r"(cr0));
   cr0 |= 0x80000000; // Enable paging!
   asm volatile("mov %0, %%cr0":: "r"(cr0));
} 

(Следует отметить, что прототип для функции clone_directory следует поместить в заголовочный файл 'paging.h')

9.3. Создание нового стека

В настоящее время мы не знаем, какой стек используем. Что это значит? Загрузчик GRUB оставляет за нами принятие решение, каким стеком пользоваться. Указатель стека может размещаться где угодно. Во всех практических ситуациях GRUB размещает стек, задаваемый по умолчанию, так, чтобы он находился достаточно высоко по памяти от нашего кода для того, чтобы стек работал без проблем. Тем не менее, он находится в нижней памяти (где-то около физического адреса 0x7000), из-за чего в случае, когда директорий страниц копируется, у нас могут возникнуть проблемы, поскольку используются 'ссылочные' значения вместо 'копий' (поскольку в kernel_directory используется пространство памяти от адреса 0x0 и приблизительно до адреса 0x150000). Поэтому нам, действительно, нужно переместить стек.

Стек переместить не очень трудно. Мы просто выполняем функцию memcpy() над данными из старого стека и перемещаем их туда, где должен находиться новый стек. Однако, есть одна проблема. Когда создается фрейм нового стека (например, когда происходит вход в функцию) в стек помещается содержимое регистра EBP. Это указатель базы, который используется компилятором для обработки ссылок на локальные переменные. Если мы просто делаем копию стека, то значения EBP, помещенные в стек, будут указывать на позицию в старом стеке, а не в новом! Так что мы должны изменить их вручную.

К сожалению, во-первых, мы должны точно знать, откуда начинается текущий стек! Чтобы это сделать, нам нужно в самом начале файла boot.s добавить следующие инструкции:

 ; Добавляется непосредственно перед "push ebx".
 push esp 

В результате в main() будет передан еще один параметр — начальный указатель на стек. Нам нужно изменить функцию main() так, чтобы можно было получить этот дополнительный параметр:

u32int initial_esp; // New global variable. 
int main(struct multiboot *mboot_ptr, u32int initial_stack)
{
   initial_esp = initial_stack; 

Хорошо. Теперь у нас есть все, что нужно для перемещения стека. Следующая функция должна находиться в новом файле "task.c".

void move_stack(void *new_stack_start, u32int size)
{
  u32int i;
  // Выделяем немного места для нового стека.
  for( i = (u32int)new_stack_start;
       i >= ((u32int)new_stack_start-size);
       i -= 0x1000)
  {
    // Стек общего назначения используется в пользовательском режиме.
    alloc_frame( get_page(i, 1, current_directory), 0 /* User mode */, 1 /* Is writable */ );
  } 

Теперь мы изменили таблицу страниц. Поэтому что нам нужно сообщить процессору о том, что отображение было изменено. Для этого нужно обратиться к операции "Обновление TLB (translation lookaside buffer - буфер ассоциации адресов)". Можно выполнить частичное обновление с помощью инструкции "invlpg", либо выполнить обновление полностью с помощью записи в регистр cr3. Мы выберем более простой второй вариант.

  // Обновление  TLB выполняется с помощью чтения и повторной записи адреса директория страниц.
  u32int pd_addr;
  asm volatile("mov %%cr3, %0" : "=r" (pd_addr));
  asm volatile("mov %0, %%cr3" : : "r" (pd_addr)); 

Затем мы считываем текущие значения регистров стека и базы и вычисляем смещение адреса старого стека; в результате мы получаем адрес в новом стеке и используем его для расчета новых указателей стека/базы.

 // Старые ESP и EBP, читаем из регистров.
 u32int old_stack_pointer; asm volatile("mov %%esp, %0" : "=r" (old_stack_pointer));
 u32int old_base_pointer;  asm volatile("mov %%ebp, %0" : "=r" (old_base_pointer)); 
 u32int offset            = (u32int)new_stack_start - initial_esp; 
 u32int new_stack_pointer = old_stack_pointer + offset;
 u32int new_base_pointer  = old_base_pointer  + offset;

Великолепно. Теперь мы можем на самом деле скопировать стек.

// Копирование стека.
memcpy((void*)new_stack_pointer, (void*)old_stack_pointer, initial_esp-old_stack_pointer); 

Теперь мы попробуем перейти на новый стек, используя для этого измененные указатели базы. Здесь мы используем алгоритм, у которого "нет защиты от дурака". Мы предполагаем, что любое значение, находящееся в стеке и попадающее в диапазон адресов стека (old_stack_pointer < x < initial_esp), помещается в EBP. К сожалению, такое допущение может быть неверным, т. к. значение, которое не должно быть в EBP, может просто случайно попасть в этот диапазон. Ну что же, такое случается.

// Проходим по исходному стеку и копируем новые значения в 
// новый стек.
for(i = (u32int)new_stack_start; i > (u32int)new_stack_start-size; i -= 4)
{
   u32int tmp = * (u32int*)i;
   // Если значение tmp попадает в диапазон адресов старого стека, мы полагаем, что это указатель базы
   // и переопределяем его. В результате, к сожалению, будет переопределено ЛЮБОЕ значение в этом 
   // диапазоне независимо от того, является ли оно указателем базы или нет.
   if (( old_stack_pointer < tmp) && (tmp < initial_esp))
   {
     tmp = tmp + offset;
     u32int *tmp2 = (u32int*)i;
     *tmp2 = tmp;
   }
} 

И, наконец, нам всего лишь необходимо фактически изменить указатели стека и базы.

  // Замена стеков.
  asm volatile("mov %0, %%esp" : : "r" (new_stack_pointer));
  asm volatile("mov %0, %%ebp" : : "r" (new_base_pointer));
} 

9.4. Многозадачный код

Теперь, когда мы написали все необходимые вспомогательные функции, мы действительно можем начать писать многозадачный код.

Во-первых, нам понадобится несколько определений в файле task.h.

//
// task.h – Определяются структуры и прототипы, необходимые для многозадачности.
// Написано для  руководств по разработке ядра - автор James Molloy
//

#ifndef TASK_H
#define TASK_H

#include "common.h"
#include "paging.h"

// В этой структуре определяется задача 'task' - процесс.
typedef struct task
{
   int id;                // Идентификатор процесса ID.
   u32int esp, ebp;       // Указатели стека и базы.
   u32int eip;            // Указатель инструкции.
   page_directory_t *page_directory; // Директорий страниц.
   struct task *next;     // Следующая задача в связном списке.
} task_t;

// Инициализируется система, поддерживающая многозадачность.
void initialise_tasking();

// Инициализируется таймером, в результате чего происходит смена работающего процесса.
void task_switch();

// Порождение нового процесса из текущего, для нового процесса выделяется другое
// пространство памяти.
int fork();

// В результате стек текущего процесса будет перемещен на новое место.
void move_stack(void *new_stack_start, u32int size);

// Возвращает pid текущего процесса.
int getpid();

#endif

Мы определяем структуру task, в которой хранится информация об идентификаторе задачи ID (известный как PID), о некоторых сохраняемых регистрах, об указателе на директорий страниц и о следующей задаче task, хранящейся в списке (это однонаправленный связный список).

В файле task.c нам понадобится несколько глобальных переменных и у нас также есть небольшая функция initialise_tasking, с помощью которой создается только одну пустую задачу task.

//
// task.c – Реализуются функции, необходимые для мультизадачности.
// Написано для  руководств по разработке ядра - автор James Molloy
//

#include "task.h"
#include "paging.h"

// Текущая работающая задача task.
volatile task_t *current_task;

// Начало задачи task из связного списка.
volatile task_t *ready_queue;

// Некоторые внешние ссылки, необходимые для доступа к объектам в файле paging.c...
extern page_directory_t *kernel_directory;
extern page_directory_t *current_directory;
extern void alloc_frame(page_t*,int,int);
extern u32int initial_esp;
extern u32int read_eip();

// Идентификатор ID  следующего процесса.
u32int next_pid = 1;

void initialise_tasking()
{ 
   asm volatile("cli");

   // Перемещаем стек так, чтобы знать, где он находится.
   move_stack((void*)0xE0000000, 0x2000);

   // Инициализируем первую задачу task (задачу ядра)
   current_task = ready_queue = (task_t*)kmalloc(sizeof(task_t));
   current_task->id = next_pid++;
   current_task->esp = current_task->ebp = 0;
   current_task->eip = 0;
   current_task->page_directory = current_directory;
   current_task->next = 0;

   //Снова включаем прерывания.
   asm volatile("sti");
}

Все правильно. У нас еще есть только две функции, которые выполняют запись — fork() и switch_task(). Функция fork() является функцией UNIX, с помощью которой создается новый процесс. Она клонирует адресное пространство и запускает новый процесс, работающий в том же месте, что и исходный процесс.

int fork()
{
   // Мы модифицируем структуры ядра и необходимо отключить прерывания.
   asm volatile("cli");

   // Берем указатель на структуру task этого процесса для последующего к ней обращения.
   task_t *parent_task = (task_t*)current_task;

   // Клонируем адресное пространство.
   page_directory_t *directory = clone_directory(current_directory); 

Итак, мы, во-первых, запрещаем прерывания, потому что мы меняем структуры данных ядра и в случае, если работа будет прервана посередине, из-за этого могут возникнуть проблемы. Затем мы клонируем текущий директорий страниц.

   // Создаем новый процесс.
   task_t *new_task = (task_t*)kmalloc(sizeof(task_t));
   new_task->id = next_pid++;
   new_task->esp = new_task->ebp = 0;
   new_task->eip = 0;
   new_task->page_directory = directory;
   new_task->next = 0;

   // Добавляем его в конец очереди задач, готовых для запуска.
   // Находим конец очереди задач, готовых для запуска...
   task_t *tmp_task = (task_t*)ready_queue;
   while (tmp_task->next)
       tmp_task = tmp_task->next;
   // ...И добавяем в нее новый процесс.
   tmp_task->next = new_task; 

Здесь мы создаем новый процесс точно также, как и в initialise_tasking. Мы добавляем его в конец очереди готовых задач (очереди задач, которые готовы к запуску). Если вам не понятен этот код я предлагаю вам посмотреть руководство по работе с односвязными списками.

Мы должны сообщить задаче, где она должна начинать выполнение. Для этого нам нужно прочитать указатель текущей инструкции. Для этого нам нужна быстро работающая функция read_eip() - она находится в файле process.s:

[GLOBAL read_eip]
read_eip:
  pop eax
  jmp eax 

Это достаточно хитрый способ чтения указателя текущей инструкции. Когда вызывается функция read_eip, адрес текущего местоположения инструкции помещается в стек. Обычно для того, чтобы вернуться из функции, мы используем команду "ret". Эта команда извлекает значение адреса из стека и переходит по этому адресу. Однако здесь мы сами выталкиваем значение адреса из стека в регистр EAX (вспомните, что согласно договоренности о вызове __cdecl регистр EAX является регистром 'значения возврата'), а затем переходим по этому адресу.

// Это будет точка входа для нового процесса.
u32int eip = read_eip(); 

Важно заметить, что поскольку мы (далее) заносим начальный адрес новой задачи в "eip", мы можем после вызова read_eip оказаться в одном из двух состояний.

  1. Мы только вызвали read_eip и находимся в родительской задаче.
  2. Мы вызываем дочернюю задачу, и она только что начала выполняться.

Чтобы различать эти два случая, мы проверяем условие "current_task == parent_task" (является ли текущая задача родительской задачей). В switch_task () мы добавляем код, который обновляет переменную "current_task" с тем, чтобы она всегда указывала на задачу, работающую в текущий момент. Так что, если мы находимся в дочерней задаче, значение current_task не будет совпадать со значением parent_task, иначе они совпадают.

   // Мы можем находиться в родительской или дочерней задаче - проверяем.
   if (current_task == parent_task)
   {
       // Мы находимся в родительской задаче, поэтому мы настраиваем esp/ebp/eip для нашей дочерней задачи.
       u32int esp; asm volatile("mov %%esp, %0" : "=r"(esp));
       u32int ebp; asm volatile("mov %%ebp, %0" : "=r"(ebp));
       new_task->esp = esp;
       new_task->ebp = ebp;
       new_task->eip = eip;
       // Все завершили: Заново включаем прерывания.
       asm volatile("sti"); 
       return new_task->id;
   }
   else
   {
       // Мы находимся в дочерней задаче — согласно договоренности возвращаем 0.
       return 0;
   }
} 

Давайте просто пробежимся по этому коду. Если мы находимся в родительской задаче, мы читаем значения указателя текущего стека и указателя базы и запоминаем их а структуре task_struct новой задачи. Мы также в этой структуре запоминаем указатель инструкции, который мы нашли ранее, а затем снова включаем прерывания (поскольку мы все завершили). Функция fork(), согласно договоренности, в случае, если мы находимся в родительской задаче, возвращает значение PID дочерней задачи, или возвращает ноль в случае, если мы находимся в дочерней задаче.

9.4.1. Копирование физического фрейма

Во-первых, для того, чтобы вызвать нашу функцию планировщика, нам нужно получить от таймера обратный вызов (callback).

В файле timer.c

static void timer_callback(registers_t regs)
{
   tick++;
   switch_task();
} 

Теперь нам нужно просто написать это! ;)

void switch_task()
{
   // Если у нас еще нет инициализированных задач, то просто выходим.
   if (!current_task)
       return; 

Поскольку эта функция вызывается каждый раз, когда поступает вызов от таймера, вполне вероятно, что она может быть вызвана раньше, чем будет вызвана функция initialise_tasking. Поэтому мы здесь это проверяем - если текущая задача NULL, то у нас еще не созданы какие-либо задачи, поэтому просто выходим из функции.

Затем давайте всего лишь быстро сохраним указатели стека и базы — они нам потребуются.

// Теперь читаем esp, ebp для того, чтобы их потом сохранить.
u32int esp, ebp, eip;
asm volatile("mov %%esp, %0" : "=r"(esp));
asm volatile("mov %%ebp, %0" : "=r"(ebp)); 

Теперь пришло время сложной логики. Убедитесь, что вы разобрались с этим куском кода. Он очень важен. Мы снова с помощью нашей функции read_eip читаем указатель инструкций. Для того, чтобы при следующем выходе из функции планировщика, мы попали снова в то же самое место, мы помещаем значение указателя инструкций в поле "eip" текущей задачи. Но точно также, как и в функции fork(), мы после вызова можем оказаться в одном из следующих двух состояний:

  1. Мы только что вызвали функцию read_eip и она вернула нам указатель на текущую инструкцию.
  2. Мы только что выполнили переключение задач, и выполнение продолжилось сразу после функции read_eip.

Как нам различить эти два состояния? Мы можем запутаться. Когда мы действительно выполняем переключение задач (в какой-то момент), мы можем поместить в регистр EAX фиктивное значение (я использовал 0x12345). Т. к. в языке C регистр EAX используется для возврата значения из функции, во втором случае значением, возвращаемым из read_eip, окажется значение 0x12345! Так что мы можем использовать его для того, чтобы различать состояния.

   // Читаем указатель инструкций. Здесь мы используем сложную логику:
   // Когда происходит выход из этой функции, то возможен один из следующих двух случаев -
   // (a) Мы вызвали функцию и она вернула значение EIP.
   // (b) Мы только что переключили задачи и, поскольку сохраненным значением EIP, 
   // в сущности,является инструкция, идущая за read_eip(), то будет все выглядеть так, 
   // как если бы только что произошел выход из функции read_eip.
   // Во втором случае нам нужно немедленно выйти из функции. Чтобы обнаружить эту ситуацию,  
  // нам нужно поместить в EAX фиктивное значение, которое будет проверяться в конце работы
   // нашей функции. Поскольку в языке C регистр EAX используется для возврата значений, будет
   // выглядеть так, как будто бы возвращаемым значением будет это фиктивное значение! (0x12345).
   eip = read_eip();

   // Только что выполнено переключение задач?
   if (eip == 0x12345)
       return; 

Тогда мы записываем новые ESP, EBP и EIP в структуру task текущей задачи.

   // Нет, переключение задач не выполнено. Давайте сохраним значения некоторых регистров и выполним переключение.
   current_task->eip = eip;
   current_task->esp = esp;
   current_task->ebp = ebp; 

Затем мы выполняем переключение задач! Двигаемся по списку current_task, в котором перечислены текущие задачи. Если мы доходим до конца (если current_task заканчивается нулевым значением), мы просто начинаем просматривать список заново.

   // Берем следующую задачу для запуска.
   current_task = current_task->next;
   // Если мы доходим до конца связного списка, то начинаем все сначала.
   if (!current_task) current_task = ready_queue; 
   esp = current_task->esp;
   ebp = current_task->ebp; 

Последние три строки нужны только для более лучшего понимания кода.

На самом деле все в этой функции описано в комментариях. Мы изменяем все регистры, которые нам необходимы, а затем переходим туда, где расположена новая инструкция.

   // Здесь мы:
   // * Останавливаем прерывания, чтобы ничего нам не мешало.
   // * Временно помещаем значение нового положения EIP в регистр ECX.
   // * Загружаем указатели стека и базы из структуры task  новой задачи.
   // * Заменяем указатель директория страниц на физический адрес (physicalAddr) нового директория.
   // * Помещаем в регистр EAX фиктивное значение (0x12345) с тем, чтобы мы могли его сразу опознать в  
   // случае, кода мы выполним переключение задач.
   // * Снова запускаем прерывания. В инструкции STI будет задержка — она не срабатывает до тех пор, 
   // пока не произойдет переход к новой инструкции.
   // * Переходим на позицию, указываемую в ECX (вспомните, что мы сюда поместили новое значение EIP).
   asm volatile("         \ 
     cli;                 \ 
     mov %0, %%ecx;       \ 
     mov %1, %%esp;       \ 
     mov %2, %%ebp;       \ 
     mov %3, %%cr3;       \ 
     mov $0x12345, %%eax; \ 
     sti;                 \ 
     jmp *%%ecx           "
                : : "r"(eip), "r"(esp), "r"(ebp), "r"(current_directory->physicalAddr));
} 

Разобрались! Мы закончили! Давайте проверим это!

9.5. Проверяем

Давайте изменим нашу функцию main():

int main(struct multiboot *mboot_ptr, u32int initial_stack)
{
   initial_esp = initial_stack;
   // Инициализируем все ISR и сегментацию
   init_descriptor_tables();
   // Инициализируем экран (очищаем его)
   monitor_clear();
   // Инициализируем PIT значением 100Hz
   asm volatile("sti");
   init_timer(50);

   // Находим место размещения нашего диска initial ramdisk.
   ASSERT(mboot_ptr->mods_count > 0);
   u32int initrd_location = *((u32int*)mboot_ptr->mods_addr);
   u32int initrd_end = *(u32int*)(mboot_ptr->mods_addr+4);
   // Пожалуйста, не затрите наш модуля при доступе к адресам размещения!
   placement_address = initrd_end;

   // Запуск страничной организации памяти.
   initialise_paging();

   // Запускаем многозадачность.
   initialise_tasking();

   // Инициализируем initial ramdisk и указываем его как корневую файловую систему.
   fs_root = initialise_initrd(initrd_location);

   // Создаем новый процесс в новом адресном пространстве, который является клоном текущего процесса.
   int ret = fork();

   monitor_write("fork() returned ");
   monitor_write_hex(ret);
   monitor_write(", and getpid() returned ");
   monitor_write_hex(getpid());
   monitor_write("\n============================================================================\n");

   // Следующий раздел кода не является реентрантным, поскольку мы не должны прерывать его исполнение.
   asm volatile("cli");
   // Список содержимого директория /
   int i = 0;
   struct dirent *node = 0;
   while ( (node = readdir_fs(fs_root, i)) != 0)
   {
       monitor_write("Found file ");
       monitor_write(node->name);
       fs_node_t *fsnode = finddir_fs(fs_root, node->name);

       if ((fsnode->flags&0x7) == FS_DIRECTORY)
       {
           monitor_write("\n\t(directory)\n");
       }
       else
       {
           monitor_write("\n\t contents: \"");
           char buf[256];
           u32int sz = read_fs(fsnode, 0, 256, buf);
           int j;
           for (j = 0; j < sz; j++)
               monitor_put(buf[j]);

           monitor_write("\"\n");
       }
       i++;
   }
   monitor_write("\n");

   asm volatile("sti");

   return 0;
} 

9.6. Итог

Отлично! Многозадачность, на самом деле, одно из последних препятствий на пути создания "правильного" ядра. В любой современной ОС важно создать у пользователя впечатление, что он работает с несколькими задачами одновременно.

Полный исходный код доступен здесь.


Назад К началу Вперед