2-Axis Gantry Control System
Embedded control system for precise two-axis positioning using STM32 Nucleo
2024 • c, stm32, embedded-systems, motor-control
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 controlCore 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:
| Approach | Latency | CPU Usage | Complexity |
|---|---|---|---|
| Interrupts | ~1µs | Low (only when triggered) | Higher (ISR context) |
| Polling | ~10-100µs (depends on loop frequency) | Constant | Lower |
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 Delay | Speed (steps/sec) | Reliability | Use Case |
|---|---|---|---|
| 1000µs | 1000 | 100% | Fine positioning |
| 500µs | 2000 | 99.5% | Normal operation |
| 200µs | 5000 | 95% | 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 Reading | Expected | Error |
|---|---|---|---|
| 0.0 | 0 | 0 | 0 |
| 1.0 | 1325 | 1325 | 0 |
| 2.0 | 2650 | 2650 | 0 |
| 3.0 | 3975 | 3975 | 0 |
| 3.3 | 4095 | 4095 | 0 |
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 10void 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:
| MS1 | MS2 | MS3 | Mode | Steps/rev |
|---|---|---|---|---|
| L | L | L | Full step | 200 |
| H | L | L | Half step | 400 |
| L | H | L | 1/4 step | 800 |
| H | H | L | 1/8 step | 1600 |
| H | H | H | 1/16 step | 3200 |
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.0 | 10.1 | +0.1 | 1.0% |
| 20.0 | 19.9 | -0.1 | 0.5% |
| 30.0 | 30.2 | +0.2 | 0.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
| Parameter | Value |
|---|---|
| Microcontroller | STM32F401RE (ARM Cortex-M4) |
| Clock Speed | 84 MHz |
| Programming Language | C (HAL library) |
| Motor Type | NEMA 17 stepper (1.8° step angle) |
| Microstepping | 1/8 (1600 steps/rev) |
| Resolution | ~0.05mm (depends on lead screw pitch) |
| Communication | UART (115200 baud) |
| Limit Switches | 2 (X and Y axes) |
| ADC Resolution | 12-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.