Nhiệm vụ của luận văn : Nghiên cứu và xây dựng hệ thống sinh logic test-case dựa trên kỹ thuật slicing cho những chương trình được viết bằng ngôn ngữ HIP .... Để khắc phục nhược điểm này
Giới thiệu
Tổng quan về đề tài
Ngày nay, trong lĩnh vực lập trình, lỗi chương trình là một thách thức lớn cho các nhà phát triển phần mềm Trên cơ sở đó, các công cụ khác nhau đã được đề xuất để giúp đỡ các nhà phát triển phần mềm nhằm giúp giảm thiểu các lỗi xảy ra trong chương trình
Các công cụ kiểm tra phần mềm tự động xuất hiện ngày càng nhiều và có nhiều đóng góp tích cực trong hướng kiểm tra phần mềm tự động Các công cụ này có thể được phân loại thành hai nhóm chính:
Nhóm đầu tiên là dựa trên kỹ thuật under-approximate hay còn được gọi là phân tích MUST [1][2][3] Phân tích MUST đảm bảo rằng thuộc tính (property) nào đó của chương trình chắc chắn xảy ra trong một vài đường thực thi nào đó của chương trình và có thể được sử dụng để chứng minh sự tồn tại lỗi trong chương trình Thuộc tính của chương trình biểu diễn cho một điều kiện lỗi nào đó trong chương trình (ví dụ như lỗi tham khảo đến con trỏ null hoặc lỗi truy xuất chỉ số phần tử của array vượt quá kích thước khai báo của array đó,…) Để kiểm tra chương trình có lỗi hay không, phải duyệt qua tất cả các đường thực thi của chương trình, đối với các chương trình lớn, điều này là không khả thi, do đó các thông tin MUST không thể phủ được tất cả các đường thực thi có thể của chương trình Do đó, phân tích MUST không thể chứng minh chương trình không có lỗi, vì vậy nhược điểm của phân tích MUST là gây ra tình trạng báo lỗi false-negatives (tức là phân tích MUST sẽ báo là chương trình không có lỗi dù thực sự chương trình có lỗi, vì như đã giải thích ở trên, phân tích MUST không thể phủ tất cả các đường thực thi trong các chương trình lớn)
Nhóm thứ hai của các công cụ kiểm tra được dựa trên kỹ thuật over- approximate, còn được gọi là phân tích MAY [4][5][6] Phân tích MAY dùng để chứng minh một thuộc tính (property) nào đó của chương trình luôn đúng đối với tất cả các đường thực thi của chương trình Phân tích MAY được sử dụng để chứng minh chương trình không có lỗi Vì áp dụng kỹ thuật over-approximate, phân tích MAY có nhược điểm là gây ra tình trạng báo lỗi false-positives (tức là phân tích MAY sẽ báo là chương trình có lỗi dù thực tế chương trình không có lỗi) Nhược điểm này của phân tích MAY có thể làm cho các công cụ kiểm tra chương trình trở nên không thực tế trong việc tìm kiếm và loại bỏ lỗi trong chương trình
Gần đây, đã có nhiều cách tiếp cận mới để kết hợp các kỹ thuật under- approximate với các kỹ thuật over-approximate Mục tiêu chính của việc kết hợp các kỹ thuật khác nhau nhằm tăng độ chính xác và hiệu quả của các công cụ kiểm tra phần mềm
Ngày nay, kiểm thử phần mềm-một kỹ thuật phân tích MUST, dựa trên test-case là một kỹ thuật phổ biến trong lĩnh vực phát triển phần mềm (software development) [7][9][10][11] để tìm lỗi trong chương trình Kỹ thuật kiểm tra hộp trắng (white-box testing) [8] dựa trên việc phân tích luồng thực thi của chương trình vẫn là cách được sử dụng chủ yếu trong môi trường giáo dục cũng như công nghiệp Tuy nhiên, kiểm tra hộp trắng có thể dẫn đến việc bùng nổ trạng thái vì nó dựa vào số lệnh rẽ nhánh trong chương trình Để khắc phục nhược điểm này, kỹ thuật concolic testing được xem là một phương pháp hiệu quả trong việc làm giảm số lần duyệt qua các đường thực thi của chương trình mà vẫn đảm bảo sinh đủ test-case để phát hiện lỗi
Tuy nhiên, đối với những chương trình thao tác trên heap, chủ yếu sử dụng con trỏ, test-case sinh ra bởi kỹ thuật concolic testing không đảm bảo phủ tất cả các trường hợp cần kiểm tra Điều này là vì phương pháp này không sử dụng thông tin về phần đặc tả cấu trúc dữ liệu để có thể kiểm tra tất cả các trường hợp xảy ra đối với cấu trúc dữ liệu này
Vì vậy, mục tiêu của đề tài là dựa vào ngữ nghĩa của separation logic áp dụng cho việc suy luận cho những chương trình thao tác trên heap, đề xuất phương pháp xây dựng một hệ thống sinh test-case hoàn chỉnh cho những chương trình thao tác trên heap Đề tài mở rộng kỹ thuật concolic testing bằng việc kết hợp phần đặc tả cấu trúc dữ liệu từ chương trình nguồn với luồng thực thi (control flow) của chương trình trong bước thực thi symbolic của kỹ thuật concolic testing Hơn nữa để cải tiến việc sinh ra số test-case hiệu quả cũng như hỗ trợ quá trình debug phát hiện lỗi, hệ thống kết hợp kỹ thuật slicing đồng thời với quá trình sinh test-case để xây dựng một hệ thống sinh test-case hoàn thiện hơn.
Mục tiêu nghiên cứu của luận văn
Đề tài sẽ tập trung vào việc nghiên cứu các kỹ thuật kiểm tra chương trình và xây dựng một hệ thống sinh logic test-case dựa trên kỹ thuật slicing cho những chương trình được viết bằng ngôn ngữ HIP
Tìm hiểu các kỹ thuật phân tích MUST và MAY và kết hợp 2 phương pháp này để kiểm tra chương trình
Tìm hiểu về separation logic và công cụ HIP/sleek - một công cụ hiệu quả.để kiểm tra chương trình dựa trên con trỏ
Tìm hiểu kỹ thuật concolic testing Xây dựng một hệ thống sinh logic test-case dựa trên kỹ thuật concolic testing cho chương trình dựa trên con trỏ
Tìm hiểu kỹ thuật slicing và áp dụng kỹ thuật slicing để hỗ trợ quá trình phát hiện lỗi cũng như cải tiến quá trình sinh test-case của hệ thống
Thử nghiệm, đánh giá và phân tích kỹ thuật đã phát triển để thấy được ưu điểm và nhược điểm.
Đóng góp của luận văn
Luận văn đã hoàn thành các mục tiêu đề ra, tìm hiểu các kỹ thuật kiểm tra chương trình, từ đó xây dựng được một hệ thống sinh logic test-case cho những chương trình thao tác trên con trỏ Đề tài tập trung nghiên cứu kỹ thuật phân tích MUST, mà điển hình là kỹ thuật concolic testing, để phát hiện lỗi xảy ra trong chương trình (nếu có) Kết quả của hệ thống sẽ là tập các test-case hiệu quả nhất (số lượng test-case là nhỏ nhất) nhưng vẫn đảm bảo độ bao phủ các đường thực thi nhằm phát hiện lỗi trong chương trình Để thực hiện điều này, luận văn kết hợp kỹ thuật slicing để hỗ trợ quá trình phát hiện lỗi cũng như cải tiến quá trình sinh test-case của hệ thống
Trong quá trình hiện thực hệ thống, luận văn đã phát triển một tập luật để hỗ trợ quá trình sinh test-case cho chương trình input được viết bằng ngôn ngữ HIP Song song đó, khi áp dụng kỹ thuật concolic testing trên những chương trình thao tác trên con trỏ, luận văn cũng đề xuất cách khắc phục một số vấn đề thường xảy ra khi làm việc với những chương trình có con trỏ, ví dụ như vấn đề bí danh và vấn đề truy xuất thuộc tính của cấu trúc dữ liệu Với những cải tiến này, tập slice trích xuất được sẽ cho kết quả chính xác
Trong quá trình thực hiện, đề tài đã đóng góp được 1 bài báo nghiên cứu được nói ở chương 7.
Cấu trúc của luận văn
Luận văn bao gồm 7 chương và được trình bày như sau:
Chương 1: Tổng quan về đề tài Chương 2: Các kiến thức nền tảng Chương 3: Hệ thống sinh test-case hoàn chỉnh Chương 4: Các giải pháp cho hệ thống sinh test-case Chương 5: Hiện thực hệ thống sinh test-case hoàn chỉnh Chương 6: Kết quả thực nghiệm và Đánh giá
Chương 7: Kết luận và hướng phát triển
Các kiến thức nền tảng
Separation logic
Separation logic [12][13] là logic mở rộng của Hoare logic [14], được dùng để suy diễn cho chương trình sử dụng cấu trúc dữ liệu chia sẻ (mutable data structure) Cấu trúc dữ liệu chia sẻ, tức cấu trúc mà một thuộc tính của nó có thể được tham khảo từ nhiều hơn một con trỏ, thường xảy ra vấn đề bí danh Đây là một vấn đề gây khó khăn trong việc kiểm tra tính đúng đắn của chương trình
Chúng ta không thể sử dụng Hoare logic để kiểm tra chương trình có chứa các con trỏ vì có thể xảy ra vấn đề bí danh
Vì vậy, Peter O'Hearn-Đại học London và John Reynolds-Đại học Carnegie Mello đã phát triển separation logic từ Hoare logic bằng cách thêm 2 toán tử mới: spatial conjunction (*) và spatial implication (-*) và một số tiên đề trên separation logic giúp cho việc chứng minh những chương trình có chứa con trỏ.
2.1.2 Ngữ nghĩa của separation logic
2.1.2.1 Mô hình biểu diễn của separation logic Đối với Hoare logic, trạng thái của chương trình được biểu diễn bằng một ánh xạ đi từ tên biến đến miền giá trị Integer (biểu diễn giá trị của biến đó) và được gọi là Store
+ Integers: tập các số nguyên biểu diễn giá trị của biến Tuy nhiên, đối với các chương trình có chứa con trỏ, với cách biểu diễn này ta không thể biểu diễn được trường hợp bí danh (tức 2 con trỏ cùng trỏ đến một địa chỉ bộ nhớ) Vì vậy, separation logic đề xuất thêm thành phần thứ 2 là heap Mô hình của separtion logic sẽ có 2 thành phần: store và heap Khái niệm store tương tự như khái niệm của stack trong ngôn ngữ lập trình, chức năng của nó là ánh xạ từ các biến đến miền giá trị số nguyên (miền số nguyên này biểu diễn địa chỉ mà biến đó trỏ đến) Heap là ánh xạ đi từ miền địa chỉ ô nhớ (miền này là tập con của miền số nguyên) đến miền số nguyên (biểu diễn giá trị của ô nhớ) Trạng thái của chương trình lúc này sẽ được biểu diễn bằng 2 thành phần (Store x Heap)
Hình 2.1 : Mô hình của separation logic (nguồn [12])
• Ints là miền các số nguyên
• Variables là miền các biến trong chương trình
• Atoms và Locations là tập con của Ints.
• Stores là hàm ánh xạ từ biến sang Ints
• Heap là ánh xạ từ Location sang Ints.
• States: biểu diễn trạng thái của chương trình được xác định bởi các cặp (Stores x Heaps)
Với mô hình này, separation logic có thể biểu diễn các lệnh như cấp phát bộ nhớ, truy xuất, thay đổi giá trị ô nhớ, cũng như hủy bộ nhớ trong các chương trình có chứa con trỏ
Ví dụ 2.1: Trạng thái của chương trình có thể được biểu diễn bằng separation logic qua các lệnh cấp phát, truy xuất, thay đổi giá trị ô nhớ và hủy ô nhớ
Hình 2.2: Minh họa trạng thái chương trình qua các lệnh (nguồn [12])
Ký hiệu [E] biểu diễn giá trị tại ô nhớ E Ở hình 2.2, giả sử chương trình ban đầu có 2 biến x và y trỏ đến 2 ô nhớ có địa chỉ tương ứng là 3 và 4
- Lệnh x := cons(1,2) khai báo biến x trỏ đến ô nhớ trong heap có giá trị là 1, và ô nhớ tiếp theo ô nhớ này trong heap có giá trị là 2
- Lệnh y := [x] gán biến y trỏ đến ô nhớ có địa chỉ là nội dung của biến x - Lệnh [x+1] := 3; thay đổi giá trị của ô nhớ tại địa chỉ x + 1
- Lệnh dispose(x+1): hủy ô nhớ tại địa chỉ x+1
Ví dụ 2.2: Minh họa việc dùng separation logic để có thể phát hiện lỗi trong chương trình, ở ví dụ này là lỗi truy xuất đến con trỏ null
Hình 2.3: Minh họa dùng separation logic để phát hiện lỗi chương trình (nguồn [12])
2.1.2.2 Các toán tử của separation logic
Bên cạnh các toán tử được sử dụng trong Hoare logic, separation logic cung cấp thêm một số toán tử sau để thuận tiện trong việc biểu diễn heap emp: biểu diễn một heap rỗng e e’: heap chứa một ô nhớ duy nhất, tại địa chỉ e và có giá trị là e’ p 1 * p 2 : heap được chia thành 2 phần heap không giao nhau là p 1 và p 2 p 1 - * p 2:
Như ta đã biết, Hoare logic chỉ có thể biểu diễn trạng thái của chương trình thông qua ánh xạ từ tên biến đến giá trị của biến đó trong các chương trình không chứa con trỏ Đối với các chương trình có chứa con trỏ, để biểu diễn trạng thái của chương trình, Hoare logic không thể biểu diễn trường hợp một con trỏ đang trỏ đến một ô nhớ nào đó, như trong ví dụ 2.3 Separation logic đã đề xuất toán tử , e e’ biểu diễn cho con trỏ e đang trỏ đến một ô nhớ có giá trị e’
Ví dụ 2.3: int * p = new int();
Thứ hai là vấn đề bí danh, Hoare logic không thể biểu diễn được trường hợp các con trỏ có trỏ đến cùng một địa chỉ bộ nhớ hay không Separation logic đã đề xuất toán tử *, p 1 * p 2 biểu diễn 2 vùng nhớ heap là không giao nhau, lúc này sẽ không xảy ra vấn đề bí danh
Ví dụ 2.4: Minh họa cho trường hợp p và q trỏ đến 2 vùng nhớ khác nhau int * p = new int(); int * q = new int();
Sau đây là một số ví dụ dùng separation logic để biểu diễn trạng thái của chương trình
Ví dụ 2.5: x3,y : biểu diễn x trỏ đến 2 ô nhớ liên tục trong heap Ô nhớ x trỏ đến có giá trị là 3, ô nhớ kế tiếp có giá trị là y (tức là, store ánh xạ x đến giá trị α và y đến giá trị β, α là một địa chỉ, khi đó heap ánh xạ α đến giá trị là 3 và ánh xạ địa chỉ α + 1 đến giá trị β)
Tương tự cho trường hợp
Ví dụ 2.6: x3,y y3,x : công thức này có nghĩa là: 2 phần heap không giao nhau, phần thứ nhất chứa 2 ô nhớ liên tục có giá trị lần lượt là 3 và y, và được trỏ đến bởi x, phần thứ hai chứa 2 ô nhớ liên tục là 3 và x, và được trỏ đến bởi y
Ví dụ 2.7: x3,y y3,x: biểu diễn 2 heap giao nhau, tức x và y trỏ đến cùng một ô nhớ
Bên cạnh đó, separation logic còn rất hữu dụng trong việc mô tả các cấu trúc dữ liệu phức tạp Để đặc tả chương trình một cách đầy đủ, cần phải mô tả nhiều hơn về các hình thức cấu trúc của nó và mối quan hệ giữa các trạng thái của chương trình đối với các giá trị trừu tượng mà chúng biểu thị Cho ví dụ, để biểu diễn cấu trúc danh sách trong separation logic, chúng ta có các định nghĩa sau, với α và β là những chuỗi:
• [x] là một chuỗi một phần tử duy nhất chứa x
• α.β là một chuỗi tạo ra bằng cách thêm β vào α
• α + là một dạng nghịch đảo (reflection) của α
• αi là phần tử thứ i trong chuỗi α
• list α(i, j) là một danh sách từ i tới j biểu diễn cho chuỗi α
Ví dụ 2.8: minh họa danh sách liên kết đơn
Chúng ta có thể định nghĩa bằng phương pháp quy nạp cho danh sách theo cấu trúc của α như sau: list (i,j) def emp i=j lista i j ji def
. ) , ( a,j list (j,k), và sử dụng định nghĩa này để chứng minh một số thuộc tính cơ bản cho danh sách list a(i, j) ia, j
) , (i k j list i j list j k list list b(i,k) j.list (i,j) jb,k
Ví dụ 2.9: minh họa xóa phần tử đầu tiên của một danh sách
Ngoài ra, separation logic còn dung cho những chương trình phức tạp hơn như đảo các phần tử của một danh sách
Ví dụ 2.10 : minh họa chương trình đảo các phần tử của một danh sách (nguồn [12])
Ví dụ 2.11: Biểu diễn danh sách liên kết đôi (doubly-linked list) (nguồn [12]) dlist α(i, i ’ , j, j ’ ) biểu diễn danh sách liên kết đôi cho chuỗi α từ i đến j, và từ j ’ ngược về i ’ Định nghĩa quy nạp cho danh sách liên kết đôi như sau: dlist (i, i ’ , j, j ’ ) def emp i=j i ’ =j ’ dlist a (i, i ’ , k, k ’ ) j i def
Từ định nghĩa này ta có thể suy luận ra các thuộc tính cơ bản sau cho danh sách liên kết đôi: dlist a (i, i ’ , j, j ’ ) ia, j, i ’ i=j ’ dlist i,i ' ,k,k ' j, j ' dlist i,i ' ,j, j ' dlist j, j ' ,k,k ' dlist b i,i ' ,k,k ' j ' dlist i,i ' ,k ' , j ' k ’ b, k, j ’
Phần này trình bày thêm các tiên đề cho phép suy luận trên separation logic dựa vào tiền điều kiện và hậu điều kiện, ngoài các tiền đề đã được giới thiệu trong Hoare logic
Hình 2.4 : Các tiên đề áp dụng trên separation logic (nguồn [12]) x: = E là một phép gán bình thường giống như trong Hoare logic
• Tiên đề đầu tiên phát biểu rằng nếu E trỏ vào một ô nhớ có giá trị bất kỳ thì sau đó khi gán nội dung của ô nhớ mà E trỏ đến cho F, thì cuối cùng ta sẽ có nội dung tại ô nhớ E là F Ý nghĩa của tiên đề này là một con trỏ chỉ trỏ tới một ô nhớ
• Tiên đề thứ hai phát biểu rằng nếu chúng ta sử dụng hàm dispose(E), thì ô nhớ được trỏ bởi E sẽ được trả lại cho hệ thống
HIP
HIP [16][17] là một hệ thống kiểm tra tự động các chương trình viết bằng ngôn ngữ mệnh lệnh đơn giản (imperative language) dựa trên separation logic HIP có thể kiểm tra các chương trình thao tác trên con trỏ Người dùng định nghĩa các vị từ ở dạng công thức của separation logic để mô tả hình dạng (shape) của cấu trúc dữ liệu cùng với các thuộc tính dẫn xuất, chẳng hạn như chiều cao, chiều dài, tập các giá trị được dùng trong cấu trúc dữ liêu … Người dùng cũng phải đặc tả tiền điều kiện và hậu điều kiện ở mỗi phương thức hoặc vòng lặp trước quá trình kiểm tra tự động HIP đầu tiên chuyển các công thức ở dạng separation logic sang các công thức thuần túy (công thức biến nguyên và boolean) có thể hiểu được bởi các công cụ chứng minh định lý (theorem prover), ví dụ như Omega (prover chứng minh các công thức Presburger-các công thức có chứa vị từ), Mona (dựa trên second-order logic), CVC Lite hoặc Isabelle
HIP được phát triển bởi giáo sư Chin Wei Ngan ở Đại học Quốc gia Singapore HIP có thể kiểm tra chương trình tự động trong khi chỉ yêu cầu kiến thức tối thiểu của người dùng về separation logic Ngoài ra, HIP có nhiều đóng góp không chỉ cho lĩnh vực nghiên cứu mà còn cả trong lĩnh vực công nghiệp
2.2.2 Kiến trúc của hệ thống HIP
Hình 2.5: Kiến trúc của HIP (nguồn [16])
HIP được hiện thực bằng ngôn ngữ OCaml, một ngôn ngữ lập trình hàm được phát triển bởi một nhà nghiên cứu người Pháp và được dùng phổ biến hiện nay
Trong hệ thống HIP, người dùng phải cung cấp các thông tin sau:
• Định nghĩa các vị từ để mô tả cấu trúc dữ liệu
• Định nghĩa tiền điều kiện và hậu điều kiện cho các phương thức cần kiểm tra
HIP có 2 thành phần chính: Hoare-style Forward Verifier và Entailment Prover
• Hoare-style Forward Verifier: yêu cầu input là code chương trình (có thể là interface) và các tiền/hậu điều kiện được định nghĩa bởi người sử dụng Thành phần này hoạt động dựa trên tập luật forward rules {∆1}code{∆2} trong đó Δ1 là tiền điều kiện và Δ2 là hậu điều kiện Dựa trên tập luật forward rules này, thành phần này sẽ thực hiện kiểm tra cho từng phương thức xem hậu điều kiện có được đảm bảo sau khi thực thi chương trình với tiền điều kiện được cho trước hay không
• Entailment Prover: dựa vào các vị từ định nghĩa cấu trúc dữ liệu do người dùng cung cấp, quá trình entailment sẽ kiểm tra các heap node có trùng nhau hay không (có thể chứng minh được trường hơp bí danh)
2.2.3 Ngôn ngữ đặc tả của HIP
HIP định nghĩa một văn phạm cho ngôn ngữ bên trong hệ thống HIP (gọi là core language) để sử dụng cho quá trình chứng minh như sau:
Hình 2.6: Văn phạm của HIP định nghĩa core language (nguồn [16])
2.2.4 Cơ chế kiểm tra tự động của HIP
Trước khi thực hiện việc kiểm tra, HIP chuyển ngôn ngữ input thành một dạng ngôn ngữ đặc biệt gọi là core language Sau đó HIP sẽ thưc hiện quá trình kiểm tra bằng việc sử dụng tập luật forward Dựa trên tập luật forward rules này, thành phần này sẽ thực hiện kiểm tra cho từng phương thức xem hậu điều kiện có được đảm bảo sau khi thực thi chương trình với tiền điều kiện được cho trước hay không
Vì các prover chỉ có thể hiểu được công thức kiểu cơ bản (integer và boolean) vì vậy, HIP thực hiện quá trình chuyển các công thức ở dạng separation logic sang các công thức thuần túy-pure formula (công thức chỉ có các kiểu dữ liệu cơ bản) và sau đó chuyển đến các prover để thực hiện quá trình kiểm tra
2.2.5 Mô tả chương trình input của HIP
Cấu trúc chương trình input của HIP gồm các phần chính sau đây:
Khai báo dữ liệu ( ví dụ như khai báo các cấu trúc node,…) Định nghĩa các vị từ cho cấu trúc dữ liệu bằng separation logic Khai báo các phương thức, định nghĩa các tiền điều kiện và hậu điều kiện cho các phương thức)
Kiểu dữ liệu cơ bản được sử dụng trong HIP mà các prover có thể hiểu được là kiểu nguyên (int) và kiểu luận lý(bool)
Ví dụ 2.12: chương trình định nghĩa danh sách liên kết trong HIP
/* representation of a node */ data node { int val; node next;
/* view for a singly linked list */ ll == self = null & n = 0 or self::node * q::ll inv n >= 0;
/* function to insert a node in a singly linked list */ void insert(node x, int a) requires x::ll & n > 0 ensures x::ll;
//dprint; node tmp = null; if (x.next == null) x.next = new node(a, tmp); else insert(x.next, a);
Phần khai báo cấu trúc dữ liệu
Mô tả cấu trúc node gồm 2 thuộc tính: thuộc tính thứ nhất là số nguyên,val và thuộc tính thứ hai là một cấu trúc node, next trỏ đến node tiếp theo trong cấu trúc dữ liệu Định nghĩa vị từ cho cấu trúc dữ liệu bằng separation logic
Công thức trong HIP được biểu diễn ở dạng chuẩn hội (disjunctive normal form), ví dụ như: A or B Mỗi phần hội có thể có 2 thành phần:
- công thức heap - công thức số học thuần túy (pure formula) ll == self = null & n = 0 or self::node * q::ll inv n >= 0; data node { int val; node next;
Trong HIP, công thức p::c(v*) biểu diễn 2 trường hợp:
- Nếu c là tên của cấu trúc dữ liệu, thì p::c(v*) có nghĩa là con trỏ p trỏ đến cấu trúc dữ liệu có tên c, và v* là danh sách đối số để biểu diễn nội dung của cấu trúc dữ liệu c
Ví dụ: p::node: có nghĩa là con trỏ p trỏ đến node có giá trị của thuộc tính val là x và con trỏ next là q
- Nếu c là vị từ thì p::c(v*) tương đương với công thức c(p,v*) Trong HIP thì mỗi vị từ có một thông số ngầm định là self, self là thông số đầu tiên của vị từ và có ý nghĩa như một con trỏ “root” trỏ đến cấu trúc dữ liệu đặc tả Con trỏ self được dùng để duyệt cấu trúc dữ liệu và nóthuận tiện cho việc định nghĩa các vị từ well-founded
Ví dụ: q::ll: có nghĩa là con trỏ q trỏ đến vị từ ll có một thông số là (n- 1) Lúc đó con trỏ ngầm định self của vị từ ll sẽ tương đương với q
Vị từ ll định nghĩa một danh sách liên kết có chiều dài n Danh sách liên kết này sẽ là hội của 2 trường hợp:
- trường hợp danh sách rỗng, biểu diễn bởi (self = null & n = 0) Công thức này gồm có 2 thành phần, thành phần công thức heap là
(self = null) và thành phần thứ hai là công thức ở dạng số học thuần túy (pure formula) là (n = 0)
- trường hợp danh sách khác rỗng, biểu diễn bởi
(self::node * q::ll) Danh sách liên kết lúc này sẽ gồm một data node được trỏ đến bởi con trỏ “root” là self, và phần cấu trúc dữ liệu theo sau được định nghĩa đệ quy cũng là một danh sách liên kết ll với chiều dài n-1 được trỏ đến bởi con trỏ next của node đầu tiên của danh sách, q::ll Toán tử * đảm bảo cho node dữ liệu đầu tiên của danh sách và phần danh sách còn lại thuộc hai heap không giao nhau
Ngoài ra, trong công thức định nghĩa vị từ ll này còn đặc tả bất biến (invariant) n >= 0, bất biến này là công thức ở dạng pure formular và luôn đúng đỗi với tất cả danh sách ll
Khai báo các phương thức, định nghĩa các tiền điều kiện và hậu điều kiện cho các phương thức)
Phương thức insert(node x, int a) thêm node có giá trị là a vào danh sách liên kết được trỏ đến bởi x như con trỏ “root”
- Định nghĩa tiền điều kiện (pre-condition) cho phương thức insert của danh sách liên kết requires x::ll & n > 0
Tiền điều kiện của phương thức insert lúc này là đảm bảo danh sách phải khác rỗng và có chiều dài là n trước khi insert node a vào danh sách
- Định nghĩa hậu điều kiện (post-condition) cho phương thức insert ensures x :: ll < n +1>;
Hậu điều kiện của phương thức insert sau khi thêm node a vào danh sách (được đảm bảo thỏa mãn tiền điều kiện) phải đảm bảo x trỏ đến danh sách có chiều dài là n+1
Kỹ thuật concolic testing
Concolic testing là một kỹ thuật kết hợp giữa kỹ thuật thực thi chương trình dựa trên input cụ thể (concrete execution) và kỹ thuật thực thi symbolic (symbolic execution) [15] Concolic testing được xem là một kỹ thuật sinh test-case hiệu quả trong lĩnh vực kiểm tra chương trình Kỹ thuật thực thi symbolic được sử dụng kết hợp với một bộ chứng minh định lý tự động (theorem prover) hoặc bộ giải ràng buộc (solver) dựa trên lập trình logic ràng buộc để sinh ra các input cụ thể (test- case) nhằm tối đa hóa độ bao phủ code (code coverage) Mục tiêu chính của phương pháp concolic testing là tìm kiếm lỗi xuất hiện trong chương trình, hơn là chứng minh tính đúng đắn của chương trình
Về cơ bản, giải thuật concolic testing hoạt động như sau:
1 Phân loại tập các biến đầu vào Các biến này sẽ được coi là các biến symbolic trong quá trình thực thi symbolic Tất cả các biến khác sẽ được xem là các giá trị cụ thể
2 Cải tiến chương trình sao cho mỗi tác vụ có thể ảnh hưởng đến giá trị của một biến symbolic hoặc một điều kiện đường dẫn (path condition) sẽ được lưu lại trong một file, cũng như khi bất kỳ lỗi xảy ra
3 Chọn một tập các giá trị đầu vào tùy ý để bắt đầu thực thi chương trình
5 Thực thi symbolic chương trình trên các tập tin lưu được, tạo ra một tập các ràng buộc symbolic (gồm các điều kiện đường dẫn)
6 Phủ định điều kiện cuối cùng chưa từng được phủ định của điều kiện đường dẫn để thực thi chương trình theo một đường thực thi mới Nếu không có điều kiện đường dẫn nào như vậy, giải thuật dừng
7 Gọi một bộ chứng minh định lý tự động (prover) để sinh ra một đầu vào mới
Nếu không có đầu vào thỏa mãn các ràng buộc, lặp lại bước 6 để thử các đường thực thi tiếp theo
Bảng 2.1 Ví dụ minh họa cho concolic testing void foo (int x, int y) {
Ví dụ, với chương trình được đưa ra trong bảng 2.1, có hai điều kiện nhánh là (x ! y) và (2*x = x + 10) Đối với kỹ thuật kiểm thử hộp trắng (white-box testing), sẽ có
3 điều kiện đường dẫn cần được xem xét Khi áp dụng kỹ thuật concolic testing, đầu tiên giải thuật sẽ sinh ngẫu nhiên giá trị cho x và y, ví dụ: x = 1 và y = 2 Ở bước thực thi chương trình với các giá trị cụ thể (concrete execution), chương trình sẽ thực thi dòng lệnh 0 vì điều kiện (x != y) là đúng, nhưng dòng lệnh 1 không được thực thi vì điều kiện (2*x = x + 10) không thỏa Đồng thời, quá trình thực thi symbolic (symbolic execution) dựa theo đường dẫn này xử lý x và y như là các biến symbolic Điều kiện (x! = y) (2 * x != x + 10) bây giờ được gọi là điều kiện đường dẫn (path conditions) Để thực thi chương trình theo một đường thực thi khác trong lần chạy tiếp theo, phương pháp concolic testing lấy điều kiện cuối cùng của điều kiện đường dẫn vừa xử lý, tức là (2*x != x + 10), phủ định điều kiện này, ta được (2*x = x + 10) Một bộ chứng minh tự động (prover) được gọi để tìm giá trị đầu vào của biến x và y thỏa mãn điều kiện mới (x != y) (2*x = x + 10) Giả sử x
= 10, y = 5, thực thi chương trình với các giá trị inputs này sẽ dẫn đến lỗi Như vậy, khi áp dụng kỹ thuật concolic testing, trong trường hợp này ta chỉ cần duyệt qua 2 nhánh của chương trình để phát hiện được lỗi mà không cần phải duyệt qua tất cả các đường thực thi có thể của chương trình.
Kỹ thuật slicing
Static Slicing [20][22] là kỹ thuật slice chỉ dựa vào mã nguồn của chương trình Tập slice các câu lệnh sẽ được trích xuất dựa vào một tiêu chí (slicing criterion) cho trước
Tiêu chí slice: trong đó, là câu lệnh của chương trình chứa kết quả ta cần quan tâm; là tập slice sẽ được trích xuất ra từ chương trình P
Phương pháp Static Slicing chủ yếu dựa vào đồ thị phụ thuộc của chương trình
(Program Dependence Graph – PDG) để thực hiện kỹ thuật slice Đồ thị này được xây dựng dựa trên sự phụ thuộc dữ liệu (data dependence) và phụ thuộc điều khiển
(control dependence) giữa các câu lệnh của chương trình Đồ thị phụ thuộc chương trình PDG (Program Dependence Graph): PDG là đồ thị mà trong đó, các node đại diện cho các câu lệnh của chương trình Các câu lệnh của chương trình có thể là những câu lệnh đơn như các lệnh gán (assignment), đọc ghi dữ liệu (read & write), hoặc các lệnh phức hợp như các lệnh if then (else), switch case, while do, Các node trong đồ thị được “nối” với nhau bằng các cạnh Trong đồ thị PDG có 2 loại cạnh cơ bản là cạnh phụ thuộc dữ liệu (data dependence) và cạnh phụ thuộc điều khiển (control dependence) Định nghĩa về sự phụ thuộc dữ liệu (Data Dependence): giả sử câu lệnh i (i chính là line number của câu lệnh đó) của chương trình sau khi chuyển đổi trở thành node v i của đồ thị PDG còn câu lệnh j được đại diện bởi node v j Khi đó, nếu node v i có sử dụng dữ liệu của một biến nào đó mà giá trị của biến đó lại được set ở câu lệnh v j thì có nghĩa là node v i phụ thuộc dữ liệu vào node v j Vì v i phụ thuộc dữ liệu vào v j nên trong đồ thị PDG sẽ có một “cạnh” từ node v i đến node v j Định nghĩa về sự phụ thuộc điều khiển (Control Dependence): node v i gọi là phụ thuộc điều khiển vào node v j nếu như sự thực thi của câu lệnh i phụ thuộc vào biểu thức điều kiện của câu lệnh điều kiện j Khi đó, trong đồ thị PDG tồn tại cạnh phụ thuộc điều kiển nối từ node v i đến node v j
Bảng 2.1 Chương trình minh họa cho PDG
1 int func (node x, int a) 2 requires x::ll & n>0 3 ensures x::ll;
Hình 2.8 : Đồ thị phụ thuộc chương trình (PDG) cho chương trình ở bảng 2.1
Giải thuật Static Slicing dựa vào PDG để tìm những câu lệnh có khả năng tác động lên biến cần quan tâm Bắt đầu từ node tương ứng với câu lệnh chứa biến cần quan tâm, Static Slicing tìm tất cả các node mà phụ thuộc vào (cả phụ thuộc dữ liệu lẫn phụ thuộc điều khiển) rồi lặp lại giải thuật đối với các node vừa tìm được
Dựa vào PDG của chương trình, tập slice trích xuất được với tiêu chí C = (9, x.data) sẽ bao gồm các dòng lệnh như hình dưới đây:
Bảng 2.2 Ví dụ minh họa cho PDG int func (node x, int a) requires x::ll & n>0 ensures x::ll;
{ if (a < 0) x.data++; else x.data ; return x.data;
Khi tiêu chí được chọn là và sử dụng phương pháp Static Slicing thì tập slice trích xuất ra chính là toàn bộ chương trình Khi đó, phương pháp Static Slicing không còn hiệu quả nữa
Thêm vào đó, khi ta debug chương trình với nhiều input đầu vào khác nhau
(nhưng tiêu chí slice vẫn không đổi) thì tập slice trích xuất được hoàn toàn giống nhau trong mọi trường hợp Do đó, Static Slicing không hỗ trợ debug tốt đối với một input đầu vào cụ thể Đối với mỗi input khác nhau sẽ có một tập các câu lệnh khác nhau được thực thi Do đó, chỉ có một tập con của tập thực thi đó mới tiềm ẩn lỗi của chương trình
Vì vậy cần có một phương pháp tiếp cận khác để slice chương trình dựa vào một input cụ thể
Dynamic Slicing [22] là phương pháp tiếp cận mới khắc phục được nhược điểm của
Static Slicing Dynamic Slicing dựa vào Execution History (EH) và Definition/Use Program Representation (D/U) để trích xuất tập slice
Execution History là tập hợp có thứ tự chứa các câu lệnh được thực thi của chương trình khi nhập vào một input cụ thể Ví dụ, khi input của chương trình ở bảng 2.1 là x::ll and a = -2 thì EH = Chỉ số bên dưới là ID của câu lệnh (trong trường hợp này lấy số thứ tự của câu lệnh làm ID) còn chỉ số trên là thứ tự của câu lệnh được thực thi trong EH
Definition/Use Program Representation là một cấu trúc dùng để đại diện cho chương trình Trong cấu trúc của D/U bao gồm 3 thành phần cơ bản: i, d, U trong đó i là ID của các câu lệnh trong chương trình, d là tên biến được define (nếu đó là lệnh gán), hoặc là đại diện cho phát biểu điều kiện (ký hiệu là p nếu đó là lệnh rẽ nhánh) hoặc là đại diện cho phát biểu return (ký hiệu là o) U là các biến được sử dụng và câu lệnh mà i j phụ thuộc điều khiển vào Lưu ý là giá trị d ở mỗi dòng trong D/U chỉ chứa một và chỉ một giá trị duy nhất trong khi U có thể chứa nhiều giá trị
Bảng 2.3: Ví dụ minh họa tập D/U của chương trình trong bảng 2.1 i d U
Tiêu chí slice của phương pháp Dynamic Slicing bao gồm C = (x, i j , V)
Trong đó, x là input đầu vào của chương trình, i j là câu lệnh thuộc EH có chứa kết quả cần quan tâm, V là tập slice được trích xuất
Với tiêu chí slice là C = (x ::ll a = -2, 9 4 , V) thì tập slice V = Trong tập V không có lệnh số 8 vì lệnh đó không ảnh hưởng đến kết quả của chương trình (với test-case x ::ll a = -2)
Hình 2.9: Giải thuật Dynamic Slicing (nguồn [20])
Trong đó: LS là ID của câu lệnh i j Sau đây là ví dụ minh hoạ cho giải thuật Dynamic Slicing cho chương trình ở bảng 2.1 với C = (x ::ll a = -2, 9 4 , V), khi đó:
Bảng 2.4: Minh họa giải thuật Dynamic Slicing cho chương trình trong bảng 2.1 i j d U DynSlice(d) LS(d)
Xem chương trình minh họa ở bảng 2.5
Bảng 2.5: Chương trình minh họa cho giải thuật Relevant Slicing
1 int func (node x) 2 requires x::ll & n>0 3 ensures x::ll;
Giả sử input của chương trình ở bảng 2.5 là x::ll Khi đó lịch sử thực thi EH của chương trình là EH =
Nếu ta chọn tiêu chí slice C = (x::ll, 10 6 , V) và áp dụng phương pháp
Dynamic Slicing thì ta được tập slice V =
Phương pháp Dynamic Slicing tuy hiệu quả hơn nhiều so với phương pháp
Static Slicing tuy nhiên nó vẫn còn điểm hạn chế Trong ví dụ trên, giả sử như chương trình có lỗi ở câu lệnh số 7, thay vì biểu thức điều kiện là (a>= 0) nhưng do lỗi người lập trình bất cẩn nên biểu thức điều kiện trong chương trình là (a>0) dẫn đến chương trình chạy sai Hay giả sử như biểu thức điều kiện số 7 đúng, nhưng phát biểu ở câu lệnh số 6 phải là int a = 1 mới đúng Như vậy, nếu như các câu lệnh số 6 và số 7 xảy ra lỗi, dẫn đến câu lệnh số 8 không được thực thi thì kết quả do đó cũng bị sai Tập slice trích xuất được của chương trình nên có thêm các câu lệnh số
6 và số 7 nữa mới đảm bảo tính đầy đủ của các câu lệnh tiềm ẩn lỗi dẫn đến kết quả sai
Giải thuật Relevant Slicing được phát triển dựa trên nền tảng của giải thuật
Dynamic Slicing Relevant Slcing cũng dựa vào Execution History và D/U Program Represen-tation để thực hiện slice Thêm vào đó, Relevant Slicing phát triển đồ thị
Extended Program Dependence Graph (EPDG) bằng cách thêm các cạnh
Conditional Flow Edge (cạnh phụ thuộc điều kiện) vào đồ thị PDG (của phương pháp Static Slicing)
Phụ thuộc điều kiện (Conditional Dependence): Ta nói câu lệnh m phụ thuộc điều kiện (conditional dependence) vào câu lệnh p nếu có ít nhất một câu lệnh n sao cho câu lệnh m phụ thuộc dữ liệu (data dependence) vào câu lệnh n và câu lệnh n phụ thuộc điều khiển (control depend-ence) vào câu lệnh p Khi đó, giữa m và p tồn tại một cạnh phụ thuộc điều kiện (conditional flow edge) p n m
Hình 2.10 Minh họa phụ thuộc điều kiện
Giải thuật Relevant Slicing có ưu điểm hơn giải thuật Dynamic Slicing ở chỗ
Relevant Slicing thêm vào trong tập slice các câu lệnh điều kiện có khả năng tác động tiềm ẩn lên kết quả, còn Dynamic Slicing sẽ loại bỏ câu lệnh điều kiện đó nếu như nó không ảnh hưởng đến kết quả
Phụ thuộc tiềm ẩn (Potential Dependence): câu lệnh m gọi là phụ thuộc tiềm ẩn vào câu lệnh p nếu như trong Execution History cả hai câu lệnh m và p đều được thực thi, câu lệnh p được thực hiện trước câu lệnh m, đồng thời trong đường thực thi của chương trình từ p tới m, câu lệnh n không được thực thi
Hình 2.11: Minh họa giải thuật Relevant Slicing (nguồn [20])
LP(d): thứ tự của câu lệnh hiện hành trong EH
RelSlice: tập hợp chứa các câu lệnh đã được slice theo giải thuật Relevant
PotDep(d): chứa các câu lệnh u mà d và u có potential dependence
DataDepSet (PotDep(d)): chứa các câu lệnh u mà các câu lệnh v (v PotDep(d)) phụ thuộc dữ liệu vào u
DataDep(p): với p PotDep(d) thì DataDep(p) chứa các câu lệnh u mà p có phụ thuộc dữ liệu vào u
Giả sử input của chương trình ở bảng 2.5 là x::ll Khi đó lịch sử thực thi EH của chương trình là EH =
Nếu ta chọn tiêu chí slice C = (x::ll, 10 6 , V) và áp dụng phương pháp Relevant Slicing thì ta được tập slice V =
Bảng 2.6: Minh họa giải thuật Relevant Slicing cho chương trình trong bảng
Hệ thống sinh logic test-case dựa trên kỹ thuật slicing
Các tiền đề
Bảng 3.1: Xóa i phần tử đầu tiên của một danh sách liên kết
/*function of remove the first i elements of a singly linked list*/
1 void remove_heads (node x, int i) 2 requires x::ll
6 //extra code for some other calculation that might not be relevant to the data structure x 7 int tmpVal;
Trong phần này, chúng ta lấy một ví dụ để làm rõ hơn vấn đề mà luận văn đang nghiên cứu Bảng 3.1 minh họa chương trình thực hiện xóa i phần tử đầu tiên của một danh sách liên kết Chúng ta có thể dễ dàng thấy rằng có một lỗi xảy ra trong thân vòng lặp khi danh sách liên kết là danh sách rỗng Khi kiểm tra chương trình trong Listring 3, hầu hết các provers sẽ thất bại vì để được kiểm tra một cách hiệu quả vòng lặp cần có một bất biến (invariant), thậm chí ngay cả với các hệ thống đặc biệt dành để xử lý con trỏ như HIP Việc suy diễn bất biến cho các vòng lặp là một việc tương đối khó, đặc biệt là đối với những lập trình viên Vì vậy, đối với một chương trình dựa trên vòng lặp, người ta chủ yếu vẫn dựa vào thử nghiệm để phát hiện các lỗi có thể xảy ra Áp dụng phương pháp concolic testing, sinh ngẫu nhiên các giá trị đầu vào, ví dụ x:: ll (có nghĩa x là một danh sách liên kết có 1 phần tử) và i = 0 Chạy chương trình với các giá trị đầu vào này, mã nguồn trong vòng lặp while không được thực thi, vì (i = loop) Bây giờ điều kiện đường dẫn là (i 0 i % 2 = 0), giải ràng buộc này ta thu được giá trị cụ thể cho các biến input, giả sử là x ::ll and i = 2 Tuy nhiên, ở 2 lần thực thi ứng với 2 test-case (x::ll i = 1) và (x::ll i = 2), code trong vòng lặp while tuy được thực thi, nhưng vẫn không thể phát hiện được lỗi
Vì vậy, chúng ta mong muốn sinh ra các test-case bao gồm cả hai trường hợp danh sách liên kết rỗng, và danh sách không rỗng Để thực hiện điều này, phương pháp concolic testing nên được mở rộng để phân tích không chỉ trên control-flow của chương trình mà còn phân tích cả phần đặc tả (specification) của các thông số Phần nghiên cứu của đề tài này không nhằm mục đích tạo ra các test-case cụ thể như vậy, ở đây chỉ sinh ra các công thức được biểu diễn trong separation logic Tương ứng với 2 trường hợp danh sách rỗng hay không rỗng và 4 control-flow của chương trình, chúng ta có 8 trường hợp:
(1a) x::ll i > 0 i % 2 = 0 (1b) x::ll i > 0 i % 2 != 0 (2a) x::ll n > 0 i > 0 i % 2 = 0 (2b) x::ll n > 0 i > 0 i % 2 != 0 (3a) x::ll i 0 i 0 i 0 i 0 x.next = 0 Khi dùng prover Z3 để giải ràng buộc này, chúng ta có thể thu được giá trị n = 2 Giá trị này không chính xác vì x.next = 0 n> 0, điều này có nghĩa là danh sách chỉ có 1 phần tử
Bảng 4.7: Hàm thực hiện insert một node vào danh sách liên kết đơn
/* function to insert a node into a singly linked list */ void insert(node x, int a) requires x::ll & n > 0 ensures x::ll;
{ node tmp = null; if (x.next == null) x.next = new node(a, tmp); else insert(x.next, a);
Nguyên nhân dẫn đến kết quả thiếu chính xác này là vì chúng ta bị mất thông tin về mối quan hệ giữa x và x.next khi thực hiện việc chuyển đổi từ separation logic sang pure logic Thông tin quan trọng chúng ta cần phải lưu trữ lại là mối quan hệ giữa x và x.next, tức là chúng ta phải suy ra được x.next::ll Khi hệ thống của chúng ta có thể suy luận ra x.next::ll và thêm công thức mới này vào ràng buộc đầu tiên, chúng ta sẽ có được một ràng buộc mới x::ll n > 0 x.next null x.next::ll Chúng ta gọi ràng buộc mới được tạo ra là ràng buộc mở rộng (expanded constraint) Sau đó, áp dụng Xpure trên ràng buộc mở rộng này, chúng ta có thể sinh ra các ràng buộc pure logic (pure constraint) [(x = 0 n = 0) (x > 0 n > 0)] n > 0 x.next = 0 [(x.next = 0 n-1 = 0) (x.next > 0 n-1 >
0)] Từ ràng buộc này, chúng ta có thể chia thành các trường hợp sau đây:
Case 1: (x = 0 n = 0) n > 0 (x.next = 0 n-1 = 0) x.next = 0 Case 2: (x = 0 n = 0) n > 0 (x.next > 0 n-1 > 0) x.next = 0 Case 3: (x > 0 n > 0) n > 0 (x.next = 0 n-1 = 0) x.next = 0
Case 4: (x > 0 n > 0) n > 0 (x.next > 0 n-1 > 0) x.next = 0 Trong 4 trường hợp trên, chỉ có trường hợp 3 là khả thỏa mãn (satisfiable), giải trường hợp 3 bằng prover Z3 chúng ta thu được test-case ở dạng pure logic chính xác, ví dụ x = 2 x.next = 0 n = 1 Để suy luận ra những ràng buộc mở rộng từ những ràng buộc kết hợp ban đầu, luận văn phát triển một tập luật gọi là luật mở rộng (expansion rules) sẽ được trình bày chi tiết trong phần này Trong phần trình bày tập luật mở rộng, ký hiệu p::c(v * ) biểu thị cho hai trường hợp sau trong hệ thống:
Nếu c là tên dữ liệu (ví dụ như tên của cấu trúc Node), khi đó p::c(v * ) biểu diễn một singleton heap p[(v) * ] với v * là những thuộc tính (fields) của phần khai báo dữ liệu c
Nếu c là tên vị từ (predicate), thì p::c(v * ) biểu diễn công thức c(p,v * ) với p là tham số ẩn self của vị từ và v * là những tham số của vị từ Để thuận tiện, luận văn sử dụng lại những ký hiệu giống trong hệ thống HIP, trong đó
: biểu diễn những công thức độc lập với heap (heap-independent)
: biểu diễn những công thức liên quan đến heap ở dạng separation logic
4.2.1 Luật mở rộng (Expansion rules)
4.2.1.1 Luật lưu trữ dữ liệu (Rules for capturing data) Để lưu trữ thông tin bị mất đi khi thực hiện chuyển đổi từ separation logic sang pure logic, chúng ta định nghĩa hai function Store và Store 0 , được trình bày trong hình 4.1 Mục đích của tập luật này là để suy diễn thêm các công thức hợp lý từ công thức ban đầu
Store self p v c p Store v c c edicate is
Hình 4.1: Luật lưu trữ dữ liệu
Xét công thức = x::ll và vị từ ll = (self = null n = 0) (self::node
* q::ll) Gọi là phần thân của định nghĩa vị từ ll, = (self = null n = 0) (self::node * q::ll) Áp dụng hàm Store 0 lên , chúng ta có:
Store 0 ( ) = Store 0 (self = null n = 0) Store 0 (self::node * q::ll) = (self = null n = 0) [Store 0 (self::node) Store 0 (q::ll)]
= (self = null n = 0) [(self.val = _ self.next = q) (q::ll)]
/[ p self Store 0 biểu diễn phép thay thế self bằng p trong công thức Store 0 ( )
Vì vậy, Store (x::ll) = [x/self] Store 0 ( )
Kết quả (1) đạt được từ bước Store có dạng đệ quy vì vị từ ll được định nghĩa đệ quy Sau đó, chúng ta sẽ tiếp tục sử dụng luật thay thế (substitution rule) trong bước tiếp theo để thu được công thức mở rộng cuối cùng
4.2.1.2 Luật thay thế (Substitution Rule-SR)
Nếu kết quả của hàm Store có dạng đệ quy, chúng ta sẽ áp dụng luật thay thế
Substitution Rule để suy diễn ra công thức mở rộng Luật thay thế Substitution Rule được định nghĩa như sau:
Giả sử chúng ta xác định được nơi vị từ được đệ quy (tức là nơi vị từ được gọi lại trong thân của vị từ) Sau khi áp dụng hàm Store cho công thức heap x:: c(v x * ), chúng ta có:
Vì vị từ c(v*) có dạng well-founded [ ] (được định nghĩa trong HIP), vì vậy q có thể tiến đến được từ x Bây giờ điều chúng ta cần là q phải có khả năng truy xuất trực tiếp từ x, có nghĩa là q phải là một thuộc tính (field) của x Để suy luận được điều này, chúng ta áp dụng luật thay thế SR cho đến khi q có thể được truy xuất trực tiếp từ x
Xét ràng buộc đầu tiên trong bảng 4.7, x::ll n > 0 x.next = null Như đã nói trước đây, ràng buộc mới phải bao gồm công thức x.next::ll với vị từ ll được định nghĩa đệ quy Từ kết quả ở ví dụ 4.1, chúng ta có Store(x::ll) = (x = null n = 0) [(x.val = _ x.next = q) (q::ll)] Ta thấy, q không thể truy xuất trực tiếp từ x, vì vậy áp dụng luật thay thế Substitution Rule SR như sau
Chúng ta có được x.next::ll, lúc này x.next đã có thể truy xuất trực tiếp từ x, vì vậy ngừng áp dụng luật thay thế, ràng buộc mới là x::ll n > 0 x.next null x.next::ll được sinh ra bằng việc thêm công thức mới được suy diễn x.next::ll vào ràng buộc ban đầu.
4.2.1.3 Luật đệ quy (Recursive Rule-RR)
Trong một vài trường hợp phức tạp hơn, ràng buộc kết hợp được tạo ra không chỉ đơn giản chứa công thức x.next mà có thể chứa cả x.next.next, x.next.next.next, … Vì vậy, chúng ta cũng cần suy diễn được x.next.next::ll, x.next.next.next::ll,…Luật đệ quy Recursive Rule RR sẽ giúp ta giải quyết vấn đề này
Giả sử ta có khai báo dữ liệu sau d(v d * ) và có p :: c ( v * ) p v d :: c ( u * )với v d là một thuộc tính (field) nào đó của khai báo dữ liệu d(v d * ) Gọi g i là ánh xạ đi từ miền các giá trị có thể của v i (d(v i )) đến miền các giá trị có thể của u i (d(u i )) với g i (v i ) = u i , i Chúng ta phát biểu luật đệ quy RR như sau:
Ví dụ 4.3: Ở ví dụ 4.2, sau khi áp dụng luật thay thế SR, chúng ta có x.next::ll
Mặt khác, ban đầu chúng ta có x::ll Áp dụng luật đệ quy RR với ánh xạ g được xác định g(x) = x-1, chúng ta thu được x.next.next::ll Tiếp tục áp dụng luật đệ quy RR, ta cũng dễ dàng suy luận được x.next.next.next::ll,… nếu cần thiết
4.2.2 Luật phục hồi test-case (Restoration rules)
Bước cuối cùng của quá trình sinh test-case là khôi phục các test-case từ pure logic sang separation logic Luật phục hồi Restoration Rule được đề xuất dựa trên heuristic Quá trình phục hồi bao gồm hai bước chính như sau:
Hiện thực hệ thống sinh logic test-case dựa trên slicing
Tổng quan về hệ thống
Chương 5 sẽ trình bày quá trình hiện thực hệ thống sinh test-case dựa trên kỹ thuật slicing Như đã đề cập ở chương 3, framework hoàn chỉnh cho hệ thống được trình bày trong hình 3.1 Framework này bao gồm 2 modules chính, đó là module slicing và module sinh test-case
Module Slicing thực hiện nhiệm vụ sinh ra tập slice của test-case tương ứng, đồng thời cũng tạo ra điều kiện đường dẫn (path condition) tương ứng với test-case đó để cung cấp cho module sinh test-case Module sinh test-case sẽ sử dụng điều kiện đường dẫn nhận từ module slicing để thực hiện các bước cần thiết để sinh ra test- case tương ứng.
Xây dựng các thành phần của hệ thống
Module slicing gồm có 3 thành phần chính như được mô tả ở chương 3:
Bộ tính toán Đồ thị phụ thuộc chương trình và Tập biểu diễn Defined/Used (PDG & Defined/Use Calculator): dựa trên source code của chương trình, thành phần này sẽ xây dựng một đồ thị phụ thuộc và dạng biểu diễn Defined/Used tương ứng với chương trình
Bộ giả lập thực thi chương trình (Execution simulator): sẽ chạy giả lập chương trình với một input là một test-case cụ thể, cho kết quả là lịch sử thực thi (execution history) tương ứng với test-case đó
Bộ thực thi giải thuật Relevant slicing (Relevant slicing algorithm): nhận
PDG và dạng biểu diễn Defined/Used của chương trình (từ Bộ tính toán Đồ thị phụ thuộc chương trình và Tập biểu diễn Defined/Used) và lịch sử thực thi chương trình ứng với một test-case cho trước (từ Bộ giả lập thực thi chương trình) làm input Bộ thực thi giải thuật Relevant slicing sẽ áp dụng giải thuật relevant slicing để sinh ra tập slice tương ứng, đồng thời cũng tạo ra điều kiện đường dẫn cung cấp cho module sinh test-case
Hình 5.1: Hiện thực chi tiết module Slicing
Tuy nhiên, để xây dựng Đồ thị phụ thuộc chương trình và Tập biểu diễn Defined/Used từ source code chương trình, hệ thống trước tiên hiện thực một bộ chuyển đổi trung gian để biến đổi mã nguồn ở dạng ngôn ngữ HIP thành cây cú pháp trừu tượng-AST Cây AST này sẽ được dùng làm input để tạo ra đồ thị phụ thuộc chương trình PDG và tập biểu diễn Defined/Used Cũng dựa vào cây AST này, bộ mô phỏng sẽ chạy giả lập chương trình trên cây AST này từ test-case input, kết quả thu được là tập các dòng lệnh được thực thi với input tương ứng hay còn gọi là lịch sử thực thi chương trình Bộ chuyển đổi chương trình thành cây AST tạo grammar cho ngôn ngữ HIP, sau đó chuyển mã nguồn chương trình thành cây AST
Duyệt cây AST hiện thực dựa trên mẫu thiết kế Visitor Pattern
Các thành phần PDG & Defined/Use Calculator, Execution simulator, Relevant slicing algorithm được hiện thực như những module chức năng gồm nhiều lớp chức năng thực hiện nhiệm vụ mô tả
Module Relevant Slicing sẽ mở rộng đồ thị PDG thành đồ thị EPDG (Extended Program Dependence Graph) bằng cách thêm vào đồ thị PDG các cạnh phụ thuộc điều kiện (conditional dependence edge) Giải thuật Relevant Slicing sẽ dựa vào đồ thị EPDG và lịch sử thực thi chương trình (Execution History) để tính toán sự phụ thuộc tiềm ẩn (Potential Dependence) rồi sau đó tiến hành Slice chương trình
Module Slicing đồng thời tạo ra điều kiện đường dẫn (path condition) từ lịch sử thực thi (execution history) Điều kiện đường dẫn này sẽ được dùng làm đầu vào cho module sinh test-case
Module sinh test-case bao gồm 4 quá trình chủ yếu sau:
Kết hợp ràng buộc (Constraint Combination): Quá trình này thực hiện kết hợp phần đặc tả (mà cụ thể là tiền điều kiện của một hàm) với điều kiện đường dẫn
(path conditions) của chương trình nhận từ module slicing để tạo ra các ràng buộc được kết hợp (combined constraints)
Chuyển đổi sang pure logic (Pure Logic Conversion): Phần này thực hiện chuyển những ràng buộc được kết hợp ở bước Kết hợp ràng buộc thành những công thức ở dạng pure logic Kết quả của bước này là tạo ra những ràng buộc ở dạng pure logic, còn gọi là pure constraints Vì việc chuyển đổi từ những công thức ở dạng separation logic sang pure logic là đúng đắn (sound) nhưng không hoàn toàn (complete), tôi đề xuất một tập luật gọi là Luật Mở rộng (expansion rules) Tập luật này được suy diễn từ phần đặc tả của cấu trúc dữ liệu được sử dụng trong chương trình và sẽ được mô tả chi tiết ở chương 4
Sinh ra pure test-case (Pure Test-case Generation): Ở bước này, chúng ta sử dụng một prover để giải các ràng buộc ở dạng pure logic, sinh ra các test-case tương ứng ở dạng pure logic (còn gọi là pure test-case)
Phục hồi test-case (Test-case Restoration): Quá trình này sẽ phục hồi các pure test-case ở bước 3 thành các logic test-case ở dạng separation logic Quá trình này sử dụng luật restoration rules sẽ được trình bày chi tiết ở chương 4
Hiện thực tập luật mở rộng (expansion rules) và tập luật phục hồi (restoration rules) để giúp quá trình suy diễn qua các bước sinh ra test-case
4 bước sinh test-case bao gồm Kết hợp ràng buộc, Chuyển đổi sang pure logic, Sinh ra pure test-case, Phục hồi test-case được hiện thực gồm các class chức năng thực hiện nhiệm vụ mô tả
Module Test-case Generation sau khi nhận được Path Condition thì được reindex và lưu vào một danh sách các đường dẫn đã duyệt qua Sau đó điều kiện đường dẫn này được phủ định điều kiện cuối, đồng thời cũng bỏ vào Stack phần Path Condition chưa được phủ định Sau đó kiểm tra điều kiện mới này đã được duyệt qua chưa dựa vào danh sách các đường dẫn đã duyệt qua được lưu lại Nếu đường dẫn chưa được duyệt qua, thì sẽ được đưa vào Solver Z3 để sinh ra test-case tương ứng Ngược lại, lấy từ stack điều kiện đường dẫn và tiếp tục đưa vào Z3 cho quá trình sinh test-case
Kết quả này sẽ được xử lý để tạo test-case mới Nếu không có test-case mới nào được sinh ra thì tiếp tục lấy Path Condition trên đỉnh Stack, phủ định nó và đưa vào
Z3 Khi stack điều kiện đường dẫn rỗng, nghĩa là không còn đường thực thi mới nào, hệ thống sẽ dừng.
Kết quả thực nghiệm và đánh giá
Các điều kiện thử nghiệm
Hệ thống được chạy thử nghiệm trên bộ benchmark input của HIP, gồm các file định nghĩa cấu trúc dữ liệu như danh sách liên kết (linked list), stack, queue,
…Ngoài ra, luận văn còn thực hiện chuyển một số benchmark của các tool làm việc trên cấu trúc dữ liệu như Smallfoot, và Slayer sang dạng input của hệ thống HIP để tiến hành thử nghiệm Mỗi cấu trúc dữ liệu sẽ định nghĩa nhiều hàm (function) thao tác trên cấu trúc dữ liệu đó, ví dụ như cấu trúc dữ liệu danh sách liên kết thường có các hàm như hàm append để nối 2 danh sách liên kết với nhau thành danh sách cuối cùng, các hàm insert, delete để thêm hay xóa một phần tử từ danh sách, hàm create_list để tạo ra danh sách liên kết từ các node cho trước, hàm reverse thực hiện đảo các phần tử của danh sách liên kết Thêm vào đó, tool
Smallfoot cung cấp thêm một số hàm để duyệt qua các phần tử của một danh sách, hàm copy để copy một danh sách nguồn sang danh sách đích, hàm filter để xóa đi các phần tử, … Ngoài ra, luận văn cũng tạo ra một số chương trình, hàm sử dụng vòng lặp while để kiểm tra quá trình sinh test-case khi HIP không thể thực hiện verify những chương trình có vòng lặp mà không đặc tả tiền và hậu điều kiện cho vòng lặp.
Kết quả thử nghiệm
Với các bộ benchmark trên, hệ thống có thể sinh ra các test-case đảm bảo phủ các đường thực thi của chương trình để giúp phát hiện lỗi Hệ thống cũng trích xuất được tập slice gồm các tập lệnh phản ánh sự ảnh hưởng đến kết quả chương trình mà chúng ta quan tâm Hệ thống có thể sinh ra tập các test-case cho những chương trình có sử dụng vòng lặp while mà HIP không thể thực hiện verify khi không đặc tả tiền và hậu điều kiện cho vòng lặp
Tuy nhiên, hệ thống chỉ giới hạn ở việc suy diễn trên những chương trình input có cấu trúc dữ liệu đơn giản như linked list, stack, queue,… Đối với các cấu trúc dữ liệu phức tạp như cây cân bằng avl, hay binary heap, hệ thống vẫn chưa thể suy diễn thông tin một cách chính xác từ phần định nghĩa vị từ của các cấu trúc dữ liệu này từ ngôn ngữ HIP Cần cải tiến hệ thống để có thể suy diễn một cách tổng quát trên những chương trình input sử dụng cấu trúc dữ liệu phức tạp
Vì kỹ thuật slicing sẽ slice chương trình ban đầu thành tập các dòng code nhỏ hơn có ảnh hưởng đến kết quả quan tâm, cho nên nó có thể giảm số lượng test case sinh ra Tuy nhiên, để giảm số lượng test case thì chương trình sau khi slice phải giảm số lượng control-flow (hay là lệnh rẽ nhánh của chương trình) Việc này chỉ xảy ra khi function thực hiện song song 2 hay nhiều nhiệm vụ không liên quan nhau Vì vậy, phần áp dụng kỹ thuật slicing chủ yếu vẫn là để hỗ trợ debug, tức trích xuất được tập slice gồm các dòng lệnh ảnh hưởng đến kết quả cần quan tâm Còn vấn đề hỗ trợ để giảm số lượng test-case trong quá trình sinh test-case thì mặc dù hệ thống vẫn thực hiện được, tuy nhiên bộ chương trình thử nghiệm (benchmark) chủ yếu vẫn là những hàm thực hiện những nhiệm vụ đơn (tức không thực hiện 2 hay nhiều nhiệm vụ song song trong cùng một function), vì vậy kết quả thực nghiệm vẫn chưa thấy rõ được tính ứng dụng của kỹ thuật slicing vào việc giảm số lượng test-case sinh ra.
Tổng kết
Tổng kết
Luận văn đặt ra các mục tiêu sau :
Tìm hiểu các kỹ thuật phân tích MUST và MAY và kết hợp 2 phương pháp này để kiểm tra chương trình
Tìm hiểu về separation logic và công cụ HIP/sleek - một công cụ hiệu quả.để kiểm tra chương trình dựa trên con trỏ
Tìm hiểu kỹ thuật concolic testing Xây dựng một hệ thống sinh logic test-case dựa trên kỹ thuật concolic testing cho chương trình dựa trên con trỏ
Tìm hiểu kỹ thuật slicing và áp dụng kỹ thuật slicing để hỗ trợ quá trình phát hiện lỗi cũng như cải tiến quá trình sinh test-case của hệ thống
Đóng góp của luận văn Luận văn đã hoàn thành các mục tiêu đề ra, tìm hiểu các kỹ thuật kiểm tra chương trình, từ đó xây dựng được một hệ thống sinh logic test-case cho những chương trình thao tác trên con trỏ Đề tài tập trung nghiên cứu kỹ thuật phân tích MUST, mà điển hình là kỹ thuật concolic testing, để phát hiện lỗi xảy ra trong chương trình (nếu có)
Kết quả của hệ thống sẽ là tập các test-case hiệu quả nhất (số lượng test-case là nhỏ nhất) nhưng vẫn đảm bảo độ bao phủ các đường thực thi nhằm phát hiện lỗi trong chương trình Để thực hiện điều này, luận văn kết hợp kỹ thuật slicing để hỗ trợ quá trình phát hiện lỗi cũng như cải tiến quá trình sinh test- case của hệ thống
Trong quá trình hiện thực hệ thống, luận văn đã phát triển một tập luật để hỗ trợ quá trình sinh test-case cho chương trình input được viết bằng ngôn ngữ HIP Song song đó, khi áp dụng kỹ thuật concolic testing trên những chương trình thao tác trên con trỏ, luận văn cũng đề xuất cách khắc phục một số vấn đề thường xảy ra khi làm việc với những chương trình có con trỏ, ví dụ như vấn đề bí danh và vấn đề truy xuất thuộc tính của cấu trúc dữ liệu
Với những cải tiến này, tập slice trích xuất được sẽ cho kết quả chính xác
Trong quá trình thực hiện, đề tài đã đóng góp được 1 bài báo nghiên cứu sau :
Le Thi Nhat Van, Quan Thanh Tho, Logic test-case generation for heap- manipulation programs, In Proceedings of International Conference on Advanced
Computing and Applications (ACOMP), Ho Chi Minh City, Vietnam, 2011
Những hạn chế của luận văn Bên cạnh những đóng góp đã nói ở trên, luận văn vẫn còn một số hạn chế:
Hệ thống chỉ giới hạn ở việc suy diễn trên những chương trình input có cấu trúc dữ liệu đơn giản như linked list, stack, queue,… Đối với các cấu trúc dữ liệu phức tạp như cây cân bằng avl, hay binary heap, hệ thống vẫn chưa thể suy diễn thông tin một cách chính xác từ phần định nghĩa vị từ của các cấu trúc dữ liệu này từ ngôn ngữ HIP
Phần áp dụng kỹ thuật slicing chủ yếu vẫn là để hỗ trợ debug, tức trích xuất được tập slice gồm các dòng lệnh ảnh hưởng đến kết quả cần quan tâm Còn vấn đề hỗ trợ để giảm số lượng test-case trong quá trình sinh test-case thì mặc dù hệ thống vẫn thực hiện được, tuy nhiên bộ chương trình thử nghiệm (benchmark) chủ yếu vẫn là những hàm thực hiện những nhiệm vụ đơn (tức không thực hiện 2 hay nhiều nhiệm vụ song song trong cùng một function), vì vậy kết quả thực nghiệm vẫn chưa thấy rõ được tính ứng dụng của kỹ thuật slicing vào việc giảm số lượng test-case sinh ra.
Hướng phát triển
Dựa vào những đóng góp và hạn chế của đề tài, để cải tiến hệ thống thành một framework sinh test-case hoàn chỉnh hơn, các hướng nghiên cứu sau được đề xuất:
Cần xây dựng một tập luật, hay một văn phạm tổng quát và hoàn chỉnh hơn để hệ thống có thể suy diễn một cách chính xác trên những chương trình input có khai báo các cấu trúc dữ liệu phức tạp Hoặc có thể đề xuất một cách tiếp cận khả quan hơn Để hệ thống mang tính ứng dụng cao hơn, cần phát triển chức năng chỉ ra lỗi sai từ tập slice một cách trực quan, thân thiện và gần gũi với người dùng.