iOS中的字节对齐

3,212 阅读16分钟

字节对齐是C语言中的一个概念,但什么是字节对齐?对齐准则是什么?为什么要进行字节对齐呢?字节对齐会不会导致一些额外问题呢? 字节对齐对我们编程有什么启示意义和思考? 带着这四个疑问我们聊聊字节对齐。

什么是字节对齐?

计算机内存大小基本单位是字节(byte),理论上讲,可以从任意地址访问某种基本数据类型,但是实际上,计算机并非逐字节大小读写内存,而是以2,4,或8的 倍数的字节块来读写内存,如此一来就会对基本数据类型的合法地址作出一些限制,即它的地址必须是2,4或8的倍数。那么就要求各种数据类型按照一定的规则在空间上排列,这就是对齐。

对齐准则是什么?

对齐的分类

Intel X86架构为例,对齐方式可分为:结构体对齐栈内存对齐位域对齐(位域本质上为结构体类型); 对于Intel X86架构平台,每次分配内存应该是从4的整数倍地址开始分配,无论是对结构体变量还是简单类型的变量。

每个特定的平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。我们可以通过预编译命令#pragma pack(n),n=1、2、4、8、16 来改变这一系数,其中的n就是要指定的“对齐系数”。我们iOS编译器Xcode的对齐系数就是8。

字节对齐的问题主要就是针对结构体,所以我们这里主要讨论结构体对齐方式,对栈内存对齐位域对齐感兴趣同学可以自行查资料哈;

在C语言中,结构体是一种符合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是一些复合数据类型(如数组、结构体、联合等)的数据单元。编译器为结构体的每个成员按照其自然边界(alignment)分配空间。各成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构的地址相同。

结构体对齐准则

先来看四个重要的基本概念:

  1. 数据类型自身的对齐值:char型数据自身对齐值为1字节,short型数据为2字节,int/float型为4字节,double型为8字节。
  2. 结构体或类的自身对齐值:其成员中自身对齐值最大的那个值。
  3. 指定对齐值:#pragma pack (value)时的指定对齐值value
  4. 数据成员、结构体和类的有效对齐值:自身对齐值和指定对齐值中较小者,即有效对齐值=min{自身对齐值,当前指定的pack值}

基于上面这些值,就可以方便地讨论具体数据结构的成员和其自身的对齐方式。 上面的概念便于理解,结构体字节对齐的细节和具体编译器实现相关,一般满足下面三个准则:

  1. 结构体变量的首地址能够被其最宽基本类型成员的大小所整除;
  2. 结构体每个成员相对结构体首地址的偏移量(offset)都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节(internal adding);
  3. 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节{trailing padding}。

对于以上规则可以这么理解:

  • 第一条:编译器在给结构体开辟空间时,首先找到结构体中最宽的基本数据类型,然后寻找内存地址能被该基本数据类型所整除的位置,作为结构体的首地址。将这个最宽的基本数据类型的大小作为对齐模数。
  • 第二条:为结构体的一个成员开辟空间之前,编译器首先检查预开辟空间的首地址相对于结构体首地址的偏移是否是本成员大小的整数倍,若是,则存放本成员,反之,则在本成员和上一个成员之间填充一定的字节,以达到整数倍的要求,也就是将预开辟空间的首地址后移几个字节。
  • 第三条:结构体总大小是包括填充字节,最后一个成员满足上面两条以外,还必须满足第三条,否则就必须在最后填充几个字节以达到本条要求。

结构体对齐示例

示例1:

定义结构体如下,已知32位机器上各数据类型的长度为:char为1字节、short为2字节、int为4字节、long为4字节、float为4字节、double为8字节。 那么sizeof(strcut A)值;sizeof(struct B)的值 分别是什么?

/// 32位,X86处理器,GCC编译器
struct A{
    int    a;
    char   b;
    short  c;
};
struct B{
    char   b;
    int    a;
    short  c;
};

结果是:sizeof(strcut A)值为8;sizeof(struct B)的值却是12。

假设B从地址空间0x0000开始存放,且指定对齐值默认为4(4字节对齐)。成员变量b的自身对齐值是1,比默认指定对齐值4小,所以其有效对齐值为1,其存放地址0x0000符合0x0000%1=0。成员变量a自身对齐值为4,所以有效对齐值也为4,只能存放在起始地址为0x0004~0x0007四个连续的字节空间中,符合0x0004%4=0且紧靠第一个变量。变量c自身对齐值为 2,所以有效对齐值也是2,可存放在0x0008~0x0009两个字节空间中,符合0x0008%2=0。所以从0x0000~0x0009存放的都是B内容。

再看数据结构B的自身对齐值为其变量中最大对齐值(这里是b)所以就是4,所以结构体的有效对齐值也是4。根据结构体圆整的要求, 0x0000~0x0009=10字节,(10+2)%4=0。所以0x0000A~0x000B也为结构体B所占用。故B从0x00000x000B 共有12个字节,sizeof(struct B)=12。

之所以编译器在后面补充2个字节,是为了实现结构数组的存取效率。试想如果定义一个结构B的数组,那么第一个结构起始地址是0没有问题,但是第二个结构呢?按照数组的定义,数组中所有元素都紧挨着。如果我们不把结构体大小补充为4的整数倍,那么下一个结构的起始地址将是0x0000A,这显然不能满足结构的地址对齐。因此要把结构体补充成有效对齐大小的整数倍。其实对于char/short/int/float/double等已有类型的自身对齐值也是基于数组考虑的,只是因为这些类型的长度已知,所以他们的自身对齐值也就已知。

示例2:

/// 32位,X86处理器,GCC编译器
#include<stdio.h> 
#include<stdint.h> 
struct test { 
    int a; 
    char b; 
    int c; 
    short d;
}; 
int main(int argc,char *argv) { 
    /*在32位和64位的机器上,size_t的大小不同*/ 
    printf("the size of struct test is %zu\n",sizeof(struct test)); 
    return 0; 
}

编译成32位程序并运行(默认四字节自然对齐),可以看到,结构体test 的大小为16字节,而不是11字节(a占4字节,b占1字节,c占4字节,d占2字节)

#64位机器上编译32位程序可能需要安装一个库 
#sudo apt-get install gcc-multilib gcc -m32 -o testByteAlign testByteAlign.c 
#编译程序 chmod +x testByteAlign 
#赋执行权限 ./testByteAlign 
#运行 the size of struct test is 16

实际上,结构体test的成员在内存中可能是像下面这样分布的(数值为偏移量)。

未对齐时:

0~345~910~11
abcd

对齐时:

0~345~78~1112~1314~15
ab填充内容cd填充内容

示例3:

/* OFFSET宏定义可取得指定结构体某成员在结构体内部的偏移 */
#define OFFSET(st, field)     (size_t)&(((st*)0)->field)
typedef struct{
    char  a;
    short b;
    char  c;
    int   d;
    char  e[3];
}T_Test;

int main(void){
    printf("Size = %d\n  a-%d, b-%d, c-%d, d-%d\n  e[0]-%d, e[1]-%d, e[2]-%d\n",
           sizeof(T_Test), OFFSET(T_Test, a), OFFSET(T_Test, b),
           OFFSET(T_Test, c), OFFSET(T_Test, d), OFFSET(T_Test, e[0]),
           OFFSET(T_Test, e[1]),OFFSET(T_Test, e[2]));
    return 0;
}

输出:

Size = 16
    a-0, b-2, c-4, d-8
    e[0]-12, e[1]-13, e[2]-14

short b本身占用2个字节,根据上面准则2,需要在b和a之间填充1个字节。

char c占用1个字节,没问题。

int d本身占用4个字节,根据准则2,需要在d和c之间填充3个字节。

char e[3];本身占用3个字节,根据原则3,需要在其后补充1个字节。

因此,sizeof(T_Test) = 1 + 1 + 2 + 1 + 3 + 4 + 3 + 1 = 16字节。

为什么要进行字节对齐呢?

其实无论数据是否对齐,大多计算机仍可以正常工作,上面示例2中个结构体test原本只需要11个字节的空间,而对齐后却占用了16字节,很明显浪费了空间,可为什么还要进行字节退旗呢?

其实主要原因是:为了提高内存系统性能 (空间换性能)

计算机每次读写一个字节块,例如,假设计算机总是从内存中取8个字节,如果一个double数据的地址对齐成8的倍数,那么一个内存操作就可以读或者写,但是如果这个double数据的地址没有对齐,数据就可能被放在两个8字节块中,那么我们可能需要执行两次内存访问,才能读写完成。显然在这样的情况下,是低效的。所以需要字节对齐来提高内存系统性能。

并且不同硬件平台对存储空间的处理上存在很大的不同。某些平台对特定类型的数据只能从特定地址开始存取,而不允许其在内存中任意存放。例如Motorola 68000 处理器不允许16位的字存放在奇地址,否则会触发异常,因此在这种架构下编程必须保证字节对齐。

因此,通过合理的内存对齐可以提高访问效率。为使CPU能够对数据进行快速访问,数据的起始地址应具有“对齐”特性。比如4字节数据的起始地址应位于4字节边界上,即起始地址能够被4整除。

此外,合理利用字节对齐还可以有效地节省存储空间。但要注意,在32位机中使用1字节或2字节对齐,反而会降低变量访问速度。因此需要考虑处理器类型。还应考虑编译器的类型。在VC/C++和GNU GCC中都是默认是4字节对齐。

字节对齐会不会导致一些额外问题呢?

字节对齐固然可以提升内存系统性能,但同时也埋下了不少隐患:

  • 数据类型转换
  • 处理器间数据通信

字节对齐注意点及思考?

需要注意的

出现对齐或者赋值问题我们如何排查呢?

可以从一下几方面:

  1. 编译器的字节序大小端设置;
  2. 处理器架构本身是否支持非对齐访问;
  3. 如果支持看设置对齐与否,如果没有则看访问时需要加某些特殊的修饰来标志其特殊访问操作。

更改对齐方式

对于C语言,我们可以修编译器的缺省字节对齐方式。 在缺省情况下,C编译器为每一个变量或是数据单元按其自然对界条件分配空间。一般地,可以通过下面的方法来改变缺省的对界条件:

  • 使用伪指令#pragma pack(n):C编译器将按照n个字节对齐;
  • 使用伪指令#pragma pack(): 取消自定义字节对齐方式。 另外,还有如下的一种方式(GCC特有语法):
  • __attribute((aligned (n))): 让所作用的结构成员对齐在n字节自然边界上。如果结构体中有成员的长度大于n,则按照最大成员的长度来对齐。
  • __attribute__ ((packed)): 取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。
#pragma pack(2)  //指定按2字节对齐
struct C{
    char  b;
    int   a;
    short c;
};
#pragma pack()   //取消指定对齐,恢复缺省对齐

变量b自身对齐值为1,指定对齐值为2,所以有效对齐值为1,假设C从0x0000开始,则b存放在0x0000,符合0x0000%1= 0;变量a自身对齐值为4,指定对齐值为2,所以有效对齐值为2,顺序存放在0x0002~0x0005四个连续字节中,符合0x0002%2=0。变量c的自身对齐值为2,所以有效对齐值为2,顺序存放在0x0006~0x0007中,符合 0x0006%2=0。所以从0x00000x00007共八字节存放的是C的变量。C的自身对齐值为4,所以其有效对齐值为2。又8%2=0,C只占用0x0000~0x0007的八个字节。所以sizeof(struct C) = 8

注意,结构体对齐到的字节数并非完全取决于当前指定的pack值,如下:

#pragma pack(8)
struct D{
    char  b;
    short a;
    char  c;
};
#pragma pack()

虽然#pragma pack(8),但依然按照两字节对齐,所以sizeof(struct D)的值为6。因为:对齐到的字节数 = min{当前指定的pack值,最大成员大小}。

另外,GNU GCC编译器中按1字节对齐可写为以下形式:

#define GNUC_PACKED __attribute__((packed))
struct C{
    char  b;
    int   a;
    short c;
}GNUC_PACKED;

此时sizeof(struct C)的值为7。

开发中的思考:

虽然字节对齐是由编译器来完成,但是我们在日常编程中仍需关注字节对齐的优化问题;

空间存储

如下结构体test,其占用空间大小为16字节,但是如果我们换一种声明方式,调整变量的顺序,重新运行程序,最后发现结构体test占用大小为12字节

struct test {
    int a; 
    char b;
    short d; 
    int c; 
};

空间存储情况如下,b和d存储在了一个字节块中:

0~3456~78~11
ab填充内容dc

也就是说,如果我们在设计结构的时候,合理调整成员的位置,可以大大节省存储空间。但是需要在空间和可读性之间进行权衡。

跨平台通信

由于不同平台对齐方式可能不同,如此一来,同样的结构在不同的平台其大小可能不同,在无意识的情况下,互相发送的数据可能出现错乱,甚至引发严重的问题。因此,为了不同处理器之间能够正确的处理消息,我们有两种可选的处理方法。

  • 1字节对齐
  • 自己对结构进行字节填充

我们可以使用伪指令#pragma pack(n)(n为字节对齐数)来使得结构间一字节对齐。 同样是前面的程序,如果在结构体test的前面加上伪指令,即如下:

#pragma pack(1) /*1字节对齐*/ 
struct test { 
    int a; 
    char b; 
    int c; 
    short d; 
}; 
#pragma pack()/*还原默认对齐*/

除了前面的1字节对齐,还可以进行人为的填充,即test结构体声明如下:

struct test { 
    int a; 
    char b;
    char reserve[3]; 
    int c; 
    short d;
    char reserve1[2]; 
};

访问效率高,但并不节省空间,同时扩展性不是很好,例如,当字节对齐有变化时,需要填充的字节数可能就会发生变化。

看下iOS中字节对齐

@interface XDPerson : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) int age;
@property (nonatomic, assign) long height;
@property (nonatomic, copy) NSString *sex;
@property (nonatomic) char ch1;
@property (nonatomic) char ch2;

@end

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
   
    XDPerson *p1 = [XDPerson alloc];
    p1.name = @"xiedong";
    p1.age = 18;
    p1.height = 180;
    p1.sex = @"男";
    p1.ch1 = 'a';
    p1.ch2 = 'b';
    
   NSLog(@"%lu - %lu",class_getInstanceSize([p1 class]),malloc_size((__bridge const void *)(p1)));
}
输出结果 40 - 48。
  • 对象申请的内存空间 <= 系统开辟的内存空间。
  • 对象申请的内存空间是以8字节对齐方式。在objc源码里面是可以得到验证的。
  • 系统开辟内存空间是以16字节对齐方式。在malloc源码里面segregated_size_to_fit()可以看到是以16字节对齐的。

使用lldb调试查看内存地址信息,

x/6xg p1意思代表 读取p1对象6段内存地址。

(lldb) x/6xg p1
0x600000ce0000: 0x00000001029570d0 0x0000001200006261
0x600000ce0010: 0x0000000102956098 0x00000000000000b4
0x600000ce0020: 0x00000001029560b8 0x0000000000000000
(lldb) po 0x00000001029570d0 & 0x0000000ffffffff8
XDPerson
(lldb) po 0x00000012
18
(lldb) po 0x62
98
(lldb) po 0x61
97
(lldb) po 0x0000000102956098
xiedong
(lldb) po 0x00000000000000b4
180
(lldb) po 0x00000001029560b8
男

发现OC里面程序员写的属性的顺序并不是内存里面的顺序,与结构体struct还是有一定的区别。其实这里就是编译器给进行二进制重排产生的效果。

第一个内存地址是isa,是objc_object这个基类带的数据成员

总结

字节对齐的细节虽然编译器在做,但是我们仍有关注,不然可能会在编程中遇到难以理解或解决的问题。 因此针对字节对齐,总结了以下处理建议:

  • 结构体成员合理安排位置,以节省空间
  • 跨平台数据结构可考虑1字节对齐,节省空间但影响访问效率
  • 跨平台数据结构人为进行字节填充,提高访问效率但不节省空间
  • 本地数据采用默认对齐,以提高访问效率
  • 32位与64位默认对齐数不一样 ,分别是4字节和8字节对齐

参考: