自制操作系统 - CPU检测与FPU浮点运算

CPU检测与FPU浮点运算

Posted by 王富杰 on Wednesday, April 3, 2024

一、CPU检测

CPU检测指的是利用 cpuid 这个指令获取CPU的信息。比如供应商(Vendor) 字符串,和模型数,内部缓存大小等。在执行 CPUID 指令之前,首先应该检测处理器是否支持该指令,如果 EFLAGS 的 ID 位可修改,那么表示处理器支持 CPUID 指令。例如80386就不支持这个指令,但是奔腾处理器支持。

执行 CPUID 指令前,将参数 0 传入 EAX,不同的参数将返回不同的信息。当 EAX=0 时,CPUID 将返回供应商 ID 字符串到 EBX, EDX 和 ECX,将它们写入内存将得到长度 12 的字符串。这个字符串表示了供应商的 ID, EAX 中返回最大的输入数。 使用参数 EAX = 1 调用 CPUID,返回一个位图存储在 EDX 和 ECX 中 2。这个位图就标注CPU支持的功能等内容。注意不同品牌的 CPU 可能有不同的含义。

bool cpu_check_cpuid()       // 检测是否支持 cpuid 指令
{
    bool ret;
    asm volatile(
        "pushfl \n" // 保存 eflags

        "pushfl \n"                   // 得到 eflags
        "xorl $0x00200000, (%%esp)\n" // 反转 ID 位
        "popfl\n"                     // 写入 eflags

        "pushfl\n"                  // 得到 eflags
        "popl %%eax\n"              // 写入 eax
        "xorl (%%esp), %%eax\n"     // 将写入的值与原值比较
        "andl $0x00200000, %%eax\n" // 得到 ID 位
        "shrl $21, %%eax\n"         // 右移 21 位,得到是否支持

        "popfl\n" // 恢复 eflags
        : "=a"(ret));
    return ret;
}

// 得到供应商 ID 字符串
void cpu_vendor_id(cpu_vendor_t *item)
{
    asm volatile(
        "cpuid \n"
        : "=a"(*((u32 *)item + 0)),
          "=b"(*((u32 *)item + 1)),
          "=d"(*((u32 *)item + 2)),
          "=c"(*((u32 *)item + 3))
        : "a"(0));
    item->info[12] = 0;
}

void cpu_version(cpu_version_t *item)
{
    asm volatile(
        "cpuid \n"
        : "=a"(*((u32 *)item + 0)),
          "=b"(*((u32 *)item + 1)),
          "=c"(*((u32 *)item + 2)),
          "=d"(*((u32 *)item + 3))
        : "a"(1));
}

以上为CPU的检测,可以在test系统调用中来进行测试。CPU检测的一大目的是,在操作系统运行时,可以先去检测CPU是否支持这个功能,例如当系统运行到一个非常古老的CPU上,就有可能存在功能不支持。例如检测是否支持FPU。

二、FPU浮点运算

x86 FPU最初是处理器的一个可选组件 x87,能够在硬件上执行浮点运算,但后来被集成到 CPU 中。在使用FPU时,可以先通过cpuid来检测是否支持FPU。如果发现存在 FPU,则应相应地设置控制寄存器。如果 FPU 不存在,也应该相应地设置寄存器。下面介绍是CR0寄存器和FPU相关的两个位:

  • CR0.EM (bit 2) (EMulated) 如果设置了 EM 位,所有 FPU 和向量操作都将导致 #NM,因此它们可以在软件中模拟。清除 EM 位才能使用 FPU;
  • CR0.TS (bit 3) 任务切换。FPU 状态被设计为延迟切换,以节省读写周期。如果设置,所有有意义的操作都会导致 #NM 异常,以便 OS 备份 FPU 状态。该位在硬件任务开关上自动设置,可以使用 CLTS 操作码清除。如果软件任务切换想要延迟存储 FPU 状态,可能需要手动设置这个位重新调度。

2.1、FPU浮点运算单元实现

在多个进程都需要使用FPU时,如果进程切换,那也应该支持保存和恢复FPU的寄存器状态。以下结构定义了FPU的状态:

typedef struct fpu_t
{
    u16 control;
    u16 RESERVED;
    u16 status;
    u16 RESERVED;
    u16 tag;
    u16 RESERVED;
    u32 fip0;
    u32 fop0;
    u32 fdp0;
    u32 fdp1;
    u8 regs[80];
} _packed fpu_t;

在每个进程的结构中需要增加 fpu_t 元素,接下来是FPU的实现:

task_t *last_fpu_task = NULL;  // 标记上次使用fpu的进程

void fpu_enable(task_t *task)     // 激活 FPU
{
    LOGK("fpu enable...\n");

    set_cr0(get_cr0() & ~(CR0_EM | CR0_TS));  // 设置CR0寄存器
    if (last_fpu_task == task)                // 如果使用的任务没有变化,则无需恢复浮点环境
        return;
    
    if (last_fpu_task && last_fpu_task->flags & TASK_FPU_ENABLED)   // 如果存在使用过浮点处理单元的进程,则保存浮点环境
    {
        assert(last_fpu_task->fpu);
        asm volatile("fnsave (%%eax) \n" ::"a"(last_fpu_task->fpu));
        last_fpu_task->flags &= ~TASK_FPU_ENABLED;
    }

    last_fpu_task = task;

    if (task->fpu)      // 如果 fpu 不为空,则恢复浮点环境
    {
        asm volatile("frstor (%%eax) \n" ::"a"(task->fpu));
    }
    else
    {
        asm volatile(       // 否则,初始化浮点环境
            "fnclex \n"
            "fninit \n");
        task->fpu = (fpu_t *)kmalloc(sizeof(fpu_t));
        task->flags |= (TASK_FPU_ENABLED | TASK_FPU_USED);
    }
}

void fpu_disable(task_t *task)   // 禁用 fpu, 设置CR0寄存器
{
    set_cr0(get_cr0() | (CR0_EM | CR0_TS));
}

void fpu_handler(int vector)   // fpu异常处理函数
{
    assert(vector == INTR_NM);
    task_t *task = running_task();
    assert(task->uid);
    fpu_enable(task);
}

void fpu_init()        // 初始化 FPU
{
    bool exist = fpu_check(); // 检查是否支持FPU   
    last_fpu_task = NULL;
    assert(exist);

    if (exist)
    {
        set_exception_handler(INTR_NM, fpu_handler);  // 设置异常处理函数,非常类似于中断
        set_cr0(get_cr0() | CR0_EM | CR0_TS | CR0_NE);   // 设置 CR0 寄存器
    }
    else
        LOGK("fpu not exists...\n");
}

以上为fpu的实现,这里省略了fpu检查的代码。保留了主要流程,在操作系统初始化是,进行FPU的初始化。设置INTR_NM异常处理函数,CPU 在执行需要使用 FPU/MMX/SSE/AVX 指令时,发现当前任务 FPU 不可用时,就触发和这个异常。以下为应用程序进行浮点运算的测试程序:

void builtin_test(int argc, char *argv[])
{
    pid_t pid = fork();
    while (true)
    {
        double num = 1.0;
        num += 23.3;
        num -= 5.7;
        num *= 2.5;
        num /= 0.7;
        printf("test %d called\n", getpid());
        sleep(1000);
    }
}
  • 应用程序执行,fork了一个子进程,此时子进程和父进程都会继续向下执行进入循环。
  • 当其中一个进程获取CPU执行权,会进行浮点运算,此时该进程未进行FPU启用,会触发异常,进而去启用FPU。
  • 当发生任务切换,保存原进程的FPU状态并禁用FPU, 现代 CPU 使用 fxsave / fxrstor 指令快速保存和恢复全部浮点上下文。
  • 另一个进程触发异常,也进行FPU启用,并进行浮点运算。
  • 再次进行FPU切换时,会继续触发异常,加载FPU,如此往复。

需注意的是:操作系统内核一般默认只允许用户态使用,内核自身很少动用 FPU。因为内核本身不需要进行浮点运算。

三、基础浮点运算

CPU提供了一系列的指令可以进行浮点运算,如 FABS 计算绝对值,FCOS计算三角函数cos,借助这些指令我们就可以通过内敛汇编来实现数学函数,如:

double cos(double x)
{
    asm volatile(
        "fldl %0 \n"
        "fcos \n"
        "fstpl %0\n"
        : "+m"(x));
    return x;
}

如上,我们就实现了cos函数,当然FPU还支持更多的指令,我们这里不做一一介绍。

「真诚赞赏,手留余香」

WangFuJie Blog

真诚赞赏,手留余香

使用微信扫描二维码完成支付