具有位字段的结构体的内存布局

10
我将翻译如下:

我有一个C结构体:(表示IP数据包)

struct ip_dgram
{
    unsigned int ver   : 4;
    unsigned int hlen  : 4;
    unsigned int stype : 8;
    unsigned int tlen  : 16;
    unsigned int fid   : 16;
    unsigned int flags : 3;
    unsigned int foff  : 13;
    unsigned int ttl   : 8;
    unsigned int pcol  : 8;
    unsigned int chksm : 16;
    unsigned int src   : 32;
    unsigned int des   : 32;
    unsigned char opt[40];
};

我正在为它分配值,然后像这样以16位字的方式打印它的内存布局:
//prints 16 bits at a time
void print_dgram(struct ip_dgram dgram)
{
    unsigned short int* ptr = (unsigned short int*)&dgram;
    int i,j;
    //print only 10 words
    for(i=0 ; i<10 ; i++)
    {
        for(j=15 ; j>=0 ; j--)
        {
            if( (*ptr) & (1<<j) ) printf("1");
            else printf("0");
            if(j%8==0)printf(" ");
        }
        ptr++;
        printf("\n");
    }
}

int main()
{
    struct ip_dgram dgram;

    dgram.ver   = 4;
    dgram.hlen  = 5;
    dgram.stype = 0;
    dgram.tlen  = 28;
    dgram.fid   = 1;
    dgram.flags = 0;
    dgram.foff  = 0;
    dgram.ttl   = 4;
    dgram.pcol  = 17;
    dgram.chksm = 0;
    dgram.src   = (unsigned int)htonl(inet_addr("10.12.14.5"));
    dgram.des   = (unsigned int)htonl(inet_addr("12.6.7.9"));

    print_dgram(dgram);

    return 0;
}

我得到了以下输出:

00000000 01010100 
00000000 00011100 
00000000 00000001 
00000000 00000000 
00010001 00000100 
00000000 00000000 
00001110 00000101 
00001010 00001100 
00000111 00001001 
00001100 00000110

但我期望的是:

enter image description here

输出结果部分正确;但某些地方似乎字节和半字节被交换了。这里是否存在端序问题?位域对于此目的是否不适用?我真的不知道。有任何帮助吗?提前感谢!


看一下例如Linux或BSD网络堆栈是如何做到这一点的。 - vonbrand
7个回答

13
不,位域不适用于此目的。布局取决于编译器。
通常情况下,如果要控制数据的布局,使用位域并不是一个好主意,除非你有(特定于编译器的)手段,例如 #pragma 来实现。
最好的方法可能是不使用位域来实现,即通过自己进行所需的位运算来完成。这很麻烦,但比挖掘修复方法要容易得多。而且,它是平台无关的。
将头文件定义为一个 16 位字的数组,然后就可以轻松计算校验和。

那么我该怎么控制布局呢?我需要它们是连续的,因为我必须计算它们的校验和,这是数据报中所有这样的16位字的1的补码和。如果我将所有字段都设置为32位无符号整数,我无法看到如何找到它们的总和... - Bruce
1
在gcc中使用__attribute__((packed))以及空的零字段:0,来强制内存对齐到下一个字。 - Patrick Schlüter
@tristopia 你能更清楚地解释一下吗?或者把它作为一个答案呢? - Bruce
@unwind 好的,谢谢,我不知道为什么没想到。那我就这样做。 - Bruce
1
Packed 在这里没有任何作用,而且通常是有害的。不建议使用。 - R.. GitHub STOP HELPING ICE

6
C11标准规定:
实现可以分配任何可寻址存储单元,足以容纳位域。如果有足够的空间,立即跟随另一个结构体中的位域将被紧密地压缩在同一个单元的相邻位中。如果剩余空间不足,则未适合的位域是放入下一个单元还是重叠相邻单元是由实现定义的。在单元内分配位域的顺序(高位到低位或低位到高位)是由实现定义的。
我很确定这是不可取的,因为这意味着字段之间可能会有填充,并且您无法控制字段的顺序。此外,您在网络字节顺序方面也要看实现的心情。此外,想象一下,如果unsigned int仅为16位,并且您要将32位位域放入其中:
指定位域宽度的表达式必须是具有非负值的整数常量表达式,其值不超过省略冒号和表达式时将指定的类型对象的宽度。
我建议使用一个unsigned char数组来代替结构体。这样,您可以确保对填充和网络字节顺序进行控制。首先确定您希望该结构的总大小为多少位。我假设您在常量中声明了这一点,例如IP_PACKET_BITCOUNT:typedef unsigned char ip_packet [(IP_PACKET_BITCOUNT / CHAR_BIT) + (IP_PACKET_BITCOUNT % CHAR_BIT > 0)];
编写一个函数,void set_bits(ip_packet p, size_t bitfield_offset, size_t bitfield_width, unsigned char *value) { ... },它允许您设置从p[bitfield_offset / CHAR_BIT]位开始的位到value中找到的位,最多为bitfield_width位。这将是您任务中最复杂的部分。
然后,您可以定义VER_OFFSET 0和VER_WIDTH 4、HLEN_OFFSET 4和HLEN_WIDTH 4等标识符,以使数组的修改看起来不太痛苦。

2
我对标准中位域的规定感到有些困惑。如果有一种可移植的显式位域布局方式,那将非常有用;但由于没有这样的方式,我不确定通过以一种有效地禁止32位编译器允许两个12位数字适合三字节结构的方式来指定规则所获得的是什么。我可以理解不希望要求编译器执行如此精细的打包,但我的理解是编译器不允许字段重叠在字节边界上。 - supercat
@supercat 是的,位域是一种相当无用和乏味的特性,不像Kat的答案中神奇的UInt4指针,似乎能够指向半字节。如果能够声明它们的数组就好了,例如一个大小为1的sizeof(int)*CHAR_BIT位域数组,以操作bitfield[0]bitfield[sizeof(int)*CHAR_BIT-1] - autistic
你能想到任何实际的事情,一个现有或潜在的明确定义的程序可以通过定义位域的方式来完成,而如果实现可以自由地安排或填充位域,只受一个约束条件的限制,即位域不应大于具有相同类型的结构,并且零位“项”或非位域项将强制适合声明类型的对齐方式,那么一个明确定义的程序将无法完成吗? - supercat
就我所知,位域没有实际用途。我想这能回答你的问题。嘿,我想知道为什么我的gcc没有实现uint_least0_t... - autistic
...以及如果这样的对象存在,它的sizeof是否可能为0。 :P - autistic
有些编译器确实支持“位”类型,它只占用一个比特的存储空间,但不允许指向这种类型的指针。这种类型在包含可以直接测试 RAM 位的指令的嵌入式处理器上特别有用;一些这样的处理器可以使用位测试指令访问 RAM 的任何部分,而其他处理器则具有可用于保存多达 128 个位标志的 16 字节区域。 - supercat

1
尽管这个问题很久以前就被提出了,但至今还没有解释你的结果的答案。我会回答它,希望对某些人有用。
我将使用数据结构的前16位来说明这个错误。
请注意:此解释仅在您的处理器和编译器集合中保证是正确的。如果其中任何一个发生更改,则行为可能会改变。
字段:
unsigned int ver   : 4;
unsigned int hlen  : 4;
unsigned int stype : 8;

分配给:

dgram.ver   = 4;
dgram.hlen  = 5;
dgram.stype = 0;
编译器从偏移量0开始分配位域。这意味着您的数据结构的第一个字节存储在内存中为:
Bit offset: 7     4     0
            -------------
            |  5  |  4  |
            -------------

赋值后的前16位如下:

Bit offset:     15   12    8     4     0
                -------------------------
                |  5  |  4  |  0  |  0  |
                -------------------------
Memory Address: 100          101

您正在使用无符号16位指针来解引用内存地址100。因此,地址100被视为16位数字的LSB。而101被视为16位数字的MSB。
如果您以十六进制打印*ptr,您将看到以下内容:
*ptr = 0x0054

您的循环正在运行这个16位值,因此您会得到:

00000000 0101 0100
-------- ---- ----
   0       5    4

解决方案: 将元素的顺序更改为

unsigned int hlen  : 4;
unsigned int ver   : 4;
unsigned int stype : 8;

使用unsigned char *指针遍历和打印值。它应该有效。
请注意,正如其他人所说,这种行为是平台和编译器特定的。如果任何一个发生改变,您需要验证数据结构的内存布局是否正确。

0

对于中国用户,我认为你们可以参考博客以获取更多细节,非常好。

总的来说,由于字节序问题,存在字节顺序和位顺序。位顺序是指一个字节中每个位在内存中保存的顺序。就字节序问题而言,位顺序与字节顺序遵循相同的规则。

对于您的图片,它是按网络顺序设计的,即大端序。因此,您的结构定义实际上是针对大端序的。根据您的输出,您的PC是小端序,因此在使用时需要改变结构字段的顺序。

显示每个位的方式是不正确的,因为当通过char获取时,位顺序已从机器顺序(在您的情况下为小端序)更改为我们人类使用的正常顺序。您可以根据参考的博客进行以下更改。

void
dump_native_bits_storage_layout(unsigned char *p, int bytes_num)
{

    union flag_t {
        unsigned char c;
        struct base_flag_t {
            unsigned int p7:1,
                        p6:1,
                        p5:1,
                        p4:1,
                        p3:1,
                        p2:1,
                        p1:1,
                        p0:1;
        } base;
    } f;

    for (int i = 0; i < bytes_num; i++) {
        f.c = *(p + i);
        printf("%d%d%d%d %d%d%d%d ",
                        f.base.p7,
                        f.base.p6, 
                        f.base.p5, 
                        f.base.p4, 
                        f.base.p3,
                        f.base.p2, 
                        f.base.p1, 
                        f.base.p0);
    }
    printf("\n");
}

//prints 16 bits at a time
void print_dgram(struct ip_dgram dgram)
{
    unsigned char* ptr = (unsigned short int*)&dgram;
    int i,j;
    //print only 10 words
    for(i=0 ; i<10 ; i++)
    {
        dump_native_bits_storage_layout(ptr, 1);
        /* for(j=7 ; j>=0 ; j--)
        {
            if( (*ptr) & (1<<j) ) printf("1");
            else printf("0");
            if(j%8==0)printf(" ");
        }*/
        ptr++;
        //printf("\n");
    }
}

-1

@解压

位字段的典型用例是根据给定的布局解释/模拟字节码或CPU指令。对于孩子们来说,“不要使用它,因为你无法控制它”就是答案。

@布鲁斯

对于Intel/GCC,我看到了一个紧凑的小端位布局,即在struct ip_dgram中,字段ver由位0..3表示,字段hlen由位4..7表示...

为了确保操作的正确性,需要在运行时验证内存布局与设计的一致性。

struct ModelIndicator
{
    int a:4;
    int b:4;
    int c:4;
};

union UModelIndicator
{
    ModelIndicator i;
    int v;
};

// test packed little endian
static bool verifyLayoutModel()
{
    UModelIndicator um;
    um.v = 0;
    um.i.a = 2; // 0..3
    um.i.b = 3; // 4..7
    um.i.c = 9; // 8..11
    return um.v == (9 << 8) + (3 << 4) + 2;
}

int main()
{
    if (!verifyLayoutModel())
    {
        std::cerr << "Invalid memory layout" << std::endl; 
        return -1;
    }
    // ...
}

在最早的时候,当上述测试失败时,您需要考虑编译器指示或相应地调整您的结构,并验证布局模型(verifyLayoutModel())。


1
"不要使用它,因为你无法控制它"是对孩子的回答。这是一个愚蠢而傲慢的回答,来自于那些未能考虑到不能控制的事物,并且毫无必要地打开了未来错误的大门。要有更高的标准。 - Andrew Henle

-2
无论编译器是否相关,这取决于您想要编写一个非常快速的程序还是想要一个可以与不同编译器一起使用的程序。如果要为C语言编写快速、紧凑的应用程序,请使用具有位字段的结构体。如果您想要一个慢速的通用程序,则需要编写较长的代码。

嗨,这不是一个非常具体的答案,你没有用任何东西支持你的论点。你能加入一些例子/证明吗? - Ben
如果优化是错误的,那么它就不能算作优化。你需要首先定义正确的行为。如果你认为可以进行优化,那么创建一个抽象层,以便你可以用更优化的版本代替正确但“较慢”的版本... 说到这个,标准中没有说“可移植和正确就是慢的”,或者“不可移植和未定义就是快的”。某个编译器可能会为两个版本生成相同的代码,或者如果编译器可以推断出逻辑未使用或不必要,则完全优化掉它。 - autistic

-2

我同意 unwind 所说的。位域依赖于编译器。

如果你需要位以特定顺序排列,请将数据打包到字符数组的指针中。增加缓冲区的大小以打包元素。然后打包下一个元素。

pack( char** buffer )
{
   if ( buffer & *buffer )
   {
     //pack ver
     //assign first 4 bits to 4. 
     *((UInt4*) *buffer ) = 4;
     *buffer += sizeof(UInt4); 

     //assign next 4 bits to 5
     *((UInt4*) *buffer ) = 5;
     *buffer += sizeof(UInt4); 

     ... continue packing
   }
}

你是在暗示 sizeof(UInt4) 可能介于0和1之间,比如0.5,因为UInt4是“半个字节”吗?哈哈哈,我希望我能为你点赞! - autistic
假设 CHAR_BIT == 9,这是否意味着 整数值 sizeof(UInt4) 是一个 无理分数 - autistic
1
单独的位不可寻址。在C语言中,最小可寻址实体是char(也称为字节),通常有8个位。 - WGH

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