尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

C语言指针与数组完全解析:从地址本质到动态内存实战

发布时间:2026/9/28 17:01:34

资讯中心
01
ARTICLE

C语言指针与数组完全解析:从地址本质到动态内存实战

C语言指针与数组完全解析:从地址本质到动态内存实战
指针与数组这俩在C语言里就像一对拆不开的冤家。数组名看着像个指针指针又能干数组干的活偏偏在某些细节上它们又泾渭分明。我带过不少实习生几乎每个都在这里栽过跟头——要么把数组名当成可修改的变量要么搞混指针数组和数组指针要么在函数参数里莫名丢了数组长度。今天这篇不打算念教科书就把指针和数组的关系掰开了揉碎了讲清楚你会搞明白a[i]和*(ai)为什么等价、二维数组在指针眼里到底是什么样子、malloc出来的动态数组要怎么玩。C语言初学者需要它准备面试的人也可以拿它当速查手册。1. 指针的本质不是魔法是地址1.1 从变量到地址先看内存长什么样计算机内存说白了就是一大串连续的格子每个格子有一个编号这个编号就是地址。变量名只是给人看的符号编译器在编译时会把它对应到某一段内存地址。比如int a 5;假设内存给a分配了地址0x7fff1234这个地址上放了5。指针变量干的事情就是把这个地址存下来。int a 5; int *p a; // p 存的是 a 的地址 printf(a 的值: %d\n, a); // 5 printf(a 的地址: %p\n, a); // 0x7fff1234 printf(p 的值: %p\n, p); // 0x7fff1234 printf(p 指向的值: %d\n, *p); // 5我用一个生活化类比帮新人理解内存地址就像快递柜的格子编号变量名是贴在外面的标签变量值是装在格子里的东西。是看一下这个格子编号是多少*是打开格子把里面的东西拿出来。1.2 指针变量的三个核心操作声明、取址、解引用指针的语法看着唬人核心操作其实就三件事。第一件是声明int *p表示p 是一个指向 int 类型变量的指针。星号在这里是类型的一部分不是取值操作。第二件是取址p a把变量a的地址存进p。第三件是解引用*p通过地址访问那个格子里的值。很多初学者卡在int *p, q;这种写法上——这里q是普通 int 变量不是指针。要声明两个指针得写int *p, *q;。这个细节我在代码评审里见多了写错的人经常会在后续使用中踩到类型不匹配的坑。建议新手尽量每个指针单独一行声明除了节省一行字符没有任何坏处。1.3 为什么要用指针三个绕不开的场景初学者会问变量用得好好的为什么要用指针答案很实际。第一个场景是函数要修改调用者的变量。C语言参数传递是值传递函数里改形参不影响实参用指针才能把函数外面的变量改掉。void swap(int *x, int *y) { int t *x; *x *y; *y t; }第二个场景是操作动态内存。malloc分配出来的内存在堆上没有变量名只能通过返回的地址来访问这个地址必须用指针接住。第三个场景是数组和结构体作为参数时传指针效率高不需要把整块数据复制一遍。在嵌入式、单片机这种资源紧张的环境里这点尤其重要——你传一个几十字节的结构体和一个传地址差别是实打实的。2. 数组名是首地址但别把它当成普通指针2.1 数组名与指针的等价关系a[i] 就是 *(ai)C语言里数组名在绝大多数表达式中会退化为指向首元素的指针。假设int arr[5] {10, 20, 30, 40, 50}; int *p arr; // arr 的值等价于 arr[0]这个时候arr[2]、*(arr2)、p[2]、*(p2)结果是完全一样的都是 30。原因在于 C 语言的下标运算a[i]在底层就是被翻译成*(ai)处理的。这个等价关系是整个数组与指针知识体系的基石理解了它后面所有东西都能串起来。甚至2[arr]也是合法的因为编译器看到2[arr]会解释成*(2arr)加法满足交换律结果自然一样。但这个写法可读性太差除了用来炫技和检验自己是否真懂下标本质我不建议在任何正式代码里这么干。2.2 数组名不是完整指针sizeof 和 的例外前面说绝大多数情况因为有两个例外。第一个是sizeof(arr)如果指针真的是数组名完整的替身这里应该得到指针大小64位机器上是8字节但实际上sizeof(arr)得到的是整个数组占用的字节数对int arr[5]来说就是 20 字节假设 int 为4字节。第二个是arr它的类型不是int *而是指向整个数组的指针int (*)[5]。arr和arr在数值上相等都是数组起始地址但类型不同arr 1跳到下一个元素arr 1却跳到整个数组末尾之后。这两个区别是面试高频点也是实际写代码时容易产生隐蔽 bug 的地方。2.3 函数参数退化的坑数组传进去只剩指针当数组作为函数参数时情况又不一样了。看这段代码void print_len(int a[]) { printf(%zu\n, sizeof(a)); } int main() { int arr[5] {1, 2, 3, 4, 5}; printf(%zu\n, sizeof(arr)); // 20整个数组的大小 print_len(arr); // 8指针的大小 return 0; }同一个arr在外面是数组传进函数后就变成了指针。C语言规定数组参数在声明时int a[]会被编译器改写成int *a。这么做是为了高效不用复制整个数组。但副作用是在函数内部你无法通过sizeof得知数组元素个数。解决办法有两个。要么额外传一个长度参数这是最常用的方式。要么用sizeof(arr)/sizeof(arr[0])在调用处算好长度再传进去。我在实际项目中见过不少新人写void process(int a[])然后心想我在函数里能拿到数组吗结果拿到的只是首元素的地址没有边界信息。记住这句话数组参数一旦进了函数就只是一个没有长度的指针。我自己的规矩是凡是函数要遍历数组必然带上长度参数哪怕暂时没用到也要保留否则后续维护的人一定会骂娘。3. 指针运算的细枝末节p1 真的加 1 吗3.1 指针加减法和类型的关系按元素移动不是按字节初学者最容易犯的错误是以为p 1会把地址加 1。实际上指针加 1 是按元素大小移动的而不是按字节。int arr[5] {10, 20, 30, 40, 50}; int *p arr; printf(%p\n, p); // 假设 0x100 printf(%p\n, p 1); // 0x104int 占4字节如果p是char *类型p1才会真的移动一个字节。这个规则的核心原理是指针类型决定了它指向的数据块多大编译器做加减法时会自动用步长乘上sizeof(*p)。这也是为什么强转指针类型要格外小心——你改变了步长遍历同一个内存区域的结果就完全变了。我在处理协议解析时经常要把收到的字节流按结构体去解析比如(struct header *)buf然后p 1就跳过整个结构体。新手如果不懂步长写(char *)p 1和(int *)p 1会得到完全不同的地址排查半天才发现是类型问题。3.2 指针相减得到的是元素个数不是地址差两个同类型的指针相减结果是两个地址之间的元素个数单位是元素不是字节。比如int arr[5]; int *p arr[0]; int *q arr[4]; ptrdiff_t n q - p; // 结果是 4不是 16这个特性在遍历数组时特别有用可以用来判断当前指针离数组末尾还有多远。实际编码中指针相减经常用于实现类似len end - begin的操作比如字符串处理函数、缓冲区管理。但要注意只有当两个指针指向同一个数组或同一块连续内存时相减才有意义。两个无关变量的地址相减结果是未定义行为别指望它能输出什么合理值。3.3 const 指针的四种写法到底谁不能改指针和const搭配是面试和日常维护中都很容易混淆的点。核心就一句const修饰谁谁就不能变。const int *p; // 指向的内容是 const不能通过 p 改值但 p 本身可以指向别处 int *const p; // 指针本身是 constp 不能指向别处但指向的内容可改 const int *const p; // 两者都不能改常见的误用是定义了一个const int *p想着意思是p 不变结果把p赋给另一个指针变量才发现编译报错。我在代码里最喜欢用的方式是如果函数不打算修改传入的数组元素参数声明成const int *a这样调用方可以放心传入const数组也防止自己手滑在函数里改了不该改的数据。在底层驱动开发里const还能把数据放到只读区省 RAM——这对单片机开发者来说是实实在在的收益。4. 二维数组的指针视角数组指针和指针数组4.1 二维数组的内存布局本质上是一块连续内存很多人在思维里把二维数组想象成网格但内存在物理上是一维的二维数组是线性排布的。比如int a[3][4]它占用的是 12 个连续 int 的空间按行优先排列先是第0行的4个然后第1行的4个最后第2行的4个。a[0]是第0行这个一维数组的名字在表达式中退化为指向该行第一个元素的指针类型是int *。而a本身退化的类型是int (*)[4]也就是指向含有4个int元素的数组的指针。这就是为什么你不能直接把a赋给int *p却又可以把a[0]赋给int *p。从指针视角看a 1跳过的是整整一行4个int而不是一个int这是二维数组指针最重要的特征。4.2 数组指针的声明与使用int (*p)[4] 怎么读先分清两个容易混的类型。int *p[4]; // 指针数组4个 int* 指针 int (*p)[4]; // 数组指针p 指向一个含4个int的数组记忆技巧是看p先和谁结合。*p[4]中[]优先级高于*所以p先是一个含4个元素的数组数组元素是int *。(*p)[4]中括号强制p先和*结合所以p先是指针指针指向的对象是int [4]数组。遍历二维数组时数组指针非常顺手int a[3][4] {{1,2,3,4},{5,6,7,8},{9,10,11,12}}; int (*row)[4] a; for (int i 0; i 3; i) { for (int j 0; j 4; j) { printf(%d , row[i][j]); } printf(\n); }这里row[i]就是第 i 行的一维数组名row[i][j]等价于*(*(rowi)j)。在实际项目中经常用int (*p)[N]去操作固定列数的二维缓冲区比如图像帧的像素矩阵。但要注意如果列数N是变量就不能用数组指针得用int **动态分配的方式否则编译器会在类型上直接把你拦下。4.3 指针数组存储字符串const char *names[] 的魅力C语言对字符串的处理方式很特殊。字符串一般用字符数组存储也可以用字符指针指向字符串常量。而一组字符串最常见的表示方式就是用指针数组const char *names[] { zhang, li, wang }; for (int i 0; i 3; i) { printf(%s\n, names[i]); }names数组里的每个元素都是const char *分别指向不同的字符串常量。这样做的好处是不需要提前声明一个固定大小的二维字符数组也不会因为某个字符串特别长而浪费空间。命令行参数int main(int argc, char *argv[])本质上也是这么回事argv就是一个字符指针数组每个元素指向一个命令行参数。这里有一个容易踩的坑如果用char *p hello然后尝试p[0] H程序会崩溃或被拦下因为字符串常量放在只读区。而char a[] hello是一个可修改的字符数组a[0] H合法。判断方法很简单数组形式是拷贝了一份可变的副本指针形式是指向了原对象。我建议在嵌入式固件和普通应用里只要不需要修改字符串内容const char *是最好的默认选择既能防止误写也能在部分平台上把只读字符串放到累加器里节省 RAM。5. 动态数组堆上开辟的可变长数组5.1 malloc/free 的正确用法分配、判空、释放C语言标准不支持可变长数组C99 之后有了 VLA但不少项目出于移植性考虑不启用做动态数组得靠堆内存。核心函数是mallocint *p (int *)malloc(n * sizeof(int)); if (p NULL) { // 分配失败n * sizeof(int) 可能很大 return -1; } for (int i 0; i n; i) { p[i] i; } free(p);三个关键点。第一malloc参数是字节数不是元素个数一定要乘sizeof(int)。写错成malloc(n)的惨案太多了。第二返回值要判空。尤其在单片机或嵌入式 Linux 上堆可能很小分配失败是常态。第三用完必须free并且free之后建议把指针置为NULL防止后面误用一个已经被释放的地址产生悬垂指针。我在做嵌入式项目时有一个血泪教训一个通信模块里动态分配了缓冲区释放时忘了置空指针后来另一个地方用到了同一个指针结果读写了一段已归还给系统的内存问题时好时坏查了两天才定位到。从那以后我定了一条规矩每次free(p)后紧跟p NULL。养成肌肉记忆能少跑断腿。5.2 动态二维数组的几种实现方式当二维数组的行数和列数都是运行时变量时最灵活的方式是用int **int **a (int **)malloc(rows * sizeof(int *)); for (int i 0; i rows; i) { a[i] (int *)malloc(cols * sizeof(int)); } // 使用 a[i][j] for (int i 0; i rows; i) { free(a[i]); } free(a);这种实现每个a[i]指向一块独立的内存行与行之间不连续。如果要求内存连续比如要整体memset或一次性拷贝可以分配一大块连续空间自己手动计算索引int *a (int *)malloc(rows * cols * sizeof(int)); #define AT(i, j) a[(i) * cols (j)]另外如果列数固定且已知还可以用int (*p)[N] malloc(rows * sizeof(*p))但这种写法要求 N 是编译期常量。我一般的建议是列数固定的小矩阵用数组指针列数可变或行数很大用int **需要连续内存且操作频繁用一维模拟二维。5.3 realloc 扩容空间不够怎么办动态数组用得多了天然会遇到容量不够要扩容的需求。realloc可以在保留原数据的前提下扩大或缩小内存块int n 10; int *p (int *)malloc(n * sizeof(int)); int new_n 20; int *tmp (int *)realloc(p, new_n * sizeof(int)); if (tmp NULL) { // 扩容失败原内存仍然有效 } else { p tmp; tmp NULL; }这里最重要的安全写法是不要直接把realloc的结果赋值给原指针。因为如果realloc失败它会返回 NULL但原来的内存块不会被释放如果你直接p realloc(p, ...)等于把原地址弄丢了既不能使用旧数据也无法再释放它直接导致内存泄漏。先存到临时变量里判断成功后再赋值这是必须养成的好习惯。6. 指针相关经典错误与调试实战6.1 三类最常见的段错误空指针、野指针、越界新手写指针最常碰到的就是段错误Segmentation Fault其实用手指头想想最常见的就是三类问题。第一类是空指针解引用。int *p NULL; *p 10;属于对地址 0 写入操作系统直接拒绝程序立即崩溃。第二类是野指针即指针变量本身未初始化里面存着一个随机的地址解引用就是访问未知内存轻则数据错乱重则崩溃。还有一个同类问题是悬垂指针指向的内存已经被 free 了你再读写那块地方可能已经被别人占用后果非常不可预测。第三类是越界访问。我见过有人写int a[5]; for (i 0; i 5; i) a[i] i;自以为没事越界那一下写进的就是相邻的内存区。如果你的数组旁边碰巧是一个函数的返回地址那程序的表现可能像鬼打墙一样时好时坏。排查这类问题的通用思路是先崩溃再倒推。看核心转储文件在崩溃调用栈里找第几行出的问题然后去看那个地方的指针有没有可能为空、越界、或已被释放。6.2 用 gdb 快速定位指针问题真到实战排查gdb是最可靠的帮手。编译时加-g -O0关闭优化保留调试信息然后跑gdb ./a.out (gdb) run (gdb) btbt能打印从入口到崩溃点的调用栈哪一行出的问题一目了然。如果问题不是崩溃而是结果不对可以在可疑的指针处下断点然后打印指针的地址和内容(gdb) p p (gdb) p *p (gdb) p arr[0]5 # 打印从 arr[0] 开始的5个元素x命令还能按格式看内存内容比如x/8wx p表示以十六进制方式查看从 p 开始的8个字。这套基本功我用了十几年比任何高级调试器的效率都高。在单片机环境比如 Keil MDK里对应的是 Watch 窗口和 Call Stack用法思路完全一样先看崩溃栈再看关键变量地址是否在合法范围。6.3 面试常问的指针数组辨析题一题搞懂是否学通最后放一道测试题下面的代码输出是什么int arr[] {10, 20, 30, 40, 50}; int *p arr; printf(%lu\n, sizeof(arr)); // 1 printf(%lu\n, sizeof(p)); // 2 printf(%d\n, *p); // 3 printf(%d\n, *(p 1)); // 4 printf(%d\n, p[0]); // 5答案是2020不准确应该是20字节实际是5*420取决于平台、864位指针、10解引用后 p 自增指向20、30p此时指向20p1指向30、20。做完这道题能检验sizeof、指针自增的优先级、数组名退化、指针下标的掌握程度。*p这里有个优先级问题优先级高于*但p是先用后加所以实际上等价于*(p)先拿*p的值10再让 p 指向下一个元素。这个运算符组合我在解析协议帧、遍历缓冲区的代码里写得太多了但可读性确实差正式项目里我建议拆成两行。我个人的体会是指针和数组的知识光看不练是永远记不住的。最好的学习路径是先理解地址和/*的本质再去手动推演a[i]与*(ai)的等价过程接着写几个小实验程序验证二维数组的地址规律最后硬着头皮用动态数组实现一个简易的常见数据结构。走完这一整轮C语言积累起步才算真正完成了之后再看任何源码里的指针用法你都只会觉得顺理成章而不是头皮发麻。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。