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 | 日志记录时标识不同的采集任务 | |
| 文件/设备 IO | openat | 打开文件或设备 | 打开串口(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 (降级) | 系统调用/中断 (升级) |
| 主导权 | 软件主导(内核决定回哪去) | 硬件强制(跳转地址由硬件寄存器预设) |
| 跳转位置 | 任意地址(通常是返回之前的现场) | 固定地址(异常向量表,不可修改) |
| 目的 | 恢复任务执行 | 处理服务请求或外部事件 |
| 安全性 | 高(内核已经处理完逻辑) | 极高(由硬件把关,防止用户态乱跑) |