Как стать автором
Обновить
2323.82
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Операционная система в 1 000 строк кода (часть 4)

Время на прочтение14 мин
Количество просмотров860
Автор оригинала: Seiya Nuta

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

Навигация по вышедшим частям



Приложение


В этой главе мы подготовим первый исполняемый файл приложения и выполним его с помощью нашего ядра.

▍ Структура памяти


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

Создайте новый скрипт компоновщика (user.ld), который будет определять место размещения приложения в памяти:
user.ld
user.ld

ENTRY(start)

SECTIONS {
    . = 0x1000000;

    /* машинный код */
    .text :{
        KEEP(*(.text.start));
        *(.text .text.*);
    }

    /* данные только для чтения */
    .rodata : ALIGN(4) {
        *(.rodata .rodata.*);
    }

    /* данные с изначальными значениями */
    .data : ALIGN(4) {
        *(.data .data.*);
    }

    /* данные, которые при запуске приложения должны заполняться нулями */
    .bss : ALIGN(4) {
        *(.bss .bss.* .sbss .sbss.*);

        . = ALIGN(16);
        . += 64 * 1024; /* 64KB */
        __stack_top = .;

       ASSERT(. < 0x1800000, "too large executable");
    }
}


Очень похоже на скрипт компоновщика ядра, не так ли? Ключевым отличием здесь выступает базовый адрес (0x1000000), обеспечивающий, чтобы приложение не пересекалось с пространством адресов ядра.

ASSERT — это инструкция утверждения, которая отменяет компоновку, если условие в первом аргументе не выполняется. Здесь она обеспечивает, чтобы конец секции .bss, завершающей выделенную под приложение память, не выходил за 0x1800000. Это делается, чтобы исполняемый файл случайно не превысил допустимый размер.

▍ Библиотека пространства пользователя


Далее мы создадим библиотеку для пользовательских программ. Чтобы не усложнять, начнём с минимальной функциональности, необходимой для запуска приложения:
user.c
user.c

#include "user.h"

extern char __stack_top[];

__attribute__((noreturn)) void exit(void) {
    for (;;);
}

void putchar(char c) {
    /* Доделать*/
}

__attribute__((section(".text.start")))
__attribute__((naked))
void start(void) {
    __asm__ __volatile__(
        "mv sp, %[stack_top] \n"
        "call main           \n"
        "call exit           \n"
        :: [stack_top] "r" (__stack_top)
    );
}


Выполнение приложения начинается с функции start. По аналогии с процессом загрузки ядра она устанавливает указатель стека и вызывает функцию main приложения.

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

Кроме того, мы определяем функцию putchar, на которую ссылается функция printf в common.c. Реализуем мы её позже.

В отличие от процесса инициализации ядра, здесь мы не очищаем секцию .bss нулями, так как это уже будет сделано ядром (посредством alloc_pages).

Подсказка

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

Наконец, подготовим для библиотеки заголовочный файл (user.h):

user.h

#pragma once
#include "common.h"

__attribute__((noreturn)) void exit(void);
void putchar(char ch);

▍ Первое приложение


Что ж, пора перейти к созданию первого приложения. К сожалению, у нас до сих пор нет способа для вывода символов, и мы не можем начать с программы “Hello World!”. Вместо этого мы создадим простой бесконечный цикл:

shell.c

#include "user.h"

void main(void) {
    for (;;);
}

▍ Сборка


Приложения будут собираться отдельно от ядра, и далее мы создадим новый скрипт (run.sh) для сборки текущего:

run.sh

OBJCOPY=/opt/homebrew/opt/llvm/bin/llvm-objcopy

# Сборка оболочки (приложения)
$CC $CFLAGS -Wl,-Tuser.ld -Wl,-Map=shell.map -o shell.elf shell.c user.c common.c
$OBJCOPY --set-section-flags .bss=alloc,contents -O binary shell.elf shell.bin
$OBJCOPY -Ibinary -Oelf32-littleriscv shell.bin shell.bin.o

# Сборка ядра
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \
    kernel.c common.c shell.bin.o

Первый вызов $CC очень похож на скрипт сборки ядра. Скомпилируйте файлы С и линкуйте их с помощью user.ld.

Первая подкоманда $OBJCOPY преобразует исполняемый файл (ELF) в двоичный. Его двоичная форма — это фактическое содержимое, которое будет записано в память из базового адреса (в данном случае 0x1000000). Для подготовки приложения в памяти операционной системе достаточно скопировать содержимое этого двоичного файла. Обычно в ОС используются форматы вроде ELF, в которых содержимое памяти и информация для отображения разделены, но в этом руководстве для упрощения мы используем простой двоичный файл.

Вторая подкоманда $OBJCOPY преобразует двоичный исполняемый образ в формат, который можно встроить в код C. Посмотрим, что там внутри, используя команду llvm-nm:

$ llvm-nm shell.bin.o
00000000 D _binary_shell_bin_start
00010260 D _binary_shell_bin_end
00010260 A _binary_shell_bin_size

Префикс _binary_ сопровождается именем файла, после чего идут start, end и size. Эти символы указывают начало, конец и размер исполняемого образа. На практике они используются так:

extern char _binary_shell_bin_start[];
extern char _binary_shell_bin_size[];

void main(void) {
    uint8_t *shell_bin = (uint8_t *) _binary_shell_bin_start;
    printf("shell_bin size = %d\n", (int) _binary_shell_bin_size);
    printf("shell_bin[0] = %x (%d bytes)\n", shell_bin[0]);
}

Эта программа выводит размер файла shell.bin и первый байт его содержимого. Иными словами, можете рассматривать переменную _binary_shell_bin_start так, будто в ней хранится содержимое этого файла:

char _binary_shell_bin_start[] = "<shell.bin contents here>";

Переменная _binary_shell_bin_size содержит размер файла. Однако используется она несколько необычным образом. Давайте ещё раз заглянем внутрь с помощью llvm-nm:

$ llvm-nm shell.bin.o | grep _binary_shell_bin_size
00010454 A _binary_shell_bin_size

$ ls -al shell.bin   ← примечание: не путайте с shell.bin.o!
-rwxr-xr-x 1 seiya staff 66644 Oct 24 13:35 shell.bin

$ python3 -c 'print(0x10454)'
66644

Первый столбец в выводе llvm-nm — это адрес символа. Шестнадцатеричное значение 10454 соответствует размеру файла, но это не совпадение. Как правило, значения каждого адреса в файле .o определяются компоновщиком. Но _binary_shell_bin_size в этом плане особенная.

A во втором столбце сообщает, что адрес _binary_shell_bin_size является таким символом (абсолютным), который компоновщик менять не должен. Это значит, в данной переменной в качестве адреса вшит размер файла.

Если определить эту переменную как массив произвольного типа, например, char _binary_shell_bin_size[], то _binary_shell_bin_size будет рассматриваться как указатель, хранящий его адрес. Однако, поскольку здесь мы используем в качестве адреса размер файла, его приведение даст нам этот размер файла. Это типичный приём (можно сказать, костыль) с использованием формата объектного файла.

Наконец, мы добавили shell.bin.o к аргументам clang при компиляции ядра. Таким образом мы встроим исполняемый файл в образ ядра.

▍ Дизассемблинг исполняемого файла


Дизассемблинг исполняемого файла показывает, что секция .text.start находится в его начале. Функцию start нужно поместить по адресу 0x1000000:
$ llvm-objdump -d shell.elf
$ llvm-objdump -d shell.elf

shell.elf:	file format elf32-littleriscv

Disassembly of section .text:

01000000 <start>:
 1000000: 37 05 01 01  	lui	a0, 4112
 1000004: 13 05 05 26  	addi	a0, a0, 608
 1000008: 2a 81        	mv	sp, a0
 100000a: 19 20        	jal	0x1000010 <main>
 100000c: 29 20        	jal	0x1000016 <exit>
 100000e: 00 00        	unimp

01000010 <main>:
 1000010: 01 a0        	j	0x1000010 <main>
 1000012: 00 00        	unimp

01000016 <exit>:
 1000016: 01 a0        	j	0x1000016 <exit>


Режим пользователя


Далее мы запустим созданное выше приложение.

▍ Извлечение исполняемого файла


В форматах исполняемых файлов вроде ELF адрес загрузки сохраняется в заголовке файла (в ELF это заголовок программы). Но поскольку исполняемый образ нашего приложения является двоичным файлом, нужно подготовить его, используя фиксированное значение:

kernel.h

// Базовый виртуальный адрес образа приложения. Должен соответствовать стартовому адресу, определённому в `user.ld`.
#define USER_BASE 0x1000000

Далее определим символы для использования этого встроенного двоичного кода в shell.bin.o:

kernel.c

extern char _binary_shell_bin_start[], _binary_shell_bin_size[];

Также обновите функцию create_process для запуска приложения:
kernel.c
kernel.c

void user_entry(void) {
    PANIC("not yet implemented");
}

struct process *create_process(const void *image, size_t image_size) {
    /* код опущен */
    *--sp = 0;                      // s3
    *--sp = 0;                      // s2
    *--sp = 0;                      // s1
    *--sp = 0;                      // s0
    *--sp = (uint32_t) user_entry;  // ra (изменился!)

    uint32_t *page_table = (uint32_t *) alloc_pages(1);

    // Отображение страниц памяти ядра.
    for (paddr_t paddr = (paddr_t) __kernel_base;
         paddr < (paddr_t) __free_ram_end; paddr += PAGE_SIZE)
        map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X);

    // Отображение страниц памяти пространства пользователя.
    for (uint32_t off = 0; off < image_size; off += PAGE_SIZE) {
        paddr_t page = alloc_pages(1);

        // Обработка случая, в котором копируемые данные меньше размера страницы.
        size_t remaining = image_size - off;
        size_t copy_size = PAGE_SIZE <= remaining ? PAGE_SIZE : remaining;

        // Заполнение и отображение страницы.
        memcpy((void *) page, image + off, copy_size);
        map_page(page_table, USER_BASE + off, page,
                 PAGE_U | PAGE_R | PAGE_W | PAGE_X);
    }


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

Предупреждение

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

Наконец, измените код, вызывающий функцию create_process, и сделайте так, чтобы она создавала процесс пользователя:
kernel.c
kernel.c

void kernel_main(void) {
    memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);

    printf("\n\n");

    WRITE_CSR(stvec, (uint32_t) kernel_entry);

    idle_proc = create_process(NULL, 0); // изменено!
    idle_proc->pid = -1; // бездействует
    current_proc = idle_proc;

    // добавлено!
    create_process(_binary_shell_bin_start, (size_t) _binary_shell_bin_size);

    yield();
    PANIC("switched to idle process");
}


Теперь запустим программу и с помощью монитора QEMU проверим, правильно ли отображается исполняемый образ:

(qemu) info mem
vaddr    paddr            size     attr
-------- ---------------- -------- -------
01000000 0000000080265000 00001000 rwxu---
01001000 0000000080267000 00010000 rwxu---

Здесь мы видим, что физический адрес 0x80265000 отображается в виртуальный 0x1000000 (USER_BASE). Посмотрим, что хранится по этому физическому адресу. Для вывода содержимого физической памяти используйте команду xp:

(qemu) xp /32b 0x80265000
0000000080265000: 0x37 0x05 0x01 0x01 0x13 0x05 0x05 0x26
0000000080265008: 0x2a 0x81 0x19 0x20 0x29 0x20 0x00 0x00
0000000080265010: 0x01 0xa0 0x00 0x00 0x82 0x80 0x01 0xa0
0000000080265018: 0x09 0xca 0xaa 0x86 0x7d 0x16 0x13 0x87

Похоже, здесь есть какие-то данные. Проверим содержимое shell.bin, чтобы убедиться в их соответствии:

$ hexdump -C shell.bin | head
00000000  37 05 01 01 13 05 05 26  2a 81 19 20 29 20 00 00  |7......&*.. ) ..|
00000010  01 a0 00 00 82 80 01 a0  09 ca aa 86 7d 16 13 87  |............}...|
00000020  16 00 23 80 b6 00 ba 86  75 fa 82 80 01 ce aa 86  |..#.....u.......|
00000030  03 87 05 00 7d 16 85 05  93 87 16 00 23 80 e6 00  |....}.......#...|
00000040  be 86 7d f6 82 80 03 c6  05 00 aa 86 01 ce 85 05  |..}.............|
00000050  2a 87 23 00 c7 00 03 c6  05 00 93 06 17 00 85 05  |*.#.............|
00000060  36 87 65 fa 23 80 06 00  82 80 03 46 05 00 15 c2  |6.e.#......F....|
00000070  05 05 83 c6 05 00 33 37  d0 00 93 77 f6 0f bd 8e  |......37...w....|
00000080  93 b6 16 00 f9 8e 91 c6  03 46 05 00 85 05 05 05  |.........F......|
00000090  6d f2 03 c5 05 00 93 75  f6 0f 33 85 a5 40 82 80  |m......u..3..@..|

Хмм, в шестнадцатеричной форме понять трудно. Давайте дизассемблируем этот машинный код для наглядности:

(qemu) xp /8i 0x80265000
0x80265000:  01010537          lui                     a0,16842752
0x80265004:  26050513          addi                    a0,a0,608
0x80265008:  812a              mv                      sp,a0
0x8026500a:  2019              jal                     ra,6                    # 0x80265010
0x8026500c:  2029              jal                     ra,10                   # 0x80265016
0x8026500e:  0000              illegal
0x80265010:  a001              j                       0                       # 0x80265010
0x80265012:  0000              illegal

Он вычисляет/устанавливает изначальное значение стека, после чего вызывает две разных функции. Если мы сравним это с результатом дизассемблинга shell.elf, то убедимся, что всё действительно совпадает:
$ llvm-objdump -d shell.elf | head -n20
$ llvm-objdump -d shell.elf | head -n20

shell.elf:      file format elf32-littleriscv

Disassembly of section .text:

01000000 <start>:
 1000000: 37 05 01 01   lui     a0, 4112
 1000004: 13 05 05 26   addi    a0, a0, 608
 1000008: 2a 81         mv      sp, a0
 100000a: 19 20         jal     0x1000010 <main>
 100000c: 29 20         jal     0x1000016 <exit>
 100000e: 00 00         unimp

01000010 <main>:
 1000010: 01 a0         j       0x1000010 <main>
 1000012: 00 00         unimp


▍ Переход в режим пользователя


Для выполнения приложений мы используем пользовательский режим работы процессора, который на языке RISC-V называется U-Mode. Переключиться на него легко:

kernel.h

#define SSTATUS_SPIE (1 << 5)

kernel.c

// ↓ __attribute__((naked)) очень важен!
__attribute__((naked)) void user_entry(void) {
    __asm__ __volatile__(
        "csrw sepc, %[sepc]        \n"
        "csrw sstatus, %[sstatus]  \n"
        "sret                      \n"
        :
        : [sepc] "r" (USER_BASE),
          [sstatus] "r" (SSTATUS_SPIE)
    );
}

Переключение из S-Mode в U-Mode производится с помощью инструкции sret. Только прежде, чем менять рабочий режим, она выполняет две записи в CSR:

  • Устанавливает в регистре sepc значение счётчика команд на момент перехода в U-Mode. То есть записывает адрес, куда переходит sret.
  • Устанавливает в регистре sstatus бит SPIE. Его установка активирует аппаратные прерывания при переходе в U-Mode, в результате которых вызывается обработчик, прописанный в регистре stvec.

Подсказка

В этом руководстве мы вместо аппаратных прерываний используем механизм опроса, поэтому устанавливать бит SPIE не обязательно. Тем не менее лучше сделать это явно, чем молча игнорировать прерывания.

▍ Проверяем режим пользователя


Пора проверить, что у нас получилось. Однако, поскольку shell.c просто выполняет бесконечный цикл, по экрану нельзя понять, правильно ли этот скрипт работает. Но у нас есть для этого монитор QEMU:

(qemu) info registers

CPU#0
 V      =   0
 pc       01000010

Похоже, процессор непрерывно выполняет содержимое 0x1000010. Работает программа корректно, но чутьё подсказывает, что чего-то не хватает. Проверим, удастся ли нам пронаблюдать поведение, характерное для U-Mode. Добавьте в shell.c буквально одну строку:

shell.c

#include "user.h"

void main(void) {
    *((volatile int *) 0x80200000) = 0x1234; // new!
    for (;;);
}

Значение 0x80200000 представляет область памяти ядра, отображаемую в таблицу страниц. Но поскольку это страница памяти ядра, для которой бит U в записи таблицы страниц не установлен, должно возникать исключение (отказ страницы) и, следовательно, паника ядра. Проверим:

$ ./run.sh

PANIC: kernel.c:71: unexpected trap scause=0000000f, stval=80200000, sepc=0100001a

Исключение 15 (scause = 0xf = 15). Оно соответствует ошибке «Store/AMO page fault». Что ж, ожидаемое исключение случилось. Кроме того, счётчик команд в sepc указывает на строку, которую мы добавили в shell.c:

$ llvm-addr2line -e shell.elf 0x100001a
/Users/seiya/dev/os-from-scratch/shell.c:4

Поздравляю! Вы успешно запустили своё первое приложение. Разве не удивительно, насколько легко реализуется режим пользователя? Ядро устроено очень похожим на приложение образом — просто у него чуть больше привилегий.

Системные вызовы


В этом разделе мы реализуем механизм системных вызовов, позволяющий приложениям обращаться к функциям ядра. Пришло время «Поприветствовать мир ядра» (имеется в виду «Hello World!») из пространства пользователя.

▍ Библиотека пространства пользователя


Активация системного вызова аналогична уже знакомой нам реализации вызова SBI:

user.c

int syscall(int sysno, int arg0, int arg1, int arg2) {
    register int a0 __asm__("a0") = arg0;
    register int a1 __asm__("a1") = arg1;
    register int a2 __asm__("a2") = arg2;
    register int a3 __asm__("a3") = sysno;

    __asm__ __volatile__("ecall"
                         : "=r"(a0)
                         : "r"(a0), "r"(a1), "r"(a2), "r"(a3)
                         : "memory");

    return a0;
}

Функция syscall устанавливает номер системного вызова в регистре a3 и его аргументы в регистрах от a0 до a2, после чего выполняет инструкцию ecall. ecall — это особая инструкция, используемая для передачи обработки ядру. При её выполнении вызывается обработчик исключений, и управление передаётся ядру. Возвращаемое ядром значение записывается в регистр a0.

В качестве первого системного вызова мы реализуем putchar. Он будет выводить символ, получаемый в виде своего первого аргумента. Для второго и последующих неиспользуемых аргументов мы установим 0:

common.h

#define SYS_PUTCHAR 1

user.c

void putchar(char ch) {
    syscall(SYS_PUTCHAR, ch, 0, 0);
}

▍ Обработка инструкции ecall в ядре


Теперь обновим обработчик исключений, чтобы он также мог обрабатывать инструкцию ecall:

kernel.h

#define SCAUSE_ECALL 8

kernel.c

void handle_trap(struct trap_frame *f) {
    uint32_t scause = READ_CSR(scause);
    uint32_t stval = READ_CSR(stval);
    uint32_t user_pc = READ_CSR(sepc);
    if (scause == SCAUSE_ECALL) {
        handle_syscall(f);
        user_pc += 4;
    } else {
        PANIC("unexpected trap scause=%x, stval=%x, sepc=%x\n", scause, stval, user_pc);
    }

    WRITE_CSR(sepc, user_pc);
}

Определить, был ли совершён вызов ecall, можно путём проверки значения scause. Помимо вызова функции handle_syscall, мы также добавим 4 (размер инструкции ecall) к значению sepc. Дело в том, что sepc указывает на вызвавший исключение счётчик команд, который указывает на инструкцию ecall. Если мы её не изменим, ядро будет возвращаться в одно и то же место, и выполнение инструкции ecall будет раз за разом повторяться.

▍ Обработчик системных вызовов


Ниже описан обработчик системных вызовов, который вызывается из обработчика исключений и получает сохранённую в нём карту «регистров на момент исключения».

kernel.c

void handle_syscall(struct trap_frame *f) {
    switch (f->a3) {
        case SYS_PUTCHAR:
            putchar(f->a0);
            break;
        default:
            PANIC("unexpected syscall a3=%x\n", f->a3);
    }
}

Этот обработчик определяет тип системного вызова, проверяя значение регистра a3. Теперь у нас есть всего один системный вызов, SYS_PUTCHAR, который просто выводит символ, сохранённый в регистре a0.

▍ Проверка системного вызова


Вот мы и реализовали системный вызов. Пора его проверить!

Помните реализацию функции printf из common.c? Она вызывает putchar для вывода символов. А поскольку мы только что реализовали функцию putchar в библиотеке пространства пользователя, можно использовать её так:

shell.c

void main(void) {
    printf("Hello World from shell!\n");
}

И на экране отобразится приветливое сообщение:

$ ./run.sh
Hello World from shell!

Поздравляю! Вы успешно реализовали системный вызов. Предлагаю реализовать ещё!

▍ Получение ввода с клавиатуры (системный вызов getchar)


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

SBI предоставляет интерфейс для считывания «ввода в отладочную консоль». Если ввода нет, возвращается -1:

kernel.c

long getchar(void) {
    struct sbiret ret = sbi_call(0, 0, 0, 0, 0, 0, 0, 2);
    return ret.error;
}

Реализуется системный вызов getchar так:

common.h

#define SYS_GETCHAR 2

user.c

int getchar(void) {
    return syscall(SYS_GETCHAR, 0, 0, 0);
}

user.h

int getchar(void);

kernel.c

void handle_syscall(struct trap_frame *f) {
    switch (f->a3) {
        case SYS_GETCHAR:
            while (1) {
                long ch = getchar();
                if (ch >= 0) {
                    f->a0 = ch;
                    break;
                }

                yield();
            }
            break;
        /* код опущен */
    }
}

Реализация системного вызова getchar продолжает вызывать SBI, пока вводятся символы. Однако, если просто повторять вызов этой функции, другие процессы будут вынуждены ждать. Поэтому мы вызываем системный вызов yield, уступая процессор для других задач.

Примечание

Строго говоря, SBI считывает символы не с клавиатуры, а с последовательного порта, к которому она (или стандартный ввод QEMU) подключена.

▍ Написание оболочки


Теперь напишем оболочку, использующую простую команду hello для вывода приветствия Hello world from shell!:
shell.c
shell.c

void main(void) {
    while (1) {
prompt:
        printf("> ");
        char cmdline[128];
        for (int i = 0;; i++) {
            char ch = getchar();
            putchar(ch);
            if (i == sizeof(cmdline) - 1) {
                printf("command line too long\n");
                goto prompt;
            } else if (ch == '\r') {
                printf("\n");
                cmdline[i] = '\0';
                break;
            } else {
                cmdline[i] = ch;
            }
        }

        if (strcmp(cmdline, "hello") == 0)
            printf("Hello world from shell!\n");
        else
            printf("unknown command: %s\n", cmdline);
    }
}


Здесь мы считываем символы, пока не встретим символ перевода строки, и проверяем, соответствует ли введённая строка имени команды.

Предупреждение

Имейте в виду, что в консоли отладки символом перевода строки является '\r'.

Теперь введём команду hello:

$ ./run.sh

> hello
Hello world from shell!

Ваша ОС всё больше начинает походить на настоящую, и потребовалось для этого не так много времени!

▍ Завершение процесса (системный вызов exit)


Наконец, мы реализуем системный вызов exit, завершающий процесс:

common.h

#define SYS_EXIT    3
user.c
c
__attribute__((noreturn)) void exit(void) {
    syscall(SYS_EXIT, 0, 0, 0);
    for (;;); // На всякий случай
}

kernel.h

#define PROC_EXITED   2

kernel.c

void handle_syscall(struct trap_frame *f) {
    switch (f->a3) {
        case SYS_EXIT:
            printf("process %d exited\n", current_proc->pid);
            current_proc->state = PROC_EXITED;
            yield();
            PANIC("unreachable");
        /* код опущен */
    }
}

Этот системный вызов изменяет состояние процесса на PROC_EXITED и вызывает yield, освобождая процессор для других задач. Планировщик будет выполнять только процессы в состоянии PROC_RUNNABLE, так что к этому процессу он не вернётся. Тем не менее на случай, если он всё же это сделает, мы добавили макрос PANIC.

Подсказка

Чтобы не усложнять, мы отмечаем процесс просто как завершённый (PROC_EXITED). Если же вы будете создавать реальную рабочую ОС, то нужно обязательно освобождать занимаемые процессом ресурсы, такие как таблицы страниц и выделенная память.

Добавьте в оболочку команду exit:

shell.c

        if (strcmp(cmdline, "hello") == 0)
            printf("Hello world from shell!\n");
        else if (strcmp(cmdline, "exit") == 0)
            exit();
        else
            printf("unknown command: %s\n", cmdline);

Готово! Проверим:

$ ./run.sh

> exit
process 2 exited
PANIC: kernel.c:333: switched to idle process

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

На этом очередная часть серии завершается. В следующей, последней части мы реализуем дисковый ввод/вывод и файловую систему, после чего подведём итоги нашего увлекательного проекта и наметим его возможные доработки.

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻
Теги:
Хабы:
+19
Комментарии1

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds