C语言指针(一)-- 从内存地址开始理解指针

摘要

指针作为C语言中的重难点,并不是因为 * 这个符号难记,而是它把变量、内存地址、数据类型和对象生命周期连在了一起。本文从一个普通整数变量出发,逐步观察地址怎样被取得、保存和再次访问;再解释指针类型、解引用、const、指针运算,以及空指针、野指针和悬空指针的区别。


先建立一个正确的目标:指针不是“另一个变量类型”

刚接触指针时,很容易把它当成一种很特殊的数据:好像它既不是整数也不是字符,只有记住 *p&a 才能使用。这样学习会很累,因为每多一个写法都像新规则。

更稳妥的理解是:指针变量本身也是变量,只不过它保存的内容是一个对象的位置。 这个位置通常叫地址。知道地址后,程序就能回到那块内存,读取或修改其中的数据。

例如,交换两个整数时,下面的函数并不能交换调用者的变量:

void Swap(int a, int b)
{
    int temp = a;
    a = b;
    b = temp;
}

调用 SwapWrong(x, y) 时,ab 分别得到 xy 的副本。函数中确实完成了交换,但交换的是副本;函数结束后副本消失,xy 保持原样。

如果希望函数真正找到 xy,就要把它们所在的位置交给函数:

void Swap(int *a, int *b)
{
    int temp = *a;
    *a = *b;
    *b = temp;
}

希望此文可以帮助你把 int *a*a&x 分别读成清楚的含义,而不是把它们当成一串需要死记的符号。

1、变量、内存和地址:先分清“放的内容”和“放在哪里”


1.1一个变量至少有三件值得关注的事

int score = 95;

对初学者而言,可以先把这句理解成:程序申请了一块能够放下一个 int 的空间,为它取名 score,并把数值 95 放进去。程序运行时,这块空间还有一个可用于定位的地址。

#include <stdio.h>

int main()
{
    int score = 95;

    printf("score 的值:%d\n", score);
    printf("score 的地址:%p\n", &score);
    return 0;
}

可能的运行结果(32位环境运行为8位十六进制,64位环境运行为16位十六进制):
在这里插入图片描述
在这里插入图片描述

第二行的具体数字不重要,也不保证每次运行相同。它只是告诉我们:score 在这一次运行中位于哪里。%p 是打印指针的格式说明符;

如果是使用的Visual Studio开发的话,可以调试验证一下:
在这里插入图片描述
在这里插入图片描述
可以观察到,得到变量经过取地址展示的地址值后,再通过内存窗口查询该地址的内容的结果发现与赋予变量的值相同(十六进制5f == 十进制95)。

注意: “内存地址”是 C 语言程序观察和操作对象位置的一种抽象。不要把它理解成固定的物理内存门牌号,也不要把一次运行输出的地址写死到代码里。操作系统、编译器和运行环境都可能让下一次地址不同。

在这里插入图片描述

注意:变量名是源码中使用的名字,地址是运行时定位对象的方式;int 类型则告诉程序这块空间应按一个整数来读取。地址只有和类型、有效范围一起使用才有意义。

1.2 &:取到对象的地址

& 用在变量前面时表示“取地址”:

int number = 10;
printf("%p\n", &number);

这里得到的不是 10,而是 number 所在位置。下面两句因此完全不同:

printf("%d\n", number);       // 打印对象里的值 
printf("%p\n", &number);      // 打印对象的位置 

把“值”和“地址”混在一起,是学习指针最常见的第一步错误。以后看到函数参数是 int *,你就应该想到它要的是一个 int 对象的地址,而不是一个普通整数。

2、指针变量:保存地址,也保留类型信息


2.1最基本的声明、赋值和访问

int value = 10;
int *p = &value;

第二行可以逐部分读:

  • p 是变量名;
  • *p 说明 p 是指针;
  • int * 说明它应指向一个 int 对象;
  • &valuevalue 的地址,正好能赋给 int *

此时内存中的关系不是“把 10 复制给了 p”,而是 value 中保存 10,p 中保存 value 的地址。通过 p 再次访问目标时,需要解引用:

#include <stdio.h>

int main()
{
    int value = 10;
    int *p = &value;

    printf("value = %d\n", value);
    printf("*p = %d\n", *p);

    *p = 20;
    printf("修改后 value = %d\n", value);
    return 0;
}

输出中的 *pvalue 都是同一个对象的值:
在这里插入图片描述
最后一行之所以变成 20,是因为 *p = 20 不是给指针变量 p 赋值,而是沿着 p 中保存的地址找到 value,再修改 value
在这里插入图片描述

2.2 指针类型为何不能随便换

int count = 3;
char letter = 'A';

int *count_ptr = &count;
char *letter_ptr = &letter;

地址都可以理解为“位置”,为什么还分 int *char *?原因是程序需要知道:

  1. 从这个位置读多少字节;
  2. 读出的字节按什么类型解释;
  3. 指针加 1 时跨过多大距离。

int * 解引用时按 int 访问,char * 解引用时按字符访问。int *指针加1是跳过4字节,而char *指针加1是跳过1字节.

把不匹配的地址强行转换为另一种指针类型,可能导致对齐问题、错误解释数据,甚至未定义行为。初学阶段最可靠的做法是:指针类型和目标对象类型保持一致。

2.3 * 在声明里和表达式里不是同一个角色

int *p;   // 声明:p 是指向 int 的指针 
*p = 20;  // 表达式:访问 p 指向的对象

第一行的 * 属于声明语法;第二行的 * 是解引用运算符。看到 * 时先看它是否处在声明位置,不要急着把所有星号都理解成“取值”。

另一个容易踩坑的声明是:

int *p, q;

这里 pint *,但 qint,并不是指针。为了减少误读,建议一行只声明一个指针:

int *p;
int *q;

3、指针最实际的用途:让函数修改外部对象


下面是完整的交换示例:

#include <stdio.h>

void Swap(int *left, int *right)
{
    int temp;

    if (left == NULL || right == NULL)
    {
        return;
    }

    temp = *left;
    *left = *right;
    *right = temp;
}

int main()
{
    int x = 3;
    int y = 8;
    printf("交换前:x = %d, y = %d\n", x, y);
    Swap(&x, &y);
    printf("交换后:x = %d, y = %d\n", x, y);
    return 0;
}

运行结果:
在这里插入图片描述

调用 Swap(&x, &y) 时,传入的是两个地址。进入函数后,leftright 也是按值传递的局部变量,但它们保存的地址能定位到调用者的 xy。因此修改 *left*right 会影响原变量。

NULL 判断表达的是接口约定的一部分:本函数选择在传入空地址时什么也不做。并非所有函数都必须这样处理;有些函数会规定参数绝不能为 NULL。重点是,调用者和函数实现者都应清楚指针参数是否允许为空。

4、使用指针前的三个核心问题


每次准备写 *p 时,停下来问三个问题:

  1. p 是否已经被初始化为明确的值?
  2. p 此刻是否指向一个有效对象,而不是 NULL
  3. 这个对象是否仍在生命周期内,并允许我以这种方式访问?

前两问解决“地址从哪里来”,第三问解决“这个地址现在还管不管用”。很多指针错误能通过这三问提前发现,是初学时避免错误的重要习惯。

4.1 空指针:明确表示当前没有目标

int *p = NULL;

if (p != NULL)
{
    printf("%d\n", *p);
}

NULL 是一个表示空指针的宏。它的含义是“当前不指向任何可访问对象”,不是一个可以解引用的零地址。空指针可以比较,也可以作为“暂时没有结果”的状态返回;但不能直接使用 *p

下面是一个更贴近函数接口的例子:

int SetZero(int *p)
{
    if (p == NULL)
    {
        return 0;
    }

    *p = 0;
    return 1;
}

返回值告诉调用者是否成功修改。这样读代码时就能知道:p 允许为空,但为空时不会执行解引用。

4.2 野指针:没有可靠来源的地址

int *p;
*p = 10;   // 错误:p 从未初始化

这段代码的危险不在于 * 写错了,而在于 p 变量从未被赋成一个已知地址。

当程序执行 *p = 10 时,它会试图把 10 写到这个未知位置。后果并不固定:

  • 有时会立刻访问冲突并终止;
  • 有时覆盖了别的变量,稍后才出现奇怪结果;
  • 这类行为属于未定义行为,不能通过多运行几次来证明安全。

正确的选择只有两类:让指针马上指向一个有效对象,或者明确置空。

int value = 10;
int *valid = &value;

int *later = NULL;

“先声明,之后再说”对普通整数有时只是逻辑问题;对指针则可能直接变成向未知地址读写的问题。因此,指针声明时初始化是值得长期坚持的习惯。

4.3 悬空指针:过去有效,不代表现在有效

int *GetNumber()
{
    int local = 42;
    return &local;  // 错误 
}

函数执行期间,local 确实有地址,&local 也确实能取得它。但 local 是局部变量,函数返回后它的生命周期结束。返回的指针仍保留旧地址,却再也没有一个仍然存在的 int 对象可以合法访问,这就是悬空指针。

可以把它和野指针区分开:野指针从一开始就没有可靠目标;悬空指针曾经有可靠目标,但目标已经消失。后者更隐蔽,因为代码中往往能看到它“原来指向了谁”。

int *GetStaticNumber(void)
{
    static int value = 42;
    return &value;
}

这个版本不会因为函数返回而失效,因为静态局部变量存储在内存的静态存储区,和全局变量放同一个区域,程序运行全程都存在,不会随函数结束销毁,不过它只有一份,所有调用者共享同一个对象。

在这里插入图片描述

5、const 和指针:“不能改的究竟是谁”


const 与指针结合后常让人混淆。不要直接背句子,可以从变量名开始看。

5.1 const int *p:不能通过 p 改目标

int value = 10;
const int *p = &value;

*p = 20;       //错误:不能通过 p 修改 
value = 20;    //合法:value 本身不是 const 

这适合只读参数,例如“只读取数组内容,不修改数组”的函数:

int Sum(const int *arr, int count)
{
    int i;
    int total = 0;

    for (i = 0; i < count; i++)
    {
        total += arr[i];
    }
    return total;
}

const 在这里既是在告诉读者意图,也让编译器阻止函数体意外写入 arr[i]

5.2 int *const p:指针本身不能改指向

int first = 1;
int second = 2;
int *const p = &first;

*p = 10;       //合法:修改 first 
 p = &second;  //错误:p 不能改为指向 second 

const 靠近变量名 p,所以限制的是 p 这个地址变量。它必须在声明时初始化,因为之后不能再给它指定目标。

5.3 const int *const p:两边都固定

int value = 10;
const int *const p = &value;

*p = 20;   //错误 
 p = NULL; //错误 
声明能否通过指针改值能否改变指向
int *p可以可以
const int *p不可以可以
int *const p可以不可以
const int *const p不可以不可以

6、指针运算:在同一个数组范围内理解


指针运算不是对任意地址做数学题。最清晰的场景是数组,因为数组元素在内存中连续排列:

int numbers[4] = {10, 20, 30, 40};
int *p = numbers;

此时 p 指向 numbers[0]。下列访问等价:

numbers[2]
*(numbers + 2)
*(p + 2)
p[2]

6.1 指针与整数:移动的是“元素个数”

printf("%d\n", *p);       // 10 
printf("%d\n", *(p + 1)); // 20 
printf("%d\n", *(p + 3)); // 40 

p + 1 的含义是“移动到下一个 int 元素”,不是简单把地址数字加 1。编译器会根据 p 的类型计算正确步长。这也是为什么指针类型不能被忽略。

常用的移动形式包括:

p++;      // 指向下一个元素 
p += 2;   // 向后跳两个元素 
p--;      // 回到前一个元素,前提是不会越过开头 
p - 1;    // 得到前一个位置,但不修改 p 

下面的两行非常容易看错:

*p++;      // 等价于 *(p++):先使用旧的 p 形成 *p,再让 p 后移 
(*p)++;    // 让 p 指向的元素值加 1,p 自己不移动 

遇到星号和自增同时出现时,主动加括号思考,比凭感觉判断可靠得多.

6.2 指针与指针相减:得到相隔的元素数

#include <stdio.h>
#include <stddef.h>

int main()
{
    int arr[] = {10,20,30,40};
    int *p1 = &arr[0];
    int *p2 = &arr[3];

    ptrdiff_t diff = p2 - p1;  // 3,相差3个int元素
    printf("diff = %td\n", diff); 

    return 0;
}

运行结果:
在这里插入图片描述
right - left 得到的是从下标 1 到下标 4 跨过了多少个 int 元素,而不是字节数。结果类型 ptrdiff_t 专门用于保存两个指针的差值,打印时使用 %td

这一规则有严格前提:两个指针必须都属于同一个数组对象,或者其中一个是该数组的尾后位置。两个完全无关变量的地址不能通过相减得到有意义的“距离”

6.3 指针之间的关系运算:判断数组中的先后

int numbers[4] = {10, 20, 30, 40};
int *p = &numbers[1];
int *q = &numbers[3];

if (p < q)
{
    printf("p 指向的元素在 q 前面\n");
}

在同一个数组内,<><=>= 可以比较元素顺序;==!= 可以判断是否正好指向同一位置。遍历数组时常会写:

int *cur = numbers;
int *end = numbers + 4;

while (cur != end)
{
    printf("%d ", *cur);
    cur++;
}

这里 end 是尾后位置,只用于判断“是否走完”,从不解引用。

6.4 哪些运算不应该写

以下写法没有定义好的用途,不能当作普通数学运算:

 p + q;   // 两个指针不能相加 
 p * 2;   // 指针不能相乘 
 p / 2;   // 指针不能相除 

还有一些看起来很像数组访问、实际上越界的写法:

int data[3] = {1, 2, 3};
int *begin = data;
int *end = data + 3;  // 可以形成尾后位置 

 *(begin - 1);  //越过开头,错误 
 *end;          //尾后位置没有元素,错误 

形成 data + 3 是允许的,因为它帮助我们表示结束;访问 *(data + 3) 则不允许。请牢牢记住:指针位置可以到达尾后,但解引用只能落在真正存在的元素上。

在这里插入图片描述

7、练习:“会写”也要“会判断”


7.1 这段代码会打印什么

int value = 7;
int *p = &value;

*p = *p + 3;
printf("%d\n", value);

答案: 输出 10*pvalue 指向同一个整数对象,右侧先读取 7,再写回 10。

7.2 指出错误并修正

int *p;
if (p != NULL)
{
    *p = 1;
}

答案: 错在 p 在比较前都没有初始化,读取它本身就是问题。若暂时没有目标,应写成:

int *p = NULL;

if (p != NULL)
{
    *p = 1;
}

当然,这段修正后的代码不会写入任何数据;它只是安全地表达“当前没有可写对象”。真正需要修改时,应让 p 指向某个有效变量。

7.3 预测差值和访问结果

int a[] = {2, 4, 6, 8, 10};
int *p = &a[1];
int *q = &a[4];

printf("%d\n", *(p + 2));
printf("%td\n", q - p);

答案: 第一行是 8pa[1] 出发,向后两个元素到 a[3]。第二行是 3a[4]a[1] 相隔三个元素。

总结

到这里,指针可以先概括为一条通往对象的路线:地址告诉程序去哪里,类型告诉程序怎样读,数组边界限制能走多远,生命周期决定这条路线何时失效。只记住“指针保存地址”还不够;能否安全地使用这个地址,取决于后面三项。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值