malloc()函数是否分配连续的内存块?

40

我有一段代码,是由一个非常老派的程序员编写的 :-) 。它的大致内容如下:

typedef struct ts_request
{ 
  ts_request_buffer_header_def header; 
  char                         package[1]; 
} ts_request_def; 

ts_request_def* request_buffer = 
malloc(sizeof(ts_request_def) + (2 * 1024 * 1024));

该程序员基本上是在处理缓冲区溢出的概念。我知道这段代码看起来可疑,所以我的问题是:

  1. malloc 函数是否总是分配连续的内存块?因为如果这段代码中分配的块不是连续的,那么代码将会崩溃。

  2. 执行 free(request_buffer),是否会释放由 malloc 分配的所有字节,即 sizeof(ts_request_def) + (2 * 1024 * 1024), 还是只会释放结构体 sizeof(ts_request_def) 的大小的字节?

  3. 您是否发现这种方法存在任何明显的问题?我需要与老板讨论并指出这种方法的任何漏洞。


1
这不是与这个模式相同吗 https://dev59.com/YXI95IYBdhLWcg3w_zQN - Romain Hippeau
2
“the blocks”-- 这个问题假设 malloc(和 free)可以区分其参数的加数并产生两个“块”,因为计算中有一个 +,这显然是荒谬的。 - Jim Balter
14个回答

54

针对您列出的各个问题进行回答:

  1. 是的。
  2. 全部字节。Malloc/free不知道也不关心对象的类型,只关心大小。
  3. 严格来说,这是未定义的行为,但是许多实现都支持这种常见技巧。请参见下面其他的替代方案。

最新的C标准ISO/IEC 9899:1999 (通常称为C99)允许使用 可变长数组成员

以下是一个示例:

int main(void)
{       
    struct { size_t x; char a[]; } *p;
    p = malloc(sizeof *p + 100);
    if (p)
    {
        /* You can now access up to p->a[99] safely */
    }
}

这个现在被标准化的特性使你可以避免使用你在问题描述中所提到的常见但非标准的实现扩展。严格来说,使用非灵活数组成员并访问其边界之外是未定义行为,但许多实现都有文档说明和鼓励。

此外,gcc 允许 零长度数组 作为一种扩展。零长度数组在标准C中是不合法的,但 gcc 在 C99 提供灵活数组成员之前引入了这个特性。

在回答评论时,我将解释下面的代码片段为什么在技术上是未定义行为。我引用的章节号是 C99(ISO/IEC 9899:1999)。

struct {
    char arr[1];
} *x;
x = malloc(sizeof *x + 1024);
x->arr[23] = 42;
首先,6.5.2.1#2表明a[i]与(*((a)+(i)))相同,因此x->arr[23]等同于(*((x->arr)+(23)))。现在,6.5.6#8(关于指针和整数相加的规则)说:
"If both the pointer operand and the result point to elements of the same array object, or one past the last element of the array object, the evaluation shall not produce an overflow; otherwise, the behavior is undefined."
因此,由于x->arr[23]不在数组范围内,行为是未定义的。你可能仍然认为这没关系,因为malloc()意味着数组现在已经扩展了,但这并不是严格的情况。信息性附录J.2(列出了未定义行为的例子)通过一个示例提供了进一步的说明:
"即使对象似乎可通过给定下标访问(例如,在lvalue表达式a[1][7]中,给定声明int a[4][5]),数组下标也越界(6.5.6)"。

1
我不同意关于未定义行为的说法。malloc()保证返回连续的内存块,因此您可以使用指针算术或数组索引安全地访问结构体之外的内存 - 根据标准它们是相同的。因此这是已定义的行为。 - qrdl
@Chris:这可能有点挑剔,但据我所知,malloc将分配连续的虚拟内存空间,但支持它的实际物理内存可能不是连续的。至少在Linux中是这样工作的,据我所知。 - Robert S. Barnes
2
@Robert S. Barnes:你说的并没有错,但物理布局对于C标准来说完全无关紧要。重要的是当以明确定义的方式访问时,它看起来是连续的。同样正确且无关紧要的是指出内存可能不是连续的,因为它可能跨越几个硅片。 - Chris Young
1
对于 char 类型,这不是未定义行为。 - R.. GitHub STOP HELPING ICE
数组大小被声明为1。因此,使用除0以外的任何数组索引都是未定义行为。因此,编译器可以假定每个数组索引都是0。 - gnasher729
显示剩余2条评论

12

3 - 这是一种常见的C语言技巧,可在结构体末尾分配动态数组。另一种方法是在结构体中放置指针,然后单独分配数组,并不要忘记释放它。但是指定大小为2mb似乎有点不寻常。


非常感谢您的评论。基本上我们从套接字接收数据。我们不知道要接收的确切大小,因此将其限制为2 MB。我们接收到的数据被复制到这个结构中。之所以进行了这个更改,是因为这是影响最小的更改。 - user66854
@unknown(谷歌),如果大小是固定的,您还可以将数组大小从1更改为您的固定大小。这个技巧只对长度可变的数组有意义。 - quinmars

9

这是一个标准的C语言技巧,和其他缓冲区一样没有更危险的地方。

如果你想向老板展示自己比“非常老派的程序员”更聪明,那么这段代码并不适合你。老派并不一定不好。看起来这位“老派”的人对内存管理有足够的了解 ;)


8

1) 是的,如果没有足够大的连续块可用,malloc将失败。(malloc失败会返回一个空指针)

2) 是的,它会。内部内存分配将跟踪使用该指针值分配的内存量,并释放所有内存。

3) 这是一种语言黑客技巧,使用有点可疑。它仍然容易受到缓冲区溢出攻击,只是攻击者可能需要更长时间才能找到会导致其崩溃的有效载荷。这种“保护”的成本也非常高(您真的需要每个请求缓冲区> 2mb吗?)。而且它看起来很丑,尽管你的老板可能不会欣赏这个观点 :)


5

我认为现有的答案没有完全抓住这个问题的本质。你说老派程序员正在做这样的事情;

typedef struct ts_request
{ 
  ts_request_buffer_header_def header; 
  char                         package[1]; 
} ts_request_def;

ts_request_buffer_def* request_buffer = 
malloc(sizeof(ts_request_def) + (2 * 1024 * 1024));

我认为他不太可能做到完全那样,因为如果他想这么做,他可以使用简化的等价代码来完成,而不需要任何技巧。

typedef struct ts_request
{ 
  ts_request_buffer_header_def header; 
  char                         package[2*1024*1024 + 1]; 
} ts_request_def;

ts_request_buffer_def* request_buffer = 
malloc(sizeof(ts_request_def));

我打赌他真正做的事情可能是这样的:
typedef struct ts_request
{ 
  ts_request_buffer_header_def header; 
  char                         package[1]; // effectively package[x]
} ts_request_def;

ts_request_buffer_def* request_buffer = 
malloc( sizeof(ts_request_def) + x );

他想要达成的目标是分配一个大小为x的可变包裹请求。当然,用变量声明数组大小是不合法的,所以他采用了一个技巧来绕过这个问题。在我看来,他似乎知道自己在做什么,这个技巧处于C语言技巧中可尊重和实用的端点位置。

3
关于问题#3,没有更多的代码很难回答。我认为它没有什么问题,除非这种情况经常发生。你不想一直分配2mb的内存块。你也不想做没必要的事情,例如如果你只使用了2k。
你不喜欢它本身并不能完全否定它或者证明需要彻底重写。我建议仔细检查使用情况,试着理解原始程序员的想法,并在使用此内存的代码中密切关注缓冲区溢出(正如workmad3所指出的)。
你可能会发现许多常见错误。例如,代码是否检查malloc()是否成功?

3

这种漏洞(问题3)实际上取决于您的结构体接口。在某些情况下,这种分配可能是有意义的,没有进一步的信息,无法确定它是否安全。
但是,如果您的意思是分配的内存比结构体还大会出现问题,那么这绝不是C语言设计的问题(我甚至不认为它是老派的... ;))
最后一个注意点:拥有char [1]的意义在于终止NULL将始终位于声明的结构中,这意味着缓冲区中可以有2 * 1024 * 1024个字符,而您不必通过“+1”来考虑NULL值。这看起来可能微不足道,但我只是想指出一下。


此外,标准不允许大小为0的数组,尽管一些编译器可以支持。 - TrayMan
不行,一个char *会寻址到完全不同的内存位置,而不是与结构体连续的内存位置。对于C99,这个正确的声明是一个可变大小的数组“char package[]”。但几乎任何支持它的编译器也支持大小为0的GNU扩展。 - puetzk

3

我经常看到并使用这种模式。

它的好处是简化了内存管理,从而避免了内存泄漏的风险。只需要释放malloc分配的块即可。如果有一个辅助缓冲区,就需要两个free。然而,应该定义和使用析构函数来封装此操作,以便您始终可以更改其行为,例如切换到辅助缓冲区或添加其他要在删除结构时执行的操作。

访问数组元素也稍微更有效率,但在现代计算机中这已不再重要。

如果不同编译器中结构的内存对齐方式发生变化,代码也将正确工作,因为这是相当频繁的。

我唯一看到的潜在问题是,如果编译器重新排列成员变量的存储顺序,因为这个技巧要求package字段保持在存储的最后。我不知道C标准是否禁止置换。

还要注意,分配的缓冲区大小很可能比所需的大,至少会多出一个字节,如果有额外的填充字节,则会更多。


C 标准要求结构体成员按照放置的顺序排列。然而,由于我在答案中解释的原因,这是未定义的行为。 - Chris Young

3
是的。malloc只返回一个指针——它怎么可能告诉请求者它已经分配了多个不连续的块来满足请求呢?

好的,这就是操作系统和虚拟内存通过MMU完成的工作。实际物理块的RAM可能随处存在。 - Zan Lynx
"void *malloc(size_t size); malloc()函数分配size字节并返回其中一个的指针。" - Carsten S

2

回答你的第三个问题。

free总是一次性释放所有分配的内存。

int* i = (int*) malloc(1024*2);

free(i+1024); // gives error because the pointer 'i' is offset

free(i); // releases all the 2KB memory

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