这一篇接着数据类型往下走,重点是“数据如何参与表达式计算”。

C 语言里的运算符看起来简单,但很多安全问题都藏在这些小地方:类型被自动转换、整数被截断、条件判断写错、&&|| 的短路顺序被误用、=== 混淆、优先级靠猜。复盘这一章时,不需要追求花哨写法,先把表达式写清楚、算明白、调试看得到。

4.0 数据类型转换

类型转换可以分成两类:隐式转换和显式转换。

类型 示例 说明
隐式转换 double x = 10; 编译器自动把 int 转成 double
显式转换 (double)a / b 程序员明确要求转换
赋值转换 char c = 300; 赋值时目标类型可能装不下
表达式转换 1 + 2.5 运算前操作数会被转换到合适类型

整数和浮点数转换

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 division = %d\n", a / b);
printf("double division = %.2f\n", (double)a / b);

return 0;
}

a / b 两边都是整数,所以结果还是整数,得到 2。把其中一个操作数转换成 double 后,表达式按浮点数计算,得到 2.50

窄化转换和截断

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

int main(void)
{
int value = 300;
unsigned char byte = (unsigned char)value;

printf("value = %d\n", value);
printf("byte = %u\n", byte);

return 0;
}

unsigned char 通常只能保存 0 ~ 255。把 300 转进去时,会丢掉高位,只留下目标类型能保存的部分。

安全开发里,类型转换最容易影响长度、下标、偏移和缓冲区大小。看到强制转换时要多问一句:转换后范围有没有变?符号有没有变?高位有没有被截掉?

符号扩展和零扩展

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

int main(void)
{
signed char a = -1;
unsigned char b = 255;

int x = a;
int y = b;

printf("x = %d\n", x);
printf("y = %d\n", y);

return 0;
}

同样是 0xFF,如果原来按有符号数解释,提升成 int 时会保留负数含义;如果原来按无符号数解释,提升后就是 255

4.1 算术运算符

C 语言常见算术运算符包括 +-*/%

运算符 含义 示例
+ 加法 a + b
- 减法 a - b
* 乘法 a * b
/ 除法 a / b
% 取余 a % b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main(void)
{
int a = 17;
int b = 5;

printf("a + b = %d\n", a + b);
printf("a - b = %d\n", a - b);
printf("a * b = %d\n", a * b);
printf("a / b = %d\n", a / b);
printf("a %% b = %d\n", a % b);

return 0;
}

除法和取余

整数除法会保留整数部分,取余得到余数。

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

int main(void)
{
int total = 17;
int page_size = 5;

int full_pages = total / page_size;
int remain = total % page_size;

printf("full_pages = %d\n", full_pages);
printf("remain = %d\n", remain);

return 0;
}

这种写法常用于分页、分块、对齐和循环处理。后面处理文件块、网络包和缓冲区时也会用到。

除零问题

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 total = 10;
int count = 0;

if (count != 0)
{
printf("average = %d\n", total / count);
}
else
{
printf("count is zero\n");
}

return 0;
}

任何来自外部输入、文件、网络或配置的数据,在作为除数前都要检查是否为 0

4.2 前置后置递增操作

++-- 用来让变量自增或自减。

写法 含义
++i 先自增,再使用表达式结果
i++ 先使用原值,再自增
--i 先自减,再使用表达式结果
i-- 先使用原值,再自减
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main(void)
{
int a = 1;
int b = 1;

printf("++a = %d\n", ++a);
printf("b++ = %d\n", b++);

printf("a = %d\n", a);
printf("b = %d\n", b);

return 0;
}

不要写复杂自增表达式

1
2
// 不推荐:可读性差,还容易触发未定义行为
// int result = i++ + ++i;

初学和复盘阶段,尽量把自增单独写成一行。

1
2
3
4
int i = 0;

i++;
int result = i + 10;

这样不仅更容易读,也更方便断点调试。

4.3 反汇编角度探究语法底层原理

这一节不用急着背汇编指令。重点是建立一个直觉:C 语言表达式最终会被编译成 CPU 能执行的指令。

例如下面的代码:

1
2
3
int a = 10;
int b = 20;
int c = a + b;

从反汇编角度看,大致会经历:

  1. 把数据放进寄存器或内存。
  2. 执行加法指令。
  3. 把结果保存到目标位置。

调试时可以观察什么

窗口 可以观察
反汇编窗口 一行 C 代码对应了哪些底层指令
寄存器窗口 运算过程中寄存器值怎么变化
内存窗口 变量附近的字节内容
调用堆栈 当前执行路径从哪里来

现在不需要把每条汇编都背下来。先知道“语法不是魔法”,它最后都会落到指令、寄存器和内存上。后面学逆向时,这个直觉会慢慢接上。

注意优化影响

同一段 C 代码,在不同优化级别下可能生成完全不同的汇编。调试学习时,可以先用 Debug 模式观察;真正分析 Release 程序时,再考虑编译优化带来的变化。

4.4 赋值运算符

赋值运算符把右边表达式的结果保存到左边变量里。

写法 等价理解
a = b b 的值赋给 a
a += b a = a + b
a -= b a = a - b
a *= b a = a * b
a /= b a = a / b
a %= b a = a % b
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;

value += 5;
printf("value = %d\n", value);

value *= 2;
printf("value = %d\n", value);

value %= 6;
printf("value = %d\n", value);

return 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 is_admin = 0;

if (is_admin == 1)
{
printf("admin\n");
}
else
{
printf("normal user\n");
}

return 0;
}

= 是赋值,== 是比较。条件判断里把 == 写成 =,是非常经典的 bug。

4.5 比较运算符

比较运算符用于得到真假结果。C 语言中,比较结果通常是 10

运算符 含义
== 等于
!= 不等于
> 大于
< 小于
>= 大于等于
<= 小于等于
1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main(void)
{
int age = 20;

printf("%d\n", age >= 18);
printf("%d\n", age == 18);
printf("%d\n", age != 18);

return 0;
}

字符串不能直接用 == 比较内容

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 password[] = "123456";

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

return 0;
}

数组名参与表达式时经常会退化成地址。== 比较两个字符串数组时,比较的不是内容是否相同,而更可能是地址是否相同。

浮点数比较要留误差

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#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");
}

return 0;
}

4.6 逻辑运算符

逻辑运算符用于组合多个条件。

运算符 含义 示例
&& 逻辑与,两个条件都真才真 a > 0 && b > 0
` ` 逻辑或,至少一个条件为真 `a > 0 b > 0`
! 逻辑非,取反 !is_valid
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 age = 20;
int has_ticket = 1;

if (age >= 18 && has_ticket)
{
printf("allow\n");
}
else
{
printf("deny\n");
}

return 0;
}

短路求值

&&|| 会短路。

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 p = 0;
int value = 10;

if (p != 0 && value / p > 1)
{
printf("ok\n");
}
else
{
printf("skip dangerous expression\n");
}

return 0;
}

因为 p != 0 为假,&& 后面的 value / p 不会被执行,从而避免除零。

短路求值可以用来保护危险表达式,但不要把带副作用的操作写进复杂逻辑条件里。条件越关键,越应该写得直白。

4.7 运算优先级

运算符有优先级,但不要把代码写成“考读者背表”。

优先级方向 常见运算符
()、函数调用、数组下标
较高 !++--、强制类型转换
*/% 高于 +-
比较 <<=>>= 高于 ==!=
逻辑 && 高于 ` `
=+=-= 等赋值运算符
1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main(void)
{
int result1 = 1 + 2 * 3;
int result2 = (1 + 2) * 3;

printf("result1 = %d\n", result1);
printf("result2 = %d\n", result2);

return 0;
}

该加括号就加括号

1
2
3
4
if ((age >= 18) && (has_ticket || is_vip))
{
printf("allow\n");
}

这类括号不是多余,而是在告诉自己和读者:这里的条件关系就是这样设计的。

4.8 三目运算符

三目运算符也叫条件运算符:

1
condition ? value_if_true : value_if_false
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main(void)
{
int score = 75;
const char *status = (score >= 60) ? "pass" : "failed";

printf("%s\n", status);

return 0;
}

适合简单选择

三目运算符适合处理很短的二选一逻辑。

1
int max = (a > b) ? a : b;

如果逻辑里有多步操作、多个分支、日志输出或错误处理,就用正常的 if,不要硬塞进三目。

4.9 单行 IF 语句

C 语言允许 if 后面不写 {},只控制紧跟着的一条语句。

1
2
if (score >= 60)
printf("pass\n");

但复盘和正式代码里,更推荐写完整代码块:

1
2
3
4
if (score >= 60)
{
printf("pass\n");
}

为什么推荐加花括号

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

int main(void)
{
int is_admin = 0;

if (is_admin)
printf("admin\n");
printf("show sensitive menu\n");

return 0;
}

这段代码看缩进好像两行都属于 if,但实际上只有第一行受 if 控制,第二行无论如何都会执行。

涉及权限、边界判断、登录状态、文件写入和危险操作时,if 一律建议加 {}。少写两行括号省不了多少时间,但能少很多低级事故。

本章最小复盘代码

这段代码把类型转换、算术运算、逻辑判断、三目运算符和输入边界放在一起。

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

int main(void)
{
int total = 0;
int count = 0;

printf("input total and count: ");

#ifdef _MSC_VER
scanf_s("%d %d", &total, &count);
#else
scanf("%d %d", &total, &count);
#endif

if (count <= 0)
{
printf("count must be positive.\n");
return 1;
}

int average_int = total / count;
double average_double = (double)total / count;
const char *level = (average_double >= 60.0) ? "pass" : "failed";

printf("average_int = %d\n", average_int);
printf("average_double = %.2f\n", average_double);
printf("level = %s\n", level);

if ((average_double >= 60.0) && (count > 0))
{
printf("valid result\n");
}

return 0;
}

复盘时可以观察:

  1. total / count(double)total / count 的结果为什么不同。
  2. count <= 0 为什么要在除法之前判断。
  3. 三目运算符适不适合这里。
  4. && 两侧条件的执行顺序。
  5. 用断点看每一步赋值后的变量变化。

常见错误整理

错误 示例 问题
忘记类型转换 5 / 2 得到整数 2,不是 2.5
窄化转换 unsigned char c = 300; 发生截断
除零 a / b,但 b 可能为 0 程序异常或未定义行为
复杂自增 i++ + ++i 容易产生未定义行为
赋值比较混淆 if (flag = 1) 条件被赋值结果影响
字符串用 == 比较 str1 == str2 比较地址,不是内容
浮点数直接比较 a == b 精度误差导致结果不稳定
条件不加括号 `a && b c` 读者需要猜优先级
单行 if 漏花括号 缩进看似受控,实际不受控 可能造成权限或逻辑错误

学习检查清单

检查项 状态
能区分隐式转换和显式转换 待复盘
能解释整数除法和浮点除法的区别 待复盘
能说明窄化转换为什么可能截断数据 待复盘
能正确使用 +-*/% 待复盘
能说明前置 ++i 和后置 i++ 的区别 待复盘
能用调试窗口观察表达式执行过程 待复盘
能区分赋值运算符和比较运算符 待复盘
能用 strcmp 比较字符串内容 待复盘
能理解 && 和 ` ` 的短路求值 待复盘
能通过加括号让复杂条件更清晰 待复盘
能合理使用三目运算符处理简单二选一 待复盘
能坚持给 if 写花括号 待复盘

这一篇最重要的复盘点是:表达式不是只看表面符号,还要看每个操作数的类型、求值顺序、边界条件和最终保存到哪里。