用 aarch64 汇编构建 Web 服务器,给我的生活(缺乏)意义
摘要
本文介绍了 '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`
- 但可见文本应该是 `&.-~><foo`
- 所以最终输出是:`<a href="%26.-%7E%3E%3Cfoo">&.-~><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
```
这会解析到文档
相似文章
Show HN: 用汇编语言构建 Web 服务器,为我的生命赋予(些许缺乏的)意义
ymawky 是一个专为 macOS 编写的 ARM64 汇编 Web 服务器,其特点是不依赖 libc 仅使用系统调用,并具备基本的 HTTP 功能。
Show HN: 一个纯ARM64汇编编写的Web服务器,现可在Linux上运行,并可无理由支持CGI
ymawky 是一个完全用ARM64汇编编写的Web服务器,支持CGI、静态文件和多种HTTP方法,现已可在Linux上运行。
用 x86_64 汇编写成的 Linux 桌面
一位开发者借助 Claude Code,用纯 x86_64 汇编重建了完整的 Linux 桌面栈——从 shell、终端、窗口管理器到各种工具,实现微秒级启动,并延长数小时续航。
编写可移植的ARM64汇编代码
一份关于编写可在Apple Darwin和Linux/BSD系统间移植的ARM64汇编代码的指南,涵盖ABI、符号命名和向量助记符的差异。
@msimoni: 这表明通过一些非常简单的宏方法,WebAssembly 几乎是一个可以直接编写的、可识别的 Lisp。
文章探讨了如何利用类似 Lisp 的宏来改进 WebAssembly,使其语法更易于人类理解,并提及了 Joel Martin 此前用 JavaScript 实现的相关内容。