Как правильно преобразовать 2 байта в 16-разрядное целое число со знаком?

31

В этом ответе , zwol сделал это заявление:

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

#include <stdint.h>

int16_t be16_to_cpu_signed(const uint8_t data[static 2]) {
    uint32_t val = (((uint32_t)data[0]) << 8) | 
                   (((uint32_t)data[1]) << 0);
    return ((int32_t) val) - 0x10000u;
}

int16_t le16_to_cpu_signed(const uint8_t data[static 2]) {
    uint32_t val = (((uint32_t)data[0]) << 0) | 
                   (((uint32_t)data[1]) << 8);
    return ((int32_t) val) - 0x10000u;
}

Какая из вышеуказанных функций подходит, зависит от того, содержит ли массив представление с прямым или обратным порядком байтов. Порядок байтов не является проблемой на вопрос здесь, я задаюсь вопросом, почему zwol вычитает 0x10000uиз uint32_tзначения преобразуются в int32_t.

Почему это правильный путь ?

Как избежать поведения, определенного при реализации, при преобразовании в тип возвращаемого значения?

Так как вы можете предположить представление дополнения 2, как это простое приведение завершится неудачно: return (uint16_t)val;

Что не так с этим наивным решением:

int16_t le16_to_cpu_signed(const uint8_t data[static 2]) {
    return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
}
chqrlie
источник
Точное поведение при приведении к int16_tзависит от реализации, поэтому наивный подход не переносим.
nwellnhof
@nwellnhof нет актеровint16_t
ММ
На вопрос в названии нельзя ответить, не указав, какое отображение использовать
ММ
4
Оба подхода основаны на поведении, определяемом реализацией (преобразование значения без знака в тип со знаком, который не может представлять значение). Например. в первом подходе 0xFFFF0001uне может быть представлен как int16_t, а во втором подходе 0xFFFFuне может быть представлен как int16_t.
Сандер Де Дайкер
1
«Так как вы можете предполагать представление дополнения 2» [цитата необходима]. C89 и C99, конечно же, не отрицали представления 1с дополнения и величины знака. Qv, stackoverflow.com/questions/12276957/…
Эрик Тауэрс

Ответы:

20

Если int16-битный, то ваша версия опирается на поведение, определяемое реализацией, если значение выражения в returnвыражении выходит за пределы диапазона int16_t.

Однако первая версия также имеет аналогичную проблему; например, если int32_ttypedef для int, а входные байты оба 0xFF, то результатом вычитания в операторе return является то, UINT_MAXчто вызывает поведение, определяемое реализацией, при преобразовании в int16_t.

ИМХО ответ, на который вы ссылаетесь, имеет несколько основных проблем.

М.М.
источник
2
Но как правильно?
Идеман
@idmean вопрос нуждается в разъяснении, прежде чем на него можно будет ответить, я попросил в комментарии под вопросом, но OP не ответил
MM
1
@MM: я редактировал вопрос, чтобы уточнить, что проблема не в порядке байтов. ИМХО проблема, которую пытается решить zwol, - это поведение, определяемое реализацией при преобразовании в тип назначения, но я согласен с вами: я считаю, что он ошибается, поскольку у его метода есть другие проблемы. Как бы вы эффективно решили поведение, определяемое реализацией?
Chqrlie
@chqrlieforyellowblockquotes Я не имел в виду конкретно порядок байтов. Вы просто хотите поместить точные биты двух входных октетов в int16_t?
ММ
@ ММ: да, это именно вопрос. Я написал байты, но правильное слово действительно должно быть октетами, как и тип uchar8_t.
Chqrlie
7

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

int le16_to_cpu_signed(const uint8_t data[static 2]) {
    unsigned value = data[0] | ((unsigned)data[1] << 8);
    if (value & 0x8000)
        return -(int)(~value) - 1;
    else
        return value;
}

Из-за ветки, это будет дороже, чем другие варианты.

Это позволяет избежать каких-либо предположений о том, как intпредставление относится к unsignedпредставлению на платформе. Приведение к intтребуется для сохранения арифметического значения для любого числа, которое будет соответствовать целевому типу. Поскольку инверсия гарантирует, что старший бит 16-разрядного числа будет равен нулю, значение будет соответствовать. Тогда унарное -и вычитание 1 применяют обычное правило для отрицания дополнения 2. В зависимости от платформы INT16_MINможет все еще переполниться, если он не соответствует intтипу на цели, и в этом случае longследует использовать.

Разница с оригинальной версией в вопросе приходит во время возврата. В то время как оригинал всегда всегда вычитался, 0x10000а дополнение 2 позволяло знаменному переполнению переносить его в int16_tдиапазон, в этой версии есть явное, ifкоторое избегает подписанного переноса (который не определен ).

Сейчас на практике почти все платформы, используемые сегодня, используют представление дополнения 2. Фактически, если у платформы есть совместимый со стандартом, stdint.hкоторый определяет int32_t, это должно использовать дополнение 2 для этого. Иногда этот подход оказывается полезным при использовании некоторых языков сценариев, которые вообще не имеют целочисленных типов данных - вы можете изменить операции, показанные выше для чисел с плавающей запятой, и это даст правильный результат.

JPA
источник
Стандарт C, в частности, предписывает, что int16_tи любой, intxx_tи их беззнаковые варианты должны использовать представление дополнения 2 без битов заполнения. Для размещения этих типов и использования другого представления потребовалась бы целенаправленная извращенная архитектура int, но я предполагаю, что DS9K можно настроить таким образом.
Chqrlie
@chqrlieforyellowblockquotes Хороший вопрос, я решил использовать, intчтобы избежать путаницы. Действительно, если платформа определяет, int32_tэто должно быть дополнение 2.
JPA
Эти типы были стандартизированы в C99 следующим образом: C99 7.18.1.1 Целочисленные типы с точной шириной Имя typedef intN_t обозначает целочисленный тип со Nint8_tзнаком с шириной , без битов заполнения и представление дополнения до двух. Таким образом, обозначает целочисленный тип со знаком шириной ровно 8 бит. Другие представления все еще поддерживаются стандартом, но для других целочисленных типов.
Chqrlie
В вашей обновленной версии (int)valueимеет поведение, определяемое реализацией, если тип intимеет только 16 бит. Я боюсь, что вам нужно использовать (long)value - 0x10000, но на архитектурах дополнения не 2, значение 0x8000 - 0x10000не может быть представлено как 16-битный int, поэтому проблема остается.
Chqrlie
@chqrlieforyellowblockquotes Да, только что заметил то же самое, я исправил с помощью ~, но longбудет работать одинаково хорошо.
JPA
6

Другой метод - использование union:

union B2I16
{
   int16_t i;
   byte    b[2];
};

В программе:

...
B2I16 conv;

conv.b[0] = first_byte;
conv.b[1] = second_byte;
int16_t result = conv.i;

first_byteи second_byteможет быть заменен в соответствии с маленькой или большой байтовой моделью. Этот метод не лучше, но является одной из альтернатив.

i486
источник
2
Разве объединение не наказывает за неопределенное поведение ?
Максим Егорушкин
1
@MaximEgorushkin: Википедия не является авторитетным источником для интерпретации стандарта C.
Эрик Постпишил
2
@EricPostpischil Фокусироваться на мессенджере, а не на сообщении, неразумно.
Максим Егорушкин
1
@MaximEgorushkin: о да, ой, я неправильно прочитал ваш комментарий. Предполагая , byte[2]и int16_tимеют тот же размер, что один или другой из двух возможных порядков, а не какие - то произвольные перемешиваются битовые значений места. Таким образом, вы можете, по крайней мере, определить во время компиляции, какой порядковый номер имеет реализация.
Питер Кордес
1
В стандарте четко указано, что значение элемента объединения является результатом интерпретации сохраненных битов в элементе как представление значения этого типа. Существуют аспекты, определяемые реализацией, поскольку представление типов определяется реализацией.
ММ
6

Арифметические операторы shift и bitwise-or в выражении (uint16_t)data[0] | ((uint16_t)data[1] << 8)не работают с типами, меньшими чем int, так что эти uint16_tзначения повышаются до int(или unsignedif sizeof(uint16_t) == sizeof(int)). Тем не менее, это должно дать правильный ответ, так как только младшие 2 байта содержат значение.

Еще одна педантически правильная версия для преобразования с прямым порядком байтов в младший (если предполагается, что процессоры с прямым порядком байтов):

#include <string.h>
#include <stdint.h>

int16_t be16_to_cpu_signed(const uint8_t data[2]) {
    int16_t r;
    memcpy(&r, data, sizeof r);
    return __builtin_bswap16(r);
}

memcpyиспользуется для копирования представления, int16_tи это является стандартным способом сделать это. Эта версия также компилируется в 1 инструкцию movbe, см. Сборку .

Максим Егорушкин
источник
1
@MM Одна из причин __builtin_bswap16заключается в том, что замена байтов в ISO C не может быть реализована так эффективно.
Максим Егорушкин
1
Не правда; компилятор может обнаружить, что в коде реализована замена байтов, и перевести его как эффективную встроенную функцию
ММ
1
Преобразование int16_tв uint16_tхорошо определено: отрицательные значения преобразуются в значения больше, чем INT_MAX, но преобразование этих значений обратно uint16_tявляется поведением, определяемым реализацией: 6.3.1.3 Целые числа со знаком и без знака 1. Когда значение с целочисленным типом преобразуется в другой целочисленный тип, отличный от _Bool, если значение может быть представлено новым типом, оно не изменяется. ... 3. В противном случае новый тип подписывается и значение не может быть представлено в нем; либо результат определяется реализацией, либо определяется сигнал реализации.
Chqrlie
1
@MaximEgorushkin gcc, кажется, не очень хорошо работает в 16-битной версии, но clang генерирует тот же код для ntohs/ __builtin_bswapи |/ <<pattern: gcc.godbolt.org/z/rJ-j87
PSkocik
3
@MM: Я думаю, что Максим говорит "не может на практике с текущими компиляторами". Конечно, компилятор не может сосать и распознавать загрузку непрерывных байтов в целое число. GCC7 или 8, наконец, вновь представили объединение нагрузки / хранилища для случаев, когда обратный байтов не требуется, после того, как GCC3 отбросил его десятилетия назад. Но в целом компиляторам, как правило, нужна помощь со многими вещами, которые процессоры могут делать эффективно, но которые ISO C пренебрегал / отказывался выставлять на перенос. Portable ISO C не является хорошим языком для эффективной работы с битами / байтами кода.
Питер Кордес
4

Вот еще одна версия, которая опирается только на переносимое и четко определенное поведение (заголовок #include <endian.h>не стандартный, код такой):

#include <endian.h>
#include <stdint.h>
#include <string.h>

static inline void swap(uint8_t* a, uint8_t* b) {
    uint8_t t = *a;
    *a = *b;
    *b = t;
}
static inline void reverse(uint8_t* data, int data_len) {
    for(int i = 0, j = data_len / 2; i < j; ++i)
        swap(data + i, data + data_len - 1 - i);
}

int16_t be16_to_cpu_signed(const uint8_t data[2]) {
    int16_t r;
#if __BYTE_ORDER == __LITTLE_ENDIAN
    uint8_t data2[sizeof r];
    memcpy(data2, data, sizeof data2);
    reverse(data2, sizeof data2);
    memcpy(&r, data2, sizeof r);
#else
    memcpy(&r, data, sizeof r);
#endif
    return r;
}

Версия с прямым порядком байтов компилируется в одну movbeинструкцию clang, gccверсия менее оптимальна, см. Сборку .

Максим Егорушкин
источник
@chqrlieforyellowblockquotes Похоже, что ваша главная задача заключалась uint16_tв int16_tконверсии, в этой версии нет конверсии, так что вы идете.
Максим Егорушкин
2

Я хочу поблагодарить всех авторов за их ответы. Вот к чему сводится коллективная работа:

  1. В соответствии с C Стандарт 7.20.1.1 Точной шириной целых типов : типы uint8_t, int16_tиuint16_t должен использовать комплемент представление двоичного без каких - либо бит заполнения, так что фактические биты представления однозначно являются те , из 2 -х байт в массиве, в порядке , определенном имена функций.
  2. вычисление 16-разрядного значения без знака с помощью (unsigned)data[0] | ((unsigned)data[1] << 8)(для версии с прямым порядком байтов) компилируется в одну инструкцию и дает 16-разрядное значение без знака.
  3. Согласно стандарту C 6.3.1.3 Целые числа со знаком и без знака : преобразование значения типа uint16_tв тип со знаком int16_tимеет поведение, определяемое реализацией, если значение не находится в диапазоне типа назначения. Специальных положений для типов, представление которых точно определено, не предусмотрено.
  4. Чтобы избежать этого поведения, определенного реализацией, можно проверить, больше ли значение без знака, INT_MAXи вычислить соответствующее значение со знаком путем вычитания 0x10000. Выполнение этого для всех значений, как предложено zwol, может привести к значениям вне диапазона int16_tс таким же поведением, определенным реализацией.
  5. проверка на 0x8000бит явно приводит к тому, что компиляторы создают неэффективный код.
  6. более эффективное преобразование без определенного поведения реализации использует наказание типов через объединение, но дебаты относительно определенности этого подхода все еще открыты, даже на уровне комитетов стандарта C.
  7. Пуннинг типа может быть выполнен переносимым и с определенным поведением, используя memcpy.

Комбинируя пункты 2 и 7, вот переносимое и полностью определенное решение, которое эффективно компилируется в одну инструкцию с использованием gcc и clang :

#include <stdint.h>
#include <string.h>

int16_t be16_to_cpu_signed(const uint8_t data[2]) {
    int16_t r;
    uint16_t u = (unsigned)data[1] | ((unsigned)data[0] << 8);
    memcpy(&r, &u, sizeof r);
    return r;
}

int16_t le16_to_cpu_signed(const uint8_t data[2]) {
    int16_t r;
    uint16_t u = (unsigned)data[0] | ((unsigned)data[1] << 8);
    memcpy(&r, &u, sizeof r);
    return r;
}

64-битная сборка :

be16_to_cpu_signed(unsigned char const*):
        movbe   ax, WORD PTR [rdi]
        ret
le16_to_cpu_signed(unsigned char const*):
        movzx   eax, WORD PTR [rdi]
        ret
chqrlie
источник
Я не адвокат по языку, но только charтипы могут создавать псевдонимы или содержать объектные представления любого другого типа. uint16_tне один из charвидов, так что memcpyиз uint16_tк int16_tне вполне определенное поведение. Стандарт требует только char[sizeof(T)] -> T > char[sizeof(T)]преобразования с, memcpyчтобы быть четко определенным.
Максим Егорушкин
memcpyиз uint16_tк конкретной int16_tреализации определяется в лучшем случае , не переносимы, а не четко определены, точно так , как присвоение одного к другому, и вы не можете волшебным образом обойти , что с memcpy. Неважно, uint16_tиспользует ли представление дополнения два или нет, или присутствуют биты заполнения или нет - это не определяется поведением или не требуется стандартом Си.
Максим Егорушкин
С таким большим количеством слов, ваше «решение» сводится к замене r = uна memcpy(&r, &u, sizeof u)но последний не лучше , чем первый, это?
Максим Егорушкин