char, unsigned char и побайтовое чтение из файла размера очередной порции данных

Часто данные в файле записываются в виде: сколько-то байтов - размер очередной порции, затем - собственно данные. Изучал код на Си, читающий такой файл, и наткнулся на следующую конструкцию:
char sizeLow, sizeHigh;
int size;
FILE * pFile;
pFile = fopen ("myfile.bin" , "rb");
fread (&sizeHigh, 1, 1, pFile);
fread (&sizeLow, 1, 1, pFile);
size = (sizeHigh << 8) | sizeLow; 
Выяснилось, что после такого чтения size легко может оказаться отрицательным, даже если и был записан вполне себе положительным (например, если первый байт был равен 0x1, а второй - 0xff).
Итак, возьмем пример с такими первыми двумя байтами: 0x01 0xff.
Здесь есть два интересных для меня момента:
  1. При чтении оба эти байта записываются в переменные типа char. Знаковость или беззнаковость char зависит от реализации компилятора. В моем случае (gcc version 4.8.2) char оказался знаковым. То есть младший байт хоть и имеет 16-ричное представление 0xff, но хранится и воспринимается компилятором скорее как -1.
  2. Для побитовых операций наименьший размер операндов - int. То есть в нашем случае все операнды сначала неявно приводятся к int, а затем только вычисляется выражение.
В следствие этого происходит следующее: sizeLow имеет шестнадцатеричное представление 0xff, затем он неявно преобразуется в int, при этом, так как char - знаковый тип, первый бит, который отвечает за знак, расширяется на старшие разряды. В итоге получается 0xffffffff (единица в страшем бите единственного байта char расширяется на три байта, за счет которых char был расширен до int). После этого, понятно, результат побитового "ИЛИ" с sizeHigh, сдвинутым на один байт, нас уже не устроит, потому что результатом всегда будут единицы в трех старших байтах. В итоге в sizе, вне зависимости о того, знаковый ли он или беззнаковый, оказывается неверное значение (в случае знакового - отрицательное).
Избежать этого можно двумя путями.
Первый: обнулить старшие байты операндов после расширения до int:
size = (sizeHigh & 0xff << 8) | (sizeLow & 0xff); 
Теперь, даже если char был знаковым, и при расширении до int знаковый бит расширился на все старшие биты, они тут же обнуляются с помощью побитового "И" с 0xff. Второй вариант - это использовать беззнаковые типы:
unsigned char sizeLow, sizeHigh;
int size; // здесь тоже логично использовать беззнаковый тип, но это не существенно в данном примере
FILE * pFile;
pFile = fopen ("myfile.bin" , "rb");
fread (&sizeHigh, 1, 1, pFile);
fread (&sizeLow, 1, 1, pFile);
size = (sizeHigh << 8) | sizeLow; 
В этом случае sizeLow при расширении до int сохраняет значение 0xff, поэтому в итоге в size мы получим нужное нам значение 0x01ff.
Следует отметить, что в случае C++ упомянутый gcc 4.8.2 ведет себя точно так же.

4 комментария :

  1. Лучше конечно пользоваться stdint с uint8_t а для размеров только size_t

    ОтветитьУдалить
    Ответы
    1. да, лучше. Но сейчас посмотрел внимательнее на проблему: изначально использовался std::ifstream и, соответственно, метод read (char* s, streamsize n), который хочет получать указатель на char. И единственный вариант тут, похоже, это сделать после расширения &0xff, и обнулить старшие биты. Теперь у меня возник вопрос, зачем был взят тип char для буфера в read.

      Удалить
  2. У GCC есть два управления: -fsigned-char и -funsigned-char . Это на случай, когда окажется под рукой компилятор не той системы.

    $ gcc -v --help | grep "signed-char"
    -fsigned-char Make "char" signed by default
    -funsigned-char Make "char" unsigned by default

    ОтветитьУдалить