저전력 설계 기초
이 장에서 배우는 것
앞 장에서는 SPI로 짧은 시간에 많은 데이터를 주고받는 법을 다뤘다. 이번 장은 반대 방향을 본다. 스마트 화분 펌웨어가 실제로 하는 일은 "가끔 측정하고 대부분은 아무것도 안 하는" 패턴이다. MCU가 아무것도 안 할 때 얼마나 적은 전류를 쓰는지, 그리고 다시 필요할 때 어떻게 깨어나는지를 설계하지 않으면 배터리 수명 계산은 항상 틀린다. 이 장은 절전 모드, 깨우기 원인(wake source), 평균 전류 계산을 PC 시뮬레이션으로 재현한다.
- ACTIVE·SLEEP·STANDBY 절전 모드가 전류·깨우기 지연·유지되는 자원 면에서 어떻게 다른지 구분한다
- 타이머와 외부 핀 등 깨우기 원인을 설계하고 hal_sim으로 시간 흐름을 시뮬레이션한다
- 구간별 전류와 시간을 누적해 평균 전류와 배터리 수명을 계산한다
- PC 시뮬레이션과 실제 보드(STM32·ESP32·아두이노)의 저전력 동작 차이를 표로 정리한다
문제 상황
스마트 화분을 코인전지(예: CR2032, 정격 220mAh)로 돌리기로 했다고 하자. 펌웨어는 10분마다 온습도를 읽고, 필요하면 펌프를 짧게 돌린다. 측정 자체는 40ms밖에 걸리지 않으니 "전류는 거의 안 쓰겠지"라고 생각하기 쉽다. 그런데 막상 만들어 보면 배터리가 며칠 만에 바닥난다.
원인은 간단하다. MCU를 절전 모드로 내려보내지 않고 그냥 while 루프에서 시간만 재고 있으면, 측정하지 않는 나머지 9분 59초 960ms 동안도 CPU가 계속 ACTIVE 상태로 수 mA를 먹는다. 40ms짜리 측정은 전체 시간의 0.007%도 안 되는데, 나머지 99.993% 구간의 전류가 배터리 수명을 결정한다. 이 장은 그 나머지 구간을 절전 모드로 내려보내고, 다시 깨어날 방법을 마련하고, 그 결과로 배터리가 며칠 버티는지 숫자로 확인하는 과정을 다룬다.
슬립 모드와 깨우기 원인
절전 모드는 보통 몇 단계로 나뉜다. 이 장에서는 이름을 ACTIVE, SLEEP, STANDBY 세 단계로 단순화한다. 단계가 깊어질수록 꺼지는 클록과 주변장치가 많아지고, 그만큼 전류는 줄지만 다시 깨어나는 데 걸리는 시간과 깨울 수 있는 방법도 줄어든다.
ACTIVE는 CPU가 명령을 실행하는 상태다. SLEEP은 CPU 클록만 멈추고 SRAM과 대부분의 주변장치는 그대로 살아 있어서, 어떤 인터럽트가 와도 곧바로 깨어난다. STANDBY는 RTC(실시간 시계)나 지정된 웨이크 핀처럼 극소수 블록만 남기고 나머지 전원을 끊는다. 대신 깨울 수 있는 방법이 그 극소수로 제한되고, 클록을 다시 켜는 시간만큼 깨우기 지연도 커진다.
깨우기 원인(wake source)은 절전 모드를 설계할 때 전류만큼 중요하다. 흔히 쓰는 원인은 두 가지다. 하나는 RTC 알람 같은 타이머로, "10분마다 깨어나 측정한다"처럼 주기적인 작업에 쓴다. 다른 하나는 외부 핀 인터럽트로, "물이 부족하면 즉시 깨어나 알린다"처럼 비주기적인 이벤트에 쓴다. 중요한 점은, 선택한 절전 모드에서 그 깨우기 원인에 필요한 클록과 전원 도메인이 실제로 살아 있어야 한다는 것이다. 이 관계를 그림으로 정리한다.
| 모드 | 대략적 전류 | 깨우기 지연 | 깨어날 수 있는 원인 |
|---|---|---|---|
| ACTIVE | 수~수십 mA(예제 8mA) | 없음(이미 실행 중) | 해당 없음 |
| SLEEP | mA대(예제 1.2mA) | 수 µs~수십 µs | 타이머, 통신 수신, 핀 등 대부분 |
| STANDBY | 수 µA(예제 3µA) | 수백 µs~수 ms | RTC 알람, 지정된 웨이크 핀 등 극소수 |
평균 전류와 배터리 수명 계산
배터리 수명을 계산할 때 가장 흔한 실수는 데이터시트의 "대기 전류" 한 줄만 보고 나누는 것이다. 실제 소비 전류는 구간마다 다르므로, 구간별 전류와 시간을 곱해서 더한 전하량을 총 시간으로 나눈 평균 전류를 써야 한다.
식으로 쓰면 평균 전류 = (Σ 구간 전류 × 구간 시간) ÷ 총 시간이고, 배터리 수명 = 배터리 용량 ÷ 평균 전류다. 이 방식을 쿨롱 카운팅이라고 부르기도 한다. 단위를 맞추는 요령이 하나 있는데, 전류를 µA, 시간을 ms로 두면 두 값을 곱한 결과가 정확히 nC(나노쿨롱)가 된다는 점이다. 1µA는 1e-6A, 1ms는 1e-3s이므로 곱하면 1e-9C, 즉 1nC다. 정수 µA와 정수 ms를 곱하면 반올림 없이 정확한 정수 nC가 나오므로, 시뮬레이션 코드는 이 관계를 이용해 부동소수점 없이 전하량을 누적한다.
여기서 실무 감각이 하나 필요하다. 활성 구간이 아무리 짧아도 전류가 대기 구간보다 수백~수천 배 크면, 평균 전류를 끌어올리는 몫이 생각보다 크다. 아래 그림은 이 관계를 실제 비율이 아니라 개념적으로 보여준다.
PC 시뮬레이션과 실제 보드의 차이
이 장의 hal_sim은 실제로 CPU를 멈추지 않는다. hal_sleep()이 호출되면 내부의 가상 시계(g_now_ms)를 깨우기 시점까지 그냥 앞으로 돌리고, 그 사이에 소비했을 전하량을 전류 상수와 시간을 곱해 누적한다. 실제 보드는 WFI(Wait For Interrupt) 같은 명령으로 코어를 정말로 멈추고, 그 시간 동안은 RTC 같은 별도 하드웨어가 독립적으로 시간을 잰다. 시뮬레이션과 실제 하드웨어의 차이를 표로 정리한다.
| 플랫폼 | 절전 진입 방법 | 대표 깨우기 소스 | 대표 대기 전류 |
|---|---|---|---|
| PC 시뮬레이션(hal_sim) | hal_sleep() 호출, 가상 시계 전진 | 인자로 지정한 타이머, hal_schedule_pin_wake() | 코드에 정의한 상수(예: 3µA) |
| STM32 계열 | PWR 레지스터 설정 후 WFI/WFE 명령 | RTC 알람, EXTI 핀, 워치독 | 모드에 따라 수백 nA~수 µA |
| ESP32 계열 | esp_sleep_enable_*() 후 딥슬립 진입 함수 호출 | 타이머, RTC GPIO, ULP 코프로세서 | 딥슬립 수 µA대, 라이트슬립 mA대 |
| 아두이노(AVR) 계열 | sleep_enable()과 sleep_mode() 호출 | 핀 체인지 인터럽트, 워치독 타이머 | 파워다운 모드 수 µA대 |
완성 코드
hal_sim.h
#ifndef HAL_SIM_H
#define HAL_SIM_H
#include <stdint.h>
typedef enum {
PWR_ACTIVE = 0,
PWR_SLEEP = 1,
PWR_STANDBY = 2
} power_mode_t;
typedef enum {
WAKE_TIMER = 0,
WAKE_PIN = 1
} wake_reason_t;
void hal_init(void);
uint32_t hal_now_ms(void);
void hal_delay_ms(uint32_t ms);
wake_reason_t hal_sleep(power_mode_t mode, uint32_t timeout_ms);
void hal_schedule_pin_wake(uint32_t at_ms);
uint64_t hal_charge_nc(void);
#endif
hal_sim.c
#include "hal_sim.h"
#define CURRENT_ACTIVE_UA 8000u
#define CURRENT_SLEEP_UA 1200u
#define CURRENT_STANDBY_UA 3u
static uint32_t g_now_ms;
static uint64_t g_charge_nc;
static uint32_t g_pin_wake_at;
static int g_pin_armed;
static uint32_t current_for_mode(power_mode_t mode) {
switch (mode) {
case PWR_ACTIVE:
return CURRENT_ACTIVE_UA;
case PWR_SLEEP:
return CURRENT_SLEEP_UA;
case PWR_STANDBY:
return CURRENT_STANDBY_UA;
}
return CURRENT_ACTIVE_UA;
}
void hal_init(void) {
g_now_ms = 0;
g_charge_nc = 0;
g_pin_wake_at = 0;
g_pin_armed = 0;
}
uint32_t hal_now_ms(void) {
return g_now_ms;
}
void hal_delay_ms(uint32_t ms) {
g_charge_nc += (uint64_t)CURRENT_ACTIVE_UA * ms;
g_now_ms += ms;
}
wake_reason_t hal_sleep(power_mode_t mode, uint32_t timeout_ms) {
uint32_t current_ua = current_for_mode(mode);
uint32_t wake_at = g_now_ms + timeout_ms;
wake_reason_t reason = WAKE_TIMER;
if (g_pin_armed && g_pin_wake_at >= g_now_ms && g_pin_wake_at <= wake_at) {
wake_at = g_pin_wake_at;
reason = WAKE_PIN;
g_pin_armed = 0;
}
g_charge_nc += (uint64_t)current_ua * (wake_at - g_now_ms);
g_now_ms = wake_at;
return reason;
}
void hal_schedule_pin_wake(uint32_t at_ms) {
g_pin_wake_at = at_ms;
g_pin_armed = 1;
}
uint64_t hal_charge_nc(void) {
return g_charge_nc;
}
main.c
#include <stdio.h>
#include <inttypes.h>
#include "hal_sim.h"
#define WAKE_INTERVAL_MS 600000u
#define SENSE_TASK_MS 40u
#define PUMP_TASK_MS 15u
#define BATTERY_CAPACITY_UAH 220000u
static void report_cycle(unsigned cycle, wake_reason_t reason) {
const char *label = (reason == WAKE_TIMER) ? "타이머" : "핀(저수위 알람)";
printf("cycle %u: t=%" PRIu32 " ms, wake=%s\n", cycle, hal_now_ms(), label);
}
int main(void) {
hal_init();
for (unsigned cycle = 0; cycle < 6; cycle++) {
if (cycle == 3) {
hal_schedule_pin_wake(hal_now_ms() + 150000u);
}
wake_reason_t reason = hal_sleep(PWR_STANDBY, WAKE_INTERVAL_MS);
hal_delay_ms(SENSE_TASK_MS);
if (reason == WAKE_PIN) {
hal_delay_ms(PUMP_TASK_MS);
}
report_cycle(cycle, reason);
}
uint64_t charge_nc = hal_charge_nc();
double charge_uah = (double)charge_nc / 3600000.0;
double avg_current_ua = (double)charge_nc / (double)hal_now_ms();
double life_hours = (double)BATTERY_CAPACITY_UAH / avg_current_ua;
printf("총 경과 시간: %" PRIu32 " ms\n", hal_now_ms());
printf("누적 소비 전하량: %.4f uAh\n", charge_uah);
printf("평균 전류: %.3f uA\n", avg_current_ua);
printf("추정 배터리 수명(220mAh 기준): %.1f 시간 (%.1f 일)\n",
life_hours, life_hours / 24.0);
return 0;
}
줄별 해설
hal_sim.h는 세 가지만 노출한다. 절전 모드(power_mode_t), 깨우기 원인(wake_reason_t), 그리고 시간·전하량을 다루는 함수들이다. 내부 상태(g_now_ms 등)는 hal_sim.c 안의 static 변수로 숨겨 두어 main.c가 레지스터를 직접 건드리지 못하게 한다.
current_for_mode()는 모드별 전류(µA)를 반환하는 조회 테이블이다. switch 문이 세 값을 모두 처리하므로 -Wall -Wextra에서 누락 경고가 나지 않는다.
hal_sleep()이 이 장의 핵심이다. 우선 타이머 기준 깨우기 시각(wake_at)을 계산한다. 이어서 hal_schedule_pin_wake()로 예약된 핀 이벤트가 있는지, 그리고 그 시각이 지금(g_now_ms)과 타이머 시각(wake_at) 사이에 있는지 확인한다. 조건을 만족하면 더 이른 핀 이벤트로 깨우기 시각을 앞당기고 사유를 WAKE_PIN으로 바꾼다. 마지막으로 실제로 지난 시간(wake_at - g_now_ms)에 모드별 전류를 곱해 g_charge_nc에 더한다. µA와 ms를 곱하면 그대로 nC가 되므로 반올림 오차 없는 정수 누적이 가능하다.
hal_delay_ms()는 ACTIVE 상태에서 실제로 일을 하는 구간, 즉 센서를 읽거나 펌프를 구동하는 시간을 흉내 낸다. 항상 CURRENT_ACTIVE_UA를 쓴다는 점에서 hal_sleep()과 대칭을 이룬다.
main()의 루프는 사이클 3에서만 hal_schedule_pin_wake()를 호출해, 저수위 알람 같은 비동기 이벤트가 타이머 대기 도중 끼어드는 상황을 재현한다. reason이 WAKE_PIN이면 펌프 구동 시간을 추가로 더해, 알람 대응이 일반 측정보다 전류를 더 쓴다는 점도 반영했다.
마지막 계산부는 누적 전하량(nC)을 사람이 읽기 쉬운 µAh로 바꾸고, 전체 시간으로 나눠 평균 전류를 구한 뒤, 배터리 용량을 평균 전류로 나눠 수명을 추정한다. PRIu32 매크로를 쓴 이유는 uint32_t의 실제 크기가 플랫폼마다 int일 수도 long일 수도 있어서, %d나 %u를 직접 쓰면 이식성 경고가 날 수 있기 때문이다.
실행 결과
$ cc -std=c11 -Wall -Wextra hal_sim.c main.c -o power_demo
$ ./power_demo
cycle 0: t=600040 ms, wake=타이머
cycle 1: t=1200080 ms, wake=타이머
cycle 2: t=1800120 ms, wake=타이머
cycle 3: t=1950175 ms, wake=핀(저수위 알람)
cycle 4: t=2550215 ms, wake=타이머
cycle 5: t=3150255 ms, wake=타이머
총 경과 시간: 3150255 ms
누적 소비 전하량: 3.1917 uAh
평균 전류: 3.647 uA
추정 배터리 수명(220mAh 기준): 60318.2 시간 (2513.3 일)
사이클 3에서 예약해 둔 핀 이벤트가 다음 타이머보다 먼저 도착해 WAKE_PIN으로 깨어나는 것을 확인할 수 있다. 평균 전류(3.647µA)는 STANDBY 전류(3µA)보다 약간 높은데, 그 차이가 바로 여섯 번의 짧은 ACTIVE 구간이 기여한 몫이다.
실무에서 자주 틀리는 것
깨우기 원인을 정리하지 않고 슬립에 들어가기
이전 인터럽트의 대기(pending) 플래그가 남아 있으면, 절전 모드에 들어가자마자 그 플래그 때문에 즉시 다시 깨어난다. 겉으로는 MCU가 전혀 잠들지 않는 것처럼 보인다.
/* 틀린 코드 */
void enter_standby(void) {
PWR->CR |= PWR_CR_STANDBY_ENTER;
__WFI();
}
/* 고친 코드 */
void enter_standby(void) {
EXTI->PR = 0xFFFFFFFFu; /* 남은 대기 플래그 정리 */
PWR->CR |= PWR_CR_STANDBY_ENTER;
__WFI();
}
능동 구간 전류를 배터리 수명 계산에서 빼먹기
대기 전류만으로 수명을 계산하면 실제보다 훨씬 긴 값이 나온다. 짧더라도 전류가 큰 구간을 반드시 합산해야 한다.
/* 틀린 코드: 대기 전류만 사용 */
double life_h = BATTERY_MAH * 1000.0 / STANDBY_UA;
/* 고친 코드: 전하량 누적으로 평균 전류 계산 */
double avg_ua = (double)total_charge_nc / (double)total_ms;
double life_h = BATTERY_MAH * 1000.0 / avg_ua;
깨우기에 필요한 주변장치까지 꺼버리기
전류를 더 아끼려고 RTC 클록까지 꺼버리면, 정작 그 RTC 알람으로 깨어날 예정이었던 MCU는 영원히 깨어나지 못한다.
/* 틀린 코드 */
void enter_standby(void) {
RCC->APB1ENR &= ~RCC_APB1ENR_RTCEN;
PWR->CR |= PWR_CR_STANDBY_ENTER;
__WFI();
}
/* 고친 코드: 깨우기 원인의 클록은 그대로 둔다 */
void enter_standby(void) {
PWR->CR |= PWR_CR_STANDBY_ENTER;
__WFI();
}
배터리 정격 용량을 그대로 수명 계산에 쓰기
데이터시트의 정격 용량은 이상적인 조건에서의 값이다. 자기방전과 컷오프 전압 여유를 빼지 않으면 스펙에 실제보다 낙관적인 수명을 적게 된다.
/* 틀린 코드 */
#define BATTERY_CAPACITY_UAH 220000u
double life_h = (double)BATTERY_CAPACITY_UAH / avg_ua;
/* 고친 코드: 사용 가능 비율을 반영 */
#define BATTERY_RATED_UAH 220000u
#define USABLE_RATIO 0.7
double life_h = (BATTERY_RATED_UAH * USABLE_RATIO) / avg_ua;
한눈에 보기
| 개념/함수 | 의미 | 이 장 코드에서의 역할 | 실제 보드에서의 대응 |
|---|---|---|---|
| ACTIVE/SLEEP/STANDBY | 전류·유지 자원이 다른 절전 단계 | current_for_mode()가 모드별 전류값 반환 | 보드별 저전력 레지스터 비트 |
| hal_sleep() | 깨우기 원인이 올 때까지 시간을 전진 | 타이머·핀 웨이크 경쟁을 계산 | WFI/WFE 등 저전력 진입 명령 |
| hal_schedule_pin_wake() | 외부 이벤트를 특정 시각에 예약 | 비동기 깨우기 시나리오 재현 | EXTI/GPIO 인터럽트 설정 |
| 평균 전류 = 전하량÷시간 | 듀티 사이클을 반영한 실제 소비 전류 | 배터리 수명 추정의 근거 | 전류계·쿨롱 카운터 IC로 실측 |
연습 문제
- WAKE_INTERVAL_MS를 600000에서 60000(1분)으로 바꾸면 평균 전류는 늘어날지 줄어들지, 그 이유를 설명하라.
- hal_sleep()이 전하량을 nC(나노쿨롱) 단위로 누적하는 이유를 µA·ms 관계식으로 설명하고, g_charge_nc를 uint64_t가 아닌 uint32_t로 선언했다면 어떤 문제가 생길 수 있는지 서술하라.
- cycle==3에서 예약한 핀 웨이크 시각이 hal_now_ms()+700000이었다면, 그 사이클의 hal_sleep()은 WAKE_TIMER와 WAKE_PIN 중 무엇을 반환하는지 코드 근거로 설명하라.
- CURRENT_STANDBY_UA를 3에서 30으로 바꿔 다시 실행하면 추정 배터리 수명(시간)은 대략 몇 분의 1로 줄어드는지, 원래 전하량 구성비를 근거로 어림 계산하라.
정답과 해설
- 늘어난다. 대기 시간당 전류(3µA)는 그대로지만, 같은 시간 동안 40ms짜리 활성 구간(8000µA)이 10배 더 자주 끼어든다. 활성 구간이 전체 전하량에서 차지하는 비율이 커지므로 평균 전류도 3.647µA보다 눈에 띄게 높아진다.
- 1µA는 1e-6A, 1ms는 1e-3s이므로 곱은 1e-9C, 즉 1nC다. 정수 µA와 정수 ms를 곱하면 반올림 없이 정확한 정수가 나온다. uint32_t로 선언했다면 최댓값이 약 42억이므로, 예를 들어 ACTIVE 전류(8000µA)로 600000ms짜리 구간 하나만 계산해도 8000×600000=48억으로 오버플로가 나서 값이 조용히 틀려진다. uint64_t와 (uint64_t) 캐스팅은 이런 오버플로를 막는다.
- WAKE_TIMER를 반환한다. hal_sleep() 안의 조건은 g_pin_wake_at <= wake_at인데, 핀 시각(now+700000)이 타이머 시각(now+600000)보다 크므로 이 조건이 거짓이 된다. 따라서 깨우기 시각은 타이머 값 그대로 유지되고 reason도 WAKE_TIMER로 남는다. 다만 g_pin_armed는 조건이 거짓일 때 초기화되지 않으므로, 예약된 핀 이벤트는 사라지지 않고 이후 사이클의 hal_sleep()에서 다시 검사된다.
- 원래 총 전하 11,490,000nC 중 대기 성분은 9,450,000nC, 활성 성분은 2,040,000nC다. 대기 전류를 10배로 올리면 대기 성분만 94,500,000nC가 되고 활성 성분은 그대로이므로 새 총합은 약 96,540,000nC다. 원래 대비 약 8.4배이므로 평균 전류도 약 8.4배 늘고, 배터리 수명은 그 역수인 약 8분의 1 수준으로 줄어든다.