8.C 指针

C 指针

数据在内存中的地址也称为指针,如果一个变量存储了一份数据的指针,我们就称它为指针变量

1.解引用和取地址运算符

*运算符:

运算符用于解引用指针,访问指针所指向的内存位置中的值。当用于指针变量前时, 运算符用于解引用该指针,即访问指针所指向的内存位置中的数据。解引用操作相当于获取存储在指针所指向地址处的值。注意:在定义指针变量时(int *p)* 只起修饰作用,表示p是一个指针变量。

& 运算符:

& 运算符用于获取变量的内存地址,生成指向该变量的指针。当用于变量前时,&运算符用于获取变量的内存地址,即生成一个指向该变量的指针。& 运算符只能应用于具有内存地址的变量、数组元素或结构体成员,不能应用于临时表达式、寄存器变量或纯右值。

这两个运算符在指针相关的操作中起到互补作用,一个用于从地址访问值,另一个用于获取值的地址。它们共同构成了C语言中对内存的间接访问和管理的基础。

2.指针定义

指针也就是内存地址,指针变量是用来存放内存地址的变量。就像其他变量或常量一样,必须在使用指针存储其他变量地址之前,对其进行声明。指针变量声明的一般形式为:

type *var_name;

在这里,type 是指针的基类型,它必须是一个有效的 C 数据类型,var_name 是指针变量的名称。所有实际数据类型,不管是整型、浮点型、字符型,还是其他的数据类型,对应指针的值的类型都是一样的,都是一个代表内存地址的长的十六进制数。不同数据类型的指针之间唯一的不同是,指针所指向的变量或常量的数据类型不同。

在变量声明的时候,如果没有确切的地址可以赋值,为指针变量赋一个 NULL 值。赋为 NULL 值的指针被称为指针。NULL 指针是一个定义在标准库中的值为零的常量(0x0)。

img

3.指针使用

指针变量存储了数据的地址,通过指针变量能够获得该地址上的数据,格式为:

*pointer;

这里的*称为指针运算符,用来取得某个地址上的数据,请看下面的例子:

#include <stdio.h>
int main(){
    int a = 15;
    int *p = &a;
    printf("%d, %d\n", a, *p);  //两种方式都可以输出a的值
    return 0;
}
15, 15

假设 a 的地址是 0X1000,p 指向 a 后,p 本身的值也会变为 0X1000,*p 表示获取地址 0X1000 上的数据,也即变量 a 的值。从运行结果看,*p 和 a 是等价的。

*在不同的场景下有不同的作用:*可以用在指针变量的定义中,表明这是一个指针变量,以和普通变量区分开;使用指针变量时在前面加*表示获取指针指向的数据,或者说表示的是指针指向的数据本身。

4.指针的算术运算

C 指针是一个用数值表示的地址。因此,可以对指针执行算术运算。可以对指针进行四种算术运算:++、--、+、-。

假设 ptr 是一个指向地址 1000 的整型指针,是一个 32 位的整数,让我们对该指针执行下列的算术运算:

int *ptr = &a //1000
char *ptr = &a //1000  
ptr++

在执行完上述的运算之后,ptr 将指向位置 1004,因为 ptr 每增加一次,它都将指向下一个整数位置,即当前位置往后移 4 字节。这个运算会在不影响内存位置中实际值的情况下,移动指针到下一个内存位置。如果 ptr 指向一个地址为 1000 的字符,上面的运算会导致指针指向位置 1001,因为下一个字符位置是在 1001。

  • 指针的每一次递增,它其实会指向下一个元素的存储单元。
  • 指针的每一次递减,它都会指向前一个元素的存储单元。
  • 指针在递增和递减时跳跃的字节数取决于指针所指向变量数据类型长度,比如 int 就是 4 个字节。

注意:我们喜欢在程序中使用指针代替数组,因为变量指针可以递增,而数组不能递增,数组可以看成一个指针常量,所以在可以把数组名这个指针常量赋值给一个指针变量后,就可以操作数组这块地址了。

#include <stdio.h>
 
const int MAX = 3;
 
int main ()
{
   int  var[] = {10, 100, 200};
   int  i, *ptr;
 
   /* 指针中的数组地址 */
   ptr = var;
   for ( i = 0; i < MAX; i++)
   {
 
      printf("存储地址:var[%d] = %p\n", i, ptr );
      printf("存储值:var[%d] = %d\n", i, *ptr );
 
      /* 指向下一个位置 */
      ptr++;
   }
   return 0;
}

5.指针数组和数组指针的区别

1.指针数组

指针数组:指针数组可以说成是”指针类型的数组”,首先这个变量是一个数组。

其次,”指针”修饰这个数组,意思是说这个数组的所有元素都是指针类型。

在 32 位系统中,指针占四个字节。

int *p1[3];

中括号[]的优先级高,因此与 p1 先结合,表示一个数组,这个数组中有 3 个元素,这 3 个元素都是指针,它们指向的是 int 型数据。

2.数组指针

数组指针:数组指针可以说成是”数组的指针”,首先这个变量是一个指针。

数组指针本质是指针变量,保存的是数组的首地址。

其次,”数组”修饰这个指针,意思是说这个指针存放着一个数组的首地址,或者说这个指针指向一个数组的首地址。

int (*p1)[3];

小括号让 p1 与 * 结合,表示 p1 是一个指针,这个指针指向了一个数组,虽然数组首地址与数组首元素地址是同一位置,但是根据定义时它们的指向不同,所以跨度也不同。

数组名是数组首元素的地址,但是有两个例外。

  • sizeof(数组名),数组名放进了sizeof内部,而sizeof是计算变量或数组在内存中存储空间的大小,所以这里计算的是整个数组在内存中占据的空间。
  • &数组名,数组名前加上取地址符号,这里取出的则是数组全部元素的地址。

6.函数指针和指针函数的区别

1.函数指针(指向函数的指针)

函数指针是指可以指向函数的指针变量,即指向函数的地址,保存的是函数的入口地址。

函数指针变量的声明:

typedef int (*fun_ptr)(int,int); // 声明一个指向同样参数、返回值的函数指针类型
#include <stdio.h>
 
int max(int x, int y)
{
    return x > y ? x : y;
}
 
int main(void)
{
    /* p 是函数指针 */
    int (* p)(int, int) = & max; // &可以省略
    int a, b, c, d;
 
    printf("请输入三个数字:");
    scanf("%d %d %d", & a, & b, & c);
 
    /* 与直接调用函数等价,d = max(max(a, b), c) */
    d = p(p(a, b), c); 
 
    printf("最大的数字是: %d\n", d);
 
    return 0;
}
/*
size_t 是一种数据类型,近似于无符号整型,但容量范围一般大于 int 和 unsigned。这里使用 size_t 是为了保证 arraysize 变量能够有足够大的容量来储存可能大的数组。
*/

一个函数在编译之后,会占据一部分内存,而它的函数名,就是这段函数的首地址。可以把一个指针声明成为一个指向函数的指针。

C 语言规定函数名会被转换为指向这个函数的指针,因此直接引用函数名等效于在函数名上应用 & 运算符。

函数指针的主要用途是作为函数的参数,例如作为回调函数的参数、在运行时动态调用不同的函数等等。

函数指针的使用场景包括:

  • 回调函数,将函数指针作为参数传递给另一个函数,使得另一个函数可以调用该函数;
  • 运行时动态调用不同的函数,通过函数指针可以在运行时动态调用不同的函数;
  • 实现函数表,函数指针可以作为数组元素,实现函数表等等。

2.指针函数(返回值为指针的函数)

指针函数是指返回值为指针类型的函数,即函数的返回值是一个指针。

int *func(int a, int b);

这里的 func 函数返回一个 int 类型的指针。

C 语言的库函数中有很多都是指针函数,比如字符串处理函数,下面给出一些函数原型:

char *strcat( char *dest, const char *src );
char *strcpy( char *dest, const char *src );
char *strchr( const char *s, int c );
char *strstr( const char *src, const char *sub );

C 不支持在调用函数时返回局部变量的地址,除非定义局部变量为 static 变量。因为局部变量是存储在内存的栈区内,当函数调用结束后,局部变量所占的内存地址便被释放了,因此当其函数执行完毕后,函数内的变量便不再拥有那个内存地址,所以不能返回其指针。

除非将其变量定义为 static 变量,static 变量的值存放在内存中的静态数据区,不会随着函数执行的结束而被清除,故能返回其地址。

指针函数的主要用途是返回一个指针类型的值,例如返回动态分配的内存地址、返回指向结构体的指针等等。

指针函数的使用场景包括:

  • 动态分配内存并返回其地址;
  • 返回指向结构体的指针;
  • 返回字符串的指针;
  • 返回函数指针。

7.C 指向指针的指针

指向指针的指针是一种多级间接寻址的形式,或者说是一个指针链。通常,一个指针包含一个变量的地址。当我们定义一个指向指针的指针时,第一个指针包含了第二个指针的地址,第二个指针指向包含实际值的位置。

C 中指向指针的指针

一个指向指针的指针变量必须如下声明,即在变量名前放置两个星号。例如,下面声明了一个指向 int 类型指针的指针:

int **var;

当一个目标值被一个指针间接指向到另一个指针时,访问这个值需要使用两个星号运算符,如下面实例所示:

#include <stdio.h>
 
int main ()
{
   int  V;
   int  *Pt1;
   int  **Pt2;
 
   V = 100;
 
   /* 获取 V 的地址 */
   Pt1 = &V;
 
   /* 使用运算符 & 获取 Pt1 的地址 */
   Pt2 = &Pt1;
 
   /* 使用 pptr 获取值 */
   printf("var = %d\n", V );
   printf("Pt1 = %p\n", Pt1 );
   printf("*Pt1 = %d\n", *Pt1 );
    printf("Pt2 = %p\n", Pt2 );
   printf("**Pt2 = %d\n", **Pt2);
 
   return 0;
}

8.内存相关知识

1.32/64位系统处理器架构区别

1. 寄存器宽度

  • 32位系统:处理器内部的通用寄存器(如eax、ebx等)宽度为32位,即一次可以存放4字节(32位)的数据。这决定了处理器一次操作所能处理的数据量上限。

  • 64位系统:处理器的通用寄存器宽度为64位,即一次可以存放8字节(64位)的数据。与32位系统相比,一次操作能处理的数据量翻倍。

2. 数据总线宽度

  • 32位系统:数据总线宽度为32位,即一次数据传输可以携带32位的数据。这直接影响了处理器与内存、外设之间数据交换的速度。

  • 64位系统:数据总线宽度为64位,一次数据传输可以携带64位的数据。数据吞吐能力相较于32位系统提升了一倍。

3. 地址总线宽度

  • 32位系统:地址总线宽度也为32位,意味着处理器可以直接寻址的最大内存空间为2^32字节,即大约4 GB(系统为内存的每一个字节 分配一个32位的地址编号)。由于二进制中每位可以表示两种状态(0或1),一个32位地址可以表示2^32种不同的组合。超出这个范围的内存需要通过其他手段(如PAE)间接访问。

  • 64位系统:地址总线宽度为64位,理论上能够直接寻址的内存空间为2^64字节,约18.4 EB,实际受硬件限制远远达不到18.4 EB(系统为内存的每一个字节 分配一个64位的地址编号)。同理,一个64位地址可以表示2^64种不同的组合。

4. 指令集与架构特性

  • 32位系统:通常基于如IA-32(Intel Architecture, 32-bit)等32位指令集架构,支持的指令集和处理器特性针对32位数据宽度进行了优化。

  • 64位系统:基于如x86-64(AMD64、Intel 64)等64位指令集架构,不仅扩展了寄存器宽度和地址空间,还引入了新的指令、寻址模式、寄存器组等高级特性,以支持更大规模的数据处理、更好的多任务并发和更高效的内存管理。

5. 性能与效率

  • 32位系统:对于大部分日常任务和轻量级应用,32位系统通常能够胜任。但在处理大量数据、多线程计算、高端图形渲染等高负载任务时,其性能可能受限于寻址能力和处理器架构。

  • 64位系统:得益于更大的寄存器宽度、更高的内存寻址能力和更宽的数据总线,64位系统在处理大数据集、多核并行计算、复杂科学计算、专业图形处理等方面通常表现出更强的性能。尤其在需要大量内存的应用中,64位系统的性能优势更为明显。

2.bit、Byte、KB、MB、GB

位(bit)、字节(Byte)、千字节(KB)、兆字节(MB)、吉字节(GB)等是计算机中用来衡量数据存储容量和传输速率的常用单位。它们之间的关系如下:

  1. 位(bit)

    • 是信息存储的最小单位,表示一个二进制数的0或1。
  2. 字节(Byte)

    • 计算机中基本的数据处理单元,通常定义为8位(bits)。
    • 1字节(Byte) = 8位(bits)
  3. 千字节(KB, Kilobyte)

    • 1千字节(KB) = 1024字节(Bytes),这是因为计算机领域传统上使用2的幂次来定义这些单位,以更有效地对应于二进制计算。
    • 1KB = 2^10 B = 1024 B
  4. 兆字节(MB, Megabyte)

    • 1兆字节(MB) = 1024千字节(KB)。
    • 1MB = 2^20 B = 1024 KB
  5. 吉字节(GB, Gigabyte)

    • 1吉字节(GB) = 1024兆字节(MB)。
    • 1GB = 2^30 B = 1024 MB

以此类推,还有更大的单位:

  • 太字节(TB, Terabyte)

    • 1太字节(TB) = 1024吉字节(GB)。
    • 1TB = 2^40 B = 1024 GB
  • 拍字节(PB, Petabyte)

    • 1拍字节(PB) = 1024太字节(TB)。
    • 1PB = 2^50 B = 1024 TB
  • 艾字节(EB, Exabyte)

    • 1艾字节(EB) = 1024拍字节(PB)。
    • 1EB = 2^60 B = 1024 PB
  • 泽字节(ZB, Zettabyte)

    • 1泽字节(ZB) = 1024艾字节(EB)。
    • 1ZB = 2^70 B = 1024 EB
  • 尧字节(YB, Yottabyte)

    • 1尧字节(YB) = 1024泽字节(ZB)。
    • 1YB = 2^80 B = 1024 ZB

3.指针变量的指向类型决定了指向变量的取值宽度

取值宽度:指针的指向类型决定了通过该指针提取数据时的取值宽度。例如:

  • 如果指针类型为 int*,那么通过该指针提取的值将是4字节的整数(在多数现代编译器和平台上)。
  • 如果指针类型为 char*,则提取的是1字节的字符数据。
  • 如果指针类型为 double*,则提取的是8字节的双精度浮点数。

因此,指针变量的指向类型决定了它所指向数据的大小(取值宽度)。

img

4.指针变量的指向类型决定了指向变量的跨度

指针变量的指向类型决定了其“指向变量”的跨度,这里的“跨度”是指指针在内存中移动一个单位距离时,所跨越的内存字节数。这个跨度是由指针所指向的数据类型的大小决定的。具体解释如下:

  • 指针变量的指向类型:指针变量在声明时就确定了它所指向的数据类型,如 int*char*double* 等。这表明该指针是用来存储这类数据类型的内存地址的。

  • 指向变量的跨度:当对指针执行加减操作(如 +1-1 或更复杂的指针算术)时,指针所指向的内存地址会发生变化。这个变化的单位距离,即“跨度”,是由指针所指向的数据类型决定的。具体来说:

    • 如果指针类型为 int*,表示它指向一个整型变量。在大多数编译器和平台上,int 类型通常占4字节。因此,当对一个 int* 类型的指针执行 +1 操作时,指针所指向的地址将增加4字节,即指向下一个整型变量的起始地址。

    • 同理,如果指针类型为 char*,由于 char 类型通常占1字节,执行 +1 后指针将指向相邻的下一个字符地址。对于 double*,由于 double 通常占8字节,执行 +1 后指针将指向8字节之后的位置,即下一个 double 变量的起始地址。

    所以,指针变量的“指向变量的跨度”就是指针在内存中移动一个单位距离时,所跨越的内存字节数,这个字节数等于指针所指向的数据类型的大小。

  • 实际应用:这种特性在遍历数组、操作字符串、处理结构体或联合体中的成员等场景中尤为重要。通过指针的加减操作,可以方便地访问连续内存区域中的数据,无需每次都显式计算内存地址。

img

5.void * 类型指针

void * 是 C 语言中的一个通用指针类型,具有以下重要作用:

  1. 表示无类型指针

    • void * 类型的指针不与任何特定的数据类型关联,它仅仅表示一个内存地址,而不关心该地址所指向的数据类型。这种无类型特性使得 void * 可以用来指向任何类型的数据。
  2. 通用数据传递

    • 在函数参数或返回值中使用 void *,可以实现对任意类型数据的通用传递。这种做法常用于需要处理多种不同类型数据的函数,如内存管理函数(如 malloc()calloc()free())、数据复制函数(如 memcpy()memmove())、排序函数(如 qsort())等。通过接收或返回 void * 类型的指针,这些函数能够处理不同类型的内存块,无需为每种数据类型编写单独的版本。
  3. 类型无关的内存操作

    • 使用 void * 可以进行与具体类型无关的内存操作,如分配、释放、复制内存块等。这在处理内存块时非常有用,特别是当实际类型未知或不重要时,只需要关注内存块的整体大小和地址。
  4. 数据类型的隐藏与封装

    • 在面向对象编程风格或实现抽象数据类型时,void * 可以用于隐藏具体的实现细节,仅提供接口供外部访问。例如,一个链表节点可以使用 void * 来存储不同类型的数据项,具体类型由使用者决定,而链表的管理函数则通过 void * 来操作节点数据,无需了解具体数据类型。void指针作为函数的输入和输出时,表示可以接受任意类型的输入指针和输出任意类型的指针。
  5. 跨类型指针转换

    • void * 可作为不同类型指针之间的中介。任何类型的指针都可以直接赋值给void指针, 且无需进行强制类型转换,且将任何类型指针强制转换为 void * 后,可以再安全地转换回原始类型或其他类型。如果要把 void 类型的指针赋值给其他类型的指针,需要进行显式转换。void指针只有强制类型转换后才可以正常对其操作。
  6. 兼容性与平台无关性

    • 在某些需要与特定硬件、操作系统接口交互或编写高度兼容性代码的场景中,void * 用于表示与平台相关的指针类型,如硬件寄存器地址、操作系统提供的句柄等。由于 void * 与具体数据类型解耦,使得代码更易于适应不同的平台或硬件环境。

尽管 void * 提供了极大的灵活性,但也带来了一些挑战,如丧失了编译时类型检查、需要手动进行类型转换等。因此,在使用 void * 时应谨慎,并确保在适当的地方进行类型转换以保证类型安全。

6.空指针NULL

在C语言中,如果一个指针不指向任何数据,我们就称之为空指针,用NULL表示。它代表一个指针变量没有指向任何有效的内存地址。空指针概念的引入主要是为了标识指针状态的不确定性或表示指针不指向任何有意义的对象或数据。以下是关于空指针NULL的详细解释:

1. 定义与实现
在C语言中,NULL通常被定义为一个预处理器宏,其值被设定为整数0或者 (void *)0。虽然表现形式上是数值0,但在指针上下文中,它并不被视为一个数值,而是作为指针的特殊值——空指针。这个值在底层被解释为一个无效的内存地址,通常是地址0(0x00000000)。C标准保证了任何对象或函数的实际地址都不会与空指针相等,这使得空指针可以安全地用于条件检查和其他逻辑操作。

int *p = NULL;
NULL 是一个宏常量定义,在stdio.h被定义为:
#define NULL ((void *)0)

2. 用途与功能
空指针在C语言编程中有多种用途和功能:

  • 初始化指针:在声明并定义指针变量时,将其初始化为NULL。这可以确保指针在使用前有一个已知且安全的状态,避免使用未初始化的指针导致的未定义行为或潜在的段错误。

    int *ptr = NULL;
    
  • 比较指针是否为空:在进行指针操作之前,尤其是解引用指针或通过指针访问数据时,应当检查指针是否为NULL,以防止对无效地址的访问。

    if (ptr != NULL) {
        printf("Pointer points to: %d\n", *ptr);
    } else {
        printf("Pointer is NULL\n");
    }
    
  • 函数返回值:函数若需要表示未能成功获取或分配资源(如动态内存、文件句柄等)时,可以返回NULL指针,以便调用者能够识别并处理这种情况。

    char *allocate_buffer(size_t size) {
        char *buffer = malloc(size);
        if (buffer == NULL) {
            return NULL;  // Indicate failure to allocate memory
        }
        return buffer;
    }
    
  • 库函数和系统接口:许多C标准库函数和系统调用都使用NULL作为特殊值,例如在字符串处理函数中,\0(空字符)作为字符串的结束标记,而在文件操作中,NULL可能表示文件结尾或查找失败。

3. 类型转换与兼容性
NULL在C语言中通常被定义为 (void *)0,这意味着它是一个指向void类型的空指针。由于void*指针可以隐式转换为任何其他类型的指针,因此NULL可以直接赋值给或与任何类型的指针进行比较,无需显式类型转换。

4. 空指针常量与空指针
从C标准的角度来看,值为0的整型常量表达式或被强制转换为void*类型的此类表达式被称为空指针常量。当这样的空指针常量被赋予一个指针变量或与指针进行比较时,它会被转换为相应类型的指针,此时这个指针就被称为空指针。空指针保证不等于任何有效对象或函数的地址。

7.段错误

C语言中的“段错误”(Segmentation Fault,简称segfault)是一种常见的运行时错误,通常发生在程序试图访问内存时违反了系统的内存访问规则。段错误本质上是操作系统对进程进行的一种保护机制,用于阻止进程对内存进行非法或未经授权的访问,从而防止数据破坏、系统崩溃或其他不稳定行为。以下是对段错误的详细解释:

img

1. 内存布局与保护机制

在现代操作系统中,进程的虚拟内存空间被划分为不同的逻辑段,如代码段、数据段、堆段、栈段等,每个段都有特定的用途和访问权限。例如:

  • 代码段:存储程序的机器指令,通常只允许读取和执行,禁止写入。
  • 数据段:包含全局变量和静态变量,允许读写。
  • 堆段:动态分配的内存区域,通过 malloc()calloc()realloc() 等函数分配,允许读写。
  • 栈段:存放局部变量和函数调用信息,自动增长和收缩,允许读写。

操作系统通过内存管理单元(MMU)和页表机制,将虚拟地址映射到物理内存,并在硬件层面实施访问权限检查。当进程试图访问的内存地址违反了相应的访问权限(如读取只写的区域、写入只读的区域、访问未分配的内存等),操作系统会触发段错误异常,终止违规进程,并通常打印出类似“Segmentation fault (core dumped)”的错误消息。

2. 段错误的常见原因

段错误通常由以下编程错误引起:

  • 空指针解引用:指针变量未被初始化或已被设置为 NULL,但程序仍尝试通过该指针访问内存。
  • 野指针使用:指针指向了未被正确分配或已释放的内存区域。
  • 数组越界访问:访问数组时索引超出其有效范围,可能导致写入到数组之外的内存区域。
  • 栈溢出:函数递归过深或局部变量过大,导致栈空间耗尽,进而覆盖了栈上的其他重要数据结构(如返回地址、栈帧信息等)。
  • 非法内存操作:如试图写入只读的代码段、数据段,或者访问未映射的地址(如NULL地址)。
  • 多线程同步问题:如竞争条件导致的内存访问冲突,其中一个线程可能修改了另一个线程正在使用的内存区域。

3. 段错误的诊断与调试

遇到段错误时,可以通过以下步骤进行诊断和调试:

  • 阅读错误消息:错误消息通常包含“Segmentation fault”字样,并可能伴随有进程ID、信号编号(如SIGSEGV)以及是否生成了核心转储文件(core dump)的信息。
  • 分析 core dump:如果启用了 core dump 功能,可以使用 gdb 等调试器加载 core 文件和可执行文件,通过回溯堆栈、检查变量值、观察内存布局等方式定位错误发生的具体位置和上下文。
  • 使用调试器:在编译时加入 -g 选项生成调试信息,然后使用 gdb 等调试器逐步执行程序,观察指针值的变化、内存访问情况,找出引发错误的代码行。
  • 代码审查:仔细检查引发错误的代码段,重点关注指针操作、数组访问、动态内存分配与释放、函数调用等可能引发段错误的部分。
  • 使用内存检查工具:如 Valgrind、AddressSanitizer、MemorySanitizer 等工具,它们能够在运行时检测出许多常见的内存错误,如未初始化的内存访问、内存泄漏、越界访问等,有助于定位段错误根源。

9.内存管理

C语言中直接用于内存操作的核心函数主要包括以下这些:

1.内存分配

  • malloc():

    void* malloc(size_t size);
    

    分配size字节的连续内存空间,并返回一个指向该内存区域的指针(类型为void*,可以转换为任何数据类型的指针)。如果分配失败(例如,系统内存不足),malloc()返回NULL。分配的内存未经初始化,其内容是不确定的。

  • calloc():

    void* calloc(size_t num, size_t size);
    

    分配num个大小为size字节的对象组成的连续内存区域,并将所有内存字节初始化为零。返回一个指向该内存区域的指针(同为void*)。如果分配失败,calloc()返回NULL

2.内存释放

  • free():
    void free(void* ptr);
    
    释放由ptr所指向的内存区域,该区域必须是由先前的malloc()calloc()realloc()成功分配的。释放后,该指针应视为无效,不能再对其进行访问或释放。如果试图释放非动态分配的内存或已经释放过的内存,结果是未定义的。

3.内存重分配

  • realloc():

    void* realloc(void* ptr, size_t new_size);
    

    调整由ptr所指向的内存区域的大小为new_size字节。如果new_size小于原大小,realloc()可能(但不保证)缩小内存区域,并返回指向新内存区域的指针。如果new_size大于原大小,函数尝试扩展内存区域,保持原有内容,并返回指向新(可能移动)内存区域的指针。如果分配失败,realloc()返回NULL,此时原始内存块保持不变。如果ptrNULLrealloc()的行为等同于调用malloc(new_size)

4.内存复制

  • memcpy():

    void* memcpy(void* dest, const void* src, size_t n);
    

    将从src开始的n个字节的数据复制到dest指向的内存区域。源和目标区域可以重叠,但结果可能不可预测。返回dest

  • memmove():

    void* memmove(void* dest, const void* src, size_t n);
    

    类似于memcpy(),但当源和目标区域重叠时,memmove()能确保正确地复制数据。返回dest

5.内存填充

  • memset():
    void* memset(void* dest, int c, size_t n);
    
    将从dest开始的n个字节填充为整数c的值(通常将其解释为一个无符号字符)。返回dest

6.内存比较

  • memcmp():

    int memcmp(const void* ptr1, const void* ptr2, size_t n);
    

    比较从ptr1ptr2开始的n个字节的内容。如果两块内存完全相同,返回0;如果ptr1的内容被认为小于ptr2的内容,返回负数;否则返回正数。

7.字符串操作

  • strcpy():

    char* strcpy(char* dest, const char* src);
    

    src指向的字符串复制到dest指向的内存区域,包括终止空字符\0。返回dest。注意:如果没有足够的空间来容纳源字符串(包括终止空字符),会导致缓冲区溢出。

  • strncpy():

    char* strncpy(char* dest, const char* src, size_t n);
    

    src指向的字符串的前n个字符复制到dest指向的内存区域。如果源字符串长度小于n,剩余的dest空间用空字符填充。如果源字符串长度大于等于n,则不保证在dest末尾添加终止空字符。返回dest

  • strcat():

    char* strcat(char* dest, const char* src);
    

    src指向的字符串追加到dest指向的已有字符串末端,包括终止空字符\0。返回dest。注意:如果没有足够的空间来容纳追加后的完整字符串(包括终止空字符),会导致缓冲区溢出。

  • strncat():

    char* strncat(char* dest, const char* src, size_t n);
    

    src指向的字符串的前n个字符追加到dest指向的已有字符串末端。dest的总长度(包括终止空字符)不超过dest的原始长度加上n。返回dest

  • strlen():

    size_t strlen(const char* str);
    

    返回str指向的字符串的长度,即从首字符到终止空字符\0之间的字符数,不包括终止空字符。

  • strdup():

    char* strdup(const char* str);
    

    动态分配足够内存来存储str指向的字符串(包括终止空字符),并将该字符串复制到新分配的内存区域。返回新字符串的指针。如果分配失败,返回NULL。注意:strdup()并非标准C库的一部分,但在许多实现(如POSIX兼容系统)中可用。在使用前,请确保你的编译环境支持该函数。

以上就是C语言中各种内存操作函数的详细说明。正确理解和使用这些函数是编写高质量C代码的重要基础。

10.内存泄漏

C语言内存泄漏是指程序在运行过程中动态分配了内存(通常通过 malloc(), calloc(), 或 realloc() 函数),但在不再需要这些内存之后未能及时释放它们,导致这些内存块无法被操作系统或程序的其他部分重新使用。内存泄漏是一种常见的编程错误,如果不加以控制,随着时间推移,程序会持续消耗越来越多的内存,直至耗尽系统资源,引发性能下降、系统崩溃或其他难以诊断的问题。

内存泄漏的原因主要有以下几点:

  1. 忘记释放内存
    程序员在分配了内存后,由于疏忽或错误,没有在适当的地方调用 free() 函数来释放不再使用的内存块。这通常发生在复杂的数据结构处理、循环或递归调用中,或者在异常处理路径中没有正确清理内存。

  2. 无法到达的内存
    当程序中的某个数据结构持有指向动态分配内存的指针,但由于程序逻辑错误,这个指针后来变得不可达(例如,被赋值为 NULL 或覆盖为其他值),使得原本分配的内存无法通过正常途径释放。

  3. 内存分配与释放不匹配
    如果程序使用了不同的函数来分配和释放内存(例如,用 malloc() 分配,却用 realloc() 或其他非匹配的方式释放),或者在释放内存时提供的指针参数错误,可能导致内存无法正确释放。

  4. 未释放嵌套分配
    在动态分配复合数据结构(如链表、树、图等)时,可能存在嵌套的内存分配。如果只释放了顶层结构的内存,而忘记了释放内部嵌套的子结构,也会造成内存泄漏。

  5. 异常或早退
    当程序遇到异常情况或提前退出某函数(如通过 returngoto 语句)时,如果忘记释放之前分配的内存,这部分内存就会泄漏。

内存泄漏的危害包括:

  • 资源浪费:泄漏的内存不能被系统回收,即使程序不再需要,也无法被其他进程或同一进程的其他部分使用,造成系统内存资源的浪费。

  • 性能下降:随着内存泄漏的累积,程序占用的内存逐渐增大,可能导致系统频繁进行内存交换(swap),降低程序和系统的整体性能。

  • 稳定性问题:严重且长期的内存泄漏最终可能导致系统内存耗尽,引发系统级错误,如进程被操作系统强制终止(OOM killer),甚至整个系统崩溃。

  • 难以诊断:内存泄漏问题可能在程序运行一段时间后才显现出来,而且症状(如速度变慢、响应迟钝、突然崩溃)往往不易直接关联到内存泄漏,增加了问题定位和修复的难度。

预防和检测内存泄漏的方法有:

  • 良好的编程习惯:始终遵循“谁分配,谁释放”的原则,确保每一块动态分配的内存都有对应的释放操作。使用智能指针(如C++中的std::unique_ptrstd::shared_ptr等)或C语言中自定义的内存管理封装可以简化这一过程。

  • 内存管理封装:如前所述,通过定义结构体封装指针和释放函数,或者使用RAII(Resource Acquisition Is Initialization)技术,确保资源(如内存)在其生命周期结束时自动释放。

  • 静态代码分析:使用专门的静态代码分析工具,它们可以检测代码中潜在的内存泄漏风险,帮助开发者在编译阶段发现问题。

  • 动态内存检测工具:如Valgrind、AddressSanitizer、LeakSanitizer等,这些工具可以在程序运行时监控内存分配和释放行为,报告未释放的内存块及其分配位置,便于定位和修复内存泄漏。

  • 平台特定的内存泄漏检测:如Windows平台上的CRT(C运行时库)提供了内存泄漏检测功能,通过重定义 mallocfree 等函数,可以在程序退出时报告未释放的内存信息。

综上所述,C语言内存泄漏是由于程序在动态分配内存后未能正确释放而导致的资源浪费和潜在系统稳定性问题。通过良好的编程习惯、适当的内存管理封装以及使用内存检测工具,可以有效地预防和检测这类问题。

posted @ 2026-08-09 21:28  littlecamel  阅读(12)  评论(0)    收藏  举报