Skip to content

问题与解决

操作CSR寄存器时关闭时钟中断

由于要运行多个测例,每一个测例都是单独的可执行文件,而当测例量加大时,会经常性出现准备开始运行某个测例的时候卡死或者循环发生内核trap的现象,错误的内容是随机的,可能呈现为store fault、卡死甚至unknown trap等内容。

通过gdb调试,定位了第一次发生错误的地址均在下列函数中:

#[no_mangle]
// #[cfg(feature = "user")]
fn first_into_user(kernel_sp: usize, frame_base: usize) -> ! {
    let trap_frame_size = core::mem::size_of::<TrapFrame>();
    let kernel_base = kernel_sp - trap_frame_size;
    unsafe {
        asm::sfence_vma_all();
        core::arch::asm!(
            r"
            mv      sp, {frame_base}
            .short  0x2432                      // fld fs0,264(sp)
            .short  0x24d2                      // fld fs1,272(sp)
            LDR     gp, sp, 2                   // load user gp and tp
            LDR     t0, sp, 3
            mv      t1, {kernel_base}
            STR     tp, t1, 3                   // save supervisor tp,注意是存储到内核栈上而不是sp中,此时存储的应该是当前运行的CPU的ID
            mv      tp, t0                      // tp:本来存储的是CPU ID,在这个时候变成了对应线程的TLS 指针
            csrw    sscratch, {kernel_sp}       // put supervisor sp to scratch
            LDR     t0, sp, 31
            LDR     t1, sp, 32
            csrw    sepc, t0
            csrw    sstatus, t1
            POP_GENERAL_REGS
            LDR     sp, sp, 1
            sret
        ",
            frame_base = in(reg) frame_base,
            kernel_sp = in(reg) kernel_sp,
            kernel_base = in(reg) kernel_base,
        );
    };
    core::panic!("already in user mode!")
}

继续定位gdb汇编代码,将错误地址进一步确定为:

csrw    sstatus, t1

当执行这一条汇编代码,会出现不可预测的错误。

考虑到sstatus的功能,其可以控制时钟中断、特权级等一系列的内容,通过查阅资料了解到当使用sstatus屏蔽内核中时钟中断时,并非是阻止了中断发出,而是阻止对中断进行处理,一旦内核中时钟中断屏蔽关闭,原先积累的时钟中断会被用来处理。sstatus是控制中断的关键寄存器,查阅资料得知,riscv要求在修改sstatus信息的时候需要保证时钟中断使能关闭,否则会产生不可预料的行为。

因此在调用first_into_user函数前需要手动调用axhal::arch::enable_irqs()关闭中断使能。

读取长度不足

当读取strings.lua测例时,常会有报错:strings.lua:1: unexpected symbol。但其他lua测例运行结果正常且正确。

strings.lua内容如下:

local str = "Jelly Think"

result = 0

-- string.len可以获得字符串的长度

if string.len(str) ~= 11 then
    result = -1
end

-- string.rep返回字符串重复n次的结果

str = "ab"

if string.rep(str, 2) ~= "abab" then
    result = -1
end

-- string.lower将字符串小写变成大写形式,并返回一个改变以后的副本

str = "Jelly Think"

if string.lower(str) ~= "jelly think" then
    result = -1
end

-- string.upper将字符串大写变成小写形式,并返回一个改变以后的副本

if string.upper(str) == "JELLY THINK" then
    result = -1
end

return result

考虑是否是文件编码问题:将strings.lua测例内容复制到其他lua文件,运行对应文件,报错不变。将其他lua文件内容复制到strings.lua,运行strings.lua,结果正常,因此排除了编码问题。

再考虑内核是否成功读入文件,输出read系统调用内容,发现read系统调用确实正确读入并返回了文件长度591。

考虑到strings.lua测试内容由多个断言形成,考虑逐个断言逐个断言验证,但发现一旦删除了strings.lua的部分内容之后,运行结果就正常了。即当缩短了strings.lua的长度之后,结果正常。

考虑输出read系统调用中读入的buf的内容,发现读入的892位buf中,前512位buf被修改为了0,后面的buf仍然正常。而当限制buf长度为512时,此时前512位buf读取结果正常。

arceos原先在fat32节点的读取函数定义如下:

fn read_at(&self, offset: u64, buf: &mut [u8]) -> VfsResult<usize> {
    let mut file = self.0.lock();
    file.seek(SeekFrom::Start(offset)).map_err(as_vfs_err)?; // TODO: more efficient
    file.read(buf).map_err(as_vfs_err)
}

依据上述debug结果,发现starry的文件系统驱动中fat32的块大小定义为512。查询read语义知,每一次实际读取长度不一定等于传入的buf长度,因此不可以直接通过传入buf来实现文件的读入,而需要进行循环判断:

n read_at(&self, offset: u64, buf: &mut [u8]) -> VfsResult<usize> {
    let mut file = self.0.lock();
    file.seek(SeekFrom::Start(offset)).map_err(as_vfs_err)?; // TODO: more efficient
    // file.read(buf).map_err(as_vfs_err)
    let buf_len = buf.len();
    let mut now_offset = 0;
    let mut probe = buf.to_vec();
    while now_offset < buf_len {
        let ans = file.read(&mut probe).map_err(as_vfs_err);
        if ans.is_err() {
            return ans;
        }
        let read_len = ans.unwrap();

        if read_len == 0 {
            break;
        }
        buf[now_offset..now_offset + read_len].copy_from_slice(&probe[..read_len]);
        now_offset += read_len;
        probe = probe[read_len..].to_vec();
    }
    Ok(now_offset)
}

依据上述写法,即可实现大文件的读入。

文件的链接与初始化

fat32文件系统自身不支持符号链接,因此需要在内核中手动维护一个链接映射。但是不同的文件名称字符串可能指向同一个文件, 因此不能单纯地将传入的文件名作为映射的键值。

比如我们建立了一个从a.outb.out的链接,此时传入的文件名叫做./a.out,此时它应该被连接到b.out,但在链接中找不到对应程序。

为了规范化起见,starry引用了arceos提供的canonicalize函数,将文件名转化为统一格式的绝对路径,并以此建立文件名到链接实际文件的映射。

因此从a.outb.out的链接,会被转化为./a.out./b.out的链接,通过规范的字符串使得误判的情况可以被减少。

实现busybox、lua、lmbench过程中,需要用到一系列的链接,对应实现在starry_libax/test.rsfs_init函数中。如程序会寻找/lib/tls_get_new-dtv_dso.so,而它会被定向到./tls_get_new-dtv_dso.so文件,这个过程需要我们手动建立链接。

另外,busybox等测例也需要我们手动建立一系列的文件系统与文件夹,如dev_fs与ram_fs,其中dev_fs并不允许动态增删文件内容,需要初始化时就确定好。相关的实现在axfs/src/root.rsinit_rootfs函数,需要添加dev/shm、dev/misc等文件夹。

多核情况下libc测例结果不可测

musl-libc测例中有一个测例为pthread_cancel,主要是测试线程是否可以正常被取消。但这个测例经常会运行失败,即能够成功退出,但是报错并未取消新建的线程。

查阅对应的代码如下:

static void *start_single(void *arg)
{
    pthread_cleanup_push(cleanup1, arg);
    sleep(3);
    pthread_cleanup_pop(0);
    return 0;
}
int main() {
/* Cancellation cleanup handlers */
    foo[0] = 0;
    TESTR(r, pthread_create(&td, 0, start_single, foo), "failed to create thread");
    TESTR(r, pthread_cancel(td), "cancelling");
    TESTR(r, pthread_join(td, &res), "joining canceled thread");
    TESTC(res == PTHREAD_CANCELED, "canceled thread exit status");
    TESTC(foo[0] == 1, "cleanup handler failed to run");
}

其测试的原理是在pthread_cleanup_push加入线程取消时调用的处理函数。若线程成功被取消,那么cleanup1函数会被调用,此时会修改f00[0]为1,代表成功取消。

但测试时经常报错:cleanup handler failed to run,通过输出f00[0]发现其值仍然为0,即线程取消函数没有被调用。但是输出内核接受到的系统调用以及信号处理流程会发现确实发出并且处理了取消线程的信号。那么问题出在了哪里呢?

输出任务调度队列信息发现,主任务在调用了pthread_create之后,并没有被阻塞,反而继续调用了pthread_cancel函数,发出了取消线程信号,之后再被pthread_join函数阻塞。此时才调度到子线程,并且立即进入了信号处理函数的流程,即立即被取消。此时子线程甚至没有进入到start_single函数就被取消了。此时没有注册取消线程函数为cleanup1,因此自然不会修改f00[0]。

但是问题在于pthread_create的语义要求本任务会被yield,即子线程应当在创建之后被立即执行。继续debug,输出任务调度列表和对应CPU编号发现,虽然在当前CPU上主任务被yield了,但当前启动了多核,闲置的CPU立即将刚被yield的主任务接管并且继续运行。则此时主任务有概率在子线程开始之前调用cancel函数发出取消信号,从而导致线程取消函数注册失败。

将内核改为单核启动之后,问题消失。总结而言,在无法改动测例代码的情况下,跑musl-libc测例应当以单核的形式进行,多核可能会遇到各种奇怪的问题。。

lmbench测例的结束

运行lmbench测例时发现时,程序总是会访问0x2,0x4,0x6,0x8等地址,导致page fault。gdb进行debug无果,发现程序已经输出了预期输出,之后会直接访问该非法地址。

询问往年参加比赛的学长,了解到去年的lmbench会在每个测例结束的时候直接让pc跳到堆栈上,从而触发I fault,通过内核捕获该信号并进行特判,从而手动调用exit结束当前的lmbench测例,进入到下一个lmbench测例。

而在今年编译得到的lmbench版本,pc不再跳转到堆栈,而是跳转到低地址如0x6,此时也是要求内核直接做出特判,结束当前任务。

查阅riscv规范得知,非法访问内存,内核处理失败之后应当发送SIGSEGV信号到对应线程,从而结束当前任务。因此修改代码如下:

#[cfg(feature = "paging")]
fn handle_page_fault(addr: VirtAddr, flags: MappingFlags, tf: &mut TrapFrame) {
    use axprocess::handle_page_fault;
    use axsignal::signal_no::SignalNo;
    use axtask::current;
    axprocess::time_stat_from_user_to_kernel();
    use crate::syscall::signal::{syscall_sigreturn, syscall_tkill};
    if addr.as_usize() == SIGNAL_RETURN_TRAP {
        // 说明是信号执行完毕,此时应当执行sig return
        tf.regs.a0 = syscall_sigreturn() as usize;
        return;
    }

    if handle_page_fault(addr, flags).is_err() {
        // 如果处理失败,则发出sigsegv信号
        let curr = current().id().as_u64() as isize;
        axlog::error!("kill task: {}", curr);
        syscall_tkill(curr, SignalNo::SIGSEGV as isize);
    }
    axprocess::time_stat_from_kernel_to_user();
}

这种情况不仅可以处理pc跳转到低地址的情况,也可以处理跳转到堆栈的情况,更加地规范化。