指针章节使用滴水逆向的教程:2015-02-26 (指针 1) - 哔哩哔哩 - bilibili,并参考《C 和指针(第二版)》

笔记中使用的开发环境说明:

  • OS: Windows 11 (Business Edition) x86_64 ver. 23H2(x64) [Kernel: WIN32_NT 10.0.22631.4317]

  • IDE(主要):Microsoft Visual Studio Community 2022 (64bit) current ver.17.11.4,平台控制集:Visual Studio 2022 (v143) / LLVM (Clang-cl)

    • 解决方案配置:Debug x64;C 语言标准:ISO C17(2018);C 语言标准:ISO C17
  • 类 IDE(备选):Microsoft Visual Studio Code ver.1.94.2,生成工具:GCC 和 G++ [14.2.0 (Rev1, Built by MSYS2 project)] / Clang (version 19.1.0),调试工具:GDB (ver. 15.2) / LLDB (ver. 9.0)

  • IDE(备选):JetBrains Clion 2024.2.2,生成工具:Cmake (version 3.29.2) + ninja (ver. 1.12.0)

  • IDE(备选):Microsoft Visual C++ 6.0 Enterprise Edition SP6,项目调试配置:Win32 Debug,C 语言标准:ANSI C (C89),C 语言标准:无,VC6 之后才有 C++ 语言标准

0. 前置知识

0.1 内存和地址

我们可以将内存视作商业街里的一排排商铺。每个商铺里都摆放着不同的物品(数据)。商铺最初只有不同的门牌号,这个门牌号就相当于地址

原先每个商铺的大小只有 1bit,空间太小导致不可用,所以商铺管理者将几个商铺之间打通,组成一个新的大的商铺。这个新商铺的大小就是 1byte。1byte 的商铺又不够用了,那就再打通几个商铺,组成更大的商铺(大小为 1 字)。

映射到计算机上,计算机的内存拥有数以亿万计的(bit),由于一个位只能表示 0 或 1,范围有限,因此计算机将多个位组合成一个字节(byte),并以地址来标示:

(来源:《C 和指针(第二版)》)

为了存储更大的值,我们又将多个字节组成一个(word),也用地址来标示:

(来源:《C 和指针(第二版)》)

一个字只有一个地址(尽管字里面包含了数个字节地址),这个地址可能是最左边字节的地址,也可能是最右边字节的地址,视机器规定不同而不同。这里还有边界对齐的问题,但这是留给硬件设计者的。

作为 C 程序员,我们更关心这两件事情:

  • 1. 内存中的每一个位置都有独一无二的地址标识
  • 2. 内存中的每一个位置都包含一个值

0.2 地址与内容

知道一个值在内存中的地址,我们就可以根据这个地址来获取值:

(来源:《C 和指针(第二版)》)

但我们不可能记住每个值对应的地址,正如去商业街多半也不是看门牌号找店铺,因此像 C 这样的高级语言的特性之一就是以变量名替代地址来访问内存的特定位置:

(来源:《C 和指针(第二版)》)

需要注意的一点是,变量名与内存位置的关联是由编译器实现的,硬件仍然通过地址访问内存位置

还有一点,程序员事先无法知道某个特定的变量将存储到内存中的哪个位置。因为编译器负责把变量赋值给内存中的位置,而这个分配到的位置可能每次都不一样。将一个具体地址和一个值锁死,无异于刻舟求剑。每次运行程序,这个具体地址可能会分配到一个奇奇怪怪的值。

当然偶尔你需要通过地址访问内存的特定位置,它并不是用于访问某个变量,而是访问硬件本身,比如某些机器需要通过某些特定的内存位置来与其他硬件联动。

“但是,你需要使用这种技巧的机会是绝无仅有的!”(《C 和指针(第二版)》)


1. 从基本性质入手,了解指针类型

指针也是一种数据类型。所有其他的数据类型都可以再次定义为指针。现在我们来探究一下它的性质。

当我们想要了解一种全新的数据类型时,我们需要了解以下几个方面:

  • 宽度(字节数)

  • 声明与定义

  • 赋值和运算性质(四则运算、递增递减、关系运算等)


1.1 指针的声明

声明一个指针类型变量,可以:data_type* name 或者 data_type *name* 可以写很多个,不同数量的 * 所代表的性质不一样)。视频教程推荐使用前一个写法,防止 * 被误认为是变量名,影响后续的特定操作。

这个 * 在不同的语境中(对,编程语言也要关注 “上下文”)的意义是不一样的。可参见 How to explain C pointers (declaration vs. unary operators) to a beginner? - Stack Overflow

两种写法的讨论

不过《C 和指针(第二版)》推荐 data_type *name 的写法。它为 data_type* name 给出的反例是:如果希望在一行内定义数个指针变量,data_type* name 的写法只会将第一个变量定义为指针,而后面的变量不会是指针。

例如:对于 int* x, y,编译器仅将 x 定义为指针(int*)类型,而将 y 定义为 int 类型。

1
2
int* x, y;
y = (int*)10;
1
错误(活动)	E0513	不能将 "int *" 类型的值分配到 "int" 类型的实体

但其实这个问题可以通过改变代码习惯来避免:

尽管一般不允许在一行内声明多个变量,但如果执意如此,请给每个应该声明为指针的变量加个 *

——Introduction to pointers – Learn C++

一行内仅声明一个变量,并注释变量用途。一行内声明多个变量可能造成误解。

——DCL04-C. Do not declare more than one variable per declaration - SEI CERT C Coding Standard - Confluence


Update: 必须承认,两种方式都有权威撑腰(前者为 Stroustrup 风格,后者为 K&R 风格),也都有其簇拥者,所以按你能够理解的风格来选用

毕竟正如 Stroustrup(注: C++ 的设计者)所说:

Both are “right” in the sense that both are valid C and C++ and both have exactly the same meaning. The choice between int* p;'and int *p; is not about right and wrong, but about style and emphasis. C emphasized expressions; declarations were often considered little more than a necessary evil. C++, on the other hand, has a heavy emphasis on types.

A “typical C programmer” writes int *p; and explains it *p is what is the int emphasizing syntax, and may point to the C (and C++) declaration grammar to argue for the correctness of the style. Indeed, the * binds to the name p in the grammar.

A “typical C++ programmer” writes int* p; and explains it p is a pointer to an int emphasizing type. Indeed the type of p is int*. I clearly prefer that emphasis and see it as important for using the more advanced parts of C++ well.

Often, opinions about code layout are strongly held, but probably consistency matters more** than any particular style.

——Stroustrup: C++ Style and Technique FAQ

两种写法无关对错,只与代码风格和强调的编程语言元素有关。尽管人们常常关注代码风格,但保持项目中代码风格的一致性更为重要。如果是个人开发,请坚持你倾向的代码风格;如果是团队开发且团队有代码风格规范,则优先遵守代码规范。


1.2 指针的宽度与赋值

研究一个新的数据类型,我们还要探测它的宽度,也就是我们先前所知的大小、占用字节数。这可以用 sizeof()

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
int* a = (int*)1;
char* b = (char*)32;
printf("%zu %zu\n", sizeof(a), sizeof(b));
return 0;
}
1
>>8 8

我们看到,原先应该分别占用 1 字节、4 字节的 charint 类型,变成指针后都占用 8 个字节(64 位开发环境下)。这个字节数不会随着指针类型的变化而变化,在同一开发环境下永远是 8 字节。

Tip

指针的大小和它指向的变量的宽度大小无关。它只和操作系统和 CPU 的架构相关。在 C 语言中,指针的大小为:

  • 对于 64 位系统,是 8 字节
  • 对于 32 位系统,是 4 字节

——C Pointers - GeeksforGeeks


这里又引出来赋值问题。我们能不能像之前给其他变量赋值那样给指针变量赋值呢?试一试!

1
int* a = 1;
1
2
//VS错误列表
错误(活动) E0144 "int" 类型的值不能用于初始化 "int *" 类型的实体

这个 1 并不是一个指针,就是普通的 int。拿内容当地址,和 “Q:‘你是哪个省的?’A:‘妈妈生的。’” 有什么区别?!

因此,我们在为指针变量赋值时,必须在 = 右边强制转换数据类型,确保左右两边的量的指针类型完全相同。

摘录两段解释:

但是 a 究竟指向哪里呢?我们声明了这个这个变量,但从未对它进行初始化,所以我们没有办法预测 1 这个值将存储于什么地方。…… 声明一个指向整型的指针都不会 “创建” 用于存储整型值的内存空间。编译器只为指针本身保留内存空间。

…… 如果你运气好,a 的初始值会是个非法地址,这样语句就会出错从而终止程序。…… 一个更为严重的情况是:这个指针偶尔可能包含了一个合法的地址。接下来的事情很简单:位于那个位置的值被修改为 1,即使你并无意去修改它。…… 所以,在你对指针进行间接访问之前,必须非常小心,确保它们已被初始化

——《C 和指针(第二版)》,有删改


指针无法被默认初始化。没有初始化的指针称为野指针,它指向一个垃圾地址(存储的数据没有意义)。间接访问野指针会造成未定义行为。因此,务必为指针变量初始化一个地址值。

为指针指定一个字面量也是不被接受的(补药硬编码呀):

1
2
int* ptr = 5; // not okay
int* ptr = 0x0012FF7C; // not okay, 0x0012FF7C is treated as an integer literal, instead of an address

——12.7 — Introduction to pointers – Learn C++


1.3 指针的运算性质

我们已经探明了指针类型的声明、赋值和宽度。接下来我们探测指针类型的运算性质

1.3.1 递增递减

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main() {
int* a = (int*)1;
char** b = (char**)32;
short* c = (short*)20;
int** d = (int**)16;
a++;
b++;
c--;
d--;
printf("%d %d\n", a, b);
printf("%d %d\n", c, d);
return 0;
}
1
2
>>5 40
18 8

从我们朴素的认识来讲,指针类型的递增递减,增减的是指针变量类型去掉一个星后的类型的一个宽度


1.3.2 和整数的增减运算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main() {
int* a = (int*)1;
char** b = (char**)32;
short* c = (short*)20;
int** d = (int**)16;
a += 1;
b += 1;
c -= 1;
d -= 1;
printf("%d %d\n", a, b);
printf("%d %d\n", c, d);
return 0;
}
1
2
>>5 40
18 8

跟递增递减的结果没变啊!再试试其他情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main() {
int* a = (int*)1;
char** b = (char**)32;
short* c = (short*)20;
int** d = (int**)16;
a += 2;
b += 2;
c -= 2;
d -= 2;
printf("%d %d\n", a, b);
printf("%d %d\n", c, d);
return 0;
}
1
2
>>9 48
16 0

恍然大悟!增减的也是指针变量类型去掉一个星后的类型的宽度增减几个数就等于增减几个宽度

那么指针有乘除运算吗?

1
2
3
4
short* c = (short*)20;
int** d = (int**)16;
c /= 2;
d *= 2;
1
2
3
//VS错误列表
错误(活动) E2138 表达式必须具有算术或未区分范围的枚举类型 3
错误(活动) E2138 表达式必须具有算术或未区分范围的枚举类型 4

报错了!那就是不行了。

指针不能和浮点数进行加减运算。编译器会提示:

1
错误(活动)	E2140	表达式必须具有整数或未区分范围的枚举类型

1.3.3 指针之间的减法运算

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main() {
int* a = (int*)100;
int* d = (int*)200;
int x = d - a;
printf("%d %d\n", a, d);
printf("%d\n", x);
return 0;
}
1
2
>>100 200
25

减是减了,但结果说不出来的怪……

实际上,编译器对这个运算的处理是:先算出两个指针指向的常变量的字面量差(不是地址差),再把这个差除以指针变量原类型去掉一个星后的类型的宽度

这里我们硬编码了一对地址值作为演示。指针减法表示两个指针指向的内存位置之间相隔多少个同类型元素(注意不是字节数)。例如对于 int 类型的指针 pp1p1-p 的意义就表示它们之间相隔着多少个 int 类型的元素。

注意最终的结果是 int 类型的。例如,我使用 %f 来输出 d-a,VS 会提示:

1
警告	C4477	“printf”: 格式字符串“%f”需要类型“double”的参数,但可变参数 1 拥有了类型“__int64”

而程序会输出 0.000000。这是很不正常的。

还有一个问题:如果两个变量的类型不一样,这个 “原类型” 是以哪一个变量为基准的?其实这是一个伪命题,因为编译器压根不允许你混用类型。硬要混用的话编译器会提示:

1
2
3
错误(活动)	E0042	操作数类型不兼容("int *""char *")
// 或者
错误(活动) E0144 "int **" 类型的值不能用于初始化 "int *" 类型的实体

那么加法和乘除法呢?

1
2
3
4
5
int* a = (int*)100;
int* d = (int*)200;
int x = d + a;
int y = d * a;
int z = d / a;
1
2
3
错误(活动)	E2138	表达式必须具有算术或未区分范围的枚举类型
错误(活动) E2138 表达式必须具有算术或未区分范围的枚举类型
错误(活动) E2138 表达式必须具有算术或未区分范围的枚举类型

抱歉,不行。


1.3.4 指针之间的关系运算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main() {
int* a = (int*)100;
int* d = (int*)200;
if (a > d)
{
printf("Bigger.\n");
}
else
{
printf("Smaller.\n");
}
return 0;
}
1
>>Smaller.

由此,我们知道同类型的指针可以进行关系运算。

但不同类型的不行:

1
2
3
4
5
6
7
8
9
10
11
int** a = (int**)100;
int* d = (int*)200;
if (a > d)
{
printf("Bigger.\n");
}
else
{
printf("Smaller.\n");
}
return 0;
1
错误(活动)	E0042	操作数类型不兼容("int **""int *")

1.4 学习使用 VS2022 的反汇编界面

在 [1.2](#1.2 指针的宽度与赋值) 中,我们使用 sizeof() 来探测数据类型的宽度。但我们还有另一种更有逆向味道的方式来探测:反汇编。

一个反汇编界面
Note

接下来的内容由 GitHub Copilot 生成。

在 Visual Studio 2022 中,你可以使用反汇编界面来查看某个数据类型的宽度。以下是具体步骤:

  1. 编写一个简单的 C++ 程序:声明你感兴趣的数据类型。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    #include <iostream>

    int main() {
    int a;
    char b;
    double c;
    std::cout << "Sizes: " << sizeof(a) << " " << sizeof(b) << " " << sizeof(c) << std::endl;
    return 0;
    }
  2. 编译并运行程序:在 Visual Studio 中打开你的项目,编译并运行程序。

  3. 设置断点:在 main 函数的开头设置一个断点。你可以通过点击行号左侧的灰色边框来设置断点。

  4. 启动调试:按 F5 启动调试模式,程序会在断点处暂停。

  5. 打开反汇编窗口:

    • 在调试模式下,选择菜单栏中的调试 > 窗口 > 反汇编。
    • 你将看到当前代码的反汇编视图。
  6. 查看数据类型的宽度:在反汇编窗口中,你可以看到汇编指令。以下是你可能会看到的内容:

    1
    2
    3
    mov     dword ptr [a], 0
    mov byte ptr [b], 0
    mov qword ptr [c], 0

    在这个示例中:

    • mov dword ptr [a], 0 表示 int 类型的宽度是 4 字节(dword)。
    • mov byte ptr [b], 0 表示 char 类型的宽度是 1 字节(byte)。
    • mov qword ptr [c], 0 表示 double 类型的宽度是 8 字节(qword)。

通过这种方式,你可以使用 Visual Studio 2022 的反汇编界面来查看不同数据类型的宽度。

Note

如果你不想使用 Visual Studio,或者所用系统不支持 VS,你也可以使用 VSCode 和 CLion 替代。


2. 指针特性进阶

2.1 类型转换

编译器无法为指针类型自动转换数据类型,必须强制类型转换。例如:

1
2
3
int* x = (int*)10;
int** y;
y = (int**)x;

2.2 & 符号的本质 —— 指针作为函数参数

我们早在读取用户输入的时候就用到了 & 符号,那时我们为了方便理解,将其解释为初始化符号。现在学了指针,我们就可以来解释这个 & 是什么东西了。

&地址运算符,任何单一变量都可以用取地址运算符来获取地址,但常量不能用。对于变量 a&a 的结果的类型就是 data_type_of_a+*。我们来试验一下:

1
2
3
4
5
6
7
#include <stdio.h>

int main() {
int* a = (int*)100;
int x = &a;
return 0;
}

提示:

1
错误(活动)	E0144	"int **" 类型的值不能用于初始化 "int" 类型的实体

* 一样,& 在不同的语境中也有不同的含义。可参见 12.7 — Introduction to pointers – Learn C++


接下来我们来探究 & 的本质。

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
int* a = (int*)100;
int** x = &a;
printf("%p\n", x);
return 0;
}

在调试时,将程序转到反汇编界面。定位到 int** x = &a,你会看到这行代码对应的汇编代码:

1
2
lea     rax,[a]
mov qword ptr [x],rax

lea取地址的指令,mov移动指令。这两行汇编代码的意思是:获取变量 a 的地址,将其存储在寄存器 rax 中;然后将 rax 中的地址移动到 qword 类型变量 x 中。

所以 & 的本质就是 lea,作用就是获取变量的地址。

所以你知道为什么 scanf() 函数要用 & 符号了吗

While scanning the input, scanf needs to store that input data somewhere. To store this input data, scanf needs to known the memory location of a variable. And here comes the ampersand(&) to rescue.

——scanf in C - GeeksforGeeks

& 可以让 scanf 知道在哪里存储数据。


以下由 FittenCode 生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>为什么scanf()要用到&符号?

在C语言中,scanf()函数用于从标准输入读取数据并存储到变量中。scanf()函数的第一个参数是格式控制字符串,用于指定输入数据的类型和格式,后面的参数是一个或多个变量的地址,scanf()需要将读取到的数据存储到这些变量中。

使用&符号是因为在C语言中,变量名表示的是变量的值,而&符号表示取地址运算符,它返回变量在内存中的地址。因此,当你使用&n时,你实际上是传递了变量n的地址,这样scanf()就能够直接在这个内存地址上存储读取到的值。如果不使用&符号,scanf()将会尝试将数据存入一个错误的地址,从而导致程序出现错误或未定义行为。

简单来说,&符号确保scanf()能够把输入的数据存储到正确的变量中。

>>那scanf不能直接使用变量名来存储值吗?就像一般的赋值那样?

在C语言中,变量名和它的值是有区别的。变量名代表的是存储在内存中的具体值,而scanf()需要接收这个变量的内存地址,以便能够修改该地址存储的内容。因此,不能直接使用变量名来存储值,因为这样做无法告诉scanf()要在哪个内存地址上进行写入。

例如,赋值操作是将一个值直接赋给一个变量,如:
n = 5;

这时n的值被直接设置为5。但在scanf()中,它需要知道要将用户输入的值放到n的内存地址中,因此必须使用&n,以便scanf()可以访问并修改n的内容。

总结来说,scanf()需要使用取地址符&来获取变量的地址,以实现输入值的存储,而赋值操作则是直接操作变量的值。

scanf 是一个函数,我们将指针作为一个参数传入函数,有什么用?不妨先来看两个用于交换变量的函数:

1
2
3
4
5
void swap(int x, int y){
int t = x;
x = y;
y = t;
}
1
2
3
4
5
void swap(int* x, int* y){
int t = *x;
*x = *y;
*y = t;
}

挺相似的,对不对?可是第一个函数在程序中根本不起作用,如果你还记得 18.5 的内容,应该知道这是因为函数中的形参不会影响实参而造成的。然而第二个函数在程序中却能忠实地履行它的任务,函数中的修改对调用方形成了影响。

这种传递方式称为按地址传递(Call by Address,OR Pass by Address,OR Pass by Pointer)。我们将指针传递进函数,就是把实参的地址传递给函数形参,这样形参就与实参能指向同一个内存位置。形参可以通过指针(地址)直接访问并修改对应的实参的值。由于是直接访问数据,因此避免了复制实参的环节,可以提高程序的时空效率。

下面的两张图解释了按地址传递的工作方式:

(来源:Function Call by Reference in C - tutorialspoint.com

Note

此处为了兼容 C++ 的知识体系,将 “Reference” 改为 “Address”。

参考这一位的建议:

C 语言没有显性的引用类型。在 C 语言中,“引用” 可以理解为指针。

——difference between pointer and reference in c? - Stack Overflow


在一些学者看来,“按地址传递” 本质上仍是 “按值传递”,只不过复制的是变量的地址,而不是它存储的内容。例如,在《C 和指针》(第二版)中有这样的叙述:

C 函数的所有参数均以 “传值调用” 方式进行传递,这意味着函数将获得参数值的一份拷贝。…… 参数(指针)实际上是一份拷贝,但在这份拷贝上执行间接访问操作所访问的是原先的数组(注:这里使用数组名作为指针作为例子)。…… 传递给函数的数组参数在行为上就像是它们是通过传址调用的那样。


2.3 通过指针访问数据

我们可以从变量那里获取地址,那么可不可以按图索骥,用地址来找对应变量的值呢?

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main() {
int x = 10;
int* y = &x;
int ret = *y;
printf("%d\n", ret);
return 0;
}

第 6 行对应的汇编代码:

1
2
3
mov         rax,qword ptr [y]
mov eax,dword ptr [rax]
mov dword ptr [ret],eax

第 2 行汇编代码的效果就是将 rax 中的地址作为一个编号,从编号获取到对应的值后,将这个存储到寄存器 eax 中。

因此我们需要用指针来获取值时,可以在指向目标值地址的指针变量前加上 ***,表示取值 ** 操作(专业点叫 “间接引用” 或 “解引用指针”)。粗略理解的话,对于 int* a*a 的结果的类型就是给原类型砍掉一颗 *(变成 int)

(来源:12.7 — Introduction to pointers – Learn C++

对于之后的内存示意图,如果指向外框,说明指向内存地址;如果指向内容,说明指向内存存储内容。

另外说一嘴,这个 * 是不能 “欠” 的。也就是说,对于类型不带星的变量,不能使用 * 进行间接引用

Tip

VS2022 可以直接查看内存中变量对应的内存地址和内容。也可以查看使用中的寄存器的内容

查看内存:

  • 在调试器工具栏中,选择 “Debug” > “Windows” > “Memory” > “Memory 1”(调试 > 窗口 > 内存 > 内存 1)。
  • 在内存窗口中,输入你想查看的内存地址。例如,如果你想查看变量 x 的内存地址,可以输入 &x 的地址。

查看使用中的寄存器:

  • 打开 “自动” 窗口
  • 在调试器工具栏中,选择 “Debug” > “Windows” > “Autos”(调试 > 窗口 > 自动)。
  • 你将看到一个窗口,显示当前和最近执行的代码行中使用的寄存器、变量和表达式的值。

2.4 用指针操作一维数组

之前我们讨论的是基本数据类型变量的指针。现在我们来讲数组元素的指针。

数组实际上是几个同类型变量顶着同一个名字。因此数组元素的指针和一般的指针没有什么区别:

1
2
int a[5] = {1, 2, 3, 4, 5};
int* x = &a[0];

那为什么还要讲?因为数组元素的指针还有这个骚操作:

1
2
int a[4] = {114, 514, 1919, 810};
int* x = a;

我们没有写 &,没有具体指定数组中的元素,程序也可以正常获取数组的地址!再看看汇编代码:

1
2
lea         rax,[a]
mov qword ptr [x],rax

也很正常啊!究竟是怎么一回事?


我们先来考虑一下没有指定数组某一元素时,int* x = a 返回来的地址是哪个元素的地址。

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main() {
int a[5] = { 1,2,3,4,5 };
int* x = a;
for (int i = 0; i < 5; i++) {
printf("%x\n", &a[i]);
}
printf("\n");
printf("%x\n", x);
return 0;
}
1
2
3
4
5
6
7
>>effbfc48//a[0]
effbfc4c//a[1]
effbfc50//a[2]
effbfc54//a[3]
effbfc58//a[4]

effbfc48//x

可见,返回来的是数组第一个元素 a[0] 的地址。


为什么不用 & 来获取一个数组的地址?我们来试试用上 & 会怎么样。

1
2
int a[4] = {114, 514, 1919, 810};
int* x = &a;
1
错误(活动)	E0144	"int (*)[4]" 类型的值不能用于初始化 "int *" 类型的实体

报错了!为什么?

编译器将 &a 转换为一个数组指针。数组指针,顾名思义,这个指针指向的是整个数组,或者更准确地说,是整个数组的首地址。这和我们前面说的数组首元素的地址一样,但意义有所出入。具体的等到后面再讲。

小结一下,数组名 a 在表达式中可以当作指针(但真身不是指针!),表示且仅表示数组首元素的地址,相当于 &a[0];而 &a 在数值上等于 a&a[0],但含义上是整个数组所占内存的大小,因为它的单位长度是整个数组的字节长度。

有关数组和指针的澄清说明

参考:

解析:数组名 a、数组名取地址 & a、数组首地址 & a [0]、数组指针 * p-CSDN 博客

C 指向数组的指针 | 菜鸟教程 (runoob.com)

有些教程将数组名和指针画等号,这是不严谨的。实际上,数组名并不是指针,是编译器隐式地将数组名转换成指针类型,这个过程也叫衰减。“衰减” 一词意味着类型和维度的丧失,一个后果是当我们通过指针传递数组进入函数时,sizeof 失去了计算出数组大小的能力,这使得我们必须额外提供数组长度。另一个后果是我们无法直接将多维数组传递给函数,维度的丧失使得传入函数的数组均被视为一维数组(虽然大部分情况下没有什么使用上的问题)。

可参考这篇知乎回答:c 中,数组名跟指针有区别吗? - 知乎

有这么一条规则,在今天的 C 语言中仍然存在:在表达式中,数组类型的值会被转化成指向数组首元素的指针。

——Chistory

也可参考 c - Is an array name a pointer? - Stack Overflowpointers - How come an array’s address is equal to its value in C? - Stack Overflowc++ - What is array-to-pointer conversion aka. decay? - Stack Overflow

请牢记数组名和指针之间的一个差异:指针是变量,而数组名不是变量。

——The C Programming Language(著名的 K&R)

如果需要保留维度信息,需要在传入函数时在参数里提供:

1
2
3
4
5
6
7
8
9
void assign(int m, int n, int arr[m][n])
{
for (int i = 0; i < m; i++)
{
for (int j = 0; j < n; j++) {
arr[i][j] = i + j;
}
}
}

来源:Pass 2D array to a function as a parameter in C | Techie Delight

另外,根据本节课 2.2 和 2.4 的知识,你应该可以回答 19.4 给出的最后一问。

基于这个特性,我们甚至可以用指针 x 代替数组名 a。在定义 type* x = a 后,x[i]a[i] 是等效的。

大部分情况下,在表达式中使用数组名称时,数组名都会自动转换为指向数组中第 1 项的指针常量。(三种例外分别是:sizeof(a)&a 和字符串)

——C99 6.3.2.1/3 - Other operands - Lvalues, arrays, and function designators


现在我们本节内容和 2.3 结合起来,尝试通过指针来遍历输出数组。

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main() {
int a[5] = { 1,2,3,4,5 };
for (int i = 0; i < 5; i++) {
printf("%x\n", *(a + i));
}
return 0;
}

在这个遍历中,*(a + i)等价于a[i]。为什么?

我们之前已经说过,数组中的元素是存储在连续的内存位置上的。也就是说,只要获取到数组首元素的地址,就变相获取了数组中其他元素的地址。数组名相当于数组首元素的指针,再结合指针与整数的增减运算法则,我们就可以得出这个结论。

基于此,我们也可以倒序输出数组:

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main() {
int a[5] = { 1,2,3,4,5 };
for (int i = 4; i >= 0; i--) {
printf("%x\n", *(a + i));
}
return 0;
}
下标起点为 0 的答案

现在你知道为什么计算机以 0 作为下标起点了吧。因为下标就是相对于数组基地址的偏移量。如果要读取数组首元素,偏移量肯定就是 0 了。


3. 实战:模拟 CE 搜索整型数据

CE(Cheat Engine)是一款游戏修改工具,可以在 Cheat Engine 下载。它主要的用途是搜索并修改内存数据。我们现在要用指针来模拟 CE 搜索数据的过程。

  • 问题描述:现在我们有一个小游戏的内存数据(如下),已知这些数据中存储了玩家的血量信息,假设血量的存储类型为 int,数值为 100(十进制。十六进制下为 0x64),要求从这些数据中找出可能代表血量的数据及其地址。

    需要注意的是这些数据所能实际对应值的个数视值的数据类型而定。比如:如果目标数据按 char 存储,则一个字节 0x00 就代表这个数据;如果按 int 存储,则四个字节 0x00,0x01,0x02,0x03 才代表这个数据。

1
2
3
4
5
6
7
8
9
10
0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00

为了模拟计算机以字节为单位存储数据的性质,我们用 char 类型数组来存储这些数据。

我们先搜索一个字节(搜索 char)做做热身。

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main() {
//在此省略定义数组data的环节
for (int i = 0; i < (sizeof(data) / sizeof(data[0])); i++) {
char* p = (char*)data;//获取首个char值的地址
if (*(p + i) == 0x64) {//*name 表示按指针取值
printf("Value = 0x%x, Address = %x\n", *(p + i), (p + i));
}
}
return 0;
}

既然四个字节代表一个 int,那我四个四个字节查过去不就行了?

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main() {
//在此省略定义数组data的环节
for (int i = 0; i < (sizeof(data) / sizeof(data[0])) / 4; i++) {
int* p = (int*)data;//获取首个int值的地址
if (*(p + i) == 0x64) {//*name 表示按指针取值
printf("Value = 0x%x, Address = %x\n", *(p + i), (p + i));
}
}
return 0;
}

输出是空白!怎么会没有血量数据!

这是因为这个程序有好几个 bug:

  • 首先,我们要知道我们从这个 data 数组读取到的 int 值究竟是什么值。由于 int 占 4 个字节,数组内一个元素占 1 字节,因此程序按 4 个元素为一组读取数据。我们读取到的其实是 03020100,也就是地址值高的作为值的高位,地址值低的作为值的低位(或者在四个字节内倒过来从右往左读)。

  • 03020100 肯定不等于 64,那么程序会接着读取 0x04,0x05,0x06,0x07…… 等等!你怎么敢假定我给的数据一定是从 0x00 开头的?假如 0x01,0x02,0x03,0x04 才是一个有效的 int 值,而 0x00 只是一个误入的字节,阁下要如何应对?所以我们要的是一个一个字节地挪读取 int 的 “区域”,而不是四个四个地挪。什么东西是按 1 个单位步进的?循环计数器 i!这个 i 我们可以用作数组下标,调整开始读取 int 的初始位置:

于是我们有了改良版的程序:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main() {
//在此省略定义数组data的环节
for (int i = 0; i < sizeof(data) / sizeof(data[0]) - 3; i++) {
int* p = (int*)&data[i];//获取要读取区域的首地址
if (*p == 0x64){
printf("Value = %x, Address = %x\n", *p, p);
}
}
return 0;
}

4. 字符串

4.1 字符数组和字符串

4.1.1 字符串是什么?

C 语言提供了字符类型,但没有提供字符串类型;字符类型只能表示一个字符,那么一串字符是怎么存储的?

Warning

不要用字符类型存储整个字符串。如果这么做,它只会存储最后一个字符

——C Characters (w3schools.com)

想一想,我们要用一个变量名存储很多数据,要用什么数据类型?数组!字符串本质上就是数个字符的集合,因此我们可以用一个字符数组(定义为字符类型的数组)来表示字符串。但与单纯的数组不同,字符串还需要以一个空 (null) 终止字符 \0 结尾


4.1.2 字符串的声明

字符串的声明和一维数组的声明基本相同:

1
char string_name[size];

(来源:String and Character Arrays in C Language | Studytonight

如果要用一个变量名存储多个字符串(字符串数组),应声明为:

1
char variable_name[r][m] = {list of string};

在这里:

  • r 是字符串数组中可以存储的字符串值的最大数量。(可选)
  • m 是每个字符串数组中可以存储的最大字符值数。(必填)

(来源:Array of Strings in C - GeeksforGeeks


4.1.3 字符串的初始化

字符串的初始化总的分为两类:

  • 1. 用字符串初始化

    1
    2
    3
    char str[] = "Hello world!";//没有预定义大小

    char str[20] = "Hello world!";//预定义大小

    与一般数组不同的是,我们可以直接用字面量来初始化字符串,而不需要 {}。以这种方式初始化的字符串存放在全局变量区,可以使用反汇编获得

    Warning

    由于字符串有结尾的额外的 \0,因此我们需要在预定义大小时考虑这个字符。预定义字符数组大小时,请确保这个大小大于字符串的字符数。注意空格也计入字符数。

    ——Strings in C - GeeksforGeeks

  • 2. 用字符初始化

    1
    2
    3
    char str[] = {'H', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '\0'};//没有预定义大小

    char str[12] = {'H', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '\0'};//预定义大小

    以这种方式初始化的字符串存放在栈区,无法被反汇编获取

    注意别忘了末尾的‘\0’。顺便说一句,这个空终止字符也可以直接写 0,也就是:

    1
    char str[] = {'H', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', 0};

    这也是被允许的。

    Tip

    字符串请用 "" 标识,单个字符请用'' 标示,因为使用 "" 标识的字符串会自动加上'\0' 结尾

    ——Strings in C - GeeksforGeeks


4.1.4 字符串的输出

我们可以按一般数组的处理方式,遍历字符串输出。不过 printf 已经提供了一个格式化说明符 %s 用于打印字符串,与之对应的参数应该是一个字符指针。输出始于该指针所指向的字符,终止于空字符('\0'):

1
2
3
4
5
6
7
#include <stdio.h>

int main() {
char str[] = { 'A', 'B', 'C', 0 };
printf("%s\n", str);
return 0;
}
1
ABC
Tip

%s 本质上还是一个字符一个字符输出,为了实现打印整个字符串,设计为遇到 0 才停止输出。因此我们必须在字符串末尾加上一个 0 或者'\0'。否则会导致数组越界,输出意外的结果。


4.2 内存分区与字符串

参考:C/C++ 内存分区 | 编程指北

在 C 语言中,内存可以被分为多个区域,我们称之为段 (Segmentation),包括栈、堆、数据段、代码段等。这些段分别用来存储不同类型的数据。一个 C 语言程序的内存分区包括以下部分:

  • 1. 代码段(.text)

    存放程序的二进制代码。该区域只读,以防止程序在运行过程中被意外修改。

  • 2.(文字)常量区(.rodata)

    存储常量值、字符串常量和宏定义。该区域只读。

  • 3. 全局数据区,进一步细分为 “已初始化的数据”(.data)和 “未初始化的数据”(.bss)。

    这个区域的内存在程序的生命周期几乎都是全局的。该区域不是只读的,因为变量的值可以在运行时更改。在现代计算机中,.bss 内的内存会被 “清零”,即该区域内所有内存内容初始化为 0.

  • 4. 堆区(.heap)

    堆区是用于动态内存分配的区域。当内存使用完毕后,程序员需要手动释放这些内存,否则可能导致内存泄漏。

  • 5. 栈 / 堆栈区(.stack)

    栈区用于存储函数调用时的局部变量、函数参数以及返回地址。当函数调用完成后,分配给这个函数的栈空间会被自动释放。

(来源:C/C++ 内存布局 - marjo 花鱼

(来源:C/C++:内存分配,详解内存分布 - CSDN 博客

(来源:Virtual Memory: Concepts | CMU 15-213

Note

以上内存分区均是在操作系统 (Linux) 中的具体实现,在语言标准中没有这样的规定。关于具体实现和语言规则之间的区别的讨论可以去看 C 的内存分区 (Lesson 25),或者 Stroustrup 的 * Programing Principles and Practice Using C*。


左上图说明了部分内存区域的可读写性。为什么会提到这个东西?因为我们会发现有的字符串允许修改内容,但有的字符串就不行。接下来的例子展示了这一点:

1
2
3
4
5
6
#include <stdio.h>
char x[] = "China";//Note: use global variable
int main() {
x[0] = 'c'; //Note: use single quotes to assign a char
printf("%s\n", x);
}
1
2
3
4
5
6
7
#include <stdio.h>
char* x = "China";
int main() {
*x = 'c';
int i = 0;
printf("%s\n", x)
}

理论上这两个程序的效果是一样的,都是将 “China” 的首字母改成 “c”。接下来看看它们的运行结果:

1
china

第一个程序运行正常。

但第二个程序就出问题了。在我的 VS2022 中,程序直接无法生成,显示:

1
2
3
错误(活动)	E0144	"const char *" 类型的值不能用于初始化 "char *" 类型的实体

错误 C2440 “初始化”: 无法从“const char [6]”转换为“char *”

后面改用 LLVM(Clang-cl)生成,编译通过,输出警告:ISO C++11 does not allow conversion from string literal to “char *”。调试时输出:“引发了异常:写入访问权限冲突。x 是 0x7FF74AA24000。”

之后转到 VSCode,使用预先配置好的 GDB 调试,调试器提示:

1
Thread 1 received signal SIGSEGV, Segmentation fault.

这个提示就相当于教程中 VC6 给出的 Unhandled exception in Test.exe:0xC0000005:Access Violation


出现这种现象的根源就是内存区域的可读写性不同。我们可以随意地改变指针存储的地址,但我们不能更改它指向的字符串的内容,就是因为指针被存储在一个可读写的内存区域,而字符串被存储在一个只读的内存区域。

因此严格来讲,char* x = "China" 应该加上 const 属性,但由于历史原因,编译器也接受前一个简化写法。

还记得本节开头展示的左上角的那张图吗?图中说明了字符串常量被存储在常量区中,而常量区正好是一个只读区域

那第一个程序是什么情况?其实它的字符串本来也是放在常量区的,但是编译器把这个字符串拷贝到了栈区,之后的操作就在这个复制后的字符串上进行。栈区是一个可读写的区域,因此我们的操作是允许的。我们通过汇编来看看是怎么一回事:

1
2
3
4
mov         eax,dword ptr [memcpy+0C40h (07FF6EE9A4000h)]
mov dword ptr [x],eax
mov ax,word ptr [memcpy+0C44h (07FF6EE9A4004h)]
mov word ptr [rsp+2Eh],ax
  • 编译器先从地址 07FF6EE9A4000h 处读取 Chin(对应的十六进制值是 6E696843),将值拷贝到寄存器 eax 中,再将 eax 中的值赋给 x
  • 但还有一个 a 没读取到啊!别急,编译器已经考虑到这一点了。它会接着从地址 07FF6EE9A4004h 处读取 a(对应的十六进制值为 61),然后将值赋给 ax,再将 ax 中的值赋给之前 x 后面的一个字节,和 Chin 接上。

4.3 指针函数

指针函数本质还是一个函数,只是它的返回类型为指针。

声明:

1
data_type* name(data_type var1, data_type var2, ...);

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int* f(int a, int b); // 声明指针函数

int main(void)
{
printf("------------------------------ Start\n");

int p1 = NULL;
printf("The memory address of p1 = 0x%x \n", p1);

p1 = f(1, 2);

printf("The memory address of p1 = 0x%x \n", p1);
printf("*p1 = %d \n", *p1);

printf("------------------------------ End\n");
getchar();
return 0;
}

/*指针函数的定义,返回值是指针类型int */
int* f(int a, int b)
{
int* p = (int*)malloc(sizeof(int));
printf("The memory address of p = 0x%x \n", p);
memset(p, 0, sizeof(int));
*p = a + b;
printf("*p = %d \n", *p);

return p;
}
1
2
3
4
5
6
------------------------------ Start
The memory address of p1 = 0x0
The memory address of p = 0xd977c8
*p = 3
The memory address of p1 = 0xd977c8
*p1 = 3

4.4 常见的字符串操作

在这一节,我们不仅要了解常用的字符串操作,还要自己动手实现这些操作。这是为了更好地理解字符串。如果你不想自己写,你也可以 includestring.h 头文件。这个库定义了一系列处理字符串的函数。实际上,你真要用的话肯定也是用正规的库函数,而不是自己手搓的野生函数。

以下函数的声明与用途来自于:C 库函数 – strcpy () | 菜鸟教程 (runoob.com)

开始吧!


4.4.1 获取字符串长度

这个操作在 string.h 中的声明是:

1
size_t strlen(const char* str);

它用于计算字符串 str 的长度,直到空结束字符,但不包括空结束字符

size_t 是什么

size_t 是许多标准库提供的专门用于表示数据对象宽度大小的数据类型。它保证能够表示主系统可以处理的最大的对象宽度,通常取决于编译器的位数:对于 32 位编译器,等价于 unsigned int;对于 64 位编译器,等价于 unsigned long long

相较于其他整数类型,size_t 更为高效。

——What is the size_t data type in C? - GeeksforGeeks

实现:

1
2
3
4
5
6
7
8
size_t strlen(char* str){
int i = 0;
while (*str != '\0'){
i++;
*str++//读取下一个字节(因为是char类型)
}
return i;
}

说明一下,这里 *str++ 有运算顺序的问题。间接访问符 * 的优先级比递增递减符的低,因此 *str++ 实际执行的是 *(str++),即先读取下一个字节的指针,再间接访问。

那么,对于 *str += 1 呢?间接访问符 * 的优先级+= 的要高,因此 *str += 1 实际执行的是 (*str) += 1

参考:pointers - Difference between *ptr += 1 and *ptr++ in C - Stack Overflow


4.4.2 复制字符串

这个操作在 string.h 的声明是:

1
char* strcpy(char* dest, const char* src)

它把指针 src 所指向的字符串复制到指针 **dest** 所指向的用于存储复制内容的目标数组,同时返回一个指向最终的目标字符串 dest 的指针。需要注意的是如果目标数组 dest 不够大,而源字符串的长度又太长,可能会造成缓冲溢出的情况。

实现:

1
2
3
4
5
char* strcpy(char* dest, const char* src){
char* ret = dest;//获取dest的指针,最后要返回
while ((*dest++) = (*src++));
return ret;
}

(*dest++) = (*src++) 不太好理解,我们着重说明一下:

  • 首先,获取 *src(被复制字符串首位)的值,将值赋给 *dest(复制字符串首位)。在没有终止前,这个表达式的值不等于 0,因此 while 循环不会跳出。
  • 此时,指针已经移动一个字节,进入下一个位置的复制。
  • 字符串复制到 0(终止符)时,表达式的值为 0while 循环跳出,复制结束。注意虽然最后 src 访问到了一个不属于字符串的地址,但由于循环跳出,这个不合理的地址就被丢弃掉了,它没有参与运算。

还有一个比较友好的写法:

1
2
3
4
5
6
7
8
9
char* strcpy(char* dest, const char* src){
while (*src != '\0'){
*dest = *src;
src++;
dest++;
}
*dest = '\0';
return dest;
}

最后 *src 读取到 \0 时,停止复制,然后我们自己给 *dest 加个 \0 结束。


4.4.3 字符串 “加法”

简单来说就是将两个字符串首尾相接。这个操作在 string.h 的声明为:

1
char *strcat(char *dest, const char *src)

它把指针 **src** 所指向的字符串追加到指针 dest 所指向的字符串的结尾(覆盖掉 *dest 末尾的 \0),并返回一个指向最终的目标字符串 dest 的指针。注意指针 dest 所指向的数组应包含一个 C 字符串,且足够容纳追加后的字符串。

实现:

1
2
3
4
5
6
7
8
9
10
char *strcat(char *dest, const char *src){
char* ret = dest;
//获取目标字符串的结尾
while (*dest != '\0'){
dest++;
}
//复制操作
while ((*dest++) = (*src++));
return ret;
}

不要学教程中那样不为 dest 指向的数组预先指定充裕的大小。

1
2
3
char arr[] = "China";
char arr2[] = "xyz";
printf("%s\n", strcat(arr[], arr2[]));

否则你会收获:

1
0x00007FF642A6141D 处有未经处理的异常(在 <your program>.exe 中): 堆栈 Cookie 检测代码检测到基于堆栈的缓冲区溢出。

程序也可以输出,但不能正常退出:

1
2
3
Chinaxyz

(进程 57816)已退出,代码为 -1 (0xffffffff)

4.4.4 比较整个字符串

这个操作在 string.h 的声明为:

1
int strcmp(const char *str1, const char *str2)

它将指针 str1 所指向的字符串和指针 str2 所指向的字符串进行比较。

原函数的返回值分为三种情况:

  • srt1 == str2:返回 0
  • str1 > str2:返回 1
  • str1 < str2:返回 - 1

两个字符串自左向右逐个字符相比(按 ASCII 值大小相比较),直到出现不同的字符或遇 '\0' 为止。如:

1
2
3
4
5
"A"<"B"
"A"<"AB"
"Apple"<"Banana"
"A"<"a"
"compare"<"computer"

ANSI 标准规定,返回值为正数,负数,0 。而确切数值是依赖不同的 C 语言编译器实现的。也就是说,当两个字符串不相等时,C 标准没有规定返回值会是 1 或 -1,只规定了正数和负数

——C 库函数 – strcmp () | 菜鸟教程

我们这里实现的要简单一点:两个字符串相同,返回 0;不同,返回 1。

这和 bool 类型的定义不一样。因此有人在 if 判断的条件中使用 strcmp,会再使用 ! 运算符:

1
2
3
if (!strcmp(str1, str2)){
//statement
}

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int strcmp(const char *str1, const char *str2){
int ret = 0;
while ((*str1) != '\0' || (*str2) != '\0'){
if ((*str1) != (*str2)){
ret = 1;
break;
}
else{
str1++;
str2++;//srt1和str2都读取下一位
}
}
return ret;
}

4.4.5 实战:模拟 CE 搜索字符串

  • 问题描述:现在我们又有一个小游戏的内存数据(如下),已知这些数据中存储了某角色的姓名信息(这角色的名字叫 WOW),要求从这些数据中找出可能代表该角色姓名的字符串及其地址。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
    0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
    0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
    0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
    0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
    0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
    0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
    0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
    0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
    0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00

    这和第 3 章的搜索 int 类型数据基本没差。唯一的区别是你要先了解 [4.4.4](#4.4.4 比较整个字符串)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    #include <stdio.h>

    int strcmp(const char* str1, const char* str2) {
    int ret = 0;
    while ((*str1) != '\0') {
    if ((*str1) != (*str2)) {
    ret = 1;
    break;
    }
    else {
    str1++;
    str2++;
    }
    }
    return ret;
    }

    int main() {
    unsigned char data[] = {
    0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
    0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
    0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
    0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
    0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
    0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
    0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
    0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
    0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
    0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
    };
    for (int i = 0; i < (sizeof(data) / sizeof(data[0]) - 3); i++) {
    if (strcmp((char*)&data[i], "WOW") == 0){//我们的函数可以做到提供首地址后,自动向后比较
    printf("Value=%d, Address=%x\n", *(int*)&data[i], &data[i]);
    }
    }
    }

5. 指针数组

5.1 指针数组的定义

指针数组就是存储指针的数组,它符合数组的属性:

(来源:Array of Pointers in C - GeeksforGeeks

数组的属性有:固定大小、元素类型相同、连续存储等。
既然指针代表地址,那我们可以把指针数组看作一个专门存储同一类地点的地址簿。

以下是指针数组的声明:

1
pointer_type* array_name [array_size];

5.2 指针数组的赋值

指针数组只能存储对应类型的指针。

1
2
3
int* arr[10]  = {0};
int a = 10;
arr[1] = &a;//左边:int*; 右边int*, 类型相同,就不用强制转换了。

通过汇编代码,我们可以看看指针数组具体是怎么赋值的:

1
2
lea         rax,[a]
mov qword ptr [arr],rax

先取 a 的地址放到 rax 中,再将 rax 中的地址放到 arr 中。


5.3 指针数组的宽度

我们在 1.2 说过,在 64 位开发环境下,所有指针类型都占用 8 字节。那么数组指针的宽度就等于 8× 数组大小 8× 数组大小


5.4 指针数组的输出

在实践中,我们常用指向字符串的指针数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main() {
char* arr[] = {
"gfg",
"geek",
"Geek",
"Geeks",
"GeeksforGeeks"
};

for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++) {
printf("%s\n", arr[i]);
}
}

数组中的元素是这样工作的:

(来源:Array of Pointers in C - GeeksforGeeks

数组的元素不是字符串,而是每个字符串对应的首地址


6. 结构体

对结构体的介绍来自:C 结构体 | 菜鸟教程 (runoob.com)C Structures - GeeksforGeeks

6.1 结构体是什么?

数组只能存储同类型数据。处于互补目的,C 语言提供了结构体这一数据类型。结构体是一种用户自定义的可用的数据类型,它允许您存储不同类型的数据项。结构体中的数据成员可以是基本数据类型(如 intfloatchar 等),也可以是其他结构体类型、指针类型等。


6.2 结构体的定义

结构体定义由关键字 struct 和结构体名 tag 组成,结构体名可以根据需要自行定义。以下是结构体的定义方式:

1
2
3
4
5
6
struct tag {
member-list
member-list
member-list
...
} variable-list ;
  • tag 是结构体名。

  • member-list 是标准的变量定义,比如 int i;float f; 或者其他有效的变量定义。我们称之为数据成员

  • variable-list 是结构体变量,定义在结构体的末尾,最后一个分号之前。可以指定一个或多个结构变量。

tagmember-listvariable-list 这 3 部分至少要出现 2 个


6.3 结构体的初始化

和其它类型变量一样,对结构体变量可以在定义时指定初始值。

1
2
3
4
5
6
7
8
9
struct Books
{
char title[50];
char author[50];
char subject[100];
int book_id;
}/* book = {"C 语言", "RUNOOB", "编程语言", 123456} */;

struct Books book = {"C 语言", "RUNOOB", "编程语言", 123456};
Warning

不允许结构体在声明时对其成员初始化。例如,下面的 C 程序就无法通过编译:

1
2
3
4
5
struct Point
{
int x = 0; // COMPILER ERROR: cannot initialize members here
int y = 0; // COMPILER ERROR: cannot initialize members here
};

原因很简单:编译器不会给一个数据类型分配内存,而是给一个变量分配内存。

——C Structures - GeeksforGeeks

上面的方法叫做初始化列表 (Initializer List)。初始化列表需要遵循结构体内成员定义的顺序。

你也可以用普通的赋值方式初始化每一个数据成员:

1
2
3
4
5
6
7
struct structure_name str;
str.member1 = value1;
str.member2 = value2;
str.member3 = value3;
.
.
.

6.4 使用结构体定义变量

作为一种数据类型,结构体可以用来定义变量,就像 int 一样。

1
2
3
4
5
6
7
8
9
10
11
12
struct Books
{
char title[50];
char author[50];
char subject[100];
int book_id;
};
int main(void){
struct Books Book1; /* 声明 Book1,类型为 Books */
struct Books Book2; /* 声明 Book2,类型为 Books */
//...
}

6.5 结构体数据成员的访问

为了访问结构的成员,我们使用成员访问运算符 (.)。成员访问运算符是结构变量名称和我们要访问的结构成员之间的一个点号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct Books
{
char title[50];
char author[50];
char subject[100];
int book_id;
};

int main( )
{
struct Books Book1; /* 声明 Book1,类型为 Books */
struct Books Book2; /* 声明 Book2,类型为 Books */

/* Book1 详述 */
strcpy( Book1.title, "C Programming");
strcpy( Book1.author, "Nuha Ali");
strcpy( Book1.subject, "C Programming Tutorial");
Book1.book_id = 6495407;

/* Book2 详述 */
strcpy( Book2.title, "Telecom Billing");
strcpy( Book2.author, "Zara Ali");
strcpy( Book2.subject, "Telecom Billing Tutorial");
Book2.book_id = 6495700;

/* 输出 Book1 信息 */
printf( "Book 1 title : %s\n", Book1.title);
printf( "Book 1 author : %s\n", Book1.author);
printf( "Book 1 subject : %s\n", Book1.subject);
printf( "Book 1 book_id : %d\n", Book1.book_id);

/* 输出 Book2 信息 */
printf( "Book 2 title : %s\n", Book2.title);
printf( "Book 2 author : %s\n", Book2.author);
printf( "Book 2 subject : %s\n", Book2.subject);
printf( "Book 2 book_id : %d\n", Book2.book_id);

return 0;
}

6.6 结构体指针

像其他类型变量一样,我们也可以定义指向结构体类型变量的指针。

1
2
3
4
5
6
7
struct Point{
int x;
int y;
};

struct Point str = { 1, 2 };
struct Point* ptr = &str;

对结构体指针的间接访问,指向的是结构体的首个数据成员

1
printf("%d", *ptr);
1
1

如果通过指针来间接访问数据成员,需要将间接访问操作用括号括起来,因为成员访问的优先级高于间接访问。例如:

1
(*ptr).y;

也可以使用箭头运算符 (->),不需要括号:

1
2
3
4
struct Point str = { 1, 2 };
struct Point* ptr = &str;
ptr->x = 3;
printf("%d %d", ptr->x, ptr->y);

箭头运算符可以和结构体指针的间接访问互换,也就是说:

ptr->x(*ptr).x等价的。


6.7 结构体指针的性质

6.7.1 宽度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

struct Person {
char name[20];
int age;
float height;
};

int main() {
struct Person person;
struct Person* person_pointer = &person;
printf("结构体 Person 大小为: %zu 字节\n", sizeof(person));
printf("结构体指针 *Person 的大小为:%zu 字节\n", sizeof(person_pointer));
return 0;
}
1
2
结构体 Person 大小为: 28 字节
结构体指针 *Person 的大小为:8 字节

再次验证那句话:所有指针类型的宽度都是 8 字节(64bit)。


6.7.2 递增递减

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

struct Person {
char name[20];
int age;
float height;
};

int main() {
struct Person person = {"Tony", 24, 180.0};
struct Person* person_pointer = &person;
printf("结构体指针 *Person 的值为 %x\n", person_pointer);
struct Person* person_pointer1 = person_pointer + 1;
printf("结构体指针 *Person++ 的值为 %x\n", person_pointer1);
struct Person* person_pointer2 = person_pointer - 1;
printf("结构体指针 *Person-- 的值为 %x\n", person_pointer2);
return 0;
}
1
2
3
结构体指针 *Person 的值为 e48ff744
结构体指针 *Person++ 的值为 e48ff760
结构体指针 *Person-- 的值为 e48ff728

我们发现,结构体指针递增递减的不是数据成员类型宽度,而是结构体本身的大小。我们在 6.7.1 计算出了结构体的大小为 28,而这里递增递减的值正好也是 28(十进制)。

Tip

结构体的大小为结构体最宽基本类型数据成员宽度的整数倍


6.7.3 与整数的加减

有了 1.3.2 和 6.7.2 的基础,你心里应该隐隐有了结论。我们还是先来试验一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

struct Person {
char name[20];
int age;
float height;
};

int main() {
struct Person person = {"Tony", 24, 180.0};
struct Person* person_pointer = &person;
printf("结构体指针 *Person 的值为 %x\n", person_pointer);
struct Person* person_pointer1 = person_pointer + 3;
printf("结构体指针 *Person+3 的值为 %x\n", person_pointer1);
struct Person* person_pointer2 = person_pointer - 3;
printf("结构体指针 *Person-3 的值为 %x\n", person_pointer2);
return 0;
}
1
2
3
结构体指针 *Person 的值为 64b0f864
结构体指针 *Person+3 的值为 64b0f8b8
结构体指针 *Person-3 的值为 64b0f810

差值是 84!正好是 3×283×28!因此结构体指针的单位长度是由结构体本身的大小所决定的。这是因为我们把结构体作为一种类型来用,就和 intchar 是一个东西。


6.7.4 结构体指针之间的减法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

struct data {
int a;
int b;
int c;
};

int main(){
data a = { 1, 2, 3 };
data b = { 114, 514, 1919 };
data* pointer = &a;
data* pointer2 = &b;
int differ = pointer - pointer2;
printf("the size of structer 'data' is %d\n", sizeof(a));
printf("Address: pointer is %x, pointer2 is %x\n", pointer, pointer2);
printf("pointer - pointer2 = %d", differ);
}
1
2
3
the size of structer 'data' is 12
Address: pointer is f34ffcc4, pointer2 is f34ffcb8
pointer - pointer2 = 1

这两个地址的差为 12(十进制),结构体的大小也是 12,12÷12=112÷12=1,符合我们之前的结论。


6.7.5 比较大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

struct data {
int a;
int b;
int c;
};

int main(){
data a = { 1, 2, 3 };
data b = { 114, 514, 1919 };
data* pointer = &a;
data* pointer2 = &b;
if (pointer > pointer2) {
printf("pointer is bigger than pointer2\n");
}
else {
printf("pointer is smaller than pointer2\n");
}
}
1
pointer is bigger than pointer2

6.8 未定义的访问行为

结构体指针指向结构体,但谁说它不能指向其他类型的数据?什么,编译器不让?强制转!转,都可以转!指针的指向是不能被定义的!(doge

其实,各类型的指针可以指向对应类型的数据,也可以不指向对应类型而指向另外不同的类型。我们说的不同类型的指针只是我们基于阅读习惯而区分开的。对于计算机而言,强制转换的事而已,存到内存里面谁认识谁啊。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

struct data {
int a;
int b;
int c;
};

int main(){
int x = 10;
data* pointer2 = (data*)&x;
printf("%d %d %d", pointer2->a, pointer2->b, pointer->c);
}
1
10 1 3

第一个数 10 我认识,后面的 1 和 3 又是何方神圣?

我们需要明白一个事实:字节之间可是不加隔板的。C 语言不会进行检查,它只会觉得:你要几个数据,行,就给你几个。反正数据都混一起,说不定它们也属于这个结构体呢(结构体成员的数据是连续存储的)!

这就是未定义的访问行为。访问行为本身没有问题,出问题的是 “未定义”,或者说,对程序员 “未定义”,我们无法预测程序行为。这是我们要避免的。

Tip

The C++ language also does not require the compiler to generate code that would blow up at runtime. In fact, your particular version of your particular compiler may, or may not, generate code that you think makes sense if you do the above. But that’s the point: since the compiler is not required to generate sensible code, you don’t know what the compiler will do.

—— 摘自 References, C++ FAQ

C 语言也一样。若代码中存在未定义的行为,编译器可能会生成你认为合理的程序,也可能不生成程序,还有可能生成乱七八糟的程序。你没法预知编译器的行为。这也是一些同学在做 PTA 上的练习题时出现的问题:有时程序第一次运行得好好的,第二次再编译运行就出问题了,然后搬到其他环境里,直接编译错误,后面 review 完才发现数组越界了。这种不可预知性甚至比错误更可怕,因为这说明你的代码无法用于需要稳定性的领域。


6.9 结构体在内存中的存储方式

结构体中元素按照定义顺序存放到内存中,但并不是紧密排列。从结构体存储的首地址开始 ,每一个元素存入内存中时,它都会认为内存是以自己的宽度来划分空间的,因此元素存放的位置一定会在自己大小的整数倍上开始。

——C 语言结构体内存分配原则 (runoob.com)

如上图所示,结构体中类型不同的数据之间会留下一些无用的空隙。即使这些空隙被填满了数据,但那些垃圾数据不是我们需要的。这个过程叫做内存对齐。内存对齐提高了计算机对结构体数据成员的存取效率,但也会造成不必要的内存浪费。

因此,我们建议,各个成员的类型最好保持一致,比如都用 char,或都用 int,不要混合使用,这样才能达到节省内存空间的目的。如果需要混合使用,可以在声明中对结构体的成员重新进行排列,让那些对边界要求最严格(类型宽度最大)的成员首先出现,边界要求最弱(类型宽度最小)的成员最后出现。这种方法可以最大程度地减少内存损失。

可以参考:CS 131/CSCI 1310: Fundamentals of Computer Systems (brown.edu)

Structure Member Alignment, Padding and Data Packing - GeeksforGeeks


6.10 实战:搜索结构体

  • 问题描述:现在我们又又有一个小游戏的内存数据(如下),已知这些数据中存储了某个玩家的等级信息,要求从这些数据中找出可能代表 id = 1, level = 8 结构体的数据的个数。

    该游戏定义的结构体如下:

    1
    2
    3
    4
    typedef struct TagPlayer{
    int id;
    int level;
    }player;

    在 C 中,我们使用 typedef 为已经存在的类型创建别名。对于结构体,我们可以定义一个新的名称来代替原始的结构体名称。不使用 typrdef 的话,在用结构体定义变量时,就需要写 struct TagPlayer x;使用后,就只需要写 player x


    不过在 6.7.4 和 6.7.5,我没用 typedef,照样写 data a; 是怎么回事?答案是:看编译器的支持情况。在 VS2022 上,Intellisense 显示 “未找到相关问题”,无论是 MSVS2022 还是 LLVM 的工具集都没有任何错误或警告,顺利生成;后换用 VSCode,Intellisense 显示有错误,GCC 和 Clang 均提示错误,但 G++ 顺利生成,没有错误与警告。再换用 Clion,IDE 未检测到错误,使用 CMake+ninja 生成,无错误消息。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
    0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
    0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
    0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
    0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
    0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
    0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
    0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
    0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
    0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    #include <stdio.h>

    unsigned char memory[] =
    {
    0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
    0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
    0x00,0x33,0x01,0x00,0x00,0x08,0x00,0x00,0x00,0x00,
    0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
    0x00,0x00,0x64,0x01,0x00,0x00,0x00,0x08,0x00,0x00,
    0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
    0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
    0x00,0x02,0x57,0x4F,0x57,0x00,0x06,0x08,0x00,0x00,
    0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
    0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
    };

    void FindMemoryForStruct()
    {
    int id = 0;
    int level = 0;
    for (int i = 0; i < sizeof(memory) / sizeof(memory[0]); i++)
    {
    if (*(memory + i) == 0x01)
    id++;
    else if (*(memory + i) == 0x08)
    level++;
    }
    if (id < level)
    printf("%d", id);
    else
    printf("%d", level);
    }

    int main()
    {
    FindMemoryForStruct();
    }
    关于原课程提供的参考作业

    ~~ ? 单字节搜索是怎么回事?数据成员不都是 int 型的吗?我目前是解释不通它的原理了 X^X。~~

    原课程提供的参考作业的正确性存疑。下面分别给出我向多个大语言模型的提问结果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    根据给定的内存数据和结构体定义,我们可以尝试找出可能代表 `id = 1, level = 8` 的结构体数据的个数。

    首先,我们需要找到可能代表 `player` 结构体的数据块。每个 `player` 结构体包含两个 `int` 类型的成员变量 `id``level`,因此每个结构体占用 8 个字节的内存空间。

    在给定的内存数据中,我们可以尝试找到满足以下条件的数据块:

    数据块长度为 8 个字节
    数据块中的前 4 个字节可能代表 `id` 值为 1
    数据块中的后 4 个字节可能代表 `level` 值为 8
    经过遍历内存数据,我们可以找到以下可能代表 `id = 1, level = 8` 的结构体数据:

    0x01, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00

    因此,从给定的内存数据中,可能代表 `id = 1, level = 8` 结构体的数据个数为 1。

    (来自 Haiku Claude 3,服务由 DuckDuckGo AI Chat 提供)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    要从给定的内存数据中找出可能代表 `id = 1``level = 8` 的结构体数据,我们需要分析内存数据并寻找符合条件的字节序列。

    根据结构体定义:
    ```c
    typedef struct TagPlayer {
    int id; // 4 bytes
    int level; // 4 bytes
    } player;
    ```
    `id``level` 都是 `int` 类型,通常占用 4 个字节。因此,一个 `player` 结构体的大小为 8 个字节。

    我们需要在内存数据中查找以下字节序列:
    - `id = 1` 的字节表示为 `0x01 0x00 0x00 0x00`(假设小端字节序)
    - `level = 8` 的字节表示为 `0x08 0x00 0x00 0x00`(假设小端字节序)

    因此,我们要查找的完整字节序列是:
    ```
    0x01 0x00 0x00 0x00 0x08 0x00 0x00 0x00
    ```

    接下来,我们在给定的内存数据中查找这个字节序列。内存数据如下:
    ```
    0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
    0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
    0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
    0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
    0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
    0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
    0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
    0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
    0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
    0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
    ```

    我们可以逐字节检查这个数据,寻找 `0x01 0x00 0x00 0x00 0x08 0x00 0x00 0x00` 的序列。

    在给定的内存数据中,我们可以找到以下匹配:
    - 从索引 60 开始的字节序列:
    ```
    0x01, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00
    ```

    因此,符合条件的结构体数据的个数为 **1**

    (来自 OpenAI GPT-4o mini,服务由 DuckDuckGo AI Chat 提供)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    根据提供的结构体定义和内存数据,我们可以尝试找到可能代表 `id = 1, level = 8` 的数据。

    结构体 `TagPlayer` 由两个 `int` 类型的成员组成:`id``level`。在大多数系统中,`int` 类型占用 4 个字节。

    我们可以从内存数据中找到可能代表 `id = 1` 的数据。由于 `id` 是第一个成员,因此我们可以从数据的开头开始搜索。我们发现 `0x01` 出现在数据的第 2 个字节处,这可能代表 `id = 1`

    接下来,我们需要找到可能代表 `level = 8` 的数据。由于 `level` 是第二个成员,因此它应该位于 `id` 之后的 4 个字节处。我们发现 `0x08` 出现在数据的第 38 个字节处,这可能代表 `level = 8`

    但是,我们需要注意到 `level` 的值可能是 16 进制的 `0x08`,而不是 10 进制的 8。因此,我们需要检查 `level` 的值是否确实是 8。

    经过检查,我们发现数据的第 38 个字节处的值确实是 `0x08`,这可能代表 `level = 8`

    因此,我们可以认为数据的第 2 个字节处的 `0x01` 和第 38 个字节处的 `0x08` 可能代表 `id = 1, level = 8` 的结构体。

    但是,我们需要注意到,这只是一个可能的解释,并不一定是唯一的解释。其他数据也可能代表相同的结构体。

    综上所述,我们可以认为数据中可能有 1 个结构体代表 `id = 1, level = 8`

    (来自 Meta Llama 3.1 70B,服务由 DuckDuckGo AI Chat 提供)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    为了找出内存数据中可能代表 id = 1, level = 8 结构体的数据个数,我们需要遍历这些内存数据,并检查每个可能的起始位置,看看是否符合 id = 1 和 level = 8 的条件。假设内存数据是以小端序存储的(即低字节在前)。
    我们可以用C语言编写一个程序来完成这个任务。以下是一个示例代码:
    ```C
    #include <stdio.h>
    #include <stdint.h>

    typedef struct TagPlayer {
    int id;
    int level;
    } player;

    int main() {
    uint8_t data[] = {
    0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x07, 0x09,
    0x00, 0x20, 0x10, 0x03, 0x03, 0x0C, 0x00, 0x00, 0x44, 0x00,
    0x00, 0x33, 0x00, 0x47, 0x0C, 0x0E, 0x00, 0x0D, 0x00, 0x11,
    0x00, 0x00, 0x00, 0x02, 0x64, 0x00, 0x00, 0x00, 0xAA, 0x00,
    0x00, 0x00, 0x64, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x02, 0x00, 0x74, 0x0F, 0x41, 0x00, 0x00, 0x00,
    0x01, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x0A, 0x00,
    0x00, 0x02, 0x74, 0x0F, 0x41, 0x00, 0x06, 0x08, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x64, 0x00, 0x0F, 0x00, 0x00, 0x0D, 0x00,
    0x00, 0x00, 0x23, 0x00, 0x00, 0x64, 0x00, 0x00, 0x64, 0x00
    };
    int dataSize = sizeof(data) / sizeof(data[0]);
    int count = 0;

    for (int i = 0; i <= dataSize - sizeof(player); i++) {
    player *p = (player *)(data + i);
    if (p->id == 1 && p->level == 8) {
    count++;
    }
    }

    printf("可能代表 `id = 1, level = 8` 结构体的数据个数: %d\n", count);
    return 0;
    }
    ```
    这个程序的工作原理如下:
    1. 定义一个 player 结构体。
    2. 将内存数据存储在一个 uint8_t 数组中。
    3. 遍历内存数据,检查每个可能的起始位置。
    4. 将当前位置的数据解释为 player 结构体,并检查 id 和 level 是否符合条件。
    5. 统计符合条件的结构体个数并输出。
    运行这个程序将会输出可能代表 id = 1, level = 8 结构体的数据个数。为了方便,我在这里直接给出结果。
    运行上述代码后,输出的结果是:
    ```cmd
    可能代表 `id = 1, level = 8` 结构体的数据个数: 1
    ```
    这意味着在给定的内存数据中,可能代表 id = 1, level = 8 结构体的数据个数是1。

    (来自 Github Copilot,服务由 Visual Studio 2022 内建 Copilot 插件提供)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    要找出内存数据中可能代表`id = 1, level = 8`的结构体数据的个数,我们可以按照以下步骤进行:

    1. 定义结构体大小:根据结构体`TagPlayer`的定义,每个结构体占用8个字节(每个`int`占用4个字节)。

    2. 遍历内存数据:我们需要遍历整个内存数据,每次读取8个字节,并检查这8个字节是否符合`id = 1, level = 8`的条件。

    3. 转换字节序:由于内存数据是以字节数组的形式给出的,我们需要将每8个字节转换为两个`int`值。假设数据是小端序(Little Endian),即低位字节在前。

    4. 检查条件:将转换后的两个`int`值与`id = 1``level = 8`进行比较,如果匹配则计数。

    以下是实现这个逻辑的代码:
    ```C
    #include <stdio.h>

    // 定义结构体
    typedef struct {
    int id;
    int level;
    } TagPlayer;

    int main() {
    // 内存数据
    unsigned char memory_data[] = {
    0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
    0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
    0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
    0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
    0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
    0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
    0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
    0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
    0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
    0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
    };

    // 目标id和level
    int target_id = 1;
    int target_level = 8;

    // 结构体大小
    int struct_size = sizeof(TagPlayer);

    // 计数器
    int count = 0;

    // 遍历内存数据
    for (int i = 0; i < sizeof(memory_data); i += struct_size) {
    // 读取8个字节并转换为结构体
    TagPlayer *player = (TagPlayer *)(memory_data + i);

    // 检查是否匹配
    if (player->id == target_id && player->level == target_level) {
    count++;
    }
    }

    printf("可能代表id = 1, level = 8的结构体数据的个数为: %d\n", count);

    return 0;
    }
    ```
    1
    2
    3
    4
    5
    运行上述代码后,输出结果为:
    ```cmd
    可能代表id = 1, level = 8的结构体数据的个数为: 1
    ```
    因此,内存数据中可能代表`id = 1, level = 8`的结构体数据有1个。

    (来自 Fitten Code Pro Search,服务由 Visual Studio Code 的 fittencode 插件提供)


7. 多级指针

7.1 多级指针的定义

多级指针和一级指针的区别,就是多个星和一个星的区别(doge

确实是这样!不管定义时给类型加了多少个星,多级指针依然是一个指针,因此它依然符合指针的特征。

n 级指针指向的是 n-1 级指针的地址(一级数组除外,指向的是具体值的地址)。

(来源:C - Pointer to Pointer (Double Pointer) - GeeksforGeeks

为什么会涉及到多层指针?多维数组指针就需要它,先按下不表。


7.2 通过多级指针访问变量

使用多级指针时,定义时有几个星,间接访问到值时也写几个星。(2.3 已经说过了)

1
2
3
4
int x = 10;
int* p1 = &x;
int** p2 = &p1;
printf("%d\n", **p2 == x);
1
1

(来源:C - Pointer to Pointer (Double Pointer) - GeeksforGeeks

Tip

间接访问的层次越多,你需要用到它的次数就越少。但是,一旦你真正理解了间接访问,无论出现多少层间接访问,你应该都能十分轻松地应付。

只有当确实需要时,你才应该使用多层间接访问。不然地话,你的程序会变得更庞大、更缓慢并且更难以维护。

——《C 和指针(第二版)》

我们比较常用的是一级指针和二级指针。


7.3 实践:使用反汇编探究多级指针特性

7.3.1 一级指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main(void)
{
int x = 10;
int* p1 = &x;

// 了解*p1
printf("%d\n", *p1);
// 了解*(p1+0) == *p1
printf("%d\n", *(p1 + 0));
// 了解*(p1+i) == p1[i];
printf("%d %d\n", *(p1 + 0), p1[0]);
printf("%d %d\n", *(p1 + 2), p1[2]);
}

转到反汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    8: 	// 了解*p1
9: printf("%d\n", *p1);
mov rax,qword ptr [p1]
mov edx,dword ptr [rax]
lea rcx,[string "%d\n" (07FF7AB534000h)]
call printf (07FF7AB531080h)
10: // 了解*(p1+0) == *p1
11: printf("%d\n", *(p1 + 0));
mov rax,qword ptr [p1]
mov edx,dword ptr [rax]
lea rcx,[string "%d\n" (07FF7AB534000h)]
call printf (07FF7AB531080h)
12: // 了解*(p1+i) == p1[i];
13: printf("%d %d\n", *(p1 + 0), p1[0]);
mov rax,qword ptr [p1]
mov r8d,dword ptr [rax]
mov rax,qword ptr [p1]
mov edx,dword ptr [rax]
lea rcx,[string "%d %d\n" (07FF7AB534004h)]
call printf (07FF7AB531080h)
14: printf("%d %d\n", *(p1 + 2), p1[2]);
mov rax,qword ptr [p1]
mov r8d,dword ptr [rax+8]
mov rax,qword ptr [p1]
mov edx,dword ptr [rax+8]
lea rcx,[string "%d %d\n" (07FF7AB534004h)]
call printf (07FF7AB531080h)

我们先了解 *p1

1
2
mov         rax,qword ptr [p1]
mov edx,dword ptr [rax]

再了解 *(p1+0)

1
2
mov         rax,qword ptr [p1]
mov edx,dword ptr [rax]

这两个对应的汇编代码有区别吗?没有!所以我们有 *(p1+0) == *p1

接下来我们来看看 printf("%d %d\n", *(p1 + 0), p1[0]); 这一行:

1
2
3
4
mov         rax,qword ptr [p1]
mov r8d,dword ptr [rax]
mov rax,qword ptr [p1]
mov edx,dword ptr [rax]

你能分清哪一部分对应 *(p1+0),哪一部分对应 p1[0] 吗?

根据上面的汇编代码,可以很容易地分辨出 3、4 行对应 *(p1+0)。那么 1、2 行就对应 p1[0]

这涉及到编译器的调用约定。这里我们不展开讲,只需要知道一点:函数的参数从右往左读取。

参考:调用约定 | Microsoft Learn

x64 调用约定 | Microsoft Learn

C 语言函数调用约定 - findumars

我们发现,*(p1+0)p[0] 的汇编也没什么功能性上的区别。我们在 *(p1+2)p[2] 也可以得出相同的结论。所以我们有 *(p1+i) == p1[i]


7.3.2 二级指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdio.h>

int main(void)
{
int x = 10;
int* p1 = &x;
int** p2 = &p1;

//了解**的反汇编
printf("%d\n", *p2);//p2: int**; *p2: int*
printf("%d\n", *(*p2));//p2: int**; *p2: int*; *(*p2) = **p2: int

//了解*(*(p2+0)) == *(*(p2+0)+0)
printf("%d %d\n", *(*(p2 + 0)), *(*(p2 + 0) + 0));

//了解*(*(p2+1))的反汇编
printf("%d\n", *(*(p2 + 1)));//p2: int**; p2+1: int**, 加8字节(int*)

//了解*(*(p2+1)+1)的反汇编
printf("%d\n", *(*(p2 + 1) + 1));//p2: int**; p2+1: int**, 加8字节; *(p2+1)+1: int*, 加4字节(int)

//了解*(*(p2+2)+3)的反汇编
printf("%d\n", *(*(p2 + 2) + 3));//先加16字节(int*),再加12字节(int)

//了解*(*(p2+j)+k) == p2[j][k]
printf("%d %d\n", *(*(p2 + 0) + 0), p2[0][0]);

}

转到反汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
    9: 	//了解**的反汇编
10: printf("%d\n", *p2);
mov rax,qword ptr [p2]
mov rdx,qword ptr [rax]
lea rcx,[string "%d\n" (07FF613BB4000h)]
call printf (07FF613BB1110h)
11: printf("%d\n", *(*p2));
mov rax,qword ptr [p2]
mov rax,qword ptr [rax]
mov edx,dword ptr [rax]
lea rcx,[string "%d\n" (07FF613BB4000h)]
call printf (07FF613BB1110h)
12:
13: //了解*(*(p2+0)) == *(*(p2+0)+0)
14: printf("%d %d\n", *(*(p2 + 0)), *(*(p2 + 0) + 0));
mov rax,qword ptr [p2]
mov rax,qword ptr [rax]
mov r8d,dword ptr [rax]
mov rax,qword ptr [p2]
mov rax,qword ptr [rax]
mov edx,dword ptr [rax]
lea rcx,[string "%d %d\n" (07FF613BB4004h)]
call printf (07FF613BB1110h)
15:
16: //了解*(*(p2+1))的反汇编
17: printf("%d\n", *(*(p2 + 1)));
mov rax,qword ptr [p2]
mov rax,qword ptr [rax+8]
mov edx,dword ptr [rax]
lea rcx,[string "%d\n" (07FF613BB4000h)]
call printf (07FF613BB1110h)
18:
19: //了解*(*(p2+1)+1)的反汇编
20: printf("%d\n", *(*(p2 + 1) + 1));
mov rax,qword ptr [p2]
mov rax,qword ptr [rax+8]
mov edx,dword ptr [rax+4]
lea rcx,[string "%d\n" (07FF613BB4000h)]
call printf (07FF613BB1110h)
21:
22: //了解*(*(p2+2)+3)的反汇编
23: printf("%d\n", *(*(p2 + 2) + 3));
mov rax,qword ptr [p2]
mov rax,qword ptr [rax+10h]
mov edx,dword ptr [rax+0Ch]
lea rcx,[string "%d\n" (07FF613BB4000h)]
call printf (07FF613BB1110h)
24:
25: //了解*(*(p2+j)+k) == p2[j][k]
26: printf("%d %d\n", *(*(p2 + 2) + 3), p2[2][3]);
mov rax,qword ptr [p2]
mov rax,qword ptr [rax+10h]
mov r8d,dword ptr [rax+0Ch]
mov rax,qword ptr [p2]
mov rax,qword ptr [rax+10h]
mov edx,dword ptr [rax+0Ch]
lea rcx,[string "%d %d\n" (07FF613BB4004h)]
call printf (07FF613BB1110h)

7.3.3 三级及其以上的指针

我们只试验三级指针,后面等级的指针实在太罕见了,而且看完前三级指针我们也可以得出点规律来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>

int main(void)
{
int x = 10;
int* p1 = &x;
int** p2 = &p1;
int*** p3 = &p2;

//了解***的反汇编
printf("%d\n", *p3);
printf("%d\n", *(*p3));
printf("%d\n", *(*(*p3)));

//了解*(*(*(p3))) == *(*(*(p3+0)+0)+0)
printf("%d %d\n", *(*(*(p3))), *(*(*(p3 + 0) + 0) + 0);

//了解*(*(*(p3+1)+2)+3)的反汇编
printf("%d\n", *(*(*(p3 + 1) + 2) + 3);

//了解*(*(*(p3+l)+m)+n) == p3[l][m][n]
printf("%d %d\n", *(*(*(p3 + 1) + 2) + 3), p3[1][2][3]);
}

转到反汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
   10: 	//了解***的反汇编
11: printf("%d\n", *p3);
mov rax,qword ptr [p3]
mov rdx,qword ptr [rax]
lea rcx,[string "%d\n" (07FF60ABF4000h)]
call printf (07FF60ABF1110h)
12: printf("%d\n", *(*p3));
mov rax,qword ptr [p3]
mov rax,qword ptr [rax]
mov rdx,qword ptr [rax]
lea rcx,[string "%d\n" (07FF60ABF4000h)]
call printf (07FF60ABF1110h)
13: printf("%d\n", *(*(*p3)));
mov rax,qword ptr [p3]
mov rax,qword ptr [rax]
mov rax,qword ptr [rax]
mov edx,dword ptr [rax]
lea rcx,[string "%d\n" (07FF60ABF4000h)]
call printf (07FF60ABF1110h)
14:
15: //了解*(*(*(p3))) == *(*(*(p3+0)+0)+0)
16: printf("%d %d\n", *(*(*(p3))), *(*(*(p3 + 0) + 0) + 0));
mov rax,qword ptr [p3]
mov rax,qword ptr [rax]
mov rax,qword ptr [rax]
mov r8d,dword ptr [rax]
mov rax,qword ptr [p3]
mov rax,qword ptr [rax]
mov rax,qword ptr [rax]
mov edx,dword ptr [rax]
lea rcx,[string "%d %d\n" (07FF60ABF4004h)]
call printf (07FF60ABF1110h)
17:
18: //了解*(*(*(p3+1)+2)+3)的反汇编
19: printf("%d\n", *(*(*(p3 + 1) + 2) + 3));
mov rax,qword ptr [p3]
mov rax,qword ptr [rax+8]
mov rax,qword ptr [rax+10h]
mov edx,dword ptr [rax+0Ch]
lea rcx,[string "%d\n" (07FF60ABF4000h)]
call printf (07FF60ABF1110h)
20:
21: //了解*(*(*(p3+l)+m)+n) == p3[l][m][n]
22: printf("%d %d\n", *(*(*(p3 + 1) + 2) + 3), p3[1][2][3]);
mov rax,qword ptr [p3]
mov rax,qword ptr [rax+8]
mov rax,qword ptr [rax+10h]
mov r8d,dword ptr [rax+0Ch]
mov rax,qword ptr [p3]
mov rax,qword ptr [rax+8]
mov rax,qword ptr [rax+10h]
mov edx,dword ptr [rax+0Ch]
lea rcx,[string "%d %d\n" (07FF60ABF4004h)]
call printf (07FF60ABF1110h)

综上,我们有 *()[] 的互换规律:

1
2
3
4
5
*(p+i) = p[i];
*(*(p+i)+k) = p[i][k];
*(*(*(p+i)+k)+m) = p[i][k][m];
*(*(*(*(p+i)+k)+m)+n) = p[i][k][m][n];
...

8. 数组指针

8.1 数组指针的定义

本节参考:Pointer to an Array | Array Pointer - GeeksforGeeks

不是指针数组,是数组指针哟!

我们之前已经接触过为数组中的单个元素定义指针,包括数组名作为常量指针指向数组首元素的地址。那么有没有指向整个数组的指针呢?

数组指针,也叫指向数组的指针,就是干这个的。它的声明是:

1
data_type (*var_name)[size_of_array]
  • **data_type** 是数组的数据类型
  • **var_name** 是指针变量的名称
  • **size_of_array** 是指针将要指向的数组的元素数量

例如:

1
int (*ptr)[10];

这里定义了一个可以指向拥有 10 个整数元素的数组的指针 ptr。由于下标的运算优先级高于 *(间接寻址)的,因此需要用括号把指针括起来,否则你定义的是指针数组而不是我们期望的数组指针


8.2 数组指针的赋值

在为指针赋值时,我们需要为右值指定类型。数组指针的类型就是:

1
data_type (*)[size_of_array]

例如:

1
ptr = (int (*)[10]) 2;

8.3 数组指针的性质

8.3.1 宽度

1
2
3
4
5
6
7
8
#include <stdio.h>

int main(void)
{
int (*ptr)[6];

printf("%d\n", sizeof(ptr));
}
1
8

毫无意外!8 字节!


8.3.2 递增递减

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main(void)
{
int (*ptr)[6];

printf("%d\n", sizeof(*ptr));

printf("%d\n", ptr);
ptr++;
printf("%d\n", ptr);
ptr--;
print("%d\n", ptr);
}
1
2
3
4
24
0
24
0

数组指针递增递减的单位长度是其指向的数组的大小。还是那句话:粗略理解,指针递增递减就是指针类型砍掉一颗 * 后新类型的宽度。


8.3.3 与整数的加减

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main(void)
{
int (*ptr)[6];

printf("%d\n", sizeof(*ptr));

printf("%d\n", ptr);
ptr += 3;
printf("%d\n", ptr);
ptr -= 3;
printf("%d\n", ptr);
}
1
2
3
4
24
0
72
0

单位长度也是指向的数组的大小。


8.3.4 数组指针间的减法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main(void)
{
int (*ptr)[6];
int (*str)[6];

ptr = (int (*)[6]) 10;
str = (int (*)[6]) 5;

printf("%d\n", ptr);
printf("%d\n", str);

printf("%d\n", ptr - str);
}
1
2
3
10
5
0

105=510-5=55÷24=05÷24=0(截断后),也符合我们之前的结论。


8.4 数组指针的间接访问

数组指针的间接访问与前面类型的指针有所不同。一级数组指针的间接访问的返回值仍然是数组的首地址:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main(void)
{
int arr[6] = { 1, 2, 3, 4, 5, 6 };
int (*ptr)[2];
ptr = (int (*)[2])arr;

printf("%d\n", ptr);
printf("%d\n", *ptr);
}
1
2
-430966880
-430966880

数组指针在数值上等于数组的首地址,我们在 2.4 已经讲过这一点。但为什么间接访问后仍然返回首地址呢?

数组指针指向数组,间接访问它后得到的是数组名,而数组名代表数组的基地址。因此,间接访问一个指向数组的指针,我们将得到数组的基地址(也就是数组的首地址)。

——Pointer to an Array | Array Pointer - GeeksforGeeks

按我们的粗略理解,间接访问 ptr 时它的类型要砍去一颗 *,砍完后变成什么了?int [2]arr!这个维度没什么用了,也砍掉。最终我们得到了一个数组名!数组名在表达式中自动转换为常量指针,指向数组首地址(基地址)。因此最后我们得到了首地址(基地址)。

原来还有一级隐藏的指针!现在我们把它找出来了。接下来如果我们要读取数组的第一个值,那就再套一层间接访问呗:

1
printf("%d\n", *(*ptr));
1
1

下图展示了 arrptr


8.5 数组指针和多级指针的结合

使用数组指针时,我们有时会想直接取到数组中的某一个位置上的元素,而不是遍历数组。这时需要结合多级指针的一些特性(参见 7.3)了。

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main(void)
{
int arr[6] = { 1, 2, 3, 4, 5, 6 };
int (*ptr)[2];
ptr = (int (*)[2])arr;

printf("%d\n", *(*ptr));
printf("%d\n", *(*(ptr + 0) + 0));
printf("%d\n", *(*(ptr + 1) + 1));
}
1
2
3
1
1
4

这里我们解释一下 *(*(ptr+1)+1)。首先 ptr 是一个数组指针,对应的数组大小为 8 字节,对应两个整数,加 1 后跳过 arr 的前两个数(视为 ptr 所指向的数组),从 3 开始(即进入下一个数组)。然后 *(ptr+1) 对应 &arr[3],这是一个 int*,加 1 等于加 4 字节,等于移到下一个整数的地址。下一个整数是 4,此时 *(*(ptr+1)+1) 的类型是 int,已经是一个值了,于是输出 4。

由 7.3.2,我们知道 *(*(ptr+j)+k) == ptr[j][k]。那么我们也可以将第 11 行的输出改为:

1
printf("%d\n", ptr[1][1]);

输出也没有问题。不太理解?你可以把这样输出的一维数组想成是二维数组:


8.5.SP1 思考:*(ptr+1)[2] 是否等于 ptr[1][2]

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main(void)
{
int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int (*ptr)[6];
ptr = (int (*)[6])arr;

printf("%d\n", ptr[1][2]);
printf("%d\n", *(ptr+1)[2]);
}
1
2
9
214114979

很明显,两个表达式的结果是不一样的。为什么?转到反汇编看一看:

1
2
3
4
5
6
7
8
9
10
    9: 	printf("%d\n", ptr[1][2]);
mov rax,qword ptr [ptr]
mov edx,dword ptr [rax+20h]
lea rcx,[string "%d\n" (07FF60C7A4028h)]
call printf (07FF60C7A1080h)
10: printf("%d\n", *(ptr+1)[2]);
mov rax,qword ptr [ptr]
mov edx,dword ptr [rax+48h]
lea rcx,[string "%d\n" (07FF60C7A4028h)]
call printf (07FF60C7A1080h)

通过反汇编我们发现,内存的偏移值不一样!ptr[1][2] 相比首地址偏移了 32 个字节(1×4×6+2×41×4×6+2×4),但 *(ptr+1)[2] 偏移了 72 个字节,后者多偏移了 40 字节!并且 72 字节正好是定义时 ptr 将要指向的数组的大小的三倍!也就是说,*(ptr+1)[2] 中方括号的 + 2,它的单位长度不是基本类型的宽度,而是 **ptr 将要指向的数组的大小 **!


8.5.SP2 实践:利用数组指针遍历一个一维数组

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main(void)
{
int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int (*ptr)[1];
ptr = (int (*)[1])arr;

for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++) {
printf("%d\n", *(*(ptr + i)));
}
}

8.6 内存视角下数组指针和多级指针的结合

8.6.1 一维数组指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>

unsigned char data[] = {
0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
};

int main(void)
{
char (*ptr)[5];
ptr = (char (*)[5])data;
printf("%x\n", *(*ptr));
printf("%x\n", *(*(ptr+4)+3));//跳过的字节数:4*5*1+3*1 = 23, 读取第24个字节, 即0x47

int (*str)[5];
str = (int (*)[5])data;
printf("%x\n", *(*str));
printf("%x\n", *(*(str+4)+3));//跳过的字节数:4*5*4+3*4 = 92, 读取从第93个字节开始的4个字节, 即0x23,0x00,0x00,0x64
}
1
2
3
4
0
47
3020100
64000023

8.6.2 二维数组指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

unsigned char data[] = {
0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
};

int main(void)
{
char (*ptr)[2][3];
ptr = (char (*)[2][3])data;

printf("%x\n", *(*(*(ptr + 1) + 2) + 3));//跳过的字节数:(2*3)*1*1+3*2*1+3*1*1 = 15, 读取第16个字节, 即0x0c

1
c

二维数组指针比一维数组指针要复杂一点。间接访问时,我们不仅要砍 *,还要按顺序砍维度。比如上面的那个程序,ptr+1 跳过的字节单位是一个二维数组 char [2][3] 的宽度,而 *(ptr+1)+2 跳过的字节单位是一个一维数组 char [3] 的宽度。

速查图:

(来源:Pointer to an Array | Array Pointer - GeeksforGeeks

Tip

Different from 1D arrays, the array identifier is also a pointer to the first row of the array. This can be confusing when you dereference a pointer to a 2D array (or array identifier), since it gives you a pointer to the first element of the first row of the array. For example, myArray holds the same value as *myArray, which is the address of myArray[0][0], i.e. myArray*myArray&myArray[0][0].

If you would add 1 to the 2D array identifier, this gives a pointer to the first element of the second row, for example, *(myArray + 1) is same as myArray[1].

Then, to get to a particular column, you need to add the index of the column. For example, *(*(myArray + 1) + 2) is same as myArray[1][2].

——9.1. Why and how to use 2D arrays? — Snefru: Learning Programming with C

在二维数组中,数组名还表示数组第一行的基地址。在使用二维数组时,如果只提供数,则返回的是指向该行首地址的指针。在 8.6.4 你可能会得到更准确的理解。


8.6.3 三维数组指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

unsigned char data[] = {
0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
};

int main(void)
{
char (*ptr)[2][3][4];
ptr = (char (*)[2][3][4])data;

printf("%x\n", *(*(*(*(ptr + 1) + 2) + 3)+ 4));//跳过的字节数:(2*3*4)*1*1+(3*4)*2*1+4*3*1+4*1 = 64, 读取第65个字节, 即0x05
}
1
5

8.6.4 用 n 维数组指针访问 k 维数组

标题党!

我们要说明的是,n 维数组指针和 n 维数组的对应关系只是出于习惯,不是绑死的。我们完全可以小马拉大车,用一维数组指针访问十维甚至九维 (要素察觉) 数组。因为无论数组有多少维,到内存里,还是和一维数组一样线性存储:

以上图片分别展示了二维数组(上图)和三维数组(下图)在内存中的存储方式(来源:Pointer to an Array | Array Pointer - GeeksforGeeks)。和我们之前展示过的一维数组的存储方式相比,有区别吗?

因此数组在内存中没有维度之分。你可以将一个一维数组 “细细地切作臊子”,当成 108 维数组;也可以将一个多维数组合成一维数组。计算机会帮你转化成线性顺序。


8.6.SP 小测验:脑测指针运算

编译器是什么?有我的大脑好使吗?也许是的(悲)

给出这些数据:

1
2
3
4
5
6
7
8
9
10
11
12
unsigned char data[] = {
0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
};

和相关的指针定义:

1
2
3
4
int (*px)[2];
int (*py)[2][3];
char (*pz)[2];
char (*pk)[2][3];

要求不上机运行,说出以下表达式的结果:

1
2
3
4
5
6
*(*(px+0)+0);
*(*(px+1)+0);
*(*(px+2)+3);
*(*(*(py+1)+2)+3);
*(*(pz+2)+3);
*(*(*(pk+2)+3)+4);

标准是全对!只要错一道,就得回炉重炼。

1
2
3
4
5
6
*(*(px+0)+0)=03020100 = 3020100;
*(*(px+1)+0)=20000907;//2*4*1 + 0 = 8
*(*(px+2)+3)=00001100 = 1100;//2*4*2 + 4*3 = 28
*(*(*(py+1)+2)+3)=00000001 = 1;//2*3*4*1 + 3*4*2 + 4*3 = 24+24+12 = 60
*(*(pz+2)+3)=07 = 7;//1*2*2 + 1*3 = 7
*(*(*(pk+2)+3)+4)=0E = E;//1*2*3*2 + 1*3*3 + 1*4 = 12+9+4 = 25

上机校对答案:

1
2
3
4
5
6
3020100
20000907
1100
1
7
e

😸完全正确!稳啦!​


9. 函数指针

9.pre1 代码和数据有什么区别?

代码和数据,从内存角度出发,没有任何区别。它们在计算机看来,都只是一串二进制数。

数据是任何能被计算机移动、处理或存储的信息程序是一组可以利用数据产生期望结果的机器指令

严格来讲,程序(不论是源代码还是编译产物)也是数据,因为程序也可以被移动、处理和存储。但当提及计算机程序时,我们一般将 “代码” 和 “数据” 当作不同的两个事物。代码就是程序自身,数据是程序产生结果的原料。

——1.3 — Introduction to objects and variables – Learn C++


9.pre2 变量和函数有什么区别?

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int x = 10;
int furry(int x, int y) {
return x + y;
}

int main(void)
{
int y = x;
}

我们分别定义了一个全局变量和一个函数。将变量名和函数名都拖到反编译的内存查看框里:

(上图对应变量 x,下图对应函数 furry

变量和函数都可以存储数据。但函数存的数据可以交给 CPU 去执行。


9.1 函数指针的定义

函数指针,书面定义是指向函数的指针。函数指针可以像一般函数一样用于调用参数、传递参数。

函数指针的声明是这样的:

1
data_type (*var_name)(parameter_list);
  • **data_type** 是函数返回值的数据类型

  • **var_name** 是指针变量的名称

  • **parameter_list** 是指针将要指向的函数的参数表。参数表只需要写参数的类型。如果要指向一个已经定义好的函数,参数表的参数数量必须与该函数相同。(正式点说:函数指针的参数表格式必须和其将指向的函数的参数表格式完全一致。不一致的话,就算强制转换也过不了编译)

    参数表也是可以写参数名称的,只是它的存在感和空气一样。简单来说就是没用!

下面的例子展示了两个比较标准的函数指针声明:

1
typedef int (*fun_ptr)(int,int);
1
void (*fun_ptr)(int);

和数组指针一样,参数表的 () 的优先级要高于指针符号 *,因此声明函数指针时,要给指针名加用来提优先级的 ()


9.2 函数指针的赋值

1
2
3
4
5
int furry(int x, int y) {
return x + y;
}

int (*furry_ptr)(int a, int b) = &furry;
1
int (*furry_ptr)(int, int) = (int (*)(int, int))10;
1
int (*furry_ptr)(int a, int b) = (int (*)(int, int))10;
1
int (*furry_ptr)(int, int) = (int (*)(int, int))furry;

以上没什么好说的,要么取地址要么强制转型。但我们还可以将函数名直接赋给函数指针:

1
int (*furry_ptr)(int, int) = furry;

函数名是一个特殊的函数指针常量,可以通过编译器自动转换为函数指针。


9.3 函数指针的性质

9.3.1 宽度

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int furry(int x, int y) {
return x + y;
}

int main(void)
{
int (*furry_ptr)(int a, int b) = &furry;
printf("%lld\n", sizeof(furry_ptr));
}
1
8

9.3.2 需要砍掉一颗 * 后宽度的运算,out

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int furry(int x, int y) {
return x + y;
}

int main(void)
{
int (*furry_ptr)(int a, int b) = (int (*)(int, int))10;
printf("%x\n", furry_ptr);
furry_ptr++;
printf("%x\n", furry_ptr);
}
1
2
3
错误(活动)	E0852	表达式必须是指向完整对象类型的指针	11

错误 arithmetic on a pointer to the function type 'int (int, int)' 11

这下热闹了!我们之前对各种指针进行了递增递减操作,都没有问题,而到了函数指针这里,直接报错!而且 Clang 返回了一句意味深长的错误信息:

1
错误:对类型为'int (int, int)'的函数指针的算术运算

不是表达式中算子的错误,而是运算这个操作本身就是错的!为什么?

按我们之前的理解,如果想要获取指针与整数加减的单位长度,我们首先要在原类型上砍掉一颗 *。基本类型不用说,结构体是基本类型的组合,数组有数组大小,字符串也是一种特殊的数组。它们都有可以确定新类型大小的东西。

那你函数本身能确定宽度吗?来,给函数上 sizeof

1
printf("%lld\n", sizeof(furry));
1
错误		invalid application of 'sizeof' to a function type

铁证如山!

Note

在 C 语言中,没有标准的方法来直接获取函数的大小。函数的大小通常是编译器和链接器在编译和链接过程中处理的,程序运行时无法直接访问这些信息

那可以进行函数指针间的减法吗?也不行!因为我们之前说算出指针指向的字面量差后,还得再除以砍去一颗 * 后类型的宽度,在这里就是函数的宽度。它的报错信息和上面一样,也是一个侧例。

9.3.3 比较大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

int x = 100;
int y = 100;

int furry(int x, int y) {
return x + y;
}

int main(void)
{
int (*furry_ptr)(int a, int b) = (int (*)(int, int))10;
int (*furry_ptr2)(int a, int b) = (int (*)(int, int))20;
if (furry_ptr2 > furry_ptr)
{
printf("furry_ptr2 is bigger than furry_ptr");
}
else
{
printf("furry_ptr2 is smaller than furry_ptr");
}
}
1
furry_ptr2 is bigger than furry_ptr

呼,还好指针之间的比较不需要砍掉一颗 * 后新类型的大小。这个运算还是能进行的。只不过我们又得到了一个从未见过的 warning:

1
警告		ordered comparison of function pointers ('int (*)(int, int)' and 'int (*)(int, int)') [-Wordered-compare-function-pointers]
Warning

在 C 语言中,函数指针的有序比较(即使用 <, >, <=, >= 运算符)是未定义行为。这是因为函数指针指向的是代码而不是数据,编译器无法保证这些指针的相对顺序。因此,直接比较函数指针可能会导致不可预测的结果。


9.4 函数指针的使用

函数指针可以替代函数名进行运算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int furry(int x, int y) {
return x + y;
}

int main(void)
{
int (*furry_ptr)(int, int) = furry;
int x = furry_ptr(1, 2);
printf("%d\n", furry(1, 2));
printf("%d\n", furry_ptr(1, 2));
printf("%d\n", x);
}
1
2
3
3
3
3

这下分不清函数名和函数指针了 (doge 不是,这不是脱裤子放屁嘛!要用函数我直接用函数名不就行了,还来个作用差不多的函数指针干什么,我看你们这些程序员就是重复造轮子……

其实我们还有一些场景是使用函数指针而不使用函数名的。比如接下来我们要讲的将源代码隐藏到数据区

Note

如果你希望在现在的 Windows 系统上复现这部分内容,请使用 x86 架构的生成工具,并关闭 Windows 的数据执行保护功能。关闭方法可见于:什么是数据执行防护 (DEP) ? - Microsoft 支持 和接下来 FittenCode Pro 生成的内容。

还记得我们 9.pre1 讲的 “代码就是数据” 吗?我们可以将数据解读成具有一定可读性的汇编代码,再从汇编代码分析出函数的作用。这就是反汇编。

我们来看一下我们写的函数 furry 对应的反汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    3: int furry(int x, int y) {
00ED1FD0 55 push ebp
00ED1FD1 8B EC mov ebp,esp
00ED1FD3 81 EC C0 00 00 00 sub esp,0C0h
00ED1FD9 53 push ebx
00ED1FDA 56 push esi
00ED1FDB 57 push edi
00ED1FDC 8B FD mov edi,ebp
00ED1FDE 33 C9 xor ecx,ecx
00ED1FE0 B8 CC CC CC CC mov eax,0CCCCCCCCh
00ED1FE5 F3 AB rep stos dword ptr es:[edi]
00ED1FE7 B9 0E C0 ED 00 mov ecx,offset _3D986EF5_pointer1@cpp (0EDC00Eh)
00ED1FEC E8 3E F3 FF FF call @__CheckForDebuggerJustMyCode@4 (0ED132Fh)
00ED1FF1 90 nop
4: return x + y;
00ED1FF2 8B 45 08 mov eax,dword ptr [x]
00ED1FF5 03 45 0C add eax,dword ptr [y]
5: }
00ED1FF8 5F pop edi
00ED1FF9 5E pop esi
00ED1FFA 5B pop ebx
00ED1FFB 81 C4 C0 00 00 00 add esp,0C0h
00ED2001 3B EC cmp ebp,esp
00ED2003 E8 4B F2 FF FF call __RTC_CheckEsp (0ED1253h)
00ED2008 8B E5 mov esp,ebp
00ED200A 5D pop ebp
00ED200B C3 ret
x64 架构的反汇编

顺便说一句,上面是我使用 x86 架构的工具集得到的反汇编代码,使用 x64 架构的工具集得到的反汇编是:

1
2
3
4
5
6
7
8
9
    21: int furry(int x, int y) {
00007FF6112C1000 50 push rax
00007FF6112C1001 89 54 24 04 mov dword ptr [rsp+4],edx
00007FF6112C1005 89 0C 24 mov dword ptr [rsp],ecx
22: return x + y;
00007FF6112C1008 8B 04 24 mov eax,dword ptr [rsp]
00007FF6112C100B 03 44 24 04 add eax,dword ptr [y]
00007FF6112C100F 59 pop rcx
00007FF6112C1010 C3 ret

明显长度差好多!而且 x64 工具集给的硬编码,无论生成的是 x86 程序还是 x64 程序都根本不能用,调试时调试器会提示:

1
0x00007FF7A5D85000 处(位于 Anti-Learning.exe 中)引发的异常: 0xC0000005: 执行位置 0x00007FF7A5D85000 时发生访问冲突。

问了 Copilot,回复是:

1
2
3
4
5
6
这个错误发生是因为代码试图将数据当作函数来执行,导致访问冲突。
这可能是因为:
1. data数组包含的原始字节并不构成一个有效的函数。将任意字节作为代码执行会导致未定义行为和访问冲突。
2. 函数指针ptr被强制转换为指向data,但data并不代表一个有效的函数机器码。
3. 内存位置0x00007FF7CEFC5000不可执行,或者该位置的字节不构成CPU的有效指令序列。
要修复此问题,请确保data包含有效的函数机器码,并且内存被标记为可执行。或者,避免使用原始字节数组作为函数指针。

我们写的函数 furry 在计算机内被存储为左侧第二列 55, 8B, EC, 83, EC... 这些硬编码。这些硬编码拥有特殊的含义,因此可以通过专门的反汇编引擎将硬编码转换为汇编代码。

这些硬编码也可以写进程序里面,等价于我们用高级语言写的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
unsigned char data[] = {
0x55,
0x8B, 0xEC,
0x83, 0xEC, 0x40,
0x53,
0x56,
0x57,
0x8D, 0x7D, 0xC0,
0xB9, 0x10, 0x00, 0x00, 0x00,
0xB8, 0xCC, 0xCC, 0xCC, 0xCC,
0xF3, 0xAB,
0x8B, 0x45, 0x08,
0x03, 0x45, 0x0C,
0x5F,
0x5E,
0x5B,
0x8B, 0xE5,
0x5D,
0xC3
};

然后我们就可以用函数指针访问这段硬编码,将其转换为函数执行:

1
2
3
4
int (*ptr)(int, int);
ptr = (int (*)(int, int))&data;
int x = ptr(1, 2);
printf("%d", x);

x86 架构工具集生成 + 关闭 DEP 的前提下编译运行,输出的是:

1
3
我的探索过程

一开始我发现我的程序根本不能运行,但教程是可以的,因此我希望可以在与教程相近的开发环境中复现代码。我在虚拟机的 Windows7 上安装了 VC6,并且在 VC6 获取了函数的硬编码。同时我在 VC++6 上测试将函数硬编码写进程序,程序在虚拟机中运行完美。

但之后我发现 VC++6 的硬编码不能用于 VS2022。无论是 MSVS2022 工具集还是 LLVM (Clang-cl),均提示:

1
0x00007FF693605000 处(位于 Anti-Learning.exe 中)引发的异常: 0xC0000005: 执行位置 0x00007FF693605000 时发生访问冲突。

问了 Copilot,答复也是差不多的:

1
这可能是因为data数组不是一个有效的函数,而是一系列字节。当你将data的地址强制转换为函数指针并调用它时,CPU试图将这些字节作为机器指令来执行,从而导致访问冲突。

转到 VSCode,还特地将 VC6 上一切正常的代码原封不动地复制过来,可是,GCC、G、Clang 全部可生成但运行不正常,用 GDB 调试后输出了一条令人意外的信息:

1
Program terminated with signal SIGSEGV, Segmentation fault.

怎么回事?我们不是将数组定义在全局变量区的吗?按理说这里的数据可读写,应该可以访问的。


转到 Clion,复制相同的代码,使用 Cmake+ninja 生成,同样可编译但不可运行。转到调试,发现 GDB 输出了一样的信息:

1
Program terminated with signal SIGSEGV, Segmentation fault.

之后我使用了 VSCode 中配置好的的 LLDB 调试,调试器输出的错误信息为我们指明了一条道路:

1
Stop reason: Exception 0xc0000005 encountered at address 0x7ff75d036000: User-mode data execution prevention (DEP) violation at location 0x7ff75d036000

看起来是 DEP 的原因。什么是 DEP?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
数据执行保护(DEP,Data Execution Prevention)是一种内置于Windows操作系统中的安全技术,旨在防止可执行代码在不应执行的位置运行。DEP通过将计算机内存中的某些区域标记为仅供数据使用,来保护系统免受来自这些内存区域的攻击,例如缓冲区溢出攻击等。这种保护使得恶意软件很难在仅为存储数据而设置的内存区域中执行其代码[1][4]。

在DEP实施的条款下,系统会监控程序的内存使用情况,确保它们在安全的环境中运行。当某个程序尝试从被保护的内存区域运行代码时,操作系统会引发访问冲突异常,如果未处理该异常,则调用进程将被终止[3][5]。这种机制不是全面的保护措施,而是用于增强应用程序的安全性。

DEP通常默认启用,但用户可以根据需要在Windows安全中心或通过命令提示符来调整其设置。用户还可以针对特定程序禁用DEP,这在某些情况下是必要的,例如程序未设计为与DEP兼容时。如果选择禁用某个程序的DEP,用户需谨慎,因为这可能会使该程序及其相关文件暴露于攻击风险中[2][4][5]。

要开启或关闭DEP,可以通过以下步骤进行操作:

1. 通过Windows搜索打开“系统属性”。
2. 转到“高级”选项卡,然后点击“性能”下的“设置”按钮。
3. 在“数据执行保护”选项卡中,选择合适的DEP设置来启用或禁用功能[1][4][5]。
需要注意的是,禁用DEP可能会使系统更易受攻击,因此建议用户在了解风险的前提下谨慎操作[3][4]。

Reference
[1]What is Data Execution Prevention (DEP)? - Microsoft Support
[2]How To Enable Or Disable Data Execution Prevention (DEP) on Windows 10 / 11
[3]Enable, Disable Data Execution Prevention DEP in Windows 11/10
[4]What is Data Execution Prevention (DEP) and How to Enable DEP
[5]Data Execution Prevention - Win32 apps | Microsoft Learn

(由 FittenCode Pro Search 生成)

那似乎是 Windows 的问题啊!为了验证,我又在 Windows11 实体机上安装了 VC6,然后编译调试了这个程序。然后 VC6 报!错!了!

1
Unhandled exception in ff.exe (OLE32.DLL):0xC0000096:Privileged Instruction.

然后我根据 Microsoft 官方的文章(也就是什么是数据执行防护 (DEP) ? - Microsoft 支持),关闭了 DEP。

有没有用呢?Win11 上的 VC++6 编译的程序可以正常运行,但 VS2022 和 VSCode 的 GCC 与 Clang 的生成仍然无法运行。


然后我又想到 FittenCode Pro 所给出的关闭路径,想要在高级系统设置中的 “数据执行保护” 项中为我生成的程序单独设置 DEP 保护禁用,然后我得到了这个报错:

⚠️你不能在 64 位可执行文件上设置 DEP 属性

64 位可执行文件不允许禁用 DEP!所以禁用 DEP 仅在 VC++6 生成的 32 位可执行文件上生效。


好消息!我将 MSVS 工具集架构从 x64 改成 x86 (即 32 位),在关闭 DEP 的情况下编译运行成功!

我们总算可以做个总结了。出现这个问题的本质是程序架构过于先进 + DEP 的阻止

将源代码隐藏到数据区,有什么用?我们可以借此为我们的程序建立一个保护壳,阻止他人反编译我们的程序(更具体说,反编译我们自定义的函数)。大概的方式是将我们自定义的函数的硬编码从生成文件里 “扣” 出来(使用代码),放到我们自定义的一个数组里,再对这个数组做一些隐藏操作,然后在之前生成文件中存自定义函数的地方把函数的硬编码全改成 0,也就是 “没有函数”。翻译翻译给我听什么叫 TMD 惊喜


10. 内存分配机制

10.1 静态内存分配

其实我们早就在使用 ** 静态内存分配(Static Memory Allocation)** 了。一个能够体现这种分配方式特点的例子就是数组,它的大小在定义后就不可改变。

静态内存分配有点像酒店入住,在你入住前就需要向酒店预约你的需求,然后酒店提前为你准备酒店。分配好后这个房间就归你了,在入住后,没有特别申请,你不能申请更大的房间,也不能自己换到别人的房间去。

静态内存分配的特点有:

  • 内存的分配与释放由编译器完成
  • 内存在程序执行之前就被分配,因此编译器需要知道确定的内存要求
  • 变量分配到的内存空间永久固定,不能进一步更改
  • 已分配的内存空间不可重复使用,可能造成空间上的浪费
  • 由于内存空间已经分配,因此程序可以高效访问内存中的数据
  • 使用栈区内存。不使用时自动释放
Note

声明变量后,编译器才知道这个变量(名)是程序的重要部分,而不是不知所云的奇怪字符。此时,编译器将为这个变量分配内存空间。这个过程在程序执行前完成,所以程序运行时无法再分配变量内存。

——What is Dynamic Memory Allocation? - GeeksforGeeks


在静态内存分配中,程序已经固定好它需要的内存大小,并且不能进一步更改。在程序执行前,它需要知道准确的内存需求。此时,内存的分配与释放由编译器自动进行。

——Static and Dynamic Memory Allocation in C - GeeksforGeeks

静态内存分配的使用简单方便(毕竟由编译器代劳),数据的执行也高效,但灵活性上有所缺乏。分配少了存不住数据,往大了分配又嫌浪费。为了解决灵活性上的痛点,我们还有另外一个内存分配机制。


10.2 动态内存分配

还是酒店的例子。假如我们有途径向酒店提出特殊要求,也就是入住时再确定房间、之后也可以再换房间呢?C 语言确实给程序员提供了这种 “特殊途径”,也就是动态内存分配 (Dynamic Memory Allocation)

动态内存分配的特点有:

  • 内存的分配与释放由程序完成,也就是说,需要程序员通过相关指令指导计算机如何管理内存
  • 内存在程序运行时被分配,需要时也可以重新分配内存大小
  • 这些手动分配的内存使用指针进行管理,对应的地址也由指针提供。
  • 使用堆区内存。不使用时需要程序员手动释放
Note

堆上的内存分配,亦称动态内存分配。程序在运行的期间申请的内存,这部分内存由程序员自己负责管理,其生存期由开发者决定:在何时分配,分配多少,并在何时释放该内存。这是唯一可以由开发者参与管理的内存。使用的好坏直接决定系统的性能和稳定。

—— 浅析动态内存分配 - CSDN 博客

接下来我们介绍 C 语言进行动态内存分配时的常用函数,需要说明的是,这些函数在 ** 标准库 <stdlib.h>** 中提供。

以下参考 C 标准库 – <stdlib.h> | 菜鸟教程


10.2.1 malloc()

该函数是动态分配内存的最简单函数。它的声明是:

1
void* malloc(size_t size)
  • size是需要动态分配的字节数,⚠️不是可以存储的元素个数

    如果需要指定存储的元素个数,请使用:

    1
    type* var_name = (type*)malloc(sizeof(type) * num);

该函数返回一个指针 ,指向已分配大小的连续可用的内存。如果请求失败,则返回 NULL

该函数不会对已分配的内存进行初始化。

这个指针指向的是已分配内存的第一个内存字节的地址

malloc() 返回的是一个 void* 类型的指针,这是考虑到通用性的需要。我们使用时需要指定类型(强制转换)。

(来源:Dynamic Memory Allocation in C using malloc(), calloc(), free() and realloc() - GeeksforGeeks

通过 malloc(),我们还可以用指针实现变长数组:

1
2
3
double* ptr = (double*)malloc(30 * sizeof(double));
ptr[0] = 1.0;
printf("%lf", ptr[0]);
1
1.000000
Tip

我们建议对 ptr 进行检查,防止在内存不足时程序仍然要求存入数据:

1
2
3
4
if (ptr == NULL) {
fprintf(stderr, "Memory allocation failed\n");//OR: perror("malloc");
return 1;
}

(示例程序由 Microsoft Copilot 生成)


10.2.2 calloc()

该函数与 malloc() 基本相同。不同之处在于,它会初始化分配的内存,使每个字节都存储 0。它的声明是:

1
void *calloc(size_t nitems, size_t size)
  • **nitems** 是要被分配的元素个数。
  • **size** 是这个元素的大小
1
2
3
4
5
6
7
int n;
int* a;

printf("要输入的元素个数:");
scanf("%d",&n);

a = (int*)calloc(n, sizeof(int));

10.2.3 realloc()

该函数尝试重新调整之前调用 malloccalloc 所分配的 ptr 所指向的内存块的大小。它的声明是:

1
void *realloc(void *ptr, size_t size)
  • ptr – 指针指向一个要重新分配内存的内存块,该内存块之前是通过调用 malloccallocrealloc 进行分配内存的。如果为空指针,则会分配一个新的内存块,且函数返回一个指向它的指针。

  • size – 内存块的新的大小,以字节为单位。如果大小为 0,且 ptr 指向一个已存在的内存块,则 ptr 所指向的内存块会被释放,并返回一个空指针。

该函数返回一个指针 ,指向重新分配大小的内存。如果请求失败,则返回 NULL

1
2
3
4
5
6
7
8
9
10
11
char *str;

/* 最初的内存分配 */
str = (char *) malloc(15);
strcpy(str, "runoob");
printf("String = %s, Address = %p\n", str, str);

/* 重新分配内存 */
str = (char *) realloc(str, 25);
strcat(str, ".com");
printf("String = %s, Address = %p\n", str, str);

10.2.4 free()

该函数用于释放由 malloc(), calloc(), realloc() 等动态分配函数分配的内存。当动态分配的内存不再需要时,调用 free() 函数可以避免内存泄漏,确保程序有效地管理内存。

Warning

如果动态分配的内存没有被释放或丢失了对其的引用,内存将无法再被程序使用,造成内存泄漏。

内存泄露的严重后果:

(来自 HG 群,群友们纷纷调侃这是 “侧漏了”)

它的声明是:

1
void free(void *ptr)
  • ptr – 指针指向一个要释放内存的内存块,该内存块之前是通过调用 malloccallocrealloc 进行分配内存的。如果传递的参数是一个空指针,则不会执行任何动作。
1
free(str);

在使用 free() 我们需要注意以下事项:

  • 释放正确的内存:只能释放通过动态内存分配函数分配的内存,不能释放由其他方式分配的内存(例如局部变量或全局变量)。

  • 避免重复释放:同一个内存块不能多次释放,否则可能导致未定义行为。

  • 释放后处理指针:释放内存后,指针仍然指向已释放的内存位置。为了避免悬空指针,可以将指针设为 NULL

    Note

    指向已释放内存的指针称为悬空指针。如果悬空指针被再次访问,会导致未定义行为,可能引起程序崩溃或数据损坏。

    1
    2
    free(array);
    array = NULL;
  • 检查空指针:调用 free() 前最好检查指针是否为空,以确保程序稳定性。

    1
    2
    3
    4
    if (array != NULL) {
    free(array);
    array = NULL;
    }
Tip

free() 不是必需的,例如,在程序终止后所有已分配的内存都将被自动释放。然而在一个更复杂的程序中,能够释放并再利用内存是重要的。


©2025-Present Watermelonabc | 萌ICP备20251229号

Powered by Hexo & Stellar latest & Vercel & 𝙌𝙞𝙪𝙙𝙪𝙣 𝘾𝘿𝙉 & HUAWEI Cloud
您的访问数据将由 Vercel 和自托管的 Umami 进行隐私优先分析,以优化未来的访问体验

本博客总访问量:capoo-2

| 开往-友链接力 | 异次元之旅 | 中文独立博客列表

猫猫🐱 发表了 41 篇文章 · 总计 209.8k 字