我们已经了解
本次课内容: 了解RISC-V指令集具体如何支撑C语言中的各种功能
更复杂了
slli
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;
}
在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
编译成汇编文件, 即可了解二者的联系
#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;
}
表面上语义等价的代码, 运行结果却不同
原因: 有符号整数加法溢出是UB
一套ISA只会使用指定的一种编码方式表示有符号数(大部分是补码)
addu/addiu
- 回滚, add/addi
- 抛异常
u
充满误导性,
大部分组原老师认为有/无u
分别对应C语言中的有/无符号加法
事实: 大多数编程语言不要求有符号加法溢出时抛异常
add/addi
显得很鸡肋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
编译, 得到不同结果
#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;
}
C语言规定, 整数除零是UB, 于是编译器可以任意处理
-1
但因为从语言层面来说就是UB, 添加-O2
之后gcc可以摆烂
ud2
ebreak
teq
设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 |
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;
}
用条件跳转指令决定是否进入代码块
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
风格的代码
循环的机器级表示 = 一条往回跳的条件跳转指令
函数调用的需求
printf()
)需要有一种共同的约定, 来规范函数调用在机器级表示的实现细节
回顾ISA状态机S = {<R, M>}
,
理论上传递方式有两种
M
传递
R
传递
R
的数量有限,
数量不够或参数过大(如结构体)则仍然通过M
传递R
传递根据RISC-V调用约定,
a0
~a7
用于传递参数,
a0
/a1
用于传递返回值
参数长度 | 首选传递方式 |
---|---|
<=XLEN |
寄存器, 值传递 |
2*XLEN |
一对寄存器, 值传递 |
>2*XLEN |
寄存器, 引用传递 |
RTFM了解更多细节: 对齐, 结构体位域, 可变参数等
通过一对寄存器传递__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);
}
jal offset
jal ra,offset
PC+4
保存到寄存器ra
PC+offset
auipc+jalr
两条指令的组合,
其原理与lui+addi
类似
ret
jalr zero,ra,0
PC+4
保存到寄存器zero
,
但硬件忽略该写入ra+0
, 即返回到上一次函数调用处之后假设函数f()
调用函数g()
g()
后,
寄存器可能还存放f()
的状态
f()
后执行出错解决这个问题的三种方案
f()
在调用g()
前先保存那些将来还会使用的寄存器
M
的栈上f()
不知道g()
要用多少寄存器,
可能造成过多的保存操作g()
在使用寄存器前保存
M
的栈上g()
不知道哪些寄存器是f()
还需要的,
同样可能造成过多的保存f()
保存(若还需使用),
另一部分由g()
保存寄存器 | 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个阶段
sp
申请栈帧(RISC-V
ABI规定栈往低地址方向生长)s0
~s11
的某些寄存器保存到栈帧ra
和部分临时寄存器保存到栈帧s0
~s11
和ra
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;
}
让我们来手写它的汇编代码!
与整数调用约定类似, ft0
-ft11
,
fs0
-fs11
,
fa0
-fa7
zero
, ra
, sp
,
gp
, tp
两个编译选项:
-march
- 指定是否使用相应指令和寄存器
-mabi
- 指定参数传递和引用方式
march\mabi | lp64 | lp64f | lp64d |
---|---|---|---|
rv64i | f: 软件模拟, 整数传参 d: 软件模拟, 整数传参 | 非法 | 非法 |
rv64if | f: 硬件指令, 整数传参 d: 软件模拟, 整数传参 | f: 硬件指令, 浮点传参 d: 软件模拟, 整数传参 | 非法 |
rv64ifd | f: 硬件指令, 整数传参 d: 硬件指令, 整数传参 | f: 硬件指令, 浮点传参 d: 硬件指令, 整数传参 | f: 硬件指令, 浮点传参 d: 硬件指令, 浮点传参 |
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 在反汇编结果中显示源代码
指针 = 变量的地址 = 访存指令中的有效地址
swap_data()
和swap_pointer()
编译出的指令序列完全一致-O1
编译,
swap_data()
和swap_pointer()
被优化成空函数
A[i]的地址 = A的地址 + i * sizeof(A)
意义: 大家写C代码, 即可预测其将如何在计算机上运行