• 朱慧月

    嗨,你好!这里是林泽,好久不见。

    最近蒸馏finnian的模型,炉子炸掉了,整个人陷入很抑郁的情况。因此我就准备换一个方向去进行探索,也算是缓解一下心情。

    于是如题所示,有了这么一篇文章。

    事实上,说起论坛,肯定很多人会说,随便找一个 Web 框架,然后找 AI 去跑,大概一个多小时,或者短一点的就十几分钟,我们就可以很轻松地做出一个论坛。

    这是 2026 年所有人都最喜欢的 vibe coding,对吧?

    非常好,非常的美妙,完全不需要动任何的脑子,很明显就可以很轻松

    但是让我们把时间倒转到 1986年。

    不妨想一下,在如今各种资源如此丰富、物质生活丰富的情况下,让我们忆苦思甜一下,回到 40 年前,来反思一下:40 年前的人们是怎么去实现一个论坛的呢?

    其实这个议题的起源,也是因为有一个博主做的一个视频,

    他做得很好,用了大量的 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 ; 换成 &lt;
    cmp al, '>'
    je .gt ; 换成 &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也没办法帮忙。

    主楼
  • Devin_Wu

    佬这么硬核的吗!nb!

    #1
  • coderZoe

    我擦,这真有点硬核了,居然还有汇编的大佬

    #2
  • 寸铁

    好帖,插个眼,等更新

    #3
  • 黯绛

    这才是真正的汇编入门(

    #4
  • fablia

    看不懂了

    硬核汇编教程嘛

    #5
  • 朱慧月

    也没有,就是单纯把做这个的时候的思路分享一下。

    最主要也是聊trick而已,因为对于我来说我也不是asm高手 ^-^。

    #6
  • 朱慧月

    其实还好,就是撞了几个大坑想聊,但是感觉不把背景聊完会让大家不理解。

    #7
  • Ningbainb

    技术贴必须支持一下

    #8

发表回复

登录后回复