베어메탈에서 임베디드 리눅스로

EmbeddedLinux

STM32 베어메탈 펌웨어를 작성해본 경험이 있으면, 임베디드 리눅스의 각 개념이 기존 코드의 어떤 부분을 대체하는지 1:1로 대응시킬 수 있다. CAN, UART/Modbus, GPIO, 메인 루프, OTA 부트로더 각각에 대해 리눅스 쪽 구현을 정리한다.


전체 구조 비교

실제 베어메탈 프로젝트 구조:

RPi5 (ROS2, Linux) ←── CAN 500kbps ──→ STM32F446 (HAL, bare-metal)

                                        UART/RS485
                                        Modbus RTU

                                        서보 모터 ×2
                                        리밋 스위치 ×2

이 STM32 부분을 리눅스 SBC(예: RPi, BeagleBone)로 대체한다고 가정하면:

베어메탈 (STM32)임베디드 리눅스
HAL_CAN_Init + 인터럽트SocketCAN (can0)
HAL_UART + Modbus 직접 구현/dev/ttyS* + libmodbus 또는 커널 serdev
HAL_GPIO + 폴링/인터럽트libgpiod 또는 GPIO 커널 드라이버
while(1) 메인 루프유저스페이스 데몬 또는 커널 드라이버
커스텀 CAN 부트로더U-Boot + SWUpdate/RAUC
링커 스크립트 (메모리 맵)Device Tree
arm-none-eabi-gccarm-linux-gnueabihf-gcc + 커널 빌드

CAN Bus: HAL → SocketCAN

베어메탈

// 필터 설정
CAN_FilterTypeDef filter = {
    .FilterBank = 0,
    .FilterMode = CAN_FILTERMODE_IDMASK,
    .FilterScale = CAN_FILTERSCALE_32BIT,
    .FilterIdHigh = 0x0000,
    .FilterIdLow = 0x0000,
    .FilterMaskIdHigh = 0x0000,
    .FilterMaskIdLow = 0x0000,
    .FilterFIFOAssignment = CAN_FILTER_FIFO0,
    .FilterActivation = ENABLE,
};
HAL_CAN_ConfigFilter(&hcan1, &filter);
HAL_CAN_Start(&hcan1);
HAL_CAN_ActivateNotification(&hcan1, CAN_IT_RX_FIFO0_MSG_PENDING);

// 송신
CAN_TxHeaderTypeDef header = {
    .StdId = 0x101,
    .DLC = 8,
    .IDE = CAN_ID_STD,
    .RTR = CAN_RTR_DATA,
};
uint8_t data[8] = { ... };
uint32_t mailbox;
HAL_CAN_AddTxMessage(&hcan1, &header, data, &mailbox);

리눅스: SocketCAN

# CAN 인터페이스 활성화
ip link set can0 type can bitrate 500000
ip link set can0 up
#include <linux/can.h>
#include <net/if.h>
#include <sys/ioctl.h>
#include <sys/socket.h>

int s = socket(PF_CAN, SOCK_RAW, CAN_RAW);

struct ifreq ifr;
strcpy(ifr.ifr_name, "can0");
ioctl(s, SIOCGIFINDEX, &ifr);

struct sockaddr_can addr = {
    .can_family = AF_CAN,
    .can_ifindex = ifr.ifr_ifindex,
};
bind(s, (struct sockaddr *)&addr, sizeof(addr));

// 송신
struct can_frame frame = {
    .can_id = 0x101,
    .can_dlc = 8,
};
memcpy(frame.data, data, 8);
write(s, &frame, sizeof(frame));

// 수신
struct can_frame rx;
read(s, &rx, sizeof(rx));

핵심 차이:

  • 베어메탈: 레지스터 직접 설정 + 인터럽트 핸들러
  • 리눅스: 소켓 API로 추상화. CAN 컨트롤러 드라이버는 커널이 처리
  • 필터도 setsockopt(s, SOL_CAN_RAW, CAN_RAW_FILTER, ...)로 설정

Device Tree (CAN 컨트롤러):

&can0 {
    compatible = "microchip,mcp2515";
    reg = <0>;                          /* SPI CS0 */
    clocks = <&clk8m>;
    interrupt-parent = <&gpio>;
    interrupts = <25 IRQ_TYPE_EDGE_FALLING>;
    spi-max-frequency = <10000000>;
    status = "okay";
};

RPi는 내장 CAN이 없으므로 SPI-CAN 컨트롤러(MCP2515)를 Device Tree로 기술한다.


UART/Modbus: HAL → serdev 또는 유저스페이스

베어메탈

// Modbus RTU 프레임 직접 구성
static void modbus_send(uint8_t id, uint8_t func,
                        uint16_t reg, uint16_t value)
{
    uint8_t frame[8];
    frame[0] = id;
    frame[1] = func;
    frame[2] = reg >> 8;
    frame[3] = reg & 0xFF;
    frame[4] = value >> 8;
    frame[5] = value & 0xFF;
    uint16_t crc = modbus_crc16(frame, 6);
    frame[6] = crc & 0xFF;
    frame[7] = crc >> 8;

    HAL_GPIO_WritePin(DE_PORT, DE_PIN, GPIO_PIN_SET);  // RS485 TX 모드
    HAL_UART_Transmit(&huart1, frame, 8, 100);
    HAL_GPIO_WritePin(DE_PORT, DE_PIN, GPIO_PIN_RESET); // RS485 RX 모드
}

리눅스 옵션 1: 유저스페이스 (libmodbus)

#include <modbus.h>

modbus_t *ctx = modbus_new_rtu("/dev/ttyS1", 57600, 'N', 8, 1);
modbus_set_slave(ctx, 1);           // 슬레이브 ID
modbus_rts_set(ctx, MODBUS_RTU_RTS_UP);  // RS485 DE 제어
modbus_connect(ctx);

uint16_t regs[10];
modbus_read_registers(ctx, 0x0B03, 10, regs);  // 상태 읽기
modbus_write_register(ctx, 0x0600, 1000);       // 속도 설정

modbus_close(ctx);
modbus_free(ctx);

리눅스 옵션 2: 커널 serdev 드라이버

UART에 연결된 디바이스를 Device Tree로 기술하고 커널 드라이버에서 직접 처리하는 방식:

&uart1 {
    status = "okay";

    servo_motor: servo@1 {
        compatible = "vendor,isv2-rs";
        current-speed = <57600>;
    };
};
#include <linux/serdev.h>

static int isv2_receive(struct serdev_device *serdev,
                        const u8 *buf, size_t count)
{
    /* Modbus 응답 파싱 */
    return count;
}

static const struct serdev_device_ops isv2_ops = {
    .receive_buf = isv2_receive,
};

static int isv2_probe(struct serdev_device *serdev)
{
    serdev_device_set_client_ops(serdev, &isv2_ops);
    serdev_device_open(serdev);
    serdev_device_set_baudrate(serdev, 57600);
    serdev_device_set_parity(serdev, SERDEV_PARITY_NONE);
    return 0;
}

실무 선택:

  • 모터 제어처럼 프로토콜이 단순하면 libmodbus (유저스페이스) 가 적절하다
  • 커널 serdev는 GPS 모듈처럼 커널 서브시스템과 통합이 필요할 때 사용

RS485 DE 핀 제어

베어메탈에서는 GPIO를 직접 토글하지만, 리눅스에서는 UART 드라이버가 자동 처리한다:

&uart1 {
    rts-gpios = <&gpio 17 GPIO_ACTIVE_HIGH>;  /* DE 핀 */
    rs485-rts-active-high;
    linux,rs485-enabled-at-boot-time;
};

또는 유저스페이스에서 ioctl:

struct serial_rs485 rs485 = {
    .flags = SER_RS485_ENABLED | SER_RS485_RTS_ON_SEND,
    .delay_rts_before_send = 0,
    .delay_rts_after_send = 0,
};
ioctl(fd, TIOCSRS485, &rs485);

GPIO: HAL → libgpiod 또는 커널 드라이버

베어메탈

// 리밋 스위치 읽기 (NPN, active LOW)
bool is_open_limit = !HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_0);
bool is_close_limit = !HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_1);

// 인터럽트
void EXTI0_IRQHandler(void) {
    HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}
void HAL_GPIO_EXTI_Callback(uint16_t pin) {
    if (pin == GPIO_PIN_0) limit_switch_triggered = true;
}

리눅스: libgpiod (유저스페이스)

#include <gpiod.h>

struct gpiod_chip *chip = gpiod_chip_open("/dev/gpiochip0");
struct gpiod_line *open_sw = gpiod_chip_get_line(chip, 0);   // PC0
struct gpiod_line *close_sw = gpiod_chip_get_line(chip, 1);  // PC1

gpiod_line_request_both_edges_events(open_sw, "limit-switch");

// 폴링
struct gpiod_line_event event;
int ret = gpiod_line_event_wait(open_sw, &timeout);
if (ret > 0) {
    gpiod_line_event_read(open_sw, &event);
    // event.event_type == GPIOD_LINE_EVENT_FALLING_EDGE
}

// 값 읽기
int val = gpiod_line_get_value(open_sw);  // 0 or 1

리눅스: Device Tree + 커널 GPIO 키 드라이버

리밋 스위치는 gpio-keys 서브시스템으로 커널에서 직접 처리할 수 있다:

/ {
    limit-switches {
        compatible = "gpio-keys";

        open-limit {
            label = "Open Limit Switch";
            gpios = <&gpio 0 GPIO_ACTIVE_LOW>;   /* NPN, active LOW */
            linux,code = <KEY_F1>;
        };

        close-limit {
            label = "Close Limit Switch";
            gpios = <&gpio 1 GPIO_ACTIVE_LOW>;
            linux,code = <KEY_F2>;
        };
    };
};

이렇게 하면 /dev/input/eventN에서 이벤트를 읽을 수 있다. 디바운싱도 debounce-interval 속성으로 설정 가능.


메인 루프: super loop → 유저스페이스 데몬

베어메탈

void app_loop(void) {
    while (1) {
        can_process_rx();          // CAN 수신 처리
        motor_control_update();    // 모터 제어
        input_update();            // 리밋 스위치 디바운싱

        if (tick_elapsed(50)) {    // 50ms 주기
            can_send_status();     // 상태 브로드캐스트
        }
    }
}

리눅스: 실시간 유저스페이스 데몬

#include <pthread.h>
#include <sched.h>
#include <time.h>

static void *control_thread(void *arg)
{
    struct sched_param param = { .sched_priority = 80 };
    pthread_setschedparam(pthread_self(), SCHED_FIFO, &param);

    mlockall(MCL_CURRENT | MCL_FUTURE);  // 페이지 폴트 방지

    struct timespec next;
    clock_gettime(CLOCK_MONOTONIC, &next);

    while (running) {
        can_process_rx();
        motor_control_update();
        input_update();
        can_send_status();

        next.tv_nsec += 50000000;  // 50ms
        if (next.tv_nsec >= 1000000000) {
            next.tv_nsec -= 1000000000;
            next.tv_sec++;
        }
        clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, NULL);
    }
    return NULL;
}

PREEMPT_RT 커널: 50ms 주기 제어는 일반 리눅스 커널에서도 충분하다. 1ms 이하 정밀도가 필요하면 PREEMPT_RT 패치를 적용한다.

# 실시간 성능 확인
cyclictest -m -p 80 -t 1 -D 60   # 60초간 레이턴시 측정
# Max latency가 제어 주기의 10% 이하면 충분

OTA: CAN 부트로더 → U-Boot + SWUpdate

베어메탈

┌──────────────┬──────────────────────┐
│  Bootloader  │   Application        │
│  0x08000000  │   0x08008000         │
│  32KB        │   480KB              │
│              │                      │
│  CRC 검증    │   앱 코드            │
│  CAN 수신    │                      │
│  플래시 쓰기 │                      │
└──────────────┴──────────────────────┘

CAN으로 바이너리를 받아서 플래시에 직접 쓴다. CRC 검증 후 앱 영역으로 점프.

리눅스: A/B 파티션 + SWUpdate

┌────────┬────────────┬────────────┬──────────┐
│ U-Boot │ Kernel A   │ Kernel B   │ Data     │
│        │ + rootfs A │ + rootfs B │          │
└────────┴────────────┴────────────┴──────────┘
# SWUpdate로 OTA
swupdate -i firmware-update.swu -e stable,upgrade_partition_b

# U-Boot 환경변수로 부팅 파티션 선택
fw_setenv boot_partition B
fw_setenv bootcount 0

Rollback: 부팅 실패 시 U-Boot가 자동으로 이전 파티션으로 복구한다. 베어메탈 CAN 부트로더에서 A/B 영역 개념을 이미 구현했다면, 리눅스의 A/B 파티션은 같은 패턴의 확장이다.


Device Tree: 링커 스크립트와의 대응

베어메탈의 링커 스크립트가 메모리 레이아웃을 기술하듯, Device Tree는 하드웨어 토폴로지를 기술한다:

베어메탈Device Tree
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K커널 이미지가 관리
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K커널이 관리
CAN1 핀/클럭 설정 (CubeMX → main.c)&can0 { ... status = "okay"; }
UART 보레이트 (HAL_UART_Init)current-speed = <57600>;
GPIO 풀업/방향 (GPIO_InitStruct)gpios = <&gpio 0 GPIO_ACTIVE_LOW>;

CubeMX에서 핀과 클럭을 설정하고 코드를 생성하는 것 → Device Tree에서 기술하고 커널 드라이버가 읽는 것. 역할이 같다.


연습 경로

기존 펌웨어 경험을 기반으로 임베디드 리눅스를 연습하는 순서:

순서주제기존 경험 연결실습 환경
1크로스 컴파일arm-none-eabi-gccarm-linux-gnueabihf-gccQEMU ARM
2커널 모듈캐릭터 디바이스 = CAN/UART 디바이스 파일QEMU 또는 RPi
3Device TreeCubeMX 핀 설정 → DTS 노드RPi + dtoverlay
4버스 드라이버HAL_UART/SPI/I2C → 커널 드라이버RPi + 센서
5SocketCANHAL_CAN → can_frame + 소켓RPi + MCP2515
6libgpiodHAL_GPIO → gpiod APIRPi GPIO
7PREEMPT_RT베어메탈 타이밍 보장 → 리눅스 실시간RPi + cyclictest

메모

  • 베어메탈에서 리눅스로 옮기면 무엇을 잃는가
    • 결정론적 타이밍. 베어메탈은 인터럽트 레이턴시가 수 μs지만, 리눅스는 수십~수백 μs
    • PREEMPT_RT를 적용해도 worst-case 레이턴시가 보장되지는 않는다
    • 50ms 제어 루프는 리눅스에서 문제없지만, 1μs 타이밍이 필요한 스텝 모터 펄스 생성은 불가능하다
  • 베어메탈에서 리눅스로 옮기면 무엇을 얻는가
    • 파일시스템, 네트워크 스택, 패키지 매니저, SSH, 로깅 등 인프라가 이미 있다
    • Modbus를 직접 구현할 필요 없이 libmodbusapt install하면 된다
    • OTA가 파일 복사 수준으로 단순해진다
  • 하이브리드 구조가 현실적
    • 리얼타임이 필요한 부분(모터 제어, 안전 인터럽트)은 MCU에 남기고, 상위 로직(통신, 로깅, OTA)을 리눅스 SBC에서 처리
    • 이미 RPi + STM32 구조가 이 패턴이다. 리눅스 학습은 RPi 쪽을 확장하는 데 직접 적용된다
  • SocketCAN은 가장 먼저 연습할 만하다
    • CAN 프로토콜을 이미 알고 있으므로, 같은 메시지를 리눅스 소켓으로 보내고 받는 것부터 시작
    • candump, cansend 유틸리티로 디버깅하면 베어메탈에서 로직 분석기 없이 CAN 메시지를 확인하는 것보다 쉽다