Chương trình biến thành lệnh CPU như thế nào?
Lập trình viên viết code bằng chữ. CPU chỉ nhận bit. Ở giữa chuyện gì xảy ra?
Ở bài trước, ta đã có khối số học và logic (ALU). Cùng hai số đầu vào, chỉ cần đổi vài bit điều khiển là ALU cộng, trừ, AND hay OR.
Nhưng những bit điều khiển đó từ đâu ra?
Người viết chương trình không gõ bit. Họ viết:
const result = 5 + 3;
CPU không nhìn thấy chữ result, dấu + hay số 5 như ta. Bên trong nó chỉ có transistor, không có chỗ nào đọc được chữ.
Vậy từ dòng chữ này tới những bit CPU làm theo, chuyện gì xảy ra?
Trước đây, đổi chương trình là cắm lại dây
Những máy tính điện tử đời đầu chưa giữ chương trình trong bộ nhớ như máy ngày nay.
ENIAC làm được khoảng 5.000 phép cộng mỗi giây. Nhưng ban đầu, muốn nó giải một bài toán khác, người vận hành phải tính lại cách nối, cắm lại cáp giữa các khối và gạt lại công tắc. Việc đó mất nhiều ngày.
Máy tính rất nhanh. Nhưng mỗi lần đổi việc, con người lại phải cấu hình lại máy.
Muốn đổi việc nhanh, phải để mạch điện đứng yên và chỉ đổi thứ mô tả công việc. Thứ đó phải là bit, vì mạch chỉ đọc được bit.
Nhưng mạch đứng yên thì chỉ làm được những việc nó đã được thiết kế để làm.
CPU chỉ biết một danh sách lệnh cố định
CPU không hiểu trực tiếp JavaScript, Python hay C. Mạch bên trong nó chỉ làm sẵn được một số thao tác nhỏ, chẳng hạn:
- đưa một số vào thanh ghi
- cộng, trừ
- đọc hoặc ghi bộ nhớ
- so sánh
- nhảy sang một lệnh khác
Mỗi thao tác như vậy là một lệnh máy (machine instruction). Danh sách mọi lệnh một CPU hiểu được gọi là tập lệnh (instruction set).
Mỗi lệnh ứng với một mạch thật trong chip, như mạch cộng trong ALU. Vì thế tập lệnh cố định: muốn thêm lệnh thì phải làm chip mới.
Khoá học dùng một CPU giả lập rất nhỏ tên TinyCPU.
Nó có một thanh ghi 8 bit tên R0. Đây là nơi TinyCPU giữ con số đang tính. Mọi phép tính đều đi qua thanh ghi này:
┌──────┐
LOAD 5 ──►│ │
ADD 3 ──►│ R0 │──► STORE 8
SUB 1 ──►│ │
└──────┘
Kiểu thiết kế dùng một thanh ghi chính như vậy gọi là kiến trúc tích luỹ (accumulator). TinyCPU dùng cách này vì nó dễ nhìn.
Ở bước này, ta chỉ cần bốn lệnh:
| Lệnh | Mã lệnh | Tác dụng |
|---|---|---|
NẠP (LOAD n) |
0001 |
đặt R0 = n |
CỘNG (ADD n) |
0010 |
R0 = R0 + n |
LƯU (STORE a) |
0011 |
ghi R0 vào ô nhớ a |
TRỪ (SUB n) |
0100 |
R0 = R0 − n |
Chẳng hạn, LOAD 5 đưa số 5 vào R0. Lệnh tiếp theo, ADD 3, lấy giá trị đang có trong R0, cộng thêm 3 rồi ghi kết quả trở lại R0.
Vậy hai lệnh:
LOAD 5
ADD 3
làm R0 thay đổi: 0 → 5 → 8.
Tên LOAD và ADD chỉ dành cho con người đọc. CPU vẫn cần chúng dưới dạng bit.
Một lệnh cũng chỉ là một dãy bit
Mỗi lệnh của TinyCPU dài đúng 8 bit, chia làm hai nửa:
- 4 bit đầu là mã thao tác (opcode): CPU phải làm gì?
- 4 bit sau là toán hạng (operand): làm với số nào, hoặc ô nhớ nào?
Ví dụ, lệnh ADD 3 là:
0010 0011
──── ────
│ └─ toán hạng: 3
└────── mã thao tác: ADD
Chính 4 bit 0010 là thứ khiến mạch điều khiển trong CPU chọn phép cộng của ALU. Bit điều khiển ở đầu bài đến từ đây.
Mô phỏng dưới đây có sẵn ba lệnh của phép 5 + 3. Đổi ADD sang SUB, chỉnh con số, và để ý nửa nào của 8 bit thay đổi. Bấm Chạy để xem R0 đi qua từng lệnh.
0010 0011 cộng 3 vào R0, phép cộng do ALU làm
Tối đa 6 dòng. Xoá bớt một dòng để thêm.
- 00 LOAD 5 0001 0101
- 01 ADD 3 0010 0011
- 02 STORE 8 0011 1000
Bấm Chạy để xem R0 đổi qua từng lệnh.
Đổi lệnh thì chỉ 4 bit đầu đổi. Đổi số thì chỉ 4 bit sau đổi.
Riêng với STORE, 4 bit sau không phải con số để tính mà là địa chỉ ô nhớ.
Một lệnh chỉ làm được một việc nhỏ. Phép 5 + 3 cần nhiều hơn một lệnh.
Vậy 5 + 3 trở thành những lệnh nào?
TinyCPU chỉ có một thanh ghi R0. Muốn tính 5 + 3 và giữ lại kết quả, nó cần ba bước: nạp 5 vào R0, cộng thêm 3, rồi cất R0 vào ô nhớ 8.
| Hợp ngữ | Mã máy |
|---|---|
LOAD 5 |
0001 0101 |
ADD 3 |
0010 0011 |
STORE 8 |
0011 1000 |
Cột trái là hợp ngữ (assembly): vẫn dùng tên LOAD, ADD, STORE để người đọc dễ hiểu.
Cột phải là mã máy (machine code). CPU không nhìn thấy ADD 3. Nó chỉ nhận 0010 0011.
Một dòng 5 + 3 đã nở ra thành ba lệnh. Một ứng dụng thật có vô số dòng như thế, và không ai ngồi ghép bit cho từng dòng.
Ai biến code thành mã máy?
Một chương trình khác làm việc đó. Hình dưới vẽ con đường từ dòng code tới những bit nằm trong bộ nhớ.
- Code bạn viết const result = 5 + 3;
- Compiler hoặc runtime dịch sang lệnh máy
- Lệnh máy LOAD 5
ADD 3
STORE 8 - Bit nằm trong RAM 0001 0101
0010 0011
0011 1000
Với C, C++ hay Rust, trình biên dịch (compiler) thường dịch cả chương trình ra mã máy trước khi chạy.
Các ngôn ngữ khác đi đường vòng hơn:
- JavaScript thường được bộ máy JavaScript, như V8 trong Chrome, phân tích và biên dịch trong lúc chương trình đang chạy.
- Python phổ biến (CPython) dịch mã nguồn thành một dạng lệnh trung gian gọi là mã byte (bytecode). Môi trường chạy Python đọc những lệnh đó và thực hiện.
Chi tiết khác nhau, nhưng điểm đến giống nhau:
mọi phép tính thật sự trên CPU đều phải trở thành lệnh trong tập lệnh của chính CPU đó.
Chữ “của chính CPU đó” rất quan trọng, vì không phải CPU nào cũng có cùng tập lệnh.
Vì sao cùng một ứng dụng có bản cho ARM và bản cho x86?
Chip trong điện thoại thường dùng tập lệnh ARM. Nhiều máy tính Windows dùng tập lệnh x86-64.
Cùng một việc “cộng hai số”, nhưng hai tập lệnh mã hoá nó thành những dãy bit khác nhau. Dãy bit đúng với CPU này có thể vô nghĩa với CPU kia.
Vì vậy cùng một chương trình thường phải được dịch riêng cho từng loại CPU. Nhiều phần mềm phát hành hai bản: một cho ARM, một cho x86.
Đến đây, chương trình đã thành mã máy đúng loại CPU. Câu hỏi còn lại: đặt chuỗi bit đó ở đâu để CPU chạy được, mà đổi chương trình không phải cắm lại dây như ENIAC?
Chương trình nằm trong bộ nhớ, cạnh dữ liệu
Lệnh đã là bit thì có thể cất vào RAM, giống hệt một con số.
Ba lệnh tính 5 + 3 nằm trong RAM của TinyCPU như sau:
ô 0: 0001 0101 ← LOAD 5
ô 1: 0010 0011 ← ADD 3
ô 2: 0011 1000 ← STORE 8
Muốn chạy chương trình khác? Không cần nối lại transistor, không cần thay dây trong ALU. Chỉ cần ghi một chuỗi bit khác vào bộ nhớ.
Ý tưởng này gọi là chương trình lưu trong bộ nhớ (stored program). Nó nảy ra ngay trong lúc nhóm ở Đại học Pennsylvania đang dựng ENIAC, và được mô tả trong bản báo cáo về máy EDVAC năm 1945.
Nói ngắn gọn:
Chương trình là một chuỗi lệnh máy, mỗi lệnh là một dãy bit nằm trong bộ nhớ như dữ liệu.
Nhưng cất chung như vậy kéo theo một chuyện: RAM không dán nhãn ô nào là lệnh, ô nào là số.
Bit không tự nói mình có nghĩa gì
Nhìn dãy bit 0100 0001. Nó là gì?
- Đọc như một số nguyên: 65.
- Đọc theo bảng mã ký tự ASCII: chữ A.
- Đọc theo tập lệnh TinyCPU:
0100là SUB,0001là 1, tức lệnh SUB 1.
Mô phỏng dưới giữ nguyên 8 bit đó. Bấm lần lượt từng cách đọc và để ý xem 8 bit có đổi chữ số nào không.
65
Đọc như một số không dấu: 64 + 1 = 65.
"A"
Trong bảng mã ASCII, 65 là chữ A viết hoa. Văn bản bạn đang đọc được lưu theo cách này, với bảng mã rộng hơn.
65 / 255
Một điểm ảnh xám, sáng khoảng một phần tư: 0 là đen, 255 là trắng.
SUB 1
Trong tập lệnh của TinyCPU: 0100 là SUB, 0001 là 1.
cờ 0 và cờ 6 bật
Mỗi bit là một cờ riêng, ví dụ tám tuỳ chọn trong phần cài đặt.
Tám bit không đổi, nhưng nghĩa thì đổi theo cách đọc.
Ý nghĩa của bit nằm ở mạch nào đang đọc nó và đọc để làm gì.
Khi CPU lấy bit ra từ chỗ nó đang tìm lệnh, các bit đó là lệnh. Khi một lệnh đọc ô nhớ để lấy số, cùng những bit đó là một con số.
Vì thế CPU phải luôn biết mình đang ở ô nào. Đọc nhầm ô dữ liệu thành lệnh là máy chạy sai ngay.
Chuỗi bit vẫn đang nằm yên
Đến đây, dòng 5 + 3 đã đi được một quãng dài:
code của lập trình viên
↓
trình biên dịch / môi trường chạy
↓
LOAD 5 · ADD 3 · STORE 8
↓
0001 0101 · 0010 0011 · 0011 1000
↓
RAM
Ba lệnh đã nằm trong RAM. Nhưng RAM chỉ là nơi giữ bit, nó không tự chạy gì cả.
CPU làm sao biết phải bắt đầu ở ô 0, rồi chuyển từ ô 0 sang ô 1, ô 2?