I/O, Lưu trữ & Tài nguyên/Thiết bị nói chuyện với CPU — MMIO, thanh ghi, PCIe
11/26
Bài 11 / 26~13 phútĐường đi I/O & GPUMiễn phí lượt xem

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ểnTrong máy tính
Công tắc ra lệnhThanh ghi lệnh (control register)
Bóng đèn báoThanh 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ưởngBus PCIe
Chuông kêu khi mẻ xongNgắt (bài sau)
💡 Cách nhớ

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.

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 inout, 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 loadstore 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.

Cay PCIe voi be dai thanh ve theo bang thong ly thuyet cua tung khe

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 lanex4 (ổ NVMe)x16 (GPU)
PCIe 3.08 GT/s≈ 3,94 GB/s≈ 15,8 GB/s
PCIe 4.016 GT/s≈ 7,88 GB/s≈ 31,5 GB/s
PCIe 5.032 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
Thử đoán

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)

📚 Nguồn kỹ thuật

7. Liên hệ các bài khác

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/out chỉ 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

Tự kiểm tra
Q1
Mộ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 đó?

Trình biên dịch nhìn ô nhớ ấy như mọi ô nhớ khác: trong vòng lặp không có lệnh nào ghi vào nó, nên nó kết luận giá trị bất biến, đọc một lần rồi giữ trong thanh ghi CPU và bỏ hẳn các lần đọc sau. Cờ có đổi ngoài đời thật thì code cũng không nhìn thấy. Gốc rễ là thanh ghi thiết bị bị phần cứng thay đổi sau lưng phần mềm, còn mô hình tối ưu của trình biên dịch lại giả định điều ngược lại. Cách đúng là dùng readl() trên con trỏ __iomem, nhóm hàm mà tài liệu kernel bảo đảm giữ đúng thứ tự và không bị lược bỏ.

Q2
Vì 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?

BAR không bày dữ liệu ra cho CPU đọc, 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 — vài chục kilobyte là thừa cho ngần ấy thứ. Dữ liệu thật không đi qua CPU: driver đặt sẵn mô tả lệnh trong RAM, ghi một giá trị nhỏ qua MMIO để đánh thức ổ, rồi ổ tự đọc mô tả và chuyển thẳng dữ liệu giữa nó với RAM bằng DMA. Nếu mọi byte đều phải chui qua thanh ghi thì CPU sẽ ngồi chép suốt ngày, đúng vấn đề mà bài sau mổ.

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?

Sai ở giả định nền: PCI Express là mạng điểm-nối-điểm hình cây chứ không phải bus dùng chung như PCI đời cũ, nên mỗi khe có liên kết riêng với số lane riêng và card bốn lane không lấy bớt băng thông của mười sáu lane bên kia. Phần đúng nằm ở phía trên — nếu hai nhánh cùng qua một switch hoặc cùng dồn về root complex thì điểm hợp lưu đó vẫn có trần của nó. Muốn biết có nghẽn thật không thì đo tổng lưu lượng tại đúng điểm hợp lưu, đừng suy từ chuyện hai card cùng nằm trong một máy.

Q4
Driver 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?

Ghi ra bus PCI là ghi treo: tài liệu kernel nói rõ lệnh được xác nhận từ lúc rời CPU chứ không đợi tới thiết bị, nên hàm ghi trả về chỉ chứng minh lệnh đã lên đường. Muốn có bảo đảm, driver phải đọc ngược lại một thanh ghi của chính thiết bị đó — thao tác đọc buộc phải chạy hết đường rồi mang giá trị quay về, nên nó đẩy luôn mọi lệnh ghi treo phía trước tới nơi.

Q5
Vì 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ị?

Ba lý do cộng lại: 64 KB không gian cổng quá chật cho thiết bị cần bày ra hàng trăm megabyte; cặp inout là đặc sản x86 nên driver viết theo cổng không mang sang ARM được; và khi thanh ghi nằm chung không gian địa chỉ vật lý với RAM thì cơ chế phân trang sẵn có dùng lại được nguyên vẹn. Cái giá đúng là mất sự tách bạch — nhìn một lệnh store không biết ngay nó chạm RAM hay chạm phần cứng — nên kernel bù lại bằng con trỏ __iomem đánh dấu ranh giới ngay trong code.

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

Đặt 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

Bài tiếp theo

Polling, interrupt và DMA — ai tốn CPU khi chép dữ liệu