news 2026/4/17 23:08:02

操作系统6(系统调用)(TODO)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
操作系统6(系统调用)(TODO)

1 简介

这一篇主要是涉及到了不同的权限级切换,以及系统调用的原理。

内容还是来自:https://github.com/s-matyukevich/raspberry-pi-os/tree/master/docs/lesson05

之前写裸机的时候,其实接触到了一些层级切换,主要是异常处理,使用的eret可以从高等级切换到低等级处理异常。具体可以看原文:https://blog.csdn.net/fanged/article/details/158703967

但是高等级要到低等级,只能通过系统调用或者中断,上次的例子是中断,这次的代码是用系统中断来实现。

不过这次显然不满足之前的小打小闹,这次直接就是大作文章了。软件看着十分复杂,其实多进程,多层级现在看来硬件上改动不是特别多,主要还是依赖软件也就是操作系统的实现。

查了一下,硬件上的改动如下,也就是三个寄存器的事。

  • 状态寄存器(Status Registers):比如 x86 的CS寄存器里的 CPL 位(0-3级)或 ARM 的CurrentEL。硬件只需维护一两个位,用来标识当前 CPU 处于什么特权等级。

  • 异常陷阱门(Interrupt/Exception Vectors):硬件规定:当低特权代码尝试执行敏感指令(如修改内存页表)时,CPU 强制跳转到一个预设的内核地址。

  • 内存管理单元(MMU):这是最重要的硬件。它通过页表(Page Table)来实现进程间的隔离。

剩下就全是软件在出风头。大概看了一下,确实也比较复杂,只能慢慢啃了。

2 运行

首先还是编译,不过代码报错了,提示undefined reference to `memset'。看了一下代码,问题出在下面这行。

void user_process(){

char buf[30] = {0};

...

}

在代码中,char buf[30] = {0}; 看起来只是简单的变量初始化。但对于编译器(GCC)来说,为了提高效率,它会自动将这种“大段内存清零”的操作优化为调用 memset 函数。由于禁用了标准库,链接器在代码和 .o 文件中找不到 memset 的实现,于是报错 undefined reference。

tom@PC-20241221RKUQ:~/os/raspberry-pi-os/src/lesson05$ make
mkdir -p build
aarch64-linux-gnu-gcc -Wall -nostdlib -nostartfiles -ffreestanding -Iinclude -mgeneral-regs-only -fno-builtin -MMD -c src/kernel.c -o build/kernel_c.o
aarch64-linux-gnu-ld -T src/linker.ld -o build/kernel8.elf build/fork_c.o build/irq_c.o build/kernel_c.o build/mini_uart_c.o build/mm_c.o build/printf_c.o build/sched_c.o build/sys_c.o build/timer_c.o build/boot_s.o build/entry_s.o build/irq_s.o build/mm_s.o build/sched_s.o build/sys_s.o build/utils_s.o
aarch64-linux-gnu-ld: warning: build/kernel8.elf has a LOAD segment with RWX permissions
aarch64-linux-gnu-ld: build/kernel_c.o: in function `user_process':
kernel.c:(.text+0x74): undefined reference to `memset'
make: *** [Makefile:30: kernel8.img] Error 1

修改代码,增加memset

void *memset(void *s, int c, int n) { unsigned char *p = s; while (n--) { *p++ = (unsigned char)c; } return s; }

之后编译可以通过了,弄在板子上试一下,运行结果如下。

看了一下输出,和之前的区别应该是新创建的进程,全部放在用户空间运行了。

3 代码分析

3.1 主流程

首先还是main,要注意的是这里的main是运行在内核态的。

void kernel_main(void) { uart_init(); init_printf(0, putc); irq_vector_init(); timer_init(); enable_interrupt_controller(); enable_irq(); int res = copy_process(PF_KTHREAD, (unsigned long)&kernel_process, 0, 0); if (res < 0) { printf("error while starting kernel process"); return; } while (1){ schedule(); } }

这里的代码流程,初始化看着和之前倒是都差不多。最大的区别就是copy_process创建进程。在函数的定义上比之前增加了两个参数。一个参数PF_KTHREAD,并且第二个是kernel_process。表明了这一次的教程,进程是可以运行在内核态或者用户态的。在上一篇里这个没做区别,就是process。

下面具体看一下copy_process的实现。

int copy_process(unsigned long clone_flags, unsigned long fn, unsigned long arg, unsigned long stack) { preempt_disable(); struct task_struct *p; p = (struct task_struct *) get_free_page(); if (!p) { return -1; } struct pt_regs *childregs = task_pt_regs(p); memzero((unsigned long)childregs, sizeof(struct pt_regs)); memzero((unsigned long)&p->cpu_context, sizeof(struct cpu_context)); if (clone_flags & PF_KTHREAD) { p->cpu_context.x19 = fn; p->cpu_context.x20 = arg; } else { struct pt_regs * cur_regs = task_pt_regs(current); *childregs = *cur_regs; childregs->regs[0] = 0; childregs->sp = stack + PAGE_SIZE; p->stack = stack; } p->flags = clone_flags; p->priority = current->priority; p->state = TASK_RUNNING; p->counter = p->priority; p->preempt_count = 1; //disable preemtion until schedule_tail p->cpu_context.pc = (unsigned long)ret_from_fork; p->cpu_context.sp = (unsigned long)childregs; int pid = nr_tasks++; task[pid] = p; preempt_enable(); return pid; }

从代码可以看出,这个和之前的区别主要是以下:

左边的新的支持不同层级的进程。首先是增加了两个参数,一个是clone_flags,表示运行在什么层级。一个是stack,运行用户代码的用户栈。这里用的时候传入的是0。

后面增加了PF_KTHREAD的判断,如果是内核态,则和之前的流程相同。如果不是内核态,则新增加的流程如下:

struct pt_regs * cur_regs = task_pt_regs(current);

*childregs = *cur_regs;

childregs->regs[0] = 0;

childregs->sp = stack + PAGE_SIZE;

p->stack = stack;

整个代码最核心的就是增加了childregs。首先childregs->sp = stack + PAGE_SIZE;这里就指向了运行用户代码的栈地址。之后实现了状态克隆。通过*childregs = *cur_regs;,子进程完整继承了父进程在触发fork瞬间的所有寄存器状态(通用寄存器、程序状态字等)。childregs->regs[0] = 0;这里手动修改了子进程的x0寄存器(ARM64的返回值寄存器),使得子进程从fork返回时拿到的是0。

从cpu_context.sp来看,之前的代码是指向内核页顶,切换后直接运行fn,之后跳到pc指向的内核函数。在新的函数中指向childregs 结构体(压满现场的栈),切换后跳转到ret_from_fork还原pt_regs里的现场。pc指向ret_from_fork汇编入口。

在内核态的情况下,和之前一样,就是一个单独的task,属性都是重新赋值。

在应用层中,子进程拥有和父进程完全一致的执行现场。下一条指令指针IP/PC,当前的函数调用栈指针 SP,之前的计算中间值是什么?(通用寄存器 AX, BX 等)这些全部和父进程保持一致。

所以在这里,调度开始后会返回fork的返回值,也就是pid,之后调度时也会到子进程中执行,在这里有新的PC。SP,返回0。最后都会运行ret_from_fork。

task的结构和之前也有一些不同,增加了两个核心变量。stack保存的用户态的栈地址,flags表示任务的属性。

struct task_struct {
struct cpu_context cpu_context;
long state;
long counter;
long priority;
long preempt_count;
unsigned long stack;
unsigned long flags;

};

TODO,这里的空间地址p,stack等还有一些疑问,后面再看看。。

最后,这里是一个标准的fork的例子。

int main() { printf("开始执行,进程 PID = %d\n", getpid()); pid_t pid = fork();// 创建子进程 if (pid < 0) { return 1;// 创建失败 } else if (pid == 0) { // 子进程执行这里 printf("我是子进程,PID = %d,父进程 PID = %d\n", getpid(), getppid()); } else { // 父进程执行这里 printf("我是父进程,PID = %d,创建的子进程 PID = %d\n", getpid(), pid); } // 父子都会执行到这里 printf("PID %d 执行结束\n", getpid()); return 0; }

运行结果

开始执行,进程 PID = 1870
我是父进程,PID = 1870,创建的子进程 PID = 1871
PID 1870 执行结束
我是子进程,PID = 1871,父进程 PID = 1870
PID 1871 执行结束

这里也说个题外话,好像从我开始学编程,就很少用fork了。主要原因还是看着反人类,代码没有分离。

最后看看这个就知道用的最多的pid是什么了。

int pid = nr_tasks++;

task[pid] = p;

pid其实就是在task任务中的序号,对应的就是task的结构的地址。。。

3.2 运行任务

运行任务,就是copy_process(PF_KTHREAD, (unsigned long)&kernel_process, 0, 0);中的那个kernel_process,先看看具体的代码。

void kernel_process(){ printf("Kernel process started. EL %d\r\n", get_el()); int err = move_to_user_mode((unsigned long)&user_process); if (err < 0){ printf("Error while moving process to user mode\n\r"); } }

首先是打印当前的层级,然后跳转到用户态运行user_process。下面看看这两个函数。

int move_to_user_mode(unsigned long pc) { struct pt_regs *regs = task_pt_regs(current); memzero((unsigned long)regs, sizeof(*regs)); regs->pc = pc; regs->pstate = PSR_MODE_EL0t; unsigned long stack = get_free_page(); //allocate new user stack if (!stack) { return -1; } regs->sp = stack + PAGE_SIZE; current->stack = stack; return 0; }

首先获取当前的寄存器,然后清零。之后设置了目标的等级EL0,设置了在用户状态的第一条指令,也就是user_process。最后给进程的栈重新分配页内存,然后设置了栈地址。

ARM64架构下双栈机制:内核态和用户态的sp绝对不一样,且必须分开。

之后就等着调度,至于调度相关的代码,和上一篇基本上一致。就不多写了。

最后设置了p->cpu_context.pc = (unsigned long)ret_from_fork;,也就是说在被调度之后,首先会执行这个。这部分倒是值得细看。

.globl ret_from_fork ret_from_fork: bl schedule_tail cbz x19, ret_to_user // not a kernel thread mov x0, x20 blr x19 ret_to_user: bl disable_irq kernel_exit 0

这里有多个调用,首先是schedule_tail,这个是打开了原子操作,保证运行的时候不被调度。之后就是判断x19寄存器。

cbz x19, ret_to_user

如果存在函数地址(这个是在创建进程时p->cpu_context.x19 = fn;设置的)则是内核运行,之后将x20作为参数推到x0(p->cpu_context.x20 = arg;),然后直接跳转到该地址。

如果是用户态函数,则运行disable_irq和kernel_exit。运行disable_irq是关闭irq,保证现场不被破坏。之后是kernel_exit,0作为参数传入。

.macro kernel_exit, el ldp x22, x23, [sp, #16 * 16] ldp x30, x21, [sp, #16 * 15] .if \el == 0 msr sp_el0, x21 .endif /* \el == 0 */ msr elr_el1, x22 msr spsr_el1, x23 ldp x0, x1, [sp, #16 * 0] ldp x2, x3, [sp, #16 * 1] ldp x4, x5, [sp, #16 * 2] ldp x6, x7, [sp, #16 * 3] ldp x8, x9, [sp, #16 * 4] ldp x10, x11, [sp, #16 * 5] ldp x12, x13, [sp, #16 * 6] ldp x14, x15, [sp, #16 * 7] ldp x16, x17, [sp, #16 * 8] ldp x18, x19, [sp, #16 * 9] ldp x20, x21, [sp, #16 * 10] ldp x22, x23, [sp, #16 * 11] ldp x24, x25, [sp, #16 * 12] ldp x26, x27, [sp, #16 * 13] ldp x28, x29, [sp, #16 * 14] add sp, sp, #S_FRAME_SIZE eret .endm

查资料,在从高等级向低等级跳转时,需要做的寄存器操作如下:

维度从内核返回用户态 (eret)
跳转目标存哪?ELR_EL1(系统寄存器)
处理器状态存哪?SPSR_EL1(系统寄存器)
栈指针操作必须手动恢复SP_EL0
权限变化降级(EL1 -> EL0)

所以上面的操作也是围绕着这些寄存器操作来的,之后是恢复通用寄存器。最后用eret跳转到低层级。

具体启动的用户进程代码倒是直观,如下:

void user_process1(char *array) { char buf[2] = {0}; while (1){ for (int i = 0; i < 5; i++){ buf[0] = array[i]; call_sys_write(buf); delay(100000); } } } void user_process(){ char buf[30] = {0}; tfp_sprintf(buf, "User process started\n\r"); call_sys_write(buf); unsigned long stack = call_sys_malloc(); if (stack < 0) { printf("Error while allocating stack for process 1\n\r"); return; } int err = call_sys_clone((unsigned long)&user_process1, (unsigned long)"12345", stack); if (err < 0){ printf("Error while clonning process 1\n\r"); return; } stack = call_sys_malloc(); if (stack < 0) { printf("Error while allocating stack for process 1\n\r"); return; } err = call_sys_clone((unsigned long)&user_process1, (unsigned long)"abcd", stack); if (err < 0){ printf("Error while clonning process 2\n\r"); return; } call_sys_exit(); }

可以看到,代码的主要内容就是一堆系统调用call_sys_xxx。

3.3 系统调用

这次的例程中提供了以下的系统调用:

void sys_write(char * buf); int sys_fork(); void call_sys_write(char * buf); int call_sys_clone(unsigned long fn, unsigned long arg, unsigned long stack); unsigned long call_sys_malloc(); void call_sys_exit();

主要就是call_sys_write,call_sys_malloc,和call_sys_exit,call_sys_clone。

有趣的是前面三个的代码是一样的。

mov w8, #SYS_EXIT_NUMBER svc #0 ret

这里的代码都是运行在应用态,核心就是svc汇编指令,用户态EL0请求内核态EL1的标准手段。这里的w8就是参数。

调用后,会进到el0_sync中断响应。

el0_sync: kernel_entry 0 mrs x25, esr_el1 // read the syndrome register lsr x24, x25, #ESR_ELx_EC_SHIFT // exception class cmp x24, #ESR_ELx_EC_SVC64 // SVC in 64-bit state b.eq el0_svc handle_invalid_entry 0, SYNC_ERROR sc_nr .req x25 // number of system calls scno .req x26 // syscall number stbl .req x27 // syscall table pointer el0_svc: adr stbl, sys_call_table // load syscall table pointer uxtw scno, w8 // syscall number in w8 mov sc_nr, #__NR_syscalls bl enable_irq cmp scno, sc_nr // check upper syscall limit b.hs ni_sys

首先是kernel_entry

.macro kernel_entry, el sub sp, sp, #S_FRAME_SIZE stp x0, x1, [sp, #16 * 0] stp x2, x3, [sp, #16 * 1] stp x4, x5, [sp, #16 * 2] stp x6, x7, [sp, #16 * 3] stp x8, x9, [sp, #16 * 4] stp x10, x11, [sp, #16 * 5] stp x12, x13, [sp, #16 * 6] stp x14, x15, [sp, #16 * 7] stp x16, x17, [sp, #16 * 8] stp x18, x19, [sp, #16 * 9] stp x20, x21, [sp, #16 * 10] stp x22, x23, [sp, #16 * 11] stp x24, x25, [sp, #16 * 12] stp x26, x27, [sp, #16 * 13] stp x28, x29, [sp, #16 * 14] .if \el == 0 mrs x21, sp_el0 .else add x21, sp, #S_FRAME_SIZE .endif /* \el == 0 */ mrs x22, elr_el1 mrs x23, spsr_el1 stp x30, x21, [sp, #16 * 15] stp x22, x23, [sp, #16 * 16] .endm

这里做的事情就是恢复上下文,将用户态的各种寄存器,恢复到内核态的各个寄存器中。同时对应用层发起或者内核层发起做了区别。

之后到el0_svc,查表sys_call_table运行对应指令。

void sys_write(char * buf){ printf(buf); } int sys_clone(unsigned long stack){ return copy_process(0, 0, 0, stack); } unsigned long sys_malloc(){ unsigned long addr = get_free_page(); if (!addr) { return -1; } return addr; } void sys_exit(){ exit_process(); } void * const sys_call_table[] = {sys_write, sys_malloc, sys_clone, sys_exit};

总的来说,系统调用就是高层级对底层进行调用。操作就是两个,一个是保存和恢复上下文。另外一个是传递需要运行指令的编号,之后查表运行,这里也只能运行在内核态中已有的指令。

最后还有一个call_sys_clone,功能是在用户态创建一个用户态新进程,这个就和我们日常用到的很类似了。代码如下:

.globl call_sys_clone call_sys_clone: /* Save args for the child. */ mov x10, x0 /*fn*/ mov x11, x1 /*arg*/ mov x12, x2 /*stack*/ /* Do the system call. */ mov x0, x2 /* stack */ mov x8, #SYS_CLONE_NUMBER svc 0x0 cmp x0, #0 beq thread_start ret thread_start: mov x29, 0 /* Pick the function arg and execute. */ mov x0, x11 blr x10 /* We are done, pass the return value through x0. */ mov x8, #SYS_EXIT_NUMBER svc 0x0

首先,这部分代码是全部在用户态运行的。可以看到,调用了两次svc,第一次是CLONE,在内核中调用了copy_process(当然还有entry,exit这些)。第二次是EXIT,实际上调用了内核中的exit_process,在tasks中会删除这个task,清零之前的page内存。

3.4 最后

最后再看看运行的结果。首先是内核态启动kernel_process,之后转到用户态启动user_process。在用户态的process中,首先是系统调用malloc分配栈空间,之后克隆user_process1,这里调用了系统的write,输出都是12345 ,所以可以看到输出都是1234512345连着的。之后再次克隆user_process1,调用系统的write,输出是abcd。所以后面可以看到连着的abcdabcd。最后显式的调用call_sys_exit推出。

两个小疑问,我们日常编程没有去分配栈空间还有退出进程呢?

疑问1:

在Linux编程中,当运行一个程序,内核的elf_loader会在进程的虚拟地址空间顶部划分一块区域(通常是 8MB)。

疑问2:

在 Linux 链接程序时,它会偷偷把你的代码和一个叫crt1.o(C Runtime Startup) 的文件链接在一起。这个文件里包含一个名为_start的函数,这才是真正的入口。

// 编译器自动生成的启动逻辑
void _start() {
// 1. 做一些准备工作(如初始化全局变量)
// 2. 调用你的 main 函数
int result = main(argc, argv);

// 3. 当你的 main 执行完 return 后,
// 它会自动调用这个系统调用!
exit(result);
}

好了,所有的东西都圆回来了。。。

4 理论

回到linux。在Linux的系统调用(System Call)标准定义就是:

用户程序向内核请求“帮忙干活”的唯一正规入口

像open,close这些很常用的API也都是系统调用。目前,linux主要的系统调用有这些:

分类系统调用功能描述典型应用场景
进程管理fork/clone创建子进程或线程启动异步数据处理进程
execve运行新程序在网关中启动一个新的插件程序
exit终止当前进程任务处理完毕后的资源释放
wait4等待子进程结束父进程回收子进程资源,防止僵尸进程
getpid获取当前进程 ID日志记录时标识不同的采集任务
文件/设备 IOopenat打开文件或设备打开串口(UART)以读取雷达数据
read从 FD 读取数据从传感器缓冲区获取二进制流
write向 FD 写入数据将处理后的流速数据保存到 SD 卡
ioctl设备特有的控制操作最常用:设置串口波特率、校验位
close关闭文件描述符释放占用的硬件接口
内存管理brk/sbrk改变数据段堆大小malloc分配小块内存的底层实现
mmap内存映射高性能方案:将硬件寄存器直接映射到用户空间
munmap释放内存映射结束硬件操作后的清理
网络通信socket创建通讯端点准备将数据发往云端服务器
bind/listen绑定并监听端口网关作为服务器接收本地指令
connect连接远端地址网关主动向云平台发起 TCP 连接
sendto/recvfrom发送/接收数据实时上传雷达流速包(UDP/TCP)
时间与同步clock_gettime获取高精度时间给雷达采集的数据打上精确时间戳
nanosleep高精度休眠控制采样频率(如每 100ms 采样一次)
futex用户态快速互斥锁多线程同步,防止数据读写冲突

在ARM中,升级和降级的两种标准操作:

维度ERET (降级)系统调用/中断 (升级)
主导权软件主导(内核决定回哪去)硬件强制(跳转地址由硬件寄存器预设)
跳转位置任意地址(通常是返回之前的现场)固定地址(异常向量表,不可修改)
目的恢复任务执行处理服务请求或外部事件
安全性高(内核已经处理完逻辑)极高(由硬件把关,防止用户态乱跑)
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/17 23:05:56

STM32实战:用SIM900A模块发送中英文短信的完整流程(附避坑指南)

STM32实战&#xff1a;用SIM900A模块发送中英文短信的完整流程&#xff08;附避坑指南&#xff09; 在物联网和嵌入式开发领域&#xff0c;短信通信作为一种可靠的低成本通信方式&#xff0c;仍然在许多场景中发挥着重要作用。SIM900A作为一款经典的GSM/GPRS模块&#xff0c;以…

作者头像 李华
网站建设 2026/4/17 23:05:16

如何在Windows server 2016系统中搭建IIS(WEB)服务并支持ASP网站

使用Windows server 2016操作系统中搭建WEB网站非常的方便。并且整个过程都是图形界面安装设置非常容易的上手。下面介绍如何在Windows server 2016中安装IIS服务&#xff0c;并搭建属于自己的网站应用。点击Windows server 2016系统的开始菜单&#xff0c;选择打开【服务器管理…

作者头像 李华
网站建设 2026/4/17 23:03:39

手把手教你用Python实现AUBO机械臂与相机的自动标定(附开源代码)

手把手教你用Python实现AUBO机械臂与相机的自动标定&#xff08;附开源代码&#xff09; 在工业自动化领域&#xff0c;机械臂与视觉系统的协同作业已成为智能制造的核心技术之一。而实现这一协同的关键&#xff0c;在于精确建立机械臂坐标系与相机坐标系之间的转换关系——这就…

作者头像 李华
网站建设 2026/4/17 23:01:32

告别裸机调试:在ZYNQ上为自定义AXI-Stream IP核编写PS端驱动的心路历程

从零构建ZYNQ AXI-Stream驱动&#xff1a;一位工程师的实战手记 第一次在ZYNQ平台上集成自定义AXI-Stream IP核的经历&#xff0c;就像在黑暗森林中摸索前行。当Block Design中的连线全部变成绿色时&#xff0c;我以为最困难的部分已经结束&#xff0c;直到打开SDK面对那些晦涩…

作者头像 李华