10 KiB
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 Output — ch32-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!needsUSB_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 (seeusb_writerinmain.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
0x55in the existing but currently-commented-out code). Not currently enabled —I2c::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'stime-driver-tim2feature 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: 0x48 — A0→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-executorfeatures =["arch-spin", "executor-thread"]— NOTarch-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-spinbusy-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 siblingPlantCtrl/Software/CAN_Sensorproject on the same HAL fork.embassy-timefeatures =["generic-queue-8"], version"0.5.0"— needs to match theembassy-timeversionch32-halitself pulls in transitively (currently 0.5.x); a direct-dependency version mismatch causes a Cargolinksconflict (embassy-time-queue-utilscan't resolve two different versions simultaneously). Thegeneric-queue-8feature makesembassy-timeuse its own self-contained timer queue instead of requiringembassy-executorto implement an "integrated timer queue" symbol that our pinnedembassy-executor 0.7.0doesn't provide (that support landed in laterembassy-executorversions).embassy-usb = "0.5.1"— NOT 0.3.0/0.4.0. Those depend onembassy-usb-driver ^0.1.0;ch32-hal'shal::usbd::Driverimplements theembassy-usb-driver 0.2.0traits.embassy-usbversions ≥0.5.0 are the first to depend onembassy-usb-driver ^0.2.0. Using an olderembassy-usbhere is a hard type-check failure, not a subtle bug.heaplessfeature ="portable-atomic-critical-section"— needed forstatic_cell(used for the'staticUSB buffers/class/device via themk_static!macro inmain.rs) to build at all.riscv32imchas no native atomic compare-and-swap instruction; without this feature,static_cell's internalAtomicBool::compare_exchangefails to compile for this target. This feature makesportable-atomic(a shared transitive dependency) emulate CAS via critical sections instead.ch32-hal— git dependency onju6ge/ch32-hal, branchfeature/i2c-slave-api(upstreamch32-rs/ch32-haldoes not have the I2C slave-mode API this firmware's (currently disabled) I2C1 code depends on — checked directly, noSlaveConfig/listen_blockingthere). This branch has been force-pushed/rebased before (the previously-locked commit disappeared from GitHub entirely) — ifcargo buildever fails to fetch the pinned commit inCargo.lock, that's almost certainly why; re-runcargo update -p ch32-halto pick up the branch's current tip.- Entry point is
qingke_rt::entry(via#[embassy_executor::main(entry = "qingke_rt::entry")]), matchingch32-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.