作为一个在嵌入式系统行业工作了二十多年的人,我见证了技术的巨大进步—从8位微控制器到如今复杂的多核系统。然而,有一件事始终不变:C和C++中的指针。它是一把双刃剑,可以带来惊人的内存管理灵活性,但管理不善也会造成严重破坏。
最近,一个NULL指针导致系统崩溃的事件,清楚地提醒了我们在代码中安全使用指针是多么重要。
在这篇文章中,我们将探讨在C和C++中安全使用指针的最佳实践,确保您的嵌入式系统顺利运行而不会出现意外崩溃。
指针本质上是存储其他变量内存地址的变量。指针可以实现高效的内存操作和动态内存分配,但也会带来风险,最明显的是,取消引用NULL或未初始化的指针可能会导致灾难性的故障。它们还可能导致安全漏洞、覆盖意外位置和其他问题,因此,了解指针的工作原理是安全使用指针的第一步。
要声明一个指针,可以使用*运算符:
int *ptr; // A pointer to an integer
此声明不会为整数分配内存,它只是创建了一个可以指向整数内存位置的指针。在使用指针之前对其进行初始化非常重要,因为使用未初始化的指针可能会导致未定义的行为。(您的编译器可能会将其初始化为0或NULL,或者它可能只是保存分配之前内存的值)。
您可以通过多种方式初始化指针:
1.分配变量地址:
int var = 42;
int *ptr = &var; // ptr now points to var
2.使用动态内存分配:
int *ptr = (int *)malloc(sizeof(int)); // Allocating memory for one integer
if (ptr == NULL) {
// Handle memory allocation failure
}
*ptr = 42; // Assign a value to the allocated memory
使用动态内存分配需要谨慎,尤其是在内存通常受限的嵌入式系统中。由于malloc通常也不是确定性的,因此无法保证运行时的执行,这使得嵌入式系统中的动态内存分配令人生疑。
如果您选择使用动态内存分配,请始终通过验证指针是否为NULL来检查内存分配是否成功。malloc函数将返回指向分配块的内存地址。如果为NULL,则表示操作失败!
(我曾经想通过在我的软件中大量使用malloc来证明同事的错误。我成功了,但后来我为我的傲慢而后悔!)。
创建指针时,最好立即将其分配给有用的对象。未初始化的指针可能指向随机的内存位置,从而导致未定义的行为。您应该始终初始化指针,即使只是用NULL指针初始化它:
int *ptr = NULL; // Initialize to NULL
指向NULL的指针比指向随机位置的指针更好。
将指针初始化为NULL的优点在于,我们可以在取消引用之前检查它以确保它已被初始化。如果值为0x08FF001234,我可能会假设此指针已初始化到正确的位置。(假设是不好的!我们或许可以使用MPU和其他链接器技巧来验证它是否指向正确的区域)。
在取消引用指针之前,请确保它不为NULL。这个简单的检查可以防止崩溃:
if (ptr != NULL) {
// Dereference and do useful work!
} else {
// Pointer is NULL, cannot dereference!
// Exception handling!
}
当我有一个函数指针数组时,我经常使用这个技巧。我可能有这样的表:
typedef void (*LedCommand_t)(void);
LedCommand_t LedCommands[] = {
turnLedOn,
turnLedOff,
NULL
}
表中的最后一项为NULL,因为这样可便于检查。我可以循环遍历该表,然后生成一行代码来调用该函数(只要它不为NULL)!
如果您使用C++,请考虑使用智能指针,例如std::unique_ptr和std::shared_ptr,它们提供自动内存管理功能:
#include
std::unique_ptr ptr(new int(42)); // Automatically deallocates memory
智能指针有助于自动管理内存,减少内存泄漏和悬垂指针的可能性。
指针算法非常强大。我曾经编写过一个应用程序,使用它来移动内存并将数据存储在缓冲区中。这是一种高效的解决方案,但如果使用不当,也会很危险。为了确保这些指针保持在正确的内存边界内,进行一些调试和大量测试是绝对必要的。
确保您保持在分配的内存范围内:
int arr[5] = {1, 2, 3, 4, 5};
int *ptr = arr; // Pointer to the first element
for (int i = 0; i < 5; i++) {
printf("%d\\n", *(ptr + i)); // Accessing elements using pointer arithmetic
}
从上面的例子中你可以看出,虽然指针可能停留在内存边界内,但它充满了神奇的数字,这使得这段代码很危险!要将数组中的元素从5个变为4个,至少需要修改三个地方的代码,因此,如果修改代码,读取缓冲区外数据的几率很高!
在嵌入式系统中,高效管理内存至关重要。工具链文件可以帮助定义内存布局并确保指针正确对齐。使用CMake等工具链时,您可以指定内存设置,确保指针指向正确的位置:
set(MEMORY_START 0x20000000)
set(MEMORY_END 0x2001FFFF)
通过定义内存区域,您可以更有效地管理指针,确保它们指向有效的内存地址。您还可以使用这些内存区域来检查指针值的完整性!为指针赋值并不意味着指针值是正确的。
动态内存分配是指针相关问题的常见来源,这就是为什么在资源受限的系统中我们通常会避免使用它。进入主循环之前使用动态内存可能是安全的,因为这样会使分配对于应用程序来说看起来是静态的。
然而,有时动态分配是完成这项工作的最好的工具。在这种情况下,以下是一些安全使用它的策略:
无论何时分配内存,请确保在不再需要时释放它,以防止内存泄漏:
int *ptr = (int *)malloc(sizeof(int));
if (ptr != NULL) {
*ptr = 42;
free(ptr); // Free the allocated memory
}
无法释放已分配的内存可能会导致内存耗尽,尤其是在长期运行的嵌入式系统中。
释放指针后,将其设置为NULL以避免悬垂指针:
free(ptr);
ptr = NULL; // Prevents accidental dereference
这个简单的步骤可以避免因取消引用已释放的内存而导致的潜在崩溃。
资源获取即初始化(RAII)是C++和面向对象编程语言中的常见原则。它用于将动态内存分配封装在类中。类构造函数分配内存,析构函数释放内存,资源能得到妥善管理。
class Resource {
public:
Resource() {
data = new int[10]; // Allocate memory
}
~Resource() {
delete[] data; // Deallocate memory
}
private:
int* data;
};
在C语言中,强制转换指针可能会隐藏问题并导致未定义的行为。与其这样,不如让编译器处理类型转换:
int *ptr = (int *)malloc(sizeof(int)); // Avoid casting; it's unnecessary in C
让编译器完成其工作可以帮助发现编译过程中的潜在问题。
注意:你必须小心处理这个问题。有些阵营极力推崇显式编程,你必须显式地进行转换。
当多个指针指向同一内存位置时,通过一个指针更改值可能会导致混乱和错误:
int *ptr1 = (int *)malloc(sizeof(int));
int *ptr2 = ptr1;
*ptr1 = 10; // Both ptr1 and ptr2 point to the same memory
printf("Value through ptr2: %d\\n", *ptr2); // Outputs 10
如果您没有留意谁拥有内存,这种情况可能会导致意想不到的行为。
指针是C和C++中的一项强大功能,但它们也存在风险,可能导致NULL指针崩溃等严重问题。通过遵循这些最佳实践(初始化指针、在取消引用之前检查是否为NULL、在C++中使用智能指针以及谨慎管理动态内存),您可以安全地驾驭复杂的指针。
此外,通过利用工具链文件来管理内存布局,您可以确保指针在嵌入式系统中始终有效且表现良好。在发展使用指针的技能时,请记住安全和谨慎应始终是您的指导原则。
(原文刊登于EDN姊妹网站Embedded,参考链接:Best practices to safely navigate pointers in C/C++,由Ricardo Xie编译。)