#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include "hexdump.h"

void f() {
    char* allocated_st1 = (char*)malloc(100);
    char* allocated_st2 = (char*)malloc(100);
    sprintf(allocated_st1, "C programming is cool");
    sprintf(allocated_st2, "Strings are sequences of bytes");

    hexdump(allocated_st1, 100);
    hexdump(allocated_st2, 100);

    free(allocated_st1);
    free(allocated_st2);

    char* allocated_again_st = malloc(100);
    sprintf(allocated_again_st, "C programming is cool");
    hexdump(allocated_again_st, 100);
}

int main() {
    f();
}

behabior


在你的代码中,你首先使用 malloc 动态分配了两块内存空间,分别存储在 allocated_st1allocated_st2 指针中。你然后将字符串写入这些分配的内存中并使用 hexdump 打印内存内容。接着,你释放了这两块内存并再次使用 malloc 分配内存,保存到 allocated_again_st 指针中。

void f() {
    char* allocated_st1 = (char*)malloc(100);
    char* allocated_st2 = (char*)malloc(100);
    sprintf(allocated_st1, "C programming is cool");
    sprintf(allocated_st2, "Strings are sequences of bytes");

    hexdump(allocated_st1, 100);
    hexdump(allocated_st2, 100);

    free(allocated_st1);
    free(allocated_st2);

    char* allocated_again_st = malloc(100);
    sprintf(allocated_again_st, "C programming is cool");
    hexdump(allocated_again_st, 100);
}

heap 释放后内容

  1. 首次分配的内存 (allocated_st1allocated_st2):
    • allocated_st1allocated_st2 分别存储了 “C programming is cool” 和 “Strings are sequences of bytes”。hexdump 函数正确地显示了这些字符串的字节表示。
  2. 释放内存 (free) 后再次分配 (malloc):
    • 当你调用 free(allocated_st1);free(allocated_st2); 后,这些内存块就变成了空闲状态。但它们的内容不会被立即清除或覆盖。接下来,当你再次调用 malloc(100); 时,系统可能会再次分配之前已释放的内存块(或其他任何空闲的内存块),这取决于内存分配器的实现。
    • 在这个例子中,由于刚释放的内存块可能没有被其他操作覆盖,再次分配时,你可能得到了相同的内存块。因此,allocated_again_st 可能指向 allocated_st2 先前使用的内存,这就是为什么你会看到前一次的字符串内容 “C programming is cool”。
  3. 未定义行为:
    • free 之后使用被释放的内存属于未定义行为。即使你看到的内容可能看起来是预期的,但它完全依赖于内存管理器的实现细节和当前内存的状态。在某些情况下,内存分配器可能会给出与之前不同的内存地址,甚至分配新内存块时覆盖旧内容。尽管这次没有出现明显的错误或段错误,但依赖未定义行为是不可取的,因为它不保证一致性和可预测性。

总结: 代码中的未定义行为是由于在 free 之后再使用已释放的内存。在实际应用中,这种行为可能导致难以预测的错误,应该避免。要确保程序的正确性,在 free 之后不应再访问相应的内存地址,也不要假设内存的内容在 free 之后会保持不变。

解决办法

在C语言中,当你释放一个指针指向的内存时,操作系统通常不会将那块内存的内容清空或将指针自动设置为 NULL。为了避免使用已经释放的内存,建议采取以下几种最佳实践:

  1. 将指针设置为 NULL: (测试过不行)
    • 在释放指针指向的内存后,立即将该指针设置为NULL。这样做可以确保在程序中任何尝试访问该指针的操作都能显著地失败,而不会产生未定义行为。尝试解引用一个空指针通常会导致明显的段错误,这可以更容易地检测和调试。
    free(allocated_st1);
    allocated_st1 = NULL;
    
  2. 清零内存内容:
    • 虽然将指针设置为NULL可以避免指针悬挂的问题,但它不会清空释放的内存内容。你可以在释放前使用memset将内存内容置零,不过这在大多数情况下是不必要的,除非你在处理敏感数据(例如密码)。
    memset(allocated_st1, 0, 100);
    free(allocated_st1);
    allocated_st1 = NULL;
    
  3. 双重释放防护:
    • 如果你的代码可能会调用多次free,确保每次free之后将指针设置为NULL,这样即使不小心调用了多次free,也不会出错。
    if (allocated_st1) {
        free(allocated_st1);
        allocated_st1 = NULL;
    }
    
  4. 内存分配追踪:
    • 如果你的程序涉及复杂的内存分配和释放,可以使用内存追踪工具(如Valgrind)来检测内存泄漏、非法内存访问和双重释放等问题。
  5. 使用智能指针(适用于C++):
    • 在C++中,可以使用智能指针(如std::unique_ptrstd::shared_ptr)来自动管理内存的分配和释放,避免手动管理内存时容易出现的错误。

1.方案一:

使用 memset(void* ptr, int c, size_t n) 来让分配的内存用某个东西占满。(memset - fill memory with a constant byte

#include <string.h>
void *memset(void *s, int c, size_t n);


char* allocated_again_st = malloc(100 * sizeof(char));
memset(allocated_again_st, '\0', 100 * sizeof(char));
sprintf(allocated_again_st, "C programming is cool");
hexdump(allocated_again_st, 100);
free(allocated_again_st);

2. 方案二: 直接使用 void *calloc(size_t nmemb, size_t size); 来分配动态内存的时候直接初始化。

==The calloc () function allocates memory for an array of nmemb elements of size bytes each and returns a pointer to the allocated memory. The memory is set to zero. ==

char* allocated_again_st = calloc(100, sizeof(char));
//char* allocated_again_st = malloc(100 * sizeof(char));
sprintf(allocated_again_st, "C programming is cool");
hexdump(allocated_again_st, 100);
free(allocated_again_st);


栈区内容

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include "hexdump.h"

char global_ch = 'A';
const char const_global_ch = 'B';
static char static_global_ch = 'D';

char* g()
{
	char ch =  'E';
	char* ptr = &ch;
	//hexdump(ptr,1);
	return ptr;
}

void f() {
    char local_ch = 'C';

    hexdump(&global_ch, 1);
    hexdump(&const_global_ch, 1);
    hexdump(&local_ch, 1);
    hexdump(&static_global_ch,1);
    
    char* ptr = g();
    printf("the char is %c\n",*ptr);
    hexdump(ptr,1);
}

int main() {
    f();
}
输出:

0x6050066e1038  41          |A|
0x6050066df004  42          |B|
0x7fff51a895f6  43          |C|
0x6050066e1039  44          |D|
the char is 
0x7fff51a895f7  00          |.|

将注释去掉后,输出:
0x592457427038  41          |A|
0x592457425004  42          |B|
0x7ffe43f1793f  43          |C|
0x592457427039  44          |D|
0x7ffe43f1793e  45          |E|
the char is E
0x7ffe43f1793e  45          |E|
这是为什么

这个问题涉及到栈内存的行为和未定义行为对程序执行的影响。让我们逐步分析代码并解释输出的不同原因。

原始代码分析

  1. 全局和静态变量的地址:
    • global_chconst_global_ch 是全局变量,static_global_ch 是静态变量。这些变量通常存储在全局/静态内存区,它们的地址在程序运行期间是固定的。
  2. 局部变量和栈帧:
    • 在函数 f() 中,local_ch 是一个局部变量,存储在栈上。同样,在函数 g() 中,ch 也是一个局部变量,存储在栈上。

行为解释

未定义行为和栈内存

g() 函数中,局部变量 ch 在函数结束时就不再有效,因为 g() 的栈帧被回收。此时,返回的指针 ptr 指向的地址内容变得不可预测,这是未定义行为的典型例子。

char* g()
{
	char ch =  'E';
	char* ptr = &ch;
	return ptr;
}

输出差异的原因

  1. 未注释的 hexdump(ptr, 1);:
    • g() 中调用 hexdump(ptr, 1); 打印了局部变量 ch 的内容。这可能会导致编译器在优化时将 ch 的值保存在栈中而不是立即回收,从而在 f() 中的 ptr 仍然指向 E。因此,在 f() 中打印 *ptr 时,显示了 E
  2. 注释掉 hexdump(ptr, 1);:
    • g() 函数中的 hexdump 调用被注释掉后,ch 的值没有被使用或保存,编译器可能会在 g() 返回后立即覆盖 ch 所在的内存。这导致在 f()*ptr 的值为 0,因为指向的内存地址不再保存 E

未定义行为导致程序的执行和输出不可预测,这也是为什么移除 hexdump(ptr, 1); 后,*ptr 的值变为 0(空字符)。即使在同一程序中,代码的执行顺序、编译器的优化策略、以及内存管理的细微变化都可能影响未定义行为的结果。

解决方法: 避免返回局部变量的地址。可以使用动态分配的内存或者全局/静态变量来确保返回的指针有效。

结论

是的,当 hexdump(ptr, 1); 没有被注释掉时,局部变量 ch 的值在函数 g() 内被使用了,因此编译器可能会选择将其值保留在栈中,而不是立即回收或覆盖。这种行为取决于编译器的优化策略和代码的具体情况。

编译器在优化过程中会根据上下文分析哪些变量的值可能会在后续代码中使用,或者哪些内存可能被访问。由于 hexdump(ptr, 1); 在函数 g() 中使用了指向 ch 的指针 ptr,编译器可能假设 ptr 所指向的内存仍然有效,并保留该内存中的数据。这样,当 f() 函数中的 ptr 仍然指向 ch 的地址时,仍能读取到正确的值 E

然而,这种行为并不保证在所有情况下都能生效,因为它依赖于编译器如何管理栈内存。未定义行为的特性在于它没有任何保证,这意味着不同的编译器、编译选项甚至程序运行环境都可能导致不同的结果。

总之,这种情况说明了为什么未定义行为是危险的,因为它可能会导致程序的不确定性。为了避免这种不确定性,应该避免在函数中返回局部变量的指针,确保返回的指针指向有效且可用的内存区域。

类型

编程中的未定义行为 (UB),尤其是在 C 和 C++ 等语言中,是指可能产生不可预测结果的代码,包括崩溃、错误结果,甚至在某些情况下似乎可以正常工作。C 和 C++ 标准没有规定针对此类情况的任何特定行为,而是由实现(编译器和运行时环境)决定如何处理它们,这通常意味着它们根本没有做任何特殊的事情。

未定义行为有几大类:

  1. 无效内存访问:
    • 取消引用空指针: 通过空指针访问内存会导致未定义行为。
    • 取消引用未初始化的指针: 使用未初始化的指针可能会导致未定义行为。
    • 越界数组访问: 访问数组边界之外的元素。
    • 使用悬垂指针: 在内存被释放或对象超出范围后通过指针访问内存。
    • 未对齐的内存访问:使用不遵守数据对齐要求的指针访问数据。
  2. 整数溢出
  • 有符号整数溢出:有符号整数(正数和负数)溢出会导致未定义的行为。但是,根据模块化算法,无符号整数溢出被定义为回绕。
  1. 未指定或不确定的值
  • 未初始化变量的使用:在初始化变量之前使用变量可能会导致未定义的行为,因为它们可能包含垃圾值。

  • 读取联合中未初始化的成员:访问联合中不是最后一个写入的成员。

  1. 违反类型规则
  • 通过指针进行类型双关:使用不同类型的指针(字符类型除外)访问对象可能会导致未定义的行为。
  • 不当使用可变参数:在可变参数函数中误用 va_list 或未提供足够的参数。
  1. 非法操作
    • 除以零:将整数除以零。
    • 修改 const 对象:尝试通过非常量引用或指针修改声明为 const 的对象。
    • 数据竞争:多个线程同时访问同一内存位置,其中至少一次访问是写入,并且没有同步机制。
  2. 流控制违规
    • 从非空函数返回而没有值:如果函数声明为返回非空类型,但返回时没有提供返回值。
    • 到达非空函数的末尾而没有返回语句:同样,如果控制到达非空函数的末尾而没有返回语句,则会导致未定义的行为。
  3. 无效的对象使用:
    • 在对象的生命周期结束后使用它: 在对象被销毁或生命周期结束后访问或修改对象。
    • 在结构体被释放后访问结构体的成员: 类似于使用悬垂指针。
  4. 特定于库的未定义行为:
    • 违反标准库函数的约束: 许多标准库函数都有先决条件,例如将有效指针传递给 strlen()。违反这些约束通常会导致未定义的行为。

理解和避免未定义的行为对于编写健壮且可移植的代码至关重要。许多现代编译器提供标志或工具(如 GCC/Clang 中的 -fsanitize=undefined)来帮助在开发过程中检测和诊断未定义的行为。