程序的机器级表示-条件分支和循环&警惕未定义行为
📚 使用须知
- 本博客内容仅供学习参考
- 建议理解思路后独立实现
- 欢迎交流讨论
条件分支和循环
if-else
int f(int x) {
int y = 0;
if (x > 500) y = 150;
else if (x > 300) y = 100;
else if (x > 100) y = 75;
return y;
}
用条件跳转指令决定是否进入代码块
beq, bne, blt, bge, bltu, bgeu
还有4条伪指令: bgt, ble, bgtu, bleu, 通过交换指令的操作数实现
如bgt r1, r2, offset等价于blt r2, r1, offset
上述指令可覆盖有/无符号数的所有比较运算
诀窍: 可通过一条无符号比较指令实现有符号数的区间检查
按无符号比较时, 负数大于所有非负数
void f(int x) { if (x >= 300 && x <= 550) printf("A"); }
switch-case
int f(int x) {
int y = 0;
switch (x) {
case 1: y = 2; break;
case 2: case 3: y = 5; break;
case 4: case 5: case 6: case 7: y = 8; break;
case 8: case 9: case 10: case 11: y = 10; break;
case 12: y = 15; break;
default: y = -1; break;
}
return y;
}
用-O0或-O1编译, gcc生成了跳转表(x->分支入口的偏移)
用lw读出跳转表中的偏移, 计算出分支入口, 再通过jr跳转到分支
若用-O2编译本例, gcc直接生成了结果查找表(x->y)
直接读出x对应的y, 连跳转都省了
若是case 1, case 10, case 100, gcc则生成if-else风格的代码
构造跳转表的空间代价过大
while, do-while & for
int f(int n) {
int y = 0;
for (int i = 0; i < n; i ++) {
y += i;
}
return y;
}
循环的机器级表示 = 一条往回跳的条件跳转指令
跳转 = 继续循环
不跳转 = 退出循环
警惕未定义行为
整数加法溢出
#include <stdio.h>
#include <limits.h>
int foo(int x) { return (x + 1) > x; }
int main() {
printf("INT_MAX=%d, cmp: %d%d\n", INT_MAX, (INT_MAX + 1) > INT_MAX, foo(INT_MAX));
return 0;
}
gcc -w a.c && ./a.out
gcc -w -O2 a.c && ./a.out
clang -w a.c && ./a.out
clang -w -O2 a.c && ./a.out
表面上语义等价的代码, 运行结果却不同
原因: 有符号整数加法溢出是UB
不同机器可能采用不同的有符号数表示方法(原码, 反码, 补码…)
C标准难以统一定义有符号整数加法溢出的确切行为
如32位的原码和反码无法表示-2147483648
于是就UB了, 编译器可以任意处理
加法指令的溢出语义
一套ISA只会使用指定的一种编码方式表示有符号数(大部分是补码)
因此对一套ISA来说, 加法指令的溢出结果是有定义的
RISC-V: 回滚, 即最大正数 + 1 = 最小负数
x86: 回滚, 同时设置溢出标志OF(overflow flag)
MIPS: addu/addiu - 回滚, add/addi - 抛异常
这里的u充满误导性, 大部分组原老师认为有/无u分别对应C语言中的有/无符号加法
就算没被误导, 估计也讲不清楚为什么 😂
事实: 大多数编程语言不要求有符号加法溢出时抛异常
于是MIPS的add/addi显得很鸡肋
编译器不使用
为了腾出宝贵的操作码空间, MIPSr6终于去掉了抛异常的addi
移位
#include <stdio.h>
int main() {
int i = 30;
printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
return 0;
}
是否采用-O2编译, 得到不同结果
移位指令的行为是有定义的
但C标准中规定, 移位结果超出表示范围是UB
整数除零
#include <stdio.h>
int f(int a, int b) { return a / b; }
__attribute__((noinline)) int g() { return 1 / 0; }
__attribute__((noinline)) int h(int a, int b) { return a / b; }
int main() {
printf("1 / 0 = %d\n", 1 / 0);
printf("f(1, 0) = %d\n", f(1, 0));
printf("g(1, 0) = %d\n", g());
printf("h(1, 0) = %d\n", h(1, 0));
return 0;
}
clang -O2 a.c && ./a.out
C语言规定, 整数除零是UB, 于是编译器可以任意处理
0000000000401140 <g>:
401140: c3 retq
clang干脆把整个除法计算都删掉了 😂
函数返回的结果取决于调用时eax寄存器的值
除法指令的除零语义
x86: 抛异常
RISC-V: 结果定义为-1
rv64gcc a.c && ./a.out
MIPS: 不抛异常, 但计算的结果unpredictable
通过观察, QEMU中可能直接将被除数作为结果
mips-linux-gnu-gcc -mno-check-zero-division -static a.c && ./a.out
但因为从语言层面来说就是UB, 添加-O2之后gcc可以摆烂
生成一条自陷指令交给运行时环境来处理
x86 - ud2
RISC-V - ebreak
MIPS - teq
UB和应对
假设int通过补码表示, 长度32位
| 表达式 | 结果 | 表达式 | 结果 |
|---|---|---|---|
| UINT_MAX+1 | 0 | 1<<-1 | undefined |
| LONG_MAX+1 | undefined | 1<<0 | 1 |
| INT_MAX+1 | undefined | 1<<31 | undefined |
| SHRT_MAX+1 | INT_MAX==SHRT_MAX则undefined | 1<<32 | undefined |
| char c=CHAR_MAX; c++ | ??? | 1/0 | undefined |
| -INT_MIN | undefined | INT_MIN/-1 | undefined |
参考阅读: Understanding integer overflow in C/C++, ICSE 2012
科学的应对方式: sanitizer - 编译器自动插入assert()
gcc -fsanitize=undefined a.c && ./a.out
man gcc # 了解-fsanitize选项的更多信息
科学的应对方式: sanitizer - 编译器自动插入assert()
gcc -fsanitize=undefined a.c && ./a.out
man gcc # 了解-fsanitize选项的更多信息
总结
C语言是一门高级汇编语言
从C语言到二进制程序的结果比大家想象中的好理解
常数, 变量, 运算, 条件分支, 循环
看着C代码, 基本上可以 “手动编译”出机器指令
未定义行为: 程序中的dark side
可通过sanitizer应对
意义: 大家写C代码, 即可预测其将如何在计算机上运行
消除对软硬件协同的恐惧, 通过软件实现对机器底层行为的精确控制

C语言是一门高级汇编语言,从C语言到二进制程序的结果比大家想象中的好理解,常数, 变量, 运算, 条件分支, 循环,看着C代码, 基本上可以 “手动编译”出机器指令
浙公网安备 33010602011771号