如何在C语言中对有符号数进行字节交换?

4
我理解,从一个无符号类型到等级相等的有符号类型的强制转换会产生一个实现定义的值:
引用C99 6.3.1.3中的话:
否则,新类型为有符号类型而且该值不能在其内表示;结果不确定或者是引发实现定义的信号。
这意味着我不知道如何交换有符号数的字节顺序。例如,假设我正在从外围设备以小端序接收两个字节的二进制补码有符号值,并在大端CPU上处理它们。C库中的字节交换原语(如ntohs)被定义为在无符号值上工作。如果我将我的数据转换为无符号数以进行字节交换,后续可靠地恢复有符号值吗?

请翻译以下与编程有关的内容,从英语到中文。只返回翻译后的文本:注意,根据Ed Heal答案下面的讨论,我已经修订了这个问题。 - zwol
1
在这些情况下,您假设使用2的补码,并假定实现定义的行为是保持位模式不变。 - M.M
3个回答

4
正如您在问题中所述,结果是“实现定义”或者会触发“实现定义信号”的产生 - 也就是说,取决于平台/编译器的情况而变化。

在这种情况下,我能否通过一些指针转换来保证得到精确的位表示?类似sval = *((signed short *)(unsigned char *)&uval)这样的语句是否能给我提供精确的位模式? - FazJaxton
@FazJaxton,不,术语“实现定义”意味着“取决于编译器的实现”。这也意味着你的编译器构建者必须在某个地方记录选择。因此,你需要阅读你的编译器文档。 - Jens Gustedt
我正在从硬件中读取有符号的多字节小端值,并将其传输到大端设备中。我以无符号方式读取它们并将其传递给大小端函数。我的大小端函数接受并返回无符号值。我想将(已经进行符号扩展,但是无符号类型的)返回值赋给有符号类型,这样我就可以在我的平台上使用它作为有符号值。然而,标准似乎声称不能保证这样做会起作用。这就是为什么我希望在无符号->有符号转换过程中保持未修改的位模式。 - FazJaxton
如果您读取负值,然后将其转换为正值,那么您会丢失信息。我认为您需要重新考虑,以确保更简单的逻辑不会导致问题。 - Ed Heal
也许这个类比更清晰:假设我正在尝试将在网络字节流中已经签名的值转换为使用ntohs函数的小端客户端上的有符号值。符号扩展已经存在,但是字节顺序与我的架构不同。没有任何版本的ntohs函数返回有符号值,并且从有符号到无符号的转换会丢失信息。正确的方法是什么? - FazJaxton
显示剩余2条评论

3
为了尽可能避免实现定义的行为,可以利用更宽的带符号中间值来进行有符号数的字节交换,该中间值可以表示与所需交换的有符号值相同宽度的无符号类型的整个范围。以你所举的小端16位数字为例:
// Code below assumes CHAR_BIT == 8, INT_MAX is at least 65536, and
// signed numbers are twos complement.
#include <stdint.h>

int16_t
sl16_to_host(unsigned char b[2])
{
    unsigned int n = ((unsigned int)b[0]) | (((unsigned int)b[1]) << 8);
    int v = n;
    if (n & 0x8000) {
        v -= 0x10000;
    }
    return (int16_t)v;
}

这是它的作用。首先,将 b 中的小端值转换为主机字节序的无符号值(不管主机实际上使用哪种字节序)。 然后,在更广泛的有符号变量中存储该值。 它的值仍在[0, 65535]范围内,但现在它是一个有符号数量。 因为 int 可以表示该范围内的所有值,所以转换完全由标准定义。
现在是关键步骤。 我们测试无符号值的高位(即符号位),如果为真,则从 signed 值中减去65536(0x10000)。 这将将范围[32768,655535]映射到[-32768,-1],这正是如何编码二进制补码有符号数的方式。 这仍然发生在较宽的类型中,因此我们保证范围内的所有值都是可表示的。
最后,我们将更广泛的类型截断为 int16_t 。 这一步涉及无法避免的实现定义行为,但几乎可以肯定地认为,您的实现定义其行为与您期望的相同。 在极小可能的情况下,如果您的实现对于有符号数使用符号和大小或反码表示,则-32768的值将被截断,可能会导致程序崩溃。 我不会烦恼。
另一种方法(在没有64位类型可用时对32位数字进行字节交换可能很有用)是掩码符号位并单独处理它:
int32_t
sl32_to_host(unsigned char b[4])
{
    uint32_t mag = ((((uint32_t)b[0]) & 0xFF) <<  0) |
                   ((((uint32_t)b[1]) & 0xFF) <<  8) |
                   ((((uint32_t)b[2]) & 0xFF) << 16) |
                   ((((uint32_t)b[3]) & 0x7F) << 24);
    int32_t val = mag;
    if (b[3] & 0x80) {
        val = (val - 0x7fffffff) - 1;
    }
    return val;
}

我在这里写了(val - 0x7fffffff) - 1,而不是只写val - 0x80000000,以确保减法发生在有符号类型中。


1
我知道将无符号类型转换为等级相同的有符号类型会产生一个实现定义的值。这只是因为C中的有符号格式是实现定义的。例如,二进制补码是一种实现定义的格式。因此,唯一的问题在于传输的任一侧是否不是二进制补码,这在现实世界中不太可能发生。我不会费心设计程序以便在黑暗时代的模糊、已灭绝的一补数计算机上可移植。
这意味着我不知道如何对有符号数进行字节交换。例如,假设我正在从外围设备按小端顺序接收两个字节的二进制补码有符号值,并在大端CPU上处理它们。
我怀疑这里的混淆源是您认为通用的二进制补码数字将从发送方(大/小端)传输,并由接收方(大/小端)接收。然而,数据传输协议并不像这样工作:它们明确指定了字节顺序和有符号格式。因此,双方都必须适应协议。
一旦指定了这个,实际上并没有什么特别难的地方:您将收到2个原始字节。将它们存储在原始数据数组中。然后将它们分配给您的二进制补码变量。假设协议指定小端:
int16_t val;
uint8_t little[2];

val = (little[1]<<8) | little[0];

位移具有独立于大小端的优势。因此,无论您的CPU是大端还是小端,上述代码都可以正常工作。尽管此代码包含大量丑陋的隐式提升,但它是100%可移植的。 C保证将以上内容视为:
val = (int16_t)( ((int)((int)little[1]<<8)) | (int)little[0] );

移位运算符的结果类型与其左操作数的提升类型相同。| 的结果类型是平衡类型 (通常的算术转换)。

移位有符号负数会导致未定义行为,但我们可以使用移位,因为单个字节是无符号的。当它们被隐式提升时,数字仍然被视为正数。

并且由于 int 至少保证为 16 位,所以该代码将在所有 CPU 上工作。

或者,您可以使用严谨的风格,完全排除所有隐式提升/转换:

val = (int16_t) ( ((uint32_t)little[1] << 8) | (uint32_t)little[0] );

但这样做会牺牲可读性。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接