Cùng một mã nguồn Object Pascal có thể hoạt động khác nhau giữa Delphi và FPC/Lazarus theo bốn cách thường xuyên gây lỗi cho thành phần PDFium Component: FPC giải phóng bản ghi kết quả hàm tạm thời trước khi phép kiểm tra tư cách thành viên in hoàn tất việc đọc chúng, trình biên dịch dcc32 tắt tính năng kiểm tra phạm vi chỉ số theo mặc định dẫn đến việc truy cập mảng ngoài biên đọc phải các giá trị rác trong im lặng, chỉ phiên bản Delphi 13 chấp nhận gán mảng ẩn danh array of Byte cho TBytes mà không cần ép kiểu, và việc nối chuỗi AnsiString của Delphi có thể làm mất các byte có giá trị từ $80 trở lên thông qua một chu trình chuyển đổi trang mã ẩn. Mỗi lỗi này có thể khiến bộ kiểm thử của bạn vượt qua (màu xanh) trên trình biên dịch này nhưng lại thất bại (màu đỏ) hoặc tệ hơn là sai lệch dữ liệu ngầm trên trình biên dịch kia
Nếu bạn lần đầu tiên thiết lập một dự án chạy song song hai trình biên dịch, bài viết về tìm hiểu trình xem Lazarus và FPC sẽ hướng dẫn quy trình thuận lợi: các gói phần mềm, đường dẫn tìm kiếm và hiển thị cửa sổ kết xuất hình ảnh lên màn hình. Thiết kế bài viết này hoàn toàn ngược lại với một tài liệu hướng dẫn. Nó là danh sách những lỗi chúng tôi gặp phải sau khi mọi thứ có vẻ đã ổn định, khi hệ thống CI đã báo xanh trên FPC và trên Delphi, để rồi một thay đổi nhỏ vượt qua kiểm thử ở bên này lại gây ra lỗi nghiêm trọng ở bên kia. Mọi cạm bẫy dưới đây đều xuất phát từ các lỗi thực tế trong bộ kiểm thử PDFiumPas hoặc các bản thử nghiệm của nó, với các phân tích cấp độ commit được cô đọng thành các ví dụ tái hiện tối thiểu, nguyên nhân gốc rễ và giải pháp khắc phục tiêu chuẩn được chúng tôi áp dụng
Tại sao một tập hợp (set) trả về rỗng trong FPC nhưng lại bình thường trong Delphi?
Tóm tắt trong một câu: Trình biên dịch FPC có thể giải phóng biến tạm thời lưu trữ kết quả bản ghi của một hàm trước khi biểu thức đọc một trường của kết quả đó hoàn tất, do đó phép thử X in Func().Issues có thể kiểm tra tư cách thành viên trên một tập hợp đã bị giải phóng trong khi biểu thức tương tự trong Delphi vẫn hoạt động bình thường. Các bài kiểm thử mức độ tuân thủ PDF/E của chúng tôi đã gặp phải lỗi này ngay ở phiên bản đầu tiên. Trình xác thực trả về một bản ghi có trường Issues là một tập hợp các cờ báo vi phạm, và các dòng mã khẳng định (assertions) thực hiện gọi hàm trực tiếp trên dòng mã đó
// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// Reliable on both compilers: pin the result to a local first
var
Vr: TPdfEValidationResult;
begin
Vr := ValidateAnsi(Pdf);
AssertTrue(pveiLzwUsed in Vr.Issues);
end;
Cấu trúc dòng mã gọi trực tiếp làm cho tập hợp được đọc thành rỗng trong FPC, khiến mọi khẳng định mong đợi một cờ báo bị thất bại, trong khi bản dựng Delphi giống hệt lại vượt qua kiểm thử. Nguyên nhân gốc rễ là sự khác biệt trong cách hai trình biên dịch quản lý vòng đời của các biến kết quả hàm tạm thời bên trong các biểu thức lớn hơn: Delphi giữ biến tạm thời hoạt động cho đến cuối câu lệnh, trong khi việc giải phóng bản ghi tạm thời của FPC có thể diễn ra trước khi toán tử kiểm tra thành viên của tập hợp thực hiện đọc nó. Chúng tôi đã từng ghi nhận hành vi tương tự một lần trước đó trong một chú thích của hàm bổ trợ FlagPresent trong đơn vị kiểm thử PDF/A, nhưng sau đó vẫn mắc lại lỗi này khi viết các bài kiểm thử mới từ đầu, cho thấy cấu trúc mã lỗi trông tự nhiên như thế nào. Giải pháp khắc phục mang tính kỹ thuật và rất đáng để áp dụng làm quy tắc chung: không bao giờ liên kết việc truy cập trường hoặc kiểm tra tập hợp trực tiếp vào một lời gọi hàm trả về một bản ghi; hãy gán kết quả cho một biến cục bộ trước, sau đó mới đọc trường dữ liệu. Việc này chỉ tốn thêm một dòng mã nhưng loại bỏ hoàn toàn một nhóm lỗi phụ thuộc vào trình biên dịch
Tại sao Delphi chấp nhận một chỉ số mảng mà FPC lại từ chối biên dịch?
Tóm tắt trong một câu: Trình biên dịch dcc32 biên dịch thành công một chỉ số ngoài phạm vi của một mảng cố định kích thước và khi tính năng kiểm tra phạm vi mặc định bị tắt, nó thực hiện đọc hoặc ghi vào vùng nhớ liền kề khi chạy chương trình mà không báo lỗi, trong khi FPC từ chối biên dịch chính chỉ số đó. Thành phần PDFium Component khai báo các điểm tứ giác dưới dạng một mảng bắt đầu từ 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, tương ứng với cách đánh số các mục nhập QuadPoints của PDF. Một bản thử nghiệm điền dữ liệu sử dụng vòng lặp bắt đầu từ 0 đã chạy bình thường trong nhiều tháng dưới Delphi
var
I: Integer;
begin
for I := 0 to 3 do // wrong: the array is [1..4]
Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
// silently touches adjacent memory
// FPC: compile-time range check error
for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
Data.AttachmentPoints[I] := Corner[I - 1]; // correct on both compilers
end;
Bản dựng Delphi cho kết quả thành công giả: do tắt tính năng kiểm tra phạm vi (thiết lập mặc định của dcc32), chỉ số 0 đã ghi đè vào bất kỳ trường nào nằm trước mảng trong bản ghi, và chương trình thử nghiệm trông có vẻ như vẫn chạy được. Khi chuyển cổng chương trình thử nghiệm này sang Lazarus, trình biên dịch FPC lập tức báo lỗi kiểm tra phạm vi tại thời điểm biên dịch, và việc sửa chỉ số mảng sau đó đã làm lộ ra một lỗi thứ hai sâu hơn trong đường dẫn chú thích của thư viện mà các dữ liệu rác trước đó đã che lấp, lỗi được phân tích chi tiết trong bài viết về chú thích QuadPoints của PDFium. Hai bài học được rút ra từ sự cố đó. Thứ nhất, hãy ưu tiên sử dụng Low() và High() thay vì các giới hạn số cố định bất cứ khi nào kiểu mảng không được thiết kế bắt đầu từ 0. Thứ hai, hãy coi việc biên dịch bằng FPC, hoặc ít nhất một bản dựng Delphi bật cấu hình {$R+}, là bộ lọc bắt buộc đầu tiên cho bất kỳ bản thử nghiệm hay bài kiểm thử mới nào: các thiết lập mặc định của dcc32 sẽ không báo cho bạn biết về nhóm lỗi này, và một chương trình chạy được không đồng nghĩa với việc nó hoạt động đúng
Phép gán TBytes mà chỉ phiên bản Delphi 13 chấp nhận
Tóm tắt trong một câu: Việc gán một trường được khai báo là mảng ẩn danh array of Byte cho một biến TBytes sẽ biên dịch thành công trên Delphi 13 (phiên bản trình biên dịch 37.0) nhưng thất bại trên Delphi 12 Athens và mọi phiên bản trước đó với lỗi E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Lỗi này không phải là sự khác biệt giữa Delphi và FPC mà là sự khác biệt giữa Delphi với chính các phiên bản cũ của nó, nhưng nó gây lỗi cho cùng một mã nguồn đa trình biên dịch theo cùng một cách: trình biên dịch mới nhất âm thầm chấp nhận một cấu trúc mã mà tất cả các trình biên dịch khác đều từ chối
type
TValidator = class
private
FBuffer: array of Byte; // anonymous dynamic array type
end;
var
OrigBytes: TBytes;
begin
OrigBytes := FBuffer; // Delphi 13 only; E2010 on Delphi 12
// Athens and earlier
OrigBytes := TBytes(FBuffer); // compiles everywhere; same byte layout,
// safe hard cast
end;
Chúng tôi đã cung cấp chính xác dòng mã này trong một quy trình xác thực được phát triển và kiểm thử cục bộ trên Delphi 13, nơi phép chuyển đổi ngầm định được chấp nhận một cách im lặng. Bộ cài đặt mã nguồn đầy đủ phục vụ một số lượng lớn người dùng trên Delphi 12 và cũ hơn, và đối với họ đơn vị mã này đơn giản là không thể biên dịch. Giải pháp khắc phục cấu trúc là sử dụng phép ép kiểu cứng như mô tả ở trên (an toàn vì kiểu mảng ẩn danh array of Byte và TBytes chia sẻ cùng một bố cục mảng động giống nhau), hoặc tốt hơn là khai báo trường dữ liệu bằng một kiểu có tên cụ thể như TBytes ngay từ đầu để không phát sinh bất kỳ phép chuyển đổi nào. Bản sửa quy trình quan trọng hơn: một cấu trúc mã biên dịch thành công trên chuỗi công cụ mới nhất của bạn không chứng minh được gì cho các trình biên dịch cũ hơn mà người dùng của bạn thực tế đang chạy, và nhóm lỗi tương thích ngược này sẽ không hiển thị cho đến khi bạn xây dựng thử nghiệm trên mọi phiên bản được hỗ trợ. Các kịch bản phát hành của chúng tôi hiện tại thực hiện biên dịch thư viện trên toàn bộ ma trận các trình biên dịch, vì một bản dựng 37.0 cục bộ không thể phát hiện các cấu hình nới lỏng chỉ có trên phiên bản 13
Byte dữ liệu AnsiString bị mất trên một máy tính Windows tiếng Trung
Tóm tắt trong một câu: Việc nối một byte thô có giá trị từ $80 trở lên vào một AnsiString bằng toán tử + có thể âm thầm thay thế byte đó bằng ký tự ? ($3F) dưới Delphi, vì biểu thức thực hiện một chu trình chuyển đổi ngầm định AnsiString → UnicodeString → AnsiString thông qua trang mã hệ thống. Chúng tôi phát hiện ra lỗi này qua một bài kiểm thử PDF/A xây dựng một tên chứa một byte $FE riêng lẻ (vốn không phải là một byte dẫn đầu UTF-8 hợp lệ) để xác nhận trình xác thực gắn cờ các tên không phải là UTF-8 hợp lệ theo điều khoản ISO 19005-2 mục 6.1.8
var
BadName: AnsiString;
begin
// On Delphi with a multi-byte system code page (observed on CP936),
// the concatenation round-trips through UnicodeString and $FE, which
// is not a valid CP936 sequence, comes back as '?' ($3F)
BadName := '/Bad' + AnsiChar($FE) + 'Name';
// Safe: build with an ASCII placeholder, then patch the byte in place;
// indexed assignment into a settled AnsiString does not round-trip
BadName := '/Bad' + #1 + 'Name';
BadName[5] := AnsiChar($FE);
end;
Trên một hệ thống Windows tiếng Trung chạy trang mã 936, chuỗi được nối hoàn toàn không chứa byte $FE, khiến thư viện báo cáo không có lỗi và bài kiểm thử báo đỏ trông như thể có lỗi của thư viện. Thư viện chưa bao giờ chạy sai: một chương trình chạy bằng FPC nạp một tệp PDF thực tế chứa byte $FE vẫn nhận được cờ báo lỗi như mong đợi. Sự hỏng hóc dữ liệu xảy ra bên trong tệp thực thi kiểm thử của Delphi trong khi biểu thức chuỗi được đánh giá, vì mô hình chuỗi ưu tiên Unicode của Delphi chuyển đổi các biểu thức AnsiString hỗn hợp qua kiểu UnicodeString, và $FE không phải là một byte dẫn đầu hợp lệ trong trang mã CP936 nên chu trình chuyển đổi đã thay thế nó. Hãy thẳng thắn về giới hạn thực tế ở đây: trên một trang mã một byte của phương Tây như CP1252 thì biểu thức tương tự thường hoạt động bình thường, đó là lý do tại sao lỗi này ẩn náu trên hầu hết các máy tính phát triển và chỉ lộ diện trên các hệ thống Đông Á hoặc các máy chủ CI cục bộ hóa. Quy tắc chúng tôi áp dụng là: không bao giờ xây dựng các vectơ kiểm thử nhị phân chứa các byte từ $80 trở lên bằng phép nối chuỗi AnsiString; hãy sửa đổi trực tiếp các byte tại chỗ sau khi chuỗi đã được định hình (như ví dụ trên), hoặc xây dựng vectơ trong cấu trúc TBytes ngay từ đầu
Những điều quy trình xử lý song song hai trình biên dịch nên kiểm tra mặc định
Bốn cạm bẫy, một mô hình chung: mỗi trình biên dịch báo cáo cho bạn một nhóm lỗi khác nhau. Phân tích phạm vi tại thời điểm biên dịch của FPC phát hiện chỉ số vượt biên mà dcc32 đã chạy im lặng trong nhiều tháng, và mô hình chuỗi Unicode của dcc32 làm hiển thị một sự phụ thuộc trang mã mà một bản dựng FPC thuần byte không bao giờ kích hoạt. Hệ quả thực tế là việc một đường ống báo xanh đơn lẻ là không đủ. Biên dịch chéo không chỉ đơn giản là một hộp kiểm kiểm tra tính di động, nó là một bộ phân tích tĩnh thứ hai và một mô hình chạy chương trình thứ hai được áp dụng trên cùng một mã nguồn, giống như mục tiêu của các phép kiểm tra ranh giới phòng vệ được mô tả trong bài viết về củng cố an toàn bộ nhớ và ABI
Các quy tắc chung rút ra từ các sự cố này rất ngắn gọn để ghi nhớ. Hãy gán các bản ghi kết quả hàm vào một biến cục bộ trước khi đọc các trường của chúng. Duyệt qua các mảng có giới hạn cố định bằng cách dùng Low() và High(), và chạy thử ít nhất một bản dựng FPC hoặc bản dựng kiểm tra phạm vi chỉ số trước khi tin tưởng vào bản thử nghiệm mới. Ép kiểu rõ ràng đối với các trường mảng động ẩn danh, hoặc khai báo chúng bằng kiểu có tên cụ thể, và xây dựng thử nghiệm trên toàn bộ ma trận trình biên dịch trước khi phát hành. Giữ các byte thô giá trị cao tránh xa khỏi phép nối chuỗi AnsiString. Không điều nào trong số này tốn nhiều công sức một khi chúng đã trở thành thói quen, và mỗi quy tắc đều giúp loại bỏ một kiểu lỗi mà quy trình làm việc với một trình biên dịch duy nhất về mặt cấu trúc hoàn toàn không thể phát hiện ra
Cả bốn vấn đề đều được phát hiện và sửa đổi trong quá trình bảo trì PDFium Component, sản phẩm cung cấp cùng một mã nguồn Object Pascal cho Delphi, C++Builder, và FPC/Lazarus và chạy các bộ kiểm thử tương thích và hồi quy trên mọi chuỗi công cụ đó, giúp các cạm bẫy được trình bày trong bài viết này được bảo vệ bởi các bài kiểm thử tự động thay vì chỉ dựa vào trí nhớ