この記事は、EEIC Advent Calender 2023 17日目の記事として書かれています。
目次
1.はじめに
2.Verilogにおけるキャストについて
3.signedって言ったのにキャストされない問題
4.おわりに
はじめに
EEIC3年のまっつんと言います。普段はTwitter(Xという名称は認めていません)でオタク話をしたり、競馬で負けたりしています。
僕は後期実験で「FPGAを用いたアルゴリズム実装(課題12)」「マイクロプロセッサの設計と実装(課題6)」と2シーズン連続でVerilogを書く実験を選択し、その中で符号付き演算のために行うキャストの仕様に躓いたことが何回かあったので、備忘録的にこの記事にまとめました。普段からVerilogを書き慣れている方から見たら当たり前の内容と思いますが、温かい目で見ていただければ幸いです。
Verilogにおけるキャストについて
Cやpythonなどのソフトウェア言語でのキャストといえば、変数の型変換を指すのでした。これはつまり、「同じ値を別の形式のビット列で表現する」ということなので、キャスト前と後ではビット列の内容が変化します。例えば、値 "2" を表すint形の変数をfloat型にキャストすると、ビット列が32'h00000002
から32'h40000000
に変化します(ビット列はVerilog風に書いています)。
一方Verilogにおけるキャストとは、「ビット列が符号付きかどうかの解釈変更」です。例えば、
wire [3:0] unsigned_value = 4'b1101
wire signed [3:0] signed_value = 4'b1101
と書いたとき、unsigned_value
の値は普通に13と解釈されますが、signedと宣言したsigned_value
は符号付きの値と解釈されるため、2の補数表現を考えて値は-3となります。
また、演算の際に$signed(変数名)
と宣言すると、その変数をsignedとして計算してくれます。
unsigned_calc <= unsigned_value * -2 // -26になって欲しい
signed_calc <= $signed(unsigned_value) * -2 // 6になって欲しい
ここでポイントとなるのが、「signedとunsignedを切り替えてもビット列自体に変化はなく、その解釈の仕方が変わるだけ」ということです。ソフトウェアでのキャストのノリで扱っているとそのうち脳がバグるので注意する必要があります。というかそうやって脳がバグったってのがここから先の話。
ちなみに、学生実験中の我々およびネット上の一部の文献はこのsignedとunsignedの切り替えの操作を「キャスト」と呼んでいたのですが、正式な用語として「キャスト」が用いられているという裏付けを取ることはできませんでした。なんかソフトウェア的な「キャスト」とはやってることが違うし、SystemVerilogには普通にintやfloat型があるので正式にはそっちの変換のことを「キャスト」って呼んでたりしそう。この辺の正式な用語について知ってる方はぜひ教えてください。
signedって言ったのにキャストされない問題
僕が遭遇した躓きを再現したのが次のモジュールです。
module signed_by_signed (
input clk,
input [7:0] unsigned_input //8'b11111001
);
wire [7:0] signed_wire = $signed(unsigned_input); //事前にキャストしたつもり
reg signed [7:0] signed_operand; //キャスト済み
reg state; //表示用、気にしないで
//結果格納用レジスタたち
reg [7:0] raw;
reg [15:0] raw_extended;
reg [7:0] cast_before_calc;
reg [15:0] cast_before_calc_extended;
reg signed [15:0] cast_before_calc_extended_signed;
reg [15:0] cast_and_calc_extended;
initial begin
signed_operand = 8'b11111110; // -2
state = 0;
end
always @(posedge clk ) begin
// -2 * -7 = 14 になれば成功
raw <= signed_operand * unsigned_input; // キャストしない
raw_extended <= signed_operand * unsigned_input; // キャストしない、ビット拡張
cast_before_calc_extended <= signed_operand * signed_wire; // 事前にキャストしたつもり、ビット拡張
cast_before_calc_extended_signed <= signed_operand * signed_wire; // ↑の結果をsignedに
cast_and_calc_extended <= signed_operand * $signed(unsigned_input); // キャストしてから計算、ビット拡張
state <= 1;
end
//表示
always @(negedge clk ) begin
if(state == 1) begin
$display(" -2 * -7 = 14 になれば成功");
$display("① キャストしない -> %b = %d", raw, raw);
$display("② キャストしない、ビット拡張 -> %b = %d", raw_extended, raw_extended);
$display("③ 事前にキャストしたつもり、ビット拡張 -> %b = %d", cast_before_calc_extended, cast_before_calc_extended);
$display("④ ↑の結果をsignedに -> %b = %d", cast_before_calc_extended_signed, cast_before_calc_extended_signed);
$display("⑤ キャストしてから計算、ビット拡張 -> %b = %d", cast_and_calc_extended, cast_and_calc_extended);
//↑これ全部copilotが書いてくれた。かがくのちからってすげー!
end
end
endmodule
要はsigned同士の掛け算を実現したいというコードです。
これにテストベンチをつけて実行した出力が↓です。
-2 * -7 = 14 になれば成功
① キャストしない -> 00001110 = 14
② キャストしない、ビット拡張 -> 1111011100001110 = 63246
③ 事前にキャスト、ビット拡張 -> 1111011100001110 = 63246
④ ↑の結果をsignedに -> 1111011100001110 = -2290
⑤ キャストしてから計算、ビット拡張 -> 0000000000001110 = 14
正しい結果を得るまでに5段階も踏んでしまいました。順に見ていきましょう。
①では、8ビットの変数8'b11111001
と8'b11111110
を掛け合わせて、結果を8ビットに格納しています。その結果、望んだ値である14を得ることができました。そもそも2の補数表現は、符号のことを全く考えずにビット演算をしても正しい結果が得られるというのが嬉しかったので、これは当然の結果と言えます。
しかし、今回の実験では結果を倍精度で保持する必要があった場面がありました。そこで、①と同じ計算の結果を16ビットに格納したのが②でした。その結果、下位8ビットだけ見れば正しかった結果が、倍精度で見ると正しくない結果になっていることがわかりました。
Verilogの仕様上、符号を考慮した計算を行ってほしい場合は、計算に用いる変数が全てsignedとして宣言されていなければなりません。今回はunsigned_input
というunsignedの値が用いられているため、正しく解釈されないことになります。
じゃあunsigned_input
の方もキャストしてやろうということで、signed_wire
という新しい変数を作り、そこにunsigned_input
の値をキャストしてから「代入」したのが③ですが、結果は変わりませんでした。
これは、(「代入」という言葉遣いからも分かるとおり)僕がまだソフトウェアの感覚でVerilogを書いていたことが原因でした。
wire [7:0] signed_wire = $signed(unsigned_input);
という記述は、「8桁のwiresigned_wire
を作り、それをunsigned_input
と接続する」ということを表しています。また、$signed
は先述の通り「値8'b11111001
を持つunsigned_input
の解釈を変更する」という宣言に過ぎず、接続先のsigned_wire
の値には一切影響を及ぼしません。signed_wire
には値8'b11111001
が入っており、signed_wire
の宣言はunsignedとしてなされているため、signed_wire
は符号付きの値 "-7" ではなく符号なしの値 "249" として解釈されてしまっています。(つまり、signed_wire
の方をキャストしていれば "-7" として解釈してくれていたのですが、そのことに気づいたのはこの記事の作成準備中でした...。)
そこから迷走し、結果を格納するレジスタの方をsignedで宣言してみたりしても、1111011100001110
というビット列の解釈が変わるだけなので正しい結果は得られません。最終的に、「計算に用いる変数が全てsignedとして宣言されている」条件を確実に満たすために、演算を宣言する式の中で`$signed'と宣言することでようやく正しい結果を得ることができました。
ちなみにこのデバッグは深夜にやっていたのですが、気づいた時に深夜テンションも相まっての脳汁が出まくったことでこの記事の執筆に至りました。もう1本書く予定の記事がふざけてるから真面目そうな記事で中和しておこうみたいなこと全く考えてないよ。
おわりに
長々と書いてしまいましたが、signedの演算をするときはオペランドを直接$signed
でキャストするのが一番確実そうです。それはそうと、ソフトウェア脳のままVerilogを書いていると非本質的なところで沼りまくるので、皆さんも一緒にソフトウェア脳から脱却しましょう。
追記
一通り記事を書き終わってから気づいたのですが、「ハードウエア設計論以来Verilogを忘れている人が読んでもわかるようにしよう」と思ってそこそこ頑張って説明を書いたのに、よく考えたら後期実験でVerilog書いてた人たちには既知の内容だろうし、そうでない人はこれからもあんまりVerilog書く機会ないだろうからあんまり役に立たないし、1個下の後輩は今読んでもさっぱりだろうしで、誰に向けて書いたのかよくわからなくなってしまいました。でもめんどいのでこのまま公開します。