原理与混用实战)
排查过一个怪问题某 RISC-V 工程的中间件静态库和主目标用了不同的 code model我想确认库到底是 medlow 还是 medany。随手数了一下反汇编里 lui 和 auipc 的指令比例auipc 远多于 lui于是判断这是 medany。结论是错的差点据此改了配置。后来才搞清楚code model 的判别根本不看指令比例而是看重定位类型。下面把 RISC-V 的 code model 机制、medlow 和 medany 的差异以及主目标 medany 中间件库 medlow 混用这个真实案例讲清楚重点就是那个容易踩错的判别方法。一、code model 到底在管什么RISC-V 有个先天约束没有单条指令能直接装一个 32 位立即数。luiLoad Upper Immediate和auipcAdd Upper Immediate to PC都只能装 20 位立即数要凑一个完整 32 位地址得再配一条addi或 load/store补上低 12 位。20 位加 12 位刚好 32 位。问题来了这 20 位立即数到底代表什么如果是绝对地址的高 20 位那lui装的就是符号在内存里的真实位置的高位如果是相对 PC 的偏移的高 20 位那auipc装的就是符号离当前这条指令有多远的高位。同一个 20 位立即数两种解释方式生成出来的代码寻址行为完全不同。code model 就是来回答这个问题的。它告诉编译器两件事你可以假设整个程序的地址落在哪个范围以及用哪种寻址方式生成访问全局符号的代码。选了 medlow编译器就用绝对地址那一套选了 medany就用 PC 相对那一套。为什么需要这个东西因为编译器在生成访问全局变量的指令时得知道用lui还是auipc得知道算出来的地址能不能落在目标范围里。code model 就是编译器和链接器之间的一个约定约定好了链接器才知道怎么回填重定位。二、medlow写死的绝对地址medlowmedium-low的指令序列长这样lui a0, %hi(sym) # 装符号绝对地址的高 20 位 addi a0, a0, %lo(sym) # 补低 12 位lui把符号的绝对地址高 20 位装进寄存器addi再补上低 12 位拼出符号的真实绝对地址。这个地址是写死的跟代码运行在哪个位置没关系——只要符号的绝对地址落在 medlow 假设的范围内就行。这个范围是0x0到0x7FFFFFFF也就是低 2GB。为什么是 2GB这要从 RISC-V 立即数的符号扩展说起。lui装的 20 位立即数会左移 12 位放进寄存器高位低 12 位由addi补而addi的 12 位立即数是有符号的范围 -2048 到 2047。所以最终地址 (hi20 12) sign_extend(lo12)。当 hi20 取最大正值 0x7FFFF、lo12 取最大正值 0x7FF 时地址 0x7FFFF000 0x7FF 0x7FFFF7FF这是正数范围的上限。官方 psABI 文档写得很清楚medlow 在 RV32 上能寻址整个 4GB 空间里符号扩展后落在正数区间的部分精确范围是0x0到0x7FFFF7FF日常说低 2GB是个近似。medlow 的特点是不限制代码运行在哪个位置因为寻址用的是符号绝对地址跟 PC 无关但限制所有符号的绝对地址必须落在这个 2GB 范围内。一旦哪个符号地址超过 0x7FFFFFFFmedlow 就寻址不了链接器会报 relocation truncated to fit 之类的错。这里有个细节值得展开。官方 psABI 规定R_RISCV_HI20的计算公式是(symbol_address 0x800) 12低 12 位则是symbol_address本身。为什么高 20 位要先加 0x800 再右移因为低 12 位的有符号偏移范围是 -2048 到 2047。当一个符号地址的低 12 位落在 0x800 到 0xFFF 时把它当有符号数看就是负数-2048 到 -1这时候高 20 位得借一位过去否则 (hi2012) sign_extend(lo12) 就凑不回原地址。加 0x800 就是做这个四舍五入到最近页的进位保证最终拼出来的地址精确等于符号地址。这个细节平时不用管但理解了就知道 medlow 的高 20 位加低 12 位不是简单拼接而是带符号的拼合。三、medany跟着 PC 走medanymedium-any的指令序列长这样.Ltmp0: auipc a0, %pcrel_hi(sym) # 取当前 PC 为基准装偏移高 20 位 addi a0, a0, %pcrel_lo(.Ltmp0) # 补低 12 位auipc把当前 PC 值 偏移高 20 位算出来放进寄存器addi再补低 12 位最终地址 PC 偏移。这里的关键是auipc用的是当前 PC不是某个写死的绝对地址。medany 的假设范围不一样它不限制符号的绝对地址只要求每个符号离引用它的那条指令不超过 ±2GB。只要满足这个符号在内存里随便放medany 都能寻址到。为什么 medany 更适合位置无关和可重定位代码因为寻址基准是 PC。假设整个代码段从 0x61000000 搬到 0x62000000所有指令的 PC 一起变了符号地址也一起变了但符号离引用点的偏移没变。auipc 算出来的还是对的。medlow 就不行代码搬走了lui 里写死的绝对地址还是老的直接寻址错。这就是 medany 名字里any的含义不挑绝对地址位置只要相对距离够近。而 medlow 的low指的是地址必须在低 2GB。顺带提一个 medlow 在 RV64 上的细节。前面说的低 2GB 是 RV32 的情况。到了 RV64寄存器是 64 位lui装的 20 位立即数左移 12 位后还要做符号扩展所以 medlow 实际能寻址的是两个区间低 2GB0x0到0x7FFFF7FF和高 2GB符号扩展后的负数区0xFFFFFFFF7FFFF800到0xFFFFFFFFFFFFFFFF。中间那一大段够不着。所以 RV64 上 medlow 也不是只能用低 2GB但中间那段空洞是它的硬伤。本工程是 RV32地址都在低 2GBmedlow 用着没问题。还有一个和 medany 强相关的优化叫 linker relaxationRELAX。RISC-V 的auipc jalr调用序列如果链接器发现目标函数就在 ±2KB 范围内会把它压缩成一条jal省一条指令还省 4 字节。这个优化对 medany 和 medlow 都适用但 medany 因为是 PC 相对调用大多集中在附近relax 命中率高medlow 的绝对地址调用跨度大命中率低。所以同样一份代码medany 编译出来往往比 medlow 体积略小这也是现代工具链默认 medany 的一个现实理由。四、判别 code model别数指令看重定位类型这一节是我踩坑的地方也是全文最想说的。排查那个中间件库的 code model 时我数了反汇编里 lui 和 auipc 的条数发现 auipc 有 3253 条lui 只有 1873 条auipc 远多于 lui。按auipc 多就是 medany的直觉我判断库是 medany。错了。为什么数指令比例会错因为auipc在 medlow 代码里也会大量出现而且可能比 lui 还多。原因有两个第一函数调用。call sym在两种 code model 下都展开成auipc ra, %pcrel_hi(sym)jalr ra, ra, %pcrel_lo(sym)这是 PC 相对调用跟 code model 无关。不管 medlow 还是 medany函数调用都用 auipc。第二gp 相对小数据访问。.sdata/.sbss段的小数据由-msmall-data-limit控制访问它们用auipc相对__global_pointer$这也跟 code model 无关。所以一个 medlow 编译的库里auipc 完全可能比 lui 多得多。指令比例没有区分度。正确的判别方法是看重定位类型。code model 决定的本质是访问全局符号时用哪种重定位这才是权威依据。用objdump -r看重定位重定位类型含义对应 code modelR_RISCV_HI20R_RISCV_LO12_I/LO12_S绝对地址高 20 位 低 12 位medlowR_RISCV_PCREL_HI20R_RISCV_PCREL_LO12_I/LO12_SPC 相对高 20 位 低 12 位medany只有HI20对PCREL_HI20这一对才是区分标志。R_RISCV_CALL函数调用PC 相对、R_RISCV_BRANCH/R_RISCV_JAL跳转PC 相对在两种 model 下都出现不能用来判别。还有一个坑未链接的.o/.a文件里lui的操作数显示为0x0因为绝对地址还没回填那是重定位占位符。所以判别未链接文件必须用objdump -r看重定位类型不能看反汇编的 lui 操作数。要看真实绝对地址得对已链接的 elf 反汇编。判别命令可以直接复制# 对未链接的库看重定位类型 LIBlibxxx.a echo HI20(绝对/medlow): $(riscv64-unknown-elf-objdump -r $LIB | grep -c R_RISCV_HI20) echo PCREL_HI20(PC相对/medany): $(riscv64-unknown-elf-objdump -r $LIB | grep -c R_RISCV_PCREL_HI20) # medlow: HI20 0, PCREL_HI20 0 # medany: HI20 0, PCREL_HI20 0五、地址布局为什么两种 model 都合法光知道机制还不够得看实际工程的地址布局能不能同时满足两种 model 的假设。某 RISC-V MCU SDK 的链接脚本化名ez_demo.lds定义的内存布局是sram : ORIGIN 0x60000000, LENGTH 218K # RAM放 data/bss/堆栈 rom : ORIGIN 0x61000000, LENGTH 1M # Flash放 text/rodataXIP 执行所有代码和数据地址都在0x60000000到0x610FFFFF之间远小于0x80000000。这个布局对两种 model 都成立对混用也成立。medlow 没问题所有符号绝对地址都小于 0x80000000落在它假设的低 2GB 内。medany 也没问题所有符号相对 PC 的偏移远小于 ±2GB毕竟整个地址空间才 1MB 出头。混用之所以能跑是因为静态链接时 medlow 库的绝对地址重定位和 medany 主目标的 PC 相对重定位由链接器分别独立计算回填互不干扰。链接器看到一条R_RISCV_HI20就按绝对地址算看到一条R_RISCV_PCREL_HI20就按 PC 相对算各算各的最后都填进同一个 elf 里不冲突。这就是主目标 medany 中间件库 medlow能正常链接运行的原因地址布局同时满足两种 model 的假设混用没有功能问题。六、实测案例主目标 medany 中间件库 medlow某 RISC-V MCU SDK 的 code model 设置是这样的主目标所有应用工程用 medany设置在tools/cmake/toolchain.cmake化名里编译标志-marchrv32imac -mabiilp32 -mcmodelmedany中间件静态库用 medlow设置在ez_middleware/CMakeLists.txt化名里编译标志-mcmodelmedlow -Os。release 模式不重新编译中间件而是直接链接一个预编译库libez_ble_m4s1.a化名这个预编译库就是 debug 方式从源码编译出来的产物。用重定位类型一查结论很直接目标code modelR_RISCV_HI20R_RISCV_PCREL_HI20主目标126 个 .obj 聚合medany04329中间件库debug 编译medlow17420预编译库release 用medlow17420主目标 HI20 是 0、PCREL_HI20 有 4329 条是 medany两个中间件库 HI20 都是 1742、PCREL_HI20 都是 0是 medlow。预编译库和 debug 编译库的重定位计数完全一致说明 release 用的预编译库就是 debug 编译产物同一个 medlow 库没有第三套配置。光看重定位计数还不够我又对比了两个库的字节级特征。预编译库libez_ble_m4s1.a化名的大小是 414664 字节debug 编译产物一字不差也是 414664 字节。md5sum对一下哈希一致。计数相同可能是巧合文件大小和哈希一致基本就是同一个文件了。所以可以放心下结论release 链接的就是 debug 编译出来的那个库没有第三套编译配置。排查时不用纠结预编译库会不会是另一个 code model它就是 medlow。反汇编对比更直观。medany 主目标里寻址全局变量g_bleList化名610018f2: auipc a0, 0x0 # 取当前 PC 为基准 610018f6: addi a0, a0, 510 # PC 510 0x61001af0 g_bleList地址 PC 偏移跟代码加载位置无关。medlow 库里寻址字符串__func__.761026a44: lui a2, 0x6102a # 绝对地址高 20 位 61026a4c: addi a2, a2, -2012 # 0x6102a000 (-2012) 0x61029824地址是写死的绝对值跟代码位置无关但要求地址小于 0x80000000。两个函数形态相似都是装高 20 位 加低 12 位两条指令但lui绝对和auipcPC 相对的区别一目了然。七、混用要不要统一既然混用功能无碍那要不要统一成一种 code model我的建议是保持现状别动。最直接的理由是当前 elf 就是 medany 主目标加 medlow 库链接出来的产物build success运行正常。一个能跑的配置没有功能收益就没有改的动力。真要统一往哪个方向改都麻烦。统一到 medany中间件库得改CMakeLists.txt一行但还得重新编译并提交预编译库libez_ble_m4s1.a这是 release 交付物动它就是动 release 流程。统一到 medlow 更糟改toolchain.cmake会让全 SDK 所有项目所有源文件重编风险面铺得很大。两个方向都是只增加成本不增加收益。从工程合理性看现状本身也说得通。主目标代码量大、分散在 flash 各处medany 的 PC 相对寻址不依赖绝对地址布局将来 flash 基地址变动更稳健库用 medlow 也无妨因为库地址也在 0x7FFFFFFF 内。两者各得其所没必要为了统一这个形式把它们拉齐。唯一的隐患是移植。哪天这个工程要移植到地址超过 2GB 的平台medlow 库就会失效它的 lui 装不下超过 0x7FFFFFFF 的绝对地址。但只要地址布局还在低 2GB这个隐患就不会触发。如果团队有全工程单一 code model的硬性规范必须统一那推荐库改 medany只改一行加重新生成预编译库主目标不动影响面最小而且 medany 是 GCC 较新工具链的默认值更现代。八、写在最后回过头看那个踩坑根子在于把指令形态当成了寻址语义。medlow 和 medany 生成的都是 lui/auipc 加 addi 的两条指令序列形态太像了但重定位类型和地址含义完全不同。数指令比例是在看形态看重定位类型才是在看语义。判别 code model记住一句话看重定位类型别数指令。你的项目 code model 统一吗混用过 medlow 和 medany 吗有没有踩过类似的看错判别依据的坑评论区聊聊有用的话点个在看让更多搞 RISC-V 的同行看到。RISC-V #code模型 #medlow #medany #嵌入式开发