Это текстовая версия моей статьи про разработку графического приложения на чистом ассемблере под линукс.
Вступление
Скажу сразу: все что ниже – это перевод вот этой замечательной статьи, у меня недостаточно навыков чтобы такое провернуть своими силами. Не получилось портировать для FreeBSD, даже доработать для поддержки юникода — читайте и поймете почему все так сложно ;)
Поехали.
Большинство быдлокодеров разработчиков думают, что современный ассемблер может использоваться либо для каких-то игрушечных поделок, создаваемых в целях обучения, либо для хардкорной оптимизации какой-то отдельной функции внутри большого и кривого проекта, написанного на языке высокого уровня.
А что если мы напишем программу целиком на ассемблере, которая открывает графическое окно в X-сервере? Конечно это будет что-то вроде «hello word» от мира интерфейсов, нечто простое визуально, но очень и очень сложно реализуемое.
Эта статья появилась на свет потому, что я хотел улучшить свои знания ассемблера, заодно сделав чего-нибудь интересное и мотивирующее.
Еще я заметил, что слишком много бинарников в наши дни очень и очень жирные, часто больше 30Mb (привет Rust) и спросил себя:
Доколе? Насколько маленьким может быть бинарник для (очень простого) интерфейса?
В результате моих экспериментов получилось очень достойно — примерно 1Kb!
Disclaimer:
Я ни в коей мере не являюсь экспертом в ассемблере или X11. Просто хотел написать развлекательную статью, что-то простое и понятное даже новичку. Что-то что я сам бы хотел иметь под рукой когда только начинал изучение ассемблера. Если вы найдете ошибку — пожалуйста оставьте описание на странице проекта в Github.
Эта статья активно обсуждалась на Hacker News и Lobsters.
Инструментарий
Я буду использовать ассемблер nasm, потому что он красивый простой, кросс-платформенный, быстрый и имеет читаемый синтаксис (речь про макросы).
Для интерфейса я буду использовать протокол X11, поскольку использую Линукс, и он имеет несколько интересных свойств, позволяющих его использовать без каких-либо внешних библиотек. Если вы используете Wayland, все должно заработать «из коробки» при помощи XWayland (проверил, работает) и (возможно) также на MacOS с использованием XQuartz, но это не проверял.
Для MacOS не забудьте указать nasm на использование macho64 формата, поскольку macOS не использует ELF. Также линковщик по умолчанию не поддерживает ключ -static. Замечу, что единственное отличие между различными *nix операционными системами для нашего тестового проекта это значения syscalls (системные вызовы).
Поскольку я использую Linux, то буду использовать значения системных вызовов Linux, но «портирование» этой программы на, скажем, FreeBSD потребует лишь изменения этих значений, возможно используя вот такой nasm-макрос:
%ifdef linux
%define SYSCALL_EXIT 60
%elifdef freebsd
%define SYSCALL_EXIT 1
%endif
Примечание переводчика:
нет, замены одних лишь syscalls недостаточно для портирования под FreeBSD, нужно заново повторять весь цикл автора: создавать клиентское приложение на Си, диззасемблировать и вручную сравнивать вызовы.
Ключевые слова вроде %define в примере выше — часть мощной системы макросов в nasm, но мы будем использовать лишь ее малую часть для определения констант как в Си: #define FOO 3. Нет необходимости в каких-то дополнительных инструментах кросс-компиляции, разборок с динамической линковкой, разной реализацией libc и так далее.
Поехали!
Основы X11
X11 это сервер, доступный по сети, который управляет окнами и отрисовкой внутри этих окон. Клиент открывает сокет, подключается к серверу и отправляет команды в специальном формате для открытия окна, рисования графических элементов и т. д. Сервер отправляет сообщения об ошибках или событиях клиенту. Большинство приложений используют готовые клиентские библиотеки libX11 или libxcb, которые предоставляют API на языке Си, но мы пойдем другим путем.
Где именно находится сервер X11 на самом деле неважно для клиента, он может быть запущенным как локально (на этой же машине) так и где-то далеко в датацентре. Разумеется в контексте домашнего PC в 2023 м он (сервер) будет запущен локально, но это лишь детали.
Официальная документация очень хорошего качества, поэтому мы спокойно можем на нее ссылаться.
Основы ассемблера x64
Начнем с минимальной программы, которая просто завершается с кодом 0. Сначала указываем nasm, что мы пишем 64-битную программу и целевая архитектура – x86_64. Затем создадим главную функцию, которую назовем _start – она должна быть видимой, поскольку является входной точкой в нашу программу (обратите внимание на ключевое слово global):
; Comments start with a semicolon!
BITS 64 ; 64 bits.
CPU X64 ; Target the x86_64 family of CPUs.
section .text
global _start
_start:
xor rax, rax ; Set rax to 0. Not actually needed, it's just to avoid having an empty body.
section .text указывает nasm и линковщику, что весь последующий код должен быть помещен в секцию text создаваемого бинарника. Дальше мы добавим секцию section .data для наших глобальных переменных.
Замечу что эти секции обычно раскидываются ОС по разным страницам памяти, с разными правами (можно увидеть с помощью команды readelf -l
), поэтому секция text является только для чтения, а секция data — с запретом на запуск, но это отличается в разных ОС.
Функция _start имеет пустое тело на данном этапе. Название функции ничем не ограничено, мы просто ее так назвали согласно хорошей практики именования.
Сборка нашего маленького проекта осуществляется вот так:
nasm -f elf64 -g main.nasm && ld main.o -static -o main
nasm на самом деле лишь создает объектный файл, поэтому чтобы получить запускаемый бинарник, нам еще необходимо вызвать линковщик ld.
Флаг -g указывает nasm на добавление отладочной информации, очень полезной, когда творишь всякую адскую дичь на чистом ассемблере.
Для удаления отладочной информации можно передать ключ -s линковщику, когда мы например делаем финальную «продуктовую» сборку и хотим сохранить несколько Kb. Наконец получаем запускабельный бинарник: выглядит вот так
Можем увидеть секции с помощью readelf -a ./main
, видно что секция .text , содержащая наш код длиной всего 3 байта.
Если мы сейчас попробуем запустить нашу программу то она.. упадет.
Это происходит потому что операционная система ожидает от нашей программы корректного выхода (ужос-то какой) с помощью специального системного вызова, в противном случае CPU продолжит выполнение всего того что следует после точки старта и до тех пор, пока не достигнет границы страницы памяти (что и закончится segfaultом). Вот это и делает библиотека libc в программах на Си, добавляем реализацию выхода:
%define SYSCALL_EXIT 60
global _start:
_start:
mov rax, SYSCALL_EXIT
mov rdi, 0
syscall
Примечание #1:
nasm использует синтаксис Intel: , , поэтому mov rdi, 0 помещает 0 в регистр rdi. Другие ассемблеры используют синтаксис AT&T, который меняет местами источник (source) и назначение (destination). Мой совет: выберите какой-то один синтаксис и один ассемблер и придерживайтесь его, хотя оба варианта хороши и большинство инструментов имеют поддержку для обоих.
Примечание #2:
Следуя Unix System V ABI, который обязателен как для Linux, так и для других юниксов, совершение системного вызова требует положить код вызова в регистр rax, а параметры к нему ( до 6ти) в регистры rdi, rsi, rdx, rcx, r8, r9, и дополнительные параметры (если есть) – на стек (что не происходит в нашей программе, поэтому мы можем опустить этот момент). Затем мы используем инструкцию syscall и проверяем rax для получения кода возврата, где 0 обычно означает отсутствие ошибок. Замечу что Linux (и возможно другие юниксы) имеют «веселую» особенность: четвертый параметр системного вызова обычно передается через регистр r10.
Примечание #3:
Самые упоротые читатели подсказали что так происходит везде, во всех ОС и описано в реализации архитектуры x86_64 для System V ABI. Вот кто бы знал! Но это только для системных вызовов, обычные функции все также используют rcx для четвертого аргумента.
Замечу, что cледование System V ABI обязательно при системных вызовах и при взаимодействии с Си, но вот в остальном коде мы вольны использовать все что захотим. Долгое время Go использовал отличные от System V ABI соглашения о вызовах (calling convention), например при вызовах функций (передача аргументов на стеке).
Большинство инструментов (дебагеры, профайлеры) также ожидают соблюдения System V ABI, поэтому я рекомендую на нем и остановиться.
Возвращаемся к нашей программе. Если мы ее сейчас запустим то увидим.. ничего.
Это потому что все прошло успешно, согласно философии UNIX! Проверяем код возврата:
$ ./main; echo $?
0
Теперь заменим инструкцию mov rdi, 0 на mov rdi, 8 и программа выдаст:
$ ./main; echo $?
8
Да, это тот самый код ошибки, которые программы отдают, если что-то идет не так.
Другой вариант изучения системных вызовов, которые делает программа – использование утилиты strace, которая очень полезна при поиске проблем на свою жопу.
В BSD-системах ее аналоги – это утилиты truss or dtruss.
Про стек
Прежде чем продолжать, надо немного погрузиться в основы работы стека, как это работает в ассемблере, поскольку у нас нет друга-компилятора, который бы сделал это для нас.
Три самые важные вещи о стеке:
1) Он растет вниз: для резервирования большего места на стеке, мы уменьшаем значение rsp.
2) Функция должна вернуть указатель стека в его начальное значение до выхода из нее. Это означает, что необходимо либо запоминать начальное значение и устанавливать его в rsp либо учитывать каждое увеличение или уменьшение этого значения.
3) До вызова функции, указатель стека должен быть 16 байт (16 bytes aligned) согласно спецификации System V ABI. Также в самом начале функции, значение указателя: 16*N + 8. Это потому что до вызова функции, значение было 16 байт, например. 16*N, и инструкция call двигает текущее положение в стеке (регистр rip, длиной 8 байт) для определения точки перехода после возврата из функции.
Игнорирование этих правил приведет к отказам в самых неожиданных местах, имейте в виду. Все потому что адрес перехода после выхода из функции скорее всего будет перезаписан и переход произойдет по ошибочному адресу.
Или этот адрес или содержимое стека будут перезаписаны и программа будет использовать неверные значения. В общем, все плохо.
Небольшой пример стека
Напишем функцию, которая просто печатает слово hello на стандартном выводе, с использованием стека, для изучения основ. Более легким решением будет запись этой строки в секции .rodata, но этот способ нас ничему не обучит.
Зарезервируем (минимум) 5 байт на стеке, поскольку это длина нашей тестовой строки в байтах. Стек будет выглядеть вот так рисунок.
И rsp указывает на конец стека.
Вот так мы будем использовать каждый элемент: рисунок
Затем мы передаем адрес на стеке в начале строки в вызов системной функции write вместе с длиной строки:
%define SYSCALL_WRITE 1
%define STDOUT 1
print_hello:
push rbp ; Save rbp on the stack to be able to restore it at the end of the function.
mov rbp, rsp ; Set rbp to rsp
sub rsp, 5 ; Reserve 5 bytes of space on the stack.
mov BYTE [rsp + 0], 'h' ; Set each byte on the stack to a string character.
mov BYTE [rsp + 1], 'e'
mov BYTE [rsp + 2], 'l'
mov BYTE [rsp + 3], 'l'
mov BYTE [rsp + 4], 'o'
; Make the write syscall
mov rax, SYSCALL_WRITE
mov rdi, STDOUT ; Write to stdout.
lea rsi, [rsp] ; Address on the stack of the string.
mov rdx, 5 ; Pass the length of the string which is 5.
syscall
add rsp, 5 ; Restore the stack to its original value.
pop rbp ; Restore rbp
ret
Примечание:
Инструкция lea destination, source загружает актуальный адрес источника в регистр назначения, точно также как реализованы указатели на Си. Для переопределения адреса в памяти мы используем квадратные скобки. Поэтому, например если мы только что положили адрес в rdi с использованием lea, например lea rdi, [hello_world], и хотим записать значение адреса в rax, делаем: mov rax, [rdi]. Обычно надо указать nasm сколько байт необходимо переназначить с помощью ключевых слов BYTE, WORD, DWORD, QWORD, поэтому вызов выглядит как: mov rax, DWORD [rdi], потому что nasm не отслеживает размеры каждой переменной. Это то что делает компилятор Си когда мы переопределяем указатели int8_t, int16_t, int32_tи int64_t.
Тут много деталей, о которых необходимо рассказать. Начнем с того что такое rbp.
Это тоже регистр, как и все остальные, но вы можете выбрать следовать ли конвенциям и не использовать его как другие регистры для записи значений, а использовать для записи связанного списка «call frames» (кадры?).
В самом начале функции, значение rbp хранится на стеке (это делает push rbp). Поскольку rbp записывает адрес (адрес фрейма который вызывает нашу функцию), мы сохраняем в стеке известный нам адрес вызова.
Сразу после этого, мы устанавливаем rbp в rsp, в указатель стека в самом начале функции. Поэтому вызовы push rbp and mov rbp, rsp обычно описываются как начало функции (function prolog). Для оставшейся части тела функции мы принимаем rbp как константу и уменьшаем значение rsp только если надо зарезервировать место на стеке.
Вообщем если функция А вызывает функцию Б, которая внутри вызывает функцию С и все они сохраняют адрес вызова на стеке, мы знаем где искать адрес для каждой функции.
Поэтому мы можем показать трассировку вызова (stack trace) в любом месте нашей программы, просто изучив стек. Очень просто (нет) и полезно для профайлеров и подобных утилит.
Но не надо забывать восстановить значение rbp перед выходом из функции в начальное значение (которое все еще находится в стеке в этом месте), что и делает вызов pop rbp. Этот вызов также известен как конец функции (function epilog). Другой вариант использования: удалить последний элемент связанного списка цепочки вызовов (call frames), поскольку мы выходим из последней функции.
Не парьтесь если описанное выше не дошло, просто запомните что всегда надо иметь начало и конец функции и все будет хорошо: my_function:
push rbp
mov rbp, rsp
sub rsp, N
[...]
add rsp, N
pop rbp
ret
Примечание #1:
Существует метод оптимизации, который использует rbp как стандартный регистр (в компиляторе Си за это отвечает флаг -fomit-frame-pointer), что означает потерю информации о стеке вызовов (call stack). Мой совет: никогда это не используйте, оно того не стоит.
Примечание #2:
Подождите, но выше же было описано что стек должен быть 16 байт, который кратный 16)? Последний раз 16 на 5 не делилось! Отлично подмечено! Единственная причина по которой эта программа вообще работает – это то что print_hello это конечная функция (leaf function), т.е она не вызывает никакие другие функции.
Помните что стек должен быть 16 байт когда мы делаем вызов call! Поэтому правильный вариант будет таким:
print_hello:
push rbp
mov rbp, rsp
sub rsp, 16
mov BYTE [rsp + 0], 'h'
mov BYTE [rsp + 1], 'e'
mov BYTE [rsp + 2], 'l'
mov BYTE [rsp + 3], 'l'
mov BYTE [rsp + 4], 'o'
mov rax, SYSCALL_WRITE
mov rdi, STDOUT
lea rsi, [rsp]
mov rdx, 5
syscall
call print_world
add rsp, 16
pop rbp
ret
Когда мы входим в функцию, значение rsp равно 16*N+8, вызов rbp увеличивает его на 8, указатель стека равен 16 байт в момент вызова sub rsp, 16. Уменьшение его на 16 (или на кратное число) сохраняет его размер в 16 байт.
Теперь мы можем безопасно вызывать другие функции из print_hello:
print_world:
push rbp
mov rbp, rsp
sub rsp, 16
mov BYTE [rsp + 0], ' '
mov BYTE [rsp + 1], 'w'
mov BYTE [rsp + 2], 'o'
mov BYTE [rsp + 3], 'r'
mov BYTE [rsp + 4], 'l'
mov BYTE [rsp + 5], 'd'
mov rax, SYSCALL_WRITE
mov rdi, STDOUT
lea rsi, [rsp]
mov rdx, 6
syscall
add rsp, 16
pop rbp
ret
print_hello:
push rbp
mov rbp, rsp
sub rsp, 16
mov BYTE [rsp + 0], 'h'
mov BYTE [rsp + 1], 'e'
mov BYTE [rsp + 2], 'l'
mov BYTE [rsp + 3], 'l'
mov BYTE [rsp + 4], 'o'
mov rax, SYSCALL_WRITE
mov rdi, STDOUT
lea rsi, [rsp]
mov rdx, 5
syscall
call print_world
add rsp, 16
pop rbp
ret
В результате вызова будет сообщение hello world , без перевода на новую строку.
Теперь попробуйте заменить вызов на sub rsp, 5 в функции print_hello, и возможно программа упадет. Ключевое слово тут «возможно», гарантий нет, именно поэтому так сложно подобное отследить.
Мои советы: Всегда используйте стандартные начало и конец функции Всегда увеличивайте/уменьшайте rsp на (делитель) 16 Указатели адреса на стеке относительны к rsp, например mov BYTE [rsp + 4], ‘o’ Если нужно уменьшить rsp на значение , неизвестное в момент компиляции (по аналогии с тем как работает alloca() в Си), вы можете вызвать and rsp, -16 для выравнивания.
И все будет хорошо.
Последний совет интересен сам по себе, смотрите:
(gdb) p -100 & -16
$1 = -112
(gdb) p -112 & -16
$2 = -112
Что транслируется в ассемблер:
sub rsp, 100
and rsp, -16
И последнее:
следование этим правилам значит что наши функции на ассемблере могут безопасно вызываться из Си или других языков, также соблюдающих System V ABI, без какой-либо модификации, что хорошо.
Я не описывал «красную зону», регион в 128 байт внизу стека, которую наша программа вольна использовать как ей будет угодно без изменения указателя стека. По моему мнению это не очень помогает и порождает баги, которые очень сложно отслеживать, поэтому я не рекомендую это использовать. Для полного отключения используйте:
nasm -f elf64 -g main.nasm && cc main.o -static -o main -mno-red-zone -nostdlib.
Открытие сокета
Следующим шагом открываем сокет с помощью вызова системной функции socket(2), добавляем несколько констант, взятых из заголовков libc (замечу что значения этих констант на самом деле могут быть разными для разных юниксов, не проверял. Повторюсь, несколько %ifdef могут легко решить эту проблему):
%define AF_UNIX 1
%define SOCK_STREAM 1
%define SYSCALL_SOCKET 41
КонстантаAF_UNIX означает что мы будем использовать Unix сокет, константа SOCK_STREAM означает потоковую работу (stream-oriented).
Мы используем Unix-сокет поскольку знаем что сервер запущен локально и такое подключение будет работать быстрее, но мы можем поменять на AF_INET для подключения к удаленному хосту. Затем заполняем необходимые регистры, указанными ниже значениями и делаем вызов:
mov rax, SYSCALL_SOCKET
mov rdi, AF_UNIX ; Unix socket.
mov rsi, SOCK_STREAM ; Stream oriented.
mov rdx, 0 ; Automatic protocol.
syscall
Аналог на языке Си будет выглядеть как: socket(AF_UNIX, SOCK_STREAM, 0);.
Как видите, пока мы заполняем регистры в том же порядке как и параметры функции в Си, мы сохраняем совместимость с реализацией на Си.
Вся программа целиком на этом шаге выглядит вот так:
BITS 64 ; 64 bits.
CPU X64 ; Target the x86_64 family of CPUs.
section .text
%define AF_UNIX 1
%define SOCK_STREAM 1
%define SYSCALL_SOCKET 41
%define SYSCALL_EXIT 60
global _start:
_start:
; open a unix socket.
mov rax, SYSCALL_SOCKET
mov rdi, AF_UNIX ; Unix socket.
mov rsi, SOCK_STREAM ; Stream oriented.
mov rdx, 0 ; automatic protocol.
syscall
; The end.
mov rax, SYSCALL_EXIT
mov rdi, 0
syscall
Собрав и запустив нашу программу через strace можно убедиться что оно работает и мы получили сокет с дескриптором 3 (но в вашем случае может число может отличаться).
Подключение к серверу
Следующим шагом после открытия сокета мы попробуем подключиться к серверу с помощью вызова системной функции connect(2) . Хороший момент для того чтобы вынести логику подключения в отдельную маленькую функцию, как в любом другом языке высокого уровня:
x11_connect_to_server:
; TODO
В ассемблере функция это просто метка, к которой можно сделать переход. Но для ясности как читателей так и инструментов разработки, мы можем добавить подсказку, указывающую что это настоящая функция и может быть вызвана как:
call x11_connect_to_server.
Это сделает более читабельным стек вызова, например при запуске strace -k. Эта подсказка (hint) выглядит как (в nasm):
static <name of the function>:function.
Разумеется нам также надо добавить стандартные начало (prolog) и конец (epilog) функции:
x11_connect_to_server:
static x11_connect_to_server:function
push rbp
mov rbp, rsp
pop rbp
ret
Дополнительной помощью при чтении функций в ассемблере является комментирование с описанием параметров, которые функция принимает, а также что именно функция возвращает.
Поскольку на уровне самого языка нет никакой поддержки для такого, мы поможем себе комментированием:
; Create a UNIX domain socket and connect to the X11 server.
; @returns The socket file descriptor.
x11_connect_to_server:
static x11_connect_to_server:function
push rbp
mov rbp, rsp
pop rbp
ret
Первым делом перемещаем логику открытия сокета в нашу функцию и вызываем ее из программы:
; Create a UNIX domain socket and connect to the X11 server.
; @returns The socket file descriptor.
x11_connect_to_server:
static x11_connect_to_server:function
push rbp
mov rbp, rsp
; Open a Unix socket: socket(2).
mov rax, SYSCALL_SOCKET
mov rdi, AF_UNIX ; Unix socket.
mov rsi, SOCK_STREAM ; Stream oriented.
mov rdx, 0 ; Automatic protocol.
syscall
cmp rax, 0
jle die
mov rdi, rax ; Store socket fd in `rdi` for the remainder of the function.
pop rbp
ret
die:
mov rax, SYSCALL_EXIT
mov rdi, 1
syscall
_start:
global _start:function
call x11_connect_to_server
; The end.
mov rax, SYSCALL_EXIT
mov rdi, 0
syscall
Проверка на ошибки очень простая: мы проверяем только возвращаемое значение системного вызова (в rax) и сравниваем его с ожидаемым, если вернулось не то что нужно — просто завершаем работу программы с ненулевым кодом путем перехода в секцию die.
Примечание: jle это условный переход, который проверяет глобальные флаги, устанавливаемый перед cmp или testвызовами, сам переход осуществляется к указанной метке если условие верно. В этом месте мы сравниваем возвращаемое значение с 0 и если оно меньше или равно 0 — осуществляем переход к метке ошибки. Таким образом реализуются условия и циклы.
Наконец мы можем подключиться к серверу. Системная фунция connect(2) принимает адрес структуры sockaddr_un в качестве входного аргумента, поскольку структура слишком большая для того чтобы поместиться в регистре.
Это первый системный вызов на нашем пути, который должен быть передан по указателю, другими словами — в виде адреса области памяти. Потому, что это ассемблер и мы творим что хотим!
Поскольку мы хотим сохранить все простым и быстрым, будем хранить все на стеке. И поскольку у нас есть целых 8Мб стека (согласно параметру limit на моей машине), этого хватит с запасом. На самом деле, наибольший объем памяти, который нам понадобится на стеке в этой программе это 32Кб.
Размер структуры sockaddr_un в памяти составляет 110 байт, поэтому мы резервируем 112 байт для выравнивания значения rsp кратному на 16.
Примечание:
В Nasm есть структуры данных, но они больше способ для описания именованных сдвигов (offsets) чем аналог структур из Си со специальным синтаксисом для доступа к определенным полям Мы записываем первые 2 байта этой структуры данных в AF_UNIX поскольку это Unix-сокет. Затем идет путь к сокету, который ожидается X11 в определенном формате. Мы хотим показать наше окно на первом мониторе, отсчет начинается с 0, поэтому полный путь выглядит как:
/tmp/.X11-unix/X0.
Анало на C, будет выглядеть как:
const sockaddr_un addr = {.sun_family = AF_UNIX,
.sun_path = "/tmp/.X11-unix/X0"};
const int res =
connect(x11_socket_fd, (const struct sockaddr *)&addr, sizeof(addr));
Как же перевести этот код в ассемблер, особенно строку? Мы можем установить каждый байт в значение каждого символа строки в структуре данных на стеке, вручную, один за другим.
Другой вариант это реализвать это использование rep movsb идиомы, который указывает процессору копировать символ из строки А в другую строку Б, N-раз.
Это именно то что нам надо!
Вот как это работает:
Кладем строку в секцию.rodata (такую же как секция data но "только для чтения")
Указываем адрес этой строки в rsi (в качестве источника)
Указываем адрес этой строки в структуру на стеке в rdi (в качестве назначения)
Устанавливаем значение rcx в числов байт которых надо скопировать
Используем cld для очистки флага DF , для того чтобы убедиться что копирование было произведено вперед (поскольку оно также может быть проведено и в обратную сторону)
Вызываем rep movsb и все работает!
Примерно так работает memcpy в Cи.
Примечание:
Это интересный случай: мы видим что некоторые инструкции ожидают нахождения своих параметров в определенных регистрах и нет пути это обойти. Поэтому необходимо планировать заранее и ожидать что эти регистры будут перезаписаны. Если необходимо сохранить оригинальные значения, то придется хранить их где-то еще, например в стеке (это называется spilling) или в других регистрах. Это более широкая тема аллокации регистров, с нарастающей сложностью! Но в маленьких функциях это тем не менее управляемо.
Первое, секция .rodata:
section .rodata
sun_path: db "/tmp/.X11-unix/X0", 0
static sun_path:data
Затем копируем строку:
mov WORD [rsp], AF_UNIX ; Set sockaddr_un.sun_family to AF_UNIX
; Fill sockaddr_un.sun_path with: "/tmp/.X11-unix/X0".
lea rsi, sun_path
mov r12, rdi ; Save the socket file descriptor in `rdi` in `r12`.
lea rdi, [rsp + 2]
cld ; Move forward
mov ecx, 19 ; Length is 19 with the null terminator.
rep movsb ; Copy.
Примечание:
ecx это 32-битный вариант регистра rcx, это значит что мы используем только нижние 32 бита из 64-битного регистра. Эта замечательная таблица содержит все формы всех регистров. Но будьте внимательны к возможным узким местам в случае использования значения лишь части регистра и использования всего регистра в дальнейшем. Остаток бит, которые не используются будут хранить какие-то устаревшие значения, которые тяжело отследить. Решение заключается в использовании movzx для забивания неиспользуемых байт нулями. Хорошее решение для визуальной оценки этого - использование команды info registers в gdb, которая покажет значение каждого регистра во всех формах например для rcx, она покажет значения rcx, ecx, cx, ch, cl.
Мы делаем системный вызов, проверяем возращаемое значение, выходим из программы если значение не равно 0 или возращаем полученный дескриптор сокета, который будет использоваться дальше по ходу работы программы во всех коммуникациях с X-сервером.
Все вместе это выглядит вот так:
; Create a UNIX domain socket and connect to the X11 server.
; @returns The socket file descriptor.
x11_connect_to_server:
static x11_connect_to_server:function
push rbp
mov rbp, rsp
; Open a Unix socket: socket(2).
mov rax, SYSCALL_SOCKET
mov rdi, AF_UNIX ; Unix socket.
mov rsi, SOCK_STREAM ; Stream oriented.
mov rdx, 0 ; Automatic protocol.
syscall
cmp rax, 0
jle die
mov rdi, rax ; Store socket fd in `rdi` for the remainder of the function.
sub rsp, 112 ; Store struct sockaddr_un on the stack.
mov WORD [rsp], AF_UNIX ; Set sockaddr_un.sun_family to AF_UNIX
; Fill sockaddr_un.sun_path with: "/tmp/.X11-unix/X0".
lea rsi, sun_path
mov r12, rdi ; Save the socket file descriptor in `rdi` in `r12`.
lea rdi, [rsp + 2]
cld ; Move forward
mov ecx, 19 ; Length is 19 with the null terminator.
rep movsb ; Copy.
; Connect to the server: connect(2).
mov rax, SYSCALL_CONNECT
mov rdi, r12
lea rsi, [rsp]
%define SIZEOF_SOCKADDR_UN 2+108
mov rdx, SIZEOF_SOCKADDR_UN
syscall
cmp rax, 0
jne die
mov rax, rdi ; Return the socket fd.
add rsp, 112
pop rbp
ret
Наконец мы готовы к взаимодействию с X-сервером!
Передача данных через сокет
Существует системный вызов send(2) для этого, но мы можем ради упрощения обойтись более общим вызовом write(2). Оба варианта работают.
%define SYSCALL_WRITE 1
Структура данных на C для установки соединения в случае успеха выглядит следующим образом:
typedef struct {
u8 order;
u8 pad1;
u16 major, minor;
u16 auth_proto, auth_data;
u16 pad2;
} x11_connection_req_t;
pad* поля могут быть пропущены, поскольку используются для выравнивания данных и их значения не читаются Х-сервером. Для установления соединения, необходимо задать значение порядка байт order в l, что означает little endian (от младшего к старшему) поскольку X11 можно указать как именно разбирать сообщение (big endian или little endian). Поскольку архитектура x64 имеет порядок байт little-endian и нам не надо использовать слой трансляции порядка байт, поэтому остановимся на little-endian.
Также необходимо установить значение для поля major , отвечающего за версию протокола, ставим число 11. Думаю читатели догадаются почему.
Вот так это выглядит на Си:
x11_connection_req_t req = {.order = 'l', .major = 11};
Размер этой структуры всего 12 байт, но поскольку мы будем читать ответ сервера, который немного больше ( примерно 14Кб по моим тестам), мы немедленно зарезервируем очень много места на стеке, 32Кб для большей безопасности:
sub rsp, 1<<15
mov BYTE [rsp + 0], 'l' ; Set order to 'l'.
mov WORD [rsp + 2], 11 ; Set major version to 11.
Затем отправляем эти данные на сервер:
; Send the handshake to the server: write(2).
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 12*8
syscall
cmp rax, 12*8 ; Check that all bytes were written.
jnz die
После этого, мы читаем ответ сервера, который должен быть в первых 8 байтах:
; Read the server response: read(2).
; Use the stack for the read buffer.
; The X11 server first replies with 8 bytes. Once these are read, it replies with a much bigger message.
mov rax, SYSCALL_READ
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 8
syscall
cmp rax, 8 ; Check that the server replied with 8 bytes.
jnz die
cmp BYTE [rsp], 1 ; Check that the server sent 'success' (first byte is 1).
jnz die
Первый байт в ответе сервера равен 0 в случае ошибки и 1 в случае успешного вызова ( и 2 для авторизации, но мы это не используем). Сервер отправляет большое сообщение с большим количеством общей информации, которая нам понадобится позже, поэтому мы сохраним значения некоторых полей в глобальных переменных, расположенных в секции .data
Для начала мы добавим вот эти переменные, каждая размером 4 байта:
section .data
id: dd 0
static id:data
id_base: dd 0
static id_base:data
id_mask: dd 0
static id_mask:data
root_visual_id: dd 0
static root_visual_id:data
Затем мы читаем ответ сервера и пропускаем ненужные части. Это требует увеличения указателя на динамическое значение, несколько раз. Замечу что поскольку мы не делаем каких-либо проверок, это будет просто огромная дыра безопасности для реализации атаки на переполнение буфера.
; Read the rest of the server response: read(2).
; Use the stack for the read buffer.
mov rax, SYSCALL_READ
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 1<<15
syscall
cmp rax, 0 ; Check that the server replied with something.
jle die
; Set id_base globally.
mov edx, DWORD [rsp + 4]
mov DWORD [id_base], edx
; Set id_mask globally.
mov edx, DWORD [rsp + 8]
mov DWORD [id_mask], edx
; Read the information we need, skip over the rest.
lea rdi, [rsp] ; Pointer that will skip over some data.
mov cx, WORD [rsp + 16] ; Vendor length (v).
movzx rcx, cx
mov al, BYTE [rsp + 21]; Number of formats (n).
movzx rax, al ; Fill the rest of the register with zeroes to avoid garbage values.
imul rax, 8 ; sizeof(format) == 8
add rdi, 32 ; Skip the connection setup
add rdi, rcx ; Skip over the vendor information (v).
; Skip over padding.
add rdi, 3
and rdi, -4
add rdi, rax ; Skip over the format information (n*8).
mov eax, DWORD [rdi] ; Store (and return) the window root id.
; Set the root_visual_id globally.
mov edx, DWORD [rdi + 32]
mov DWORD [root_visual_id], edx
Небольшое примечания по поводу выравнивания (padding) от особо умного читателя:
То как мы пропукаем выравнивание лишь часть «оптимизации» которую мы себе разрешили, поскольку некоторые поля в X11-протоколе имеют переменную длину. Но сам протокол раскидывает все по блокам в 4 байта. Это означает что если длина поля составляет 5 байт, согласно протоколу будет 3 байта выравнивания (которые должны быть пропущены приложением), поэтому значение этого поля займет два блока по 4 байта.
Ну и как спрашивается все это повторить в ассемблере? Спецификация использует деление и операции по модулю, но все это слишком муторно для реализации на чистом ассемблере. Поэтому мы пойдем другим путем. БиблиотекаlibX11 использует вот такой макрос:
#define ROUNDUP(nbytes, pad) (((nbytes) + ((pad)-1)) & ~(long)((pad)-1))
Который используется следующим образом:
assert(ROUNDUP(0, 4) == 0);
assert(ROUNDUP(1, 4) == 4);
assert(ROUNDUP(2, 4) == 4);
assert(ROUNDUP(3, 4) == 4);
assert(ROUNDUP(4, 4) == 4);
assert(ROUNDUP(5, 4) == 8);
// etc
Это работает, но немного сложновато.
Если мы посмотрим на вывод после компиляции, то увидим что gcc оптимизирует этот макрос:
add eax, 3
and eax, -4
Именно такой вариант мы и будем использовать. Все вместе:
; Send the handshake to the X11 server and read the returned system information.
; @param rdi The socket file descriptor
; @returns The window root id (uint32_t) in rax.
x11_send_handshake:
static x11_send_handshake:function
push rbp
mov rbp, rsp
sub rsp, 1<<15
mov BYTE [rsp + 0], 'l' ; Set order to 'l'.
mov WORD [rsp + 2], 11 ; Set major version to 11.
; Send the handshake to the server: write(2).
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 12*8
syscall
cmp rax, 12*8 ; Check that all bytes were written.
jnz die
; Read the server response: read(2).
; Use the stack for the read buffer.
; The X11 server first replies with 8 bytes. Once these are read, it replies with a much bigger message.
mov rax, SYSCALL_READ
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 8
syscall
cmp rax, 8 ; Check that the server replied with 8 bytes.
jnz die
cmp BYTE [rsp], 1 ; Check that the server sent 'success' (first byte is 1).
jnz die
; Read the rest of the server response: read(2).
; Use the stack for the read buffer.
mov rax, SYSCALL_READ
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 1<<15
syscall
cmp rax, 0 ; Check that the server replied with something.
jle die
; Set id_base globally.
mov edx, DWORD [rsp + 4]
mov DWORD [id_base], edx
; Set id_mask globally.
mov edx, DWORD [rsp + 8]
mov DWORD [id_mask], edx
; Read the information we need, skip over the rest.
lea rdi, [rsp] ; Pointer that will skip over some data.
mov cx, WORD [rsp + 16] ; Vendor length (v).
movzx rcx, cx
mov al, BYTE [rsp + 21]; Number of formats (n).
movzx rax, al ; Fill the rest of the register with zeroes to avoid garbage values.
imul rax, 8 ; sizeof(format) == 8
add rdi, 32 ; Skip the connection setup
add rdi, rcx ; Skip over the vendor information (v).
; Skip over padding.
add rdi, 3
and rdi, -4
add rdi, rax ; Skip over the format information (n*8).
mov eax, DWORD [rdi] ; Store (and return) the window root id.
; Set the root_visual_id globally.
mov edx, DWORD [rdi + 32]
mov DWORD [root_visual_id], edx
add rsp, 1<<15
pop rbp
ret
C этого места, я буду полагать что вы знакомы с основами ассемблера и протокола Х11 и больше не буду так сильно углубляться в детали.
Генерация id
Создавая ресурсы на стороне Х-сервера, мы обычно сначала генерируем id ресурса на клиентской стороне и затем передаем его серверу при создании.
Мы будем хранить текущее значение id в глобальной переменной и увеличивать его при каждой генерации нового идентификатора. Вот как это выглядит:
; Increment the global id.
; @return The new id.
x11_next_id:
static x11_next_id:function
push rbp
mov rbp, rsp
mov eax, DWORD [id] ; Load global id.
mov edi, DWORD [id_base] ; Load global id_base.
mov edx, DWORD [id_mask] ; Load global id_mask.
; Return: id_mask & (id) | id_base
and eax, edx
or eax, edi
add DWORD [id], 1 ; Increment id.
pop rbp
ret
Использование шрифтов
Для того чтобы подгрузить шрифт, что является требованием для отрисовки текста, мы отправляем сообщение на сервер, указав (часть) названия шрифта, который нам нужен. Сервер сам выберет подходящий шрифт.
Для использования других шрифтов, можете использовать утилиту xfontsel , которая показывает все названия всех известных серверу шрифтов.
Первым делом мы генерируем id для шрифта локально, затем передаем серверу вместе с названием шрифта:
; Open the font on the server side.
; @param rdi The socket file descriptor.
; @param esi The font id.
x11_open_font:
static x11_open_font:function
push rbp
mov rbp, rsp
%define OPEN_FONT_NAME_BYTE_COUNT 5
%define OPEN_FONT_PADDING ((4 - (OPEN_FONT_NAME_BYTE_COUNT % 4)) % 4)
%define OPEN_FONT_PACKET_U32_COUNT (3 + (OPEN_FONT_NAME_BYTE_COUNT + OPEN_FONT_PADDING) / 4)
%define X11_OP_REQ_OPEN_FONT 0x2d
sub rsp, 6*8
mov DWORD [rsp + 0*4], X11_OP_REQ_OPEN_FONT | (OPEN_FONT_NAME_BYTE_COUNT << 16)
mov DWORD [rsp + 1*4], esi
mov DWORD [rsp + 2*4], OPEN_FONT_NAME_BYTE_COUNT
mov BYTE [rsp + 3*4 + 0], 'f'
mov BYTE [rsp + 3*4 + 1], 'i'
mov BYTE [rsp + 3*4 + 2], 'x'
mov BYTE [rsp + 3*4 + 3], 'e'
mov BYTE [rsp + 3*4 + 4], 'd'
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, OPEN_FONT_PACKET_U32_COUNT*4
syscall
cmp rax, OPEN_FONT_PACKET_U32_COUNT*4
jnz die
add rsp, 6*8
pop rbp
ret
Создание графического контекста
Поскольку Х11-приложение может иметь несколько окон, первым делом нам надо создать графический контекст, содержащий общую информацию. При создании окна, мы будем ссылаться на этот контекст по id.
Повторюсь, нам нужно сгенерировать id для создания графического контекста.
Х11 хранит иерархию окон, поэтому при создании графического контекста нам надо указать id родительского окна (root window id).
; Create a X11 graphical context.
; @param rdi The socket file descriptor.
; @param esi The graphical context id.
; @param edx The window root id.
; @param ecx The font id.
x11_create_gc:
static x11_create_gc:function
push rbp
mov rbp, rsp
sub rsp, 8*8
%define X11_OP_REQ_CREATE_GC 0x37
%define X11_FLAG_GC_BG 0x00000004
%define X11_FLAG_GC_FG 0x00000008
%define X11_FLAG_GC_FONT 0x00004000
%define X11_FLAG_GC_EXPOSE 0x00010000
%define CREATE_GC_FLAGS X11_FLAG_GC_BG | X11_FLAG_GC_FG | X11_FLAG_GC_FONT
%define CREATE_GC_PACKET_FLAG_COUNT 3
%define CREATE_GC_PACKET_U32_COUNT (4 + CREATE_GC_PACKET_FLAG_COUNT)
%define MY_COLOR_RGB 0x0000ffff
mov DWORD [rsp + 0*4], X11_OP_REQ_CREATE_GC | (CREATE_GC_PACKET_U32_COUNT<<16)
mov DWORD [rsp + 1*4], esi
mov DWORD [rsp + 2*4], edx
mov DWORD [rsp + 3*4], CREATE_GC_FLAGS
mov DWORD [rsp + 4*4], MY_COLOR_RGB
mov DWORD [rsp + 5*4], 0
mov DWORD [rsp + 6*4], ecx
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, CREATE_GC_PACKET_U32_COUNT*4
syscall
cmp rax, CREATE_GC_PACKET_U32_COUNT*4
jnz die
add rsp, 8*8
pop rbp
ret
Создание окна
Следующим шагом, создаем окно, которое ссылается на созданный выше графический контекст.
При создании окна мы указываем требуемые координаты x и y левого верхнего угла а также длину и ширину.
Замечу что все это лишь «подсказки» для сервера и созданное окно может иметь другие координаты и размеры, например при использовании tiling window manager или при ручном изменении размеров окна.
; Create the X11 window.
; @param rdi The socket file descriptor.
; @param esi The new window id.
; @param edx The window root id.
; @param ecx The root visual id.
; @param r8d Packed x and y.
; @param r9d Packed w and h.
x11_create_window:
static x11_create_window:function
push rbp
mov rbp, rsp
%define X11_OP_REQ_CREATE_WINDOW 0x01
%define X11_FLAG_WIN_BG_COLOR 0x00000002
%define X11_EVENT_FLAG_KEY_RELEASE 0x0002
%define X11_EVENT_FLAG_EXPOSURE 0x8000
%define X11_FLAG_WIN_EVENT 0x00000800
%define CREATE_WINDOW_FLAG_COUNT 2
%define CREATE_WINDOW_PACKET_U32_COUNT (8 + CREATE_WINDOW_FLAG_COUNT)
%define CREATE_WINDOW_BORDER 1
%define CREATE_WINDOW_GROUP 1
sub rsp, 12*8
mov DWORD [rsp + 0*4], X11_OP_REQ_CREATE_WINDOW | (CREATE_WINDOW_PACKET_U32_COUNT << 16)
mov DWORD [rsp + 1*4], esi
mov DWORD [rsp + 2*4], edx
mov DWORD [rsp + 3*4], r8d
mov DWORD [rsp + 4*4], r9d
mov DWORD [rsp + 5*4], CREATE_WINDOW_GROUP | (CREATE_WINDOW_BORDER << 16)
mov DWORD [rsp + 6*4], ecx
mov DWORD [rsp + 7*4], X11_FLAG_WIN_BG_COLOR | X11_FLAG_WIN_EVENT
mov DWORD [rsp + 8*4], 0
mov DWORD [rsp + 9*4], X11_EVENT_FLAG_KEY_RELEASE | X11_EVENT_FLAG_EXPOSURE
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, CREATE_WINDOW_PACKET_U32_COUNT*4
syscall
cmp rax, CREATE_WINDOW_PACKET_U32_COUNT*4
jnz die
add rsp, 12*8
pop rbp
ret
Связывание окна
Если вы следовали всем инструкциям выше и только что попытались запустить программу, могли заметить что ничего не отображается. Это происходит потому что Х11 не показывает окно до тех пор пока оно не будет связано (mapped).
Связывание делается вот таким отдельным сообщением:
; Map a X11 window.
; @param rdi The socket file descriptor.
; @param esi The window id.
x11_map_window:
static x11_map_window:function
push rbp
mov rbp, rsp
sub rsp, 16
%define X11_OP_REQ_MAP_WINDOW 0x08
mov DWORD [rsp + 0*4], X11_OP_REQ_MAP_WINDOW | (2<<16)
mov DWORD [rsp + 1*4], esi
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 2*4
syscall
cmp rax, 2*4
jnz die
add rsp, 16
pop rbp
ret
Теперь у нас есть черное окно, ура!
Поллинг сообщений сервера
Мы собираемся лишь отобразить текст в окне, но даже для этого нужно ждать получения события Expose, что означает что созданное окно видимо и готово для использования.
Нам необходимо получать все сообщения от сервера на самом деле, в не зависимости от того ошибки это или события, например когда пользователь нажимает клавишу на клавиатуре.
Если мы используем простой блокирующий read(2), но сервер ничего не пришлет, программа перестанет отвечать. Не хорошо. Решением является использование системного вызова poll(2) , который будет пробуждаться самой операционной системой когда необходимо прочитать данные из сокета. Аналогично устроены например NodeJS или Nginx.
Примечание:
Особо отбитый читатель указал на то что мы можем просто читать из сокета в цикле, возможно с таймаутом, поскольку он (цикл) у нас в проекте все равно один. Linux и возможно другие ОС поддерживают установку таймаута чтения для сокета с помощью вызова setsockopt(2). Но я сохраню текущую версию в статье, поскольку это п@здец и экспериментируйте сами с этим говном.
Для начала пометим сокет как «неблокирующий», поскольку по-умолчанию включен блокирующий режим:
; Set a file descriptor in non-blocking mode.
; @param rdi The file descriptor.
set_fd_non_blocking:
static set_fd_non_blocking:function
push rbp
mov rbp, rsp
mov rax, SYSCALL_FCNTL
mov rdi, rdi
mov rsi, F_GETFL
mov rdx, 0
syscall
cmp rax, 0
jl die
; `or` the current file status flag with O_NONBLOCK.
mov rdx, rax
or rdx, O_NONBLOCK
mov rax, SYSCALL_FCNTL
mov rdi, rdi
mov rsi, F_SETFL
mov rdx, rdx
syscall
cmp rax, 0
jl die
pop rbp
ret
Затем мы напишем маленькую функцию для чтения данных из сокета. Для упрощения мы читаем лишь первые 32 байта данных, поскольку большинство сообщений Х11-сервера укладываются в этот лимит. Также мы возвращаем первый байт, который содержит тип события.
; Read the X11 server reply.
; @return The message code in al.
x11_read_reply:
static x11_read_reply:function
push rbp
mov rbp, rsp
sub rsp, 32
mov rax, SYSCALL_READ
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 32
syscall
cmp rax, 1
jle die
mov al, BYTE [rsp]
add rsp, 32
pop rbp
ret
Включаем поллинг. Если произойдет ошибка или другая сторона закроет сокет - мы просто завершим программу.
; Poll indefinitely messages from the X11 server with poll(2).
; @param rdi The socket file descriptor.
; @param esi The window id.
; @param edx The gc id.
poll_messages:
static poll_messages:function
push rbp
mov rbp, rsp
sub rsp, 32
%define POLLIN 0x001
%define POLLPRI 0x002
%define POLLOUT 0x004
%define POLLERR 0x008
%define POLLHUP 0x010
%define POLLNVAL 0x020
mov DWORD [rsp + 0*4], edi
mov DWORD [rsp + 1*4], POLLIN
mov DWORD [rsp + 16], esi ; window id
mov DWORD [rsp + 20], edx ; gc id
.loop:
mov rax, SYSCALL_POLL
lea rdi, [rsp]
mov rsi, 1
mov rdx, -1
syscall
cmp rax, 0
jle die
cmp DWORD [rsp + 2*4], POLLERR
je die
cmp DWORD [rsp + 2*4], POLLHUP
je die
mov rdi, [rsp + 0*4]
call x11_read_reply
jmp .loop
add rsp, 32
pop rbp
ret
Отрисовка текста
Наконец переходим к отрисовке текста. Небольшой сложностью является тот факт что текст обычно имеет неизвестную длину, поэтому нам надо вычислить размер Х11-сообщения, включая выравнивание в конце. До этого момента мы имели дело только с сообщениями фиксированной длины.
Официальная документация содержит формулы для вычисления этих значений.
; Draw text in a X11 window with server-side text rendering.
; @param rdi The socket file descriptor.
; @param rsi The text string.
; @param edx The text string length in bytes.
; @param ecx The window id.
; @param r8d The gc id.
; @param r9d Packed x and y.
x11_draw_text:
static x11_draw_text:function
push rbp
mov rbp, rsp
sub rsp, 1024
mov DWORD [rsp + 1*4], ecx ; Store the window id directly in the packet data on the stack.
mov DWORD [rsp + 2*4], r8d ; Store the gc id directly in the packet data on the stack.
mov DWORD [rsp + 3*4], r9d ; Store x, y directly in the packet data on the stack.
mov r8d, edx ; Store the string length in r8 since edx will be overwritten next.
mov QWORD [rsp + 1024 - 8], rdi ; Store the socket file descriptor on the stack to free the register.
; Compute padding and packet u32 count with division and modulo 4.
mov eax, edx ; Put dividend in eax.
mov ecx, 4 ; Put divisor in ecx.
cdq ; Sign extend.
idiv ecx ; Compute eax / ecx, and put the remainder (i.e. modulo) in edx.
; LLVM optimizer magic: `(4-x)%4 == -x & 3`, for some reason.
neg edx
and edx, 3
mov r9d, edx ; Store padding in r9.
mov eax, r8d
add eax, r9d
shr eax, 2 ; Compute: eax /= 4
add eax, 4 ; eax now contains the packet u32 count.
%define X11_OP_REQ_IMAGE_TEXT8 0x4c
mov DWORD [rsp + 0*4], r8d
shl DWORD [rsp + 0*4], 8
or DWORD [rsp + 0*4], X11_OP_REQ_IMAGE_TEXT8
mov ecx, eax
shl ecx, 16
or [rsp + 0*4], ecx
; Copy the text string into the packet data on the stack.
mov rsi, rsi ; Source string in rsi.
lea rdi, [rsp + 4*4] ; Destination
cld ; Move forward
mov ecx, r8d ; String length.
rep movsb ; Copy.
mov rdx, rax ; packet u32 count
imul rdx, 4
mov rax, SYSCALL_WRITE
mov rdi, QWORD [rsp + 1024 - 8] ; fd
lea rsi, [rsp]
syscall
cmp rax, rdx
jnz die
add rsp, 1024
pop rbp
ret
Затем мы вызываем эту функцию внутри цикла поллинга и сохраняем состояние в булевой переменной на стеке, для определения надо ли отрисовывать текст или нет:
%define X11_EVENT_EXPOSURE 0xc
cmp eax, X11_EVENT_EXPOSURE
jnz .received_other_event
.received_exposed_event:
mov BYTE [rsp + 24], 1 ; Mark as exposed.
.received_other_event:
cmp BYTE [rsp + 24], 1 ; exposed?
jnz .loop
.draw_text:
mov rdi, [rsp + 0*4] ; socket fd
lea rsi, [hello_world] ; string
mov edx, 13 ; length
mov ecx, [rsp + 16] ; window id
mov r8d, [rsp + 20] ; gc id
mov r9d, 100 ; x
shl r9d, 16
or r9d, 100 ; y
call x11_draw_text
Наконец мы можем увидеть наше сообщение Hello, world! отображаемое внутри окна.
Конец
Это было долго, но мы справились! Мы написали (очень простую) программу с графическим интерфейсом на чистом ассемблере, без каких-либо зависимостей и уложившись в 600 строк кода.
Как далеко мы можем зайти в оптимизации бинарника? C отладочной информацией: 10744 байт (10 Кб) Без отладочной информации (stripped): 8592 байт (8 Кб) С оптимизациями stripped and OMAGIC (–omagic это ключ линковщика, из рукводства: Set the text and data sections to be readable and writable. Also, do not page-align the data segment): 1776 байт (1 Kб)
Вообщем вот такая программка с интерфейсом размером в 1 Кб.
Надеюсь вам понравилось и вы получили не меньше удовольствия от чтения чем автор от написания этой статьи.
Полный исходный код
Ниже приведен полный исходный код:
; Build with: nasm -f elf64 -g main.nasm && ld main.o -static -o main
BITS 64 ; 64 bits.
CPU X64 ; Target the x86_64 family of CPUs.
section .rodata
sun_path: db "/tmp/.X11-unix/X0", 0
static sun_path:data
hello_world: db "Hello, world!"
static hello_world:data
section .data
id: dd 0
static id:data
id_base: dd 0
static id_base:data
id_mask: dd 0
static id_mask:data
root_visual_id: dd 0
static root_visual_id:data
section .text
%define AF_UNIX 1
%define SOCK_STREAM 1
%define SYSCALL_READ 0
%define SYSCALL_WRITE 1
%define SYSCALL_POLL 7
%define SYSCALL_SOCKET 41
%define SYSCALL_CONNECT 42
%define SYSCALL_EXIT 60
%define SYSCALL_FCNTL 72
; Create a UNIX domain socket and connect to the X11 server.
; @returns The socket file descriptor.
x11_connect_to_server:
static x11_connect_to_server:function
push rbp
mov rbp, rsp
; Open a Unix socket: socket(2).
mov rax, SYSCALL_SOCKET
mov rdi, AF_UNIX ; Unix socket.
mov rsi, SOCK_STREAM ; Stream oriented.
mov rdx, 0 ; Automatic protocol.
syscall
cmp rax, 0
jle die
mov rdi, rax ; Store socket fd in `rdi` for the remainder of the function.
sub rsp, 112 ; Store struct sockaddr_un on the stack.
mov WORD [rsp], AF_UNIX ; Set sockaddr_un.sun_family to AF_UNIX
; Fill sockaddr_un.sun_path with: "/tmp/.X11-unix/X0".
lea rsi, sun_path
mov r12, rdi ; Save the socket file descriptor in `rdi` in `r12`.
lea rdi, [rsp + 2]
cld ; Move forward
mov ecx, 19 ; Length is 19 with the null terminator.
rep movsb ; Copy.
; Connect to the server: connect(2).
mov rax, SYSCALL_CONNECT
mov rdi, r12
lea rsi, [rsp]
%define SIZEOF_SOCKADDR_UN 2+108
mov rdx, SIZEOF_SOCKADDR_UN
syscall
cmp rax, 0
jne die
mov rax, rdi ; Return the socket fd.
add rsp, 112
pop rbp
ret
; Send the handshake to the X11 server and read the returned system information.
; @param rdi The socket file descriptor
; @returns The window root id (uint32_t) in rax.
x11_send_handshake:
static x11_send_handshake:function
push rbp
mov rbp, rsp
sub rsp, 1<<15
mov BYTE [rsp + 0], 'l' ; Set order to 'l'.
mov WORD [rsp + 2], 11 ; Set major version to 11.
; Send the handshake to the server: write(2).
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 12*8
syscall
cmp rax, 12*8 ; Check that all bytes were written.
jnz die
; Read the server response: read(2).
; Use the stack for the read buffer.
; The X11 server first replies with 8 bytes. Once these are read, it replies with a much bigger message.
mov rax, SYSCALL_READ
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 8
syscall
cmp rax, 8 ; Check that the server replied with 8 bytes.
jnz die
cmp BYTE [rsp], 1 ; Check that the server sent 'success' (first byte is 1).
jnz die
; Read the rest of the server response: read(2).
; Use the stack for the read buffer.
mov rax, SYSCALL_READ
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 1<<15
syscall
cmp rax, 0 ; Check that the server replied with something.
jle die
; Set id_base globally.
mov edx, DWORD [rsp + 4]
mov DWORD [id_base], edx
; Set id_mask globally.
mov edx, DWORD [rsp + 8]
mov DWORD [id_mask], edx
; Read the information we need, skip over the rest.
lea rdi, [rsp] ; Pointer that will skip over some data.
mov cx, WORD [rsp + 16] ; Vendor length (v).
movzx rcx, cx
mov al, BYTE [rsp + 21]; Number of formats (n).
movzx rax, al ; Fill the rest of the register with zeroes to avoid garbage values.
imul rax, 8 ; sizeof(format) == 8
add rdi, 32 ; Skip the connection setup
; Skip over padding.
add rdi, 3
and rdi, -4
add rdi, rcx ; Skip over the vendor information (v).
add rdi, rax ; Skip over the format information (n*8).
mov eax, DWORD [rdi] ; Store (and return) the window root id.
; Set the root_visual_id globally.
mov edx, DWORD [rdi + 32]
mov DWORD [root_visual_id], edx
add rsp, 1<<15
pop rbp
ret
; Increment the global id.
; @return The new id.
x11_next_id:
static x11_next_id:function
push rbp
mov rbp, rsp
mov eax, DWORD [id] ; Load global id.
mov edi, DWORD [id_base] ; Load global id_base.
mov edx, DWORD [id_mask] ; Load global id_mask.
; Return: id_mask & (id) | id_base
and eax, edx
or eax, edi
add DWORD [id], 1 ; Increment id.
pop rbp
ret
; Open the font on the server side.
; @param rdi The socket file descriptor.
; @param esi The font id.
x11_open_font:
static x11_open_font:function
push rbp
mov rbp, rsp
%define OPEN_FONT_NAME_BYTE_COUNT 5
%define OPEN_FONT_PADDING ((4 - (OPEN_FONT_NAME_BYTE_COUNT % 4)) % 4)
%define OPEN_FONT_PACKET_U32_COUNT (3 + (OPEN_FONT_NAME_BYTE_COUNT + OPEN_FONT_PADDING) / 4)
%define X11_OP_REQ_OPEN_FONT 0x2d
sub rsp, 6*8
mov DWORD [rsp + 0*4], X11_OP_REQ_OPEN_FONT | (OPEN_FONT_NAME_BYTE_COUNT << 16)
mov DWORD [rsp + 1*4], esi
mov DWORD [rsp + 2*4], OPEN_FONT_NAME_BYTE_COUNT
mov BYTE [rsp + 3*4 + 0], 'f'
mov BYTE [rsp + 3*4 + 1], 'i'
mov BYTE [rsp + 3*4 + 2], 'x'
mov BYTE [rsp + 3*4 + 3], 'e'
mov BYTE [rsp + 3*4 + 4], 'd'
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, OPEN_FONT_PACKET_U32_COUNT*4
syscall
cmp rax, OPEN_FONT_PACKET_U32_COUNT*4
jnz die
add rsp, 6*8
pop rbp
ret
; Create a X11 graphical context.
; @param rdi The socket file descriptor.
; @param esi The graphical context id.
; @param edx The window root id.
; @param ecx The font id.
x11_create_gc:
static x11_create_gc:function
push rbp
mov rbp, rsp
sub rsp, 8*8
%define X11_OP_REQ_CREATE_GC 0x37
%define X11_FLAG_GC_BG 0x00000004
%define X11_FLAG_GC_FG 0x00000008
%define X11_FLAG_GC_FONT 0x00004000
%define X11_FLAG_GC_EXPOSE 0x00010000
%define CREATE_GC_FLAGS X11_FLAG_GC_BG | X11_FLAG_GC_FG | X11_FLAG_GC_FONT
%define CREATE_GC_PACKET_FLAG_COUNT 3
%define CREATE_GC_PACKET_U32_COUNT (4 + CREATE_GC_PACKET_FLAG_COUNT)
%define MY_COLOR_RGB 0x0000ffff
mov DWORD [rsp + 0*4], X11_OP_REQ_CREATE_GC | (CREATE_GC_PACKET_U32_COUNT<<16)
mov DWORD [rsp + 1*4], esi
mov DWORD [rsp + 2*4], edx
mov DWORD [rsp + 3*4], CREATE_GC_FLAGS
mov DWORD [rsp + 4*4], MY_COLOR_RGB
mov DWORD [rsp + 5*4], 0
mov DWORD [rsp + 6*4], ecx
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, CREATE_GC_PACKET_U32_COUNT*4
syscall
cmp rax, CREATE_GC_PACKET_U32_COUNT*4
jnz die
add rsp, 8*8
pop rbp
ret
; Create the X11 window.
; @param rdi The socket file descriptor.
; @param esi The new window id.
; @param edx The window root id.
; @param ecx The root visual id.
; @param r8d Packed x and y.
; @param r9d Packed w and h.
x11_create_window:
static x11_create_window:function
push rbp
mov rbp, rsp
%define X11_OP_REQ_CREATE_WINDOW 0x01
%define X11_FLAG_WIN_BG_COLOR 0x00000002
%define X11_EVENT_FLAG_KEY_RELEASE 0x0002
%define X11_EVENT_FLAG_EXPOSURE 0x8000
%define X11_FLAG_WIN_EVENT 0x00000800
%define CREATE_WINDOW_FLAG_COUNT 2
%define CREATE_WINDOW_PACKET_U32_COUNT (8 + CREATE_WINDOW_FLAG_COUNT)
%define CREATE_WINDOW_BORDER 1
%define CREATE_WINDOW_GROUP 1
sub rsp, 12*8
mov DWORD [rsp + 0*4], X11_OP_REQ_CREATE_WINDOW | (CREATE_WINDOW_PACKET_U32_COUNT << 16)
mov DWORD [rsp + 1*4], esi
mov DWORD [rsp + 2*4], edx
mov DWORD [rsp + 3*4], r8d
mov DWORD [rsp + 4*4], r9d
mov DWORD [rsp + 5*4], CREATE_WINDOW_GROUP | (CREATE_WINDOW_BORDER << 16)
mov DWORD [rsp + 6*4], ecx
mov DWORD [rsp + 7*4], X11_FLAG_WIN_BG_COLOR | X11_FLAG_WIN_EVENT
mov DWORD [rsp + 8*4], 0
mov DWORD [rsp + 9*4], X11_EVENT_FLAG_KEY_RELEASE | X11_EVENT_FLAG_EXPOSURE
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, CREATE_WINDOW_PACKET_U32_COUNT*4
syscall
cmp rax, CREATE_WINDOW_PACKET_U32_COUNT*4
jnz die
add rsp, 12*8
pop rbp
ret
; Map a X11 window.
; @param rdi The socket file descriptor.
; @param esi The window id.
x11_map_window:
static x11_map_window:function
push rbp
mov rbp, rsp
sub rsp, 16
%define X11_OP_REQ_MAP_WINDOW 0x08
mov DWORD [rsp + 0*4], X11_OP_REQ_MAP_WINDOW | (2<<16)
mov DWORD [rsp + 1*4], esi
mov rax, SYSCALL_WRITE
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 2*4
syscall
cmp rax, 2*4
jnz die
add rsp, 16
pop rbp
ret
; Read the X11 server reply.
; @return The message code in al.
x11_read_reply:
static x11_read_reply:function
push rbp
mov rbp, rsp
sub rsp, 32
mov rax, SYSCALL_READ
mov rdi, rdi
lea rsi, [rsp]
mov rdx, 32
syscall
cmp rax, 1
jle die
mov al, BYTE [rsp]
add rsp, 32
pop rbp
ret
die:
mov rax, SYSCALL_EXIT
mov rdi, 1
syscall
; Set a file descriptor in non-blocking mode.
; @param rdi The file descriptor.
set_fd_non_blocking:
static set_fd_non_blocking:function
push rbp
mov rbp, rsp
%define F_GETFL 3
%define F_SETFL 4
%define O_NONBLOCK 2048
mov rax, SYSCALL_FCNTL
mov rdi, rdi
mov rsi, F_GETFL
mov rdx, 0
syscall
cmp rax, 0
jl die
; `or` the current file status flag with O_NONBLOCK.
mov rdx, rax
or rdx, O_NONBLOCK
mov rax, SYSCALL_FCNTL
mov rdi, rdi
mov rsi, F_SETFL
mov rdx, rdx
syscall
cmp rax, 0
jl die
pop rbp
ret
; Poll indefinitely messages from the X11 server with poll(2).
; @param rdi The socket file descriptor.
; @param esi The window id.
; @param edx The gc id.
poll_messages:
static poll_messages:function
push rbp
mov rbp, rsp
sub rsp, 32
%define POLLIN 0x001
%define POLLPRI 0x002
%define POLLOUT 0x004
%define POLLERR 0x008
%define POLLHUP 0x010
%define POLLNVAL 0x020
mov DWORD [rsp + 0*4], edi
mov DWORD [rsp + 1*4], POLLIN
mov DWORD [rsp + 16], esi ; window id
mov DWORD [rsp + 20], edx ; gc id
mov BYTE [rsp + 24], 0 ; exposed? (boolean)
.loop:
mov rax, SYSCALL_POLL
lea rdi, [rsp]
mov rsi, 1
mov rdx, -1
syscall
cmp rax, 0
jle die
cmp DWORD [rsp + 2*4], POLLERR
je die
cmp DWORD [rsp + 2*4], POLLHUP
je die
mov rdi, [rsp + 0*4]
call x11_read_reply
%define X11_EVENT_EXPOSURE 0xc
cmp eax, X11_EVENT_EXPOSURE
jnz .received_other_event
.received_exposed_event:
mov BYTE [rsp + 24], 1 ; Mark as exposed.
.received_other_event:
cmp BYTE [rsp + 24], 1 ; exposed?
jnz .loop
.draw_text:
mov rdi, [rsp + 0*4] ; socket fd
lea rsi, [hello_world] ; string
mov edx, 13 ; length
mov ecx, [rsp + 16] ; window id
mov r8d, [rsp + 20] ; gc id
mov r9d, 100 ; x
shl r9d, 16
or r9d, 100 ; y
call x11_draw_text
jmp .loop
add rsp, 32
pop rbp
ret
; Draw text in a X11 window with server-side text rendering.
; @param rdi The socket file descriptor.
; @param rsi The text string.
; @param edx The text string length in bytes.
; @param ecx The window id.
; @param r8d The gc id.
; @param r9d Packed x and y.
x11_draw_text:
static x11_draw_text:function
push rbp
mov rbp, rsp
sub rsp, 1024
mov DWORD [rsp + 1*4], ecx ; Store the window id directly in the packet data on the stack.
mov DWORD [rsp + 2*4], r8d ; Store the gc id directly in the packet data on the stack.
mov DWORD [rsp + 3*4], r9d ; Store x, y directly in the packet data on the stack.
mov r8d, edx ; Store the string length in r8 since edx will be overwritten next.
mov QWORD [rsp + 1024 - 8], rdi ; Store the socket file descriptor on the stack to free the register.
; Compute padding and packet u32 count with division and modulo 4.
mov eax, edx ; Put dividend in eax.
mov ecx, 4 ; Put divisor in ecx.
cdq ; Sign extend.
idiv ecx ; Compute eax / ecx, and put the remainder (i.e. modulo) in edx.
; LLVM optimizer magic: `(4-x)%4 == -x & 3`, for some reason.
neg edx
and edx, 3
mov r9d, edx ; Store padding in r9.
mov eax, r8d
add eax, r9d
shr eax, 2 ; Compute: eax /= 4
add eax, 4 ; eax now contains the packet u32 count.
%define X11_OP_REQ_IMAGE_TEXT8 0x4c
mov DWORD [rsp + 0*4], r8d
shl DWORD [rsp + 0*4], 8
or DWORD [rsp + 0*4], X11_OP_REQ_IMAGE_TEXT8
mov ecx, eax
shl ecx, 16
or [rsp + 0*4], ecx
; Copy the text string into the packet data on the stack.
mov rsi, rsi ; Source string in rsi.
lea rdi, [rsp + 4*4] ; Destination
cld ; Move forward
mov ecx, r8d ; String length.
rep movsb ; Copy.
mov rdx, rax ; packet u32 count
imul rdx, 4
mov rax, SYSCALL_WRITE
mov rdi, QWORD [rsp + 1024 - 8] ; fd
lea rsi, [rsp]
syscall
cmp rax, rdx
jnz die
add rsp, 1024
pop rbp
ret
_start:
global _start:function
call x11_connect_to_server
mov r15, rax ; Store the socket file descriptor in r15.
mov rdi, rax
call x11_send_handshake
mov r12d, eax ; Store the window root id in r12.
call x11_next_id
mov r13d, eax ; Store the gc_id in r13.
call x11_next_id
mov r14d, eax ; Store the font_id in r14.
mov rdi, r15
mov esi, r14d
call x11_open_font
mov rdi, r15
mov esi, r13d
mov edx, r12d
mov ecx, r14d
call x11_create_gc
call x11_next_id
mov ebx, eax ; Store the window id in ebx.
mov rdi, r15 ; socket fd
mov esi, eax
mov edx, r12d
mov ecx, [root_visual_id]
mov r8d, 200 | (200 << 16) ; x and y are 200
%define WINDOW_W 800
%define WINDOW_H 600
mov r9d, WINDOW_W | (WINDOW_H << 16)
call x11_create_window
mov rdi, r15 ; socket fd
mov esi, ebx
call x11_map_window
mov rdi, r15 ; socket fd
call set_fd_non_blocking
mov rdi, r15 ; socket fd
mov esi, ebx ; window id
mov edx, r13d ; gc id
call poll_messages
; The end.
mov rax, SYSCALL_EXIT
mov rdi, 0
syscall