Skip to main content

系统调用

前置知识:

arm svc指令:基本和riscv ecall, x86 syscall没什么区别

对于syscall的一个概述,可以参考https://hackmd.io/@RinHizakura/S1wfy6nQO

系统调用是系统为用户程序提供的高特权操作接口。在本实验中,用户程序通过 svc 指令进入内核模式。在内核模式下,首先操作系统代码和硬件将保存用户程序的状态。操作系统根据系统调用号码执行相应的系统调用处理代码,完成系统调用的实际功能,并保存返回值。最后,操作系统和硬件将恢复用户程序的状态,将系统调用的返回值返回给用户程序,继续用户程序的执行。

通过异常进入到内核后,需要保存当前线程的各个寄存器值,以便从内核态返回用户态时进行恢复。保存工作在exception_enter 中进行,恢复工作则由exception_exit完成。可以参考kernel/include/arch/aarch64/arch/machine/register.h中的寄存器结构,保存时在栈中应准备ARCH_EXEC_CONT_SIZE大小的空间。

到此为止是上一节的内容

完成保存后,需要进行内核栈切换,首先从TPIDR_EL1寄存器中读取到当前核的per_cpu_info(参考kernel/include/arch/aarch64/arch/machine/smp.h),从而拿到其中的cpu_stack地址。

TPIDR_EL1(Thread Process ID Register for EL1)是ARM架构中一个特殊的寄存器,用于存储当前执行线程或进程的上下文信息。在操作系统内核中,这个寄存器经常被用来存储指向per_cpu_data结构的指针,该结构包含了特定于CPU的数据,比如CPU的局部变量和栈指针。

实质上这是个“保留寄存器”,硬件上没有对其的直接操作,留给操作系统实现者自行使用,具体的初始化和设置在smp 之中,chcore将其设置为指向cpu_info结构体的指针,并且在之后不再变化

struct per_cpu_info {
/* The execution context of current thread */
u64 cur_exec_ctx;

/* Per-CPU stack */
char *cpu_stack;

/* struct thread *fpu_owner */
void *fpu_owner;
u32 fpu_disable;

char pad[pad_to_cache_line(sizeof(u64) +
sizeof(char *) +
sizeof(void *) +
sizeof(u32))];
} __attribute__((packed, aligned(64)));

extern struct per_cpu_info cpu_info[PLAT_CPU_NUM];

extern volatile char cpu_status[PLAT_CPU_NUM];

extern u64 ctr_el0;
void init_per_cpu_info(u32 cpuid)
{
struct per_cpu_info *info;

if (cpuid == 0)
ctr_el0 = read_ctr();

info = &cpu_info[cpuid];

info->cur_exec_ctx = 0;

info->cpu_stack = (char *)(KSTACKx_ADDR(cpuid) + CPU_STACK_SIZE);

info->fpu_owner = NULL;
info->fpu_disable = 0;
// 在这里初始化tpidr
asm volatile("msr tpidr_el1, %0"::"r" (info));
}

阅读了这些代码之后,最后的内核栈切换也可以理解了,内核栈的切换其实就是在汇编之中保存/加载不同的寄存器在某个地址,而通过将这个地址存在某个保留寄存器之中,我们就能在保存好寄存器之后,直接赋值sp来顺利完成栈的切换

.macro switch_to_cpu_stack
mrs x24, TPIDR_EL1
add x24, x24, #OFFSET_LOCAL_CPU_STACK
ldr x24, [x24]
mov sp, x24
.endm

.macro switch_to_thread_ctx
mrs x24, TPIDR_EL1
add x24, x24, #OFFSET_CURRENT_EXEC_CTX
ldr x24, [x24]
mov sp, x24
.endm

即使不去翻 #OFFSET_XXX 的定义,也能想到是用类似container_of的hack得到


u64 cur_exec_ctx;
/* Per-CPU stack */
char *cpu_stack;

在cpu_info之中的偏移量

最后的定义也不出所料

#define OFFSET_CURRENT_EXEC_CTX		0
#define OFFSET_LOCAL_CPU_STACK 8
#define OFFSET_CURRENT_FPU_OWNER 16
#define OFFSET_FPU_DISABLE 24

以下有一个很长的调用链条,最终图在 https://www.notion.so/164bb0b856f780199160ce212c64356c?pvs=4#173bb0b856f78062906dc753069d407a

接下来我们尝试分析printf这个用户态函数,文档已经给出了他在musl-libc之中的调用链,而跟踪这个调用链,我们就可以一窥API和ABI的边界

// user/system-services/chcore-libc/musl-libc/src/stdio/__stdio_write.c
size_t __stdio_write(FILE *f, const unsigned char *buf, size_t len)
{
struct iovec iovs[2] = {
{ .iov_base = f->wbase, .iov_len = f->wpos-f->wbase },
{ .iov_base = (void *)buf, .iov_len = len }
};
struct iovec *iov = iovs;
size_t rem = iov[0].iov_len + iov[1].iov_len;
int iovcnt = 2;
ssize_t cnt;
for (;;) {
// HERE!!!
cnt = syscall(SYS_writev, f->fd, iov, iovcnt);

可以看到,最后回归到了一个SYS_writev的syscall

继续跟踪,我们似乎来到了奇怪的位置

// /usr/include/x86_64-linux-gnu/bits/syscall.h
#ifdef __NR_writev
# define SYS_writev __NR_writev
#endif

// /usr/include/x86_64-linux-gnu/asm/unistd_64.h
#define __NR_writev 20

// /usr/include/asm-generic/unistd.h
#define __NR_writev 66
__SC_COMP(__NR_writev, sys_writev, compat_sys_writev)

#ifdef __SYSCALL_COMPAT
#define __SC_COMP(_nr, _sys, _comp) __SYSCALL(_nr, _comp)
#define __SC_COMP_3264(_nr, _32, _64, _comp) __SYSCALL(_nr, _comp)
#else
#define __SC_COMP(_nr, _sys, _comp) __SYSCALL(_nr, _sys)
#define __SC_COMP_3264(_nr, _32, _64, _comp) __SC_3264(_nr, _32, _64)
#endif

此处, __SYSCALL_COMPAT 是兼容性的宏,不需要太过深入探究,而在正常情况下,用户态的 SYS_writev 就绑定到了 __NR_writev ,并继续绑定到了内核的 sys_writev

在 Linux 中,__NR_XXXSYS_XXX 是用于系统调用编号的宏定义。它们在用户态和内核态之间提供了一种通过系统调用接口进行交互的机制。

__NR_XXXSYS_XXX 的含义

  • __NR_XXX:这是内核中定义的系统调用编号。每个系统调用都有一个唯一的编号,用于标识不同的系统调用。
  • SYS_XXX:这是用户态(如 glibc)使用的宏,通常直接定义为 __NR_XXX。它们用于在用户态代码中引用系统调用编号。

用户态如何与自定义内核系统调用交互

  1. 定义系统调用编号
    • 在内核中,每个系统调用都有一个编号,通常在内核源代码的头文件中定义(如 asm/unistd.h)。
    • 编号通过宏 __NR_XXX 提供。
  2. 用户态使用系统调用
    • 用户态程序通过封装的库函数(如 glibc 提供的接口)调用系统调用。
    • 这些库函数通常使用 syscall 函数,通过系统调用编号和参数与内核交互。
  3. 系统调用的实现
    • 用户态调用 syscall 函数时,通过软件中断或指令(如 syscall 指令)进入内核态。
    • 内核根据传入的系统调用编号(如 __NR_XXX)找到对应的内核实现函数。
  4. 自定义系统调用
    • 如果你自定义了一个系统调用,需要在内核中为其分配一个新的编号,并实现其功能。
    • 在用户态,添加相应的宏定义(如 SYS_XXX),并使用 syscall 函数进行调用。

示例

假设你在内核中添加了一个新的系统调用 my_syscall

  • 在内核中为 my_syscall 分配一个编号 __NR_my_syscall
  • 在用户态头文件中定义 #define SYS_my_syscall __NR_my_syscall
  • 用户态程序可以通过 syscall(SYS_my_syscall, arg1, arg2, ...) 来调用这个自定义系统调用。

这种机制使得用户态程序可以灵活地调用内核提供的系统服务,包括自定义的系统调用。

好像走偏了,重新根据文档,查找stdout和syscall_dispatcher


// user/chcore-libc/musl-libc/src/chcore-port/syscall_dispatcher.c
long __syscall6(long n, long a, long b, long c, long d, long e, long f){
// ...
switch (n){
case SYS_write: {
return chcore_write((int)a, (void *)b, (size_t)c);
}
// ...

这个 __syscall6 是什么意思呢,只是这个syscall带六个参数

long __syscall0(long n);
long __syscall1(long n, long a);
long __syscall2(long n, long a, long b);
long __syscall3(long n, long a, long b, long c);
long __syscall4(long n, long a, long b, long c, long d);
long __syscall5(long n, long a, long b, long c, long d, long e);
long __syscall6(long n, long a, long b, long c, long d, long e, long f);

但为什么这个chcore_write是三个参数,却被放到了syscall6里面?

可能是为了灵活性的考量,也可能是不同架构下的write对应的syscall参数不同,选了比较大的那个(例如pwrite就需要5个参数)

anyway, 我们找到了内核的入口(可以在 /user/chcore-libc/musl-libc/arch/x86_64/syscall_arch.h 下找到syscall0~6的libc 体系结构相关的asm,就是单纯的搬运寄存器然后发出汇编指令

继续追踪chcore_write的过程并不是十分的愉快

一方面,我们可以顺着chcore_write往下找,会发现他调用了stdout这一个初始化的fd的fd_ops的write函数,再往下可以得到这个write函数是chcore_file_write_cb,而这个函数又是通过ipc_call这个syscall,利用ipc在给filesystem传信息,然后继续追踪能找到他自定义了一个handler……

回到文档,先找到chcore_stdout_write吧(在笔者的环境之中,这个符号是缺失的,使用全文搜索而不是符号检索去找,即vscode的左侧搜索而不是#检索)

// user/system-services/chcore-libc/libchcore/porting/overrides/src/chcore-port/stdio.c
static ssize_t chcore_stdout_write(int fd, void *buf, size_t count)
{
/* TODO: stdout should also follow termios flags */
char buffer[STDOUT_BUFSIZE];
size_t size = 0;

for (char *p = buf; p < (char *)buf + count; p++) {
if (size + 2 > STDOUT_BUFSIZE) {
put(buffer, size);
size = 0;
}

if (*p == '\n') {
buffer[size++] = '\r';
}
buffer[size++] = *p;
}

if (size > 0) {
put(buffer, size);
}

return count;
}

有个问题是

user/chcore-libc/libchcore/porting/overrides/src/chcore-port/stdio.c user/chcore-libc/musl-libc/src/chcore-port/stdio.c user/system-services/chcore-libc/musl-libc/src/chcore-port/stdio.c 三个文件之中都有相同的static ssize_t chcore_stdout_write(int fd, void *buf, size_t count)函数 但不知道是为什么,留待后面探究了

可以看到函数之中调用了put, 而put终于是有直接的内核态syscall table项对应了

static void put(char buffer[], unsigned size)
{
chcore_syscall2(CHCORE_SYS_putstr, (vaddr_t)buffer, size);
}

反向追踪可以发现

// user/system-services/chcore-libc/libchcore/porting/overrides/src/chcore-port/stdio.c
struct fd_ops stdout_ops = {
.read = chcore_stdio_read,
.write = chcore_stdout_write,
.close = chcore_stdout_close,
.poll = chcore_stdio_poll,
.ioctl = chcore_stdio_ioctl,
.fcntl = chcore_stdio_fcntl,
};

在chcore-libc而不是musl-libc之中

最终的调用者是

// user/system-services/chcore-libc/libchcore/porting/overrides/src/chcore-port/syscall_dispatcher.c
/*
* This function is local to libc and it will
* only be executed once during the libc init time.
*
* It will be executed in the dynamic loader (for dynamic-apps) or
* just before calling user main (for static-apps).
* Nevertheless, when loading a dynamic application, it will be invoked twice.
* This is why the variable `initialized` is required.
*/
__attribute__((constructor(101))) void __libc_chcore_init(void)
{
static int initialized = 0;
int fd0, fd1, fd2;
struct termios *ts;
char *pidstr;
size_t i;
elf_auxv_t *auxv;

if (initialized == 1)
return;
initialized = 1;

/* Initialize libfs_client */
init_fs_client_side();

/* Open stdin/stdout/stderr */
/* STDIN */
fd0 = alloc_fd();
assert(fd0 == STDIN_FILENO);
fd_dic[fd0]->type = FD_TYPE_STDIN;
// BUG: this fd0 is a file descriptor, but fd_dic[fd0]->fd should be an
// "fid" in fsm.srv
fd_dic[fd0]->fd = fd0;
fd_dic[fd0]->fd_op = &stdin_ops;
/* Set default stdin termios flags */
ts = &fd_dic[fd0]->termios;
ts->c_iflag = INLCR | ICRNL;
ts->c_oflag = OPOST | ONLCR;
ts->c_cflag = CS8;
ts->c_lflag = ISIG | ICANON | ECHO | ECHOE | NOFLSH;
ts->c_cc[VMIN] = 1;
ts->c_cc[VTIME] = 0;

/* STDOUT */
fd1 = alloc_fd();
assert(fd1 == STDOUT_FILENO);
fd_dic[fd1]->type = FD_TYPE_STDOUT;
fd_dic[fd1]->fd = fd1;
fd_dic[fd1]->fd_op = &stdout_ops; // 这里!!!!!

/* STDERR */
fd2 = alloc_fd();
assert(fd2 == STDERR_FILENO);
fd_dic[fd2]->type = FD_TYPE_STDERR;
fd_dic[fd2]->fd = fd2;
fd_dic[fd2]->fd_op = &stderr_ops;

/* ProcMgr passes PID through env variable */
pidstr = getenv("PID");
if (pidstr)
chcore_pid = atoi(pidstr);

for (i = 0; __environ[i]; i++)
;

auxv = (elf_auxv_t *)(__environ + i + 1);

/* Set seed for ASLR */
init_chcore_aslr();
init_heap_start();

libc_connect_services(auxv);
}

所以从printf的整个调用栈逻辑是

printf→vprintf→printf-core→out→__fwrite_x→write

那FILE*的write又是哪里的呢?

FILE 是 一个等效于 _IO_FILE 结构体的宏,而后者在 user/system-services/chcore-libc/musl-libc/src/internal/stdio_impl.h 中有着声明

struct _IO_FILE {
unsigned flags;
unsigned char *rpos, *rend;
int (*close)(FILE *);
unsigned char *wend, *wpos;
unsigned char *mustbezero_1;
unsigned char *wbase;
size_t (*read)(FILE *, unsigned char *, size_t);
size_t (*write)(FILE *, const unsigned char *, size_t);
off_t (*seek)(FILE *, off_t, int);
unsigned char *buf;
size_t buf_size;
FILE *prev, *next;
int fd;
int pipe_pid;
long lockcount;
int mode;
volatile int lock;
int lbf;
void *cookie;
off_t off;
char *getln_buf;
void *mustbezero_2;
unsigned char *shend;
off_t shlim, shcnt;
FILE *prev_locked, *next_locked;
struct __locale_struct *locale;
};

实际上,这个 _IO_FILE 是OS实现者自己完成的,与POSIX对接只需要有 read, write, seek, close四个方法

他的实现可以用这个函数来说明

FILE *__fdopen(int fd, const char *mode)
{
FILE *f;
struct winsize wsz;

/* Check for valid initial mode character */
if (!strchr("rwa", *mode)) {
errno = EINVAL;
return 0;
}

/* Allocate FILE+buffer or fail */
if (!(f=malloc(sizeof *f + UNGET + BUFSIZ))) return 0;

/* Zero-fill only the struct, not the buffer */
memset(f, 0, sizeof *f);

/* Impose mode restrictions */
if (!strchr(mode, '+')) f->flags = (*mode == 'r') ? F_NOWR : F_NORD;

/* Apply close-on-exec flag */
if (strchr(mode, 'e')) __syscall(SYS_fcntl, fd, F_SETFD, FD_CLOEXEC);

/* Set append mode on fd if opened for append */
if (*mode == 'a') {
int flags = __syscall(SYS_fcntl, fd, F_GETFL);
if (!(flags & O_APPEND))
__syscall(SYS_fcntl, fd, F_SETFL, flags | O_APPEND);
f->flags |= F_APP;
}

f->fd = fd;
f->buf = (unsigned char *)f + sizeof *f + UNGET;
f->buf_size = BUFSIZ;

/* Activate line buffered mode for terminals */
f->lbf = EOF;
if (!(f->flags & F_NOWR) && !__syscall(SYS_ioctl, fd, TIOCGWINSZ, &wsz))
f->lbf = '\n';

/* Initialize op ptrs. No problem if some are unneeded. */
f->read = __stdio_read;
f->write = __stdio_write;
f->seek = __stdio_seek;
f->close = __stdio_close;

if (!libc.threaded) f->lock = -1;

/* Add new FILE to open file list */
return __ofl_add(f);
}

我们从write往回找

FILE *__fdopen(int fd, const char *mode)
{
FILE *f;
struct winsize wsz;

/* Check for valid initial mode character */
if (!strchr("rwa", *mode)) {
errno = EINVAL;
return 0;
}

/* Allocate FILE+buffer or fail */
if (!(f=malloc(sizeof *f + UNGET + BUFSIZ))) return 0;

/* Zero-fill only the struct, not the buffer */
memset(f, 0, sizeof *f);

/* Impose mode restrictions */
if (!strchr(mode, '+')) f->flags = (*mode == 'r') ? F_NOWR : F_NORD;

/* Apply close-on-exec flag */
if (strchr(mode, 'e')) __syscall(SYS_fcntl, fd, F_SETFD, FD_CLOEXEC);

/* Set append mode on fd if opened for append */
if (*mode == 'a') {
int flags = __syscall(SYS_fcntl, fd, F_GETFL);
if (!(flags & O_APPEND))
__syscall(SYS_fcntl, fd, F_SETFL, flags | O_APPEND);
f->flags |= F_APP;
}

f->fd = fd;
f->buf = (unsigned char *)f + sizeof *f + UNGET;
f->buf_size = BUFSIZ;

/* Activate line buffered mode for terminals */
f->lbf = EOF;
if (!(f->flags & F_NOWR) && !__syscall(SYS_ioctl, fd, TIOCGWINSZ, &wsz))
f->lbf = '\n';

/* Initialize op ptrs. No problem if some are unneeded. */
f->read = __stdio_read;
f->write = __stdio_write;
f->seek = __stdio_seek;
f->close = __stdio_close;

if (!libc.threaded) f->lock = -1;

/* Add new FILE to open file list */
return __ofl_add(f);
}

可以看到,在调用 __fdopen 的时候,我们由一个fd,动态地生成了这个 _IO_FILE 结构体,并把他的方法用 __stdio_xx 赋值

__stdio_xx 内部是libc库实现的逻辑,但落到最后是调用SYS_readv, SYS_read的syscall

size_t __stdio_read(FILE *f, unsigned char *buf, size_t len)
{
struct iovec iov[2] = {
{ .iov_base = buf, .iov_len = len - !!f->buf_size },
{ .iov_base = f->buf, .iov_len = f->buf_size }
};
ssize_t cnt;

cnt = iov[0].iov_len ? syscall(SYS_readv, f->fd, iov, 2)
: syscall(SYS_read, f->fd, iov[1].iov_base, iov[1].iov_len);
if (cnt <= 0) {
f->flags |= cnt ? F_ERR : F_EOF;
return 0;
}
if (cnt <= iov[0].iov_len) return cnt;
cnt -= iov[0].iov_len;
f->rpos = f->buf;
f->rend = f->buf + cnt;
if (f->buf_size) buf[len-1] = *f->rpos++;
return len;
}

最后给到用户的就是fopen了

FILE *fopen(const char *restrict filename, const char *restrict mode)
{
FILE *f;
int fd;
int flags;

/* Check for valid initial mode character */
if (!strchr("rwa", *mode)) {
errno = EINVAL;
return 0;
}

/* Compute the flags to pass to open() */
flags = __fmodeflags(mode);

fd = sys_open(filename, flags, 0666);
if (fd < 0) return 0;
if (flags & O_CLOEXEC)
__syscall(SYS_fcntl, fd, F_SETFD, FD_CLOEXEC);

f = __fdopen(fd, mode);
if (f) return f;

__syscall(SYS_close, fd);
return 0;
}

由此我们可以得出, 内核里面始终只维护fd, 而用户态的FILE*其实是libc做的一层包装,而如果想要自定义kernel, 只需要保证SYS_readv, SYS_writev, SYS_read, SYS_write这些宏存在,并处理对应参数的syscall就行

众所周知,stdout只是一个stdout文件的宏,而stdout文件就是FILE*

// user/system-services/chcore-libc/musl-libc/src/stdio/stdout.c
hidden FILE __stdout_FILE = {
.buf = buf+UNGET,
.buf_size = sizeof buf-UNGET,
.fd = 1,
.flags = F_PERM | F_NORD,
.lbf = '\n',
.write = __stdout_write,
.seek = __stdio_seek,
.close = __stdio_close,
.lock = -1,
};
FILE *const stdout = &__stdout_FILE;
// user/system-services/chcore-libc/musl-libc/src/stdio/__stdout_write.c
#include "stdio_impl.h"
#include <sys/ioctl.h>

size_t __stdout_write(FILE *f, const unsigned char *buf, size_t len)
{
struct winsize wsz;
f->write = __stdio_write;
if (!(f->flags & F_SVB) && __syscall(SYS_ioctl, f->fd, TIOCGWINSZ, &wsz))
f->lbf = -1;
return __stdio_write(f, buf, len);
}

用户态

printf→vprintf→printf-core→out→__fwrite_x→(f→)write=stdout_write→__stdio_write

fopen→sys_open→sys_openat→chcore_openat→__fdopen→(f→write)=__stdio_write

两者是殊途同归的

syscall过程

__stdio_write→SYS_write/SYS_writev

进行如下的宏展开

syscall 调用__syscall3

switch case 再调用 __syscall6

        case SYS_writev: {
return __syscall6(SYS_writev, a, b, c, 0, 0, 0);
}

在__syscall6里面调用chcore_write或者chcore_writev

chcore_write调用文件描述符表里面fd对应的fd_op的write函数,就是stdout_ops, 就是chcore_stdout_write, 就得到put, 最后转换为kernel里面syscall table的SYS_putstr