Как работать с бинарными данными для создания своего собственного формата файлов

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

Битовые операции

Если вы не знаете о битовых операциях, то в коде встретите непонятные символы, в частности: &, |, << и >>. Это стандартные битовые операции для работы с двоичным представлением чисел, доступные в большинстве языков программирования.

Порядок байтов и потоки

Прежде чем начать, давайте разберем два важных определения: порядок байтов (endiannes) и потоки.

Порядок байтов определяет — как это ни странно — порядок байтов (простите за тавтологию). Предположим, что у нас есть 16-битное число со значением 0x1020. В двоичном виде число может быть представлено по-разному: байт 0x20, а следом за ним байт со значением 0x10 (это обратный порядок байтов), или 0x10, после которого стоит байт 0x20 (это прямой порядок байтов).

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

Считывание двоичных данных

Давайте начнем с определения некоторых полей. В идеале все они должны быть в секции private:

__stream   // объект, подобный массиву и содержащий байты
__endian   // порядок данных в потоке
__length   // количество байт в потоке
__position // положение следующего байта для чтения из потока

А вот так может выглядеть конструктор нашего класса:

class DataInput( stream, endian ) {
  __stream   = stream
  __endian   = endian
  __length   = stream.length
  __position = 0
}

Следующие функции будут читать из потока целые беззнаковые числа:

// чтение 8-битного беззнакового целого числа
function readU8() {
  // выбрасываем исключение, если больше нет байтов для считывания
  if( __position >= __length ) {
    throw new Exception( "..." )
  }
  // возвращаем значение байта и увеличиваем положение следующего байта для его корректного считывания
  return __stream[ __position ++ ]
}
 
// чтение 16-битного беззнакового целого числа
function readU16() {
  value = 0
  // так как число состоит из нескольких байт, то обрабатываем 2 случая
  if( __endian == BIG_ENDIAN ) { //прямой порядок байтов
    value |= readU8() << 8
    value |= readU8() << 0
  } else {
    // обратный порядок байтов
    value |= readU8() << 0
    value |= readU8() << 8
  }
  return value
}
 
// чтение 24-битного беззнакового целого числа
function readU24() {
  value = 0
  if( __endian == BIG_ENDIAN ) {
    value |= readU8() << 16
    value |= readU8() << 8
    value |= readU8() << 0
  } else {
    value |= readU8() << 0
    value |= readU8() << 8
    value |= readU8() << 16
  }
  return value
}
 
// чтение 32-битного беззнакового целого числа
function readU32() {
  value = 0
  if( __endian == BIG_ENDIAN ) {
    value |= readU8() << 24
    value |= readU8() << 16
    value |= readU8() << 8
    value |= readU8() << 0
  } else {
    value |= readU8() << 0
    value |= readU8() << 8
    value |= readU8() << 16
    value |= readU8() << 24
  }
  return value
}

Эти функции будут считывать знаковые числа:

// чтение 8-битного знакового целого числа
function readS8() {
  // считываем беззнаковое число
  value = readU8()
  // смотрим старший бит (означающий знак числа)
  if( value >> 7 == 1 ) {
    // используем дополнительный код для конвертирования значения
    value = ~( value ^ 0xFF )
  }
  return value
}
 
// чтение 16-битного знакового целого числа
function readS16() {
  value = readU16()
  if( value >> 15 == 1 ) {
    value = ~( value ^ 0xFFFF )
  }
  return value
}
 
// чтение 24-битного знакового целого числа
function readS24() {
  value = readU24()
  if( value >> 23 == 1 ) {
    value = ~( value ^ 0xFFFFFF )
  }
  return value
}
 
// чтение 32-битного знакового целого числа
function readS32() {
  value = readU32()
  if( value >> 31 == 1 ) {
    value = ~( value ^ 0xFFFFFFFF )
  }
  return value
}

Запись двоичных данных

Как и в примере выше, начнем с определения полей нашего класса. Различий будет не много, но они все же будут. Как уже говорилось, в идеале все представленные ниже поля должны быть в секции private:

__stream   // объект, подобный массиву и содержащий байты
__endian   // порядок данных в потоке
__position // положение следующего байта для записи в поток

Вот так будет выглядеть конструктор класса:

class DataOutput( stream, endian ) {
  __stream   = stream
  __endian   = endian
  __position = 0
}

Следующие функции будут записывать в поток целые беззнаковые числа:

// запись 8-битного беззнакового целого числа
function writeU8( value ) {
  // следующая строчка обеспечивает беззнаковость
  value &= 0xFF
  // добавляем значение в поток и увеличиваем положение следующего байта
  __stream[ __position ++ ] = value
}
 
// запись 16-битного беззнакового целого числа
function writeU16( value ) {
  value &= 0xFFFF
  // скорректируем число в зависимости от порядка байтов
  if( __endian == BIG_ENDIAN ) { //прямой порядок
    writeU8( value >> 8 )
    writeU8( value >> 0 )
  } else {
    // обратный порядок
    writeU8( value >> 0 )
    writeU8( value >> 8 )
  }
}
 
// запись 24-битного беззнакового целого числа
function writeU24( value ) {
  value &= 0xFFFFFF
  if( __endian == BIG_ENDIAN ) {
    writeU8( value >> 16 )
    writeU8( value >> 8  )
    writeU8( value >> 0  )
  } else {
    writeU8( value >> 0  )
    writeU8( value >> 8  )
    writeU8( value >> 16 )
  }
}
 
// запись 32-битного беззнакового целого числа
function writeU32( value ) {
  value &= 0xFFFFFFFF
  if( __endian == BIG_ENDIAN ) {
    writeU8( value >> 24 )
    writeU8( value >> 16 )
    writeU8( value >> 8  )
    writeU8( value >> 0  )
  } else {
    writeU8( value >> 0  )
    writeU8( value >> 8  )
    writeU8( value >> 16 )
    writeU8( value >> 24 )
 }
}

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

// запись 8-битного беззнакового целого числа
function writeS8( value ) {
  writeU8( value )
}
 
// запись 16-битного беззнакового целого числа
function writeS16( value ) {
  writeU16( value )
}
 
// запись 24-битного беззнакового целого числа
function writeS24( value ) {
  writeU24( value )
}
 
// запись 32-битного беззнакового целого числа
function writeS32( value ) {
  writeU32( value )
}

Вывод

И на этом все! Теперь вы знаете, как можно осуществить чтение и запись двоичных данных.

Перевод статьи «How to Read and Write Binary Data for Your Custom File Formats»