【问题标题】:When is it worthwhile to use bit fields?什么时候值得使用位域?
【发布时间】:2011-05-13 13:29:35
【问题描述】:

使用 C 的位域实现是否值得?如果有,什么时候用过?

我正在查看一些仿真器代码,看起来芯片的寄存器没有使用位域实现。

这是出于性能原因(或其他原因)而避免的事情吗?

还有使用位域的时候吗? (即固件放在实际芯片等)

【问题讨论】:

  • 实现你自己的softfloat时
  • 当你有一个包含固定最大条目数的 unordered_map 时,我发现了一个现代用例。设置一个位字段,它是可能出现在映射中的元素数量的整个大小,然后在将 1 个或多个元素保存到特定映射条目中时将位值设置为 true。通过在散列到映射之前检查位,可以显着优化访问,因为位操作可以比散列查找更快。

标签: c++ c bit-fields


【解决方案1】:

位域通常仅在需要将结构域映射到特定位片时使用,其中某些硬件将解释原始位。一个示例可能是组装 IP 数据包标头。我看不出模拟器使用位域对寄存器进行建模的令人信服的理由,因为它永远不会触及真正的硬件!

虽然位域可以带来简洁的语法,但它们非常依赖于平台,因此不可移植。一种更便携但更冗长的方法是使用直接按位操作,使用移位和位掩码。

如果您将位域用于在某些物理接口上组装(或拆卸)结构之外的任何东西,性能可能会受到影响。这是因为每次从位域读取或写入时,编译器都必须生成代码来执行屏蔽和移位,这会消耗循环。

【讨论】:

  • 关于“燃烧周期”问题,我发现使用可能的最小整数类型确实比使用位域更快。除了布尔标志(掩蔽很容易并且不需要移位),我同意你的观点:)
  • @Matthieu:我想在大多数情况下,使用int 会是最快的,因为它是平台的原生宽度。例外情况是,如果以 int 的方式执行所有操作会使您的数据结构显着变大,从而导致缓存未命中等。
  • @OliCharlesworth,网络 little-endian 或 big-endian 问题将使您使用位域传递数据包头失败。而且 C++ 标准也没有定义位域的存储方式,它是特定于实现的。而基于bit-field的性能不好,bit-field是没用的。
  • @ZijingWu,“实现特定”(或“平台/编译器依赖”)不会使某些东西变得无用。这只是意味着应用程序有限,您必须小心。
  • @ZijingWu 虽然我不会说“无用”,但位域的用途肯定是非常有限的,因为 - 正如你所说 - 它们的实现定义的打包使它们很少或非常不可移植的实用程序用于表示特定的位格式。因此,在 Oliver 的示例中,必须确定 (A) 他们的实现如何打包 IP 标头位,以及 (B) 这与目标的做事方式相匹配。编写自己的按位处理程序以完全可预测的方式执行此操作通常更可靠 - 这并不至少要求 cmets 说“仅在 ​​yarch 上使用 xcc 编译”
【解决方案2】:

尚未提及的位域的一个用途是unsigned 位域“免费”提供以二的幂为模的算术运算。例如,给定:

struct { unsigned x:10; } foo;

foo.x 的算术运算将以 2 为模10 = 1024。

(当然,同样可以通过使用按位 & 操作直接实现 - 但有时它可能会导致更清晰的代码让编译器为您完成)。

【讨论】:

  • 值得注意的是这里很可能sizeof(foo) == sizeof(unsigned),即你没有节省任何内存,你只是有一个更好的语法。
  • 我不会假设sizeof(foo)==sizeof(unsigned) sizeof(unsigned)==4
  • @caf,你也可以在没有位域的情况下做到这一点,没有两个麻烦。只需计算结果和&(2^n - 1)
  • @ZijingWu:是的,这就是我在上一段中所指的。
  • 我在 for 循环中使用了一个 3 位值来循环 0 .. 7 一遍又一遍。
【解决方案3】:

FWIW,只关注相对性能问题 - 一个庞大的基准:

#include <time.h>
#include <iostream>

struct A
{
    void a(unsigned n) { a_ = n; }
    void b(unsigned n) { b_ = n; }
    void c(unsigned n) { c_ = n; }
    void d(unsigned n) { d_ = n; }
    unsigned a() { return a_; }
    unsigned b() { return b_; }
    unsigned c() { return c_; }
    unsigned d() { return d_; }
    volatile unsigned a_:1,
                      b_:5,
                      c_:2,
                      d_:8;
};

struct B
{
    void a(unsigned n) { a_ = n; }
    void b(unsigned n) { b_ = n; }
    void c(unsigned n) { c_ = n; }
    void d(unsigned n) { d_ = n; }
    unsigned a() { return a_; }
    unsigned b() { return b_; }
    unsigned c() { return c_; }
    unsigned d() { return d_; }
    volatile unsigned a_, b_, c_, d_;
};

struct C
{
    void a(unsigned n) { x_ &= ~0x01; x_ |= n; }
    void b(unsigned n) { x_ &= ~0x3E; x_ |= n << 1; }
    void c(unsigned n) { x_ &= ~0xC0; x_ |= n << 6; }
    void d(unsigned n) { x_ &= ~0xFF00; x_ |= n << 8; }
    unsigned a() const { return x_ & 0x01; }
    unsigned b() const { return (x_ & 0x3E) >> 1; }
    unsigned c() const { return (x_ & 0xC0) >> 6; }
    unsigned d() const { return (x_ & 0xFF00) >> 8; }
    volatile unsigned x_;
};

struct Timer
{
    Timer() { get(&start_tp); }
    double elapsed() const {
        struct timespec end_tp;
        get(&end_tp);
        return (end_tp.tv_sec - start_tp.tv_sec) +
               (1E-9 * end_tp.tv_nsec - 1E-9 * start_tp.tv_nsec);
    }
  private:
    static void get(struct timespec* p_tp) {
        if (clock_gettime(CLOCK_REALTIME, p_tp) != 0)
        {
            std::cerr << "clock_gettime() error\n";
            exit(EXIT_FAILURE);
        }
    }
    struct timespec start_tp;
};

template <typename T>
unsigned f()
{
    int n = 0;
    Timer timer;
    T t;
    for (int i = 0; i < 10000000; ++i)
    {
        t.a(i & 0x01);
        t.b(i & 0x1F);
        t.c(i & 0x03);
        t.d(i & 0xFF);
        n += t.a() + t.b() + t.c() + t.d();
    }
    std::cout << timer.elapsed() << '\n';
    return n;
}

int main()
{
    std::cout << "bitfields: " << f<A>() << '\n';
    std::cout << "separate ints: " << f<B>() << '\n';
    std::cout << "explicit and/or/shift: " << f<C>() << '\n';
}

我的测试机器上的输出(每次运行的数字相差约 20%):

bitfields: 0.140586
1449991808
separate ints: 0.039374
1449991808
explicit and/or/shift: 0.252723
1449991808

建议在最近的 Athlon 上使用 g++ -O3,位域比单独的 int 慢几倍,而且这个特定的和/或/bitshift 实现至少再次糟糕两倍(“更糟糕”作为其他操作,如上面的波动性强调了内存读/写,并且存在循环开销等,因此结果中的差异被低估了)。

如果您要处理数百兆字节的结构,这些结构可能主要是位域或主要是不同的整数,缓存问题可能会成为主要问题 - 因此在您的系统中进行基准测试。

从 2021 年开始使用 AMD Ryzen 9 3900X 和 -O2 -march=native 进行更新:

bitfields: 0.0224893
1449991808
separate ints: 0.0288447
1449991808
explicit and/or/shift: 0.0190325
1449991808

在这里,我们看到一切都发生了巨大变化,主要含义是 - 与您关心的系统进行基准测试。


更新:user2188211 尝试了一个被拒绝的编辑,但有用地说明了位域如何随着数据量的增加而变得更快:“在上述代码的 [a modified version of] 中迭代数百万个元素的向量时,这样变量不驻留在缓存或寄存器中,位域代码可能是最快的。"

template <typename T>
unsigned f()
{
    int n = 0;
    Timer timer;
    std::vector<T> ts(1024 * 1024 * 16);
    for (size_t i = 0, idx = 0; i < 10000000; ++i)
    {
        T& t = ts[idx];
        t.a(i & 0x01);
        t.b(i & 0x1F);
        t.c(i & 0x03);
        t.d(i & 0xFF);
        n += t.a() + t.b() + t.c() + t.d();
        idx++;
        if (idx >= ts.size()) {
            idx = 0;
        }
    }
    std::cout << timer.elapsed() << '\n';
    return n;
}

示例运行的结果(g++ -03,Core2Duo):

 0.19016
 bitfields: 1449991808
 0.342756
 separate ints: 1449991808
 0.215243
 explicit and/or/shift: 1449991808

当然,时间都是相对的,而你实现这些字段的方式在你的系统环境中可能根本不重要。

【讨论】:

  • 在许多平台上,位域结构和自定义掩码和移位结构的大小为 4 字节,而非位域结构为 16 字节。因此,位域和单独实现的性能/大小大致相等(这是一个由您决定的权衡),而自定义掩码和移位结构的性能/大小比低于其他结构,但至少它与平台无关。
  • 你不应该用-march=native编译吗?
【解决方案4】:

我在两种情况下看到/使用过位域:计算机游戏和硬件接口。硬件使用非常简单:硬件需要某种位格式的数据,您可以手动定义或通过预定义的库结构定义。它们是使用位域还是仅使用位操作取决于特定的库。

在“过去”的电脑游戏中,经常使用位域来尽可能地利用电脑/磁盘内存。例如,对于 RPG 中的 NPC 定义,您可能会发现(虚构示例):

struct charinfo_t
{
     unsigned int Strength : 7;  // 0-100
     unsigned int Agility : 7;  
     unsigned int Endurance: 7;  
     unsigned int Speed : 7;  
     unsigned int Charisma : 7;  
     unsigned int HitPoints : 10;    //0-1000
     unsigned int MaxHitPoints : 10;  
     //etc...
};

您不会在更现代的游戏/软件中看到这么多,因为随着计算机获得更多内存,空间节省的比例变得更糟。当您的计算机只有 16MB 时,节省 1MB 内存是一件大事,但当您有 4GB 时,就没有那么多了。

【讨论】:

  • 如今计算机可能拥有更多 RAM,但保持较低的内存使用率有助于将其保持在 CPU 内存缓存内,从而提高性能。另一方面,位域需要更多指令才能访问它们,这会降低性能。哪个更重要?
  • @Pharap:是的,比较读/写位域和普通int 结构成员所需的指令。在旨在提高执行速度而不是节省内存的代码中值得考虑。
  • 正如其他地方提到的,硬件接口的这种使用是高度不可移植的,并且取决于所使用机器的实现和架构。手动执行这些操作通常更容易。
  • 现代电脑游戏如果想要最大化某些类型的操作的性能,也会使用位域。主要是过滤器。那将是 f.ex.,如果 A 可以与 B、C 和 E 交互,但不能与 D、F 和 G 交互——那么所有这些检查都可以通过一个按位操作来执行。当需要检查 A 与 E 的交互时,它们的交互位域使用 & 操作进行比较。如果有结果,则发生了交互。这还具有可以进行不对称交互的良好副作用。
【解决方案5】:

位域的主要目的是通过实现更紧密的数据打包,提供一种在大规模实例化聚合数据结构中节省内存的方法。

整个想法是利用某些结构类型中有多个字段的情况,这些字段不需要某些标准数据类型的整个宽度(和范围)。这为您提供了将多个此类字段打包在一个分配单元中的机会,从而减少了结构类型的整体大小。极端的例子是布尔字段,它可以由单个位表示(例如,其中 32 个可打包到单个 unsigned int 分配单元中)。

显然,这仅在减少内存消耗的优点大于访问存储在位域中的值的速度较慢的缺点的情况下才有意义。然而,这种情况经常出现,这使得位域成为绝对不可缺少的语言特性。这应该回答您关于现代使用位域的问题:不仅使用它们,而且在任何面向处理大量同质数据(例如大图)的实际有意义的代码中,它们本质上是强制性的,因为它们的内存- 节省的好处大大超过任何个人访问性能损失。

在某种程度上,位域的用途非常类似于“小”算术类型:signed/unsigned charshortfloat。在实际的数据处理代码中,通常不会使用任何小于intdouble 的类型(少数例外)。像signed/unsigned charshortfloat 这样的算术类型只是为了充当“存储”类型:在已知其范围(或精度)足够的情况下,作为结构类型的节省内存的紧凑成员。位域只是朝着同一方向迈出的又一步,它以更高的性能换取更大的内存节省优势。

因此,这为我们提供了一组相当明确的条件,在这些条件下值得使用位域:

  1. 结构类型包含多个字段,这些字段可以打包成较少的位数。
  2. 程序实例化了大量该结构类型的对象。

如果满足条件,则连续声明所有可位压缩字段(通常在结构类型的末尾),为它们分配适当的位宽(并且通常采取一些步骤来确保位宽是合适的)。在大多数情况下,对这些字段进行排序以实现最佳打包和/或性能是有意义的。


位域还有一个奇怪的次要用途:使用它们来映射各种外部指定的表示形式的位组,如硬件寄存器、浮点格式、文件格式等。这从未打算作为正确使用位域,尽管出于某种无法解释的原因,这种位域滥用继续在现实生活中的代码中弹出。只是不要这样做。

【讨论】:

  • 在我的代码中,我使用位移宏来打包这样的小字段。这避免了位字段的实现定义方面。 (不确定 OP 是否专门询问使用 C 的位域语法;或者关于打包小于一个字节的字段的一般概念)
【解决方案6】:

位域的一个用途是在编写嵌入式代码时镜像硬件寄存器。但是,由于位顺序取决于平台,因此如果硬件对其位的排序与处理器不同,它们将不起作用。也就是说,我再也想不出位域的用途了。你最好实现一个可以跨平台移植的位操作库。

【讨论】:

  • 如何最好地编写这样一个库? C++ 中的一种方法是定义一个属性,该属性将采用地址和位范围并产生可修改的整数左值,然后执行类似“#define UART3_BAUD_RATE_MODE MOD_BITFIELD(UART3_CONTROL,12,4)”的操作。我希望可以安排一些事情,以便内联读取和写入(而不是生成函数调用),但我不知道如何最好地安排布尔逻辑赋值运算符(|= 等)以有效地工作并且/ 或原子地。
【解决方案7】:

过去中使用位域来节省程序内存。

它们会降低性能,因为寄存器不能与它们一起使用,因此必须将它们转换为整数才能对它们进行任何操作。它们往往会导致更复杂的代码,这些代码不可移植且难以理解(因为您必须一直屏蔽和取消屏蔽事物才能实际使用这些值。)

查看 http://www.nethack.org/ 的源代码,了解 pre ansi c 的所有位域荣耀!

【讨论】:

  • 我不明白。为什么“你必须一直屏蔽和取消屏蔽才能真正使用这些值”?编译器为您处理所有这些不是重点吗?
【解决方案8】:

在 70 年代,我使用位域来控制 trs80 上的硬件。显示器/键盘/盒式磁带/磁盘都是内存映射设备。单个位控制各种事物。

  1. 位控制 32 列与 64 列显示。
  2. 同一存储单元中的第 0 位是卡带串行数据输入/输出。

我记得,磁盘驱动器控件有很多。总共有 4 个字节。我认为有一个 2 位驱动器选择。但那是很久以前的事了。那时令人印象深刻的是,该平台至少有两种不同的 c 编译器。

另一个观察结果是位字段确实是特定于平台的。不期望具有位域的程序应该移植到另一个平台。

【讨论】:

  • 70 年代甚至有一个用于 TRS80 的 C 编译器让我有些惊讶
  • 实际上有两个,虽然我主要是手动编码 z80。
【解决方案9】:

在现代代码中,使用位域实际上只有一个原因:在结构/类中控制boolenum 类型的空间需求。例如(C++):

enum token_code { TK_a, TK_b, TK_c, ... /* less than 255 codes */ };
struct token {
    token_code code      : 8;
    bool number_unsigned : 1;
    bool is_keyword      : 1;
    /* etc */
};

IMO 基本上没有理由不为 bool 使用 :1 位域,因为现代编译器将为它生成非常高效的代码。但是,在 C 中,请确保您的 bool typedef 是 C99 _Bool 或失败的 unsigned int,因为有符号的 1 位字段只能保存值 0 和 @ 987654329@(除非你有一台非二进制补码机器)。

对于枚举类型,始终使用与原始整数类型之一(在普通 CPU 上为 8/16/32/64 位)的大小相对应的大小,以避免代码生成效率低下(重复的读取-修改-写入循环,通常)。

通常建议使用位域将结构与一些外部定义的数据格式(数据包头、内存映射的 I/O 寄存器)对齐,但我实际上认为这是一种不好的做法,因为 C 没有给你足够的控制字节顺序、填充和(对于 I/O regs)确切发出的组装序列。如果您想了解这方面缺少多少 C,请查看 Ada 的表示条款。

【讨论】:

  • 注意:bool 和 MSVC 上的位域不能混合,为了与 MSVC 兼容,您需要使用一些 unsigned
  • 另一个避免使用 MSVC 的原因 :-(
  • @Matthieu M.: 嗯....根据我的经验,MSVC 在使用任何带位字段的整数类型时从来没有遇到过任何问题。
  • enum 可以容纳 &lt;= 256 个代码,而不是 less than 255。对于 C++,现在我们只需使用从 &lt;cstdint&gt; 指定底层类型和大小类型的能力。此外,不仅bools 和enums:已知边界的小整数值也可以从节省空间中受益。但是,不使用位域的原因是,如果对象不够丰富,或者其他成员排列得如此紧密,以至于使位域的任何用途无效,那么添加的额外冗长是不值得的;通常不建议使用位域作为默认设置,并且有充分的理由这样做
  • @underscore_d 我希望 C 能够采用底层类型的位域特性。它在其他几种情况下也很有价值,例如控制您的库 ABI,在这方面仍然普遍使用纯 C。
【解决方案10】:

Boost.Thread 在其shared_mutex 中使用位域,至少在 Windows 上:

    struct state_data
    {
        unsigned shared_count:11,
        shared_waiting:11,
        exclusive:1,
        upgrade:1,
        exclusive_waiting:7,
        exclusive_waiting_blocked:1;
    };

【讨论】:

  • 看起来这样做是为了将结构打包成一个 32 位机器字,可能是为了原子性。 Boost 的人确切地知道他们在做什么,并且不会向不知道的人详细地解释自己,这不幸地意味着复制 Boost 逻辑很容易以流泪告终——例如,exclusiveupgrade 不是 bool 是有原因的,但你知道它是什么吗?
  • 逻辑使用比较和交换来避免内核锁,除非必要。我相信这就是它这样做的原因。
  • 我认为@zwol 的观点是,它的 1 位宽部分没有输入为 bools,因为那可能(除非 booltypedefunsigned,它在几乎没有现代或节省空间的代码中不太可能)导致由于类型边界而开始一个新的分配单元,从而破坏了尝试将这些位与其他值打包在一起的点。
  • @underscore_d 是的,这就是我想到的“原因排他和升级不是 bool”。 (天哪,我忘了我写了那条评论。)
【解决方案11】:

要考虑的替代方法是使用虚拟结构(从未实例化)指定位字段结构,其中每个字节代表一个位:

struct Bf_format
{
  char field1[5];
  char field2[9];
  char field3[18];
};

使用这种方法,sizeof 给出位域的宽度,offsetof 给出位域的偏移量。至少在 GNU gcc 的情况下,编译器对逐位操作(使用常量移位和掩码)的优化似乎已经与(基本语言)位字段大致相同。

我编写了一个 C++ 头文件(使用这种方法),它允许以高性能、更便携、更灵活的方式定义和使用位字段的结构:https://github.com/wkaras/C-plus-plus-library-bit-fields。所以,除非你被 C 卡住了,否则我认为很少有充分的理由为位字段使用基础语言工具。

【讨论】:

  • where each byte represents a bit--这会在 RAM 中造成巨大的浪费,不是吗?--使用 8 位来表示您实际需要的每一位。
  • 不,你不使用结构的实例来存储数据,只是为了定义位域的布局和大小。上面的示例 Bf_format 结构定义了适合 4 个 8 位字节的 3 位字段。
猜你喜欢
  • 2011-03-03
  • 2011-05-06
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-10-11
  • 2010-10-03
  • 1970-01-01
相关资源
最近更新 更多