Phát triển một trình biên dịch là một nhiệm vụ rất khó khăn. Nhưng may mắn thay, với sự phát triển của các dự án như LLVM, giải pháp cho vấn đề này đã được đơn giản hóa rất nhiều, điều này cho phép ngay cả một lập trình viên duy nhất cũng có thể tạo ra một ngôn ngữ mới có hiệu suất gần giống với C. Làm việc với LLVM rất phức tạp bởi thực tế là điều này hệ thống được thể hiện bằng một lượng lớn mã, được trang bị ít tài liệu. Để cố gắng khắc phục thiếu sót này, tác giả của tài liệu, bản dịch mà chúng tôi đang xuất bản ngày hôm nay, sẽ trình bày các ví dụ về mã được viết bằng Go và cho thấy cách chúng được dịch lần đầu sang
Ví dụ đầu tiên
Hàm đầu tiên tôi sẽ xem xét ở đây là một cơ chế đơn giản để cộng số:
func myAdd(a, b int) int{
return a + b
}
Chức năng này rất đơn giản và có lẽ không gì có thể đơn giản hơn. Nó dịch sang mã Go SSA sau:
func myAdd(a int, b int) int:
entry:
t0 = a + b int
return t0
Với chế độ xem này, gợi ý về loại dữ liệu được đặt ở bên phải và có thể bị bỏ qua trong hầu hết các trường hợp.
Ví dụ nhỏ này đã cho phép bạn thấy được bản chất của một khía cạnh của SSA. Cụ thể, khi chuyển đổi mã thành dạng SSA, mỗi biểu thức được chia thành các phần cơ bản nhất mà nó được tạo thành. Trong trường hợp của chúng tôi, lệnh return a + b
trên thực tế, đại diện cho hai thao tác: cộng hai số và trả về kết quả.
Ngoài ra, tại đây bạn có thể thấy các khối cơ bản của chương trình, trong mã này chỉ có một khối - khối đầu vào. Chúng ta sẽ nói nhiều hơn về các khối bên dưới.
Mã Go SSA dễ dàng chuyển đổi sang LLVM IR:
define i64 @myAdd(i64 %a, i64 %b) {
entry:
%0 = add i64 %a, %b
ret i64 %0
}
Điều bạn có thể nhận thấy là mặc dù các cấu trúc cú pháp khác nhau được sử dụng ở đây nhưng cấu trúc của hàm về cơ bản không thay đổi. Mã LLVM IR mạnh hơn mã Go SSA một chút, tương tự như C. Ở đây, trong phần khai báo hàm, đầu tiên có mô tả về kiểu dữ liệu mà nó trả về, kiểu đối số được chỉ định trước tên đối số. Ngoài ra, để đơn giản hóa việc phân tích cú pháp IR, tên của các thực thể toàn cục được đặt trước ký hiệu @
và trước tên địa phương có ký hiệu %
(một chức năng cũng được coi là một thực thể toàn cầu).
Một điều cần lưu ý về mã này là quyết định biểu diễn kiểu của Go int
, có thể được biểu diễn dưới dạng giá trị 32 bit hoặc 64 bit, tùy thuộc vào trình biên dịch và mục tiêu biên dịch, được chấp nhận khi LLVM tạo mã IR. Đây là một trong nhiều lý do khiến mã LLVM IR không độc lập với nền tảng như nhiều người nghĩ. Mã như vậy, được tạo cho một nền tảng, không thể được lấy và biên dịch đơn giản cho nền tảng khác (trừ khi bạn phù hợp để giải quyết vấn đề này
Một điểm thú vị khác đáng chú ý là loại i64
không phải là số nguyên có dấu: nó trung tính về mặt biểu thị dấu của số. Tùy thuộc vào hướng dẫn, nó có thể biểu thị cả số có dấu và số không dấu. Trong trường hợp biểu diễn phép cộng, điều này không thành vấn đề, do đó không có sự khác biệt khi làm việc với số có dấu hoặc không dấu. Ở đây tôi muốn lưu ý rằng trong ngôn ngữ C, việc tràn một biến số nguyên có dấu dẫn đến hành vi không xác định, do đó giao diện Clang thêm cờ vào thao tác nsw
(không có gói có chữ ký), thông báo cho LLVM biết rằng nó có thể cho rằng phần bổ sung đó không bao giờ tràn.
Điều này có thể quan trọng đối với một số tối ưu hóa. Ví dụ: cộng hai giá trị i16
trên nền tảng 32 bit (có thanh ghi 32 bit), sau đó, yêu cầu hoạt động mở rộng dấu hiệu để duy trì trong phạm vi i16
. Vì điều này, việc thực hiện các phép toán số nguyên dựa trên kích thước thanh ghi máy thường hiệu quả hơn.
Điều gì xảy ra tiếp theo với mã IR này hiện không được chúng tôi đặc biệt quan tâm. Mã được tối ưu hóa (nhưng trong trường hợp ví dụ đơn giản như của chúng tôi, không có gì được tối ưu hóa) và sau đó được chuyển đổi thành mã máy.
Ví dụ thứ hai
Ví dụ tiếp theo chúng ta xem xét sẽ phức tạp hơn một chút. Cụ thể, chúng ta đang nói về một hàm tính tổng một lát số nguyên:
func sum(numbers []int) int {
n := 0
for i := 0; i < len(numbers); i++ {
n += numbers[i]
}
return n
}
Mã này chuyển đổi thành mã Go SSA sau:
func sum(numbers []int) int:
entry:
jump for.loop
for.loop:
t0 = phi [entry: 0:int, for.body: t6] #n int
t1 = phi [entry: 0:int, for.body: t7] #i int
t2 = len(numbers) int
t3 = t1 < t2 bool
if t3 goto for.body else for.done
for.body:
t4 = &numbers[t1] *int
t5 = *t4 int
t6 = t0 + t5 int
t7 = t1 + 1:int int
jump for.loop
for.done:
return t0
Tại đây, bạn có thể thấy nhiều cấu trúc điển hình hơn để biểu diễn mã ở dạng SSA. Có lẽ tính năng rõ ràng nhất của mã này là không có lệnh điều khiển luồng có cấu trúc nào. Để kiểm soát luồng tính toán, chỉ có các bước nhảy có điều kiện và vô điều kiện, và nếu chúng ta coi lệnh này là lệnh để kiểm soát luồng, thì lệnh quay lại.
Trên thực tế, ở đây bạn có thể chú ý đến thực tế là chương trình không được chia thành các khối bằng dấu ngoặc nhọn (như trong họ ngôn ngữ C). Nó được chia theo nhãn, gợi nhớ đến các ngôn ngữ hợp ngữ và được trình bày dưới dạng các khối cơ bản. Trong SSA, các khối cơ bản được định nghĩa là các chuỗi mã liền kề bắt đầu bằng nhãn và kết thúc bằng các hướng dẫn hoàn thành khối cơ bản, chẳng hạn như − return
и jump
.
Một chi tiết thú vị khác của mã này được thể hiện bằng lệnh phi
. Các hướng dẫn khá bất thường và có thể mất một thời gian để hiểu. nhớ lấy myAdd
được hiển thị ở trên, nhưng không phù hợp với các hàm phức tạp hơn như hàm được thảo luận trong phần này sum
. Đặc biệt, các biến thay đổi trong quá trình thực hiện vòng lặp i
и n
.
SSA bỏ qua hạn chế trong việc gán giá trị biến một lần bằng cách sử dụng cái gọi là lệnh phi
(tên của nó được lấy từ bảng chữ cái Hy Lạp). Thực tế là để tạo ra biểu diễn mã SSA cho các ngôn ngữ như C, bạn phải sử dụng một số thủ thuật. Kết quả của việc gọi lệnh này là giá trị hiện tại của biến (i
hoặc n
) và danh sách các khối cơ bản được sử dụng làm tham số của nó. Ví dụ, hãy xem xét hướng dẫn này:
t0 = phi [entry: 0:int, for.body: t6] #n
Ý nghĩa của nó như sau: nếu khối cơ bản trước đó là một khối entry
(đầu vào), sau đó t0
là một hằng số 0
và nếu khối cơ bản trước đó là for.body
, thì bạn cần lấy giá trị t6
từ khối này. Tất cả điều này có vẻ khá bí ẩn, nhưng cơ chế này là nguyên nhân khiến SSA hoạt động. Từ góc độ con người, tất cả điều này làm cho mã khó hiểu, nhưng thực tế là mỗi giá trị chỉ được gán một lần khiến cho nhiều lần tối ưu hóa trở nên dễ dàng hơn nhiều.
Lưu ý rằng nếu bạn viết trình biên dịch của riêng mình, bạn thường sẽ không phải đối mặt với loại nội dung này. Ngay cả Clang cũng không tạo ra tất cả các hướng dẫn này phi
, nó sử dụng cơ chế alloca
(nó giống như làm việc với các biến cục bộ thông thường). Sau đó, khi chạy thẻ tối ưu hóa LLVM có tên alloca
được chuyển đổi sang dạng SSA. Tuy nhiên, TinyGo nhận đầu vào từ Go SSA, thuận tiện là đã được chuyển đổi sang dạng SSA.
Một sự đổi mới khác của đoạn mã trung gian đang được xem xét là việc truy cập vào các phần tử lát cắt theo chỉ mục được biểu diễn dưới dạng một phép toán tính địa chỉ và một phép toán hủy tham chiếu con trỏ kết quả. Tại đây bạn có thể thấy việc bổ sung trực tiếp các hằng số vào mã IR (ví dụ - 1:int
). Trong ví dụ với hàm myAdd
cái này chưa được sử dụng Bây giờ chúng ta đã có những tính năng đó, hãy xem mã này sẽ trở thành gì khi được chuyển đổi sang dạng LLVM IR:
define i64 @sum(i64* %ptr, i64 %len, i64 %cap) {
entry:
br label %for.loop
for.loop: ; preds = %for.body, %entry
%0 = phi i64 [ 0, %entry ], [ %5, %deref.next ]
%1 = phi i64 [ 0, %entry ], [ %6, %deref.next ]
%2 = icmp slt i64 %1, %len
br i1 %2, label %for.body, label %for.done
for.body: ; preds = %for.loop
%3 = getelementptr i64, i64* %ptr, i64 %1
%4 = load i64, i64* %3
%5 = add i64 %0, %4
%6 = add i64 %1, 1
br label %for.loop
for.done: ; preds = %for.loop
ret i64 %0
}
Ở đây, như trước đây, chúng ta có thể thấy cấu trúc tương tự, bao gồm các cấu trúc cú pháp khác. Ví dụ, trong các cuộc gọi phi
giá trị và nhãn được hoán đổi. Tuy nhiên, có một điều ở đây đáng được đặc biệt chú ý.
Để bắt đầu, ở đây bạn có thể thấy một chữ ký hàm hoàn toàn khác. LLVM không hỗ trợ các lát cắt và do đó, để tối ưu hóa, trình biên dịch TinyGo tạo ra mã trung gian này sẽ chia mô tả cấu trúc dữ liệu này thành các phần. Nó có thể đại diện cho ba phần tử lát cắt (ptr
, len
и cap
) dưới dạng cấu trúc (struct), nhưng việc biểu diễn chúng dưới dạng ba thực thể riêng biệt sẽ cho phép thực hiện một số tối ưu hóa. Các trình biên dịch khác có thể biểu diễn lát cắt theo những cách khác, tùy thuộc vào quy ước gọi các chức năng của nền tảng đích.
Một tính năng thú vị khác của mã này là việc sử dụng lệnh getelementptr
(thường viết tắt là GEP).
Lệnh này hoạt động với các con trỏ và được sử dụng để lấy một con trỏ tới phần tử lát cắt. Ví dụ: hãy so sánh nó với đoạn mã sau được viết bằng C:
int* sliceptr(int *ptr, int index) {
return &ptr[index];
}
Hoặc với những điều sau đây tương đương với điều này:
int* sliceptr(int *ptr, int index) {
return ptr + index;
}
Điều quan trọng nhất ở đây là hướng dẫn getelementptr
không thực hiện các hoạt động hội thảo. Nó chỉ tính toán một con trỏ mới dựa trên con trỏ hiện có. Nó có thể được coi là hướng dẫn mul
и add
ở cấp độ phần cứng. Bạn có thể đọc thêm về hướng dẫn GEP
Một đặc điểm thú vị khác của mã trung gian này là việc sử dụng lệnh icmp
. Đây là lệnh có mục đích chung được sử dụng để thực hiện so sánh số nguyên. Kết quả của lệnh này luôn là một giá trị thuộc loại i1
- giá trị logic. Trong trường hợp này, sự so sánh được thực hiện bằng từ khóa slt
(có dấu nhỏ hơn), vì chúng ta đang so sánh hai số được biểu thị trước đó bằng loại int
. Nếu chúng ta so sánh hai số nguyên không dấu thì chúng ta sẽ sử dụng icmp
và từ khóa được sử dụng trong so sánh sẽ là ult
. Để so sánh các số dấu phẩy động, một lệnh khác được sử dụng, fcmp
, hoạt động theo cách tương tự.
Kết quả
Tôi tin rằng trong tài liệu này tôi đã đề cập đến những tính năng quan trọng nhất của LLVM IR. Tất nhiên, có rất nhiều hơn nữa ở đây. Đặc biệt, biểu diễn trung gian của mã có thể chứa nhiều chú thích cho phép quá trình tối ưu hóa có tính đến một số tính năng nhất định của mã mà trình biên dịch đã biết mà không thể được biểu thị bằng IR. Ví dụ: đây là một lá cờ inbounds
Hướng dẫn GEP hoặc cờ nsw
и nuw
, có thể được thêm vào hướng dẫn add
. Từ khóa cũng vậy private
, cho trình tối ưu hóa biết rằng hàm mà nó đánh dấu sẽ không được tham chiếu từ bên ngoài đơn vị biên dịch hiện tại. Điều này cho phép thực hiện nhiều tối ưu hóa liên thủ tục thú vị như loại bỏ các đối số không sử dụng.
Bạn có thể đọc thêm về LLVM trong
Gởi bạn đọc! Bạn có đang sử dụng LLVM không?
Nguồn: www.habr.com