# C 语言指针笔记

# 前言

🔼

指针 可以说是 C 语言的灵魂,最巧妙的地方。不明白,不理解指针 那就是等于没学 C 语言.
指针这玩意说难也不难,主要是细节问题。比如最常见的,指针数组和数组指针、指针常数和常数指针、指针函数和函数指针。刚学完指针还好,时间一久,听到之这些东西很难短时间内反应过来
指针这块 用的多的 就是字符串了,其他的用的都比较少,所以久而久之总是忘,所以干脆写篇博客加深印象,也便于日后回顾
我才不承认是我自己,搞混了概念,重新学了一遍,想和人分享没人听才写的博客,绝对不是!

# 基础部分

🔼
写在前面: 指针是一种变量,地址是一种数据.
大概的介绍一下 什么是指针,指针的作用,以及指针的基本操作
PS: 以下假设环境均为 64 位 1904 Win10 vscode gcc8.1.0

# 指针的作用

🔼

我先说明作用后面慢慢分析.
作用:

  1. 数据共享更加便捷,打破共享壁垒
  2. 以更精简的方式引用大的数据结构
  3. 利用指针可以直接操纵内存地址 (在 MCU, 嵌入式开发上体会会很深刻)
  4. 利用指针,能在程序执行过程中预留新的内存空间 (当然在没有 MMU 的单片机中,无法实现,能写内存管理的大佬除外)

先抛开指针,让我们来想想,变量的作用是什么?使内存空间易于管理.
好,为什么内存空间难管理,因为内存标号 (内存地址) 往往都是一串 16 进制数,难于记忆,所以对内存地址取个名字,便于记忆
局部变量都有一定的局限性,所谓的作用域全局变量虽然没有局限性,但大量的全局变量会浪费许多内存.
然而很多时候我们需要在作用域外来修改内存上的值 (例如,两数交换), 数据唯一不变的只有地址,所以想要实现数据共享,只能通过 传递地址 来实现了,指针 —— 一种特殊的变量,就应运而生了.(学过汇编后,会对指针的数据共享理解更加深刻)

第二个作用,很大程度上体现在函数传参和字符串上,思考一个问题,如果我有 一个结构体,如下:

struct RxBufferTypeDef{
    int size;
    int read;
    int write;
    int *buffer;
};

里面有 3 个整型变量,一个指针变量,一个 int 变量 4 字节,一个指针变量 8 字节,一个结构体 20 个字节大小,如果 一个函数调用时传递的是整个结构体,那么就相当于在内存中又开辟了 20 字节大小空间,用于实现对传入结构体的复制,而且不能对结构体的成员进行修改,如果传递结构体指针,大小恒为 8 字节,而且可以对结构体进行修改,整个程序中只占用 20 字节,在内存和运行速度上,比之直接传结构体更为方便.

大多数情况下,可以看到程序使用的内存是通过显式声明分配给变量的内存 (也就是静态内存分配). 这一点对于节省计算机内存是有帮助的,因为计算机可以提前为需要的变量分配内存。但是在很多应用场合中,可能程序运行时不清楚到底需要多少内存,这时候可以使用指针,让程序在运行时获得新的内存空间 (实际上应该就是动态内存分配 malloc , calloc ), 并让指针指向这一内存更为方便.

# 指针的声明

🔼

语法: 数据类型 * 指针名称 = 初始地址;
示例:

int main(void)
{
    int i_variable = 10;
    double d_variable = 1.1;
    // 声明一个 指向整型的指针,指向 i_variable
    int *ptr_a = &i_variable; 
    // 声明一个 指向整型的指针,指向 d_variable
    int *ptr_b = &d_variable; 
    return 0;
}

# 指针的使用

🔼

两个和指针息息相关的运算符:

  • & 是 C 语言中的取地址符号,用于获取地址
  • * 是 C 语言中的解引用的符号,用于获取地址上的值

操作:

  • 修改指向的地址;
    语法: ptr_b = &a;
  • 修改指向地址上的值;
    语法: *ptr_b = 100;
  • 所有指针都之和 处理位数以及 编译器相关,一般来说 是 8 字节或者 4 字节,比较特殊的 51 单片机是 12 位

示例 1:

int main(void)
{
    int i_variable1 = 10;
    int i_variable2 = 20;
    double d_variable = 1.1;
    // 声明一个 指向整型的指针,指向 i_variable1
    int *ptr_a = &i_variable1; 
    // 声明一个 指向整型的指针,指向 d_variable
    int *ptr_b = &d_variable; 
    // 打印指针大小
    printf("sizeof(ptr_a) = %d\n",sizeof(ptr_a));
    printf("sizeof(ptr_b) = %d\n",sizeof(ptr_b));
    // 打印 i_variable 指向的地址上的值
    printf("ptr_a = %d\n", *ptr_a);
    ptr_a = &i_variable2; // 改变 ptr_a 的指向
    printf("ptr_a = %p\n", *ptr_a);
    *ptr_a = 100;
    printf("i_variable1 = %d\n", i_variable1); // 
    printf("i_variable2 = %d\n", i_variable2);
    printf("ptr_a = %d\n", *ptr_a);
    return 0;
}

# 空指针

🔼

  • 定义:指针变量指向内存中编号为 0 的空间
  • 用途:初始化指针变量,不知道指哪里,就先指向这里
  • 注意:空指针指向的内存是不可以访问的
  • 示例:
int main(void)
{
	// 指针变量 p 指向内存地址编号为 0 的空间
	int * p = NULL;
	// 访问空指针报错 
	// 内存编号 0 ~255 为系统占用内存,不允许用户访问
	printf("0x%x", p);
	printf("%d", *p);
	return 0;
}

# 野指针

🔼

  • 定义:指针变量指向非法的内存空间
    非法空间是指,指针指向系统和程序协商后可访问空间之外的地址
    野指针

  • 示例:

int main(void) 
{
	// 指针变量 p 指向内存地址编号为 0x1100 的空间
	int * p = (int *)0x1100;
	// 访问野指针报错 
	printf("%d", *p);
	return 0;
}

# 一级指针

🔼

简单来说就是只有 一个 * 运算符的指针变量,大多数的一维指针会用于指向结构体、数组、字符串...

# 指向变量的指针

🔼

指向变量的语法,如上面指针的基本内容所述,只要存在数据类型,都可以定义指针
这说一个比较特殊的 指针 —— void 指针,众所周知, void 是一种空类型,那么,正常思路下指向 void 的指针,就是指向空的指针,简称除了 NULL 啥都不能指.
其实不然,正所谓 万物皆虚,万事皆允 (we are assassins!), void 指针啥都能指,最简单的例子就是 内置 qsort 的回调函数 cmp(const void *a, const void *b) , 咱们总不可能开两个空数组吧 (滑稽)
其实无论什么数据,在存储器上都是高低电平,只是读取的方式不同,才有了不同的数据类型, void 指针正是依靠了这个特性,通过强制类型转换来实现,任意传参.
其实无论什么指针,都可以强制转化,用于传递地址,只是因为指针只存储地址,且数据的意义只与读取方式相关所以才可以进行相互转换
PS: 个人建议,如果要传入不知道什么类型的数据时可以考虑以 void * 作为参数,不到万不得已,不要使用其他指针来实现类型转换

示例

typedef struct _Nodes
{
    int id;
    char *str;
} MyStruct;
int main(void)
{
    int a = 10;
    double d = 1.1;
    MyStruct node1 = {1, "This is a test about void pointer"};
    void *v_ptr = &a;
    // 转化位 int
    printf("====================int=======================\n");
    printf("a = %d\n", a);
    printf("&a = 0x%x\n", &a);
    printf("*v_ptr = %d\n", *(int *)v_ptr);
    printf("v_ptr = 0x%x\n", v_ptr);
    // 转化位 double
    v_ptr = &d;
    printf("====================double=======================\n");
    printf("d = %.2lf\n", d);
    printf("&d = 0x%x\n", &d);
    printf("*v_ptr = %.2lf\n", *(double *)v_ptr);
    printf("v_ptr = 0x%x\n", v_ptr);
    // 转化为 结构体
    v_ptr = &node1;
    printf("====================structer=======================\n");
    printf("node.id = %d\n", node1.id);
    printf("node.str = %s\n", node1.str);
    printf("&d = 0x%x\n", &node1);
    printf("(*v_ptr).id = %d\n", (*(MyStruct *)v_ptr).id);
    printf("(*v_ptr).str = %s\n", (*(MyStruct *)v_ptr).str);
    printf("v_ptr = 0x%x\n", v_ptr);
    return 0;
}

示例 2:
这一部分是标准库调用示例:

int cmp(const void *a, const void *b)
{
    return (*(int *)a) > (*(int *)b) ? 1 : -1;
}
void travelArray(int *arr, int n);
int main(void)
{
    int n;
    // 获取数组长度
    scanf("%d", &n);
    // 动态开辟数组
    int *arr = (int *)malloc(sizeof(int) * n);
    // 读入数据
    for (int i = 0; i < n; i++)
    {
        scanf("%d", arr + i);
    }
    
    travelArray(arr, n);
    qsort(arr, n, sizeof(arr[0]), cmp);
    travelArray(arr, n);
    return 0;
}
/**
 * @brief 遍历数组
 * 
 * @param arr   数组首地址
 * @param n     数组大小 
 */
void travelArray(int *arr, int n)
{
    for (int i = 0; i < n; i++)
    {
        printf("%d ", arr[i]);
    }
    putchar('\n');
}

# const 指针

🔼

这就是一个经典的问题了,** **, const 修饰不同 的地方,指针的效果就不一样,const 一共有三种修饰方式:

  • 第一种: const int* ptr = &a; 常量指针,可以改变指向方向
  • 第二种: int* const ptr = &a; 指针常量,可以改变地址上的值
  • 第三种: const int* const ptr = &a; 上面两种的结合体,可以称为指针常数

顾名思义, 常量指针,指向常量的指针,指向的是常量,指针不是常量,可以改变指向的地址,但是不能改变指向的值
指针常量,指针自己是一个常量,指向的不一定是常量,所以可以改变所指向地址上的值,不能改变指向的地址
指针常数,这个就不多说了,啥都改不了,指向的是常数

示例:

int main(void)
{
    int a = 10, b = 20;
    int *pa = &a;
    // 常量指针
    const int *cpa = pa;
    // 指针常量
    int *const pca = pa;
    // 指针常数
    const int *const cpca = pa;
    // cout << "a = " << a << endl;
    printf("pa = 0x%x\n", pa);
    printf("*cpa = %d\n", *cpa);
    printf("*pca = %d\n", *pca);
    printf("*cpca = %d\n", *cpca);
    puts("================修改cpa指向==================\n");
    cpa = &b; // 正确,修改常量指针指向的地址
    //pca = &b; // 错误,修改指针指常量向的地址
    printf("&b = 0x%x\n", &b);
    printf("cpa = 0x%x\n", cpa);
    printf("pca = 0x%x\n", pca);
    cpa = &a;
    puts("=================修改pca指向的值===============\n");
    // *cpa = 90;   // 错误,修改常量指针的指向的变量的值
    *pca = 100; // 正确,修改指针常量指向的值
    printf("*pa = %d\n", *pa);
    printf("*cpa = %d\n", *cpa);
    printf("*pca = %d\n", *pca);
    //cpca = &b;   // 错误,双 const 啥都不能改
    // *cpca = 90;  // 错误,双 const 啥都不能改
    return 0;
}

# 指向函数的指针

话外:为什么不能用二级指针直接指向二维数组

  1. 什么是函数指针:
    和变量类似,如果在程序中定义了一个函数,那么在编译时系统就会为这个函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址,在 debug 时,我们会在主栈中看到,被压入的函数的地址。而且函数名表示的就是这个地址。既然是地址我们就可以定义一个指针变量来存放,这个指针变量就叫作函数指针变量,简称函数指针.
  2. 函数指针的声明:
    函数指针与其他指针声明方式不同,正如前面所说,指针只能指向一种数据类型,所以 函数指针的声明有些复杂,大致格式如下:
    返回数据类型 (* 指针名称)(参数列表);
    例如: int (*funPtr)(int *, int *);
    这个语句就定义了一个指向函数的指针变量 funPtr . 首先它是一个指针变量,所以要有一个 * , 即 (*p); 其次前面的 int 表示这个指针变量可以指向返回值类型为 int 型的函数;后面括号中的两个 int 表示这个指针变量可以指向有两个参数且都是 int 型的函数。所以合起来这个语句的意思就是:定义了一个指针变量 funPtr , 该指针变量可以指向返回值类型为 int 型,且有两个整型参数的函数. funPtr 的类型为 int(*)(int, int)
  3. 如何用函数指针调用函数
    int Func(int x);   /* 声明一个函数 */
    int (*pFunc) (int x);  /* 定义一个函数指针 */
    pFunc = Func;          /* 将 Func 函数的首地址赋给指针变量 p*/
    PS: 函数指针 没有 ++-- 的操作
  • 示例
    #include <stdio.h>
    int Max(int x, int y); // 函数声明
    int main(void)
    {
        int a = 0, b = 0, c = 0;
        int (*p)(int, int); // 定义一个函数指针
        // 把函数 Max 赋给指针变量 p, 使 p 指向 Max 函数
        p = Max;
        printf("please enter a and b:");
        scanf("%d%d", &a, &b);
        // 通过函数指针调用 Max 函数
        c = (*p)(a, b);
        printf("a = %d\nb = %d\nmax = %d\n", a, b, c);
        return 0;
    }
    /**
     * @brief 比较连个数大小
     * 
     * @param x 比较数 x
     * @param y 比较数 y
     * @retval int 最大值
     */
    int Max(int x, int y) // 定义 Max 函数
    {
        return x > y ? x : y;
    }

# 二级指针

🔼
与 一级指针类似,需要两次 * 操作才能得到最顶层值 的 指针变量,最常见的就是 字符串数组。一级指针往往比较简单,维度一升高 后就开始变得复杂起来
三者之间的关系如图,手残,将就一下
指针与数据
在内存中的图示
内存上的映射

# 指向指针的指针

🔼

指针可以指向一份普通类型的数据,例如 int、double、char 等,也可以指向一份指针类型的数据,例如 int *double *char * 等,所以就有了指向指针的指针
上面图片的关系用 C 语言来描述就是

int a = 10;
int *ptr_a = &a;
int **pptr_a = &ptr_a;

指针变量也是一种变量,也会占用存储空间,也可以使用 & 获取它的地址.C 语言不限制指针的级数,每增加一级指针,在定义指针变量时就得增加一个星号 * . p1 是一级指针,指向普通类型的数据,定义时有一个 * ; p2 是二级指针,指向一级指针 p1 , 定义时有两个 * .

那么,为什么要有二级指针呢?
先来看一下这段代码:
有两个变量 a , b , 指针 q , q 指向 a , 我们想让 q 指向 b , 在函数里面实现.
这里贴一下用于测试的主函数

#include <stdio.h>
int a = 10;
int b = 100;
int *q;
void func(int *p);
int main(void)
{
    printf("&a=0x%x, &b=0x%x, &q=0x%x\n", &a, &b, &q); // 1
    q = &a;
    printf("*q=%d, q=0x%x, &q=0x%x\n", *q, q, &q); // 2
    func(q);
    printf("*q=%d, q=0x%x, &q=0x%x\n", *q, q, &q); // 5
    return 0;
}
  • 用 一级指针 实现:
    void func(int *p)
    {
        printf("func: &p=0x%x, p=%d\n", &p, p); // 3
        p = &b; // 让指针 p 指向 b;
        printf("func: &p=0x%x, p=%d\n", &p, p); // 4
    }

看起来 在逻辑上代码没有什么问题,但是众所周知,程序执行后 *q 不等于 100, 为什么呢?
来简单看一下,测试输出的结果

&a=0x403010, &b=0x403014, &q=0x407970
*q=10, q=0x403010, &q=0x407970
func: &p=0x61fe00, p=4206608  
func: &p=0x61fe00, p=4206612  
*q=10, q=0x403010, &q=0x407970

来分析一下输出:

  • 注释 1: a, b, q 都有一个地址.
  • 注释 2: q 指向 a , q 的值发生了变化,地址是固定的
  • 注释 3: 进入函数后的参数 p 的地址跟 q 不一样了。这是因为在函数调用时,为了保障原数据不变对其进行了拷贝,也就是说 pq 不是同一个指针,但是 他们指向的地址是相同的,都指向 &a (0x403010)
  • 注释 4: p 指向 b , 这时候 p 的值发生了变化
  • 注释 5: 回到主函数后,函数栈释放, p 也就丢失了, q 也不会有任何变化.

结论:
编译器会对函数的每个参数制作临时副本,指针参数 p 的副本是 q , 编译器使 p = q (但是 &p != &q , 也就是他们并不在同一块内存地址,只是他们的内容一样,都是 a 的地址). 如果函数体内的程序修改了 p 的内容 (比如在这里它指向 b). 在本例中,p 申请了新的内存,只是把 p 所指的内存地址改变了 (变成了 b 的地址,但是 q 指向的内存地址没有影响), 所以在这里并不影响函数外的指针 q.

其实,这就是 所谓的 传值调用传地址调用,这两个概念就是一个抽象概念,value 和 address 是相对的,对于指针变量来说,传值 也是地址,传地址也是地址,只不过前者是传递指向的地址,后者是传递本身的地址.

例如 swap 函数,如果参数为 (int a, int b) , 那就是传值调用,因为我们想要交换 ab 的值,如果仅仅传入值,那么调用函数产生的副本,也仅仅是 数值与 a b 相同的两个全新变量而已。我们想要交换两个变量,就必须要传入地址,在地址上直接对值进行操作.

上例中, p 对应的是 a b 变量,我们只传进想要改变的值,而非传入值所在的地址,所以 q 并没产生变化。这时候我们就需要传入,指针 *q 的地址了,对应的函数参数类型,就变量了指向指针的指针,也就是二级指针.

  • 二级指针操作

    void func(int **p)
    {
        printf("func: &p=0x%x, p=%d\n", &p, p); 
        *p = &b; 
        printf("func: &p=0x%x, p=%d\n", &p, p); 
    }

    改动的地方很少.

    因为传了指针 q 的地址 (二级指针 **p ) 到函数,所以二级指针拷贝 (拷贝的是 p , 一级指针中拷贝的是 q , 就是指向的地址),(拷贝了指针但是指针内容也就是指针所指向的地址是不变的) 所以它还是指向一级指针 q ( *p = q ). 在这里无论拷贝多少次,它依然指向 q , 那么 *p = &b; 自然的就是 q = &b; 了.

  • PS:
    到这里其实我,想说的是其实 一级指针也可以实现二级指针的效果,但是并不推荐,咱们永远不知道这种方法的通用性,
    我们可以 一级指针的调用的时候传入 q 的地址,然后把赋值语句改为 *p = &b; 也可以得到相应的效果,因为指针都是 8 字节,里面进行了一次 (隐式) 强制类型转换。由于指针指向的类型比较简单,没有导致数据异常,所以 GUN 仅仅是抛出了 warming.

# 指针与数组

🔼
这里 大概会涉及到几个内容:

  • 指针 与 数组首地址
  • 指针数组 和 指向数组的指针

先声明一下:

  • 指针:是用于存储 地址的变量
  • 数组:是一串相同类型变量的
  • 地址:是一种数据,与指针不同的地方在于,没有数据类型 (某种意义上像是指针常量)

先来说一下第一个:指针 与 数组首地址

在我初学指针的时候一直有一个疑惑就是 int arr[4];int *ptr; 的区别,因为老师经常说,数组的首地址等价于指针,而且 访问数组的时候使用 arr[1]*(arr+1) 的效果是一样的,我一度就把 一级指针 等价为 一维数组.
直到有一次,有个小姐姐问我,为什么 sizeof(ptr) 算不出数组大小 而 sizeof(arr) 可以。我当时的回答是,因为 arr 是 一个数组 ptr 是一个指针变量。说完我就察觉到不对了,如果 arr 完全等价于 ptr 那么为什么 sizeof(ptr) 不等于 sizeof(arr) , arr = ptr 会报错。果然啊,教学相长 (有点丢脸,错失良机啊)
其实 数组的首地址、变量的取地址 这些得到的都是指针常量 (上面说过, 传送门), 只能充当右值,不能作为左值.
ptr 仅仅是一个指针变量,用于存储地址的变量。对于二维数组,也是如此, int arr[2][3] = {0}; , arr 整个二维数组的首地址, arr[i] 为 每一行一维数组的首地址.
示例:

#include <stdio.h>
int main(void)
{
    int arr[9] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
    int *ptr1 = arr;
    for (int i = 0; i < 9; i++)
    {
        printf("ptr1_val = %d \n", ptr1[i]);
    }
    return 0;
}

接着咱们 来讨论一下:指针数组和指向数组的指针

这个问题 经常出现在,声明指向二维数组指针上,经常会有 问题是 int *arr[5]int(*arr)[5] 哪个是指向数组的指针之流.
在说这个问题之前,先来说明一下两个概念:

  • 行指针:指的是一整行,不指向具体元素
    声明格式: 数据类型 (*指针名)[长度];
  • 列指针:指的是一行中某个具体元素 (一维指针)
    声明格式: 数据类型 *指针名称
    示例
#include <stdio.h>
int main(void)
{
    int arr[9] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
    int *ptr1 = arr;        // 声明列指针
    int(*ptr2)[9] = &arr;   // 声明行指针
    for (int i = 0; i < 9; i++)
    {
        printf("ptr1_val = %d, ptr2_val = %d\n", ptr1[i], (*ptr2)[i]);
    }
    return 0;
}

PS: 可以将列指针理解为行指针的具体元素,行指针理解为列指针的地址

言归正传,假设 我们需要声明一个指向 int arr[5][9]; 和 一个大小为 4 指向整型指针的数组.

先来解决第一个:

假设 指针名为 ptr

  1. ptr 先是一个指针,所以第一步是 *ptr ,
  2. *ptr 指向的是数组,由于 [] 运算优先级比 * 高,所以 需要加上 () , 即 (*arr)[9]
  3. (*arr)[9] 指向的类型为 int , 得出最终结果 int (*arr)[9] = &arr;

然后是指针数组

设 ptrs 为数组名:

  1. ptrs 先是一个数组,得到 ptrs[4]
  2. ptrs[4] 的成员是指针,得到 *ptrs[4]
  3. *ptrs[4] 成员指向的数据类型为 int , 得到 int ptrs[4];

小结:指向数组的指针格式一般以 (*指针名) 打头,而指针数组一般以 *数组名 打头

正如上面所说,一般情况下指向数组的指针格式以 (*指针名) 打头,咱们这行只要是一般,就必然有例外.
由于 数组是在一块连续的内存上定义的 所以 只要找到 第一个元素所在的地址,即数组起始地址,就等级于找到整个数组.
所以就有了 p = &arr[0]p = &arr[0][0]*p = &arr[0][0][0]; 的奇观.
这也就是上面所说的 列指针。突然就感觉 上面的行指针不香了 (滑稽)

示例:

#include <stdio.h>
int main(void)
{
    int arr[3][3] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
    int *ptr1 = &arr[0][0];
    int(*ptr2)[3] = arr;
    for (int i = 0; i < 9; i++)
    {
        printf("ptr1_val = %d, ptr2_val = %d\n", ptr1[i], (*ptr2)[i]);
    }
    printf("======================================================\n");
    for (int i = 0; i < 3; i++)
    {
        for (int j = 0; j < 3; j++)
        {
            printf("ptr1_val = %d, ptr2_val = %d\n", ptr1[i * 3 + j], ptr2[i][j]);
        }
    }
    return 0;
}

# 话外:为什么不能用二级指针直接指向二维数组

🔼

举个例子:

int arr[2][3] = {1, 2, 3, 4, 5, 6}; 
    int **pptr = arr; // 编译出错,不能用二级指针直接指向二维数组
    int(*ptrRow)[3] = arr; // 对,ptrRow 是指向一维数组的指针,可以指向二维数组
    int *ptrCol = arr[0]; // 可以,ptrCol 也是一维指针,可以指向二维数组

理论上一维数组对应一维指针,例如 int arr[3]; int *ptr = arr ;
那么二维数组应该也对应于二级指针才对啊.
对于这个问题,咱们先来看一下二级指针的定义:
二级指针指向一级指针,一级指针是取一次地址,二级指针是取两次地址.
就此可以推及到 更高的维度: n 级指针是指向 n-1 级指针的指针,n 级指针是取 n 次地址

现在我们来分析一下
int **pptr = arr; 为什么会出错
首先 **pptr = arr 是等价于 **pptr = &arr[0][0];
那么 *ptr 得到的结果为 1 , 如果 再对其进行 * 操作,就会访问到 内存地址 1 上的值,显然这是不允许的
PS: 二级指针是指向一级指针的,那么二级指针 pptr 每次移动的大小就是 sizeof(int *) 也就是 8 个字节,所以 pptr+1 不是像二维数组 arr+1 那样移动到下一行首地址,而是移动 8 个字节.

int **pp=a; 不行。那 int **pp=&a; 呢?
很遗憾也不行,原因也是数据类型不一致,导致地址偏移非法.

凡事总有那么个例外:
例如:

char *str[2] = {"hello","world"};
char **strptrs = str;

本质原因是因为这是一个指针数组,而非 真正的二维数组,每一行的首地址 本身即为指针,符合了两次取地址的要求。而且指针偏移量也为 8 字节, strptrs++ 偏移正常,所以 strptrs 才能指向 str

其实这个问题的核心在于 数据类型的不匹配 导致的地址自加异常.
示例

#include <stdio.h>
int main(void)
{
    int arr[2][4] = {1, 2, 3, 4, 5, 6, 7, 8};
    int **pptr = arr;
    char *str[4] = {"hello", "world", "C pointer", "G++"};
    char **strptrs = str;
    printf("%c\n", strptrs[0][0]);
    //printf ("% d\n", pptr [0][1]);// 异常,不会打印数据
    return 0;
}

# 话外:如何定义一个返回函数指针且参数为函数指针的函数

2021.10.21 更新
首先来说明一下,函数指针的类型为 指向没有返回值且参数为 int 的函数
咱一步步来,首先函数的定义如下:

int func(void); // 返回值为整型 参数为空

这一个基本的带返回值的函数声明,现在咱来回顾一下,函数指针是什么样的

int *func(void);

现在我再把参数放入

int *func(void(*func_p)(int));

最后再将 int 也替换为 void(*func_p)(int)
那么得出的结果如下:

(void(*ptr)(int id)) *func(void(*func_p)(int));

然而这就是想当然了。放弃刚刚的想法,咱们来分析一下这个声明
前半部分声明了一个指针变量,后半部分声明了一个函数,再 *func 前面加个分号刚刚好拆成两个声明.
如果不能这么定义那么函数指针又该如何定义呢?

很显然 对于 C 语言编译器来说,它先扫描到了 ptr 这个关键字,进行类型匹配后判定为指针变量,然后再扫描到 func 进行识别后判定为 函数.
一个变量当然不能作为函数的返回值来使用,所以返回 函数指针的函数 该如何定义 返回值的函数指针呢?

首先, func(void(*func_p)(int)) 这一部分是没有什么问题的,来回以一下,函数指针的通式是什么样的
返回值类型 (*指针名称)(参数); , 既然从左到右最左边的非关键字的字符串视为函数名,那么就只能以 func 为中心进行处理.
void (* )(int id); 前缀的缩写因该是这样的,再将 func(void(*func_p)(int)) 视为整体当作变量名称放入括号中.
得到 void (*func(void(*func_p)(int)))(int id) , 这个定义刚好就是所需要的定义.

抛开上面的所有来分析一下,首先从 func 开始,

  1. func 先和 () 结合,所以 func 是个函数
  2. 再读取括号中的信息, func 是一个函数,参数为指针,这个指针指向 返回值位空参数为 int 的函数
  3. 再和 * 结合, func 是一个返回指针的函数.
  4. (*func( )) 再和 void (int id) , 表示这是指向函数指针,指向 返回值位空,参数为 整型的函数

结合起来就是 func 是一个返回值为函数指针的函数,参数为指针,这个指针指向返回值为空参数为整型的函数,返回值的指针指向返回值为空参数为整型的函数.

# 但是

正儿八经的声明永远不长这样,这么复杂的声明咱还是别写的好. C 语言为了化简数据类型的定义专门出了一个关键字,叫 typedef .
咱们使用 typedef 来实现一下函数声明
typedef 的语法

typedef 类型声明 重命名

先举个简单的例子, typedef struct node* ListNode_PTR_T 这的意思就是将 结构体 struct node 的指针重命名为 ListNode_PTR_T 从此之二者等效使用。很类似 #define 但是 #define 仅仅是字符串的等效替代, debug 时无法进入,而且会有点 bug, 更详细的讨论会专门写一篇博客
言归正传其实在上面那个函数的声明,返回值和参数时一致的 可以使用 typedef 来简化.
如下:

typedef void (*RVOID_PINT_FUNCPTR_T)(int);

那么 上面的声明就可以简写为

RVOID_PINT_FUNCPTR_T func(RVOID_PINT_FUNCPTR_T);

这里是验证测试的代码

// 原始定义
void (*func(void (*func_p)(int)))(int id);
// 重命名一个 指向 返回值为空参数为整型的函数 的指针类型 为 RVOID_PINT_FUNCPTR_T
typedef void (*RVOID_PINT_FUNCPTR_T)(int);
RVOID_PINT_FUNCPTR_T funcx(RVOID_PINT_FUNCPTR_T func_ptr);
// 重命名 指向上面这个函数的指针 的类型名称
typedef RVOID_PINT_FUNCPTR_T (*TEST_TYPE)(RVOID_PINT_FUNCPTR_T func_ptr);
// 赋值成功表示类型向等
TEST_TYPE CC = func; 
TEST_TYPE C = funcx;

这样子的代码配上注释可读性就大大提升,毕竟源码是给人看,没必要写的云里雾里.
但是毕竟早些年的人会不乐意使用 typedef, 所以阅读能力还是需要有的
例如,ANSI C 的信号的的声明,啧啧啧
signal
signal_ANSI_C
不过现在 Window 下的声明就不一样了
signal_Windows

以上都是,个人浅薄的见解,如有不当,欢迎各位大佬们指出