System call — cách chương trình xin kernel làm việc
Trace một system call từ hàm thư viện (printf/read) qua lệnh syscall vào kernel rồi quay về — mode switch, bảng syscall, và chi phí của nó.
TL;DR: Một system call là cách duy nhất hợp pháp để chương trình (user mode) nhờ kernel (kernel mode) làm việc đặc quyền: đọc file, gửi mạng, cấp bộ nhớ. Khi bạn gọi write(), hàm thư viện glibc đặt số hiệu syscall và đối số vào các thanh ghi rồi chạy lệnh syscall. Lệnh này chuyển CPU sang kernel mode, nhảy tới điểm vào cố định, kernel tra bảng syscall để tìm handler, chạy nó, đặt kết quả trở lại rax, rồi trả về user mode. Toàn bộ vòng này là mode switch (đổi đặc quyền trong cùng tiến trình), khác hẳn context switch; chi phí cỡ hàng trăm nano-giây — rẻ so với I/O nhưng đắt nếu gọi hàng triệu lần.
Ở bài trước, bạn thấy user code không được đụng phần cứng: thử chạy lệnh đặc quyền là bị SIGSEGV. Nhưng chương trình vẫn đọc file, in màn hình, gửi request mạng mỗi ngày. Vậy nó làm bằng cách nào, nếu không được tự tay đụng ổ đĩa hay card mạng?
Câu trả lời là system call — cây cầu được kiểm soát chặt bắc qua ranh giới hai mode. Bài này trace một write() từ dòng C của bạn, qua hàm thư viện, xuống lệnh syscall, vào kernel, chạy handler, rồi quay ngược về — từng chặng một, ở mức thanh ghi. Hiểu con đường này, bạn đọc được output strace và biết vì sao "gọi nhiều syscall nhỏ" là một anti-pattern hiệu năng.
1. Analogy — gọi tổng đài dịch vụ công
Bạn muốn cấp lại căn cước. Bạn không tự đi vào kho dữ liệu quốc gia sửa hồ sơ — bạn gọi tổng đài dịch vụ công, đọc đúng mã thủ tục ("thủ tục 1234"), cung cấp thông tin cần thiết, rồi chờ. Nhân viên có quyền (kernel) vào kho làm giúp, xong báo kết quả ra cho bạn. Bạn không bao giờ trực tiếp chạm vào kho.
System call hoạt động y hệt: chương trình của bạn "gọi tổng đài" bằng lệnh syscall, đưa số hiệu (mã thủ tục) và đối số (thông tin), rồi kernel — bên có đặc quyền — làm việc đó và trả kết quả.
| Dịch vụ công | System call |
|---|---|
| Bạn (công dân) | Chương trình ở user mode |
| Nhân viên có quyền vào kho | Kernel ở kernel mode |
| Kho dữ liệu quốc gia | Phần cứng (ổ đĩa, mạng, bộ nhớ) |
| Mã thủ tục "1234" | Số hiệu syscall (trong rax) |
| Thông tin bạn cung cấp | Đối số (trong rdi, rsi, rdx...) |
| Nhân viên báo kết quả ra | Giá trị trả về (trong rax) |
| Gọi tổng đài (hành động chuyển tiếp) | Lệnh syscall (chuyển sang kernel mode) |
System call = đưa số hiệu + đối số vào thanh ghi rồi chạy một lệnh chuyển mode. Không phải "gọi hàm bình thường": nó cố tình gây một cú chuyển từ user mode sang kernel mode, có kiểm soát.
2. Từ printf tới syscall — ai thật sự đọc đĩa?
Khi bạn viết printf("hi") trong C, chuỗi lệnh thật sự dài hơn bạn tưởng. printf là hàm của thư viện chuẩn C (glibc): nó định dạng chuỗi, gộp vào buffer, rồi cuối cùng gọi write() — cũng là hàm glibc. Bản thân write() vẫn chưa đụng đĩa; nó chỉ là lớp bọc mỏng (wrapper) phát ra một system call.
Thử đoán trước khi đọc giải thích: trong đoạn dưới, ai thật sự đẩy 3 byte ra màn hình — hàm write bạn gọi, wrapper glibc, hay kernel?
// vd_write.c -- goi write() truc tiep, khong qua printf
#include <unistd.h>
int main(void) {
// write(fd, buf, count): fd=1 la stdout
write(1, "hi\n", 3);
return 0;
}
Ba tầng, tách bạch:
- Code của bạn (user): gọi
write(1, "hi\n", 3). - Wrapper glibc (vẫn user): đặt số hiệu syscall của
writevàorax, các đối số vào thanh ghi, chạy lệnhsyscall. - Kernel (kernel mode): thực sự đẩy 3 byte ra file descriptor 1.
Điểm cần khắc: không có tầng nào ở user mode chạm phần cứng. Wrapper glibc chỉ chuẩn bị thanh ghi rồi "gọi tổng đài". Đây là lý do bạn có thể viết chương trình đọc/ghi file mà chưa từng học một lệnh I/O phần cứng nào.
Lệnh syscall là lệnh hợp ngữ, quy ước thanh ghi khác nhau tuỳ kiến trúc (x86-64, ARM64...). Nếu mỗi chương trình tự viết đoạn hợp ngữ này, code sẽ không portable và dễ sai. glibc gói mỗi syscall thành một hàm C gọn (read, write, open...), lo phần thanh ghi và xử lý lỗi, để bạn chỉ cần gọi hàm C bình thường.
3. Cơ chế bên dưới — đường đi một system call
Đây là phần cốt lõi. Ta trace write(1, "hi\n", 3) ở mức thanh ghi, trên x86-64.
3.1 Phía user chuẩn bị
Wrapper glibc nạp các thanh ghi theo quy ước gọi syscall của Linux x86-64:
| Thanh ghi | Vai trò | Giá trị cho write |
|---|---|---|
rax | Số hiệu syscall | 1 (số của write trên x86-64) |
rdi | Đối số 1 | 1 (fd = stdout) |
rsi | Đối số 2 | Địa chỉ chuỗi "hi\n" |
rdx | Đối số 3 | 3 (số byte) |
Các đối số tiếp theo (nếu có) đi vào r10, r8, r9. Chuẩn bị xong, wrapper chạy đúng một lệnh:
syscall
3.2 Phần cứng chuyển mode
Lệnh syscall làm mấy việc nguyên tử: chuyển mode bit sang kernel mode, lưu địa chỉ trở về (vào rcx) và cờ trạng thái (vào r11), rồi nhảy tới một địa chỉ cố định mà kernel đã đăng ký sẵn lúc khởi động (điểm vào syscall). User code không tự chọn được nhảy vào đâu trong kernel — nó chỉ có một cửa vào duy nhất. Đó là điều khiến system call an toàn.
3.3 Kernel tra bảng và chạy handler
Vào kernel rồi, code điểm vào đọc rax để biết đây là syscall số mấy, rồi dùng nó làm chỉ số tra vào bảng syscall (sys_call_table) — một mảng con trỏ hàm, mỗi ô trỏ tới handler tương ứng. Số 1 → sys_write. Kernel gọi sys_write, hàm này kiểm tra đối số (fd hợp lệ? buffer nằm trong vùng nhớ user hợp lệ?) rồi thực sự đẩy byte ra.
3.4 Trả về user
sys_write trả về số byte đã ghi (ở đây là 3). Kernel đặt giá trị này vào rax, chạy lệnh sysret để chuyển mode bit về user mode và nhảy về đúng chỗ đang dở (địa chỉ đã lưu trong rcx). Wrapper glibc đọc rax: nếu là số không âm, đó là kết quả; nếu là giá trị âm nhỏ (khoảng từ -4095 tới -1), đó là mã lỗi -errno — wrapper đặt errno tương ứng và trả -1 cho bạn.
sequenceDiagram
participant U as User code (ban)
participant G as glibc wrapper
participant C as CPU (phan cung)
participant K as Kernel
U->>G: write(1, "hi", 3)
G->>G: rax=1, rdi=1, rsi=buf, rdx=3
G->>C: lenh "syscall"
C->>K: doi sang kernel mode, nhay diem vao co dinh
K->>K: tra sys_call_table[1] = sys_write
K->>K: chay sys_write, ghi 3 byte
K->>C: dat rax=3, lenh "sysret"
C->>G: doi ve user mode, tra ve cho dang do
G->>U: return 34. Mode switch khác context switch
Hai khái niệm này rất dễ lẫn, nhưng phân biệt được là bạn hiểu quá nửa module.
- Mode switch (đổi chế độ): CPU chuyển giữa user mode và kernel mode trong cùng một tiến trình. Không đổi không gian địa chỉ, không nạp lại page table, không đá tiến trình ra khỏi CPU. Chỉ đổi mode bit + lưu vài thanh ghi. Đây là những gì xảy ra ở mọi system call.
- Context switch (đổi bối cảnh): kernel thay hẳn tiến trình đang chạy bằng tiến trình khác. Phải lưu toàn bộ thanh ghi của tiến trình cũ, nạp thanh ghi tiến trình mới, và đổi page table (nạp
cr3mới) — kéo theo xả TLB (Translation Lookaside Buffer — cache dịch địa chỉ ảo sang vật lý, học ở Course 2 — MMU & TLB), làm cache "nguội". Đắt hơn mode switch nhiều lần.
| Mode switch | Context switch | |
|---|---|---|
| Đổi tiến trình? | Không (cùng tiến trình) | Có (sang tiến trình khác) |
Đổi page table (cr3)? | Không (trừ khi bật KPTI — xem Đào sâu) | Có → xả TLB |
| Kích hoạt bởi | Mọi system call, interrupt, exception | Scheduler quyết định |
| Chi phí tương đối | Thấp (hàng trăm ns) | Cao hơn (thường vài µs) |
Một system call luôn là mode switch. Nó có thể dẫn tới context switch (ví dụ read() phải chờ đĩa → kernel cho tiến trình khác chạy trong lúc chờ), nhưng bản thân việc gọi syscall không đồng nghĩa với đổi tiến trình. Nhầm hai cái này là nhầm "hỏi tổng đài một câu" với "nghỉ việc để người khác vào chỗ".
5. Chi phí của một system call
Một mode switch không miễn phí: một system call "rỗng" (ví dụ getpid()) mất cỡ hàng chục tới hàng trăm nano-giây (cao hơn khi bật mitigation như KPTI) — gồm lệnh syscall/sysret, lưu/khôi phục thanh ghi, và xáo trộn cache/pipeline. Hệ quả thực tế: gọi ít syscall lớn nhanh hơn nhiều syscall nhỏ — đọc 1 MB bằng một read(fd, buf, 1MB) rẻ hơn hẳn gọi read một triệu lần mỗi lần 1 byte, vì mỗi lần gọi phải trả lại chi phí vượt biên. Đây là lý do các thư viện I/O dùng buffer.
- KPTI (Kernel Page-Table Isolation — mitigation cho lỗ hổng Meltdown từ 2018, xem Course 1 — CPU hiện đại) tách bảng trang kernel khỏi user, nên mỗi lần vào/ra kernel phải đổi page table và xả một phần TLB — làm syscall đắt hơn đáng kể.
- vDSO là một vùng nhớ Linux ánh xạ vào mỗi tiến trình, chứa sẵn code + dữ liệu để trả lời vài truy vấn hay gặp (như
clock_gettime()) ngay trong user mode, không mode switch — nên nhanh gấp nhiều lần.
Cả hai cùng chứng minh: phần lớn chi phí syscall nằm ở chính cú chuyển mode, không phải công việc bên trong.
6. Pitfall — những hiểu nhầm thường gặp
❌ Nhầm 1 — "Gọi write() là một lời gọi hàm bình thường."
✅ Không. Một lời gọi hàm bình thường (myFunc()) chỉ nhảy trong cùng user mode. write() cuối cùng chạy lệnh syscall, cố ý gây mode switch sang kernel — đắt hơn lời gọi hàm thường hàng trăm lần (hơn nữa khi bật mitigation như KPTI). Đó là lý do vòng lặp gọi syscall triệu lần thì chậm dù mỗi lần "chỉ ghi 1 byte".
❌ Nhầm 2 — "System call luôn kéo theo đổi tiến trình."
✅ Không. System call luôn là mode switch nhưng không nhất thiết là context switch. getpid() chẳng hạn: vào kernel, đọc PID, trả về ngay — cùng tiến trình từ đầu tới cuối. Chỉ khi syscall phải chờ (I/O, lock) kernel mới có thể chuyển sang tiến trình khác.
❌ Nhầm 3 — "Giá trị trả về âm nghĩa là bug."
✅ Không. Kernel mã hoá lỗi bằng cách trả -errno (giá trị âm nhỏ). Ví dụ open() một file không tồn tại: kernel trả -2 (-ENOENT). Wrapper glibc dịch thành errno = ENOENT và trả -1. Đây là hợp đồng bình thường, không phải lỗi hệ thống — bạn phải kiểm tra giá trị trả về và errno.
7. 📚 Deep Dive
Spec / tài liệu tham khảo:
- syscall(2) — Linux man page — bảng quy ước thanh ghi cho từng kiến trúc (x86-64: số hiệu ở
rax, trả về ởrax), và lệnh dùng để vào kernel. Đây là nguồn chính xác cho phần section 3. - syscalls(2) — Linux man page — danh sách toàn bộ system call của Linux kèm phiên bản kernel giới thiệu. Dùng để tra "syscall X làm gì".
- OSTEP — Chapter 6: Limited Direct Execution — mục "System Calls" và "trap table" giải thích vì sao có điểm vào cố định và kernel tra bảng.
Ghi chú: Muốn thấy syscall thật của một chương trình, dùng strace (bài 04). Muốn tra một syscall cụ thể, đọc man page mục 2, ví dụ man 2 write.
8. Liên hệ các bài khác
- Bài 01 — Kernel mode vs user mode: ranh giới mà system call bắc cầu qua; lệnh
syscallchính là cách hợp pháp để chuyển mode mà bài trước nói tới. - Bài 03 — Interrupt, trap & exception: system call là một dạng trap (chủ động vào kernel); bài này đặt nó cạnh interrupt và exception.
- Bài 04 — Đọc syscall bằng strace: công cụ để nhìn thấy từng syscall bạn vừa học, với đối số và giá trị trả về thật.
- Course 1 — Spectre & Meltdown: vì sao mitigation KPTI làm mọi system call đắt hơn.
9. Tóm tắt
- System call là giao diện duy nhất để user code nhờ kernel làm việc đặc quyền; hàm
glibc(read,write) chỉ là wrapper mỏng. - Cơ chế x86-64: số hiệu vào
rax, đối số vàordi/rsi/rdx/r10/r8/r9, chạy lệnhsyscall→ CPU sang kernel mode, nhảy tới điểm vào cố định. - Kernel đọc
rax, tra bảng syscall để tìm handler (sys_write...), chạy, đặt kết quả vàorax, rồisysretvề user. - Giá trị trả về âm nhỏ mã hoá lỗi (
-errno);glibcdịch thànherrno+ trả-1. - Một system call luôn là mode switch (rẻ, cùng tiến trình), khác context switch (đắt, đổi tiến trình + đổi page table + xả TLB).
- Chi phí syscall cỡ hàng trăm ns, cao hơn do KPTI; nên gom thành ít syscall lớn (buffer) thay vì nhiều syscall nhỏ. vDSO tránh mode switch cho vài truy vấn hay gặp.
10. Tự kiểm tra
Q1Khi bạn gọi write(1, buf, 3), hãy trace số hiệu syscall và đối số đi qua những thanh ghi nào, và kernel dùng gì để tìm đúng handler.▸
Trên x86-64, wrapper glibc đặt số hiệu syscall của write (là 1) vào rax, rồi ba đối số vào rdi (fd = 1), rsi (địa chỉ buffer), rdx (số byte = 3). Sau đó nó chạy lệnh syscall, chuyển CPU sang kernel mode và nhảy tới điểm vào cố định.
Trong kernel, code điểm vào đọc rax = 1 và dùng nó làm chỉ số tra vào bảng syscall (sys_call_table) — một mảng con trỏ hàm. Ô số 1 trỏ tới sys_write. Kernel gọi handler này, nó ghi 3 byte, rồi đặt số byte đã ghi vào rax làm giá trị trả về, và sysret về user mode.
Q2Sau khi kernel chạy xong sys_write, kết quả đi ngược về chương trình của bạn qua những bước nào? Và nếu bạn open() một file không tồn tại, kernel báo lỗi về bằng cách nào?▸
sys_write đặt số byte đã ghi vào rax làm giá trị trả về. Kernel chạy lệnh sysret để đổi mode bit về user mode và nhảy về đúng chỗ đang dở (địa chỉ đã lưu trong rcx lúc vào). Wrapper glibc đọc rax: số không âm là kết quả bình thường.
Với lỗi, kernel không có kênh riêng — nó mã hoá lỗi ngay trong rax bằng một giá trị âm nhỏ (-errno). Ví dụ open() một file không tồn tại trả về -2 (tức -ENOENT). glibc thấy giá trị âm nhỏ này liền đặt biến errno tương ứng và trả -1 cho bạn — nên bạn phải kiểm tra giá trị trả về rồi đọc errno.
Q3Vì sao vào kernel phải dùng lệnh syscall (một lệnh CPU đặc biệt) tới một điểm vào cố định, chứ không phải một lệnh call/jmp thường tới địa chỉ hàm kernel?▸
Một lệnh call/jmp thường không đổi mode bit: CPU vẫn ở user mode. Ngay khi code kernel chạy lệnh đặc quyền đầu tiên, phần cứng sẽ ném fault (nhớ bài 01) — nên chỉ nhảy tới địa chỉ hàm kernel là vô dụng. Lệnh syscall làm được điều lệnh thường không làm: nguyên tử chuyển mode bit sang kernel mode và nhảy tới địa chỉ kernel đã đăng ký sẵn.
Quan trọng hơn, điểm vào là cố định và duy nhất: user chỉ điều khiển được số hiệu trong rax, và số đó chỉ dùng để tra bảng syscall — một danh sách hữu hạn handler kernel cho phép. Nếu user nhảy được vào giữa một hàm kernel bất kỳ, nó sẽ bỏ qua các bước kiểm tra quyền và đối số. "Một cửa vào duy nhất + tra bảng" chính là nền tảng an ninh của giao diện syscall.
Q4Phân biệt mode switch và context switch. Một system call là loại nào, và khi nào nó kéo theo loại còn lại?▸
Mode switch là CPU đổi giữa user mode và kernel mode trong cùng một tiến trình: chỉ đổi mode bit và lưu vài thanh ghi, không đổi không gian địa chỉ. Context switch là kernel thay hẳn tiến trình đang chạy bằng tiến trình khác: phải lưu/khôi phục toàn bộ thanh ghi và nạp lại page table (cr3), kéo theo xả TLB và làm cache nguội — nên đắt hơn nhiều.
Một system call luôn là mode switch. Nó chỉ kéo theo context switch khi phải chờ: ví dụ read() từ đĩa chưa có dữ liệu ngay, kernel cho tiến trình khác chạy trong lúc chờ. Còn getpid() trả về ngay nên chỉ là mode switch thuần.
Q5Đọc một file 1 MB bằng một lệnh read(buf, 1MB) so với gọi read 1 triệu lần mỗi lần 1 byte — cùng tổng số byte. Vì sao cách sau chậm hơn nhiều?▸
read là một system call, tức một mode switch tốn cỡ hàng trăm ns (lệnh syscall/sysret, lưu/khôi phục thanh ghi, xáo trộn cache/TLB — càng nặng nếu có KPTI). Cách một-lệnh trả cái giá đó đúng một lần cho cả 1 MB. Cách một-triệu-lần trả cái giá đó một triệu lần: chi phí mode switch cộng dồn có thể lớn gấp nhiều lần bản thân việc đọc dữ liệu. Đây chính là lý do các thư viện I/O dùng buffer — gom nhiều thao tác nhỏ của bạn thành ít syscall lớn để khấu hao chi phí vượt biên.Bài tiếp theo: Interrupt, trap & exception — ba lối vào kernel
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên