Devin.KR

ADC - 아날로그 센서 읽기

개발자KR 조회 4

이 장에서 배우는 것

스마트 화분의 토양 습도 센서는 저항이나 정전용량의 변화를 전압으로 내보낸다. CPU는 전압을 그대로 다루지 못하고 정수 코드로 바꿔야 계산할 수 있는데, 이 변환을 맡는 주변장치가 ADC(analog-to-digital converter)다. 앞 장에서는 타이머 레지스터로 LED 밝기와 펌프 속도를 만들어내는 출력 경로를 다뤘다. 이 장은 반대 방향이다. 센서가 내보낸 아날로그 신호를 읽어 들여, 분해능과 기준전압을 정확히 반영한 공식으로 전압과 습도값을 복원하고, 잡음 섞인 값을 이동평균 필터로 다듬는다.

  • ADC 분해능과 기준전압이 원시 코드(raw code) 한 칸의 의미를 어떻게 정하는지 설명한다
  • raw 코드를 전압(mV)으로, 전압을 다시 습도(%)로 바꾸는 변환 공식을 코드로 구현한다
  • 잡음 섞인 샘플을 이동평균 필터(moving average filter)로 다듬고, 창 크기가 만드는 trade-off를 이해한다
  • hal_sim 계층으로 토양 습도 센서를 흉내 내는 프로그램을 PC에서 경고 없이 컴파일·실행한다

문제 상황

화분에 토양 습도 센서를 꽂고 값을 반복해서 읽으면, 같은 흙인데도 읽을 때마다 숫자가 오르내린다. 원인은 여러 가지다. 센서 자체의 전기적 잡음, 전원 레일의 미세한 흔들림, 전극과 흙 사이 접촉 저항의 변화가 매번 조금씩 다른 raw 코드를 만들어낸다. 이 상태에서 raw 값을 그대로 습도로 취급하거나, 회로의 실제 기준전압과 다른 값으로 계산식을 짜면 결과는 완전히 틀어진다. 예를 들어 보드가 3.3V를 기준전압으로 쓰는데 계산식에 5V를 넣으면, 실제로는 흙이 젖어 있어도 "건조"로 판단해 펌프가 계속 돌아간다. 반대로 raw 코드를 매 순간 그대로 판단 기준으로 쓰면 값이 튈 때마다 펌프가 켜졌다 꺼졌다를 반복한다. 이 장에서는 두 가지를 순서대로 해결한다. 먼저 raw 코드를 정확한 물리량으로 바꾸는 변환 공식을 세우고, 그다음 잡음을 줄이는 이동평균 필터를 더한다.

ADC 분해능과 기준전압

ADC는 입력 전압을 0부터 기준전압(reference voltage, Vref) 사이의 구간으로 나누고, 입력이 그 구간 안 어디에 있는지를 정수 코드로 표현한다. 분해능(resolution)이 n비트라면 전체 구간을 2^n개의 칸으로 나누고, 코드값은 0부터 2^n-1까지를 오간다. 12비트 ADC라면 칸 수는 4096개(0~4095)이고, 코드 하나가 나타내는 전압 폭, 즉 최소 유효 비트(LSB) 크기는 Vref를 4096으로 나눈 값이다.

분해능이 커질수록 같은 전압 범위를 더 잘게 나누므로 코드 하나의 오차가 줄어든다. 대신 변환에 걸리는 시간이 늘고, 회로 잡음이 하위 비트를 흔들어 실제로 쓸 수 있는 유효 분해능은 표기값보다 낮아지는 경우가 많다. 표 1은 기준전압을 3.3V로 고정했을 때 분해능별로 코드 한 칸이 나타내는 전압 폭을 보여준다.

분해능이 코드 한 칸의 전압 폭을 결정한다(Vref=3.3V 기준)
분해능단계 수(2^n)코드당 전압(LSB)비고
8비트256약 12.9mV저가 MCU 내장 ADC, 거친 측정
10비트1024약 3.22mV구형 8비트 MCU 내장 ADC
12비트4096약 0.81mV이 장 예제, 32비트 MCU 표준급
16비트65536약 0.05mV정밀 계측용 외장 ADC

기준전압을 실제 회로와 다르게 가정하면 분해능과 무관하게 결과 전체가 틀어진다. 회로도에서 ADC 기준전압 핀이 어디에 연결돼 있는지, 내부 기준전압을 쓰는지 외부 레퍼런스를 쓰는지를 먼저 확인해야 한다.

PC 시뮬레이션과 실제 MCU의 차이

이 장의 예제는 hal_sim.h/hal_sim.c로 ADC 레지스터와 센서 전압을 PC에서 흉내 낸다. 실제 보드로 옮기면 분해능, 기준전압을 얻는 방법, 변환을 시작하는 방식이 제품마다 다르다. 표 2는 대표적인 차이를 정리한 것이다.

PC 시뮬레이션과 실제 MCU에서 달라지는 부분
항목PC 시뮬레이션(hal_sim)STM32 계열ESP32/아두이노 계열
분해능12비트 고정값대개 12비트, 일부 시리즈는 오버샘플링으로 16비트까지 확장ESP32 12비트, 아두이노 UNO(ATmega328P) 10비트
기준전압3300mV 상수내부 기준전압 또는 VDDA 핀 실측값 사용ESP32는 개인차가 커서 공장 보정값(eFuse) 권장, 아두이노는 보통 AVCC(5V)
변환 트리거함수 호출 시 즉시 반환타이머 트리거와 DMA로 연속 변환 가능대개 소프트웨어 폴링(analogRead 계열)
잡음 원인표로 정의한 인위적 패턴전원 레일, PCB 배선, 채널 간 크로스토크ESP32는 Wi-Fi 송신 중 잡음이 늘어나는 특성 보고됨

ESP32의 ADC 비선형성과 보정 방법은 ESP-IDF의 ADC 문서에 정리돼 있다.

변환 공식과 이동평균 필터

raw 코드를 전압으로 바꾸는 공식은 간단하다.

mV = raw × Vref_mV / 최대_코드

12비트, Vref 3300mV 기준이면 최대_코드는 4095다. 그림 1은 raw 코드가 전압을 거쳐 습도 퍼센트가 되기까지의 흐름을 보여준다.

raw 코드는 전압을 거쳐야 습도 퍼센트라는 물리량이 된다

전압만으로는 습도를 알 수 없다. 센서의 전형적인 두 지점, 즉 완전히 마른 상태의 전압과 흠뻑 젖은 상태의 전압을 기준으로 직선 보정식을 세워야 한다. 이 장의 예제는 마른 상태를 3000mV, 젖은 상태를 1200mV로 두고 그 사이를 선형으로 나눠 습도 퍼센트를 계산한다. 실제 센서라면 이 두 값을 직접 측정해서 넣어야 하며, 데이터시트에 적힌 값과 실측값이 다를 수 있다는 점도 감안해야 한다.

이동평균 필터로 잡음 줄이기

raw 코드 하나하나는 잡음 때문에 흔들리지만, 여러 개를 모아 평균을 내면 무작위로 섞인 잡음은 서로 상쇄되고 실제 값에 가까운 결과가 남는다. 구현은 최근 N개의 샘플을 원형 버퍼(ring buffer)에 저장해 두고, 새 샘플이 들어올 때마다 가장 오래된 샘플 하나를 버리고 합계와 평균을 다시 계산하는 식으로 한다. 그림 2는 창 크기 4인 버퍼에 새 샘플이 들어올 때 상태가 어떻게 바뀌는지 보여준다.

이동평균 필터는 오래된 샘플 하나를 빼고 새 샘플 하나를 더해 평균을 다시 계산한다

창 크기(window size)를 키우면 잡음은 더 잘 걸러지지만, 값이 실제로 바뀌었을 때 필터가 그 변화를 따라잡는 데도 그만큼 시간이 걸린다. 표 3은 창 크기에 따른 trade-off를 정리한 것이다.

이동평균 필터 창 크기의 trade-off
창 크기(샘플 수)잡음 감소반응 속도적합한 상황
4약함빠름버튼 입력처럼 빠르게 바뀌는 값
8보통보통토양 습도처럼 천천히 변하는 값(이 장 예제)
16강함느림조도 센서처럼 완만한 추세만 필요할 때
32매우 강함매우 느림배터리 전압처럼 급변이 의미 없는 값

완성 코드

아래 세 파일은 cc -std=c11 -Wall -Wextra로 경고 없이 컴파일된다. hal_sim.h/hal_sim.c가 ADC 레지스터와 토양 습도 센서를 흉내 내고, main.c가 두 가지 화분 상태(급수 직후, 며칠 지난 화분)에서 값을 읽어 필터링하고 변환한다.

hal_sim.h

#ifndef HAL_SIM_H
#define HAL_SIM_H

#include <stdint.h>

#define ADC_RESOLUTION_BITS 12u
#define ADC_MAX_CODE        4095u   /* (1 << 12) - 1 */
#define ADC_VREF_MV         3300u

void hal_sim_seed(unsigned seed);
void hal_sim_set_moisture_percent(int percent);
uint16_t hal_adc_read_raw(void);

#endif

hal_sim.c

#include "hal_sim.h"

#define SENSOR_DRY_MV 3000
#define SENSOR_WET_MV 1200
#define NOISE_TABLE_LEN 4u

static const int noise_table_mv[NOISE_TABLE_LEN] = { 8, -8, 4, -4 };
static unsigned g_noise_index = 0;
static int g_moisture_percent = 50;

void hal_sim_seed(unsigned seed)
{
    g_noise_index = seed % NOISE_TABLE_LEN;
}

void hal_sim_set_moisture_percent(int percent)
{
    if (percent < 0) percent = 0;
    if (percent > 100) percent = 100;
    g_moisture_percent = percent;
}

uint16_t hal_adc_read_raw(void)
{
    long true_mv = (long)SENSOR_DRY_MV -
        ((long)(SENSOR_DRY_MV - SENSOR_WET_MV) * g_moisture_percent) / 100;
    long noisy_mv = true_mv + noise_table_mv[g_noise_index];

    g_noise_index = (g_noise_index + 1u) % NOISE_TABLE_LEN;

    if (noisy_mv < 0) {
        noisy_mv = 0;
    }
    if (noisy_mv > (long)ADC_VREF_MV) {
        noisy_mv = (long)ADC_VREF_MV;
    }

    return (uint16_t)((noisy_mv * ADC_MAX_CODE) / ADC_VREF_MV);
}

main.c

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

#define FILTER_WINDOW 4u

typedef struct {
    uint16_t buf[FILTER_WINDOW];
    unsigned count;
    unsigned index;
    uint32_t sum;
} avg_filter_t;

static void filter_init(avg_filter_t *f)
{
    f->count = 0;
    f->index = 0;
    f->sum = 0;
}

static uint16_t filter_push(avg_filter_t *f, uint16_t sample)
{
    if (f->count == FILTER_WINDOW) {
        f->sum -= f->buf[f->index];
    } else {
        f->count++;
    }
    f->buf[f->index] = sample;
    f->sum += sample;
    f->index = (f->index + 1u) % FILTER_WINDOW;
    return (uint16_t)(f->sum / f->count);
}

static uint32_t adc_raw_to_mv(uint16_t raw)
{
    return ((uint32_t)raw * ADC_VREF_MV) / ADC_MAX_CODE;
}

static int mv_to_moisture_percent(uint32_t mv)
{
    const uint32_t dry_mv = 3000u;
    const uint32_t wet_mv = 1200u;

    if (mv >= dry_mv) return 0;
    if (mv <= wet_mv) return 100;

    return (int)(((dry_mv - mv) * 100u) / (dry_mv - wet_mv));
}

static void run_scenario(const char *label, int moisture_percent)
{
    avg_filter_t filter;
    filter_init(&filter);
    hal_sim_seed(0u);
    hal_sim_set_moisture_percent(moisture_percent);

    printf("[%s]\n", label);
    printf("샘플  raw   filt\n");

    uint16_t filtered = 0;
    for (unsigned i = 0; i < FILTER_WINDOW; i++) {
        uint16_t raw = hal_adc_read_raw();
        filtered = filter_push(&filter, raw);
        printf("%3u  %4u  %4u\n", i + 1u, (unsigned)raw, (unsigned)filtered);
    }

    uint32_t mv = adc_raw_to_mv(filtered);
    int percent = mv_to_moisture_percent(mv);
    printf("-> 변환: %u mV, 습도 %d%%\n\n", (unsigned)mv, percent);
}

int main(void)
{
    run_scenario("급수 직후", 70);
    run_scenario("며칠 지난 화분", 20);
    return 0;
}

줄별 해설

  • ADC_MAX_CODE, ADC_VREF_MV: 12비트 분해능과 3.3V 기준전압을 상수로 고정한다. 실제 보드에서는 이 두 값이 데이터시트나 레지스터 설정에서 나온다.
  • noise_table_mv: 매 호출마다 순서대로 적용되는 잡음 값의 표다. 네 값의 합이 0이므로, 창 크기 4인 필터로 한 바퀴를 다 돌리면 잡음이 정확히 상쇄된다. 실제 센서의 잡음은 이렇게 규칙적이지 않지만, 필터가 잡음을 걸러내는 원리를 손으로 계산해 확인할 수 있도록 일부러 이렇게 만들었다.
  • hal_adc_read_raw: true_mv에 목표 습도에 해당하는 전압을 구한 뒤 잡음을 더하고, 0~3300 범위를 벗어나지 않게 자른 다음, mV를 다시 12비트 코드로 양자화해 돌려준다.
  • avg_filter_t와 filter_push: 고정 크기 배열을 원형 버퍼로 쓴다. count가 아직 창 크기에 못 미치면 그냥 채우고, 다 찼으면 index가 가리키는 가장 오래된 값을 합계에서 빼고 새 값을 더한다. 매 호출마다 합계를 나눈 평균을 즉시 돌려준다.
  • adc_raw_to_mv: 필터를 거친 코드를 다시 전압으로 바꾼다. raw에 Vref를 곱한 다음 최대 코드로 나누는 순서를 지켜야 정수 나눗셈에서 정밀도가 죽지 않는다.
  • mv_to_moisture_percent: 마른 상태(3000mV)와 젖은 상태(1200mV) 두 기준점 사이를 선형으로 나눠 습도 퍼센트를 구한다. 범위를 벗어나면 0이나 100으로 자른다.
  • run_scenario: 화분 상태 하나를 골라 잡음 인덱스와 필터를 초기화하고, 창 크기만큼 raw 값을 읽어 필터에 넣으면서 매 샘플의 raw와 누적 평균을 출력한 뒤, 마지막 평균값을 전압과 습도로 변환해 보여준다.

실행 결과

세 파일을 같은 디렉터리에 두고 다음처럼 빌드하고 실행한다.

$ cc -std=c11 -Wall -Wextra -o adc_demo hal_sim.c main.c
$ ./adc_demo
[급수 직후]
샘플  raw   filt
  1  2169  2169
  2  2149  2159
  3  2164  2160
  4  2154  2159
-> 변환: 1739 mV, 습도 70%

[며칠 지난 화분]
샘플  raw   filt
  1  3285  3285
  2  3266  3275
  3  3280  3277
  4  3271  3275
-> 변환: 2639 mV, 습도 20%

raw 열은 샘플마다 잡음 때문에 오르내리지만, filt 열은 창이 다 찰수록 목표값에 수렴한다. 마지막 변환 결과인 습도 70%, 20%는 애초에 hal_sim_set_moisture_percent에 넣은 값과 거의 일치한다.

실무에서 자주 틀리는 것

기준전압을 회로와 다르게 가정한다

보드의 실제 Vref를 확인하지 않고 흔히 보이는 값을 그대로 쓰면 결과 전체가 틀어진다.

/* 틀린 코드: 보드는 3.3V인데 5V로 계산 */
uint32_t mv = raw * 5000 / 4095;

/* 고친 코드: 실측하거나 데이터시트에서 확인한 값을 쓴다 */
uint32_t mv = raw * 3300 / 4095;

나눗셈을 먼저 해서 정밀도를 날린다

곱셈보다 나눗셈을 먼저 하면 정수 연산에서 소수부가 통째로 사라진다.

/* 틀린 코드: raw가 4095보다 작으면 나눗셈 결과가 항상 0 */
uint32_t mv = raw / ADC_MAX_CODE * ADC_VREF_MV;

/* 고친 코드: 곱셈을 먼저 하고, 오버플로를 피하려고 넓은 타입으로 계산 */
uint32_t mv = (uint32_t)raw * ADC_VREF_MV / ADC_MAX_CODE;

필터 창 크기를 대상의 변화 속도와 안 맞게 고른다

창을 무작정 크게 잡으면 값이 실제로 바뀐 뒤에도 한참 지나야 반영된다.

/* 틀린 코드: 토양 습도는 천천히 변하는데 창이 지나치게 큼 */
#define FILTER_WINDOW 64u

/* 고친 코드: 대상 물리량의 변화 속도에 맞춰 창 크기를 고른다 */
#define FILTER_WINDOW 8u

raw 코드를 그대로 물리량처럼 비교한다

raw는 아직 전압도, 습도도 아닌 원시 코드일 뿐이다.

/* 틀린 코드: raw 코드를 습도처럼 취급 */
if (raw > 2000) {
    pump_off();
}

/* 고친 코드: 전압, 습도로 변환한 뒤 판단 */
uint32_t mv = adc_raw_to_mv(filtered);
int percent = mv_to_moisture_percent(mv);
if (percent > 40) {
    pump_off();
}

한눈에 보기

ADC 변환과 필터링에서 기억할 것
개념핵심 식 또는 값이 장 예제에서의 적용
분해능2^n 단계12비트, 4096단계(0~4095)
기준전압코드 0~최대코드가 나타내는 전압 범위3300mV(3.3V)
raw → 전압mV = raw × Vref / 최대코드mV = raw × 3300 / 4095
전압 → 습도두 기준점 사이 선형 보정3000mV(건조)~1200mV(포화)를 0~100%로 환산
이동평균 필터최근 N개 평균, 가장 오래된 값 교체창 크기 4, 원형 버퍼로 구현

연습 문제

  1. 10비트 ADC, 기준전압 5.0V(5000mV)인 회로에서 raw=512를 읽었다. 이 장에서 쓴 변환 공식으로 전압(mV)을 계산하라.
  2. 본문의 "나눗셈을 먼저 해서 정밀도를 날린다" 항목에서, raw / ADC_MAX_CODE * ADC_VREF_MV가 왜 항상 잘못된 값을 내는지 정수 연산 관점에서 설명하라.
  3. 이동평균 필터의 창 크기를 4에서 16으로 늘리면 무엇이 좋아지고 무엇이 나빠지는지 설명하고, 스마트 화분의 토양 습도 측정에는 어느 쪽이 더 나은지 근거를 들어 답하라.
  4. 본문 예제의 SENSOR_DRY_MV, SENSOR_WET_MV 값이 실제 센서와 다르면 습도 계산에 어떤 증상이 나타나는지 설명하고, 이를 바로잡을 방법을 제시하라.

정답과 해설

  1. mV = raw × Vref_mV / 최대코드 = 512 × 5000 / 1023이다. 512 × 5000 = 2,560,000이고, 이를 1023으로 정수 나눗셈하면 2502mV가 나온다(실수로 계산하면 약 2.502V). 최대코드를 1023으로 쓰는 이유는 10비트 분해능이 만드는 단계 수가 1024개(0~1023)이기 때문이다.
  2. raw / ADC_MAX_CODE는 raw가 4095(최대코드)보다 작은 한 정수 나눗셈 결과가 항상 0이다. 0에 어떤 값을 곱해도 0이므로 mV는 항상 0으로 나온다. 곱셈을 먼저 해서 값을 충분히 키운 뒤 나눠야 나눗셈에서 버려지는 소수부가 최소화된다.
  3. 창 크기를 16으로 늘리면 잡음은 더 잘 상쇄되어 값이 더 매끄러워지지만, 실제로 흙이 젖거나 마르는 변화가 필터 출력에 반영되기까지 걸리는 시간도 그만큼 늘어난다. 토양 습도는 물을 준 순간을 빼면 분 단위로 천천히 변하는 값이므로, 반응이 조금 느려지는 대신 잡음을 더 줄이는 큰 창(예: 16)을 써도 실용상 문제가 적다. 다만 급수 직후의 급격한 변화를 빠르게 감지하고 싶다면 창을 너무 키우지 않는 편이 낫다.
  4. 두 기준점이 실제 센서와 다르면, 전압-습도 변환 직선 자체가 어긋나 항상 실제보다 습하거나 건조하게 표시된다. 예를 들어 실제 건조 전압이 2800mV인데 3000mV로 가정하면, 진짜로 마른 상태에서도 습도가 0%보다 높게 나온다. 해결하려면 화분을 완전히 말린 상태와 물에 흠뻑 적신 상태에서 각각 raw 값을 직접 측정해 SENSOR_DRY_MV, SENSOR_WET_MV를 실측값으로 바꿔야 한다.

댓글 0

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

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