SOLID未分類 SOLID for Raspberry Pi 4 (連載24)
(2023/4/25)
引き続きC言語とRust間のデータ渡しについて見ていきます。
思いつく限り実験していきます。
前回、Rust側で指定した型のデータを引数として受け取ってみました。
特に前回の最後の方で、C/C++側とRust側で引数の定義を間違えてしまった場合について見てみました。
C/C++同士、あるいはRust同士の関数コールであればビルドエラーになるので、そもそも起こり得ない事です。
しかし”異なった言語間”での関数コールであるため、人間の不注意さえあれば簡単に引数定義を間違えることがあります。
前回、引数が、C/C++で渡す側と、Rustで受ける側で順番が異なったり、抜けたり、、、という実験をしてみました。
そうなった場合どう動いてしまうのか。もしかしてしらーっと動いてしまうのか?
Debugビルドでは動いてしまっても、いざReleaseビルドをすると動かなくなったりするのか?
と、いうところを試してみます。
まず、振り返ってみます。
通常ケース:
渡す側(C/C++):uint8_t, uint32_t, uint64_t, unsigned int, double, bool, char
受ける側(Rust):u8, u32, u64, usize, f64, bool, char
結果:意図したとおりに引数を受け取れた。
間違いケース1:Rustの第2引数がu16の場合
渡す側(C/C++):uint8_t, uint32_t, uint64_t, unsigned int, double, bool, char
受ける側(Rust):u8, u16, u64, usize, f64, bool, char
結果:データずれ発生せず、意図したとおりに引数を受け取れた。
間違いケース2:Rustの第5引数であるべき f64 が抜けた場合
渡す側(C/C++):uint8_t, uint32_t, uint64_t, unsigned int, double, bool, char
受ける側(Rust):u8, u32, u64, usize, f64, bool, char
結果:データずれ発生せず、意図したとおりに引数を受け取れた。
間違いケース3:Rustの第2引数と第6引数が逆になった場合
渡す側(C/C++):uint8_t, uint32_t, uint64_t, unsigned int, double, bool, char
受ける側(Rust):u8, bool, u64, usize, f64, u32, char
結果:データの値も逆になった。
間違いケース4 :Rustの第6引数であるべき bool が抜けた場合
渡す側(C/C++):uint8_t, uint32_t, uint64_t, unsigned int, double, bool, char
受ける側(Rust):u8, u32, u64, usize, f64, bool, char
結果:char型のデータの値が1バイト分ずれた。
前回は、これら、すべてDebugビルドで行いましたが、今回はReleaseビルドで行ってみます。
どういう結果になるでしょうか。
まず、通常ケースで、格納されているデータ配置がどうなるのかを見てみました。
Rust関数に入った直後で止めてみます。
まず、Debugビルドの場合:
次、Releaseビルドの場合:
両方とも、f64_val以外はレジスタ渡しになっているんですね。
スタックの場所はReleaseとDebugのビルドで違いますね。
この時点で、まだf64の値は渡されていません。
どこでスコープに入るのだろう?
Releaseビルドを使って、Disassembleで少しずつ進めてみます。
値を使用するprintln!マクロをコールする直前に、f64_valも見れるようになりました。
このタイミングでスタックからもってくるんですね。
(ちなみに、Debugビルドの場合は、println!マクロの行でブレークを設定すると、既にf64_valがスコープに入っていました。)
各Caseの結果は以下になりました。
通常ケース:Debugビルド同様、意図したとおりに引数を受け取れた。
間違いケース1:Debugビルド同様、意図したとおりに引数を受け取れた。
間違いケース2:Debugビルド同様、意図したとおりに引数を受け取れた。
間違いケース3:Debugビルド同様、データの値は逆になったが、bool値がtrueになった。
X1レジスタの0xF0F0F0F0 ⇒bool値として受け取った
今回はbool値として最下位ビットの0を取るのではなく、X1レジスタ
そのもので判断してtrue?
X4レジスタの0x00000001 ⇒uint値として受け取った
間違いケース4:Debugビルド同様、char型のデータの値が1バイト分ずれた。
以上、ReleaseビルドではDebugビルドと少しだけ動きが違いました。
Debugビルドでデバッグをした後Releaseビルドでの確認を忘れないようにしないといけない要因の一つかと思います。
引数に配列がある場合は、どのように書けばよいでしょうか。
やってみます。
C/C++(呼ぶ側):
extern "C" void func_array(uint32_t data[]);
extern "C" void slo_main(){
:
uint32_t data_c[3] = {0, 0x11111111, 0x22222222};
func_array(data_c);
:
}
Rust(呼ばれる側):
#[no_mangle]
pub extern "C" fn func_array(arraydata: &[u32; 3]) {
println!("func2_array");
println!("1st data={:#X}, 2nd data={:#X}, 3rd data={:#X}"
,arraydata[0], arraydata[1], arraydata[2]);
}
注:
C/C++では引数中のトップレベルの配列型はポインタ型で置き換えられるため、厳密には「引数に配列がある」状態ではないですが、便宜上という事で。。。
参考:https://en.cppreference.com/w/cpp/language/function
実行してみました。意図通り受け渡しができています。
ではここで実験します。
C/C++で渡している配列は、要素が3個でした。
Rust側で4個を期待していた場合、どう動くのでしょうか。
Rust(呼ばれる側):
#[no_mangle]
pub extern "C" fn func_array(arraydata: &[u32; 4]) {
println!("func2_array");
println!("1st data=={:#X}, 2nd data={:#X}, 3rd data={:#X}, fourth data={:#X}"
,arraydata[0], arraydata[1], arraydata[2], arraydata[3]);
}
4つめ、何か入っていますね。
メモリを見てみましょう。
次の場所のメモリ内容を読んでますね。
という事は、Rust側が勝手に、先ほどのメモリをタプルと読むようにすれば、C/C++から受け取るデータをタプル形式で受け取れるような気がしてきました。
やってみます。
C/C++(呼ぶ側):
extern "C" void func_tuple(uint32_t data[]);
extern "C" void slo_main(){
:
uint32_t data_c[3] = {0, 0x11111111, 0x22222222};
func_tuple(data_c);
:
}
Rust(呼ばれる側):
#[no_mangle]
pub extern "C" fn func_tuple(tupledata: (u32, u32, u32)) {
println!("func_tuple");
println!("1st data={:#X}, 2nd data={:#X}, 3rd data={:#X}"
,tupledata.0, tupledata.1, tupledata.2);
}
こう書いたところで、ワーニングが出ました。
強行して、動かしてみます。
おっと、全然違うところからデータを取ってきていました。
赤線:C/C++側で引き渡したかったデータ(配列)
青線:Rust側で読んだデータ
ワーニングが教えてくれた通り、C/C++との間でタプルは用いてはいけないですね。
次は、ポインタを引数にしてみます。
C/C++(呼ぶ側):
extern "C" void func_pointer(uint32_t* data);
extern "C" void slo_main(){
:
uint32_t data_c[3] = {0, 0x11111111, 0x22222222};
func_pointer(&data_c[1]);
:
}
配列data_c{}の要素2番目のアドレスをポインタとして渡します。
Rust(呼ばれる側):
#[no_mangle]
pub extern "C" fn func_pointer(p_data: *mut i32) {
println!("func_pointer");
println!("data[1]={:#X}", unsafe{*p_data});
}
無事、data_c[1]の内容を見ることができました。
C/C++で、戻り値を配列で?は、書けないですね。
書けないとは思いますが、Rust側から何が帰ってくるのかを見てみたいので、強行に実行してみます。
こういうコードを書きました。
C/C++(呼ぶ側):
extern "C" int32_t* func_return_array();
extern "C" void slo_main(){
:
int32_t *p;
p=func_return_array();
SOLID_LOG_printf("Return from Rust = %x\n", p);
:
}
(もし万が一、配列のアドレス情報が返ってきていたら、中身が見えるよう、ポインタにしてみました)
Rust(呼ばれる側):
static RTN_ARRAY: [u32; 3] = [0x12233445, 0x01010101, 0xAAAABBBB];
#[no_mangle]
pub extern "C" fn func_return_array() -> [u32; 3] {
println!("func_return_array");
return RTN_ARRAY;
}
実行してみました。
ふむふむ。
Rust側から、普通に配列の中のデータがベタで返ってきていました。
という事は、タプル形式でも同じように、データがベタで返ってくるのかも?
試してみました。
C/C++(呼ぶ側):
extern "C" int32_t *func_return_tuple();
extern "C" void slo_main(){
:
int32_t *p;
p=func_return_ tuple();
SOLID_LOG_printf("Return from Rust = %x\n", p);
:
}
Rust(呼ばれる側):
#[no_mangle]
pub extern "C" fn func_return_tuple() -> (u32, u32, u32){
println!("func_return_tuple");
return (0x12233445, 0x01010101, 0xAAAABBBB);
}
実行してみました。
配列の時と同様、ベタでデータが返ってきました。
今度こそ正しい戻り値を!
戻り値をポインタで返してみます。
C/C++(呼ぶ側):
extern "C" uint32_t *func_return_pointer();
extern "C" void slo_main(){
:
uint32_t *data_rust;
data_rust = func_return_pointer();
SOLID_LOG_printf("Return from Rust = %x, %x, %x\n", *(data_rust++), *(data_rust++), *(data_rust));
:
}
Rust(呼ばれる側):
static RTN_ARRAY: [u32; 3] = [0x12233445, 0x01010101, 0xAAAABBBB];
#[no_mangle]
pub extern "C" fn func_return_pointer() -> *const u32{
println!("func_return_pointer");
let rawptr: *const u32 = &RTN_ARRAY[0]; //生ポインタ取得
return rawptr;
}
実行してみました。
やっと!
Rust側から正しい配列データを取得することができました!
Rustではタプルだとか配列そのまま返しとか、便利な機能がありますが、C/C++との間でデータをやり取りする場合は、ポインタが一番良さそうに思いました。
C/C++で準備した構造体をRust側に渡して使う、といった用途を考えてみます。
構造体をnewして動的に領域を確保し、その領域のデータをRustで使うことを試してみます。
C/C++(呼ぶ側):
struct TestDataStruct{
uint8_t u8_val;
uint32_t u32_val;
uint64_t u64_val;
unsigned int uint_val;
double f64_val;
bool bool_val;
char char_val;
};
extern "C" void func_structure(TestDataStruct *);
extern "C" void slo_main(){
:
TestDataStruct *p_testdata = new TestDataStruct;
p_testdata->u8_val = 0xa5;
p_testdata->u32_val = 0xF0F0F0F0;
p_testdata->u64_val = 0x2222222211111111l;
p_testdata->uint_val = 0x9abcdef0;
p_testdata->f64_val = 1.2345;
p_testdata->bool_val = true;
p_testdata->char_val = 'A';
func_structure(p_testdata);
delete p_testdata;
:
}
Rust(呼ばれる側):
#[repr(C)]
pub struct TestDataStructRust
{
u8_val_rust: u8,
u32_val_rust: u32,
u64_val_rust: u64,
uint_val_rust: usize, //C/C++側では32ビットなのでu32とすべきだが実験のためusize
f64_val_rust: f64,
bool_val_rust: bool,
char_val_rust: u8, //C/C++側では8ビットなので今回はu8とした
}
#[no_mangle]
pub extern "C" fn func_structure(mut testdata: &mut TestDataStructRust){
println!("func_structure");
testdata.bool_val_rust = false;
testdata.u8_val_rust = 0x62;
println!("u8={:#X}, u32={:#X}, u64={:#X}, usize={:#X}, f64={}, bool={}, char={}"
,testdata.u8_val_rust, testdata.u32_val_rust, testdata.u64_val_rust, testdata.uint_val_rust, testdata.f64_val_rust, testdata.bool_val_rust, testdata.char_val_rust);
}
[注]
最初、#[repr(C)]を記述するのを忘れており、なんだかRust側で受け取るデータの順番が違ってるなぁ、、、と、しばらくハマってしまいました。。。
まだ慣れてないですね。。。
実行してみました。
値は正しく渡っていますね。
シリアルターミナルでの出力結果も問題ありません。
Rust関数内での値変更も正しくなされています。
今回行った内容について、Releaseビルドで再確認してみました。
全ケースにおいて、結果はReleaseビルドでも同様でした。
戻り値をポインタで返すプログラムの番外編です。
配列を、ローカルで書いてみて、DebugビルドとReleaseビルドで動作確認してみました。
スコープ外になるとライフタイムが消滅するパターンです。
同じコードをC/C++で書いたとしても、ローカル変数なので解放されてしまい、
正く動かないコードです。
ですが、筆者はおっちょこちょいプログラマなので、たまにコレやります。
まぁ大体の場合、ポインタで返すような配列は複数の関数で使う配列ではあるので、滅多にローカルで宣言してしまうようなことはないですが、試しにちょろっと書いた小さめのプログラムであまり深く考えずにさくっと片付けてしまうと、この失敗やりがちです。
ややこしいのは、これは結合テスト等で見つけにくい事です。
解放はすぐにはなされず、当面はメモリ上残っているため、そこそこ動いてしまうのです。
そして、なぜかよくわからないタイミングで動かなくなる。
動かなくなるタイミングは、まちまち。
デバッグが難しい現象になります。
大苦労のデバッグで原因判明するも、初歩的すぎて膝から崩れ落ちる、例のヤツです。
Rust(呼ばれる側):
#[no_mangle]
pub extern "C" fn func_return_pointer() -> *const u32{
println!("func_return_pointer");
let rtn_array: [u32; 3] = [0x12233445, 0x01010101, 0xAAAABBBB];
let rawptr: *const u32 = &rtn_array[0]; //生ポインタ取得
return rawptr;
}
これがどう動くのか、やってみたところ、偶然にもDebugビルドではC/C++に戻ってしばらく解放がなされず、ぱっと見は動いてしまいました。
ちなみにReleaseビルドでは、既にメモリが解放されていました。
Rustを使うとライフタイム系は安心、というイメージがありますが、言語間を行き来するのであればこういう間違いは当然(今まで同様)自分で気を付ける必要があり、いくらRustのコードだからといって頼りすぎないようにしなければ、と実感しました。
今回は、引数や戻り値のいろいろなパターンを見てみました。
結局のところ、Rust側で拡張された配列返しやタプル形式などは当然C/C++では解釈されないわけです。
両者間のデータ渡しのコードを書く上では、C/C++側仕様に寄せ、それをRustでどう書くのかを考える、という作業を行った感覚でした。
次回は、構造体つながりで、組み込み開発でちょくちょく気にしないといけない「データ配置」について、Rustに置き換えて考えてみたいと思います。