C語言實用之道

qinghuawenkang發表於2018-10-25


大資料應用與技術叢書
C 語言實用之道
[美] Giulio Zambon 著
潘愛民 譯
北 京
-2 011-7430
Giulio Zambon
Practical C
EISBN 978-1-4842-1768-9
Original English language edition published by Apress Media. Copyright © 2016 by Apress
Media. Simplified Chinese-Language edition copyright © 2018 by Tsinghua University
Press. All rights reserved.
本書中文簡體字版由 Apress 出版公司授權清華大學出版社出版。未經出版者書面許可,不
得以任何方式複製或抄襲本書內容。
北京市版權局著作權合同登記號 圖字: 01-2017-5758
本書封面貼有清華大學出版社防偽標籤,無標籤者不得銷售。
版權所有,侵權必究。侵權舉報電話: 010-62782989 13701121933
圖書在版編目(CIP)資料
C 語言實用之道 / (美)朱里奧·贊博(Giulio Zambon) 著;潘愛民 譯. —北京:清華大
學出版社, 2018
書名原文: Practical C
ISBN 978-7-302-49904-6
Ⅰ. ①C… Ⅱ. ①朱… ②潘… Ⅲ. ①C 語言-程式設計 Ⅳ. ①TP312.8
中國版本圖書館 CIP 資料核字(2018)第 055459 號
責任編輯 :王 軍 李維傑
裝幀設計 :孔祥峰
責任校對 :曹 陽
責任印製 :李紅英
出版發行 :清華大學出版社
網 址
地 址 :北京清華大學學研大廈 A 座 郵 編 : 100084
社 總 機 : 010-62770175 郵 購 : 010-62786544
投稿與讀者服務 : 010-62776969, c-service@tup.tsinghua.edu.cn
質 量 反 饋 : 010-62772015 zhiliang@tup.tsinghua.edu.cn
印 裝 者: 三河市金元印裝有限公司
經 銷 :全國新華書店
開 本 : 170mm×240mm 印 張 : 32.5 字 數 : 637 千字
版 次 : 2018 年 5 月第 1 版 印 次 : 2018 年 5 月第 1 次印刷
印 數 : 1~4000
定 價 : 98.00 元
———————————————————————————————————————
產品編號: 075900-01

中文版序
這是一本講述 C 語言實踐的書, 作者以自身的實踐和思考來展示 C 語言程式設計
中的基礎概念和典型使用場景。 C 語言本身簡潔而又靈活,有強大的表達能力,
幾乎可以實現迄今為止所有能夠想象到的計算能力。 然而,越來越多的程式設計師在
棄用 C 語言, 改而學習更具生產效率的程式語言。 很多人提出的一個問題是, 學
了C語言有什麼用?從現實的角度,用 C 語言編寫的、新的大型軟體越來越少,
但是, 一些關鍵的軟體往往離不開 C 語言, 如圖形引擎、 網路協議等一些效能關
鍵的模組,當然少不了像作業系統和驅動程式之類的最底層軟體。因此, C 語言
在各種程式語言排行榜上始終排在前列。另外, C 語言也適合一些“小而美”的
程式,在本書中可以看到這樣一些例子。以我個人之見, C 語言是最貼近計算機
工作原理的高階語言,並且 Internet 上有豐富的文件和程式碼積累,每一個對計算
機工作原理有好奇心的 IT 從業人員都應該掌握 C 語言。
本書內容涵蓋兩大部分。 第一部分介紹 C 語言程式設計中的基本概念和程式設計
基礎(前 7 章),涉及變數、宏、結構、本地化、寬字元、整數和浮點數的表達形
式等基本語言層面的概念和要點(第 2 章), 也包括迭代、 遞迴、 連結串列、 棧、 佇列、
異常等程式設計中廣泛使用的設計元素(第 3 至第 5 章),同時作者還完整剖析了
兩個實用案例: 字串(第 6 章)和動態陣列(第 7 章)。 第二部分是用 C 語言來完成
特定領域中的開發示例,包括搜尋(第 8 章)、排序(第 9 章)、數值積分(第 10 章)、
嵌入式軟體開發(第 11 章)、 嵌入資料庫功能(第 12 章)、 嵌入 Web 伺服器(第 13 章)
以及遊戲應用開發(第 14 章)。即使讀者在實踐中不需要涉獵如此廣泛的應用範圍,
透過閱讀這些章,也可以瞭解到 C 語言在這些領域中是如何被使用併發揮作用的。
這不是一本教科書, 但是其內容非常適合學習 C 語言, 並且作者的敘述風格
也很有特色,他直接以第一人稱和第二人稱來講解書中的內容, 就好像在課堂上
傳授 C 語言的開發經驗。 本書在表達上有明顯的口語化特點, 相信在閱讀時會有
一種親切感,學習也相對要輕鬆一些。然而, 本書透過大量的例子和程式碼來講解
概念和技巧, 這確保了本書內容的嚴謹性, 並且不少程式碼非常有啟發意義。 此外,
本書的程式碼又是成體系的,前後一致性比較好,如第 6 和第 7 章中講到的字串
和動態陣列元件, 在後面的章節中也都用到了。從這個角度看, 這本書又非常適
合作為課程參考材料, 教師在講解了 C 語言基礎知識以後, 可以成體系地引入這
本書中的內容。

C 語言實用之道
我已經很多年沒有接手翻譯或著書的工作了, 當王軍編輯向我推薦這本書時,
無論是內容, 還是作者的經歷, 都引起我的共鳴。 作為一名 C 程式設計師老兵, 我看
到每一個標準 C 函式都有一種親切感。 基於這樣的心情, 我答應王軍編輯翻譯這
本書。 經過一年來的努力,終於完成了翻譯工作。原著中有一些筆誤,我在翻譯
過程中修正了一些,但我相信,翻譯本身難免也會引入新的錯誤,雖然經過兩遍
校對,但還會留有錯誤,請讀者諒解。
潘愛民
2017 年 12 月於杭州
作者簡介
Giulio Zambon 最初喜愛的是物理, 但是三十年前
他決定還是專注於軟體開發,當時計算機是由電晶體和
核 心 存 儲 體 構 成 的 , 程 序 還 是 打 在 卡 上 的 , 並 且
FORTRAN 還只有算術 IF。多年來, 他學習了很多種計
算機語言,與各種作業系統打交道。 他對電信和實時系
統特別有興趣,他曾經管理過好多個專案,都順利地完
成了。
在 Zambon 的職業生涯中,他去過五個不同國家的
八個城市,曾任軟體開發人員、系統顧問、過程改進經理、 專案經理和首席運營
官。 自 2008 年初以來, 他住在澳大利亞堪培拉以北幾公里處的寧靜的郊區, 在這
裡他致力於他的許多興趣,特別是編寫軟體來生成和解決數字難題。訪問他的網
站 , 可以看到他撰寫的論文和所著書籍的完整列表。



目 錄
第 1 章 引言······································· 1
1.1 編碼風格································ 1
1.1.1 縮排 ···································· 2
1.1.2 命名和其他規範················· 4
1.1.3 goto 的使用 ························ 5
1.2 如何閱讀本書 ························· 7
第 2 章 微妙之 C································ 9
2.1 變數的作用域和生命週期······ 9
2.1.1 區域性變數······························ 9
2.1.2 全域性變數··························· 13
2.1.3 函式 ·································· 14
2.2 按值呼叫······························· 15
2.3 前處理器宏 ··························· 18
2.4 布林值··································· 19
2.5 結構打包······························· 22
2.6 字元和區域 ··························· 24
2.7 普通字元和寬字元 ··············· 27
2.8 處理數值······························· 32
2.8.1 整數··································· 32
2.8.2 浮點數······························· 34
2.9 本章小結······························· 54
第 3 章 迭代、遞迴和二叉樹············ 55
3.1 迭代······································· 55
3.2 遞迴······································· 57
3.3 二叉樹··································· 59
3.3.1 圖形化顯示一棵樹 ··········· 65
3.3.2 生成一棵隨機樹 ··············· 83
3.3.3 遍歷一棵樹 ······················· 88
3.3.4 更多關於二叉樹的內容 ····· 93
3.4 本章小結······························· 95
第 4 章 列表、棧和佇列 ·················· 97
4.1 列表······································· 98
4.2 棧 ·········································· 99
4.2.1 基於陣列的棧···················· 99
4.2.2 基於連結串列的棧················· 109
4.3 佇列····································· 113
4.3.1 基於陣列的佇列············· 114
4.3.2 基於陣列的佇列的更多
內容································
120
4.3.3 基於連結串列的佇列············· 126
4.4 本章小結····························· 130
第 5 章 異常處理 ··························· 133
5.1 長跳轉 ································ 134
5.2 THROW ······························ 135
5.3 TRY 和 CATCH ·················· 136
5.4 多個 CATCH······················· 144
5.5 多個 TRY···························· 145
5.6 異常用法樣例····················· 149
5.7 本章小結····························· 152
第 6 章 字串輔助功能 ················ 153
6.1 字串的分配和釋放 ········ 154
6.1.1 str_new( )······················· 155
6.1.2 str_release( )··················· 159
6.1.3 str_release_all( )············· 161
6.1.4 str_list( ) ························ 162
6.1.5 一些例子 ······················· 163
6.1.6 多個棧 ························· 166
C 語言實用之道
6.2 字串格式化 ····················· 169
6.3 字串資訊 ························· 171
6.4 字串更新 ························· 173
6.4.1 字串複製····················· 173
6.4.2 字串轉換····················· 176
6.4.3 字串整理····················· 177
6.4.4 字串移除····················· 179
6.5 搜尋····································· 181
6.5.1 找到一個字元················· 181
6.5.2 找到一個子串················· 186
6.6 替換····································· 189
6.6.1 替換一個字元················· 189
6.6.2 替換一個子串················· 191
6.7 提取一個子串 ····················· 193
6.8 拼接字串 ························· 196
6.9 更多功能····························· 200
6.10 本章小結··························· 201
第 7 章 動態陣列···························· 205
7.1 陣列的分配與釋放 ············ 205
7.1.1 分配一個陣列················ 206
7.1.2 釋放一個陣列················ 208
7.1.3 多個棧···························· 212
7.2 改變一個陣列的大小 ········· 215
7.3 陣列的複製和複製 ············· 219
7.4 選擇陣列元素 ····················· 222
7.5 本章小結····························· 225
第 8 章 搜尋··································· 227
8.1 比較···································· 227
8.1.1 C 語言的標準比較
函式 ·······························
227
8.1.2 比較結構 ······················· 230
8.1.3 比較陣列 ······················· 232
8.1.4 模糊化 ··························· 232
8.2 搜尋····································· 238
8.2.1 未排序的整數陣列········· 238
8.2.2 未排序的指標陣列········· 246
8.2.3 排序的陣列 ···················· 251
8.2.4 連結串列與二叉搜尋樹········· 257
8.3 本章小結····························· 277
第 9 章 排序 ·································· 279
9.1 插入排序····························· 279
9.2 希爾排序····························· 280
9.3 氣泡排序····························· 285
9.4 Quicksort(快排)··················· 286
9.5 整數陣列····························· 296
9.6 標準 C 函式 ························ 298
9.7 本章小結····························· 301
第 10 章 數值積分 ························· 303
10.1 從單變數函式開始··········· 303
10.2 梯形規則 ·························· 306
10.3 Simpson 規則 ··················· 310
10.4 Newton-Cotes 公式··········· 313
10.5 決定何時停止 ·················· 317
10.6 奇點·································· 321
10.7 蒙特卡洛 ·························· 324
10.8 3D 積分 ···························· 329
10.8.1 積分域························ 330
10.8.2 從 2D 的梯形到 3D 的
稜柱 ····························
331
10.8.3 改進稜柱規則 ············ 336
10.8.4 將矩形規則轉
換成 3D······················
340
10.9 多重積分的最後一些
考慮·································· 342
10.10 本章小結 ························ 343
第 11 章 嵌入式軟體······················ 345
11.1 位操作 ······························ 346

11.2 端 ······································ 349
11.3 嵌入式環境······················· 351
11.3.1 裸主機板 ························ 351
11.3.2 實時 OS(RTOS)·········· 352
11.3.3 高階 OS······················ 353
11.4 訊號和中斷······················· 353
11.5 併發性 ······························ 365
11.6 本章小結··························· 371
第 12 章 資料庫 ····························· 373
12.1 MySQL ····························· 374
12.1.1 使用 CLI建立和填充一個
資料庫························
374
12.1.2 MySQL Workbench ···· 380
12.1.3 在 C 程式中使用
MySQL·······················
382
12.2 SQLite······························· 395
12.2.1 在 CLI 中使用 SQLite··· 398
12.2.2 在 C 程式中使用
SQLite ························
399
12.2.3 使用動態字串和
陣列····························
404
12.3 本章小結························· 408
第 13 使用 Mongoose 開發 Web
伺服器······························
409
13.1 Web 頁面和協議··············· 409
13.2 動態 Web 頁面 ················· 413
目 錄
13.3 最簡單的支援 Web
伺服器的應用程式··········· 413
13.3.1 事件處理器函式 ········ 415
13.3.2 主程式························ 416
13.4 支援 Web 伺服器的應用
程式·································· 416
13.4.1 靜態變數···················· 419
13.4.2 main( )························ 420
13.4.3 e_handler( )、 get_x( )和
send_response( ) ·········
420
13.4.4 index.html··················· 423
13.5 定製 Mongoose················· 428
13.6 本章小結 ·························· 431
第 14 章 遊戲應用:
MathSearch ····················
433
14.1 MathSearch 規範和設計··· 434
14.1.1 MathSearch 規範······ 434
14.1.2 MathSearch 設計···· 435
14.2 實現 MathSearch ·············· 437
14.3 模組: count ····················· 456
14.4 模組: display··················· 457
14.5 模組: save_html ·············· 464
14.6 模組: save_images ·········· 470
14.7 本章小結 ·························· 475
附錄 A 縮寫詞 ······························· 477
附錄 B SQL 介紹··························· 483

引 言
第 1 章
因為這是一本介紹 C 語言使用訣竅的書,所以這裡不會有關於 C 語言的描述。
不過, 為了保證我們處在同一個頻道上, 有時候我會引入一些對於語言特性的簡
短描述。第 2 章將涵蓋一些通常招致錯誤的 C 語言特性。
關於 C 語言的介紹, 可以參考經典的 Ivor Horton 編著的《C 語言入門經典(第
5 版)》,以及大量的關於這一主題的其他書籍。
我開發了本書中講述的所有程式,使用 gcc(GNU Compiler Collection) 4.8.4 版本
和 Eclipse 開發環境(4.5.0 釋出版),在一臺 64 位膝上型電腦上執行 Linux-GNU
Ubuntu 14.04 LTS 版本。
C 標準的當前版本是 ISO/IEC 9899:2011, 通常稱為 C11,它擴充套件了 C 標準的
上一個版本(ISO/IEC 9899:1999,稱為 C99)。 gcc 的 C 編譯器支援 C99 和 C11。關
於 gcc 選項中涉及 C 語言版本的完整列表, 可以參考 gcc.gnu.org/onlinedocs/gcc/CDialect-Options.html。
為了編譯本書中的絕大多數程式碼, 需要使用-std=c99 選項, 因為我使用了類似於
Java 的 for 迴圈格式,迴圈控制變數的定義包含在 for 語句中。例如:
for (int k = 0; k < N; k++)
以前版本的 C 語言要求在 for 語句之外定義控制變數,如下所示:
int k;
for (k = 0; k < N; k++)
1.1 編碼風格
編碼風格由幾個方面構成。由於在本書的例子中使用了我的編碼風格,因此
透過理解這一編碼風格,你可以更容易地理解我的程式碼。

1.1.1 縮排
看一下本書附帶的原始碼,可以注意到, 語句結束處所有的右花括號都縮排
排列,如下面的程式碼清單 1-1 所示。
程式碼清單1-1 作者的編碼風格
1. void dar_list(Dar *stk) {
2. if (stk == NULL) {
3. printf("Nothing to list\n");
4. }
5. while (stk != NULL) {
6. printf("%p %zu %zu\n", stk, stk->size, stk->n);
7. stk = stk->down;
8. }
9. }
第 1、第 2 和第 5 行的左花括號緊跟在前面一行, 而第 4、第 8 和第 9 行的右
花括號縮排排列。 這種風格算是與眾不同, 因為 Eclipse 和其他的開發環境預設並
不認識這種風格, 但它們對另外兩種廣為使用的風格(如程式碼清單 1-2 和程式碼清單
1-3 所示)卻完全支援。
程式碼清單1-2 展開式的編碼風格
1. void dar_list(Dar *stk)
2. {
3. if (stk == NULL)
4. {
5. printf("Nothing to list\n");
6. }
7. while (stk != NULL)
8. {
9. printf("%p %zu %zu\n", stk, stk->size, stk->n);
10. stk = stk->down;
11. }
12. }
程式碼清單 1-2 中的展開式風格真的是展得太開了。除了需要更開闊的視野才
能跟上程式碼流的邏輯以外,這種風格也給人造成了這樣的印象: 塊語句相對前面
的條件或迭代語句是獨立的。 例如, 需要注意到第 3 行的 if 並非跟著一條簡單語
句和分號, 這樣可以強調只有當 if 條件為真的時候, 此塊語句才會被執行。 這種
風格也有一個概念性問題,在後面關於程式碼清單 1-3 的解釋中再說明這一問題。
很顯然,如果把程式碼行數作為要達成的目標,那麼你可能會喜歡這種風格!

第 1 章 引 言
程式碼清單1-3 緊湊型的非縮排編碼風格
1. void dar_list(Dar *stk) {
2. if (stk == NULL) {
3. printf("Nothing to list\n");
4. }
5. while (stk != NULL) {
6. printf("%p %zu %zu\n", stk, stk->size, stk->n);
7. stk = stk->down;
8. }
9. }
程式碼清單 1-3 中顯示的風格可能最為常用。 這種風格很不錯, 但是在我看來,
它有兩個問題:一個是概念性的,另一個是實用性的。
概念性的問題在於:用於分隔塊語句的左右花括號屬於塊語句本身, 塊語句
縮排了, 那麼為什麼右花括號卻沒有跟著縮排(在展開式風格的情況下, 左花括號
也有同樣的問題)?例如, 程式碼清單 1-3 中第 8 行的右花括號屬於從第 5 行開始的
塊語句, 這一塊語句包含 printf 和對 stk 的賦值。 於是, 第 8 行的右花括號應該放
在 stk 的 s 的下面,而不是放在 while 的 w 的下面。
實用性的問題在於:透過不縮排右花括號,你可能獲得了視覺上的純淨,我
將之稱為“石板效果”。 為了演示什麼是石板效果, 我擷取了程式碼清單 1-1 的螢幕
快照,再加上陰影,形成了圖 1-1。

圖 1-1 本書編碼風格的石板效果
正如你所看到的, 凡是依賴於 if 條件的所有內容都“掛” 在 if 語句後面, 凡
是包含在 while 迴圈中的所有內容都掛在 while 語句後面。 毫無疑問, 這使得閱讀
程式碼更加容易。
關於多個 if 和 else,再多說幾句。
我通常看到 if 和 else 串成這樣:
if (condition 1) {
...
} else if (condition 2) {
...
} else {
...
}
這有可能從圖形上看起來非常舒適和緊湊, 但是, 它並沒有反映出這些 if 和 else
未被顯示在同一層次上的事實(它們應該是同一層次的)。 下面看看我如何處理這樣的
程式碼片段:
if (condition 1) {
...
}
else if (condition 2) {
...
}
else {
...
}
1.1.2 命名和其他規範
本書包含了幾個函式庫,每個都由一個 C 檔案和對應的標頭檔案(例如 string.c
和 string.h)構成。 每個庫都被概括描述成少數幾個標識性的字母(例如 str), 用這些
字母作為所有匯出的宏、變數和函式的字首。 宏常量(即不帶引數的宏)用大寫字
母 ( 比 如 STR_LOG) 表 示 , 而 像 函 數 類 型 的 宏 , 只 有 前 綴 是 大 寫 的 ( 例 如
STR_crash( ))。在匯出的變數和函式的名稱中,字首部分用小寫(例如 str_stack 和
str_list( ))。
絕大多數整型變數的名稱以一個從 i 到 n 的字母開頭,特別是如果這些名稱
很短的話。 這是我最初使用計算機時養成的習慣: 我學習的第一門計算機語言(40
多年前)是 FORTRAN, 它自動把以這些字母開頭的變數識別成 INTEGER 型別。
我必須承認,我並不完全遵從這條規則來命名整型變數, 但是, 你在我的程式碼中
絕對找不到以任何一個“整型” 字母開頭的非整型變數。 很簡單, 我不會那麼做。
如果看到一個帶有多個函式的模組,你可能會注意到,這些函式以字母順序
排列。 所以,無須搜尋功能就可以立即找到函式。對於非匯出的函式,為了做到
無須關心它們在模組中的實際位置就可以引用它們, 我在 C 檔案的開始處宣告這
些函式。
同樣, 為便利起見,我在每個函式的最前面寫一行註釋, 函式的名稱位於右
側,如下所示:
//---------------------------------------------- str_clean_up
當程式碼流要被中斷的時候,也使用類似的規範,如下所示:

if (str == NULL) return; //-->
透過將函式和退出點在右端單獨標記出來,使得它們更易於被辨認出來。
這帶來了另一個規範: 每一程式碼行永遠不要超過 80 個字元。 這也是我早年編
程經歷留下來的另一個習慣, 當時我在穿孔卡上鍵入 FORTRAN 程式,而穿孔卡
只有 80 列。 更進一步, 精確而言, 只有 6 至 72 列可被用於可執行程式碼(如果好奇
的話,第 1 列用來標明是否註釋,第 2 至第 4 列用於標記,第 5 列標明續行,第
73 至第 80 列是卡片編號)。 為了使程式碼有更好的可讀性, 80 列看起來是一個合理
長度。
無論如何, 在程式設計中重要的事情並不是採用了哪些規範, 而在於是否始終如
一地堅持規範。只要遵守規範並保持始終如一,就可以使程式碼更易於理解, 更易
於維護。
我認為,遵守規範的這種紀律和堅持,是優秀程式設計師的內在品質。在培訓新
程式設計師的時候,我甚至會檢查:語句內的空白間隔是否在整個模組中保持一致,
在任何一行的尾部有沒有多餘的空格, 更不能使用製表符。 現在, 開發環境會自動
剔除尾部的空格, 但是“不使用製表符” 這一規則仍然是有用的, 例如當要把一部
分程式碼貼上到文件中時。


1.1.3 goto 的使用
當我學會程式設計的時候,還沒有塊語句(block statement)。因此,實現塊語句的
唯一辦法是使用 goto。 例如,如下程式碼結構:
if (condition) {
// condition satisfied
...
}
else {
// condition not satisfied
...
}
// whatever
...
在 FORTRAN 中的做法, 類似如下(在 20 世紀 70 年代早期, FORTRAN 嚴格
大寫,並且記住,從第一列開始的行是註釋):
IF (condition) GOTO 1
C CONDITION NOT SATISFIED
...
GOTO 2
5
C 語言實用之道
C CONDITION SATISFIED
1 ...
C WHATEVER
2 ...
C 語言中等價於上述 FORTRAN 的程式碼如下:
if (condition) goto yes;
// Condition not satisfied.
...
goto done;
// Condition satisfied.
yes: ...
// whatever
done: ...
沒有人會用這種方式來使用 C 語言, 但是, 在結構化語言中圍繞著使用 goto
是不是一種禁忌,尚無定論(可能禁忌根本不存在,但我們就不跑題了)。例如,
考慮下面的程式碼:
if (condition_1) {
// Satisfied: 1.
...
if (condition_2) {
// Satisfied: 1 and 2.
...
if (condition_3) {
// satisfied: 1, 2, and 3.
...
if (condition_4) {
// satisfied: 1, 2, 3, and 4.
...
// Here a big chunk of code happens to follow
} // condition_4
} // condition_3
} // condition_2
} // condition_1
你要往右走多遠?每多出一個 if, 都把中間部分的大塊程式碼往右移一點。你
能用這種方式來處理 10 個 if 條件嗎?我不會這麼做。下面是使用 goto 的做法:

if (!condition_1) goto checks_done; //-->
// Satisfied: 1
...
6


if (!condition_2) goto checks_done; //-->
// Satisfied: 1 and 2
...
if (!condition_3) goto checks_done;
//-->
// Satisfied: 1, 2, and 3
...
if (!condition_4) goto checks_done;
//-->
// Satisfied: 1, 2, 3, and 4
...
checks_done:
//<--


...
// Here a big chunk of code happens to follow
這只是一個例子,用於說明在這樣的場合下你可能會考慮使用 goto。 然而,
有可能它們並不恰當。 我只是在說明我的觀點:出於感情因素, 而不是理性和實
用的因素來拒絕使用一種有效的語言結構,這是錯誤的。
1.2 如何閱讀本書
在本書中, 當一章依賴於前面章節中介紹的資訊時, 可以找到對前面相關章
節的引用。 因此, 你總是可以安全地跳過那些你當下認為沒有幫助的章節。 換句
話說, 可以聚焦在那些對於你當前正在開發的程式碼有幫助的章節上, 而無須按順
序閱讀本書。
第 2 章“微妙之 C”,討論 C 語言中經常被誤解的以及可能引入莫名其妙錯誤的
那些特性。
第 3 章“迭代、遞迴和二叉樹”,介紹遞迴技術和二叉樹。
第 4 章“列表、 棧和佇列”, 幫助你在表達專案集合時從多種可能的方法中進
行選擇。
第 5 章“異常處理”, 告訴你如何捕捉執行時發生的問題, 而不是簡單地讓程
序崩潰。
第 6 章“字串輔助功能”,講述一種動態分配字串的方法, 而不是在編譯時
靜態分配。
第 7 章“動態陣列”, 相對於第 6 章中講述的針對字串的一些函式, 改編為
可適用於通用的陣列(畢竟,字串只不過是以 null 結尾的字元陣列而已)。
第 8 章“搜尋”,講述線性搜尋和二分搜尋,以及如何使用二叉搜尋樹。
第 9 章“排序”,介紹對一組無序專案進行排序的各種技術。

第 10 章“數值積分”,講述在一條點畫線的下面求面積以及在一個面的下面
求體積的數值化方法。
第 11 章“嵌入式軟體”, 討論在編寫操縱硬體的實時軟體時需要考慮的一些
特殊事項。
第 12 章“資料庫”,介紹如何在 C 語言中操作 SQL 資料庫。
第 13 章“使用 Mongoose 開發 Web 伺服器”, 講述如何在程式中嵌入一個
Web 伺服器。
第 14 章“遊戲應用: MathSearch”, 講述如何開發一個生成數字迷宮的程式。
附錄 A 列出了本書用到的所有縮寫,包括首字母縮寫。
附錄 B 概要摘錄了用於控制資料庫的 SQL 命令。

第 2 章
C 語言包含的一些特性常常被誤解,因而會引發一些問題或者意料之外的結
果。本章討論這些微妙之處。
2.1 變數的作用域和生命週期
變數的作用域(scope)定義了在哪裡可以使用該變數;而變數的生命週期(life)
則定義了什麼時候可以使用該變數。這兩個方面不是獨立的。它們代表不同的方
式,用來說明一個變數如何維護它的有效性。
廣義而言, C 語言支援兩種型別的變數:區域性變數和全域性變數。
2.1.1 區域性變數
區域性變數是在一個函式或塊語句的內部定義的。可以從它們被定義的那一行
開始, 直到該函式或塊語句的結束花括號為止, 使用區域性變數。 考慮程式碼清單 2-1
中展示的小函式(現在它並不精巧,以後會更加精巧)。
程式碼清單2-1 一個小函式
1. int multi_sum(int n1, int n2, int do_mult) {
2. int retval = n1;
3. if (do_mult) retval *= n2;
4. else retval += n2;
5. return retval;
6. }
變數 n1、n2、do_mult 和 retval 都是區域性變數,但三個形式引數(n1、n2 和 do-mult)
在該函式內部的任何地方(即, 從第 2 行到第 5 行)都是有效的, 而 retval 變數只從

C 語言實用之道
第 3 行開始才有效。
為了儲存這些動態變數的值而需要的記憶體,是在該函式被呼叫的時候,在程
序的棧上分配的。 然後,當函式返回的時候, 棧指標被移回該函式呼叫之前的位
置,這些變數都超出了它們的作用域。也就是說,實際上,它們都不再存在。
當該函式被呼叫的時候,對於每個引數, 都從程式的棧上分配一個變數,對
應的引數的值被複製到變數中。
例如,如果以如下方式呼叫該函式:
int n1 = 3;
int result = multi-sum(n1, 5, 1);
那麼在函式中, n1 區域性變數包含 3, n2 包含 5, do_mult 包含 1。
同樣的名稱 n1 既可以在函式外面使用, 也可以在函式內部使用, 這並不意味
著該名稱只代表一個變數:這兩個 3 被儲存在不同的位置。
這意味著可以重寫該函式,如下所示:
int multi_sum(int n1, int n2, int do_mult) {
if (do_mult) n1 *= n2;
else n1 += n2;
return n1;
}
這不會影響函式之外的 n1 所儲存的值。
如果一個函式返回一個指向某個區域性變數的指標,那麼這可能會引起嚴重的
問題。 編譯器會檢查你是否做了類似的事情, 但是隻會發出一條警告。例如, 如
果編譯一個如下的函式:
int *ptr(int n) { return &n; }
編譯器會發出如下警告:
warning: function returns address of local variable [-Wreturn-local-addr]
但是, 可以忽略該警告(儘管你永遠不應該忽略任何警告! )。 在任何情況下,
其實編寫出不讓編譯器發出警告的程式碼也十分容易(就是不讓函式返回的指標指
向區域性變數):
int *ptr(int n) {
int *p = &n;
return p;
}
如果用下面這樣的方式來執行這個有點危險的函式 ptr( ):
10
第 2 章 微 妙 之 C
int main(void) {
int *nn = ptr(7);
printf("%d\n", *nn);
}
程式將會在控制檯上列印出 7,正如預期的那樣。但是, 定義一個無足輕重
的、什麼也不幹的如下函式:
void nothing(int n1) { int n2 = n1; }
並且將它放在執行 ptr( )函式和列印 nn 的程式碼之間, 如程式碼清單 2-2 所示, 你
將大吃一驚。該程式將會列印出 10 而不是 7,儘管你根本沒有去碰 nn。
程式碼清單2-2 一個小程式
int main(void) {
int *nn = ptr(7);
nothing(10);
printf("%d\n", *nn);
}
這是因為, 執行這個什麼也不做的函式時, 又重新使用了 nn 所指向的動態地
址,因此改變了它的內容。
很顯然,如果返回函式中任何一個區域性變數的地址, 而不管該區域性變數是否
如上例所示是輸入引數,都會有同樣的問題發生。例如,下面的程式碼也不工作:
int *ptr(int n) {
int val = n;
return &val;
}
但是,如果將區域性變數變成靜態的,如下所示:
int *ptr(int n) {
static int val;
val = n;
return &val;
}
那麼程式碼清單 2-2 中的程式將列印出 7。 這是因為, 將儲存類 static 應用在一
個區域性變數上,使得編譯器到編譯時才在程式的資料段中為它分配空間。這樣的
變數隨著程式的生命週期一直存在,因而能夠保持住它的值。由於靜態變數的存
在, 使得該函式不是可重入的(在後面的一章中, 當討論到併發性時將進一步討論
重入問題),但是它允許你透過返回其地址的方式來擴充套件它的作用域。
雖然這樣使用靜態區域性變數的方式不會引發直接的問題, 但帶來的束縛是:
程式碼更難理解和維護。 這不是我願意推薦的做法。更有甚者,它將允許你在一個
11
C 語言實用之道
函式之外修改該函式的一個區域性變數的值。
靜態區域性變數通常被用於在一個函式的連續兩次執行之間保持一個值不變,
或者從另一個角度來描述,將一個函式一次執行後得到的值,傳遞給它的下一次
執行。
需要記住的很重要的一點是: 雖然編譯器並不初始化動態區域性變數, 但是對
於靜態區域性變數, 它會清除它們的值——將數值型別設定為 ,將字元型別設定
為'\0',將指標設定為 NULL。
也就是說, 好的實踐是:在任何情況下都要初始化所有變數。利用通用的初
始化器{0}, 可以很容易地將任何變數初始化為零,如下所示:
anytype simple_var = {0];
anytype array[SIZE] = {0};
anytype multi_dim_array[SIZE1][SIZE2][SIZE3] = {0};
簡而言之, 區域性變數預設僅在它們被定義的程式碼塊中才有效,並且只有當代
碼塊啟用的時候它們才存在。 如果它們被定義成靜態的, 那麼它們存在於程式的
執行過程中,並且可以在它們被定義的程式碼塊之外訪問它們,不過, 在實踐中這
樣的訪問是不鼓勵的。
2. 限制區域性變數的作用域
所有的程式設計師都會犯錯。錯誤被越早檢測到, 通常修復起來越容易。 最好的
做法是:應該儘量在編譯時或者在程式執行之前,找到儘可能多的錯誤。其中一
種做法是,將區域性變數的作用域限制到最小。
這是因為, 當在一個大的程式碼塊中針對不同的用途而使用一個變數的時候,
很有可能會忘記重新初始化該變數(本該重新初始化)。
針對不同的用途使用不同的變數, 可以讓你更加恰當地命名每個變數,因此
提高程式碼的可讀性和可維護性。
更進一步, 如果在本地定義大的陣列,它們可能會在程式棧中堆積起來,盡
管在桌面系統或膝上型電腦上執行的程式的可用記憶體在不斷增加,但是這些大數
組可能會“暴跳起來(hit the ceiling)”,引發程式崩潰。
為了限制一個變數的作用域, 你需要做的是, 將它的定義和使用它的程式碼括
在由花括號分隔的塊語句中:
...
{ // here the block begins
double d_array[N];
...
} // here the block ends and the stack space used up by d_array is recovered
12
第 2 章 微 妙 之 C
將一段程式碼用花括號括起來, 這種做法也可以鼓勵你將程式碼儘可能靠近它所
用到的至少某些變數。
從 C99 開始,可以在 for 語句的內部定義 for 迴圈的控制變數:
for (int k = 0; k < 5; k++) {
...
}
需要使用-std=c99 選項來編譯本書所有的程式碼, 因為我總是用到這一特性。
也可以將 for 迴圈括在一個塊語句中, 從而限定 for 迴圈的控制變數, 這樣可以用
任何版本的 C 語言,如下面的例子所示:
{
int k;
for (k = 0; k < 5; k++) {
...
}
}
這樣做不會讓你的程式碼變慢。
gcc 支援 C 標準的幾個版本(關於完整的列表,可以檢視 gcc.gnu.org/onlinedocs/
gcc/C-Dialect- Options.html), 可以使用-std 選項來選擇 C 標準的版本。 預設情況下,
gcc 認為 C 程式碼遵守 ISO 9899 標準的 1990 發行版(因而使用-std=c90 選項實際上
是不必要的)。 ISO C 標準最新支援的版本是 2011(不過,在寫作本書時,還不是
100%支援), 可以透過編譯器的-std=c11 選項來選擇該版本。 C 標準的新發行版不
僅增加編譯器的特性,而且也往往廢棄一些以前舊發行版的某些特性。
2.1.2 全域性變數
全域性變數是指那些定義在函式外面的變數。它們預設都是被匯出的, 之所以
是全域性的, 是因為它們在程式中的任何地方都可以訪問。 在程式的整個生命週期
中,它們都保持有效。
使用關鍵字 extern 通常會導致混淆。為了理解這一關鍵字,需要理解變數的
定義和宣告之間的區別。
變數的定義是指: 指示編譯器為一個變數分配記憶體。 而變數的宣告是指:告
訴編譯器,將要使用一個變數,而該變數已經被定義在其他某個地方。
例如:
int ijk[5] = {0};
上述定義告訴編譯器, 為一個包含五個整數的陣列分配記憶體,並且為它的第
13
C 語言實用之道
一個位置賦予名稱 ijk。 如果在任何函式的外面定義 ijk,那麼 ijk 是靜態分配的變
量,在整個程式中都可以使用。
如果該程式包含幾個模組, 其中某一個模組(不是定義 ijk 的那個模組)需要訪
問 ijk,那麼需要在這個模組內宣告 ijk:
extern int ijk[5];
注意, 只能在定義變數的地方對變數進行初始化。雖然可以直接在需要訪問
變數的模組內部寫上變數宣告,但是一般推薦的做法是: 把宣告寫在一個標頭檔案
中,檔名稱類似於定義變數的那個 C 檔案。 例如,如果 ijk 定義在 whatever.c
檔案中,那麼可以把宣告寫在 whatever.h 中。然後,你所需要做的就是,在需要
使用 ijk 的模組中#include "whatever.h"。
所有的全域性變數都是靜態分配的, 預設在整個程式中都可以使用。這使得可
以為儲存類 static 賦予不同的含義:可以阻止其他的模組引用一個變數。也就是
說, 如果在所有函式的外面使用儲存類 static 定義一個變數, 那麼不能再用 extern
來引用它。
這一區別非常重要,接下來再用另一種方法來解釋, 你應該會完全明白:雖
然在一個函式內部的變數定義前加上 static, 可以潛在地將該變數的作用域擴充套件到
整個程式的範圍,但是當為一個全域性變數加上 static 時,這限定它的作用域僅在
定義該變數的模組範圍內。
2.1.3 函式
在 C 語言中, 所有的函式都是全域性的, 因為不能在一個函式內部定義另一個
函式。 上一節中提到的關於全域性變數的絕大多數內容也都適用於函式。尤其是,
靜態函式的作用域被限定在定義它們的模組中。
唯一明顯的區別是,許多程式設計師(包括我)在宣告函式的時候省略了關鍵字
extern, 但是在宣告全域性變數的時候不會省略。 事實上, 在宣告變數的時候, 也可
以省略 extern 關鍵字, 只需要在模組中對變數進行初始化即可(否則, 編譯器會報
告同一個變數被多次定義的錯誤)。
換句話說, 編譯器會認為在變數被初始化的地方是定義, 所有其他地方是聲
明。那麼, 如果變數在任何地方都未被初始化,該怎麼辦?這種情況下,編譯器
自行決定哪個是定義。 由於編譯器為全域性變數在資料段中分配記憶體, 嚴格來講,
它並不真的關心哪裡是變數定義, 哪裡是宣告,只要定義和宣告能匹配就可以。
但是, 我發現這種情況多少有些“不讓人愉快” (原諒我找不到更好的詞來描述)。
可能這與我已經編寫了相當數量的 Java 程式碼有關係。 無論如何, 儘管其中的差別
14
第 2 章 微 妙 之 C
有些虛幻, 但是你可以發現, 在我的程式碼中, 所有的全域性變數, 在與定義它們的
原始檔對應的標頭檔案中,都會出現關鍵字 extern。 而且這樣做往往也是多餘的,
因為它們都被初始化了。
2.2 按值呼叫
C語言使用一種被稱為“按值” 的機制, 給函式傳遞引數。 這是因為, 當使用
變數作為函式引數的時候, C 語言並不是把變數的地址傳給函式,而是傳遞變數
的值。 雖然新的程式設計師並不總是很清楚這種機制意味著什麼,但是很少有人願意
閱讀關於這一機制的資料。一些新的開發者渴望早一點開始編碼,他們只是看了
一些例子就開始工作了。然後,當程式的行為不正確或者編譯器報錯的時候, 這
種態度就會導致他們產生迷茫。
函式的形式引數是佔位符。例如,如下函式:
int funct(int kk, int jj) {
int retval = 0;
...
return retval;
}
有兩個 int 引數, 返回一個 int 值。 當用類似於下面這樣的語句來呼叫這一函
數時:
int result = funct(3, 7);
程式為兩個 int 區域性變數在棧上分配空間, 並且先把值 3 和 7 複製進去, 然後
開始執行 funct( )函式(這並非它全部的工作, 因為至少它還需要記住函式是在程式
的什麼地方被呼叫,所以當函式返回的時候, 程式可以繼續往下執行。不過, 現
在我們不跑題)。
當該函式返回的時候,程式把儲存在 retval 中的值複製到 result 中。
這裡我們感興趣的是:值 3 和 7 被複製到函式的區域性變數中。這意味著,在
函式中對 kk 和 jj 所做的任何事情,對外部都沒有任何影響。
因此,如果像下面這樣呼叫 funct():
int kk = 3;
int jj = 7;
int result = funct(kk, jj);
那麼不管在函式 funct( )中對 kk 和 jj 做了什麼,呼叫程式的兩個變數 kk 和 jj
仍然保持不變。它們有相同的名稱,這無關緊要,因為這兩對變數有完全分離的
15
C 語言實用之道
作用域。
現在,考慮下面修改版本的 funct( ),這裡 kk 和 jj 是指標:
int functp(int *kk_p, int *jj_p) {
int reval = 0;
...
jj_p++;
(*kk_p)++;
...
return retval;
}
可以像下面這樣來呼叫:
int kk = 3;
int jj = 7;
int result = funct(&kk, &jj);
當函式被呼叫的時候,程式把 kk 的地址複製到區域性變數 kk_p 中,把 jj 的地
址複製到區域性變數 jj_p 中。順便提一下,也可以保持區域性變數的名稱 kk 和 jj 不
變,但是像上面改了名稱之後,可以更好地反映出它們是指標這一事實。這也使
得這裡的討論更易於理解。
在 jj_p 被遞增以後,它指向緊跟在呼叫程式定義的 jj 的地址之後的記憶體位置,
這是非常危險的。 然而,儘管我無法想象你期望這樣的操作能達到什麼目的, 但
遞增操作本身沒有後果。
這種情況與遞增(*kk_p)不同,因為遞增(*kk_p)改變的是呼叫程式中變數 kk
的值, 從 3 變到 4。 正如你所想象的, 這樣做的副作用可能是導致災難性的後果,
因此只有當確實有必要並且知道這樣做的結果時才應該使用。也就是說,任何教
程式設計的老師極有可能在批改作業時,發現包含這樣的語句就會扣分。
我們來看一個與字串有關的例子:
void string_to_upper_lower(char *s, int (*f)(int));
該函式的目的是, 將整個字串轉換成大寫或小寫。 它有兩個引數: s 是將要被
轉換的字串, f 是一個指標, 指向一個接受單個 int 型別引數並返回一個同樣 int 類
型值的函式。
下面是該函式可能的實現方式:
void string_to_upper_lower(char *s, int (*f)(int)) {
if (s != NULL) {
while (*s != '\0') {
*s = (*f)(*s);
s++;
}
16
第 2 章 微 妙 之 C
}
}
可以像下面的例子那樣來呼叫該函式:
#define <ctype.h>
char test_s[] = "abcDEF";
printf("toupper: \"%s\" -> \"%s\"\n", string_to_upper_lower(test_s,
&toupper));
printf("tolower: \"%s\" -> \"%s\"\n", string_to_upper_lower(test_s,
&tolower));
下面是這個小的測試程式列印輸出的結果:
toupper: "abcDEF" -> "ABCDEF"
tolower: "abcDEF" -> "abcdef"
注意, string_to_upper_lower( )遞增字串的地址(即字元陣列的地址),但是
對呼叫程式中 s 的值沒有影響,因為在該函式中,區域性變數 s 是呼叫程式中區域性
變數 s 的副本。但是這並不妨礙修改字串的內容,因為這裡只有一個字串,
並且有它的地址。
在繼續討論以前,出於趣味性考量,這裡給出一種更緊湊的(富有想象力
的)string_to_upper_lower()實現:
void string_to_upper_lower(char *s, int (*f)(int)) {
if (s != NULL && *s != '\0') do *s = (*f)(*s); while (*++s != '\0');
}
當輸入字串為空時,如果不介意呼叫一次 toupper( )或 tolower( )的話(也沒
有什麼影響),可以省略 if 條件的第二部分。
如果想要一個函式能夠改變一個陣列的地址, 而不是僅僅改變它的元素,那
麼需要將該陣列的地址的地址傳遞給函式。例如,下面演示瞭如何實現一個函式
來交換兩個指標:
void swap(void **a, void **b) {
void *temp = *a;
*a = *b;
*b = temp;
}
如果以下面的方式來執行 swap( ):
char *a = "abcdEFG";
char *b = "hijKLM";
swap(&a, &b);
printf("\"%s\" \"%s\"\n", a, b);
17
C 語言實用之道
將會得到:
"hijKLM" "abcdEFG"
最後要說明的一點是: 當給一個函式傳遞一個陣列作為引數的時候, 你已經
看到, 編譯器把陣列的地址複製到一個變數中,該變數對於函式來說是區域性的。
對於結構, 雖然它們可以包含大量的成員,但是它們的處理也像簡單資料型別一
樣。編譯器對結構做一份區域性複製,而不是複製它的地址。只需要執行下面的短
程式就可以做一下測試:
typedef struct a_t { int an_int; } a_t;
void a_fun(a_t x) { x.an_int = 5; }
void main(void) {
a_t a_struct = { 7 };
a_fun(a_struct);
printf("%d\n", a_struct.an_int);
}
你將會看到,雖然在函式中設定 an_int 為 5,但是列印輸出的值仍然是初始
值 7。
2.3 前處理器宏
宏是一個極其強大的工具,但也非常容易引起混淆。 在開發宏的時候,需要
注意的兩個關鍵點是:
● 當宏被擴充套件以後,它們可以導致相關的語句與你預想的不一樣。
● 宏的引數在每次它們出現在展示式中的時候計算,這可能會引起不必要的
副作用。
為了理解第一點,請考慮下面的經典例子:
#define SQR(x) x*x
printf("%d\n", SQR(3+2));
你期望 SQR(3+2)的結果是 5 的平方,等於 25, 但是得到的卻是 11,因為宏
展開之後的結果是下面的 printf( ):
printf("%d\n", 3+2*3+2);
你需要做的是,把 x 括在括號中,如下所示:
#define SQR(x) (x)*(x)
但是,這樣做雖然修正了前面提到的 SQR( )宏的問題,但是一般而言,如果
18
第 2 章 微 妙 之 C
想要高枕無憂的話,還需要做更多。考慮下面的例子:
#define DIFF(a, b) (a)-(b)
printf("%d\n", 5 - DIFF(3, 2));
你可能期望得到的結果為 4,因為 3-2 = 1,並且 5-1 = 4。 但是, 你將會得到
,因為宏展開之後的結果是下面的 printf( ):
printf("%d\n", 5 - (3)-(2));
為了安全起見,必須確保宏永遠不會得到不可計算的表示式:
#define SQR(x) ((x)*(x))
#define DIFF(a, b) ((a)-(b))
關於第二個問題(即宏引起副作用的問題),考慮下面的例子:
#define SQR(x) ((x)*(x))
int x = 5;
printf("%d;%d\n", SQR(x++), x);
你可能期望得到結果“25;6”, 但得到的是“30;7”。 這是因為, 隨著宏被展開,
printf( )變成下面的形式:
printf("%d;%d\n", ((x++)*(x++)), x);
x 被遞增了兩次, 因為它在宏的內部被計算了兩次。 為了避免這種型別的問
題,每個宏引數應該在宏展開中只出現一次。下面是這個宏的安全版本:
#define SQR(x) ({ \
int _x = x; \
_x * _x; \
})
當這個宏被展開的時候, x 只計算一次, 並且它的值被賦給_x。然後, 在宏
展開中出現了兩次(是_x 而不是 x)。 這個宏也很簡單, 甚至可以把它寫在一行中:
#define SQR(x) ({ int _x = x; _x * _x; })
這個宏返回的值是複合語句的最後一條語句中表示式的結果。注意, 當宏返
回一個值的時候,需要把複合語句用圓括號括起來。
2.4 布林值
在 C 語言中, 不同形式的零(比如 、 '\0'或 NULL)被認為是假(false), 任何別
的值被認為是真(true)。
19
C 語言實用之道
根據下面的定義:
float real = 1.0;
int array[] = { 6, 0, 25, 40};
char *string = "This is a string";
下面的所有條件都是真:
real
array[0]
array[3] - array[2]
strchr(string, 0x20)
strstr(string, string)
array
以下條件也是真:
365
75 / 2 * 2
-11
然而,下面的條件是假:
0.0
50 - 25 << 1
array[1]
strchr(string, 'u')
strstr(string + 6, "is")
array != &array[0]
在 Java 中, boolean 型別的變數只有兩種值: true 和 false。但是,在 C 語言
中沒有與之對應的資料型別。
許多 C 程式設計師定義一種新的資料型別,如下所示:
typedef enum { false, true } bool;
C99 標準也支援類似的定義,可以在 stdbool.h 中找到:
#define bool _Bool
#define true 1
#define false 0
我個人並不喜歡這些定義,因為它們帶來了安全性方面的錯覺: 它們使你以
為 bool 型別的變數只能有兩個值。 從某種意義上這是對的, 但實際上並不能阻止
你給它賦其他任何值。這看起來有些矛盾,對不對?考慮下面的例子:
#include <stdbool.h>
bool choice = false;
choice = -335;
printf("%d\n", choice);
20
第 2 章 微 妙 之 C
你會驚訝地發現, 列印在控制檯上的值是 1。 但是, 利用類似下面的做法,
可以把記憶體中的細節資訊轉儲出來:
#include <stdbool.h>
bool choice = false;
int *naughty = &choice;
*naughty = -335;
printf("%d\n", choice);
在控制檯上列印出來的值是 177! 這是從哪裡來的呢?為了理解真相,你需
要知道,負數被儲存成 2 的補數。在本章後面,你將會明白這意味著什麼。為了
理解-335 如何變成了 177,只需要知道,-335 被儲存在一個 32 位的 int 變數中,
是 0xFFFFFEB1。如果不熟悉十六進位制符號,可以這樣來看,每個十六進位制符號
代表儲存在 4 位中的一個值, A 代表 10, B 代表 11, 如此下去, 直到 F 代表 15。
現在, bool 型別的變數只被分配了 8 位空間。 因此, 當顯示 choice 的時候, 將會
看到包含 0xB1 的那個位元組,它的十進位制值是 11 * 16 + 1 = 177。你可能會奇怪,
為什麼在 choice 中看到的是“最右邊” 的 8 位, 而不是最左邊的 8 位。 重申一下,
要想理解這一點,需要閱讀第 11 章的內容。
這很糟糕, 是不是?但還有更糟的。為了看清楚怎麼回事,修改一下前面那
個小程式:
bool choice[4] = {0};
int *naughty = &choice;
*naughty = -335;
for (int k = 0; k < 4; k++) printf(" %2x", choice[k]);
printf("\n");
現在, choice 是一個包含 4 個布林值的陣列, 它們被初始化為 0(即 false)。但
是,當該陣列被列印出來時,得到的結果如下:
0xb1 0xfe 0xff 0xff
你知道第一個位元組是 177,前面當 choice 是單個變數時你已經見過了。但是
現在,你看到整個-335(忘掉位元組的順序)。這是合理的:你告訴編譯器, naughty
指向一個 int 變數(32 位長),然後在這個 int 變數中存放數值-335。不用奇怪, 整
個數值被複製過來,於是也覆蓋掉後面的 3 個位元組。
這個例子顯示: 指標誤用會導致記憶體一團糟。 但是, 這也表明 bool 型別並非
你所感知的那樣安全。
當想要實現布林變數的時候,通常採用下面的方式:
#define FALSE 0
#define TRUE 1
21
C 語言實用之道
然後,把它們賦值給 int 變數,並且檢查這些變數是否為 FALSE。同樣也有
可能存在記憶體受損的情況, 當變數從 TRUE 變成 FALSE 時, 從 FALSE 變成 TRUE
時也一樣。 對於數學家來說, 這是一個有趣的問題。 但是,我不是數學家, 我的
感覺是(可能是錯誤的): 變數被錯誤地設定為非零值的方法比錯誤地設定為零值
的方法多得多。 這就是我為什麼傾向於檢查 FALSE 的原因。如果你是一位數學家,
並且能夠證明我的感覺是錯誤的,請告知我。
顯然,可以非常小心翼翼,寫出類似如下的具有超級防禦性的程式碼:
if (a_flag == FALSE) {
...
}
else if (a_flag == TRUE) {
...
}
else {
// this should never happen -> abort the program
}
於是,可以確信,只有 1 被解釋成 TRUE。很多年以前,我寫過一篇關於這
一話題的小文章,題目為
The Third Boolean Alternative 。但這更多是為了趣味性,
而不是想給出另一個理由。真正有意義的是, 瞭解這一點可以幫助你明白為什麼
你的程式碼工作不正常。 有些情況下,你可能會發現, 像前面顯示的那樣檢查記憶體
被破壞,比透過用偵錯程式去單步執行程式碼要方便得多。
2.5 結構打包
C 語言的結構資料型別可以讓你建立複雜的資料型別,做法是:將一組不同
型別的成員集合起來併為之分配一個識別符號。許多人不知道的是, C 編譯器並不
一定要把這些成員在結構內部緊緊地包裝在一起。 這是因為, 為了加速記憶體訪
問, 編譯器為那些佔用記憶體少於一個整型字(通常是 32 位=4 位元組)的成員填充一些
啞位元組。
例如,考慮下面的結構:
typedef struct z_t {
char c;
int i;
} z_t;
假定一個字元佔據一個位元組、 一個整數佔據四個位元組(很容易驗證這一點, 只
需要列印出 sizeof(char)和 sizeof(int))。 上面的結構應該佔據五個位元組,對不對?
22
第 2 章 微 妙 之 C
一個位元組給 char,四個位元組給 int。錯了!只需要列印一下 sizeof(z_t),就會看到
該結構要求八個位元組。
這是因為, 編譯器在字元後面自動加上了填充位元組, 以便後面的整數對齊到
字的邊界。這就好像是以下面的方式來定義這個結構一樣:
typedef struct z_t {
char c; char padding[3];
int i;
} z_t;
遺憾的是, 交換一下這兩個成員也沒有用。也就是說,如果先定義整數,再
定義字元,結構的尺寸仍然是八個位元組。
但是, 考慮在一個複雜結構中,可能有幾個成員是單個字元的情形。 於是,
值得將它們一個接一個地定義。例如,下面的結構:
typedef struct z_t {
char c;
int i;
char ccc[3];
} z_t;
佔據 12 個位元組,而下面的結構:
typedef struct z_t {
char c;
char ccc[3];
int i;
} z_t;
只佔據八個位元組,因為 c 後面沒有用到填充位元組。事實上,編譯器把前面那
個結構看成如下:
typedef struct z_t {
char c; char pad1[3];
int i;
char ccc[3]; char pad2;
} z_t;
順便提一下, 注意 ccc 可以與 c 合併到一個字中, 因為剛好只佔據三個位元組。
如果把一個 char 陣列看成一個以 null 結尾的 C 字串,因而要求一個額外的字元,
那麼再仔細想一下: 把 C 字串實現成為一個字元陣列, 也確實如此, 但是字元
陣列並不必須是 C 字串。可以使用 ccc 成員來儲存三個字元,或者儲存一個只
包含兩個字元加上結尾 null 的 C 字串,但是如果定義長度為 3,那麼它能得到
的空間就是三個字元。請不要與諸如 sprintf( )之類的函式自動寫一個 null 這樣的
情形混淆起來:仍需要為那個 null 顯式地分配空間。
23
C 語言實用之道
這裡節省一個位元組,可能看起來沒有多大意義。甚至當需要定義大陣列 z_t
結構的時候。 現在的系統都以 GB 或 TB 來衡量記憶體, 你可能覺得浪費 KB 或 MB
位元組級別的空間不是問題。但是,作為 C 程式設計師的驕傲哪裡去了呢?我會覺得,
在我的資料中有這些“洞”總是有點煩人。
在任何情況下都應該知道這一情況,在本書關於嵌入式軟體的章節中你將會
看到,在有些案例中不能忽略結構內部這些縫隙的存在。
此外, 雖然 C 編譯器為了達到成員的字對齊目的而自動在結構中插入填充字
節, 但是 C99 標準要求編譯器生成的陣列沒有縫隙。 也就是說, 對於沒有佔滿字
的元素之間不允許有填充。因此,如下定義的字元陣列:
char cx[5][3];
保證恰好佔據 15 個位元組。 如果陣列元素被填充到 32 位的字, 那麼 cx 將佔據
5×4 = 20 個位元組。
2.6 字元和區域
在計算機中, 字元的表示如同其他事物一樣, 用一個位串來表示。 在 20 世紀
50 年代和 60 年代, 當在穿孔卡上將程式輸入到計算機中時, 字元被編碼成 6 位。
UNIVAC 計算機使用 Fieldata 編碼, 而 IBM 選擇 BCD 編碼。到 20 世紀 70 年代
早期,隨著小型機的出現, 7 位 ASCII 編碼成了事實上的標準。今天,為了向後
相容, UTF-8 編碼的前 128 個字元等同於 ASCII 中定義的字元。
雖然用 7 位來表達公共的拉丁/英語字元已經足夠, 但是為了表達重音、 變音
符和非拉丁字元,需要不止一個位元組。例如,在 UTF-8 中兩個十六進位制位元組 c2
和 a2(即十進位制的 194 和 162)代表分幣字元¢。
我在柏林自由大學(Free University of Berlin)的網站(
chemnet/use/info/libc/libc_19.html)上看到了下面的描述, 我確信他們不會介意我貼
在這裡:
不同的國家和文化對於如何溝通有不一樣的習慣(convention)。 這些習慣涵蓋
範圍很廣, 從非常簡單的習慣, 比如表達日期和時間的格式, 到非常複雜的習慣,
比如語言中的口語。
軟體的國際化意味著要編寫程式來適應使用者的偏好習慣。在 ANSI C 中,國
際化是透過區域(locale)來工作的。每個區域指定了一個習慣的集合,每個習慣各
有目的。使用者透過指定一個區域來選擇一組習慣。
如果在計算機上執行 GNU/Linux,輸入命令 locale,你會得到類似程式碼清單 2-3
24
第 2 章 微 妙 之 C
中顯示的一個列表(空行已刪除)。
程式碼清單2-3 預設區域
LANG=en_AU.UTF-8
LANGUAGE=en_AU:en
LC_CTYPE="en_AU.UTF-8"
LC_NUMERIC="en_AU.UTF-8"
LC_TIME="en_AU.UTF-8"
LC_COLLATE="en_AU.UTF-8"
LC_MONETARY="en_AU.UTF-8"
LC_MESSAGES="en_AU.UTF-8"
LC_PAPER="en_AU.UTF-8"
LC_NAME="en_AU.UTF-8"
LC_ADDRESS="en_AU.UTF-8"
LC_TELEPHONE="en_AU.UTF-8"
LC_MEASUREMENT="en_AU.UTF-8"
LC_IDENTIFICATION="en_AU.UTF-8"
LC_ALL=
在美國,你可能會看到,與各種專案相關聯的區域全都是“en_US.UTF-8”。
在德國,它們可能是“de_DE.UTF-8”,等等。
一般而言, 用於標識區域的標記是由語言程式碼(比如 en)和大寫的國家程式碼(比
如 AU)構成的,通常還跟著編碼方法(比如 UTF-8)。
毋庸多說, 微軟使用自己私有的區域識別符號, 這些識別符號使用一些標識語言
和地域的數值。但是,一般而言,這裡描述的針對 GNU/Linux 的概念仍然是有
效的。
注意, 有多個不同的環境變數與區域相關聯, 它們會影響不同的專案。 例如,
LC_MONETARY 設定隻影響貨幣值如何書寫, LC_TIME 影響日期和時間, 等等。
要想找到在你的 GNU/Linux 系統上有哪些區域可以使用, 可以輸入命令 locale –a。
你將會得到類似於程式碼清單 2-4 所示的一個列表(空行已刪除)。
程式碼清單2-4 可以使用的區域
C
C.UTF-8
en_AG
en_AG.utf8
en_AU.utf8
en_BW.utf8
en_CA.utf8
en_DK.utf8
en_GB.utf8
en_HK.utf8
en_IE.utf8
en_IN
25
C 語言實用之道
en_IN.utf8
en_NG
en_NG.utf8
en_NZ.utf8
en_PH.utf8
en_SG.utf8
en_US.utf8
en_ZA.utf8
en_ZM
en_ZM.utf8
en_ZW.utf8
POSIX
並不是系統上所有可用的區域都已經預設被編譯好並且可以訪問。這樣做是
為了節省空間, 但是可以很容易編譯額外的區域。 例如, 在 GNU/Linux 中, 可以
輸入以下命令:來編譯德國區域:
sudo locale-gen de_DE.UTF-8
並且可以透過鍵入 locale –a 命令來很容易地驗證這一點。
程式碼清單 2-5 中的簡單程式顯示瞭如何利用 setlocale( )來切換區域。
程式碼清單2-5 設定區域
#include <stdio.h>
#include <stdlib.h>
#include <locale.h>
int main(int argc, char *argv[]) {
struct lconv *lc;
char *where = "en_US.UTF-8";
setlocale(LC_MONETARY, where);
lc = localeconv();
printf ("%s: %s %s\n", where, lc->currency_symbol, lc->int_curr_symbol);
where = "en_AU.UTF-8";
setlocale(LC_MONETARY, where);
lc = localeconv();
printf ("%s: %s %s\n", where, lc->currency_symbol, lc->int_curr_symbol);
where = "de_DE.UTF-8";
setlocale(LC_MONETARY, where);
lc = localeconv();
printf ("%s: %s %s\n", where, lc->currency_symbol, lc->int_curr_symbol);
return EXIT_SUCCESS;
}
下面是上述程式的輸出:
en_US.UTF-8: $ USD
26
------------------------
------------------
第 2 章 微 妙 之 C
en_AU.UTF-8: $ AUD
de_DE.UTF-8: € EUR
要想找到更多關於區域名稱的資訊, 可以參考
html_node/Locale-Names.html。
2.7 普通字元和寬字元
前面已經說過, 在 UTF-8 中, 前 127 個字元(即只需要 7 位二進位制來表達的字
符)等同於 ASCII。 但是, 所有其他的字元碼都要求兩至四個位元組。 可以很容易地
區分它們,因為這些位元組都設定了最高位(MSB, Most Significant Bit)。例如,不
變空間符號(no-break space)被編碼成兩個位元組 c2 a0(即 11000010 10100000)。
可以在普通的 C 字串中儲存 UTF-8 字元,諸如 printf( )這樣的函式可以毫
無問題將它們正確地列印出來。例如,如果執行:
char *s = "€ © ♥";
printf("%zu \"%s\"\n", strlen(s), s);
for (int k = 0; k < strlen(s); k++) printf("%02x ", (unsigned char)s[k]);
printf("\n");
將會得到:
15 "€ © ♥"
e2 82 ac 20 c2 a9 20 f0 90 8e ab 20 e2 99 a5
普通的 C 字串 s 儲存了 4 個由空格分隔的特殊字元,總共 15 位元組長。 for
迴圈列印出該字串, 每次以十六進位制格式列印一個字元; 我們知道 0x20 是空格,
所以可以很容易地看到 UTF-8 是如何用可變數量的位元組來編碼這些特殊字元的:
e2 82 ac
© c2 a9
f0 90 8e ab
♥ e2 99 a5
如果好奇的話, 這裡的楔形字元是一種古老的波斯符號 TA。很有趣, 對不對?
可以看到所有 UTF-8 編碼的地方是 。
C 語言可以處理普通 C 字串中的多位元組字元, 但是也引入了 wchar_t,這是
一種專門為寬字元(wide character)設計的型別。 也就是說, 針對的是那些要求不止
一個位元組來編碼的字元。不必吃驚,不同的系統使用不同的編碼方案。例如,
GNU/Linux 使用 wchar_t 來表達採用 UCS-4/UTF-32 編碼的 32 位字元(儘管
27
C 語言實用之道
GNU/Linux 有些針對特殊計算機的埠可能不這樣做),微軟使用同樣的 wchar_t
來表達採用 UTF-16 編碼的 16 位字元。
為了處理寬字元和字串,需要設定一個區域,然後使用專門的函式,如下
面的簡單例子所示:
setlocale(LC_CTYPE, "");
wchar_t wc = L'€';
wprintf(L"A wide character: %lc\n", wc);
這會生成下面的輸出:
A wide character: €
注意這裡用來初始化 wc 的字元前面的 L, 以及 wprintf( )的格式字串前面的
L,這指明它們是寬字元(串)。同時也請注意,格式化程式碼%lc 中的 l 指明該字元
是寬字元。
將區域設定為空字串,相當於指示該程式採用系統的預設區域。你可能在
想,既然是設定預設區域,那麼應該可以省略這一語句。 再想一想。 如果這麼做
的話,輸出將會是:
A wide character: EUR
聰明!但不一定是你想要的。
不過, 當列印寬字元時有一個微妙的問題: printf( )和 wprintf( )將字元寫到同
樣的字元流 stdout 中,但它們並不共享 stdout。這是因為 stdout 有定向(orientation):
既可以輸出普通字元, 也可以輸出寬字元,但不能同時輸出這兩種字元。當程式
啟動時, stdout 沒有定向, 但是一旦用 stdout 列印普通字元, stdout 就變成定向的,
並且壓制住所有寬字元的輸出。類似地, 如果在啟動一個程式後列印寬字元, 那
麼 stdout 便不再列印普通字元。
stdout 在關閉(並重新開啟)之後會丟失定向, 但是我不太願意這樣多次關閉並
重新開啟 stdout,這在任何情況下多少有點棘手。如果需要同時列印普通字元和
寬字元,建議克隆 stdout,這樣就可以使用原來的 stdout 列印普通字元,使用克
隆出來的 stdout 列印寬字元,或者反過來也可以。
程式碼清單 2-6 中的程式顯示瞭如何做到這一點。
程式碼清單2-6 克隆stdout
#include <stdio.h>
#include <stdlib.h>
#include <locale.h>
#include <wchar.h>
#include <string.h>
28
第 2 章 微 妙 之 C
#include <unistd.h>
int main(int argc, char *argv[]) {
// Printing UTF-8 characters in normal C-strings...
char *s = "€ © ♥";
printf("%zu \"%s\"\n", strlen(s), s);
for (int k = 0; k < strlen(s); k++) printf("%02x ", (unsigned char)s[k]);
printf("\n");
// ... and as wide characters after cloning stdout.
int stdout_fd = dup(1);
FILE *stdout2 = fdopen(stdout_fd, "w"); // compile with -gnu99
//
setlocale(LC_CTYPE, "");
wchar_t wc = L'€';
fwprintf(stdout2, L"A wide character: %lc\n", wc);
//
fclose(stdout2);
close(stdout_fd);
return EXIT_SUCCESS;
}
需要包含 unistd.h,以避免“implicit declaration of function 'dup'”以及針對函
數'close'的警告, 而且當使用 gcc 來編譯這個程式時應該加上-std=gnu99 選項, 這
將會避免“implicit declaration of function 'fdopen'” 警告。
要想檢查和設定流的定向,可以使用函式 fwide( ):
int orientation = fwide(stdout, 0);
以 0 作為第二個引數, fwide( ) 返回當前的定向: -1 表示普通字元, 1 表示寬
字元。所以,下面的例子:
wprintf(L"Stream orientation: %d\n", fwide(stdout, 0));
wprintf(L"Stream orientation: %d\n", fwide(stdout, 0));
將列印出:
Stream orientation: 0
Stream orientation: 1
而下面的例子:
printf("Stream orientation: %d\n", fwide(stdout, 0));
printf("Stream orientation: %d\n", fwide(stdout, 0));
將列印出:
Stream orientation: 0
Stream orientation: -1
29
C 語言實用之道
這是因為,初始定向為 ,在第一個 wprintf( )/printf( )之後被設定為寬字元或
普通字元。 也可以利用 fwide( )來設定定向(正值代表寬字元, 負值代表普通字元)。
例如:
wprintf(L"Stream orientation: %d\n", fwide(stdout, 3));
wprintf(L"Stream orientation: %d\n", fwide(stdout, 0));
將列印出:
Stream orientation: 1
Stream orientation: 1
作為對這一節關於普通字元和寬字元討論的總結,我們需要看一下如何從一
種定向轉換到另一種定向。首先, 我們來看一下如何將單個多位元組字元轉換成寬
字元。程式碼清單 2-7 中的程式碼顯示瞭如何將字串轉換成寬字串,一次轉換一
個字元。
程式碼清單2-7 將字串轉換成寬字串,一次一個字元
char *s = "€ © ♥";
setlocale(LC_CTYPE, "");
wprintf(L"Normal string: %2d \"%s\"\nConversion\n", strlen(s), s);
wchar_t ws[100] = {};
size_t conv_size = 0;
int next = 0;
wchar_t wc;
int k = 0;
do {
conv_size = mbtowc(&wc, &s[next], strlen(s) - next);
if (conv_size) {
wprintf(L"%4d: %d -> %zu '%lc'\n", next, (int)conv_size, sizeof(wc), wc);
next += (int)conv_size;
ws[k++] = wc;
}
} while (conv_size > 0);
wprintf(L"Wide string: %zu \"%ls\"\n", wcslen(ws), ws);
下面是上述程式碼產生的輸出:
Normal string: 15 "€ © ♥"
Conversion
0: 3 -> 4 '€'
3: 1 -> 4 ' '
4: 2 -> 4 '©'
6: 1 -> 4 ' '
7: 4 -> 4 ' '
11: 1 -> 4 ' '
12: 3 -> 4 '♥'
Wide string: 7 "€ © ♥"
30
第 2 章 微 妙 之 C
注意,所有的寬字元佔據 4 個位元組。
程式碼清單 2-7 是一次很好的練習, 但是(不必驚訝)也可以用函式呼叫轉換整個
字串:
size_t n = mbstowcs(ws, s, 100);
這看起來很好,然而,這些字元在 wchar_t 中實際是如何編碼的呢?可以把
下面的程式碼行附加在程式碼清單 2-7 中的程式碼片段之後,就可以看到:
wprintf(L"\n");
for (int k = 0; k < 7; k++) {
for (int j = 0; j < 4; j++) {
wprintf(L"%02x ", ((unsigned char *)ws)[k*4 + j]);
}
wprintf(L" '%lc'\n", ws[k]);
}
注意, 這段程式碼只能在定義了 wchar_t 為 32 位寬度的編譯器和系統上正常工
作,比如 gcc 和 GNU/Linux。下面是得到的輸出:
ac 20 00 00 '€'
20 00 00 00 ' '
a9 00 00 00 '©'
20 00 00 00 ' '
ab 03 01 00 ' '
20 00 00 00 ' '
65 26 00 00 '♥'
哇!這是什麼碼?好,我來告訴你:它們是 UTF-32 碼,最高位元組儲存在最
後。 所以, 現在你知道了, 在普通字串中, 歐元符號用 UTF-8 編碼, 最高位元組
在前, 是 e2 82 ac; 而在寬字元中, 用 UTF-32 編碼, 最高位元組在後, 是 ac 20 00 00。
至少,這是 Ubuntu 的工作方式, Ubuntu 是 GNU/Linux 的一個發行版。在其
他系統上,一般性的概念也是相同的,不過,每個 wchar_t 中的位元組數量和編碼
會有不同。例如,在微軟的系統中, wchar_t 是 16 位寬,而寬字元采用 UTF-16
編碼。但是,在 Windows 中,區域名稱並不相同,它們使用 16 位數值。可以在
msdn.microsoft.com/en-au/ goglobal/bb964664.aspx 上找到區域列表。
再重申一下,這裡的底線是: 寬字元和字串很容易讓程式碼不可移植,除非
使用條件編譯指令。
現在, 為了把寬字元轉換成多位元組字元, 可以使用 wctomb(), 如下面的例子所示:
char airplane[5];
size_t n_c = wctomb(airplane, L'✈ ');
airplane[n_c] = '\0';
wprintf(L"\nWide to multibyte char %zu: %lc -> %s\n", n_c, L'✈ ', airplane);
31
C 語言實用之道
字串 airplane 足夠長, 以容納多位元組形式下最多數量的字元(即 4 個字元),
再加上結尾的 null。同樣,你需要在多位元組字串的最後一個字元的後面寫上結
尾字元 null。
輸出是:
Wide to multibyte char 3:✈ -> ✈
為把寬字串轉換成多位元組字串,可以使用 wcstombs( ):
char ss[100];
n_c = wcstombs(ss, L" ", 100);
wprintf(L"\nWide to multibyte string %zu: %ls -> %s\n", n_c, L" ", ss);
對於字串,不需要附加結尾字元 null,因為輸入字串已經有一個寬 null
字元作為結尾,它會被轉換成普通的 null 字元。輸出結果是:
Wide to multibyte string 6: ->
2.8 處理數值
遲早, 你需要知道 C 變數中是如何儲存數值的。 你可能會想得非常簡單, 從
來不會仔細思考, 但是我相信,任何一名認真的程式設計師都應該想一想諸如“2 的
補數意味著什麼”或 “什麼時候使用 double 而不是 float” 之類的事情。
2.8.1 整數
在我的 Ubuntu 系統上,可以使用 char、 short、 int 和 long 型別來儲存整型數
值,分別佔據 1、 2、 4 和 8 個位元組。
表 2-1 顯示了 C99 標準要求的最小位數,以及 Ubuntu 和 Windows 提供的實
際位數。
表 2-1 整數的尺寸

型別 C99 Ubuntu Windows
char 8 位 8 位 8 位
short 16 位 16 位 16 位
int 16 位 32 位 32 位
long 32 位 64 位 32 位
long long 64 位 64 位 64 位
pointer 與實現相關 64 位 64 位


32
第 2 章 微 妙 之 C
Ubuntu(和其他的 GNU/Linux 系統, 包括 Mac)與 Windows 之間最主要的區別
是:後者對於 long 型別使用 32 位(是的,即使在 64 位處理器環境下)。為了達到
可移植目的, 可以包含標準標頭檔案 stdint.h, 並使用 int8_t、uint8_t、int16_t、uint16_t、
int32_t、 uint32_t、 int64_t 和 uint64_t。我本來可以利用這些標準的整數型別來編
寫本書的例子和程式碼, 但是我沒有這麼做,因為我發現, 這些型別多少有點分散
注意力。原因可能是這樣:為了理解當前正在處理的型別,實際上需要讀出數值
8 至 64,而對於傳統的型別,看一眼就可以識別出來。對於本書,清晰和易讀是
非常重要的。
如果在變數定義中包含額外的型別指示符 unsigned,那麼得到的所有結果類
型的最小值都是 , 而最大值對應的所有位都被設定為 1,即 2
#bits – 1。 這是因為,
當所有的位(從 0 到#bits -1(這裡 0 是最低位))都被設定為 1 時,只需要給它加上 1,
就可以得到一個數: 其二進位制形式是 1 後面跟著#bits 個零。 例如, 最大的 unsigned
short 數值是 2
16 – 1 = 65535。
在標準標頭檔案 linits.h 中定義了這些最大值。所以,如果執行:
printf("%u %u %u %lu\n", UCHAR_MAX, USHRT_MAX, UINT_MAX, ULONG_MAX);
將會得到:
255 65535 4294967295 18446744073709551615
當需要有符號數值時, 事情會變得複雜一些(預設的整數型別是有符號的。 然
而,如果願意的話,可以加上型別指示符 signed)。基本的訣竅是使用 MSB(最高
位)作為符號位:當 MSB 為 0 時,數值是正的;當 MSB 為 1 時,數值是負的。
如果已經理解了,那麼short 型別可以表達-32767(所有 16 位置為 1)和+32767
(MSB 置為 ,剩下的 15 位置為 1)之間的所有整數。 但是, 這樣會得到兩個有符號
的零: +0,所有的 16 位被設定為 ;-, MSB 置為 1, 而剩下的位置為 。對於程
序員和晶片設計師而言,這顯然是一個問題。
為了解決這個問題, 廣泛採用下面的策略: 為了存放一個 N 位的負數, 從 2
N
減去它的絕對值。 為了理解在實踐中這是如何工作的,我們來看一下如何將-127
儲存到一個 signed char 中。 127 的二進位制是 0b01111111(即 1+2+4+8+16+32+64)。
一個位元組的 2
N 是 0b100000000(即 2 8 = 256)。 正如減十進位制數一樣, 減一下二進位制
數, 結果得到 0b10000001。 另一個例子, 我們來看一下-1 長什麼樣。 在二進位制中,
1 是 0b00000001,當從 0b100000000 減去它時,會得到 0b11111111。
這一策略消除了雙零的問題,因為 0b10000000 原來是-,現在代表-128。
不需要做減法,就可以很容易確定負數的表達方式, 只需要把所有的位都翻
轉, 然後加上 1 即可。 例如, 如果翻轉 127(即 0b01111111), 將得到 0b10000000,
33
C 語言實用之道
再加上 1 之後, 就得到了正確的表達方式(即 0b10000001)。 翻轉一個數的位之後,
將得到的數稱為這個數的補數,因為如果把這個數加到原來的數上, 會得到所有
的位都是 1。儲存在記憶體中的代表一個負數的那個數稱為 2 的補數,因為在一個
數的補數上加上 1 就可以得到它。
在進行上面的解釋之後,下面程式碼的執行結果對你來說應該是顯而易見的:
printf("%d %d %d %ld\n", CHAR_MIN, SHRT_MIN, INT_MIN, LONG_MIN);
printf(" %d %d %d %ld\n", CHAR_MAX, SHRT_MAX, INT_MAX, LONG_MAX);
將會得到:
-128 -32768 -2147483648 -9223372036854775808
127 32767 2147483647 9223372036854775807
在進入下一節之前,還有一件事情——書寫數值的習慣是:最低位在右側。
例如, 把一百二十三寫成 123。 在任何情況下,書寫數值都要這樣做,包括二進
制(只有 0 和 1 兩個數字)和十六進位制(0 和 F 之間的任何數字)。 因此, 0x12345678
意味著 8 對應於 16
, 7 對應於 16 1 , 等等。
但是, 當把一個數值儲存在計算機記憶體中時, 位和位元組的順序並不總是相同
的。例如,如果把 0x12345678 這個數存放到一個 32 位的整數中,在最低記憶體地
址處的那個位元組中會有什麼內容呢?
在我的 Ubuntu 系統上,我寫了下面三行程式碼:
int ii = 0x12345678;
unsigned char *pip = (unsigned char *)ⅈ
for (int i = 0; i < sizeof(ii); i++) printf("%02x", pip[i]);
它列印出 78563412。
因為每一對十六進位制數字是一個位元組, 所以這意味著 Ubuntu 上的 gcc 把最低
位元組(即最低記憶體地址)寫在前面。 用計算機術語來講,將這一選擇稱為小端(little
endianness)儲存。
2.8.2 浮點數
宣告一下,幾乎是顯然的:浮點數是帶小數點的數。
1. 有效數字、截斷和取整
為了理解在計算機中如何表達浮點數,需要知道數值的科學記數法。 下面是
用科學記數法來書寫 123.456 的一些例子:
34
第 2 章 微 妙 之 C
123.456 * 10
1234.56 * 10 -1
12345.6 * 10 -2
123456 * 10 -3
1234560 * 10 -4
12.3456 * 10 1
1.23456 * 10 2
0.123456 * 10 3
0.0123456 * 10 4
第一部分稱為係數(coefficient), 10 是基數(base), 10 的冪次稱為指數
(exponent)。 每當把係數的小數點向左移動一個數字時,就會得到一個 10 倍小的
數。因此, 如果想要保持原始數的值,需要在指數上加一。同樣,很顯然, 如果
把小數點向右移動一點, 需要相應地遞減指數。 100 等於 1, 因此通常被省略掉, 這
是科學記數法的一個特例,是我們熟悉的表達十進位制數值的方法,沒有 10 的冪次。
一般約定的習慣是: 用小數點的左邊只有一個數字的科學記數法來書寫數值。
在上面的例子中,是 1.23456*10
2
在任何情況下,上面列出的數值並不完全相等。為了讓你相信這一點,考慮
20 除以 3 的結果。近似等於 6.66。 如果像剛才針對 123.456 的做法那樣,用科學
記數法來表達,那麼會得到:
6.66 * 10
66.6 * 10 -1
666 * 10 -2
6660 * 10 -3
66600 * 10 -4
0.666 * 10 1
0.0666 * 10 2
0.00666 * 10 3
0.000666 * 10 4
對於 123.456, 這一切看起來都是合理的, 因為我們不知道這個數值是如何計
算得到的。 但是, 我們知道 6.66 是 20/3 的結果, 這使得它的有些表達看起來很奇
怪。為了理解為什麼會這樣,可以寫下:
20/3 = 6660 * 10 -3
然後,在表示式的兩邊都乘以 1000。結果是:
20000/3 = 6660
很明顯,這是錯誤的。
問題是,我們習慣於把 0 當作什麼都沒有。但是,這並不總是正確的。 6660
中的 0 是有效的(significant)。 當寫下 20/3 = 6.66 時, 指定了 3 個有效數字。 但是,
6660 有 4 個有效數字。你基於什麼來考慮 3 個 6 之後跟一個 0 呢?
35
C 語言實用之道
下面的兩種表示法又如何呢?
0.666000 * 10 1
0.066600 * 10 2
它們也是錯誤的,因為它們分別加上了三個或兩個有效的 0(即 3 個 6 右邊的
0)。下面的兩條規則指出了什麼是有效位:
● 所有的非 0 數字都是有效的。
● 非零數字右邊的所有 0 都是有效的。
第二條規則也意味著非 0 數字之間的 0 都是有效的。
當手工計算 20/3 得到 6.66 時,首先得到 6 餘 2,然後 0.6 餘 0.2,然後 0.06
餘 0.02, 到這兒就停住了。 這種近似計算的方法稱為截斷(truncation, 來自於拉丁
語中的動詞 truncare,意思是徹底破壞掉)。
如果提煉一下 20/3 的計算過程,會不斷得到 6。因此,如果決定在保持 3 個
有效數字的情況下, 不使用截斷, 而使用取整(rounding, 也稱為舍入), 那麼可以
說, 20/3 近似等於 6.67。當宣告 20/3 是 6.67 時,是向上取整了;而當宣告 10/3
是 3.33 時, 是向下取整了。
有一件事情需要記住: 只有當能計算/估計的位數超過結果位數時, 才能進行
取整。 如果想要顯示所有你能計算的位數,只能截斷。在大多數實際情況下, 這
樣做沒有影響,但並非所有情況皆如此。
上面這些考慮是重要的,因為在任何計算機操作中, 有效數字的個數都是有
限的。下一節將介紹更多資訊。
通常, 簡化的科學記數法是: 把 10 的冪次用字母 e 跟上指數來替代。 例如下
面的例子: 1.23 * 10
-5 = 1.23e-5。
2. 表示浮點數
計算機在計算小數時結果是近似的。 例如, 在計算機上執行下面的三行程式碼:
printf("%2zu %10.8f\n", sizeof(float), (float)10/3);
printf("%2zu %19.17f\n", sizeof(double), (double)10/3);
printf("%2zu %22.20Lf\n", sizeof(long double), (long double)10/3);
結果是:
4 3.33333325
8 3.33333333333333348
16 3.33333333333333333326
可以看到,用來表達一個數的位元組數越多,有效數字的個數也越多。
所有的浮點數都是按科學記數法來儲存的, 存放浮點數的記憶體塊分成三部分:
36
第 2 章 微 妙 之 C
符號位、 指數和尾數(mantissa)。 尾數是數學中使用的名稱, 是指對數中小數點之
後的部分,對應於科學記數法的係數在計算中是如何使用的。
IEEE 754-2008 標準指定了如何在計算機中編碼浮點數, 已經被廣泛採用, 盡
管在本書寫作時有些部分尚未實現。巴爾的摩大學(The University of Baltimore)提
供了文件: ~tsimo1/CMSC455/IEEE-754-2008.pdf。
在該標準的協議中, C 語言中的 float 資料型別將浮點數編碼成 32 位, 如下
所示(最右邊是第 0 位, 即最低位(LSB)):
含義 : seeeeeeeemmmmmmmmmmmmmmmmmmmmmmm
: <---8--><--------23----------->
這裡 s 是符號位,標記為 e 的 8 位是指數,而標記為 m 的 23 位是尾數。
類似於在上一節中看到的針對十進位制數值的情形,可以透過將尾數的有效數
字向左或向右移動,並相應地增加或減小指數的值, 以多種方式來表達同樣的數
值。 但是, 作為浮點數的計算機表達形式, 能夠以不同的方式來表達同樣的數值,
這是不能接受的,因為這會使數值之間的比較變得非常複雜和耗時。
IEEE 754 標準中採用的約定類似於科學記數法針對十進位制數值採用的約定:
向左或向右移動數值, 直到單個非 0 數字(即一個 1, 因為在二進位制中只有 0 和 1)
仍然在小數點(注意是二進位制小數點)的左邊,並相應地調整指數(現在,指數代表
2 的冪次而不是 10 的冪次)。
符號位指的是整個數值,但是,指數部分必須有自己的符號來表達絕對值位
於 0 和 1 之間的數值。為了涵蓋這種情形,該標準指定指數部分在儲存的時候使
用 shift-127 編碼。也就是說,為了得到一個數的實際指數,從這個數的 8 位 eeeeeeee
編碼的部分減去 127(即 0x7f)。例如,指數為 , 則在這 8 位中儲存的值是 127;
指數為 1,儲存的值是 128;指數為-1,儲存的值是 126;等等。
現在一切都很好。 但是,該標準在編碼上增加了一點曲折:為什麼我們要記
住小數點左邊的那個 1?它總是在那裡,並且我們知道它是 1(對於用科學記數法
來表示的十進位制數,小數點的左邊可以出現 1 至 9 之間的任何一個數字;但是對
於二進位制數,只可能是 1)。我們也可以丟掉它,從而避免浪費 1 位空間!
“等一等”, 你可能會說,“當我以浮點數形式表達數值 1, 丟掉唯一的非
數字時, 又如何將它與 0 的表達形式區分開呢?”該標準採用的方案是: 專橫地(但
也很方便和愉悅地)使用全 0 的二進位制編碼來表達數值 。 於是, 0 被表示成
0x00000000, 1 被表示成 0x3f800000。 注意, 1 的低 23 位(即尾數)是 , 因為一旦
把唯一的非 0 數字向小數點的左邊移動, 數值 1 整個就是由 0 構成了(這裡重申一
下,以防你第一次沒有充分領會)。同時也注意, 1 的符號位是 , 8 位指數是
0b01111111,這是 127。 實際上, 當從指數部分減去 127,並恢復 1(從小數點的左
37
C 語言實用之道
邊已經去掉)時,得到了 1 * 2 ,這等於 1.0,正好是它應該的值。
看另一個例子, 在浮點數中, -1 的編碼是 0xbf800000。它與 1 的編碼的唯一
區別是:整個表示式的最高位是 1 而不是 , 因為最高的十六進位制位被置成 0xb
或 0b1011, 而不是 0x3 或 0b011。但那是符號位!這完全是合理的。
最後一個例子: 0.5 被編碼成 0x3f000000:如同 1 的表示式中一樣, 符號位和
尾數都是 , 但指數部分是 0x7e 或 0b01111110,即 126。在將指數減去 127,並
恢復被省掉的 1 之後, 得到 1 * 2
-1 , 很驚訝吧!正好是 0.5。
由於該標準決定將 0 編碼成全 , 這使得此編碼方案不可能再存放數值 2
-127
因為在對指數進行了 shift-127 編碼之後, 它與 0 無法區分。 但是, 為了方便地表
達 0 而“丟失”這麼小的一個數值,只是很小的代價。該標準引入了更進一步的
限制,它定義了以下額外編碼:
0x7f800000: +infinity( 正無窮 )
0xff800000: -infinity( 負無窮 )
0x7fc00000 0x7ff00000: + NaN Not-a-Number( 不是數字 )
總而言之, 要根據一個浮點數的 IEEE 754 二進位制表示式來計算它的十進位制數
值,可以如下計算(一種有趣的語法,但含義應該很清晰):
十進位制數值 = (1 – 符號位 * 2) * 2^( 指數位 - 127) * 1. 尾數位
如果想要更好看一點,可以將(1 – 符號位 * 2)替換成 C 語言的形式(符號位 ?
-1 : 1)。
任何關於 float 型別的內容也適用於 double 和 long double 型別, 不過,很顯
然,指數和尾數使用的位數有所不同。 IEEE 754 標準指定 double 和 long double
型別的指數的位數分別是 11 和 15, 而尾數的位數分別是 52 和 112。當然,兩者
都要加上符號位。
但是, 你的系統在實現浮點數時可能並不相同。 表 2-2 顯示了在 Ubuntu 上執
行下面語句的結果(其中的宏被定義在標準標頭檔案 float.h 中):
printf("Property\tfloat\tdouble\tlong double\n");
printf("mantissa:\t%d\t%d\t%d\n", FLT_MANT_DIG, DBL_MANT_DIG,
LDBL_MANT_DIG);
printf("# dec. digits:\t%d\t%d\t%d\n", FLT_DIG, DBL_DIG, LDBL_DIG);
printf("max:\t%9.5e\t%18.14e\t%22.17Le\n", FLT_MAX, DBL_MAX, LDBL_MAX);
printf("min:\t%9.5e\t%18.14e\t%22.17Le\n", FLT_MIN, DBL_MIN, LDBL_MIN);
表 2-2 Ubuntu 中的浮點編碼

屬性 float double long double
尾數+符號位 24 53 64


38
第 2 章 微 妙 之 C
(續表)

屬性 float double long double
十進位制數字的
個數
6 15 18
最大值 3.40282e+38 1.79769313486232e+308 1.18973149535723177e+4932
最小值 1.17549e-38 2.22507385850720e-308 3.36210314311209351e-4932


留作 float 型別的尾數的位數是 24 而不是前面宣告的 23,是因為 float.h 中定
義的*_MANT_DIG 宏在計數中包含了符號位。 double 型別的尾數的長度與標準一
致。但是 LDBL_MANT_DIG 並不符合標準,因為它是 64, 而標準指定了 113。
標準也指定了指數位的數量: float 型別是 8, double 型別是 11, long double
型別是 15。 實際上,如果把尾數+符號位的數量加上指數位的數量,就會得到:
對於 float 型別, 24+8 = 32(即 4 位元組);對於 double 型別, 53+11 = 64(即 8 位元組);
對於 long double 型別, 113+15 = 128(即 16 位元組)。
假定 Ubuntu 上執行的 gcc 遵從標準, long double 型別的指數位是 15, 而 long
double 型別的尾數只使用 64 位,而並非標準指定的 113 位,那麼未計算在內的
49 位怎麼辦?
為了搞清楚這一問題, 看一下標準標頭檔案 ieee754.h, 特別是與小端相關的那
些定義。為方便起見,我去除了一些(對於我們目前的討論)非本質的程式碼並重新進
行了格式化, 得到程式碼清單 2-8。 long double 的定義引用的是 IEEE 854 而不是 IEEE
754,因為 IEEE 854 只被合併到 2008 發行版的 IEEE 754 中,沒有人在 ieee754.h
中進行更新。 但是這不要緊。
程式碼清單2-8 ieee754.h(部分程式碼)
// Single-precision format.
union ieee754_float {
float f;
struct {
unsigned int mantissa:23;
unsigned int exponent:8;
unsigned int negative:1;
} ieee;
};
// Double-precision format.
union ieee754_double {
double d;
struct {
unsigned int mantissa1:32;
unsigned int mantissa0:20;
39
C 語言實用之道
unsigned int exponent:11;
unsigned int negative:1;
} ieee;
};
// Double-extended-precision format.
union ieee854_long_double {
long double d;
struct {
unsigned int mantissa1:32;
unsigned int mantissa0:32;
unsigned int exponent:15;
unsigned int negative:1;
unsigned int empty:16;
} ieee;
};
在程式碼清單 2-8 的定義中, 可以看到在表 2-2 中顯示的 float 和 double 的尾數
和指數的位數。由於小端的特性同樣適用於浮點數和整數,因此尾數相比其他部
分出現在前面,儲存在記憶體的低地址位置。
但是, 當檢視 long double 的定義時, 可以發現, 有一個額外的名為 empty 的
16 位域, 並且尾數和符號加起來是 65 位, 而不是 float.h 中 LDBL_MANT_DIG 定義
的 64 位(如表 2-2 所示)。所以,漏掉的實際上是 48 位,並非按假設 LDBL_MANT_DIG
包含符號位而計算得到的 49 位。然而,名為 empty 的位域只計算了 48 個漏掉的
位中的 16 個,還有 32 位仍未定義和計算進去。
這是一本理論結合實踐的書, 因此,為了解決這一謎題, 我們使用實際動手
的方法。下面的程式碼定義了所有這三種型別的浮點數,將它們設定為 1,然後按
十六進位制列印出它們的內容:
float f = 1;
unsigned char *c = (unsigned char *)&f;
for (int i = 0; i < sizeof(f); i++) printf("%02x", c[i]);
printf("\n");
//
double d = 1;
c = (unsigned char *)&d;
for (int i = 0; i < sizeof(d); i++) printf("%02x", c[i]);
printf("\n");
//
long double ld = 1;
c = (unsigned char *)&ld;
for (int i = 0; i < sizeof(ld); i++) printf("%02x", c[i]);
printf("\n");
在 Ubuntu 上輸入如下程式碼:
40
第 2 章 微 妙 之 C
0000803f
000000000000f03f
0000000000000080ff3f000000000000
因為這些數值是按小端形式儲存的,所以如果想要讓這些數值像你看到它們
本來的樣子那樣(低位元組在右邊,即大端形式),需要將位元組順序反轉過來:
float: 3f800000
double: 3ff0000000000000
long double: 0000000000003fff8000000000000000
由於這三種型別的變數都儲存了值 1,因此符號位(即 MSB)是 。
你已經看到 0x3f800000 如何作為 1 儲存在一個 float 變數中。指數部分的 8 位
被設定為 127(即 0b01111111), 剩餘部分(即尾數的 23 位)是 。這裡沒什麼新花樣。
至於 double 型別,對指數部分的 11 位也做了偏移編碼,功能與前面提到的
float 型別所採用的 shift-127 相同。 float 型別的指數位是 8 位, 偏移是 2
7 –1。 同理,
double 型別有 11 位指數,偏移為 2
11 – 1 或二進位制 0b01111111111。只需要將它向
右移一位,以留出所表達數值的符號位,可以得到 0x3ff,這與 1 的 double 編碼
的展開式一致。透過這個練習,你得知 double 型別的指數是採用 shift-1023 進行
編碼的。
再考慮 long double, 15 位的指數部分意味著採用 2
14 – 1 偏移的編碼, 以十進
製表示是 16383,二進位制是 0b011111111111111。當將它向右移一位,留出符號位
時,可以得到 0x3fff。 但是,如果看一下上面顯示的大端編碼,就可以看到,緊
跟 15 位的指數部分之後,有一位被設定了(即 0x3fff 之後跟著 0x8)。這一位是尾
數的 MSB(最高位), 它的存在意味著 gcc 在實現 long double 型別的時候並沒有丟
掉小數點前面的 1!也就是說, 64 位的尾數中有 1 位並沒有包含任何資訊,因為
它總是被設定。
注意, 在符號和指數部分的 0x3fff 前面, 有 6 個全 0 位元組。 它們是漏掉的 48
位,佔據 long double 數值的 16 位元組的最高位部分。
出於好玩的心理,把這 48 位全部置成 1, 再看編譯器會怎麼辦。下面是加上的
程式碼:
for (int i = 1; i <= 6; i++) c[sizeof(ld) - i] = 0xff;
printf("%Lf\n", ld);
for (int i = 0; i < sizeof(ld); i++) printf("%02x", c[i]);
printf("\n");
這裡是輸出結果:
1.000000
0000000000000080ff3fffffffffffff
41
C 語言實用之道
編譯器忽略這 48 位,仍然列印出 1.0!再進一步測試,加上另一段程式碼來檢
查一下, 當把一個 long double 數值複製到另一個時, 這些位會發生什麼情況。 代
碼清單 2-9 顯示了整個測試過程。
程式碼清單2-9 檢查long double中未使用的位
1. long double ld = 1;
2. unsigned char *c = (unsigned char *)&ld;
3. for (int i = 1; i <= 6; i++) c[sizeof(ld) - i] = 0xff;
4. printf("%Lf\n", ld);
5. for (int i = 0; i < sizeof(ld); i++) printf("%02x", c[i]);
6. printf("\n");
7. long double ld2;
8. c = (unsigned char *)&ld2;
9. for (int i = 1; i < sizeof(ld2); i++) c[i] = i;
10. ld2 = ld;
11. printf("%Lf\n", ld2);
12. for (int i = 0; i < sizeof(ld2); i++) printf("%02x", c[i]);
13. printf("\n");
輸出結果如下:
1.000000
0000000000000080ff3fffffffffffff
1.000000
0000000000000080ff3fffff0c0d0e0f
換句話說, 編譯器把 ld 中的 6 個位元組設定為 0xff 中的兩個位元組, 複製到新的
long double 變數中。這正是在 ieee754.h 中定義為 empty 的 16 位。 ld2 餘下的 32
位, 在 ieee754.h 中沒有定義, 仍然保留不變。 上述測試程式碼開始時沒有包含第 9
行,結果,這 32 位是一些垃圾值,即當 ld2 被定義時碰巧包含的值。
現在你知道了編譯器如何處理這 48 個未使用的位(在 ieee754.h 中定義的 16
位會被複製, 而其他的 32 位被直接忽略), 可以在 long double 表格中隱藏訊息了,
而使用這些表格的人渾然不知訊息的存在。可以想象一下有多少間諜利用這種方
法來隱藏資訊……
你可能會對在 ieee854_long_doulbe.ieee 中追加 unsigned int empty1:32;感興趣,
看一下在這種情況下前面被忽略的 32 位是否會被複製過去,但是這肯定不會發
生, 除非重新編譯 gcc。 我沒有這麼做, 但是在任何情況下, 都不應該篡改系統,
除非真的知道自己在做什麼。
3. 檢查浮點數是否相等
正如你已經看到的,浮點數的精度是有限的。 也就是說, 只能依賴於給定個
數的數字。 表 2-2 告訴你,針對 float 變數, 個數是 6;而針對 double 變數,個數
42
第 2 章 微 妙 之 C
是 15。
但是這些簡單的數值並沒有告訴你全部的資訊。每當你操縱浮點變數的時候,
近似動作(取整或截斷)都會發生。例如,執行下面的兩行程式碼(cos( )是一個返回
double 數值的函式):
double d = cos(M_PI/2);
printf("%18.15: it is%s zero.\n", d, (d == 0.0) ? "": " not");
將會得到:
0.000000000000000: it is not zero.
C 庫中的三角函式和其他函式是透過多項式近似來計算的。因此,期望
cos(M_PI/2)的所有 64 位都是 ,這是不合理的。
我們知道 double 型別的尾數(不考慮符號位)佔據 52 位。 因此, 儲存在 double
變數中的數值的精度不可能比±2
-52 更高。不需要計算這個值。如果包含標準頭文
件 float.h 並列印 DBL_EPSILON, 將會得到 2.22045e-16。 這就好了: e-16 解釋了
為什麼在使用 double 型別時只可依賴 15 位十進位制數字。
讓我們透過另一個簡單的程式來進一步挖掘這裡的關鍵之處:
double d = cos(M_PI/2);
for (int k = -9; k <= 9; k++) {
double dx = d + DBL_EPSILON * k;
printf("%2d %18.15f: it is%s zero.\n", k, dx, (dx == 0.0) ? "": " not");
}
用 你 從 餘 弦 函 數 計 算 得 到 的 值 , 減 去 9*DBL_EPSILON , 每 次 加 一 個
DBL_EPSILON,再將每個值與 0 比較。下面是得到的結果:
-7 -0.000000000000001: it is not zero.
-6 -0.000000000000001: it is not zero.
-5 -0.000000000000001: it is not zero.
-4 -0.000000000000001: it is not zero.
-3 -0.000000000000001: it is not zero.
-2 -0.000000000000000: it is not zero.
-1 -0.000000000000000: it is not zero.
0 0.000000000000000: it is not zero.
1 0.000000000000000: it is not zero.
2 0.000000000000001: it is not zero.
3 0.000000000000001: it is not zero.
4 0.000000000000001: it is not zero.
5 0.000000000000001: it is not zero.
6 0.000000000000001: it is not zero.
7 0.000000000000002: it is not zero.
太整齊了! 由於取整的原因, 所有位於-2*DBL_EPSILON 和+DBL_EPSILON
43
C 語言實用之道
之間的值, 都會產生正確的 15 位十進位制數字結果。 但沒有一種情況讓結果等於 。
如果將浮點數的格式從%18.15f 改成%20.17f,再列印餘弦值,將可以顯示接
下來的兩個十進位制數字:
0.00000000000000006: it is not zero.
現在可以看到, 儘管計算得到的值與 0 之間的差值小於 DBL_EPSILON 的三
分之一,但仍不是 。
可以這樣做,不是檢查兩個浮點數是否相等, 而是判斷, 如果它們的差小於
對應的 EPSILON(float 型別對應 FLT_EPSILON,double 型別對應 DBL_EPSILON,
long double 型別對應 LDBL_EPSILON),那麼它們相等。
雖然這樣做聽起來很合理,但實際上並非好的測試, 因為這僅適用於那些遠
遠大於 EPSILON 的數值。例如,考慮兩個 double 數值 1.23e-14 和 1.22e-14。double
型別可以儲存 15 個有效數字, 這兩個數值已經在第三個有效數字上有了差異。 因
此,它們顯然是不同的。然而,當計算它們的差值時,會得到 2e-16,小於
DBL_EPSILON(它近似於 2.2e-16)。
換句話說, 僅僅基於兩個數值的差(顯然是指絕對值)是否小於 EPSILON 來測
試它們是否相等, 顯然還不夠好。 這裡例子中的兩個數值比 DBL_EPSILON 大了
兩個數量級, 對於更大的數, 同樣的問題也會發生。 而且, 所有絕對值小於 EPSILON
的數值都被認為是相等的。
這一問題發生的原因是,不能用 EPSILON 作為絕對的條件,因為 EPSILON
僅僅告訴你在一個浮點變數中可以儲存多少十進位制數字。
EPSILON 只代表一個較低的限值, 若差值在這個限值之下, 可以認為兩個數
值相等。在現實情況下,可能只需要一個更寬鬆的條件就可以滿足了。例如, 如
果正在比較的數值是實際測量的結果,那麼使用比測量過程和裝置所能提供的有
效位數多得多的位數並沒有意義。
這裡有一點微妙, 因為我們的大腦對於絕對數值可以工作得很好,但傾向於
忽略有效數字,而這會導致無效的結果。 如果還不相信, 請考慮這一點:現代測
量裝置都有數字化的顯示功能,但並不顯示的所有數字都是有意義的。例如, 如
果在使用一個精度為±0.01V 的電壓表,測量近似於 100V 的電壓,那麼可以正確
地寫下測量結果是 100.00±0.01V。 但是, 如果在測量 1V 的電壓時有同樣的精度,
並且只有三個有效數字,那麼應該寫成 1.00±0.01V,儘管實際上該裝置可能把測
量的值顯示成 1.0000V。
同樣的情況也適用於儲存在計算機中的浮點數。
為了正確地檢查兩個浮點數是否相等, 首先需要檢查它們的符號位是否相同。
如果符號位不相同,那麼這兩個數肯定不同。
44
第 2 章 微 妙 之 C
下一步是比較指數。記住,當編譯器在記憶體中儲存一個浮點數時,計算指數
的方式是:最高的非 0 位在小數點的左邊(因此可以被丟棄)。這意味著,如果兩
個指數不相同,那麼這兩個浮點數肯定不相同。
一旦檢查發現符號位和指數是相同的,就可以檢查尾數部分是否有足夠的位
數是相同的, 以滿足對相等性判斷的要求。 也就是說, 如果兩個數的前 N 個十進
制數字是相同的(可以多達表 2-2 中列出的十進位制數字的個數), 就認為它們是相等
的, 為此需要檢查 N/log(2)位或 N*3.322。 例如, 如果正在處理數值, 當它們有相
同的最高 4 個數字時, 認為它們是相等的, 應該比較尾數的前 4*3.322=13.29 位(即
14 位)。
但這並不是一種可靠的工作方式, 因為需要比較的位數對於所有的數並不都
是相同的。你剛才看到了,對於四個十進位制數字,應該比較尾數的 13.29 位。對
於有些數, 13 位足夠了;而對於其他數,則需要 14 位。唯一確定的是,為了比
較浮點數,需要確定想要多少尾數位相同。
這不是一種讓人舒服的檢查相等性的方法,但是我們將繼續努力,想辦法完
成這個目標,因為這是一個很好的練習。
程式碼清單 2-10 顯示了函式 num_fltcmp( ), 它不是簡單地檢查兩個浮點數是否
相等,而且還判斷哪個數更大。
程式碼清單2-10 num_fltcmp( )
1. //--------------------------------------------------------- num_fltcmp
2. int num_fltcmp(float a, float b, unsigned int n_bits) {
3. if (n_bits > FLT_MANT_DIG - 1) n_bits = FLT_MANT_DIG - 1;
4. if (a == b) return 0; //-->
5. union ieee754_float *aa = (union ieee754_float *)&a;
6. union ieee754_float *bb = (union ieee754_float *)&b;
7.
8. // Compare the signs.
9. char a_sign = (char)aa->ieee.negative;
10. char b_sign = (char)bb->ieee.negative;
11. if (a_sign != b_sign) return b_sign - a_sign; //-->
12. if (a == 0) return ((b_sign) ? 1 : -1); //-->
13. if (b == 0) return ((a_sign) ? -1 : 1); //-->
14.
15. // Compare the exponents.
16. char a_exp = (char)aa->ieee.exponent - 127;
17. char b_exp = (char)bb->ieee.exponent - 127;
18. if (a_exp != b_exp) {
19. int ret = (a_exp > b_exp) ? 1 : -1;
20. return (a_sign) ? -ret : ret; //-->
21. }
22.
23. // Compare the mantissas.
45
C 語言實用之道
24. int n_shift = (int)sizeof(unsigned int) * 8 - FLT_MANT_DIG + 1;
25. unsigned int a_mant = (unsigned int)aa->ieee.mantissa << n_shift;
26. unsigned int b_mant = (unsigned int)bb->ieee.mantissa << n_shift;
27.# define MASK 0x80000000 // 2^31
28. for (int k = 0; k < n_bits; k++) {
29. if ((a_mant & MASK) != (b_mant & MASK)) {
30. int ret = (a_mant & MASK) ? 1 : -1;
31. return (a_sign) ? -ret : ret; //-->
32. }
33. a_mant <<= 1;
34. b_mant <<= 1;
35. }
36. # undef MASK
37. return 0;
38. } // num_fltcmp
為了使這段程式碼可以正常工作, 需要包含標準標頭檔案 float.h、ieee754.h 和 math.h。
注意, gcc 連結器要求顯式地連結 GNU 數學庫, 預設情況下它不包含在內。 為了做
到這一點,在 GNU/Linux 上,需要指定選項-lm 和-L /usr/lib/x86_64-linux-gnu/。
也請注意,首先檢查兩個浮點數是否相同(第 4 行)。這是有可能的,如果就
是這樣的情況,立即返回 。同樣, 也有可能兩個數都是 。
如果一個數是負的,那麼它肯定小於另一個。 因此,如果符號位不相同,那麼
可以返回它們的差值(第 11 行)。請注意, 從哪個數減去哪個數決定了何時返回-1 和
1。在 num_fltcmp( )中使用的約定與在標準庫函式 strcmp( )和 memcmp( )中使用的
約定相同:當第一個數小於第二個數時返回-1。
現在, 如果兩個符號相同,那麼需要考慮這樣的可能性: 其中一個數正好是
一個特例, 即它的所有位是 。 注意, 它們不可能都是 , 因為已經在第 4 行檢查
過這種可能性了。
如果第一個數是 0(第 12 行),那麼當第二個數是正數時,它小於第二個數。
類似地,在第 13 行,如果第二個數是 ,那麼當第一個數是正數時返回 1。
在考慮了符號位以及其中一個或兩個數為 0 的可能性之後, 可以比較指數了。
我們知道, float 型別的指數佔據八位。因此,在第 16 和第 17 行,我們可以
用一個 char 變數來儲存指數。如果執行如下程式碼:
float f1;
union ieee754_float *ff1 = (union ieee754_float *)&f1;
f1 = 1e-38;
char exp = (char)ff1->ieee.exponent - 127;
printf("%d\n", exp);
f1 = 1;
exp = (char)ff1->ieee.exponent - 127;
printf("%d\n", exp);
f1 = 3e+38;
exp = (char)ff1->ieee.exponent - 127;
46
第 2 章 微 妙 之 C
printf("%d\n", exp);
將會得到:
-127

127
再回到 num_fltcmp( )的程式碼上,如果指數不相同,那麼當 a_exp 大於 b_exp
時,在第 20 行返回 1(只有當這兩個數都是正數的時候)。當它們是負數時,越小的
指數對應越大的數。
是否還記得 FLT_MANT_DIG 在計數中包含了符合位。 因此, 在第 24 行計算的
數值 n_shift 是需要對尾數(不含符號位)進行移位的位數,這樣可以得到 unsigned int
變數的最高位。
於是,a_mant 和 b_mant(參見第 25 和第 26 行)的 MSB 也是對應尾數的 MSB。
這相當於對尾數做了“左對齊”, 因而可以很容易地逐位進行檢查: 透過重複地將
數值向左移動一位, 所有的位依次佔據最高位。 這正是第 28 行開始的 for 迴圈的
功能,兩個尾數的左移操作發生在第 33 和第 34 行。使用這一演算法的好處是,無
須為它們中的每一位使用不同的掩碼,就可以檢查所有的位。
迴圈繼續進行 n_bits 次,但是,若兩個對應的位不相同,迴圈將中斷,透過
return 返回。 第 29 行完成對兩個尾數的位的比較。 一旦確定兩個位不相同, 那麼
如果第一個尾數的位為 1, 就意味著第二個尾數的對應位為 ,於是 a 大於 b。但
是, 只有當兩個數是正數時這才成立(我們知道它們有相同的符號, 否則我們在第
11 行就返回了)。如果它們是負數(即兩者的符號位為 1),那麼 a 小於 b。
在繼續往下討論以前, 我們先來確認一下:決定兩個尾數需要有多少位相同
並不等同於它們有同樣數量的十進位制數字相同。為了讓你看到這一點,我們執行
程式碼清單 2-11 中的程式碼。
程式碼清單 2-11 測試 num_fltcmp( )
1. int N = 3;
2. srand(123456789);
3. float max_x = 10.0;
4. float d[] = {
5. -0.1, -0.01, -0.001, -0.0001, -0.00001, -0.000001, 0,
6. 0.000001, 0.00001, 0.0001, 0.001, 0.01, 0.1
7. };
8. int nd = sizeof(d) / sizeof(float);
9. for (int k = 0; k < N; k++) {
10. float x = (float)rand() / RAND_MAX * max_x;
11. printf("\n%9.7f ", x);
12. for (int i = 1; i < FLT_MANT_DIG; i++) printf("%2d", i % 10);
13. printf("\n");
47
C 語言實用之道
14. for (int j = 0; j < nd; j++) {
15. printf("%10f:", d[j]);
16. for (unsigned int i = 1; i < FLT_MANT_DIG; i++) {
17. int res = num_fltcmp(x, x + d[j], i);
18. if (res) printf(" %c", (res > 0) ? '+' : '-');
19. else printf(" ");
20. }
21. printf("\n");
22. }
23. }
選擇 N 個隨機的浮點數(第 2 和第 10 行), 並且將其中每一個數與另一個數進
行比較(第 9 行開始的 for 迴圈), 被比較的第二個數是在第一個數的基礎上加上一
個隨機的差量(儲存在陣列 d 中,參見第 4 至第 7 行)。當對每一個隨機數及其變
種進行比較時,指定比較的位數為: 1 和尾數的最大位數 23 之間的每一個數。
程式碼清單 2-12 顯示了前三個數的結果(對偽隨機種子的選擇純粹是任意的)。
程式碼清單2-12 測試num_fltcmp( )的輸出
1. 9.1507225 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3
2. -0.100000: + + + + + + + + + + + + + + + + + +
3. -0.010000: + + + + + + + + + + + + + +
4. -0.001000: + + + + + + + + + + + +
5. -0.000100: + + + + + + + + +
6. -0.000010: + + + +
7. -0.000001: + + +
8. 0.000000:
9. 0.000001: -
10. 0.000010: - - - - - -
11. 0.000100: - - - - - - - -
12. 0.001000: - - - - - - - - - - -
13. 0.010000: - - - - - - - - - - - - - - - -
14. 0.100000: - - - - - - - - - - - - - - - - - - -
15.
16. 7.6355686 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3
17. -0.100000: + + + + + + + + + + + + + + + + + + +
18. -0.010000: + + + + + + + + + + + + + + +
19. -0.001000: + + + + + + + + + + + + +
20. -0.000100: + + + + + + + + + +
21. -0.000010: + + + + + + + +
22. -0.000001: + + +
23. 0.000000:
24. 0.000001: - -
25. 0.000010: - - - - - -
26. 0.000100: - - - - - - - - -
27. 0.001000: - - - - - - - - - - - -
28. 0.010000: - - - - - - - - - - - - - - - -
29. 0.100000: - - - - - - - - - - - - - - - - - -
30.
31. 3.2907567 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3
48
第 2 章 微 妙 之 C
32. -0.100000: + + + + + + + + + + + + + + + + + + + + +
33. -0.010000: + + + + + + + + + + + + + + + + + +
34. -0.001000: + + + + + + + + + + + + +
35. -0.000100: + + + + + + + + +
36. -0.000010: + + + + + + +
37. -0.000001: + + + + + + +
38. 0.000000:
39. 0.000001: - - -
40. 0.000010: - - - - - -
41. 0.000100: - - - - - - - - - - -
42. 0.001000: - - - - - - - - - - - - - -
43. 0.010000: - - - - - - - - - - - - - - - - -
44. 0.100000: - - - - - - - - - - - - - - - - - - - -
每個數值的結果的第一行顯示了這個數值本身,並提供了一行標題, 指明瞭
在考慮相等性判斷時尾數有多少位參與決定。 下面的每一行顯示了對應起作用的
隨機數,以得到第二個用於比較的數,以及相應的尾數位數的所有選擇。加號表
示 num_fltcmp( )識別出第一個數大於第二個數,而空格表示兩者相等。
結果表明, num_fltcmp( )函式的行為依賴於差量的符號。例如,當從第一個
數減去 0.000001 時(第 7 行), num_fltcmp( )函式識別出, 當至少考慮 21 位尾數時,
兩個數是不同的。 但是, 當同樣的差量加到這個數上時(第 9 行), 需要所有 23 位
才能識別出兩個數是不同的。
當比較不同數值的結果時,可以看到,為了識別出兩個數的不同,同樣的差
量要求不同的位數。例如,非常引人注目的一個例子,-0.1 的差量應用在第三個
數上(第 31 行),用三位尾數就可以識別出來,而同樣的差量應用在第二個數上,
則要求五位; 應用在第一個數上, 要求六位。 同時也請注意, 在所有三個例子中,
該差量都沒有引起借位,因此不會引起前面的十進位制數字發生改變。
記住, 一個十進位制位對應大約 3.3 位(只要想一想, 一個因子 8 就正好等於 3 位,
就不會覺得奇怪了)。因此, 可以很容易找到由於轉換和取整而引發的幾位差異。
在本章所附的原始碼中,也能找到函式 num_fltequ( )。它是 num_fltcmp( )的
簡化版本, 當它發現兩個數相等(在給定的容許範圍內)時, 返回 1, 不相等時返回
。 程式碼更新很簡單, 這裡不再列出程式碼了。 但是, 你需要知曉, 針對 num_fltcmp( )
的差量問題也同樣適用於 num_fltequ( )。
程式碼清單 2-13 顯示了 num_fltcmp( )的 double 等價版本。
程式碼清單2-13 num_dblcmp( )
1. //------------------------------------------------------------- num_dblcmp
2. int num_dblcmp(double a, double b, unsigned int n_bits) {
3. if (n_bits > DBL_MANT_DIG - 1) n_bits = DBL_MANT_DIG - 1; //#
4. if (a == b) return 0; //-->
5. union ieee754_double *aa = (union ieee754_double *)&a; //#
49
C 語言實用之道
6. union ieee754_double *bb = (union ieee754_double *)&b; //#
7.
8. // Compare the signs.
9. char a_sign = (char)aa->ieee.negative;
10. char b_sign = (char)bb->ieee.negative;
11. if (a_sign != b_sign) return b_sign - a_sign; //-->
12. if (a == 0) return ((b_sign) ? 1 : -1); //-->
13. if (b == 0) return ((a_sign) ? -1 : 1); //-->
14.
15. // Compare the exponents.
16. int a_exp = (char)aa->ieee.exponent - 1023; //#
17. int b_exp = (char)bb->ieee.exponent - 1023; //#
18. if (a_exp != b_exp) {
19. int ret = (a_exp > b_exp) ? 1 : -1;
20. return (a_sign) ? -ret : ret; //-->
21. }
22.
23. // Compare the mantissas.
24. unsigned long a_mant = (unsigned int)aa->ieee.mantissa1
25. | (unsigned long)aa->ieee.mantissa0 << 32
26. ; //#
27. unsigned long b_mant = (unsigned int)bb->ieee.mantissa1
28. | (unsigned long)bb->ieee.mantissa0 << 32
29. ; //#
30. int n_shift = (int)sizeof(unsigned int) * 8 - DBL_MANT_DIG + 32 + 1; //#
31. a_mant <<= n_shift;
32. b_mant <<= n_shift;
33. # define MASK 0x8000000000000000 //# 2^63
34. for (int k = 0; k < n_bits; k++) {
35. if ((a_mant & MASK) != (b_mant & MASK)) {
36. int ret = (a_mant & MASK) ? 1 : -1;
37. return (a_sign) ? -ret : ret; //-->
38. }
39. a_mant <<= 1;
40. b_mant <<= 1;
41. }
42. #undef MASK
43. return 0;
44. } // num_dblcmp
num_dblcmp( )函式在功能上等同於 num_fltcmp( )。 但還是有一些不同, 為了
方便理解,我已經把所有改變的/新增的程式碼行用註釋//#做了標記。在程式碼清單
2-13 中, 第 3、第 5、第 6、第 16、第 17 和第 33 行中的改變都非常直接, 但是對
第 24 至第 32 行(替代了程式碼清單 2-10 中顯示的 num_fltcmp( )的第 24 至第 26 行)
還是需要做些說明。
float 型別的尾數是一個 23 位的域(ieee754.h 中的 ieee754_float.ieee.mantissa)。因
50
第 2 章 微 妙 之 C
此, 它可以儲存在單個 unsigned int 型別的變數中。 但是, double 型別的尾數包含 52
位, 它被定義在兩個單獨的位域(ieee754_double.ieee.mantissa1 和 ieee754_double.ieee.
mantissa0)中。 因此, 需要將兩部分合在一起, 放在一個 unsigned long 型別的變數
中,注意,在 unsigned long 型別中有 64 – 52 = 12 位未使用,它們位於 unsigned long
的低位。只有儘可能地把尾數向左移位, 才可能用掩碼來測試所有的位。可以將
尾數向右移位, 並且用 1 作為掩碼來測試所有的精度, 但隨後將從 LSB 開始測試
這些位,而你實際上卻想要從 MSB 開始測試它們。
由於 ieee754_double.ieee.mantissa1 在前面,這意味著它是低位部分(小端系統,
還記得嗎? )。因此,只需要將它賦給 unsigned long(第 24 和第 27 行)。但為了拷
貝尾數的高 32 位(即 ieee754_double.ieee.mantissa0),需要將它強制轉換成 unsigned
long,再左移 32 位,然後執行或(or)操作。
這可能有點讓人混淆。 或許一張簡單的圖形有助於理解。 mantissa1(該域包含
尾數的低 32 位)在記憶體中的儲存形式是:
AAAAAAAA BBBBBBBB CCCCCCCC DDDDDDDD
^ ^
LSB MSB
這裡 AAAAAAAA 代表最低位元組, DDDDDDDD 代表最高位元組。 在程式碼清單
2-13 的第 24 行,將第一個數的 mantissa1 域複製到 a_mant,於是 a_mant 變成:
AAAAAAAA BBBBBBBB CCCCCCCC DDDDDDDD 00000000 00000000 00000000 00000000
^
LSB
^
MSB
mantissa0 在記憶體中的儲存形式是:
EEEEEEEE FFFFFFFF 0000GGGG 00000000
^ ^
LSB MSB
因為在位域 mantissa0 中只定義了最低 20 位,所以當在第 25 行將 mantissa0
強制轉換成 unsigned long,變成:
EEEEEEEE FFFFFFFF 0000GGGG 00000000 00000000 00000000 00000000 00000000
^ ^
LSB MSB
然後,當左移 32 位時, 把四個低位元組移到了高記憶體位置(再次宣告,因為低
位元組儲存在前)。 也就是說, 把最高的四個位元組丟掉, 替換為前面的四個位元組, 其
中包含尾數的 20 個高位。結果得到的 unsigned long 如下:
51
C 語言實用之道
00000000 00000000 00000000 00000000 EEEEEEEE FFFFFFFF 0000GGGG 00000000
^ ^
LSB MSB
若將轉換後的 mantissa0 按位 OR 到 a_mant,將得到:
AAAAAAAA BBBBBBBB CCCCCCCC DDDDDDDD EEEEEEEE FFFFFFFF 0000GGGG 00000000
^ ^
LSB MSB
為了將尾數的 MSB(最高位)變成 a_mant 的 MSB, 現在需要將 a_mant 左移 12
位,這正是第 30 行所做的事。這樣得到的結果是:
00000000 AAAA0000 BBBBAAAA CCCCBBBB DDDDCCCC EEEEDDDD FFFFEEEE GGGGFFFF
^ ^
LSB MSB
如果記憶體中的表示形式是大端,即最高的位元組在最低的記憶體位置,那麼 a_mant
的儲存形式類似如下(將第一個位元組與最後的位元組交換, 將第二個位元組與最後第二
個位元組交換,等等):
GGGGFFFF FFFFEEEE EEEEDDDD DDDDCCCC CCCCBBBB BBBBAAAA AAAA0000 00000000
^ ^
MSB LSB
這樣的表示形式要清晰得多,對不對?
函式 num_ldblcmp( )與 num_dblcmp( )幾乎相同。可以在本章所附的原始碼中
找到該函式,連同測試所有這些比較函式的程式碼。
但是,如下程式碼行:
if (n_bits > LDBL_MANT_DIG - 1) n_bits = LDBL_MANT_DIG - 1; //#
這 裡 出 現 -1 的 原 因 與 num_dblcmp( ) 的 第 3 行 中 的 -1 有 所 不 同 。 在
num_ldblcmp( )中,減去 1 的原因是,尾數的最高位沒有被丟掉;而在 num_dblcmp( )
中, 減去 1 的原因是, DBL_MANT_DIG 包含了符號位(正如前面討論過的, LDBL_
MANT_DIG 並沒有包含符號位)。
這一差別反映在程式碼清單 2-13 的第 30 至第 32 行, 在 num_ldblcmp( )中變成:
a_mant <<= 1;
b_mant <<= 1;
至於與 num_fltequ( )對應的 double 和 long double 版本的函式,留作練習。
代 碼 清 單 2-14 至 2-16 顯 示 了 三 個 工 具 函 數 , 你 可 能 會 用 得 著 :
num_to_big_endian( )交換位元組的前後順序; num_binprt( )以二進位制的形式列印出給
定數量的位元組; num_binfmt( )將一組位元組格式化成一個字串,每位一個字元。
52
第 2 章 微 妙 之 C
程式碼清單2-14 num_to_big_endian( )
//---------------------------------------------------- num_to_ big_endian
void num_to_big_endian(void *in, void *out, int n_bytes) {
unsigned char *from = in;
unsigned char *to = out + n_bytes - 1;
for (int k = 0; k < n_bytes; k++) *to-- = *from++;
} // num_to_big_endian
程式碼清單2-15 num_binprt( )
//----------------------------------------------------------- num_binprt
void num_binprt(void *p, int n, int space, int line) {
unsigned char c;
while (n > 0) {
c = *((unsigned char *)p++);
for (int nb = 0; nb < 8 && n > 0; nb++) {
printf("%c", (c & 128) ? '1' : '0');
c <<= 1;
n--;
}
if (space) printf(" ");
}
if (line) printf("\n");
} // num_binprt
程式碼清單2-16 num_binfmt( )
//--------------------------------------------------------- num_binfmt
void num_binfmt(void *p, int n, char *s, int space) {
unsigned char c;
while (n > 0) {
c = *((unsigned char *)p++);
for (int nb = 0; nb < 8 && n > 0; nb++) {
*s++ = (c & 128) ? '1' : '0';
c <<= 1;
n--;
}
if (space) *s++ = ' ';
}
*s = '\0';
} // num_binfmt
在第 11 章討論嵌入式軟體時,你將會看到關於 num_binfmt( )的描述。
2.9 本章小結
在本章中, 你已經熟悉了 C 語言中經常引發問題的一些方面。 尤其是, 你已
經學習了區域性變數和全域性變數的區別、用於向函式傳遞引數的按值呼叫的含義、
為什麼使用布林變數可能會被誤導、如何使用區域、 如何使用寬字元和字串、
浮點數在記憶體中是如何儲存的以及如何處理浮點數

購買地址:

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/26421423/viewspace-2217468/,如需轉載,請註明出處,否則將追究法律責任。

相關文章