「图解」指针与内存对齐到底是什么鬼?

什么是指针?

先看看什么是内存地址

首先,我们要搞清楚数据结构在计算机里面到底怎么存取?怎么描述它们。

任何数据结构(struct)以及组成数据结构的基本数据类型,一旦分配了内存空间,那么就会有两个部分来描述这块内存:内存的地址(红色部分,不占用实际空间,相当于门牌号,用于寻址)与内存的值(绿色部分,是实际的信息存储部分,占用内存空间,以byte为单位)。就像下面这张图:


数据在内存中的结构


所以一块内存,或者一个符号(编程语言的符号其实就代表了一块内存,所以它们代表同一个意思)有两个重要的属性:

  1. 内存或者符号的地址
  2. 内存或者符号的值

这两个属性如同一个事物的两面,不可分割,形影不离。

有时候,如果对事情的本质进行深挖的话,你可能对一些基本概念有更深刻的理解。比如,到这里,如果你理解了内存或者编程语言的符号有两个基本的属性:地址与值,那么你就可以理解C/C++中的&=操作符的含义。

可以推断出,从CPU的角度,或者编程语言底层来看,没有数据类型的概念,任何数据都是一块块连续的、长短不一的内存存储单元而已,就像上图所画。那么问题就变成了,怎么描述这块内存呢?

答案是:内存的起始地址+长度。比如下面这个结构:

struct test {
int a;
short b;
}

对于test这个结构,怎么描述它?

答案是:struct test是——符号a的内存地址+6个字节长度数据块,如果要读取或者写入test某个部分(a或者b),编译器至少要编译两条指令:1、获取test也就是a符号的地址,2、根据类型定位偏移量就行了。这就是数据结构的本质了。

那么对数据结构成员变量的访问就很容易理解了:

是不是有点类似数学中的极坐标系的概念。而实际上系统确实是这么做的。

站在编译器的角度看看符号与变量

指针在C与C++中很难理解,但是又是重要的构成部分,没有了指针其实就发挥不出语言的光芒了。因为指针是很自然的事物,它是承接CPU取址与程序可读性的关键概念,理解了它就既能看穿机器的运行,又能写出合理的优雅的代码去描述业务。

要真正理解指针或者更普遍的意义来说,理解符号,就得将自己想象成编译器去读代码,这样一切都会变得理所当然的容易起来。

我们看到的程序都是由变量符号组成的,本质上符号代表一块内存,比如上面的结构体就有三个变量符号或者简称符号testab。每个符号其实都对应一块型如下图的内存块:


内存块的两个维度

再来看看这个代码片段

typedef struct test {
	int a;
  short b;
} Test;

Test t;
t.a =1;
t.b =2;
Test* t_ptr = &t;
t_ptr->a = 3;

可以用Linux内核中的例子验证下这个思路——container_of宏

container_of宏是linux内核中常用的功能,用于实现一个逆面向对象取值的功能。面向对象的取值是:先获取对象,才能访问对象中的数据结构,也就是说,对象的成员变量只能通过对象来访问。而,逆面向对象指的是,通过成员变量来获取对象的实例。比如:

void wake_up_q(struct wake_q_head *head)
{
	struct wake_q_node *node = head->first;

	while (node != WAKE_Q_TAIL) {
		struct task_struct *task;

		task = container_of(node, struct task_struct, wake_q); //通过成员变量wake_q的地址获取task_struct对象的地址。
		BUG_ON(!task);
		/* Task can safely be re-inserted now: */
		node = node->next;
		task->wake_q.next = NULL;

		/*
		 * wake_up_process() executes a full barrier, which pairs with
		 * the queueing in wake_q_add() so as not to miss wakeups.
		 */
		wake_up_process(task);
		put_task_struct(task);
	}
}

task = container_of(node, struct task_struct, wake_q); 就是container_of的典型用法。这个宏有三个参数:

那么container_of的功能是怎么实现的呢?起始就跟内存 = 其实地址 + 偏移长度这个公式密切相关。因为:

  1. 成员变量的定义在编译器底层就是通过偏移来描述的,访问成员变量就是:首先获得的首字节地址,然后加上成员变量相对首字节地址的偏移,来获得成员变量的首字节地址;最后根据成员变量的类型来确定成员变量的长度就可以了;
  2. 所以,要向通过成员变量的地址获取的首字节地址,其实就是1.过程的逆运算。首先,获取成员变量相对首字节地址的相对偏移,这在GCC编译的时候就确定了;然后,根据成员变量符号的地址 + 这个偏移 = 的首字节地址了。

我写了一段小程序来模拟这个过程:

#include 
#include 

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

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

    My my;
    my.a = 1;
    my.b = 2L;
    my.c = 'a';
    my.d = 4;
    printf("size of my:%d
", sizeof(My));
    printf("address of my is:%x
", &my);

    //cc这个符号的值是一个地址,是my对象中c成员的地址。
    char *cc = &my.c;

    long delta = (long)(&(((My *)0))->c); // 将数字0强制转为My*类型,相当于定义了一个指向My结构的指针,指针变量的值是0。然后,取这个位置向下的第'c'个成员的地址,将地址转成long就是c这个成员的偏移。其实就是骗了gcc,gcc就是用的偏移来为数据类型赋值的,这是原理。

    printf("delta:%d
", delta);
    printf("c's address is:%x
", cc);

    void *mycAddress = cc; //将cc这个符号的值,也就是my对象c成员的地址,赋值给mycAddress符号的值。
    printf("之前value of d =%d
", my.d);
    my.d = 10;
    int value_of_d = ((My *)(mycAddress - delta))->d; // 根据delta值,与mycAddress符号的值,计算获得my结构的地址。然后,将符号切换成my对象中的d成员。再将d符号的值,赋值给value_of_d符号的值。
    printf("之后value of d =%d
", value_of_d);
    return 0;
}

关键的步骤是获取成员变量相对的首字节偏移量:long delta = (long)(&(((My *)0))->c);。其实就是”欺骗“GCC,以一个0地址的My对象作为基准,然后c成员变量的地址就是相对0地址的相对偏移地址了。有了这个delta获取其他成员变量的地址就容易了,比如:int value_of_d = ((My *)(mycAddress - delta))->d;

所以这进一步验证了,底层系统软件是没有所谓的类型信息的,他们读取与解释内存的方式就是这个公式:内存 = 内存的起始地址+长度

到这里我们可以推敲一下指针的本质了

接着上面的例子,我们已经分析了t_ptr的内存布局,它的值是一个地址。问题就来了,你想过没有,如果一个符号,它的值保存了一个地址,我对他能做什么操作?我们知道,如果t_ptr的值是intlong,我就能用CPU的算术模块对它们进行“加减乘除”,这样是有意义的,因为我在做代数运算。那么对一个地址,显然,做加减乘除运算是没有意义的。我们唯一能对地址做的有意义的操作就是找到这块地址,对这个地址对应的内存进行操作,这才是地址类型数据的意义。

因为对地址进行普通意义上的四则运算是没有代数意义的,所以,C语言为地址数据类型(指针)增加了两个操作符*->

看看Linux中一些指针操作——二重指针

看个Linux内核中的例子,这是mcs spinlock的加锁操作

static inline
void mcs_spin_lock(struct mcs_spinlock **lock, struct mcs_spinlock *node)
{
	struct mcs_spinlock *prev;

	/* Init node */
	node->locked = 0;
	node->next   = NULL;
	
	prev = xchg(lock, node); //相当于把mcslock.next = node;同时返回*lock修改之前的值。
	
	if (likely(prev == NULL)) { //原来*lock指向NULL。也就是现在链表还没形成,没有竞争。
		return;
	} // 如果有值说明有竞争,要排队。所以直接插入最后就行了。prev就是最后一个元素。
	WRITE_ONCE(prev->next, node);

	/*这里是个spin loop。在percpu自身的lock上面自旋,等待变成1,获取锁。
	*/
	/* Wait until the lock holder passes the lock down. */
	arch_mcs_spin_lock_contended(&node->locked);
}


指针的指针到底是什么?



*就表示切换符号



图解三重指针

看看Linux中一些指针操作——链表操作

//链表头指针
struct wake_q_head {
	struct wake_q_node *first;
	struct wake_q_node **lastp;
};

struct wake_q_node {
	struct wake_q_node *next;
};

//初始化链表头
static inline void wake_q_init(struct wake_q_head *head)
{
	head->first = WAKE_Q_TAIL; // #define WAKE_Q_TAIL ((struct wake_q_node *) 0x01)
	head->lastp = &head->first;
}

//添加新元素
static bool __wake_q_add(struct wake_q_head *head, struct task_struct *task)
{
	struct wake_q_node *node = &task->wake_q;

	/*
	 * Atomically grab the task, if ->wake_q is !nil already it means
	 * it's already queued (either by us or someone else) and will get the
	 * wakeup due to that.
	 *
	 * In order to ensure that a pending wakeup will observe our pending
	 * state, even in the failed case, an explicit smp_mb() must be used.
	 */
	smp_mb__before_atomic();
	if (unlikely(cmpxchg_relaxed(&node->next, NULL, WAKE_Q_TAIL)))
		return false;

	/*
	 * The head is context local, there can be no concurrency.
	 */
	*head->lastp = node;
	head->lastp = &node->next;
	return true;
}

先看看wake_q_init函数

wake_q_init

再来看看wake_q_add函数

如下图描述:所以这个add函数就做了两件事:

  1. first符号的值设置成wake_q符号的地址;
  2. lastp符号的值更新为wake_q->next符号的地址。

添加第一个元素


再添加一个元素:


添加第二个元素

关于指针你只要记住

理解了指针就能更进一步理解内存对齐了

内存对齐应该叫做内存的虚拟地址对齐,讲的是如果我们为一个数据结构——抽象来讲就是一块内存——分配一个地址的时候,需要满足的规则。那么规则有哪些?我们可以先列出来:

下面具体说明下这些规则都是什么意思。

基本数据类型的首地址必须是类型字节数的整数倍

还是这个代码片段:

#include
#include
typedef struct test {
  short b;
  int a;
} Test;
int main(){
  printf("Test size is:%ld
",sizeof(Test));
  Test* t_ptr = (Test*)malloc(sizeof(Test));
  t_ptr->a = 1;
  t_ptr->b = 2;
  printf("a is:%d
",t_ptr->a);
  printf("b is:%d
",t_ptr->b);
}

为了迎合这个问题,我们调换了a,b符号在结构体中出现的顺序。之前我们假设sizeof(Test)是6,但是真的如此吗?我们看看运行的结果:

Test size is:8
a is:1
b is:2

其实是8个字节。为啥呢?就是因为int a要符合基本数据类型的首地址必须是类型字节数的整数倍这条规则,所以编译器会在ba之间插入2个字节的0,使得a的首字节地址是int的整数倍;变成:

typedef struct test {
  short b;
  short invisible_padding; //实际看不见
  int a;
} Test;

反汇编验证下:

 t_ptr->a = 1;
    119c:       48 8b 45 f8             mov    -0x8(%rbp),%rax /*拿到符号t_ptr的地址*/
    11a0:       c7 40 04 01 00 00 00    movl   $0x1,0x4(%rax)  /*执行 = 操作符,给符号a的内存赋值*/
  t_ptr->b = 2;
    11a7:       48 8b 45 f8             mov    -0x8(%rbp),%rax /*拿到符号t_ptr的地址*/
    11ab:       66 c7 00 02 00          movw   $0x2,(%rax)     /*执行 = 操作符,给符号b的内存赋值*/

可以从反汇编看到a的内存地址从偏移地址0x4开始,而b从偏移地址0x2开始,而padding是放在t_ptr的开始位置的,这跟我的猜想有点出入,但是并不破坏规则,因为int a的首字节地址依然变成了4的整数倍。如下图:


反汇编1


那么问题就来了,为什么要填充呢?本质的原因是什么?

从CPU角度看看为什么要对齐

一图胜千言:


CPU角度看内存加载问题


解释:

一图胜千言,上图:


对齐的与没有对齐的内存读取差别


所以,内存必须对齐,不然同样的数据结构,没对齐比对齐后的内存要多一次内存的开销。

不要小看这一次内存访问的开销,因为:

关于结构体的三条规则

  1. 结构体(首字节地址)必须是最大成员变量数据类型的整数倍(编译器维护);
  2. 结构体中每个成员变量的首字节地址,必须是成员类型的整数倍,如果不是,则编译器填充实现;
  3. 结构体的总体长度必须是最大成员变量类型长度的整数倍,如果不是,编译器在结构体最后一个字节末尾填充0实现。

其中2.就是基本数据类型的首地址必须是类型字节数的整数倍的推论,或者说是等价的,不需要证明。

关于1.3.的证明,需要引入一个推论:如果符号的首地址是n字节对齐的,那么一定是n/2对齐的,也一定是n/4对齐的,依次类推下去。

举个例子来说就是:符号a的首地址如果是8字节对齐,那么一定也是4字节对齐,一定也是2字节对齐的。其实很容易证明:如果a的地址是x,x%8 = 0;那么x = b×8;x%4 = b×4×2 %4=0;所以也是4字节对齐的。

结构体(首字节地址)必须是最大成员变量数据类型的整数倍的证明

可以由2.推导而来。步骤是:

1、假设结构体中的最大成员变量的长度是long8个字节;那么,根据2.可知,这个变量前面的变量的长度总和,必须是8的整数倍;

2、所以,如果结构体的首字节地址不是8的整数倍,那么就算最大成员变量之前的所有成员变量长度和满足了是8的整数倍,也不能保证2.的成立;

3、所以结构体的首字节地址必定是最大成员长度的整数倍,也就是8字节对齐的。


内存布局


结构体的总体长度必须是最大成员变量类型长度的整数倍证明

这个主要是考虑数组的情况。在单个结构体中对齐的数据,必须在数组情况下也是对齐的,如果最大的成员变量是对齐的,则所有其他成员变量都是对齐的。证明如下图:


证明


以一个例子总结

#include 
#include 

typedef struct test
{
    int a; // 4
    // padding 4
    long long b; // 8 b要8字节对齐,也就是前面的字节数要是8的倍数,而前面只有4字节,所以要padding4个字节
    char c;      // 1
    // padding 1
    short d; // 2 同样d前面的字节数要是2的倍数,所以padding 1个字节
    // padding 4 前面整体的test结构只有20个字节,而整体的大小也要是最大元素的整数倍,因为如果是数组,那么两个my的元素那么第二个my的b变量位于28的位置,不是8的整数倍,所以结尾再padding4个字节。凑成24个字节。
} My;

int main(int argc, char *argv[])
{
    //栈上分配
    My my;
    my.a = 1;
    11ab:       c7 45 e0 01 00 00 00    movl   $0x1,-0x20(%rbp)
    my.b = 2L;
    11b2:       48 c7 45 e8 02 00 00    movq   $0x2,-0x18(%rbp)
    11b9:       00
    my.c = 'a';
    11ba:       c6 45 f0 61             movb   $0x61,-0x10(%rbp)
    my.d = 4;
    11be:       66 c7 45 f2 04 00       movw   $0x4,-0xe(%rbp)
    */
    My my;
    my.a = 1;
    my.b = 2L;
    my.c = 'a';
    my.d = 4;
    printf("size of my:%d
", sizeof(My));
    printf("address of my is:%x
", &my);
    
    //堆上分配
    /*
    11f8:       bf 18 00 00 00          mov    $0x18,%edi
    11fd:       e8 8e fe ff ff          call   1090 
    1202:       48 89 45 c0             mov    %rax,-0x40(%rbp)
    my_on_heap->a = 1;
    1206:       48 8b 45 c0             mov    -0x40(%rbp),%rax
    120a:       c7 00 01 00 00 00       movl   $0x1,(%rax)
    my_on_heap->b = 2L;
    1210:       48 8b 45 c0             mov    -0x40(%rbp),%rax
    1214:       48 c7 40 08 02 00 00    movq   $0x2,0x8(%rax)
    121b:       00
    my_on_heap->c = 'a';
    121c:       48 8b 45 c0             mov    -0x40(%rbp),%rax
    1220:       c6 40 10 61             movb   $0x61,0x10(%rax)
    my_on_heap->d = 4;
    1224:       48 8b 45 c0             mov    -0x40(%rbp),%rax
    1228:       66 c7 40 12 04 00       movw   $0x4,0x12(%rax)
    */
    My* my_on_heap = (My*)malloc(sizeof(My));
    my_on_heap->a = 1;
    my_on_heap->b = 2L;
    my_on_heap->c = 'a';
    my_on_heap->d = 4;
    printf("address of my is:%x
", my_on_heap);
}

参考

视频——内存对齐到底是个什么鬼

展开阅读全文

页面更新:2024-05-18

标签:指针   内存   首字   赋值   变量   字节   符号   成员   结构   地址

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top