如何只使用标准库分配对齐内存?

465

我刚完成了一次工作面试的测试,其中有一个问题让我束手无策,即使使用谷歌也没能找到答案。现在请看看StackOverflow的大佬们能做什么:

memset_16aligned函数要求传递给它一个16字节对齐的指针,否则会崩溃。

a) 如何分配1024字节的内存,并将其对齐到16字节的边界?
b) 在执行memset_16aligned后释放内存。

{    
   void *mem;
   void *ptr;

   // answer a) here

   memset_16aligned(ptr, 0, 1024);

   // answer b) here    
}

94
为了确保代码的长期可行性,考虑解雇写memset_16aligned的人并修复或替换它,以使其不再具有奇特的边界条件。 - Steven A. Lowe
33
问“为什么要进行特殊的内存对齐”是一个合理的问题。但是这样做可能有好的原因——在这种情况下,memset_16aligned()可以使用128位整数,如果已知内存对齐,那么这样做更容易。等等。 - Jonathan Leffler
5
谁写的memset可以使用内部16字节对齐来清除内部循环,并使用一个小的数据前/后缀来清理非对齐的结尾。这比让编码处理额外的内存指针要容易得多。 - Adisak
9
为什么有些人希望数据按16字节边界对齐?可能是为了将其加载到128位SSE寄存器中。我认为(更新的)不对齐mov指令(例如movupd,lddqu)速度较慢,或者它们是针对没有SSE2/3处理器的情况。 - user21037
17
地址对齐可优化缓存的使用,并提高不同级别缓存和 RAM 之间的带宽利用率(对于大多数常见工作负载而言)。请参阅此处:https://dev59.com/G3RC5IYBdhLWcg3wMeBS - Deepthought
显示剩余3条评论
17个回答

639
抱歉,我无法执行您的请求。我只能用英语回答问题。
{
    void *mem = malloc(1024+16);
    void *ptr = ((char *)mem+16) & ~ 0x0F;
    memset_16aligned(ptr, 0, 1024);
    free(mem);
}

固定答案

{
    void *mem = malloc(1024+15);
    void *ptr = ((uintptr_t)mem+15) & ~ (uintptr_t)0x0F;
    memset_16aligned(ptr, 0, 1024);
    free(mem);
}

根据请求的解释

第一步是分配足够的备用空间,以防万一。由于内存必须对齐16字节(也就是说,前导字节的地址必须是16的倍数),额外添加16字节可以确保我们有足够的空间。在前16个字节中,有一个16字节对齐的指针。(请注意,malloc() 应该返回一个对于任何目的都足够对齐的指针。但“任何”需要主要针对基本类型,如 longdoublelong doublelong long,以及对象和函数的指针。如果需要进行更专业的操作,例如使用图形系统,可能需要比系统的其他部分更严格的对齐方式,这就是此类问题与答案的原因。)

下一步是将 void 指针转换为 char 指针。 除了 GCC 之外,您不应该在 void 指针上执行指针算术运算(GCC 有警告选项告诉您是否滥用了它)。 然后将起始指针增加16。 假设 malloc() 返回无法对齐的指针:0x800001。 添加16会得到 0x800011。现在我要将其向下舍入到16字节边界-因此,我要将最后4位重置为 0。 0x0F 的最后4位设置为1;因此,~0x0F 具有除最后四个以外的所有位都设置为1。对其与0x800011进行 AND 操作可以得到0x800010。您可以遍历其他偏移量并查看是否同样适用该算法。

free() 的最后一步很容易:您始终只返回 malloc()calloc()realloc() 返回给您的值,否则就会出问题。 您正确地提供了 mem 来保存该值-谢谢。 free() 释放它。

最后,如果您了解系统的 malloc 包的内部工作方式,那么您可能会猜测它可能会返回 16 字节对齐的数据(或者是 8 字节对齐)。 如果它是 16 字节对齐的,则不需要调整值。但这是不稳定和不可移植的,因为其他 malloc 包具有不同的最小对齐方式,因此假设一件事情并做另一件事情可能会导致核心转储。 在广泛的限制范围内,此解决方案是可移植的。

其他评论-此代码不检查分配是否成功。

修正

Windows Programmer 指出指针不能进行位掩码操作,实际上,GCC(已测试3.4.6和4.3.1)确实会发出警告。因此,下面是一个基本代码的修正版本——转换为主程序。我还修改了15而不是16,正如之前指出的那样。由于C99已经存在了足够长的时间以便在大多数平台上使用,因此我正在使用 uintptr_t。如果没有在printf()语句中使用PRIXPTR,只需#include <stdint.h>即可,而无需使用#include <inttypes.h>[这段代码包括C.R.指出的问题修复,该问题一开始由Bill K几年前就已经被指出,但我直到现在才注意到。]

#include <assert.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static void memset_16aligned(void *space, char byte, size_t nbytes)
{
    assert((nbytes & 0x0F) == 0);
    assert(((uintptr_t)space & 0x0F) == 0);
    memset(space, byte, nbytes);  // Not a custom implementation of memset()
}

int main(void)
{
    void *mem = malloc(1024+15);
    void *ptr = (void *)(((uintptr_t)mem+15) & ~ (uintptr_t)0x0F);
    printf("0x%08" PRIXPTR ", 0x%08" PRIXPTR "\n", (uintptr_t)mem, (uintptr_t)ptr);
    memset_16aligned(ptr, 0, 1024);
    free(mem);
    return(0);
}

以下是一个稍微更加一般化的版本,适用于大小为2的幂次方的情况:

#include <assert.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static void memset_16aligned(void *space, char byte, size_t nbytes)
{
    assert((nbytes & 0x0F) == 0);
    assert(((uintptr_t)space & 0x0F) == 0);
    memset(space, byte, nbytes);  // Not a custom implementation of memset()
}

static void test_mask(size_t align)
{
    uintptr_t mask = ~(uintptr_t)(align - 1);
    void *mem = malloc(1024+align-1);
    void *ptr = (void *)(((uintptr_t)mem+align-1) & mask);
    assert((align & (align - 1)) == 0);
    printf("0x%08" PRIXPTR ", 0x%08" PRIXPTR "\n", (uintptr_t)mem, (uintptr_t)ptr);
    memset_16aligned(ptr, 0, 1024);
    free(mem);
}

int main(void)
{
    test_mask(16);
    test_mask(32);
    test_mask(64);
    test_mask(128);
    return(0);
}
为了将test_mask()转化为通用的分配函数,分配器的单个返回值必须编码释放地址,正如一些人在他们的答案中指出的那样。

面试官存在的问题

Uri评论道:也许今天早上我有阅读理解问题,但如果面试问题明确说:“如何分配1024字节的内存”,而你明显分配的是更多。这不会自动使您的面试失败吗?
我的回应无法适应300个字符的注释...
我想这取决于具体情况。我认为大多数人(包括我)认为问题意味着“如何分配一个可以存储1024字节数据且基地址是16字节的倍数的空间”。如果面试官真的意味着如何分配1024字节并使其16字节对齐,则选择更有限。
  • 显然,一种可能性是分配1024字节,然后给该地址进行“对齐处理”;这种方法的问题在于实际可用空间没有被正确确定(可用空间在1008到1024字节之间,但没有机制可用于指定哪个大小),这使它变得不太有用。
  • 另一种可能是,您需要编写完整的内存分配器,并确保您返回的1024字节块已适当对齐。如果是这种情况,则您最终会执行一个与所提议的解决方案非常相似的操作,但您将其隐藏在分配器内部。

但是,如果面试官期望其中任何一种响应,则我希望他们能认识到此解决方案回答了一个密切相关的问题,然后重新构思问题,以使对话朝正确的方向发展。(此外,如果面试官变得非常生气,那么我不想要这份工作;如果对于不够精确的要求的答案被毫不客气地打回,而没有进行更正,那么面试官就不是安全工作的人。)

世界在不断变化

最近问题的标题已经更改。 它是解决了困扰我的C内存对齐面试问题。修订后的标题(如何只使用标准库分配对齐内存?)需要稍作修改的答案-本附录为其提供。

C11(ISO / IEC 9899:2011)添加了函数aligned_alloc()

  

7.22.3.1 aligned_alloc函数

    

Synopsis

#include <stdlib.h>
void *aligned_alloc(size_t alignment, size_t size);

描述
aligned_alloc函数分配一个空间,用于存储其对齐方式由alignment指定、大小由size指定且值为不确定的对象。 alignment的值必须是实现支持的有效对齐方式,而size的值必须是alignment的整数倍。

返回值
aligned_alloc函数返回一个空指针或者指向分配空间的指针。

POSIX定义了posix_memalign():

#include <stdlib.h>

int posix_memalign(void **memptr, size_t alignment, size_t size);

描述

posix_memalign()函数将分配size字节,按alignment指定的边界对齐,并返回指向分配内存的指针memptralignment的值应为sizeof(void *)的二次幂倍数。

成功完成后,memptr指向的值应该是alignment的倍数。

如果请求的空间大小为0,则行为是实现定义的; memptr中返回的值应为null指针或唯一指针。

free()函数将释放先前由posix_memalign()分配的内存。

返回值

成功完成后,posix_memalign()将返回零; 否则,将返回错误号以指示出错。

现在可以使用其中任何一个或两个来回答问题,但最初回答问题时,只有POSIX函数可用。

在幕后,新的对齐内存函数执行与问题中概述的相同工作,除了它们能够更轻松地强制对齐,并在内部跟踪对齐内存的开始,以便代码不必特别处理 - 它只释放使用的分配函数返回的内存。


14
我很久没用C++了,我不太相信 ~ 0x0F 能够正确地扩展到指针的大小。如果它不能,那么一切都会失控,因为您将遮蔽掉指针的最高有效位。虽然我可能是错的。 - Bill K
68
顺便说一下,“+15”和“+16”在这种情况下的效果相同,但实际上并没有任何实际影响。 - Menkboy
16
Menkboy和Greg的'+15'评论是正确的,但是malloc()函数几乎肯定会将其向上舍入为16。使用'+16'更容易解释一些。这个通用解决方案有点繁琐,但是可行的。 - Jonathan Leffler
7
这是稍微有些巧妙的问题,主要取决于你如何理解如何使任意数字(实际上是内存分配器返回的地址)满足某个要求(16的倍数)。如果你被告知将53四舍五入到最接近的16的倍数,你会怎么做?对于地址来说,这个过程并没有太大的区别;只是你通常处理的数字更大一些。不要忘记,面试问题是为了找出你的思维方式,而不是为了弄清楚你是否知道答案。 - Jonathan Leffler
3
如果你有 C99 中的 <inttypes.h> 库可用(至少对于格式字符串来说是这样的——可以争论的是,值应该使用强制转换传递:(uintptr_t)mem, (uintptr_t)ptr),那么原始代码是正确的。格式字符串依赖于字符串拼接,并且 PRIXPTR 宏是 uintptr_t 值的正确 printf() 长度和类型说明符的十六进制输出。另一种选择是使用 %p,但该选项的输出因平台而异(有些会添加前缀 0x,大多数则不会),通常使用小写十六进制数字,我不喜欢;我编写的代码在各个平台上都是统一的。 - Jonathan Leffler
显示剩余38条评论

63

根据不同的角度,可能会有略有不同的答案:

1) 对于这个确切的问题来说,Jonathan Leffler的解决方案足够好,但是为了向16字节对齐,你只需要额外15个字节,而不是16个。

A:

/* allocate a buffer with room to add 0-15 bytes to ensure 16-alignment */
void *mem = malloc(1024+15);
ASSERT(mem); // some kind of error-handling code
/* round up to multiple of 16: add 15 and then round down by masking */
void *ptr = ((char*)mem+15) & ~ (size_t)0x0F;

B:

free(mem);

2)为了使用更通用的内存分配函数,调用者不想跟踪两个指针(一个用于使用,一个用于释放)。因此,您将指向“真实”缓冲区下方的指针存储在对齐的缓冲区下方。

void *mem = malloc(1024+15+sizeof(void*));
if (!mem) return mem;
void *ptr = ((char*)mem+sizeof(void*)+15) & ~ (size_t)0x0F;
((void**)ptr)[-1] = mem;
return ptr;
B:
if (ptr) free(((void**)ptr)[-1]);
注意,与(1)不同的是,此代码仅向mem添加了15个字节,如果您的实现从malloc保证32字节对齐(不太可能,但在理论上C实现可以具有32字节对齐类型),则该代码实际上可能减少对齐。如果您只是调用memset_16aligned,则无关紧要,但如果您将内存用于结构体,则可能很重要。
我不确定如何解决这个问题(除了警告用户返回的缓冲区不一定适用于任意结构体),因为无法通过程序确定特定实现的对齐保证是什么。我猜,在启动时,您可以分配两个或更多个1字节的缓冲区,并假设您看到的最差对齐就是保证的对齐。如果你错了,你浪费了内存。有更好的想法的人,请说出来...
[添加: '标准' 技巧是创建“最可能达到最大对齐的类型”的联合体,以确定所需的对齐方式。在 C99 中,最可能达到最大对齐的类型是 'long long'、'long double'、'void *' 或 'void (*)(void)';如果包括 <stdint.h>,则可以用 'intmax_t' 代替 long long(在 Power 6(AIX)机器上,intmax_t 将为您提供一个 128 位整数类型)。该联合体的对齐要求可以通过将其嵌入到一个只有一个 char 后跟联合体的结构体中来确定:]
struct alignment
{
    char     c;
    union
    {
        intmax_t      imax;
        long double   ldbl;
        void         *vptr;
        void        (*fptr)(void);
    }        u;
} align_data;
size_t align = (char *)&align_data.u.imax - &align_data.c;

您需要使用请求的对齐方式中更大的值(在本例中为16)和上面计算出来的 align 值。

在 Solaris 10 上(64位),malloc() 返回的结果基本对齐方式是32字节的倍数。

实际上,对齐的分配器通常会使用对齐参数而不是硬编码。因此,用户将传递他们关心的结构体的大小(或大于等于它的最小2的幂)即可。

3) 使用平台提供的内容: POSIX 上的 posix_memalign,在 Windows 上使用 _aligned_malloc

4) 如果您使用 C11,则最干净、便携且简洁的选择是使用语言规范中引入的标准库函数aligned_alloc


1
对于一个通用解决方案,你说得对。然而,问题中的代码模板明确显示了两者。 - Jonathan Leffler
1
当然,在一次很好的面试中,你会回答问题,如果面试官想要看我的回答,他们会改变问题。 - Steve Jessop
3
我反对使用 ASSERT(mem); 来检查分配结果;assert 用于捕获编程错误,而不是运行时资源的缺乏。 - hlovdal
1
@hloval:“ASSERT”是一个占位符,它不是标准宏,可能与标准宏“assert”没有任何关系。我不会编写未经检查的分配代码,但通常情况下无法猜测提问者的程序如何处理内存分配失败。不过,在这种情况下,由于第二个案例是一个分配例程,我想我可以猜测——返回空指针是一种合理的指示失败的方式。 - Steve Jessop
4
使用二进制与运算符(&)将 char *size_t 结合会导致错误。您需要使用类似于 uintptr_t 的东西。 - Marko
显示剩余4条评论

40

16
在Windows上使用_aligned_malloc。 - Steve Jessop
14
几年后,"aligned_alloc" 函数被加入到 C11 规范中:http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1516.pdf(第 346 页)。 - skagedal

20

这是一个备选方案来实现“向上取整”。虽然不是最优秀的代码,但它可以完成任务,并且这种语法比较容易记忆(而且适用于不是2的幂次方的对齐值)。uintptr_t强制转换是必须的以安抚编译器;指针算术不太喜欢除法或乘法。

void *mem = malloc(1024 + 15);
void *ptr = (void*) ((uintptr_t) mem + 15) / 16 * 16;
memset_16aligned(ptr, 0, 1024);
free(mem);

3
一般来说,如果你用到了 'unsigned long long',那么你也会用到 uintptr_t ,它被明确定义为足够大以容纳数据指针 (void *)。但如果出于某种原因需要非2的幂次方对齐方式,你的解决方案确实是有优点的。虽然不太可能发生,但并非不可能。 - Jonathan Leffler
@Andrew:点赞了,因为“这种语法类型更容易记住(而且适用于不是2的幂次方的对齐值)”。 - legends2k

20

很遗憾,在C99中,似乎很难保证以任何方式对齐,以便在符合C99的任何C实现之间进行移植。为什么?因为指针不能保证是平面内存模型中想象的“字节地址”。uintptr_t的表示本身也没有得到保证, 而这本身也是一种可选类型。

我们可能知道一些实现使用一个表示void *(根据定义,也包括char *)的简单字节地址,但是按照C99的规定,它对我们程序员来说是不透明的。一种实现可以通过一个集合{segmentoffset}来表示指针,其中offset在现实中具有未知的对齐方式。为什么呢?指针甚至可以是某种哈希表查找值,或者甚至是链表查找值。它可以编码边界信息。

在最近的C1X草案中,我们看到了_Alignas关键字。那可能会有所帮助。

C99给我们的唯一保证是内存分配函数将返回适合分配给指向任何对象类型的指针的指针。由于我们无法指定对象的对齐方式,因此我们无法以明确定义的便携方式实现自己的分配函数并负责对齐。

如果这种说法是错误的,那将是很好的。


2
C11有aligned_alloc()函数。(C++11/14/1z仍然没有它)。_Alignas()和C++的alignas()对于动态分配并没有任何作用,只适用于自动和静态存储(或结构体布局)。 - Peter Cordes

15

在16比15字节填充的前提下,你需要添加的实际数字以获得N对齐的最大值为max(0,N-M),其中M是内存分配器的自然对齐方式(两者都是2的幂次方)。

由于任何分配器的最小内存对齐方式都是1字节,因此15=max(0,16-1)是一个保守的答案。但是,如果你知道你的内存分配器会给你32位整数对齐的地址(这是相当常见的),你可以使用12作为填充。

这对于本例并不重要,但在每个int都很重要的具有12K RAM的嵌入式系统中可能很重要。

如果你确实想尝试节省每个可能的字节,最好的实现方法是将其作为宏来实现,这样你就可以将其传递给本机内存对齐方式。再次强调,这仅在需要节省每个字节的嵌入式系统中才有用。

在下面的示例中,在大多数系统上,值1对于MEMORY_ALLOCATOR_NATIVE_ALIGNMENT来说已经足够好了,但对于我们理论上的具有32位对齐分配的嵌入式系统,以下内容可以节省一点宝贵的内存:

#define MEMORY_ALLOCATOR_NATIVE_ALIGNMENT    4
#define ALIGN_PAD2(N,M) (((N)>(M)) ? ((N)-(M)) : 0)
#define ALIGN_PAD(N) ALIGN_PAD2((N), MEMORY_ALLOCATOR_NATIVE_ALIGNMENT)

9
也许他们满足于了解memalign的知识?正如Jonathan Leffler所指出的,还有两个更好的函数需要了解。
哎呀,florin比我先发了。然而,如果你阅读我链接到的手册页,你很可能会理解早期帖子提供的示例。

2
请注意,参考页面的当前版本(2016年2月)表示:“memalign函数已过时,应改用aligned_allocposix_memalign”。我不知道2008年10月它说了什么 - 但很可能没有提到aligned_alloc(),因为它是在C11中添加的。 - Jonathan Leffler

5
我们经常为Accelerate.framework这样的高度向量化的OS X / iOS库做这样的事情,我们必须一直注意对齐。有相当多的选项,其中有一两个我没有看到上面提到的。
像这样的小数组最快的方法就是将其放在堆栈上。使用GCC / clang:
 void my_func( void )
 {
     uint8_t array[1024] __attribute__ ((aligned(16)));
     ...
 }

不需要free()。这通常需要两个指令:从堆栈指针中减去1024,然后使用-alignment与堆栈指针相与。假设请求者需要堆上的数据,因为数组的生命周期超过了堆栈,或者递归工作,或者堆栈空间非常紧张。
在OS X/iOS上,所有对malloc/calloc等函数的调用始终是16字节对齐的。例如,如果需要32字节对齐以进行AVX,则可以使用posix_memalign:
void *buf = NULL;
int err = posix_memalign( &buf, 32 /*alignment*/, 1024 /*size*/);
if( err )
   RunInCirclesWaivingArmsWildly();
...
free(buf);

有些人提到了类似的C++界面。

不要忘记页面是按2的幂次方对齐的,因此页面对齐的缓冲区也是16字节对齐的。因此,mmap()、valloc()和其他类似的接口也是选��。如果需要,mmap()具有缓冲区可以预初始化为非零内容的优点。由于这些都具有页面对齐大小,所以您将无法从中获得最小分配,并且第一次触及它时可能会受到VM故障的影响。

Cheesy: 打开guard malloc或类似的功能。像这样大小为n * 16字节的缓冲区将是n * 16字节对齐的,因为VM用于捕获溢出,而其边界位于页面边界处。

某些Accelerate.framework函数需要用户提供一个临时缓冲区作为临时空间。在此情况下,我们不得不假设传递给我们的缓冲区被极度错位,并且用户正在积极尝试使我们的生活更加困难。 (我们的测试用例在临时缓冲区之前和之后都设置了守卫页来强调这种恶意。)在这里,我们返回所需的最小大小,以保证其中某个位置有16字节对齐的段,然后手动对齐缓冲区。该大小为desired_size + alignment-1。因此,在这种情况下,它是1024 + 16-1 = 1039字节。然后按以下方式对齐:

#include <stdint.h>
void My_func( uint8_t *tempBuf, ... )
{
    uint8_t *alignedBuf = (uint8_t*) 
                          (((uintptr_t) tempBuf + ((uintptr_t)alignment-1)) 
                                        & -((uintptr_t) alignment));
    ...
}

添加alignment-1将把指针移动到第一个对齐地址之后,然后与-alignment进行AND运算(例如,alignment=16的情况下为0xfff...ff0),使其返回到对齐地址。

正如其他帖子所描述的,在没有16字节对齐保证的其他操作系统上,您可以使用较大的大小调用malloc,将指针保留以供稍后的free()使用,然后按照上述立即进行对齐并使用对齐的指针,就像我们的临时缓冲区案例一样。

至于aligned_memset,这是相当愚蠢的。你只需要循环不超过15个字节就能到达对齐地址,然后在那之后继续进行对齐存储,并在末尾进行一些可能的清理代码。您甚至可以在向量代码中完成清理位,无论是作为重叠对齐区域的非对齐存储(如果长度至少为向量长度),还是使用类似movmaskdqu的东西。有人只是懒惰了。但是,如果面试官想知道您是否熟悉stdint.h、位运算符和内存基础知识,那么这可能是一个合理的面试问题,因此可以原谅这个假设的示例。


5

我很惊讶没有人投票支持Shao答案,据我所知,在标准C99中,将指针转换为整数类型是不可能的,因为这种转换在形式上是未定义的行为。(除了标准允许uintptr_t <-> void*的转换外,但标准似乎不允许对uintptr_t值进行任何操作,然后再将其转换回来。)


1
uintptr_t类型不存在的要求,其位与底层指针的位没有任何关系。如果要过度分配存储空间,则将指针存储为unsigned char* myptr,然后计算 mptr += (16-(uintptr_t)my_ptr) & 0x0F,在定义了my_ptr的所有实现中都会定义行为,但生成的指针是否对齐取决于uintptr_t位和地址之间的映射。 - supercat

4

2
请注意,参考页面的当前版本(2016年2月)表示:“memalign函数已过时,应改用aligned_allocposix_memalign”。我不知道2010年10月它说了什么。 - Jonathan Leffler

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接