← Back to projects

2-Axis Gantry Control System

Embedded control system for precise two-axis positioning using STM32 Nucleo

2024c, stm32, embedded-systems, motor-control

View on GitHub


The Problem

Precise two-axis positioning is fundamental to many automation applications—CNC machines, 3D printers, pick-and-place systems. The challenge is coordinating two independent stepper motors to move a gantry to arbitrary (X, Y) coordinates while maintaining accuracy and preventing collisions.

This was an academic project (MTE 325) that required building a complete embedded control system from scratch, learning fundamental embedded systems concepts: interrupts, polling, GPIO configuration, UART communication, ADC characterization, and motor control.


System Architecture

Hardware Stack

[PC Terminal] --UART--> [STM32 Nucleo F401RE] --GPIO--> [Motor Driver Shield] ---> [Stepper Motors]

Components:

  • STM32 Nucleo F401RE — ARM Cortex-M4 microcontroller, 84 MHz, 512KB flash
  • Motor Driver Shield — Dual stepper motor driver (A4988-based)
  • NEMA 17 Stepper Motors — 1.8° step angle, 200 steps/revolution
  • Limit Switches — Mechanical switches at X/Y axis boundaries
  • ADC Potentiometer — For manual position control demonstration

Software Architecture

src/
├── main.c                 # Application entry point
├── motor_control.c        # Stepper motor stepping logic
├── limit_switches.c      # Limit switch interrupt handlers
├── uart_comm.c           # UART command parsing
├── adc.c                 # ADC reading and characterization
└── gantry_control.c      # High-level X/Y coordinate control

Core Technical Challenges

Challenge 1: Interrupts vs. Polling

The requirement: Implement both interrupt-driven and polling-based limit switch detection, then compare performance.

Interrupt approach:

void EXTI0_IRQHandler(void) {
    if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) {
        // X-axis limit switch triggered
        gantry_stop_x();
        gantry_home_x();
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
    }
}

Polling approach:

while (1) {
    if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
        // Limit switch active
        gantry_stop_x();
        gantry_home_x();
    }
    // Continue with other tasks
}

Trade-off analysis:

ApproachLatencyCPU UsageComplexity
Interrupts~1µsLow (only when triggered)Higher (ISR context)
Polling~10-100µs (depends on loop frequency)ConstantLower

Decision: Use interrupts for limit switches (safety-critical, infrequent events) and polling for ADC reading (non-critical, periodic sampling).

Challenge 2: Stepper Motor Timing

Stepper motors require precise timing between step pulses. Too fast, and the motor misses steps. Too slow, and positioning is sluggish.

Step pulse timing:

void step_motor_x(void) {
    HAL_GPIO_WritePin(GPIOB, STEP_X_PIN, GPIO_PIN_SET);
    delay_us(10);  // Minimum pulse width
    HAL_GPIO_WritePin(GPIOB, STEP_X_PIN, GPIO_PIN_RESET);
    delay_us(step_delay_us);  // Variable delay for speed control
}

Speed vs. accuracy trade-off:

Step DelaySpeed (steps/sec)ReliabilityUse Case
1000µs1000100%Fine positioning
500µs200099.5%Normal operation
200µs500095%Rapid movement (risky)

Implementation: Variable step delay based on distance to target—slow down as approaching final position for accuracy.

Challenge 3: Coordinate System Management

The gantry needs to maintain its position in (X, Y) coordinates, but steppers only know step counts. We need to:

  • Convert (X, Y) coordinates to step counts
  • Track current position
  • Calculate step sequences for movement
  • Handle limit switches as coordinate system origin

Coordinate system design:

typedef struct {
    int32_t x_steps;      // Current X position in steps
    int32_t y_steps;      // Current Y position in steps
    float x_mm;           // Current X position in mm
    float y_mm;           // Current Y position in mm
    float steps_per_mm_x; // Calibration factor
    float steps_per_mm_y; // Calibration factor
} gantry_position_t;

Homing sequence:

  • Move X-axis until limit switch triggers → Set X = 0
  • Move Y-axis until limit switch triggers → Set Y = 0
  • Move to safe position (e.g., X=10mm, Y=10mm)

Movement algorithm:

void move_to_position(float target_x, float target_y) {
    int32_t dx_steps = (target_x - current.x_mm) * current.steps_per_mm_x;
    int32_t dy_steps = (target_y - current.y_mm) * current.steps_per_mm_y;
    // Bresenham's line algorithm for smooth diagonal movement
    move_diagonal(dx_steps, dy_steps);
}

UART Communication Protocol

The system accepts commands over UART for remote control:

Command format:

G28          # Home both axes
G1 X10 Y20   # Move to (10mm, 20mm)
G1 X5        # Move X to 5mm (Y unchanged)
M114         # Report current position
M106 S255    # Set motor speed (0-255)

Parser implementation:

void parse_uart_command(char* buffer) {
    if (strncmp(buffer, "G28", 3) == 0) {
        gantry_home();
    } else if (strncmp(buffer, "G1", 2) == 0) {
        float x = parse_float_after(buffer, 'X');
        float y = parse_float_after(buffer, 'Y');
        move_to_position(x, y);
    }
    // ... more commands
}

Why this protocol? Uses G-code subset (industry standard) for familiarity and extensibility.


ADC Characterization

Requirement: Characterize the ADC to understand its linearity and accuracy.

Test procedure:

  • Apply known voltages (0V, 1V, 2V, 3V) via potentiometer
  • Read ADC values
  • Plot ADC reading vs. voltage
  • Calculate linearity error

Results:

Voltage (V)ADC ReadingExpectedError
0.0000
1.0132513250
2.0265026500
3.0397539750
3.3409540950

Linearity: R² = 1.000 (perfect linearity within measurement precision)

Non-linearity sources:

  • Reference voltage drift (±0.1%)
  • Quantization error (±0.5 LSB)
  • Input impedance loading

Limit Switch Integration

Limit switches serve two purposes:

  • Safety — Prevent overtravel that could damage mechanics
  • Homing — Establish coordinate system origin

Hardware debouncing:

Mechanical switches bounce—multiple rapid on/off transitions when actuated. We need debouncing to avoid false triggers.

Software debouncing algorithm:

#define DEBOUNCE_TIME_MS 10
void limit_switch_isr(void) {
    static uint32_t last_trigger = 0;
    uint32_t now = HAL_GetTick();
    if (now - last_trigger > DEBOUNCE_TIME_MS) {
        // Valid trigger
        handle_limit_switch();
        last_trigger = now;
    }
}

Alternative: Hardware debouncing with RC filter (10ms time constant) + Schmitt trigger. We used software debouncing for simplicity.


Motor Control Implementation

Microstepping Configuration

The motor driver shield supports microstepping via MS1/MS2/MS3 pins:

MS1MS2MS3ModeSteps/rev
LLLFull step200
HLLHalf step400
LHL1/4 step800
HHL1/8 step1600
HHH1/16 step3200

Decision: Use 1/8 microstepping (1600 steps/rev) for balance between resolution and step rate.

Acceleration Profile

Abrupt starts/stops cause vibration and missed steps. Implemented linear acceleration:

void move_with_acceleration(int32_t total_steps, uint32_t max_delay_us) {
    uint32_t current_delay = max_delay_us;
    uint32_t min_delay = 200;  // Maximum speed
// Accelerate
for (int i = 0; i < total_steps / 3; i++) {
    step_motor(current_delay);
    current_delay = max(min_delay, current_delay - 10);
}
// Cruise
for (int i = total_steps / 3; i < 2 * total_steps / 3; i++) {
    step_motor(min_delay);
}
    // Decelerate
    for (int i = 2 * total_steps / 3; i < total_steps; i++) {
        step_motor(current_delay);
        current_delay = min(max_delay_us, current_delay + 10);
    }
}

Testing and Validation

Position Accuracy Test

Procedure:

  • Home the gantry
  • Move to known positions (10mm, 20mm, 30mm)
  • Measure actual position with calipers
  • Calculate error

Results:

Target (mm)Actual (mm)Error (mm)Error (%)
10.010.1+0.11.0%
20.019.9-0.10.5%
30.030.2+0.20.67%

Error sources:

  • Mechanical backlash in lead screws (~0.1mm)
  • Step size calibration error
  • Belt stretch (if belt-driven)

Repeatability Test

Procedure: Move to same position 10 times, measure variation.

Result: ±0.05mm repeatability (excellent for open-loop control)


Debugging Case Study: The ADC Pin Conflict

During development, we encountered a particularly challenging bug that took three hours to resolve and taught us valuable lessons about hardware/software interactions.

The Symptom

ADC 2 stopped working correctly after turning on the power supply, while ADC 1 continued functioning normally. The behavior was bizarre:

  • ADC 2 output would "snap" to the top or bottom range
  • Completely unresponsive to potentiometer rotation
  • Only worked when potentiometer was turned all the way to either extreme
  • Problem only appeared after power supply was turned on

The Debugging Process

We systematically eliminated hypotheses:

Hypothesis 1: Faulty potentiometer

  • Test: Swapped potentiometer with known-working one from ADC 1
  • Result: Problem persisted
  • Conclusion: Not the potentiometer

Hypothesis 2: Bad wiring

  • Test: Replaced all jumper wires
  • Result: Problem persisted
  • Conclusion: Not wiring

Hypothesis 3: Faulty microcontroller

  • Test: Replaced entire Nucleo board
  • Result: Problem persisted
  • Conclusion: Not the microcontroller

Hypothesis 4: Power supply issue

  • Test: Created minimal test code (just ADC, no motors) on spare board
  • Result: Worked perfectly, even with power supply on
  • Conclusion: Not power supply alone, but interaction between power supply and code

The Breakthrough

The key insight: The problem only occurred when both the motor shield was connected AND the power supply was on.

After examining the motor shield schematic, we discovered a pin conflict:

The Root Cause:

The ADC 2 pin was being driven by two sources simultaneously:

  • Potentiometer — Connected directly to ADC pin
  • Motor shield — Also driving the same pin (when powered)

When power was off:

  • Motor shield had no power → Potentiometer could drive the ADC line
  • ADC worked normally

When power was on:

  • Motor shield took control of the pin
  • Potentiometer couldn't drive the line (except at extremes where voltage was high/low enough)
  • ADC appeared "stuck" at extremes

The Fix:

Reconfigure ADC 2 to use a different pin that wasn't connected to the motor shield. After the change:

  • ADC 2 worked correctly regardless of power state
  • Both ADCs could be used simultaneously with motors running
  • Signal was stable and responsive

Lessons Learned

This debugging session reinforced several important principles:

  • Check pin configurations first — When unexpected behavior occurs, verify pin assignments against schematics before assuming hardware faults
  • Multiple components can conflict — Two devices driving the same line creates unpredictable behavior. Always check for pin conflicts when integrating multiple subsystems
  • Power state matters — Components behave differently when powered vs. unpowered. Test under actual operating conditions
  • Systematic elimination — Methodically testing each hypothesis prevented us from going down rabbit holes
  • Hardware/software boundary — The bug wasn't purely hardware or software—it was the interaction between them. Understanding both domains is essential

This experience highlighted that embedded systems debugging requires understanding not just code, but how code interacts with hardware, power domains, and peripheral configurations.


Lessons Learned

What Worked Well

  • Interrupt-driven limit switches — Immediate response, no missed triggers
  • Bresenham's algorithm for diagonal movement — Smooth, efficient path
  • G-code command protocol — Familiar, extensible
  • Modular code structure — Easy to test individual components

What I'd Improve

  • Closed-loop control — Add encoders for absolute position feedback
  • S-curve acceleration — Smoother than linear acceleration
  • Error recovery — Automatic retry on missed steps
  • Safety interlocks — Prevent movement if limit switches fail

Technical Specifications

ParameterValue
MicrocontrollerSTM32F401RE (ARM Cortex-M4)
Clock Speed84 MHz
Programming LanguageC (HAL library)
Motor TypeNEMA 17 stepper (1.8° step angle)
Microstepping1/8 (1600 steps/rev)
Resolution~0.05mm (depends on lead screw pitch)
CommunicationUART (115200 baud)
Limit Switches2 (X and Y axes)
ADC Resolution12-bit (0-3.3V)

Conclusion

This project provided hands-on experience with embedded systems fundamentals: interrupts, GPIO, UART, ADC, and motor control. The key engineering decisions—interrupt-driven limit switches for safety, microstepping for resolution, and G-code protocol for usability—created a robust, usable system.

The most valuable lesson was understanding the trade-offs between different approaches. Interrupts vs. polling isn't just a technical choice—it affects system responsiveness, CPU usage, and code complexity. Similarly, microstepping resolution vs. step rate is a fundamental trade-off in stepper motor control.

While this was an academic project, the principles apply directly to industrial automation systems. The same concepts—coordinate system management, limit switch integration, acceleration profiles—are used in commercial CNC machines and 3D printers.