[C.C++] C Fundamentals 笔记

370 0
Honkers 2026-4-30 06:39:45 来自手机 | 显示全部楼层 |阅读模式

0-introduction

C 语言简介

C 是当今最流行的编程语言之一,长期位列全球前十,且这一地位已保持数十年之久。C 最初是为构建 UNIX 操作系统而设计的。时至今日,UNIX 的众多衍生系统依然无处不在:macOS 和 iOS 基于 BSD 风格的 UNIX,Linux 是独立的 UNIX 分支,Android 同样基于 UNIX。即便是不基于 UNIX 的 Windows,其底层也是用 C 编写的。

除操作系统之外,大量我们日常依赖的软件也以 C 为基础,包括 PostgreSQL、MySQL、Redis 等几乎所有主流数据库,以及 Python、PHP、Ruby 等编程语言的运行时实现。考虑到 C 诞生于 1972 年——超过半个世纪之前——这种持久的生命力实在令人叹为观止。

C 语言的历史背景

C 诞生于一个与今天截然不同的计算时代。其创造者 Dennis Ritchie 和 UNIX 设计者 Ken Thompson 在一台名为 PDP-11 的机器上完成了这项工作。那台机器没有显示器,使用的输出设备是"电传打字机(teletype)"——一种兼具键盘与打字机功能的设备,程序的输出会被物理打印在纸张上。

这正是计算机界沿用"print(打印)"一词来表示"向屏幕输出内容"的历史根源——因为最初它真的是在打印。同样地,C 语言中那些简短甚至看起来奇怪的变量命名风格,也与此背景密切相关:纸张宽度只有 80 个字符,变量名自然不能太长。

PDP-11 的硬件规格在今天看来极为局促:内存仅有 256 KB,单核 CPU,主频仅 125 KHz(连 1 MHz 都不到)。然而正是在这样的约束下,C 语言被设计出来,并支撑起了沿用至今的众多核心软件。

为什么 C 在现代硬件上依然有意义

一个自然会产生的疑问是:既然 C 是为如此贫瘠的硬件设计的,在现代高性能硬件上为什么还要用它?

答案恰恰在于:当年能在那么有限的硬件上运行良好的代码,放在拥有 32/64 GB 内存、16 核 CPU、多 GHz 主频的现代机器上,会运行得极其迅速。C 语言通常被半开玩笑地称为"可移植汇编语言(portable assembly)"。

汇编语言是处于机器码之上仅一层薄薄抽象的编程语言,具有极高的硬件针对性。C 的"可移植"体现在:同一份 C 源代码,无需修改,便可编译为 ARM64(如 Apple Silicon Mac)或 x86-64(如 Intel 台式机)的可执行文件,而汇编代码则必须为不同架构分别编写。

C 语言的核心特性:零开销与近零安全

C 之所以能达到极致性能,根本原因在于它实现了"零开销编程"。所谓开销,最典型的例子是垃圾回收机制(GC)。Java、Python、JavaScript 等语言都带有 GC,它虽然方便且安全,但会引入 GC 暂停、额外的内存占用和运行时元数据。C 没有垃圾回收器,内存中只存储真正需要的数据,没有任何附加信息。

C 甚至支持内联汇编,允许开发者在 C 程序中直接嵌入汇编指令,与硬件直接对话,进一步压榨性能。

然而,这种能力是有代价的。C 同时也是"近零安全"的语言——它不仅能让你"搬起石头砸自己的脚",甚至能让你"把整条腿都炸掉"。这是 C 最重要的缺陷之一,课程的练习环节将在受控的非生产环境中直观演示这一点。

C 作为跨语言通信的桥梁

C 还承担着另一个重要角色:它是现代编程语言之间相互通信的通用语言(lingua franca)。几乎所有你能想到的现代编程语言都提供了与 C 进行互操作的方式:Node.js、Haskell、Rust、Python 均有 C interop 支持。即便是 JavaScript,也通过 WebAssembly 间接实现了与 C 的对接。

当 Python 嵌入 Rust 程序时,两者之间的通信也是通过 C ABI(C 语言的内存表示规范)来完成的,即便并没有直接使用 .c 文件。

课程内容概览

本课程将依次涵盖以下主题:字符串与数字的处理、文件 I/O、使用上述知识构建一个真实的静态 Web 服务器,最后简要介绍如何将所学知识应用于开发 Node.js 的 C 扩展插件(C addon)。受时间限制,C addon 的实际构建不会在课程中完成,但会在结尾处提供概念性指引。


1-why-c-is-popular

C 依然流行的核心原因

C 持续流行的首要原因,是它提供了接近硬件极限的最大性能。这种性能来自于它的零开销本质——在操作系统允许的范围内,C 能让程序员尽可能贴近底层,仅比手写寄存器操作的汇编高出微薄的一层抽象。

与 C++ 相比,尽管 C++ 的初衷是改进 C,但它引入了极度复杂的语言规范和显著更长的编译时间。因此,许多同时掌握 C 和 C++ 的开发者在日常工作中依然更倾向于选择 C——简洁、可预期、编译快。

性能实证:"本地静态 HTTP 服务器性能小奥运"

为了直观展示 C 的性能优势,课程设计了一个非正式的性能对比实验,测试不同语言编写的静态 HTTP 服务器加载 Frontend Masters 网站所需的时间。

结果如下:第三名(铜牌)是 Node.js 的 HTTP server 包,拥有约 230 万次周下载量,是 Node.js 生态中最主流的静态服务器方案。第二名(银牌)是 Rust 的 simple-http-server,Rust 的生态规模远小于 Node.js,该包累计下载量约 52,000 次。第一名(金牌)是课程中用约 300 行 C 代码编写的静态服务器,仅依赖标准库,零第三方依赖。

这个结果颇具说服力:即便没有刻意进行性能调优,仅凭 C 语言"按惯例写代码"的方式,就足以超越已经非常成熟、专为高性能设计的 Rust 和 Node.js 实现。

C 的低开销替代语言

课程也坦诚地介绍了当下生态中试图在 C 基础上进行改进的语言选项。

C++ 是最流行的替代方案,在游戏开发领域占据统治地位。若想成为专业游戏开发者,几乎必然要与 C++ 打交道。

Rust 是近年来增长最快的低开销语言,代表性项目包括代码编辑器 Zed、JavaScript 生态工具 Biome(Rome 的继任者)以及命令行搜索工具 Ripgrep。

Zig 是另一个值得关注的新兴语言,终端模拟器 Ghostty 以及 Bun 的部分代码均使用 Zig 编写。此外还有 D、Odin、JAI、Carbon 等,它们都试图在保留 C 核心优势的同时,提供更现代的语言体验。

所有这些语言都支持 C interop,印证了此前所说:C 是现代编程语言生态的通用桥梁。

C 是最具普适性的编程语言

综合来看,如果一个人只能学习一门编程语言,并希望这门语言能让自己与当今几乎所有其他语言进行交互,那个答案就是 C。它不仅性能卓越、生态通用,更是理解计算机底层运作方式的最佳窗口。

为什么课程排除了 Windows 原生环境

课程要求使用 macOS、Linux 或 Windows Subsystem for Linux(WSL),原因在于课程中使用了部分操作系统特定的 API,而这些 API 遵循 POSIX 标准——这是 UNIX 衍生系统(macOS/BSD/Linux)共同遵守的 API 规范。Windows 原生 API 与之差异显著,若要同时支持两套体系,则每个练习都需要维护两个版本,大幅增加课程复杂度。

WSL 本质上是一个完整的 Linux 环境,因此同样支持 POSIX API,可以无缝融入课程体系。值得一提的是,Windows 原生 API 在概念上与 POSIX 非常相近,学完本课程后若想转向 Windows 专属开发,所有核心概念都能平滑迁移,只是函数名称和参数细节会有所不同。


2-hello-world-in-c

核心主题

本节课程("Hello, Metal!")的目标不只是展示如何用 C 写 Hello World,而是深入探讨 C 语言的底层机制——从源代码到汇编指令,再到硬件行为,帮助学习者真正理解"接近底层"意味着什么。

C 语言 Hello World 代码解析

最接近底层的 C Hello World 程序使用 unistd.h 库中的 write 函数。这个程序由以下几个关键部分构成。

预处理指令 #include:#include 是一个预处理器指令,作用类似于"导入"——它将指定库文件的内容复制粘贴到当前文件中。unistd.h 提供了 write 函数。

main 函数:C 是静态类型语言,每个函数都必须声明返回类型。void main() 表示 main 函数不返回任何值,参数列表为空表示不接受任何参数。main 是 C 编译器识别的特殊名称,是程序的入口点——程序运行时,就是从 main 开始执行的。

write 函数调用:write(1, "Hello, World!", 13) 包含三个参数:

  • 1:文件描述符,代表标准输出(stdout);2 则代表标准错误(stderr);这些是硬编码常量,在 C 中直接使用数字 1 而非具名常量是非常常见的惯用写法。
  • "Hello, World!":要输出的字符串。
  • 13:字符串的字节长度。之所以必须手动指定长度,是因为在 C 中获取字符串长度需要遍历每一个字节逐一计数,效率较低;提前告知长度可以避免这一开销,同时也允许只打印字符串的一部分。

C 对"真值"的处理:C 语言没有原生的 true/false,而是以 1 表示真、0 表示假。这是 C 语言"拥抱真值性(truthiness)"的体现,在 C 代码中直接使用 1 代替 true 非常普遍。

从 C 到汇编:硬件实际看到的内容

这是课程中唯一深入讲解汇编语言的部分,目的是让学习者直观感受"接近底层"的真实含义。

编译后的二进制结构:当 C 代码被编译为可执行文件后,二进制文件会分为不同的区段(section)。其中一个区段专门存放硬编码常量,例如字符串 "Hello, World!" 会被放在这里,标签为 .LC0(即"本地常量 0",Local Constant 0)。

main 函数体对应的汇编指令:write(1, "Hello, World!", 13) 这一个函数调用,在汇编层面会被展开为四条指令,分别将 1、字符串地址和 13 载入寄存器,然后通过 JMP(jump,跳转)指令跳转到 write 函数所在的位置继续执行。

关于作用域与函数:在汇编层面,作用域(scope)和函数(function)都不存在——这些是软件层面发明的抽象概念,CPU 根本不理解它们。汇编的世界里一切都是全局的,程序就是一系列跳转和寄存器操作。

系统调用(Syscall)与跨平台差异

write 函数并非 C 标准库的发明,它最终会触发操作系统的系统调用(syscall)——即通知操作系统"我要执行某个操作"。

Linux 的保证:Linux 对 syscall 编号做出了长期稳定的承诺(例如,编号 17 永远代表写入标准输出),因此在 Linux 上可以直接硬编码 syscall 编号,并进一步消除对 write 的跳转,从而更接近底层。

macOS 和 Windows 的限制:这两个系统不保证 syscall 编号的稳定性,可能在新版本中随时更改。因此,官方要求程序员必须通过 C 标准库(如 unistd.h 的 write)间接调用,而不能硬编码 syscall 编号。这也是为什么本课程将 write 定义为"在 macOS/Windows 上所能触及的最底层"。

性能含义:无可超越的速度

C 的 Hello World 程序在 macOS/Windows 上是字面意义上无法再提速的——因为它已经编译成了完成这一任务所需的最少硬件指令,没有任何冗余。这揭示了 C 性能的本质:不是因为它做了什么特别的事,而是因为它做的东西极少。相比之下,Go 的 Hello World 在汇编层面需要多屏内容,因为它要初始化垃圾收集器和虚拟机等基础设施。

这也带来了一个实用推论:如果你在基准测试中发现某个程序比这个 C Hello World 运行得"更快",那一定是基准测试本身出了问题,而不是真的有比这更快的实现。


3-hello-world-using-printf

核心主题

本节在上一节 Hello World 底层实现的基础上,介绍两种改善 C 代码人体工程学(ergonomics)的方式:让 main 返回整数退出码,以及使用 printf 替代 write。同时也对比了各语言 Hello World 的汇编开销,并总结了第一部分的全部内容。

各语言汇编开销对比

将 C、C++、Go 的 Hello World 编译后的汇编代码进行对比可以发现:C 的汇编极为简洁;C++ 稍多一些,但差距不大;Go 则需要多屏汇编,且内部包含大量跳转,因为它需要初始化垃圾收集器和虚拟机。

这个对比直观说明了为何 C 的继任者语言(Rust、Zig、Jai、Odin、Carbon 等)都以"尽量靠近 C 的汇编体量"为设计目标——它们可以接受像 C++ 那样有少量额外开销,但绝不希望像 Go 那样引入大量运行时基础设施的代价。

改进一:main 返回整数退出码

更惯用(idiomatic)的写法是将 main 声明为 int main(),并在结尾 return 0。按照惯例,退出码 0 表示程序成功执行,非零值表示某种错误。具体非零值所代表的错误含义并没有统一标准。

这样写的另一个原因是实用性:如果使用 void main(),某些 C 编译器会发出警告,在后续练习中会使用 int main() 的风格。

改进二:使用 printf 替代 write

printf(print formatted,格式化打印)来自 stdio.h(标准输入输出库),是对 write 的便利封装。它自动处理两件事:自动计算字符串长度(无需手动传入),以及自动写入标准输出(无需传入文件描述符 1)。

函数名称来源于 1972 年 PDP-11 时代——"print" 对应物理打字机的打印动作,"f" 表示 formatted(格式化),因为当时极度节省键盘输入,连下划线都省略了。

printf 的格式化插值特性

printf 最重要的附加功能是支持格式说明符(format specifier),实现类似字符串插值的效果。

  1. int num = 42;
  2. printf("The number is %d\\n", num);
复制代码

常用格式说明符包括 %d(代表 digit,整数)和 %s(字符串)。printf 还支持可变参数(var args),可以传入任意数量的参数依次对应多个格式说明符。这种方式比现代语言的 ${} 插值语法更繁琐,但在 1972 年已是相当实用的设计。

第一部分总结

本部分从最接近底层的 Hello World 出发,覆盖了以下内容:硬件实际执行的汇编指令;C 性能的本质(生成最少的指令);两种改善代码惯用性的方式(int main() + printf)。整个过程展示了 C 语言"在软件抽象与硬件现实之间距离最近"的核心特质。


4-hello-world-in-c-exercise

练习格式说明

所有练习采用统一格式:在 .c 文件中通过箭头符号(→)标注的注释提示需要完成的任务,按照注释操作即可。练习文件位于 exercises 目录,包含 6 个练习(1.c 到 6.c),编译后会在磁盘上生成对应的二进制文件。

编译与运行

编译命令为 gcc -o app1 1.c,其中 -o 指定输出文件名。在 macOS 上,gcc 实际上是 clang 的别名(运行 gcc --version 可以看到 "This is clang"),这是因为 GCC 被广泛用于构建脚本,macOS 为了兼容性将其重定向到 Clang。在 Linux 或 WSL(Windows Subsystem for Linux)上,gcc 才是真正的 GCC。

练习一:添加换行符

初始程序打印 "Hello, World!" 但没有末尾换行符,因此终端会显示一个特殊的 % 字符(提示"程序结束时没有换行")。

修改方法是将字符串改为 "Hello, World!\\n",同时将长度参数从 13 更新为 14。这里有一个重要的细节:\\n 在源代码中是两个字符,但在内存中只是一个字节(换行符),因此计数时按 1 计算。printf 会自动添加换行,而 write 只是原样输出你给它的内容,不做任何处理。

练习二:探索长度参数的边界行为(内存安全演示)

长度小于 14:程序只打印部分字符串,末尾 % 重新出现(因为截断后没有换行)。

长度大于 14(重要!):程序不会报错,不会崩溃,而是直接读取并输出字符串之后的任意内存内容——可能是二进制中其他常量、甚至是 API 密钥等敏感信息。这直观展示了 C 的内存不安全本质:一个整数写错,就可能将应用程序内存中的机密内容暴露给用户或日志。将长度改到 2000 时,甚至可以看到可执行文件中其他存储区域的内容。

练习三:使用 printf 并进行数字插值

将代码从 write 切换到 printf 需要在文件顶部取消注释 #include 。如果忘记这一步,编译器会报错 "Call to undeclared library function printf"。

成功后,打印效果为:Hello, World! The number is 42。添加第二个数字时,需要同时修改两处:在格式字符串中添加第二个 %d,并在 printf 的参数列表中添加第二个变量。仅添加变量而不修改格式字符串会触发编译器警告 "data argument not used by format string",但程序仍然可以运行(只是忽略多余参数)。

练习四:类型错误与段错误(Segmentation Fault)

将格式说明符改为 %s(字符串)但仍传入整数,编译器会发出警告,但不会阻止编译——这与大多数现代语言(会将类型错误视为编译错误)的处理方式截然不同。运行后程序会产生段错误(Segmentation Fault):程序试图读取它没有权限访问的内存区域,操作系统直接终止程序。

段错误的调试难点在于:默认情况下没有堆栈跟踪(stack trace),只有 "Segmentation fault" 这几个字。调试需要依赖调试器,而且这类 bug 往往难以稳定复现(称为 heisenbug)。

练习五:修改退出码

将 return 0 改为 return 11,然后在终端运行 echo $? 即可查看上一个程序的退出码,输出为 11。$? 是 shell 中存储最近一次程序退出码的特殊变量。

Q&A:#include 的工作原理

#include 的本质是文本复制粘贴——预处理器将被包含文件的全部内容插入到当前文件中。如果多个文件都依赖同一个头文件,该头文件会被重复插入,但每个头文件通常通过预处理器宏防止语义上的重复包含。

关于"是否会把整个库都打包进二进制"的问题:编译阶段确实会处理所有内容,但之后的链接(linking)阶段会进行分析,删除从未被跳转到的代码区段,类似于垃圾回收,最终只保留实际用到的函数。这也是 C/C++ 编译速度较慢的原因之一——#include 的语义决定了每次都需要处理大量文本。

5-numbers-in-memory

核心概念:内存中的数字表示

内存的本质是由无数个 1 和 0 组成的序列。硬件本身并不赋予这些 1 和 0 任何含义——它只是接受这些二进制数据,执行你指定的操作,然后输出新的二进制数据。一段相同的 1 和 0,可以被解释为整数、浮点数、字符串,或任何其他类型,完全取决于程序员的决定。

位与字节

现代硬件以 字节(byte) 为基本单位来组织内存,1 字节 = 8 位(bit)。这是现代计算机的标准,历史上也曾出现过 6 位或 7 位为一组的设计,但如今 8 位已成为通用标准。在上一节 Hello World 的练习中,传入的字符串长度 13 或 14,指的就是 13 或 14 个字节。

二进制(Base 2)数学

人类习惯使用十进制(Base 10),这可能是因为我们有十根手指。二进制的规则与十进制完全相同,只是每一位的最大值是 1 而不是 9。

以下是几个对应关系:

  • 00000000 → 十进制 0
  • 00000001 → 十进制 1($2^0$)
  • 00000010 → 十进制 2($2^1$)
  • 00000011 → 十进制 3($2^1 + 2^0$)
  • 00001000 → 十进制 8

规律与十进制相同:当最低位满了,就向高位进一,其余位归零——就像十进制里 9 变成 10 一样。

同一段内存,不同的解释方式

C 语言中一个极其重要的概念是:同一段内存可以用不同的方式来解释,这在 C 中叫做类型转换(casting)

举个例子,假设有 4 个字节的内存,里面存放了相同的 1 和 0:

  • 解释方式一:4 个独立的 8 位整数(一个字节数组)
  • 解释方式二:2 个 16 位整数(每个占两字节)
  • 解释方式三:1 个 32 位整数(整体视为一个大数字)

这三种解释使用的是完全相同的内存内容,只是划分和解读方式不同。

关键结论

程序员决定如何解释内存,这是 C 语言编程最核心的思想之一。硬件不会强制规定内存的含义,一切语义都由我们作为程序员来赋予。这种灵活性是 C 语言强大的根源,同时也是它容易出错的原因所在。


6-byte-arrays

write 函数的参数实际上都是整数

在上一节 Hello World 的 write 调用中,第一个参数 1(文件描述符)实际上是一个 32 位整数,它最大能表示 $2^{32}$ 个不同的值,约为 40 亿。整数位数越少,能表示的范围越小:16 位整数最大表示 65,535;8 位整数(一个字节)最大只能表示 256 个值。函数的设计者决定使用多大的整数,取决于他们认为需要支持多大的数值范围。

Hello 在内存中的实际样子

"Hello" 这个字符串在内存中对应的字节值是:

  • H → 72,e → 65,l → 108,l → 108,o → 111

这种映射关系是人类约定的,叫做 ASCII 编码(美国信息交换标准代码)。CPU 对"字母"毫无概念,它只认识 1 和 0。我们人为规定了"看到这种位模式就在屏幕上显示这个字符",仅此而已。

ASCII 与 UTF-8

ASCII 使用 7 位来表示字符(注意每个字节的最高位始终为 0),这足以覆盖英语的所有字母、数字和常用符号。但英语显然不是世界上唯一的语言,于是 UTF-8 应运而生。

UTF-8 的巧妙之处在于它与 ASCII 完全向后兼容:所有 ASCII 字符在 UTF-8 中的编码方式完全相同,因此用 ASCII 写的老程序无需任何修改就能在 UTF-8 环境下运行。UTF-8 利用 ASCII 那个"始终为 0"的最高位做了一个聪明的扩展:若最高位为 0,就是 ASCII 兼容模式;若最高位为 1,就进入 Unicode 扩展模式,可能需要多个字节来表示一个字符。

值得一提的是,Windows 系统和 JavaScript 在浏览器中普遍使用 UTF-16,而 HTTP 协议使用 UTF-8,现代世界正逐渐以 UTF-8 为主流标准。

内存地址(指针)的本质

write 函数实际上接收三个整数,而不是我们表面上看到的"一个整数 + 一个字符串 + 一个整数"。当我们写下一个字符串字面量时,C 编译器会把它编译进二进制文件,然后在调用 write 时,将那段内存的起始地址(一个整数)传进去。

关键理解:内存可以被看作一个巨大的字节数组,内存地址就是这个数组的下标(索引)。你告诉 write:"从第 N 个字节开始,给我读 M 个字节。"这就是为什么在之前的练习里,把长度改成 200 或 2000,会读出内存里的其他数据——因为 write 就是简单地从那个起点往后读指定数量的字节,完全不管那些字节原本是什么含义。

指针(Pointer)= 内存地址

"指针"这个词在很多人心中笼罩着神秘色彩,但它的含义其实非常朴素:指针就是一个整数,表示那个巨大字节数组(内存)中某个位置的下标。正如一条网络上流传的幽默所说:"指针不就是全局字节数组的一个索引吗,有什么难的?"——这当然是在故意类比另一个让人头疼的概念(monad),但道理是真的:指针并没有什么魔法,它就是内存的索引。

本课程中,讲师会优先使用"内存地址"而不是"指针",因为前者更直观,但两者表达的是完全相同的含义。

整数大小与上限

  • 32 位整数:最大约 40 亿,也就是说 write 的字符串最长只能 4 GB
  • 64 位整数:最大约 9 quintillion($9 \times 10^{18}$),地球上目前没有任何硬件能真正用满 64 位地址空间

Java 在 1995 年选择了 32 位整数作为数组长度,这导致了一个著名的局限:Java 无法直接处理超过 2-4 GB 的数组,需要拆分处理。C 则支持 64 位整数,从设计上避免了这个问题。


7-string-length-null-termination

HTTP 响应的结构

一个标准的 HTTP/1.1 响应在最底层,本质上就是一段文本字符串,格式如下:

  1. HTTP/1.1 200 OK
  2. (空行)
  3. <!doctype html>...(HTML 内容)
复制代码

没有 JSON,没有复杂结构,就是字符串拼接在一起。这也是为什么理解字符串在内存中的表示至关重要——我们的静态 Web 服务器就是要构造并发送这样的字符串。

null 终止符(Null Terminator)

在内存中,字符串 HTTP/1.1 200 OK 按字节展开后(H→72, T→84, P→80……),最末尾有一个特殊的 0 字节,这就是 null 终止符

需要特别注意的是:这里的 0 字节(值为 0)与字符 '0' 完全不同——字符 '0' 的 ASCII 值是 48,不是 0。

Null 终止符的作用是让函数在遍历字符串时知道何时停止:只需一路读取字节,遇到 0 就停下,不需要额外传递长度信息。

char * 类型的含义

在 C 中,字符串变量的类型写作 char *,分两部分理解:

  • char:字节(历史上叫"字符",是 1972 年的命名,即使在 Unicode 时代,一个 char 仍然只代表 1 字节,不一定对应一个完整字符)
  • (星号):表示这是一个内存地址,即指向某个字节的地址

因此,char *header 的含义是:header 这个变量存储的是一个字符串起始字节的内存地址,而不是字符串本身。在运行时,header 的实际值是一个 64 位整数,代表内存数组中的某个下标。

strlen 函数的工作原理

strlen 接收一个内存地址,然后从该位置开始逐字节遍历,直到遇到 0 字节,统计途中经过的字节数,即为字符串长度。

这个方案的缺点显而易见:每次调用 strlen 都需要遍历整个字符串,时间复杂度是 O(n)。把长度存储起来岂不是更高效?确实,这被很多人认为是 1972 年 C 语言设计中一个未能经受时间考验的决策——当年内存极度匮乏,节省存储长度字段的那几个字节似乎值得;但以今天的眼光来看,这个权衡并不划算。

好消息是:如果字符串是编译期已知的硬编码字面量,编译器的优化器会直接在编译时计算出长度,替换掉 strlen 的调用,不产生运行时开销。如果是运行时动态产生的字符串,就会真正付出遍历的代价。

printf 的 %s 格式说明符

%s 告诉 printf:把传入的整数解释为一个字符串的起始地址,从那里开始逐字节读取并输出,直到遇到 null 终止符(0 字节)停止。这是 printf 对 write 的一层封装,它自动处理了 null 终止符的检测,而 write 则需要你显式提供长度。

null 终止符陷阱

如果在字符串中间手动插入 \\0(null 字节),strlen 和 printf 都会把它当作字符串结尾,忽略后面的所有内容。比如把 "HTTP/1.1 200 OK" 改成 "HTT\\0P/1.1 200 OK",输出只会是 HTT。这是 C 语言"没有护栏"哲学的一个典型体现——你做了什么,它就执行什么,不会帮你检查对不对。


8-strings-memory-exercise

练习概览

本节是第 2 部分的实操练习,目标是通过实际运行代码,直观感受字符串、内存地址和 null 终止符的行为。练习围绕把 Hello World 替换为 HTTP 响应头 HTTP/1.1 200 OK 来展开。

练习一:用 strlen 替换硬编码长度

第一步是把 write 调用中硬编码的长度 15 替换为 strlen(header),同时在文件顶部添加 #include ,否则编译器会报"未声明的函数"错误。

结果与之前相同,因为 strlen 计算出的长度就是 15。但这种写法更正确、更健壮——如果字符串内容发生变化,不需要手动更新数字。此处的 strlen 因为作用于硬编码字符串,会在编译时被优化掉,不产生运行时开销。

练习二:在字符串中插入 null 终止符

在字符串中间的某处插入 \\0,例如在 "HTT" 之后插入,变成 "HTT\\0P/1.1 200 OK"。

实验结果会发现:write 和 printf 都只输出了 HTT,因为它们遇到 0 字节后立即停止。这生动地演示了 null 终止符的工作机制,同时也揭示了一个风险:在 C 中,字符串数据与控制信息(终止符)混用,一旦数据里意外包含 0 字节,整个字符串就会被截断,且没有任何错误提示。

练习三:打印内存地址的真实值

将 printf 的格式说明符从 %s 改为 %zu(打印 64 位无符号整数),就能看到 header 变量在运行时的真实值——一个很大的整数,这就是字符串起始字节在内存中的实际地址。

有趣的现象是:多次运行同一个程序,这个地址会略有不同。原因是操作系统的 ASLR(地址空间布局随机化) 安全机制:为了防止黑客利用已知的内存地址进行攻击(就像练习一中读取超长内存那种漏洞),操作系统在每次运行时会对内存地址进行随机偏移。这个实验也从侧面验证了一个核心结论:字符串变量在运行时就是一个整数

练习四:传入非法内存地址导致段错误

把 printf 的最后一个参数从 header 改为 (char *)123456,意思是"把整数 123456 当作字符串地址来使用"。

结果是 段错误(segmentation fault)。原因是内存地址 123456 处于操作系统保留的低地址区域,程序根本没有权限访问那里。操作系统检测到这次非法访问,立即终止了进程。

这说明 C 对这类操作完全没有护栏——你传什么地址,它就去读那里的内存,正确与否全凭程序员自己保证。

练习五:传入零地址(null 指针)

把地址换成 0,即传入一个 null 指针。

结果 printf 没有崩溃,而是输出了 (null) 这个字符串。这是 printf 的特殊处理:它知道地址 0 是一个无效的"空指针",如果真的去读地址 0 处的内存必然会段错误,于是直接打印 (null) 作为替代。但这个特殊处理仅针对地址 0,其他任意非法地址(如上一步的 123456)它都不会帮你兜底。

核心结论

这四个实验共同强化了一个贯穿本课程的核心主题:C 语言中的字符串、指针,在运行时本质上都是整数——内存这个巨大字节数组的下标。C 信任程序员做出正确的解释,它只是忠实地按照你的指令去读写内存,不做任何额外的验证或保护。这种设计既赋予了 C 极大的灵活性和性能,也要求程序员对内存的每一次操作都保持清醒的认识。

9-functions-iteration

概述

本课程为 C 语言系列第三部分——解析 HTTP 请求,核心内容包括:在 C 中定义新函数、使用循环进行迭代、复制内存,以及只读内存的概念。


HTTP 请求的结构

当浏览器访问服务器时,会发送一条 HTTP 请求,格式如下:

  1. GET /blog HTTP/1.1
  2. Host: ...
  3. User-Agent: ...
复制代码

服务器的任务是从中提取路径(如 /blog),并将其映射到文件系统上的实际文件路径。具体规则如下:

  • 如果路径没有文件扩展名(如 /blog),则默认访问 blog/index.html。
  • 如果路径以斜杠结尾(如 /blog/),同样处理为 blog/index.html。

定义新函数:to_path

函数的基本结构

在 C 中,定义一个新函数和定义 main 非常相似,只是返回值类型和参数不同。这里定义一个名为 to_path 的函数:

  1. char* to_path(char* request) {
  2. // 从请求字符串中提取路径
  3. }
复制代码
  • char* 表示返回一个内存地址(指向字符串的指针),而不是程序退出码。
  • request 参数同样是一个内存地址,指向请求字符串的起始位置。

前向声明(Forward Declaration)

C 语言在处理文件时是自上而下的。如果在调用 to_path 时它还没有被定义,编译器会报错。有两种解决方案:

  1. 前向声明:在调用位置之前写出函数签名(加分号),告诉编译器"这个函数之后会定义":

    1. char* to_path(char* request); // 前向声明
    复制代码
  2. 直接将函数移到调用之前:更简洁,直接避免问题。

这是 C 语言 1972 年设计遗留下来的历史包袱之一。


使用循环解析请求字符串

任务目标

输入:GET /blog HTTP/1.1

目标:提取中间的路径部分 /blog,并最终得到 blog/index.html。

具体步骤:

  1. 跳过 HTTP 动词(GET、POST 等),找到第一个空格。
  2. 跳过空格,从路径起点开始。
  3. 找到路径的终点(下一个空格之前)。
  4. 处理末尾斜杠的边界情况。
  5. 追加 index.html。

第一步:用 while 循环跳过 HTTP 动词

  1. char* start = request;
  2. while (start[0] != ' ') {
  3. start = start + 1; // 也可以写 start++ 或 start += 1
  4. }
  5. start++; // 跳过空格本身
复制代码

这里 start[0] 的含义是:把 start 当作一个数组,取第 0 个元素,即当前内存地址上的那个字节。循环会一直向前移动,直到遇到空格为止。

关于 ++ 运算符:start++ 是 start = start + 1 的简写。有趣的是,C++ 语言的名字就来源于此,寓意"在 C 的基础上递增一步"——这本身是一个带有 C 风格幽默感的名字。

边界处理:检查字符串是否提前结束

在遍历时,必须防止越过字符串末尾(\\0,即空字节)进入未知内存,引发未定义行为:

  1. if (!start[0]) {
  2. return NULL; // 提前遇到字符串结尾,返回空指针
  3. }
复制代码

!start[0] 等价于 start[0] == 0,即当前字节是空字节(字符串结束标志)。在 C 中,NULL 就是整数 0,只是语义上明确表示"这是一个空的内存地址"。

关于花括号:C 语言允许 if 语句不加花括号,但这是危险的。历史上著名的 Heartbleed 漏洞就源于此——有人写了一个没有花括号的 if,代码缩进让人误以为两条语句都在条件中,实际上只有第一条。因此,始终加花括号是良好的编程习惯。

第二步:将 while 循环改写为 for 循环

  1. for (char* start = request; start[0] != ' '; start++) {
  2. if (!start[0]) return NULL;
  3. }
复制代码

for 循环的三个部分:

  • 初始化:char* start = request(只在循环开始时执行一次)。
  • 条件:start[0] != ' '(每次迭代开始前检查)。
  • 迭代步骤:start++(每次迭代结束后执行)。

如果把 char* start 写在 for 内部,则 start 的作用域仅限于循环内部;若需要在循环结束后继续使用 start,则应写在外部。

第三步:找到路径的结尾

  1. char* end = start;
  2. for (; end[0] != ' '; end++) {
  3. if (!end[0]) return NULL;
  4. }
复制代码

此时 end 指向路径后面的那个空格,start 到 end 之间就是路径(如 /blog)。

第四步:处理末尾斜杠

  1. if (end[-1] == '/') {
  2. end--; // 去掉多余的斜杠
  3. } else {
  4. end[0] = '/'; // 补上缺失的斜杠
  5. }
复制代码

end[-1] 表示 end 向前一个字节,即路径最后一个字符。这段逻辑确保无论原始请求是 /blog 还是 /blog/,最终结果都一致。


原地修改字符串(In-place Mutation)

这里有一个重要的 C 语言思维方式:to_path 函数直接修改了传入的 request 字符串的字节,而不是创建一个新字符串再返回。

虽然从接口语义上看这似乎"不干净",但在 C 中这是最高效的做法。创建一个全新的字符串需要额外的内存分配,而直接修改原始内存则完全避免了这一开销。这种思想体现了 C 语言的核心哲学:内存是你的,你可以按需修改它


关键概念总结

概念说明
char*指向字符串的指针(内存地址)
start[0]取当前地址处的字节(等价于 *start)
start++将指针向后移动一个字节
end[-1]取当前地址前一个字节
NULL零地址,表示"无效"或"未找到"
前向声明告知编译器函数签名,允许先调用后定义
原地修改直接修改传入的内存,避免额外分配,效率最高
Heartbleed 教训始终为 if 添加花括号,防止逻辑错误

10-copying-memory

概述

本节课承接上一节的内容,完成 to_path 函数的最后一步:在提取出路径(如 /blog/)之后,将 index.html 以及空字节终止符复制进去,最终得到 blog/index.html。同时深入探讨了 C 语言中内存操作的效率考量与安全风险。


memcpy:内存复制函数

基本用法

C 标准库提供了一个名为 memcpy 的内置函数,用于将一段内存中的字节复制到另一段内存中:

  1. memcpy(end + 1, "index.html", 11);
复制代码

这三个参数分别是:

  • 目标地址(destination):end + 1,即紧跟在斜杠之后的内存位置。
  • 源地址(source):字符串字面量 "index.html" 的内存地址。
  • 字节数:要从源复制到目标的字节数量。

和 write 函数类似,memcpy 本质上接收三个整数:目标的 64 位内存地址、源的 64 位内存地址,以及要复制的字节数。

为什么复制 11 个字节而不是 10 个?

index.html 这个字符串有 10 个字符(i-n-d-e-x-.-h-t-m-l),但这里传入的是 11。原因在于:我们还需要复制字符串末尾的空字节终止符(\\0)。

在 C 中,当你写下一个字符串字面量(如 "index.html"),编译器会自动在末尾追加一个 \\0。因此,这段内存实际上是 11 个字节。通过指定 11,我们一并把这个空字节也复制过去,使目标字符串成为一个合法的 C 字符串。

这比手动追加 \\0 更高效——如果显式写 \\0,反而会多出一个多余的空字节,造成浪费。


使用常量(const)提升代码可读性

为了让代码意图更清晰,可以将 "index.html" 提取为一个具名常量:

  1. // 定义在函数外部,全局可见
  2. const char* DEFAULT_FILE = "index.html";
  3. // 使用时,显式表达"复制长度+1是为了包含空字节"
  4. memcpy(end + 1, DEFAULT_FILE, strlen(DEFAULT_FILE) + 1);
复制代码

几个值得注意的点:

  • const 关键字表示这个变量不可被修改,与 start、end 这些在遍历过程中会变化的变量形成对比。
  • 命名约定:常量使用全大写加下划线(即 SCREAMING_SNAKE_CASE),局部变量使用普通小写下划线(snake_case)。
  • strlen(DEFAULT_FILE) + 1 在编译时会被优化为常量 11,不会带来运行时开销,但让代码的意图一目了然。

to_path 函数的完整行为总结

执行完 memcpy 后,原始的 HTTP 请求字符串发生了根本性的改变。例如:

  1. 原始:GET /blog HTTP/1.1\\r\\nHost: ...
  2. 处理后:GET /blog/index.html...(HTTP/1.1 被覆盖)
复制代码

函数最终 return start,即返回路径部分的起始地址,指向 blog/index.html 这段内容。

需要特别注意的是:处理完之后,原始请求字符串已经不再是合法的 HTTP 请求。memcpy 直接将 index.html 写入了 HTTP/1.1 所在的位置,覆盖了原有内容。这是一种"原地销毁再利用"的 C 风格做法——高效,但不可逆。


内存安全风险:memcpy 的越界问题

潜在的危险场景

如果传入的 HTTP 请求字符串异常短(例如某个恶意客户端故意省略了 HTTP/1.1 部分),memcpy 尝试写入 index.html 时,目标位置后面的内存可能根本不属于这个字符串。

C 语言对此完全没有防护。它只会拿着三个整数(目标地址、源地址、字节数)机械地执行,不管目标内存后面是什么,都会照样覆盖。这可能导致:

  • 段错误(Segmentation Fault):程序崩溃。
  • 内存数据被破坏:其他变量的值被悄悄改写。
  • 安全漏洞:攻击者可以精心构造请求,让 memcpy 覆盖特定内存区域,从而控制程序行为。

这正是内存不安全的典型例子

这节课展示了一个规律性的模式:在 C 中,每一个内存操作都需要开发者自己负责边界检查。如果忘记检查,或者没想到某个边界情况,就可能留下漏洞。正如上一节提到的 Heartbleed,这类问题在现实中造成过极为严重的后果。

解决方案是:在调用 memcpy 之前,显式验证目标内存区域是否足够大,确保有足够的空间容纳 index.html\\0 这 11 个字节。这个边界检查的实现将在后续练习中完成。


关键概念总结

概念说明
memcpy(dst, src, n)将 src 处的 n 个字节复制到 dst
为何复制 strlen + 1额外复制空字节终止符,使目标成为合法 C 字符串
const声明不可变变量,语义上明确"这是只读的"
SCREAMING_SNAKE_CASEC 语言中常量的命名约定
原地修改memcpy 直接覆盖原始请求字符串,处理后请求不再有效
越界写入memcpy 不检查目标边界,可能覆盖任意内存,是重大安全隐患

11-readonly-vs-writable-memory

课程概述

本节课是 Richard Feldman 主讲的 C 语言内存模型进阶课,聚焦于一个常见但隐蔽的陷阱:并非所有内存都可以随意读写。课程以一个会触发"总线错误"(bus error)的真实代码为引子,深入讲解只读内存与可写内存的本质区别,以及如何通过语法选择来控制数据存储的位置。


一、问题的起点:神秘的 Bus Error

课程回顾了 to_path 函数的设计:它接受一个 HTTP 请求字符串,原地修改(mutate in place)这块内存,将请求路径如 /blog 翻译成 blog/index.html,最后返回修改后字符串开头的内存地址。

逻辑上,这套方案完全没有问题。然而,当代码如下书写时:

  1. char *req = "GET /blog HTTP/1.1\\r\\n..."; // 字符串字面量
复制代码

在 macOS 上运行后,程序会崩溃并报出 bus error(在 Linux 上是类似的段错误或保护错误)。错误原因既不是逻辑错误,也不是算术失误,而是程序试图修改一块操作系统标记为"只读"的内存


二、根本原因:二进制文件的内存布局

要理解这个错误,需要回顾 C 程序在内存中的结构。当编译器看到一个字符串字面量(string literal),例如 "GET /blog HTTP/1.1...",它会将这段文本存储到可执行二进制文件的一个特殊区域中——通常称为 数据段(data section)只读数据段(rodata section)

操作系统在加载程序时,会将这个区域标记为只读(read-only)。这样做的设计动机是合理的:字符串字面量是程序员写死在代码里的常量,操作系统借此提供了一层安全保障,防止程序意外(或恶意)地篡改自己的代码和常量。

所以,当 to_path 函数试图"踩踏"(stomp over)这块内存、将字节原地改写时,操作系统会立刻叫停,抛出总线错误:"你没有权限修改这里。"

与之形成对比的是,在 JavaScript 这类语言中,字符串从根本上就是不可变的(immutable),语言本身就不允许你直接修改字符串的字节,所以这类问题根本不会出现。C 的哲学则截然不同——"一切都是字节,你可以随心所欲"——但代价是程序员必须自己清楚每一块内存"住在哪里"、"有什么权限"。


三、解决方案:用数组语法把数据"搬"到栈上

解决这个问题只需一个微小的语法改动——将 char * 改为数组声明:

  1. // 原来:字符串存储在只读数据段,不可修改
  2. char *req = "GET /blog HTTP/1.1\\r\\n...";
  3. // 修改后:字符串内容被复制到栈上的局部数组,可以自由修改
  4. char req[] = "GET /blog HTTP/1.1\\r\\n...";
复制代码

这两种写法在表面上看起来差不多,但它们对编译器传达了截然不同的指令。char *req 的意思是:"给我一个指针,让它指向这个字符串——而字符串本身就存在二进制文件的只读区域里。" 而 char req[] 的意思是:"我要一个栈上的局部数组,把这个字符串的内容复制一份放进去。" 既然数组是函数自己栈帧(stack frame)的一部分,当然可以随意读写,操作系统不会干涉。

这也是在 C 代码中看到方括号数组声明的一个重要原因:程序员有意地将数据从只读区"拉"到可写的栈上,以便后续修改。


四、课堂问答:指针运算与数组下标的关系

课程末尾有一段精彩的答疑,解释了一个容易混淆的细节:在 to_path 的循环里,start 和 end 都是内存地址(指针),代码用 start[0] 来读取当前字符,但用 start = start + 1 来移动指针。为什么是这样而不是去递增下标?

理解这个问题的关键在于区分两个层次的操作。start[0] 是间接寻址(dereferencing):它的意思是"去 start 这个地址所指向的内存,读取那里的数据"。读取多少字节由类型决定——char * 就读 8 bits,int * 就读 32 bits。而 start = start + 1 是直接修改指针本身:它把 start 这个整数(内存地址)加一,让它指向数组的下一个元素,整个过程没有去"看"那块内存里存了什么。

简而言之,带方括号是"透过地址看内容",不带方括号是"修改地址本身"。这种区分是 C 指针运算的核心,也是理解 C 内存操作的基础。


五、本章知识点总结

本节课将第三部分的内容收束,串联了以下几个概念:函数的定义与前向声明(forward declaration);基于指针移动的迭代风格(而非传统下标递增);用 memcpy 将字节从一处复制到另一处;以及本节核心——只读内存与可写内存的区别,以及如何用 char[] 数组声明将字符串显式地放置到栈上以获得可写权限。这些知识组合在一起,使得程序能够正确地从原始 HTTP 请求字符串中提取并翻译出目标文件路径(如 blog/index.html)。

12-parsing-http-requests-exercise

概述

本节课是解析 HTTP 请求的综合练习课,通过实际运行代码、观察 bug 现象、逐步修复的方式,深入理解 C 语言中指针运算、off-by-one 错误、栈内存覆盖以及内存安全的核心概念。


初始状态:三个 Bug

运行初始程序后,发现以下问题:

  • Should be blog/index.html,实际输出 /blog/index.html(多了一个前导斜杠)。
  • Should be index.html,实际输出 /index.html(同样多了前导斜杠)。
  • Should be null,实际输出 /blog/index.html(本应返回 null,但没有)。

这是一个"世界上最简陋的单元测试套件"——直接用 printf 打印期望值和实际值对比。课程以修复这三个 bug 为主线展开。


第一步:重构 end + 1(消除 memcpy 中的 +1)

原始写法

在调用 memcpy 时,目标地址是 end + 1,含义是:end 当前指向那个保证存在的斜杠,+1 让我们跳过它,从斜杠之后开始写 index.html。

重构思路

通过调整 if/else 逻辑,让 end 在执行完条件判断后自然地停在斜杠之后的位置,从而不再需要 memcpy 里的 +1:

  1. // 原始写法
  2. if (end[-1] == '/') {
  3. end--; // end 退回到斜杠位置
  4. } else {
  5. end[0] = '/'; // 写入斜杠
  6. }
  7. // memcpy(end + 1, DEFAULT_FILE, ...); // 需要 +1
  8. // 重构后写法
  9. if (end[-1] != '/') {
  10. end[0] = '/'; // 写入斜杠
  11. end++; // end 向前移一步,现在指向斜杠之后
  12. }
  13. // memcpy(end, DEFAULT_FILE, ...); // 不再需要 +1
复制代码

两种写法在语义上完全等价,只是数学表达方式不同。这个重构也展示了 C 语言的一个特点:即使是简单的逻辑重构,也需要仔细追踪指针的具体位置,稍有不慎就会引入新的 off-by-one 错误。

实验:如果忘记复制空字节会怎样?

将 memcpy 的字节数从 strlen(DEFAULT_FILE) + 1 改为 strlen(DEFAULT_FILE)(少复制一个字节,不复制 \\0)后,程序会继续读取原始 HTTP 请求字符串中后续的内存内容,直到偶然碰到某个 \\0 才停下来。结果是屏幕上出现一大堆乱码——index.html 后面跟着 HTTP/1.1 和请求头的内容。这直观地展示了"没有 null 终止符"的后果:C 不会报错,只会一路读下去。


第二步:修复前导斜杠(核心 Bug)

问题原因

当前 start 在跳过 HTTP 动词(GET)和空格之后,指向的是 /blog 中的那个斜杠。因此最终返回的路径以 / 开头,变成了 /blog/index.html 而非期望的 blog/index.html。

修复方法

在跳过空格时,额外再前进一步,跳过那个前导斜杠:

  1. // 原来只跳过一个空格
  2. start++;
  3. // 修复:跳过空格,再跳过前导斜杠
  4. start++;
  5. start++;
  6. // 或者合并写成:
  7. start += 2;
复制代码

由于 end 的初始值是 start,这个额外的 ++ 也顺带让 end 从正确的位置开始遍历,不会影响程序语义(因为 / 既不是空格也不是 null,所以不会改变循环的行为)。


第三步:移动代码引发的"爆炸"——栈溢出与内存覆盖

实验现象

在修复前导斜杠之后,老师要求将处理第四个请求(rec4,一个故意截短的、缺少 HTTP 协议头的请求)的代码从 main 函数末尾移到 main 函数的开头,结果程序直接崩溃,输出 abort,没有任何堆栈追踪。

为什么移动代码会导致崩溃?

这里涉及一个关键的 C 内存概念:

rec4 是在栈(stack)上分配的字符数组。 它的内容刻意很短,没有 HTTP/1.1 协议头,这意味着当 memcpy 尝试把 index.html 写入其中时,写入的字节数超过了这个数组实际占用的内存,导致写入溢出到相邻的内存区域。

在函数末尾时,rec4 周围的内存碰巧是一些无关紧要的数据,覆盖它们不会立即触发错误,程序可以继续运行。

在函数开头时,rec4 周围的内存是函数调用的关键数据,例如:函数的返回地址、栈帧指针等。将这些字节覆盖掉会导致操作系统检测到异常并强制终止程序(abort)。

这揭示了内存安全 bug 最可怕的特性

这个例子完美展示了为什么内存安全 bug 如此难以捕捉:

  • 没有症状不代表没有 bug。 代码在函数末尾时"能跑",但 bug 一直都在,只是碰巧没有破坏重要的内存。
  • 看似无害的重构可能引爆问题。 仅仅是把代码移到函数开头,行为就从"貌似正常"变成"立即崩溃"。
  • 编译器优化可能让潜在 bug 暴露。 即使在同一个位置,不同的优化级别可能导致内存布局改变,让原本"正常"的代码突然崩溃。这时可能有人误以为是编译器的 bug,但问题根源是代码本身。

附:栈保护技术——Stack Canary(金丝雀)

课程中提到了一种调试和防护手段:Stack Canary(栈金丝雀)。其原理是:在栈的末端放置一个"哨兵"内存页,并通知操作系统将这段地址标记为只读。如果任何代码试图写入这段内存,操作系统会立即触发 segfault,让开发者知道"这里发生了越界写入"。这不能阻止 bug 的发生,但至少能提供一个可见的崩溃信号,而不是让程序带着内存错误悄悄继续运行,最终造成更难追踪的后果。


第四步:修复 null 案例——检查 memcpy 目标是否越界

问题描述

第三个 bug 是:对于一个故意截短的请求字符串,to_path 本应返回 NULL(因为没有足够的空间写入 index.html),但实际上却返回了 /blog/index.html。

修复思路

在调用 memcpy 之前,需要先验证目标内存是否足够:

  1. // 检查:写入的最后一个字节的地址,是否超过了请求字符串的末尾?
  2. if ((size_t)end + strlen(DEFAULT_FILE) > (size_t)req + strlen(req)) {
  3. return NULL; // 空间不足,拒绝写入,安全返回
  4. }
  5. memcpy(end, DEFAULT_FILE, strlen(DEFAULT_FILE) + 1);
复制代码

这里有一个细节值得注意:strlen 返回的类型是 size_t,而不是普通的 int。在做指针减法与 strlen 结果的比较时,需要保持类型一致,否则编译器会报警告。将相关值显式转换为 size_t 可以消除这个警告。

修复效果

加入边界检查后,截短的请求会在 memcpy 之前被拦截,to_path 返回 NULL,程序正常打印 null。此时再把这段代码移回函数开头,程序也不会崩溃了——因为 memcpy 根本不会被执行,自然不会踩到栈上的关键数据。


关键概念总结

概念说明
Off-by-one 错误指针位置差一个字节导致前导斜杠残留,需要精确追踪每次 ++ 的效果
栈内存覆盖memcpy 写入超出数组边界时,会覆盖栈上相邻的数据,行为取决于周围内存的内容
abort 崩溃覆盖了函数返回地址等关键栈数据,操作系统强制终止程序
无症状的 bug内存错误可能长期潜伏,直到某次重构或优化才突然暴露,这是内存安全问题最危险之处
Stack Canary在栈末端设置只读哨兵页,触发越界写入时立即 segfault,提供可见的错误信号
size_t 类型strlen 的返回类型,做指针算术比较时需要保持类型一致,避免编译器警告
边界检查前置在 memcpy 之前验证目标空间是否充足,是防止内存越界的根本手段

13-file-i-o

概述

本节课进入文件 I/O 的核心内容:如何在 C 中打开文件、读取文件内容,以及如何正确处理错误返回值。这些操作是构建静态 Web 服务器的关键一步——服务器需要从磁盘读取 HTML 文件,再将其内容发送给浏览器。


打开文件:open 函数与文件描述符

基本用法

  1. int fd = open("example.txt", O_RDONLY);
复制代码

open 函数接收两个参数:文件路径(一个内存地址,即 char*)和访问标志(一个整数常量)。O_RDONLY 是标准库提供的常量,表示以只读模式打开文件。除此之外还有只写(O_WRONLY)和读写(O_RDWR)模式。对于静态 Web 服务器来说,只读模式是最合适的——明确声明不需要写入,也防止了误操作。

open 返回一个 int,称为文件描述符(File Descriptor,简称 FD)。

文件描述符是什么?

你可以把文件描述符理解为操作系统维护的一张"已打开文件登记表"中的索引编号。操作系统在内部记录了每个整数 ID 对应哪个文件、文件在磁盘上的位置等信息。有了这个 ID,后续所有操作(读取、获取大小、关闭)都只需要传入这个整数,而不用每次都重复传入完整的文件路径。

操作系统对每个进程能同时持有的文件描述符数量有上限(通常远小于 int 的理论最大值约 42 亿)。因此,用完文件后必须调用 close(fd) 关闭它,否则会发生文件描述符泄漏(类似内存泄漏),最终导致无法再打开新文件。


C 语言的错误处理约定:返回 1

这里有一个非常重要的 C 语言惯例,初学者务必牢记:C 函数通过返回特殊值来表示错误,而不是抛出异常。

对于 open 来说,如果文件路径不存在或没有权限,它会返回 -1。程序员需要自己检查这个值:

  1. int fd = open("example.txt", O_RDONLY);
  2. if (fd == -1) {
  3. // 发生了错误,需要处理
  4. }
复制代码

C 不会自动提醒你检查错误,也不会自动传播错误。如果你忘记检查,程序会拿着这个 -1 继续往下执行,把它当成一个合法的文件描述符传给其他函数,结果会是各种难以预料的行为。因此,养成习惯:每次调用 C 标准库函数后,都查阅文档,确认哪个返回值代表错误,并主动检查它。


读取文件:read 函数

缓冲区的概念

在读取文件之前,需要先准备一块内存来存放读入的内容,这块内存称为缓冲区(buffer):

  1. char buffer[100]; // 在栈上分配 100 个字节,不初始化
复制代码

char buffer[100] 的含义是:在栈上预留 100 个字节的空间,并把第一个字节的地址赋给 buffer。注意,这里没有初始化——这块内存里装的是"内存垃圾",即之前程序运行遗留下来的随机字节。在 read 写入真正的数据之前,千万不要去读取 buffer 的内容。

read 的调用方式

  1. int bytes_read = read(fd, buffer, 100);
复制代码

三个参数分别是:文件描述符、目标内存地址(缓冲区)、最多读取的字节数。

read 的行为和名字有点反直觉——它做的事情本质上是:把文件中的字节"砸"进你提供的内存地址里,覆盖那里原有的内容。这与前几节课中原地修改请求字符串的思路完全一致,在 C 中这是标准做法。

返回值的含义

read 返回它实际写入的字节数。这里有几种情况需要注意:

  • 如果文件只有 30 字节,你请求 100 字节,read 只会写入 30 字节,并返回 30。
  • 如果文件超过 100 字节,read 写入 100 字节,返回 100,但不告诉你文件还有剩余内容
  • 如果发生错误,返回 1,需要主动检查。

这意味着 read 的返回值本身并不能告诉你文件的完整大小,只能告诉你"这次实际读了多少"。

为什么不能传入比缓冲区更大的字节数?

如果 buffer 只有 100 字节,却告诉 read 读 200 字节,它会毫不犹豫地写满 200 字节,覆盖掉缓冲区后面的内存——可能是其他局部变量、函数返回地址,等等。结果就是 bus error、abort,或者更隐蔽的数据损坏。C 语言没有任何内置的边界检查,这一切都靠程序员自己保证。


关键概念总结

文件描述符是操作系统为已打开文件分配的整数 ID,使用完毕后必须关闭,否则会造成资源泄漏。C 语言的错误处理依赖返回值约定(通常是 -1 表示错误),没有异常机制,开发者必须主动检查。缓冲区是一块预先分配的内存,read 会直接将文件内容覆盖写入其中;缓冲区的大小决定了单次最多能读取多少字节,传入错误的大小会导致内存越界。这些底层机制是构建静态 Web 服务器的基础,也再次体现了 C 语言"完全信任程序员、完全不设防"的设计哲学。

14-file-metadata

概述

本节课解决一个实际问题:如何在不知道文件大小的情况下,精确地读取文件的全部内容?这引出了 C 语言中两个重要的新概念——结构体(struct)取地址运算符(&),并通过 fstat 函数获取文件元数据来实现精确读取。


问题:如何读取完整的文件?

上一节课的 read 函数需要指定读取的字节数,但我们用的是硬编码的 100。这显然不够用——如果文件有 5000 字节,我们只读了 100 字节;如果文件只有 30 字节,我们又浪费了 70 字节的缓冲区空间。

正确的做法需要三步:

第一步,先询问操作系统文件有多少字节。第二步,根据这个大小动态分配一个恰好合适的缓冲区。第三步,再调用 read,传入这个精确大小的缓冲区和字节数。

要完成第一步,就需要用到 fstat 函数和 struct stat。


C 语言中的结构体(struct)

什么是 struct?

struct 可以粗略地类比为其他语言中的"对象",但它极度精简,没有方法、没有继承、没有运行时类型信息。本质上,struct 就是一块连续的内存,里面按顺序存放着若干个不同类型的变量,每个变量可以通过名字来访问,而名字只是编译器帮你记住"这个变量在这块内存中偏移了多少字节"。

  1. // struct 的使用方式:先写 struct 关键字,再写结构体名,最后是变量名
  2. struct stat metadata;
复制代码

struct stat 是操作系统提供的一个标准结构体,大小为 144 字节,包含文件的各种元数据:inode 编号、所有者用户 ID、所属组 ID、设备 ID、最后访问时间、最后修改时间,以及我们最关心的——文件大小 st_size

注意,这里声明 metadata 时没有初始化(没有 =),这意味着这 144 字节是"内存垃圾",在调用 fstat 之前不能读取任何字段,否则得到的只是随机数据。

struct 与数组的相似之处

从内存角度看,struct 和数组非常相像——都是一块连续的字节,都通过基地址加偏移量来访问内部数据。区别在于:数组的每个元素类型相同、大小相同,而 struct 的每个字段可以是不同类型、不同大小。编译器在编译时就知道每个字段的偏移量,所以 metadata.st_size 这样的写法实际上被编译器翻译成了"从 metadata 的起始地址偏移 X 个字节处读取 Y 个字节"。


取地址运算符:&

为什么需要 &?

调用 fstat 时,我们这样写:

  1. fstat(fd, &metadata);
复制代码

&metadata 的含义是:不要把 metadata 的 144 字节内容复制过去,而是给我 metadata 这块内存的起始地址(一个 8 字节的指针)。

理解这一点的关键在于 C 的函数调用机制。当你把一个变量传给函数时,C 默认会复制这个变量的全部内容。对于一个 int(4 字节)来说,这没什么问题。但对于一个 144 字节的 struct,如果每次传递都要复制 144 字节,效率就会下降。更重要的是,fstat 需要往 metadata 里写数据,如果传的是副本,写入结果就会丢失,原来的 metadata 永远不会被填充。

所以 & 的作用是:把"传递值"变成"传递地址",让 fstat 知道该往哪里写那 144 字节的元数据。

& 可以用在任何变量上

  1. int fd = open("example.txt", O_RDONLY);
  2. int* fd_address = &fd; // 得到 fd 这个整数在内存中的地址
复制代码

这个运算符适用于任何类型的变量——int、char、struct,都可以用 & 取到它的内存地址。这是 C 语言中"一切皆地址"哲学的又一体现。


完整流程:获取文件大小

  1. // 第一步:声明一个 stat 结构体,预留 144 字节(暂未初始化)
  2. struct stat metadata;
  3. // 第二步:调用 fstat,让操作系统把文件元数据写入这 144 字节
  4. // &metadata 传递的是地址,fstat 会直接往里写数据
  5. if (fstat(fd, &metadata) == -1) {
  6. // 返回 -1 表示出错,需要处理错误
  7. }
  8. // 第三步:现在 metadata 已被填充,可以安全读取 st_size
  9. // %ld 是 long int 对应的格式化符号
  10. printf("文件大小:%ld 字节\\n", metadata.st_size);
复制代码

值得注意的是,fstat 的返回值本身不携带有用的数据,只用于判断是否出错(-1 代表错误)。实际的文件大小存在 metadata.st_size 里。这是 C 语言中常见的模式:函数通过修改你传入的内存地址来"返回"数据,而函数的返回值则专门用于错误码


为什么要走这么复杂的流程?

这个"声明结构体 → 传地址 → 函数填充 → 读取字段"的流程,乍看起来远比其他语言繁琐(比如 Python 里直接 os.path.getsize("file.txt") 就完事了)。但这背后有其合理性:

这种方式极其高效。整个过程中没有任何动态内存分配,没有垃圾回收,没有对象包装。struct stat metadata 直接分配在栈上,fstat 直接把字节写进去,我们直接读取对应字段。从 CPU 的角度看,这是最直接的数据传递路径。

这也是为什么 C 语言至今仍在操作系统、嵌入式系统、高性能服务器等领域广泛使用——当你需要对每一个字节、每一次内存操作保持完全掌控时,这种"繁琐"恰恰是它的优势所在。


关键概念总结

struct 是 C 中组织多个不同类型数据的方式,本质是一块有命名字段的连续内存,没有方法或运行时元数据。struct stat 是操作系统提供的文件元数据结构体,包含 144 字节的信息,其中 st_size 字段存储文件的字节大小。& 运算符用于获取任意变量的内存地址,将"按值传递"转换为"按地址传递",既提升了效率,也允许被调用函数向调用者的内存中写入数据。fstat 通过返回 -1 来表示错误,有用的数据通过修改传入的 struct 指针来传回,这是 C 中常见的输出参数模式。在读取 metadata 的任何字段之前,必须先成功调用 fstat,否则读到的只是未初始化的内存垃圾。

15-memory-management-in-c

概述

本节课深入探讨 C 语言中最核心也最危险的话题之一:内存的生命周期管理。课程从"为什么 fstat 要接受地址而不是直接返回 struct"这个问题出发,逐步引出栈内存的自动释放机制、堆内存(malloc/free)的使用场景,以及两类最常见、最致命的内存安全漏洞——use-after-freedouble-free


为什么传地址,而不是直接返回 struct?

效率原因:避免大量字节的复制

struct stat 占 144 字节。如果 fstat 直接返回这个 struct,调用者每次接收它都需要把 144 字节复制一次。如果调用者还想把结果传给另一个函数,又要复制 144 字节。随着调用链变长,这种复制开销会不断叠加。

相比之下,传递地址(指针)永远只需要 8 字节(64 位系统上一个内存地址的大小)。所以 fstat 让你预先准备好一块内存,然后把地址传过去,它直接往那块内存里写 144 字节。整个调用链中传来传去的始终只是这 8 字节的地址,效率上有本质差别。

这也解释了为什么在较大的 C 程序中,你几乎总是看到函数在传递指针(内存地址),而很少看到函数在传递或返回完整的 struct。

安全原因:不能返回函数内部变量的地址

另一个更深层的原因是:函数结束时,它内部所有局部变量的内存会被自动标记为"可重用"。这是 C 语言中函数调用的基本机制,也是为什么调用函数不会造成内存泄漏的原因——函数退出,它的局部变量内存就自动归还了。

但这同时意味着,如果 fstat 在自己的函数体内创建了一个 struct stat,然后返回它的地址,那个地址在 fstat 返回之后就成了"危险地带"——那块内存已经被标记为可以被任何后续函数随意覆盖。调用者拿着这个地址去读数据,读到的可能是正确的值,也可能是后续某个函数调用留下的垃圾数据,完全取决于运气。这正是上一节课中"把代码移到函数开头就崩溃"那个 bug 的根本原因。

to_path 函数的设计回顾

这也解释了前面 to_path 函数为何要直接修改传入的 request 字符串,而不是在函数内部创建一个新字符串再返回它的地址:传入的 request 内存是由调用者负责管理的,函数结束不会影响它的生命周期。所以返回一个指向 request 内部某处的地址是安全的,而返回一个指向函数内部局部变量的地址则是危险的。


堆内存:malloc 与 free

为什么需要堆?

栈上的局部变量有一个根本限制:它们的生命周期和函数绑定,函数返回即消亡。如果你需要一块内存在多个函数调用之间保持存活,甚至在整个程序运行期间都保持存活,就需要用到堆(heap)

malloc(memory allocate 的缩写)是申请堆内存的标准函数。你告诉它需要多少字节,它返回一个指向那块内存的地址。这块内存不会随着任何函数的退出而消失,它会一直存在,直到你显式地调用 free 来释放它。

  1. // 在堆上申请 144 字节,返回地址
  2. void* buffer = malloc(144);
  3. // 用完之后,必须手动释放
  4. free(buffer);
复制代码

malloc 的代价:你必须手动管理生命周期

malloc 带来的问题是:它默认就是内存泄漏。每次调用 malloc,如果不对应地调用 free,那块内存就永远不会被归还,程序会随着时间推移消耗越来越多的内存。和文件描述符的 open/close 类似,malloc/free 必须成对出现。

但更麻烦的是,不只是"忘记调用 free"会出问题,"在错误的时机调用 free"同样会造成严重后果。


两类最危险的内存安全漏洞

课程中将几乎所有严重的内存安全漏洞(包括各种历史上著名的 exploit)归结为两类根本原因:缓冲区溢出(我们在之前几节课已经详细看过)和**malloc/free 的错误使用**。后者又分为两种具体形式:

Use-after-free(释放后使用)

这种错误的流程是:调用 malloc 得到一块内存的地址,使用这块内存,调用 free 释放它,然后——错误来了——又用原来那个地址去读或写数据。

free 之后,那块内存已经归还给系统,可能被后续的 malloc 分配出去给其他用途。此时你再去读它,可能碰巧没人动过它,读到的还是旧数据,程序表现正常;也可能别人已经把那块内存写满了新内容,你读到的是彻底的垃圾,程序行为不可预测。最糟糕的情况下,攻击者可以精心构造数据来填充那块被释放的内存,让你的程序在读取时执行攻击者希望的逻辑。

Double-free(重复释放)

这种错误更加隐蔽:调用 free 释放了某个地址之后,后来又对同一个地址调用了第二次 free。

表面上第二次 free 看起来什么都没发生,但实际上它可能产生极其诡异的后果。在第一次 free 和第二次 free 之间,如果系统恰好把那块地址通过 malloc 分配给了其他代码,那第二次 free 就在不知情的情况下释放了别人正在使用的内存。结果是那段"完全不相关"的代码突然开始读到垃圾数据,而你完全找不到原因——因为那段代码自己的 malloc 和 free 都是正确的,问题根源是另一段代码里的一次看似无害的 free。


是否一定要用 malloc?

课程给出了一个反直觉但非常有价值的观点:能不用 malloc,就不用 malloc

malloc 比栈内存分配性能更低(它需要向操作系统申请内存,有额外开销),而且引入了上述两类很难调试的 bug 风险。前几节课展示的"原地修改 request 字符串"的技巧,表面上看起来有点奇怪,但实际上它避免了 malloc,因此既更高效,又规避了 use-after-free 和 double-free 的风险。

这节课最终将要构建的静态 Web 服务器,不使用任何 malloc。这本身就是一个重要的设计目标——通过巧妙地重用已有内存(比如直接在请求字符串上操作),可以写出既安全又高效的 C 程序,而无需引入堆内存管理的复杂性。


关键概念总结

传递地址而非整个 struct,既是出于效率(8 字节 vs. 144 字节),也是出于安全(不返回栈上局部变量的地址)。函数退出时,其局部变量的内存自动被标记为可重用,返回指向局部变量的地址会导致悬空指针。malloc 在堆上分配长生命周期的内存,但必须配对调用 free,否则内存泄漏。Use-after-free 是在 free 之后继续使用那块内存;double-free 是对同一地址调用两次 free,两者都是严重的安全漏洞。尽可能避免 malloc,通过原地修改已有内存来复用空间,是 C 语言中兼顾性能与安全的重要技巧。

16-stack-vs-heap-memory

概述

本节课将前几节的内容汇总,集中讨论一个贯穿整个 C 内存模型的核心问题:栈(stack)和堆(heap)各自适合什么场景,以及如何在两者之间做出正确的选择。同时引出了"分块读取"(chunked read)这一实践中更常见、更健壮的文件读取模式。


用文件实际大小作为缓冲区——问题在哪里?

上一节课解决了"如何知道文件有多少字节"的问题,自然的下一步是:

  1. // 用文件实际大小来分配缓冲区
  2. char buffer[metadata.st_size];
复制代码

这看起来非常合理——不再硬编码 100,而是用精确的文件大小来声明数组。但这里有一个严重的潜在问题:栈的大小是固定且有限的

操作系统在程序启动时为栈分配一块固定大小的内存(通常只有几兆字节),这个限制是有意为之的设计决定,有其充分的理由。如果文件是几百 MB 甚至几 GB,在栈上声明这么大的数组,程序会立刻触发段错误(segfault)——就是前面提到的"守卫页"机制在起作用,防止栈溢出蔓延到其他内存区域。


程序内存的整体布局

理解这个问题的关键是了解程序的内存是如何分布的:

第一块是可执行代码区,存放程序编译后的指令,以及字符串常量等只读数据。第二块是全局可变变量区,存放程序全局作用域中定义的变量。第三块是,存放函数调用时的局部变量,大小固定且相对较小,函数退出时自动回收。第四块是,占程序内存的绝大部分,通过 malloc/free 手动管理,理论上可以使用大量内存。

这就是为什么当你需要存放一个大文件的内容时,直觉上会想到用堆来解决——堆足够大,不会因为文件稍大就溢出。


用 malloc 解决栈溢出问题

用堆内存来存放文件内容的写法如下:

  1. // 在堆上分配精确大小的缓冲区
  2. char* buffer = malloc(metadata.st_size);
  3. // 读取文件内容
  4. int bytes_read = read(fd, buffer, metadata.st_size);
  5. // 使用完毕后,必须同时关闭文件描述符和释放堆内存
  6. close(fd);
  7. free(buffer); // 顺序可以互换,但两者都必须做
复制代码

这样确实解决了栈溢出的问题,但同时引入了之前详细讨论过的 malloc/free 风险:忘记 free 导致内存泄漏,过早 free 导致 use-after-free,重复 free 导致 double-free。


更好的方案:分块读取(Chunked Read)

这里有一个关键的转折思路,也是本节课最重要的洞见之一。

read 函数本身有一个限制:当你请求读取的字节数超过操作系统允许的上限时(比如直接请求读 5 GB),操作系统会拒绝,返回比你要求的少得多的字节数。这意味着,即使你在堆上分配了足够大的缓冲区,单次 read 调用也未必能读完整个文件

正确的生产级做法是分块读取:选一个合理的固定块大小(比如 4096 字节或 64KB),每次只读一块,处理完之后再读下一块,循环直到文件结束。

这个认识带来了一个优雅的结论:如果我们无论如何都要分块读取,那缓冲区的大小本来就是固定的(等于块大小),那么完全没必要用堆——直接在栈上分配这个固定大小的缓冲区即可!

  1. // 固定块大小,直接在栈上分配,安全、快速、无需 free
  2. char buffer[4096];
  3. int bytes_read;
  4. while ((bytes_read = read(fd, buffer, sizeof(buffer))) > 0) {
  5. // 处理这一块数据
  6. }
复制代码

这种方式的优点是多方面的:不需要担心栈溢出(4096 字节对栈来说轻松得很);不需要调用 malloc,也就不存在 use-after-free 或 double-free 的风险;函数退出时栈内存自动回收,没有内存管理负担;性能更好,因为省去了 malloc/free 的开销。


栈为什么比堆快?

课程中有学生提问,这里值得详细说明。

读写栈和读写堆上的内存,从 CPU 操作内存的角度看,速度完全一样——都是读写某个地址处的字节,没有本质区别。速度差异完全来自 malloc 和 free 这两个函数本身的执行开销

malloc 需要在整块堆内存中找到一个空闲的、足够大的连续区域分配给你。为了做到这一点,它内部维护着复杂的数据结构(类似哈希表或链表),记录哪些范围已被使用、哪些是空闲的,还要尽量把相近大小的分配放在一起以减少内存碎片。free 则需要把那块内存标记回"空闲",同时更新这些数据结构。这些操作比"把栈指针移动 N 个字节"要复杂得多,代价也高得多。

相比之下,在栈上分配内存就是把栈指针移动相应的字节数——一条机器指令就能完成,几乎没有任何开销。


扩展话题:内存竞技场(Memory Arenas)

课程末尾提到了一种更高级的内存管理模式——内存竞技场(Memory Arena),可以理解为 malloc/free 的一种替代方案。

其核心思想是:一次性从系统申请一大块内存作为"竞技场",之后所有需要动态分配的小块都从这个竞技场内部切割出来,完全不需要对每个小块单独调用 free。等到整个竞技场的生命周期结束时,一次性释放整个竞技场即可。

这种模式既避免了反复调用 malloc/free 的性能开销,又极大简化了内存生命周期管理——不需要追踪每一个小分配,只需要追踪整个竞技场什么时候不再需要。这在游戏引擎、编译器、高性能服务器等对性能要求极高的场景下非常常见。


本节课及文件 I/O 部分总结

整个文件 I/O 模块的内容可以梳理成一条清晰的逻辑链:用 open 打开文件,获得文件描述符(记得最后 close);用 fstat 获取文件元数据(通过传入 struct 的地址),得到文件大小;用 read 将文件内容写入缓冲区,注意单次读取有上限;选择缓冲区的分配方式时,栈(固定大小)适合分块读取,简单高效;堆(malloc)适合需要跨函数生存的大块内存,但需要严格配对 free。最终结论是:通过采用分块读取策略,可以完全避免 malloc,用栈内存就能安全、高效地完成文件读取,这也是本课程最终实现的静态 Web 服务器所采用的方案。

17-file-i-o-exercise

课程概述

本节课是 Richard Feldman 主讲的 C 语言文件 I/O 练习(第四部分),重点讲解如何用 malloc 安全地读取文件内容,以及在有条件分支和提前返回的真实程序中正确管理内存与文件描述符。


练习背景与程序结构

程序会打印出两个 HTML 文件的内容:根目录下的 index.html(内容为 "Hello World")和 blog/index.html(内容为 "This is a blog")。这两个文件的存在是为了验证路径解析逻辑的正确性。程序的核心是一个名为 print_file 的函数,它接受一个从 HTTP 请求中解析出的路径,然后通过以下步骤读取文件:

  1. 调用 open() 获取文件描述符(fd)
  2. 创建 metadata 结构体,调用 fstat() 填充文件元数据
  3. 分配缓冲区,读取文件内容并打印

核心练习一:用 malloc 替换栈分配

原来的代码将缓冲区声明为栈上的数组(大小为 metadata.st_size + 1),这是不安全的——如果文件很大,会直接爆栈(stack overflow)。正确的做法是用 malloc 在堆上分配:

  1. #include <stdlib.h> // 必须引入,否则 malloc 未定义
  2. char *buff = malloc(metadata.st_size + 1);
  3. // +1 是为了存放字符串结尾的空终止符 '\\0'
复制代码

关键注意点:

malloc 在内存不足时会返回 NULL,必须检查:

  1. if (buff == NULL) {
  2. return; // 提前退出,避免向 NULL 写入数据(否则程序崩溃)
  3. }
复制代码

读取结束后,必须调用 free(buff) 释放堆内存,防止内存泄漏:

  1. free(buff);
  2. close(fd);
复制代码

核心练习二:为所有系统调用添加错误处理

C 标准库的系统调用函数在失败时通常返回 -1,需要逐一检查:

  1. // open() 失败时返回 -1
  2. if (fd == -1) return;
  3. // fstat() 失败时返回 -1
  4. if (fstat(fd, &metadata) == -1) return;
  5. // read() 失败时返回 -1
  6. if (bytes_read == -1) return;
复制代码

重点难点:条件分支中的资源管理陷阱

这是本课最核心的教学点。Richard 故意留下了若干错误,让学生发现,以此说明真实程序中资源管理的复杂性:

错误一:内存泄漏(Memory Leak)

在 bytes_read == -1 的提前返回路径中,忘记 free(buff)。因为 malloc 的分配是"长寿命"的(heap allocation),函数退出后这块内存不会自动释放,必须手动 free。

错误二:文件描述符泄漏(File Descriptor Leak)

在 buff == NULL 的提前返回路径中,open() 已经成功,但忘记 close(fd)。文件描述符是系统资源,不关闭会导致文件描述符耗尽(对长期运行的服务尤为危险)。

错误三:在正确位置不做 free

fstat 失败时,buff 尚未 malloc,此时不应调用 free;但 fd 已经打开,必须 close。每一个提前返回的路径都需要独立思考"此时哪些资源已被分配"。

总结规律如下:

提前返回位置需要 close(fd)?需要 free(buff)?
fd == -1(open 失败)否(fd 无效)否(未 malloc)
fstat 失败否(未 malloc)
buff == NULL(malloc 失败)否(malloc 返回 NULL)
bytes_read == -1(read 失败)
正常结束

深入理解:C 的内存安全声誉问题的根源

Richard 通过这个练习揭示了 C 语言内存不安全声誉的根本原因:在只有顺序执行时,"打开就关闭、malloc 就 free"的规则很直观;但真实程序充满了条件分支和提前返回,每一条路径都需要独立正确地处理资源释放。 这非常容易出错:

  • 内存泄漏(Memory Leak):忘记 free → 程序慢慢耗尽内存
  • Use-After-Free:free 之后还使用了那块内存 → 比内存泄漏更严重,导致未定义行为
  • Double-Free:对同一块内存 free 两次 → 比 use-after-free 还严重,可能导致安全漏洞

其他语言的解决方案对比

Rust:通过所有权系统在编译期自动管理内存,不需要手动 close 或 free,且没有运行时开销。Richard 提到他在 Frontend Masters 上有完整的 Rust 课程。

Zig / Jai / Odin:引入了 defer 关键字,允许开发者在打开资源的地方立即声明"函数结束时关闭它",无论是正常返回还是提前返回:

  1. // 伪代码示意
  2. open(fd, path);
  3. defer close(fd); // 无论函数如何退出,都会执行
  4. // ...后续代码无需担心 close
复制代码

defer 对文件描述符管理非常有效,但对于需要将 malloc 的内存返回给调用者的场景则力不从心(因为此时不能在函数结束时 free),这正是 Rust 所有权模型更强大之处。

不用 malloc 的方案:如果改用分块读取(chunked reads)并将缓冲区放在栈上,就完全不需要 malloc/free,整个类别的 bug 直接消失,还能获得更好的性能。但文件描述符的管理仍无法回避。


阅读 C 文档的方法

课程中以 man7.org 上的 open() 函数文档为例,介绍了如何读懂 C 文档:

  • const char *pathname:const 表示函数不会修改这块内存,是只读输入;char * 是 C 字符串
  • int flags:如 O_RDONLY(只读标志)
  • ...(可变参数):表示可选的额外参数(varargs),如 mode_t 类型的权限标志

掌握阅读 C 文档的能力非常有价值——无论是做跨语言互操作(FFI),还是在性能关键路径中嵌入少量 C 代码,都需要直接查阅和理解这类文档。Linux 文档和 BSD 文档大多数情况下可以互用,但在少数函数上存在重要差异(第六部分会专门讨论)。

18-open-socket-listen-for-connections

课程概述

本节课是 Richard Feldman 主讲的 C 语言网络 I/O 入门,承接上一节的文件 I/O,讲解如何在不依赖任何第三方库的情况下,仅用 POSIX C 标准库打开套接字(socket)并开始监听网络连接,为构建静态 Web 服务器奠定基础。


一、核心思想:文件描述符无处不在

C 标准库(libc)对文件描述符情有独钟,能用文件描述符解决的问题,它绝不另起炉灶。课程回顾了文件描述符已出现的场景:stdout 是文件描述符、open() 打开文件返回文件描述符,而现在——套接字同样是文件描述符,是一个普通的整数。

值得注意的是,Windows 的 Socket API 虽然形态上与此高度相似,但在技术层面并非真正的文件描述符,而是另一套独立的整数空间。本课聚焦于 UNIX/POSIX 体系。


二、第一步:socket() — 创建套接字

调用 socket() 函数即可创建一个套接字,它返回的就是一个套接字文件描述符:

  1. int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
  2. // AF_INET → 使用 IPv4 协议(若需 IPv6,此处改为 AF_INET6)
  3. // SOCK_STREAM → 使用 TCP 协议(可靠传输,适合 HTTP)
  4. // 0 → 协议号,通常填 0 让系统自动选择
复制代码

TCP vs UDP 的选择: UDP 发出数据包后不确认对方是否收到,常用于对实时性要求极高的游戏服务器;而 HTTP 与浏览器通信必须使用 TCP,因为它保证数据的可靠传递。


三、第二步:setsockopt() — 设置套接字选项(必要的样板代码)

  1. int opt = 1;
  2. setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
  3. // &opt → 传入 opt 变量的内存地址(函数会读取甚至修改它)
  4. // sizeof(opt) → 告知函数这块内存有多大
复制代码

这里出现了一个重要的 C API 模式:传入变量的地址(&opt),让函数直接操作那块内存。这与之前 memcpy、fstat 中传入结构体地址的模式完全一致——只不过这次传的是单个整数的地址。

为什么需要 SO_REUSEADDR? 不加它的话,在频繁停止/重启本地服务器时,系统会报错"Address already in use"(地址已被占用),即使服务器进程已经退出。设置 SO_REUSEADDR 后,端口不会在进程退出后继续被"占用",可以立即重用。sizeof 是 C 的一个语言关键字,返回的是变量在内存中占用的字节数(类似 JavaScript 的 typeof,它操作的是类型本身,而非值)。


四、第三步:bind() — 绑定端口

创建套接字后,它只是一个抽象的通信通道,还没有与任何端口关联。bind() 将套接字绑定到具体的 IP 地址和端口号:

  1. struct sockaddr_in address;
  2. memset(&address, 0, sizeof(address)); // 先将所有字节清零
  3. address.sin_family = AF_INET; // IPv4
  4. address.sin_addr.s_addr = INADDR_ANY; // 接受任意来源地址
  5. address.sin_port = htons(8080); // 端口 8080(htons 处理字节序)
  6. bind(socket_fd, (struct sockaddr *)&address, sizeof(address));
  7. // (struct sockaddr *) → 强制类型转换
  8. // sizeof(address) → 告知 bind 实际结构体大小
复制代码

为什么需要强制类型转换? bind() 被设计为同时兼容 IPv4(sockaddr_in)和 IPv6(sockaddr_in6)两种地址结构体,它接受一个通用的 sockaddr * 指针。两种结构体的头两个字段(地址族和端口)布局相同,bind() 只读取这部分公共字段。因此开发者需要将自己具体的结构体地址强制转换为 sockaddr *,同时传入 sizeof(address) 告知实际大小,防止 bind() 越界读取内存。

这是 C 中一种典型的"信任程序员"模式:转换时你在对编译器说:"这块内存的 1 和 0 我知道是什么形状的,请按我说的类型来解读它,不会出错的,相信我。"


五、第四步:listen() — 开始监听

  1. listen(socket_fd, 10);
  2. // 10 → 连接积压队列(backlog)的最大长度
复制代码

listen() 让操作系统开始接受发往该端口的连接请求。第二个参数 backlog 决定了在服务器来不及处理时,允许有多少个连接在队列中等待。设置过大会占用大量内存,且如果服务器处理速度跟不上,队列只会越积越长;设置过小则在高并发时会提前拒绝连接。合理的数值取决于预期的流量规模和请求处理速度。


六、完整流程回顾

  1. socket() → 创建套接字,获得 socket_fd(整数)
  2. setsockopt() → 配置选项(如 SO_REUSEADDR),避免重启报错
  3. bind() → 将 socket_fd 绑定到端口 8080
  4. listen() → 开始监听,设置连接等待队列长度
  5. 打印 "Listening on localhost:8080"
  6. (后续)accept() → 接受连接,read/write 处理请求
复制代码

七、关键 C 编程模式总结

本节课再次强化了两个贯穿整个课程的核心 C 编程模式。第一个是传地址让函数修改数据:无论是 memcpy、fstat,还是本节的 setsockopt 和 bind,C 标准库的惯用法都是接受一个指针(内存地址),直接在调用方的内存上原地修改,而非返回新值。第二个是sizeof 配合指针传递:每当将一块内存的地址传给函数时,通常还需要同时传入 sizeof,告知函数这块内存实际有多大,防止越界访问。理解这两个模式,就掌握了阅读和理解大多数 POSIX C API 的钥匙。

19-read-write-to-a-socket

课程概述

本节课是 Richard Feldman 主讲的 C 语言 Web 服务器系列的核心一讲,将前面所有章节的知识——文件 I/O、路径解析、套接字绑定——汇聚在一起,完成"从网络读取请求、解析路径、读取文件、将内容写回套接字"的完整闭环,实现一个真正可运行的静态 HTTP 服务器。


一、为什么用栈上数组而非 malloc 来接收请求数据

程序在进入主循环之前,先在栈上分配一个固定大小的缓冲区用于存放 HTTP 请求:

  1. char req[MAX_REQUEST_BYTES + 1]; // 栈上数组,+1 留给空终止符
复制代码

选择栈而非 malloc 有三重理由。性能方面,栈分配不需要系统调用,速度更快,也不需要 free,省去了内存管理的开销。安全性方面,预设一个最大请求字节数(MAX_REQUEST_BYTES)是必要的防御措施——如果允许客户端发来任意大小的请求,恶意攻击者可以发送一个数 GB 的请求,让服务器卡死在解析上,造成拒绝服务攻击(DoS)。简洁性方面,不用 malloc 就彻底规避了内存泄漏、use-after-free、double-free 这一整类 bug,正如上一节课所强调的。


二、主循环:while(1) 无限循环

  1. while (1) {
  2. // 等待连接 → 读取请求 → 解析路径 → 读文件 → 写响应
  3. }
复制代码

while(1) 是 C 代码库中极为常见的写法,语义与 while(true) 完全相同。静态 HTTP 服务器的本质就是"永远运行,直到被手动终止(Ctrl+C)",所以这个无限循环是设计上的必然,而非错误。程序里没有任何"正常退出"路径,唯一的退出方式是进程被外部信号杀死。


三、accept():为每个连接获取独立的文件描述符

  1. int request_fd = accept(socket_fd, (struct sockaddr *)&address, &addrlen);
复制代码

这里有一个关键的设计问题值得深思:为什么 accept() 要返回一个新的文件描述符,而不是直接复用 socket_fd?

答案与并发处理有关。想象一个多线程服务器:线程 A 接受了请求 1,线程 B 接受了请求 2,如果两者共享同一个 socket_fd,那么调用 read(socket_fd, ...) 时,操作系统根本不知道你要读哪个请求的数据。accept() 返回一个专属于这次连接的新文件描述符,解决了这个歧义——线程 A 拿着 fd=5 读请求 1,线程 B 拿着 fd=6 读请求 2,互不干扰。即便本课不涉及多线程,这个设计依然是 POSIX 套接字 API 的基础。

accept() 的另一个重要特性是阻塞(blocking):它会让程序完全暂停在这一行,不执行后续代码,直到真的有新连接到来为止。这就是服务器"等待"请求的实现机制。

由于 request_fd 是文件描述符,使用完毕后需要 close(request_fd),否则会产生文件描述符泄漏。


四、read():网络数据与文件数据的统一接口

  1. ssize_t bytes_read = read(request_fd, req, MAX_REQUEST_BYTES);
  2. if (bytes_read == -1) return; // 错误处理
复制代码

这里体现了 UNIX "一切皆文件" 哲学的精髓:读网络套接字和读磁盘文件,调用的是同一个 read() 函数,参数格式完全相同,区别仅仅是传入的文件描述符不同。读完之后,req 数组里装的就是浏览器发来的原始 HTTP 请求字符串,例如 GET /blog HTTP/1.1\\r\\n...。


五、完整请求处理流程

拿到原始请求字节后,后续步骤与之前章节完全一致,整个流程如下:

  1. accept() → 等待连接,获得 request_fd
  2. read(request_fd) → 将请求字节读入 req[]
  3. to_path(req) → 原地修改 req,解析出文件路径(如 blog/index.html)
  4. open(path) → 打开对应文件,获得文件 fd
  5. fstat(file_fd) → 获取文件大小
  6. read(file_fd) → 读取文件内容到缓冲区
  7. write(request_fd) → 将内容写回套接字,发送给浏览器
  8. close(file_fd)
  9. close(request_fd)
复制代码

其中 to_path() 会直接"踩踏"(stomp)req 缓冲区里的字节,将 HTTP 请求原地改写成文件路径。这没问题,因为此时我们已经不再需要原始请求内容了。


六、从"打印到标准输出"到"真正的 HTTP 服务器":一行之差

最初的版本将文件内容写到标准输出(write(1, contents, length)),并不发送给浏览器。让它成为真正 HTTP 服务器只需改一处:

  1. // 旧版:写到标准输出
  2. write(1, contents, length);
  3. // 新版:写回客户端套接字
  4. write(1, "HTTP/1.1 200 OK\\r\\n\\r\\n", 19); // 先发响应头
  5. write(request_fd, contents, length); // 再发文件内容
复制代码

HTTP 响应格式要求:先发状态行(HTTP/1.1 200 OK),再发一个空行(\\r\\n\\r\\n),然后才是内容体。这个最简响应头对于纯 HTML 文件已经足够,浏览器可以正确渲染。


七、write() 的一脉相承

从课程第一节的 write(1, "Hello, World!\\n", 14)(写到标准输出),到现在的 write(request_fd, contents, length)(写到客户端套接字),调用的是完全相同的 write() 函数。这再次印证了 UNIX 文件描述符的统一抽象:无论目标是终端、文件还是网络连接,写操作的接口永远一致。


八、章节总结:知识的汇聚

阶段关键操作文件描述符来源
开启服务socket() + bind() + listen()socket_fd(全局,整个服务器生命周期)
接受连接accept()request_fd(每次请求独立)
读网络请求read(request_fd, ...)复用 read(),与文件读取相同
解析路径to_path(req)原地修改缓冲区(第三部分的成果)
读文件open() + fstat() + read(file_fd)file_fd(第四部分的成果)
发送响应write(request_fd, ...)复用 write(),与 Hello World 相同

20-socket-exercise

课程概述

本节课是整个 C 语言 Web 服务器系列的练习环节(第五部分),Richard Feldman 带领学员将前几节课讲解的理论转化为可运行的完整代码,实现错误处理(400、404、500)、响应头发送,并揭示服务器当前的局限性(不支持图片、CSS、JavaScript),为最终章埋下伏笔。


一、里程碑:一个真正运行的静态 HTTP 服务器

程序启动后打印 "Listening on port 8080",在浏览器访问 localhost:8080 即可看到 index.html 的内容。更令人印象深刻的是,编辑 index.html 并保存后,无需重启服务器,刷新浏览器即可立即看到更新——因为每次请求都会重新从磁盘读取文件。整个服务器只有约 220 行 C 代码,却实现了实时响应、路径路由、文件读取的完整功能。


二、代码结构:handle_request() 辅助函数

为了代码组织清晰,练习将请求处理逻辑提取为独立的辅助函数:

  1. void handle_request(char *req, int socket_fd) {
  2. char *path = to_path(req); // 解析路径
  3. // 打开文件、读取内容、写回套接字...
  4. }
复制代码

主循环(while(1))只负责 accept() 和调用 handle_request(),关注点分离,结构更清晰。


三、完整的 HTTP 错误处理

练习的核心任务是将原来"打印到标准输出"的错误信息,改为真正发送给浏览器的 HTTP 响应:

HTTP 400 Bad Request:当 to_path() 返回 NULL(请求格式非法)时触发。

  1. const char *err_400 = "HTTP/1.1 400 Bad Request\\r\\n\\r\\n";
  2. write(socket_fd, err_400, strlen(err_400));
  3. return;
复制代码

HTTP 404 Not Found:当 open() 失败且 errno == ENOENT 时触发。ENOENT 是 C 标准库用来表示"文件不存在"的错误码,Node.js 报错中的 ENOENT 正是来源于此。

  1. if (fd == -1) {
  2. if (errno == ENOENT) {
  3. // 发送 404
  4. } else {
  5. // 发送 500(权限问题等其他错误)
  6. }
  7. }
复制代码

HTTP 500 Internal Server Error:用于 fstat() 失败、read() 失败、write() 失败等各类意外情况。由于 500 错误出现频率最高,可以提取为辅助函数:

  1. ssize_t write_500(int socket_fd) {
  2. const char *err = "HTTP/1.1 500 Internal Server Error\\r\\n\\r\\n";
  3. return write(socket_fd, err, strlen(err));
  4. }
复制代码

HTTP 413 Content Too Large:当请求体超过 MAX_REQUEST_BYTES 时触发,处理后无需提前返回,可以继续接受下一个请求。


四、一个有趣的调试现象:favicon 与双倍 500 错误

服务器运行时控制台会出现两条 500 错误,即使只刷新一次页面。原因是浏览器在请求页面内容的同时,会自动发起第二个请求来获取 favicon.ico(浏览器标签页上的小图标)。由于服务器目前不支持图片,这个请求会触发错误。这是一个典型的"看似神秘、实则有规律"的调试现象:当你观察到的请求数量比预期多时,首先考虑浏览器的自动行为(favicon、prefetch、service worker 等)。


五、初始化阶段的错误处理与请求处理阶段的区别

服务器启动阶段(socket()、bind()、listen())的错误处理不能发送 HTTP 错误响应,只能打印日志并退出,原因很简单:在完成 accept() 之前,服务器尚未与任何浏览器建立连接,根本没有可以写入的套接字。一个典型例子是:如果服务器已经在运行,再次启动会在 bind() 阶段报错 "Address already in use",此时直接退出是正确行为。只有进入 while(1) 循环、完成 accept() 之后,才真正拥有一个可以通信的客户端连接。


六、当前服务器的局限性:缺少 Content-Type 响应头

尝试用服务器托管一个有 CSS、JS 和图片的真实网页(如 Frontend Masters 主页),结果是页面样式完全错乱——所有 CSS、JS 加载失败,图片也无法显示。根本原因是服务器只发送了最简单的响应头:

  1. HTTP/1.1 200 OK\\r\\n\\r\\n
复制代码

浏览器需要 Content-Type 头来判断如何解析响应体。收到一堆字节却没有类型说明时,浏览器无从判断这是 CSS、JavaScript 还是图片数据,因此拒绝渲染。这个问题将在课程最终章中通过添加完整的响应头来解决。


七、关于 AI 自动补全的小插曲

课程中出现了一个有趣的现象:IDE 的 AI 自动补全"幻觉式"地补全了一个并不存在的辅助函数名。Richard 以此为例提醒:AI 代码补全有时会生成看似合理但实际不存在的函数调用,编译时才会暴露错误。这是使用 AI 辅助编程时需要保持警惕的典型情况。

21-bit-shifts

核心问题:C语言中的字符串比较

本节课从一个实际问题出发:在静态HTTP服务器中,需要根据请求文件的扩展名(如.png、.css、.js、.html)来决定返回的 Content-Type 响应头。

字符串比较的陷阱

在C语言中,字符串变量存储的是内存地址(指针),而非字符串内容本身。因此,写出如下代码:

  1. if (extension == "png")
复制代码

这个判断比较的是两个内存地址的数值是否相同,而不是字符串的内容。这意味着只有当 extension 和字面量 "png" 恰好指向内存中完全相同的位置时,条件才为真。这几乎永远不会发生。

同理,switch 语句也无法用于字符串比较,因为它同样只比较数值(内存地址)。

标准解法:strcmp

C标准库提供了 strcmp(string compare)函数来解决这个问题。它接受两个以 null 结尾的字符串的内存地址,逐字节比较,直到发现不同的字节或同时到达两个字符串的末尾(null 终止符)。相等时返回 0,否则返回非零值。

  1. // 注意:返回0表示相等,所以用 ! 取反
  2. if (!strcmp(extension, "png")) {
  3. content_type = "image/png";
  4. }
复制代码

但这种方式的缺点是:支持的扩展名越多,就需要做越多次的逐字节比较。判断是否为 html,需要先依次与 png、css、js 比较失败后,才能轮到 html。


进阶技巧:将字符串转换为整数

讲师介绍了一种更快、更符合人体工程学的 C 语言特定技巧,它利用了我们在第二部分学到的核心思想:内存中的比特模式,由我们自己决定如何解读

核心思想

文件扩展名最多为4个字节(如 html)。一个32位整数也恰好是4个字节。因此,可以将扩展名的字节序列重新解读为一个整数,然后用整数比较来替代字符串比较。整数比较是CPU的原生操作,速度极快,且可以使用 switch 语句。

例如,html 四个字符对应的字节是:

  • h = 104
  • t = 116
  • m = 109
  • l = 108

这四个字节拼在一起,可以被解读为一个32位整数 1,752,460,652,它在本质上"就是 HTML"。对于 png 这样只有3字节的扩展名,只需在末尾补一个零字节即可凑成4字节。


位移(Bit Shifts):避免硬编码"魔法数字"

直接硬编码 1,752,460,652 这样的数字完全不可读。这就是引入位移(bit shift)操作的原因。

位移的基本概念

位移操作将一个整数的所有比特向左或向右移动若干位。

左移1位等价于乘以2,左移N位等价于乘以 $2^N$。编译器会自动将"乘以2的幂次"的操作优化为位移指令,因为位移比乘法在 CPU 层面上要快得多。

需要注意的是,C语言规范将整数溢出定义为未定义行为(undefined behavior),这意味着编译器在这种情况下字面上可以做任何事情(讲师幽默地称之为"让哥布林从你鼻子里飞出来")。

用位移构造整数:to_int 函数

通过对各个字节进行策略性的位移,再用按位或(bitwise OR)将它们组合,可以将多个字节拼装成一个整数:

  1. // 将 'h', 't', 'm', 'l' 四个字节组合成一个32位整数
  2. // h 在最低位,不移动
  3. // t 移动8位(1字节)
  4. // m 移动16位(2字节)
  5. // l 移动24位(3字节)
  6. // 然后用 | (按位或) 将它们合并
  7. int html_int = 'h' | ('t' << 8) | ('m' << 16) | ('l' << 24);
复制代码

按位或的作用是:对应位中只要有一个为1,结果就为1。由于位移后的字节彼此占据不同的位区间,按位或操作相当于安全地将它们"拼接"在一起,不会互相干扰。

由此可以封装一个 to_int 函数:

  1. // to_int("png", 0) 或 to_int("html", 0) 等
  2. // 这比直接写 1886283520 可读性高太多了
复制代码

这样,原来难以维护的硬编码数字,变成了清晰可辨的函数调用,可以配合 switch 语句高效地处理所有扩展名的比较。


关于字节序(Endianness)的说明

讲师最后提到,这些整数的具体数值会因处理器的字节序(Endianness)不同而有所变化。字节序是一个来自《格列佛游记》的玩笑词,指的是处理器以大端(big-endian)还是小端(little-endian)方式存储多字节数据。本课程的所有参与者使用相同字节序的处理器,因此不必担心这个问题,但这是一个值得了解的概念。

22-preprocessor-macros

问题背景:为什么 const 不够用?

上一节我们设计了一个聪明的技巧:将文件扩展名的字节序列解读为一个32位整数,从而可以用 switch 语句替代大量的 strcmp 调用。但实现这个技巧时,我们遇到了一个新的障碍。

理想中,我们希望写出这样的代码:

  1. const int HTML = to_int("html");
复制代码

然而这在C语言中行不通,原因有两个。第一,部分C编译器不允许将 const 变量用于 switch 语句的 case 标签。第二,更根本的问题是,C语言规定在顶层(全局作用域)声明 const 变量时,不允许调用任意函数——to_int 这样的函数调用根本无法在这个阶段执行。

作为对比,讲师提到了 Zig 语言的 comptime 特性。Zig 允许开发者将普通函数标记为"在编译期执行",从而完全消除了这类问题。但 C 语言设计时没有这个概念,所以我们需要另辟蹊径。


解决方案:预处理器(Preprocessor)

什么是预处理器?

C 编译器实际上分为两个独立的阶段运行。第一阶段是预处理器(Preprocessor),它在真正的编译工作开始之前运行,本质上是一个功能极其有限的"查找并替换"工具,它的工作是对源代码文本进行字符串层面的复制粘贴,不做类型检查,不理解语言语义。第二阶段才是编译器本体,它接收预处理器处理过的代码,进行类型检查、语法分析和代码生成。

所有以 #(pound)开头的指令都属于预处理器指令。我们之前已经见过一种:#include,它的作用就是字面上的"把那个文件的全部内容复制粘贴到这里"。

#define:定义宏

#define 是另一种预处理器指令,其核心含义同样是"查找并替换":

  1. // 定义一个简单常量宏
  2. // 作用:将代码中所有出现 PORT 的地方,替换为 8080
  3. #define PORT 8080
复制代码

这与类型安全的 const int port = 8080; 截然不同。编译器根本看不到 PORT 这个名字——在编译器介入之前,预处理器已经把所有 PORT 机械地替换成了 8080。没有类型检查,没有作用域,完全是文本替换。

函数式宏(Function-like Macros)

#define 还可以接受参数,模拟函数调用的形态,这就是预处理器宏(Preprocessor Macros)

  1. // 定义一个接受四个字符参数的宏,执行位移和按位或操作
  2. // 反斜杠 \\ 表示"这行定义延续到下一行"(因为宏没有花括号定界)
  3. #define FOUR_CHAR(a, b, c, d) \\
  4. ((a) | ((b) << 8) | ((c) << 16) | ((d) << 24))
复制代码

当预处理器看到代码中的 FOUR_CHAR('h','t','m','l') 时,它不会调用任何函数,而是直接将整个表达式的文本展开替换到调用处。到编译器真正开始工作时,它看到的已经是展开后的纯表达式,没有任何函数调用。

这就巧妙地绕开了"顶层不能调用函数"的限制——因为从编译器的视角来看,根本就没有函数调用发生。


最终方案

综合运用 const 与宏,我们可以写出既可读又可在 switch 中使用的代码:

  1. // 方案一:用 const(在支持的编译器上)
  2. const int HTML = FOUR_CHAR('h', 't', 'm', 'l');
  3. const int CSS = FOUR_CHAR('c', 's', 's', 0 );
  4. const int JS = FOUR_CHAR('j', 's', 0, 0 );
  5. // 方案二:如果编译器不支持 const 用于 switch,则直接用 #define
  6. #define HTML FOUR_CHAR('h', 't', 'm', 'l')
复制代码

方案二中,HTML 本身也变成了一个宏,每次引用都会被展开为完整的位运算表达式,从而彻底绕开所有编译器的限制。


这套技巧的价值与代价

讲师坦诚地指出,如果只是个人项目,他会直接用 strcmp,不会费心做这些优化。这套技巧的价值在于两点:一是性能,整数比较是CPU的原生操作,远快于逐字节的 strcmp;二是让我们得以使用 switch 语句,代码结构更清晰。

但代价也是真实的。这是一套极度依赖特定领域知识的脆弱技巧——它只在扩展名不超过4字节时成立,它依赖字节序的一致性,它牺牲了类型安全,它用宏替代了真正的函数。

讲师最后将这类技巧放在了更宏观的C语言哲学中来理解:这正是C语言拥有"最高性能上限"的原因。像《Quake》和《Doom》这样的早期游戏,就是靠大量类似的内存层面技巧(乃至直接写汇编)来实现当时人们认为不可能的图形效果。C语言给了程序员这把"双刃剑"——你可以做任何其他高级语言做不到的性能优化,但你同时也承担了所有内存不安全带来的风险。

23-platform-specific-macros

回顾:sendfile 的引入动机

在此之前,我们实现的静态HTTP服务器处理文件请求的方式是:先将文件内容从文件描述符读入一个本地缓冲区(buffer),再将缓冲区的内容通过 socket 文件描述符写出去。这个流程有两个问题。第一,它需要额外分配和管理一块内存缓冲区,增加了代码复杂度。第二,更重要的是,它在操作系统内核用户空间(即我们自己的程序代码)之间制造了不必要的"交接"——数据先从内核的文件系统读进用户空间的缓冲区,再从用户空间写回内核的网络栈。这种来回切换本身就有性能开销。

sendfile 函数解决了这个问题。它接受一个源文件描述符、一个目标文件描述符和一个字节数,直接告诉操作系统:"你来负责,把这么多字节从这里搬到那里。"整个过程在内核内部完成,无需经过用户空间的缓冲区。讲师认为,这很可能是本课程实现的静态服务器在基准测试中超越 Rust 等其他实现的最重要原因之一。


核心问题:同名函数,不同接口

然而,sendfile 带来了一个麻烦。Linux 和 macOS 上都有这个函数,名字完全相同,目的也完全相同,但它们的函数签名(类型和参数列表)完全不同

Linux 版本(需要 #include )的签名大致如下:

  1. // Linux 版本:返回 ssize_t,参数清晰直接
  2. ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
复制代码

macOS 版本(基于 BSD,需要三个不同的头文件,其中没有一个叫 sendfile.h)的签名则截然不同:

  1. // macOS/BSD 版本:返回 int,参数顺序和类型都不同,还多了额外的结构体参数
  2. int sendfile(int fd, int s, off_t offset, off_t *len, struct sf_hdtr *hdtr, int flags);
复制代码

这就是讲师在课程开头提到的"可移植汇编"概念的真正边界所在。C语言作为语言本身是可移植的,但各操作系统提供的系统库并不总是一致的。到目前为止,课程中使用的 open、read、write 等函数恰好在 Linux 和 macOS 上是兼容的,所以我们没有遇到这个问题。sendfile 是我们遇到的第一个真实的平台差异案例。


解决方案:平台特定的预处理器宏

我们不需要为此维护两套代码库。上一节学到的预处理器正好提供了处理这种情况的工具:#ifdef(if defined)。

它的工作原理是:当 C 编译器为特定平台编译代码时,它会预先执行一些 #define,来"宣告"当前的目标平台。例如,为 Linux 编译时,编译器会自动定义 __linux__;为 macOS 编译时,会定义 __APPLE__。我们的代码可以检测这些预定义的宏,从而在同一份代码中包含针对不同平台的不同实现:

  1. // 先处理头文件的平台差异
  2. #ifdef __linux__
  3. #include <sys/sendfile.h> // 只在 Linux 上才存在这个头文件
  4. #endif
  5. // 然后在实际调用处,区分平台实现
  6. #ifdef __linux__
  7. // 使用 Linux 版本的 sendfile
  8. sendfile(socket_fd, file_fd, NULL, file_size);
  9. #elif __APPLE__
  10. // 使用 macOS/BSD 版本的 sendfile(参数完全不同)
  11. off_t len = file_size;
  12. sendfile(file_fd, socket_fd, 0, &len, NULL, 0);
  13. #else
  14. // 如果两者都不是(例如 Windows),有两种选择:
  15. // 选项一:触发一个编译期错误,明确告知不支持
  16. #error "sendfile not supported on this platform"
  17. // 选项二:回退到低效但可用的"读入缓冲区再写出"方案
  18. #endif
复制代码

#elif 是预处理器的"else if",#endif 标记条件块的结束。正如讲师所说,这套语法(#ifdef、#elif、#endif、#define)构成了一套功能极其有限的"元编程语言",这也正是 Zig 设计 comptime 的动机——与其维护一套平行的预处理器语言,不如直接让开发者用同一种语言写编译期逻辑。


延伸:Windows 的情况

讲师指出,Windows 平台的差异会更加显著。不只是 sendfile,连 open、read、write 这些最基本的系统调用在 Windows 上都有不同的名称和 API。理论上,要支持 Windows,需要在代码库中几乎每一处系统调用的地方都加上 #ifdef _WIN32 ... #else ... #endif 这样的条件块。

这个现实催生了跨平台标准库的需求。开发者可以将所有平台差异封装在一套统一的接口函数后面,让上层代码无需感知底层差异,然后将这个封装层发布为可复用的库。这正是许多知名 C/C++ 跨平台库存在的核心原因之一。


本章总结

讲师在最后对过去几节课的内容做了一个完整的串联回顾。我们从位移与整数比较出发,解决了字符串 switch 的性能问题;用预处理器宏(#define)改善了代码的可读性,避免了神秘的硬编码数字;最终用平台特定宏(#ifdef)解决了 sendfile 在 Linux 与 macOS 之间的 API 差异。这三个主题共同展示了 C 语言在"贴近硬件"和"跨平台抽象"之间如何用有限的工具寻求平衡。

24-web-server-image-support-exercise

练习概览:补全最后一块拼图

这是整个课程的最终练习。此前,我们已经实现了:解析 HTTP 请求、识别文件扩展名、用整数比较技巧高效地确定 Content-Type 响应头,以及通过平台特定宏处理 Linux 与 macOS 的代码差异。唯一缺失的部分,是真正将文件内容发送出去的那一步——也就是调用 sendfile。整个练习的核心任务就是:查阅 Linux 和 macOS 两份文档,分别实现对应的 sendfile 调用。


代码结构说明

讲师指出了一个值得注意的细节:在 #ifdef 块内部声明的变量(如 bytes_sent 和 send_failed),并不会创建一个新的作用域。这与 if 语句后面跟花括号 {} 的情况不同——花括号才是C语言中作用域的边界。#ifdef 纯粹是预处理器的文本替换,编译器看到的代码中根本没有这个分支结构,只有被选中的那一段代码。

这意味着,虽然这两个变量在视觉上缩进在 #ifdef 块里,但它们实际上属于同一个函数作用域,可以在 #endif 之后继续使用。两个平台上变量的类型略有不同,但C语言宽松的整数类型转换使得后续的统一处理(如做减法)仍然可以正常工作。


Linux 版本的 sendfile

Linux 的 sendfile API 设计相对直观,其签名为:

  1. ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
复制代码

它的行为与我们已经熟悉的 read 和 write 非常相似:函数返回值就是实际成功发送的字节数;如果发生错误,则返回 -1。

这里有一个关键细节:函数告诉你"你想发送N字节",但实际发送的可能比N少。这并不一定代表错误,而是一种正常的"部分发送"情况。因此,正确的使用方式是将其放在一个循环中:

  1. // 每次循环后,根据实际发送量更新剩余字节数
  2. // 如果返回 -1,说明真的发生了错误,跳出循环
  3. while (remaining_bytes > 0) {
  4. ssize_t bytes_sent = sendfile(socket_fd, file_fd, NULL, remaining_bytes);
  5. if (bytes_sent == -1) {
  6. send_failed = 1;
  7. break;
  8. }
  9. remaining_bytes -= bytes_sent; // 减去本次已发送的,继续循环
  10. }
复制代码

这个循环机制,实际上就是我们处理"分块传输"的方式。


macOS(BSD)版本的 sendfile

macOS 的版本来自 BSD,其 API 设计风格截然不同,也更加"C风格"——即通过修改传入参数的值来返回信息,而非通过函数返回值:

  1. int sendfile(int fd, int s, off_t offset, off_t *len, struct sf_hdtr *hdtr, int flags);
复制代码

最关键的区别在于第四个参数 off_t *len:调用前,你将想要发送的字节数写入这个地址;调用后,sendfile 会把这个值覆盖为实际发送的字节数。函数本身的返回值(int)只用来指示成功(0)或失败(-1)。

  1. // 先将想发送的字节数赋值给变量,再传入其地址
  2. off_t bytes_to_send = stats.st_size;
  3. int result = sendfile(file_fd, socket_fd, 0, &bytes_to_send, NULL, 0);
  4. // 调用之后,bytes_to_send 的值已被 sendfile 改写为实际发送的字节数
复制代码

此外,macOS 版本还接受额外的 sf_hdtr 结构体和 flags 参数,提供了更多的配置选项,这体现了不同操作系统在同一功能上演化出不同扩展能力的现象。


错误处理的局限性

讲师坦诚地讨论了一个在生产环境中重要但本练习中未深入处理的问题:如果 sendfile 在发送过程中途失败,我们已经向客户端发送了 200 OK 的响应头,却无法再收回它。浏览器已经认为一切正常,但却只收到了截断的文件内容。

对于一个真正的生产级HTTP服务器,可能的补救策略包括在第一次循环前检测是否已发送任何数据(若无则发送 500 错误)、尝试重试发送剩余字节,或发送一个特殊的截断提示。但这些边界情况的处理并非本课程的重点,而且这个问题并不是 sendfile 特有的——任何写入 socket 的操作中途失败都会面临同样的困境。


最终成果

完成 sendfile 的实现后,整个静态HTTP服务器便可以正确地提供HTML、CSS、JS、PNG、JPEG等各类文件,并附带正确的 Content-Type 响应头。讲师展示了用约 300行 C 代码零第三方依赖,从操作系统原语出发构建出的服务器,成功在本地托管了一份完整的 Frontend Masters 主页——包括所有的 HTML、CSS、字体和图片资源,运行速度极快。

这个结果也是对整个课程的一个有力总结:C语言的能力与代价是相互绑定的,它给了你贴近金属、掌控一切的能力,而你需要以承担所有复杂性作为交换。

25-wrapping-up

课程核心收获:一个最重要的心智模型

讲师将整个课程最核心的概念浓缩为一句话:内存就是一个巨大的字节数组,而这些一和零,你可以选择以任何方式去解读它们。 这不只是一个关于C语言的技术事实,而是一种看待计算机底层世界的根本方式。理解了这一点,指针不再神秘——它不过是一个整数,告诉你这个巨大字节数组中的某个位置。理解了这一点,我们在第六部分做的那些"野蛮"技巧——把字符串字节重新解读为32位整数——就有了坚实的概念基础。


C语言的历史定位与流行原因

C语言诞生于1972年,最初是在资源极其有限的PDP-11计算机上,用电传打字机(teletype)编写出来的。讲师特意强调,我们今天写的C代码,在语言层面上和那时的几乎没有本质区别——只是运行它的机器强大了几个数量级。

C语言至今仍是全球前十最流行的语言,核心原因只有一个:性能。当你需要构建操作系统、数据库、编程语言编译器或语言运行时这类系统级软件时,你需要的不只是"足够快",而是"去掉所有安全护栏之后能达到的极限速度"。C++在理论上可以达到同等性能,Rust、Zig、Odin、JAI也可以,但C拥有五十多年的历史积累,生态系统的根基远比这些新兴语言深厚。

课程本身也是一个生动的佐证:用约300行C代码、零第三方依赖构建的静态HTTP服务器,在讲师自己设计的基准测试中超越了那些拥有更多开发资源和更广泛使用量的Rust等语言实现。


"可移植汇编"的边界

讲师回顾了课程开头提出的"可移植汇编"概念,并诚实地承认了它的局限性。在编译产物(机器指令)层面,C确实高度可移植;但在系统库层面,不同操作系统之间存在真实的差异,sendfile 的例子就是最直接的说明。所以C并不是"写一次,到处运行"的银弹,但它相比直接写汇编语言,仍然是一次巨大的人体工程学飞跃。


C语言与其他语言的桥接

讲师强调了C语言作为"语言间的通用语"的地位。几乎所有主流语言都支持通过某种形式调用C代码,因为C是事实上的跨语言互操作标准。以JavaScript/Node.js为例,Node.js提供了完整的文档,说明如何编写C语言的原生插件(Node.js Add-ons)。如果你的Node.js服务器有一个性能瓶颈,你可以把那一小块逻辑用C重写,然后直接从JavaScript中调用。如果你更喜欢Rust,也可以用C作为中间层,因为Node.js的底层API是C接口,而C++(Node.js官方支持的语言)是C的超集。讲师本人就有过为Node.js编写C插件并获得报酬的实际经历。


延伸学习资源推荐

构建大型C项目: 对于单个.c文件,编译非常简单。但面对Postgres或Linux内核这样的巨型C/C++代码库时,仅仅是"如何把它编译出来"就是一门学问。讲师推荐Zig语言创始人Andrew Kelley在2023年"Software You Can Love"大会上的演讲《How to Build Software from Source》,其中有大量实用的构建技巧。

经典书籍K&R: 《The C Programming Language》由Brian Kernighan和C语言创始人Dennis Ritchie合著,是公认的C语言圣经。讲师特别推荐第一版,因为其中保留了关于早期硬件的历史趣闻,以及关于C语言编程哲学的独特见解——而这些哲学与现代高级语言的最佳实践往往截然不同,读来颇具启发性。

性能优化深潜: 如果想深入挖掘性能优化,讲师推荐两个资源。Agner Fog的网站(agner.org)提供了极其详细的底层硬件和CPU优化文档,内容密集但质量极高。Casey Muratori的课程《Performance Aware Programming》则更为系统,课程从亲手构建一个老式CPU的模拟器开始,逐步深入到现代CPU的各种复杂特性,并通过实际演示展示了同一个Python程序在C语言重写后可以快约一万倍的量级差距。


C语言的低开销替代品横向对比

讲师最后对当前流行的C语言替代品做了一个简明的横向梳理。

C++ 可以达到与C相同的性能上限,但它可能是目前最复杂的主流语言,学习曲线极为陡峭。Rust 复杂度同样很高,但相比C++提供了更强的内存安全保证和更好的人体工程学,且不受历史包袱的拖累。D语言 历史悠久、功能强大,但在社区关注度和生态增长上一直不温不火。Zig 则是目前最受瞩目的新兴选手,其设计哲学是"改进C,但不增加过多复杂性",以 comptime 等特性替代C的预处理器宏,受到越来越多开发者的青睐。OdinJAI 是更小众的选项,走的是与Zig相似的"简化路线",其中JAI由游戏《Braid》和《The Witness》的创作者Jonathan Blow基于对C++的长期不满而开发,专为游戏领域设计,目前仍未公开发布。

值得一提的是,所有这些语言都支持与C代码互操作,因为C是跨语言交流的通用标准——而现在,你已经掌握了这门最具普遍性的语言。

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

中国红客联盟公众号

联系站长QQ:5520533

admin@chnhonker.com
Copyright © 2001-2026 Discuz Team. Powered by Discuz! X3.5 ( 粤ICP备13060014号 )|天天打卡 本站已运行