第十五讲 指针 从本质吃透 C 语言指针(上)

本文Gittee: 东华逐梦码为径,万里探真路自长。

指针是 C 语言的灵魂,也是初学者的 "拦路虎"。很多人觉得指针难,核心是没搞懂 "地址" 和 "指针变量" 的本质关系。这篇文章会抛开复杂概念,用生活案例 + 极简代码,从底层逻辑到实战用法层层拆解,让你彻底摸清指针的门道。

一、先搞懂底层:内存、地址和指针的本质

要学指针,先明白计算机是怎么管理内存的 ------ 指针的所有操作,本质都是和 "内存地址" 打交道。

电脑内存功能

电脑内存就像 CPU 的 "专属工作台"------ 硬盘是存放所有资料的仓库,而内存是 CPU 干活时临时摆资料、工具的桌面。

核心功能:

  • 程序运行前,会从 "仓库"(硬盘)搬到 "工作台"(内存),方便 CPU 快速取用。
  • 工作台越大(内存容量越大),能同时摆下的程序、数据越多,CPU 不用频繁跑仓库取东西,干活效率更高。
  • 活儿干完(程序关闭 / 关机),工作台上的临时资料会清空,下次用再重新从仓库调取。

1.1 内存:计算机的 "宿舍楼"

计算机的内存就像一栋宿舍楼,每间 "宿舍" 就是一个内存单元,大小固定为 1 字节(能存 8 个二进制位,类似 8 人间宿舍)。

  • 宿舍要编号才能快速找到人,内存单元也需要编号,这个编号就是地址
  • C 语言里,地址还有个专属名字:指针
  • 核心结论:内存单元的编号 = 地址 = 指针(三者完全等价)。

1.2 地址是怎么来的?硬件编址的底层逻辑

地址不是凭空生成的,是计算机硬件通过 "地址总线" 设计出来的:

  • 32 位机器有 32 根地址总线,每根线只有 0/1 两种状态(有电 / 没电)。
  • 32 根线能组合出 2³² 种状态,每种状态对应一个地址,所以 32 位系统最大支持 4GB 内存(2³² 字节 = 4GB)。
  • 64 位机器有 64 根地址总线,支持的内存空间更大(理论上 16EB)。

1.3 关键单位换算

cpp 复制代码
1Byte(字节)= 8bit(比特位)
1KB = 1024Byte
1MB = 1024KB
1GB = 1024MB
1TB = 1024GB

二、核心工具:指针变量的定义与使用

知道了地址的本质,指针变量就好理解了 ------ 它就是专门用来存 "地址" 的变量,类似用小本本记宿舍门牌号。

2.1 取地址操作符 &:拿到变量的 "门牌号"

我们在第二讲第六节中讲过变量创建的本质变量创建的本质是对内存空间的申请,忘了可以回去看看

创建变量时,计算机会给变量分配内存空间(比如 int 变量占 4 字节),& 操作符能取出变量的地址(即首字节地址)。

代码示例

cpp 复制代码
#include <stdio.h>
int main() 
{
    int a = 10; // 申请4字节内存存10
    printf("a的地址:%p\n", &a); // %p是地址的格式化输出符
    return 0;
}

运行结果

cpp 复制代码
a的地址:006FFD70(随机值、X86环境)

实际上:变量 a 占 4 字节,地址分别是 006FFD70、006FFD71、006FFD72、006FFD73,&a 取出的是最小的首地址。

2.2 指针变量定义:存地址的 "小本本"

指针变量的定义格式:**数据类型 * 变量名,**其中:

  • * 表示这是指针变量。
  • 前面的 "数据类型" 表示指针指向的变量类型(关键!后面会讲)。

代码示例

cpp 复制代码
#include <stdio.h>
int main() 
{
    int a = 10;
    int *pa = &a; // 定义int*类型指针pa,存a的地址
    char ch = 'w';
    char *pc = &ch; // 定义char*类型指针pc,存ch的地址
    return 0;
}

2.3 解引用操作符 *:通过地址访问变量

拿到地址后,用*操作符能通过地址找到变量(类似按门牌号找宿舍),这就是 "解引用"。

代码示例

cpp 复制代码
#include <stdio.h>
int main() 
{
    int a = 10;
    int *pa = &a; // pa存a的地址
    *pa = 20; // 解引用:通过pa的地址修改a的值
    printf("a = %d\n", a); // 输出a = 20
    return 0;
}

核心逻辑:*pa 等价于 a,通过指针修改*pa,本质就是修改变量 a。

2.4 指针变量的大小(关键结论)

指针变量的大小只和系统位数有关,和指向的类型无关:

  • 32 位系统:地址是 32 位(4 字节),所有指针变量都是 4 字节,对应 VS2022 X64。
  • 64 位系统:地址是 64 位(8 字节),所有指针变量都是 8 字节,对应 VS2022 X86。

代码验证

cpp 复制代码
#include <stdio.h>
int main() {
    printf("char* 大小:%zd\n", sizeof(char*));
    printf("int* 大小:%zd\n", sizeof(int*));
    printf("double* 大小:%zd\n", sizeof(double*));
    return 0;
}

运行结果

  • 32 位环境:4 4 4
  • 64 位环境:8 8 8

三、指针类型的真正意义(初学者易忽略)

既然指针变量大小和类型无关,为什么还要分 int*、char*?核心是两个关键作用:

3.1 决定解引用的 "操作权限"

指针类型决定了解引用时能访问多少字节:

  • char* 解引用:访问 1 字节(char 类型大小)。
  • int* 解引用:访问 4 字节(int 类型大小)。

代码对比

注意,下面代码对应的注释是在它们上面

在林锐前辈所著《高质量的 C++/C 编程指南》中,关于注释位置的规范表述为:

【规则 2-7-6】注释的位置应与被描述的代码相邻,可以放在代码的上方或右方,不可放在下方。

详见:《高质量 C++/C 编程指南》注释规范 + VS2022 模板

cpp 复制代码
// 代码1:int* 解引用修改4字节
#include <stdio.h>

int main()
{
    // 定义4字节int型变量,十六进制初始化
    int n = 0x11223344;
    // 指向int变量的指针
    int* pi = &n;
    // 解引用修改整个4字节为0
    *pi = 0;
    // %#x强制输出0x前缀,符合十六进制规范
    printf("n = %#x\n", n); // 稳定输出:n = 0x0
    return 0;
}



// 代码2:char* 解引用修改1字节
#include <stdio.h>

int main()
{
    // 4字节int型变量,小端存储:内存中字节顺序为 0x44 → 0x33 → 0x22 → 0x11(低地址→高地址)
    int n = 0x11223344;
    // 强制转换为char*(仅指向首字节,即0x44所在地址)
    char* pc = (char*)&n;
    // 仅修改首字节为0,其余3字节保留
    *pc = 0;
    // 输出结果:0x11223300
    printf("n = %#x\n", n); 
    return 0;
}

你可能会发现,代码 1 运行后输出的n = 0没有显示0x前缀。这是因为 VS2022 的printf%#x在值为 0 时的特殊表现 ------ 当输出值是 0 时,部分编译器(包括 VS2022)会省略0x前缀,只显示0

解决方法(让 0 也显示0x0):

printf语句改成:

cpp 复制代码
printf("n = 0x%x\n", n); // 手动加0x前缀,强制显示

这样无论 n 是 0 还是其他值,都会固定输出0x开头的十六进制格式(比如 n=0 时显示0x0,n=0x11223344 时显示0x11223344)。

3.2 决定指针 ± 整数的 "步长"

指针 ± 整数时,跳过的字节数 = 指针类型的大小:

  • char* + 1:跳过 1 字节(char 大小)。
  • int* + 1:跳过 4 字节(int 大小)。

代码验证

cpp 复制代码
#include <stdio.h>
int main()
 {
    int n = 10;
    char *pc = (char*)&n;
    int *pi = &n;
    printf("pc = %p\n", pc);     // 输出n的首地址,如00AFF974
    printf("pc+1 = %p\n", pc+1); // 跳过1字节,输出00AFF975
    printf("pi = %p\n", pi);     // 输出00AFF974
    printf("pi+1 = %p\n", pi+1); // 跳过4字节,输出00AFF978
    return 0;
}

3.3 特殊类型:void* 泛型指针

void* 是 "无类型指针",能接收任意类型的地址,但有两个限制:

  • 不能直接解引用。
  • 不能直接 ± 整数。

用途:常用于函数参数,实现泛型编程(比如接收 int、char、double 等任意类型地址)。

代码示例

cpp 复制代码
#include <stdio.h>
int main() 
{
    int a = 10;
    char ch = 'w';
    void *pv1 = &a; // 合法:void*接收int*地址
    void *pv2 = &ch; // 合法:void*接收char*地址
    // *pv1 = 20; // 非法:不能直接解引用
    // pv1 += 1; // 非法:不能直接±整数
    return 0;
}

四、避坑指南:const 修饰指针与野指针

4.1 const 修饰指针:控制修改权限

const 放在*的左右,意义完全不同,记住口诀:左定值,右定针

指针类型 含义 能否修改指向的内容? 能否修改指针本身?
int *p 普通指针
const int *p(int const *p) 指向 const 的指针(左定值) 不能
int *const p const 指针(右定针) 不能
const int *const p 指向 const 的 const 指针 不能 不能

代码示例

cpp 复制代码
#include <stdio.h>
int main()
{
    int a = 10, b = 20;

    const int* p1 = &a;
    // *p1 = 30; // 错误:不能修改指向的内容
    p1 = &b; // 正确:可以修改指针指向
    printf("%d\n",*p1);//结果为20

    int* const p2 = &a;
    *p2 = 30; // 正确:可以修改指向的内容
    // p2 = &b; // 错误:不能修改指针指向
    printf("%d\n", *p2);//结果为30


    const int* const p3 = &a;
    // *p3 = 30; // 错误
    // p3 = &b; // 错误
    return 0;
}

4.2 野指针:最危险的 "陷阱"

野指针是指向未知地址的指针,访问野指针会导致程序崩溃,常见成因及规避方法:

4.2.1 野指针的 3 种成因
  1. 指针未初始化:局部指针变量默认是随机值。

    cpp 复制代码
    int *p; // 野指针:p的值随机
    *p = 10; // 危险:访问未知地址
  2. 指针越界访问:超出数组等合法空间范围。

    cpp 复制代码
    int arr[10] = {0};
    int *p = &arr[0];
    for (int i = 0; i <= 10; i++)
     {
        *(p++) = i; // 当i=10时,p越界,成为野指针
    }
  3. 指针指向的空间已释放:返回局部变量的地址。

    cpp 复制代码
    int *test()
     {
        int n = 10;
        return &n; // n是局部变量,函数结束后空间释放
    }
    int main()
     {
        int *p = test(); // p是野指针
        *p = 20; // 危险
        return 0;
    }
4.2.2 规避野指针的 4 个方法
  1. 初始化指针:不知道指向哪里时,赋值为NULL(C 语言定义的空指针,值为 0)

    cpp 复制代码
    int *p = NULL; // 合法:空指针,不指向任何有效地址
  2. 避免指针越界:访问范围不超过申请的内存空间。

  3. 指针不用时置为NULL,使用前检查有效性。

    cpp 复制代码
    int arr[10] = {0};
    int *p = &arr[0];
    // 使用后置空
    p = NULL;
    // 使用前检查
    if (p != NULL) 
    {
        *p = 10;
    }
  4. 不返回局部变量的地址。

4.3 assert 断言:调试时的 "安全检查"

从上面的"指针不用时置为NULL,使用前检查有效性。"可以拓展出assert

assert.h 中的assert()宏能在运行时检查条件,不符合则报错终止程序,适合调试时使用。

代码示例

cpp 复制代码
#include <stdio.h>
#include <assert.h>
void test(int *p) 
{
    assert(p != NULL); // 断言p不是空指针
    *p = 20;
}
int main() {
    int a = 10;
    int *p = NULL;
    test(p); // 运行时断言失败,报错并终止
    return 0;
}

说明:Release 版本中可通过定义#define NDEBUG禁用 assert,不影响程序效率。

详见:Release 版本禁用 assert:NDEBUG 的底层逻辑与效率优化

五、实战场景:指针与数组、函数传参

指针的核心用途是简化数组操作和实现函数传址调用,这是 C 语言的核心技巧。

5.1 数组名的本质:首元素地址

数组名在绝大多数情况下等于首元素的地址,只有两个例外:

  1. sizeof(数组名):数组名表示整个数组,计算数组总大小(字节)。
  2. &数组名:数组名表示整个数组,取出整个数组的地址。

代码验证

cpp 复制代码
#include <stdio.h>
int main()
{
    int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
    printf("&arr[0] = %p\n", &arr[0]); // 首元素地址
    printf("&arr[0]+1 = %p\n", &arr[0]+1); // 首元素地址+1
    printf("arr = %p\n", arr);         // 等价于&arr[0]
    printf("arr+1 = %p\n", arr+1);         // 等价于&arr[0]+1
    printf("\n");  

    printf("&arr = %p\n", &arr);       // 整个数组的地址(值相同,含义不同)
    printf("&arr+1 = %p\n", &arr+1);       // 整个数组的地址+1(与上面几句相比,增加的不是四个字节而是40个字节)
    printf("\n");


    printf("sizeof(arr) = %zd\n", sizeof(arr)); // 40字节(10*4)
    return 0;
}

结果截图:

可以看到,前两次的加一,打印的结果都是只加了4个字节,结合我们上面讲的:指针 ± 整数时,跳过的字节数 = 指针类型的大小可知:数组名arr等价于&arr[0],都是首元素地址,指针类型都为:int*,而&arr的类型则不同,它是数组指针,+1后打印的结果大了40就是佐证

5.2 用指针访问数组:更灵活的方式

因为arr = &arr[0],所以arr[i] 等价于 *(arr + i),指针访问数组更灵活。

代码示例

cpp 复制代码
#include <stdio.h>
int main() {
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    int *p = arr; // 等价于int *p = &arr[0]
    int sz = sizeof(arr)/sizeof(arr[0]); // 计算数组长度
    
    // 用指针遍历数组
    for (int i = 0; i < sz; i++) {
        printf("%d ", *(p + i)); // 等价于p[i]、arr[i]
    }
    return 0;
}

5.3 一维数组传参的本质:传递首元素地址

数组传参时,本质是传递首元素的地址,因此:函数形参可以写成数组形式或指针形式(完全等价)。

注意 :函数内部不能用sizeof(arr)计算数组长度(此时 arr 是指针,计算的是指针大小)。

代码示例

cpp 复制代码
#include <stdio.h>
// 形参写成数组形式
void print_arr1(int arr[], int sz)
{
    for (int i = 0; i < sz; i++)
    {
        printf("%d\n ", arr[i]);
    }
}
// 形参写成指针形式(等价)
void print_arr2(int* arr, int sz)
{
    for (int i = 0; i < sz; i++)
    {
        printf("%d\n ", *(arr + i));
    }
}
int main()
{
    int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
    int sz = sizeof(arr) / sizeof(arr[0]);
    print_arr1(arr, sz); // 传递首元素地址
    print_arr2(arr, sz);
    return 0;
}

5.4 传址调用:函数修改主调变量的值

传值调用时,函数形参是实参的副本,修改形参不影响实参;传址调用传递地址,函数内部能直接修改主调变量。

经典案例:交换两个整数

cpp 复制代码
#include <stdio.h>
// 传值调用:失败(修改的是副本)
void Swap1(int x, int y) 
{
    int tmp = x;
    x = y;
    y = tmp;
}
// 传址调用:成功(通过地址修改实参)
void Swap2(int *px, int *py) 
{
    int tmp = *px;
    *px = *py;
    *py = tmp;
}
int main()
{
    int a = 10, b = 20;
    Swap1(a, b);
    printf("Swap1后:a=%d, b=%d\n", a, b); // 输出10,20(未交换)
    
    Swap2(&a, &b);
    printf("Swap2后:a=%d, b=%d\n", a, b); // 输出20,10(交换成功)
    return 0;
}

六、进阶内容:二级指针与指针数组

6.1 二级指针:指向指针的指针

指针变量也是变量,有自己的地址,二级指针就是用来存 "指针变量地址" 的变量。

定义格式数据类型 ** 变量名

代码示例

cpp 复制代码
#include <stdio.h>
int main()
 {
    int a = 10;
    int *pa = &a; // 一级指针:存a的地址
    int **ppa = &pa; // 二级指针:存pa的地址
    
    **ppa = 20; // 等价于*pa = 20,最终修改a的值
    printf("a = %d\n", a); // 输出20
    return 0;
}

核心逻辑

  • *ppa:访问 pa(一级指针变量)。
  • **ppa:访问 a(最终变量)。

6.2 指针数组:存放指针的数组

指针数组是 "数组",每个元素都是指针(地址),格式:数据类型 * 数组名[长度]

代码示例:指针数组模拟二维数组

cpp 复制代码
#include <stdio.h>
int main() 
{
    int arr1[] = { 1,2,3,4,5 };
    int arr2[] = { 2,3,4,5,6 };
    int arr3[] = { 3,4,5,6,7 };

    // 指针数组:每个元素存一维数组的首地址
    int* parr[3] = { arr1, arr2, arr3 };

    // 遍历模拟的二维数组
    for (int i = 0; i < 3; i++)
    {
        for (int j = 0; j < 5; j++) 
        {
            printf("%d ", parr[i][j]); // 等价于*(parr[i] + j)
        }
        printf("\n");
    }
    return 0;
}

运行结果

cpp 复制代码
1 2 3 4 5 
2 3 4 5 6 
3 4 5 6 7 

说明:这种模拟不是真正的二维数组,每行的数组在内存中不连续,只是逻辑上的二维结构。

七、总结

  1. 本质关系:地址 = 指针,指针变量是存放地址的变量。
  2. 核心操作:&取地址、*解引用(通过地址访问变量)。
  3. 指针大小:32 位系统 4 字节,64 位系统 8 字节,与类型无关。
  4. 类型意义:决定解引用的操作权限(字节数)和指针 ± 整数的步长。
  5. 避坑要点:初始化指针、避免越界、不用野指针、合理使用 const 和 assert。
  6. 核心用途:简化数组操作、实现传址调用(修改主调变量)。

OK,还是经典结尾:

嗯,希望能够得到你的关注,希望我的内容能够给你带来帮助,希望有幸能够和你一起成长。

写这篇博客的时候,晚灯映着代码的微光,指尖划过键盘的声响与夜色相融,调试与发布的逻辑在屏上渐次明朗。

东华逐梦码为径,万里探真路自长。

我走到阳台拍下了一张宿舍对面的照片作为本文的封面。

相关推荐
moxiaoran57532 小时前
Go 语言指针
开发语言·golang
爱吃大芒果2 小时前
Flutter 网络请求完全指南:Dio 封装与拦截器实战
开发语言·javascript·flutter·华为·harmonyos
云水木石2 小时前
Rust 语言开发的 Linux 桌面来了
linux·运维·开发语言·后端·rust
Logic1013 小时前
深入理解C语言if语句的汇编实现原理:从条件判断到底层跳转
c语言·汇编语言·逆向工程·底层原理·条件跳转·编译器原理·x86汇编
听风吟丶3 小时前
Java NIO 深度解析:从核心组件到高并发实战
java·开发语言·jvm
C++业余爱好者3 小时前
Java开发中Entity、VO、DTO、Form对象详解
java·开发语言
zmzb01033 小时前
C++课后习题训练记录Day50
开发语言·c++
froginwe113 小时前
`.toggleClass()` 方法详解
开发语言
lsx2024063 小时前
SQLite 附加数据库详解
开发语言