我们已经了解如何用RISC-V指令集来实现C语言的功能
本次课内容: 更多C语言特性的机器级表示
考虑f()
调用g()
g
和f
在同一个源文件 -
貌似编译器自己处理就可以了g
在其他文件
printf()
)需要有一种共同的约定, 来规范函数调用在机器级表示的实现细节
f
和g
都要使用寄存器, 如何协商?回顾\(S_{isa} = \{<R, M>\}\), 理论上传递方式有两种
根据RISC-V调用约定,
a0
~a7
用于传递参数,
a0
/a1
用于传递返回值
参数长度 | 首选传递方式 |
---|---|
<=XLEN |
寄存器, 值传递 |
2*XLEN |
一对寄存器, 值传递 |
>2*XLEN |
寄存器, 引用传递 |
通过寄存器传递int
变量
若参数寄存器不够用, 则剩下的参数通过\(M\)传递
在RV32中通过一对寄存器传递uint64_t
变量
a0
和a1
#include <stdint.h>
__attribute__((noinline)) uint64_t g(int x0, uint64_t x1, uint64_t x2, uint64_t x3,
uint64_t x4, uint64_t x5) { return x0 + x1 + x2 + x3 + x4 + x5; }
uint64_t f() { return g(0, 0x0000000200000001ull, 0x0000000400000003ull,
0x0000000600000005ull, 0x0000000800000007ull,
0x0000000a00000009ull); }
边界情况:
类似情况: 在RV64中通过一对寄存器传递__uint128_t
变量
在RV32中通过引用传递传递long double
变量
#include <stdint.h>
__attribute__((noinline)) long double g(int x0, long double x1, int x2) {
return x0 + x1 + x2; }
long double f() { return g(0, 0.0, 1); }
f
先在栈上为long double
参数和返回值分配一段临时空间,
把变量复制到临时空间中, 然后传递临时空间的地址f
传进来的地址所在的临时空间,
并将这个地址放到a0
可变参数的传递与命名参数的传递基本相同
#include <stdint.h>
#include <stdarg.h>
uint64_t sum(int n, ...) {
va_list ap;
va_start(ap, n);
uint64_t sum = 0;
for (int i = 0; i < n; i ++) {
sum += va_arg(ap, uint64_t);
}
va_end(ap);
return sum;
}
uint64_t g1() { return sum(1, 0ull); }
uint64_t g2() { return sum(3, 1ull, 2ull, 3ull); }
uint64_t g3() { return sum(4, 10ull, 20ull, 30ull, 40ull); }
jal offset
jal ra,offset
PC+4
保存到寄存器ra
jal
指令的下一条静态指令的地址PC+offset
auipc+jalr
两条指令的组合,
其原理与lui+addi
类似
ret
jalr zero,ra,0
PC+4
保存到寄存器zero
,
但硬件忽略该写入ra+0
, 即返回到上一次函数调用处之后假设f()
调用g()
g
后, 寄存器可能还存放f
的状态
f
后执行出错解决这个问题的三种方案
f
在调用g
前先保存那些将来还会使用的寄存器
f
不知道g
要用多少寄存器,
可能造成过多的保存操作g
在使用寄存器前保存
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
申请栈帧ra
保存到栈帧,
把部分临时寄存器移到保存寄存器ra
sp
释放栈帧ret
返回// f1() -> f2() -> f3()
| |
| |
+-----------+---
| | ^
| saved reg | |
| local var | f1
| temp var | |
| ra | v
+-----------+---
| | ^
| saved reg | |
| local var | f2
| temp var | |
| ra | v
+-----------+---
| | ^
| saved reg | |
| local var | f3
| temp var | |
| ra | v
sp -->+-----------+---
| | |
| | |
| V |
#include <stdio.h>
#define N 10
int array[N] = {2, 6, 9, 3, 8, 4, 7, 5, 10, 1};
int min(int *a, int len, int left) { // 叶子函数, 即不会调用其他函数 -> 无需保存ra
int m = left; // 优先使用临时/参数寄存器 -> 无需保存s0~s11
for (int i = left; i < len; i ++) // 因此准备阶段无需申请栈桢
if (a[i] < a[m]) { m = i; } // 结束阶段只有ret
return m;
}
void sort(int *a, int len) { // 非叶子函数, 会调用其他函数
for (int i = 0; i < len; i++) { // a, len和i在调用min()后仍继续使用
int m = min(a, len, i); // 需要将a和len从a0和a1移到保存寄存器
int tmp = a[i]; // 优先将i分配在保存寄存器
a[i] = a[m]; // 因此准备阶段申请栈桢, 保存ra和要用的保存寄存器
a[m] = tmp; // 结束阶段要恢复ra和之前保存的寄存器, 释放栈桢
}
}
void print_msg() { // 尾调用(调用后直接返回), 优化成无条件跳转
printf("output:\n"); // 将ra的保存和恢复工作交给目标函数
} // 由目标函数直接返回到当前函数的调用者:
// main ==(jal)=> print_msg ==(j)=> printf ==(ret)=> main
void print_array() { // i在调用printf()后仍继续使用, 优先分配在保存寄存器
for (int i = 0; i < N; i++) // 将字符串和array的地址读入到保存寄存器, 在调用printf()后继续使用
printf("%d\n", array[i]); // 因此准备阶段申请栈桢, 保存ra和要用的保存寄存器
} // 结束阶段要恢复ra和之前保存的寄存器, 释放栈桢
int main () { // 准备阶段申请栈桢, 保存ra
sort(array, N); print_msg(); print_array();
return 0; // 结束阶段要恢复ra, 释放栈桢
}
#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;
}
理解函数调用的过程后, 我们甚至可以手写它的汇编代码!
因为调用一次函数, 都会分配一个栈桢
如果函数调用的链条太长, 栈就会不断往下生长
+----------+
| |
+----------+
| stack |
sp -> +----------+
| | |
| v |
| |
| ^ |
| | |
+----------+
| heap |
+----------+
| data |
+----------+
| text |
+----------+
| |
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;
}
指针 = 变量的地址 = 访存指令中的有效地址
指针解引用 = 访存指令的操作
swap_data()
和swap_pointer()
编译出的指令序列完全一致-O1
编译,
swap_data()
和swap_pointer()
被优化成空函数
如果对变量取地址, 编译器就将变量分配在\(M\)中的栈桢
元素类型相同的有序集合
元素的存储空间从低地址往高地址连续分配
可用下标索引, 其访问和指针解引用类似
A[i]的地址 = A的地址 + i * sizeof(A的元素)
A[i]的访问 -> *(A[i]的地址)
int a[10];
void f(int *p, int b) {
a[0] = 1;
a[b] = 2;
p[-1] = 3; // ?
4[a] = 5; // ??
(-2)[p] = 6; // ???
b[p] = 7; // ????
}
void g() { f(&a[7], 2); }
RTFM(C99手册):
The definition of the subscript operator [] is that E1[E2] is identical to (*((E1)+(E2))).
#include <string.h>
void f(char *s) {
char c[12];
strcpy(c, s);
}
int main(int argc, char *argv[]) {
f(argv[1]);
return 0;
}
从命令行读入的字符串过长, 会覆盖栈上的数组c
,
从而造成缓冲区溢出
ra
!
f
返回后, 将跳转到黑客希望的位置root
权限启动的,
那就可以以最高权限入侵计算机元素类型不同的有序集合
元素的存储空间从低地址往高地址连续分配
只能通过名称引用成员, 编译器会将成员名称转换为成员在结构体中的偏移
a.b的地址 = a的地址 + b在结构体中的偏移
a.b的访问 -> *(a.b的地址)
如果结构体大小不超过XLEN
, 则按值传递
#include <stdint.h>
typedef struct { uint16_t x; uint8_t y; } T;
__attribute__((noinline)) uint32_t g(T a) {
return a.x + a.y;
}
uint32_t f() {
T t = { .x = 1, .y = 2 };
return g(t);
}
如果结构体大小超过2*XLEN
, 则按引用传递
#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);
}
#include <stdio.h>
#include <stdint.h>
#define offset(type, member) (uintptr_t)(&((type *)0ul)->member)
struct T { int i; char c; double d; short si; } t;
int main () {
printf("offset(i) = %ld\n", offset(struct T, i));
printf("offset(c) = %ld\n", offset(struct T, c));
printf("offset(d) = %ld\n", offset(struct T, d));
printf("offset(si)= %ld\n", offset(struct T, si));
printf("sizeof(t)= %ld\n", sizeof(t));
printf("alignof(t)= %ld\n", _Alignof(t));
return 0;
}
对于结构体的分配, RISC-V ABI规定:
Q: 为什么要这样规定?
A: 变量分配不对齐会降低访问效率
// 某编译器对某结构体的分配方式如下:
0 4 5 8 16 18 24
+---------+---+------+-----------------+------+--------------+
a[0] | i | c |@@@@@@| d | si |@@@@@@@@@@@@@@| @ = padding
+---------+---+------+-----------------+------+--------------+
24 28 29 32 40 42 48
+---------+---+------+-----------------+------+--------------+
a[1] | i | c |@@@@@@| d | si |@@@@@@@@@@@@@@|
+---------+---+------+-----------------+------+--------------+
元素类型不同的无序集合, 与结构体相似, 但有以下不同
a.b的地址 = a的地址
a.b的访问 -> *(a.b的地址)
typedef union node {
struct { unsigned char data1[4]; 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];
// p->node2.next->node2.data2
// =
// *(p->node2.next->node1.ptr)
// +
// p->node1.data1[2];
}
意义: 大家写C代码, 即可预测其将如何在计算机上运行