C++ 结构体对齐

Posted fallen_leaves

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++ 结构体对齐相关的知识,希望对你有一定的参考价值。

C++ 结构体对齐

引言

数据结构对齐是数据在计算机内存中排列和访问的方式。它由三个独立但相关的问题组成:数据对齐、数据结构填充和打包。现代计算机硬件中的 CPU 在数据自然对齐时最有效地执行内存读取和写入,这通常意味着数据的内存地址是数据大小的倍数。例如,在 32 位架构中,如果数据存储在四个连续字节中并且第一个字节位于4字节边界上,则数据可能是对齐的。

数据对齐是根据元素的自然对齐方式对齐元素。为了确保自然对齐,可能需要在结构元素之间或结构的最后一个元素之后插入一些填充。例如,在 32 位机器上,包含一个 16 位值后跟一个 32 位值的数据结构可以在 16 位值和 32 位值之间有 16 位填充以对齐 32 位值32 位边界上的值。或者,可以打包结构,省略填充,这可能会导致访问速度变慢,但会使用四分之三的内存。

尽管数据结构对齐是所有现代计算机的基本问题,但许多计算机语言和计算机语言实现会自动处理数据对齐。 Fortran、Ada、PL/I、Pascal、某些 C 和 C++ 实现、D、Rust、C#、和汇编语言至少允许部分控制数据结构填充,这在某些特殊情况下可能很有用。


如何计算填充

这里以64位操作系统为例,首先我们列出一些基本类型的对其大小。

类型 对齐大小
char(1byte) 1
short(2bytes) 2
long(4bytes) 4
float(4bytes) 4
double(8bytes) 8
long double(8bytes) 8

以long类型为例,long的对齐大小为4,代表着这个类型的地址应该可以被4所整除,也就是其二进制地址应该是以0b100结尾。同理,short就是以0b10结尾,double就是以0b1000结尾。需要注意的是对齐大小都是2的整数次幂。

int main(int argc, char const *argv[])


    static_assert(alignof(long) == 4);

    alignas(4) long x;

    std::bitset<64> address = (uint64_t)std::addressof(x);

    // 0000000000000000000000000111001010111111010111111111101100111100
    std::cout << address.to_string() << \'\\n\';

    return 0;   

知道了基础类型之后,我们来看结构体的类型:

struct F1

                  //   alignment  sizeof 
    int8_t i8;    //      1          1
    int16_t i16;  //      2          2
    double f64;   //      8          8
;

static_assert(alignof(F1) == 8);
static_assert(sizeof(F1) == 16);

1、如何计算对齐大小?

结构体类型的对齐大小为最大的成员字段的对齐大小,F1结构体中f64字段对齐大小是最大的,所以F1的对齐大小和double保持一致。


2、如何计算结构体大小

int main(int argc, char const *argv[])


    alignas(8) F1 f;

    std::bitset<64> address;
    
    // 0b000000
    address = (uint64_t)std::addressof(f);
    std::cout << address.to_string() << \'\\n\';

    // 0b000000
    address = (uint64_t)std::addressof(f.i8);
    std::cout << address.to_string() << \'\\n\';

    // 0b000010
    address = (uint64_t)std::addressof(f.i16);
    std::cout << address.to_string() << \'\\n\';
    
    // 0b001000
    address = (uint64_t)std::addressof(f.f64);
    std::cout << address.to_string() << \'\\n\';

    return 0;   

我们将f及其所有字段的地址的最后6比特打印出来。由于f是按照8字节对齐,所以f的二进制地址最后应该是0b1000结尾。i8是按照1字节对齐,它是结构体的第一个字段,其地址和f地址一样。i16是按照2字节对齐的,所以他的地址应该是以0b100结尾,而f的地址0b000000在安放完一个i8后的下一个以0b100结尾的地址是0b000010,所以i16会被安放在0b000010。f64是按照8字节对齐,而f的地址0b000000在安放完一个i8和一个i16后的下一个以0b1000结尾的地址是0b001000,所以f64会被安放在0b001000,如图所示:

图1

模拟布局

为了更好地了解结构体的内存布局,接下来我们来模拟一下。

template <typename TypeList, typename SizeSequence> 
struct layout_impl;

template <typename... Ts, size_t... Sizes> 
struct layout_impl<std::tuple<Ts...>, std::index_sequence<Sizes...>>
 
    // ...
;

// Ts... 是我们结构体每一个字段的类型
template <typename... Ts>
struct layout : layout_impl<
    std::tuple<Ts...>, std::make_index_sequence<sizeof...(Ts)>>

;

首先是构造函数,我们使用一个数组来记录每个字段的数量,在构造时提供每个字段的数量即可。

template <typename... Ts, size_t... Sizes> 
struct layout_impl<std::tuple<Ts...>, std::index_sequence<Sizes...>>

    // C++目前不允许layout_impl(size_t... sizes)这种写法,我们利用模板来完成这个功能
    constexpr explicit layout_impl(detail::to_size_t<Sizes>... sizes)
      : m_sizes sizes...  
    std::array<size_t, NumSizes> m_sizes;
;

/*
struct F1

    int8_t i8;    
    int16_t i16;  
    double f64;   
; => layout<int8_t, int16_t, double>(1, 1, 1)

struct TreeNode

    int value;
    TreeNode* children[2];
; => layout<int, TreeNode*>(1, 2);
*/

其次是结构体的对齐大小,结构体的对齐大小和对齐大小最大的那个字段保持一致。

static constexpr size_t alignment() 

    return std::ranges::max( alignof(Ts)... );

再然后是计算每一个字段的offset。

在计算某个字段的时候,我们需要知道上一个字段的offset和size,然后按照当前字段的对齐大小进行对齐。以F1的第二个字段i16为例,上一个字段是i8,由于他是第一个字段,offset为0,size是1个字节,我们可以按照如下公式计算:

\\[ next = (current + align - 1) \\mod align \\]

举个例子,假设我们是十进制并且按照10字节对齐,当前面有1个字节被占用的时候,我们需要填充9个字节,当前面有2个字节被占用的时候,我们需要填充8个字节,依次类推,当前面有10个字节都被占用的时候,我们不需要填充字节。

constexpr size_t align(size_t n, size_t m) 
 
    assert(std::popcount(m) == 1 && "m should be power of two.");
    return (n + m - 1) & ~(m - 1); 


template <size_t N>
constexpr size_t offset() const

    static_assert(N < NumSizes);
    if constexpr (N == 0)
    
        return 0;
    
    else
    
        const auto last_type_size = sizeof(std::tuple_element_t<N - 1, std::tuple<Ts...>>);
        const auto current_type_alignment = alignof(std::tuple_element_t<N, std::tuple<Ts...>>);
        return detail::align(offset<N - 1>() + last_type_size * m_sizes[N - 1], current_type_alignment);
    

最后是结构体的大小。我们只需要知道最后一个字段的offset,大小以及填充字节大小就可以了,需要注意的是,最后一个字节也是可能会填充的,这样在申请数组的时候才能保证每一个元素都是对齐的。

constexpr size_t alloc_size() const

    return offset<NumTypes - 1>() + sizeof(std::tuple_element_t<NumTypes - 1, std::tuple<Ts...>>) * m_sizes[NumTypes - 1];


题外话

结构体的字段的分布对布局是有影响的,如果我们更改F1字段的顺序,那么它的大小可能会发生变化。

struct F2

                  //   alignment  sizeof 
    int8_t i8;    //      1          1
    double f64;   //      8   
    int16_t i16;  //      2          2
;

static_assert(alignof(F2) == 8);
static_assert(sizeof(F2) == 24);

如果您想更好地回顾关于结构体对齐的知识,可以参见https://www.youtube.com/watch?v=SShSV_iV1Ko

参考文献

[1]维基百科:https://en.wikipedia.org/wiki/Data_structure_alignment

手把手写C++服务器(10):结构体struct常用技术之柔性数组字节对齐__attribute__

前言:在Linux C/C++当中常用的技巧有很多,这里先讨论一下结构体struct中常用的柔型数组、字节对齐和__attribute__,了解针对结构体内存分配过程中的一些常用优化方法。

目录

柔性数组:为变长结构体而生

数组名不占用内存空间

柔性数组的定义方法

为什么需要柔性数组?

怎样使用柔性数组?

结构体中的字节对齐问题

__attribute__:进行属性设置

1、aligned:指定对选哪个的对齐格式

2、packed:取消编译过程中的优化对齐

3、at:绝对定位,将变量或函数绝对定位到flash或RAM中

4、weak:转换成弱符号类型

5、unused:忽略未使用函数的告警

6、deprecated:管理过时代码

7、may_alias:取消拒绝类型打印指针

8、transparent_union:指定透明联合体

参考:

柔性数组:为变长结构体而生

数组名不占用内存空间

数组名本身不占用内存空间!所以在结构体内部声明一个长度为0的数组,结构体长度不变。

先理解这一点,才能明白为什么会在结构体最后定义一个数组名。

柔性数组的定义方法

在struct的最后定义一个长度为0的数组即可,例如下面在网络编程中设定一个消息缓冲区:

struct PayloadMessage
{
  int32_t length;
  char data[0];
};

为什么需要柔性数组?

1、定长数组行不行?

在网络编程、服务端编程的过程中,我们经常需要设定数据缓冲区,需要存储缓冲区的长度和具体的数据,但是我们怎样知道要设定多大的长度呢?C语言中没有C++这样现成vector等数据结构可以动态扩展。默认一个最大长度好像是一个不错的做法,如下所示:

#define MAX_LENGTH 104000
struct PayloadMessage
{
  int32_t length;
  char data[MAX_LENGTH];
};

但是数据溢出了怎么办?扩大默认最大长度?那如果大量没有填满,造成大量浪费怎么办?这样基于MAX_LENGTH相互battle并不是一个好办法。

2、指针行不行?

此处可以当做内存泄漏风险的典例。例如将struct定义为下面这种形式:

struct PayloadMessage
{
  int32_t length;
  char *data;
};

每次要两次分配内存空间:先给结构体分配内存空间,再给指针变量分配空间;释放先释放data再释放结构体内存空间。

要是自己写的代码没有问题,如果给别人接手,who care?四个内存管理步骤一旦顺序错误或者漏掉一步,可怕的内存泄漏便开始了……

怎样使用柔性数组?

1、柔性数组是最好的解决方案

每次使用只用申请一次空间,在构造不定长数据包, 不会浪费空间浪费网络流量, 因为char data[0]; 只是个数组名, 是不占用存储空间。下面是一段小demo。

struct PayloadMessage
{
    int32_t length;
    char data[0];
};

PayloadMessage* payload = static_cast<PayloadMessage*>(::malloc(total_len));
assert(payload);
payload->length(your_length);
for (int i = 0; i < your_length; i++) {
    payload->data[i] = your_data;
} 

2、连续的内存分配空间有益于访问速度。

在指针的解决方案中,两次分配的空间时不连续的。但是柔性数组的解决方案中,只有一次内存分配,内存空间的是连续的,有利于访问速度,可以减少内存碎片。

3、标准的C/C++不支持柔性数组。

最开始的C90是不支持柔性数组的,最先使用柔性数组技术的GNU,其中具体的发展可以参看:https://blog.csdn.net/ssdsafsdsd/article/details/8234736

结构体中的字节对齐问题

这也是一道面试中经常考察、老生常谈的问题。

把握两个规律即可:

1、结构体中元素是按照定义顺序一个一个放到内存中去的,但并不是紧密排列的。从结构体存储的首地址开始,每一个元素放置到内存中时,它都会认为内存是以它自己的大小来划分的,因此元素放置的位置一定会在自己宽度的整数倍上开始(以结构体变量首地址为0计算)。

2、检查计算出的存储单元是否为所有元素中最宽的元素的长度的整数倍,是,则结束;若不是,则补齐为它的整数倍。

__attribute__:进行属性设置

使用__attribute__对结构体struct或共用体union进行设置,分成六种格式aligned, packed, transparent_union, unused, deprecated 和 may_alias 。

1、aligned:指定对选哪个的对齐格式

例如指定对齐格式为8字节:

struct SessionMessage
{
  int32_t number;
  int32_t length;
} __attribute__((aligned(8)))

2、packed:取消编译过程中的优化对齐

就是告诉编译器取消结构在编译过程中的优化对齐(使用1字节对齐),按照实际占用字节数进行对齐。

struct SessionMessage
{
  int32_t number;
  int32_t length;
} __attribute__ ((__packed__));//紧凑排列的方式取消字节对齐

3、at:绝对定位,将变量或函数绝对定位到flash或RAM中

  1. 定位到 flash 中,常用于固化信息,例如:设备的出厂信息,FLASH 标记等;
    const uint8_t usFlashInitVal[] __attribute__((at(0x00030000))) = {0x11,0x22,0x33,0x44,0x55,0x66};//定位在flash中,0x00030000开始的6个字节信息固定
    
  2. 定位到RAM中,常用于数据量较大的缓存,如:串口接收数据。也用于某个位置的特定变量。

    uint8_t ucUsartRecvBuffer[USART_RECV_LEN] __attribute__ ((at(0x00025000)));	//接收缓冲,最大USART_RECV_LEN个字节,起始地址为 0x00025000
    

注意:

  1. 绝对定位不能在函数中定义,局部变量是定义在栈区,栈区是自动分配、释放,不能定义为绝对地址,只能于函数外定义;

  2. 定义的长度不能超过栈或 Flash 的大小,否则导致栈、Flash 溢出。

4、weak:转换成弱符号类型

int  __attribute__((weak))  func(...)
{
    ...
    return 0;
}

func 转成弱符号类型

  • 如果遇到强符号类型(即外部模块定义了 func, extern int func(void);),那么我们在本模块执行的 func 将会是外部模块定义的 func。
  • 如果外部模块没有定义,那么将会调用这个弱符号,也就是在本地定义的 func,直接返回了一个 1(返回值视具体情况而定)相当于增加了一个默认函数

原理链接器发现同时存在弱符号强符号,就先选择强符号,如果发现不存在强符号,只存在弱符号,则选择弱符号。如果都不存在:静态链接,恭喜,编译时报错,动态链接:对不起,系统无法启动。

注意:weak 属性只会在静态库 (.o .a) 中生效,动态库 (.so) 中不会生效。

5、unused:忽略未使用函数的告警

如果一个函数没有被使用,在使用GCC编译的时候,会产生告警!在函数使用前面加上__attribute__((unused))即可:

__attribute__((unused)) double now()
{
  struct timeval tv = { 0, 0 };
  gettimeofday(&tv, NULL);//sys/time.h当中,精确获取当前时间
  return tv.tv_sec + tv.tv_usec / 1000000.0;
}

6、deprecated:管理过时代码

有些代码已经过时不被使用,但是又不能删除掉(保留下兼容的接口),这时候编译会产生告警,我们用deprecated管理过时代码。

直接将需要被兼容的接口写到宏当中,一劳永逸,举例如下:

 
#define  DEPR_AFTER __attribute__((deprecated))
#define  DEPR_BEFOR 
 
 
class DEPR_BEFOR AAA
{
}DEPR_AFTER;
 
int main(int argc, char** argv)
{
    typedef float T;
    AAA aa;
 
    return 0;
}

7、may_alias:取消拒绝类型打印指针

通过指向具有此属性的类型的指针进行的访问不受基于类型的别名分析的约束,而是假定能够为任何其他类型的对象设置别名。在 C99 标准第 6.5 节第 7 段的上下文中,取消引用此类指针的左值表达式被视为具有字符类型。看-fstrict-别名有关别名问题的更多信息。此扩展的存在是为了支持某些向量 API,其中允许指向一种向量类型的指针作为指向不同向量类型的指针的别名。

          typedef short __attribute__((__may_alias__)) short_a;
          
          int
          main (void)
          {
            int a = 0x12345678;
            short_a *b = (short_a *) &a;
          
            b[1] = 0;
          
            if (a == 0x12345678)
              abort();
          
            exit(0);
          }

请注意,具有此属性的类型的对象没有任何特殊语义。

更多参见:https://gcc.gnu.org/onlinedocs/gcc-4.8.4/gcc/Type-Attributes.html

8、transparent_union:指定透明联合体

当使用具有透明联合类型的参数定义函数时,使用联合中任何类型的参数调用该函数会导致联合对象的初始化,该联合对象的成员具有传递的参数的类型并且其值设置为传递参数的值。

当联合数据类型用 限定时__attribute__((transparent_union)),透明联合适用于具有该类型的所有函数参数。

更多参见:https://www.keil.com/support/man/docs/armclang_ref/armclang_ref_chr1384881256073.htm

参考:

以上是关于C++ 结构体对齐的主要内容,如果未能解决你的问题,请参考以下文章

C++ 结构体内存对齐

手把手写C++服务器(10):结构体struct常用技术之柔性数组字节对齐__attribute__

C++基本知识点总结(网摘)

转载c++面试题

C++中结构体的大小

C++ 类和对象上