news 2026/9/7 22:51:30

C语言指针详解:从内存地址到二级指针的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言指针详解:从内存地址到二级指针的完整指南

1. 指针到底存了什么:从门牌号开始理解

1.1 变量的门牌号:&取地址,*访客进门

我当年学C语言,最先卡死的地方就是指针。老师在黑板上画方框、画箭头,说“指针就是指向变量的变量”,我听完更晕——变量还能指向变量?后来自己写代码,才慢慢悟明白一件事:指针解决的根本问题,就是“程序怎么找到一块内存”。

把内存想象成一条很长的街道,每个字节都有一个门牌号(地址)。定义int age = 25;的时候,系统在街道某处找了四个连续房间,把25存进去,然后让名字age跟那个门牌号绑定。你写代码时只需要用age,但计算机真正需要的是地址。&age就是“查询门牌号”,*则是“拿着门牌号进屋取东西或放东西”。

#include <stdio.h> int main(void) { int age = 25; int *p = &age; printf("age 的值: %d\n", age); // 25 printf("p 的值: %p\n", (void *)p); // 某个十六进制地址 printf("*p 的值: %d\n", *p); // 25 *p = 30; // 等价于 age = 30 printf("age 的新值: %d\n", age); // 30 return 0; }

p保存的是age的地址,所以p是“指针变量”。*p表示“p 所指向的那个 int 变量”,因此*p = 30会直接修改age所在内存里的内容。新手最容易混淆的其实是声明和使用的区别:int *p;里的*是在告诉编译器“p 是指针”,而后面单独写*p是在解引用。我后来有个习惯——把代码里的*p读作“p 所指的那个东西”,很多混淆就自动消失了。

1.2 类型决定解释方式,但不决定指针的大小

有一个问题几乎人人都会问:int *pchar *p,指针变量占的内存一样大吗?在64位系统上,答案是一样大,通常8字节。因为指针变量的内容本质上是一个地址,地址就是整数,大小由系统位数决定。

那类型有什么用?类型决定的是“拿到地址之后怎么解释这块内存”。

指针类型解引用读取长度(常见平台)p + 1 的地址偏移
char *1 字节1
short *2 字节2
int *4 字节4
double *8 字节8
struct Node *sizeof(struct Node)sizeof(struct Node)

int *p; p + 1不是简单地“地址+1”,而是让地址跳过一个 int 的长度,也就是4字节。很多人觉得指针的加减运算神出鬼没,根子全在这个“步长”上。写代码时如果发现指针加1后地址没有按预期跳,多半是类型写错了,或者用%p打印地址时没有转成void*

1.3 打印地址的正确姿势

打印指针值要用%p,并且建议显式转成void *

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

%p匹配void*,虽然不转在很多编译器上也能过,但严格按标准来可以避免不必要的warning。还有一个细节:不要试图用printf打印“指针指向的地址的十进制值”来做所谓的“验证”,除非你在做底层调试,否则毫无必要,还容易引入未定义行为。

2. 数组与指针:别再说“数组名就是指针”了

2.1 数组名会退化,但它不是指针变量

网上有句话传播很广:“数组名就是指针。”这句话害了不少人。严格说,数组名在多数表达式中会“退化”成首元素的地址,所以下面的代码能编译通过:

int a[5] = {10, 20, 30, 40, 50}; int *p = a; // a 在这里退化为 &a[0]

但数组名本身不是指针变量。它不能出现在赋值运算符左侧,比如a++a = p都是非法的。区分两者的最简单方式是sizeof

printf("%zu\n", sizeof(a)); // 整个数组大小,例如 20 printf("%zu\n", sizeof(p)); // 指针变量大小,例如 8

a是一个包含5个int的数组,p只是保存地址的变量。它们的区别在C语言面试里几乎必考,理解这一点,后面看二维数组和函数参数才不会乱。

2.2 指针算术:p[i] 就是 *(p+i)

数组下标p[i],编译器最终会理解成*(p + i)。这个等价关系是理解数组和指针的钥匙。

int a[5] = {10, 20, 30, 40, 50}; int *p = a; printf("%d\n", *p); // 10 printf("%d\n", *(p + 2)); // 30 printf("%d\n", p[2]); // 30 printf("%d\n", 2[p]); // 30,合法但别这么写

2[p]能跑是因为2[p]会被解析为*(2 + p),而*(2+p)*(p+2)完全一样。知道有这回事就行,实际项目里没人这么写,但看见别人写了要能看懂。

数组作为函数参数时,形参写int arr[]int *arr完全等价,因为数组在传参时退化成指针了。也正因如此,函数内部用sizeof(arr)拿到的是指针大小,不是数组大小。这就是为什么C语言里传数组必须额外传长度:

int sum(int *arr, int n) { int total = 0; for (int i = 0; i < n; i++) { total += *(arr + i); // 等价于 arr[i] } return total; }

2.3 二维数组为什么不能直接当 int** 传

很多初学者刷题时写过类似代码:二维数组传进函数,形参写成int **arr,结果编译警告或者运行崩溃。原因在内存布局。int matrix[3][4]在内存里是一段连续12个int,而int**描述的是“指向指针的指针”,内存布局完全不同。

正确的写法有两种,本质是一样的:

void print_matrix(int m[][4], int rows) { ... } // 或 void print_matrix(int (*m)[4], int rows) { ... }

这里int (*m)[4]是“指向一个含4个int数组的指针”,也就是数组指针。小括号是关键:int *m[4]是“含4个int指针的数组”,也就是指针数组,俩完全不是一回事。我一直用一个口诀:[]优先级比*高,所以int *m[4]是数组,用括号把*m包起来才是指针。

#include <stdio.h> void print_matrix(int (*m)[4], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 4; j++) { printf("%d ", m[i][j]); } putchar('\n'); } } int main(void) { int matrix[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; print_matrix(matrix, 3); return 0; }

print_matrix(matrix, 3)调用时,matrix退化成指向第一行的指针,也就是int (*)[4]。记住这个“退化”方向,就不会再疑惑为什么形参写成int**不对了。

3. 字符串和指针:搞清楚可改和不可改

3.1 字符数组和字符指针:一个想改就崩,一个碰巧能跑

C语言里没有独立的字符串类型,字符串是用字符数组或字符指针表示的。下面两行长得像,实际天差地别:

char s1[] = "hello"; // 栈上分配6字节,内容从常量区拷贝过来,可修改 char *s2 = "hello"; // s2保存字符串字面量的首地址,内容在只读段

s1[0] = 'H'安全,改的是自己栈上的数组。s2[0] = 'H'是未定义行为,在Linux上多半直接段错误,在Windows上可能“碰巧能跑”,然后留给你一个后半夜才能查出来的bug。为什么?字符串字面量"hello"在程序加载时被放在只读区域,任何写入都会触发保护机制。

我的习惯是:只要不打算改字符串内容,统一写成const char *s = "hello";。这样万一后面有人试图改它,编译器直接报错,把运行时问题提前到编译期。想要能改的字符串,就用char s[]

3.2 字符串逆序:双指针移动的思路

字符串逆序是很多练习平台(比如PTA)的经典题。用指针写比用下标写更能展示“指针操作内存”的含义:

#include <stdio.h> #include <string.h> void reverse(char *s) { char *left = s; char *right = s + strlen(s) - 1; while (left < right) { char tmp = *left; *left = *right; *right = tmp; left++; right--; } } int main(void) { char str[] = "hello"; reverse(str); printf("%s\n", str); // olleh return 0; }

left从头往后,right从尾往前,两者都通过指针运算移动。注意调用strlen(s)的前提是s指向以\0结尾的字符串,如果传进去的是不可写内存还是会出问题,所以调用reverse前要保证参数是char[]这类可写数组。

3.3 指针数组存放字符串:排序只需要换指针

处理多个字符串时,有人会下意识用char words[3][16]这种二维数组。好处是内存连续,坏处是浪费空间、长度写死,排序时交换两个字符串要strcpy,成本高。更常见也更推荐的是指针数组:

#include <stdio.h> #include <string.h> int main(void) { char *words[] = {"pear", "apple", "orange"}; int n = sizeof(words) / sizeof(words[0]); for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (strcmp(words[j], words[j + 1]) > 0) { char *tmp = words[j]; words[j] = words[j + 1]; words[j + 1] = tmp; } } } for (int i = 0; i < n; i++) { printf("%s\n", words[i]); // apple orange pear } return 0; }

words是一个数组,里面每个元素是char *。排序时交换的是“指针指向的地址”,字符串本身在原地没动,所以效率很高。但如果想用scanf("%s", words[i])往里面读字符串,问题就来了——指针数组里的指针还没有指向任何可写的内存,必须先malloc或者改成二维数组。这算是我见过最频繁的崩溃原因之一。

4. 函数、参数和函数指针:指针不只是用来传地址

4.1 值传递:为什么swap必须传指针

C语言函数传参一律是“拷贝”。写一个交换函数,如果不传指针,等于白写:

void swap_wrong(int a, int b) { int tmp = a; a = b; b = tmp; } void swap_right(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; }

swap_wrong里交换的是形参的副本,调用结束后什么都没改变。swap_right虽然也是值传递,但它拷贝的是两个地址。通过地址能定位到调用者的变量,于是交换成功。

理解这一步的关键是:指针本身也是值,只不过这个值是一个地址。要修改一个int,传int *;要修改一个int *,就得传int **。这个推导关系到二级指针时特别有用。

4.2 函数指针:把函数当成一种数据

函数不是变量,但函数有地址,也就是入口地址。函数名在表达式中也会退化成函数指针。所以可以写:

#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("add: %d\n", op(3, 4)); // 7 op = sub; printf("sub: %d\n", op(3, 4)); // -1 return 0; }

声明int (*op)(int, int)的括号一定不能丢。int *op(int, int)会被解析成“返回int*的函数”,int (*op)(int, int)才是“指向函数的指针”。函数指针最常见的用途是回调,标准库qsort最后那个参数就是比较函数指针。我曾经写过一个基于qsort的字符串排序:

#include <stdlib.h> #include <string.h> int cmp_str(const void *a, const void *b) { return strcmp(*(const char **)a, *(const char **)b); } // 调用 qsort(words, n, sizeof(char *), cmp_str);

qsort不知道你排的是int还是char *,它只拿到元素的起始地址和大小,所以比较函数的参数是const void *。在cmp_str里把void *转回char **,再解引用得到char *,也就是字符串本身。这段代码是函数指针、二级指针和指针数组的一次组合拳,建议在本地跑一遍。

4.3 空指针的防御:NULL不是用来“撞”的,是用来“查”的

NULL在C语言里是“不指向任何有效内存”的特殊指针值。很多运行时报错,比如网上常搜的“timer执行查询报空指针”,本质上就是拿了一个NULL去解引用,程序当然崩。

我的防御习惯有三条:

  • mallocfopen这类可能失败的函数,返回值立刻判断是否为NULL
  • 函数入口对形参做检查:if (p == NULL) return;
  • 释放内存后把指针置NULLfree(p); p = NULL;

为什么释放后要置NULL?因为free只是把内存还给了堆管理器,p里保存的地址还在。这个地址所指的内存可能马上被分配给别人,再解引用就是读或写未知数据。置NULL之后,解引用会立刻崩溃,反而比悄悄出错好排查得多。

5. 结构体指针与二级指针:链表和“改指针”的真相

5.1 结构体指针:使用 -> 的底层逻辑

p->data其实就是(*p).data的简写。先解引用拿到结构体,再取成员。箭头让代码更直观,但心里要清楚它在做什么。

结构体在C语言里的地位很高,因为它是组织复合数据的基础。链表节点就是结构体指针最典型的应用:

#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node; Node *create_node(int data) { Node *node = (Node *)malloc(sizeof(Node)); if (node == NULL) { return NULL; } node->data = data; node->next = NULL; return node; } int main(void) { Node *head = create_node(1); head->next = create_node(2); printf("%d -> %d\n", head->data, head->next->data); return 0; }

结构体可能很大,函数传结构体指针比传整个结构体拷贝更高效。链表、二叉树这类动态数据结构更是离不开指针,next字段本身就是一个Node *。写链表的时候,新手最容易问的问题就是“为什么节点里要放一个指向自己类型的指针”,因为这样节点才能串起来,没有指针就没有链条。

5.2 二级指针:只有需要修改指针本身时才用

一级指针用于修改普通变量的值,二级指针用于修改一级指针的值。当函数需要让调用者的指针变量指向一块新内存时,必须传&head

#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node; void init_list(Node **head) { *head = (Node *)malloc(sizeof(Node)); if (*head == NULL) { return; } (*head)->data = 0; (*head)->next = NULL; } int main(void) { Node *head = NULL; init_list(&head); if (head != NULL) { printf("head->data = %d\n", head->data); } free(head); return 0; }

如果函数签名写成void init_list(Node *head),函数内部对head赋值,改的只是形参副本,调用结束后main里的head依然是NULL。这一点和 swap 是同一个道理:要改intint *,要改int *就用int **。链表的头插法和删除头节点都需要这种能力,因为头指针本身会变。

5.3 指针赋值:分清是改指向还是改值

学习指针时有一个特别基础、特别容易混的操作:

int a = 10, b = 20; int *p = &a; p = &b; // 改指向:p 现在指向 b *p = 99; // 改值:b 变成 99,a 不受影响

判断方法很简单:看等号左边是p还是*pp = ...是在改“门牌号”,让指针指向别处;*p = ...是在改“房间里的东西”。我反复强调这个,是因为发现很多段错误其实是把这两行搞反了,尤其是传递参数时。

另一个经典错误是“未初始化指针直接解引用”:

int *p; *p = 10; // p 没初始化,里面是随机地址,往随机地址写数据,大概率崩溃

正确的做法是让指针先指向某个合法变量,或者用malloc分配内存,或者直接置为NULL再判断。三者选其一,具体看场景。

6. 动态内存、文件指针与指针排错

6.1 malloc、realloc 和 free 的正确配合

谈到指针不可能绕开动态内存。malloc返回void *,它是一块“大小已知但类型未知”的内存,需要转换成具体类型的指针。分配的字节数建议用sizeof计算,不要手写数字。

free只释放指针指向的那块内存,不会把指针变量清掉,所以释放后要习惯性置NULL。还有一个更隐蔽的坑是realloc

#include <stdio.h> #include <stdlib.h> int main(void) { int *p = (int *)malloc(10 * sizeof(int)); if (p == NULL) { return 1; } int *tmp = (int *)realloc(p, 20 * sizeof(int)); if (tmp == NULL) { // 失败:p 仍然指向原有内存,仍然有效 perror("realloc"); } else { p = tmp; } free(p); return 0; }

不要写p = realloc(p, 20 * sizeof(int));。如果realloc失败,它会返回NULL,但原来那块内存还活着,你把NULL覆盖给p,原来的地址就丢了,既造成泄漏,后面再想释放也无法释放。

动态内存泄漏在每个malloc都必须对应一个free。长期运行的服务如果泄漏,内存会逐渐上涨直到被系统杀掉。我在本地排查泄漏时常用valgrind,后来也直接用编译器的 AddressSanitizer,效果很好。

6.2 文件指针:FILE* 的读写套路

C语言里文件操作的FILE *也是指针。fopen返回一个文件指针,失败时返回NULL。这跟malloc很像,第一步永远是判空:

#include <stdio.h> int main(void) { FILE *fp = fopen("scores.txt", "r"); if (fp == NULL) { perror("fopen"); return 1; } int score; while (fscanf(fp, "%d", &score) == 1) { printf("score = %d\n", score); } fclose(fp); return 0; }

这里有个细节:循环条件用的是fscanf(...) == 1,不是!feof(fp)feof要等到读操作已经触底之后才会返回真,用它做循环条件经常会多处理一次,这是C语言文件读写里非常经典的低级错误。写入用fprintf(fp, "score = %d\n", score);,模式改为"w"。文件操作结束后记得fclose,并且习惯性把fpNULL,逻辑和动态内存的悬空指针一模一样。

6.3 指针Bug排查三板斧

指针出问题时,我最常干的三件事,按顺序来:

  • 编译时开警告。gcc -Wall -Wextra -g能把很多未初始化、类型不匹配的问题直接亮出来。
  • 运行时开 AddressSanitizer。gcc -fsanitize=address -g,越界读写、use-after-free、栈溢出这类问题会直接报出文件和行号。
  • 小范围加打印。printf("p=%p *p=%d\n", (void *)p, *p);看指针指向哪里、解引用得到什么,往往三五分钟就能定位。

还有一个多年积累的习惯:在代码里明确注释每个指针的“所有权”。谁malloc谁负责free;函数是“借”指针来用,还是“接管”这块内存。多人协作时很多Bug都源于“都以为对方会释放”。指针不是玄学,它只是地址,真正坑人的是“地址指向哪里、生命周期归谁管”这两件事没想清楚。把这套思路内化之后,你再看链表、二叉树、文件操作和回调,会发现它们都是同一套底层逻辑在不同场景的变体。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 22:49:37

n8n循环节点Loop Over Items详解:批量数据处理与邮件发送实战

用 n8n 做自动化&#xff0c;几乎都会撞上同一个需求&#xff1a;上游一次性返回了几百条数据&#xff0c;你却希望一条一条处理。给用户挨个发邮件、按订单逐条查物流、把接口返回的列表分批写入第三方系统……这些场景里&#xff0c;Loop Over Items 就是那个“救火队员”。它…

作者头像 李华
网站建设 2026/9/7 22:48:51

DTU Tool在工业物联网中的核心功能与应用实践

1. DTU Tool 核心功能解析DTU Tool作为工业物联网领域的关键数据传输工具&#xff0c;其核心价值在于实现设备数据的可靠采集与远程传输。我接触过的上百个工业现场案例中&#xff0c;约78%的SCADA系统集成项目都会用到这类工具。不同于普通的串口转网口设备&#xff0c;专业级…

作者头像 李华
网站建设 2026/9/7 22:48:40

AI Debugger Pro实战:Java后端生产环境BUG定位与修复全解析

如果你是个Java后端&#xff0c;看到“AI Debugger Pro”这个开源项目&#xff0c;大概率会先愣一下&#xff1a;又一个蹭AI热度的玩具&#xff1f;我在生产环境摸爬滚打这么多年&#xff0c;Debug靠的是日志、经验和运气&#xff0c;一个工具敢说自己能“一键定位并修复90%的B…

作者头像 李华
网站建设 2026/9/7 22:45:49

电动车与微电网的波动性博弈:光储充项目实战解析

做了这么多年微电网项目&#xff0c;我对“电动车遇上微电网”这件事的第一反应是&#xff1a;这不是巧合&#xff0c;是必然。去年我参与的一个园区光储充项目&#xff0c;前前后后折腾了快一年&#xff0c;踩了不少坑&#xff0c;也终于把“波动性”这三个字琢磨得比较透。电…

作者头像 李华
网站建设 2026/9/7 22:44:40

HDFS与S3对象存储深度对比:架构差异、适用场景与选型指南

先回答一个我经常被问到的问题&#xff1a;公司 Hadoop 集群上存着几十 TB 数据&#xff0c;跑得好好的&#xff0c;但领导看到云厂商的 S3 宣传&#xff0c;问要不要把 HDFS 整个迁到对象存储上。这个问题我被人问过不下十次&#xff0c;每次都得从头解释一遍 HDFS、S3、对象存…

作者头像 李华
网站建设 2026/9/7 22:38:53

从容错到限流:保障微服务可靠性的关键策略分析

目录 一、服务访问失败的原因和应对策略 (一)服务访问失败的4大原因和分类 1.硬件失败 2.分布式环境的固有原因 3.服务自身失败 4.服务依赖失败 (二)服务访问的雪崩效应 (三)服务访问失败的应对策略 二、服务容错策略 (一)Failover(失效转移) (二)Failb…

作者头像 李华