将 2 个字节转换为有符号 16 位整数的正确方法是啥?

Posted

技术标签:

【中文标题】将 2 个字节转换为有符号 16 位整数的正确方法是啥?【英文标题】:What is the correct way to convert 2 bytes to a signed 16-bit integer?将 2 个字节转换为有符号 16 位整数的正确方法是什么? 【发布时间】:2020-07-06 22:05:03 【问题描述】:

在this answer,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 从转换为 int32_tuint32_t 值中减去 0x10000u

为什么这是正确的方法

在转换为返回类型时如何避免实现定义的行为?

既然你可以假设 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);

【问题讨论】:

转换为int16_t 时的确切行为是实现定义的,因此这种幼稚的方法是不可移植的。 @nwellnhof 没有演员表int16_t 标题中的问题不指定使用哪个映射就无法回答 两种方法都依赖于实现定义的行为(将无符号值转换为不能表示该值的有符号类型)。例如。在第一种方法中,0xFFFF0001u 不能表示为int16_t,而在第二种方法中,0xFFFFu 不能表示为int16_t “因为你可以假设 2 的补码表示”[需要引用]。 C89 和 C99 当然不否认 1 的补码和符号幅度表示。 Q.v.,***.com/questions/12276957/… 【参考方案1】:

如果 int 是 16 位,那么如果 return 语句中的表达式值超出 int16_t 的范围,则您的版本依赖于实现定义的行为。

但是第一个版本也有类似的问题;例如,如果int32_tint 的typedef,并且输入字节都是0xFF,那么return 语句中的减法结果是UINT_MAX,这会在转换为int16_t 时导致实现定义的行为.

恕我直言,您链接到的答案有几个主要问题。

【讨论】:

@idmean 这个问题需要澄清才能回答,我已经在问题下的评论中要求,但 OP 没有回复 @M.M:我编辑了指定字节序不是问题的问题。恕我直言,zwol 试图解决的问题是转换为目标类型时实现定义的行为,但我同意你的观点:我相信他被误认为他的方法有其他问题。您将如何有效地解决实现定义的行为? @chqrlieforyellowblockquotes 我并不是专门指字节序。您是否只想将两个输入八位字节的确切位放入 int16_t 中? @M.M:是的,这正是问题所在。我写了 bytes 但正确的词确实应该是 octets 因为类型是 uchar8_t. @chqrlieforyellowblockquotes 好的,然后使用只写入两个字节的解决方案之一(例如 memcpy,或分配给 uint8_t【参考方案2】:

这应该是正确的,并且也适用于使用sign bit 或1's complement 表示的平台,而不是通常的2's complement。输入字节假定为 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 以避免有符号包装(即 undefined)。

现在在实践中,当今使用的几乎所有平台都使用 2 的补码表示。事实上,如果平台具有定义int32_t 的符合标准的stdint.h,它必须使用2 的补码。这种方法有时派上用场的地方是一些根本没有整数数据类型的脚本语言 - 您可以修改上面显示的浮点操作,它会给出正确的结果。

【讨论】:

C 标准特别要求int16_t 和任何intxx_t 及其无符号变体必须使用2 的补码表示,而不需要填充位。托管这些类型并为int 使用另一种表示形式需要故意不正当的架构,但我猜DS9K 可以这样配置。 @chqrlieforyellowblockquotes 好点,我改用int 以避免混淆。事实上,如果平台定义了int32_t,它必须是 2 的补码。 这些类型在 C99 中以这种方式标准化:C99 7.18.1.1 精确宽度整数类型 typedef 名称intN_t 指定宽度为@ 的有符号整数类型987654344@,无填充位和二进制补码表示。因此,int8_t 表示宽度正好为 8 位的有符号整数类型。 标准仍然支持其他表示,但对于其他整数类型。 使用您的更新版本,(int)value 具有实现定义的行为,如果类型 int 只有 16 位。恐怕你需要使用(long)value - 0x10000,但是在非2的补码架构上,值0x8000 - 0x10000不能表示为16位int,所以问题仍然存在。 @chqrlieforyellowblockquotes 是的,只是注意到了同样的问题,我用 ~ 修复了,但 long 也同样有效。【参考方案3】:

另一种方法——使用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_bytesecond_byte 可以根据小端或大端模型交换。这种方法不是更好,而是一种替代方法。

【讨论】:

联合类型不是双关语unspecified behaviour吗? @MaximEgorushkin:***不是解释 C 标准的权威来源。 @EricPostpischil 专注于信使而不是消息是不明智的。 @MaximEgorushkin:哦,是的,哎呀,我误读了你的评论。假设byte[2]int16_t 的大小相同,则它是两种可能的排序中的一种,而不是一些任意打乱的按位位置值。所以你至少可以在编译时检测出实现的字节序。 标准明确规定联合成员的值是将成员中存储的位解释为该类型的值表示的结果。只要类型的表示是实现定义的,就有实现定义的方面。【参考方案4】:

表达式(uint16_t)data[0] | ((uint16_t)data[1] &lt;&lt; 8) 中的算术运算符shiftbitwise-or 不适用于小于int 的类型,因此那些uint16_t 值得到提升为int(或unsigned,如果sizeof(uint16_t) == sizeof(int))。尽管如此,这应该会产生正确的答案,因为只有低 2 个字节包含该值。

另一个用于大端到小端转换(假设是小端 CPU)的学究式正确版本是:

#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,见assembly。

【讨论】:

@M.M __builtin_bswap16 存在的一个原因是 ISO C 中的字节交换无法高效实现。 不正确;编译器可以检测到代码实现了字节交换并将其转换为有效的内置 int16_t 转换为uint16_t 定义明确:负值转换为大于INT_MAX 的值,但将这些值转换回uint16_t 是实现定义的行为:6.3。 1.3 有符号和无符号整数 1.当整数类型的值转换为_Bool以外的其他整数类型时,如果该值可以用新类型表示,则保持不变。 ... 3.否则,新类型是有符号的,值不能在其中表示;结果要么是实现定义的,要么是产生实现定义的信号。 @MaximEgorushkin gcc 在 16 位版本中似乎表现不佳,但 clang 为 ntohs/__builtin_bswap|/&lt;&lt; 模式生成相同的代码:gcc.godbolt.org/z/rJ-j87 @M.M:我认为 Maxim 是在说“不能在实践中使用当前的编译器”。当然,编译器不能吸一次并识别将连续字节加载到整数中。在 GCC3 几十年前放弃它之后,GCC7 或 8 最终确实重新引入了加载/存储合并以用于字节反向不需要的情况。但总的来说,编译器在实践中往往需要帮助,以处理 CPU 可以有效执行但 ISO C 忽略/拒绝可移植公开的许多内容。可移植的 ISO C 不是高效代码位/字节操作的好语言。【参考方案5】:

这是另一个版本,它只依赖于可移植和明确定义的行为(标题#include &lt;endian.h&gt; 不是标准的,代码是):

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

little-endian 版本编译为单个movbe 指令,clanggcc 版本不太理想,请参阅assembly。

【讨论】:

@chqrlieforyellowblockquotes 你主要关心的似乎是uint16_tint16_t的转换,这个版本没有那个转换,所以你去吧。【参考方案6】:

我要感谢所有贡献者的回答。以下是集体作品归结为:

    根据 C 标准 7.20.1.1 精确宽度整数类型:类型 uint8_tint16_tuint16_t 必须使用不带任何填充位的二进制补码表示,因此实际位的表示形式明确地是数组中 2 个字节的表示形式,按照函数名称指定的顺序。 使用(unsigned)data[0] | ((unsigned)data[1] &lt;&lt; 8)(对于小端版本)计算无符号的 16 位值会编译为一条指令并产生一个无符号的 16 位值。 根据 C 标准 6.3.1.3 有符号和无符号整数:如果值不在目的地类型。没有为精确定义表示的类型做出特殊规定。 为了避免这种实现定义的行为,可以测试无符号值是否大于INT_MAX,并通过减去0x10000 来计算相应的有符号值。对 zwol 建议的所有值执行此操作可能会产生超出 int16_t 范围的值,并具有相同的实现定义行为。 显式测试0x8000 位会导致编译器生成低效代码。 在没有实现定义的行为的情况下更有效的转换通过联合使用类型双关语,但是关于这种方法的定义性的争论仍然存在,即使在 C 标准委员会级别也是​​如此。 类型双关语可以使用memcpy以可移植的方式执行并具有定义的行为。

结合第 2 点和第 7 点,这是一个可移植且完全定义的解决方案,它可以使用 gccclang 高效地编译为单个指令:

#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-bit Assembly:

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

【讨论】:

我不是语言律师,但只有char 类型可以别名或包含任何其他类型的对象表示。 uint16_t 不是 char 类型之一,因此 memcpyuint16_tint16_t 不是明确定义的行为。该标准只要求明确定义char[sizeof(T)] -&gt; T &gt; char[sizeof(T)]memcpy 的转换。 memcpy of uint16_tint16_t 充其量是实现定义的,不能移植,定义不明确,就像一个分配给另一个,你不能神奇地绕过它memcpyuint16_t 是否使用二进制补码表示,或者是否存在填充位都无关紧要 - 这不是 C 标准定义或要求的行为。 这么多的话,你的“解决方案”归结为将r = u替换为memcpy(&amp;r, &amp;u, sizeof u),但后者并不比前者好,不是吗?【参考方案7】:

为什么不只使用您的“简单解决方案”,而是将每个元素转换为 int16_t 而不是 uint16_t

int16_t le16_to_cpu_signed(const uint8_t data[static 2]) 
    return (int16_t)data[0] | ((int16_t)data[1] << 8);

那么您就不必处理将无符号整数转换为有符号整数(并且可能超出有符号整数范围)的问题。

【讨论】:

以上是关于将 2 个字节转换为有符号 16 位整数的正确方法是啥?的主要内容,如果未能解决你的问题,请参考以下文章

在 C# 中将 3 个字节转换为有符号整数

int类型最大能存储到哪一位

将 4 个字节转换为无符号 32 位整数并将其存储在 long

将整数拆分并存储为两个字节

C# char*如何转换string

Python 将一个值转换为 16 位 2 的补码