ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C语言指针精讲:从内存地址到数组、字符串与程序段分析

C语言指针精讲:从内存地址到数组、字符串与程序段分析 写这篇的时候我正在给一位零基础的朋友补C语言第八次课刚把函数和数组过了一遍按原计划第九节应该直接进递归和排序结果课前做评测题时发现他卡在一个根子上为什么数组传进函数里改了会生效普通变量却不会这个问题背后的答案就是“地址”也就是指针。所以第九天我临时改了节奏把指针、字符串、指针与数组的关系、程序段输出分析全部串在一起讲透顺手还把循环里最容易混淆的while和do-while做了对比。这篇就是day9的实际授课记录适合正在学C语言的学生、准备计算机二级的考生以及想回头补内存基础的程序员。内容偏实操建议打开编译器看到代码就跟着敲一遍。1. day9 学习路线今天要啃下哪块硬骨头1.1 前面八天做了什么为什么第九天要变节奏前八天的内容大概是day1-2 基础语法和输入输出day3-4 分支和循环day5 一维数组与字符数组day6 函数day7 综合练习day8 递归入门。这些内容像是搭积木但有个致命问题——大部分练习都在“照着语法写”并没有回答“这行代码在内存里到底做了什么”。数组中传递为什么退化成地址scanf为什么要传函数参数为什么改不动外面的值所有问题都指向同一个核心模型内存、地址、间接访问。所以我决定第九天停下来不再赶进度先把指针讲透。前面是学“怎么写代码”今天开始学“代码在机器里怎么跑”。从后续学习看链表、文件操作、嵌入式寄存器访问、socket编程全部建立在这一层理解上。这个节奏调整虽然慢了一天但换来的是一劳永逸的内存观。1.2 day9 的内容清单与学习目标为了避免“听懂了做不出题”的情况我给朋友列了一张简单的目标表每完成一项就在后面打个勾学习模块核心要解决的问题练习产出指针的声明与解引用和*到底干了什么能分析一段含指针的printf输出指针与数组的关系为什么a[i]等价于*(ai)能用指针逆序遍历数组数组名与指针的差异arr和arr的区别能讲清楚“数组名是常量地址”字符串的本质char s[]与char *s为什么不一样能手写strlen程序段输出分析运算符优先级与自增自减的副作用能预测三个以上 printf 结果while与do-while两者执行时机的差异能写一个至少执行一次的菜单循环我的要求是今天结束后看到任何指针相关代码能用“地址”、“指向”、“解引用”这三个词把过程完整讲出来而不是猜输出。这才算真正过关。1.3 为什么说指针是 C 语言的分水岭这个说法听起来老套但真实不虚。指针不是一个新的语法点而是一种思维方式的切换从“变量存值”到“变量有地址”从“直接访问”到“通过地址间接访问”。Java、Python 这类语言大多把指针细节藏起来C 语言却强制你面对。好处是一旦过了这道坎后面学链表、二叉树、文件、内存管理都会顺因为底子就是同一套坏处是过不去的人会觉得 C 语言又难又烦弃坑的也不在少数。day9 的定位不是让指针“玄学化”而是让它“日常化”。指针就是一个存地址的变量取地址、存地址、按地址取内容三步走。这节课能不能立住就看这个模型是否真的建立了。2. 指针把地址变成可操作的“快递单号”2.1 从变量到地址内存里的“门牌号”怎么拿到要理解指针先理解变量在内存里的存在方式。定义一个int value 42;编译器会在内存某个位置给它分配一块空间这块空间的地址就是“门牌号”。用取地址符可以拿到这个门牌号比如printf(%p, (void*)value);会输出一串十六进制地址。而指针变量就是用来存这种门牌号的变量。我常用的类比是快递。value 42相当于商品已经放在仓库货架上货架编号就是地址int *p value;相当于把货架编号抄在一张快递单上*p就是根据快递单去仓库把货取出来。这个类比能解释几乎所有指针操作p是单号*p是货如果你通过*p 20;把货换掉原地址上的内容也就变成了20因为p指向的就是value那块内存。2.2 指针声明与解引用*的多重身份造成的坑初学阶段最容易混的就是*的三种身份。声明里int *p;表示“p 是一个指向 int 的指针”这时候*是类型声明的一部分表达式里*p是解引用表示“取 p 指向内存单元的值”乘法里a * b就不用说了。建议初学者把int *p;读成“*p的类型是 int”这样反而更直观p 指向一个 int解引用之后是 int。还有一个经典坑int* p, q;这行代码里只有p是int *q是普通int。因为*只修饰紧挨着的变量名 p。如果想让两个变量都是指针要写成int *p, *q;。这个细节几乎每年都有新手踩笔试题也常拿它做文章。下面是最基础的指针使用#include stdio.h int main(void) { int a 10; int *p a; printf(a 的地址: %p\n, (void*)a); printf(p 的值: %p\n, (void*)p); printf(*p 的值: %d\n, *p); *p 20; printf(修改后 a 的值: %d\n, a); return 0; }运行后可以看到a和p输出的地址完全一样*p就是a的值。这里要特别提醒指针变量一定要先赋一个有效地址再解引用。如果int *p;就直接*p 20;p 里面是一个随机值程序很可能会崩溃这就是俗称的野指针问题。2.3 指针的算术运算p1 到底移动了几个字节指针可以参与加减运算但语义和普通整数完全不同。p 1并不是把地址数值加1而是移动“一个元素”的距离。具体移动多少个字节取决于指针指向的类型指针类型p 1移动的字节数以常见32/64位平台为例char *1 字节int *4 字节double *8 字节指向某个结构体的指针sizeof(该结构体)字节这个设计是为了让指针运算直接服务数组。看这个例子#include stdio.h int main(void) { int arr[5] {10, 20, 30, 40, 50}; int *p arr; printf(%d\n, *p); // 10 printf(%d\n, *(p 2)); // 30 printf(%d\n, p[2]); // 30下标本质就是指针偏移 return 0; }p 2指向数组中第二个元素也就是下标 2 的位置。这里就引出了“数组名就是首元素地址”这个关键概念arr在没有被sizeof或包裹时会隐式转换成指向首元素的指针。也是从这一刻开始数组和指针深深绑定在一起。3. 程序段输出分析先猜后跑练出“人肉编译器”3.1 两个迷惑性很强的程序段南开也可能是PTA上经常有这样的题给出程序段让你记录输出结果并分析原因。这类题不是为了刁难人而是为了考察运算符优先级、自增副作用的真正理解。我准备了两个典型片段测试前要求朋友必须先在心里跑一遍写出答案再上机验证不允许直接编译。片段A#include stdio.h int main(void) { int arr[4] {1, 2, 3, 4}; int *p arr; printf(%d\n, *p); // 输出 printf(%d\n, (*p)); // 输出 printf(%d\n, *p); // 输出 printf(%d\n, *p); // 输出 printf(%d\n, (*p)); // 输出 return 0; }正确答案是 1、2、3、3、4。先别急着往下看自己在心里算一遍。逐条拆解*p根据运算符优先级和结合性等价于*(p)。后缀自增的求值顺序是“先返回旧值再自增”所以第一步先取出当前p指向的arr[0]也就是 1然后p后移指向arr[1]。输出 1。(*p)括号强制先解引用此时p指向arr[1]取出 2 作为后缀自增的结果输出然后把arr[1]自增为 3。输出 2。*p此时p还在arr[1]但arr[1]已经被上一步改成了 3。输出 3。*p等价于*(p)先让p移动到arr[2]再解引用得到 3。输出 3。(*p)解引用arr[2]当前是3前缀自增后结果是 4同时arr[2]也变成 4。输出 4。我把每步之后的状态整理成表方便复盘语句输出执行后p指向数组 arr 的变化*p1arr[1]无变化(*p)2arr[1]arr[1]: 2 - 3*p3arr[1]无变化*p3arr[2]无变化(*p)4arr[2]arr[2]: 3 - 4片段B#include stdio.h int main(void) { char s1[] hello; char *s2 hello; printf(%c\n, s1[0]); // h s1[0] H; printf(%s\n, s1); // Hello // s2[0] H; // 取消注释会怎样 printf(%s\n, s2); // hello return 0; }s1是一个字符数组编译器会为它分配 6 字节空间把h,e,l,l,o,\0复制进去所以可以修改。而s2是指向字符串字面量的指针字符串字面量一般存在只读数据段修改它属于未定义行为。不同平台表现不一样有的直接崩溃有的“碰巧”还能跑但绝不能依赖这种碰巧。这正是指针阶段必须建立的直觉能改还是不能改取决于内存的读写权限而不是语法上像不像。3.2 优先级、副作用与序列点输出结果为什么经常“出乎意料”*和自增属于同优先级结合方向是自右向左。所以表达式等价于关键动作*p*(p)先取值p 后移*p*(p)p 先移动再取值(*p)对*p做后缀自增值作为结果输出然后自增(*p)对*p做前缀自增自增后的值作为结果输出很多人把*p误以为是“p 指向的内容加1”其实它移动的是指针。想表达“内容加1”请写(*p)或(*p)。这类题目专门考验这一点。另外注意不要在一个表达式中多次修改同一个变量比如printf(%d %d\n, *p, *p);。这是未定义行为因为两个自增之间没有序列点编译器怎么处理都可能不同编译器结果不一样。做输出分析题时凡是遇到“同一个变量在一个表达式里被改两次”的写法直接说“结果是未定义的”不需要猜。3.3 用 gdb 或 IDE 调试器单步观察比死记优先级更直观很多同学学指针靠背结论我觉得不如直接看内存。VSCode 配置 C 环境这件事看着麻烦其实就三步安装 C/C 扩展并安装编译器。Windows 推荐 MinGW-w64装完后把bin目录加入系统 PATHLinux 和 macOS 直接用自带的gcc即可。创建一个.vscode/launch.json调试器选 “C (GDB/LLDB)”再创建tasks.json配置编译命令核心是开启调试信息gcc -g -O0注意-O0是为了防止编译器优化掉变量。在代码左侧行号处打断点F5 启动调试在“监视”窗口添加p、*p、arr就能看到运行到每一行时指针的值、地址里的内容如何变化。这套操作比在代码里加一百个printf都直观。看着p从0x...100跳到0x...104再跳0x...108内存布局就彻底清晰了。gdb 对命令行党也同样好用break 行号打断点print p看变量next单步执行基本够用。4. 字符串与指针的配合战从字符串函数到逆序实战4.1 字符串字面量 vs 字符数组一个能改一个不能改C 语言没有真正的字符串类型所谓字符串本质上就是字符数组在末尾放一个\0表示结束。理解这一点后很多字符串题目其实就是在考数组和指针的综合运用。char s1[] hello;和char *s2 hello;虽然看起来一样底层完全不同。前者会在栈或静态区开辟一块能读写的空间复制进去hello所以s1[0] H没问题。后者只是让指针变量s2指向“字符串字面量”所在的内存这块内存通常在只读段任何尝试修改的行为都是未定义行为。所以平时写代码能用const char *s hello;就加上const既表达意图也能让编译器帮你拦下这类错误。平台差异也值得提一下。在有些老旧开发环境里修改字符串字面量可能不崩只是行为诡异在 Linux 上大概率直接段错误。这类差异恰恰说明“未定义行为”有多危险——它不是“一定会报错”而是“什么结果都可能”。4.2 手写字符串函数strlen / strcpy / strcmp 的实现原理字符串函数是理解指针和\0语义的最好材料。下面三个版本简单可靠面试也常见my_strlen统计字符个数遇到\0停止size_t my_strlen(const char *s) { size_t n 0; while (*s) { n; } return n; }这里*s等价于*(s)先判断*s是否为\0然后把指针后移。很多人第一次看不明白但结合上一节“后缀自增先返回值再自增”就顺了。my_strcpy把源字符串复制到目标缓冲区char *my_strcpy(char *dest, const char *src) { char *p dest; while ((*dest *src) ! \0) ; return p; }这个循环很极端但很经典赋值表达式的值是赋值后的字符如果等于\0就退出循环否则继续。注意赋值运算符优先级比!低所以外层括号不能省略。返回dest的原始值是为了支持链式调用比如strlen(strcpy(buf, hello));。my_strcmp按字典序比较两个字符串int my_strcmp(const char *s1, const char *s2) { while (*s1 *s1 *s2) { s1; s2; } return (unsigned char)*s1 - (unsigned char)*s2; }两个字符相同时继续走直到一方到\0或者出现不相等最后用两个字符做差。强制转成unsigned char是为了避免字符符号扩展带来的坑在比较中文字符串二进制内容时尤其重要。4.3 实战字符串逆序PTA 经典题双指针解法字符串逆序是PTA和很多教材反复出现的题翁恺老师的练习题里也常有变体。要求输入一行字符串逆序输出。直接见代码#include stdio.h #include string.h void reverse(char *s) { size_t left 0; size_t right strlen(s); while (left right) { right--; char tmp s[left]; s[left] s[right]; s[right] tmp; left; } } int main(void) { char buf[256]; fgets(buf, sizeof buf, stdin); buf[strcspn(buf, \n)] \0; reverse(buf); puts(buf); return 0; }这里有两个细节值得单独讲。第一为什么用fgets而不是scanf(%s)因为%s遇空格会停止输入 “hello world” 只能读到 “hello”而fgets可以读整行。第二right为什么初始值是strlen(s)而不是strlen(s) - 1因为strlen返回的是size_t无符号类型如果减 1当字符串为空时会变成很大的数导致循环逻辑出错。所以先在while里判断left right再执行right--安全又干净。这个写法是踩过坑之后总结的建议直接抄。运行示例输入: hello world 输出: dlrow olleh这个双指针思路左边一个箭头、右边一个箭头交换所指字符后一起向中间靠后续做回文判断、反转链表都是同一套思想。5. 控制流的最后一道选择题while 与 do-while 到底怎么选5.1 执行时机差异一个“先查后干”一个“先干后查”day9 除了指针和字符串我还特意把while和do-while拿出来对比。很多同学学到后面容易把这两个搞混其实一句话就能说清while先判断条件条件为假就一次都不执行do-while无条件先执行一次循环体再判断条件所以至少执行一次。类比一下while像“先从钱包里看有没有钱再决定买不买东西”do-while像“先拿一件商品到收银台再检查口袋里有没有钱”。下面这段对比代码最直观int i 0; while (i 0) { printf(while 不会执行这一行\n); } do { printf(do-while 至少执行一次\n); } while (i 0);运行结果只会打印第二句。注意do-while末尾的分号不能漏这是初学者最常犯的语法错误之一。5.2 何时必须用 do-while菜单循环与输入校验需要“至少执行一次”的场景典型代表是菜单和输入合法性校验。比如一个菜单程序不管用户选什么菜单至少要显示一次int choice; do { printf(1. 添加记录\n); printf(2. 删除记录\n); printf(3. 退出\n); printf(请选择: ); scanf(%d, choice); switch (choice) { case 1: /* 添加逻辑 */ break; case 2: /* 删除逻辑 */ break; case 3: break; default: printf(无效选项\n); break; } } while (choice ! 3);再比如校验输入范围要求用户必须输入一个 1 到 100 的整数int n; do { printf(请输入一个 1 到 100 的整数: ); scanf(%d, n); while (getchar() ! \n) ; } while (n 1 || n 100);那个内层while (getchar() ! \n);是为了把输入缓冲区里残留的换行符清空否则下一次scanf可能直接读到换行而失败。这也是一个非常实用的技巧遇到“输入数字后回车程序却跳过下一次输入”的问题多半就是缓冲区没清干净。5.3 一个容易忽略的细节do-while(0) 宏技巧还有一个do-while的隐藏用法在宏定义中特别常见。比如你要写一个多语句宏#define PRINT_MSG(msg) do { \ printf(%s\n, msg); \ fflush(stdout); \ } while (0)为什么不用{ ... }直接包起来因为如果宏用在if (a) PRINT_MSG(hi); else ...;这样的场景里展开后分号位置和else的配对会出问题编译都过不了。用do { ... } while(0)包裹宏整体表现为一个普通语句可以安全地用在if/else后面。这个技巧在工程代码里非常常见day9 阶段先混个眼熟后面读开源项目时会频繁碰到。6. 常见问题与避坑速查day9 版每过完一个阶段我都会整理一张避坑表。这一天的内容密集出现频率高的问题基本集中在这张表里问题现象可能原因排查与解决程序一运行就 Segmentation fault指针未初始化或野指针解引用先打印指针地址确认不是 NULL 和乱值初始化指针后再解引用数组传进函数被“改坏”忽略数组传参本质是传首地址函数参数加const能尽早发现意外修改修改s[0]崩溃用char *s hello指向字面量后尝试修改改用char s[] hello拷贝一份printf输出和预期差距很大格式符写错或*p优先级混淆先拆解表达式再用调试器看中间值while循环一次都没执行初始条件就为假确认需求是“先判断”还是“至少执行一次”后者用do-whilescanf第二次输入被跳过缓冲区残留换行用while (getchar() ! \n);清空缓冲区逆序结果出现重复字符左右指针交叉后还在交换用left right条件避免重复交换VSCode 按 F5 无法启动调试编译器路径未配置或缺少-g编译选项检查tasks.json确认gcc -g -O0命令能执行gdb 中变量显示 optimized out编译器开优化把变量优化掉了编译时加-O0调试阶段别开优化除了这份表还有三条私房经验。第一学指针的阶段每写一行涉及指针的代码先口头说出三件事这个指针指向哪里它指向的类型占几个字节对它1会跳到哪说不出就说明还没懂别急着编译。第二逆序题里size_t是无符号类型尽量避免直接strlen(s) - 1用先减后用的方式更稳。这也是为什么我给的reverse函数写成right strlen(s); while (left right) { right--; ... }而不是一次减一安全第一。第三测试“非法地址”时不要拿生产数据做实验。想验证野指针和只读段问题用最小可复现代码比如 10 行以内的小程序崩溃也不会心疼。把问题范围缩小是定位一切内存错误的基础。第九课学完朋友跟我说他最值的一刻是把*p和(*p)在草稿纸上一步步画出来而不是背结论。我觉得这才是学 C 语言该有的姿态。语法只是皮内存模型才是骨头指针就是连接两者的桥。下一课我准备带他写一个带菜单的学生成绩小项目正好把do-while菜单、指针访问数组、字符串处理一次性串起来。如果你也卡在指针上别急先准备好纸笔把“地址、指针、解引用”三个词写成卡片遇到一个代码就往里套很快就能顺过来。
返回列表