大家好!欢迎来到“别让你的 CPU 流汗”研讨会。我是你们的老朋友,那个喜欢在汇编代码里找乐子的资深工程师。
今天我们要聊的话题,听起来有点枯燥,甚至有点像教科书上的定义,但如果你真的懂了它,你会发现它就像是在炎热的夏天喝了一口冰镇可乐——透心凉,心飞扬。
我们要聊的是:在 RISC-V 架构下,如何利用 C++ 链接器松弛,把那些笨重的全局变量访问指令,缩减成几条轻快的小短腿。
准备好了吗?让我们开始这场关于“懒惰”与“优化”的辩论。
第一部分:CPU 的通勤成本与 RISC-V 的“短腿”限制
首先,我们要理解一个残酷的现实:每一条指令的执行,都是要花钱的。 这里的钱,不是人民币,是时间(周期)和能量。
在计算机世界里,如果你想让 CPU 去取一个数据,最理想的情况是什么?当然是“一步到位”。
在 RISC-V 架构里,这种“一步到位”的魔法叫做立即数寻址,具体来说,就是 addi 指令。这就像是你出门买酱油,直接从家门口走到小卖部,只需要几秒钟,甚至不需要换鞋。
但是,addi 指令有个毛病,它太“短”了。它的偏移量只有 12 位。这意味着什么?意味着它最多只能访问 2048 字节 范围内的内存。
这 2048 字节大概是多大?大概就够放几百个 int,或者几个 struct。
但是! 在现代 C++ 程序里,全局变量(Global Variables)就像是个囤积癖患者,它们可能被放在内存的任何地方,甚至跨越了整个 4GB 的地址空间。
这时候,CPU 就尴尬了。如果全局变量离得太远(超过 2047 字节),CPU 就不能直接用 addi 跑过去。它得先坐地铁(lui 指令,加载高 20 位地址),再走几步(addi 指令,加载低 12 位地址),然后才能取到东西。
这就是所谓的长指令序列:
# 假设 g_var 在很远的地方
lui t0, 0x10000 # 加载高地址部分
addi t0, t0, 1000 # 加上低地址部分
ld x1, 0(t0) # 去那个地址取数据
这得多累啊!CPU 要做三步动作才能拿到一个 int。
那么,有没有办法让它偷个懒呢?有!这就是我们今天的主角——链接器松弛。
第二部分:什么是链接器松弛?
想象一下,你是个包工头(编译器),你盖了一栋楼(程序),里面住了很多房客(全局变量)。你本来以为这些房客住在很远的地方,所以你给每个房客都配了一辆豪华轿车(lui + addi)来接送。
但是,你的工头同事——链接器——在最后验收的时候发现:“哎?这栋楼其实挺紧凑的,这些房客其实都在你隔壁单元!你干嘛非得开豪车?直接走过去不就行了吗?”
链接器松弛,就是链接器介入,告诉编译器生成的代码:“嘿,既然我知道这些变量都在这附近,我就帮你把那辆豪车换成了自行车(addi)。”
关键点在于: 链接器在链接时,掌握了所有符号(变量、函数)的最终地址。它知道全局变量离当前函数有多远。
第三部分:代码示例——当松弛生效时
为了演示,我们要写一段最简单的 C++ 代码:
// global.cpp
int g_big_number = 999999; // 这是一个全局变量
int g_small_number = 42; // 这是一个全局变量
void print_big() {
// 我们要访问 g_big_number
int x = g_big_number;
(void)x; // 防止编译器优化掉
}
void print_small() {
// 我们要访问 g_small_number
int y = g_small_number;
(void)y;
}
我们假设链接器非常聪明,它把 g_big_number 和 g_small_number 放在了 .bss 段的末尾,而把 print_big 和 print_small 放在了开头。
场景 A:没有松弛(或者距离太远)
如果链接器没有工作,或者距离真的太远(超过 2048 字节),生成的汇编代码可能长这样:
# print_big 函数
print_big:
lui t0, 0x10010 # 加载高地址 0x100100000
addi t0, t0, 1000 # 加上偏移量 0x1000 -> 0x100101000
ld x1, 0(t0) # 从 0x100101000 读取 g_big_number
ret
# print_small 函数
print_small:
lui t0, 0x10010 # 加载高地址 (同上)
addi t0, t0, 1004 # 加上偏移量 0x1004 -> 0x100101004
ld x1, 0(t0) # 读取 g_small_number
ret
看到了吗?print_big 和 print_small 都在用 lui(加载高地址),这简直是资源浪费!
场景 B:链接器松弛生效
现在,链接器介入了。它发现 print_small 离 g_small_number 只有 1004 字节,远小于 2048 字节的限制。于是,它大笔一挥,把代码改成了这样:
# print_small 函数 (松弛后)
print_small:
addi t0, x0, 1004 # 只需要 addi!直接算出地址,甚至不需要 lui
ld x1, 0(t0) # 读取 g_small_number
ret
看! 少了 lui,少了一行指令,指令周期(CPI)直接减少 1/3。这还不爽吗?
第四部分:为什么编译器不自己干?(PIC 的陷阱)
你可能会问:“既然编译器能算,为什么编译时不直接生成短地址,非要用 lui 呢?”
这就涉及到了 PIC(Position Independent Code,位置无关代码)。
在早期的 Linux/Unix 系统上,为了支持动态链接(比如你运行 ls 命令,它加载了 /lib/ls.so,但这个库的地址在内存里是随机的),编译器必须假设代码可能在任何地址运行。
如果编译器生成了绝对地址(比如 addi t0, x0, 1004),那么当这个 .so 文件被加载到内存地址 0x20000000 时,代码里的 1004 就变成 0x20001004 了,这就乱了套。
所以,编译器为了安全,默认会生成 lui + addi 的模式。这个模式有个名字,叫 PC-relative(PC 相对) 或者 GOT(Global Offset Table) 模式。
但是!RISC-V 的 lui 和 auipc 是很贵的。
lui 是 32 位立即数,需要 2 个时钟周期。addi 只需要 1 个。如果每个全局变量访问都要 lui,那程序跑起来就像是在爬楼梯。
第五部分:C++ 的特殊挑战——静态变量与链接器重排
C++ 程序比纯 C 程序要复杂得多,因为 C++ 有 静态链接 的概念。
如果你定义了一个 static int x = 0;,这个变量就被限制在当前编译单元(.cpp 文件)里了。链接器在合并所有 .cpp 文件时,会重新安排内存布局。
这时候,链接器就展现了它作为“懒人”的终极奥义——段重排。
链接器重排(Linker Relaxation via Section Reordering):
链接器不仅仅是在合并文件,它还会根据距离来调整内存布局。它会把小的、频繁访问的全局变量,尽量往代码段(.text)的末尾凑,或者把 .data 和 .bss 段的头部对齐到离代码段近的地方。
举个例子:
- 模块 A 定义了
int a = 1;。 - 模块 B 定义了
int b = 2;。 - 模块 A 里的函数
func_A经常访问a,很少访问b。 - 模块 B 里的函数
func_B经常访问b,很少访问a。
没有重排时:a 在内存开头,b 在内存中间。func_A 跑到内存中间去取 a,要跑很远。
有重排时:链接器把 a 和 b 交换位置,让 a 贴着 func_A,让 b 贴着 func_B。
这就叫 距离感知的链接器。它通过松弛,实现了物理上的“近水楼台先得月”。
第六部分:实战演练——如何控制松弛(编译选项大揭秘)
既然松弛这么好,为什么我的代码里还是老是有 lui?这通常是因为编译器觉得“不安全”,或者你用了错误的编译选项。
在 RISC-V 上,控制松弛的关键在于 代码模型 和 可见性。
1. -mcmodel=small:小模型的魔法
这是最激进的选项。它告诉编译器:“放心吧,整个程序(包括所有全局变量)都会加载到同一个 2GB 的地址空间内,而且距离不会超过 1GB(具体取决于架构,通常指 2GB 范围内的相对偏移)。”
如果你使用这个选项,编译器生成的代码假设所有符号都在 1GB 范围内。
riscv64-unknown-elf-g++ -mcmodel=small global.cpp -o global.elf
在这种模式下,编译器生成的全局变量访问代码,如果距离足够近,会直接使用 addi。链接器会自动完成松弛。你根本不需要写任何特殊的代码,只需要告诉链接器:“我的世界很小,别担心。”
2. -mcmodel=medany:默认的“胆小鬼”模式
这是 RISC-V Linux 的默认模型。它假设代码可能在任何地址运行(比如动态加载的共享库)。因此,编译器默认生成 PIC 代码,禁止了大部分直接 addi 的松弛。
3. -fvisibility=hidden:隐藏的敌人
这是一个高级技巧。如果你把所有全局变量都声明为 static 或者使用 __attribute__((visibility("hidden"))),那么链接器就知道这些变量不会在多个编译单元间共享。
在这种情况下,链接器可以将这些符号视为“私有”。对于私有符号,链接器可以更加激进地进行松弛优化,因为它不需要担心 PIC 的约束。
第七部分:高级话题——constexpr 与 链接器松弛
C++11 引入了 constexpr。很多初学者以为 constexpr int x = 5; 会被编译成 addi 指令。
错!大错特错。
constexpr 声明的是编译时常量。编译器在编译阶段,会直接把 x 的值嵌入到使用它的代码里。比如 int y = x + 1; 变成 addi x1, x0, 6。
但是,如果 constexpr 指针呢?比如 constexpr int* p = &g_var;。这涉及到地址计算,编译器不知道 g_var 在运行时到底在哪,所以它依然会生成 lui + addi。这时候,链接器松弛依然能发挥作用。
第八部分:动态链接与 PLT/GOT——松弛的终结者
最后,我们要谈谈那个让所有优化师都头疼的东西——动态链接。
当你的程序使用了 extern "C" 函数,或者使用了动态库时,程序启动时会有一个 PLT(Procedure Linkage Table) 和 GOT(Global Offset Table)。
// main.cpp
extern void some_function();
int main() {
some_function();
return 0;
}
编译器不会直接调用 some_function,而是调用一个 PLT 条目。
# PLT 条目
some_function@plt:
jalr t0, 24(t0) # 跳转到 GOT 里的地址
...
这个地址在运行时才会被填充。
结论: 链接器松弛发生在链接阶段,而 PLT/GOT 的填充发生在运行阶段。链接器在链接时根本看不到动态符号的真实地址。因此,对于动态链接符号,链接器无法进行松弛。 这就是为什么动态链接会有额外的开销。
第九部分:实战代码对比——从痛苦到快乐
让我们写一个完整的例子,看看 -mcmodel=small 到底能省多少指令。
代码:
// optimized.cpp
#include <cstdint>
// 假设我们有很多全局变量
static uint8_t data_buffer[2048]; // 比较大
static uint32_t magic_number = 0xDEADBEEF;
static uint8_t small_var = 42;
// 这个函数访问所有变量
void process_data() {
// 访问小变量
uint8_t val = small_var;
// 访问中等变量
val += magic_number;
// 访问大变量
uint8_t* ptr = data_buffer + 1024;
val += *ptr;
(void)val;
}
编译(默认 medany):
riscv64-linux-gnu-gcc -c optimized.cpp -fPIC -o optimized.o
riscv64-linux-gnu-objdump -d optimized.o | grep -A 20 process_data
你会看到大量的 lui, addi, ld。
编译(small model):
riscv64-linux-gnu-gcc -c optimized.cpp -mcmodel=small -fno-PIC -o optimized_small.o
riscv64-linux-gnu-objdump -d optimized_small.o | grep -A 20 process_data
你会发现,对于 small_var 和 magic_number(如果它们被放在了足够近的地方),编译器直接使用了 addi。对于远处的 data_buffer,可能还是需要 lui,但至少节省了大部分开销。
第十部分:总结——如何成为链接器大师
作为一个资深工程师,要利用好 RISC-V 的链接器松弛,你需要记住以下几点:
- 默认情况别太自信: 现代编译器(GCC, Clang)在 RISC-V 上默认是
medany模式,这会抑制松弛。 - 使用
-mcmodel=small: 如果你的程序是静态链接的,且不需要动态加载到任意地址,这是提升性能的神器。它能触发编译器生成直接寻址。 - 链接器重排: 相信链接器。不要手动把代码和变量分得太开。链接器会帮你把它们“聚”在一起,以减少指令周期。
- 避免全局变量滥用: 虽然松弛能优化访问,但过多的全局变量会增加链接器的负担,也可能导致缓存抖动。局部变量(栈上)依然是性能之王。
- 理解 PIC 的代价: 如果你必须做动态链接,那就接受
lui的存在。这是为了灵活性支付的代价。
最后的忠告:
当你下次写 C++ 代码时,看着屏幕,想象一下 CPU 正在汗流浃背地执行 lui 指令。深吸一口气,检查一下你的编译选项。如果你用了 -mcmodel=small,你就是在帮 CPU 穿上跑鞋,而不是让它穿拖鞋走远路。
好了,今天的讲座就到这里。别忘了去检查一下你的 Makefile,看看那个 -mcmodel 参数是不是还在睡大觉!
谢谢大家!