⚡ Jawaban Singkat / Key Takeaways
Const generics di Rust (fitur const N: usize) memungkinkan kamu membangun safe wrapper FFI yang diverifikasi compiler. Array dengan panjang statis [u8; N] dipetakan ke pointer + length yang diharapkan oleh fungsi C. Hasilnya: buffer overflow terdeteksi saat kompilasi, bukan saat runtime di production.
Kenapa FFI Rust ke C Itu Kayak Main Petasan di Gudang Bensin?
Bayangin kamu nulis firmware sensor untuk chip ARM Cortex-M4. RAM cuma 128KB, tanpa OS, tanpa heap allocator mewah. Kamu perlu manggil library C legacy buat ngolah data telemetry. Fungsi C-nya sederhana:
// C header — kamu nggak bisa ubah ini
void process_samples(const uint8_t *data, size_t len);
Implementasi naif di Rust biasanya begini:
// ❌ Ini resep bencana
let buf: Vec<u8> = vec![0; 256];
unsafe {
process_samples(buf.as_ptr(), buf.len());
}
Masalahnya? Nggak ada yang mastiin panjang buffer yang kamu passing sesuai dengan ekspektasi C di sisi sana. Fungsi C mungkin nulis 512 byte ke buffer 256 byte. Boom. Undefined behavior. Di embedded system, undefined behavior sering berakhir dengan corrupted stack frame dan device yang tiba-tiba restart tanpa jejak.
Menurut laporan CWE Top 25 2024, out-of-bounds write masih jadi bug software paling berbahaya nomor satu. Ini bukan masalah legacy. Ini masih terjadi hari ini, di firmware device yang baru dirilis.
Const Generics: Solusi yang Nempel di Type System, Bukan Komentar
Rust memperkenalkan const generics di versi 1.51. Fitur ini mengizinkan kamu membuat tipe generik yang diparameterisasi oleh nilai konstanta, bukan cuma tipe lain. Simpelnya: kamu bisa bikin struct Buffer<const N: usize>.
Ini fundamental untuk FFI safety. Kenapa? Karena fungsi C yang menerima pointer + length punya kontrak implisit. Kontrak itu sekarang bisa kamu enkode langsung di type system Rust.
/// Safe wrapper untuk fungsi C yang butuh pointer + length.
/// Compiler MENJAMIN N yang dipassing ke process_samples()
/// selalu sesuai dengan panjang array aktual.
pub fn process_samples_safe<const N: usize>(data: &[u8; N]) {
// Safety: &[u8; N] → ptr valid, N == len, tidak mungkin mismatch
unsafe {
process_samples(data.as_ptr(), N);
}
}
// Pemakaian:
let samples: [u8; 256] = [0; 256];
process_samples_safe(&samples); // ✅ Ok: 256 byte, N=256
let short: [u8; 64] = [0; 64];
process_samples_safe(&short); // ✅ Ok: 64 byte, N=64. Tetap aman!
Lihat apa yang terjadi? Sekarang kamu nggak bisa salah. Panjang array secara harfiah adalah bagian dari tipe data. Kalau kamu ganti ukuran buffer, compiler otomatis menyesuaikan parameter length yang dikirim ke C. Nggak ada lagi cerita lupa update magic number di dua tempat berbeda.
Kenapa Ini Lebih Powerful dari Vec?
- Vec<T>: runtime length, heap-allocated, butuh allocator. Di bare-metal embedded, ini sering nggak tersedia.
- &[T]: slice reference, tetap runtime length. Compiler nggak tahu panjangnya saat kompilasi.
- &[T; N]: reference ke array fixed-size. Panjang adalah konstanta kompilasi. Zero-cost abstraction.
Trik yang Jarang Dibahas: Two-Way Size Contract Pakai Trait
Masalah sebenarnya di FFI bukan cuma “Rust ngirim data ke C”. Lebih sering, C mengembalikan data ke buffer yang disediakan Rust. Ini pola umum di driver hardware dan codec library:
// C function: decode frame, tulis ke buffer output
int jpeg_decode(const uint8_t *input, size_t input_len,
uint8_t *output, size_t *output_len);
Fungsi jpeg_decode nerima buffer output dengan kapasitas tertentu dan menulis jumlah byte aktual ke output_len. Kalau output lebih besar dari kapasitas buffer, fungsi ini bisa nulis melebihi batas. Ini adalah two-way contract yang mayoritas developer nggak validasi dengan benar.
Dengan const generics, kamu bisa bikin wrapper yang mengecek dua arah:
pub fn jpeg_decode_safe<const IN: usize, const OUT: usize>(
input: &[u8; IN],
output: &mut [u8; OUT],
) -> Result<usize, DecodeError> {
let mut actual_out: size_t = OUT as size_t;
let ret = unsafe {
jpeg_decode(
input.as_ptr(),
IN as size_t,
output.as_mut_ptr(),
&mut actual_out,
)
};
if ret != 0 {
return Err(DecodeError::DecodeFailed);
}
let written = actual_out as usize;
// ⚡ Verifikasi saat runtime: C nggak boleh nulis lebih dari kapasitas
assert!(written <= OUT, "C wrote {} bytes into {} byte buffer", written, OUT);
Ok(written)
}
Kuncinya: OUT tersedia sebagai konstanta kompilasi sekaligus sebagai runtime safety net. C nggak bisa bohong soal berapa byte yang ditulis karena kamu langsung membandingkannya dengan kapasitas buffer yang dienkode di tipe.
Real-World Pattern: Firmware OTA dengan Signature Verification
Ini pola nyata dari proyek firmware OTA (Over-The-Air) update. Kamu punya:
- C library
libed25519yang memverifikasi signature digital - Fungsi
ed25519_verify(sig, msg, msg_len, pubkey)mengharapkanmsg_lentepat 32 byte (SHA-256 hash)
Kesalahan umum: passing hash yang kepotong atau kelebihan 1 byte. Signature verification selalu gagal, tapi tanpa pesan error yang jelas. Debugging butuh berjam-jam.
Dengan const generics:
use core::convert::TryInto;
/// Hanya menerima hash tepat 32 byte. Diverifikasi compiler.
pub fn verify_firmware_signature(
signature: &[u8; 64],
hash: &[u8; 32], // ← const generic: HARUS 32 byte
public_key: &[u8; 32],
) -> bool {
let ret = unsafe {
ed25519_verify(
signature.as_ptr(),
hash.as_ptr(),
32, // konstanta, selalu benar
public_key.as_ptr(),
)
};
ret == 0
}
// Pemakaian:
let hash: [u8; 32] = sha256(&firmware_bytes[..]);
verify_firmware_signature(&sig, &hash, &pubkey); // ✅
// Kalau hash cuma 31 byte? Compile error. Nggak perlu unit test buat ini.
let wrong_hash: [u8; 31] = [0; 31];
// verify_firmware_signature(&sig, &wrong_hash, &pubkey); // ❌ Compile error!
Ini yang dimaksud dengan “bikin compiler yang bekerja untukmu”. Kamu menghapus satu kelas bug dari kemungkinan runtime. Bukan dengan linter. Bukan dengan review. Tapi dengan type system.
Pattern Lanjutan: Const Assertions untuk Kontrak Antar Tipe
Kadang kamu butuh hubungan matematis antar dua konstanta generik. Misalnya, buffer output harus minimal 2x ukuran input setelah encoding. Di Rust stable, where clause dengan const expression sudah tersedia sejak 1.80:
pub fn encode_hex<const IN: usize, const OUT: usize>(
input: &[u8; IN],
output: &mut [u8; OUT],
) where
[(); OUT - (IN * 2)]:, // ← Compile-time assertion: OUT >= IN*2
{
// ... implementasi encoding hex ...
}
Pattern [(); OUT - (IN * 2)]: memaksa compiler mengecek bahwa OUT - (IN * 2) menghasilkan bilangan non-negatif yang valid sebagai ukuran array. Kalau OUT terlalu kecil, kompilasi gagal dengan pesan error yang lumayan jelas. Ini teknik dari crate static_assertions, yang sekarang sebagian besar bisa diganti dengan const generic expressions native.
Tabel: Bug FFI vs Deteksi
| Jenis Bug | Tanpa Const Generics | Dengan Const Generics |
|---|---|---|
| Panjang buffer salah | Runtime UB / crash | Compile error |
| C nulis melebihi buffer | Silent corruption | Runtime assert (aman) |
| Kontrak size antar tipe | Manual review | Const where clause |
| Null pointer | Masih mungkin | Reference = non-null |
Yang Masih Perlu Kamu Waspadai
Const generics bukan silver bullet. Beberapa hal tetap jadi tanggung jawabmu:
- Ownership: C mungkin menyimpan pointer yang kamu passing. Pastiin buffer tetap hidup selama C butuh. Pattern
ManuallyDropatauPinkadang diperlukan. - Aliasing: Kalau kamu passing dua mutable reference ke buffer berbeda tapi C menganggapnya bisa overlap, LLVM optimizer bisa menghasilkan kode yang salah. Gunakan
UnsafeCellatau konsultasikan dengan Rustonomicon. - Transmute & Padding:
[u8; N]aman. Tapi kalau kamu punya[SomeStruct; N], pastikan layout-nya match dengan#[repr(C)]. Jangan asal transmute. - Stack overflow: Array besar (>1MB) di stack bisa meledak. Gunakan
Box<[u8; N]>ataustaticallocation untuk data besar.
Baca juga: Ngoding Array Fixed-Size di Rust 1.80? N+1 di Where Clause Akhirnya Bisa Tanpa Nightly — bahas lebih dalam soal const generic expressions di stable Rust.
FAQ: FFI Safety dengan Const Generics
Apa bedanya const generics dengan generic biasa di Rust?
Generic biasa diparameterisasi oleh tipe (T, U). Const generics diparameterisasi oleh nilai konstanta (const N: usize). Contoh: struct Foo<T, const N: usize> punya parameter tipe T dan parameter konstanta N. Di FFI, N inilah yang memungkinkan compiler tahu ukuran array tanpa perlu alokasi heap.
Apakah const generics bisa dipakai di Rust stable?
Ya. Const generics dasar (seperti const N: usize untuk ukuran array) udah stabil sejak Rust 1.51. Const generic expressions (seperti where [(); N + M]:) mulai stabil bertahap sejak Rust 1.79/1.80. Kamu nggak perlu nightly lagi untuk mayoritas use case FFI.
Gimana kalau fungsi C-nya butuh null-terminated string, bukan fixed-size array?
Gunakan core::ffi::CStr. Tapi kalau kamu tahu panjang maksimumnya (misal: username maksimum 32 karakter), kamu tetap bisa pakai [u8; 32] dan konversi manual. Const generics memastikan buffer selalu cukup untuk kontrak yang disepakati. Untuk string dinamis, CString tetap jadi pilihan utama.
Kesimpulan: Type System Itu Senjatamu, Bukan Bebanmu
Mayoritas developer melihat unsafe Rust sebagai lubang di armor. Padahal kenyataannya, unsafe adalah alat presisi: kamu yang pegang tanggung jawab ekstra, dan compiler membebaskanmu dari guardrails yang mungkin terlalu ketat. Const generics adalah cara kamu membangun guardrails custom sendiri, tanpa perlu runtime overhead.
Setiap kali kamu menulis fn process<const N: usize>(data: &[u8; N]), kamu menghilangkan satu kemungkinan buffer overflow dari codebase-mu. Bukan dengan komentar // TODO: pastiin length benar. Tapi dengan type. Dengan compiler. Dengan jaminan yang terverifikasi sebelum binary menyentuh chip target.
Mau belajar lebih banyak pola FFI Rust yang aman? Cek juga Crate generic-array Bikin Cargo.toml Gemuk? Ini Cara Migrasi ke const Generics Rust Native dan Matrix-mu Masih Alokasi Heap? Ini Cara Rust 1.80 Bikin Komputasi Matriks 100% Stack. Dua artikel ini ngelengkapin fondasi const generics buat workflow embedded-mu.
Referensi resmi: Rust Reference – Const Generics dan buku The Rustonomicon untuk aturan unsafe FFI secara mendalam.
