STM32 UART 통신 예제: 송수신, 인터럽트 및 DMA 구현 가이드
15 분
- 1. 이번 글에서 만들 시스템
- 2. STM32 UART 통신 예제의 기본 원리
- 3. 사용 부품과 역할
- 4. 회로 연결
- 5. STM32CubeMX 설정
- 6. 먼저 Virtual COM이 동작하는지 확인한다
- 7. G491RE에서 I2C를 통해서 BH1750 값을 읽는다
- 8. Polling 방식 UART 송수신
- 9. UART 인터럽트 방식 (수신)
- 10. UART DMA 방식 (수신)
- 11. 정리
- 참고 문서
핵심 요약
- Polling, Interrupt, DMA 수신 방식의 차이와 CPU·버퍼의 역할을 비교합니다.
- NUCLEO-G491RE, NUCLEO-F446RE와 BH1750을 연결해 실제 센서 데이터를 전송합니다.
- 115200 bps, 8-N-1, TX/RX 교차 연결과 공통 GND 설정을 단계별로 확인합니다.
- DMA Normal 및 Circular 모드와 Receive-to-Idle 처리 방법을 예제로 구현합니다.
Virtual COM, 인터럽트 및 DMA로 센서 데이터 송수신 하기
NUCLEO-G491RE, NUCLEO-F446RE, BH1750
1. 이번 글에서 만들 시스템
STM32 UART 통신 예제를 처음 배울 때는 문자열 하나를 보내는 예제부터 시작하는 경우가 많습니다. 문자열이 터미널에 출력되면 통신이 된 것처럼 보입니다. 하지만 실제 임베디드 시스템에서는 센서가 계속 데이터를 만들고, 다른 MCU가 그 데이터를 놓치지 않고 받아 처리해야 합니다.
이번 글에서는 Nucleo-G491RE에 BH1750 Ambient Light Sensor Module을 연결합니다. G491RE는 센서의 I2C 출력을 읽고, 측정값을 문자열로 만들어 UART로 전송하고 동시에 Virtual COM Port(이하 VCP)를 통해 PC 터미널에 출력을 합니다. STM32F446RE는 UART를 통해 그 문자열을 받아 VCP를 통해 PC 터미널에 출력합니다. 브레드보드에서 기능을 확인한 뒤 센서, UART 커넥터와 테스트 포인트를 작은 인터페이스 PCB로 정리하면 배선 오류를 줄이고 같은 실험 환경을 반복하기 쉽습니다. PCB 제작시에Gerber 파일은 JLCPCB PCB 서비스에 업로드한 뒤 온라인 견적 페이지에서 제작 가능 여부와 비용을 확인할 수 있습니다.
두개의 다른 PC 터미널의 값을 비교함으로써 전송이 잘 이루어졌는지 확인을 할 수 있습니다.

그림 1. BH1750 측정값이 PC 터미널까지 이동하는 전체 흐름
핵심 목표
같은 센서 데이터를 Polling, Interrupt, DMA 세 가지 방식으로 받아 보면서 CPU의 역할과 버퍼 처리 방식이 어떻게 달라지는지 확인합니다.
2. STM32 UART 통신 예제의 기본 원리
UART는 Universal Asynchronous Receiver/Transmitter의 약자입니다. 이름에서 Asynchronous라는 단어가 중요합니다. SPI나 I2C처럼 Clock 선을 따로 연결하지 않고, 송신기와 수신기가 미리 약속한 Baud rate를 기준으로 각 비트의 위치를 판단합니다.
두 사람이 같은 속도로 숫자를 읽기로 약속했다고 생각해 봅시다. 한 사람이 일정한 간격으로 숫자를 말하고, 다른 사람도 같은 간격을 예상해 듣습니다. UART의 Baud rate가 이 공통 속도에 해당합니다. 별도의 Clock 선은 없지만 양쪽의 속도와 Frame 형식은 같아야 합니다.
2.1 UART에 필요한 세 개의 기본 연결
| 신호 | 역할 | 연결 원칙 |
|---|---|---|
| TX | Transmit: 데이터를 내보내는 선 | 한 보드의 TX를 다른 보드의 RX에 연결 |
| RX | Receive: 데이터를 받아들이는 선 | 한 보드의 RX를 다른 보드의 TX에 연결 |
| GND | 두 보드가 전압 기준을 공유 | 두 보드의 GND를 반드시 공통으로 연결 |
주의
동일한 전압으로 동작하는 보드에서는 선 연결만으로 충분하지만 전압이 다를 경우(예를 들어 한쪽은 5V, 다른 한쪽은 3.3V) RX와 TX연결에 로직 레벨 시프트(Logic level shift)가 필요합니다. FET만으로 구현을 하거나 TXS0108E 같은 제품을 사용합니다.
2.2 Baud rate와 8-N-1
이번 실습은 115200 bps, 8-N-1을 사용합니다. 8은 Data bit 8개, N은 Parity 없음(None), 1은 Stop bit 1개를 의미합니다. Start bit까지 포함하면 한 바이트를 보내기 위해 실제 선에서는 보통 10개의 비트가 전송됩니다.

그림 2. UART 8-N-1 Frame의 단순화된 구성
따라서 115200 bps의 이상적인 최대 전송량은 약 11,520 byte/s입니다. 실제 전송률은 메시지 사이의 간격, Software 처리, Buffer 크기에 따라 더 낮아집니다. 이번 실습의 약 6~8바이트 센서 문자열을 180 ms마다 보내는 데는 충분한 속도입니다.
3. 사용 부품과 역할
| 부품 | 역할 | 비고 |
|---|---|---|
| NUCLEO-G491RE | BH1750 I2C 통신및 UART 송신 | USART1(F446RE 통신), LPUART1 VCP, I2C1 |
| NUCLEO-F446RE | UART 수신 및 PC 출력 | USART1(G491RE 통신), USART2 VCP |
| BH1750 | 주변 밝기를 Lux 값으로 출력 (I2C) | GY302 모듈(SDA, SCL) |
| Jumper wire | TX, RX, GND, Sensor 연결 | 짧게 연결 |
| USB cable 2개 | 각 Nucleo 전원, Programming, VCP | Data 통신 케이블 |
| Serial Terminal | PC에서 수신 문자열 확인 (Tera Term) | 115200, 8-N-1 |
3.1 BH1750은 어떤 센서인가?
BH1750은 주변 밝기를 측정하는 디지털 조도 센서입니다. 측정값을 사람이 사용하는 조도 단위인 lux로 변환해 I2C 통신으로 MCU에 전달합니다.
주요 특징:
측정 방식: 디지털 조도 측정
통신 방식: I2C
측정 단위: lux
일반적인 측정 범위: 약 1~65,535 lux
동작 전압: 센서 IC는 약 2.4~3.6V, GY-302 같은 모듈은 보통 3.3V 또는 5V 지원
연결 핀: VCC, GND, SCL, SDA, ADDR

그림 3. BH1750 센서 (GY 302)
4. 회로 연결

그림 4. G491RE, F446RE 및 BH1750의 연결
| BH1750 VCC | G491RE 3V3 | 센서 모듈을 3.3 V로 구동 |
|---|---|---|
| BH1750 GND | G491RE GND | 센서 기준 GND |
| BH1750 SDA | G491RE PB9 | I2C Data |
| BH1750 SCL | G491RE PB8 | I2C Clock |
| BH1750 ADDR | G491RE GND (기본) | LOW: 0x23 / HIGH: 0x5C |
| G491RE PA9 | F446RE PA10 | USART1_TX → USART1_RX |
| G491RE PA10 | F446RE PA9 | USART1_RX ← USART1_TX |
| G491RE GND | F446RE GND | 두 보드의 공통 기준 |
BH1750의 I2C 신호는 NUCLEO-G491RE의 Arduino 호환 핀인 PB9(SDA)과 PB8(SCL)에 연결합니다. G491RE의 ST-LINK VCP는 PA2/PA3의 LPUART1을 사용하고, F446RE의 ST-LINK VCP는 PA2/PA3의 USART2를 사용합니다. 보드 사이 통신에는 양쪽 모두 PA9/PA10의 USART1을 사용합니다.

그림 5. 보드와 센서의 연결
브레드보드에서 여러 보드와 센서를 반복해서 연결하면 TX/RX 교차 연결이나 GND 누락이 자주 발생합니다. 장기적으로 같은 구성을 반복한다면 앞에서 설명한 것처럼 센서와 UART 커넥터를 작은 인터페이스 PCB로 정리해 배선 오류를 줄이고 센서 위치를 고정할 수 있습니다.
5. STM32CubeMX 설정
이 프로젝트에는 두 개의 보드가 필요합니다. 라즈베리파이 와 NUCLEO보드의 조합도 추천할만 하지만 이후에 이어질 시리즈에서 DMA구현을 하기 위해 두 개의 NUCLEO 보드를 사용합니다. 이번 UART 에서는 G491RE 송신을 담당하고, F446RE 수신을 담당합니다. 두 프로젝트의 UART 설정은 반드시 같아야 합니다. 아래의 테이블에서 설정값을 확인할 수 있습니다.
| 항목 | G491RE | F446RE |
|---|---|---|
| Board-to-board UART | USART1 | USART1 |
| TX/RX Pin | PA9 / PA10 | PA9 / PA10 |
| Baud rate | 115200 | 115200 |
| Word length | 8 Bits | 8 Bits |
| Parity | None | None |
| Stop bits | 1 | 1 |
| Hardware flow control | None | None |
| PC Debug UART | LPUART1 / PA2, PA3 / VCP | USART2 / PA2, PA3 / VCP |
5.1 G491RE: I2C 설정
센서의 값을 I2C로 읽어서 F446RE 보드로 송신합니다.
설정은 기본설정을 사용합니다.

I2C는 UART 메시지를 만들 때 한 번씩 Software Trigger로 읽습니다. (Polling 방식)
5.2 G491RE: USART1 TX 설정
TX (송신) 설정은 Polling 방식만을 사용하기 때문에 기본 설정을 그대로 사용해도 됩니다. 다만 속도와 비트 설정은 다시한번 확인하는 것이 좋습니다.

5.3 F446RE: USART1 RX Polling 설정
RX (수신) 설정은 세가지 방법을 사용합니다. 그 중 첫번째는 Polling입니다.
Polling에서는 TX와 마찬가지로 기본적인 설정만을 사용합니다. 역시 속도와 비트 설정은 다시한번 확인하는 것이 좋습니다.

5.4 F446RE: USART1 RX Interrupt 설정
두번째 방법은 수신에서 인터럽트를 사용하는 것입니다. Parameter Settings탭의 설정을 다시 사용하면 됩니다. 이 방법을 사용할 때는 NVIC Settings탭을 열어 다음과 같이 설정합니다. 참고로 NVIC는 Nested Vector Interrupt Controller로 STM32에서 인터럽트를 관리하는 제어기 입니다.

5.5 F446RE: USART1 RX DMA 설정
RX (수신) 에서의 DMA 설정은 다시 두개로 나눌 수 있습니다.
1. Normal – 일반적인 버퍼를 사용합니다.

DMA Request 항목에서 USART1_RX를 선택을 하면 Stream 과 Direction을 자동으로 설정 해 줍니다. 이번 프로젝트에서는 비교적 느린 속도로 데이터를 전송하기 때문에 Priority를 Low로 설정해도 잘 동작합니다.
DMA는 데이터가 전송되는 시작점과 도착점을 중심으로 Memory To Memory 와 Peripheral To Memory, Memory To Peripheral 로 구분할 수 있습니다. 여기서는 UART로 값을 받아서(Peripheral) 버퍼 (Memory)로 전송을 하기 때문에 Direction에서 Peripheral To Memory를 선택하면 됩니다.
Stream은 다른 Stream이 이미 사용이 되고 있지 않다면 기본으로 설정되는 stream을 사용하면 됩니다. 만일 다른 DMA에서 사용이 되고 있다면 값을 수정해야 합니다.
모드는 “Normal”을 선택하면 됩니다. 일반적인 배열 형태의 버퍼를 사용하게 됩니다. 데이터 전송을 할 때 한번에 전송되는 크기를 정할 수 있는데 이번에는 짧은 데이터이고 가변적이기 때문에 Byte로 설정합니다. 하지만 데이터의 크기가 정해져 있다면 Byte 이 외에 다른 크기를 사용해도 됩니다. (이 부분은 통신방법에 따라서 다릅니다)
2. Circular – 원형 (Circular) 버퍼를 사용합니다.

단순히 모드를 바꾸는 것 만으로 Circular 버퍼를 사용할 수 있습니다. 실제 코드에서는 이에 맞는 코드를 만들어주어야 합니다.
5.6 G491RE/F446RE: 보드별 Virtual COM 설정
USART2를 Asynchronous mode로 설정하고 115200, 8-N-1을 사용합니다. NUCLEO-F446RE의 PA2/PA3는 기본적으로 ST-LINK Virtual COM Port와 연결됩니다. PC에서는 보드가 만든 COM Port를 선택하면 됩니다.

G491RE는 PA2/PA3의 LPUART1을 ST-LINK VCP로 사용합니다.

6. 먼저 Virtual COM이 동작하는지 확인한다
두 보드를 바로 연결하기 전에 G491RE의 LPUART1과 F446RE의 USART2로 PC 터미널 출력부터 확인합니다. 문제를 한 번에 여러 개 만들지 않는 것이 중요합니다. VCP 출력이 정상인 것을 확인한 뒤 USART1 보드 간 통신을 추가합니다.
코드 1. F446RE의 USART2 VCP 출력 (코드 일부)
/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART2_UART_Init();
uint8_t msg[] = "Hello World\r\n";
HAL_UART_Transmit(&huart2, msg, sizeof(msg) - 1, HAL_MAX_DELAY );
while (1)
{
}
}
/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART2_UART_Init();
uint8_t msg[] = "Hello World\r\n";
HAL_UART_Transmit(&huart2, msg, sizeof(msg) - 1, HAL_MAX_DELAY );
while (1)
{
}
}
main()의 초기화가 끝난 뒤 다음 “Hello World” 를 PC의 시리얼 터미널을 통해 출력합니다.
만일 터미널에 문자열이 보이지 않으면 G491RE와 F446RE를 연결하지 않습니다. COM Port, Baud rate, USB cable과 보드별 VCP 설정(G491RE LPUART1, F446RE USART2)을 먼저 확인합니다.
G491RE 보드와 F446RE 보드에서 모두 시리얼 터미널을 통해 “Hello World”가 출력이 되면 세가닥의 선을 RX-TX, TX-RX, GND-GND 로 연결 합니다.
추가로 printf 함수를 써서 PC의 시리얼 터미널을 통해 출력을 할 수 있습니다.
코드 2. G491RE의 LPUART1 printf 출력 연결 (코드 일부)
// USER CODE Include 영역에 추가 합니다.
#include <stdio.h>
// USER CODE 4 영역에 추가 합니다.
int __io_putchar(int ch)
{
HAL_UART_Transmit(&hlpuart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_LPUART1_UART_Init();
// 아래의 printf 코드만 추가 합니다.
printf("Hello World\r\n");
while (1)
{
}
}
// USER CODE Include 영역에 추가 합니다.
#include <stdio.h>
// USER CODE 4 영역에 추가 합니다.
int __io_putchar(int ch)
{
HAL_UART_Transmit(&hlpuart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_LPUART1_UART_Init();
// 아래의 printf 코드만 추가 합니다.
printf("Hello World\r\n");
while (1)
{
}
}
출력 스트림을 _write() 함수나 __io_putchar() 함수를 이용해서 UART로 보내는 기능을 추가 하는것입니다. STM32 뿐만 아니라 다른 많은 MCU에서도 비슷한 방법을 사용해서 printf를 UART로 출력하도록 만듭니다.
7. G491RE에서 I2C를 통해서 BH1750 값을 읽는다
UART를 통해서 값을 전달하기 전에 G491RE 보드에서 I2C를 통해 BH1750의 값을 제대로 읽는지 확인해야 합니다.
BH1750의 고해상도 연속 측정 모드는 일반적으로 약 120 ms가 필요하며 조건에 따라 최대 180 ms가 걸릴 수 있습니다. 이 예제는 G491RE가 180 ms마다 I2C 값을 읽고 VCP를 통해 PC 터미널에 출력하도록 구성합니다.
이렇게 단계별로 진행을 하면 나중에 문제가 생겼을 때 원인을 파악하기가 쉬워집니다.
코드를 세부분으로 나누었습니다.
- 설정과 초기화 함수
- BH1750으로부터 값을 읽는 함수와 printf를 위한 함수
- main() 함수에 추가해야 할 내용
BH1750과 통신하는 함수에 대해서는 다루지 않겠습니다. 어디까지나 이 프로젝트의 중심은 UART 이니까요. 그리고 printf를 위한 __io_putchar() 함수는 앞에서 간단하게 설명을 했습니다.
여기서 통신용 함수를 쓰기 위해서는 I2C 초기화가 먼저입니다. BH1750의 ADDR 핀이 GND 또는 LOW이면 7-bit 주소는 0x23이고, VCC 또는 HIGH이면 0x5C입니다. 아래 코드는 ADDR=LOW(0x23)를 기본으로 사용하며, 연속 고해상도 명령 뒤에는 첫 측정이 준비되도록 180 ms를 기다립니다.
while문 내부에서는 180 ms마다 값을 읽고 그 값을 VCP를 통해 printf로 출력합니다.
코드 3. G491RE BH1750 I2C 통신 코드 (코드 일부)
#define BH1750_ADDR (0x23U << 1) // ADDR=LOW; HIGH: (0x5CU << 1)
#define BH1750_POWER_ON 0x01
#define BH1750_CONT_HRES 0x10
I2C_HandleTypeDef hi2c1;
UART_HandleTypeDef hlpuart1;
static HAL_StatusTypeDef BH1750_Init(void)
{
uint8_t cmd;
cmd = BH1750_POWER_ON;
if (HAL_I2C_Master_Transmit(&hi2c1, BH1750_ADDR, &cmd, 1, HAL_MAX_DELAY) != HAL_OK)
{
return HAL_ERROR;
}
cmd = BH1750_CONT_HRES;
if (HAL_I2C_Master_Transmit(&hi2c1, BH1750_ADDR, &cmd, 1, HAL_MAX_DELAY) != HAL_OK)
{
return HAL_ERROR;
}
HAL_Delay(180); return HAL_OK;
}
#define BH1750_ADDR (0x23U << 1) // ADDR=LOW; HIGH: (0x5CU << 1)
#define BH1750_POWER_ON 0x01
#define BH1750_CONT_HRES 0x10
I2C_HandleTypeDef hi2c1;
UART_HandleTypeDef hlpuart1;
static HAL_StatusTypeDef BH1750_Init(void)
{
uint8_t cmd;
cmd = BH1750_POWER_ON;
if (HAL_I2C_Master_Transmit(&hi2c1, BH1750_ADDR, &cmd, 1, HAL_MAX_DELAY) != HAL_OK)
{
return HAL_ERROR;
}
cmd = BH1750_CONT_HRES;
if (HAL_I2C_Master_Transmit(&hi2c1, BH1750_ADDR, &cmd, 1, HAL_MAX_DELAY) != HAL_OK)
{
return HAL_ERROR;
}
HAL_Delay(180); return HAL_OK;
}
static float BH1750_ReadLux(void)
{
uint8_t data[2];
if (HAL_I2C_Master_Receive(&hi2c1, BH1750_ADDR, data, 2, HAL_MAX_DELAY) != HAL_OK)
{
return -1.0f;
}
uint16_t raw = ((uint16_t)data[0] << 8) | data[1];
return ((float)raw / 1.2f);
}
int __io_putchar(int ch)
{
HAL_UART_Transmit(&hlpuart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
static float BH1750_ReadLux(void)
{
uint8_t data[2];
if (HAL_I2C_Master_Receive(&hi2c1, BH1750_ADDR, data, 2, HAL_MAX_DELAY) != HAL_OK)
{
return -1.0f;
}
uint16_t raw = ((uint16_t)data[0] << 8) | data[1];
return ((float)raw / 1.2f);
}
int __io_putchar(int ch)
{
HAL_UART_Transmit(&hlpuart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
int main(void)
{
uint32_t lux_int;
printf("NUCLEO-G491RE BH1750 Test\r\n");
if (BH1750_Init() == HAL_OK)
{
printf("BH1750 initialized successfully.\r\n");
}
else
{
printf("BH1750 initialization failed.\r\n");
}
while (1)
{
float lux = BH1750_ReadLux(); lux_int = (uint32_t)lux;
if (lux >= 0.0f) printf("Light: %lu lux\r\n", lux_int);
else printf("BH1750 read error\r\n");
HAL_Delay(180);
}
}
int main(void)
{
uint32_t lux_int;
printf("NUCLEO-G491RE BH1750 Test\r\n");
if (BH1750_Init() == HAL_OK)
{
printf("BH1750 initialized successfully.\r\n");
}
else
{
printf("BH1750 initialization failed.\r\n");
}
while (1)
{
float lux = BH1750_ReadLux(); lux_int = (uint32_t)lux;
if (lux >= 0.0f) printf("Light: %lu lux\r\n", lux_int);
else printf("BH1750 read error\r\n");
HAL_Delay(180);
}
}
8. Polling 방식 UART 송수신
Polling은 CPU가 UART 상태를 직접 기다리는 방식입니다. 가장 단순하고 Debugging하기 쉽지만, Timeout 동안 CPU가 다른 일을 하기 어렵습니다. 이전 section 에서 설명한 연결과 cubeMX 설정을 다시 한번 확인합니다. Polling 방식에서는 인터럽트와 DMA를 사용하지 않기 때문에 혹시 설정이 되어 있으면 지우면 됩니다.
코드 4. G491RE Polling 송신 (코드 일부)
UART_HandleTypeDef huart1;
int main(void)
{
char txBuffer[16];
while (1)
{
float lux = BH1750_ReadLux();
if (lux >= 0.0f)
{
uint32_t lux_int = (uint32_t)lux;
printf("Light: %lu lux\r\n", lux_int);
int len = snprintf(txBuffer, sizeof(txBuffer), "%lu\r\n", lux_int);
HAL_UART_Transmit(&huart1, (uint8_t *)txBuffer, len, HAL_MAX_DELAY);
}
else
{
printf("BH1750 read error\r\n");
}
HAL_Delay(180);
}
}
UART_HandleTypeDef huart1;
int main(void)
{
char txBuffer[16];
while (1)
{
float lux = BH1750_ReadLux();
if (lux >= 0.0f)
{
uint32_t lux_int = (uint32_t)lux;
printf("Light: %lu lux\r\n", lux_int);
int len = snprintf(txBuffer, sizeof(txBuffer), "%lu\r\n", lux_int);
HAL_UART_Transmit(&huart1, (uint8_t *)txBuffer, len, HAL_MAX_DELAY);
}
else
{
printf("BH1750 read error\r\n");
}
HAL_Delay(180);
}
}
통신을 위한 새로운 Handle을 추가합니다. (huart1)
통신을 위한 버퍼를 추가합니다 (txBuffer)
snprintf함수를 사용해서 BH1750으로부터 읽어온 값을 문자열로 만듭니다.
그리고 이 문자열을 HAL_UART_Transmit 함수를 사용해서 UART로 전송을 합니다.
코드 5. F446RE Polling 수신 (코드 일부)
while (1)
{
if (HAL_UART_Receive(&huart1, &rxChar, 1, HAL_MAX_DELAY) == HAL_OK)
{
if (rxChar == '\n')
{
rxBuffer[rxIndex] = '\0';
if ((rxIndex > 0) && (rxBuffer[rxIndex - 1] == '\r'))
{
rxBuffer[rxIndex - 1] = '\0';
}
char output[32];
int len = snprintf(output, sizeof(output), "Light: %s lux\r\n", rxBuffer);
HAL_UART_Transmit(&huart2, (uint8_t *)output, len, HAL_MAX_DELAY);
rxIndex = 0;
}
else
{
if (rxIndex < sizeof(rxBuffer) - 1)
{
rxBuffer[rxIndex++] = rxChar;
}
else
{
rxIndex = 0;
}
}
}
}
}
while (1)
{
if (HAL_UART_Receive(&huart1, &rxChar, 1, HAL_MAX_DELAY) == HAL_OK)
{
if (rxChar == '\n')
{
rxBuffer[rxIndex] = '\0';
if ((rxIndex > 0) && (rxBuffer[rxIndex - 1] == '\r'))
{
rxBuffer[rxIndex - 1] = '\0';
}
char output[32];
int len = snprintf(output, sizeof(output), "Light: %s lux\r\n", rxBuffer);
HAL_UART_Transmit(&huart2, (uint8_t *)output, len, HAL_MAX_DELAY);
rxIndex = 0;
}
else
{
if (rxIndex < sizeof(rxBuffer) - 1)
{
rxBuffer[rxIndex++] = rxChar;
}
else
{
rxIndex = 0;
}
}
}
}
}
F446RE는 한 바이트씩 Polling으로 받고 줄바꿈 문자를 만날 때까지 문자열을 조립할 수 있습니다. G491RE에서 전송 문자열을 만들 때 마지막에 “\r\n” 가 추가 됩니다.
수신쪽에서는 while 루프를 계속 돌면서 새로운 문자가 들어왔는지 확인을 하고 전송된 문자가 마지막 문자인 “\n” 이면 그 이전의 문자가 “\r” 인지 확인을 하고 “\r” 자리에 “\0”를 넣어서 문자열의 끝으로 만듭니다. 그리고 나서 그 문자열을 사용하면 됩니다. 이 코드에서는 이 문자열을 VCP로 출력을 합니다.
이 방식은 구조를 이해하기 쉽지만, 한 바이트를 받을 때마다 HAL_UART_Receive()를 호출하고 최대 10 ms까지 기다릴 수 있습니다. 센서 외에 Motor control이나 User interface를 함께 처리한다면 불필요한 시간이 추가될 수 있습니다.
main() 함수에 있는 while루프를 흔히들 슈퍼루프(super loop)라고 합니다. 이 슈퍼루프에 코드가 많아지면 많아질수록 의도하지 않은 딜레이가 발생할수도 있습니다. 이러한 딜레이를 없애기 위해서 타이머, 인터럽트, DMA를 사용해서 처리하기도 하고 RTOS(Task)를 사용해서 처리하기도 합니다.
9. UART 인터럽트 방식 (수신)
두 번째 방법으로F446RE의 UART Interrupt를 사용한 수신입니다. BH1750과의 I2C 통신과 G491RE의 UART 데이터 송신은 첫 번째와 동일하게 Polling을 유지합니다. 여기서 수신부의 MCU(F446RE)는 UART 전송 완료나 새 데이터 도착을 계속 기다리지 않으며 문자가 도착할 때 마다 인터럽트를 발생시키고 그 인터럽트를 처리하는 방법을 사용합니다.
앞(5.4)에서 설명한 것 처럼CubeMX의 F446RE 프로젝트에서 USART1 global interrupt를 Enable합니다. Interrupt 예제와 DMA 예제는 같은 빌드에서 동시에 실행하지 말고, 비교할 방식의 초기화와 호출만 사용합니다.
코드 6. 인터럽트 수신 (코드 일부)
UART_HandleTypeDef huart1;
UART_HandleTypeDef huart2;
uint8_t rxChar;
char rxBuffer[16];
volatile uint8_t rxIndex = 0;
volatile uint8_t lineReady = 0;
UART_HandleTypeDef huart1;
UART_HandleTypeDef huart2;
uint8_t rxChar;
char rxBuffer[16];
volatile uint8_t rxIndex = 0;
volatile uint8_t lineReady = 0;
인터럽트에 사용될 변수들을 추가합니다.
MX_USART1_UART_Init()이 끝난 뒤 while 루프에 들어가기 전에 최초 수신을 한 번 시작합니다.
HAL_UART_Receive_IT(&huart1, &rxChar, 1);
while (1)
{
if (lineReady)
{
char output[32];
int len = snprintf(output, sizeof(output), "Light: %s lux\r\n", rxBuffer);
HAL_UART_Transmit(&huart2, (uint8_t *)output, len, HAL_MAX_DELAY);
rxIndex = 0;
lineReady = 0;
}
}
while (1)
{
if (lineReady)
{
char output[32];
int len = snprintf(output, sizeof(output), "Light: %s lux\r\n", rxBuffer);
HAL_UART_Transmit(&huart2, (uint8_t *)output, len, HAL_MAX_DELAY);
rxIndex = 0;
lineReady = 0;
}
}
중요한 점은 while 내부에서는 수신된 데이터를 직접적으로 처리하지 않습니다.
대신에 문자열 수신이 완료되면 그 문자열을 VCP로 출력하는 문자열로 변환하고 출력하는 작업만 수행합니다.
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
if (rxChar == '\n')
{
rxBuffer[rxIndex] = '\0';
lineReady = 1;
}
else if (rxChar != '\r')
{
if (rxIndex < sizeof(rxBuffer) - 1)
{
rxBuffer[rxIndex++] = rxChar;
}
else
{
rxIndex = 0;
}
}
HAL_UART_Receive_IT(&huart1, &rxChar, 1);
}
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
if (rxChar == '\n')
{
rxBuffer[rxIndex] = '\0';
lineReady = 1;
}
else if (rxChar != '\r')
{
if (rxIndex < sizeof(rxBuffer) - 1)
{
rxBuffer[rxIndex++] = rxChar;
}
else
{
rxIndex = 0;
}
}
HAL_UART_Receive_IT(&huart1, &rxChar, 1);
}
}
인터럽트가 발생되면 ISR(Interrupt Service Routine)로 점프하고 설정된 콜백(Callback) 함수를 호출하게 됩니다. 이 콜백함수에서는 문자를 받아서 “\n”이 아니면 문자열에 계속 추가를 하고 “\n” 이면 앞의 polling의 경우와 마찬가지로 “\0” 문자열을 추가해서 VCP로 전송할 문자열을 만듭니다. 그리고 lineReady 변수를 1로 만들어서 하나의 문자열을 다 받았음을 알려 줍니다. 슈퍼 루프에서는 문자열이 다 만들어 졌음을 알고 VCP로 값을 출력합니다. 여기서 lineReady 변수는 전역으로 설정이 되어 있어야 인터럽트 콜백 함수와 main() 함수에서 동시에 사용할 수 있습니다. 데이터 전송의 시간 간격이 충분히 넓기 때문에 Race condition은 고민하지 않아도 됩니다.
참고로 인터럽트는 문자가 하나씩 들어올 때 마다 발생합니다. 그리고 가능하면 콜백 함수를 간단하게 작성하는 것이 좋습니다. 너무 길어지면 다른 코드의 실행이 밀릴수 있기 때문입니다.
10. UART DMA 방식 (수신)
DMA 방식도 인터럽트를 사용합니다. 인터럽트 세팅은 그대로 두고 DMA 세팅을 추가합니다. DMA세팅을하게 되면 아래와 같이 그 스트림에 맞는 인터럽트가 나타나고 이 인터럽트를 추가하면 됩니다. Normal, Circular 모두 공통으로 사용합니다.

10.1 Normal 모드
선형버퍼(배열)을 사용합니다. 세팅은 5.5를 참조하면 됩니다.
코드를 두 부분으로 나누어 보여드립니다.
첫번째는 변수 설정과 초기화 후 while 이전에 들어갈 코드. 그리고 콜백 함수 입니다.
코드 7. DMA Normal 수신 (코드 일부)
// 변수 (Handle 포함)
DMA_HandleTypeDef hdma_usart1_rx;
char rxBuffer[32];
volatile uint8_t dataReady = 0;
volatile uint16_t rxSize = 0;
// 초기화 이후에 사용될 코드
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, (uint8_t *)rxBuffer, sizeof(rxBuffer));
__HAL_DMA_DISABLE_IT(huart1.hdmarx, DMA_IT_HT);
// 콜백 함수
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1)
{
rxSize = Size;
dataReady = 1;
}
}
// 변수 (Handle 포함)
DMA_HandleTypeDef hdma_usart1_rx;
char rxBuffer[32];
volatile uint8_t dataReady = 0;
volatile uint16_t rxSize = 0;
// 초기화 이후에 사용될 코드
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, (uint8_t *)rxBuffer, sizeof(rxBuffer));
__HAL_DMA_DISABLE_IT(huart1.hdmarx, DMA_IT_HT);
// 콜백 함수
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1)
{
rxSize = Size;
dataReady = 1;
}
}
초기화 이후에 사용될 두개의 라인이 있습니다.
첫번째는 HAL_UARTEx_ReceiveToIdle_DMA() 함수 입니다.
이 함수는 UART1으로 들어오는 데이터를 rxBuffer에 저장합니다. 그리고 라인이 IDLE 상태가 되면 수신된 데이터를 처리하도록 설정합니다.
rxBuffer가 수신 버퍼로 설정이 되었기 때문에 CPU의 개입없이 UART1으로 들어온 바이트값을 차례로 rxBuffer[0], rxBuffer[1] … 에 저장을 합니다.
그런데 이 함수는 세개의 다른 수신 이벤트를 발생시킬 수 있습니다.
- 버퍼의 절반까지 수신한 경우: Half Transfer
- 버퍼를 모두 채운 경우: Transfer Complete
- 데이터 수신 도중 UART가 IDLE 상태가 된 경우: IDLE
두번째 경우처럼 버퍼를 모두 채우는 경우에만 사용하려면 HAL_UART_Receive_DMA() 함수를 쓰면 됩니다. G491RE에서 보내는 데이터는 길이가 가변적이라서 버퍼가 가득참을 인식하는 함수는 쓰기 어렵습니다.
이벤트에 있어서 G491RE에서 보내는 데이터는 버퍼를 모두 채우는 경우에 사용하지도 않고 버퍼의 절반까지 수신한 경우에도 사용하지 않습니다. 그래서 첫번째 경우에 발생하는 이벤트를 발생하지 않도록 해야 하는데 그 코드가 다음줄에 있는 __HAL_DMA_DISABLE_IT() 함수 입니다. 물론 모두 채운 경우도 없애고 싶겠지만 이 이벤트는 버퍼가 가득 찼을 때 (Full)를 알아야 하기 때문에 disable 하지 않습니다.
참고로 DMA_IT_HT에서 HT는 Half Transfer의 약자입니다.
결국 우리가 받은 데이터 처리만 잘 하면 버퍼가 가득 차지 않을 것이고 한가지 경우, 즉 UART가 IDLE 상태가 될 때만 콜백 함수가 실행이 될 것입니다. 예제가 아니라 실제 프로젝트에서는 버퍼가 full 될 때의 상황도 고려해야 합니다.
while (1)
{
if (dataReady)
{
char output[48];
uint16_t len = rxSize;
while ((len > 0) && ((rxBuffer[len - 1] == '\r') || (rxBuffer[len - 1] == '\n')))
{
len--;
}
if (len < sizeof(rxBuffer))
{
rxBuffer[len] = '\0';
}
else
{
rxBuffer[sizeof(rxBuffer) - 1] = '\0';
}
int outLen = snprintf(output, sizeof(output), "Light: %s lux\r\n", rxBuffer);
HAL_UART_Transmit(&huart2, (uint8_t *)output, outLen, HAL_MAX_DELAY);
dataReady = 0;
rxSize = 0;
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, (uint8_t *)rxBuffer, sizeof(rxBuffer));
__HAL_DMA_DISABLE_IT(huart1.hdmarx, DMA_IT_HT);
}
}
}
while (1)
{
if (dataReady)
{
char output[48];
uint16_t len = rxSize;
while ((len > 0) && ((rxBuffer[len - 1] == '\r') || (rxBuffer[len - 1] == '\n')))
{
len--;
}
if (len < sizeof(rxBuffer))
{
rxBuffer[len] = '\0';
}
else
{
rxBuffer[sizeof(rxBuffer) - 1] = '\0';
}
int outLen = snprintf(output, sizeof(output), "Light: %s lux\r\n", rxBuffer);
HAL_UART_Transmit(&huart2, (uint8_t *)output, outLen, HAL_MAX_DELAY);
dataReady = 0;
rxSize = 0;
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, (uint8_t *)rxBuffer, sizeof(rxBuffer));
__HAL_DMA_DISABLE_IT(huart1.hdmarx, DMA_IT_HT);
}
}
}
DMA는 기본적으로 한번 사용이 되고 나면 다시 설정을 해 주어야 합니다. 인터럽트처럼 한번 설정하고 끝이 나는 방식이 아닌 거죠. 그래서 while 루프안에 설정코드(두개의 라인)를 추가했습니다. 그런데 이게 귀찮을 때가 있습니다. 그냥 계속해서 데이터를 받고 싶을 때 쓰는 방법이 바로 Circular 모드입니다.
10.2 Circular 모드
원형 버퍼를 사용합니다. 세팅은 5.5의 Circular 모드를 참조하면 됩니다.
원형 버퍼는 일반 선형 버퍼와 달리 버퍼의 처음과 끝이 만나는 구조입니다.

그림 6. Circular Buffer
개념적으로 그렇다는 것이지 실제 이렇게 구조를 만들 수는 없습니다. 다만 버퍼의 마지막까지 데이터가 들어가면 다음 바이트(혹은 구조에 따라 다른)를 버퍼의 처음에 넣는 작업을 하도록 코드를 구현하는 것이죠.
이렇게 작업을 하려면 현재 문자열의 Head 위치와 Tail 위치를 알고 있어야 합니다. 초기화 한 후에는 Head의 위치가 Tail의 위치와 같습니다. 그리고 데이터가 하나씩 추가되면서 Tail값이 계속 증가하고 버퍼에 들어있던 데이터를 하나씩 가져가면 Head값을 증가시켜 줍니다. 그리고 Head와 Tail이 만나는 경우에 처리를 해 주어야 합니다. Head의 인덱스가 증가해서 Tail과 같아지면 Empty가 되는 것이고 Tail의 인덱스가 증가해서 Head의 인덱스와 같아지면 Buffer가 full 이 되는 것입니다.
이 내용을 알고 코드를 보겠습니다.
코드 8. DMA Circular 수신 (코드 일부)
UART_HandleTypeDef huart1;
UART_HandleTypeDef huart2;
DMA_HandleTypeDef hdma_usart1_rx;
/* USER CODE BEGIN PV */
#define RX_BUFFER_SIZE 64
#define MSG_BUFFER_SIZE 16
uint8_t rxBuffer[RX_BUFFER_SIZE];
char msgBuffer[MSG_BUFFER_SIZE];
uint8_t msgIndex = 0;
volatile uint16_t headPos = 0;
volatile uint16_t tailPos = 0;
volatile uint8_t dataReady = 0;
UART_HandleTypeDef huart1;
UART_HandleTypeDef huart2;
DMA_HandleTypeDef hdma_usart1_rx;
/* USER CODE BEGIN PV */
#define RX_BUFFER_SIZE 64
#define MSG_BUFFER_SIZE 16
uint8_t rxBuffer[RX_BUFFER_SIZE];
char msgBuffer[MSG_BUFFER_SIZE];
uint8_t msgIndex = 0;
volatile uint16_t headPos = 0;
volatile uint16_t tailPos = 0;
volatile uint8_t dataReady = 0;
중요하게 봐야 할 부분이 바로 Head와 Tail 입니다.
여기서는 headPos와 tailPos라는 변수로 설정을 했습니다.
MX_DMA_Init()과 MX_USART1_UART_Init()이 끝난 뒤 while 루프 전에 Circular DMA 수신을 한 번 시작합니다. Half Transfer 이벤트를 사용하지 않을 때는 두 번째 줄도 함께 적용합니다.
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, (uint8_t *)rxBuffer, RX_BUFFER_SIZE);
__HAL_DMA_DISABLE_IT(huart1.hdmarx, DMA_IT_HT);
코드가 복잡하게 보이는 것을 막기 위해 함수를 하나 추가합니다. while문에서 사용되는 함수입니다. 이전에 사용하던 기능과 차이가 없습니다. 문자열을 만들고 전송하는 바로 그 기능입니다.
static void ProcessRxByte(uint8_t ch)
{
if (ch == '\n')
{
msgBuffer[msgIndex] = '\0';
char output[32];
int len = snprintf(output, sizeof(output), "Light: %s lux\r\n", msgBuffer);
HAL_UART_Transmit(&huart2, (uint8_t *)output, len, HAL_MAX_DELAY);
msgIndex = 0;
}
else if (ch != '\r')
{
if (msgIndex < MSG_BUFFER_SIZE - 1)
{
msgBuffer[msgIndex++] = (char)ch;
}
else
{
msgIndex = 0;
}
}
}
static void ProcessRxByte(uint8_t ch)
{
if (ch == '\n')
{
msgBuffer[msgIndex] = '\0';
char output[32];
int len = snprintf(output, sizeof(output), "Light: %s lux\r\n", msgBuffer);
HAL_UART_Transmit(&huart2, (uint8_t *)output, len, HAL_MAX_DELAY);
msgIndex = 0;
}
else if (ch != '\r')
{
if (msgIndex < MSG_BUFFER_SIZE - 1)
{
msgBuffer[msgIndex++] = (char)ch;
}
else
{
msgIndex = 0;
}
}
}
콜백함수 역시 위치를 설정해 줍니다.
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1)
{
tailPos = Size;
dataReady = 1;
}
}
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1)
{
tailPos = Size;
dataReady = 1;
}
}
While 루프의 내용은
while (1)
{
if (dataReady)
{
if (tailPos > headPos)
{
for (uint16_t i = headPos; i < tailPos; i++)
{
ProcessRxByte(rxBuffer[i]);
}
}
else if (tailPos < headPos)
{
for (uint16_t i = headPos; i < RX_BUFFER_SIZE; i++)
{
ProcessRxByte(rxBuffer[i]);
}
for (uint16_t i = 0; i < tailPos; i++)
{
ProcessRxByte(rxBuffer[i]);
}
}
headPos = tailPos;
dataReady = 0;
}
}
while (1)
{
if (dataReady)
{
if (tailPos > headPos)
{
for (uint16_t i = headPos; i < tailPos; i++)
{
ProcessRxByte(rxBuffer[i]);
}
}
else if (tailPos < headPos)
{
for (uint16_t i = headPos; i < RX_BUFFER_SIZE; i++)
{
ProcessRxByte(rxBuffer[i]);
}
for (uint16_t i = 0; i < tailPos; i++)
{
ProcessRxByte(rxBuffer[i]);
}
}
headPos = tailPos;
dataReady = 0;
}
}
첫번째 if 문에서 tailPos > headPos 의 의미는 아직 버퍼가 한번도 full이 되지 않았거나 짝수번 full이 된 경우입니다.(사실 full이라고 말하기 보다는 tail이 head 인덱스 보다 뒤에 있다는 말이 더 맞을 것 같습니다. 원형이기 때문에 full 이라는 말이 맞지 않을 수 있습니다) 두번째 if 문은 tail이 head 보다 인덱스가 앞이고 이는 tail이 버퍼의 끝까지 갔다가 다시 0으로 돌아온 경우가 됩니다. (잘 생각해 보시면 원형이라고 하지만 실제는 선형 버퍼라서 그렇습니다). 각각의 경우를 처리하는 코드가 되겠습니다. ProcessRxByte() 함수를 사용해서 보기 쉽게 만들었습니다.
결과 동영상을 첨부했습니다. 잘 동작 한다면 Polling, Interrupt, DMA 모두 결과는 같게 출력이 됩니다.

11. 정리
이번 글에서는 G491RE를 Transmitter, F446RE를 Receiver로 고정했습니다. G491RE의 BH1750 값을 Polling으로 측정한 뒤 USART1을 통해 F446RE에 전송하고, 각 보드의 PC Virtual COM Port에서 결과를 확인했습니다.
가장 중요한 차이는 CPU가 데이터를 기다리고 옮기는 방식입니다. Polling에서는 CPU가 직접 기다리고, Interrupt에서는 Data가 도착했을 때 CPU가 호출되며, DMA에서는 Peripheral과 Memory 사이의 반복적인 이동을 Hardware가 담당합니다.
다음 ADC 글에서는 이 프로젝트의 센서 측정 부분에서 다른 센서를 사용합니다. 이번 프로젝트에서 I2C를 사용해서 값을 읽었다면 다음에는 가변저항과 TEMT6000을 Multi-channel로 읽고, Timer Trigger와 Circular DMA를 이용해 일정한 Sample rate로 수집한 뒤 LED PWM 밝기를 피드백 제어합니다.
참고 문서
• STMicroelectronics, Getting started with UART
• STMicroelectronics, UM2505 - STM32G4 Nucleo-64 boards
• STMicroelectronics, UM1724 - STM32 Nucleo-64 boards
• STMicroelectronics, RM0440 - STM32G4 Reference Manual
• STMicroelectronics, RM0390 - STM32F446 Reference Manual
• STMicroelectronics, Getting started with DMA
• STMicroelectronics, AN4031 - Using the STM32F2/F4/F7 DMA controller
지속적인 성장
STM32 CAN 통신 예제: CubeMX와 HAL로 송수신 구현하기
핵심 요약 Classic CAN과 CAN FD의 프레임 및 비트 타이밍 차이를 설명합니다. NUCLEO-G491RE와 Jetson TX2를 CAN 트랜시버로 연결합니다. CubeMX 필터·인터럽트 설정과 HAL 기반 송수신 코드를 구현합니다. 종단저항, CANH·CANL, candump와 오류 응답을 이용한 점검 방법을 다룹니다. Nucleo-G491RE 보드와 Jetson 보드를 이용한 CAN 버스 통신 이 글에서는 STM32 CAN 통신 예제로 NUCLEO-G491RE와 Jetson TX2를 연결하고, Classic CAN과 CAN FD의 비트 타이밍·필터·수신 인터럽트·응답 송신을 단계별로 구현합니다. 브레드보드에서 트랜시버와 종단저항, CANH·CANL 연결을 검증한 뒤 커넥터와 테스트 포인트를 작은 CAN 인터페이스 PCB로 정리하면 반복 시험과 배선 점검이 쉬워집니다. PCB 제작시Gerber 파일은 JLCPCB PCB 서비스에 업로드하고 온라인 견적에서 제작 가능 여부와 비용을 확......
STM32 PWM 제어 가이드: 주파수·듀티비 설정과 모터 제어 예제
핵심 요약 타이머 Prescaler와 ARR을 이용한 PWM 주파수·듀티비 계산 방법을 정리합니다. NUCLEO-G491RE에서 서보모터와 DC 모터를 PWM으로 제어합니다. 단일 채널 및 A/B상 인코더를 읽어 회전 방향과 RPM을 계산합니다. CubeMX 설정, HAL 코드와 VCP 출력 결과를 실제 실습 이미지로 확인합니다. Nucleo-G491RE 보드와 DC 모터/인코더를 이용한 실습 이 글에서는 STM32 PWM의 주파수·듀티비 계산 원리를 익히고, NUCLEO-G491RE의 타이머와 카운터를 이용해 서보모터·DC 모터를 구동하며 단일 채널 및 A/B상 인코더를 읽습니다. 브레드보드에서 모터 드라이버와 인코더 동작을 확인한 뒤 전원·신호 커넥터와 테스트 포인트를 작은 인터페이스 PCB로 정리하면 배선 오류를 줄이고 재현성을 높일 수 있습니다. PCB 제작시Gerber 파일은 JLCPCB PCB 서비스에 업로드하고 온라인 견적에서 제작 가능 여부와 비용을 확인할 수 있습니다. 타이머/......
STM32 ADC 사용법: 전압 측정, 인터럽트·DMA와 DAC 피드백
핵심 요약 STM32 ADC의 해상도, 샘플링과 디지털 값을 전압으로 환산하는 원리를 설명합니다. NUCLEO-F446RE에서 Polling, Interrupt, DMA 방식으로 ADC 데이터를 수집합니다. TEMT6000 조도 센서와 다중 채널 ADC 설정 및 Circular DMA 처리를 구현합니다. DAC 출력과 LED 조명 피드백을 연결해 간단한 실시간 제어를 확인합니다. 이 글에서는 STM32 ADC의 기본 원리와 전압 계산 방법을 살펴보고, NUCLEO-F446RE에서 TEMT6000의 밝기 값을 Polling·인터럽트·DMA로 읽어 DAC 기반 LED 조명 피드백까지 구현합니다. 브레드보드에서 센서와 LED 드라이버의 동작을 확인한 뒤 커넥터와 테스트 포인트를 작은 인터페이스 PCB로 정리하면 배선 오류를 줄이고 같은 실험 환경을 반복하기 쉽습니다. PCB 제작시에Gerber 파일은 JLCPCB PCB 서비스에 업로드하고 온라인 견적에서 제작 가능 여부와 비용을 확인할 수 있습니다......
STM32 UART 통신 예제: 송수신, 인터럽트 및 DMA 구현 가이드
핵심 요약 Polling, Interrupt, DMA 수신 방식의 차이와 CPU·버퍼의 역할을 비교합니다. NUCLEO-G491RE, NUCLEO-F446RE와 BH1750을 연결해 실제 센서 데이터를 전송합니다. 115200 bps, 8-N-1, TX/RX 교차 연결과 공통 GND 설정을 단계별로 확인합니다. DMA Normal 및 Circular 모드와 Receive-to-Idle 처리 방법을 예제로 구현합니다. Virtual COM, 인터럽트 및 DMA로 센서 데이터 송수신 하기 NUCLEO-G491RE, NUCLEO-F446RE, BH1750 1. 이번 글에서 만들 시스템 STM32 UART 통신 예제를 처음 배울 때는 문자열 하나를 보내는 예제부터 시작하는 경우가 많습니다. 문자열이 터미널에 출력되면 통신이 된 것처럼 보입니다. 하지만 실제 임베디드 시스템에서는 센서가 계속 데이터를 만들고, 다른 MCU가 그 데이터를 놓치지 않고 받아 처리해야 합니다. 이번 글에서는 Nucleo-G4......
EasyEDA에서 커스텀 PCB 풋프린트 만드는 방법
필요한 PCB 풋프린트가 EasyEDA 부품 라이브러리에 없다면 직접 만들 수 있습니다. 풋프린트 라이브러리 편집기는 PCB 편집기와 유사한 도구를 사용하지만 일부 불필요한 기능은 표시되지 않을 수 있습니다. 풋프린트는 부품 본체의 크기, 패키지 형식, 패드 크기, 핀 길이와 폭, 핀 간격 등 실제 실장에 필요한 정보를 도형으로 표현한 PCB 설계 데이터입니다. 기호 라이브러리 작성과 기본 흐름이 비슷하며, 이 글에서는 EasyEDA에서 커스텀 풋프린트를 만드는 과정을 단계별로 설명합니다. 회로도에 관한 내용은 전기 회로도 설계 가이드를 참고하십시오. PCB 패드와 풋프린트 설계 PCB 설계에서는 부품을 선택하고 보드 위에 배치한 뒤 각 부품을 전기적으로 연결합니다. 부품마다 핀 수, 핀 배열과 패키지 형식이 다릅니다. PCB 패드는 부품 리드나 단자를 납땜하는 노출 금속 영역이며, 패드를 정확한 위치에 배열하고 외곽선과 조립 정보를 더한 것이 풋프린트입니다. 부품 라이브러리 작성은 크게 두 ......
PCB 레이어 수 선택 방법: 단면·양면·다층 PCB 설계 가이드
PCB 설계를 시작할 때 가장 중요한 결정 중 하나는 동박 레이어 수를 정하는 것입니다. 레이어 수는 배선 공간, 신호 무결성, 전원 분배, 방열, 기판 크기와 제조 비용에 영향을 줍니다. 이 글에서는 프로젝트에 적합한 PCB 레이어 수를 판단하는 기준과 단면·양면·다층 PCB의 설계 지침을 설명합니다. PCB 레이어 수를 결정하는 기준 회로 복잡도 분석 부품 수가 많고 연결 관계가 복잡할수록 더 많은 배선 공간이 필요합니다. 부품 밀도가 높거나 BGA와 같이 핀 수가 많은 부품을 사용하면 추가 신호 레이어가 필요할 수 있습니다. 여러 고속 신호나 민감한 아날로그 신호가 포함된 설계에서는 전원 및 그라운드 플레인을 별도로 배치할 수 있는 추가 레이어가 신호 무결성 향상에 도움이 됩니다. 연속적인 리턴 경로를 확보하면 노이즈, 신호 간섭과 크로스토크를 줄일 수 있습니다. 기판 크기와 공간 제약 고려 PCB가 소형 전자기기 내부에 들어가야 한다면 사용할 수 있는 기판 면적이 제한됩니다. 이 경우 ......