Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ jobs:
run: cd ch32 && cargo +nightly clippy --no-default-features --features ${{ matrix.chip }} --target ../examples/ch32/v003/riscv32ec-unknown-none-elf.json -Zbuild-std=core -Zjson-target-spec -- -D warnings
- name: Build libraries
run: cd ch32 && cargo +nightly build --no-default-features --features ${{ matrix.chip }} --target ../examples/ch32/v003/riscv32ec-unknown-none-elf.json -Zbuild-std=core -Zjson-target-spec --release
- name: Clippy libraries (half-duplex)
run: cd ch32 && cargo +nightly clippy --no-default-features --features ${{ matrix.chip }},half-duplex --target ../examples/ch32/v003/riscv32ec-unknown-none-elf.json -Zbuild-std=core -Zjson-target-spec -- -D warnings
- name: Doc libraries
run: cd ch32 && cargo +nightly doc --no-default-features --features ${{ matrix.chip }} --target ../examples/ch32/v003/riscv32ec-unknown-none-elf.json -Zbuild-std=core -Zjson-target-spec --no-deps
- name: Fmt examples
Expand Down Expand Up @@ -95,6 +97,8 @@ jobs:
run: cd ch32 && cargo clippy --no-default-features --features ${{ matrix.chip }} --target riscv32imc-unknown-none-elf -- -D warnings
- name: Build libraries
run: cd ch32 && cargo build --no-default-features --features ${{ matrix.chip }} --target riscv32imc-unknown-none-elf --release
- name: Clippy libraries (half-duplex)
run: cd ch32 && cargo clippy --no-default-features --features ${{ matrix.chip }},half-duplex --target riscv32imc-unknown-none-elf -- -D warnings
- name: Fmt examples
run: cd examples/ch32/v103 && cargo fmt --all -- --check
- name: Clippy boot (system-flash)
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ Releases up through `v0.4.0` were workspace-wide and tagged `vX.Y.Z`. From `0.4.

## [Unreleased]

### Changed

- **Breaking:** `tinyboot-ch32`: duplex selection moved from runtime config to compile time. The `Duplex` enum and `UsartConfig::duplex` are gone; enable the new `half-duplex` cargo feature for single-wire operation (full duplex is the default, and `rx_pull` only exists without the feature). Half-duplex now uses a shared-bus drive discipline: the wire parks open-drain while idle and drives push-pull only around writes, so an idle node never contends with the wire's other talkers — **an external pull-up on the wire is now a hardware requirement** (it owns the idle mark level). Costs ~130 bytes over full duplex; full-duplex binaries are byte-identical to before.

### Fixed

- `tinyboot-ch32`: half-duplex raises HDSEL before TE — the reverse order latched the TX output LOW until the first own transmission, clamping the wire from init (measured on CH32V006).

## [tinyboot-ch32-rt 0.4.1] - 2026-05-08

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions ch32/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ ch32v103r8t6 = ["ch32-metapac/ch32v103r8t6"]

system-flash = []

# Single wire (HDSEL): open-drain idle park, push-pull around writes.
# Needs a pull-up on the wire — see docs/transports.md.
half-duplex = []

[workspace]
members = [".", "rt"]
default-members = [".", "rt"]
Expand Down
3 changes: 1 addition & 2 deletions ch32/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ tinyboot-ch32-rt = "0.4" # optional, bootloader-only; on crates.io

| Module | For | What it provides |
| ---------- | -------------------- | ------------------------------------------------------------------------------------------------ |
| `boot` | Bootloader binaries | `run()`, `BootCtl`, USART transport (`Usart`, `UsartConfig`, `BaudRate`, `Duplex`, `TxEnConfig`) |
| `boot` | Bootloader binaries | `run()`, `BootCtl`, USART transport (`Usart`, `UsartConfig`, `BaudRate`, `TxEnConfig`) |
| `app` | Application binaries | `new_app()`, `App`, `BootCtl`, the `tinyboot_core::app` types |
| `hal` | Both | `flash`, `gpio`, `usart`, `afio`, `rcc`, `pfic`, `iwdg`; auto-generated `Pin` and `UsartMapping` |
| `platform` | (internal) | `tinyboot_core::traits` impls for Storage, Transport, BootCtl, BootMetaStore |
Expand All @@ -38,7 +38,6 @@ use tinyboot_ch32::boot::prelude::*;
#[unsafe(export_name = "main")]
fn main() -> ! {
let transport = Usart::new(&UsartConfig {
duplex: Duplex::Full,
baud: BaudRate::B115200,
pclk: 8_000_000,
mapping: UsartMapping::Usart1Remap0,
Expand Down
4 changes: 2 additions & 2 deletions ch32/src/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use crate::platform::{BootMetaStore, Storage};

pub use crate::platform::{BaudRate, BootCtl, Duplex, TxEnConfig, Usart, UsartConfig};
pub use crate::platform::{BaudRate, BootCtl, TxEnConfig, Usart, UsartConfig};

pub use crate::hal::gpio::{Level, Pull};
pub use crate::hal::{Pin, UsartMapping};
Expand All @@ -12,7 +12,7 @@ pub use tinyboot_core::{boot_version, pkg_version};
/// Common imports for bootloader binaries.
pub mod prelude {
pub use super::{
BaudRate, BootCtl, Duplex, Level, Pin, Pull, TxEnConfig, Usart, UsartConfig, UsartMapping,
BaudRate, BootCtl, Level, Pin, Pull, TxEnConfig, Usart, UsartConfig, UsartMapping,
};
}

Expand Down
10 changes: 6 additions & 4 deletions ch32/src/hal/usart/usart_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ pub use ch32_metapac::usart::Usart as Regs;

#[inline(always)]
pub fn init(r: Regs, pclk: u32, baud: u32, half_duplex: bool) {
// HDSEL before TE — the reverse order latches the TX output LOW until
// the first own transmission, clamping the wire from init.
if half_duplex {
r.ctlr3().write(|w| w.set_hdsel(true));
}

// CTLR1 default (M=0, PCE=0, STOP=00) gives 8N1; just set TE+RE.
r.ctlr1().write(|w| {
w.set_te(true);
w.set_re(true);
});

if half_duplex {
r.ctlr3().write(|w| w.set_hdsel(true));
}

let brr = (pclk + baud / 2) / baud;
r.brr().write_value(ch32_metapac::usart::regs::Brr(brr));

Expand Down
2 changes: 1 addition & 1 deletion ch32/src/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ pub use crate::hal::{Pin, UsartMapping};
pub use boot_ctl::BootCtl;
pub use boot_meta_store::BootMetaStore;
pub use storage::Storage;
pub use transport::usart::{BaudRate, Duplex, TxEnConfig, Usart, UsartConfig};
pub use transport::usart::{BaudRate, TxEnConfig, Usart, UsartConfig};
40 changes: 27 additions & 13 deletions ch32/src/platform/transport/usart/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,12 @@ use core::convert::Infallible;

use embedded_io::ErrorType;

use crate::hal::gpio::{PinMode, Pull};
use crate::hal::gpio::PinMode;
#[cfg(not(feature = "half-duplex"))]
use crate::hal::gpio::Pull;
use crate::hal::{Pin, UsartMapping};
use crate::hal::{afio, gpio, rcc, usart};

pub enum Duplex {
/// Half-duplex (single wire, RS-485).
Half,
/// Full-duplex (separate TX/RX).
Full,
}

#[derive(Copy, Clone)]
#[repr(u32)]
pub enum BaudRate {
Expand Down Expand Up @@ -41,7 +36,6 @@ pub struct TxEnConfig {
}

pub struct UsartConfig {
pub duplex: Duplex,
pub baud: BaudRate,
/// USART peripheral clock (Hz). Used for BRR = pclk / baud.
///
Expand All @@ -51,6 +45,8 @@ pub struct UsartConfig {
/// X0xx: HSI 48MHz / HPRE 1 = 48_000_000
pub pclk: u32,
pub mapping: UsartMapping,
/// RX pin bias (full duplex only).
#[cfg(not(feature = "half-duplex"))]
pub rx_pull: Pull,
/// Optional RS-485 DE/RE pin.
pub tx_en: Option<TxEnConfig>,
Expand All @@ -59,6 +55,9 @@ pub struct UsartConfig {
pub struct Usart {
regs: crate::hal::usart::Regs,
tx_en: Option<TxEnConfig>,
/// The single wire: push-pull while driving, open-drain parked.
#[cfg(feature = "half-duplex")]
tx_pin: Pin,
}

impl tinyboot_core::traits::Transport for Usart {}
Expand All @@ -71,7 +70,6 @@ impl Usart {
let tx_pin = config.mapping.tx_pin();
let rx_pin = config.mapping.rx_pin();
let regs = config.mapping.regs();
let half_duplex = matches!(config.duplex, Duplex::Half);

let usart_n = config.mapping.peripheral_index();

Expand All @@ -89,8 +87,13 @@ impl Usart {

afio::set_usart_remap(usart_n, config.mapping.remap_value());

gpio::configure(tx_pin, PinMode::AF_PUSH_PULL);
if !half_duplex {
// Park the single wire open-drain: RX hears through the pin, the
// bus pull-up holds mark; writes flip to push-pull.
#[cfg(feature = "half-duplex")]
gpio::configure(tx_pin, PinMode::AF_OPEN_DRAIN);
#[cfg(not(feature = "half-duplex"))]
{
gpio::configure(tx_pin, PinMode::AF_PUSH_PULL);
gpio::configure(rx_pin, PinMode::input_pull(config.rx_pull));
}

Expand All @@ -101,23 +104,34 @@ impl Usart {
gpio::set_level(tx_en.pin, invert(tx_en.tx_level));
}

usart::init(regs, config.pclk, config.baud as u32, half_duplex);
usart::init(
regs,
config.pclk,
config.baud as u32,
cfg!(feature = "half-duplex"),
);

Usart {
regs,
tx_en: config.tx_en,
#[cfg(feature = "half-duplex")]
tx_pin,
}
}

#[inline(always)]
fn set_tx_mode(&self) {
#[cfg(feature = "half-duplex")]
gpio::configure(self.tx_pin, PinMode::AF_PUSH_PULL);
if let Some(ref tx_en) = self.tx_en {
gpio::set_level(tx_en.pin, tx_en.tx_level);
}
}

#[inline(always)]
fn set_rx_mode(&self) {
#[cfg(feature = "half-duplex")]
gpio::configure(self.tx_pin, PinMode::AF_OPEN_DRAIN);
if let Some(ref tx_en) = self.tx_en {
gpio::set_level(tx_en.pin, invert(tx_en.tx_level));
}
Expand Down
2 changes: 1 addition & 1 deletion docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ The example workspaces are built across a CI matrix: multiple chip variants × s
1. Copy the example that matches your chip (e.g. `examples/ch32/v003/`) to a new directory.
2. In the `boot/Cargo.toml`, remove the extra variant features you don't need. Leave one, set as the default.
3. Pick a flash mode. Delete the `memory_x/` file you don't need, and simplify `build.rs` to just copy the remaining one.
4. In `src/main.rs`, change the UART config (pins, baud, duplex, tx_en) to match your board.
4. In `src/main.rs`, change the UART config (pins, baud, tx_en — and the `half-duplex` cargo feature for single-wire boards) to match your board.
5. Do the same for `app/` — match the UART config, adjust your pins.

That gives you a minimal, single-purpose workspace with none of the CI scaffolding.
Expand Down
39 changes: 24 additions & 15 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,54 @@

The tinyboot protocol runs over any `embedded_io::Read + Write` stream. The CH32 implementation ships a USART transport configured via two **independent** axes:

- **`duplex`** — controls the **MCU's** pin arrangement.
- `Full` — separate RX and TX pins.
- `Half` — RX is muxed onto the TX pin; the MCU uses a single wire.
- **`tx_en`** — optional direction pin for an **external** buffer (RS-485 transceiver, etc.). Independent of `duplex`. Driven to the configured `tx_level` around writes, to the inverse while idle / reading.
- **the `half-duplex` cargo feature** — controls the **MCU's** pin arrangement, at compile time (a bootloader binary serves one board, so there is no runtime switch to pay for).
- off (default) — full duplex: separate RX and TX pins.
- on — RX is muxed onto the TX pin (HDSEL); the MCU uses a single wire, parked open-drain while idle.
- **`tx_en`** — optional direction pin for an **external** buffer (RS-485 transceiver, etc.). Independent of the duplex mode. Driven to the configured `tx_level` around writes, to the inverse while idle / reading.

Combining these gives four useful setups. Pick whichever matches your board.

## Setup 1: full-duplex UART (two wires)

Regular UART — separate TX and RX to the host.
Regular UART — separate TX and RX to the host. This is the default (no feature).

```rust
Usart::new(&UsartConfig {
duplex: Duplex::Full,
tx_en: None,
..
});
```

`rx_pull: Pull::Up` if the RX line can float when the host is disconnected; `Pull::None` if an external pull-up is already present.

## Setup 2: single-wire UART (MCU-level half duplex)
## Setup 2: single-wire UART (the `half-duplex` feature)

The MCU muxes RX onto the TX pin — one wire to the host, no external buffer. Useful when both ends speak half duplex directly (some probes, some DXL servo chains where the MCU is the sole driver on its segment).
The MCU muxes RX onto the TX pin — one wire to the host, no external buffer. Useful when both ends speak half duplex directly: probes, DXL-style servo chains, any shared single-wire bus.

```toml
tinyboot-ch32 = { version = "...", features = ["<your-chip>", "half-duplex"] }
```

```rust
Usart::new(&UsartConfig {
duplex: Duplex::Half,
// no rx_pull: the mapping's RX pin is unused in half duplex
tx_en: None,
..
});
```

**Hardware requirement: the wire needs an external pull-up.** The driver parks the pin **open-drain** while idle or listening and flips it to push-pull only around its own writes, so between frames nothing holds the line at mark except the pull-up. Without one the wire floats and the receiver sees noise and framing errors — garbage bytes the protocol has to sync past. 4.7–10 kΩ to the bus voltage is typical; go stiffer for high baud rates or long, capacitive buses.

The open-drain park is what makes the single wire **shareable**: an idle node never drives the line, so several half-duplex devices (and the host) can sit on one bus without contention. The driver also raises HDSEL before enabling the transmitter — the reverse order latches the TX output low until the first own transmission, which would clamp the whole bus from init to the first reply.

The feature costs ~130 bytes of flash over full duplex, which is why it's a compile-time switch: a full-duplex boot pays nothing (and on the CH32V003's 1920-byte system flash, has nothing to spare).

## Setup 3: full-duplex UART + external half-duplex buffer (RS-485 / DXL TTL)

This is the OpenServoCore hardware style: the MCU runs regular full-duplex UART to a hardware transceiver (MAX485, 74LVC2G241, etc.), and `tx_en` drives the transceiver's direction pin so its output stage only drives the bus while the MCU is transmitting.
The MCU runs regular full-duplex UART to a hardware transceiver (MAX485, 74LVC2G241, etc.), and `tx_en` drives the transceiver's direction pin so its output stage only drives the bus while the MCU is transmitting.

```rust
Usart::new(&UsartConfig {
duplex: Duplex::Full,
tx_en: Some(TxEnConfig {
pin: Pin::PC2,
tx_level: Level::High, // level that puts the transceiver in TX mode
Expand All @@ -57,16 +65,17 @@ Usart::new(&UsartConfig {

## Setup 4: single-wire UART + external buffer

MCU half-duplex (muxed RX/TX) **and** a direction-controlled external buffer. Valid if your board puts a buffer in front of the MCU's single wire and still needs direction switching.
MCU half-duplex (muxed RX/TX, the `half-duplex` feature) **and** a direction-controlled external buffer. Valid if your board puts a non-auto-direction buffer or level shifter in front of the MCU's single wire — the one pin carries both directions, and `tx_en` tells the buffer which way to point.

```rust
Usart::new(&UsartConfig {
duplex: Duplex::Half,
tx_en: Some(TxEnConfig { pin: Pin::PC2, tx_level: Level::High }),
..
});
```

The open-drain idle park applies in this setup too — it follows the `half-duplex` feature, independent of `tx_en` — so the stub between the pin and the buffer input needs a pull-up (or a transceiver with internal biasing) to keep the buffer's input at mark while the MCU listens.

## What `tx_en` actually does

When configured, the driver toggles the direction pin around every frame:
Expand All @@ -90,7 +99,7 @@ On a single-wire bus where the host's TX and RX are also tied to the data line

`UsartMapping` picks the AFIO remap and selects which physical pins carry TX / RX. Available mappings are codegen'd per chip — check the generated `UsartMapping` enum in `tinyboot-ch32`, and cross-reference against the USART / AFIO sections of your chip's datasheet for the pin assignments.

In `Duplex::Half`, only the TX pin is used; the RX pin of the mapping is unused.
With the `half-duplex` feature, only the TX pin is used; the RX pin of the mapping is unused (and `UsartConfig` has no `rx_pull` field).

## Matching the app side

Expand All @@ -99,7 +108,7 @@ The app's USART configuration must match the bootloader's:
- Same USART instance (e.g. USART1).
- Same pins / remap.
- Same baud rate.
- Same `duplex` mode.
- Same wire arrangement (the bootloader's `half-duplex` feature ↔ the app's single-wire vs two-wire UART setup).
- Same `tx_en` pin and `tx_level` (if used).

If any of these differ, the app can still run — but it won't be able to receive `Reset` or `Info` over the bus, so remote bootloader entry won't work. See the [app integration guide](app-integration.md) for the app-side wiring.
Expand Down
1 change: 1 addition & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Something in the UART chain isn't right. Work through these in order:
- **`rx_pull`** — floating RX lines need `Pull::Up`; externally pulled-up lines should use `Pull::None`.
- **RS-485 / DXL TTL** — a DE/RE pin must be configured via `TxEnConfig`, with `tx_level` matching the transceiver's DE polarity.
- **Half-duplex contention** — on some boards the programmer's TX driver and the MCU's TX driver both reach MCU_RX. Flipping `tx_level` (so the MCU's side is tri-stated while idle) often resolves it. See the [transports guide](transports.md).
- **Half-duplex missing pull-up** — the `half-duplex` feature parks the pin open-drain while idle, so the wire needs an external pull-up to hold mark between frames. A floating wire reads as noise and framing errors. See the [transports guide](transports.md).

---

Expand Down
6 changes: 2 additions & 4 deletions examples/ch32/v003/boot/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,9 @@ fn main() -> ! {
//
// rx_pull: Pull::Up for floating lines; Pull::None if externally pulled up.
//
// RS-485 half-duplex with DE pin:
// duplex: Duplex::Half,
// tx_en: Some(TxEnConfig { pin: Pin::PC2, tx_level: Level::High }),
// Single-wire half duplex: enable tinyboot-ch32's `half-duplex`
// feature (drops `rx_pull`; the wire needs a pull-up).
let transport = Usart::new(&UsartConfig {
duplex: Duplex::Full,
baud: BaudRate::B115200,
pclk: 8_000_000,
mapping: UsartMapping::Usart1Remap0,
Expand Down
6 changes: 2 additions & 4 deletions examples/ch32/v00x/boot/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,9 @@ fn main() -> ! {
//
// rx_pull: Pull::Up for floating lines; Pull::None if externally pulled up.
//
// RS-485 half-duplex with DE pin:
// duplex: Duplex::Half,
// tx_en: Some(TxEnConfig { pin: Pin::PC2, tx_level: Level::High }),
// Single-wire half duplex: enable tinyboot-ch32's `half-duplex`
// feature (drops `rx_pull`; the wire needs a pull-up).
let transport = Usart::new(&UsartConfig {
duplex: Duplex::Full,
baud: BaudRate::B3000000,
pclk: 48_000_000,
mapping: UsartMapping::Usart1Remap3,
Expand Down
1 change: 0 additions & 1 deletion examples/ch32/v103/boot/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ fn main() -> ! {
// Remap0 (default): TX=PA9, RX=PA10
// Remap1: TX=PB6, RX=PB7
let transport = Usart::new(&UsartConfig {
duplex: Duplex::Full,
baud: BaudRate::B115200,
pclk: 8_000_000,
mapping: UsartMapping::Usart1Remap0,
Expand Down