第一次在别人的引导扇区代码里看到org 0x7C00这行,我下意识把它当成了某种处理器指令,翻了大半天的指令手册也没找到对应的操作码,直到把生成的二进制文件翻出来对照,才发现这行代码一个字节都没产出。这个误会其实挺普遍的,尤其是从高级语言转过来的朋友,很容易把org、equ、db这些汇编器伪指令和真正的机器指令混在一起看。ORG在汇编语言里干的活,说白了就是给汇编器报一个门牌号:接下来的这段代码,你按这个地址来计算所有标号的值。它不搬数据、不占空间、不产生机器码,却能让一段逻辑完全正确的程序在真实硬件上一行都跑不起来。这篇内容我打算把ORG从"它是什么"一直聊到"它出错时怎么查",覆盖 NASM、GNU as、MASM 三套常见工具链的行为差异,以及从裸机引导、固件搬到内存执行、到链接脚本接管地址这几类真实场景。适合刚接触汇编和裸机开发、被地址问题折磨过的人,也适合想把自己脑子里的地址模型重新校准一遍的老手。
1. 把 ORG 从"指令"里摘出来:它改的是汇编器的账本
1.1 位置计数器才是 ORG 唯一操作的对象
汇编器在逐行扫描源文件的时候,内部一直维护着一个数字,通常叫位置计数器(location counter),有的文档写成$,有的写成.。每生成一个字节,这个数字就加一;每遇到一条指令,就按指令长度往后推。你在源码里写的所有标号,本质上是"在这个时刻把当前位置计数器的值记下来"。所以标号的值从来不是随机的,它完全取决于计数器当时走到了哪里。ORG做的事情只有一件:把这个计数器强行改写成你给的数。改完之后,后面所有标号的计算基准全部平移。
这就解释了一个很多人困惑的现象:为什么加了ORG之后,生成的文件大小一点没变,但反汇编出来的地址全变了。因为文件里的字节序列没动过,动的只是汇编器心里那本账。我在早期调试时习惯用十六进制编辑器直接看.bin文件,看到开头还是那几条指令,就以为ORG没生效,实际上是看错了地方——文件偏移和内存地址是两个坐标系,ORG只影响后者。
理解这一点之后,后面所有坑都好解释了。ORG不改变代码放在文件里的位置,它改变的是代码"自认为"住在内存的哪个位置。这两件事在简单场景下恰好一致,在复杂场景下经常不一致,而绝大多数ORG相关的故障,根子都在这个不一致上。
1.2 符号地址错了,和映像放错了,是两类完全不同的问题
顺着上面的思路,ORG引发的错误可以干净地切成两类,分开看会省很多时间。
第一类是符号地址错。ORG写了一个值,代码里的标号就按这个值算,但程序实际被加载到了另一个地址。这种情况下,凡是依赖标号绝对值的指令全部指向错误的地方。最典型的是mov si, msg这种把标号当地址装进寄存器的写法,生成的机器码里直接嵌了一个立即数,这个立即数错了,数据就取不到。
第二类是映像尺寸错。ORG在某些工具链里会真的往文件里填字节,把空洞补齐,如果填的数量算错,生成的文件长度不对,后面配套的工具(比如校验和计算、烧录工具的长度检查)就会拒绝处理。引导扇区必须正好 512 字节就是最经典的例子。
这两类问题表现出来的症状差别很大:前者往往是程序跑飞、跳到乱七八糟的地方、读出一堆0xFF;后者通常是"文件生成了但用不了",工具直接报错或者设备根本不认。排查的时候先分清是哪一类,能省掉一大半无用功。
| 错误类型 | 典型症状 | 第一反应该看什么 |
|---|---|---|
| 符号地址错 | 程序跳到无效地址、字符串打印乱码、跳转表全错 | 反汇编的地址列与实际加载地址 |
| 映像尺寸错 | 文件长度不对、校验和不匹配、设备不识别 | 文件字节数与目标格式要求 |
| 计数器回退 | 汇编器直接报错、或前面生成的代码被覆盖 | 汇编器报错行号与段起始位置 |
提示:
ORG只影响汇编期的符号计算,它不能修正已经被写进机器码里的错误地址。一旦指令编码完成,后面再改ORG也救不回来,必须重新汇编。
2. 反推 0x7C00:ORG 值与真实装载地址的对齐关系
2.1 从一条 mov 的机器码看 ORG 到底改了什么
拿最容易复现的引导扇区场景举例,代码骨架大概长这样:
BITS 16 org 0x7C00 start: xor ax, ax mov ds, ax mov si, msg call print jmp $ print: lodsb or al, al jz .done mov ah, 0x0E int 0x10 jmp print .done: ret msg db 'boot ok', 0 times 510 - ($ - $$) db 0 dw 0xAA55把这段汇编出来,mov si, msg会被编码成BE加一个 16 位立即数。这个立即数等于msg的符号值。如果ORG是0x7C00,msg的符号值大概是0x7C00加上它在段内的偏移;如果ORG是 0,符号值就只是段内偏移,两者差了整整0x7C00。而这段代码实际是被固件搬到物理地址0x7C00去执行的,DS设成 0 之后,DS:SI寻址得到的线性地址就是SI本身。所以只有ORG等于0x7C00时,SI里的值才刚好指向内存中字符串的真实位置。
这里有个容易忽略的细节:ORG的值和加载地址不是"应该相等",而是"必须相等"。只要程序在某处使用了绝对值寻址,这个等式就是硬约束。想验证很简单,把ORG改成 0 重新汇编,机器码里那个立即数会小掉0x7C00,程序照样能跑进第一行,但打印那一步会去读一段根本不存在的内存。
2.2 相对跳转为什么能扛住错误的 ORG
有意思的地方来了:把ORG改错之后,程序往往不是立刻死掉,而是能跑一段才出问题。原因在于跳转指令分两种编码方式。
jmp short label、jmp near label、loop、jz这些,机器码里存的是相对位移,而不是目标地址。CPU 执行时是拿当前IP加上这个位移,所以只要跳转指令和目标之间的相对距离没变,ORG写错多少都不影响。上面那段代码里的call print、jmp print、jz .done全是相对形式,ORG错了它们照样跳得准。
但mov si, msg、mov ax, offset table、jmp far 0x7C00:start这类,机器码里是绝对地址,ORG一错全错。这造成了一个特别迷惑的现象:程序进入后能正常执行一连串控制流,看起来"活着",一到访问数据或者进中断就崩。很多人第一反应是数据段没设对、是栈溢出,查了半天才发现是ORG的问题。
我在实际排查里养成的习惯是:看到"控制流正常但数据访问异常"这个组合,先把反汇编拉出来,只看那些带立即数的指令,把立即数和预期地址对一遍,基本三分钟能定位。这个判断顺序反过来会浪费很多时间。
| 指令形式 | 机器码里存什么 | ORG 写错后的表现 |
|---|---|---|
jmp short/near | 相对位移 | 不受影响 |
call近调用 | 相对位移 | 不受影响 |
jmp far seg:off | 绝对段:偏移 | 跳到错误位置 |
mov reg, label | 绝对地址立即数 | 读到错误内存 |
mov ax, [label] | 绝对地址(直接寻址) | 读写出错 |
注意:别用"程序能跑起来"当作
ORG写对了的证据。相对跳转的容错性太强,能骗过很多人。
3. 三套工具链里 ORG 的三副面孔
3.1 NASM:只改符号账本,一个字节都不产
NASM 的org语义最干净:它只在-f bin输出格式下有效,作用是设置当前段的起始地址,影响$和$$两个内置符号的值,不产生任何填充字节,不改变文件长度。你在文件开头写org 0x7C00,生成的.bin第一个字节仍然对应偏移 0,只是所有标号的符号值整体加了0x7C00。
这个特性带来一个很好用的写法:times 510 - ($ - $$) db 0。因为$$本身就等于org给的起始值,$ - $$算出来的永远是文件内偏移,和ORG具体是多少无关。所以你改ORG的时候,这行补齐代码不用跟着动,它自动算对。这一点经常被误解,有人以为$ - $$算的是绝对地址差,于是把510改成510 - 0x7C00之类的奇怪数字,直接溢出。
NASM 里还有两个相关的段属性值得知道:start=和vstart=。org本质上等价于给当前段设置起始地址,而vstart控制的是段内标签的虚拟起始值,两者在单段 bin 输出里经常是同一件事,但在多段场景下行为会有差异。我的建议很直接:bin 输出只在文件最前面写一次org,不要在多段里各写各的,否则不同段之间的地址关系会变得很难推理。
如果输出格式不是 bin,比如生成 ELF 目标文件,org要么被忽略,要么直接报错,具体行为跟版本有关。这种情况下的地址该交给链接脚本来管,而不是硬塞在源码里。这一点后面第 5 节会展开。
3.2 GNU as:真的会填字节,而且不许随便往回走
GNU as 的.org和 NASM 的org长得像,行为差得远。.org new-lc会把位置计数器推进到new-lc,中间的空洞会真的用填充字节补上,所以它同时改变符号地址和输出文件大小。这一点在做向量表、跳转表占位的时候特别方便:
.section .vectors, "a" .org 0x00 .word reset_handler .org 0x10 .word timer_handler .org 0x20 .word fault_handler每两个.org之间会自动补零,最终生成的段大小是确定的。填充值默认是 0,但 as 允许你指定第二个参数:
.org 0x100, 0xFF这样0x100之前的部分全填0xFF。做 Flash 空白区填充的时候这个参数很有用,因为擦除后的 Flash 本来就该是0xFF,填 0 反而会让校验逻辑误判。
另一个必须知道的限制:.org不能往回走。如果你在一个段里已经把位置计数器推到了0x200,再写.org 0x100,as 会直接报attempt to move .org backwards之类的错误。这个限制其实是在保护你,因为往回走意味着要覆盖已经生成的字节,语义上就说不清楚。真需要往回,只能换一种组织方式,比如拆成两个段或者在源码层面重排。
.org的表达式必须是绝对地址,不能用依赖重定位的符号。想写.org some_label + 4这种,只要some_label在同一个段里且已经定义,通常可以;如果是外部符号,就算不出来,汇编阶段直接失败。
3.3 MASM:宽松的位置计数器,以及容易埋雷的回退
MASM 的ORG出现在完整段定义(SEGMENT/ENDS)的语境里,写法是ORG expression,把当前段的位置计数器设成表达式的值。它相对宽松,允许你把计数器改到比当前小的位置,但这里有个陷阱:已经生成的字节并不会因为你把计数器拨回去就消失。最终映像里那块区域的内容,取决于后面有没有新代码覆盖上去,很容易和你的预期不一致。
MYSEG SEGMENT ORG 0x100 start: mov ax, cs mov ds, ax ; ... MYSEG ENDS在简化段模型(.MODEL SMALL那一套)下,ORG用得很少,因为段的布局交给链接器了。真要控制地址,走链接选项比在源码里写ORG更靠谱。我在维护一些老代码时见过在简化段模型里硬写ORG的,行为取决于具体版本和内存模型,非常不建议模仿。
三套工具链的差异整理成一张表,遇到跨工具链迁移的时候可以直接对照:
| 对比项 | NASMorg | GNU as.org | MASMORG |
|---|---|---|---|
| 是否产生填充字节 | 否 | 是(可指定填充值) | 视情况,通常不主动填 |
| 是否影响文件大小 | 否 | 是 | 一般不 |
| 能否回退 | 不适用 | 报错 | 允许但易埋雷 |
| 生效范围 | 当前段,bin 格式 | 当前段 | 当前段 |
| 典型用途 | 裸机镜像定址 | 向量表/对齐占位 | 老式完整段布局 |
4. 排查实录:烧进去就重启的那块板子
4.1 现象与第一轮误判
说一个我自己踩过的完整链路,场景是给一块自制的板子写二级引导。板子上有一颗小容量 Flash,上电后片上固件先把 Flash 开头的一小段搬进片内 SRAM 执行,再由这段代码把后面的应用镜像搬到外部内存的0x2000_0000去跑。应用镜像编译出来大概几十 KB,单独在一个测试板上跑得好好的,换到目标板上,上电后串口只吐了一行启动信息就没了,过一会儿又吐一遍——典型的反复复位。
第一轮判断走了弯路。我先怀疑是栈没设好,把栈顶改到内存末端,没用;然后怀疑是时钟初始化时序问题,加了一堆延时,还是重启;接着怀疑外部内存的时序参数不对,拿示波器量了半天,波形挺干净。整个过程大概耗掉一个下午,方向完全偏了,因为我把"重启"当成了硬件问题。
真正的转折点是换了块调试板,把复位向量配成直接跳到一个死循环,发现程序能进死循环,说明搬运和跳转都是好的。那就意味着问题出在进入应用之后的某个具体动作上,而不是启动流程本身。这个信息把范围从"整个启动链"缩到了"应用的第一段代码"。
4.2 用反汇编的 VMA 参数把真相翻出来
接下来做的事情就是拉反汇编。用objdump看 ELF 的时候,地址列显示的是链接时指定的地址,和实际运行地址不一定一致,所以我额外加了按实际运行地址重定位的参数,把一个纯二进制文件按目标架构和运行地址反汇编:
objdump -D -b binary -m i8086 --adjust-vma=0x7C00 boot.bin这套参数组合在做裸机镜像分析时非常好用:-b binary告诉它输入是裸二进制,-m指定架构,--adjust-vma把反汇编的起始地址对齐到真实运行地址。不加最后这个参数,反汇编出来的地址从 0 开始,看着和源码对得上,实际上和内存里的真实位置差了一个固定偏移,越看越糊涂。
ndisasm也有对应选项:
ndisasm -o 0x7C00 boot.bin把应用镜像按运行地址反汇编之后,问题几乎是立刻跳出来的:向量表里那一堆绝对地址,全部指向0x0800_0000开头的区域,而不是0x2000_0000。也就是说,代码被搬到内存里去了,但它脑子里的地址还停留在 Flash 上。
4.3 根因与修复:镜像的链接地址和运行地址分家了
往前追,根因很明确:这个应用的链接脚本是从另一个项目里抄过来的,段起始地址写的是 Flash 的基址0x0800_0000,因为那个项目是直接在 Flash 上原地执行的。而这块板子的方案是搬到内存跑,实际运行地址是0x2000_0000。搬运程序老老实实把镜像搬到了内存,跳过去之后,代码里的每一条绝对寻址、每一个向量表项、每一处函数指针,全都指着 Flash 里那份旧拷贝。Flash 里那份内容还是对的,所以简单调用能跑;一旦触发中断,硬件去内存里的向量表取入口,取到的地址指向 Flash,跳过去执行的又是另一套阶段,很快就乱掉,看门狗一超时就复位。
修复其实只需要改一处:把运行地址改到0x2000_0000,同时用加载地址把镜像的存放位置仍然定在 Flash。这一步在链接脚本里就是AT()的活,后面第 5 节会讲。改完之后重新编译,反汇编里向量表地址全落在0x2000_0000区间,烧进去一次就跑通。
这次故障给我留下一个很具体的检查习惯:只要涉及"代码被搬到别处执行",先把镜像反汇编出来,扫一遍所有立即数形态的地址,确认它们落在运行地址区间而不是存放地址区间。这个动作两分钟,能省掉半天的硬件排查。
| 排查步骤 | 具体动作 | 得到的信息 |
|---|---|---|
| 1. 缩小范围 | 改成跳死循环,确认启动链是否正常 | 区分启动问题与运行问题 |
| 2. 反汇编 | 按实际运行地址拉出反汇编 | 看到真实生效的地址值 |
| 3. 扫立即数 | 检查向量表、函数指针、标号引用 | 判断符号地址是否错误 |
| 4. 对比布局 | 对照链接脚本的段地址 | 确认运行地址与存放地址 |
提示:反汇编如果不带地址重定位参数,地址列的可信度为零。这个参数是排查地址类问题的第一道门槛,别省。
5. 链接器接管之后,ORG 还剩多少地盘
5.1 链接脚本把 ORG 拆成了 VMA 和 LMA 两个概念
现代工具链里,ORG的职责基本被链接器接管了,但接管得并不完整——它把ORG那个单一的数字拆成了两个:运行地址(VMA)和加载地址(LMA)。VMA 决定代码"认为自己住在哪",LMA 决定镜像"实际被烧到哪"。这个拆分正是ORG做不到的部分,也是第 4 节那个故障的根源:老式ORG只有 VMA 的概念,一旦运行地址和存放地址不同,它就没法表达。
链接脚本里的写法大致是这样:
SECTIONS { . = 0x20000000; .text : { *(.text*) *(.rodata*) } .data : AT(0x08008000) { *(.data*) } }. = 0x20000000;这一行推进位置计数器,和ORG的语义几乎就是同一个东西,只不过它作用在链接阶段而不是汇编阶段。.data : AT(...)里的AT明确告诉链接器:这一段的运行地址按当前位置算,但镜像里它被放在0x08008000。搬运代码在启动时把这一段从 LMA 复制到 VMA,后续一切就都对了。
把ORG和链接脚本的元素做个对照会更清楚:
| ORG 概念 | 链接脚本对应 | 差异点 |
|---|---|---|
| 设置位置计数器的值 | . = 地址; | 语义几乎一致 |
| 只影响符号值 | VMA 概念 | 链接器区分 VMA/LMA |
| 无法表达存放地址 | AT(地址) | ORG 完全缺失的能力 |
| 段内填充 | FILL()/. = ALIGN(n) | 链接器更灵活 |
5.2 仍然必须手写 ORG 的四类场景
那ORG是不是就没用了?并不是。下面这几类场景里,手写ORG依然是最直接的做法。
第一类是纯二进制裸机镜像。没有链接器介入,汇编器直接吐出一个.bin,地址只能靠ORG指定。引导扇区、某些一次性烧录的小固件、还有各种自制玩具操作系统,都属于这一类。
第二类是 ROM 里的固定入口。有些芯片上电后固定从某个地址取指令,你想让某段代码正好落在那个位置,用ORG直接顶到那个地址上,比自己算偏移要直观得多。GNU as 的.org在这种场景下还能顺便把中间的空洞填成0xFF,一举两得。
第三类是段内偏移的精细控制。比如想让某个函数正好落在 16 字节边界上,或者想让一段数据从段内第 256 个字节开始,ORG或者.org是最短路径。GNU as 里用. = ALIGN(16)也能做到对齐,但.org 0x100表达的是绝对位置,语义更硬。
第四类是单文件级别的教学和实验。写一段几百行的汇编做实验,不想引入链接脚本,ORG一个数字就能把地址模型说清楚。这种情况下用链接器反而是过度设计。
反过来说,只要项目里已经引入了链接脚本,就不要再在汇编源码里写ORG,两者同时存在会让地址来源变得不可追踪。我在一个项目里见过源码里写ORG 0x8000、链接脚本又写. = 0x08000000的情况,最后谁也说不清某个标号为什么是那个值,只能靠反汇编倒推。
6. 我自己的 ORG 检查清单
写多了之后,我形成了一套固定的自查动作,基本上能在编译完成到烧录之前把地址类问题挡住,列出来供参考。
第一件事,打开汇编器生成的列表文件看地址列。NASM 加-l参数就能生成:
nasm -f bin -l boot.lst boot.asm列表文件里每一行前面都有对应的地址和生成的机器码,比反汇编更贴近源码,定位问题快得多。我一般会扫一眼第一个标号的地址是不是预期值,再扫一眼最后那个补齐表达式的计算结果。
第二件事,核对ORG值和实际加载地址是否相等。这个"实际加载地址"要去看搬运代码或者固件的约定,不能凭印象。有些板子上电后跳转用的段:偏移组合并不统一,CS可能是0x0000也可能是别的值,所以第一段代码里最好自己把段寄存器拍平,别假设CS是多少。
第三件事,检查是不是所有绝对寻址都落在正确的地址区间。向量表、函数指针表、字符串标号引用——这三类最容易出问题,挨个确认一遍。
第四件事,算清楚文件长度。引导扇区是 512 字节且以固定标记结尾,这个用times或者.org都能控制,但算完最好用ls -l或者stat实际看一眼,别只看源码里的数字。
第五件事,如果项目里已经有链接脚本,回头把源码里的ORG全删掉,地址只留一个来源。
还有一个容易被忽略的点:ORG的值必须是偶数对齐的场景要留意。某些架构要求指令地址对齐,ORG设成奇数虽然汇编器不报错,运行时可能直接触发异常。这类限制得去翻架构手册,别想当然。
最后分享一个我觉得最省事的习惯:任何涉及地址的改动,改完立刻反汇编一次,把前后两份反汇编 diff 一下。地址类问题的表现往往很隐蔽,但 diff 里那些变动的立即数是藏不住的。这个动作花不了半分钟,比我事后拿着示波器猜要划算太多。