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

UnixForum





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

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

10. Пользовательский режим

Оригинал: "10. User mode (and syscalls)"
Автор: James Molloy
Дата публикации: 2008
Перевод: Н.Ромоданов
Дата перевода: январь 2012 г.

Система защиты архитектуры x86.

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

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

10.1. Переключение в пользовательский режим

Архитектура X86 необычна тем, что в ней нет прямого способа переключения в пользовательский режим. Единственный способ, с помощью которого можно перейти в пользовательский режим, состоит в выходе из исключительного состояния, которое возникло в пользовательском режиме. Единственный способ создания такого исключения состоит сначала в создании в стеке такого состояния, как если бы в пользовательском режиме произошло исключение, а затем в выходе из него с помощью инструкции возврата из исключения (IRET).

При выполнении инструкции IRET предполагается, что в стеке будут находиться следующее содержимое (начиная с указателя стека — с самого младшего адреса и выше):

Стек до выполнения инструкции IRET.

  • Инструкция, с которой продолжается исполнение - значение EIP.
  • Переключатель сегмента кода, на который нужно перейти.
  • Значение, которое будет загружено в регистр EFLAGS.
  • Указатель стека для загрузки.
  • Переключатель сегмента стека, на который нужно перейти.

Со значениями регистров EIP, EFLAGS и ESP работать просто, но немного сложнее использовать значения CS и SS.

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

Размер всех переключателей по 8 байтов, так что индексы переключателей следующие:

  • 0x00: Дескриптор null
  • 0x08: Сегмент кода для режима ядра
  • 0x10: Сегмент данных для режима ядра
  • 0x18: Сегмент кода для пользовательского режима
  • 0x20: Сегмент данных для пользовательского режима

Мы в настоящее время используем переключатели 0x08 и 0x10; для пользовательского режима нам потребуется использовать переключатели 0x18 и 0x20. Тем не менее, все не так просто. Поскольку размеры всех переключателей по 8 байтов, два младших бита переключателя всегда будут равны нулю. Intel использует эти два бита для указания RPL - Requested Privilege Level (запрашиваемый уровень привилегий). Эти биты в текущий момент равны нулю, поскольку мы работали в кольце 0, но теперь, когда мы хотим перейти в кольцо три, нам нужно в них задать значение '3'. Если вы хотите узнать больше о RPL и сегментации в целом, вам следует прочитать руководство фирмы Intel. По моему, информации слишком много, чтобы ее всю здесь объяснять.

Итак, это значит, что нашим переключателем сегмента кода будет (0x18 | 0x3 = 0x1b), а нашим переключателем сегмента данных будет (0x20 | 0x3 = 0x23).

10.1.1. Файл task.c

Эта функция должна находиться в нашем файле task.c. Мы вызываем ее из файла main.c.

void switch_to_user_mode()
{
   // Настраиваем структуру стека для переключения в пользовательский режим.
   asm volatile("  \ 
     cli; \ 
     mov $0x23, %ax; \ 
     mov %ax, %ds; \ 
     mov %ax, %es; \ 
     mov %ax, %fs; \ 
     mov %ax, %gs; \ 
                   \ 
     mov %esp, %eax; \ 
     pushl $0x23; \ 
     pushl %eax; \ 
     pushf; \ 
     pushl $0x1B; \ 
     push $1f; \ 
     iret; \ 
   1: \ 
     ");
} 

В этом коде сначала отключаются прерывания, т.к. мы находимся в критической секции кода. Затем в переключателях сегментов ds, es, fs и gs записываются значения переключателя данных нашего пользовательского режима - 0x23.

Наша цель состоит в выходе из функции switch_to_user_mode() в пользовательском режиме, поэтому для того, чтобы это сделать, нам не нужно менять указатель стека. В следующей строке в регистре EAX сохраняется указатель стека с тем, чтобы в дальнейшем им можно было воспользоваться. Мы помещаем в стек значение переключателя нашего сегмента стека (0x23), затем помещаем значение, на которое, по нашему мнению, должен указывать указатель стека после выхода из команды IRET. Это то значение ESP, которое имел регистр ESP перед тем, как мы стали что-либо менять в стеке (запомнили в EAX).

Инструкция pushf помещает в стек текущее значение EFLAGS — затем мы помещаем в стек значение переключателя CS (0x1b).

Следующая инструкция несколько особенная и может оказаться непонятной тем, кто не привык к синтаксису AS. Мы помещаем в стек значение $1f. Обозначение $1f означает "адрес метки '1:', следующей по ходу кода". Дополнительную информацию смотрите в руководстве GNU AS,; числовое значение может быть любым - "1:", "2:" и т. д. - какое вам понравится.

После этого мы выполняем нашу команду IRET, и, надеюсь, мы теперь должны в пользовательском режиме выполнить код в строке "1:" с использованием того же самого стека.

10.1.2. Чтобы ничего не упустить

Вы могли заметить, что мы отключили прерывание прежде, чем начали переключение режимов. Теперь возникает следующая проблема - как мы снова включим прерывания? Можно обнаружить, что при выполнении команды sti в пользовательском режиме возникает общая ошибка защиты, так что если мы включим прерывания раньше, чем мы выполним команду IRET, прерывание может возникнуть не в тот момент времени.

Если вы знаете, как работают инструкции sti и cli, то решение приходит само собой, с помощью этих инструкций в EFLAGS просто устанавливается флаг 'IF'. Из википедии можно узнать, что флаг IF имеет маску 0x200, поэтому все, что вы должны сделать, это сразу после инструкции 'pushf' вставить следующие строки в ассемблерный код, который был приведен выше:

pop %eax ; Помещаем EFLAGS обратно в EAX. Единственный способ — читаем EFLAGS с помощью pushf, а затем выталкиваем из стека.
or %eax, $0x200 ; Устанавливаем флаг IF.
push %eax ; Помещаем новое значение EFLAGS обратно в стек. 

При использовании этого решения, прерывания снова будут активированы автоматически сразу, как только будет выполнена инструкция IRET, причем совершенно безопасно.

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

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

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

Единственное, что важно отметить, что ядру при исполнении кода обработки прерываний требуется для работы отдельный стек. Если стека нет, то процессор выдаст две ошибки (а затем, в сущности, и третью, поскольку для обработки двух ошибок также потребуется соответствующий стек!). Очевидно, это может позволить злоумышленнику достаточно просто вывести из строя вашу систему, поэтому на практике считается нормой при изменении режима с кольца 3 на кольцо 0 переключаться на новый стек, предназначенный исключительно для использования в ядре и который гарантированно будет отдельным стеком.

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

10.2.1. Сегмент состояния задачи

В архитектуре X86 есть поддержка аппаратной реализации переключения задач, осуществляемая с использованием списка сегментов состояния задач (Task State Segments - TSS). В настоящем руководстве мы решили (точно также как и в BSD, в Linux и в большинстве операционных систем с архитектурой x86) не использовать эту возможность, а вместо нее использовать программное решение. Основная причина этого состоит в том, что аппаратное переключение задач на самом деле не намного быстрее, чем программное, а программное переключение задач позволяет обеспечить лучшую переносимость между различными платформами.

С учетом сказанного, особенности реализации архитектуры x86 таковы, что у нас нет выбора, кроме как использовать хотя бы один сегмент TSS. Это связано с тем, что когда программа в пользовательском режиме (кольцо 3) выполняет системный вызов (программное прерывание), процессор автоматически ищет текущий сегмент TSS и устанавливает сегмент стека (SS) и указатель стека (ESP) равным тому, что он найдет в полях SS0 и ESP0 ('0', поскольку это переход в кольцо 0) – по существу происходит переключение из пользовательского стека в стек вашего ядра.

Обычной практикой при реализации программного переключения задач является просто использование одного сегмента TSS и обновление его поля ESP0 всякий раз, когда происходит переключение задач - эти минимальные действия необходимы для того, чтобы системные вызовы работали правильно.

10.2.1.1. Файл descriptor_tables.h

Нам нужно в заголовочный файл descriptor_tables добавить структуру записи TSS:

// Структура, описывающая сегмент состояния задачи Task State Segment.
struct tss_entry_struct
{
   u32int prev_tss;   // Предыдущий TSS – если используется аппаратное переключение задач, то это поле нужно создания связного списка.
   u32int esp0;       // Указатель стека, загружаемый при переходе в режим ядра.
   u32int ss0;        // Сегмент стека, загружаемый при переходе в режим ядра.
   u32int esp1;       // Не используется ...
   u32int ss1;
   u32int esp2;
   u32int ss2;
   u32int cr3;
   u32int eip;
   u32int eflags;
   u32int eax;
   u32int ecx;
   u32int edx;
   u32int ebx;
   u32int esp;
   u32int ebp;
   u32int esi;
   u32int edi;
   u32int es;         // Значение, загружаемое в ES при переходе в режим ядра.
   u32int cs;         // Значение, загружаемое в  CS при переходе в режим ядра
   u32int ss;         // Значение, загружаемое в  SS при переходе в режим ядра
   u32int ds;         // Значение, загружаемое в  DS при переходе в режим ядра
   u32int fs;         // Значение, загружаемое в  FS при переходе в режим ядра
   u32int gs;         // Значение, загружаемое в  GS при переходе в режим ядра
   u32int ldt;        // Не используется ...
   u16int trap;
   u16int iomap_base;
} __attribute__((packed));

typedef struct tss_entry_struct tss_entry_t; 

10.2.1.2. Файл descriptor_tables.c

Нам также нужен код для инициализации TSS. TSS, на самом деле, хранится в таблице GDT в виде указателя GDT, поэтому нам в таблице GDT также потребуется еще одна запись.

// Давайте из кода на языке C получим доступ к нашим ассемблерным функциям.
...
extern void tss_flush();

// Внутренние прототипы функций.
...
static void write_tss(s32int,u16int,u32int);
...

tss_entry_t tss_entry;

static void init_gdt()
{
   gdt_ptr.limit = (sizeof(gdt_entry_t) * 6) - 1;
   gdt_ptr.base  = (u32int)&gdt_entries;

   gdt_set_gate(0, 0, 0, 0, 0);                // Сегмент null
   gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF); // Сегмент кода
   gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF); // Сегмент данных
   gdt_set_gate(3, 0, 0xFFFFFFFF, 0xFA, 0xCF); // Сегмент кода пользовательского режима
   gdt_set_gate(4, 0, 0xFFFFFFFF, 0xF2, 0xCF); // Сегмент данных пользовательского режима
   write_tss(5, 0x10, 0x0);

   gdt_flush((u32int)&gdt_ptr);
   tss_flush();
}

// Инициализируем нашу структуру сегмента состояния задачи.
static void write_tss(s32int num, u16int ss0, u32int esp0)
{
   // Сначала давайте вычислим базу и предельное значение для нашей записи в таблице GDT.
   u32int base = (u32int) &tss_entry;
   u32int limit = base + sizeof(tss_entry);

   // Теперь добавим в таблицу GDT  адрес дескриптора нашего TSS.
   gdt_set_gate(num, base, limit, 0xE9, 0x00);

   // Обеспечим, чтобы первоначально дескриптор был равен нулю.
   memset(&tss_entry, 0, sizeof(tss_entry));

   tss_entry.ss0  = ss0;  // Запоминаем сегмент стека ядра.
   tss_entry.esp0 = esp0; // Запоминаем указатель стека ядра.

   // Здесь заносим в таблицу TSS записи cs, ss, ds, es, fs и gs. В них указывается, какие сегменты
   // должны быть загружены в случае,  когда процессор переключается в режим ядра. Поэтому
   // они являются нашими обычными сегментами кода/данных ядра - 0x08 и 0x10 соответственно,
   // но в последних двух битах будут указаны значения 0x0b и 0x13. Значения этих битов указывают,
   // что уровень запрашиваемых привилегий RPL (requested privilege level) равен 3; это означает, что 
   // этот сегмент TSS  можно использовать для переключения в режим ядра из кольца 3.
   tss_entry.cs   = 0x0b;
   tss_entry.ss = tss_entry.ds = tss_entry.es = tss_entry.fs = tss_entry.gs = 0x13;
} 

Далее нужно определить функцию tss_flush. Нам также в случае, когда мы переключаем задачи, потребуется функция обновления записи TSS поскольку в этой записи хранится адрес правильного стека ядра;

void set_kernel_stack(u32int stack)
{
   tss_entry.esp0 = stack;
} 

10.2.1.3. Файл gdt.s

Здесь мы определяем нашу функцию tss_flush. В ней мы сообщаем процессору, где в таблице GDT найти нашу запись TSS.

[GLOBAL tss_flush]    ; Allows our C code to call tss_flush().
tss_flush:
   mov ax, 0x2B      ; Load the index of our TSS structure - The index is
                     ; 0x28, as it is the 5th selector and each is 8 bytes
                     ; long, but we set the bottom two bits (making 0x2B)
                     ; so that it has an RPL of 3, not zero.
   ltr ax            ; Load 0x2B into the task state register.
   ret 

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

10.2.2. Интерфейс системных вызовов

Мы собираемся создать интерфейс системных вызовов syscall, похожий на тот, что есть в Linux, в котором используется вектор прерываний 0x80. Обработчики прерываний, определенные нами, в настоящее время не обрабатывают эти адреса, поэтому мы должны добавить в файл interrupt.s инструкцию "ISR_NOERRCODE 128", а в файл descriptor_tables.c дополнительный шлюз idt_set_gate (и, конечно, дополнительный прототип функции в файл descriptor_tables.h).

10.2.2.1. Файл syscall.h

Сначала нам нужно просто получить интерфейс для запуска интерфейса системных вызовов ...

// syscall.h – Определяет интерфейс и структуры, относящиеся к диспетчеризации системных вызовов.
// Написано для  руководств по разработке ядра - автор James Molloy

#ifndef SYSCALL_H
#define SYSCALL_H

#include "common.h"

void initialise_syscalls();

#endif 

10.2.2.2. Файл syscall.c

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

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

// syscall.c – Определяет реализацию механизма системных вызовов.
// Написано для  руководств по разработке ядра - автор James Molloy

#include "syscall.h"
#include "isr.h"

#include "monitor.h"

static void syscall_handler(registers_t *regs);

static void *syscalls[3] =
{
   &monitor_write,
   &monitor_write_hex,
   &monitor_write_dec,
};
u32int num_syscalls = 3;

void initialise_syscalls()
{
   // Регистрируем наш обработчик системных вызовов.
   register_interrupt_handler (0x80, &syscall_handler);
}

void syscall_handler(registers_t *regs)
{
   // Сначала проверяем, является ли допустимым запрашиваемый номер системного вызова.
   // Номер системного вызова находится в EAX.
   if (regs->eax >= num_syscalls)
       return;

   // Вычисляем место, где находится запрашиваемый системный вызов.
   void *location = syscalls[regs->eax];

   // Нам неизвестно, сколько параметров необходимо функции, поэтому мы  просто
   // помещаем их в стек в правильном порядке. Функция может использовать все эти 
   // параметры, если они потребуются, а затем мы можем убрать их из стека.
   int ret;
   asm volatile (" \ 
     push %1; \ 
     push %2; \ 
     push %3; \ 
     push %4; \ 
     push %5; \ 
     call *%6; \ 
     pop %%ebx; \ 
     pop %%ebx; \ 
     pop %%ebx; \ 
     pop %%ebx; \ 
     pop %%ebx; \ 
   " : "=a" (ret) : "r" (regs->edi), "r" (regs->esi), "r" (regs->edx), "r" (regs->ecx), "r" (regs->ebx), "r" (location));
   regs->eax = ret;
}

Итак, здесь у нас есть таблица адресов наших функций системных вызовов. Функция initialise_syscalls всего лишь добавляет функцию обработчика syscall_handler в качестве обработчика для прерывания 0x80.

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

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

10.2.3. Вспомогательные макросы

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

mov eax, call>
mov ebx, 
mov ecx, 
mov edx, 
mov esi, 
mov edi, 
int 0x80 ; execute syscall
         ; значение, возвращаемое из системного вызова, находится в EAX. 

Однако, он выглядит несколько громоздким. Мы можем упростить его, создав несколько вспомогательных макросов, в которых определены функции-заглушки с ассемблерными строками, с помощью которых в действительности выполняется системный вызов;

В файле syscall.h

#define DECL_SYSCALL0(fn) int syscall_##fn();
#define DECL_SYSCALL1(fn,p1) int syscall_##fn(p1);
#define DECL_SYSCALL2(fn,p1,p2) int syscall_##fn(p1,p2);
#define DECL_SYSCALL3(fn,p1,p2,p3) int syscall_##fn(p1,p2,p3);
#define DECL_SYSCALL4(fn,p1,p2,p3,p4) int syscall_##fn(p1,p2,p3,p4);
#define DECL_SYSCALL5(fn,p1,p2,p3,p4,p5) int syscall_##fn(p1,p2,p3,p4,p5);

#define DEFN_SYSCALL0(fn, num) \ 
int syscall_##fn() \ 
{ \ 
 int a; \ 
 asm volatile("int $0x80" : "=a" (a) : "0" (num)); \ 
 return a; \ 
}

#define DEFN_SYSCALL1(fn, num, P1) \ 
int syscall_##fn(P1 p1) \ 
{ \ 
 int a; \ 
 asm volatile("int $0x80" : "=a" (a) : "0" (num), "b" ((int)p1)); \ 
 return a; \ 
}

#define DEFN_SYSCALL2(fn, num, P1, P2) \ 
int syscall_##fn(P1 p1, P2 p2) \ 
{ \ 
 int a; \ 
 asm volatile("int $0x80" : "=a" (a) : "0" (num), "b" ((int)p1), "c" ((int)p2)); \ 
 return a; \ 
}

... 

Итак, у нас есть макрос "DECL_SYSCALLX", в котором объявляется функция-заглушка для функции fn с параметрами X, причем типы параметров определяются как p1..pn.

В макросе "DEFN_SYSCALLX" фактически определяется функция-заглушка, которая является просто фрагментом вставленного кода на ассемблере. Параметр num используется как индекс таблицы функций системных вызовов.

Таким образом, чтобы определить наши функции monitor_ *, нам потребуется объявить их в файле syscall.h:

DECL_SYSCALL1(monitor_write, const char*)
DECL_SYSCALL1(monitor_write_hex, const char*)
DECL_SYSCALL1(monitor_write_dec, const char*) 

а затем в файле syscall.c определяем:

DEFN_SYSCALL1(monitor_write, 0, const char*);
DEFN_SYSCALL1(monitor_write_hex, 1, const char*);
DEFN_SYSCALL1(monitor_write_dec, 2, const char*); 

10.3. Проверяем

Привет, мир пользователей!

В файле main.c

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

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

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

initialise_syscalls();

switch_to_user_mode();

syscall_monitor_write("Hello, user world!\n");

return 0; 

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

Полный исходный код и образ файла имеются здесь.

10.3.1. Возможные проблемы

Если вы при переходе в пользовательский режим будете получать ошибки неверного обращения к страницам памяти (page faults), то проверьте, чтобы в вашем коде/ в данных ядра было разрешено получать доступ к пользовательскому режиму. Когда вы действительно загрузите программы пользователей, у вас будет другая ситуация, однако на данный момент мы просто возвращаемся обратно в ядро и выполняем код внутри функции main(), поэтому у этого кода также должна быть возможность получать доступ в пользовательский режим!


Назад К началу