Devin.KR

UART 통신 - 로그와 명령 주고받기

개발자KR 조회 7

이 장에서 배우는 것

앞 장에서는 ADC로 토양 습도와 온도를 값으로 바꿔 읽는 방법을 다뤘다. 값을 읽었으면 이제 그 값을 화분 밖으로 꺼내 보여주고, 반대로 화분에 명령을 내릴 통로가 필요하다. 가장 단순하고 널리 쓰이는 통로가 UART(Universal Asynchronous Receiver/Transmitter)다. 이 장에서는 UART로 로그를 내보내고 명령을 받아들이는 펌웨어를 PC에서 그대로 컴파일해 돌려 보며, 수신 속도와 처리 속도가 어긋날 때 왜 데이터가 사라지는지, 그리고 이를 막는 링 버퍼(ring buffer) 구조를 만든다.

  • 보레이트(baud rate)와 프레임(frame) 구성이 왜 송수신 양쪽에서 일치해야 하는지 설명한다
  • 수신 하드웨어 레지스터가 1바이트만 보관한다는 제약과 오버런(overrun)이 발생하는 조건을 이해한다
  • 링 버퍼로 수신 속도와 메인 루프 처리 속도의 차이를 흡수하는 방법을 구현한다
  • 개행 문자로 줄을 구분하는 간단한 명령 파서를 만들어 펌프·LED 명령을 처리한다
  • PC 시뮬레이션 계층과 실제 보드에서 UART 처리가 어떻게 달라지는지 비교한다

문제 상황

스마트 화분 시제품을 만들던 중, 개발자는 시리얼 콘솔로 로그를 보고 "PUMP ON" 같은 문자열을 직접 입력해 펌프를 테스트하고 싶어졌다. 처음에는 수신 레지스터를 읽어 바로 그 자리에서 명령을 해석하고 실행하는 코드를 짰다. 문제는 펌프를 켜는 처리(릴레이 제어, 로그 출력)가 몇십 마이크로초라도 걸린다는 점이었다. 그 사이에 터미널에서 다음 글자가 도착하면, UART 하드웨어의 수신 레지스터는 이전 글자를 미처 읽어가지 못한 채 덮어써지고 뒤 글자가 사라졌다. 짧은 명령은 운 좋게 통과했지만, 긴 문자열을 빠르게 입력하면 중간 글자가 빠져 파서가 엉뚱한 명령으로 해석하곤 했다.

원인은 간단하다. UART 수신 하드웨어는 대개 1바이트짜리 레지스터 하나만 가지고 있다. 다음 바이트가 도착하기 전에 펌웨어가 그 레지스터를 읽어가지 않으면, 새 바이트는 오버런 상태로 유실된다. 이 장에서는 이 문제를 직접 재현해 보고, 수신은 항상 빠르게 비워 별도의 버퍼에 쌓아두고, 명령 해석은 메인 루프가 자기 속도대로 처리하도록 구조를 바꾼다.

보레이트와 프레임

UART는 클록 선을 따로 두지 않는 비동기(asynchronous) 통신이다. 그래서 송신 쪽과 수신 쪽이 "1비트가 몇 마이크로초 동안 유지되는가"를 미리 같은 값으로 약속해 둬야 한다. 이 값이 보레이트이며, 초당 전송하는 비트 수(bps)로 나타낸다. 9600bps, 115200bps처럼 관행적으로 쓰는 값이 정해져 있다.

한 문자를 보낼 때는 비트 하나만 보내는 게 아니라 정해진 구성의 프레임을 보낸다. 대기 상태는 논리 1(high)이고, 프레임은 시작 비트(항상 0)로 시작해 데이터 비트(보통 8비트, LSB 먼저), 필요하면 패리티 비트, 마지막으로 정지 비트(항상 1)로 끝난다. 그림 1은 문자 'A'(0x41)를 8N1(데이터 8비트, 패리티 없음, 정지 비트 1개) 방식으로 보낼 때의 프레임이다.

UART 프레임은 시작 비트·데이터 8비트·정지 비트로 구성되며 양쪽 보레이트가 같아야 정확히 해석된다

양쪽 보레이트가 다르면 수신 쪽은 각 비트 구간의 폭을 잘못 나눠 읽는다. 예를 들어 송신이 115200bps인데 수신이 9600bps로 설정돼 있으면, 수신 쪽은 한 비트를 읽어야 할 시점에 이미 다음 비트로 넘어가 있어 글자가 깨진 값으로 나온다. 이 장의 예제는 hal_uart_init(115200)처럼 한 곳에서만 보레이트를 정의해, 값이 여러 군데 흩어져 어긋나는 실수를 피한다.

링 버퍼로 생산자와 소비자 속도차 줄이기

UART 수신은 두 개의 서로 다른 리듬으로 움직이는 코드가 만난다. 하나는 하드웨어 레지스터를 확인하는 쪽(인터럽트 또는 폴링)이고, 다른 하나는 명령을 해석하고 실행하는 메인 루프다. 전자는 한 글자가 도착할 때마다 즉시 반응해야 하고, 후자는 릴레이를 켜거나 로그를 출력하는 동안 잠깐씩 느려질 수 있다. 이 둘을 직접 연결하면 느린 쪽이 빠른 쪽의 발목을 잡는다.

오버런이 일어나는 순간

수신 레지스터가 1바이트만 담을 수 있다고 가정하면, 다음 순서로 오버런이 일어난다. 첫째, 바이트가 도착해 레지스터에 저장된다. 둘째, 펌웨어가 이를 읽기 전에 두 번째 바이트가 도착한다. 셋째, 하드웨어는 아직 읽히지 않은 첫 번째 바이트를 보호하기 위해(또는 설계에 따라 덮어쓰며) 두 번째 바이트를 버리고 오버런 플래그를 세운다. 결과는 같다 — 최소 한 바이트가 사라진다.

고정 크기 링 버퍼 구현 아이디어

해결책은 수신 확인 루틴이 레지스터를 본 즉시 값을 꺼내 별도의 배열에 순서대로 쌓아두는 것이다. 이 배열을 원형으로 운용하면 링 버퍼가 된다. 쓰는 위치(head)와 읽는 위치(tail)를 따로 두고, 쓸 때는 head를 한 칸 전진시키고 읽을 때는 tail을 한 칸 전진시킨다. head와 tail만으로는 "가득 찼다"와 "비어 있다"를 구분할 수 없어(둘 다 head == tail로 보이므로), 이 장의 구현은 현재 담긴 개수를 나타내는 count 필드를 따로 둔다. 그림 2는 명령 3바이트가 도착한 직후와, 메인 루프가 이를 모두 꺼내 간 직후의 상태를 비교한다.

링 버퍼는 head와 tail을 따로 둬 수신 속도와 처리 속도가 달라도 데이터를 잃지 않는다

줄 단위 명령 파서 설계

화분에 내릴 명령은 "PUMP ON", "LED 128"처럼 사람이 읽기 쉬운 텍스트 줄로 정의한다. 파서는 링 버퍼에서 바이트를 하나씩 꺼내 줄 버퍼에 모으다가, 개행 문자(\n 또는 \r)를 만나면 그때까지 모은 문자열을 하나의 명령으로 실행한다. 명령 실행은 문자열 비교와 sscanf로 간단히 처리한다 — 값이 필요한 명령은 형식과 범위를 함께 검사하고, 어느 것과도 맞지 않으면 오류를 응답한다. 그림 3은 수신 하드웨어에서 명령 실행까지 데이터가 거치는 단계를 정리한 것이다.

수신 바이트는 하드웨어 레지스터에서 링 버퍼를 거쳐 줄 단위 명령 실행까지 단계적으로 옮겨진다

완성 코드

hal_sim.h

#ifndef HAL_SIM_H
#define HAL_SIM_H

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

void hal_uart_init(uint32_t baud_bps);
uint32_t hal_uart_get_baud(void);

void hal_uart_feed_rx(const uint8_t *data, size_t len);
void hal_uart_tick(void);
int hal_uart_rx_ready(void);
uint8_t hal_uart_rx_byte(void);
int hal_uart_rx_overrun(void);
void hal_uart_tx_byte(uint8_t byte);

uint32_t hal_millis(void);
void hal_advance_ms(uint32_t ms);

#endif

hal_sim.c

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

#define WIRE_CAP 256

static uint8_t wire_buf[WIRE_CAP];
static size_t wire_head;
static size_t wire_len;

static uint8_t hw_rx_reg;
static int hw_rx_full;
static int overrun_flag;

static uint32_t sim_millis;
static uint32_t sim_baud;

void hal_uart_init(uint32_t baud_bps)
{
    sim_baud = baud_bps;
    wire_head = 0;
    wire_len = 0;
    hw_rx_full = 0;
    overrun_flag = 0;
    sim_millis = 0;
}

uint32_t hal_uart_get_baud(void)
{
    return sim_baud;
}

void hal_uart_feed_rx(const uint8_t *data, size_t len)
{
    for (size_t i = 0; i < len && wire_len < WIRE_CAP; i++) {
        size_t pos = (wire_head + wire_len) % WIRE_CAP;
        wire_buf[pos] = data[i];
        wire_len++;
    }
}

void hal_uart_tick(void)
{
    if (wire_len == 0) {
        return;
    }
    if (hw_rx_full) {
        overrun_flag = 1;
        wire_head = (wire_head + 1) % WIRE_CAP;
        wire_len--;
        return;
    }
    hw_rx_reg = wire_buf[wire_head];
    wire_head = (wire_head + 1) % WIRE_CAP;
    wire_len--;
    hw_rx_full = 1;
}

int hal_uart_rx_ready(void)
{
    return hw_rx_full;
}

uint8_t hal_uart_rx_byte(void)
{
    hw_rx_full = 0;
    return hw_rx_reg;
}

int hal_uart_rx_overrun(void)
{
    int flag = overrun_flag;
    overrun_flag = 0;
    return flag;
}

void hal_uart_tx_byte(uint8_t byte)
{
    putchar(byte);
}

uint32_t hal_millis(void)
{
    return sim_millis;
}

void hal_advance_ms(uint32_t ms)
{
    sim_millis += ms;
}

ring_buffer.h

#ifndef RING_BUFFER_H
#define RING_BUFFER_H

#include <stdint.h>

#define RING_BUFFER_CAP 64

typedef struct {
    uint8_t data[RING_BUFFER_CAP];
    uint16_t head;
    uint16_t tail;
    uint16_t count;
} ring_buffer_t;

void ring_buffer_init(ring_buffer_t *rb);
int ring_buffer_push(ring_buffer_t *rb, uint8_t byte);
int ring_buffer_pop(ring_buffer_t *rb, uint8_t *out);

#endif

ring_buffer.c

#include "ring_buffer.h"

void ring_buffer_init(ring_buffer_t *rb)
{
    rb->head = 0;
    rb->tail = 0;
    rb->count = 0;
}

int ring_buffer_push(ring_buffer_t *rb, uint8_t byte)
{
    if (rb->count == RING_BUFFER_CAP) {
        return 0;
    }
    rb->data[rb->head] = byte;
    rb->head = (uint16_t)((rb->head + 1u) % RING_BUFFER_CAP);
    rb->count++;
    return 1;
}

int ring_buffer_pop(ring_buffer_t *rb, uint8_t *out)
{
    if (rb->count == 0) {
        return 0;
    }
    *out = rb->data[rb->tail];
    rb->tail = (uint16_t)((rb->tail + 1u) % RING_BUFFER_CAP);
    rb->count--;
    return 1;
}

uart_cmd.h

#ifndef UART_CMD_H
#define UART_CMD_H

void uart_cmd_init(void);
void uart_cmd_poll(void);

#endif

uart_cmd.c

#include "uart_cmd.h"
#include "hal_sim.h"
#include "ring_buffer.h"
#include <stdint.h>
#include <string.h>
#include <stdio.h>

#define LINE_MAX 32

static ring_buffer_t rx_rb;
static char line_buf[LINE_MAX];
static size_t line_len;

static void uart_log(const char *msg)
{
    for (const unsigned char *p = (const unsigned char *)msg; *p != '\0'; p++) {
        hal_uart_tx_byte(*p);
    }
}

static void run_command(const char *line)
{
    unsigned value;

    if (strcmp(line, "PUMP ON") == 0) {
        uart_log("OK PUMP=ON\n");
    } else if (strcmp(line, "PUMP OFF") == 0) {
        uart_log("OK PUMP=OFF\n");
    } else if (sscanf(line, "LED %u", &value) == 1 && value <= 255u) {
        char out[32];
        snprintf(out, sizeof(out), "OK LED=%u\n", value);
        uart_log(out);
    } else if (strcmp(line, "GET TEMP") == 0) {
        uart_log("TEMP=24.6\n");
    } else {
        uart_log("ERR UNKNOWN CMD\n");
    }
}

void uart_cmd_init(void)
{
    ring_buffer_init(&rx_rb);
    line_len = 0;
}

void uart_cmd_poll(void)
{
    uint8_t byte;

    hal_uart_tick();

    if (hal_uart_rx_overrun()) {
        uart_log("ERR RX_OVERRUN\n");
    }

    if (hal_uart_rx_ready()) {
        byte = hal_uart_rx_byte();
        if (!ring_buffer_push(&rx_rb, byte)) {
            uart_log("ERR RING_FULL\n");
        }
    }

    while (ring_buffer_pop(&rx_rb, &byte)) {
        if (byte == '\n' || byte == '\r') {
            if (line_len > 0) {
                line_buf[line_len] = '\0';
                run_command(line_buf);
                line_len = 0;
            }
        } else if (line_len < LINE_MAX - 1) {
            line_buf[line_len++] = (char)byte;
        } else {
            uart_log("ERR LINE_TOO_LONG\n");
            line_len = 0;
        }
    }
}

main.c

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

static void demo_overrun(void)
{
    const uint8_t burst[2] = { 'A', 'B' };

    printf("--- 느린 폴링 시연 ---\n");
    hal_uart_feed_rx(burst, sizeof(burst));
    hal_uart_tick();
    hal_uart_tick();
    if (hal_uart_rx_overrun()) {
        printf("오버런 발생: 폴링이 늦으면 바이트가 사라진다\n");
    }
    (void)hal_uart_rx_byte();
    printf("\n");
}

int main(void)
{
    static const char script[] =
        "PUMP ON\n"
        "LED 128\n"
        "GET TEMP\n"
        "WATER NOW\n"
        "PUMP OFF\n";

    hal_uart_init(115200);
    printf("UART 초기화: %u bps\n", (unsigned)hal_uart_get_baud());

    demo_overrun();

    uart_cmd_init();
    hal_uart_feed_rx((const uint8_t *)script, sizeof(script) - 1);

    printf("--- 명령 처리 시작 ---\n");
    for (int i = 0; i < 200; i++) {
        uart_cmd_poll();
        hal_advance_ms(1);
    }

    return 0;
}

줄별 해설

hal_sim.c는 실제 보드가 아니라 PC에서 UART 하드웨어를 흉내 낸다. wire_buf는 "선을 타고 도착하는 바이트들"의 대기열이고, hw_rx_reg와 hw_rx_full은 1바이트만 담는 실제 UART 수신 레지스터를 모델링한다. hal_uart_tick이 호출될 때마다 대기열에서 한 바이트를 꺼내 레지스터로 옮기되, 레지스터가 이미 차 있으면(hw_rx_full이 참) 새 바이트를 버리고 overrun_flag를 세운다 — 이 두 줄이 오버런의 핵심 재현부다.

ring_buffer.c는 head·tail·count 세 필드로 원형 버퍼를 구현한다. ring_buffer_push는 count가 용량과 같으면 즉시 실패를 돌려줘 오래된 데이터를 지키고, ring_buffer_pop은 count가 0이면 실패를 돌려줘 빈 버퍼를 읽지 않게 막는다. head·tail만으로는 구분할 수 없는 가득 참/빈 상태를 count가 명확히 알려준다.

uart_cmd.c의 uart_cmd_poll은 메인 루프가 매 반복마다 호출하는 함수다. 먼저 hal_uart_tick으로 하드웨어 레지스터를 갱신하고, 오버런 여부를 확인한 뒤, 레지스터에 값이 있으면 즉시 꺼내 링 버퍼로 옮긴다. 이 "즉시 옮기기"가 이 장의 핵심이다 — 레지스터에 오래 남겨두지 않으므로 다음 바이트가 와도 잃지 않는다. 이어지는 while 루프는 링 버퍼에 쌓인 바이트를 모두 꺼내 줄 버퍼에 채우고, 개행 문자를 만나면 run_command를 호출한다. line_len > 0 검사는 빈 줄(연속된 개행 문자)이 빈 명령으로 실행되는 것을 막는다.

run_command는 문자열을 순서대로 비교한다. PUMP ON과 PUMP OFF는 완전 일치를 요구해 서로 헷갈리지 않게 하고, LED %u는 sscanf로 값을 뽑아낸 뒤 &&의 단락 평가 덕분에 형식이 맞지 않으면 값 범위 검사(value <= 255u)를 아예 건너뛴다. 어느 조건에도 맞지 않으면 ERR UNKNOWN CMD를 돌려준다.

main.c의 demo_overrun은 고의로 hal_uart_tick을 두 번 연달아 호출해, 레지스터를 비우지 않은 채 다음 바이트가 도착하는 상황을 만든다. 이후 uart_cmd_init과 hal_uart_feed_rx로 실제 명령 스크립트를 흘려보내고, 200번의 루프 동안 uart_cmd_poll을 반복 호출해 44바이트를 모두 소비한다.

이 코드는 PC에서 동작하도록 hal_sim으로 UART 레지스터를 흉내 냈다. 실제 보드로 옮기면 수신을 감지하는 방식과 오버런을 확인하는 방식이 달라진다.

PC 시뮬레이션과 실제 보드의 차이
항목PC 시뮬레이션(hal_sim)실제 보드(STM32/ESP32/Arduino)
UART 초기화hal_uart_init이 내부 변수만 설정클록 분주비 레지스터를 계산해 보레이트를 만들고, 핀을 UART 기능으로 전환
수신 처리메인 루프에서 hal_uart_tick을 직접 호출대부분 수신 인터럽트(또는 DMA)가 바이트 도착 시 자동으로 실행
오버런 검사overrun_flag 변수를 프로그램이 직접 관리상태 레지스터의 오버런 비트를 읽어 확인하며, 읽지 않으면 이후 수신이 막히는 칩도 있다
시간 기준hal_millis가 호출 횟수로 흐르는 가짜 시각SysTick이나 별도 타이머 등 실제 클록 소스 기반

실행 결과

$ cc -std=c11 -Wall -Wextra -o smartpot_uart hal_sim.c ring_buffer.c uart_cmd.c main.c
$ ./smartpot_uart
UART 초기화: 115200 bps
--- 느린 폴링 시연 ---
오버런 발생: 폴링이 늦으면 바이트가 사라진다

--- 명령 처리 시작 ---
OK PUMP=ON
OK LED=128
TEMP=24.6
ERR UNKNOWN CMD
OK PUMP=OFF

실무에서 자주 틀리는 것

수신 콜백 안에서 무거운 처리를 직접 실행

수신 콜백(인터럽트 핸들러)이 오래 걸리면 그동안 도착하는 바이트를 받을 수 없다. 콜백은 옮기기만 하고, 해석은 메인 루프가 맡아야 한다.

void uart_rx_isr(void)
{
    uint8_t b = UART_DR;
    if (b == '\n') {
        run_command(line_buf); /* 콜백 안에서 명령까지 실행 */
    }
}
void uart_rx_isr(void)
{
    uint8_t b = UART_DR;
    ring_buffer_push(&rx_rb, b); /* 콜백은 옮기기만 한다 */
}

/* 메인 루프 */
while (ring_buffer_pop(&rx_rb, &b)) {
    process_byte(b);
}

head/tail만으로 가득 참과 빈 상태를 구분

head와 tail이 같아지는 경우는 "가득 참"과 "비어 있음" 둘 다에서 나타날 수 있어, 별도의 판단 기준 없이는 두 상태를 구분할 수 없다.

int rb_push(ring_t *rb, uint8_t b)
{
    uint16_t next = (rb->head + 1) % CAP;
    if (next == rb->tail) {
        return 0;
    }
    rb->data[rb->head] = b;
    rb->head = next;
    return 1;
}
/* head == tail 이면 가득 찬 것인지 비어 있는 것인지 알 수 없다 */
int rb_push(ring_t *rb, uint8_t b)
{
    if (rb->count == CAP) {
        return 0; /* count가 명확한 기준이 된다 */
    }
    rb->data[rb->head] = b;
    rb->head = (rb->head + 1) % CAP;
    rb->count++;
    return 1;
}

개행 처리에서 \n 하나만 가정

터미널에 따라 줄 끝에 \r\n을 함께 보내기도 한다. \n만 검사하면 \r이 다음 줄 맨 앞에 남아 빈 명령으로 잘못 처리된다.

if (byte == '\n') {
    line_buf[line_len] = '\0';
    run_command(line_buf);
    line_len = 0;
}
/* '\r' 는 그대로 line_buf에 쌓인 채 다음 글자와 섞인다 */
if (byte == '\n' || byte == '\r') {
    if (line_len > 0) {
        line_buf[line_len] = '\0';
        run_command(line_buf);
        line_len = 0;
    }
}

접두어만 비교하는 strncmp로 명령 구분

앞 글자 몇 개만 비교하면 "PUMP OFF"가 "PUMP"로 시작한다는 이유로 켜기 명령과 같이 처리될 수 있다.

if (strncmp(line, "PUMP", 4) == 0) {
    pump_set(1); /* "PUMP OFF" 도 켜짐으로 처리된다 */
}
if (strcmp(line, "PUMP ON") == 0) {
    pump_set(1);
} else if (strcmp(line, "PUMP OFF") == 0) {
    pump_set(0);
}

한눈에 보기

이 장에서 다룬 개념 정리
개념핵심 규칙이 장 코드에서 확인할 부분
보레이트/프레임시작 비트-데이터 8비트-정지 비트, 양쪽 속도가 같아야 함hal_uart_init(115200), 그림1
링 버퍼head/tail/count로 생산자와 소비자의 속도차를 흡수ring_buffer.c, uart_cmd_poll
오버런하드웨어 레지스터를 제때 못 읽으면 바이트가 유실됨demo_overrun, hal_uart_tick
명령 파서개행 문자로 줄을 구분해 문자열 비교와 sscanf로 처리run_command, uart_cmd.c

연습 문제

  1. 링 버퍼 크기를 시스템이 감당할 수 있는 한 무작정 키우지 않고 적절한 값으로 제한해야 하는 이유를 두 가지 들어라.
  2. uart_cmd_poll에서 hal_uart_tick 호출을 while 루프 뒤로 옮기면 어떤 문제가 생기는지 설명하고, 이를 확인할 수 있는 실험 방법을 제안하라.
  3. CRLF(\r\n)를 보내는 터미널과 LF(\n)만 보내는 터미널이 섞여 있어도 이 장의 코드가 오동작하지 않는 이유를 줄별 해설을 근거로 설명하라.
  4. "LED 300"처럼 범위를 벗어난 값을 보내면 이 장의 코드는 어떤 응답을 돌려주는지 예상하고, 그 이유를 run_command의 조건문으로 설명하라.

정답과 해설

  1. 첫째, 마이크로컨트롤러의 RAM은 한정돼 있어 버퍼를 무한정 키울 수 없다. 둘째, 버퍼를 아무리 키워도 소비자(메인 루프)가 영영 읽지 않으면 결국 가득 차 ring_buffer_push가 실패하므로, 버퍼 크기는 근본 해결책이 아니라 여유 시간을 벌어주는 수단일 뿐이다.
  2. 순서를 바꾸면 그 폴링 주기 동안 레지스터가 이미 채워져 있어도 링 버퍼로 옮기지 못한 채 다음 hal_uart_tick 호출까지 기다리게 되고, 그사이 또 바이트가 도착하면 demo_overrun과 같은 방식으로 오버런이 발생한다. main.c의 demo_overrun처럼 hal_uart_tick을 레지스터를 비우지 않고 두 번 호출해 hal_uart_rx_overrun이 참을 반환하는지 확인하면 실험할 수 있다.
  3. uart_cmd.c의 파서는 byte == '\n' || byte == '\r' 조건으로 두 문자 모두를 줄 종료로 인식한다. 또한 line_len > 0 검사가 있어, \r\n처럼 종료 문자가 연달아 와도 두 번째 문자에서는 line_len이 이미 0이므로 run_command가 빈 문자열로 호출되지 않는다.
  4. sscanf(line, "LED %u", &value)는 300을 성공적으로 읽어 1을 반환하지만, 이어지는 value <= 255u 검사에서 거짓이 되어 전체 조건이 거짓이 된다. 따라서 다음 조건인 "GET TEMP" 비교로 넘어가고 그것도 맞지 않으므로 최종적으로 ERR UNKNOWN CMD가 출력된다. 겉보기엔 LED 명령처럼 보이지만 범위 검사에 걸려 알 수 없는 명령으로 처리된다는 점이 핵심이다.

댓글 0

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

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