Qual é a maneira correta de converter 2 bytes em um inteiro assinado de 16 bits?

31

Em esta resposta , Zwol fez esta afirmação:

A maneira correta de converter dois bytes de dados de uma fonte externa em um número inteiro assinado de 16 bits é com funções auxiliares como esta:

#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;
}

Qual das funções acima é apropriada depende se a matriz contém uma pequena representação endian ou big endian. Endianness não é o problema em questão aqui, estou me perguntando por que o zwol subtrai 0x10000udo uint32_tvalor convertido para int32_t.

Por que essa é a maneira correta ?

Como evita o comportamento definido pela implementação ao converter para o tipo de retorno?

Como você pode assumir a representação do complemento de 2, como essa conversão mais simples falharia: return (uint16_t)val;

O que há de errado com esta solução ingênua:

int16_t le16_to_cpu_signed(const uint8_t data[static 2]) {
    return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
}
chqrlie
fonte
O comportamento exato ao transmitir para int16_té definido pela implementação; portanto, a abordagem ingênua não é portátil.
nwellnhof 26/03
@nwellnhof não há elenco paraint16_t
MM
A pergunta no título não pode ser respondida sem especificar qual mapeamento usar
MM
4
Ambas as abordagens dependem do comportamento definido pela implementação (convertendo um valor não assinado em um tipo assinado que não pode representar o valor). Por exemplo. na primeira abordagem, 0xFFFF0001unão pode ser representado como int16_te na segunda abordagem 0xFFFFunão pode ser representado como int16_t.
Sander De Dycker 26/03
11
"Como você pode assumir a representação do complemento de 2" [citação necessário]. C89 e C99 certamente não negaram representações de complemento e magnitude de sinal de 1s. Qv, stackoverflow.com/questions/12276957/...
Eric Torres

Respostas:

20

Se intfor de 16 bits, sua versão dependerá do comportamento definido pela implementação se o valor da expressão na returninstrução estiver fora do intervalo int16_t.

No entanto, a primeira versão também tem um problema semelhante; por exemplo, se int32_tfor um typedef para inte os bytes de entrada forem ambos 0xFF, o resultado da subtração na instrução de retorno é o UINT_MAXque causa um comportamento definido pela implementação quando convertido em int16_t.

IMHO, a resposta à qual você vincula tem vários problemas importantes.

MILÍMETROS
fonte
2
Mas qual é o caminho correto?
idmean 27/03
@idmean a pergunta precisa de esclarecimentos antes que possa ser respondida, solicitei em um comentário a pergunta, mas o OP não respondeu
MM
11
@ MM: Eu editei a pergunta e especifique que endianness não é o problema. O IMHO que o problema que o zwol está tentando resolver é o comportamento definido pela implementação ao converter para o tipo de destino, mas concordo com você: acredito que ele está enganado, pois seu método tem outros problemas. Como você resolveria o comportamento definido de implementação com eficiência?
chqrlie 28/03
@chqrlieforyellowblockquotes Eu não estava me referindo especificamente a endianness. Você só quer colocar os bits exatos dos dois octetos de entrada no int16_t?
MM
@ MM: sim, essa é exatamente a questão. Eu escrevi bytes, mas a palavra correta deve realmente ser octetos como o tipo é uchar8_t.
chqrlie 28/03
7

Isso deve ser pedanticamente correto e funcionar também em plataformas que usam representações de complemento de bit de sinal ou 1 , em vez do complemento usual de 2 . Supõe-se que os bytes de entrada estejam no complemento de 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;
}

Por causa da filial, será mais caro que outras opções.

O que isso realiza é que evita qualquer suposição sobre como a intrepresentação se relaciona com a unsignedrepresentação na plataforma. A conversão para inté necessária para preservar o valor aritmético de qualquer número que caiba no tipo de destino. Como a inversão garante que o bit superior do número de 16 bits seja zero, o valor será adequado. Então o unário -e a subtração de 1 aplicam a regra usual para a negação do complemento de 2. Dependendo da plataforma, INT16_MINainda poderá estourar se não couber no inttipo no destino, caso em que longdeve ser usado.

A diferença para a versão original na pergunta ocorre no tempo de retorno. Enquanto o original sempre subtrai 0x10000e o complemento do 2 permite que o excesso de sinal acondicionado o int16_talcance, esta versão tem o explícito ifque evita o acúmulo de sinal (que é indefinido ).

Agora, na prática, quase todas as plataformas em uso hoje usam a representação de complemento de 2. De fato, se a plataforma possui um padrão stdint.hque define int32_t, ela deve usar o complemento de 2 para isso. Onde essa abordagem às vezes é útil, é com algumas linguagens de script que não têm tipos de dados inteiros - você pode modificar as operações mostradas acima para flutuadores e isso dará o resultado correto.

jpa
fonte
O Padrão C especifica especificamente que int16_te qualquer uma de intxx_tsuas variantes não assinadas deve usar a representação de complemento de 2 sem preenchimento de bits. Seria necessária uma arquitetura propositalmente perversa para hospedar esses tipos e usar outra representação int, mas acho que o DS9K poderia ser configurado dessa maneira.
chqrlie 27/03
@chqrlieforyellowblockquotes Bom ponto, mudei para usar intpara evitar a confusão. De fato, se a plataforma define int32_t, deve ser o complemento de 2.
jpa 27/03
Esses tipos foram padronizados no C99 da seguinte maneira: C99 7.18.1.1 Tipos inteiros de largura exata O nome typedef intN_t designa um tipo inteiro assinado com largura N, sem bits de preenchimento e uma representação complementar de dois. Assim, int8_tdenota um tipo inteiro assinado com uma largura de exatamente 8 bits. Outras representações ainda são suportadas pelo padrão, mas para outros tipos inteiros.
chqrlie 27/03
Com sua versão atualizada, (int)valuepossui um comportamento definido por implementação se o tipo inttiver apenas 16 bits. Receio que você precise usar (long)value - 0x10000, mas nas arquiteturas de complemento que não são 2, o valor 0x8000 - 0x10000não pode ser representado como 16 bits int, portanto o problema permanece.
chqrlie 27/03
@chqrlieforyellowblockquotes Sim, apenas notei o mesmo, eu consertei com ~ em vez disso, mas longfuncionaria igualmente bem.
jpa
6

Outro método - usando union:

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

No programa:

...
B2I16 conv;

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

first_bytee second_bytepode ser trocado de acordo com o modelo endian pequeno ou grande. Este método não é melhor, mas é uma das alternativas.

i486
fonte
2
O tipo de união não está afetando o comportamento não especificado ?
Maxim Egorushkin 26/03
11
@MaximEgorushkin: A Wikipedia não é uma fonte autorizada para interpretar o padrão C.
Eric Postpischil 26/03
2
@EricPostpischil Focar o messenger em vez da mensagem é imprudente.
Maxim Egorushkin 26/03
11
@MaximEgorushkin: oh sim, oops, eu interpretei mal o seu comentário. Assumindo byte[2]e com int16_to mesmo tamanho, é um ou outro dos dois pedidos possíveis, e não alguns valores arbitrários aleatórios de local em bits. Portanto, você pode pelo menos detectar, em tempo de compilação, qual endianness a implementação possui.
Peter Cordes
11
O padrão afirma claramente que o valor do membro da união é o resultado da interpretação dos bits armazenados no membro como uma representação de valor desse tipo. Existem aspectos definidos pela implementação no que se refere à representação dos tipos.
MM
6

Os operadores aritméticos mudam e bit a bit - ou na expressão (uint16_t)data[0] | ((uint16_t)data[1] << 8)não funcionam em tipos menores que int, para que esses uint16_tvalores sejam promovidos para int(ou unsignedse sizeof(uint16_t) == sizeof(int)). Ainda assim, isso deve gerar a resposta correta, pois apenas os 2 bytes inferiores contêm o valor.

Outra versão pedanticamente correta para a conversão de big-endian em little-endian (assumindo CPU little-endian) é:

#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é usado para copiar a representação int16_te essa é a maneira compatível com os padrões. Esta versão também compila em 1 instrução movbe, consulte montagem .

Maxim Egorushkin
fonte
11
@MM Existe uma razão __builtin_bswap16para a troca de bytes na ISO C não poder ser implementada com a mesma eficiência.
Maxim Egorushkin 26/03
11
Não é verdade; o compilador pode detectar que o código implementa a troca de bytes e traduzi-lo como um recurso eficiente
MM
11
A conversão int16_tpara uint16_testá bem definida: os valores negativos são convertidos em valores maiores que INT_MAX, mas a conversão desses valores no uint16_tcomportamento definido pela implementação: 6.3.1.3 Inteiros assinados e não assinados 1. Quando um valor com o tipo inteiro é convertido em outro tipo de número inteiro diferente de _Bool, se o valor pode ser representado pelo novo tipo, é inalterado. ... 3. Caso contrário, o novo tipo é assinado e o valor não pode ser representado nele; o resultado é definido pela implementação ou um sinal definido pela implementação é gerado.
chqrlie 26/03
11
@MaximEgorushkin O gcc não parece se sair tão bem na versão de 16 bits, mas o clang gera o mesmo código para ntohs/ __builtin_bswape o |/ <<pattern: gcc.godbolt.org/z/rJ-j87
PSkocik
3
@ MM: Eu acho que o Maxim está dizendo "não posso praticar com os compiladores atuais". Obviamente, um compilador não pôde ser sugado pela primeira vez e reconhecer o carregamento de bytes contíguos em um número inteiro. O GCC7 ou 8 finalmente reintroduz a coalescência de carga / armazenamento para casos em que o byte-reverse não é necessário, depois que o GCC3 o abandonou décadas atrás. Mas, em geral, os compiladores tendem a precisar de ajuda na prática, com muitas coisas que as CPUs podem fazer de maneira eficiente, mas que a ISO C negligenciou / se recusou a expor de forma portável. O ISO C portátil não é uma boa linguagem para manipulação eficiente de bits / bytes de código.
Peter Cordes
4

Aqui está outra versão que depende apenas de comportamentos portáteis e bem definidos (o cabeçalho #include <endian.h>não é padrão, o código é):

#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;
}

A versão little-endian é compilada com uma movbeinstrução única clang, a gccversão é menos ideal, consulte assembly .

Maxim Egorushkin
fonte
@chqrlieforyellowblockquotes Sua principal preocupação parece ter sido uint16_ta int16_tconversão, esta versão não tem essa conversão, então aqui está.
Maxim Egorushkin 30/03
2

Quero agradecer a todos os colaboradores por suas respostas. Aqui está o que o trabalho coletivo se resume a:

  1. De acordo com os Standard C 7.20.1.1 tipos inteiros exacta de largura : tipos uint8_t, int16_te uint16_tdeve usar representação de complemento de dois, sem quaisquer bits de preenchimento, de modo que os bits reais da representação são inequivocamente os das 2 bytes na matriz, na ordem especificada pela os nomes das funções.
  2. calcular o valor não assinado de 16 bits com (unsigned)data[0] | ((unsigned)data[1] << 8)(para a versão little endian) é compilado em uma única instrução e gera um valor não assinado de 16 bits.
  3. De acordo com o Padrão C 6.3.1.3 Inteiros assinados e não assinados : a conversão de um valor do tipo uint16_tem tipo assinado int16_tpossui um comportamento definido pela implementação se o valor não estiver no intervalo do tipo de destino. Nenhuma provisão especial é feita para tipos cuja representação é definida com precisão.
  4. para evitar esse comportamento definido pela implementação, é possível testar se o valor não assinado é maior que INT_MAXe calcular o valor assinado correspondente subtraindo 0x10000. Fazer isso para todos os valores sugeridos por zwol pode produzir valores fora do intervalo int16_tcom o mesmo comportamento definido pela implementação.
  5. testar o 0x8000bit explicitamente faz com que os compiladores produzam código ineficiente.
  6. uma conversão mais eficiente sem um comportamento definido pela implementação usa punção de tipo por meio de uma união, mas o debate sobre a definição dessa abordagem ainda está aberto, mesmo no nível do Comitê do Padrão C.
  7. punição de tipo pode ser executada de forma portável e com comportamento definido usando memcpy.

Combinando os pontos 2 e 7, aqui está uma solução portátil e totalmente definida que compila eficientemente uma única instrução com o gcc e o 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;
}

Conjunto de 64 bits :

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
fonte
Não sou advogado de idiomas, mas apenas os chartipos podem alias ou conter a representação de objeto de qualquer outro tipo. uint16_tnão é um dos chartipos, portanto, memcpyde uint16_tto int16_tnão é um comportamento bem definido. O padrão requer apenas que a char[sizeof(T)] -> T > char[sizeof(T)]conversão memcpyseja bem definida.
Maxim Egorushkin
memcpyde uint16_tto int16_té definido na implementação, na melhor das hipóteses, não é portátil, não é bem definido, exatamente como a atribuição de um ao outro, e você não pode contorná-lo magicamente memcpy. Não importa se uint16_tusa ou não a representação de complemento de dois, ou se os bits de preenchimento estão presentes ou não - isso não é um comportamento definido ou exigido pelo padrão C.
Maxim Egorushkin
Com tantas palavras, a sua "solução" resume-se a substituir r = ua memcpy(&r, &u, sizeof u)mas o último não é melhor que o anterior, não é?
Maxim Egorushkin