bt

8086 汇编上手:VS Code + DOSBox-X 环境搭建与 14 课实战

· 28 min read · 汇编语言 , 8086 , dosbox , masm , 实验环境

⚠︎ This post has no English translation yet — showing the original Chinese text.

这篇和仓库里那篇《汇编语言(王爽·第 4 版)—— 完整教程与复习笔记》分工不同:那一篇按教材章节讲知识点,这一篇讲怎么把代码跑起来,以及 14 个动手实例各自踩到的坑。所有输出都是在 Windows + DOSBox-X 上实际跑出来的,不是抄书。

系列里的另外三篇:为什么学汇编 讲入门和路线,王爽完整教程 按教材章节查知识点,期末真题与课程项目 讲考试。

配套代码:仓库 汇编/LAB 里 L01~L14 的 .ASM 源文件。


目录

  1. 为什么 8086 需要一个”虚拟机”
  2. 环境搭建:三段式工具链
  3. 一键链:VS Code 里 Ctrl+Alt+N
  4. 第一个程序:L01HI
  5. DEBUG:看得见寄存器才学得会
  6. 14 课实战清单
  7. 六个反复出现的坑
  8. 上手路线

1. 为什么 8086 需要一个”虚拟机”

8086 是 16 位实模式 CPU,它的程序跑在 DOS 下。今天的 64 位 Windows 已经不能直接执行 DOS 的 16 位 .exe(NTVDM 在 64 位系统上被移除了,会直接报”不支持此应用”)。

所以 8086 的代码天生需要两层东西:

层负责什么能不能在 Windows 跑
汇编器 masm / 链接器 link把 .asm 变成 .exe能,本身是 Windows 程序
目标程序本身16 位 DOS 可执行文件不能,必须进 DOSBox

关键认知:编译在 Windows,运行在 DOSBox。很多教程把两步混在一起讲,结果第一次配环境就卡住——DOS 版的 MASM.EXE 要在 DOSBox 里跑,Windows 版的 ml.exe 要在命令行里跑,两个别搞混。


2. 环境搭建:三段式工具链

2.1 需要的四样东西

组件版本作用
MASM 汇编器6.15(ml.exe)汇编 .asm → .obj
Link 链接器5.60(LINK.EXE)链接 .obj → .exe
DOSBox-X任意较新版本运行 16 位 .exe
VS Code + Code Runner—一键触发上面三步

2.2 目录布局

C:\Users\<你>\asm\
├── ML615\ ml.exe / LINK.EXE,Windows 版
└── dos16\ run16-615.bat,一键脚本

2.3 /Zm 这个参数很关键

课程材料里的程序经常这么写:

main proc far
start:
...
main endp

start: 标号放在 proc far 内部。这属于 MASM 5.1 时代的写法,MASM 6.x 默认按新规则解析会报:

error A2006: undefined symbol : start

解决方法是加 /Zm(MASM 5.1 兼容模式):

ml.exe /c /Zm /Fl prog.asm

/Zm 同时兼容老师那套写法和纯教材写法,所以无脑加上就行,不用判断。

2.4 为什么还有一个 /c 和 /Fl

  • /c:只汇编不链接,交给后面的 LINK.EXE 处理。这样汇编出错时不会产生半截 .exe。
  • /Fl:顺手生成一份 .lst 列表文件。这个文件后面看宏展开时要用——它是”汇编器眼里的源码”,左边是地址和机器码,右边是你的源代码。

3. 一键链:VS Code 里 Ctrl+Alt+N

把 Code Runner 的映射配好:

{
"code-runner.executorMapByFileExtension": {
".asm": "C:\\Users\\<你>\\asm\\dos16\\run16-615.bat \"$fullFileName\""
},
"code-runner.saveFileBeforeRun": true
}

之后打开任意 .asm,按 Ctrl+Alt+N 或者右键 Run Code,脚本自动完成:

1/3 assembling (ML 6.15 /Zm, on Windows) ← 在 Windows 下汇编
2/3 linking (Segmented Linker 5.60) ← 在 Windows 下链接
3/3 running in DOSBox ← 弹 DOSBox 窗口运行

3.1 脚本里两个不起眼但救命的细节

第一,源文件先拷到临时目录。

Terminal window
copy /y "%src%" "%work%\PROG.ASM"

如果直接在工作目录汇编,路径里一旦有中文或空格(比如 C:\Users\张三\我的文档\汇编 作业\),MASM 就炸。统一改名成 PROG.ASM 放进 %TEMP% 下,什么路径都安全。

第二,自动清理旧目录。

Terminal window
for /d %%d in ("%TEMP%\asm16-*") do rd /s /q "%%d" 2>nul

否则某次编译失败留下的旧 PROG.EXE 会被误当成”这次编译成功了”。

3.2 有时候会出现 no stack segment

warning L4021: no stack segment

这不是错误。L01、L02 这类小程序的代码里没定义 stack segment,链接器不知道栈在哪,就警告一声。程序照样能跑,因为 DOS 的加载器会兜底。但等你写了 call/push(第 7 课以后),必须自己定义栈段,否则压栈会把数据段踩烂。

stack segment stack
db 200h dup(0)
stack ends

段名随便取,但第二行的 stack 组合类型必须写,链接器靠它认这是栈段。


4. 第一个程序:L01HI

data segment
msg db 'Hello World!', 0dh, 0ah
db 'I am learning 8086!$'
data ends
code segment
assume cs:code, ds:data
start:
mov ax, data ; 段地址不能直接进 DS
mov ds, ax ; 必须借用 AX 中转
mov dx, offset msg ; DX = 字符串偏移地址
mov ah, 9 ; 9 号功能:显示以 '$' 结尾的字符串
int 21h
mov ah, 4ch ; 4Ch:正常退出,返回 DOS
int 21h
code ends
end start

运行结果:

Hello World!
I am learning 8086!

4.1 三段式框架的结构

每个 DOS 汇编程序都是这个骨架,只是中间填的东西不同:

定义数据段(放字符串/数组)
定义栈段(第 7 课起必须有)
定义代码段
start: 初始化 DS
干活
mov ah,4ch / int 21h 交给 DOS 退出
end start 告诉链接器入口是 start

end 后面必须跟 start,这是链接器判断”程序从哪开始执行”的唯一依据。漏了它,.exe 能生成但一跑就乱跳。

4.2 mov ax, data / mov ds, ax 为什么要两行

因为 8086 规定段寄存器不能直接加载立即数,这条限制来自硬件:mov ds, imm16 这种编码压根不存在。只能先放到通用寄存器,再进段寄存器。

当时代码里 data 是什么?汇编阶段它是个未知数(段地址要等链接、加载时才定),所以 mov ax, data 在机器码里是 B8 04 0E——段地址的补丁位置,等 DOS 加载器把程序放进内存,才有真正的值。


5. DEBUG:看得见寄存器才学得会

汇编最难的地方在于”我看不见它在干什么”。DEBUG 就是那个 X 光机。

在 DOSBox 里:

C:\> debug L01HI.EXE
-r ← 看寄存器
-t ← 单步(执行一条指令,显示执行后状态)
-u ← 反汇编,把机器码翻译回汇编
-d ds:0 ← 看数据段内容
-g ← 连续执行到结束
-q ← 退出 debug

5.1 一次真实跟踪

进入 DEBUG 后的初始状态(节选):

AX=0000 BX=0000 CX=00FF DX=0000 SP=FFFE BP=0000 SI=0000 DI=0000
DS=0DF4 ES=0DF4 SS=0DF4 CS=0DF6 IP=0000

注意 DS=0DF4,这个值不是你的数据段,而是 DOS 给的 PSP(程序段前缀,一个 256 字节的控制块)的段地址。执行完两行初始化:

-t ; mov ax,0e04
AX=0E04 DS=0DF4 CS=0DF6
-t ; mov ds,ax
AX=0E04 DS=0E04 CS=0DF6

DS 从 0DF4 变成 0E04,这才是数据段真正的段地址。这一步的对比是理解”段地址是运行时才确定的”最直观的方式。

5.2 用 DEBUG 证明几个结论

想验证的事在 DEBUG 里做结果
机器码是不是小端序-u 看 mov dx,0000显示 BA 00 00,低字节在前
$ 到底靠什么终止-e ds:000c 00 把 $ 改成 0两行都被打印两次(找不到终止符了)
AH=9 和 AH=2 的差别-e cs:0006 41 改功能号只打印一个 A,不打印整串

第二条特别值得做:int 21h 的 9 号功能打印时不认长度,只认 $。把 $ 改掉,它就会一路吃下去,吃穿整个数据段。这就是 L01 里 msg 末尾那个 $ 不能漏的原因。


6. 14 课实战清单

每课都是可直接编译的完整程序,输出是我实际跑出来的。

L02 键盘输入 + 大小写转换:位运算的妙用

int 21h 的 1 号功能读一个字符进 AL(带回显),2 号功能把 DL 里的字符打到屏幕。

大小写转换不靠查表,靠改一位:

'a' = 0110 0001b
'A' = 0100 0001b
↑ 只差第 5 位(bit5)

所以:

and dl, 11011111b ; bit5 清 0 → 小写变大写
or dl, 00100000b ; bit5 置 1 → 大写变小写

跑一遍,输入 aB5#:

aA ← 输入 a,回显 a,程序再打印 A
Bb ← 输入 B,回显 B,程序再打印 b
5? ← 输入 5,回显 5,程序打印 ?
#Bye!

注意第一列的小写字母出现了两次:a 是 1 号功能自动回显的,A 才是程序打印的。这个”多出来一个字符”是初学者最常见的困惑,不是 bug。

L03 loop 指令与 CX

loop 标号 的执行顺序容易记反:先 CX - 1,再判断是否为 0。

CX = 26 → 循环 26 次(第 26 次时 CX 减到 0,退出)
CX = 0 → 减 1 变成 FFFFH,循环 65536 次(死循环陷阱)
CX = 1 → 减到 0,不跳转,循环 1 次

程序里两个任务:

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
*
**
***
****
*****

嵌套循环的坑在这一课暴露得最彻底:loop 只认 CX,内层循环一旦也用 CX,外层的计数就被内层吃掉了。标准解法有两个:

; 解法 A:外层换寄存器
mov bl, 1
row_loop:
mov cl, bl
mov ch, 0 ; ★CH 必须清 0,否则 CX = CH:CL 不等于 BL
star_loop:
...
loop star_loop
inc bl
cmp bl, 5
jbe row_loop
; 解法 B:push/pop 保存 CX

mov ch, 0 这一行是经典的送命题。BL 只有 8 位,CL 也只有 8 位,把 BL 塞进 CL 只是改了下半截,CH 里可能还留着垃圾。

L04 [bx] 寻址:让指针动起来

mov bx, offset arr ; BX = 数组首地址
mov cx, 10
loop_start:
mov al, [bx] ; 取 DS:BX 处一个字节
...
add bx, 2 ; dw 每个元素 2 字节
loop loop_start

输出:

arr: 8 3 9 1 7 5 2 4 6 0
sum = 45

这一课第一次碰 div,也是最容易踩的一课:

mov ax, 45
mov bl, 10
div bl ; 8 位除法:AX ÷ BL,商在 AL,余数在 AH

45 ÷ 10 → AL = 4(十位),AH = 5(个位)。但注意下面的坑——int 21h 会覆盖 AH:

div bl
mov cl, ah ; 先把个位藏进 CL
mov dl, al
add dl, '0'
mov ah, 2
int 21h ; 打印十位(AH 已经被改成 2 了)
mov dl, cl ; 取回个位
...

顺序反了就打印出错误的数字。程序里 mov si, ax 那句同理:求和结果在 AX 里,下一句就调 int 21h 打印,不先存到 SI,结果就没了。

L05 cmp 与条件跳转

cmp a, b 的本质是做减法但不存结果,只改标志位。之后用标志位组合出来的条件跳转分两套:

用途指令判据
相等je / jne / jz / jnzZF
无符号比较jb / jbe / ja / jaeCF、ZF
有符号比较jl / jle / jg / jgeSF、OF、ZF

用错组会得到反直觉的结果:80h 和 7Fh,按无符号比 80h > 7Fh(128 > 127),按有符号比 80h < 7Fh(-128 < 127)。

输出:

a - LOWERCASE
Z - UPPERCASE
5 - DIGIT
? - OTHER
m - LOWERCASE
max = 92

找最大值用”打擂台”:先假定第一个是擂主,后面的挨个挑战。

mov ax, [bx] ; 擂主
max_loop:
add bx, 2
cmp ax, [bx] ; 擂主 vs 挑战者
jae keep_champ
mov ax, [bx] ; 挑战者更大,换擂主
keep_champ:
loop max_loop

判定字符类别时的顺序也有讲究:必须先查 '0'~'9',再查大写,最后小写。

L06 冒泡排序:双层循环实战

before:49 38 65 97 76 13 27 49 55 4
after: 4 13 27 38 49 49 55 65 76 97

核心交换只有三行,用 xchg 和”基址+位移”:

mov ax, [bx]
cmp ax, [bx+2] ; 和下一个元素比(偏移 +2)
jbe no_swap
xchg ax, [bx+2] ; ax 拿到后一个数
mov [bx], ax ; 小的写回前一个位置
no_swap:
add bx, 2

这一课还演示了 div 的操作数可以是内存单元:

ten db 10
div ten

原因是 BX 已经被借去当数组指针了,BL 腾不出来当除数。同理,外层轮数不能用 CX(内层 loop 要抢),改用 DX 配 dec/jnz。

打印时”十位是 0 就补空格”是为了让 4 和 49 对齐,这是个纯排版技巧,和第 11 课的补零正好相反。

L07 call/ret 与栈:分水岭

这一课是整条路线的分水岭。call 做的事:把下一条指令的地址压栈,然后跳转;ret 做的事:从栈里弹出那个地址,跳回去。子程序的全部原理就这两句话。

0 7 42 12345 65535 300 43981

最后那个 43981 是 0ABCDh 的十进制。程序先把 AX 设成 0ABCDh,然后穿过两次 printnum 调用,最后打印 AX——值毫发无损,证明子程序把自己的寄存器保护好了。

通用十进制打印是必须背下来的套路:

pn_div:
xor dx, dx ; ★16 位除法是 DX:AX ÷ BX,DX 不清零就溢出
div bx ; AX ÷ BX,余数在 DX
push dx ; 余数(个位先出来)压栈
inc cx
test ax, ax
jnz pn_div
pn_out:
pop dx ; 栈是后进先出 → 最高位先出来
add dl, '0'
mov ah, 2
int 21h
loop pn_out

为什么要压栈?因为 div 只能从个位开始拆,但人是从最高位开始读。栈的后进先出刚好把顺序反过来。这是全课程里最漂亮的一个”数据结构顺手解决了问题”。

xor dx, dx 那句是最常被删掉然后程序崩溃的一行。16 位除法的被除数是 DX:AX 拼起来的 32 位数,DX 里留一点垃圾,商就装不进 AX,CPU 直接抛”除法溢出”,程序当场死。

恢复现场的顺序必须和保存相反:

push ax
push bx
push cx
push dx
...
pop dx
pop cx
pop bx
pop ax

写反了逻辑上”每个寄存器都恢复了”,但因为值互相错位,结果是全乱。

L08 二进制/十六进制打印:移位与查表

0A21h = 0x0A21 0000 1010 0010 0001
0FFFFh = 0xFFFF 1111 1111 1111 1111
8000h = 0x8000 1000 0000 0000 0000

两条路:

移位法——shl bx, 1 左移一位,移出去的那位进 CF。循环 16 次,“移一位、看 CF、打 ‘0’ 或 ‘1’”。

查表法——xlat 指令,效果等价于 AL = DS:[BX+AL]:

hextab db '0123456789ABCDEF'
...
xlat ; AL 里放 0~15,一条指令换成字符

比写一堆 cmp 判断优雅得多。十六进制每位 4 bit,用 rol dx, 1 连转 4 次把高 4 位挪到低 4 位,再 and al, 0Fh 取值。

L09 字符串处理四连

len = 43
count o = 4
upper = THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG
rev = god yzal eht revo spmuj xof nworb kciuq ehT

这里换了字符串结尾约定:前面都用 $(给 9 号功能打印用),这里用 0(处理数据时更灵活,不用关心 $ 正好在哪)。

批量转大写有一个隐蔽的坑:

and al, 11011111b ; 想当然地全转大写

空格是 20h = 0010 0000b,AND 0DFh 之后变成 00h——空格被”消灭”成不可见字符了。正确做法是先判断是不是小写字母,是才改。任何”批量位运算”都要先问一句:这个运算对范围内的所有值都成立吗?

反转靠反向遍历:先求长度,指针从最后一个字符往低地址倒着走(dec si + loop)。

L10 直接写显存

文本模式的显存从 B800:0000 开始,每行 80 字符,每字符 2 字节:

偶数偏移 = ASCII 码
奇数偏移 = 属性字节(高 4 位背景色,低 4 位前景色)
位置 = 行号 × 160 + 列号 × 2
mov ax, 0b800h
mov es, ax
mov es:[di], ax ; AL 是字符,AH 是属性,一次写两个字节

常用属性:07h 黑底白字,1Eh 蓝底黄字,4Eh 红底白字。

这一课的程序清屏后在屏幕中间打出蓝底黄字的 WELCOME TO 8086 ASM!。它和第 9 号功能的区别在于:9 号功能只能顺着光标往后打,写显存能定位到任意位置、任意颜色。游戏、菜单、横幅都靠这个。

L11 取系统时间与补零

int 21h 的 2Ch 功能一次返回四个值,挤在两个 16 位寄存器里:

CH = 时 CL = 分 DH = 秒 DL = 百分秒

也就是说 CX 装”时分”,DX 装”秒+百分秒”。这个布局是考试必考。

取日期是 2Ah:CX = 年,DH = 月,DL = 日,AL = 星期几。

补零是这一课的主题:9:5:0 要显示成 09:05:00。算法是先拆十位和个位,十位即使是 0 也老老实实打出来。和 L06 的”补空格”对照着看,一个对齐用、一个格式用。

L12 int 16h:BIOS 键盘中断

比 int 21h 更底层,直接摸硬件键盘。两个功能序号含义完全不同:

AH行为关键点
01h查询有没有按键只看 ZF,不把键取走
00h读取一个按键AH=扫描码,AL=ASCII码,取走就没了
ascii=61h scan=1Eh 按了 a
ascii=41h scan=1Eh 按了 A(扫描码一样,ASCII 不同)
ascii=-- scan=48h 按了 ↑(扩展键没有 ASCII 码)
ascii=1Bh scan=01h 按了 ESC → Bye!

“查询 + 读取”两段式是游戏和菜单的标准姿势:主循环一边干活,每圈查一下键盘,有键才读走。

和 int 21h 的 1 号功能有三点本质差别:int 16h 不回显、不能被输入重定向接管(因为 DOS 重定向管的是 DOS 层的输入,int 16h 直接问 BIOS 键盘缓冲区)、大小写由当前 Shift/CapsLock 状态决定而扫描码不变。

L13 伪指令与内存布局

这一课是为了考试里那类题准备的:

BUFF DB '34',-6,$ ARRAY DW 1234H 用十六进制按字节标出以上数据在内存中的存放。

与其背结论,不如把内存打印出来看。程序的实际输出:

t1 的 3 个字节: 33 34 FA ← '3' '4' 和 -6 的补码
t2 的 2 个字节: 34 12 ← 小端序
t3 的 4 个字节: 44 33 22 11
wnd 的 2 个字节: 09 00 ← $ 存的就是它自己的偏移 0009H
gap 的偏移 = 002BH, buf 的偏移 = 003CH

几个能直接拿去做题的结论:

  • DB/DW/DD 分别占 1/2/4 字节,字和双字都是低字节放低地址(小端序)。DW 1234H 在内存里是 34 12。
  • $ 不是”美元符号”,是当前偏移地址。db $ 存进去的就是这一格自己的偏移。
  • ORG $+20H 把定位计数器后移 20H 字节,之后所有变量的偏移都被推后。
  • 四个取值伪指令(考试爱考):
伪指令返回什么陷阱
OFFSET 变量偏移地址—
SEG 变量段地址—
TYPE 变量每个数据单位的字节数DB=1,DW=2,DD=4
LENGTH 变量第一个 DUP 的重复次数没有 DUP 时返回 1
SIZE 变量TYPE × LENGTH跟着 LENGTH 一起错

实测 d1 db 4 dup(?) 这类变量:TYPE = 01h,LENGTH = 04h,SIZE = 04h;而普通的 d1 db ?,LENGTH 是 1 而不是别的。

还有一条实测得来的实话:MASM 5.0 不接受 DB '34',-6,$ 这种写法,会报 error A2042: Constant expected!。考卷上可以这么写,但实验环境编不过,别在那上面浪费半小时。DW $ 可以,且存的确实是自己的偏移。

L14 宏:汇编期的文本替换

4661 OK 300 65535 5 3

宏的本质是把宏体复制过来、实参替换形参,发生在汇编期,不是运行期的调用。三个运算符是重点:

MSG MACRO P1, P2, P3
IN&P1 P2 P3
ENDM
  • &:拼接。IN&P1 把 IN 和 P1 的值缝在一起。P1=1 → IN1(当数据名);P1=C → INC(当指令助记符)。
  • %:展开。把 K=1 这类等值符号先算出数值再传参。不写 %,传过去的是字面 K,缝出 INK,名字对不上就报错。
  • LOCAL:宏体里定义标号必须用它。宏调用两次 = 标号出现两次 → Symbol already defined。

输出里 4661 对应宏展开出的 INC AX 真的执行了,OK 对应宏展开出的数据 IN1。

怎么看宏到底展开成了什么:看列表文件 .lst,搜索 IN1 和 INC:

masm L14MAC, L14MAC.OBJ, L14MAC.LST;

用 /Fl 参数(第 2 节那个一键脚本已经带上了)也会自动生成。

仓库的 汇编/LAB/L14LIS.TXT 是一份现成的列表文件,打开搜 inc ax 就能看见展开的痕迹。

自己生成时注意:列表文件会把源码注释里的中文一起写进去(GBK 编码),在 VS Code 里显示成乱码。别管乱码,汇编出来的指令、地址、机器码全是纯 ASCII,搜索一样能找到。想看中文就用记事本以 ANSI 编码打开。

宏和子程序的取舍(必考简答):

宏子程序
展开时机汇编期运行期
代码体积每调用一次复制一份,变胖只有一份,省空间
运行开销无每次 call/ret 有压栈开销
适用短小、频繁长功能、逻辑复杂

7. 六个反复出现的坑

坑 1:int 21h 会覆盖 AX/AH

这是 14 课里出现频率最高的坑。任何”算完要打印”的值,先存到别处:

mov si, ax ; 先救走
mov dx, offset msg
mov ah, 9
int 21h
mov ax, si ; 再取回

坑 2:div 之前忘了 xor dx, dx

16 位除法是 DX:AX ÷ 操作数,商在 AX、余数在 DX。DX 不清零就是给了除数一个 32 位的巨型被除数,商装不下 AX 就抛除法溢出。

8 位除法 div bl 同理:要求商能装进 AL,也就是 AX ÷ BL < 256。AX=300, BL=10 会得到 30,没问题;AX=3000, BL=10 得到 300,装不进 8 位,崩溃。

坑 3:段寄存器不能直接加载立即数

mov ds, data ; ✗ 不合法
mov ax, data ; ✓
mov ds, ax

坑 4:loop 只认 CX,嵌套循环必抢

内层用 CX,外层就得换寄存器或压栈保存。

坑 5:cmp 之后跳对组

无符号用 ja/jb,有符号用 jg/jl。处理字符和数组下标用无符号那组,处理可能为负的数值用有符号那组。

坑 6:写了 call/push 却没定义栈段

前六课不定义栈段也能跑(no stack segment 只是警告),从第 7 课开始必须定义,否则压栈会一直往下长,踩穿数据段。


8. 上手路线

如果只想过一遍手感,六天够了:

天内容重点
1L01 → L02 → L03程序框架、DOS 输入输出、loop 与 CX
2L04 → L05[bx] 寻址、div 拆分、cmp 与条件跳转
3L06 → L07冒泡、call/ret、栈、通用十进制打印
4L08 → L09移位、xlat 查表、字符串处理
5L10 → L11 → L12显存、系统时间、BIOS 键盘
6L13 → L14伪指令、内存布局、宏

每天做完记得做两件事:

  1. 改参数再跑一遍。把 L05 的找最大值改成找最小值(jae 换 jbe),把 L06 改成降序,把 L03 打印 a~z。改一次胜过读十遍。
  2. 在 DEBUG 里单步走一遍。-t 看寄存器怎么变,-d ds:0 看内存怎么变。汇编是”看得见才学得会”的科目。

环境一旦一键化,剩下的事就只有写和调。先把第一个 Hello World 跑出来,后面的东西都是在这个骨架上长出来的。