Bare-metal firmware for a 6-DOF robot arm: precise, glitch-free servo motion over a CRC-checked serial protocol.
STM32F103C8T6 · C (no HAL, no RTOS) · 6.7 KB flash · Python host library · built for the arm in this AliExpress listing
VectorM replaces the stock firmware on the arm's STM32 controller board. The PC does the thinking (inverse kinematics, planning, vision) and streams joint angles over serial; the STM32 turns them into hardware-timed PWM pulses for the six servos and keeps them on a 1 kHz motion engine, so the arm settles on target without overshoot or jitter.
It is the body of Cyph, the Furry Engineer, a pixel-art AI companion that talks, plans your day and drives this arm. VectorM works on its own too: anything that can speak the protocol below can move the arm.
Hardware. I built this for the arm kit from the AliExpress listing (the WIT open-source vision robotic arm: STM32F103C8T6 controller, six 20 kg·cm 270° PWM servos, an OpenMV camera). Listings change and boards get revised, so if yours differs, check the pin map in
firmware/inc/config.hfirst (see If your board differs).
| Item | Value |
|---|---|
| PWM timer resolution | 2 MHz tick = 0.5 µs per step |
| Angular resolution | 0.068° per step (the servos are good to about 0.3°) |
| Pulse period | 20.000 ms exactly (50.00 Hz), hardware-timed |
| Motion update rate | 1 kHz, smootherstep (5th-order minimum-jerk) profile |
| Pulse updates | timer-preloaded: changes latch at the cycle boundary, zero glitches |
| Angle math | integer centidegrees (0.01°), no float drift |
| Framing | CRC-16/CCITT, sequence numbers, ACK/NAK on every command |
| Calibration | per-servo min/max pulse, trim and direction, saved to flash |
| Size | 6,708 bytes of the 64 KB flash |
Point-to-point moves use a minimum-jerk profile (zero velocity and acceleration at both ends), so there is no overshoot and no vibration. Re-targeting mid-move always starts from the live interpolated position, never a jump. Streaming mode plays pre-computed waypoint trajectories with sample-exact timing from a 32-point queue.
You need the arm, an 8.4 V / 3 A supply for the servos, and a USB-serial link to the board's Serial Comm Port (a USB-TTL adapter, an Arduino Uno used as a bridge, or the OpenMV camera as a bridge: all three are covered below).
pip install pyserial
git clone https://github.com/IRSPlays/VectorM.git
cd VectorM/host
# flash the prebuilt firmware (BOOT0 = 1, press RESET first; then BOOT0 = 0 and RESET after)
python flash_stm32.py --port COM15 --bin ../firmware/build/vectorm_arm.bin
# move the arm
python -c "from arm_client import ArmClient; a=ArmClient('COM15'); print(a.ping()); a.move_joints([135]*6, time_ms=1500)"flash_stm32.py talks to the chip's ROM bootloader at 8E1, erases only the pages it needs, writes, reads everything
back to verify, jumps to the app and PINGs it. A real run looks like this (from flash_stm32_full.log while bringing the arm up):
bootloader v3.0, chip PID 0x0410
erasing pages 0..6... erase done in 0.13s
writing 6708 bytes... written in 2.19s
verifying (read-back compare)... verify OK: 6708 bytes match the .bin exactly
application answered PING: {'version': '1.0.0', 'servos': 6, 'max_payload': 64, 'control_period_ms': 1}
firmware/
inc/config.h ALL tunables: pins, servo map, pulse ranges, calibration address
inc/stm32f103.h minimal register definitions (no vendor HAL)
src/startup.c vector table, 72 MHz clock init
src/servo.c PWM driver, angle <-> pulse, calibration in flash
src/motion.c 1 kHz interpolation engine and stream queue
src/protocol.c binary framing, CRC, command dispatch
src/uart.c interrupt-driven ring buffers
src/main.c
build.py / Makefile Windows / Linux, macOS, MSYS2
linker.ld
build/vectorm_arm.bin, .hex the prebuilt firmware (byte-identical to what is flashed on my arm)
host/
arm_client.py Python control class (ArmClient)
expressions.py 26 animal-like moods for the arm (Cipher mode)
example_ik_control.py where your inverse kinematics plugs in
flash_stm32.py serial-bootloader flasher with read-back verify
pin_scan.py, pin_probe.py find which pin drives which servo header (bring-up)
openmv_serial_bridge.py turn the OpenMV camera into a USB-serial bridge
tests/
test_host_client.py host library against a fake serial link
test_protocol.c real protocol.c + motion.c with stubbed servo/uart
test_servo_map.c angle <-> pulse mapping
- MCU: STM32F103C8T6, 72 MHz, 64 KB flash, 20 KB RAM
- 6× PWM servos, 20 kg·cm, 5–8.4 V, 0.16 s per 60° at 7.4 V, 0.3° accuracy, 270° travel
- Servo IDs from the base up: 1 base yaw · 2 shoulder · 3 elbow · 4 wrist pitch · 5 wrist roll · 6 gripper
- Power: 8.4 V / 2 A minimum (3 A+ recommended) into the board's power terminal. Servos draw up to 2.5 A each at stall, so a brownout during a fast move is a supply problem, not a firmware problem.
- Control link: the board's Serial Comm Port (TX / RX / GND / 5V) to a USB-serial adapter. The board's own USB connector is not the control channel (it goes to the F103's native USB peripheral).
Servo signal pins (measured on the real board with host/pin_probe.py; the map lives in inc/config.h):
| Servo | Signal pin | Timer |
|---|---|---|
| 1 base yaw | PB1 | TIM3_CH4 |
| 2 shoulder | PB0 | TIM3_CH3 |
| 3 elbow | PA7 | TIM3_CH2 |
| 4 wrist pitch | PA6 | TIM3_CH1 |
| 5 wrist roll | PB6 | TIM4_CH1 |
| 6 gripper | PB7 | TIM4_CH2 |
(PA8 also moved something during the scan and is unused.)
Serial link, USART1 at 115200 baud:
| Board Serial Comm Port | USB-TTL adapter |
|---|---|
| TX | RX |
| RX | TX |
| GND | GND |
| 5V | only if the adapter should power the logic |
A common ground between the adapter, the controller board and the servo supply is mandatory.
An Arduino Uno R3. Make it a dumb bridge by pulling out the ATmega328P (or jumpering RESET to GND), then wire straight through: Uno pin 0 (RX) to STM32 RX (PA10), Uno pin 1 (TX) to STM32 TX (PA9), GND to GND. Do not connect 5 V. PA9/PA10 are 5 V tolerant, so no level shifter is needed.
The OpenMV camera already on the arm. Copy host/openmv_serial_bridge.py onto it and it becomes a USB-serial bridge to the
STM32 (OpenMV P5/RX to PA9, P4/TX to PA10, GND to GND), usable for flashing and for control.
Needs the Arm GNU toolchain (arm-none-eabi-gcc); the xPack build installs with npm i -g @xpack-dev-tools/arm-none-eabi-gcc.
cd firmware
python build.py # Windows
make # Linux, macOS, MSYS2Output: build/vectorm_arm.bin, .hex, .elf, .map. I rebuilt it for this repo and the .bin is byte-identical to the
one flashed on the arm (6,708 bytes).
Flashing alternatives: an ST-Link (SWDIO/SWCLK/GND) with STM32CubeProgrammer at 0x08000000, or the serial bootloader
with STM32CubeProgrammer (UART) or stm32flash.
from arm_client import ArmClient
with ArmClient("COM15") as arm:
arm.ping()
arm.move_joints([135, 135, 135, 135, 135, 135], time_ms=1500)
joints = your_ik_solver(x, y, z, gripper_deg) # <- your math
arm.move_joints(joints, time_ms=800)
arm.stream_trajectory(waypoints, dt_ms=20) # path playback
arm.torque(False) # go limp to pose it by handAngles are degrees, joints 1..6 as above. The rest of the client: status(), move_joint(), move_raw() (calibration only),
stop(), reboot(), and set_calibration() / get_calibration() / save_calibration() / reset_calibration().
Moods. host/expressions.py makes the arm act like a silly animal (this is what Cyph uses): 26 expressions, blended
smoothly, with shoulder and elbow kept small so the arm cannot slam the table.
python expressions.py COM15 list # happy, excited, curious, sniff, sleepy, sad, startle, purr, zoomies, ...
python expressions.py COM15 happy excited sad # chained
python expressions.py COM15 idle # endless random moods, Ctrl+C to stopBinary, little-endian:
AA 55 | LEN | SEQ | CMD | PAYLOAD... | CRC16_LO CRC16_HI
LEN= byte count ofSEQ + CMD + PAYLOAD(2..66)- CRC-16/CCITT-FALSE over
LEN, SEQ, CMD, PAYLOAD - Response: same framing,
CMD | 0x80,SEQechoed, first payload byte is the status:0OK,1BAD_CMD,2BAD_ARGS,3FULL,4FLASH_ERR
| CMD | Name | Request payload | Response payload (after status) |
|---|---|---|---|
| 01 | PING | none | fw ×3, servos, max_payload u16, period_ms u16 |
| 02 | GET_STATUS | none | uptime u32, flags u8, stream_free u16, 6×(pos s32, target s32) in 0.01° |
| 10 | MOVE | 6× s32 angle (0.01°), u16 time_ms, u8 profile (0 smooth, 1 linear) | none |
| 11 | MOVE_RAW | 6× u16 pulse_us (immediate, calibration only) | none |
| 12 | STREAM | 6× s32 angle (0.01°), u16 dt_ms | u16 free queue slots |
| 13 | STOP | none | none |
| 14 | TORQUE | u8 enable (0 = limp) | none |
| 20 | SET_CALIB | u8 id, u16 min_us, u16 max_us, s32 min (0.01°), s32 max (0.01°), s16 trim (0.01°), s8 dir | none |
| 21 | GET_CALIB | u8 id | same struct as SET_CALIB |
| 22 | SAVE_CALIB | none | (4 = flash error) |
| 23 | DEFAULT_CALIB | none | erases stored calibration |
| 30 | REBOOT | none | none |
| 40 | PIN_PROBE | u8 port (0 = A), u8 pin | sweeps a servo signal on that GPIO for ~1.6 s (bring-up) |
Mechanical centering varies from servo to servo.
move_raw([1500]*6)parks every servo at mid-pulse.- Measure each joint's true angle and set
trim_degso commanded angle equals actual angle (set_calibration(id, trim_deg=...)). - If a joint is wired mirrored, use
direction=-1. - Narrow
min_us/max_usto the joint's real mechanical range, so clamping happens in software (important for the gripper). save_calibration()stores it in flash (at0x0800FC00); it survives reboots.
Servo header order differs between board revisions. Find your map instead of guessing: run python host/pin_scan.py COM15,
watch which joint moves for each pin and type its number (or Enter if nothing moved), then put the result in
SERVO_PIN_MAP_INIT in firmware/inc/config.h and rebuild. pin_probe.py does the same for one pin you name.
python tests/test_host_client.pyThis runs the host library against a fake serial link (framing, CRC, encoding, round trips) and passes. The two C tests
(test_protocol.c runs the real protocol.c and motion.c with stubbed servo and UART layers; test_servo_map.c checks the
angle-to-pulse mapping) need a native gcc (they compile the real firmware sources against stubs); I have not run them on this machine, which has only the ARM toolchain.
Honest limits: the tests never touch real servos, so they say nothing about your supply, wiring or calibration.
- "USB device not recognized" when plugging the board's USB in. That USB is not the serial link. Use a USB-TTL adapter (or a bridge above) on the Serial Comm Port. If a C-to-C cable fails to enumerate, try USB-A to C: many boards lack the CC pull-downs.
- Servos jitter on heavy moves. Servo supply sag. Use a stronger 8.4 V supply, thick power wires, and a common ground.
- The arm twitches on boot. The firmware parks all servos at 135° on start-up to hold position; this is intentional. Use
torque(False)to make the arm limp before posing it by hand. - The wrong joint moves. Your servo header order differs; see If your board differs.
- Flashing fails. The bootloader needs even parity (8E1), BOOT0 = 1 and a RESET before you start; set BOOT0 = 0 and RESET afterwards.
Working on my arm: flashing, motion, streaming, calibration and the 26 moods. Not done: closed-loop feedback (the servos have no
position sensor, so the position read back is the commanded one), a watchdog that goes limp when the PC goes quiet
(CMD_TIMEOUT_MS is 0 in config.h, so it is off), and a photo or video of the arm in this README.
No license has been chosen yet, so by default all rights are reserved. If you want to reuse the firmware, open an issue.