Слегка устав от засилия объемных современных фреймворков и «продвинутых» технологий, решил устроить себе день психического здоровья. Ниже будет полный «back-to-roots»: чистый ассемблер и открытие окна в X-сервере, на линуксе. Никаких библиотек, фрейворков и виртуальных машин.

Собственно вот.  Картинка на фоне это снова результат работы нейросети.
Собственно вот. Картинка на фоне это снова результат работы нейросети.

Вступление

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

Также не получилось портировать описываемый код под FreeBSD и даже доработать для поддержки юникода — читайте и поймете почему все так сложно ;)

Поехали.

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

Если вообще знают что такое ассемблер.

А что если мы напишем программу целиком на ассемблере, которая открывает графическое окно в X-сервере?

Конечно это будет что-то вроде «hello word» от мира интерфейсов, нечто простое визуально, но достаточно сложно реализуемое:

Эта статья появилась на свет, потому что я хотел улучшить свои знания ассемблера заодно сделав чего-нибудь интересное и мотивирующее.

Еще я заметил, что слишком много программ в наши дни имеют какие-то невероятные размеры и спросил себя:

Доколе? Насколько маленьким может быть бинарник для очень простого интерфейса?

В результате моих экспериментов получилось очень достойно — примерно 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 и так далее.

Просто скомпилируйте на любом 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
  

Примечание №1

nasm использует синтаксис Intel: <instruction> <destination>, <source>, поэтому 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.

Вот так выглядит вызов strace в работе:

Про стек

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

Три самые важные вещи о стеке:

  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) или в других регистрах. Это более широкая тема аллокации регистров, с нарастающей сложностью! Но в маленьких функциях это тем не менее управляемо.

Первоеt, секция .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 Кб.

Что еще можно оптимизировать?

  • Можно переместить отрисовку текста на клиентскую сторону, выполнение этого на сервере накладывает множество ограничений

  • Можно добавить отрисовку различных графических элементов вроде прямоугольников или кругов

  • Можно обрабатывать события нажатия клавиш и движения мыши (цикл поллинга легко расширить для поддержи такого)

Надеюсь вам понравилось и вы получили не меньше удовольствия от чтения чем автор от написания этой статьи.

Если статья вам понравилась, вы хотите поддержать автора и не являетесь нищебродом: 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тавится вот так:

P.S.

Это немного отцезурированная версия статьи, оригинал которой доступен в нашем блоге.

0x08 Software

Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.

Комментарии (28)


  1. JordanCpp
    03.09.2024 12:58
    +3

    Меня терзают смутные сомнения, что и на си и с++, можно добиться аналогичного результата. Причем оно будет переносимо.


    1. alex0x08 Автор
      03.09.2024 12:58
      +8

      Да что вы говорите! Быть такого не может!


    1. dv0ich
      03.09.2024 12:58
      +2

      Хм. На С++ в Linux (gcc 14.2.1) минимальный ПриветМир с использованием unistd.h даёт бинарник размером 14392 байта, с использованием iostream.h - 14400 байтов. Это чисто для терминала. Довольно толстенько.


      1. alex0x08 Автор
        03.09.2024 12:58
        +2

        Программисты C++ начинают отсчет сразу с мегабайтов, все что меньше — погрешность (ц)


        1. dv0ich
          03.09.2024 12:58

          Хех) Пишу прогу на плюсах - чисто терминальная, ничего такого особенного, работа с файлами. Я её ещё даже не доделал, а бинарник уже за 430 КБ перевалил. А я думал, что килобайтов в 250 уложусь.

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


  1. JordanCpp
    03.09.2024 12:58

    Я тоже устал от монстроузности в софте. Но я пошел путем С++, позволивший поддерживать как новые, так и старые api. И получилось сделать очень переносимый код, не только на уровне разных компиляторов, но и архитектур.

    Скромненько выскажусь, что ассемблер в таких количествах - это антипатиерн.


    1. Serpentine
      03.09.2024 12:58
      +1

      Скромненько выскажусь, что ассемблер в таких количествах - это антипатиерн.

      Статья же в хабе "Ненормальное программирование" + автор пишет, что:

      Эта статья появилась на свет, потому что я хотел улучшить свои знания ассемблера заодно сделав чего-нибудь интересное и мотивирующее.

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


  1. shashurup
    03.09.2024 12:58

    А X сервер-то здесь зачем - тот еще монстр?
    Чем писать в видеопамять напрямую плохо-то?


    1. alex0x08 Автор
      03.09.2024 12:58
      +7

      Чем писать в видеопамять напрямую плохо-то?

      Конечно же ничем, но враги зачем‑то придумали какие‑то дурацкие права пользователя и протоколы — ересь сплошная да порнография. Милонова на них нет!


      1. shashurup
        03.09.2024 12:58
        +1

        Я, когда проделал аналогичное упражнение на винде, - приложение на ассемблере создавало окно используя win api, испытал странное чувство что я сделал какую-то фигню, но не мог понять почему.
        А потом меня озарило. Я вспомнил споры со своим другом - аудиофилом. Аргумент про дорогучий силовой кабель из бескислородной меди (тут про технологию точно соврал, но деталей не помню) который ну никак, ни за какие деньги не сможет исправить кривое электричество которое было погнуто в процессе передачи по километрам зоопарка проводки от электростанции до розетки.


        1. alex0x08 Автор
          03.09.2024 12:58

          Когда учился в ВУЗе, мой одногруппник написал клон Notepad целиком на ассемблере. Было время — были люди.


    1. a-tk
      03.09.2024 12:58
      +2

      mov ax, 13h
      int 10h
      mov ax, 0A000h
      mov es, ax
      ; ...

      Ага, ага...


  1. smt_one
    03.09.2024 12:58

    FASM конечно поудобнее (для меня). А так хотелось бы похожую статью о Wayland.


    1. Gordon01
      03.09.2024 12:58
      +1

      Wayland не имеет никаких АПИ для рисования


  1. AndrewT2
    03.09.2024 12:58
    +1

    Когда-то асм был суровой необходимостью.

    16кб ОЗУ и 1Мгц проц.

    PDP-11 и вперед, делать графические игрушки для БК0010

    Компиляция ассемблера с кассеты в память. Даже дизасемблер и дебугер был - с кассеты грузился.

    До появления графических ускорителей только на асме можно было выжать 3d из чахлых 286-386. С другом натягивали битмап на крутящийся шар. Экранов 5 на ассемблере за один день.

    Было весело в детстве)


    1. Joysi
      03.09.2024 12:58

      FAQ-и из demo.design, ru.algorithms и ru.game.design наверное были изучены почти в наизусть =)


      1. AndrewT2
        03.09.2024 12:58

        Журнал электроника 1987 года - вот источник знаний. Случайно на даче читал рекламную статью про новый графический адаптер КГД для ДВК. Там было описано в какую ячейку положить адрес графической памяти и в какую ячейку положить затем значение. Как раз в школе поставили эти КГД (забивал молотком лично). Ну и понеслось - спрайты вручную прогал, графический редактор на асме.


  1. densss2
    03.09.2024 12:58

    ubunchu)))

    Это отсылка?


    1. ignorabimus
      03.09.2024 12:58

      может, глагол? )


  1. XenRE
    03.09.2024 12:58
    +2

    Linux это единственная современная (mainstream) операционная система, которая официально предоставляет стабильное ABI уровня пользователя, другие ОС часто ломают их ABI от версии к версии

    Зато на уровне ядра ломают с завидной периодичностью, часто даже в пределах одной версии.

    До вызова функции, указатель стека должен быть 16 байт (16 bytes aligned)

    Наверное "выровнен по 16 байт"?


  1. a-tk
    03.09.2024 12:58
    +1

    Беглый просмотр кода даёт основания считать, что процентов 15 можно отрезать, схлопнув запись байтов и не выполняя явно лишних операций вида mov rdi, rdi или lea rdi, [rsp], а также избавиться от rex-префикса инструкций mov rax, SYSCALL_EXIT и mov rdi, 0


    1. alex0x08 Автор
      03.09.2024 12:58

      На ЛОРе это тоже заметили, но автор оригинальной статьи честно писал что получил ассемблерный код декомпиляцией из Си и затем уже чистил. Я так тоже делал, когда пробовал портировать под FreeBSD, могу сказать что ассемблерных листингов получается чудовищное количество даже для пустого окна.

      Так что совсем уж обвинять автора в криворукости я бы не стал.


      1. a-tk
        03.09.2024 12:58

        Речь конечно же не про криворукость. Но раз упор делается на малый размер бинарника, то было бы неплохо поработать над тем, чтобы хоть какой-то прогресс по нему обеспечить.


      1. a-tk
        03.09.2024 12:58

        В общем, на удалении ненужных REX, удалении ненужных прологов и эпилогов, а также на замене инструкций на меньшие по размеру получилось сократить объём кода на 120 байт.


  1. Jec13
    03.09.2024 12:58
    +1

    Побольше таких статей.


  1. NutsUnderline
    03.09.2024 12:58

    Это классно, но видно что ядро ОС заставляет играть по ее правилам и все класть на стек.


    1. a-tk
      03.09.2024 12:58

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

      В регистрах много информации не разместишь.


  1. Seenkao
    03.09.2024 12:58

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