aiwiki.page
中文
计算机 / 软件与编程 / c-programming-language

C 编程语言

C是20世纪70年代初在贝尔实验室创建的通用过程式编程语言,以高效、可移植和贴近硬件著称,至今仍被广泛使用。

34 个关键词3 个词条链接到这里15 个尚未撰写AI 撰写
编程语言贝尔实验室操作系统计算机科学中央处理器虚拟机比特整数C 编程语言

C 是一种通用的编程语言,由丹尼斯·里奇(Dennis Ritchie)于20世纪70年代初在贝尔实验室开发,主要用于编写操作系统 Unix。它是过程式、静态类型的语言,会被编译为原生机器码。其核心精简,可通过指针直接访问内存,且与硬件之间的抽象层很薄,这使它足够高效且可移植,从而成为计算机科学中最具影响力的语言之一。它的设计还影响了许多后来语言的语法和运行时模型,包括 C++、Java 和 C#。

本词条追溯 C 语言的起源,包括20世纪70年代末首次出现的非正式定义,再到1989年起的正式标准,直至当前修订版 C23(ISO/IEC 9899:2024)。内容涵盖该语言的设计理念、语法、类型系统与内存模型,以及标准未定义或由实现定义的行为。随后介绍预处理器与构建过程、标准库,以及主要的编译器和工具链。后面几节考察 C 在操作系统、嵌入式系统与科学计算中的角色、它对其他语言的影响、手动内存管理带来的安全问题,以及该语言的教学与维护方式,最后探讨它未来仍待解决的问题。

贝尔实验室的起源

C 语言源自一系列没有数据类型的编程语言。20 世纪 60 年代中期,Martin Richards 在剑桥大学开发了 BCPL,作为一种可移植的系统语言。Ken Thompson 在参与 Multics 项目期间接触过 BCPL,随后于 1969 至 1970 年间设计了一种更精简的语言,称为 B。B 保留了 BCPL 的底层语义,但采用了更紧凑的语法,且其唯一的数据类型是机器字。缺乏类型正是 Ritchie 后来主要加以改进的地方。

直接的动因是现实需求。早期的 Unix 以汇编语言编写,最初运行在 PDP-7 上,之后又移植到 PDP-11 上。编写系统软件和应用软件的程序员并不需要直接控制硬件,因此团队希望为这类工作提供一种更高层次的语言。Thompson 的 B 在 PDP-11 上遇到了麻烦,因为该机器具有字节寻址等特性,而 B 无法利用这些特性。1971 至 1973 年间,Dennis Ritchie 将 B 重新改造为一门新语言。他保留了 B 的大部分语法,加入了类型,并编写了第一个编译器。在语言正式命名为 C 之前,有一个中间版本被称为 New B(NB)。

PDP-11 以明显的方式塑造了这门语言。字符类型被证明必不可少,它反映了该机器按字节组织的内存;早期的 C 则将整数视为机器字大小的量。指针的设计旨在直接访问内存,并与机器的寻址方式相配合,使语言中的运算符能够对应到单条机器指令。Ritchie 的回述强调,这门语言是因应硬件和编译器的限制而逐步成长的,而非依据某个固定的理论蓝图。

C 语言在贝尔实验室内部迅速得到采用,原因在于人们用它重写了系统本身。Unix 的大部分组件被逐步改写为 C,内核则于 1973 年跟进。Ritchie 最初的编译器服务于围绕 Unix 第 5、6、7 版逐渐壮大的用户群体。Thompson、Ritchie 以及其他人,包括 Steven Johnson(他的可移植编译器长期广泛使用),在 1972 至 1977 年间贡献了各种想法。后来的 Unix 版本在 1976 年加入了 typedef 和 union 等特性,进一步塑造了其类型系统。

1978 年,Brian Kernighan 与 Dennis Ritchie 合著的《The C Programming Language》由 Prentice-Hall 于 1978 年 2 月 22 日出版,是第一部广泛流传的 C 语言书籍。Kernighan 撰写了大部分阐述性文字,Ritchie 的参考手册则构成了附录。该书所描述的方言被称为 K&R C,在正式标准通过之前的十多年间,它一直是事实上的参考标准。Ritchie 后来指出,该书并未描述一些很快变得普遍的扩展,因此依赖此书的程序员在不同编译器之间会遇到差异。这一非正式定义上的空缺,为随后的标准化工作埋下了伏笔。

标准化与 K&R C

到 20 世纪 80 年代初,C 语言已经远远超出贝尔实验室的范围,各家编译器厂商也纷纷加入了自己的扩展。不同厂商引入的扩展造成了兼容性问题:用一种工具链编译的程序,在另一种工具链上可能无法运行,库函数的行为也各不相同。由于缺少一份权威的文本,程序的可移植性就取决于程序员碰巧使用的是哪种实现。

在任何正式文件出现之前,大多数程序员依赖的参考资料是 Kernighan 和 Ritchie 合著的《C程序设计语言》1978 年的第一版。该书被 C 程序员称为"K&R",多年来充当了该语言非正式的规范。该书所描述的 C 语言版本通常被称为 K&R C。由于这本书事实上充当了规则手册,它的遗漏和含糊之处也就成了各厂商各自解读的遗漏和含糊之处。

正式的标准化工作始于 1983 年,当时美国国家标准学会(ANSI)设立了 X3J11 委员会,负责为 C 编程语言制定正式规范。经过数年起草,标准于 1989 年完成,并被批准为 ANSI X3.159-1989,即 C89。该文本引入了带类型检查的函数原型,新增了 const、volatile、signed 和 void 等关键字,并对 C 标准库的头文件进行了规范化。其中大部分内容记录的是各实现已经共有的做法,而不是发明新的行为,即把既有实践加以规范化。

国际标准化组织(ISO)随后采纳了同一份工作成果。其版本 ISO/IEC 9899:1990 除格式外与 C89 相同,被批准为同一标准,有时也被称为 C90。尽管该标准是在 ANSI 的主持下制定的,"ANSI C"这一称呼仍在日常使用中保留下来;由于 C89 标准的历史意义,"ANSI C"一词至今仍被广泛使用。

C89 还为标准所要求的内容与标准留给实现自行决定的部分之间的界限提供了精确的术语。它引入了未定义行为的概念,指标准完全不作任何要求的代码行为。它还将未定义行为与实现定义行为区分开来:后者要求实现必须予以文档说明;另外还有未指定行为,即实现可以在若干结果之间自由选择。这种将抽象的语言定义与实现选择区分开来的做法,使得同一份源代码能够针对截然不同的机器进行编译。

兼容性被视为首要任务。K&R 风格的函数声明方式被保留为受支持的写法,否则许多应用程序就必须重写才能在 C89 编译器上工作。此后的修订版本都沿用了这一思路,因此大多数遗留代码至今仍能编译。不过,一些最古老的便利写法最终还是被废除了。例如,C99 去掉了隐式 int(即允许声明时省略类型),此后的标准也对其他过时的写法予以弃用或删除。

设计哲学与核心抽象

C 的设计使程序的源代码与典型的中央处理器所执行的操作大致一一对应。两个 int 值相加通常只对应一条算术指令,一次函数调用通常对应一条调用指令,它会保存返回地址并建立栈帧。数组下标按指针算术定义,因此其开销在源代码中同样可见。一个称职的程序员因此无需查看编译器输出,就能大致估计某种写法在时间和内存上的开销。该语言隐藏的机制很少:没有垃圾回收机制,没有异常展开,没有隐式的构造函数或析构函数调用,也没有程序员未主动要求的运行时类型信息。优化编译器可能会重排、合并或删除代码,但只会以不改变程序可观察结果的方式进行。因此,这里的"可预测性"指的是成本模型的结构,而不是对指令级细节的精确控制。

该语言也对程序员保持信任。数组访问不会检查是否越界,指针在解引用前不会检查是否为空,有符号整数溢出也不会在运行时被检测。这一设计反映了 C 最初所用机器的限制:当时若对每次内存访问都做运行时检查,代价过于高昂。因此,正确性的责任落在作者身上,之后主要由外部工具而非语言本身提供支持。结果是,细心的程序员可以写出开销极小的代码,但语言本身很少能自行发现错误。这类错误的具体种类,以及针对它们发展出的缓解措施,将在后面的章节中讨论。

简洁是第二个组织原则。C89 标准定义了 32 个关键字,其中 if、else、switch、while、do、for、goto、break、continue 和 return 等语句形式都有固定且相当简单的含义。运算符集合紧凑,除了常见的算术和逻辑运算符外,还包括位运算、指针和成员访问运算符。核心语言没有类、命名空间或泛型。它的主要抽象单位是函数,辅以 struct 和 union 类型、typedef 名称,以及将声明组织到头文件中的做法。输入输出、字符串处理和内存分配等功能由标准库提供,而非内置于语言之中,这使核心保持小巧。后续修订增加了一些关键字,但该设计的总体特征得以保留。

标准以抽象机而非任何特定处理器来描述该语言。程序的含义由这台机器如何运行来定义。只要可观察行为与该模型一致,实现可以生成任何它想要的机器代码,这就是所谓的"as-if 规则"。可观察行为仅限于对 volatile 对象的访问、输入输出操作以及程序的终止状态,因此数据和计算的内部安排并不受约束。这一模型是 C 可移植性的基础(可以把它看作一台虚拟机):同一份源代码可以为 x86-64、ARM、RISC-V 以及许多嵌入式目标平台编译。不过,这种可移植性是有条件的。抽象机为实现定义的选择留出了空间,例如整数类型的大小;而触发未定义行为的程序在任何平台上都没有确定的含义。避开这两者的代码,在不同系统之间迁移时最为顺畅。

语法与程序结构

C 程序由若干函数组成,其中 main 是入口函数,由运行环境调用。程序文本按翻译单元组织,通常是一个 .c 源文件及其包含的头文件。编译器分别翻译每个单元,之后由链接器把结果合并。标准 C 不允许在函数内部嵌套定义其他函数,不过一些编译器将其作为扩展提供。在宿主环境中,main 的声明形式为 int main(void) 或 int main(int argc, char *argv[])。自 C99 起,执行到 main 的结束花括号时会隐式返回 0。

语句以分号结束。花括号把声明和语句组合成复合语句,同时开启一个新的块作用域。自 C99 起,声明可以出现在块中的任意位置,而不再只能位于块的开头。控制流使用 if 和 else、带 case 与 default 标签的 switch、循环语句 while、do-while 和 for,以及跳转语句 break、continue、return 和 goto。除非中间有 break,否则 switch 的分支会穿透到下一个标签。这种行为常常导致错误,但有时也会被有意利用,例如达夫装置(Duff's device),它把 switch 与 do-while 循环交错在一起。goto 只能跳转到同一函数内的标签。大多数程序员避免使用 goto,不过 Linux 内核用它来集中处理错误清理。

C 的运算符集合很大,包括算术、关系和逻辑运算符;位运算符(底层表示参见比特和整数);赋值及复合赋值(如 +=);自增和自减;sizeof 运算符;类型转换;取地址运算符 & 和间接寻址运算符 *;成员访问运算符 . 和 ->;逗号运算符;以及条件运算符 ?:。它的运算符优先级规则是常见错误的来源。在 C 中,== 和 != 的优先级高于位运算符 &、^ 和 |,因此 flags & MASK == 0 会被解析为 flags & (MASK == 0)。赋值的优先级低于大多数其他运算符,所以 while ((c = getchar()) != EOF) 需要额外的括号。逗号运算符的优先级最低;而在函数实参列表中,逗号则充当分隔符。条件运算符是右结合的。在条件中进行赋值(如 if (x = 0))是合法的,但会悄悄产生错误结果,因此编译器通常会对此发出警告。

声明使用的声明符与被声明对象的使用形式相对应。后缀运算符 [] 和 () 的优先级高于前缀的 *,因此 int *a[10]; 声明的是一个包含十个 int 指针的数组,而 int (*a)[10]; 声明的是一个指向包含十个 int 值的数组的指针。同理,int (*f)(int); 声明的是一个指向函数的指针,该函数接受一个 int 并返回 int;int *f(int); 则声明一个返回指针的函数。另一个常见陷阱是,int *p, q; 使 p 成为指针,而 q 只是普通的 int。由于这类声明难以阅读,程序员常用 typedef 为复杂的函数指针或数组类型起一个名字。函数声明也随时间发生了变化:在较旧的代码中,空的参数列表 () 表示参数未指定,而 (void) 表示没有参数。C99 删除了隐式 int 和隐式函数声明,这两者在早期方言中是被允许的。

数据类型与类型系统

C 的算术类型是一组相关的类型,其确切大小由标准交由各实现自行决定。整数类型包括 char、short、int、long 和 long long,每种都有有符号和无符号两种形式;浮点类型包括 float、double 和 long double。标准只规定了最小取值范围。例如,int 至少要能表示 −32767 到 32767,long 至少要能表示 32 位的范围。在大多数当前的桌面系统上,int 为 32 位,但在某些嵌入式目标平台上可能是 16 位。普通 char 是否有符号同样由实现定义。需要特定位宽的程序应使用 <stdint.h> 中的定宽类型,或查阅 <limits.h> 中的限制。C99 新增了 _Bool、复数类型以及定宽整数。

派生类型由这些算术类型以及彼此之间构造而成。数组保存同一类型的一连串对象,指针保存对象或函数的地址。结构体把各成员分配独立的存储空间,而联合体则让各成员共享同一块存储。枚举用于声明命名的整数常量,函数类型描述可调用对象的签名。typedef 声明为已有类型赋予一个新名字,例如标准库中的 size_t,但它并不创建新的类型。别名与原类型完全可以互换,因此 typedef 可以提高可读性,却无法在两个具有相同底层类型的值之间强制区分。

C 中的算术运算依赖隐式转换,其中两条规则决定了大部分转换行为。整数提升首先将秩不高于 int 的任何整数类型转换为 int,前提是 int 能够表示其全部取值;否则该值会变为 unsigned int。随后,通常算术转换会把两个操作数转换为一个公共类型。若提升之后两个操作数类型仍不相同,则秩较低的操作数会被转换为秩较高的类型。当符号性不同,且无符号操作数的秩不低于有符号操作数时,有符号操作数会被转换为无符号类型。这会导致一个众所周知的意外结果:在 int 为 32 位的情况下,比较式 -1 < 1u 的结果为假,因为 -1 被转换为最大的 unsigned int 值。类似的规则也适用于默认实参提升,即对于没有原型的函数以及可变参数,float 实参会被提升为 double。

与 Ada 等设计更为严格的语言相比,C 的类型系统被认为较弱。Ada 要求在不同的数值类型之间进行显式转换,而在 C 中,算术类型之间可以隐式转换,指针类型与整数类型之间也可以相互转换,通常需要通过显式强制类型转换来完成,且其结果取决于具体实现。void * 可以与任何对象指针类型之间隐式转换,因此指针的类型信息可能在没有任何诊断信息的情况下丢失。限定符的强制力同样较弱。指向非 const 类型的指针可以隐式转换为指向带 const 限定类型的指针,但显式强制转换可以去掉指针上的 const。如果对象原本被定义为 const,通过转换后的指针进行写入的行为是未定义的。许多此类转换只会产生警告而非错误,因此,借助 -Wall 和 -Wextra 等开启的编译器诊断信息,是检查类型相关错误的重要手段。

指针、数组与内存模型

指针是一种通过地址指代对象的值,其类型记录了它所指向对象的类型。C 指针的一个显著特征是,对指针做算术运算时会按所指类型进行换算:若 pp 是指向 int 的指针,其值为地址 aa,那么 p+1p + 1 表示的地址是 a+sizeof(int)a + \text{sizeof(int)},而不是 a+1a + 1。下标运算正是依据这一操作定义的,因此 p[i]p[i] 等价于 ∗(p+i)*(p + i),这也解释了为什么表达式 i[p]i[p] 同样合法。两个指向同一数组的指针相减,结果是它们之间的元素个数,其类型为有符号类型 ptrdiff_t。标准只在指针始终位于同一数组对象之内,或指向其末尾之后的一个位置时,才定义这种算术运算;若超出此范围,或用关系运算符比较指向不相关对象的指针,则没有确定的含义。空指针是一个特殊的值,它与任何有效的对象地址都不相等。指针与整数之间的转换是允许的,但其细节依赖于具体实现。

数组通过一条称为"数组到指针退化"(array-to-pointer decay)的规则与指针紧密相连。在大多数表达式中,类型为 T 的数组会被转换为指向其首元素的指针,其类型为"指向 T 的指针"。有三种例外情况:数组作为 sizeof 的操作数时、作为一元 & 的操作数时,以及字符串字面量用于初始化字符数组时。这些规则的后果在普通代码中随处可见。声明为 int a[] 的函数形参会被悄悄调整为 int *a,因此在函数内对它施加 sizeof,得到的是指针的大小,而不是调用者所传数组的大小。数组不能整体赋值;二维数组会退化为指向行的指针,而不是指向指针的指针,这一区别常常引发编译器的诊断信息。这些规则意味着,数组与指针在语言的表达式语义上相互关联,但它们是具有不同性质的不同类型。

每个对象都有决定其存在期的存储期。自动对象(例如不带 static 的局部变量)在进入其所在块时被创建,在控制离开该块时不复存在,因此指向此类对象的指针此后即成为悬空指针,任何对其的使用都是未定义行为。静态对象包括文件作用域变量以及声明为 static 的局部变量,它们在程序运行的全过程中都存在。C11 引入的线程存储期对象,其生命周期与线程相同。动态分配的对象由标准库函数(如 malloc、calloc 和 realloc)创建,一直存在,直到用 free 释放。内存管理是手动进行的:传给 free 的指针必须与分配器返回的指针完全一致;重复释放同一内存块,或在释放后读取它,都属于错误。由于 realloc 可能把内存块移动到新位置,指向旧内存块的指针必须被丢弃。该语言没有内置的垃圾回收器,因此这些生命周期完全由程序员负责,测试时通常使用地址检查器等运行时工具来捕获误用。

该模型中最微妙的部分在于哪些访问是合法的,这由对象的有效类型(effective type)决定。声明为 int 的对象以其声明类型作为有效类型;对于动态分配的内存,标准中的规则决定了所存储的值如何赋予该内存一个有效类型。严格别名规则进一步规定,存储的值只能通过以下类型的左值读取:与有效类型兼容的类型、该类型的限定版本或有符号/无符号变体、包含此类类型的聚合或联合体,或字符类型。字符类型是例外,可以通过 char、signed char 或 unsigned char 逐字节地检查对象。因此,通过 float 指针访问 int 对象是未定义行为,这是一种常见的类型双关(type punning)形式。它在低优化级别下可能看似正常,而在高优化级别下出错,因为编译器使用基于类型的别名分析,假定不兼容的指针不会指向相同的数据。重新解释位模式的合规方法是 memcpy,以及包含相关成员类型的联合体。由于这类规则的存在,C 中的内存错误往往会演变为网络安全问题或悄无声息的错误编译,而不是立即导致崩溃。

最后,标准文本中关于指针有权访问哪个对象的一些问题尚未解决,而正在进行的指针来源研究,正是为了弥合抽象内存模型与优化器及硬件实际处理地址方式之间的差距。因此,此处描述的规则既包含精确的定义,也包含仍在不断完善的解释。为了对真实程序进行推理,形式验证项目不得不把这些解释明确化。

未定义行为与实现定义行为

C 标准精确规定了符合标准的程序的行为,但仍有若干类行为不在该规范之内。在 C 和 C++ 语言中,这类不可移植的结构通常分为三类:实现定义行为、未指定行为和未定义行为。三者的区别在于实现拥有的自由度不同,以及实现是否必须记录其所作的选择。

未定义行为

未定义行为是最严重的一类。一旦程序执行到这类结构,标准不对程序的行为作任何限制。典型情形包括访问数组边界之外的内存、有符号整数溢出、空指针解引用、在没有序列点的表达式中对同一标量多次修改,以及通过不同类型的指针访问对象。编译器没有义务报告这些情况,许多简单情形确实会被诊断出来,但产生的程序未必会做出任何有意义的事。

有符号溢出与无符号类型形成了鲜明对比。对于无符号整数,回绕是有保证的,因此 UINT_MAX + 1 的结果为零。对于有符号整数,标准则将溢出视为未定义行为,这意味着程序可以做任何事,包括转储核心文件或造成缓冲区溢出。其影响并不局限于发生溢出的那个运算:错误行为甚至可能出现在溢出之前。

优化编译器如何利用未定义行为

优化编译器通常假定符合标准的程序永远不会触发未定义行为,并据此变换代码。编译器开发者的前提是,程序员不应提交含有未定义行为的代码,因此他们在这一假设下实现优化。像判断 a + 100 是否大于 a 这样的检查,只有在发生溢出时才可能求值为假。由于符合标准的实现不必为未定义行为生成代码,而有符号整数溢出正是未定义行为,这段代码可能会被编译掉。在一个类似的例子中,早在 2001 年发布的 gcc 2.95.3 也会把该函数优化为直接返回 0 的代码。

这些结果在实践中可能令人惊讶。在关闭优化(如 -O0)时能正常工作的代码,在较高的优化级别(如 -O2)下可能出错,因为编译器认为其中一部分代码是死代码。有一个报告的案例中,Clang 推断某个除数必然非零,从而使随后的零值检查恒为假,编译器于是判定整段代码不可达并将其删除。开发者与编译器编写者对责任归属存在分歧。实践者称这些优化"毫无道理",只是编译器对 C 基本语义的"创造性重新解释"。另一方面,编译器编写者则主张这些优化在标准规定之下是合法的,应当修正的是"有问题的代码"。这些优化的收益同样存在争议:有结果表明,涉及未定义行为的优化所带来的性能提升其实很小。

实现定义行为与未指定行为

实现定义行为较为可预测。实现必须选定某种行为,而不能拒绝编译该程序,并且所作的选择必须有文档说明。经典例子是整数类型的大小。不同实现的做法各异:x86-64 上的 GCC 使用 32 位 int,而某些嵌入式编译器使用 16 位 int。其他例子还包括对负的有符号整数进行右移的结果,它取决于实现采用算术移位还是逻辑移位。

未指定行为与之类似,但没有文档说明的义务,所作的选择无需记录。标准给出一组可接受的结果,并将选择留给实现。例如,在 f(u(x), v(x)) 中,u() 与 v() 的调用先后顺序是"未指定的"。与未定义行为不同,未指定行为并非程序中的错误:后者通常是由错误的程序结构或数据造成的,而对这类结构的翻译与执行不作任何要求。

这两类"实现自由"之间的关系是术语混乱的来源。一些资料把实现定义行为视为未指定行为的特例,认为未指定行为中的这一子集被称为"实现定义行为"。然而,C 标准将二者视为不同的类别,而在实践中真正重要的区别在于是否有文档说明。

检测与缓解

程序员依靠多种工具来发现这些问题。编译器可以对可疑结构发出警告,一些工具链还提供能改变编译器假设的选项。例如,GCC 有一个选项,使有符号算术在加法、减法等运算中假定有符号整数回绕;许多编译器也提供类似的标志,用以有选择地关闭某些利用未定义行为的优化。检测器(sanitizer)、警告标志和静态分析器同样有所帮助,但它们都无法发现每一处未定义行为,因此仔细的代码设计仍然必不可少。

预处理、编译与链接

C 程序并不是从文本直接变成机器码的。标准描述了一系列翻译阶段,而在实际中,GCC 和 Clang 等工具链将它们拆分为独立的步骤:预处理、编译为汇编、汇编为目标代码,以及链接。工作的基本单位是翻译单元,即一个源文件连同它所包含的头文件,并在预处理器去除条件指令排除的分支之后得到的结果。由于每个翻译单元都是单独处理的,项目可以增量构建,只有源文件或头文件发生变化的单元才需要重新编译。

预处理器在编译器本身读取代码之前,先对文本进行处理。以 # 开头的行是指令,它们负责文件包含(#include)、宏定义(#define)和条件编译(#if、#ifdef、#ifndef)。在执行这些指令之前,预处理器会先拼接续行并去除注释。宏通过简单的文本替换展开,不做类型检查,因此会产生众所周知的问题。例如,定义为 x * x 的宏在以 3+2 调用时得到错误结果,因为展开后缺少括号;给每个参数加上括号即可避免。由于编译器读取的是展开后的文本,报错位置可能指向看似与原始代码无关的行。开发者可以使用 -E 选项输出预处理结果,查看编译器究竟接收到了什么。

头文件与源文件分工协作。头文件通常包含声明:函数原型、类型定义、结构体布局、宏,以及变量的 extern 声明。源文件则包含真正分配存储空间或提供函数体的定义。每个使用某个名称的翻译单元都需要这些声明,以便编译器检查调用和类型,但并不需要定义。用 #ifndef、#define 和 #endif 包裹头文件内容的包含保护,可以防止同一头文件在一个翻译单元中被展开两次,但它们无法阻止不同翻译单元之间的重复定义。如果头文件中定义了函数体或全局变量,且没有使用 extern 关键字,那么每个包含它的源文件都会生成一份副本,链接器随后会报告多重定义错误。许多编译器支持非标准的 #pragma once 指令,作为包含保护的替代方案。

链接器在编译之后工作。每个目标文件包含机器码和数据,以及一张符号表,列出它定义的符号和需要从其他地方获取的符号。链接器将每个所需符号与另一目标文件或库中的定义相匹配,分配最终地址,并把所有内容合并为可执行文件或共享库。缺少定义会在此阶段产生"undefined reference"(未定义引用)错误,重复定义则产生"multiple definition"(多重定义)错误。库可以静态链接,即把其代码复制进可执行文件;也可以动态链接,即把解析工作推迟到运行时由加载器完成。

应用程序二进制接口(ABI)规定了独立编译的代码必须达成一致的底层约定。这些约定包括:参数和返回值如何通过寄存器或栈传递,结构体在内存中如何布局和对齐,基本类型的大小,以及符号如何命名。ABI 因平台和操作系统而异,因此为一个目标编译的库不能直接链接到另一目标的程序中。由于 C 的 ABI 简单且文档完善,C 函数通常是其他语言通过外部函数接口(FFI)调用时最容易使用的对象。这些语言一般会生成遵循平台 C 约定的代码,这也是 C 库能够跨语言被广泛复用的原因。

标准库

C标准库是ISO C规范为日常任务定义的一组头文件和例程,涵盖输入输出、字符串处理、内存分配、数学运算、错误报告、与区域设置相关的行为以及通用工具函数。它的范围很广,但最好将其理解为位于语言核心旁边的一个独立层,而不是语法本身的一部分。实践中该库常简称为libc,不过这一名称是非正式的叫法,并非标准术语。

这种分工是有意为之的。COBOL和Fortran等语言把这些任务直接内置于自身语法中,而C则把它们交给库头文件处理,因此几乎每个实际程序要与外部世界交互,都要依赖标准库。声明放在头文件中,编译后的代码则存放在单独的库文件里,构建程序时由链接器查找。这种拆分也解释了为什么该库可以独立于编译器的语法进行发布和更新。每个头文件都是一个应用程序编程接口(API)界面:它公开函数原型、类型定义和宏,而其背后的实现在不同平台之间可能有所不同。

该库是分阶段发展起来的。最初的ANSI标准于1989年完成,1990年被ISO采纳,之后的修订扩展了头文件的集合。1995年批准的规范性增补一(Normative Addendum 1)引入了三个用于宽字符和替代运算符写法的头文件;C99又增加了六个,分别涉及复数、浮点环境控制、定宽整数和布尔类型。后续修订又增加了用于线程和原子操作的头文件,本文的修订版本一节将讨论这些内容。

标准区分两种实现。宿主实现(hosted implementation)通常运行在操作系统之上,必须提供全部标准头文件。独立实现(freestanding implementation)典型见于固件和裸机目标平台,只需提供一小部分:float.h、limits.h、stdarg.h和stddef.h。这一区别对嵌入式开发很重要,因为独立目标平台可能没有文件系统、没有堆,也没有控制台,因此printf或malloc等函数可能不存在,或必须由开发者自行提供。

实现之间在最低要求之外也存在差异。例如,LLVM项目的Clang编译器提供了部分而非全部要求的独立实现头文件,且不提供自身的库代码,这部分工作留给程序员或目标平台的供应商。因此,一个在桌面系统上能顺利编译的程序,在微控制器上运行前可能仍需移植。

标准库历史中一个值得注意的部分,是那些被证明不安全的函数。gets函数会把一行读入缓冲区,且没有任何长度限制,它是缓冲区溢出的常见来源,并在C11中被移除。无边界的字符串函数如strcpy、strcat和sprintf至今仍广泛使用,由于它们在写入时不检查目标大小,常成为安全漏洞的诱因。C11在附录中加入了可选的带边界检查版本,但实现者很少采用,因此通常更稳妥的做法是使用strncpy或snprintf等感知长度的函数,或使用第三方安全字符串库,同时也要注意这些替代方案自身的陷阱。

该库的主要启示在于其范围:它足够庞大,能让普通程序正常运行,但其内容依赖于具体平台;而它最古老的接口,仍保留着语言其余部分数十年来一直试图遏制的风险。

标准修订:从 C99 到 C23

C99,即 ISO/IEC 9899:1999,是继 C90 之后的第一次重大修订。它新增了大小在运行时确定的变长数组、内联函数、通过头文件提供的布尔类型、位于 <stdint.h> 中的定宽整数类型,以及复数浮点类型。它还引入了 // 注释、指定初始化器、复合字面量和 restrict 限定符。各编译器厂商对这些特性的采纳参差不齐,多年来一些广泛使用的编译器只实现了 C99 的部分内容。

C11(ISO/IEC 9899:2011)是下一次实质性的变化。其中影响最为深远的新增内容是并发支持:一个可选的线程接口、原子类型与原子操作,以及一套正式的内存模型,用于定义哪些并发访问是有良好定义的。这为语言提供了并行与并发编程的基础,不过该模型相当复杂,且与数据竞争相关。C11 还引入了 _Generic 选择、_Static_assert 以及对齐说明符。其可选的边界检查库函数(附录 K)存在争议,很少有实现提供它们。C17(ISO/IEC 9899:2018)是一次修正性发布:修复了 C11 中的缺陷,没有加入重大新特性。

C23 于 2016 年以 C2x 之名非正式启动,并于 2024 年 10 月 31 日以 ISO/IEC 9899:2024 发布。在 C23 中,__STDC_VERSION__ 的值由 201710L 变为 202311L。此次修订将 bool、true 和 false 变为关键字,因此它们不再依赖头文件中的宏。常量 true、false 和 nullptr 现在是语言关键字,其中 nullptr 为语言提供了有类型的空指针常量。新增了 typeof 运算符,修订还包括数字分隔符、二进制整数常量,以及可用于声明编译期常量的 constexpr。库方面的新增内容包括旨在擦除敏感数据且不被优化掉的 memset_explicit,以及通过 <stdckdint.h> 提供的带检查的整数运算。C23 还要求,当 n*s 会溢出时,calloc 返回空指针。

C23 还删除了语言早期章节曾容忍的一些遗留特性。三字符组被移除,省略参数信息的 K&R 风格函数声明与定义也被移除。有符号整数被要求使用二进制补码表示。这些删除是标准有意收窄旧代码可依赖内容的少数几处之一,也是对向后兼容这一总体模式的一个显著例外。

工作组 WG14 仍在继续开发下一次修订,非正式名称为 C2Y。早期工作草案出现在 2024 年 1 月 WG14 会议之后,草案以编号文档的形式发布,供任何人阅读。各来源对当前状态的说法不一:一篇文章称草案 N3685 于 2025 年 9 月发布,另一篇则称草案 N3886 于 2026 年 5 月出现,并给出计划于 2029 年末发布、名称为 C29 的时间表。部分改动已在实际使用的编译器中实现;例如,一些改动已开始被集成到流行的 C/C++ 编译器中:GCC 15 和 Clang 19。拟议的改动包括八进制字面量前缀 0o 和 0O,它与 C23 新增的二进制前缀相对应。

编译器与工具链

目前有几款编译器在 C 语言开发中占据主导地位。GCC(GNU 编译器套件)是一款自由软件,支持众多目标平台,也是大多数 Linux 系统的默认选择。LLVM 的 Clang 前端基于 LLVM 后端,同样是开源的,设计上可以近乎直接替代 GCC,能够接受其大部分命令行参数和常见扩展。它还附带静态分析器及相关的代码分析工具。微软的编译器是 Visual Studio 的组成部分,是面向 Windows 平台开发软件的标准选择。除此之外,芯片厂商也会提供针对其 处理器 优化的编译器,尤其常见于嵌入式芯片和高性能硬件领域,在这些场景中,厂商提供的代码生成和库函数可能比可移植性更重要。

各编译器对新语言标准的支持差异很大,因此开发者通常会通过编译选项明确指定所用的方言。GCC 支持从 C90 到 C23 的各个方言,以及正在制定中的 C2y,可通过 -std= 选项进行选择。其 C23 模式自 GCC 15 起成为默认模式,而 GNU 变体 gnu23 则会在标准特性之外启用 GNU 扩展。Clang 的推进则更为渐进:其状态页面将 C23 支持描述为“部分支持”,从 18 版本起接受 -std=c23,从 19 版本起接受 -std=c2y。由于支持范围参差不齐,如果项目使用了较新的关键字或库特性,在较旧或配置不同的工具链上可能无法编译。因此,许多项目会在构建配置中固定标准版本,并使用多个编译器进行测试。

交叉编译器在嵌入式开发中至关重要。交叉编译器运行在一台机器上(通常是台式电脑),却为另一种目标架构生成机器码,例如小型 ARM 或 RISC-V 微控制器。这类目标平台的工具链名称中通常带有目标三元组,例如 arm-none-eabi-gcc,表明它生成的是无操作系统的裸机 ARM 代码。在这种环境下,标准 C 库可能只是精简实现,启动代码和链接脚本需要由开发者或芯片厂商提供,程序通常是烧录到设备中运行,而不是在构建主机上执行。测试也更加困难,因为开发者可能需要借助模拟器或硬件调试器来运行生成的二进制文件。

编译器周围还有一系列大多数程序员每天都会用到的工具。Make 和 CMake 等构建系统用于描述源文件如何被编译、链接和测试,并处理大型项目中变得复杂的依赖关系追踪。GDB 和 LLDB 等调试器可以检查内存和执行状态,对于本文其他部分讨论的指针问题和未定义行为问题尤其有价值。集成开发环境将编辑器、调试器和项目管理功能整合在一起,而语言服务器则在许多编辑器中提供代码补全、导航和诊断等功能。此外,相关工具链中通常还集成了sanitizer(运行时检查工具)和静态分析器,持续集成系统也常会使用多个编译器和不同编译选项运行相同的构建,以便及早发现可移植性问题。

在操作系统与系统编程中的角色

C 在系统软件中的地位始于 Unix。1973 年,贝尔实验室的 Ken Thompson 和 Dennis Ritchie 用 C 重写了 Unix 内核,此前该内核是在 PDP-11 上用汇编语言编写的。Ritchie 后来回忆说,到 1973 年初,这门语言和编译器已经足够成熟,使得当年夏天的重写成为可能。内核约有 11,000 行代码,据报道其中约 10,000 行被转换为 C。结果是一个内核核心可以迁移到其他硬件上的操作系统,这在当时并不常见,常被视为 Unix 可移植性的起源。

在 Unix 家族中,这一模式延续了下来。1978 年第七版之后,该系统分裂为 System V 和 BSD 两条发展路线,二者都保留 C 作为主要实现语言。Linux 内核是最著名的现代例子。Linus Torvalds 于 1991 年使用 C 和汇编语言启动了该项目,如今内核采用 ISO C11 的 GNU 方言编写。通常使用 gcc 并加上 -std=gnu11 选项进行编译,也支持 clang。内核依赖 GNU 扩展(如属性),并且常规性地使用这些扩展。少量部分仍是针对特定架构的汇编代码,自 2023 年底起内核开始接纳 Rust 编写的驱动程序,但 C 仍占代码的绝大部分。Linux 项目也始终拒绝在内核开发中使用 C++。

商业系统走的是类似的道路,但公开记录不够统一。有些资料称 Windows NT 内核是用 C 编写的,另一些讨论则认为某些 Windows 内核(包括 Windows XP 所用的内核)是用 C++ 编写的。专有内核的确切语言构成很少有详细记载,因此关于它们的说法应谨慎看待。可以确定的是,大多数主流操作系统中的内核代码、设备驱动程序和核心系统服务都依赖 C,因为对直接硬件访问、可预测的性能以及对内存的精细控制的需求,使这门语言始终处于这项工作的核心。软件栈的其他一些部分,例如操作系统接口和底层库,也出于同样的原因使用 C 编写。

C 相对稳定的二进制接口和极少的运行时要求,也使其成为语言之间通信的常用媒介。由于 C 函数可以通过明确定义的调用约定和数据布局被调用,其他语言便能通过同一机制对外提供自身的功能。Python 就是很好的例子。Python 的参考实现 CPython 以 C 编写,其核心部分,包括解析器、字节码编译器和主求值循环,都是用 C 实现的。CPython 还提供了 C 语言应用程序编程接口,使编译好的扩展模块能够被加载并在 Python 代码中调用。标准库中许多对性能敏感的部分,例如 math 模块,都以 C 共享库的形式实现。其他解释型语言的运行时中也有类似的安排,这也是 C 常被描述为编程语言“公分母”的原因。

使 C 在系统工作中表现出色的同一些特性,也解释了为何其程序员必须承担重大责任。内存的分配与释放需要手动处理,指针运算又可以直接访问内存,因此内核开发者必须自行管理这些细节。内核项目接受这一负担,以换取对内存布局、时序和资源使用的控制。其对安全性的影响将在单独的章节中讨论。

嵌入式、科学与高性能计算

嵌入式系统

C 语言是嵌入式系统中占主导地位的语言。这类基于微处理器的设备,其软件常被称为固件。C 语言的吸引力来自三个特性。编译后的代码体积紧凑,能放进微控制器有限的内存中。程序员可以直接访问硬件:指向固定地址的指针,若加上 volatile 限定符,便是读写设备寄存器的常用方式。此外,该语言不需要垃圾回收器,也不依赖庞大的运行时,因此可以根据生成的机器码估算某条语句的开销。当设备必须在固定期限内作出响应时,这种可预测性十分重要。

嵌入式开发也推动了语言向核心标准未涵盖的方向发展。各厂商长期以来都发布自家编译器,并提供针对中断处理程序、特殊内存区域和定点运算的扩展。为了标准化其中一些嵌入式特性,ISO/IEC 发布了技术报告 TR 18037。由于许多小型目标平台缺乏通用操作系统,开发者通常使用在主机 PC 上运行、为目标芯片生成代码的交叉编译器。

安全关键行业与 MISRA C

在汽车、航空航天、铁路、医疗和工业控制系统中,C 代码通常须遵循由编码指南定义的受限子集。其中最著名的是 MISRA C,由汽车工业软件可靠性协会(Motor Industry Software Reliability Association)发布,现行版本为 MISRA C:2023。这些指南旨在消除或限制可能导致未定义行为的结构,例如某些指针转换、隐式类型转换以及不寻常的控制流。其中许多规则是"可判定的",这意味着静态分析工具能够机械地判断某条规则是否被违反。

这类标准常被各行业的功能安全法规所引用,例如道路车辆的 ISO 26262 和机载软件的 DO-178C。遵守编码指南本身并不能证明系统是安全的。静态分析工具并未获得 MISRA 的官方认证,工程师对于任何偏离规则的做法,仍需进行审查、测试并提供书面论证。

科学计算与数值计算

物理、天气预报和工程仿真领域的大型数值程序通常使用 C 或 Fortran 编写,许多程序两者兼用。Fortran 历来因适合以数组为主的数值计算内核而受到青睐,部分原因是其关于别名的规则让编译器有更大的优化空间。C 的指针可能指向重叠的内存,这会限制优化,因此 C99 引入了 restrict 限定符,让程序员可以保证指针之间不存在别名。实践中,C 常被用作接口层:稳定的调用约定使得用其他语言编写的求解器可以从 C 中调用,而 C 的 API 也是访问各类库和并行框架的常用途径。

数值正确性也带来了额外的困难。浮点加法不满足结合律,因此重新排列运算的优化编译器可能改变结果;激进的"快速数学"选项则可能破坏数值稳定算法所依赖的假设。因此,科学计算的 C 代码需要针对已知解进行仔细测试,并注意浮点运算和数值分析的基本原理。

高性能计算

该语言的性能模型是它在高性能计算中常用的主要原因。程序员可以控制内存布局,因此能够为缓存局部性安排数据,在结构体数组与数组结构体之间作出选择,并为向量指令对齐缓冲区。由于生成的代码紧密对应源代码,性能调优在很大程度上只是修改代码,而不必与运行时对抗。

并行性通常通过库和语言扩展来加入。跨越超级计算机各节点运行的消息传递接口(如 MPI),通常通过 C 绑定提供。共享内存并行通常用 OpenMP 指令或 POSIX 线程来表达,C11 标准还增加了自己的线程和原子操作设施。这些模型带来数据竞争和死锁的风险,而一次数据竞争就可能产生难以复现的未定义行为。图形处理器(GPU)等加速器通过独立的工具链编程,但启动其内核的主机端代码往往是用 C 编写的。因此,仔细管理内存与并行性仍然是程序员的核心职责,也是抵消 C 语言效率优势的主要代价。

对其他语言的影响

C对后来语言的影响主要有三种形式:直接扩展、借用语法,以及让不同语言编写的代码能够协同工作的调用约定。最直接的后代是C++。1979年,本贾尼·斯特劳斯特鲁普在贝尔实验室开始了"带类的C"(C with Classes)项目,这是C语言的超集,增加了类、继承和更严格的类型检查。它的第一个编译器Cfront并不直接生成机器码,而是先将C++转换为C,再由现有的C编译器生成可执行文件。这种设计在多年间塑造了该语言:直到1990年前后,Cfront一直定义着C++,而C++中许多较为晦涩的边界情况都与这种C++到C的转译方式有关。转译方式最终变得难以维持,Cfront于1993年被弃用,原因是很难再把新特性(即C++异常)整合进去。

Objective-C走了另一条路。布拉德·考克斯和汤姆·洛夫起初在国际电话电报公司(ITT)工作,后来创办了自己的公司。由于与C的向后兼容性对其电信工程环境至关重要,他们选择以C为基础。考克斯早期的工具是一个预处理器,可将类似Smalltalk的语法翻译为C,他于1983年以"面向对象预编译器"(Object-Oriented Precompiler)之名发表了这一工具。后来他用新的编译器重写了它,并将成果命名为Objective-C。该语言为C添加了Smalltalk风格的消息传递机制。其动态运行时允许程序运行期间改变行为,这是它与C++的主要区别之一。它后来的发展与NeXT密不可分:直到1988年NeXT将其用于NeXTSTEP操作系统后,该语言才真正流行起来。此后苹果收购了NeXT,使Objective-C成为Mac OS X和iOS开发的核心。

许多并非基于C构建的语言都采用了它的表层语法。Java、C#和JavaScript使用花括号界定代码块,使用分号结束语句,采用同一系列的运算符,并有熟悉的<code>if</code>、<code>for</code>、<code>while</code>和<code>switch</code>结构。这种相似主要体现在语法层面。这些语言通常去掉了手动指针运算和手动释放内存的机制,代之以垃圾回收,而Java还借助虚拟机执行字节码。程序员阅读一段Java方法时可以立即看懂其控制流,但其背后的内存和类型规则与C并不相同。不过,运算符优先级和整数转换规则往往原样保留,因此C的一些陷阱仍存在于其后代语言之中。

较新的系统级语言则体现了对C的另一种回应。Go由谷歌开发,于2009年正式发布,保留了类似C的语法和编译后的性能,同时加入了垃圾回收,并内置了轻量级并发原语。Rust最初是格雷登·霍尔的个人项目,后来获得Mozilla的资助,它用编译期强制执行的所有权和借用规则,取代了C中由程序员手动管理内存的方式。两者都瞄准了与C相同的高性能领域,同时也回应了内存安全和并发问题。正是这些问题使得大规模C代码难以写对。

最后,C成为了语言之间的通用接口。由于其调用约定和数据布局简单且稳定,几乎所有其他语言都能通过外部函数接口调用C函数,而大多数主流平台也将其系统服务以类C的API形式对外提供。Python的扩展模块、Java的本地接口,以及Ruby、Lua等众多语言所使用的绑定机制,都依赖这种兼容性。因此,其他语言往往先接入C库,然后才与彼此互通,这也是C在跨多种语言的软件中始终是通用基础的重要原因。

内存安全、安全性与批评

C 软件中绝大多数严重的安全问题都源于同一小类内存错误。内存安全违规包括缓冲区溢出、释放后使用错误,以及会破坏分配所用大小计算的整数溢出。由于 C 将指针运算、对象生命周期和边界检查在很大程度上交给程序员负责,这些错误可以在没有任何警告的情况下通过编译,往往要等攻击者提供恰当的输入时才会暴露。Chromium 安全团队基于对 2015 年以来 912 个高危或严重级别漏洞的分析指出,其重大安全漏洞中约 70% 属于内存安全问题,其中约一半是释放后使用漏洞。微软也给出了类似的数字,称其每年分配的 CVE 中大约 70% 涉及内存安全。苹果对 iOS 12 的统计则较低,约为其修复的 CVE 中的 66%,这表明该比例因代码库而异,但在大型 C 和 C++ 项目中,这一规律是一致的。

关于这些数字的争论主要围绕成因与补救措施展开。内存安全语言的支持者认为,这类缺陷具有结构性:当语言赋予程序员直接的指针控制权、又不做运行时边界检查时,细心编码只能减少而无法在大型代码库中彻底消除此类错误。这一立场也得到了政府机构的推动。美国国家安全局 2022 年的信息表《软件内存安全》建议在条件允许时转向内存安全语言;美国网络安全和基础设施安全局 2023 年的报告《内存安全路线图的理由》则敦促各机构规划迁移。2025 年 6 月,CISA 与 NSA 联合发布了关于采用内存安全语言的指南;CISA 的产品安全指南也将使用内存不安全语言列为不良实践。在系统级工作中,Rust 是最常被提及的实用替代语言,因为它在编译期强制执行所有权与借用规则,且无需垃圾回收器。

C 的捍卫者提出了几点看法。C 仍是操作系统内核、设备驱动程序以及许多新语言所依赖的基础设施的实现语言,而重写本身既昂贵又有风险。他们还指出,使 C 具有吸引力的性能与可预测性,恰恰也是它在嵌入式和实时场景中难以被取代的原因。许多捍卫者接受这些统计数据,但强调规范的开发流程、代码审查、测试和工具链可以降低缺陷率,而且迁移到更安全的语言并不能消除逻辑错误或其他类别的漏洞,例如注入类缺陷。这一立场塑造了当前 C 开发的大部分实践,即依靠多层防御,而非依赖单一的修复手段。

这些防御层正是实践中最常被推荐的缓解措施。边界检查无论是由语言库实现还是由编译器插入,都能阻止部分越界访问,但会牺牲一定的速度和体积。地址消毒器(address sanitizer)及类似的运行时插桩工具可以在测试阶段发现许多错误,不过它们会带来额外开销,也无法覆盖所有执行路径。加固编译选项以及栈保护、控制流完整性等漏洞利用缓解功能,即使缺陷存在,也能增加成功利用的难度。静态分析以及针对特别关键组件的形式化验证,可以在部署前发现或排除某些缺陷。内存标记等硬件特性也被提议用于在运行时捕获部分错误。这些措施各自都能降低风险,但没有一项能从根本上消除问题本身。

社区尚未就更广泛的问题达成共识。一派倾向于渐进式迁移,即将新组件和高风险部分迁移到内存安全语言,并通过互操作性让其余部分继续运行。另一派则主张改进 C 语言本身,包括提供更安全的库接口、为未定义行为制定更清晰的规则以及完善工具,从而在不重写的前提下提高现有代码的安全性。从 C 到 Rust 的自动翻译已作为研究方向获得资助,但其输出仍需仔细审查,而大规模重写的性能与维护成本也仍有争议。结局将取决于必须延续的遗留 C 代码有多少,以及新的安全机制能否在那些最难取代 C 的环境中证明其实用性。

教学、实践与社区

C 仍是大学系统编程、操作系统和计算机组成课程的标准内容。教师看重它,是因为它暴露了高级语言通常隐藏的细节:变量如何在内存中占用字节,整数溢出时如何回绕,指针如何保存地址,以及同一位模式如何依据其类型被解释。学生在课程项目中追踪悬空指针或越界写入时,往往会获得一种关于机器的具体认识,这是垃圾回收语言无法提供的。因此,教材通常会把 C 与数据表示和内存布局的练习配套,许多课程也把 C 允许的那些错误当作有意设计的教学内容,而不是偶然的失误。

编码风格是实践中分歧较大的另一方面。各项目对大括号放在哪里、缩进多少、使用制表符还是空格意见不一。K&R 风格以 Kernighan 和 Ritchie 的著作命名,将左大括号放在它所属语句的同一行。GNU 风格用于 GNU 项目,大括号单独占一行,并与代码块一同缩进,缩进步长为两个空格。Linux 内核风格使用显示宽度为八列的制表符,普通代码块仍采用 K&R 的大括号位置,但函数的左大括号单独放在一行。其编码指南很简短,坚持每行一条语句,多语句代码块要加大括号,其明确目标是可读性和可维护性,而不仅仅是美观。GNU indent 工具可以通过命令行选项在其中几种布局之间转换代码,这表明许多分歧其实是机械性的。许多组织会在项目级配置文件中统一采用一种风格,使差异比较和代码审查能专注于逻辑本身。

该语言本身通过开放的、委员会式的流程进行维护。ISO/IEC 工作组 WG14 负责制定标准,并将其工作草案、理据文档和缺陷报告以编号文件的形式发布,可免费下载。这种开放性使编译器开发者、库作者和普通程序员都能跟踪提案的变化,也使各国标准机构和个人专家能够在正式通过前对草案提出意见。然而,已出版的标准正文由 ISO 成员机构出售,因此可免费获取的草案与最终文件并不完全相同。需要特定修订版精确措辞的读者,必须注意自己引用的是哪个版本。关于提案功能的讨论,例如针对下一修订版(非正式称为 C2Y)的讨论,也都在这一公开记录中进行。

数十年来,C 的社区一直依靠书籍、参考网站、邮件列表和开源项目维持。Kernighan 和 Ritchie 的著作至今仍是参考基准,GCC 和 Clang 等开源编译器以及 Linux 内核等大型公共代码库,为学习者提供了可以阅读和修改的真实程序。由于主要系统软件的源代码公开可见,学生可以考察经验丰富的开发者如何处理错误路径、内存所有权和可移植性问题。这套易于获取的代码是 C 持续成为进入系统领域人士常用入门语言的原因之一,尽管许多较新的语言也在争夺同一批受众。共同的惯例也有助于程序员在不同项目之间切换,因为经验丰富的 C 开发者通常只需少量熟悉就能读懂陌生代码。

未来方向与未决问题

该标准的下一次修订在非正式场合被称为 C2Y,不过某百科全书摘要将其标为 C29,并把计划发布时间定在 2029 年底。ISO 项目页面列出该标准尚未发布,其工作草案日期为 2026 年 5 月。在正式通过之前,一些改动已经出现在主流工具链中:GCC 15 和 Clang 19 已开始实现草案中的部分特性。由于草案仍在变动,其特性清单应视为暂定的。

委员会近期的工作大多是渐进式的,而非雄心勃勃。2024 年和 2025 年提交给 WG14 的提案包括:澄清复数运算符的措辞,修正十进制浮点数的术语,为 HUGE_VAL 宏补充缺失的后缀,以及定义常量求值的规则。另有一些提案引入了新的语言结构,例如用于字符转换的可重启函数、作为技术规范开发的 defer 机制,以及具名循环。对未定义行为的澄清是一个反复出现的主题:委员会成员屡次追问,某种措辞是否在本意应当予以定义的情况下仍留下了未定义的空间。每一次澄清都会改变符合标准的编译器可以做出哪些假设。

最具争议的问题涉及不安全的库函数。C11 附录 K 是可选的边界检查接口,它以 2007 年的一份技术报告为基础,旨在为 strcpy 等函数提供 strcpy_s 之类的替代品。然而其采用率很低,WG14 的一篇文件指出,委员会在修复附录 K 与删除附录 K 之间的意见大致各半。主流编译器和库对实现它并不积极,而基于 MISRA 的静态分析规则也不鼓励使用。不过,其采用情况并非一概否定:2025 年有一个向 LLVM C 库提交的拉取请求添加了附录 K 的函数,这表明仍有实现者认为这些函数有价值。2024 年关于更好的字符串类型的讨论也已进入委员会,但与会者认为时机尚早,无法进行表决,部分原因在于该提案考虑了新标准最终可能提供的"胖指针"。

形式化验证是一个活跃的研究领域,并已取得实际成果。CompCert 是一款面向 C 语言大部分子集的编译器,其已通过机械化证明,能够在翻译过程中保持程序的语义。Verified Software Toolchain 将 CompCert 与分离逻辑程序逻辑相结合,并已被用于验证 OpenSSL 中的 SHA-256 和 HMAC 实现。seL4 微内核以 C 语言编写,并在 Isabelle/HOL 中完成了形式化,是经过验证的系统中的一个杰出例子,其机器码编译则通过翻译验证进行了检查。Frama-C 等分析平台直接以 C 源代码为目标。seL4 的工作同时也揭示了一个局限:其保证是针对 C 源代码陈述的,且依赖于将其翻译为机器码的编译器的正确性,这正是经过验证的编译如此重要的原因。

尚未解决的问题大多涉及权衡取舍。如何在不破坏大量依赖现有语义的代码的前提下,通过新的接口、更严格的未定义行为定义或工具支持,使 C 语言实质上变得更安全,目前尚不清楚。同样悬而未决的是,在长期维护的软件中,C 将如何与较新的系统级语言共存:重写、互操作或加固现有代码,各自所带来的成本都难以事先估算。

参考来源

  1. The Development of the C Languageߤ Dennis M. Ritchienokia.com
  2. The C Programming Languageen.wikipedia.org
  3. C - Engineering and Technology History Wikiethw.org
  4. Evolution of Ccatb.org
  5. History of C Programming Languagelivinginternet.com
  6. c-historymath.pku.edu.cn
  7. ANSI C - C89 Standard - GeeksforGeeksgeeksforgeeks.org
  8. ANSI C Standards (C89, C99, C11, C17 & C23): Complete Guidemokshelearning.blogspot.com
  9. C Programming Language Version Historydeveloperinsider.co
  10. C89 - C - WikiChipen.wikichip.org
  11. C - Standards (ANSI, ISO, C99, C11, C17)tutorialspoint.com
  12. What is the difference between K&R and ANSI/ISO (C89) versions of the C programming language? - Quoraquora.com
  13. ANSI Cen.wikipedia.org
  14. What’s The Difference Between Celectronicdesign.com
  15. Implicit conversions - cppreference.comen.cppreference.com
  16. Arrays and Pointerslysator.liu.se
  17. Sizeofen.wikipedia.org
  18. Understanding C/C++ Strict Aliasingdbp-consulting.com
  19. Effective types and aliasinggustedt.wordpress.com
  20. Type-Based Alias Analysis in Cstefansf.de
  21. wiki.sei.cmu.eduwiki.sei.cmu.edu
  22. What is the Strict Aliasing Rule and Why do we care?gist.github.com
  23. Unspecified behavioren.wikipedia.org
  24. Undefined behavior - cppreference.comen.cppreference.com
  25. Integer Overflow Basics - Autoconfgnu.org
  26. Isolating Compiler Bugs by Generating Effective Witness Programs with Large Language Modelsarxiv.org
  27. MSC15-C. Do not depend on undefined behavior - SEI CERT C Coding Standard - Confluencewiki.sei.cmu.edu
  28. [Date Prev][Date Next][Thread Prev][Thread Next]lua-users.org
  29. Towards Optimization-Safe Systems: Analyzing the Impact of Undefined Behaviorpeople.csail.mit.edu
  30. Undefined Behavior: What Happened to My Code? - Peoplepeople.csail.mit.edu
  31. ACM: Digital Library: Communications of the ACMdl.acm.org
  32. Undefined Behavior in C and C++: An Experiment With Desktop Use Casesarxiv.org
  33. Question 11.33c-faq.com
  34. Undefined, Unspecified, and Implementation-Defined Behavior in C and C++: What They Are and Key Differences Explained — codegenes.netcodegenes.net
  35. Implementation-Defined vs Unspecified Behavior in C - csalivity.github.io
  36. undefined and unspecified behavior - Post.Byespost.bytes.com
  37. Programming in C and C++ Lecture 10: Undefined Behaviourcl.cam.ac.uk
  38. C Code Compilation Step by Stepalexanderobregon.substack.com
  39. Phases of translationlearn.microsoft.com
  40. The Four Stages of Compiling a C Programcalleluks.com
  41. Include guarden.wikipedia.org
  42. Include Guards in C: #ifndef, #define and #pragma oncemycplus.com
  43. Comprehensive Guide to Macros in C: Preprocessor Directives and Best Practicestech-champion.com
  44. C standard libraryen.wikipedia.org
  45. Using Standard C Headers · C Library Conventions · · Program Startup and Terminationscp.byu.edu
  46. C programming/Standard libraries - Wikibooks, open books for an open worlden.wikibooks.org
  47. github.comgithub.com
  48. C23 (C standard revision)en.wikipedia.org
  49. Catch-23: The New C Standard Sets the World on Fire - ACM Queuequeue.acm.org
  50. C2Y (C standard revision)en.wikipedia.org
  51. C29 (C standard revision)en.wikipedia.org
  52. C23 - cppreference.comen.cppreference.com
  53. The Standard - C - iso-9899.infoiso-9899.info
  54. C23 (C standard revision) - Wikiwandwikiwand.com
  55. Clang - C Programming Language Statusclang.llvm.org
  56. C Standards Support in GCC - GNU Projectgcc.gnu.org
  57. Clangen.wikipedia.org
  58. GCC Preparing To Set C23 "GNU23" As Default C Language Version - Phoronixphoronix.com
  59. History of Unixibgwww.colorado.edu
  60. History of UNIX - Part Ituhs.org
  61. Programming Language — The Linux Kernel documentationdocs.kernel.org
  62. What Language Is Linux Kernel Written In? C, Rust & Moreunanswered.io
  63. Understanding the Linux Kernel: The Core of an Operating Systemmedium.com
  64. Dennis Ritchie (1941-2011): C Language, UNIX Co-Creator — IT Historyhistory.itlibra.com
  65. Cornell Virtual Workshop > Python for High Performance > Overview > CPython and the Python/C APIcvw.cac.cornell.edu
  66. What is Python written in? CPython: written in Clanguagelineage.org
  67. MISRA C/C++ Code Checking - Parasoftparasoft.com
  68. MISRA C & MISRA C++perforce.com
  69. MISRA C - Guidelines for the use of the C language in critical systemsquality.arc42.org
  70. Introduction to Embedded C - Advanced Computer Engineeringuniofgreenwich.github.io
  71. Embedded Systems/C Programming - Wikibooks, open books for an open worlden.wikibooks.org
  72. Why C Language Is Preferred in Embedded Systemiies.in
  73. History of C++ - C++ Informationcplusplus.com
  74. Cfronten.wikipedia.org
  75. Cfronten-academic.com
  76. Objective-Cen-academic.com
  77. Objective-Cen.wikipedia.org
  78. Objective-Csimple.wikipedia.org
  79. Bjarne Stroustrup's FAQstroustrup.com
  80. A Short History of Objective-C. When the App Store debuted in 2008, all…medium.com
  81. Memory safetychromium.org
  82. Conquering Memory Safety Vulnerabilities in C/C++.trust-in-soft.com
  83. What science can tell us about C and C++'s security · Alex Gaynoralexgaynor.net
  84. Buying down risk: Memory safety - Atlantic Councilatlanticcouncil.org
  85. Memory Safety and the C/C++ CVE Crisis - DEV Communitydev.to
  86. Memory Safety and the C/C++ CVE Crisishavenmessenger.com
  87. The Case for Memory Safe Roadmapscisa.gov
  88. Memory-Unsafe Code Is a Liabilitycorrode.dev
  89. Fix It or Face the Consequences: CISA's Memory-Safe Musterdevopsdigest.com
  90. A Comparative Study of Fuzzers and Static Analysis Tools for Finding Memory Unsafety in C and C++arxiv.org
  91. Protecting against invalid memory referencesimage-ppubs.uspto.gov
  92. Linux kernel coding style — The Linux Kernel documentationdocs.kernel.org
  93. GNU Coding Standardsgnu.org
  94. Indent Format C Code Edition 2.2.12, for Indent Version 2.2.12 18 April 2021gnu.org
  95. C Coding Style - GNOME Developer Documentationdeveloper.gnome.org
  96. Clean up your code with Indentallthingsopen.org
  97. ISO/IEC JTC1/SC22/WG14 - C: Approved standardsopen-std.org
  98. WG 14 Document logopen-std.org
  99. Project status and milestonesopen-std.org
  100. Final Minutes for 10 - 14 June, 2024 MEETING OF ISO/IEC JTC 1/SC 22/WG 14open-std.org
  101. Minutes for Sept 30 - Oct 4, 2024 MEETING OF ISO/IEC JTC 1/SC 22/WG 14open-std.org
  102. Standard C and C++ Proposalsthephd.dev
  103. Bounds-checking Interfacesopen-std.org
  104. C11 (C standard revision)en.wikipedia.org
  105. github.comgithub.com
  106. V2645. MISRA. The language features specified in Annex K should not be used.pvs-studio.com
  107. STR07 C.+Use+the+bounds checking+interfaces+for+string+manipulationwiki.sei.cmu.edu
  108. Mechanized semanticsarxiv.org
  109. TSts.data61.csiro.au
  110. Verified VCG and Verified Compiler for Dafnyarxiv.org
  111. GitHub - ElNiak/awesome-formal-verification: Welcome to the ultimate list of resources for formal verification techniques and tools. This repository aims to provide an organized collection of high-quality resources to help professionals, researchers, and enthusiasts stay updated and advance their knowledge in the field. · GitHubgithub.com