☰
C语言指针从入门到实战:内存地址、数组与函数指针
2026/9/29 18:15:26 网站建设 项目流程

1. 想明白指针,先重新认识内存地址

很多人学指针卡住,不是智商问题,而是脑子里缺了一张“内存地图”。C语言的变量,本质上就是一块内存区域,而指针就是用来记录这些区域位置的“门牌号”。

理解指针有一个好办法:把内存想象成一个大宾馆的走廊,每个房间有编号。房间大小不一样,有的一间住4个字节,有的一间住8个字节。程序运行的时候,我们的变量就是在这些房间里存东西。变量名是什么?变量名是这个房间在代码里的名字,住客本身,比如a = 3,意思是往a这个房间放了一个数字3。而指针变量呢,它本身也是一个房间,只不过这个房间里存放的内容,是另一个房间的门牌号——地址。

1.1 地址、门牌号和变量名的区别

先看一段最简单的代码:

#include <stdio.h> int main(void) { int a = 3; int *p = &a; printf("变量 a 的值:%d\n", a); printf("变量 a 的地址:%p\n", (void*)&a); printf("指针 p 里存放的内容(也是地址):%p\n", (void*)p); printf("通过指针 p 解引用拿到 a 的值:%d\n", *p); return 0; }

输出大概是这样的(地址每次运行都不同):

变量 a 的值:3 变量 a 的地址:0x7ffc5a3b4c14 指针 p 里存放的内容(也是地址):0x7ffc5a3b4c14 通过指针 p 解引用拿到 a 的值:3

关键点有三个:

  • &a是取地址运算,表示“把a这个变量的门牌号拿出来”。
  • int *p = &a;的意思是定义一个指针变量p,它里面装的是a的地址。
  • *p是解引用,意思是“顺着p里存的门牌号,找到那个房间,去访问里面的内容”。

我觉得多数人当时没搞清的是:a这个变量名,在编译后几乎不存在了。程序跑起来之后,CPU只知道一堆地址,不知道什么叫“a”。a只是写代码时给人看的标签,真正的“地址”才是机器找数据用的钥匙。搞懂这一点,后面所有指针问题都顺了。

1.2 地址有几个字节:32位和64位的差异

一个指针变量的大小,取决于平台。常见情况:

平台地址宽度指针大小
32位程序32位4字节
64位程序64位8字节

写代码时,你可以用sizeof(int*)确认:

printf("int* 的大小:%zu\n", sizeof(int*));

在64位Linux/Windows上输出一般是8,在32位环境是4。这能解释很多初学者困惑:为什么指针变量明明只是存个地址,却占了8字节?因为地址本身上限就是那么宽,64位系统地址总线决定了它必须用64位(8字节)才能表示所有内存位置。

再给你一颗定心丸:指针的“指向类型”不会改变指针变量本身的大小,char*、int*、long long*在同一个平台上大小都一样。类型不同,影响的是“解引用时从哪个位置读几个字节”,这个先记在心里,下面马上讲。

1.3 指针指向类型的作用:步长和解读方式

还是用房间做比喻。int *p和char *cp本质上内存里存的都是地址。那为什么非要区分类型?

因为当你写*p的时候,编译器必须知道:从p指向的地址开始,连续读几个字节,再把这几个字节解释成什么类型。

  • char*解引用:从那个地址读1个字节,当成字符处理。
  • int*解引用:从那个地址连续读4个字节,按整数解释。
  • double*解引用:从那个地址连续读8个字节,按浮点数解释。

另外,指针做加减法时,步长也由类型决定。比如:

int arr[5] = {10, 20, 30, 40, 50}; int *p = &arr[0]; printf("%d\n", *p); // 10 printf("%d\n", *(p + 1)); // 20

p + 1并不是“地址数值加1”,而是“跳到下一个 int 的位置”,也就是地址数值加了sizeof(int),一般是4。这个设计就是为了方便数组遍历。所以,声明指针时写清楚类型,不是形式主义,是给编译器规定“步长”和“读取宽度”。

2. 指针变量怎么用:声明、初始化、解引用

接下来进入实操。建议你打开编译器跟着敲,这一节每行代码都值得亲手验证。

2.1 声明语句怎么读:星号属于谁

三种写法都可以:

int *p; int* p; int * p;

它们含义一样。初学者常纠结“星号是靠着类型还是靠着变量”,在C语言里,星号属于变量名那一侧,声明int* p更像一种强调风格的写法,但多变量声明时会有坑:

int* p1, p2; // p1是指针,p2是普通int!

如果连着写多个指针,必须写成:

int *p1, *p2, *p3;

就是说,*是跟着变量名走的,每个指针变量前面都要有自己的*。这个坑我见过很多次,面试题也常考。

2.2 初始化:让指针先有归宿

声明指针之后,最好立刻给它一个明确的初始值。C语言不会自动帮你把局部指针变量初始化为NULL。

int *p; // 危险!p里面是一个不确定的随机地址

这时候如果直接*p = 100;,相当于随机选了一个内存地址往里写数据,轻则覆盖别的变量,重则直接段错误崩溃。

正确做法:

int a = 0; int *p = &a; int *q = NULL; // 暂时没有目标,起码有个“无效值”可判断

NULL在C语言里通常定义为((void*)0),它表示这个指针不指向任何有效对象。判断指针是否可用,最常用的就是:

if (q != NULL) { *q = 42; }

不过要注意,if (p)和if (p != NULL)是等价的,都是“p 非空”才执行。C语言里直接写if(p)更常见,简洁。

2.3 解引用、取地址和“左值”概念

*p不只是一个“取值”操作,它也可以出现在赋值号左边。比如:

int a = 1; int *p = &a; *p = 99; printf("%d\n", a); // 99

为什么*p能赋值?因为解引用后,它代表了p指向的那个内存位置本身,也就是一个“左值”——可以写入的实体。理解这一点,后面写字符串、操作链表都会用到。

还有个高频误区:p = &a和*p = a看起来有点像,但含义完全相反。前一个是把地址放进指针;后一个是把值写进指针指向的位置。初学者一旦混了,代码跑起来全是歪的。我建议你每次写完指针操作,心里默念一遍:“左边是给指针换地址,还是给目标换内容”。

2.4 打印指针的正确姿势

打印地址一定要用%p,并且转成void*打印(C标准要求):

printf("地址是:%p\n", (void*)p);

不要用%x或%d来打印指针。虽然某些编译器能跑,但严格来说行为未定义,而且在高位地址场景下输出可能截断,排查问题时会误导你。顺便说一下,打印NULL指针,用printf("%p", p)也没问题,会输出(nil)或0x0之类。

3. 指针最核心的应用:传参和修改实参

学了声明和打印,接下来要解决一个经典问题:为什么C语言里要传指针才能“改变函数外面变量的值”?

3.1 值传递的本质:传的是副本

C语言函数参数默认是值传递。什么意思?

void swap_fail(int x, int y) { int temp = x; x = y; y = temp; } int main(void) { int a = 3, b = 5; swap_fail(a, b); printf("a=%d, b=%d\n", a, b); // 还是3和5 return 0; }

过程是这样的:调用swap_fail(a, b)时,函数栈里创建了两个新变量x和y,它们只是把a和b的值复制了一份。你在函数里交换的始终是x和y,跟外面的a、b毫无关系。函数一结束,x、y连同副本一起随风飘散。

3.2 传指针:把地址当值传递

要修改实参,就得让函数知道实参住在哪里,也就是把地址传过去。

void swap_ok(int *x, int *y) { int temp = *x; *x = *y; *y = temp; } int main(void) { int a = 3, b = 5; swap_ok(&a, &b); printf("a=%d, b=%d\n", a, b); // 5和3,成功交换 return 0; }

这时x和y仍然是副本,但副本的内容是地址。通过地址解引用,函数就能直接读写main里那块内存。这就像你给室友留了一张纸条,上面写着“帮我把我桌上的钥匙递给门外的人”,虽然纸条本身是复制品,但照着纸条上的位置,门外的室友真能拿到钥匙。

记住口诀:想通过函数修改普通变量的值,就传那个变量的地址。更宽泛地讲,凡是需要“带回来结果”的参数,都用指针。

3.3 C++里有人说引用更省事

热词里也提到“引用 指针 区别”。简单说几句:C++的int &x = a;本质上是编译器帮你实现的隐式指针,写起来像普通变量,用起来有指针的效果。但在C语言里没有引用,只能用指针。如果你学C++,引用确实更安全,可以少写*和&;不过理解了指针,理解引用完全是降维打击。

3.4 用const限定指针:读懂声明顺序

C语言里,const和指针组合有点绕,但非常常用,尤其是函数参数:

int strlen_my(const char *s);
  • const char *s:s本身可以被修改,但*s不能通过s修改(即指向的内容只读)。
  • char *const s:s本身不能改,但*s可以改。
  • const char *const s:都不能改。

记忆方法:const修饰的是紧跟它后面的东西。const char *s中const后面是char,所以char(也就是指针指向的内容)不能改;char *const s中const后面是*s,所以指针本身不能改。

在函数设计上,如果函数只读取数组、不修改内容,参数尽量写成const char*。这样既能接收字符串字面量,也能防止在函数里误写导致运行时崩溃。

4. 指针与数组的纠缠:从一维到二维

数组和指针在C语言里几乎是一对双胞胎,也是最多人犯糊涂的地方。先把最基础的结论摆出来:

  • arr作为右值时,通常代表数组首元素的地址。
  • 数组名本身不是一个普通变量,它不能被整体重新赋值(比如arr = other;是错的)。
  • 数组不是指针,只是它会在很多表达式中“衰减”成指针。

4.1 一维数组:遍历的多种写法

#include <stdio.h> int main(void) { int arr[5] = {10, 20, 30, 40, 50}; for (int i = 0; i < 5; i++) { printf("%d ", arr[i]); } printf("\n"); int *p = arr; // 等价于 int *p = &arr[0]; for (int i = 0; i < 5; i++) { printf("%d ", *(p + i)); } printf("\n"); for (int i = 0; i < 5; i++) { printf("%d ", *p++); // 利用指针后移 } printf("\n"); return 0; }

*p++这句值得仔细讲。运算符优先级里,*和++都是后缀优先的,所以*p++等价于*(p++)。整个表达式的值是*p(当前指向的元素),副作用是p自增到下一个元素。这样写循环特别像用迭代器,简洁有力。

4.2 二维数组和数组指针

int arr[3][4]是3行4列的二维数组。在内存里它其实是连续存放的12个int,按行优先排列。这里有一个著名的复杂声明:

int (*p)[4]; // p指向“含4个int的数组” int *q[4]; // q是“含4个int指针的数组”

两者长得像,含义却完全不同。括号改变了结合顺序:

  • int (*p)[4]:先看到*p,说明p是指针;再看到[4],说明它指向一个长度为4的int数组。这种指针叫“数组指针”。
  • int *q[4]:先看到q[4],说明q是数组;数组里面每个元素是int*。这种数组叫“指针数组”。

用的时候:

int arr[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int (*p)[4] = arr; // p指向第一行(一个长度为4的数组) for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { printf("%2d ", p[i][j]); // 或 *(*(p+i)+j) } printf("\n"); }

arr[i][j]在才能上是*(*(arr+i)+j):先定位到第i行的数组,再取第j个元素。数组指针对应的也就是这种行指针的语义。

4.3 字符串指针和字符数组:看似一样,天差地别

字符串相关的问题几乎是C语言面试必考。看这段代码:

char *s1 = "hello"; char s2[] = "hello";

区别在于:

  • s1是一个指针,指向字符串字面量。字符串字面量通常存储在只读数据段,尝试s1[0] = 'H';是未定义行为,多数平台会崩溃。
  • s2是一个长度为6的字符数组(含末尾的\0),它把字符串字面量拷贝到了自己的栈空间,可以随意修改s2[0] = 'H';。

很多初学者写了char *s = "abc";之后试图修改,运行直接段错误,一脸懵。看一眼就明白了:你用指针指向了只读区域,当然不能写。

如果想让函数接收字符串并安全读取,习惯上写:

void print_string(const char *s) { while (*s) { putchar(*s); s++; } }

while (*s)的判断逻辑很C语言:*s是非零字符则继续循环,读到'\0'(数值0)则停止。这种写法比for (int i = 0; s[i] != '\0'; i++)更地道,也更能体现指针的流动感。

4.4 指针数组的应用:字符串表

指针数组最常见的场景是管理多个字符串:

const char *names[] = { "张三", "李四", "王五", "赵六" }; for (int i = 0; i < 4; i++) { printf("%s\n", names[i]); }

names是一个数组,里面每个元素都是const char*,指向不同的字符串常量。这个结构非常像一个小型数据库的表,适合存菜单项、错误信息、状态名等。

5. 更实用的进阶:指针的指针、函数指针、结构体指针

语法上再往上走一层,就是“指针的指针”和“函数指针”。这一节是很多教程里讲得最快,但实际工程中又特别常用的部分。

5.1 二级指针:想改指针本身,就要拿指指针的指针

一级指针int *p,用来修改它所指向的普通变量。如果想在函数里修改调用方传入的指针变量本身,比如让指针指向另一块新内存,就得用二级指针,也就是“指针的指针”。

一个典型场景是链表插入:

void insert_head(struct Node **head, int data) { struct Node *new_node = malloc(sizeof(struct Node)); new_node->data = data; new_node->next = *head; *head = new_node; // 通过二级指针修改外部的head }

这里为什么不能只用struct Node *head?因为如果传head本身,函数内部修改head只会改副本,外部的头节点指针不变。只有传&head,也就是二级指针,才能从函数内部把外部的头节点指针挪到新节点上,这才达到“插入头部”的效果。

另一个常考知识点是动态二维数组。如果你要分配一个int** matrix(每行长度可以不同),就需要二级指针。底层逻辑是:matrix本身是一个int**,它指向一个int*数组,这个数组里的每个int*又指向一行数据。

实际使用不要忘了释放:先释放每一行,再释放行指针数组。

5.2 函数指针:把函数当作参数传递

C语言里,函数本身也有地址。函数名就代表函数的入口地址。函数指针就是保存这个入口地址的变量。

声明格式看起来吓人,但拆开看很逻辑:

int (*handler)(int, int);

这是声明一个指针handler,它指向一个函数,该函数接受两个int,返回一个int。回忆之前数组指针的读法:先看到括号里的*handler,知道它是指针;再看到(int, int),知道它指向的函数的参数;最左边的int是返回值。

最简单的用法:

#include <stdio.h> int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int main(void) { int (*op)(int, int) = add; printf("%d\n", op(3, 4)); // 7 op = sub; printf("%d\n", op(8, 3)); // 5 return 0; }

函数指针最大的作用就是“回调”。比如写一个工具函数,对一个数组里的每个元素执行某种操作:

void for_each(int *arr, int n, int (*func)(int)) { for (int i = 0; i < n; i++) { arr[i] = func(arr[i]); } }

调用时,把square、double_it这类函数传进去,这个工具函数就能复用。C标准库的qsort也是这样设计的,它接受一个比较函数的指针,由用户决定排序规则。理解函数指针后,你对“函数是一等公民”这类说法也会有更明确的C语言视角理解。

5.3 结构体指针:隐藏的->运算符

结构体在C语言中是组合数据的方式,结构体指针则是最常用的访问方式。核心只有一个运算符记忆点:

  • (*ptr).member和ptr->member完全等价。
  • 因为->太常用,C语言专门设计了这个符号,免得你写一堆(*p).next。

一个链表示例:

struct Node { int data; struct Node *next; }; void print_list(const struct Node *head) { const struct Node *cur = head; while (cur != NULL) { printf("%d -> ", cur->data); cur = cur->next; } printf("NULL\n"); }

注意const struct Node*和struct Node* const的搭配。上面写法表示“不能通过 cur 修改节点内容”,但 cur 本身可以移动,适合只读遍历。写成struct Node *const cur才表示指针本身固定,你想遍历数组的时候就不能这么干。

结构体指针在工程中一个隐蔽的好处是传参成本低:传结构体本身则整个结构体要复制一份;传指针只复制一个地址。如果结构体很大,比如有几十个字段,传值会白白消耗大量栈空间和拷贝时间,所以C工程里几乎全部用指针传递。

5.4 指向结构体的指针和自引用

struct Node *next;为什么可以在结构体定义里用指向自己的指针?因为编译到这里时,struct Node这个类型名已经足够用来声明指针了。指针只需要尺寸信息,不需要完整的结构体布局,而指针的尺寸编译器已经知道。读到这里,你可以想想“如果不连续分配,链表怎么串起来”——答案就是每个节点都存了下一个节点的地址。

这是一种非常常见的“自引用结构”,也是链表、二叉树、图这些数据结构的地基。

6. 我踩过的指针的坑:排查崩溃和野指针

说真的,任何学指针的人都会经历一段“三天两头段错误”的日子。我把常见的问题整理成一张排查表,再讲几个排查思路,能帮你少踩很多坑。

现象常见原因对策
程序一运行就段错误定义了未初始化的指针,并解引用初始化:int *p = NULL;
函数里修改实参失败只传了值,没传地址改传指针
char *s = "abc"; s[0]='A'崩溃指向字符串字面量只读区域改用char s[] = "abc";
指针越界访问数组p超出了数组范围仍继续解引用遍历时检查边界
释放内存后继续使用悬垂指针释放后立即置NULL
free时崩溃指针不是malloc/返回的原始地址,比如加了偏移保存原始指针再free
打印乱码类型不匹配,如%d打印char*地址严格使用%p和类型转换

6.1 野指针和悬垂指针

  • 野指针:没有指向有效对象的指针。比如声明了没赋值,或者指向了已离开作用域的局部变量。
  • 悬垂指针:指针曾指向有效内存,但该内存已被释放或回收。

典型悬垂例子:

int *get_pointer(void) { int local = 1; return &local; // 错误!local在函数结束后就不存在了 }

local是栈上变量,函数返回后栈帧被销毁,地址虽然还能指向那块区域,但内容已不受保护。第二次调用其他函数很可能会覆盖这块区域。返回值必须是malloc出来的堆内存,或者指向静态存储区的变量。

平时写代码,我习惯在释放指针后立刻补一句:

free(p); p = NULL;

这样后续如果误用了p,至少会在if(p)时发现是空指针,不至于解引用一块已经被回收的内存。

6.2 排查思路:先确认指针是谁给的

遇到崩溃,第一步不要盲目改代码,先问自己三句话:

  1. 指针变量的初始值是什么?是取自&,还是数组名,还是malloc,还是函数参数?
  2. 在解引用之前,指针是否可能被改成了别的值?比如越界、自增过头、被意外的p = ...覆盖?
  3. 这个指针指向的内存的“生命周期”是否还在?

有一个小技巧:在怀疑的指针附近打印地址和值:

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

如果p打印出0x0,说明是空指针;如果打印出一个看似随机的地址,就要查指针的来源和赋值是否被破坏。

6.3 编译器是你的第一位调试员

早年间我学C,没开警告就瞎撞。后来才知道,Windows上IDE或Linux上GCC编译时加-Wall -Wextra,能立刻抓出一堆指针问题。

gcc -Wall -Wextra -std=c11 -o program program.c

比如未初始化局部变量、类型不匹配、符号比较等问题,编译器都能提示。别忽略警告,警告不是“可看可不看”,大多数时候它已经指出了你接下来几个小时的排查方向。

7. 指针题目,参考答案之外的思考方式

保姆级教程的最后一环,我想给你一套做题和自测的方法。C语言指针题目范围很广,但形式就那么几种:声明解读、传参改值、字符串操作、动态内存、链表。很多新手卡住是因为“只会抄代码,不会画图”。

7.1 画内存图:学习指针的最强工具

我强烈建议你在纸上或白板上画出每个变量的方框和地址。比如:

int a = 10; int *p = &a; int **pp = &p;

画三个方框:a方框里写10;p方框里写&a(也就是a方框的地址);pp方框里写&p。然后你就会发现:*pp等价于p,**pp等价于a。这比背一万句“二级指针指向一级指针”都管用。

内存图能避免一个特别常见的思维错误——把&当成一个普通的数值符号,而不知它是“某某方框的坐标”。多画几次,概念会自然沉淀成直觉。

7.2 动手复刻这几个经典函数

不要拿《 C程序设计语言》等经典书只读不练。下面这组函数,每个都值得独立实现,再做优化:

  • my_strlen(const char *s):计算字符串长度。
  • my_strcpy(char *dest, const char *src):把src拷贝到dest。
  • my_strcmp(const char *a, const char *b):比较字符串大小。
  • my_atoi(const char *s):把字符串转成整数。
  • 逆序字符串:输入hello输出olleh,要求用一个指针从前往后,一个指针从后往前。
  • 实现链表节点的头插、遍历、删除(删除时要修改被删除节点的前一个节点的next)。

这些题目刚好把指针的核心用法都过了一遍:遍历、步长、数组衰减、字符操作、结构体指针、二级指针。热搜里也有“字符串逆序c语言pta”,说明这是很多人学指针时的拦路虎。

给一个逆序字符串的参考实现:

void reverse_string(char *s) { char *left = s; char *right = s; while (*right) { right++; } right--; // 指向最后一个非'\0'字符 while (left < right) { char temp = *left; *left = *right; *right = temp; left++; right--; } }

代码本身不难,关键是两个指针相向移动。这能顺便理解“指向尾部”和“指向最后一个有效字符”的微妙区别。

7.3 进一步:在真实项目中体会指针

如果你已经把上面的函数都写通了,我建议去做一个带点实际意义的小项目,比如:

  • 自己写一个文本统计工具:读取一个文件,统计单词数、行数、字符数,用指针遍历缓冲。
  • 写一个保存学生信息的链表:增删查改,用二级指针处理头插。
  • 重写qsort的调用,用函数指针实现按姓名排序、按分数排序。

在这些项目里,你会真正感受到指针不是用来“考试”的,而是C语言运作的最小骨骼。文件的读写、链表的组织、回调机制,全都离不开它。

我个人回顾学指针的过程,最想强调的一句话是:不要害怕段错误,而要提前用“内存图+初始化+边界检查+编译警告”把自己保护起来。指针报错确实刺眼,但它的每次崩溃都在告诉你“你还不了解这台机器具体在哪找数据”。跑通几个函数、画了几张图之后,指针带给你的就不再是头疼,而是对程序运行本质的掌控感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询