Computer Science
탄탄한 기반 실력을 위한
전공과 이론 지식 모음
Today I Learned!
배웠으면 기록을 해야지
TIL 사진
Flutter 사진
Flutter로 모바일까지
거꾸로캠퍼스 코딩랩 Flutter 앱개발 강사
스파르타코딩클럽 즉문즉답 튜터
카카오테크캠퍼스 3기 학습코치
프로필 사진
박성민
임베디드 세계에
발을 들인 박치기 공룡
임베디드 사진
EMBEDDED SYSTEM
임베디드 SW와 HW, 이론부터 실전까지
ALGORITHM
알고리즘 해결 전략 기록
🎓
중앙대학교 소프트웨어학부
텔레칩스 차량용 임베디드 스쿨 3기
애플 개발자 아카데미 1기
깃허브 사진
GitHub
프로젝트 모아보기
Instagram
인스타그램 사진

Embedded System/MCU

[STM32] STM32에서 DHT11 로 온습도 측정하기

sm_amoled 2025. 9. 29. 22:27

지난 번부터 STM 보드에서 DHT11 온습도센서를 연결해보려고 정말 갖은 노력을 다 했었다. 다만 저번에 찾아내 어찌저찌 실행을 해서 센서값을 받아왔던 코드의 경우에는 내가 거의 이해하지 못하는 방식으로 코드가 작성되어 있어서 “이거는 내가 다시 연구를 하면서 이해를 해야겠는데?” 라는 생각이 들던 코드였다.

마침 FND 코드를 추가하려다가 코드를 지워서

(날려먹어서)

새롭게 조금 더 이해하기 명료한 코드를 찾아서 구현해보려고 했다. 여러 게시글들과 유튜브 강의영상들을 찾아 돌아다녔는데, 아래 영상이 데이터 시트를 보는 방법부터 STM 핀 세팅, HW 빵판 선 연결, 코드 작성까지 모든 프로세스를 한 번에 자세하게 설명해주고 있어서 참고해 작성하였다. 사실상 저 영상의 내용을 정리하면서 공부했다.

 

https://www.youtube.com/watch?v=FgD4-Oh3gKs&list=LL

 

 

다만 가장 큰 단점은 포르투칼어로 진행되는 영상이라는 점,,, 이지만 중간중간에 나오는 영어발음은 굉장히 유창하셔서 변수명이나 영어단어를 읽을 때에 ‘이 단어를 말하는거구나!’ 싶은 모먼트가 있었다. 유튜브의 자동 자막 번역을 켜두고 눈치껏 영상을 보고 의도와 의미를 읽어냈다. 물론 코드의 주석과 변수명도 포르투칼어라서 조금 더 집중해서 봤다.

데이터시트부터 시작



우선 칩은 3개의 핀을 필요로 한다. VCC, GND, 그리고 데이터 통신을 위한 핀. 기본적으로 DHT11은 4개의 핀을 달고있지만, 3번 핀은 사용하지 않고 3개의 핀 만을 사용한다. 처음에 데이터시트를 찾아볼 때 가장 이해가 안되던게 요거였다. 내가 가진 DHT11에는 IC칩이 하나 달려서 핀이 3개만 나와있는데 왜 데이터시트들은 다 핀이 4개인 것만 사용하고 있는거지? 라고 생각을 했었는데, 그냥 하나는 안쓰는거라 제외한거였다. ㅋㅋ

문서를 읽어보면, 우선 이 센서는 Single-Wire / Bi-direction 통신을 한다. 한 번에 40bit 단위로 데이터를 주고받고, MSB부터 차례대로 습도의 정수부-소수부, 온도의 정수부-소수부, checksum을 8bit씩 전달한다.

그리고 그 전달에 대한 프로토콜은 아래와 같다.

  1. 처음에 데이터 신호는 SET을 유지하고 있다.
  2. MCU가 데이터 핀과 연결된 GPIO를 Output Mode로 설정한다.
  3. MCU에서 데이터 신호를 RESET으로 낮추었다가 다시 SET 한다.
  4. MCU에서 데이터 신호를 RESET으로 낮추며 이제 Input Mode로 전환해 신호를 읽을 준비를 한다.
  5. DHT11에서 보내주는 40bit의 신호를 읽는다.
  6. 모든 신호의 전달이 끝나면 데이터 신호를 SET으로 유지한다.

여기에서, 앞의 순서에 대해서는 어찌저찌 내가 코드로 구현을 할 수 있을 것 같았는데, ‘그래서 어떻게 읽는건데?’에 대해서는 전혀 갈피를 잡지 못하다가 위 영상에서 코드를 찾아 읽으면서 겨우 이 DHT11의 통신 방법을 이해했다. 그리고 역시나 모든 필요한 정보는 데이터시트에 작성되어있었다 ^,^

 

DHT11이 신호를 보낼 때, 0으로 내렸다가 1로 올리면서 각 bit에 대해 전송을 한다. 다만, 정해진 CLK에 따라서 011011 이렇게 bit를 보내는게 아니라 010101 으로 신호를 보내는데 “1을 얼마나 오래 유지했나”가 SET인지 RESET인지를 구분하는 기준이 된다. 50마이크로초의 0 신호 이후에 1 신호를 대략 27마이크로초를 유지했다면 RESET, 70마이크로초를 유지했다면 1이라고 판단한다.

다시 데이터시트를 살펴보면, 전체 신호에서 드릉드릉을 위한 0→1→0→1 이후에 본격적인 40회의 bit 전달이 시작된다.

 

 

STM CUBE IDE 세팅하기

데이터시트를 보면 0과 1 신호를 구분하기 위해서, 신호를 전달하는 프로토콜을 준수하기 위해서는 마이크로초 ($\mu s$ 이지만 편의상 us라고 하겠음) 단위의 시간 측정이 필요하다. 다만 기본적으로 제공하는 드라이브의 HAL_Delay 함수는 밀리초(ms) 단위의 딜레이와 측정을 제공한다. 1ms는 1,000us 이므로 꽤나 큰 차이가 난다. 그래서 별도의 타이머를 도입해서 시간을 측정할 필요가 있다.

이를 위해서 Configuration에서 타이머를 추가해준다. 나는 TIM2를 선택해주었고, CLK Source를 Internal CLK 으로 변경해주었다. 그리고 아래에 있는 Prescaler의 값을 수정해주어야 하는데, 나는 72-1 (71) 으로 맞춰주었다. 이 값을 결정하는 방법은 아래 사진과 함께 설명할 수 있다.

세팅 메뉴중 CLK Configuration에 오면 아래와 같은 타이머 주파수 설정을 확인할 수 있다. 이 중에서 가장 오른쪽에 있는 APB1 Timer CLK (MHz)의 수치를 확인하면 된다. 나의 경우 이 값이 72로 되어있어서 Prescaler를 71로 설정하였다. 사람에 따라 84나 150 등으로 되어있을텐데, 여기에서 1을 빼주면 된다. 그러면 Prescaler의 동작에 의해 이게 1MHz가 되고, 이게 $10^{-6}s$ 이라서 $1\mu s$ 으로 동작된다.

 

여기 아래위에 있는 다른 값들도 궁금해서 찾아봤는데, APB (Advanced Peripheral Bus)는 주변장치 제어를 위해 사용하는 버스를 일컫는 용어이다. APB1 타이머는 주로 저속 장치를 위해 사용하고, APB2 타이머는 고속 장치를 위해 사용한다고 한다. 그리고 TIM이 각자 사용하는 타이머 번호가 따로 있다고 한다..!!! (어쩐지 APB1 수정하면서 TIM1 을 선택했더니 안되더라…)

APB1 (저속 버스) 연결 타이머들:

  • TIM2, TIM3, TIM4, TIM5 - 기본 범용 타이머
  • TIM6, TIM7 - 기본 타이머 (DAC 트리거용)
  • TIM12, TIM13, TIM14 - 기본 타이머
  • 클럭: 72MHz (APB1 Timer clocks)

APB2 (고속 버스) 연결 타이머들:

  • TIM1 - 고급 제어 타이머 (PWM, 모터 제어)
  • TIM8 - 고급 제어 타이머 (PWM, 모터 제어)
  • TIM9, TIM10, TIM11 - 범용 타이머
  • 클럭: 144MHz (APB2 Timer clocks)

그리고 같은 APB1 이라고 하더라도 Timer CLK과 Pheriperal CLK이 따로 있는 것을 볼 수 있는데, 주변장치는 0-1 / 0-1 단위로 타이머를 사용하면 되지만 타이머 자체에서는 조금 더 정밀한 제어가 필요한 경우가 있어 보통 Pheriperal의 2배 주파수를 사용하는 것으로 보인다. (정확하지는 않음. 팩트체크 필요)

그리고 데이터 통신을 위한 선으로 PA1을 선택해주었다. GPIO 아무 핀이나 선택해도 크게 문제는 없는 것으로 보인다. 아래와 같이 설정해줬다. GPIO mode 와 다른 설정값들은 어차피 코드를 통해 동적으로 변경해주므로 크게 신경쓰지 않아도 되는 듯 하다. 기본값으로 SET 을 가져야 한다고 했으므로 output level 정도만 High로 설정해주었다.

 

코드로 옮기기

이제 이걸 코드로 옮기는 과정이다. 샤라웃 투 포르투칼 아저씨.



부분부분 코드를 쪼개어서 설명하고 전체 코드를 한 번에 확인하자

우선 가장 처음 MCU에서 0 신호를 보내고 다시 1 신호를 보내는 과정이다. 우선 GPIO를 Output Mode로 전환한 뒤, 0 전송→20ms 대기→1 전송→DHT가 보내는 신호를 읽을 준비를 한다. (이렇게 하나의 Pin에 대해서 Output과 Input Mode를 코드로 전환할 수 있다는게 좀 신기했다. 이런것도 결국에는 다 bit 단위 조작을 통해서 변경할 수 있는거구나… 싶었다)

TIM_HandleTypeDef htim2;

void dht11(uint16_t *temperature, uint16_t *humid)
{
        uint16_t tempc, humidc;

    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = DHT11_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct);

    HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET);

    HAL_Delay(20);

    HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET);

    GPIO_InitStruct.Pin = DHT11_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct);

        ... 이후 계속
}

그 다음, 각 신호의 간격을 측정하기 위해 타이머를 활성화한다. 필요한 변수들을 선언 및 초기화해준 뒤, 42번 신호가 1 (SET)을 유지한 시간에 대한 시간 간격을 측정하고 변수에 담는다. 시간 간격은 us 단위로 흘러가는 tim2를 이용해 SET된 시간 / RESET 된 시간을 측정한 뒤 두 값의 차이를 활용한다. 여기에서 42번인 이유는 바로 위 데이터시트의 그림에서도 볼 수 있듯이, 앞서 2번은 데이터 송수신을 위한 준비과정에서 사용되는 시간이고, 40번의 데이터 bit 구분이 추가로 필요하기 때문이다. 반복문을 돌면서 duration에 42번의 시간 간격을 모두 담는다.

/* Private variables ---------------------------------------------------------*/
TIM_HandleTypeDef htim2;

void dht11(uint16_t *temperature, uint16_t *humid)
{        
        ... 중략

    // try tot counter's start
    __HAL_TIM_SET_COUNTER(&htim2, 0);

    // pick the time of start GPIO_PIN_SET / RESET for each ler[0], ler[1]
    uint16_t ler[2];
    // save the time between start and end of signal
    uint16_t duration[42];

    uint8_t bits[40];
    uint16_t temph = 0;
    uint16_t humidh = 0;

    for(int i = 0; i < 42; i++) {
        while(HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET);
        ler[0] = __HAL_TIM_GET_COUNTER(&htim2);
        while(HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET);
        ler[1] = __HAL_TIM_GET_COUNTER(&htim2);
        duration[i] = ler[1] - ler[0];
    }

        ... 이후 계속
}

이제 각 신호의 1을 유지한 시간을 담은 배열인 duration을 순회하면서 각 신호가 0인지 1인지를 구분해 bits 배열에 담아준다. 가장 앞에 있는 두 개의 비트는 의미가 없기 때문에 2번 bit (2번 index) 부터 차례대로 bits 배열에 담는다. 그리고 이 값을 꺼내 사용한다.

아래 예제에서는 checksum에 대한 검사를 따로 진행하지 않고, 실수부도 버리고 단순히 정수부의 값만 가져와 사용해주었다. 이 값을 전체적으로 다루고 싶다면 온도, 습도, 값의 유효성을 다루는 구조체를 하나 만들어서 관리해주고, checksum 값이 틀린 경우에는 is_valid 값을 False로 처리하면 깔끔하지 않을까 싶다.

uint16_t tempdht11, humidht11;
/* USER CODE END PV */

void dht11(uint16_t *temperature, uint16_t *humid)
{
        uint16_t tempc, humidc;

        .. 중략

    for(int i = 0; i < 40; i++) {
        if((duration[i+2] >= 20) && (duration[i+2] <= 32)) {
            bits[i] = 0;
        }
        else if ((duration[i+2] >= 65) && (duration[i+2] <= 75)) {
            bits[i] = 1;
        }
    }

    for(int i = 0; i < 8; i++) {
        temph += bits[i+16] << (7-i);
        humidh += bits[i] << (7-i);
    }

    tempc = temph;
    humidc = humidh;

    *temperature = tempc;
    *humid = humidc;
}

메인함수 쪽에서는 단순히 dht11() 함수 호출만 해주고, 전달한 int 변수에 값을 담아주도록 작성했다. 그리고 디버거를 찍어보면 온도값을 잘 가져오는 것을 확인할 수 있다!

int main()
{
    ...
    // 타이머 시작
    HAL_TIM_Base_Start(&htim2);

    // 초기 대기 (센서 안정화)
    HAL_Delay(2000);
      /* USER CODE END 2 */

      /* Infinite loop */
      /* USER CODE BEGIN WHILE */
    while (1)
    {
            dht11(&tempdht11, &humidht11);

        HAL_Delay(2000);  // 2초마다 읽기
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
      }
}

dht11 함수의 전체 코드

/* Private variables ---------------------------------------------------------*/
TIM_HandleTypeDef htim2;

/* USER CODE BEGIN PV */
// DHT11 설정
#define DHT11_PORT GPIOA
#define DHT11_PIN GPIO_PIN_1

uint16_t tempdht11, humidht11;
/* USER CODE END PV */

/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
static void MX_TIM2_Init(void);
/* USER CODE BEGIN PFP */

/* USER CODE END PFP */

/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */

void dht11(uint16_t *temperature, uint16_t *humid)
{
    uint16_t tempc, humidc;

    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = DHT11_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct);

    HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET);

    HAL_Delay(20);

    HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET);

    GPIO_InitStruct.Pin = DHT11_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct);

    // try to get counter's start
    __HAL_TIM_SET_COUNTER(&htim2, 0);

    // pick the time of start GPIO_PIN_SET / RESET for each ler[0], ler[1]
    uint16_t ler[2];
    // save the time between start and end of signal
    uint16_t duration[42];
    uint8_t bits[40];
    uint16_t temph = 0;
    uint16_t humidh = 0;

    for(int i = 0; i < 42; i++) {
        while(HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET);
        ler[0] = __HAL_TIM_GET_COUNTER(&htim2);
        while(HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET);
        ler[1] = __HAL_TIM_GET_COUNTER(&htim2);
        duration[i] = ler[1] - ler[0];
    }

    for(int i = 0; i < 40; i++) {
        if((duration[i+2] >= 20) && (duration[i+2] <= 32)) {
            bits[i] = 0;
        }
        else if ((duration[i+2] >= 65) && (duration[i+2] <= 75)) {
            bits[i] = 1;
        }
    }

    for(int i = 0; i < 8; i++) {
        temph += bits[i+16] << (7-i);
        humidh += bits[i] << (7-i);
    }

    tempc = temph;
    humidc = humidh;

    *temperature = tempc;
    *humid = humidc;
}

/* USER CODE END 0 */

/**
  * @brief  The application entry point.
  * @retval int
  */
int main(void)
{

  /* USER CODE BEGIN 1 */
  /* USER CODE END 1 */

  /* MCU Configuration--------------------------------------------------------*/

  /* Reset of all peripherals, Initializes the Flash interface and the Systick. */
  HAL_Init();

  /* USER CODE BEGIN Init */

  /* USER CODE END Init */

  /* Configure the system clock */
  SystemClock_Config();

  /* USER CODE BEGIN SysInit */

  /* USER CODE END SysInit */

  /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_TIM2_Init();
  /* USER CODE BEGIN 2 */
    // 타이머 시작
    HAL_TIM_Base_Start(&htim2);

    // 초기 대기 (센서 안정화)
    HAL_Delay(2000);
  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
    while (1)
    {
        dht11(&tempdht11, &humidht11);

        HAL_Delay(2000);  // 2초마다 읽기
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }
  /* USER CODE END 3 */
}

이게 잘 하고 있는게 맞나?

DHT11 이라는 센서에 대해서 딱 적절하게 데이터를 주고받을 수 있는 코드를 작성해서 데이터를 받아왔다고 생각이 들었다. 그러면서도 한 편으로는 “이게 너무 DHT11에만 적용되는 코드이지 않나? 너무 확장성이 없는게 아닌가?” 라는 생각이 들었다. 아무래도 SW 개발을 하던 사람이라 그런지 재활용 병이 좀 있는 것 같다.

오늘 다시 고민을 해보면서는 “어차피 DHT11 센서를 위한 코드를 작성하는거고 각 센서별로 프로토콜을 다르게 설계했을테니 크게 문제가 없다” 라는 판단을 내렸다. 물론 이제 Output Mode, Input Mode 전환이나 타이머로 시간 간격을 측정하는 부분 등은 조금 더 모듈화를 하고 코드분리를 하면 좋겠지만, 각 핀마다 원하는 설정도 다르고, 시간 간격을 위해 사용할 타이머도 다르니깐 함부로 할 수는 없겠다는 걱정도 든다.

박치기 해가면서 또 어떻게 하는게 섹시한 코드인지 배워가는 과정이겠지~

 


25. 10. 14. 추가)

자꾸 온습도 값이 65534 같은 값이 나오는 경우가 있었다. 찾아보니, 이게 데이터를 제대로 읽어오지 못했을 경우에 내보내는 오류 코드인데, 나는 뭔가 값이 간헐적으로 이상하게 나오는 것 같아서 계속 의심만 하고 원인을 찾지 못하고 있었다. 그런데 알고보니깐 이게 풀업 저항을 10K 를 꽂아야 하는데 내가 220짜리 저항을 꽂아서 문제가 된 거였다. 혹시 비슷하게 시행착오를 겪고있다면 저항의 숫자가 올바른지 한 번 확인해보자!!

320x100