Thiết bị nói chuyện với CPU — MMIO, thanh ghi, PCIe
Lời hứa treo từ bài von Neumann: I/O có địa chỉ riêng. Bài này mở ra — thanh ghi thiết bị, memory-mapped I/O so với port I/O, và cây thiết bị trên bus PCIe.
TL;DR: CPU không có lệnh riêng để "nói chuyện với ổ cứng"; nó chỉ biết đọc và ghi địa chỉ. Vậy nên thiết bị bày ra trước CPU vài ô nhớ đặc biệt gọi là thanh ghi thiết bị: ghi vào một ô là ra lệnh, đọc ô khác là xem trạng thái. Cách phổ biến nhất để những ô đó có địa chỉ là memory-mapped I/O: thiết bị chiếm hẳn một dải trong không gian địa chỉ vật lý, và driver điều khiển nó bằng đúng lệnh load/store thường ngày. PCIe là cái cây nối chúng về CPU — không phải bus dùng chung, mỗi thiết bị có đường riêng và băng thông riêng.
Chạy lspci trên bất kỳ máy Linux nào, bạn thấy một danh sách hai chục dòng: cầu nối, card mạng, ổ NVMe, GPU. Mở tiếp /proc/iomem, bạn gặp lại chúng dưới dạng những khoảng địa chỉ. Cùng một tấm card, ở file thứ nhất là một thiết bị, ở file thứ hai là một dải số.
Bài mô hình von Neumann từng nói qua rằng I/O có không gian địa chỉ riêng rồi gác lại. Đây là chỗ mở nó ra.
1. Analogy — bảng điều khiển của một cỗ máy
Hình dung một cỗ máy đặt trong xưởng, còn bạn ngồi ở phòng điều khiển cách đó ba chục mét. Thứ duy nhất nối hai bên là một bảng gắn trên tường phòng bạn: vài công tắc để ra lệnh, vài bóng đèn báo trạng thái, một khay giấy để máy đẩy kết quả ra. Bật công tắc, nhìn đèn, đèn xanh thì với tay lấy tờ giấy.
| Trong phòng điều khiển | Trong máy tính |
|---|---|
| Công tắc ra lệnh | Thanh ghi lệnh (control register) |
| Bóng đèn báo | Thanh ghi trạng thái (status register) |
| Khay giấy nhận kết quả | Thanh ghi dữ liệu (data register) |
| Vị trí bảng trên tường | Địa chỉ vật lý được gán cho thiết bị |
| Dây nối phòng với xưởng | Bus PCIe |
| Chuông kêu khi mẻ xong | Ngắt (bài sau) |
CPU không "gửi lệnh" cho thiết bị. Nó chỉ ghi vào một địa chỉ, mà ở địa chỉ đó tình cờ không phải RAM, là phần cứng đang lắng nghe.
2. Thiết bị hiện ra trước CPU dưới hình hài gì?
Tập lệnh của CPU không có lệnh send_to_disk. Nó có mov, load, store, chấm hết. Mọi cuộc trò chuyện với phần cứng vì thế đều phải quy về đọc hoặc ghi một địa chỉ.
Thiết bị đáp lại bằng cách bày ra một nhóm nhỏ ô nhớ gọi là thanh ghi thiết bị (device register). Chúng trông như bộ nhớ nhưng không phải bộ nhớ: mỗi ô nối thẳng vào mạch logic bên trong thiết bị, nên chạm vào ô đó gây ra chuyện. Ghi vào thanh ghi lệnh là ra lệnh; đọc thanh ghi trạng thái để biết thiết bị bận, xong hay lỗi; thanh ghi dữ liệu là nơi đặt dữ liệu vào, hoặc nơi ghi địa chỉ RAM để thiết bị tự tới lấy.
Điểm khiến chúng khác RAM nằm ở đây: đọc cùng một địa chỉ hai lần có thể ra hai giá trị khác nhau, và ghi vào một địa chỉ có thể làm thiết bị quay ổ hoặc xoá sạch một hàng đợi. Đây gọi là side effect, và chính nó đẻ ra loạt hệ quả ở mục sau.
Thử ngẫmbạn đọc thanh ghi trạng thái hai lần liên tiếp trong debugger và thấy hai giá trị khác nhau. Đây có phải bug không, và vì sao nó khác đọc một biến RAM hai lần?
3. Vì sao memory-mapped I/O thắng port I/O?
Có hai cách gán địa chỉ cho thanh ghi thiết bị, và lịch sử đã chọn một.
Port I/O dựng riêng một không gian địa chỉ thứ hai tách khỏi bộ nhớ, truy cập bằng lệnh chuyên dụng — trên x86 là cặp in và out, với không gian cổng rộng 64 KB. Nó ra đời thời máy 16 bit, khi không gian địa chỉ bộ nhớ còn quá chật để chia cho thiết bị.
Memory-mapped I/O (MMIO) thì đặt thẳng các thanh ghi thiết bị vào không gian địa chỉ vật lý, chung hệ toạ độ với RAM; driver dùng đúng load và store như đọc một biến. Nó thắng vì ba lẽ: 64 KB cổng đủ cho bàn phím nhưng không đủ cho một GPU cần bày ra hàng trăm megabyte; cặp in/out là đặc sản x86, còn ARM và RISC-V không có khái niệm không gian cổng riêng; và quan trọng nhất, khi thanh ghi nằm chung không gian địa chỉ vật lý với RAM thì bộ máy phân trang sẵn có dùng lại được nguyên vẹn — đó chính là việc ioremap() làm.
ioremap() mượn lại nguyên cơ chế của bài đó: tạo một ánh xạ trỏ tới dải địa chỉ vật lý của thiết bị thay vì trỏ tới RAM, rồi đánh dấu vùng ấy là không được cache.
Nhưng dùng lệnh load/store thường không có nghĩa là viết code thường. Vì mỗi lần chạm đều có side effect, trình biên dịch và CPU tuyệt đối không được sắp xếp lại hay gộp các thao tác đó, trong khi với biến bình thường thì cả hai làm việc ấy suốt ngày. Vậy nên kernel Linux bắt đi qua bộ hàm truy cập riêng:
/* Map the device register block into kernel virtual address space. */
void __iomem *regs = ioremap(phys_addr, len);
/* Read the status register, then raise the start bit in the command one. */
u32 status = readl(regs + STATUS_OFFSET);
writel(CMD_START, regs + CMD_OFFSET);
Chú thích __iomem trên con trỏ chính là lời nhắc của kernel: đây không phải bộ nhớ, đừng đối xử với nó như bộ nhớ. Tài liệu kernel nói rõ nhóm hàm readl/writel được định nghĩa là có thứ tự, nghĩa là trình biên dịch không được phép đảo trình tự các thao tác I/O.
4. PCIe — một cái cây, không phải một cái bus
Cái tên "bus PCI" để lại một hiểu lầm dai dẳng: rằng mọi thiết bị cắm chung một đường dây rồi tranh nhau băng thông. Với PCI đời cũ thì đúng thật, PCI Express thì không.

Gốc cây là root complex nằm trong CPU. Từ đó toả ra các liên kết riêng biệt, mỗi liên kết đi thẳng tới một endpoint hoặc tới một switch rồi mới toả tiếp, nên hai thiết bị cắm hai khe khác nhau không giành đường của nhau. Mỗi liên kết gồm một số lane (một cặp dây tín hiệu chạy song song), nên băng thông tính thẳng ra từ tốc độ mỗi lane nhân số lane.
| Thế hệ | Tốc độ mỗi lane | x4 (ổ NVMe) | x16 (GPU) |
|---|---|---|---|
| PCIe 3.0 | 8 GT/s | ≈ 3,94 GB/s | ≈ 15,8 GB/s |
| PCIe 4.0 | 16 GT/s | ≈ 7,88 GB/s | ≈ 31,5 GB/s |
| PCIe 5.0 | 32 GT/s | ≈ 15,8 GB/s | ≈ 63,0 GB/s |
Cả bảng là số học lý thuyết, không phải kết quả đo: lấy tốc độ theo chuẩn, trừ hao mã hoá 128b/130b khoảng 1,5%, rồi nhân số lane. Thực đo luôn thấp hơn. Cách dùng đúng là coi chúng như trần trên: thấy pipeline báo chuyển 20 GB/s trên khe PCIe 3.0 x16 thì biết ngay mình đo sai chỗ nào đó.
Còn một mảnh nữa: thiết bị biết dải địa chỉ MMIO của nó bắt đầu ở đâu bằng cách nào? Nó không tự chọn. Mỗi endpoint khai qua BAR (Base Address Register) rằng nó cần bao nhiêu byte; lúc khởi động, firmware và kernel duyệt cây rồi cấp cho từng thiết bị một khoảng không chồng lấn. Sau bước này /proc/iomem mới có nội dung để đọc:
# Cay thiet bi PCIe, va chi tiet mot endpoint (vung BAR, IRQ, toc do lien ket)
lspci -tv
lspci -v -s 00:1f.2
Với một ổ NVMe dung lượng vài terabyte, dòng Memory at ... trong lspci -v mô tả vùng nhớ cỡ bao nhiêu? Viết con số bạn tin là đúng, kèm đơn vị và lý do, trước khi đọc tiếp.
Thường chỉ vài chục kilobyte, dù ổ chứa hàng terabyte. Lý do là BAR không bày dữ liệu ra — nó chỉ bày bảng điều khiển: thanh ghi cấu hình, con trỏ tới hàng đợi lệnh, ô để driver báo có lệnh mới. Dữ liệu thật chuyển thẳng giữa ổ và RAM bằng DMA, đúng chủ đề bài sau.
5. Pitfall của riêng concept này
❌ Nhầm 1 — coi thanh ghi thiết bị như một biến bình thường.
✅ Đọc bằng con trỏ trần thì trình biên dịch có toàn quyền giữ giá trị trong thanh ghi CPU rồi bỏ hẳn lần đọc sau, vì theo nó thì chẳng ai ghi vào ô nhớ ấy cả — vòng lặp chờ cờ "xong" quay vô tận. Đó là lý do kernel bắt đi qua readl/writel với con trỏ __iomem.
❌ Nhầm 2 — tưởng ghi xong là thiết bị đã nhận.
✅ Tài liệu kernel nói thẳng rằng ghi ra bus PCI là ghi treo (posted write): lệnh được xác nhận ngay khi rời CPU, không đợi tới đích. Muốn chắc, driver phải đọc ngược lại một thanh ghi của chính thiết bị đó. Đây là họ hàng của chuyện write() trả về mà dữ liệu chưa xuống đĩa ở bài fsync, chỉ khác tầng.
❌ Nhầm 3 — tưởng PCIe là bus dùng chung, cắm thêm card là chậm cả máy.
✅ Mỗi liên kết là đường riêng, băng thông tính theo lane của chính khe cắm. Chỗ thật sự có thể nghẽn là switch hoặc root complex phía trên.
6. 📚 Đào sâu (tuỳ chọn)
- Bus-Independent Device Accesses — docs.kernel.org — nguồn chính chủ cho
ioremap(), nhóm hàmreadb/readl/writel, chú thích__iomem, và mục posted write. - How To Write Linux PCI Drivers — docs.kernel.org — vòng đời driver PCI, kèm cảnh báo đừng đọc địa chỉ MMIO thẳng từ config space.
7. Liên hệ các bài khác
- Mô hình von Neumann — nơi đặt ra lời hứa rằng I/O có không gian địa chỉ riêng; bài này trả lời nó.
- Phân trang và bảng trang — cơ chế mà
ioremap()mượn lại để đưa dải địa chỉ của thiết bị vào không gian ảo của kernel. - Polling, interrupt và DMA — trả lời câu bỏ ngỏ ở cuối mục 4: nếu dữ liệu không đi qua MMIO thì nó đi đường nào.
- Host và device — vì sao chép dữ liệu đắt hơn tính — lấy chính bảng băng thông PCIe ở mục 4 làm trần trên để chẩn đoán pipeline GPU.
8. Tóm tắt
- Chạm vào thanh ghi thiết bị có side effect, còn chạm vào RAM thì không; mọi khác biệt về cách viết driver đều mọc ra từ một câu đó.
- MMIO thắng port I/O vì 64 KB cổng quá chật, vì
in/outchỉ có trên x86, và vì nó cho phép dùng lại nguyên bộ máy phân trang. - BAR là cách endpoint xin chỗ; firmware và kernel mới là bên cấp phát địa chỉ thật lúc khởi động.
- Bảng băng thông PCIe là trần lý thuyết suy từ chuẩn — dùng để bắt lỗi phép đo, đừng dùng như số kỳ vọng.
9. Tự kiểm tra
- Q1Một driver viết vòng lặp chờ cờ báo xong bằng cách dereference thẳng con trỏ trỏ vào thanh ghi trạng thái. Code biên dịch sạch, không cảnh báo, nhưng chạy thì treo vô tận. Cơ chế nào gây ra chuyện đó?
- Q2Vì sao vùng MMIO mà một ổ NVMe khai qua BAR chỉ cỡ vài chục kilobyte, trong khi ổ chứa hàng terabyte dữ liệu? Dữ liệu thật đi bằng đường nào?
- Q3Đồng nghiệp lo rằng cắm thêm một card mạng vào khe PCIe x4 sẽ làm chậm GPU đang ở khe x16, vì 'chúng dùng chung bus'. Lập luận này sai ở đâu, và có phần nào đúng không?
- Q4Driver ghi lệnh 'bắt đầu truyền' vào thanh ghi lệnh của card mạng, hàm ghi trả về ngay, rồi driver lập tức báo lên tầng trên là đã gửi xong. Cách làm này sai chỗ nào?
- Q5Vì sao kiến trúc hiện đại bỏ hẳn không gian cổng I/O riêng và dồn tất cả vào memory-mapped I/O, dù cách cũ có ưu điểm là tách bạch bộ nhớ với thiết bị?
Bài tiếp theo: Polling, interrupt và DMA — ai tốn CPU khi chép dữ liệu
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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