¿Cuál es la forma correcta de convertir 2 bytes a un entero de 16 bits con signo?

31

En esta respuesta , zwol hizo esta afirmación:

La forma correcta de convertir dos bytes de datos de una fuente externa en un entero con signo de 16 bits es con funciones 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;
}

Cuál de las funciones anteriores es apropiada depende de si la matriz contiene una representación little endian o big endian. Endianness no es el problema en cuestión aquí, me pregunto por qué zwol resta 0x10000udel uint32_tvalor convertido a int32_t.

¿Por qué es esta la forma correcta ?

¿Cómo evita el comportamiento definido de implementación cuando se convierte al tipo de retorno?

Como puede asumir la representación del complemento de 2, ¿cómo podría fallar este elenco más simple? return (uint16_t)val;

Lo que está mal con esta solución ingenua:

int16_t le16_to_cpu_signed(const uint8_t data[static 2]) {
    return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
}
chqrlie
fuente
El comportamiento exacto cuando se realiza la conversión int16_testá definido por la implementación, por lo que el enfoque ingenuo no es portátil.
nwellnhof
@nwellnhof no hay elenco paraint16_t
MM
La pregunta en el título no se puede responder sin especificar qué mapeo usar
MM
44
Ambos enfoques dependen del comportamiento definido por la implementación (convertir un valor sin signo a un tipo con signo que no puede representar el valor). P.ej. en el primer enfoque, 0xFFFF0001uno se puede representar como int16_t, y en el segundo enfoque 0xFFFFuno se puede representar como int16_t.
Sander De Dycker
1
"Como puedes asumir la representación del complemento de 2" [cita requerida]. C89 y C99 ciertamente no negaron el complemento de 1 y las representaciones de magnitud de signo. Qv, stackoverflow.com/questions/12276957/…
Eric Towers

Respuestas:

20

Si intes de 16 bits, su versión se basa en el comportamiento definido por la implementación si el valor de la expresión en la returndeclaración está fuera de rango int16_t.

Sin embargo, la primera versión también tiene un problema similar; por ejemplo, si int32_tes un typedef para int, y los bytes de entrada son ambos 0xFF, entonces el resultado de la resta en la declaración de retorno es lo UINT_MAXque causa un comportamiento definido por la implementación cuando se convierte a int16_t.

En mi humilde opinión, la respuesta a la que se vincula tiene varios problemas importantes.

MM
fuente
2
¿Pero cuál es la forma correcta?
idmean
@idmean la pregunta necesita aclaración antes de que pueda ser respondida, he solicitado en un comentario bajo la pregunta pero OP no ha respondido
MM
1
@MM: Edité la pregunta para especificar que la endianidad no es el problema. En mi humilde opinión, el problema que zwol está tratando de resolver es el comportamiento definido de implementación cuando se convierte al tipo de destino, pero estoy de acuerdo con usted: creo que está equivocado ya que su método tiene otros problemas. ¿Cómo resolvería el comportamiento definido de implementación de manera eficiente?
chqrlie
@chqrlieforyellowblockquotes No me refería específicamente a la endianidad. ¿Solo desea poner los bits exactos de los dos octetos de entrada en el int16_t?
MM
@MM: sí, esa es exactamente la pregunta. Escribí bytes, pero la palabra correcta debería ser octetos como es el tipo uchar8_t.
chqrlie
7

Esto debe ser pedagógicamente correcto y funcionar también en plataformas que usan bit de signo o representaciones de complemento de 1 , en lugar del complemento habitual de 2 . Se supone que los bytes de entrada están en el 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;
}

Debido a la sucursal, será más costoso que otras opciones.

Lo que esto logra es que evita cualquier suposición sobre cómo la intrepresentación se relaciona con la unsignedrepresentación en la plataforma. La conversión a intse requiere para preservar el valor aritmético de cualquier número que se ajuste al tipo de destino. Debido a que la inversión asegura que el bit superior del número de 16 bits será cero, el valor se ajustará. Luego, el unario -y la resta de 1 aplican la regla usual para la negación del complemento de 2. Dependiendo de la plataforma, INT16_MINaún podría desbordarse si no cabe en el inttipo en el destino, en cuyo caso longdebe usarse.

La diferencia con la versión original en la pregunta viene en el tiempo de regreso. Si bien el original siempre se resta 0x10000y el complemento de 2 permite que el desbordamiento firmado lo envuelva al int16_trango, esta versión tiene el explícito ifque evita el wrapover firmado (que no está definido ).

Ahora en la práctica, casi todas las plataformas en uso hoy en día usan la representación del complemento 2. De hecho, si la plataforma cumple con el estándar stdint.hque defineint32_t , debe usar el complemento de 2 para ello. Donde este enfoque a veces resulta útil es con algunos lenguajes de secuencias de comandos que no tienen tipos de datos enteros en absoluto: puede modificar las operaciones que se muestran arriba para los flotantes y dará el resultado correcto.

jpa
fuente
El estándar C exige específicamente que int16_ty cualquiera de intxx_tsus variantes sin signo debe usar la representación del complemento 2 sin bits de relleno. Se necesitaría una arquitectura deliberadamente perversa para alojar estos tipos y usar otra representación int, pero supongo que el DS9K podría configurarse de esta manera.
chqrlie
@chqrlieforyellowblockquotes Buen punto, cambié el uso intpara evitar la confusión. De hecho, si la plataforma define int32_tdebe ser el complemento de 2.
jpa
Estos tipos se estandarizaron en C99 de esta manera: C99 7.18.1.1 Tipos enteros de ancho exacto El nombre typedef intN_t designa un tipo entero con signo con ancho N, sin bits de relleno y una representación complementaria de dos. Por lo tanto, int8_tdenota un tipo entero con signo con un ancho de exactamente 8 bits. El estándar todavía admite otras representaciones, pero para otros tipos enteros.
chqrlie
Con su versión actualizada, (int)valuetiene un comportamiento definido de implementación si el tipo inttiene solo 16 bits. Me temo que debe usarlo (long)value - 0x10000, pero en arquitecturas de complemento que no son 2, el valor 0x8000 - 0x10000no puede representarse como 16 bits int, por lo que el problema persiste.
chqrlie
@chqrlieforyellowblockquotes Sí, acabo de notar lo mismo, solucioné con ~ en su lugar, pero longfuncionaría igualmente bien.
jpa
6

Otro método: usar union:

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

En programa:

...
B2I16 conv;

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

first_bytey second_bytepuede intercambiarse según el modelo endian pequeño o grande. Este método no es mejor pero es una de las alternativas.

i486
fuente
2
¿No es el tipo de unión castigar el comportamiento no especificado ?
Maxim Egorushkin
1
@MaximEgorushkin: Wikipedia no es una fuente autorizada para interpretar el estándar C.
Eric Postpischil
2
@EricPostpischil Centrarse en el mensajero en lugar del mensaje es imprudente.
Maxim Egorushkin
1
@MaximEgorushkin: oh sí, vaya, leí mal tu comentario. Suponiendo byte[2]y int16_tson del mismo tamaño, es uno o el otro de los dos ordenamientos posibles, no un arbitraria barajan valores de lugar a nivel de bit. Por lo tanto, al menos puede detectar en tiempo de compilación qué importancia tiene la implementación.
Peter Cordes
1
El estándar establece claramente que el valor del miembro de la unión es el resultado de interpretar los bits almacenados en el miembro como una representación de valor de ese tipo. Hay aspectos definidos por la implementación en la medida en que la representación de tipos está definida por la implementación.
MM
6

Los operadores aritméticos shift y bit a bit o en expresión (uint16_t)data[0] | ((uint16_t)data[1] << 8)no funcionan en tipos más pequeños int, de modo que esos uint16_tvalores se promueven a int(o unsignedsi sizeof(uint16_t) == sizeof(int)). Sin embargo, eso debería dar la respuesta correcta, ya que solo los 2 bytes inferiores contienen el valor.

Otra versión pedagógicamente correcta para la conversión big-endian a little-endian (suponiendo CPU little-endian) es:

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

memcpyse utiliza para copiar la representación de int16_ty esa es la forma de hacerlo que cumple con los estándares. Esta versión también se compila en 1 instrucción movbe, ver ensamblaje .

Maxim Egorushkin
fuente
1
@MM Una razón __builtin_bswap16existe porque el intercambio de bytes en ISO C no se puede implementar de manera tan eficiente.
Maxim Egorushkin
1
No es verdad; el compilador podría detectar que el código implementa el intercambio de bytes y traducirlo como un generador integrado eficiente
MM
1
La conversión int16_ta uint16_testá bien definida: los valores negativos se convierten en valores mayores que INT_MAX, pero volver a convertir estos valores uint16_tes un comportamiento definido por la implementación: 6.3.1.3 Enteros con y sin signo 1. Cuando un valor con tipo de entero se convierte en otro tipo de entero distinto de Bool, si el valor puede ser representado por el nuevo tipo, no cambia. ... 3. De lo contrario, el nuevo tipo está firmado y el valor no se puede representar en él; el resultado está definido por la implementación o se genera una señal definida por la implementación.
chqrlie
1
@MaximEgorushkin gcc no parece ser tan bueno en la versión de 16 bits, pero clang genera el mismo código para ntohs/ __builtin_bswapy el |/ <<patrón: gcc.godbolt.org/z/rJ-j87
PSkocik
3
@MM: Creo que Maxim dice "no puedo en la práctica con los compiladores actuales". Por supuesto, un compilador no podría succionar por una vez y reconocer la carga de bytes contiguos en un entero. GCC7 u 8 finalmente reintrodujo la fusión de carga / tienda para casos donde no se necesita byte-reverse , después de que GCC3 lo dejó hace décadas. Pero en general, los compiladores tienden a necesitar ayuda en la práctica con muchas cosas que las CPU pueden hacer de manera eficiente pero que ISO C descuidó / rechazó exponer de forma portátil. Portable ISO C no es un buen lenguaje para la manipulación eficiente de bits / bytes de código.
Peter Cordes
4

Aquí hay otra versión que se basa solo en comportamientos portátiles y bien definidos (el encabezado #include <endian.h>no es estándar, el código sí lo es):

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

La versión little-endian se compila con una sola movbeinstrucción clang, la gccversión es menos óptima, vea el ensamblaje .

Maxim Egorushkin
fuente
@chqrlieforyellowblockquotes Su principal preocupación parece haber sido uint16_ta int16_tla conversión, esta versión no tiene esa conversión, así que aquí tienes.
Maxim Egorushkin
2

Quiero agradecer a todos los contribuyentes por sus respuestas. Esto es a lo que se reduce el trabajo colectivo:

  1. Según el estándar C 7.20.1.1 Tipos enteros de ancho exacto : tipos uint8_t, int16_ty uint16_tdeben usar la representación del complemento a dos sin ningún bit de relleno, por lo que los bits reales de la representación son inequívocamente los de los 2 bytes en la matriz, en el orden especificado por Los nombres de las funciones.
  2. calcular el valor de 16 bits sin signo con (unsigned)data[0] | ((unsigned)data[1] << 8)(para la versión little endian) se compila en una sola instrucción y produce un valor de 16 bits sin signo.
  3. Según el Estándar C 6.3.1.3 Enteros con y sin signo : la conversión de un valor de tipo uint16_ta tipo con signo int16_ttiene un comportamiento definido de implementación si el valor no está en el rango del tipo de destino. No se hacen disposiciones especiales para los tipos cuya representación se define con precisión.
  4. Para evitar este comportamiento definido de implementación, se puede probar si el valor sin signo es mayor que INT_MAXy calcular el valor firmado correspondiente restando 0x10000. Hacer esto para todos los valores sugeridos por zwol puede producir valores fuera del rango de int16_tcon el mismo comportamiento definido de implementación.
  5. probar el 0x8000bit explícitamente hace que los compiladores produzcan código ineficiente.
  6. Una conversión más eficiente sin un comportamiento definido de implementación utiliza el tipo de punteo a través de un sindicato, pero el debate sobre la definición de este enfoque aún está abierto, incluso a nivel del Comité de la Norma C.
  7. Tipo de juegos de palabras puede ser realizada de forma portátil y con el comportamiento define utilizando memcpy.

Combinando los puntos 2 y 7, aquí hay una solución portátil y totalmente definida que se compila eficientemente en una sola instrucción con gcc y 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;
}

Asamblea 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
fuente
No soy un abogado de idiomas, pero solo los chartipos pueden tener alias o contener la representación de objetos de cualquier otro tipo. uint16_tNo es uno de charlos tipos, para que memcpyde uint16_ta int16_tno es un comportamiento bien definido. El estándar solo requiere char[sizeof(T)] -> T > char[sizeof(T)]conversión con memcpyestar bien definido.
Maxim Egorushkin
memcpyde uint16_tque int16_tes definido por la implementación en el mejor, no es portátil, no bien definida, exactamente como la asignación de uno a otro, y no se puede eludir que mágicamente con memcpy. No importa si uint16_tusa la representación del complemento de dos o no, o si los bits de relleno están presentes o no, ese no es un comportamiento definido o requerido por el estándar C.
Maxim Egorushkin
Con tantas palabras, su "solución" se reduce a sustituir r = ua memcpy(&r, &u, sizeof u)pero éste no es mejor que el anterior, ¿verdad?
Maxim Egorushkin