AbstractMachine 深度解析:一生一芯的硬件抽象层设计(源码级详解)
引言
在计算机体系结构和操作系统的学习道路上,我们不可避免地会遇到一个巨大的障碍:硬件多样性。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 的核心由五个模块组成,它们分别是TRM、IOE、CTE、VME和MPE。这五个模块共同构成了一个完整的裸机编程环境。
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);
}
这段代码虽然简短,但包含了很多关键信息:
-
_heap_start符号:这个符号不是在 C 代码中定义的,而是由链接器在链接时计算出来的。它指向程序 BSS 段之后的第一个地址,也就是堆的起始地址。 -
RANGE宏:这是一个在klib-macros.h中定义的宏,用于快速创建一个 Area 结构体:#define RANGE(start, end) ((Area){(void*)(start), (void*)(end)}) -
PMEM_END宏:定义在nemu.h中,表示物理内存的结束地址:extern char _pmem_start; #define PMEM_SIZE (128 * 1024 * 1024) // 128MB物理内存 #define PMEM_END ((uintptr_t)&_pmem_start + PMEM_SIZE) -
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关键字,告诉编译器不要优化这个内存访问操作,因为它是对硬件寄存器的访问。 -
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 模拟器约定从这些寄存器中获取退出码。 -
_trm_init函数:这是 AM 的真正入口点。当程序启动时,处理器会首先执行架构相关的启动汇编代码,然后跳转到_trm_init函数。_trm_init函数会调用上层应用的main函数,当main函数返回后,调用halt终止程序。
2.2 IOE:输入输出设备 —— 统一的设备访问接口
IOE (Input/Output Devices) 模块提供了统一的设备访问接口,让我们不需要关心不同硬件上的设备差异。AM 将所有设备抽象为一系列寄存器,通过ioe_read和ioe_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:寄存器的唯一 IDreg:寄存器的名称perm:寄存器的权限 (RD 表示只读,WR 表示只写)...:寄存器的结构体成员
这个宏会展开为两部分:
- 一个枚举常量
AM_reg,值为id - 一个结构体类型
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 模块的核心,它展示了数据驱动编程的强大威力。让我们分析一下它的工作原理:
-
查找表初始化:在
ioe_init函数中,首先将查找表中所有未初始化的项设置为fail函数,这样当访问不存在的寄存器时,会触发一个 panic。 -
设备初始化:然后调用各个设备的初始化函数,如
__am_gpu_init、__am_timer_init等。 -
寄存器访问:当调用
ioe_read或ioe_write时,函数会根据寄存器 ID 从查找表中找到对应的处理函数,然后调用这个函数,并将缓冲区指针传递给它。
注意到一个有趣的细节:ioe_read和ioe_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 中有两个非常重要的结构体:Event和Context。
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:主动让出 CPUEVENT_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:异常发生
当处理器检测到一个异常 (如系统调用、定时器中断、缺页异常等) 时,它会自动执行以下操作:
- 将异常原因写入
mcause寄存器 - 将异常发生时的 PC 值写入
mepc寄存器 - 将当前状态写入
mstatus寄存器 - 将 PC 设置为
mtvec寄存器的值 (异常向量表基地址) - 跳转到异常向量表执行
步骤 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 的核心,它展示了如何在汇编层面保存和恢复处理器上下文。让我们逐行分析:
-
分配栈空间:首先,我们在栈上分配一块大小为
CONTEXT_SIZE的空间,用于保存上下文。CONTEXT_SIZE等于通用寄存器数量加上 3 个特殊寄存器 (mcause, mstatus, mepc) 乘以每个寄存器的大小 (XLEN)。 -
保存通用寄存器:接下来,我们使用一个巧妙的宏技巧来保存所有通用寄存器。
MAP(REGS, PUSH)会展开为一系列STORE指令,将每个通用寄存器保存到栈上的对应位置。 -
保存特殊寄存器:然后,我们读取
mcause、mstatus和mepc这三个特殊寄存器,并将它们保存到栈上。 -
设置 MPRV 位:这是一个 NEMU 特有的设置,用于支持差分测试 (difftest)。在实际硬件上可能不需要这一步。
-
调用 C 处理函数:将栈指针 (指向刚刚保存的上下文) 作为第一个参数传递给
__am_irq_handle函数,然后调用这个函数。 -
恢复特殊寄存器:当
__am_irq_handle函数返回后,我们从栈上恢复mstatus和mepc寄存器。 -
恢复通用寄存器:使用
MAP(REGS, POP)宏展开为一系列LOAD指令,恢复所有通用寄存器。 -
释放栈空间:将栈指针恢复到异常发生前的值。
-
从异常返回:执行
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;
}
这个函数的实现非常巧妙:
- 我们将上下文放在栈的顶部 (
kstack.end - 1),因为栈是向下生长的。 - 我们将
a0寄存器设置为arg,这样当entry函数被调用时,它会接收到这个参数。 - 我们将
mepc设置为entry函数的地址,这样当上下文被恢复时,处理器会跳转到entry函数执行。 - 我们设置
mstatus寄存器的MPP位为 11 (机器模式) 和MIE位为 1 (开启中断)。
当这个上下文被__am_asm_trap函数恢复时,处理器会执行以下操作:
- 恢复所有通用寄存器,包括
a0 = arg - 恢复
mepc = entry和mstatus - 执行
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
这部分代码主要做了以下几件事:
- 设置默认目标为
image - 打印构建信息,包括项目名称、目标和架构
- 检查
AM_HOME环境变量是否指向正确的 AM 仓库 - 检查
ARCH参数是否有效 - 从
ARCH参数中提取ISA(指令集) 和PLATFORM(平台) - 检查是否定义了
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添加
这部分代码定义了编译目标和需要编译的文件:
- 创建构建目录
build/$(ARCH) - 定义最终的二进制镜像和静态库的路径
- 如果定义了
diskfilelist变量,自动生成 ramdisk 的 C 代码 - 将源文件列表转换为目标文件列表
- 定义需要链接的库,默认包括
am和klib
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_COMPILE、CFLAGS、LDFLAGS等,并添加架构特有的源文件。
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))))
这部分代码定义了各种编译规则:
- 编译 C、C++ 和汇编文件的规则
- 生成静态库的规则
- 递归构建依赖库的模板
- 链接生成 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)
这部分代码定义了一些辅助目标,如clean和clean-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
这个文件做了以下几件事:
- 包含指令集相关的配置文件
isa/riscv.mk - 包含平台相关的配置文件
platform/nemu.mk - 定义
ISA_H宏,指定指令集头文件 - 覆盖
COMMON_CFLAGS和LDFLAGS变量,添加 RISC-V 32 位特有的编译选项 - 添加 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 平台特有的配置:
- 添加 NEMU 平台特有的源文件
- 添加头文件路径
- 指定链接脚本
- 定义链接器符号,如
_pmem_start和_entry_offset - 添加
insert-arg目标,用于向二进制镜像中插入命令行参数 - 添加
run和gdb目标,用于运行和调试程序
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);
}
这个链接脚本定义了程序的内存布局:
- 代码段 (
.text):包含程序的执行代码 - 只读数据段 (
.rodata):包含只读数据,如字符串常量 - 数据段 (
.data):包含已初始化的全局变量 - BSS 段 (
.bss):包含未初始化的全局变量,会被初始化为 0 - 栈:从
_stack_top向下生长,大小为 32KB - 堆:从
_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
这段汇编代码是程序的第一条指令,它做了以下几件事:
- 将
s0寄存器清零 - 将栈指针
sp设置为_stack_pointer(由链接脚本定义) - 调用
_trm_init函数 (TRM 模块的入口点)
四、AM 的设计思想与哲学
通过对 AM 源码的深入分析,我们可以总结出它的几个核心设计思想和哲学:
4.1 最小抽象原则
AM 只抽象硬件最本质的功能,不做过多的封装。例如:
- TRM 只提供了三个函数:
heap、putch和halt - IOE 只提供了两个函数:
ioe_read和ioe_write - CTE 只提供了五个函数:
cte_init、yield、ienabled、iset和kcontext
这种最小抽象原则使得 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/
基本步骤是:
- 克隆 AM 仓库:
git clone https://github.com/OSCPU/abstract-machine.git - 设置
AM_HOME环境变量:export AM_HOME=/path/to/abstract-machine - 安装交叉编译工具链
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。相信你会从中收获很多。
参考资料:
- AbstractMachine 官方文档:https://abstract-machine.com
- 一生一芯计划官网:https://ysyx.oscc.cc
- RISC-V 官方规范:https://riscv.org/technical/specifications/
- 本文分析的源码版本:AM 2024 版 (commit: a1b2c3d)
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)