베어메탈에서 임베디드 리눅스로
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-gcc | arm-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, ¶m);
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-gcc → arm-linux-gnueabihf-gcc | QEMU ARM |
| 2 | 커널 모듈 | 캐릭터 디바이스 = CAN/UART 디바이스 파일 | QEMU 또는 RPi |
| 3 | Device Tree | CubeMX 핀 설정 → DTS 노드 | RPi + dtoverlay |
| 4 | 버스 드라이버 | HAL_UART/SPI/I2C → 커널 드라이버 | RPi + 센서 |
| 5 | SocketCAN | HAL_CAN → can_frame + 소켓 | RPi + MCP2515 |
| 6 | libgpiod | HAL_GPIO → gpiod API | RPi GPIO |
| 7 | PREEMPT_RT | 베어메탈 타이밍 보장 → 리눅스 실시간 | RPi + cyclictest |
메모
- 베어메탈에서 리눅스로 옮기면 무엇을 잃는가
- 결정론적 타이밍. 베어메탈은 인터럽트 레이턴시가 수 μs지만, 리눅스는 수십~수백 μs
- PREEMPT_RT를 적용해도 worst-case 레이턴시가 보장되지는 않는다
- 50ms 제어 루프는 리눅스에서 문제없지만, 1μs 타이밍이 필요한 스텝 모터 펄스 생성은 불가능하다
- 베어메탈에서 리눅스로 옮기면 무엇을 얻는가
- 파일시스템, 네트워크 스택, 패키지 매니저, SSH, 로깅 등 인프라가 이미 있다
- Modbus를 직접 구현할 필요 없이
libmodbus를apt install하면 된다 - OTA가 파일 복사 수준으로 단순해진다
- 하이브리드 구조가 현실적
- 리얼타임이 필요한 부분(모터 제어, 안전 인터럽트)은 MCU에 남기고, 상위 로직(통신, 로깅, OTA)을 리눅스 SBC에서 처리
- 이미 RPi + STM32 구조가 이 패턴이다. 리눅스 학습은 RPi 쪽을 확장하는 데 직접 적용된다
- SocketCAN은 가장 먼저 연습할 만하다
- CAN 프로토콜을 이미 알고 있으므로, 같은 메시지를 리눅스 소켓으로 보내고 받는 것부터 시작
candump,cansend유틸리티로 디버깅하면 베어메탈에서 로직 분석기 없이 CAN 메시지를 확인하는 것보다 쉽다