找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2843

积分

0

好友

379

主题
发表于 5 天前 | 查看: 21| 回复: 0

指针是C语言的灵魂,也是区分程序员水平的一道重要分水岭。想要真正掌握指针,不仅需要对C语言本身有深刻的理解,还离不开对计算机硬件与操作系统底层原理的认知。这篇文章将从最基础的概念出发,系统性地为你拆解指针的方方面面。

为什么需要指针?

指针解决了编程中的几个根本性问题:

第一,高效的数据共享。 使用指针可以让不同代码区域轻松地共享同一份内存数据。虽然通过复制数据也能达到目的,但对于结构体这类大型数据,复制操作会消耗大量性能。指针则能完美避开这个问题,因为任何类型的指针所占用的内存大小通常都是固定的(例如在32位系统上是4字节)。

第二,构建复杂数据结构。 指针使得链表、链式二叉树等需要链接性的复杂数据结构的实现成为可能。

第三,突破“按值传递”的限制。 C语言中所有的函数调用默认都是“按值传递”。如果要在函数内部修改外部传入的数据对象,就必须通过指向该对象的指针来完成。此外,操作动态申请的堆内存也必须使用指针。

计算机如何访问内存?

计算机通过总线系统与内存通信,其中与我们理解指针最相关的是数据总线地址总线

  • 数据总线负责传送数据信息,其宽度(位数)通常与CPU的字长一致,也决定了int等基本类型的长度。例如,32位机器的数据总线宽32位,一次最多能存取32位数据。
  • 地址总线专门用于寻址。CPU通过它发送要访问的内存地址,然后通过数据总线传送该地址处的数据。地址总线的宽度决定了CPU可直接寻址的内存空间大小(如32位总线对应4GB寻址空间)。

通常,我们说32位CPU,其数据总线和地址总线的宽度都是32位。计算机访问数据时,先通过地址总线传送目标地址,再通过数据总线读写数据。int等基本类型的位数等于数据总线的宽度,而指针的位数等于地址总线的宽度。

内存的基本访问单元

C语言中,最小的基本数据类型是char,占8位即1个字节。我们可以认为,计算机以字节(Byte)为基本访问单元。访问小于一个字节的数据,需要通过位操作来完成。

内存字节访问示意图

如上图所示,计算机在进行数据访问时,总是从某个字节(例如第p个字节)开始,访问的长度则由编译器根据该数据的实际类型(通过sizeof得知)来决定。

sizeof 关键字

sizeof是编译器在编译期用来计算类型或变量所占字节数的关键字。例如:

sizeof(char)=1;
sizeof(int)=4;

sizeof(Type)的值是一个编译期常量。

指针到底是什么?

我们常说int数组、char数组。同样,指针也泛指一类数据类型:int指针类型、char指针类型等等。

任何程序数据载入内存后,都有一个唯一的编号来标识其存储位置,这个编号就是地址,也就是指针。而用来保存这个地址的变量,就是指针变量

所以:指针是程序数据在内存中的地址,指针变量是用来保存地址的变量。

指针类型分类

你可以简单地将指针变量理解为一个特殊的整型变量,它存储的不是普通数值,而是内存的“门牌号”——地址值。图2清晰地展示了指针与内存地址的指向关系。

指针与内存地址关系

指针的长度

我们这样定义指针:Type *p;,表示p是一个指向Type类型的指针。这里的Type可以是任意类型,包括基本类型、结构体、函数,甚至是指针本身(如int **,即指向指针的指针)。

一个关键结论是:在特定平台上,所有数据指针的长度都是相同的,因为它存储的是地址,其宽度由地址总线决定。在32位系统上,指针长度是4字节;在64位系统上,通常是8字节。因此,sizeof(char*)sizeof(int*)sizeof(void*)在同一个平台下的结果是一样的。

程序中的数据为何有地址?

我们需要从操作系统的抽象视角来看内存。对程序员而言,内存就像一个巨大的、线性的字节数组(平坦寻址),每个字节都有一个从0开始的唯一编号,这个编号就是地址。

例如,一个256MB的内存,共有 256 1024 1024 = 268435456 个字节,其地址范围就是 0 ~ 268435455。

程序员视角的内存抽象

因此,程序中使用的变量、常量等数据被载入内存后,都有自己唯一的地址。

#include <stdio.h>
int main(void)
{
    char ch = 'a';
    int  num = 97;
    printf("ch 的地址:%p\n",&ch);   //ch 的地址:0028FF47
    printf("num的地址:%p\n",&num);  //num的地址:0028FF40
    return 0;
}

变量地址输出

指针的值(虚拟地址)实质上就是内存单元的编号,通常用十六进制表示。对于一个机器字长为w位的计算机,其程序最多能访问 2^w 个字节。

假设char占1字节,int占4字节,变量chnum在内存中的存储模型可能如下:

变量在内存中的布局

变量与内存的深度剖析

以局部变量int num = 97为例,我们来剖析它在内存中的方方面面。

整数变量内存存储细节(小端模式)

  1. 内存的数据:即变量值的二进制形式。97的二进制是 00000000 00000000 00000000 01100001。图中显示为小端模式存储(低位在低地址)。
  2. 内存数据的类型:类型(这里是int)决定了数据占用的字节数(4字节),以及计算机如何解释这些字节(解释为一个整数)。
  3. 内存数据的名称:即变量名num。这是高级语言提供的抽象,方便我们操作。并非所有内存数据都有名称(如malloc分配的堆内存)。
  4. 内存数据的地址:如果一个类型占用多个字节,则其变量的地址是这些字节中地址值最小的那个。所以num的地址是0028FF40
  5. 内存数据的生命周期:局部变量num的生命周期与main函数同步。程序使用的内存被划分为栈区、堆区、静态数据区等,不同区域的数据生命周期不同。

指针运算

指针运算,特别是加减运算,是理解指针行为的关键。p++这样的操作,并非简单地将地址值加1,而是加上sizeof(Type),即指向下一个同类型数据的位置。

// 示例:观察不同类型指针+1的地址变化
#include<iostream>
using namespace std;
int main() {
    short sv=1, *psv=&sv;
    int iv=1, *piv=&iv;
    long lv=1, *plv=&lv;
    // ... 其他类型
    cout<<"psv:"<<psv<<" psv+1:"<<psv+1<<endl;
    cout<<"piv:"<<piv<<" piv+1:"<<piv+1<<endl;
    // ... 输出其他
    return 0;
}

指针运算代码

运行结果会显示,地址的增加量恰好是各自类型的sizeof值(short:2, int:4, long:4等)。这背后的原理可以通过查看反汇编代码来验证。

int iv=1,*piv=&iv;
piv++;
cout<<piv<<endl;
piv=piv+4;
cout<<piv<<endl;

指针运算C++代码

对应的汇编指令中,可以看到对指针变量直接进行了加4(sizeof(int))和加16(4*sizeof(int))的操作。

指针运算汇编代码
指针运算汇编代码续

指针变量与指向关系

保存指针的变量即指针变量。如果指针变量p1保存了变量num的地址,我们就说“p1指向num”。

指针指向关系示意图

定义指针变量

在变量名前加*号即可定义指针变量。

int *p_int; // 指向int的指针
double *p_double;
struct Student *p_struct; // 结构体指针
int (*p_func)(int,int); // 函数指针
int (*p_arr)[3]; // 指向数组的指针
int **p_pointer; // 指向指针的指针(二级指针)

指针的两个关键属性

  1. 指针的值:即它所保存的内存地址。
  2. 指针的类型:决定了它指向的内存区域有多大,以及如何解释那块内存中的数据。
int main(void)
{
    int num = 97;
    int *p1  = #
    char* p2 = (char*)(&num);

    printf("%d\n",*p1);    // 输出 97,按4字节整数解释
    putchar(*p2);          // 输出 ‘a’,按1字节字符解释,取低地址字节
    return 0;
}

p1p2的值相同(都是&num),但类型不同,解引用时访问和解释内存的方式就不同。

取地址(&)与解地址(*)

  • 取地址:使用&运算符获取变量的地址。
    int* p_num = #
    int (*p_arr)[3] = &arr; // 数组指针
    int (*fp_add)(int ,int )  = add; // 函数指针

    注意:数组名、函数名作为右值时,本身就代表地址,可不用&

  • 解地址:在指针变量前加*,可以读写它指向的内存数据。
    int age = 19;
    int*p_age = &age;
    *p_age  = 20;  // 通过指针修改数据
    printf(“age = %d\n”,*p_age); // 通过指针读取数据

指针的赋值

指针赋值是浅拷贝,仅仅拷贝了地址值,使得多个指针可以共享同一份数据。

int* p1 = #
int* p3 = p1; // p3 和 p1 现在指向同一个num

多指针指向同一数据

空指针(NULL)

NULL是一个标准定义的宏,表示空指针,不指向任何有效对象。在C语言中通常是((void*)0),在C++中是0。对NULL指针解引用会导致未定义行为(通常导致程序崩溃)。

重要实践:动态分配的内存被释放后,应立即将指针置为NULL,防止出现“悬空指针”和重复释放。

int *p = (int*)malloc(sizeof(int));
*p = 23;
free(p);
p = NULL; // 良好习惯

更安全的做法是封装释放函数或使用宏:

// 方法1:传入指针的地址
void my_free(void *p){
    void **tp = (void **)p;
    if(NULL == *tp) return;
    free(*tp);
    *tp = NULL;
}
// 使用:my_free(&p);

// 方法2:使用宏
#define FREE(x) if(x) free(x); x=NULL
// 使用:FREE(p);

自定义释放函数
使用宏释放内存

void* 类型指针

void*是“指向任意类型的指针”。任何类型的指针都可以直接赋给void*指针,而无需强制转换。void*指针无法直接解引用,必须转换回具体类型后才能使用。它常用于内存管理相关函数(如malloc, free)的接口。

结构体与指针

访问结构体指针的成员使用->运算符。

typedef struct {
    char name[31];
    int age;
    float score;
}Student;

int main(void) {
    Student stu = {“Bob”, 19, 98.0};
    Student*ps = &stu;
    ps->age = 20; // 通过指针修改成员
    printf(“name:%s age:%d\n”, ps->name, ps->age);
    return 0;
}

数组与指针

  1. 数组名作为右值时,是首元素的地址。int *p_first = arr;
  2. 指向数组元素的指针支持递增递减运算,移动单位是sizeof(元素类型)
  3. 指针差值:同一数组内,两指针之差等于其下标之差。
  4. 下标与指针的等价性p[n] 等价于 *(p+n)
  5. 对数组名使用sizeof:得到整个数组的字节大小。数组名赋值给指针后,对指针用sizeof得到的是指针本身的大小(如4或8字节)。这就是为什么传递数组给函数时,通常需要额外传递元素个数的原因。

函数与指针

函数的参数和指针

C语言参数传递是“按值传递”。若要修改实参,必须传递指针。

// 错误:无法交换
void swap_bad(int a, int b) { int t=a; a=b; b=t; }
// 正确:传递指针
void swap_ok(int*pa, int*pb) { int t=*pa; *pa=*pb; *pb=t; }

错误的值交换
正确的指针交换

即使不想修改数据,传递大型结构体时使用指针(常指针)也比传递整个结构体副本更高效。

void show(const Student * ps) { // const 防止误修改
    printf(“name:%s , age:%d , score:%.2f\n”, ps->name, ps->age, ps->score);
}

函数指针

函数名本身也是一个指针常量,指向函数的代码。函数指针常用于实现回调机制,是编程范式中强大而灵活的工具。

typedef int (*compare)(const void *x, const void *y); // 定义函数指针类型

int my_compare(const void *x, const void *y){
    const int *a = (const int *)x; // 注意:这里原文有误,应为 const int *a = ...;
    const int *b = (const int *)y;
    if(*a > *b) return 1;
    if(*a == *b) return 0;
    return -1;
}

void my_sort(void *data, int length, int size, compare cmp){
    // ... 利用 cmp 函数指针进行比较
}

int main() {
    int arr[] = {2,4,3,656,23};
    my_sort(arr, 5, sizeof(int), my_compare); // 传递函数指针作为回调
    return 0;
}

函数指针类型定义与使用

const 与指针

理解const修饰的是谁,关键在于看const后面紧跟的是什么。

  • const int *pint const *pconst修饰*p,即指针指向的数据是常量,不能通过p修改。
  • int * const pconst修饰p,即指针本身是常量,不能指向别处。
  • const int * const p:指针本身和指向的数据都是常量。

深拷贝与浅拷贝

  • 浅拷贝:只拷贝了指针(地址),多个指针共享同一份实际数据。
  • 深拷贝:拷贝了指针指向的实际数据,产生一份独立的副本,修改互不影响。

浅拷贝与深拷贝对比

附加知识

大端模式(Big-Endian)与小端模式(Little-Endian)

  • 小端模式:低位字节存储在低地址。Intel x86/ARM常用此模式。
  • 大端模式:高位字节存储在低地址。网络字节序通常为大端。

例如,对于short a = 1(十六进制0x0001):

  • 小端存储:地址0x300x01,地址0x310x00
  • 大端存储:地址0x300x00,地址0x310x01

大小端模式存储对比

判断当前机器是否为小端模式的简单方法:

bool isLittleEndian() {
    unsigned int val = ‘A’; // ‘A’ 的 ASCII 为 0x00000041
    unsigned char* p = (unsigned char*)&val;
    return *p == ‘A’; // 小端模式下,低地址存的是最低字节 0x41(即‘A’)
}

指针与引用的关系

指针和引用在本质上是相似的,都是对内存数据的间接访问。指针是C/C++中的显式概念,提供了更灵活(也可能更危险)的操作。而引用则出现在Java、C#等语言中,是语言层面封装了指针操作、更安全的语法糖。

理解指针,不仅仅是记忆语法,更是建立对计算机基础运行机制的心智模型。希望这篇全面的梳理能帮助你查漏补缺,更自信地驾驭C语言中最强大的工具之一。如果在实践中遇到具体问题,欢迎在云栈社区与更多开发者一起交流探讨。




上一篇:C语言指针完全指南:从基础概念到复杂应用解析
下一篇:C语言函数指针与指针函数详解:从语法到嵌入式应用
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-7 17:07 , Processed in 0.756022 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表