手把手写一个红黑树:从 CLRS 到 x86 汇编,零 libc 的硬核实现

Полный текст — около 16 тысяч слов. Рекомендуется читать вдумчиво, лучше всего открыв две баночки любимого напитка
Вы получите: суть красно-чёрных деревьев, адресацию в ассемблере, системный вызов brk, повороты и перекраску, рекурсивный обход, а также красно-чёрное дерево, которое вы «запустите» своими руками.

全文约 1.6 万字,建议静心阅读,最好打开两瓶快乐水。
你将收获:红黑树本质、汇编寻址、系统调用 brk、旋转与着色、递归遍历、以及一颗亲手“运行”的红黑树。


在这里插入图片描述


0. 开头

Всем привет, 很多人学数据结构时,红黑树是绕不过去的坎。CLRS(《算法导论》)用十几页讲插入修复,伪代码写得清清楚楚,但真让你用 C 写一遍,你可能会被指针绕晕。如果更进一步,用汇编来实现呢?——没有编译器帮你优化,没有 malloc,连 printf 都没有,只有赤裸裸的寄存器、内存地址和系统调用。

本文要分析一个用 NASM x86-32 编写的红黑树完整实现,它不依赖任何 C 库,只用 Linux 的 brk 系统调用来动态申请内存,最后以中序遍历打印所有键值。代码总共约 300 行,却麻雀虽小五脏俱全。

我会带着你逐段拆解,从红黑树理论到汇编细节,从内存布局到旋转修复,确保你看完能独立写出自己的汇编版红黑树或者至少能吹牛 : D


1. 红黑树速览(五分钟重温)

红黑树是一种自平衡二叉搜索树,每个节点有颜色(红/黑),并满足五条性质:

  1. 每个节点要么红,要么黑。
  2. 根是黑色。
  3. 所有叶子(NIL)是黑色。
  4. 如果一个节点是红色,则它的两个子节点都是黑色(即不能有连续红色)。
  5. 从任意节点到其每个叶子(NIL)的所有简单路径都包含相同数目的黑色节点(黑高)。

这些性质保证了树的高度最多为 2*log2(n+1),从而保证了插入、删除、查找的 O(log n)

在 CLRS 的实现中,使用一个哨兵 NIL 节点来代表所有叶子,这样每个“真正”节点的左右指针总是指向一个合法节点(NIL),便于处理边界。本汇编代码正是沿用了这一设计。

插入过程:先按 BST 规则插入新节点(红色),然后调用 rb_insert_fixup 修复可能违反的性质。修复分三种情况(以父为祖父左子为例):

  • Case 1:叔父为红 → 颜色翻转,向上移动。
  • Case 2:当前节点为右孩子 → 左旋父节点,转为 Case 3。
  • Case 3:当前节点为左孩子 → 右旋祖父,并交换颜色。

对称处理父为右子。

本篇不重复基础理论,但代码分析时会反复提到这些性质,确保你能将抽象规则映射到寄存器操作上。


2. 汇编前置知识(适合入门)

如果你对 x86_32 汇编不太熟悉,这里快速复习一下关键点:

  • 寄存器:32 位模式下通用寄存器有 eax, ebx, ecx, edx, esi, edi, ebp, esp。函数调用约定通常用 eax 返回值。
  • 内存寻址[eax + NODE_LEFT] 表示 eax 指向的结构体偏移 0 字节处的双字(4 字节)。
  • 指令mov 传送,add/sub 算术,cmp/test 比较,jmp/jcc 条件跳转,call/ret 调用返回。
  • 系统调用:Linux 32 位使用 int 0x80,功能号在 eax,参数依次在 ebx, ecx, edx 等。例如 SYS_write 是 4,SYS_brk 是 45,SYS_exit 是 1。

代码使用 ELF32 格式,静态链接,入口是 _start,没有 main,因为不依赖 libc。


3. 整体结构与内存布局

3.1 节点定义

NODE_LEFT    0
NODE_RIGHT   4
NODE_PARENT  8
NODE_COLOR   12
NODE_KEY     16
NODE_SIZE    20

每个节点 20 字节,全部是双字(dword)。颜色用 0 表示红色,1 表示黑色(方便用 dword 存储)。

3.2 全局变量

section .bss
    root        resd 1          ; 根指针
    heap_cur    resd 1          ; 当前分配区顶
    heap_end    resd 1          ; 当前 brk 结束地址
    nil_node    resb NODE_SIZE  ; 哨兵节点
    numbuf      resb 16         ; 数字转字符串缓冲

nil_node.bss 中分配 20 字节,启动时初始化为黑色,左右父都指向自身(自环)。这样做的好处:任何对 NIL 的引用都是可解引用的,且 NIL->color 总是黑色。

3.3 数据区

section .data
    keys        dd 41, 38, 31, 12, 19, 8, 20, 17, 43, 15, 25, 5, 1, 60, 50
    keys_count  equ ($ - keys) / 4

测试数据共 15 个整数,按任意顺序插入,最终中序遍历应升序输出。


4. 内存分配器:使用 brk 实现 bump allocator

C 语言的 malloc 在底层通常调用 brkmmap。这里直接封装了 brk 系统调用,实现了一个极简的线性分配器(bump allocator)。

4.1 rb_alloc 函数

rb_alloc:
    mov     eax, [heap_cur]
    add     eax, NODE_SIZE
    cmp     eax, [heap_end]
    jbe     .have_space
    ; 申请更多内存
    mov     ebx, [heap_end]
    add     ebx, 4096
    mov     eax, SYS_brk
    int     0x80
    mov     [heap_end], eax
.have_space:
    mov     eax, [heap_cur]      ; 返回旧指针
    add     dword [heap_cur], NODE_SIZE
    ; 初始化节点为红色,左右父指向 nil_node
    ...
    ret

流程

  1. 尝试从当前 heap_cur 分配 20 字节,如果 heap_cur + 20 <= heap_end 则直接分配。
  2. 否则调用 brk 增加 4096 字节(一页),更新 heap_end,再分配。
  3. 返回分配前的 heap_cur 指针,并递增。
  4. 将新节点的左、右、父全部指向 nil_node,颜色设为红色。

注意:这里没有考虑 brk 失败(内存耗尽),因为测试数据很少。实际生产需要检查返回值。

系统调用 brk 细节int 0x80eax=45ebx 为新的程序中断地址。若 ebx=0 则返回当前中断地址(用于初始化)。在 _start 中我们先用 brk(0) 获取初始值。

这种分配器释放内存只能整个进程退出后归还,但对我们单次运行足够了。


5. 旋转操作:左旋与右旋

红黑树的修复依赖于旋转,旋转不改变 BST 性质,但能改变树的形状和颜色关系。代码实现了 left_rotateright_rotate,两者互为镜像。我们来细读左旋:

left_rotate:   ; ebx = x
    mov     eax, [ebx + NODE_RIGHT]   ; y = x->right
    mov     ecx, [eax + NODE_LEFT]    ; T2 = y->left
    mov     [ebx + NODE_RIGHT], ecx   ; x->right = T2
    cmp     ecx, nil_node
    je      .skip_pf
    mov     [ecx + NODE_PARENT], ebx  ; T2->parent = x (若 T2 非 NIL)
.skip_pf:
    mov     ecx, [ebx + NODE_PARENT]  ; p = x->parent
    mov     [eax + NODE_PARENT], ecx  ; y->parent = p
    cmp     ecx, nil_node
    jne     .not_root
    mov     [root], eax               ; 若 p 为 NIL,则 y 成为新根
    jmp     .link
.not_root:
    mov     edx, [ecx + NODE_LEFT]
    cmp     edx, ebx
    jne     .was_right
    mov     [ecx + NODE_LEFT], eax    ; p->left = y
    jmp     .link
.was_right:
    mov     [ecx + NODE_RIGHT], eax   ; p->right = y
.link:
    mov     [eax + NODE_LEFT], ebx    ; y->left = x
    mov     [ebx + NODE_PARENT], eax  ; x->parent = y
    ret

要点

  • 必须处理 NIL 指针:当 T2 是 NIL 时,不要设置其 parent
  • 更新父节点指针时,要判断 x 是左子还是右子,或者 x 是根。
  • 最后交换父子关系。整个过程中所有指针均指向合法节点(NIL 自环),所以没有空指针异常。

右旋完全对称,只需将 LEFTRIGHT 互换即可,这里不赘述。


6. 插入与修复:算法精髓

6.1 rb_insert:BST 插入 + 调用修复

rb_insert:
    push    eax                ; 保存 key
    call    rb_alloc           ; 新节点 z (eax)
    pop     ecx                ; 恢复 key
    mov     [eax + NODE_KEY], ecx
    mov     edi, eax           ; edi = z (固定保存)

    mov     ebx, nil_node      ; y = trailing parent
    mov     edx, [root]        ; x = root
.descend:
    cmp     edx, nil_node
    je      .found_spot
    mov     ebx, edx
    mov     ecx, [edi + NODE_KEY]
    cmp     ecx, [edx + NODE_KEY]
    jge     .go_right_d
    mov     edx, [edx + NODE_LEFT]
    jmp     .descend
.go_right_d:
    mov     edx, [edx + NODE_RIGHT]
    jmp     .descend
.found_spot:
    ; 将 z 挂在 y 下
    mov     [edi + NODE_PARENT], ebx
    cmp     ebx, nil_node
    jne     .set_child
    mov     [root], edi
    jmp     .fixup
.set_child:
    mov     ecx, [edi + NODE_KEY]
    cmp     ecx, [ebx + NODE_KEY]
    jge     .go_right
    mov     [ebx + NODE_LEFT], edi
    jmp     .fixup
.go_right:
    mov     [ebx + NODE_RIGHT], edi
.fixup:
    mov     ebx, edi
    call    rb_insert_fixup
    ret

注意:这里没有处理重复键(相等时走右子树),但测试数据无重复。

6.2 rb_insert_fixup:颜色调整与旋转

这是最复杂的部分,完全按照 CLRS 伪代码翻译成汇编。我们只看父为左子的分支(右子镜像同理):

.loop:
    mov     eax, [edi + NODE_PARENT]    ; parent
    cmp     dword [eax + NODE_COLOR], RED
    jne     .done                       ; 父为黑则退出

    mov     ecx, [eax + NODE_PARENT]    ; grandparent
    mov     edx, [ecx + NODE_LEFT]
    cmp     edx, eax
    jne     .parent_is_right

    ; ---- 父是祖父的左子 ----
    mov     edx, [ecx + NODE_RIGHT]     ; uncle
    cmp     dword [edx + NODE_COLOR], RED
    jne     .case23_left

    ; Case 1: 叔红 -> 颜色翻转,上移
    mov     dword [eax + NODE_COLOR], BLACK
    mov     dword [edx + NODE_COLOR], BLACK
    mov     dword [ecx + NODE_COLOR], RED
    mov     edi, ecx
    jmp     .loop

.case23_left:
    cmp     edi, [eax + NODE_RIGHT]
    jne     .case3_left
    ; Case 2: z 是右孩子 -> 左旋父
    mov     edi, eax
    mov     ebx, edi
    call    left_rotate
    mov     eax, [edi + NODE_PARENT]
    mov     ecx, [eax + NODE_PARENT]
.case3_left:
    ; Case 3: 父黑,祖父红,右旋祖父
    mov     dword [eax + NODE_COLOR], BLACK
    mov     dword [ecx + NODE_COLOR], RED
    mov     ebx, ecx
    call    right_rotate
    jmp     .loop

关键点

  • 每次 edi 表示当前“关注”的红色节点(开始是插入的 z)。
  • 循环条件:如果父节点是红色,则一定需要修复(因为红红冲突)。
  • Case 1 只做颜色翻转,并将 edi 上移到祖父,继续循环。
  • Case 2 和 Case 3 通过旋转修正,之后再次循环(但此时父变黑,通常循环会结束)。
  • 对称部分处理父为右子,旋转方向相反。

最后,无论是否进入循环,都将根涂黑(保证性质 2)。


7. 中序遍历:递归的汇编实现

由于没有栈溢出问题(树很小),使用递归很方便。汇编中递归需要手动保存寄存器,因为子调用会破坏调用者保存的寄存器(我们遵循 cdecl 约定,但这里实际上是自己约定)。

inorder_print:
    cmp     ebx, nil_node
    je      .ret

    push    ebx
    mov     ebx, [ebx + NODE_LEFT]
    call    inorder_print
    pop     ebx

    push    ebx
    mov     eax, [ebx + NODE_KEY]
    call    print_int
    ; 输出空格
    mov     eax, SYS_write
    mov     ebx, 1
    mov     ecx, space
    mov     edx, 1
    int     0x80
    pop     ebx

    push    ebx
    mov     ebx, [ebx + NODE_RIGHT]
    call    inorder_print
    pop     ebx

.ret:
    ret

注意:每次递归调用前将当前节点 ebx 压栈,因为 call 会改变 ebx(子调用中用它作为参数)。输出完再恢复。这种手动保存/恢复是汇编中常见的模式。


8. 数字输出:print_int 的完整实现

因为没有 printf,必须自己将整数转为字符串并调用 write。实现如下:

print_int:
    push    ebx
    push    ecx
    push    edx
    push    edi
    push    esi

    xor     esi, esi          ; sign flag
    test    eax, eax
    jns     .conv
    neg     eax
    mov     esi, 1

.conv:
    mov     edi, numbuf + 15
    xor     ecx, ecx          ; digit count
    mov     ebx, 10
.conv_loop:
    xor     edx, edx
    div     ebx               ; eax = quotient, edx = remainder
    add     edx, '0'
    dec     edi
    mov     [edi], dl
    inc     ecx
    test    eax, eax
    jnz     .conv_loop

    test    esi, esi
    jz      .out
    dec     edi
    mov     byte [edi], '-'
    inc     ecx

.out:
    mov     edx, ecx          ; length
    mov     ecx, edi          ; buffer
    mov     eax, SYS_write
    mov     ebx, 1
    int     0x80

    pop     esi
    pop     edi
    pop     edx
    pop     ecx
    pop     ebx
    ret

要点

  • 使用 numbuf 从末尾向前填充,最后 edi 指向起始地址。
  • 支持负数(测试中没有,但保留)。
  • 通过 div 逐位提取十进制数字,每次除以 10。
  • 保存和恢复所有被修改的寄存器(调用者保存)。

9. 入口 _start 与测试驱动

_start:
    ; 初始化 NIL
    mov     dword [nil_node + NODE_COLOR], BLACK
    mov     eax, nil_node
    mov     dword [nil_node + NODE_LEFT], eax
    mov     dword [nil_node + NODE_RIGHT], eax
    mov     dword [nil_node + NODE_PARENT], eax

    mov     dword [root], nil_node

    ; 初始化堆
    mov     eax, SYS_brk
    xor     ebx, ebx
    int     0x80
    mov     [heap_cur], eax
    mov     [heap_end], eax

    ; 循环插入 keys
    xor     esi, esi
.insert_loop:
    cmp     esi, keys_count
    jge     .insert_done
    mov     eax, [keys + esi*4]
    call    rb_insert
    inc     esi
    jmp     .insert_loop
.insert_done:

    ; 输出 "inorder: "
    mov     eax, SYS_write
    mov     ebx, 1
    mov     ecx, msg_inorder
    mov     edx, msg_inorder_len
    int     0x80

    ; 中序遍历
    mov     ebx, [root]
    call    inorder_print

    ; 换行
    mov     eax, SYS_write
    mov     ebx, 1
    mov     ecx, nl
    mov     edx, 1
    int     0x80

    ; 退出
    mov     eax, SYS_exit
    xor     ebx, ebx
    int     0x80

流程清晰:

  1. 初始化哨兵。
  2. 获取初始堆顶(brk(0))。
  3. 逐个插入数据。
  4. 打印前缀。
  5. 递归中序。
  6. 换行并退出。

10. 编译、链接与运行

nasm -f elf32 rbtree.asm -o rbtree.o
ld -m elf_i386 rbtree.o -o rbtree
./rbtree

期望输出:

inorder: 1 5 8 12 15 17 19 20 25 31 38 41 43 50 60

(注意末尾有空格)
实际运行效果如下(可用 qemu-i386 或直接在 64 位系统上用 gcc -m32 链接,但这里用纯 ld)。


11. 深入细节:汇编中的常见陷阱与注意事项

11.1 寄存器滥用

代码中 eax 经常被用作临时变量,但在 rb_alloc 中返回后,紧接着 pop ecx 恢复 key,这里必须小心 eax 不被覆盖。我们已经确保 rb_alloccall 前将 key 压栈,返回后弹出,所以安全。

11.2 指针 vs 数值

所有指针都是 4 字节,用 dword 操作。注意 nil_node 是符号地址,在指令中可以直接使用,汇编器会将其转为立即数(但注意它是 .bss 中的地址)。

11.3 条件跳转

cmpjnejejge 等要仔细。例如 cmp ecx, [edx + NODE_KEY] 后用 jge 表示 >= 时走右子树,符合 BST 规则。

11.4 颜色比较

颜色用 dword 存储,cmp dword [eax + NODE_COLOR], RED 是合法的,因为 RED 是立即数 0。也可直接 test,但 cmp 更清晰。

11.5 递归深度

测试树高很小,递归安全。如果插入大量数据,则需考虑栈溢出,但这里仅为演示。


12. 为什么用汇编?—— 性能与理解的双重收获

有人会说:现在谁还用汇编写红黑树?没错,生产代码当然用 C++/Rust。但汇编让你彻底明白:

  • 数据结构在内存中如何布局(没有编译器帮你 offsetof)。
  • 指针操作如何影响寄存器。
  • 系统调用如何与用户态交互。
  • 算法中的每个分支都对应哪几条机器指令。

这种“贴近金属”的体验,对于理解计算机系统底层至关重要。此外,当你调试 C 程序时,也能更好地理解反汇编代码。


13. 扩展思考:删除操作与更多功能

本代码只实现了插入,没有删除。红黑树的删除更复杂,需要处理“双黑”等情况。有兴趣的读者可以尝试扩展,实现 rb_deleterb_delete_fixup。需要额外考虑:

  • 寻找后继节点。
  • 颜色变换与旋转的多种情形。
  • 处理 nil_node 的父指针更新。

另外,当前中序遍历是递归的,如果想避免递归,可以用栈模拟,但代码会更长。


14. 常见问题与调试建议

  • Q:为什么运行后没有输出?
    检查是否在 32 位系统或已安装 qemu-i386。64 位 Linux 需要安装 gcc-multilib 或使用 qemu

  • Q:brk 失败怎么办?
    测试数据极少,不会失败。若扩展,要检查 eax 是否为 -1。

  • Q:如何验证红黑树性质?
    可以写一个 check_rb 函数,遍历树检查黑高、无连续红、根黑等。可作为练习。

  • Q:节点为什么是 20 字节,而不是 16?
    因为颜色我们用了 dword(4 字节),如果压缩为 byte,可以减小到 17 字节,但会破坏对齐。为简单,全用 dword


15. 总结与收获

通过这篇长文,我们从零开始剖析了一个完整的汇编红黑树实现。你不仅学会了红黑树插入的汇编翻译,还掌握了 brk 内存管理、系统调用、递归遍历和数字打印等实用技巧。

更重要的是,你亲眼见证了算法书上的伪代码如何一步步变成可在裸机上运行的二进制代码。这种从抽象到具体的过程,正是计算机科学的核心魅力。

最后,鼓励你动手修改代码:

  • 尝试不同的测试数据。
  • 添加删除功能。
  • 改成 64 位版本。
  • 或者实现一个 find 函数。

只有亲自踩坑,才能学得最扎实。
Удачного программирования!


如果你喜欢这篇博客,欢迎分享给同样热爱底层的小伙伴。如有任何问题,请留言讨论。Спасибо

内容概要:本报告基于寻汇与万事达卡在2026年联合发布的《超越自动化:定义智能体驱动的全球支付》白皮书,系统分析了AI智能体在B2B跨境支付领域的应用与发展。报告指出,传统跨境支付存在效率低、人工干预多、合规风险高等问题,当前正从数字化、数据化迈向“自主化”新阶段。AI智能体可在授权下自主完成支付、换汇、合规审核、对账等全流程操作,核心技术包括深度强化学习、自然语言处理和图神经网络,用于路径优化、合规解析与异常检测。报告揭示了决策可解释性不足、跨系统协同标准缺失、安全审计机制缺位三大研究空白,并探讨了法律责任归属、监管碎片化、数据主权与技术可靠性四大现实挑战。寻汇与万事达卡的合作构建了“智能体编排引擎”与全球合规决策网络,首次提出L0-L5的智能体自主化等级框架,推动行业标准化。预计2026至2027年将实现首批大规模商业部署,提升支付效率超30%。; 适合人群:金融科技研究人员、AI技术开发者、跨境支付行业从业者、企业财资管理人员及政策监管机构相关人员。; 使用场景及目标:①理解AI智能体在跨境支付中的技术架构与应用场景;②把握自主化支付的演进趋势与商业化前景;③为金融机构和技术公司布局AI驱动型支付系统提供战略参考;④助力监管机构制定适应智能体时代的合规框架。; 阅读建议:本报告兼具技术深度与产业视野,建议结合白皮书原文及相关技术文献对照研读,重点关注智能体决策逻辑、合规实现机制与跨系统集成方案,并关注后续试点项目的实际成效与监管反馈。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值