程序的机器级表示-条件分支和循环&警惕未定义行为

📚 使用须知

  • 本博客内容仅供学习参考
  • 建议理解思路后独立实现
  • 欢迎交流讨论

task : 条件分支和循环&警惕未定义行为

条件分支和循环

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代码, 即可预测其将如何在计算机上运行

消除对软硬件协同的恐惧, 通过软件实现对机器底层行为的精确控制

posted @ 2025-12-02 13:29  mo686  阅读(8)  评论(0)    收藏  举报