王mono · 2022年05月09日

【GD32F310开发板试用】用 Rust 开发 Cortex-M4 初探—寄存器访问, 设备初始化

首先提示, 此文章将会比较干.

4.2 收到社区开发板试用审核通知. 要求1个月内发布 【GD32F310开发板试用】文章. 于是它来了.

点过灯的板子也不少了. 插电, 开机, 观看出场固件(曾有块板子的出场固件是全板所有功能组件依次展示, TFT-LCD 还有不错的 UI, 所以出场固件被 dump 下来珍藏了).
玩板子, 重在参与. 装环境, 写 Blinky, 烧程序, 点灯成功, 喜悦, 吃灰.
收到邮件之后, 觉得有必要先准备准备. 于是注册了社区会员. 掏出资料浏览了浏览.
预期目标: macOS M1 下完成评测, 如果工具链支持不足, 改用 Win 10.

Why Rust

  • 请自行搜索社区相关介绍文章和会议分享视频.
  • 简言之: 更好的对 MCU 抽象, 和 C 等价的性能, 用语言特性避免一些常见编程误区和 BUG 点
  • 强大的社区支持, 社区提供了最佳实践的范式, 基于相同范式衍生出整个生态, 例如图形库, 外设驱动等等.
  • 不足: 语言本身门槛较高, 一定的学习成本, 库目前没有到达 1.0 稳定, 存在代码需要大改的可能, 硬件行业的惯性, 不愿意迎合新技术

预备知识

  • Rust 基础, 知道 Rust 生态官网 crates.io, docs.rs 等日常
  • 玩板子基础(电子电路基础), 尤其注意用电安全, 知道不把 220V 接板子上(老梗, 那年在大学时候借给学电子的学霸一块 Arduino, 然后他就给上 220V 了)

初识 GD32F310

初申请时候看到群里大家嚷嚷着有羊毛, 就冲了. 拿到手此时才开始了解板子基础情况.
列出如下:

  • 名称: GD32F310G-START 评估板, 带一根 USB 数据线
  • MCU: GD32F310G8U6, U6为封装后缀

    • Arm® Cortex®-M4 RISC 内核, 32 位, 支持 DSP, 带 FPU
    • 72MHz Clock Speed
    • 64K Flash, 8K SRAM (大 Byte, K=Ki, 下同)
    • QFN28 封装, up to 23 I/O
    • 2x USART

      • USART0: PA9 - TX, PA10 - RX (也有其他 alternative function 暂时不考虑)
      • USART1: PA2 - TX, PA3 - RX
    • 2x I2C, 2x SPI, 1x I2S, 1x ADC(10 channel)
    • 遇到新板子新 MCU, 以上指标是最重要的之一, 决定了你能拿这板子干啥.
    • 5x GPTM, 1x ATM, WDG, RTC
      GP = general purpose
      A = Advanced
      TM = Timer
      WDG = Watch Dog
      RTC = Realtime Clock(时钟)
  • 板载:

    • 自带下载器 GD-Link(GD32F103C8T6)

      • GD-Link 等价 CMSIS-DAP, 好用
      • 这里吐槽下 GD 家的 START 板子, 其实可以学习友商把下载器部分和 MCU 部分用跳线帽隔离, 必要时候能做个下载器
    • 1x User LED, PA1
    • Reset Key, Wakeup/User Key PA0

开箱第一步

二话不说先焊排针, 我自己搞了个白色排针. 随后检查设备信息, 准备安装开发工具.
USB 设备信息, 这里可以看到是一个 CMSIS-DAP 设备:

Bus 001 Device 008: ID 28e9:058f 28e9 CMSIS-DAP

官方工具 GD-Link Programmer 很可惜是 Windows Only, 这时候我们需要去开源工具集找找看.

官方提供的工具集里面有用于 Keil 的 .pack 扩展, 很多开源工具都支持 .pack (cmsis-pack) 加载芯片信息.

probe-rs / probe-run / cargo-flash

https://probe.rs/ 目前是 Rust 嵌入式排名靠前(如果不是第一)的调试烧录工具集之一, 比较完善,
拿到板子时候, probe-rs 对 GD32 只有 GD32VF 支持, 需要添加. 没事我已经加好了, 直接安装即可. probe-rs/probe-rs#1079, 在 probe-rs 发布最新版本之前, 需要从 git repo 安装.

# CMSIS-DAP 兼容识别, 但无法识别 MCU
> probe-run --list-probes
the following probes were found:
[0]: CMSIS-DAP (VID: 28e9, PID: 058f, CmsisDap)

# 直接安装 git 版本
> cargo install --git https://github.com/probe-rs/probe-rs --all-features probe-rs-cli
.... (耗时, 耗网)
> probe-rs-cli chip list | grep -i gd32f310
        GD32F310C8
        GD32F310F4
        GD32F310F6
        GD32F310F8
        GD32F310G8
        GD32F310K6
        GD32F310K8
# 可以看到, 我们的 GD32F310G8 芯片在支持列表里!
> probe-rs-cli chip info GD32F310G8
GD32F310G8
Cores (2):
    - main (Armv7em)
    - main (Armv7em)
RAM: 0x20000000..0x20002000 (8.00 KiB)
NVM: 0x08000000..0x08010000 (64.00 KiB)

# 列出下载器, 这里看到的是 GD-Link
> probe-rs-cli list
The following devices were found:
[0]: CMSIS-DAP (VID: 28e9, PID: 058f, CmsisDap)

# 检测目标芯片
> probe-rs-cli info --probe 28e9:058f

# 列出当前 MCU 固件前 100 word(每word 4 bytes)
> probe-rs-cli dump 0x08000000 100 --probe 28e9:058f --chip GD32F310G8
Addr 0x08000000: 0x20002000
Addr 0x08000004: 0x08000401
Addr 0x08000008: 0x080006dd
Addr 0x0800000c: 0x08000751
Addr 0x08000010: 0x080006dd
....
Addr 0x080003f4: 0x080006dd
Addr 0x080003f8: 0x080006dd
Addr 0x080003fc: 0x080006dd
Addr 0x08000400: 0x34fff04f
Addr 0x08000404: 0xf00046a6
Addr 0x08000408: 0x46a6f81f
...

pyocd

pyocd 好处是如果有 .pack 文件, 可以直接支持. 但官方提供的 Demo/AddOn 中 .pack 文件格式非法, 需要单独处理后才可以使用, 这个在微信群之前提过:

  • 修复非法 svd xml 文件
  • 去掉 .pack(其实是.zip格式)解压后的第一层目录重新打包

准备 Rust 环境

安装 Rust 参考 https://rustup.rs/, 记得使用 nightly. Coretex-M4 需要使用 thumbv7em-non-eabihf 工具链, 其中 hf 表示 hardware float. 表示有 FPU.

rustup target add thumbv7em-none-eabihf

brew install arm-none-eabi-gcc

Bare Metal 点灯计划

准备 Rust 基础库

  • Rust Embedded 对多数嵌入式 MCU 的支持大致采用2级或者3级的结构, 即 PAC -> HAL -> BSP, 从最底层到最抽象. 其中 BSP 一般是具体板子的外设和 PIN 定义, 多数情况下, 应用直接依赖 HAL 即可. 也可以直接 PAC 开写, 特别类似 C/C++ 嵌入式开发中引入一个 .h 头文件寄存器定义, 然后直接程序里用 C 操作寄存器完成各种开发.
  • PAC 即 peripheral access crate, 相当于定义了芯片的寄存器结构和中断结构. PAC 库可以通过 SVD 文件自动生成, 唯一需要的就是找到芯片家族对应的 SVD 文件, 运行 svd2rust 工具. 正好官方资料页面提供的 AddOn IDE 支持中就有我们需要的 SVD 文件.
  • 因为比较简单, 生成后即可在 docs.rs 查看文档. 我这里已经发布好了. https://docs.rs/gd32f3x0-pac/..., 个人觉得熟悉了 PAC 使用方式和结构之后, docs.rs 比直接查寄存次手册 PDF 要方便得多.

这里简单介绍下 PAC 库的创建, 需要使用 svd2rust.

cargo new --lib gd32f3x0-pac
cd gd32f3x0-pac/

unzip ../GD32F3x0_Demo_Suites_V2.2.0/GD32F3x0_AddOn/Keil/keil5/GigaDevice.GD32F3x0_DFP.3.0.0.pack

cargo install svd2rust

# svd xml 非法, 删除头两个空格

0: In device `GD32F3x0`
1: In peripheral `TSI`
2: In register `CTL1`
3: Parsing unknown access at 21808:11
4: unknown access variant 'write' found

发现对应的字段应该是 read-write, 改值

svd2rust -g --strict -i GD32F3x0.svd # -o src

修改 Cargo.toml
[lib]
path = "lib.rs"

cargo fmt

建立 Rust embedded 项目!

这里采用 BSP lib crate + app as examples 的方式创建我们玩板子的项目结构. 即, 项目其实是一个 START 开发板的 BSP 库, 然后把点灯, 点屏等应用放入 examples/ 目录下. 以 examples 方式执行. 库的名字就和开发板名字一样即可.

> cargo new --lib gd32f310g-start
Created library `gd32f310g-start` package

编辑 Cargo.toml 添加依赖

# Cargo.toml
...

[dependencies]
cortex-m = "0.7.4"
cortex-m-rt = "0.7.1"

gd32f3x0-pac = { version = "0.1.0", features = ["rt"] }
# gd32f3x0-pac = { path = "../gd32f3x0-pac" }

添加 memory.x 链接脚本, 描述我们设备的 FLASH 和 RAM 内存偏移和长度. 这个文件名在构建过程中会被 rt 识别并自动包含.

/* GD32F310G8U6 */
MEMORY
{
    FLASH : ORIGIN = 0x08000000, LENGTH = 64K
    RAM : ORIGIN = 0x20000000, LENGTH = 8K
}

添加 build.rs 文件, (可选) 多数情况下不需要, 但如果是复杂 workspace 结构, 那么链接脚本有可能无法找到需要被引用的 memory.x 文件. 本例中, 是单独一个 rust lib crate, 不需要这一步.

添加 .cargo/config.toml 文件

[build]
target = "thumbv7em-none-eabihf"

# [target.'cfg(all(target_arch = "arm", target_os = "none"))']
# runner = "arm-none-eabi-gdb -q -x pyocd.gdb"

# NOTE: memory.x is included by upstream deps in link.x
rustflags = [
    "-C",
    "linker=arm-none-eabi-ld",
    # "-C",
    #"link-arg=-nostartfiles",
    "-C",
    "link-arg=-Tlink.x",
]

修改 src/lib.rs 暂时只 reexport PAC 库:

// src/lib.rs
#![no_std]

pub use gd32f3x0_pac as pac;

添加点灯项目模板 examples/blinky.rs, Rust 中, 所有 examples 目录的代码可以通过 cargo run/build --example NAME 来执行或编译.

// blinky.rs
// TODO: finish blinky
#![no_std]
#![no_main]

use cortex_m_rt::entry;
use gd32f310g_start::pac;

#[entry]
fn main() -> ! {
    loop {}
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

提示:

  • Rust Embedded 一般都伴随着 no_std, 在明确引入内存分配之前, 无法使用任何需要在 Heap 分配内存的类型和函数. 例如 String/Vec/format! 等.
  • cortex_m_rt::entry 宏可以用来帮助定义程序入口. 主函数返回类型 ! 表示 Never, 即永远不返回. 所以需要用死循环结尾.
  • 如果不添加 panic_handler, 在 Embedded 环境下会报错 error: #[panic_handler] function required, but not found, 因为此时 Rust 不知道如何处理 panic 异常. 这里直接挂起就行(死循环).
  • 即使不用到 PAC, 也要引入, 因为 linker 相关设置位于 PAC.

尝试编译:

> cargo build --release --example blinky

> ls -lah target/thumbv7em-none-eabihf/release/examples/blinky
...

考虑到此时的空项目烧录上去完全无法判断程序是否执行, 就不搞这步了, 直接开始写点灯

裸 PAC 点灯

在官方 Demo 目录有现成的 GPIO 点灯代码, C 实现. 这里就根据此写一个 Rust 纯操作寄存器版的点灯.

需要知道的是, 看起来官方例子 01_GPIO_Runing_LED 源文件只有非常简单的 main() 函数, 实际上还有一部分是 Firmware_Library 完成的. IDE 隐藏了部分细节.

具体来说, 是在进入 main() 之前的的 SystemInit() 函数, 和中断向量表配置.

  • SystemInit() 函数 GD32F3x0_Firmware_Library/CMSIS/GD/GD32F3x0/Source/system_gd32f3x0.c
  • 中断向量表 GD32F3x0_Firmware_Library/CMSIS/GD/GD32F3x0/Source/ARM/startup_gd32f3x0.s
  • 其中中断向量表在 MCU 启动时被自动读取, 用于加载并调用用户代码
  • 需要关注的是位于 0x08000004 的 reset 向量, 即 Reset_Handler, 系统复位的入口
  • 逻辑非常简单, 依次调用了 SystemInit 函数和 __main 函数

            ;/* reset Handler */
            Reset_Handler   PROC
                            EXPORT  Reset_Handler                     [WEAK]
                            IMPORT  SystemInit
                            IMPORT  __main
                            LDR     R0, =SystemInit
                            BLX     R0
                            LDR     R0, =__main
                            BX      R0
                            ENDP
  • SystemInit() 函数主要负责时钟初始化, 同时还包含必备外设的参数设置初始化, 例如 FPU.

相对应的 Rust Embedded 初始化相关逻辑位于 cortex-m:cortex-m-rt/src/lib.rs 与 Firmware_Library 中的 startup 类似. .Reset 逻辑包括调用 pre_initmain 函数, 初始化内存分配, 初始化 FPU 等逻辑.

两者相似的地方都是使用大量汇编代码完成 "init" 逻辑. 而 Rust 中, PAC 和 cortex-m-rt 库提供了中断向量表, 但没有提供具体时钟初始化!

可以偷懒是, MCU 上电后都有默认内部 RC 时钟, SDK 库提供的时钟初始化往往是为了启用外部高速/低速晶振.

去掉时钟初始化, 点灯需要做的就很简单了:

  • 启用 AHB 的 GPIO 对应 port 时钟
  • 设置 GPIO Pin 的模式
  • 点灯

所以, 修改我们的 blinky.rs 代码如下:

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use gd32f310g_start::pac;
use cortex_m::delay::Delay;

#[entry]
fn main() -> ! {
    let periph = pac::Peripherals::take().unwrap();

    let rcu = periph.RCU;

    // - enable the led clock, GPIOA
    rcu.ahben.modify(|_, w| w.paen().set_bit());

    // - configure led GPIO port
    //   LED = PA1
    let gpioa = periph.GPIOA;
    // #define GPIO_MODE_INPUT            CTL_CLTR(0)           /*!< input mode */
    // #define GPIO_MODE_OUTPUT           CTL_CLTR(1)           /*!< output mode */
    // #define GPIO_MODE_AF               CTL_CLTR(2)           /*!< alternate function mode */
    // #define GPIO_MODE_ANALOG           CTL_CLTR(3)           /*!< analog mode */
    //   MODE = OUTPUT
    gpioa.ctl.modify(|_, w| unsafe { w.ctl1().bits(1) });
    // GPIO_PUPD_NONE = 0
    // GPIO_PUPD_PULLUP = 1
    // GPIO_PUPD_PULLDOWN = 2
    gpioa.pud.modify(|_, w| unsafe { w.pud1().bits(0) });
    //   OMODE = 0, PushPull
    gpioa.omode.modify(|_, w| w.om1().clear_bit());

    // gpio pin output speed: over GPIO_OSPEED_50MHZ
    gpioa.ospd0.modify(|_, w| unsafe { w.ospd1().bits(0x03) });
    gpioa.ospd1.modify(|_, w| w.spd1().set_bit());

    // Port Bit clear register
    // gpioa.bc.write(|w| w.cr1().set_bit());
    // Port bit operate register, led on
    gpioa.bop.write(|w| w.bop1().set_bit());

    let core_periph = pac::CorePeripherals::take().unwrap();
    let syst = core_periph.SYST;

    let mut delay = Delay::new(core_periph.SYST, 1_000_000);

    loop {
        gpioa.tg.write(|w| w.tg1().set_bit());

        delay.delay_ms(1000_u32);
    }
}

这里直接使用 cortex-m-rt 库提供的平台 Delay 实现, 即 SysTick.

Rust 在访问寄存器时候, 需要首先获取设备 Peripherals, 所有寄存器块 RegisterBlock 都位于其结构下, 例如 RCU, GPIOA, SPI0 等等. 块内每个偏移后的寄存器字 Reg<T> 位于其下 . 之中包含每个寄存器的位/位域(bit/bits field)访问结构. 大概图示如下:

pac::Peripherals
    RCU: RegisterBlock
        ctl0: Reg<CTL0>
            pllstb: PLL_STB
                R: PLL_STB_R # 用于读取 bit/bits: bit_is_clear(), bit_is_set()...
                W: PLL_STB_W # 用户写入 bit/bits: set_bit(), clear_bit(), .bit(bool), .bits(u8)...
            ...
        ...
    GPIOA: RegisterBlock
        ctl:
            ctl0
            ctl1
            ...
        tg: ...
    ADC
    DMA
    ...

写寄存器的时候, 经常遇到位设置的操作, 可能有先读后的需求. Rust 提供了 .modify(|r, w| { }) 工具方便一次调用同时完成读写. 其他工具函数还有 .read(), .write(|w| ...), .reset().

由此可见, 相当于整本寄存器手册定义被作为 PAC 库呈现出来(实际上是从 SVD 文件自动生成). 配置得当的 IDE 或编辑器可以很方便地做代码提示. 而相较于 C 中的宏定义, 不会让你迷失在位操作里和寄存器名前缀里.

烧录执行

> cargo build --release

> arm-none-eabi-objcopy -v -O binary target/thumbv7em-none-eabihf/release/gd32f310g-playground firmware.bin

# 检查固件内容
> hexdump -C firmware.bin | more
00000000  00 20 00 20 51 01 00 08  45 04 00 08 b9 04 00 08  |. . Q...E.......|
00000010  45 04 00 08 45 04 00 08  45 04 00 08 00 00 00 00  |E...E...E.......|
00000020  00 00 00 00 00 00 00 00  00 00 00 00 45 04 00 08  |............E...|
00000030  45 04 00 08 00 00 00 00  45 04 00 08 45 04 00 08  |E.......E...E...|
00000040  45 04 00 08 45 04 00 08  45 04 00 08 45 04 00 08  |E...E...E...E...|
*
00000080  45 04 00 08 45 04 00 08  00 00 00 00 45 04 00 08  |E...E.......E...|
00000090  45 04 00 08 45 04 00 08  45 04 00 08 45 04 00 08  |E...E...E...E...|
*
000000b0  45 04 00 08 00 00 00 00  45 04 00 08 00 00 00 00  |E.......E.......|
*
000000d0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
000000e0  00 00 00 00 00 00 00 00  45 04 00 08 00 00 00 00  |........E.......|
000000f0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000100  45 04 00 08 00 00 00 00  00 00 00 00 00 00 00 00  |E...............|
00000110  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000140  00 00 00 00 00 00 00 00  00 00 00 00 45 04 00 08  |............E...|
00000150  4f f0 ff 34 a6 46 00 f0  2b f8 a6 46 0e 48 0f 49  |O..4.F..+..F.H.I|
00000160  00 22 81 42 01 d0 04 c0  fb e7 0d 48 0d 49 0e 4a  |.".B.......H.I.J|
00000170  81 42 02 d0 08 ca 08 c0  fa e7 0c 48 4f f4 70 01  |.B.........HO.p.|
00000180  02 68 0a 43 02 60 bf f3  4f 8f bf f3 6f 8f 00 b5  |.h.C.`..O...o...|
00000190  00 f0 0f f8 00 de 00 00  00 00 00 20 04 00 00 20  |........... ... |
000001a0  00 00 00 20 00 00 00 20  28 05 00 08 88 ed 00 e0  |... ... (.......|
...

# 确认固件内容 OK

# 方法0 (可选) 从 ELF 提取固件
> arm-none-eabi-objcopy -v -O binary target/thumbv7em-none-eabihf/release/gd32f310g-playground firmware.bin
# 因为工具可以自动识别 ELF 及加载地址, 所以不需要以上.

# 方法1 直接烧录固件, 然后重启
> probe-rs-cli download --probe 28e9:058f --chip GD32F310G8 ./target/thumbv7em-none-eabihf/release/gd32f310g-playground --connect-under-reset
> probe-rs-cli reset --probe 28e9:058f --chip GD32F310G8

# 方法2 
probe-rs-cli run --probe 28e9:058f --chip GD32F310G8 target/thumbv7em-none-eabihf/release/gd32f310g-playground

HAL 库

HAL 即 Hardware Abstraction Layer. 与 PAC 库一起组成 Rust 嵌入式开发的重要部分.

HAL 库名字必须是 chip 或者 chip family, 以 -hal 结尾, 区分于 register access crates(PAC).

在 PAC 层, 接触到的主要是 cortex-m / cortex-m-rt 这类 crate 带来的生态. 而一旦进入 HAL 层, 基于 embedded-hal, nb 等提供的良好抽象, 可以更方便地实现所需功能. 例如 embedded-graphics 对 MCU 的显示需求抽象出统一接口, 只需要替换不同的底层驱动, 就可以方便地完成点屏.

具体说来, 就是利用 Rust 的多种语言特性, 近一步对 PAC 提供的寄存器组做封装, 通过 borrow 语义和类型安全在逻辑上限制部分不安全的操作(比如随便设置寄存器位).

以时钟 RCU 为例, C SDK 的写法可能是, 依次复位, 把设置不同的配置组写成工具函数依次调用(比如 GD32F3x0 SDK 中的多个时钟初始化函数, 其实就是内部RC/外部晶振 + 倍频的组合).

而在 Rust HAL 中, 会通过 Builder pattern, 模拟时钟树的方式一步一步完成初始化, 同时会提供类型安全的接口供打开外设时访问对应的外设时钟 EN RST 寄存器位.

let periph = pac::Peripherals::take().unwrap();

let rcu = periph.RCU.constrain(); // of type Rcu
let clocks = rcu
    .cfg
    .sysclk(SystemClockSwitch::IRC8M)
    .apb2_psc(ApbPrescaler::DIV1)
    .freeze();
    
/*
struct Rcu {
   cfg: CfgBuilder,
   AHB: ?,
   APB1: ?,
   APB2: ?,
}
*/

如上, .constrain() 对 PAC 的 RCU 寄存器进行了一次包装 Rcu 类型, 抽象出了 cfg 等操作组合, 通过结构体内部字段的严格 move 语义, 做到安全限制. (.constrain() 方法通过 Ext trait 提供)

cfg 用于系统时钟初始化, 按照时钟树, 依次选择整条链路上的 PLL, MUL, DIV 等等. .freeze() 函数才是真正按照所提供参数开始初始化时钟, 并返回一个 Clocks 结构.

Clocks 结构包含很简单的几个字段, 例如 sysclk, hclk 等等, 供其他外设初始化时候计算时钟频率使用. .freeze() 之后, 再无法通过非 unsafe 的方法操作重要时钟的复位和设置!

在 HAL 中, 除了 constrain 另一个常用寄存器组封装逻辑是 .split(),
在 GPIO 中常用.

例如:

let gpioa = periph.GPIOA.split(&mut rcu.AHB);
let pin = gpioa.pa1.into_push_pull_output();

pin.set_high();
let _ = pin.toggle();

很明显, GPIO Port 在初始化的时候需要打开 AHB 对应的时钟, 所以 .split() 函数需要接受 rcu.AHB 对象.

综上, 其实可以发现:

  • PAC 用严格的 access method 限制了寄存器字段和位的访问, 按照设备树组织 RegisterBlock
  • HAL 通过把 RegisterBlock 分装为抽象设备, 通过实现 embedded-hal, embedded-dma 等接口库等协议, 提供统一的设备外设访问方法.
  • 基于结构体字段的 move 语义, 保证了非 unsafe 情况下, 无法用破坏方式访问寄存器

    • 设想场景, 时钟已经初始化完毕, Clocks 已经被用来计算 UART 速率提供服务, 这时候再去修改时钟源或倍频?

当然, 以上说的只是 Rust Embedded 的皮毛. 具体到 GPIO, DMA 等, 又有更多话题需要讨论.

所有相关示例代码和配置见 https://gitee.com/andelf/gd32...

  • probe-rs 的 GD32F3x0 系列支持已提交合并
  • gd32f3x0-pac 已发布到 crates.io

参考

UPDATE

[[2022-04-20]] 群里遇到 gd32-rs 维护者, 可能会把以上修改合并到 gd32-rs 完善整个生态.

推荐阅读
关注数
10692
内容数
187
中国高性能通用微控制器领域的领跑者兆易创新GD系列芯片技术专栏。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息