位设置和代码可读性

4

我有一个Arduino应用程序(实际上是一个库),其中有许多状态标志 - 最初我只是将它们声明为整数(实际上是uint8_t,因此在这种情况下是8位无符号字符)。但我可以将它们全部合并为一个整数,并使用位掩码操作来设置和测试状态。

以下是前者的示例:

if (_shift == HIGH)
{
    _shift = LOW;
}
else
{
    _shift = HIGH;
}

后者的一个例子

#define SHIFT_BIT 0

if (bitRead(_flags, SHIFT_BIT) == HIGH)
{
   bitWrite(_flags, SHIFT_BIT, LOW);
}
else
{
   bitWrite(_flags, SHIFT_BIT, HIGH);
}

前者阅读起来更好,但后者更高效(空间和时间)。在这种情况下,空间和时间效率总是应该获胜,还是只有在需要时才进行优化?

(添加)

为了完整起见,这里是关于bitWrite等宏的Wiring定义:

#define bitRead(value, bit) (((value) >> (bit)) & 0x01)
#define bitSet(value, bit) ((value) |= (1UL << (bit)))
#define bitClear(value, bit) ((value) &= ~(1UL << (bit)))
#define bitWrite(value, bit, bitvalue) (bitvalue ? bitSet(value, bit) : bitClear(value, bit))
9个回答

8
请查看 雷蒙德·陈(Raymond Chen) 对此问题的精彩探讨。总之,您需要进行一些详细计算,以确定后一种情况实际上是否更有效,这取决于有多少对象与有多少调用站点实际设置了这些状态。

就可读性而言,似乎您是使用成员变量来做到这一点,这意味着您可能已经将它们封装在不错的函数中。在这种情况下,我对可读性并不太担心,因为至少使用该类的人的代码看起来很好。但是,如果这是一个问题,您可以将其封装在私有函数中。


1
在类的实例很少的情况下,我同意其中大部分观点。但是,(a)他说得好像创建1000个结构的实例是闻所未闻的,(b)自然字原子性的好处甚至可以说是毫无意义的。在你的多线程代码依赖于原子读写但从不需要原子更新的字段的情况下,这种情况比具有1000个实例的结构要罕见得多。并且,在大多数情况下,一旦将其移动到缺乏高速缓存一致性的架构上,它就会出现问题。 - Steve Jessop
我会查看一下参考资料,谢谢。在这种情况下(它是一个管理按钮/LED组合的通用类),我无法想象实例化超过大约20个,但关于1000个实例的观点是正确的,只是在这个特定的情况下不适用... - Alan Moore
1
那是一篇好文章。好处是我可以通过查看目标应用程序的内存占用和相关内存使用情况来直接衡量它。在我所针对的芯片上,我只有2k的可用RAM用于“变量”,但30k用于程序。因此,通过增加后者来减少前者可能是一个不错的权衡。 - Alan Moore
2
当您的系统只有2k RAM和16Mhz处理器时,实际可用的实例数量会迅速下降到不到1000个。 - NoMoreZealots
@Pete:非常同意。如果节省16个字节的数据实际上是有价值的,那么Chen的分析根本不适用(或声称适用)。 - Steve Jessop

5

根据AVR-GCC编译器的兼容性情况(我不确定),您可以这样做,使事情保持整洁。

struct flags {
    unsigned int flag1 : 1;  //1 sets the length of the field in bits
    unsigned int flag2 : 4;
}; 

flags data;

data.flag1 = 0;
data.flag2 = 12;

if (data.flag1 == 1)
{
    data.flag1 = 0;
}
else
{
    data.flag1 = 1;
}

如果您也想一次访问整个标志 int,则需要:
union 
{
    struct {
        unsigned int flag1 : 1;  //1 sets the length of the field in bits
        unsigned int flag2 : 4;
    } bits;
    unsigned int val;
} flags;

您可以通过两个间接级别访问位:variable.bits.flag1 <-- 返回单个位标志或使用单个级别获取整个int值的标志:variable.val <-- 返回int


是的,我在回到C++时奇怪地忘记了那个技巧。 - Alan Moore
1
除此之外,您还可以为每个标志定义:#define f_EndOfFrameReached (data.endOfFrameReached)这样可以增加可读性。我曾经参与过一些嵌入式项目,它们都一致地使用了这种方法,而且它既易于维护又节省 RAM。程序员只需要熟悉这个约定及其注意事项(例如,要注意非原子性的读-修改-写操作,这意味着您可能不应该将这些用于中断例程)。 - Craig McQueen
位域结构体因为编译器可能以意想不到的方式对结构体进行打包而臭名昭著。只有在您确信编译器的行为(1)并且永远不会更改编译器(例如,只有一个编译器的专用处理器)时,才应使用此方法。 - myron-semack
@msemack:只要你不试图使用结构体通过类型转换或联合来“解码”字节流,无论是位域打包、大小端问题还是对齐问题,这都不应该成为问题。或者我误解了你的意思? - Craig McQueen

3

如果您将使用常量 HIGHLOW 的需求去除,通过拆分为两个方法,可以使内容更清晰。只需创建 bitSetbitClear 方法。 bitSet 将位设置为 HIGH,而 bitClear 将位设置为 LOW。然后它变成了:

#define SHIFT_BIT 0

if (bitRead(_flags, SHIFT_BIT) == HIGH)
{
    bitClear(_flags, SHIFT_BIT);
}
else
{
    bitSet(_flags, SHIFT_BIT);
}

当然,如果您只有HIGH == 1LOW == 0,那么您不需要进行==检查。

1
在Arduino/Wiring实现中,HIGH等于0x01,LOW等于0x00... - Alan Moore

1
在我看来,即使是你后面的代码也很易读。通过为每个标志命名,代码可以轻松阅读。
一个不好的做法是使用“魔术”数字:
if( _flags | 0x20 ) {  // What does this number mean?
   do_something();
}

1
如果你在谈论可读性、位集合和 C++,为什么我在那里找不到 std::bitset 的任何东西呢? 我知道嵌入式程序员竞赛非常喜欢位掩码,并且演化出对其纯粹丑陋的盲目(不是种族:),但除了掩码和位域之外,标准库也有相当优雅的解决方案。
一个例子:
#include <bitset>

enum tFlags { c_firstflag, c_secondflag, c_NumberOfFlags };

...

std::bitset<c_NumberOfFlags> bits;

bits.set( c_firstflag );
if( bits.test( c_secondflag ) ) {
  bits.clear();
}

// even has a pretty print function!
std::cout << bits << std::endl;// does a "100101" representation.

不确定这个编译器是否支持标准库(或者会占用太多内存而无法使用)。默认情况下没有标准输出。没有屏幕,只有当你启用串口接口时才有可能。 - Alan Moore
@Alan Moore:cout位只是为了炫耀它可以做什么。非常适用于测试! - xtofl

1

仅仅说“太简单了”是否过于简单?

flags ^= bit;

1

如果你不需要优化,就不要去做,使用最简单的解决方案。

如果你确实需要优化,你应该知道为什么:

  • 如果你只设置或清除位而不是切换它,那么第一个版本会更快,因为你不需要读取内存。

  • 第一个版本在并发方面更好。在第二个版本中,你需要进行读-修改-写操作,因此你需要确保内存字节不会同时被访问。通常你会禁用中断,这会增加中断延迟。而且,忘记禁用中断可能会导致非常难以找到的错误(我遇到过的最恶心的错误就是这种错误)。

  • 第一个版本在代码大小方面略微更好(使用更少的闪存),因为每个访问都是单个加载或存储操作。第二种方法需要额外的位操作。

  • 第二个版本使用的RAM更少,特别是如果你有很多这样的位。

  • 如果你想一次测试几个位(例如,其中一个位是否设置),第二个版本也更快。


0

对于位域,最好使用逻辑运算,这样你可以这样做:

if (flags & FLAG_SHIFT) {
   flags &= ~FLAG_SHIFT;
} else {
   flags |= FLAG_SHIFT;
}

现在它看起来像前者,具有后者的效率。 现在您可以拥有宏而不是函数,因此(如果我理解正确 - 它应该是这样的):

#define bitIsSet(flags,bit) flags | bit
#define bitSet(flags,bit) flags |= bit
#define bitClear(flags,bit) flags &= ~bit

您不需要调用函数,代码变得更易读。

我还没有尝试过Arduino,但可能已经有了这种宏,我不确定。


1
是的,很抱歉使用了Arduino/wiring定义 - bitSet/bitWrite等都是通过宏完成的,这些宏与您上面编写的内容非常相似或完全相同。挑战在于五个不同级别的宏的可读性,这最终会隐藏您试图实现的真正内容... - Alan Moore
我认为在C语言中,位操作是相当简单的,任何从事嵌入式系统开发(这实际上就是Arduino所属的领域)的人都应该熟悉这些构造。我认为你第二段代码的问题可能只是宏的命名... bitRead()和bitWrite()与使用Set、Clear或IsSet相比似乎有些奇怪。但我们无需过多纠结于此 :-) - Chris J
是的,有一个bitWrite函数可以定义是设置还是清除,然后还有bitSet和bitClear函数,它们使用一个较少的参数执行相同的操作(即变量+位而不是变量+位+0或1)。在查看头文件之前,我曾希望这些差异中有一些神奇的优化,但实际上只是某个人的约定... :-) - Alan Moore
这种方法的一个潜在问题是,如果你有很多标志,因此有几个变量持有标志。那么“flags”和“bit”必须成对出现,程序员有责任不要混淆它们。即是 bitSet(flags1, END_OF_FRAME_REACHED) 还是 bitSet(flags2, END_OF_FRAME_REACHED)?你可以通过命名约定来缓解这个问题,例如 bitSet(flags2, F2_END_OF_FRAME_REACHED)。 - Craig McQueen
1
请注意,在实现宏时,要把所有内容用括号括起来...此外,我认为你希望bitIsSet()宏/函数看起来更像:(((flags) & (bit)) != 0)。 - Michael Burr
我已经将这些宏的定义添加到上面问题的末尾。括号随处可见... - Alan Moore

0
我认为我最关心的第一件事是: “#define SHIFT 0” 为什么不使用常量而不是宏?就效率而言,常量允许确定类型,从而确保之后不需要进行转换。
至于您技术的效率: - 首先,摆脱else子句(如果其值已经高,则为什么将位设置为HIGH?) - 其次,首选可读性较好的内容,内部使用位掩码的内联设置器/获取器可以完成工作,既有效又可读。
至于存储,对于C ++,我倾向于使用bitset(与枚举结合使用)。

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