深入理解指针(六):sizeof 与 strlen 辨析及指针运算通关
前言
在前几篇《深入理解指针》系列中,我们系统梳理了字符指针、数组指针、函数指针、回调函数以及 qsort 的模拟实现。指针的核心在于对内存布局 和类型步长 的精准把控。本篇作为指针系列的教学课件,将围绕三个核心主题展开:
sizeof与strlen的本质区别、数组与指针的经典笔试题、指针运算。

目录
- [一、sizeof 和 strlen 的对比](#一、sizeof 和 strlen 的对比)
- [1.1 本质区别](#1.1 本质区别)
- [1.2 原创示例代码](#1.2 原创示例代码)
- [1.3 一个容易被忽略的细节](#1.3 一个容易被忽略的细节)
- 二、数组和指针笔试题解析
- [2.1 一维数组](#2.1 一维数组)
- [2.2 字符数组与字符指针](#2.2 字符数组与字符指针)
- [2.3 二维数组](#2.3 二维数组)
- 三、指针运算笔试题解析
- [3.1 指针类型决定步长](#3.1 指针类型决定步长)
- [3.2 结构体指针偏移与强制类型转换](#3.2 结构体指针偏移与强制类型转换)
- [3.3 逗号表达式陷阱](#3.3 逗号表达式陷阱)
- [3.4 指针相减:元素个数而非字节差](#3.4 指针相减:元素个数而非字节差)
- [3.5 二维数组的行指针偏移](#3.5 二维数组的行指针偏移)
- [3.6 指针数组与二级指针](#3.6 指针数组与二级指针)
- [3.7 多级指针的内存追踪](#3.7 多级指针的内存追踪)
- 四、总结
一、sizeof 和 strlen 的对比
1.1 本质区别
sizeof 是 C 语言中的一个单目操作符(关键字) ,而 strlen 是一个库函数 ,这是它们最根本的区别。很多人初学时会误以为 sizeof 是函数,实际上它是由编译器直接处理的。
| 对比项 | sizeof | strlen |
|---|---|---|
| 本质 | 单目操作符(关键字) | 库函数(需 <string.h>) |
| 求值时机 | 编译期 | 运行期 |
| 功能 | 计算变量或类型占用的字节数 | 统计字符串中 \0 之前的字符个数 |
| 关注点 | 只关心内存空间大小,不关心内容 | 关心内存中是否有 \0,无 \0 会越界 |
| 返回值类型 | size_t |
size_t |
1.2 原创示例代码
c
#include <stdio.h>
#include <string.h>
int main(void) {
int num = 0x12345678;
printf("sizeof(num) = %zu\n", sizeof(num)); // 4:整型大小
printf("sizeof num = %zu\n", sizeof num); // 4:sizeof 是操作符,可省略括号
printf("sizeof(long) = %zu\n", sizeof(long)); // 4 或 8,取决于平台
char text1[] = "C pointer";
char text2[] = {'C', ' ', 'p', 'o', 'i', 'n', 't', 'e', 'r'};
const char *text3 = "C pointer";
printf("sizeof(text1) = %zu\n", sizeof(text1)); // 10:包含结尾的 '\0'
printf("strlen(text1) = %zu\n", strlen(text1)); // 9:不包含 '\0'
printf("sizeof(text2) = %zu\n", sizeof(text2)); // 9:数组本身大小
// printf("strlen(text2) = %zu\n", strlen(text2)); // 危险!无 '\0',越界查找,结果随机
printf("sizeof(text3) = %zu\n", sizeof(text3)); // 8 或 4:指针大小
printf("strlen(text3) = %zu\n", strlen(text3)); // 9:字符串长度
printf("sizeof(*text3) = %zu\n", sizeof(*text3)); // 1:字符 'C' 的大小
return 0;
}

提示 :
strlen的参数必须是char *类型。如果传入*text3(即字符'C',ASCII 值为 67),strlen会把 67 当作地址去访问内存,导致段错误。这是一个非常隐蔽的陷阱------编译器不会报错,但运行时会崩溃。
1.3 一个容易被忽略的细节
sizeof 在计算表达式大小时,如果表达式包含函数调用,函数并不会被真正执行 ,因为 sizeof 在编译期就能根据类型确定结果。例如:
c
#include <stdio.h>
#include <string.h>
int get_length(void) {
printf("该函数被调用了!\n");
return 0;
}
int main(void) {
int len = 10;
printf("sizeof(len + get_length()) = %zu\n", sizeof(len + get_length()));
// 输出为 4,且不会打印"该函数被调用了!"
return 0;
}

这一特性再次印证了 sizeof 的编译期本质,与 strlen 的运行期求值形成鲜明对比。
二、数组和指针笔试题解析
核心法则:数组名在大多数表达式中会退化为首元素地址,但有两个例外:
sizeof(数组名):此时数组名代表整个数组,计算整个数组的大小。&数组名:此时数组名代表整个数组,取出的是整个数组的地址。
2.1 一维数组
c
#include <stdio.h>
int main(void) {
double d[] = {1.5, 2.5, 3.5, 4.5};
printf("sizeof(d) = %zu\n", sizeof(d)); // 32:整个数组,4 * 8
printf("sizeof(d + 0) = %zu\n", sizeof(d + 0)); // 8 或 4:d 退化为 double*,指针大小
printf("sizeof(*d) = %zu\n", sizeof(*d)); // 8:首元素 double
printf("sizeof(d + 2) = %zu\n", sizeof(d + 2)); // 8 或 4:指向第三个元素的指针
printf("sizeof(d[2]) = %zu\n", sizeof(d[2])); // 8:第三个元素 double
printf("sizeof(&d) = %zu\n", sizeof(&d)); // 8 或 4:整个数组的地址,数组指针
printf("sizeof(*&d) = %zu\n", sizeof(*&d)); // 32:*&d 等价于 d,整个数组
printf("sizeof(&d + 1) = %zu\n", sizeof(&d + 1)); // 8 或 4:跳过整个数组后的指针
printf("sizeof(&d[0] + 1) = %zu\n", sizeof(&d[0] + 1)); // 8 或 4:指向第二个元素的指针
return 0;
}

解析 :d 和 &d 的值相同,但类型不同。d + 1 跳过 8 个字节(一个 double),而 &d + 1 跳过 32 个字节(整个数组)。理解这一点的关键在于区分指针的值和指针的类型:值相同不代表行为相同,类型才决定了指针的步长。
2.2 字符数组与字符指针
c
#include <stdio.h>
#include <string.h>
int main(void) {
char s1[] = "hello";
char s2[] = {'h', 'e', 'l', 'l', 'o'};
char *s3 = "hello";
printf("sizeof(s1) = %zu, strlen(s1) = %zu\n", sizeof(s1), strlen(s1)); // 6, 5
printf("sizeof(s2) = %zu\n", sizeof(s2)); // 5
// strlen(s2) 未定义,因为 s2 没有 '\0',不演示
printf("sizeof(s3) = %zu, strlen(s3) = %zu\n", sizeof(s3), strlen(s3)); // 8/4, 5
printf("sizeof(*s3) = %zu\n", sizeof(*s3)); // 1
return 0;
}

解析 :字符数组 s1 由字符串字面量初始化时,编译器会自动在末尾添加 \0,因此 sizeof(s1) 为 6。而 s2 使用初始化列表逐字符赋值,没有隐式的 \0,sizeof(s2) 为 5,对其调用 strlen 属于未定义行为。
2.3 二维数组
c
#include <stdio.h>
int main(void) {
short mat[2][3] = {0};
printf("sizeof(mat) = %zu\n", sizeof(mat)); // 12:2 * 3 * 2
printf("sizeof(mat[0]) = %zu\n", sizeof(mat[0])); // 6:第一行,3 * 2
printf("sizeof(mat[0][0]) = %zu\n", sizeof(mat[0][0])); // 2:一个 short
printf("sizeof(mat + 1) = %zu\n", sizeof(mat + 1)); // 8 或 4:行指针
printf("sizeof(*(mat + 1)) = %zu\n", sizeof(*(mat + 1))); // 6:第二行数组
printf("sizeof(&mat[0] + 1) = %zu\n", sizeof(&mat[0] + 1)); // 8 或 4:行指针
printf("sizeof(*(&mat[0] + 1)) = %zu\n", sizeof(*(&mat[0] + 1))); // 6:等价于 mat[1]
printf("sizeof(mat[1]) = %zu\n", sizeof(mat[1])); // 6:第二行数组
return 0;
}

解析 :二维数组 mat 是一维数组的数组。mat 退化为指向第一行的指针,类型为 short (*)[3],步长是一整行(6 字节)。mat + 1 跳过第一行,指向第二行的起始地址;而 *(mat + 1) 解引用后得到第二行这个一维数组,大小为 6 字节。
三、指针运算笔试题解析
指针运算的核心在于类型决定步长。《C和指针》中反复强调:指针的算术运算不是简单的整数加减,而是以指针所指向类型的大小为单位进行移动的。理解这一点,是破解所有指针运算题目的关键。
3.1 指针类型决定步长
题目:
c
#include <stdio.h>
int main(void) {
int arr[6] = {10, 20, 30, 40, 50, 60};
int *p = (int *)(&arr + 1);
printf("%d\n", *(p - 2));
return 0;
}

解析:
这道题的关键在于区分 arr 和 &arr 的类型差异。arr 作为数组名,在参与运算时退化为指向首元素的指针,类型为 int *,步长为 4 字节。而 &arr 取出的是整个数组的地址,类型为 int (*)[6],步长为整个数组的大小------24 字节。
因此 &arr + 1 从数组首地址出发,跳过完整的 24 字节,指向数组末尾之后的位置。这个位置本身不能解引用,但我们可以将其强制转换为 int * 类型的指针 p。此时 p 的步长从 24 字节变为了 4 字节。
p - 2 表示从数组末尾之后的位置向前移动 2 个 int,即 8 字节。这个位置指向数组的倒数第二个元素,也就是 50。
输出结果 :50
核心法则 :指针的步长由指针的类型决定,而不是由它当前指向的数据决定。
(int *)(&arr + 1)先完成了"跳过整个数组"的操作,再通过强制类型转换将步长改回 4 字节,实现了从数组尾部反向访问的能力。
3.2 结构体指针偏移与强制类型转换
题目:假设在 32 位环境下,结构体定义如下,请计算各表达式的值。
c
#include <stdio.h>
struct Test {
int Num;
char *pcName;
short sDate;
char cha[2];
short sBa[4];
} *p = (struct Test *)0x100000;
int main(void) {
printf("%p\n", p + 0x1);
printf("%p\n", (unsigned long)p + 0x1);
printf("%p\n", (unsigned int *)p + 0x1);
return 0;
}

解析:
这道题的实质是考察同一个地址值在不同类型下的不同解释 。p 被初始化为 0x100000,但它是一个结构体指针,它的步长由 sizeof(struct Test) 决定。
第一个表达式 p + 0x1:p 是 struct Test * 类型,p + 1 表示跳过一整个结构体。根据题目条件,结构体大小为 20 字节,因此 0x100000 + 20 得到 0x100014。
第二个表达式 (unsigned long)p + 0x1:指针被强制转换为无符号长整型,不再是指针,而是普通的整数。整数加 1 就是数值加 1,结果就是 0x100001。
第三个表达式 (unsigned int *)p + 0x1:指针被强制转换为 unsigned int * 类型,步长变为 4 字节。0x100000 + 4 得到 0x100004。
输出结果 :0x100014、0x100001、0x100004
核心法则 :指针的类型决定了
+1的含义。struct Test *的+1是加 20 字节,unsigned long的+1是加 1,unsigned int *的+1是加 4 字节。
3.3 逗号表达式陷阱
题目:以下代码输出什么?
c
#include <stdio.h>
int main(void) {
int a[3][2] = { (0, 1), (2, 3), (4, 5) };
int *p;
p = a[0];
printf("%d", p[0]);
return 0;
}

解析:
这道题的陷阱在于初始化列表的写法。(0, 1) 使用的是小括号 而不是大括号 。在 C 语言中,小括号内的逗号是逗号表达式,它会依次计算每个表达式的值,最终返回最后一个表达式的值。
因此 (0, 1) 的结果是 1,(2, 3) 的结果是 3,(4, 5) 的结果是 5。数组实际上被初始化为:
a[0][0] = 1, a[0][1] = 3
a[1][0] = 5, a[1][1] = 0
a[2][0] = 0, a[2][1] = 0
p = a[0] 让 p 指向数组第一行的首元素。p[0] 就是 a[0][0],即 1。
输出结果 :1
核心法则 :二维数组初始化时,必须使用嵌套的大括号
{``{0, 1}, {2, 3}, {4, 5}}才能正确初始化。使用小括号会被编译器解释为逗号表达式,数据会错位。这个陷阱也提醒我们:初始化列表是"初始化"而非"赋值",语法上的细微差别可能导致语义完全不同。
3.4 指针相减:元素个数而非字节差
题目:假设环境是 x86(32 位),程序输出的结果是啥?
c
#include <stdio.h>
int main(void) {
int a[5][5];
int (*p)[4];
p = a;
printf("%p, %d\n", &p[4][2] - &a[4][2], &p[4][2] - &a[4][2]);
return 0;
}

解析:
这道题是《C和指针》中"指针减法得到的是元素个数"这一规则的经典应用。两个指针相减的结果不是地址的字节差,而是它们之间元素的个数。
首先分析 a 的类型。a 是 int[5][5],作为右值参与运算时退化为指向第一行的指针,类型为 int (*)[5],步长是一整行(5 个 int,20 字节)。
p 被声明为 int (*)[4],步长是一行 4 个 int(16 字节)。
计算 &p[4][2]:p[4] 等价于 *(p + 4),p 跳过 4 行(每行 4 个 int),到达第 5 行。p[4][2] 再偏移 2 个 int。相对于数组首地址,总偏移为 4 * 4 + 2 = 18 个 int。
计算 &a[4][2]:a[4] 等价于 *(a + 4),a 跳过 4 行(每行 5 个 int),到达第 5 行。a[4][2] 再偏移 2 个 int。相对于数组首地址,总偏移为 4 * 5 + 2 = 22 个 int。
两者相减:18 - 22 = -4,得到的是元素个数差,即 -4。
第一个 printf 使用 %p 打印这个差值。在 32 位系统中,-4 的补码表示为 0xFFFFFFFC。第二个 printf 使用 %d 打印有符号整数 -4。
输出结果 :0xFFFFFFFC, -4
核心法则 :指针相减的前提是两个指针指向同一块连续内存。相减的结果是元素个数 (带符号整数),而非字节数。如果地址计算涉及不同的步长(如本题中的
int (*)[4]和int (*)[5]),必须分别按各自的步长计算出相对于同一个基地址的偏移量,再相减。
3.5 二维数组的行指针偏移
题目:
c
#include <stdio.h>
int main(void) {
int aa[2][5] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int *ptr1 = (int *)(aa + 1);
int *ptr2 = (int *)(*aa + 1);
printf("%d, %d", *(ptr1 - 1), *(ptr2 - 1));
return 0;
}

解析:
这道题考察的是二维数组名与解引用操作的不同含义。
aa 作为二维数组名,在参与运算时退化为指向第一行的行指针,类型为 int (*)[5],步长是一整行(20 字节)。aa + 1 跳过第一行,指向第二行的起始地址。强制转换为 int * 后,ptr1 的步长变为 4 字节。ptr1 - 1 从第二行起始位置向前移动 1 个 int,指向第一行的最后一个元素。
*aa 等价于 aa[0],是第一行的数组名。作为右值参与运算时退化为指向第一行首元素的指针,类型为 int *,步长为 4 字节。*aa + 1 指向第一行的第二个元素(值为 2)。强制转换为 int * 后 ptr2 仍指向该位置。ptr2 - 1 向前移动 1 个 int,指向第一行的第一个元素。
输出结果 :5, 1
核心法则 :
aa和*aa的值在数值上相同,都是数组首地址,但类型完全不同。aa是int (*)[5],*aa是int *。aa + 1跳过 20 字节,*aa + 1跳过 4 字节。
3.6 指针数组与二级指针
题目:
c
#include <stdio.h>
int main(void) {
char *a[] = {"work", "at", "alibaba"};
char **pa = a;
pa++;
printf("%s\n", *pa);
return 0;
}

解析:
a 是一个指针数组,每个元素都是 char * 类型,分别指向三个字符串常量。pa 是一个二级指针,被初始化为指向数组 a 的首元素(即 a[0] 的地址)。
pa++ 让 pa 向后移动一个 char * 的大小,指向 a[1]。*pa 解引用得到 a[1] 的值,也就是指向字符串 "at" 的指针。
输出结果 :at
3.7 多级指针的内存追踪
题目:
c
#include <stdio.h>
int main(void) {
char *c[] = {"ENTER", "NEW", "POINT", "FIRST"};
char **cp[] = {c + 3, c + 2, c + 1, c};
char ***cpp = cp;
printf("%s\n", **++cpp);
printf("%s\n", *--*++cpp + 3);
printf("%s\n", *cpp[-2] + 3);
printf("%s\n", cpp[-1][-1] + 1);
return 0;
}

解析:
这道题是《C专家编程》中的经典题目,也是多级指针最极致的考验。它的唯一解法是画内存图 。需要注意的是,++cpp 和 -- 操作会修改 cpp 和 cp 的实际内容,因此每一步都建立在上一步修改后的状态之上。
初始内存布局:
| 数组 | 内容 | 指向 |
|---|---|---|
c[0] |
"ENTER" |
字符串 ENTER |
c[1] |
"NEW" |
字符串 NEW |
c[2] |
"POINT" |
字符串 POINT |
c[3] |
"FIRST" |
字符串 FIRST |
| 数组 | 内容 | 指向 |
|---|---|---|
cp[0] |
c + 3 |
c[3],即 "FIRST" |
cp[1] |
c + 2 |
c[2],即 "POINT" |
cp[2] |
c + 1 |
c[1],即 "NEW" |
cp[3] |
c |
c[0],即 "ENTER" |
cpp 初始指向 cp[0]。
- 第一行:
**++cpp
++cpp 首先自增,cpp 从指向 cp[0] 变为指向 cp[1]。*cpp 得到 cp[1] 的值,即 c + 2。再解引用一次,*(c + 2) 得到字符串 "POINT"。
输出 :POINT
此时 cpp 指向 cp[1],保持不变。
- 第二行:
*--*++cpp + 3
++cpp 先自增,cpp 从指向 cp[1] 变为指向 cp[2]。
*++cpp 解引用得到 cp[2] 的值,即 c + 1。
--*++cpp:注意 cp[2] 的值被修改,从 c + 1 变为 c。
*--*++cpp 解引用得到 *(c + 0),即字符串 "ENTER"。
+ 3 后,指针从 "ENTER" 的 'E' 位置向后移动 3 个字符,指向 "ER"。
输出 :ER
此时 cpp 指向 cp[2],cp[2] 的值变为 c。
- 第三行:
*cpp[-2] + 3
cpp[-2] 等价于 *(cpp - 2)。cpp 当前指向 cp[2],cpp - 2 指向 cp[0]。*(cpp - 2) 得到 cp[0] 的值,即 c + 3。
*cpp[-2] 再解引用一次,得到 *(c + 3),即字符串 "FIRST"。
+ 3 后,从 "FIRST" 的 'F' 位置向后移动 3 个字符,指向 "ST"。
输出 :ST
注意:cpp[-2] 不修改 cpp,cpp 仍指向 cp[2]。
- 第四行:
cpp[-1][-1] + 1
cpp[-1] 等价于 *(cpp - 1)。cpp 当前指向 cp[2],cpp - 1 指向 cp[1]。
cpp[-1] 得到 cp[1] 的值。注意 cp[1] 的值没有被修改过,仍然是 c + 2。
cpp[-1][-1] 等价于 *(*(cpp - 1) - 1)。*(cpp - 1) 是 c + 2,减 1 得到 c + 1。解引用得到 *(c + 1),即字符串 "NEW"。
+ 1 后,从 "NEW" 的 'N' 位置向后移动 1 个字符,指向 "EW"。
输出 :EW
最终输出:
POINT
ER
ST
EW
核心法则 :多级指针的运算必须一步一步展开,每一步都要明确当前指针指向谁 ,以及解引用后得到什么值 。自增和自减操作会永久修改指针的指向和值,必须追踪这些副作用。画内存图是解决多级指针问题的唯一可靠方法。
四、总结
通过本篇的原创题目,我们可以提炼出以下指针通关心法:
sizeof与strlen不可混用 :sizeof是编译期操作符,算内存大小;strlen是运行期库函数,找\0算长度。sizeof中的函数调用不会被执行。- 数组名的两个例外 :
sizeof(数组名)和&数组名中,数组名代表整个数组;其他情况退化为首元素地址。 - 指针类型决定步长 :
int *步长 4,int (*)[3]步长 12,&数组步长是整个数组大小。强制类型转换会改变步长。 - 指针相减算元素 :两个同类型指针相减,得到的是它们之间相差的元素个数(有符号整数),而非字节数。不同步长的指针需要先统一基地址再计算。
- 多级指针画图追踪 :遇到
***套娃,务必在草稿纸上画出内存块,标注每一级指针的指向,逐步追踪自增/自减带来的副作用。 - 警惕逗号表达式 :二维数组初始化时,大括号
{}嵌套才是初始化数组,小括号()是逗号表达式,容易造成数据错位。这类错误编译器不会报警,但结果完全错误。
指针的进阶不仅是语法游戏,更是对计算机内存模型的深度理解。希望这篇博客能帮你在指针题海中拨云见日,顺利通关!
如果觉得有帮助,欢迎点赞收藏,留个关注支持一下吧!