Mở đầu: một CPU cực nhanh vẫn vô dụng nếu chờ bộ nhớ mãi

Bài 6 xây CPU thực thi ngoài thứ tự cực kỳ tinh vi — nhưng dù có bao nhiêu Reservation Station, mọi lệnh LW/SW (Bài 3) cuối cùng vẫn phải chạm tới bộ nhớ chính (DRAM). Vấn đề: DRAM CHẬM HƠN CPU hàng trăm lần — đây là Memory Wall (bức tường bộ nhớ). Giải pháp không phải làm DRAM nhanh hơn (vật lý không cho phép với giá rẻ), mà là chèn một lớp bộ nhớ SRAM nhỏ, cực nhanh, cực đắt giữa CPU và DRAM — gọi là Cache.

🔍 SRAM và DRAM khác nhau ở đâu — và vì sao khác biệt đó sinh ra cache
Cả bài này dựa trên việc có hai loại bộ nhớ với hai cái giá khác hẳn nhau, nên cần nói rõ ngay.
  • DRAM (dynamic RAM) lưu mỗi bit bằng một tụ điện. Tụ rất nhỏ nên nhét được rất nhiều bit trên một diện tích — RAM máy bạn tính bằng gigabyte là nhờ vậy. Nhưng tụ rò điện, nên phải nạp lại liên tục (chữ "dynamic" là ở đó), và đọc nó là một quá trình tương đối chậm.
  • SRAM (static RAM) lưu mỗi bit bằng khoảng 6 transistor khoá lẫn nhau. Không cần nạp lại ("static"), và đọc gần như tức thì. Đổi lại nó tốn diện tích gấp nhiều lần cho cùng một bit — nên đắt hơn hẳn, và không thể làm dung lượng lớn.
Toàn bộ ý tưởng cache nằm ở chỗ đó: ta không thể làm cả bộ nhớ chính bằng SRAM (quá đắt, quá lớn), và cũng không chịu nổi tốc độ DRAM. Nên ta lấy một lượng SRAM nhỏ và cố đoán xem phần nào của DRAM đáng nằm trong đó — và hai quy luật ở mục 1 chính là cơ sở để đoán.

📚 Điều kiện tiên quyết
Nên đọc Bài 3 (truy cập bộ nhớ LW/SW trong datapath — cache bài này chính là lớp ẩn giữa CPU và bộ nhớ chính mà datapath Bài 3 coi là tức thời).

1. Nút thắt cổ chai bộ nhớ (Memory Wall) & Nguyên lý Locality

Tốc độ CPU tăng nhanh hơn tốc độ DRAM suốt nhiều thập kỷ — khoảng cách này chính là Memory Wall. Trước khi nói cache hoạt động ra sao, hãy nhìn toàn cảnh: bộ nhớ máy tính không phải MỘT thứ, mà là một chồng nhiều tầng, mỗi tầng đổi dung lượng lấy tốc độ theo đúng đánh đổi SRAM/DRAM ở trên:

Thanh ghi (Register) Cache L1 — SRAM Cache L2 — SRAM Cache L3 — SRAM (dùng chung các nhân) Bộ nhớ chính — DRAM Ổ đĩa SSD / HDD ~1 chu kỳ · vài trăm byte ~4 chu kỳ · vài chục KB ~12 chu kỳ · vài trăm KB ~40 chu kỳ · vài chục MB ~200 chu kỳ · vài chục GB hàng triệu chu kỳ · hàng TB chậm dần, rẻ dần, to dần Mỗi tầng là bộ đệm cho tầng ngay dưới nó — con số là bậc độ lớn, không phải thông số một CPU cụ thể
Phân cấp bộ nhớ: mỗi bước xuống dưới rẻ hơn và to hơn khoảng một bậc độ lớn, nhưng cũng chậm hơn chừng đó. Cache tồn tại để phần lớn truy cập dừng lại ở hai tầng xanh trên cùng.

Cache "che giấu" độ trễ DRAM bằng cách khai thác 2 quy luật thống kê gần như LUÔN đúng với chương trình thật:

  • Temporal Locality (cục bộ thời gian) — một địa chỉ VỪA được truy cập có xu hướng được truy cập LẠI trong tương lai gần (vd biến đếm vòng lặp).
  • Spatial Locality (cục bộ không gian) — nếu địa chỉ X vừa được truy cập, các địa chỉ GẦN X (cùng dòng cache) có xu hướng SẮP được truy cập (vd duyệt tuần tự một mảng).
⚠️ Cạm bẫy: duyệt mảng 2D sai chiều phá vỡ Spatial Locality
Mảng 2D lưu theo hàng (row-major, chuẩn C/JS) — duyệt THEO HÀNG truy cập các phần tử NẰM CẠNH NHAU trong bộ nhớ, tận dụng tối đa Spatial Locality. Duyệt THEO CỘT nhảy CÁCH XA mỗi lần (đúng bằng độ dài 1 hàng) — gần như MỌI truy cập đều rơi vào dòng cache KHÁC, phá vỡ hoàn toàn Spatial Locality. Verified thật: mảng 8×8 phần tử 4-byte trên cache 4 dòng×16-byte — duyệt theo HÀNG chỉ 16/64 miss (25%), duyệt theo CỘT MISS TOÀN BỘ 64/64 (100%) — gấp 4 lần tệ hơn, dù tổng số phần tử truy cập giống hệt nhau.

2. Cấu trúc ánh xạ Cache: Direct-Mapped & Set-Associative

Một địa chỉ bộ nhớ được tách thành 3 phần để tra cứu cache (Mục 7.2): Offset (bit thấp nhất, chọn byte TRONG dòng cache), Index (chọn DÒNG/tập trong cache), và Tag (phần còn lại, dùng để XÁC NHẬN đúng dữ liệu đang cần, tránh nhầm với dữ liệu khác cùng index).

Địa chỉ 0x1234, với Offset 4 bit và Index 2 bit: Tag = 0x48 Index = 3 Offset = 4 bit cao — còn lại 2 bit giữa 4 bit thấp nhất Mảng cache (4 dòng): dòng 0 dòng 1 dòng 2 dòng 3 Index CHỌN dòng tag đã lưu 16 byte dữ liệu của dòng Tag SO KHỚP: bằng nhau và valid = HIT, khác = MISS Offset chọn byte trong dòng Ba phần được dùng theo ba việc khác nhau — chỉ Index là địa chỉ của dòng, Tag chỉ để xác nhận
Cùng một địa chỉ, ba vai trò: Offset chọn byte bên trong dòng, Index chọn dòng nào, Tag xác nhận dòng đó đúng là của địa chỉ này chứ không phải của một địa chỉ khác trùng Index.
split_address.js (trích engine dùng chung cpu-core.js)
function splitAddress(address, offsetBits, indexBits) {
  const offset = address & ((1 << offsetBits) - 1);
  const index = (address >>> offsetBits) & ((1 << indexBits) - 1);
  const tag = address >>> (offsetBits + indexBits);
  return { tag, index, offset };
}
// Verified: splitAddress(0x1234, 4, 2) -> offset=0x4; recombining gives back the address

Direct-Mapped: mỗi index ứng với ĐÚNG 1 dòng cache duy nhất — đơn giản, tra cứu cực nhanh, nhưng 2 địa chỉ khác tag CÙNG index sẽ liên tục "đá" nhau ra dù cache còn dư chỗ ở dòng khác (Conflict Miss). Set-Associative N-way: mỗi index ứng với một TẬP chứa N dòng — địa chỉ trùng index nhưng khác tag không còn phải tranh 1 chỗ duy nhất, dùng LRU (Least Recently Used) để quyết định dòng nào bị thay thế khi tập đầy.

conflict_miss_demo.js (verified bằng engine — Direct-Mapped vs 2-way)
// 2 addresses with the SAME index but DIFFERENT tags, alternating 10 times (20 accesses):
// addrA = 0, addrB = numSets * lineSize (same index as A)
const dm = makeDirectMappedCache(4, 4);   // direct-mapped: 20/20 MISS (100%!)
const sa = makeSetAssociativeCache(4, 2, 4); // 2-way: only 2/20 MISS (10%)
// Set-associative holds BOTH addresses at once in the SAME set
⚠️ Cạm bẫy: Set-Associative không phải "miễn phí"
Tăng số "way" giảm Conflict Miss, nhưng mỗi lần truy cập phải so khớp tag với TẤT CẢ N dòng trong tập SONG SONG (mạch so sánh N-way) — tốn diện tích silicon và năng lượng hơn hẳn Direct-Mapped (chỉ so 1 tag). Đây là lý do L1 cache (cần cực nhanh, ít way — thường 4-8 way) khác hẳn L3 cache (chấp nhận chậm hơn để đổi lấy độ liên kết cao hơn, giảm miss — thường 16 way trở lên).

3. Tính toán chỉ số hiệu năng Cache: AMAT

Thời gian truy cập bộ nhớ TRUNG BÌNH (Average Memory Access Time) với 1 cấp cache:

$$AMAT = T_{Hit} + \text{MissRate} \times T_{MissPenalty}$$

Với hệ 2 cấp L1+L2, công thức mở rộng — missRateL2Local là tỷ lệ miss CỤC BỘ của L2 (chỉ tính trên số lần L1 ĐÃ miss, không phải trên tổng số truy cập chương trình):

$$AMAT = T_{HitL1} + \text{MissRateL1} \times (T_{HitL2} + \text{MissRateL2}_{local} \times T_{MissPenalty\_DRAM})$$

Verified thật (ví dụ kinh điển Patterson & Hennessy): $T_{HitL1}=1$, MissRateL1=2%, $T_{HitL2}=10$, MissRateL2 CỤC BỘ=25%, phạt truy cập DRAM=200 chu kỳ → $AMAT = 1 + 0,02 \times (10 + 0,25 \times 200) = \mathbf{2,2}$ chu kỳ.

amat.js (trích engine dùng chung cpu-core.js)
function amat(hitTime, missRate, missPenalty) {
  return hitTime + missRate * missPenalty;
}
function amatTwoLevel(hitTimeL1, missRateL1, hitTimeL2, missRateL2Local, missPenaltyMem) {
  return hitTimeL1 + missRateL1 * (hitTimeL2 + missRateL2Local * missPenaltyMem);
}
// Verified: amat(1, 0.05, 100) = 6
// Verified: amatTwoLevel(1, 0.02, 10, 0.25, 200) = 2.2 (the classic P&H example)
⚠️ Miss Rate cục bộ (Local) ≠ Miss Rate toàn cục (Global)
MissRateL2 CỤC BỘ (25% ở ví dụ trên) chỉ đo trên số lần L1 ĐÃ MISS — không phải trên TỔNG số truy cập chương trình. Miss Rate TOÀN CỤC của L2 (tỷ lệ truy cập chương trình phải chạm tới DRAM) = MissRateL1 × MissRateL2Local = $0,02 \times 0,25 = 0,005$ (0,5%) — một con số HOÀN TOÀN khác 25%. Nhầm lẫn 2 tỷ lệ này khi báo cáo hiệu năng hệ thống nhớ nhiều cấp là lỗi cực kỳ phổ biến.
locality_demo.js (verified bằng engine — duyệt hàng vs duyệt cột)
// An 8x8 array of 4-byte elements, cache of 4 lines x 16 bytes/line (64 bytes total)
const rowMajorAddrs = []; // walk: for row { for col { addrOf(row,col) } }
const colMajorAddrs = []; // walk: for col { for row { addrOf(row,col) } }

runCacheTrace(makeDirectMappedCache(4, 4), rowMajorAddrs); // 16/64 miss (25%)
runCacheTrace(makeDirectMappedCache(4, 4), colMajorAddrs); // 64/64 miss (100%!)
// The SAME elements are accessed - only the ORDER differs - yet 4x the miss rate

4. Khi CPU GHI: Write-Through & Write-Back

Mọi thứ từ đầu bài tới đây chỉ nói về ĐỌC. Nhưng chương trình thật cũng ghi — mỗi lệnh sw (store word) là một lần ghi. Và câu hỏi này không có câu trả lời hiển nhiên: CPU ghi vào một địa chỉ ĐANG nằm trong cache, thì DRAM bên dưới có được cập nhật ngay không?

Hai lựa chọn, và chúng chia đôi mọi thiết kế cache trên đời:

  • Write-Through — ghi vào cache VÀ ghi thẳng xuống DRAM ngay lập tức. DRAM luôn đúng, đơn giản, không cần theo dõi gì thêm. Đổi lại: MỌI lệnh ghi đều tốn một chuyến đi xuống DRAM.
  • Write-Back — chỉ ghi vào cache, và bật một bit đánh dấu gọi là dirty bit trên dòng đó (nghĩa là "dòng này đã bị sửa, DRAM đang lạc hậu"). Chỉ khi dòng đó bị THAY THẾ (hoặc lúc kết thúc) mới thật sự ghi xuống DRAM. Ghi 100 lần vào cùng một biến chỉ tốn ĐÚNG 1 chuyến xuống DRAM.

Còn một câu hỏi con: nếu ghi mà MISS (địa chỉ chưa có trong cache) thì sao? Write-Allocate nạp dòng đó lên cache rồi mới ghi (hợp với write-back, vì các lần ghi tiếp theo sẽ hit); No-Write-Allocate ghi thẳng xuống DRAM, không nạp gì (hợp với write-through, vì có nạp lên cũng chẳng tiết kiệm được lần ghi nào). Đó là lý do hai cặp này gần như luôn đi cùng nhau.

Đây là chỗ dễ hiểu sai nhất, nên hãy đo thay vì tin. Kịch bản: cộng dồn vào MỘT biến 10 lần — mỗi vòng một lệnh đọc rồi một lệnh ghi vào cùng địa chỉ, tổng 20 lần truy cập:

write_policy_demo.js (verified bằng engine — through vs back)
// Accumulate into ONE variable 10 times: read then write, same address
const accesses = [];
for (let i = 0; i < 10; i++) {
  accesses.push({ address: 0, isWrite: false }); // lw
  accesses.push({ address: 0, isWrite: true });  // sw
}

runWriteTrace(makeWritePolicyCache(4, 2, 4, { writePolicy: 'through' }), accesses);
// -> { hits: 19, misses: 1, memWrites: 10 }   every store goes down to DRAM

runWriteTrace(makeWritePolicyCache(4, 2, 4, { writePolicy: 'back' }), accesses);
// -> { hits: 19, misses: 1, memWrites: 1 }    one dirty line, written back once

// Same hit rate. 10x the DRAM traffic. That is the whole trade-off.
⚠️ Cạm bẫy: chính sách ghi KHÔNG đổi hit rate — đừng đo nhầm chỗ
Nhìn kỹ hai dòng kết quả: cả hai đều 19 hit / 1 miss. Giống hệt nhau. Nếu bạn đánh giá write-back bằng hit rate, bạn sẽ kết luận nó chẳng cải thiện gì — và bỏ luôn một thiết kế giảm lưu lượng DRAM 10 lần. Chính sách ghi không nằm ở trục hit/miss; nó nằm ở trục bao nhiêu byte thật sự phải đi xuống DRAM. Chọn sai thước đo thì thiết kế đúng trông như vô dụng.
🔍 Cái giá thật của Write-Back: DRAM có lúc SAI
Write-Back nhanh hơn vì nó cho phép DRAM lạc hậu so với cache trong một khoảng thời gian. Với một CPU một nhân chạy một chương trình thì không ai biết. Nhưng thêm nhân thứ hai — có cache L1 riêng — thì nhân đó đọc DRAM và thấy giá trị cũ. Thêm một card mạng tự đọc bộ nhớ (DMA) thì nó gửi đi dữ liệu cũ. Đây là gốc rễ của cache coherence (nhất quán cache), và là lý do CPU nhiều nhân phải có cả một giao thức (MESI) chỉ để các cache nói chuyện với nhau. Bài này dừng ở một nhân — nhưng nên biết cái giá đó tồn tại, chứ không phải write-back là bữa trưa miễn phí.

5. Không phải miss nào cũng giống nhau: phân loại 3C

Tới đây ta đã gặp hai kịch bản cùng hỏng nặng: duyệt theo cột miss 100%, và hai địa chỉ tranh nhau cũng miss 100%. Nhưng đó là hai căn bệnh KHÁC NHAU, và chữa bằng hai cách khác nhau. Gộp chung vào một con số "miss rate" là vứt bỏ đúng cái thông tin cần để sửa. Cách phân loại chuẩn (Hill, 1989) chia miss làm ba loại — quen gọi là 3C:

  • Compulsory (bắt buộc) — lần ĐẦU TIÊN chương trình chạm tới một block. Không cache nào tránh được, kể cả cache vô hạn: dữ liệu chưa từng được nạp thì phải nạp. Giảm bằng dòng cache to hơn hoặc prefetch (nạp trước khi cần).
  • Capacity (dung lượng) — block ĐÃ từng nằm trong cache nhưng bị đá ra vì tập dữ liệu đơn giản là lớn hơn cache. Chữa bằng cache TO HƠN, không có cách nào khác.
  • Conflict (xung đột) — cache vẫn còn chỗ trống, nhưng block bị đá ra vì tranh đúng cái set đó với block khác. Chữa bằng tăng ASSOCIATIVITY — chính là hiện tượng Mục 2 đã đo.

Đo bằng cách so cache thật với hai cache tưởng tượng: cache vô hạn (chỉ còn compulsory) và cache fully-associative cùng dung lượng (compulsory + capacity). Phần dôi ra chính là conflict:

three_c_demo.js (verified bằng engine — cùng miss rate, khác chẩn đoán)
// The conflict trace from section 2, on a direct-mapped cache
classifyMisses(conflictSeq, 4, 1, 4);
// -> { total: 20, compulsory: 2, capacity: 0, conflict: 18 }   almost all conflict
classifyMisses(conflictSeq, 4, 2, 4);
// -> { total:  2, compulsory: 2, capacity: 0, conflict:  0 }   2-way cured it

// The column-major walk from section 1, SAME cache, also ~100% miss
classifyMisses(colMajorAddrs, 4, 1, 4);
// -> { total: 64, compulsory: 16, capacity: 48, conflict: 0 }  NOT conflict at all
// More associativity would fix nothing here. Only a bigger cache would.
⚠️ Cạm bẫy: cùng một tỷ lệ miss, hai cách chữa ngược nhau
Hai kịch bản trên đều cho miss rate rất cao trên CÙNG một cache. Nếu chỉ nhìn con số đó, phản xạ tự nhiên là "tăng associativity lên 2-way". Với chuỗi conflict thì đúng — 18 conflict miss biến sạch. Với chuỗi duyệt theo cột thì vô ích hoàn toàn: 0 conflict miss, 48 miss là capacity, tăng bao nhiêu way cũng không cứu được. Cái thật sự cứu nó nằm ở tận Mục 1 — đổi thứ tự duyệt để lấy lại Spatial Locality, và miss tụt từ 64 xuống 16. Phân loại 3C tồn tại chính vì lý do đó: nó cho biết nên sửa PHẦN CỨNG hay sửa PHẦN MỀM.

6. Thực hành: Cache L1 Simulator

Bộ tách địa chỉ bên dưới tính trực tiếp Tag/Index/Offset từ một địa chỉ hex tuỳ chỉnh. Bộ mô phỏng Hit/ Miss chạy 4 kịch bản đã verify ở trên — đổi giữa Direct-Mapped và Set-Associative để tự tay thấy Conflict Miss biến mất:

Ba thứ đáng thử, không chỉ nhìn: (1) giữ nguyên địa chỉ 0x1234 rồi đổi Offset bits từ 4 xuống 2 — Index và Tag đổi theo thế nào, và vì sao dòng cache nhỏ đi lại làm Tag dài ra? (2) chạy kịch bản duyệt theo CỘT rồi đọc vết Hit/Miss: không có lấy một chữ H nào, đúng như phân loại 3C đã chỉ ra đó là capacity miss. (3) chuyển giữa hai kịch bản conflict cuối và so vết: chỗ chuỗi M M M M… biến thành M M H H H… chính là khoảnh khắc way thứ hai bắt đầu giữ được cả hai địa chỉ.

🗄️ Bộ tách địa chỉ & Mô phỏng Cache Hit/Miss

Bộ tách Tag/Index/Offset

Mô phỏng Hit/Miss

Tóm lược

  • ✅ Cache che giấu Memory Wall bằng cách khai thác Temporal & Spatial Locality — không làm DRAM nhanh hơn, mà giảm số lần cần CHẠM tới DRAM.
  • ✅ Verified: duyệt mảng theo HÀNG (locality tốt) chỉ 25% miss; theo CỘT (locality mất) MISS 100% — gấp 4 lần tệ hơn dù cùng số phần tử.
  • ✅ Direct-Mapped đơn giản nhưng dễ Conflict Miss (verified 100% miss với 2 địa chỉ trùng index); 2-way Set-Associative giải quyết gần như hoàn toàn (verified chỉ 10% miss).
  • ✅ Verified: $AMAT$ 2 cấp (2% L1 miss, L2 hit=10, 25% L2 cục bộ, phạt DRAM=200) = 2,2 chu kỳ.
  • ✅ Pitfall: Miss Rate cục bộ (25%) và toàn cục (0,5%) là 2 con số khác hẳn nhau — đừng nhầm lẫn.
  • ✅ Chính sách ghi: verified cùng một chuỗi truy cập, write-through ghi xuống DRAM 10 lần còn write-back chỉ 1 lần — với hit rate GIỐNG HỆT NHAU (19/20). Đo write-back bằng hit rate là đo nhầm trục.
  • ✅ Phân loại 3C: verified chuỗi conflict = 2 compulsory + 18 conflict (2-way xoá sạch), còn duyệt theo cột = 16 compulsory + 48 capacity + 0 conflict — cùng miss rate cao, hai cách chữa ngược nhau.

Còn một mắt xích bị giấu kín suốt cả bài. Cache tra cứu bằng Index và Tag cắt ra từ địa chỉ — nhưng địa chỉ nào? Chương trình của bạn chỉ biết địa chỉ ảo do hệ điều hành cấp, trong khi DRAM chỉ hiểu địa chỉ vật lý. Ai dịch giữa hai thứ đó, dịch lúc nào, và nếu bản thân việc dịch cũng cần tra một bảng nằm trong bộ nhớ thì chẳng phải mỗi lần truy cập lại tốn thêm một chuyến đi DRAM nữa hay sao? Đó đúng là vấn đề Bài 8 giải, bằng một cái cache thứ hai chuyên để cache việc dịch địa chỉ — khối TLB.

Trắc nghiệm ôn tập

Câu 1

Verified: duyệt mảng 8×8 theo HÀNG chỉ 25% miss, theo CỘT MISS 100%. Vì sao chênh lệch lớn vậy dù cùng số phần tử được truy cập?

Câu 2

Verified: 2 địa chỉ trùng index nhưng khác tag, xen kẽ 20 lần — Direct-Mapped MISS 100% (20/20), Set-Associative 2-way chỉ MISS 10% (2/20). Vì sao?

Câu 3

Verified: $AMAT = 1 + 0,02 \times (10 + 0,25 \times 200) = 2,2$ chu kỳ. Tỷ lệ 25% trong công thức là MissRateL2 CỤC BỘ. Miss Rate TOÀN CỤC của L2 (trên tổng số truy cập chương trình) là bao nhiêu?

Câu 4

Vì sao L1 cache thường dùng ít "way" hơn (vd 4-8 way) trong khi L3 cache có thể dùng 16 way trở lên?

Câu 5

Verified: cùng chuỗi 10 lần đọc-rồi-ghi vào MỘT biến, write-through ghi xuống DRAM 10 lần, write-back chỉ 1 lần — nhưng cả hai đều 19 hit / 1 miss. Kết luận đúng là gì?

Câu 6

Verified: chuỗi duyệt theo CỘT miss 64/64, phân loại 3C ra 16 compulsory + 48 capacity + 0 conflict. Vậy nâng cache từ Direct-Mapped lên 8-way sẽ cải thiện được bao nhiêu?

Tải file code thực hành minh họa bài học

File JavaScript CPUJS — thư viện kiến trúc máy tính mini dùng xuyên suốt cả 12 bài, Bài 7 vừa thêm splitAddress(), makeDirectMappedCache(), makeSetAssociativeCache(), amat(), amatTwoLevel(), makeWritePolicyCache(), runWriteTrace(), classifyMisses() — tách địa chỉ, mô phỏng cache Direct-Mapped/Set-Associative (LRU), công thức AMAT nhiều cấp, chính sách ghi write-through/write-back kèm dirty bit, và phân loại 3C, kèm self-test đối chiếu đúng mọi con số trong bài (chạy node cpu-core.js, không cần cài thêm gì):

Tải về cpu-core.js

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 6: Song Song Cấp Lệnh & Thực Thi Ngoài Thứ Tự (Tomasulo) Bài 8: Bộ Nhớ Ảo & Khối TLB Quay lại Lộ trình Kiến Trúc Máy Tính

Bình luận