
Overview
Trong chuỗi bài viết này, chúng ta sẽ cùng học về XV6: a simple, Unix-like teaching operating system. Chúng ta sẽ học được một hệ điều hành sẽ hoạt động như thế nào, cách nó ảo hoá, quản lý tài nguyên trên máy tính. Sau mỗi chương ta sẽ cùng thực hành để củng cố kiến thức nhé.
~ Lẹt gô ~
Chapter 1 - Operating system interfaces
Linh tinh:
Con người là loài duy nhất luôn sợ cảm giác thấp kém hơn, chúng ta PHẢI là sinh vật thông minh nhất, chúng ta PHẢI là sinh vật khéo léo nhất. Thậm chí khi có máy tính, chúng ta phải là giống loài tạo ra máy tính và làm chủ máy tính!
Để tìm ra ai thông minh hơn học sinh lớp 5 thì hôm nay tôi viết bài này, về NÓ - HỆ ĐIỀU HÀNH!
Hệ điều hành là phật sống, vì sao á? Vì nó cung cấp đủ đồ chơi cho những ứng dụng khác chạy được và hơn hết là nó cung cấp một low-level abstraction để ứng dụng có thể dùng phần cứng mà không cần biết bố nó là ai. Chẳng hạn như là ứng dụng Zalo bạn cài trên máy tính chả cần biết bạn xài Macbook hay một con PC chip Intel Core i7 11700KF, dùng ổ cứng SSD hay HDD đời nhà tống, cứ thế mà nó chạy thôi, anh em mình cứ thế thôi hẹ hẹ.
Ứng dụng người dùng giao tiếp được với hệ điều hành thông qua một cái interface. Nhưng để tạo nên một interface đủ tốt để hàng tá ứng dụng có thể dùng nó một cách đơn giản là cực kỳ khó. Một thiết kế tốt là một thiết kế giảm tối đa độ phức tạp, một module nên đủ sâu để che giấu được độ phức tạp và một abstraction tốt chỉ làm chính xác những việc nó nói, không mập mờ, không nửa vời.
Đến đây tôi recommend mọi người nên đọc quyển Philosophy of Software Design để thẩm cái sự phức tạp của việc thiết kế ứng dụng nó cũng ụ ụ oẹ oẹ như bạn biết crush cũng thích bạn nhưng bạn không thể chứng minh ấy :V
Và hể ưe go, một abstraction siêu cơ bản cho trẻ em cũng có thể học được:
| System call | Description |
|---|---|
int fork() | Create a process, return child’s PID. |
int exit(int status) | Terminate the current process; status reported to wait(). No return. |
int wait(int *status) | Wait for a child to exit; exit status in *status; returns child PID. |
int kill(int pid) | Terminate process PID. Returns 0, or -1 for error. |
int getpid() | Return the current process’s PID. |
int sleep(int n) | Pause for n clock ticks. |
int exec(char *file, char *argv[]) | Load a file and execute it with arguments; only returns if error. |
char *sbrk(int n) | Grow process’s memory by n zero bytes. Returns start of new memory. |
int open(char *file, int flags) | Open a file; flags indicate read/write; returns an fd (file descriptor). |
int write(int fd, char *buf, int n) | Write n bytes from buf to file descriptor fd; returns n. |
int read(int fd, char *buf, int n) | Read n bytes into buf; returns number read, or 0 if end of file. |
int close(int fd) | Release open file fd. |
int dup(int fd) | Return a new file descriptor referring to the same file as fd. |
int pipe(int p[]) | Create a pipe, put read/write file descriptors in p[0] and p[1]. |
int chdir(char *dir) | Change the current directory. |
int mkdir(char *dir) | Create a new directory. |
int mknod(char *file, int, int) | Create a device file. |
int fstat(int fd, struct stat *st) | Place info about an open file into *st. |
int link(char *file1, char *file2) | Create another name (file2) for the file file1. |
int unlink(char *file) | Remove a file. |
Bảng 1. UNIX System Calls
Hệ điều hành là một cô gái đẹp, cô ấy có ranh giới rõ ràng giữa con tim và lý trí. Con tim của hệ điều hành chính là kernel, một ứng dụng đặc biệt phục vụ những service ở bảng trên cho các chương trình (program) đang chạy. Các chương trình đang chạy này được gọi là một process, nó có bộ nhớ chứa những instructions, dữ liệu và một stack. Instructions là những câu lệnh hướng dẫn máy tính thực hiện việc tính toán, dữ liệu là những biến số được tính từ chuỗi những instructions, và stack tổ chức cách mà những câu lệnh thực hiện theo thứ tự. Một máy tính có thể có nhiều process, nhưng duy chỉ có một kernel.
Khi một process cần thực thi một kernel service, nó sẽ thực thi một system call - chính là một trong những system call ở Bảng 1. Cụ thể hơn ta xem ở Hình 1: Kernel và hai ứng process đang chạy.

Hình 1: Kernel và hai process đang chạy
Tiến trình và bộ nhớ
Mỗi process trong xv6 gồm vùng nhớ người dùng (instructions, data và stack) và trạng thái kernel riêng cho từng process. Kernel chia CPU giữa các process bằng cách luân phiên (time-sharing), lưu/khôi phục thanh ghi CPU khi chuyển ngữ cảnh, và quản lý từng process bằng một PID duy nhất.
Đến đây ta sẽ tiếp cập với system call fork. fork tạo một process mới, và cho process mới đó một bản copy tất cả mọi thử y hệt như process đang gọi fork.
Trong process gốc, fork sẽ trả về PID của process mới, còn ở process mới thì fork sẽ trả về 0. Hai process cũ và mới thường được gọi là cha và con.
Xem đoạn code sau được viết bằng C:
int pid = fork();
if (pid > 0) {
printf("parent: child=%d\n", pid);
pid = wait((int * ) 0);
printf("child %d is done\n", pid);
} else if (pid == 0) {
printf("child: exiting\n");
exit(0);
} else {
printf("fork error\n");
}
exit system call sẽ dừng process hiện tại và trả về một số nguyên. Thông thường trả về 0 nếu thành công, 1 nếu thất bại.
wait sẽ trả về PID của process con đã kết thúc (existed) hoặc bị huỷ (killed)
- Nếu không có process con, ngay lập tức trả về -1.
- Nếu process con đang chạy ngon lành, process cha sẽ đợi process con.
Mặc dù process con được chia sẻ một bản copy full HD không che từ process cha, hai cha con thực thi với bộ nhớ riêng, thanh ghi riêng, nên cả hai sẽ không giao tiếp hay chia sẻ được gì với nhau.
exec thay thế toàn bộ dữ liệu kể cả code, data, stack của process hiện tại sang một process mới nhưng PID vẫn được giữ nguyên.
Ví dụ, đoạn code dưới đây thực hiện thay thế chương trình hiện tại bằng chương trình echo được đặt ở /bin/echo, thực thi với tham số đầu vào argv
int main() {
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");
}
Một lưu ý về bộ nhớ của hai câu lệnh này:
fork: kernel sẽ cấp phát vùng nhớ cần thiết cho process con, bao gồm toàn bộ vùng nhớ user-spaceexec: kernel xoá toàn bộ vùng nhớ cũ của process hiện tại, cấp phát vùng nhớ mới đủ để nạp chương trình mới vào.
Process có thể yêu cầu kernel cấp phát vùng nhớ thông qua system call sbrk. sbrk nhận vào một số nguyên kích thước dữ liệu cần được cấp phát, trả về địa chỉ của dữ liệu mới được cấp.
Ngôn ngữ lập trình C cung cấp một function malloc để có thể làm việc với vùng nhớ đơn giản hơn, ta chỉ cần gọi hàm malloc mà không cần kêu kernel cho bố cái địa chỉ.
I/O và file descriptor
File descriptor là một số nguyên nhỏ, biểu diễn một object được quản lý bởi kernel mà process có thể đọc hoặc ghi. Interface của file descriptor giúp trừu tượng hoá I/O, giúp cho việc trao đổi dữ liệu giữa những files, pipes hay devices đều giống như là một chuỗi các bytes.
| file descriptor | 0 | 1 | 2 | 3... |
|---|---|---|---|---|
| ý nghĩa | stdin | stdout | stderr | ... |
Bảng 2: File descriptor table
Nội tại bên trong kernel dùng file descriptor như một index trỏ tới các object, mỗi process có một file descriptor table riêng và đều có 3 file descriptor 0, 1 và 2 trỏ tới standard input, standard output và standard error. Còn lại những resource sẽ đếm từ 3 trở đi, mỗi process sẽ bắt đầu từ 3.
Ví dụ đơn giản với hai system call read và write
int read(int fd, char *buf, int n): đọc nhiều nhấtnbytes từ file descriptorfdtại vị tríoffsetvà sao chép nó vào bufferbuf, di chuyểnoffsetlênnbytes rồi trả về số byte đã đọc được. Mỗi file descriptor được gắn với mộtoffset, chỉ vị trí hiện tại của con trỏ ở trên file.
Ví dụ ta có một file data.txt 22 bytes, có nội dung như sau:
Phuc dep trai vai noi!
Với đoạn code:
int main() {
int fd = open("data.txt", O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
char buf[32];
memset(buf, 0, sizeof(buf));
for (int bytes_read = 0; ; bytes_read = read(fd, buf, 10)) {
if (bytes_read == 0) { // EOF
break;
} else if (bytes_read < 0) { // error
perror("read");
break;
}
printf("%s\n", buf);
}
close(fd);
return 0;
| Iteration | fd | Offset | n | bytes_read | buf |
|---|---|---|---|---|---|
| 1 | 3 | 0 | 10 | 10 | Phuc dep t |
| 2 | 3 | 10 | 10 | 10 | rai vai no |
| 3 | 3 | 20 | 10 | 2 | i! |
| 4 | 3 | 22 (EOF) | 10 | 0 |
int write(int fd, char *buf, int n): ghinbytes dữ liệu từ bufferbufvào file descriptorfdvà trả về số byte đã ghi. Giống nhưread,writecũng có offset nhưng nếu số lượng bytes trả về nhỏ hơnnnghĩa là đã có lỗi xảy ra.
File descriptor là một thứ trừu tượng rất vippro123, nó ẩn đi toàn bộ sự phức tạp của thứ mà nó cần connect tới.
- Muốn ghi file hả?
write(0, buf, n) - Muốn ghi ra console hả?
write(0, buf, n) - Muốn ghi vô pipe hả?
write(0, buf, n)
Pipe
Pipe là một buffer nhỏ trong kernel, là cầu nối để hai process có thể giao tiếp được với nhau mà không cần ghi dữ liệu ra file. Khi tạo một pipe, kernel sẽ cấp cho caller hai file descriptor, một để đọc, một để ghi.
Pipe không có tên, chỉ tồn tại trong bộ nhớ kernel khi process đang chạy. Khi tất cả các process đóng cả hai đầu của pipe, kernel sẽ tự động hủy pipe.

Hình 2: Hai processes giao tiếp với nhau qua pipe
File system
Everything Is A File
Với những hệ thống Unix-like, hoặc hầu hết những hệ điều hành thì tất cả mọi thứ đều có thể được coi là một file, kể cả thư mục thì cũng chỉ là một loại file đặc biệt.
Cấu trúc tổng quan của hệ thống file có thể được xem như sau:

Hình 3: File system
Ta làm quen với khái niệm inode, đây là một object lưu trữ thông tin về file.
Một file có thể có nhiều tên, được gọi là link.
Một inode chứa những thông tin như số lượng link được trỏ tới, loại file (file, device, folder), kích thước, địa chỉ ô nhớ chứa dữ liệu,...
Nhiều link có thể trỏ vào cùng một inode, một inode có thể trỏ tới nhiều ô nhớ chứa dữ liệu.
Đây là ví dụ về một cây thư mục và cách nó được tổ chức bên dưới
/
├── a.txt
└── subfolder/
└── b.txt

Hình 3: Example file system