Giới Thiệu tmux-rs: Hành Trình Chuyển Đổi tmux từ C Sang Rust

Trong 6 tháng qua, tôi đã âm thầm làm một dự án thú vị: chuyển toàn bộ codebase của tmux từ ngôn ngữ C sang Rust. Và giờ đây, tôi vui mừng thông báo rằng dự án đã đạt được cột mốc quan trọng – toàn bộ codebase đã được chuyển đổi 100% sang Rust (dù vẫn còn sử dụng unsafe code). Bài viết này sẽ chia sẻ chi tiết hành trình chuyển đổi từ ~67,000 dòng C sang ~81,000 dòng Rust (không tính comment và dòng trống).

Tại Sao Lại Viết Lại tmux Bằng Rust?

Câu hỏi đầu tiên có lẽ nhiều người thắc mắc: Tại sao lại bỏ công sức viết lại tmux bằng Rust? Thực ra, tôi không có lý do đặc biệt nào cả. Đây đơn giản là một dự án giải trí, giống như việc làm vườn, nhưng thay vì cỏ dại thì tôi phải đối mặt với những cú segfault.

Bắt Đầu Với C2Rust

Tôi bắt đầu dự án này như một cách để thử nghiệm C2Rust – công cụ chuyển đổi từ C sang Rust. Mặc dù cần một chút thời gian để thiết lập, nhưng sau khi chạy, công cụ đã tạo ra thành công một bản port Rust của codebase tmux.

Tuy nhiên, code được tạo ra khó bảo trì và lớn gấp 3 lần bản gốc. Ví dụ:

// C gố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 được tạo ra
#[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);
}

Sau nhiều lần refactor thủ công, tôi quyết định từ bỏ cách tiếp cận này và chuyển sang dịch toàn bộ code bằng tay từ C sang Rust.

Quá Trình Build

Phần quan trọng nhất của việc viết lại này là hiểu rõ quy trình build của project. Với tmux, đó là autotools. Tôi đã tìm hiểu cách thêm/xóa file trong autotools và cách điều chỉnh Makefile để liên kết với thư viện Rust thông qua crate-type = “staticlib”.

Ban đầu, tôi dịch từng file một mà không có cách nào kiểm tra các thay đổi giữa chừng. Sau đó, tôi chuyển sang cách tiếp cận dịch từng hàm một, chạy build.sh sau mỗi hàm để đảm bảo mọi thứ hoạt động.

Sau khi dịch được khoảng nửa số file C, tôi nhận ra quy trình build hiện tại khá ngớ ngẩn. Phần lớn code đã là Rust, vì vậy thay vì build binary C và liên kết với thư viện Rust, tôi nên build binary Rust và liên kết với thư viện C. Điều này có thể thực hiện bằng cách sử dụng crate cc.

Những Lỗi Thú Vị

Tôi đã giới thiệu rất nhiều bug trong quá trình dịch code. Dưới đây là một số ví dụ thú vị:

Bug 1: Sai Kiểu Trả Về

Chương trình bắt đầu segfault sau khi dịch một hàm đơn giản:

// C gố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 }
}

Sau khi chạy debugger, lỗi là: Invalid read at address 0x2764. Vấn đề là code C đang sử dụng khai báo ngầm định int get_addr() thay vì void* get_addr(client*), khiến trình biên dịch C nghĩ rằng hàm trả về int 4 byte thay vì con trỏ 8 byte.

Bug 2: Sai Kiểu Trong Struct

Một lỗi khác xảy ra khi dịch một hàm đơn giản:

// C gốc
void set_value(client* c) { c->foo = 5; }

// Rust
unsafe extern "C" fn set_value(c: *mut client) {
    unsafe { (*c).foo = 5; }
}

Khi kiểm tra, tôi phát hiện ra rằng khi dịch thủ công khai báo struct client, tôi đã bỏ sót một * trên một trong các kiểu, khiến code C và Rust có cách hiểu khác nhau về bố cục bộ nhớ sau trường đó.

Các Mẫu C Trong Rust

Con Trỏ Thô

Rust có hai loại tham chiếu: &T (tham chiếu chia sẻ) và &mut T (tham chiếu độc quyền). Tuy nhiên, do các ràng buộc của Rust reference, chúng ta thường phải sử dụng raw pointer (*mut T và *const T) khi chuyển đổi từ C.

Xử Lý Goto

C sử dụng goto, và trong codebase tmux, hầu hết các trường hợp sử dụng goto khá đơn giản:

  • Nhảy tiến: Sử dụng khối có nhãn với break
  • Nhảy lùi: Sử dụng vòng lặp có nhãn với continue

Macros Xâm Nhập

Tmux sử dụng nhiều cấu trúc dữ liệu được định nghĩa bằng macro: cây đỏ-đen và danh sách liên kết. Tôi đã thử nhiều cách để triển khai giao diện Rust tương tự code C.

Xử Lý Yacc

Tmux sử dụng yacc để triển khai trình phân tích cú pháp tùy chỉnh. Sau khi thử nghiệm một số crate, tôi quyết định sử dụng lalrpop để triển khai lại trình phân tích cú pháp trong Rust.

Quy Trình Phát Triển

Sử Dụng Vim

Tôi đã sử dụng neovim với các macro tùy chỉnh để tăng tốc quá trình dịch, như chuyển đổi:

  • ptr == NULL thành ptr.is_null()
  • ptr->field thành (*ptr).field

Công Cụ AI

Tôi đã thử sử dụng Cursor nhưng nhận thấy nó không thực sự tăng tốc độ phát triển mà chỉ giúp giảm đau tay. Tôi vẫn phải dành nhiều thời gian kiểm tra code được tạo ra như khi tự viết.

Kết Luận

Mặc dù code đã được chuyển đổi 100% sang Rust, tôi chưa thực sự đạt được mục tiêu chính. Code dịch thủ công của tôi không tốt hơn nhiều so với output từ C2Rust và vẫn có nhiều bug. Mục tiêu tiếp theo là chuyển đổi codebase sang safe Rust.

Tôi đã phát hành phiên bản 0.0.1 để chia sẻ với những người yêu thích Rust và tmux. Nếu dự án này thú vị với bạn, hãy kết nối với tôi qua GitHub Discussions. Xem hướng dẫn cài đặt trong README.

Chỉ mục