Tuna Kılıç
All posts
  • OSDev
  • Narc

Narc Devlog: Ring 3 (Hello from Userspace)

Narc'ta ilk defa ring 3'e geçip küçük bir libc ile userspace'ten ekrana yazı yazdırıyoruz.

Published
İçindekiler

Selam ben Tuna, GDT ve TSS yazılarından sonra asıl şeye geldik. bu yazının sonunda C ile yazılmış bi program ring 3'te çalışıp ekrana "Hello from Userspace!" yazıcak

GDT ve TSS yazılarını okumadıysan önce onları oku

Ders 1: Ring 3 nedir

Şu ana kadar Narc'taki her şey ring 0'da yani kernel modunda çalıştı. ring 0'daki kod her şeyi yapabiliyo her belleği okuyabiliyo interrupt'ları kapatabiliyo vs

ring 3 user modu. ring 3'teki kod:

  • PTE_USER biti olmayan sayfalara dokunamıyo
  • cli hlt gibi komutları çalıştıramıyo
  • kernel'den bişey isterse syscall yapmak zorunda

yani bi program saçmalarsa kernel'i bozamıyo. işletim sistemi dediğimiz şeyin olayı bu

Yapacaklarımız:

  1. kernel'de syscall (int 0x80)
  2. küçük bi libc (sadece print yetecek kadar)
  3. C ile hello world
  4. programı belleğe koyup ring 3'e zıplamak

Ders 2: Syscall

user programı ekrana direkt yazamaz (framebuffer kernel'de map'li user'a açık değil). o yüzden kernel'den "şunu ekrana yaz" diye istemesi lazım. buna syscall diyoruz

İki yolu var syscall komutu ya da int 0x80. int 0x80 normal bi interrupt yani IDT'ye bi gate eklemek yetiyo, o yüzden onu yaptım

1. IDT

idt.c'ye:

extern void syscall_entry(void);
idt_set_gate(0x80, (void *)syscall_entry);
idt[0x80].type_attr = 0xEE;

diğer gate'ler 0x8E bu 0xEE. aradaki fark DPL yani hangi ring'in bu interrupt'ı çağırabileceği. 0x8E DPL 0 0xEE DPL 3. DPL 0 bırakırsak user int 0x80 yapınca CPU izin vermiyo general protection fault atıyo

2. syscall_entry

Diğer handler'lar gibi __attribute__((interrupt)) kullanamadım çünkü syscall'ın numarası ve argümanları register'larda geliyo (rax rdi rsi) ve gcc'nin interrupt attribute'u bize register'ları vermiyo. o yüzden giriş kısmını assembly yazdım. bütün register'ları stack'e push edip C fonksiyonuna stack'in adresini veriyo:

__asm__(
    ".global syscall_entry\n"
    "syscall_entry:\n"
    "    push %rax\n"
    "    push %rbx\n"
    "    push %rcx\n"
    "    push %rdx\n"
    "    push %rsi\n"
    "    push %rdi\n"
    "    push %rbp\n"
    "    push %r8\n"
    "    push %r9\n"
    "    push %r10\n"
    "    push %r11\n"
    "    push %r12\n"
    "    push %r13\n"
    "    push %r14\n"
    "    push %r15\n"
    "    mov %rsp, %rdi\n"
    "    call syscall_handler\n"
    "    pop %r15\n"
    ...
    "    pop %rax\n"
    "    iretq\n"
);

(pop'lar push'ların tersi sırada uzun olmasın diye kestim)

C tarafında stack'teki register'ları struct olarak görüyoruz:

struct syscall_regs {
    uint64_t r15, r14, r13, r12, r11, r10, r9, r8;
    uint64_t rbp, rdi, rsi, rdx, rcx, rbx, rax;
};

en son push ettiğimiz r15 en düşük adreste o yüzden struct r15 ile başlıyo

3. syscall_handler

linux'taki numaraları kullandım write 1 exit 60:

#define SYS_WRITE 1
#define SYS_EXIT  60

__attribute__((used))
void syscall_handler(struct syscall_regs *regs) {
    if (regs->rax == SYS_WRITE) {
        const char *buf = (const char *)regs->rdi;
        uint64_t len = regs->rsi;

        if (regs->rdi >= 0x800000000000 || len > 4096) {
            regs->rax = -1;
            return;
        }

        for (uint64_t i = 0; i < len; i++)
            terminal_putchar(buf[i]);

        regs->rax = len;
    } else if (regs->rax == SYS_EXIT) {
        kprint("USER: program exited with code ");
        kprint_dec(regs->rdi);
        kprint("\n");
        kernel_idle();
    } else {
        regs->rax = -1;
    }
}
  • write: rdi'deki adresten rsi kadar karakteri ekrana basıyo. regs->rax'a yazdığımız şey asm'de pop edilip user'a dönüş değeri olarak gidiyo
  • user bize kernel'in adresini verip "bunu ekrana yaz" diyebilir o yüzden adres user yarısında mı (0x800000000000'dan küçük mü) diye bakıyorum. çok basit bi kontrol ama şimdilik idare eder
  • exit: kodu yazıp kernel_idle'a gidiyo

used koydum çünkü bu fonksiyonu sadece assembly çağırıyo gcc kullanılmıyo sanıp silmesin

4. kernel_idle

Eskiden kmain'in sonunda N tuşuna basınca yazı yazan bi döngü vardı. program exit olunca kernel'in bi yere gitmesi lazım o yüzden o döngüyü fonksiyona çevirdim:

void kernel_idle(void) {
    __asm__ volatile ("sti");

    uint8_t previous = 0;

    for (;;) {
        uint8_t current = keyboard_get_last_scancode();
        ...
        __asm__ volatile ("hlt");
    }
}

başta sti var çünkü syscall'dan buraya geldiğimizde interrupt'lar kapalı oluyo (interrupt gate CPU'ya girerken interrupt'ları kapatıyo)

Ders 3: Küçük libc

Şimdi user tarafı. programı C ile yazmak istiyorum ama C'nin standart kütüphanesi yok elimizde. printf falan hiçbişey. o yüzden kendi minik libc'mizi yazıyoruz sadece print yetecek kadar

user kodu kernel'den ayrı derlenmeli o yüzden projenin köküne user/ klasörü açtım

1. libc.h

#ifndef LIBC_H
#define LIBC_H

#include <stddef.h>

size_t strlen(const char *s);
long write(const char *buf, size_t len);
void print(const char *s);
void exit(int code) __attribute__((noreturn));

#endif

2. libc.c

#define SYS_WRITE 1
#define SYS_EXIT  60

int main(void);

static long syscall2(long num, long arg1, long arg2) {
    long ret;
    __asm__ volatile ("int $0x80" : "=a"(ret) : "a"(num), "D"(arg1), "S"(arg2) : "memory");
    return ret;
}

asm'deki harfler register demek: a rax D rdi S rsi. yani syscall numarasını rax'a argümanları rdi ve rsi'ye koyup int $0x80 yapıyo, dönüşte rax'taki değeri alıyo. kernel'deki handler'ın beklediği şey tam bu

size_t strlen(const char *s) {
    size_t len = 0;
    while (s[len] != '\0')
        len++;
    return len;
}

long write(const char *buf, size_t len) {
    return syscall2(SYS_WRITE, (long)buf, (long)len);
}

void print(const char *s) {
    write(s, strlen(s));
}

void exit(int code) {
    syscall2(SYS_EXIT, code, 0);
    for (;;) {
    }
}

print = strlen + write o kadar

3. _start

C programları aslında main'den başlamıyo. önce _start çalışıyo o main'i çağırıyo, main dönünce de exit yapıyo:

__attribute__((section(".text._start")))
void _start(void) {
    exit(main());
}

section(".text._start") kısmı _start'ın binary'nin en başında olması için. kernel programı 0x400000'a koyup direkt oraya zıplıyacak yani ilk byte'ta _start olmalı

4. hello.c

ve programın kendisi:

#include "libc.h"

int main(void) {
    print("Hello from Userspace!\n");
    return 0;
}

5. user/linker.ld

ENTRY(_start)

SECTIONS
{
    . = 0x400000;

    .text : { *(.text._start) *(.text .text.*) }
    .rodata : { *(.rodata .rodata.*) }
    .data : { *(.data .data.*) *(.bss .bss.*) *(COMMON) }

    /DISCARD/ : { *(.comment) *(.note .note.*) *(.eh_frame*) }
}
  • program 0x400000'da çalışacak
  • .text._start en başa
  • .bss'i .data'nın içine koydum. çünkü birazdan binary'yi düz dosyaya çeviricez ve objcopy .bss'i dosyaya koymuyo (bss normalde sadece "şu kadar sıfır lazım" bilgisi). data'nın içine koyunca sıfırları dosyaya yazıyo, şimdilik kolay yol

6. Makefile

user programı kernel'den farklı flag'lerle derlenmeli (mesela -mcmodel=kernel olmamalı):

USER_CFLAGS := -m64 -std=gnu11 -O2 -Wall -Wextra -ffreestanding -fno-pie -fno-pic -fno-stack-protector -fno-builtin -fno-asynchronous-unwind-tables -mgeneral-regs-only -fno-tree-loop-distribute-patterns
USER_SRCS := user/libc.c user/hello.c
USER_OBJS := $(patsubst user/%.c,$(BUILD)/user/%.o,$(USER_SRCS))
USER_BIN := $(BUILD)/user/hello.bin
$(BUILD)/user/%.o: user/%.c user/libc.h
	@mkdir -p $(dir $@)
	$(CC) $(USER_CFLAGS) -c $< -o $@

$(BUILD)/user/hello.elf: $(USER_OBJS) user/linker.ld
	$(LD) -m elf_x86_64 -nostdlib -static -T user/linker.ld -o $@ $(USER_OBJS)

$(USER_BIN): $(BUILD)/user/hello.elf
	objcopy -O binary $< $@

$(BUILD)/kernel/user/usermode.o: $(USER_BIN)

-mgeneral-regs-only önemli. Limine SSE'yi açmıyo (CR4'te OSFXSR kapalı), gcc user kodunda SSE komutu üretirse program "geçersiz komut" ile patlar. kernel'de de aynı flag zaten vardı

objcopy -O binary ELF'in bütün header'larını atıp sadece içindeki kod ve veriyi düz dosya yapıyo. ELF yükleyicimiz yok o yüzden şimdilik düz binary

en sondaki satır: usermode.o binary'yi içine gömüyo, binary değişirse usermode.o tekrar derlensin diye

objdump ile baktım:

0000000000400000 <_start>:
  400000:	48 83 ec 08          	sub    $0x8,%rsp
  400004:	e8 b7 00 00 00       	call   4000c0 <main>

_start tam 0x400000'da. bütün program 236 byte :D

Ders 4: Programı yükleyip ring 3'e zıplamak

1. VMM'de küçük bi düzeltme

VMM yazısında demiştim CPU izinleri bütün seviyelerde kontrol ediyo. bi sayfayı user'a açmak için PML4 PDPT PD PT'nin hepsinde U biti lazım. ama get_next_table yeni tablo açarken U bitini koymuyodu. flags'ı parametre olarak ekledim:

static uint64_t *get_next_table(uint64_t *table, uint64_t index, uint64_t flags) {
    if (table[index] & PTE_PRESENT) {
        if (table[index] & PTE_HUGE)
            return NULL;
        if (flags & PTE_USER)
            table[index] |= PTE_USER;
        return phys_to_table(table[index] & PTE_ADDR_MASK);
    }

    ...

    table[index] = page | PTE_PRESENT | PTE_WRITE | (flags & PTE_USER);
    return phys_to_table(page);
}

bunu yapmasaydık PT'de U olurdu ama üst tablolarda olmazdı ve program ilk komutta page fault yerdi

2. Binary'yi kernel'e gömmek

wallpaper'ı gömdüğümüz gibi .incbin ile:

__asm__(
    ".section .rodata\n"
    "user_program_start:\n"
    ".incbin \"build/user/hello.bin\"\n"
    "user_program_end:\n"
    ".previous\n"
);

extern uint8_t user_program_start[];
extern uint8_t user_program_end[];

3. Sayfaları map etmek

#define USER_CODE_ADDR  0x400000
#define USER_STACK_TOP  0x800000
#define USER_STACK_PAGES 4
static void map_user_page(uint64_t virt, uint64_t flags) {
    uint64_t phys = pmm_alloc_page();
    if (phys == 0 || !vmm_map_page(virt, phys, flags | PTE_USER)) {
        kprint("USER: could not map page!\n");
        khalt();
    }
}
void usermode_start(void) {
    uint64_t size = user_program_end - user_program_start;
    uint64_t pages = (size + PAGE_SIZE - 1) / PAGE_SIZE;

    for (uint64_t i = 0; i < pages; i++)
        map_user_page(USER_CODE_ADDR + i * PAGE_SIZE, PTE_WRITE);

    for (uint64_t i = 1; i <= USER_STACK_PAGES; i++)
        map_user_page(USER_STACK_TOP - i * PAGE_SIZE, PTE_WRITE | PTE_NX);

    memset((void *)USER_CODE_ADDR, 0, pages * PAGE_SIZE);
    memcpy((void *)USER_CODE_ADDR, user_program_start, size);

    kprint("USER: program is ");
    kprint_dec(size);
    kprint(" bytes, jumping to userspace...\n");

    jump_to_usermode(USER_CODE_ADDR, USER_STACK_TOP - 8);
}
  • kod sayfaları yazılabilir ve çalıştırılabilir. binary'de kod ve veri aynı sayfada olduğu için şimdilik ikisi de lazım
  • stack 16 KiB 0x800000'ın hemen altında, yazılabilir ama çalıştırılamaz
  • sayfaları önce sıfırlıyoruz sonra programı kopyalıyoruz. sıfırlamazsak PMM'den gelen sayfada başkasının eski verisi kalabilir

memcpy'yi direkt user adresine yapabiliyoruz çünkü CR3 aynı ve kernel her sayfaya erişebiliyo

stack'i neden -8 verdim? C fonksiyonları başladığında rsp % 16 = 8 olmasını bekliyo (çünkü normalde call 8 byte'lık dönüş adresi push ediyo). _start'ı call ile değil iretq ile çağırdığımız için o 8'i kendimiz çıkarıyoruz

4. iretq ile ring 3

ring 3'e geçmek için direkt bi komut yok. ama interrupt'tan dönerken iretq stack'ten rip cs rflags rsp ss alıyo. cs ring 3'ü gösteriyosa CPU ring 3'e iniyo. biz de stack'e sahte bi interrupt frame koyup iretq yapıyoruz CPU "user'dan gelen interrupt'tan dönüyorum" sanıyo:

static void jump_to_usermode(uint64_t entry, uint64_t stack) {
    __asm__ volatile (
        "pushq $0x1B\n"
        "pushq %0\n"
        "pushq $0x202\n"
        "pushq $0x23\n"
        "pushq %1\n"
        "iretq\n"
        : : "r"(stack), "r"(entry) : "memory"
    );
}

sırayla:

  • 0x1B ss = user veri (0x18) | 3
  • stack adresi
  • 0x202 rflags. bit 9 interrupt'lar açık (yoksa user'dayken timer bile çalışmaz) bit 1 hep 1 olmalı
  • 0x23 cs = user kod (0x20) | 3
  • programın başlangıç adresi

selector'ların sonundaki | 3 "ring 3 olarak istiyorum" demek. GDT yazısında user segmentlerini DPL 3 yapmıştık o yüzden izin veriliyo

5. rsp0

TSS yazısında rsp0 koymuştuk işte burda işe yarıyo. user int 0x80 yapınca (ya da timer gelince) CPU ring 3'ten ring 0'a geçiyo ve stack'i TSS'deki rsp0'a çeviriyo. rsp0 olmasaydı CPU kernel'e hangi stack'le gireceğini bilemezdi

6. kernel.c

kmain'in en sonunda heap testlerinden sonra:

usermode_start();

Ders 5: Test

alt text

HELLO FROM USERSPACEEEEE AHAHAHAHAHAHAHAHAH

NARC'TA İLK DEFA RING 3'TE KOD ÇALIŞTI. o yazıyı kernel yazmadı C ile yazdığım program kendi libc'siyle syscall yapıp yazdırdı. main 0 döndü _start exit(0) yaptı kernel de exit kodunu yazdı

exit'ten sonra kernel_idle'a geçiyoduk bakalım gerçekten yaşıyo mu N'ye bastım:

alt text

KERNEL YAŞIYOOO program bitti kernel klavyeyi dinlemeye devam ediyo

Bi de kötü program deneyelim

user gerçekten kernel'e dokunamıyo mu? hello.c'yi geçici olarak şöyle yaptım:

int main(void) {
    print("Hello from Userspace!\n");
    print("trying to read kernel memory...\n");
    volatile char *kernel = (volatile char *)0xFFFFFFFF80000000;
    char c = *kernel;
    print("I read the kernel lol\n");
    return c;
}

alt text

OKUYAMADIIII AHAHAHAH "I read the kernel lol" hiç yazılmadı

  • address 0xFFFFFFFF80000000 kernel'in başı
  • error 0x5 = bit 0 (sayfa var) + bit 2 (user'dan geldi). yani sayfa var ama user'ın izni yok, U biti yok
  • rip 0x4000D5 yani user programının içi

şimdilik user hata yapınca kernel'in page fault handler'ı her şeyi durduruyo, ilerde sadece programı öldürecek şekilde düzeltirim. testten sonra hello.c'yi eski haline aldım

Son

Narc'ta artık:

  • kendi GDT'si
  • TSS ve double fault için ayrı stack
  • int 0x80 ile write ve exit syscall'ları
  • küçük bi libc (strlen write print exit)
  • C ile yazılmış bi program ring 3'te çalışıp ekrana yazı yazabiliyo

eksikler çok:

  • user hata yapınca bütün kernel duruyo
  • tek program var ve kernel'e gömülü, ELF yükleyici lazım
  • her programın kendi page table'ı yok
  • scheduler yok

sırada büyük ihtimalle bunlar var

Work with me

Freelance work or a collaboration in systems programming. Send me an email.