为什么MISRA C规定指针的复制可能会导致内存异常?

38

MISRA C 2012指令4.12是"不应使用动态内存分配"。

作为示例,该文档提供了以下代码示例:

char *p = (char *) malloc(10);
char *q;

free(p);
q = p; /* Undefined behaviour - value of p is indeterminate */

该文档指出:

尽管在调用free后指针中存储的值未更改,但在某些目标上,它所指向的内存可能已不再存在,复制该指针的行为可能会引起内存异常

我对这个句子几乎都没问题,但是最后一句话有点疑问。由于p和q都是分配在堆栈上的,复制指针如何引起内存异常?


指针 p 是堆栈上的本地变量,但它指向堆。如果在代码片段之后解引用 q,则会出现未定义行为 - Basile Starynkevitch
5
可能在此之前就已经发生了,因为可以看到2501的答案。 - Deduplicator
4
过度反应的典型例子。由于动态分配可能被误用,因此“不应该使用”。猜猜看?按照这种逻辑,在编写 C 代码时,你可能应该限制自己只使用 unsigned int。但即使是 unsigned 也可能会被误用。 - MSalters
4
在x86的16位保护模式下,加载无效指针(更准确地说是无效选择器)会导致处理器异常,因此这不仅仅是一个理论问题。请参阅Intel® 64和IA-32体系结构软件开发人员手册第2卷中MOV指令的相关内容。 - user786653
2
请注意,MISRA不是普通的编码标准。它适用于航空航天和医疗设备等嵌入式系统的上下文中。其原因不是“它可能被误用”,而是“对于我们的应用程序很少需要,并且不使用它可以防止一类难以强健处理的运行时错误(内存不足),而在我们的应用程序中,强健性至关重要”。当然,“应该”并不等同于“必须”,正如toto所解释的那样。 - user395760
显示剩余4条评论
8个回答

44

根据标准规定,复制指针q = p;是未定义行为。

阅读《J.2 未定义行为》可以得知:

使用了已经结束生命周期对象的指针值(6.2.4)。

到达这个章节我们可以看到:

6.2.4 对象的存储期

一个对象的生命周期是程序执行期间保证为其保留存储空间的部分。一个对象存在,具有恒定的地址33),并在其生命周期内保持其最后存储的值34)。如果在其生命周期外引用对象,则行为未定义。当指向的对象(或者刚好超出)到达其生命周期的末尾时,指针的值变得不确定。

什么是不确定的:

3.19.2 不确定的值: 不确定的值可能是未指定的值或trap表示形式


5
有些架构实际上规定,所有指针如果没有指向有效的内存(或者只是在其后面),就属于陷阱表示。+1 - Deduplicator
8
http://www.ibm.com/developerworks/library/pa-ctypes3/ 这篇文章对陷阱值的背景进行了很好地解释。 - Blagovest Buyukliev
1
感谢大家的回复和链接。 - toto
3
即使在没有陷阱表示的实现中,这是为什么会产生问题的一个例子,考虑将最后一行替换为q = malloc(10); if (p==q) ...会发生什么。请注意,此处涉及UB,需特别关注。 - R.. GitHub STOP HELPING ICE

14

一旦通过指针释放对象,所有指向该内存的指针都变得不确定。即使是reading不确定的内存也是未定义行为(UB)。以下是UB:

char *p = malloc(5);
free(p);
if(p == NULL) // UB: even just reading value of p as here, is UB
{

}

1
啊,这就对了,有人懂了。(请注意,这只是因为编译器可以假定标准库函数存在。) - Joshua
1
如果您使用了标准库中的 malloc,但是您覆盖了 free 的功能,那么代码将不会有未定义的行为。但是,由于编译器可以假定 free 确实是标准库函数,因此它可以执行优化,这将导致代码变得未定义。 - kasperd
1
@barakmanos - 这是C标准规定的。在free()之后,指针是不确定的。 - Andrew
1
@Andrew:这不是一个有逻辑推理的实用性答案。听起来更像是一个神学上的回答(就像“因为上帝这样说”)。 - barak manos
1
@Andrew:人们相互残杀,因为他们声称某个地方写着他们应该这样做(即“标准规定”)。就我个人而言,我怀疑他们有足够好的理由这样做,但即使有,绝对不是因为他们所谓的“标准”规定的原因。 - barak manos
显示剩余5条评论

4
首先,让我们了解一些历史...
当ISO/IEC JTC1/SC22/WG14开始正式制定C语言(现在的 ISO/IEC 9899:2011)时,他们遇到了一个问题。
许多编译器供应商对事物的解释有所不同。
早期,他们决定不破坏任何现有功能...因此,在编译器实现存在分歧的情况下,标准提供了未指定和未定义的行为。
MISRA C试图捕捉这些行为将触发的陷阱。以上就是理论...
现在进入这个问题的具体内容:
考虑到free()的目的是将动态内存释放回堆中,有三种可能的实现方式,而所有这些方式都已经“在野外”中被使用:
1.将指针重置为NULL; 2.保持指针原样; 3.销毁指针。
标准不能强制要求其中的任何一种,因此形式上将行为留空为未定义——您的实现可以遵循一条路径,但不同的编译器可能会执行其他操作...您不能假设,并且依赖于某一种方法是危险的。
就我个人而言,我宁愿标准非常明确,并要求free()将指针设为NULL,但这只是我的个人意见。
所以TL;DR答案是,不幸的是:因为它就是这样!

3
啊?由于free()的标准声明是void free(void *ptr);,因此编译器无法处理指针本身,只能处理其内容。编译器不能将其设置为NULL或“销毁它”(你如何销毁指针?),或以花哨的、实现定义的方式执行其他操作,因为_free函数只能访问指针的本地副本_。无论如何,它都不能影响调用者版本的指针。你必须改变C标准为free (void**),但这不可能发生。因此,C标准间接规定了上述第2点事项。 - Lundin
改变C标准是不会发生的,未定义的行为仍将保持未定义! - Andrew
如果free是一个函数,那么它不能像C++中的delete一样始终将指针置为NULL。它必须是一个操作符。 - Antti Haapala -- Слава Україні

3
虽然pq都是栈上的指针变量,但malloc()返回的内存地址不在栈上。
一旦成功分配了一个内存区域,如果释放了它,就无法确定谁会使用该内存区域或该内存区域的状态。
因此,一旦用free()释放了之前使用malloc()获取的内存区域,尝试使用该内存区域将产生未定义类型的行为。你可能会运气好,它能正常工作;你可能会倒霉,它不能正常工作。一旦你释放了一个内存区域,你不再拥有它,有其他的东西拥有它。
这里的问题似乎在于从一个内存位置复制一个值到另一个位置所涉及的机器码。请记住,MISRA面向嵌入式软件开发,因此问题总是与执行某些特殊功能的奇怪处理器有关。
MISRA标准关注的是软件的健壮性、可靠性和消除软件故障风险。它们要求非常严格。

6
问题不在于已分配的内存,而在于指针本身。 - toto
1
@toto,是的,我意识到它与指针本身有关。内存分配是一个引子,因为指针指向一个malloced区域。请看第四段。 - Richard Chambers
1
是的,谢谢您的回复。我觉得您误解了我的问题,因为您在前三段中的回答。 - toto
“未定义(undefined)”更多是由于高级处理器而不是简单的嵌入式处理器。 - H H
你假设本地变量在堆栈上...但这并不一定是这样。但无论如何,这都不重要! - Andrew
@Andrew,由于代码片段显示了使用malloc()分配变量定义,并且问题实际上包含短语“As p和q都在堆栈上分配”,因此前提似乎是成立的。 - Richard Chambers

3
p指向的内存被释放后,不能直接使用它的值。同样地,未初始化指针的值也有相同的状态:即使只是为了复制而读取它,也会导致未定义的行为。
这个令人惊讶的限制的原因是可能存在陷阱表示法。释放p所指向的内存可能会使其值成为一个陷阱表示法。
我记得在上世纪90年代初曾经有一个目标机,它的行为就是这样的。当时不是嵌入式系统,而是广泛使用的Windows 2.x。它使用Intel架构的16位保护模式,在这种模式下,指针宽度为32位,具有16位选择器和16位偏移量。为了访问内存,需要使用特定的指令将指针加载到一对寄存器中(一个段寄存器和一个地址寄存器)。
    LES  BX,[BP+4]   ; load pointer into ES:BX

将指针值的选择器部分加载到段寄存器中会具有验证选择器值的副作用:如果选择器没有指向有效的内存段,就会触发异常。

编译看似无害的语句q = p;可以以许多不同的方式编译:

    MOV  AX,[BP+4]    ; loading via DX:AX registers: no side effects
    MOV  DX,[BP+6]
    MOV  [BP-6],AX
    MOV  [BP-4],DX

或者
    LES  BX,[BP+4]    ; loading via ES:BX registers: side effects
    MOV  [BP-6],BX
    MOV  [BP-4],ES

第二个选择有两个优点:
  • 代码更加紧凑, 减少了1条指令

  • 指针值被加载到可以直接用于解引用内存的寄存器中,这可能会导致生成的后续语句所需的指令更少。

释放内存可能会取消映射段并使选择器无效。该值成为trap值,并在某些体系结构上将其加载到ES:BX中会触发异常,也称为陷阱
并非所有编译器都会仅使用LES指令来复制指针值,因为它更慢,但一些编译器会在指示生成紧凑代码时使用它,当时通常是常见选择,因为内存相对昂贵且稀缺。
C标准允许这样做,并描述了一种未定义行为的代码形式,其中:

使用了对象生命周期已结束的指针的值(6.2.4)。

因为按照这种方式定义,此值变得不确定:

3.19.2 不确定的值:未指定的值或陷阱表示

但请注意,您仍然可以通过字符类型进行别名处理来操作该值:
/* dumping the value of the free'd pointer */
unsigned char *pc = (unsigned char*)&p;
size_t i;
for (i = 0; i < sizeof(p); i++)
    printf("%02X", pc[i]);   /* no problem here */

/* copying the value of the free'd pointer */
memcpy(&q, &p, sizeof(p));   /* no problem either */

0

即使指针未被解除引用,检查释放后的指针的代码存在两个问题:

  1. C标准的作者不希望干扰在指针包含有关周围内存块信息的平台上实现语言的情况,并且可能会在任何使用指针的地方验证这些指针,无论它们是否被解除引用。如果存在这样的平台,则违反标准的代码可能无法与它们一起使用。

  2. 一些编译器基于这样的假设:程序永远不会接收到任何组合的输入,这些输入将调用UB,因此应该假定任何产生UB的输入组合都是不可能的。由于这个原因,即使是对目标平台没有不利影响的UB形式,如果编译器简单地忽略它们,也可能产生任意和无限的副作用。

在我看来,对已释放指针进行相等、关系或指针差异运算符操作不应对任何现代系统产生负面影响,但由于编译器流行应用疯狂的“优化”,有用的构造在常见平台上变得危险。


-1

示例代码中的措辞不准确,让您感到困惑。

它说“p的值是不确定的”,但不是p的值不确定,因为p仍然具有相同的值(已释放的内存块的地址)。

调用free(p)不会更改p - 只有在定义p的范围之外离开时才会更改p。

相反,p指向的内容的值是不确定的,因为内存块已被释放,而且可能被操作系统取消映射。通过p或别名指针(q)访问它可能会导致访问冲突。


5
当然,指针p所指向的值是不确定的,但这里讨论的话题是指针p本身。样例中使用的措辞是正确的。请检查其他人提供的回答。 - toto
3
标准文件中提供了“不确定”的定义(请参见2501回答中的3.19.2)。在陷阱表示法的情况下,值无法被确定,因为读取/复制该值会触发异常。 - Mike Strobel
@Mike Strobel: 标准不应重新定义常用词以适应其破损的定义。单词“indeterminate”已经有明确定义,除非重新定义什么是“indeterminate”,否则指针能够成为indeterminate的唯一方法是如果它能够具有NaN的值,因为分配给指针变量的每个其他数字值都是有效的。无效的是取消引用未映射到实际内存并由其支持的数值。 - Igor Levicki
@IgorLevicki:如果标准对程序执行某些操作没有任何要求,即使在目标平台上存在明确和逻辑的行为,符合规范的实现也可能以任意方式行事。我不认为优质的实现应该在这种情况下费力地表现出无用的行为,但是像gcc和clang这样的“现代”编译器的作者则持有不同看法。 - supercat
1
@IgorLevicki: GCC和clang有时会决定,如果使用特定值调用函数将导致UB,则可以省略任何条件测试,即使该条件测试不会防止UB。例如,在gcc中,unsigned mul(unsigned short x, unsigned short y) {return x*y;}在乘积的算术值介于INT_MAX+1u和UINT_MAX之间的情况下可能会干扰周围代码的行为。 - supercat
显示剩余19条评论

-3
一个重要的概念是理解“不确定”或“未定义”行为的含义。它确实是未知和无法预测的。我们经常告诉学生:“你的电脑变成一个无形的块,或者磁盘飞到火星上都是完全合理的”。当我阅读原始文档时,并没有看到任何地方说不要使用malloc。它只是指出错误的程序将失败。实际上,程序发生内存异常是一件好事,因为它立即告诉您程序有缺陷。我不明白为什么这份文件会认为这可能是一件坏事。最糟糕的是,在大多数架构上,它不会引发内存异常。继续使用该指针将产生错误值,可能使堆无法使用,并且如果为不同用途分配了相同的存储块,则会损坏该用途的有效数据或将其值解释为您自己的值。底线:不要使用“过时”的指针!换句话说,编写有缺陷的代码意味着它不起作用。
此外,将p分配给q的行为绝对不是“未定义”的。存储在变量p中的位(无意义的胡言乱语)很容易且正确地复制到q中。现在这意味着由p访问的任何值现在也可以由q访问,并且由于p是未定义的胡言乱语,因此q现在也是未定义的胡言乱语。因此,使用它们中的任何一个来读取或写入都会产生“未定义”的结果。如果您足够幸运,正在运行可以导致内存故障的架构,则可以轻松检测到不当使用。否则,使用任一指针意味着您的程序有缺陷。计划花费大量时间找到它。

7
不,这是错误的。 p 可能是一个“陷阱表示”,因此简单地复制它将会导致错误。 - nobody
@AndrewMedico:甚至 NULL 指针也不是 "陷阱表示法",否则您将无法将 0 加载到任何 CPU 寄存器中而不触发未定义的行为。 - Igor Levicki
5
NULL并不是被释放的指针值,但已被释放的指针值可能会是NULL。请参阅http://www.ibm.com/developerworks/library/pa-ctypes3/(由@BlagovestBuyukliev在2501的优秀答案中提供链接)。 - nobody
我读到了这句话——“指向已释放内存的指针……变得不确定”,但实际上指针并没有变得不确定,因为它的值在保存它的位置被覆盖之前是已知的。 - Igor Levicki
这是为了适应某些处理器,在加载地址寄存器时进行一定量的地址验证。char *q 可以在一个特殊的寄存器中,该寄存器会验证任何输入。 - QuentinUK

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