So sánh các lựa chọn thiết kế khác nhau trong hợp ngữ của ba tập lệnh quan trọng của bộ vi xử lý.
Trong thế giới PC, vi xử lý x86 của AMD và Intel chiếm ưu thế. Trên máy tính bảng và điện thoại thông minh, chip ARM của Qualcomm và Apple chiếm ưu thế. RISC-V là tập lệnh bộ vi xử lý mới mà các công ty khác nhau đang bắt đầu sử dụng.
Vì vậy, đối với những người quan tâm đến lập trình lắp ráp, tôi nghĩ tôi sẽ thực hiện một so sánh giữa các chip này, về cách chúng xử lý các hoạt động phổ biến và hợp lý cho sự khác biệt của chúng.
Chiều dài hướng dẫn
Đối với các bộ vi xử lý RISC như ARM và RISC-V thì đơn giản là như vậy. Mỗi lệnh đều dài 32 bit (4 byte). Điều này rất phổ biến đối với các bộ vi xử lý RISC: ARM, MIPS, RISC-V và PowerPC đều sử dụng các lệnh 32-bit có độ dài cố định.
Tuy nhiên, đừng nhầm lẫn điều này với việc nó là bộ vi xử lý 64 bit hay 32 bit. Bộ vi xử lý 64 bit thường sẽ có các thanh ghi 64 bit mà nó có thể hoạt động. Tuy nhiên, bản thân các hướng dẫn thường vẫn sẽ là 32-bit. Lý do rất đơn giản: Bạn thường không cần nhiều không gian như 64-bit cho một lệnh và nếu bạn có lệnh dài đó, bạn sẽ tăng gấp đôi yêu cầu bộ nhớ cho mã nhị phân của mình.
Tuy nhiên có một số ngoại lệ cho điều này. Bộ vi xử lý AVR là bộ xử lý RISC 8-bit được sử dụng hầu hết trong các bộ vi điều khiển như Arduino phổ biến cho những người yêu thích. Nó có hướng dẫn 16-bit. Trên thực tế, bạn có thể gỡ bỏ tập lệnh RISC giới hạn trên 16 bit. Đó là lý do tại sao nhiều kiến trúc CPU bao gồm ARM, MIPS và RISC-V đều hỗ trợ các lệnh 16-bit. Chúng tôi gọi đây là các hướng dẫn nén. CPU vẫn đọc từng bit 32-bit. Nhưng khi nó được nhận, CPU có thể xác định rằng đó là một lệnh nén và thổi phồng nó lên thành hai lệnh 32-bit bình thường.
Các CPU CISC như x86 là một sự khác biệt lớn ở đây. Hướng dẫn của họ không có độ dài cố định. Đối với các lệnh x86 có thể dài từ 1 đến 15 byte. Trên thực tế về lý thuyết, một lệnh x86 có thể có độ dài vô hạn, nhưng xử lý các lệnh dài vô hạn là không thực tế. Do đó, cả Intel và AMD đều đặt ra một giới hạn thực tế và từ chối xử lý các lệnh được mã hóa dài hơn 15 byte. Những người viết trình biên dịch biết điều này và tất nhiên sẽ tránh xuất các hướng dẫn dài hơn thế này.
Toán hạng và sổ đăng ký
Toán hạng là đầu vào của một lệnh hợp ngữ. Chúng ta có thể xem xét một số hướng dẫn khá phổ biến như cộng, trừ và nhân. Hãy lưu ý mọi thứ sau khi ;
thường là một nhận xét trong mã Assembly. Nó không phải là một phần của hướng dẫn. Tôi sẽ thêm các nhận xét nhỏ đằng sau mỗi hướng dẫn để giải thích những gì nó làm.
ADD x1, x4, x6 ; x1 ← x4 + x6
SUB x1, x4, x6 ; x1 ← x4 - x6
MUL x2, x4, x6 ; x1 ← x4 × x6
ADD eax, ebx ; eax ← eax + ebx
SUB eax, ebx ; eax ← eax - ebx
IMUL eax, ebx ; eax ← eax × ebx
Bởi vì x86 có một di sản lâu đời từ bộ vi xử lý Intel 8086 16 bit. Do đó, các lệnh đầu tiên đã được thực hiện để xử lý 16-bit và 8-bit. Nhiều hậu tố và tiền tố khác nhau đã được thêm vào trong những năm qua để đối phó với các thanh ghi lớn hơn. Vì vậy, các thanh ghi 16-bit gốc nơi được gọi là ax
, bx
, cx
, dx
, si
, di
, bp
, rsp
.
Vì vậy, để đối phó với 32-bit, chúng tôi đã nhận các thanh ghi có tên eax
, eax
v.v. là các phiên bản dài 32-bit.
Một sự khác biệt nữa là các lệnh x86 chỉ nhận hai toán hạng. Điều này có thể dễ giải thích hơn bằng cách giải thích tại sao các lệnh RISC có xu hướng nhận 3 toán hạng.
Vì RISC là tất cả các hướng dẫn chiều rộng cố định, không có ý nghĩa gì nếu không sử dụng tất cả không gian có sẵn. Bạn cần 32 bit để có thể vừa với một địa chỉ có kích thước hợp lý, chẳng hạn như địa chỉ 16 bit. Vì vậy, khi chỉ hoạt động trên các thanh ghi thay vì địa chỉ bộ nhớ, chúng ta có ít nhất 16-bit có sẵn để mã hoá các thanh ghi.
Chúng ta có thể có hai toán hạng, cung cấp cho chúng ta 8 bit cho mỗi toán hạng. Nhưng 8-bit là đủ để chỉ định 256 thanh ghi khác nhau (²⁸ = 256). Chúng ta có thực sự cần nhiều như vậy không? Chắc là không. Vì vậy, hầu hết các nhà thiết kế CPU RISC đã quyết định tốt hơn nên sử dụng 5-bit để mã hóa mỗi thanh ghi. Như vậy chúng ta có thể mã hóa ba thanh ghi khác nhau với 15 bit. 5-bit cho chúng ta 32 thanh ghi khác nhau (²⁵ = 32).
Điều này giết chết hai con chim bằng một viên đá. Chúng tôi sử dụng các bit có sẵn cho chúng tôi, nhưng với 3 toán hạng, chúng tôi có thể sắp xếp dữ liệu xung quanh trong thanh ghi dễ dàng hơn nhiều. Điều này làm giảm số lượng các phép toán chúng ta cần thực hiện và số lần đọc và ghi chúng ta cần thực hiện vào bộ nhớ.
Đăng ký đặc biệt
Bộ vi xử lý x86 của Intel có đầy đủ các thanh ghi đặc biệt. Ý của chúng tôi là có một số thao tác nhất định được sử dụng cho các hướng dẫn cụ thể. Ví dụ rsi
và rdi
được sử dụng để lập chỉ mục các hoạt động liên quan. rbp
được sử dụng cho khung ngăn xếp (khu vực nằm cho các biến cục bộ khi gọi một hàm).
Các bộ xử lý RISC khá khác nhau về mặt này. Thông thường hầu hết các thanh ghi là mục đích chung. Đôi khi chúng có một công dụng bổ sung, nhưng chúng có thể được dùng làm toán hạng cho hầu hết mọi lệnh sử dụng thanh ghi mục đích chung làm toán hạng.
Xóa sổ đăng ký
Để các bộ xử lý RISC đơn giản hóa hoạt động, thường có một thanh ghi được chỉ định là thanh ghi zero. Điều này có nghĩa là thanh ghi đó luôn chứa số không. Bạn không thể thay đổi nó. Điều này nghe có vẻ giống như một vụ hack kỳ quặc, nhưng tôi sẽ nói rõ tại sao đây là một giải pháp rất thanh lịch.
Đối với Intel x86, nếu tôi muốn xóa sổ đăng ký (đặt nó thành 0), tôi có thể viết hướng dẫn như sau:
MOV eax, 0 ; eax ← 0
XOR eax, eax ; eax ← eax ⊻ eax
Trên ARM, chúng ta có thể xóa một thanh ghi x8
bằng cách sử dụng thanh ghi số không x31
có bí danh xzr
.
MOV x8, x31 ; x8 ← x31
MOV x8, xzr
MV x1, x4 ; shorthand for ADDI x1, x4, 0
Vì lý do này, chúng tôi thường sử dụng lệnh AND ngay lập tức ANDI
trên RISC-V để xóa sổ đăng ký.
ANDI x8, x8, 0 ; x8 ← x8 & 0
Sao có thể như thế được? Chà, thanh ghi số không tồn tại vật lý trên khuôn silicon ở bất cứ đâu. Nó chỉ là số đăng ký mà bộ giải mã lệnh CPU chọn để diễn giải theo một cách cụ thể.
Thanh lịch của Zero Register
Với thanh ghi số không, chúng ta có thể dễ dàng tạo ra một loạt các hướng dẫn giả hữu ích mà không cần thêm các lệnh bổ sung thực tế vào bộ vi xử lý. Hãy nhớ rằng một chỉ dẫn giả chỉ là một cách viết tắt cho một chỉ dẫn khác.
Ví dụ, nhiều bộ xử lý có một lệnh đặc biệt để tạo một giá trị âm được gọi là NEG
:
NEG x2, x4 ; x2 ← -x4
SUB x2, x0, x4 ; x2 ← x0 - x4 equals 0 - x4
NOP
ADDI x0, x0, 0 ; ; x0 ← x0 + 0
loop:
LI x2, 4 ; Load 4 into x2. Pseudo instruction
BEQZ x2, loop ; IF x2 = 0 GOTO loop
BEQ x2, x0, loop ; if x2 == x0 GOTO loop
Phân nhánh có điều kiện
Phân nhánh là những gì cho phép chúng ta nhảy xung quanh mã của mình, để một số lệnh có thể được lặp lại nhiều lần. Nhánh có điều kiện có nghĩa là một bước nhảy chỉ được thực hiện trong trường hợp một điều kiện nhất định được đáp ứng. Đây là cách chúng tôi triển khai các vòng lặp for, vòng lặp while và các câu lệnh if trong mã hợp ngữ.
Trong trường hợp này x86, ARM và RISC-V là những trường hợp khá thú vị vì chúng đều có cách tiếp cận khá khác nhau đối với phân nhánh có điều kiện.
Trong trường hợp này, thực sự hữu ích khi xem xét RISC-V trước tiên, vì nó phân nhánh theo cách giống với cách hoạt động của các ngôn ngữ lập trình thông thường. Chương trình đơn giản này về cơ bản đếm lên từ 1 đến 12 trong x4
sổ đăng ký.
LI x4, 1 ; x4 ← 1
LI x5, 12 ; x5 ← 12
loop:
ADDI x4, x4, 1 ; x4 ← x4 + 1
BLT x4, x5, loop ; IF x4 < x5 GOTO loop
Lệnh B ranch L ess T han BLT
thực hiện chuyển đến lệnh tại nhãn loop
nếu register x4
< x5
. Mặc dù điều này khá tự nhiên để làm việc, nhưng nó không phải là cách mã lắp ráp hoạt động bình thường.
Thay vào đó, thanh ghi được so sánh với một lệnh riêng biệt khiến các bit trong thanh ghi trạng thái của CPU được thiết lập. Các bit riêng lẻ được gọi là cờ và thường có tên chữ cái đơn lẻ như C, N, Z và V:
- Thực hiện
C
- Khi bạn đã thêm, nhân hoặc thực hiện một thao tác khác tạo ra một số quá lớn để vừa với thanh ghi đích. Nếu đúng như vậy, căn hộ này sẽ được đặt thành 1. - Âm
N
- Kết quả của một phép tính hoặc so sánh cho một số âm. - Không
Z
- Cả hai số bằng nhau, hoặc bằng cách nào đó so sánh hoặc kiểm tra cho ra kết quả là số không. - Tràn
V
- Bạn không thể lưu trữ một dấu âm bên trong thanh ghi CPU. Ví dụ: một số 8 bit không dấu đi từ 0 đến 255, trong khi một số có dấu đi từ -128 đến 127. Vì vậy, nếu bạn thêm hai số 8 bit có dấu và kết quả vượt quá 127, bạn sẽ bị tràn.
MOV ecx, 12
MOV eax, 1
loop:
ADD eax, 1
CMP eax, ecx
JL loop
Trong trường hợp này, chúng tôi so sánh giữa các thanh ghi eax
và ecx
sử dụng C o MP riêng biệt là CMP
lệnh. Nó hoạt động tương tự như phép trừ SUB
nhưng chỉ các thanh ghi trạng thái được thiết lập. Không có kết quả nào được lưu trữ trong bất kỳ toán hạng nào.
Lệnh J ump L ess JL
sẽ xem xét thanh ghi trạng thái để xác định xem có eax
<không ecx
, trước khi thực hiện chuyển đến loop
vị trí.
Mã cho bộ xử lý ARM khá giống nhau. Ngoại trừ ở đây là chúng tôi hiển thị hướng dẫn ARM 32-bit. Trước đây, chúng tôi đã đưa ra các hướng dẫn ARM 64-bit hơi khác một chút:
MOV r4, #1 ; r4 ← 1
MOV r5, #12 ; r5 ← 12
loop:
ADD r4, r4, #1 ; r4 ← r4 + 1
CMP r4, r5 ; status_flag = r4 < r5
BLT loop ; IF < GOTO loop
Bây giờ ví dụ về ARM trông rất giống với x86, nhưng không phải ARM được cho là hoàn toàn khác sao? Có, bởi vì ngoài việc phân nhánh như thế này, ARM có các hướng dẫn có điều kiện.
ARM có thể thực hiện các lệnh đơn có điều kiện
Hãy xem xét ví dụ mã lắp ráp x86 vô nghĩa này với một số hướng dẫn phân nhánh:
CMP AX, 42
JE equal
MOV BX, 12
JMP done
equal:
MOV BX, 33
done:
NOP
Nếu đó là trường hợp, chúng tôi đưa 33 vào đăng ký BX
với lệnh MOV e MOV
. Lưu ý rằng chúng ta cần sử dụng một loạt các bước nhảy để đảm bảo MOV BX, 12
chỉ chạy khi chúng không bằng nhau và không có gì khác.
Với ARM, rất nhiều loại mã này trở nên dễ dàng hơn rất nhiều, do các lệnh có điều kiện.
CMP r6, #42
LDREQ r3, #33
LDRNE r3, #12
Mỗi hướng dẫn trong tập lệnh ARM có thể được thực thi có điều kiện, bằng cách thêm một hậu tố hai chữ cái như EQ
, NE
, GT
, LT
vv, mà tương ứng với các toán tử so sánh =, ≠,>, <.
Vì vậy, một lệnh tải bình thường nên luôn được thực thi được viết LDR
, trong khi một lệnh chỉ nên được thực thi nếu kết quả so sánh cuối cùng là bằng nhau LDREQ
. Một tải cho không bằng nhau sẽ được LDRNE
.
Điều này áp dụng cho bất kỳ hướng dẫn nào. Vì vậy, một phép cộng không điều kiện bình thường được viết ADD
trong khi một phép cộng chỉ nên chạy nếu kết quả cuối cùng không bằng nhau sẽ được viết dưới dạng ADDNE
.
Ngoài ra, chúng ta có thể thêm một S
vào các hoạt động khác nhau để khiến chúng cập nhật thanh ghi có điều kiện.
SUBS r3, r6, #42
ADDEQ r3, #33
ADDNE r3, #12
Tại sao ARM có Thực thi có điều kiện?
Việc phân nhánh là khá tệ đối với một bộ vi xử lý pipelined. Trong các lệnh pipelining được xếp hàng đợi và việc thực thi một lệnh bắt đầu trước khi thực hiện lệnh trước đó. Với một Bước nhảy các hướng dẫn trước đó trong đường ống chỉ là một chất thải. Chúng ta cần xóa bộ nhớ cache. Với thực thi có điều kiện, chúng tôi tránh phân nhánh.
Tất nhiên, đôi khi chúng ta phải nhảy, nhưng điều này làm giảm nhu cầu phân nhánh rất nhiều.
Một số kiến trúc RISC khác thực sự có thực thi có điều kiện.
Đây là một cuộc thảo luận thú vị về thực thi có điều kiện trên ARM. Trong số những thứ khác, làm thế nào để câu lệnh if có thể được triển khai trên ARM.
RISC-V không có thực thi có điều kiện
Bây giờ có vẻ như thực thi có điều kiện là một ý tưởng thực sự gọn gàng và thông minh, nhưng hóa ra các nhà thiết kế của tập lệnh RISC-V mới hơn không thích ý tưởng này :
Lập luận của họ là bằng cách sử dụng cờ điều kiện, bạn tạo ra sự phụ thuộc giữa các lệnh trong đường dẫn. Ví dụ: nếu một lệnh đặt cờ thực thi có điều kiện, thì lệnh sau có thể không được thực thi.
Lập luận của họ là với dự đoán nhánh hoặc Khớp lệnh ngoài lệnh, bạn không cần thực hiện có điều kiện.
Chú thích cuối
Tôi thực sự đã lên kế hoạch viết nhiều hơn về vấn đề này, nhưng tôi không biết những lĩnh vực khác tốt để tập trung vào. Nếu bạn quan tâm đến việc so sánh một số lĩnh vực khác, hãy gửi cho tôi một dòng. Xin lưu ý rằng tôi không phải là chuyên gia về vấn đề này nên tôi không thể viết về các khía cạnh kỹ thuật quá sâu.
Đăng nhận xét