使用 C 的位域实现是否值得?如果是这样,它什么时候使用过?
我正在查看一些仿真器代码,看起来芯片的寄存器没有使用位字段实现。
这是出于性能原因(或其他原因)而避免的事情吗?
是否还有使用位域的时候?(即固件放在实际芯片等)
使用 C 的位域实现是否值得?如果是这样,它什么时候使用过?
我正在查看一些仿真器代码,看起来芯片的寄存器没有使用位字段实现。
这是出于性能原因(或其他原因)而避免的事情吗?
是否还有使用位域的时候?(即固件放在实际芯片等)
位字段通常仅在需要将结构字段映射到特定位片时使用,其中某些硬件将解释原始位。一个示例可能是组装 IP 数据包标头。我看不出模拟器使用位域对寄存器建模的令人信服的理由,因为它永远不会触及真正的硬件!
虽然位域可以带来简洁的语法,但它们非常依赖于平台,因此不可移植。一种更便携但更冗长的方法是使用直接按位操作,使用移位和位掩码。
如果您将位域用于在某些物理接口处组装(或拆卸)结构之外的任何东西,性能可能会受到影响。这是因为每次从位域读取或写入时,编译器都必须生成代码来执行屏蔽和移位,这将消耗周期。
尚未提及的位域的一个用途是,unsigned
位域“免费”提供以二的幂为模的算术运算。例如,给定:
struct { unsigned x:10; } foo;
算术运算foo.x
将模 2 10 = 1024 执行。
(当然,同样可以通过使用按位&
运算直接实现 - 但有时它可能会导致更清晰的代码让编译器为你做这件事)。
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] 中迭代数百万个元素的向量时,使得变量确实不驻留在缓存或寄存器中,位域代码可能是最快的。”
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
当然,时间都是相对的,你实现这些字段的方式在你的系统环境中可能根本不重要。
我在两种情况下看到/使用过位字段:计算机游戏和硬件接口。硬件使用非常简单:硬件需要某种位格式的数据,您可以手动定义或通过预定义的库结构定义。它们是使用位域还是仅使用位操作取决于特定的库。
在“旧时代”,计算机游戏经常使用位域来尽可能地利用计算机/磁盘内存。例如,对于 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 时,就没有那么多了。
位域的主要目的是通过实现更紧密的数据打包,提供一种在大规模实例化聚合数据结构中节省内存的方法。
整个想法是利用某些结构类型中有多个字段的情况,这些字段不需要某些标准数据类型的整个宽度(和范围)。这为您提供了将多个此类字段打包在一个分配单元中的机会,从而减少了结构类型的整体大小。极端的例子是布尔字段,它可以由单个位表示(例如,其中 32 个可打包到单个unsigned int
分配单元中)。
显然,这仅在减少内存消耗的优点大于访问存储在位域中的值的速度较慢的缺点的情况下才有意义。然而,这种情况经常出现,这使得位域成为绝对不可缺少的语言特征。这应该回答您关于现代使用位域的问题:不仅使用它们,而且在任何面向处理大量同质数据(例如大图)的实际有意义的代码中,它们本质上是强制性的,因为它们的内存- 节省的好处大大超过任何个人访问性能损失。
在某种程度上,位域的用途与诸如“小”算术类型之类的东西非常相似:signed/unsigned char
, short
, float
. 在实际的数据处理代码中,通常不会使用任何小于int
or的类型double
(除了少数例外)。signed/unsigned char
像, short
,之类的算术类型float
仅作为“存储”类型存在:在已知范围(或精度)足够的情况下,作为结构类型的节省内存的紧凑成员。位域只是朝同一方向迈出的又一步,它以更高的性能换取更大的内存节省优势。
因此,这为我们提供了一组相当明确的条件,在这些条件下值得使用位域:
如果满足条件,则连续声明所有可位打包字段(通常在结构类型的末尾),为它们分配适当的位宽(并且通常采取一些步骤来确保位宽是适当的) . 在大多数情况下,对这些字段进行排序以实现最佳打包和/或性能是有意义的。
位域还有一个奇怪的次要用途:使用它们以各种外部指定的表示形式映射位组,如硬件寄存器、浮点格式、文件格式等。这从来没有打算作为位域的正确使用,尽管出于某种无法解释的原因,这种位字段滥用继续在现实生活中的代码中弹出。只是不要这样做。
位域的一种用途是在编写嵌入式代码时镜像硬件寄存器。但是,由于位顺序与平台有关,因此如果硬件对位的顺序与处理器不同,它们就不起作用。也就是说,我再也想不出位域的用途了。你最好实现一个可以跨平台移植的位操作库。
过去使用位域来节省程序内存。
它们会降低性能,因为寄存器不能与它们一起使用,因此必须将它们转换为整数才能对它们做任何事情。它们往往会导致更复杂的代码,这些代码不可移植且难以理解(因为您必须始终屏蔽和取消屏蔽事物才能实际使用这些值。)
查看http://www.nethack.org/的源代码,了解 pre ansi c 的所有位域荣耀!
在 70 年代,我使用位域来控制 trs80 上的硬件。显示器/键盘/盒式磁带/磁盘都是内存映射设备。单个位控制各种事物。
我记得,磁盘驱动器控件有很多。总共有 4 个字节。我认为有一个 2 位驱动器选择。但那是很久以前的事了。当时令人印象深刻的是,该平台至少有两个不同的 c 编译器。
另一个观察结果是位字段确实是特定于平台的。不期望具有位字段的程序应该移植到另一个平台。
在现代代码中,使用位域实际上只有一个原因:在结构/类中控制一个bool
或一个类型的空间需求。enum
例如(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 基本上没有理由不使用:1
位域bool
,因为现代编译器将为它生成非常有效的代码。但是,在 C 中,请确保您的bool
typedef 是 C99_Bool
或未使用无符号int,因为有符号的 1 位字段只能保存值0
和-1
(除非您以某种方式拥有非二进制补码机器)。
对于枚举类型,始终使用与原始整数类型之一(在普通 CPU 上为 8/16/32/64 位)的大小相对应的大小,以避免代码生成效率低下(通常是重复的读取-修改-写入周期) .
通常建议使用位域将结构与一些外部定义的数据格式(数据包头、内存映射的 I/O 寄存器)对齐,但我实际上认为这是一种不好的做法,因为 C 没有给你足够的字节序控制权、填充和(对于 I/O regs)确切地发出了哪些组装序列。如果您想了解该领域缺少多少 C,请查看 Ada 的表示条款。
Boost.Threadshared_mutex
至少在 Windows 上使用位域:
struct state_data
{
unsigned shared_count:11,
shared_waiting:11,
exclusive:1,
upgrade:1,
exclusive_waiting:7,
exclusive_waiting_blocked:1;
};
要考虑的另一种方法是使用虚拟结构(从未实例化)指定位字段结构,其中每个字节代表一个位:
struct Bf_format
{
char field1[5];
char field2[9];
char field3[18];
};
使用这种方法sizeof给出了位域的宽度,而offsetof给出了位域的偏移量。至少在 GNU gcc 的情况下,编译器对按位操作(使用常量移位和掩码)的优化似乎已经与(基本语言)位字段大致相同。
我编写了一个 C++ 头文件(使用这种方法),它允许以高性能、更便携、更灵活的方式定义和使用位字段的结构: https ://github.com/wkaras/C-plus-加库位字段。因此,除非您被 C 卡住,否则我认为很少有充分的理由将基本语言工具用于位字段。