KLEE tiến hành kiểm chứng tự động trên mã nguồn của C và C++ với giá trị đầu vào tượng trưng thông quá trình biên dịch LLVM (LLVM complier) và môi trường tượng trưng hóa (Symbolic Environment) về dạng Bit – Vector để làm đầu vào cho KLEE. Từng thành phần tạo đầu vào cho KLEE sẽ được trình bày ngay dưới đây.
a) LLVM complier (trình biên dịch LLVM): KLEE được xây dựng trên
đỉnh của trình biên dịch LLVM [26]. LLVM là một hạ tầng biên dịch được viết bởi ngôn ngữ C++ và STL [26]. LLVM được thực hiện với mục tiêu tối ưu hóa, tích hợp trình biên dịch AOT (ahead of time), JIT (just in time), thông dịch. Nó thay thế cho sự cồng kềnh chậm chạp của GCC với kiến trúc đơn giản hơn, tối ưu hóa tốt hơn, biên dịch nhanh hơn.
Kiến trúc của trình biên dịch LLVM gồm 3 thành phần
LLVM suite là thành phần quan trọng của trình biên dịch LLVM. Nó chứa toàn bộ các công cụ, các thư viện và các header file cần thiết để sử dụng LLVM. Nó cũng chứa Assembler, Disassemble, bộ phân tích và tối ưu bitcode. Đồng thời, nó bao gồm cả bộ kiểm tra hồi quy cơ bản được sử dụng để kiểm thử Clang front end.
Clang font end là thành phần giúp biên dịch mã C, C++, Objective C, Objective C++ thành LLVM bitcode. Sau đó các chương trình này có thể thao tác với LLVM thông qua LLVM suite.
Test suite là thành phần tùy chọn của LLVM complier. Nó cung cấp công cụ cho phép kiểm tra các hàm và hiệu suất thực hiện của LLVM.
b) LLVM bitcode: Là định dạng file được sinh ra sau khi tiến hành biên
dịch mã nguồn C, C++, Object C/C++ với LLVM complier.
c) Môi trƣờng thực thi tƣợng trƣng (Symbolic environment): Là môi trường tương tác của KLEE với hệ thống. KLEE sử dụng kỹ thuật thực thi tượng trưng động là kỹ thuật kết hợp hiệu quả giữa thực thi với tham số tượng trưng và thực thi với tham số cụ thể. Chính vì vậy, quá trình tương tác với hệ điều hành của KLEE chia làm 2 quá trình.
Tương tác thông qua hệ điều hành là cách thức tương tác áp dụng nếu các tham số là cụ thể, KLEE gọi trực tiếp hệ điều hành để hệ điều hành thực hiện mà không cần thiết phải mô hình hóa môi trường.
Mô hình hóa là cách thức tương tác áp dụng trong trường hợp tham số vào là tượng trưng. KLEE sẽ cung cấp một mô hình mà có thể thực thi dữ liệu tượng trưng. Mục tiêu là để duyệt tất cả những tương tác có thể có với môi trường. Mô hình này được viết bằng ngôn ngữ C. Nó cung cấp các hàm đơn giản giống với lời gọi từ hệ điều hành (chẳng hạn, open, read, write, stat, lseek … ). Về chi tiết cài đặt các hàm này tham khảo trong [5].
4.4.2. Tối ƣu hóa tập các ràng buộc với KLEE
KLEE sử dụng nhiều kỹ thuật khác nhau để tối ưu hóa tập các ràng buộc giúp tăng tốc độ thực thi của công cụ cũng như làm giảm bớt công việc mà các SMT solver phải thực hiện. Trong năm kỹ thuật được trình bày dưới đây thì hai
kỹ thuật giúp giảm thiểu đáng kể tập ràng buộc đó là contraint independence
(CI) và Counter Example Cache (CEC). Ba kỹ thuật còn lại thường được thực hiện bởi trình biên dịch LLVM.
a) Viết lại biểu thức: Phương pháp này được thực hiện rất nhiều và tự động
trong trình biên dịch. Chẳng hạn những phép toán có dạng: x +0 x, x * 2n (x << n), đơn giản hóa tuyến tính (2*x – x x).
b) Đơn giản hóa tập ràng buộc: Thực thi tượng trưng thường liên quan đến
một số trên đường thực thi (Path Condition - PC) lượng lớn các ràng buộc trên path condition. Theo cấu trúc tự nhiên của chương trình các ràng buộc trên cùng một biến có xu hướng ngày một cụ thể hóa hơn. Ví dụ, thông thường một ràng buộc không cụ thể được thêm vào trước chẳng hạn
x<10, sau đó một ràng buộc mới có thể được thêm vào chẳng hạn x=5. Trong trường hợp này KLEE tích cực đơn giản hóa tập ràng buộc bằng cách viết lại ràng buộc trước. Khi một ràng buộc mới được thêm vào tập ràng buộc. Trong ví dụ này, thay giá trị của x vào ràng buộc đầu tiên thấy nó thỏa mãn vậy nó được loại bỏ, có nghĩa là chỉ còn ràng buộc x = 5.
c) Cụ thể hóa giá trị (Implied value concretization): Khi ràng buộc dạng
x + 1 = 10 được thêm vào PC, thì giá trị của x lúc này sẽ được cụ thể hóa là x = 9. Giá trị này sẽ được ghi trở lại bộ nhớ. Điều này đảm bảo rằng truy cập tiếp theo là vị trí bộ nhớ có thể trở thành biểu thức hằng số với chi phí thấp hơn.
d) Loại bỏ ràng buộc không liên quan (constraint independence - CI):
Nhiều ràng buộc tham cham chiếu vào những vùng độc lập nhau về bộ nhớ. CI chia tập ràng buộc thành các tập con độc lập dựa trên biến tượng trưng mà chúng tham chiếu. Bằng cách giám sát kỹ các tập con này KLEE thường loại bỏ những ràng buộc không thích hợp trước khi đưa một truy vấn vào SMT solver.
Thí dụ: Cho một nhánh thực thi có ràng buộc sau:
Giả sử lệnh tiếp theo là câu lệnh rẽ nhánh với điều kiện rẽ nhánh là x > 3. Áp dụng kỹ thuật loại bỏ ràng buộc không liên quan được tập ràng buộc sau khi thực thi câu lệnh rẽ nhánh là:
PC = (x < 10)(x + y = 10)(y > 5) (x > 3).
Hai ràng buộc (z < 20) và (w = 2 mod z) độc lập với (x > 3) nên được loại bỏ.
Sau bước tối ưu này, KLEE sẽ thực hiện lần lượt trên các nhánh. Nó sử dụng Branch Cache để lưu trữ tập các ràng buộc trên từng nhánh thực thi.
e) Bộ nhớ đệm nhánh (Branch Cache – BC): Là một bộ nhớ đệm sẽ lưu
trữ tập tất cả các ràng buộc trên các nhánh thực thi (nhánh đúng hoặc sai của biểu thức điều kiện) trong câu lệnh rẽ nhánh.
f) Bộ lƣu trữ các phản Ví Dụ (Counter Example Cache - CEC): Là bộ
nhớ đệm sẽ lưu trữ lại các trạng thái hiện thời của tập ràng buộc khi gặp một lỗi phát sinh hoặc khi gặp câu lệnh return trong quá trình thực thi. Mô hình này được lưu trữ dưới dạng cấu trúc dữ liệu tùy chỉnh được chuyển từ cấu trúc UBTree của Hoffmann và Hoehler [1] cho phép tìm kiếm hiệu quả cho các đối tượng trong bộ đệm. Đồng thời nó cũng định nghĩa quan hệ tập cha (superset), tập con (subset) để xác định tính thỏa mãn của các truy vấn.
1. Nếu tập con là UNSAT thì tập cha của nó cũng UNSAT.
2. Nếu tập cha là SAT thì tập con bất kì của nó cũng SAT.
Bằng việc lưu trữ bộ nhớ đệm theo kiểu này CEC cũng giảm thiểu một số bước đáng kể trong quá trình giải ràng buộc.
Thí dụ BC lưu trữ ràng buộc sau:
PC = (i < 10) (j = 8) (i ≠ 3) được lưu trữ trên Branch cache. Giả sử với tập con
PC’ = (i < 10) (j = 8) SMT solver giải được kết quả i = 5 và j = 8. CEC sẽ lưu trữ tập này và kết quả tương ứng.
Khi SMT solver thực hiện giải tập cha (i < 10) (j = 8) (i ≠ 3) CEC sẽ được sử dụng để tìm xem kết quả này có thỏa mãn tập cha đó hay không? Trong trường hợp này là thỏa mãn và kết quả là i = 5 và j =8. Việc sử dụng CEC giảm thiểu được rất nhiều công việc cho SMT solver.
Sau năm bước tối ưu trên truy vấn tồn tại dưới dạng tập ràng buộc đã được giản lược khá nhiều về số lượng nhưng vẫn đảm bảo không thay đổi về kết quả.
Việc giải các ràng buộc luôn là vấn đề lớn trong kiểm chứng phần mềm một cách tự động. KLEE đã sử dụng metaSMT[8, 10] như là một giải pháp đột phá kết hợp sức mạnh của các ba SMT solver được đánh giá là hiệu quả để giải tập ràng buộc này.
4.1.3. Giải ràng buộc tự động với metaSMT
Theo [10] hiện nay nhiều vấn đề thuộc trí tuệ nhân tạo và phương pháp hình thức được giải quyết bằng cách sử dụng các SMT solver. Việc lựa chọn một SMT solver tốt nhất cho một chương trình nhất định cho trước là điều hết sức cần thiết. Nhưng lại là một nhiệm vụ hết sức khó khăn với 2 lý do:
1. Bài toán SAT là NP – đầy đủ, hiệu quả cũng như sức mạnh của SMT solver phụ thuộc vào cách tiếp cận dựa trên kinh nghiệm. Vì vậy, mỗi SMT solver có điểm mạnh và điểm yếu riêng của nó và do đó thời gian thực hiện cũng như bộ nhớ tiêu thụ cũng khác nhau tùy thuộc vào khả năng của các SMT solver cho từng vấn đề cụ thể.
2. Các SMT solver khác nhau không sử dụng chung một định dạng đầu vào. SMT – LIB [20] sau này cải tiến thành SMT – LIB2 [2] là định dạng chuẩn mà nhiều SMT solver sử dụng. Tuy nhiên, không phải toàn bộ các SMT solver đều sử dụng chuẩn đầu vào này.
MetaSMT [8, 10] là giải pháp cho vấn đề này. Nó cung cấp một khung làm việc cho phép tích hợp nhiều SMT solver trong các ứng dụng C, C++, Python. Với mục đích cung cấp một giao diện chung thống nhất cho các SMT
solver khác nhau, sử dụng ngôn ngữ C/C++, khả mở với các lý thuyết cũng như các API của solver mới, dễ dàng tùy chỉnh với từng mục đích cụ thể. Nó sử dụng và phát triển dựa trên chuẩn SMT – LIB2. Kiến trúc của metaSMT thể hiện trong hình 4.2.
Hình 4.2 Kiến trúc của metaSMT [10]
Kiến trúc của metaSMT gồm ba tầng: FrontEnd, MiddleEnd, và BackEnd. Với ba tầng này metaSMT đã định nghĩa một chuẩn đầu vào chung cho nhiều SMT solver.
a) Tầng FrontEnd: Là tầng cung cấp ngôn ngữ đầu vào cho metaSMT. Nó
cung cấp các lý thuyết được hỗ trợ bởi định dạng SMT – LIB2 dưới dạng một API của ngôn ngữ lập trình. Gồm lý thuyết về các thao tác Logic cơ bản (Core), Bit – Vector với số Bit cố định cho trước (FixedSize Bit Vector) và lý thuyết mảng mở rộng (ArrayEx). Trong đó cấu trúc của từng thành phần được trình bày dưới đây:
Core (Logic cơ bản): Bao gồm các vị từ, các biến với kiểu trả về
boolean (với hai giá trị là đúng (true) hoặc sai (false)) và các thao tác trên biến chẳng hạn and (), or (), not (), các phép so sánh bằng (), phép so sánh khác ().
FixedSize Bit vector (Bit – Vector số Bit cố định cho trước): Bao gồm các phép toán tạo mới một Bit – Vector với n bit
meta
S
M
T
FrontEnd
Core FixedSize Bit vector ArrayEx
MiddleEnd
Direct Solver Graph Solver Bit Blast
BackEnd
Z3 Boolector STP MiniSAT …
(new_bitvector (n)), các phép Logic trên bit như and (bvand), or (bvor), not (bvnot) hay các phép toán trên Bit – Vector chẳng hạn phép cộng (bvadd), phép trừ (bvsub), phép nhân (bvmul), … Ngoài ra, Bit – Vector cũng định nghĩa một số các phép khác như phép so sánh bằng (equal) hoặc so sánh khác (less - than).
ArrayEx (Lý thuyết mở rộng của mảng): Bao gồm các thao tác trên các phần tử của mảng hoặc các thao tác trên chỉ số mảng.
Hiện tại, các ngôn ngữ lập trình hỗ trợ lý thuyết về thao tác Logic cơ bản (Logic Core Boolean), Bit – Vector với kích thước Bit cố định cho trước và lý thuyết mảng mở rộng là C/C++ và Python.
b) Tầng MiddleEnd: Là tầng trung gian kết nối giữa FrontEnd với
BackEnd. Nó cung cấp đầu vào chuẩn cho các SMT solver trong Backend và tối ưu hóa. Chẳng hạn sự kết hợp các lý thuyết được cung cấp bởi FrontEnd với GraphSolver trong tầng MiddleEnd tạo ra đầu vào chuẩn cho SMT solver Z3 ở tầng BackEnd. Hai thành phần chính được sử dụng trong middleend là DirectSolver và GraphSolver.
Director Solver: Cho phép biên dịch trực tiếp từ một FronEnd và BackEnd. Nghĩa là biểu thức đầu vào sẽ được đưa trực tiếp vào BackEnd và được đánh giá bởi thành phần này. Chẳng hạn với SMT solver STP có thể dùng trực tiếp đầu vào ở FrontEnd mà không cần thông qua MiddleEnd hay một biểu thức dạng CNF là đầu vào trực tiếp của các SAT solver như miniSAT.
Graph Solver: Thay vì đưa trực tiếp biểu thức ở FrontEnd vào BackEnd để giải thì trong trường hợp này đưa vào một đồ thị có hướng. Biểu thức đầu vào được đưa vào dưới dạng một đồ thị có hướng không chứa chu trình. Trong đó, mỗi nút của đồ thị này là toán hoạng của biểu thức đầu vào hoặc là một giá trị cụ thể được gán cho các toán hạng của biểu thức. Mỗi cạnh của đồ thị là một biểu thức gán giá trị cho các toán hạng của biểu thức đầu vào. Hình
4.3 minh họa một biểu thức đầu vào dưới dạng một đồ thị có hướng không chứa chu trình.
Hình 4.3. Biểu thức đầu vào dạng đồ thị [8]
Bit Blast: Là hình thức mô phỏng đầu vào dưới dạng Bit – Vector
(QF – BV). Trong đó mỗi biến Logic được biểu diễn bằng một Bit – Vector. Các phép toán trên Bit có thể được thực hiện dễ dàng.
Mỗi phép so sánh giữa hai Bit – Vector được thể hiện bằng các mệnh đề (equal, less – than, …).
c) Tầng Backend: Tầng này cung cấp hai giao diện khác nhau cho cho SAT
và SMT solver. Giao diện đầu tiên là một ánh xạ tĩnh từ API metaSMT vào các API của các SMT và SAT solver. Giao diện thứ hai là luồng chung với đầu vào dạng SMT –LIB2 và các solver tương thích với nó. MetaSMT quy ước sự kết hợp của một thành phần ở MiddleEnd với một thành phần tương ứng với nó trong BackEnd được gọi một context.
Trên đây là kiến trúc tổng quan và các thành phần của metaSMT. Tiếp theo luận văn sẽ trình bày về quá trình giải ràng buộc của các solver trong metaSMT.
Quá trình giải ràng buộc của metaSMT xem hình 4.4. Quá trình thực hiện của metaSMT gồm 2 pha cơ bản. Pha 1 là quá trình phân tích cú pháp biểu thức dạng SMT – LIB2 thông qua bộ phân tích cú pháp SMT – LIB2 và đánh giá biểu
x0
x1 1
0 x
2
thức thông qua bộ đánh giá chung. Pha 2 là quá trình giải với các solver của metaSMT. Pha này tiến hành đọc các lệnh dạng SMT – LIB2 ở đầu vào và đánh giá các biểu thức này. Sau đó tiến hành giải với các solver và chọn ra solver thực hiện tốt nhất.
Hình 4.4. Mô hình quá trình giải ràng buộc của metaSMT [10]
Một ca kiểm thử là tốt nếu độ bao phủ [14] (coverage) mã nguồn của nó là cao. Một công cụ sinh ca kiểm thử tự động được đánh giá là tốt nếu các ca kiểm thử mà nó sinh ra đủ tốt. Theo [4] công cụ KLEE được đánh giá là có độ bao phủ mã trung bình hơn 90%. Các kiến thức cơ bản về độ bao phủ mã nguồn và tiêu chí chọn các đường thực thi để đạt được độ bao phủ mã cao được trình bày trong 4.2 của luận văn.
4.2. Độ bao phủ mã nguồn của ca kiểm thử đƣợc sinh bởi KLEE
Độ bao phủ mã nguồn của ca kiểm thử (test coverage) [14] là tỷ lệ phần trăm mã được duyệt thông qua các ca kiểm thử trên tổng lượng code của chương trình.
Trong quá trình kiểm chứng chương trình, việc đánh giá một ca kiểm thử là tốt hay không phụ thuộc vào độ bao phủ mã nguồn của ca kiểm thử đó. Kỹ thuật thực thi tượng trưng động cho phép duyệt tất cả các đường thực thi có thể có của chương trình. Việc này thực sự rất cần thiết giúp phát hiện những lỗi tiềm ẩn của chương trình. Tuy nhiên, trong thực tế việc lựa chọn ca kiểm thử đảm
Kết quả Lệnh SMT-LIB2 Bộ phân tích cú pháp SMT – LIB2 Bộ đánh giá chung Thực hiện bởi Solver 1 Lựa chọn Solver nhanh nhất … Thực hiện bởi Solver 2 Thực hiện bởi Solver n
bảo độ bao phủ theo tiêu chí nào là rất quan trọng. Trong phần này, luận văn trình bày bốn tiêu chí về độ bao phủ mã nguồn. Đồng thời, luận văn cũng minh họa cụ thể các tiêu chí này thông qua một ví dụ cụ thể.
Xét ví dụ hàm ReturnAverage hình 4.5 cho phép tính giá trị trung bình của mảng số nguyên dương thuộc đoạn từ giá trị min đến giá trị max kể cả hai giá trị min và max. Trong đó kích thước của mảng luôn nhỏ hơn AS và giá trị kết thúc của mảng kí hiệu là – 999 để kiểm tra độ bao phủ mã nguồn với các tiêu chí khác nhau của của độ bao phủ.
Hình 4.6 là đồ thị luồng điều khiển của chương trình trên.
Hình 4.5. Ví dụ về mã nguồn hàm ReturnAverage
Trang tiếp theo luận văn trình bày biểu đồ luồng điều khiển của ReturnAverage():
public static double ReturnAverage(int value[], int AS, int MIN, int MAX){ int i, ti, tv, sum; double av;
i=0;ti=0;tv=0;sum=0;
while (ti < AS && value[i] != -999) { ti++;
if (value[i] >= MIN && value[i] <= MAX) { tv++;
sum = sum + value[i];} i++;} if (tv > 0) av = (double)sum/tv; else av = (double) -999; return (av);}
Hình 4.6 Đồ thị luồng điều khiển của hàm ReturnAverager [14] 10 F F av = (double)sum/tv ti++ T T av = (double)-999 return (av) Value[i]>=min Value[i]<=max tv++ sum=sum+value[i] i++