tmux-rs 소개

ko생성일: 2025. 7. 4.갱신일: 2025. 7. 4.

tmux를 C에서 Rust로 이식한 과정을 소개합니다. C2Rust 사용 경험, 빌드 과정, 버그, C 패턴을 Rust에 적용하는 방법, 개발 도구 등에 대해 다룹니다.

2025년 7월 3일

tmux-rs 소개

Collin Richards 작성

지난 6개월간 조용히 tmux를 C에서 Rust로 포팅해왔습니다. 최근 큰 이정표에 도달했습니다: 이제 전체 코드베이스가 100% (unsafe) Rust로 되어 있습니다. 원래 코드베이스 약 67,000줄의 C 코드를 약 81,000줄의 Rust 코드(주석과 공백 제외)로 옮긴 경험을 공유하고자 합니다. 왜 tmux를 굳이 Rust로 새로 썼냐고요? 별다른 이유는 없습니다. 그냥 취미 프로젝트입니다. 정원 가꾸기 같은데, 좀 더 세그폴트가 많은 셈이죠.

C2Rust로 시작하기

이 프로젝트는 C2Rust라는 C to Rust 트랜스파일러를 체험해보고 싶어서 시작했습니다. C2Rust를 세팅하는 건 약간 까다로웠지만, 일단 실행하면 tmux 코드베이스의 Rust 포팅물을 멀쩡하게 만들어냈습니다.

하지만 자동 생성된 코드는 기본적으로 유지보수 불가에다 원래 C 코드보다 3배는 더 컸습니다. 손도 대기 싫은 코드였죠. 예를 들어 볼까요:

c
// 원본 C 코드 int colour_palette_get(struct colour_palette *p, int c) { if (p == NULL) return (-1); if (c >= 90 && c <= 97) c = 8 + c - 90; else if (c & y) c &= ~COLOUR_FLAG_256; else if (c >= 8) return (-1); if (p->palette != NULL && p->palette[c] != -1) return (p->palette[c]); if (p->default_palette != NULL && p->default_palette[c] != -1) return (p->default_palette[c]); return (-1); }
rust
// 자동 생성된 Rust 코드 #[no_mangle] pub unsafe extern "C" fn colour_palette_get( mut p: *mut colour_palette, mut c: libc::c_int, ) -> libc::c_int { if p.is_null() { return -(1 as libc::c_int); } if c >= 90 as libc::c_int && c <= 97 as libc::c_int { c = 8 as libc::c_int + c - 90 as libc::c_int; } else if c & 0x1000000 as libc::c_int != 0 { c &= !(0x1000000 as libc::c_int); } else if c >= 8 as libc::c_int { return -(1 as libc::c_int) } if !((*p).palette).is_null() && *((*p).palette).offset(c as isize) != -(1 as libc::c_int) { return *((*p).palette).offset(c as isize); } if !((*p).default_palette).is_null() && *((*p).default_palette).offset(c as isize) != -(1 as libc::c_int) { return *((*p).default_palette).offset(c as isize); } return -(1 as libc::c_int); }

이 정도면 양반이고, 더 심각한 경우도 많았습니다. 제일 큰 고민은 COLOUR_FLAG_256 같은 상수가 그냥 0x1000000 등으로 바뀌면서 코드 의미가 탁해졌다는 점입니다. libc::c_int로의 캐스팅도 잔뜩 들어가 코드가 지저분해졌죠. 아마 C의 정수 승격 규칙을 처리하려는 것 같은데, Rust에선 불필요한 경우가 태반입니다.

결국 이 "엉터리 Rust" 코드를 손으로 고치고 또 고치다 보니, 원본 C 코드를 계속 들여다봐야 의도가 보였습니다. 반복적으로 이런 식으로 수정하다가 결국 방법을 바꿨습니다. C2Rust로 만든 코드를 전부 버리고 모든 파일을 처음부터 직접 Rust로 옮기기로 했습니다.

이 프로젝트에선 안 썼지만 C2Rust는 여전히 훌륭한 도구라고 생각합니다. 프로젝트를 시작부터 컴파일하고 실행할 수 있게 해준 점만으로도 아주 중요했습니다. 이 경험 덕분에 실현 가능하단 걸 깨달았어요. 실제로 다른 사이드 프로젝트엔 통합해 쓰고 있습니다.

빌드 과정

┌─────────────┐    ┌────────────┐     ┌──────────────┐    ┌──────────┐         ┌───────┐    
│ Makefile.am │───►│ autogen.sh ├────►│ configure.sh │───►│ Makefile │         │ cargo │    
└─────────────┘    └────────────┘     └──────────────┘    └──────────┘         └───┬───┘    
                                                                                   │        
                                                                                   │        
                                ┌──────┐       ┌──────┐                            │        
                           ┌───►│tmux.c├──────►│tmux.o├───────┐                    │        
               ┌──────┐    │    └──────┘       └──────┘       │                    │        
               │tmux.h├────┤                                  │                    │        
               └──────┘    │  ┌────────┐     ┌────────┐       │                    │        
                           ├─►│window.c├────►│window.o├───────┤                    │        
              ┌────────┐   │  └────────┘     └────────┘       │                    │        
              │compat.h├───┤                                  │                    │        
              └────────┘   │    ┌──────┐       ┌──────┐       │                    ▼        
                           └───►│pane.c├──────►│pane.o├───────┤             ┌──────────────┐
                                └──────┘       └──────┘       │    ┌────┐   │              │
                                          ┌───────────┐       │    │tmux│◄──┤ libtmux_rs.a │
                                          │           │       ├───►└────┘   │              │
                                          │ libc.so.6 ├───────┤             └──────────────┘
                                          │           │       │                              
                                          └───────────┘       │                             
                                      ┌───────────────┐       │                             
                                      │               │       │                             
                                      │ libtinfo.so.6 ├───────┘                             
                                      │               │                                     
                                      └───────────────┘

이 포팅에서 가장 중요한 첫 단계는 빌드 방식을 제대로 이해하는 것이었습니다. tmux는 autotools를 활용합니다. 파일을 어디서 추가/삭제하는지, Rust 크레이트에서 만든 static 라이브러리를 crate-type = "staticlib" 옵션으로 어떻게 링크시킬지 등도 파악해야 했습니다.

그래서 빌드 과정이 단순히 cargo build로 끝나지 않았고, cargo 빌드 후 make를 실행하는 build.sh 스크립트를 작성해 사용했습니다. 다만 파일을 하나씩 번역할 때마다 메이크파일이나 헤더 수정, 재설정 등 부가 작업이 자주 필요했습니다.

초반엔 여러 미니 크레이트로 나눠보려 노력했지만, 결과적으로 모든 걸 한 크레이트에 담는 게 더 쉬웠습니다. 이유는 크게 두 가지였습니다: 1. 크레이트끼리 순환 참조 불가, 2. 여러 Rust 라이브러리를 하나의 바이너리에 링크하면 링킹 이슈 발생.

초기에는 한 파일씩 통째로 옮기다 보니, 그 파일이 끝날 때까지 부분적으로 테스트할 수가 없었습니다. 대형 파일을 옮기다가 디버깅에 막혀 개발 방식을 바꿨습니다. 이제는 함수 하나씩 번역하고, build.sh run으로 수시로 빌드를 체크했습니다. 원래 static 함수였던 것도 헤더에 별도 추가해줘야 했죠. 프로세스는 대충 이렇게 됩니다:

  • C 함수 헤더를 복사함
  • C 함수 본문을 주석 처리함
int colour_palette_get(struct colour_palette *p, int c);
// int colour_palette_get(struct colour_palette *p, int c) {
// ...
//
  • 해당 함수를 Rust로 구현함

이러면, Rust에서 #[unsafe(no_mangle)]extern "C" 속성을 붙여 정확히 맞는 시그니처만 있으면 C 코드는 Rust 구현체를 잘 링크해 쓸 수 있습니다.

대략 절반쯤 C 파일을 옮긴 뒤엔 "빌드 주체가 왜 여전히 C이냐" 의문이 들어, 이제는 반대의 접근을 했습니다. 즉, Rust 바이너리에 C 라이브러리를 링크하는 방식으로 변경했습니다. 이 방식엔 cc 크레이트를 쓰면 됩니다.

아래와 같이 build.rs를 작성했습니다:

rust
// tmux-rs/build.rs 요약판 fn main() { println!("cargo::rerun-if-changed=build.rs"); println!("cargo::rustc-link-lib=bsd"); println!("cargo::rustc-link-lib=tinfo"); println!("cargo::rustc-link-lib=event_core"); println!("cargo::rustc-link-lib=m"); println!("cargo::rustc-link-lib=resolv"); let mut builder = &mut cc::Build::new(); static FILES: &[&str] = &[ "osdep-linux.c", "cmd-new-session.c", "cmd-queue.c", // ... "window-customize.c", "window-tree.c", ]; for f in FILES { builder = builder.file(std::path::PathBuf::from("..").join(f)) } builder.compile("foo"); }

흥미로운 버그들

포팅 과정에서 여러 버그를 만들었습니다. 그 중 두 가지의 발견/수정 과정을 공유합니다.

버그 1

사소한 함수 하나를 번역한 뒤 프로그램이 segfault(비정상 종료) 되기 시작했습니다. 아래가 원본과 번역 코드입니다.

c
void* get_addr(client* c) { return c->bar; }
rust
unsafe extern "C" fn get_addr(c: *mut client) -> *mut c_void { unsafe { (*c).bar } }

디버거로 보면 에러 메시지가 Invalid read at address 0x2764 같은 식이었습니다.

코드를 한 줄씩 따라 본 결과, Rust 함수 내에서 (*c).bar의 값은 0x60302764처럼 정상 주소인데, C에서 이걸 받을 땐 0x2764로 나옵니다. 왜일까요? 힌트가 더 필요하다면... C 컴파일 경고를 관련해서 보았더라면 이런 문구가 있었겠죠:

warning: implicit declaration of function ‘get_addr’ [-Wimplicit-function-declaration]

즉, C 코드에서 암시적 선언(int get_addr();)을 사용한 것과 같았습니다. 그러니 C 컴파일러는 4바이트 int 반환으로 생각하고 8바이트 포인터의 앞 4바이트를 날려버린 것입니다. 고치는 방법은 올바른 프로토타입을 헤더에 추가하는 것뿐입니다. 그러면 컴파일러가 올바른 코드를 생성합니다.

버그 2

역시나 별문제 없어 보이는 함수를 번역한 뒤 생긴 버그입니다. 다음과 같이 생겼습니다.

c
void set_value(client* c) { c->foo = 5; }
rust
unsafe extern "C" fn set_value(c: *mut client) { unsafe { (*c).foo = 5; } }

간단한 함수인데 번역 후 segfault가 나서 당황스러웠습니다. 디버거 상에서 Rust 코드는 해당 줄에서 죽었고, C와 Rust에서 포인터 주소가 약간씩 달랐는데 단순 무작위 주소화인가 했죠.

진짜 문제는 client 구조체 타입 선언을 직접 옮기다가 타입에서 '*'을 하나 빼먹은 거였습니다. 해당 필드는 데이터 필드 바로 위에 있었는데, 이 때문에 C와 Rust 모두 다른 구조체 레이아웃을 갖게 되었습니다.

예를 들어 C 구조체는:

c
struct client { int bar; int *baz; int foo; }

Rust 구조체는:

rust
struct client { bar: i32, baz: i32, foo: i32, }

아직 Rust 코드에선 baz 필드를 직접 쓰지 않아 에러가 안 나왔지만, 데이터가 잘못 해석되었습니다. 수정 방법은 Rust 필드 타입을 제대로 맞추는 것이었습니다.

Rust에서의 C 패턴

Raw 포인터

Rust에는 &T(공유 참조)와 &mut T(가변 참조) 두 가지 참조 타입이 있습니다. Rust 참조는 여러 불변식이 보장됩니다. 반드시 null이 아니고, 가리키는 값이 완전히 초기화되어 유효해야 합니다.

C의 포인터를 자연스럽게 Rust 레퍼런스로 옮기면 되겠지만, C에서처럼 모든 포인터가 이 조건을 만족하는 건 아닙니다. 그래서 처음에는 레퍼런스를 쓸 수 없고, 대신 raw 포인터 *mut T, *const T를 씁니다. 의미상 C 포인터와 동일하지만 unsafe 블록 안에서만 쓸 수 있어 다소 불편합니다.

Goto 고려하기

C에는 goto가 있습니다. tmux 코드에서는 비교적 얌전하게 쓰였고, 실제 고생스러운 케이스는 극소수뿐입니다.

c2rust 트랜스파일러는 goto 논리를 흉내내는 알고리즘을 씁니다. 관련 영상이 참고할 만합니다. 하지만 대부분의 사례는 더 단순한 방법으로 처리 가능합니다.

  • 전방점프(forward jump): 레이블 블록 + break
rust
fn foo() { 'error: { println!("hello"); if random() % 2 == 0 { break 'error; // C의 goto error와 동일 } println!("world"); return; } // 'error: println!("error"); }
  • 후방점프(backward jump): 레이블 루프 + continue
rust
fn bar() { 'again: loop { println!("hello"); if random() % 2 == 0 { continue 'again; // C의 goto again과 동일 } println!("world"); return; } }

실제로 tmux 코드베이스에선 이런 방식의 goto가 가장 흔합니다. 더 복잡한 흐름은 실제로 컨트롤 플로우를 도식화해서 매핑해야 했습니다(window_copy_search_marks 참고).

인트루시브 매크로

tmux는 매크로로 정의된 두 가지 특수 자료구조를 아주 많이 씁니다: '인트루시브 레드블랙 트리', 그리고 링크드 리스트입니다. 인트루시브 자료구조란 구조체 안에 직접 자료구조의 일부가 들어있는 형태를 말합니다. 오늘날 흔한 '컨테이너가 구조체를 소유하는' 방식과 다릅니다.

Rust에서 이걸 최대한 원래 C와 비슷하게 구현하려 여러 번 시행착오를 겪었습니다. 최종적으로 도달한 코드는 대략 아래와 같습니다:

c
// cmd-kill-session.c RB_FOREACH(wl, winlinks, &s->windows) { wl->window->flags &= ~WINDOW_ALERTFLAGS; wl->flags &= ~WINLINK_ALERTFLAGS; }
rust
// cmd_kill_session.rs for wl in rb_foreach(&raw mut (*s).windows).map(NonNull::as_ptr) { (*(*wl).window).flags &= !WINDOW_ALERTFLAGS; (*wl).flags &= !WINLINK_ALERTFLAGS; }

이 코드가 더 깔끔해지려면 iterator에서 NonNull<T>를 반환하지 않아도 되지만, C 인터페이스를 그대로 흉내 내려 직접 trait을 구현했습니다. 동일 자료형에 trait을 여러 번 구현하는 게 필요했는데, 이때 제네릭 파라미터를 활용해 해결했습니다(더미 타입으로 구분).

rust
pub trait GetEntry<T, D = ()> { unsafe fn entry_mut(this: *mut Self) -> *mut rb_entry<T>; unsafe fn entry(this: *const Self) -> *const rb_entry<T>; unsafe fn cmp(this: *const Self, other: *const Self) -> std::cmp::Ordering; } pub unsafe fn rb_foreach<T, D>(head: *mut rb_head<T>) -> RbForwardIterator<T, D> where T: GetEntry<T, D>, { RbForwardIterator { curr: NonNull::new(unsafe { rb_min(head) }), _phantom: std::marker::PhantomData, } } pub struct RbForwardIterator<T, D> { curr: Option<NonNull<T>>, _phantom: std::marker::PhantomData<D>, } impl<T, D> Iterator for RbForwardIterator<T, D> where T: GetEntry<T, D>, { type Item = NonNull<T>; fn next(&mut self) -> Option<Self::Item> { let curr = self.curr?.as_ptr(); std::mem::replace(&mut self.curr, NonNull::new(unsafe { rb_next(curr) })) } }

Yacc 변환

tmux는 설정 언어 파서를 위해 yacc를 씁니다. 저도 lex/yacc 이름만 알았지 실제 사용은 처음이었고, 포팅의 마지막 단계가 yacc 기반의 cmd-parse.y를 Rust로 바꾸는 것이었습니다. 이것만 끝내면 cc 크레이트 의존도 확 줄고 빌드가 훨씬 깔끔해지는 셈이죠.

여러 파서용 크레이트를 시도하다 결국 lalrpop을 쓰기로 했습니다. lalrpop의 구조가 yacc와 매우 유사해 일대일 변환이 쉬웠습니다.

아래는 yacc/코드 일부입니다:

yacc
lines : /* empty */ | statements { struct cmd_parse_state *ps = &parse_state; ps->commands = $1; } statements : statement '\n' { $$ = $1; } | statements statement '\n' { $$ = $1; TAILQ_CONCAT($$, $2, entry); free($2); }

Rust(lalrpop)에서 대응되는 부분은 다음과 같습니다:

rust
grammar(ps: NonNull<cmd_parse_state>); pub Lines: () = { => (), <s:Statements> => unsafe { (*ps.as_ptr()).commands = s.as_ptr(); } }; pub Statements: NonNull<cmd_parse_commands> = { <s:Statement> "\n" => s, <arg1:Statements> <arg2:Statement> "\n" => unsafe { let mut value = arg1; tailq_concat(value.as_ptr(), arg2.as_ptr()); free_(arg2.as_ptr()); value } };

lalrpop에는 몇몇 버그가 있는데, raw pointer를 문법상 제대로 못 처리할 때가 있더라고요(* 때문에). 그래서 모든 포인터를 NonNull<T>로 처리하면 잘 동작했습니다.

파서 포팅 후엔 커스텀 lexer도 어댑터로 감싸서 lalrpop과 연동하게 했습니다. lexer 자체는 원본 코드를 거의 그대로 Rust iterator로 감싼 것뿐이었고, 연결하니 즉시 동작해서 놀라웠습니다. 이 마지막 단계가 끝나고 모든 C 코드와 헤더를 완전히 제거할 수 있었습니다.

개발 과정

Vim

여러 텍스트 에디터와 IDE를 번갈아 썼지만, 기본 작업 흐름은 주로 neovim(혹은 vim) + 커스텀 매크로 활용이었습니다. 예를 들어 다음과 같이 매크로로 전환했습니다:

  • ptr == NULLptr.is_null()
  • ptr->field(*ptr).field

이런 기계적인 치환은 쉽지만, 한 번에 find/replace로 하기 어렵습니다. 결국 수작업으로 수천 번 직접 해야 했습니다.

AI 도구

개발 후반 Cursor(코드 AI)에 도전해봤으나, 실제로 속도가 그리 빨라지진 않아 곧 중단했습니다. 실수를 막아주진 못했고, 제가 직접 코드를 검토하는 시간이 필요했으니 손으로 쓴 것과 큰 차이가 없었습니다. 실제로 손가락은 더 편해지지만요! 대규모 refactoring은 진짜 손가락에 무리라, 손에 물집이 잡힐 정도로 아파서야 그때 만족하게 쉬는 게 낫다는 생각도 들었습니다. AI 도구가 더 발전하면 나중엔 이런 프로젝트가 훨씬 빠르게 가능해질 거라 생각합니다.

결론

이제 코드가 100% Rust지만, 제 본래 목표가 이루어졌는지는 잘 모르겠습니다. 손으로 번역한 코드도 C2Rust 결과와 그리 크게 낫지 않고, 크래시나 버그도 여전히 많습니다. 다음 목표는 이 코드베이스를 안전한(safe) Rust로 만드는 것입니다.

그럼에도 불구하고, Rust와 tmux 팬들에게 공유하고 싶어 0.0.1 버전을 릴리스합니다. 관심 있는 분은 Github Discussions에서 함께 이야기해요. 설치법 등은 README에서 확인할 수 있습니다.