这一篇是安全开发篇(一)的收尾:数组继续往多维展开,字符串开始接入输入输出和缓冲区边界,结构体则用于组织更复杂的数据。

对 C 语言来说,这几块内容非常重要。数组和字符串贴着内存走,结构体决定数据如何排列。后面学指针、文件格式、网络协议、PE 结构、逆向和漏洞分析时,都会反复回到这些基础问题:地址怎么算,长度是多少,是否越界,结构体成员在内存里怎么排。

7.0 多维数组特性解析

二维数组已经可以看成“行和列”,多维数组则是在这个基础上继续增加维度。

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

int main(void)
{
int cube[2][3][4] = {0};

cube[0][0][0] = 10;
cube[1][2][3] = 99;

printf("cube[0][0][0] = %d\n", cube[0][0][0]);
printf("cube[1][2][3] = %d\n", cube[1][2][3]);

return 0;
}

多维数组的理解方式

形式 可以理解成
int arr[5] 5 个 int
int arr[3][4] 3 行,每行 4 个 int
int arr[2][3][4] 2 个平面,每个平面 3 行,每行 4 个 int

虽然逻辑上有多维,物理内存中仍然是一段连续空间。

三维数组遍历

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 data[2][2][3] = {
{
{1, 2, 3},
{4, 5, 6}
},
{
{7, 8, 9},
{10, 11, 12}
}
};

for (int i = 0; i < 2; i++)
{
for (int j = 0; j < 2; j++)
{
for (int k = 0; k < 3; k++)
{
printf("data[%d][%d][%d] = %d\n", i, j, k, data[i][j][k]);
}
}
}

return 0;
}

多维数组复盘时,不要只记写法,要能说清楚每一层下标表示什么。

7.1 汇编下的寻址

数组访问的本质是地址计算。

1
2
int arr[5] = {10, 20, 30, 40, 50};
int value = arr[3];

arr[3] 的地址可以理解成:

1
数组首地址 + 3 * sizeof(int)

编译成底层指令后,CPU 不知道“数组”这个高级概念,它只是在做基址、索引、比例因子的地址计算。

一维数组地址观察

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

int main(void)
{
int arr[4] = {11, 22, 33, 44};

for (int i = 0; i < 4; i++)
{
printf("&arr[%d] = %p\n", i, (void *)&arr[i]);
}

return 0;
}

如果 int 是 4 字节,相邻地址通常相差 4。

二维数组地址公式

对于:

1
int matrix[rows][cols];

元素地址可以理解成:

1
matrix[row][col] = 首地址 + (row * cols + col) * sizeof(int)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

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

printf("&matrix[0][0] = %p\n", (void *)&matrix[0][0]);
printf("&matrix[0][1] = %p\n", (void *)&matrix[0][1]);
printf("&matrix[1][0] = %p\n", (void *)&matrix[1][0]);

return 0;
}

汇编里的寻址先不用急着背具体格式。先抓住核心:数组下标最终会变成地址偏移,元素类型决定每次偏移的步长。

7.2 数组越界缓冲区案例

数组越界是 C 语言里最重要的风险之一。它的本质不是“访问了不存在的东西”,而是访问到了数组外面的真实内存。

越界读取

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

int main(void)
{
int arr[3] = {10, 20, 30};

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

return 0;
}

合法下标是 0 ~ 2。如果写成 i <= 3,就会多访问一次。

1
2
3
4
5
// 错误示例:越界访问
// for (int i = 0; i <= 3; i++)
// {
// printf("arr[%d] = %d\n", i, arr[i]);
// }

这类代码可能崩溃,也可能看起来“能跑”,但结果已经不可靠。

字符缓冲区要留 \0

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

int main(void)
{
char name[8] = {0};

strncpy(name, "Ruiqy", sizeof(name) - 1);
name[sizeof(name) - 1] = '\0';

printf("name = %s\n", name);

return 0;
}

字符数组作为字符串使用时,必须给结尾的 \0 留位置。长度为 8 的缓冲区,最多安全保存 7 个普通字符。

更推荐的边界习惯

场景 建议
遍历数组 条件写成 i < count
字符串输入 限制最大读取长度
拷贝字符串 目标缓冲区大小要参与判断
打印字符串 确保字符串以 \0 结束
外部输入 先验证长度,再处理内容

这一节只做防御性复盘:知道越界会破坏相邻内存,写代码时主动检查边界。不要把越界当成“技巧”,它是后续很多崩溃和漏洞的源头。

7.3 字符与字符串区别

字符是单个字符常量,字符串是一串字符,并且以 \0 结束。

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

int main(void)
{
char ch = 'A';
char str[] = "A";

printf("ch = %c, sizeof(ch) = %zu\n", ch, sizeof(ch));
printf("str = %s, sizeof(str) = %zu\n", str, sizeof(str));

return 0;
}

'A' 是一个字符,通常占 1 字节。"A" 是字符串,里面有 'A' 和结尾的 '\0',所以数组大小通常是 2。

单引号和双引号

写法 含义
'A' 字符常量
"A" 字符串常量
'\0' 字符串结束符
"ABC" 字符数组内容为 A、B、C、\0

字符数组初始化

1
2
char a[] = {'R', 'u', 'i', 'q', 'y', '\0'};
char b[] = "Ruiqy";

这两种写法都能得到一个可作为字符串使用的字符数组。

7.4 字符串特性

C 字符串的核心特性是:以 \0 作为结束标志。

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

int main(void)
{
char text[] = "hello";

printf("strlen(text) = %zu\n", strlen(text));
printf("sizeof(text) = %zu\n", sizeof(text));

return 0;
}

strlen 统计 \0 之前的字符数量,sizeof 统计整个数组占用的字节数。

字符串数组和字符串指针

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

int main(void)
{
char text1[] = "hello";
const char *text2 = "world";

text1[0] = 'H';

printf("%s\n", text1);
printf("%s\n", text2);

return 0;
}

text1 是字符数组,内容在数组里,可以修改。text2 指向字符串常量,通常不应该修改。

字符串常量不要强行修改。写 char *p = "hello"; p[0] = 'H'; 这类代码很危险,可能直接崩溃。

空字符串

1
2
char empty1[] = "";
char empty2[10] = {0};

空字符串不是“什么都没有”,它至少有一个 \0。

7.5 字符串处理

C 标准库提供了很多字符串处理函数。使用时最重要的是:知道源字符串多长、目标缓冲区多大、是否能保证结尾有 \0。

函数 作用 注意
strlen 计算字符串长度 不包含结尾 \0
strcmp 比较字符串内容 相等返回 0
strcpy 拷贝字符串 容易越界,不推荐直接用
strncpy 限长拷贝 可能不自动补 \0
strcat 拼接字符串 目标空间必须足够
strchr 查找字符 找不到返回 NULL
strstr 查找子串 找不到返回 NULL

比较字符串

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

int main(void)
{
char input[] = "admin";

if (strcmp(input, "admin") == 0)
{
printf("match\n");
}
else
{
printf("not match\n");
}

return 0;
}

字符串内容比较用 strcmp,不要用 ==。

安全一点的拷贝习惯

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

#define NAME_SIZE 16

int main(void)
{
char name[NAME_SIZE] = {0};
const char *src = "Ruiqingyan";

strncpy(name, src, sizeof(name) - 1);
name[sizeof(name) - 1] = '\0';

printf("name = %s\n", name);

return 0;
}

这个写法的关键是:最多拷贝 sizeof(name) - 1 个字符,把最后一个位置留给 \0。

读取一整行

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>
#include <string.h>

#define LINE_SIZE 64

int main(void)
{
char line[LINE_SIZE] = {0};

printf("input: ");

if (fgets(line, sizeof(line), stdin) == NULL)
{
printf("input failed.\n");
return 1;
}

line[strcspn(line, "\n")] = '\0';

printf("line = %s\n", line);
return 0;
}

fgets 会把换行符也读进来,所以常用 strcspn 去掉结尾换行。

7.6 结构体类型

结构体可以把多个相关字段组合成一个整体。

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

struct Student
{
int id;
char name[32];
int score;
};

int main(void)
{
struct Student stu = {1, "Ruiqy", 95};

printf("id = %d\n", stu.id);
printf("name = %s\n", stu.name);
printf("score = %d\n", stu.score);

return 0;
}

结构体适合组织记录

字段 含义
id 编号
name 名称
score 分数

如果不用结构体,就要分别维护多个数组或变量,数据之间的关系会变散。

使用 typedef

1
2
3
4
5
6
typedef struct Student
{
int id;
char name[32];
int score;
} Student;

之后就可以直接写:

1
Student stu = {1, "Ruiqy", 95};

7.7 结构体数组

结构体数组用于保存多条同类记录。

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
#include <stdio.h>

typedef struct Student
{
int id;
char name[32];
int score;
} Student;

int main(void)
{
Student students[] = {
{1, "Alice", 88},
{2, "Bob", 92},
{3, "Cindy", 79}
};

size_t count = sizeof(students) / sizeof(students[0]);

for (size_t i = 0; i < count; i++)
{
printf("%d %-10s %d\n",
students[i].id,
students[i].name,
students[i].score);
}

return 0;
}

修改结构体成员

1
students[0].score = 95;

结构体数组和普通数组一样,合法下标是 0 ~ count - 1。访问 students[count] 就是越界。

7.8 结构体嵌套

结构体成员也可以是另一个结构体。

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
#include <stdio.h>

typedef struct Date
{
int year;
int month;
int day;
} Date;

typedef struct Student
{
int id;
char name[32];
Date birthday;
} Student;

int main(void)
{
Student stu = {
1,
"Ruiqy",
{2002, 9, 1}
};

printf("id = %d\n", stu.id);
printf("name = %s\n", stu.name);
printf("birthday = %04d-%02d-%02d\n",
stu.birthday.year,
stu.birthday.month,
stu.birthday.day);

return 0;
}

什么时候适合嵌套

场景 示例
一个对象里包含另一个对象 学生包含生日
一条记录里有分组字段 文件头包含版本、标志、大小
需要表达层次结构 账户包含权限、配置、状态

后面看文件格式和协议结构时,嵌套结构体会很常见。

7.9 结构体内存布局与对齐

结构体成员在内存里并不一定是紧紧挨着的。编译器会为了访问效率加入填充字节,这就是对齐。

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

typedef struct Demo
{
char a;
int b;
char c;
} Demo;

int main(void)
{
printf("sizeof(Demo) = %zu\n", sizeof(Demo));
printf("offset a = %zu\n", offsetof(Demo, a));
printf("offset b = %zu\n", offsetof(Demo, b));
printf("offset c = %zu\n", offsetof(Demo, c));

return 0;
}

sizeof(Demo) 可能不是 1 + 4 + 1 = 6,因为中间和末尾可能有填充。

成员顺序会影响大小

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>

typedef struct A
{
char c1;
int i;
char c2;
} A;

typedef struct B
{
int i;
char c1;
char c2;
} B;

int main(void)
{
printf("sizeof(A) = %zu\n", sizeof(A));
printf("sizeof(B) = %zu\n", sizeof(B));

return 0;
}

相同成员,不同顺序,结构体大小可能不同。

安全开发中的意义

场景 为什么要懂对齐
文件格式解析 结构体大小不一定等于字段大小相加
网络协议 不能盲目把字节流强转成结构体
逆向分析 成员偏移有助于理解对象布局
漏洞分析 越界写入可能覆盖相邻成员或填充区域

结构体对齐和平台、编译器、编译选项有关。涉及文件、网络和跨平台数据时,不要只依赖 sizeof(struct),要明确字段长度、字节序和对齐规则。

本章最小复盘代码

这段代码把字符串、结构体数组和边界控制放在一起。

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
#include <stdio.h>
#include <string.h>

#define NAME_SIZE 32

typedef struct Student
{
int id;
char name[NAME_SIZE];
int score;
} Student;

int main(void)
{
Student students[3] = {
{1, "Alice", 88},
{2, "Bob", 92},
{3, "Cindy", 79}
};

size_t count = sizeof(students) / sizeof(students[0]);

for (size_t i = 0; i < count; i++)
{
printf("%d %-10s %d\n",
students[i].id,
students[i].name,
students[i].score);
}

char new_name[NAME_SIZE] = {0};
strncpy(new_name, "Ruiqingyan", sizeof(new_name) - 1);
new_name[sizeof(new_name) - 1] = '\0';

strncpy(students[0].name, new_name, sizeof(students[0].name) - 1);
students[0].name[sizeof(students[0].name) - 1] = '\0';

printf("updated: %s\n", students[0].name);

return 0;
}

复盘时重点看:

  1. students 是结构体数组,每个元素都是一条记录。
  2. name 是结构体里的字符数组,不是无限长字符串。
  3. strncpy 后手动补 \0 是为了保证字符串结束。
  4. count 只在当前作用域里能用 sizeof 这样计算。
  5. 结构体成员在内存里可能存在对齐填充。

常见错误整理

错误 示例 问题
多维数组下标写错 arr[z][y][x] 顺序混乱 访问到错误元素
误解多维数组内存 以为每一行不连续 地址计算理解错误
数组越界 arr[count] 访问数组外内存
忘记字符串结尾 字符数组没有 \0 打印越界、乱码或崩溃
混淆字符和字符串 'A' 与 "A" 类型和大小不同
用 == 比较字符串 name == "admin" 比较地址,不是内容
盲目使用 strcpy 目标缓冲区不够 可能越界写入
修改字符串常量 char *p = "hi"; p[0] = 'H'; 可能崩溃
结构体数组越界 students[count] 访问不存在的记录
误算结构体大小 字段大小直接相加 忽略对齐和填充
直接把网络数据强转结构体 (Header *)buffer 对齐、字节序、长度都可能出问题

学习检查清单

检查项 状态
能写出三维数组并用三层循环遍历 待复盘
能说明数组寻址公式和元素步长 待复盘
能解释二维数组按行优先连续存储 待复盘
能说清楚数组越界为什么危险 待复盘
能区分字符 'A' 和字符串 "A" 待复盘
能解释字符串结尾 \0 的作用 待复盘
能区分 strlen 和 sizeof 待复盘
能用 strcmp 比较字符串内容 待复盘
能用 fgets 安全读取一行输入 待复盘
能定义结构体并访问成员 待复盘
能定义结构体数组并遍历记录 待复盘
能写出结构体嵌套示例 待复盘
能用 sizeof 和 offsetof 观察结构体布局 待复盘
能说明结构体对齐为什么会影响大小 待复盘

这一篇的核心是“数据结构背后仍然是内存布局”。数组、字符串和结构体都不是孤立语法,它们共同决定了数据怎么排、怎么找、怎么越界、怎么被安全地处理。