news 2026/6/11 11:36:52

用 AArch64 汇编挑战 Nginx!深入探究 ymawky 静态 HTTP 服务器的实现原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用 AArch64 汇编挑战 Nginx!深入探究 ymawky 静态 HTTP 服务器的实现原理

项目简介

ymawky 是一个小型的静态 HTTP Web 服务器,完全用适用于 macOS 的 AArch64 汇编语言编写。它使用原生 Darwin 系统调用,不依赖任何 libc 包装器,可提供静态文件服务,支持 `GET`、`HEAD`、`PUT`、`OPTIONS`、`DELETE` 请求方法,还支持字节范围请求、目录列表展示、自定义错误页面,并且尽可能保证安全性。开发者表示想深入了解 Web 服务器的实际工作原理,大家都在用 Nginx,用 Apache 又显得老套,所以想抛开自 1957 年以来计算机科学带来的各种便利试试。这个项目(可能)无法取代 Nginx,但它确实以最具挑战性的方式实现了一些功能。

项目约束

开发者为这个项目设定了一些约束条件:仅使用 AArch64 汇编语言;针对 macOS/Darwin 系统,而非 Linux,只因目前使用的是 macOS;仅使用原生系统调用,不使用任何 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 - on - request)的服务器。这意味着对于每个新的传入连接,它会调用 `fork()` 系统调用。这种方式有一些优点:请求处理程序之间不共享内存;更容易理解;更容易编写代码。但也有一些明显的缺点:资源占用大;每个进程都有自己的内存空间;与 Nginx 的事件驱动异步非阻塞模型相比,处理并发连接的能力较弱;并发连接越多,内核在进程间切换所花费的时间就越多,而实际处理进程的时间就越少;资源占用大,内存消耗高。绑定套接字并监听相对容易,真正困难的是处理请求。这涉及到很多方面:确定请求类型:`GET`、`HEAD`、`OPTIONS`、`PUT` 或 `DELETE`;提取请求的路径;对路径进行规范化,例如将 `%20` 解码为空格;对路径进行安全检查;解析客户端发送的请求头字段;获取请求文件的信息;判断请求的是目录还是普通文件;对于 `PUT` 请求,将上传内容写入临时文件;构建响应头;发送响应,这并非易事;关闭所有打开的文件;在不使服务器崩溃的情况下处理错误。

手动解析 HTTP 请求

开发者讨厌字符串解析,尤其是在汇编语言中。不幸的是,HTTP 请求本质上就是一个字符串,服务器需要理解它。让我们来看一个 HTTP 请求的示例:
```
GET /index.html HTTP/1.0
Range: bytes=1 - 5
```
第一行包含了很多信息。这是一个 `GET` 请求,意味着客户端希望获取 `index.html` 文件。`HTTP/1.0` 表明客户端使用的 HTTP 版本。` ` 序列(回车符加换行符)表示一行结束,服务器应处理下一行。最后的 ` ` 表示请求头结束。如果没有收到 ` `,服务器将返回 `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 不同,这些头可以出现在请求头的任何行。需要逐字符遍历请求头。如果找到 ` `,需要检查下一个字符是否为 ` `。如果不是,说明请求头格式错误,服务器将返回 `400 Bad Request`。同样,如果找到 ` ` 但前面没有 ` `,也属于格式错误。当找到 ` ` 时,表明当前行结束,下一行开始。检查新行是否以空格开头,如果是,则返回 `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 ;; 两个字符相等且都为 NULL,即字符串结束,匹配成功

;; 如果到达末尾,说明匹配成功
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”是字符串而不是整数,需要将每一边从 ASCII 数字转换为整数。需要编写一个类似 `atoi` 的函数,并注意检查整数溢出:
```
;; x0 -> 字符串指针
atoi:
mov x1, #0
mov x3, #10
mov x4, #0
1:
; 如果数字长度 >= 19 位,可能会导致 64 位寄存器溢出
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

; 结果 = (结果 * 10) + 当前数字
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 ; 清除进位标志表示成功
mov x0, x1
ret

Latoi_error:
cmp xzr, xzr ; 设置进位标志表示失败
mov x0, #0
ret
```
在 Python 中,这只需 `int(string)` 即可。

PUT 请求处理

`PUT` 请求很有意思。它具有幂等性,意味着无论发送相同请求多少次,服务器上的最终结果都是一样的。`PUT /file.txt` 会创建 `file.txt` 文件,如果文件已存在则会完全覆盖它。连续两次向 `file.txt` 写入 `1234`,最终文件内容是 `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 会先将内容写入临时文件:
```
.ymawky_tmp_
```
为了获取进程 ID(PID),使用 `getpid()`(系统调用编号 20),然后使用自定义的 `itoa()` 函数将数字转换为字符串(当然,要检查缓冲区溢出)。接着,将客户端请求的内容写入临时文件。如果一切顺利,临时文件将被重命名为 `file.txt`。如果客户端意外断开连接、超时或发送格式错误的内容,临时文件将被 `unlink()`(系统调用编号 10 或 `unlinkat()` 的编号 472)删除。只有在完整的请求成功发送后,现有文件才会被覆盖。

目录列表与更多字符串解析

有时访问网站上的目录时,会列出所有文件并带有可点击的链接。这看似是基本功能,实现起来也不算太复杂。但在汇编中,一切都得手动完成。如果请求 `GET /somedir/`,会检查是否启用了目录列表功能(`config.S` 中的 `ALLOW_DIR_LISTING`)。如果未启用,服务器将返回 `403 Forbidden`。如果启用了该功能,会对请求的目录调用 `getdirentries64()`(系统调用编号 344)。这会将目录中每个文件的信息填充到一个缓冲区中。对开发者来说,重要的是它包含每个文件的名称和文件名长度。利用这些名称信息构建 HTML,使目录列表可点击且美观。对于每个文件,向客户端发送以下内容:
```
filename
```
但这两个 `filename` 需要进行不同的处理和清理。在 `href="..."` 中,文件名需要进行 URL/路径段的百分号编码;在可见的文本中,需要进行 HTML 转义。对于名为 `&.-~> ```
&.-~><foo
```
像 ``(可能导致可见部分的 XSS 攻击)或 `">`(可能导致 `href="..."` 部分的 XSS 攻击)这样的文件名会被安全编码,而不会被执行。

网络安全

有一种拒绝服务攻击叫做 Slowloris 攻击。ymawky 很容易受到这种攻击。Slowloris 攻击的原理是向服务器打开大量连接,但不结束请求。这些连接会一直保持打开状态,服务器会持续等待完整的请求,从而占用大量资源。那么如何防范这种攻击呢?如果在配置的超时时间内(`config.S` 中的 `HEADER_REQ_TIMEOUT_SECS`)未收到完整的请求头,服务器将返回 `408 Request Timeout` 并关闭连接。如果在请求体传输过程中,客户端长时间停止发送数据(`config.S` 中的 `RECV_TIMEOUT`),也会有相同的处理。但仅设置每次读取的超时时间是不够的。如果恶意客户端发送如下请求:
```
PUT /file.txt HTTP/1.0
Content - Length: 1073741823

```
然后每 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

```
这可能会导致系统被攻击,这可不行!得采取措施。首先,所有请求的路径都会在前面加上文档根目录。默认情况下,文档根目录是 `www/`(`config.S` 中的 `DEFAULT_DIR`)。请求 `/etc/shadow` 会变成请求 `www/etc/shadow`,这通常会返回 404(除非 `www/` 目录下有 `etc/` 目录,且其中有 `shadow` 文件)。问题似乎解决了……但事情没那么简单。熟悉 Unix 文件系统的人都知道 `..` 或路径遍历。有人可能会请求:
```
GET /../../../../etc/shadow
```
这会变成:
```
www/../../../../etc/shadow
```
这会解析到文档根目录之外,这可不行。需要拒绝路径遍历尝试,但不能过于严格。不想因为简单的 `..` 子字符串匹配而拒绝所有请求,因为 `ohwell...png` 是一个有效的文件名。因此,ymawky 会拒绝完全为 `..` 的路径段。这需要在百分号解码之后进行,因为 `%2E%2E` 解码后会变成 `..`。但还有一个问题,符号链接怎么办?`open()`(系统调用编号 5)有 `O_NOFOLLOW` 标志,由 POSIX 定义,如果最终路径组件是符号链接,调用会失败。但如果路径中间的某个目录是符号链接呢?Darwin 还有 `O_NOFOLLOW_ANY` 标志,如果路径中的任何元素是符号链接,调用也会失败。当然,如果有人能在文档根目录中创建特定的符号链接,那可能已经出大问题了,但防范一下也没坏处。

苹果系统特定行为

为了使请求超时功能正常工作,需要使用 `setitimer()`(系统调用编号 83)在一定时间后发送 `SIGALRM` 信号。默认情况下,`SIGALRM` 会直接终止子进程,但希望先发送 `408 Request Timeout` 消息。使用 `sigaction()`(系统调用编号 46)。在 Darwin 系统中,原生的 sigaction 结构体有一个 `sa_tramp` 字段。通常,libc 会为你设置 `sa_tramp`,无需操心。它会保存栈、寄存器,设置 `sigreturn` 等,然后跳转到处理程序。如果 `sa_tramp` 没有这样做,程序在处理程序结束后将不知道返回哪里。但在 ymawky 中,超时处理程序不需要返回。它会发送 `408 Request Timeout` 消息,关闭需要关闭的资源,然后终止子进程。由于不需要返回,可以将 `sa_tramp` 指向直接执行超时响应的代码,完全绕过 `sa_handler` 和 `sigreturn`。苹果还有一个文档较少的系统调用 `proc_info()`(系统调用编号 336),它可以获取正在运行的进程信息,包括其子进程。通常,`ps`、`lsof` 和 `top` 等工具会使用这个系统调用,而 ymawky 用它来统计活跃的子进程数量。由于 ymawky 有可配置的最大连接数,它需要知道有多少子进程处于活跃状态。`proc_info()` 会将子进程信息写入一个缓冲区。由于每个元素的大小已知,服务器可以通过查看写入的字节数来确定子进程的数量。如果子进程数量超过 `MAX_PROCS`,新连接将被拒绝,并返回 `503 Service Unavailable`。

总结

每个人都应该多写点汇编代码。别管什么安全性,也别管什么易用性。能用 4000 行汇编代码实现的功能,干嘛要用 100 行 Python 脚本呢?能花 7 个小时调试字符串解析,干嘛要让日子过得有成效呢?(提示:你可能把 `[x3, #1]` 写成了 `[x3], #1`)说正经的,编写静态 Web 服务器的难点不在于打开套接字或监听请求,而在于解析请求并处理各种边界情况。每个请求都是字节,每个路径都是字节,每个响应都是字节,每个范围都必须精确,每个文件名都需要不同的转义处理。汇编让你亲力亲为做所有事情,是不是很棒呢?[ymawky](https://github.com/imtomt/ymawky) 由 [imtomt](https://github.com/imtomt) 维护。此页面由 [GitHub Pages](https://pages.github.com) 生成。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/13 17:57:12

PyCharm专业版SSH远程开发环境一站式部署指南

1. PyCharm专业版安装与激活 作为数据科学和算法开发的主力工具&#xff0c;PyCharm专业版提供了完整的远程开发支持。首先需要从JetBrains官网下载对应操作系统的安装包。这里有个小技巧&#xff1a;如果你使用的是Windows系统但需要连接Linux服务器开发&#xff0c;建议选择W…

作者头像 李华
网站建设 2026/5/13 17:51:39

基于MCP协议构建AI助手与OVH云API的安全自动化运维桥梁

1. 项目概述&#xff1a;一个连接MCP与OVH云的桥梁最近在折腾一些自动化运维和云资源管理的项目&#xff0c;发现一个挺有意思的工具&#xff1a;davidlandais/ovh-api-mcp。简单来说&#xff0c;这是一个Model Context Protocol (MCP) 服务器&#xff0c;专门用来桥接你的AI助…

作者头像 李华
网站建设 2026/5/13 17:48:08

为Hermes Agent配置Taotoken自定义供应商的完整步骤

&#x1f680; 告别海外账号与网络限制&#xff01;稳定直连全球优质大模型&#xff0c;限时半价接入中。 &#x1f449; 点击领取海量免费额度 为Hermes Agent配置Taotoken自定义供应商的完整步骤 如果你正在使用Hermes Agent进行AI应用开发&#xff0c;并且希望接入Taotoken…

作者头像 李华
网站建设 2026/5/13 17:47:37

AI智能体实战竞技场BuildersClaw:区块链与AI融合的软件开发新范式

1. 项目概述&#xff1a;一个为AI智能体打造的实战竞技场 如果你和我一样&#xff0c;这几年一直在关注AI智能体&#xff08;AI Agent&#xff09;的发展&#xff0c;你可能会发现一个现象&#xff1a;演示视频和论文很多&#xff0c;但真正能让智能体像人类开发者一样&#xf…

作者头像 李华