오늘은 cortex-M 보드를 이용한 간단한 서보 모터 제어 구현을 기록하겠습니다.!

STM32F401RE의 Datasheet와 Reference Manual의 Pin descriptions를 기반으로 기능 구현을 위한 임의의 Pin을 선택했습니다.
PinCtrl
| 핀 | 기능 | 사용 채널/타이머 | 근거 문서 및 페이지 |
|---|---|---|---|
| PA0 | TIM2_CH1 (PWM 출력) | servo1 _sig | Datasheet DS10086 Rev 4 : Table 9 (Pin definitions) p.47: PA0 = TIM2_CH1 (AF1) RM0368: Table 9 (Alternate function mapping) p.68: TIM2_CH1 가능 |
| PA8 | TIM1_CH1 (PWM 출력) | servo2 _sig | Datasheet: Table 9 p.48: PA8 = TIM1_CH1 (AF1) RM0368 : Table 9 p.69: TIM1_CH1 가능 |
| PB0 | ADC1_IN8 (아날로그 입력) | 조이스틱 VRx (X축) | Datasheet : Table 9 p.49: PB0 = ADC1_IN8 RM0368, Ch.11 ADC, Table 48 (ADC pin assignments) p.288: PB0 = ADC1_IN8 |
| PB1 | ADC1_IN9 (아날로그 입력) | 조이스틱 VRy (Y축) | Datasheet, Table 9 p.49: PB1 = ADC1_IN9 RM0368, Table 48 p.288: PB1 = ADC1_IN9 |
| PB2 | GPIO Input (Pull-up) | 조이스틱 SW (버튼) | Datasheet, Table 9,p.49: PB2 = GPIO (일반 입력 가능) RM0368, Ch.7 GPIO, p.155~157: 입력 모드 및 내부 풀업 지원 |

Project Configuration
==========================================
1. Clock Configuration
HCLK = 84 MHz (기본 설정 그대로 사용 )
2. Timers → TIM2
Clock Source → Internal Clock
Channel1 → PWM Generation CH1(PA0)
Configuration
Prescaler : 83
Counter Period (ARR) : 19999
Pulse : 1500 (초기 90도)
Mode : PWM mode 1
Polarity : High
-----------------------------
Timers → TIM1
Clock Source: Internal
Channel1: PWM Generation CH1 (PA8)
Configuration
Prescaler : 83
Counter Period (ARR) : 19999
Pulse : 1500 (초기 90도)
Mode : PWM mode 1
Polarity : High
3. GPIO
PA0 → TIM2_CH1 (자동 설정됨)
PA8 → TIM1_CH1 (자동 설정됨)
PB2 → GPIO_Input + Pull-up (Joystick SW Btn)
~~#PA5 → Output (LED용, 선택)~~
4. ADC1
Parameter Settings → Continuous Conversion Mode 체크
Number of Conversion: 2
Rank1: IN8 (PB0)
Rank2: IN9 (PB1)
4. USART2 (선택, 디버그용)
Mode → Asynchronous
Baud Rate → 115200
PA2 → USART2_TX


HW-504 조이스틱은 2개의 potentiometerVRx, VRy)와 Push 버튼(SW)으로 구성.
potentiometer: X/Y축 움직임에 따라 저항 변화 → 아날로그 전압 출력 0~3.3V, 중앙 ≈1.65V ⇒ ADC 12bit 중앙값 : = 2048 ADC 값)⇒ STM32 ADC로 아날로그 값을 디지털화하여 읽음. Continuous Mode + Polling 방식으로 실시간 value read
CONT 비트를 1로 설정하면 연속 모드 ON
시작 방법 2가지
ADC Result
- 마지막 변환 결과가 ADC_DR 레지스터 (16비트)에 저장됨
- EOC 플래그 (End Of Conversion)가 1이 됨 → "변환 끝났어요!" 신호
- 만약 EOCIE 비트가 1이면 → 인터럽트 발생 (CPU가 "오! 데이터 왔네" 하고 깨어남)
시간축 -------------------------------------------------------------->
[START 명령] ────────────────► (HAL_ADC_Start() 또는 SWSTRT=1)
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
Conversion 1 │ 변환 중... │ │ 변환 중... │ │ 변환 중... │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
EOC 플래그 1 EOC 플래그 1 EOC 플래그 1
ADC_DR 업데이트 ADC_DR 업데이트 ADC_DR 업데이트
▼ ▼ ▼
└───────► 자동으로 다음 변환 시작 (Continuous 모드의 핵심!)
(CONT=1이면 멈추지 않고 무한 반복)
인터럽트나 DMA가 켜져 있으면:
EOC마다 인터럽트 발생 (EOCIE=1 시)
또는 DMA가 자동으로 ADC_DR 값을 메모리(adc_values)에 복사
**DMA (11.8.1):** "Converted regular channel values are stored in memory by DMA without CPU intervention." ⇒ 추가적으로 구현 시 내용 업데이트 예정
hadc1.Init.ScanConvMode = ENABLE; // 여러 채널 순차 변환 허용 (2채널 스캔)
hadc1.Init.ContinuousConvMode = ENABLE; // 한 번 시작하면 자동 연속 변환 (멈추지 않음)
hadc1.Init.NbrOfConversion = 2; // 변환할 채널 수: 2개 (Rank1 + Rank2)
hadc1.Init.DMAContinuousRequests = ENABLE; // DMA가 계속 요청 가능 (하지만 지금은 폴링 쓰는 중)
sConfig.Channel = ADC_CHANNEL_8; // Rank 1: PB0 (VRx)
sConfig.Rank = 1;
HAL_ADC_ConfigChannel(&hadc1, &sConfig);
sConfig.Channel = ADC_CHANNEL_9; // Rank 2: PB1 (VRy)
sConfig.Rank = 2;
HAL_ADC_ConfigChannel(&hadc1, &sConfig);HAL_ADC_Start(&hadc1); // ADC 변환 시작
// 첫 번째 ADC 값 읽기 테스트
if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK)
{
adc_values[0] = HAL_ADC_GetValue(&hadc1); // 첫 번째 채널 (Rank 1)
}
if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK)
{
adc_values[1] = HAL_ADC_GetValue(&hadc1); // 두 번째 채널 (Rank 2)
}HAL_ADC_GetValue() → ADC_DR 레지스터에서 최신 변환 값을 가져온다
HAL_ADC_Stop(&hadc1); // 이전 변환 강제 종료
HAL_Delay(1); // 안정화 대기 (짧게)
HAL_ADC_Start(&hadc1); // 새 변환 시작
HAL_ADC_PollForConversion(&hadc1, 50); // Rank1 변환 완료 기다림
adc_values[0] = HAL_ADC_GetValue(&hadc1); // X값 저장
HAL_ADC_PollForConversion(&hadc1, 50); // Rank2 변환 완료 기다림
adc_values[1] = HAL_ADC_GetValue(&hadc1); // Y값 저장
HAL_ADC_PollForConversion(&hadc1, 50); ⇒ EOC 값이 1 return까지 wait
이 때 50ms 이상 대기시 그냥 넘어간다 timeout!!
⇒ adc_values 에 X, Y 값이 들어간다 .
const int center_x = 2048; // X축 중앙값
const int center_y = 2048; // Y축 중앙값
const int deadzone = 100; // 흔들림 방지
조이스틱 중앙에 있을 때 이론적:v ADC 2048 , 하지만 오차로 1980~2100 사이 나올 수 있다.
시리얼 모니터로 X/Y 값 확인 → 그 값을 center_x/y로 바꿈 시리얼에 "X=2010" 계속 나오면 → center_x = 2010;으로 수정
deadzone: 중앙 ±100 범위는 움직이지 않게 해서 미세 떨림 방지
보정 적용
if (x > center_x + deadzone) // 오른쪽으로 많이 기울면
pulse1 = 1500 + (x - center_x - deadzone) * 1000 / (4095 - center_x - deadzone);
else if (x < center_x - deadzone) // 왼쪽으로 많이 기울면
pulse1 = 1500 - (center_x - deadzone - x) * 1000 / (center_x - deadzone);
→ 중앙에서 멀어질수록 펄스 폭이 1500us 기준으로 ±1000us 변함 (500~2500us 범위)


SG90 서보는 PWM 신호로 제어.
PWM 주기 20ms (50Hz), 펄스 폭(Duty Cycle)으로 각도 결정
Duty_cycle : PWM 주기 안에서 High(ON) 시간의 비율(%)
Datasheet 근거
TIMx_ARR로 주파수(예: 50Hz)를 정하고, TIMx_CCRx로 듀티 사이클(펄스 폭)을 정해서 원하는 신호를 만들 수 있다 .→ HAL_TIM_PWM_Start () 함수 정의를 확인
MX_TIMx_Init ())// MX_TIM2_Init() - 서보1 (PA0, TIM2_CH1)
htim2.Init.Prescaler = 83; // 클럭 분주 (84MHz / 84 = 1MHz)
htim2.Init.Period = 19999; // 주기 = 20ms (50Hz) → 서보 표준
sConfigOC.OCMode = TIM_OCMODE_PWM1; // PWM mode 1 (정상 방향)
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; // High active
// MX_TIM1_Init() - 서보2 (PA8, TIM1_CH1)
htim1.Init.Prescaler = 83;
htim1.Init.Period = 19999; // 동일하게 20ms
sConfigOC.OCMode = TIM_OCMODE_PWM1;HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); // PA0 - 서보1 시작
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); // PA8 - 서보2 시작uint32_t pulse1 = 1500; // 기본값 = 1.5ms (중앙 90°)
if (x > center_x + deadzone) { // 조이스틱 오른쪽으로 기울면
pulse1 = 1500 + (x - center_x - deadzone) * 1000 / (4095 - center_x - deadzone);
} else if (x < center_x - deadzone) { // 왼쪽으로 기울면
pulse1 = 1500 - (center_x - deadzone - x) * 1000 / (center_x - deadzone);
}
if (pulse1 < 500) pulse1 = 500; // 최소 0.5ms (0°)
if (pulse1 > 2500) pulse1 = 2500; // 최대 2.5ms (180°) → 조이스틱 움직임 → 펄스 폭 변화 (0.5ms ~ 2.5ms 범위)SG90은 물리적으로 0.5~2.5ms 펄스만 허용
이 범위를 넘으면 기어 손상 위험 → 코드에서 강제 제한
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pulse1); // 서보1 (PA0)
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, pulse2); // 서보2 (PA8)
TIMx_CCRx 레지스터에 pulse 값을 쓰면 타이머가 자동으로 CCR 값만큼 High 유지
→ 듀티 사이클 변경
서보 내부 회로가 이 펄스 폭을 읽어서 각도로 변환 → 회전
HAL_Delay(20)) 로 20ms 지연 → 펄스 주기에 맞게 delay 사용