Линукс, ассемблер и X11
Слегка устав от засилия жирных современных фреймворков и «продвинутых» технологий, решил устроить себе день психического здоровья. Ниже будет полный «back-to-roots»: чистый ассемблер и открытие окна в X-сервере, на линуксе. Никаких библиотек, фрейворков и виртуальных машин.
Вступление
Скажу сразу: все что ниже это перевод вот этой замечательной статьи, у меня недостаточно навыков чтобы такое провернуть своими силами.
Не получилось портировать для FreeBSD, даже доработать для поддержки юникода — читайте и поймете почему все так сложно ;)
Большинство быдлокодеров разработчиков думают что современный ассемблер может использоваться либо для каких-то игрушечных поделок, создаваемых в целях обучения, либо для хардкорной оптимизации какой-то отдельной функции внутри большого и кривого проекта, написанного на языке высокого уровня.
А что если мы напишем программу целиком на ассемблере, которая открывает графическое окно в X-сервере?
Конечно это будет что-то вроде «hello word» от мира интерфейсов, нечто простое визуально, но очень и очень сложно реализуемое:
Эта статья появилась на свет, потому что я хотел улучшить свои знания ассемблера заодно сделав чего-нибудь интересное и мотивирующее.
Еще я заметил, что слишком много бинарников в наши дни очень и очень жирные, часто больше 30Mb (привет Rust) и спросил себя:
Доколе?Насколько маленьким может быть бинарник для (очень простого) интерфейса?
В результате моих экспериментов получилось очень достойно — примерно 1Kb!
Я ни в коей мере не являюсь экспертом в ассмеблере или 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 и так далее.
Просто скомпилируйте на любом Linux указав правильную переменную в качестве аргумента командной строки, отправьте своему другу на FreeBSD и оно просто заработает (нет). Это освежает.
Некоторые читатели правильно заметили что Linux это единственная современная (mainstream) операционная система, которая официально предоставляет стабильное ABI уровня пользователя, другие ОС часто ломают их ABI от версии к версии и рекомендуют всем программам линковку к системной библиотеке (libSystem в случае macOS). Этот слой и гарантирует стабильность API и работает в качестве защиты от «ломающих изменений» в ABI. На практике, для обычных системных вызовов, которые мы тут используем — шанс на то что они сломаются очень невелик, но разумеется это не повод творить с их помощью всякую дичь. Что кстати случалось с проектом Go в прошлом на macOS. Решение для такого — банальная перекомпиляция.
Основы 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
nasm
использует синтаксис Intel: <instruction> <destination>, <source>
, поэтому mov rdi, 0
помещает 0 в регистр rdi
. Другие ассемблеры используют синтаксис AT&T, который меняет местами источник (source) и назначение (destination). Мой совет: выберите какой-то один синтаксис и один ассемблер и придерживайтесь его, хотя оба варианта хороши и большинство инструментов имеют поддержку для обоих.
Следуя Unix System V ABI, который обязателен как для Linux так и для других юниксов, совершение системного вызова требует положить код вызова в регистр rax
, а параметры к нему ( до 6ти) в регистры rdi
, rsi
, rdx
, rcx
, r8
, r9
, и дополнительные параметры (если есть) - на стек (что не происходит в нашей программе, поэтому можем опустить этот момент). Затем мы используем инструкцию syscall
и проверяем rax
для получения кода возврата, где 0 обычно означает отсутствие ошибок.
Замечу что Linux (и возможно другие юниксы) имеют «веселую» особенность: четвертый параметр системного вызова обычно передается через регистр r10
.
Самые упоротые читатели подсказали что так происходит везде, во всех ОС и описано в реализации архитектуры 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
.
Вот так выглядит вызов strace в работе:
Про стек
Прежде чем продолжать, надо немного погрузиться в основы работы стека, как это работает в ассемблере, поскольку у нас нет друга-компилятора, который бы сделал это для нас.
Три самые важные вещи о стеке:
- Он растет вниз: для резервирования большего места на стеке, мы уменьшаем значение
rsp
- Функция должна вернуть указатель стека в его начальное значение до выхода из нее. Это значает что необходимо либо запоминать начальное значение и устанавливать его в
rsp
либо учитывать каждое увеличение или уменьшение этого значения. - До вызова функции, указатель стека должен быть 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
.
Тут много деталей, о которых необходимо рассказать.
Это тоже регистр, как и все остальные, но вы можете выбрать следовать ли конвенциям и не использовать его как другие регистры для записи значений, а использовать для записи связанного списка «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
Существует метод оптимизации, который используетrbp
как стандартный регистр (в компиляторе Си за это отвечает флаг-fomit-frame-pointer
), что означает потерю информации о стеке вызовов (call stack). Мой совет: никогда это не используйте, оно того не стоит.
Подождите, но выше же было описано что стек должен быть 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) или в других регистрах. Это более широкая тема аллокации регистров, с нарастающей сложностью! Но в маленьких функциях это тем не менее управляемо.
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 Кб.
- Можно переместить отрисовку текста на клиентскую сторону, выполнение этого на сервере накладывает множество ограничений
- Можно добавить отрисовку различных графических элементов вроде прямоугольников или кругов
- Можно обрабатывать события нажатия клавиш и движения мыши (цикл поллинга легко расширить для поддержи такого)
Надеюсь вам понравилось и вы получили не меньше удовольствия от чтения чем автор от написания этой статьи.
Если статья вам понравилась, вы хотите поддержать автора и не являетесь нищебродом: Paypal (оригинальный автор статьи на английском)
Полный исходный код
Ниже приведен полный исходный код:
; 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
Подстветка ASM-синтаксиса для Gedit
Как вы могли заметить, я использовал gedit в качестве редактора кода, по умолчанию он не имеет поддержки подсветки синтаксиса для ассемблера.
Чтобы ее заиметь — нужно скачать и поставить специальный конфиг с подстветкой.
Взять его можно вот отсюда, cтавится вот так:
wget https://gist.githubusercontent.com/mrbesher/5c98da1c220d10e2aba8276554d3456c/raw/5bedeaeacfc0886c2d5de9dcd36f63cbc328a653/asm-intel.lang sudo cp asm-intel.lang /usr/share/gtksourceview-4/language-specs/
В результате в выпадающем списке подсветок должна появиться опция Assembler (Intel):