gd32e2-hal 0.10.1

HAL for the GD32E23x series (Cortex-M23), currently the GD32E230
Documentation

gd32e2-hal

English · Русский


English

A hardware abstraction layer for the GD32E230K8U6 (Cortex-M23), written in Rust from scratch on top of the gd32e2 PAC.

⚠️ Work in progress. Written by hand, incrementally; the API is unstable. The package is a library (src/lib.rs → adc, dma, gpio, rcu, spi, time, timer, usart) plus on-hardware test binaries in examples/; all 13 examples have been flashed and verified on the board — RCU, GPIO, USART (8/9-bit and parity), SPI0/SPI1, ADC, a one-shot DMA transfer, TIMER, blocking delays, PWM, RTT.

Principles

  • Errors at compile time, not on the board. Pin identity and mode live in the type: Pin<'A', 5, Input> has no set_high, an invalid AF number doesn't compile, and ownership prevents reconfiguring a pin twice or using a port before its clock is on.
  • Zero-cost. The same register writes as hand-written PAC code; Pin is a ZST.
  • #![no_std], no heap.

Chip variants

The same pin and AF number reach a different peripheral depending on the variant (PA2 AF1 is USART0_TX on x4, USART1_TX on x8). The AF map is gated on three mutually exclusive features; exactly one must be enabled, or it's a compile_error! rather than a silently truncated pin map.

feature datasheet note applies to
gd32e230x4 (1) x4
gd32e230x6 (2) x6
gd32e230x8 (default) (2) + (3) x8 — the targeted GD32E230K8U6

x8 is a superset of x6; where a row is footnoted only (1) and (3), x6 has no function on that AF at all.

An orthogonal feature, defmt, derives defmt::Format on the public enums and error types. Off by default; also enables embedded-hal/defmt-03.

What's implemented

GPIO (src/gpio.rs) — const-generic Pin<P, N, MODE> (a ZST: port and pin number live in the type). Modes as typestate: Input, Analog, Output<PushPull> / Output<OpenDrain>, Alternate<AF>, Debugger (PA13/PA14, escaped only via unsafe activate()), and Locked<MODE> (terminal — the hardware stays locked until reset, so there is no unlock). dp.gpioa.split(&mut rcu) (GpioExt) enables the port clock and hands out pins. Transitions: into_input / into_output / into_push_pull_output / into_open_drain_output / into_analog / into_alternate::<AF>() — the AF number is checked at compile time against a per-pin ValidAf map. Plus set_pull, set_speed, lock() (ports A/B only — port F has no LOCK register). Pin state is inherent and Result-free: set_high / set_low / toggle (atomic TG) / is_set_high / is_set_low on outputs, is_high / is_low on inputs and on open-drain outputs. The same pins implement embedded-hal 1.0 OutputPin / InputPin / StatefulOutputPin, which return Result<_, Infallible>; both layers call the same private register helpers. Ports A, B and F; port C isn't bonded on this package.

RCU (src/rcu.rs) — dp.rcu.constrain() (RcuExt). Per-peripheral Enable and Reset traits, generated by the bus_en!/bus_rst! macros (split apart since not every peripheral has a reset bit — DMA has none) and wired into split(), so a port can't be used unclocked. Clock tree: a CFGR builder → .freeze(&mut rcu, &mut dp.fmc) → a frozen Clocks. PLL from IRC8M (PllFreq, 8–72 MHz) and bus prescalers (AhbPsc / ApbPsc) are typed enums, so an unreachable frequency is a compile error instead of silent rounding; flash wait states are set from the resulting hclk before the source switch. Usart0Sel and AdcSel / AdcPsc resolve into Clocks for the USART and ADC modules. rcu.ck_out(src, div) routes an internal clock node onto PA8/PA9 (CkOutSrc / CkOutDiv), so a real frequency can be measured on a pin rather than trusted. pclk1_tim / pclk2_tim carry the timer branch: hclk at an undivided APB, twice the bus clock otherwise. Typed frequencies live in src/time.rs, aliases over fugit (Hertz), which is re-exported. HXTAL is out of scope — no crystal on this board.

USART (src/usart.rs) — Usart<USARTX, TX, RX, WORD = Byte> owns the peripheral and both pins. TxPin / RxPin markers (filled by usart_pins!) reject a wrong pin or AF at compile time; BusClocks picks the right bus frequency per instance, so the baud calculation can't use the wrong one. UsartConfig (fluent, Default = 115200 / ×16 / N8) carries baud (named constants in usart::baud), Oversampling::{X16, X8} and FrameFormat::{N8, E8, O8, E7, O7} (one source of truth for WL/PCEN/PM). Blocking inherent API on the Byte width: write_byte / write_bytes / read_byte / read_bytes, plus flush (waits for TC, not TBE). Transmission has no error conditions, so the sending half returns no Result. read_bytes blocks for the first byte, then takes only what is already waiting. read_ready / write_ready poll RBNE / TBE without blocking. Trait layers on the same width: embedded-hal-nb Read<u8> / Write<u8> and embedded-io Read / Write / ReadReady / WriteReady, the latter crate supplying read_exact / write_all / write_fmt as trait defaults. flush, read_ready and write_ready exist in both layers; the inherent ones win method-call syntax, the trait ones are reached by path. Pure 9-bit words via new_word / write_word / write_words / read_word / read_words on the Word typestate (UsartConfig9). RX errors are usart::Error (Overrun / Noise / Framing / Parity), cleared through USART_INTC; portable ErrorKinds come from the serial::Error and embedded_io::Error impls. release() returns the peripheral and both pins.

ADC (src/adc.rs) — Adc::new(rcu, adc, clocks), or dp.adc.constrain(...) through AdcExt, runs the manual's calibration sequence (ADCON, a 14-CK_ADC-cycle delay converted to core cycles, RSTCLB/CLB). read<PIN: Channel>(&pin, SampTime) -> u16 performs a single blocking, software-triggered conversion. Channel is implemented only for Pin<P, N, Analog>, so a pin must actually have gone through into_analog(). Internal channels: read_vref() -> i32 returns the real VDDA in mV (derived from the factory VREFINT_CAL in flash, falling back to the typical ~1.2 V VREFINT if that calibration is blank), and read_temperature() -> Option<i32> returns tenths of °C — None when CK_ADC is too fast to satisfy the sensor's minimum sampling time. Scan mode needs DMA and is deferred.

SPI (src/spi.rs) — SPI0 and SPI1: master, full-duplex, blocking, 8- or 16-bit, software NSS. Spi::new(...) / Spi::new_word(...) take a SpiConfig (SpiConfig::new(psc) — psc is mandatory and there is no Default, since an SCK divider has no universal value; .mode() and .bit_order() override the Mode 0 / MSB-first defaults). Word width is a typestate: transfer_word and SpiBus<u16> don't exist on Spi<..., Byte>, transfer_byte and SpiBus<u8> don't exist on Spi<..., Word>. BitOrder is a plain runtime value — it changes wire serialization, not any method signature. An Instance trait abstracts the peripheral at the operation level, so one generic Spi<> serves both instances despite their distinct RegisterBlock types and bit-level divergence (FF16 in CTL0 on SPI0 vs DZ in CTL1 on SPI1, with BYTEN derived from the width). Buffer operations are inherent — transfer_bytes / transfer_bytes_in_place / read_bytes / write_bytes and the _words variants; the SpiBus impls delegate to them. Errors are spi::Error (Overrun / ModeFault / Crc / Framing); ErrorKind comes from the spi::Error trait impl, where Crc maps to Other for lack of an upstream variant. release() returns the peripheral and pins. Hardware NSS and CRC are deliberately not implemented.

DMA (src/dma.rs) — one-shot transfers, verified on hardware. dp.dma.split(&mut rcu) (DmaExt) hands out Channel<0>…Channel<4>, each a unique ZST token, so a channel can't drive two transfers at once. write_to / read_from take the channel, the peripheral and the buffer by value and return a Transfer; wait() is the only way to get all three back, which is what makes it impossible to read a buffer the hardware is still filling. Buffers are &'static [W] / &'static mut [W]. DmaSrc<N> / DmaDst<N> encode the datasheet's request map (Table 8-3), so a peripheral paired with the wrong channel doesn't compile rather than silently never being requested, and the associated Word derives PWIDTH/MWIDTH from the peripheral's own typestate. The shared DmaPeriph<N> supertrait also gates the request line (DENT/DENR, DMATEN/DMAREN, ADC DMA) — raised on start, dropped before the peripheral goes back to its owner — so the drivers themselves know nothing about DMA. remaining() and is_error() inspect a running transfer. Circular mode and M2M are deferred.

TIMER (src/timer.rs) — the counter core, verified on hardware. All seven timers: Timer::new(rcu, timer, clocks) clocks and resets the peripheral and records its own CK_TIMERx through the Instance trait, which binds each timer to its bus, so an APB2 timer can't be given the APB1 frequency. start(psc, car) consumes the stopped Timer and returns a running CountDownTimer, so wait() doesn't exist on a timer that was never started; stop() takes the peripheral back the other way. start writes both dividers, loads them out of their shadow registers with UPG and consumes the update event that raises, so the first wait() measures a full interval. wait() blocks for one rollover and leaves the timer running; cnt() reads the counter in ticks, one tick being psc + 1 cycles of the timer's clock, and car() / psc() read the dividers back from the hardware rather than from a remembered copy. Register access is confined to Instance, so no Deref to a register block is needed. start_interval(5.secs()) takes a fugit duration in whatever scale the caller wrote it in — the scale is a const generic on the method, so millis and micros need no conversion at the call site — and derives the dividers against this timer's own clock, in u64 and saturating. start(psc, car) remains for the raw pair. Timer::new(rcu, timer, clocks) and dp.timer5.constrain(...) through TimerExt are the two entry points, the trait being one blanket impl over Instance. into_delay() moves the timer to a third type, Delay, which promises no interval of its own: delay(interval) takes any fugit scale, sets up the dividers, blocks and stops the counter again, so a period set elsewhere cannot be overwritten by a delay. embedded-hal's DelayNs is implemented on it, with delay_us / delay_ms as the trait's own defaults; resolution is one timer tick. elapsed() converts the counter into a duration of any fugit scale, in u64 and saturating.

into_pwm(psc, car) / into_pwm_interval(interval) move the timer to a fourth type, Pwm, which owns the period and hands out channels. channel(pin) takes a pin and returns a PwmChannel: the channel number comes from the pin through PwmPin<TIMERX, C>, implemented only for the pin and alternate function the silicon routes there, and the channel operations live in PwmOps<C>, implemented only for channels a given timer has — TIMER5 has none, TIMER13 has one. The pin moves into the channel and comes back from release(). A channel carries its own handle to the peripheral, taken through the unsafe Instance::steal(), and touches only its own compare register; several pins reaching one channel yield several channels writing it. enable() / disable(), set_duty(cv) and max_duty() are the inherent layer, embedded-hal's SetDutyCycle sits on top. set_period / set_period_interval change the frequency for every channel at once; duties keep their tick value, not their share. enable_output() exists only on the timers with a CCHP register (TIMER0, TIMER14, TIMER15, TIMER16), whose outputs stay silent until POEN is raised. Complementary outputs, break inputs, dead time, input capture and interrupts are not implemented.

Usage

use gd32e2_hal::gpio::GpioExt;
use gd32e2_hal::pac;
use gd32e2_hal::rcu::{RcuExt, CFGR, PllFreq};
use gd32e2_hal::usart::{Usart, UsartConfig};

let mut dp = pac::Peripherals::take().unwrap();
let mut rcu = dp.rcu.constrain();
let clocks = CFGR::default()
    .sysclk(PllFreq::Mhz48)                  // PLL from IRC8M -> 48 MHz
    .freeze(&mut rcu, &mut dp.fmc);
let parts = dp.gpioa.split(&mut rcu);        // enables the GPIOA clock

let led = parts.pa5.into_output();
led.set_high();

let tx = parts.pa9.into_alternate::<1>();    // USART0_TX; ::<3>() wouldn't compile
let rx = parts.pa10.into_alternate::<1>();
let usart0 = Usart::new(&mut rcu, dp.usart0, tx, rx, clocks, UsartConfig::default());
if let Ok(byte) = usart0.read_byte() {
    usart0.write_byte(byte);                 // verified on hardware: echoes back
}

Constraints

  • PAC-only base, no third-party HAL.
  • Flashing and debugging over SWD (ST-Link V2 + probe-rs, PA13/PA14). Runtime output goes over RTT on the same probe (defmt + defmt-rtt); no USB-serial adapter, and PA9/PA10 are used only by the USART examples.
  • Target thumbv8m.base-none-eabi; flash 64K, RAM 8K.
  • gd32e2 is generated from patched SVDs — verify field names against docs/GD32E23x_User_Manual.pdf (PDFs are kept locally, not committed).

Building

The examples link against a memory.x that is git-ignored, since its lengths depend on the part. Copy the template once, before the first build:

cp memory.x.example memory.x
cargo build --release          # library only
cargo be usart-echo            # compile-check one example, needs no probe
cargo bre usart-echo           # same, release profile

To flash, with an ST-Link on PA13/PA14:

cargo re usart-echo   # build + flash over SWD, then stay attached

re is cargo run --release --example; .cargo/config.toml points the target's runner at probe-rs run --chip GD32E230K8, which flashes the ELF directly (no objcopy, no .bin) and then stays attached, printing the RTT log until Ctrl-C. The log level is fixed at compile time by DEFMT_LOG in .cargo/config.toml. The chip keeps running without the probe, so the freed probe can read any register live:

probe-rs reset --chip GD32E230K8              # run the firmware from reset
probe-rs read  --chip GD32E230K8 b32 0x48000014 1   # e.g. GPIOA_OCTL

The default feature targets the GD32E230K8U6 (x8); for another variant:

cargo build --release --no-default-features --features gd32e230x4

rust-toolchain.toml pins the channel and installs the target. On Windows without Visual Studio the host toolchain has to be GNU, otherwise build scripts fail for want of the MSVC linker: rustup default stable-x86_64-pc-windows-gnu.

Roadmap

  • DMA: circular mode and M2M.
  • Timers: input capture; complementary outputs, break and dead time.
  • I²C.
  • Interrupt-driven operation (NVIC infrastructure — also affects USART/SPI).
  • SPI: half-duplex / single-wire modes (BDEN/BDOEN/RO).
  • SPI: hardware NSS, CRC, TI mode, slave — low priority.
  • USART: hardware flow control (CTS/RTS).
  • RCU: HXTAL (needs an external crystal on the board).
  • GPIO: port F alternate functions (no AFSEL register — needs its own study).
  • Package / pin-count variants — a second axis, independent of x4/x6/x8: which pins are bonded out at all (PC13–PC15, PF6/PF7 exist on QFN48 but not on this QFN32).
  • Trait crates follow their peripherals: i2c::I2c with I²C, embedded-dma for the DMA buffers.
  • Extract the HAL into its own standalone crate/repo (not just examples/ — splitting the library out entirely).

License

Dual-licensed, at your option:

Any contribution submitted for inclusion in this work is licensed the same way, without additional terms, unless stated otherwise (Apache-2.0, section 5).


Русский

HAL для микроконтроллера GD32E230K8U6 (Cortex-M23), написанный на Rust с нуля поверх PAC-крейта gd32e2.

⚠️ Работа в процессе. Пишется вручную и постепенно; API нестабилен. Пакет — библиотека (src/lib.rs → adc, dma, gpio, rcu, spi, time, timer, usart) плюс тестовые бинарники на железо в examples/; все 13 примеров прошиты и проверены на плате — RCU, GPIO, USART (8/9-бит и чётность), SPI0/SPI1, ADC, разовая передача по DMA, TIMER, блокирующие задержки, PWM, RTT.

Принципы

  • Ошибки на компиляции, а не на плате. Идентичность и режим ноги живут в типе: у Pin<'A', 5, Input> нет set_high, неверный номер AF не компилируется, а система владения не даст перенастроить ногу дважды или использовать порт до включения такта.
  • Zero-cost. Те же записи в регистры, что и ручной PAC-код; Pin — ZST.
  • #![no_std], без кучи.

Варианты чипа

Одна и та же нога на одном и том же номере AF ведёт к разной периферии в зависимости от варианта (PA2 AF1 — это USART0_TX у x4 и USART1_TX у x8). Карта AF гейтится тремя взаимоисключающими фичами; ровно одна обязана быть включена, иначе compile_error!, а не молча урезанная карта пинов.

фича сноска datasheet относится к
gd32e230x4 (1) x4
gd32e230x6 (2) x6
gd32e230x8 (дефолт) (2) + (3) x8 — целевой GD32E230K8U6

x8 — надмножество x6; там, где строка помечена только (1) и (3), у x6 функции на этом AF нет вовсе.

Ортогональная фича defmt вешает defmt::Format на публичные энумы и типы ошибок. По умолчанию выключена; заодно включает embedded-hal/defmt-03.

Что уже есть

GPIO (src/gpio.rs) — const-generic Pin<P, N, MODE> (ZST: порт и номер ноги живут в типе). Режимы как typestate: Input, Analog, Output<PushPull> / Output<OpenDrain>, Alternate<AF>, Debugger (PA13/PA14, выход только через unsafe activate()) и Locked<MODE> (терминальный — железо остаётся залоченным до сброса, поэтому unlock нет). dp.gpioa.split(&mut rcu) (GpioExt) включает такт порта и раздаёт пины. Переходы: into_input / into_output / into_push_pull_output / into_open_drain_output / into_analog / into_alternate::<AF>() — номер AF сверяется на компиляции с per-pin картой ValidAf. Плюс set_pull, set_speed, lock() (только порты A/B — у порта F нет регистра LOCK). Состояние ноги — инхерентное и без Result: set_high / set_low / toggle (атомарный TG) / is_set_high / is_set_low у выходов, is_high / is_low у входов и у выходов open-drain. Те же пины реализуют embedded-hal 1.0 OutputPin / InputPin / StatefulOutputPin, возвращающие Result<_, Infallible>; оба слоя зовут одни и те же приватные регистровые хелперы. Порты A, B и F; порт C на этом корпусе не разведён.

RCU (src/rcu.rs) — dp.rcu.constrain() (RcuExt). Трейты Enable и Reset на каждую периферию, генерируются макросами bus_en!/bus_rst! (разнесены порознь, потому что не у каждой периферии есть бит сброса — у DMA его нет) и вплетены в split(), так что порт нельзя использовать без такта. Дерево тактов: билдер CFGR → .freeze(&mut rcu, &mut dp.fmc) → замороженный Clocks. PLL от IRC8M (PllFreq, 8–72 МГц) и прескейлеры шин (AhbPsc / ApbPsc) — типизированные энумы, поэтому недостижимая частота даёт ошибку компиляции, а не тихое округление; wait state'ы flash выставляются от получившегося hclk до переключения источника. Usart0Sel и AdcSel / AdcPsc оседают в Clocks для модулей USART и ADC. rcu.ck_out(src, div) выводит внутренний тактовый узел на PA8/PA9 (CkOutSrc / CkOutDiv), так что реальную частоту можно измерить на ноге, а не принимать на веру. pclk1_tim / pclk2_tim несут тактовую ветку таймеров: hclk при неделённой APB, удвоенная частота шины во всех остальных случаях. Типизированные частоты — в src/time.rs, псевдонимы над fugit (Hertz), сам крейт реэкспортируется. HXTAL вне скоупа — кварц на плате не запаян.

USART (src/usart.rs) — Usart<USARTX, TX, RX, WORD = Byte> владеет периферией и обоими пинами. Маркеры TxPin / RxPin (заполняются usart_pins!) отсекают неверный пин или AF на компиляции; BusClocks выбирает правильную частоту шины под конкретный инстанс, так что расчёт baud не может взять не ту. UsartConfig (fluent, Default = 115200 / ×16 / N8) несёт baud (именованные константы в usart::baud), Oversampling::{X16, X8} и FrameFormat::{N8, E8, O8, E7, O7} (единственный источник правды для WL/PCEN/PM). Блокирующий инхерентный API на ширине Byte: write_byte / write_bytes / read_byte / read_bytes плюс flush (ждёт TC, а не TBE). У передачи нет условий ошибки, поэтому передающая половина не возвращает Result. read_bytes блокируется до первого байта, дальше забирает только уже пришедшее. read_ready / write_ready опрашивают RBNE / TBE без блокировки. Трейтовые слои на той же ширине: embedded-hal-nb Read<u8> / Write<u8> и embedded-io Read / Write / ReadReady / WriteReady; второй крейт даёт read_exact / write_all / write_fmt дефолтами трейта. flush, read_ready и write_ready есть в обоих слоях — точечную запись выигрывает инхерентный, трейтовый доступен по пути. Чистый 9-битный режим — new_word / write_word / write_words / read_word / read_words на typestate Word (UsartConfig9). Ошибки приёма — usart::Error (Overrun / Noise / Framing / Parity), сбрасываются через USART_INTC; переносимые ErrorKind дают impl'ы serial::Error и embedded_io::Error. release() возвращает периферию и оба пина.

ADC (src/adc.rs) — Adc::new(rcu, adc, clocks), либо dp.adc.constrain(...) через AdcExt, выполняет процедуру калибровки из мануала (ADCON, задержка 14 тактов CK_ADC в пересчёте на такты ядра, RSTCLB/CLB). read<PIN: Channel>(&pin, SampTime) -> u16 — одиночное блокирующее преобразование по софт-триггеру. Channel реализован только для Pin<P, N, Analog>, поэтому пин обязан реально пройти через into_analog(). Внутренние каналы: read_vref() -> i32 возвращает реальное VDDA в мВ (вычисляется по заводскому VREFINT_CAL из flash, а если эта калибровка пустая — по типовому VREFINT ≈ 1.2 В), а read_temperature() -> Option<i32> — десятые доли °C; None, когда CK_ADC слишком высока для минимального времени сэмплирования датчика. Scan-режим требует DMA и отложен.

SPI (src/spi.rs) — SPI0 и SPI1: master, full-duplex, блокирующий, 8 или 16 бит, программный NSS. Spi::new(...) / Spi::new_word(...) принимают SpiConfig (SpiConfig::new(psc) — psc обязателен, Default нет, поскольку у делителя SCK нет универсального значения; .mode() и .bit_order() переопределяют дефолты Mode 0 / MSB-first). Ширина слова — typestate: transfer_word и SpiBus<u16> не существуют на Spi<..., Byte>, а transfer_byte и SpiBus<u8> — на Spi<..., Word>. BitOrder — обычное рантайм-значение: он меняет сериализацию на проводе, но не сигнатуры методов. Трейт Instance абстрагирует периферию на уровне операций, поэтому один generic Spi<> обслуживает оба инстанса, несмотря на разные типы RegisterBlock и расхождение на уровне битов (FF16 в CTL0 у SPI0 против DZ в CTL1 у SPI1, BYTEN выводится из ширины). Буферные операции инхерентные — transfer_bytes / transfer_bytes_in_place / read_bytes / write_bytes и те же в _words-варианте; impl'ы SpiBus делегируют к ним. Ошибки — spi::Error (Overrun / ModeFault / Crc / Framing); ErrorKind даёт impl трейта spi::Error, где Crc ложится на Other за отсутствием варианта у апстрима. release() возвращает периферию и пины. Аппаратный NSS и CRC сознательно не реализованы.

DMA (src/dma.rs) — разовые передачи, проверены на железе. dp.dma.split(&mut rcu) (DmaExt) раздаёт Channel<0>…Channel<4> — каждый уникальный ZST-токен, поэтому один канал не может вести две передачи сразу. write_to / read_from забирают по значению канал, периферию и буфер и отдают Transfer; вернуть все три можно только через wait() — именно это делает невозможным чтение буфера, который железо ещё заполняет. Буферы — &'static [W] / &'static mut [W]. DmaSrc<N> / DmaDst<N> кодируют карту запросов из datasheet (Table 8-3), поэтому периферия в паре с неверным каналом не компилируется, а не простаивает молча, а ассоциированный Word выводит PWIDTH/MWIDTH из typestate самой периферии. Общий супертрейт DmaPeriph<N> заодно держит линию запроса (DENT/DENR, DMATEN/DMAREN, ADC DMA) — поднимает на старте и гасит до возврата периферии владельцу, — так что сами драйверы про DMA ничего не знают. remaining() и is_error() показывают состояние идущей передачи. Циклический режим и M2M отложены.

TIMER (src/timer.rs) — ядро счётчика, проверено на железе. Все семь таймеров: Timer::new(rcu, timer, clocks) тактирует и сбрасывает периферию и запоминает свой CK_TIMERx через трейт Instance, который привязывает каждый таймер к его шине, поэтому таймеру с APB2 нельзя подсунуть частоту APB1. start(psc, car) забирает остановленный Timer и отдаёт запущенный CountDownTimer, поэтому wait() не существует у незапущенного таймера; stop() возвращает периферию обратно. start пишет оба делителя, загружает их из теневых регистров через UPG и гасит порождённое им событие обновления, так что первый wait() отсчитывает полный интервал. wait() блокирует до одного переполнения и оставляет таймер бежать; cnt() читает счётчик в тиках, один тик — psc + 1 тактов такта таймера, car() и psc() читают делители из железа, а не из запомненной копии. Доступ к регистрам заперт в Instance, поэтому Deref до регистрового блока не нужен. start_interval(5.secs()) принимает длительность fugit в той шкале, в которой её написал вызывающий — шкала приезжает const-генериком метода, поэтому millis и micros не требуют конверсии на месте вызова, — и выводит делители от собственного такта таймера, в u64 и с насыщением. start(psc, car) остаётся для сырой пары. Точек входа две — Timer::new(rcu, timer, clocks) и dp.timer5.constrain(...) через TimerExt, который сделан одним blanket-impl'ом поверх Instance. into_delay() переводит таймер в третий тип, Delay, который не обещает никакого своего интервала: delay(interval) принимает любую шкалу fugit, настраивает делители, блокирует и снова останавливает счётчик, поэтому задержкой нельзя затереть период, заданный где-то ещё. На нём реализован DelayNs из embedded-hal, delay_us / delay_ms приходят дефолтами трейта; разрешение — один тик таймера. elapsed() переводит счётчик в длительность любой шкалы fugit, в u64 и с насыщением.

into_pwm(psc, car) / into_pwm_interval(interval) переводят таймер в четвёртый тип, Pwm, который владеет периодом и раздаёт каналы. channel(pin) принимает ногу и возвращает PwmChannel: номер канала приезжает из ноги через PwmPin<TIMERX, C>, реализованный только для той ноги и того номера AF, куда канал ведёт в железе, а операции над каналом живут в PwmOps<C>, реализованном только для каналов, которые у таймера есть — у TIMER5 их нет, у TIMER13 один. Нога уезжает в канал и возвращается из release(). Канал держит собственный хэндл периферии, взятый через unsafe Instance::steal(), и трогает только свой регистр сравнения; несколько ног, ведущих на один канал, дают несколько каналов, пишущих в него. enable() / disable(), set_duty(cv) и max_duty() — это инхерентный слой, сверху лежит SetDutyCycle из embedded-hal. set_period / set_period_interval меняют частоту сразу всем каналам; скважность сохраняется в тиках, а не в долях. enable_output() существует только у таймеров с регистром CCHP (TIMER0, TIMER14, TIMER15, TIMER16), выходы которых молчат, пока не поднят POEN. Комплементарные выходы, break, dead time, input capture и прерывания не реализованы.

Пример

use gd32e2_hal::gpio::GpioExt;
use gd32e2_hal::pac;
use gd32e2_hal::rcu::{RcuExt, CFGR, PllFreq};
use gd32e2_hal::usart::{Usart, UsartConfig};

let mut dp = pac::Peripherals::take().unwrap();
let mut rcu = dp.rcu.constrain();
let clocks = CFGR::default()
    .sysclk(PllFreq::Mhz48)                  // PLL от IRC8M -> 48 МГц
    .freeze(&mut rcu, &mut dp.fmc);
let parts = dp.gpioa.split(&mut rcu);        // включает такт GPIOA

let led = parts.pa5.into_output();
led.set_high();

let tx = parts.pa9.into_alternate::<1>();    // USART0_TX; ::<3>() не скомпилируется
let rx = parts.pa10.into_alternate::<1>();
let usart0 = Usart::new(&mut rcu, dp.usart0, tx, rx, clocks, UsartConfig::default());
if let Ok(byte) = usart0.read_byte() {
    usart0.write_byte(byte);                 // проверено на железе: приходит эхом
}

Ограничения

  • PAC-only база, без сторонних HAL.
  • Прошивка и отладка по SWD (ST-Link V2 + probe-rs, PA13/PA14). Вывод из прошивки идёт по RTT через тот же зонд (defmt + defmt-rtt); USB-переходник не нужен, PA9/PA10 заняты только в примерах на USART.
  • Target thumbv8m.base-none-eabi; флеш 64K, ОЗУ 8K.
  • gd32e2 сгенерирован из патченных SVD — имена полей сверять по docs/GD32E23x_User_Manual.pdf (PDF лежат локально, в git не входят).

Сборка

Примеры линкуются с memory.x; он в .gitignore, потому что размеры зависят от варианта чипа. Один раз перед первой сборкой скопировать шаблон:

cp memory.x.example memory.x
cargo build --release          # только библиотека
cargo be usart-echo            # проверить сборку одного примера, зонд не нужен
cargo bre usart-echo           # то же самое, release

Чтобы прошить, с ST-Link на PA13/PA14:

cargo re usart-echo   # сборка + прошивка по SWD, дальше остаётся подключённым

re — это cargo run --release --example; в .cargo/config.toml у цели прописан runner = "probe-rs run --chip GD32E230K8", который заливает ELF напрямую (без objcopy и без .bin), а дальше остаётся подключённым и печатает RTT-лог до Ctrl-C. Уровень лога фиксируется на компиляции переменной DEFMT_LOG из того же .cargo/config.toml. Чип работает и без зонда, а освободившийся зонд читает любой регистр вживую:

probe-rs reset --chip GD32E230K8              # запустить прошивку со сброса
probe-rs read  --chip GD32E230K8 b32 0x48000014 1   # например, GPIOA_OCTL

Дефолтная фича собирает под GD32E230K8U6 (x8); для другого варианта:

cargo build --release --no-default-features --features gd32e230x4

rust-toolchain.toml фиксирует канал и ставит нужный таргет. На Windows без Visual Studio host-toolchain должен быть GNU, иначе build-скрипты падают без MSVC-линкера: rustup default stable-x86_64-pc-windows-gnu.

Roadmap

  • DMA: циклический режим и M2M.
  • Таймеры: input capture; комплементарные выходы, break, dead time.
  • I²C.
  • Работа на прерываниях (инфраструктура NVIC — затронет и USART/SPI).
  • SPI: half-duplex / однопроводные режимы (BDEN/BDOEN/RO).
  • SPI: аппаратный NSS, CRC, TI mode, slave — низкий приоритет.
  • USART: аппаратное управление потоком (CTS/RTS).
  • RCU: HXTAL (нужен внешний кварц на плате).
  • GPIO: альтернативные функции порта F (регистра AFSEL нет — нужен отдельный разбор).
  • Варианты корпуса / числа ног — вторая ось, независимая от x4/x6/x8: какие ноги вообще разварены (PC13–PC15, PF6/PF7 есть на QFN48, но не на нашем QFN32).
  • Трейт-крейты идут за своей периферией: i2c::I2c с I²C, embedded-dma для буферов DMA.
  • Вынос HAL в полностью отдельный крейт/репозиторий (не просто examples/ — разделение самой библиотеки).

Лицензия

Двойное лицензирование, на выбор пользователя:

Вклад, присланный в проект, лицензируется так же, без дополнительных условий, если явно не оговорено иное (Apache-2.0, раздел 5).