Ultrasonic distance measurement is a common requirement in fields like robotics, quadcopters, and smart home devices. For short-range applications (approximately 2 to 300 cm) where environmental precision requirements are moderate, the HC-SR04 stands out as a classic, cost-effective solution. It simplifies the implementation of ultrasonic ranging by requiring only a single trigger pulse to emit a 40 kHz ultrasonic wave and return an Echo pulse whose duration corresponds to the measured distance. Common ARM-core MCUs like the APM32F402, with their rich GPIO and timer resources, are perfectly suited for driving the HC-SR04.
This article shares insights gained from implementing distance measurement using the HC-SR04 module with an APM32F402R-EVB development board.
2. A Refresher on HC-SR04 Principles: Transmit, Receive, and CalculateLet's briefly review the operating principle of the HC-SR04:
- Trig Pin: When this pin receives a high-level pulse of at least 10µs, the module emits an ultrasonic burst at approximately 40 kHz.
- Echo Pin: After detecting the reflected ultrasonic wave, the Echo pin transitions from low to high. It returns to a low state after the echo is received or a timeout occurs. The duration of this high-level state is equal to the round-trip time of the ultrasonic wave.
- Distance Formula:
d = (v × T) / 2
Wherev
is the speed of sound (approximately 350 m/s, varying slightly with temperature and humidity) andT
is the total duration of the Echo pin's high state. For example, if you capture an Echo high-level duration of 3 ms, the distance would be approximatelyd = (350 × 0.003) / 2 = 0.525
meters.
Module Advantages and Disadvantages:
- Advantages: Low cost and easy to use, suitable for general-purpose ranging needs within 2 cm to 300/400 cm.
- Disadvantages: Precision can fluctuate significantly with changes in ambient temperature, humidity, and airflow. It has a blind zone of about 2 cm. The success rate and accuracy decrease rapidly beyond 4-5 meters. The Echo pin outputs a 5V signal by default, which requires care to avoid damaging 3.3V MCU I/O pins.
In the initial learning phase, many developers use a standard GPIO pin to generate the Trig pulse and another GPIO to capture the rising and falling edges of the Echo pulse, using a timer's count to measure the pulse width. This method is direct and easy to understand.
- Trig: A GPIO is configured as an output. At regular intervals (e.g., every 60ms), the code pulls the pin high for >10µs and then low to trigger the HC-SR04.
- Echo: Another GPIO is configured in input mode to detect the rising and falling edges of the Echo signal. When a rising edge occurs, the current timer count is recorded. When the subsequent falling edge occurs, the count is recorded again, and the difference between the two values gives the pulse width.
The following code example is based on this approach:
- Trig = PB3 (Output)
- Echo = PB4 (Input)
/*!
* @brief Initialize GPIO-based HC-SR04 measurement.
* @param None
* @retval None
*/
void GPIO_HCSR04_Init(void)
{
GPIO_Config_T GPIO_ConfigStruct = {0U};
/* Enable the TRIG/ECHO Clock */
RCM_EnableAPB2PeriphClock(TRIG_GPIO_CLK);
RCM_EnableAPB2PeriphClock(ECHO_GPIO_CLK);
RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_AFIO);
GPIO_ConfigPinRemap(GPIO_REMAP_SWJ_JTAGDISABLE);
/* Configure the TRIG pin */
GPIO_ConfigStruct.pin = TRIG_PIN;
GPIO_ConfigStruct.mode = GPIO_MODE_OUT_PP;
GPIO_ConfigStruct.speed = GPIO_SPEED_50MHz;
GPIO_Config(TRIG_GPIO_PORT, &GPIO_ConfigStruct);
/* Configure the ECHO pin */
GPIO_ConfigStruct.pin = ECHO_PIN;
GPIO_ConfigStruct.mode = GPIO_MODE_IN_PD;
GPIO_Config(ECHO_GPIO_PORT, &GPIO_ConfigStruct);
/* Initialize TMR3 for 1us base */
TMR3_Config();
}
/**
* @brief Configure TMR3 as a 1 MHz counter to measure echo pulse width.
* TMR3 clock = APB1 Clock (e.g. 120 MHz) / Prescaler -> 1 MHz => 1 us per tick.
* @param None
* @retval None
*/
static void TMR3_Config(void)
{
/* Enable TMR3 clock */
RCM_EnableAPB1PeriphClock(RCM_APB1_PERIPH_TMR3);
TMR_BaseConfig_T tmrConfig;
// Prescaler to get 1 MHz from e.g. 120 MHz APB1 clock
tmrConfig.division = 120 - 1; // PSC = 119
tmrConfig.countMode = TMR_COUNTER_MODE_UP;
tmrConfig.period = 0xFFFF; // Max period for 16-bit timer
tmrConfig.clockDivision = TMR_CLOCK_DIV_1;
TMR_ConfigTimeBase(TMR3, &tmrConfig);
// Enable timer
TMR_Enable(TMR3);
}
/**
* @brief Trigger an ultrasonic pulse using GPIO and measure the echo width using TMR3.
* @note 1. TMR3 is configured as 1us time base in TMR3_Config().
* 2. The function will return a float distance in mm.
* 3. If echo signal fails or distance exceeds 4m, it returns 0.0f.
* @retval Distance in millimeters (float).
*/
float sonar_mm_gpio(void)
{
uint32_t time_end = 0;
float distance_mm = 0.0f;
// 1) Trigger >10us pulse
TRIG_GPIO_PIN_High();
BOARD_Delay_Us(15); // This function can be a rough delay
TRIG_GPIO_PIN_Low();
// 2) Wait for Echo rising edge
// Wait until the ECHO pin becomes high
while ((GPIO_ReadInputBit(ECHO_GPIO_PORT, ECHO_PIN) == BIT_RESET))
{
// Optional: Add a timeout check if needed
}
// 3) Reset TMR3 counter to 0 once rising edge is detected
TMR3->CNT = 0;
// 4) Wait for Echo falling edge
// Wait until the ECHO pin becomes low
while ((GPIO_ReadInputBit(ECHO_GPIO_PORT, ECHO_PIN) == BIT_SET))
{
// Optional: Add timeout check if needed (ECHO_TIMEOUT_US)
if (TMR3->CNT > ECHO_TIMEOUT_US)
{
return 0.0f; // Echo timeout
}
}
// 5) Read pulse width in microseconds
time_end = TMR3->CNT;
// 6) Calculate distance (in mm)
// Speed of sound ~350 m/s => 0.35 mm/us (round trip => half => 0.175 mm/us)
distance_mm = time_end * SOUND_SPEED_COEF;
// If over 4m => invalid measurement
if (distance_mm > 4000.0f)
{
distance_mm = 0.0f;
}
return (distance_mm);
}
The advantage of this method is its simplicity and ease of understanding. The disadvantage is that the CPU is busy-waiting and timing in software, which can be detrimental in multi-tasking or real-time scenarios. Furthermore, frequent measurements will cause the CPU to repeatedly enter a waiting state, which is inefficient.
4. Dual-Timer Driver: Letting Hardware Timers Automate the ProcessWhile the GPIO method is simple, it may lack the speed and stability required for multi-tasking or high-precision applications. Therefore, we can use two timers to perform this task.
- PB3 (TMR2_CH2) → PWM Output: Automatically generates a 10µs trigger pulse every 60ms.
- PB4 (TMR3_CH1) → Input Capture: Detects the duration of the Echo pulse with a 1MHz counter frequency (1µs precision).
Unlike the "brute-force" GPIO method, here the PWM hardware automatically handles pulling the pin high and low, and the input capture hardware records the timestamps of the rising and falling edges in registers. We only need to calculate the difference between these timestamps in an interrupt service routine to determine the Echo duration.
4.1 TMR2_CH2: Timed Output of the Trigger PulseFirst, we configure the PWM output on TMR2_CH2. With the code below, TMR2_CH2 will automatically generate a 10µs pulse every 60ms.
/**
* @brief Configures TMR2 CH2 on PB3 to output a PWM pulse of 15~16us
* every 60ms. This pulse is fed to the HC-SR04 trigger pin.
* @param None
* @retval None
*/
void TMR2_PWM_Trigger_Init(void)
{
GPIO_Config_T gpioConfig;
TMR_BaseConfig_T tmrBaseConfig;
TMR_OCConfig_T tmrOCConfig;
/* 1) Enable AFIO clock if needed and PB3 GPIO clock */
RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_AFIO);
RCM_EnableAPB2PeriphClock(TMR_TRIG_GPIO_CLK);
/* 2) Configure PB3 as Alternate Function Push-Pull for TMR2_CH2 */
gpioConfig.speed = GPIO_SPEED_50MHz;
gpioConfig.mode = GPIO_MODE_AF_PP; // Alternate function push-pull
gpioConfig.pin = TMR_TRIG_PIN;
GPIO_Config(TMR_TRIG_GPIO_PORT, &gpioConfig);
/* 3) Remap if the MCU requires switching TMR2_CH2 to PB3 */
TMR_TRIG_GPIO_AF();
/* 4) Enable TMR2 clock */
RCM_EnableAPB1PeriphClock(RCM_APB1_PERIPH_TMR2);
/*
* 5) Configure TMR2 base
* - Assume TMR2 clock source = 120 MHz
* - We want 1 MHz => PSC = 119 => 120 MHz / (119+1) = 1 MHz
* - Period = 59999 => 60,000 counts => 60 ms
* => Timer overflows every 60 ms
*/
tmrBaseConfig.countMode = TMR_COUNTER_MODE_UP;
tmrBaseConfig.clockDivision = TMR_CLOCK_DIV_1;
tmrBaseConfig.period = 59999; // ARR
tmrBaseConfig.division = 119; // PSC
tmrBaseConfig.repetitionCounter = 0; // Not used for general-purpose timers
TMR_ConfigTimeBase(TMR2, &tmrBaseConfig);
/*
* 6) Configure TMR2_CH2 for PWM mode
* - PWM1 mode => output is HIGH from 0 to CCR2
* - CCR2 = 16 => ~ 15us ~ 16us high pulse
*/
tmrOCConfig.mode = TMR_OC_MODE_PWM1;
tmrOCConfig.outputState = TMR_OC_STATE_ENABLE;
tmrOCConfig.outputNState = TMR_OC_NSTATE_DISABLE;
tmrOCConfig.polarity = TMR_OC_POLARITY_HIGH;
tmrOCConfig.nPolarity = TMR_OC_NPOLARITY_HIGH;
tmrOCConfig.idleState = TMR_OC_IDLE_STATE_RESET;
tmrOCConfig.nIdleState = TMR_OC_NIDLE_STATE_RESET;
tmrOCConfig.pulse = 16; // 16 => ~16us high level
TMR_ConfigOC2(TMR2, &tmrOCConfig);
/* 7) Enable PWM outputs if necessary */
TMR_EnablePWMOutputs(TMR2);
/* 8) Enable TMR2 counter */
TMR_Enable(TMR2);
}
4.2 TMR3_CH1: Calculating the High-Level Pulse DurationThe Echo pin is managed by TMR3. It is initialized as follows:
- Timer 3 base frequency = 1MHz.
- Channel 1 is configured for Input Capture, initially set to capture the rising edge. After the first capture, it is reconfigured to capture the falling edge. The difference between the two capture values is then stored in a global variable
echoWidth_us
.
Polarity Switching Macro: Manipulating CC1POLIn the APM32F402, the input capture polarity is controlled by the CC1POL
bit: 0 for rising edge, 1 for falling edge. We can create two simple macros for convenience.
// macros for TMR3 CH1
#define TMR3_IC1_POLARITY_RISING_ENABLE() (TMR3->CCEN_B.CC1POL = BIT_RESET)
#define TMR3_IC1_POLARITY_FALLING_ENABLE() (TMR3->CCEN_B.CC1POL = BIT_SET)
Timer Initialization
Using TIM3_CH1 for input capture.
/**
* @brief Configures TMR3 to capture the echo pulse on PB4 (TMR3_CH1).
* The timer runs at 1us per count; rising and falling edges
* are captured. The difference indicates the pulse width.
* @param None
* @retval None
*/
void TIM3_IC_EchoInit(void)
{
// 1) Enable TMR3 and PB4 clocks
RCM_EnableAPB1PeriphClock(RCM_APB1_PERIPH_TMR3);
RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_AFIO);
RCM_EnableAPB2PeriphClock(TMR_ECHO_GPIO_CLK);
// 2) Configure PB4 as input (pull-down or floating as needed)
GPIO_Config_T gpioConfig;
gpioConfig.pin = TMR_ECHO_PIN;
gpioConfig.mode = GPIO_MODE_IN_PD;
gpioConfig.speed = GPIO_SPEED_50MHz;
GPIO_Config(TMR_ECHO_GPIO_PORT, &gpioConfig);
// Remap or pin AF if needed
TMR_ECHO_GPIO_AF();
/*
* 3) Set up TMR3 as 1us time base
* - TMR3 clock = 120 MHz
* - PSC = 120 - 1 => 119 => 120MHz/(119+1)=1MHz => 1 tick = 1us
* - ARR = 0xFFFF => 16-bit full range
*/
TMR_BaseConfig_T base;
base.countMode = TMR_COUNTER_MODE_UP;
base.clockDivision = TMR_CLOCK_DIV_1;
base.period = 0xFFFF; // 65535
base.division = (120 - 1); // PSC=119
base.repetitionCounter = 0;
TMR_ConfigTimeBase(TMR3, &base);
/*
* 4) Configure Channel 1 for input capture
* - Polarity: rising edge first
* - Then switch to falling edge after capturing rising
*/
TMR_ICConfig_T ic;
ic.channel = TMR_CHANNEL_1;
ic.polarity = TMR_IC_POLARITY_RISING;
ic.selection = TMR_IC_SELECTION_DIRECT_TI;
ic.prescaler = TMR_IC_PSC_1;
ic.filter = 0;
TMR_ConfigIC(TMR3, &ic);
/* 5) Enable update interrupt (for overflow) and CC1 interrupt */
TMR_EnableInterrupt(TMR3, TMR_INT_UPDATE);
TMR_EnableInterrupt(TMR3, TMR_INT_CC1);
NVIC_EnableIRQRequest(TMR3_IRQn, 0xF, 0xF);
/* 6) Start TMR3 */
TMR_Enable(TMR3);
}
Interrupt Service Routine
Implementation of the capture logic.
/**
* @brief TMR3 IRQ Handler for echo pulse capture.
* - On update event => overflowCount++
* - On CC1 event => capture rising/falling edges and compute the difference
* @param None
* @retval None
*/
void TMR3_IRQHandler(void)
{
/* 1) Check overflow => increment overflowCount */
if (TMR_ReadIntFlag(TMR3, TMR_INT_UPDATE))
{
overflowCount++;
TMR_ClearIntFlag(TMR3, TMR_INT_UPDATE);
}
/* 2) Check CC1 capture => read CCR1 */
if (TMR_ReadIntFlag(TMR3, TMR_INT_CC1))
{
uint32_t ccrVal = TMR_ReadCaputer1(TMR3);
/* First capture: rising edge => record CCR1, reset overflowCount,
switch to falling edge next time */
if (captureIndex == 0)
{
captureVal[0] = ccrVal;
captureIndex = 1;
overflowCount = 0;
/* Switch to falling edge for next capture */
TMR3_IC1_POLARITY_FALLING_ENABLE();
}
else
{
/* Second capture: falling edge => compute pulse width */
captureVal[1] = ccrVal;
uint32_t totalCnt = (overflowCount << 16) +
(captureVal[1] - captureVal[0]);
/* Store the elapsed counts (each count = 1us) */
echoWidth_us = totalCnt;
/* Reset for next measurement */
captureIndex = 0;
overflowCount = 0;
TMR3_IC1_POLARITY_RISING_ENABLE();
}
TMR_ClearIntFlag(TMR3, TMR_INT_CC1);
}
}
Calculating the Distance
Call the calculation function where the distance is needed.
/**
* @brief Converts the measured pulse width (in us) into distance (in mm).
* - Speed of sound is ~350 m/s => 0.35 mm/us round trip.
* - Divided by 2 => 0.175 mm/us one-way => multiply time by 0.175.
* @param None
* @retval Distance in millimeters (float).
*/
float sonar_mm_tmr(void)
{
float distance_mm = 0.0f;
/* Convert echoWidth_us to mm => time(us) * 0.175 mm/us */
distance_mm = echoWidth_us * SOUND_SPEED_COEF;
/* Discard invalid if distance > 4m */
if (distance_mm > 4000.0f)
{
distance_mm = 0.0f;
}
return distance_mm;
}
5. Results: How Accurate is the Measurement?We connected a logic analyzer to measure the ECHO waveform width for a fixed distance to compare the results of both methods.
- GPIO Method:
- The logic analyzer measured a high-level duration of 3490µs, which matched the program's measurement.
- Timer Method:
- The logic analyzer measured a high-level duration of 3515µs, which also matched the program's measurement.
Both solutions provide sufficient accuracy for general-purpose use. However, for applications requiring higher precision or less CPU intervention, the "hardware capture" timer solution holds a distinct advantage.
By using a serial port, we can print the corresponding distance measurements to a serial monitor.
Whether using the GPIO or the timer solution, the core algorithm is to capture the duration of the Echo's high-level pulse and then calculate the distance using the formula d = (v × t) / 2
. You can choose the implementation method based on your project's requirements:
- For simple validation: You can directly use GPIO polling and software delays.
- For more professional and stable ranging, or to free up the MCU for other tasks: The "PWM Trigger + Hardware Input Capture" dual-timer solution is recommended to maximize the use of hardware resources.
This concludes the experience sharing on mixed-signal ranging with the APM32F402R-EVB and HC-SR04. Feel free to leave comments and discuss below!
Comments