什么是指针?先看看什么是内存地址
首先,我们要搞清楚数据结构在计算机里面到底怎么存取?怎么描述它们。
任何数据结构(struct)以及组成数据结构的基本数据类型,一旦分配了内存空间,那么就会有两个部分来描述这块内存:内存的地址(红色部分,不占用实际空间,相当于门牌号,用于寻址)与内存的值(绿色部分,是实际的信息存储部分,占用内存空间,以byte为单位)。就像下面这张图:
数据在内存中的结构
所以一块内存,或者一个符号(编程语言的符号其实就代表了一块内存,所以它们代表同一个意思)有两个重要的属性:
这两个属性如同一个事物的两面,不可分割,形影不离。
有时候,如果对事情的本质进行深挖的话,你可能对一些基本概念有更深刻的理解。比如,到这里,如果你理解了内存或者编程语言的符号有两个基本的属性:地址与值,那么你就可以理解C/C++中的&与=操作符的含义。
可以推断出,从CPU的角度,或者编程语言底层来看,没有数据类型的概念,任何数据都是一块块连续的、长短不一的内存存储单元而已,就像上图所画。那么问题就变成了,怎么描述这块内存呢?
答案是:内存的起始地址+长度。比如下面这个结构:
struct Test {int a;short b;
对于test这个结构,怎么描述它?
答案是:struct test是——符号a的内存地址+6个字节长度的数据块,如果要读取或者写入test某个部分(a或者b),编译器至少要编译两条指令:1、获取test也就是a符号的地址,2、根据类型定位偏移量就行了。这就是数据结构的本质了。
那么对数据结构成员变量的访问就很容易理解了:
是不是有点类似数学中的极坐标系的概念。而实际上系统确实是这么做的。
站在编译器的角度看看符号与变量
指针在C与C++中很难理解,但是又是重要的构成部分,没有了指针其实就发挥不出语言的光芒了。因为指针是很自然的事物,它是承接CPU取址与程序可读性的关键概念,理解了它就既能看穿机器的运行,又能写出合理的优雅的代码去描述业务。
要真正理解指针或者更普遍的意义来说,理解符号,就得将自己想象成编译器去读代码,这样一切都会变得理所当然的容易起来。
我们看到的程序都是由变量符号组成的,本质上符号代表一块内存,比如上面的结构体就有三个变量符号或者简称符号:test,a,b。每个符号其实都对应一块型如下图的内存块:
内存块的两个维度
再来看看这个代码片段
typedef struct test {int a;short b;} Test;Test t;t.a =1;t.b =2;Test* t_ptr = &t;t_ptr->a = 3;
接着上面的例子,我们已经分析了t_ptr的内存布局,它的值是一个地址。问题就来了,你想过没有,如果一个符号,它的值保存了一个地址,我对他能做什么操作?我们知道,如果t_ptr的值是int、long,我就能用CPU的算术模块对它们进行“加减乘除”,这样是有意义的,因为我在做代数运算。那么对一个地址,显然,做加减乘除运算是没有意义的。我们唯一能对地址做的有意义的操作就是找到这块地址,对这个地址对应的内存进行操作,这才是地址类型数据的意义。
因为对地址进行普通意义上的四则运算是没有代数意义的,所以,C语言为地址数据类型(指针)增加了两个操作符*与->。
看个Linux内核中的例子,这是mcs spinlock的加锁操作
static inlinevoid 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
添加第一个元素
再添加一个元素:
再添加一个元素
内存对齐应该叫做内存的虚拟地址对齐,讲的是如果我们为一个数据结构——抽象来讲就是一块内存——分配一个地址的时候,需要满足的规则。那么规则有哪些?我们可以先列出来:
下面具体说明下这些规则都是什么意思。
基本数据类型的首地址必须是类型字节数的整数倍
还是这个代码片段:
#include#includetypedef struct test {short b;int a;} Test;int main(){printf("Test size is:%ldn",sizeof(Test));Test* t_ptr = (Test*)malloc(sizeof(Test));t_ptr->a = 1;t_ptr->b = 2;printf("a is:%dn",t_ptr->a);printf("b is:%dn",t_ptr->b);
为了迎合这个问题,我们调换了a,b符号在结构体中出现的顺序。之前我们假设sizeof(Test)是6,但是真的如此吗?我们看看运行的结果:
Test size is:8a is:1b is:2
其实是8个字节。为啥呢?就是因为int a要符合基本数据类型的首地址必须是类型字节数的整数倍这条规则,所以编译器会在b与a之间插入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角度看内存加载问题
解释:
一图胜千言,上图:
对齐的与没有对齐的内存读取差别
所以,内存必须对齐,不然同样的数据结构,没对齐比对齐后的内存要多一次内存的开销。
不要小看这一次内存访问的开销,因为:
其中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#includetypedef struct testint a; // 4// padding 4long long b; // 8 b要8字节对齐,也就是前面的字节数要是8的倍数,而前面只有4字节,所以要padding4个字节char c; // 1// padding 1short 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: 00my.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:%dn", sizeof(My));printf("address of my is:%xn", &my);//堆上分配11f8: bf 18 00 00 00 mov $0x18,%edi11fd: e8 8e fe ff ff call 10901202: 48 89 45 c0 mov %rax,-0x40(%rbp)my_on_heap->a = 1;1206: 48 8b 45 c0 mov -0x40(%rbp),%rax120a: c7 00 01 00 00 00 movl $0x1,(%rax)my_on_heap->b = 2L;1210: 48 8b 45 c0 mov -0x40(%rbp),%rax1214: 48 c7 40 08 02 00 00 movq $0x2,0x8(%rax)121b: 00my_on_heap->c = 'a';121c: 48 8b 45 c0 mov -0x40(%rbp),%rax1220: c6 40 10 61 movb $0x61,0x10(%rax)my_on_heap->d = 4;1224: 48 8b 45 c0 mov -0x40(%rbp),%rax1228: 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:%xn", my_on_heap);
参考
视频——内存对齐到底是个什么鬼