📚 背景知識(読んでから問題へ)
Day 053 では、C にデータを借用で渡すだけ(&CStr や &mut [i32] を渡し、呼び出しが終われば返ってくる)に絞りました。今日はその先、メモリの所有権そのものが Rust と C の境界を越えるケースを扱います。
Rust 同士なら「誰が解放するか」はコンパイラが決めます。値の持ち主が1人に決まっていて、スコープを抜けると Drop が1回だけ走ります。ところが C の関数が返した char * は、Rust から見ればただの整数(アドレス)です。コンパイラには次のどれも分かりません。
| 問い | 例: strdup の場合(POSIX) | コンパイラに分かるか |
|---|---|---|
| 誰が確保したか | strdup 内部の malloc | 分からない |
| 誰が解放するか | 呼び出し側が free で | 分からない |
| 何で解放するか | C の free(Rust の dealloc ではない) | 分からない |
| NULL がありうるか | 確保失敗時に NULL(errno = ENOMEM) | 分からない |
| いつまで有効か | free されるまで | 分からない |
この5つを型に書き込むのが今日の仕事です。道具は次の4つです。
| 道具 | 表すこと |
|---|---|
所有型 + impl Drop | 「解放責任を持つのはこの値1つだけ」「解放方法は free」 |
NonNull<T> | 「NULL ではない」。確保失敗は構築時に Result / Option で弾く |
戻り値の寿命を &self に縛る(fn f(&self) -> &CStr) | 「持ち主が生きている間だけ読める」 |
into_raw / from_raw + ManuallyDrop | 所有権を C に一時的に渡す/受け取る。渡している間は Drop を止める |
もう1つ大事な前提があります。**Rust の String / CString / Box は Rust のグローバルアロケータで確保され、C の malloc とは別の仕組み**です。macOS や Linux の既定では Rust のアロケータ(std::alloc::System)が内部で malloc を呼んでいるので、取り違えてもたまたま動いてしまうことがあります。今日のコードレビューでは、この「たまたま動く」を実測で崩します。
📝 問題
同僚が「C の strdup が返した文字列を Rust で安全に扱う型」を書き、レビューを依頼してきました。cargo new ffi_day054(edition 2024)の src/main.rs にそのまま貼ると、警告1件でビルドが通り、cargo run -- 1 〜 cargo run -- 5 のシナリオが実行できます。
// レビュー対象: C の strdup が確保した文字列を所有する型
use std::ffi::{CStr, CString, c_char, c_void};
unsafe extern "C" {
fn strdup(s: *const c_char) -> *mut c_char;
fn free(p: *mut c_void);
}
#[derive(Clone)]
pub struct CMallocStr {
ptr: *mut c_char,
}
impl CMallocStr {
pub fn new(s: &str) -> CMallocStr {
let p = CString::new(s).unwrap().as_ptr();
CMallocStr { ptr: unsafe { strdup(p) } }
}
pub fn as_c_str(&self) -> &'static CStr {
unsafe { CStr::from_ptr(self.ptr) }
}
pub fn into_string(self) -> String {
let s = unsafe { CString::from_raw(self.ptr) };
std::mem::forget(self);
s.into_string().unwrap()
}
}
impl Drop for CMallocStr {
fn drop(&mut self) {
unsafe { free(self.ptr.cast()) }
}
}
/// Rust で作った文字列を C 側へ渡す。受け取った C 側は使い終わったら free(p) する
pub fn hand_to_c(s: &str) -> *mut c_char {
CString::new(s).unwrap().into_raw()
}
fn main() {
match std::env::args().nth(1).as_deref() {
Some("1") => {
let a = CMallocStr::new("hello, ffi");
println!("{:?}", a.as_c_str());
}
Some("2") => {
let a = CMallocStr::new("hello");
let b = a.clone();
println!("{:?} {:?}", a.as_c_str(), b.as_c_str());
}
Some("3") => {
let r;
{
let a = CMallocStr::new("hello, dangling");
r = a.as_c_str();
}
let _x = CMallocStr::new("XXXXXXXXXXXXXXX");
println!("{:?}", r);
}
Some("4") => {
let a = CMallocStr::new("hello");
println!("{}", a.into_string());
}
Some("5") => {
let p = hand_to_c("hello");
unsafe { free(p.cast()) }; // C 側の free を模擬
println!("freed");
}
_ => eprintln!("usage: cargo run -- <1..5>"),
}
}
(1) 指摘: このコードには、メモリ安全性・所有権に関わる問題が少なくとも6件あります。それぞれについて次の3点を表にしてください。
- どの関数の何が問題か
- 何が起きるか(use-after-free / 二重解放 / アロケータの取り違え / NULL 参照 など)
- rustc と clippy が検出するか(するならどんなメッセージか)
(2) 修正: 次のシグネチャで、呼ぶ側が unsafe を書かない限り UB を起こせない型に直してください。外部クレートは使いません。
pub struct CMallocStr { /* ??? */ }
pub enum NewError { InteriorNul(NulError), OutOfMemory }
impl CMallocStr {
pub fn new(s: &str) -> Result<Self, NewError>;
pub fn as_c_str(&self) -> &CStr;
pub fn to_string(&self) -> Result<String, std::str::Utf8Error>;
pub fn into_raw(self) -> *mut c_char;
pub unsafe fn from_raw(p: *mut c_char) -> Option<Self>;
}
impl Clone for CMallocStr { /* ??? */ }
impl Drop for CMallocStr { /* ??? */ }
pub fn hand_to_c(s: &str) -> Result<*mut c_char, NulError>;
pub unsafe extern "C" fn rust_string_free(p: *mut c_char); // C 側に呼んでもらう解放関数
(3) テスト: 「確保したアロケータで解放されている」ことを cargo test で数えて確かめてください(ヒント: #[global_allocator] を自作すると、Rust のアロケータを通った確保・解放のバイト数を数えられます)。
(4) 記述: 同僚は「シナリオ 4 と 5 は macOS で問題なく動いた」と言っています。それでもバグである理由を2〜3文で説明してください。
💡 ヒント
ヒント1 — 方向性
6件は「確保・借用・複製・解放」のどこで起きるかで分けると見つけやすくなります。それぞれの関数について「このポインタは誰が確保し、今誰が持っていて、何で解放されるのか」を1行ずつ書き出してみてください。答えが「分からない」「2人いる」「確保した側と違う」になった所が問題です。
ヒント2 — アプローチ
CString::new(s).unwrap().as_ptr()のCStringは一時値です。一時値は文(;)の終わりで破棄されます#[derive(Clone)]はフィールドを1つずつコピーします。フィールドが生ポインタなら、コピーされるのはアドレスだけです&'static CStrは「プログラム終了まで有効」という約束です。selfの寿命と無関係な約束を返していないかCString::from_rawの公式ドキュメントは「CString::into_rawが返したポインタにだけ使え」と書いています。strdupの戻り値はそうではありませんManuallyDrop::new(self)で包むと、その値のDropは走りません(mem::forgetと同じ効果を、selfを使い終わる前に宣言できます)
ヒント3 — コード骨格
use std::ffi::{CStr, CString, NulError, c_char, c_void};
use std::mem::ManuallyDrop;
use std::ptr::NonNull;
pub struct CMallocStr { ptr: NonNull<c_char> }
impl CMallocStr {
pub fn new(s: &str) -> Result<Self, NewError> {
let cs = CString::new(s)?; // 一時値にしない
let p = unsafe { strdup(cs.as_ptr()) };
NonNull::new(p).map(|ptr| Self { ptr }).ok_or(NewError::OutOfMemory)
}
pub fn as_c_str(&self) -> &CStr { unsafe { CStr::from_ptr(self.ptr.as_ptr()) } }
pub fn to_string(&self) -> Result<String, std::str::Utf8Error> {
/* C のバッファから Rust の String へ「コピー」する */
}
pub fn into_raw(self) -> *mut c_char {
let me = ManuallyDrop::new(self);
me.ptr.as_ptr()
}
}
impl Clone for CMallocStr { fn clone(&self) -> Self { /* strdup し直す */ } }
impl Drop for CMallocStr { fn drop(&mut self) { unsafe { free(self.ptr.as_ptr().cast()) } } }
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_string_free(p: *mut c_char) {
if !p.is_null() { drop(unsafe { CString::from_raw(p) }); }
}
✅ 模範解答
(1) 指摘一覧
| # | 場所 | 問題 | 起きること | rustc / clippy |
|---|---|---|---|---|
| ① | new | CString::new(s).unwrap().as_ptr() の CString が文末で破棄され、p は解放済み領域を指す | use-after-free。strdup が解放済みメモリを読む。実測では全シナリオの中身が空文字列 "" になった | 検出する(dangling_pointers_from_temporaries 警告。rustc の既定で有効、clippy も同じ1件) |
| ② | new | strdup の戻り値の NULL を確かめていない。さらに unwrap() で入力中の NUL により panic | 確保失敗時に NULL を持つ値ができ、as_c_str で NULL 参照(UB)、Drop で free(NULL)(これは無害) | 検出しない |
| ③ | #[derive(Clone)] | 生ポインタのアドレスだけを複製する浅いコピー | 2つの値が同じ領域を持ち、両方の Drop で 二重解放 | 検出しない(Copy まで derive すれば E0184 で止まるが、Clone だけなら通る) |
| ④ | as_c_str | 戻り値が &'static CStr。self の寿命と無関係 | self の drop 後も参照が使え use-after-free。実測では後から確保した "XXXXXXXXXXXXXXX" が見えた | 検出しない(unsafe の中で寿命を自由に作れるため) |
| ⑤ | into_string | strdup(C の malloc)の領域を CString::from_raw(Rust のアロケータ)で解放 | アロケータの取り違え。既定の macOS/Linux では偶然動くが、#[global_allocator] を差し替えた瞬間に壊れる(下の実測) | 検出しない |
| ⑥ | hand_to_c | CString::into_raw(Rust のアロケータ)の領域を、C 側が free する契約 | ⑤ の逆向きの取り違え | 検出しない |
cargo build / cargo clippy とも警告 1 件)。残り 5 件は unsafe の中の「人間の約束」なので、コンパイラには見えません。参考として derive(Clone) の代わりに Copy を足すと出るエラー、Send を実装しなかったときのエラーは「コンパイルエラーが出た場合」に載せています。
実測: レビュー対象コードの挙動
出題者の環境(rustc 1.96.0 / edition 2024 / Apple M1 macOS、2026-09-24)で、終了コードは echo $? で確認しました。
| シナリオ | そのままのコード | ①だけ直したコード | 原因 |
|---|---|---|---|
| 1: 作って表示 | ""(本来は "hello, ffi")/終了 0 | "hello, ffi"/終了 0 | ① |
| 2: clone して両方 drop | "" "" の後に終了コード 133(SIGTRAP。macOS の malloc が二重解放を検出して停止) | "hello" "hello"/終了 133 | ③ |
| 3: 持ち主が消えた後に読む | ""/終了 0 | "XXXXXXXXXXXXXXX"/終了 0(別の文字列が見える) | ④ |
4: into_string | 空行/終了 0 | hello/終了 0 | ⑤(見かけ上は動く) |
5: hand_to_c → C の free | freed/終了 0 | freed/終了 0 | ⑥(見かけ上は動く) |
release ビルドでも 1〜5 の表示は同じでした。クラッシュしたのは 2 だけで、3〜5 は黙って誤動作するか、誤りが見えません。 同僚のメモ「落ちたのは 2 だけ」は正確な観察ですが、「だから他は大丈夫」とは言えない、というのが今日のレビューの核心です。
(2)(3) 修正版コード
// src/main.rs — Day 054: FFI ② 所有権が境界を越えるとき(修正版)
// cargo run / cargo test でそのまま実行できます(edition = "2024"、追加クレート不要)。
// strdup / free は OS の C 標準ライブラリ(macOS: libSystem / Linux: glibc・musl)の関数で、
// Rust の std が既にリンクしているため build.rs も -l 指定も要りません。
use std::ffi::{CStr, CString, NulError, c_char, c_void};
use std::fmt;
use std::mem::ManuallyDrop;
use std::ptr::NonNull;
unsafe extern "C" {
/// # Safety
/// `s` は NUL 終端された有効な C 文字列。戻り値は malloc で確保され、
/// 呼び出し側が `free` で解放する責任を負う。確保に失敗すると NULL(errno = ENOMEM)。
fn strdup(s: *const c_char) -> *mut c_char;
/// # Safety
/// `p` は NULL か、malloc 系(malloc / calloc / realloc / strdup)が返してまだ free していないポインタ。
fn free(p: *mut c_void);
}
// ---------------------------------------------------------------------------
// C の malloc で確保された文字列を「所有」する型
// ---------------------------------------------------------------------------
/// C のヒープ(malloc)に置かれた NUL 終端文字列の所有者。
///
/// 不変条件(この型の safety invariant):
/// - `ptr` は malloc 系が返した、まだ free されていない NUL 終端文字列を指す
/// - その領域を free する責任を持つのは、この値ただ1つ
pub struct CMallocStr {
ptr: NonNull<c_char>, // ①' NULL を型で排除する
}
#[derive(Debug)]
pub enum NewError {
/// 入力の途中に NUL があり、C 文字列にできない
InteriorNul(NulError),
/// strdup が NULL を返した(C 側のメモリ確保失敗)
OutOfMemory,
}
impl From<NulError> for NewError {
fn from(e: NulError) -> Self {
NewError::InteriorNul(e)
}
}
impl CMallocStr {
pub fn new(s: &str) -> Result<Self, NewError> {
let cs = CString::new(s)?; // ① 一時値にしない。cs はこの関数の終わりまで生きる
// SAFETY: cs.as_ptr() は NUL 終端された有効な文字列で、strdup の実行中 cs は生きている。
let p = unsafe { strdup(cs.as_ptr()) };
NonNull::new(p)
.map(|ptr| CMallocStr { ptr })
.ok_or(NewError::OutOfMemory) // ①' NULL を Err にする
}
/// 借用している間だけ有効な `&CStr` を返す(③ 'static にしない)
pub fn as_c_str(&self) -> &CStr {
// SAFETY: 不変条件により ptr は有効な NUL 終端文字列。
// 戻り値の寿命は &self に縛られるので、self が drop された後には使えない。
unsafe { CStr::from_ptr(self.ptr.as_ptr()) }
}
/// 中身を Rust の String に「コピー」し、C のバッファは Drop で free する(④)
pub fn to_string(&self) -> Result<String, std::str::Utf8Error> {
self.as_c_str().to_str().map(str::to_owned)
}
/// 所有権を C 側へ手放す。戻り値は C 側(または from_raw)が free する責任を負う
pub fn into_raw(self) -> *mut c_char {
let me = ManuallyDrop::new(self); // Drop を走らせない
me.ptr.as_ptr()
}
/// C 側から所有権を受け取る
///
/// # Safety
/// `p` は malloc 系が返した、まだ free されていない NUL 終端文字列で、
/// 呼び出し後は他の誰もそれを free しないこと。
pub unsafe fn from_raw(p: *mut c_char) -> Option<Self> {
NonNull::new(p).map(|ptr| CMallocStr { ptr })
}
}
/// ② ポインタのコピーではなく、C のヒープ上に「中身」を複製する
impl Clone for CMallocStr {
fn clone(&self) -> Self {
// SAFETY: as_ptr() は有効な NUL 終端文字列(不変条件)。
let p = unsafe { strdup(self.ptr.as_ptr()) };
// clone は Result を返せないので、確保失敗は Vec / String と同じく abort 相当で扱う
CMallocStr {
ptr: NonNull::new(p).expect("strdup: out of memory"),
}
}
}
impl Drop for CMallocStr {
fn drop(&mut self) {
// SAFETY: 不変条件により ptr は malloc 系が返した未解放のポインタで、
// 解放責任を持つのはこの値だけ(Clone は別領域を確保し、into_raw は Drop を止める)。
unsafe { free(self.ptr.as_ptr().cast()) }
}
}
impl fmt::Debug for CMallocStr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.as_c_str().fmt(f)
}
}
// SAFETY: CMallocStr は領域を排他的に所有し、malloc の領域はどのスレッドから free してもよい
// (C11 7.22.3: malloc / free はスレッド安全)。スレッド固有の状態を持たないので Send にできる。
unsafe impl Send for CMallocStr {}
// SAFETY: &CMallocStr から得られるのは読み取り専用の &CStr だけで、内部可変性が無いので Sync にできる。
unsafe impl Sync for CMallocStr {}
// ---------------------------------------------------------------------------
// ⑤ Rust が確保した文字列を C へ渡す: 解放関数を Rust 側から提供する
// ---------------------------------------------------------------------------
/// Rust のヒープに確保した C 文字列を C へ渡す。
/// C 側は使い終わったら **free ではなく `rust_string_free`** を呼ぶ契約にする。
pub fn hand_to_c(s: &str) -> Result<*mut c_char, NulError> {
Ok(CString::new(s)?.into_raw())
}
/// `hand_to_c` が返したポインタを解放する。C からも呼べるよう C ABI・シンボル名固定で公開する。
///
/// # Safety
/// `p` は NULL か、`hand_to_c`(= CString::into_raw)が返してまだ解放していないポインタ。
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_string_free(p: *mut c_char) {
if !p.is_null() {
// SAFETY: 関数の契約により p は CString::into_raw 由来で未解放。
drop(unsafe { CString::from_raw(p) });
}
}
fn main() {
let a = CMallocStr::new("hello, ffi").expect("new");
let b = a.clone();
println!(
"a = {a:?}, b = {b:?}, same buffer? {}",
a.as_c_str().as_ptr() == b.as_c_str().as_ptr()
);
let moved = std::thread::spawn(move || b.to_string().unwrap())
.join()
.unwrap();
println!("to_string on another thread = {moved:?}");
let raw = a.into_raw();
// SAFETY: raw は直前の into_raw が返した strdup 由来のポインタで、他に解放者はいない。
let back = unsafe { CMallocStr::from_raw(raw) }.expect("non-null");
println!("round-trip via into_raw/from_raw = {back:?}");
println!("new(\"a\\0b\") = {:?}", CMallocStr::new("a\0b").map(|_| ()));
let p = hand_to_c("to C").unwrap();
// SAFETY: p は hand_to_c が返したもので、ここで一度だけ解放する。
unsafe { rust_string_free(p) };
println!("hand_to_c + rust_string_free: ok");
}
// ---------------------------------------------------------------------------
// テスト: 「どちらのアロケータで確保・解放されたか」を数えて確かめる
// ---------------------------------------------------------------------------
#[cfg(test)]
mod counting {
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
thread_local! {
/// このスレッドで Rust のグローバルアロケータを通った「確保 − 解放」バイト数
pub static LIVE: Cell<isize> = const { Cell::new(0) };
}
pub struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
let _ = LIVE.try_with(|c| c.set(c.get() + l.size() as isize));
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
let _ = LIVE.try_with(|c| c.set(c.get() - l.size() as isize));
unsafe { System.dealloc(p, l) }
}
}
#[global_allocator]
static A: Counting = Counting;
pub fn live() -> isize {
LIVE.with(Cell::get)
}
}
#[cfg(test)]
mod tests {
use super::counting::live;
use super::*;
#[test]
fn new_and_drop_leave_rust_allocator_balanced() {
let before = live();
{
let s = CMallocStr::new("hello").unwrap();
assert_eq!(s.as_c_str(), c"hello");
// CString の一時確保(6 バイト)は new の中で解放済み。
// strdup の 6 バイトは C の malloc 側なので Rust のカウンタには現れない
assert_eq!(live() - before, 0);
}
assert_eq!(live() - before, 0);
}
#[test]
fn interior_nul_is_an_error_not_a_panic() {
assert!(matches!(
CMallocStr::new("a\0b"),
Err(NewError::InteriorNul(_))
));
}
#[test]
fn clone_owns_a_separate_buffer() {
let a = CMallocStr::new("hello").unwrap();
let b = a.clone();
assert_ne!(a.as_c_str().as_ptr(), b.as_c_str().as_ptr());
assert_eq!(a.as_c_str(), b.as_c_str());
drop(a);
assert_eq!(b.as_c_str(), c"hello"); // a の解放後も b は有効
}
#[test]
fn to_string_copies_and_counts_only_the_string() {
let s = CMallocStr::new("hello").unwrap();
let before = live();
let owned = s.to_string().unwrap();
assert_eq!(owned, "hello");
assert_eq!(live() - before, 5); // Rust 側に増えたのは String の 5 バイトだけ
drop(s); // C のバッファは free で返る(Rust のカウンタは動かない)
assert_eq!(live() - before, 5);
}
#[test]
fn to_string_rejects_invalid_utf8() {
// C から来る文字列は UTF-8 とは限らない(Latin-1 の 'é' = 0xE9)
let raw = unsafe { strdup(c"caf\xe9".as_ptr()) };
let s = unsafe { CMallocStr::from_raw(raw) }.unwrap();
assert!(s.to_string().is_err());
assert_eq!(s.as_c_str().to_bytes(), b"caf\xe9");
}
#[test]
fn into_raw_then_from_raw_frees_exactly_once() {
let a = CMallocStr::new("owned by C for a while").unwrap();
let p = a.into_raw(); // ここで free されていれば次の行が use-after-free
let b = unsafe { CMallocStr::from_raw(p) }.unwrap();
assert_eq!(b.as_c_str(), c"owned by C for a while");
assert!(unsafe { CMallocStr::from_raw(std::ptr::null_mut()) }.is_none());
}
#[test]
fn hand_to_c_is_freed_by_rust_side_function() {
let before = live();
let p = hand_to_c("hello").unwrap();
assert_eq!(live() - before, 6); // "hello\0" が Rust のアロケータから出ている
unsafe { rust_string_free(p) };
assert_eq!(live() - before, 0); // 同じアロケータに返った
unsafe { rust_string_free(std::ptr::null_mut()) }; // NULL は何もしない
}
#[test]
fn is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CMallocStr>();
let s = CMallocStr::new("across threads").unwrap();
let got = std::thread::spawn(move || s.to_string().unwrap())
.join()
.unwrap();
assert_eq!(got, "across threads");
}
}
実測(出題者の環境: rustc 1.96.0 / edition 2024 / Apple M1 macOS、2026-09-24)
$ cargo run
a = "hello, ffi", b = "hello, ffi", same buffer? false
to_string on another thread = "hello, ffi"
round-trip via into_raw/from_raw = "hello, ffi"
new("a\0b") = Err(InteriorNul(NulError(1, [97, 0, 98])))
hand_to_c + rust_string_free: ok
$ cargo test
running 8 tests
........
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
$ cargo clippy --all-targets # 警告 0 件
(4) ⑤⑥ が「macOS で動いた」のにバグである理由を実測で示す
次の examples/mismatch.rs は、⑤⑥ と同じ操作を「数えるアロケータ」と「先頭にヘッダを置くアロケータ」の2通りで走らせます。後者は、jemalloc・mimalloc など実在のアロケータが独自のメタデータを持つことの代役です(実物の jemalloc / mimalloc では試していません。外部クレートなしで再現するための模型です)。
// examples/mismatch.rs — ④⑤「確保した側と違うアロケータで解放する」を観測する
// cargo run --example mismatch -- count : Rust アロケータの収支を数える
// cargo run --example mismatch -- header : 先頭にヘッダを置くアロケータ(jemalloc/mimalloc 等の代役)で ④ を走らせる
// cargo run --example mismatch -- header5 : 同じアロケータで ⑤ だけを走らせる
use std::alloc::{GlobalAlloc, Layout, System};
use std::ffi::{CString, c_char, c_void};
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering::Relaxed};
unsafe extern "C" {
fn strdup(s: *const c_char) -> *mut c_char;
fn free(p: *mut c_void);
}
static LIVE: AtomicIsize = AtomicIsize::new(0);
static HEADER_MODE: AtomicBool = AtomicBool::new(false);
const MAGIC: u64 = 0x5255_5354_414C_4C43; // "RUSTALLC"
const H: usize = 16;
struct Demo;
unsafe impl GlobalAlloc for Demo {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
LIVE.fetch_add(l.size() as isize, Relaxed);
if !HEADER_MODE.load(Relaxed) {
return unsafe { System.alloc(l) };
}
// 先頭 16 バイトに印を書き、その後ろを利用者に返す(実在のアロケータも独自のメタデータを持つ)
let big = Layout::from_size_align(l.size() + H, l.align().max(H)).unwrap();
unsafe {
let p = System.alloc(big);
(p as *mut u64).write(MAGIC);
p.add(H)
}
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
LIVE.fetch_sub(l.size() as isize, Relaxed);
if !HEADER_MODE.load(Relaxed) {
return unsafe { System.dealloc(p, l) };
}
unsafe {
let base = p.sub(H);
if (base as *const u64).read() != MAGIC {
let msg = b"dealloc: this pointer was not allocated by the Rust global allocator\n";
libc_write(msg);
std::process::abort();
}
let big = Layout::from_size_align(l.size() + H, l.align().max(H)).unwrap();
System.dealloc(base, big)
}
}
}
fn libc_write(b: &[u8]) {
unsafe extern "C" {
fn write(fd: i32, buf: *const c_void, n: usize) -> isize;
}
unsafe { write(2, b.as_ptr().cast(), b.len()) };
}
#[global_allocator]
static A: Demo = Demo;
fn main() {
let mode = std::env::args().nth(1).unwrap_or_default();
if mode == "header" {
HEADER_MODE.store(true, Relaxed);
}
if mode != "header5" {
// ④ strdup(C の malloc)で確保 → CString::from_raw(Rust のアロケータ)で解放
let before = LIVE.load(Relaxed);
let p = unsafe { strdup(c"hello".as_ptr()) };
let s = unsafe { CString::from_raw(p) };
drop(s);
println!(
"④ strdup → CString::from_raw → drop : Rust allocator live delta = {}",
LIVE.load(Relaxed) - before
);
}
if mode == "header5" {
HEADER_MODE.store(true, Relaxed);
}
// ⑤ CString::into_raw(Rust のアロケータ)で確保 → C の free で解放
let before = LIVE.load(Relaxed);
let p = CString::new("hello").unwrap().into_raw();
unsafe { free(p.cast()) };
println!(
"⑤ CString::into_raw → free : Rust allocator live delta = {}",
LIVE.load(Relaxed) - before
);
}
(出力の「④」「⑤」はこの例のコード内のラベルで、指摘一覧の ⑤・⑥ に当たります)
(4) の回答: Rust のアロケータは「Rust が確保した領域」だけを解放する契約で、CString::from_raw を strdup の戻り値に使うと、Rust のアロケータは確保していない 6 バイトを解放します(収支 −6)。逆に into_raw の領域を C の free で返すと、Rust から見ると 6 バイトが返ってこないままです(収支 +6)。macOS で動いたのは、既定の System アロケータが内部で malloc / free を呼んでいる実装上の偶然にすぎず、#[global_allocator] を差し替える(プロダクションでは jemalloc / mimalloc がよく使われる)、Windows で DLL ごとに C ランタイムのヒープが分かれる、といった前提の変化で即座に abort か静かなヒープ破壊になります。
🪜 Step-by-Step 解説
let p = CString::new(s).unwrap().as_ptr(); // ← この ; で CString が drop
CMallocStr { ptr: unsafe { strdup(p) } } // p は解放済み領域を指している
Rust の一時値は、それを含む文の終わりで破棄されます(Rust Reference の "Temporary scopes")。as_ptr() が返すのは生ポインタで、借用ではないので、借用チェッカーは CString の寿命と p を結びつけません。rustc はこの形だけは典型パターンとして認識し、dangling_pointers_from_temporaries 警告を出します。
なお、strdup(CString::new(s).unwrap().as_ptr()) と1つの文の中で使えば、一時値は文の終わりまで生きるので実は安全です。ただしこの書き方でも同じ警告が出るので、「警告が出るが安全」と「警告が出て危険」を人間が見分ける必要があります。変数に束縛する(let cs = CString::new(s)?;)のが、読み手にも lint にも誤解の余地を残さない書き方です。
NonNull と Result
strdup は確保に失敗すると NULL を返します(POSIX)。ptr: *mut c_char のままだと、その NULL を持った CMallocStr が普通に作れてしまい、後の as_c_str がすべて NULL 参照の危険を抱えます。NonNull<c_char> にすると「このフィールドが NULL でないこと」が型の不変条件になり、NULL の判定は構築時の1か所に集まります。
CString::new(s).unwrap() も直します。入力の途中に \0 があると CString::new は Err(NulError) を返します。ライブラリの new が利用者の入力で panic するのは API として不親切なので、NewError::InteriorNul として返します。? で書けるよう From<NulError> を実装しました。
NonNull にはもう1つ効果があります。Option<CMallocStr> のサイズが CMallocStr と同じ 8 バイトになります(NULL を None の表現に使える「ニッチ最適化」。実測: size_of がどちらも 8)。from_raw が Option<Self> を返してもコストは増えません。
Clone は「中身の複製」を書く
#[derive(Clone)] は各フィールドの Clone を呼ぶだけで、*mut c_char の Clone はアドレスのコピーです。所有型では解放責任まで複製されてしまい、二重解放になります。手書きの Clone で strdup をもう一度呼び、別の領域を確保します。テスト clone_owns_a_separate_buffer は、2つのポインタが違うこと・片方を drop しても他方が読めることを確かめています。
Clone を実装しないという選択肢もあります(下の「設計思想」の表)。
&self に縛る
pub fn as_c_str(&self) -> &CStr // 省略規則で &'a self -> &'a CStr
CStr::from_ptr は unsafe fn で、戻り値の寿命は呼び出し側が自由に選べます。だから &'static CStr と書いてもコンパイルが通ります。寿命を省略して &self に結びつけると、持ち主より長く参照を使うコードが E0597 で止まるようになります(「コンパイルエラーが出た場合」に実際のエラー)。**unsafe の中で作った参照の寿命は、人間が正しく選ばないと誰も直してくれない**、というのが Day 052 の「safety invariant」の具体例です。
into_string は「C の malloc の領域を、そのまま Rust の String として使い続けたい」という発想ですが、String は Rust のアロケータで解放されるので、取り違えは避けられません。正しくは:
as_c_str().to_str()で UTF-8 を検査し(C の文字列は UTF-8 とは限らない。テストto_string_rejects_invalid_utf8は Latin-1 の0xE9で確認)to_owned()で Rust のアロケータにコピーし- C の領域は
Dropのfreeで返す
テスト to_string_copies_and_counts_only_the_string は、Rust 側のカウンタが String の 5 バイトだけ増えることを確かめています。コピーのコストは文字列長に比例しますが、境界を越えるときの唯一の安全な受け渡しです。
Rust で確保した領域を C に渡すときは、解放関数も Rust が提供します。#[unsafe(no_mangle)] pub unsafe extern "C" fn rust_string_free は、C からは void rust_string_free(char *p); として呼べます(Rust 2024 では no_mangle は unsafe(...) で包む必要があります。シンボル名の衝突が UB になりうるため)。テスト hand_to_c_is_freed_by_rust_side_function は、確保で +6、rust_string_free で 0 に戻ることを確かめています。
into_raw は ManuallyDrop で Drop を止める
所有権を C に渡すときは「このあと Rust 側では free しない」ことを保証しなければなりません。ManuallyDrop::new(self) で包むと Drop が走らなくなり、中のポインタを取り出して返せます。
元のコードの into_string は CString::from_raw(self.ptr) の直後に mem::forget(self) を呼んでいるので、この順序だけ見れば二重解放にはなりません(問題は ⑤ のアロケータ取り違えのほう)。ただし forget を「最後に呼ぶ」書き方は、将来だれかが from_raw と forget の間に panic しうる処理(unwrap や添字アクセス)を挟んだ瞬間に、巻き戻し中の Drop と合わせて二重解放になります。**先に ManuallyDrop で包む**書き方は、この順序の問題を構造で消します(標準ライブラリの Box::into_raw や CString::into_raw も内部でこの形です)。
Send / Sync を自分で判断して付ける
生ポインタ(*mut T・NonNull<T>)を持つ型は、自動では Send / Sync になりません。コンパイラは「このポインタがスレッドをまたいで安全か」を知らないからです。今日の型は領域を排他的に所有し、malloc / free はスレッド安全(C11 7.22.3)なので Send、&self からは読み取りしかできないので Sync を、// SAFETY: の理由つきで付けました。Rc を内部に持つ C のオブジェクトや、「作ったスレッドでしか解放できない」API(一部の GUI ツールキットなど)を包むときは、同じ判断で付けないのが正解になります。
⚖️ 設計思想・なぜこう書くのか
Rust の所有権は、C の世界では「ドキュメントに書かれた約束」でしかありません。 strdup の man ページの「呼び出し側が free すること」、sqlite3_mprintf の「sqlite3_free で解放すること」、どれも C コンパイラは検査しません。Rust の FFI ラッパーの仕事は、その文章で書かれた所有権を型に翻訳することです。翻訳の結果が NonNull + Drop + 寿命つきの借用で、翻訳を誤ると(今日の6件)コンパイラは黙って通します。
今日の判断で迷った点を3つ残します。
判断1: Rust が確保した文字列を C に渡す方法(⑥ の直し方)
| 選択肢 | 利点 | 欠点 | 今日の判断 |
|---|---|---|---|
A. Rust が解放関数 rust_string_free を公開する | 確保と解放が同じアロケータ。#[global_allocator] を変えても壊れない | C 側に「free ではなくこれを呼ぶ」という規則が1つ増える | 採用。SQLite(sqlite3_free)、OpenSSL(OPENSSL_free)、libcurl(curl_free)など主要な C ライブラリ自身がこの形 |
B. Rust 側で strdup / malloc を呼んで C のヒープに確保し、C に free させる | C 側はいつもの free で済む | 確保が C の関数経由になり、Rust の型の恩恵(String の容量管理など)を使えない | C 側の API が「free で解放する」と既に決まっているとき(既存 C コードのコールバック規約など) |
C. C 側がバッファを用意し、Rust は書き込むだけ(snprintf 型) | 所有権が境界を越えない。いちばん事故が少ない | 必要な長さを先に問い合わせる2段階呼び出しが要る | 頻繁に呼ぶ API や、C 側でメモリを管理したいとき(組み込みなど) |
前提が変わると判断も変わります。Windows の DLL では、DLL ごとに C ランタイムのヒープが別になりうるため、B でも「DLL 側の malloc と EXE 側の free」の取り違えが起きます(Microsoft のドキュメントが CRT オブジェクトを DLL 境界で渡す危険として明記)。どの OS でも通用するのは A と C です。
**判断2: Clone をどうするか(③ の直し方)**
| 選択肢 | 利点 | 欠点 | 今日の判断 |
|---|---|---|---|
A. 手書きの Clone で strdup し直す | String と同じ感覚で使える | 複製のたびに C のヒープ確保。clone は Result を返せないので確保失敗は panic で扱うしかない | 採用(Vec / String も確保失敗は abort 扱いで、標準ライブラリと揃う) |
B. Clone を実装しない | 最も単純。複製したければ to_string で Rust 側にコピーさせる | 使う側が少し不便 | 大きなバッファや、複製の意味が曖昧なハンドル(ファイル・ソケット)ならこちら |
C. Rc / Arc で共有し、最後の1つが free | 複製が安い | 参照カウントの分だけ重く、Send の判断も変わる | 同じ C オブジェクトを多数で読み回すとき |
**判断3: into_string を「コピー」にするか「ゼロコピー」を狙うか(⑤ の直し方)**
ゼロコピーで String にする方法は、Rust のアロケータが malloc と同一である保証が無い限り存在しません。コピーのコストは長さに比例しますが、「C の領域は C の free で、Rust の領域は Rust の dealloc で」という規則を例外なく守れるのはコピーだけです。コピーが本当にボトルネックになる場合は、String にせず &CStr / &[u8] のまま処理する(as_c_str().to_bytes())のが次の手です。
🚨 コンパイルエラーが出た場合
以下はすべて、出題者の環境(rustc 1.96.0、edition 2024、aarch64 macOS)で実際に出た出力です。
警告1: 一時値の CString からポインタを取った(①)
読み方: 下線が2本あり、------ の範囲が「文末で破棄される一時値」、^^^^^^ が「そこからポインタを作った場所」です。note の「dangling pointer を作ること自体は安全、デリファレンスが UB」は Day 051 の「生成は安全・デリファレンスだけが危険」と同じ話です。これは error ではなく warning なので、ビルドは通ります。 CI では RUSTFLAGS="-D warnings" などで警告をエラー扱いにしておかないと、今日の同僚のように見落とします。
エラー1: ④ を直した後、持ち主より長く参照を使った
修正版の main の先頭にシナリオ 3 と同じコードを足してビルドした結果です。
これは直した結果として出てほしいエラーです。シナリオ 3 のコードが、実行時の use-after-free からコンパイル時のエラーに変わりました。読み方はこれまでの借用エラーと同じで、「宣言」「借用」「drop」「後で使用」の4点が順に示されています。
エラー2: derive(Clone) の代わりに Copy も付けようとした
Drop を持つ型は Copy にできません。Rust は「暗黙のビットコピー」と「解放処理」の両立を言語レベルで禁止しています。ところが **Clone は明示的な呼び出しなので禁止されません**。③ がコンパイルを通るのはこのためで、derive(Clone) が浅いコピーでよいかは人間が判断します。
エラー3: unsafe impl Send を書かずにスレッドへ渡した
note をたどると「クロージャが CMallocStr を持ち、その中の NonNull<i8> が Send でない」と原因の連鎖が分かります。ここで**何も考えずに unsafe impl Send を足すのは危険**です。Step 8 のとおり、包んでいる C のオブジェクトが本当に別スレッドで使って・解放してよいかを確かめてから付けます(i8 は aarch64 macOS の c_char。aarch64 Linux なら u8 と表示されます)。
実行時: 二重解放(③)
macOS の malloc は二重解放を検出すると SIGTRAP で止めます(終了コード 133 = 128 + 5)。メッセージは何も出ませんでした。Linux の glibc では free(): double free detected in tcache 2 のようなメッセージとともに abort するのが一般的です(Linux では未実測)。「落ちた」という事実だけで原因の Drop を特定するのは難しく、今日のように所有権の表を書いて読むレビューのほうが早く見つかります。
🌐 他言語との比較
| 観点 | C++ | Go(cgo) | Java(FFM API) | Python(ctypes) | Rust |
|---|---|---|---|---|---|
C の malloc 領域を所有する型 | std::unique_ptr<char, decltype(&free)> で free を削除子に指定 | 型は無い。defer C.free(unsafe.Pointer(p)) を手で書く | MemorySegment を Arena に紐づけ、Arena を閉じると解放 | 型は無い。libc.free を手で呼ぶ | 所有型 + Drop(今日の CMallocStr) |
| 言語の文字列を C 文字列にする | std::string::c_str()(string が生きている間だけ有効) | C.CString(s) は C の malloc で確保し、呼び出し側が C.free する | arena.allocateFrom(s)(Arena の寿命に従う) | c_char_p(b"...") / create_string_buffer | CString::new(s)?(Rust のアロケータ。into_raw したら Rust 側で解放) |
| 寿命の誤り(④)の検出 | 検出しない(c_str() のダングリングは古典的なバグ) | 検出しない | Arena を閉じた後のアクセスは実行時に例外 | 検出しない | 寿命を正しく書けばコンパイル時に E0597 |
| 二重解放(③)の防止 | unique_ptr はコピー禁止(ムーブのみ)で防ぐ | 防がない | Arena 単位で1回だけ解放 | 防がない | Drop 型は Copy 不可。ただし Clone の中身は人間の責任 |
Go 経験者向けの要点: cgo の C.CString は C のヒープに確保するので C.free で正しく解放できます。Rust の CString は Rust のヒープに確保するので、free してはいけません。名前が似ていて逆なので、Go から来た人がちょうど ⑥ の誤りを書きやすい点に注意してください。
Python 経験者向けの要点: ctypes で C 関数の restype = c_char_p にすると、戻り値が自動で bytes に変換され元のポインタが失われるため、strdup のような「呼び出し側が free する」関数ではメモリリークになります。restype = c_void_p で受けて自分で解放するのが定石で、今日の「コピーしてから free」と同じ構造です。
🏭 実務での使いどころ
- C ライブラリの文字列を返す API のラッパー:
opensslクレートのOpensslStringはOPENSSL_freeをDropで呼ぶ所有型、git2クレートのBufは libgit2 のgit_bufをgit_buf_disposeで解放する所有型です。構造は今日のCMallocStrと同じで、「解放関数を型に閉じ込める」のが実務の標準形です - Rust で書いたライブラリを C から使わせる(cdylib): Rust 製ライブラリを C / Python / Ruby から呼ばせるとき、文字列やバッファを返す関数には必ず対になる
xxx_free関数を公開します。今日のhand_to_c+rust_string_freeがその最小形です - グローバルアロケータの差し替え: 性能のために jemalloc(
tikv-jemallocator)や mimalloc を#[global_allocator]に設定するのはサーバでよくある最適化です。そのとき ⑤ ⑥ 型の取り違えが潜んでいると、アロケータを変えた PR で初めてクラッシュします。原因の PR と原因のコードが別になるため、調査が難しい種類の障害です - コードレビューの観点表: FFI のラッパーをレビューするときは、今日の「誰が確保/誰が持つ/何で解放/NULL/寿命」の5列の表を関数ごとに埋めるのが実用的です。rustc と clippy は今日の6件中1件しか見つけなかったので、この表が機械の代わりになります。動的な検査を足すなら、Miri(Rust の UB 検出インタプリタ)は既定では FFI 呼び出しの先(
strdup/free)を実行できないため今日のコードには向かず、AddressSanitizer(nightly の-Zsanitizer=address)が候補です(今日は未実測)
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
C が返したポインタを CString::from_raw で受け取る | 「C 文字列を所有する型 = CString」という連想 | from_raw は into_raw の戻り値専用。C が確保した文字列は free を呼ぶ自前の所有型で包むか、CStr として借りてコピーする |
CString::into_raw の戻り値を C の free で解放させる | Go の C.CString + C.free の感覚、macOS/Linux で動いてしまう | 確保したアロケータで解放する。Rust 側から解放関数を公開する |
| 「手元で落ちないから大丈夫」 | UB は「必ず落ちる」ではなく「何が起きてもよい」 | 今日の実測では6件中クラッシュは1件。残りは空文字列・別の文字列・何も起きない、という形で現れた |
生ポインタを持つ型に #[derive(Clone)] を付ける | いつもの習慣 | 所有権を持つポインタなら浅いコピーは二重解放。Clone を手書きするか、実装しない |
unsafe の中で作った参照に 'static を付ける | コンパイルが通るので正しいと思う | CStr::from_ptr などの戻り値の寿命は呼び出し側が選べる。持ち主(&self)に結びつける |
| 警告は後で直せばよい | ビルドが通る | dangling_pointers_from_temporaries は今日の6件で唯一機械が見つけたもの。CI で -D warnings にする |
mem::forget(self) を最後に呼べば Drop を止められる | 順序を意識しない | forget より前で panic すると届かず、巻き戻しで Drop が走る。先に ManuallyDrop::new(self) で包む |
📖 一次資料(参照日: 2026-09-24。記述は各資料の要旨で、原文の引用ではありません)
std::ffi::CString::from_raw(into_rawが返したポインタ以外に使ってはならない、C が確保したポインタの解放に使ってはならない旨の注意): https://doc.rust-lang.org/std/ffi/struct.CString.html#method.from_rawstd::ffi::CString::as_ptr(一時値のCStringから取ったポインタがダングリングになる例と警告): https://doc.rust-lang.org/std/ffi/struct.CString.html#method.as_ptr- rustc lint
dangling_pointers_from_temporaries: https://doc.rust-lang.org/rustc/lints/listing/warn-by-default.html#dangling-pointers-from-temporaries - The Rust Reference — Temporary scopes(一時値は文の終わりで破棄): https://doc.rust-lang.org/reference/destructors.html#temporary-scopes
- The Rust Edition Guide — Unsafe attributes(Rust 2024 で
#[unsafe(no_mangle)]が必須): https://doc.rust-lang.org/edition-guide/rust-2024/unsafe-attributes.html std::ptr::NonNull/std::mem::ManuallyDrop/std::alloc::GlobalAlloc: https://doc.rust-lang.org/std/ptr/struct.NonNull.html ・ https://doc.rust-lang.org/std/mem/struct.ManuallyDrop.html ・ https://doc.rust-lang.org/std/alloc/trait.GlobalAlloc.html- POSIX
strdup(戻り値はfreeに渡せる、失敗時は NULL と ENOMEM): https://pubs.opengroup.org/onlinepubs/9799919799/functions/strdup.html - SQLite
sqlite3_free(SQLite が確保したメモリは SQLite の解放関数で返す): https://www.sqlite.org/c3ref/free.html - Microsoft Learn — Potential errors passing CRT objects across DLL boundaries(DLL ごとに CRT ヒープが分かれる問題): https://learn.microsoft.com/en-us/cpp/c-runtime-library/potential-errors-passing-crt-objects-across-dll-boundaries
- cgo のドキュメント(
C.CStringは C のヒープに確保し、呼び出し側がC.freeする): https://pkg.go.dev/cmd/cgo
🧭 今日扱わなかった範囲(次回以降)
FFI テーマは今日で2問目です。今日は「文字列1本の所有権」に絞りました。
- C から Rust を呼ぶ方向の本格版:
cdylibとしてビルドし、cbindgenでrust_string_freeを含むヘッダを生成して、実際の C プログラムからリンクする(今日は Rust 内で呼ぶだけで、C コンパイラを使った検証はしていません) - ユーザーデータ付きコールバック:
void *userdata経由でクロージャを渡す(Box::into_rawで所有権を C に預け、コールバック終了時にBox::from_rawで回収する)。今日のinto_raw/from_rawの応用 - 構造体・配列の所有権: C が確保した構造体の配列(
struct foo **list, size_t n)を包む型、Vec::from_raw_partsを C の領域に使ってはいけない理由 - 動的検査: AddressSanitizer / Valgrind で今日の ③④⑤ を検出できるかの実測(nightly が必要なため割愛)
- Linux での実測: 今日の終了コードと表示は macOS のもの。glibc での二重解放メッセージは一般的な挙動の記述で、未実測
🚀 次のステップ
- 発展:
examples/mismatch.rsのDemoアロケータを修正版のテストに組み込み、CMallocStrの全メソッドを通した後に Rust 側の収支が 0 になること、deallocが一度も「知らないポインタ」を受け取らないことを1本のテストで確かめる。そのうえで、わざとto_stringを元のCString::from_raw版に戻し、テストが abort で止まることを確認する - 次回予告: FFI ③ C から Rust を呼ぶ —
void *userdata付きコールバックで Rust のクロージャを C に渡し、Box::into_raw/from_rawで所有権を往復させる(qsort_rの引数順が macOS と glibc で違う問題も扱う)