RC servo control for CH32 RISC-V microcontrollers, built directly on ch32fun. No Arduino core, no HAL, no floating point, no dynamic allocation.
Builds for every CH32 family ch32fun supports that has a TIM2: CH32V003, CH32V00x, CH32V10x, CH32V20x, CH32V30x, CH32L103 and CH32X03x.
Only the CH32V003 has been run on hardware. Every other family's pin and register tables come from WCH reference manuals, cross-checked against a second source where one exists. They compile cleanly and are expected to work, but no servo has been driven by them. Board support gives the evidence per family; Known issues lists what is deliberately incomplete. A report from real silicon, working or not, is the most useful thing anyone could contribute right now.
The library has two interchangeable pulse-generation backends, chosen at build
time. The API is identical under both, so switching is one #define and a pin
change:
#include "Servo.h"
int main(void)
{
SystemInit();
Servo servo;
servo.attach(SERVO_TIM2_CH1_PIN); // TIM2 CH1 for this chip and remap group
servo.writeMicroseconds(1500); // centre
while (1) Delay_Ms(1000); // the timer holds the position for you
}TIM2 is the only general-purpose timer on a CH32V003, and plenty of libraries want it, including Ticker, which owns it outright. A servo library that only works by taking TIM2 is unusable in exactly the projects that need it most.
| TIM2 backend (default) | SysTick backend (SERVO_USE_SYSTICK) |
|
|---|---|---|
| Pulse generated by | the timer peripheral | a compare-match interrupt |
| Interrupts used | none¹ | SysTick compare |
| Timers consumed | TIM2 | none |
| Servos | up to 4, one per channel² | 8+, limited by frame time |
| Pins | TIM2 channel pins only | any GPIO |
| Pulse accuracy | exact, immune to your code | ±10 µs, subject to interrupt latency |
| Flash | 224 B | 484 B |
¹ Unless SERVO_SMOOTH is enabled, which needs the TIM2 update interrupt to
advance ramps.
² Four on most families. Some remap groups route fewer: see the TIM2 pin table. Flash figures are CH32V003, from Flash and RAM.
Pick the TIM2 backend unless something else in your firmware needs TIM2, or you need more than four servos, or you need a pin that is not a TIM2 channel.
- Optional smooth motion:
write()sets a target and returns, and the position ramps there from the interrupt the active backend already runs. Nothing to poll in your main loop. - Microsecond and integer-degree APIs. The degree API compiles out if unused.
- Integer-only, no dynamic allocation, no runtime pin tables, no runtime validation. 224 B of flash and 8 B per servo at its smallest.
- A CH32 chip with a TIM2, and ch32fun.
- A servo. Give it its own supply, because a hobby servo's stall current will brown out an MCU sharing its rail, and tie the grounds together. Signal is 3.3 V from these parts; most servos accept that, some want 5 V logic.
[env:genericCH32V003F4P6]
platform = https://github.com/Community-PIO-CH32V/platform-ch32v.git
board = genericCH32V003F4P6
framework = ch32v003fun
lib_deps = fyfar/Servofyfar/Servo is the registry name. The source lives under the ch32-libraries
GitHub organisation, which is a separate namespace.
For another chip, change board to one the CH32V platform provides. The library
picks the family up from the framework's own -D, so nothing else in the .ini
changes. examples/pio_servo_sweep is a complete standalone project.
Copy src/Servo.h and src/Servo.cpp into your project, then:
TARGET_MCU?=CH32V003 # ch32fun.mk has no default and errors without one
ADDITIONAL_C_FILES+=path/to/Servo.cpp
include path/to/ch32fun/ch32fun.mk
# Must come after the include: ch32fun.mk sets CFLAGS with `?=`, so setting it
# beforehand makes that assignment a no-op and silently drops -Os and -flto.
CFLAGS+=-Ipath/to/servo/srcbool attach(uint8_t pin); // ch32fun pin constant; false if registry full
void detach(); // stops pulses, leaves the line LOW
void writeMicroseconds(uint16_t us);// clamped to SERVO_MIN_US..SERVO_MAX_US
uint16_t readMicroseconds() const; // the commanded position, after clamping
void write(uint8_t angle); // SERVO_ENABLE_ANGLE (on by default)
void setSpeed(uint16_t us_per_sec); // SERVO_SMOOTH; 0 = move immediately
bool isMoving() const; // SERVO_SMOOTHwriteMicroseconds() is safe before attach(): the value is retained and
applied when the servo attaches. Copying a Servo is a compile error, and a
Servo going out of scope detaches itself.
Servo servo; at file scope works, but its constructor only runs if you set
FUNCONF_SUPPORT_CONSTRUCTORS 1 in funconfig.h. Without it the object is
zeroed in BSS and starts at SERVO_MIN_US instead of centre: harmless, but not
what you wrote. A Servo declared inside a function needs nothing.
Every knob is a build-time #define, in funconfig.h or as -D. There are no
runtime tables and no runtime validation: a wrong define gives you wrong
hardware, not a diagnostic. Where a mistake is statically detectable the build
fails and says so.
| Macro | Default | Meaning |
|---|---|---|
SERVO_USE_SYSTICK |
0 |
1 selects the SysTick backend |
SERVO_SMOOTH |
0 |
1 compiles in ramped motion |
SERVO_ENABLE_ANGLE |
1 |
0 drops write(degrees) |
SERVO_FRAME_US |
20000 |
frame period, µs (20000 = 50 Hz) |
SERVO_MIN_US |
1000 |
pulse width at minimum position |
SERVO_MAX_US |
2000 |
pulse width at maximum position |
SERVO_ANGLE_MAX |
180 |
degree span mapped onto that range |
SERVO_MAX_SERVOS |
4 |
registry size |
SERVO_TIM2_REMAP |
0 |
TIM2 output remap group; legal range is per family (see below) |
- TIM2 backend: core clock must be a whole multiple of 1 MHz.
- SysTick backend: core clock at least 8 MHz, or at least 1 MHz with
FUNCONF_SYSTICK_USE_HCLK=1.
Any FUNCONF_USE_HSI/FUNCONF_USE_HSE and FUNCONF_PLL_MULTIPLIER choice
that meets these is fine; every default ch32fun ships for a supported chip
already does. An unsafe combination fails at build time rather than
mis-timing your servo.
1000-2000 µs is a nominal figure, not a fact about your servo. Real units
vary; many accept 500-2500 µs and give you noticeably more travel, and two
servos of the same model can differ. SERVO_MIN_US/SERVO_MAX_US is the
calibration knob. Sweep slowly outward and back off when the servo stalls or
buzzes at the endpoint.
The remap group is a property of the timer, not of one servo: it moves all four channels at once. Choose the group that covers the pins you want, then attach to pins from that row only.
Which pins a group provides depends on the family, so Servo.h exposes
SERVO_TIM2_CH1_PIN..SERVO_TIM2_CH4_PIN for whichever family and group is
configured. The examples use those. Passing a pin outside the configured row
fails at link time by design; the pin must be a compile-time constant.
Each family's tier and the documents behind its table are in Board support.
What each family gives you:
| Family | Legal SERVO_TIM2_REMAP |
Max servos on TIM2 | Notes |
|---|---|---|---|
| CH32V003 | 0-3 |
4 | package may cost you pins, see below |
| CH32V10x, CH32V20x, CH32V30x | 0-3 |
4 | |
| CH32L103 | 0-3 |
4 | register supports 4-7; this library does not |
| CH32X03x | 0-3, 5-7 |
4 in groups 0 and 2, otherwise 3 |
group 4 unusable; CH1 unavailable in 1, 3, 5-7 |
| CH32V00x | 0-7 |
4 |
A group with no usable channel, or a group number the family does not define, fails the build with a message naming the problem rather than silently picking another. Needing more servos than the table allows is what the SysTick backend is for. It uses any GPIO and has no such limit.
SERVO_TIM2_REMAP |
CH1 | CH2 | CH3 | CH4 |
|---|---|---|---|---|
0 (default) |
PD4 |
PD3 |
PC0 |
PD7 ⚠ |
1 |
PC5 |
PC2 |
PD2 |
PC1 |
2 |
PC1 |
PD3 |
PC0 |
PD7 ⚠ |
3 |
PC1 |
PC7 |
PD6 |
PD5 |
⚠ PD7 is also NRST. To use CH4 in groups 0 or 2 you must first disable the
reset function via the option bytes (minichlink -d).
Package matters. TSSOP20/QFN20 (CH32V003F4P6/F4U6) expose all 18 GPIO, so
every row above is usable. SOP16 (CH32V003A4M6, 14 GPIO) omits PD0, PD2,
PD3 and PC5, which makes remap group 1 unusable there (it needs both
PC5 and PD2) and costs CH2 in groups 0 and 2. SOP8 (CH32V003J4M6,
6 GPIO) is more restricted still. Check your package's pinout before choosing a
remap group.
All four carry the same table.
SERVO_TIM2_REMAP |
CH1 | CH2 | CH3 | CH4 |
|---|---|---|---|---|
0 (default) |
PA0 |
PA1 |
PA2 |
PA3 |
1 |
PA15 |
PB3 |
PA2 |
PA3 |
2 |
PA0 |
PA1 |
PB10 |
PB11 |
3 |
PA15 |
PB3 |
PB10 |
PB11 |
No pin here doubles as a debug or reset line. Debug is two-wire on PA13/PA14,
so PA15 and PB3 are ordinary GPIO and groups 1 and 3 are usable. ch32fun's
AFIO_PCFR1_SWJ_CFG macros suggest otherwise, but they are an ST leftover: WCH's
manuals give that field as SWD-only and never mention JTAG.
CH32L103 stops at group 3. Its remap field is {TIM2_RM_H, TIM2_RM}, and the
third bit lives in AFIO->PCFR2[21], which this library leaves at reset.
Reaching groups 4 to 7 needs a second, L103-only register write. Group 6 is not
defined by the manual at all.
SERVO_TIM2_REMAP |
CH1 | CH2 | CH3 | CH4 |
|---|---|---|---|---|
0 (default) |
PA0 |
PA1 |
PA2 |
PA3 |
1 |
- † | PB15 |
PA2 |
PA3 |
2 |
PA0 |
PA1 |
PB3 |
PB4 |
3 |
- † | PB15 |
PB3 |
PB4 |
4 |
- † | - † | - † | - † |
5 |
- † | PA12 |
PA13 |
PC0 |
6, 7 |
- † | PC14 |
PC15 |
PC0 |
† Unreachable through ch32fun. CH32X035's GPIO ports are 24 bits wide and these
channels land on PB21, PB16-PB19 or PC19, but ch32fun's pin constants
stop at 15 per port and its GpioOf() treats pin 16 as the next port. Group 4
has no usable channel at all. Fixing this means extending ch32fun.
No usable pin here doubles as a debug or reset line. RST is on PA21, PC3 or
PB7 depending on package, PC17 is the BOOT detection pin, and SWD is on
PC18/PC19, the latter being a CH1 that cannot be named anyway.
CH32M007 shares this table and Servo.h handles it, but ch32fun has no
TARGET_MCU for that part, so it cannot be built today.
SERVO_TIM2_REMAP |
CH1 | CH2 | CH3 | CH4 |
|---|---|---|---|---|
0 (default) |
PD4 |
PD3 |
PC0 |
PD7 ⚠ |
1 |
PC1 |
PD3 |
PC0 |
PD7 ⚠ |
2 |
PC5 |
PC2, or PB3 ⚠ on V007/M007 |
PD2 |
PC1 |
3 |
PC1 |
PC7 |
PD6 |
PD5 |
4 |
PC0 |
PC1 |
PC3 |
PB6 |
5 |
PA0 |
PA1 ⚠ |
PA2 ⚠ |
PA3 |
6 |
PB1 |
PA1 ⚠ |
PA2 ⚠ |
PA3 |
7 |
PD3 |
PD4 |
PA2 ⚠ |
PA3 |
⚠ Three pins carry a second function. PD7 is also RST, as on CH32V003, so
CH4 in groups 0 and 1 needs the reset function disabled in the option bytes.
PB3 is SWCLK, and is a TIM2 channel only on CH32V007 and CH32M007, which put
CH2 there instead of PC2. That split is the only per-part difference in any
table here, and the manual documents it. PA1 and PA2 are the XI/XO
crystal pins, gated by AFIO_PCFR1.PA1PA2_RM, so they are usable as GPIO only
when no external crystal is fitted.
servo.setSpeed(1000); // 1000 µs of pulse width per second
servo.writeMicroseconds(2000); // returns immediately
while (servo.isMoving()) { } // optionalThe ramp is advanced once per frame from whichever interrupt the active backend already runs: the TIM2 update interrupt, or the SysTick handler. Your main loop does not have to call anything.
Rates are quantised to whole microseconds per frame, so they come in 50 µs/s increments at the default 50 Hz and the slowest non-zero rate is 50 µs/s (a full 1000 µs sweep in 20 seconds). A rate too small to move one microsecond per frame is rounded up to one rather than silently standing still.
| Example | What it shows |
|---|---|
servo_sweep |
one servo on TIM2, plain microsecond sweep |
servo_multi |
four servos on the four TIM2 channels (needs a family and group that provides all four) |
servo_systick |
three servos on the SysTick backend, on non-timer pins |
servo_smooth |
ramped motion with an idle main loop |
pio_servo_sweep |
standalone PlatformIO project |
cd examples/servo_sweep && makeMeasured on CH32V003 at -Os -flto, as the increase over an empty
SystemInit()-only firmware. "Static RAM" is the library's own state; each
Servo object costs sizeof(Servo) on top, wherever you put it.
| Configuration | Flash | Static RAM | sizeof(Servo) |
|---|---|---|---|
| TIM2 backend | 224 B | 4 B | 8 B |
| TIM2 + degree API | 324 B | 4 B | 8 B |
| TIM2 + smoothing | 688 B | 20 B | 10 B |
| SysTick backend | 484 B | 32 B | 6 B |
| SysTick + degree API | 556 B | 32 B | 6 B |
| SysTick + smoothing | 640 B | 32 B | 8 B |
Smoothing costs the most because it adds an interrupt handler, not because of
the ramp math. The degree API costs about 100 B because this core has no hardware
multiply, so set SERVO_ENABLE_ANGLE=0 if you only speak microseconds. No
soft-float or allocator symbols appear in any configuration.
The TIM2 backend claims TIM2, its four channels and the AFIO->PCFR1 remap
field. It sets the prescaler for a 1 MHz tick and the auto-reload for a 20 ms
frame, so a compare register holds the pulse width in microseconds directly, and
enables no interrupt unless SERVO_SMOOTH is set. On CH32X03x it also sets MOE
in TIM2->BDTR, because TIM2 there is an advanced-control timer whose outputs
are off at reset. No other family has that register.
The SysTick backend claims only the compare-match interrupt vector. It never
resets the counter, changes its clock source or enables auto-reload, so
Delay_Us(), funSysTick32() and anything else reading SysTick->CNT keep
working, and TIM2 is never touched. Servos are pulsed one at a time, so every
pulse must fit inside one frame. That caps the backend near nine servos, checked
at build time.
- The TIM2 backend cannot coexist with any other TIM2 user, including
Ticker. This is what "uses TIM2" means, not a bug we can fix. Use the SysTick backend, which is exactly why it exists. - The SysTick backend claims the SysTick interrupt vector, so it collides
with a hand-rolled
millis()or anything else wanting that vector. - SysTick-backend pulse width degrades with interrupt latency. Code that disables interrupts, or a higher-priority handler that runs long, shows up as servo jitter. Use the TIM2 backend if you cannot accept that.
- Configuration mistakes are yours to catch. Only statically detectable ones
fail the build. A legal-but-wrong
SERVO_TIM2_REMAPwill happily drive the wrong pin. This is a deliberate trade for size and is the ch32fun convention. - One pulse-width range for all servos. Mixed servos wanting different
ranges should use
writeMicroseconds()directly. - Not every family gives four TIM2 channels in every remap group. CH32X03x loses
CH1 in five of its seven usable groups and has one group with none at all.
CH32L103 is limited to groups
0-3. CH32M007 cannot be built at all. Known issues has the reasons.
CH32V003 was checked with a scope, logic analyzer and WCH-LinkE: both backends, pulse widths and clamping, detach and re-attach, four servos sequenced on non-timer pins, frame stability, ramp rate, mid-flight retargeting, and register read-back confirming the SysTick backend never enables TIM2's clock.
Every other family carries the evidence its support actually rests on:
- Hardware-verified: confirmed by measurement on real silicon.
- Cross-checked: a WCH reference manual plus an independent corroborating source.
- Manual only: a reference manual with nothing to check it against.
| Chips | SysTick backend | TIM2 backend | What the TIM2 table rests on |
|---|---|---|---|
| CH32V003 | Hardware-verified | Hardware-verified | CH32V003RM + examples/tim2_pwm_remap, run on hardware |
| CH32V10x | Cross-checked (its SysTick is shaped differently and handled accordingly) | Cross-checked | CH32xRM table 10-11 + ch32v10xhw.h |
| CH32V20x (all packages, incl. D8/D8W), CH32V30x | Cross-checked | Cross-checked | CH32FV2x_V3xRM table 10-16 + ch32v20xhw.h/ch32v30xhw.h |
| CH32L103 | Cross-checked | Cross-checked, groups 0-3 only | CH32L103RM §10 + CH32L103DS0 pin table |
| CH32X03x | Cross-checked | Cross-checked, some channels unreachable | CH32X035RM §8 + CH32X035DS0 + examples_x035/tim2_pwm |
| CH32V002, CH32V004-007 | Cross-checked | Manual only | CH32V00XRM §9, no second source except on V006 |
| CH32M007 | - | - | Servo has its table, but ch32fun has no TARGET_MCU for it yet |
| CH32H41x | Not supported | Not supported | Incompatible clock and interrupt architecture |
| CH5xx family (CH551, CH552, CH570-CH592) | Not supported (no TIM2) | Not supported (no TIM2) | - |
All MCU-dependent code sits in one marked portability block in Servo.h: a
per-family descriptor chain, then one table of four pin defines per family and
remap group. Building for a family with no TIM2 register layout fails at compile
time with a message pointing at that block rather than misconfiguring registers.
Deliberately incomplete, in rough order of how much anyone is likely to care:
- CH32X03x channels on pins above
P?15. CH32X035's GPIO ports are 24 bits wide, but ch32fun's pin constants stop at 15 per port and itsGpioOf()treats pin 16 as the next port. That costs CH1 in remap groups1,3,5,6and7, and all four channels in group4. Fixing it means extending ch32fun's GPIO helpers first. - CH32L103 remap groups
4-7. NeedsAFIO->PCFR2[21]handling, which is a second, L103-only register write. Group6is not defined by the manual at all. - CH32M007 cannot be built.
Servo.hhas its table, but ch32fun has noTARGET_MCUfor that part. - CH32V00x has no second source. Its tables come from CH32V00XRM alone; only the CH32V006 datasheet was available to check pinouts against.
https://github.com/ch32-libraries/Servo
ISC. See LICENSE.