C语言逆向——数组和结构体,数组多维只是一个编译构造的假象,本质会转成一维数组,结构体的话最难的就是对齐了
数组
数组是C语言中非常重要的一个概念,学习C语言主要就是两个知识点:数组、指针,学好这两个,那么你的C语言一定也会很好。
什么是数组?或者说什么情况下我们需要使用数组,比如说我们需要定义一个人的年龄,我们可以定义一个变量来表示,但是如果我们需要定义三个人的年龄呢?那就需要三个变量来表示,这样很复杂,那么我们是否可以使用一个变量来存储三个人的年龄呢?这时候我们就需要使用数组来定义。
数组的定义格式如下:
数据类型 变量名[常量];
在方括号中我们只能选择使用常量,而不可以选择变量,这是因为在声明的时候编译器需要提前知道数组的长度,然后才会去分配对应大小的内存;那么也就说明此处的常量是用来表示数组可存储的个数。
这里我们以之前的例子,定义一个数组来表示:张三、李四、王五的年龄:
int
age[
3
] = {
20
,
18
,
39
};
除该方式外,我们还可以使用如下这种方式定义:
int
age[] = {
20
,
18
,
39
};
我们可以简单看下反汇编,观察数组在汇编中是如何体现的:
通过反汇编,我们可以看到数组就是整体连续存储进入堆栈中,从左到右依次进入。
那么数组在内存中是如何分配的呢?在之前我们学习过很多数据类型,在这里我们以char类型举例:
可以看见char类型在分配内存空间时,都是以4字节空间分配的,这是因为在32位操作系统中,char类型分配的空间与int类型是一样的。
这个概念实际上就是本机宽度,本机是32位操作系统也就是4字节,在32位操作系统中处理4字节数据时速度最快,这也就出现了需要字节对齐(4字节)的情况。
这里我们再来看下char、short、int类型的数组的空间具体是如何分配的:
char
a[
10
];
// char占用一个字节,需要10个字节,但是因为4字节对齐,所以分配的就是12个字节
short
b[
10
];
// short占用两个字节,需要20个字节,20个字节正好符合4字节对齐,所以分配的就是20个字节
int
c[
10
];
// int占用4个字节,需要40个字节,40个字节正好符合4字节对齐,所以分配的就是40个字节
接下来我们学习一下如何存入、读取数组的数据(方括号[]内由0开始):
int
age[
3
] = {
1
,
2
,
3
};
// 读取
int
a = age[
0
];
int
b = age[
1
];
// 赋值(存入)
age[
1
] =
6
;
// 注意:在使用数组时,方括号[]中的内容可以使用表达式,而并非强制要求为常量。
思考在数组数据读时候,可以越界读取使用么?如果可以结果是什么?我们可以做个小实验:
int
arr[
10
];
arr[
10
] =
100
;
如上代码我们来运行则会出现这种错误:
也就是说当我们使用越界读取时会去读取一个不存在的未知地址。
课外:缓冲区溢出
#include <stdio.h>
void
Fun()
{
while
(
1
)
{
printf(
"why?\n"
);
}
}
int
main()
{
int
arr[
8
];
arr[
9
] = (
int
)&Fun;
return
0
;
}
如上代码中Fun函数为什么会被调用?我们可以通过反汇编代码+堆栈图来理解:
堆栈图如下:
经过观察我们发现,这里的数组越界访问,造成了堆栈中返回地址被篡改为Fun函数的地址,一旦执行到ret指令后,程序将会跳转到Fun函数然后往下执行,也就进入了死循环输出。
多维数组
多维数组是什么?假设我们现在需要定义一个班级有2个组,每个组有2个人,数组可以这样定义:
int
arr[
4
] = {
1
,
2
,
3
,
4
};
int
arr1[
2
*
2
] = {
1
,
2
,
3
,
4
};
int
arr2[
2
][
2
] = {{
1
,
2
},{
3
,
4
}};
一共有三种方式,最后一种的表示我们就称之为多维数组,我们之前所学的数组我们可以称之为一维数组;为什么会使用到多维数组?
如上图代码所示,我们想要知道第二组的第二个人,可以这样调用:
arr[
3
];
arr1[
3
]
arr2[
1
][
1
];
可以很明显的看见,当我们使用一维数组去调用的时候要通过计算的方法去思考,但是使用多维数组(这里有两个方括号所以称之为二维数组)我们完全没有这种烦恼,可以很方便的去调用。
那么以上所示的多维数组在内存中的分布是怎么样的呢?我们可以通过反汇编来看一下:
如上图所示我们可以清晰的看见多维数组在内存中的分布是怎么样的,跟一维数组存储一点区别都没有。
所以也可以得出一个结论就是int arr[2*2];等价于int arr[2][2];
多维数组的读写也很容易理解,举例说明一年有12个月,每个月都有一个平均气温,存储5年的数据:
int
arr[
5
][
12
] = {
{
1
,
2
,
1
,
4
,
5
,
6
,
7
,
8
,
9
,
1
,
2
,
3
},
// 0
{
1
,
2
,
1
,
4
,
5
,
6
,
7
,
8
,
9
,
1
,
2
,
3
},
// 1
{
1
,
2
,
1
,
4
,
5
,
6
,
7
,
8
,
9
,
1
,
2
,
3
},
// 2
{
1
,
2
,
1
,
4
,
5
,
6
,
7
,
8
,
9
,
1
,
2
,
3
},
// 3
{
1
,
2
,
1
,
4
,
5
,
6
,
7
,
8
,
9
,
1
,
2
,
3
}
// 4
};
读取第一年五月份的数据,修改第二年三月份的数据,可以这样来操作:
arr[
0
][
4
];
arr[
1
][
2
] =
10
;
编译器是如何找到对应数据的呢?第一年五月份的数据 → arr[0*12+4];
结构体
思考一下:
当需要一个容器能够存储1个字节,你会怎么做?使用char。
当需要一个容器能够存储4个字节,你会怎么做? 使用int。
当需要一个容器能够存储100个2个字节的数据,你会怎么做? 使用short arr[100]。
当需要一个容器能够存储5个数据,这5个数据中有1字节的,2字节的有10字节的...你会怎么做?
这时候我们就要学习一个新的概念叫做:结构体;结构体的定义如下:
struct 类型名{
// 可以定义多种类型
int
a;
char
b;
short
c;
};
那么结构体的特点是什么呢?
-
char/int/数组 等类型是编译器已知类型,我们称之为内置类型;但结构体编译器并不认识,当我们使用的时候需要告诉编译器一声,我们也称之为自定义类型;
-
如上代码所示我们仅仅是告诉编译器,我们定义的类型是什么样的,这段代码本身并不会占用内存空间;
-
结构体声明的位置和变量一样,都存在全局和局部的属性;
-
结构体在定义的时候,除了本身以外可以使用任何类型。
结构体类型变量的定义:
struct stStudent
{
int
stucode;
char
stuName[
20
];
int
stuAge;
char
stuSex;
};
stStudent student = {
101
,
"张三"
,
18
,
'M'
};
结构体类型变量的读写:
struct stPoint
{
int
x;
int
y;
};
stPoint point = {
10
,
20
};
int
x;
int
y;
// read
x = point.x;
y = point.y;
// write
point.x =
100
;
point.y =
200
;
定义结构体类型的时候,直接定义变量:
struct stPoint
{
int
x;
int
y;
}point1,point2,point3;
point1.x =
1
;
point1.y =
2
;
point2.x =
3
;
point2.y =
4
;
point3.x =
5
;
point3.y =
6
;
如上代码所示,定义结构体时是分配内存的,因为不仅定义了新的类型,还定义了三个变量。
动手思考一下,如下代码是否可行?
struct stPoint
{
int
x;
int
y;
};
stPoint point = {
10
,
20
};
stPoint point2 = {
11
,
20
};
point = point2;
简单看下反汇编,我们发现是可以的,因为这里结构体类型都是一样的,类型一样,自然可以赋值:
字节对齐
之前我们学习过本机宽度的概念,在32位操作系统中,当我们定义变量时,当其数据宽度小于4字节时,在编译的时候还是会以4字节的方式去存储,这是一种用空间换时间的策略,那么除了本机宽度,还有字节对齐也属于这一策略。
我们先使用一段代码来测试一下:
char
x;
int
y;
int
main()
{
x =
1
;
y =
2
;
return
0
;
}
如上x、y两个全局变量,char类型数据宽度是一个字节,所以假设其内存地址是0x0,那么全局变量y的内存地址是0x1,这是我们的猜想,但实际并不是这样,通过反汇编来看一下:
可以清晰的看见,这里的地址并不是连续的,35、36、37这三个字节是被浪费掉了,这是为什么呢?这就是我们所谓的字节对齐;细心的人会发现这里的地址实际上就是数据宽度的整数倍,例如0x38是十进制4的整数倍。
字节对齐就是:一个变量占用N个字节,则该变量的起始地址必须是N的整数倍,即:起始地址 % N = 0;如果是结构体,那么结构体的起始地址则是其最宽的数据类型成员的整数倍;这种方式可以提升程序编译的效率。
例如如下代码:
struct Test {
int
a;
char
b;
};
则该结构体的起始地址则是4的整数倍,因为其最宽的数据类型成员是a(int类型)。
当我们想要打印一个变量、结构体等等的数据宽度该怎么办?这里我们需要使用到一个关键词sizeof,其使用方法如下:
我们可以以上面所示的结构体代码举例,来打印一下看看:
结构是8,所以也印证了,我们说的结构体也是需要字节对齐的。
之前我们说了这种方式是空间换时间的策略,但是在一些场景下,我们可用的空间有限,这种空间浪费可能无法满足或者我们无法接受这种浪费,这种情况下我们可以使用如下的方式来改变这种对齐方式:
#pragma pack(
1
)
struct Test{
char
a;
int
b;
};
#pragma pack()
如上代码是通过#pragma pack(1)来改变结构体成员的对齐方式,但是无法影响结构体本身。
#pragma pack(n)中的n用来设定变量以n字节对齐方式,可以设定的值包含:1、2、4、8,VC6编译器默认是8;所以我们可以使用如上这种方式来取消强制对齐。
我们来看一下这个结构体最终的宽度是多少:
#pragma pack(
2
)
struct Test{
char
a;
int
b;
char
c;
};
#pragma pack()
它的内存分配是这样的:
因为我们强制要求了以2字节的方式进行对齐,所以char类型虽然只占了一个字节,却需要分配2个地址,而结构体的宽度等于 最 小值(对齐参数, 最大数据宽度)的倍数。
结构体数组
结构体和int、char等本质是没有区别的,所以结构体也有数组,结构体数组的定义如下:
类型 变量名[常量表达式];
// 定义结构体类型
struct stStudent
{
int
Age;
int
Level;
};
// 定义结构体变量
struct stStudent st;
// 定义结构体数组
struct stStudent arr[
10
]; 或者 stStudent arr[
10
];
结构体数组初始化:
struct stStudent {
int
Age;
int
Level;
};
stStudent arr[
5
] = {{
0
,
0
}, {
1
,
1
}, {
2
,
2
}, {
3
,
3
}, {
4
,
4
}};
arr[
0
].Age =
100
;
arr[
0
].Level =
100
;
结构体成员的使用:
// 结构体数组名[下标].成员名
arr[
0
].Age =
10
;
int
age = arr[
0
].Age;
字符串成员的处理:
struct stStudent{
int
Age;
char
Name[
0x20
];
};
struct stStudent arr[
3
] = {{
0
,
"张三"
},{
1
,
"李四"
},{
2
,
"王五"
}};
// 读
char
buffer[
0x20
];
strcpy(buffer,arr[
0
].Name);
// 写
strcpy(arr[
0
].Name,
"王钢蛋"
);
strcpy是一个字符串处理函数,用于字符串拷贝,其参数是传入的是两个地址,就谁传给谁。
最后我们来看一下结构体数组的内存结构,如下代码:
struct stStudent{
int
Age;
char
Name[
0x20
];
};
struct stStudent arr[
3
] = {{
0
,
"张三"
},{
1
,
"李四"
},{
2
,
"王五"
}};
int
x = arr[
0
].Age;
结构体 stStudent 的宽度为 8 + 32 = 40;我们观察到结构体数组在内存中是连续存储的。