本章要回答的问题:memory.x 里面每一行是什么意思?从 cargo build 到芯片运行之间发生了什么?调试链路是怎么构成的?
本章定位:第 3 章中我们快速搭建了第一个裸机工程,对工具链做了”够用就好”的介绍。本章将深入每一层的内部机制,帮助你真正理解从源码到芯片运行的完整链路。掌握这些知识后,你将能够独立排查链接错误、配置自定义内存布局、优化调试体验。
12.1 链接脚本是什么?#
C 工程师熟悉的 linker.ld#
如果你用过 GCC 工具链开发 STM32,一定见过类似这样的文件:
/* STM32F407 的链接脚本(GCC 版本) */
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS
{
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} >FLASH
.text :
{
. = ALIGN(4);
*(.text)
*(.text*)
*(.rodata)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} >FLASH
_sidata = LOADADDR(.data);
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .;
} >RAM AT> FLASH
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} >RAM
}plaintext这个文件告诉链接器(ld)三件事:
- 内存有哪些区域(
MEMORY段):Flash 从哪开始、多大;RAM 从哪开始、多大。 - 代码和数据放在哪(
SECTIONS段):.text(代码)放 Flash,.data(已初始化全局变量)运行时在 RAM 但初始值存在 Flash,.bss(未初始化全局变量)在 RAM。 - 导出符号:
_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)在编译时做了以下事情:
- 读取你项目根目录下的
memory.x文件 - 将其内容嵌入到
cortex-m-rt提供的主链接脚本link.x中 - 通过
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 │
│ ... │
│ } │
└─────────────────────────────────────────────────┘plaintextC 工程师的直觉验证:如果你曾经修改过 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 |
注意:区域名称 FLASH 和 RAM 是 cortex-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 默认只使用 FLASH 和 RAM 两个区域。如果你需要将某些数据放到 FLASH2 或 DTCM,需要在自己的代码中使用 #[link_section] 属性,并在自定义链接脚本片段中指定。这属于高级用法,在 embassy-boot 的 A/B 分区方案中会用到(见第 11 章)。
实际例子:常见芯片的 memory.x#
STM32F103C8T6(Blue Pill):
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 64K
RAM : ORIGIN = 0x20000000, LENGTH = 20K
}plaintextSTM32F407VGT6(Discovery):
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 1024K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}plaintextnRF52840:
MEMORY
{
FLASH : ORIGIN = 0x00000000, LENGTH = 1024K
RAM : ORIGIN = 0x20000000, LENGTH = 256K
}plaintextRP2040:
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 之一。它做了三件关键的事:
- 提供启动代码(
Reset_Handler) - 生成向量表
- 提供链接脚本
向量表的生成#
在 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] 宏标注处理函数:
use cortex_m_rt::{entry, exception, interrupt};
#[entry]
fn main() -> ! {
loop {}
}
#[exception]
fn HardFault(_frame: &cortex_m_rt::ExceptionFrame) -> ! {
// HardFault 处理
loop {}
}
#[interrupt]
fn USART2() {
// USART2 中断处理
}rustcortex-m-rt 的构建脚本会:
- 扫描所有
#[exception]和#[interrupt]标注的函数 - 生成一个
vector_table段,将函数地址放到正确的偏移位置 - 未定义的中断向量默认指向
DefaultHandler(一个死循环)
与 C 的关键区别:在 C 中,如果你忘记定义某个中断处理函数,链接器可能不会报错(弱符号被默认处理)。在 Rust 中,#[interrupt] 宏会在编译期检查中断名称是否合法(通过 PAC crate 提供的枚举),拼写错误会直接编译失败。
Reset_Handler 的完整流程#
cortex-m-rt 的 Reset_Handler 用 Rust 编写(内部使用 unsafe 和裸指针),其逻辑等价于以下伪代码:
// cortex-m-rt 内部 Reset_Handler 的简化逻辑
#[no_mangle]
pub unsafe extern "C" fn Reset_Handler() -> ! {
// 1. 初始化 .data 段
// 将 Flash 中的初始值复制到 RAM
let mut dest = _sdata as *mut u32; // RAM 中 .data 的起始地址
let src = _sidata as *const u32; // Flash 中 .data 初始值的地址
let end = _edata as *mut u32; // RAM 中 .data 的结束地址
while dest < end {
*dest = *src;
dest = dest.add(1);
src = src.add(1);
}
// 2. 清零 .bss 段
let mut dest = _sbss as *mut u32; // .bss 起始地址
let end = _ebss as *mut u32; // .bss 结束地址
while dest < end {
*dest = 0;
dest = dest.add(1);
}
// 3. 调用用户定义的 main 函数
// 在 Embassy 中,这是 #[embassy_executor::main] 展开后的函数
main();
// 4. 如果 main 返回(不应该发生),进入死循环
loop {
cortex_m::asm::bkpt();
}
}rust与 C 的 startup.s 逐行对比#
| 步骤 | C(Keil/GCC 启动文件) | Rust(cortex-m-rt) |
|---|---|---|
| 设置栈指针 | 硬件自动从向量表第 0 项加载 MSP | 同左(硬件行为,无需软件) |
| 调用 SystemInit | BL SystemInit(配置时钟等) | 无(Rust 中时钟配置在 HAL 的 Config 中完成) |
| 复制 .data | 汇编循环:LDR + STR | Rust 循环:裸指针读写 |
| 清零 .bss | 汇编循环:STR #0 | Rust 循环:裸指针写零 |
| 跳转到 main | BL __main → BL main | 直接调用 main() |
| 未使用的中断 | [WEAK] 弱符号 → Default_Handler | 默认指向 DefaultHandler(死循环) |
关键差异:C 的启动流程中通常有一个
SystemInit()函数用于配置时钟(如设置 PLL、Flash 等待周期)。在 Rust 中,这一步被移到了 HAL 层。例如embassy-stm32的Config结构体中配置时钟树,在#[embassy_executor::main]展开的代码中,HAL 初始化会在你的main函数体执行之前完成。
中断向量的注册机制#
cortex-m-rt 使用了一种巧妙的机制来注册中断向量:
- PAC crate 定义中断枚举:例如
stm32f4xx-hal依赖的stm32f4PAC 中定义了enum Interrupt { USART2 = 38, ... } #[interrupt]宏生成链接段:宏展开后,函数被放入一个名为.vector_table.interrupts的链接段- 链接脚本排列:
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_VECTOR | Reset_Handler 的地址 | 向量表第 1 项 |
_sidata | Flash 中 .data 初始值的起始地址 | 启动时复制 .data |
_sdata / _edata | RAM 中 .data 段的起止地址 | 启动时复制 .data |
_sbss / _ebss | RAM 中 .bss 段的起止地址 | 启动时清零 .bss |
这些符号由链接脚本定义,在 Rust 代码中通过 extern "C" 声明引用。
12.4 调试链路:从 VSCode 到芯片#
完整链路概览#
┌──────────┐ DAP ┌───────────┐ USB ┌──────────┐ SWD/JTAG ┌──────┐
│ VSCode │◄────────────►│ probe-rs │◄──────────►│ 调试探针 │◄───────────►│ 芯片 │
│ │ (协议) │ (主机端) │ (通信) │ (硬件) │ (物理层) │ │
└──────────┘ └───────────┘ └──────────┘ └──────┘
│ │
│ launch.json │ 内置芯片数据库
│ 配置芯片型号 │ Flash 算法
▼ ▼
用户配置 自动识别目标plaintextprobe-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 --versionbashVSCode 集成#
- 在 VSCode 扩展市场搜索并安装 “Debugger for probe-rs”
- 在项目根目录创建
.vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "probe-rs-debug",
"request": "launch",
"name": "STM32F407 Debug",
"chip": "STM32F407VGT6",
"flashingConfig": {
"flashingEnabled": true,
"resetAfterFlashing": true
},
"coreConfigs": [
{
"coreIndex": 0,
"programBinary": "target/thumbv7em-none-eabihf/debug/my-project",
"rttEnabled": true
}
]
}
]
}json- 按 F5 即可:编译 → 烧录 → 启动调试 → 查看 RTT 日志
断点、单步、变量查看#
probe-rs 的 VSCode 调试体验与 C 的 Keil/IAR 基本一致:
- 硬件断点:Cortex-M4 通常支持 4-6 个硬件断点(Flash 中的代码)
- 软件断点:RAM 中的代码可设置无限个(通过替换指令为
BKPT) - 单步执行:Step Into / Step Over / Step Out
- 变量查看:支持 Rust 类型的完整展示(
Option、Result、枚举变体等) - 寄存器查看: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-allbashEmbed.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 时关闭toml12.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这种方式有两个严重问题:
- 体积开销:格式化字符串(
"Temperature: %d.%d°C, status: %s\n")存储在 Flash 中,每个printf调用都需要完整的格式化库代码 - 运行时开销:
printf的格式化在目标芯片上执行,消耗 CPU 周期
defmt 的核心思想:延迟格式化#
defmt(deferred formatting)的核心创新是:格式化不在芯片上执行,而是在主机端执行。
┌─────────────────────────────────────────────────────────────┐
│ 传统 printf 方式 │
│ │
│ 芯片端:格式化字符串 + 参数 → 格式化为文本 → 通过 UART 发送 │
│ 主机端:接收文本 → 显示 │
│ │
│ 问题:格式化在芯片端执行,消耗 Flash(字符串)+ CPU(格式化) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ defmt 方式 │
│ │
│ 编译时:格式化字符串被替换为 interned index(一个整数) │
│ 芯片端:发送 index + 原始参数(二进制)→ 通过 RTT 发送 │
│ 主机端:接收 index + 参数 → 查找字符串 → 格式化 → 显示 │
│ │
│ 优势:芯片端只传二进制数据,不做任何字符串处理 │
└─────────────────────────────────────────────────────────────┘plaintext实际代码对比#
// 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 printf | Rust defmt |
|---|---|---|
| Flash 占用 | 完整格式字符串(每处调用) | 仅一个整数索引(4 字节) |
| RAM 占用 | 格式化缓冲区(通常 128-256 字节) | 极小(仅传输缓冲区) |
| CPU 开销 | 运行时格式化(除零、浮点转换等) | 仅编码二进制参数 |
| 传输量 | 格式化后的文本(较长) | 二进制编码(较短) |
| 传输通道 | 通常需要 UART | RTT(无需额外引脚) |
defmt 的传输层:RTT#
defmt 本身只负责”编码”,传输由底层 transport 完成。最常用的 transport 是 RTT(Real-Time Transfer)。
RTT 的工作原理:
RTT 是 SEGGER 提出的技术,其核心是在目标芯片的 RAM 中分配一组环形缓冲区(Ring Buffer),调试器通过 SWD/JTAG 接口直接读写这些缓冲区:
┌─────────────────────────────────────────────┐
│ 目标芯片 RAM │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ RTT Control Block │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ Up Buffer 0 (芯片→主机) │ │ │
│ │ │ [环形缓冲区, 默认 1024B] │ │ │
│ │ └───────────────────────────────┘ │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ Down Buffer 0 (主机→芯片) │ │ │
│ │ │ [环形缓冲区, 默认 16B] │ │ │
│ │ └───────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
▲ │
│ SWD 读取 │ SWD 写入
│ ▼
┌─────────────────────────────────────────────┐
│ 调试探针 (ST-Link/J-Link) │
└─────────────────────────────────────────────┘
▲ │
│ USB │ USB
▼ │
┌─────────────────────────────────────────────┐
│ 主机 (probe-rs / J-Link RTT │
│ Viewer) │
└─────────────────────────────────────────────┘plaintextRTT 的关键特性:
- 不需要额外引脚:复用已有的 SWD/JTAG 调试接口
- 速度极快:实测可达 MB/s 级别(远超 UART 的 115200 bps)
- 非阻塞:缓冲区满时可选择跳过(
NoBlockSkip)或阻塞(BlockIfFull) - 双向:主机也可以向芯片发送数据
C 工程师的类比:如果你用过 SEGGER 的 J-Link RTT Viewer,defmt + RTT 的体验几乎相同——只是 defmt 的日志格式更结构化,支持 Rust 类型的自动格式化。
日志级别与运行时过滤#
defmt 支持 5 个日志级别(与 C 的 syslog 类似):
| 级别 | 宏 | 用途 | 编译时控制 |
|---|---|---|---|
| Trace | trace! | 最详细的跟踪信息 | DEFMT_LOG=trace |
| Debug | debug! | 调试信息 | DEFMT_LOG=debug |
| Info | info! | 一般运行信息 | DEFMT_LOG=info |
| Warn | warn! | 警告 | DEFMT_LOG=warn |
| Error | error! | 错误 | DEFMT_LOG=error |
编译时过滤:通过环境变量控制,低于指定级别的日志在编译时被完全移除(零开销):
# 只保留 warn 和 error 级别的日志
DEFMT_LOG=warn cargo build --release
# 保留所有日志(开发阶段)
DEFMT_LOG=trace cargo buildbash这比 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 + UART | C SEGGER RTT | Rust 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 到芯片运行的每一步#
让我们追踪一条完整的链路,理解每一步发生了什么:
步骤 1: cargo build
│
▼
步骤 2: Cargo 解析 Cargo.toml,下载依赖
│
▼
步骤 3: 执行 build.rs 构建脚本
│ - cortex-m-rt 的 build.rs 读取 memory.x
│ - 生成最终链接脚本
│ - 设置 cargo:rustc-link-arg
│
▼
步骤 4: rustc 编译每个 crate
│ - 前端:解析 → HIR → 类型检查 → MIR
│ - 后端:MIR → LLVM IR → 机器码(.o 文件)
│ - 目标:thumbv7em-none-eabihf
│
▼
步骤 5: 链接(rust-lld)
│ - 使用 cortex-m-rt 提供的链接脚本
│ - 将所有 .o 文件合并
│ - 按链接脚本安排段的位置
│ - 生成 ELF 文件
│
▼
步骤 6: 生成 .elf 文件
│ - 包含:机器码 + 调试信息 + 段布局
│ - 位置:target/thumbv7em-none-eabihf/debug/my-project
│
▼
步骤 7: cargo run / cargo flash / cargo embed
│ - probe-rs 解析 ELF 文件
│ - 通过 USB 连接调试探针
│ - 通过 SWD/JTAG 将 ELF 写入 Flash
│ - 复位芯片
│
▼
步骤 8: 芯片上电/复位
│ - CPU 从向量表读取初始 MSP(栈顶)
│ - CPU 从向量表读取 Reset_Handler 地址
│ - 跳转到 Reset_Handler
│
▼
步骤 9: Reset_Handler 执行
│ - 复制 .data 段(Flash → RAM)
│ - 清零 .bss 段
│ - 调用 main()
│
▼
步骤 10: main() 执行
- 在 Embassy 中:初始化 HAL、启动 Executor
- 在裸机中:你的应用逻辑plaintext全景图:工具链层次#
┌─────────────────────────────────────────────────────────────────┐
│ 用户代码 (main.rs) │
├─────────────────────────────────────────────────────────────────┤
│ Embassy / HAL / PAC 层 │
├─────────────────────────────────────────────────────────────────┤
│ cortex-m-rt (启动 + 链接脚本) │
├─────────────────────────────────────────────────────────────────┤
│ rustc (Rust 编译器前端) │
├─────────────────────────────────────────────────────────────────┤
│ LLVM (优化 + 代码生成) │
├─────────────────────────────────────────────────────────────────┤
│ rust-lld (链接器) │
├─────────────────────────────────────────────────────────────────┤
│ ELF 文件 (.elf) │
├─────────────────────────────────────────────────────────────────┤
│ probe-rs (烧录 + 调试) │
├─────────────────────────────────────────────────────────────────┤
│ 调试探针硬件 (ST-Link / J-Link) │
├─────────────────────────────────────────────────────────────────┤
│ SWD / JTAG 物理接口 │
├─────────────────────────────────────────────────────────────────┤
│ 目标芯片 (STM32 / nRF / RP2040) │
└─────────────────────────────────────────────────────────────────┘plaintext各层的可替换性#
| 层 | 默认选择 | 可替换为 | 替换场景 |
|---|---|---|---|
| 编译器 | rustc + LLVM | Ferrocene(认证工具链) | 安全关键项目(ISO 26262) |
| 链接器 | rust-lld | GNU ld | 特殊链接需求 |
| 启动代码 | cortex-m-rt | 自定义(极少需要) | 非标准启动流程 |
| 烧录工具 | probe-rs | OpenOCD + GDB | 遗留工具链兼容 |
| 日志框架 | defmt | RTT 裸用 / UART | 特殊需求 |
| 调试器 | probe-rs VSCode 扩展 | GDB + OpenOCD | 需要 GDB 脚本 |
实际建议:对于绝大多数项目,使用默认工具链即可。只有在安全认证(需要 Ferrocene)或特殊调试需求(需要 GDB 脚本)时才考虑替换。
编译产物分析#
# 查看 ELF 文件的段信息
cargo objdump -- -h target/thumbv7em-none-eabihf/release/my-project
# 输出示例:
# Sections:
# Idx Name Size VMA LMA File off
# 0 .vector_table 00000400 08000000 08000000 00010000
# 1 .text 00003a2c 08000400 08000400 00010400
# 2 .rodata 00000518 08003e2c 08003e2c 00013e2c
# 3 .data 00000044 20000000 08004344 00020000
# 4 .bss 00001234 20000044 20000044 00000000
# 查看 Flash 和 RAM 占用
cargo size --release
# 输出示例:
# text data bss dec hex filename
# 17484 68 4660 22212 56c4 my-projectbash解读:
text(17484 字节):代码 + 只读数据,占用 Flashdata(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 + UART | defmt + RTT | 更快、更省、类型安全 |
| 多个工具组合 | Cargo 一站式 | cargo run = 编译 + 烧录 + 日志 |
下一章预告#
第 13 章将讨论多平台移植:当你需要从一个芯片切换到另一个芯片时,需要修改什么?各厂商的 HAL 成熟度如何?Embassy 的跨平台能力到底怎样?