云计算百科
云计算领域专业知识百科平台

深入理解指针6

深入理解指针(六):sizeof 与 strlen 辨析及指针运算通关

前言

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


在这里插入图片描述

目录

  • 一、sizeof 和 strlen 的对比
    • 1.1 本质区别
    • 1.2 原创示例代码
    • 1.3 一个容易被忽略的细节
  • 二、数组和指针笔试题解析
    • 2.1 一维数组
    • 2.2 字符数组与字符指针
    • 2.3 二维数组
  • 三、指针运算笔试题解析
    • 3.1 指针类型决定步长
    • 3.2 结构体指针偏移与强制类型转换
    • 3.3 逗号表达式陷阱
    • 3.4 指针相减:元素个数而非字节差
    • 3.5 二维数组的行指针偏移
    • 3.6 指针数组与二级指针
    • 3.7 多级指针的内存追踪
  • 四、总结

一、sizeof 和 strlen 的对比

1.1 本质区别

sizeof 是 C 语言中的一个单目操作符(关键字),而 strlen 是一个库函数,这是它们最根本的区别。很多人初学时会误以为 sizeof 是函数,实际上它是由编译器直接处理的。

对比项sizeofstrlen
本质 单目操作符(关键字) 库函数(需 <string.h>)
求值时机 编译期 运行期
功能 计算变量或类型占用的字节数 统计字符串中 \\0 之前的字符个数
关注点 只关心内存空间大小,不关心内容 关心内存中是否有 \\0,无 \\0 会越界
返回值类型 size_t size_t

1.2 原创示例代码

#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 在编译期就能根据类型确定结果。例如:

#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 一维数组

    #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 字符数组与字符指针

    #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 二维数组

    #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 指针类型决定步长

    题目:

    #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 位环境下,结构体定义如下,请计算各表达式的值。

    #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 逗号表达式陷阱

    题目:以下代码输出什么?

    #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 位),程序输出的结果是啥?

    #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 二维数组的行指针偏移

    题目:

    #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 指针数组与二级指针

    题目:

    #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 多级指针的内存追踪

    题目:

    #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,&数组 步长是整个数组大小。强制类型转换会改变步长。
  • 指针相减算元素:两个同类型指针相减,得到的是它们之间相差的元素个数(有符号整数),而非字节数。不同步长的指针需要先统一基地址再计算。
  • 多级指针画图追踪:遇到 *** 套娃,务必在草稿纸上画出内存块,标注每一级指针的指向,逐步追踪自增/自减带来的副作用。
  • 警惕逗号表达式:二维数组初始化时,大括号 {} 嵌套才是初始化数组,小括号 () 是逗号表达式,容易造成数据错位。这类错误编译器不会报警,但结果完全错误。
  • 指针的进阶不仅是语法游戏,更是对计算机内存模型的深度理解。希望这篇博客能帮你在指针题海中拨云见日,顺利通关!


    如果觉得有帮助,欢迎点赞收藏,留个关注支持一下吧!

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 深入理解指针6
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!