Devin.KR

종합 실습 - 스마트 화분 펌웨어

개발자KR 조회 9

이 장에서 배우는 것

지금까지 GPIO 출력과 입력, 타이머, ADC, UART, I2C, SPI, 저전력, 디버깅을 한 가지씩 따로 다뤘다. 실제 제품은 이 조각들을 한 파일 안에서가 아니라 하나의 흐름 안에서 같이 굴려야 동작한다. 이 장에서는 스마트 화분을 예로 삼아 센서 읽기 → 판단 → 펌프 제어로 이어지는 주기 루프를 상태 기계로 정리하고, 전원이 꺼져도 남아야 하는 설정값을 저장하는 방법을 다룬다. 이 책의 마지막 장이므로 완성 코드는 PC 에서 그대로 컴파일·실행되는 시뮬레이션이며, 실제 보드로 옮길 때 무엇이 달라지는지도 정리한다.

  • 여러 개의 if-else 대신 상태 기계로 제어 루프를 설계하는 이유를 설명할 수 있다
  • 임계값 하나가 아니라 히스테리시스(hysteresis)로 잦은 on/off를 막는 방법을 안다
  • 설정값을 매직넘버로 검증한 뒤 비휘발성 저장소(NVM)에 저장·복원할 수 있다
  • hal_sim 계층을 이용해 실제 보드 없이 펌웨어 로직을 검증하는 습관을 갖는다
  • PC 시뮬레이션과 실제 보드(STM32·ESP32·아두이노) 사이에서 무엇을 바꿔야 하는지 안다

문제 상황

장마다 만든 LED 깜박이기, 버튼 디바운싱, PWM 밝기, ADC 읽기, UART 로그는 각각 따로 실행하면 잘 동작한다. 그런데 이 조각들을 한 제품으로 묶으려 하면 새로운 문제가 나타난다. 토양수분이 임계값 근처에서 오르내리면 펌프가 1초에 여러 번 켜졌다 꺼지기를 반복한다. 전원 코드를 뽑았다 꽂으면 그동안 맞춰둔 임계값과 누적 급수 횟수가 사라지고 기본값으로 되돌아간다. 이런 문제는 개별 주변장치 드라이버가 아니라 그것들을 엮는 제어 흐름과 저장 로직에서 생긴다. 이 장은 새 주변장치를 배우는 장이 아니라, 지금까지 만든 것을 하나의 펌웨어로 엮는 장이다.

주기 루프를 상태 기계로 만들기

토양수분이 임계값 아래로 내려가면 펌프를 켜고, 임계값 위로 올라오면 끈다는 규칙을 그대로 코드로 옮기면 if (soil < 500) pump_on(); else pump_off(); 한 줄로 끝난다. 문제는 수분값이 499와 501 사이를 오가는 순간이다. 버튼 디바운싱을 다룬 장에서 본 것처럼, 경계값 근처에서 값이 흔들리면 출력도 같이 흔들린다. 펌프처럼 기계적으로 움직이는 부하는 이런 채터링(chattering)에 특히 취약하다.

해법은 버튼 디바운싱과 같은 방향이다. 경계를 하나가 아니라 둘로 나눈다. 수분이 moisture_low 아래로 내려가야 급수를 시작하고, moisture_high 위로 올라와야 멈춘다. 두 값 사이에서는 직전 상태를 유지한다. 여기에 급수 종료 뒤 일정 시간 동안 다시 판단하지 않는 휴지(cooldown) 상태를 더하면, 센서 잡음이나 물이 흙에 퍼지는 지연 때문에 생기는 재시동도 막을 수 있다.

이 규칙을 코드로 옮기는 가장 깔끔한 방법은 불리언 변수 여러 개가 아니라 상태 하나를 열거형(enum)으로 두고 switch 문으로 분기하는 것이다. 상태가 셋이면 조합도 셋뿐이다. 불리언 두 개로 같은 것을 표현하면 논리적으로 있을 수 없는 조합(급수중이면서 동시에 휴지중)까지 코드에 남는다.

급수 상태 기계는 대기에서 급수중을 거쳐 휴지 상태로 갔다가 다시 대기로 돌아온다

설정값을 파일(NVM)에 저장하기

임계값과 누적 급수 횟수를 전역 변수에만 두면 전원이 꺼지는 순간 사라진다. 실제 보드에서는 이 값을 내부 플래시의 남는 페이지나 I2C로 연결한 EEPROM 칩에 넣는다. I2C로 센서와 대화하는 장에서 본 것과 같은 방식으로, 저장소 역시 주소를 정해 읽고 쓰는 주변장치일 뿐이다. PC 에는 그런 칩이 없으므로 이 장의 시뮬레이션에서는 파일 하나(planter_config.dat)로 같은 역할을 흉내 낸다. 프로그램을 다시 실행해도 이 파일이 남아 있으면 이전 설정을 그대로 불러온다.

저장소에서 읽어온 값을 검증 없이 바로 쓰면 위험하다. 저장소가 비어 있거나, 구조체 필드를 나중에 하나 더 추가해 예전 파일과 크기가 달라지면 쓰레기 값을 설정으로 착각한다. 그래서 구조체 맨 앞에 매직넘버(magic number)를 두고, 불러온 값의 매직넘버가 기대한 값과 같을 때만 신뢰한다. 다르면 기본값으로 되돌린다.

저장 시점도 중요하다. 루프를 돌 때마다 저장하지 않고, 급수가 끝나 누적 횟수가 바뀌는 의미 있는 순간에만 저장한다. 실제 플래시는 쓰기 횟수에 한계가 있어 자주 쓸수록 수명이 줄어든다. 더 깊이 다루고 싶다면 마모 평준화(wear leveling) 개념을 별도로 찾아보는 것이 좋다. 이 장 이후에는 저전력 설계를 다룬 장의 내용과 함께, 실제 보드용 HAL 문서와 RTOS 관련 자료를 참고하며 이 코드를 실제 하드웨어로 옮겨보는 것을 권한다.

파일에 저장한 설정값은 전원을 꺼도 남아 다음 실행에서 그대로 이어진다

완성 코드

파일 세 개로 나눈다. hal_sim.h/.c는 GPIO·ADC·시간·저장소를 PC 에서 흉내 내는 계층이고, planter.c는 이 장에서 새로 쓰는 상태 기계와 설정 저장 로직이다.

hal_sim.h

#ifndef HAL_SIM_H
#define HAL_SIM_H

#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>

typedef enum {
    PIN_LED  = 0,
    PIN_PUMP = 1
} hal_pin_t;

void hal_reset(void);
uint32_t hal_millis(void);
void hal_tick(uint32_t ms);

void hal_gpio_write(hal_pin_t pin, bool level);
bool hal_gpio_read(hal_pin_t pin);

uint16_t hal_adc_read_soil(void);

bool hal_nvm_load(void *buf, size_t len);
void hal_nvm_save(const void *buf, size_t len);

#endif

hal_sim.c

#include "hal_sim.h"
#include <stdio.h>

#define NVM_FILE "planter_config.dat"

static uint32_t g_millis;
static bool g_pin[2];
static int32_t g_wetness = 430;

void hal_reset(void)
{
    g_millis = 0;
    g_pin[PIN_LED]  = false;
    g_pin[PIN_PUMP] = false;
    g_wetness = 430;
}

uint32_t hal_millis(void)
{
    return g_millis;
}

void hal_tick(uint32_t ms)
{
    g_millis += ms;

    if (g_pin[PIN_PUMP]) {
        g_wetness += 80;   /* 펌프가 돌면 급격히 젖는다 */
    } else {
        g_wetness -= 30;   /* 평소에는 서서히 마른다 */
    }
    if (g_wetness < 0)    g_wetness = 0;
    if (g_wetness > 1000) g_wetness = 1000;
}

void hal_gpio_write(hal_pin_t pin, bool level)
{
    g_pin[pin] = level;
}

bool hal_gpio_read(hal_pin_t pin)
{
    return g_pin[pin];
}

uint16_t hal_adc_read_soil(void)
{
    return (uint16_t)g_wetness;
}

bool hal_nvm_load(void *buf, size_t len)
{
    FILE *fp = fopen(NVM_FILE, "rb");
    if (!fp) return false;
    size_t n = fread(buf, 1, len, fp);
    fclose(fp);
    return n == len;
}

void hal_nvm_save(const void *buf, size_t len)
{
    FILE *fp = fopen(NVM_FILE, "wb");
    if (!fp) return;
    fwrite(buf, 1, len, fp);
    fclose(fp);
}

planter.c

#include <stdint.h>
#include <stdio.h>
#include "hal_sim.h"

#define CONFIG_MAGIC   0x504C4E31u  /* "PLN1" */
#define TICK_MS        500u
#define CYCLE_COUNT    14

typedef struct {
    uint32_t magic;
    uint16_t moisture_low;
    uint16_t moisture_high;
    uint16_t watering_ms;
    uint32_t watering_count;
} planter_config_t;

typedef enum {
    ST_IDLE,
    ST_WATERING,
    ST_COOLDOWN
} planter_state_t;

static const char *state_name(planter_state_t state)
{
    switch (state) {
    case ST_IDLE:     return "대기";
    case ST_WATERING: return "급수중";
    case ST_COOLDOWN: return "휴지";
    }
    return "?";
}

static void load_or_init_config(planter_config_t *cfg)
{
    if (hal_nvm_load(cfg, sizeof(*cfg)) && cfg->magic == CONFIG_MAGIC) {
        printf("[CFG] 저장된 설정을 불러왔다 (누적 급수 %u회)\n",
               (unsigned)cfg->watering_count);
        return;
    }
    cfg->magic = CONFIG_MAGIC;
    cfg->moisture_low = 410;
    cfg->moisture_high = 600;
    cfg->watering_ms = 1500;
    cfg->watering_count = 0;
    printf("[CFG] 저장된 설정이 없어 기본값을 사용한다\n");
}

int main(void)
{
    hal_reset();

    planter_config_t cfg;
    load_or_init_config(&cfg);

    planter_state_t state = ST_IDLE;
    uint32_t state_elapsed = 0;
    const uint32_t cooldown_ms = 1000;

    for (int cycle = 0; cycle < CYCLE_COUNT; cycle++) {
        hal_tick(TICK_MS);
        state_elapsed += TICK_MS;

        uint16_t soil = hal_adc_read_soil();
        printf("[%5u ms] 토양수분=%3u‰ 상태=%s\n",
               (unsigned)hal_millis(), (unsigned)soil, state_name(state));

        switch (state) {
        case ST_IDLE:
            if (soil < cfg.moisture_low) {
                hal_gpio_write(PIN_PUMP, true);
                hal_gpio_write(PIN_LED, true);
                state = ST_WATERING;
                state_elapsed = 0;
                printf("  -> 급수 시작\n");
            }
            break;
        case ST_WATERING:
            if (soil >= cfg.moisture_high || state_elapsed >= cfg.watering_ms) {
                hal_gpio_write(PIN_PUMP, false);
                hal_gpio_write(PIN_LED, false);
                cfg.watering_count++;
                hal_nvm_save(&cfg, sizeof(cfg));
                state = ST_COOLDOWN;
                state_elapsed = 0;
                printf("  -> 급수 종료 (누적 %u회), 설정 저장\n",
                       (unsigned)cfg.watering_count);
            }
            break;
        case ST_COOLDOWN:
            if (state_elapsed >= cooldown_ms) {
                state = ST_IDLE;
                state_elapsed = 0;
            }
            break;
        }
    }

    return 0;
}

줄별 해설

hal_sim.c의 g_wetness는 실제 토양수분 센서 대신 쓰는 내부 모델이다. hal_tick은 펌프 핀이 켜져 있으면 값을 크게 올리고 꺼져 있으면 천천히 내린다. 실제 보드에서는 이 자리가 ADC 레지스터를 읽는 코드로 바뀐다. hal_nvm_load/save는 구조체 내용을 바이트 그대로 파일에 쓰고 읽는다. 저장소를 구조체 타입이 아니라 void *와 길이로 받게 한 것은, 실제 플래시나 EEPROM도 바이트 단위로만 다루기 때문이다.

planter.c의 load_or_init_config는 저장된 값을 불러오되 cfg->magic이 기대한 값과 다르면 기본값으로 덮어쓴다. 이 검사 하나가 파일이 아예 없을 때와 파일 내용이 손상되었을 때를 같은 방식으로 처리해 준다.

main의 루프는 hal_tick으로 시간을 한 칸 전진시키고, ADC 값을 읽고, 로그를 남긴 다음, 현재 상태에 따라 전이 조건을 검사한다. ST_WATERING 분기에서 수분이 moisture_high에 도달했거나 watering_ms가 지났으면 펌프를 끄고, 그 순간에만 설정을 저장한다. ST_COOLDOWN 분기는 일정 시간이 지날 때까지 아무 판단도 하지 않는다. 상태 전이는 항상 핀 제어, 상태 변경, state_elapsed 초기화를 한 자리에서 같이 한다.

실행 결과

세 파일을 같은 폴더에 두고 아래처럼 빌드한다. planter_config.dat 파일이 없는 최초 실행 결과는 다음과 같다.

$ cc -std=c11 -Wall -Wextra -o planter hal_sim.c planter.c
$ ./planter
[CFG] 저장된 설정이 없어 기본값을 사용한다
[  500 ms] 토양수분=400‰ 상태=대기
  -> 급수 시작
[ 1000 ms] 토양수분=480‰ 상태=급수중
[ 1500 ms] 토양수분=560‰ 상태=급수중
[ 2000 ms] 토양수분=640‰ 상태=급수중
  -> 급수 종료 (누적 1회), 설정 저장
[ 2500 ms] 토양수분=610‰ 상태=휴지
[ 3000 ms] 토양수분=580‰ 상태=휴지
[ 3500 ms] 토양수분=550‰ 상태=대기
[ 4000 ms] 토양수분=520‰ 상태=대기
[ 4500 ms] 토양수분=490‰ 상태=대기
[ 5000 ms] 토양수분=460‰ 상태=대기
[ 5500 ms] 토양수분=430‰ 상태=대기
[ 6000 ms] 토양수분=400‰ 상태=대기
  -> 급수 시작
[ 6500 ms] 토양수분=480‰ 상태=급수중
[ 7000 ms] 토양수분=560‰ 상태=급수중

이 실행이 끝나면 폴더에 planter_config.dat가 생긴다. 삭제하지 않고 다시 실행하면 첫 줄이 [CFG] 저장된 설정을 불러왔다 (누적 급수 1회)로 바뀌고, 나머지 로그는 같은 흐름으로 진행되며 급수가 끝나는 시점에는 누적 급수가 2회로 저장된다. 초기값과 판단 로직이 그대로이므로 시간별 수분값 자체는 첫 실행과 동일하다.

실무에서 자주 틀리는 것

임계값 하나로 펌프를 켜고 끈다

경계 하나만 쓰면 값이 흔들릴 때 출력도 같이 흔들린다.

/* 틀린 코드 */
if (soil < 500) {
    pump_on();
} else {
    pump_off();
}
/* 고친 코드 */
if (soil < cfg.moisture_low)  state = ST_WATERING;
if (soil >= cfg.moisture_high) state = ST_IDLE;

루프를 돌 때마다 설정을 저장한다

변할 일이 없는 값을 매번 쓰면 플래시 수명만 깎아먹는다.

/* 틀린 코드 */
for (;;) {
    read_soil();
    control_pump();
    hal_nvm_save(&cfg, sizeof(cfg)); /* 매 반복마다 저장 */
}
/* 고친 코드 */
/* 누적 급수 횟수처럼 실제로 바뀌는 순간에만 저장한다 */
cfg.watering_count++;
hal_nvm_save(&cfg, sizeof(cfg));

불러온 설정값을 검증 없이 그대로 쓴다

저장소가 비어 있거나 예전 버전 데이터가 남아 있으면 쓰레기 값을 그대로 쓰게 된다.

/* 틀린 코드 */
hal_nvm_load(&cfg, sizeof(cfg));
/* 매직넘버 확인 없이 cfg 를 바로 사용 */
/* 고친 코드 */
if (!hal_nvm_load(&cfg, sizeof(cfg)) || cfg.magic != CONFIG_MAGIC) {
    cfg = default_config();
}

상태를 불리언 여러 개로 흩어 놓는다

불리언을 늘릴수록 있을 수 없는 조합까지 코드에 남는다.

/* 틀린 코드 */
bool is_watering, is_cooldown;
if (is_watering) { ... }
else if (is_cooldown) { ... }
else { ... }
/* 고친 코드 */
planter_state_t state; /* ST_IDLE / ST_WATERING / ST_COOLDOWN 중 하나 */
switch (state) { ... }

한눈에 보기

이 장에서 정리한 개념과 실무 확인 포인트
개념이 장에서 구현한 것실무 확인 포인트
주기 루프for 루프와 hal_tick으로 흉내 낸 시간 진행실제 보드는 인터럽트나 타이머로 주기를 맞춘다
상태 기계대기·급수중·휴지 3상태와 히스테리시스상태 수가 늘면 표나 다이어그램으로 먼저 정리한다
설정 저장매직넘버로 검증한 구조체를 파일(NVM)에 저장플래시는 쓰기 횟수 제한이 있어 저장 시점을 고른다
이식hal_sim.h 인터페이스만 유지하면 됨보드별 HAL에 맞춰 hal_sim.c만 다시 구현한다
PC 시뮬레이션과 실제 보드에서 달라지는 것
항목PC 시뮬레이션(hal_sim)STM32 계열ESP32/아두이노 계열
GPIO 제어배열 변수로 핀 상태 흉내GPIOx->BSRR 같은 레지스터 직접 조작digitalWrite / gpio_set_level
ADC 읽기내부 모델 값을 정수로 반환ADC 주변장치 + DMA로 변환값 수집analogRead / adc1_get_raw
설정 저장fopen으로 파일에 읽고 쓰기내부 플래시 페이지 또는 I2C EEPROMNVS(ESP32) 또는 EEPROM 라이브러리
시간 기준hal_tick 호출 횟수로 진행SysTick/타이머 레지스터 카운트millis() 또는 FreeRTOS 틱

연습 문제

  1. cfg.moisture_high를 cfg.moisture_low보다 작은 값으로 잘못 설정하면 상태 기계가 어떻게 동작하는지 설명하라.
  2. cooldown_ms를 0으로 두면 어떤 문제가 생기는지, 코드의 어느 부분과 관련 있는지 설명하라.
  3. hal_nvm_save 호출을 펌프를 켜는 순간(ST_WATERING 진입 시점)으로 옮기면 실제 보드에서 어떤 단점이 생기는지 설명하라.
  4. load_or_init_config에서 매직넘버 검사를 지우면 어떤 상황에서 문제가 드러나는지 예를 들어 설명하라.

정답과 해설

  1. moisture_high가 moisture_low보다 작으면 급수중 상태에서 soil >= cfg.moisture_high 조건이 moisture_low보다 낮은 값에서도 참이 될 수 있다. 히스테리시스 폭이 뒤집혀 임계값 하나만 쓰는 것보다 더 빨리 펌프가 꺼지고, 다시 대기 상태에서 곧바로 급수 조건을 만족해 채터링이 재현된다.
  2. cooldown_ms가 0이면 ST_COOLDOWN 분기의 state_elapsed >= cooldown_ms가 진입 직후부터 참이 되어 바로 ST_IDLE로 넘어간다. 휴지 상태를 둔 이유(물이 흙에 퍼지는 지연을 기다리는 것)가 사라져, 급수 종료 직후 값이 아직 낮으면 곧바로 다시 급수를 시작해 사실상 휴지 상태가 없는 것과 같아진다.
  3. 펌프를 켤 때마다 저장하면 대기·급수 전이가 반복될수록 저장 횟수가 늘어난다. 지금 코드는 급수가 끝나 누적 횟수가 바뀌는 순간에만 저장해 쓰기 횟수를 최소로 유지하는데, 이 최적화가 사라져 실제 플래시에서는 마모가 빨리 진행된다.
  4. 매직넘버 검사를 지우면 planter_config.dat가 없어 fopen이 실패할 때 hal_nvm_load가 false를 반환해 기본값 초기화까지는 그대로 동작한다. 문제는 파일이 손상되었거나 구조체 필드를 나중에 추가해 크기가 달라졌을 때다. fread가 우연히 길이만 맞아 true를 반환하면, 검증 없이 임계값과 누적 횟수에 쓰레기 값이 들어가 펌프가 엉뚱하게 동작한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.