位运算的最佳实践

34

作为一名初学者的C程序员,我在想,在设备中设置控制位的最佳易读易懂的解决方案是什么。是否有任何标准?是否有任何示例代码可供模仿?谷歌没有给出可靠的答案。

例如,我有一个控制块映射: map

我首先看到的方法是简单地设置所需的位。这需要在注释中进行大量解释,并且似乎并不是很专业。

DMA_base_ptr[DMA_CONTROL_OFFS] = 0b10001100;

我看到的第二种方法是创建一个位域。我不确定这是否应该是我坚持的方法,因为我从未遇到过它被用于这种方式(与我提到的第一选项不同)。

struct DMA_control_block_struct
{ 
    unsigned int BYTE:1; 
    unsigned int HW:1; 
    // etc
} DMA_control_block_struct;

这两个选项中哪一个更好?是否还有其他我没有考虑到的选项?

非常感谢您提供任何建议。


8
顺便提一下,使用0b来表示二进制常量是非标准的。 - Steve Summit
5
就标准 C 语言而言,十六进制数字需要以前导 0x 标识,八进制则为前导 0,否则为十进制。有时我们希望能有一种输入二进制常量的方式,其中前导 0b 是显而易见的表示方法(一些编译器已实现),但正如我所说,这并不是标准规范。 - Steve Summit
此外,显示了九个位,因此该寄存器必须比普通字节大。最好指示寄存器(或其他内容)的长度。您可以使用它们通常的十六进制掩码值(0x01、0x02、0x04、0x08、0x10、0x20、0x40等)来指示位。也许包括完整的长度,如0x0001、0x0002等? - MPW
你没有提到你写的代码是针对常规计算机(例如作为设备驱动程序)还是嵌入式系统的。规范有很大的区别,而且不同平台也有所不同(Linux驱动程序标准与Windows不太相同,尽管它们比AVR更相似)。 - chrylis -cautiouslyoptimistic-
7个回答

40
位域的问题在于C标准没有规定它们定义的顺序与实现的顺序相同。因此,您可能没有设置您认为的位。 C标准的第6.7.2.1p11节规定:
“实现可以分配任何可寻址的存储单元,以容纳位域。如果有足够的空间,立即跟随结构中另一个位域的位域将被打包成同一单元的相邻位。如果空间不足,则未适合的位域是放入下一个单元还是重叠相邻单元是由实现定义的。在单元内分配位域的顺序(高位到低位或低位到高位)是由实现定义的。地址存储单元的对齐方式未指定。”
例如,查看Linux上/usr/include/netinet/ip.h文件中表示IP头的struct iphdr的定义:
struct iphdr
  {
#if __BYTE_ORDER == __LITTLE_ENDIAN
    unsigned int ihl:4;
    unsigned int version:4;
#elif __BYTE_ORDER == __BIG_ENDIAN
    unsigned int version:4;
    unsigned int ihl:4;
#else
# error "Please fix <bits/endian.h>"
#endif
    u_int8_t tos;
    ...

你可以看到这里位域的顺序取决于实现。你也不应该使用这个特定的检查,因为这种行为是系统相关的。因为它是系统的一部分,所以在这个文件中使用是可以接受的。其他系统可能会以不同的方式实现。
所以不要使用位域。
最好的方法是设置所需的位。然而,定义每个位的命名常量并执行想要设置的常量的按位OR操作是有意义的。例如:
const uint8_t BIT_BYTE =     0x1;
const uint8_t BIT_HW   =     0x2;
const uint8_t BIT_WORD =     0x4;
const uint8_t BIT_GO   =     0x8;
const uint8_t BIT_I_EN =     0x10;
const uint8_t BIT_REEN =     0x20;
const uint8_t BIT_WEEN =     0x40;
const uint8_t BIT_LEEN =     0x80;

DMA_base_ptr[DMA_CONTROL_OFFS] = BIT_LEEN | BIT_GO | BIT_WORD;

4
这个文件是系统的一部分,因此它是可以接受的。由于Linux几乎需要GCC来编译,所以这也是“可接受的”。即使字节顺序保持不变,不同的编译器也可以自由更改位域的分配方式。 - Andrew Henle
4
在类Unix系统上的C编译器不仅需要符合C标准,还需要符合该平台的ABI,以便与平台库进行交互。 - plugwash
2
为什么不使用enum来代替定义可能存在ODR问题的常量变量呢? - Ruslan
@Ruslan 可能是因为枚举类型的整数实现方式无法控制。 - Will
1
你可以为你的位域和结构体编写各种测试,例如普通的运行时测试或静态断言宏。如果位不在预期位置,则报告错误并停止。 - Zan Lynx

19

其他回答已经涵盖了大部分内容,但值得一提的是,即使您无法使用非标准的0b语法,您仍然可以使用移位操作将1位移动到指定位置。

#define DMA_BYTE  (1U << 0)
#define DMA_HW    (1U << 1)
#define DMA_WORD  (1U << 2)
#define DMA_GO    (1U << 3)
// …

注意最后一个数字与文档中的“位数”列匹配。

设置和清除位的用法不变:

#define DMA_CONTROL_REG DMA_base_ptr[DMA_CONTROL_OFFS]

DMA_CONTROL_REG |= DMA_HW | DMA_WORD;    // set HW and WORD
DMA_CONTROL_REG &= ~(DMA_BYTE | DMA_GO); // clear BYTE and GO

3
对于初学者而言,宏定义中的括号非常重要,例如 #define DMA_BYTE (1U << 0) - 可以参考这个问题 - mgarey
@mgarey 我认为这对所有C开发人员都很重要,而不仅仅是初学者。我认为宏中括号的使用不足是宏中的一个错误,无论你打算让谁使用这个宏。 - kasperd
2
@kasperd 我认为重点是非初学者已经被这个问题咬过了,因此学会在宏中加括号了。=) - Arkku

18

传统的C语言方式是定义一组位:

#define WORD  0x04
#define GO    0x08
#define I_EN  0x10
#define LEEN  0x80

那么你的初始化就变成了

DMA_base_ptr[DMA_CONTROL_OFFS] = WORD | GO | LEEN;
您可以使用|来设置单个位:
DMA_base_ptr[DMA_CONTROL_OFFS] |= I_EN;

您可以使用&~清除单个位:

DMA_base_ptr[DMA_CONTROL_OFFS] &= ~GO;

您可以使用&测试单个位:

if(DMA_base_ptr[DMA_CONTROL_OFFS] & WORD) ...

绝对不要使用位域(bitfields),虽然它们有用处,但当一个外部规范定义了位于某些位置的位时,就不适合使用它们,我认为这种情况下是不适合的。

另请参见问题20.72.26C FAQ列表中。


4
我认为在特定的嵌入式平台上,使用位域来匹配硬件寄存器并没有根本性问题。因为代码往往本质上不具备可移植性(与特定设备绑定并且通常只用单个编译器)。特别是对于多位域,可读性和方便性的收益可能是值得的。(当然,可能存在其他问题,例如代码大小或性能,需要进行检查。但我的观点是我不会自动排除位域用于此目的的可能性。) - Arkku
1
谢谢您的回答,我发现使用细节非常有帮助,肯定会用上其中一两个。 - KateOleneva
1
@Arkku,ClayRaynor:最终它是风格问题。在我看来,试图让内存数据结构符合外部强制的存储布局只会带来更多的麻烦,不值得。虽然这可能是少数意见,因为无数C程序员花费大量时间尝试安排这样的符合性。(当他们将单个位与位域匹配时,有时他们会成功。) - Steve Summit
1
使用位域来匹配硬件确实会使代码不可移植(理论上甚至对于同一硬件的不同编译器也是如此),因此我认为默认情况下不应该使用它们。但同时,我认为在硬件寄存器中匹配位的前提表明这个代码可能已经非常不可移植了,因此将位域添加到混合中也不会那么严重。对于1位字段,我个人不会这样做,但对于一个一次性的非可移植项目中的2+位字段,我可能会考虑一下,仅仅是因为它具有良好的语法。=) - Arkku
1
@Arkku,Steve Summuit,我必须同意你们的观点。我完全支持尝试最大化可移植性。但是,我认为可移植性不应该是主要关注点,因为你正在处理硬件相关的代码。我也理解并同意匹配外部存储限制所带来的麻烦。 - Clay Raynor
显示剩余5条评论

8

位域没有标准。在这种情况下,映射和位操作取决于编译器。二进制值(如0b0000)也没有标准化。通常的方法是为每个位定义十六进制值。例如:

#define BYTE (0x01)
#define HW   (0x02)
/*etc*/

当你想要设置位时,可以使用:

DMA_base_ptr[DMA_CONTROL_OFFS] |= HW;

或者你可以使用以下方式清除位:

DMA_base_ptr[DMA_CONTROL_OFFS] &= ~HW;

5
现代C编译器可以很好地处理简单的内联函数,而不会有额外的开销。我会把所有的抽象都做成函数,这样用户就不需要操作任何位或整数,并且不太可能滥用实现细节。
当然,你也可以使用常量而不是函数来实现细节,但API应该是函数。这还允许在使用古老的编译器时使用宏代替函数。
例如:
#include <stdbool.h>
#include <stdint.h>

typedef union DmaBase {
  volatile uint8_t u8[32];
} DmaBase;
static inline DmaBase *const dma1__base(void) { return (void*)0x12340000; }

// instead of DMA_CONTROL_OFFS
static inline volatile uint8_t *dma_CONTROL(DmaBase *base) { return &(base->u8[12]); }
// instead of constants etc
static inline uint8_t dma__BYTE(void) { return 0x01; }

inline bool dma_BYTE(DmaBase *base) { return *dma_CONTROL(base) & dma__BYTE(); }
inline void dma_set_BYTE(DmaBase *base, bool val) {
  if (val) *dma_CONTROL(base) |= dma__BYTE();
  else *dma_CONTROL(base) &= ~dma__BYTE();
}
inline bool dma1_BYTE(void) { return dma_BYTE(dma1__base()); }
inline void dma1_set_BYTE(bool val) { dma_set_BYTE(dma1__base(), val); }

这样的代码应该是机器生成的:我使用 gsl(0mq 的知名库)来根据模板和一些列出寄存器详细信息的 XML 输入生成这些代码。


2
也许我很奇怪,但如果我在处理像 DMA 控制这样的低级别东西时,我更喜欢看到位层面的操作,而不是把它们包装成“bool”,特别是那些我不能一次读取或设置多个的“bool”。(如果想要提供真正的高级别 API,那么导出的函数也应该比“set_BYTE”更高级别,至少在名称上是如此。) - Arkku
1
@Arkku 当然可以有更高级别的API,同时一次设置多个位也将在那里处理。可能只有某些位组合是有用的,尽管当然会有所不同。在C语言中强制类型安全,即不在UART上使用DMA位模式,是一个问题... - Kuba hasn't forgotten Monica

1
你可以使用位域,尽管这里有一些人在散布恐慌。你只需要了解编译器和系统ABI定义位域的"实现定义"方面即可。不要被那些把"实现定义"之类的词加粗的学究吓到。
然而,其他人似乎忽略了处理内存映射硬件设备时可能出现的各种反直觉的情况,特别是当使用像C这样的高级语言和这些语言提供的优化功能时。例如,每次读取或写入硬件寄存器都可能产生副作用,即使在写入时未更改位。同时,优化器可能会使生成的代码难以判断是否实际上正在读取或写入寄存器的地址,即使C对象描述寄存器已经被仔细地限定为volatile,也需要非常小心地控制I/O发生的时间。
也许你需要使用编译器和系统定义的一些特定技术来正确操作内存映射硬件设备,这对于许多嵌入式系统来说都是必需的。在某些情况下,编译器和系统供应商确实会使用位域,就像Linux在某些情况下所做的那样。我建议先阅读你的编译器手册。
您引用的位描述表似乎是针对Intel Avalon DMA控制器核心的控制寄存器。 “读/写/清除”列提供了有关特定位在读取或写入时如何行为的提示。该设备的状态寄存器具有一个位的示例,其中写零将清除位值,但它可能不会读回与写入相同的值 - 即写入寄存器可能会对设备产生副作用,具体取决于DONE位的值。有趣的是,他们将SOFTWARERESET位文档化为“RW”,但然后将过程描述为将1两次写入该位以触发复位,然后他们还警告说,在DMA传输处于活动状态时执行DMA软件复位可能会导致总线永久锁定(直到下一次系统复位)。因此,除非切实需要,否则不应写入SOFTWARERESET位。 在C中管理重置无论如何描述寄存器都需要仔细编码。
关于标准,ISO/IEC制定了一份名为“ISO/IEC TR 18037”的技术报告,副标题为“扩展支持嵌入式处理器”。它讨论了使用C语言管理硬件寻址和设备I/O相关的问题,特别是对于你在问题中提到的位映射寄存器类型,它记录了一个名为<iohw.h>的头文件中可用的一些宏和技术。如果你的编译器提供这样的头文件,那么你可能可以使用这些宏。
TR 18037有草案版本可供使用,最新版本为TR 18037(2007),但阅读起来可能比较枯燥。然而,它包含了一个<iohw.h>的示例实现。
也许一个真实世界中<iohw.h>的好例子是在QNX中。 QNX文档提供了一个不错的概述(以及一个示例,尽管我强烈建议使用枚举而不是宏来表示整数值):QNX <iohw.h>

关于使用enum而不是宏,宏的一个好处是它们可以包括对特定类型的强制转换(例如匹配硬件寄存器的宽度),而enum的实际类型是由实现定义的。(是的,你可以像位字段一样在这里提出同样的论点,即如果您知道实现是如何定义的,那么这不是一个问题,这是一个有效的论点。=) - Arkku
一个 enum 值总是作为一个 int 给出,并且在使用时表示的类型必须与 int 兼容,因此对于这些目的来说,它仍然有效地只是一个 int。此外,我强烈反对在宏定义中包含转换。如果有必要,您可以在使用枚举时或使用常量时(无论是否来自宏),添加转换,尽管通常这样的转换只是我们人类需要阅读和弄清楚它们是否实际上与没有它们有任何不同的多余噪音。 - Greg A. Woods

-2

在声明变量以存储它们的值时,您应确保将位初始化为已知的默认值。在 C 中,当您声明一个变量时,您只是在地址上保留了一块内存,并且该块的大小基于其类型。如果您不初始化变量,则可能会遇到未定义/意外行为,因为变量的值将受到在声明之前该块内存中的值/状态的影响。通过将变量初始化为默认值,您可以清除该块内存的现有状态并将其置于已知状态。

就可读性而言,您应使用 位域 来存储位的值。位域使您能够在结构体中存储位的值。这使得组织更容易,因为您可以使用点表示法。此外,您应确保注释位域的声明以解释不同字段的用途作为最佳实践。希望这回答了您的问题。祝您在 C 编程中好运!


4
位域在不同编译器间的可移植性非常差,因为每个编译器都可以任意处理。根据C标准6.7.2.1 结构体和联合体说明符中的第11段所述:“...如果一个不适合的位域被放到下一个存储单元中或与相邻的存储单元重叠 是由实现定义的。在一个存储单元中分配位域的顺序(从高位到低位或从低位到高位)是由实现定义的。可寻址存储单元的对齐方式 未指定。” - Andrew Henle
你应该检查stddef.hlimits.h头文件中的定义,因为整数原语的大小是特定于平台的,位移操作可能会受到系统字节序的影响。此外,编译器手册应该指定位字段的行为。而且,这是硬件特定的,所以可移植性已经不存在了。 - Clay Raynor

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