引言

我们已经了解如何用RISC-V指令集来实现C语言的功能

  • 常数, 变量, 运算, 条件分支, 循环

 

本次课内容: 更多C语言特性的机器级表示

函数调用

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

考虑f()调用g()

  • gf在同一个源文件 - 貌似编译器自己处理就可以了
  • g在其他文件
    • 可能是其他编译器编译的
    • 也可能是汇编程序员开发的汇编代码
    • 还有可能是动态库(如printf())

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

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

1. 参数和返回值

回顾\(S_{isa} = \{<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 寄存器, 引用传递

 

通过寄存器传递int变量

__attribute__((noinline))
int g(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 f() { return g(0, 1, 2, 3, 4, 5, 6, 7, 8, 9); }

若参数寄存器不够用, 则剩下的参数通过\(M\)传递

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

在RV32中通过一对寄存器传递uint64_t变量

  • 低位放在编号较小的寄存器, 高位放在编号较大的寄存器
  • 返回值放在a0a1
#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); }

 

边界情况:

  1. 若参数寄存器不够用, 则剩下的参数通过\(M\)传递
  2. 若只剩1个参数寄存器, 则低位放在\(R\)中, 高位放在\(M\)

 

类似情况: 在RV64中通过一对寄存器传递__uint128_t变量

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

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

2. 控制权的转移

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

 

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

3. 寄存器如何使用?

假设f()调用g()

  • 转到g后, 寄存器可能还存放f的状态
    • 若破坏, 将导致返回f后执行出错
g:          | f:
  # ...     |   # ...
  li x8, 5  |   li x8, 100
  # ...     |   jal g
  ret       |   # x8被g修改, 但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保存, 并在返回前恢复

 

根据上述约定, 编译器一般采用如下策略进行寄存器分配:

  • 若变量生命周期较短, 则优先分配在临时/参数寄存器(拿来即用)
t0 = a + b;
t1 = t0 + 2;
// 后续不再使用t0
  • 若变量生命周期跨越了其他函数调用, 则优先分配在保存寄存器
t0 = a + b;
g();
printf("%d", t0);

\(M\)中栈的管理 - 栈帧

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

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

  • 准备阶段(prologue; 前言)
    • 通过减小sp申请栈帧
    • 若需要, 则将某些保存寄存器保存到栈帧
    • 若需要, 则在栈帧中为局部/临时变量分配空间
  • 执行阶段 - 将C代码翻译成指令序列
    • 若要调用其他函数, 则将ra保存到栈帧, 把部分临时寄存器移到保存寄存器
  • 结束阶段(epilogue; 后记)
    • 若需要, 则从栈帧中恢复保存寄存器和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 Overflow)

因为调用一次函数, 都会分配一个栈桢

如果函数调用的链条太长, 栈就会不断往下生长

  • Linux运行时环境
    • 操作系统设置了栈的最大大小, 超过后将报错并退出程序
#include <stdio.h>
void overflow(int *k0) {
  int k = 0;
  printf("already allocate %ld Kbytes\n",
    ((void *)k0 - (void *)&k) / 1024);
  overflow(k0);
}
int main() {
  int k = 0; overflow(&k); return 0;
}
      +----------+
      |          |
      +----------+
      |   stack  |
sp -> +----------+
      |    |     |
      |    v     |
      |          |
      |    ^     |
      |    |     |
      +----------+
      |   heap   |
      +----------+
      |   data   |
      +----------+
      |   text   |
      +----------+
      |          |
    0 +----------+
  • freestanding运行时环境
    • 基本上没有任何保护机制
    • 在栈空间不大的时候, 很容易覆盖数据区甚至代码区!

RISC-V的浮点数调用约定

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

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

 

两个编译选项:

  • -march - 指定是否使用相应指令和寄存器
    • 决定程序可在哪些硬件运行
    • 硬件执行不支持的指令将抛出 “非法指令”异常
  • -mabi - 指定参数传递和引用方式
    • 决定程序可与哪些软件链接
    • 强行链接ABI不兼容的目标文件将是UB

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=lp64  -S a.c
riscv64-linux-gnu-gcc -O2 -march=rv64ifd -mabi=lp64f -S a.c
riscv64-linux-gnu-gcc -O2 -march=rv64ifd -mabi=lp64d -S a.c
riscv64-linux-gnu-gcc -O2 -march=rv64if  -mabi=lp64d -S a.c
riscv64-linux-gnu-gcc -O2 -march=rv64ifd -mabi=lp64f -c -o lp64f.o a.c
riscv64-linux-gnu-gcc -O2 -march=rv64ifd -mabi=lp64d -c -o lp64d.o a.c
riscv64-linux-gnu-gcc -O2 lp64f.o lp64d.o

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

指针

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;
}
rv32gcc -ggdb3 -c a.c  # -ggdb3  生成调试信息
rvobjdump -S a.o  # -S  在反汇编结果中显示源代码

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

指针解引用 = 访存指令的操作

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

多级指针

int fun(int ***p) {
  return *(*(*(p + 1) + 2) + 3) + 4;
}
rv32gcc -O1 -S a.c
rvobjdump -S a.o
fun:
  lw  a5,4(a0)
  lw  a5,8(a5)
  lw  a0,12(a5)
  addi  a0,a0,4
  ret

每一层解引用都编译成一次访存操作

局部变量的地址

如果对变量取地址, 编译器就将变量分配在\(M\)中的栈桢

  • \(R\)没有地址的概念
    • 更准确地说: \(R\)\(M\)的地址空间不同, C语言中的地址是\(M\)的地址

 

返回局部变量的地址是很危险的!

  • C语言标准: 变量生存期结束后通过地址访问其内容, 是UB
  • 机器级行为: 栈桢销毁后, 无法得知该内存位置将来被写入什么数据
#include <stdio.h>
int* f(int *p) { return p; }
int* g() { int i = 0; return f(&i); }
int main() {
  int *p = g();
  printf("i = %d\n", *p);
  printf("i = %d\n", *p);
  return 0;
}

数组

元素类型相同的有序集合

元素的存储空间从低地址往高地址连续分配

可用下标索引, 其访问和指针解引用类似

  • 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))).

缓冲区溢出(Buffer Overflow)

#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返回后, 将跳转到黑客希望的位置
    • 一般是跳转到黑客注入的代码
      • 这段代码通过系统调用启动一个shell, 就可以执行任意程序
      • 如果被攻击的程序是通过root权限启动的, 那就可以以最高权限入侵计算机

结构体

元素类型不同的有序集合

元素的存储空间从低地址往高地址连续分配

只能通过名称引用成员, 编译器会将成员名称转换为成员在结构体中的偏移

  • a.b的地址 = a的地址 + b在结构体中的偏移
  • a.b的访问 -> *(a.b的地址)
#include <stdio.h>
#include <stdint.h>
struct T { int x[10]; char y; } t;
#define offset(type, member) (uintptr_t)(&((type *)0ul)->member)

int main() {
  t.y = 'a';
  t.x[5] = 1;
  printf("offset(y) = %ld\n", offset(struct T, y));
  return 0;
}

结构体传参

如果结构体大小不超过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规定:

  1. 每个成员在结构体中的偏移满足其成员类型的对齐要求
  2. 整个结构体变量的对齐方式与其中对齐方式最严格的成员相同
  3. 结构体大小应为其对齐长度的整数倍

成员的对齐和插空(2)

  1. 每个成员在结构体中的偏移满足其成员类型的对齐要求
  2. 整个结构体变量的对齐方式与其中对齐方式最严格的成员相同
  3. 结构体大小应为其对齐长度的整数倍

 

Q: 为什么要这样规定?

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

  • 1 + 2 = 保证结构体中的任意成员都对齐
  • 1 + 2 + 3 = 保证结构体数组中每个元素的任意成员都对齐
// 某编译器对某结构体的分配方式如下:
       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的地址)
  • 成员间不存在对齐和插空的问题

 

#include <stdio.h>
#include <stdint.h>
union T { int x[10]; char y; } t;
#define offset(type, member) (uintptr_t)(&((type *)0ul)->member)

int main() {
  t.y = 'a';
  printf("t.x[0] = %d\n", t.x[0]);
  printf("offset(y) = %ld\n", offset(union T, y));
  return 0;
}

一个复杂类型的例子

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];
}
rv32gcc -O1 -ggdb3 -c a.c
rvobjdump -S a.o

 

成员的引用编译成基于成员偏移的访存

  p->node2.next->node2.data2
    =
    *(p->node2.next->node1.ptr)
   0:   00452703                lw      a4,4(a0)
    +
    p->node1.data1[2];
   4:   00254683                lbu     a3,2(a0)
    *(p->node2.next->node1.ptr)
   8:   00472783                lw      a5,4(a4)
    +
   c:   0007a783                lw      a5,0(a5)
  10:   00d787b3                add     a5,a5,a3
    =
  14:   00f72023                sw      a5,0(a4)
}
  18:   00008067                ret

总结

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

  • 我们已经了解如何用RISC-V指令集实现C语言的功能
    • 常数, 变量, 运算, 条件分支, 循环, 函数调用, 指针, 数组, 结构体, 联合体
    • 看着C代码, 基本上可以 “手动编译”出机器指令
      • 汉诺塔的例子

 

  • 调用约定: 规范函数调用的机器级实现, 使不同函数可正确互相调用
  • 复杂数据类型: 在指令集视角中不复存在, 全是基于寻址的数据访问

 

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

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