Назад Вверх Вперёд

Глава III. Организация двоичного протокола.

При организации двоичного протокола необходимо определить:

  1. Будут ли посылки полностью фиксированного формата, фиксированного с переменной частью, нефиксированного? Как при этом будут разграничиваться посылки, если подложка не обеспечивает разграничение посылок?
  2. Как будут кодироваться отдельные элементы посылок?
  3. Как будет решён выбор компромиссных размеров посылок, их частей и конечных полей данных?
  4. Как будет устроено опознание и согласование применяемых версий протокола?
  5. Как будет выглядеть адаптация обмена данными к применяемой подложке?

Рассмотрим эти вопросы более подробно, с учётом их частичного пересечения.

III.1. Первое, что приходится делать стороне, принявшей посылку - определить её целостность, длину и тип сообщения в посылке. Во многих случаях целостность гарантируется подложкой, и подложка же сообщает длину; но для некоторых применений надёжности посылки может и не хватать, а длина может оказаться неизвестной без явного её указания в посылке (особенно это характерно для подложки 1-го типа, такой, как TCP). Тип сообщения - свойство сообщения, важное для определения смысла сообщения и его детальной структуры, для выбора необходимого пути парсинга - задаётся полем внутри сообщения (в некоторых случаях может передаваться подложкой; например, VPI/VCI в ATM, номер потока в SCTP). Если тип не передан подложкой, то он должен определяться по содержанию сообщения, наиболее прямой метод - передача типа сообщения известным методом (см. далее по тексту) в поле известного формата и размера по известному смещению внутри сообщения, так, чтобы сообщение любого типа имело указание типа данного размера по данному смещению и чтобы длина сообщения любого типа была достаточна, чтобы передать таким образом в нём тип. Во многих случаях (особенно при долгом историческом развитии протокола) картина усложнена, но сохраняется общий принцип, что проверяется определённая часть сообщения, некоторые значения которой могут вызывать необходимость прочтения других частей сообщения строго детерминированным образом. Передающая сторона обязана обеспечить формирование сообщения так, чтобы оно могло быть однозначно декодировано приёмной стороной; а протокол изначально должен быть так разработан, чтобы не допускать неоднозначностей декодирования (то есть должен быть определён алгоритм разбора входящей посылки для однозначного декодирования).

Примеры:

1. IPv4. Поля фиксированной длины, смещения и интерпретации указывают полную длину пакета (эта информация может не совпадать с тем, что сообщает подложка 2-го уровня модели OSI, из-за фрагментации или ограничений на минимальную длину кадра), длину заголовка и тип полезной нагрузки (payload). Подложка сообщает длину данного фрагмента (обычно, целого пакета) и признак того, что это IPv4.

2. IEEE 802.3, он же (с некоторыми натяжками) Ethernet. Поле "тип/длина" состоит из октетов по смещению 12 и 13 от начала содержательной части посылки (кадра). Интерпретация поля зависит от содержимого и имеет несколько развилок алгоритма. Значение менее 1536 (иногда указывается немного другая цифра) означает необходимость интерпретации согласно IEEE 802.3, тогда начиная от смещения 14 анализируются поля 3-байтного LLC заголовка (кроме случая "плоского" 802.3, когда соглашением сторон устанавливается отказ от анализа LLC). Значение "тип/длина" от 1536 и выше означает режим интерпретации "Ethernet II", когда значение поля определяет конкретный целевой протокол. В этом случае некоторые значения имеют специальный смысл: например, 0x8100 означает, что после него идёт заголовок 802.1p, который может нести в себе VLAN (стандарта 802.1q) и TOS (стандарта 802.1p), а после него - ещё одно поле "тип/длина", подсказывающее интерпретацию содержания "внутреннего" пакета (и тогда внутри может быть уже любой из возможных форматов, но на смещении большем на 4). Физический уровень указывает длину кадра (и, наоборот, ему указывается длина кадра для передачи).

3. Milter (протокол общения sendmail с внешними фильтрами). Подложка - TCP. Перед сообщением идёт его длина - 4 байта - 32-битное целое число без знака в big-endian порядке, при подсчёте длины не учитывается передача самой длины. Минимальная длина сообщения - 1 байт. Тип сообщения имеет размер 1 байт и размещается по смещению 0. Интерпретация остатка сообщения определяется типом сообщения.

Пример чтения такого сообщения (на Python):


  lenbin = s.recv(4)
  msglen = struct.unpack('>L', lenbin)[0] ## U32BE
  message = s.recv(msglen)

Аналогичный подход штатно поддерживается в Erlang - опции {packet,N}, где N - 1, 2 или 4, в inet:setopts(), задают указание пакетов в потоке их длинами и разделение на приёме средствами библиотеки реализации.

4. Протокол ZeroMQ. Подложка - TCP. Сообщение начинается с октета типа, в котором находится однобитовый признак короткое/длинное. В случае "короткого" сообщения длина занимает 1 октет; в случае "длинного" - 8. Очевидная логика в данном случае - если сообщение уже достигло 256 октетов, ещё 4 на кодирование длины принципиально не дадут большие затраты, но в случае типичных коротких сообщений лучше избавиться от лишнего.

5. Протокол Websockets. Аналогично предыдущему, но: тип сообщения идёт отдельно от длины; первое поле длины - 7 бит; его значения 0..125 понимаются буквально, 126 - означает, что дальше пойдёт 2 октета реальной длины, 127 - 4 октета. (Нам кажется, что промежуточный вариант в 2 октета здесь не даст реальной пользы, а только усложняет реализацию.)

По примерам видно, насколько разнообразны могут быть ситуации, но все они объединяются тем, что должна быть обеспечена однозначная интерпретация сообщения на приёмной стороне, начиная с его типа. Обычно для этого используются поля фиксированной длины по фиксированному смещению от начала сообщения. Есть случаи использования фиксированного смещения от конца сообщения (AAL5 для ATM - последние 8 байт последней ячейки; ESP из IPSec для IPv4 пакетов - pad length и next header в конце), но они нетипичны по сравнению с общей массой. Есть (редкие) случаи передачи типа сообщения в переменной части - см. ниже; но тогда правила интерпретации до определённой степени не зависят от типа сообщения (нужно суметь вытащить этот тип из переменной части и продолжить разбор уже зная тип).

В случае использования полностью или частично переменного формата, проблема однозначного декодирования сообщения возникает для вложенных посылок в переменной части сообщения. Точно так же, её решение начинается с определения типа и длины, зная алгоритм разбора этой части сообщения.

III.2. Передача посылок 2-го уровня модели OSI (кадров) поверх 1-го уровня имеет свои особенности, практически неизвестные на более высоких уровнях. К ним относятся: во многих случаях - отсутствие явных границ посылок (их надо формировать средствами протокола) и необходимость непосредственного контроля ошибок на этом уровне наиболее надёжными средствами среди всех уровней (за исключением особых требований, таких, как криптография). К ошибкам относятся изменение данных, добавление или удаление байтов или битов (на асинхронных протоколах). Основные методы обеспечения корректности передачи - CRC для контроля целостности содержания, особые последовательности для границ кадров и стаффинг (stuffing) для исключения попадания таких последовательностей из содержания кадров.

Протоколы семейств ZModem и Hydra работают поверх асинхронного последовательного порта, для которого минимальная единица передачи - байт (обычно 8-битный). Помехи на линии могут искажать, добавлять или удалять байты. Протокол выбирает один однобайтный код как начало управляющей последовательности, назовём его CTL. Кадр начинается фиксированной последовательностью, начинающейся с CTL; заканчивается фиксированной последовательностью, начинающейся с CTL; все байты содержания кадра (включая CRC) при передаче проверяются на их равенство CTL, если не равны - передаются без изменения, если равны - заменяются на фиксированную последовательность, начинающуся с CTL. Все три указанных последовательности (начало кадра, конец кадра, замена CTL) должны быть различны (и желательно отличаться от CTL; для байтовой подложки это легко соблюсти и тем самым упростить приёмный парсер, но для битовой это уже невозможно). Например, может быть следующее назначение:

Приёмная сторона использует накопительный буфер кадра и два логических флага - "в кадре" и "последним принятым был CTL". При инициализации приёмной стороны оба флага сбрасываются в "ложно" (false). Процедура обработки приёма, вызываемая для каждого байта, выглядит примерно так:


[code]
void
on_byte(uint8_t b)
{
  if (b == CTL) {
    if (last_ctl) {
      // два CTL подряд - явный сбой, удаляем весь кадр, если был.
      report_error(); flag_in_frame = false; reset_frame(); return;
    }
    last_ctl = true; return;
  }
  if (last_ctl) {
    if (b == CTL2_BOF) { // CTL2_BOF - второй байт кода начала кадра,
                         // в нашем примере - 0x31
      reset_frame(); flag_in_frame = true; start_frame(); return;
    }
    if (b == CTL2_EOF) { // CTL2_EOF - второй байт кода конца кадра,
                         // в нашем примере - 0x2E
      if (flag_in_frame) {
        process_frame(); flag_in_frame = false; reset_frame(); return;
      }
    }
    if (!flag_in_frame)
      return;
    if (b & 0x40 == 0x40) {
        put_to_frame(b & ~0x40); return;
    }
    report_error(); flag_in_frame = false; reset_frame(); return;
  }
  if (flag_in_frame)
    put_to_frame(b);
}
[/code]

В этом виде процедура обработки также допускает передачу в маскированном виде любых управляющих кодов; это бывает полезно для случаев, когда управляющий код не может быть передан через порт напрямую (например, для программного управления потоком (software flow control) такое ограничение действует для 0x11 и 0x13). Передающая сторона добавляет к такому коду 0x40, предваряя CTL'ом - например, 0x11 будет передано как 0x1A 0x51. Процедура, которая производится с такими кодами, обычно называется escaping или stuffing (stuffing - если символ/код, требующий защиты от неправильной интерпретации, не меняется, escaping - если меняется; но такая трактовка терминов не единственная и надо иметь в виду, что многие их смешивают или употребляют не с такими значениями).

Нетрудно убедиться, что эскейпинг такого рода допускает передачу произвольных данных и однозначно определяет поведение как передающей, так и приёмной стороны. В то же время он достаточно дорог - в предельном случае, кадр из всех CTL просто будет удвоен по длине. Средства вроде V.42bis, MNP5 помогают против этого (сжатием данных), но имеют свои побочные эффекты (задержка передачи). Или - но реализаций такого рода мы не наблюдали, вероятно, из-за их процессорной дороговизны - XOR содержимого пакета со значением, которое передаётся в нём в управляющей части (почти всегда это 0, но для случая многих CTL может быть любым другим, лишь бы уйти от этого значения в данных.) Ещё одним, практически весьма полезным, вариантом решения является удлинение управляющей последовательности. В случае управляющей последовательности из четырёх байт, максимальное увеличение размера содержимого кадра будет составлять 1/4. Ценой за меньшее удлинение данных является удлинение управляющих последовательностей начала и конца кадра (обычно это несущественно, так как управляющие передачи требуют подтверждения, а передачи данных делаются с максимально возможным для конкретных условий размером кадра) и усложнение алгоритмов как передающей, так и принимающей стороны, для необходимости отслеживания выдачи последовательности, совпадающей с управляющей. Для надёжной работы этих алгоритмов в любой реализации, все байты управляющей последовательности желательно должны быть разными и отличаться от используемых субкодов начала, конца кадра и буквальной передачи.

Протоколы: SDLC, HDLC, PPP (по синхронному каналу), V.42 - работают по синхронному каналу (фиксированная скорость, сплошной поток бит без внешне определённых границ) и используют битстаффинг. Управляющая последовательность в их случае - 011111 (шесть бит - один нуль и пять единиц); если она встречается в передаваемых данных, после неё вставляется 0. Начало и конец кадра обозначаются одинаково - восьмибитной последовательностью 01111110. Другие последовательности (ноль, единицы в количестве от семи и выше, снова ноль) используются для передачи нескольких кодов ошибок и (в случае 15 единиц и выше) "покоя" линии. Приёмная сторона, встретив последовательность 011111, переходит в состояние "получена управляющая последовательность" и ждёт следующий бит. Если пришёл 0, он просто "выбрасывается", приёмная сторона переходит в нейтральное состояние и читает поток дальше. Если пришёл 1, далее различное количество единиц в суммарной последовательности определяет различные управляющие коды. Последовательность 01111110 ("флаг"), кроме того, определяет, что октет данных будет начинаться сразу за ней - это служит средством синхронизации для правильного деления потока на октеты. Для таких протоколов максимальное удлинение потока для соответствия стаффингу - 1/6 (соответственно, в пределе 1/7 потока может быть служебными затратами протокола). Средневероятное увеличение потока из равномерных бинарных данных составляет 1/384 (1/2^6*1/6), то есть треть процента.

III.3. Теперь поднимемся на следующий участок лестницы - к кодированию элементарных посылок.

III.3.1. В почти любом формате кодируются числа (целые и вещественные), текстовые строки, даты и времена, IP-адреса, другие элементарные (то есть не составные) типы данных. Выбор их представления должен соответствовать типу данного, контекстным ограничениям (например, на максимальное передаваемое значение), и обеспечивать однозначность чтения, адекватность и эффективность представления.

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

(Однозначность чтения представления не означает однозначность представления, то есть единственную форму для каждого значения. Единственность формы имеет смысл в специальных случаях: например, ASN.1 DER отличается от BER именно требованием однозначности представления для возможности сравнивать байтовые последовательности без анализа их содержания. Но в общем случае требуется, чтобы любая из форм представления читалась как единственный смысл по сути, но не требуется однозначности в обратную сторону.)

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

Рассмотрим эти требования и варианты представления более подробно.

III.3.2. Целые числа могут быть представлены:

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

Локальное представление целых чисел для непосредственных машинных операций, как правило (в ~99.999%) случаев, соответствует сейчас общему шаблону: целое число 8-битных байтов (то есть октетов), кратное степени двойки; формат "дополнения до 2", обеспечивающий общий метод сложения и вычитания для знаковых и беззнаковых чисел. Различающимися параметрами являются:

Проблема размеров и разноконечности - наиболее публично известная; есть big-endian платформы (M68K, Sparc до V9, S/360...S/390; основные режимы для PPC, MIPS), big-endian форматы и протоколы (практически все двоичные протоколы Internet, также SCSI и многие другие), little-endian платформы (x86, PDP-11, VAX-11), little-endian форматы и протоколы (например, USB, ATA, FTN, структуры PCI, EFI GPT). Для кодирования и декодирования данных независимо от порядка размещения байт используется соответствующее API - стандартное или самописное. Как пример, приведём функции, исторически возникшие с BSD sockets. Функция htonl() получает на входе беззнаковое число (расширяемое до 32 бит) и превращает в такой результат, что если его записать в память на том же хосте обычной записью числа, оно будет записано в "network order" (синоним для big-endian). Например, 400 (0x190) в результате вызова этой функции на x86 превратится в 2415984640 (0x90010000), но в памяти это будет записано как 0x00 0x00 0x01 0x90 (то есть 400 в "сетевом" порядке). В примерах кода выше, использовался Python модуль struct; формат ">L" означает 32-битовое без знака в big endian (сокращённо U32BE), где ">" - big-endian, "L" - 32 бита без знака. Делается также API прямой записи в память; в ядре Linux - cpu_to_be32() не даёт промежуточный uint32_t, а сразу пишет в память.

Наиболее известный пример проблемного протокола, который не был рассчитан на проблему разноконечности, а вместо этого реализовывался на разных хостах в зависимости от их локального порядка - юниксовый talk. (Фактически, протокол не был документирован, а был доступен только исходный код клиентских программ, в которых не было предусмотрено конверсии в сетевой порядок.) В результате, переговоры между хостами с разным порядком байт приводили к перестановкам букв. Обновлённая реализация (ntalk) уже была рассчитана на соблюдение порядка независимо от местных особенностей.

Из неожиданных "подводных камней" следует также упомянуть представление UUID (он же GUID в Windows; но не путать с GUID'ом в Infiniband) - часть полей полного ID является числовыми и зависят от локального порядка, а поле MAC - последовательностью байт постоянного порядка. В EFI GPT, GUID'ы диска и раздела представлены в little-endian порядке.

Интересный пример - формат файла дампа libpcap. Оптимизация записи на скорость не допускает конверсии форматов чисел, поэтому формат допускает два варианта дампа - little-endian и big-endian согласно порядку байт на машине - генераторе. Порядок байт распространяется на сигнатуру формата (по ней и определяется порядок, выбранный при записи), поля длин пакетов, сохранения, типа данных и т.д.

Последний интересный пример на разноконечность - формат дескрипторов ISO9660: некоторые поля записываются 8-байтными последовательностями, где первые 4 байта - представление в little endian, вторые - в big endian. В зависимости от архитектуры, код выбирает данные по смещению, для которого ему не нужно делать конверсию представления.

Неожиданностью может быть также разный размер вроде бы обычных типов данных. В 64-битных Unix системах, int - 32 бита, long - 64 - модель LP64. В 64-битной Windows с "родными" компиляторами, int - 32 бита, long - тоже 32 (а для 64 - long long или int64_t) - модель LLP64; хотя в C# словом long называется, внезапно, 64-битное целое. При работе с внешними представлениями желательно использовать типы [u]intN_t из <stdint.h>, чтобы избежать проблем при переносе на другую платформу.

Представления целых чисел полями фиксированной длины пригодны для широкого ряда применений, однако бывают случаи их непригодности по причине недостаточного диапазона представляемых значений, или же, наоборот, чрезмерного затраченного размера. В 802.1p на TOS отведено всего 3 бита, потому что более 8 классов приоритета бывает крайне редко, а место (2 октета на TOS, VLAN и служебные поля) надо экономить. В алгоритмах сжатия данных могут быть ещё более жёсткие ограничения, но даже без этих условий, если какой-то параметр может принимать только значения -38 и 45, естественно закодировать одно из них нулём, а второе - единицей, и потратить один бит вместо восьми или более. С другой стороны, ряд применений может требовать представления чисел произвольных размеров, или просто очень больших размеров. В этом случае разумно переходить на представление числа последовательностью переменной длины.

ASN.1 BER использует два варианта представления целых чисел последовательностями переменной длины. Для целевого типа INTEGER, сначала записывается длина данных, а потом сами данные (в "дополнении до 2" и в big-endian, минимальным числом байтов из возможного). Сама длина тоже может быть достаточно большой, в пределе (длина длины - 126) получаем максимальную длину представления числа равной 2**1008-1, а максимум самого числа невозможно себе представить;) Другой вариант - при записи тега типа данного и компонентов OID'а - теги от 31 и более, и все компоненты OID записываются последовательностью октетов, в которых часть значения формируют младшие 7 бит, а старший бит выступает признаком продолжения (1 - есть ещё октеты тега, 0 - это последний октет); таким образом, этот вариант является самотерминирующимся. Этот дизайн рассчитан на то, что тег крайне редко бывает очень длинным (в реальности, так как он определяет тип данных, он не требует более одного октета - есть мало применений с более чем 32 типами данных), и большинство компонент OID'а малы (например, такой популярный OID, как 1.3.6.1.2.1 (внутренне - 43 6 1 2 1) не содержит частей более 127), а вот целое число может быть произвольно большим.

Пример представлений INTEGER в BER (если несколько вариантов, первый - самый короткий и потому канонический):


0       - 02 01 00, или 02 81 01 00
1       - 02 01 01, или 02 81 01 01
-1      - 02 01 FF, или 02 81 01 FF
127     - 02 01 7F, или 02 81 01 7F
128     - 02 02 00 80, или 02 81 02 00 80
-128    - 02 01 80
-129    - 02 02 FF 7F
2010    - 02 02 07 DA

(BER явно запрещает лишние октеты в представлении самого числа, но не запрещает в представлении длины. Например, для значения 127 метод представления мог бы допустить также запись 02 02 00 7F или 02 04 00 00 00 7F, но тут уже начинает действовать административный запрет.)

Пример представления частного тега типа 2009 в BER: цепочечно разделим с остатком 2009 на 128, получим: 89, 15, 0. Тогда тег типа запишется как DF 8F 59 (при примитивном кодировании) или FF 8F 59 (при составном кодировании), здесь первый байт фиксирован (для всех значений тегов больше либо равных 31), а следующие содержат по 7 бит значения тега.

III.3.3. Вещественные числа (они же числа с плавающей точкой; по английски - floating или real number) могут быть представлены:

(Некоторую сводку форматов представления можно посмотреть, например, здесь.)

Даже в пределах стандарта IEEE754 и при фиксированных базовом формате (двоичный или десятичный) и суммарном размере, вещественные числа могут быть представлены в зависимости от порядка байт в полях двумя разными вариантами (big-endian начинает писаться со стороны порядка и по убыванию значений байтов порядка и мантиссы, а little-endian - наоборот).

Пример представления. 4-байтный двоичный вещественный формат (так называемый single или binary32) согласно IEEE754 состоит (в порядке начиная со старших бит):

  1. 1 бит знака (англ. sign) числа (0 - неотрицательное, 1 - неположительное);
  2. 8 бит порядка (англ. exponent) (поле разделено между двумя байтами), увеличенного на 127; значение 0 значит число 0 или денормализованное число, а значение 255 - специальные значения NaN и INF;
  3. 23 бита мантиссы (англ. significand), не включая старший бит мантиссы (имеющий "внешнее" десятичное значение 1 для всех случаев, кроме смещённого порядка 0, когда этот бит равен 0, и смещённого порядка 255). То есть, если смещённый порядок не равен 0 или 255, то мантисса нормализована.

(Альтернативно можно было бы сказать, что старший бит мантиссы имеет "внешнее" значение 0.5, а порядок увеличен на 126; результат будет тем же. Но это менее удобно для описания случая денормализованного числа, и не соответствует описанию в стандарте.) Для обозначения системы счисления воспользуемся синтаксисом языка Erlang (p#n означает запись n числа по основанию p), префиксы типа 0x или 0b здесь неудобны из-за их неприменимости к дробным числам. "%" - взятие остатка от деления (C-like синтаксис).

Число 27 в этом случае будет представлено как 2#1.1011 * 2**4; смещенный порядок равен 4 + 127 == 131; старший байт представления будет равен 0 (знак) + 131/2 (порядок кроме младшего бита) == 65 == 16#41; второй байт представления будет равен 128*(131%2) (младший бит порядка в старшем бите байта представления) + 2#1011000 (7 бит мантиссы, не включая самый старший) == 16#D8; 2 оставшихся байта равны каждый нулю. Таким образом, представление числа в этом формате выглядит (шестнадцатиричными числами) 41 D8 00 00. На little-endian архитектурах, таких, как x86, оно будет представлено в памяти обратным к этому порядком - 00 00 D8 41.

Все двоичные форматы IEEE754 организованы унифицированно, аналогично описанному выше: знак, смещённый порядок и мантисса; самое большое значение смещённого порядка (все единицы в двоичном представлении) занято для NaN и INF, самое малое (0) - для 0 и денормализованных чисел; смещение порядка равно 2**(np-1)-1, где np - количество бит в поле порядка. Количества бит в полях порядка и мантиссы для разных размеров формата: 16 - 5+10; 32 - 8+23; 64 - 11+52; 128 - 15+112. В основном используются binary32 (single) и binary64 (double); binary16 используется в некоторых графических ускорителях; аппаратная поддержка binary128 - большая редкость.

Поддержка стандартом C "разбора" значения с плавающей точкой на части и обратной сборки состоит из следующих функций:

Этот список неполон, за полным списком смотрите <math.h>.

Разумеется, в случае, если платформа соответствует IEEE754 (таких сейчас большинство), проще воспользоваться этим в платформенно-зависимой манере, не забыв про локальный порядок байт и про нужное локальное представление (4, 8 байт или иное).

Модули работы с внешними представлениями данных сейчас в основном реализуют IEEE754. Примеры использования для нескольких языков:

Erlang:


1> <<27:32/big-float>>.
<<65,216,0,0>>

(На сейчас в Erlang в принципе нет поддержки значений типа NaN, INF.)

Python:


>>> import struct
>>> [ord(x) for x in struct.pack('>f', 27)]
[65, 216, 0, 0]

Perl (вызов через шелл):


$ perl -e 'print pack("f>", 27.0);' | hexdump -C
00000000  41 d8 00 00
$ perl -e 'print pack("f>", 27.0);' | od -t u1
0000000    65 216   0   0
0000004

В то же время, использование IEEE754 может быть неудобным для случаев ограниченных по ресурсам участников (особенно источников данных). Автору данного описания довелось участвовать в разработке встроенного источника данных с резко ограниченными ресурсами, в котором размещение реализации IEEE754 было признано неоптимальным 1) из-за затрат на получение нормализованного представления, 2) из-за сложности чтения полученного представления в сетевых пакетах. Поэтому, основные принципы IEEE754 были отвергнуты и вместо этого выбрана следующая реализация:

  1. Данные представляются в виде 4 октетов, из которых первый (смещение 0) - несмещённый порядок, а остальные (смещение 1-3) - мантисса как целое число со знаком в big-endian.
  2. Мантисса имеет десятичную точку в конце значения, а не вначале; таким образом, при порядке 0 передаваемое значение точно равно мантиссе.
  3. Знак числа не отделён, а закодирован в значении мантиссы, представленном в "дополнении до 2", аналогично тому, как представляются целые числа в большинстве современных архитектур.
  4. Значение порядка 0x80 (-128) означает, что мантисса передаёт такие особые случаи, как отсутствие источника значения, проблемы снятия значения, и так далее.

Преимуществом этого подхода явилось резкое облегчение формирования пакета источником данных. Например, источник, получающий скорость вращения вентилятора в оборотах за 1/64 секунды, передавал скорость неизменной, но октет порядка выставлял равным -6 (0xFA). Значения датчика температуры, формируемые в 1/100 градуса, домножались на 41 и в качестве порядка передавалось -12 (таким образом, получалась погрешность 0.1% при формировании пакета, которая считалась допустимой.)

Двоичное представление вещественных чисел в ASN.1 BER построено в весьма сходном стиле (число как целое, точка справа, порядок не смещается).

III.4. Группирование данных.

Передача одиночного значения (даже такого произвольного, как строка) является редким случаем в реальных протоколах (кроме таких маргинальных ситуаций, как одиночный код ответа). Практически всегда посылка состоит из нескольких полей.

P1. Наиболее простым и быстрым для ряда применений является метод "структуры" (structure; термин языка C и ему подобных), "записи" (record - термин Pascal и родственных языков) - группа байтов фиксированного размера, состоящая из отдельных полей определённого назначения, представления, размера и смещения в группе. Это наиболее пригодный метод для аппаратной обработки, при которой базовые функции выполняется логикой, записанной в структуру логических связей устройства (в английском это называется термином ASIC), как делается в высокоскоростных коммутаторах и маршрутизаторах; поэтому IP, TCP, UDP, практически все протокольные форматы 1-4 уровней используют в основном этот метод.
Особенности применения:

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

2. Достаточно часто используется выравнивание (aligning) данных на соответствующую размеру границу. Например, 4-байтное число будет начинаться со смещения, кратного 4. Это связано с особенностями современной аппаратуры (доступ к правильно выровненным данным быстрее, а на некоторых процессорах доступ к невыровненным данным вообще запрещён). Оборотной стороной является необходимость переупорядочения полей (возможно, не с порядком, который кажется логичным автору протокола) и вероятное появление незаполненных мест в посылке.

3. Возможно появление незаполненных мест в посылке не только по причинам выравнивания, но и как остаток от старых версий или резерв для будущего расширения. Как правило, от передающей стороны требуется заполнение всех таких мест нулевыми байтами.

4. Возможно использование полей, значение которых предопределено и не может меняться в данном протоколе (по-английски обычно называется magic field), используемых для дополнительного контроля, что передаётся именно то, что должно быть. Это обычно менее типично для сетевого взаимодействия (потому что кратковременный контекст не допускает потери информации о том, что передаётся и почему), но очень распространено при долговременном хранении, когда такие поля помогают как проверить целостность данных, так и уточнить применение, когда точные сведения о типе данных потеряны или искажены.

5. Иногда (особенно на младших уровнях модели OSI) используются контрольные суммы, передаваемые в соответствующих полях.

6. Состав полей в посылке (её фиксированной части) и даже её полный размер могут быть непостоянным и варьироваться как в зависимости от внешнего контекста (версии протокола, договорённости о параметрах, etc.), так и от содержимого других полей. Например, может быть вариант "если в поле X бит 0 равен 0, то в байтах 8-15 находится поле Y; иначе, в 8-11 находится Z+, а в 12-15 - Z-". Другим крайним примером является случай IEEE802+Ethernet "если значение поля тип/длина >= 0x600, это тип, иначе это длина". Основное правило реализации подобного подхода - приёмная сторона должна иметь алгоритм однозначной интепретации посылки; если его нет - подобный формат недопустим.

7. Варьирование размера может использоваться для прямого или косвенного указания версии протокола. Характерным примером является интерфейс EDD (расширение PC BIOS для x86-совместимых компьютеров). Посылка начинается с поля длины, которую вызывающая сторона заполняет значением, соответствующим версии интерфейса и в то же время указывающем размер отведенного для данных блока. Вызванная функция может уменьшить этот размер до того, который она реально использовала, но не может увеличить этот размер; по возвращённому размеру определяется версия согласованного между обеими сторонами интерфейса. Ещё пример - структура LVITEM (Microsoft, Common Controls) - здесь первое поле mask является полем битовых флагов, по одному на поле данных, и расширение производится за счёт объявления новых флагов для новых полей.

P2. В тех случаях, когда использование фиксированного формата недостаточно или неэффективно, применяются методы передачи данных переменного состава и размера.

AV-list (AV-список), AVP-list - один из наиболее простых и распространённых методов такой передачи. Область посылки, отведённая для данных, делится на последовательность (обычно сплошную, но иногда - с выравниванием) субпосылок (AV-пара, attribute-value pair), в которых указываются длина, имя атрибута (которое определяет семантику для интепретации и применения; например "количество попыток", "содержание ответа", но никак не "число" или "строка") и его значение. Длина чаще передаётся в самом начале такой субпосылки, но иногда - после имени атрибута; крайне редко длина вычисляется по имени атрибута (это практически всегда грубая ошибка проектирования протокола, потому что такой протокол очень сложно расширять - приложения, не знающие правила вычисления длины нового атрибута, не смогут его разобрать; эта ошибка была совершена с протоколом telnet). Разумеется, правила парсинга длины и имени атрибута должны быть строго однозначными. Тип содержимого атрибута (целое, вещественное, строка...), как правило, определяется уже приложением по его имени. Конец AV-списка определяется или окончанием посылки, определённой по известной полной длине, или (часто) по длине 0 в начале парсинга очередной AV-пары.

Имя атрибута в такой схеме может быть устроено достаточно произвольным образом - например, целое число фиксированного размера, или UUID (для уникальности выдачи), или текстовая строка. Важны определённость схемы именования и однозначность парсинга.

Иногда используется термин "TLV" (расшифровывается type - length - value; например, есть такая статья в Википедии) для обозначения элемента такого списка, но слово type тут не совсем адекватно именно возможностью его перепутать с типом содержимого атрибута (что заметно и по той статье на момент написания данного текста - смешиваются случаи указания кода атрибута и типа данных, как в ASN.1 BER). По аналогии, следовало бы более распространённый вариант схемы назвать "NLV" (name - length - value), но термин TLV уже закрепился в общем сознании.

Дополнительным субэлементом в AV-паре (или даже в имени атрибута) могут передаваться, кроме базовой семантики имени как признака, определяющего правила интерпретации и применения атрибута, ряд дополнительных признаков. Например, для взаимодействия разных реализаций протокола может оказаться важным указать, обязательно ли понимание конкретного атрибута для понимания всей посылки, или же сторона, не понявшая его, может игнорировать данный атрибут. Это применяется, например, в BGP4 (см. RFC4271): поле attribute flags атрибута содержит бит Optional - разрешение игнорировать неизвестный получателю атрибут.

Примеры использования AV-списка:

Некоторые применения RADIUS показывают стиль расширения пространства кодов атрибутов за счёт вендоро-зависимых типов и произвольных строк. Общий код атрибута "vendor-specific", содержащий в начале данных код вендора, позволяет расширять пространство кодов атрибутов, а произвольная строка (например, AV-string у Cisco) позволяет передать в формате "имя=значение" произвольные данные, представляя код атрибута текстовой строкой.

P3. Совершенно отдельным методом представления являются encoding rules для ASN.1, такие, как Basic Encoding Rules (BER) и их вариации (CER, DER). Посылка данных представлена в них как TLV (это включает и сложные данные, в частности, контейнеры), но с возможностью кодирования длины не только "определённым" (definite) вариантом, когда длина передаётся в начале, но и "неопределённым" (indefinite), который является вариацией на тему описанного выше chunked transfer encoding - несколько частей с указанием длины каждой. Поле типа передаёт именно тип содержимого данных, а не семантику использования; семантика определяется за счёт порядка полей, с возможностью пропуска отдельных полей при гарантии однозначного разбора содержимого посылки. Тип данных относится к одному из четырёх множеств подтипов - общие (universal) - строка, число, время, etc.; для применения (application); для конкретной программы (private); для контекста (context-specific). Основным вариантом передачи AV-пары считается передача значения, тегированного (явно или неявно) выбранным context-specific тегом типа; но в таком варианте невозможно задать несколько одноимённых атрибутов. Более универсально передавать такой список как SEQUENCE_OF или SET_OF элементов типа INSTANCE_OF, но тогда для каждого типа нужно определить OID (увеличиваются затраты места). AV-список, как группа таких AV-пар, может кодироваться как SEQUENCE, SEQUENCE OF, SET, SET OF в зависимости от особенностей интерпретации (например, если порядок AV-пар не важен, то можно использовать SET [OF], иначе - SEQUENCE [OF]). Суммируя, ASN.1 ориентирован на косвенную передачу имени за счёт однозначности разбора, но позволяет назначать явные имена, если автор протокола (формата) заинтересован в этом.

Форматы представления для ASN.1 более детально рассматриваются в главе "Структурированные форматные схемы".

III.4. Контрольные суммы.

Контрольные суммы имеют большое значение на младших уровнях протокольного стека, при долговременном хранении, при котором надо обеспечивать контроль целостности данных, и при необходимости криптографической защиты целостности. Мы не будем рассматривать здесь криптографическую защиту; упомянем лишь, что наиболее традиционным путём является использование криптографических хэшей (MD*, SHA* и других), с хранением сумм или ключей в более надёжном месте (транспортом более надёжным путём) или с применением алгоритмов цифровой подписи. Нам здесь важнее нижние уровни и защита целостности данных от ошибок транспорта.

Наиболее типичным является применение алгоритмов группы CRC (а из них - CRC-16-BSC, CRC-16-CCITT, CRC-32). Входом алгоритма является поток данных, а выходом - значение контрольной суммы, которое надо помещать в пакет. Сами алгоритмы общеизвестны и расписывать их тут не будем, но есть ряд обычно не упоминаемых особенностей применения:

1. Начальное состояние аккумулятора: рекомендуется использование состояния "все единицы", поскольку оно показывает нелинейную зависимость суммы от входного потока уже на первых его байтах (в отличие от "всех нулей", когда для суммируемой части короче CRC сумма повторяет суммируемую часть).

2. Порядок подачи потока в эквивалентном битовом виде: на каналах связи обычно контрольная сумма считается так, что в каждом байте первым обрабатывается самый младший бит. Большинство известных реализаций алгоритмов CRC с побайтовой обработкой и массивом из 256 значений неявно рассчитаны на этот порядок.

3. Как помещается итоговая контрольная сумма в исходящий поток: во многих методах она побитово инвертируется, чтобы избежать корреляции с соседними байтами в случае систематической ошибки в одну сторону (1 или 0). Также, возможна укладка в big-endian или little-endian (другие вариации применяются значительно реже).

4. Приёмный блок может использовать подсчёт суммы в размере полезной части пакета и сравнение с пришедшей со входа, или же подсчёт суммы в размере полезной части пакета плюс входящая контрольная сумма и сравнение результата с константой. Во втором случае в подсчёт суммы при передаче добавляется последовательность из нулевых битов, равная по длине контрольной сумме.

Кроме того, следует учесть условия применимости циклических контрольных сумм: они устойчиво работают при внешней терминации посылки подложкой, но не в случае потери такого терминатора. Если подложка имеет проблемы с определением конца посылки, лучше применять не циклическую, а обыкновенную сумму байтов или "слов" (с диапазоном значений, достаточным, чтобы не происходило переполнения её значения). Видимо, это явилось причиной отказа от CRC в форматах заголовков IP, TCP, UDP, в пользу линейной суммы.

III.5. Проблема версионности рассмотрена в основных чертах в главе II. Какова её специфика для бинарного протокола?

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

Возможно использование особых значений в полях данных. Например, если температура кодируется одним байтом и может принимать значения от 0 до 100, значения 101-255 недопустимы для прежней интерпретации и могут быть использованы для расширения.

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

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

Как уже сказано было в главе II, структурированные форматные схемы (ASN.1+BER, XML, JSON и т.д.) допускают неограниченное расширение, которое нельзя запретить построением протокола; это, однако, не относится к предельно экономящим представлениям, как PER.

III.6. Как будет выглядеть адаптация обмена данными к применяемой подложке?

Подложка может вводить следующие ограничения:

1. Размер отдельной посылки. Это относится к классу 2 (датаграммный) - напрямую, классу 3 (битовый поток) - косвенно за счёт ухудшения оперативности передачи и сокращения качества контроля искажений; в случае класса 1 (TCP, pipe) - проблемой может быть сокращение оперативности передачи (когда очень большая посылка, в мегабайты и более, передаётся по соедеинению, не может быть передано короткое, но срочное сообщение). Ограничение на размер может быть очень жёстким (для IPv6 минимальное MTU - 1280 байт, но для IPv4 - формально 68; реальный замечавшийся минимум в сетях - 296 байт; типично сейчас с учётом туннелей рассчитывать на 1400 байт). Если посылка не влезает в отдельную порцию данных - её надо резать на фрагменты и передавать, сообщая другой стороне данные, достаточные для обратной сборки. В IP сетях во многих случаях можно обойтись IP фрагментацией, хоть это и не рекомендуется.

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

Для DNS и SIP существует стандартная рекомендация использовать TCP, если посылка превышает граничный размер, и UDP, если она меньше граничного размера. Рекомендованные значения для граничного размера - 512 и 1300 байт соответственно; такое сильное расхождение между размерами отражает не только историю развития Internet, но и тот факт, что принципиальность этой границы резко ниже, чем кажется.

В случае использования отдельных битов и битовых полей в байтах и словах возникает проблема доступа к их значениям, как на чтение, так и на запись. Во-первых, как их обозначить для документирования? Это рассматривается в отдельной главе, и рекомендуем для понимания подраздела заглянуть туда. Для данного описания мы примем стиль 0=LSB.

Представим себе битовое поле из двух бит - 3 и 2 в байте по смещению 17. Сначала вариант без использования синтаксиса битовых полей в Си. Чтобы прочитать значение:


 unsigned char *data;
 ...
 v = (data[17] >> 2) & 3
альтернативно (чуть менее надёжно) последняя строка:

 v = (data[17] & 0x0c) >> 2

Для записи значения нужно выполнить:


 data[17] = (data[17] & ~0x0c) | (v << 2)

Фактически тут требуются две константы: маски битов значения (2 бита - равна 3, потому что 11 в двоичной), сдвига в поле (2); константа маски в поле (0x0c) равна первой, сдвинутой влево на вторую. Всё это достаточно неудобно в применении.

Альтернатива - битовые поля. Но для них нет переносимого между платформами и компиляторами правила, как распределяются поля в значении. Разные компиляторы по-разному решают это. Например, gcc начинает заполнять со старших бит для big-endian платформ и с младших - наоборот. Это даёт то преимущество, что если сериализовать значения в побитное последовательное представление в том же порядке, то поля не будут иметь разрывов, даже если они переходят границу байта, независимо от того, как считать размеры "чисел", которые хранят эти поля с точки зрения процессора. Зато с gcc приходится учитывать порядок бит для описания структур с фиксированным порядком - например, для заголовка IP пакета (цитата из <netinet/ip.h> из FreeBSD):


struct ip {
#if BYTE_ORDER == LITTLE_ENDIAN
        u_int   ip_hl:4,                /* header length */
                ip_v:4;                 /* version */
#endif
#if BYTE_ORDER == BIG_ENDIAN
        u_int   ip_v:4,                 /* version */
                ip_hl:4;                /* header length */
#endif
[...]
};

В заголовке IP пакета поле ip_v занимает старшие 4 бита байта (октета) по смещению 0, ip_hl - его же младшие 4 бита.

Многие компиляторы используют ту же политику заполнения, что gcc, но это непереносимо (С99 явно говорит про implementation defined, пункт 6.7.2.1.10). В общем случае сюда бы хорошо подошло введение специальных атрибутов для структур, типа "заполнять в порядке, соответствующем конкретному порядку байт, без непрошеных выравниваний".

(Пример на разрыв от неправильного порядка. Представим себе, например, структуру с unsigned a:15, b:3, c:14; — если на LE машине оно будет заполняться с младших бит, то независимо от того, как мы считаем: оно лежит 1, 2, 4, 8-байтными числами — поля будут связаны; но если заполнять со старших бит, то считая, что заполняется по 4 байта, "b" будет лежать в байтах 1 и 2, а если по 2 байта и b лежит в двух таких двухбайтовых целых — то оно окажется в байтах 0 и 3.)

XXX to be continued

Copyright (C) 2006-2015 Valentin Nechayev. All rights reserved.
Разрешается полное или частичное копирование, цитирование. При полном или частичном копировании ссылка на оригинал обязательна.

Назад Вверх Вперёд