Как я могу прочитать целое число со знаком из буфера uint8_t, не вызывая поведение, не зависящее от реализации или реализации?
Вот простая функция, которая пытается прочитать общее целое число из двух дополнений из буфера с прямым порядком байтов, где мы предположим, std::is_signed_v<INT_T>
:
template<typename INT_T>
INT_T read_big_endian(uint8_t const *data) {
INT_T result = 0;
for (size_t i = 0; i < sizeof(INT_T); i++) {
result <<= 8;
result |= *data;
data++;
}
return result;
}
К сожалению, это неопределенное поведение, так как последнее <<=
сдвигается в знак бит.
Итак, теперь мы попробуем следующее:
template<typename INT_T>
INT_T read_big_endian(uint8_t const *data) {
std::make_unsigned_t<INT_T> result = 0;
for (size_t i = 0; i < sizeof(INT_T); i++) {
result <<= 8;
result |= *data;
data++;
}
return static_cast<INT_T>(result);
}
Но сейчас мы вызываем определяемое реализацией поведение в static_cast
, переводя из неподписанного в подписанное.
Как я могу сделать это, оставаясь в "четко определенной" сфере?
1 ответ
Начните с объединения байтов в беззнаковое значение. Если вам не нужно собирать группы из 9 или более октетов, соответствующая реализация C99 гарантированно будет иметь такой тип, который достаточно велик, чтобы вместить их все (реализация C89 гарантированно будет иметь тип unsigned, достаточно большой, чтобы содержать как минимум четыре).
В большинстве случаев, когда вы хотите преобразовать последовательность октетов в число, вы будете знать, сколько октетов вы ожидаете. Если данные закодированы как 4 байта, вы должны использовать четыре байта независимо от размеров int
а также long
(переносимая функция должна возвращать тип long
).
unsigned long octets_to_unsigned32_little_endian(unsigned char *p)
{
return p[0] |
((unsigned)p[1]<<8) |
((unsigned long)p[2]<<16) |
((unsigned long)p[3]<<24);
}
long octets_to_signed32_little_endian(unsigned char *p)
{
unsigned long as_unsigned = octets_to_unsigned32_little_endian(p);
if (as_unsigned < 0x80000000)
return as_unsigned;
else
return (long)(as_unsigned^0x80000000UL)-0x40000000L-0x40000000L;
}
Обратите внимание, что вычитание выполняется в виде двух частей, каждая из которых находится в диапазоне длинны со знаком, для обеспечения возможности систем, в которых LNG_MIN
это -2147483647. Попытка преобразовать последовательность байтов {0,0,0,0x80} в такой системе может привести к неопределенному поведению [так как при этом будет вычислено значение -2147483648], но код должен обрабатывать полностью переносимым образом все значения, которые будут находиться в диапазоне "долго".
К сожалению, это неопределенное поведение, поскольку последний <<= переходит в знаковый бит.
Фактически, в C++17 сдвиг влево целого числа со знаком, имеющего отрицательное значение, является поведением undefined. Сдвиг влево целого числа со знаком, имеющего положительное значение, в знаковый бит - это поведение, определяемое реализацией. Смотрите также:
2 Значение
E1 << E2
- E1 сдвинутые влево позиции битов E2; освобожденные биты заполняются нулями. Если E1 имеет беззнаковый тип, значение результата будетE1 × 2**E2
, уменьшенная по модулю на единицу больше максимального значения, представленного в типе результата. В противном случае, если E1 имеет знаковый тип и неотрицательное значение, иE1 × 2**E2
может быть представлен в соответствующем беззнаковом типе типа результата, тогда это значение, преобразованное в тип результата, является результирующим значением; в противном случае поведение не определено.
(Окончательный рабочий проект C++17, Раздел 8.8 Операторы сдвига [expr.shift], Параграф 2, стр. 132 - курсив мой)
В C++20 переход в знаковый бит изменился с определенной реализации на определенное поведение:
2 Значение
E1 << E2
уникальное значение, соответствующееE1 × 2**E2 modulo 2**N
, где N - ширина типа результата. [Примечание: E1 - битовые позиции E2 со смещением влево; освобожденные биты заполняются нулями. - конец примечания]
(Последний рабочий проект C++20, раздел 7.6.7 Операторы сдвига [expr.shift], пункт 2, стр. 129)
Пример:
int i = 2147483647; // here: 2**31-1 == INT_MAX, sizeof(int) = 32
int j = i << 1; // i.e. -2
Утверждение: -2
это уникальное значение, которое конгруэнтно к2147483647 * 2 % 2**32
Проверьте:
a ≡ b (mod n) | i.e. there exists an integer k:
<=> a - b = k * n
=> -2 - 2147483647 * 2 = k * 2**32
<=> -4294967296 = k * 2**32
<=> k = -1 | i.e. there is an integer!
Значение -2
уникален, потому что в домене нет другого значения [INT_MIN .. INT_MAX]
который удовлетворяет этому соотношению конгруэнтности.
Это следствие того, что C++20 требует двух дополнительных представлений целочисленных типов со знаком:
3 [..] Для каждого значения x целочисленного типа со знаком значение соответствующего целочисленного типа без знака, конгруэнтное x по модулю 2 N, имеет то же значение соответствующих битов в его представлении значения. 41) Это также известно как представление дополнения до двух. [..]
(Последний рабочий проект C++20, Раздел 6.8.1 Основные типы [basic.fundamental], Параграф 3, стр. 66)
Это означает, что в C++20 ваш исходный пример вызывает определенное поведение как есть.
Дополнительное примечание: это ничего не доказывает, но дезинфицирующее средство неопределенного поведения GCC/Clang (вызываемое с помощью -fsanitize=undefined
) срабатывает только при компиляции этого примера для std <= C++17, а затем жалуется только на смещение отрицательного значения (как и ожидалось):
#include <stdio.h>
#include <limits.h>
int main(int argc, char **argv)
{
int i = INT_MAX - 1 + argc;
int j = i << 1;
int k = j << 1;
printf("%d %d %d\n", i, j, k);
return 0;
}
Пример сеанса (в Fedora 31):
$ g++ -std=c++17 -Wall -Og sign.cc -o sign -fsanitize=undefined
$ ./sign
sign.cc:8:15: runtime error: left shift of negative value -2
2147483647 -2 -4
$ g++ -std=c++2a -Wall -Og sign.cc -o sign -fsanitize=undefined
$ ./sign
2147483647 -2 -4
Чтобы предложить альтернативное решение, лучший способ скопировать биты и избежать UB - это memcpy
:
template<typename INT_T>
INT_T read_big_endian(uint8_t const *data) {
std::make_unsigned_t<INT_T> tmp = 0;
for (size_t i = 0; i < sizeof(INT_T); i++) {
tmp <<= 8;
tmp |= *data;
data++;
}
INT_T result;
memcpy(&result, &tmp, sizeof(tmp));
return result;
}
При этом вы не получите UB от приведения неподписанного к подписанному типу, а с оптимизациями он компилируется в ту же сборку, что и ваши примеры.
#include <cstdint>
#include <cstring>
#include <type_traits>
template<typename INT_T>
INT_T read_big_endian(uint8_t const *data) {
std::make_unsigned_t<INT_T> tmp = 0;
for (std::size_t i = 0; i < sizeof(INT_T); i++) {
tmp <<= 8;
tmp |= *data;
data++;
}
return static_cast<INT_T>(tmp);
}
template<typename INT_T>
INT_T read_big_endian2(uint8_t const *data) {
std::make_unsigned_t<INT_T> tmp = 0;
for (std::size_t i = 0; i < sizeof(INT_T); i++) {
tmp <<= 8;
tmp |= *data;
data++;
}
INT_T res;
memcpy(&res, &tmp, sizeof(res));
return res;
}
// Just to manifest the template expansions.
auto read32_1(uint8_t const *data) {
return read_big_endian<int32_t>(data);
}
auto read32_2(uint8_t const *data) {
return read_big_endian2<int32_t>(data);
}
auto read64_1(uint8_t const *data) {
return read_big_endian<int64_t>(data);
}
auto read64_2(uint8_t const *data) {
return read_big_endian2<int64_t>(data);
}
Компилируется с clang++ /tmp/test.cpp -std=c++17 -c -O3
кому:
_Z8read32_1PKh: # read32_1
movl (%rdi), %eax
bswapl %eax
retq
_Z8read32_2PKh: # read32_2
movl (%rdi), %eax
bswapl %eax
retq
_Z8read64_1PKh: # read64_1
movzbl (%rdi), %eax
shlq $8, %rax
movzbl 1(%rdi), %ecx
orq %rax, %rcx
shlq $8, %rcx
movzbl 2(%rdi), %eax
orq %rcx, %rax
shlq $8, %rax
movzbl 3(%rdi), %ecx
orq %rax, %rcx
shlq $8, %rcx
movzbl 4(%rdi), %eax
orq %rcx, %rax
shlq $8, %rax
movzbl 5(%rdi), %ecx
orq %rax, %rcx
shlq $8, %rcx
movzbl 6(%rdi), %edx
orq %rcx, %rdx
shlq $8, %rdx
movzbl 7(%rdi), %eax
orq %rdx, %rax
retq
_Z8read64_2PKh: # read64_2
movzbl (%rdi), %eax
shlq $8, %rax
movzbl 1(%rdi), %ecx
orq %rax, %rcx
shlq $8, %rcx
movzbl 2(%rdi), %eax
orq %rcx, %rax
shlq $8, %rax
movzbl 3(%rdi), %ecx
orq %rax, %rcx
shlq $8, %rcx
movzbl 4(%rdi), %eax
orq %rcx, %rax
shlq $8, %rax
movzbl 5(%rdi), %ecx
orq %rax, %rcx
shlq $8, %rcx
movzbl 6(%rdi), %edx
orq %rcx, %rdx
shlq $8, %rdx
movzbl 7(%rdi), %eax
orq %rdx, %rax
retq
на x86_64-linux-gnu с clang++ v8
.
Большую часть времени, memcpy
с оптимизацией будет компилироваться в ту же сборку, что и вы намереваетесь, но с дополнительным преимуществом отсутствия UB.
Обновление для corectness: OP правильно отмечает, что это все равно будет недействительным, поскольку подписанные представления int не обязательно должны быть двумя дополнениями (по крайней мере, до C++20), и это будет поведение, определяемое реализацией.
AFAICT, вплоть до C++20, на самом деле, похоже, не существовало аккуратного способа C++ для выполнения операций битового уровня с int без фактического знания битового представления подписанного int, которое определяется реализацией. При этом, если вы знаете, что ваш компилятор будет представлять интегральный тип C++ как два дополнения, тогда оба будут использоватьmemcpy
или static_cast
во втором примере OP должен работать.
Одна из основных причин, по которой C++20 представляет исключительно знаковые целые числа как дополнение до двух, заключается в том, что большинство существующих компиляторов уже представляют их как дополнение до двух. И GCC, и LLVM (и, следовательно, Clang) уже внутренне используют два дополнения.
Это не кажется полностью переносимым (и это понятно, если это не лучший ответ), но я предполагаю, что вы знаете, с каким компилятором вы будете создавать свой код, поэтому вы можете технически обернуть этот или второй пример с помощью проверяет, используете ли вы подходящий компилятор.