引言

我们已经了解\(S_C\rightarrow S_{isa}\)

  • \(\{v_1, v_2, \dots, PC\}\rightarrow \{r_1, r_2, \dots, M, PC\}\)
    • 包括全局变量和局部变量
  • \(\{语句\}\rightarrow \{指令\}\)

 

本次课内容: 以RISC-V为例, 进一步解释映射的结果

  • 如何用RISC-V指令集实现C语言中的各种功能
  • 体会 “C语言是一门高级汇编语言”

常数, 变量, 运算

32位常数的装入

int f() { return 0x123; /* 291 */ }
int g() { return -1; }
int h() { return 0x1234; /* 4660 */ }
int i() { return 0xbb8; /* 3000 */ }
rv32gcc -O2 -c a.c
rvobjdump -M no-aliases -d a.o
lui  r,(imm[31:12]+imm[11])  # 若结果为0, 可省略
addi r,zero,imm[11:0]

理解lui中的imm[11]: 一大步(lui) + 一小步(addi)

________________________________________________
    |  |   |         |      |     |
   -1  |  291      3000     |    4660
       0                   4096
  • 根据addi的语义, imm[11:0]需要符号扩展
    • 相当于对lui结果进行±2048范围内的调整(可仔细推敲边界情况)
  • lui需要先装入一个距离imm不超过±2048范围的数
    • 如果imm[11:0]为负, 则lui需先右移4096, 再让addi左移

64位常数在RV64中的装入

long long j() { return 0x1234567800000000; }
long long k() { return 0x1234567887654321; }
rv64gcc -O2 -S a.c
  • 多一条移位指令slli
  • 如果再复杂一点, gcc干脆把常数放到只读数据段, 用ld读内存
    • 用访问数据的延迟换取更短的指令序列

 

  • 指令太多并不好: 乱序执行的高性能CPU中, 取指令的代价大于取数据
    • 在流水线中, 指令是数据的上游
    • 若从内存取数据, 只需让取数逻辑等待, 可先执行其他无关指令
    • 若从内存取指令, 则整条流水线都要等待

 

伪指令的定义可参考《RISC-V汇编语言编程手册》

64位常数在RV32中的装入

long long j() { return 0x1234567800000000; }
long long k() { return 0x1234567887654321; }
rv32gcc -O2 -S a.c

用两个32位寄存器联合存放一个64位数据

 

在这个版本中, clang生成的代码比gcc更好一些 😂

clang --target=riscv32 -O2 -S a.c

变量的大小和对齐

RISC-V的两套整数ABI

  • I = Integer, L = Long, P = Pointer

ILP32 ABI (RV32)

大小 对齐
bool/_Bool 1 1
char 1 1
short 2 2
int 4 4
long 4 4
long long 8 8
void * 4 4
float 4 4
double 8 8

LP64 ABI (RV64)

大小 对齐
bool/_Bool 1 1
char 1 1
short 2 2
int 4 4
long 8 8
long long 8 8
void * 8 8
float 4 4
double 8 8

变量的分配

\(\{v_1, v_2, \dots\}\rightarrow \{r_1, r_2, \dots, M\}\)

 

在实际的电路中

  • \(R\)访问速度较快(当前周期可读出), 但容量有限(RISC-V中只有32个)
  • \(M\)访问速度较慢(需要上百个周期读出), 但容量几乎无限(现代内存条8~32GB)
    • DDR颗粒中的存储单元由一个晶体管+一个电容组成
    • 电容需要充放电, 延迟普遍比晶体管大, 故通常\(M\)的访问速度较慢

 

C程序中的变量远多于寄存器, 该如何分配?

  • 直观的分配策略: 将常用的变量分配在\(R\), 将不常用的变量分配在\(M\)
    • 但编译器很难分析哪些变量更常用
  • 实际的分配策略: 所有变量先分配在\(M\), 需要访问时读入\(R\)

程序的内存布局

  • 与变量相关的三个内存区域: 静态数据区(data), 堆区(heap), 栈区(stack)
    • 静态 = 不动态增长和变化, 编译时确定
  • 四种需要分配的C变量
    • 全局变量 -> data区
    • 静态局部变量 -> data区
    • 非静态局部变量 -> stack区
    • 动态变量 -> heap区
  +----------+
  |          |
  +----------+
  |   stack  |
  +----------+
  |    |     |
  |    v     |
  |          |
  |    ^     |
  |    |     |
  +----------+
  |   heap   |
  +----------+
  |   data   |
  +----------+
  |   text   |
  +----------+
  |          |
0 +----------+
#include <stdio.h>
#include <stdlib.h>
int g;
void f(int n) {
  static int sl;
  int l, *h = malloc(4);
  printf("n = %2d, &g = %p, &sl = %p, &l = %p, h = %p\n", n, &g, &sl, &l, h);
  if (n < 5) f(n + 1);
}
int main() { f(1); printf("===\n"); f(1); return 0; }

变量的访问

#define def(type, name) \
  volatile type name ## _a; \
  volatile type name ## _b; \
  void f_##name () { name ## _a = name ## _b; }

def(_Bool, _Bool)
def(char, char)
def(signed char, signed_char)
def(short, short)
def(unsigned short, unsigned_short)
def(int, int)
def(unsigned int, unsigned_int)
def(long, long)
def(long long, long_long)
def(void *, void_)
def(float, float)
def(double, double)
rv32gcc -O2 -S a.c
rv64gcc -O2 -S a.c

 

一个例外: unsigned int在RV64中使用lw而不是lwu

变量的访问(2)

ILP32 ABI (RV32)

大小 指令
bool/_Bool 1 lbu/sb
char 1 lbu/sb
signed char 1 lb/sb
short 2 lh/sh
unsigned short 2 lhu/sh
int 4 lw/sw
unsigned int 4 lw/sw
long 4 lw/sw
long long 8 lw+lw/sw+sw
void * 4 lw/sw
float 4 flw/fsw
double 8 fld/fsd

LP64 ABI (RV64)

大小 指令
bool/_Bool 1 lbu/sb
char 1 lbu/sb
signed char 1 lb/sb
short 2 lh/sh
unsigned short 2 lhu/sh
int 4 lw/sw
unsigned int 4 lw/sw
long 8 ld/sd
long long 8 ld/sd
void * 8 ld/sd
float 4 flw/fsw
double 8 fld/fsd

变量分配不对齐会降低访问效率

不对齐即addr(n) % align(n) != 0, 如int变量分配在地址0x13

  • 硬件支持不对齐访存: 电路更复杂, 且需要两个周期以上
  • 软件支持不对齐访存: 抛异常, 效率很低

 

在x86上实测不对齐访存的性能

#include <stdlib.h>
#define LOOP 2000000
#define SIZE 10000
char buf[SIZE + 64] __attribute((aligned(64))); // 64 = 缓存块的大小(字节)
int main(int argc, char *argv[]) {
  int offset = atoi(argv[1]);
  for (int n = LOOP; n != 0; n --) {
    for (char *p = buf + offset; p < buf + SIZE; p += 64) { *(long *)p = 1; }
  }
  return 0;
}
gcc -O2 a.c && for i in `seq 1 64`; do TIME="$i: %E" /usr/bin/time ./a.out $i; done

在x86上, 当不对齐的数据跨越64字节边界时, 可观察到约2x的性能下降

运算和指令

C运算符 RISC-V指令
+, -, *, /, % add, sub, mul, div, rem
= mv, 访存指令
&, |, ^ and, or, xor
~ xori r, r, -1
<<, >> sll, srl, sra
! sltiu r, r, 1
<, > slt
long f1(long a, long b) { return a + b; }
long f2(long a, long b) { return a - b; }
long f3(long a, long b) { return a * b; }
long f4(long a, long b) { return a / b; }
// ...

通过-O1编译成汇编文件, 即可了解二者的联系

编译优化 - 尽可能在寄存器中进行运算

int sum = 0;
void f() {
  int i;
  for (i = 1; i <= 100; i ++) {
    sum += i;
  }
}

 

  • -O0 - 每次计算前先从内存读出变量, 每次计算后马上写回内存
  • -O1 - 开始计算前从内存读出变量, 计算过程在寄存器中进行, 计算全部结束后再写回内存
  • -O2 - 编译器直接把结果算好了 😂

RV32进行64位加法

long long f1(long long a, long long b) { return a + b; }
rv32gcc -O2 -S a.c

 

(a1, a0) <= (a1, a0) + (a3, a2)

           mv
    a1 a0 ===> a5
    a3 a2      |
               v
   carry <--- sltu
   +           ^
 ---------     |
    a1 a0 -----+

 

RV64进行128位加法, 过程类似

__int128 f1(__int128 a, __int128 b) { return a + b; }

有符号数和无符号数

#include <stdint.h>
 int32_t add1( int32_t a,  int32_t b) { return a + b; }
uint32_t add2(uint32_t a, uint32_t b) { return a + b; }
 int32_t cmp1( int32_t a,  int32_t b) { return a < b; }
 int32_t cmp2(uint32_t a, uint32_t b) { return a < b; }
 int32_t shr1( int32_t a,  int32_t b) { return a >> b; }
uint32_t shr2(uint32_t a,  int32_t b) { return a >> b; }
 int64_t zext1( int32_t a) { return a; }
uint64_t zext2(uint32_t a) { return a; }
  • add1add2的代码完全一样
    • 结论: 在RISC-V硬件看来, 有符号加法和无符号加法的行为完全一致
      • 可以使用同一个加法器模块进行计算

 

  • 比较 - slt/sltu
  • 右移 - sra/srl
  • 扩展到64位 - 符号扩展/零扩展
  • 乘, 除, 取余均有两种符号的指令

条件分支和循环

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

加法指令的溢出语义

一套ISA只会使用指定的一种编码方式表示有符号数(大部分是补码)

  • 因此对一套ISA来说, 加法指令的溢出结果是有定义的
    • RISC-V: 回滚, 即最大正数 + 1 = 最小负数
    • x86: 回滚, 同时设置溢出标志OF(overflow flag)
    • MIPS: addu/addiu - 回滚, add/addi - 抛异常
      • 这里的u充满误导性, 大部分组原老师认为有/无u分别对应C语言中的有/无符号加法
      • 就算没被误导, 估计也讲不清楚为什么 😂

 

事实: 大多数编程语言不要求有符号加法溢出时抛异常

移位

#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选项的更多信息

总结

C语言是一门高级汇编语言

  • 从C语言到二进制程序的结果比大家想象中的好理解
    • 常数, 变量, 运算, 条件分支, 循环
    • 看着C代码, 基本上可以 “手动编译”出机器指令

 

  • 未定义行为: 程序中的dark side
    • 可通过sanitizer应对

 

意义: 大家写C代码, 即可预测其将如何在计算机上运行

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