x0FANSA-hk 님의 블로그
ESP-NOW 간략하게 다뤄보기 본문
저는 수십개의 ESP32를 가지고 있습니다. 종류도 다양하게 가지고 있습니다.(CPU만 해도 C3, S3갈리고... 보드도 WROOM, ZERO... 추가스펙으로 OLED가 붙던가, PSRAM이 붙던가...) 당연히 죄다 특성과 사용법이 다르고 사용처도 다릅니다만, 공통적으로 무선통신이 필요하거나 비교적으로 좋은 CPU가 필요할때 사용합니다.(반대로, 가볍게 한번 사용하는 용도로는 atmega328계열을 사용하죠.) 즉, 같은 ESP32 가족이기에 여러가지 공통점이나 호환성이 있습니다. 그중에서도 제가 가장 유용하다싶은게 ESP-NOW 입니다. 오늘은 이 ESP-NOW의 기초적인 내용에 대해 적어볼까 합니다.
ESP-NOW란
ESP시리즈를 만든 Espressif Systems사에서 ESP32 패밀리들을 위해 개발한 ESP32패밀리 전용 무선통신 프로토콜입니다. 기존의 Wi-Fi 통신의 경우 같은 휴대폰이 근접해 있어도 보통은 공유기를 호스트로, 스마트폰들이 게스트로 이어져서 공유기 안에서 만나는 방식이었습니다. 이 방식은 많은 데이터를 주고받을 수 있지만 IP를 할당받거나, 전력소모가 크거나, 지연이 있거나 하는 문제가 있었죠. 그래서 적어도 스마트폰 정도는 되야 가능할 뿐, 기성 스마트폰 성능의 1/100도 안되는 ESP32가 감당하기에는 힘든 일입니다. 그래도 인터넷통신을 쓰려면 어쩔수 없이 공유기에 연결하지만, ESP32끼리만 통신할때는 '굳이 그럴 필요가 있나' 라는 아이디어에서 나오게 되었습니다. 즉 ESP32끼리 통신할 경우 공유기와 복잡한 절차없이 직접 통신하는 방식입니다.
ESP-NOW의 특징
예시를 하나 들어볼게요. 둘다 밥약속을 잡을 목적으로 대화하는 거에요. 아래의 대화 메세지중에서, 친하지는 않고 아는 지인과 격식없이 친한친구와의 대화는 각각 무엇일까요?


높은 확률로 왼쪽사진을 지인, 오른쪽이 친구일거에요. 둘의 대화방식 차이가 뭘까요? 저는 이 차이를 가지고 ESP-NOW(친구)와 일반 AP/STA통신(지인)에 관해 비유하고 싶습니다. 이를통해 알수있는 ESP-NOW의 장점은 다음과 같습니다.
1. 빠르다.: 카톡 시간을 보면 지인과의 대화는 몇십분씩 걸린 반면, 친구와의 대화는 1분안에 끝났습니다. 불필요한 내용을 다 제거하고 간략히 통신하기에 ESP-NOW는 AP/STA에 비해 훨씬 빠릅니다.
2. 본인확인이 중요하지 않다.: AP/STA의 경우 발신자가 누구인지, 수신자가 누구인지, 잠깐 시간내서 대화해도 되는지를 전부 확인해야 합니다. 그러나 ESP-NOW전후상황 안따지고 발신자가 그냥 코드내에 있는 주소로 데이터를 던져버립니다. 받았나 안받았나(카카X톡으로는 읽음 표시)만 확인하고 끝납니다.
3. 일반적으로 언제나 보내도 된다.: AP/STA의 경우 반드시 AP/STA모드가 켜져 있어야 하는데 이것이 전력소비가 큽니다. 따라서 전력소비를 줄이기 위해 AP/STA모드를 꺼둘때가 있어서, 보통은 AP/STA모드를 특정 시간대에만 켜 놓고 통신하는 불안정한 방식을 사용합니다. 결정적으로 AP/STA는 통신을 준비하는 절차부터 3초정도로 오래걸립니다. 반대로 ESP-NOW의 경우에는 AP모드 자체가 필요없고, 모든 절차를 생략하고 안테나에 전력만 인가하면 되기에, 준비하는 시간이 적습니다. 따라서 ESP-NOW의 STA모드는 보통 100ms라는 짧은 주기마다 온오프를 하기 때문에 전력소모를 줄이면서 거의 실시간성을 유지가능합니다.
4. 절차가 간락하다.: AP/STA는 지인에게 연락할때 높임말을 쓰고 문장으로 적는것처럼, 하드웨어 연결, 패킷 분할, IP 주소 할당, TCP/UDP 포트 지정, HTTP/MQTT 프로토콜 이라는 복잡하고 많은 범용 절차가 필요합니다. 반대로, ESP-NOW는 친구들끼리는 말풍선 6개를 넘어가질 않는것처럼, 간단한 전용 규격으로 빠르게 통신합니다.
5. 많은 내용을 보내지 않는다.: 친구들과의 대화에서는 구체적으로 뭘 정하질 않고 간략하게 끝냅니다. 이처럼 ESP-NOW도 큰 데이터를 보내지 않습니다.(왜 우리 친구끼리 문장이나 장문은 잘 안보내잖아요.)
6. 어쨌든 STA모드를 켜야한다.: 3번항목의 연장선인데, 어쨌든 STA모드가 켜져야 뭘 주건 받건 합니다. 문제는 STA모드를 유지하는것 자체도 배터리 환경에서는 부담될 수 있습니다. 사실상 친구가 카톡을 보기 위해 상시 폰을 보고있는 상태와 비슷하기 때문이죠. 따라서 현명하게 특정주기마다 확인하는 등의 편법을 써야 합니다.
ESP-NOW 간략하게 실습하기
일단 소규모로 ESP-NOW를 간략하게 실습해 볼게요. 마스터 ESP32로는 ESP32-S3-N16R8을 사용하였습니다. 공유기가 하던 역할을 마스터 ESP32가 전부 해야하기에 고사양 보드가 필요하기 때문이죠.(나중에 호스트-게스트 방식으로 ESP-NOW IoT를 구현할 경우 이러한 구조가 유리합니다.) 그리고 이 마스터 ESP32에게는 상시 전원을 공급할 것입니다. 슬레이브ESP32는 ESP32-S3-ZERO와 ESP32-C3-OLED를 사용하였습니다. 둘 다 Supermini보드로 매우작고, 서로 다른 기본입출력(S3는 3색 LED, C3는 OLED)을 가지고 있기에 괜찮을 것 같습니다.

1단계. MAC Address 알아오기
#include <WiFi.h>
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
}
void loop() {
delay(1000);
Serial.print("MAC 주소: ");
Serial.println(WiFi.macAddress());
Serial.print("\n");
}
우선 ESP-NOW를 위해서는 해당 장치의 고유한 연결주소, 즉 MAC Address가 필요합니다. 따라서 위의 코드는 각 기기를 하나씩 유선연결하여, 시리얼 모니터로 김유식의 맥어드레스를 기기의 맥어드레스를 출력하는 코드입니다. 아래는 장치연결과 출력된 결과의 이미지입니다.






각각 출력결과는 다음과 같습니다.(당연히 기기 고유값이므로 여러분들중 누군가가 실험을 한다면 다른 값을 가질 것이기에, 직접 얻어와야합니다.)
N16R8: 90:70:69:18:A4:04
Zero: E0:72:A1:E6:B2:B0
OLED: 14:63:93:75:F2:8C
2단계. ESP32 S3 N16R8 코드 작성
[env:esp32-s3-devkitm-1]
platform = espressif32
board = esp32-s3-devkitm-1
framework = arduino
monitor_speed = 115200
#include <esp_now.h>
#include <WiFi.h>
uint8_t zero_address[]={0xE0,0x72,0xA1,0xE6,0xB2,0xB0};
uint8_t oled_address[]={0x14,0x63,0x93,0x75,0xF2,0x8C};
esp_now_peer_info_t zero_peer;
esp_now_peer_info_t oled_peer;
struct dearI{
char text[32];
int i;
};
int j;
char buffer[32];
struct dearI zeroI;
struct dearI oledI;
const int dataS=sizeof(struct dearI);
static inline void liking_peer(esp_now_peer_info_t *peer, uint8_t *ADDRESS){
memset(peer,0,sizeof(esp_now_peer_info_t));
memcpy(peer->peer_addr, ADDRESS, 6);
peer->channel=0;
peer->encrypt=false;
if (esp_now_add_peer(peer)!=ESP_OK){
Serial.println("피어 등록 실패");
return;}
}
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
Serial.print("Receive: ");
Serial.println(status==ESP_NOW_SEND_SUCCESS ? "Complete\n" : "Fail\n");
}
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
if (esp_now_init()!=ESP_OK) {
Serial.println("ESP-NOW 초기화 실패");
return;}
esp_now_register_send_cb(OnDataSent);
liking_peer(&zero_peer, zero_address);
liking_peer(&oled_peer, oled_address);
zeroI.i=0;
oledI.i=0;
}
void loop() {
//zero먼저 송신
strcpy(zeroI.text, buffer);
esp_err_t zero_R=esp_now_send(zero_address, (uint8_t *) &zeroI, dataS);
zeroI.i++;
Serial.print("\nSend: ");
Serial.printf("%s\n", (zero_R==ESP_OK) ? "Complete" : "Fail");
delay(100);
yield();
//oled송신
strcpy(oledI.text, buffer);
esp_err_t oled_R=esp_now_send(oled_address, (uint8_t *) &oledI, dataS);
oledI.i++;
Serial.print("\nSend: ");
Serial.printf("%s\n", (oled_R==ESP_OK) ? "Complete" : "Fail");
delay(800);
yield();
//문자열보내기(폴링방식)
Serial.printf("Input send Message: ");
while (Serial.available()>0) {
char receivedChar=Serial.read();
if (receivedChar=='\n') {
buffer[j]='\0';
Serial.println(buffer);
Serial.print("\n");
j=0;
}
else if (j<31) {
buffer[j]=receivedChar;
j++;
}
}
delay(100);
yield();
}
N16R8이 송신을 하는 아이였습니다. 코드해석을 간략히만 해볼게요.
1. 송신을 하려면 어디로 보낼 지 목적지가 있어야 합니다. 따라서, 이전에 얻어왔던 맥어드레스를 전역변수로 선언하여 목적지로 사용하게 했습니다.
2. 실제로 송신을 하기 위해서는 맥주소가 중요한것도 맞지만, 이 외에도 다른 통신에 필요한 변수들도 있습니다. 이때 필요한 변수들이 esp_now_peer_info_t라는 구조체에 이미 구현되어 있습니다. 이 코드에서는 해당 구조체를 liking_peer()함수로 적절한 값을 대입하여 일반적인 ESP-NOW 통신을 하게 세팅하였습니다.
3. dearI라는 구조체는 ESP-NOW통신으로 보낼 데이터의 구조 입니다. ESP-NOW로의 통신은 결국 2진수데이터를 보내는 건데, 이를 해석하려면 송신기와 수신기가 같은 자료형을 가지는게 편리합니다. 즉, 여기서 dearI라고 구조체를 만들면, 수신기에도 똑같은 자료형의 구조체를 선언하는 것이 편리합니다. 따라서 이번 코드에서는 dearI라는 구조체형태로 데이터를 보내겠다고 명시하였습니다.
4. 어쨌는 ESP-NOW도 WiFi모듈을 사용하기때문에 STA모드를 켜야합니다. setup()함수내에서 WiFi STA를 설정하고, esp_now_peer_info_t에 전송여부함수를 대입하는 등, 통신을 위핸 사전준비를 합니다.(여기는 모르겠으면 AI치거나 제 예제코드를 복붙해도 문제없습니다.)
5. loop()함수는 주석에 적혀있는 것처럼 실제로 송신을 하고, 송신할 데이터의 값을 바꾸는 역할을 반복해서 합니다.
3단계. ESP32 S3 ZERO 코드 작성
이제 수신자의 코드를 작성할 차례입니다. 수신코드는 마치 인터럽트처럼 사용하면 됩니다. 무슨 뜻이냐면, 인터럽트 함수를 작성하고 attachInterrupt(핀, 함수, 조건);으로 인터럽트를 시작하라고 등록한것처럼, ESP-NOW도 신호를 받았을 때 실행할 함수(여기서는 OnDataRecv)를 작성하고, esp_now_register_recv_cb();로 그 함수를 등록하면 됩니다. 이걸 이해하면 코드해석이 편해져요.
[env:esp32dev]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
board_build.flash_mode = qio
board_build.arduino.memory_type = qio_qspi
board_build.flash_size = 4MB
board_build.partitions = default.csv
board_upload.flash_size = 4MB
lib_deps =
adafruit/Adafruit NeoPixel @ ^1.12.4
#include <Arduino.h>
#include <esp_now.h>
#include <WiFi.h>
#include <Adafruit_NeoPixel.h>
struct dearI {
char text[32];
int i;
};
dearI myData;
Adafruit_NeoPixel pixels(1, 21, NEO_GRB + NEO_KHZ800);
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
memcpy(&myData, incomingData, sizeof(myData));
switch(*myData.text){
case 'A' ... 'Z': pixels.setPixelColor(0, pixels.Color(255, 0, 0)); break;
case 'a' ... 'z': pixels.setPixelColor(0, pixels.Color(0, 255, 0)); break;
default: pixels.setPixelColor(0, pixels.Color(0, 0, 255)); break;
}
pixels.show();
digitalWrite(7, HIGH);
delay((myData.i%10)*100);
digitalWrite(7, LOW);
delay(1000-(myData.i%10)*100);
}
void setup() {
Serial.begin(115200);
pinMode(7, OUTPUT);
pinMode(21, OUTPUT);
pixels.begin();
pixels.clear();
digitalWrite(7, LOW);
WiFi.mode(WIFI_STA);
if (esp_now_init()!=ESP_OK) {
Serial.println("Fail");
return;
}
esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));
Serial.println("Waiting");
}
void loop() {
delay(1000); yield();
}
S3-Zero는 수신을 하는 아이였습니다. 코드해석을 간략히만 해볼게요.
1. 우선 구조체선언이 눈에 뜁니다. 근데 이 구조체, 낯익지 않습니까? 바로 N16R8에서 전송할 데이터의 구조체와 같습니다. 그래야 2진수덩어리인 데이터를 목적에 맞게 볼 수 있으니까요.(물론 안그래도 상관은 없어요. 알아서 해석하기 나름이니)

2. OnDataRecv()함수를 선언해 줍니다. 이 함수가 위에서 간략하게 설명했듯, ESP-NOW신호를 받으면 바로 실행될 함수 입니다. 이 중에서 중요한건 함수선언 바로 아랫줄 입니다. 우리가 ESP-NOW를 쓰는 목적 중 하나가 어쨌든 데이터전송인데, 이 함수 자체는 신호를 받으면 실행될 뿐, 데이터와 관련된건 아무것도 없습니다. 즉, 데이터를 받아서 어딘가에 복사해 두는 동작이 필요한데 그걸 수행하는게 "memcpy(&myData, incomingData, sizeof(myData));" 이 한줄 입니다. 안써도 상관없지만, 거의 쓰게 될 거니까 사실상 함수선언 직후 바로 붙여쓴다고 기억해도 과언은 아닐거에요.
이후 코드는 이제 이 함수에서 할 거 입니다. 즉, 코드 그대로 받은 데이터 구조체의 문자열 첫번째문자가 대문자면 LED빨강, 소문자면 LED초록, 나머지는 LED파랑을 하는거고, 구조체의 정수의 1의자리만큼 7번핀을 ON하자 라는 단순한 코드 입니다.
3. setup()함수에서는 기기를 준비하고, ESP-NOW송신을 받기 위해 STA모드를 켭니다. 이후 esp_now_register_recv_cb()함수를 통해 아까 만들었던 OnDataRecv()함수를 ESP-NOW를 받자마자 실행되게 할 함수라고 등록합니다.
4. loop()함수는 하는 역할이 없습니다. ESP-NOW시 실행되는 함수의 경우, 인터럽트처럼 조건만 맞으면 언제든지 호출되기 때문에, loop()함수 내부에서 계속 호출할 필요가 없습니다. 즉, 아무것도 안할거기에 비워둡니다.
즉, 이렇게 코드를 작성한다면 송신된 데이터의 i가 증가할때마다 7번핀의 ON/OFF 듀티사이클이 달라질거고, text가 바뀔때마다 조건에 맞는 색상의 LED가 켜집니다.
4단계. ESP32 C3 OLED 코드 작성
기본적인 틀은 ESP32-S3-Zero와 비슷합니다. 역시 같은 데이터를 받을 거고요. 그러나 데이터를 받을 때의 반응만은 다르게 코딩하였습니다. 즉, OnDataRecv()함수가 주요 변경점이겠죠?
[env:esp32-c3-devkitm-1]
platform = espressif32
board = esp32-c3-devkitm-1
framework = arduino
monitor_speed = 115200
lib_deps =
olikraus/U8g2 @ ^2.35.11
SPI
#include <Arduino.h>
#include <esp_now.h>
#include <WiFi.h>
#include <U8g2lib.h>
#include <Wire.h>
#define SDA_PIN 5
#define SCL_PIN 6
U8G2_SSD1306_72X40_ER_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE, SCL_PIN, SDA_PIN);
struct dearI {
char text[32];
int i;
};
dearI myData;
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
memcpy(&myData, incomingData, sizeof(myData));
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_ncenB08_tr);
u8g2.setCursor(0, 10);
u8g2.print("Data: ");
u8g2.print(myData.text);
u8g2.setCursor(0, 30);
u8g2.print("Count: ");
u8g2.print(myData.i);
u8g2.sendBuffer();
}
void setup() {
Serial.begin(115200);
u8g2.begin();
WiFi.mode(WIFI_STA);
if (esp_now_init()!=ESP_OK) {
Serial.println("ESP-NOW 초기화 실패");
return;
}
esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));
Serial.println("수신 대기 중...");
}
void loop() {
delay(1000); yield();
}
코드해석은 OnDataRecv()만 하겠습니다. 우선 Zero에서처럼 송신된 데이터를 써먹기 위해서 memcpy로 데이터를 다른곳으로 복사해둬야 합니다. 이후는 C3-OLED고유의 코드입니다. 내장된 OLED에 전송된 데이터 중 문자열을 출력하고, i값을 정수로 출력하는 간략한 코드입니다. 나머지는 Zero랑 유사하기에 해석하지 않겠습니다.
즉, 이렇게 코드를 작성한다면 송신된 데이터의 i의 값에 맞게 OLED에 i의 값이 받을때마다 갱신될거고, N16R8과 연결된 시리얼 모니터에 입력한 문자열이 myData.text로 담겨와서 OLED에 출력될 것입니다.
시연모습



이런식으로 세팅하였습니다. 시리얼모니터를 통해서 문자열을 입력받을 수 있게, 컴퓨터와 N16R8을 연결해줍니다. 다음으로 Zero와 OLED는 코드만 업로드 한 뒤, 5V전원만 인가하였습니다. 어차피 무선통신으로 동작하기에, 전원만 있으면 충분합니다.
시리얼모니터에 "Hello" 입력

보시다시피 OLED에는 "Hello"라는 문자열이 출력됩니다. 그리고 'H'는 대문자이기에 Zero의 LED는 빨간색을 띄고 있습니다.
시리얼모니터에 "world" 입력

보시다시피 OLED에는 "world"라는 문자열이 출력됩니다. 그리고 'w'는 소문자이기에 Zero의 LED는 초록색을 띄고 있습니다.
시리얼모니터에 "341000" 입력

보시다시피 OLED에는 "341000"라는 문자열(정수 아닙니다. 문자열형태로 전송된거에요.)이 출력됩니다. 그리고 '3'는 알파벳이 아니기에 Zero의 LED는 파란색을 띄고 있습니다.
여기까지 와서 눈치채신분이 있을지는 모르겠지만, OLED의 Count옆의 숫자가 107, 120, 132로 증가하고 있습니다. 이것은 초기 전원연결부터 문자열을 입력하는 동안까지 계속해서 ESP-NOW통신이 이루어졌기에 i값이 증가하는 것을 보여줍니다. 즉, dearI라는 구조체를 전송하는 ESP-NOW코드가 3개의 기기간 성공적으로 동작하는걸 볼 수 있습니다.
시연영상 및 자료


저작권고지: 이 프로젝트는 U8g2 및 Adafruit_NeoPixel 라이브러리를 사용하며, 각각 BSD 2-Clause 및 LGPL-3.0 라이선스를 따릅니다. 단, 공유된 소스파일에는 외부 라이브러리 원문은 포함되어 있지는 않습니다.
'Satellite' 카테고리의 다른 글
| 8비트 LED버스 제작하기 (0) | 2026.07.13 |
|---|---|
| ESP32-C3-OLED 워밍업 (0) | 2026.06.30 |
| 10진수 16진수 자동변환 계산기 (0) | 2026.05.15 |
| n자리 난수생성기 (1) | 2026.04.30 |
