Как работает протокол X11 на самом нижнем уровне
X11 это тот механизм на чем работает весь графический интерфейс Unix подобных ОС.
Но мало кто знает как он работает на самом деле. Потому что с годами он оброс слоями и слоями библиотек, которые стремятся скрыть саму сущность протокола.
А протокол в своей сути прекрасен. Он лаконичен и почти совершен.
В Интернете есть полная документация по протоколу. Но дело в том, что эта документация большая, написана не совсем ясным языком и по сути представляет просто спецификация. Важные моменты никак не обозначены, а как использовать тоже оставлено на фантазии читателя.
А все книги и статьи по использованию X11 описывают это через библиотеки прокладки типа XLib и XCB, и даже, что хуже, GTK или Qt.
Так что документацию приходится читать всю и самому выделять что важно, а что не очень. Придумывать сценарии использования и писать хотя бы короткие программы чтобы испробовать как все работает на самом деле.
Как бы то ни было, если кому-то интересно как все работает на самом деле, пожалуйста под кат.
Суть
Суть X11 в том, что есть программа сервер (X server) которая ожидает подключения и выполняет те команды которые получает от клиента. Например создать графическое окно. Нарисовать что-то и так далее.
Клиенты подключаются к серверу через обычный сокет. Посылают команды и получают обратно ответы, ошибки, если что-то пошло не так, а также события (например перемещения мыши, нажимания на кнопки и т.п.)
Клиент, по сути это консольная программа, которая с графикой не имеет ничего общего, кроме этого сетевого соединения.
Протокол
Весь основной протокол описан в документе X Window System Protocol
Самое полезное в этом документе, это приложение «B», где описано побайтно что и куда присылается и принимается.
Я буду цитировать отрывки, чтобы иллюстрировать текст.
Идентификаторы
Все объекты в X имеют идентификатор. Это 32 битовое число, которое генерирует клиент и передает серверу чтобы обозначит создаваемый объект. Например окно, курсор, картинка и т.д.
Другой тип идентификаторы это ATOM. Атомы это тоже 32 битовые числа, но их генерирует сервер. Клиент передает серверу какую-то символьную строку, а сервер в ответ дает число. На одинаковых строках всегда соответствует одинаковое число. Это похоже на хеширование, но сделано по другому — сервер просто хранит список строк и присваивает им номера. Если какой-то клиент запросит атом для строки которая уже находится в списке, ему возвращают номер строки в списке.
Атомы используются прежде всего чтобы разные клиенты могли обменивать информацию друг с другом используя стандартные текстовые идентификаторы.
А чтобы не грузить сетевой обмен длинными текстовыми идентификаторами, передаются собственно числа.
Чтобы снизить нагрузку на сервер, самые важные атомы определены в стандарте и всегда имеют одни и те же значения. Если кому-то интересно, список здесь:
То что написано большими буквами, является та строка из которой генерирован атом:
atomPRIMARY = 1
atomSECONDARY = 2
atomARC = 3
atomATOM = 4
atomBITMAP = 5
atomCARDINAL = 6
atomCOLORMAP = 7
atomCURSOR = 8
atomCUT_BUFFER0 = 9
atomCUT_BUFFER1 = 10
atomCUT_BUFFER2 = 11
atomCUT_BUFFER3 = 12
atomCUT_BUFFER4 = 13
atomCUT_BUFFER5 = 14
atomCUT_BUFFER6 = 15
atomCUT_BUFFER7 = 16
atomDRAWABLE = 17
atomFONT = 18
atomINTEGER = 19
atomPIXMAP = 20
atomPOINT = 21
atomRECTANGLE = 22
atomRESOURCE_MANAGER = 23
atomRGB_COLOR_MAP = 24
atomRGB_BEST_MAP = 25
atomRGB_BLUE_MAP = 26
atomRGB_DEFAULT_MAP = 27
atomRGB_GRAY_MAP = 28
atomRGB_GREEN_MAP = 29
atomRGB_RED_MAP = 30
atomSTRING = 31
atomVISUALID = 32
atomWINDOW = 33
atomWM_COMMAND = 34
atomWM_HINTS = 35
atomWM_CLIENT_MACHINE = 36
atomWM_ICON_NAME = 37
atomWM_ICON_SIZE = 38
atomWM_NAME = 39
atomWM_NORMAL_HINTS = 40
atomWM_SIZE_HINTS = 41
atomWM_ZOOM_HINTS = 42
atomMIN_SPACE = 43
atomNORM_SPACE = 44
atomMAX_SPACE = 45
atomEND_SPACE = 46
atomSUPERSCRIPT_X = 47
atomSUPERSCRIPT_Y = 48
atomSUBSCRIPT_X = 49
atomSUBSCRIPT_Y = 50
atomUNDERLINE_POSITION = 51
atomUNDERLINE_THICKNESS= 52
atomSTRIKEOUT_ASCENT = 53
atomSTRIKEOUT_DESCENT = 54
atomITALIC_ANGLE = 55
atomX_HEIGHT = 56
atomQUAD_WIDTH = 57
atomWEIGHT = 58
atomPOINT_SIZE = 59
atomRESOLUTION = 60
atomCOPYRIGHT = 61
atomNOTICE = 62
atomFONT_NAME = 63
atomFAMILY_NAME = 64
atomFULL_NAME = 65
atomCAP_HEIGHT = 66
atomWM_CLASS = 67
atomWM_TRANSIENT_FOR = 68
Запросы
Все запросы в X11 бинарные, с полями разной длины. По сути, здесь есть поля длиной в 1 байт, 2 байта и 4 байта.
Первые 4 байта запроса всегда присутствуют и всегда содержат одинаковую информацию:
Прочтя этот заголовок, сервер уже знает сколько байт (а точнее двойные слова) еще надо прочесть чтобы забрать весь запрос.
Чтобы не быть слишком голословным покажу простой пример:
Запрос «DestroyWindow» кодируется вот так (допустим хотим закрыть окно с ID 0×12345678):
Или в итоге, по сокете уходит вот что: 03 00 02 00 78 56 34 12
Получив этот запрос, X сервер закроет окно с идентификатором 0×12345678
В документации протокола (а точнее в приложении), вот это запрос DestroyWindow описан следующим синтаксисом:
1 4 opcode
1 unused
2 2 request length
4 WINDOW window
А сейчас что-то посложнее: «CreateWindow».
Предварительно надо выбрать идентификатор окна. Выберем опять 0×12345678 чтобы было попроще.
Еще понадобиться идентификатор коренного окна (это служебное окно, которое занимает весь дисплей и является родительским для всех окон верхнего уровня. Допустим его идентификатор 0×9abcdef0 (а откуда взять реальные значения, я расскажу немножко позже).
И так итоговый запрос который отправляем на сокет: 01 00 08 00 78 65 43 21 f0 de bc 9a 64 65 c8 66 00 00 01 00 00 00 00 00 00 00 00 00
Вот и полное описание запроса в приложении протокола:
1 1 opcode
1 CARD8 depth
2 8+n request length
4 WINDOW wid
4 WINDOW parent
2 INT16 x
2 INT16 y
2 CARD16 width
2 CARD16 height
2 CARD16 border-width
2 class
0 CopyFromParent
1 InputOutput
2 InputOnly
4 VISUALID visual
0 CopyFromParent
4 BITMASK value-mask (has n bits set to 1)
#x00000001 background-pixmap
#x00000002 background-pixel
#x00000004 border-pixmap
#x00000008 border-pixel
#x00000010 bit-gravity
#x00000020 win-gravity
#x00000040 backing-store
#x00000080 backing-planes
#x00000100 backing-pixel
#x00000200 override-redirect
#x00000400 save-under
#x00000800 event-mask
#x00001000 do-not-propagate-mask
#x00002000 colormap
#x00004000 cursor
4n LISTofVALUE value-list
VALUEs
4 PIXMAP background-pixmap
0 None
1 ParentRelative
4 CARD32 background-pixel
4 PIXMAP border-pixmap
0 CopyFromParent
4 CARD32 border-pixel
1 BITGRAVITY bit-gravity
1 WINGRAVITY win-gravity
1 backing-store
0 NotUseful
1 WhenMapped
2 Always
4 CARD32 backing-planes
4 CARD32 backing-pixel
1 BOOL override-redirect
1 BOOL save-under
4 SETofEVENT event-mask
4 SETofDEVICEEVENT do-not-propagate-mask
4 COLORMAP colormap
0 CopyFromParent
4 CURSOR cursor
0 None
Немножко сложнее, но надеюсь более-менее понятно… Сложность здесь из-за того, что в запросе можно передать кучу параметров окна разного вида и формата. Но по сути все идет последовательно и более-менее логично.
После получения этого запроса, сервер создает окно с заданными параметрами. Но это окно не появится, так как все еще не показано на экране. Делаем это через запрос «MapWindow». На фоне прежнего, он совсем простенький:
На сокет уходит: 08 00 02 00 78 56 34 12
, а окно становится видным.
Ответы
Сервер тоже присылает нам по сокете информацию. Она бывает 3 вида: Ответы (Reply), События (Events) и Ошибки (Errors).
Все три виды имеют длину минимум 32 байта. (А события и ошибки всегда точно 32 байта). Так что чтение из сервера происходит всегда порциями в 32 байта и если это Reply из тела ответа берем длина дополнительной части и читаем ее тоже.
Вся информация с сервера приходит асинхронно, но ответы и ошибки всегда приходят в порядке запросов чьими результатами они являются.
- Ответы на запросы (Reply). Если запрос предполагает ответ от сервера, то сервер его присылает по сокете, как только обработает запрос. Если ответ содержит информацию, которая помещается в 32 байта, то это все что нужно принять. Если ответ длиннее, то в его теле содержится длина дополнительной части ответа.
Общий формат ответа такой:
- События (Events). Содержат те же 32 байта и генерируются в ответ на какие-то события в GUI. Чтобы получать некоторые события, клиент должен подписаться на них, когда создает окно, например.
Некоторые события обще-системного характера присылаются всегда и всем.
Формат событии такой:
- Ошибки. Присылаются если какой-то запрос клиента нельзя было исполнить потому что содержит какую-то ошибку в данных или параметрах. Формат ошибки такой:
Подключение.
А сейчас сделаем шаг назад и рассмотрим наверное самое сложное в X11 — подключение к серверу. К сожалению процедура сложная и запутанная и является камнем преткновения для прямого использования X11.
Именно подключение поднимает уровень вхождения в технологию.
Как мы увидели само использование протокола достаточно просто. Но подключение — это что-то с чем-то!
Само подключение по сути простое создаем сокет и выполняем connect на него. Но сперва надо узнать адрес сервера. Для этого есть алгоритм:
Смотрим на содержание переменной окружения DISPLAY
. Если существует, она содержит адрес X11 сервера в формате: [host]:D.S
.
host — это хост сервера. Это может быть имя домейна, может быть строкой »/unix» или просто отсутствовать. Отсутствующий host
равен "/unix"
и означает что сервер слушает на unix domain сокете на локальной машине.
Кстати, это самый частый случай. Если host присутствует, это значит что подключаться надо к этому хосту, по TCP, через IP6 адрес.
D
это номер дисплея, а S
это номер экрана. В большинстве случаев на современных конфигурациях номер экрана будет 0, даже если мониторы больше одного. Все они виртуально объединены в один экран.
От номера дисплея зависит порт подключения к серверу. Если по TCP, то сервер слушает на порт 6000+D. Если подключаемся через unix domain сокет, он находится по адресу /tmp/.X11-unix/X{D}
— то есть, нулевой дисплей на /tmp/.X11-unix/X0
, первый на /tmp/.X11-unix/X1
и т.д.
И вот, мы подключились к сокету. После подключения, нельзя просто так посылать запросы. Надо сперва отправить на сервер информацию о себе и авторизоваться на сервере.
Все это содержится в первом (а точнее нулевом) запросе, который нестандартный и содержит:
Первый байт определяет в каком формате наша программа понимает числа. Сервер будет присылать нам все числа длиннее одного байта в этом формате и будет понимать числа которые мы присылаем в этом формате.
Потом следует минимальная версия протокола, которая подошла бы программы. Если сервер поддерживает версию ниже этой, то подключение будет отклонено.
Потом следует имя протокола авторизации и собственно данные авторизации. Это типа доказательство, что эта программа имеет право подключаться к серверу X11.
Откуда берем имя протокола и данные об авторизации? Они находятся в файле, путь к которому находится в переменную окружения $XAUTHORITY
. Если эта переменная не существует можно поискать в файле $HOME/.Xauthority
— это самый распространенный вариант. Если у вашего приложения нет прав доступа к этому файлу или файл не существует, то значит у вас нет доступа к этому X11 серверу.
Файл бинарный и его формат не слишком хорошо задокументирован. Мне пришлось спрашивать на stackoverflow чтобы разобраться, да и то получилось лишь частично.
Так, структура файла, это последовательность записей вот таких структур:
typedef struct xauth {
unsigned short family;
unsigned short address_length;
char *address;
unsigned short number_length;
char *number;
unsigned short name_length;
char *name;
unsigned short data_length;
char *data;
} Xauth;
Но во первых, в файле, конечно указателей нет. Все строки вписаны просто последовательно, символ за символом в файле. Во вторых — все двухбайтовые числа всегда являются big-endian. Вне зависимости от архитектуры компьютера.
address
— это HOST адрес сервера.
number
— это номер дисплея, которого мы уже определили из переменной $DISPLAY, записанный в виде текстовой строки!
name
— это имя протокола. В настоящем времени и насколько я знаю, используется только MIT-MAGIC-COOKIE-1
протокол.
data
— это массив байтов, примерно вот такой: 07 bd 70 26 1а ab 4c 7c 35 3c c1 b2 cc 25 a2 29
. который мы должны переслать серверу в знак, что у нас доступ позволен.
Перебираем этот файл пока не найдем запись, у которой HOST совпадает с хостом из $DISPLAY и номер дисплея с номером дисплея из $DISPLAY. Из этой записи достаем имя протокола и данные авторизации.
И так мы собрали все необходимые данные о нулевом запросе и формируем его:
К серверу уходит: 6c 00 0b 00 00 00 12 10 4d 49 54 2d 4d 41 47 49 43 2d 43 4f 4f 4b 49 45 2d 31 07 bd 70 26 1а ab 4c 7c 35 3c c1 b2 cc 25 a2 29
На что сервер может ответить тремя возможными ответами. Вариант ответа определяется по первому байту. Он может быть:
0: Подключение отклонено. Весь ответ содержит:
2: Нужна дополнительная аутентификация. Я этого варианта не изучал потому что так и не успел найти систему, которая так бы отвечала…
1: Подключение принято.
Самый хороший для нас вариант. Ответ очень длинный и сложный, содержит главные параметры системы, которые мы должны запомнить и использовать позже в наших запросов.
Я так и не смог нарисовать такую сложную табличку, чтобы все разложить по полочкам. Поэтому вот вам описания ответа из документации протокола:
1 1 Success
1 unused
2 CARD16 protocol-major-version
2 CARD16 protocol-minor-version
2 8+2n+(v+p+m)/4 length in 4-byte units of
"additional data"
4 CARD32 release-number
4 CARD32 resource-id-base
4 CARD32 resource-id-mask
4 CARD32 motion-buffer-size
2 v length of vendor
2 CARD16 maximum-request-length
1 CARD8 number of SCREENs in roots
1 n number for FORMATs in
pixmap-formats
1 image-byte-order
0 LSBFirst
1 MSBFirst
1 bitmap-format-bit-order
0 LeastSignificant
1 MostSignificant
1 CARD8 bitmap-format-scanline-unit
1 CARD8 bitmap-format-scanline-pad
1 KEYCODE min-keycode
1 KEYCODE max-keycode
4 unused
v STRING8 vendor
p unused, p=pad(v)
8n LISTofFORMAT pixmap-formats
m LISTofSCREEN roots (m is always a multiple of 4)
FORMAT
1 CARD8 depth
1 CARD8 bits-per-pixel
1 CARD8 scanline-pad
5 unused
SCREEN
4 WINDOW root
4 COLORMAP default-colormap
4 CARD32 white-pixel
4 CARD32 black-pixel
4 SETofEVENT current-input-masks
2 CARD16 width-in-pixels
2 CARD16 height-in-pixels
2 CARD16 width-in-millimeters
2 CARD16 height-in-millimeters
2 CARD16 min-installed-maps
2 CARD16 max-installed-maps
4 VISUALID root-visual
1 backing-stores
0 Never
1 WhenMapped
2 Always
1 BOOL save-unders
1 CARD8 root-depth
1 CARD8 number of DEPTHs in allowed-depths
n LISTofDEPTH allowed-depths (n is always a
multiple of 4)
DEPTH
1 CARD8 depth
1 unused
2 n number of VISUALTYPES in visuals
4 unused
24n LISTofVISUALTYPE visuals
VISUALTYPE
4 VISUALID visual-id
1 class
0 StaticGray
1 GrayScale
2 StaticColor
3 PseudoColor
4 TrueColor
5 DirectColor
1 CARD8 bits-per-rgb-value
2 CARD16 colormap-entries
4 CARD32 red-mask
4 CARD32 green-mask
4 CARD32 blue-mask
4 unused
Но как бы и сложным это не выглядело бы, всю информацию не надо запоминать или даже анализировать.
Мы от этого ответа возьмем только то, что важно для нас. И это во первых два числа из полей: resource-id-base
и resource-id-mask
. Они дают нам диапазон в котором надо генерировать ID константы для всех объектов GUI. (Не забывайте, что в X11 все идентификаторы объектов генерируются на стороне клиента, а серверу именно клиент говорит какой будет ID окна или других объектов.)
Так, у сервера есть только одно ограничение — каждой программе он выделяет диапазон в котором идентификаторы должны помещаться. Так идентификатор должен содержать только те биты которые в resource-id-mask установлены в единицу. И идентификатор должен начинать с resource-id_base.
Еще надо запомнить для будущего использования диапазон клавиатурных кодов (min-keycode/max-keycode), найти в ответе те форматы изображений, которые программа может использовать и которые ей удобны.
Еще обязательно надо найти подходящий SCREEN из списка и оттуда взять идентификатор коренного окна. Он нам нужен, в качестве родительского окна для всех окон верхнего уровня, которые мы будем создавать.
Все остального более или менее можно проигнорировать.
Я обычно ищу во всем этом многообразии тот SCREEN, который меня устраивает (32 бит TrueColor) и использую только его. А если сервер такое не поддерживает, просто заканчиваю работу. Это сильно упрощает работу и код.
Заключение
Ну это все для первого раза. Надеюсь сумел все объяснить яснее чем в документации и дать то понимание, которое позволит дальше свободно читать документацию (А она и правда хороша, если человек умеет ее понимать).
В качестве упражнения предлагаю конкурс-челендж: Написать программу на bash которая устанавливает соединение с X сервером и создает и показывает окно с заголовком «X11 rules».
Если никто не справится или не захочет, я попробую написать ее в качестве примера для следующей статьи цикла.
Спрашивайте в комментариях если что не ясно. Если что не нравиться тоже пишите. Статья может и будет редактироваться по мере обсуждения.