轉(zhuǎn)換:從補碼原理到FPGA實戰(zhàn)避坑指南)
1. 項目概述從一次仿真Bug說起最近在調(diào)一個圖像處理相關(guān)的FPGA模塊里面涉及到一些濾波系數(shù)的運算。仿真的時候數(shù)據(jù)流看起來一切正常但輸出圖像的邊緣部分總會出現(xiàn)一些奇怪的、非預期的亮斑或暗斑。排查了很久從算法到時序都查了一遍最后定位到問題出在一行看起來很簡單的賦值語句上result coeff * pixel_data;。問題在于coeff被我定義成了signed有符號數(shù)而pixel_data是攝像頭輸入的unsigned無符號數(shù)。在Verilog中當有符號數(shù)和無符號數(shù)進行混合運算時如果你沒有顯式地告訴工具你的意圖它可能會按照一套默認的、有時反直覺的規(guī)則進行處理導致計算結(jié)果與你的預期大相徑庭。這個“有符號數(shù)與無符號數(shù)的相互轉(zhuǎn)化”問題看似基礎(chǔ)卻是我踩過最深、也最隱蔽的坑之一。它不僅僅是語法問題更關(guān)乎你對數(shù)字電路底層數(shù)據(jù)表示的理解。無論是做信號處理、通信算法還是控制邏輯只要涉及數(shù)學運算這都是一個繞不開的核心知識點。今天我就結(jié)合自己踩坑和填坑的經(jīng)驗把Verilog里有符號數(shù)和無符號數(shù)轉(zhuǎn)換的那些門道徹底講清楚讓你在寫代碼時心里有底調(diào)試時有的放矢。2. 核心概念補碼、位寬與工具視角在深入轉(zhuǎn)換之前我們必須統(tǒng)一認知在Verilog以及絕大多數(shù)數(shù)字系統(tǒng)中數(shù)據(jù)本身并沒有“符號”屬性它只是一串二進制位bits。所謂的“有符號數(shù)”或“無符號數(shù)”是我們程序員和綜合/仿真工具附加在這串二進制位上的“解讀方式”或“處理規(guī)則”。2.1 二進制世界的兩種解讀假設我們有一個4位的寄存器reg [3:0] a 4‘b1111;。無符號數(shù)解讀我們把它當作一個純二進制數(shù)。4‘b1111就是 1518 14 12 11。有符號數(shù)解讀我們采用二進制補碼規(guī)則。最高位第3位是符號位1表示負剩余位是數(shù)值位按補碼規(guī)則解讀。4‘b1111在補碼中表示 -1計算方式先取反得0000再加1得0001所以是 -1。關(guān)鍵在于這串1111的物理存在沒有變變的是我們和工具看待它的“眼鏡”。2.2 補碼硬件友好的表示法為什么用補碼因為它讓加法和減法在硬件上統(tǒng)一了。對于減法A - B硬件可以將其計算為A (~B 1)即A加上B的補碼。這樣CPU或FPGA內(nèi)部只需要加法器就能完成加減運算極大地簡化了設計。理解補碼是理解有符號數(shù)運算的基礎(chǔ)。2.3 變量的“符號屬性”聲明在Verilog中我們通過以下方式聲明變量的符號屬性reg [7:0] data_u;// 默認或無關(guān)鍵字通常被工具視為無符號數(shù)。reg signed [7:0] data_s;// 使用signed關(guān)鍵字明確聲明為有符號數(shù)。這個聲明至關(guān)重要因為它直接影響仿真行為仿真器如何顯示這個變量的值是顯示十進制正數(shù)還是可能顯示負數(shù)。運算規(guī)則當這個變量參與,-,*,,等運算時工具采用哪套規(guī)則。綜合結(jié)果綜合工具可能會根據(jù)運算類型生成不同的電路結(jié)構(gòu)例如選擇有符號數(shù)乘法器IP核還是無符號數(shù)乘法器IP核。2.4 工具如何對待未聲明的數(shù)這里有一個巨大的陷阱直接寫在代碼中的數(shù)字常量例如8-5 它的符號和位寬是由上下文決定的Verilog有一個復雜的“整數(shù)常量”規(guī)則。簡單來說一個沒有指定位寬和進制的十進制數(shù)如-5會被當作有符號整數(shù)來處理。但更常見也更安全的方法是使用明確的格式8‘d10 8位寬十進制數(shù)值10。通常被視為無符號。8‘sd10 8位寬十進制有符號數(shù)值10。-8‘sd5 8位寬十進制有符號數(shù)值-5。8‘b1111_1010 就是一串二進制位其符號屬性由賦值目標或運算上下文決定。我的踩坑心得養(yǎng)成對常量也指定符號和位寬的習慣尤其是在進行混合類型運算時。寫data_s 8‘sd1比寫data_s 1要清晰和安全得多能避免很多意想不到的符號擴展問題。3. 轉(zhuǎn)換的本質(zhì)與核心方法轉(zhuǎn)換的核心目標在保持二進制位序列的物理意義即電路連接基本不變的前提下改變工具和我們對這串二進制位的“解讀規(guī)則”。3.1 無符號數(shù)轉(zhuǎn)有符號數(shù)場景你從ADC模數(shù)轉(zhuǎn)換器讀到一個無符號數(shù)據(jù)adc_data[11:0]范圍0~4095?,F(xiàn)在你需要對其進行數(shù)字濾波濾波系數(shù)是有負數(shù)的因此需要將ADC數(shù)據(jù)當作有符號數(shù)來處理比如將零點設在2048范圍變?yōu)?2048~2047。方法1直接賦值最常用但需理解位寬匹配reg [11:0] adc_data_u; // 無符號0~4095 reg signed [12:0] adc_data_s; // 有符號我們想得到 -2048~2047 // 方法先進行位寬擴展再賦值 adc_data_s {1‘b0, adc_data_u}; // 錯誤這只是簡單的拼接adc_data_s[12]是0正數(shù)范圍是0~4095不是我們想要的-2048~2047。 adc_data_s $signed({1‘b0, adc_data_u}); // 仍然不對。$signed()只是改變“解讀方式”但{1‘b0, adc_data_u}本身是一個13位的無符號數(shù)最高位是0$signed()解讀后還是一個正數(shù)。正確的做法是進行“符號化偏移”// 核心思想將無符號的原始值減去一個偏移量如2048將其轉(zhuǎn)化為以該偏移量為零點的有符號數(shù)。 localparam OFFSET 12‘d2048; // 偏移量 reg signed [12:0] adc_data_s; // 需要多1位因為減法可能產(chǎn)生負數(shù) always (posedge clk) begin adc_data_s $signed(adc_data_u) - $signed(OFFSET); end // 此時當 adc_data_u 0 時adc_data_s -2048當 adc_data_u 4095時adc_data_s 2047。方法2使用 $signed() 系統(tǒng)函數(shù)$signed()函數(shù)的作用是將一個表達式的結(jié)果當作有符號數(shù)來解釋。它不改變底層的位值只改變“類型標簽”。reg [7:0] unsigned_data 8‘hFF; // 二進制 11111111無符號解讀為 255 reg signed [7:0] signed_data; signed_data $signed(unsigned_data); // 將 unsigned_data 的值255即8‘hFF當作有符號數(shù)解讀。 // 仿真器會顯示 signed_data 的值為 -1。關(guān)鍵點$signed(unsigned_data)并不意味著把255“轉(zhuǎn)換”成了-1的位模式。它意味著工具在接下來的運算中會把unsigned_data當前的值8‘hFF當成補碼-1來處理。如果unsigned_data的值是 8‘h7F127那么$signed()后就被當成127。3.2 有符號數(shù)轉(zhuǎn)無符號數(shù)場景你完成了一個有符號濾波運算得到了一個負的結(jié)果filter_out_s現(xiàn)在需要將其送到DAC數(shù)模轉(zhuǎn)換器輸出而DAC通常需要無符號的正整數(shù)輸入。方法1直接賦值同樣注意位寬與偏移reg signed [15:0] filter_out_s; // 濾波結(jié)果有正有負 reg [15:0] dac_data_u; // 輸出給DAC需要是0~65535的正數(shù) // 如果DAC的零點是中間值32768 localparam DAC_OFFSET 16‘d32768; always (posedge clk) begin dac_data_u filter_out_s DAC_OFFSET; // 先加上偏移量使其變?yōu)檎龜?shù) // 注意filter_out_s DAC_OFFSET 這個表達式的結(jié)果由于包含有符號數(shù)默認會被當作有符號數(shù)運算。 // 但賦值給無符號的 dac_data_u 時會發(fā)生隱式轉(zhuǎn)換。為了清晰可以 // dac_data_u $unsigned(filter_out_s DAC_OFFSET); end方法2使用 $unsigned() 系統(tǒng)函數(shù)$unsigned()與$signed()相反它強制將表達式結(jié)果當作無符號數(shù)解釋。reg signed [7:0] signed_data -1; // 補碼表示為 8‘hFF reg [7:0] unsigned_data; unsigned_data $unsigned(signed_data); // 將 signed_data 的值-1當作無符號數(shù)解讀。 // 仿真器會顯示 unsigned_data 的值為 255。重要警告直接將一個負的有符號數(shù)賦值給無符號變量或者用$unsigned()包裹一個負值不會產(chǎn)生任何數(shù)值上的轉(zhuǎn)換它只是粗暴地改變了“解讀方式”。-18‘hFF被解讀為無符號數(shù)就是255。這通常不是你想要的結(jié)果你真正需要的往往是一個“數(shù)值上的平移”如上文的加偏移量示例。3.3 轉(zhuǎn)換中的位寬擴展問題這是轉(zhuǎn)換時最容易出錯的地方。當目標位寬大于源位寬時需要進行位擴展。無符號數(shù)擴展高位補0。這叫零擴展。reg [3:0] small_u 4‘b1010; // 10 reg [7:0] large_u; large_u {4‘b0000, small_u}; // 8‘b00001010 仍然是10有符號數(shù)擴展高位補符號位最高位的值。這叫符號擴展。這是為了保持數(shù)值不變例如4位有符號數(shù)-61010擴展為8位應該是-611111010而不是1000001010。reg signed [3:0] small_s 4‘b1010; // -6 (補碼) reg signed [7:0] large_s; large_s {{4{small_s[3]}}, small_s}; // 8‘b11111010 仍然是 -6 // {{4{small_s[3]}}} 是Verilog的復制運算符將 small_s 的最高位復制4次。隱式擴展的陷阱reg signed [7:0] a 8‘sh8F; // -113 reg [15:0] b; b a; // 這里會發(fā)生什么賦值時工具需要將8位的a擴展到16位來匹配b。因為b是無符號的你可能會期待零擴展。但Verilog的規(guī)則是在賦值過程中如果源表達式是有符號的會先進行符號擴展然后再進行賦值。所以a(-113) 先被符號擴展為16位的16‘shFF8F仍然是-113然后這個位模式被賦值給無符號的b。b的值將是 65423即 16‘hFF8F這很可能不是你想要的結(jié)果。正確的做法是顯式控制擴展b {8‘h00, a}; // 零擴展b 143 不對a是8‘sh8F (143的位模式但作為有符號數(shù)是-113)。{8‘h00, 8‘sh8F} 得到 16‘h008F即 143。 // 看這又回到了“解讀”問題。如果你想要 a 的“數(shù)值”143你應該用 b $unsigned(a); // 但 $unsigned(a) 得到的是 8‘h8F 143然后零擴展為16位b143。 // 如果你想要 a 的“位模式”進行零擴展那就是上面 {8‘h00, a} 的做法??梢姳仨毞浅G宄戕D(zhuǎn)換的意圖是保持“數(shù)值”不變還是進行某種“位模式”的映射4. 混合類型運算的黃金法則當表達式中同時出現(xiàn)有符號和無符號操作數(shù)時Verilog會根據(jù)一套優(yōu)先級規(guī)則將所有操作數(shù)轉(zhuǎn)換為同一類型后再計算。這套規(guī)則比較復雜但記住以下黃金法則可以避免99%的問題法則一顯式聲明優(yōu)于隱式轉(zhuǎn)換對于任何可能產(chǎn)生歧義的運算使用$signed()或$unsigned()顯式地標記每一個操作數(shù)的類型。這相當于告訴工具“請按我指定的方式來解讀這個數(shù)據(jù)”。法則二警惕比較運算符,,,這些關(guān)系運算符在混合類型比較時極易出錯。reg [7:0] u_data 8‘hFF; // 255 reg signed [7:0] s_data 8‘hFF; // -1 if (u_data s_data) // 危險混合比較 $display(“u_data s_data”); else $display(“u_data s_data”);結(jié)果可能會輸出u_data s_data因為工具可能會把u_data也當作有符號數(shù)來比較255的補碼形式是-1不這里涉及整型提升規(guī)則很繞。絕對安全的做法是if ($unsigned(u_data) $unsigned(s_data)) // 都轉(zhuǎn)為無符號比 255 255? 不s_data轉(zhuǎn)無符號是255所以相等。 if ($signed(u_data) $signed(s_data)) // 都轉(zhuǎn)為有符號比 -1 -1? 相等。 // 更好的做法在比較前先將數(shù)據(jù)轉(zhuǎn)換到你期望的數(shù)值范圍再比。 if (u_data (s_data 128)) // 假設你想把s_data的零點偏移法則三賦值時的“類型覆蓋”賦值語句LHS RHS;最終結(jié)果的類型是由左值LHS的類型決定的。但RHS表達式計算過程中的類型轉(zhuǎn)換會影響中間結(jié)果。確保RHS的計算過程符合你的預期。法則四使用中間變量明確轉(zhuǎn)換步驟對于復雜的表達式不要追求一行寫完。拆分成多步用中間變量存儲轉(zhuǎn)換結(jié)果這樣代碼更清晰也更容易調(diào)試。// 模糊的寫法 output_data (coeff_s * adc_data_u) 8; // 清晰的寫法 reg signed [19:0] mult_result_s; // 假設位寬足夠 reg signed [11:0] adc_data_s_tmp; always (posedge clk) begin // 步驟1將無符號ADC數(shù)據(jù)轉(zhuǎn)換到有符號域減去偏移 adc_data_s_tmp $signed(adc_data_u) - OFFSET; // 步驟2進行有符號乘法 mult_result_s coeff_s * adc_data_s_tmp; // 步驟3截位或縮放賦值給輸出輸出可能又是無符號的 output_data $unsigned((mult_result_s 8) OUTPUT_OFFSET); // 是算術(shù)右移會保持符號 end5. 實戰(zhàn)案例一個簡單的有符號FIR濾波器讓我們設計一個3抽頭的有符號FIR濾波器輸入是無符號的ADC數(shù)據(jù)系數(shù)是有符號的輸出最終要轉(zhuǎn)換為無符號送給DAC。module signed_fir_filter ( input wire clk, input wire rst_n, input wire [11:0] adc_data_u, // 無符號輸入0-4095 output reg [11:0] dac_data_u // 無符號輸出0-4095 ); // 參數(shù)定義 localparam signed [7:0] COEFF_0 8‘sd30; // 明確指定有符號系數(shù) localparam signed [7:0] COEFF_1 8‘sd100; localparam signed [7:0] COEFF_2 8‘sd30; localparam OFFSET 12‘d2048; // ADC零點偏移使輸入范圍變?yōu)?-2048 ~ 2047 localparam OUTPUT_GAIN 4; // 輸出增益放大倍數(shù) localparam OUTPUT_OFFSET 12‘d2048; // 輸出偏移使范圍回到 0~4095 // 輸入數(shù)據(jù)寄存與轉(zhuǎn)換 reg signed [12:0] adc_data_s; // 13位有符號容納 -2048~2047 reg signed [12:0] data_delay1, data_delay2; // 延遲線 always (posedge clk or negedge rst_n) begin if (!rst_n) begin adc_data_s 13‘sd0; data_delay1 13‘sd0; data_delay2 13‘sd0; end else begin // 關(guān)鍵轉(zhuǎn)換步驟1無符號輸入 - 有符號處理域 adc_data_s $signed(adc_data_u) - $signed(OFFSET); data_delay1 adc_data_s; data_delay2 data_delay1; end end // 有符號乘累加 reg signed [21:0] mult_acc_s; // 乘法累加結(jié)果需要足夠位寬防止溢出 // 位寬計算輸入13位系數(shù)8位乘法結(jié)果21位。3個乘積相加最多23位我們?nèi)?4位。 reg signed [23:0] sum_s; always (posedge clk or negedge rst_n) begin if (!rst_n) begin mult_acc_s 22‘sd0; sum_s 24‘sd0; end else begin // 有符號乘法使用 signed 聲明的變量和常量確保是有符號乘法 mult_acc_s (adc_data_s * COEFF_0) (data_delay1 * COEFF_1) (data_delay2 * COEFF_2); // 應用增益并截位這里簡單右移相當于除以4 sum_s mult_acc_s * OUTPUT_GAIN; // 先增益 end end // 輸出轉(zhuǎn)換有符號域 - 無符號輸出 reg signed [23:0] sum_shifted; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sum_shifted 24‘sd0; dac_data_u 12‘d0; end else begin // 算術(shù)右移保持符號進行縮放 sum_shifted sum_s 2; // 除以4 // 關(guān)鍵轉(zhuǎn)換步驟2有符號結(jié)果 - 無符號輸出域 // 1. 加上輸出偏移量將零點移回 // 2. 使用 $unsigned 改變解讀方式 // 3. 同時進行飽和處理防止超出0-4095范圍 if (($signed(sum_shifted) OUTPUT_OFFSET) 4095) dac_data_u 12‘d4095; else if (($signed(sum_shifted) OUTPUT_OFFSET) 0) dac_data_u 12‘d0; else dac_data_u $unsigned($signed(sum_shifted) OUTPUT_OFFSET); // 先做有符號加法再轉(zhuǎn)無符號 end end endmodule在這個例子中轉(zhuǎn)換清晰地發(fā)生在兩個邊界輸入邊界adc_data_u - OFFSET并用$signed()包裹確保后續(xù)運算在有符號域進行。輸出邊界sum_shifted OUTPUT_OFFSET用$unsigned()包裹將最終數(shù)值轉(zhuǎn)換回DAC需要的無符號格式。飽和處理是防止轉(zhuǎn)換后溢出的重要步驟。6. 仿真、調(diào)試與常見問題排查6.1 仿真器顯示差異在Modelsim、VCS等仿真器中一個變量顯示的值取決于它的聲明。reg [7:0] a 8‘hFF;仿真器可能顯示255。reg signed [7:0] b 8‘hFF;仿真器可能顯示-1。調(diào)試技巧在查看波形時可以同時添加該信號的“無符號格式”和“有符號格式”。例如在Modelsim中你可以對同一個信號data添加data默認顯示再添加一個虛擬信號$signed(data)和$unsigned(data)來觀察不同解讀下的值。這能幫你快速定位是否是符號解讀錯誤導致的問題。6.2 綜合警告與錯誤綜合工具如Vivado、Quartus可能會對混合類型運算提出警告。警告示例[Synth 8-5536] Signed/unsigned mismatch in expression.不要忽略這個警告它提示你的代碼中存在潛在的邏輯錯誤風險。仔細檢查警告所在行的表達式按照黃金法則顯式轉(zhuǎn)換操作數(shù)類型。位寬截斷警告當你將一個較寬的有符號數(shù)賦值給較窄的無符號變量時綜合工具會警告可能的數(shù)據(jù)丟失。你需要確認這種截斷是否是設計意圖例如丟棄高位是安全的或者你是否需要先進行飽和處理。6.3 常見問題速查表現(xiàn)象可能原因排查與解決方法仿真結(jié)果與數(shù)學計算不符混合類型運算未顯式轉(zhuǎn)換檢查所有運算符兩邊的操作數(shù)類型使用$signed()/$unsigned()顯式標記。輸出數(shù)據(jù)出現(xiàn)非預期的跳變?nèi)鐝恼龜?shù)突變?yōu)楹艽蟮臄?shù)有符號負數(shù)被誤當作無符號數(shù)使用檢查從有符號到無符號的轉(zhuǎn)換點是否忘記了加偏移量進行數(shù)值映射而只是簡單改變了類型解讀。乘法或加法結(jié)果符號錯誤常量未指定符號和位寬檢查代碼中的數(shù)字常量特別是負數(shù)。將-1改為8‘sd-1或-8‘sd1。比較邏輯始終為真或為假關(guān)系運算符兩邊的類型不一致在比較前將兩邊操作數(shù)統(tǒng)一轉(zhuǎn)換為同一種類型通常轉(zhuǎn)換為有符號數(shù)進行比較更符合直覺。資源使用如DSP比預期多工具無法推斷出有符號乘法可能生成了更通用的乘法器確保乘法操作的兩個操作數(shù)都被明確聲明為signed或者使用$signed()包裹。這樣工具才能調(diào)用優(yōu)化的有符號乘法器IP。時序仿真與功能仿真結(jié)果不同在賦值時有符號數(shù)的符號擴展行為與預期不符仔細檢查位寬擴展的代碼。對于有符號數(shù)賦值給更寬的無符號數(shù)考慮是否先進行了零擴展{0, signed_data}而不是依賴默認的符號擴展。6.4 我的調(diào)試心得波形圖是你的朋友遇到運算問題第一件事是把所有相關(guān)信號原始輸入、轉(zhuǎn)換后的中間值、運算結(jié)果、最終輸出都拉到波形圖里。同時觀察它們的十六進制形式和有/無符號十進制形式。十六進制形式反映了真實的位模式十進制形式反映了當前的解讀方式兩者對比往往能立刻發(fā)現(xiàn)問題。編寫小型測試平臺針對復雜的轉(zhuǎn)換邏輯不要在主測試平臺里調(diào)試。單獨寫一個簡單的tb_conversion.v用幾個邊界值如最大值、最小值、0、-1進行測試驗證轉(zhuǎn)換函數(shù)是否正確。理解工具的“默認行為”花點時間閱讀你所用的FPGA開發(fā)工具Vivado/Quartus和仿真器Modelsim/VCS關(guān)于有符號數(shù)運算的文檔。了解它們在特定情況下的默認選擇才能寫出可預測、可移植的代碼。保持一致的數(shù)據(jù)流域在一個模塊內(nèi)部盡量讓數(shù)據(jù)在同一個“域”內(nèi)流動要么全有符號要么全無符號。轉(zhuǎn)換只在模塊的輸入和輸出接口處進行。這能極大簡化內(nèi)部邏輯減少錯誤。有符號數(shù)和無符號數(shù)的轉(zhuǎn)換本質(zhì)上是在數(shù)字的物理表示位模式和邏輯含義數(shù)值之間進行映射。它考驗的是你對二進制系統(tǒng)、補碼表示以及Verilog語言規(guī)則的透徹理解。沒有捷徑多寫、多仿、多踩坑自然就能建立起準確的直覺。當你再看到$signed()和$unsigned()時你想到的不再是模糊的“類型轉(zhuǎn)換”而是清晰的“在此處請將這段比特流按補碼規(guī)則解讀”或“在此處請將其視為純二進制正整數(shù)”的明確指令。