写这篇的时候我正在给一位零基础的朋友补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]等价于*(a+i) | 能用指针逆序遍历数组 |
| 数组名与指针的差异 | 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 指针的算术运算:p+1 到底移动了几个字节
指针可以参与加减运算,但语义和普通整数完全不同。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 的变化 |
|---|---|---|---|
*p++ | 1 | arr[1] | 无变化 |
(*p)++ | 2 | arr[1] | arr[1]: 2 -> 3 |
*p | 3 | arr[1] | 无变化 |
*++p | 3 | arr[2] | 无变化 |
++(*p) | 4 | arr[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目录加入系统 PATH;Linux 和 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-while |
scanf第二次输入被跳过 | 缓冲区残留换行 | 用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菜单、指针访问数组、字符串处理一次性串起来。如果你也卡在指针上,别急,先准备好纸笔,把“地址、指针、解引用”三个词写成卡片,遇到一个代码就往里套,很快就能顺过来。