为什么 curl 用 c 语言开发
对我来说,这是个有些常见的问题:我们如何在curl中编写C语言,使其在千亿安装时安全可靠?我们会采取一些预防措施,做出一些决定。没有灵丹妙药,只有指导原则。我想你可以在下文中看到,它们既不奇怪,也不令人吃惊。
curl中的 “c ”并不代表也从未代表C编程语言,它代表的是客户端。
免责声明
这篇文章绝不意味着我们偶尔不会合并与安全相关的错误。我们会。我们是人类。我们会犯错。然后我们会修复它们。
测试
我们尽可能多地编写测试。经常在代码上运行所有静态代码分析工具。我们不停地在代码上运行模糊器。
C 语言并非内存安全
我们当然无法避免与内存相关的错误、失误或漏洞。迄今为止,我们发现约 40% 的安全漏洞是使用 C 语言而非内存安全语言直接造成的。不过,这个数字要比通常所说的 60%-70% 低得多,因为它们都是由少数几个大公司和项目造成的。这究竟是因为计算方法不同,还是因为我们实际遇到的 C 语言问题较少,我也说不清楚。
在过去的五年里,我们没有收到过发现关键漏洞的报告,其中只有两个被评为严重程度较高。其余的(60 个左右)都是严重性低或中等。
我们目前有近 180,000 行 C89 生产代码(不包括空白行)。我们坚持使用 C89,以获得尽可能广泛的可移植性,因为我们相信持续不断的迭代和打磨,绝不重写。
可读性
代码应易于阅读。应该清晰明了。不要将代码隐藏在巧妙的结构、花哨的宏或重载之下。易读的代码便于审查、调试和扩展。
小函数比长函数更容易阅读和理解,因此更可取。
代码应该读起来像一个人写的一样。所有代码都应该有一致和统一的风格,因为这有助于我们更好地阅读代码。错误或不一致的代码风格就是错误。我们会修复所有发现的错误。
我们拥有验证基本代码风格合规性的工具。
狭窄的代码和简短的名称
代码应写得狭窄。阅读长行对眼睛不好,因此我们严格规定行长不得超过 80 列。我们使用双空格缩进,以便在列数限制成为问题之前,仍可进行一定程度的缩进。如果缩进级别成为一个问题,也许应该把它拆分成几个子函数来处理?
同样相关的是:(尤其是本地)标识符和名称应该简短。名称过长会造成阅读困难,尤其是在有多个名称相似的情况下。更不用说在一定程度的缩进后,80 列内就很难容纳这些名称了。
现在很多人会开玩笑说宽屏什么的,但这里的关键是可读性。更宽的代码更难阅读。就是这样。问题可能是在哪里划定界限,这是每个项目都要讨论的问题。
无警告
虽然这对每个人来说都很自然,但在我们执行的220多个CI任务中,我们在编译所有curl代码时都不会出现任何编译器警告。我们在编译curl时,会使用我们所使用的编译器中所有最挑剔的编译器选项,并对出现的所有警告进行静音处理。我们将每一个编译器警告都视为错误。
避免使用 “坏 ”函数
有些C函数因为缺乏边界控制或本地状态而非常糟糕,我们会避免使用这些函数(gets、sprintf、strcat、strtok、localtime等)。
有些 C 函数在其他方面也很复杂。它们的功能过于开放,或者所做的事情往往会导致问题,或者就是纯粹的错误;它们很容易让我们犯错误。出于这些原因,我们避免使用 sscanf 和 strncpy。
我们拥有禁止在代码中使用这些函数的工具。如果尝试在拉取请求中使用这些函数,CI 工作就会变成红色,并提醒作者他们的错误。
缓冲区函数
几年前,我们发现自己在处理不同动态缓冲区的代码中犯了几个错误。我们有太多独立的实现来处理动态增长的内存区域。我们使用一组新的内部帮助函数来统一处理增长的缓冲区,并确保只使用这些函数。这大大减少了对 realloc() 的需求,有助于我们避免与该函数相关的错误。
此外,每个动态缓冲区都有自己的最大大小设置,这也有助于避免错误。在当前的 libcurl 代码中,我们有 80 个不同的动态缓冲区。
解析函数
我提到过我们不喜欢 sscanf。它是一个功能强大的解析函数,但最终解析的结果往往超出用户的预期(例如,即使只接受一个空格,也会解析多个空格),而且它对整数溢出的处理能力很弱(根本不存在)。最后,它还会引导用户不必要地复制解析结果,导致不必要地使用本地堆栈缓冲区或短暂的堆分配。
因此,我们引入了另一套用于字符串解析的辅助函数,随着时间的推移,我们将curl中的所有解析器代码都改用这套函数。它让我们更容易编写严格的解析器,只匹配我们想要匹配的内容,避免额外的复制/分配,并能更好地进行严格的整数溢出和边界检查。
监控内存函数的使用
内存问题通常涉及动态内存分配,然后将数据复制到分配的内存区域。或许,如果分配和复制都正确进行,就不会出现问题,但如果其中任何一个出错,都可能导致问题。因此,我们的目标是尽量减少这种模式。我们更倾向于使用 strdup 和内存复制,在同一调用中分配和复制数据,或者使用辅助函数,在其应用程序接口后面做这些事情。我们在curl仪表盘中每天更新图表,显示curl中内存函数的调用密度。理想情况下,这幅图会随着时间的推移不断下降。

或许还可以补充一点,我们避免了多余的内存分配,尤其是在热路径中。大型下载并不需要比小型下载更多的内存分配。
重复检查乘法
整数溢出是另一个值得关注的问题。每次算术运算都必须确保不会溢出。遗憾的是,这主要还是一项手工劳动,需要人工审核才能发现。
保证 64 位支持
2023 年初,我们放弃了在没有功能性 64 位整数类型的系统上构建 curl 的支持。这简化了大量代码和逻辑。整数溢出的可能性降低了,也不会像过去那样,在一些罕见的构建中,作者意外地认为自己做的是64位运算,而最终可能是32位运算。当然,如果使用了错误的类型,溢出和错误仍有可能发生。
最大字符串长度
为了帮助我们避免字符串错误,尤其是整数溢出,同时也为了避免其他逻辑错误,我们对库中的所有字符串输入进行了全面检查:它们不接受长度超过设定限制的字符串。我们认为,任何长度更长的字符串要么是公然的错误,要么是试图(攻击?对于此类调用,我们会返回错误信息。现在的最大限制是8兆字节,但随着世界和curl的发展,我们可能会在未来调整这一限制。
keep master golden
在任何时候都不允许破坏master。我们只将我们认为干净、良好、运行完美的代码合并到主代码中。这有时仍会失败,但我们会尽最大努力尽快解决。
始终检查错误并采取相应措施
在curl中,我们始终检查错误,并在错误发生时不泄露任何内存的情况下跳出。这包括所有内存操作、I/O、文件操作等等。所有调用。
一些开发者习惯于现代操作系统基本上不会返回错误,但curl在许多环境中运行时,会有不同的行为。此外,系统库不能在出错时退出或中止,它需要让应用程序来决定。
API和ABI
每一个可公开访问的函数和接口,都不得以可能破坏API或ABI的方式进行更改。为此,我们制定了一条严格的规则:公共函数的前缀为 “curl_”,其他函数不得使用该前缀。
每个人都能做到
多亏了人工审核员、大量自动工具和精心设计的广泛测试套件,每个人都可以(尝试)编写curl代码。当然,前提是你懂C语言。无论代码的作者是谁,不被发现的风险都是大致相同的。责任是共同承担的。
继续吧。你能做到的
本文文字及图片出自 WRITING C FOR CURL
你也许感兴趣的:
- 从 160 行代码到 200 亿安装量:Curl 的传奇故事
- 办事不力、沟通无果,cURL作者公开指责微软
- 【译文】C 和 C++ 优先考虑性能而非正确性
- 【译文】您(也许)不需要学习 C 语言
- NVIDIA 安全团队:如果我们停止使用 C 会怎样?
- C 语言不完全类型是什么?有什么用途?
- 望而生畏的C语言在逐渐凋零
- C语言能够被替换吗?
- Google 也要放弃 C/C++?Chrome 代码库中 70% 的安全漏洞是内存问题
- 关于C语言,我讨厌和喜欢的十件事!
> 到目前为止,我们发现大约 40% 的安全漏洞都是使用 C 语言而不是内存安全语言直接造成的。不过,这个数字要比通常所说的 60%-70% 低得多,因为它们都来自一些大公司和项目。
Arch Linux 内部频道曾讨论过这些分类的准确性。我们注意到许多公告都包含 “此错误不属于 C 语言错误。如果我们没有使用 C 语言,它不可能被避免 “的免责声明,但并不清楚其目的是什么,以及如何定义 ”C 语言错误”。
之所以提出这个问题,是因为 CVE-2025-0665 咨询[0]中也有这样的免责声明,它本质上是一个文件描述符级别的双重免责声明。它的影响非常小(更像是 “libcurl 在你的进程中导致不健全,而不是可能被利用到 RCE 中”),但它是 C 语言管理资源的直接结果。Python 中也可能出现这种 bug,但在 Rust 中不太可能发现这种 bug。
如果使用的编程语言不是 C 语言,会出现这种漏洞吗?有可能。使用非 C 语言的编程语言能否避免这一错误?也可以。
[0]: https://curl.se/docs/CVE-2025-0665.html
问题是:如果使用正确的工具和策略,能否在 C 语言中避免此类错误?答案往往也是:可以。
这就是为什么转用其他语言的大部分论据通常都是,即使是专家也不可能避免 C 语言中的此类错误。但我认为,这种说法虽然有一点道理,但也有部分欺骗性。我们不能简单地看 CVE 的数量就得出结论,我们需要将苹果与苹果进行比较,然后我发现现实情况并非如此,例如,如果由于某种原因无法在 C 语言中使用简单的 bug 缓解方法,但这一原因也会首先阻止使用另一种语言,那么以此作为论据就会产生误导。
> 使用正确的工具和策略能否在 C 语言中避免此类错误
“正确的工具和策略 “是非常开放的,几乎是同义反复–如果你没有抓住错误,那么显然你没有使用正确的工具和策略!实际上,工具和策略都有缺陷和局限性,这些缺陷和局限性会把问题变成 “是,但实际上不是”。
C 代码的静态分析有其基本限制,因此有些错误是它无法发现的,而有些非小错误是它无法在不发现误报的情况下发现的。误报会让开发人员无谓地调整原本正确的代码,并导致疲劳,使他们轻视或忽略报告。更可靠的工具需要在运行时捕捉问题,但像双免这样的问题往往只发生在难以测试的罕见代码路径中,而且模糊器也无法触及所有代码。
对任意遗留代码的静态分析是有限的。但是,我并不觉得以一种可以合理排除大多数错误的方式来构造我的代码有什么困难。关于 C 语言误报的讨论很有意思。从某种意义上说,Rust 编译器会抱怨的 99% 的问题在 C 语言中都会被认为是误报。因此,如果想在 C 语言中保证安全性,就不能从这个角度切入。但这与我的观点有关。如果为了让 Rust 编译器满意,可以接受以特定的方式来构造代码,但你却不接受在 C 语言中可能必须以特定的方式来编写代码以避免误报,那么你已经不是在拿苹果和苹果作比较了。
即使你把 C 代码重写成和 Rust 一样的 “形状”,它也不会变得同样容易静态分析。C 语言并不提供相同的保证,因此它也无法从相同的限制中获益。例如,指针不能保证其数据始终初始化,`const`不能使其背后的数据真正不可变,也没有 Send/Sync 来描述线程安全。你根本无法表达一段内存只有一个所有者。每当你需要使用 void* 代替泛型、使用 DIY 标记的联合,以及使用混合来源/所有者的指针时,C 语言的类型系统也会丢失信息。
Rust 会检查你是否遵守了可分析结构,并防止你在每一步都不小心破坏它。而在 C 语言中,编译器不会为此提供任何帮助。除了局部调整外,我还不知道有什么工具可以指导 C 语言的这种结构。如果有足够多的非标准 C 语言扩展(添加借用、具有移动语义的排他性所有权),理论上是可以实现的,但这将非常接近于在 Rust 中重写代码,只不过使用的是一种螺栓固定的语法、一种无法理解它的笨拙编译器,而且 Rust 语言、工具和生态系统的其他部分也没有任何好处。
如果不使用外部工具,就无法获得同样的自动保证。但如果使用现成的工具,并采取良好的组织策略,就能获得几乎相同的结果。我的代码中没有双释放、void 类型不安全或标记联合的问题。我偶尔会出现内存泄露,而工具往往会发现这些问题。当然,我也有可利用的整数溢出问题,但 UBsan 可以轻松、全面地解决这些问题。
> 如果为了让 Rust 编译器高兴,以特定方式构造代码是可以接受的
,我认为这是一种误导性的表述方式。在 Rust 中,有一类错误是有效的 Rust 代码不可能存在的(除非使用了 “unsafe ”关键字),而有效的 C 代码却存在上述错误。区别就在这里:在 Rust 中,编译器会帮你避免一些错误,而在 C 语言中,你必须严格执行规范,确保每次都能以这样的方式构建代码,使上述错误不太可能发生。由此可见,Rust 代码的(与内存相关的)错误会更少。
这并不是苹果与苹果之间的比较,因为 Rust 被设计为内存安全果,而 C 却不是。
问题的关键在于,你不能因为 “警告会让程序员感到厌烦,因为它有误报 “而拒绝将警告作为 C 语言解决方案的一部分,同时又接受 Rust 的借用检查器作为解决方案。
我认为我评论的总体观点仍然有效,因为你和其他参与项目的人都需要遵守纪律,只发布编译时没有警告的二进制文件。在 C 语言中,即使使用 -Werror 也不能完全解决问题,因为没有警告/错误仍然不足以保证内存安全性。
我并不是真的不同意你的观点,但我也提出了一个稍有不同的观点。如果你需要绝对的内存安全,你可以使用 Rust(不使用过 unsafe)
,但你可以通过 C 语言、一点纪律和工具来获得 99% 的内存安全,这也意味着要维护一个超级干净的代码库,并激活许多警告,即使它们会导致误报。我想说的是,这些误报并不能说明这种策略无效或低劣。
你的说法是,它之所以低劣,是因为你只能获得 99% 的安全性,而不是 100% 的安全性。但我们可以质疑,由于 FFI 和不安全,在具有相关规模和复杂性的 Rust 项目中,你是否真的能获得 100% 的安全性。我们还可以质疑,在还有许多其他问题需要注意的情况下,100% 的内存安全性是否真的那么重要,但这是另一个问题。
这篇博文声称他们已经在运行 “所有工具”,能否请您更具体地说明他们缺少哪一个工具?也许避免这种生命周期问题的工具恰好就是 rustc?
rust 是一个可以用来避免终身问题的工具。它不是唯一的工具。也只有当你只限于使用安全的 Rust,而不使用 C 库、不安全的 Rust,或者不直接使用使用整数的 API 时,它才能完美地发挥作用(我假设在这个例子中,Rust 可能有特殊的安全封装,但一般来说,这种语言也无法避免这种错误)。资源管理是模型检查程序可以在 C 语言中验证的问题。无论如何,问题在于我们是否愿意投入更多精力,以及解决方案的折衷效果如何。漏掉这类东西的小风险也可能是完全合理的选择,即便如此,Rust 的支持者也会非理性地提出相反的主张。例如,curl 使用的 C89 当然不是安全性的最佳选择。它是可移植到模糊平台的最佳选择,但这一要求也会排除 Rust 的可能性。
> 它不是唯一的工具
举证责任在你。我问的是具体哪种工具能检测到 CVE-2025-0665,我无意刻薄,但你的回答基本上是非常自信的 “我不知道,但我肯定有人能构建一个”,同时还手忙脚乱地解释用 Rust 编程的安全优势。
在构建 http 客户端库时,不安全的 Rust 和 C FFI 并不是我遇到的真正问题。
我已经提到模型检查器是一种选择。除了 Rust 之外,还有其他内存安全语言。不过,现有工具能否发现现有 C 代码中的错误则是另一个问题。我希望 GCC 的分析器能发现双关,但我还没试过发现这个特殊的 bug(在简单的情况下,它肯定能发现:https://godbolt.org/z/rzr9zT619)。一般来说,这些工具更容易在编写良好的 C 代码中发现错误,但在代码复杂的情况下帮助不大。但无论如何,不编写复杂的代码比选择编程语言更重要。
如果自 1979 年 lint 诞生以来,绝大多数 C 语言开发人员都能真正使用正确的工具和策略,那将大有裨益。
在实践中,人们似乎只有在被迫执行类似于 MISRA 的程序时才会关心安全编程,而自 20 世纪 60 年代以来,安全编程在其他编程语言社区中的相关性可见一斑。
安全编程是 Burroughs 和 Multics 设计的一部分,那么为什么在莫里斯蠕虫病毒发生 40 多年后,十年后设计的系统语言社区的答案却是 “我们没有使用正确的工具和策略”?
我想,这大概就是 Rust 对公司有吸引力的原因。他们可以说:“使用 Rust,在没有高级程序员签字同意的情况下,绝不使用不安全的程序”,这样就能相对确保不会出现缓冲区溢出。在 C 语言中,你会说,你需要使用工具 X、Y、Z 遵循这些准则,然后你就能相对确定不会出现缓冲区溢出。问题是,只有当你关心的只是内存安全问题时,这种说法才是有说服力的。一旦你关心其他形式的安全性/正确性,无论如何你都需要 “使用工具 X、Y、Z 的指南”。而当你有混合代码库时,无论如何你也需要这样做。
尽管C++的fstreams有很多缺点,但它并不容易受到双重释放的影响(作为对@Galanwe的部分回复,它们避免问题的方法是运行时检查)。
在 C++ 中,你也可以将 FD 等价于 std::unique_ptr,就像 Android 中的 unique_fd:https://cs.android.com/android/platform/superproject/main/+/…
这并不能保证问题永远不会发生,就像 Rust 所做的那样,但它确实大大降低了问题发生的可能性。
此外,我认为人们普遍低估了 EBADF 问题的严重性。除了极其特殊的、仅有单线程的情况外,该错误本质上就是内核告诉你发生了堆损坏,但几乎没人会以这种严重程度来对待它
早在 20 世纪 90 年代初,随着 C++ARM 成为第一个标准,使用 C++ 而不是普通 C 有了很多优势。
RAII、流而不是 stdio 模式、编译器为常见类型(字符串、数组……)提供了具有边界检查配置的集合类,….。
> 之所以提出来,是因为 CVE-2025-0665 advisory[0] 中也有这个免责声明,它本质上是一个 double-free,但在文件描述符级别上
,我看不出 rust 如何能防止用同一个 eventfd 调用两次 close()。
Rust 防止在一个文件上调用两次 close() 的方法也是如此。
rust 的标准是,当文件描述符退出作用域时,close() 会被自动调用。我相信你可以选择手动执行,但那是不寻常的编码。
实际上,std::fs::File 中并没有任何 close() 函数: https://doc.rust-lang.org/std/fs/struct.File.html
而且如果有的话(`drop`算是那个函数),它将消耗文件句柄,所以你无法再次调用它。
问题并不在于 C 语言,而是编码实践让人觉得我们还停留在 20 世纪 70 年代。像curl这样的代码库使用的是非常低级的C语言。但 C 语言有函数、有结构、有很多功能,可以让你在更高层次上编写代码,而不是在每条 while 语句中追逐指针。处理指针的代码可以按照人们在其他语言中必须采用的方式进行抽象。
20 世纪 70 年代的贝尔实验室,我建议学习其他地方已经存在的系统编程语言。
指针并不是一个困难的概念。
性能开销如何?我认为很多人编写 C 语言的原因是,他们可以直接控制生成的汇编,从而最大限度地提高性能。
即使是直接控制生成的汇编程序(C 语言不提供这种功能;只有编写汇编程序才提供,而且前提是你不做类似 LTO 的事情)也是不够的。
现代 CPU 会做各种奇怪的事情。汇编指令可以不按顺序执行。你的条件跳转指令可能会在条件的真假已知之前被推测执行。从主存储器中获取的数据可能被重新排序。
更离谱的是,将一个寄存器的内容复制到另一个寄存器往往是不可能的。是的,没错,下面的代码就是这样:
… 在某些现代 CPU 上几乎什么也做不了。有时它所做的只是设置一个内部注释,大意是 “以后对 edx 的引用应改为读/写 eax”,直到该注释被其他操作清除为止。
在编写汇编程序时,你可以完全按照自己的意图来编写,但却发现 CPU 的做法完全不同。你最终还是会得到相同的可观察结果,但任何时间和操作顺序的保证在几十年前就已经不存在了。
> 它们可以直接控制生成的程序集
。从所有 “优化器做了奇怪的事 ”的 bug 就能看出这一点。
> [Rust 和 C++] 鼓励创建一个具有继承性的类型库
你要在这里扩展并明确你的意思,因为 Rust 没有继承性。
无论技术上是否继承,Traits 都鼓励相同的编程风格。
我完全不同意。
这不仅仅是一个技术问题,特质与多重继承并不相同。它们能让你解决同样的问题,但特质不会像多重继承那样带来大量混乱、复杂和令人头疼的问题。
此外,没有任何东西强迫你使用特性。严格来说,在很多情况下使用特性会更好。不过,如果你愿意,也可以继续编写明显重复的 C 风格代码。
然而,我对将 COM 的 OOP 方法映射到 rust 没有任何意见,因为事实证明,根据 CS 类型系统理论,有许多方法可以实现相同的目标。
举个实际的例子,在一个周末将 Raytracing 从 C++ 移植到 Rust,并保持整体架构。
第二个例子,微软为 Rust 绑定 WinRT。
是的,我想说的是,COM 具体来说是非常有限的,以至于可以用 traits 来无缝实现它。但无论如何,争议的焦点在于“[Rust 和 C++] 鼓励创建一个具有继承性的类型库”。
任何至少读过并理解过这两种语言的人都可以发现,即使在 rust 中用 traits 代替继承,事实也并非如此。不仅如此,特质所支持的范围也与继承不同。它们虽然有重叠,但差别并不小。当你真正进入 rust 的代码库时,即使它们大量使用了 traits,最终也会有很大的不同。但同样的,没有任何东西要求你必须使用 traits。你经常能在库中看到它们,因为它们对泛型编程非常有用。但这并不意味着你必须在应用代码中使用它们。
你把继承和类继承混为一谈了。
还有接口继承,Rust 允许用 trait bounds composition 来实现接口继承,现在 trait upcasting 使得接口继承变得更加容易。
在一些宏的帮助下,我们甚至可以通过聚合和委托来实现 COM 风格的多接口实现,从而模拟类继承,就像 ATL 允许使用模板元编程一样。
我从没说过多重继承。
通过模板进行多重继承和 SFINAE 是 C++ 中唯一的非概念特质方式
C++ 中使用单一继承的方式与 Rust 中使用特质的方式完全不同。
该指南感觉与我多年来看到的人们推动编码风格的方向不一致
“标识符应该简短”,而我主要看到的是人们谴责在一个代码库中发现所有东西都是 C 风格缩写(htons、strstr、printf、char_t、_wfopen、fgetws、wwcslen)是多么令人讨厌
更多的冗余是有道理的,如果你看看现代 Curl 代码,它也反映了这一点,新的标识符并不简短
https://github。com/curl/curl/blob/master/lib/vquic/vquic.c
“函数应该简短”,我在这里看到的大多数反馈都非常负面,因为代码库都是按照 Bob 叔叔的简短函数趋势编写的。有人抱怨说,将代码隐藏在 10 层函数调用中毫无用处,即使使用现代编辑器也很乏味
。”代码应该窄”、”我们严格执行 80 列的最大行长”,我想我最近没有看到过这样的说法。我记得我看到过一些关于 80 这个数字的帖子,特别是
你要防止拖累你的眼睛。就我的 IDE 而言,在 1080p 显示器的默认设置下,15 “屏幕的一半可以容纳 100 个字符
如果你为了在更少的屏幕上容纳文本而去掉 20 列,你真的能得到任何好处吗
那么代码的级联效应呢,比如更糟糕的名称、分行……
说到底,这些都是半有趣的问题,但我们都是在建棚子,而这些争论主要是关于棚子应该是什么颜色的
一切都需要平衡。在我看来,“标识符应该简短”、“函数应该简短 ”之类的说法是对其他语言中常见的过长内容(看你的了,Java)的膝盖反应。比如指明类型、指针等的做法。比如 `pWcharInputBuffer` 之类的东西。
在 “*p ”和 “inputPointerToMiddleOfBufferThatFrobnicates ”之间应保持平衡。
>一切都是一种平衡。
非常正确,或者就像我喜欢说的:一切都是一种权衡。
在数十年的编程生涯中,我相当确信我对函数/标识符长度等东西的偏好可以沿着一条阻尼振荡曲线绘制出来。)
> https://github.com/curl/curl/blob/master/lib/vquic/vquic.c
当有人说到与 C 有关的 “短标识符 “时,这正是指这种风格,而不是 C 标准库的神秘风格。
> “代码应该很窄”、”我们严格执行 80 列的最大行长”,我最近好像没见过这种说法。我记得我看到过一些关于 80 列的帖子
公平地说,这在 C 语言中比在其他大多数语言中更容易实现。没有命名间隔、没有泛型等意味着你不需要使用那么多列。
不过我还是不相信。这是一个难题。我宁可设置 120 到 160 列的限制,并使标识符具有应有的描述性。而且无论如何,我都会在所有地方使用前缀名称间距–模糊自动完成可以带来方便。
似乎与 linux 内核编码风格相似:https://www.kernel.org/doc/html/latest/process/coding-style….
对于短标识符,我认为你忽略了一个重要细节。
> 同样相关的是:(尤其是本地)标识符和名称应该简短。
一般认为,标识符越远,描述性就越强。因为你没有那么多的上下文,这也是一个提示:如果你看到一个长的、描述性的名称,它更有可能是全局性的。
而描述性并不意味着长。你仍然需要尽量缩短描述性名称。例如,“timeSinceMidnightInSeconds ”可以缩短为 “secondsSinceMidnight ”而不会丢失信息:秒是一个时间单位,无需重复。
计算机科学中有两个难题:缓存失效、命名和偏差错误。
我不认为好的命名会很快得到解决
> 据我们统计,迄今为止约有 40% 的安全漏洞是我们使用 C 语言而非内存安全语言直接造成的。不过,这个数字要比通常所说的 60%-70% 低得多,因为它们都来自少数几个大公司和项目。我不知道这是因为计算方法不同,还是因为我们的 C 语言问题数量较少。
当我统计时,我得到了大约 55%,这与标准的 2/3 非常接近。
https://blog.timhutt.co.uk/curl-vulnerabilities-rust/
> 代码应该易读。应该清晰明了。不要将代码隐藏在巧妙的结构、花哨的宏或重载之下。
我非常同意这一点。我并不总是想要高度抽象化的代码,一些旨在取代 C 的编程语言要难读得多,话虽如此,Rust 应该是取代 C++,而不是 C,对吧?
感谢您的文章!
我最近经常玩 Zig,虽然它仍处于测试阶段,但我感觉它最有可能成为真正的 C 语言继承者。Rust 给人的感觉就像是他们从 C++ 开始,致力于让错误的编写变得更难;而 Zig 给人的感觉则像是他们从 C 开始,致力于让正确的编写变得更容易。
他们还提出了一些支柱,称之为 Zig 的 “禅”[0],其中前五条中的三条与可读性直接相关。
[0] https://ziglang.org/documentation/0.14.0/#Zen
我曾经认为 rust 就像 C++,但更难写不好,但现在我花了几年时间来写它,我觉得它一点也不像 C++。
rust 是它自己的东西,它没有 C++ 的大量包袱,似乎也不会在短期内达到 C++ 的包袱水平。它是一种更简洁、更清晰、更易于推理的编程语言。
Rust和C++似乎也鼓励类似的编程风格(尤其是较新的C++),Zig和C也是如此。前者鼓励创建一个具有继承性的类型库,在适当的地方使用语法糖(即运算符重载)和函数式风格,而后者则将程序员限制在没有继承性的简单复合类型(手动、显式分派)、明显的语法和命令式风格。
在我看来,两者在生态系统中都有自己的位置,但我真的很期待看到 Zig 的成熟。
我真的很想开始尝试学习 zig,但现在我觉得它还不够完善。当它达到 1.0 时,我可能会更认真地研究它
Zig 对软转换很友好,因为编译器可以编译 C 代码。你可以在 C 代码库中使用 Zig 工具,然后在最有意义的地方慢慢添加 Zig 代码。
我今年还在考虑学习 C 语言,但 Zig 似乎是更有吸引力的选择。我将从头开始新的项目
,所以,是的,我同意对于新的独立项目来说,学习 zig 是个好主意,但(在目前阶段)至少也要准备学习足够的 C 语言,以便使用 C 语言库(返回类型约定、原始枚举、C 风格字符串、不灵活的分配器)。
不过,我认为学习 C 语言仍然有用。对于个人项目来说,你可以使用 Zig,但了解 C 语言仍然有好处。C 语言就像是编程语言中的通用语言,还有一些汇编语言(例如,如果你关心优化的话)。
> Rust 应该取代 C++,而不是 C,对吗?
Rust 的目的是作为一种系统语言。如果说它 “应该 ”取代什么,那就是两者兼而有之。
现实情况是,我们花在阅读代码上的时间远远多于编写代码。这就是为什么可读性比巧妙的、节省行数的结构要重要得多。
要进一步减少重新熟悉现有旧代码所带来的精神负担,关键在于确定一套代码模式,然后严格使用它们。
然后,如果你想为自己的代码轻松编写一个解析器(不需要规范中的每一个细节),那就更重要了。
现在我读了 TFA,我看到他写道
> 我们有验证基本代码风格合规性的工具。
他的经验和勤奋把他带到了山顶,那就是我们必须让自己成为更大机器中的齿轮,为了未来的工作量和生产质量而自我限制。
> 现实情况是,我们花在阅读代码上的时间远远多于编写代码。这就是为什么可读性比巧妙的、节省行数的结构要重要得多。
在 JS 中,有时会专门为了可读性而链上两三个内联箭头函数。当你阅读代码时,你经常会在数据格式、API 响应预处理、本地化、异常处理等繁杂的工作中寻找 “真正的东西”。
有时,这些速记结构会帮助我跳过不太相关的部分,而不是费尽心思地爬上爬下每个排序和重命名函数。
尽管如此,我并不希望将这种想法正式写入代码指南中:) 除了都有大括号之外,JS 不是 C。
> 话虽如此,我还是不希望在代码指南中正式提出这种观点:)
当然。只要是我自己的代码格式标准,我都支持:-)
理想情况下,我希望集成开发环境在打开时按照用户/程序员的风格来格式化代码,但以格式无关的方式将一系列标记保存到代码数据库中。
这样,我们每个人都可以有自己的风格,但仍能拥有一致的代码库。
另外,我还要补充一点,我的格式习惯在过去几年里变得越来越极端和苛刻,现在我在逗号两边都加上空格,因为它们是一个独立的标记,不属于两边表达式的一部分。我这样做纯粹是为了便于阅读,但我在互联网上阅读代码和处理大型代码库的几十年中,从未见过有人这样做。但我真的很喜欢将表达式信息与结构信息分开的做法。
这也有助于我抛弃代码颜色格式,因为我发现它在过去很有用,但我不想在新环境中导入/设置所有这些环境信息。因此,我只使用平淡无奇的 vi,把那些用户界面的小玩意儿都用到了代码格式中。
而且,我完全支持你使用 JS,因为自从它出现以来,我就对它深恶痛绝,但这只是我这个老派 C 语言使用者的想法。
> 这就是为什么可读性比巧妙的、节省行数的结构要重要得多。
是的,我同意,这就是为什么我不喜欢某些所谓的 C 语言替代品,它们试图用抽象概念或构造来耍小聪明。
你能举例说明 “聪明”(坏)与 “简单”(好)的区别吗?
根据我的经验,C 语言有很多简单的语法,有一个普遍持有的简单(错误)执行模型,还有很多复杂性潜藏在不易察觉的地方。
(我的一本启蒙学习书籍是 https://en.wikipedia.org/wiki/C_Traps_and_Pitfalls ,这本书在上世纪 90 年代有效,现在大多仍有效)
简单是随着时间推移实现可控复杂性的关键。
要处理规模问题,就必须进行抽象。如果你煞费苦心地为一个复杂问题(比如锁定)找到了可行的解决方案,你希望能够将其打包并在整个代码库中使用。而 C 语言除了使用其脆弱得令人难以置信的宏工具外,缺乏实现这一点的机制。
Ada 有内建的并发结构和契约,Ada 的一个子集 SPARK 中还有形式验证,因此 Ada / SPARK 相当不错。
> C 除了使用其脆弱得令人难以置信的宏设施外,缺乏实现这一点的机制。
我们程序员是终极抽象机制,在代码库中完善模式设计和实现技术是我们的最高艺术形式。四人帮 “的《设计模式》中所列出的模式并不如前 50 页那么有趣,因为前 50 页具有开创性意义。
从项目中文件的组织,到项目的组织,到类的结构和使用,到函数设计,到调试输出,到根据作用域命名变量,到命令行参数规范,到解析,无一不是模式再模式。
你要么在做模式,要么在做一次性的东西,而一次性的东西比 C 宏更脆弱,以后也很难理解,而且当你修复了其中一个 bug 时,你只是修复了一个 bug,而不是修复了整个一类的 bug。
抽象是编程的精髓,而抽象只是代码库中的模式设计和实现,是一个功能块的设计以及如何随着时间的推移而被消耗。
抽象的分层是代码库最基本的视角。它们不仅能处理规模问题,还能决定正确性、易延展性、错误分流、性能和可理解性–我相信我还能找到更多。
抽象层的设计就是代码库的一切。
C 语言能够让程序员创建抽象层,这也是 C 语言成为我所使用的操作系统以及我正在输入此信息的浏览器的基础语言的原因。我猜你也是,虽然我可能错了,但可能性不大。而且没有出现任何故障。Unix 的规模无与伦比。
> C 的成功之处在于,它能让程序员创建层层抽象,这也是为什么 C 是我正在使用的操作系统的基础语言,也是我正在输入此信息的浏览器的基础语言。
你使用的浏览器中有哪款使用了大量的 C 语言?它们早就都用 C++ 了,因为 C++ 的抽象和组织能力更强。
这是我没有考虑过的一个合理的观点。上世纪 90 年代中期,C++ 刚刚发布时,我就在开发 C++ 对象,后来在本世纪初又使用了 Borland 的 C++ 编译器,但我从来没有真正想过,C++ 除了它的名字所暗示的那样,还能有什么其他功能: “C++ 是在 C 语言的基础上增加了一些抽象概念。
谢谢您的指正,但我认为 C++ 只是建立在 C 语言基础上的一套抽象结构,仔细想想,这些结构都不是独立于 C 语言的,而只是覆盖在 C 语言之上。我的意思是,它仍然只是使用更高级抽象分组的 ints、浮点数和指针。是的,它们通常比我在扩展 DOS 上编写图形用户界面要好用得多,但在我看来,它们都只是 C 语言的包装。
C++ 绝对不仅仅是 C 语言的封装,这样说是非常荒谬的。如果你坚持这么说,那么 C 也就不存在了,因为它只是汇编上的几个小抽象而已。
> C 的成功之处在于让程序员创建了层层抽象
你写了几段完全正确的文字,讲述了抽象是多么重要,然后把这句话放在了最后,而 C 已经被 40 多年来更好的抽象黯然失色了。
因为程序员创造的是抽象概念,而不是编程语言。
据我所知,目前还没有任何操作系统能在短期内威胁到 Unix 的统治地位。
我并不反对 Unix,但现在想想,C 语言如此接近微处理器的实际工作似乎是它成功的原因。
我个人已经有半个多世纪没有用 C 语言编写程序了,我更喜欢 Python,但我用 Python 所做的一切,只要有足够的脚手架,都可以用 C 语言完成。事实上,Python 是用 C 语言编写的,这是有道理的,因为 C++ 会带来太多的副产品,无法满足其严密性的要求。
当 C++ 被开发和发布时,我正在使用自己的对象结构抽象来编写 C 语言。它可以做到,而且做得很好(如 curl 所证明的那样),只是需要更加小心,而这归结于我们选择的抽象。
因此,我认为 “黯然失色 “的说法有点过了,尤其是考虑到我们新近最喜欢的编程语言都是在 C 语言编写的操作系统上运行的。
如果我有自己的选择,我希望一切都能用本地编译的 F#(即不使用 .NET JIT 运行),或者用更像 C 语言风格的变量实例化和无 GC 的 OCaml。但阻抗失配可能会使 F# 无法产生操作系统所需的精确抽象,但这只是我的看法。无论如何,运行的代码都是通过微处理器运行的,所以问题实际上是:“什么样的编程抽象才能产生在微处理器上运行良好的代码”。
我以前从没想过这个问题,谢谢你的精彩提问。
> 据我所知,目前还没有任何操作系统能在短期内威胁到 Unix 的统治地位。
这取决于我们的观点以及我们谈论的计算模式。
虽然 iDevices 和 Android 有类似 UNIX 的底层,但用户空间与 UNIX 毫无关系,而是由 Objective-C、Swift、Java、Kotlin 和 C++ 混合开发而成。
游戏机上没有 UNIX 本身,即使在 Orbit OS 上也所剩无几。
Windows 在游戏产业中占据主导地位,以至于 Valve 无法吸引开发人员编写 GNU/Linux 游戏,不得不用 Proton 来代替,它不是 UNIX,自 Windows XP 以来,旧式的 Win32 C 代码几乎被冻结,只有极少的添加,因为自 Windows Vista 以来,它大量基于 C++ 和 .NET 代码。macOS 虽然通过了 UNIX 认证,但苹果关心的用户空间,或者说收购前的 NeXT,与 UNIX 和 C 几乎没有关系,而是 Objective-C、C++ 和 Swift。
在云原生空间,应用容器上的托管运行时或无服务器上的托管运行时,底层内核或 1 类管理程序的确切性质对应用开发人员来说大多无关紧要。
> 我希望一切都能用 F# 本机编译
这在今天已经可以实现(即使是 GUI 应用程序)–只需使用 printfn(2 LOC)的替换定义非绑定反射,就可以了: dotnet publish /p:PublishAot=true
要明确的是,在 .NET 中,JIT 运行时和 ILC(IL AOT 编译器)都驱动同一个后端。编译器本身被称为 RyuJIT,但它确实服务于当今的各种应用场景。
> 这使得 F# 无法生成操作系统所需的精确抽象
你可以在 F# 中实现这一点,因为它可以访问与 C# 相同的属性,用于细粒度内存布局和调用控制,但使用 C# 实现这一点的体验更好(总体而言,也比使用 C# 更好)。在一些方面,F# 的使用不如 C# 方便–它缺乏 C# 对 refs 和 ref structs 的生命周期分析,而且它的模式匹配不能在跨度上使用,同样,在 ref structs 上也有问题。
> 据我所知,没有任何操作系统能在短期内威胁到 Unix 的统治地位
没错,但无关紧要吗?
> 什么样的编程抽象才能产生在微处理器上运行良好的代码
……安全。是的,C-with-proofs (sel4) 可以做到这一点,但代价相当高昂。
在某种程度上,微处理器与 C 语言共同进化,是因为需要运行已有的相同代码。而现有的系统又迫使新的工作与 C 链接。但持续的 CVE 压力永远不会消失。
我一点也不反对为新操作系统提供更坚实基础的新模式,但它不会是垃圾收集的,所以最流行的新语言确实是个不错的选择。
> 但持续的 CVE 压力永远不会消失。
我认为还有其他方法可以转移或击败这种压力,但我没有这方面的证据或工作,所以我真的什么都没有,只能说是天马行空的想法。
不过,在这个方向上有一种潜在的可能性,那就是不可变内核的出现,但这也只是我的一种直觉,它们很可能最终会被打败,如果只是被底层硬件架构的弱点所打败的话,尽管更新的技术(如定时攻击)应该更容易被检测到,因为它们依赖于大规模的蛮力。
在我看来,问题在于 “能否将固有的弱点减少到实际无懈可击的程度?我并不抱有希望,但考虑到完全重新实现所需的工作量,从成本效益分析的角度来看,这可能是最好的选择。同样,这种完美需要硬件架构与操作系统及其语言共同开发,才能真正创建一个防弹系统。
> 现在很多人都会开玩笑说什么宽屏幕可用
而这是一个愚蠢的观点,因为我希望能够在那个大显示器上并排放置 2-3 个文件。是谁在要求写长代码,让我不能同时在屏幕上显示多个文件?
不仅如此。报纸之所以设置多栏而不是长行,严格来说是因为短行更容易阅读。
我想没有人不同意这一点,但 80 个字符显然限制太多。我认为 120 个字符更合理。
在大多数语言中,我都使用 2 个空格或制表符作为缩进,而且从不超过 80 个字符(实际上是 79 个)。这对 XTerm 和我使用的大多数实用程序都很有效。
对于 git 提交,我的每行缩进不超过 69 个字符,这样在查看提交历史时看起来比较整洁。
如果我只关心在 VSCodium 中编码,120 个字符可能没问题,例如,有时我在使用 VSCodium 进行围棋编程时可能会超过 80 列宽,但我尽量不这样做,因为我仍然使用 “少 ”等字样,而且我有一台老式的 17 英寸显示器。我不喜欢宽大的显示器,我希望能够同时注视整个屏幕,如果使用宽大的显示器,我就必须离得太远,或者频繁地移动我的头部/颈部/眼睛。
所以……我的字体很小,我把列宽限制在 80,我很满意。:P
萝卜白菜各有所爱,不过我在使用 Java 代码时会遇到一些问题,因为它不仅要求我打开许多文件,还要求我来回切换文件,而且我还必须经常横向滚动。
我希望你明白我想说什么,如果不明白,我再详细说明。
清楚给谁看?我认为在 C 语言中每缩进 8 个空格就可以正常工作。在 python 和 rust 中,每级缩进 4 个空格也行得通。对于某些语言,我认为每级缩进 3 个空格是值得的。但我还没发现有多少语言值得超过 80 个字符。
如文章所述:
> 问题可能是究竟在哪里划定限制,这是每个项目都要讨论的问题。
这是个主观问题,并不存在于真空中,因为除了纯粹的主观偏好之外,它还会影响命名和缩进约定等其他选择。
在他们的项目中,他们喜欢 80。你也可以为你的项目选择其他的。
有很多人,虽然也有一些开发人员等,但通常都是非技术人员,他们把所有东西都最大化,然后抱怨他们看中的屏幕右边浪费了太多空间。
我有一个以 “标准 ”像素间距运行的 32 英寸屏幕(与我旁边的 24 英寸 1080p 纵向屏幕相匹配),我有时会全屏使用,但通常是 50/50、33/66、25/75 或 33/33/33,具体取决于我在做什么。我们的一位测试人员不理解,看不到我从这种灵活性中得到的好处(“为什么不干脆用两台显示器呢?”已经问过好几次了)。这种宽屏幕的存在似乎让她非常恼火。如果她看到我朋友用来玩游戏的超宽屏,我想她会发作的。
无可否认,当我坐着时,这台显示器加上另一台纵向显示器总的来说有点宽(所以另一台屏幕通常只能作为邮件/聊天窗口,只有当有东西引起我注意时我才会与之互动),而且有点太高了。如果我把办公桌抬高,站着使用会更舒服,这也是我 >⅔ 的工作方式。
Curl 是我用一个非常简单的 PR 就成功贡献的为数不多的项目之一。
当时,我对他们的自定义测试框架还有些迷茫,但我对能为最成功的开源项目之一做出贡献感到非常兴奋。
我现在明白了原因。正是因为他们在测试和可读性方面的规定(以及丹尼尔-斯滕伯格的友好态度),我这样的新手才得以成功。
很棒的帖子!
关于 40% 与 60-70% 内存问题百分比的原因,我有一些随意的猜测:
– 180k 并不是很多代码。60-70% 这个数字来自谷歌和微软,它们处理的代码库要大得多。当然,从理论上讲,代码库的大小不应该影响内存问题的比例,但我认为在实践中确实会有影响,因为代码库越大,就越难执行不变式并注意所有可能的边缘情况。
– 与此相关的一个方面是,curl主要由一个人(你)或最多几个贡献者来维护。当然,还有更多的人参与其中,但只有一个维护者对整个代码库了如指掌,能够洞察所有(或大部分)角落。对于有数百人参与的大型代码库而言,情况可能并非如此。
– Curl 经常被客户端使用(可能客户端比服务器使用得更多,不管这些词的定义是什么),而你却无法对其进行控制和监控。这意味着在客户端 “野外 ”触发的某些 UB 或漏洞可能永远不会被发现。对于谷歌/微软来说,如果我们谈论的是Chrome浏览器、Windows、网络服务等由其公司更严格控制和监控的产品,我认为他们能够检测到的漏洞和问题要比我们在curl中检测到的多得多。
– 你们写出了优秀的代码,热爱自己的工作,并以出色完成工作为荣(同样,如果我们将其扩展到一个拥有数百名开发人员的大型代码库,就很难达到同样的质量和敬业精神)。
(直接在帖子上发表评论,但似乎未获批准)
这篇文章写得非常清楚–你能感觉到它是如何通过成千上万个小时的交流形成的,真的很酷。
> 我们如何在 curl 中编写 C 语言,使其在数十亿次安装中安全可靠?
“这就是整洁的地方–你不需要”。
Curl应该像fish那样:咬咬牙,用Rust重写这个该死的东西。
这意味着它将不再运行在目前的许多设备上。,还有什么好主意吗?
> “更宽的代码更难阅读。句号。”
这句话说得好像它已经被证实了一样,我相信它有足够的事实根据,人们可能会选择执行它,但我不相信它是放之四海而皆准的真理。
我经常看到一些代码被执行行长限制,而我认为这些代码如果不被分割成多行就会更清晰。
就我个人而言,我更喜欢带有逃生口的线程,这样你就可以声明 “此行免于遵守这样那样的规则”,如果你有足够的理由,并且愿意向 pull request(拉取请求)提出抗争的话 😀
这篇文章的某些部分是观点性的。Curl可能写得很好,但这更可能是整体结构的结果,而不是每行的字符数。其实我也不知道curl是否写得好。受欢迎程度并不总是等同于代码质量。我以前用过curl API。我不喜欢它们。
他的所有想法都非常棒,显然是在一个经验丰富、非常成功的项目中长期积累的经验。他分享的都是适用于大型复杂代码库的技术。如果忽视它们,后果不堪设想!
具体来说,根据我的经验,这些部分是相关的:
> 避免 “坏 “函数
> 缓冲函数
> 解析函数
> 监控内存函数的使用
这些相关方面是我倾向于用自己的封装函数来封装我使用的许多库函数(在任何语言环境中)的原因,即使只是将它们的使用本地化为一个单一的入口/使用点。这样我就有了一种使用函数的方式,从而使我的代码不仅可以放置使用函数的所有最佳实践,还可以在整个代码库中的一个地方更新这些最佳实践。如果我想简单地重写代码本身,例如永远不使用 scanf,那么它就会特别有用,我在很多很多年前就确定了这是一个必要的策略。
现在,当一个函数需要满足不同的使用情况,而单独的逻辑会产生过多的逻辑或运行时成本时,可以添加一个单独的封装器,但如果额外的封装器可以利用基石封装器,那么在可行的情况下,这是最好的。当然,所有这些封装器都应位于同一块代码中。
特别是对于 C 语言来说,封装函数还能让我在标准库简洁的名称之上拥有自己的命名约定(不使用宏,因为宏是必须避免的)。这让我更容易记住它的名字,从而进一步减轻认知负担。