启动汇编代码与CPU架构:x86、ARM汇编指令详解
2025-03-11 20:14:06
启动时汇编代码是否与特定体系结构相关?
最近在研究一段用于启动Linux的引导加载程序代码,看到这么一段:
static inline u16 ds(void)
{
u16 seg;
asm("movw %%ds,%0" : "=rm" (seg));
return seg;
}
我猜这是一种把汇编代码内联到 C 程序中的方法。 挺好奇这玩意儿怎么转成纯汇编?这种写法在 x86 和 ARM 架构上都通用吗?还是说它是一种更高层次的抽象,与具体架构无关?
目前看下来,感觉这条指令是把寄存器零 (0) 里面的字(word)移动到某个地方。
可能是下面这两种情况之一:
- 移动到
ds
寄存器本身。 - 移动到
ds
寄存器里存的那个地址。
或者,也许干了点别的?ds
前面那两个 %%
是啥意思?
代码解析及原因分析
咱们来捋一捋。这段 C 代码使用 GCC 的内联汇编(Inline Assembly)特性。内联汇编允许我们在 C 代码中直接嵌入汇编指令。这种方式很方便,比如要直接操作硬件、进行底层优化,或者使用某些 C 语言本身不支持的特殊指令时,就特别管用。
上面代码的含义, 就是把ds寄存器的值取出来, 放在变量seg中.
不过,直接写汇编通常可移植性比较差,因为它与特定的 CPU 架构紧密相关。x86、ARM、MIPS…… 不同的 CPU 有不同的指令集。一段 x86 汇编代码,直接搬到 ARM 上肯定跑不起来。
那,咱就逐行解读下这段代码,看看它到底干了啥:
-
static inline u16 ds(void)
: 定义了一个名为ds
的内联函数,它没有参数,返回值是 16 位无符号整数 (u16)。static
表示函数只在当前文件内可见,inline
提示编译器尝试将函数调用直接替换为函数体代码,以减少函数调用的开销。 -
u16 seg;
: 声明了一个 16 位的无符号整型变量seg
,用来存储从ds
寄存器读取的值。 -
asm("movw %%ds,%0" : "=rm" (seg));
: 这就是关键的内联汇编部分。asm(...)
: GCC 内联汇编的,告诉编译器这里是汇编代码。"movw %%ds,%0"
: 这是要执行的汇编指令,使用 AT&T 语法(别担心,下面会细说)。movw
表示移动一个字(16 位)。%%ds
是源操作数,表示ds
段寄存器。%0
是目标操作数,它是一个占位符,对应后面输出操作数列表中的第一个变量,也就是seg
。: "=rm" (seg)
: 这部分是输出操作数列表。=rm
是一个约束,告诉编译器seg
变量可以放在寄存器(r
)或内存(m
)中,由编译器决定最合适的位置。=
表示seg
是一个只写(write-only)操作数,它的值会被汇编指令修改。
AT&T 与 Intel 汇编语法差异
上面代码段的内敛汇编使用的是 AT&T 语法。日常写汇编, 两种汇编格式更可能接触到:
- Intel 语法: 微软家的 MASM、Borland 的 TASM,以及 NASM 汇编器常用这种格式。Windows 下开发常用。
- AT&T 语法: GCC、GAS 汇编器使用。通常在类 Unix 系统(如 Linux)下更常见。
这两兄弟长得不太一样, 主要区别:
- 操作数顺序: Intel 语法是
[目标操作数], [源操作数]
,而 AT&T 语法是[源操作数], [目标操作数]
。就像“把... 放到 ...里” vs “从 ...里 取出来 放到 ...里”。 - 寄存器命名: AT&T 语法中,寄存器名前面要加
%
。比如%eax
、%ds
。Intel 语法直接写寄存器名,如EAX
、DS
。 - 立即数: AT&T 语法中,立即数(常数)前面要加
$
符号。 - 内存寻址: AT&T 语法和 Intel 语法中,表示内存地址的语法也有区别,比如基址变址寻址的写法不同。
那么, 我们把上面的内联汇编“翻译”成纯汇编,而且分别用 Intel 语法和 AT&T 语法写出来, 对比如下:
AT&T 语法(与内联汇编中相同):
movw %ds, %ax ; 假设编译器选择 ax 寄存器来存储 seg 变量
Intel 语法:
mov ax, ds ; 假设编译器选择 ax 寄存器来存储 seg 变量
清楚了把? 原理都是一样的,只是“写法”有差别。
针对架构的解决方案及深入探索
既然内联汇编这么“挑”架构,如果我们真的想写一段在不同架构启动的 boot loader上都能跑的汇编代码,该怎么办?
有以下几条路:
-
完全条件编译:
对每种支持的架构, 分别提供一份汇编代码实现, 类似于:u16 get_ds(void) { #if defined(__x86_64__) || defined(__i386__) u16 seg; asm("movw %%ds,%0" : "=rm" (seg)); return seg; #elif defined(__arm__) // ARM 架构的汇编代码 uint16_t seg; __asm__("mrs %0, CPSR" : "=r" (seg)); // 举例:获取 CPSR 寄存器 // 实际上获取段寄存器的值可能需要更复杂的逻辑, 取决于当前 ARM 的运行模式 return seg; //需要做处理。 #else #error "Unsupported architecture" #endif }
这么干, 代码直接膨胀几倍...
-
统一汇编接口(函数):
如果不同的体系结构做的事情很类似,只是用的汇编指令不同,那么,你可以试着抽象出一个统一的接口.
类似于这样:// 统一接口 (汇编实现, 根据不同的架构来) extern u16 asm_get_ds(void); u16 get_ds(void) { return asm_get_ds(); }
然后, 在不同的汇编文件 (.s) 里针对具体的架构来实现
asm_get_ds
。比如arch/x86/boot.s
、arch/arm/boot.s
。 编译时根据选择的体系结构决定链接哪个目标文件.这样做, 代码组织更清晰.
-
使用汇编器预处理:
像GNU assemler (as
)支持预处理器指令。我们可以根据架构定义条件,在汇编源代码级做条件包含或条件编译。 这点类似上面的C预处理.示例:
; boot.S (汇编源文件) #ifdef __x86__ movw %ds, %ax ; x86 架构代码 #endif #ifdef __arm__ ; ... ARM 架构代码 ... #endif
-
注意, 特殊情况的 boot loader :
- Bootloader 的最早期阶段通常没有 C 环境,甚至连栈都没设置好。 这时候通常是用纯汇编来完成的,比如设置 CPU 模式、初始化基本的硬件、加载内核到内存等. 这阶段的代码是必须针对特定架构编写的, 没有“通用”办法。
- 只有到设置了可用的 C 环境 (设置了堆栈), boot loader才能开始调用 C 函数。我们这里讨论的读取段寄存器的值 (
ds
register),一般是x86 16-bit 模式(real mode)时, 这通常也是Bootloader最早期工作的方式.
到了 C 环境能用以后, boot loader 后面的工作, 可以尽量写成可移植的 C 代码。如果有少量和底层架构强相关的操作,再使用内联汇编或者单独的汇编模块去实现, 就类似我们之前讲的。
-
安全考虑(进阶):
- 内联汇编可以直接操作硬件、内存,如果控制不好, 很可能会弄崩整个系统!写之前要三思,对汇编和底层原理了解的要足够。
- 在内核代码里(Linux 内核里有大量的汇编),要尽可能使用内核提供的封装好的宏和函数来操作硬件。内核做了大量保护工作,用它们更保险,直接写汇编可能会破坏内核的安全机制。
总结 :
最初的引导程序(例如BootLoader)在建立好C环境(特别是设置正确的栈之前),完全无法避免使用汇编代码进行一些底层的初始化。 并且,那些代码是特定架构(体系结构)相关的. 在C语言环境建立起来之后,才有了尽可能实现通用的启动操作逻辑的条件。