Хабрахабр

Эволюция системных вызовов архитектуры x86

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

System call

Каждый раз, когда мы хотим что-то отобразить на мониторе, записать в устройство, считать с файла, нам приходится обращаться к ядру ОС. Именно ядро ОС отвечает за любое общение с железом, именно там происходит работа с прерываниями, режимами процессора, переключениями задач… Чтобы пользователь программой не смог завалить работу всей операционной системы, было решено разделить пространство памяти на пространство пользователя (область памяти, предназначенная для выполнения пользовательских программ) и пространство ядра, а также запретить пользователю доступ к памяти ядра ОС. Реализовано это разделение в x86-семействе аппаратно при помощи сегментной защиты памяти. Но пользовательской программе нужно каким-то образом общаться с ядром, для этого и была придумана концепция системных вызовов.

Системный вызов — способ обращения программы пользовательского пространства к пространству ядра. Со стороны это может выглядеть как вызов обычной функции со своим собственным calling convention, но на самом деле процессором выполняется чуть больше действий, чем при вызове функции инструкцией call. Например, в архитектуре x86 во время системного вызова как минимум происходит увеличение уровня привилегий, замена пользовательских сегментов на сегменты ядра и установка регистра IP на обработчик системного вызова.

Программист обычно не работает с системными вызовами напрямую, так как системные вызовы обернуты в функции и скрыты в различных библиотеках, например libc.so в Linux или же ntdll.dll в Windows, с которыми и взаимодействует прикладной разработчик.

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

Способы реализации системных вызовов

Выполнение неверной инструкции.

Ранее, ещё на 80386 это был самый быстрый способ сделать системный вызов. Для этого обычно применялась бессмысленная и неверная инструкция LOCK NOP, после исполнения которой процессором вызывался обработчик неверной инструкции. Это было больше 20 лет назад и, говорят, этим приёмом обрабатывались системные вызовы в корпорации Microsoft. Обработчик неверной инструкции в наши дни используется по назначению.

Call gates

Для того, чтобы иметь доступ к сегментам кода с различным уровнем привилегий, в Intel был разработан специальный набор дескрипторов, называемый gate descriptors. Существует 4 вида таких дескрипторов:

  • Call gates
  • Trap gates (для исключений, вроде int 3, требующих выполнения участка кода)
  • Interrupt gates (аналогичен trap gates, но с некоторыми отличиями)
  • Task gates (полагалось, что будут использоваться для переключения задач)

Нам интересны только call gates, так как именно через них планировалось реализовывать системные вызовы в x86.

Call gate реализован при помощи инструкции call far или jmp far и принимает в качестве параметра call gate-дескриптор, который настраивается ядром ОС. Является достаточно гибким механизмом, так как возможен переход и на любой уровень защитного кольца, и на 16-битный код. Считается, что call gates производительней прерываний. Этот способ использовался в OS/2 и Windows 95. Из-за неудобства использования в Linux механизм так и не был реализован. Со временем совсем перестал использоваться, так как появились более производительные и простые в обращении реализации системных вызовов (sysenter/sysexit).

Системные вызовы, реализованные в Linux

В архитектуре x86-64 ОС Linux существует несколько различных способов системных вызовов:

  • int 80h
  • sysenter/sysexit
  • syscall/sysret
  • vsyscall
  • vDSO

В реализации каждого системного вызова есть свои особенности, но в общем, обработчик в Linux имеет примерно одинаковую структуру:

  • Включается защита от чтения/записи/исполнения кода пользовательского пространства.
  • Заменяется пользовательский стек на стек ядра, сохраняются callee-saved регистры.
  • Выполняется обработка системного вызова
  • Восстановление стека, регистров
  • Отключение защиты
  • Выход из системного вызова

Рассмотрим немного подробнее каждый системный вызов.

int 80h

Изначально, в архитектуре x86, Linux использовал программное прерывание 128 для совершения системного вызова. Для указания номера системного вызова, пользователь задаёт в eax номер системного вызова, а его параметры располагает по порядку в регистрах ebx, ecx, edx, esi, edi, ebp. Далее вызывается инструкция int 80h, которая программно вызывает прерывание. Процессором вызывается обработчик прерывания, установленный ядром Linux ещё во время инициализации ядра. В x86-64 вызов прерывания используется только во время эмуляции режима x32 для обратной совместимости.

В принципе, никто не запрещает пользоваться инструкцией в расширенном режиме. Но вы должны понимать, что используется 32-битная таблица вызовов и все используемые адреса должны помещаться в 32-битное адресное пространство. Согласно SYSTEM V ABI [4] §3.5.1, для программ, виртуальный адрес которых известен на этапе линковки и помещается в 2гб, по умолчанию используется stackoverflow.

В ядре обработчиком этого прерывания является функция entry_INT80_compat и находится в arch/x86/entry/entry_64_compat.S

Пример вызова int 80h

section .text
global _start _start: mov edx,len mov ecx,msg mov ebx,1 ; file descriptor (stdout) mov eax,4 ; system call number (sys_write) int 0x80 ; call kernel mov eax,1 ; system call number (sys_exit) int 0x80 ; call kernel section .data msg db 'Hello, world!',0xa
len equ $ - msg

Компиляция:

nasm -f elf main.s -o main32.o
ld -melf_i386 main32.o -o a32.out

Или в расширенном режиме (программа работает так как компилируется статически)

nasm -f elf64 main.s -o main.o
ld main.o -o a.out

sysenter/sysexit

Спустя некоторое время, ещё когда не было x86-64, в Intel поняли, что можно ускорить системные вызовы, если создать специальную инструкцию системного вызова, тем самым минуя некоторые издержки прерывания. Так появилась пара инструкций sysenter/sysexit. Ускорение достигается за счёт того, что на аппаратном уровне при выполнении инструкции sysenter опускается множество проверок на валидность дескрипторов, а так же проверок, зависящих от уровня привилегий [3] §6.1. Также инструкция опирается на то, что вызывающая её программа использует плоскую модель памяти. В архитектуре Intel, инструкция валидна как для режима совместимости, так и для расширенного режима, но у AMD данная инструкция в расширенном режиме приводит к исключению неизвестного опкода [3]. Поэтому в настоящее время пара sysenter/sysexit используется только в режиме совместимости.

В ядре обработчиком этой инструкции является функция entry_SYSENTER_compat и находится в arch/x86/entry/entry_64_compat.S

Пример вызова sysenter

section .text
global _start _start: mov edx,len ;message length mov ecx,msg ;message to write mov ebx,1 ;file descriptor (stdout) mov eax,4 ;system call number (sys_write) push continue_l push ecx push edx push ebp mov ebp,esp sysenter hlt ; dumb instructions that is going to be skipped
continue_l: mov eax,1 ;system call number (sys_exit) mov ebx,0 push ecx push edx push ebp mov ebp,esp sysenter section .data msg db 'Hello, world!',0xa
len equ $ - msg

Компилирование:

nasm -f elf main.s -o main.o
ld main.o -melf_i386 -o a.out

Несмотря на то, что в реализации архитектуры от Intel инструкция валидна, в расширенном режиме скорее всего такой системный вызов никак не получится использовать. Это из-за того, что в регистре ebp сохраняется текущее значение стека, а адрес верхушки независимо от модели памяти находится вне 32-битного адресного пространства. Это всё потому, что Linux отображает стек на конец нижней половины каноничного адреса пространства.

Разработчики ядра Linux предостерегают пользователей от жесткого программирования sysenter из-за того, что ABI системного вызова может измениться. Из-за того, что Android не последовал этому совету, Linux пришлось откатить свой патч для сохранения обратной совместимости. Правильно реализовывать системный вызов нужно используя vDSO, речь о которой будет идти далее.

syscall/sysret

Так как именно AMD разработали x86-64 архитектуру, которая и называется AMD64, то они решили создать свой собственный системный вызов. Инструкция разрабатывалась AMD, как аналог sysenter/sysexit для архитектуры x86-64. В AMD позаботились о том, чтобы инструкция была реализована как в расширенном режиме, так и в режиме совместимости, но в Intel решили не поддерживать данную инструкцию в режиме совместимости. Несмотря на всё это, Linux имеет 2 обработчика для каждого из режимов: для x32 и x64. Обработчиками этой инструкции является функции entry_SYSCALL_64 для x64 и entry_SYSCALL_compat для x32 и находится в arch/x86/entry/entry_64.S и arch/x86/entry/entry_64_compat.S соответственно.

Кому интересно более подробно ознакомиться с инструкциями системных вызовов, в мануале Intel [0] (§4.3) приведён их псевдокод.

Пример вызова syscall

section .text
global _start _start: mov rdx,len ;message length mov rsi,msg ;message to write mov rdi,1 ;file descriptor (stdout) mov rax,1 ;system call number (sys_write) syscall mov rax,60 ;system call number (sys_exit) syscall section .data msg db 'Hello, world!',0xa
len equ $ - msg

Компилирование

nasm -f elf64 main.s -o main.o
ld main.o -o a.out

Пример вызова 32-битного syscall

Для тестирования следующего примера потребуется ядро с конфигурацией CONFIG_IA32_EMULATION=y и компьютер AMD. Если же у вас компьютер фирмы Intel, то можно запустить пример на виртуалке. Linux может без предупреждения изменить ABI и этого системного вызова, поэтому в очередной раз напомню: системные вызовы в режиме совместимости правильнее исполнять через vDSO.

section .text
global _start _start: mov edx,len ;message length mov ebp,msg ;message to write mov ebx,1 ;file descriptor (stdout) mov eax,4 ;system call number (sys_write) push continue_l push ecx push edx push ebp syscall hlt
continue_l: mov eax,1 ;system call number (sys_exit) mov ebx,0 push ecx push edx push ebp syscall section .data
msg db 'Hello, world!',0xa
len equ $ - msg

Компиляция:

nasm -f elf main.s -o main.o
ld main.o -melf_i386 -o a.out

Непонятна причина, по которой AMD решили разработать свою инструкцию вместо того, чтобы расширить инструкцию Intel sysenter на архитектуру x86-64.

vsyscall

При переходе из пространства пользователя в пространство ядра происходит переключение контекста, что является не самой дешёвой операцией. Поэтому, для улучшения производительности системных вызовов, было решено их обрабатывать в пространстве пользователя. Для этого было зарезервировано 8 мб памяти для отображения пространства ядра в пространство пользователя. В эту память для архитектуры x86 поместили 3 реализации часто используемых read-only вызова: gettimeofday, time, getcpu.

Со временем стало понятно, что vsyscall имеет существенные недостатки. Фиксированное размещение в адресном пространстве является уязвимым местом с точки зрения безопасности, а отсутствие гибкости в размере выделяемой памяти может негативно сказаться на расширении отображаемой области ядра.

Для того, чтобы пример работал, необходимо, чтобы в ядре была включена поддержка vsyscall: CONFIG_X86_VSYSCALL_EMULATION=y

Пример вызова vsyscall

#include <sys/time.h>
#include <stdio.h> #define VSYSCALL_ADDR 0xffffffffff600000UL int main()
{ // Offsets in x86-64 // 0: gettimeofday // 1024: time // 2048: getcpu int (*f)(struct timeval *, struct timezone *); struct timeval tm; unsigned long addrOffset = 0; f = (void*)VSYSCALL_ADDR + addrOffset; f(&tm, NULL); printf("%d:%d\n", tm.tv_sec, tm.tv_usec);
}

Компиляция:

gcc main.c

Linux не отображает vsyscall в режиме совместимости.

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

Эмуляция может быть реализована двумя способами.

Первый способ — при помощи замены адреса функции на системный вызов syscall. В таком случае виртуальный системный вызов функции gettimeofday на x86-64 выглядит следующим образом:

movq $0x60, %rax
syscall
ret

Где 0x60 — код системного вызова функции gettimeofday.

Второй же способ немного интереснее. При вызове функции vsyscall генерируется исключение Page fault, которое обрабатывается Linux. ОС видит, что ошибка произошла из-за исполнения инструкции по адресу vsyscall и передаёт управление обработчику виртуальных системных вызовов emulate_vsyscall (arch/x86/entry/vsyscall/vsyscall_64.c).

Реализацией vsyscall можно управлять при помощи параметра ядра vsyscall. Можно как отключить виртуальный системный вызов при помощи параметра vsyscall=none, задать реализацию как при помощи инструкции syscall syscall=native, так и через Page fault vsyscall=emulate.

vDSO (Virtual Dynamic Shared Object)

Чтобы исправить основной недостаток vsyscall, было предложено реализовать системные вызовы в виде отображения динамически подключаемой библиотеки, к которой применяется технология ASLR. В "длинном" режиме библиотека называется linux-vdso.so.1, а в режиме совместимости — linux-gate.so.1. Библиотека автоматически подгружается для каждого процесса, даже статически скомпилированного. Увидеть зависимости приложения от неё можно при помощи утилиты ldd в случае динамической компоновки библиотеки libc.

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

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

Пример вызова vDSO

#include <sys/time.h>
#include <dlfcn.h>
#include <stdio.h>
#include <assert.h> #if defined __x86_64__
#define VDSO_NAME "linux-vdso.so.1"
#else
#define VDSO_NAME "linux-gate.so.1"
#endif int main()
{ int (*f)(struct timeval *, struct timezone *); struct timeval tm = {0}; void *vdso = dlopen(VDSO_NAME, RTLD_LAZY | RTLD_LOCAL | RTLD_NOLOAD); assert(vdso && "vdso not found"); f = dlsym(vdso, "__vdso_gettimeofday"); assert(f); f(&tm, NULL); printf("%d:%d\n", tm.tv_sec, tm.tv_usec);
}

Компиляция:

gcc -ldl main.c

Для режима совместимости:

gcc -ldl -m32 main.c -o a32.elf

Правильнее всего искать функции vDSO при помощи извлечения адреса библиотеки из вспомогательного вектора AT_SYSINFO_EHDR и последующего парсинга разделяемого объекта. Пример парсинга vDSO из вспомогательного вектора можно найти в исходном коде ядра: tools/testing/selftests/vDSO/parse_vdso.c

Или если интересно, то можно покопаться и посмотреть, как парсится vDSO в glibc:

  1. Парсинг вспомогательных векторов: elf/dl-sysdep.c
  2. Парсинг разделяемой библиотеки: elf/setup-vdso.h
  3. Установка значений функций: sysdeps/unix/sysv/linux/x86_64/init-first.c, sysdeps/unix/sysv/linux/x86/gettimeofday.c, sysdeps/unix/sysv/linux/x86/time.c

Согласно System V ABI AMD64 [4] вызовы должны происходить при помощи инструкции syscall. На практике же к этой инструкции добавляются вызовы через vDSO. Поддержка системных вызовов в виде int 80h и vsyscall остались для обратной совместимости.

Сравнение производительности системных вызовов

С тестированием скорости системных вызовов всё неоднозначно. В архитектуре x86 на выполнение одной инструкции влияет множество факторов таких как наличие инструкции в кэше, загруженность конвейера, даже существует таблица задержек для данной архитектуры [2]. Поэтому достаточно сложно определить скорость выполнения участка кода. У Intel есть даже специальный гайд по замеру времени для участка кода [1]. Но проблема в том, что мы не можем замерить время согласно документу из-за того, что нам нужно вызывать объекты ядра из пользовательского пространства.

Поэтому было решено замерить время при помощи clock_gettime и тестировать производительность вызова gettimeofday, так как он есть во всех реализациях системных вызовов. На разных процессорах время может отличаться, но в целом, относительные результаты должны быть схожи.

Программа запускалась несколько раз и в итоге бралось минимальное время исполнения.
Тестирование int 80h, sysenter и vDSO-32 производилось в режиме совместимости.

Программа тестирования

#include <sys/time.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <syscall.h>
#include <dlfcn.h>
#include <limits.h> #define min(a,b) ((a) < (b)) ? (a) : (b)
#define GIGA 1000000000
#define difftime(start, end) (end.tv_sec - start.tv_sec) * GIGA + end.tv_nsec - start.tv_nsec static struct timeval g_timespec;
#if defined __x86_64__
static inline int test_syscall() { register long int result asm ("rax"); asm volatile ( "lea %[p0], %%rdi \n\t" "mov $0, %%rsi \n\t" "mov %[sysnum], %%rax \n\t" "syscall \n\t" : "=r"(result) : [sysnum] "i" (SYS_gettimeofday), [p0] "m" (g_timespec) : "rcx", "rsi"); return result;
}
#endif static inline int test_int80h() { register int result asm ("eax"); asm volatile ( "lea %[p0], %%ebx \n\t" "mov $0, %%ecx \n\t" "mov %[sysnum], %%eax \n\t" "int $0x80 \n\t" : "=r"(result) : [sysnum] "i" (SYS_gettimeofday), [p0] "m" (g_timespec) : "ebx", "ecx"); return result;
} int (*g_f)(struct timeval *, struct timezone *); static void prepare_vdso() { void *vdso = dlopen("linux-vdso.so.1", RTLD_LAZY | RTLD_LOCAL | RTLD_NOLOAD); if (!vdso) { vdso = dlopen("linux-gate.so.1", RTLD_LAZY | RTLD_LOCAL | RTLD_NOLOAD); } assert(vdso && "vdso not found"); g_f = dlsym(vdso, "__vdso_gettimeofday");
} static int test_g_f() { return g_f(&g_timespec, 0);
} #define VSYSCALL_ADDR 0xffffffffff600000UL
static void prepare_vsyscall() { g_f = (void*)VSYSCALL_ADDR;
} static inline int test_sysenter() { register int result asm ("eax"); asm volatile ( "lea %[p0], %%ebx \n\t" "mov $0, %%ecx \n\t" "mov %[sysnum], %%eax \n\t" "push $cont_label%=\n\t" "push %%ecx \n\t" "push %%edx \n\t" "push %%ebp \n\t" "mov %%esp, %%ebp \n\t" "sysenter \n\t" "cont_label%=: \n\t" : "=r"(result) : [sysnum] "i" (SYS_gettimeofday), [p0] "m" (g_timespec) : "ebx", "esp"); return result;
} #ifdef TEST_SYSCALL
#define TEST_PREPARE()
#define TEST_PROC_CALL() test_syscall()
#elif defined TEST_VDSO
#define TEST_PREPARE() prepare_vdso()
#define TEST_PROC_CALL() test_g_f()
#elif defined TEST_VSYSCALL
#define TEST_PREPARE() prepare_vsyscall()
#define TEST_PROC_CALL() test_g_f()
#elif defined TEST_INT80H
#define TEST_PREPARE()
#define TEST_PROC_CALL() test_int80h()
#elif defined TEST_SYSENTER
#define TEST_PREPARE()
#define TEST_PROC_CALL() test_sysenter()
#else
#error Choose test
#endif static inline unsigned long test() { unsigned long result = ULONG_MAX; struct timespec start = {0}, end = {0}; int rt, rt2, rt3; for (int i = 0; i < 1000; ++i) { rt = clock_gettime(CLOCK_MONOTONIC, &start); rt3 = TEST_PROC_CALL(); rt2 = clock_gettime(CLOCK_MONOTONIC, &end); assert(rt == 0); assert(rt2 == 0); assert(rt3 == 0); result = min(difftime(start, end), result); } return result;
} int main() { TEST_PREPARE(); // prepare calls int a = TEST_PROC_CALL(); assert(a == 0); a = TEST_PROC_CALL(); assert(a == 0); a = TEST_PROC_CALL(); assert(a == 0); unsigned long result = test(); printf("%lu\n", result);
}

Компиляция:

gcc -O2 -DTEST_SYSCALL time_test.c -o test_syscall
gcc -O2 -DTEST_VDSO -ldl time_test.c -o test_vdso
gcc -O2 -DTEST_VSYSCALL time_test.c -o test_vsyscall
#m32
gcc -O2 -DTEST_VDSO -ldl -m32 time_test.c -o test_vdso_32
gcc -O2 -DTEST_INT80H -m32 time_test.c -o test_int80
gcc -O2 -DTEST_SYSENTER -m32 time_test.c -o test_sysenter

О системе
cat /proc/cpuinfo | grep "model name" -m 1 — Intel® Core(TM) i7-5500U CPU @ 2.40GHz
uname -r — 4.14.13-1-ARCH

Таблица Результатов

Реализация время (нс)
int 80h 498
sysenter 338
syscall 278
vsyscall emulate 692
vsyscall native 278
vDSO 37
vDSO-32 51

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

Все текущие сравнения производительности были произведены с патчем KPTI, исправляющим уязвимость meltdown.

Бонус: Производительность системных вызовов без KPTI

Патч KPTI был разработан специально для исправления уязвимости meltdown. Как известно, данный патч замедляет производительность ОС. Проверим производительность с выключенным KPTI (pti=off).

Таблица результатов с выключенным патчем

Реализация Время (нс) Увеличение времени исполнения после патча (нс) Ухудшение производительности после патча (t1 - t0) / t0 * 100%
int 80h 317 181 57%
sysenter 150 188 125%
syscall 103 175 170%
vsyscall emulate 496 196 40%
vsyscall native 103 175 170%
vDSO 37 0 0%
vDSO-32 51 0 0%

Переход в режим ядра и обратно в среднем после патча стал занимать примерно на 180 нс. больше времени, видимо это и есть цена сброса TLB-кэша.

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

Для дальнейшего чтения

Реализация виртуальных системных вызовов в ядре Linux (очень хорошая книга, советую): https://0xax.gitbooks.io/linux-insides/content/SysCall/syscall-3.html
История развития Linux: https://www.win.tue.nl/~aeb/linux/lk/lk-4.html
Анатомия системных вызовов, часть 1: https://lwn.net/Articles/604287/
Анатомия системных вызовов, часть 2: https://lwn.net/Articles/604515/

Ссылки

[0] Intel 64 and IA-32 Architectures Developer's Manual: Vol. 2B
[1] How to benchmark code execution times ...
[2] Instruction latencies and throughput for AMD and Intel x86 processors
[3] AMD64 Architecture Programmer’s Manual Volume 2: System Programming
[4] System V ABI AMD64

Теги
Показать больше

Похожие статьи

Кнопка «Наверх»
Закрыть