嗨,你好!这里是林泽,好久不见。
最近蒸馏finnian的模型,炉子炸掉了,整个人陷入很抑郁的情况。因此我就准备换一个方向去进行探索,也算是缓解一下心情。
于是如题所示,有了这么一篇文章。
事实上,说起论坛,肯定很多人会说,随便找一个 Web 框架,然后找 AI 去跑,大概一个多小时,或者短一点的就十几分钟,我们就可以很轻松地做出一个论坛。
这是 2026 年所有人都最喜欢的 vibe coding,对吧?
非常好,非常的美妙,完全不需要动任何的脑子,很明显就可以很轻松
但是让我们把时间倒转到 1986年。
不妨想一下,在如今各种资源如此丰富、物质生活丰富的情况下,让我们忆苦思甜一下,回到 40 年前,来反思一下:40 年前的人们是怎么去实现一个论坛的呢?
其实这个议题的起源,也是因为有一个博主做的一个视频,https://www.bilibili.com/video/BV1SBe56GEPQ
他做得很好,用了大量的 trick,对吧?他叫黑魔法,我叫它 trick,来保证他所有的东西:包括 web server,包括他整个静态资源,全部都塞在了一个 QR code 里面
但是,他的这个挑战对于静态网页来说还是过于简单了。因为事实上,他只需要去维护一个静态的网页,不需要考虑任何的交互,也不需要考虑任何的数据存储。
如果说它只是把一个固定的网页吐出去,对吧?那如果让我们去做一些真正可以应用到生产里的东西呢?
比如说:有用户、有数据、有并发或者有权限之后,那 asm 还可以用、撑得住吗?
对吧?那这个当然肯定不是极简主义,但是我们不妨从这个角度来进一步延伸。
所以,我们的任务就是把大小限制在 8kb,来实现一个健全的论坛系统。
说干就干。
首先我给他取名geek taco
第一版的目标其实很朴素,这个东西只要能跑就行,对吧?
事已至此,先做hello world吧
push SYS_socket
pop rax
push AF_INET
pop rdi
push SOCK_STREAM
pop rsi
xor edx, edx
syscall
mov r12d, eax ;
事实上来说,在汇编上如果想让操作系统去帮助你跑你的软件,首先你就要先把你的需求写给 RAX 寄存器,然后把办这件事情需要的东西写到 RDI 还有 RSI。
它其实这个很约定俗成啊 你可以理解成这就像去银行办业务:
- 先取号,说你要办什么,对吧?
- 然后,你要把证件递过去。
- 之后,你就要把材料放到窗口里面,剩下的就是系统去处理。
- 系统处理完之后会给你一个回调,就像银行窗口给你回复一样,对吧?
socket 这个系统调用,其实就是"给我造一扇门"。AF_INET 和 SOCK_STREAM 的意思是:一扇走互联网的、一问一答的门,也就是 TCP。
之所以前面先push,然后再 pop 出来,主要原因是因为这样可以省两个字节。之后所有的策略基本上都是以这样的方式展开的。用这种方式我们可以省下将近几百个字节。
操作系统在 rax 里递回这扇门的号码。我把它存进 r12,后面一直要用。
然后两步:
push SYS_bind
pop rax
mov edi, r12d
mov esi, listen_address
...
push SYS_listen
pop rax
bind 是"把门牌号挂上去",也就是端口号;listen 是"开门营业"。
好,那这个门开了,对吧?客人进来了,那接下来就是他说了什么?
.accept:
push SYS_accept
pop rax
mov edi, r12d ;
xor esi, esi
xor edx, edx
syscall ;
test eax, eax
js .accept ;
mov r13d, eax ;
程序启动完之后,大部分时间其实都会停在这个 syscall 里面一动不动。
如果有人去访问它,系统就会在 RAX 里面填一个数字,也就是这个客人的号码牌。之后跟他说的每一句话,都要去 call 这个东西。
如果它返回来的是负数,那就意味着出了一些问题,比如浏览器刚连进来就断开了。那么 js 的意思就是:如果是负数就跳转,当成没发生过,让我们继续等待。
最后一条指令就是把它的返回值存到 r13 里面。从现在开始,r13 就是我们唯一可以跟这个客户端保持联系的方式
浏览器连接上了。
xor eax, eax ; 0 号系统调用:read
mov edi, r13d ; 从client那里读
mov esi, request_buffer
mov edx, REQ_SIZE
syscall
...
mov r14d, eax ; 一共收到了多少字节
mov edi, request_buffer
mov esi, r14d
call http_parse ; 拆开看看他要什么
read,它其实就是读取,它是从 r13 那边开始读,然后读到一块叫 request.buffer 的内存里面。
那么我们读这个系统调用,只需要把 eax 清零就行,对吧?这样其实它会比 move 一个零进去还会省。
我们收到多少字节,就存到 r14,然后就调那个 HTTP 的处理,把这段去拆开
浏览器发来的请求,其实就只是一个纯文本。
POST /new HTTP/1.1
Content-Length: 27
t=hello&b=my+first+post
比如说在 Python 里面,其实这些东西框架都会帮你拆得很好,你拿到的基本上直接就是 Python 里面的字段,对吧?
但是在汇编里面很明显,它太底层了,基本上没有人会给你这样做。当然,其实好像也有 ASM 的库会这样做,但是我们不引入任何的外部库,所以我们需要自己去做一些处理。
处理的方式是类似这样的:
cmp dword [rdi], 'GET '
jne .check_post
xor eax, eax ; 方法 = GET
asm可以只用了一条指令,基本上就可以把一整个单词对比完,这其实还蛮不错的对吧?
如果它不是 GET,那我们再去看它是不是 POST。因为其实我们这个论坛,它至少需要让用户去发送一些信息,然后再获取一些信息。
虽然说我们可以只用 GET,但是最好不要只用 GET,对吧?那这样的话太不优雅了。
在辨识之后,我们就可以去找路径了。
.path_scan:
mov al, [rbx] ; 读一个字节
cmp al, ' '
je .path_end ; 空格:路径结束
cmp al, '?'
je .path_end ; 问号:后面是查询参数,不要
inc rbx ; 往后挪一个字节
jmp .path_scan
这其实就是汇编里面去找一个字符的样子:基本上都是你读一个字节,对比一下,如果不是的话,就往后再挪一个,再来一遍,对吧?
高级语言里面可以一行搞定的事情,我们就只能去做一个循环来处理。
之后它就需要一行一行地扫头信息,然后只需要去找这个 context length,也就是说它正文一共有多长。我会把每个字母都转成小写再比,因为有的客户端写成全小写(对,Chrome 就是你
or al, 0x20 ; 先转成小写
.header_compare_byte:
cmp al, [header_content_length+rcx]
jne .next_header
它就会一个字母一个字母地去跟 content-length 的值和冒号去比。每一个字母在对比之前,都会先去或一个 0x20。
在 ASCII 码里面,大写和小写其实就只差那么一个比特。或上 0x20 之后,它就变成了小写。
最后,我们只需要找到那个空行,空行后面的第一个字节其实就是正文的开头。
然后,我们只需要把方法、路径、正文在哪、正文多长这四项东西,全部放到内存的固定位置,再让这个 HTTP 处理函数去回调它
然后在这个时候,它其实就出现了一个问题。并且我最开始就考虑过了,因为很多时候浏览器在发这个表单的时候,它的正文不一定会跟头部信息一起到。因为网络其实会把这个数据切成一块一块的,那你第一次读,它可能只会拿到一半。
curl不会出现类似的问题,但是任意成熟的浏览器都会采用分段的方式进行处理。事实上来说,蛮多的这种类似的服务器,它其实都会默认说读一次就够了。然后大部分情况下虽然也是够了,对吧?
但是肯定会有一些浏览器,它会只存下半条帖子,所以说我加入了相关的处理。
.read_body:
cmp r14d, REQ_SIZE
jae .not_found ; 缓冲区满了还没读够,放弃
... ; 再 read 一次,接在后面
cmp edx, [req_clen]
jb .read_body ; 还没读够 Content-Length,接着读
如果说我没有读明白、读够这个声明的长度,那我其实就会接着读。那缓冲区满了还没有读够,那就直接返回一个错误。
我其实事实上来说,宁可报错,也绝对不可能去把残缺的正文当成一个正常的帖子存下来。
这个其实是合理的,对吧?
接下来决定这个请求交给谁处理。这就是路由:
.route:
mov edi, [req_path]
mov ecx, [req_path_len]
cmp dword [req_method], M_GET
je .get
cmp dword [req_method], M_POST
je .post
jmp .not_found
rdi 放路径,rcx 放路径的长度。GET 去一边,POST 去另一边,其他的一律 404。这个设计也是考虑到我们目前只是一个最简论坛。如果你需要添加其他功能,在这个地方就要去调整其他的路由方式,在此不再赘述。
因为获取过于简单,所以我们可以直接跟着一个发帖的请求。
接下来我们去看一下.post
.post:
cmp ecx, 4
jne .post_reply
cmp dword [rdi], '/new'
jne .not_found
jmp .new
事实上来说,这也是一个习惯的问题。因为我基本上做设计的话,会有一个习惯,就是可能会先去看长度,然后再去比内容。
如果长度是 4,再去比 4 个字节(也就是 \new)。如果对上的话,那我们就跳给 NEW 函数,对吧?
那如果长度不是 4 的话,我们再去看它是不是 reply,对吧?
这个其实不是一个好习惯,但是事实上来说,先这样吧。我在后面付出了代价。
.new:
call .prepare_record
...
.new_append:
mov eax, [db_count]
mov [record_buffer + R_PARENT], eax
call .append_record
处理发帖分三步:先准备好一条记录,然后设置它的"父帖",然后把它存下来。
我们先跟进.prepare_record
.prepare_record:
mov edi, record_buffer
xor eax, eax
push REC_SIZE / 8
pop rcx
rep stosq ; 先把整条记录清零
mov edx, key_author
mov ecx, record_buffer + R_AUTHOR
...
call .field ; 从表单里取作者名
我们先用 rep stosq 把一整块草稿区清零。rep 的意思是"重复 rcx 次",stosq 是"写 8 个字节的零",所以这是一条指令,清掉 512 个字节。
然后从表单里取作者名,放进记录的作者位置。再用同样的方式取正文。于是取字段的 .field,最后会跳进 form_field。
根据这样的表单提交上来的正文长这样:t 等于标题,和号,b 等于正文。form_field 最关键的是这一小段:
.key_end:
cmp byte [rsi], '='
jne .next_field ; 名字后面不是等号,就不算匹配
为什么这样设计呢?主要的点在于假设我要找字段 b,而正文里恰好有一个字段叫 bb。不检查等号的话,b 就会在 bb 里被错误地匹配上。
那么如果找到了,就把值交给 url_decode 还原:加号变回空格,百分号加两位十六进制变回原来的字节。遇到写错的编码,就原样保留。接下来我们就可以回到.new了。
我们接下来其实要说明,我们的帖子大概是什么样的,对吧?
因为实际上来说,我们一定要设计好数据结构,然后才能开始接下来的工作。现在可能会觉得有点混乱,因为这个话题我们刚才就应该聊的。
我的设计其实比较简单:每一个帖子固定只占 512 个字节,结构如下所示:
R_PARENT equ 0 ; 父帖编号
R_TIME equ 4 ; 发布时间
R_FLAGS equ 8 ; 标记,比如"已删除"
R_AUTHOR equ 20 ; 作者,24 字节
R_TITLE equ 44 ; 标题,80 字节
R_BODY equ 124 ; 正文,388 字节
实际上来说,每一个字段它其实从哪个字节开始,其实是全部写死的。然后刚才其实 .prepare_record 里的 record_buffer 加 R_AUTHOR,就是"草稿区第 20 个字节"的意思。这样其实就会比较干净。除此之外,我们的主题帖和回复其实用的是同一种记录。在 parent 字段里面,如果它的父帖是自己,那它就是主题帖;否则,它就是某一个帖子下面的回复。这样设计的话,其实会干净很多。
我们接下来回头看刚刚 .new 函数里面的那几行
mov eax, [db_count]
mov [record_buffer + R_PARENT], eax
db_count 是"现在一共有几条帖子"。比如有 41 条,那新帖子就会是第 41 号。它把自己的父帖设成 41,也就是设成它自己。
所以它是一个主题帖。一张表,搞定主题和回复。
然后是 .append_record:
.append_record:
call now_secs
mov [record_buffer + R_TIME], eax
mov edi, record_buffer
jmp db_append
取当前时间,填进时间字段,然后交给 db_append 写进文件:
db_append:
mov r8d, [db_count] ; 新帖子的编号
...
mov r10d, r8d
shl r10d, REC_SHIFT ; 编号左移 9 位 = 在文件里的位置
... ; pwrite:写进文件的那个位置
inc qword [db_count] ; 计数加一
哎呀,这就是小巧思啊!因为 512 它是 2 的 9 次方嘛。如果我们要去算这个帖子是在文件的第几个字节,那我们就只需要去把这个 N 右移 9 位,这样的话我们连乘法都不用做,就又可以省空间,对吧?
当然,因为我们确实做的是最简的实现,所以说真的没有数据库的 driver。我们也只能用嵌入式的数据库希望理解。
好了好了 我们把话题切回来。
那我接下来帖子发完之后,浏览器其实就应该跳回主页,对吧?
然后我是这样实现的:
.new_redirect:
mov edi, root_path ; 就是 "/"
call render_redirect
jmp .respond
render_redirect 生成一个"302,请你去这个地址"的回复:
render_redirect:
...
call ob_reset ; 清空输出缓冲区
mov edi, s_302a ; "HTTP/1.0 302 Found,Location: "
call ob_puts
mov rdi, rbx
call ob_puts ; 要去的地址
所有要发给浏览器的东西,都先拼在一块输出缓冲区里。ob_puts 往里追加一段文本,先数一下这段文本有多长,然后交给最底层的 ob_put:
ob_put:
mov rax, [ob_len] ; 已经用了多少
mov rcx, OBUF_SIZE
sub rcx, rax ; 还剩多少空间
jbe .done ; 一点都不剩了,直接放弃
cmp rsi, rcx
cmovb rcx, rsi ; 想写的和剩下的,取小的那个
它其实就只遵循一个事情:装不下就截断,不要写出界。
因为主要是在汇编里面,如果写出界的话,它并不会报错,而是会把周围的内存给覆盖掉。这样之后就会在后续的某些情况下崩溃,并且很难去追踪这个问题
网页这样的话就可以组装好了,接下来我们就回到主循环的 .respond:
.write_response:
...
mov edi, r13d ;
...
syscall ; write
test eax, eax
jle .close_client ;
add r15d, eax ;
sub r14d, eax ;
jmp .write_response
是不是很熟悉 又看到 r13了。
网络一次不一定能把整个网页送出去,write 会告诉你"这次送了多少",那就接着送剩下的,直到送完。
送完,关掉这个连接:
.close_client:
...
push SYS_close
pop rax
syscall
jmp .accept
最后一行,jmp .accept,然后我们如此往复就完成了一个最基本的网络请求。
刚才那个浏览器收到 302,会马上再来一次,这次是 GET 斜杠。到了路由那里,它会走进 .get:
.get:
cmp ecx, 1
jne .get_thread
cmp byte [rdi], '/'
jne .not_found
call render_index
jmp .respond
长度是 1,内容是斜杠,那就是首页,交给 render_index。
render_index 从最新的帖子开始往回找:
.outer:
dec r13 ; 从最新的一条往回数
...
call db_load ; 把第 r13 条读出来
...
mov eax, [rec_a + R_PARENT]
cmp eax, r13d ; 我父是我自己吗?
jne .next ; 不是:这是回复,首页不显示
找到一条主题帖,我们就把它的标题和作者输出出来。而标题和作者,有虞氏是用户写的。那么我们为了安全就全部要经过一个函数:
ob_put_esc:
...
cmp al, 0x20
jb .ctrl ; 看不见的控制字符,单独处理
cmp al, '&'
je .amp ; 换成 &
cmp al, '<'
je .lt ; 换成 <
cmp al, '>'
je .gt ; 换成 >
熟悉的朋友都知道,这是anti-XSS 在此不再赘述。
如果说我们的请求时某个帖子,那么我们就会把这个请求交给 render_thread
它输出主题帖和所有回复:
mov edi, s_repl_a ; 回复表单的开头
call ob_puts
mov rdi, r12
call ob_putu ; 主题编号
mov edi, s_th_b ; 引号、右尖括号
call ob_puts
回复表单里有一个隐藏字段,告诉服务器"这条回复是给哪个主题的"。这段代码把它拼出来:先是一段固定的开头,然后是主题编号,然后是一个引号加右尖括号。
接下来 其实初版的主体任务就已经完成了,已经可以正常的发帖回帖。但是事实上,我们现在还有很多东西没有完成,我们没有鉴权,请求是串行的,效率是底下的,用户无法管理,样式完全丢失。等等等等,我们为了目标要继续做额外的设计,这是后话了。
后续更新应该还在这个帖子,但是估计要等一段时间。稿子写的头疼,typeless也没办法帮忙。