Skip to content
Published at:

17. C 扩展:压缩指令

前面 16 章中所有指令都是 32 位定长编码。RISC-V 的基础设计理念之一是"固定长度指令简化取指和解码",但固定 32 位也意味着代码体积偏大——在指令缓存有限的嵌入式场景中,代码密度直接影响性能和功耗。C 扩展(Compressed Instructions)以 16 位指令编码覆盖最频繁使用的操作,将代码体积压缩 25%-30%。本章剖析压缩指令的设计约束、编码策略、常用指令表、以及对汇编程序员的影响。

压缩指令概述

核心思想

C 扩展的每一条 16 位压缩指令都唯一映射到一条标准 32 位指令c.addi sp, -16 的底层完全等同于 addi sp, sp, -16——不是新的 ISA,是同一条架构指令的短编码。这个设计意味着:

  • 处理器不需要两套执行单元:解码器将 16 位指令展开为 32 位内部表示,ALU 只看到一种格式
  • 无需新的汇编语义c.addadd 在程序员视角下完全等价
  • 汇编器自动选择:写 add t0, t1, t2,若 t0/t1/t2 都在 x8-x15 范围内,汇编器自动生成 c.add t0, t1(2 字节);否则生成 add t0, t1, t2(4 字节)

为什么 16 位编码有效

RISC-V 的分析统计显示:典型程序中 50%-60% 的指令可以用压缩形式表达。原因是:

  1. 寄存器使用的 Pareto 分布:x8-x15(s0-s1, a0-a5)八个寄存器承载了大部分数据流
  2. 立即数集中在窄范围:大多数立即数是 0、±1、±2、小偏移——不需要 12 位全宽
  3. 指令类型高度集中:load/store/branch/add 占指令总数的 80%+——仅覆盖这些类别就有极大收益

适用场景与代价

优点代价
代码体积降低 25%-30%取指逻辑复杂化(需处理 16 位边界对齐)
指令缓存命中率提高(同样的缓存可容纳更多指令)16 位指令和 32 位指令可以混合在一个 cache line 中——需额外的预解码标记位
取指带宽有效提升(每次取 32 位可拿回 2 条压缩指令)压缩指令受限操作数(见下文)

对于有指令缓存的处理器,C 扩展的收益远大于成本;对于最小面积 MCU,C 扩展可大幅缩小 flash 占用。

设计约束

寄存器访问限制

C 扩展将压缩指令的操作数限制在 x8-x15(s0, s1, a0-a5)八个寄存器上——这个子集承载了典型代码中 70%+ 的寄存器引用。指令编码用 3 位(而非 5 位)表示寄存器编号,节省 4 位:

asm
# 完整寄存器集的指令(32 位)
add  t0, t1, t2           # t0/t1/t2 = x5/x6/x7 —— 任何 x0-x31

# 压缩指令受限为 x8-x15(16 位)
c.add s0, a0              # s0=x8, a0=x10 —— 必须在 x8-x15 范围内
c.add x15, x12            # x15=a5, x12=a2 —— 也在 x8-x15 范围内

汇编器自动判断:当目标代码中的所有操作数都在 x8-x15 内时,生成压缩形式;否则生成 32 位形式。大多数时间你不需要意识到这种切换。

立即数范围减小

压缩指令立即数位宽对应 32 位指令的立即数位宽限制
c.addi6 位有符号12 位有符号-32..+31
c.addi16sp10 位有符号(×16)12 位有符号-512..+496, 步长 16
c.lui6 位非零20 位rd !=
c.li6 位有符号12 位有符号rd != x0
c.slli6 位无符号(1..63)6 位无符号shift != 0
c.lwsp6 位无符号(×4)12 位有符号相对 sp
c.sdsp6 位无符号(×8)12 位有符号相对 sp

关键模式:压缩立即数通常更窄,且许多是缩放过的(×4 用于 32 位访问,×8 用于 64 位访问)——这反而匹配最常见的内存布局模式(栈帧中的 32/64 位值)。

无三操作数字段

C 扩展的寄存器-寄存器运算全部是二操作数(目标寄存器也是第一源寄存器,即破坏性操作):

asm
# 32 位格式:非破坏性,独立 rd
add  t0, t1, t2           # t0 = t1 + t2; t1 和 t2 不变

# 16 位压缩格式:破坏性,rd = rs1(即 rd 也是 rs1)
c.add t0, t1              # t0 = t0 + t1;  t0 被覆盖

这是个可察觉的限制——分配寄存器时需要为压缩指令腾出"破坏目标"的操作模式。但汇编器依然透明处理:写 add t0, t0, t1 时生成 c.add t0, t1

常用压缩指令表

整数运算类

压缩指令等价 32 位约束
c.addi rd, immaddi rd, rd, immrd != x0, imm ∈ [-32, 31]
c.addi16sp immaddi sp, sp, immimm 10 位 × 16 = [-512, 496]
c.addi4spn rd', uimmaddi rd, sp, uimmrd ∈ x8-x15, uimm × 4
c.li rd, immaddi rd, x0, immrd != x0, imm ∈ [-32, 31]
c.lui rd, immlui rd, immrd != {x0, x2}, imm != 0
c.add rd, rs2add rd, rd, rs2rd != x0
c.sub rd, rs2sub rd, rd, rs2
c.and rd, rs2and rd, rd, rs2
c.or rd, rs2or rd, rd, rs2
c.xor rd, rs2xor rd, rd, rs2
c.mv rd, rs2add rd, rs2, x0rd != x0, rs2 != x0
c.nopaddi x0, x0, 0
c.slli rd, shamtslli rd, rd, shamtrd != x0, shamt ∈ [1, 63]
c.srli rd, shamtsrli rd, rd, shamt
c.srai rd, shamtsrai rd, rd, shamt

Load/Store 类

压缩指令等价 32 位约束
c.lw rd', offset(rs1')lw rd, offset(rs1)rd, rs1 ∈ x8-x15; offset 7 位 × 4
c.sw rs2', offset(rs1')sw rs2, offset(rs1)rs2, rs1 ∈ x8-x15; offset 7 位 × 4
c.ld rd', offset(rs1')ld rd, offset(rs1)rd, rs1 ∈ x8-x15; offset 8 位 × 8
c.sd rs2', offset(rs1')sd rs2, offset(rs1)rs2, rs1 ∈ x8-x15; offset 8 位 × 8
c.lwsp rd, offset(sp)lw rd, offset(sp)rd != x0; offset 8 位 × 4
c.swsp rs2, offset(sp)sw rs2, offset(sp)offset 8 位 × 4
c.ldsp rd, offset(sp)ld rd, offset(sp)rd != x0; offset 9 位 × 8
c.sdsp rs2, offset(sp)sd rs2, offset(sp)offset 9 位 × 8

这里有两条重要的设计:

  1. 基于 sp 的 load/storec.lwsp/c.swsp/c.ldsp/c.sdsp)可以访问任何寄存器(不限于 x8-x15)——因为 sp 自身已编码在 opcode 中,节省了 5 位
  2. 基于任意寄存器的 load/storec.lw/c.sw/c.ld/c.sd)源和目标都限制在 x8-x15——3 位寄存器编号编码

分支与跳转类

压缩指令等价 32 位约束
c.j offsetjal x0, offsetoffset ∈ [±2KB]
c.jal offsetjal ra, offsetoffset ∈ [±2KB]
c.jr rs1jalr x0, rs1, 0rs1 != x0
c.jalr rs1jalr ra, rs1, 0rs1 != x0
c.beqz rs1', offsetbeq rs1, x0, offsetrs1 ∈ x8-x15; offset ∈ [±256]
c.bnez rs1', offsetbne rs1, x0, offsetrs1 ∈ x8-x15; offset ∈ [±256]

注意 c.jal 是 RV32 专有——RV64 中 c.jal 不可用(其编码空间用于 c.addiw)。c.jc.jr 是最常用的压缩跳转:子程序无条件转移的体积从 4 字节减小到 2 字节。

编码策略

压缩标记:最低 2 位

RISC-V 的指令长度解码规则非常简单——最低 2 位字节指示长度:

bits[1:0] = 11 → 32 位指令(标准编码)
bits[1:0] ≠ 11 → 16 位指令(压缩编码)
             = 00, 01, 或 10 → 进一步区分压缩指令象限

取指单元只需检查第一个 16 位半字的 bit[1:0]:

  • 如果 = 11:这是 32 位指令的开头,再取下一个半字组成完整指令
  • 如果不是 11:这是 16 位压缩指令,停止取指

这种设计将长度判定简化为 2 位——无需查表、无需状态机。在 16 位对齐允许的 ISA 中,这是最简洁的长度自识别方案之一。

压缩指令的四个象限

C 扩展将压缩指令空间按 opcode[1:0] 划分为四个象限:

象限bits[1:0]指令类别
C000load/store、整数运算(基于 x8-x15)
C101addi、addiw、li、lui、各种 ALU、跳转
C210各种 ALU、跳转、基于 sp 的 load/store

每个象限中的指令进一步由 bits[15:13](funct3 等价物)和 bits[12:0] 的不同字段区分。完整的编码表不在本书范围——汇编器替你管理这一切。

代码密度对比

同一函数:不加 C 扩展 vs 加 C 扩展

以一个简单求和函数为例,展示 C 扩展带来的体积差异:

asm
# ---- 不加 C 扩展 (objdump -d) ----
# 每条指令 4 字节
0000000000000000 <sum_array>:
   0:   00000337            lui    t1, 0x0           # 4 bytes
   4:   00000513            li     a0, 0              # 4 bytes
   8:   00000593            li     a1, 0              # 4 bytes
   c:   00b50663            beq    a0, a1, 18        # 4 bytes
  10:   00c50533            add    a0, a0, a2         # 4 bytes
  14:   ff5ff06f            j       c                # 4 bytes
  18:   00008067            ret                       # 4 bytes
# 总计: 7 条指令, 28 字节

# ---- 加 C 扩展 (objdump -d -M no-aliases) ----
0000000000000000 <sum_array>:
   0:   0337                c.lui  t1, 0x0           # 2 bytes
   2:   4501                c.li   a0, 0              # 2 bytes
   4:   4581                c.li   a1, 0              # 2 bytes
   6:   c58d                c.beqz a0, 18             # 2 bytes
   8:   9532                c.add  a0, a2             # 2 bytes
   a:   bfd5                c.j    c                  # 2 bytes
   c:   8082                c.jr   ra                  # 2 bytes
# 总计: 7 条指令, 14 字节
# 体积节省: 50%

实际大型程序的平均节省率约为 25%-30%——因为有些指令(如大范围 lui、有符号大偏移 load/store、跨寄存器文件的转换)不易压缩。

汇编器如何处理

写汇编代码时,你写出的是标准助记符。汇编器在处理每一条指令时执行以下决策:

1. 检查所有操作数是否在 x8-x15 内?立即数是否在压缩范围内?
   → 是:生成 16 位编码
   → 否:生成 32 位编码
2. 检查是否可以用 .s 后缀显式强制(如 c.add.s 或 add.s)
   → 程序员可显式控制:.s 后缀强制 16 位,否则不强制
asm
# 显式控制的例子
c.add  t0, t1            # 强制压缩形式(若不可用则汇编器报错)
add.s  t0, t1            # 同样效果:.s 后缀表示"压缩"
add    t0, t1, t2        # 标准写法:汇编器自动选择

对比 objdump -dobjdump -d -M no-aliases 可以看到压缩指令的展开:

bash
riscv64-linux-gnu-objdump -d hello         # 显示 c.addi, c.li 等压缩助记符
riscv64-linux-gnu-objdump -d -M no-aliases hello  # 显示 addi, c.addi 等标准助记符(但保留 c. 前缀)

C 扩展对汇编程序员的实际影响

不需要你操心

在日常开发中,C 扩展几乎完全透明。写 addi sp, sp, -16,汇编器自动生成 c.addi sp, -16——你不需要记忆哪些场景可用压缩形式。调试时在 objdump 输出中看到 c. 前缀是识别压缩指令的方式:c.addi 长 2 字节,addi 长 4 字节。

但理解它有助于高效编程

当你在以下场景中手工汇编时,理解压缩约束能帮你写出更紧凑的代码:

  1. 优先使用 x8-x15 寄存器存储热数据:a0-a5 + s0-s1 共八个,对压缩指令全覆盖
  2. 栈帧在 496 字节内时c.addi16sp 替代两字节 addi sp, sp, -N(节省 2 字节)
  3. 零与寄存器的比较用 c.beqz/c.bnez:比完整 beq rs, x0, label 省 2 字节
  4. 函数调用尾端使用 c.jr ra 而非 retret 展开为 c.jr ra(2 字节),比 jalr x0, ra, 0 省 2 字节
asm
# 紧凑的函数尾端
c.ldsp  ra, 8(sp)        # 恢复 ra(2 字节)
c.ldsp  s0, 0(sp)        # 恢复 s0(2 字节)
c.addi16sp sp, 16         # 栈收缩(2 字节)
c.jr    ra                # 返回(2 字节)
# 尾端合计 8 字节 vs 无压缩的 16 字节

压缩指令集与指令集扩展的交互

C 扩展独立于 M、A、F、D 等扩展。常见组合:

  • RV64IMAC = 基础整数 + 乘除 + 原子 + 压缩(嵌入式高性能 MCU 标配)
  • RV64GC = IMAFD + C(通用计算指令集完整包)
  • RV64IC = 基础整数 + 压缩(最小体积配置)

汇编器中通过 .option rvc / .option norvc 开启/关闭压缩指令生成(GNU 汇编器默认 rvc 开启)。

本章要点

  • C 扩展将最常用的 50%-60% 指令压缩为 16 位编码,典型代码体积节省 25%-30%——每条压缩指令唯一映射到标准 32 位指令
  • 压缩指令受限于 x8-x15 八个寄存器、较小立即数、无三操作数格式——汇编器自动在压缩和非压缩间选择
  • 长度解码仅需检查 bit[1:0]:不等于 11 即为 16 位指令——RISC-V 最简洁的长度自识别方案
  • c.ldsp/c.sdsp(基于 sp 可满寄存器集)、c.beqz/c.bnez(零比较分支)、c.j/c.jr 是无条件跳转的紧凑形式
  • 汇编器透明处理压缩;了解约束可帮你优先布局热数据到 x8-x15、控制栈帧在压缩范围内,从而挤出更多空间效率