Skip to content

Kernel Space, User Space Interfaces

Linux tách bộ nhớ và quyền thực thi thành hai không gian: kernel space, nơi kernel và driver chạy với toàn quyền trên phần cứng, và user space, nơi ứng dụng chạy với quyền hạn chế. Ứng dụng không thể gọi thẳng một hàm trong kernel hay đọc một biến của driver; mọi trao đổi phải đi qua một interface mà kernel định nghĩa sẵn.

Trang này trình bày ranh giới giữa hai không gian, cách dữ liệu đi qua ranh giới đó, và so sánh các interface để chọn đúng công cụ cho từng bài toán. Chi tiết từng nhóm interface nằm ở các trang con.

1. Ranh giới kernel space và user space

1.1. Privilege level

CPU hỗ trợ nhiều mức đặc quyền. Linux chỉ dùng hai mức:

Kiến trúc User space Kernel space
x86_64 Ring 3 Ring 0
arm64 EL0 EL1 (EL2 nếu chạy VHE)
RISC-V U-mode S-mode

Ở mức user, CPU chặn các lệnh đặc quyền (cấu hình MMU, tắt interrupt, truy cập thanh ghi hệ thống) và chặn truy cập vào các trang nhớ đánh dấu chỉ dành cho kernel. CPU chuyển từ user sang kernel trong ba trường hợp:

  • System call: ứng dụng chủ động gọi bằng lệnh syscall (x86_64) hoặc svc #0 (arm64).
  • Exception: ví dụ page fault, chia cho 0, lệnh không hợp lệ.
  • Interrupt: phần cứng báo sự kiện, không liên quan đến tiến trình đang chạy.

Mọi interface trong bộ tài liệu này, từ đọc file trong /sys, gửi message netlink đến thiết lập mmap, đều bắt đầu bằng một system call.

1.2. Chia không gian địa chỉ

Mỗi tiến trình có một không gian địa chỉ ảo riêng, nhưng nửa kernel thì giống nhau ở mọi tiến trình. Với cấu hình phổ biến (page table 4 mức, VA 48 bit):

Kiến trúc User space Kernel space
x86_64 0x0000000000000000 – 0x00007fffffffffff (128 TiB) từ 0xffff800000000000
arm64 0x0000000000000000 – 0x0000ffffffffffff, qua TTBR0_EL1 từ 0xffff000000000000, qua TTBR1_EL1

Nhờ vậy, khi vào kernel qua system call, kernel vẫn "nhìn thấy" bộ nhớ của tiến trình vừa gọi. Đây là điều kiện để kernel copy dữ liệu từ buffer của ứng dụng.

1.3. Vì sao kernel không được dereference trực tiếp con trỏ từ user

Khi ứng dụng gọi read(fd, buf, n), buf là một địa chỉ ảo do user cung cấp. Về mặt kỹ thuật, kernel có thể viết *buf = x, nhưng làm vậy là sai vì bốn lý do:

  1. Địa chỉ có thể không hợp lệ. Trang có thể chưa được map, đã bị swap ra đĩa, hoặc chỉ cho phép đọc. Một page fault không được xử lý trong kernel sẽ gây oops.
  2. Địa chỉ có thể trỏ vào kernel. Nếu ứng dụng truyền một địa chỉ thuộc nửa kernel, một driver dereference trực tiếp sẽ vô tình đọc hoặc ghi bộ nhớ kernel thay cho ứng dụng. Đây là lỗ hổng leo thang đặc quyền kinh điển.
  3. Phần cứng chặn. SMAP (x86) và PAN (arm64) cấm kernel truy cập trang của user, trừ khi kernel mở một "cửa sổ" rõ ràng. Code dereference trực tiếp sẽ fault ngay trên CPU hiện đại.
  4. Dữ liệu có thể thay đổi bất kỳ lúc nào. Một thread khác trong cùng tiến trình có thể sửa buffer trong lúc kernel đang đọc. Nếu kernel đọc một giá trị hai lần (lần đầu để kiểm tra, lần sau để dùng), kẻ tấn công có thể đổi giá trị ở giữa. Lỗi này gọi là double fetch.

Vì vậy, kernel luôn truy cập bộ nhớ user qua các hàm uaccess. Các hàm này kiểm tra dải địa chỉ, mở cửa sổ SMAP/PAN, và đăng ký lệnh truy cập vào exception table để page fault được chuyển thành lỗi -EFAULT thay vì oops.

Trong code kernel, con trỏ trỏ vào bộ nhớ user được đánh dấu __user. Công cụ sparse (make C=1) cảnh báo khi một con trỏ __user bị dereference trực tiếp hoặc bị gán lẫn với con trỏ kernel.

2. Truyền dữ liệu qua ranh giới

2.1. Các hàm uaccess

Khai báo trong <linux/uaccess.h>:

Hàm Dùng cho Giá trị trả về
copy_from_user(to, from, n) Copy n byte từ user vào kernel Số byte không copy được; 0 là thành công
copy_to_user(to, from, n) Copy n byte từ kernel ra user Như trên
get_user(x, ptr) Đọc một biến đơn (1/2/4/8 byte), kiểu suy ra từ ptr 0 hoặc -EFAULT
put_user(x, ptr) Ghi một biến đơn 0 hoặc -EFAULT
strncpy_from_user(dst, src, count) Copy chuỗi kết thúc bằng '\0' Độ dài chuỗi, hoặc -EFAULT; bằng count nghĩa là chưa gặp '\0'
memdup_user(src, len) kmalloc() rồi copy Con trỏ kernel, hoặc ERR_PTR(-EFAULT/-ENOMEM)
memdup_user_nul(src, len) Như trên, thêm '\0' ở cuối Như trên
copy_struct_from_user(dst, ksize, src, usize) Struct có thể mở rộng theo phiên bản (dùng bởi openat2(), clone3()) 0, -EFAULT hoặc -E2BIG
access_ok(addr, size) Chỉ kiểm tra dải địa chỉ thuộc user space true/false

Một số lưu ý:

  • copy_*_user(), get_user(), put_user() đã tự gọi access_ok(). Không cần gọi thêm, và không nên dùng các biến thể có hai dấu gạch dưới (__copy_from_user(), __get_user()) vì chúng bỏ qua bước kiểm tra này.
  • access_ok() chỉ kiểm tra địa chỉ có nằm trong nửa user hay không, không đảm bảo trang đã được map. Từ kernel 5.0, hàm nhận hai tham số; tham số type (VERIFY_READ/VERIFY_WRITE) đã bị bỏ.
  • Bộ nhớ user có thể đang nằm trên đĩa, nên các hàm uaccess có thể ngủ để chờ nạp trang. Không gọi chúng khi đang giữ spinlock hoặc trong interrupt context. Bật CONFIG_DEBUG_ATOMIC_SLEEP để might_fault() cảnh báo khi vi phạm.

2.2. Mẫu code chuẩn

Giá trị trả về khác 0 của copy_*_user() là số byte còn lại, không phải mã lỗi. Mẫu đúng là chuyển nó thành -EFAULT:

struct foo_config cfg;

if (copy_from_user(&cfg, argp, sizeof(cfg)))
    return -EFAULT;

/* Từ đây chỉ làm việc trên bản sao cfg trong kernel. */
if (cfg.mode > FOO_MODE_MAX)
    return -EINVAL;

Mẫu này cũng giải quyết vấn đề double fetch: kernel copy dữ liệu một lần vào bộ nhớ của mình, rồi kiểm tra và dùng bản sao đó. Không bao giờ kiểm tra trên buffer user rồi copy lại lần nữa để dùng.

Chiều ngược lại, khi trả dữ liệu ra user, phải đảm bảo không có byte nào chưa khởi tạo trong struct, kể cả các byte padding do compiler chèn vào. Nếu không, copy_to_user() sẽ làm lộ nội dung cũ của stack kernel ra ngoài. Cách an toàn là memset() struct về 0 trước khi điền, hoặc thiết kế struct không có padding (xem ABI, bảo mật và độ ổn định).

2.3. Kiểm chứng: kernel xử lý con trỏ sai như thế nào

Driver mẫu vux_demo ở trang Device File và ioctl dùng copy_to_user() trong lệnh VUX_DEMO_GET_STATS. Chương trình test cố tình truyền vào hai con trỏ sai: địa chỉ 0x1 (không được map) và một địa chỉ thuộc nửa kernel. Kết quả khi chạy trên kernel 6.8 (QEMU x86_64):

bad ptr   : Bad address
kernel ptr: Bad address

Cả hai trường hợp đều trả về -EFAULT (Bad address), kernel không oops và không bị đọc/ghi nhầm vùng nhớ.

3. Bảng so sánh các interface

Interface Chiều dữ liệu Mô hình Phù hợp cho Ổn định ABI
System call U → K, kết quả trả về Đồng bộ Dịch vụ chung của kernel, không gắn với một thiết bị Rất cao; thêm mới phải qua review mainline
Device file read/write Hai chiều Đồng bộ, blocking hoặc non-blocking Luồng byte của thiết bị Do driver định nghĩa
ioctl Hai chiều Đồng bộ Lệnh điều khiển có cấu trúc Là ABI, khai báo qua UAPI header
sysfs Hai chiều Đồng bộ; có thể poll() một attribute Thuộc tính một giá trị: trạng thái, cấu hình Là ABI, mô tả trong Documentation/ABI/
procfs Chủ yếu K → U Đồng bộ Thông tin tiến trình và hệ thống Là ABI; hạn chế thêm mục mới
debugfs Hai chiều Đồng bộ Chỉ để debug Không có cam kết
configfs U → K Đồng bộ User tạo và cấu hình object trong kernel Là ABI
mmap Dùng chung bộ nhớ Không copy Dữ liệu lớn, zero-copy, thanh ghi thiết bị Bố cục bộ nhớ là ABI
poll/epoll, signal, eventfd K → U Bất đồng bộ Báo dữ liệu sẵn sàng hoặc sự kiện Theo interface gốc
uevent K → U, broadcast Bất đồng bộ Hotplug thiết bị Là ABI
Netlink Hai chiều, có multicast Đồng bộ và bất đồng bộ Networking; subsystem cần kernel chủ động gửi sự kiện Là ABI, mở rộng được nhờ attribute
io_uring Hai chiều qua ring buffer Bất đồng bộ I/O hiệu năng cao, ít system call Là ABI
eBPF User nạp chương trình vào kernel Theo sự kiện Tracing, networking, security policy Helper và map là ABI
UIO, VFIO User điều khiển thiết bị trực tiếp Thanh ghi qua mmap, interrupt qua read Driver ở user space Là ABI

4. Chọn interface nào?

Không có interface "tốt nhất"; mỗi loại sinh ra cho một dạng bài toán. Có thể chọn theo các câu hỏi sau, đi từ trên xuống:

  1. Chỉ phục vụ debug, không cho ứng dụng thật dùng? Dùng debugfs. Không có cam kết ABI nên có thể thay đổi tùy ý.
  2. Đọc hoặc ghi một giá trị đơn lẻ của thiết bị (trạng thái, ngưỡng, chế độ)? Dùng sysfs, mỗi file một giá trị. Có thể thao tác bằng cat/echo, không cần viết chương trình.
  3. Truyền một luồng dữ liệu (đọc mẫu ADC, ghi frame ra UART)? Dùng read/write trên device file.
  4. Gửi lệnh điều khiển có tham số cấu trúc, hoặc cần một thao tác nguyên tử gồm nhiều tham số? Dùng ioctl.
  5. Khối lượng dữ liệu lớn, cần tránh copy (buffer DMA, frame video)? Dùng mmap.
  6. Kernel cần chủ động báo sự kiện?
    • Báo cho tiến trình đang mở device file: poll/epoll (kèm read để lấy dữ liệu).
    • Báo cho toàn hệ thống khi thiết bị xuất hiện hoặc biến mất: uevent.
    • Gửi message có cấu trúc, nhiều bên nhận, hoặc giao tiếp hai chiều phức tạp: netlink.
  7. Thêm một dịch vụ mới cho toàn hệ thống, không gắn với thiết bị? Cân nhắc system call mới. Đây là lựa chọn cuối cùng vì phải qua review rất kỹ và không bao giờ được gỡ bỏ.

Một driver thật thường kết hợp nhiều interface. Ví dụ, một driver ADC có thể dùng sysfs để chỉnh sample rate, read() hoặc mmap() để lấy dữ liệu, poll() để báo dữ liệu sẵn sàng, và debugfs để dump thanh ghi.

5. Các trang con

Tham khảo

  • Kernel Space - User Space Interfaces HOWTO (TLDP)
  • Kernel source: include/linux/uaccess.h, Documentation/arch/x86/x86_64/mm.rst, Documentation/arch/arm64/memory.rst
  • Linux Device Drivers, 3rd Edition (LDD3), chương 3 và 6
  • The Linux Programming Interface (TLPI), chương 3