引言

我们已经了解

  • C程序生成二进制程序的大致过程
  • RISC-V指令集
  • 一个简单的C程序如何在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 */ }
riscv64-linux-gnu-gcc -march=rv64g -O2 -c a.c
riscv64-linux-gnu-objdump -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)

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

64位常数的加载

long long j() { return 0x1234567800000000; }
long long k() { return 0x1234567800000001; }
riscv64-linux-gnu-gcc -O2 -S a.c

更复杂了

  • 多一条位移指令slli
  • 如果再复杂一点, gcc干脆把常数放到只读数据段, 用ld读内存
    • 用访问数据的延迟换取更短的指令序列
    • 在乱序执行的高性能处理器中, 取指令的代价比取数据更大
      • 若从内存取数据, 只需让取数逻辑等待, 可先执行其他无关指令
      • 若从内存取指令, 则整条流水线都要等待

 

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

变量的分配和对齐

RISC-V的两套整数ABI

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

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

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

在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 printf "%2d :" $i; time ./a.out $i; done

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

运算和指令

C运算符 RISC-V指令
+, -, *, /, % add, sub, mul, div, rem
= mv, ld, sd
&, |, ^ 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编译成汇编文件, 即可了解二者的联系

RV32进行64位加法

long long f1(long long a, long long b) { return a + b; }
riscv64-linux-gnu-gcc -march=rv32g -mabi=ilp32 -O2 -S a.c

 

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

 

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

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

警惕未定义行为 ⚠️

整数加法溢出

#include <stdio.h>
#include <limits.h>
int foo(int x) { return (x + 1) > x; }
int main() {
  printf("%d%d\n", (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来说, 加法指令的溢出结果是有定义的
    • x86: 回滚, 同时设置溢出标志OF(overflow flag)
    • RISC-V: 回滚
    • 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
riscv64-linux-gnu-gcc a.c && ./a.out
  • MIPS: 不抛异常, 但计算的结果unpredictable

 

但因为从语言层面来说就是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选项的更多信息

条件分支和循环

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;
    else if (x > 50) y = 50;
    return y;
}

用条件跳转指令决定是否进入代码块

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

循环的机器级表示 = 一条往回跳的条件跳转指令

  • 跳转 = 继续循环
  • 不跳转 = 退出循环

函数调用约定

函数调用需要考虑更多问题

函数调用的需求

  • 调用本文件的函数 - 貌似编译器自己处理就可以了
  • 调用其他文件的函数
    • 可能是其他编译器编译的
    • 也可能是汇编程序员开发的汇编代码
    • 还有可能是动态库(如printf())

需要有一种共同的约定, 来规范函数调用在机器级表示的实现细节

  • 这就是调用约定(calling convention), 通常是ABI的一部分
  • 需要约定如下内容
    • 参数和返回值如何传递?
    • 控制权如何转移?
    • 其余寄存器如何使用?

1. 参数和返回值

回顾ISA状态机S = {<R, M>}, 理论上传递方式有两种

  • 通过M传递
    • 需要一个支持嵌套调用, 动态变化(后进先出)的数据结构 - 栈
    • 32位的x86在system V ABI上通过栈来传递参数
  • 通过R传递
    • R的数量有限, 数量不够或参数过大(如结构体)则仍然通过M传递
    • 大部分RISC指令集的GPR数量丰富, 优先通过R传递

RISC-V的整数调用约定

根据RISC-V调用约定, a0~a7用于传递参数, a0/a1用于传递返回值

参数长度 首选传递方式
<=XLEN 寄存器, 值传递
2*XLEN 一对寄存器, 值传递
>2*XLEN 寄存器, 引用传递

RTFM了解更多细节: 对齐, 结构体位域, 可变参数等

 

通过寄存器传递int变量

__attribute__((noinline))
int f(int x0,int x1,int x2,int x3,int x4,int x5,int x6,int x7,int x8,int x9) {
  return x0 + x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + x9;
}
int g() { return f(0, 1, 2, 3, 4, 5, 6, 7, 8, 9); }

RISC-V的整数调用约定(2)

通过一对寄存器传递__uint128_t变量

#include <stdint.h>
__uint128_t u128(uint64_t hi, uint64_t lo) { return ((__uint128_t)hi << 64) + lo; }
__attribute__((noinline))
__uint128_t f(int x0,__uint128_t x1,__uint128_t x2,__uint128_t x3,__uint128_t x4) {
    return x0 + x1 + x2 + x3 + x4;
}
int g() { return f(0, u128(2,1), u128(4,3), u128(6,5), u128(8,7)); }

 

通过引用传递方式传递结构体地址

#include <stdio.h>
typedef struct { long x, y, z; } vec_t;
const vec_t a0 = {1, 2, 3}, b0 = {4, 5, 6};
__attribute__((noinline)) vec_t vadd(vec_t a, vec_t b) {
  vec_t c = { .x = a.x + b.x, .y = a.y + b.y, .z = a.z + b.z }; return c;
}
void f() {
  vec_t c = vadd(a0, b0);
  printf("c = (%ld, %ld, %ld)\n", c.x, c.y, c.z);
}

2. 控制权的转移

  • 函数调用伪指令 - jal offset
    • 其真实指令为jal ra,offset
    • 将返回地址PC+4保存到寄存器ra
    • 跳转到目标函数入口PC+offset
      • 若目标函数距离大于±1MB, 则采用auipc+jalr两条指令的组合, 其原理与lui+addi类似

 

  • 函数返回伪指令 - ret
    • 其真实指令为jalr zero,ra,0
    • 将返回地址PC+4保存到寄存器zero, 但硬件忽略该写入
    • 跳转到地址ra+0, 即返回到上一次函数调用处之后

3. 寄存器如何使用?

假设函数f()调用函数g()

  • 控制权转移到g()后, 寄存器可能还存放f()的状态
    • 若破坏, 将导致返回f()后执行出错

解决这个问题的三种方案

  • 方案1: 由f()在调用g()前先保存那些将来还会使用的寄存器
    • 保存到M的栈上
    • f()不知道g()要用多少寄存器, 可能造成过多的保存操作
  • 方案2: 由g()在使用寄存器前保存
    • 同样保存到M的栈上
    • g()不知道哪些寄存器是f()还需要的, 同样可能造成过多的保存
  • 方案3: 上述两个方案的组合
    • 一部分由f()保存(若还需使用), 另一部分由g()保存

RISC-V的寄存器使用约定

寄存器 ABI名 用途 调用前后一致 | 寄存器 ABI名 用途 调用前后一致
x0 zero 常数0 不可写入 | x5-x7 t0-t2 临时寄存器
x1 ra 返回地址 | x8-x9 s0-s1 保存寄存器
x2 sp 栈指针 | x10-x17 a0-a7 参数寄存器
x3 gp 全局指针 不可分配 | x18-x27 s2-s11 保存寄存器
x4 tp 线程指针 不可分配 | x28-x31 t3-t6 临时寄存器
  • 对于调用前后不保证一致的寄存器, 若仍需在g()返回后使用, 则需由f()保存
  • 对于调用前后保证一致的寄存器, 若g()需要使用, 则需由g()保存, 并在返回前恢复

栈帧和函数

栈帧 - 一个函数所使用的栈上的区域

一个函数的机器级表示通常有3个阶段

  • 准备阶段(prologue; 序)
    • 通过减小sp申请栈帧(RISC-V ABI规定栈往低地址方向生长)
    • 若需要, 则将s0~s11的某些寄存器保存到栈帧
    • 若需要, 则在栈帧中为局部变量和临时变量分配空间
  • 执行阶段 - 将C代码翻译成指令序列
    • 若要调用其他函数, 则将ra和部分临时寄存器保存到栈帧
  • 结束阶段(epilogue; 后记)
    • 若需要, 则从栈帧中恢复s0~s11ra
    • 通过增加sp释放栈帧
    • 执行ret返回

递归的例子 - 汉诺塔

#include <stdio.h>
void hanoi(int n, char from, char to, char via) {
  if (n == 1) { printf("%c -> %c\n", from , to); }
  else {
    hanoi(n - 1, from, via, to);
    hanoi(1,     from, to,  via);
    hanoi(n - 1, via,  to,  from);
  }
}
int main() {
  hanoi(3, 'A', 'B', 'C');
  return 0;
}

让我们来手写它的汇编代码!

 

另一个问题: 如何把上述递归代码改成非递归形式?

RISC-V的浮点数调用约定

与整数调用约定类似, ft0-ft11, fs0-fs11, fa0-fa7

  • 没有zero, ra, sp, gp, tp

 

两个编译选项:

  • -march - 指定是否使用相应指令和寄存器
    • 决定程序可在哪些硬件运行
  • -mabi - 指定参数传递和引用方式
    • 决定程序可与哪些软件链接

RISC-V的浮点数调用约定(2)

march\mabi lp64 lp64f lp64d
rv64i f: 软件模拟, 整数传参
d: 软件模拟, 整数传参
非法 非法
rv64if f: 硬件指令, 整数传参
d: 软件模拟, 整数传参
f: 硬件指令, 浮点传参
d: 软件模拟, 整数传参
非法
rv64ifd f: 硬件指令, 整数传参
d: 硬件指令, 整数传参
f: 硬件指令, 浮点传参
d: 硬件指令, 整数传参
f: 硬件指令, 浮点传参
d: 硬件指令, 浮点传参

 

float add_float(float a, float b) { return a + b; }
double add_double(double a, double b) { return a + b; }
riscv64-linux-gnu-gcc -O2 -march=rv64ifd -mabi=lp64f -S a.c

指针, 数组, 结构体, 联合体

指针

void swap_data(long x, long y) {
  long temp; temp = x; x = y; y = temp;
}

void swap_pointer(long *x, long *y) {
  long *temp; temp = x; x = y; y = temp;
}

void swap_pointer_dereference(long *x, long *y) {
  long temp; temp = *x; *x = *y; *y = temp;
}
riscv64-linux-gnu-gcc -march=rv64g -ggdb3 -c a.c  # -ggdb3  生成调试信息
riscv64-linux-gnu-objdump -S a.o  # -S  在反汇编结果中显示源代码

指针 = 变量的地址 = 访存指令中的有效地址

  • 如果不进行解引用, 在CPU看来指针和普通变量没有区别
    • swap_data()swap_pointer()编译出的指令序列完全一致
  • 使用-O1编译, swap_data()swap_pointer()被优化成空函数
    • 它们没有产生任何副作用

其他复杂类型

  • 数组
    • 元素类型相同的有序集合
    • 元素的存储空间从低地址往高地址连续分配
    • 可用下标索引, A[i]的地址 = A的地址 + i * sizeof(A)
  • 结构体
    • 元素类型不同的有序集合
    • 元素的存储空间从低地址往高地址连续分配
      • 可能会有对齐(alignment)和插空(padding)的现象
    • 只能用成员名称索引, 编译器会计算成员与结构体首地址的距离
  • 联合体
    • 元素类型不同的无序集合
    • 成员共享存储空间

一个复杂类型的例子

typedef union node {
  struct { unsigned char data1[8]; long *ptr; } node1;
  struct { long data2; union node *next; } node2;
} node_t;

void f(node_t *p) {
  p->node2.next->node2.data2 = *(p->node2.next->node1.ptr) + p->node1.data1[2];
}
riscv64-linux-gnu-gcc -O2 -S a.c
f:
    ld  a4,8(a0)
    lbu a3,2(a0)
    ld  a5,8(a4)
    ld  a5,0(a5)
    add a5,a5,a3
    sd  a5,0(a4)
    ret

总结

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

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

 

  • 调用约定: 规范函数调用的机器级实现, 使不同函数可正确互相调用
  • 未定义行为: 程序中的dark side
    • 可通过sanitizer应对

 

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

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