如何链接内存布局脚本?

解读

在国内 Rust 岗位面试中,面试官问“如何链接内存布局脚本”并不是想听你背 Cargo.toml 语法,而是考察三件事:

  1. 你是否知道 Rust 最终也要经过 ld.lldGNU ld 这类系统链接器;
  2. 你是否能把“Rust 的构建抽象”与“底层链接脚本”打通,给出可落地的工程步骤;
  3. 你是否理解 链接脚本对嵌入式、内核、WASM 等场景 的不可替代性。
    一句话:把 .cargo/config.toml + build.rs + memory.x 串起来,让面试官看到你“既懂语言又懂系统”。

知识点

  • 链接器脚本(linker script,*.ld / *.x):用 SECTIONS 命令精确规定各段 VMA/LMA、对齐、排除标准启动文件等。
  • -C link-arg=-Txxx.ld:rustc 透传参数,把脚本喂给底层链接器。
  • .cargo/config.tomltarget.<triple>.rustflags:项目级永久生效,避免手写 RUSTFLAGS
  • build.rs:编译期把 memory.x 拷贝到 OUT_DIR 并打印 cargo:rustc-link-search=;保证脚本路径对 Cargo 可见。
  • #[link_section = ".my_vec"]:Rust 侧把符号塞进自定义段,与脚本中的 KEEP(*(.my_vec)) 对应。
  • -nostartfiles-ffreestanding:内核/裸机场景下关闭 rustc 默认链接的 crt1.o,完全由脚本接管入口。
  • 国内常见坑:Windows 用 rust-lld 时路径分隔符必须是 /;公司 CI 若用交叉工具链,需确认 CARGO_TARGET_<TRIPLE>_LINKER 指向正确 arm-none-eabi-ld

答案

以国产 RISC-V MCU 项目为例,步骤如下:

  1. 在项目根创建 memory.x,内容示例:
    MEMORY
    {
      FLASH : ORIGIN = 0x20000000, LENGTH = 2M
      RAM   : ORIGIN = 0x80000000, LENGTH = 512K
    }
    SECTIONS
    {
      .text : { *(.text .text.*) } > FLASH
      .rodata : ALIGN(4) { *(.rodata .rodata.*) } > FLASH
      .data : AT(ADDR(.rodata) + SIZEOF(.rodata))
      { _sdata = .; *(.data .data.*); _edata = .; } > RAM
      .bss : { _sbss = .; *(.bss .bss.*); _ebss = .; } > RAM
    }
    
  2. 新建 .cargo/config.toml
    [target.riscv32imac-unknown-none-elf]
    rustflags = [
      "-C", "link-arg=-Tmemory.x",
      "-C", "link-arg=-nostartfiles",
    ]
    runner = "riscv64-unknown-elf-gdb -q -x openocd.gdb"
    
  3. 编写 build.rs
    use std::env;
    use std::fs::File;
    use std::io::Write;
    use std::path::PathBuf;
    
    fn main() {
        let out = PathBuf::from(env::var("OUT_DIR").unwrap());
        let mem_x = include_bytes!("memory.x");
        File::create(out.join("memory.x")).unwrap().write_all(mem_x).unwrap();
        println!("cargo:rustc-link-search={}", out.display());
        println!("cargo:rerun-if-changed=memory.x");
    }
    
  4. 主入口 src/main.rs
    #![no_std]
    #![no_main]
    use core::panic::PanicInfo;
    #[link_section = ".text.start"]
    #[export_name = "_start"]
    pub extern "C" fn start() -> ! {
        loop {}
    }
    #[panic_handler]
    fn panic(_: &PanicInfo) -> ! { loop {} }
    
  5. 编译验证:
    cargo build --target riscv32imac-unknown-none-elf --release
    
    riscv32-elf-objdump -h target/release/app 查看段 VMA 与 memory.x 完全一致即成功。

拓展思考

  1. 若公司项目需要 动态链接脚本(如 FPGA 软核可重定位 ELF),可在 build.rs 里根据环境变量 PROFILE 选择 memory-release.xmemory-debug.x,实现一份代码两套布局。
  2. 对于 WASM32-unknown-unknown,链接器换成 wasm-ld,脚本后缀仍是 .ld,但 MEMORY 语法改为 memory 指令;此时可用 __heap_base__data_end 符号让 JS 宿主掌握 Rust 线性内存边界。
  3. 国内安全审计要求 固件哈希对齐到 4K 页,可在脚本尾部加 ASSERT(SIZEOF(.text) % 4096 == 0, "text not page aligned"); 让链接期即报错,避免上线后 OTA 验签失败。
  4. 当面试官追问“如果不用 build.rs 还能怎么传脚本”,可答:
    • 直接 RUSTFLAGS="-C link-arg=-Tmemory.x" cargo build——最简但不利于团队协作
    • 把脚本安装到 <toolchain>/lib/rustlib/<triple>/lib/,然后改目标 JSON 的 "pre-link-args"——适合 SDK 厂商做闭源发布
      对比优劣,体现你对 Cargo 工作模型与 rustc 目标规范的深度理解。