How-To-Tutorials · October 12, 2025

How to Implement Synchronized Multi-Axis Motion Control with STM32 and PID

Synchronized Multi-Axis Motion Control with STM32 and PID: A CNC Application

If your CNC machine's axes don't move in perfect coordination, you get curved lines where you wanted straight ones and rough surfaces where you needed smooth finishes. Synchronized multi-axis control is the backbone of any serious CNC system, and PID is still the workhorse algorithm that makes it happen.

Here you'll set up an STM32 to read incremental encoders on two axes, run independent PID loops for each, and synchronize them so both axes reach their targets at the same time. The STM32's hardware timer encoder mode does the heavy lifting for position tracking, and a fixed-rate timer interrupt keeps your control loop deterministic.

Prerequisites

  • Solid C programming skills
  • Experience with STM32CubeIDE v1.16+ and the HAL library
  • Working understanding of PID control (at minimum: what P, I, and D terms do)
  • Access to a multi-axis motion system (CNC mill, gantry, or even a 2-axis test rig)
  • Incremental encoders mounted on each axis

Parts/Tools

  • STM32F4 series microcontroller (STM32F446RE or STM32F407 are good choices -- they have plenty of timers)
  • Incremental quadrature encoders (one per axis, 400-2000 PPR depending on your resolution needs)
  • Motor drivers rated for your stepper or servo motors
  • STM32CubeIDE v1.16+ with STM32CubeMX integrated
  • Appropriate power supply for your motors (keep motor power separate from logic power)
  • Oscilloscope or logic analyzer (optional but incredibly helpful for debugging timing)

Steps

  1. Set Up the Hardware
    • Connect your incremental encoders to STM32 timer input pins. For two axes, you need two timers with encoder interface capability. On the STM32F446RE, TIM1 (PA8/PA9) and TIM3 (PA6/PA7) work well. Each encoder needs its A and B channels connected to the timer's CH1 and CH2 inputs.
    • Wire your motor drivers to PWM output pins and direction GPIO pins. Keep signal wires away from motor power lines -- encoder noise will wreck your position readings.
    • Watch out: use shielded cables for your encoder connections if the cable runs are longer than about 30cm. Electrical noise from stepper motors is real, and it will cause phantom counts.
  2. Configure Timers in STM32CubeMX
    • Open STM32CubeIDE, create a new project, and select your MCU. In the CubeMX pinout view:
    • Set TIM1 to "Encoder Mode" -- this automatically configures it to count quadrature encoder pulses in hardware. No interrupts needed for counting. Set the counter period to the max (0xFFFF for 16-bit timers, 0xFFFFFFFF for 32-bit timers like TIM2/TIM5).
    • Do the same for TIM3 (or whichever second timer you're using).
    • Configure a separate timer (TIM6 is a good choice since it's a basic timer) as your control loop trigger. For a 1kHz control loop:
    • // Assuming 84 MHz APB1 timer clock
      // Prescaler: 83 -> timer ticks at 1 MHz
      // Period: 999 -> overflow every 1 ms (1 kHz)
      htim6.Init.Prescaler = 83;
      htim6.Init.Period = 999;
    • Enable the TIM6 update interrupt in NVIC settings.
  3. Read Encoder Values Using Hardware Counting
    • Start the encoder timers in your initialization code. The beauty of encoder mode is that the hardware counts pulses automatically -- no interrupt overhead, no missed counts:
    • HAL_TIM_Encoder_Start(&htim1, TIM_CHANNEL_ALL);
      HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL);
    • Read the current position whenever you need it by grabbing the counter register:
    • int32_t Read_Encoder_X(void) {
          return (int16_t)__HAL_TIM_GET_COUNTER(&htim1);
      }
      
      int32_t Read_Encoder_Y(void) {
          return (int16_t)__HAL_TIM_GET_COUNTER(&htim3);
      }
    • The cast to int16_t handles counter rollover correctly for position tracking. For 32-bit timers (TIM2, TIM5), use int32_t directly.
  4. Implement the PID Controller
    • Use a struct-based PID so each axis gets its own independent state. This is cleaner than global variables and scales to any number of axes:
    • typedef struct {
          float Kp, Ki, Kd;
          float integral;
          float prev_error;
          float output_min, output_max;
      } PID_t;
      
      float PID_Update(PID_t *pid, float setpoint, float measured, float dt) {
          float error = setpoint - measured;
          
          pid->integral += error * dt;
          // Anti-windup: clamp integral term
          if (pid->integral > pid->output_max) pid->integral = pid->output_max;
          if (pid->integral < pid->output_min) pid->integral = pid->output_min;
          
          float derivative = (error - pid->prev_error) / dt;
          pid->prev_error = error;
          
          float output = (pid->Kp * error) + (pid->Ki * pid->integral) + (pid->Kd * derivative);
          
          // Clamp output
          if (output > pid->output_max) output = pid->output_max;
          if (output < pid->output_min) output = pid->output_min;
          
          return output;
      }
    • Anti-windup clamping is not optional for real motion control. Without it, your integral term accumulates when the motor is stalled or saturated, and the axis overshoots wildly when it finally starts moving.
  5. Control Motor Movement
    • Map PID output to PWM duty cycle and direction pin:
    • void Set_Motor_X(float pid_output) {
          if (pid_output >= 0) {
              HAL_GPIO_WritePin(DIR_X_GPIO_Port, DIR_X_Pin, GPIO_PIN_SET);
              __HAL_TIM_SET_COMPARE(&htim_pwm, TIM_CHANNEL_1, (uint32_t)pid_output);
          } else {
              HAL_GPIO_WritePin(DIR_X_GPIO_Port, DIR_X_Pin, GPIO_PIN_RESET);
              __HAL_TIM_SET_COMPARE(&htim_pwm, TIM_CHANNEL_1, (uint32_t)(-pid_output));
          }
      }
  6. Synchronize Multi-Axis Motion
    • The key to synchronized motion: don't just send both axes to their targets independently. Instead, use a trajectory planner that breaks the path into time-based setpoints so both axes arrive together. For linear interpolation between two points:
    • typedef struct {
          float start_x, start_y;
          float end_x, end_y;
          float duration;       // total move time in seconds
          float elapsed;
      } Motion_Segment_t;
      
      void Update_Setpoints(Motion_Segment_t *seg, float dt,
                            float *sp_x, float *sp_y) {
          seg->elapsed += dt;
          float t = seg->elapsed / seg->duration;
          if (t > 1.0f) t = 1.0f;
          
          *sp_x = seg->start_x + (seg->end_x - seg->start_x) * t;
          *sp_y = seg->start_y + (seg->end_y - seg->start_y) * t;
      }
    • Now tie it all together in the timer interrupt callback:
    • PID_t pid_x = { .Kp=2.0, .Ki=0.5, .Kd=0.05, .output_min=-999, .output_max=999 };
      PID_t pid_y = { .Kp=2.0, .Ki=0.5, .Kd=0.05, .output_min=-999, .output_max=999 };
      
      void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
          if (htim->Instance == TIM6) {
              float dt = 0.001f; // 1ms control loop
              
              float sp_x, sp_y;
              Update_Setpoints(¤t_segment, dt, &sp_x, &sp_y);
              
              float meas_x = (float)Read_Encoder_X();
              float meas_y = (float)Read_Encoder_Y();
              
              float out_x = PID_Update(&pid_x, sp_x, meas_x, dt);
              float out_y = PID_Update(&pid_y, sp_y, meas_y, dt);
              
              Set_Motor_X(out_x);
              Set_Motor_Y(out_y);
          }
      }

Troubleshooting

  • Encoder counts going the wrong direction: Swap the A and B channel connections, or invert the count in software. It's a 50/50 chance you'll get it backward the first time -- totally normal.
  • Oscillation or overshoot: Your Kp is probably too high. Start with a very low Kp (like 0.5), zero Ki and Kd, and increase Kp until you see slight oscillation. Then back it off 20% and start adding Ki. The Ziegler-Nichols method gives you a formal starting point, but I find manual tuning from a low starting point works well for CNC applications.
  • Axes drifting apart during moves: Make sure both PID loops are running from the same timer interrupt. If they update at different rates, synchronization falls apart. Also verify your trajectory planner is feeding consistent time-based setpoints.
  • Motor overheating: Check your PWM frequency (16-20kHz is typical for motor drivers) and make sure you're not commanding full duty cycle continuously. Add current limiting in hardware if your driver doesn't have it built in.
  • Erratic encoder readings: Almost always a noise problem. Add 100nF decoupling caps close to the encoder connector and use shielded cabling. Also check that your encoder supply voltage matches what the encoder expects.

Next Steps

You've got the foundation of a synchronized multi-axis controller. The real work starts with PID tuning for your specific mechanical system -- every machine is different. From here, consider adding acceleration profiles (trapezoidal or S-curve) to your trajectory planner, implementing G-code parsing for real CNC operation, and adding a serial interface for real-time monitoring. If you need more axes or more computing power, the STM32H7 series gives you a faster core and more timers to work with.