Системные вызовы 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>
Взаимодействие с операционной системой
В языке ассемблера программирование напрямую связано с работой с
аппаратной частью и операционной системой. Программы, написанные на
ассемблере, могут взаимодействовать с операционной системой для
выполнения задач, таких как ввод-вывод, управление памятью, обработка
прерываний и выполнение системных вызовов.
В операционных системах на базе семейства UNIX и Windows
взаимодействие с ОС обычно осуществляется через системные вызовы,
которые позволяют программе получить доступ к различным функциям ОС. В
ассемблере взаимодействие с операционной системой осуществляется через
прерывания, указание системных вызовов в виде определенных чисел и
передачу данных в регистры.
Системные вызовы и
прерывания
Основным способом взаимодействия с операционной системой на уровне
ассемблера является использование системных вызовов. Каждый системный
вызов выполняет определенную операцию, такую как чтение или запись
файла, работа с памятью, создание или завершение процесса и другие
операции.
Прерывания в x86 архитектуре
Для работы с системными вызовами в архитектуре x86 используется
инструкция int
, которая генерирует прерывание. Это
прерывание заставляет процессор временно приостановить выполнение
текущей программы и передать управление обработчику прерываний
операционной системы. В результате операционная система может выполнить
нужный системный вызов и вернуть управление обратно программе.
Пример использования прерывания для системного вызова в Linux:
section .data
msg db 'Hello, world!', 0
section .text
global _start
_start:
; Системный вызов для записи (write)
mov eax, 4 ; номер системного вызова для записи
mov ebx, 1 ; дескриптор вывода (1 - стандартный вывод)
mov ecx, msg ; указатель на строку для вывода
mov edx, 13 ; длина строки
int 0x80 ; прерывание для вызова ОС
; Системный вызов для выхода (exit)
mov eax, 1 ; номер системного вызова для выхода
xor ebx, ebx ; код выхода 0
int 0x80 ; прерывание для вызова ОС
В этом примере используется прерывание 0x80
для
обращения к ядру операционной системы Linux. Системный вызов с номером
4
отвечает за вывод данных на стандартный вывод, а вызов с
номером 1
— за завершение программы.
Структура системных вызовов
в Linux
Для системы Linux номер системного вызова (например, 4
для записи, 1
для завершения программы) загружается в
регистр eax
, а параметры системного вызова передаются через
другие регистры:
eax
— номер системного вызова;ebx
— первый параметр;ecx
— второй параметр;edx
— третий параметр.
Операционная система использует эти значения для выполнения
соответствующей операции. Таким образом, программист на ассемблере
должен правильно определить нужный системный вызов и передать параметры,
чтобы получить правильный результат.
Взаимодействие
с операционной системой в Windows
Для ОС Windows взаимодействие с операционной системой через ассемблер
несколько отличается, поскольку используется другая система вызовов —
через API Windows. В Windows системные вызовы чаще всего происходят
через функции в динамических библиотеках (DLL), таких как
kernel32.dll
, user32.dll
и другие.
Пример
использования API Windows для вывода строки
Пример ассемблерной программы, которая выводит строку в консоль через
Windows API, используя функцию WriteConsoleA
из
kernel32.dll
:
section .data
msg db 'Hello, Windows!', 0
section .text
extern GetStdHandle, WriteConsoleA
extern ExitProcess
global _start
_start:
; Получаем стандартный вывод
push -11 ; STD_OUTPUT_HANDLE
call GetStdHandle
; Пишем строку
push 0 ; lpNumberOfCharsWritten (не нужен)
push 13 ; количество символов
push msg ; указатель на строку
push eax ; дескриптор вывода (получен из GetStdHandle)
call WriteConsoleA
; Завершаем программу
push 0 ; код возврата
call ExitProcess
Здесь используется функция GetStdHandle
для получения
дескриптора стандартного вывода и функция WriteConsoleA
для
вывода строки. Для завершения программы используется функция
ExitProcess
.
Управление памятью
Операционная система также предоставляет механизмы для управления
памятью, такие как выделение динамической памяти. В Windows это может
быть сделано через API функции, такие как VirtualAlloc
и
VirtualFree
. В Linux для этого часто используются системные
вызовы, такие как brk
или mmap
.
Пример выделения памяти в Windows с помощью
VirtualAlloc
:
section .text
extern VirtualAlloc, VirtualFree, ExitProcess
global _start
_start:
; Выделяем 4096 байт памяти
push 0 ; флаги (MEM_COMMIT)
push 4096 ; размер памяти
push 0 ; адрес (NULL)
push 0x1000 ; тип выделяемой памяти (PAGE_READWRITE)
call VirtualAlloc
; Используем память (например, записываем что-то в неё)
; Адрес выделенной памяти хранится в eax после вызова VirtualAlloc
; Освобождаем память
push 0 ; флаги
push eax ; адрес выделенной памяти
call VirtualFree
; Завершаем программу
push 0
call ExitProcess
Прерывания и обработка
исключений
Прерывания, как было упомянуто ранее, являются ключевыми для
взаимодействия с операционной системой. Однако также стоит упомянуть об
исключениях и их обработке. В системах с архитектурой x86/64 прерывания
и исключения могут возникать по разным причинам, например, при делении
на ноль, обращении к несуществующей памяти и так далее.
Для ассемблерных программ важно правильно обрабатывать такие
исключения, что обычно достигается через настройку обработчиков
прерываний и использование инструкций, таких как int
или
cli/sti
(для отключения и включения прерываний).
Обработчики исключений могут быть использованы для реализации механизмов
надежности и предотвращения сбоя программы.
Заключение
Взаимодействие с операционной системой на ассемблере является важной
частью разработки низкоуровневого кода. Понимание системных вызовов,
работы с прерываниями, а также использования API операционной системы
дает программисту возможность более эффективно использовать возможности
ОС. Понимание этих аспектов необходимо для разработки эффективных и
высокопроизводительных программ, особенно в области системного
программирования и разработки драйверов.
Время на прочтение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:
- Парсинг вспомогательных векторов: elf/dl-sysdep.c
- Парсинг разделяемой библиотеки: elf/setup-vdso.h
- Установка значений функций: 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
Note: this page has been created with the use of AI. Please take caution, and note that the content of this page does not necessarily reflect the opinion of Cratecode.
In the world of x86 assembly language programming, system calls play a crucial role in communicating with the operating system. They allow your program to request services, like console output, from the OS. In this article, we’ll dive into the nuts and bolts of using system calls with NASM assembler.
What is a System Call?
A system call is a request made by a program to the operating system for a specific service. When your program needs to perform an action it can’t do on its own, like printing text to the console, it relies on a system call to ask the OS for help.
NASM Assembler
NASM is a popular assembler for x86 assembly language, known for its flexibility and compatibility with various platforms. It provides a simple and clean syntax, making it easier to write and understand assembly code.
Making a System Call
To make a system call in x86 assembly, you’ll need to follow three steps:
- Set up the registers with appropriate values.
- Execute the
int 0x80
instruction to trigger the system call. - Handle the return value, if necessary.
Let’s go through these steps in more detail.
Step 1: Set up the registers
Before making a system call, you need to set up the registers with the required values. The most important register to set is the EAX register, which specifies the system call number. You’ll also need to set other registers depending on the specific system call you’re making.
For console output, we’ll use the sys_write
system call (number 4). We’ll also need to set the EBX register to the file descriptor (1 for stdout), the ECX register to the address of the data to be printed, and the EDX register to the length of the data.
Here’s an example of setting up the registers for console output:
; Set up the registers for sys_write mov eax, 4 ; sys_write system call number mov ebx, 1 ; file descriptor for stdout mov ecx, msg ; address of the message to be printed mov edx, len ; length of the message
Step 2: Trigger the system call
Once the registers are set up, you can trigger the system call using the int 0x80
instruction. This instruction generates a software interrupt, signaling the OS to handle the system call.
; Trigger the system call int 0x80
Step 3: Handle the return value
After the system call is executed, the OS usually returns a value in the EAX register. For sys_write
, the return value is the number of bytes written. You can use this value as needed, or simply ignore it if it’s not important for your program.
; Handle the return value (optional) mov [bytes_written], eax
Example: Printing Hello, World!
Here’s a complete example of using a system call to print «Hello, World!» in x86 assembly with NASM:
section .data msg db 'Hello, World!', 0xA ; The message with a newline character len equ $ - msg ; Calculate the length of the message section .bss bytes_written resd 1 ; Reserve space for the return value section .text global _start _start: ; Set up the registers for sys_write mov eax, 4 ; sys_write system call number mov ebx, 1 ; file descriptor for stdout mov ecx, msg ; address of the message to be printed mov edx, len ; length of the message ; Trigger the system call int 0x80 ; Handle the return value (optional) mov [bytes_written], eax ; Exit the program using sys_exit system call mov eax, 1 ; sys_exit system call number xor ebx, ebx ; set ebx to 0 (exit status) int 0x80
Now you know how to use system calls for console output in x86 assembly language programming with NASM. Happy coding!
Hey there! Want to learn more? Cratecode is an online learning platform that lets you forge your own path. Click here to check out a lesson: Why Program? (psst, it’s free!).
FAQ
What is a system call in x86 assembly language programming with NASM?
A system call is a method used by programs to request services from the operating system. In x86 assembly language programming with NASM, system calls are utilized to perform tasks such as console output, file handling, and process management. They provide an interface between the program and the operating system, allowing the programmer to access system-level functions.
How do I make a system call in x86 assembly using NASM?
To make a system call in x86 assembly using NASM, you need to follow these steps:
- Load the system call number into the EAX register.
- Load the required arguments for the system call into the appropriate registers (EBX, ECX, etc.).
- Issue the
int 0x80
instruction to trigger the system call.
Here’s an example of a basic system call for writing to the console:
mov eax, 4 ; System call number for write mov ebx, 1 ; File descriptor (stdout) mov ecx, msg ; Address of the message to write mov edx, msg_len ; Length of the message int 0x80 ; Trigger the system call
How can I find the system call numbers for x86 assembly programming with NASM?
System call numbers for x86 assembly programming with NASM can be found in the Linux kernel’s source code, specifically in the unistd_32.h
header file. The system call numbers are defined as constants, which can be used in your assembly code. Alternatively, you can search online for a list of x86 system calls to find the specific call numbers you need.
How do I exit a program using a system call in x86 assembly with NASM?
To exit a program using a system call in x86 assembly with NASM, you can use the exit
system call. Here’s an example of how to exit a program with a return code of 0 (success):
mov eax, 1 ; System call number for exit xor ebx, ebx ; Return code 0 (success) int 0x80 ; Trigger the system call
Can I make system calls in x86-64 assembly programming with NASM?
Yes, you can make system calls in x86-64 assembly programming with NASM as well. The process is similar to x86 assembly, but with some differences in register names and the system call instruction. Instead of int 0x80
, you’ll use the syscall
instruction, and you’ll use the RAX, RDI, RSI, and RDX registers for the system call number and arguments.
Вызов функций API из ассемблерных приложений
Вызов функций API из ассемблерных приложений — Архив WASM.RU
В этой статье речь идет именно о вызове системных функций API win32, а не об организации процедур в ассемблере вообще. Обсуждаемая здесь тема, следовательно, существенна уже, так как многие возможности языка при вызове функций API не применяются. Например, здесь нет необходимости обсуждать описание процедур, так как функции API написаны добрыми людьми из Misrosoft, и нам самим их писать не придется. Также не придется обсуждать вызов функций с переменным числом параметров и кое-что еще.
Вызов системных функций API win32 из программы на ассемблере подчиняется набору соглашений stdcall, ведущему свою родословную в части именования функций — от языка C, а в части передачи аргументов — от языка Pascal. С точки зрения прикладного программиста и с учетом специфики Windows и MASM эти соглашения заключаются в следующем:
- регистр символов в имени функции не имеет значения. Например, функции с именами ILoveYou и iloveyou — это одна и та же функция. Здесь мы наблюдаем отличие от реализации MS Visual C++, где компилятор строго отслеживает совпадение регистра в именах используемых программистом функций с прототипами, содержащимися в системных заголовочных файлах. Сборщику же, как видим, регистр символов безразличен
- аргументы передаются вызываемой функции через стек. Если аргумент укладывается в 32-битное значение и не подлежит модификации вызываемой функцией, он обычно записывается в стек непосредственно. В остальных случаях программист должен разместить значение аргумента в памяти, а в стек записать 32-битный указатель на него. Таким образом, все передаваемые функции API параметры представляются 32-битными величинами, и количество байт, занимаемых в стеке для передачи аргументов, кратно четырем
- вызывающая программа загружает аргументы в стек последовательно, начиная с последнего, указанного в описании функции, и кончая первым. После загрузки всех аргументов программа вызывает функцию командой call
- за возвращение стека в исходное состояние после возврата из функции API отвечает сама эта вызываемая функция. Программисту заботиться о восстановлении указателя стека esp нет необходимости
- вызываемая функция API гарантированно сохраняет регистры общего назначения ebp, esi, edi. Регистр eax, как правило, содержит возвращаемое значение. Состояние остальных регистров после возврата из функции API следует считать неопределенным. (Полный набор соглашений stdcall регламентирует также сохранение системных регистров ds и ss. Однако для flat-модели памяти, используемой в win32, эти регистры значения не имеют.)
На системном уровне этот набор соглашений добавляется вот еще чем. Компилятор при формировании из исходного текста объектного файла добавляет к началу имени функции символ подчеркивания, а к концу — выражение вида @n, где n — десятичное число, равное количеству байт, занятому в стеке под аргументы функции. Так формируется технологическое имя, позволяющее осуществить связывание не только по имени вызываемой функции, но и по количеству ее аргументов. Благодаря ему при сборке обнаруживаются ошибки программиста в случае, когда он задал для вызываемой функции неправильное число аргументов. Конечно, этому сервису далеко до строгого контроля типов C++, но от огромного количества трудноустранимых ошибок он все-таки оберегает.
Соблюдение перечисленных соглашений обеспечивается компилятором автоматически. Для этого необходимо включить в начало исходного файла комбинацию директив:
Директиву .386 можно заменить на более высокую в зависимости от того, на какой процессор вы рассчитываете. Директива model определяет сегментную модель приложения. Для всех приложений win32 она должна иметь именно такой вид, как показано здесь.
Прежде чем переходить к рассмотрению примеров, следует обсудить еще одно обстоятельство. Оно связано с тем, что Windows, благодаря прозорливости ее создателей, поддерживает два типа кодировки текстовых символов — архаичную восьмибитную ANSI и перспективную шестнадцатибитную Unicode. Из-за этого программистам Microsoft пришлось для всех функций API, так или иначе работающих с текстовыми символами, писать по два программных варианта. Тот, который работает с кодировкой ANSI, имеет суффикс A, например, CreateFileA. Тот, который работает с кодировкой Unicode, имеет суффикс W, например, CreateFileW.
Обычно приложение ориентируется на работу только с одной из этих двух кодировок. Выбор производится программистом на этапе компиляции путем указания значения для специальной настроечной константы. В MS Visual C++, например, это константа UNICODE. Если она определена, то вызов функции CreateFile в тексте программы при компиляции незаметно для программиста преобразуется в вызов функции CreateFileW, в противном случае — в CreateFileA.
В программах на ассемблере, конечно, можно реализовать тот же механизм. А можно, руководствуясь девизом «handmade forever», делать это вручную.
Основываясь на вышесказанном, приведем пример вызова какой-нибудь функции API. Допустим, это будет уже упоминавшаяся функция CreateFile. Вот ее описание для C++, как оно дано в Platform SDK:
LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDistribution, DWORD dwFlagsAndAttributes,Фрагмент программы для кодировки ANSI, открывающий для чтения уже существующий файл, может выглядеть, например, так:
file_name DB «MyFile.dat»,0 push FILE_ATTRIBUTE_NORMALНо многим гораздо больше понравится тот же самый фрагмент так, как он выглядит в MASM 6.1+:
CreateFileA PROTO :DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD file_name DB «MyFile.dat»,0 invoke CreateFileA,offset file_name,GENERIC_READ,0,NULL, OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,NULLОператор invoke — это совсем небольшая и довольно очевидная доработка, которая, вместе с некоторыми другими, мгновенно превратила ассемблер из машинного языка в нормальный язык программирования.
© Svet(R)off
archive
New Member
- Регистрация:
- 27 фев 2017
- Публикаций:
- 532