知识门户

返回

第 12 章:工具链深度——从链接脚本到调试链路

Part 3:工程实践与生态全景

views | comments

本章要回答的问题memory.x 里面每一行是什么意思?从 cargo build 到芯片运行之间发生了什么?调试链路是怎么构成的?

本章定位:第 3 章中我们快速搭建了第一个裸机工程,对工具链做了”够用就好”的介绍。本章将深入每一层的内部机制,帮助你真正理解从源码到芯片运行的完整链路。掌握这些知识后,你将能够独立排查链接错误、配置自定义内存布局、优化调试体验。


12.1 链接脚本是什么?#

C 工程师熟悉的 linker.ld#

如果你用过 GCC 工具链开发 STM32,一定见过类似这样的文件:

这个文件告诉链接器(ld)三件事:

  1. 内存有哪些区域MEMORY 段):Flash 从哪开始、多大;RAM 从哪开始、多大。
  2. 代码和数据放在哪SECTIONS 段):.text(代码)放 Flash,.data(已初始化全局变量)运行时在 RAM 但初始值存在 Flash,.bss(未初始化全局变量)在 RAM。
  3. 导出符号_sidata_sdata_edata_sbss_ebss 等符号供启动代码使用。

Rust 中链接脚本的角色#

Rust 编译器(rustc)底层使用 LLVM,链接阶段同样需要链接脚本。但 Rust 嵌入式生态做了一个巧妙的设计:将链接脚本拆分为两部分

部分文件谁提供内容
主链接脚本link.x(内部)cortex-m-rt crate定义所有段的布局规则、向量表位置、启动符号
内存描述文件memory.x你自己只描述芯片的 Flash 和 RAM 地址与大小

这种拆分的好处是:你不需要理解完整的链接脚本语法,只需要告诉工具链”我的芯片有多少 Flash、多少 RAM”。

memory.x 与主链接脚本的关系#

cortex-m-rt 的构建脚本(build.rs)在编译时做了以下事情:

  1. 读取你项目根目录下的 memory.x 文件
  2. 将其内容嵌入到 cortex-m-rt 提供的主链接脚本 link.x
  3. 通过 cargo:rustc-link-arg 将最终的链接脚本传递给链接器

用 C 的类比来说:memory.x 就像是一个”配置头文件”,而 cortex-m-rt 内部的 link.x 就像是一个”模板源文件”,构建时两者合并。

┌─────────────────────────────────────────────────┐
│           cortex-m-rt 内部 link.x               │
│  ┌───────────────────────────────────────────┐  │
│  │  INCLUDE memory.x    ← 你的文件被包含进来  │  │
│  └───────────────────────────────────────────┘  │
│                                                 │
│  SECTIONS {                                     │
│    .vector_table : { ... } > FLASH              │
│    .text         : { ... } > FLASH              │
│    .data         : { ... } > RAM AT> FLASH      │
│    .bss          : { ... } > RAM                │
│    ...                                          │
│  }                                              │
└─────────────────────────────────────────────────┘
plaintext

C 工程师的直觉验证:如果你曾经修改过 Keil 的 scatter file(.sct)或 IAR 的 .icf 文件来调整内存布局,memory.x 就是 Rust 世界中对应的东西——只不过它被极度简化了。


12.2 memory.x 详解#

基本格式#

一个最简的 memory.x 文件只有几行:

MEMORY
{
  FLASH : ORIGIN = 0x08000000, LENGTH = 1024K
  RAM   : ORIGIN = 0x20000000, LENGTH = 128K
}
plaintext

逐字段解释:

字段含义C 工程中的对应
FLASH区域名称,cortex-m-rt 约定使用此名scatter file 中的 ROM_region
ORIGIN起始地址ORIGIN / ROM_START
LENGTH区域大小LENGTH / ROM_SIZE
RAM区域名称,cortex-m-rt 约定使用此名scatter file 中的 RAM_region

注意:区域名称 FLASHRAMcortex-m-rt 的约定,不能随意更改。cortex-m-rt 内部的链接脚本会引用这两个名称来放置段。

如何从芯片 Datasheet 中获取这些信息#

以 STM32F407VGT6 为例,打开参考手册(RM0090)的”Memory and bus architecture”章节:

Memory map:
  0x0800_0000 - 0x080F_FFFF  : Flash memory (1024 KB)
  0x2000_0000 - 0x2001_FFFF  : SRAM1 (128 KB)
  0x2001_C000 - 0x2001_FFFF  : SRAM2 (16 KB, 与 SRAM1 连续)
plaintext

因此 memory.x 写为:

MEMORY
{
  FLASH : ORIGIN = 0x08000000, LENGTH = 1024K
  RAM   : ORIGIN = 0x20000000, LENGTH = 128K
}
plaintext

常见错误:有些芯片的 SRAM 分为多块(如 STM32F407 的 112KB + 16KB),但地址连续时可以合并写。如果地址不连续(如 STM32H7 的 DTCM 和 AXI SRAM),则需要定义多个区域。

多 Bank Flash 的处理#

某些芯片(如 STM32H743)有两块独立的 Flash Bank:

MEMORY
{
  FLASH  : ORIGIN = 0x08000000, LENGTH = 1024K   /* Bank 1 */
  FLASH2 : ORIGIN = 0x08100000, LENGTH = 1024K   /* Bank 2 */
  RAM    : ORIGIN = 0x24000000, LENGTH = 512K    /* AXI SRAM */
  DTCM   : ORIGIN = 0x20000000, LENGTH = 128K    /* DTCM */
}
plaintext

但请注意:cortex-m-rt 默认只使用 FLASHRAM 两个区域。如果你需要将某些数据放到 FLASH2DTCM,需要在自己的代码中使用 #[link_section] 属性,并在自定义链接脚本片段中指定。这属于高级用法,在 embassy-boot 的 A/B 分区方案中会用到(见第 11 章)。

实际例子:常见芯片的 memory.x#

STM32F103C8T6(Blue Pill)

MEMORY
{
  FLASH : ORIGIN = 0x08000000, LENGTH = 64K
  RAM   : ORIGIN = 0x20000000, LENGTH = 20K
}
plaintext

STM32F407VGT6(Discovery)

MEMORY
{
  FLASH : ORIGIN = 0x08000000, LENGTH = 1024K
  RAM   : ORIGIN = 0x20000000, LENGTH = 128K
}
plaintext

nRF52840

MEMORY
{
  FLASH : ORIGIN = 0x00000000, LENGTH = 1024K
  RAM   : ORIGIN = 0x20000000, LENGTH = 256K
}
plaintext

RP2040

MEMORY
{
  FLASH : ORIGIN = 0x10000000, LENGTH = 2048K
  RAM   : ORIGIN = 0x20000000, LENGTH = 264K
}
plaintext

注意 nRF52840 的 Flash 起始地址是 0x00000000,而不是 0x08000000。这是因为 Nordic 芯片的 Flash 直接映射到地址 0,而 STM32 的 Flash 映射到 0x08000000(地址 0 通过 Boot 引脚配置可重映射到 Flash 或 SRAM)。

栈大小的配置#

cortex-m-rt 默认使用 RAM 区域的末尾作为栈顶(_stack_start),栈向低地址增长。如果你需要限制栈的大小(例如为堆或其他用途保留空间),可以在 memory.x 中添加:

MEMORY
{
  FLASH : ORIGIN = 0x08000000, LENGTH = 1024K
  RAM   : ORIGIN = 0x20000000, LENGTH = 128K
}

_stack_start = ORIGIN(RAM) + LENGTH(RAM) - 8K;  /* 预留 8K 给其他用途 */
plaintext

但在 Embassy 项目中,由于任务栈是静态分配的(见第 9 章),通常不需要手动调整栈大小。


12.3 cortex-m-rt 深度解析#

cortex-m-rt 是 Rust 嵌入式生态中最基础的 crate 之一。它做了三件关键的事:

  1. 提供启动代码Reset_Handler
  2. 生成向量表
  3. 提供链接脚本

向量表的生成#

在 C 工程中,向量表通常在 startup_stm32f4xx.s 中手动定义:

; Keil 启动文件中的向量表
__Vectors
    DCD     __initial_sp        ; 0: 初始栈指针
    DCD     Reset_Handler       ; 1: 复位向量
    DCD     NMI_Handler         ; 2: NMI
    DCD     HardFault_Handler   ; 3: HardFault
    DCD     MemManage_Handler   ; 4: MemManage
    DCD     BusFault_Handler    ; 5: BusFault
    DCD     UsageFault_Handler  ; 6: UsageFault
    ; ... 更多异常和中断
asm

在 Rust 中,cortex-m-rt 通过宏和链接脚本自动生成向量表。你只需要用 #[exception]#[interrupt] 宏标注处理函数:

cortex-m-rt 的构建脚本会:

  1. 扫描所有 #[exception]#[interrupt] 标注的函数
  2. 生成一个 vector_table 段,将函数地址放到正确的偏移位置
  3. 未定义的中断向量默认指向 DefaultHandler(一个死循环)

与 C 的关键区别:在 C 中,如果你忘记定义某个中断处理函数,链接器可能不会报错(弱符号被默认处理)。在 Rust 中,#[interrupt] 宏会在编译期检查中断名称是否合法(通过 PAC crate 提供的枚举),拼写错误会直接编译失败。

Reset_Handler 的完整流程#

cortex-m-rtReset_Handler 用 Rust 编写(内部使用 unsafe 和裸指针),其逻辑等价于以下伪代码:

与 C 的 startup.s 逐行对比#

步骤C(Keil/GCC 启动文件)Rust(cortex-m-rt
设置栈指针硬件自动从向量表第 0 项加载 MSP同左(硬件行为,无需软件)
调用 SystemInitBL SystemInit(配置时钟等)无(Rust 中时钟配置在 HAL 的 Config 中完成)
复制 .data汇编循环:LDR + STRRust 循环:裸指针读写
清零 .bss汇编循环:STR #0Rust 循环:裸指针写零
跳转到 mainBL __mainBL main直接调用 main()
未使用的中断[WEAK] 弱符号 → Default_Handler默认指向 DefaultHandler(死循环)

关键差异:C 的启动流程中通常有一个 SystemInit() 函数用于配置时钟(如设置 PLL、Flash 等待周期)。在 Rust 中,这一步被移到了 HAL 层。例如 embassy-stm32Config 结构体中配置时钟树,在 #[embassy_executor::main] 展开的代码中,HAL 初始化会在你的 main 函数体执行之前完成。

中断向量的注册机制#

cortex-m-rt 使用了一种巧妙的机制来注册中断向量:

  1. PAC crate 定义中断枚举:例如 stm32f4xx-hal 依赖的 stm32f4 PAC 中定义了 enum Interrupt { USART2 = 38, ... }
  2. #[interrupt] 宏生成链接段:宏展开后,函数被放入一个名为 .vector_table.interrupts 的链接段
  3. 链接脚本排列cortex-m-rt 的链接脚本将所有中断向量按编号顺序排列
// #[interrupt] 宏展开后的简化效果:
#[link_section = ".vector_table.interrupts"]
#[no_mangle]
pub static __VECTOR_38: unsafe extern "C" fn() = USART2;

#[no_mangle]
fn USART2() {
    // 你的中断处理代码
}
rust

这种机制的优势是:中断向量的注册是分散的。你可以在不同的模块中定义不同的中断处理函数,链接器会自动将它们放到正确的位置。而在 C 中,通常需要在一个集中的启动文件中维护整个向量表。

cortex-m-rt 提供的关键符号#

符号含义用途
_stack_start栈顶地址(初始 MSP 值)向量表第 0 项
__RESET_VECTORReset_Handler 的地址向量表第 1 项
_sidataFlash 中 .data 初始值的起始地址启动时复制 .data
_sdata / _edataRAM 中 .data 段的起止地址启动时复制 .data
_sbss / _ebssRAM 中 .bss 段的起止地址启动时清零 .bss

这些符号由链接脚本定义,在 Rust 代码中通过 extern "C" 声明引用。


12.4 调试链路:从 VSCode 到芯片#

完整链路概览#

┌──────────┐     DAP      ┌───────────┐    USB     ┌──────────┐   SWD/JTAG  ┌──────┐
│  VSCode  │◄────────────►│  probe-rs │◄──────────►│  调试探针  │◄───────────►│ 芯片  │
│          │   (协议)      │  (主机端)  │  (通信)    │ (硬件)    │  (物理层)   │      │
└──────────┘              └───────────┘            └──────────┘             └──────┘
     │                          │
     │  launch.json             │  内置芯片数据库
     │  配置芯片型号             │  Flash 算法
     ▼                          ▼
  用户配置                  自动识别目标
plaintext

probe-rs 的角色#

probe-rs 是 Rust 嵌入式生态中的”瑞士军刀”,它同时替代了 C 工具链中的多个工具:

C 工具链probe-rs 对应功能
OpenOCD(调试服务器)probe-rs 内置 GDB server + DAP server
arm-none-eabi-gdb(调试器)probe-rs 内置调试引擎
ST-Link Utility / J-Flash(烧录工具)cargo-flash / cargo-embed
ST-Link 驱动 + OpenOCD 配置probe-rs 内置探针驱动(ST-Link、J-Link、CMSIS-DAP)

一个工具,替代整条链路。 这是 probe-rs 最大的价值。

支持的调试探针#

probe-rs 支持以下调试探针(无需额外安装驱动):

  • ST-Link(V2、V2.1、V3):STM32 开发板自带
  • J-Link:SEGGER 出品,性能最强
  • CMSIS-DAP / DAPLink:开源协议,许多开发板自带
  • ESP-Prog:乐鑫官方调试器

安装与配置#

# 安装 probe-rs 工具集(包括 cargo-flash、cargo-embed 等)
cargo binstall probe-rs-tools

# 或者从源码编译(较慢)
cargo install probe-rs-tools --locked

# 验证安装
probe-rs --version
cargo flash --version
cargo embed --version
bash

VSCode 集成#

  1. 在 VSCode 扩展市场搜索并安装 “Debugger for probe-rs”
  2. 在项目根目录创建 .vscode/launch.json
  1. 按 F5 即可:编译 → 烧录 → 启动调试 → 查看 RTT 日志

断点、单步、变量查看#

probe-rs 的 VSCode 调试体验与 C 的 Keil/IAR 基本一致:

  • 硬件断点:Cortex-M4 通常支持 4-6 个硬件断点(Flash 中的代码)
  • 软件断点:RAM 中的代码可设置无限个(通过替换指令为 BKPT
  • 单步执行:Step Into / Step Over / Step Out
  • 变量查看:支持 Rust 类型的完整展示(OptionResult、枚举变体等)
  • 寄存器查看:ARM 核心寄存器 + 外设寄存器(通过 SVD 文件)
  • 内存查看:任意地址的内存内容

与 Keil/IAR 的对比:Keil 和 IAR 的调试器是商业产品,经过数十年打磨,在易用性上仍有优势(如逻辑分析仪、事件记录器)。但 probe-rs 的优势在于:免费开源、跨平台、与 Cargo 深度集成、对 Rust 类型的原生支持。

命令行调试#

如果不使用 VSCode,也可以通过命令行完成所有操作:

# 烧录
cargo flash --chip STM32F407VGT6

# 烧录 + RTT 日志
cargo embed --chip STM32F407VGT6

# 列出已连接的探针
probe-rs list

# 手动擦除芯片
probe-rs erase --chip STM32F407VGT6 --allow-erase-all
bash

Embed.toml 配置文件#

cargo-embed 支持通过项目根目录的 Embed.toml 文件进行配置,避免每次输入命令行参数:

[default.general]
chip = "STM32F407VGT6"

[default.rtt]
enabled = true
up_mode = "NoBlockSkip"   # 缓冲区满时跳过(不阻塞目标)
log_enabled = true

[default.gdb]
enabled = false           # 不需要 GDB server 时关闭
toml

12.5 defmt:高效的嵌入式日志框架#

为什么不用 println!#

no_std 环境中,没有标准输出(stdout),println! 宏不可用。C 工程师通常的做法是:

// C 的做法:通过 UART 输出日志
printf("Temperature: %d.%d°C, status: %s\n", temp_int, temp_frac, status_str);
c

这种方式有两个严重问题:

  1. 体积开销:格式化字符串("Temperature: %d.%d°C, status: %s\n")存储在 Flash 中,每个 printf 调用都需要完整的格式化库代码
  2. 运行时开销printf 的格式化在目标芯片上执行,消耗 CPU 周期

defmt 的核心思想:延迟格式化#

defmt(deferred formatting)的核心创新是:格式化不在芯片上执行,而是在主机端执行

实际代码对比#

// defmt 的使用方式(看起来和 println! 几乎一样)
use defmt::{info, warn, error, debug, trace};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let temperature: i32 = 25;
    let status: &str = "OK";

    info!("System started, temperature: {}°C, status: {}", temperature, status);
    warn!("Battery low: {}%", 15);
    error!("Sensor read failed: {:?}", Error::Timeout);
}
rust
// C 的等价写法
printf("[INFO] System started, temperature: %d°C, status: %s\n", temperature, status);
printf("[WARN] Battery low: %d%%\n", 15);
printf("[ERROR] Sensor read failed: %d\n", ERR_TIMEOUT);
c

看起来差不多,但底层完全不同:

方面C printfRust defmt
Flash 占用完整格式字符串(每处调用)仅一个整数索引(4 字节)
RAM 占用格式化缓冲区(通常 128-256 字节)极小(仅传输缓冲区)
CPU 开销运行时格式化(除零、浮点转换等)仅编码二进制参数
传输量格式化后的文本(较长)二进制编码(较短)
传输通道通常需要 UARTRTT(无需额外引脚)

defmt 的传输层:RTT#

defmt 本身只负责”编码”,传输由底层 transport 完成。最常用的 transport 是 RTT(Real-Time Transfer)

RTT 的工作原理

RTT 是 SEGGER 提出的技术,其核心是在目标芯片的 RAM 中分配一组环形缓冲区(Ring Buffer),调试器通过 SWD/JTAG 接口直接读写这些缓冲区:

RTT 的关键特性

  • 不需要额外引脚:复用已有的 SWD/JTAG 调试接口
  • 速度极快:实测可达 MB/s 级别(远超 UART 的 115200 bps)
  • 非阻塞:缓冲区满时可选择跳过(NoBlockSkip)或阻塞(BlockIfFull
  • 双向:主机也可以向芯片发送数据

C 工程师的类比:如果你用过 SEGGER 的 J-Link RTT Viewer,defmt + RTT 的体验几乎相同——只是 defmt 的日志格式更结构化,支持 Rust 类型的自动格式化。

日志级别与运行时过滤#

defmt 支持 5 个日志级别(与 C 的 syslog 类似):

级别用途编译时控制
Tracetrace!最详细的跟踪信息DEFMT_LOG=trace
Debugdebug!调试信息DEFMT_LOG=debug
Infoinfo!一般运行信息DEFMT_LOG=info
Warnwarn!警告DEFMT_LOG=warn
Errorerror!错误DEFMT_LOG=error

编译时过滤:通过环境变量控制,低于指定级别的日志在编译时被完全移除(零开销):

# 只保留 warn 和 error 级别的日志
DEFMT_LOG=warn cargo build --release

# 保留所有日志(开发阶段)
DEFMT_LOG=trace cargo build
bash

这比 C 的 #ifdef DEBUG 更优雅——不需要修改代码,只需要改变编译命令。

defmt 的 Cargo.toml 配置#

[dependencies]
defmt = "0.3"
defmt-rtt = "0.4"       # RTT 传输层

[features]
# 开发阶段:详细日志
defmt-trace = []
defmt-debug = []
defmt-info = []
defmt-warn = []
defmt-error = []
toml

.cargo/config.toml 中配置:

[env]
DEFMT_LOG = "debug"   # 默认日志级别
toml

与 C 日志方案的完整对比#

特性C printf + UARTC SEGGER RTTRust defmt + RTT
需要额外引脚是(UART TX)
Flash 开销高(格式字符串 + 库代码)中(格式字符串)极低(仅索引)
CPU 开销高(运行时格式化)中(运行时格式化)极低(仅编码)
传输速度~11 KB/s (115200 baud)~1 MB/s~1 MB/s
类型安全无(%d vs %s 错误运行时才发现)编译期检查
结构化输出手动手动自动(枚举、结构体)
编译时过滤#ifdef宏控制环境变量

12.6 完整链路回顾#

cargo build 到芯片运行的每一步#

让我们追踪一条完整的链路,理解每一步发生了什么:

全景图:工具链层次#

各层的可替换性#

默认选择可替换为替换场景
编译器rustc + LLVMFerrocene(认证工具链)安全关键项目(ISO 26262)
链接器rust-lldGNU ld特殊链接需求
启动代码cortex-m-rt自定义(极少需要)非标准启动流程
烧录工具probe-rsOpenOCD + GDB遗留工具链兼容
日志框架defmtRTT 裸用 / UART特殊需求
调试器probe-rs VSCode 扩展GDB + OpenOCD需要 GDB 脚本

实际建议:对于绝大多数项目,使用默认工具链即可。只有在安全认证(需要 Ferrocene)或特殊调试需求(需要 GDB 脚本)时才考虑替换。

编译产物分析#

解读

  • text(17484 字节):代码 + 只读数据,占用 Flash
  • data(68 字节):已初始化全局变量,占用 Flash(初始值)+ RAM(运行时)
  • bss(4660 字节):未初始化全局变量,仅占用 RAM
  • Flash 总占用 = text + data = 17552 字节
  • RAM 总占用 = data + bss + 栈 + 任务栈 = 需要额外计算

12.7 本章小结#

本章深入探讨了 Rust 嵌入式工具链的三个核心主题:

关键知识点回顾#

主题核心要点
链接脚本memory.x 只需定义 Flash/RAM 的地址和大小;cortex-m-rt 提供完整的段布局规则
启动流程Reset_Handler 完成 .data 复制和 .bss 清零后调用 main;与 C 的 startup.s 逻辑一致
调试链路probe-rs 一个工具替代 OpenOCD + GDB + 烧录工具;支持 ST-Link、J-Link、CMSIS-DAP
日志框架defmt 将格式化延迟到主机端,芯片只传二进制数据;通过 RTT 传输,无需额外引脚
完整链路rustc → LLVM → rust-lld → ELF → probe-rs → SWD → 芯片

C 工程师的思维转换#

C 的习惯Rust 的做法为什么更好
手动维护启动文件cortex-m-rt 自动生成不会遗漏中断向量
手动配置链接脚本只需写 memory.x减少出错可能
OpenOCD 配置文件probe-rs 自动识别零配置
printf + UARTdefmt + RTT更快、更省、类型安全
多个工具组合Cargo 一站式cargo run = 编译 + 烧录 + 日志

下一章预告#

第 13 章将讨论多平台移植:当你需要从一个芯片切换到另一个芯片时,需要修改什么?各厂商的 HAL 成熟度如何?Embassy 的跨平台能力到底怎样?


Comment seems to stuck. Try to refresh?✨