返回

启动汇编代码与CPU架构:x86、ARM汇编指令详解

Linux

启动时汇编代码是否与特定体系结构相关?

最近在研究一段用于启动Linux的引导加载程序代码,看到这么一段:

static inline u16 ds(void)
{
    u16 seg;
    asm("movw %%ds,%0" : "=rm" (seg));
    return seg;
}

我猜这是一种把汇编代码内联到 C 程序中的方法。 挺好奇这玩意儿怎么转成纯汇编?这种写法在 x86 和 ARM 架构上都通用吗?还是说它是一种更高层次的抽象,与具体架构无关?

目前看下来,感觉这条指令是把寄存器零 (0) 里面的字(word)移动到某个地方。

可能是下面这两种情况之一:

  1. 移动到 ds 寄存器本身。
  2. 移动到 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 语法。日常写汇编, 两种汇编格式更可能接触到:

  1. Intel 语法: 微软家的 MASM、Borland 的 TASM,以及 NASM 汇编器常用这种格式。Windows 下开发常用。
  2. AT&T 语法: GCC、GAS 汇编器使用。通常在类 Unix 系统(如 Linux)下更常见。

这两兄弟长得不太一样, 主要区别:

  • 操作数顺序: Intel 语法是 [目标操作数], [源操作数],而 AT&T 语法是 [源操作数], [目标操作数]。就像“把... 放到 ...里” vs “从 ...里 取出来 放到 ...里”。
  • 寄存器命名: AT&T 语法中,寄存器名前面要加 %。比如 %eax%ds。Intel 语法直接写寄存器名,如 EAXDS
  • 立即数: AT&T 语法中,立即数(常数)前面要加 $ 符号。
  • 内存寻址: AT&T 语法和 Intel 语法中,表示内存地址的语法也有区别,比如基址变址寻址的写法不同。

那么, 我们把上面的内联汇编“翻译”成纯汇编,而且分别用 Intel 语法和 AT&T 语法写出来, 对比如下:

AT&T 语法(与内联汇编中相同):

movw %ds, %ax ; 假设编译器选择 ax 寄存器来存储 seg 变量

Intel 语法:

mov ax, ds  ; 假设编译器选择 ax 寄存器来存储 seg 变量

清楚了把? 原理都是一样的,只是“写法”有差别。

针对架构的解决方案及深入探索

既然内联汇编这么“挑”架构,如果我们真的想写一段在不同架构启动的 boot loader上都能跑的汇编代码,该怎么办?

有以下几条路:

  1. 完全条件编译:
    对每种支持的架构, 分别提供一份汇编代码实现, 类似于:

    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
    }
    
    

    这么干, 代码直接膨胀几倍...

  2. 统一汇编接口(函数):
    如果不同的体系结构做的事情很类似,只是用的汇编指令不同,那么,你可以试着抽象出一个统一的接口.
    类似于这样:

    // 统一接口 (汇编实现, 根据不同的架构来)
    extern u16 asm_get_ds(void);
    
    u16 get_ds(void) {
        return asm_get_ds();
    }
    

    然后, 在不同的汇编文件 (.s) 里针对具体的架构来实现 asm_get_ds。比如 arch/x86/boot.sarch/arm/boot.s。 编译时根据选择的体系结构决定链接哪个目标文件.

    这样做, 代码组织更清晰.

  3. 使用汇编器预处理:
    像GNU assemler (as)支持预处理器指令。我们可以根据架构定义条件,在汇编源代码级做条件包含或条件编译。 这点类似上面的C预处理.

    示例:

    ; boot.S (汇编源文件)
    #ifdef __x86__
        movw %ds, %ax   ; x86 架构代码
    #endif
    
    #ifdef __arm__
        ; ... ARM 架构代码 ...
    #endif
    
  4. 注意, 特殊情况的 boot loader :

    • Bootloader 的最早期阶段通常没有 C 环境,甚至连栈都没设置好。 这时候通常是用纯汇编来完成的,比如设置 CPU 模式、初始化基本的硬件、加载内核到内存等. 这阶段的代码是必须针对特定架构编写的, 没有“通用”办法。
    • 只有到设置了可用的 C 环境 (设置了堆栈), boot loader才能开始调用 C 函数。我们这里讨论的读取段寄存器的值 (ds register),一般是x86 16-bit 模式(real mode)时, 这通常也是Bootloader最早期工作的方式.

    到了 C 环境能用以后, boot loader 后面的工作, 可以尽量写成可移植的 C 代码。如果有少量和底层架构强相关的操作,再使用内联汇编或者单独的汇编模块去实现, 就类似我们之前讲的。

  5. 安全考虑(进阶):

    • 内联汇编可以直接操作硬件、内存,如果控制不好, 很可能会弄崩整个系统!写之前要三思,对汇编和底层原理了解的要足够。
    • 在内核代码里(Linux 内核里有大量的汇编),要尽可能使用内核提供的封装好的宏和函数来操作硬件。内核做了大量保护工作,用它们更保险,直接写汇编可能会破坏内核的安全机制。

总结 :
最初的引导程序(例如BootLoader)在建立好C环境(特别是设置正确的栈之前),完全无法避免使用汇编代码进行一些底层的初始化。 并且,那些代码是特定架构(体系结构)相关的. 在C语言环境建立起来之后,才有了尽可能实现通用的启动操作逻辑的条件。