内存对齐介绍



  • 什么是内存对齐?

    在现代处理器,比如在x86或ARM处理器中,基本 C 数据类型通常并不存储于内存中的随机字节地址。实际情况是,除 char 外,所有其他类型都有“对齐要求”:char 可起始于任意字节地址,2字节的 short 必须从偶数字节地址开始,4字节的int或float必须从能被4整除的地址开始,8字节的 long 和 double 必须从能被8整除的地址开始,指针也是对齐的:32位系统上指针为4字节,64位系统上为8字节。

    对齐可令内存访问速度更快,因为它有利于生成单指令存取这些类型的数据。另一方面,如若没有对齐约束,可能最终不得不通过两个或更多指令访问跨越机器字边界的数据。字符数据是种特殊情况,因其始终处在单一机器字中,所以无论存取何处的字符数据,开销都是一致的。这也就是它不需要对齐的原因。

    我们虽然可以通过pragma指令(通常为#pragma pack)强迫编译器不采用处理器惯用的对齐规则,但请别随意运用这种方式,因为它强制生成开销更大、速度更慢的代码。

    填充

    我们来看一个关于变量在内存中分布的简单案例。思考形式如下的一系列变量声明,它们处在一个C代码块的开始。

    char *p;
    char c;
    int x;
    

    存储p需要自对齐的4或8字节空间,这取决于机器字的大小。这是指针对齐——极其严格。

    c紧随其后,但接下来x的4字节对齐要求,将强制在分布中生成了一段空白,仿佛在这段代码中插入了第四个变量,如下所示。

    char *p;      /* 4 or 8 bytes */
    char c;       /* 1 byte */
    char pad[3];  /* 3 bytes */
    int x;        /* 4 bytes */
    

    字符数组pad[3]意味着在这个结构体中,有3个字节的空间被浪费掉了。

    如果x为2字节short,我们将得到:

    char *p;      /* 4 or 8 bytes */
    char c;       /* 1 byte */
    char pad[1];  /* 1 byte */
    short x;      /* 2 bytes */
    

    另一方面,如果x为64位系统中的long,我们将得到:

    char *p;     /* 8 bytes */
    char c;      /* 1 byte */
    char pad[7]; /* 7 bytes */
    long x;      /* 8 bytes */
    

    若你一路仔细读下来,现在可能会思索,何不首先声明较短的变量?

    char c;
    char *p;
    int x;
    

    假如实际内存分布可以写成下面这样:

    char c;
    char pad1[M];
    char *p;
    char pad2[N];
    int x;
    

    MN分别为多少?

    首先,在此例中,N将为0,x的地址紧随p之后,能确保是与指针对齐的,因为指针的对齐要求总比int严格。

    M的值就不易预测了。编译器若是恰好将c映射为机器字的最后一个字节,那么下一个字节(p的第一个字节)将恰好由此开始,并恰好与指针对齐。这种情况下,M将为0。

    不过更有可能的情况是,c将被映射为机器字的首字节。于是乎M将会用于填充,以使p指针对齐——32位系统中为3字节,64位系统中为7字节。

    中间情况也有可能发生。M的值有可能在0到7之间(32位系统为0到3),因为char可以从机器字的任何位置起始。

    如果你希望这些变量占用的空间更少,那么可以交换xc的次序。

    char *p;     /* 8 bytes */
    long x;      /* 8 bytes */
    char c;      /* 1 byte */
    

    通常,对于C代码中的少数标量变量(scalar variable),采用调换声明次序的方式能节省几个有限的字节,效果不算明显。而将这种技术应用于非标量变量(nonscalar variable)——尤其是结构体,则要有趣多了。

    Note:在具有自对齐类型的平台上,char、short、int、long和指针数组都没有内部填充,每个成员都与下一个成员自动对齐。

    结构体的对齐和填充

    通常情况下,结构体实例以其最宽的 标量 成员为基准进行对齐。编译器之所以如此,是因为此乃确保所有成员自对齐,实现快速访问最简便的方法。

    此外,在C语言中,结构体的地址,与其第一个成员的地址一致——不存在头填充(leading padding)。

    Note:在C++中,与结构体相似的类,可能会打破上述规则!

    考虑这个结构体:

    struct foo1 {
        char c;
        char *p;
        long x;
    };
    

    假定处在64位系统中,任何struct fool的实例都采用8字节对齐。其内存分布将会像下面这样:

    struct foo1 {
        char c;      /* 1 byte */
        char pad[7]; /* 7 bytes */
        char *p;     /* 8 bytes */
        long x;      /* 8 bytes */
    };
    

    现在,我们来谈谈结构体的尾填充(trailing padding)。为了解释它,需要引入一个基本概念,我将其称为结构体的“跨步地址(stride address)”。它是在结构体数据之后,与结构体对齐一致的首个地址。

    结构体尾填充的通用法则是:编译器将会对结构体进行尾填充,直至它的跨步地址。这条法则决定了sizeof()的返回值。

    考虑64位x86或ARM系统中的这个例子:

    struct foo2 {
        char *p;     /* 8 bytes */
        char c;      /* 1 byte */
    };
    
    struct foo2 singleton;
    struct foo2 quad[4];
    

    你以为sizeof(struct foo2)的值是9,但实际是16。它的跨步地址是(&p)[2]。于是,在quad数组中,每个成员都有7字节的尾填充,因为下个结构体的首个成员需要在8字节边界上对齐。内存分布就好像这个结构是这样声明的:

    struct foo2 {
        char *p;     /* 8 bytes */
        char c;      /* 1 byte */
        char pad[7];
    };
    

    作为对比,思考下面的例子:

    struct foo3 {
        short s;     /* 2 bytes */
        char c;      /* 1 byte */
    };
    

    因为s只需要2字节对齐,跨步地址仅在c的1字节之后,整个struct foo3也只需要1字节的尾填充。形式如下:

    struct foo3 {
        short s;     /* 2 bytes */
        char c;      /* 1 byte */
        char pad[1];
    };
    

    sizeof(struct foo3)的返回值将为4。

    现在我们考虑位域(bitfields)。利用位域,你能声明比字符宽度更小的成员,低至1位,例如:

    struct foo4 {
        short s;
        char c;
        int flip:1;
        int nybble:4;
        int septet:7;
    };
    

    关于位域需要了解的是,它们是由字(或字节)层面的掩码和移位指令实现的。从编译器的角度来看,struct foo4 中的位域就像2字节、16位的字符数组,只用到了其中12位。为了使结构体的长度是其最宽成员长度sizeof(short)的整数倍,接下来进行了填充。

    struct foo4 {
        short s;       /* 2 bytes */
        char c;        /* 1 byte */
        char pad1;     /* 1 byte */
        int flip:1;    /* total 1 bit */
        int nybble:4;  /* total 5 bits */
        int septet:7;  /* total 12 bits */
        int pad2:4;    /* total 16 bits = 2 bytes */
        char pad3[2];  /* 2 byte */
    };
    

    这是最后一个重要细节:如果你的结构体中含有结构体成员,内层结构体也要和最长的标量有相同的对齐。假如你写下了这段代码:

    struct foo5 {
        char c;
        struct foo5_inner {
            char *p;
            short x;
        } inner;
    };
    

    内层结构体成员char *p强迫外层结构体与内层结构体指针对齐一致。在64位系统中,实际的内存分布将类似这样:

    struct foo5 {
        char c;           /* 1 byte */
        char pad1[7];     /* 7 bytes */
        struct foo5_inner {
            char *p;      /* 8 bytes */
            short x;      /* 2 bytes */
            char pad2[6]; /* 6 bytes */
        } inner;
    };
    

    它启示我们,能通过重新打包节省空间。24个字节中,有13个为填充,浪费了超过50%的空间!

    结构体成员重排

    首先注意,内存浪费只存在于两处。其一是较大的数据类型(需要更严格的对齐)跟在较小的数据类型之后。其二是结构体自然结束的位置在跨步地址之前,这里需要填充,以使下个结构体能正确地对齐。

    最简单的方式,是按对齐值递减重新对结构体成员排序。即让所有指针对齐成员排在最前面,因为在64位系统中它们占用8字节;然后是4字节的int;再然后是2字节的short,最后是字符。

    内存对齐的特性

    由于对齐的存在,在不同平台下指针的最后2位一般都会是0,Nginx 中利用了这一特性,将指针的最后一位当做 flag 用,节约了一定内存。



  • 上面没有说清楚,最后提到的指针指向的是调用 malloc 得到的内存。



  • 微机原理会讲这个,但没有这么详细



  • 我们还没学微机原理啊。。


 

Copyright © 2018 bbs.dian.org.cn All rights reserved.

与 Dian 的连接断开,我们正在尝试重连,请耐心等待