Có một dòng code kinh điển làm người mới học C ngã ngửa: char *s = "hello"; s[0] = 'H'; — biên dịch trót lọt, chạy thì chết ngay với "Segmentation fault". Không có vòng lặp, không truy cập ngoài mảng, chỉ đổi một chữ cái. Thủ phạm là một cơ chế phần cứng lặng lẽ đứng sau mọi lần ghi bộ nhớ: bit quyền trên mỗi trang. Bài này ghi vào vài trang chỉ đọc thật trong container để xem chuyện gì xảy ra — và vấp đúng chỗ mà trực giác "ghi vào trang chỉ đọc thì chết" tưởng là hiển nhiên lại hóa ra chỉ đúng một nửa.

Trang chỉ đọc

Bit quyền nằm trên mỗi trang

Bộ nhớ ảo được chia thành các trang 4KB. Mỗi trang, ngoài chuyện ánh xạ sang đâu trong RAM vật lý, còn mang theo một bộ bit quyền: đọc được không, ghi được không, chạy được không. Đây không phải quy ước mềm của trình biên dịch mà là thứ MMU (đơn vị quản lý bộ nhớ) của CPU ép cứng trên từng lần truy cập. Mỗi lần chương trình đọc hoặc ghi một địa chỉ, phần cứng tra bảng trang, thấy quyền không cho phép là lập tức bẫy vào kernel — không có cách nào lách.

Ta xem tận mắt các bit đó qua /proc/self/maps. Tôi mmap ba trang với ba quyền khác nhau rồi in dòng maps tương ứng:

r-xp 00000000 00:00 0    <- PROT_READ|PROT_EXEC  (đọc + chạy)
rw-p 00000000 00:00 0    <- PROT_READ|PROT_WRITE (đọc + ghi)
r--p 00000000 00:00 0    <- PROT_READ           (chỉ đọc)

Cột bốn ký tự rwxp chính là bit quyền. Trang r--p cho đọc nhưng không cho ghi; trang r-xp cho đọc và chạy (đây là nơi mã lệnh .text của chương trình nằm) nhưng cũng cấm ghi. Khi bạn ghi vào một trang thiếu chữ w, CPU bẫy vào trình xử lý lỗi trang (page fault handler) của kernel, và kernel quyết định làm gì tiếp theo. Điểm mấu chốt của cả bài nằm ở chữ "quyết định" đó — ta sẽ quay lại.

Đo: ghi vào trang chỉ đọc thì chết

Tôi dựng ba tình huống ghi vào trang chỉ đọc, mỗi cái bắt tín hiệu bằng một trình xử lý SIGSEGV để in ra địa chỉ gây lỗi thay vì chỉ thấy chương trình chết trơ.

Một, trang mmap với PROT_READ. Đọc ô đầu tiên: trả về 0, êm ru. Ghi vào chính ô đó:

ĐỌC ro[0] = 0            (đọc thì OK)
GHI -> SIGSEGV tại 0xffffb6b3e000

Địa chỉ gây lỗi trùng khít đầu trang mmap — phần cứng chặn đúng lần ghi.

Hai, trang vốn ghi được, rồi hạ quyền bằng mprotect. Tôi cấp một trang PROT_READ|PROT_WRITE, ghi rw[0]=42 trót lọt, rồi gọi mprotect(rw, 4096, PROT_READ) để biến nó thành chỉ đọc giữa chừng, và ghi lại:

ghi rw[0]=42 OK                 (lúc còn rw-p)
GHI sau mprotect RO -> SIGSEGV tại 0xffffb6b3d000

Cùng một địa chỉ, cùng một dòng lệnh ghi, chỉ khác cái bit quyền vừa bị hạ — từ chạy được thành chết. Quyền là của trang tại thời điểm ghi, không phải của biến con trỏ.

Ba, chuỗi literal. char *lit = "hello"; lit[0] = 'H';

ghi lit[0]='H' -> SIGSEGV tại 0x400be8

Chú ý địa chỉ: 0x400be8 — rất thấp, nằm trong vùng nạp từ chính file thực thi, không phải vùng heap/mmap cao chót vót như hai ca trên. Đó là đoạn .rodata (read-only data): linker gom mọi hằng chuỗi vào một vùng được ánh xạ chỉ đọc. Chuỗi "hello" sống ở đó, nên ghi vào là đụng trang r--p và chết y hệt.

Cả ba đều kết thúc bằng cùng một tín hiệu: SIGSEGV, số 11, do MMU sinh ra khi lần ghi vi phạm bit quyền. Đến đây câu chuyện có vẻ gọn gàng: ghi vào trang chỉ đọc thì chết. Tôi suýt chốt bài ở đúng câu đó.

Một lần tôi đo hớ: cùng cái bẫy, hai kết cục

Trước khi viết, tôi định phát biểu một quy tắc chắc nịch: "ghi vào trang write-protected luôn luôn gây SIGSEGV — đó là ý nghĩa của chỉ đọc". Nhưng có một tình huống tôi đã đo ở bài về sao chép khi ghi mà đáng lẽ phải nhớ ngay: sau fork, các trang bộ nhớ được đánh dấu chỉ đọc ở cả cha lẫn con để chia sẻ tạm — và khi một bên ghi vào, chương trình không chết.

Tôi đo lại để chắc. Một trang rw chứa chuỗi "cha", rồi fork, rồi cho tiến trình con ghi đè "con" lên chính trang đó:

Trước fork: buf='cha'
Sau khi con ghi: [cha] buf vẫn = 'cha'

Con ghi được — không SIGSEGV. Cha đọc lại vẫn thấy "cha" nguyên vẹn, chứng tỏ con đã ghi lên một bản sao riêng. Nhưng khoan: ngay sau fork, trang đó đang là chỉ đọc (kernel hạ quyền ghi để hai bên chia sẻ chung một bản vật lý). Lần con ghi vào cũng là một cú "ghi vào trang write-protected", bẫy vào kernel y như ba ca SIGSEGV ở trên. Vậy tại sao lần này không chết?

cùng một sự kiện phần cứng, kernel lại có kế hoạch khác. Khi bẫy lỗi trang bật lên, trình xử lý của kernel nhìn xem trang này vì sao chỉ đọc:

  • Nếu là trang ta cấm ghi thật (mmap PROT_READ, mprotect RO), hoặc .rodata do linker đặt — kernel không có kế hoạch nào cho lần ghi này, nên gửi SIGSEGV giết tiến trình.
  • Nếu là trang COW tạm khóa sau fork — kernel copy trang ra một bản riêng, cấp quyền ghi cho bản đó, rồi cho lệnh ghi chạy tiếp như chưa có gì xảy ra.

Bẫy write-protect chỉ là một tín hiệu; chính kernel mới quyết định giết hay âm thầm sửa. "Ghi vào trang chỉ đọc" hóa ra không phải một sự việc mà là hai tình huống dùng chung một cơ chế MMU, cho hai kết cục trái ngược. Bài học đo lường: một câu khẳng định gọn gàng như "ghi vào trang chỉ đọc thì chết" giấu mất câu hỏi quan trọng — chỉ đọc vì lý do gì. Tôi suýt viết một quy tắc đúng cho ba trường hợp mình vô tình chọn, rồi ngoại suy thành "luôn luôn", trong khi một trường hợp thứ tư ngay trong tầm tay lại lật ngược nó. Đo thêm một biến thể của cùng hiện tượng, thay vì tin vào ba mẫu đầu, mới thấy ranh giới thật.

const của C không phải quyền ghi của trang

Có một nhầm lẫn liên quan đáng gỡ luôn: nhiều người tưởng từ khóa const trong C là thứ chặn việc ghi. Không phải. const chỉ là lời hứa với trình biên dịch — nó giúp bắt lỗi lúc biên dịch và cho phép tối ưu, nhưng không đặt bit quyền nào lên trang cả. Bạn hoàn toàn có thể ép kiểu bỏ const (*(char*)p = 'x') và trình biên dịch sẽ ngậm ngùi cho qua.

Chuyện gì xảy ra sau đó thì do trang quyết định, không do const. Ép kiểu bỏ const rồi ghi vào một biến const nằm trên stack (trang rw-p) sẽ chạy trơn tru; làm y hệt với một hằng chuỗi .rodata (trang r--p) sẽ chết bằng SIGSEGV. Cùng một từ khóa const, cùng một thao tác ghi, hai kết cục — vì hai vùng đó nằm trên hai loại trang khác quyền. Đây lại chính là mẫu hình của cả bài: thứ quyết định là bit quyền phần cứng trên trang, không phải khái niệm ở tầng ngôn ngữ. Muốn biết một con trỏ có ghi được hay không, đừng đoán qua kiểu C của nó — hãy tra cột quyền của trang tương ứng trong /proc/<pid>/maps.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là đọc đúng một SIGSEGV. Segmentation fault không phải lỗi "trỏ lung tung" mơ hồ; nó là MMU báo bạn vừa truy cập một trang theo cách trang đó không cho phép. Biết vậy thì cách gỡ đổi hẳn: xem địa chỉ gây lỗi (kernel ghi trong dmesg, hoặc bắt bằng SA_SIGINFO như bài này) rồi đối chiếu với /proc/<pid>/maps để biết địa chỉ đó rơi vào vùng nào và quyền gì. Địa chỉ thấp trùng .rodata thường là ghi vào hằng chuỗi; địa chỉ 0 hoặc gần 0 là con trỏ NULL; địa chỉ trong vùng r-xp là ghi đè lên mã. Mỗi loại chỉ ra một loại lỗi khác nhau.

Hệ quả thứ hai là bit quyền là một lá chắn an ninh, không chỉ là bẫy lỗi. Việc đoạn mã .text nằm trên trang r-xp (đọc + chạy, cấm ghi) khiến kẻ tấn công không dễ ghi đè mã đang chạy. Cơ chế W^X ("ghi hoặc chạy, không cả hai") mà các hệ điều hành hiện đại áp dụng chính là dùng bit quyền trang: một trang hoặc ghi được, hoặc chạy được, không bao giờ cùng lúc — chặn cả một họ khai thác chèn shellcode. mprotect mà ta dùng để đo cũng chính là công cụ để một chương trình tự siết quyền vùng nhớ nhạy cảm của mình.

Hệ quả thứ ba là hiểu vì sao COW, mmap file, và trang lười lại rẻ. Cùng cơ chế "trang chỉ đọc + bẫy lỗi trang" là nền cho hàng loạt tối ưu: fork không copy toàn bộ bộ nhớ mà chỉ khóa chỉ đọc rồi copy dần khi ai đó ghi; mmap một file khổng lồ trả về ngay mà chưa đọc byte nào, chỉ nạp trang khi bạn chạm tới. Con số mang theo: ghi vào trang chỉ đọc luôn bẫy vào kernel, nhưng kết cục — SIGSEGV hay copy-rồi-chạy-tiếp — do kernel quyết định dựa trên vì sao trang đó chỉ đọc. Cùng một tín hiệu phần cứng phục vụ cả việc báo lỗi lẫn việc tối ưu lặng lẽ.

Thử ba mươi giây

Chạy cat /proc/self/maps trên máy Linux của bạn (hoặc trong một container) và nhìn cột bốn ký tự rwxp ở mỗi dòng. Tìm vùng r-xp — đó là mã chương trình, đọc và chạy được nhưng cấm ghi; tìm vùng r--p — dữ liệu chỉ đọc như hằng chuỗi; tìm rw-p — heap và stack, nơi ghi được. Rồi thử đoán trước: một char *s = "x"; trỏ vào vùng nào, và char buf[8]; trên stack trỏ vào vùng nào? Ghi vào cái nào thì chết, cái nào thì không? Chỉ cần cột rwxp là bạn trả lời được — và đó đúng là câu mà MMU trả lời hàng triệu lần mỗi giây, trên từng lần chương trình chạm vào bộ nhớ.