关于rust:The-embedonomicon学习笔记

5次阅读

共计 35953 个字符,预计需要花费 90 分钟才能阅读完成。

《The embedonomicon》学习笔记

和原教程的一些差别

  • 重写了第五章,因为 !asm!global_asm曾经进入 stable,原文那种形式切实太繁琐。
  • Rust Edition 原书采纳 2018,本文采纳 2021。依照官网的说法,Rust Edition 和 Rust 的版本应该是独立的,基于任何一个 Rust Edition 的代码应该都能够在最新版本的 Rust 编译器上编译。(All Rust code, regardless of edition, is ultimately compiled to the same internal representation within the compiler.
  • 原文中是通过 cargo 子命令的形式去调用 cargo-binutils,例如应用 cargo size 而不是rust-size。集体感觉这样尽管简洁然而不够直观,因而我将应用间接调用的形式调用 cargo-binutils,将编译和查看二进制两个过程分离出来。
  • 所有命令都是在 powershell 下运行的

【0】前言

《The embedonomicon》蕴含了以下内容:

  • 率领你从头开始构建 #![no_std] 应用程序(裸机编程)
  • 开发基于特定架构的 Cortex- M 微控制器的应用程序,并一直迭代这个过程

目标

通过本文,你会学到:

  • 如何构建一个 #![no_std] 程序(裸机编程,不依赖 OS)。
  • 如何管制 Rust 程序的内存布局(在链接阶段确定),这须要接触链接器、链接脚本和一些跟 ABI 相干的 Rust feature
  • 如何实现能够被动态笼罩(statically overridden)的函数,动态笼罩意味着没有运行时耗费

前置条件

因为本文是基于 win10 的,所以只介绍 win10 下相干工具的装置过程

  • Rust 相干

    • Rust 编译器:原书采纳 1.31,本文采纳 1.60.0
    • cargo-binutils:间接应用最新
  • QEMU 模拟器

    采纳 7.0.0 版本(qemu-w64-setup-20220419.exe)

  • gdb 工具

    应用 gcc-arm-none-eabi-10.3-2021.10-win32 或 arm-gnu-toolchain-11.3.rel1-mingw-w64-i686-arm-none-eabi。

    留神不要应用 gcc-arm-11.2-2022.02-mingw-w64-i686-arm-none-eabi,这个版本依赖过期的 32 位 python2.7,在 Win10 上很难筹备相干的运行环境给它,相干探讨见这里

【1】最小的 #![no_std] 程序

这个章节次要是写一个 #![no_std] 版的 hello world 程序,残缺代码见:https://github.com/youth7/the-embedonomicon-note/tree/01-the-smallest-nostd-program

std 和 core

std

std意味着程序运行在通用 OS 上(通用 OS 通常提供线程,过程,文件系统,socket 等 API),它封装了 OS 形象层的拜访形式。除此还提供其它性能,包含:

  • 栈溢出爱护
  • 命令行参数解决
  • main 函数的调用前生成主线程

core

#![no_std]意味着程序将依赖 core 而不是 stdcorestd的子集,只提供语言的根本类型(如 float、string、slice)以及裸露处理器的个性(如一些原子操作和 SIMD 指令)。core不提供规范的运行时,没有 堆调配 。因而,#![no_std] 通常是零碎中的第一或惟一的程序,它能做一般 Rust 程序所不能的事件,例如:

  • 内核或者 OS
  • 固件
  • bootloader

例子

当初咱们来看看这个程序

#![no_main]// 通知编译器不要应用 main 函数作为程序的入口,因为 main 对运行时有要求
#![no_std]// 下面曾经解释过

use core::panic::PanicInfo;

#[panic_handler]// 自定义程序奔溃时的行为,因为不足运行时的起因这个必须本人定义
fn panic(_panic: &PanicInfo<'_>) -> ! {loop {}
}

顺次应用以下 2 个命令将源码编译为指标文件,并查看其中的符号

# 编译代码,--target 是 cargo 命令的参数, 指定了编译产物的指标平台,详情参考 cargo book
# --emit 指定了编译产物的文件类型,详情参考 rustc book
cargo rustc --target thumbv7m-none-eabi -- --emit=obj

# 列出指标文件中的符号
rust-nm  target/thumbv7m-none-eabi/debug/deps/app-*.o

此时的输入为

00000000 T rust_begin_unwind

如果你装置了 wsl2 的话,能够用其它命令来查看符号,例如应用 readelf 来查看

readelf -s ./target/thumbv7m-none-eabi/debug/deps/*.o

控制台输入:

Symbol table '.symtab' contains 10 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS 49d4frxgydk7ies3
     2: 00000000     0 SECTION LOCAL  DEFAULT    3
     3: 00000000     0 NOTYPE  LOCAL  DEFAULT    3 $t.0
     4: 00000000     0 SECTION LOCAL  DEFAULT    6
     5: 00000000     0 SECTION LOCAL  DEFAULT    7
     6: 00000000     0 SECTION LOCAL  DEFAULT   11
     7: 00000000     0 SECTION LOCAL  DEFAULT   18
     8: 00000000     0 SECTION LOCAL  DEFAULT   20
     9: 00000001     8 FUNC    GLOBAL HIDDEN     3 rust_begin_unwind

或者应用 nm 来查看

nm ./target/thumbv7m-none-eabi/debug/deps/*.o

控制台输入:

00000000 t $t.0
00000001 T rust_begin_unwind

#[panic_handler]eh_personality

刚开始的时候感觉两者都是程序解体时调用的,它们有什么区别?文档是这样解释的:

Unwinding the Stack or Aborting in Response to a Panic

By default, when a panic occurs, the program starts unwinding, which means Rust walks back up the stack and cleans up the data from each function it encounters. However, this walking back and cleanup is a lot of work. Rust, therefore, allows you to choose the alternative of immediately aborting, which ends the program without cleaning up. Memory that the program was using will then need to be cleaned up by the operating system. If in your project you need to make the resulting binary as small as possible, you can switch from unwinding to aborting upon a panic by adding panic = 'abort' to the appropriate [profile] sections in your Cargo.toml file. For example, if you want to abort on panic in release mode, add this:

[profile.release]
panic = 'abort'

可见前者是程序 panic 时候调用的,而运行 panic 处理程序时候,能够抉择是 unwind stack 或者间接 abort。我集体的疑难是这两个设定是互相独立的吗,还是说 #[panic_handler] 指向的实现外面蕴含了eh_personality

但无论如何,在本文的运行环境下并不需要对 eh_personality 做任何批改

另:eh 应该是 exception handling 的缩写,见这里的探讨

【2】内存布局

这个章节次要是介绍如何通过各种工具来调整内存布局,使得生成的二进制程序可能在裸机上运行,并通过 gdb 来验证生成的程序是否正确,残缺代码见:https://github.com/youth7/the-embedonomicon-note/tree/02-memory-layout

这一章次要是讲如何生成正确构造的二进制文件,使其可能在特定架构的 CPU 上运行。要实现这个指标就必须:

  • 理解 CPU 对二进制文件构造的要求
  • 编写 Rust 代码
  • 通过链接器调整二进制文件构造

理解 CPU 对二进制文件构造的要求

教程是基于 Cortex-M3 微控制器 LM3S6965 编写的,对于它的技术细节能够查阅文档,目前对咱们来说最重要的是:

初始化 vector table 前两个指针的值

vector_table 是一个指针数组,外面每个元素(vector)都指向了某个内存地址(大部分是异样处理函数的起始地址),对于它的具体构造能够看这里。对本教程来说最重要的是前 2 个指针:

  • 第 1 个:(ISP:Initial SP value)栈顶指针,用于初始化栈
  • 第 2 个:(Reset)指向了reset handler,它是一个函数,会在零碎被重置或者加电时运行(同时也是程序栈帧外面的第一帧)。

所以咱们须要做的事件就是:

  1. 在 Rust 代码中编写 reset handler 函数,并将其裸露进去以供链接脚本应用
  2. 联合步骤 1,通过链接脚本将 vector table 前两个元素的值设置好

vector_table 属于异样模型的一部分,外面每个 vector 指向的对象都跟异样解决相干。Initial SP valueReset 其实也能够了解为当零碎因异样重启时须要如何初始化零碎零碎。

零碎重置的时候,vector_table 的默认地址是 0x00000000,能够通过批改 VTOR(Vector Table Offset Register) 来调整 vector_table 的默认地址。

编写 Rust 代码

后续会用到一些跟编译相干的 attribute,这里先对立介绍

  • #[export_name = "foo"] 指定源码中的某个变量、函数编译后的符号名为 foo.
  • #[no_mangle] 应用变量、函数在源码中的名称作为符号名
  • #[link_section = ".bar"] 将符号搁置到名为 .bar的节中

首先编写 reset handler 函数,它是零碎栈帧中的第一个帧,从第一个帧返回是一种未定义行为,因而这个函数永远不能退出,即它必须是一个发散函数

#[no_mangle]
pub unsafe extern "C" fn Reset() -> ! {
    let _x = 42;
    // 永不退出的发散函数
    loop {}}

// 阐明这个函数须要编译到名称为.vector_table.reset_vector 的这个节中,这个节在前面会被援用到
#[link_section = ".vector_table.reset_vector"]
// 通知编译器不要用 Rust 的命名规定为 Reset 重命名,保留原来的名称就好
#[no_mangle]
//RESET_VECTOR 就是 vector table 中的第二个元素,指向了异样处理函数 Reset
// 其实这里不太明确为何要多用一个变量 RESET_VECTOR 而不是间接应用 Reset 函数
pub static RESET_VECTOR: unsafe extern "C" fn() -> ! = Reset;

通过链接器调整二进制文件构造

在根目录下创立一个名为 link.x 的文件,其内容为:

/* Memory layout of the LM3S6965 microcontroller */
/* 1K = 1 KiBi = 1024 bytes */
MEMORY
{
  FLASH : ORIGIN = 0x00000000, LENGTH = 256K
  RAM : ORIGIN = 0x20000000, LENGTH = 64K
}

/* The entry point is the reset handler */
ENTRY(Reset);

EXTERN(RESET_VECTOR);

SECTIONS
{.vector_table ORIGIN(FLASH) :
  {
    /* First entry: initial Stack Pointer value */
    LONG(ORIGIN(RAM) + LENGTH(RAM));

    /* Second entry: reset vector */
    KEEP(*(.vector_table.reset_vector));
  } > FLASH

  .text :
  {*(.text .text.*);
  } > FLASH

  /DISCARD/ :
  {*(.ARM.exidx .ARM.exidx.*);
  }
}

这个脚本初始化了 vector table 的前两项并将它搁置到正确的中央,对于链接脚本的残缺语法请参考 https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_chapter/ld_toc.html#TOC16,这里咱们只简要阐明一下各个局部的作用。

MEMORY

定义了可用的存储空间的地址和大小。此处定义了两个可用的存储空间:

  • FLASH:定义了微控制器上的闪存起始和大小
  • RAM:定义为微控制器上的内存起始和大小

具体的值是依据 LM3S6965 的技术文档

对于 FLASH、RAM、ROM 的区别请看这里

ENTRY

定义了程序的入口为 Rust 代码中定义的函数 Reset。链接器会摈弃未应用的节,如果脚本中没有这一行则链接器会认为Reset 未被应用从而摈弃之。

Reset 就是在零碎重置时运行的第一个函数,因而指定它为入口也是正当的。

EXTERN

链接器会从 entry 命令指定的函数开始,从指标文件中递归搜寻所有用到的符号,一旦所有合乎解析实现了就进行,即便此时还有指标文件未被搜寻。EXTERN的作用是强制链接器去持续解析被 EXTERN 作为参数的符号,例如本节中的RESET_VECTOR

其实不太明确为何要多用一个变量 RESET_VECTOR 而不是间接应用 Reset 这个符号,Reset曾经蕴含了足够的信息用来填充 vector table(在下一小结咱们会通过查看符号表来印证这个论断),惟一的可能性是应用 RESET_VECTOR 会使得链接脚本更加容易编写?

留神:文中对于 EXTERN 的目标和文档中的记录在 字面上 不是完全一致,这里先记录几个关键点:

  • ELF 中 undefined symbol 的相干常识,能够参考这里。
  • 文档说 EXTERN-u等价,-u中有这么一句:If this option is being used to force additional modules to be pulled into the link,这可能就是文章中应用EXTERN 的目标

SECTIONS

定义了如何将 input 指标文件中的节(section)输入到 output 指标文件中。此处在 output 指标文件中定义了 3 个节:.vector_table.text/DISCARD/

  • .vector_table被输入到 FLASHFLASHMEMORY中定义)中,它的前两个元素的值别离是 LONG(ORIGIN(RAM) + LENGTH(RAM))KEEP(*(.vector_table.reset_vector)),前者就是栈顶(SP)的值(栈的增长方向是从高地址往低地址),后者将所有名为 .vector_table.reset_vector 的节都搁置到 SP 后,因为 Rust 代码中只有一个 .vector_table.reset_vector 节且这个节中只有 RESET_VECTOR 这个符号,因而这条语句的作用相当于将 RESET_VECTOR 作为 vector table 的第 2 个元素。留神 KEEP 关键字是必须的,对于 KEEP 的具体阐明看这里。
  • .text也是被输入到FLASH,它紧跟着.vector_table
  • /DISCARD/:这是一个特地的节名称,所有被搁置到这个节的内容都会被摈弃

查看可执行文件

因为采纳自定义的链接脚本,所以必须通知编译器应用这个脚本,这能够通过批改 .cargo/config 文件来实现,新建 .cargo 文件夹并创立文件 config 文件,在外面增加以下内容

# 针对这个 target 应用链接脚本
[target.thumbv7m-none-eabi]
rustflags = ["-C", "link-arg=-Tlink.x"]

# 指定编译的 target,批改这里之后就无需在命令行传递 --target 参数了
[build]
target = "thumbv7m-none-eabi"

先应用 cargo build --bin app 编译我的项目。胜利后应用以下命令来查看后果是否合乎预期

# 应用 rust-objdump 去查看最终生成的可执行文件中的汇编代码
rust-objdump -d --no-show-raw-insn .\target\thumbv7m-none-eabi\debug\app

此时输入为:

.\target\thumbv7m-none-eabi\debug\app:  file format elf32-littlearm

Disassembly of section .text:

00000008 <Reset>:
       8:       sub     sp, #4
       a:       movs    r0, #42
       c:       str     r0, [sp]
       e:       b       0x10 <Reset+0x8>        @ imm = #-2
      10:       b       0x10 <Reset+0x8>        @ imm = #-4

能够看到 Reset 位于 0x00000008,这和预期是一样的。因为.vector_table 的大小是 8 个字节,它前面就是 .text 节,而函数 Reset 又是 .text 节里惟一的内容。

保险起见咱们还须要检查一下 vector table,应用以下命令:

# 应用 rust-objdump 去查看最终生成的可执行文件中指定节的具体内容
rust-objdump -s --section .vector_table .\target\thumbv7m-none-eabi\debug\app

此时的输入为

.\target\thumbv7m-none-eabi\debug\app:  file format elf32-littlearm
Contents of section .vector_table:
 0000 00000120 09000000                    ... ....

vector table 第 1 个元素是的值由 LONG(ORIGIN(RAM) + LENGTH(RAM)) 决定,这个表达式的值为 0x20000000 + 64*1024 = 0x20010000,二进制示意为00100000_00000001_00000000_00000000。将这个二进制依照小端法读取进去就是 0x00000120(这是 objump 的行为)。

第 2 个元素的值是 0x09000000,然而从下面咱们能够晓得 Reset 函数其实位于 0x00000008。其实这是 ARM cpu 的标准,用函数地址的最低位的奇偶性来示意以后处于哪种模式,具体能够看这里(记住这个探讨,上面会持续用到)

查看 RESET_VECTORReset

再用命令 rust-readobj.exe .\target\thumbv7m-none-eabi\debug\app -s -S --elf-output-style=GNU 检查一下符号表和 section header:

There are 17 section headers, starting at offset 0x111fc:

Section Headers:
  [Nr] Name              Type            Address  Off    Size   ES Flg Lk Inf Al
  [0]                   NULL            00000000 000000 000000 00      0   0  0
  [1] .vector_table     PROGBITS        00000000 010000 000008 00   A  0   0  4
  [2] .text             PROGBITS        00000008 010008 00000a 00  AX  0   0  2
  [3] .debug_abbrev     PROGBITS        00000000 010012 00013c 00      0   0  1
  [4] .debug_info       PROGBITS        00000000 01014e 00058e 00      0   0  1
  [5] .debug_aranges    PROGBITS        00000000 0106dc 000030 00      0   0  1
  [6] .debug_ranges     PROGBITS        00000000 01070c 000018 00      0   0  1
  [7] .debug_str        PROGBITS        00000000 010724 00048c 01  MS  0   0  1
  [8] .debug_pubnames   PROGBITS        00000000 010bb0 0000c9 00      0   0  1
  [9] .debug_pubtypes   PROGBITS        00000000 010c79 00036b 00      0   0  1
  [10] .ARM.attributes   ARM_ATTRIBUTES  00000000 010fe4 000032 00      0   0  1
  [11] .debug_frame      PROGBITS        00000000 011018 00003c 00      0   0  4
  [12] .debug_line       PROGBITS        00000000 011054 000054 00      0   0  1
  [13] .comment          PROGBITS        00000000 0110a8 000013 01  MS  0   0  1
  [14] .symtab           SYMTAB          00000000 0110bc 000050 10     16   3  4
  [15] .shstrtab         STRTAB          00000000 01110c 0000c3 00      0   0  1
  [16] .strtab           STRTAB          00000000 0111cf 00002a 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  R (retain), y (purecode), p (processor specific)

Symbol table '.symtab' contains 5 entries:
   Num:    Value  Size Type    Bind   Vis       Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT   UND
     1: 00000000     0 FILE    LOCAL  DEFAULT   ABS 11xi6ura3lrymj6b
     2: 00000008     0 NOTYPE  LOCAL  DEFAULT     2 $t.1
     3: 00000004     4 OBJECT  GLOBAL DEFAULT     1 RESET_VECTOR
     4: 00000009    10 FUNC    GLOBAL DEFAULT     2 Reset

从上可知:

  • Reset位于 .text,其 value 和 size 别离是 9 和 10,示意了这个函数在.text 中的偏移量和大小(留神偏移量 9 和咱们后面探讨的地址 0x09000000 是统一的)
  • RESET_VECTOR位于 .vector_table,其 value 和 size 别离是 4 和 4,示意了这个函数在.text 中的偏移量和大小,具体的值还须要进一步读取。

测试一下

用以下命令启动一个 Qemu 模拟器,关上一个 gdb 服务器并监听端口 3333

qemu-system-arm `
      -cpu cortex-m3 `
      -machine lm3s6965evb `
      -gdb tcp::3333 `
      -S `
      -nographic `
      -kernel target/thumbv7m-none-eabi/debug/app

而后在新窗口启动 GDB 并进行近程调试

arm-none-eabi-gdb -q target/thumbv7m-none-eabi/debug/app
Reading symbols from target/thumbv7m-none-eabi/debug/app...
(gdb) target remote :3333     #连贯近程调试服务器,程序会主动进行在第 1 行期待调试
Remote debugging using :3333
app::Reset () at src/main.rs:12
12      pub unsafe extern "C" fn Reset() -> ! {(gdb) print/x $sp            #打印栈顶的值
$1 = 0x20010000
(gdb) s                        #运行下一行代码
13          let _x = 42;
(gdb) s                        #运行下一行代码
15          loop {}
(gdb) print _x                #打印变量_x 的值
$2 = 42
(gdb) print &_x                #打印变量_x 的地址
$3 = (*mut i32) 0x2000fffc
(gdb) quit                    #退出

【3】main接口

本章节次要介绍了如何将上一章节的成绩从 binary package 转化为 lib package,以便其余开发者能够应用它来开发本人的应用程序。这样相当于建设了一个形象层,屏蔽了裸机相干的内容,开发者只需编写本人的 main 程序即可。本章难点在于了解为何须要初始化内存,以及如何初始化内存,残缺代码见:https://github.com/youth7/the-embedonomicon-note/tree/03-main-interface

为了达到这个指标,咱们须要将之前的我的项目改为 lib package(名为 rt,即 runtime 的意思),而后再新建一个 binary package(名为 app),而后在 app 中援用rt

对于 package 和 crate 的比照能够看这里

将原我的项目革新名为 rt 的 lib package

首先将之前类型为 binary 的 package 改为 lib 类型,这须要:

  • main.rs重命名为lib.rs
  • Cargo.toml[package]name 改为rt,同时将我的项目根目录重命名为rt

    [package]
    name = "rt" #批改这里
    version = "0.1.0"
    edition = "2021"
  • 改写 Reset 函数,让它去调用用户编写的 main 函数,留神这里只列出了改写的局部

    #![no_std]
    
    use core::panic::PanicInfo;
    
    // CHANGED!
    #[no_mangle]
    pub unsafe extern "C" fn Reset() -> ! {
        extern "Rust" {fn main() -> !;// 将控制权交给用户的 main 函数,因而 main 必须是发散的
        }
    
        main()}
  • 在根目录创立build.rs,这是十分要害的一步,原理是通过 Rust 提供的构建脚本来调整一些编译时的行为,请看考代码中的正文

    use std::{env, error::Error, fs::File, io::Write, path::PathBuf};
    
    fn main() -> Result<(), Box<dyn Error>> {
        // 从环境变量 OUT_DIR 中读取一个门路,用于寄存构建过程的一些两头产物
        let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
    
        // 这是最重要的一步了,通过特定的指令通知编译器从哪个门路搜寻链接脚本
        println!("cargo:rustc-link-search={}", out_dir.display());
    
        // 将链接脚本复制到上一步指定的门路
        // 如果在上一步中将链接脚本的搜寻门路设置为库的根目录,则这一步能够省略
        File::create(out_dir.join("link.x"))?.write_all(include_bytes!("link.x"))?;
    
        Ok(())
    }

注:当在 rt 下批改 build.rs 时会触发主动编译,此时 OUT_DIR 指向 rt/target 下的子目录;而编译 appOUT_DIR则指向 app/target 下的子目录,这意味着 link.x 会被复制到我的项目 app 中。

应用 rt 编写应用程序

创立一个 binary package

cargo new --bin app

批改 cargo.toml,引入rt 作为依赖

[dependencies]
rt = {path = "../rt"}

rt.cargo文件夹复制到 app 的根目录,应用和 rt 一样的 cargo 配置编译。

最初编写本人的main.rs

#![no_std]
#![no_main]

extern crate rt;

#[no_mangle]
pub fn main() -> ! {
    let _x = 42;

    loop {}}

而后编译并查看二进制文件

cargo build --bin app # 编译
rust-objdump   -d --no-show-raw-insn .\target\thumbv7m-none-eabi\debug\app # 显示汇编代码

会有如下输入,能够看到 Reset 曾经被链接进来并调用了 main 函数

.\target\thumbv7m-none-eabi\debug\app:  file format elf32-littlearm

Disassembly of section .text:

00000008 <main>:
       8:       sub     sp, #4
       a:       movs    r0, #42
       c:       str     r0, [sp]
       e:       b       0x10 <main+0x8>         @ imm = #-2
      10:       b       0x10 <main+0x8>         @ imm = #-4

00000012 <Reset>:
      12:       push    {r7, lr}
      14:       mov     r7, sp
      16:       bl      0x8 <main>              @ imm = #-18
      1a:       trap

注:其实 app.cargo/config外面的 rustflags 配置项申明了须要了 link.x,阐明apprt还是有些显式依赖的,不晓得后续有无方法可能移除这个配置项,使得 app 无需晓得 rt 中的任何内容。

rt 革新为类型平安

用户尽管能够编写本人的 main 函数,但如果 main 不合乎 rt 的要求,例如用户不小心把 main 编写为一个非发散的函数,此时编译不会报错但运行时会有不可预知的谬误。

集体认为尽管 rt 中申明了 main 的类型是发散函数,但给 rt 提供 main 是链接器行为而不是编译器行为,链接器不会去管语言级别的一些细节,因而不会有链接谬误。而编译时 rtapp` 之间没有代码层面的依赖,因而也不会觉察类型不匹配的问题。

能够在 rtlib.rs中定义并裸露一个宏给 app 调用,通过宏来做类型查看以打消上述隐患。

#[macro_export]
macro_rules! entry {($path:path) => {#[export_name = "main"]//__main 导出为 main,则 rt 中最终链接的是以后文件内的 main 函数,而不是用户的 main 函数
        pub unsafe fn __main() -> ! {
            // $path 就是用户传入的函数,对它进行类型测验后调用,此时用户写的函数的名称能够自定义了,不肯定就是要用 main
            let f: fn() -> ! = $path;

            f()}
    }
}

而后批改 app 中的main.rs

#![no_std]
#![no_main]

use rt::entry;
// 应用 rt 中裸露进去的宏来调用用户编写的函数,此时用户编写的函数能够用其它名称
// 其实这样做减少了一些复杂性,之前的办法用户只须要编写一个 main 函数就能够了,其它什么不必管
// 而当初则须要理解 entry 宏
entry!(main2);

fn main2() -> ! {
    let _x = 42;

    loop {}}

main 运行之前初始化内存(难点)

rt当初曾经比拟残缺了,然而性能上还是有一些缺失,比方用户不能在本人的代码中应用动态变量和字符串,因为编译器会将这些内容生成到 .bss.data.rodata 节中,但咱们的链接脚本中摈弃了这些节。为此咱们须要批改一下链接脚本:

  /DISCARD/ :
  {*(.ARM.exidx .ARM.exidx.*);
  }

  /* 新增三个用于保留数据的 section */
  .rodata :
  {*(.rodata .rodata.*);
  } > FLASH

  .bss :
  {*(.bss .bss.*);
  } > RAM

  .data :
  {*(.data .data.*);
  } > RAM

这样就能在代码中应用这些变量了

#![no_std]
#![no_main]

use rt::entry;
// 应用 rt 中裸露进去的宏来调用用户编写的函数,此时用户编写的函数能够用其它名称,例如这里就用了 main2
// 其实这样做减少了一些复杂性,之前的办法用户只须要编写一个 main 函数就能够了,其它什么不必管
// 而当初则须要理解 entry 宏
entry!(main2);
static RODATA: &[u8] = b"Hello, world!";
static mut BSS: u8 = 0;
static mut DATA: u16 = 1;
fn main2() -> ! {
    let _x = RODATA;
    let _y = unsafe {&BSS};
    let _z = unsafe {&DATA};

    loop {}}

如果在真机上调试这段代码,你会发现 BSSDATA的值并非预期中的 0 和 1,这是因为真机启动后内存中的值是随机导致的。但在 Qemu 上你无奈重现这个问题,因为 Qemu 曾经帮你初始化了。

对于真机内存中的值是随机这个问题须要这样意识:

  • 编译和链接时会确定一些信息并将它们记录到 ELF 中,这些信息包含:

    • 变量 BSSDATA的值(它们别离属于节 .bss.data
    • .bss.data的 LMA、VMA 值(了解 LMA 和 VMA 十分重要,能够参考这里和这里)
  • ELF 会被加载(烧录)到 ROM 外面,但运行时却在 RAM 外面。(例如链接脚本外面就把 .bss.data调配到 RAM 中,因而相干变量的地址是指向 RAM 的,即编译时候就认为这些数据 / 代码是在 RAM 的地址空间内运行,而此时 RAM 外面的内容尚未被初始化,间接读取的话会读到脏数据)
  • 因为第 2 条的起因,须要在运行前 将 ROM 外面的相干数据复制到 RAM 中,营造一个与预期统一的运行环境

上述的核心思想是,编译和链接器为了放弃程序可能失常执行,对程序和运行环境作出了一些约定(这里次要是指地址空间),这些约定被记录在 ELF 文件中。加载器或者 OS 必须保障程序运行时这些约定都得以满足 。而对于本教程,Qemu 负责将 ELF 加载到 ROM,lib.rs 中的代码负责初始化 RAM 并(将 ROM 内的局部数据加载到 RAM)。

(以上阐述仅针对裸机编程,对于古代罕用的通用操作系统来说,因为 OS 曾经帮你将虚拟内存设置好了,程序面向的是一个现实的环境:VMA 和 LMA 相等,因而无需关注上述的一些细节了。)

为此咱们在应用内存先须要先初始化,首先批改link.x,这相当于将一些约定信息写入到 ELF 中

/* Memory layout of the LM3S6965 microcontroller */
/* 1K = 1 KiBi = 1024 bytes */
MEMORY
{
  FLASH : ORIGIN = 0x00000000, LENGTH = 256K
  RAM : ORIGIN = 0x20000000, LENGTH = 64K
}

/* The entry point is the reset handler */
ENTRY(Reset);

EXTERN(RESET_VECTOR);

SECTIONS
{.vector_table ORIGIN(FLASH) :
  {
    /* First entry: initial Stack Pointer value */
    LONG(ORIGIN(RAM) + LENGTH(RAM));

    /* Second entry: reset vector */
    KEEP(*(.vector_table.reset_vector));
  } > FLASH

  .text :
  {*(.text .text.*);
  } > FLASH

  /DISCARD/ :
  {*(.ARM.exidx .ARM.exidx.*);
  }

  /* 新增三个用于保留数据的 section */
  .rodata :
  {*(.rodata .rodata.*);
  } > FLASH

  .bss :
  {
    _sbss = .; /* 将.bss 的起始地址保留到_sbss 中 */
    *(.bss .bss.*);
    _ebss = .;/* 将.bss 的完结地址保留到_ebss 中 */
  } > RAM

/*
用 AT 命令指定.data 的 LMA,让这个段紧贴着.rodata 段,但这里并没有指定 VMA
这里十分重要,了解 AT 命令请看这里的例子:https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_chapter/ld_3.html#SEC16
务必了解这里通过什么样的形式指定了.data 的 VMA 和 LMA(链接脚本并没有指定 VMA)*/
  .data : AT(ADDR(.rodata) + SIZEOF(.rodata))  
  {
    _sdata = .;/* 将.data 的起始地址保留到_sdata 中, 留神:这里相当于保留了该节的 VMA!*/
    *(.data .data.*);
    _edata = .;/* 将.data 的完结地址保留到_edata 中 */
  } > RAM

  _sidata = LOADADDR(.data);/* 将.data 的 LMA 与某个符号关联起来 */
}

而后批改lib.rs,减少对 RAM 进行初始化的代码

    // 为何这里须要 extern 块润饰呢?因为这些符号都是由链接脚本间接定义的,须要间接从 ELF 文件中读取。extern "C" {
        static mut _sbss: u8;
        static mut _ebss: u8;

        static mut _sdata: u8;
        static mut _edata: u8;
        static _sidata: u8;
    }

    // 初始化.bss 只须要将对应区域全副置为 0 即可
    let count = &_ebss as *const u8 as usize - &_sbss as *const u8 as usize;
    ptr::write_bytes(&mut _sbss as *mut u8, 0, count);

    // 初始化.data 则须要从 ROM 复制
    let count = &_edata as *const u8 as usize - &_sdata as *const u8 as usize;
    ptr::copy_nonoverlapping(&_sidata as *const u8, &mut _sdata as *mut u8, count);

最初编译并检查一下后果是否合乎预期

cargo build --bin app
rust-readobj .\target\thumbv7m-none-eabi\debug\app --program-headers --elf-output-style=GNU

输入如下:

Elf file type is EXEC (Executable file)
Entry point 0x51
There are 6 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x010000 0x00000000 0x00000000 0x00008 0x00008 R   0x10000
  LOAD           0x010008 0x00000008 0x00000008 0x0038c 0x0038c R E 0x10000
  LOAD           0x0103a0 0x000003a0 0x000003a0 0x00094 0x00094 R   0x10000
  LOAD           0x020000 0x20000000 0x20000000 0x00000 0x00001 RW  0x10000
  LOAD           0x020002 0x20000002 0x00000434 0x00002 0x00002 RW  0x10000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x0

 Section to Segment mapping:
  Segment Sections...
   00     .vector_table
   01     .text
   02     .rodata
   03     .bss
   04     .data
   05
   None   .debug_abbrev .debug_info .debug_aranges .debug_ranges .debug_str .debug_pubnames .debug_pubtypes .ARM.attributes .debug_frame .debug_line .comment .symtab .shstrtab .strtab

留神下面输入中的 VMA(VirtAddr)和 LMA(PhysAddr)都是从程序运行的角度登程的,和它们在 ELF 中的具体偏移量齐全没有关系,不要混同

能够看到 .data 的 LMA 和 VMA 是不一样。并且,.rodata的 LMA=0x000003a0,长度是 0x00094,0x000003a0+0x00094 =0x00000434,这正好就是 .data的 LMA。阐明咱们在 link.x 中通过 AT 命令对 .data 的 LMA 进行了调整的确失效了。

【4】异样解决

这一章是通过欠缺 vector table,为 rt 减少更多的异样处理程序,同时实际 编译期重写(compile time overridable behavior)这个性能。这节残缺代码见这里。

本章次要实现两个性能:

  • rt 中 vector table 中的其它项提供一个默认值(在此之前只提供了前两项)
  • 用户在应用 rt 的时候,可能用自定义的函数去笼罩 vector table 中的默认函数

对于中断、异样(trap)、陷入的概念请参考这里

调整 Rust 代码

为了演示不便,只批改 vector table 的前 16 个函数,因为它们与设施无关,且实用于所有 Cortex- M 系列的微控制器。先批改lib.rs

pub union Vector {
    // 一个 Vector 就是 vector table 中的一项,依据 arm 的文档,每一项要么是一个异样处理函数,要么是预留(值为 0)reserved: u32,
    handler: unsafe extern "C" fn(),}

extern "C" {
    // 申明会用到的内部函数,因为有可能是用户提供的所以必须用 extern,不明确为何是 C 标准而不是 Rust 标准,// 留神这里只是申明并没有提供具体实现,实现有两种,一种是应用默认的 DefaultExceptionHandler;一种是用户提供
    fn NMI();
    fn HardFault();
    fn MemManage();
    fn BusFault();
    fn UsageFault();
    fn SVCall();
    fn PendSV();
    fn SysTick();}

#[link_section = ".vector_table.exceptions"]// 将异样处理函数保留到节.vector_table.exceptions 中
#[no_mangle]
pub static EXCEPTIONS: [Vector; 14] = [// 定义 vector table 中残余的 14 项
    Vector {handler: NMI},
    Vector {handler: HardFault},
    Vector {handler: MemManage},
    Vector {handler: BusFault},
    Vector {handler: UsageFault},
    Vector {reserved: 0},
    Vector {reserved: 0},
    Vector {reserved: 0},
    Vector {reserved: 0},
    Vector {handler: SVCall},
    Vector {reserved: 0},
    Vector {reserved: 0},
    Vector {handler: PendSV},
    Vector {handler: SysTick},
];

#[no_mangle]
pub extern "C" fn DefaultExceptionHandler() {// 定义一个默认的异样处理函数
    loop {}}

调整链接脚本

EXTERN(EXCEPTIONS); 

SECTIONS
{.vector_table ORIGIN(FLASH) :
  {
    /* vector table 第一项:ISP */
    LONG(ORIGIN(RAM) + LENGTH(RAM));

    /* vector table 第二项 */
    KEEP(*(.vector_table.reset_vector));

    KEEP(*(.vector_table.exceptions)); /* 将残余的 14 个异样处理函数保留到 flash 中,加上下面已有的两项刚好 16 项 */
  } > FLASH

  /* 为符号提供默认值,只有当用户未提供自定义的异样处理程序时候才会失效,留神被提供默认值的项都是在 lib.rs 中申明过的内部函数 */
  PROVIDE(NMI = DefaultExceptionHandler);
  PROVIDE(HardFault = DefaultExceptionHandler);
  PROVIDE(MemManage = DefaultExceptionHandler);
  PROVIDE(BusFault = DefaultExceptionHandler);
  PROVIDE(UsageFault = DefaultExceptionHandler);
  PROVIDE(SVCall = DefaultExceptionHandler);
  PROVIDE(PendSV = DefaultExceptionHandler);
  PROVIDE(SysTick = DefaultExceptionHandler);
...

测试一下

先批改 main.rs,在main2() 中触发一个异样

#![no_std]
#![no_main]
#![feature(core_intrinsics)]// 因为应用了 core_intrinsics 的起因,必须切换到 nightly 来构建

use core::intrinsics;
use rt::entry;
// 应用 rt 中裸露进去的宏来调用用户编写的函数,此时用户编写的函数能够用其它名称,例如这里就用了 main2
// 其实这样做减少了一些复杂性,之前的办法用户只须要编写一个 main 函数就能够了,其它什么不必管
// 而当初则须要理解 entry 宏
entry!(main2);
fn main2() -> ! {
    // 触发 HardFault exception
    intrinsics::abort()}

因为用到了 core_intrinsics,因而须要切换到 nightly 中编译

rustup default nightly # 切换为 nightly
rustup target add thumbv7m-none-eabi #在 nightly 下须要先重新安装 target 和其它相干工具
cargo  build --bin app# 编译我的项目

而后依照第二章中的办法启动 QEMU 和 GDB 进行调试(留神须要在 app 我的项目的根目录下进行)

(gdb)  target remote :3333
Remote debugging using :3333
rt::Reset () at src/lib.rs:12
12      pub unsafe extern "C" fn Reset() -> ! {(gdb) b DefaultExceptionHandler # 在默认的异样处理函数 DefaultExceptionHandler 设置一个断点
Breakpoint 1 at 0x100: file src/lib.rs, line 103.
(gdb) c # 持续运行
Continuing.

# intrinsics::abort()触发异样,使得执行流程切换到 DefaultExceptionHandler 中
Breakpoint 1, rt::DefaultExceptionHandler () at src/lib.rs:103 
103         loop {}
(gdb) list # 列出断点左近的源码
98          Vector {handler: SysTick},
99      ];
100
101     #[no_mangle]
102     pub extern "C" fn DefaultExceptionHandler() {// €
103         loop {}
104     }
(gdb)

平安起见查看 vector table 是否合乎咱们的预期,通过以下命令编译并查看

cargo build --bin app --release # 应用 release 模式编译
.\target\thumbv7m-none-eabi\release\app -s -j .vector_table #查看.vector_table 内容 

输入如下:

.\target\thumbv7m-none-eabi\release\app:        file format elf32-littlearm
Contents of section .vector_table:
 0000 00000120 45000000 83000000 83000000  ... E...........
 0010 83000000 83000000 83000000 00000000  ................
 0020 00000000 00000000 00000000 83000000  ................
 0030 00000000 00000000 83000000 83000000  ................

可见.vector_table 中有 16 项,比照一下各项的值可知它的确和 lib.rs 中的 EXCEPTIONS 数组是统一的(0x83000000 就是默认的异样函数的值)。此外须要注意第 4 项,它是异样处理函数 HardFault() 的地址,下一节咱们会在 app 中笼罩这个函数,笼罩后它的地址就不再是 0x83000000。

用户自定义异样处理函数

因为在 lib.rs 中申明了各个异样处理函数为 extern,所以用户能够在内部自定义异样处理函数来代替 rt 中的 DefaultExceptionHandler(),这只须要在rtmain.rs中定义合乎签名的函数即可。


#[no_mangle]
pub extern "C" fn HardFault() -> ! {
    // 自定义异样处理函数,用 QEMU 调试时候应该停留在这里
    loop {}}

而后像下面那样编译 debug,能够发现代码的确停留在用户自定义的 HardFault()

(gdb) target remote :3333
Remote debugging using :3333
rt::Reset () at src/lib.rs:12
12      pub unsafe extern "C" fn Reset() -> ! {(gdb) b HardFault
Breakpoint 1 at 0x44: file src/main.rs, line 19.
(gdb) c
Continuing.

Breakpoint 1, app::HardFault () at src/main.rs:19
19          loop {}
(gdb) list
14      }
15
16      #[no_mangle]
17      pub extern "C" fn HardFault() -> ! {
18          //QEMU€
19          loop {}
20      }

再像上一大节那样编译并查看.vector_table

.\target\thumbv7m-none-eabi\release\app:        file format elf32-littlearm
Contents of section .vector_table:
 0000 00000120 47000000 85000000 41000000  ... G.......A...
 0010 85000000 85000000 85000000 00000000  ................
 0020 00000000 00000000 00000000 85000000  ................
 0030 00000000 00000000 85000000 85000000  ................

此时 DefaultExceptionHandler() 的地址变成了 0x85000000,然而第 4 项 HardFault() 的值和 DefaultExceptionHandler() 不一样,这是因为用户在 app 中定义了本人的异样处理函数。

【5】应用新办法写汇编

这一章次要是在代码中嵌入汇编来批改寄存器的值,从而实现为 HardFault() 传递参数。原文编写时候 asm!global_asm!尚未稳固,因而是应用旧形式嵌入汇编。这种形式十分繁琐,因而我将它改为用 asm! 来实现。残缺代码见这里

在上一章中,咱们将 HardFault() 搁置到 vector table 的特定地位,当对应的异样产生时候 HardFault() 就会被调用。而本章不再将 HardFault() 间接搁置到 vector table,而是创立一个辅助函数 HardFaultTrampoline() 并将它搁置到 vector table,而后通过它来调用 HardFault()。而HardFaultTrampoline() 在调用 HardFault() 前会批改特定寄存器的值,从而实现为 HardFault() 传参。

批改rt

首先批改 lib.rs 如下:

extern "C" {fn NMI();
    // fn HardFault(); 删除对 HardFault 的申明,因为不须要在 rust 代码中调用它
    fn MemManage();
    fn BusFault();
    fn UsageFault();
    fn SVCall();
    fn PendSV();
    fn SysTick();}

#[link_section = ".vector_table.exceptions"]// 将异样处理函数保留到节.vector_table.exceptions 中
#[no_mangle]
pub static EXCEPTIONS: [Vector; 14] = [// 定义 vector table 中残余的 14 项
    Vector {handler: NMI},
    Vector {handler: HardFaultTrampoline},// 改为应用辅助函数,通过它去调用 HardFault
    Vector {handler: MemManage},
    Vector {handler: BusFault},
    Vector {handler: UsageFault},
    Vector {reserved: 0},
    Vector {reserved: 0},
    Vector {reserved: 0},
    Vector {reserved: 0},
    Vector {handler: SVCall},
    Vector {reserved: 0},
    Vector {reserved: 0},
    Vector {handler: PendSV},
    Vector {handler: SysTick},
];

#[allow(non_snake_case)]
#[no_mangle]
pub fn DefaultExceptionHandler(_ef: *const u32) -> ! {// 因为 HardFaultTrampoline 会传递参数,因而函数签名也要同步批改
    loop {}}

HardFaultTrampoline() 搁置到 vertor table,取代原来的 HardFault()。它的性能是读取以后栈指针的值,而后将它作为参数传递给HardFault(),这样HardFault() 就能够读取异样产生时候栈外面的内容了。为何不间接在 HardFault() 中读取栈指针呢?因为 HardFault() 是用 Rust 写的,无奈间接拜访寄存器的值。

而后在 lib.rs 中应用汇编实现HardFaultTrampoline()

#[no_mangle]
extern "C" fn HardFaultTrampoline() {
    unsafe{
        asm!(
          "mrs r0, MSP",
          "b HardFault"
        )
    }
}

批改app

因为 HardFaultTrampoline() 会给 HardFault() 传参,所以须要批改 main.rsHardFault()的签名

#[no_mangle]
#[allow(non_snake_case)]
pub fn HardFault(_ef: *const u32) -> ! {// 因为 HardFaultTrampoline 会传递参数,因而函数签名也要同步批改
    loop {}}

测试一下

而后用以下命令编译并查看汇编代码

 cargo build --bin app --release #编译
 rust-objdump -d --print-imm-hex --no-show-raw-insn .\target\thumbv7m-none-eabi\release\app #显示汇编

会有以下输入

.\target\thumbv7m-none-eabi\release\app:        file format elf32-littlearm

Disassembly of section .text:

00000040 <HardFault>:
      40:       b       0x40 <HardFault>        @ imm = #-0x4

00000042 <main>:
      42:       trap
      44:       trap

00000046 <Reset>:
      46:       push    {r7, lr}
      48:       mov     r7, sp
      4a:       bl      0x42 <main>             @ imm = #-0xc
      4e:       trap

00000050 <UsageFault>:
      50:       b       0x50 <UsageFault>       @ imm = #-0x4

00000052 <HardFaultTrampoline>:
      52:       push    {r7, lr}# 比咱们的汇编多了两行,可能是因为函数调用导致生成了保留寄存器的代码
      54:       mov     r7, sp
      56:       mrs     r0, msp
      5a:       b.w     0x40 <HardFault>        @ imm = #-0x1e
      5e:       pop     {r7, pc}

可知 HardFaultTrampoline() 的地址是 0x00000052,再检查一下 vector table 的内容

rust-objdump -s -j .vector_table  .\target\thumbv7m-none-eabi\release\app

输入如下:

.\target\thumbv7m-none-eabi\release\app:        file format elf32-littlearm
Contents of section .vector_table:
 0000 00000120 47000000 51000000 53000000  ... G...Q...S...
 0010 51000000 51000000 51000000 00000000  Q...Q...Q.......
 0020 00000000 00000000 00000000 51000000  ............Q...
 0030 00000000 00000000 51000000 51000000  ........Q...Q...

注意第 4 项的值为 0x53000000(Thumb mode),此时证实 HardFaultTrampoline() 的确曾经失效。

【6】利用符号进行日志输入

在嵌入式零碎中,常见的日志输出 / 输入形式有以下几种:

  • 串口:将日志信息写到串口,串口的接收端连贯着显示设施
  • 内存:将日志信息写到 RAM 中,而后再从 RAM 中读取
  • 文件:将日志信息写到文件,这要求设施必须有 sd 卡或者片外 flash(呈现文件概念的话意味着须要 OS 反对?)
  • 嵌入式设施自带的显示模块、网络网口等

本章次要是 2 和 3 的混合体,不过略有区别:不是间接输入日志内容(字符串),而是输入日志内容的地址,而后再依据地址去 ELF 文件中查找日志的具体内容。(为何不间接输入字符串?是因为在嵌入式中不容易实现?)

具体步骤如下:

  1. 在 Rust 代码中定义若干变量,
  2. 批改变量在 ELF 中的符号名(symbol name),将日志内容嵌入到符号名中,则通过编译后日志的内容便存储到 ELF 中
  3. 在 Rust 代码中输入变量的地址,在符号表中查问这些地址便能失去日志的内容

残缺代码请看这里

批改变量导出的符号名称的例子

创立一个名为 foo 的 lib package,而后批改 lib.rs 文件如下:

#![allow(unused)]
fn main() {#[export_name = "Hello, world!"] // 批改变量 A 的符号名
    #[used]// 要求编译器不要抛弃动态变量 A,即便它没有被应用
    static A: u8 = 0;

    #[export_name = "你好,这是一个中文句子"]
    #[used]// 要求编译器不要抛弃动态变量 B,即便它没有被应用
    static B: u8 = 0;// 批改变量 B 的符号名
}

接着编译并查看符号表

cargo build --lib;rust-nm .\target\debug\libfoo.rlib

会有以下输入:

   Compiling foo v0.1.0 (D:\workspace\rust\app\foo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s

foo-71f85fa4234f96d2.3bevs2kbnkn4yeof.rcgu.o:
00000000 a @feat.00
00000000 R Hello, world!
00000000 D __imp_Hello, world!
00000000 D __imp_你好,这是一个中文句子
00000000 R 你好,这是一个中文句子

lib.rmeta:
.\target\debug\libfoo.rlib:lib.rmeta: no symbols

可见咱们胜利将一些自定义信息当做符号名写入了 ELF 中。

将日志信息编码到符号名中

通过以下步骤将日志信息编码

  1. 为每条日志创立一个对应的 static 类型变量,但变量自身不存储日志的内容,日志的内容是通过 attribute 编码到符号名中
  2. 将步骤 1 中的变量的地址输入到控制台

先批改 app 中的main.rs

#![no_main]
#![no_std]

use core::fmt::Write;
// 应用 semihosting 技术进行输入,因为 QEMU 间接反对 semihosting。而在真机环境则可能须要用到串口等技术
use cortex_m_semihosting::{debug, hio};

use rt::entry;

entry!(main);

fn main() -> ! {let mut hstdout = hio::hstdout().unwrap();

    #[export_name = "Hello, world!"]// 将日志信息编码到动态变量 A 的符号名中,static A: u8 = 0;

    // 将地址的值作为 usize 输入
    let _ = writeln!(hstdout, "{:#x}", &A as *const u8 as usize);

    #[export_name = "Goodbye"]
    static B: u8 = 0;

    let _ = writeln!(hstdout, "{:#x}", &B as *const u8 as usize);

    debug::exit(debug::EXIT_SUCCESS);

    loop {}}

而后引入相干依赖

[dependencies]
cortex-m-semihosting = "0.5.0"
rt = {path = "../rt"}

最初构建并运行

cargo build #构建

#开启 semihosting 运行
qemu-system-arm `
    -cpu cortex-m3 `
    -machine lm3s6965evb `
    -nographic `
    -semihosting-config enable=on,target=native `
    -kernel target/thumbv7m-none-eabi/debug/app

会有如下输入:

Timer with period zero, disabling
0x1c4c
0x1c4d

每次都要构建后再运行一大串 QEMU 命令是很繁琐的,能够通过批改 .cargo/config 来简化:

[target.thumbv7m-none-eabi]
# 减少 runner 的相干配置项
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"

这样构建和运行能够简化为一行命令:cargo run --release

PS D:\workspace\rust\app\app> cargo run --release
    Finished release [optimized] target(s) in 0.03s
     Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target\thumbv7m-none-eabi\release\app`
Timer with period zero, disabling
0x11ec
0x11ed

从地址中解码出日志信息

在上一步中咱们失去了变量的地址,当初须要在符号表中查找这些地址,从而取得这些地址对应的符号名,这能够通过以下命令实现

rust-objdump .\target\thumbv7m-none-eabi\debug\app -t | findstr "00000001"

而后有以下输入:

# 从左到右各列的意义是:符号地址 | 符号的 flag | 跟符号相干的节的序号 | 所属的节 | 符号长度 | 符号名
00001c4c g     O .rodata        00000001 Hello, world!
00001c4d g     O .rodata        00000001 Goodbye

findstr 去查找 objdump 的输入中含有字符串 00000001 的行,如无意外应该只会找到咱们定义的两个符号,因为动态变量 AB的类型都是u8,只占一个字节,所以第五列(符号长度)的值必然是00000001

一些改良

在之前的实现中,动态变量被编译后会存储到 .rodata 节,这意味着把 ELF 烧录到 flash 中后这些动态仍然占据空间,即便它们的值并没有被应用(只用到了它们的地址)。因而要想一个方法让程序不要加载这些节从而节俭空间,这须要应用链接脚本。

app 中创立一个新的链接脚本 log.x 并增加以下内容:

SECTIONS
{.log 0 (INFO) : {*(.log);
  }
}

这段代码的意义如下:

  • 收集所有 input 指标文件中的名为 .log 的节,而后在 output 指标文件中生成名为 .log 的节
  • INFO示意该节在运行时不须要在内存中调配空间
  • 0示意该节的的加载地址(设为 0 是有它的非凡意义的,等下再说)

而后批改 app 中的 main.rs 为:

#![no_main]
#![no_std]

// 应用 semihosting 技术进行输入,因为 QEMU 间接反对 semihosting。而在真机环境则可能须要用到串口等技术
use cortex_m_semihosting::{debug, hio};

use rt::entry;

entry!(main);

fn main() -> ! {let mut hstdout = hio::hstdout().unwrap();

    #[export_name = "Hello, world!"]// 将日志信息编码到动态变量 A 的符号名中,#[link_section = ".log"]// 指定该动态变量输入到.log 这个节
    static A: u8 = 0;

    // 将地址的值作为 usize 输入
    let address = &A as *const u8 as usize as u8;
    hstdout.write_all(&[address]).unwrap(); // 不应用 core 中的格式化 I /O,改为应用第三方依赖的二进制 I /O

    #[export_name = "Goodbye"]
    #[link_section = ".log"]// 指定该动态变量输入到.log 这个节
    static B: u8 = 0;

    let address = &B as *const u8 as usize as u8;
    hstdout.write_all(&[address]).unwrap(); // 不应用 core 中的格式化 I /O,改为应用第三方依赖的二进制 I /O

    debug::exit(debug::EXIT_SUCCESS);

    loop {}}

代码中最值得注意的中央有两点:

  • 将变量的地址类型强转为u8
  • 不应用 core 中的格式化 I /O,改为应用第三方依赖的二进制 I /O

因为改为了二进制 I /O,因而会波及到多字节的序列化问题,为了防止这个问题罗唆将地址改为单个字节。但地址原本是 4 字节的,这样可能会导致地址的值被截断。为了防止截断的问题咱们将 .log 的加载地址设为 0,同时将地址的取值范畴限度为[0, 255],这样就能保障地址可能用单个字节来准确示意,避开了被截断的问题。但弊病就是最多只能应用 255 条日志。

最初在 .cargo/config 中指定新的链接脚本

# 针对这个 target 应用链接脚本
[target.thumbv7m-none-eabi]
rustflags = [
    "-C", "link-arg=-Tlink.x",
    "-C", "link-arg=-Tlog.x"
]
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
# 指定编译的 target,批改这里之后就无需在命令行传递 --target 参数了
[build]
target = "thumbv7m-none-eabi"

而后运行 cargo run --release | Format-Hex,会有以下输入(留神,原文中是用 linux 中的xxd 来显示二进制输入,这里用 Win11 中 powershell 自带的的 Format-Hex 来代替):

    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
     Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target\thumbv7m-none-eabi\debug\app`      
Timer with period zero, disabling


           00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F

00000000   00 01 

可见输入的地址是 0x00 和 0x01,在.log 节中查找这两个地址:

rust-objdump .\target\thumbv7m-none-eabi\release\app -t | findstr log

会有以下输入

00000000 g     O .log   00000001 Hello, world!
00000001 g     O .log   00000001 Goodbye

所有都合乎预期。

形象为一个库

下面尽管曾经实现了输入日志,然而这个过程还是相当繁琐用且不直观的,用户的冀望的输入日志应该像应用 std::println! 那么简略,为实现这个目标咱们须要将下面的逻辑封装为一个 lib crate。

先用命令 cargo new --lib log 创立一个名为 lib 的 lib package,而后批改 lib.rs 的内容为:

#![no_std]

pub trait Log {
    type Error;

    fn log(&mut self, address: u8) -> Result<(), Self::Error>;}

#[macro_export]
macro_rules! log {($logger:expr, $string:expr) => {{// 用户调用宏的时候参数包含 2 个:一个 log Trait 实例;一个日志字符串
        #[export_name = $string]
        #[link_section = ".log"]
        static SYMBOL: u8 = 0;// 每条日志字符串都有一个对应的动态变量

        $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8)
        // 由用户提供具体的输入实现,然而对于本教程来说,集体认为应该由库提供实现才对,这样用户就无需关注这方面的细节
    }};
}

和之前的 rt 一样,须要提供 build.rs 用于构建时复制log.x

use std::{env, error::Error, fs::File, io::Write, path::PathBuf};

fn main() -> Result<(), Box<dyn Error>> {
    // Put the linker script somewhere the linker can find it
    let out = PathBuf::from(env::var("OUT_DIR")?);

    File::create(out.join("log.x"))?.write_all(include_bytes!("log.x"))?;

    println!("cargo:rustc-link-search={}", out.display());

    Ok(())
}

最初批改 app 中的 main.rs,让它调用log 中的宏来输入日志

#![no_main]
#![no_std]

use cortex_m_semihosting::{
    debug,
    hio::{self, HostStream}//0.5.0 之后改为应用 HostStream 构造体,原文中是应用 HStdout
};

use log::{log, Log};
use rt::entry;

struct Logger {hstdout: HostStream,}

impl Log for Logger {type Error = ();

    fn log(&mut self, address: u8) -> Result<(), ()> {self.hstdout.write_all(&[address])
    }
}

entry!(main);

fn main() -> ! {let hstdout = hio::hstdout().unwrap();
    let mut logger = Logger {hstdout};

    let _ = log!(logger, "Hello, world!");

    let _ = log!(logger, "Goodbye");

    debug::exit(debug::EXIT_SUCCESS);

    loop {}}

此时打印日志所用的宏曾经十分靠近 println!,比之前那种艰涩的办法好多了!同时不要遗记批改 Cargo.toml,引入log 作为依赖

[dependencies]
rt = {path ="../rt"}
log = {path ="../log"}
cortex-m-semihosting  = "0.5.0"

最初运行一下 cargo run --release | Format-Hex 会有以下输入:

    Finished release [optimized] target(s) in 0.71s
     Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target\thumbv7m-none-eabi\release\app`
Timer with period zero, disabling


           00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F

00000000   00 01   

依照上一大节的办法用 rust-objdump 去查看 ELF 文件,会发现相干地位就是咱们日志中输入的字符串,这里不再反复

日志分级

实现日志分级的思维比较简单,如上所述咱们地址的无效范畴是 [0, 255],咱们在这个区间之中取一点 n,让[0, n] 这个地址区间放 error 级别的日志地址,[n, 255]放 warn 级别的日志地址,则实现了日志分级。此时想专门看某种类型的日志的话,只须要拿特定范畴的地址值去搜寻 ELF 文件即可。

首先咱们须要批改 log 中的lib.rs,提供别离用于输入 warn 和 error 级别日志的宏:

#![no_std]

pub trait Log {
    type Error;
    fn log(&mut self, address: u8) -> Result<(), Self::Error>;}

// 输入 error 等级的日志
#[macro_export]
macro_rules! error {($logger:expr, $string:expr) => {{#[export_name = $string]
        #[link_section = ".log.error"] // 搁置到.log.error 这个节
        static SYMBOL: u8 = 0;
        $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8)// 最终都是调用 log 函数,只是搁置的中央不一样
    }};
}

// 输入 warn 等级的日志
#[macro_export]
macro_rules! warn {($logger:expr, $string:expr) => {{#[export_name = $string]
        #[link_section = ".log.warning"] // 搁置到.log.warning 这个节
        static SYMBOL: u8 = 0;
        $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8)// 最终都是调用 log 函数,只是搁置的中央不一样
    }};
}

而后调整链接脚本,让不同级别的日志依照咱们下面形容的形式搁置,留神 __log_warning_start__ 就是咱们下面说的n,它是不同级别日志的分界点

SECTIONS
{.log 0 (INFO) : {*(.log.error);              /* 后面局部搁置 error 级别日志 */
    __log_warning_start__ = .;  /* 将以后地址值与符号__log_warning_start__关联起来,意味着剩下地址存的都是正告级别的日志 */
    *(.log.warning);            /* 剩下局部搁置 warning 级别日志 */
  }
}

最初批改 app 中的main.rs,应用新的宏来输入不同级别的日志

#![no_main]
#![no_std]

use cortex_m_semihosting::{
    debug,
    hio::{self, HostStream}//0.5.0 之后改为应用 HostStream 构造体,原文中是应用 HStdout
};

use log::{error, warn, Log};
use rt::entry;

struct Logger {hstdout: HostStream,}

impl Log for Logger {type Error = ();
    fn log(&mut self, address: u8) -> Result<(), ()> {self.hstdout.write_all(&[address])
    }
}

entry!(main);

fn main() -> ! {let hstdout = hio::hstdout().unwrap();
    let mut logger = Logger {hstdout};
    let _ = warn!(logger, "Hello, world!");
    let _ = error!(logger, "Goodbye");
    let _ = error!(logger, "你好呀");
    let _ = warn!(logger, "是的师父!");
    debug::exit(debug::EXIT_SUCCESS);
    loop {}}

cargo run --release | Format-Hex 运行程序,会有以下输入:

           00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F

00000000   02 00 01 03 

尽管 rust 代码里交织输入 warn 和 error 日志,然而这并不会扭转它们的关系:error 日志的地址永远小于 warn 日志。这是由链接脚本决定的,能够从输入中察看到这个法则。同时咱们检查一下生成的二进制文件

# 进入 wsl 而后执行以下命令:objdump ./target/thumbv7m-none-eabi/release/app -t | grep log
# 而后会有以下输入
00000002 g     O .log   00000001 Hello, world!
00000000 g     O .log   00000001 Goodbye
00000001 g     O .log   00000001 你好呀
00000003 g     O .log   00000001 是的师父!00000002 g       .log   00000000 __log_warning_start__
# 在 wsl 执行的起因是程序中用了中文字符串,在 powershell 下用 rust-objdump 去解析的话会乱码,成果不好。如果没有装置 wsl 的话也能够像之前那样应用命令:
# rust-objdump .\target\thumbv7m-none-eabi\release\app -t | findstr log

相比之前多了符号 __log_warning_start__,地址值属于[0, __log_warning_start__] 的符号都代表 error 级别日志;地址值属于 [__log_warning_start__, 255] 的符号都代表 warn 级别日志。

【7】全局的单例对象

本章次要是对上一章基进行改良,使得 log! 的应用更加像里的 std::println!,即应用相似log!(日志信息) 这样的 API 来输入日志。这种调用形式和之前相比少了一个 logger 对象,实现这个个性的原理是,注册一个全局的 logger 对象,从而使得用户在调用 log! 时不须要再提供logger。残缺代码请见这里

首先要批改 loglib.rs,它对外提供这些性能:

  • 让用户注册全局的单例对象logger
  • 提供 log! 宏,该宏能够应用全局或自定义的 logger 进行日志输入

具体代码如下,一些重要的细节曾经正文:

#![no_std]

// 对于 Sync、Send 的语义参考:https://www.zhihu.com/question/303273488/answer/2345814358
pub trait GlobalLog: Sync {
    /**
    申明一个 trait,全局的单例日志对象必须实现它,须要留神以下几点:1,log 办法只须要 &self,不耗费所有权,因为它用的是单例的全局共享对象。2,这里并没有像上面的 Log trait 那样定义一个关联的谬误类型,这是为了简化细节。将错误处理委托给用户,而不是由库指定错误处理规定并强制用户实现。**/
    fn log(&self, address: u8);
}

pub trait Log {
    type Error;
    fn log(&mut self, address: u8) -> Result<(), Self::Error>;}

#[macro_export]
macro_rules! log {
    // 该宏承受两种传参,第一种传参不须要提供日志对象,第二种传参须要提供日志对象

    // 第一种传参形式,此时会应用一个名为 "LOGGER" 的全局对象进行日志输入,它是一个定义在某处的全局对象
    ($string:expr) => {
        unsafe {
            extern "Rust" {
                // 对于 $crate 请见://https://zjp-cn.github.io/tlborm/decl-macros/minutiae/hygiene.html?highlight=%24crate#unhygientic
                // 咱们并不知道 LOGGER 的具体类型,但要求它必须实现了这里必须实现了 GlobalLog,所以必须用 trait object
                static LOGGER: &'static dyn $crate::GlobalLog;
            }

            #[export_name = $string]
            #[link_section = ".log"]
            static SYMBOL: u8 = 0;

            $crate::GlobalLog::log(LOGGER, &SYMBOL as *const u8 as usize as u8)
        }
    };

    // 第二种传参形式,须要用户本人提供日志对象进行输入,这是上一章的形式
    ($logger:expr, $string:expr) => {{#[export_name = $string]
        #[link_section = ".log"]
        static SYMBOL: u8 = 0;

        $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8)
    }};
}

// 提供一个宏,让用户注册单例的全局日志对象,并将符号名称定为 "LOGGER",这样正好和下面对应
#[macro_export]
macro_rules! global_logger {($logger:expr) => {#[no_mangle]
        pub static LOGGER: &dyn $crate::GlobalLog = &$logger;
    };
}

而后咱们批改 app/main.rs 来应用上述的新个性:

#![no_main]
#![no_std]

use cortex_m::interrupt;
use cortex_m_semihosting::{
    debug,
    hio::{self, HostStream}//0.5.0 之后改为应用 HostStream 构造体,原文中是应用 HStdout
};

use log::{global_logger, log, GlobalLog};
use rt::entry;

struct Logger;

global_logger!(Logger);// 将 Logger 注册为全局 logger,这样在应用 log! 宏的时候就不再须要提供 logger 对象

entry!(main);

fn main() -> ! {log!("Hello, world!");// 更为简洁的日志 API,不须要被动提供 logger 对象
    log!("Goodbye");
    debug::exit(debug::EXIT_SUCCESS);
    loop {}}

// 全局 logger 的实现
impl GlobalLog for Logger {fn log(&self, address: u8) {
        //interrupt::free 作用是在一个无中断的上下文环境中执行函数,这是拜访 static mut 类型变量的要求
        // 因为 HSTDOUT 是动态变量,只有做到这样能力保障内存平安。// 这种机制就是所谓的临界区(critical section)interrupt::free(|_| unsafe {
            static mut HSTDOUT: Option<HostStream> = None;

            // 提早初始化
            if HSTDOUT.is_none() {HSTDOUT = Some(hio::hstdout()?);
            }
            let hstdout = HSTDOUT.as_mut().unwrap();
            hstdout.write_all(&[address])
        })
        .ok(); // 调用 ok()意味着疏忽谬误并返回 Option
    }
}

不要遗记在 Cargo.toml 中引入新的依赖

[dependencies]
rt = {path ="../rt"}
log = {path ="../log"}
cortex-m-semihosting  = "0.5.0"
cortex-m = "0.7.6"

cargo run --release | Format-Hex 运行程序,会有以下输入:

           00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F

00000000   00 01 

应用全局单例对象来简化 log! 宏的指标曾经达成。

因为应用了 trait object,因而可能会因为引入 vtable(实现动静分派)而造成性能损耗。但 LLVM 仿佛相当智能地做了优化,比照上面的命令和输入能够发现,在 release 模式下 LOGGER 都找不到了

cargo objdump --bin app --release -- -t| findstr LOGGER
    Finished release [optimized] target(s) in 0.03s #release 模式下曾经找不到符号 LOGGER

cargo objdump --bin app -- -t| findstr LOGGER
    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
00000730 g     O .rodata        00000008 LOGGER

【8】DMA

第八章对实现 DMA 做了介绍并给出一些代码,但没有可供理论运行的例子,因而这个章节临时先放一放,日后如果有能力在此基础上实现一个能够运行的 DMA 时候再回来补充笔记

正文完
 0