ARTICLE DETAIL

资讯详情

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

C语言指针数组详解:定义、内存布局与三大应用场景

C语言指针数组详解:定义、内存布局与三大应用场景 1. 指针数组到底是啥先搞清楚定义和内存布局C语言学到指针这一块很多人都有这种感觉知识点一个接一个每个单独拿出来都懂一旦组合起来就懵。指针、数组本来就绕这俩还能组合成一个“指针数组”光听名字就知道不是善茬。但说实话指针数组反而是指针系列里最好理解的一个概念因为它的本质就是四个字数组里存指针。先看定义。指针数组顾名思义它是一个数组只不过数组中每个元素存的是指针变量而不是普通整数或者浮点数。假设现在有一个数组类型是int *arr[5]读法是这样arr先和[]结合说明arr是一个数组数组里有 5 个元素每个元素的类型是int *也就是 int 类型指针。所以arr就是“一个存储了 5 个 int 指针的数组”这个整体就叫做指针数组。这里最容易搞混的对手是“数组指针”。int (*p)[5]才是数组指针p是一个指针指向一个包含 5 个 int 元素的数组。有人总结过记忆方法看谁先和变量名结合。*p[3]里p先和[]结合所以是数组(*p)[3]里p先和*结合所以是指针。这个方法虽然粗糙但对初学者确实管用先记住优先级再慢慢消化。指针数组的内存布局也很有意思。定义一个char *strArr[4]这意味着在栈上连续分配 4 个指针变量每个指针变量本身占 8 个字节64 位系统总共占 32 个字节。这四个指针变量可以分别指向不同的内存地址比如字符串常量区、堆区、栈上的数组地址甚至指向同一个地址完全由你控制。画成图就是这样的感觉strArr[0] - hello strArr[1] - world strArr[2] - c language strArr[3] - pointerstrArr是一个连续的指针数组每个元素存的是指向字符串首字符的地址。所以这里要特别注意strArr本身不存储任何字符串内容它只存储地址。字符串真正的字面量在常量区指针数组只是把它们的位置登记了一遍。我见过很多初学者在理解这里时卡住原因只有一个把指针数组和二维数组在概念上混淆了。二维数组char names[4][20]是申请了 4 行、每行 20 个字符的空间总共 80 个字节每一行存放的是字符数据本身。而指针数组char *names[4]只有 32 个字节4 个指针的占用它存放的是地址。一个是“把字符直接摆在这里”另一个是“告诉你字符在哪里”这两个东西各自解决不同的问题下面展开讲。2. 为什么需要指针数组三个典型场景的价值2.1 管理一组字符串省内存是关键如果有多个字符串要统一管理最容易想到的方案是二维数组。比如要存 5 个名字最直观的写法是char names[5][20]。这个方案有一个致命问题每行的长度被固定为 20如果某个名字只有 3 个字符剩下 17 个字节就全部浪费了。如果名字很多、长度差异很大浪费的内存就相当可观。指针数组的做法完全不同char *names[5]只存 5 个指针真正的字符串各自按实际长度存放在内存中。比如Tom占 4 个字节Alexander占 11 个字节毫不浪费。在单片机、嵌入式这种内存以 KB 计算的环境中这种节省可能直接决定程序能不能跑起来。我自己做过一个 STM32 上的菜单系统菜单项名称有长有短用二维数组直接爆了 2KB 内存换成指针数组后只用了不到 300 字节。差异非常直观内存紧张时这个选择就是刚需。2.2 字符串排序交换开销可以忽略不计如果要对一组字符串排序用二维数组实现时交换两个字符串得用strcpy把整串内容拷贝一遍字符串越长、交换次数越多耗时就越明显。用指针数组排序只需要交换数组里存的两个指针值操作是固定的小动作跟字符串长度完全无关。这个思路本质上是“排序指针而非排序数据”。数据本身待在原地不动你只需要把指针的指向关系调整一下逻辑上的顺序就变了。在 C 语言里这种思路相当实用因为 C 没有现成的字符串类型字符串操作本身就慢能少拷贝就少拷贝。2.3 处理多个同类型函数的入口地址指针数组的第三个典型应用是把一组函数指针存进数组做成一个“函数表”。每个数组元素对应一个函数的入口地址调用时通过下标直接选取要执行的函数。这种写法在处理命令分发、状态机、菜单动作时极其常用。这几个场景如果你真用熟了指针数组的掌握程度就是“会了”而不只是“看懂了”。接下来我用具体的代码把这些场景一一实现。3. 核心实操指针数组的声明、初始化和三种典型应用3.1 声明和初始化注意区分几种写法先看最基本的声明和初始化方式#include stdio.h int main(void) { // 方式一直接用字符串常量初始化 char *strArr[3] {hello, world, c}; // 方式二先声明后逐个赋值 char *strArr2[3]; strArr2[0] hello; strArr2[1] world; strArr2[2] c; // 方式三使用复合字面量C99 支持 int *numArr[3] {(int[]){1, 2, 3}, (int[]){4, 5}, (int[]){6}}; for (int i 0; i 3; i) { printf(%s\n, strArr[i]); } return 0; }方式一最常用直接用一个花括号把多个字符串塞进去。这里有一个很多人忽略的细节字符串常量的本质是char[]类型但通常会被编译器放在只读常量区所以用char *strArr[]存放、只读访问是没有问题的。但如果后续要修改字符串内容就不能用字符串常量初始化得改成char strArr[][20]或者动态分配内存。方式三用到了复合字面量这个特性在 C99 里才有。它解决的场景是数组中每个“字符串”实际上不是字符串而是一组 int 数组长度还不一样。比如不同长度的坐标点数组、不同维度的向量用这种写法可以非常灵活地管理。再看一个容易犯的错char *arr[2]; arr[0] abc; // 正确arr[0] 是指针指向字符串常量 arr[1] def; // 正确如果你写成arr[0][0] x想通过指针去修改字符串常量就会出错——因为字符串常量本质上是只读的。很多新手在 Windows 上可能没报错但换到 Linux 或单片机环境直接段错误这就是原因。3.2 核心应用一用指针数组管理多个字符串这个例子最实用我强烈建议手动敲一遍并跑起来。需求有 5 个城市名要求按照字母序从小到大排序并输出。#include stdio.h #include string.h void sort_strings(char *arr[], int n) { for (int i 0; i n - 1; i) { for (int j i 1; j n; j) { if (strcmp(arr[i], arr[j]) 0) { char *tmp arr[i]; arr[i] arr[j]; arr[j] tmp; } } } } int main(void) { char *cities[] {Wuhan, Beijing, Shanghai, Guangzhou, Shenzhen}; int n sizeof(cities) / sizeof(cities[0]); sort_strings(cities, n); for (int i 0; i n; i) { printf(%s\n, cities[i]); } return 0; }输出结果Beijing Guangzhou Shenzhen Shanghai Wuhan重点看sort_strings里的交换操作char *tmp arr[i]; arr[i] arr[j]; arr[j] tmp;这段代码就是 swap 两个指针。对比交换两个二维数组行的做法二维数组得用strcpy 临时字符数组循环几十上百次交换时性能差距非常明显。我在一个嵌入式项目里处理过 100 多个菜单名称的排序用指针数组后整体耗时几乎可以忽略。这里有一个值得琢磨的点为什么strcmp(arr[i], arr[j]) 0就交换strcmp返回的是两个字符串逐字符比较的差值如果第一个比第二个大字母序靠后就返回正数交换后小的在前最终从小到大。如果想做降序把条件改成 0即可。3.3 核心应用二模拟命令行参数 argc 和 argv很多人学习 C 语言时都被main(int argc, char *argv[])困扰过。这个argv名字看起来是“参数值数组”长得也不像指针数组但它就是一个标准版的指针数组。写一个简单的命令行解析程序#include stdio.h int main(int argc, char *argv[]) { printf(参数个数: %d\n, argc); for (int i 0; i argc; i) { printf(参数 %d: %s\n, i, argv[i]); } return 0; }在命令行输入./program -h -o output.txt input.txt输出参数个数: 5 参数 0: ./program 参数 1: -h 参数 2: -o 参数 3: output.txt 参数 4: input.txtargv[0]是程序名本身后面的元素才是真正传给程序的参数。这个设计的本质就是操作系统把命令行拆成一个个字符串把它们的首地址依次存进一个char *数组然后传给main。如果不用argv想实现一个“根据命令行选项执行不同分支”的程序几乎是灾难。有了指针数组后你可以遍历argv用选项字符串逐个匹配匹配上就执行对应逻辑写起来就是几十行的事。我曾经写过一个给串口工具用的命令行参数解析器就是把argv里的参数扫描一遍遇到-b就取下一个参数当作波特率遇到-p就取下一个当作串口号。整个过程就是在操作一个char *数组。3.4 核心应用三函数指针数组实现命令分发这个应用是很多人指针进阶路上的“最后一关”也最能体现指针数组的真正威力。把函数指针和数组这两个概念合到一起得到的结构叫“函数指针数组”。它的语法长这样int (*func_ptr_arr[])(int, int) {add, sub, mul, div};读法func_ptr_arr是一个数组数组里的每个元素是一个函数指针这些指针指向的函数的签名是int (int, 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 mul(int a, int b) { return a * b; } int divide(int a, int b) { return a / b; } int main(void) { // 函数指针数组 int (*operations[])(int, int) {add, sub, mul, divide}; int a 20, b 4; // 0: add, 1: sub, 2: mul, 3: div for (int i 0; i 4; i) { printf(operation %d result: %d\n, i, operations[i](a, b)); } return 0; }输出operation 0 result: 24 operation 1 result: 16 operation 2 result: 80 operation 3 result: 5这个写法有什么好处在没有函数指针数组的时候要实现一个四则运算计算器最朴素的办法是switch (op)分四个分支每个分支写一行调用。代码量看起来差不多但如果操作增加到几十个分支switch就会变成一长串又臭又长的代码。而函数指针数组只需要在数组初始化时把你的操作函数登记进去调用时直接按下标选。再高级一点的用法是做一张“命令表”数组每个元素不仅存函数指针还存一个命令字符串和一个帮助说明比如这样typedef struct { const char *cmd; void (*handler)(void); } CommandEntry; CommandEntry cmd_table[] { {help, show_help}, {info, show_info}, {reset, do_reset}, {clear, do_clear}, };这不是严格意义上的指针数组但底层逻辑完全一致用一个表格把命令名称和执行函数关联起来。遍历表格、用strcmp匹配命令匹配成功就调用对应的handler。我在写嵌入式终端调试组件时就这么干的用户输入命令字符串程序去命令表里匹配找到就执行、找不到就报错。加一个命令只需要在表格里加一行核心代码完全不用动。这种扩展性switch是比不了的。注意函数指针数组的元素个数在编译期就固定了如果你要在运行时动态增删命令就不能用数组得用链表或者动态数组。在设计程序时先想清楚场景再选结构。4. 常见坑和排查技巧这些坑我都替你踩过4.1 二维数组和指针数组怎么选很多场景下二维数组和指针数组都能实现“存储多个字符串”但两者的差别很大。列个表对比一下对比项二维数组char a[5][20]指针数组char *a[5]存储内容字符数据本身字符数据的地址内存占用固定 100 字节5 个指针32/64位下 20/40 字节 字符串本体字符串长度每行最多 19 字符超出截断不受数组限制修改字符串内容可直接改a[i][j]指针指向常量区时不可修改需要可写内存时改用动态内存排序开销需要strcpy交换整串交换指针即可成本极低适用场景内存足够、字符串定长、需要就地修改字符串长度不固定、需要频繁排序、内存紧张选错结构的后果很直观字符串长度不固定时用了二维数组内存浪费严重还可能因为strcpy越界导致缓冲区溢出定长字符串用了指针数组反而多了一堆指针变量还要小心管理内存生命周期。4.2 为什么用%s打印指针数组元素时总出乱码这个问题的原因通常是指针数组里的某个元素不是“指向字符串的指针”而是一个没有初始化的野指针。char *strArr[3]; strArr[0] ok; // strArr[1] 和 strArr[2] 没有初始化是野指针 printf(%s\n, strArr[1]); // 未定义行为运气好输出乱码运气差直接崩溃解决方法是声明时立刻初始化或者用 {0}把所有元素置为 NULL打印之前先判断是否为 NULLchar *strArr[3] {0}; for (int i 0; i 3; i) { if (strArr[i] ! NULL) { printf(%s\n, strArr[i]); } }在嵌入式开发中野指针问题尤其致命。我在用 Keil MDK 调试 STM32 程序时曾经因为模块初始化的某个结构体数组没有清零导致指针数组里混入了非法的地址一访问就进 HardFault。后来排查了半天其实就是初始化时漏了个memset。4.3sizeof套在指针数组上得到的是什么char *arr[5]; printf(%zu\n, sizeof(arr)); // 4064位系统5 * 8 printf(%zu\n, sizeof(arr[0])); // 8一个指针的大小如果拿sizeof(arr) / sizeof(arr[0])算元素个数指针数组是没问题的因为它是真正的数组sizeof能拿到完整大小。这个技巧在函数内部用就不行了——参数传入后arr已经退化为指针sizeof(arr)得到的是指针大小计算出来的“元素个数”就错了。为了避免这个坑我通常的做法是要么在函数里同时传入数组长度要么在外部算好长度再传进去。这个规则对指针数组和普通数组同样适用。4.4 指针数组里存放动态分配的内存怎么释放如果指针数组里面的字符串是malloc动态分配的那么释放的时候也必须逐个释放char *names[3]; names[0] (char *)malloc(32); names[1] (char *)malloc(32); names[2] (char *)malloc(32); // 用完以后 for (int i 0; i 3; i) { free(names[i]); }注意free(names)是错的因为你只释放了数组本身如果它是动态分配的数组里元素指向的内存还挂着就会内存泄漏。正确顺序是先释放数组元素指向的内存再释放数组本身。这个顺序反了就出大问题轻则泄漏重则重复释放导致崩溃。再说一个真实场景。我做过一个设备信息采集程序需要动态读取多组传感器数据并保存。每组数据单独指定不同长度然后让一个指针数组把这些数据块的地址都收过来。处理完了先free每个数据块最后再free指针数组本身。只有这样内存才不会越用越少。4.5 函数指针数组的声明读不懂int (*operations[])(int, int)这种声明初次看确实劝退。我提供一个拆解思路从变量名开始按照优先级来。int (*operations[])(int, int);operations先和[]结合所以它是数组。数组元素是int (*)(int, int)也就是“一个指向返回值为 int、参数为 (int, int) 的函数的指针”。这样一拆整行声明就通了。可读性更好的做法是加一个 typedeftypedef int (*Operation)(int, int); Operation operations[] {add, sub, mul, divide};这样声明就变成了一个“操作数组”理解成本低很多。在实际项目中我建议遇到复杂的函数指针时都用 typedef 包一层代码给别人维护时不至于被骂。5. 指针数组在嵌入式环境和复杂项目中的实战经验热词里有大量关于单片机、STM32、Keil 的内容多说一点这块的经验。在嵌入式 C 开发中指针数组最常见的用法是把外设名称和设备操作绑定。比如你要写一个“用按键切换屏幕菜单”的功能不同的菜单项触发不同的显示函数最直白的写法就是用函数指针数组把各个页面的显示函数登记起来void display_main_menu(void); void display_settings(void); void display_about(void); void (*screen_pages[])(void) { display_main_menu, display_settings, display_about, }; int current_page 0; void next_page(void) { current_page (current_page 1) % 3; screen_pages[current_page](); }翻页操作只需要改current_page然后调用对应的函数指针界面代码瞬间干净很多。搜热词时看到有朋友问“单片机 C 语言没有堆栈吗”这里顺便说一句单片机当然有堆栈但规模很小很多 MCU 的栈只有几 KB指针数组本身占不了多少栈空间但数组里指针指向的动态分配内存就要看清楚分配在哪里了。如果频繁malloc又忘记释放很快就把堆耗尽程序表现得就像“没有内存一样”。另外在使用 Keil MDK 调试时如果你在 Watch 窗口看不到指针数组的内容可以在 Watch 里展开数组元素并查看地址用内存观察窗口去看指针真正指向的内存区域这样定位问题会快很多。指针数组里存的是地址调试时多观察地址指向的数据而不是只盯着数组本身看经验多了以后会形成习惯。还有一个非常实用的点在状态机设计里指针数组天然适合做“状态表”。每种状态对应一个处理函数状态机每次循环就根据当前状态的下标从处理函数表里调用对应的函数。这样状态转移逻辑和状态处理函数完全分离加状态只要在枚举和函数表里各加一项非常干净。6. 指针数组的最终理解一次讲透“多级指针”和“数组参数”指针数组学完之后其实可以把视角再拉高一点。char *strArr[3]传入函数时它会退化为一个指向“char 指针”的指针也就是char **。这也是为什么很多人看到char **就头疼其实它的来源可能就是指针数组。看这个例子void print_all(char **arr, int n) { for (int i 0; i n; i) { printf(%s\n, arr[i]); } } int main(void) { char *cmd[] {ls, cat, grep}; print_all(cmd, 3); return 0; }cmd传入print_all时隐式转换成char **。所以char **绝不是“只用来做双重指针搞晕别人”的语法玩具它完全可以看作“指针数组的头指针”。一旦你理解了这种转化关系以后看到char **就不会再慌。再往上char **argv和char *argv[]在函数参数中是等价的写法。区别只在声明的一部分上下文里函数形参里这两种写法完全一样。理解了这一点操作系统给main传参的机制、命令行解析的原理也就一并通了。到这里指针数组的内容基本讲完了。它核心就一句话数组里存的是各个数据的地址而不是数据本身。它和普通数组的区别仅仅是元素类型不同但因为这个“元素类型”是指针操作时就多了一层间接性需要你随时问自己“我这个下标得到的是地址还是地址指向的值”。我做了这么多年 C 语言开发和嵌入式项目指针数组可以说是 C 语言里最值得花时间吃透的结构之一。从管理字符串到做命令表再到状态机到处都有它的影子。如果你能把这一节的内容真正消化再看项目源码里那些“花里胡哨”的指针用法会发现大部分都逃不过这几种模式。最后再分享一个建议学习指针数组一定不要只看不写。把上面的代码全部手敲一遍在调试器里观察每个数组元素的值和地址再故意写错几个地方观察报错和运行结果。踩过这些真实的坑才算真正入坑成功。
返回列表