可移植性和C语言
章节大纲
-
各版本前言
第1版,1989年(经轻度编辑)
[Howard W. Sams, Hayden Books, ISBN 0-672-48428-5,1989年]
1986年初,我受邀为C语言的可移植性讲授为期三天的研讨会。这个研讨会原本计划在美国多个主要城市举办,虽然最终系列课程被取消,但我已经准备了一份70页的手稿,打算作为讲义使用。
自从我开始接触C语言以来,就一直对它的“双重身份”感到着迷——C既是一种底层的系统实现语言,同时又声称具备可移植性。每当有人兴奋地谈论C语言“本质上的可移植性”时,我总感到不安:要么是我,要么是C语言社区中的相当一部分人忽视了“C语言图景”的某些重要组成部分。结果看来,问题应该不在我。尽管如此,许多写得不错的C代码确实可以相对轻松地进行移植。
由于我既有一份关于可移植性的基础文稿,又对C语言及其标准特别感兴趣,我决定对C语言的可移植性展开正式而深入的研究。同时,由于我长期从事C语言咨询和培训工作,也增强了我将这份为期三天研讨会准备的材料拓展成书籍的决心。过程中我逐渐意识到,这最终的成果值得出版成书。
起初我预计这本书约为200页,后来扩展到300、400,最后定为425页,还是在删减了许多附录的前提下,仅因篇幅限制。而这些“剪辑室地板上的内容”实用性很强,我也在考虑是否通过未来修订版或姊妹篇来发布这些内容。无论如何,这本书并未包含我所有的研究成果。
本书旨在记录在移植现有C代码或编写将要移植到多个目标环境的代码时,可能遇到的C语言特定问题。我说“旨在”是因为本书并不能提供所有答案,很多时候甚至不假装能解决问题。例如,如果你要在不同UNIX版本之间移植代码,本书并不会讨论该操作系统的所有阴暗角落。但我仍认为它是一个可信的起点,为后续作品打下基础。据我所知,这是第一本广泛出版的、专门讨论C语言可移植性、篇幅超过20-30页的书籍。
由于我对3到4个操作系统和硬件环境的了解较为深入,因此本书必然会遗漏某些相关问题;反过来也可能过多地探讨一些仅存在于理论中的晦涩细节。
无论你对可移植性感兴趣的出发点是什么,我都希望本书能给你带来启发——即使它只是帮助你意识到“可移植性并不适合你”,那它也算是大获成功了。反之,如果它能帮助你制定移植策略,或者避免走一些弯路,那我也很欣慰。无论你对本书有何看法,欢迎告诉我。唯有通过建设性的批评、外部意见和持续实践,我才能在未来修订版或姊妹篇中加以改进。
任何写过面向大众的长文档的人都知道:读到第三遍之后,你已经不再“读”你写的内容,而是“看见”你本应写进去的内容。因此,你需要有技术能力的校对者来提供批评性意见。在这方面,以下人士为我校对了全部或大部分手稿,做出了重要贡献:Steve Bartels、Don Bixler、Don Courtney、Dennis Deloria、John Hausman、Bryan Higgs、Gary Jeter、Tom MacDonald 和 Sue Meloy。尽管我采纳了他们许多建议,但因时间和篇幅所限,未能充分利用他们在结构等方面的所有建议。就像软件公司常说的:“我们得给下一版留点内容。”
还有几位对我这段虽短但极其密集的C语言生涯影响深远的人:
-
P.J. Plauger,C标准秘书,ISO C召集人,Whitesmiths Ltd. 总裁;
-
Tom Plum,C标准副主席、Plum Hall 主席、著名C语言作者;
-
Larry Rasler,曾任C标准草案编辑,AT&T C标准委员会代表(现任惠普公司);
-
Jim Brodie,独立顾问(曾在摩托罗拉),自1983年中期起担任C标准委员会主席,并成功主持委员会运作直至1988年左右。
我还要感谢C标准X3J11委员会的各位同仁,有幸与你们合作十分荣幸。没有你们的论文、报告,以及在会议内外的激烈(有时甚至“volatile”)讨论,本书的内容质量和数量都难以达到出版标准。
Rex Jaeschke
第2版,2021年
快进到32年后的今天,C语言世界发生了翻天覆地的变化。尤其值得一提的是:
-
C95、C99、C11 和 C17 标准相继发布;
-
C++ 已成为标准语言,并多次修订;
-
16位系统已近绝迹,甚至32位系统也不再普遍,主流已全面转向64位;
-
不支持C89之前版本的编译器已极少见,但早期代码仍可能在使用。
我进行本次修订的契机来自于我的遗产规划,我问自己:“如果我什么也不做,我的知识产权在我去世后会怎样?”显然,它们将会丢失!因此我决定为这些内容寻找一个公共平台,使其能被阅读,并(希望是负责任地)持续更新。
一旦决定要修订,我就下定决心要狠删内容。(我是《Strunk and White》“少即是多”理念的坚定信徒!)我移除了所有与可移植性无关的内容,删减了大量关于标准库的章节内容。1988年时,第一个C标准即将发布,关于标准库的权威资料匮乏,因此第一版中包含了这些内容。而现在完全没有必要——你可以轻松获取可检索的C和C++标准文档。
这次我还做了两个重要决定:
-
承认可以移植非标准C代码,即使它从未、也永远不会符合C标准;
-
提及C++:C++应用广泛,许多程序员从C++中调用C函数,或者用C++编译器编译C代码。
当然,这一版也终将过时——写作时,C标准委员会正在定稿C23!
第一版中附录列出了按不同方式排列的保留标识符列表。我这次选择不再收录这些内容,原因包括:
-
自C89以来,新增了大量标识符;
-
C23即将发布,更新列表工作量极大;
-
校对者们对列表的呈现方式意见不一,难以兼顾易读性与实用性。
特别感谢以下本版的审阅者:Rajan Bhakta、Jim Brodie、Doug Gwyn、David Keaton、Tom MacDonald、Robert Seacord、Fred Tydeman 和 Willem Wakker。
Rex Jaeschke
本书未来修订方向
更新本书的理由包括:
-
修正拼写或事实错误
-
扩展某些主题
-
增加具体的移植场景、目标硬件和操作系统的细节
-
增加标准C与C++之间的不兼容项
-
涵盖未来版本的C和C++标准
-
扩展与C99及之后版本新增头文件相关的问题,尤其是浮点支持
-
处理与可选IEC 60559(IEEE 754)浮点和复数支持相关的问题
-
增补与可选扩展库有关的问题
-
汇总未定义、不确定、实现定义、本地化相关行为的新实例
-
充实“目标读者”章节
-
考虑发布可下载的保留标识符列表(按头文件和标准版本分类)
对于具体的库函数,若有关于可移植性的评论但本书中尚未收录该函数,需要先创建条目再添加内容。
如您有意参与补充本书,请务必精确,并使用C标准中的术语。所有内容应仅写一次,并在其他地方引用。
目标读者
审阅者 Willem Wakker 写道:
我浏览了整本文档,认为它非常实用。但我不太确定目标读者是谁。您的引言中未提及目标对象。一位经验丰富的C程序员可能会认为:“我已经知道所有技术细节了,不需要这本书。”
实际上,可移植性如同安全性,必须在项目伊始就加以考虑。而在初期阶段,相较于书中详尽的技术细节,更需要的是关于可移植性的整体认知。因此,本书也许应出现在项目中的管理者视野中,由他们“推动”程序员采纳书中的建议。然而,对于这些非技术管理者来说,本书又显得太“技术性”,难以引起他们兴趣。因此,在书前加入几段专门面向管理者、介绍可移植性概念和重要性的内容,也许会是个很好的补充。
我的回应:目前,我将此部分作为占位符添加。但我不会亲自撰写内容,而是留待出版后由读者根据需要自行扩展。
读者预期与建议
本书不教授C语言基础语法,也不讲授C标准。部分段落甚至会像C语言一样“简洁”。虽然我尝试尽可能通俗表达,但仍不为保留的技术性内容道歉。可移植性从不是初学者或实习程序员的关注点——恰恰相反。
本书专注于与语言相关的C代码移植问题。但它不是移植指南,也不提供跨平台移植成功的通用方案,只是详细列出你可能遇到或需要研究的各种问题。本书假定读者已经熟悉C语言的基本结构、运算符、语句、预处理器指令、数据/函数指针及标准运行时库的使用。
C标准及其附带的Rationale文档与本书在结构上保持一致,拥有这两份文件会非常有帮助(虽然不是必需)。不过Rationale写得更通俗,适合非语言学家阅读。值得一提的是,我在1984–1999年间参与了C标准委员会工作,因此本书用语大量借鉴C标准术语。
书中提到“K&R”指的是 Kernighan 和 Ritchie 在1978年出版的《C程序设计语言》第一版。
书中提到的“Standard C”涵盖所有版本,若某特性出自特定版本,则明确注明,如C99。C90只是C89的ISO封装,因此未单独标注。
C标准历史
-
C89:1989年由美国X3J11委员会制定的ANSI标准,首次C语言标准。
-
C90:1990年发布的第一个ISO C标准,与C89技术上等效。
-
C95:1995年对C90的补充修正。
-
C99:第二版ISO C标准,1999年发布。
-
C11:第三版ISO C标准,2011年发布。
-
C17:2018年发布,属于维护版本,无新功能。
-
C23:计划发布的第五版。
关于C++的说明
书中部分段落标注“C++ Consideration”,因为C++被广泛使用,许多程序员从C++调用C函数或用C++编译器编译C代码。但C++并非C的超集,因此理解两者不兼容的地方十分重要。C++标准社区中有句名言:“尽量贴近标准C,但不要更近了!”
可移植性相关术语(摘自C17标准)
-
Unspecified behavior(未指定行为):文档提供多种可能结果,但未规定使用哪一种。
-
Undefined behavior(未定义行为):程序结构或数据错误导致的行为,标准不作要求。
-
Implementation-defined behavior(实现定义行为):各实现需自行说明如何处理的行为。
-
Locale-specific behavior(本地化行为):依赖于国家、文化、语言约定的行为,由实现说明。
尽管本书包含许多此类行为的实例,但完整列表见于C标准的“可移植性问题”附录。
关于“实现依赖”
虽然符合标准的实现必须记录实现定义行为,但本书使用“实现依赖(implementation-dependent)”一词来描述不需要被标准记录的实现特性。
关于“废弃特性”
C89标准曾指出,某些特性为“过时(obsolescent)”,虽因广泛使用而保留,但不建议在新实现或新程序中使用。后来各版本标准通过“弃用(deprecate)”方式正式声明某些特性将逐步移除。
标准委员会宗旨节选
从成立之初,C标准委员会便有一套宗旨,以下条目尤为重要:
-
第2项:C代码应具备可移植性。尽管C语言诞生于UNIX和PDP-11,但它已被广泛应用于各类系统,包括嵌入式系统的交叉编译。
-
第3项:C代码也可以不具备可移植性。标准并不强制程序员编写可移植代码,保留“高级汇编语言”能力是C语言的一大优势。这正是“严格符合程序”与“符合程序”区别的核心原因之一。
-
-
定义可移植性
根据 Robert A. Edmunds 编著的《普伦蒂斯-霍尔计算机术语标准词典》,可移植性定义如下:“可移植性:一个与兼容性相关的术语。可移植性决定了一个程序或其他软件可以从一个计算机系统移动到另一个计算机系统的程度。” 这里的关键短语是“一个程序可以被移动的程度”。
来自维基百科:“在软件工程中,移植是将软件适配到与给定程序(为执行此目的而设计)最初设计的计算环境不同的计算环境中执行的过程(例如,不同的 CPU、操作系统或第三方库)。当更改软件/硬件以使其可在不同的环境中使用时,也使用该术语。当将软件移植到新平台的成本远低于从头开始编写的成本时,该软件是可移植的。相对于其实现成本而言,移植软件的成本越低,就认为它越可移植。”
我们可以从两个角度讨论可移植性:通用和特定。从广义上讲,可移植性仅仅意味着在一个或多个与设计环境不同的环境中运行程序。由于生产和维护软件的成本远远超过生产硬件的成本,因此我们有巨大的动力来延长软件的寿命,使其超出当前硬件的形态。这样做在经济上是完全合理的。
特定的可移植性涉及识别给定程序必须执行的各个目标环境,并清楚地说明这些环境之间的差异。移植场景的示例包括:
- 在同一机器上从一个操作系统移动到另一个操作系统。
- 在一台机器上的某个操作系统版本移动到另一台具有不同架构的机器上的相同操作系统版本。
- 在不同机器上相同的操作系统的不同变体(例如各种 UNIX 和 Linux)之间移动。
- 在两个完全不同的硬件和操作系统环境之间移动。
- 在使用不同浮点硬件或仿真软件的系统之间移动。
- 在同一系统上使用不同的编译器之间移动。
- 在符合标准 C 的实现和不符合标准 C 的实现之间移动,反之亦然。
- 在同一编译器上重新编译代码,但使用不同的编译器选项。
- 在同一系统上从一个编译器版本移动到同一编译器的另一个版本。
最后两种情况可能不太明显。然而,当采用已经编译无误、可以运行并完成工作的现有代码,并通过同一编译器的新版本或仅仅使用不同的编译时选项运行时,可能会遇到问题。潜在意外行为的一个原因是实现定义的行为发生变化(例如,普通
char
的有符号性)。另一个原因可能是之前依赖于未定义行为,而该行为恰好实现了程序员期望的结果(例如,某些表达式的求值顺序)。请注意,在不符合标准 C 的系统之间移植代码是可以的!例如,早期的数字信号处理 (DSP) 芯片仅支持 32 位浮点数据和运算,在这种情况下,类型
float
、double
和long double
(如果后两者甚至被编译器支持)都映射到 32 位。在这种情况下,有意义的应用程序仍然可以在 DSP 芯片系列的成员之间移植。移植不仅仅是将一段软件在多个目标上运行起来。它还涉及以合理(且经济实惠)的资源量、及时地完成这项工作,并确保生成的代码能够充分地执行。将一个系统移植到一个目标,结果移植完成后,它运行缓慢或占用大量系统资源以至于无法使用,是没有意义的。
需要问自己的重要问题是:
- 我正在移植到还是从标准 C 实现移植?如果是,支持哪些标准版本?
- 我正在移植的代码在设计和编写时是否考虑了可移植性?
- 我是否预先知道所有环境是什么样的,以及我实际有多少环境可用于测试?
- 我对速度、内存和磁盘效率的性能要求是什么?
还有另一个重要的移植场景,即使用 C++ 编译器进行编译。即使这样的移植代码没有利用 C++ 的特性,也会进行某些额外的检查。例如,C++ 要求 C 使用原型风格的函数声明和定义。并且,随着时间的推移,可以添加使用 C++ 特性的新代码,或者现有的 C++ 函数可以调用 C 代码。请注意,C++ 标准不止一个;到目前为止,我们已经有了 C++99、C++03、C++11、C++14、C++17 和 C++20。
可移植性并非新鲜事物
随着 20 世纪 80 年代初良好且廉价的 C 编译器和开发工具的广泛普及,软件可移植性的概念开始流行起来。以至于,听有些人说,可移植性是因为 C 才成为可能的。
可移植性的概念比 C 古老得多,早在 Dennis Ritchie 想到 C 之前,软件就已经成功地被移植了。1959 年,一个小团体定义了一种名为 COBOL 的标准商业语言,1960 年,两家供应商(Remington Rand 和 RCA)实现了该语言的编译器。同年 12 月,他们进行了一项实验,交换了 COBOL 程序,根据 COBOL 设计团队成员 Jean E. Sammet 的说法,“……仅由于实现上的差异而进行了最少量的修改,这些程序就在两台机器上运行了。” 关于 COBOL 开发的逻辑上独立于机器的数据描述,Sammet 在 1969 年写道:“[COBOL] 并不能同时在不同机器上保持效率和兼容性。”
Fortran 也是可移植性领域的早期参与者。根据维基百科,“……FORTRAN 日益普及促使竞争的计算机制造商为其机器提供 FORTRAN 编译器,到 1963 年,已经存在 40 多个 FORTRAN 编译器。由于这些原因,FORTRAN 被认为是第一种广泛使用的跨平台编程语言。”
仅仅因为一个程序是用 C 语言编写的,并不能说明移植它需要多少工作。这项任务可能是微不足道的、困难的、不可能的或不经济的。如果一个程序在编写时没有考虑将其移植到某些不同环境的可能性,那么它实际移植到该环境的难易程度可能很大程度上取决于其作者的严谨性和个人特点,以及语言本身。
为一系列环境(其中一些可能尚未定义)设计可移植的程序可能很困难,但并非不可能。这只需要相当大的严谨性和计划。它需要理解和控制(并在合理的情况下消除)可能在您预期的不同环境中产生不可接受的不同结果的特性的使用。这种理解有助于您避免有意(或更可能是无意地)依赖于您正在编写的程序的不可移植的特性或特征。此外,此类项目的一个关键目标通常不是编写一个无需修改即可在任何系统上运行的程序,而是隔离特定于环境的功能,以便可以为新系统重写这些功能。对于任何语言,主要的可移植性考虑因素都大致相同。只有具体的实现细节由所使用的语言决定。
可移植性的经济性
成功移植的两个主要要求是:拥有必要的技术专长和工具,以及获得管理层的支持和批准。也就是说,必须承认许多项目是由个人或没有管理层的小团体实施的,但仍然需要可移植性。
显然,需要拥有或能够获得并留住优秀的 C 程序员。“优秀”这个词并不意味着仅指或完全指资深专家,因为这类员工通常会有难以管理的自负。也许成功移植项目所需的最重要的特质是纪律,包括个人层面和团队层面。
管理层支持的问题通常更重要,但开发者和管理层本身都很大程度上忽略了这个问题。考虑以下场景。为所有(或具有代表性的子集)指定的目标环境提供了足够的硬件和软件,并且开发团队每周至少虔诚地在所有目标上运行所有代码。通常,他们每天晚上都会以批处理作业的形式提交测试流。
项目进行六个月后,管理层审查进展情况,发现项目占用的资源超出预期(难道不是一直这样吗?),并决定至少暂时缩小目标范围。也就是说,“我们必须在展会上展示一些切实的东西,因为我们已经宣布了该产品”或者“风险投资家希望在下次董事会会议上看到原型”。无论出于何种原因,某些目标的测试和专门针对这些目标的开发都被暂停了,通常是永久性的。
从那时起,开发团队必须忽略被放弃的机器的特性,因为它们不再是项目的一部分,而且公司无法承担额外的资源来认真考虑它们。当然,管理层的建议通常是:“虽然我们不希望您特意去支持被放弃的环境,但如果您不做任何使我们以后无法或低效地重新启用它们的事情,那就太好了。”
当然,随着项目进一步延迟,竞争对手宣布和/或发布替代产品,或者公司陷入经济困境,其他目标也可能被放弃,可能只剩下一个,因为这是开发和营销部门唯一能支持的。每次放弃一个目标,开发团队就开始偷工减料,因为它不再需要担心其他硬件和/或操作系统目标。最终,这降低了以后重新启动被放弃目标的可能性,因为所有在放弃对这些目标的支持后设计和编写的代码都需要检查(当然,前提是这些代码甚至可以被识别出来),以确定所需的工作量以及恢复支持该目标的影响。您很可能会发现,某些设计决策要么禁止要么负面影响重新激活被放弃的项目。
最终结果通常是产品最初只针对一个目标交付,并且永远不会在任何其他环境中提供。另一种情况是针对一个目标交付,然后回头为其他一个或多个目标挽救“尽可能多的”代码。在这种情况下,这项任务可能与移植从未考虑过可移植性的代码的任务没有什么不同。
衡量可移植性
如何知道一个系统何时或是否已成功移植?是当代码编译和链接没有错误时吗?结果必须完全相同吗?如果不是,那么什么程度的接近才算足够?哪些测试用例足以证明成功?除了最简单的情况外,您将无法详尽地/完全地测试每种可能的输入/情况。
当然,代码必须编译和链接没有错误,但是由于实现定义的行为,从不同的目标获得不同的结果是完全可能的。合法的差异甚至可能大到使其毫无用处。例如,浮点数的范围和精度在一个目标到另一个目标之间可能差异很大,以至于最有限的浮点环境产生的结果不够精确。当然,这是一个设计问题,应该在移植系统之前充分考虑。
一个普遍的误解是,必须在所有目标上使用完全相同的源代码文件,并且这些文件充满了条件编译的行。完全没有必要这样做。当然,您可能需要一些目标特定的自定义头文件。您可能还需要用 C 编写的系统特定代码,甚至可能是汇编语言或其他语言。只要这些代码被隔离在单独的模块中,并且这些模块的内容和接口都有详细的文档记录,这种方法就不成问题。
如果您在多个目标上使用相同的数据文件,则需要确保数据被正确移植,尤其是在数据以二进制而不是文本格式存储并且涉及字节序差异的情况下。否则,您可能会浪费大量资源来寻找不存在的代码错误。
除非您充分定义了您的特定可移植性场景和要求,否则您无法知道何时实现了它。并且根据定义,如果您实现了它,您必须感到满意。如果您不满意,要么是您的要求发生了变化,要么是您的设计存在缺陷。最重要的是,成功地将一个程序移植到某些环境并不能可靠地表明将其移植到另一个目标所需的工作量。
环境问题
正如其他章节所指出的,一些可移植性问题与实现语言几乎或完全无关。相反,这些问题与程序必须在其上执行的硬件和操作系统环境相关。本书主体部分已经暗示了其中一些问题;它们在此总结如下:
- 混合语言环境。 对将要调用或被其他语言处理器调用的 C 代码可能存在某些要求。
- 命令行处理。 不同的命令行处理器不仅行为差异很大,而且某些目标甚至可能不存在等效的命令行处理器。
- 数据表示。 这当然完全是实现定义的,并且可能差异很大。不仅
int
类型的大小在您的目标之间可能不同,而且您甚至不能保证分配给对象的所有位都用于表示该对象的值。另一个重要的问题是字内字节和长字内字的顺序。这种编码方案被称为大端或小端。 - CPU 速度。 通常的做法是假设在给定的环境中执行一个空循环
n
次会导致暂停 5 秒,例如。然而,在更快或更慢的机器上运行相同的程序将使这种方法失效。(在具有不同时钟速度的同一处理器版本上运行也是如此。)或者,当同一系统上运行更多(或更少)程序时,计时可能略有不同。相关的问题包括硬件和软件定时器中断的处理频率和效率。 - 操作系统。 即使存在(独立 C 不需要操作系统),主要问题是单任务与多任务以及固定内存组织与虚拟内存组织。其他问题包括处理同步和异步中断的能力、可重入代码的存在以及共享内存。在某些系统上,获取系统日期和时间等看似简单的任务可能是不可能的。当然,系统时间测量的粒度差异很大。
- 文件系统。 同一个文件的多个版本是否可以共存,或者是否存储了创建或上次修改的日期和时间,这是实现相关的。文件名中允许的字符集、名称长度以及名称是否区分大小写也是如此。至于设备和目录命名约定,其差异之大,如同发明者的想象力一样广阔。因此,C 标准对文件系统没有任何规定,除了单个用户访问的顺序文件。
- 开发支持工具。 这些工具可能会对您为给定系统编写代码的方式产生重大影响,或者对编写代码的要求产生重大影响。它们包括 C 编译器、链接器、目标文件和源文件库管理器、汇编器、源代码管理系统、宏预处理器和实用程序库。限制的示例包括外部标识符的大小写、重要性和数量,甚至每个目标模块的大小或源模块的数量和大小。也许覆盖链接器对覆盖方案的复杂性有重大限制。
- 交叉编译。 在目标不是开发软件的系统的环境中,字符集、算术表示和字节序的差异变得重要。
- 屏幕和键盘设备。 这些设备使用的协议差异很大。虽然许多设备实现了各种 ANSI 标准的部分或全部,但也有许多设备没有实现,或者包含不兼容的扩展。可能无法普遍实现从标准输入获取字符而不回显,或者不需要同时按下回车或输入键。直接光标寻址、图形显示和输入设备(如光笔、轨迹球和鼠标)也是如此。
- 其他外围接口。 您的设计可能需要与打印机、绘图仪、扫描仪和调制解调器等设备进行交互。虽然每种设备可能存在一些事实上的标准,但由于某种原因,您可能不得不采用“略微”不兼容的设备。
程序员可移植性
在所有关于可移植性的讨论中,我们不断提到将代码从一个环境移动到另一个环境的方面。虽然这是一个重要的考虑因素,但更有可能的是,C 程序员比他们编写的软件更频繁地移动到不同的环境。出于这个原因,作者创造了术语“程序员可移植性”。
程序员可移植性可以定义为 C 程序员从一个环境移动到另一个环境的难易程度。这对于任何 C 项目都很重要,而不仅仅是涉及代码可移植性的项目。如果您采用某些编程策略和风格,您可以使新团队成员更容易和更快地融入项目。但请注意,虽然您可能已经制定了一种强大的方法,但如果它与主流 C 实践相差太远,那么教授或说服其他 C 程序员其优点将是困难和/或昂贵的。
-
程序执行环境
在编写 C 程序时,需要考虑两个主要的环境:编译环境(即翻译环境)和执行环境。对绝大多数 C 程序而言,这两个环境是相同的。然而,C 语言现在正被广泛用于执行环境与编译环境不一致的场景中。
概念模型
翻译环境(Translation Environment)
翻译阶段(Translation Phases)
在 C89 之前,不同的编译器对源代码的词法分析和处理方式各异。为此,C 标准明确制定了一套规则,称为翻译阶段,用于规定源代码的处理顺序。这些规则可能会使一些依赖旧有翻译顺序的程序出错。
建议:请阅读并理解标准 C 的“翻译阶段”规则,以判断你的实现是否遵循这些规范。
标准 C 并不要求预处理器是独立程序(虽然可以如此实现)。通常情况下,预处理器无需了解目标实现的细节(一个例外是:标准要求预处理器的算术表达式计算必须使用指定类型,详见
#if
语法)。
诊断机制(Diagnostics)
标准 C 规定了在何种情况下实现必须提供诊断信息,但诊断的形式由实现定义。标准并未涉及诸如“变量 x 在初始化前被使用”或“不可达代码”的信息提示或警告。这类问题被认为属于实现质量的范畴,由市场自由决定。
标准 C 允许扩展,前提是它们不会使**严格符合的程序(strictly conforming programs)**失效。符合标准的编译器必须能禁用或诊断扩展。扩展的范围仅限于赋予语法新的语义,或定义未定义或未指定行为。
执行环境(Execution Environments)
标准 C 定义了两类执行环境:独立环境(freestanding)和宿主环境(hosted)。在这两种环境中,程序启动时执行环境调用一个指定的 C 函数。
静态初始化的方式与时机未作具体规定,但所有静态存储对象必须在程序启动前初始化。宿主环境中,通常启动函数是
main
,但也不一定非得如此。若使用main
以外的入口函数,程序就不再是“严格符合”的。建议:在宿主环境中,请始终使用
main
作为程序入口,除非有非常充分的理由,并且你对其做了详细文档说明。程序终止意味着控制权返回给执行环境。
独立环境(Freestanding Environment)
独立环境运行时不依赖操作系统,因此程序可从任何方式开始执行。这类环境本质上不可移植,但如果设计合理,很多代码(例如设备驱动)还是可以在相似平台间移植的。嵌入式系统开发者经常需要面对此类移植需求。
启动函数的名称、类型及终止方式由实现定义。
提供的标准库功能(如果有)也是实现定义的。但标准要求支持以下头文件:
<float.h>, <iso646.h>, <limits.h>, <stdalign.h>, <stdarg.h>, <stdbool.h>, <stddef.h>, <stdint.h>, <stdnoreturn.h>
宿主环境(Hosted Environment)
标准 C 允许
main
有以下两种声明方式:int main(void) { /* ... */ } int main(int argc, char *argv[]) { /* ... */ }
当然也可写成
char **argv
,参数名argc
和argv
也不固定。一种常见扩展是
main
接收第三个参数:char *envp[]
,用于传递环境变量的指针数组。若定义了三个以上参数,该程序就不再是严格符合标准的,即不可移植。建议:使用
getenv()
来访问环境变量,而不是依赖envp
。但请注意,getenv
返回的字符串格式及支持的变量名是实现定义的。有些文献错误地建议将
main
定义为void
类型,这种做法不可取。建议:总是将
main
定义为int
类型,并返回合理的退出码。标准要求
argc
非负,通常至少为 1,即使argv[0]
是空字符串。建议:不要假设
argc
始终大于 0。标准规定
argv[argc]
必须是空指针,因此argv
有argc + 1
个元素。关于命令行参数中的引号(如
"abc def"
)是否保留、是否大小写敏感,这些行为是实现定义的。建议:不要假设命令行处理会特殊处理引号。不同系统可能会保留引号、将其视为字符串一部分,或者大小写不一致等。为避免此问题,可使用
tolower()
或toupper()
处理命令行参数。命令行常用于设置程序行为的参数,例如:
textpro /left_margin=10 /page_length=55
如果参数较多或较长,应使用配置文件:
textpro /command_file=commands.txt
标准规定
argv[0]
表示程序名称,但如不可用时,应指向空字符串。建议:不要假设程序名称总是可用,即使
argv[0]
存在,也可能已被系统转换大小写或路径。标准规定
argc
、argv
及其所指向的字符串可由用户程序修改,且实现不应在程序运行时更改它们。多数系统支持
<
,>
,>>
命令行重定向符。这些符号可能会在传入执行环境前被剥离。建议:不要假设所有系统都支持命令行重定向。可以用
freopen()
在程序中实现重定向。建议:将错误信息输出到
stderr
,即使某些系统将其与stdout
视为相同,这样可在支持独立重定向的系统中获得更好控制。标准要求以如下方式调用
main
:exit(main(argc, argv));
即,
main
的返回值作为程序的退出码。若
main
直接结束(无return
),其退出码为0。某些系统限制退出码为无符号整数或字节大小。建议:使用
EXIT_SUCCESS
而非0
表示成功退出。因为返回值的范围和意义是实现定义的,标准提供<stdlib.h>
中的EXIT_SUCCESS
和EXIT_FAILURE
。如果你用退出码与父进程通信,可自定义数值,因为主机系统通常不会解释退出码。
程序执行(Program Execution)
标准 C 描述了一种抽象机器。在程序执行的“序列点(sequence point)”处,所有副作用必须完成,之后的副作用尚未发生。
终端输入输出的处理方式因实现不同而异,有的使用缓冲,有的无缓冲。
优化编译器可跨越序列点优化,只要结果等同于按序列点严格执行。
C11 引入了多线程支持,早期多线程需依赖库函数或编译器扩展。
环境相关注意事项
字符集
C 程序涉及两种字符集:源字符集与执行字符集。它们在跨平台(如交叉编译)时可能不同。
-
除明确规定外,源字符集与执行字符集中的字符及其值都是实现定义的。
-
执行字符
'\0'
必须是全零位表示。
许多 C 程序仍在 ASCII 或 Unicode 环境中翻译与运行,但其他字符集(如 EBCDIC)也在使用。因此处理大小写字符时要特别注意。
建议:如程序依赖特定字符集,应使用条件编译或标注为特定实现模块。使用
<ctype.h>
函数代替直接用整数比较字符。
三字符序列(Trigraph Sequences)
某些平台(如ISO 646键盘)缺失必要标点,为此 C89 引入三字符序列:
三字符 替代符号 ??= # ??( [ ??/ \ ??) ] ??' ^ ??< { ??! ??> } ??- ~ 例:
printf("??(error??)");
将输出:
[error?]
即
"??("
会变为'['
。建议:使用程序搜索
??
序列,若频繁出现,请检查是否为三字符。建议:使用
\?
避免误判,例如"\??("
的长度为4,而"??("
的长度为2。建议:即使实现不支持三字符,也建议使用
\?
,以便未来支持时保持兼容。
多字节字符(Multibyte Characters)
C89 引入了多字节字符的概念,其处理方式受当前区域设置(locale)影响。C95 引入 双字符(digraphs),用于提供某些替代符号(如
<:
代表[
),不同于三字符,它们是单独的语言符号,不能出现在字符串或字符常量中。
字符显示语义
某些转义序列(如
\a
、\v
)或\n
的行为与系统有关。有的系统将\n
视为换行和回车,有的仅视为换行。
信号与中断
标准 C 限定信号处理函数只能修改特定类型的对象。除
signal()
外,其他库函数并不保证可重入。
环境限制(Environmental Limits)
编译限制(Translation Limits)
C17 标准要求实现至少支持如下内容:
-
块嵌套:127 层
-
条件包含:63 层
-
指针/数组/函数声明嵌套:12 层
-
括号声明器嵌套:63 层
-
表达式嵌套括号:63 层
-
内部标识符有效前缀:63 字符
-
外部标识符有效前缀:31 字符
-
每个翻译单元的外部标识符数:4095
-
每个块的局部标识符数:511
-
同时定义的宏:4095
-
每个函数的参数数:127
-
每次宏调用的参数数:127
-
每个字符串字面量:4095 字符
-
对象最大字节数:65535(仅宿主环境)
-
嵌套
#include
文件:15 层 -
switch
语句标签数:1023 -
单个结构/共用体成员数:1023
-
单个枚举值数:1023
-
嵌套结构定义层数:63 层
这些数字只是下限,不表示所有组合都必须支持。
数值限制(Numerical Limits)
这些限制通过
<limits.h>
、<float.h>
、<stdint.h>
中的宏定义记录。-
自 C99 起,宏
__STDC_IEC_559__
表示支持 IEC 60559(IEEE 754)浮点标准。 -
缺少宏
__STDC_NO_COMPLEX__
表示支持复数类型。 -
存在宏
__STDC_IEC_559_COMPLEX__
表示复数计算也遵循 IEEE 标准。
相关头文件:
<complex.h>
与<fenv.h>
。 -
-
源代码词法单元
标准 C 要求在将源输入解析为词法单元时,必须形成尽可能长的有效词法单元序列。对于任何特定的语法结构,其含义必须不存在歧义。例如,对于文本
a+++++b
必须生成语法错误,因为被识别的词法单元依次为:
a, ++, ++, +, b
其中(后置)第二个
++
操作符的操作数不是一个左值。注意,表达式a++ + ++b
是合法的,因为空白字符使得词法单元被解析为:
a, ++, +, ++, b
同样,
a+++ ++b
亦然。
旧式做法
在 C89 之前,一些预处理器允许通过组合其他词法单元来创建新的词法单元。例如:
#define PASTE(a,b) a/**/b PASTE(total, cost)
这里的意图是宏展开后生成单一词法单元
totalcost
,而不是两个独立的词法单元total
与cost
。这一做法依赖于非标准 C 的方式,即将宏定义中的注释替换为“无”(而非一个空格)。标准 C 引入了预处理器的词法单元粘贴运算符##
,作为实现所期望行为的跨平台解决方案。在 C89 之前,一些预处理器还允许在预处理过程中生成字符串字面量词法单元。标准 C 添加了预处理器字符串化运算符
#
,作为实现该行为的跨平台解决方案。建议:避免利用遵循与标准 C 定义的词法规则不一致的预处理器的特殊行为。
关键字
以下词法单元被标准 C 定义为关键字:
auto break case char const /* C89 */ continue default do double else enum extern float for goto if inline /* C99 */ int long register restrict /* C99 */ return short signed /* C89 */ sizeof static struct switch typedef union unsigned void volatile /* C89 */ while _Alignas /* C11 */ _Alignof /* C11 */ _Atomic /* C11 */ _Bool /* C99 */ _Complex /* C99 */ _Generic /* C11 */ _Imaginary/* C99 */ _Noreturn /* C11 */ _Static_assert /* C11 */ _Thread_local /* C11 */
旧式:虽然 K&R 中未定义
enum
和void
,但在 C89 之前的多个编译器中已对其提供了支持。标准 C 不定义或保留在 K&R 以及某些旧版 C 编译器中曾保留的关键字
entry
。关于 C++ 的考虑:标准 C++ 不定义或保留关键字
restrict
;同样,也不定义那些以下划线开头且后跟大写字母的关键字。(不过,对于部分情况,它提供了替代拼写,例如alignas
、alignof
、bool
和thread_local
。)许多编译器支持扩展关键字,有的以一个或两个下划线开头,或处于程序员标识符命名空间中的名称。
标识符
拼写规则
K&R 和 C89 允许标识符中包含下划线、英文大小写字母以及十进制数字。
在某些较旧环境中,允许的外部名称集可能不包括下划线,且可能不区分大小写,这种情况下,外部名称中的某些字符可能会被映射为其他字符。
C99 添加了预定义标识符
__func__
。此外,C99 还支持在标识符中使用通用字符名(参见通用字符名),以及任意数量的实现定义扩展字符。关于 C++ 的考虑:标准 C++ 定义了以下关键字:
alignas alignof and and_eq asm bitand bitor bool catch char8_t char16_t char32_t class compl concept consteval constexpr constinit const_cast co_await co_return co_yield decltype delete dynamic_cast explicit export false friend mutable namespace new noexcept not not_eq nullptr operator or or_eq private protected public reinterpret_cast requires static_assert static_cast template this thread_local throw true try typeid typename using virtual wchar_t xor xor_eq
其中一些名称在标准 C 的头文件(例如
<stdalign.h>
中的alignas
)中定义为宏,这将在其他地方讨论。关于 C++ 的考虑:标准 C++ 对以下标识符赋予特殊意义:
final
、import
、module
与override
。建议:如果你的 C 代码有可能经过 C++ 编译器处理,请避免使用标准 C++ 定义为关键字或具有特殊意义的标识符。
关于 C++ 的考虑:根据标准 C++,“每个包含双下划线__
或以一个下划线后跟大写字母开头的标识符均保留给实现使用”,以及“每个以单个下划线开头的标识符均保留给实现用作全局命名空间中的名称”。
长度和有效字符限制
虽然标准 C 对标识符的总长度没有限制,但被视为有效(有意义)的字符数可能有限。具体地说,外部名称的长度限制可能比内部名称更严格(通常由于链接器的考虑)。标识符中被认为是有效的字符数是实现定义的。标准 C 要求实现至少能够区分外部标识符的前 31 个字符,以及内部标识符的前 63 个字符。
命名空间
K&R 定义了两大不相交的标识符类别:一类是普通变量相关的标识符,另一类是结构体和共用体成员以及标签。
标准 C 添加了几个新的标识符命名空间。完整的集合包括:
-
标签
-
结构体、共用体和枚举标签
-
结构体和共用体的成员(每个结构体和共用体有其独立的命名空间)
-
其他所有标识符,统称为普通标识符
在标准 C 函数原型中允许的标识符拥有其独立的命名空间,其作用域从标识符开始到该原型声明结束。因此,相同的标识符可以在不同原型中使用,但在同一原型中不能重复使用。
K&R 中有一句话:“两个结构体可以共享一组共同的初始成员;也就是说,如果两个结构体的前面所有成员相同,并且后面的相同成员类型也一致,则同一成员可以出现在两个结构体中。(实际上,编译器只检查两个结构体中同一名称的成员在类型和偏移上是否一致,但如果前面的成员不同,则这种构造在移植性上存在问题。)”标准 C 通过采用每个结构体有独立成员命名空间的方式,消除了这一限制。
通用字符名
C99 添加了对通用字符名的支持,其形式为
\uXXXX
和\UXXXXXXXX
,其中 X 为十六进制数字。它们可以出现在标识符、字符常量和字符串字面量中。
常量
数值常量
标准 C 要求实现处理常量表达式时至少使用目标执行环境所具有的精度,它可以使用更高的精度。
整数常量
-
C89 为支持无符号常量提供了后缀
U
(或u
),此后缀可用于十进制、八进制和十六进制常量。长整型常量可加后缀L
(或l
)。 -
C99 添加了类型
long long int
,其常量可加后缀ll
(或LL
);同时添加了无符号长长整型,其常量后缀为ull
(或ULL
)。 -
在 K&R 中,八进制常量允许出现数字 8 和 9(其八进制值分别为 10 和 11)。标准 C 不允许在八进制常量中使用 8 和 9。
整数常量的类型取决于其数值、基数和后缀的存在,这可能会引起问题。例如,考虑一个 int 为 16 位、采用二进制补码表示的机器,最小 int 值为 -32768。但表达式
-32768
的类型实际上是 long 而非 int!因为并不存在负整数常量,实际上这是两个词法单元:整数常量32768
与一元减号。由于32768
无法放入 16 位,因此其类型为 long,然后值再取负。因此,在没有函数原型的情况下调用f(-32768)
可能会导致参数类型不匹配。如果查看<limits.h>
中INT_MIN
的定义,通常会发现类似:#define INT_MIN (-32767 - 1)
这满足了要求该宏的类型为 int 的要求。
关于基数,在此 16 位机器上,
0xFFFF
的类型为 unsigned int,而-32768
的类型为 long。同样,在 32 位、二进制补码的机器上,对于最小值
-2147483648
,其类型可能是 long 或 long long 而非 int,这取决于类型映射。建议:当整数常量的类型非常重要时(例如作为函数调用参数或用于
sizeof
),请明确指定其类型,或进行适当的强制类型转换。类似的问题也存在于将零常量传递给一个期望指针的函数时——你可能意图表达“空指针”,但在没有函数原型时,零的类型为 int,其大小或格式可能与参数指针类型不匹配。此外,对于指针与整数格式差异较大的机器,不会自动进行隐式转换以补偿这一差异。
正确的做法是使用库宏
NULL
,它通常定义为以下之一:#define NULL 0 #define NULL 0L #define NULL ((void *)0)
旧式:在 C89 之前,不同编译器对整数常量的类型处理各有不同。K&R 要求:“一个十进制常量,其值超过机器最大带符号整数时,被视为 long;同理,超过机器最大无符号整数的八进制或十六进制常量也被视为 long。”
标准 C 对整数常量的类型规定如下:“一个整数常量的类型是其可以表示的对应列表中的第一个。对于无后缀的十进制常量:依次为 int, long int, unsigned long int;对于无后缀的八进制或十六进制常量:依次为 int, unsigned int, long int, unsigned long int;带有 U (或 u) 后缀的:unsigned int, unsigned long int;带有 L (或 l) 后缀的:long int, unsigned long int;同时带有 U (或 u) 和 L (或 l) 后缀的:unsigned long int。” C99 对
long long
与unsigned long long
增加了相应规定。有些编译器支持用二进制(基数 2)表示整数常量;另一些则允许在所有进制中使用分隔符(如下划线)分隔数字。这些功能均不属于标准 C 的范畴。
以 0 开头的整数常量被视为八进制。预处理指令
#line
的形式为:# line digit-sequence new-line
请注意,该语法不涉及“整数常量”,而
digit-sequence
即使有前导零,也被解释为十进制整数。浮点常量
浮点常量默认类型为 double。C89 增加了对long double
类型的支持,并提供了常量后缀 F(或 f)用于 float 常量,以及 L(或 l)用于 long double 常量。建议:当浮点常量的类型很重要时(例如作为函数调用参数或用于
sizeof
),请明确指定类型或进行强制类型转换。C99 增加了使用十六进制表示浮点常量的支持。
C99 同时新增了
<float.h>
中的宏FLT_EVAL_METHOD
,其值可能允许浮点常量以超出规定范围和精度的格式进行求值。例如,编译器有可能(悄然)将3.14f
视作3.14
或甚至3.14L
来处理。枚举常量
枚举中定义的值的名称是整数常量,标准 C 定义它们为 int 类型。K&R 中未包含枚举。
关于 C++ 的考虑:在 C++ 中,枚举常量具有所属枚举类型,该类型为可以表示该枚举中所有常量值的某种整型。
字符常量
-
源字符集到执行字符集的字符映射为实现定义。
-
对于包含在执行字符集中未表示的字符或转义序列的字符常量,其值为实现定义。
-
对于字符常量或字符串字面量中未指定转义序列(除反斜杠后跟小写字母外)的含义为实现定义。注意,保留给标准 C 未来使用的小写字母后的未指定转义序列,其含义不应由实现自由定义。这意味着一个符合标准的实现可以为例如
'\E'
(比如表示 ASCII 的 Escape 字符)提供语义,但不应为'\e'
提供。
建议:避免在字符常量中使用非标准转义序列。
-
包含多个字符的字符常量的值为实现定义。在 32 位机器上,可以将四个字符打包进一个 word,如
int i = 'abcd';
;在 16 位机器上,类似int i = 'ab';
可能被允许。
建议:避免使用多字符常量,因为其内部表示是实现定义的。
-
标准 C 支持早期流行的十六进制形式的字符常量,其通常形式为
'\xh'
或'\xhh'
,其中 h 是十六进制数字。 -
K&R 声明如果反斜杠后跟的字符不在规定范围内,则反斜杠将被忽略。标准 C 则规定这种行为是未定义的。
由于标准 C 不允许在八进制常量中使用数字 8 和 9,之前支持的类似
'\078'
的字符常量将获得新的含义。为避免与形如
??x
的三字符序列混淆,C89 定义了字符常量'\?'
。一个已经存在的'\?'
形式的常量将因此具有不同的含义。建议:由于不同字符集可能有所差异,使用字符的图形表示形式而非其内部表示更为合适。例如,在 ASCII 环境中,建议直接使用
'A'
而非'\101'
。有些实现可能允许使用
''
表示空字符,但标准 C 不允许这种表示。K&R 未定义常量
'\"'
,尽管在字面字符串中显然是必要的。在标准 C 中,字符'"'
与转义后的\"
等价。C89 增加了宽字符常量的概念,其写法与字符常量相同,但前置一个
L
(例如,L"abc"
用于宽字符串)。C99 增加了在字符常量中支持通用字符名。参见通用字符名。
C11 增加了对带前缀
u
或U
的宽字符常量的支持。标准 C 要求整数类型的字符常量的类型为 int。
关于 C++ 的考虑:标准 C++ 要求字符常量的类型为 char。
字符串字面量
标准 C 允许具有相同字面表示的字符串字面量被共享,但并不要求必须共享。
在某些系统中,字符串字面量可能存储在可读写内存中,而在其他系统则存储于只读内存。标准 C 指出,如果程序试图修改字符串字面量,行为将是未定义的。
建议:即使实现允许,也不要修改字符串字面量,因为这违背程序员的直观预期。也不要依赖于相同字面量会被共享。如果代码需要修改字符串,请改为使用字符数组来初始化该字符串,再对数组进行修改。这不仅避免了对字面量的修改,还使得你可以显式共享相同字符串。
建议:常见写法如
char *pMessage = "some text";
在使用 C89 或更高版本编译器时,应改为声明为
const char *
,以便任何修改底层字符串的尝试都能被诊断出来。关于 C++ 的考虑:标准 C++ 要求字符串字面量默认具有 const 限定,因此下面这种在 C 中常用的写法在 C++ 中无效:
char *message = "…";
必须写为
const char *message = "…";
字面字符串的最大长度是实现定义的,但标准 C 要求至少支持 509 个字符。
由于标准 C 不允许在八进制常量中使用数字 8 与 9,之前支持的类似
"\078"
的字符串字面量将获得新的含义。K&R 以及标准 C 允许使用反斜杠/换行符续行的方式将字符串字面量延续到多行,如下:
static char text[] = "a string \ of text";
但这要求续行必须从第一列开始。另一种方法是利用 C89(以及某些早期编译器)所支持的字符串连接功能,如下:
static char text[] = "a string " "of text";
C89 增加了宽字符串字面量的概念,其写法与普通字符串字面量相同,但前置一个
L
(例如L"abc"
)。C99 增加了在字符串字面量中支持通用字符名。参见通用字符名。
C11 增加了带前缀
u
(或U
)的宽字符字符串,以及使用前缀u8
的 UTF–8 字符串字面量。
标点符号
旧式:在 K&R 之前,复合赋值运算符写作
=op
;而在 K&R 及标准 C 中,写作op=
。例如,将s =* 10
变更为s *= 10
。C89 增加了省略号标点符号
...
,作为函数声明与定义增强语法的一部分。它还增加了预处理器专用的标点符号#
与##
。C95 增加了以下双字符标点符号(digraphs):
<:
,:>
,<%
,%>
,%:
, 以及%:%:
。
头文件名
标准 C 定义了头文件名的语法。若在形如
<…>
的#include
指令中出现字符, \, "
或/*
,其行为为未定义。同样地,对于形式为"…"
的#include
指令中,出现这些字符也是未定义的。在使用
#include "…"
形式时,文本"…"
并不被视为字符串字面量。在使用分层文件系统的环境中,需要用\
指示不同文件夹/目录层级,此反斜杠不被认为是转义序列的开始,因此也不需要进行转义。
注释
C99 增加了支持以
//
开头的单行注释。C99 之前,部分实现作为扩展支持这种注释方式。K&R 以及标准 C 均不支持嵌套注释,尽管有的实现确实支持。嵌套注释的需求主要源于希望能禁用一段包含注释的代码块,如下:
/* int i = 10; /* ... */ */
可以通过以下方式达到同样效果:
#if 0 int i = 10; /* ... */ #endif
标准 C 要求,在词法解析阶段,一个注释应被替换为一个空格。有的实现则将其替换为“无”,从而允许一些巧妙的词法单元粘贴行为(参见前文的“源代码词法单元”示例)。
-
-
算术操作数(Arithmetic Operands)
布尔值、字符和整数(Boolean, Characters, and Integers)
-
char
是有符号(signed)还是无符号(unsigned)是实现定义的。
在 C89 制定期间,存在两套常见的整数算术转换规则:保留无符号(UP, Unsigned Preserving)和保留数值(VP, Value Preserving)。
-
UP 规则:如果表达式中包含两个较小的无符号类型(例如
unsigned char
或unsigned short
),它们被提升为unsigned int
。 -
VP 规则:若值可以放入
int
,则提升为int
,否则提升为unsigned int
。
尽管多数情况下两者结果一致,但在某些特定场景下会产生差异。例如,在一个 16 位二进制补码系统上:
#include <stdio.h> int main() { unsigned char uc = 10; int i = 32767; int j; unsigned int uj; j = uc + i; uj = uc + i; printf("j = %d (%x), uj = %u (%x)\n", j, j, uj, uj); printf("expr shifted right = %x\n", (uc + i) >> 4); return 0; }
-
UP 规则结果:
j = -32759 (8009), uj = 32777 (8009) expr shifted right = 800
-
VP 规则结果:
j = -32759 (8009), uj = 32777 (8009) expr shifted right = f800
因为类型不同,右移时可能出现符号扩展(VP)或逻辑移位(UP),导致结果差异。
再如:
uc = 10; i = 30000;
则不论规则如何,右移结果都一致。
结论:不同规则在高位无符号位被设置的情况下才会产生差异。
为避免这种差异,可使用强制类型转换(cast):
#include <stdio.h> int main() { unsigned char uc = 10; int i = 32767; int expr1, expr2, expr3; expr1 = ((int) uc + i) >> 4; expr2 = (uc + (unsigned) i) >> 4; expr3 = (uc + i) >> 4; printf("expr1 = %x\n", expr1); printf("expr2 = %x\n", expr2); printf("expr3 = %x\n", expr3); return 0; }
-
UP 规则结果:
expr1 = f800 expr2 = 800 expr3 = 800
-
VP 规则结果:
expr1 = f800 expr2 = 800 expr3 = f800
由此可见,使用类型转换可以消除因规则差异带来的影响。
虽然标准 C 使用 VP 规则,但在 C89 之前,许多编译器采用 UP 规则,因此依赖旧规则的代码在标准 C 中可能表现不同。
-
C99 引入了
_Bool
类型,并支持扩展整型类型。 -
整数扩展规则同样适用于位字段(bit fields),它们可以是有符号或无符号的。
浮点与整数转换(Floating and Integer)
浮点类型(Floating Types)
-
将有限的浮点类型值转换为整型时,小数部分将被截断(向零舍入)。
若整数部分无法在目标整型中表示,行为未定义。
-
将整数类型值转换为浮点类型时:
-
可精确表示时,值不变;
-
若不能精确表示,则结果为最接近的高值或低值,选择方式为实现定义;
-
若超出可表示范围,行为未定义。
-
-
将
double
转为float
,或将long double
转为double/float
:若值不能表示,行为未定义;若可表示但不精确,则结果为两个最邻近值之一(实现定义)。
注意:在有函数原型的情况下,float 可按值传递而不必提升为 double;但这是允许行为,不是强制要求。
复数类型(Complex Types)
-
C99 引入了
_Complex
类型及其转换规则,定义于<complex.h>
。
常规算术转换(Usual Arithmetic Conversions)
-
标准 C 调整了转换规则以适应 VP 模式。
-
表达式可在“更宽”模式下求值(以提高硬件效率),也可在“更窄”类型下求值,只要结果相同。
-
二元运算符的两个操作数若类型不同,将触发一种或两种操作数的转换。
-
这些规则大致继承自 K&R,但作出了三点扩展:
-
兼容 VP 规则;
-
新增类型;
-
允许在不扩展的前提下使用“窄”类型进行运算。
-
其他操作数类型
指针类型(Pointers)
-
C89 引入了
void *
概念。它可以无需强制类型转换转换为任何对象类型的指针。 -
对象指针也可转换为
void *
并可还原,且不会丢失信息。
关于 C++ 的考虑:标准 C++ 要求将
void *
指针赋值给对象指针时必须使用显式强转。-
不同类型的对象指针不一定具有相同大小,例如
char *
与int *
大小可能不同。 -
将一个类型的对象指针转换为另一类型的指针,如果转换后未正确对齐,则行为未定义。
-
尽管
int
和指针在某些平台上大小相同,但两者属于不同的类型,它们之间的转换并不具有可移植性。
空指针(null pointer)的值不必是“全为零”的位模式,但常实现为全零。标准 C 仅要求
(void *)0
表示一个永不与任何对象或函数地址相等的地址。-
表达式
p == 0
中,0 会提升为指针类型再与p
比较。 -
整数转为指针或指针转为整数的行为是实现定义的。
若结果无法用目标整数类型表示,则行为未定义。
-
函数指针 与数据指针是截然不同的类型:
-
它们的大小与格式都可能不同;
-
不能假设它们可互换。
-
标准 C 要求,当一个返回某种类型的函数指针被赋值为另一种返回类型的函数指针时,必须进行强制类型转换。
-
标准 C 对函数指针的类型检查更严格:
-
函数指针不仅包括返回类型,还包括参数类型;
-
若函数指针类型不兼容却被用于调用函数,行为未定义。
-
总之,标准 C 在类型转换方面进行了细致规范,尤其在涉及不同整型、浮点型、指针、函数指针之间转换时,需要特别关注:
✅ 使用显式类型转换;
✅ 避免依赖实现定义行为;
✅ 遵守标准规定的转换语义;
✅ 编写可移植的跨平台代码。 -
-
求值顺序与序列点(Order of Evaluation and Sequence Points)
根据标准 C 的规定,除以下几种运算符外,其它表达式的求值顺序都是未指定的:
-
函数调用运算符
()
-
逻辑或运算符
||
-
逻辑与运算符
&&
-
逗号运算符
,
-
条件运算符
?:
虽然运算符优先级表定义了运算符的优先级和结合性,但括号分组的优先级更高。不过,K&R 认为像
*
、+
、&
、|
和^
这样的交换律和结合律运算符可以在括号存在的情况下仍然被重排。但标准 C 要求:必须遵守括号中的分组顺序。例如以下表达式:
i = a + (b + c);
在 K&R 中可能会被当成如下来计算:
i = (a + b) + c; i = (a + c) + b;
这可能在某些整数边界值场景中引发中间值溢出问题。为避免此类问题,可以将表达式拆分为多条语句,如下:
i = (b + c); i += a;
✅ 推荐:如果担心求值顺序对结果有影响,应拆分表达式,使用中间变量。
对于浮点运算,由于精度有限,以下数学恒等式不一定成立:
-
(x + y) + z == x + (y + z)
-
(x * y) * z == x * (y * z)
-
(x / y) * y == x
-
(x + y) - y == x
涉及副作用的表达式也可能受求值顺序影响,例如:
i = f() + g(); // f() 和 g() 调用顺序不确定
不确定求值顺序的表达式包括:
j = (i + 1)/i++; dest[i] = source[i++]; dest[i++] = source[i]; i & i++; i++ | i; i * --i;
这些语句的行为是未定义的。
✅ 推荐:即便你知道当前编译器的行为,也不要依赖它。不同版本或不同编译选项都可能导致行为变化。
C89 引入了“完整表达式(full expression)”与“序列点(sequence point)”的概念:
-
完整表达式是指不属于其它表达式、声明符或抽象声明符的一部分;
-
每个完整表达式求值完毕后与下一个完整表达式之间存在一个序列点。
✅ 推荐:熟悉并识别代码中的所有序列点。
对有符号类型进行位运算(如 ~, <<, >>, &, |, ^)的结果是实现定义的。
✅ 推荐:明确目标平台整数类型的表示方式,尤其是移位和掩码操作。
浮点运算的性质是实现定义的,且软件仿真与硬件执行可能结果不同。有些机器允许通过编译选项选择浮点格式。
✅ 推荐:使用浮点类型时,确认其表示范围和精度,了解是否有硬件浮点支持。
关于浮点表达式求值,C99 规定:
-
浮点表达式可以被“合并(contracted)”为一个操作,从而减少中间舍入误差;
-
<math.h>
中的FP_CONTRACT
编译指令允许禁止这种合并; -
否则是否合并及如何合并是实现定义的。
对于无效算术操作(如除以零)或结果无法表示(如溢出),行为是未定义的。
好的,我们继续翻译下一部分内容:
主表达式(Primary Expressions)
-
括号表达式(如
(x + y)
)是主表达式; -
C89 要求编译器至少支持 32 层嵌套括号表达式,C99 将其提高到 63 层;
-
C11 引入了泛型选择表达式
_Generic
,它也是主表达式的一种。
后缀运算符(Postfix Operators)
数组下标运算(Array Subscripting)
-
格式为
a[b]
,其中a
和b
都是表达式; -
a
必须是指向某种类型(不能是void
)的指针,b
必须是整型; -
并不要求
a
是指针、b
是整数。即a[b]
和b[a]
是等价的。
示例:
int i[] = {0, 1, 2, 3, 4}; int *pi = &i[2]; for (int j = -2; j <= 2; ++j) printf("x[%2d] = %d\n", j, pi[j]);
输出:
x[-2] = 0 x[-1] = 1 x[ 0] = 2 x[ 1] = 3 x[ 2] = 4
✅ 推荐:
-
不要对数组
A
使用超出[0, n-1]
范围的下标; -
如果是指针表达式,则可以适当使用负下标,只要指向的是可预知的内存区域。
利用指针构造任意下标范围的数组(⚠️ 不推荐,可能不具可移植性):
int k[] = {1, 2, 3, 4, 5}; int *p4 = &k[-1]; int *yr = &k[-1983]; printf("p4[1] = %d\n", p4[1]); // 即 k[0] printf("yr[1983] = %d\n", yr[1983]); // 即 k[0]
这种“反向偏移”依赖于机器是否具有线性地址空间,以及指针算术的实现方式,某些使用段式内存架构的机器可能不支持。
⚠️ C 标准规定:指针运算结果只能指向数组内部或“数组末尾后一个元素”,否则行为未定义。
多维数组维度计算示例:
int i[2][3][4]; dim3 = sizeof(i[0][0]) / sizeof(i[0][0][0]); // 4 dim2 = sizeof(i[0]) / (dim3 * sizeof(int)); // 3 dim1 = sizeof(i) / (dim2 * dim3 * sizeof(int)); // 2
函数调用(Function Calls)
-
C99 要求:调用函数前必须有其声明;
-
如果未声明就调用函数,或实参与形参类型/数量不匹配,行为未定义;
-
对可变参数函数(如
printf
)调用时若无带省略号...
的原型,行为未定义。
✅ 推荐:
-
函数原型中必须包含
...
才能安全地使用变参函数; -
对于
printf
、scanf
等,始终包含<stdio.h>
; -
使用
stdarg.h
(或varargs.h
)正确处理变参。
函数参数的求值顺序未指定:
f(i, i++); // 行为未定义
扩展示例:
(*table[i])(i, i++); // 无法确定 i++ 是否影响 table[i]
✅ 推荐:不要依赖函数调用参数的求值顺序或函数名表达式的求值顺序。
未显式声明的函数将被当作
extern int
返回类型处理。⚠️ 示例:
g(60000); // 没有原型的调用 void g(int i) { /* ... */ }
这在 16 位系统上可能出错,因为
60000
是 long,但函数期望 int。✅ 推荐:使用显式类型转换(cast)或函数原型来匹配参数类型。
C 标准允许结构体或联合体以值传递方式传参。但值传递结构体的最大大小是实现定义的。
C89 要求函数最多接收 31 个参数,C99 扩展至 127 个。
调用函数指针可用
(*fp)()
或fp()
,但推荐使用前者以清晰表达意图。✅ 推荐:当通过函数指针调用函数时,使用
(*fp)()
格式; ✅ 推荐:所有函数调用处和定义处应使用一致的函数原型。
接下来部分包括:
-
->
和.
的结构体/联合体成员访问规则; -
一元与二元运算符行为;
-
sizeof
、_Alignof
、强制类型转换; -
各种运算符的实现细节和未定义行为。
好的,我们继续完整翻译剩下的内容,以下是后续部分的翻译:
结构体与联合体成员(Structure and Union Members)
由于 C89 引入了结构体(或联合体)按值传递与返回、以及结构体赋值,因此现在可以将结构体(或联合体)表达式作为合法表达式使用。
K&R 中指出,在
x->y
表达式中,x
可以是指向结构体(或联合体)的指针,也可以是绝对机器地址。但标准 C 要求.
或->
运算符的左操作数必须是结构体(或联合体)类型或其指针。例如在一些机器上,I/O 地址会映射到物理内存中,设备寄存器会像普通变量一样访问。旧做法可能是:
0xFF010->status
但现在应显式转换为结构体指针:
((struct tag1 *)0xFF010)->status ((union tag2 *)0xFF010)->status
当联合体中访问的成员与上一次赋值的成员不同,则行为是实现定义的。不能假设多个成员在内存中重叠的方式,除非多个结构体具有相同的前缀成员序列并包含在同一个联合体中。
示例:
struct rectype1 { int rectype; int var1a; }; struct rectype2 { int rectype; float var2a; }; union record { struct rectype1 rt1; struct rectype2 rt2; } inrec;
如果
inrec
当前保存的是rt1
或rt2
,则可以安全访问rectype
字段,因为它们位置一致。⚠️ 标准 C 明确指出:访问原子结构体或联合体对象的成员会导致未定义行为。
后缀自增/自减运算符(Postfix Increment and Decrement)
早期某些实现曾认为
(i++)++
是合法的并可修改。但标准 C 不允许。此表达式应报错。
复合字面量(Compound Literals)
C99 支持复合字面量(如
(int[]){1,2,3}
),可在表达式中创建匿名数组或结构体。⚠️ C++ 不支持复合字面量。
一元运算符(Unary Operators)
前缀自增/自减(Prefix Increment/Decrement)
同样
(++i)++
等表达式在早期实现中可能被允许,但标准 C 不支持修改前缀运算结果。
取地址与间接访问运算符(Address and Indirection Operators)
以下操作行为未定义:
-
越界数组下标;
-
解引用空指针;
-
访问自动变量生命周期已结束的对象;
-
访问已释放的堆内存。
例如某些平台中,解引用空指针会直接触发严重的“访问冲突”。
-
对函数名使用
&
运算符是多余的; -
但对结构体或联合体变量名使用
&
表示获取指针,不能省略; -
某些编译器允许
&bit-field
,这在标准 C 中是不允许的; -
也不允许
®ister
变量,虽然有些实现接受; -
有些实现允许对常量表达式取地址,标准 C 不支持。
如果将指针转换为其它类型后再解引用,可能因对齐要求不满足而导致致命错误,例如将奇地址的
char *
转为int *
。
一元算术运算符(Unary Arithmetic Operators)
-
+
是 C89 引入的; -
在二进制补码系统中,
-INT_MIN
仍为INT_MIN
,因为无法表示其相反数;同理适用于
LONG_MIN
,LLONG_MIN
。
sizeof
运算符C99 之前,
sizeof
的结果始终为编译时常量。
C99 引入变长数组后,如果sizeof
作用于变长数组,则在运行时求值。-
结果类型为
size_t
,定义于<stddef.h>
; -
如何使用
printf
打印sizeof
的结果:
printf("%u", (unsigned int)sizeof(type)); // 最多支持 65535 printf("%lu", (unsigned long)sizeof(type)); // 最多支持 4G printf("%llu", (unsigned long long)sizeof(type));// 最多支持 18E printf("%zu", sizeof(type)); // C99 推荐用法
✅ 推荐:如果使用
printf
打印size_t
值,应使用%zu
和#include <stddef.h>
。
_Alignof
运算符C11 引入,返回类型也是
size_t
,定义在<stddef.h>
。-
<stdalign.h>
中定义宏alignof
,展开为_Alignof
; -
C++11 中的
alignof
是关键字,但在 C 中是宏。
强制类型转换(Cast Operators)
-
指针与整数互转(除了 0)是实现定义的;
-
不同对齐要求的指针类型之间转换,其结果也是实现定义的;
-
C11 禁止将指针类型直接转换为浮点类型,或反过来;
-
某些转换为确保表达式行为正确是必要的(如 VP/UP 问题);
-
C 标准库中多数函数返回
void *
,通常无需显式转换。
✅ C++ 中将
void *
转换为其他类型需显式强转。要获取结构体成员偏移量,有时会用如下宏:
#define OFFSET(struct_type, member) \ ((size_t)(char *) &((struct_type *)0)->member)
✅ 推荐:标准 C 提供
offsetof
宏(定义于<stddef.h>
)用于此目的,优先使用。注意:
(void *)0
表示空指针,但其位模式不一定是“全为 0”。
乘法运算符(Multiplicative Operators)
-
整型和浮点除法可能导致未定义行为;
-
C89 中某些整数除法行为为实现定义,C99 删除了这项内容。
加法运算符(Additive Operators)
-
若指针不是指向数组中的元素或“末尾后一个元素”,对其加减整数会导致未定义行为;
-
两个指向同一数组元素的指针相减,结果类型为
ptrdiff_t
,定义于<stddef.h>
。
位移运算符(Bitwise Shift Operators)
-
对表达式移负数位,或移位数 ≥ 表达式位宽 → 未定义行为;
-
左移操作若结果不能表示 → 未定义行为;
-
有符号负数右移 → 实现定义行为;
-
在 VP/UP 模式下,右移结果也可能不同。
关系运算符(Relational Operators)
-
比较不属于同一数组或结构的指针 → 未定义行为;
-
指针可向后越过数组一位比较;
-
VP/UP 模式下,比较表达式可能产生不同结果。
相等运算符(Equality Operators)
-
指针可与 0 比较;
-
将非 0 整型与指针比较 → 实现定义行为;
-
结构体和联合体不能整体比较,只能成员比较;
-
对浮点数使用相等运算要小心,因精度误差导致结果不可预测。
位与 / 位异或 / 位或运算符(Bitwise AND / XOR / OR)
这些运算符的结果依赖于整型的位宽和表示方式。
逻辑与 / 逻辑或 运算符(Logical AND / OR)
-
逻辑与
&&
、逻辑或||
运算符之间存在序列点; -
第一个操作数求值完成后,才会求第二个操作数。
条件运算符(?:)
-
在判断表达式与第二/第三操作数之间存在序列点。
赋值运算符(Assignment Operators)
简单赋值(=)
-
将 0 赋值给任意指针是可移植的;
-
其他数值赋给指针则不可移植;
-
严格对齐要求下,类型转换行为为实现定义;
-
不同对象类型之间的指针赋值必须显式强制转换(void * 除外);
-
结构体和联合体只能赋值给同类型的结构体或联合体;
-
若目标对象与源对象有重叠,赋值结果为未定义行为,应使用临时变量中转。
复合赋值(+=, -=, *= 等)
-
类似
=op
的早期形式(如=+
)不被标准 C 支持; -
如果
x[i] = x[i++] + 10;
,行为不明确;使用
x[i++] += 10;
可以明确求值顺序。
逗号运算符(Comma Operator)
-
在两个操作数之间定义了序列点。
常量表达式(Constant Expressions)
-
静态初始化表达式可在程序启动时计算;
-
编译器必须使用不少于运行时精度;
-
C89 引入了 float, long double, unsigned 整型常量;
-
C99 引入 signed/unsigned long long 整型常量;
-
C99 还支持带二进制指数的浮点常量;
-
标准允许扩展常量表达式支持自定义类型,但不同编译器行为可能不同。
-
-
常量表达式(Constant Expressions)
静态初始化表达式允许在程序启动时而不是编译时进行求值。
翻译环境必须使用不少于执行环境的精度;如果使用了更高的精度,那么在编译期初始化的静态值,在目标机器启动时初始化可能会有不同的值。
C89 引入了
float
、long double
和无符号整型常量(unsigned
常量)。C99 又引入了有符号/无符号long long
整型常量。C99 还增加了带二进制指数的浮点常量(如
0x1.fp2
)。标准 C 允许实现支持标准中未定义的其它/扩展类型的常量表达式形式。不过,各编译器对这些常量表达式的处理方式不尽相同:有些会将其当作整数常量表达式处理。
声明(Declarations)
C89 对 C 语言影响最大的部分也许就是“声明”机制。它新增了许多与类型相关的关键字,并引入了新的术语来分类它们。对程序员而言,最显著的变化是函数原型(即新风格函数声明)从 C++ 引入到 C 中。
声明元素的顺序
一个声明可以包含以下一种或多种元素:
-
存储类说明符(storage-class specifier)
-
类型说明符(type specifier)
-
类型限定符(type qualifier)
-
函数说明符(function specifier)
-
对齐说明符(alignment specifier)
标准 C 允许这些元素顺序任意,但要求标识符及其初始化表达式出现在最后。例如:
static const unsigned long int x = 123;
可以重写为:
int long unsigned const static x = 123;
或者任何其他组合,只要
x
和它的初始值在最后即可。同样地:
typedef unsigned long int uType;
可以写成:
int long unsigned typedef uType;
一些老旧编译器可能要求特定顺序。K&R 是否允许类型说明符任意排序是有争议的:K&R 第 192 页的语法图表示支持,但第 193 页写道:“以下类型说明符组合是允许的:short int、long int、unsigned int 和 long float。” 这是否明确禁止
int short
、int long
等组合,尚不明确。
代码块中的位置
在 C99 之前,块级作用域中的所有声明都必须在所有语句之前。C99 取消了这个限制,允许声明与语句交错出现。C++ 也允许这一点。
存储类说明符(Storage-Class Specifiers)
auto
存储类在实际代码中极少看到
auto
的使用,因为在标准 C 中,局部变量默认就是 auto 类型。自动变量的存储方式及其可用内存大小由实现决定。使用栈或其他方式的实现可能对自动变量的空间有上限。例如,16 位机器可能将栈限制为 64 KB,或者如果整个地址空间是 64 KB,则代码、静态数据和栈加起来不得超过此限制。此时,随着代码或静态数据增大,可用栈空间减少,可能会导致无法为
auto
对象分配足够空间。有些实现会在每次进入函数时检查栈空间是否足够,如果不够就终止程序。有些甚至调用一个函数来执行检查,也就是说,每次调用你自己的函数时都会隐式调用另一个函数。
建议: 如果某个实现“探测栈空间”,通常可以通过编译选项关闭这种检查。虽然这可能会释放更多的栈空间,使程序得以运行,但不建议在测试阶段禁用栈溢出检查。
int i, values[10]; int j, k;
上述
auto
声明中,四个变量在内存中的相对位置是不确定的,并可能在不同的系统或不同的编译之间变化。但可以确定的是,数组values
的十个元素一定是连续分配的,并且地址按升序排列。建议: 不要依赖特定实现的自动变量分配方案,尤其不要假设变量会按照声明顺序在内存中分配。
C++ 注意事项:
从 C++11 起,
auto
不再作为存储类说明符使用,而是作为类型推导关键字,含义完全不同。
register
存储类register
是对编译器的提示,表示希望将变量存储在寄存器中以提高访问速度。实际能放入寄存器的变量数和支持的类型是实现定义的。如果不能放入寄存器,就会被当作auto
类型处理。标准 C 允许任何数据声明使用此存储类,也允许将其用于函数参数。
K&R 曾指出:“只有某些类型的变量才会被存入寄存器;在 PDP-11 上,它们是 int、char 和指针。”
建议: 随着编译器优化技术的提升,现代编译器自动优化寄存器分配,
register
的作用已经很小。除非你能证明它对某个目标实现确实有优化效果,否则不要使用register
。标准 C 不允许将
register char
当作register int
处理。即使它被存入一个宽于 char 的寄存器,它在行为上也必须完全像一个char
。有些实现甚至能在一个寄存器中同时存放多个register char
对象。
C++ 注意事项:
-
在 C++14 中仍然支持
register
,但已被弃用; -
在 C++17 中该关键字已被保留但不再使用,未来可能赋予不同语义。
static
存储类当尝试前向引用一个
static
函数时,可能会出现问题,例如:void test() { static void g(void); void (*pfi)(void) = &g; (*pfi)(); } static void g() { // ... }
上述代码中,
test
函数中对g
的声明具有块级作用域,并带有static
存储类。这意味着test
会调用一个static
函数g
,而不是其它同名extern
函数。然而,标准 C 不允许块作用域的函数声明带有
static
存储类。它只允许文件作用域的函数声明使用static
,如下:static void g(void); void test() { void (*pfi)(void) = &g; (*pfi)(); }
建议: 即使编译器允许,也不要在块作用域的函数声明中使用
static
存储类。
_Thread_local
存储类这是 C11 新增的关键字,用于定义线程局部存储的变量。(参见
<threads.h>
)
C++ 注意事项:
-
C++11 中引入了对应但不相同的关键字
thread_local
; -
标准 C 定义了宏
thread_local
,用于兼容性。
判断编译器是否支持线程局部存储的方式:
#ifdef __cplusplus /* 如果是 C++ 编译器 */ #if __cplusplus >= 201103L /* 支持 thread_local 存储时长 */ #else #error "该 C++ 编译器不支持 thread_local 存储时长" #endif #else /* 否则是 C 编译器 */ #ifdef __STDC_NO_THREADS__ #error "该 C 编译器不支持 thread_local 存储时长" #else /* 支持 thread_local 存储时长 */ #endif #endif
以下是你提供内容的完整中文翻译:
类型说明符(Type Specifiers)
C89 引入了以下用于类型说明符的关键字:
enum
、signed
和void
。它们对应的基本类型声明包括:-
void
(仅用于函数返回类型) -
signed char
-
signed short
-
signed short int
-
signed
-
signed int
-
signed long
-
signed long int
-
enum [tag]
(枚举类型)
C89 还新增了以下类型声明(部分实现原本已支持
unsigned char
和unsigned long
):-
unsigned char
-
unsigned short
-
unsigned short int
-
unsigned long
-
unsigned long int
-
long double
C99 增加了以下类型支持:
-
signed long long
-
signed long long int
-
unsigned long long
-
unsigned long long int
标准 C 规定,是否将一个“普通的 char”(即未指定
signed
或unsigned
)视为有符号或无符号,是实现定义的行为。虽然 K&R 允许
long float
作为double
的同义词,但标准 C 并不支持这种用法。在 C99 之前,如果省略了类型说明符,则默认视为
int
。例如,以下是合法的文件作用域声明:i = 1; extern j = 1;
但 C99 明确禁止这种写法。
C99 通过类型说明符
_Bool
添加了对布尔类型的支持(参见<stdbool.h>
,若该头文件不可用,亦有兼容处理)。💡 C++ 注意事项: C++ 使用关键字
bool
(与 C 中的_Bool
不同),而标准 C 将其定义为宏bool
,在<stdbool.h>
中提供。C99 还引入了
_Complex
类型说明符,从而提供以下类型:-
float _Complex
-
double _Complex
-
long double _Complex
(参见<complex.h>
)
C11 引入了
_Atomic
类型说明符,但它是可选的;详情见条件宏__STDC_NO_ATOMICS__
(参见<stdatomic.h>
)。
表示方式、大小与对齐(Representation, Size, and Alignment)
头文件
<limits.h>
和<float.h>
中的宏定义了算术类型的最小范围和精度。标准 C 要求如下:-
_Bool
:足以存储值0
和1
-
char
:至少 8 位 -
short int
:至少 16 位 -
int
:至少 16 位 -
long int
:至少 32 位 -
long long int
:至少 64 位
浮点类型的范围和精度应满足以下关系:
float ≤ double ≤ long double
三者可能具有相同的大小与表示方式,也可能完全不同,或部分重叠。
对于整数值,一个符合标准的实现可以使用:
-
一补码(ones-complement)
-
二补码(two-complement)
-
带符号绝对值表示(signed magnitude)
标准 C 的有符号整数最小值定义允许使用一补码。例如,一个使用 32 位 long 并采用二补码的实现,可以将
LONG_MIN
定义为-2147483647
,但合理的做法应是使用-2147483648
,以准确反映二补码的性质。通常,
float
用 32 位单精度表示,double
用 64 位双精度表示,long double
也常用 64 位表示。但若系统支持扩展精度,则long double
可能为 80 位或 128 位。⚠️ 注意:即便在多个浮点表示方式相同的处理器上运行同一个程序,也不能期望获得完全相同的浮点计算结果。例如,早期的 Intel 浮点处理器总是使用 80 位扩展精度进行计算,而不是严格的 64 位模式,这会导致与标准
double
运算结果不同。此外,舍入模式(rounding mode) 也会影响结果。建议: 关于浮点运算,应设定合理的期望,不能苛求不同平台上的结果一致性。特别要注意硬件浮点单元与软件浮点库之间可能的差异。
sizeof
与预处理表达式标准 C 不要求在预处理器
#if
表达式中识别sizeof
作为运算符。根据机器字长进行条件编译是一种常见做法。例如,若不在 16 位系统上,则默认为 32 位,可使用如下判断:
#include <limits.h> #if INT_MAX < LONG_MAX long total; #else int total; #endif
sizeof
编译时运算符会返回某个数据类型对象所占的 char 数量。乘以<limits.h>
中的CHAR_BIT
可得出实际使用的比特数。但对象所占的所有比特未必全部用于表示其值!以下是一些示例:示例 1:
早期 Cray Research 的机器采用 64 位字寻址结构。当声明一个
short int
时,虽然会分配 64 位(sizeof(short)
为 8),但只使用其中 24 或 32 位来表示值。示例 2:
Intel 浮点处理器支持:
-
32 位单精度(float)
-
64 位双精度(double)
-
80 位扩展精度(long double)
某些编译器将
long double
映射为 80 位格式。但为了性能,可能将其对齐到 32 位边界,从而占用 12 字节,其中有 2 字节未使用。示例 3:
C89 制定时曾讨论是否要求整数类型必须使用二进制表示,最终决定是的。因此描述中写道:“每一个物理上相邻的位代表一个更高的 2 的幂”。但有一位委员指出,他们公司使用的 16 位处理器将两个 16 位字用于表示一个
long int
,但低字中的最高位未被使用,等于中间有一个 1 位的“空洞”。这种实现虽然不能满足标准对 32 位整数的要求,但在其目标应用中依然有效。示例 4:
为了对齐,结构体的成员之间或最后可能插入空洞(未使用的位),位域容器内部也可能存在。
建议:
-
不要假设或硬编码任何对象类型的大小。请使用
sizeof
,并结合<limits.h>
、<float.h>
和<stdint.h>
提供的宏。 -
不要假设对象未使用的位具有特定或可预测的值。
虽然在许多实现中,所有数据指针和函数指针的大小与表示方法可能一致,甚至与整数类型一致,但标准 C 并不强制如此。例如,有些机器将地址表示为有符号整数,那么地址 0 就处于地址空间的中间位置。在这类机器上,空指针值可能不是“全 0”。一些分段内存架构支持近指针(16 位)和远指针(32 位)。标准 C 的要求是:所有数据指针和函数指针必须能用
void *
类型表示。建议: 除非你有非常特殊的应用需求,请默认认为:
-
每种指针类型都有独立的表示
-
它们与任何整数类型的表示方式不同
-
不要假设任何空指针值为“全 0”
一些程序会使用
union
将对象与某个整数类型组合,从而检查或操作其内部位。显然这属于实现定义行为。标准 C 提供了一个特殊保证:“为了简化
union
的使用,如果一个union
包含若干结构体,且这些结构体共享一个公共的初始成员序列,那么当前union
对象中包含这些结构体之一时,可以在任何声明可见范围内访问这些结构体的公共初始部分。”
结构体与联合体说明符(Structure and Union Specifiers)
K&R 没有限定位域(bit-field)可用的类型,但 C89 限定为:
int
、unsigned int
和signed int
,并说明:“是否将普通
int
位域的高位作为符号位,是实现定义的。”C99 规定:
“位域的类型应是
_Bool
、signed int
、unsigned int
或其他实现定义的类型(无论是否加限定符)。”C11 增加:
“是否允许原子类型用于位域,是实现定义的。”
K&R 要求连续的位域被打包进机器整数中,不能跨越字边界。而标准 C 则规定:
-
位域打包在哪种容器对象中是实现定义的;
-
位域是否可以跨容器边界也是实现定义的;
-
位域在容器中分配的顺序也是实现定义的。
标准 C 允许位域直接存在于联合体中,而无需先声明在结构体中,例如:
union tag { int i; unsigned int bf1 : 6; unsigned int bf2 : 4; };
K&R 要求联合体所有成员“从偏移量 0 开始”。标准 C 更明确地说明:
“一个指向
union
的指针(经过适当类型转换)应可以访问每个成员,反之亦然。如果某成员是位域,则该指针指向包含该位域的容器。”C11 支持匿名结构体、匿名联合体和灵活数组成员(flexible array members)。
枚举说明符(Enumeration Specifiers)
K&R 没有枚举类型,但 C89 出现前已有一些编译器实现支持枚举。
标准 C 规定:
“每个枚举类型应与
char
、有符号整数类型或无符号整数类型兼容。具体选择是实现定义的,但必须能表示该枚举中所有成员的值。”建议: 不要假设枚举类型就是
int
类型 —— 它可能是任何整型。需要注意的是,标准 C 要求枚举常量的类型为
int
。因此,某个枚举变量的类型不一定等同于其成员的类型。
C99 对枚举器列表末尾逗号的支持
C99 增加了对枚举器列表末尾逗号的支持,例如:
enum Color { red, white, blue, };
💡 C++ 注意事项: 标准 C++ 扩展了枚举类型,允许为其指定具体的基础类型(即底层表示),并将枚举常量的作用域限制在该枚举类型中。
原子类型说明符(Atomic Type Specifiers)
除非实现支持原子类型,否则不允许使用原子类型说明符。是否支持,可通过检查条件定义的宏
__STDC_NO_ATOMICS__
是否为整数常量1
来确定。(参见<stdatomic.h>
)_Atomic
类型说明符的形式为_Atomic(type-name)
,不要将其与_Atomic
类型限定符混淆(后者仅为_Atomic
名字本身,详见“类型限定符”部分)。💡 C++ 注意事项: C++ 不支持
_Atomic
,但它定义了头文件<atomic>
,用于提供各种原子操作相关的支持。
类型限定符(Type Qualifiers)
C89 引入了
const
类型限定符(源自 C++),还引入了volatile
限定符。通过指向非
const
类型的指针来修改const
对象,其行为是未定义的。建议: 不要通过没有
const
限定的指针修改const
对象。同样地,通过未带
volatile
限定的指针引用volatile
对象,其行为也是未定义的。建议: 不要通过非
volatile
指针访问volatile
对象。C99 增加了
restrict
类型限定符,并将其用于多个标准库函数以增强性能优化。💡 C++ 注意事项: C++ 不支持
restrict
。C11 引入了
_Atomic
类型限定符(不要与_Atomic
类型说明符混淆)。
函数说明符(Function Specifiers)
C99 增加了
inline
函数说明符,这是对编译器的建议,其是否生效由实现自行决定。在 C99 之前,一些编译器使用__inline__
支持此功能。标准 C 允许同时存在
inline
定义和外部(external)定义。在这种情况下,使用哪一个定义由实现决定,未作规定。C11 引入了
_Noreturn
函数说明符,并提供了头文件<stdnoreturn.h>
,其中包含宏noreturn
,它展开为_Noreturn
。💡 C++ 注意事项: C++11 提供了不同方式的等价支持,通过属性
[[noreturn]]
实现。
对齐说明符(Alignment Specifier)
C11 使用关键字
_Alignas
增加了对齐说明符支持。头文件
<stdalign.h>
提供了宏alignas
,它展开为_Alignas
。💡 C++ 注意事项: C++11 增加了
alignas
关键字,用于类似功能,但其行为和语法略有不同。标准 C 规定:
“如果在不同翻译单元中,对同一对象的声明使用了不同的对齐说明符,其行为是未定义的。”
声明符(Declarators)
通用说明
K&R 和标准 C 都认为括号中的声明符与非括号形式等价。例如,以下写法在语法上是正确的:
void f() { int (i); int (g)(); ... }
第二个声明可用于隐藏一个与宏重名的函数声明。
标准 C 要求声明至少支持 12 层指针、数组、函数的派生声明修饰。例如,
***p[4]
含有 4 层修饰。K&R 未规定明确限制,只表示声明符中可以有多个类型修饰符。(Ritchie 最初的编译器仅支持声明符中的 6 层修饰。)
标准 C 还要求数组维度必须为 正整数且非零。也就是说,不允许声明大小为 0 的数组,尽管某些实现允许这种写法。
数组声明符(Array Declarators)
标准 C 允许省略大小信息来声明不完整的数组,例如:
extern int i[]; int (*pi)[];
但这些对象在大小信息确定前,其用途受到限制。例如,
sizeof(i)
和sizeof(*pi)
是未知的,应导致编译错误。C99 允许在函数参数中带有数组类型的声明中使用类型限定符和关键字
static
。💡 C++ 注意事项: 标准 C++ 不支持在数组类型声明中使用这些特性。
C99 增加了对变长数组(VLA,Variable-Length Array)的支持,并强制要求实现支持。但 C11 将其变为可选,是否支持由宏
__STDC_NO_VLA__
决定。💡 C++ 注意事项: 标准 C++ 不支持变长数组。
函数声明符(Function Declarators)
调用非 C 函数(Calling Non-C Functions)
某些实现允许使用扩展的
fortran
类型说明符,表示采用 Fortran 的调用约定(即按引用调用),或者生成不同形式的外部名称。还有一些实现提供pascal
和cdecl
关键字用于调用 Pascal 或 C 例程。标准 C 不提供任何外部链接机制。💡 C++ 注意事项: 标准 C++ 提供
extern "C"
来指定使用 C 的链接方式。
函数原型(Function Prototypes)
借鉴自 C++,C89 引入了函数声明与定义的新形式——函数原型,即在函数参数列表中包含参数类型信息。例如,传统风格写法如下:
int CountThings(); // 无参数信息的声明 int CountThings(table, tableSize, value) // 定义 char table[][20]; int tableSize; char* value; { // ... }
新的函数原型形式则为:
// 函数原型 - 含参数信息的函数声明 int CountThings2(char table[][20], int tableSize, char* value); int CountThings2(char table[][20], int tableSize, char* value) // 定义 { // ... }
标准 C 保留了对旧式写法的兼容。
💡 C++ 注意事项: 标准 C++ 要求必须使用函数原型形式。
C++ 注意事项:
标准 C++ 要求使用函数原型语法。
尽管你可能仍然在使用旧式函数定义的生产代码中看到这类写法,它们仍可与新式函数原型共存。唯一需要注意的是**窄类型(narrow types)**的问题。例如,一个旧式函数定义中使用
char
、short
或float
类型的参数会期望它们以更宽的形式(分别是int
、int
和double
)传递。如果同时存在带有窄类型的函数原型,可能会引发不匹配的问题。建议: 尽可能使用函数原型,它可以确保函数以正确的参数类型被调用。函数原型还可以实现参数的自动类型转换。例如,调用
f(int *p)
时如果没有原型在作用域内,而传入参数0
,则不会自动将0
转换为int *
,这在某些系统上可能会导致问题。
标准 C 要求:调用变参函数时,必须在有函数原型的前提下进行。例如,下面这个出自 K&R 的经典程序就不符合标准 C:
main() { printf("hello, world\n"); }
原因是:在没有函数原型的情况下,编译器可以假定参数个数是固定的。这样,它可能会使用寄存器或其他更高效的方式传递参数。然而,
printf
是一个需要变参的函数,如果调用端以固定参数方式进行编译,可能无法正确与printf
通信。要修复上述程序,可以选择以下方式之一(推荐第一种):
-
#include <stdio.h>
-
在使用
printf
之前显式地声明其原型(包括尾部的...
)
此外,函数也应显式声明为返回
int
类型。建议: 在调用变参函数时,始终确保原型在作用域内,并包含
...
表示法。
关于原型声明中的标识符命名问题
函数原型中允许使用占位符标识符,但它们可能引发如下问题:
#define test 10 int f(int test);
虽然
test
这个名字在原型中的作用域仅限于其声明到原型结尾,但它在预处理阶段仍会被宏替换,因此变成:int f(int 10); // 语法错误
更糟的是,如果
test
被宏定义为*
,那么原型就会悄然地变成以int *
为参数。类似问题也可能出现在标准头文件中,如果实现使用的占位符名称与用户自定义的宏名冲突(特别是未加前导下划线的名称)。
建议: 如果必须在函数原型中使用标识符,请选择不容易与宏名冲突的名称。一个通用做法是:所有宏用全大写,其他标识符用小写,以避免混淆。
关于
int f();
与int f(void);
的区别-
int f();
告诉编译器f
是一个返回int
的函数,但参数数量和类型未知。 -
int f(void);
明确表示f
没有参数。
💡 C++ 注意事项: 在标准 C++ 中,这两个声明是等价的。
初始化(Initialization)
-
如果使用自动存储期(automatic storage duration)对象的值时尚未初始化,那么行为是未定义的。
-
外部和静态变量如果没有显式初始化,则默认初始化为
0
,并强制转换为相应类型。(这可能与calloc
或aligned_alloc
分配的区域不同,后者初始化为所有位为零。)
K&R 不允许对自动数组、结构体和联合体进行初始化,但标准 C 允许,只要初始化列表中的表达式为常量表达式,且不涉及变长数组。
此外,自动结构体或联合体也可以使用同类型的非常量表达式进行初始化。
标准 C 允许对联合体(union)进行显式初始化。该值会被转换为第一个成员的类型并存储。因此,成员的声明顺序很重要!
如果静态或外部联合体未显式初始化,其内容会是0
转换为第一个成员的类型(可能不会是所有位为零)。标准 C 允许自动结构体和联合体的初始化表达式为结构体或联合体类型的值。
位域初始化示例:
struct { unsigned int bf1 : 5; unsigned int bf2 : 5; unsigned int bf3 : 5; unsigned int bf4 : 1; } bf = {1, 2, 3, 0};
K&R 和标准 C 都要求:初始化表达式数量不得超过预期数量。不过有一种例外情况不会报错,例如:
char text[5] = "hello";
这里,数组
text
被初始化为字符'h'
,'e'
,'l'
,'l'
,'o'
,但不包含尾部的'\0'
字符。
某些实现允许在初始化列表末尾使用逗号,这种做法被标准 C 接受,也被 K&R 所允许。
C99 增加了对“指定初始化器(designated initializers)”的支持。
💡 C++ 注意事项: 标准 C++ 不支持指定初始化器。
静态断言(Static Assertions)
C11 引入了对静态断言的支持,并在头文件
<assert.h>
中新增了一个宏static_assert
,它会展开为_Static_assert
。 -
-
匹配外部定义与其声明
尽管 K&R(Kernighan 和 Ritchie)定义了一个用于定义和引用外部对象的模型,但也有许多其他模型被广泛采用,这导致了一些混淆。以下各小节中将介绍这些不同的模型。
标准 C 采用了一个结合了“严格引用/定义模型”和“初始化模型”的混合模型。这种方法旨在尽可能兼容各种环境和已有实现。
标准 C 规定:如果一个具有外部链接的标识符在两个源文件中有不兼容的声明,则行为是未定义的。
某些实现中,只要用户代码中声明了一个外部标识符,即使并未实际使用它,链接器也会将包含该标识符定义的目标模块加载到可执行映像中。而标准 C 规定:如果一个具有外部链接的标识符未在表达式中使用,则不必为它提供外部定义。换句话说,仅通过声明一个对象并不能强制它被加载!
严格引用/定义模型(Strict ref/def Model)
/* 源文件 1 源文件 2 */ int i; extern int i; int main() void sub() { { i = 10; /* … */ /* … */ } }
在这个模型中,对变量
i
的声明只能出现一次,并且不能带有extern
关键字。其他对该外部变量的引用都必须使用extern
关键字。此模型为 K&R 所定义的模型。
宽松引用/定义模型(Relaxed ref/def Model)
/* 源文件 1 源文件 2 */ int i; int i; int main() void sub() { { i = 10; /* … */; /* … */ } }
在此模型中,两个对变量
i
的声明都不使用extern
关键字。如果在某处使用了extern
来声明该标识符,则必须在程序的其他地方出现一次实际的定义。如果带有初始化器进行声明,那么该初始化声明在整个程序中只能出现一次。这个模型在类 UNIX 系统中非常常见。虽然采用此模型的程序符合标准 C,但它们的可移植性并非最佳。
通用模型(Common Model)
/* 源文件 1 源文件 2 */ extern int i; extern int i; int main() void sub() { { i = 10; /* … */; /* … */ } }
在该模型中,所有对外部变量
i
的声明都可以(也可以不)带上extern
关键字。此模型模仿了 Fortran 中的 COMMON 块行为。
初始化器模型(Initializer Model)
/* 源文件 1 源文件 2 */ int i = 0; int i; int main() void sub() { { i = 10; /* … */; /* … */ } }
在此模型中,包含显式初始化器的声明(即使只是默认值)即被视为该变量的定义实例。
临时对象定义(Tentative Object Definitions)
标准 C 引入了“临时对象定义”的概念。即一个声明是否是定义,取决于它后续是否有其他定义。例如:
/* 临时定义,外部链接 */ int ei1; /* 定义,外部链接 */ int ei1 = 10; /* 临时定义,内部链接 */ static int si1; /* 定义,内部链接 */ static int si1 = 20;
在上面的示例中,对
ei1
和si1
的首次引用为临时定义。如果后续没有使用初始化器再次声明相同的标识符,这些临时定义将被视为真正的定义。但如上所示,它们后面都有带初始化器的声明,因此被当作普通声明处理。这一机制的目的是为了支持两个互相引用的变量能够相互初始化指向对方。 -
带标签语句(Labeled Statements)
在 K&R 中,标签名与“普通”标识符共享同一个命名空间。也就是说,如果在一个内层代码块中声明了一个与标签同名的标识符,那么该标签名就会被隐藏。例如:
void f() { label: ; { int label; … goto label; } }
这个代码会在编译时报错,因为
goto
所跳转的目标label
被视为一个整型变量而不是标签。而在标准 C 中,标签有自己的独立命名空间,因此上面的代码可以正常编译,不会报错。
K&R 指定内部标识符(如标签)的有效长度为 8 个字符。
而标准 C 要求内部标识符(包括标签)的有效长度至少为 63 个字符。
复合语句(Compound Statement / Block)
在 C99 之前,代码块中的所有声明必须位于所有语句之前。但从 C99 开始,声明和语句可以交错出现。
C++ 注意事项:C++ 也允许声明和语句交错使用。
使用
goto
或switch
可以跳转进入一个代码块。虽然这样是可移植的,但是否能正确初始化被“跳过”的自动变量则未作保证。K&R 允许嵌套代码块,但未说明嵌套的深度限制。
C89 要求复合语句至少支持嵌套 15 层,C99 将此限制提升到了 127 层。
表达式语句和空语句(Expression and Null Statements)
以下是一个使用
volatile
类型限定符的示例(此限定符由 C89 引入):extern volatile int vi[5]; void test2() { volatile int *pvi = &vi[2]; vi[0]; pvi; *pvi; *pvi++; }
当处理带有
volatile
限定的对象时,优化器必须非常小心,因为不能对这些对象的状态作出任何假设。即使像*pvi;
这样的语句,看似没有实际用途,编译器也可能会生成访问vi[2]
的代码,这可能涉及到硬件同步。但pvi;
语句不会被执行任何访问操作,因为pvi
本身不是volatile
。建议:不要依赖表达式语句如
i[0];
、pi;
或*pi;
来生成代码。即使i
是一个volatile
对象,也不能保证一定会进行访问。
选择语句(Selection Statements)
if
语句if
控制表达式是完整表达式,因此在其后有一个序列点(sequence point)。switch
语句K&R 要求控制表达式及每个
case
表达式的类型必须是int
。标准 C 放宽了限制,要求它们是某种整型。
case
表达式会在必要时转换为与控制表达式相同的类型。由于标准 C 支持枚举类型(以整数形式表示),可以在
switch
和case
中使用枚举常量(K&R 中没有枚举类型)。某些实现支持case
表达式中的区间语法,但因语法不统一,标准 C 不支持该特性。标准 C 允许多字符字符常量(如
'ab'
、'abcd'
)用于case
中。建议:由于多字符常量的内部表示是实现定义的,不应在
case
中使用多字符字符常量。K&R 没有规定
switch
中最多允许多少个case
。C89 要求至少支持 257 个,C99 将此数量提升至 1023 个。switch
控制表达式是完整表达式,因此在其后存在一个序列点。关于
switch
中跳转到复合语句的问题,见上文“复合语句”。
循环语句(Iteration Statements)
在
while
、do
和for
语句中的控制表达式可能包含如expr1 == expr2
的浮点比较表达式。由于浮点运算涉及舍入等实现定义行为,表达式结果可能难以精确相等。建议:控制表达式中如需比较浮点值,考虑使用类似
fabs(expr1 - expr2) < 0.1e-5
这样的方式代替expr1 == expr2
。有些程序中会出现“空转循环”(idle loop),例如:
for (i = 0; i <= 1000000; ++i) { }
为解决这类空循环的问题,C11 引入以下规则:如果循环控制表达式不是常量表达式,且其循环体中没有输入/输出操作、未访问
volatile
对象、未进行同步或原子操作,那么编译器可以假定该循环会终止,并可以将其优化掉。比如上述示例中,编译器可以省略整个循环,只保留使i
最终变为1000001
的效果。建议:不要使用“空转循环”来实现延时。这类循环即使没有被优化掉,其执行时间也极易受系统任务优先级和处理器速度影响。
标准 C 保证控制结构和复合语句至少支持 15 层嵌套,C99 提升到 127 层。K&R 未说明嵌套层数下限。
while
语句控制表达式是完整表达式,后有序列点。
do
语句控制表达式是完整表达式,后有序列点。
for
语句三部分表达式均为完整表达式,三者之后均有序列点。
C99 支持在
for
的第一个表达式中进行声明,例如int i = 0;
,而不再要求变量i
事先定义。C++ 注意事项:C++ 也支持这一 C99 特性,但在
for
中声明的变量作用域规则与 C 不同。
跳转语句(Jump Statements)
goto
语句关于标签命名空间的说明,见前文“带标签语句”。关于跳转进入代码块的问题,见“复合语句”部分。
return
语句当使用
return expression;
形式时,expression
是完整表达式,之后存在序列点。若函数声明有返回值,但实际未返回任何值,行为未定义(C99 起,对
main
函数除外,若无明确返回则视为return 0;
)。标准 C 支持
void
函数类型,允许编译器验证函数不返回值(K&R 不支持 void 类型)。标准 C 还支持按值返回结构体与联合体,对返回对象大小无强制限制,虽然传值的大小可能受限。K&R 不支持这种返回方式。
K&R 第 68 页和第 70 页示例中使用
return(expression);
,而第 203 页的正式语法为return expression;
。这看似矛盾,实则不然:第 203 页是正确的,括号只是表达式中的冗余分组符号,不属于语法的一部分。示例中使用括号是出于风格考虑,便于将return
与表达式分开。第二版 K&R 已移除括号,并通常使用return 0;
结束main
函数。C99 还添加了一条限制:“不带表达式的 return 语句只能出现在返回类型为 void 的函数中。”
-
预处理器(The Preprocessor)
根据最初为 C89 制定的《C 语言标准合理性说明》文档中的描述:“现有 C 实现中最令人头疼的多样性,也许正出现在预处理阶段。作为一种独立、原始的语言,预处理命令是在几乎没有统一指引、文档也极不严谨的背景下逐渐积累起来的。”
基本信息
预处理器 vs 编译器
许多 C 编译器都采用多阶段处理,预处理通常作为第一阶段。基于这一点,一些编译器在预处理器与后续阶段之间共享信息以优化效率。然而,这种方式只是特定实现的优化手段,你应当始终将“预处理”与“编译”视为两个独立的阶段。
建议:务必在思维上区分预处理与编译。如果混淆这两者,某些操作(例如稍后将讨论的
sizeof
)可能会引发问题。虽然 C 是一种自由格式的语言,但预处理器严格来说并不是 C 语言的一部分,因此不一定遵守自由格式的规则。C 语言和预处理器各自拥有独立的语法、约束和语义,二者均由标准 C 定义。
指令名称格式(Directive Name Format)
预处理指令必须以
#
开头。然而,并非所有预处理器都要求#
与指令名是一个不可分的记号。一些实现允许#
和指令名称之间存在空格或制表符。K&R 显示
#
是指令名称的一部分,未明确说明是否允许中间有空白。标准 C 允许在
#
和指令名称之间插入任意数量的水平空白(空格和水平制表符),这些被视为独立的预处理记号。
指令的起始位置(Start Position of Directives)
许多预处理器允许在指令前加空白字符(如缩进嵌套指令)。而某些更严格的预处理器要求
#
必须是源行的第一个字符。K&R 指出:“以
#
开头的行会与预处理器交互。”但没有明确什么是“开头”。标准 C 允许
#
前有任意空白字符(不仅限于空格和制表符),任何类型的空白都被接受。
指令内部的空白字符(White Space Within Directives)
标准 C 规定,指令名称与终止换行符之间的所有空白必须是空格或水平制表符。
K&R 并未对指令内的空白做出说明。
在指令中只要使用至少一个空白字符来分隔记号,具体使用几个空格或混合制表/空格对大多数预处理器都无影响。例外情况是宏的良性重定义(稍后详述)。
指令内的宏展开(Macro Expansion Within a Directive)
标准 C 规定:“除非特别说明,预处理指令内的预处理记号不会进行宏展开。”例如:
#define EMPTY EMPTY # include <file.h>
上述第二行不会被识别为预处理指令,因为在翻译阶段 4 开始时,它并非以
#
开头(尽管宏替换后变成了#include
)。
指令续行(Directive Continuation Lines)
K&R 指出,带或不带参数的宏定义可使用反斜线
\
续行——反斜线需位于行末、紧邻换行。标准 C 推广了这一概念,允许任何记号(不仅仅是预处理器能看到的)都可以通过反斜线/换行对来续行。
例如:
#define NAME … \ #define …
此时第二行不被视为宏定义,因为它是续行,且其中的
#
被前方非空格字符(反斜线)“阻隔”。
行尾多余记号(Trailing Tokens)
严格来说,预处理器应报告指令中多余的记号。然而某些实现只处理其所期待的记号,其余忽略。例如:
#include <header.h> #define MAX 23
这种写法常见于某些移植中换行被丢失的情况,会成功包含头文件,但宏定义将被忽略。
再比如:
#ifdef DEBUG fp = fopen(name, "r");
无论
DEBUG
是否定义,文件都不会被打开。K&R 未说明这类情况应如何处理。
标准 C 要求:若出现多余记号,必须发出诊断信息。
指令中的注释(Comments in Directives)
块注释(
/* … */
)在预处理阶段会被视为一个空格,因此可以出现在指令中任何允许空白的地方。例如:#define NUM_ELEMS 50 /* … */ #include "global.h" /* … */ /* … */ #define FALSE 0 /* … */ #ifdef SMALL /* … */ #define TRUE 1 /* … */
这些注释在预处理阶段均会被替换为一个空格。尽管前两行能很好地移植,但后三行含有前置空白字符,可能会被某些实现拒绝(如前述)。
当然,注释也可以出现在指令记号之间。
需要注意的是:块注释可跨多行,不需要使用反斜线/换行来续行。
而行注释(
//
)也可用于指令中。
翻译阶段(Phases of Translation)
标准 C 对源代码转换为记号并由编译器处理的过程做了详细说明。C89 之前并无统一规则,导致以下代码在不同预处理器中产生不同解释:
#ifdef DEBUG #define T #else #define T /\ * #endif T printf(...); /* … */
意图可能是:在未定义
DEBUG
时,宏T
被展开为/*
,从而注释掉printf
。如一位程序员所说:“为了让
T
成为/*
,我们需要欺骗预处理器,因为它在处理任何记号前就会识别注释。我们将星号放在续行中,预处理器就不会直接识别出/*
,一切如愿。这种写法在 UNIX 环境下的编译器能正常工作。”但问题是:预处理器是否真的在所有处理前识别注释?
根据标准 C 的翻译阶段,流程如下:
-
移除反斜线/换行对,将续行拼接为一行。
-
将源码拆分为预处理记号和空白序列(包括注释)。
-
用一个空格替换每个注释(是否压缩多个空白为一个由实现定义)。
-
执行预处理指令、展开宏,每个包含的头文件也重新走一遍上述流程。
由此,标准 C 规定前述代码是非法的,因为
#endif
会被包含在宏定义形成的注释中,导致指令丢失或结构错乱。有些实现先展开宏,再识别预处理指令,因此会接受以下写法:
#define d define #d MAX 43
但标准 C 明确禁止这种用法。
检查预处理器输出(Inspecting Preprocessor Output)
有些实现将预处理器与编译器分离,这种情况下会生成一个中间文本文件;而其他实现将预处理器和编译器集成在一起,并通过列表选项在编译输出中显示所有预处理指令的最终效果。有些实现还支持列出包含嵌套宏的中间宏展开结果。
需要注意的是,由于在预处理指令处理前,注释可能已被替换为空格,因此某些实现无法在中间代码中保留注释或空白字符。这符合标准 C 所定义的翻译阶段行为。
建议:查看你的编译器是否支持保存预处理器输出文件。一个非常有用的质量保证步骤是:比较不同预处理器生成的输出文件,检查它们是否正确地展开宏以及正确包含条件代码。迁移代码到新环境时,也建议一同迁移其预处理后的版本。
源文件包含(Source File Inclusion)
#include
指令用于将指定头文件的内容当作当前源文件的一部分进行处理。虽然头文件通常是文件本身,但并不要求其完全对应某个具体文本文件或名称。标准 C 要求头文件必须包含完整的记号。也就是说,不能在头文件中只放注释、字符串字面量或字符常量的开始或结束部分。头文件也必须以换行符结束。这意味着不能在多个
#include
指令之间拼接记号。为避免标准头文件与程序员自定义代码之间的命名冲突,标准 C 要求实现方使用双下划线或下划线加大写字母作为其标识符的前缀。K&R 索引中只提到了三个宏:
EOF
、FILE
和NULL
,以及二三十个库函数。而标准 C 拥有数百个保留标识符,大部分是宏或函数名。再加上系统相关、第三方库中的标识符,命名冲突的风险相当高。建议:为每个目标环境生成按字母顺序排列的标识符清单(按头文件分类和跨头文件分类)。这份清单可用于两个目的:
-
作为命名时应避免使用的参考。
-
找出所有环境中的公共标识符,便于在跨平台代码中合理使用。
此外,不同环境中同名宏并不一定含义相同。为了减少冲突,自定义标识符时建议使用独特前缀(避免使用下划线)、后缀或专属命名风格。
#include
指令格式(#include Directive Format)K&R 与标准 C 定义了两种
#include
形式:#include "header-name" #include <header-name>
-
K&R 规定:
"header-name"
形式首先在源文件所在目录中查找,然后再在标准路径中查找。 -
标准 C 则规定查找路径为实现定义。
K&R 和标准 C 都规定:
<header-name>
形式只能在实现定义的标准路径中查找。C89 添加了第三种形式:
#include identifier
只要该标识符最终被展开成
"..."
或<...>
的形式即可。由于宏名属于标识符,因此可以用宏构造头文件名,例如通过##
运算符或命令行#define
实现。许多编译器支持如下形式的命令行参数:
-Didentifier 或 /define=identifier
这相当于在源代码中加入
#define identifier 1
。如果你的目标编译器支持
-D
选项和#include identifier
形式,你就可以在编译时指定完整路径而不是硬编码到#include
中。示例:将硬编码的头文件路径抽象化处理:
/* hnames.h - fully qualified header names */ #define MASTER "DBA0:[data]master.h" #define STRUCTURES "DUA2:[templates]struct.h"
引用方式:
#include "hnames.h" #include MASTER #include STRUCTURES
如果头文件被移动或系统更换,只需修改
hnames.h
并重新编译即可。
头文件名称(Header Names)
"..."
和<...>
中头文件名的格式和拼写取决于实现。一些文件系统使用反斜线
\
作为路径分隔符,如 DOS:\dir1\dir2\...\filename.ext
此时可能出现如下路径:
\totals\accum.h \summary\files.h \volume\backup.h
但如
\t
或\v
可能被解释为转义字符,这就会导致语义错误。标准 C 规定:尽管
"..."
看起来像字符串字面量,但它不是,因此其内容必须原样处理,不进行转义。建议:尽量避免在头文件名中嵌入设备名、目录名和子目录信息。
若头文件名与文件系统文件名直接对应,还应注意跨平台命名限制。例如有的系统区分大小写,
STDIO.H
、stdio.h
、Stdio.h
被认为是不同文件。C89 规定:实现可以忽略大小写,并将文件名映射限制为扩展名前最多 6 个有效字符。C99 将此限制提升到 8 个有效字符。
嵌套头文件(Nested Headers)
头文件可以包含其他
#include
指令。嵌套深度为实现定义。-
K&R 表示支持头文件嵌套,但未规定最小深度。
-
标准 C 要求支持至少 8 层嵌套。
建议:设计良好的头文件应支持多次、任意顺序包含。
每个头文件应具备自包含性(自己引入所需头文件)。
每个头文件只包含相关内容,并将嵌套层数限制在 3~4 层。
使用#ifdef
包裹头文件内容,避免重复包含。
#include
路径指定(#include Path Specifiers)K&R 和标准 C 只支持
"..."
和<...>
两种路径机制。但在测试或临时替换时可能需要更多灵活性。多数实现允许在编译时通过命令行指定多个搜索路径。例如:
cc -I"path1" -I"path2" -I"path3" source.c
预处理器会按顺序在 path1、path2、path3 中查找
"..."
格式的头文件,最后在系统默认位置查找。路径支持数量不足或命令行长度受限,可能影响跨平台移植。
建议:确认你所有编译器支持多少路径和命令行参数长度限制。
修改标准头文件(Modification of Standard Headers)
建议:不要修改标准头文件以添加定义、声明或
#include
。
应创建你自己的通用或本地头文件,并在需要的地方引用它。这样在升级编译器或更换平台时只需维持原来的本地头文件即可。
宏替换(Macro Replacement)
使用
#define
将宏名与一段字符串定义关联。宏名是标识符,受命名限制。-
K&R 有效长度为 8 个字符
-
标准 C 要求至少支持 31 个字符
建议:采用最小公约数的长度(例如 31 个字符以内)作为宏名长度限制。
命名风格建议:宏名通常使用大写字母、数字、下划线。
标准 C 要求宏定义中的记号必须是完整合法的,即不能只包含注释、字符串或字符常量的一部分。
有些编译器允许定义“部分记号”的宏,在展开时自动拼接。
建议:避免使用部分记号的宏定义。
宏定义可能包含表达式:
#define MIN 5 #define MAX MIN + 30
预处理器并不计算表达式,只是文本替换。因此这不等价于:
#define MAX 35
只有在
#if
条件包含中,预处理器才执行宏表达式求值:#if MAX → #if MIN + 30 → #if 35
不同实现可能对宏定义长度有限制。
建议:若宏定义超过 80 个字符,请测试所有目标环境是否支持。
带参数的宏(Macros with Arguments)
通用格式:
#define name(arg1, arg2, ..., argn) definition
K&R 没有规定最大参数个数。
标准 C 要求支持至少 31 个参数。
建议:如果打算使用超过 4~5 个参数的宏,需检查目标实现是否支持。
宏定义中,宏名与左括号之间不能有空白字符,但调用宏时没有此限制。
宏定义参数列表中的参数不要求全部出现在定义体中。
C99 增加对可变参数宏的支持,使用省略号(
...
)与__VA_ARGS__
实现。以下是你提供的英文内容的中文翻译:
宏名的重新扫描(Rescanning Macro Names)
一个宏的定义中可以引用另一个宏,在这种情况下,该定义会在必要时被重新扫描。
标准 C 要求在宏展开过程中临时禁用该宏自身的定义,以防止“递归死亡”。也就是说,宏在自身定义中出现时不会被再次展开。这允许将宏名作为参数传递给另一个宏。
字符串字面量与字符常量中的替换(Replacement Within String Literals and Character Constants)
某些实现允许在字符串字面量和字符常量中替换宏参数。例如:
#define PR(a) printf("a = %d\n", a)
调用:
PR(total);
有的实现会展开为:
printf("total = %d\n", total);
但不支持的实现则展开为:
printf("a = %d\n", total);
K&R 说明“字符串或字符常量内部的文本不会被替换”。
标准 C 不支持在字符串或字符常量中替换宏参数。但它提供了
#
字符串化运算符(C89 引入),可以达到类似效果。例如:#define PR(a) printf(#a " = %d\n", a)
调用:
PR(total);
展开为:
printf("total" " = %d\n", total);
由于标准 C 支持字符串常量拼接,最终结果为:
printf("total = %d\n", total);
命令行宏定义(Command-Line Macro Definition)
许多编译器允许通过命令行参数(如
-Didentifier
或/define=identifier
)来定义宏,这相当于在源文件中使用#define identifier 1
。有些编译器还允许用这种方式定义带参数的宏。命令行缓冲区的大小或参数个数可能导致无法指定所有所需的宏定义,特别是在你用此方式为多个
#include
提供宏时。建议:确认所有目标编译器是否支持此功能。只要标识符名足够短,通常应支持 5~6 个宏定义。注意:如果使用 31 字符长的标识符,可能会超出命令行缓冲区限制。
宏重定义(Macro Redefinition)
多数实现允许宏在未先
#undef
的情况下重新定义。这通常是为了允许多个头文件在同一作用域中重复定义相同宏。但若定义内容不同,就可能出现严重问题。例如:
#define NULL 0 … #define NULL 0L
前面部分代码中
NULL
是整数 0,后面则为长整型 0L。若调用f(NULL)
,传入参数大小可能与f
所预期不符。标准 C 允许宏重复定义,只要定义完全一致,称为“良性重定义”(benign redefinition)。那么“完全一致”是什么意思?基本上就是拼写必须一模一样,包括空白字符的处理。比如:
-
#define MACRO a macro
-
#define MACRO a macro
-
#define MACRO a<tab>macro
-
#define MACRO a macro
-
#define MACRO example
前两者一致,第 3、4 是否一致取决于实现如何处理空格,第 5 肯定会报错。
这并不能解决同一宏在不同作用域下定义不一致的问题。
建议:可以在多个位置重复定义相同宏(常见于头文件中),并鼓励这样做。但要避免对同一宏有不同定义。
使用单个空格分隔记号,避免多个连续空格,建议统一使用空格而非制表符,因为 tab 可能被转为多个空格。宏重定义指的是:无参宏重定义为相同名称的无参宏,或带参宏重定义为参数名和数量都相同的带参宏。
建议:即使实现允许,也不要将无参宏重定义为带参宏,或反之。标准 C 不支持这种行为。
预定义标准宏(Predefined Standard Macros)
标准 C 定义了以下预定义宏:
-
__DATE__
(C89):编译日期 -
__TIME__
(C89):编译时间 -
__FILE__
(C89):正在编译的源文件名(未说明是否为完整路径) -
__LINE__
(C89):当前源文件的行号 -
__STDC__
(C89):若编译器符合某版标准 C,则值为 1。注意:不要仅根据是否定义了该宏来判断是否符合标准,而应检查其值为 1。某些实现可能设为 0(不完全符合)或 2(有扩展)。要判断是否符合 C89,需
__STDC__
为 1,且未定义__STDC_VERSION__
。 -
__STDC_VERSION__
(C95):所遵循的标准 C 版本号-
C95:
199409L
-
C99:
199901L
-
C11:
201112L
-
C17:
201710L
-
-
__STDC_HOSTED__
(C99):指示实现是“宿主环境”还是“独立环境”
试图
#define
或#undef
这些预定义宏会导致未定义行为。宏名以
__STDC_
开头的是保留供将来标准使用的。K&R 中没有预定义宏。但在 C89 之前,一些实现已提供
__LINE__
、__FILE__
、__DATE__
和__TIME__
,只不过日期格式不统一。标准 C 还要求:其他预定义宏名必须以下划线加大写字母或第二个下划线开头。
同时,禁止在标准头文件或代码中定义__cplusplus
。C++ 注意事项:标准 C++ 预定义了
__cplusplus
,类似__STDC_VERSION__
,也是版本号编码。
是否预定义__STDC__
或__STDC_VERSION__
由 C++ 实现自行决定。通过命令行定义的宏不属于“预定义宏”,尽管它们在源码处理之前被定义。
除标准 C 规定的宏之外,其他预定义宏的命名都是实现定义的。
虽然没有固定集合,但 GNU C 编译器提供了丰富的预定义宏,其他实现可能效仿。符合标准的实现还可以条件性地定义其他宏(见下节)。
条件定义的标准宏(Conditionally Defined Standard Macros)
标准 C 允许但不强制预定义以下宏:
-
__STDC_ANALYZABLE__
(C11) -
__STDC_IEC_559__
(C99) — 表示符合 IEEE 754 浮点标准 -
__STDC_IEC_559_COMPLEX__
(C99)如果定义了
__STDC_NO_COMPLEX__
,就不得再定义此宏。 -
__STDC_ISO_10646__
(C99)C++ 也定义了此宏,表示 wchar_t 编码遵循 ISO/IEC 10646
-
__STDC_LIB_EXT1__
(C11) -
__STDC_MB_MIGHT_NEQ_WC__
(C11) -
__STDC_NO_ATOMICS__
(C11) — 不支持原子类型 -
__STDC_NO_COMPLEX__
(C11) — 不支持复数类型 -
__STDC_NO_THREADS__
(C11) — 不支持多线程 -
__STDC_NO_VLA__
(C11) — 不支持变长数组 -
__STDC_UTF_16__
(C11) -
__STDC_UTF_32__
(C11)
以下是你提供内容的完整中文翻译:
宏定义限制(Macro Definition Limit)
不同实现中,预处理器符号表可容纳的宏定义条目数及字符串空间大小可能差异很大。
C89 要求:至少支持 1024 个宏标识符同时在一个源文件中定义(包括所有
#include
的头文件)。C99 及以后版本将该限制提高到 4095。
尽管符合标准的实现需要满足这个数量要求,但宏的定义长度可能会被限制,标准并不保证你可以拥有数量众多、长度和复杂度无限制的宏定义。K&R 没有对宏定义数量或大小的限制做出说明。
建议:如果你预计将会有数百个以上的宏定义,请编写一个程序,生成包含任意数量和复杂度宏的测试头文件,测试各个编译器是否支持。
此外,应避免不必要的头文件包含,并使每个头文件模块化,只包含相关内容。多个头文件中出现同一个宏定义是可以接受的。
例如:有些实现会在多个头文件中重复定义NULL
,以避免仅为了使用一个宏就引入整个stdio.h
。
宏定义叠加(Stacking Macro Definitions)
某些实现支持“宏叠加”——即,如果一个宏已在作用域中,又定义了同名宏,后者会隐藏前者;当后者被
#undef
后,前者会恢复生效。例如:#define MAX 30 // ... MAX 为 30 #define MAX 35 // ... MAX 为 35 #undef MAX // ... MAX 恢复为 30
标准 C 不支持宏定义叠加。
K&R 表示
#undef
会使“该标识符的宏定义被遗忘”,通常意味着完全移除定义。
#
字符串化运算符(The#
Stringize Operator)这是 C89 引入的特性。
C99 增加了对空宏参数的支持,字符串化时会变成
""
。#
和##
运算符的求值顺序未定义(implementation-defined)。
##
记号粘贴运算符(The##
Token-Pasting Operator)同样由 C89 引入。它允许宏展开过程中构造新记号,并被重新扫描。例如:
#define PRN(x) printf("%d", value ## x) PRN(3);
展开为:
printf("%d", value3);
标准 C 之前的替代方法是:
#define M(a, b) a/* */b
虽然注释会被替换为一个空格,但某些实现会将
a/* */b
拼接为ab
,形成新记号。但这种做法不被 K&R 和标准 C 支持。标准 C 提供了更规范的方法:
#define M(a, b) a ## b
其中
##
运算符两侧可以有空格,也可以没有。标准 C 规定:在
A ## B ## C
的表达式中,运算顺序为实现定义。
有趣的宏替换情况(特殊粘贴情形)
#define INT_MIN -32767 int i = 1000 - INT_MIN;
宏展开后变成:
int i = 1000 --32767;
看起来像是语法错误,因为
--
是自减运算符,左侧不是 lvalue。但标准 C 的“翻译阶段”规则要求,-
和32767
保持其独立记号,不合并为--
,因此语义仍然正确。但某些非标准实现可能错误地将两个
-
合并为--
,造成不符合预期的行为。建议:为了防止这种误解析,应将宏值用括号包裹,例如:
#define INT_MIN (-32767)
重定义关键字(Redefining Keywords)
某些实现(包括标准 C)允许对 C 语言关键字进行宏重定义。例如:
#if __STDC__ != 1 #define void int #endif
建议:不要随意重定义语言关键字。
#undef
指令(The#undef
Directive)#undef
可用于移除库中的宏定义,以访问同名的真正函数。如果宏不存在,标准 C 规定
#undef
应被忽略 —— 即你可以安全地对不存在的宏执行#undef
,不会出错。详见“宏定义叠加”部分中关于
#undef
的进一步讨论。标准 C 不允许对预定义标准宏(如
__FILE__
、__LINE__
等)执行#undef
。
条件包含(Conditional Inclusion)
条件包含是 C 环境中最强大的功能之一,用于编写可在多个目标系统运行的代码。
建议:尽可能多地使用条件包含指令。前提是你维护一套有意义的宏集合,用于区分不同的目标平台。
可以参考<limits.h>
和<float.h>
来了解宿主平台特性。
#if
算术表达式(#if Arithmetic)#if
指令后的表达式是常量表达式,用来与 0 做比较。某些实现允许在表达式中使用
sizeof
,例如:#if sizeof(int) == 4 int total; #else long total; #endif
严格来说,预处理器是一个宏处理器与字符串替换工具,它无需了解 C 的类型系统。
sizeof
是编译时操作符,而此时是预处理阶段,还未进入编译阶段。K&R 使用与 C 语言相同的常量表达式定义,因此似乎允许使用
sizeof
,但没有说明是否允许类型转换。标准 C 规定:常量表达式中不得包含类型转换或枚举常量。
是否支持sizeof
表达式是实现定义的。
如果表达式中包含枚举常量,它将被视为未知宏,并默认值为 0。建议:不要在条件常量表达式中使用
sizeof
、类型转换或枚举常量。
若需要判断类型大小,可借助<limits.h>
提供的宏间接实现。C89 规定:“… 控制常量表达式的求值应遵循 C 的算术规则,并且其范围至少应覆盖《数值限制》一节中所规定的范围,且
int
和unsigned int
视为分别与long
和unsigned long
等价。”C99 改为:“… 控制常量表达式的求值应遵循 6.6 节的规则,所有有符号和无符号整数类型视为分别与
<stdint.h>
中定义的intmax_t
和uintmax_t
等价。”以下是你提供内容的完整中文翻译:
不允许使用浮点常量(Floating-point constants are not permitted)
建议:不要依赖下溢或上溢的行为,因为补码、一补码或压缩十进制机器上的算术特性差异很大。如果操作数为有符号类型,不要使用右移运算符,因为当符号位被设置时,其结果是实现定义的。
字符常量
字符常量可以作为常量表达式的一部分(此时它们被当作整数处理)。字符常量可以包含任意位模式,例如使用
'\nnn'
或'\xhhh'
。有些实现允许字符常量取负值(例如
'\377'
或'\xFA'
,其高位为1)。标准 C 规定:单字符常量是否允许为负值是实现定义的。K&R 未作说明。
标准 C 和某些实现也支持多字符常量。
建议:不要使用可能为负值的字符常量。
另外,由于多字符常量的顺序与含义是实现定义的,不要在#if
条件表达式中使用它们。
未定义的宏在表达式中默认值为 0
在标准 C 中,如果常量表达式中包含一个未定义的宏名,该宏会被视为其值为
0
(但不会真正定义为0
)。K&R 并未规定此行为。
建议:不要依赖未定义宏等于 0 的规则。如果遗漏了某个宏定义(无论是头文件还是编译命令行中),那么这个默认规则会让你误以为该宏已经被定义了。
虽然有时不实际检查是否定义,但对于那些依赖命令行定义的宏,建议始终加以检查。
为避免此类错误,建议使用构建脚本或编译批处理命令,特别是在命令行包含大量宏定义与路径时。
表达式中的错误
某些表达式可能导致错误,例如:除以 0(可能由于分母使用的宏未定义而默认为 0)。
有些实现会报告错误,有些则不会,有些甚至直接将整个表达式视为0
。建议:不要假定你的实现会在
#if
表达式中发生数学错误时报告诊断信息。
K&R 未包含逻辑非运算符
!
K&R 没有将一元运算符
!
列为常量表达式中允许使用的运算符,这通常被认为是疏忽或排版错误。
defined
运算符(Thedefined
Operator)有时需要嵌套使用条件包含指令,例如:
#ifdef VAX #ifdef VMS #ifdef DEBUG ... #endif #endif #endif
K&R 与标准 C 都支持这种嵌套结构。
标准 C(及某些 C89 之前的实现)提供了defined
一元运算符,让写法更简洁,例如:#if defined(VAX) && defined(VMS) && defined(DEBUG) ... #endif
标准 C 还保留了
defined
作为关键字,不允许在其他地方用作宏名。建议:除非所有目标环境都支持
defined
运算符,否则不要使用它。
#elif
指令在可移植代码中经常使用如下冗长的结构,K&R 和标准 C 都支持:
#if MAX >= TOTAL1 ... #else #if MAX >= TOTAL2 ... #else #if MAX >= TOTAL3 ... #else ... #endif #endif #endif
#elif
指令可以极大地简化这类嵌套结构:#if MAX >= TOTAL1 ... #elif MAX >= TOTAL2 ... #elif MAX >= TOTAL3 ... #else ... #endif
建议:除非所有目标实现都支持
#elif
,否则不要使用该指令。
嵌套条件编译指令(Nested Conditional Directives)
标准 C 保证支持至少 8 层嵌套。
K&R 也允许嵌套,但未规定最小嵌套深度。
建议:除非你确信所有目标实现都支持较深的嵌套层数,最多使用 2~3 层嵌套。
行控制(Line Control)
#line
指令的语法如下:#line 行号 #line 行号 文件名
其中行号和文件名分别用于更新
__LINE__
和__FILE__
这两个预定义宏。标准 C 允许使用宏名或字符串字面量代替文件名。也允许使用宏替代行号,只要宏展开后是十进制数字序列(前导 0 不表示八进制)。
实际上,
#line
后可以接任意预处理记号,只要在宏展开后符合上述两种形式即可。不同实现中,如果
__LINE__
用于跨多行的指令或宏调用,其取值可能不同。
空指令(The Null Directive)
标准 C 允许使用空指令:
#
该指令无任何作用,通常只出现在自动生成的代码中。虽然已存在多年,但 K&R 中并未正式定义。
#pragma
指令#pragma
是 C89 引入的。其设计目的是为实现提供扩展预处理器语法的机制。预处理器会忽略它无法识别的
#pragma
指令。因此其语法与语义为实现定义。一般格式为:#pragma token-sequence
典型用途包括控制分页、优化开关、启用/禁用静态分析等。
实现者可以自行设计各种
#pragma
。格式如下的
#pragma
:#pragma STDC token-sequence
是专门保留给标准 C 使用的,例如:
-
FP_CONTRACT
-
FENV_ACCESS
-
CX_LIMITED_RANGE
(均由 C99 引入)
_Pragma
运算符C99 引入的仅限预处理器使用的一元运算符,格式如下:
_Pragma("string-literal")
等价于使用
#pragma
指令,适用于宏中嵌入#pragma
。
#error
指令C89 引入的特性。语法如下:
#error token-sequence
其作用是让编译器在此处生成包含指定信息的诊断信息。
典型用途是在期望某些宏已定义时,发现未定义而中止编译。例如你正在移植使用变长数组(VLA)或多线程的代码,而宏:
-
__STDC_NO_VLA__
-
__STDC_NO_THREADS__
被定义了,说明当前环境不支持这些功能。
非标准指令(Non-Standard Directives)
有些实现支持其他非标准的预处理指令。这些扩展通常只对特定平台有意义,无法在其他环境中通用。
因此,在移植代码时,应识别这些指令并以其他方式重写或替代。
-
-
术语定义(Definition of Terms)
在 K&R 中,字符(character)的类型是
char
,字符串是char
类型的数组。C89 引入了多字节字符串和移位序列的概念,以及宽字符(wchar_t
)和宽字符串(wchar_t[]
)。C89 标准库中也包含了处理这些类型的函数,后续版本的标准又添加了更多的头文件和函数。在 C89 之前,C 语言库以所谓的“美国英语”模式运行,例如
printf
所用的小数点为英文句点.
。C89 引入了区域设置(locale)的概念,规定传统 C 语言环境被称为"C"
区域,并引入了<locale.h>
头文件。某些标准 C 库函数的行为会受到当前区域设置的影响,也就是说它们具有区域依赖性。
标准头文件(The Standard Headers)
必需内容(Required Contents)
C89 定义了以下头文件:
<assert.h>, <ctype.h>, <errno.h>, <float.h>, <limits.h>, <locale.h>, <math.h>, <setjmp.h>, <signal.h>, <stdarg.h>, <stddef.h>, <stdio.h>, <stdlib.h>, <string.h>, <time.h>
后续标准新增:
-
C95:
<iso646.h>
,<wchar.h>
,<wctype.h>
-
C99:
<complex.h>
,<fenv.h>
,<inttypes.h>
,<stdbool.h>
,<stdint.h>
,<tgmath.h>
-
C11:
<stdalign.h>
,<stdatomic.h>
,<stdnoreturn.h>
,<threads.h>
,<uchar.h>
-
C17:未新增任何头文件
这些头文件名称必须全为小写,并且标准实现必须识别这些拼写。虽然某些文件系统支持大小写混合,但标准头文件的拼写不能被更改。
每个标准头文件都是自包含的,即它包含调用其中声明函数所需的全部声明与定义。但不一定包含其函数可能返回的所有宏定义。例如,
<stdlib.h>
中的strtod
函数可能返回HUGE_VAL
,并设置errno
为ERANGE
,但这两个宏分别定义在<math.h>
和<errno.h>
中,因此使用时也要包含这两个头文件。为实现自包含性,多个头文件可能定义相同的标识符(例如
NULL
和size_t
)。标准库中的所有函数都使用函数原型进行声明。
标准头文件可以任意顺序、多次包含,不会产生不良影响。唯一的例外是
<assert.h>
,如果多次包含,且NDEBUG
宏的定义状态发生变化,行为可能会不同。为了完全符合标准,禁止在外部声明或定义中包含标准头文件,也就是说不应在函数内部包含标准头文件,因为函数属于外部定义。
许多标准库函数的原型使用了 C89 引入或采用的关键字和派生类型,如
const
、fpos_t
、size_t
和void *
。在对老函数应用这些特性时,它们与旧式调用仍兼容。标准 C 要求宿主环境(hosted implementation)必须支持该版本所定义的所有标准头文件。而自由环境(freestanding implementation)只需支持以下内容:
-
C89:
<float.h>
,<limits.h>
,<stdarg.h>
,<stddef.h>
-
C95:额外要求
<iso646.h>
-
C99:添加
<stdbool.h>
,<stdint.h>
-
C11:添加
<stdalign.h>
,<stdnoreturn.h>
-
C17:无新增要求
可选内容(Optional Contents)
C11 添加了一个附录“边界检查接口(Bounds-checking interfaces)”,提供一系列可选扩展,用于增强程序的安全性,这些扩展包括新的函数、宏和类型,声明或定义在已有的标准头文件中。
如果实现定义了宏
__STDC_LIB_EXT1__
,则必须提供该附录中的所有扩展。这些扩展适用于以下头文件:<errno.h>, <stddef.h>, <stdint.h>, <stdio.h>, <stdlib.h>, <string.h>, <time.h>, <wchar.h>
如果程序在包含这些头文件前定义了宏
__STDC_WANT_LIB_EXT1__
为0
,则可以禁用这些扩展;若定义为1
,则启用扩展。
保留标识符(Reserved Identifiers)
所有在标准头文件中声明的外部标识符都是保留的,无论你是否实际包含该头文件。
例如:即使你从未包含<time.h>
,也不能安全地自定义一个名为clock
的外部函数。但宏名和
typedef
名不属于外部标识符,因此不受此限制。所有以下划线开头的外部标识符是保留的,其他库标识符应以双下划线或下划线+大写字母开头。
标准库函数的使用(Use of Library Functions)
并非所有标准库函数都会检查其输入参数的有效性,如果传入无效参数,行为将是未定义的。
实现允许将任何标准库函数实现为宏,前提是这些宏是安全可展开的。也就是说,如果参数包含副作用(如函数调用、++i),展开后也不应造成副作用重复。
如果你包含了标准头文件,不应自行显式声明其中的函数,否则可能与头文件中的宏定义冲突,从而引发错误。
在使用库函数的地址时需注意,函数可能是宏形式。可以先使用
#undef
,或使用括号包围的形式(name)
来获取其地址。即使不使用#undef
,也可以在同一作用域中同时调用宏版本与函数版本。强烈建议在使用库函数时包含相应头文件。如果不包含,应显式用函数原型声明该函数,尤其是如
printf
这类变参函数。
原因是:使用原型时,编译器会明确知道实参数量与类型,甚至可以将前几个参数通过寄存器传递;而没有原型时,调用机制不同,可能导致与库中定义不兼容而链接失败。
非标准头文件(Non-Standard Headers)
早期 UNIX C 库中包含通用与系统相关的函数,大多数通用部分被 C89 采纳,系统相关部分由 IEEE POSIX 接管。
部分函数和宏被两个标准共同使用,或都未采用,它们被合理划分到了各自标准中。但有极少数函数或宏在两个标准中声明或定义方式不同,例如
<limits.h>
。不过,两者的共同目标是:C 程序应当能同时符合 ISO C 与 POSIX 标准。
许多常见但不属于标准 C 库的头文件包括:
bios.h, conio.h, curses.h, direct.h, dos.h, fcntl.h, io.h, process.h, search.h, share.h, stat.h, sys/locking.h, sys/stat.h, sys/timeb.h, sys/types.h, values.h
有些头文件虽未被 C89 采纳,但其功能已被其他标准头文件覆盖或整合,例如:
-
malloc.h
→ 整合进<stdlib.h>
-
memory.h
→ 功能转入<string.h>
-
varargs.h
→ 被<stdarg.h>
取代
-
-
<assert.h>
– 诊断(Diagnostics)C11 增加了对**静态断言(static assertions)**的支持,其中包括向该头文件添加宏
static_assert
。C++ 注意事项:对应的标准 C++ 头文件是
<cassert>
。
程序诊断(Program Diagnostics)
assert
宏标准 C 要求
assert
必须是一个宏,而不是实际的函数。
如果你使用#undef
取消其宏定义后,试图访问一个名为assert
的实际函数,其行为是未定义的。assert
宏输出的消息格式是实现定义的。但标准 C 的意图是:输出的信息应包括断言表达式的文本形式(即源码中的原样表达式),以及发生断言失败的位置的文件名和行号(分别由__FILE__
和__LINE__
表示)。例如,如果你写的是:
assert(MAX - MIN);
其中
MAX
定义为 100,MIN
定义为 20,那么断言失败时的输出应为:MAX - MIN
而不是其计算结果
80
。C89 规定
assert
的参数必须是int
类型,而 C99 将其拓展为任意标量类型。由于
assert
是宏而非函数,使用时要小心避免表达式带有副作用。不要依赖宏只计算一次表达式。
<complex.h>
– 复数运算(Complex Arithmetic)C99 引入了
<complex.h>
头文件,并将对复数类型及其运算的支持设为可选。如果未定义宏
__STDC_NO_COMPLEX__
,则表示实现支持复数类型及其相关运算。
若定义了宏__STDC_IEC_559_COMPLEX__
,则表示该复数支持符合 IEC 60559 标准(即 IEEE 754 的复数扩展),该标准在 C 的附录中有说明。以下函数名在该头文件中被保留,供标准 C 将来可能使用:
cerf, cerfc, cexp2, cexpm1, clog10, clog1p, clog2, clgamma, ctgamma 以及上述函数名带有后缀 f(float 版本)和 l(long double 版本)
C++ 注意事项:对应的标准 C++ 头文件是
<ccomplex>
,但在 C++17 中已被弃用。
如你希望继续翻译
<ctype.h>
、<errno.h>
或其他标准库头文件内容,我可以继续为你翻译。需要吗? -
<ctype.h>
– 字符处理(Character Handling)在标准 C 中,
<ctype.h>
中提供的所有函数都接受一个int
类型参数。然而,传入的值必须是可以用unsigned char
表示的值或宏EOF
。若传入其他值,行为是未定义的。C89 引入了**区域设置(locale)**的概念。默认情况下,C 程序运行在
"C"
区域,除非调用了setlocale
函数(或实现默认区域不是 "C")。在"C"
区域中,<ctype.h>
的函数行为与 C89 之前一致。当选择了除 "C" 以外的区域时,某些字符类别的判定可能会扩展。例如,在西欧地区运行的实现中,可能将带变音符(如
ä
、ê
、ñ
)的字符视为字母。因此,字符ä
是否使isalpha
返回真,是由当前区域设置定义的。许多实现使用的字符表示比宿主字符集所需的位数更多,例如支持 7 位 ASCII 的 8 位字符系统。这样可以利用未使用的位扩展字符集。此外,C 程序员可以将
char
当作小整数使用,存入任何能表示的位模式。但当
char
中的内容不是本机字符集的一部分时,不应将其传入<ctype.h>
中的函数,除非当前区域允许。即使允许,结果仍是实现定义的。同时,还应注意char
是否为有符号类型,因为值为0x80
的 8 位char
,在 signed 与 unsigned 情况下行为会不同。标准 C 要求
<ctype.h>
中的函数必须作为实际函数实现。但也允许它们以宏的形式实现,前提是宏是安全的(即每个参数只被求值一次)。如果需要使用函数版本,可以通过#undef
取消对应宏。建议:
<ctype.h>
中的测试函数在返回 true 时的实际值是实现定义的。因此,不应用作算术比较,只应用于逻辑判断。标准 C 保留了所有以
is
或to
开头、后跟小写字母(然后是任意标识符字符)的函数名,以备将来扩展库函数。以下函数具有区域相关行为:
-
isalpha
,isblank
,isgraph
,islower
,isprint
,ispunct
,isspace
,isupper
,toupper
,tolower
C89 之前,以下函数在许多实现中也通过此头文件提供:
-
isascii
,toascii
,iscsym
,iscsymf
但这些函数不属于标准 C 支持的函数。
C++ 注意事项:对应的标准 C++ 头文件为
<cctype>
。
字符分类函数(Character Classification Functions)
isalpha
函数
使用isalpha
替代如下代码:if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))
因为在某些字符集(如 EBCDIC)中,大小写字母在内部编码上不一定是连续的。
isblank
函数
由 C99 引入。islower
,isupper
函数
同样参考isalpha
的说明。
字符大小写转换函数(Character Case Mapping Functions)
tolower
和toupper
函数
在非 "C" 区域中,大写转小写的映射不一定是一对一的。
例如,大写字母可能被映射为两个小写字母,或者可能根本没有小写形式。toupper
同理。
<errno.h>
– 错误处理(Errors)早期的
errno
被声明为extern int
,但标准 C 要求它是一个宏。
该宏可以展开为对同名函数的调用。标准中,errno
被定义为可修改的int *
类型左值。
例如:errno
可以定义为*_Errno()
,其中_Errno()
是一个返回int *
的函数。对
errno
使用#undef
试图访问底层对象的行为是未定义的。许多标准库函数在检测到某些错误时会将
errno
设为非零值,且该值必须为正数。
标准 C 明确规定:库函数不负责将errno
重置为 0,因此不能依赖其清除行为。通常,
errno
的宏值以E
开头。虽然各系统之间对错误名的拼写和含义不统一,但 C89 仅定义了两个:-
EDOM
(数学域错误) -
ERANGE
(结果超出范围)
C99 新增:
-
EILSEQ
(非法多字节序列)
标准兼容实现可定义其他以
E
加数字或大写字母开头的宏。例如:E2BIG // 参数列表太长 EACCES // 权限被拒绝 EAGAIN // 暂时无法分配资源 EBADF // 错误的文件描述符 EEXIST // 文件已存在 ENOMEM // 内存不足 ENOTDIR // 非目录 EPIPE // 管道已破裂 ...
完整列表可见于原文,或参考实现的
<errno.h>
。参见:C11 添加的“边界检查接口”附录中可能对该头文件提出的要求(如宏
__STDC_LIB_EXT1__
)。C++ 注意事项:对应的标准 C++ 头文件为
<cerrno>
。
<fenv.h>
– 浮点环境(Floating-Point Environment)该头文件由 C99 引入。
标准 C 保留所有以
FE_
开头、后接大写字母的宏名,用于将来在此头文件中添加新宏。C++ 注意事项:对应的标准 C++ 头文件为
<cfenv>
。
<float.h>
– 浮点类型特性(Characteristics of Floating Types)该头文件通过一系列宏定义目标系统的浮点类型特性,其值多为实现定义的。
截至 C17,大部分宏已由 C89 定义,少数新增:
-
C99 新增:
DECIMAL_DIG
,FLT_EVAL_METHOD
-
C11 新增:
FLT_DECIMAL_DIG
,DBL_DECIMAL_DIG
,LDBL_DECIMAL_DIG
,以及FLT_HAS_SUBNORM
,DBL_HAS_SUBNORM
,LDBL_HAS_SUBNORM
,FLT_TRUE_MIN
,DBL_TRUE_MIN
,LDBL_TRUE_MIN
虽然许多系统采用 IEEE-754 浮点格式,但在 C89 制定时,还有其他三种格式被广泛使用,C89 对这些格式也提供支持。
标准 C 为
FLT_ROUNDS
宏定义了值 -1 到 3,其它值表示实现定义的舍入方式。标准 C 为
FLT_EVAL_METHOD
宏定义了值 -1 到 2,其它负值表示实现定义行为。
该宏的值可能影响浮点常量的求值(详见“浮点常量”相关说明)。C++ 注意事项:对应的标准 C++ 头文件为
<cfloat>
。
<inttypes.h>
– 整数类型的格式转换(Format Conversion of Integer Types)该头文件由 C99 引入。
标准 C 保留所有以
PRI
或SCN
开头、后跟小写字母或X
的宏名,用于未来在此头文件中扩展。C++ 注意事项:对应的标准 C++ 头文件为
<cinttypes>
。
<iso646.h>
– 替代拼写(Alternative Spellings)该头文件由 C95 引入,定义了一些操作符的英文拼写替代形式,例如:
-
and
替代&&
-
or
替代||
-
not
替代!
C++ 注意事项:对应的标准 C++ 头文件为
<ciso646>
,这些宏在标准 C++ 中被当作关键字处理。
<limits.h>
– 数值限制(Numerical Limits)该头文件通过一系列宏定义目标系统中整数类型的数值特性,其值大多是实现定义的。
几乎所有宏都已在 C89 中定义,少数例外如下:
-
C99 新增:
LLONG_MIN
,LLONG_MAX
,ULLONG_MAX
C++ 注意事项:对应的标准 C++ 头文件为
<climits>
。 -
-
<locale.h>
– 本地化(Localization)几乎所有
struct lconv
类型的成员都已在 C89 中定义。
以下六个成员是 C99 新增的:-
int_p_cs_precedes
-
int_n_cs_precedes
-
int_p_sep_by_space
-
int_n_sep_by_space
-
int_p_sign_posn
-
int_n_sign_posn
实现还可以自行添加其他成员。
标准 C 保留了所有以
LC_
开头、后跟大写字母的宏名,用于实现自定义的区域子分类宏。标准 C 预定义的区域有两个:
-
"C"
:传统 C 区域(标准环境) -
""
(空字符串):表示本地化的原生环境
除此之外的所有区域名称字符串,其含义都是实现定义的。
C++ 注意事项:对应的标准 C++ 头文件为
<clocale>
。
区域控制(Locale Control)
setlocale
函数
如果你修改了setlocale
返回的字符串内容,其行为是未定义的。
<math.h>
– 数学运算(Mathematics)C99 引入了以下内容:
-
类型:
float_t
,double_t
-
宏:
-
FP_FAST_FMA
,FP_FAST_FMAF
,FP_FAST_FMAL
-
FP_ILOGB0
,FP_ILOGBNAN
-
FP_INFINITE
,FP_NAN
,FP_NORMAL
,FP_SUBNORMAL
,FP_ZERO
-
HUGE_VALF
,HUGE_VALL
,INFINITY
,NAN
-
错误处理相关宏:
MATH_ERRNO
,MATH_ERREXCEPT
,math_errhandling
-
-
其他:
FP_CONTRACT
预处理器指令、宏函数与大量新数学函数
某些数学函数使用的宏
EDOM
和ERANGE
定义在<errno.h>
中,因此应包含该头文件。在 C89 中,为
float
和long double
类型保留了以f
和l
为后缀的数学函数名,但只有double
类型的函数是强制实现的。从 C99 起,所有三种版本(float、double、long double)都必须提供。
关于
float
类型的函数:调用这些函数必须有相应的原型声明,否则float
类型的参数会被提升为double
。注意:即使指定了
float
类型的原型,也不一定阻止类型提升,此行为是实现定义的。
然而,要正确支持float
版本函数,提供原型声明是必要的。
数学错误处理(math_errhandling)
从 C99 开始,引入了
math_errhandling
宏,因此在某些情况下无需设置errno
。-
定义域错误(domain error):
当输入参数超出函数定义域时发生。
此时返回一个实现定义的值,并且在 C99 之前,errno
被设置为EDOM
。 -
值域错误(range error):
当结果无法用double
表示时发生:-
上溢时返回
HUGE_VAL
,符号与正确值相同。C99 之前,errno
被设为ERANGE
。 -
下溢时返回
0
,errno
是否设置为ERANGE
是实现定义的。
-
C++ 注意事项:对应的标准 C++ 头文件为
<cmath>
。 -
-
<setjmp.h>
– 非局部跳转(Non-Local Jumps)标准 C 要求
jmp_buf
是一个足够大小的数组,用于存储“当前程序上下文”(不管具体包含什么)。
C99 补充说明:该上下文不包括浮点状态标志的状态、已打开的文件、或抽象机器的任何其他组件的状态。C++ 注意事项:对应的标准 C++ 头文件为
<csetjmp>
。
保存调用环境(Save Calling Environment)
setjmp
宏标准 C 规定:“不指定
setjmp
是宏还是具有外部链接的标识符(函数)。
如果为了访问实际函数而屏蔽了宏定义,或程序自行定义了名为setjmp
的外部标识符,行为是未定义的。”如果在标准 C 所定义的上下文之外调用
setjmp
,其行为也是未定义的。
恢复调用环境(Restore Calling Environment)
longjmp
函数-
如果
longjmp
尝试恢复到一个从未由setjmp
保存过的上下文,其结果是未定义的。 -
如果
longjmp
恢复到的上下文中,其最初调用setjmp
的函数已经结束返回,其结果也是未定义的。 -
如果在嵌套的信号处理程序中调用
longjmp
,行为是未定义的。 -
不要在退出处理程序中调用
longjmp
,例如那些通过atexit
注册的处理函数中。
-
-
<signal.h>
– 信号处理(Signal Handling)C89 引入了类型
sig_atomic_t
。标准 C 保留了所有形如
SIG*
和SIG_*
的标识符名称(其中 * 表示以大写字母开头的剩余部分),用于定义其他类型的信号。
一个实现中可用的完整信号集合、各信号的语义及默认处理方式均为实现定义的。C++ 注意事项:对应的标准 C++ 头文件为
<csignal>
。
指定信号处理方式(Specify Signal Handling)
signal
函数如果无法执行所请求的操作,
signal
函数会返回SIG_ERR
。
在 C89 之前,signal
返回-1
,但不要直接判断返回值是否为-1
,应使用宏SIG_ERR
进行判断。使用
signal
时,务必检查其返回值,不要假设它一定成功执行了你的请求。通常,当某个信号被检测到并传递给处理函数后,下一次再触发该信号时将恢复为“默认”处理方式。
也就是说:如果你希望持续捕获该信号,必须在信号处理函数中再次调用signal
注册处理器。
(标准 C 要求这种行为,除了SIGILL
以外,是否自动重置由实现决定。)如果在处理函数内部调用
signal
返回SIG_ERR
,则此时errno
的值是不确定的。
在其他情况下,如果signal
返回SIG_ERR
,errno
将包含一个正值,其可能值为实现定义。程序启动期间,实现可以自由决定某些信号是否被忽略或由默认方式处理,即信号处理的初始状态是实现定义的。
标准 C 未规定当同一信号在上一次尚未处理完时再次发生时的行为。
<stdalign.h>
– 对齐(Alignment)该头文件由 C11 引入,用于提供对齐需求的支持。
C++ 注意事项:对应的标准 C++ 头文件为
<cstdalign>
。
但注意:C++17 中已弃用该头文件。 -
<stdarg.h>
– 可变参数(Variable Arguments)该头文件由 C89 引入,其设计灵感来自 UNIX 的
<varargs.h>
。由于标准 C 所采用的方法略有不同,因此定义了新的头文件<stdarg.h>
,而不是修改<varargs.h>
的含义。C++ 注意事项:对应的标准 C++ 头文件为
<cstdarg>
。
可变参数访问宏(Variable Argument List Access Macros)
va_arg
宏
标准 C 要求va_arg
是一个宏。若使用#undef
取消宏定义并尝试使用同名函数,行为是未定义的。va_copy
宏
该宏在 C99 中新增。标准 C 规定:“
va_copy
是否为宏或具有外部链接的标识符未指定。如果屏蔽宏定义以访问实际函数,或者程序定义了同名外部标识符,则行为为未定义的。”va_end
宏
标准 C 同样规定其是否为宏或具有外部链接的标识符未指定。取消宏定义或自定义同名标识符的行为是未定义的。va_start
宏
标准 C 要求它必须为宏。若取消宏定义并使用同名函数,行为为未定义的。如果
va_start
的第二个参数使用register
声明,或者该参数是函数或数组类型,则行为是未定义的。
<stdatomic.h>
– 原子操作(Atomics)该头文件由 C11 引入。
标准 C 为将来可能添加的名称保留了以下命名空间:
-
以
ATOMIC_
开头、后接大写字母的宏名; -
以
atomic_
或memory_
开头、后接小写字母的类型名; -
对于
memory_order
类型,所有以memory_order_
开头、后接小写字母的枚举常量; -
以
atomic_
开头、后接小写字母的函数名。
C17 弃用了宏
ATOMIC_VAR_INIT
。C++ 注意事项:标准 C++ 没有对应的头文件。
<stdbool.h>
– 布尔类型和值(Boolean Type and Values)C99 引入了类型说明符
_Bool
及对应的头文件<stdbool.h>
,该头文件定义了以下内容:-
类型别名
bool
-
宏常量
true
,false
-
宏
__bool_true_false_are_defined
(表示是否已定义布尔宏)
C++ 注意事项:
-
C++11 引入
<cstdbool>
,模拟<stdbool.h>
的行为; -
C++17 改为支持
<stdbool.h>
; -
但 C++17 同时也弃用了该头文件,因为 C++ 自带
bool
、true
、false
关键字。
如何编写可移植的布尔支持代码?
下面是一种兼容多种 C 编译器(包括不支持
<stdbool.h>
的旧版本)以及 C++ 的写法:#ifndef __cplusplus // 如果是 C 编译器 #ifndef __bool_true_false_are_defined #ifdef true #error "存在名为 >true< 的宏" #else #ifdef false #error "存在名为 >false< 的宏" #else #ifdef bool #error "存在名为 >bool< 的宏" #else #if __STDC_VERSION__ >= 199901L #include <stdbool.h> // 使用 C99 的布尔定义 #else typedef int bool; #define true 1 #define false 0 #define __bool_true_false_are_defined 1 #endif #endif #endif #endif #endif #else // 如果是 C++ 编译器(内建 bool/true/false) #ifdef true #error "存在名为 >true< 的宏" #endif #ifdef false #error "存在名为 >false< 的宏" #endif #ifdef bool #error "存在名为 >bool< 的宏" #endif #endif
<stddef.h>
– 通用定义(Common Definitions)C89 引入该头文件,用于集中定义一些常用宏和类型,包括:
-
宏:
NULL
,offsetof
-
类型:
ptrdiff_t
,size_t
,wchar_t
-
C11 还添加了:
max_align_t
除
NULL
外,其余均为 C89 的新增内容。如果
offsetof
的第二个参数是位域(bit-field),行为是未定义的。参见:C11 所附的“边界检查接口”附录可能要求实现向该头文件添加内容。
C++ 注意事项:对应的标准 C++ 头文件为
<cstddef>
。
<stdint.h>
– 整数类型(Integer Types)该头文件由 C99 引入。
标准 C 为将来扩展预留了以下命名规则:
-
宏名以
INT
或UINT
开头,结尾为_MAX
、_MIN
或_C
-
类型名以
int
或uint
开头,结尾为_t
C++ 注意事项:对应的标准 C++ 头文件为
<cstdint>
。 -
-
<stdio.h>
– 输入/输出(Input/Output)
文件与文件系统(Files and File Systems)
文件和目录系统的许多方面是实现相关的,甚至连“文件名”这种最基本的概念,标准 C 都无法统一规范。
-
实现必须支持哪些格式的文件名?
-
目录和设备名的规则也没有统一标准。
-
虽然标准头文件名是固定的,但这些名称不一定与文件系统中的实际文件名完全一致。
一些实现可能允许文件名包含通配符,例如
*.dat
表示所有以.dat
为扩展名的文件。但标准 I/O 函数并不要求支持通配符功能。操作系统可能会对每个用户限制最大打开文件数。有些文件系统不允许同一目录中存在多个同名文件,这对使用
"w"
模式的fopen
可能造成影响。一些文件系统对用户设置磁盘配额,如果文件过大可能导致写入失败,直到写操作失败时才会察觉。
经研究发现,为实现可移植性,标准 C 认为“安全”的文件名格式是:最多六个字母 + 一个点 + 最多一个字母的扩展名,并建议统一大小写。但你也可以用条件编译来适配不同平台。
命令行重定向的行为(如
stdin
重定向为文件)是实现相关的。例如,gets
和fgets(stdin)
行为略有不同,但如果stdin
被重定向,gets
实际上也可能从文件读取。文件缓冲、磁盘扇区大小等也都是实现相关的。但标准 C 要求实现至少能够处理每行包含 254 个字符(包括换行符)的文本文件。
stdin
,stdout
,stderr
有时由操作系统维护,有时在程序启动时创建。这三者是否计入最大打开文件数由实现决定。下列宏的值为实现定义:
-
BUFSIZ
:默认缓冲区大小 -
FOPEN_MAX
:允许同时打开的最大文件数 -
FILENAME_MAX
:合法文件名最大长度 -
TMP_MAX
:可生成的唯一临时文件名最大数
参见: C11 引入的“边界检查接口”附录可能对该头文件提出扩展要求。
C++ 注意事项:对应标准 C++ 头文件为
<cstdio>
。
文件操作函数(Operations on Files)
remove
函数-
通常会真正删除文件。
-
但如果该文件名只是某个文件的“别名”(例如硬链接),则只有最后一个别名被删除时,文件才会被真正删除。
-
如果删除的文件正在被打开使用,行为是实现定义的。
rename
函数-
旧文件名会被删除,类似调用了
remove
。 -
如果旧文件正在被打开使用,行为是实现定义的。
-
如果新文件名已存在,行为同样实现定义。
-
在某些文件系统中,不能跨目录重命名,可能会失败,也可能实际是执行复制+删除。
tmpfile
函数-
如果程序异常终止,临时文件可能不会被删除。
-
临时文件的目录、权限、文件名等都是实现定义的。
tmpnam
函数-
超过
TMP_MAX
次调用时,行为是实现定义的。 -
若提供的非 NULL 地址指向的缓冲区小于
L_tmpnam
字符,行为未定义。 -
返回的文件名在调用时是唯一的,但在使用前可能已被其他程序创建,若需避免此问题,应使用
tmpfile
。
文件访问函数(File Access Functions)
fclose
-
程序异常终止时,不能保证输出流会自动刷新写入缓冲区。
-
在某些实现中,若未写入内容可能无法成功关闭并保留空文件。
fflush
-
如果是输入流,或上次操作非输出,则行为未定义。但一些实现允许安全地刷新输入流。
-
stdout
,stderr
允许调用fflush
。 -
stdin
上的fflush
是未定义行为,但一些实现允许这么做。
fopen
-
一些实现可能在文本文件上无法执行
seek
,此时+
模式可能意味着必须加上b
。 -
'w'
模式下,如果文件已存在,有些系统是覆盖,有些则是创建新版本。 -
模式字符含义是实现定义的。C11 增加了独占模式
'x'
。 -
二进制文件关闭时,有些系统会在末尾添加
\0
,之后以追加方式打开时可能从文件尾之后开始写入。 -
只有当实现确定打开的是非交互设备,才使用全缓冲。
-
成功时返回
FILE*
,失败返回 NULL。标准 C 不保证设置errno
。
freopen
-
成功时返回
stream
,失败返回 NULL。是否设置errno
未定义。
setbuf
-
无返回值,程序员需确保传入的是已打开文件流,缓冲区为 NULL 或足够大。
-
实现可将不同缓冲类型视作等效。即使设置了缓冲方式,也可能无效,且不会有错误提示。
setvbuf
-
mode
可为:_IOFBF
(全缓冲)、_IOLBF
(行缓冲)、_IONBF
(无缓冲)。 -
返回 0 表示成功,非 0 表示失败(例如
mode
无效),不保证设置errno
。 -
若程序员提供了缓冲区,该缓冲区在流关闭前必须保持有效。
-
实现可能不会真正使用提供的缓冲区。
-
缓冲区大小是实现定义的。
格式化输入/输出函数(Formatted Input/Output Functions)
以下函数的行为,均共享
fprintf
/fscanf
所定义的通用格式规则。fprintf
-
若参数不足,行为未定义。
-
C89 增加了
i
,n
,p
转换符(p
以实现定义格式输出void*
)。 -
C99 增加了
F
,a
,A
转换符,以及hh
,ll
,j
,t
,z
长度修饰符;支持无穷大和 NaN。 -
无效转换符行为未定义,标准保留未使用的小写符号供将来使用。
-
若参数是结构体、联合体或数组(除
%s
和%p
允许的情况),行为未定义。 -
没有原型声明时调用该函数,行为未定义。
-
格式化输出缓存有限,标准要求至少支持单项转换为 509 字符。
fscanf
-
输入行为与
fscanf
类似,错误返回EOF
,不保证设置errno
。 -
p
要求参数为void*
,格式为实现定义。
其他格式化函数(所有以下函数在 C99 引入)
输出类函数(遵循
fprintf
行为):-
printf
,snprintf
,sprintf
,vfprintf
,vprintf
,vsnprintf
,vsprintf
输入类函数(遵循
fscanf
行为):-
scanf
,sscanf
,vfscanf
,vscanf
,vsscanf
字符输入输出函数(Character I/O Functions)
gets
-
在 C11 中被移除,因为其存在安全隐患(缓冲区溢出风险)。
ungetc
-
C99 弃用了在二进制文件开头使用该函数的行为。
直接输入输出函数(Direct I/O Functions)
fread
-
发生错误时,文件位置指示器的值为不确定。
-
若只读取部分字段,该字段值为不确定。
-
标准未规定是否将 CR/LF 对在读取时转换为换行符,某些实现对文本文件会这样处理。
fwrite
-
同样,发生错误时,文件位置指示器为不确定。
-
输出时是否将换行符转换为 CR/LF 对也是实现定义的。
文件定位函数(File Positioning Functions)
fgetpos
/fsetpos
-
两者均由 C89 引入。
-
失败时返回非零,并设置
errno
为实现定义的正值。
错误处理函数(Error-Handling Functions)
perror
-
错误信息的内容和格式均为实现定义。
-
-
<stdlib.h>
– 通用工具(General Utilities)该头文件由 C89 定义。
C99 增加了类型
lldiv_t
。宏
EXIT_SUCCESS
与EXIT_FAILURE
是标准 C 中定义的,用作exit
函数的成功与失败的返回码,其具体数值为实现定义。标准 C 保留所有以
str
开头、后跟小写字母的函数名,用于将来在该头文件中扩展函数。参见附录内容:C11 的“边界检查接口”附录可能要求实现向该头文件添加额外内容。
C++ 注意事项:对应的标准 C++ 头文件为
<cstdlib>
。
数值转换函数(Numeric Conversion Functions)
标准 C 不要求
atof
、atoi
和atol
在发生错误时设置errno
。若发生错误,其行为为未定义。-
atoll
函数:C99 添加 -
strtod
、strtof
、strtold
:解析浮点数的格式依赖于当前区域(locale) -
strtol
、strtoll
、strtoul
、strtoull
:解析整数的格式同样依赖区域设置
伪随机序列生成函数(Pseudo-Random Sequence Generation Functions)
-
rand
函数要求RAND_MAX
至少为 32767
内存管理函数(Memory Management Functions)
-
若分配失败,返回
NULL
。永远不要假设内存分配一定成功。 -
若请求分配 0 字节,行为为实现定义,可能返回
NULL
,也可能返回唯一指针。 -
可用堆大小及其管理方式为实现特定。
aligned_alloc
函数由 C11 添加。
calloc
函数分配空间会初始化为全比特为零。注意:这不保证等价于浮点零或空指针的表示。
free
函数-
ptr
为NULL
时无操作。 -
若
ptr
非由malloc
/calloc
/realloc
返回的值,行为未定义。 -
被释放后的指针不可解引用,其值为不确定的。
-
free
无法报告错误。
malloc
函数分配空间的初始值为未指定。
realloc
函数-
若
ptr == NULL
,等同于malloc
。 -
若
ptr
非合法指针或已被释放,行为未定义。
与环境通信(Communication with the Environment)
abort
函数是否刷新输出流、关闭文件、删除临时文件为实现定义。
-
程序的退出码是由
raise(SIGABRT)
触发的实现定义值。
atexit
函数-
标准 C 要求至少支持注册 32 个函数。
-
可以注册一个主函数,在其中调用其他函数以突破此限制。
at_quick_exit
函数由 C11 添加。
_Exit
函数由 C99 添加。
getenv
函数-
环境变量列表由主机环境维护,变量名集合为实现定义。
-
若修改返回的字符串内容,行为为未定义。
-
某些实现支持
main
的第三个参数envp
,但标准 C 并不包括它。
quick_exit
函数由 C11 添加。
system
函数-
标准 C 不要求命令处理器存在,若无,则返回实现定义值。
-
使用
system(NULL)
检查命令处理器是否可用(非零表示可用)。 -
命令字符串格式为实现定义。
查找与排序工具(Searching and Sorting Utilities)
bsearch
函数-
若多个元素比较相等,则匹配的是哪个未指定。
qsort
函数-
若多个元素比较相等,它们在数组中的顺序未指定。
整数数学函数(Integer Arithmetic Functions)
abs
函数-
若结果超出表示范围,行为未定义。
-
可能实现为宏。
div
函数-
若结果超出范围,行为未定义。
-
由 C89 引入。
labs
函数-
同样行为未定义,可为宏。
ldiv
函数-
行为未定义,由 C89 引入。
llabs
函数-
C17 引入。
lldiv
函数-
行为未定义,由 C99 引入。
多字节字符函数(Multibyte Character Functions)
这些函数的行为依赖于当前区域设置(尤其是
LC_CTYPE
分类)。
最早的多字节字符处理支持由 C89 引入。
<stdnoreturn.h>
–_Noreturn
该头文件由 C11 添加,用于支持
_Noreturn
函数修饰符。C++ 注意事项:标准 C++ 没有对应头文件。
-
-
<string.h>
– 字符串处理(String Handling)C 语言的实现可以自由地对任何数据类型施加特定的内存对齐要求。因此,当你复制一个已对齐的对象到内存中时,你必须保证目标副本的对齐同样满足要求。
否则,复制出的对象可能无法访问或被错误解释。这意味着:程序员必须确保复制后的对象既符合格式,又处于可正确使用的内存位置。
标准 C 保留所有以
str
、mem
或wcs
开头、后跟小写字母的函数名,以供将来在本头文件中添加更多函数。参见附录内容:C11 的“边界检查接口”可能要求实现向该头文件添加更多内容。
C++ 注意事项:对应标准 C++ 头文件为
<cstring>
。
复制函数(Copying Functions)
memcpy
函数-
如果两个内存块重叠,行为未定义。
memmove
函数-
由 C89 引入。
-
与
memcpy
不同,它可以处理重叠内存区域。
strcpy
函数-
若源字符串与目标字符串重叠,行为未定义。
strncpy
函数-
同样,重叠行为未定义。
拼接函数(Concatenation Functions)
strcat
函数-
若两个字符串重叠,行为未定义。
strncat
函数-
同样,重叠行为未定义。
比较函数(Comparison Functions)
建议:
所有比较函数都会返回一个整数,表示:-
小于 0:前者小于后者
-
等于 0:相等
-
大于 0:前者大于后者
切勿假设返回值的具体正负值具有特定含义,应只与 0 作比较,而不是特定非零值。
strcoll
函数-
比较方式依赖当前区域设置(locale)
-
由 C89 引入。
strxfrm
函数-
由 C89 引入,用于字符串转换,便于按
strcoll
的方式比较。
strstr
函数-
由 C89 引入,用于查找子字符串。
其他函数(Miscellaneous Functions)
strerror
函数-
返回的错误信息内容为实现定义。
-
不要尝试修改其返回的字符串内容。
<tgmath.h>
– 类型泛化数学函数(Type-Generic Math)该头文件由 C99 引入,用于在不指定具体类型的情况下调用
math.h
或complex.h
中的函数(即允许编译器根据实参类型自动选择对应的版本,如float
、double
、long double
)。C++ 注意事项:对应 C++ 头文件为
<ctgmath>
,但在 C++17 中已被弃用。
<threads.h>
– 线程支持(Threads)该头文件由 C11 引入。
如果某个实现支持关键字
_Thread_local
(可通过宏__STDC_NO_THREADS__
判断),则它也会提供<threads.h>
头文件。因此,你应使用如下方式启用线程局部存储:
#include <threads.h> void f() { thread_local static int tlsI = 0; … }
其中,
thread_local
是<threads.h>
中定义的宏,映射为_Thread_local
,也与 C++ 中的同名关键字一致。标准 C 保留所有以以下前缀开头、后跟小写字母的名称,用于在本头文件中未来扩展:
-
函数名:
cnd_
、mtx_
、thrd_
、tss_
-
类型名和枚举常量也遵循上述命名规则
C++ 注意事项:标准 C++ 没有对应的头文件。
-
-
<time.h>
– 日期与时间(Date and Time)标准 C 保留所有以
TIME_
开头、后跟大写字母的宏名称,用于将来在该头文件中添加更多内容。参见附录内容:C11 的“边界检查接口”附录可能要求实现向该头文件添加额外内容。
C++ 注意事项:对应的标准 C++ 头文件为
<ctime>
。
时间组成(Components of Time)
-
C99 用
CLOCKS_PER_SEC
替代了旧的CLK_TCK
宏。 -
C11 添加了宏
TIME_UTC
,以及结构体类型struct timespec
。 -
C11 还为
struct tm
结构添加了成员tv_sec
和tv_nsec
。
时间操作函数(Time Manipulation Functions)
-
difftime
函数:由 C89 引入,用于计算两个时间点之间的差值。 -
mktime
函数:由 C89 引入,用于将struct tm
转换为time_t
。 -
timespecget
函数:由 C11 引入,用于获取高精度时间信息。
时间转换函数(Time Conversion Functions)
-
strftime
函数:由 C89 引入,用于将struct tm
格式化为字符串。 -
C99 为
strftime
增加了以下格式说明符:C
、D
、e
、F
、g
、G
、h
、n
、r
、R
、t
、T
、u
、V
、z
。
<uchar.h>
– Unicode 工具(Unicode Utilities)该头文件由 C11 添加,提供对 Unicode 字符类型的支持。
C++ 注意事项:对应的标准 C++ 头文件为
<cuchar>
。
<wchar.h>
– 扩展多字节与宽字符工具(Extended Multibyte and Wide Character Utilities)该头文件由 C95 添加。
标准 C 保留所有以
wcs
开头、后跟小写字母的函数名称,用于将来在该头文件中添加内容。参见附录内容:C11 的“边界检查接口”附录可能要求实现向该头文件添加更多内容。
C++ 注意事项:对应的标准 C++ 头文件为
<cwchar>
。
<wctype.h>
– 宽字符分类与映射工具(Wide Character Classification and Mapping Utilities)该头文件由 C95 添加。
标准 C 保留所有以
is
或to
开头、后跟小写字母的函数名称,用于将来在该头文件中添加内容。C++ 注意事项:对应的标准 C++ 头文件为
<cwctype>
。 -