首先,我们要搞清楚数据结构在计算机里面到底怎么存取?怎么描述它们。
任何数据结构(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;
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的功能是怎么实现的呢?起始就跟内存 = 其实地址 + 偏移长度这个公式密切相关。因为:
我写了一段小程序来模拟这个过程:
#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的值是int、long,我就能用CPU的算术模块对它们进行“加减乘除”,这样是有意义的,因为我在做代数运算。那么对一个地址,显然,做加减乘除运算是没有意义的。我们唯一能对地址做的有意义的操作就是找到这块地址,对这个地址对应的内存进行操作,这才是地址类型数据的意义。
因为对地址进行普通意义上的四则运算是没有代数意义的,所以,C语言为地址数据类型(指针)增加了两个操作符*与->。
看个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);
}
//链表头指针
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;
}
如下图描述:所以这个add函数就做了两件事:
再添加一个元素:
内存对齐应该叫做内存的虚拟地址对齐,讲的是如果我们为一个数据结构——抽象来讲就是一块内存——分配一个地址的时候,需要满足的规则。那么规则有哪些?我们可以先列出来:
下面具体说明下这些规则都是什么意思。
还是这个代码片段:
#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要符合基本数据类型的首地址必须是类型字节数的整数倍这条规则,所以编译器会在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的整数倍。如下图:
那么问题就来了,为什么要填充呢?本质的原因是什么?
一图胜千言:
解释:
一图胜千言,上图:
所以,内存必须对齐,不然同样的数据结构,没对齐比对齐后的内存要多一次内存的开销。
不要小看这一次内存访问的开销,因为:
其中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
本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828
© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号