一篇文章深入理解指针

1 内存和地址

一、先理解 "内存":计算机的 "储物间"

可以把计算机的内存想象成一个编号整齐的储物间,里面有很多小的存储格

  • 每个 "储物格" 的大小是 1 字节(8 位)(最小可寻址单位);
  • 整个储物间的容量就是内存大小(比如 8GB=810241024*1024 字节);
  • 程序运行时,变量、数据、指令都会被存放在这些 "储物格" 里。

核心特点:

  1. 内存是临时存储(断电数据丢失),区别于硬盘的永久存储;
  2. 每个字节 都有唯一的 "位置标识"------ 这就是地址
  3. 不同类型的变量会占用不同数量的字节(比如 char 占 1 字节,int 占 4 字节)。

二、再理解 "地址":内存的 "门牌号"

地址就是内存中每个字节的唯一编号,类似储物间的 "门牌号":

  • 地址用十六进制数 表示(比如 0x7ffeefbff5ac),方便简写和计算;
  • 变量的地址,指的是它占用的第一个字节的地址 (比如 int 占 4 字节,只记录起始地址);
  • 程序通过 "地址" 找到对应的内存字节,读取 / 修改里面的数据。

三、内存和地址的核心关系(新手必懂)

1. 变量 = "数据值" + "内存地址"
  • 变量名是给程序员的 "别名",编译器会把变量名映射到对应的内存地址
  • 比如下面代码 int b = 200,本质是:找到地址 0x7ffeefbff5a8(随机) 开始的 4 个字节,把数值 200 的二进制存进去。(创建变量的本质是向内存申请一空空间)

如图 :调试,在内存1中输入&b,查看存放变量b的地址。输出变量b的地址,虽然变量b占用4个字节的空间(int型),但发现打印的是它占用的第一个字节的地址

我们只要知道了第⼀个字节地址,顺藤摸⽠访问到4个字节的数据也是可⾏的。

2. 地址的本质:整数

地址在底层就是一个整数(就像宿舍的寝室门牌号一样固定好的,表示内存的编号(就是每个字节的编号)),只是 C 语言用 指针类型变量(比如 int*,char*,float*)来专门表示 "地址",避免和普通整数混淆。指针变量也是⼀种变量,这种变量就是⽤来存放地址的(内存也会为他分配空间),存放在指针变量中的值都会理解为地址。

3. 内存访问的两种方式
  • 直接访问 :通过变量名(比如 printf("%d", b)),编译器自动根据变量名找到地址(变量名是给程序员的 "别名",编译器会把变量名映射到对应的内存地址 ),读取数据;
  • 间接访问 :通过地址(也就是指针)(我们通过取地址操作符(&)拿到的地址是⼀个数值,)比如:
cpp 复制代码
#include<stdio.h>
int main(){
int b=10;
int *p = &b; // p是指针变量,存储b的地址   
*p=20;  //等价于  b=20;
printf("%d\n", *p); // *p:根据地址p,读取对应内存的数据(输出100)
}

*p (也叫解引⽤操作符 )的意思就是通过p中存放的地址,找到指向的空间(存放的是10),*p其实就是b变量了

程序操作变量的本质:通过地址找到对应的内存字节,读写数据(直接访问 = 用变量名找地址,间接访问 = 用指针存地址)。

4.如何拆解指针类型

2.指针变量的大小(关键规律)(操作系统知识这里知道就行)

指针变量既然是变量,就会占用一定的内存空间,其大小遵循一个核心规律:指针变量的大小只和"系统架构(地址总线宽度)"有关,和它指向的变量类型无关

具体说明如下:

  • 32位系统(地址总线宽度32位):地址是32位的整数,因此指针变量占用 4 字节(32位 = 4字节)------无论指针类型是 int*、char* 还是 float*,大小都是4字节;

  • 64位系统(地址总线宽度64位):地址是64位的整数,因此指针变量占用 8 字节(64位 = 8字节)------同样,所有类型的指针变量大小都相同,都是8字节;

  • 为什么和指向类型无关?因为指针变量存的是"地址",而地址的长度由系统架构决定,和指向的数据占用多少字节(比如int占4字节、char占1字节)没有关系。

cpp 复制代码
#include<stdio.h>
int main() {
    // 不同类型的指针变量
    int* p1;
    char* p2;
    float* p3;
    double* p4;
    // 打印各指针变量的大小(单位:字节)
    printf("int* 大小:%zd\n", sizeof(p1));
    printf("char* 大小:%zd\n", sizeof(p2));
    printf("float* 大小:%zd\n", sizeof(p3));
    printf("double* 大小:%zd\n", sizeof(p4));
    return 0;
}

运行结果说明:

  • 在32位编译器(如32位VS)中运行,所有结果都是 4;

  • 在64位编译器(如64位VS、GCC)中运行,所有结果都是 8;

  • sizeof() 是C语言的运算符,用于计算变量或类型占用的字节数,这里用来验证指针变量的大小。

2.1 指针变量类型的意义

指针变量的⼤⼩和类型⽆关,只要是指针变量,在同⼀个平台下,⼤⼩都是⼀样的,为什么还要有各 种各样的指针类型呢?有一个int *不就行了。

下图代码可以得出答案:

我们通过解引⽤操作符 *p修改指针p指向内存中变量的值。

指针变量类型的意义:控制解引用的内存读取范围(最核心作用) :解引用操作(*p)的本质是"根据指针地址,读取对应内存的数据",而读取多少个字节,由指针类型决定比如:int* 指针解引用时,会从地址开始读取4个字节(对应int类型的大小),所以能把变量a 4个字节全部改为0char* 指针解引用时,只读取1个字节(所以第一张图中只有一个字节变为了0);float* 指针解引用时,读取4个字节并按浮点数规则解析。如果没有指针类型区分,编译器无法知道该读取多少字节,会导致数据解析错误。

2.2 const修饰指针变量

⼀般来讲const修饰指针变量,可以放在*的左边,也可以放在*的右边,意义是不⼀样的。

要理解这个区别,核心是看 const 修饰的是指针本身 ,还是指针指向的内存内容。我们用通俗的比喻和代码示例来拆解:

  1. const 放在 * 左边,const 修饰的是指针指向的内容 (可以理解为 "指向的东西不能改"),指针变量本身可以指向其他地址(指针能移动)例子:
cpp 复制代码
#include <stdio.h>

int main() {
    int a = 10;
    int b = 20;
    
    // const 放在*左边:指向的内容不可修改
    // 我们可以理解为const 修饰 *ptr,所以我们不能通过*ptr=100,修改它指向的内容
    int  const* ptr = &a; //等价于 int const* ptr
    
    // 错误:不能通过ptr修改指向的内容(a的值不能被改)
    // *ptr = 100; 
    
    // 正确:指针本身可以指向其他地址(ptr可以指向b)
    ptr = &b;
    printf("ptr指向的b的值:%d\n", *ptr); // 输出:20
    
    return 0;
}
  1. const 放在 * 右边。const 修饰的是指针变量本身(可以理解为 "指针的指向不能改")
  • 指针变量本身的地址(指向)是固定的,不能指向其他内存地址;
  • 但通过这个指针,可以修改它指向的内存里的值
cpp 复制代码
#include <stdio.h>

int main() {
    int a = 10;
    int b = 20;
    
    // const 放在*右边:指针本身不可修改(指向固定)
    // const 修饰的是ptr,是一个指针变量(存放的是地址),有了const修饰,所以不能改它存放的地址
    int* const ptr = &a;
    
    // 正确:可以修改指向的内容(a的值能被改)
    *ptr = 100;
    printf("修改后的a的值:%d\n", a); // 输出:100
    
    // 错误:指针本身不能指向其他地址(不能指向b)
    // ptr = &b; 
    
    return 0;
}

3 *两边都有const

cpp 复制代码
#include <stdio.h>

int main() {
    int a = 10;
    int b = 20;

    // const 放在*两边:既不能改指向的内容,也不能改指针的指向
    int const* const ptr = &a;

    // 错误1:不能通过ptr修改指向的内容(a的值)
    // *ptr = 100; 

    // 错误2:不能修改指针本身的指向(不能指向b)
    // ptr = &b; 

    // 合法操作:直接修改a的值(a本身是普通变量,不受ptr的const限制)
    a = 200;
    printf("直接修改后的a的值:%d\n", a); // 输出:200
    printf("ptr指向的a的值:%d\n", *ptr); // 输出:200

    return 0;
}

既不能修改指针指向的内容,也不能修改指针本身的指向

3 void* 指针(也叫 "无类型指针")

可以指向任意类型的数据(int、char、数组、结构体等);

本身不携带类型信息 ,无法直接解引用 ,也不能直接进行指针运算

void* 可以和任意类型指针互相转换(无需强制类型转换)

  • int* p = &a; void* vp = p;(合法,int* → void*)

void* 不能直接解引用、不能直接做指针运算

  • 因为编译器不知道 void* 指向的数据占多少字节(int 占 4、char 占 1)
cpp 复制代码
int a = 10;
void* pa = &a;
void* pc = &a;
*pa = 10;// 错误:invalid use of void expression(非法使用void类型表达式)
*pc = 0;  // 同样错误,原因同上

4.指针运算

指针的基本运算有三种,分别是: 1 指针+-整数 2指针-指针 3指针的关系运算

1. 指针 ± 整数(指针的偏移运算)(核心用途:遍历数组)

指针加减整数 n,表示指针在内存中向前 / 向后偏移 n 个 "数据单元" ,偏移的字节数 = n * 指针指向类型的大小(4/8)。

  • 指针 + n:向内存地址增大的方向偏移 n 个单元;
  • 指针 - n:向内存地址减小的方向偏移 n 个单元。

最常用在数组遍历(数组名本质是指向首元素的常量指针)。

代码示例:

cpp 复制代码
#include <stdio.h>

int main() {
    int arr[] = { 10, 20, 30, 40, 50 };
    int* p = arr; // p存放数组首元素地址,指向数组首元素arr[0],地址假设为0x100

    // 1. 指针 + 整数
    printf("p指向的值:%d\n", *p);       // 输出:10(arr[0])
    p = p + 1;                          // 偏移1个int单元(4字节),地址变为0x104
    printf("p+1后指向的值:%d\n", *p);  // 输出:20(arr[1])

    // 2. 指针 - 整数
    p = p - 1;                          // 偏移回原地址0x100
    printf("p-1后指向的值:%d\n", *p);  // 输出:10(arr[0])

    // 遍历数组(常用写法)
    for (int i = 0; i < 5; i++) {
        // *(p+i)等价于 *(arr+i), 因为数组名是数组第一个元素的地址,所以*(arr+i)就是数组第i个元素的值
        printf("%d ", *(arr + i));      // arr+i 指向arr[i],输出:10 20 30 40 50
    }

    return 0;
}

2.指针-指针

两个指针相减 → 结果是两个指针之间的元素个数(不是字节数)

公式:指针1 - 指针2 = (指针1的地址 - 指针2的地址) / 指向类型的字节大小

cpp 复制代码
int my_strlen(char *s)
{
    // 步骤1:定义指针p,指向s的首地址(0x100),此时p和s都指向'a'
    char *p = s; 

    // 步骤2:循环判断并移动指针
    while(*p != '\0' ) // 解引用p,判断指向的内容是否是结束符
    {
        p++; // 指针+1:每次偏移1字节(char类型大小),指向后一个字符
    }
    // 循环执行过程:
    // - 初始p=0x100 → *p='a'≠'\0' → p++ → 0x101
    // - p=0x101 → *p='b'≠'\0' → p++ → 0x102
    // - p=0x102 → *p='c'≠'\0' → p++ → 0x103
    // - p=0x103 → *p='\0' → 退出循环

    // 步骤3:指针-指针,返回元素个数
    return p-s; // p=0x103,s=0x100 → 0x103-0x100=3(char*相减结果是元素个数)
}
#include <stdio.h>
int main()
{
printf("%d\n", my_strlen("abc"));
return 0;
}
  • "abc" 是字符串常量,存储在内存中时会自动在末尾加 '\0',实际是 'a' 'b' 'c' '\0'
  • 调用 my_strlen 时,会把 "abc" 的首地址('a' 的地址)传给形参 char *s

这段代码完美体现了指针的两种核心运算:

  1. 指针 + 整数p++ 本质是 p = p + 1,因为 pchar* 类型,每次偏移 1 字节,精准指向字符串的下一个字符;
  2. 指针 - 指针p-s 计算两个指针之间的元素个数(不是字节数),因为 ps 都是 char* 且指向同一个字符串,结果就是字符串的有效长度(不含 '\0')。

例子2:

cpp 复制代码
#include <stdio.h>

int main() {
    int arr[] = { 10, 20, 30, 40, 50 };
    printf("指针相减结果(元素个数):%d\n", &arr[4] - &arr[0]);//  16/4=4
    printf("地址差(字节数):%d\n", (char*)&arr[4] - (char*)&arr[0]); //16/1=16
    return 0;
}

如果想得到字节数,需要先把指针强转为 char*char* 每次偏移 1 字节),这就是代码中第二个 printf 的逻辑:(char*)&arr[4] - (char*)&arr[0] = 16(直接是地址的字节差)。

3.指针的关系运算

cpp 复制代码
//指针的关系运算

#include <stdio.h>
int main()
{
	int arr[10] = { 10,20,30,40,50,60,70,80,90,100 };
	int* p = arr;//数组名是数组首元素的地址 = &arr[0]
	int sz = sizeof(arr)/sizeof(arr[0]);// 计算数组长度:10(10个int元素)
//特别注意sizeof(arr)中的arr代表arr 不再是 "数组首元素的地址"(指针),而是整个数组的标识符;计算的是「整个数组所占的总字节数」,而非指针大小
	while (p < arr + sz) //向数组末尾的 "边界地址"(无有效元素),仅用于判断,不解引用,保证代码安全 =   0<10
	{
	printf("%d ", *p);
	p++;
	}
	return 0;
}

5. 野指针

指针成因:

1指针未初始化

定义指针变量时,未给它赋值,指针会指向内存中随机的地址(栈区的垃圾值),成为野指针。

cpp 复制代码
#include <stdio.h>
int main()
{
	int* p;//
	* p = 20;
	printf("%d\n", *p); // 输出 20
	return 0;
}

运行的话编译器会报错:

2.指针越界访问

指针指向数组 / 字符串的范围之外,成为野指针。

cpp 复制代码
#include <stdio.h>

int main() {
    int arr[5] = { 1,2,3,4,5 };
    int* p = arr;

    // 循环越界:p最终指向arr[5](数组只有0-4下标),成为野指针
    for (int i = 0; i <= 5; i++) {
        p++;
    }
    //*p = 6; // 解引用越界的野指针 → 数据乱码/崩溃
    printf("越界后p的地址:%p\n", p); // 指向数组外的无效地址
    return 0;
}
  1. 指针指向的空间释放
cpp 复制代码
#include <stdio.h>
int* test()
{
	int n = 99;// n是test函数的局部变量,存储在【栈区】
	return &n;// 返回n的地址,但test函数执行结束后,栈区会释放n的内存
}
int main()
{
	int* p = test();
	printf("%d\n", *p);
	return 0;
}

这段代码的核心问题是返回局部变量的指针,导致拿到的是野指针,但代码却可能 "看似能运行并输出 99"------ 这正是野指针最隐蔽的特点(未定义行为),但这个结果是 "偶然的、不可靠的",属于典型的未定义行为,换编译器 / 系统 / 运行时机,可能输出随机数、程序崩溃。

  • 栈区的特点:函数执行时为局部变量分配内存,函数执行完毕后,这块内存会被操作系统 "回收"(标记为可用,数据不会立即清空);
  • 返回的 &n 本质是 "已释放的栈内存地址",属于野指针

test 函数执行完后,n 的内存虽然被释放,但数据 100 还没被新的操作覆盖;

main 函数执行 printf 时,恰好这块内存还没被改写,所以能 "侥幸" 读到 100;

5.1规避野指针

  1. 方法 1:指针定义时立即初始化
  • 明确指向有效地址;
  • 暂时无有效地址时,初始化为 NULL
cpp 复制代码
#include <stdio.h>

int main() {
    int a = 10;
    int *p1 = &a; // 初始化指向有效变量
    int *p2 = NULL; // 暂时无指向,初始化为NULL
    
    if (p2 == NULL) { // 可通过判断避免解引用空指针
        printf("p2是NULL指针,不操作\n");
    }
    return 0;
}

如果明确知道指针指向哪⾥就直接赋值地址 ,如果不知道指针应该指向哪⾥,可以给指针赋值NULL. NULL 是C语⾔中定义的⼀个标识符常量,值是0,0也是地址,这个地址是⽆法使⽤的,读写该地址会报错

2.方法 2 :严格控制指针的边界(避免越界)

遍历数组 / 字符串时,用明确的边界条件限制指针范围

cpp 复制代码
#include <stdio.h>

int main() {
    int arr[5] = {1,2,3,4,5};
    int *p = arr;
    int sz = sizeof(arr)/sizeof(arr[0]);
    
    // 边界条件:p < arr+sz,避免越界
    while (p < arr + sz) {
        printf("%d ", *p);
        p++;
    }
    return 0;
}

3 assert 断⾔

assert 是 C 标准库 <assert.h> 中的宏(不是函数),核心作用是:

在程序运行时检查某个条件是否为真

  • 如果条件为真(非 0):程序继续执行;
  • 如果条件为假(0):立即终止程序,打印错误信息(包含文件名、行号、断言条件),帮助快速定位问题。

如果已经确认程序没有问 题,不需要再做断⾔,就在 #include 语句的前⾯,定义⼀个宏 NDEBUG 。

cpp 复制代码
#include <stdio.h>
#include<assert.h>
int main()
{
    int a; // 定义有效变量
    int* p = NULL; // 显式初始化为NULL(不是未初始化)
    
    // 先赋值为有效地址,再解引用

    assert(p != NULL); // 此时p=&a≠NULL,断言通过
    
    *p = 20; // 解引用有效指针,修改a的值
    printf("%d\n", *p); // 稳定输出20
    return 0;
}

6 传值调⽤和传址调⽤

通过指针实现两个整数的交换

cpp 复制代码
#include <stdio.h>

void Swap2(int*px, int*py)
{
    int tmp = 0;
    tmp = *px;   // 把px指向的值(a)存入tmp
    *px = *py;   // 把py指向的值(b)赋给px指向的变量(a)
    *py = tmp;   // 把tmp的值(原a)赋给py指向的变量(b)
}

int main()
{
    int a = 0;
    int b = 0;
    printf("请输入两个整数(用空格分隔):");
    scanf("%d %d", &a, &b);
    
    // 先打印交换前的值
    printf("交换前:\na=%d b=%d\n", a, b);
    
    // 调用交换函数
    Swap2(&a, &b);
    
    // 再打印交换后的值
    printf("交换后:\na=%d b=%d\n", a, b);
    return 0;
}

Swap2 函数通过指针操作实现交换的核心是:函数内通过指针解引用,直接修改主函数中变量 ab 的值(而不是修改形参本身)。

这种函数调⽤⽅式叫:传址调⽤。 传址调⽤,可以让函数和主调函数之间建⽴真正的联系,在函数内部可以修改主调函数中的变量;所以未来函数中只是需要主调函数中的变量值来实现计算,就可以采⽤传值调⽤。如果函数内部要修改 主调函数中的变量的值,就需要传址调⽤

相关推荐
linweidong2 小时前
C++ 中避免悬挂引用的企业策略有哪些?
java·jvm·c++
曹轲恒3 小时前
JVM中的直接内存
jvm
BHXDML4 小时前
JVM 深度理解 —— 程序的底层运行逻辑
java·开发语言·jvm
隐退山林5 小时前
JavaEE:多线程初阶(二)
java·开发语言·jvm
期待のcode6 小时前
Java虚拟机堆
java·开发语言·jvm
alonewolf_9915 小时前
JDK17新特性全面解析:从语法革新到模块化革命
java·开发语言·jvm·jdk
weixin_4657909116 小时前
电动汽车有序充电:电网负荷削峰填谷的新利器
jvm
ProgramHan18 小时前
Spring Boot 3.2 新特性:虚拟线程的落地实践
java·jvm·spring boot
小当家.10520 小时前
深入理解JVM:架构、原理与调优实战
java·jvm·架构