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

对 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" 字符数组内容为 ABC\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 的作用 待复盘
能区分 strlensizeof 待复盘
能用 strcmp 比较字符串内容 待复盘
能用 fgets 安全读取一行输入 待复盘
能定义结构体并访问成员 待复盘
能定义结构体数组并遍历记录 待复盘
能写出结构体嵌套示例 待复盘
能用 sizeofoffsetof 观察结构体布局 待复盘
能说明结构体对齐为什么会影响大小 待复盘

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