Полный текст — около 16 тысяч слов. Рекомендуется читать вдумчиво, лучше всего открыв две баночки любимого напитка
Вы получите: суть красно-чёрных деревьев, адресацию в ассемблере, системный вызовbrk, повороты и перекраску, рекурсивный обход, а также красно-чёрное дерево, которое вы «запустите» своими руками.
全文约 1.6 万字,建议静心阅读,最好打开两瓶快乐水。
你将收获:红黑树本质、汇编寻址、系统调用brk、旋转与着色、递归遍历、以及一颗亲手“运行”的红黑树。

0. 开头
Всем привет, 很多人学数据结构时,红黑树是绕不过去的坎。CLRS(《算法导论》)用十几页讲插入修复,伪代码写得清清楚楚,但真让你用 C 写一遍,你可能会被指针绕晕。如果更进一步,用汇编来实现呢?——没有编译器帮你优化,没有 malloc,连 printf 都没有,只有赤裸裸的寄存器、内存地址和系统调用。
本文要分析一个用 NASM x86-32 编写的红黑树完整实现,它不依赖任何 C 库,只用 Linux 的 brk 系统调用来动态申请内存,最后以中序遍历打印所有键值。代码总共约 300 行,却麻雀虽小五脏俱全。
我会带着你逐段拆解,从红黑树理论到汇编细节,从内存布局到旋转修复,确保你看完能独立写出自己的汇编版红黑树或者至少能吹牛 : D
1. 红黑树速览(五分钟重温)
红黑树是一种自平衡二叉搜索树,每个节点有颜色(红/黑),并满足五条性质:
- 每个节点要么红,要么黑。
- 根是黑色。
- 所有叶子(NIL)是黑色。
- 如果一个节点是红色,则它的两个子节点都是黑色(即不能有连续红色)。
- 从任意节点到其每个叶子(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 在底层通常调用 brk 或 mmap。这里直接封装了 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
流程:
- 尝试从当前
heap_cur分配 20 字节,如果heap_cur + 20 <= heap_end则直接分配。 - 否则调用
brk增加 4096 字节(一页),更新heap_end,再分配。 - 返回分配前的
heap_cur指针,并递增。 - 将新节点的左、右、父全部指向
nil_node,颜色设为红色。
注意:这里没有考虑 brk 失败(内存耗尽),因为测试数据很少。实际生产需要检查返回值。
系统调用 brk 细节:int 0x80 时 eax=45,ebx 为新的程序中断地址。若 ebx=0 则返回当前中断地址(用于初始化)。在 _start 中我们先用 brk(0) 获取初始值。
这种分配器释放内存只能整个进程退出后归还,但对我们单次运行足够了。
5. 旋转操作:左旋与右旋
红黑树的修复依赖于旋转,旋转不改变 BST 性质,但能改变树的形状和颜色关系。代码实现了 left_rotate 和 right_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 自环),所以没有空指针异常。
右旋完全对称,只需将 LEFT 与 RIGHT 互换即可,这里不赘述。
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
流程清晰:
- 初始化哨兵。
- 获取初始堆顶(
brk(0))。 - 逐个插入数据。
- 打印前缀。
- 递归中序。
- 换行并退出。
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_alloc 在 call 前将 key 压栈,返回后弹出,所以安全。
11.2 指针 vs 数值
所有指针都是 4 字节,用 dword 操作。注意 nil_node 是符号地址,在指令中可以直接使用,汇编器会将其转为立即数(但注意它是 .bss 中的地址)。
11.3 条件跳转
cmp 后 jne、je、jge 等要仔细。例如 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_delete 和 rb_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函数。
只有亲自踩坑,才能学得最扎实。
Удачного программирования!
如果你喜欢这篇博客,欢迎分享给同样热爱底层的小伙伴。如有任何问题,请留言讨论。Спасибо

359

被折叠的 条评论
为什么被折叠?



