这一篇是安全开发篇(一)的收尾:数组继续往多维展开,字符串开始接入输入输出和缓冲区边界,结构体则用于组织更复杂的数据。
对 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] 的地址可以理解成:
编译成底层指令后,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 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,就会多访问一次。
这类代码可能崩溃,也可能看起来“能跑”,但结果已经不可靠。
字符缓冲区要留 \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 ; }
修改结构体成员
结构体数组和普通数组一样,合法下标是 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 ; }
复盘时重点看:
students 是结构体数组,每个元素都是一条记录。
name 是结构体里的字符数组,不是无限长字符串。
strncpy 后手动补 \0 是为了保证字符串结束。
count 只在当前作用域里能用 sizeof 这样计算。
结构体成员在内存里可能存在对齐填充。
常见错误整理
错误
示例
问题
多维数组下标写错
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 观察结构体布局
待复盘
能说明结构体对齐为什么会影响大小
待复盘
这一篇的核心是“数据结构背后仍然是内存布局”。数组、字符串和结构体都不是孤立语法,它们共同决定了数据怎么排、怎么找、怎么越界、怎么被安全地处理。