Таблица системных вызовов windows

Windows System Call Tables

The repository contains system call tables collected from all modern and most older releases of Windows, starting with Windows NT.

Both 32-bit and 64-bit builds were analyzed, and the tables were extracted from both the core kernel image (ntoskrnl.exe) and the graphical subsystem (win32k.sys).

Formats

The data is formatted in the CSV and JSON formats for programmatic use, and as an HTML table for manual inspection.

The HTML files are also hosted on my blog under the following links:

  • ntoskrnl.exe, x86: https://j00ru.vexillium.org/syscalls/nt/32/
  • ntoskrnl.exe, x64: https://j00ru.vexillium.org/syscalls/nt/64/
  • win32k.sys, x86: https://j00ru.vexillium.org/syscalls/win32k/32/
  • win32k.sys, x64: https://j00ru.vexillium.org/syscalls/win32k/64/

Operating systems

The following major versions of Windows are included in the tables:

System x86 versions x64 versions
Windows NT 3.x 3.1, 3.5, 3.51
Windows NT 4.0 SP0, SP1, SP2, SP3, SP3 Terminal Server Edition, SP4, SP5, SP6
Windows 2000 SP0, SP1, SP2, SP3, SP4
Windows XP SP0, SP1, SP2, SP3 SP1, SP2
Windows Server 2003 SP0, SP1, SP2, R2, R2 SP2 SP0, SP2, R2, R2 SP2
Windows Vista SP0, SP1, SP2 SP0, SP1, SP2
Windows 7 SP0, SP1 SP0, SP1
Windows 8 8.0, 8.1 8.0, 8.1
Windows 10 1507, 1511, 1607, 1703, 1709, 1803, 1809, 1903, 1909, 2004, 20H2, 21H1, 21H2, 22H2 1507, 1511, 1607, 1703, 1709, 1803, 1809, 1903, 1909, 2004, 20H2, 21H1, 21H2, 22H2
Windows 11 21H2, 22H2, 23H2, 24H2
Windows Server 2022, 23H2, 2025

Some older versions of Windows Server are not included, as their syscall tables are equivalent to these of desktop Windows editions:

Windows Server version Windows Desktop version
2008 SP0/SP2 Vista SP1/SP2
2008 R2 SP0/SP1 7 SP0/SP1
2012 SP0 8.0
2012 R2 8.1
2016 LTSC (1607) 10 1607
1709 10 1709
1803 10 1803
2019 LTSC (1809) 10 1809
1903 10 1903
1909 10 1909
2004 10 2004
20H2 10 20H2

Historical system call counts

Below is a line chart showing the progression of Windows system call development over time. It covers all major desktop versions of Windows starting with Windows NT 3.1 released in July 1993, up to the most recent versions of Windows 11. Server editions are not included for brevity. The analysis was performed on x86 builds for consistency, with the exception of Windows 11 where x64 is the only supported platform. There might be very small differences on x64 builds of the kernel or the less popular editions (e.g. Windows NT 4.0 Terminal Server Edition), but they are insignificant for the purpose of this overview chart.

Historical system call counts

Thanks

We would like to thank the following contributors to the project: Woodmann, Deus, Gynvael Coldwind, MeMek, Alex, Omega Red, Wandering Glitch.

Contact

Mateusz ‘j00ru’ Jurczyk (j00ru.vx@gmail.com)

Введение

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

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

Что такое системный вызов? 

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

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

Такой многоуровневый подход, осуществляемый при помощи системных вызовов:

  • Гарантирует изоляцию аппаратных ресурсов от процессов пользовательского пространства
  • Не допускает прямого доступа к ядру или аппаратной памяти
  • Позволяет коду приложения работать на различных аппаратных архитектурах

Какова задача системного вызова?

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

  • Граница между пользователем и ядром. При запросе действия у ядра системные вызовы выступают в качестве авторизованного шлюза для пользовательских программ. Они гарантируют, что последние не смогут получить доступ к функциям ядра или критически важным системным ресурсам без достаточных оснований.
  • Управление ресурсами. С помощью системных вызовов пользовательские программы могут запрашивать важными ресурсами и управлять ими, например, временем ЦП, памятью и хранилищем файлов. ОС контролирует этот процесс и гарантирует, что он выполняется по всем правилам.
  • Более простая разработка. Системные вызовы позволяют абстрагироваться от сложностей аппаратного обеспечения. Таким образом, разработчики могут выполнять такие операции, как чтение и запись в файл или управление данными сети, и не писать отдельный код для аппаратного обеспечения.
  • Безопасность и контроль доступа. Системные вызовы выполняют проверки для того, чтобы гарантировать, что запросы, отправленные пользовательскими программами, действительны и что программы имеют необходимые права доступа для выполнения запрашиваемых операций. 
  • Межпроцессное взаимодействие (IPC — Inter-Process Communication). Системные вызовы предоставляют механизмы, с помощью которых процессы могут взаимодействовать друг с другом. Они предлагают такие средства, как каналы, очереди сообщений и общую память, для упрощения межпроцессного взаимодействия.
  • Сетевые операции. Системные вызовы предоставляют фреймворк для того, чтобы программы могли взаимодействовать по сети. Разработчики могут полностью сконцентрироваться на построении логики своего приложения вместо того, чтобы уделять внимание низкоуровневому сетевому программированию.

Как работают системные вызовы?

Здесь кратко описано как работают системные вызовы:

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

2. Переключение контекста в пространство ядра. Для инициирования переключения контекста и перехода из пользовательского режима в режим ядра используются программное прерывание и специальные инструкции.

3. Идентификация системного вызова. Для идентификации системного вызова и адреса соответствующей функции ядра система использует индекс.

4. Выполнение функции ядра. Выполняется функция ядра, соответствующая системному вызову, например, чтение данных из файла.

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

6. Переключение контекста в пользовательское пространство. Контекст выполнения переключается обратно из режима ядра в пользовательский режим.

7. Возобновление работы приложения. Приложение возобновляет свою работу с момента своей остановки, но теперь уже с результатами системного вызова.

Примечание: точное количество шагов и принцип работы системных вызовов могут различаться в зависимости от операционной системы.

Каковы функции системных вызовов?

Ниже представлены функции, характеризующие системные вызовы:

  • Безопасность. Системные вызовы гарантируют, что приложения пользовательского пространства никак не смогут навредить системе или помешать другим процессам.
  • Абстракция. Программам, например, не нужно знать особенности конфигурации сетевого оборудования для того, чтобы отправлять данные через Интернет или выполнять операции с диском для чтения файлов, так как эти задачи выполняет операционная система.
  • Контроль доступа. Системные вызовы обеспечивают соблюдение всех необходимых мер безопасности, проверяя, имеет ли программа соответствующие права доступа для обращения к ресурсам. 
  • Согласованность. Взаимодействие между ОС и программой остается согласованным, независимо от базовой конфигурации аппаратного обеспечения. Одна и та же программа может работать на разном оборудовании, главное, чтобы ее поддерживала операционная система.
  • Синхронное выполнение. Многие системные вызовы выполняются синхронно, блокируя вызывающий процесс до завершения операции. Однако существуют и асинхронные системные вызовы, которые позволяют процессам продолжать выполнение, не ожидая завершения операции.
  • Управление процессами. Системные вызовы упрощают управление процессами и выполнение нескольких задач одновременно за счет механизмов создания, завершения, планирования и синхронизации процессов.
  • Управление файлами. Системные вызовы поддерживают операции с файлами, такие как такие как чтение, запись, открытие и закрытие файлов.

File operations via system calls.

  • Управление устройствами. С помощью системных вызовов процессы могут запрашивать доступ к устройствам, выполнять операции чтения/записи на этих устройствах и освобождать их. 
  • Управление ресурсами. Системные вызовы помогают выделять и освобождать ресурсы, такие как память, время ЦП и устройства ввода-вывода.
  • Текущее обслуживание. Системные вызовы можно использовать для получения или настройки системной информации, такой как дата и время или состояние процесса.
  • Взаимодействие. Системные вызовы позволяют процессам взаимодействовать друг с другом и синхронизировать свои действия.
  • Обработка ошибок. Если системный вызов не может быть завершен, он возвращает код ошибки, который может обработать вызывающая программа.

Типы системных вызовов

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

1. Управление процессами

Системные вызовы играют важную роль в управлении системными процессами. С их помощью можно: 

  • Создавать новые процессы и завершать текущие
  • Загружать и выполнять программы в пространстве процесса
  • Планировать процессы и устанавливать параметры выполнения, например, приоритет
  • Дожидаться завершения процесса или подавать сигнал о его завершении

2. Управление файлами

Системные вызовы могут выполнять различные операции с файлами, например,

  • Чтение файлов и запись в файлы
  • Открытие и закрытие файлов
  • Удаление и изменение файловых атрибутов
  • Перемещение или переименование файлов

3. Управление устройствами

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

  • Запроса доступа к устройству и его освобождения после использования
  • Установка атрибутов и параметров устройства
  • Чтение с устройств или запись на устройства
  • Сопоставление имен логических устройств с физическими

4. Информационное обеспечение

Эти системные вызовы позволяют процессам:

  • Получать и изменять различные системные атрибуты
  • Устанавливать системную дату и время
  • Запрашивать показатели производительности системы

5. Взаимодействие 

Эти системные вызовы упрощают:

  • Отправку и получение сообщение между процессами
  • Синхронизацию действий между пользовательскими процессами
  • Организации областей разделяемой памяти для межпроцессного взаимодействия
  • Работу в сети через сокеты

6. Безопасность и управление доступом

Системные вызовы вносят свой вклад в обеспечение безопасности и управление доступом за счет:

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

Примеры системных вызовов

Ниже в таблице перечислены самые распространенные системные вызовы Unix и Windows и их описания.

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

СИСТЕМНЫЙ ВЫЗОВЫ UNIX

ОПИСАНИЕ

СИСТЕМНЫЕ ВЫЗОВЫ WINDOWS

ОПИСАНИЕ

Управление процессами

fork()

Создает новый процесс

CreateProcess()

Создает новый процесс

exit()

Завершает текущий процесс

ExitProcess()

Завершает текущий процесс

wait()

Переводит процесс в режим ожидания до тех пор, пока не завершатся его дочерние процессы

WaitForSingleObject()

Ждет, пока процесс или поток завершит свою работу

exec()

Выполняет новую программу в процессе

CreateProcess() или 
ShellExecute()

Выполняет новую программу в процессе

getpid()

Получает уникальный идентификатор процесса

GetCurrentProcessId()

Получает уникальный идентификатор процесса

Управление файлами

open()

Открывает файл (или устройство)

open()

Открывает или создает файл или устройство

close()

Закрывает открытый файл (или устройство)

close()

Закрывает дескриптор открытого объекта

read()

Выполняет чтение из файла (или устройства)

read()

Выполняет чтение данных из файла или устройство ввода

write()

Выполняет запись в файл (или устройство)

write()

Выполняет запись данных в файл или устройство вывода

lseek()

Изменяет место выполнения чтения/записи в файле

lseek()

Устанавливает положение указателя позиции в файле

unlink()

Удаляет файл

unlink()

Удаляет существующий файл

rename()

Переименовывает файл

rename()

Перемещает или переименовывает файл

Управление каталогами

mkdir()

Создает новый каталог

CreateDirectory()

Создает новый каталог

rmdir()

Удаляет каталог

RemoveDirectory()

Удаляет существующий каталог

chdir()

Изменяет текущий каталог

SetCurrentDirectory()

Изменяет текущий каталог

stat()

Получает статус файла

GetFileAttributesEx()

Получает расширенные атрибуты файла

fstat()

Получает статус открытого файла

GetFileInformationByHandle()

Получает информацию о файле, используя его дескриптор

link()

Создает ссылку на файл

CreateHardLink()

Создает жесткую ссылку на существующий файл

symlink()

Получает статус открытого файла

CreateSymbolicLink()

Создает символическую ссылку

Управление устройствами

brk()или sbrk()

Увеличивает/уменьшает пространство данных программы 

VirtualAlloc()или
VirtualFree()

Резервирует, фиксирует изменения или освобождает область памяти

mmap()

Проецируют файлы или устройства в память

MapViewOfFile()

Проецирует файл в адресное пространство приложения

Информационное обеспечение

time()

Получает текущее время

GetSystemTime()

Получает текущее системное время

alarm()

Получает статус открытого файла

SetWaitableTimer()

Устанавливает таймер

getuid()

Устанавливает будильник для подачи сигнала

GetUserName()или
LookupAccountName()

Получает имя пользователям или его ID

getgid()

Получает идентификатор группы

GetTokenInformation()

Получает информацию о маркере доступа

Взаимодействие

socket()

Создает новый сокет

socket()

Создает новый сокет

bind()

Привязывает сокет к сетевому адресу

bind()

Привязывает сокет к сетевому адресу

listen()

Привязывает сокет к сетевому адресу

listen()

Отслеживает соединения в сокете 

accept()

Принимает новое соединение в сокете

accept()

Принимает новое соединение в сокете

connect()

Инициализирует соединение в сокете

connect()

Инициализирует соединение в сокете

send()или recv()

Отправляет и получает данные через сокет

send()или recv()

Отправляет и получает данные через сокет

Безопасность и управление доступом

chmod()или umask()

Изменяет права доступа/режим файла

SetFileAttributes()или SetSecurityInfo()

Изменяет атрибуты файла или сведения о защите

chown()

Изменяет владельца или группу файла

SetSecurityInfo()

Устанавливает сведения о защите

Как передавать параметры системным вызовам?

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

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

  • Ограниченное количество параметров. Чаще всего системные вызовы принимают ограниченное количество параметров. Это правило нужно для того, чтобы упростить взаимодействие и принудить пользователей использовать структуры данных или блоки памяти.
  • Использование регистров ЦП. Регистры ЦП – это области памяти, к которым можно быстрее всего получить доступ. Количество регистров ЦП ограничено, а это значит, что количество передаваемых вызову параметров также ограничено. Если вы передаете небольшое количество параметров, используйте регистры ЦП.

  • Использование указателей для агрегирования данных. Вместо того, чтобы передавать огромное количество параметров или большие объемы данных, используйте переменные-указатели, чтобы указывать на блоки или структуры памяти (которые содержат все параметры). Ядро будет использовать этот указатель для того, чтобы получить доступ к этому блоку памяти и извлечь параметры.
  • Проверка целостности и безопасности данных. Ядро должно проверять любые указатели, которые передаются из пользовательского пространства. Оно должно проверить, что эти указатели относятся только к тем областям, к которым имеет доступ пользовательская программа. Кроме того, прежде чем использовать данные, которые поступают из пользовательских программ, оно их перепроверяет.
  • Обработка стековых параметров. Некоторые системы помещают параметры в стек, и для того, чтобы выполнить их обработку, ядру приходится их извлекать. Такой метод не так распространен, как использование регистров ЦП и указателей, поскольку он более сложен с точки зрения реализации и управления.
  • Изоляция данных путем их копирования. Ядро часто копирует данные из пространства пользователя в пространство ядра (и наоборот). Оно это делает для того, чтобы защитить систему от ошибочных или вредоносных данных. В связи с этим, данные, которые передаются между этими двумя пространствами, не должны передаваться напрямую. 
  • Возвращаемые значения и обработка ошибок. Системный вызов возвращает значение – как правило, это код успешного завершения или ошибки. В случае получения кода ошибки, всегда пытайтесь найти больше информации об этой ошибке. Сообщения об ошибках чаще всего хранятся в определенных местах, например, в переменной errno (Linux).

Примечание: правила и методы, приведенные выше, могут различаться в зависимости от архитектуры (x86, ARM, MIPS и т.д.) и особенностей операционной системы. Всегда обращайтесь к документации операционной системы или исходному коду, чтобы получить более точную информацию.

Заключение

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

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

Последнее обновление: 09.10.2023

Для взаимодействия с ресурсами системы на Windows, также как и на Linux, теоретически можно использовать системные вызовы или syscalls. Однако в Windows обращение к
системным вызовам имеет свои особенности. Прежде всего, надо установить номер вызываемой системной функции в регистре RAX. И как и в общем случае для выполнения системного вызова применяется инструкция syscall:

movq НОМЕР_СИСТЕМНОГО_ВЫЗОВА, %rax
syscall

Если системный вызов принимает параметры, то их можно передать, как и в общую функцию C/C++: для передачи в функцию первых четырех параметров используются регистры,
но первый параметр передается через регистр R10, а не через RCX, как в случае с функциями C/C++. То есть первые четыре параметра передаются через R10, RDX, R8 и R9 соответственно. Результат вызова возвращается через регистр RAX.

Минусом ОС Windows является то, что она не ориентирована на использование системных вызовов. Так, официальной документации по этому поводу нет, более
того о некоторых системных функциях нет вообще никакого упоминания в документации а сами номера системных вызовах
могут меняться в зависимости от номера билда ОС. Хотя в целом они стабильны для большинства выпусков. Более менее полную таблицу системных вызовов
для Windows можно найти на страницу https://hfiref0x.github.io/NT10_syscalls.html.

Возьмем простейший системный вызов — завершение процесса, который представлен функцией NtTerminateProcess. Если мы обратимся к вышеуказанной таблице,
то увидим, что эта функция имеет номер 44. Мы можем найти в документации определение этой функции:

NTSYSAPI NTSTATUS ZwTerminateProcess(
  [in, optional] HANDLE   ProcessHandle,
  [in]           NTSTATUS ExitStatus
);

Хотя здесь указана функция ZwTerminateProcess, а не NtTerminateProcess, но в целом
Zw-версии функций и Nt-версии аналогичны.

И из определения функции мы видим, что она принимает два параметра:

  • ProcessHandle: дескриптор процесса, который надо закрыть. Это необязательный параметр. Если он равен 0, то закрываем текущий процесс.

  • ExitStatus: статус завершения процесса, в качестве которого выступает числовой код и который обычно возвращается данной функцией.

Применим данную функцию в программе:

.globl main
 
.text
main: 
    movq $0, %r10       # первый параметр - ProcessHandle не указываем
    movq $17, %rdx      # второй параметр - код статуса - произвольное число
    movq $44, %rax      # в rax номер вызываемой системной функции -  44 - NtTerminateProcess
    syscall             # вызываем системную функцию
    ret

Поскольку первый параметр необязательный, и мы хотим завершить текущую программу, то первому параметру через регистр R10 передаем значение 0.
Второму параметру через регистр RDХ передается произвольный числовой код статуса, в нашем случае число 17.

Для вызова системной функции инструкции в регистр %rax передается числовой код функции — 44, и выполняется инструкция syscall.
Результат компиляции и вызова программы (допустим, код программы расположен в файле hello.s и компилируется в файл hello.exe):

c:\asm>as hello.s -o hello.o

c:\asm>ld hello.o -o hello.exe

c:\asm>hello.exe

c:\asm>echo %ERRORLEVEL%
17

c:\asm>

Таким образом, функция NtTerminateProcess возвратила число 17, которое передавалось через второй параметр функции.

NtWriteFile

Рассмотрим другой пример — запись данных в файл, и как частный случай, вывод строки на консоль. За это отвечает системная функция
NtWriteFile, которая для последних версий Windows имеет номер 8 и которая имеет следующий заголовок:

__kernel_entry NTSYSCALLAPI NTSTATUS NtWriteFile(
  [in]           HANDLE           FileHandle,
  [in, optional] HANDLE           Event,
  [in, optional] PIO_APC_ROUTINE  ApcRoutine,
  [in, optional] PVOID            ApcContext,
  [out]          PIO_STATUS_BLOCK IoStatusBlock,
  [in]           PVOID            Buffer,
  [in]           ULONG            Length,
  [in, optional] PLARGE_INTEGER   ByteOffset,
  [in, optional] PULONG           Key
);

Функция принимает аж 9 параметров, из которых отметим наболее важные.

  • 1-й параметр — FileHandle представляет дескриптор файла (в нашем случае дискриптор консольного вывода).

  • 5-й пареметр IoStatusBlock представляет блок байтов, в который записывается статус операции.

  • 6-й параметр — Buffer — указатель на данные для записи (в нашем случае это будет адрес строки для вывода на экран)

  • 7-й параметр — Length хранит размер записываемых данных (размер строки для вывода)

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

.globl main
 
.data
message: .asciz "Hello METANIT.COM\n"
.equ message_len, . - message
.balign 8
IoStatusBlock: .space 16  # буфер для получения статуса
.text
main: 
    subq $88, %rsp
  
    movq $-11, %rcx     # Аргумент для GetStdHandle - STD_OUTPUT
    call GetStdHandle     # вызываем функцию GetStdHandle

    movq %rax, %r10         # первый аргумент
    movq $0, %rdx            # Второй аргумент
    movq $0, %r8             # Третий аргумент
    movq $0, %r9             # Четвертый аргумент
    leaq IoStatusBlock(%rip), %rax  # Пятый аргумент
    movq %rax, 40(%rsp)    
    
    leaq message(%rip), %rax        
    movq %rax, 48(%rsp)    # Шестой аргумент - строка для вывода
    movq $message_len, 56(%rsp)    # # Седьмой аргумент - длина строки
    movq $0, 64(%rsp)            # Восьмой аргумент - для него выделяем память в стеке
    movq $0, 72(%rsp)      # Девятый аргумент - для него выделяем память в стеке
    movq $8, %rax          # вызов системной функции NtWriteFile
    syscall

    movq $0, %r10       # первый параметр - ProcessHandle не указываем
    movq $message_len, %rdx      # второй параметр - код статуса - произвольное число
    movq $44, %rax      # в rax номер вызываемой системной функции -  44 - NtTerminateProcess
    syscall             # вызываем системную функцию
    addq $88, %rsp     # очищаем стек 

    ret

Для упрощения для получения дескриптора консольного вывода используем функцию GetStdHandle. Эта функция возвращает через регистр
RAX дескриптор, который помещаем в регистр R10. Стоит отметить, что поскольку системный вызов NtWriteFile принимает 9 параметров, соответственно все параметры
не поместятся в 4 стандартных регистра, поэтому выделяем достаточное местов в стеке. Причем пятый параметр (он же первый параметр, который помещается в стек) должен начинаться в стеке со смещения
40(%rsp). Большинство параметров необязательные, и данном случае не имеют значения, поэтому передаем им значение 0.:

movq %rax, %r10         # первый аргумент
movq $0, %rdx            # Второй аргумент
movq $0, %r8             # Третий аргумент
movq $0, %r9             # Четвертый аргумент
leaq IoStatusBlock(%rip), %rax  # Пятый аргумент
movq %rax, 40(%rsp)    
    
leaq message(%rip), %rax        
movq %rax, 48(%rsp)    # Шестой аргумент - строка для вывода
movq $message_len, 56(%rsp)    # # Седьмой аргумент - длина строки
movq $0, 64(%rsp)            # Восьмой аргумент - для него выделяем память в стеке
movq $0, 72(%rsp)      # Девятый аргумент - для него выделяем память в стеке

После установки параметров вызываем системную функцию:

movq $8, %rax          # вызов системной функции NtWriteFile
syscall

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

c:\asm>as hello.s -o hello.o

c:\asm>ld hello.o -o hello.exe -lkernel32

c:\asm>hello.exe
Hello METANIT.COM

c:\asm>echo %ERRORLEVEL%
19

c:\asm>

EreTIk’s Box » Cтатьи, исходники » Таблица системных сервисов (SSTD) на x64 системах: поиск и трактовка содержимого

Warning (12.08.2016)

Статья не применима к ядру Windows 10.0.14393. Описанный в конце статьи способ поиска не применим к этой версии ОС.

Сразу скажу, что под x64-системами, здесь и далее будет идти речь о
AMD64-сборках Windows.С 64-х разрядными Itanium-сборками (IA-64) ядра Windows я
так тесно не работал, да и Microsoft уже отказалась от этой платформы. Поэтому
Itanium-платформа в рамках этой статьи рассматриваться не будет.

С приходом x64-ядер Windows разработчики драйверов, в частности разработчики
защит и прочих продуктов, использующих перехваты вызовов системных сервисов,
сразу столкнулись с рядом трудностей. Среди этого длинного списка:
PatchGuard,
Kernel Mode Code Signing (KMCS).
Впрочем, сейчас существует достаточно надежные способы как отключения защиты
от модификации ядра, так и способы загрузки неподписанного кода в ядро Windows.
Хотя количество способов сильно ограничено, но все таки они есть и успешно
применяются.

И тут разработчик сталкивается со следующей особенностью x64-систем: переменная
nt!KeServiceDescriptorTable больше не экспортируется из ядра ОС. Как
известно, nt!KeServiceDescriptorTable является массивом, в котором
хранятся описатели таблицы системных вызовов: указатель на начало таблицы, ее
размер и еще некоторые служебные поля. На 32-х разрядных системах в таблице
системных вызовов хранятся указатели на соответствующие функции-обработчики. И
это утверждение неверно для x64-систем, при загруженном ядре в таблице
системных вызовов хранятся не указатели, а знаковые 4-х байтные смещения. Но и
на этом проблемы не закачиваются, формат хранения данных в таблицах системных
вызовов различается на ОС раньше Vista и системах Vista и старше. Поэтому
начнем именно с формата таблицы системных вызовов.

Формат данных таблицы системных вызовов SSDT на x64-системах

Вначале рассмотрим формат SSDT для систем младше Vista. Ни для кого не
секрет, что ядра Windows XP Professional x64 это не что иное, как ядро Windows
2003 Server x64. В этом легко убедится, если выполнить простую команду в WinDbg:

kd> ? wo(nt!NtBuildNumber)
Evaluate expression: 3790 = 00000000`00000ece

Номер сборки 3790 всегда был закреплен за 2003-им сервером (напомню, что для XP
номер сборки всегда был 2600), о чем нам и говорит WinDbg при соединении с
Windows XP Professional x64:

Windows Server 2003 Kernel Version 3790 (Service Pack 2) MP (2 procs) Free x64
Product: WinNt, suite: TerminalServer SingleUserTS
Built by: 3790.srv03_sp2_qfe.100324-1618

Поэтому сейчас речь пойдет о Windows 2003 Server x64, подразумевая, что на
Windows XP Professional x64 дела обстоят аналогично. При рассмотрении формата
SSDT будем использовать таблицу системных сервисов ядра, которая
расположена в KiServiceTable. Именно на этот символ указывает поле
начала таблицы системных сервисов дескриптора с индексом 0 из массива
nt!KeServiceDescriptorTable. Итак, откроем файл ntoskrnl.exe и взглянем
на содержимое начала таблицы системных сервисов:

0:000> dps ntoskrnl!KiServiceTable
00000000`0045ea80 00000000`007659c0 ntoskrnl!NtMapUserPhysicalPagesScatter
00000000`0045ea88 00000000`00681ef0 ntoskrnl!NtWaitForSingleObject
00000000`0045ea90 00000000`00426d10 ntoskrnl!NtCallbackReturn
00000000`0045ea98 00000000`00665685 ntoskrnl!NtReadFile+0x5

Вроде бы все привычно, но вот только немного смущает символ
ntoskrnl!NtReadFile+0x5. На самом деле все просто: в значениях таблицы
системных сервисов, точнее в младших четырех битах, закодировано количество
параметров, передаваемых через стек. У функции
ZwReadFile(…)
девять параметров, но, учитывая специфику передачи параметров на x64 платформе
(Calling Conventions: x64 Architecture:
первые четыре параметра передаются через регистры), как раз и получается, что
при вызове пять параметров будут переданы в эту функцию через стек.

Теперь посмотрим на тот же символ nt!KiServiceTable, но уже на
загруженной системе:

0: kd> dd nt!KiServiceTable
fffff800`0105ea80 00306f40 00223470 fffc8290 00206c05
fffff800`0105ea90 002240b6 0023b385 00225091 001fe1a0
fffff800`0105eaa0 0022b870 0020ddd0 0022e9e0 0020bfb0
fffff800`0105eab0 0022d330 00230f21 0020a4b1 0023f480

На очень многих форумах по низкоуровневому программированию поднималась
примерно одна и та же тема: почему команда WinDbg
«dps nt!KiServiceTable» в начале таблицы отображает «мусор»? А все
потому, что это не мусор, а, как было написано выше, 4-х байтные смещения. Но
вот только количество параметров, закодированное в исходной таблице системных
вызовов, никуда не делось. Поэтому младшие четыре бита в каждом 4-х байтном
слове таблицы это все то же количество параметров, передаваемых через стек. В
этом легко убедиться, выполнив следующую команду:

0: kd> ln nt!KiServiceTable+(dwo(nt!KiServiceTable+(3*4))&0xFFFFFFF0)
(fffff800`01265680) nt!NtReadFile | (fffff800`01265b90) nt!NtSetInformationFile
Exact matches:
nt!NtReadFile = <no type information>

Аналогично тому, как мы видели в файле ntoskrnl.exe, по индексу 3 лежит
смещение до NtReadFile(…). Преобразованием элементов таблицы из 8-ми
байтовых указателей а 4-х байтовые смещения занимается цикл в функции
nt!KeCompactServiceTable (в первом параметре rcx передан
указатель на таблицу, а во втором параметре edx передано количество
элементов таблицы):

mov     r11d,edx ; количество элементов
mov     r10,rcx ; начало таблицы описателей
mov     rdx,rcx ; начальный указатель массива и ULONG’ов, и PVOID’ов
test    r11d,r11d
je      nt!KeCompactServiceTable+0x35
mov     r9,r11 ; количество сервисов
    ; ...    
nt!KeCompactServiceTable+0x20:
mov     eax,dword ptr [rdx] ; очередной (указатель | кол-во параметров)
add     rcx,4 ; смещаем указатель ULONG’ов
add     rdx,8 ; смещаем указатель PVOID’ов
sub     eax,r10d ; указатель –> смещение от начала таблицы
dec     r9
mov     dword ptr [rcx-4],eax
jne     nt!KeCompactServiceTable+0x20
                

Теперь перейдем к системам Windows Vista и старше. Откроем файл ntoskrnl.exe от
Windows 7 RTM x64 (7600) и взглянем на KiServiceTable:

0:000> dps ntoskrnl!KiServiceTable
00000001`40072b00 00000001`40483190 ntoskrnl!NtMapUserPhysicalPagesScatter
00000001`40072b08 00000001`40369a00 ntoskrnl!NtWaitForSingleObject
00000001`40072b10 00000001`40069dd0 ntoskrnl!NtCallbackReturn
00000001`40072b18 00000001`4038cb10 ntoskrnl!NtReadFile

То есть на системах Vista и старше в файле элементы таблицы системных сервисов
больше не содержат количество параметров функций. Для этого существует, как и
на x32, отдельная таблица KiArgumentTable. Естественно, что в ней
учтено, что первые 4-е параметра передается через стек, поэтому значения в ней
(размер параметров на стеке в байтах, из расчета 4-х байт на параметр)
отличаются по содержимому от таблицы в x32-сборке. Например, для того же
ntoskrnl.exe из Windows 7 x64:

0:000> db ntoskrnl!KiArgumentTable
00000001`4007378c 00 00 00 14 18 14 04 00-00 00 00 00 00 04 04 00

Но, как и для систем Win2k3, после загрузки ядра массив KiServiceTable
преобразуется в 4-х байтные смещения, младшие четыре бита которых содержат
количество параметров, передаваемых через стек:

1: kd> dd nt!KiServiceTable
fffff800`030c8300 04113300 02f9e200 fff73100 031cb705
fffff800`030c8310 031ac106 0315f605 02ba5601 02b76c00
fffff800`030c8320 0310e200 03e0fc00 02cf1e00 031bdc40
fffff800`030c8330 03153200 02e7ee01 02e25d01 02dfcb80

В данном формате смешением являются старшие только 28 бит 4-х байтового слова
элемента таблицы (т.е. нужно отбросить младшие 4-е бита). Например,
можно вычислить адрес того же NtReadFile‘а по индексу 3:

1: kd> ln nt!KiServiceTable+(dwo(nt!KiServiceTable+(3*4))>>4)
(fffff800`033e4e70) nt!NtReadFile | (fffff800`033e55b0) nt!NtOpenFile
Exact matches:
nt!NtReadFile = <no type information>

Преобразованием таблицы занимается все та же функция
nt!KeCompactServiceTable, но формат ее вызова расширен, ведь в
изначальной таблице (той, что записана в файле) не было количества
параметров, передаваемых через стек:

nt!KiInitializeKernel+0x358:
mov     r8d,dword ptr [nt!KiServiceLimit]
lea     rdx,[nt!KiArgumentTable]
lea     rcx,[nt!KiServiceTable]
call    nt!KeCompactServiceTable
                

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

mov     rdi,rdx
mov     r10d,r8d
mov     rbx,rcx
mov     rdx,rcx
test    r8d,r8d
je      nt!KeCompactServiceTable+0x45
mov     r11,r10
nt!KeCompactServiceTable+0x1e:
mov     r8d,dword ptr [rdx]
movzx   eax,byte ptr [rdi]
add     rdx,8
sub     r8d,ebx ; указатель -> смещение
shr     eax,2 ; 'элемент KiArgumentTable -> количество параметров
inc     rdi
shl     r8d,4 ; перенос значения смещения в старшие 28 бит
or      r8d,eax ; сохранение количества параметров
mov     dword ptr [rcx],r8d
add     rcx,4
sub     r11,1
jne     nt!KeCompactServiceTable+0x1e
                

Реализацию разбора содержимого таблицы на языке Python можно посмотреть в
примере ~\samples\ssdt.py из проекта PYKD.

Поиск таблицы системных вызовов ядра: nt!KiServiceTable

Таблица системных вызовов ядра (указатель на которую хранится в
nt!KeServiceDescriptorTable по нулевому индексу) расположена по адресу
символа nt!KiServiceTable, который не экспортируется ни в x32, ни в x64
сборках ядра ОС. В интернете можно найти достаточное количество способов
нахождения массива nt!KeServiceDescriptorTable, по данным которого
определяется адрес nt!KiServiceTable. Но я хочу рассмотреть способ
«эвристического» поиска nt!KiServiceTable на x64-платформе
напрямую.

Способ основан на формате SSDT, описанном выше. Фактически,
nt!KeCompactServiceTable перетирает первую половину таблицы
nt!KiServiceTable смещениями, оставляя вторую половину нетронутой. Это
означает, что во второй половине таблицы остаются «настоящие» указатели
на Nt-функции ядра. Это можно использовать:

  • Выбираем экспортируемую Nt-функцию, которая всегда находится во второй половине
    таблицы и получаем ее адрес. Я бы рекомендовал выбрать функции с количеством
    параметров <= 4, что бы при поиске не закладываться на номер билда ОС,
    например это функция
    NtSetSecurityObject
  • Проходим все not-paged секции PE-модуля ядра
  • В каждой секции с шагом в размер указателя сравниваем очередные 8-мь байт с
    полученным на первом этапе указателем функции. Когда значения совпадут — мы
    внутри nt!KiServiceTable
  • Перед таблицей nt!KiServiceTable всегда (во всяком случае я иного не
    встречал) есть область padding’а заполненная nop’ами. Следовательно, для поиска
    начала таблицы нужно идти вверх от NtSetSecurityObject, пока не встретим
    значение 0x9090909090909090
  • Что бы найти конец таблицы (и узнать ее размер) необходимо идти по таблице от
    NtSetSecurityObject вниз и проверять, что очередные 8-мь байт являются
    указателем в диапазон одной из секций кода PE-модуля ядра

Метод не самый надежный, но был протестирован на нескольких машинах с разными
версиями x64-ОС. Во всех случаях таблица nt!KiServiceTable была найдена
успешно.

ΞρεΤΙκ

Время на прочтение13 мин

Количество просмотров45K

Про системные вызовы уже много было сказано, например здесь или здесь. Наверняка вам уже известно, что системный вызов — это способ вызова функции ядра ОС. Мне же захотелось копнуть глубже и узнать, что особенного в этом системном вызове, какие существуют реализации и какова их производительность на примере архитектуры 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гб, по умолчанию используется малая модель памяти и все известные символы находятся в 32-битном адресном пространстве. Под это определение подходят статически скомпилированные программы, где и возможно использовать int 80h. Пошаговая работа прерывания подробно описана на 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 для архитектуры IA-32. В 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

Понравилась статья? Поделить с друзьями:
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
  • Avast remover windows 10
  • Dual boot kali and windows 10
  • Как установить windows 10 на компьютер с нуля для чайников пошаговая инструкция на русском языке
  • Nvidia control panel windows 10 pro
  • 0xc00000225 при загрузке windows 10