ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言指针从入门到精通:内存地址、数组、函数与动态内存全解析

C语言指针从入门到精通:内存地址、数组、函数与动态内存全解析 学C语言最绕不过去的一道坎就是指针而网上也一直流传着“指针是C语言的灵魂”这种说法。作为一个从第12天开始硬啃指针、后来又在嵌入式开发里和指针纠缠了十几年的人我可以明确告诉你这句话是真的学懂指针之后数组、字符串、函数、结构体、链表这些东西会像被打通了任督二脉一样一下子串起来了。这篇内容写给正在学指针的C语言初学者也写给那些被指针坑过无数次、想回来把基础彻底补扎实的人。我会从最底层的内存地址讲起把指针变量、取地址和解引用、指针与数组、指针与字符串、指针与函数、动态内存分配、二级指针全部过一遍每一步都会告诉你为什么要这么写、哪里容易翻车。假设你已经有基本的C语言底子至少写过循环、数组和函数如果这些还不熟建议先把前面的内容补一补再来。1. 先搞清楚指针到底是个啥1.1 一切从内存地址说起讲指针之前必须先讲一个几乎所有C语言教材都会带过、但很多初学者根本没当回事的概念内存地址。你可以把内存想象成一栋巨大的公寓楼里面每一间房都有固定的门牌号。程序运行的时候不管是int、char还是结构体变量都要住进这栋公寓楼而它们的门牌号就是所谓的地址。在C语言里用取地址运算符可以轻松拿到一个变量的门牌号。比如int a 5; printf(a的地址是: %p\n, a);这段代码会输出一个类似0x7ffd4f2b3c44的十六进制数这就是变量a在内存中的位置。地址本身在内部就是一个无符号整数32位系统里占4字节64位系统里占8字节。我第一次打印地址的时候盯着那串十六进制看了半天才慢慢意识到原来我写的每个变量最终在机器眼里就是“一块起始地址加一段长度”根本没有所谓的变量名。地址这个概念为什么重要因为C语言不像有的语言那样帮你管理好一切它给你的是直接访问内存的能力。你要理解指针首先得习惯“变量背后还有一个地址”这个视角。往后你看到a、arr[0]、stu这些表达都应该条件反射地意识到这是在取门牌号。1.2 指针变量一个专门存地址的盒子有了地址这个概念指针变量就很好解释了普通变量存值指针变量存地址。比如int a 5; int *p a;这里p就是一个指针变量它里面装的不是5而是a的地址。很多人第一次看int *p会觉得困惑以为*p才是变量名其实不是变量名就是p星号只是告诉编译器“这个变量存的是地址”。指针变量本身也是变量当然也有自己的地址。你可以用p拿到指针变量自己的地址也就是“指向指针的指针”后面我们会讲到二级指针原理就在这一步埋下了。还有一个特别容易造成误解的点指针变量的大小其实和它指向什么类型没关系。在64位系统上所有指针变量都是8字节32位系统上是4字节。不管你是int *、char *、double *存地址的时候都占一样大的内存。这就好比门牌号本身无论对应的是单间还是豪宅号码都是同样长度的。别小看这句话它解决了后期很多关于“指针到底占几个字节”的困惑。1.3 C语言为什么要设计指针很多人会问别的语言没有指针不也活得好好的吗为什么C语言非要搞这么个东西出来因为C语言从出生那天起定位就是系统级语言。它要写操作系统、写驱动、写嵌入式程序、做底层数据管理这些场景无一例外都需要直接和内存打交道。没有指针你根本没有办法把一块内存地址传给另一段代码也没有办法高效地操作连续的数组、字符串更别说实现链表、树这些动态数据结构了。从实用角度讲指针带来了两个核心好处第一高效传参。一个大型结构体如果按值传给函数要复制整个结构体性能开销很大传指针就只传一个地址。第二动态内存。程序运行到一半才知道需要多少数组malloc一下弄一块堆内存出来然后靠指针去管理这块内存。没有指针这些操作一个都做不了。所以初学阶段可能会觉得“指针只是折腾地址”但等到学链表、二叉树、做项目的时候你会发现指针不只是一个语法点它是C语言组织数据结构的骨架。2. 指针的声明、赋值与解引用2.1 星号到底贴谁是个细节问题声明指针变量的时候星号的位置让人纠结。int *p、int* p、int * p这三种写法编译器都接受但这里有一个特别经典的坑我得单独拿出来说。假设你写了一行int* p, q;你的本意可能是“p和q都是指向int的指针”但实际结果是p是int*而q只是一个普通的int。这是因为星号只对它紧邻的那个变量生效不是对整条语句生效。正确的写法应该是int *p, *q;每个变量前面都要带星号。所以我的习惯是星号尽量贴着变量名写写成int *p。这样一条声明里如果跟着声明多个变量我要么全部写成带星号的形式要么干脆分行写。这个细节在写复杂声明的时候能少踩很多坑。还有就是命名习惯。指针变量最好带个能看出它是指针的名字比如ptr、pNode、pBuf不要起p、q到处滥用。等你一个月后再回来看代码看到一个p你会拼命猜它指向哪但如果叫pHead、pCurrent一眼就知道含义。这是我从被自己代码坑过好几次之后养成的习惯。2.2 取地址和解引用的配合C语言里两个运算符最容易混淆和*。是取地址把一个变量的地址拿出来*是解引用根据地址找到那一块内存拿到那个变量本身。一个最简单的流程就是int a 10; int *p a; // p存的是a的地址 *p 20; // 通过p修改a的值 printf(%d\n, a); // 输出20用生活化的类比来讲就是问“你住在哪给我门牌号”*就是拿着门牌号找过去然后进门、把房间里的东西换个样。*p 20这件事本质上改的就不仅仅是p指向的东西它改的就是a本身。很多初学者在这里绕是因为还不习惯“地址”和“值”这两个视角在来回切换。我给新人的一个建议是先不要管什么高深理论把这个流程写三遍。每次打印a的值、a的地址、p的值、*p的值把打印结果对比着看。你很快就会发现p的值和a一模一样*p的值和a一样。当你亲眼看到这一组数字的关系指针就不再是抽象概念了。2.3 指针类型到底有什么用能扔掉吗有同学会问既然指针变量都是存地址为什么还要区分int *、char *、double *反正都是8字节64位下。如果这样想后面一定会吃大亏。指针的类型至少决定了两件事。第一件事是解引用的时候访问几个字节。int *p做*p操作编译器会读取4字节char *p做*p操作只读1字节。如果类型不对在读内存时就会“多读”或者“少读”取出来的数字完全是乱的。第二件事是指针算术的步长这个我们下一节详细说但先记住指针加1并不是地址值加1而是加上指针类型的大小。我用一个实际场景说明如果你把一个double *强转成int *然后去解引用可能只读到8字节中的前4字节后面数据被当成另外的东西整个计算全错。初学阶段千万不要为了“省事”用强制转换绕过类型检查。编译器给的警告是帮你排查问题的不是用来忽略的。3. 指针与数组绕不开的组合3.1 数组名的真面目学数组的时候老师会说“数组名代表数组首地址”但到指针这一章这句话需要细化一下。看代码int arr[5] {1, 2, 3, 4, 5}; int *p arr; // 等价于 int *p arr[0]; printf(%d\n, *p); // 输出1在绝大多数表达式里数组名会“退化”成一个指向首元素的指针。所以arr和arr[0]在作为值使用的时候是同一个东西arr 1指向arr[1]p 1也指向arr[1]。但这里有个容易踩的坑数组名不能自增也不能赋值因为它本质上是“常量指针”性质的东西不是变量。arr或者arr p这种写法编译器直接报错。你可以把数组名理解成一个写着“我是首地址”的门牌这个门牌你不能摘下来换位置。还有一个容易混淆的点是sizeof(arr)和sizeof(p)。arr是整个数组的标识所以sizeof(arr)返回整个数组的长度比如10个int就是40字节而sizeof(p)返回指针本身的大小64位系统下是8字节。理解这个区别之后你在写函数时就能明白为什么数组传参后长度信息会丢失。3.2 指针算术加1到底跳多远指针支持加减整数但它加的不是1个字节。int *p指向某个intp 1在内存上会向后跳过一个int的大小也就是4字节double *pd加1则跳过8字节。再极端一点如果你有一个指向结构体的指针比如struct Node *p那么p 1会跳过整个struct Node。这个规则背后的原因就是我们前面说的指针类型。编译器看到指针的类型之后做加法时会自动乘上这个类型的大小int arr[5] {10, 20, 30, 40, 50}; int *p arr; printf(%d\n, *(p 2)); // 输出30而且C语言里有一个非常重要的等价关系p[i]和*(p i)完全等价数组下标本质上就是指针算术的语法糖。很多初学者在理解arr[3]时把它当成普通下标其实编译器眼里它就是在做*(arr 3)。理解了这个你读那些写得很巧妙的C代码时就不会一头雾水。不过要注意指针算术虽然方便但绝不能越界。p指向数组开头循环里一路p一旦过了数组末尾就是未定义行为。它不一定会立刻崩但会影响周围内存里那些你根本不该碰的数据。3.3 数组作为函数参数时的退化与长度问题写一个打印数组的函数有人喜欢这么写void printArray(int arr[]) { // 打印arr的每一个元素 }但实际上C语言里int arr[]和int *arr在函数参数声明中是完全一样的。编译器把数组参数“退化”成指针因为C语言是值传递如果传整个数组就要复制所有元素代价太大传一个指针只复制一个地址变量效率高很多。代价就是函数内部根本不知道数组到底有多长。你写sizeof(arr) / sizeof(arr[0])结果只会得到一个指针大小除以一个int大小完全不是元素个数。这就是为什么每个有经验的C程序员都会在数组函数里额外传一个长度参数void printArray(int *arr, int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } }我在培训新人的时候看到最多的错误就是不传长度、然后在函数里瞎猜数组大小最后越界访问程序莫名其妙崩了。记住一条铁律C语言数组传给函数必须把长度一起传过去除非你用了结束标志比如字符串的\0。4. 指针与字符串应用最广也最容易翻车4.1 两种字符串写法一个能改一个不能改C语言没有专门的字符串类型字符串本质上就是以\0结尾的字符数组。常见的写法有两种但它们的含义天差地别。char s1[] hello; // 字符数组可修改 char *s2 hello; // 指向字符串字面量通常只读s1创建了一个数组把hello含结尾的\0拷贝进去之后s1[0] H完全合法。而s2只是一个指针指向内存中一块字符串字面量这块数据在不少平台上是放在只读区的你尝试s2[0] H轻则运行时报错重则段错误崩溃。我年轻的时候就干过这种事程序跑起来直接崩还以为是机器的问题查了半天才反应过来是字符串被写入了只读区。所以实际经验是如果字符串内容需要修改就声明成字符数组如果只是用于显示、比较、传递可以用指针指向字符串常量。但稳妥起见我自己写代码时习惯统一声明成const char *s2 hello加了const之后编译器会帮我检查有没有不小心改它。4.2 指针数组和数组指针别让名字骗了你这两个概念几乎把每一个学指针的人都绕晕过一次。先看代码char *names[3] {Alice, Bob, Cindy}; int (*matrix)[4]; // 指向一个长度为4的int数组的指针names是指针数组它本身是一个数组数组里每个元素都是char*也就是说每个元素存的是字符串的起始地址。这种结构在管理一串字符串时非常实用比如命令列表、配置项列表只要维护一个指针数组就可以搞定。matrix是数组指针它本身是一个指针指向的是一个完整的数组。二维数组传参时数组指针就是主力。比如有一个int m[3][4]要在函数里接收它可以用void printMatrix(int (*m)[4], int rows);。编译器会把m[3][4]这种二维数组名处理成指向第一行的指针所以参数声明里int m[][4]其实和int (*m)[4]等价。记忆窍门就是看运算符优先级[]的优先级高于*。char *names[3]因为names[3]先成立所以它先是数组再是元素里存指针而(*matrix)里括号让*先起作用所以它先是指针再指向数组。你现在觉得绕没关系多写几行声明就知道这俩区别有多大了。4.3 用指针实现字符串操作字符串操作是指针最经典的实战场景。比如自己实现一个字符串拷贝代码可以非常简洁char *my_strcpy(char *dest, const char *src) { char *d dest; while (*d *src); return dest; }这段代码的核心就是一个while循环先把*src赋给*d然后两个指针分别自增一直循环到把\0也拷贝过去循环条件为假才停。第一次看到这种写法的人会觉得很炫但它本质上就是标准库strcpy的工作方式。不过我强烈建议初学者不要直接复制这种写法到项目里因为它有一个巨大的陷阱如果dest缓冲区不够大拷贝过程会一路越界写下去轻则破坏相邻数据重则崩溃。很多安全漏洞就是这么来的。实际开发中应该用strncpy或者自己加长度检查。理解指针操作的确很爽但安全边界必须时刻刻在脑子里。字符串拼接、查找、比较这些操作用指针实现的方式都差不多核心就是“用一个指针往前遍历直到遇到\0”。5. 指针与函数传参、返回值和函数指针5.1 为什么直接交换两个数就是不行学指针之前几乎所有人都写过这样一个失败的函数void swap(int a, int b) { int tmp a; a b; b tmp; }然后主函数里调用swap(x, y)结果打印x、y发现根本没变于是开始怀疑人生。问题不是逻辑写错了而是C语言的函数参数是值传递调用时a和b拿到的是x和y的副本函数里交换的是副本原件纹丝不动。解决办法就是用指针把原件地址传进去void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; } // 调用 swap(x, y);这里本质上还是值传递但传的是地址这个“值”有了地址函数内部就能顺着地址找到原始变量并直接修改。这件事的重要性远超它看起来的样子因为它把“值传递”“地址”“解引用”三个概念绑在一起。想通了这个后面学结构体指针、链表操作思路都会顺很多。5.2 函数也有地址函数指针代码编译后函数本身也存在于内存的代码区所以函数也有地址也可以被指针指向。函数指针的声明方式比较绕int add(int a, int b) { return a b; } int (*fp)(int, int); // fp是指向“返回int、参数两个int”的函数的指针 fp add; printf(%d\n, fp(3, 4)); // 输出7注意int (*fp)(int, int)的写法(*fp)加了括号让fp先被解释成一个指针然后再理解函数签名。这种声明方式和数组指针的思路是一致的核心都是“先让指针的身份成立再描述它指向什么”。函数指针的厉害之处在于你可以把函数当成一个值传来传去也可以把它存在数组里。比如一个计算器程序可以把加减乘除四个函数放到一个函数指针数组里用一个整数索引直接调用对应运算比写一堆if和switch清爽很多。5.3 回调函数把决定权交给调用者函数指针最实用的场景就是回调函数。C标准库里的快速排序函数qsort就是一个典型int cmp_int(const void *a, const void *b) { return *(int*)a - *(int*)b; } int arr[5] {5, 2, 8, 1, 9}; qsort(arr, 5, sizeof(int), cmp_int);qsort内部并不知道你要排的数字是按什么规则比较它只声明一个函数指针参数排序时调用这个函数来决定两个元素的先后。这就是“把决定权交给调用者”的回调思想。同一个排序函数换一个比较函数就能排结构体、字符串、小数代码复用程度立刻上去了。初学阶段你可能觉得回调抽象但实际项目里到处都是定时器调用你给的超时处理函数界面按钮绑定点击回调网络库收到数据后调用你的数据解析函数。理解函数指针本质上是理解了“程序可以把行为本身当作数据来传递”。这一步跨过去了你对C语言的认识会上一个台阶。6. 结构体指针、动态内存与二级指针6.1 结构体指针与-运算符结构体也是C语言里的一种类型自然也能被指针指向。访问结构体成员的经典写法是struct Student stu; struct Student *p stu; (*p).name 张三; // 先解引用再取成员麻烦 p-name 张三; // 推荐写法等价于上面-运算符其实就是“解引用再取成员”的语法糖省得每次写括号和解引用。链表的每个节点都包含一个指向下一个节点的指针这种“结构体里放同类型指针”的设计就是结构体指针最经典的应用场景。写链表时经常出现p-next-next这种连续解引用这时候一定要先确认p、p-next都不是NULL否则一个访问直接段错误。这个习惯不是理论是我被段错误砸过无数次之后练出来的肌肉记忆。6.2 malloc和free动态内存怎么用才安全指针的另一个大威力是配合动态内存分配。在堆上要一块内存int *p (int*)malloc(sizeof(int) * n); if (p NULL) { // 处理分配失败 }成功后p指向一块连续的堆内存大小是n个int你可以像数组一样p[i]去访问。但这里有一堆规矩每一条都是血泪教训第一malloc之后必须检查返回值是否为NULL内存分配可能失败。第二用完之后必须free(p)否则内存泄漏。长期运行的程序如果每次都泄漏一点迟早内存耗尽崩掉。第三free之后立刻把p置为NULL不然就留下一个“悬空指针”再访问就是未定义行为。第四记住malloc和free要配套谁分配谁释放。在嵌入式环境中要注意很多单片机平台堆资源非常有限malloc可能不可靠甚至不可用这种情况就要用静态分配或者自定义内存池这也是为什么单片机C语言代码里直接malloc的情况远比PC上少。6.3 二级指针什么时候才是真正的需要二级指针就是指向指针的指针。声明是int **pp读法是从右往左看**说明它是两级指针最终指向int。别被它吓到它的逻辑很简单一级指针指向一个变量二级指针指向一个一级指针。二级指针最常见的一个用途是在函数里修改调用者的指针变量。比如你想让一个函数给外界分配内存并让外界的指针指向这块内存只传一级指针是做不到的因为指针本身也是值传递函数内部改的是副本。必须传二级指针void init(int **pp) { *pp (int*)malloc(sizeof(int) * 10); } int *p NULL; init(p); // 此时p已经指向分配好的内存另一个经典场景是管理指针数组比如一个动态字符串列表扩容时要修改数组指针本身也要用到二级指针。将来学链表时很多教材用Node **pHead来更新头指针原因也是这个。如果现在觉得二级指针还没用上没关系但你知道它的存在和用途等真遇到时就不会懵了。7. 初学指针最容易踩的坑与调试技巧7.1 野指针没初始化就敢用定义一个指针变量后如果立刻解引用那基本上是在玩火。因为指针变量里装的还是一个垃圾值它指向哪里完全不可预测。对它解引用轻则读到乱七八糟的数据重则直接段错误。正确做法是声明指针时就初始化。没有确定目标就明确赋NULLint *p NULL;以后使用前先判断if (p ! NULL)再操作。判空是防御编程的第一道防线。我当年初学时就写int *p; *p 100;程序当场崩了当时还一头雾水现在想起来就是典型的野指针。现在不管什么指针声明必初始化已经成了肌肉记忆。7.2 悬空指针内存释放了指针还留着free(p)之后如果p没有置空它仍保存着那块已经还给系统的地址此时再访问就是典型的“悬空指针”问题。更隐蔽的是多个指针指向同一块内存你只free了一个另一个还傻傻地指向那里后面某处通过那个“傻指针”去读写就是经典的use-after-free错误。这类问题在初学阶段不容易遇到因为代码量小但一旦遇到就是最难查的bug之一。常见解法free之后立刻p NULL同一个内存块明确只有一个所有者不要把同一个指针到处赋值后又到处free。真在项目里排查这种问题有两个工具帮大忙一个是gdb调试器另一个是valgrind内存检测工具valgrind ./a.out会报告非法的读写、不匹配的分配释放定位问题非常快。7.3 越界访问指针趟过了边界数组下标越界和指针越界本质是一回事指针在循环里一路前进忘了检查边界。越界访问最坑的地方是它不会立刻报错可能先污染了相邻内存等真正崩溃时崩溃位置和你越界的地方看起来毫无关系排查起来心态直接崩掉。我的经验是每次写涉及指针移动的循环都在心里过一遍边界条件。循环里的计数变量、比较条件、内存分配大小这三者必须一一对应。比如分配了10个int循环里i的范围就只能是0到9。字符串操作里特别容易越界因为strcpy、strcat根本不知道目的缓冲区多大。初学时宁可多写几行检查也不要图省事。7.4 确实好用的调试方法遇到指针相关的bug最有效的三板斧第一printf大法。在关键步骤前后打印指针的值和指向的值比如printf(p%p, *p%d\n, p, *p);。地址值的变化会告诉你指针走到了哪很多问题一眼就能定位。第二gdb单步调试。在可疑位置打断点用print p看指针值用print *p看指向的值用x/8bx p看那一片内存的原始字节。有时候你从打印结果里能直接看出某个字节本来不该被修改但被改了说明越界写就发生在附近。第三画内存图。纸笔或者画图工具都行把变量、地址、指针箭头画出来。指针学不好十有八九是脑内没有内存布局的图景。把“p指向什么”“p1又指向什么”画清楚很多绕来绕去的问题其实很简单。7.5 别乱用强制转换和起个好名字还有一个很多人忽略的坑为了强行让类型匹配直接用强制转换把编译器警告压下去。比如把一个int *转成char *再解引用编译器警告不报了数据的解释方式却变了结果可能是灾难。初学阶段类型不匹配就乖乖改类型不要用强转掩盖问题。另外给复杂的指针类型起别名能省掉大量麻烦。C语言的typedef可以把一个折腾人的函数指针类型简化成短名字typedef int (*FuncPtr)(int, int); FuncPtr fp add;遇到int (*fp[4])(int, int)这种复杂声明时用typedef分段拆开代码清晰很多也不容易写错。这个技巧我在写带回调接口的模块时几乎天天用。最后再把一个个人习惯分享给你每次遇到指针相关的问题我都要求自己先画内存图再写代码。从学指针第一天开始这个习惯帮我省下来的debug时间我自己都数不清。指针学得好不好其实有一个很简单的自测题int *p, arr[10]; p arr; // 请回答p指向什么*p等于多少p3指向哪里如果你能在零点几秒里说出答案恭喜你你已经摸到指针的门道了。剩下的事情就是多写代码把“门道”变成“肌肉记忆”。
返回列表