首页 / 资讯中心 / 文章详情

C语言指针从入门到精通:内存地址、数组与函数指针实战

C语言指针从入门到精通:内存地址、数组与函数指针实战 ★ FEATURED ARTICLE
咱们直接聊指针。“C语言指针”这四个字可以说是无数初学C语言同学的拦路虎。去各大论坛逛一圈总能看到“指针到底怎么学”“指针怎么又段错误了”之类的帖子。这几年我带过不少新人自己也带过嵌入式项目说实话指针这东西没那么玄乎关键在于你把它放到“内存地址”这个底层模型里去看。理解了地址指针就是“存地址的变量”理解了这一点后面数组、字符串、函数指针、动态内存这些东西全部都是一通百通。这篇东西我会按自己实际学下来、教下来的经验把指针从头到尾拆开讲清楚每一步都带代码、带运行结果、带常见坑适合刚学完C语言基本语法、看到指针就发懵的同学也适合那些能写出简单程序但在指针上经常出错的读者。看完之后你至少能搞明白指针是什么为什么需要它它怎么跟数组、字符串、函数配合以及遇到段错误时该怎么查。1. 指针到底是什么从内存地址开始1.1 内存地址变量的“门牌号”很多人一上来就背“指针就是地址”背是背了但没真正建立模型。我讲课习惯先从计算机内存的布局说起。你的电脑内存可以理解成一排很长很长的宿舍每个宿舍有一个唯一编号。这个编号在计算机里叫“内存地址”。程序跑起来的时候你定义的所有变量都要申请一个或几个这样的“宿舍”住进去。比如你写了int a 10;系统就会在内存里找一块区域假设它从地址0x7ffeefbff5ac开始占用4个字节里面存的数值是10。以后程序里用到a本质上是说“去0x7ffeefbff5ac这个地址读4个字节出来”。那怎么拿到这个地址呢C语言提供了一个很直接的运算符取地址符printf(a 的地址是%p\n, a);%p 是专门打印地址的格式符输出的是十六进制。这个操作你其实一直都会只是没意识到它有多重要——scanf(%d, a)就是传a的地址进去让 scanf 把输入的数据写到那个内存位置。指针变量说白了就是用来保存这种“门牌号”的变量。普通变量存值指针变量存地址。就这么简单。1.2 指针变量的定义与使用定义一个指针变量的语法看起来有点怪其实有规律int *p; // 读作p 是一个指向 int 的指针或者说 p 保存的是 int 变量的地址 double *q; // q 保存的是 double 变量的地址 char *r; // r 保存的是 char 变量的地址星号*紧挨着谁不重要可以写int* p、int *p、int*p意思都一样。但“p是指向int的指针”这个语义要刻进脑子里。有了指针变量我们还要让它“指向”某个变量。方法就是把那个变量的地址赋给它int a 10; int *p a; // 把 a 的地址存到 p 里此时我们说 p 指向 a printf(a 的值%d\n, a); // 直接访问 a输出 10 printf(p 的值%p\n, p); // 输出 a 的地址 printf(p 指向的值%d\n, *p); // 用 * 解引用取 p 指向的内存里的值输出 10这个*p就是解引用dereference意思是你手里拿着一张写着“a的地址”的纸条*p就是按纸条上的门牌号找过去把那个变量揪出来。找到之后你还能改它*p 20; printf(%d\n, a); // 现在 a 变成 20 了这里就是指针最有意思的地方我没有直接动变量名a而是通过地址把它给改了。1.3 指针类型为什么重要你可能注意到了定义指针的时候都要带上类型比如int *p、double *q。有人会问地址不就是地址吗都是一串数字干嘛还要分类型因为解引用的时候编译器得知道要读多少个字节读出来的数怎么解释。int在大多数平台占4个字节double占8个字节如果不知道类型*p根本不知道该从那个地址往后切多少数据。另外后面讲到指针加减时类型还决定“走一步”跨多远。我们把指针理解为“类型 地址”的组合。int *p翻译成人话是p 保存的是一个地址而且那个地址处的数据应该按 int 来解释。如果类型对不上轻则警告重则数据错乱。还有非常经典的一个问题const 修饰指针的时候有两种截然不同的写法很多人分不清。比如int a 10, b 20; const int *p a; // p 指向 const int不能通过 p 修改 a但 p 自己可以改 int *const q a; // q 是 const 指针q 本身不能改但可以通过 q 修改 a这里涉及两个层次底层指针指向的数据不能改和顶层指针指针本身不能改。如果问你“顶层指针和底层指针可以相互赋值吗”答案是底层指针指向const int的指针初始化时一般不能用顶层指针int *直接赋给它因为这会绕过const限制反过来倒可以。日常写代码最省心的做法是清楚你写的是“指针本身的const”还是“指向数据的const”两个方向不要搞混。2. 指针的操作、运算和绕不开的坑2.1 取地址与解引用 和 * 是一对反操作是取地址*是解引用。如果把看成“告诉我门牌号”那*就是“按门牌号进门”。一对互逆的操作int a 5; int *p a; // 把 a 变成地址存入 p int b *p; // * 把 p 里的地址还原成变量取出值 5这个对联式的用法初学者特别容易懵我给你一个比较稳的心法看到变量心里默念“变量的地址”看到*指针变量心里默念“指针指向的那个变量”。写多了自然就顺了。解引用有个前提指针必须指向合法的内存。如果你的指针是乱七八糟的值解引用就是非法访问程序会崩。所以写代码的时候要时刻问自己我这个指针现在是有效地址吗2.2 指针运算p1 到底加了多少个字节指针不是单纯整数它的加减法是带“步长”的。数组遍历是理解这个的最佳场景int arr[5] {10, 20, 30, 40, 50}; int *p arr; // 数组名会退化成首元素地址等价于 arr[0] printf(%d\n, *p); // 10 printf(%d\n, *(p1)); // 20 printf(%d\n, *(p2)); // 30这里p1不是把地址简单加1而是加了一个sizeof(int)也就是4个字节。编译器看到 p 的类型是int *它就自动把“走一步”的步长设成 int 的大小。所以p从指向 arr[0] 变成指向 arr[1]。这个特性用起来非常舒服遍历数组不用下标也能写for (int i 0; i 5; i) { printf(%d , *(p i)); // 等价于 p[i] }顺便说一句下标访问p[i]在编译器眼里就是*(p i)这俩本质上是同一种东西。理解了这一点你会突然明白为什么数组下标可以写成i[arr]这种鬼样子——因为i[arr]展开成*(i arr)加法交换一下也没毛病。虽然实际开发没人这么写但考题喜欢出。2.3 空指针、野指针、悬空指针与const修饰这个坑太经典了几乎每个写过C语言的人都被坑过。空指针指向 NULL 的指针。NULL 在C语言里就是 0一个不存在的地址。对空指针解引用是提领空指针程序通常直接崩溃。野指针定义后没有初始化指向哪完全随机。它比空指针更危险因为空指针你还能判断if (p NULL)野指针你连判断的机会都没有。悬空指针指向的内存已经被释放了。典型情况是free(p)之后没有把 p 置为 NULL之后再用 p 就会出问题。安全习惯就三条。第一指针变量定义时全都初始化为 NULL。第二使用前判断是否为 NULL。第三free 之后立刻把指针置 NULL。这就像开车系安全带养成习惯后能少百分之九十九的崩溃现场。还有 const 修饰这块前面提过的“顶层 const”和“底层 const”的概念在C里讲得比较多但C语言里同样适用。判断方法很简单const 紧挨着谁的左边谁就不能变。const int *pconst 修饰的是int所以指向的数据不能变int *const pconst 修饰的是p所以指针本身不能变。2.4 传值还是传址swap函数是最好的入门题我能想到的、最能说明“为什么需要指针”的例子就是交换两个数。你写一个函数void swap(int a, int b) { int temp a; a b; b temp; } int main() { int x 3, y 5; swap(x, y); printf(x%d y%d\n, x, y); // 输出 x3 y5没换成功 return 0; }没换成功因为C语言默认传值。函数里的 a、b 是 x、y 的临时拷贝你在函数里怎么折腾都是拷贝的事影响不到外面的 x、y。这就好比你把手机号和自己的照片印在名片上发给别人别人把名片上的照片涂了你自己手里的照片一点事没有。要真正影响外面的变量必须拿到它的地址void swap(int *a, int *b) { int temp *a; *a *b; *b temp; } int main() { int x 3, y 5; swap(x, y); printf(x%d y%d\n, x, y); // 输出 x5 y3 return 0; }看到区别了吧。形参的类型从 int 变成 int*调用的时候传地址函数内部用 *a、*b 直接操作原来的变量。这可以说是指针最重要的价值之一让函数有能力修改调用者的数据。很多初学者在这会犯一个错误写成void swap(int *a, int *b) { int *temp a; a b; b temp; }这个写法交换的是指针形参本身对函数外的 x、y 依然没影响。它和正确版本的区别在于你要交换的是“指针指向的变量”还是“指针的值”。这个细节千万注意。3. 指针与数组、字符串最实战的组合3.1 数组名是常量指针吗严格来说数组名不是指针它是一个“能退化成指针的数组标识符”。在很多表达式里数组名默认转换成指向首元素的指针。比如int arr[5] {1, 2, 3, 4, 5}; int *p arr; // 等价于 int *p arr[0];但数组名本身不能被赋值所以也常有人说是“常量指针”。问题不大关键是理解下面这个常见面试题printf(%p\n, arr); // 首元素地址 printf(%p\n, arr[0]); // 首元素地址和上面一样 printf(%p\n, arr); // 整个数组的地址但值上和首元素地址相同前两个确实一样。但arr类型是int (*)[5]也就是“指向整个数组的指针”和int *不是一回事。区别体现在指针运算上arr 1会跳到 arr[1]arr 1会直接跳过整个数组5个 int也就是往后挪20个字节。实际开发中arr 1用得少但考题和底层代码里偶尔见到知道这个“步长不同”就行。数组做函数参数时也会退化成指针。所以下面两种写法没有本质区别void print(int arr[]) { ... } void print(int *arr) { ... }在函数内部用sizeof(arr)只会得到指针大小而不是整个数组的大小。这个坑无数人踩过因为数组作为函数参数后已经失去了数组的“整体信息”。3.2 指针数组与数组指针一句话记住这俩名字长得像意思完全相反。“指针数组”本质是数组数组里每个元素是指针“数组指针”本质是指针这个指针指向一个数组。int *pArr[5]因为[]优先级比*高所以 pArr 先和[5]结合它是一个有5个元素的数组每个元素是int *。指针数组。int (*p)[5]先通过括号让*生效p 是一个指针它指向一个有5个 int 元素的数组。数组指针。记法我就用最土的方法“变量名先和谁结合它本质就是谁”。和 [] 结合本质是数组和 * 结合本质是指针。指针数组常见在字符串数组上。比如你要存几条字符串用二维数组可以用指针数组更省事const char *fruits[] {apple, banana, cherry}; // fruits 是一个数组三个元素都是 const char*分别指向三个字符串常量 for (int i 0; i 3; i) { printf(%s\n, fruits[i]); }数组指针什么时候用呢二维数组传参的时候形参可以写成int (*p)[4]意思是 p 指向一行这一行有4个 int。这也算高频用法。3.3 字符指针与字符串逆序输出实战C语言没有专门的字符串类型字符串是用字符数组存、以\0结尾的。字符指针保存的是字符串首字符的地址。这里有个大坑我必须单独拿出来讲字符数组和字符指针不是一回事。char str1[] hello; // 字符数组内容在栈上可以修改 char *str2 hello; // 字符指针指向字符串常量区内容不能修改如果你写str2[0] H在小端平台很可能段错误因为字符串常量存放在只读区。而str1[0] H是合法的。判断方法很简单sizeof(str1)得6包含\0sizeof(str2)得指针大小8或4。来一个实战例子字符串逆序。这个题目在各种题库里出现频率极高你在搜索“字符串逆序c语言pta”时就能看到一堆变体。用指针写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--; } }核心思路就是双指针一个从前往后走一个从后往前走交换它们指向的字符直到两个指针相遇。这里你能清晰看到指针运算、解引用、字符串结尾\0这些知识点全串起来了。注意right初始指向最后一个有效字符不是\0所以要减1。3.4 用指针实现冒泡排序冒泡排序本身不难但如果要求你用指针操作数组元素很多人就不太顺手了。我在这里演示一个用指针遍历、用指针交换的版本你可以拿它和普通下标版本对比void bubble_sort(int *arr, int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (*(arr j) *(arr j 1)) { int tmp *(arr j); *(arr j) *(arr j 1); *(arr j 1) tmp; } } } }在代码里*(arr j)就是你熟悉的arr[j]只是换了个写法。这么做有什么实际意义吗有。第一它逼你理解指针和数组的等价关系第二在函数内部你只拿到一个int *无法知道数组长度必须显式传入 n这让你对“数组退化”有更直观的体会。很多人把数组传进函数后想当然地sizeof(arr)/sizeof(arr[0])求长度得到的是错的就是因为没吃透这一点。这个函数我能稳定推荐给所有刚学完数组、想进阶指针的读者题目简单、逻辑清晰、能查错能验证非常适合作为第一道指针练习题。4. 指针的高阶用法函数指针、二级指针与动态内存4.1 函数指针把函数当成变量函数指针是很多教程放最后才讲的其实是重点。前面说的指针都是指向数据函数指针指向的是代码的入口地址。定义一个函数指针的写法int add(int a, int b) { return a b; } int (*fp)(int, int); // fp 是一个函数指针指向“参数是两个int、返回值是int”的函数 fp add; // 等价于 fp add; int result fp(3, 4); // 或者 (*fp)(3, 4)输出 7为什么需要函数指针最常见的理由是“回调函数”。你写一个排序函数想让它既能排升序又能排降序最优雅的办法是把比较函数作为参数传进去void sort(int *arr, int n, int (*compare)(int, int)) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (compare(arr[j], arr[j 1]) 0) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } } } } int asc(int a, int b) { return a - b; } int desc(int a, int b) { return b - a; } // 调用时传入不同比较函数就能改变排序方向 sort(arr, n, asc); sort(arr, n, desc);很多大的库比如 qsort、各种图形库都大量使用这种思路。你在C语言里看到的所谓“高级”其实没那么神秘本质上就是“函数也能被传来传去”。4.2 指针函数到底在返回什么和函数指针一字之差的是“指针函数”。它指的是“返回指针的函数”比如int *find(int *arr, int n, int target) { for (int i 0; i n; i) { if (arr[i] target) { return arr[i]; } } return NULL; // 这种写法很安全没找到就返回空指针 }返回数组内某个元素的地址这是很常见的场景。注意一个经典禁忌不要返回局部变量的地址。函数执行完局部变量就销毁了你返回一个指向已销毁空间的指针用起来就是悬空指针。如果确实要返回一个局部创建的数组用static修饰或者让调用者传入缓冲区工程上更推荐后者。这点在学习时容易犯迷糊我见过不少同学写int *get_num(void) { int a 100; return a; // 危险 }编译器一般会警告但很多环境默认不打开警告程序跑起来时运气好还能输出100运气不好就是垃圾值。这种问题没有万能解法只能靠规范意识来规避凡是你没有用 malloc 创建、没有用 static 保底的内容别想着把地址传出去。4.3 二级指针什么时候需要它二级指针就是“指向指针的指针”定义方式int a 10; int *p a; int **pp p;pp里存的是 p 的地址。用*pp拿到 p用**pp拿到 a。三个层次一环套一环。那什么时候非用不可一个典型场景在函数里修改指针本身。比如你要写一个初始化链表头结点的函数它需要把传入的指针更新为 NULL 或新分配的节点。如果只传一级指针函数里改的是形参外面一点变化没有就像前面 swap 失败那样。所以必须传二级指针void init_node(Node **head) { *head (Node*)malloc(sizeof(Node)); (*head)-next NULL; } int main() { Node *list NULL; init_node(list); // 传的是指针的地址 // 此时 list 已经被正确初始化 }链表、二叉树的插入和删除经常能看到这种二级指针的写法本质就是在函数内部“更新外部的指针变量”。理解了这一层二级指针其实不如想象中可怕。有人会觉得二级指针好绕我的建议是先画图。一个方框代表变量 a一个箭头代表指针 p 指向 a再画一个箭头代表 pp 指向 p。多画几次你会发现二级指针无非是“地址的地址”没比普通指针多什么玄机。4.4 动态内存分配malloc与free的黄金法则C语言里指针最有价值的应用之一就是配合动态内存分配。当你不知道数组要开多大时malloc可以按需申请int *arr (int*)malloc(sizeof(int) * n); if (arr NULL) { // 申请失败通常意味着内存不足 exit(1); } for (int i 0; i n; i) { arr[i] i * i; } free(arr); // 用完必须释放每一条 malloc 背后必须有一个 free就像借了钱必须还一样。忘了 free 就是内存泄漏程序跑得越久内存占用越大最后卡死。free 之后再使用该指针就是悬空指针所以要养成 free 后置 NULL 的习惯。再强调一个经典错误malloc 之后不检查返回值直接使用。万一内存不够返回 NULL 你解引用一个 NULL程序立刻崩。生产环境下的服务器代码malloc 判空是标配。还有realloc的时候很多人会踩坑char *p (char*)malloc(10); p (char*)realloc(p, 100); // 如果 realloc 失败p 就被置成 NULL原来的内存也丢了更稳的写法是先用临时变量保存 realloc 的返回值确认不是 NULL 之后再赋给原来的p。这种“先备份再替换”的思路在指针操作里普遍适用。5. 踩坑实录常见错误与排查技巧5.1 段错误是怎么产生的段错误Segmentation Fault可以说是C初学者最常遇到的运行时错误。它本质上就是你访问了不属于自己的内存。我把常见的触发原因列一下对空指针解引用比如*p 1但 p 是 NULL。访问已释放的内存比如 free 之后又用 p。数组越界比如定义了int a[3]却去访问a[3]或a[4]本质是访问了数组后面的内存。修改字符串常量比如char *p hello; p[0] H;。栈溢出递归没有终止条件栈空间被耗光。遇到段错误第一反应不是“我代码删了重写”而是先定位。Linux 下可以用 gdb 跑一下崩了之后输入bt查看调用栈它会指着具体哪一行这比肉眼找快太多。如果不会 gdb最简单的方式就是在关键位置加printf打点看到最后一个输出是哪个就知道大概崩在哪一段。这个土办法虽然不优雅但急用时是真有效。Windows 下用 Visual Studio 调试也一样程序崩了会给你中断看调用堆栈就能找到问题函数。5.2 怎么检验非法地址这几乎是每个C初学者都会问的问题“我手上有一个指针怎么知道它是不是合法的”很遗憾C语言本身没有提供一个绝对可靠的函数来“检测”任意指针是否可访问。原因很简单地址合法与否由操作系统决定C层面没有统一的断言接口。但实际开发里有几招可以用第一最常用也最有效的办法是从源头保证即指针初始化。你不可能检测一个“从未被正确赋值”的指针但你可以从一开始就避免产生这种指针。变量定义时初始化为 NULL用完释放后置 NULL。第二使用前判空。对可能为空的指针做防御if (p NULL) { // 打印错误日志返回错误码 }这个能拦截空指针拦不住悬空指针。第三用工具辅助。Linux 下 valgrind 可以检测非法读写、内存泄漏专门处理这类棘手问题。跑一下valgrind ./a.out它会清清楚楚告诉你哪一行用了未初始化内存、哪一行释放后又访问了。这是排查“看起来没有崩溃但结果不对”这类问题的利器。嵌入式环境下没有 valgrind就靠好的编程习惯加 code review。所以关于“怎么检验非法地址”我的答案其实是先通过规范让非法地址不存在再用工具辅助确认。5.3 编译警告里藏着的指针问题很多初学者有一个习惯编译的时候只关心有没有 errorwarning 全部忽略。但在指针这一块warning 往往是高级救命信号。比如你写了int a 10; char *p a;编译器大概率会警告你类型不兼容。这时候别急着关仔细看一眼int *和char *虽然都是指针但解引用宽度不同后面用*p只会读到一个字节的机会。如果程序结果和预想差很多回头检查是不是有类似的类型警告。现代编译器都开启了-Wall -Wextra这类选项强烈建议从学习阶段就加这些编译参数gcc -Wall -Wextra main.c -o main把编译器当成一位严格的老师它发的警告大多时候都在提醒你“这里有隐患”。代码写得越多你越能体会到让编译器帮你提前发现问题比自己 Debug 到半夜划算得多。5.4 常见问题速查表我把平时答疑遇到最多的、和指针相关的问题整理成一个表方便你遇到问题快速查找。症状可能原因排查方向编译警告“assignment from incompatible pointer type”指针类型不匹配检查等号左右类型必要时显式转换运行崩溃gdb 提示段错误空指针解引用、访问非法内存加空指针判断检查越界free 之后又崩了悬空指针free 后置 NULL确认没有二次释放函数里改了参数外面没变传值而不是传址需要传指针比如 swap(a,b)sizeof(arr) 求数组长度不对数组在函数中退化成指针长度作为参数传入或在使用它的作用域内求字符串赋值后修改字符串崩了指针指向字符串常量区改用字符数组char s[]数据错乱、输出奇怪的数字类型不兼容读了错误的字节数检查类型启用 -Wall 看警告程序运行越久内存越大内存泄漏检查 malloc/free 是否配对用 valgrind 检测这表不是说背下来就万事大吉而是希望你在出问题时能有个快速定位的抓手。C语言调试本质就是不断对比“预期”和“实际”通过工具和日志一步步缩小范围。我最后再分享一个自己带了这么多年新人的体会学指针最怕死背概念最管用的是拿着代码在纸上画内存图。一个格子一个格子地画一个箭头一个箭头地连画上十几道题地址、解引用、指针运算这些自然就懂了。你如果现在正处于“好像懂了一写就错”的状态别急所有人都是这么过来的多画图、多调错、多试错这一关过了后面的C语言之路会顺很多。
阅读完成 · 觉得有帮助?
咨询建站