Files
ch32-bms/firmware/HARDWARE.md
T

10 KiB
Raw Blame History

Hardware implementor's notes

Reference for anyone touching firmware/src/*.rs: what's wired to what, which peripherals/pins are used and why, and the non-obvious dependency-version constraints this firmware relies on. Written against the real, currently maintained schematic at /home/empire/workspace/PlantCtrl/Hardware/open-bms/bms/bms.kicad_sch.

The KiCad project checked into this repo (board/bms/) is a stale, older board revision. Its component references and pin assignments do not match the hardware this firmware actually targets — do not use it as a source of truth. Re-derive facts from the real schematic (e.g. via kicad-cli sch export netlist) if anything below is in doubt.

Target chip: CH32V203C8T6 (RISC-V, riscv32imc-unknown-none-elf), no external crystal populated — clocked from HSI only (see RCC note below).

Pin map

Pin Function Notes
PA8 D7 LED Active-high, 2.2kΩ (R13) to GND on the cathode side
PB12 D3 LED Active-high, 2.2kΩ (R14) to GND
PB13 D4 LED Active-high, 2.2kΩ (R15) to GND
PB14 D5 LED Active-high, 2.2kΩ (R16) to GND
PB15 D6 LED Active-high, 2.2kΩ (R17) to GND
PA11 USB D Fixed-function USB pin on this chip, not remappable
PA12 USB D+ Fixed-function USB pin on this chip, not remappable
PA4 SPI1 NSS (flash CS) Driven manually as a GPIO Outputch32-hal's Spi has no CS management. Idle-high, matches the board's 10kΩ pull-up (R7)
PA5 SPI1 SCK Default/non-remapped SPI1 pin
PA6 SPI1 MISO Default/non-remapped SPI1 pin
PA7 SPI1 MOSI Default/non-remapped SPI1 pin
PB9 INA219 SDA Bit-banged, not hardware I2C — see gotcha below
PB10 INA219 SCL Bit-banged, not hardware I2C — see gotcha below
PB2 Spare GPIO input Pulled low via 10kΩ (R8), nothing else attached. Only real "input" available on this board
PB6 I2C1 SDA (S_SDA) Battery-pack-facing bus (XT30 connector pins 3/4, BAV99 protection diodes). Currently unused — see I2C1 note below
PB7 I2C1 SCL (S_SCL) Battery-pack-facing bus. Currently unused — see I2C1 note below

Not wired to anything: PA0-3, PA9-10, PA15, PB0-1, PB3-5, PB8, PB11, PC13-15. Boot1/Reset1 are the BOOT0 strap and NRST reset buttons respectively — not runtime-readable GPIOs, don't try to poll them as inputs.

Peripherals in use

  • USBD — USB CDC-ACM virtual serial console. hal::usbd::Driver::new(p.USBD, Irqs, p.PA12 /*dp*/, p.PA11 /*dm*/). Interrupt vector is shared with CAN1: bind_interrupts! needs USB_LP_CAN1_RX0 => hal::usbd::InterruptHandler<hal::peripherals::USBD>. Max packet size is 64 bytes — write_packet() on a single call fails (EndpointError::BufferOverflow, does not panic) for anything longer; chunk before sending (see usb_writer in main.rs).
  • SPI1 — blocking, talks to the W25Q128JVE NOR flash (U5). hal::spi::Spi::new_blocking::<0>(p.SPI1, sck, mosi, miso, config) — note the argument order is (sck, mosi, miso), not alphabetical. No hardware CS support in this driver; drive it yourself.
  • I2C1 — hardware peripheral, PB6/PB7 (remap 0). Intended for the battery-pack-facing slave bus (device acts as an I2C slave, address 0x55 in the existing but currently-commented-out code). Not currently enabledI2c::listen_blocking() is a genuine busy-spin with no .await, which starves every other embassy task (USB included) on this single-threaded executor the instant it's called. Don't re-enable it without either moving it off the main task's critical path or replacing it with a non-blocking variant.
  • I2C2 — hardware peripheral, fixed to PB10 (SCL) / PB11 (SDA), no remap. Do not use for the INA219. PB11 is completely unconnected on this board. See the bit-bang gotcha below.
  • TIM2 — claimed by ch32-hal's time-driver-tim2 feature as the embassy time driver's tick source. Don't reuse TIM2 directly for anything else.

Hardware gotcha: the INA219 bus needs software (bit-banged) I2C

The schematic labels a bus "SCL"/"SDA" going to the INA219 (U3), wired to PB10 and PB9. This looks like it should be a normal hardware I2C bus, but it isn't reachable by any single hardware I2C peripheral on this chip:

  • I2C2 is fixed to PB10=SCL, PB11=SDA (no remap available). PB11 is unconnected on this board.
  • I2C1 remap 1 is PB8=SCL, PB9=SDA. PB8 is unconnected on this board.

PB10 only pairs (in hardware) with PB11; PB9 only pairs with PB8. Neither partner pin is wired to the sensor. This is a board wiring quirk, not something fixable via ch32-hal configuration. firmware/src/selfcheck.rs therefore implements a minimal bit-banged I2C driver (BitbangI2c) over plain open-drain GPIOs (hal::gpio::Flex) on PB9/PB10, implementing embedded_hal::i2c::I2c via its single required transaction() method (the read/write/write_read provided methods come for free from that). It does not support clock stretching — fine for the INA219, which doesn't stretch, but keep that in mind if another device ever goes on this bus.

INA219 address: 0x48A0→GND, A1→SDA on this board, i.e. ina219::address::Address::from_pins(Pin::Gnd, Pin::Sda). Not the more common default addresses seen in INA219 examples/datasheet defaults.

Current-sense shunt: R4 ‖ R5, 100mΩ each → 50mΩ combined. Current is derived in software from shunt_voltage_uv() / 50, not from the INA219's own calibration registers (IntCalibration) — this board's real max current draw isn't documented anywhere, so guessing a current_lsb for hardware calibration seemed worse than a simple, honest Ohm's-law calculation using the known shunt value.

RCC / clock configuration

hal::init is called with rcc: hal::rcc::Config::SYSCLK_FREQ_144MHZ_HSI, not the HAL's default config. This is required for USB to get a valid 48MHz derived clock, and it's HSI-based because there's no external crystal on this board (OSC_IN/OSC_OUT are unconnected). If you ever add code that assumes a different clock tree, check this first.

Dependency versions that matter (not arbitrary pins in Cargo.toml)

These aren't just "whatever version worked" — each one fixes a real, previously-hit build or runtime failure. Don't casually bump/change them without re-verifying:

  • embassy-executor features = ["arch-spin", "executor-thread"] — NOT arch-riscv32. arch-riscv32's WFI-based sleep races with the USB interrupt's wake signal: if the interrupt fires right as the core is about to sleep, the wake can be missed and anything .awaiting on it (e.g. wait_connection()/write_packet()) hangs forever, even though the device still enumerates fine. arch-spin busy-polls instead of sleeping, sidestepping the race. (Symptom if this regresses: USB enumerates, but no console output ever appears.) Confirmed the same fix is needed in the sibling PlantCtrl/Software/CAN_Sensor project on the same HAL fork.
  • embassy-time features = ["generic-queue-8"], version "0.5.0" — needs to match the embassy-time version ch32-hal itself pulls in transitively (currently 0.5.x); a direct-dependency version mismatch causes a Cargo links conflict (embassy-time-queue-utils can't resolve two different versions simultaneously). The generic-queue-8 feature makes embassy-time use its own self-contained timer queue instead of requiring embassy-executor to implement an "integrated timer queue" symbol that our pinned embassy-executor 0.7.0 doesn't provide (that support landed in later embassy-executor versions).
  • embassy-usb = "0.5.1" — NOT 0.3.0/0.4.0. Those depend on embassy-usb-driver ^0.1.0; ch32-hal's hal::usbd::Driver implements the embassy-usb-driver 0.2.0 traits. embassy-usb versions ≥0.5.0 are the first to depend on embassy-usb-driver ^0.2.0. Using an older embassy-usb here is a hard type-check failure, not a subtle bug.
  • heapless feature = "portable-atomic-critical-section" — needed for static_cell (used for the 'static USB buffers/class/device via the mk_static! macro in main.rs) to build at all. riscv32imc has no native atomic compare-and-swap instruction; without this feature, static_cell's internal AtomicBool::compare_exchange fails to compile for this target. This feature makes portable-atomic (a shared transitive dependency) emulate CAS via critical sections instead.
  • ch32-hal — git dependency on ju6ge/ch32-hal, branch feature/i2c-slave-api (upstream ch32-rs/ch32-hal does not have the I2C slave-mode API this firmware's (currently disabled) I2C1 code depends on — checked directly, no SlaveConfig/listen_blocking there). This branch has been force-pushed/rebased before (the previously-locked commit disappeared from GitHub entirely) — if cargo build ever fails to fetch the pinned commit in Cargo.lock, that's almost certainly why; re-run cargo update -p ch32-hal to pick up the branch's current tip.
  • Entry point is qingke_rt::entry (via #[embassy_executor::main(entry = "qingke_rt::entry")]), matching ch32-hal's own examples for this chip family.

Logging

println! in main.rs is a local macro, not ch32_hal::println! — the latter writes over WCH's SDI single-wire debug protocol (needs a WCH-Link probe attached, see hal's debug.rs). The local one formats into a heapless::String<128> and pushes it onto a channel (LOG_CH) that a spawned usb_writer task drains and writes out over the USB CDC-ACM console. It's #[macro_export]ed with $crate::LOG_CH internally so other modules (e.g. selfcheck.rs) can call crate::println!(...) and have it land in the same place. Sends are non-blocking (try_send) and silently drop the line if the channel (capacity 8) is full — there's no backpressure, so a burst of many println! calls in a tight loop with no .await in between can lose messages if nothing's draining the channel yet (e.g. no terminal connected). USB CDC devices also re-enumerate on every reflash; most terminal programs need to be manually reconnected afterward.