这一篇开始真正看“数据”本身。

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;
}

复盘时可以把断点放在:

  1. int a = 10;
  2. int sum = add(a, b);
  3. int result = left + right;
  4. 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> 使用 booltruefalse

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 语言条件判断中:

  1. 0 表示假。
  2. 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。所以字符和整数之间可以互相观察。

charsigned charunsigned 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;
}

复盘时可以观察:

  1. signed_valueunsigned_value 的底层位是否相同。
  2. score 在内存里是否就是直观的十进制小数。
  3. is_passed 输出为什么是 1
  4. byte 为什么用 %02X 更适合观察。
  5. strlen(text)sizeof(text) 为什么不同。

常见错误整理

错误 示例 问题
忽视整数范围 INT_MAX + 1 可能溢出
混用有符号和无符号 int indexunsigned size 比较 可能得到反直觉结果
误用八进制 int x = 010; 实际值是十进制 8
用浮点数做精确比较 0.1 + 0.2 == 0.3 可能不相等
char 当稳定有符号类型 char c = 0xFF; 不同编译器行为可能不同
分不清字符数和字节数 strlen("你好") 得到的是字节数
打印格式不匹配 %d 打印 size_t 输出异常或警告
忽略调试观察 只看代码猜结果 很难发现真实内存变化

学习检查清单

检查项 状态
能用断点观察变量值和函数执行流程 待复盘
能使用 sizeof 查看整数类型大小 待复盘
能解释 signedunsigned 的区别 待复盘
能说明为什么负数转换成无符号数可能变大 待复盘
能写代码观察整数溢出、回绕和截断 待复盘
能区分十进制、八进制、十六进制整数常量 待复盘
能解释浮点数为什么可能出现精度误差 待复盘
能用误差范围比较两个浮点数 待复盘
能说清楚 C 语言里 0 和非 0 的真假规则 待复盘
能说明 char 既可以表示字符,也可以表示小整数 待复盘
能区分 ASCII、GBK、UTF-8、UTF-16 的基本作用 待复盘
能解释 strlensizeof 在字符串上的区别 待复盘

这一篇的核心是“同一组字节可以被不同方式解释”。后面看内存、看反汇编、看网络包、看文件格式时,很多问题都不是数据变了,而是解释数据的方式变了。