Prerequisite: 04-discrete-time-control.md (discrete PID, sampling)
Unlocks: 06-fixed-point-pid.md (software that runs on this hardware)
The PID controller computes a number. But the motor needs current flowing through copper. Between the PID output and actual motor torque, there’s an H-bridge, a current sensor, a PWM generator, and an encoder. When any of these misbehaves, the PID is flying blind.
Real AMR failures traced to hardware: - Encoder connector vibration → intermittent count loss → speed estimate spikes → PID overreacts → oscillation - Current sensor offset drift with temperature → steady-state current error → motor runs hot - PWM dead-time too short → H-bridge shoot-through → FET damage
A DC motor needs variable voltage. You could use a linear regulator, but it wastes power as heat. PWM switches the full supply voltage on and off rapidly. The motor’s inductance acts as a low-pass filter, averaging the pulses into effective DC.
Effective voltage: $V_{eff} = V_{supply} \times D$
where $D$ = duty cycle (0.0 to 1.0). At $D = 0.5$, the motor sees half the supply voltage.
PWM at 50% duty cycle:
V ───┐ ┌───┐ ┌───┐ ┌───
│ │ │ │ │ │ │
│ │ │ │ │ │ │
0 ───┘ └───┘ └───┘ └───
├─Ton─┤Toff├─Ton─┤
D = Ton / (Ton + Toff) = 0.5
f_pwm = 1 / (Ton + Toff)
| Frequency | Motor effect | Audio | AMR choice |
|---|---|---|---|
| 1–4 kHz | High current ripple, inefficient | Audible whine | ❌ |
| 10–20 kHz | Moderate ripple, good efficiency | Barely audible | ✅ 20 kHz |
| 50–100 kHz | Low ripple, switching losses increase | Silent | ❌ (FET losses) |
AMR uses 20 kHz — just above human hearing, low enough for efficient FET switching.
Current ripple at PWM frequency $f_{pwm}$:
$$\Delta I = \frac{V_{supply} \cdot D \cdot (1-D)}{L \cdot f_{pwm}}$$
At 20 kHz, $V_{supply} = 12$ V, $L = 1$ mH, $D = 0.5$: $\Delta I = \frac{12 \times 0.25}{0.001 \times 20000} = 0.15$ A peak-to-peak.
At 50 kHz, the ripple drops to 0.06 A — but the FET switching losses triple.
The STM32’s hardware timer generates PWM without CPU intervention:
// STM32 HAL - TIM1 configured for center-aligned PWM at 20 kHz
// System clock = 168 MHz, prescaler = 0, ARR = 4199
// Center-aligned → effective PWM freq = 168e6 / (2 × 4200) = 20 kHz
void motor_pwm_init(void) {
TIM_HandleTypeDef htim1;
htim1.Instance = TIM1;
htim1.Init.Prescaler = 0;
htim1.Init.CounterMode = TIM_COUNTERMODE_CENTERALIGNED1;
htim1.Init.Period = 4199; // ARR value
htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
HAL_TIM_PWM_Init(&htim1);
// Channel 1: motor PWM
TIM_OC_InitTypeDef oc_config;
oc_config.OCMode = TIM_OCMODE_PWM1;
oc_config.Pulse = 0; // Start at 0% duty
oc_config.OCPolarity = TIM_OCPOLARITY_HIGH;
HAL_TIM_PWM_ConfigChannel(&htim1, &oc_config, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);
}
void motor_set_duty(float duty) {
// duty: -1.0 to +1.0
uint16_t ccr = (uint16_t)(fabsf(duty) * 4199.0f);
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, ccr);
// Set direction pin based on sign
HAL_GPIO_WritePin(DIR_PORT, DIR_PIN, duty >= 0 ? GPIO_PIN_SET : GPIO_PIN_RESET);
}
Center-aligned PWM is preferred for motor control because the ADC can sample current at the center of the PWM period (when all switches are in a known state), giving a clean current measurement.
An H-bridge uses 4 switches (MOSFETs) to control current direction through the motor:
V+
│
┌───┴───┐
│ │
Q1 Q3
│ │
├── M ──┤ M = Motor
│ │
Q2 Q4
│ │
└───┬───┘
│
GND
Forward: Q1 ON, Q4 ON, Q2 OFF, Q3 OFF → current flows left to right
Reverse: Q3 ON, Q2 ON, Q1 OFF, Q4 OFF → current flows right to left
Brake: Q1 ON, Q3 ON (or Q2 ON, Q4 ON) → motor shorted → dynamic braking
Coast: All OFF → motor coasts (back-EMF drives current through body diodes)
If Q1 and Q2 are BOTH on simultaneously → short circuit from V+ to GND → shoot-through → massive current → FET destruction.
Dead time is a brief period (0.5–2 µs) when BOTH switches in a leg are OFF during transitions:
Q1: ────┐ ┌────
│ │
Q2: └─┐ ┌─┘
│ │
├───┤ ← dead time (both OFF)
AMR STM32 dead time: 1 µs, configured in TIM1’s BDTR register:
TIM_BreakDeadTimeConfigTypeDef sBreakDeadTimeConfig;
sBreakDeadTimeConfig.DeadTime = 168; // 1 µs at 168 MHz
HAL_TIMEx_ConfigBreakDeadTime(&htim1, &sBreakDeadTimeConfig);
Dead-time distortion: During dead time, the output voltage is neither high nor low — it depends on current direction. This creates a nonlinearity, especially visible at low duty cycles. The PID controller sees this as a dead zone near zero speed. Compensation:
// Dead-time compensation
float compensated_duty = duty;
if (fabsf(duty) > 0.01f) {
float dead_time_comp = DEAD_TIME_US * PWM_FREQ * 2.0f; // normalized
compensated_duty += (duty > 0) ? dead_time_comp : -dead_time_comp;
}
| Method | Principle | AMR usage | Pros/Cons |
|---|---|---|---|
| Shunt resistor | Low-R in series, measure voltage | ✅ Low-side | Cheap, fast; power loss = $I^2 R$ |
| Hall effect | Magnetic field sensing | ❌ | No power loss; expensive, slow |
| Inline (high-side) | Shunt on V+ side | Some models | Sees all current; needs differential amp |
AMR low-side shunt sensing:
V+
│
Q1, Q3 (H-bridge)
│
MOTOR
│
Q2, Q4 (H-bridge)
│
┤R_shunt├──→ to ADC (via op-amp)
│
GND
$R_{shunt}$ = 10–50 mΩ. At 3A: $V_{sense} = 3 \times 0.02 = 60$ mV. This needs amplification.
Current sense amplifier:
// AMR current sense: 20 mΩ shunt, 50× gain amplifier
// ADC: 12-bit, 3.3V reference
// I = (ADC_value / 4096 * 3.3 - offset) / (R_shunt * gain)
#define R_SHUNT 0.020f // 20 mΩ
#define AMP_GAIN 50.0f // INA180 or similar
#define ADC_VREF 3.3f
#define ADC_RES 4096.0f
#define V_OFFSET 1.65f // Midpoint for bidirectional sensing
float read_motor_current(uint16_t adc_raw) {
float voltage = (float)adc_raw / ADC_RES * ADC_VREF;
float current = (voltage - V_OFFSET) / (R_SHUNT * AMP_GAIN);
return current; // Amps, signed
}
Sample the ADC at the center of the PWM period (when the low-side FETs are on and current is flowing through the shunt):
PWM: ──┐ ┌──────────┐ ┌──
│ │ │ │
└────┘ └────┘
▲ ▲
│ │
ADC sample ADC sample
(center) (center)
STM32 can trigger ADC from the timer’s center event automatically — no CPU involvement.
A quadrature encoder outputs two square waves (A and B) shifted by 90°:
Forward rotation:
A: ──┐ ┌──┐ ┌──┐ ┌──
│ │ │ │ │ │
└──┘ └──┘ └──┘
B: ──┐ ┌──┐ ┌──┐ ┌──
│ │ │ │ │ │
└──┘ └──┘ └──┘
↑
B leads A by 90°
Reverse rotation:
A: ──┐ ┌──┐ ┌──┐ ┌──
│ │ │ │ │ │
└──┘ └──┘ └──┘
B: ──┐ ┌──┐ ┌──┐ ┌──
│ │ │ │ │ │
└──┘ └──┘ └──┘
↑
A leads B by 90°
4× decoding counts all edges of both A and B channels:
| A | B | Event | Direction |
|---|---|---|---|
| ↑ | 0 | Count +1 | Forward |
| ↑ | 1 | Count -1 | Reverse |
| ↓ | 0 | Count -1 | Reverse |
| ↓ | 1 | Count +1 | Forward |
STM32 encoder mode does this in hardware:
void encoder_init(void) {
TIM_HandleTypeDef htim3;
htim3.Instance = TIM3;
TIM_Encoder_InitTypeDef encoder_config;
encoder_config.EncoderMode = TIM_ENCODERMODE_TI12; // 4× mode
encoder_config.IC1Polarity = TIM_ICPOLARITY_RISING;
encoder_config.IC2Polarity = TIM_ICPOLARITY_RISING;
encoder_config.IC1Filter = 0x05; // Noise filter: 8 clock cycles
HAL_TIM_Encoder_Init(&htim3, &encoder_config);
HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL);
}
int32_t encoder_get_count(void) {
return (int16_t)__HAL_TIM_GET_COUNTER(&htim3);
}
static int32_t prev_count = 0;
float compute_speed_rad_per_sec(float dt) {
int32_t count = encoder_get_count();
int32_t diff = count - prev_count;
prev_count = count;
// Handle 16-bit timer overflow
if (diff > 32767) diff -= 65536;
if (diff < -32767) diff += 65536;
float rad_per_count = 2.0f * M_PI / (float)(ENCODER_CPR * 4);
return (float)diff * rad_per_count / dt;
}
| Failure | Symptom | AMR ticket |
|---|---|---|
| Loose connector | Intermittent zero-speed spikes | Incident-A |
| Electrical noise | Random count jumps (1000+ counts in one sample) | Case-E |
| Index pulse loss | Cumulative position drift | Commissioning issue |
| One channel dead | Speed always positive (can’t detect direction) | Case-F |
Noise filter: The STM32 encoder mode has a configurable digital filter (IC1Filter, IC2Filter). Set to 0x05 (8 clock cycles) → ignores glitches shorter than 48 ns at 168 MHz.
┌─────────────────────────────────────────────┐
│ STM32 MCU │
│ │
speed_cmd ──SPI──→│ Speed PID ──→ Current PID ──→ PWM Register │
│ ↑ ↑ │ │
│ │ │ ▼ │
│ Encoder ◄── ADC ◄── TIM1 PWM out │
│ (TIM3) (DMA) │ │
└─────────────────────────────│─────────────┘
│
┌─────────────────────────────│─────────────┐
│ POWER STAGE │ │
│ ▼ │
│ V_bat ──→ H-Bridge ──→ Motor ──→ Encoder │
│ │ │ │
│ Shunt R ──→ Amp │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ to ADC to ADC to TIM3 │
└────────────────────────────────────────────┘
Timing budget (per 10 kHz cycle = 100 µs):
| Step | Time | How |
|---|---|---|
| ADC current sample | 1 µs | DMA-triggered by timer center event |
| Encoder read | 0.1 µs | Hardware register read |
| Speed computation | 0.5 µs | Integer subtract + multiply |
| Speed PID | 0.5 µs | 3 multiply-adds |
| Current PID | 0.3 µs | 2 multiply-adds |
| PWM update | 0.1 µs | Register write |
| Total | ~2.5 µs | 2.5% of 100 µs budget |
The remaining 97.5% is available for SPI communication, diagnostics, watchdog, and other tasks.