C语言:二级指针及void与void*的区别

内容提要

  • 指针

    • 二级指针

    • main函数原型

    • 常量指针与指针常量

    • 野指针、空指针、空悬指针

    • void与void*的区别

  • 内存管理

二级指针

定义

二级指针(多重指针)用于储存一级指针的地址,需要两次解引用才能访问原始数据。其他多重指针的用法类似,但实际开发中最常见的指针是二级指针。

cpp 复制代码
int a = 10;    // a是普通变量,也就是原始数据
int *p = &a;   // 一级指针,p指向a,解引用1次就可以获取a的值
printf("%d\n", *p); // 10
​
int **w = &p;  // 二级指针,w指向p,解引用2次就可以获取a的值
printf("%d\n", **w);// 10
-----------------------------------------------------------------
int ***k = &w; // 三级指针,k指向w,解引用3次就可以获取a的值
printf("%d\n", ***k);  // 10   int a1 = ***k;  int *a2 = **k;  int **a3 = *k; int ***a4 = k;
语法
复制代码
数据类型 **指针变量名 = 指针数组的数组名 | 一级指针的地址
特点

与指针数组的等效性 二级指针与指针数组在某些时候存在等效性,但与二维数组不等效。二维数组名是数组指针类型,如int (*)[3],而非二级指针。

cpp 复制代码
// 指针数组
int arr[] = {11,22,33};
int *arr_[] = {&arr[0],&arr[1],&arr[2]};
​
// 二级指针接收指针数组
char *str[3] = {"abc","aaa034","12a12"};
char **p = str;  // p:数组首地址,行地址,默认0行  *p:列地址,默认0行0列  **p:列元素
复制代码
cpp 复制代码
/*************************************************************************
  > File Name:    demo03.c
  > Author:       三道渊
  > Description:  
  > Created Time: 2025年07月29日 星期二 10时27分05秒
 ************************************************************************/
​
#include <stdio.h>
​
int main(int argc,char *argv[])
{
    // 字符串数组,字符类型的指针数组
    char *str[3] = {"abc","aaa034","12a12"};
    char **p = str;// str表示数组首地址,其实就是首元素地址  abc 这个字符串
    // p 存储的就是 abc的地址,指向的行,*p 访问到abc的列,默认首列  
    
    // char str[] = "hello";
    // printf("%s\n", str);
    // char str2[20];
    // scanf("%s", str2); // str2就是一个地址
​
    // 打印字符串
    // for (int i = 0; i < 3; i++)
    // {
    //    printf("%s\n", *p);
    //    p++;
    // }
    
    // 打印字符
    int i = 0;
    while(**p != '\0')
    {
        printf("%-2c",**p);
        (*p)++;
    }
    printf("\n");
    return 0;
}

与二维数组的差异 二维数组名是数组指针类型,直接赋值给二级指针会导致类型不匹配

cpp 复制代码
// 数组指针可以指向一个二维数组
int arr[2][3] = {{1,3,5},{11,33,55}};
int (*p)[3] = arr;  // 数组名默认代表首元素地址,在二维数组中,就是行地址
​
// 二级指针不等效二维数组
int **k = arr; // 编译报错 arr类型 int(*)[3]   不兼容 k类型 int**
解引用

字符型二级指针 可直接遍历字符串数组,类似一维数组操作:

cpp 复制代码
/*************************************************************************
  > File Name:    demo01.c
  > Author:       三道渊
  > Description:  
  > Created Time: 2025年07月30日 星期三 09时44分42秒
 ************************************************************************/
​
#include <stdio.h>
​
void fun1()
{
    // 定义一个字符类型的指针数组(字符串数组)
    char *arr[] = {"orange","apple","grape","banana","kiwi"};
    int len = sizeof(arr) / sizeof(arr[0]); // int len = 5 * 8(指针) / 8(指针) = 5
​
    for (int i = 0; i < len; i++) printf("%s\n", arr[i]); printf("\n");
​
}
​
void fun2()
{
    char *arr[] = {"orange", "apple", "grape", "banana", "kiwi"};
    int len = sizeof(arr) / sizeof(arr[0]);
​
    // 此时二级指针完全等价于指针数组
    char **p = arr;  // p 指向 arr的首元素,也就是orange
​
    for (int i = 0; i < len; i++)
    {
        // printf("%s\n", p[i]);    // 下标法
        printf("%s\n", *(p + i));// 指针法
    }
    printf("\n");
}
​
void fun3()
{
    char *arr[] = {"orange","apple","grape","banana","kiwi"};
​
    int len = sizeof(arr) / sizeof(arr[0]);
​
    char **p;
​
    int i = 0;
​
    // 遍历数组
    do
    {
        p = arr + i; // arr代表行,+i,此时是行偏移,返回的行地址   p指向字符串
        printf("%s\n", *p);// 对行地址解引用得到列地址   int a = 10; int *p = &a;
        i++;
    } while (i < len);
​
    printf("\n");
}
​
int main(int argc,char *argv[])
{
   
    fun1();
    fun2();
    fun3();
​
    return 0;
}
​

注意:如果需要一个字符串类型的数组,我们可以选择使用二级指针或者指针数组,此时两者完全等价。

其他类型的二级指针 需要两次解引用访问数据,常用于操作指针数组

cpp 复制代码
#include <stdio.h>
​
int main()
{
    // 创建一个一维数组
    int arr1[] = {11,22,33,44,55,66}; // 11:0x11
    // 创建一个指针数组
    int *arr[] = {&arr1[0],&arr1[1],&arr1[2],&arr1[3],&arr1[4],&arr1[5]}; // [0]:0x22 --> 0x11
    
    // 用一个二级指针接收指针数组
    int **p = arr;  // p 指向 arr,p存储的arr第一个元素的地址
    
    // 遍历数组
    for(int i=0;i<sizeof(arr)/sizeof(arr[0]);i++){
        printf("%-6d", *p[i]); // 下标法(1.指针偏移,2.对新指针解引用)
        printf("%-6d", **(p+i));// 指针法  p+i  元素地址偏移  元素地址,对元素地址解引用,返回元素值(11..对应的地址)
    }
    printf("\n");
}
总结
类型 本质 内存布局 等效性
二级指针(int** 指向指针的指针 指针的指针 与指针数组等效
指针数组(int*[] 元素为指针的数组 分散的指针 退化为二级指针
二维数组(int[][3] 数组的数组 连续的数据块 数组指针(int(*)[3]

main函数原型

定义

main函数有多种定义格式,main函数也是函数,函数相关的结论对main函数也有效。

main函数的完整写法:

cpp 复制代码
 int main(int argc, char *argv[]){..} 
 int main(int argc, char **argv){..}

扩展写法:

cpp 复制代码
 main(){}  等价 int main(){}   // C11之后不再支持 缺省 返回类型
 int main(void){}   等价 int main(){}
 void main(void){}  等价 void main(){}
 int main(int a){}
 int main(int a, int b, int c){}
 ...
说明

① argc,argv是形参,他们俩可以修改

② main函数的扩展写法有些编译器不支持,编译报警告

③ argc和argv的常规写法

  • argc:存储了参数的个数,默认是1个,也就是运行程序的名字

  • argv:存储了所有参数的字符串形式

④ main函数是系统通过函数指针的回调调用。

演示

代码:

cpp 复制代码
#include <stdio.h>
​
int main(int argc, char **argv)  // {"abc","aaa"}   对行地址解引用,得到首列地址
{
    // 访问参数个数 argc
    printf("argc=%d\n", argc);
    
    // 遍历参数(每一个参数都是一个字符串常量)
    for(int i=0;i< argc; i++){
        printf("%s,%s\n", argv[i], *(argv+i));
    }
    printf("\n");
}

运行结果:

常量指针与指针常量

常量类型

① 字面量:直接使用固定值(如:12,hello,orange, 杨家辉三角),符号常量和枚举在编译器转换为了字面量

② 只读常量:用const修饰的变量,初始化之后不可修改。

cpp 复制代码
const int a = 10;  // 只读常量
a = 21;  // 编译报错
常量指针
  • 本质:指向常量数据的指针

  • 语法:

    cpp 复制代码
    const 数据类型 *变量名;
    const 数据类型* 变量名;
  • 举例:

    cpp 复制代码
    const int *p;   // p是常量指针
  • 特性:

    • 指向对象的数据不可改变(int a = 10; const int *p = &a; *p = 20;,非法)

    • 指针本身的指向可以改变(int a = 10, b = 20; const int *p = &a; p = &b;,合法)

  • 案例:

    cpp 复制代码
    #include <stdio.h>
    ​
    int main()
    {
        int a = 10;        // 变量
        const int *p = &a; // 常量指针
        
        // *p = 100;       // 错误,指针指向的数据不可改变
        printf("%d\n", *p);// 10
        
        int b = 20;        // 变量
        p = &b;            // 正确,指针指向可以改变
        printf("%d\n", *p);// 20
    }
指针常量
  • 本质:指针本身是常量,指向固定地址

  • 语法:

    cpp 复制代码
    数据类型* const 变量名;
    数据类型 *const 变量名;
  • 特性:

    • 指向对象的数据可以改变(int a = 10; int* const p = &a; *p = 20;,合法)

    • 指针本身的指向不可改变(int a = 10, b = 20; int* const p = &a; p = &b;,非法)

  • 注意:

    定义时必须初始化:

    cpp 复制代码
    int a = 10;
    int* const p = &a;  // 正确
  • 案例:

    cpp 复制代码
    #include <stdio.h>
    ​
    int main()
    {
        int a = 10;        // 变量
        int* const p = &a; // 指针常量
        
        *p = 100;          // 正确,指针指向的数据可以改变
        printf("%d\n", *p);// 100
        
        int b = 20;        // 变量
        // p = &b;            // 错误,指针指向不可改变
        printf("%d\n", *p);// 100
    }
常量指针常量
  • 本质:指针指向和指向对象的数据都不可改变

  • 语法:

    cpp 复制代码
    const 数据类型* const 变量名;
    const 数据类型 *const 变量名;
  • 举例:

    cpp 复制代码
    const int* const p;  // p是常量指针常量
  • 特性:

    • 指向对象的数据不可改变(int a = 10; int* const p = &a; *p = 20;,非法)

    • 指针本身的指向不可改变(int a = 10, b = 20; int* const p = &a; p = &b;,非法)

  • 注意:

    定义时需要初始化:

    cpp 复制代码
    int a = 10;
    const int *const p = &a; // 正确

    简单理解:不管是常量指针、指针常量还是常量指针常量,本质上都是一个赋值受到限制的指针变量。

总结对比
类型 语法 指向可变 数据可变
常量指针 const int *p ✔️
指针常量 int *const p ✔️
常量指针常量 const int *const p
关键点
  1. const* 左侧:修饰数据(常量指针)

  2. const* 右侧:修饰指针(指针常量)

  3. 函数参数优先使用常量指针,提高代码安全性

  4. 指针常量必须初始化,且不可重新指向

野指针、空指针、空悬指针

野指针

定义

指向无效内存区域(==比如未初始化、已释放或者越界访问==)的指针称之为野指针。野指针会导致未定义(UB)行为。

危害:

  • 访问野指针可能引发段错误(Segmentation Fault)

  • 可能破坏关键内存数据,导致程序崩溃。

产生场景:

  1. 指针变量未初始化

    cpp 复制代码
    int *p;    // p未初始化,是野指针
    printf("%d\n", *p); // 危险操作:p就是野指针
  2. 指针指向已释放的内存

    cpp 复制代码
    int *p = malloc(sizeof(int)); // 在堆区申请1个int大小的内存空间,将该空间地址赋值给指针变量p
    free(p); // 释放指针p指向的空间内存
    printf("%d\n", *p); // 危险操作:p就是野指针
  3. 返回局部变量的地址

    cpp 复制代码
    int* fun(int a, int b)
    {
        int sum = a + b; // sum就是一个局部变量
        return &sum;  // 将局部变量的地址返回给主调函数
    }
    ​
    int main()
    {
        int *p = fun(2,3);
        printf("%d\n", *p); // 危险操作:p就是野指针
    }

如何避免野指针:

  1. 初始化指针为NULL

  2. 释放内存后立即置指针为NULL

  3. 避免返回局部变量的地址

  4. 使用前检查指针有效性(非空校验,边界检查)。

    cpp 复制代码
    int fun(int *pt)
    {
        int *p = pt;
        // 校验指针
        if(p == NULL) // 结果为假   等价于 if(!p)  其实底层: if(p == 0)
        {
            printf("错误!");
            return -1;
        }
        printf("%d\n", *p);
        return 0;
    }
空指针

定义: 值为NULL的指针,指向地址0x000000000000(系统保留,不可访问)

**作用:**明确表示指针当前不指向有效内存,一般用作指针的初始化。

示例:

cpp 复制代码
int *p = NULL;  // 初始化为空指针
​
free(p);   // 释放后置空
p = NULL;
空悬指针

**定义:**指针指向的内存已经被释放,但未重新赋值。空悬指针是野指针的一种特例。

示例:

cpp 复制代码
char *p = malloc(100); // 在堆区分配100个char的空间给p
free(p); // 释放指针p指向的内存空间
printf("%p,%d\n", p, *p); // p可以正常输出,*p此时属于危险操作 
// p指向的内存空间被回收,但是p指向空间的地址依然保留,此时这个指针被称作空悬指针

void与void*的区别

定义
  • void: 表示"无类型/空类型",用于函数返回类型或者参数。

    cpp 复制代码
    void func(void);    // 没有返回值也没有参数,一般简写:void func();
  • **void*:**通用指针类型(万能指针),可指向任意类型数据,但需要==强制类型转换后==才能解引用。

    cpp 复制代码
    void* ptr = malloc(4);  // ptr指向4个字节大小的堆内存空间  
    // 存放int类型数据
    int *p = (int*)ptr;
    *p = 10;
    ​
    // 存放float类型数据
    float* p1 = (float*)ptr;
    *p = 12.5f;
    ​
    // 存放char类型数组
    char* p2 = (char*)ptr;
    ​
    // 以下写法完全错误
    float* ptr = malloc(4);
    int *p = (int*)ptr;  // 此时编译报错,类型不兼容 float* int*

    注意:只能是具体的类型(int*,double*,float*,char*...)和void*之间转换

注意事项
  • void不能直接解引用(*ptr 会报错

  • 函数返回void*需要外部接收的时候明确类型(不明确类型,就无法解引用)

示例
cpp 复制代码
/*************************************************************************
  > File Name:    demo05.c
  > Author:       三道渊
  > Description:  
  > Created Time: 2025-07-30 15:52:36
 ************************************************************************/
#include <stdio.h>
​
/**
 * 定义一个返回类型为void*类型的指针函数
 */
void* proces_data(void* p)
{
    return p;
}
​
int main(int argc, char *argv[])
{
    // int类型
    int m = 10;
    int* p_int = &m;
    
    int* result_int = (int*)proces_data(p_int);
    printf("Integer value:%d\n", *result_int);
    
    // double类型
    double pi = 3.1415926;
    double* p_double = &pi;
    
    double* result_double = (double*)proces_data(p_double);
    printf("Double value:%lf\n", *result_double);
    
    // void* p_void = proces_data(p_double);
    // printf("Void value:%lf\n", *p_void);
    // *p_void = 20;
    
    // 注意:void* 修饰的指针是可以进行赋值操作的,但是不能对其解引用
    
    return 0;
}

内存管理【扩展资料】

C进程内存布局

任何一个程序,正常运行都需要内存资源,用来存放诸如变量、常量、函数代码等等。这些不同的内容,所存储的内存区域是不同的,且不同的区域有不同的特性。因此我们需要研究C语言进程的内存布局,逐个了解不同内存区域的特性。

每个C语言进程都拥有一片结构相同的虚拟内存,所谓的虚拟内存,就是从实际物理内存映射出来的地址规范范围,最重要的特征是所有的虚拟内存布局都是相同的,极大地方便内核管理不同的进程。例如三个完全不相干的进程p1、p2、p3,它们很显然会占据不同区段的物理内存,但经过系统的变换和映射,它们的虚拟内存的布局是完全一样的。

  • PM:Physical Memory,物理内存。

  • VM:Virtual Memory,虚拟内存。

将其中一个C语言含如进程的虚拟内存放大来看,会发现其内部包下区域:

  • 栈(stack)

  • 堆(heap)

  • 数据段

  • 代码段

虚拟内存中,内核区段对于应用程序而言是禁闭的,它们用于存放操作系统的关键性代码,另外由于 Linux 系统的历史性原因,在虚拟内存的最底端 0x00000000 ~ 0x08048000 之间也有一段禁闭的区段,该区段也是不可访问的。

虚拟内存中各个区段的详细内容:

栈内存

  • 什么东西存储在栈内存中?

    • 环境变量

    • 命令行参数

    • 局部变量(包括形参)

  • 栈内存有什么特点?

    • 空间有限,尤其在嵌入式环境下。因此不可以用来存储尺寸太大的变量。

    • 每当一个函数被调用,栈就会向下增长一段,用以存储该函数的局部变量。

    • 每当一个函数退出,栈就会向上缩减一段,将该函数的局部变量所占内存归还给系统。

  • 注意: 栈内存的分配和释放,都是由系统规定的,我们无法干预。

  • 示例代码:
cpp 复制代码
void func(int a, int *p) // 在函数 func 的栈内存中分配
{
    double f1, f2;        // 在函数 func 的栈内存中分配
    ...                   // 退出函数 func 时,系统的栈向上缩减,释放内存
}
​
int main(void)
{
    int m  = 100;  // 在函数 main 的栈内存中分配
    func(m, &m);  // 调用func时,系统的栈内存向下增长
}

静态数据

C语言中,静态数据有两种:

  • 全局变量:定义在函数外部的变量。

  • 静态局部变量:定义在函数内部,且被static修饰的变量。

  • 示例:

cpp 复制代码
int a; // 全局变量,退出整个程序之前不会释放
void f(void)
{
    static int b; // 静态局部变量,退出整个程序之前不会释放
    printf("%d\n", b);
    b++;
}
​
int main(void)
{
    f();
    f(); // 重复调用函数 f(),会使静态局部变量 b 的值不断增大
}
  • 为什么需要静态数据?
  1. 全局变量在默认的情况下,对所有文件可见,为某些需要在各个不同文件和函数间访问的数据提供操作上的方便。

  2. 当我们希望一个函数退出后依然能保留局部变量的值,以便于下次调用时还能用时,静态局部变量可帮助实现这样的功能。

  • 注意1:

    • 若定义时未初始化,则系统会将所有的静态数据自动初始化为0

    • 静态数据初始化语句,只会执行一遍。

    • 静态数据从程序开始运行时便已存在,直到程序退出时才释放。

  • 注意2:

    • static修饰局部变量:使之由栈内存临时数据,变成了静态数据。

    • static修饰全局变量:使之由各文件可见的静态数据,变成了本文件可见的静态数据。

    • static修饰函数:使之由各文件可见的函数,变成了本文件可见的静态函数。

数据段与代码段

  • 数据段细分成如下几个区域:

    • .bss 段:存放未初始化的静态数据,它们将被系统自动初始化为0

    • .data段:存放已初始化的静态数据

    • .rodata段:存放常量数据

  • 代码段细分成如下几个区域:

    • .text段:存放用户代码

    • .init段:存放系统初始化代码

cpp 复制代码
int a;       // 未初始化的全局变量,放置在.bss 中
int b = 100; // 已初始化的全局变量,放置在.data 中
​
int main(void)
{
    static int c;       // 未初始化的静态局部变量,放置在.bss 中
    static int d = 200; // 已初始化的静态局部变量,放置在.data 中
    
    // 以上代码中的常量100、200防止在.rodata 中
}
  • 注意:数据段和代码段内存的分配和释放,都是由系统规定的,我们无法干预。

堆内存

堆内存(heap)又被称为动态内存、自由内存,简称堆。堆是唯一可被开发者自定义的区段,开发者可以根据需要申请内存的大小、决定使用的时间长短等。但又由于这是一块系统"飞地",所有的细节均由开发者自己把握,系统不对此做任何干预,给予开发者绝对的"自由",但也正因如此,对开发者的内存管理提出了很高的要求。对堆内存的合理使用,几乎是软件开发中的一个永恒的话题。

  • 堆内存基本特征:

    • 相比栈内存,堆的总大小仅受限于物理内存,在物理内存允许的范围内,系统对堆内存的申请不做限制。

    • 相比栈内存,堆内存从下往上增长。

    • 堆内存是匿名的,只能由指针来访问。

    • 自定义分配的堆内存,除非开发者主动释放,否则永不释放,直到程序退出。

  • 相关API:

    • 申请堆内存:malloc() / calloc() int *p = malloc(4);

    • 清零堆内存:bzero()

    • 释放堆内存:free()

  • 示例:
cpp 复制代码
int *p = malloc(sizeof(int)); // 申请1块大小为 sizeof(int) 的堆内存
bzero(p, sizeof(int));        // 将刚申请的堆内存清零
​
*p = 100; // 将整型数据 100 放入堆内存中
free(p);  // 释放堆内存
​
// 申请3块连续的大小为 sizeof(double) 的堆内存
double *k = calloc(3, sizeof(double));
​
k[0] = 0.618;
k[1] = 2.718;
k[2] = 3.142;
free(k);  // 释放堆内存
复制代码
 
  • 注意:

    • malloc()申请的堆内存,默认情况下是随机值,一般需要用 bzero() 来清零。

    • calloc()申请的堆内存,默认情况下是已经清零了的,不需要再清零。

    • free()只能释放堆内存,并且只能释放整块堆内存,不能释放别的区段的内存或者释放一部分堆内存。

  • 释放内存的含义:

    • 释放内存意味着将内存的使用权归还给系统。

    • 释放内存并不会改变指针的指向。

    • 释放内存并不会对内存做任何修改,更不会将内存清零。

相关推荐
Scott9999HH5 小时前
【IIoT流量实战】蒸汽管道阀门全关却仍有流量?用 Python 实现涡街信号 FFT 频谱分析与温压全补偿积算网关,深度拆解靠谱的涡街流量计厂家硬核技术标准
开发语言·python
码智社6 小时前
AES加密原理详解及Java实现加解密实战
java·开发语言
AI云海6 小时前
python 列表、元组、集合和字典
开发语言·python
萧瑟余晖7 小时前
JDK 26 新特性详解
java·开发语言
马优晨8 小时前
Freemarker 完整讲解(后端 Java 模板引擎)
java·开发语言·freemarker·freemarker 完整讲解·freemarker模板引擎
人邮异步社区10 小时前
怎么把C语言学到精通?
c语言·开发语言
心平气和量大福大10 小时前
C#-WPF-控件-TextBox 数据绑定
开发语言·c#·wpf
ttwuai11 小时前
Cursor 生成 CRUD 后,Go 后台接口别只测 200:JWT、RBAC 和 tenant_id 怎么验
开发语言·后端·golang
এ慕ོ冬℘゜11 小时前
前端基础:什么是时间戳?JS获取时间戳三种方法与实战用途
开发语言·前端·javascript
执明wa11 小时前
LayoutInflater详解: XML是如何变成View的?
android·xml·开发语言·android studio