Ассемблер системные вызовы windows

Системные вызовы 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:

  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

a brightly lit neon sign over the side of a computer tower rack of different electronic components

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:

  1. Set up the registers with appropriate values.
  2. Execute the int 0x80 instruction to trigger the system call.
  3. 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 из ассемблерных приложений

Дата публикации 14 июн 2002

Вызов функций 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:

  1. LPSECURITY_ATTRIBUTES lpSecurityAttributes,
  2. DWORD dwCreationDistribution,
  3. DWORD dwFlagsAndAttributes,

  Фрагмент программы для кодировки ANSI, открывающий для чтения уже существующий файл, может выглядеть, например, так:

  1. file_name DB «MyFile.dat»,0
  2. push FILE_ATTRIBUTE_NORMAL

  Но многим гораздо больше понравится тот же самый фрагмент так, как он выглядит в MASM 6.1+:

  1. CreateFileA PROTO :DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD
  2. file_name DB «MyFile.dat»,0
  3. invoke CreateFileA,offset file_name,GENERIC_READ,0,NULL,
  4.                    OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,NULL

  Оператор invoke — это совсем небольшая и довольно очевидная доработка, которая, вместе с некоторыми другими, мгновенно превратила ассемблер из машинного языка в нормальный язык программирования.
© Svet(R)off

archive
New Member

Регистрация:
27 фев 2017
Публикаций:
532

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

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
  • Выключается диспетчер задач windows 10 сам
  • Marvell 6121 sata driver windows 10
  • Ошибка ms gamingoverlay windows 11
  • Windows forms custom controls
  • Почему windows media player не воспроизводит видео mp4