从CPU角度看内存访问对齐

本文探讨了自然对齐的概念及其对处理器性能的影响,详细解释了不同数据类型的对齐要求,以及如何通过自然对齐避免不必要的性能损失。此外,还介绍了Itanium2处理器的缓存组织结构,并提供了在HPUX平台上处理对齐问题的具体方法。

关于自然对齐

 

1. 首先什么是cache line? cache line就是处理器从RAM load/store数据到CPU cache所使用的数据线。

2. 什么是对齐:有些人说,address of data % sizeof(data type)==0 就是对齐,其实这不准确,全面的理解它必须从CPU为什么需要对齐,不对齐就会造成数据访问花费额外的时钟周期,和额外的指令(编译器或OS附加的),并且数据经过更长的路径,比如pipeline才能到达CPU(从RAM)。这就是对齐问题。这里的重点是  数据的起始地址 & 数据的大小

 

P4极其之后的处理器,cache line(缓存线)宽度是64字节,


1. 内存的访问如果没有按照64字节边界对齐(例如:每次访问 -读/写在Addrd!=0就是没对齐),就可能会造成两次cache line split内存访问,和多个upos的执行。
所以一个原则就是,总是对齐数据在操作数大小的地址边界(按照操作数自身的大小进行对齐),如果内存被访问通过矢量指令(解指针引用的内存访问,如, mov ecx, DWORD PTR [ebx], 或 st4 [r8]=r9 (这是IA64指令)。这样的访问不会超过16字节(80位浮点数,或128位long long,在64位系统),那么在16字节边界对齐总是没问题的。是否所有长度的数据都要对齐在16字节,如果,char 和char连着排放,那么岂不是要加15个padding字节?太过浪费,所以,用数学的办法,只要能保证对齐在16字节边界就行。

 

所以引入自然对齐的概念:(对于下面的,可以这样来理解:按照这些规则进行对齐的话,就可以保证内存的一次访问都在64字节的cache line的宽度之内)


 a. 1字节数据可以在任何位置开始访问(对齐在任何地址)
 b. 2字节数据的开始地址必须包含在一个对齐的4字节之内。
 c. 4字节数据的开始地址是4的倍数,(按4字节对齐)
 d. 8字节数据的开始地址是8的倍数,(按8字节对齐)
 e. 10字节浮点数据的开始地址是16的倍数,(按16字节对齐)
 f. 16字节数据的开始地址是16的倍数,(按16字节对齐)


整数的访问不能跨越8字节边界,就是说整数必须是<=8字节进行对齐(1,2,4,8)
任何数据类型访问不能跨越16字节边界,就是说必须是<=16字节进行对齐(1,2,4,8,16)
 
一个64字节或更大的数据结构或数组的开始地址应该按照64字节边界对齐,数据成员按照每个成员的size从大到小排列,是一个有助于自然对齐的启发性方法。只要按照上面的自然对齐,就可以容易的保证一次数据访问不穿越16/64字节边界。反过来,只要能保证不穿越16/64字节边界,就不需要使用自然对齐。(通常我们没法保证访问数据不穿越16/64字节边界,内存地址分配不由我们控制)


2. 一次访问如果穿越64字节边界可能极大降低性能,pipelines(管线)越长,代价越大。


3. 双精度浮点数作为操作数时,8字节对齐的访问比非8字节对齐有更好的性能。


Itanium 2的cache组织:


处理器高速缓存被组织为一个表,行对应于cache line中的数据,内存中的数据将被存储于哪一行由内存单元地址的最末6位决定(处理器简单的将这6位归零后,作为行索引),为什么是6位呢?因为Itanium 2 L1D 的cache line的宽度是64字节,也就是说一个cache line一次可以访问(load/store)64字节的内存单位,对于地址就是低6位不变,第7位+1的地址范围的数据。每64字节数据,按照同一列,新行顺序存放,这个表的列叫做"associativity"。一般来说,因为cache不足以存放所有的数据,新数据请求将会导致覆盖旧数据,为了高效管理cache,每一列的所有行组成"associative"集。列数越多,替换的准确性越高(可以理解为有更少的可能性替换当前正在使用的数据),越有效。Itanium 2 L1有4-Way associative set, 每way 64字节line宽度,所以行数就是(cache size=16KB)/(4*64)=64行,所以,内存地址的从低到高的第7位开始,到第12位,用于索引行,用于访问数据。所以Itanium 2的每way(associative set,每列)有64个cache line,并且涵盖4K字节数据,所以访问不穿越4K边界的数据,将会保证在一个associative set中,将会极大提高内存访问效率。见下图:

 

Itanium2 cache table

不得不说,处理器的这种设计,4Kbytes/1way,不正好符合OS虚拟内存管理中,页大小为4K。这样OS的每次虚拟内存访问,和处理器对内存的访问正好是一致的

,RAM<->Cache 都是4K。

 

 

关于Itanium2的数据对齐要求

 


Itanium2也遵循上面的自然对齐方式,如果(Processor Status Register)PSR.ac=1,所有的未对齐的内存访问将会引发UDRf(Unaligned Data Reference fault),Unix(如linux ia64, and HPUX ia64),将会表现为sig 10, bus error。IA32模式将会表现为IA_32_Exception(AlignmentCheck) fault,对齐的内存访问则不会。如果PSR.ac=0, 则访问未对齐的数据,可能会也可能不会引发UDRf。这是执行的自定行为。(注:这里的执行表示OS)

 

 1. 如果数据单元访问跨越4K边界,那么一定会引发UDRf。这是必须行为。
 2. 某些执行将会在跨越cache line边界(64字节)时引发UDRf。这是执行的自定行为。
 3. 执行可以自定在某种未自然对齐的情况下,引发UDRf。
 4. 如果ia32模式,PSR.ac=0,并且Alignment Check被禁用,那么任何未对齐都会被忽略。不会有UDRf。

 

semaphore指令从不考虑PSR.ac标志,只要未对齐引用发生,就会引发UDRf。对于cmp8xchg16指令,必须是8字节对齐

 

HPUX如何处理对齐问题:

(一般编译器会默认按照 min(8字节,数据类型),即自然对齐的方式,处理对齐问题,分配到指定的内存边界)

 

1. 编译器指令:#pragma align N  (N=1,2,4,8,16)

该指令影响随后的一条定义语句,如char a; 将导致a 分配在在N字节边界地址

2. #pragma pack N 将影响范围扩大到从它开始到文件结束。再用一次不带参数#pragma pack,将复位到默认对齐。

3. #pragma unalign N将影响随后的一个typedef,如 typedef int int_align_n; 将导致之后用int_align_n定义的数据对齐在N字节边界。

4. 取某数据地址,然后运行时cast后付给一个指针变量,将会导致未对齐的访问。因为编译器没有为这种情况使用特定的非对齐访问指令。

 

前面这些指令都会导致数据的存储变成非自然对齐,访问时就可能会引发UDRf或Alignment check failed. 可能会导致程序终止用bus error。视OS平台/执行不同而不同(看上面这段有描述)。

 

如何避免非对齐的数据访问crash呢?

1. 不要使用非自然对齐的与编译指令,或指针dereference.

2. HPUX aCC编译器+unum(如+u1)可以让编译器产生非对齐,1字节进行dereference load/store的指令,避免UDRf或Alignment check failed错。但会显著降低程序效率。

3. HPUX aCC链接器-lunalign (ia64架构)选项或-lhppa (pa-risc架构)。它会链接一段代码,包含一个函数allow_unaligned_data_access(),调用这个函数将安装一个sig handler,处理sig 10(其实安全的忽略它,访问数据没有问题),降低效率是必然的。

 

自然对齐的方式最容易被碰到,在HPUX IA64平台。莫名其妙你的程序会bus error 然后终止,就是说你该考虑一下你的数据对齐问题了。:-)

<think> 我们参考用户提供的引用内容,特别是引用[1]中提到的结构体和类在内存中的布局,以及访问成员的方式。 从汇编角度看,结构体或类实例的内存布局就是其成员变量依次排列(可能有对齐)。访问某个成员就是通过基地址加上该成员的偏移量来实现。 下面我们通过一个简单的结构体例子来分析其汇编实现。 例如,有一个结构体: struct Point { int x; int y; int z; }; 假设我们有一个Point类型的变量p,那么访问p.y相当于访问内存地址:p的地址 + 4(假设int为4字节,且没有额外对齐要求)。 我们用一个C++例子并查看其反汇编(以x86为例,但原理相同): void example() { Point p; p.x = 1; p.y = 2; p.z = 3; int a = p.y; } 对应的汇编可能类似: ; 假设p在栈上的地址为[ebp-0Ch] mov dword ptr [ebp-0Ch], 1 ; p.x = 1 [ebp-0Ch]就是p的地址,偏移0 mov dword ptr [ebp-08h], 2 ; p.y = 2 y的偏移为4,所以地址为[ebp-0Ch]+4 = [ebp-08h] mov dword ptr [ebp-04h], 3 ; p.z = 3 z的偏移为8,地址为[ebp-04h] mov eax, dword ptr [ebp-08h] ; 将p.y的值赋给eax,即a=eax 因此,访问结构体成员的关键是知道结构体的起始地址和成员在结构体中的偏移量。 在C++类中,如果类有成员函数,那么调用成员函数时,编译器会隐式传递一个指向当前对象的指针(即this指针)。在汇编层面,这个this指针通常作为第一个参数传递(在x86的cdecl调用约定中,通过ecx寄存器或栈传递,具体取决于编译器和约定)。 参考引用[1]中的例子,类Test的成员函数fun被调用时,实际上会传递test对象的地址作为this指针。在fun函数内部访问成员变量,就是通过this指针加上成员变量的偏移量来实现的。 总结: 1. 结构体或类实例在内存中是其成员变量的连续存储(考虑对齐)。 2. 访问成员变量通过基地址(对象起始地址)加上该成员的偏移量。 3. 对于类成员函数,编译器会自动插入this指针作为第一个参数,用于在函数内部访问成员变量。 下面我们用一个更具体的例子(结合引用[1]和[4]中的思想)来说明: 假设有如下结构体: struct S { int a; char b; short c; }; 在32位系统中,考虑对齐,假设a占4字节(偏移0),b占1字节(偏移4),c占2字节,但为了对齐可能需要在b后面填充1字节,然后c从偏移6开始。因此整个结构体大小为8字节。 访问s.b的汇编代码可能是: mov eax, [ebp-8] ; 假设s在栈上的地址是ebp-8,则s.a在[ebp-8] ; 要取s.b,地址是ebp-8+4 -> ebp-4,但注意,这里我们取一个字节 mov cl, byte ptr [ebp-4] ; 将s.b的值取到cl寄存器 如果是在类成员函数中,则通过this指针(通常放在ecx寄存器中)来访问: ; 假设this指针在ecx中,访问成员b: mov dl, byte ptr [ecx+4] ; 因为b的偏移是4 因此,从汇编角度看访问结构体成员就是基地址加偏移量的内存访问操作。 如果结构体嵌套,则访问内层成员需要逐层计算偏移。例如: struct Inner { int x; }; struct Outer { int a; Inner inner; }; 访问outer.inner.x:先计算outer的起始地址,然后加上inner的偏移(假设为4,因为a占4字节),然后再加上x在Inner中的偏移(0),所以总偏移为4。 最后,我们还可以通过指针来访问结构体成员,例如: Outer *p = ...; int y = p->inner.x; 汇编层面: ; 假设p的值在eax中 mov ecx, [eax+4] ; 因为inner的偏移是4,而x在inner中的偏移是0,所以直接取[eax+4]就是inner.x 综上所述,结构体成员的访问在汇编层面就是简单的基础地址加偏移量的内存访问。 </think>从汇编角度分析,访问结构体成员的原理可概括为 **基地址+偏移量寻址**。以下是详细分析: ### 1. **结构体内存布局原理** 结构体在内存中是其成员变量的**连续排列**。例如以下结构体: ```c struct Point { int x; // 偏移量 0 int y; // 偏移量 4(假设int占4字节) char tag; // 偏移量 8 }; ``` 内存布局示意: ``` +------+------+------+ | x | y | tag | +------+------+------+ 0 4 8 9(字节) ``` ### 2. **访问成员的汇编实现** 假设有代码: ```c Point p; p.x = 10; p.y = 20; ``` 对应的典型x86汇编: ```assembly ; 假设p的地址在ebx中 mov dword ptr [ebx], 10 ; 写入p.x(基地址+0偏移) mov dword ptr [ebx+4], 20 ; 写入p.y(基地址+4偏移) ``` 关键点: - **基地址**:结构体首地址(如`ebx`) - **偏移量**:成员相对首地址的位移(如`y`的`+4`) - **指令类型**:通过`mov`等指令结合内存寻址完成访问 ### 3. **编译器对偏移量的计算** 编译器在编译期确定每个成员的偏移量: 1. **内存对齐**:根据成员类型和平台对齐规则计算偏移 (如32位系统int对齐到4字节) 2. **偏移量固化**:生成指令时直接使用硬编码偏移值 (如`y`始终用`[base+4]`) ### 4. **与类成员访问的关联** C++类成员访问原理相同(参考引用[1][^1]): ```c++ class Test { public: int a; void set(int val) { this->a = val; // 等效于 mov [ecx], val } }; ``` 调用`test.set(5)`的汇编关键步骤: ```assembly mov ecx, test_addr ; this指针存入ecx(基地址) call Test::set ; 函数内执行 mov [ecx], 5 ``` > 💡 **核心机制**:通过隐式传递`this`指针(结构体基址)实现成员访问,与结构体寻址本质相同。 ### 5. **指针访问的特殊情况** 通过指针访问成员时仍遵循偏移规则: ```c Point *ptr = &p; ptr->y = 30; ``` 汇编实现: ```assembly mov eax, [ptr] ; 获取结构体基址 mov [eax+4], 30 ; 基址+4偏移写入值 ``` ### 性能特点 - **零运行时开销**:偏移量在编译期确定 - **与数组成员访问效率相同**:均为直接内存访问 - **地址计算仅需1条指令**:如`lea eax, [ebx+4]` --- **总结**:结构体成员访问的本质是 **基地址 + 编译期预计算的偏移量**,通过CPU的寻址指令直接操作内存。该机制与C++类成员访问(通过`this`指针)在汇编层完全一致[^1]。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值