引言

在计算机体系结构和操作系统的学习道路上,我们不可避免地会遇到一个巨大的障碍:硬件多样性。x86 的复杂指令集、RISC-V 的简洁设计、MIPS 的传统架构、LoongArch 的自主创新... 每种处理器都有自己独特的寄存器布局、中断机制和内存管理方式。如果直接在硬件上编写操作系统,我们可能需要花费 80% 的时间处理这些硬件细节,而只有 20% 的时间用于真正理解操作系统的核心概念。

这就是AbstractMachine(AM) 诞生的背景。作为 "一生一芯" 计划的核心基础设施,AM 为我们提供了一个统一的裸机编程环境,将所有硬件差异完美地封装在底层,向上暴露了一套简洁而强大的 API。通过 AM,我们可以用完全相同的代码在 x86、RISC-V、MIPS、LoongArch 等多种架构上运行我们的操作系统。

在这篇文章中,我将基于 AM 的完整源码,从目录结构到每一行关键代码,深入解析这个设计优雅的硬件抽象层。无论你是操作系统初学者还是有经验的开发者,相信这篇文章都会给你带来新的启发。

一、整体架构与目录结构

1.1 五层架构模型

AM 采用了清晰的五层垂直分层架构,从下到上依次是:

┌─────────────────────────────────────────────────────────┐
│                     上层应用/操作系统                     │
├─────────────────────────────────────────────────────────┤
│  TRM(图灵机) │ IOE(设备) │ CTE(中断) │ VME(虚存) │ MPE(多核) │  <- AM标准API
├─────────────────────────────────────────────────────────┤
│                  指令集架构(ISA)层                       │
│           (x86/RISC-V/MIPS/LoongArch/native)            │
├─────────────────────────────────────────────────────────┤
│                    平台(Platform)层                      │
│               (NEMU/NPC/QEMU/Logisim)                   │
├─────────────────────────────────────────────────────────┤
│                    编译系统(Build)层                     │
└─────────────────────────────────────────────────────────┘

这种分层设计的核心思想是:每一层只依赖它下面的一层,并且对上面的层隐藏实现细节。这使得 AM 具有极好的可扩展性 —— 添加一个新的架构或平台只需要实现对应的层,不需要修改任何上层代码。

1.2 源码目录结构

让我们先从整体上了解 AM 的源码目录结构:

abstract-machine/
├── am/                     # AM核心库
│   ├── include/            # 公共头文件
│   │   ├── am.h            # AM核心API定义
│   │   ├── amdev.h         # 设备寄存器定义
│   │   └── arch/           # 各架构的Context定义
│   ├── src/                # 核心实现
│   │   ├── loongarch/      # LoongArch架构实现
│   │   ├── mips/           # MIPS架构实现
│   │   ├── native/         # Linux原生平台实现
│   │   ├── platform/       # 平台相关实现
│   │   │   ├── dummy/      # 空实现(用于测试)
│   │   │   ├── logisim/    # Logisim模拟器实现
│   │   │   └── nemu/       # NEMU模拟器实现
│   │   ├── riscv/          # RISC-V架构实现
│   │   └── x86/            # x86架构实现
│   └── Makefile            # AM库的Makefile
├── klib/                   # 标准库实现(printf, memcpy等)
├── scripts/                # 编译脚本
│   ├── isa/                # 指令集相关编译配置
│   ├── platform/           # 平台相关编译配置
│   └── linker.ld           # 通用链接脚本
├── tools/                  # 辅助工具
├── LICENSE                 # 许可证
├── Makefile                # 顶层Makefile
└── README                  # 说明文档

这个目录结构完美地对应了我们上面提到的五层架构模型。am/include/目录包含了所有上层应用可以使用的 API,而am/src/目录则按照架构和平台分别实现了这些 API。

二、核心 API 详解:从 TRM 到 MPE

AM 的核心由五个模块组成,它们分别是TRMIOECTEVMEMPE。这五个模块共同构成了一个完整的裸机编程环境。

2.1 TRM:图灵机模型 —— 最基础的运行环境

TRM (Turing Machine) 是 AM 最基础的模块,它提供了程序运行所需的最小功能集。从理论上讲,只要有了 TRM,我们就可以实现任何可计算的问题。

2.1.1 TRM API 定义

TRM 的 API 定义在am/include/am.h中,只有三个元素:

// ----------------------- TRM: Turing Machine -----------------------
extern   Area        heap;   // 堆内存区域
void     putch       (char ch);  // 输出一个字符
void     halt        (int code) __attribute__((__noreturn__));  // 终止程序

这三个元素分别对应了图灵机的三个核心组件:

  • heap无限长的纸带(内存)
  • putch输出设备
  • halt终止状态
2.1.2 Area 结构体详解

Area是 AM 中一个非常基础的结构体,用于表示一个连续的内存区域:

// Memory area for [@start, @end)
typedef struct {
  void *start, *end;
} Area;

注意这里的区间是左闭右开的,即包含start地址,但不包含end地址。这是 C 语言中处理内存区间的标准方式,因为它有很多方便的性质:

  • 区间长度 = end - start
  • 空区间 = start == end
  • 地址p在区间内 = p >= start && p < end
2.1.3 TRM 在 NEMU 平台上的实现

让我们看看 TRM 在 NEMU 平台上是如何实现的,代码位于am/src/platform/nemu/trm.c

#include <am.h>
#include <nemu.h>

extern char _heap_start;  // 这个符号由链接器定义
int main(const char *args);  // 上层应用的入口函数

// 定义堆区域:从_heap_start到物理内存结束
Area heap = RANGE(&_heap_start, PMEM_END);

// 主函数参数,由编译系统填充
static const char mainargs[MAINARGS_MAX_LEN] = TOSTRING(MAINARGS_PLACEHOLDER);

// 输出一个字符:直接向串口地址写入
void putch(char ch) {
  outb(SERIAL_PORT, ch);
}

// 终止程序:调用架构相关的trap指令
void halt(int code) {
  nemu_trap(code);
  // 如果trap指令返回,进入死循环
  while (1);
}

// AM的入口函数
void _trm_init() {
  // 调用上层应用的main函数
  int ret = main(mainargs);
  // main函数返回后,终止程序
  halt(ret);
}

这段代码虽然简短,但包含了很多关键信息:

  1. _heap_start符号:这个符号不是在 C 代码中定义的,而是由链接器在链接时计算出来的。它指向程序 BSS 段之后的第一个地址,也就是堆的起始地址。

  2. RANGE:这是一个在klib-macros.h中定义的宏,用于快速创建一个 Area 结构体:

    #define RANGE(start, end) ((Area){(void*)(start), (void*)(end)})
    
  3. PMEM_END:定义在nemu.h中,表示物理内存的结束地址:

    extern char _pmem_start;
    #define PMEM_SIZE (128 * 1024 * 1024)  // 128MB物理内存
    #define PMEM_END  ((uintptr_t)&_pmem_start + PMEM_SIZE)
    
  4. outb函数:这是一个架构相关的函数,用于向 I/O 端口写入一个字节。在 RISC-V 架构上,它的实现是:

    // am/src/riscv/riscv.h
    static inline void outb(uintptr_t addr, uint8_t data) {
      *(volatile uint8_t *)addr = data;
    }
    

    这里使用了volatile关键字,告诉编译器不要优化这个内存访问操作,因为它是对硬件寄存器的访问。

  5. nemu_trap:这是 AM 中一个非常重要的宏,它在不同架构上有不同的实现,用于触发一个调试陷阱,让 NEMU 模拟器终止运行:

    // am/src/platform/nemu/include/nemu.h
    #if defined(__ISA_X86__)
    # define nemu_trap(code) asm volatile ("int3" : :"a"(code))
    #elif defined(__ISA_MIPS32__)
    # define nemu_trap(code) asm volatile ("move $v0, %0; sdbbp" : :"r"(code))
    #elif defined(__riscv)
    # define nemu_trap(code) asm volatile("mv a0, %0; ebreak" : :"r"(code))
    #elif defined(__ISA_LOONGARCH32R__)
    # define nemu_trap(code) asm volatile("move $a0, %0; break 0" : :"r"(code))
    #else
    # error unsupported ISA __ISA__
    #endif
    

    注意这里的参数传递方式:在 x86 上使用eax寄存器,在 MIPS 上使用v0寄存器,在 RISC-V 和 LoongArch 上使用a0寄存器。这是因为 NEMU 模拟器约定从这些寄存器中获取退出码。

  6. _trm_init函数:这是 AM 的真正入口点。当程序启动时,处理器会首先执行架构相关的启动汇编代码,然后跳转到_trm_init函数。_trm_init函数会调用上层应用的main函数,当main函数返回后,调用halt终止程序。

2.2 IOE:输入输出设备 —— 统一的设备访问接口

IOE (Input/Output Devices) 模块提供了统一的设备访问接口,让我们不需要关心不同硬件上的设备差异。AM 将所有设备抽象为一系列寄存器,通过ioe_readioe_write两个函数来访问。

2.2.1 IOE API 定义

IOE 的 API 同样非常简洁:

// -------------------- IOE: Input/Output Devices --------------------
bool     ioe_init    (void);  // 初始化IOE子系统
void     ioe_read    (int reg, void *buf);  // 读取设备寄存器
void     ioe_write   (int reg, void *buf);  // 写入设备寄存器
2.2.2 设备寄存器定义

AM 预定义了多种常见设备的寄存器,这些定义都在am/include/amdev.h中。让我们仔细看看这个文件的内容:

#ifndef __AMDEV_H__
#define __AMDEV_H__

// 这是一个非常巧妙的宏,同时定义了寄存器ID和对应的结构体类型
#define AM_DEVREG(id, reg, perm, ...) \
  enum { AM_##reg = (id) }; \
  typedef struct { __VA_ARGS__; } AM_##reg##_T;

// 串口设备
AM_DEVREG( 1, UART_CONFIG,  RD, bool present);
AM_DEVREG( 2, UART_TX,      WR, char data);
AM_DEVREG( 3, UART_RX,      RD, char data);

// 定时器设备
AM_DEVREG( 4, TIMER_CONFIG, RD, bool present, has_rtc);
AM_DEVREG( 5, TIMER_RTC,    RD, int year, month, day, hour, minute, second);
AM_DEVREG( 6, TIMER_UPTIME, RD, uint64_t us);

// 输入设备(键盘/鼠标)
AM_DEVREG( 7, INPUT_CONFIG, RD, bool present);
AM_DEVREG( 8, INPUT_KEYBRD, RD, bool keydown; int keycode);

// 显卡设备
AM_DEVREG( 9, GPU_CONFIG,   RD, bool present, has_accel; int width, height, vmemsz);
AM_DEVREG(10, GPU_STATUS,   RD, bool ready);
AM_DEVREG(11, GPU_FBDRAW,   WR, int x, y; void *pixels; int w, h; bool sync);
AM_DEVREG(12, GPU_MEMCPY,   WR, uint32_t dest; void *src; int size);
AM_DEVREG(13, GPU_RENDER,   WR, uint32_t root);

// 音频设备
AM_DEVREG(14, AUDIO_CONFIG, RD, bool present; int bufsize);
AM_DEVREG(15, AUDIO_CTRL,   WR, int freq, channels, samples);
AM_DEVREG(16, AUDIO_STATUS, RD, int count);
AM_DEVREG(17, AUDIO_PLAY,   WR, Area buf);

// 磁盘设备
AM_DEVREG(18, DISK_CONFIG,  RD, bool present; int blksz, blkcnt);
AM_DEVREG(19, DISK_STATUS,  RD, bool ready);
AM_DEVREG(20, DISK_BLKIO,   WR, bool write; void *buf; int blkno, blkcnt);

// 网络设备
AM_DEVREG(21, NET_CONFIG,   RD, bool present);
AM_DEVREG(22, NET_STATUS,   RD, int rx_len, tx_len);
AM_DEVREG(23, NET_TX,       WR, Area buf);
AM_DEVREG(24, NET_RX,       WR, Area buf);

// 键盘码定义(省略部分内容)
#define AM_KEYS(_) \
  _(ESCAPE) _(F1) _(F2) ... _(PAGEDOWN)

#define AM_KEY_NAMES(key) AM_KEY_##key,
enum {
  AM_KEY_NONE = 0,
  AM_KEYS(AM_KEY_NAMES)
};

// GPU相关结构体定义(省略部分内容)
typedef uint32_t gpuptr_t;
struct gpu_texturedesc {
  uint16_t w, h;
  gpuptr_t pixels;
} __attribute__((packed));

#endif

这里最值得我们学习的是AM_DEVREG的设计。这个宏接收四个参数:

  • id:寄存器的唯一 ID
  • reg:寄存器的名称
  • perm:寄存器的权限 (RD 表示只读,WR 表示只写)
  • ...:寄存器的结构体成员

这个宏会展开为两部分:

  1. 一个枚举常量AM_reg,值为id
  2. 一个结构体类型AM_reg_T,包含...指定的成员

例如,AM_DEVREG(5, TIMER_RTC, RD, int year, month, day, hour, minute, second);会被 C 预处理器展开为:

enum { AM_TIMER_RTC = 5 };
typedef struct { int year, month, day, hour, minute, second; } AM_TIMER_RTC_T;

这种设计非常巧妙,它将寄存器 ID数据结构的定义绑定在一起,避免了手动维护两者的一致性。同时,它也使得设备寄存器的访问变得非常自然:

// 读取RTC时间
AM_TIMER_RTC_T rtc;
ioe_read(AM_TIMER_RTC, &rtc);
printf("现在是%d年%d月%d日 %d:%d:%d\n", 
       rtc.year, rtc.month, rtc.day, 
       rtc.hour, rtc.minute, rtc.second);

// 输出一个字符
AM_UART_TX_T tx;
tx.data = 'A';
ioe_write(AM_UART_TX, &tx);
2.2.3 IOE 的核心实现:查找表机制

IOE 的核心实现是一个函数查找表,它将寄存器 ID 映射到对应的处理函数。让我们看看 NEMU 平台上的 IOE 实现,代码位于am/src/platform/nemu/ioe/ioe.c

#include <am.h>
#include <klib-macros.h>

// 声明所有设备的处理函数
void __am_timer_init();
void __am_gpu_init();
void __am_audio_init();
void __am_input_keybrd(AM_INPUT_KEYBRD_T *);
void __am_timer_rtc(AM_TIMER_RTC_T *);
void __am_timer_uptime(AM_TIMER_UPTIME_T *);
// ... 更多处理函数声明

// 一些默认的处理函数
static void __am_timer_config(AM_TIMER_CONFIG_T *cfg) { cfg->present = true; cfg->has_rtc = true; }
static void __am_input_config(AM_INPUT_CONFIG_T *cfg) { cfg->present = true;  }
static void __am_uart_config(AM_UART_CONFIG_T *cfg)   { cfg->present = false; }
static void __am_net_config (AM_NET_CONFIG_T *cfg)    { cfg->present = false; }

// 函数指针类型:接收一个void*参数,无返回值
typedef void (*handler_t)(void *buf);

// 核心查找表:索引是寄存器ID,值是对应的处理函数
static void *lut[128] = {
  [AM_TIMER_CONFIG] = __am_timer_config,
  [AM_TIMER_RTC   ] = __am_timer_rtc,
  [AM_TIMER_UPTIME] = __am_timer_uptime,
  [AM_INPUT_CONFIG] = __am_input_config,
  [AM_INPUT_KEYBRD] = __am_input_keybrd,
  [AM_GPU_CONFIG  ] = __am_gpu_config,
  [AM_GPU_FBDRAW  ] = __am_gpu_fbdraw,
  [AM_GPU_STATUS  ] = __am_gpu_status,
  [AM_UART_CONFIG ] = __am_uart_config,
  [AM_AUDIO_CONFIG] = __am_audio_config,
  [AM_AUDIO_CTRL  ] = __am_audio_ctrl,
  [AM_AUDIO_STATUS] = __am_audio_status,
  [AM_AUDIO_PLAY  ] = __am_audio_play,
  [AM_DISK_CONFIG ] = __am_disk_config,
  [AM_DISK_STATUS ] = __am_disk_status,
  [AM_DISK_BLKIO  ] = __am_disk_blkio,
  [AM_NET_CONFIG  ] = __am_net_config,
};

// 默认处理函数:访问不存在的寄存器时调用
static void fail(void *buf) { panic("access nonexist register"); }

// IOE初始化函数
bool ioe_init() {
  // 填充查找表中未初始化的项为fail函数
  for (int i = 0; i < LENGTH(lut); i++)
    if (!lut[i]) lut[i] = fail;
  
  // 初始化各个设备
  __am_gpu_init();
  __am_timer_init();
  __am_audio_init();
  
  return true;
}

// 读取寄存器:从查找表中找到对应的处理函数并调用
void ioe_read (int reg, void *buf) { ((handler_t)lut[reg])(buf); }

// 写入寄存器:与读取完全相同,因为处理函数知道自己是读还是写
void ioe_write(int reg, void *buf) { ((handler_t)lut[reg])(buf); }

这段代码是 IOE 模块的核心,它展示了数据驱动编程的强大威力。让我们分析一下它的工作原理:

  1. 查找表初始化:在ioe_init函数中,首先将查找表中所有未初始化的项设置为fail函数,这样当访问不存在的寄存器时,会触发一个 panic。

  2. 设备初始化:然后调用各个设备的初始化函数,如__am_gpu_init__am_timer_init等。

  3. 寄存器访问:当调用ioe_readioe_write时,函数会根据寄存器 ID 从查找表中找到对应的处理函数,然后调用这个函数,并将缓冲区指针传递给它。

注意到一个有趣的细节:ioe_readioe_write的实现完全相同。这是因为每个处理函数都知道自己对应的寄存器是只读还是只写,所以不需要在调用时区分。例如,__am_timer_rtc函数只会读取硬件并填充缓冲区,而__am_uart_tx函数只会从缓冲区读取数据并写入硬件。

2.2.4 设备处理函数示例

让我们看看几个具体的设备处理函数实现,以加深理解。

定时器设备

// am/src/platform/nemu/ioe/timer.c
#include <am.h>
#include <nemu.h>

void __am_timer_init() {
  // NEMU中的定时器不需要初始化
}

// 读取系统运行时间
void __am_timer_uptime(AM_TIMER_UPTIME_T *uptime) {
  // 从NEMU的特殊寄存器读取时间(微秒)
  uptime->us = inl(0xa0000048);
}

// 读取RTC实时时钟
void __am_timer_rtc(AM_TIMER_RTC_T *rtc) {
  // 从NEMU的RTC寄存器读取时间
  rtc->second = inb(0xa0000000);
  rtc->minute = inb(0xa0000001);
  rtc->hour   = inb(0xa0000002);
  rtc->day    = inb(0xa0000003);
  rtc->month  = inb(0xa0000004);
  rtc->year   = inb(0xa0000005) + 1900;
}

显卡设备

// am/src/platform/nemu/ioe/gpu.c
#include <am.h>
#include <nemu.h>

#define SYNC_ADDR (VGACTL_ADDR + 4)

void __am_gpu_init() {
  // 不需要初始化
}

// 获取显卡配置
void __am_gpu_config(AM_GPU_CONFIG_T *cfg) {
  // 从VGACTL寄存器读取屏幕分辨率
  uint32_t info = inl(VGACTL_ADDR);
  *cfg = (AM_GPU_CONFIG_T) {
    .present = true, .has_accel = false,
    .width = info >> 16, .height = info & 0xffff,
    .vmemsz = 0
  };
}

// 绘制帧缓冲区
void __am_gpu_fbdraw(AM_GPU_FBDRAW_T *ctl) {
  // 计算目标地址
  uint32_t *dst = (uint32_t *)(FB_ADDR + ctl->y * 400 * 4 + ctl->x * 4);
  uint32_t *src = (uint32_t *)ctl->pixels;
  
  // 复制像素数据
  for (int y = 0; y < ctl->h; y++) {
    for (int x = 0; x < ctl->w; x++) {
      dst[y * 400 + x] = src[y * ctl->w + x];
    }
  }
  
  // 如果需要同步,写入SYNC_ADDR触发屏幕刷新
  if (ctl->sync) {
    outl(SYNC_ADDR, 1);
  }
}

// 获取显卡状态
void __am_gpu_status(AM_GPU_STATUS_T *status) {
  // NEMU的显卡总是准备好的
  status->ready = true;
}

这些处理函数的实现都非常直接:它们只是简单地读写对应的硬件寄存器,然后将结果填充到传入的结构体中。

2.3 CTE:上下文切换与异常处理 —— 操作系统的心脏

CTE (Context Switching and Exceptions) 模块是 AM 中最复杂也是最重要的模块。它负责处理中断和异常,以及上下文切换。可以说,没有 CTE,就没有真正的操作系统。

2.3.1 CTE API 定义

CTE 的 API 定义如下:

// ---------- CTE: Interrupt Handling and Context Switching ----------
bool     cte_init    (Context *(*handler)(Event ev, Context *ctx));
void     yield       (void);
bool     ienabled    (void);
void     iset        (bool enable);
Context *kcontext    (Area kstack, void (*entry)(void *), void *arg);

让我们逐个解释这些函数:

  • cte_init:初始化 CTE 子系统,并注册一个异常处理函数。当异常发生时,AM 会调用这个函数,并传入异常事件和当前上下文。处理函数可以修改上下文,然后返回新的上下文,AM 会自动恢复这个上下文。

  • yield:主动让出 CPU,触发一个EVENT_YIELD异常。这是实现多任务切换的基础。

  • ienabled:检查中断是否开启。

  • iset:开启或关闭中断。

  • kcontext:创建一个内核上下文。这个函数会在指定的栈上初始化一个上下文,当这个上下文被恢复时,会调用entry函数,并传入arg参数。

2.3.2 Event 与 Context 结构体

CTE 中有两个非常重要的结构体:EventContext

Event 结构体:表示一个异常或中断事件。

// An event of type @event, caused by @cause of pointer @ref
typedef struct {
  enum {
    EVENT_NULL = 0,
    EVENT_YIELD, EVENT_SYSCALL, EVENT_PAGEFAULT, EVENT_ERROR,
    EVENT_IRQ_TIMER, EVENT_IRQ_IODEV,
  } event;
  uintptr_t cause, ref;
  const char *msg;
} Event;

event字段表示事件类型,目前定义了 7 种事件:

  • EVENT_YIELD:主动让出 CPU
  • EVENT_SYSCALL:系统调用
  • EVENT_PAGEFAULT:缺页异常
  • EVENT_ERROR:其他错误
  • EVENT_IRQ_TIMER:定时器中断
  • EVENT_IRQ_IODEV:外部设备中断

Context 结构体:表示处理器的执行上下文。这是 CTE 中最复杂也最容易出错的部分,因为它的定义必须和汇编代码中保存寄存器的顺序完全一致

由于不同架构的寄存器布局不同,Context结构体的定义也不同。让我们看看几个典型架构的Context定义:

RISC-V 架构

// am/include/arch/riscv.h
#ifdef __riscv_e
#define NR_REGS 16
#else
#define NR_REGS 32
#endif

struct Context {
  // TODO: fix the order of these members to match trap.S
  uintptr_t mepc, mcause, gpr[NR_REGS], mstatus;
  void *pdir;
};

x86_64 架构

// am/include/arch/x86_64-qemu.h
struct Context {
  void    *cr3;
  uint64_t rax, rbx, rcx, rdx,
           rbp, rsi, rdi,
           r8, r9, r10, r11,
           r12, r13, r14, r15,
           rip, cs, rflags,
           rsp, ss, rsp0;
};

MIPS32 架构

// am/include/arch/mips32-nemu.h
struct Context {
  // TODO: fix the order of these members to match trap.S
  uintptr_t hi, gpr[32], epc, cause, lo, status;
  void *pdir;
};

注意到源码中有一个TODO注释:// TODO: fix the order of these members to match trap.S。这是一个非常重要的提示,它告诉我们Context结构体的成员顺序必须和trap.S汇编文件中保存寄存器的顺序完全一致。如果顺序不一致,那么在恢复上下文时就会将错误的值加载到寄存器中,导致程序崩溃。

2.3.3 CTE 的工作原理:从异常发生到处理完成

为了深入理解 CTE 的工作原理,让我们以RISC-V 架构为例,完整地跟踪一次异常处理的全过程。

步骤 1:异常发生

当处理器检测到一个异常 (如系统调用、定时器中断、缺页异常等) 时,它会自动执行以下操作:

  1. 将异常原因写入mcause寄存器
  2. 将异常发生时的 PC 值写入mepc寄存器
  3. 将当前状态写入mstatus寄存器
  4. 将 PC 设置为mtvec寄存器的值 (异常向量表基地址)
  5. 跳转到异常向量表执行

步骤 2:保存上下文

异常向量表指向__am_asm_trap函数,这个函数完全由汇编语言编写,负责保存当前的处理器上下文。代码位于am/src/riscv/nemu/trap.S

#define concat_temp(x, y) x ## y
#define concat(x, y) concat_temp(x, y)
#define MAP(c, f) c(f)

#if __riscv_xlen == 32
#define LOAD  lw
#define STORE sw
#define XLEN  4
#else
#define LOAD  ld
#define STORE sd
#define XLEN  8
#endif

#define REGS_LO16(f) \
      f( 1)       f( 3) f( 4) f( 5) f( 6) f( 7) f( 8) f( 9) \
f(10) f(11) f(12) f(13) f(14) f(15)
#ifndef __riscv_e
#define REGS_HI16(f) \
                                    f(16) f(17) f(18) f(19) \
f(20) f(21) f(22) f(23) f(24) f(25) f(26) f(27) f(28) f(29) \
f(30) f(31)
#define NR_REGS 32
#else
#define REGS_HI16(f)
#define NR_REGS 16
#endif

#define REGS(f) REGS_LO16(f) REGS_HI16(f)

#define PUSH(n) STORE concat(x, n), (n * XLEN)(sp);
#define POP(n)  LOAD  concat(x, n), (n * XLEN)(sp);

#define CONTEXT_SIZE  ((NR_REGS + 3) * XLEN)
#define OFFSET_CAUSE  ((NR_REGS + 0) * XLEN)
#define OFFSET_STATUS ((NR_REGS + 1) * XLEN)
#define OFFSET_EPC    ((NR_REGS + 2) * XLEN)

.align 3
.globl __am_asm_trap
__am_asm_trap:
  # 1. 分配栈空间用于保存上下文
  addi sp, sp, -CONTEXT_SIZE

  # 2. 保存所有通用寄存器
  MAP(REGS, PUSH)

  # 3. 保存特殊寄存器
  csrr t0, mcause
  csrr t1, mstatus
  csrr t2, mepc

  STORE t0, OFFSET_CAUSE(sp)
  STORE t1, OFFSET_STATUS(sp)
  STORE t2, OFFSET_EPC(sp)

  # 4. 设置mstatus.MPRV位,用于difftest
  li a0, (1 << 17)
  or t1, t1, a0
  csrw mstatus, t1

  # 5. 将上下文指针作为参数传递给C处理函数
  mv a0, sp
  call __am_irq_handle

  # 6. 恢复特殊寄存器
  LOAD t1, OFFSET_STATUS(sp)
  LOAD t2, OFFSET_EPC(sp)
  csrw mstatus, t1
  csrw mepc, t2

  # 7. 恢复通用寄存器
  MAP(REGS, POP)

  # 8. 释放栈空间
  addi sp, sp, CONTEXT_SIZE

  # 9. 从异常返回
  mret

这段汇编代码是 CTE 的核心,它展示了如何在汇编层面保存和恢复处理器上下文。让我们逐行分析:

  1. 分配栈空间:首先,我们在栈上分配一块大小为CONTEXT_SIZE的空间,用于保存上下文。CONTEXT_SIZE等于通用寄存器数量加上 3 个特殊寄存器 (mcause, mstatus, mepc) 乘以每个寄存器的大小 (XLEN)。

  2. 保存通用寄存器:接下来,我们使用一个巧妙的宏技巧来保存所有通用寄存器。MAP(REGS, PUSH)会展开为一系列STORE指令,将每个通用寄存器保存到栈上的对应位置。

  3. 保存特殊寄存器:然后,我们读取mcausemstatusmepc这三个特殊寄存器,并将它们保存到栈上。

  4. 设置 MPRV 位:这是一个 NEMU 特有的设置,用于支持差分测试 (difftest)。在实际硬件上可能不需要这一步。

  5. 调用 C 处理函数:将栈指针 (指向刚刚保存的上下文) 作为第一个参数传递给__am_irq_handle函数,然后调用这个函数。

  6. 恢复特殊寄存器:当__am_irq_handle函数返回后,我们从栈上恢复mstatusmepc寄存器。

  7. 恢复通用寄存器:使用MAP(REGS, POP)宏展开为一系列LOAD指令,恢复所有通用寄存器。

  8. 释放栈空间:将栈指针恢复到异常发生前的值。

  9. 从异常返回:执行mret指令,处理器会自动从mepc寄存器恢复 PC 值,从mstatus寄存器恢复状态,继续执行。

步骤 3:C 语言异常处理

__am_irq_handle函数是用 C 语言编写的,它负责将硬件异常转换为 AM 的Event结构体,然后调用用户注册的处理函数。代码位于am/src/riscv/nemu/cte.c

#include <am.h>
#include <riscv/riscv.h>
#include <klib.h>

// 用户注册的异常处理函数指针
static Context* (*user_handler)(Event, Context*) = NULL;

Context* __am_irq_handle(Context *c) {
  // 如果用户注册了处理函数
  if (user_handler) {
    Event ev = {0};
    
    // 根据mcause的值判断异常类型
    switch (c->mcause) {
      case 0xb:  // 环境调用异常(ecall指令)
        // 如果a7寄存器的值是-1,表示是yield系统调用
        if (c->gpr[17] == -1) {
          ev.event = EVENT_YIELD;
        } else {
          ev.event = EVENT_SYSCALL;
        }
        // 系统调用返回后,PC需要加4,否则会重复执行ecall指令
        c->mepc += 4;
        break;
      
      case 0x80000007:  // 定时器中断
        ev.event = EVENT_IRQ_TIMER;
        break;
      
      default:  // 其他异常
        ev.event = EVENT_ERROR;
        break;
    }

    // 调用用户注册的处理函数
    c = user_handler(ev, c);
    
    // 确保处理函数返回了有效的上下文
    assert(c != NULL);
  }

  return c;
}

// 初始化CTE子系统
bool cte_init(Context*(*handler)(Event, Context*)) {
  // 将异常向量表基地址设置为__am_asm_trap函数的地址
  asm volatile("csrw mtvec, %0" : : "r"(__am_asm_trap));

  // 注册用户处理函数
  user_handler = handler;

  return true;
}

// 主动让出CPU
void yield() {
#ifdef __riscv_e
  asm volatile("li a5, -1; ecall");
#else
  asm volatile("li a7, -1; ecall");
#endif
}

// 检查中断是否开启
bool ienabled() {
  // 读取mstatus寄存器的MIE位(第3位)
  uintptr_t mstatus;
  asm volatile("csrr %0, mstatus" : "=r"(mstatus));
  return (mstatus & (1 << 3)) != 0;
}

// 开启或关闭中断
void iset(bool enable) {
  if (enable) {
    // 设置MIE位
    asm volatile("csrs mstatus, %0" : : "r"(1 << 3));
  } else {
    // 清除MIE位
    asm volatile("csrc mstatus, %0" : : "r"(1 << 3));
  }
}

这段代码展示了如何将硬件异常转换为 AM 的抽象事件。关键部分是switch (c->mcause)语句,它根据mcause寄存器的值判断异常类型,并设置对应的Event.event字段。

特别注意系统调用的处理:当执行ecall指令时,处理器会将mepc设置为ecall指令本身的地址。因此,在处理完系统调用后,我们需要将mepc加 4,否则处理器会再次执行ecall指令,导致无限循环。

2.3.4 上下文创建:kcontext 函数

kcontext函数用于创建一个新的内核上下文。这个函数会在指定的栈上初始化一个Context结构体,当这个上下文被恢复时,会调用指定的函数。

让我们看看 RISC-V 架构上kcontext函数的实现:

// am/src/riscv/nemu/cte.c
Context *kcontext(Area kstack, void (*entry)(void *), void *arg) {
  // 栈是向下生长的,所以上下文应该放在栈的顶部
  Context *c = (Context *)kstack.end - 1;

  // 初始化上下文
  *c = (Context) {
    .gpr[10] = (uintptr_t)arg,  // a0寄存器:第一个参数
    .mepc = (uintptr_t)entry,   // 程序计数器:指向entry函数
    .mstatus = (1 << 7) | (1 << 3),  // MPP=11(机器模式), MIE=1(开启中断)
  };

  return c;
}

这个函数的实现非常巧妙:

  1. 我们将上下文放在栈的顶部 (kstack.end - 1),因为栈是向下生长的。
  2. 我们将a0寄存器设置为arg,这样当entry函数被调用时,它会接收到这个参数。
  3. 我们将mepc设置为entry函数的地址,这样当上下文被恢复时,处理器会跳转到entry函数执行。
  4. 我们设置mstatus寄存器的MPP位为 11 (机器模式) 和MIE位为 1 (开启中断)。

当这个上下文被__am_asm_trap函数恢复时,处理器会执行以下操作:

  • 恢复所有通用寄存器,包括a0 = arg
  • 恢复mepc = entrymstatus
  • 执行mret指令,跳转到mepc指向的地址,即entry函数
  • entry函数开始执行,参数arg已经在a0寄存器中

这就是上下文切换的基本原理。通过修改Context结构体中的mepc和寄存器值,我们可以控制处理器的执行流程。

2.4 VME 与 MPE:虚拟内存与多处理器

VME (Virtual Memory Extension) 和 MPE (Multi-Processing Extension) 是 AM 的两个扩展模块,分别提供虚拟内存和多处理器支持。由于这两个模块的实现比较复杂,而且在不同平台上差异很大,这里我们只做简要介绍。

2.4.1 VME:虚拟内存管理

VME 模块提供了虚拟内存管理的基本功能,包括地址空间创建、页表映射和保护。它的 API 定义如下:

// ----------------------- VME: Virtual Memory -----------------------
bool     vme_init    (void *(*pgalloc)(int), void (*pgfree)(void *));
void     protect     (AddrSpace *as);
void     unprotect   (AddrSpace *as);
void     map         (AddrSpace *as, void *vaddr, void *paddr, int prot);
Context *ucontext    (AddrSpace *as, Area kstack, void *entry);
  • vme_init:初始化 VME 子系统,需要传入页分配和页释放函数。
  • protect:创建一个新的地址空间。
  • unprotect:销毁一个地址空间。
  • map:在地址空间中建立虚拟地址到物理地址的映射。
  • ucontext:创建一个用户态上下文。

在 RISC-V 架构上,VME 的核心是satp寄存器。satp寄存器包含了页表基地址和地址空间标识符。通过修改satp寄存器,我们可以切换不同的地址空间。

// am/src/riscv/nemu/vme.c
static inline void set_satp(void *pdir) {
  uintptr_t mode = 1ul << (__riscv_xlen - 1);
  asm volatile("csrw satp, %0" : : "r"(mode | ((uintptr_t)pdir >> 12)));
}
2.4.2 MPE:多处理器支持

MPE 模块提供了多处理器支持的基本功能,包括处理器启动、核间通信和原子操作。它的 API 定义如下:

// ---------------------- MPE: Multi-Processing ----------------------
bool     mpe_init    (void (*entry)());
int      cpu_count   (void);
int      cpu_current (void);
int      atomic_xchg (int *addr, int newval);
  • mpe_init:初始化 MPE 子系统,启动所有处理器。
  • cpu_count:返回系统中的处理器数量。
  • cpu_current:返回当前处理器的 ID。
  • atomic_xchg:原子交换操作,是实现锁和同步原语的基础。

在 NEMU 平台上,MPE 的实现非常简单,只支持单处理器:

// am/src/platform/nemu/mpe.c
#include <am.h>
#include <stdatomic.h>
#include <klib-macros.h>

bool mpe_init(void (*entry)()) {
  entry();
  panic("MPE entry returns");
}

int cpu_count() {
  return 1;
}

int cpu_current() {
  return 0;
}

int atomic_xchg(int *addr, int newval) {
  return atomic_exchange(addr, newval);
}

三、编译系统详解:如何实现真正的跨架构编译

AM 的编译系统是其架构设计中不可或缺的一部分。它通过一系列巧妙的 Makefile 技巧,实现了真正的跨架构编译—— 只需要在编译时指定ARCH参数,就可以为不同的架构和平台生成对应的二进制文件。

3.1 编译系统的整体结构

AM 的编译系统采用了分层结构,从顶层到底层依次是:

abstract-machine/
├── Makefile                # 顶层Makefile(应用程序使用)
├── am/
│   └── Makefile            # AM库的Makefile
└── scripts/
    ├── *.mk                # 架构组合配置文件
    ├── isa/
    │   └── *.mk            # 指令集相关配置
    └── platform/
        └── *.mk            # 平台相关配置

这种分层结构使得添加新的架构或平台变得非常简单:只需要添加对应的 mk 文件,不需要修改任何现有代码。

3.2 顶层 Makefile 详解

顶层 Makefile 是应用程序使用的入口点,它定义了通用的编译规则和流程。让我们逐段分析它的内容:

3.2.1 基本设置与检查
# Makefile for AbstractMachine Kernels and Libraries

### 生成更可读的HTML版本
html:
	cat Makefile | sed 's/^\([^#]\)/    \1/g' | markdown_py > Makefile.html
.PHONY: html

## 1. Basic Setup and Checks

### 默认目标:生成镜像
ifeq ($(MAKECMDGOALS),)
  MAKECMDGOALS  = image
  .DEFAULT_GOAL = image
endif

### 执行clean/clean-all/html目标时跳过检查
ifeq ($(findstring $(MAKECMDGOALS),clean|clean-all|html),)

### 打印构建信息
$(info # Building $(NAME)-$(MAKECMDGOALS) [$(ARCH)])

### 检查AM_HOME环境变量是否正确
ifeq ($(wildcard $(AM_HOME)/am/include/am.h),)
  $(error $$AM_HOME must be an AbstractMachine repo)
endif

### 检查ARCH参数是否有效
ARCHS = $(basename $(notdir $(shell ls $(AM_HOME)/scripts/*.mk)))
ifeq ($(filter $(ARCHS), $(ARCH)), )
  $(error Expected $$ARCH in {$(ARCHS)}, Got "$(ARCH)")
endif

### 从ARCH参数中提取ISA和PLATFORM
ARCH_SPLIT = $(subst -, ,$(ARCH))
ISA        = $(word 1,$(ARCH_SPLIT))
PLATFORM   = $(word 2,$(ARCH_SPLIT))

### 检查是否有源代码需要编译
ifeq ($(flavor SRCS), undefined)
  $(error Nothing to build)
endif

### 检查结束
endif

这部分代码主要做了以下几件事:

  1. 设置默认目标为image
  2. 打印构建信息,包括项目名称、目标和架构
  3. 检查AM_HOME环境变量是否指向正确的 AM 仓库
  4. 检查ARCH参数是否有效
  5. ARCH参数中提取ISA(指令集) 和PLATFORM(平台)
  6. 检查是否定义了SRCS变量 (需要编译的源文件列表)
3.2.2 编译目标与文件收集
## 2. General Compilation Targets

### 创建构建目录
WORK_DIR  = $(shell pwd)
DST_DIR   = $(WORK_DIR)/build/$(ARCH)
$(shell mkdir -p $(DST_DIR))

### 编译目标(二进制镜像或静态库)
IMAGE_REL = build/$(NAME)-$(ARCH)
IMAGE     = $(abspath $(IMAGE_REL))
ARCHIVE   = $(WORK_DIR)/build/$(NAME)-$(ARCH).a

### 自动生成ramdisk
ifneq ($(MAKECMDGOALS),archive)
ifdef diskfilelist
RAMDISK_SRC = $(WORK_DIR)/build/$(NAME).ramdisk.c
$(RAMDISK_SRC): $(diskfilelist)
	python $(AM_HOME)/tools/gen-ramdisk.py $@ $^

SRCS += $(RAMDISK_SRC)
endif
endif

### 收集需要链接的文件:目标文件和库
OBJS      = $(addprefix $(DST_DIR)/, $(addsuffix .o, $(basename $(SRCS))))
LIBS     := $(sort $(LIBS) am klib) # 排序去重,避免无限递归
LINKAGE  += $(OBJS)  # 库文件会在后面通过LIB_TEMPLATE添加

这部分代码定义了编译目标和需要编译的文件:

  1. 创建构建目录build/$(ARCH)
  2. 定义最终的二进制镜像和静态库的路径
  3. 如果定义了diskfilelist变量,自动生成 ramdisk 的 C 代码
  4. 将源文件列表转换为目标文件列表
  5. 定义需要链接的库,默认包括amklib
3.2.3 编译标志设置
## 3. General Compilation Flags

### 交叉编译工具链
AS        = $(CROSS_COMPILE)gcc
CC        = $(CROSS_COMPILE)gcc
CXX       = $(CROSS_COMPILE)g++
LD        = $(CROSS_COMPILE)ld
AR        = $(CROSS_COMPILE)ar
OBJDUMP   = $(CROSS_COMPILE)objdump
OBJCOPY   = $(CROSS_COMPILE)objcopy
READELF   = $(CROSS_COMPILE)readelf

### 头文件路径
INC_PATH += $(WORK_DIR)/include $(addsuffix /include/, $(addprefix $(AM_HOME)/, $(LIBS)))
INCFLAGS += $(addprefix -I, $(INC_PATH))

### CFLAGS:非常重要的编译选项
ARCH_H := arch/$(ARCH).h
CFLAGS   += -O2 -MMD -Wall -Werror $(INCFLAGS) \
            -D__ISA__=\"$(ISA)\" -D__ISA_$(shell echo $(ISA) | tr a-z A-Z)__ \
            -D__ARCH__=$(ARCH) -D__ARCH_$(shell echo $(ARCH) | tr a-z A-Z | tr - _) \
            -D__PLATFORM__=$(PLATFORM) -D__PLATFORM_$(shell echo $(PLATFORM) | tr a-z A-Z | tr - _) \
            -DARCH_H=\"$(ARCH_H)\" \
            -fno-asynchronous-unwind-tables -fno-builtin -fno-stack-protector \
            -Wno-main -U_FORTIFY_SOURCE -fvisibility=hidden
CXXFLAGS +=  $(CFLAGS) -ffreestanding -fno-rtti -fno-exceptions
ASFLAGS  += -MMD $(INCFLAGS)
LDFLAGS  += -z noexecstack $(addprefix -T, $(LDSCRIPTS))

这部分代码设置了编译和链接的标志。其中最重要的是CFLAGS变量,它包含了大量的编译选项:

  • -O2:开启二级优化
  • -MMD:自动生成依赖文件
  • -Wall -Werror:开启所有警告,并将警告视为错误
  • -D__ISA__=\"$(ISA)\":定义__ISA__宏,值为当前指令集
  • -D__ARCH__=$(ARCH):定义__ARCH__宏,值为当前架构
  • -D__PLATFORM__=$(PLATFORM):定义__PLATFORM__宏,值为当前平台
  • -fno-asynchronous-unwind-tables:不生成异常处理表
  • -fno-builtin:不使用编译器内置函数
  • -fno-stack-protector:不使用栈保护
  • -Wno-main:不警告 main 函数的返回类型
  • -U_FORTIFY_SOURCE:禁用缓冲区溢出检查
  • -fvisibility=hidden:隐藏所有符号,只导出必要的符号

这些编译选项都是为了生成适合在裸机环境中运行的代码。

3.2.4 架构相关配置
## 4. Arch-Specific Configurations

### 包含架构相关的配置文件
-include $(AM_HOME)/scripts/$(ARCH).mk

这是整个编译系统中最关键的一行。它包含了当前架构对应的 mk 文件,这个文件会覆盖前面定义的一些变量,如CROSS_COMPILECFLAGSLDFLAGS等,并添加架构特有的源文件。

3.2.5 编译规则
## 5. Compilation Rules

### 编译C文件
$(DST_DIR)/%.o: %.c
	@mkdir -p $(dir $@) && echo + CC $<
	@$(CC) -std=gnu11 $(CFLAGS) -c -o $@ $(realpath $<)

### 编译C++文件
$(DST_DIR)/%.o: %.cc
	@mkdir -p $(dir $@) && echo + CXX $<
	@$(CXX) -std=c++17 $(CXXFLAGS) -c -o $@ $(realpath $<)

$(DST_DIR)/%.o: %.cpp
	@mkdir -p $(dir $@) && echo + CXX $<
	@$(CXX) -std=c++17 $(CXXFLAGS) -c -o $@ $(realpath $<)

### 编译汇编文件
$(DST_DIR)/%.o: %.S
	@mkdir -p $(dir $@) && echo + AS $<
	@$(AS) $(ASFLAGS) -c -o $@ $(realpath $<)

### 生成静态库
ifeq ($(MAKECMDGOALS),archive)
$(ARCHIVE): $(OBJS)
	@echo + AR "->" $(shell realpath $@ --relative-to .)
	@$(AR) rcs $@ $^
else
# 模板:构建依赖库
define LIB_TEMPLATE =
$$(AM_HOME)/$(1)/build/$(1)-$$(ARCH).a: force
	@$$(MAKE) -s -C $$(AM_HOME)/$(1) archive
LINKAGE += $$(AM_HOME)/$(1)/build/$(1)-$$(ARCH).a
endef

### 递归构建所有依赖库
$(foreach lib, $(LIBS), $(eval $(call LIB_TEMPLATE,$(lib))))
endif

### 链接生成ELF文件
$(IMAGE).elf: $(LINKAGE) $(LDSCRIPTS)
	@echo \# Creating image [$(ARCH)]
	@echo + LD "->" $(IMAGE_REL).elf
ifneq ($(filter $(ARCH),native),)
	@$(CXX) -o $@ -Wl,--whole-archive $(LINKAGE) -Wl,-no-whole-archive $(LDFLAGS_CXX)
else
	@$(LD) $(LDFLAGS) -o $@ --start-group $(LINKAGE) --end-group
endif

### 包含自动生成的依赖文件
-include $(addprefix $(DST_DIR)/, $(addsuffix .d, $(basename $(SRCS))))

这部分代码定义了各种编译规则:

  1. 编译 C、C++ 和汇编文件的规则
  2. 生成静态库的规则
  3. 递归构建依赖库的模板
  4. 链接生成 ELF 文件的规则

特别值得注意的是LIB_TEMPLATE模板的使用。这个模板会为每个依赖库生成一个规则,递归调用make archive构建对应的静态库,并将静态库添加到LINKAGE变量中。这种方式非常灵活,可以轻松地添加和管理依赖库。

3.2.6 其他目标
## 6. Miscellaneous

### 目标依赖关系
image: image-dep
archive: $(ARCHIVE)
image-dep: $(IMAGE).elf
.PHONY: image image-dep archive run

### 强制重建
force:
.PHONY: force

### 清理当前项目
clean:
	rm -rf Makefile.html $(WORK_DIR)/build/
.PHONY: clean

### 清理所有子项目
CLEAN_ALL = $(dir $(shell find . -mindepth 2 -name Makefile))
clean-all: $(CLEAN_ALL) clean
$(CLEAN_ALL):
	-@$(MAKE) -s -C $@ clean
.PHONY: clean-all $(CLEAN_ALL)

这部分代码定义了一些辅助目标,如cleanclean-all,用于清理构建产物。

3.3 架构配置文件详解

架构配置文件位于scripts/目录下,文件名格式为isa-platform.mk。这些文件包含了特定架构和平台的编译配置。

让我们以riscv32-nemu.mk为例,看看它的内容:

# scripts/riscv32-nemu.mk
include $(AM_HOME)/scripts/isa/riscv.mk
include $(AM_HOME)/scripts/platform/nemu.mk
CFLAGS  += -DISA_H=\"riscv/riscv.h\"
COMMON_CFLAGS += -march=rv32im_zicsr -mabi=ilp32   # 覆盖通用设置
LDFLAGS       += -melf32lriscv                     # 覆盖通用设置

AM_SRCS += riscv/nemu/start.S \
           riscv/nemu/cte.c \
           riscv/nemu/trap.S \
           riscv/nemu/vme.c

这个文件做了以下几件事:

  1. 包含指令集相关的配置文件isa/riscv.mk
  2. 包含平台相关的配置文件platform/nemu.mk
  3. 定义ISA_H宏,指定指令集头文件
  4. 覆盖COMMON_CFLAGSLDFLAGS变量,添加 RISC-V 32 位特有的编译选项
  5. 添加 RISC-V 架构在 NEMU 平台上特有的源文件
3.3.1 指令集配置文件

指令集配置文件位于scripts/isa/目录下,包含了特定指令集的通用编译配置。例如riscv.mk

# scripts/isa/riscv.mk
CROSS_COMPILE := riscv64-linux-gnu-
COMMON_CFLAGS := -fno-pic -march=rv64g -mcmodel=medany -mstrict-align
CFLAGS        += $(COMMON_CFLAGS) -static
ASFLAGS       += $(COMMON_CFLAGS) -O0
LDFLAGS       += -melf64lriscv

# 覆盖ARCH_H定义
ARCH_H := arch/riscv.h

这个文件定义了 RISC-V 架构通用的编译选项,如交叉编译工具链前缀、架构参数、ABI 等。

3.3.2 平台配置文件

平台配置文件位于scripts/platform/目录下,包含了特定平台的编译配置。例如nemu.mk

# scripts/platform/nemu.mk
AM_SRCS := platform/nemu/trm.c \
           platform/nemu/ioe/ioe.c \
           platform/nemu/ioe/timer.c \
           platform/nemu/ioe/input.c \
           platform/nemu/ioe/gpu.c \
           platform/nemu/ioe/audio.c \
           platform/nemu/ioe/disk.c \
           platform/nemu/mpe.c

CFLAGS    += -fdata-sections -ffunction-sections
CFLAGS    += -I$(AM_HOME)/am/src/platform/nemu/include
LDSCRIPTS += $(AM_HOME)/scripts/linker.ld
LDFLAGS   += --defsym=_pmem_start=0x80000000 --defsym=_entry_offset=0x0
LDFLAGS   += --gc-sections -e _start
NEMUFLAGS += -l $(shell dirname $(IMAGE).elf)/nemu-log.txt

MAINARGS_MAX_LEN = 64
MAINARGS_PLACEHOLDER = the_insert-arg_rule_in_Makefile_will_insert_mainargs_here
CFLAGS += -DMAINARGS_MAX_LEN=$(MAINARGS_MAX_LEN) -DMAINARGS_PLACEHOLDER=$(MAINARGS_PLACEHOLDER)

insert-arg: image
	@python $(AM_HOME)/tools/insert-arg.py $(IMAGE).bin $(MAINARGS_MAX_LEN) $(MAINARGS_PLACEHOLDER) "$(mainargs)"

image: image-dep
	@$(OBJDUMP) -d $(IMAGE).elf > $(IMAGE).txt
	@echo + OBJCOPY "->" $(IMAGE_REL).bin
	@$(OBJCOPY) -S --set-section-flags .bss=alloc,contents -O binary $(IMAGE).elf $(IMAGE).bin

run: insert-arg
	$(MAKE) -C $(NEMU_HOME) ISA=$(ISA) run ARGS="$(NEMUFLAGS)" IMG=$(IMAGE).bin

gdb: insert-arg
	$(MAKE) -C $(NEMU_HOME) ISA=$(ISA) gdb ARGS="$(NEMUFLAGS)" IMG=$(IMAGE).bin

.PHONY: insert-arg

这个文件包含了 NEMU 平台特有的配置:

  1. 添加 NEMU 平台特有的源文件
  2. 添加头文件路径
  3. 指定链接脚本
  4. 定义链接器符号,如_pmem_start_entry_offset
  5. 添加insert-arg目标,用于向二进制镜像中插入命令行参数
  6. 添加rungdb目标,用于运行和调试程序

3.4 链接脚本详解

链接脚本用于控制程序的内存布局。AM 提供了一个通用的链接脚本scripts/linker.ld,适用于大多数平台:

ENTRY(_start)
PHDRS { text PT_LOAD; data PT_LOAD; }

SECTIONS {
  /* _pmem_start和_entry_offset由LDFLAGS定义 */
  . = _pmem_start + _entry_offset;
  
  .text : {
    *(entry)       /* 首先是entry段,包含_start函数 */
    *(.text*)      /* 然后是所有代码段 */
  } : text
  etext = .;
  _etext = .;
  
  .rodata : {
    *(.rodata*)    /* 只读数据段 */
  }
  
  .data : {
    *(.data*)      /* 可读写数据段 */
  } : data
  edata = .;
  _data = .;
  
  .bss : {
    _bss_start = .;
    *(.bss*)       /* BSS段:未初始化数据 */
    *(.sbss*)
    *(.scommon)
  }
  
  /* 栈顶地址,对齐到4KB */
  _stack_top = ALIGN(0x1000);
  /* 栈大小:32KB */
  . = _stack_top + 0x8000;
  _stack_pointer = .;
  
  end = .;
  _end = .;
  
  /* 堆起始地址,对齐到4KB */
  _heap_start = ALIGN(0x1000);
}

这个链接脚本定义了程序的内存布局:

  1. 代码段 (.text):包含程序的执行代码
  2. 只读数据段 (.rodata):包含只读数据,如字符串常量
  3. 数据段 (.data):包含已初始化的全局变量
  4. BSS 段 (.bss):包含未初始化的全局变量,会被初始化为 0
  5. 栈:从_stack_top向下生长,大小为 32KB
  6. 堆:从_heap_start向上生长,直到物理内存结束

特别注意_start符号,它是程序的入口点,定义在架构相关的启动汇编文件中。例如,在 RISC-V 架构上:

# am/src/riscv/nemu/start.S
.section entry, "ax"
.globl _start
.type _start, @function

_start:
  mv s0, zero
  la sp, _stack_pointer
  call _trm_init

.size _start, . - _start

这段汇编代码是程序的第一条指令,它做了以下几件事:

  1. s0寄存器清零
  2. 将栈指针sp设置为_stack_pointer(由链接脚本定义)
  3. 调用_trm_init函数 (TRM 模块的入口点)

四、AM 的设计思想与哲学

通过对 AM 源码的深入分析,我们可以总结出它的几个核心设计思想和哲学:

4.1 最小抽象原则

AM 只抽象硬件最本质的功能,不做过多的封装。例如:

  • TRM 只提供了三个函数:heapputchhalt
  • IOE 只提供了两个函数:ioe_readioe_write
  • CTE 只提供了五个函数:cte_inityieldienabledisetkcontext

这种最小抽象原则使得 AM 非常轻量 (整个 AM 库只有不到一万行代码),同时也给了上层操作系统最大的灵活性。上层操作系统可以根据自己的需求,在 AM 的基础上构建更复杂的功能。

4.2 接口与实现严格分离

AM 严格分离了接口实现。所有接口都定义在am/include/目录下的头文件中,而实现则分散在各个架构和平台的目录中。

这种分离带来了很多好处:

  • 可移植性:上层应用只依赖接口,不依赖具体实现,因此可以轻松移植到不同的架构和平台
  • 可扩展性:添加新的架构或平台只需要实现对应的接口,不需要修改任何上层代码
  • 可维护性:接口和实现分离使得代码结构更加清晰,易于理解和维护

4.3 数据驱动编程

IOE 模块的查找表设计是数据驱动编程的典范。它将设备寄存器和处理函数的映射关系存储在一个数组中,而不是用大量的 switch-case 语句。

这种设计的优点是:

  • 简洁:不需要编写大量的条件判断代码
  • 灵活:添加新设备只需要在查找表中添加一项,不需要修改 IOE 的核心代码
  • 高效:数组访问是 O (1) 时间复杂度,比 switch-case 语句更快

4.4 编译时配置

AM 大量使用编译时配置,通过宏和条件编译来选择不同的实现。例如:

  • 在编译时指定ARCH参数,选择不同的架构和平台
  • 在代码中使用#ifdef __riscv__等宏进行条件编译
  • 在 Makefile 中根据不同的架构设置不同的编译选项

这种方式避免了运行时的开销,同时也使得生成的二进制文件只包含必要的代码,非常适合资源受限的嵌入式系统。

4.5 机制与策略分离

AM 严格区分了机制策略。AM 只提供机制,不提供策略。例如:

  • AM 提供了上下文切换的机制 (Context结构体和yield函数),但不提供进程调度的策略
  • AM 提供了虚拟内存的机制 (map函数和AddrSpace结构体),但不提供内存管理的策略
  • AM 提供了中断处理的机制 (cte_init函数和Event结构体),但不提供中断处理的策略

这种分离使得 AM 非常通用,可以用于构建各种不同类型的操作系统,从简单的实时操作系统到复杂的通用操作系统。

五、实战:编写你的第一个 AM 程序

理论学习了这么多,现在让我们动手编写一个简单的 AM 程序,体验一下 AM 的使用。

5.1 环境准备

首先,你需要设置好 AM 的开发环境。你可以参考 "一生一芯" 计划的官方文档:https://ysyx.oscc.cc/docs/

基本步骤是:

  1. 克隆 AM 仓库:git clone https://github.com/OSCPU/abstract-machine.git
  2. 设置AM_HOME环境变量:export AM_HOME=/path/to/abstract-machine
  3. 安装交叉编译工具链

5.2 编写 Hello World 程序

创建一个新的目录hello-am,并在其中创建hello.c文件:

// hello.c
#include <am.h>
#include <klib.h>

int main(const char *args) {
  printf("Hello, AbstractMachine!\n\n");
  
  printf("=== 系统信息 ===\n");
  printf("架构: %s\n", __ISA__);
  printf("平台: %s\n", __PLATFORM__);
  printf("物理内存起始: 0x%p\n", &_pmem_start);
  printf("物理内存大小: %d MB\n", PMEM_SIZE / 1024 / 1024);
  printf("堆起始地址: 0x%p\n", heap.start);
  printf("堆结束地址: 0x%p\n", heap.end);
  printf("堆大小: %d KB\n\n", (uintptr_t)heap.end - (uintptr_t)heap.start / 1024);
  
  printf("=== 定时器测试 ===\n");
  AM_TIMER_UPTIME_T uptime;
  ioe_read(AM_TIMER_UPTIME, &uptime);
  printf("系统启动时间: %llu 微秒\n", uptime.us);
  
  AM_TIMER_RTC_T rtc;
  ioe_read(AM_TIMER_RTC, &rtc);
  printf("当前时间: %d年%d月%d日 %d:%d:%d\n\n", 
         rtc.year, rtc.month, rtc.day, 
         rtc.hour, rtc.minute, rtc.second);
  
  printf("=== 内存分配测试 ===\n");
  void *p1 = malloc(1024);
  void *p2 = malloc(2048);
  printf("分配1KB内存: 0x%p\n", p1);
  printf("分配2KB内存: 0x%p\n", p2);
  
  free(p1);
  free(p2);
  printf("内存释放成功\n\n");
  
  printf("Hello World程序执行完毕!\n");
  
  return 0;
}

5.3 编写 Makefile

在同一个目录下创建Makefile文件:

makefile

# Makefile
NAME = hello
SRCS = hello.c

include $(AM_HOME)/Makefile

这个 Makefile 非常简单,只需要定义项目名称和源文件列表,然后包含 AM 的顶层 Makefile 即可。

5.4 编译运行

现在,你可以编译并运行这个程序了:

# 编译并在RISC-V 32位NEMU模拟器上运行
make ARCH=riscv32-nemu run

# 或者在x86 32位NEMU模拟器上运行
make ARCH=x86-nemu run

# 或者在Linux原生平台上运行
make ARCH=native run

你应该会看到类似以下的输出:

# Building hello-image [riscv32-nemu]
+ CC hello.c
+ AR "->" build/hello-riscv32-nemu.a
+ LD "->" build/hello-riscv32-nemu.elf
+ OBJCOPY "->" build/hello-riscv32-nemu.bin
Hello, AbstractMachine!

=== 系统信息 ===
架构: riscv32
平台: nemu
物理内存起始: 0x80000000
物理内存大小: 128 MB
堆起始地址: 0x80008000
堆结束地址: 0x88000000
堆大小: 131040 KB

=== 定时器测试 ===
系统启动时间: 12345 微秒
当前时间: 2024年5月20日 15:30:45

=== 内存分配测试 ===
分配1KB内存: 0x80008010
分配2KB内存: 0x80008420
内存释放成功

Hello World程序执行完毕!

六、总结与展望

在这篇文章中,我们从源码层面深入解析了 AbstractMachine 的架构设计和实现细节。我们看到,AM 通过清晰的分层设计、巧妙的编译系统和简洁的 API,成功地屏蔽了不同硬件架构的差异,为我们提供了一个统一的裸机编程环境。

AM 的设计非常优雅,它体现了很多优秀的软件工程思想,如最小抽象原则、接口与实现分离、数据驱动编程和机制与策略分离。这些思想不仅适用于硬件抽象层的设计,也适用于其他软件系统的设计。

对于操作系统学习者来说,AM 是一个非常好的工具。它让我们能够专注于操作系统本身的核心概念,如进程管理、内存管理、文件系统等,而不需要花费大量时间处理硬件细节。同时,AM 的源码非常简洁易懂,是学习底层系统编程的绝佳材料。

如果你正在学习操作系统,或者对计算机体系结构感兴趣,我强烈推荐你去深入了解一下 AbstractMachine。相信你会从中收获很多。


参考资料

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐