1.4.1 基本内联汇编

227 阅读11分钟

1.4 汇编语言

在进行内核代码开发的过程中,有很多地方需要直接操作 CPU 寄存器和 I/O 端口,用 C/C++ 无法完成相应的功能。使用汇编的场景主要有两种: 一是操作系统引导和初始化阶段需要大量地直接操作 I/O 端口,使用汇编语言会很高效; 二是内核代码中需要操作硬件,例如修改某些状态寄存器,改变 CPU 的工作模式等,这种情况就是以内联汇编为主。

每一种 C 编译器的内联汇编都不尽相同,下面详细介绍 GCC 内联汇编的语法。

实模式内存布局

  • 关键区域:
    • 0x00000~0x003FF:中断向量表(1KB)。
    • 0x7C00~0x7DFF:引导扇区(512 字节)。
    • 0xA0000~0xBFFFF:显存区域。
  • 地址计算:
    • 物理地址 = 段寄存器 × 16 + 偏移地址
    • 例如:DS=0x7C0,BX=0x0005 → 物理地址 0x7C05。

那操作系统内核的代码在实模式下是如何布局的呢?

实模式下典型的内核内存布局(以 Linux 0.11 为例):

  1. 0x7C00-0x7DFF: 引导扇区(512 字节)

    • BIOS 将 MBR 主引导记录 加载到此处执行
    • 负责加载后续内核代码到 0x10000(64KB)处
  2. 0x90000-0x901FF: setup 模块(512 字节)

    • 由引导程序加载
    • 获取硬件参数并进入保护模式
  3. 0x10000-0x9FFFF: 系统模块(约 600KB)

    • 内核主体代码
    • 包含进程管理、内存管理等功能
  4. 其他关键区域:

    • 0x00000-0x003FF: 中断向量表(1KB)
    • 0xA0000-0xBFFFF: 显存区域(128KB)
    • 0xE0000-0xFFFFF: BIOS 程序区(128KB)

上面的说法有问题。

1.4.1 基本内联汇编

GCC 提供的基本汇编语法形式如下:

__asm__ (AssemblerTemplate);  

在这里,—asm 是内联汇编命令的关键字,用于声明内联汇编表达式。指示编译器特定操作之插入汇编代码,而 AssemblerTemplate 则是一组插入到 C/C++ 代码中的汇编指令。

例如,下面的代码用于在C 语言中揪入一条mov 寄存器的指令: asm("mov %edx, %eax"); 支持汇编器的所有指形式,包括汇编中的伪指令。

在基本内嵌汇编,中我们可以插入一段汇编指令,但是无法让汇编指令与我们原本的 C/C++ 程序代码产生关联。例如 ,修改或读取C/C++ 中的变量等。

因此, 除了支持基本内藤汇编指令,GCC 还支持通过扩展内航汇编的方式让汇编指令与C/C++ 代码进行互操作。 GCC 的内峰汇编语法形式如代码清单1-2 所示。

# 代码清单 1-2 内嵌汇编语法
__asm__ asm-qualifiers (
    AssemblerTemplate   /*内嵌汇编的主体部*/
    : OutputOperands    /* 可选 */
    : InputOperands     /* 可选 */
    : Clobbers)         /* 可选 */。

asm volatile ( "mov %%edx, %%eax" :); 该 例子同基本内髓汇编中的例子的内容是一 样的,但这里采用的是扩展内峰汇编的方 式,因此有两个不同的地方: 一 是因为 该例子不涉及任何与C/C++ 交互的地方,所以例子中输出操作 数、输人操作数以及破坏描述部分都 为空,需要在最后以一个慎号结尾; 二是在扩展内典汇 编 中,引用寄存器时,需要在寄存器名称前 添加“%%”,这是为了与操作数占位符的 "%" (如%0、%1)进区行分。

在基本汇编中,寄存器用单个百分号,而扩展汇编中必须用双百分号,因为扩展汇编引入了操作数占位符,导致语法冲突。需要明确说明这两种情况的区别,并强调正确使用双百分号的重要性,以避免编译错误。

  1. OutputOperands:输出操作数,由逗号分隔,可以为空。每个内嵌汇编表达式都可以有 0 个或多个输出操作数,用来标识在汇编中被修改的 C/C++ 程序变量。

输出操作数的形式如下:

[ [asmSymbolicName] ]"constraint"(cvariablename)

要理解 asmSymbolicName 的含义,需要先理解扩展内嵌汇编中操作数占位符(提前占位充当操作数,类似格式化打印)的作用。在扩展内嵌汇编指令中,汇编指令的操作数可以由占位符进行引用,占位符代表了输出操作数以及输入操作数的位置。例如总共有 5 个操作数(2 个输出操作数,3 个输入操作数),则占位符 %0~%4 分别代表了这 5 个操作数,具体的实现如代码清单 1-3 所示。

代码清单 1-3 输入/输出参数

int out1, out2;
int in1 = 1, in2 = 2, in3 = 3;
__asm__ __volatile__ (
    "add %3, %4\n\t"
    "add %2, %3\n\t"
    "mov %4, %1\n\t"
    "mov %3, %0\n\t"
    : "=r"(out1), "=r"(out2)
    : "r"(in1), "r"(in2), "r"(in3)
);

例子中占位符 %0~%4 分别指向 C 代码中 out1, out2, in1, in2, in3 这 5 个变量。

“虽然数字类型的占位符比较方便,但是如果输出/输入操作数太多,则容易使得数字类型占位符过于混乱。因此, asmSymbolicName 提供了一种别名的方式,允许在扩展内嵌汇编中使用别名来操作占位符。上面例子也可以修改为别名的形式,具体实现如代码清单 1-4 所示。

代码清单 1-4 别名形式的参数

int out1, out2;
int in1 = 1, in2 = 2, in3 = 3;
__asm__ __volatile__ (
    "add %[in2], %[in3]\n\t" // 使用 asmSymbolicName别名 占位符[out1]、[out2]、[in1]、[in2]、[in3] 来表示输出/输入操作数
    "add %[in1], %[in2]\n\t"
    "mov %[in3], %[out2]\n\t"
    "mov %[in2], %[out1]\n\t"
    : [out1]"=r"(out1), [out2]"=r"(out2)
    : [in1]"r"(in1), [in2]"r"(in2), [in3]"r"(in3)
);

"constraint" 表明操作数的约束,即上面例子中 out1 和 out2 的 "=r"。对输出操作数而言,约束必须以 "="(意思是对当前变量进行写操作)或
"+"(意思是对当前变量进行读和写操作)开头。
在前缀之后,必须有一个或多个附加约束来描述值所在的位置。常见的约束包括代表寄存器的 "r" 和代表内存的 "m"。上述例子中
"=r(out1)" 的约束含义是内嵌汇编指令将对 out1 变量进行写操作,并且会将 out1 与一个通用寄存器进行关联。
GCC 内嵌汇编中的约束符还有很多,详细列表可以查看 GCC 官方手册,此处不再赘述。

(cvariablename) 表示该输出操作符所绑定的 C/C++ 程序的变量,这个比较好理解。

最后再看一下来自 Linux 0.11 中的具体例子,如代码清单 1-5 所示。
代码清单 1-5 Linux 0.11 中的真实示例

inline unsigned long get_fs() {
    unsigned short _v; // 定义一个变量_v
    __asm__("mov %%fs,%%ax":"=a" (_v):); // 将fs寄存器的值存到_v中
    return _v; // 返回_v的值
}

这个函数的功能是获取当前 fs 寄存器的值并返回。在函数 get_fs() 中,输出操作数为变量 _v,其形式为 "=a"(_v)。
这里约束 "=a" 表明输出操作符与寄存器 %ax 关联,因此内嵌汇编的作用就是将寄存器 %fs 的值存到存储变量 _v 中。

这里为什么没有把 %%ax写到破坏描述部分? 根据GCC的文档,输出操作数中列出的寄存器不需要在Clobbers中重复声明,因为编译器已经知道这些寄存器会被修改。破坏描述部分主要用于那些没有被输入/输出操作数覆盖的寄存器或内存。在这个例子中,ax寄存器已经被输出约束"=a"(v)所指定,因此不需要在Clobbers里再写%ax。如果加上,反而可能多余,甚至导致编译器的警告或错误。

用于那些隐式修改的寄存器。例如,如果在汇编代码中使用了mov指令修改了ebx,但ebx没有在输入输出中列出,这时就需要在Clobbers中声明%ebx。

"a"约束的含义

  • 表示要求编译器将变量绑定到 eax寄存器(32位)
  • 对于16位模式会自动使用 ax寄存器
  • 对于8位模式会自动使用 al寄存器

与其他约束的对比

约束符寄存器位宽
"a"eax/ax/al自动适配
"b"ebx/bx/bl自动适配
"c"ecx/cx/cl自动适配
"d"edx/dx/dl自动适配
"r"任意通用寄存器由编译器选择
  1. InputOperands: 输入操作数,每个用双引号扩起来,由逗号分隔,可以为空。输入操作数集合标识了哪些 C/C++ 变量是需要在汇编代码中读取使用的。

输入操作数的形式如下: [ [asmSymbolicName] ]"constraint"(cvariablename)

同输出操作数语法形式一致。这里需要单独对输入操作数的 constraint 进行说明, 与输出操作数不同,输入约束字符串不能以"="或"+"开头,另外,输入约束也可以是数字。这表明指定的输入变量必须与输出约束列表中(从零开始的)索引处的输出变量指向同一个变量。

例如以下例子,

__asm__ __volatile__(
    "add %2, %0"
    : "=r"(a)
    : "0"(a), "r"(b)
);

在这个例子中,变量 a 对应的寄存器既要作为输入变量,也要作为输出变量。这里通过 "0" 约束将输入操作数与输出操作数绑定。

我们最后再看一下 Linux 0.11 中的具体例子,

inline void set_fs(unsigned long val) {
    __asm__("mov %0, %%fs"::"a" ((unsigned short) val));
    // 等价于 mov ax, %%fs
} //  这里必须用"a"约束,因为mov到段寄存器fs的源操作数必须通过通用寄存器传递,编译器自动将val的值加载到ax寄存器(16位模式),相当于定义输入操作数变量了

该函数的作用是将变量val 的值存到 %fs 寄存器中。在对应的内嵌汇编中,输出操作数为空,而输入操作数则为 set_fs输入参数 val变量,在汇编指令里通过 %0 占位符表来示。

  1. Clobbers:破坏描述部分。该位置需要列出除了输出操作数列表中会被修改的值之外,其他会被内联汇编修改的寄存器值。破坏描述部分的列表内容是寄存器的名称,要通过引号引起来,如果需要多个寄存器的话则需要使用逗号进行分隔。这里的作用是告知编译器说明在内联汇编中有哪些寄存器的值会被修改,使得编译器在内联汇编语句之前保存对应的寄存器值

例如以下例子:

__asm__ __volatile__(
    "mov %0, %%eax"
    : 
    : "a"(a) // 
    : "%eax"
);

例子中将变量 a 的值写到 %eax 寄存器中,这里 %eax 寄存器既非输出操作数,又非输入操作数,因此需要在破坏描述部分进行声明。

除了通用寄存器,clobbers list 还有两个特殊的参数有着不同的意义: 一个是 "cc" 它用来表示内联汇编修改了标志寄存器(flags register);
另一个是 "memory",它用于通知编译器汇编代码对列表中的项目执行内存读取或写入(例如,访问由输入参数指向的内存)。为了确保内存包含正确的值,GCC 可能需要在执行内联汇编之前将特定的寄存器值保存到内存中。此外,编译器不会假设在内联汇编之前从内存读取的值保持不变,它会根据需要重新加载这些值。"memory clobber" 的作用等同于为编译器添加了一个读写内存屏障

其他相关约束示例

约束类型是否需要破坏描述原因
"=a"(var)已显式声明使用ax
"=r"(var)编译器自动分配寄存器
使用push %ebx未在约束中声明ebx