User Space Programlamaya Giriş: write() ve read()

Yavaştan xv6 üzerinde çalışacak user space programlara bakalım. Bunun için MIT 6.1810 tarafından sağlanan lab repository’sini kullanacağım.

Merhaba Dünya! yazısında cross-compiler ve QEMU kurmuştum. Elimizde şu an xv6-riscv çalıştırabilen bir sistemimiz var.

git clone git://g.csail.mit.edu/xv6-labs-2023
cd xv6-labs-2023
make qemu

Sorunsuzca boot edip çalışması lazım

xv6 kernel is booting

hart 2 starting
hart 1 starting
init: starting sh
$

Ctrl-A X ile çıkabiliriz. Varsayılan olarak util branchinde olmamız lazım. git branch ile kontrol edebiliriz.

Tekrar çalıştıralım make qemu ile. ls yazarsak emüle edilen makine içerisindeki dosyaları görüyoruz. xv6 reposu, bir sanal disk oluşturuyor, mkfs ile. Burada çalıştırabileceğimiz bir çok program var. ls de böyle bir program.

$ ls
.              1 1 1024
..             1 1 1024
README         2 2 2305
xargstest.sh   2 3 93
cat            2 4 32848
echo           2 5 31696
forktest       2 6 15824
grep           2 7 36224
init           2 8 32192
kill           2 9 31656
ln             2 10 31480
ls             2 11 34784
mkdir          2 12 31712
rm             2 13 31696
sh             2 14 54144
stressfs       2 15 32584
usertests      2 16 180624
grind          2 17 47536
wc             2 18 33800
zombie         2 19 31056
console        3 20 0

Normalde Linux’ta ps komutu ile çalışan programları görebiliyoruz. xv6’da bu yok fakat Ctrl-p ile görebiliriz.

$
1 sleep  init
2 sleep  sh

2 adet program var. init, çalışan ilk program vs sh ile şu an çalışan shell diye tahmin ediyorum. 1 ve 2 değerleri de PID diye tahmin ediyorum.

read() ve write()

xv6 çekirdeğinin desteklediği iki adet syscall ile tanışalım: read ve write Bunlar, Unix read() ve write() fonksiyonlarına benzer tasarlanmışlar.

user/user.h içerisinde syscall ve C kütüphanesi fonksiyon prototipleri mevcut

struct stat;

// system calls
int fork(void);
int exit(int) __attribute__((noreturn));
int wait(int*);
int pipe(int*);
int write(int, const void*, int);
int read(int, void*, int);
int close(int);
int kill(int);
int exec(const char*, char**);
int open(const char*, int);
int mknod(const char*, short, short);
int unlink(const char*);
int fstat(int fd, struct stat*);
int link(const char*, const char*);
int mkdir(const char*);
int chdir(const char*);
int dup(int);
int getpid(void);
char* sbrk(int);
int sleep(int);
int uptime(void);

// ulib.c
int stat(const char*, struct stat*);
char* strcpy(char*, const char*);
void *memmove(void*, const void*, int);
char* strchr(const char*, char c);
int strcmp(const char*, const char*);
void fprintf(int, const char*, ...);
void printf(const char*, ...);
char* gets(char*, int max);
uint strlen(const char*);
void* memset(void*, int, uint);
void* malloc(uint);
void free(void*);
int atoi(const char*);
int memcmp(const void *, const void *, uint);
void *memcpy(void *, const void *, uint);

write() fonksiyonu 3 adet parametre alıyor. İlki file descriptor, fd, ikincisi içeriği yazılacak bufferın adresi ve üçüncüsü de kaç byte yazılacağı. Fonksiyon yazımı yapılan byte sayısını dönüyor. İstenenden daha az yazma yapılması bir hata göstergesi. Hemen ekrana Merhaba Dunya! yazan bir örnek yapalım.

#include "kernel/types.h" //uint vs için
#include "user/user.h"

int
main()
{
  int n = write(1, "Merhaba Dunya!\n", 15);
  if (15 == n)
    exit(0);
  else
    exit(1);
}

Yukarıdaki kodu user/merhaba.c olarak kaydedelim. Bir de Makefile içerisinde değişiklik yapmamız gerekiyor ki kodumuz derlensin UPROGS kısmını bulup en sona kendi kodumuzu ekleyelim.

UPROGS=\
        $U/_cat\
        $U/_echo\
        $U/_forktest\
        $U/_grep\
        $U/_init\
        $U/_kill\
        $U/_ln\
        $U/_ls\
        $U/_mkdir\
        $U/_rm\
        $U/_sh\
        $U/_stressfs\
        $U/_usertests\
        $U/_grind\
        $U/_wc\
        $U/_zombie\
        $U/_merhaba\

En sona $U/_merhaba\ ekledim. Makefile ın sentaksını henüz anlamadım ama öncekilere benzeterek yapıyorum şimdilik, sonra bakarız. make qemu dediğimizde vs ls çektiğimizde merhaba komutunu görmemiz lazım.

$ ls
.              1 1 1024
..             1 1 1024
README         2 2 2305
xargstest.sh   2 3 93
cat            2 4 32848
echo           2 5 31696
forktest       2 6 15824
grep           2 7 36224
init           2 8 32192
kill           2 9 31656
ln             2 10 31480
ls             2 11 34784
mkdir          2 12 31712
rm             2 13 31696
sh             2 14 54144
stressfs       2 15 32584
usertests      2 16 180624
grind          2 17 47536
wc             2 18 33800
zombie         2 19 31056
merhaba        2 20 31160
console        3 21 0

Burada dikkatimi çeken şey sıralamanın alfabetik olmaması oldu, Makefile daki sıraya göre çıkıyor. i-node ? gibi bir şeye göre sıralıyor olabilir, ilerde bakarız. console da bir device file muhtemelen.

$ merhaba
Merhaba Dunya!

Ne güzel oldu değil mi? write(1, ..) ile stdout a bastık.

ay@dsklin:~/ws/xv6-labs-2023$ ll user/merhaba*
-rw-rw-r-- 1 ay ay 38743 Apr 23 14:36 user/merhaba.asm
-rw-rw-r-- 1 ay ay   173 Apr 23 14:35 user/merhaba.c
-rw-rw-r-- 1 ay ay    58 Apr 23 14:36 user/merhaba.d
-rw-rw-r-- 1 ay ay  6536 Apr 23 14:36 user/merhaba.o
-rw-rw-r-- 1 ay ay  1535 Apr 23 14:36 user/merhaba.sym

Gözlemler: make qemu deyince otomatik derlendi ve bunlar çıktı. .o obhe dosyası olmalı. Tüm executableların önünde _ karakteri var, neden bilmiyorum. Her şey statik linkleniyor, basit bir OS için mantıklı. O yüzden .asm dosyasının boyutu büyük diye anlıyorum, içeriği de karmaşık. Fakat .asm neden sadece bizim programı içermiyor çözemedim, ayrıca bunu nasıl yapmışlar Makefile ile bakacağım.

ay@dsklin:~/ws/xv6-labs-2023$ file user/_merhaba
user/_merhaba: ELF 64-bit LSB executable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), statically linked, with debug_info, not stripped
ay@dsklin:~/ws/xv6-labs-2023$ file user/merhaba.o
user/merhaba.o: ELF 64-bit LSB relocatable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), with debug_info, not stripped

Şimdi read() e bakalım. Benzer mantık, bu okuma yapıyor. Okuduğu byte sayısını döndürüyor.

stdin’den okuduğunu stdout’a yazan bir program yazalım.

#include "kernel/types.h"
#include "user/user.h"

static char buf[16];
static const char msg1[] = "read yapildi\n";
static const char msg2[] = "write yapildi\n";
static const char msg3[] = "read cikti!!!\n";

int main()
{
  for (;;){
    int n = read(0 , buf, sizeof(buf));
    write(1, msg1, sizeof(msg1) - 1);
    if (n <= 0) {
      write(1, msg3, sizeof(msg3) - 1);
      break;
    }
   write(1, buf, n);
   write(1, msg2, sizeof(msg2) - 1);
  }

  exit(0);
}

Bunu user/loop.c olarak kaydedelim ve yine Makefileı benzer şekilde düzenleyelim.

...
  $U/_zombie\
  $U/_merhaba\
  $U/_loop\
...

Benzer şekilde make qemu ile çalıştıralım.

Şimdi bu kod ne yapıyor? read(0,..) ile stdin’den okuyoruz, okunan byte kadar yazma yapıyoruz aynı içeriği geri basıyoruz, yani loopback yapıyoruz. sizeof(x) - 1 deki -1 in sebebi stringlerin sonundaki \0 karakteri basmamak.

$ loop

ile çalıştırdığımızda program bekliyor.

$ loop
asd
read yapildi
asd
write yapildi

asd yazdım ama Enter diyene kadar bir şey olmadı. read() bloklanan bir ama Enter’a basana kadar yazdıklarımız nerde, birazdan bakarız.

Burada buffer boyutunu 16 gibi küçük bir sayı seçtim. Bunu aşarsak ne olacak?

abcdefghijklmnoprst0123456789
read yapildi
abcdefghijklmnopwrite yapildi
read yapildi
rst0123456789
write yapildi

Burada Enter demeden önce abcdefghijklmnoprst0123456789 yazdım, 29 karakterden oluşuyor. Sonra Enter’a bastığım zaman read yapildi mesajını gördük ve abcdefghijklmnop kısmı ekrana write ile basıldı, tam 16 karakter. Mesajın sonunda \n olmadığı için write yapildi yazısı hemen arkasına geldi. Daha sonra read tekrar donüş yaptı ve kalan rst0123456789 ve \n ekrana basıldı. Dikkat ederseniz Enter karakteri de programa iletiliyor, asd yazdığımda da öyle olmuştu.

Peki ben Enter’a basana kadar bu karakterler nerede duruyor? Bunun cevabı için belki erken ama biraz kayna koduna bakındım, cevaplar kernel/console.c içerisinde. Aslında biz bir şeyler yazdıkça ekranda görmemizin sebebi de bu. Yani QEMU ya da başka bir şey bize bastığımızı geriye basmıyor. Burası ekrana basıldığı yer.

kernel/console.c
162// echo back to the user.
163consputc(c);

Burada kerneldeki console sürücüsü ekrana basıyor.

Hemen altında da gelen mesajların circular buffer mantığı ile console bufferına konulduğunu görüyoruz.

kernel/console.c
159if(c != 0 && cons.e-cons.r < INPUT_BUF_SIZE){
160  c = (c == '\r') ? '\n' : c;
161
162  // echo back to the user.
163  consputc(c);
164
165  // store for consumption by consoleread().
166  cons.buf[cons.e++ % INPUT_BUF_SIZE] = c;

Bu buffer ise 128’lik olarak tanımlanmış.

kernel/console.c
48#define INPUT_BUF_SIZE 128

Eğer buffer dolana kadar Enter’a basılmazsa 128 karakter yazılınca sanki Enter’a basılmış gibi read ile bloklanmış olan fonksiyona buffer iletiliyor.

kernel/console.c
168if(c == '\n' || c == C('D') || cons.e-cons.r == INPUT_BUF_SIZE){
169  // wake up consoleread() if a whole line (or end-of-file)
170  // has arrived.
171  cons.w = cons.e;
172  wakeup(&cons.r);

O zaman hemen deneyelim bakalım 128 den fazla bir şey verince ne oluyor. Beklediğimiz gibi Enter’a basmasak bile 128 karakter gelince read devam ediyor,

00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000read yapildi
0000000000000000write yapildi
read yapildi
0000000000000000write yapildi
read yapildi
0000000000000000write yapildi
read yapildi
0000000000000000write yapildi
read yapildi
0000000000000000write yapildi
read yapildi
0000000000000000write yapildi
read yapildi
0000000000000000write yapildi
read yapildi
0000000000000000write yapildi

Diyelim ki CTRL-D ye bastık bu sefer de

read yapildi
read cikti!!!
$

alıyoruz çünkü CTRL-D aslında bir nevi EOT/EOF sinyali veriyor yani bitti. Bu durumda read() fonksiyonu pozitif bir sayı dönmüyor, bizim programımız da çıkıyor.

Bu da yine yukarıdaki satır 168’de var, c == C('D') olarak kontrol ediliyor. C(x) bir fonksiyonel macro, #define C(x)  ((x)-'@')  // Control-x olarak verilmiş satır 26’da. Ctrl-x kombinasyonun kodları bu değerler sahip [1], [2]. Yani Enter’a basmadan girdi vermek istiyorsak CTRL-D yapabiliriz yazıyı yazıp. Yazmadan verirsek 0 uzunlukta bir girdi yapmış oluyoruz.

$ loop
asd
read yapildi
asd
write yapildi
asdread yapildi
asdwrite yapildi
read yapildi
read cikti!!!

Yukarıdaki denemede asd yazıp Enter dedim daha sonra asd yazıp Ctrl-d yaptım. Peki neden read ikinci çağrıda hemen dönüp, çıkışa sebep oldu? O da sanıyorum şundan, sonra tekrar bakarız biraz yoruldum 😅

kernel/console.c
101if(c == C('D')){  // end-of-file
102  if(n < target){
103    // Save ^D for next time, to make sure
104    // caller gets a 0-byte result.
105    cons.r--;
106  }
107  break;
108}

Özet

Fena ilerlemedik, biraz sistem programlama yaptık, console driver’ına baktık. write() ve read() i sanki anladık. Bir sonraki bölümde programımızı debug etmeye çalışabiliriz gdb ile.

Kaynaklar