这一篇开始真正看“数据”本身。
C 语言里的数据不是抽象概念,它最终都会落到内存里的二进制位上。整数有宽度,符号位会影响解释方式,浮点数不一定能精确表示小数,字符也不只是屏幕上看到的字母。后面学指针、内存、溢出、编码、逆向和漏洞时,这些内容会反复出现。
3.0 代码调试与窗口监控
调试不是代码出错后才做的事情。学习 C 语言时,调试更像是一种观察方式:让程序暂停在某一行,看变量、内存、调用栈和输出窗口分别发生了什么。
常用调试动作
| 动作 |
作用 |
| 设置断点 |
让程序运行到指定位置暂停 |
| 单步跳过 |
执行当前行,不进入函数内部 |
| 单步进入 |
遇到函数调用时进入函数内部 |
| 单步跳出 |
从当前函数执行完并返回调用处 |
| 观察变量 |
看变量当前值是否符合预期 |
| 观察内存 |
直接看某个地址附近的字节内容 |
| 调用堆栈 |
看当前函数是从哪里被调用过来的 |
适合用来调试的最小程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| #include <stdio.h>
int add(int left, int right) { int result = left + right; return result; }
int main(void) { int a = 10; int b = 20; int sum = add(a, b);
printf("sum = %d\n", sum);
return 0; }
|
复盘时可以把断点放在:
int a = 10;
int sum = add(a, b);
int result = left + right;
printf("sum = %d\n", sum);
这样能看到程序从 main 进入 add,再从 add 返回 main 的过程。
后面学函数栈、返回地址、栈溢出时,调试窗口和内存窗口会非常重要。现在先养成一个习惯:不要只看代码“猜”,要让程序停下来,看它真实执行到了哪里。
3.1 整数类型
整数类型用于表示没有小数部分的数据,比如年龄、长度、计数、数组下标、文件大小和偏移。
| 类型 |
常见大小 |
说明 |
char |
1 字节 |
既可表示字符,也可当小整数 |
short |
2 字节 |
短整型 |
int |
4 字节 |
最常用整数类型 |
long |
4 或 8 字节 |
与平台有关 |
long long |
8 字节 |
长长整型 |
具体大小不要只靠背,直接用 sizeof 看最稳。
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdio.h>
int main(void) { printf("sizeof(char) = %zu\n", sizeof(char)); printf("sizeof(short) = %zu\n", sizeof(short)); printf("sizeof(int) = %zu\n", sizeof(int)); printf("sizeof(long) = %zu\n", sizeof(long)); printf("sizeof(long long) = %zu\n", sizeof(long long));
return 0; }
|
使用标准整数类型
如果需要明确“这个数必须是 32 位”或“这个数必须是 64 位”,可以使用 <stdint.h>。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| #include <stdio.h> #include <stdint.h>
int main(void) { int8_t a = 127; int16_t b = 32767; int32_t c = 2147483647; int64_t d = 9223372036854775807LL;
printf("a = %d\n", a); printf("b = %d\n", b); printf("c = %d\n", c); printf("d = %lld\n", (long long)d);
return 0; }
|
安全开发里经常会处理文件格式、网络协议和二进制结构。这个时候数据宽度必须明确,不能只写一个模糊的 int。
3.2 有符号与无符号数据
同样一串二进制位,可以按有符号数解释,也可以按无符号数解释。
| 类型 |
说明 |
signed |
可以表示正数、负数和 0 |
unsigned |
只表示非负数 |
1 2 3 4 5 6 7 8 9 10 11
| #include <stdio.h> #include <limits.h>
int main(void) { printf("INT_MIN = %d\n", INT_MIN); printf("INT_MAX = %d\n", INT_MAX); printf("UINT_MAX = %u\n", UINT_MAX);
return 0; }
|
同一组字节,不同解释
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdio.h>
int main(void) { signed char a = -1; unsigned char b = (unsigned char)a;
printf("signed = %d\n", a); printf("unsigned = %u\n", b);
return 0; }
|
在很多机器上,signed char 的 -1 底层字节是 0xFF。如果把这组位按无符号解释,就会得到 255。
安全复盘重点
长度、下标、偏移、文件大小这类数据经常使用无符号类型。一旦把负数转换成无符号数,可能会变成一个非常大的值,这就是很多边界判断错误的来源。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| #include <stdio.h>
int main(void) { int index = -1; unsigned int size = 10;
if ((unsigned int)index < size) { printf("index is valid\n"); } else { printf("index is invalid\n"); }
return 0; }
|
这段代码表面上是在判断下标是否合法,但强制转换会改变 index 的解释方式。写边界判断时要尽量避免这种含混写法。
3.3 数据溢出与精度丢失
数据类型有范围。超过范围后,结果可能不是“报错”,而是变成另一个值。
整数溢出
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdio.h> #include <limits.h>
int main(void) { int max = INT_MAX;
printf("max = %d\n", max); printf("max + 1 = %d\n", max + 1);
return 0; }
|
有符号整数溢出在 C 标准里属于未定义行为。不同编译器、优化级别、平台下结果都可能不一样。
无符号整数溢出则会按模运算回绕。
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdio.h> #include <limits.h>
int main(void) { unsigned int value = UINT_MAX;
printf("value = %u\n", value); printf("value + 1 = %u\n", value + 1);
return 0; }
|
精度丢失
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdio.h>
int main(void) { int a = 5; int b = 2;
printf("int result = %d\n", a / b); printf("double result = %.2f\n", (double)a / b);
return 0; }
|
整数除法会丢掉小数部分。想得到小数结果,至少要让其中一个操作数变成浮点类型。
截断
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdio.h>
int main(void) { int value = 300; unsigned char small = (unsigned char)value;
printf("value = %d\n", value); printf("small = %u\n", small);
return 0; }
|
unsigned char 通常只能保存 0 ~ 255。把 300 放进去,会发生截断,只留下低位部分。
3.4 整数常量与常见样式
整数常量可以用不同进制和后缀表示。
| 写法 |
含义 |
10 |
十进制 |
010 |
八进制,值是十进制 8 |
0x10 |
十六进制,值是十进制 16 |
0b1010 |
二进制,部分编译器支持 |
100U |
无符号整数 |
100L |
long |
100LL |
long long |
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| #include <stdio.h>
int main(void) { int dec = 10; int oct = 010; int hex = 0x10;
printf("dec = %d\n", dec); printf("oct = %d\n", oct); printf("hex = %d\n", hex);
return 0; }
|
十六进制更适合看二进制结构
安全开发和逆向里经常写十六进制,因为一个十六进制位刚好对应 4 个二进制位。
1 2 3 4 5 6 7 8 9 10 11
| #include <stdio.h>
int main(void) { unsigned int flag = 0x0000000F;
printf("dec = %u\n", flag); printf("hex = 0x%08X\n", flag);
return 0; }
|
0x%08X 这种写法适合观察标志位、地址、偏移和二进制结构。
3.5 浮点型
浮点数用于表示小数,但它不是十进制小数的精确容器。
| 类型 |
常见大小 |
说明 |
float |
4 字节 |
单精度 |
double |
8 字节 |
双精度,C 里更常用 |
long double |
与平台有关 |
更高精度或更大范围,取决于实现 |
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdio.h>
int main(void) { float a = 0.1f; double b = 0.1;
printf("float = %.10f\n", a); printf("double = %.17f\n", b);
return 0; }
|
输出时可能会看到看似“多出来”的小数,这是因为很多十进制小数无法被二进制浮点数精确表示。
浮点数不要直接比较相等
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| #include <stdio.h> #include <math.h>
int main(void) { double a = 0.1 + 0.2; double b = 0.3;
if (fabs(a - b) < 1e-9) { printf("almost equal\n"); } else { printf("not equal\n"); }
return 0; }
|
涉及金额、计数、权限、长度时,不要随便用浮点数。安全和系统开发里,很多场景更适合用整数表示单位,比如用“分”表示金额,用“字节”表示长度。
3.6 小数编码规则
浮点数大致可以理解成三部分:
| 部分 |
作用 |
| 符号位 |
表示正负 |
| 指数 |
表示数量级 |
| 尾数 |
表示有效数字 |
这也是为什么浮点数有范围、有精度,而且不是所有小数都能精确表示。
观察浮点数的字节
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| #include <stdio.h>
int main(void) { float value = 3.14f; unsigned char *p = (unsigned char *)&value;
for (size_t i = 0; i < sizeof(value); i++) { printf("byte[%zu] = 0x%02X\n", i, p[i]); }
return 0; }
|
这段代码不是为了背出每一位怎么分布,而是提醒自己:屏幕上看到的 3.14,进入内存后也是一组字节。
复盘浮点数时可以抓住一句话:浮点数擅长表示近似连续的数值,不擅长表示必须完全精确的十进制结果。
3.7 布尔类型
C99 开始可以通过 <stdbool.h> 使用 bool、true、false。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| #include <stdio.h> #include <stdbool.h>
int main(void) { bool is_admin = false; bool has_token = true;
if (has_token && !is_admin) { printf("normal user\n"); }
return 0; }
|
C 语言里的真假
在 C 语言条件判断中:
0 表示假。
- 非
0 表示真。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| #include <stdio.h>
int main(void) { int value = -10;
if (value) { printf("true\n"); } else { printf("false\n"); }
return 0; }
|
安全复盘重点
不要把“非 0 即真”写得太隐晦。涉及权限、状态、错误码时,最好让判断表达清楚。
1 2 3 4 5 6 7 8
| if (login_result == 0) { printf("login success\n"); } else { printf("login failed\n"); }
|
这比只写 if (!login_result) 更适合复盘。
3.8 字符类型
char 本质上也是整数类型,只是经常用来表示字符。
1 2 3 4 5 6 7 8 9 10 11
| #include <stdio.h>
int main(void) { char ch = 'A';
printf("char = %c\n", ch); printf("code = %d\n", ch);
return 0; }
|
'A' 对应的 ASCII 编码是 65。所以字符和整数之间可以互相观察。
char、signed char、unsigned char
普通 char 到底是有符号还是无符号,取决于编译器实现。处理原始字节时,建议明确使用 unsigned char。
1 2 3 4 5 6 7 8 9 10 11
| #include <stdio.h>
int main(void) { unsigned char byte = 0xFF;
printf("byte dec = %u\n", byte); printf("byte hex = 0x%02X\n", byte);
return 0; }
|
后面看文件内容、网络包、内存转储时,unsigned char 会非常常见。
3.9 字符编码规则
字符编码解决的是“字符如何映射成数字”的问题。
| 编码 |
说明 |
| ASCII |
早期常见英文字符编码,范围较小 |
| GBK |
常见中文编码之一 |
| UTF-8 |
现在更常见的 Unicode 编码方式,英文 1 字节,中文通常 3 字节 |
| UTF-16 |
Windows 内部和宽字符相关内容经常会遇到 |
ASCII 观察
1 2 3 4 5 6 7 8 9 10 11
| #include <stdio.h>
int main(void) { for (char ch = 'A'; ch <= 'Z'; ch++) { printf("%c = %d = 0x%02X\n", ch, ch, ch); }
return 0; }
|
字符串长度和字节数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| #include <stdio.h> #include <string.h>
int main(void) { char text1[] = "ABC"; char text2[] = "你好";
printf("strlen(text1) = %zu\n", strlen(text1)); printf("sizeof(text1) = %zu\n", sizeof(text1));
printf("strlen(text2) = %zu\n", strlen(text2)); printf("sizeof(text2) = %zu\n", sizeof(text2));
return 0; }
|
strlen 统计的是字符串中 \0 之前的字节数,不是“人眼看到的字符个数”。中文、emoji、多字节编码都会让这个问题变复杂。
安全开发里,长度经常按“字节”计算,而不是按“字符个数”计算。处理字符串、缓冲区、文件路径和网络数据时,一定要分清楚字符、字节、编码和结束符。
本章最小复盘代码
这段代码把整数、无符号数、浮点数、布尔值和字符放在一起,适合用断点和内存窗口观察。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| #include <stdio.h> #include <stdbool.h> #include <stdint.h> #include <string.h>
int main(void) { int32_t signed_value = -1; uint32_t unsigned_value = (uint32_t)signed_value; float score = 95.5f; bool is_passed = score >= 60.0f; unsigned char byte = 0xFF; char text[] = "ABC";
printf("signed_value = %d\n", signed_value); printf("unsigned_value = %u\n", unsigned_value); printf("score = %.2f\n", score); printf("is_passed = %d\n", is_passed); printf("byte = 0x%02X\n", byte); printf("strlen(text) = %zu\n", strlen(text)); printf("sizeof(text) = %zu\n", sizeof(text));
return 0; }
|
复盘时可以观察:
signed_value 和 unsigned_value 的底层位是否相同。
score 在内存里是否就是直观的十进制小数。
is_passed 输出为什么是 1。
byte 为什么用 %02X 更适合观察。
strlen(text) 和 sizeof(text) 为什么不同。
常见错误整理
| 错误 |
示例 |
问题 |
| 忽视整数范围 |
INT_MAX + 1 |
可能溢出 |
| 混用有符号和无符号 |
int index 与 unsigned size 比较 |
可能得到反直觉结果 |
| 误用八进制 |
int x = 010; |
实际值是十进制 8 |
| 用浮点数做精确比较 |
0.1 + 0.2 == 0.3 |
可能不相等 |
把 char 当稳定有符号类型 |
char c = 0xFF; |
不同编译器行为可能不同 |
| 分不清字符数和字节数 |
strlen("你好") |
得到的是字节数 |
| 打印格式不匹配 |
%d 打印 size_t |
输出异常或警告 |
| 忽略调试观察 |
只看代码猜结果 |
很难发现真实内存变化 |
学习检查清单
| 检查项 |
状态 |
| 能用断点观察变量值和函数执行流程 |
待复盘 |
能使用 sizeof 查看整数类型大小 |
待复盘 |
能解释 signed 和 unsigned 的区别 |
待复盘 |
| 能说明为什么负数转换成无符号数可能变大 |
待复盘 |
| 能写代码观察整数溢出、回绕和截断 |
待复盘 |
| 能区分十进制、八进制、十六进制整数常量 |
待复盘 |
| 能解释浮点数为什么可能出现精度误差 |
待复盘 |
| 能用误差范围比较两个浮点数 |
待复盘 |
能说清楚 C 语言里 0 和非 0 的真假规则 |
待复盘 |
能说明 char 既可以表示字符,也可以表示小整数 |
待复盘 |
| 能区分 ASCII、GBK、UTF-8、UTF-16 的基本作用 |
待复盘 |
能解释 strlen 和 sizeof 在字符串上的区别 |
待复盘 |
这一篇的核心是“同一组字节可以被不同方式解释”。后面看内存、看反汇编、看网络包、看文件格式时,很多问题都不是数据变了,而是解释数据的方式变了。