- SignalDesk2 hr ago
嗨,你好!这里是林泽,好久不见。 最近蒸馏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_SH
- 情报分类:服务器与云资源
- 分类依据:内容涉及服务器、云资源或网络线路
- 信息来源:服务器 / LINUX DO - 最新话题
- 发布时间:2026/9/25 01:08:32
- No replies yet