Quelle est la bonne façon de convertir 2 octets en un entier signé 16 bits?

31

Dans cette réponse , zwol a fait cette affirmation:

La bonne façon de convertir deux octets de données d'une source externe en un entier signé 16 bits est avec des fonctions d'assistance comme celle-ci:

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

Laquelle des fonctions ci-dessus est appropriée dépend du fait que le tableau contient un petit endian ou une grande représentation endian. L'endianité n'est pas le problème en cause ici, je me demande pourquoi zwol soustrait 0x10000ude la uint32_tvaleur convertie en int32_t.

Pourquoi est-ce la bonne façon ?

Comment évite-t-il le comportement défini par l'implémentation lors de la conversion au type de retour?

Puisque vous pouvez assumer la représentation du complément à 2, comment cette conversion plus simple échouerait-elle: return (uint16_t)val;

Quel est le problème avec cette solution naïve:

int16_t le16_to_cpu_signed(const uint8_t data[static 2]) {
    return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
}
chqrlie
la source
Le comportement exact lors de la conversion vers int16_test défini par l'implémentation, de sorte que l'approche naïve n'est pas portable.
nwellnhof
@nwellnhof il n'y a pas de casting pourint16_t
MM
La question dans le titre ne peut pas être répondue sans spécifier la cartographie à utiliser
MM
4
Les deux approches reposent sur un comportement défini par l'implémentation (conversion d'une valeur non signée en un type signé qui ne peut pas représenter la valeur). Par exemple. dans la première approche, 0xFFFF0001une peut pas être représenté comme int16_t, et dans la deuxième approche 0xFFFFune peut pas être représenté comme int16_t.
Sander De Dycker
1
"Puisque vous pouvez assumer la représentation du complément à 2" [citation nécessaire]. C89 et C99 n'ont certainement pas nié les représentations du complément et de l'amplitude des signes. Qv, stackoverflow.com/questions/12276957/…
Eric Towers

Réponses:

20

Si intest 16 bits, votre version s'appuie sur un comportement défini par l'implémentation si la valeur de l'expression dans l' returninstruction est hors limites pour int16_t.

Cependant, la première version a également un problème similaire; par exemple, si int32_test un typedef pour int, et que les octets d'entrée sont les deux 0xFF, le résultat de la soustraction dans l'instruction de retour est UINT_MAXce qui provoque le comportement défini par l'implémentation lors de la conversion en int16_t.

À mon humble avis, la réponse à laquelle vous liez a plusieurs problèmes majeurs.

MM
la source
2
Mais quelle est la bonne façon?
idmean
@idmean la question a besoin d'éclaircissements avant de pouvoir y répondre, j'ai demandé dans un commentaire sous la question mais OP n'a pas répondu
MM
1
@MM: J'ai édité la question pour préciser que l'endianité n'est pas le problème. À mon humble avis, le problème que zwol essaie de résoudre est le comportement défini par l'implémentation lors de la conversion vers le type de destination, mais je suis d'accord avec vous: je pense qu'il se trompe car sa méthode a d'autres problèmes. Comment résoudriez-vous efficacement le comportement défini par l'implémentation?
chqrlie
@chqrlieforyellowblockquotes Je ne parlais pas spécifiquement de l'endianité. Voulez-vous simplement mettre les bits exacts des deux octets d'entrée dans le int16_t?
MM
@MM: oui, c'est exactement la question. J'ai écrit des octets mais le mot correct devrait en effet être des octets comme le type uchar8_t.
chqrlie
7

Cela devrait être correct sur le plan pédiatrique et fonctionner également sur les plates-formes qui utilisent le bit de signe ou les représentations du complément à 1 , au lieu du complément à 2 habituel . Les octets d'entrée sont supposés être en complément 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;
}

En raison de la succursale, ce sera plus cher que les autres options.

Cela permet d'éviter toute hypothèse sur la intrelation entre la unsignedreprésentation et la représentation sur la plate-forme. Le transtypage en intest requis pour conserver la valeur arithmétique de tout nombre pouvant tenir dans le type cible. Étant donné que l'inversion garantit que le bit supérieur du nombre 16 bits sera nul, la valeur s'adaptera. Ensuite, l'unaire -et la soustraction de 1 appliquent la règle habituelle pour la négation du complément à 2. Selon la plate-forme, elle INT16_MINpourrait toujours déborder si elle ne correspond pas au inttype sur la cible, auquel cas elle longdoit être utilisée.

La différence avec la version originale dans la question vient au moment du retour. Alors que l'original est toujours soustrait 0x10000et que le complément à 2 laisse le débordement signé l'envelopper int16_t, il est explicite ifqui évite le wrapover signé (qui n'est pas défini ).

Maintenant, dans la pratique, presque toutes les plateformes utilisées aujourd'hui utilisent la représentation du complément à 2. En fait, si la plate-forme est conforme aux normes stdint.hqui la définit int32_t, elle doit utiliser le complément 2 pour cela. Lorsque cette approche est parfois utile, c'est avec certains langages de script qui n'ont pas du tout de types de données entiers - vous pouvez modifier les opérations indiquées ci-dessus pour les flottants et cela donnera le résultat correct.

jpa
la source
La norme C stipule spécifiquement que int16_ttoute intxx_tvariante non signée doit utiliser la représentation du complément à 2 sans bits de remplissage. Il faudrait une architecture délibérément perverse pour héberger ces types et utiliser une autre représentation int, mais je suppose que le DS9K pourrait être configuré de cette façon.
chqrlie
@chqrlieforyellowblockquotes Bon point, j'ai changé d'utilisation intpour éviter la confusion. En effet, si la plateforme le définit, int32_til doit s'agir du complément à 2.
JPA
Ces types ont été normalisés en C99 de cette manière: C99 7.18.1.1 Types entiers de largeur exacte Le nom typedef intN_t désigne un type entier signé avec largeur N, sans bits de remplissage et une représentation du complément à deux. Ainsi, int8_tdésigne un type entier signé avec une largeur d'exactement 8 bits. D'autres représentations sont toujours prises en charge par la norme, mais pour d'autres types entiers.
chqrlie
Avec votre version mise à jour, le (int)valuecomportement est défini par l'implémentation si le type intn'a que 16 bits. Je crains que vous n'ayez besoin de l'utiliser (long)value - 0x10000, mais sur les architectures complémentaires non 2, la valeur 0x8000 - 0x10000ne peut pas être représentée en 16 bits int, donc le problème persiste.
chqrlie
@chqrlieforyellowblockquotes Oui, je viens de remarquer la même chose, j'ai corrigé avec ~ à la place, mais longcela fonctionnerait tout aussi bien.
jpa
6

Une autre méthode - en utilisant union:

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

Au programme:

...
B2I16 conv;

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

first_byteet second_bytepeuvent être échangés selon le modèle petit ou grand endien. Cette méthode n'est pas meilleure mais est l'une des alternatives.

i486
la source
2
Le type d'union ne punit-il pas un comportement non spécifié ?
Maxim Egorushkin
1
@MaximEgorushkin: Wikipedia n'est pas une source faisant autorité pour interpréter la norme C.
Eric Postpischil
2
@EricPostpischil Il n'est pas judicieux de se concentrer sur le messager plutôt que sur le message.
Maxim Egorushkin
1
@MaximEgorushkin: oh oui, oups j'ai mal lu votre commentaire. En supposant byte[2]que int16_tla taille est la même, il s'agit de l'un ou l'autre des deux ordres possibles, et non de certaines valeurs de position binaires arbitraires. Ainsi, vous pouvez au moins détecter au moment de la compilation la finalité de l'implémentation.
Peter Cordes
1
La norme indique clairement que la valeur du membre d'union est le résultat de l'interprétation des bits stockés dans le membre comme une représentation de valeur de ce type. Il existe des aspects définis par l'implémentation dans la mesure où la représentation des types est définie par l'implémentation.
MM
6

Les opérateurs arithmétiques shift et expression au niveau du bit ou in (uint16_t)data[0] | ((uint16_t)data[1] << 8)ne fonctionnent pas sur les types plus petits que int, de sorte que ces uint16_tvaleurs soient promues en int(ou unsignedsi sizeof(uint16_t) == sizeof(int)). Cependant, cela devrait donner la bonne réponse, car seuls les 2 octets inférieurs contiennent la valeur.

Une autre version pédantiquement correcte pour la conversion big-endian en little-endian (en supposant que le processeur little-endian) est:

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

memcpyest utilisé pour copier la représentation de int16_tet c'est la manière conforme aux normes de le faire. Cette version se compile également en 1 instruction movbe, voir assemblage .

Maxim Egorushkin
la source
1
@MM Une raison __builtin_bswap16existe parce que l'échange d'octets dans ISO C ne peut pas être implémenté aussi efficacement.
Maxim Egorushkin
1
Pas vrai; le compilateur pourrait détecter que le code implémente l'échange d'octets et le traduire comme un intégré efficace
MM
1
La conversion int16_ten uint16_test bien définie: les valeurs négatives se convertissent en valeurs supérieures à INT_MAX, mais la reconversion de ces valeurs en uint16_test un comportement défini par l'implémentation: 6.3.1.3 Entiers signés et non signés 1. Lorsqu'une valeur de type entier est convertie en un autre type entier autre que_Bool, si la valeur peut être représentée par le nouveau type, elle est inchangée. ... 3. Sinon, le nouveau type est signé et la valeur ne peut pas y être représentée; soit le résultat est défini par l'implémentation, soit un signal défini par l'implémentation est émis.
chqrlie
1
@MaximEgorushkin gcc ne semble pas si bien dans la version 16 bits, mais clang génère le même code pour ntohs/ __builtin_bswapet le |/ <<pattern: gcc.godbolt.org/z/rJ-j87
PSkocik
3
@MM: Je pense que Maxim dit "ne peut pas en pratique avec les compilateurs actuels". Bien sûr, un compilateur ne pouvait pas aspirer une fois et reconnaître le chargement d'octets contigus dans un entier. GCC7 ou 8 a finalement réintroduit la coalescence charge / stockage pour les cas où l'inverse d'octet n'est pas nécessaire, après que GCC3 l'ait abandonné il y a des décennies. Mais en général, les compilateurs ont tendance à avoir besoin d'aide dans la pratique avec beaucoup de choses que les processeurs peuvent faire efficacement mais que ISO C a négligé / refusé d'exposer de manière portable. L'ISO portable C n'est pas un bon langage pour une manipulation efficace des bits / octets de code.
Peter Cordes
4

Voici une autre version qui ne repose que sur des comportements portables et bien définis (l'en-tête #include <endian.h>n'est pas standard, le code l'est):

#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 version little-endian se compile en movbeinstruction unique avec clang, la gccversion est moins optimale, voir assemblage .

Maxim Egorushkin
la source
@chqrlieforyellowblockquotes Votre principale préoccupation semble avoir été uint16_tà la int16_tconversion, cette version ne dispose pas de cette conversion, donc ici vous allez.
Maxim Egorushkin
2

Je tiens à remercier tous les contributeurs pour leurs réponses. Voici ce que le travail collectif se résume à:

  1. Conformément à la norme C 7.20.1.1 Types entiers de largeur exacte : types uint8_t, int16_tet uint16_tdoivent utiliser la représentation du complément à deux sans aucun bit de remplissage, de sorte que les bits réels de la représentation sont sans ambiguïté ceux des 2 octets du tableau, dans l'ordre spécifié par les noms des fonctions.
  2. le calcul de la valeur 16 bits non signée avec (unsigned)data[0] | ((unsigned)data[1] << 8)(pour la petite version endienne) se compile en une seule instruction et produit une valeur 16 bits non signée.
  3. Conformément à la norme C 6.3.1.3 Entiers signés et non signés : la conversion d'une valeur de type uint16_ten type signé int16_ta un comportement défini par l'implémentation si la valeur n'est pas dans la plage du type de destination. Aucune disposition particulière n'est prévue pour les types dont la représentation est définie avec précision.
  4. pour éviter ce comportement défini par l'implémentation, on peut tester si la valeur non signée est supérieure à INT_MAXet calculer la valeur signée correspondante en soustrayant 0x10000. Faire cela pour toutes les valeurs comme suggéré par zwol peut produire des valeurs en dehors de la plage de int16_tavec le même comportement défini par l'implémentation.
  5. le test du 0x8000bit entraîne explicitement les compilateurs à produire du code inefficace.
  6. une conversion plus efficace sans implémentation du comportement défini utilise le type punning via un syndicat, mais le débat concernant la définition de cette approche est toujours ouvert, même au niveau du comité de la norme C.
  7. la punition de type peut être effectuée de manière portative et avec un comportement défini à l'aide de memcpy.

En combinant les points 2 et 7, voici une solution portable et entièrement définie qui se compile efficacement en une seule instruction avec gcc et 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;
}

Assemblage 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
la source
Je ne suis pas un avocat spécialisé dans les langues, mais seuls les chartypes peuvent alias ou contenir la représentation d'objet de tout autre type. uint16_test pas l' un des chartypes, de sorte que memcpyde uint16_tà int16_test pas un comportement bien défini. La norme nécessite uniquement une char[sizeof(T)] -> T > char[sizeof(T)]conversion avec memcpypour être bien définie.
Maxim Egorushkin
memcpyof uint16_tto int16_test au mieux défini par l'implémentation, pas portable, pas bien défini, exactement comme l'affectation de l'un à l'autre, et vous ne pouvez pas par magie contourner cela avec memcpy. Peu importe si uint16_tla représentation du complément à deux est utilisée ou non, ou si des bits de remplissage sont présents ou non - ce n'est pas un comportement défini ou requis par la norme C.
Maxim Egorushkin
Avec autant de mots, votre «solution» se résume à remplacer r = upar memcpy(&r, &u, sizeof u)mais ce dernier n'est pas meilleur que le premier, n'est-ce pas?
Maxim Egorushkin