用 aarch64 汇编构建 Web 服务器,给我的生活(缺乏)意义

Lobsters Hottest 工具

摘要

本文介绍了 'ymawky',一个完全用 aarch64 汇编为 macOS 编写的最小 HTTP Web 服务器,使用原始系统调用而无需 libc 包装器,以探索底层系统机制。

<p><a href="https://lobste.rs/s/wfqsc4/building_web_server_aarch64_assembly">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/08 10:56

# ymawky 来源:https://imtomt.github.io/ymawky/ ## 在 aarch64 汇编中构建一个 web 服务器,给我的生命(缺乏)一点意义 ymawky 是一个小型静态 HTTP web 服务器,完全用适用于 macOS 的 aarch64 汇编编写。它使用原始 Darwin 系统调用,**没有** libc 包装器,提供静态文件,支持 `GET`、`HEAD`、`PUT`、`OPTIONS`、`DELETE`、字节范围、目录列表、自定义错误页面,并尽可能做到安全加固。 为什么?为什么不呢?ymawky 让 80 年代的梦想依然鲜活。人人都有 nginx。用 Apache 就太老土了。那为什么不干脆剥离自 1957 年以来计算机科学赋予我们的每一层便利?我想深入了解一个 web 服务器究竟是如何工作的——对于出身底层/系统背景的我来说,对此知之甚少。那些出现的安全风险、需要解决的问题,以及你在写 Python 或 C 时根本不会想到的事情。这个(**大概**)不会取代 nginx,但它确实在以最困难的方式完成某些工作。 *ymawky 目录列表* *ymawky 提供目录列表* ## 约束条件 我为这个项目设定了一些约束: - 仅限 aarch64 汇编 - 仅限 macOS/Darwin,而非 Linux。只因为我现在手头的系统就是这个。抱歉 Linux 爱好者们 :( - 仅使用原始系统调用:**没有** libc 包装器 - 仅限静态文件 - 没有现成的解析器 - **绝对没有**外部库 ## 汇编,吾爱 汇编语言是机器代码与其他语言之间的桥梁。C 被编译成汇编,然后汇编被组装成可执行二进制文件。汇编本质上是直接对应原始可执行字节的人类可读助记符:`mov`、`add`、`ldr`、`str`、`cmp` 等。`svc #0x80` 是对应于可执行二进制文件中字节 `D4 00 10 01` 的易读表示。你几乎得不到任何抽象。你在 CPU 寄存器和内存之间移动值、比较它们、跳转到代码的不同部分,并调用内核进行系统调用。它让简单的事情看起来复杂,但也让 CPU 执行的几乎每一步都可见且受你控制。它完全按照你告诉它的去做,没有警告,也没有任何帮助。如果行为不正确,那是因为**你**写错了。 在汇编中编写 web 服务器意味着没有 HTTP 库。没有自动清理。没有字符串类型:字符串只是按顺序保存单个字节的内存区域。C 语言中的 `struct` 作为语言特性并不真正存在。你必须知道每个字段之间的精确字节偏移量以及结构体的总大小,否则 CPU 会愉快地读取错误的内存。 ## 原始系统调用 ymawky 不使用任何 libc 包装器,只使用对内核的原始调用。例如,这段打开文件的代码: ``` mov x16, #5 ; SYS_open 系统调用号 adrp x0, filename@PAGE add x0, x0, filename@PAGEOFF mov x1, #0x0 ; O_RDONLY 就是 0x0000 svc #0x80 b.cs open_failed ``` 在 Darwin 中,系统调用号放在 `x16` 寄存器中(在 aarch64 Linux 中,它放在 `x8` 中)。系统调用号 5 是 `open()`,它接受几个参数:文件名和模式。你手动将每个参数放入寄存器,然后用 `svc #0x80` 调用内核。如果 `open()` 失败,进位标志会被设置。我们用 `b.cs open_failed` 来检查,意思是“如果进位标志被设置,则跳转到 `open_failed`”。然后我们必须编写 `open_failed` 来处理所需的清理和响应。这种情况经常发生。汇编没有“异常”或“对象”,它只是设置一个 CPU 标志,你需要检查并处理。 ## 总体概述 最基本地,web 服务器接收一个请求,处理它,返回一个状态码,可能还有一个文件。“接收请求”这一步涉及很多内容: - 用 `socket(AF_INET, SOCK_STREAM, 0)` 设置套接字 - 用 `setsockopt(serverfd, SOL_SOCKET, SO_REUSEADDR, &buf, sizeof(int))` 配置套接字 - 用 `bind(sockfd, &addr, 16)` 将文件描述符绑定到一个地址 - 用 `listen(sockfd, 5)` 监听套接字的新连接 - 用 `accept(sockfd, NULL, NULL)` 接受一个连接 ymawky 是一个按请求 fork 的服务器。这意味着对于每个新的入站连接,它都会调用 `fork()` 系统调用。这有一些优点: - 请求处理程序之间不共享内存 - 更容易理解 - 更容易编写 但它也有一些相当明显的缺点: - 臃肿 - 每个进程都有自己的内存空间 - 从根本上来说,它处理的并发连接数少于像 nginx 事件驱动异步非阻塞模型那样的模型 - 并发连接数越多,内核花在进程间切换上的时间就越多,而不是真正**在**进程中执行任务 - 我提过臃肿和内存消耗吗? 绑定到套接字并监听是容易的部分。真正令人崩溃的任务是处理请求。这涉及很多内容: - 确定请求类型:`GET`、`HEAD`、`OPTIONS`、`PUT` 或 `DELETE` - 提取请求路径 - 标准化路径,例如将 `%20` 解码为空格 - 对路径执行安全检查 - 解析客户端发送的头部字段 - 获取所请求文件的信息 - 判断它是目录还是普通文件 - 为 `PUT` 请求将上传主体写入临时文件 - 构建响应头部 - 写出响应——这不知为何并不直接 - 关闭所有打开的文件 - 处理错误而不使服务器崩溃 ## 手动解析 HTTP 我**讨厌**字符串解析。**尤其是在汇编中**。不幸的是,HTTP 请求只是一个要求服务器做某事的字符串,而服务器必须理解它。让我们逐步分析一个示例 HTTP 请求: ``` GET /index.html HTTP/1.0\r\n Range: bytes=1-5\r\n \r\n ``` 第一行告诉我们很多信息。这是一个 `GET` 请求,意味着客户端希望我们发送 `index.html`。`HTTP/1.0` 告诉服务器客户端使用的 HTTP 版本。`\r\n` 序列(回车加换行)告诉服务器“这一行到此结束,请处理下一行”。最后的 `\r\n\r\n` 告诉服务器头部结束。如果我们从未接收到 `\r\n\r\n`,我们必须以 `400 Bad Request` 退出。然后是 `Range: bytes=3-5`,意思是“从这个文件中,只给我字节 3 到 5,忽略其余部分”。如果一个文件有 500GB,但你只请求字节 3 到 5,你只会收到 3 个字节。**耶!** 不幸的是,我必须处理那个头部。**呸!** 首先,ymawky 通过将前几个字节与其支持的所有方法进行比较来确定请求类型,然后提取路径。我们逐字节扫描头部,直到找到 `/` 或 `*`。但我们不能假定每个 `/` 都是请求的路径。如果有人发送 `GET / HTTP/1.0`,在 `HTTP/1.0` 中也有一个 `/`。一旦我们遇到 `/`,我们检查**前一个**字节是否是空格。如果不是,我们回复 `400 Bad Request`。 一旦找到路径,我们需要一个地方来存储它。在大多数系统上,`PATH_MAX` 是 4096 字节,所以 ymawky 有一个 4096 字节的文件名缓冲区加上一个字节的空终止符: ``` .bss filename_buffer: .skip 4097 .align 3 ``` 复制文件名只是一个循环,但循环必须不断地检查两个方面:不要读取超过头部,也不要写入超过文件名缓冲区。如果客户端请求 `GET /aa....[5000 A]...a HTTP/1.0`,他们应该得到 `414 URI Too Long`,而不是覆盖 5KB 的任意内存。在 Python 中,这就像: ``` text.split("GET /")[1].split(" ")[0] ``` 在汇编中,这大约有 200 行长,包括确保 HTTP 合法性。汇编是不是最棒的语言? 然后路径必须进行百分号解码。如果解析器看到 `%`,它必须读取接下来的两个字节,验证它们是有效的十六进制字符(`0-9`、`a-f`、`A-F`),将它们转换回它们所代表的字节,然后继续。 `GET` 请求可以有一个 `Range:` 头部,而 `PUT` 请求需要 `Content-Length:`。与请求的 URL 不同,这些可以出现在头部的任何一行。我们必须逐字符迭代头部。如果找到 `\r`,我们需要检查下一个字符是否是 `\n`。如果不是,则头部格式错误,我们必须发送 `400 Bad Request`。同样,如果我们找到 `\n` 而没有前面的 `\r`,那也是格式错误。一旦我们找到 `\r\n`,这标志着当前行的结束和下一行的开始。我们检查这一新行是否以空格开头,如果是则发送 `400 Bad Request`(头部字段不能以空白字符开头)。然后我们检查 `Range:`(或 `Content-Length:`,取决于方法),使用一个小字符串比较函数: ``` streqn: ldrb w3, [x0] ldrb w4, [x1] cmp w3, w4 b.ne Lstreqn_no_match cbz w3, Lstreqn_match ;; both equal and both NULL = end of string = match ;; if we've reached the end, it's a match yeah? subs x2, x2, #1 b.eq Lstreqn_match add x0, x0, #1 add x1, x1, #1 b streqn Lstreqn_match: mov x0, #1 ret Lstreqn_no_match: mov x0, #0 ret ``` 这个函数接受两个字符串指针 `x0` 和 `x1`,以及 `x2` 中的最大长度,并检查每个字符是否相同。 让我们看看一个 `Range:` 头部可能是什么样子: ``` Range: bytes=10- Range: bytes=-10 Range: bytes=5-10 ``` 范围的两边都是可选的,但至少需要有一边。由于“10”是一个字符串而不是字面数字 10,每一边都必须从 ASCII 数字转换为整数。我们必须编写一个类似 `atoi` 的函数,注意检查整数溢出: ``` ;; x0 -> pointer to string atoi: mov x1, #0 mov x3, #10 mov x4, #0 1: ; if the number is >=19 digits long, it could overflow the 64-bit registers cmp x4, #19 b.hs Latoi_error ldrb w2, [x0] cbz w2, 2f cmp w2, #'0' b.lo Latoi_error cmp w2, #'9' b.hi Latoi_error ; result = (result * 10) + current digit mul x1, x1, x3 sub w2, w2, #'0' add x1, x1, x2 add x0, x0, #1 add x4, x4, #1 b 1b 2: cmn xzr, xzr ; clear carry to signal success mov x0, x1 ret Latoi_error: cmp xzr, xzr ; set carry to signal failure mov x0, #0 ret ``` 在 Python 中,那会是 `int(string)`。汇编是不是很神奇? ## PUT `PUT` 很有趣。它是幂等的,意味着无论你发送多少次相同的请求,服务器上的最终结果都是一样的。`PUT /file.txt` 会创建 `file.txt`,如果它已经存在,则完全覆盖它。将 `1234` 连续两次 `PUT` 到 `file.txt` 只会得到一个包含 `1234` 的文件,而不是 `12341234`。这使得 `PUT` 在全球范围内开放实际上相当危险,但嘿,谁在乎呢? 处理 `PUT` 时需要考虑几件事: - 如果进程在处理请求的中途崩溃了怎么办? - 如果客户端说 `Content-Length` 是 2KB,但只发送了 100 字节怎么办? - 如果客户端说 `Content-Length` 非常大,比如 50GB,怎么办? 最后一点很容易解决。配置最大文件大小。在 `config.S` 中,`MAX_BODY_SIZE` 默认是 1GB。如果 `Content-Length` 大于这个值,ymawky 会拒绝请求,返回 `413 Content Too Large`。小菜一碟。 前两点有相同的基本解决方法。如果我们盲目地打开 `file.txt` 并开始写入,如果出现问题,文件可能只写了一半。所以,ymawky 会写入一个临时文件: - 临时文件名是 `file.txt.<PID>`,其中 `<PID>` 是服务器的进程 ID - 要获取 PID,我们使用 `getpid()`(系统调用 #20),然后用自定义的 `itoa()` 将数字转换为字符串(当然要检查缓冲区溢出) - 然后,客户端请求的内容被写入临时文件 - 如果一切顺利,临时文件被原地重命名,`file.txt` 现在存在于服务器上 - 如果客户端意外断开连接、超时或发送了格式错误的主体,临时文件会被 `unlink()` 掉(系统调用 #10 / 系统调用 #472 用于 `unlinkat()`) 现有文件只有在完整请求成功发送后才会被覆盖。 ## 目录列表和更多字符串解析 *耶* 你有没有注意到,有时你访问网站上的一个目录,它会列出所有文件并提供可点击的链接?这看起来是很基本的功能,并且也不是**太**复杂。但就像汇编中的一切一样,你必须手动完成所有事情。 如果你 `GET /somedir/`,我们检查目录列表是否启用(`config.S` 中的 `ALLOW_DIR_LISTING`)。如果没有启用,我们发送 `403 Forbidden` 并结束。如果允许,我们在请求的目录上调用 `getdirentries64()`(系统调用 #344)。这会用一个缓冲区填充关于目录中每个文件的信息。对我们来说重要的是,它包括每个文件的名称和文件名长度。我们使用这些名称信息来构建一些 HTML,使目录列表变得可点击且漂亮。 对于每个文件,我们向客户端写入: ``` <a href="filename">filename</a> ``` 但这两个 `filename` 需要以不同的方式处理和转义。在 `href="..."` 内部,文件名必须进行百分号编码以用于 URL/路径段。在可见的正文文本中,它必须进行 HTML 转义。 对于一个名为 `&.-~><foo` 的文件: - href 应该是 `%26.-%7E%3E%3Cfoo` - 但可见文本应该是 `&amp;.-~&gt;&lt;foo` - 所以最终输出是:`<a href="%26.-%7E%3E%3Cfoo">&amp;.-~&gt;&lt;foo</a>` 名为 `something evil`(这允许可见部分的 XSS)或 `"><something dastardly`(这允许 `href="..."` 部分的 XSS)的文件会被安全编码,而不是被执行。 ## 网络安全 有一种拒绝服务攻击叫 slowloris。ymawky 是 slowloris 的天堂。slowloris 通过打开大量连接并不断开请求来工作。连接保持打开,没有完整请求到达,服务器保持资源等待。那么我们如何防御呢? - 如果整个头部在配置的超时(`config.S` 中的 `HEADER_REQ_TIMEOUT_SECS`)内未收到,客户端会收到 `408 Request Timeout` 并关闭连接。 - 如果在请求主体期间客户端停止发送数据时间过长(`config.S` 中的 `RECV_TIMEOUT`),同样处理。 但仅靠每次读取超时是不够的。如果一个恶意客户端发送: ``` PUT /file.txt HTTP/1.0\r\n Content-Length: 1073741823\r\n \r\n ``` 然后每 9 秒发送一个字节呢?这个请求会被接受,因为内容长度比我们的最大值小 1 字节。如果唯一的超时是每字节 10 秒,服务器会耐心地等待超过 300 年。不好。非常糟糕。 为了最小化这种情况,ymawky 根据 `Content-Length` 和一个最低字节每秒传输速度计算超时: ``` timeout = grace_period + content_length / min_bps ``` `grace_period` 是给予任何主体的最小时间。`min_bps` 是服务器愿意容忍的最慢传输速度。默认情况下,它相当慷慨,为 16KB/s,但不是无限的。这并不会让 ymawky 免受拒绝服务攻击的影响,但它确实限制了某些类型的攻击占用资源的时间。 ## 文件系统安全 对于 `GET` 和 `HEAD` 方法,ymawky 打开请求的路径,然后在文件描述符上调用 `fstat64()`(系统调用 #339),以获取文件类型和文件大小等信息。先对路径调用 `stat64()`(系统调用 #338)检查,然后再打开文件存在潜在的时间检查/时间使用竞争条件;在你检查文件到打开文件之间的微秒内,文件可能已经改变。 ### 恶意请求 想象一个服务器运行时不考虑文件敏感性。一切皆可访问。有人可能会请求: ``` GET /etc/shadow HTTP/1.0\r\n \r\n ``` 然后掌控整个系统。那可不行!我们必须做点什么! 首先,所有请求的路径都会在前面加上文档根目录。默认情况下,它是 `www/`(`config.S` 中的 `DEFAULT_DIR`)。对 `/etc/shadow` 的请求变成对 `www/etc/shadow` 的请求,这应该返回 404(除非你在 `www/` 内有一个名为 `etc/` 的目录,并且里面有一个名为 `shadow` 的文件)。问题解决了! ......好吧,没那么简单。任何对类 Unix 文件系统稍有了解的人都知道 `..`,也就是路径遍历。他们可能会请求: ``` GET /../../../../etc/shadow ``` 这变成: ``` www/../../../../etc/shadow ``` 这会解析到文档

相似文章

用 x86_64 汇编写成的 Linux 桌面

Lobsters Hottest

一位开发者借助 Claude Code,用纯 x86_64 汇编重建了完整的 Linux 桌面栈——从 shell、终端、窗口管理器到各种工具,实现微秒级启动,并延长数小时续航。

编写可移植的ARM64汇编代码

Hacker News Top

一份关于编写可在Apple Darwin和Linux/BSD系统间移植的ARM64汇编代码的指南,涵盖ABI、符号命名和向量助记符的差异。