
1. 從浮點數到定點數為什么我們需要另一種數字表示在編程和硬件設計的日常工作中浮點數Float幾乎無處不在。無論是處理科學計算、圖形渲染還是簡單的業務邏輯float和double類型都是我們最熟悉的數字伙伴。它們能優雅地表示極大或極小的數值比如光速或電子的質量這得益于其基于科學計數法的設計——一個尾數乘以2的指數次方。然而當我們的戰場轉移到嵌入式系統、數字信號處理DSP、音頻編解碼或某些對性能和確定性要求極高的金融交易場景時浮點數這位“優雅的貴族”就開始顯得有些笨拙和昂貴了。浮點數的運算無論是加法還是乘法其硬件實現FPU都相對復雜消耗更多的晶體管、功耗和時鐘周期。在資源受限的微控制器MCU上可能根本沒有硬件FPU軟件模擬浮點運算的效率又極其低下。更重要的是浮點運算的結果有時是“非確定性的”——在不同的編譯器、不同的優化等級、甚至不同的硬件平臺上對同一串浮點運算可能產生極其微小的差異。對于要求結果絕對一致、可重現的系統如分布式仿真、區塊鏈智能合約中的金融計算這是不可接受的。這時定點數Fixed-Point Number就登場了。你可以把它理解為一種“簡化版”或“特化版”的浮點數。它放棄了動態變化的指數部分強制規定小數點隱含在一個固定不變的位置上。所有的數字無論大小都使用同一套整數運算規則來處理。聽起來像是退步恰恰相反在特定領域這是巨大的進步。它用可預測的、高效的整數運算單元實現了對小數的高性能、確定性處理。理解定點數不僅是掌握一種數字表示法更是獲得了一把優化關鍵性能路徑、深入理解計算機底層數值運算的鑰匙。無論你是嵌入式開發者、算法工程師還是對性能有極致追求的后端工程師定點數都是你工具箱里不可或缺的利器。2. 定點數的核心原理把小數點“釘”在固定位置要理解定點數最直觀的方式是和浮點數做對比并從一個簡單的整數開始。2.1 從整數到小數隱含的尺度假設我們有一個8位的無符號整數它的表示范圍是0到255。它只能表示整數。現在我們引入一個約定這個8位數字其最低的2位不再代表個位和十位而是代表“1/4位”和“1/2位”。更一般化地說我們約定這個數字的小數點固定在從右向左數的第2位之后。這是什么意思呢我們定義一個格式UQ8.2。這里的“U”代表無符號Unsigned“Q”是Q格式標記后面會詳述“8”是總位數“2”是小數部分的位數。那么這個UQ8.2格式的數字其表示的實際值 存儲的整數值 × 2^{-小數位數} 存儲的整數值 × 2^{-2} 存儲的整數值 × 0.25。舉個例子存儲的二進制整數是00000100十進制4。那么它表示的實際數值是 4 * 0.25 1.0。存儲的二進制整數是00000101十進制5。那么實際值是 5 * 0.25 1.25。存儲的二進制整數是11111111十進制255。那么實際值是 255 * 0.25 63.75。看到了嗎我們并沒有在內存中存儲一個小數點。我們存儲的依然是一個純粹的整數這里是4、5、255。但是我們約定了這個整數背后有一個固定的縮放因子這里是0.25。當我們需要這個數的“實際意義”時就乘以這個縮放因子。在進行運算時我們直接對存儲的整數進行操作并在必要時考慮縮放因子帶來的影響。注意這種“約定”必須在整個系統內保持一致。數據的生產者、處理者和消費者都必須明確知道這個定點數格式是UQ8.2否則解讀出來的數值將完全錯誤。這通常通過文檔、變量命名或強類型來保證。2.2 Q格式定點數的標準“方言”在工程實踐中人們常用“Q格式”來精確描述一個定點數。其通用形式是Qm.n或UQm.n/SQm.n。Q: 標識此為定點數格式。m: 整數部分的位數包括符號位如果是有符號數。n: 小數部分的位數。總位數m n。對于有符號數SQm.n通常用補碼表示一位符號位所以m包含了這1位。U/S: 可選前綴U代表無符號UnsignedS代表有符號Signed。有時會省略默認可能為有符號。舉例說明Q7.8: 這是一個有符號定點數總位寬為15位78。其中1位是符號位6位是整數位8位是小數位。其縮放因子為 2^{-8} 1/256。UQ10.6: 這是一個無符號定點數總位寬為16位。其中10位整數位6位小數位。縮放因子為 2^{-6} 1/64。Q1.14: 常見于音頻處理總位寬15位。1位符號位0位整數位即整數部分只能是-10或接近014位小數位。縮放因子為 2^{-14} 1/16384。它能精細地表示-1.0到接近1.0之間的小數非常適合表示音頻采樣幅值。縮放因子與精度 縮放因子S 2^{-n}它直接決定了該定點數格式的精度即能表示的最小非零小數。例如Q15.1632位定點數16位小數的精度是 2^{-16} ≈ 0.00001526這對于大多數控制算法和信號處理來說已經足夠精細。同時它也決定了表示范圍。例如UQ8.8的范圍是 0 到 (2^8 - 1) * 2^{-8} 0 到 255/256 ≈ 0 到 0.996。2.3 與浮點數的本質區別現在我們可以清晰地對比兩者浮點數 由符號位S、指數位E和尾數位M組成。數值 (-1)^S * 1.M * 2^{(E-bias)}。小數點位置由指數E動態決定因此可以在極大動態范圍內保持相對精度。定點數 就是一個整數或補碼整數乘以一個固定的縮放因子2^{-n}。小數點位置是靜態的、隱含的由格式Qm.n中的n固定死。這種靜態特性帶來了優缺點優點運算等同于整數運算速度快功耗低硬件實現簡單結果確定。缺點動態范圍有限。你必須事先預估好運算過程中可能出現的最大值和最小值并選擇合適的m和n。如果選小了會溢出如果為了安全把m選得很大又會浪費精度。3. 定點數的運算規則與實操要點理解了表示法接下來就是如何在定點數之間進行加、減、乘、除。這是定點數使用的核心也是最容易出錯的地方。3.1 加法與減法對齊小數點定點數的加減法有一個黃金法則參與運算的兩個操作數必須具有相同的小數點位置即相同的n。如果n不同直接對存儲的整數進行加減結果將毫無意義。這就像你不能直接把“3.5米”和“350厘米”的數字3.5和350相加一樣必須先統一單位。操作假設有A Qa.ma, na和B Qb.mb, nb且na ! nb。確定目標格式。通常選擇小數位數更多的那個n_target max(na, nb)以避免精度損失。對小數位數少的操作數進行格式轉換。這本質上是一個整數的移位操作。如果要將A (na)轉換為n_target且n_target na則需要將A存儲的整數值算術左移(n_target - na)位。這相當于放大了數值。例如A是Q8.4值a_int要轉為Q12.8。8-44位需要將a_int 4。對轉換到同一格式后的兩個整數值進行加減運算。結果自然繼承了目標格式Qm_target.n_target。實操心得在C語言中對于有符號定點數移位前最好先進行類型提升到更高位寬的整數如int32_t以避免移位時符號位出現問題或溢出。加減法可能溢出即使兩個操作數本身不溢出它們的和或差可能超出結果格式的表示范圍。在設計格式時m整數位數必須能容納運算結果。// 示例Q8.4 與 Q8.6 相加 int16_t a_q8_4 160; // 實際值160 * 2^{-4} 10.0 int16_t b_q8_6 640; // 實際值640 * 2^{-6} 10.0 // 統一到Q10.6格式選擇n6 // 將a從Q8.4轉為Q10.6左移 (6-4)2位 int16_t a_converted (int16_t)((int32_t)a_q8_4 2); // 160 2 640 // 現在兩者都是Q10.6格式a_converted和b_q8_6 int16_t sum_q10_6 a_converted b_q8_6; // 640 640 1280 // 實際值1280 * 2^{-6} 1280 / 64 20.0正確。3.2 乘法小數位相加乘法相對直接但有一個重要特點兩個定點數相乘結果的小數位數是兩者小數位數之和。操作A (Qma.na)乘以B (Qmb.nb)。直接將存儲的整數值a_int和b_int相乘。注意兩個N位數相乘結果最多需要2N位來存儲因此通常需要使用雙倍位寬的中間變量如int32_t存放兩個int16_t的乘積。乘積結果是一個中間格式其小數位數 na nb。通常我們需要將結果調整回某個標準的目標格式例如存回一個16位變量。這就需要進行重新定標Rescaling即右移或左移并處理舍入。// 示例Q8.4 乘以 Q8.4結果希望存為 Q8.4 int16_t a_q8_4 160; // 10.0 int16_t b_q8_4 80; // 5.0 int32_t product_raw (int32_t)a_q8_4 * (int32_t)b_q8_4; // 160 * 80 12800 // 此時 product_raw 的格式可以理解為 Q16.8因為448位小數 // 我們需要將其轉換回 Q8.4即保留4位小數。 // 從8位小數變為4位小數需要右移 (8-4)4位。 int16_t result_q8_4 (int16_t)(product_raw 4); // 12800 4 800 // 驗證800 * 2^{-4} 800 / 16 50.0而 10.0 * 5.0 50.0正確。關鍵點中間精度與舍入上面的例子直接使用了截斷右移丟棄低位。這引入了截斷誤差。為了更精確通常采用舍入。最簡單的舍入是“四舍五入”可以在右移前加上一個舍入因子。// 舍入到最近的數加上 1 (shift-1) int shift 4; int32_t rounded_product product_raw (1 (shift - 1)); // 加 2^(shift-1) result_q8_4 (int16_t)(rounded_product shift);乘法是溢出的高發區。即使單個操作數不溢出乘積很可能溢出中間變量的范圍。務必使用足夠大的數據類型如int32_t來存放兩個int16_t的乘積。3.3 除法最復雜的運算定點數的除法是最棘手的因為整數除法本身就會丟失小數部分。我們需要通過技巧來保留精度。核心思想將被除數“放大”后再進行整數除法。 假設計算A / B兩者格式均為Qm.n。為了在結果中保留k位小數我們可以先將被除數A算術左移k位。這相當于將其乘以2^k。然后對這個放大后的被除數與除數B做整數除法。得到的結果其縮放因子可以理解為2^{-k}。更通用的方法是如果我們希望結果格式為Qm_res.n_res可以// A, B 都是 Q8.4 希望得到 Q8.4 的結果 int16_t a_q8_4 160; // 10.0 int16_t b_q8_4 80; // 5.0 期望結果 2.0 // 方法將被除數提升到更高精度再做除法 int32_t dividend (int32_t)a_q8_4 8; // 左移8位放大以便保留小數。160 8 40960 int32_t divisor b_q8_4; // 80 int32_t raw_result dividend / divisor; // 40960 / 80 512 // 現在 raw_result 的縮放因子是 2^{-(48)}? 需要分析。 // 原始 A: 值 a_int * 2^{-4} // 左移8位后: 值 (a_int * 2^8) * 2^{-4} a_int * 2^{4} // 除以 b_int 后: 值 (a_int / b_int) * 2^{4} // 而我們需要的是 (a_int * 2^{-4}) / (b_int * 2^{-4}) (a_int / b_int) * 2^{0} // 所以需要將 raw_result 右移4位以抵消之前多乘的 2^4。 int16_t result_q8_4 (int16_t)(raw_result 4); // 512 4 32 // 驗證32 * 2^{-4} 32 / 16 2.0正確。除法避坑指南除零檢查必須進行否則程序會崩潰。精度與移位量的權衡左移的位數k決定了除法運算的精度但同時也可能引起被除數的溢出。需要根據數據范圍謹慎選擇。使用查找表或近似算法在性能關鍵的場合對于固定的除數可以考慮使用預先計算好的倒數乘法表將除法轉換為乘法速度極快。3.4 溢出保護與飽和處理溢出是定點數運算的常態。處理溢出有兩種主要策略使用更大位寬在中間計算步驟中使用int32_t甚至int64_t來存放int16_t的運算結果最后再縮放到目標格式。這是最常用、最安全的方法。飽和運算Saturation當結果超出目標格式能表示的范圍時不再簡單地截斷高位這會導致正數變負數的環繞而是將其“鉗制”在該格式的最大值或最小值上。例如對于有符號Q7.8范圍約-128~128如果運算結果是150則飽和處理會將其設置為最大值127.996。許多DSP指令集和硬件模塊直接提供了飽和加法/乘法指令。在軟件中實現需要額外的比較和賦值操作。4. 定點數的實戰應用與格式設計理論懂了怎么用到實際項目中關鍵在于格式設計。4.1 設計流程以PID控制器為例假設我們要為一個直流電機速度環實現一個數字PID控制器。輸出是PWM占空比范圍0-100%。輸入是速度誤差單位轉/分鐘。分析物理量范圍速度誤差假設最大為 ±1000 RPM。積分項需要累積誤差假設最大累積值為 ±5000 RPM*秒這是一個估計值。輸出PWM0.0 到 1.0。比例系數Kp、積分時間Ti、微分時間Td通過調試確定假設Kp0.5 KiKp/Ti0.1 KdKp*Td0.02。選擇定點數格式核心原則在保證不溢出的前提下盡可能提高精度即給小數部分更多位數。我們選擇16位有符號整數int16_t作為存儲類型總位寬16。對于誤差e范圍±1000。我們需要整數部分能容納1000。2^101024所以至少需要10位整數位加上符號位1位共11位。剩下16-115位給小數。我們可以選擇Q11.5。縮放因子2^{-5}1/32。那么1000 RPM 對應的整數值為 1000 * 32 32000在int16_t范圍-32768~32767內安全。對于積分項integral范圍±5000。5000*32160000這已經超出int16_t范圍。因此積分項必須用更寬的格式比如Q16.16int32_t。或者我們可以對積分項使用不同的縮放因子例如Q19.13也是int32_t使其范圍更大。對于系數Kp, Ki, Kd它們都是小于1的小數精度很重要。我們可以用Q1.15格式即常見的Q15格式。這是16位有符號數1位符號0位整數15位小數。它能表示-1到接近1之間的高精度小數。0.5表示為 0.5 * 2^{15} 16384。對于輸出output范圍0.0~1.0。可以用UQ1.15無符號Q15或Q2.14。確定運算順序與中間格式P項 Kp * eQ1.15 * Q11.5。結果小數位15520位。需要用int32_t中間變量存放。然后根據輸出格式進行定標。I項 Ki * e * dtQ1.15 * Q11.5 * Qx.y。dt是采樣時間也是一個定點數。需要仔細設計各環節格式避免溢出和精度損失。最終各項求和得到輸出并限制在0-1.0之間飽和處理。4.2 常見問題與排查技巧實錄在實際編碼和調試定點數算法時以下是我踩過坑后總結出的經驗問題1結果出現不正確的巨大數值或正負號翻轉。排查這是典型的溢出跡象。首先檢查所有乘法操作是否使用了足夠位寬的中間變量例如兩個int16_t相乘結果必須用int32_t接收。其次檢查加法鏈特別是積分項的累積是否可能超出變量范圍。技巧在關鍵運算步驟后添加調試代碼打印出存儲的整數值和其對應的物理值。對比理論物理值看是否匹配。問題2系統控制精度不夠存在穩態誤差或抖動。排查可能是精度小數位數不足或舍入誤差累積導致。例如積分項Ki非常小用Q1.15表示時其整數值可能只有個位數乘以誤差后右移舍入時很容易被舍掉導致積分作用太弱。技巧提升關鍵路徑上數據的精度。例如將誤差e、積分項、系數全部提升到Q16.1632位進行計算僅在最終輸出時降為16位。或者對積分項使用“累加器”模式用高精度如32位變量累積只在用于計算輸出時才取部分高位。問題3不同平臺或編譯器下運算結果有微小差異。排查雖然定點數本身是確定的但移位運算和除法運算在不同編譯器下的行為可能有細微差別如對于有符號負數的右移是算術右移還是邏輯右移C語言標準未完全定義。除法的舍入方式也可能不同。技巧使用明確的、可移植的代碼。對于有符號數的右移如果希望是算術右移保留符號位使用運算符在大多數平臺上是安全的但為了絕對明確可以使用條件判斷。或者使用編譯器內置的、明確語義的函數或宏。對于除法舍入自己實現明確的舍入函數如四舍五入、向零取整等。問題4調試時看著一堆十六進制或十進制整數無法直觀理解。技巧編寫簡單的轉換函數或宏方便在整數值和物理值之間切換。// 定義格式 #define Q16_16_SCALE (1 16) // 2^16 typedef int32_t q16_16_t; // 從物理值轉換到定點數 static inline q16_16_t float_to_q16_16(float f) { return (q16_16_t)(f * Q16_16_SCALE); } // 從定點數轉換到物理值 static inline float q16_16_to_float(q16_16_t q) { return (float)q / Q16_16_SCALE; } // 在調試打印時非常有用 printf(Error: phys%f, fixed0x%08lx\n, q16_16_to_float(error_fixed), error_fixed);5. 進階話題定點數數學庫與優化當項目中的定點數運算變得復雜時手動管理格式和移位會非常繁瑣且易錯。這時可以考慮以下方案5.1 使用定點數數學庫成熟的嵌入式或DSP社區通常有優秀的定點數數學庫它們提供了經過高度優化的、類型安全的定點數運算。例如ARM CMSIS-DSP提供了豐富的q7_t,q15_t,q31_t數據類型的函數對應不同的Q格式如Q7, Q15, Q31并充分利用了ARM Cortex-M系列處理器的SIMD和飽和運算指令。libfixmath: 一個開源的定點數數學庫實現了三角函數、指數、對數等復雜函數。自己封裝對于特定項目可以封裝一個簡單的定點數類或結構體重載運算符讓代碼看起來像浮點數一樣簡潔但底層是定點運算。5.2 硬件加速與指令集現代許多微控制器和DSP都帶有助力定點數運算的硬件特性飽和指令如ARM的QADD,QSUB。乘加指令MAC單周期內完成乘法并累加是濾波器、矩陣運算的核心。桶形移位器與ALU并行可以在數據進入運算單元前或后快速完成移位定標。單指令多數據SIMD允許同時對多個定點數進行相同操作大幅提升吞吐量。在編寫性能關鍵代碼時查閱芯片的數據手冊和編譯器 intrinsics內聯函數直接調用這些硬件指令可以獲得數量級的性能提升。5.3 動態定點數在某些場景下數據的動態范圍可能變化很大靜態的定點數格式難以兼顧。這時可以考慮動態定點數。其基本思想是存儲一個整數和一個獨立的、可變的指數縮放因子。運算時需要動態調整操作數的指數使其對齊。這有點像簡化版的浮點數但通常指數變化范圍小且運算規則可以自定義以優化性能。它比純定點數復雜但比標準浮點數高效是兩者之間的一個折中方案。我個人在音頻處理項目中就曾大量使用定點數。將一個浮點版本的音頻濾波器如二階IIR移植到定點數實現最初被溢出和精度問題折磨得不輕。后來嚴格遵循了“分析范圍-設計格式-寬中間變量-飽和處理”這個流程并用宏和調試函數武裝自己才讓代碼穩定下來。實測在一顆沒有FPU的100MHz Cortex-M4內核上定點數版本的濾波器比軟件浮點版本快了近20倍并且功耗顯著降低。這種將控制權從硬件交還給程序員的感覺雖然增加了前期設計負擔但帶來的性能收益和確定性是浮點數無法比擬的。當你需要從系統中榨取最后一滴性能或者確保不同設備上算法行為完全一致時定點數是你必須掌握并信賴的工具。