ITPub博客

首页 > 应用开发 > C/C++ > C语言实用之道

C语言实用之道

原创 C/C++ 作者:qinghuawenkang 时间:2018-10-25 15:37:26 0 删除 编辑


大数据应用与技术丛书
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 号
责任编辑 :王 军 李维杰
装帧设计 :孔祥峰
责任校对 :曹 阳
责任印制 :李红英
出版发行 :清华大学出版社
网 址 : http://www.tup.com.cn http://www.wqbook.com
地 址 :北京清华大学学研大厦 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 年初以来, 他住在澳大利亚堪培拉以北几公里处的宁静的郊区, 在这
里他致力于他的许多兴趣,特别是编写软件来生成和解决数字难题。访问他的网
站 http://zambon.com.au/, 可以看到他撰写的论文和所著书籍的完整列表。



目 录
第 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)的网站(www.chemie.fuberlin.de/
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
要想找到更多关于区域名称的信息, 可以参考 www.gnu.org/software/libc/manual/
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 编码的地方是 www.utf8-chartable.de。
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)提
供了文档: www.csee.umbc.edu/~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 语言中经常引发问题的一些方面。 尤其是, 你已
经学习了局部变量和全局变量的区别、用于向函数传递参数的按值调用的含义、
为什么使用布尔变量可能会被误导、如何使用区域、 如何使用宽字符和字符串、
浮点数在内存中是如何存储的以及如何处理浮点数

购买地址:

http://product.dangdang.com/25273955.html

来自 “ ITPUB博客 ” ,链接:http://blog.itpub.net/26421423/viewspace-2217468/,如需转载,请注明出处,否则将追究法律责任。

请登录后发表评论 登录
全部评论
分享计算机前沿技术和国外计算机先进技术书籍。

注册时间:2011-11-08

  • 博文量
    54
  • 访问量
    105097