2026-07-28

Ender 3 V3 SE Klipper 전환 기록 (8) - 호스트 UART 연결

Ender 3 V3 SE를 Klipper로 전환하는 과정의 여덟 번째 게시글입니다.

3D PrintKlipperLinux

Ender 3 V3 SE에 Klipper를 설치할 때는 일반적으로 프린터의 USB-C 포트를 통해 호스트와 메인보드를 연결한다. 설치가 간단하고 별도의 배선 작업이 거의 필요하지 않다는 장점이 있지만, 프린터 외부에 USB 케이블이 하나 더 노출된다.

한편 Ender 3 V3 SE의 순정 디스플레이는 메인보드와 UART를 통해 통신한다. Creality의 Klipper 기반 호스트인 Nebula Pad 역시 순정 디스플레이를 대체해 이 커넥터를 사용하는 구조로 알려져 있다. 순정 디스플레이를 더 이상 사용하지 않는다면, 이 UART 통신선을 호스트와 프린터 MCU 간 연결에 재사용할 수 있다.

내가 사용 중인 BTT Pi v1.2 역시 GPIO를 통해 UART를 사용할 수 있으므로, 기존 USB 연결을 화면 커넥터를 이용한 UART 연결로 변경해 보기로 했다.

작업 자체는 TX, RX, GND 세 선을 연결하고 펌웨어의 통신 인터페이스를 바꾸는 정도다. 그러나 실제 작업에서는 다음 문제가 동시에 발생했다.

  • BTT Pi의 UART가 리눅스 콘솔로 사용되고 있었다.
  • 최초 펌웨어 빌드에서 잘못된 USART를 선택했다.
  • 설정을 정정한 펌웨어가 SD 카드를 통해 실제로 플래시되지 않았다.
  • 트러블슈팅 도중 TX와 RX 배선까지 잘못 연결되었다.

여러 원인이 서로를 가린 탓에 단순한 연결 변경 작업이 예상보다 긴 트러블슈팅으로 이어졌다. 이번 글에서는 먼저 최종적으로 확인한 UART 연결 방법을 정리하고, 이후 실제로 문제를 좁혀 간 과정을 기록한다.

작업 개요와 주의사항

주의: 이 글은 내가 사용한 Ender 3 V3 SE 메인보드, BTT Pi v1.2, ST-Link 클론을 기준으로 작성한 트러블슈팅 기록이다. 같은 제품명이라도 메인보드 리비전, MCU, 호스트 이미지, 디버거의 핀 기능이 다를 수 있다. 특히 뒤에서 사용하는 OpenOCD 명령은 MCU 종류, 플래시 크기, 부트로더 오프셋을 직접 지정하고 메인 플래시를 지우므로, 이 글의 명령이나 배선을 그대로 신뢰해 실행하면 안 된다. 자신의 보드 마킹과 핀 배치, 빌드 설정을 확인하고 복구 가능한 백업을 만든 경우에만 적용해야 한다.

이번 작업에서는 순정 디스플레이 커넥터의 UART 통신선을 사용한다. 따라서 순정 디스플레이와 UART 호스트 연결을 동시에 사용할 수는 없다.

UART에서는 한쪽 장치의 TX를 상대 장치의 RX에 연결해야 한다. TX끼리, RX끼리 같은 이름으로 연결하면 통신할 수 없다.

또한 이번 연결에서는 전원선을 사용하지 않는다. BTT Pi와 프린터 메인보드는 각각 기존 전원으로 동작시키고, UART 통신을 위해 TX, RX, GND만 연결했다.

작업 전 주의사항
  • 프린터와 BTT Pi의 전원을 모두 끈 상태에서 배선을 연결한다.
  • UART는 3.3V 로직 레벨 시리얼 통신이다.
  • 연결 전 양쪽 장치의 UART 전압 레벨을 확인한다.
  • TX와 RX는 교차 연결하고 GND는 반드시 공통으로 연결한다.
  • 커넥터 방향을 반드시 확인한다. 특히 키 위치 및 1번 핀의 위치에 유의한다.
  • ST-Link를 사용하는 과정은 일반적인 설치 절차가 아니다. SD 카드 플래시가 실제로 반영되지 않은 문제를 확인하기 위해 사용한 복구 및 검증 절차이며, 메인보드를 개조하는 행위가 포함되니 작업에 대한 이해가 없다면 따라하지 않아야 한다.

사용 환경

프린터 메인보드의 MCU는 칩 각인으로 확인한 GD32F303RET6이다. OpenOCD는 별도의 미니 PC에서 Ubuntu Server 26.04의 apt 패키지로 설치해 사용했다. 작업 당시 버전을 기록하지 않았지만, 해당 배포판의 패키지 기록을 기준으로 0.12.0 계열이었던 것으로 추정한다.

Ender 3 V3 SE 메인보드에 실장된 GD32F303RET6 MCU의 각인 확대

MCU 종류는 보드에 실장된 칩의 각인을 기준으로 확인했다.

준비물

기본 준비물

변환 케이블을 만들기 위해 다음 부품과 도구를 준비했다.

  • 순정 디스플레이 케이블과 맞는 2×5핀 2.54 mm 커넥터
  • BTT Pi GPIO에 연결할 점퍼선 또는 듀퐁 커넥터
  • 전선
  • 납땜 인두와 납
  • 멀티미터
  • 펌웨어 플래시용 SD 카드

전용 커넥터 없이 2.54 mm 점퍼선을 각각 꽂는 방법도 가능하다. 그러나 핀 위치를 잘못 잡거나 커넥터를 뒤집어 꽂을 가능성이 있으므로, 가능하면 키가 있는 올바른 커넥터를 사용하는 것이 좋다.

나는 아두이노 작업에 흔히 사용하는 점퍼선을 잘라 BTT Pi 쪽 배선으로 사용했고, 납땜에는 Pinecil v2를 사용했다.

트러블슈팅에 사용한 장비

다음 장비는 정상적인 UART 연결 작업에는 필요하지 않다. SD 카드로 플래시한 펌웨어가 실제 MCU에 기록되었는지 확인하기 위해 사용했다.

  • ST-Link V2 클론
  • SWD 연결용 2.54 mm 핀 헤더
  • OpenOCD를 실행할 별도의 리눅스 시스템

위 장비는 어디까지나 내가 당장 쓸 수 있던 것을 사용한 것으로, 완벽하게 참고할 필요는 없다.

Ender 3 V3 SE 메인보드에는 SWD 연결용 패드가 있지만 핀 헤더가 실장되어 있지 않았다. 따라서 ST-Link를 연결하기 위해 직접 핀을 납땜했다.

UART 연결 구성

변환 케이블 제작

순정 디스플레이 커넥터에서 사용할 핀은 GND, TX, RX 세 개다.

순정 디스플레이 보드의 2×5핀 커넥터와 주변 GND, TX, RX 실크 마킹

순정 디스플레이 보드 쪽 커넥터의 실크 마킹.

프린터 메인보드의 TX, RX를 BTT Pi의 RX, TX에 연결하고, 서로의 GND를 연결했다.

2×5핀 커넥터에 검정색, 파란색, 녹색 전선을 연결하고 납땜부를 고정한 UART 변환 케이블

제작한 UART 변환 케이블. 세 선만 사용하고 납땜부는 절연과 기계적 고정을 위해 글루건으로 덮어 두었다.

전선을 납땜한 뒤 멀티미터로 다음 항목을 확인했다.

  1. 각 선의 양 끝이 정상적으로 연결되어 있는지
  2. 인접한 핀끼리 단락되지 않았는지
  3. TX와 RX가 교차 연결되어 있는지
  4. 전원 핀이 연결되지 않았는지

이번 작업에서는 마지막에 TX와 RX 배선 오류까지 겪었다. 따라서 케이블을 완성한 직후뿐 아니라, 반복적인 테스트를 위해 선을 분리했다가 다시 연결한 뒤에도 배선을 다시 확인하는 것이 좋다.

Klipper MCU 펌웨어 변경

기존에는 USB를 통해 호스트와 MCU가 통신하도록 펌웨어를 빌드했다. 이번에는 순정 디스플레이 통신선에 해당하는 USART2를 사용하도록 설정을 변경해야 한다.

기본적인 펌웨어 빌드 과정은 기존 포스팅을 참고한다. 기존 설정과 달라지는 핵심 항목은 Communication interface다.

Processor model: STM32F103
Bootloader offset: 28KiB bootloader
Communication interface: Serial (on USART2 PA3/PA2)

GD32F303RET6을 STM32F103 호환 대상으로 빌드했고, 프린터의 기존 28 KiB 부트로더를 유지하도록 애플리케이션 시작 주소를 0x08007000으로 설정했다. 그 밖의 클럭 설정은 기존에 사용하던 설정을 유지했다. 위 설정은 이 글에서 사용한 보드와 펌웨어를 위한 값이며, MCU 이름이나 프린터 모델만 보고 다른 보드에 그대로 적용하면 안 된다.

설정을 완료한 뒤 Klipper 펌웨어를 빌드한다.

cd ~/klipper
make clean
make

생성된 펌웨어는 일반적으로 다음 경로에 있다.

out/klipper.bin

SD 카드로 펌웨어 플래시

빌드한 klipper.bin을 SD 카드에 복사한 뒤 프린터 부트로더가 인식할 수 있도록 파일명을 변경한다.

Ender 3 V3 SE의 순정 부트로더는 이전과 다른 이름의 BIN 파일이 있으면 플래시를 시도한다고 알려져 있다. 다만 내 이전 경험상 너무 긴 파일명을 사용하면 실패하는 경우가 여러 번 있었다. 가급적 klipper2.bin처럼 짧은 파일명을 추천한다.

플래시 과정은 다음과 같다.

  1. 프린터 전원을 끈다.
  2. 펌웨어 파일이 들어 있는 SD 카드를 삽입한다.
  3. 프린터 전원을 켠다.
  4. 충분히 기다린 뒤 다시 전원을 끈다.
  • 참고: 나는 이전 작업으로 인해 프린터 전원을 켜면 BTT Pi가 같이 켜졌다. 일반적으로 부팅 완료 후 즉시 KlipperScreen에서 종료 버튼을 누른 다음 종료가 끝나면 대략 1분이 경과하는데, 나는 이 시간을 기준으로 기다렸다.
  1. SD 카드를 제거한다.

내 프린터에서는 플래시 후에도 SD 카드의 BIN 파일이 그대로 남아 있었다. 따라서 파일이 삭제되거나 이름이 변경되었는지만으로는 성공 여부를 판단할 수 없었다.

이 불명확한 동작은 이후 트러블슈팅을 크게 어렵게 만든 원인 중 하나였다.

BTT Pi에서 UART 사용 준비

UART 장치 확인

BTT Pi의 GPIO UART는 /dev/ttyS0으로 나타났다.

ls -l /dev/ttyS0

내 환경에서는 다음과 같이 표시되었다.

crw-rw---- 1 root dialout 4, 64 Jul 13 22:43 /dev/ttyS0

장치의 그룹이 dialout이므로, Klipper 서비스를 실행하는 사용자가 이 그룹에 포함되어 있어야 한다.

내 BTT Pi에는 내가 만든 일반 사용자 계정이 ppp 하나뿐이었고, Klipper도 이 계정으로 설치했다. 따라서 당시에는 서비스 사용자를 별도로 조회하지 않고 ppp를 권한 설정 대상으로 사용했다.

sudo usermod -aG dialout ppp
sudo systemctl restart klipper

그룹 데이터베이스에 반영된 결과는 다음 명령으로 확인할 수 있다.

id ppp

usermod 실행 뒤 Klipper 서비스를 재시작하면 새로 시작한 서비스 프로세스에 그룹 정보가 반영된다. 현재 로그인한 셸에서도 새 그룹 권한을 사용해야 한다면 재로그인해야 한다.

시리얼 콘솔 해제

BTT Pi의 UART0은 기본 설정에서 커널 콘솔이나 시리얼 로그인 콘솔로 사용될 수 있다. 이 상태에서는 /dev/ttyS0이 존재하더라도 Klipper 통신에 정상적으로 사용할 수 없다.

내 환경에서는 /boot/armbianEnv.txtconsole 값을 none으로 변경하는 것만으로는 UART 콘솔이 완전히 해제되지 않았다. 최종적으로는 커널 콘솔을 화면으로 명시하기 위해 extraargs에 다음 인자를 추가했다.

extraargs=console=tty0

이미 extraargs가 존재한다면 기존 내용을 지우지 않고 같은 줄 뒤에 console=tty0을 추가한다.

예를 들어 기존 설정이 다음과 같다면,

extraargs=기존옵션

다음과 같이 변경한다.

extraargs=기존옵션 console=tty0

내가 사용한 이미지에서는 이 설정을 적용한 뒤 UART0이 커널 콘솔로 사용되지 않았다. 다만 이미지 버전에 따라 부트 스크립트의 동작이 다를 수 있으므로, 설정값만 믿지 말고 실제로 적용된 커널 명령행을 확인해야 한다.

cat /proc/cmdline

출력에 다음과 같은 시리얼 콘솔 인자가 남아 있지 않은지 확인한다.

console=ttyS0,...
console=serial0,...

커널 로그에서도 콘솔과 시리얼 장치 상태를 확인할 수 있다.

dmesg | grep -E 'console|ttyS0|serial'

serial-getty 비활성화

시리얼 장치에서 로그인 프롬프트를 제공하는 serial-getty 서비스도 비활성화했다.

sudo systemctl disable --now serial-getty@ttyS0.service
sudo systemctl mask serial-getty@ttyS0.service

serial-getty와 커널 콘솔은 서로 다른 기능이다. 따라서 이 서비스를 비활성화했다고 해서 커널의 console=ttyS0 등록까지 자동으로 제거되는 것은 아니다.

장치를 점유한 프로세스가 있는지도 확인한다.

sudo fuser -v /dev/ttyS0

불필요한 프로세스가 표시되지 않아야 한다.

Klipper 설정 변경

호스트의 UART를 사용할 수 있게 되었다면 printer.cfg에서 MCU 연결 장치를 변경한다.

[mcu]
serial: /dev/ttyS0
restart_method: command

설정을 저장한 뒤 Klipper를 재시작한다.

sudo systemctl restart klipper

정상적으로 연결되었다면 Mainsail에서 MCU 연결 오류가 사라진다. 문제가 발생하면 klippy.log를 확인한다.

tail -n 100 ~/printer_data/logs/klippy.log

여기까지가 최종적으로 확인한 정상적인 UART 연결 절차다.

트러블슈팅

실제 작업은 앞서 정리한 순서대로 진행되지 않았다. 여러 문제가 동시에 존재했고, 하나를 해결할 때마다 그 뒤에 가려져 있던 다른 문제가 드러났다.

console=none으로 UART 콘솔 비활성화

BTT Pi의 UART0을 사용하기에 앞서 /boot/armbianEnv.txt를 확인했다. 당시 console=both라고 설정되어 있었는데, 이것을 console=none으로 변경했다.

그러나 MCU와의 통신은 여전히 이루어지지 않았다.

Klipper가 /dev/ttyS0을 열지 못함

처음 UART 펌웨어를 플래시하고 BTT Pi와 프린터를 연결했을 때, Klipper는 /dev/ttyS0을 열지 못했다.

장치 자체는 존재했지만 권한은 다음과 같았다.

crw-rw---- 1 root dialout 4, 64 Jul 13 22:43 /dev/ttyS0

/dev/ttyS0의 그룹이 dialout이었으므로, Klipper를 설치할 때 사용한 ppp 계정에 이 그룹을 추가했다.

sudo usermod -aG dialout ppp

참고: 내 BTT Pi에는 내가 만든 일반 사용자 계정이 ppp 하나뿐이었고 Klipper도 이 계정으로 설치했기 때문에, 당시에는 systemd 서비스의 유효 User=를 따로 조회하지 않았다. 다른 환경에서는 systemctl show klipper -p User -p Group -p SupplementaryGroups --no-pager로 실제 적용값을 확인해야 한다.

재부팅 후 Klipper의 상태를 확인하니 이제 /dev/ttyS0을 열 수 있게 되었다. 그러나 여전히 통신은 이루어지지 않았다.

프린터를 연결하면 SysRq 메시지가 출력됨

분명히 리눅스 콘솔의 UART 점유를 해제했다고 생각했으나 이상한 증상이 남아 있었다.

프린터의 UART를 연결한 상태에서는 BTT Pi가 정상적으로 부팅하지 못하거나, 콘솔에 SysRq HELP 관련 메시지가 반복적으로 표시되었다.

조사해 보니 리눅스 시리얼 콘솔에서는 BREAK 신호가 들어온 뒤 특정 문자가 수신되면 Magic SysRq 기능이 실행될 수 있었다. 즉 프린터 MCU가 송신한 데이터가 Klipper로 전달되는 것이 아니라, 리눅스 커널의 콘솔 입력으로 해석되고 있을 가능성이 높았다.

이 현상은 console=none 설정 이후에도 UART0이 여전히 커널 콘솔로 등록되어 있다는 강한 단서였다.

첫 번째 시도: serial-getty 비활성화

먼저 /dev/ttyS0에서 실행되는 로그인 서비스를 의심했다.

sudo systemctl disable --now serial-getty@ttyS0.service
sudo systemctl mask serial-getty@ttyS0.service

그러나 증상은 그대로였다.

이를 통해 serial-getty가 직접적인 원인은 아니라는 것을 확인했다. 로그인 서비스가 없어도 UART는 여전히 커널 콘솔로 등록되어 있을 수 있었다.

두 번째 시도: boot.cmd 직접 수정

부트 스크립트에서 console=none일 때 콘솔 인자를 제거하기 위해 /boot/boot.cmd에 다음 내용을 추가해 보았다.

if test "${console}" = "none"; then setenv consoleargs ""; fi

그러나 파일 첫 부분에는 직접 수정하지 말라는 경고가 있었고, 이 변경만으로도 문제는 해결되지 않았다.

따라서 이 방법은 정상 설치 절차로 권장하지 않는다. 내 환경에서 문제를 좁히는 과정에서 시도한 내용으로만 남긴다.

실제 적용된 커널 명령행 확인

추가로 확인한 결과 디바이스 트리에는 다음과 유사한 stdout-path가 설정되어 있었다.

stdout-path: serial0:115200n8

리눅스는 명시적인 콘솔 인자가 없을 경우 디바이스 트리의 stdout-path를 콘솔로 사용할 수 있다. 따라서 console=none을 설정한 결과 오히려 명시적인 콘솔 인자가 사라지고, 시리얼 stdout-path가 다시 콘솔로 사용된 것으로 판단했다.

이를 확인하기 위해 실제 커널 명령행을 확인했다.

cat /proc/cmdline

설정 파일만 보는 것보다 이 출력이 실제 상태를 판단하는 데 중요했다.

해결: 화면 콘솔 명시

/boot/armbianEnv.txtextraargs에 다음 내용을 추가했다.

extraargs=console=tty0

이후 재부팅하자 부팅 로그가 console=none 설정 직후와 달리 다시 화면으로 출력되었고, 프린터 UART를 연결해도 SysRq 메시지가 나타나지 않았다.

다음 항목도 함께 확인했다.

cat /proc/cmdline
sudo fuser -v /dev/ttyS0

커널 명령행에서 시리얼 콘솔 인자가 사라지고, /dev/ttyS0을 점유한 Klipper 외의 프로세스가 없는 것을 확인했다.

이 문제에서 얻은 가장 중요한 교훈은, armbianEnv.txt의 설정값만으로 UART 콘솔이 해제되었다고 판단해서는 안 된다는 점이었다. 최종적으로 생성된 /proc/cmdline과 실제 장치 점유 상태를 확인해야 했다.

잘못된 USART 설정

호스트 측 UART 문제를 해결한 뒤에도 Klipper는 프린터 MCU와 통신하지 못했다. 이때 왠지 USART3으로 설정한 것이 틀렸다는 생각이 들었다. 당시 UART 설정을 잘못 했었고, 이 시점에 과거 시도에서 PA3/PA2를 썼던 것 같은 생각이 들었다.

사실 최초 빌드에서 잘못된 자료를 참고해 다음 인터페이스를 선택한 상태였다.

USART3 PB11/PB10

실제로 순정 디스플레이 커넥터의 통신선에 해당하는 설정은 다음이었다.

USART2 PA3/PA2

이를 확인한 뒤 올바른 USART2 설정으로 Klipper 펌웨어를 다시 빌드하고 SD 카드로 플래시를 시도했다.

이 시점에는 잘못된 USART 문제가 해결되었다고 생각했다. 그러나 실제로는 플래시가 실패했었고, 뒤늦게 알아차렸다.

SD 카드 플래시가 반영되지 않음

올바른 설정으로 재빌드한 펌웨어를 SD 카드에 넣고 플래시했지만, 연결 문제는 계속되었다.

문제는 순정 부트로더에서 플래시 성공 여부를 명확하게 확인하기 어려웠다는 점이다. 별도로 진행 상태를 보여주는 기능이 원래부터 없었기 때문이다. SD 카드를 확인한다고 해도, 이전 경험상 내 프린터는 정상 플래시 후에도 해당 BIN 파일이 그대로 남아 있어 알 수 없었다.

파일명을 바꾸어 여러 번 시도했지만 상황은 달라지지 않았다.

호스트 UART 설정, MCU 펌웨어, 배선 혹은 제 4의 문제 중 어느 부분이 문제인지 더 이상 추측만으로 구분하기 어려웠기 때문에, ST-Link를 사용해 플래시 내용을 직접 확인하기로 했다. 후술하겠지만 실제로 올바른 USART 설정을 한 펌웨어는 반영되지 않았다. 아마도 이전 경험상 SD 카드를 안정적으로 읽지 못했을 가능성이 있다고 추측하고 있다. 다만 실제로 검증하지는 못했기 때문에 단순한 추측으로 남겨 두고 있다.

ST-Link를 이용한 펌웨어 확인

ST-Link를 사용한 이유

ST-Link를 연결한 목적은 두 가지였다.

  1. 현재 MCU 플래시에 실제로 어떤 펌웨어가 기록되어 있는지 확인한다.
  2. SD 부트로더를 거치지 않고 의도한 Klipper 펌웨어를 직접 기록한다.

일반적인 UART 연결 작업이라면 이 과정까지 필요하지 않다. 내 경우에는 SD 카드 플래시가 반영되었는지 확인할 방법이 필요했기 때문에 사용했다.

SWD 핀 연결

프린터 전원을 완전히 차단한 뒤 하판을 열었다.

메인보드에는 SWD 연결용 패드가 있었지만 핀 헤더는 실장되어 있지 않았다. 해당 위치에 2.54 mm 핀 헤더를 직접 납땜했다.

GD32F303RET6 MCU와 직접 납땜한 SWD 핀 헤더가 보이는 Ender 3 V3 SE 메인보드

메인보드 전체 모습. MCU 위쪽 중앙에 SWD 연결을 위해 납땜한 핀 헤더가 보인다.

ST-Link와 메인보드는 다음과 같이 연결했다. 아래 핀 이름과 VCC 처리 방법은 내가 사용한 특정 저가형 ST-Link 클론을 기준으로 한다.

메인보드사용한 ST-Link 클론
GNDGND
CLKSWCLK
DIOSWDIO
RSTNRST
VCC연결하지 않음

프린터 메인보드는 프린터 자체 전원으로 구동하고, 이 클론에서는 GND, SWCLK, SWDIO, NRST만 연결했다. 클론의 VCC 핀은 타깃 전압을 감지하는 VTref가 아니라 3.3V 전원 출력이었기 때문에 연결하지 않았다.

프린터 전원을 인가한 상태에서 이 전원 출력까지 연결하면 두 전원이 충돌하거나 역급전이 발생할 수 있다. 반대로 다른 ST-Link의 VAPP 또는 VTref 핀은 타깃 전압 감지를 위해 연결해야 할 수 있다. 따라서 다른 디버거나 외형이 비슷한 클론에 이 표를 그대로 적용하지 말고, 해당 장치의 핀 기능을 먼저 확인해야 한다.

프린터 주변에서 직접 명령을 입력하기 불편했기 때문에, 미니 PC에 ST-Link를 연결한 뒤 SSH로 접속해 작업했다.

OpenOCD 실행 전 확인

아래 명령들은 작업 기록을 바탕으로 사후 복원한 것이며, LLM의 도움을 받아 작성되었기 때문에, 그대로 실행하기 전에 반드시 검증해야 한다. 구성은 앞서 언급한 OpenOCD 0.12.0 계열의 HLA 드라이버를 기준으로 하며, 버전이나 ST-Link 드라이버가 다르면 설정 파일과 전송 방식도 달라질 수 있다.

OpenOCD 초기 설정과 플래시 형상 확인 명령

아래 명령은 OpenOCD 0.12.0의 interface/stlink.cfg를 기준으로 작성한 0.12.0 HLA 구성 전용 예시다. 이 버전의 설정 파일은 HLA 드라이버를 사용하므로 transport select hla_swd와 함께 사용한다. 네이티브 ST-Link 드라이버를 사용하는 구성에서는 transport select swd를 사용하거나 HLA 전용 설정 파일을 선택해야 한다. 설치된 설정 파일과 전송 방식을 서로 다른 버전의 예시에서 섞어 쓰면 안 된다.

OpenOCD는 일반 사용자에게 ST-Link USB 장치 접근 권한을 부여한 뒤 실행하는 것이 좋다. 배포판의 OpenOCD 패키지가 제공하는 udev 규칙을 설치하고, 필요한 그룹 권한을 적용한 뒤 ST-Link를 다시 연결한다. 아래 예시에서는 권한 구성이 끝났다고 가정하고 sudo를 사용하지 않았다.

GD32F303 전용 대상 설정 대신 STM32F1용 target/stm32f1x.cfg의 호환 설정을 사용했다. 다음 변수들은 반드시 대상 설정 파일을 불러오기 전에 지정해야 한다.

CHIPNAME=gd32f303
CPUTAPID=0x2ba01477
FLASH_SIZE=0x80000
WORKAREASIZE=0x4000

여기서 CPUTAPID는 MCU 제품 번호가 아니라 디버거가 확인하는 SW-DP 식별값이다. 실제 연결 로그에 다른 값이 표시된다면 예시 값을 억지로 적용하지 말고 MCU와 디버그 연결부터 다시 확인해야 한다.

보드에 실장된 MCU의 실제 마킹은 GD32F303RET6이었고, GD32F303xx 데이터시트상 이 부품의 메인 플래시 용량은 512 KiB, SRAM은 64 KiB다. 따라서 이 환경에서는 FLASH_SIZE=0x80000WORKAREASIZE=0x4000이 부품 사양 범위에 들어간다. 다만 FLASH_SIZE 변수는 OpenOCD 드라이버가 사용할 크기를 강제로 지정할 뿐, 실제 실리콘의 용량을 독립적으로 검출해 증명하지는 않는다. 다른 보드에서는 반드시 MCU 마킹과 데이터시트를 따로 확인해야 한다.

같은 이름의 이전 덤프를 잘못 비교하지 않도록 실행할 때마다 새 디렉터리를 만든다. 이후 명령은 같은 Bash 세션에서 계속 실행한다. 여러 코드 블록의 exit 1은 스크립트로 실행하는 것을 전제로 하며, 대화형 SSH 셸에 그대로 붙여 넣으면 해당 셸이 종료될 수 있다.

RUN_DIR=$(mktemp -d "$HOME/v3se-swd.XXXXXXXX") || exit 1
printf 'output directory: %s\n' "$RUN_DIR"

펌웨어를 기록하기 전에 다음 읽기 전용 명령으로 OpenOCD가 인식한 플래시 형상을 확인했다.

set -o pipefail

if ! openocd \
  -f interface/stlink.cfg \
  -c "transport select hla_swd" \
  -c "set CHIPNAME gd32f303" \
  -c "set CPUTAPID 0x2ba01477" \
  -c "set FLASH_SIZE 0x80000" \
  -c "set WORKAREASIZE 0x4000" \
  -f target/stm32f1x.cfg \
  -c "adapter speed 100" \
  -c "init" \
  -c "reset init" \
  -c "flash probe 0" \
  -c "flash info 0 sectors" \
  -c "shutdown" \
  2>&1 | tee "$RUN_DIR/openocd-probe.log"
then
  echo "OpenOCD probe failed" >&2
  exit 1
fi

adapter speed 100은 SWD 클럭을 보수적인 100 kHz로 설정한다. 출력에서는 드라이버가 메인 플래시 크기를 512 KiB(0x80000), 지우기 페이지를 2 KiB(0x800)로 다루는지 확인했다. GD32F30x 사용자 설명서에서도 512 KiB 이하 부품의 메인 플래시 물리 지우기 페이지를 2 KiB로 설명한다. 다만 이 출력의 용량은 앞서 지정한 FLASH_SIZE의 영향을 받으므로 실제 MCU 용량에 대한 독립적인 검증은 아니다. MCU 마킹과 데이터시트가 일치하고, OpenOCD의 크기와 페이지 형상도 예상대로 표시될 때만 이후 기록 명령을 실행해야 한다.

현재 펌웨어 덤프

OpenOCD로 현재 애플리케이션 영역을 읽어 후보 펌웨어와 비교한 결과, 두 파일은 일치하지 않았다. 올바른 USART2 설정으로 다시 빌드한 펌웨어가 SD 카드 플래시를 통해 실제로 기록되지 않았음을 이 단계에서 확인했다.

현재 펌웨어 덤프 및 비교 명령

여기서 APP는 Klipper를 빌드한 시스템의 원래 경로가 아니라, 직접 빌드한 klipper.bin을 옮긴 뒤 OpenOCD를 실행하는 미니 PC에서 보이는 경로다. 당시 두 시스템 사이에서 파일을 여러 번 옮겨 실제 경로가 일정하지 않았기 때문에 아래에는 교체가 필요한 경로로 표시했다.

APP="/OpenOCD_호스트에서의_실제_경로/klipper.bin"
if [[ ! -r "$APP" ]]; then
  echo "firmware is not readable: $APP" >&2
  exit 1
fi

sha256sum "$APP"

APP_SIZE=$(stat -c '%s' "$APP") || exit 1
if (( APP_SIZE == 0 || APP_SIZE > 0x79000 )); then
  echo "invalid firmware size: $APP_SIZE bytes" >&2
  exit 1
fi

set -o pipefail

if ! openocd \
  -f interface/stlink.cfg \
  -c "transport select hla_swd" \
  -c "set CHIPNAME gd32f303" \
  -c "set CPUTAPID 0x2ba01477" \
  -c "set FLASH_SIZE 0x80000" \
  -c "set WORKAREASIZE 0x4000" \
  -f target/stm32f1x.cfg \
  -c "adapter speed 100" \
  -c "init" \
  -c "reset init" \
  -c "dump_image {$RUN_DIR/bootloader-0x08000000.bin} 0x08000000 0x7000" \
  -c "dump_image {$RUN_DIR/mcu-app-0x08007000.bin} 0x08007000 $APP_SIZE" \
  -c "shutdown" \
  2>&1 | tee "$RUN_DIR/openocd-dump.log"
then
  echo "OpenOCD dump failed" >&2
  exit 1
fi

BOOT_DUMP="$RUN_DIR/bootloader-0x08000000.bin"
APP_DUMP="$RUN_DIR/mcu-app-0x08007000.bin"
BOOT_DUMP_SIZE=$(stat -c '%s' "$BOOT_DUMP") || exit 1
APP_DUMP_SIZE=$(stat -c '%s' "$APP_DUMP") || exit 1

if (( BOOT_DUMP_SIZE != 0x7000 || APP_DUMP_SIZE != APP_SIZE )); then
  echo "unexpected dump size" >&2
  exit 1
fi

sha256sum "$BOOT_DUMP" "$APP_DUMP"

0x08007000은 28 KiB(0x7000) 부트로더 다음의 애플리케이션 시작 주소다. 512 KiB 메인 플래시를 전제로 하면 애플리케이션에 사용할 수 있는 최대 크기는 0x79000, 즉 495,616바이트다. 위의 크기 검사는 BIN 파일이 이 범위를 벗어나는 실수를 막는다.

여기서 mcu-app-0x08007000.bin은 전체 애플리케이션 영역이 아니라, 비교하려는 klipper.bin과 같은 크기만큼 덤프한 파일이다. 새 실행 디렉터리, OpenOCD 종료 상태, 덤프 파일 크기를 모두 확인한 뒤 비교해야 이전 실행에서 남은 파일을 현재 결과로 착각하지 않는다.

덤프한 내용과 내가 USART2, PA3/PA2 및 28 KiB 부트로더 오프셋으로 직접 빌드한 후보 펌웨어를 비교했다. 이 비교가 확인하는 범위는 0x08007000부터 후보 파일의 크기만큼이다.

cmp -s "$APP" "$APP_DUMP"

RC=$?
echo "cmp result: $RC"

cmp의 종료 코드는 다음과 같다.

결과의미
0두 파일이 동일함
1두 파일의 내용이 다름
2 이상파일 접근 또는 실행 오류

내 결과는 다음과 같았다.

cmp result: 1

즉 후보 파일과 MCU의 0x08007000부터 같은 길이만큼 덤프한 바이트열이 일치하지 않았다. 따라서 SD 카드로 다시 플래시했다고 생각했던 해당 후보 바이너리가 그 위치에 동일한 형태로 기록되지는 않았음을 확인했다. 이 결과만으로 기존 펌웨어의 전체 빌드 설정이나 애플리케이션 영역 전체의 내용을 판별할 수 있는 것은 아니다.

메인 플래시 전체 백업

이미 ST-Link를 연결한 상태였으므로, 펌웨어를 직접 기록하기 전에 512 KiB 메인 플래시 전체를 백업했다.

메인 플래시 전체 백업 명령
set -o pipefail

if ! openocd \
  -f interface/stlink.cfg \
  -c "transport select hla_swd" \
  -c "set CHIPNAME gd32f303" \
  -c "set CPUTAPID 0x2ba01477" \
  -c "set FLASH_SIZE 0x80000" \
  -c "set WORKAREASIZE 0x4000" \
  -f target/stm32f1x.cfg \
  -c "adapter speed 100" \
  -c "init" \
  -c "reset init" \
  -c "dump_image {$RUN_DIR/main-flash-before.bin} 0x08000000 0x80000" \
  -c "dump_image {$RUN_DIR/bootloader-before.bin} 0x08000000 0x7000" \
  -c "shutdown" \
  2>&1 | tee "$RUN_DIR/openocd-backup.log"
then
  echo "OpenOCD backup failed" >&2
  exit 1
fi

MAIN_BACKUP_SIZE=$(stat -c '%s' "$RUN_DIR/main-flash-before.bin") || exit 1
BOOT_BACKUP_SIZE=$(stat -c '%s' "$RUN_DIR/bootloader-before.bin") || exit 1

if (( MAIN_BACKUP_SIZE != 0x80000 || BOOT_BACKUP_SIZE != 0x7000 )); then
  echo "unexpected backup size" >&2
  exit 1
fi

sha256sum \
  "$RUN_DIR/main-flash-before.bin" \
  "$RUN_DIR/bootloader-before.bin"

0x80000은 512 KiB에 해당한다. 이 값은 앞 단계에서 MCU 마킹과 flash info 출력으로 확인한 경우에만 사용해야 한다.

여기서 백업한 것은 주소 0x08000000부터 시작하는 메인 플래시 전체다. 옵션 바이트와 그 밖의 비휘발성 설정까지 포함하는 MCU 전체 백업은 아니다. 별도로 부트로더 영역도 저장해, 애플리케이션 펌웨어를 기록한 뒤 부트로더가 변경되지 않았는지 비교할 수 있게 했다.

가능하다면 실제 기록을 시작하기 전에 main-flash-before.bin, bootloader-before.bin과 해시를 OpenOCD 실행 호스트 밖에도 복사해 두는 것이 안전하다.

Klipper 펌웨어 직접 기록

USART2로 빌드한 Klipper 펌웨어를 애플리케이션 시작 주소인 0x08007000에 직접 기록했다.

주의: 다음 블록의 flash write_image erase는 메인 플래시를 실제로 지우고 기록한다. 이 글에서 확인한 GD32F303RET6, 512 KiB 플래시, 28 KiB 부트로더, 후보 펌웨어의 빌드 설정이 모두 자신의 환경과 일치하지 않으면 실행하면 안 된다.

Klipper 펌웨어 직접 기록 및 검증 명령
if [[ ! -r "$APP" ]]; then
  echo "firmware is not readable: $APP" >&2
  exit 1
fi

APP_SIZE=$(stat -c '%s' "$APP") || exit 1
if (( APP_SIZE == 0 || APP_SIZE > 0x79000 )); then
  echo "invalid firmware size: $APP_SIZE bytes" >&2
  exit 1
fi

if [[ ! -r "$RUN_DIR/main-flash-before.bin" ]]; then
  echo "main flash backup is missing" >&2
  exit 1
fi

MAIN_BACKUP_SIZE=$(stat -c '%s' "$RUN_DIR/main-flash-before.bin") || exit 1
if (( MAIN_BACKUP_SIZE != 0x80000 )); then
  echo "unexpected main flash backup size: $MAIN_BACKUP_SIZE bytes" >&2
  exit 1
fi

sha256sum "$APP" "$RUN_DIR/main-flash-before.bin"

set -o pipefail

if ! openocd \
  -f interface/stlink.cfg \
  -c "transport select hla_swd" \
  -c "set CHIPNAME gd32f303" \
  -c "set CPUTAPID 0x2ba01477" \
  -c "set FLASH_SIZE 0x80000" \
  -c "set WORKAREASIZE 0x4000" \
  -f target/stm32f1x.cfg \
  -c "adapter speed 100" \
  -c "init" \
  -c "reset init" \
  -c "flash probe 0" \
  -c "flash write_image erase {$APP} 0x08007000 bin" \
  -c "verify_image {$APP} 0x08007000 bin" \
  -c "dump_image {$RUN_DIR/bootloader-after.bin} 0x08000000 0x7000" \
  -c "dump_image {$RUN_DIR/app-after.bin} 0x08007000 $APP_SIZE" \
  -c "reset run" \
  -c "shutdown" \
  2>&1 | tee "$RUN_DIR/openocd-write.log"
then
  echo "OpenOCD write or verification failed" >&2
  exit 1
fi

flash write_image erase는 BIN 파일이 걸치는 물리 페이지를 먼저 지운다. 0x08007000은 확인한 2 KiB 페이지 경계에 맞지만, 플래시 형상이 예상과 다르면 부트로더 경계까지 영향을 줄 수 있다. 따라서 앞 단계의 비파괴 점검과 백업을 생략하면 안 된다.

파이프라인에는 pipefail을 설정했다. 이것이 없으면 OpenOCD가 실패해도 마지막의 tee가 성공하면서 전체 명령이 성공한 것처럼 보일 수 있다. 위 조건문을 통과했다면 OpenOCD의 verify_image도 성공한 것이므로, 이어서 다시 덤프한 파일을 비교했다.

cmp -s \
  "$RUN_DIR/bootloader-before.bin" \
  "$RUN_DIR/bootloader-after.bin"
BOOT_RC=$?

cmp -s "$APP" "$RUN_DIR/app-after.bin"
APP_RC=$?

echo "bootloader compare result: $BOOT_RC"
echo "application compare result: $APP_RC"

if (( BOOT_RC != 0 || APP_RC != 0 )); then
  echo "post-write comparison failed" >&2
  exit 1
fi

정상적인 결과는 다음과 같다.

bootloader compare result: 0
application compare result: 0

첫 번째 결과가 0이면 펌웨어 기록 전후의 부트로더 영역이 동일하다는 뜻이다.

두 번째 결과가 0이면 0x08007000부터 klipper.bin과 같은 길이만큼 다시 읽은 바이트열이 후보 파일과 동일하다는 뜻이다.

후보 파일은 내가 USART2, PA3/PA2 및 28 KiB 부트로더 오프셋으로 직접 빌드한 파일이므로, 비교한 범위에서는 해당 빌드 결과가 MCU에 정상적으로 기록되었음을 확인했다. 이는 후보 파일 뒤의 사용하지 않은 플래시 영역 전체가 지워졌다는 뜻은 아니다.

기록 후 부트로더 영역은 이전 백업과 일치했고, 애플리케이션 영역도 후보 펌웨어와 일치했다. 이로써 의도한 Klipper 펌웨어가 정상적으로 기록되었음을 확인했다.

마지막 문제: TX와 RX 배선 오류

이제 다음 항목은 모두 확인된 상태였다.

  • BTT Pi의 /dev/ttyS0을 Klipper가 열 수 있었다.
  • UART0의 커널 콘솔 점유가 해제되었다.
  • SysRq 메시지가 더 이상 발생하지 않았다.
  • 직접 빌드한 USART2 설정의 후보 파일과 MCU에서 다시 읽은 바이트열이 일치했다.

그런데도 Klipper는 여전히 MCU와 연결되지 않았다.

이 시점에서 다시 배선을 확인한 결과, 반복적으로 선을 뺐다 연결하는 과정에서 TX와 RX가 잘못 연결되어 있었다.

UART는 다음과 같이 교차 연결해야 한다.

BTT Pi TX  -> 프린터 RX
BTT Pi RX  <- 프린터 TX

배선을 정정하자 Klipper가 MCU와 정상적으로 연결되기 시작했다.

결과적으로 마지막 문제는 흔한 TX/RX 배선 오류였다. 그러나 그 전에 호스트 콘솔 설정, MCU의 USART 선택, SD 펌웨어 플래시 문제까지 실제로 존재했기 때문에 처음부터 배선만 의심하기는 어려웠다.

최종 구성

최종적으로 사용한 구성은 다음과 같다.

  • BTT Pi 장치: /dev/ttyS0
  • 프린터 MCU UART: USART2
  • MCU 핀: PA3/PA2
  • 연결선: TX, RX, GND
순정 디스플레이 자리에 설치한 BTT Pi v1.2와 UART 배선

순정 디스플레이 자리에 설치한 BTT Pi와 최종 배선 모습.

USB-C 케이블을 제거하고 순정 디스플레이 케이블 경로를 활용하니 외부로 노출되는 선이 줄어들어 이전보다 구성이 깔끔해졌다.

BTT Pi와 GD32F303RET6의 UART는 모두 3.3V 로직 레벨이며, 두 장치 사이에는 TX, RX, GND만 연결했다. BTT Pi와 프린터 메인보드는 각각 기존 전원으로 구동하고 장치 사이의 전원선은 연결하지 않았다.

재부팅 후에도 /dev/ttyS0을 통해 MCU가 정상적으로 연결되는 것을 확인했다.

정리

이번 작업은 결과만 보면 USB 대신 UART로 연결 방식을 바꾼 단순한 변경이었다. 그러나 실제로는 서로 다른 문제가 동시에 발생했다.

먼저 BTT Pi의 UART0은 리눅스 콘솔로 사용되고 있었다. /dev/ttyS0이 존재하고 권한 문제를 해결했다고 해서 UART를 자유롭게 사용할 수 있는 것은 아니었다.

또한 serial-getty를 비활성화하는 것과 커널 콘솔을 해제하는 것은 별개의 작업이었다. console=none이라는 설정만 보고 콘솔이 완전히 사라졌다고 판단했지만, 실제로는 디바이스 트리의 stdout-path를 통해 시리얼 콘솔이 계속 사용되고 있었다. 이 문제는 SysRq HELP 메시지가 중요한 단서가 되었고, /proc/cmdline을 확인한 뒤 console=tty0을 명시하면서 해결할 수 있었다.

프린터 MCU 쪽에서도 문제가 겹쳤다. 최초 펌웨어에서는 USART3을 잘못 선택했고, 이를 USART2로 수정한 후보 바이너리를 다시 플래시했다고 생각했지만 MCU에서 같은 길이만큼 읽은 바이트열은 후보 파일과 달랐다. 순정 SD 부트로더가 플래시 성공 여부를 명확하게 알려주지 않았기 때문에 이 사실을 바로 확인하기 어려웠다.

결국 ST-Link를 연결해 플래시 내용을 직접 덤프하고 비교한 뒤에야 해당 후보 바이너리가 동일한 형태로 기록되지 않았다는 것을 확인할 수 있었다. 이후 SWD를 통해 후보 파일을 직접 기록하고 같은 길이의 바이트열을 다시 읽어 검증했지만, 마지막에는 TX와 RX 배선까지 잘못되어 있었다.

각 문제는 개별적으로 보면 비교적 흔하고 단순하다. 그러나 여러 문제가 동시에 존재하면 하나의 증상이 다른 원인을 가리기 때문에 트러블슈팅이 훨씬 어려워진다.

이번 작업에서 얻은 교훈은 다음과 같다.

  • 장치 파일이 존재한다고 해서 해당 UART를 애플리케이션이 자유롭게 사용할 수 있는 것은 아니다.
  • 설정 파일의 값보다 실제 /proc/cmdline과 장치 점유 상태를 확인하는 것이 중요하다.
  • SD 카드에 펌웨어 파일을 넣었다고 해서 실제 플래시가 완료되었다고 가정해서는 안 된다.
  • 소프트웨어 설정을 충분히 확인했더라도 마지막에는 기본적인 TX/RX/GND 배선을 다시 검증해야 한다.
  • 여러 원인이 겹쳤을 때는 호스트, 펌웨어, 물리 배선을 각각 독립적으로 검증할 수 있는 방법을 마련해야 한다.

다음 글: Katapult 설치

이번 트러블슈팅에서 가장 불편했던 부분 중 하나는 순정 부트로더의 SD 카드 플래시가 실제로 성공했는지 쉽게 확인할 수 없다는 점이었다.

ST-Link를 이용하면 펌웨어를 확실하게 기록하고 검증할 수 있지만, 펌웨어를 업데이트할 때마다 프린터 하판을 열고 SWD를 연결하는 것은 현실적인 방법이 아니다.

이를 해결하기 위해 다음 작업에서는 순정 부트로더를 Katapult로 교체한다.

Katapult를 설치하면 이후에는 SD 카드를 준비하지 않고 UART를 통해 Klipper 펌웨어를 전송할 수 있다. 플래시 도구의 출력과 검증 결과를 통해 성공 여부도 확인할 수 있어, 순정 부트로더보다 업데이트 과정의 불확실성을 줄일 수 있다.

다음 글에서는 다음 내용을 다룰 예정이다.

  • 기존 부트로더 백업
  • Katapult 설정과 빌드
  • SWD를 이용한 최초 설치
  • Katapult와 Klipper의 애플리케이션 시작 주소 설정
  • UART를 통한 Klipper 펌웨어 업데이트
  • 복구 방법과 주의사항