检查 malloc 函数返回内容的四个理由

简介: 检查 malloc 函数返回内容的四个理由

写在前面:


一些开发人员可能对检查不屑一顾:他们故意不检查malloc函数是否分配了内存。他们的推理很简单——他们认为会有足够的记忆。如果没有足够的内存来完成操作,请让程序崩溃。似乎是一个糟糕的事实。


注意。在本文中,malloc 函数下将暗示问题不仅与这个特定函数有关,还与 calloc、realloc、_aligned_malloc、_recalloc、strdup 等有关。我不想用所有这些函数名称弄乱文章。所有这些函数的共同点是它们可以返回空指针。

b76879fa100c4818e36ab97b575b14fc.png

malloc


c55b7486dc6d4835865f8473de715764.png

如果 malloc 函数无法分配内存缓冲区,它将返回 NULL。任何正常的程序都应该检查 malloc 函数返回的指针,并适当地处理无法分配内存的情况。


不幸的是,许多程序员忽略了检查指针,有时他们故意不检查内存是否已分配。他们的理由如下:


如果 malloc 函数无法分配内存,则程序不太可能继续正常运行。最有可能的是,内存不足以执行其他操作,因此为什么要为内存分配错误而烦恼。空指针对内存的第一个寻址会导致在 Windows 中生成结构化异常。当涉及到类Unix系统时,该过程接收SIGSEGV [RU]信号。结果,程序崩溃,这是完全可以接受的。没有记忆,没有痛苦。或者,您可以捕获结构化异常/信号,并以更集中的方式处理空指针的取消引用。这比写数千张支票更方便。


我不是在编造这个。我和一些人交谈过,他们认为这种方法是合适的,并且有意识地从不检查malloc函数返回的结果。


顺便说一句,开发人员还有另一个借口,为什么他们不进行检查。malloc 函数只保留内存,但不保证当我们开始使用分配的内存缓冲区时会有足够的物理内存。因此,如果仍然没有保证,为什么要进行检查?例如,EFL Core 库的开发人员之一 Carsten Haitzler 解释了为什么我在库代码中没有检查的情况下计算了 500 多个片段。以下是他对文章的评论:


这是一个普遍的共识,至少在Linux上,这一直是我们的主要关注点,很长一段时间是我们的唯一目标,malloc/calloc/realloc的返回值不能被信任,特别是小的时候。Linux默认会过度使用内存。这意味着您获得了新的内存,但内核还没有实际分配实际的物理内存页给它。只有虚拟空间。除非你碰了它。如果内核不能服务这个请求,你的程序无论如何都会崩溃,试图访问一个看起来像有效指针的内存。因此,总的来说,检查allocs的返回值的价值很小,至少在Linux上是这样的。有时我们这样做……有时不是。但是一般情况下,返回值不能被信任,除非它是非常大的内存,并且你的alloc永远不会被服务——例如,你的alloc根本不适合虚拟地址空间(有时在32位上发生)。是的,过度投入是可以调整的,但它的代价是大多数人都不想付出的,甚至没有人知道他们可以调整。其次,如果分配一小块内存失败——例如一个链表节点……实际上,如果返回NULL…坠毁是你能做的最好的事情。你的内存很低,可能会崩溃,像glib对g_malloc那样调用abort(),因为如果你不能分配20-40字节…你的系统无论如何都会崩溃,因为你已经没有工作内存了。这里我说的不是微型嵌入式系统,而是具有虚拟内存和几兆字节内存等的大型机器,这一直是我们的目标。我可以理解为什么PVS-Studio不喜欢这样。严格来说,它实际上是正确的,但实际上,在实际情况下,花在处理这些东西上的代码是一种代码浪费。稍后我会详细介绍。


开发人员的给定推理是不正确的。下面,我将详细解释原因。


你需要自己检查


这里同时有四个原因,每个原因都足以证明在调用malloc函数后写检查。如果有人从你的团队不写检查,让他阅读本文。

在我开始之前,这里有一个小的理论参考,为什么发生空指针的解引用会发生结构性异常或信号。这对进一步讲故事很重要。


在地址空间的开头,操作系统保护一个或多个内存页。这允许通过空指针或值接近0的指针来检测内存寻址的错误。


在各种操作系统中,为这些目的保留的内存数量不同。此外,在某些操作系统中,这个值是可配置的。因此,调用特定字节数的内存预留是没有意义的。让我提醒您,在Linux系统中,标准值是64Kb。


重要的是,当向空指针添加任何足够大的数字时,您可能会“删除”控制内存页,并意外地进入不受写入保护的页。因此,可能会损坏一些数据。操作系统不会注意到这一点,也不会产生任何信号/异常。


请注意。如果我们讨论嵌入式系统,可能没有任何内存保护来防止由空地址写入。有些系统内存很低,所有的内存都用来存储数据。然而,具有少量RAM的系统很可能没有动态内存管理,因此也没有malloc函数。


准备好你的咖啡,让我们开始吧!


空指针取消引用是未定义的行为


就 C 和C++语言而言,空指针取消引用会导致未定义的行为。当调用未定义的行为时,任何事情都可能发生。不要假设您知道如果发生 nullptr 取消引用,程序将如何运行。现代编译器利用了认真的优化。因此,有时无法预测特定代码错误将如何表现。


程序的未定义行为非常令人讨厌。应避免代码中未定义的行为。


不要以为你能使用结构化异常处理程序(Windows中的SEH)或信号(在类UNIX系统中)来处理空指针取消引用。如果发生了空指针取消引用,则程序工作已经中断,任何事情都可能发生。让我们看一个抽象的例子,为什么我们不能依赖 SEH 处理程序等。


size_t *ptr = (size_t *)malloc(sizeof(size_t) * N * 2);
for (size_t i = 0; i != N; ++i)
{
  ptr[i] = i;
  ptr[N * 2 - i - 1] = i;
}


此代码填充从边缘到中心的数组。元素值向中心增加。我在 1 分钟内想出了这个例子,所以不要猜测为什么有人需要这样的数组。我什至不认识我自己。对我来说,相邻行中的记录发生在数组的开头和末尾的某个地方很重要。有时你在实际任务中需要这样的东西,当我们到达第 4 个原因时,我们将查看实际代码。


让我们再次仔细看看这两行:

ptr[i] = i;
ptr[N * 2 - i - 1] = i;


从程序员的角度来看,在循环开始时,ptr[0] 元素中会出现一条记录 — 将出现结构化异常/信号。它会被处理,一切都会好起来的。


但是,编译器可能会出于某些优化目的交换赋值。它拥有这样做的所有权利。根据编译器,如果指针被取消引用,则它不能等于 nullptr。如果指针为 null,则它是未定义的行为,编译器不需要考虑优化的后果。


因此,编译器可能会决定,出于优化目的,按如下方式执行赋值更有利可图:

ptr[N * 2 - i - 1] = i;
ptr[i] = i;

因此,在开始时,将通过 ((size_t *)nullptr)[N * 2 - 0 - 1] 地址进行记录。如果值 N 足够大,则内存开头的受保护页面将被“跳过”,并且 i 变量的值可以写入任何可用于写入的单元格中。总的来说,一些数据将被损坏。


只有在该地址之后,才会执行 ((size_t *)nullptr)[0] 地址的赋值。操作系统将注意到尝试写入它控制的区域,并将生成信号/异常。


程序可以处理此结构异常/信号。但为时已晚。在内存中的某个地方,有损坏的数据。此外,目前尚不清楚哪些数据已损坏以及可能产生什么后果!


编译器是否应该为交换赋值操作负责?不。程序员允许取消引用空指针,从而引导程序处于未定义的行为状态。在这种特殊情况下,程序的未定义行为将是数据在内存中的某个地方损坏。


结论:

遵循以下原则:

任何空指针解引用都是程序的未定义行为。没有所谓的“无害的”未定义的行为。任何未定义的行为是不可接受的。

如果没有事先检查,不允许对malloc函数及其类似物返回的指针进行解引用。不要依赖任何其他方法来拦截空指针的解引用。只使用老式的if运算符。


空指针取消引用是一个漏洞


一些开发人员根本不认为是错误,其他人则认为是漏洞。这是空指针取消引用时发生的确切情况。


在许多项目中,如果程序由于取消引用空指针而崩溃,或者如果使用信号拦截/结构异常以某种常规方式处理错误,这是可以接受的。


在其他应用程序中,空指针取消引用表示一种可用于应用层 DoS 攻击的潜在漏洞。程序或其中一个执行线程不会正常处理内存不足,而是终止其工作。这可能会导致数据丢失、数据完整性等。


下面是一个示例。有这样一个程序,如Ytnef,用于解码TNEF线程,例如,在Outlook中创建。调用calloc后没有检查被认为是CVE-2017-6298漏洞。


所有可能包含空指针取消引用的固定片段大致相同:

vl->data = calloc(vl->size, sizeof(WORD));
temp_word = SwapWord((BYTE*)d, sizeof(WORD));
memcpy(vl->data, &temp_word, vl->size);


如果您正在开发的应用程序不是非常重要,并且在工作期间崩溃不是问题,那么是的 - 不要编写检查。


但是,如果您正在开发真正的软件项目或库,则不进行检查是不可接受的!


如果要创建库,请注意,在某些应用程序中,取消引用空指针是一个漏洞。您必须处理内存分配错误并正确返回有关失败的信息。


在哪里保证将取消引用恰好一个空指针?


那些懒得写检查的人,出于某种原因认为取消引用正好影响空指针。是的,这种情况经常发生。但是程序员能够为整个应用程序的代码担保吗?我肯定没有。


我将用实际例子来说明我的意思。例如,让我们看看在Chromium中使用的LLVM-subzero库的代码片段。

void StringMapImpl::init(unsigned InitSize) {
  assert((InitSize & (InitSize-1)) == 0 &&
         "Init Size must be a power of 2 or zero!");
  NumBuckets = InitSize ? InitSize : 16;
  NumItems = 0;
  NumTombstones = 0;
  TheTable = (StringMapEntryBase **)
             calloc(NumBuckets+1,
                    sizeof(StringMapEntryBase **) + 
                    sizeof(unsigned));
  // Allocate one extra bucket, set it to look filled
  // so the iterators stop at end.
  TheTable[NumBuckets] = (StringMapEntryBase*)2;
}

在分配内存缓冲区后,TheTable[NumBuckets] 单元格中将立即发生一条记录。如果变量 NumBuckets 的值足够大,我们将污染一些数据,带来不可预测的后果。在这种损害之后,推测程序将如何运行是没有意义的。可能会有最意想不到的后果。


当库开发人员不检查调用malloc函数的结果时,他们明白自己在做什么。恐怕他们低估了这种方法的危险性。例如,让我们看一下 EFL 库中的以下代码片段:

static void
st_collections_group_parts_part_description_filter_data(void)
{
  ....
  filter->data_count++;
  array = realloc(filter->data,
    sizeof(Edje_Part_Description_Spec_Filter_Data) *
    filter->data_count);
  array[filter->data_count - 1].name = name;
  array[filter->data_count - 1].value = value;
  filter->data = array;
}

这里我们有一个典型的情况:缓冲区中没有足够的空间来存储数据,应该增加。为了增加缓冲区的大小,使用了 realloc 函数,该函数可能会返回 NULL。

如果发生这种情况,由于空指针取消引用,不一定会发生结构化异常/信号。让我们看一下这些行:

array[filter->data_count - 1].name = name;
array[filter->data_count - 1].value = value;

如果 filter->data_count 变量的值足够大,则这些值将被写入一个奇怪的地址。


在内存中,某些数据将损坏,但程序仍会运行。后果是不可预测的,肯定不会有好处。


结论:


我再次问这个问题:“哪里能保证对空指针的解引用会发生?”没有这样的保证。在开发或修改代码时,不可能记住最近考虑过的细微差别。你可以很容易地在内存中弄乱一些东西,而程序将继续执行,因为什么都没有发生。


编写可靠和正确的代码的唯一方法是始终检查malloc函数返回的结果。检查一下,少写点bug。


内存集以直接顺序填充内存的保证在哪里?


会有人说这样的话:

我非常了解 realloc 和文章中写的其他一切。但我是专业人士,在分配内存时,我只是立即使用内存集用零填充它。必要时,我使用支票。但我不会在每个 malloc 之后编写额外的检查。


通常,在缓冲区分配后立即填充内存是一个很奇怪的想法。这很奇怪,因为有一个calloc函数。然而,人们经常这样做。你不需要看很远就能找到例子,这是来自WebRTC库的代码:

int Resampler::Reset(int inFreq, int outFreq, size_t num_channels) {
  ....
  state1_ = malloc(8 * sizeof(int32_t));
  memset(state1_, 0, 8 * sizeof(int32_t));
  ....
}


分配内存,然后用零填充缓冲区。这是一种非常普遍的做法,尽管实际上,使用calloc可以将两条线减少为一条。不过这并不重要。


最主要的是,即使是这样的代码也是不安全的!memset 函数不需要从头开始填充内存,从而导致空指针取消引用。


memset 函数有权从末尾开始填充缓冲区。如果分配了大量缓冲区,则可以清除一些有用的数据。是的,在填充内存的同时,memset 函数最终将到达受保护的页面,并且操作系统将生成结构异常/信号。但是,处理它们不再有意义。到这个时候,一大块内存将被破坏——程序的后续工作将是不可预测的。


读者可能会争辩说,这一切都纯粹是理论上的。是的,memset 函数理论上可以从缓冲区的末尾开始填充缓冲区,但实际上,没有人会以这种方式实现此函数。

void *memset(void *dest, int c, size_t n)
{
  unsigned char *s = dest;
  size_t k;
  if (!n) return dest;
  s[0] = c;
  s[n-1] = c;
  ....
}

注意以下几行:

1. s[0] = c;
2. s[n-1] = c;


在这里,我们来到原因 N1“取消引用空指针是未定义的行为”。不能保证编译器不会交换赋值。如果您的编译器这样做,并且 n 参数非常有价值,那么一开始就会损坏一个字节的内存。空指针取消引用将仅在该之后发生。

又不服气了?那么,这个实现怎么样?

void *memset(void *dest, int c, size_t n)
{
  size_t k;
  if (!n) return dest;
  s[0] = s[n-1] = c;
  if (n <= 2) return dest;
  ....
}


您甚至不能信任内存集函数。是的,这可能是一个人为和牵强附会的问题。我只是想展示如果不检查指针的值会出现多少细微差别。根本不可能考虑到所有这些。因此,您应该仔细检查 malloc 函数返回的每个指针和类似指针。这就是你成为一名专业人士并编写可靠代码的时候。


结论

始终立即检查由 malloc 函数或其类似物返回的指针。


相关文章
|
14天前
|
存储 算法 C语言
【C语言程序设计——函数】素数判定(头歌实践教学平台习题)【合集】
本内容介绍了编写一个判断素数的子函数的任务,涵盖循环控制与跳转语句、算术运算符(%)、以及素数的概念。任务要求在主函数中输入整数并输出是否为素数的信息。相关知识包括 `for` 和 `while` 循环、`break` 和 `continue` 语句、取余运算符 `%` 的使用及素数定义、分布规律和应用场景。编程要求根据提示补充代码,测试说明提供了输入输出示例,最后给出通关代码和测试结果。 任务核心:编写判断素数的子函数并在主函数中调用,涉及循环结构和条件判断。
52 23
|
14天前
|
算法 C语言
【C语言程序设计——函数】利用函数求解最大公约数和最小公倍数(头歌实践教学平台习题)【合集】
本文档介绍了如何编写两个子函数,分别求任意两个整数的最大公约数和最小公倍数。内容涵盖循环控制与跳转语句的使用、最大公约数的求法(包括辗转相除法和更相减损术),以及基于最大公约数求最小公倍数的方法。通过示例代码和测试说明,帮助读者理解和实现相关算法。最终提供了完整的通关代码及测试结果,确保编程任务的成功完成。
44 15
|
14天前
|
C语言
【C语言程序设计——函数】亲密数判定(头歌实践教学平台习题)【合集】
本文介绍了通过编程实现打印3000以内的全部亲密数的任务。主要内容包括: 1. **任务描述**:实现函数打印3000以内的全部亲密数。 2. **相关知识**: - 循环控制和跳转语句(for、while循环,break、continue语句)的使用。 - 亲密数的概念及历史背景。 - 判断亲密数的方法:计算数A的因子和存于B,再计算B的因子和存于sum,最后比较sum与A是否相等。 3. **编程要求**:根据提示在指定区域内补充代码。 4. **测试说明**:平台对代码进行测试,预期输出如220和284是一组亲密数。 5. **通关代码**:提供了完整的C语言代码实现
54 24
|
10天前
|
存储 C语言
【C语言程序设计——函数】递归求斐波那契数列的前n项(头歌实践教学平台习题)【合集】
本关任务是编写递归函数求斐波那契数列的前n项。主要内容包括: 1. **递归的概念**:递归是一种函数直接或间接调用自身的编程技巧,通过“俄罗斯套娃”的方式解决问题。 2. **边界条件的确定**:边界条件是递归停止的条件,确保递归不会无限进行。例如,计算阶乘时,当n为0或1时返回1。 3. **循环控制与跳转语句**:介绍`for`、`while`循环及`break`、`continue`语句的使用方法。 编程要求是在右侧编辑器Begin--End之间补充代码,测试输入分别为3和5,预期输出为斐波那契数列的前几项。通关代码已给出,需确保正确实现递归逻辑并处理好边界条件,以避免栈溢出或结果
48 16
|
10天前
|
存储 编译器 C语言
【C语言程序设计——函数】分数数列求和2(头歌实践教学平台习题)【合集】
函数首部:按照 C 语言语法,函数的定义首部表明这是一个自定义函数,函数名为fun,它接收一个整型参数n,用于指定要求阶乘的那个数,并且函数的返回值类型为float(在实际中如果阶乘结果数值较大,用float可能会有精度损失,也可以考虑使用double等更合适的数据类型,这里以float为例)。例如:// 函数体代码将放在这里函数体内部变量定义:在函数体中,首先需要定义一些变量来辅助完成阶乘的计算。比如需要定义一个变量(通常为float或double类型,这里假设用float。
21 3
|
10天前
|
存储 算法 安全
【C语言程序设计——函数】分数数列求和1(头歌实践教学平台习题)【合集】
if 语句是最基础的形式,当条件为真时执行其内部的语句块;switch 语句则适用于针对一个表达式的多个固定值进行判断,根据表达式的值与各个 case 后的常量值匹配情况,执行相应 case 分支下的语句,直到遇到 break 语句跳出 switch 结构,若没有匹配值则执行 default 分支(可选)。例如,在判断一个数是否大于 10 的场景中,条件表达式为 “num> 10”,这里的 “num” 是程序中的变量,通过比较其值与 10 的大小关系来确定条件的真假。常量的值必须是唯一的,且在同一个。
12 2
|
14天前
|
存储 编译器 C语言
【C语言程序设计——函数】回文数判定(头歌实践教学平台习题)【合集】
算术运算于 C 语言仿若精密 “齿轮组”,驱动着数值处理流程。编写函数求区间[100,500]中所有的回文数,要求每行打印10个数。根据提示在右侧编辑器Begin--End之间的区域内补充必要的代码。如果操作数是浮点数,在 C 语言中是不允许直接进行。的结果是 -1,因为 -7 除以 3 商为 -2,余数为 -1;注意:每一个数据输出格式为 printf("%4d", i);的结果是 1,因为 7 除以 -3 商为 -2,余数为 1。取余运算要求两个操作数必须是整数类型,包括。开始你的任务吧,祝你成功!
45 1
|
1月前
|
存储 C语言 开发者
【C语言】字符串操作函数详解
这些字符串操作函数在C语言中提供了强大的功能,帮助开发者有效地处理字符串数据。通过对每个函数的详细讲解、示例代码和表格说明,可以更好地理解如何使用这些函数进行各种字符串操作。如果在实际编程中遇到特定的字符串处理需求,可以参考这些函数和示例,灵活运用。
82 10
|
1月前
|
存储 程序员 C语言
【C语言】文件操作函数详解
C语言提供了一组标准库函数来处理文件操作,这些函数定义在 `<stdio.h>` 头文件中。文件操作包括文件的打开、读写、关闭以及文件属性的查询等。以下是常用文件操作函数的详细讲解,包括函数原型、参数说明、返回值说明、示例代码和表格汇总。
63 9
|
1月前
|
C语言 开发者
【C语言】数学函数详解
在C语言中,数学函数是由标准库 `math.h` 提供的。使用这些函数时,需要包含 `#include <math.h>` 头文件。以下是一些常用的数学函数的详细讲解,包括函数原型、参数说明、返回值说明以及示例代码和表格汇总。
57 6

热门文章

最新文章