Vấn Đề Thiết Kế Giao Diện Direct3D12 - nói dối e blog

Vấn Đề Thiết Kế Giao Diện Direct3D12

Cách đây không lâu, tôi đã dành nguyên một đêm để vật lộn với một lỗi nghiêm trọng liên quan đến Direct3D12. Đây là một vấn đề thú vị và đáng để chia sẻ chi tiết.

Sự cố bắt nguồn từ hàm ID3D12Device::GetAdapterLuid(). Khi biên dịch bằng MinGW64 GCC, chỉ cần gọi hàm API này, bảng vtable của đối tượng d3d12device lập tức bị phá vỡ. Mọi thao tác gọi API tiếp theo trên thiết bị này đều dẫn đến crash chương trình.

Do hàm này được triển khai trong d3d12.dll (không có mã nguồn mở), tôi đành phải dùng gdb để debug. Vấn đề nằm ở điểm trả về của hàm - kiểu LUID (một cấu trúc dữ liệu). Trong C/C++, việc trả về cấu trúc từ hàm không có quy chuẩn thống nhất về cách gọi. Điều này vi phạm nguyên tắc thiết kế COM, nơi lẽ ra nên tránh kiểu thiết kế API như vậy.

Theo quy tắc COM, mọi API bắt buộc phải trả về HRESULT, chỉ có vài ngoại lệ đặc biệt được phép trả về kiểu số nguyên ULONG. Thực tế, các phiên bản D3D trước đây đều tuân thủ nghiêm ngặt quy tắc này. Ví dụ, D3D9 có API tương tự với thiết kế hợp lý hơn:

1
HRESULT GetAdapterLUID([in] UINT Adapter, [in] LUID *pLUID)

Có thể các kỹ sư mới của Microsoft đã vô tình lãng quên các quy tắc COM khi thiết kế lại D3D12, đồng thời không tính đến sự tồn tại của các trình biên dịch C++ khác ngoài Visual C++. Trong triển khai d3d12.dll, khi trả về cấu trúc, VC++ quy định địa chỉ vùng nhớ trả về sẽ được truyền như một tham số ẩn trên stack.

Nói cách khác, hàm API thực chất có dạng:

1
void ID3D12Device::GetAdapterLuid(LUID *)

Tuy nhiên, GCC lại tối ưu hóa việc trả về cấu trúc nhỏ bằng cách sử dụng thanh ghi thay vì stack. Nếu cấu trúc ≤ 64bit, GCC sẽ trả về trực tiếp qua thanh ghi, không truyền địa chỉ stack. Điều này dẫn đến mâu thuẫn:

  • Mã biên dịch bởi GCC chỉ truyền con trỏ this qua rcx, coi rdx là giá trị vô nghĩa
  • d3d12.dll lại hiểu rdx là địa chỉ vùng nhớ trả về, dẫn đến việc ghi đè dữ liệu lên chính vtable của đối tượng

Giải pháp khả thi

GCC cung cấp tùy chọn -fpcc-struct-return để vô hiệu hóa tối ưu hóa này, nhưng vẫn tồn tại bất đồng về ABI:

  • GCC truyền địa chỉ trả về như tham số đầu tiên
  • VC++ lại đặt nó ở vị trí thứ hai, sau con trỏ this

Trước khi Microsoft cập nhật SDK, tôi đã xây dựng một hàm hỗ trợ:

1
2
3
4
5
6
static inline LUID D3D12DeviceGetAdapterLuid(ID3D12Device *device) {
  typedef void (STDMETHODCALLTYPE ID3D12Device::*GetAdapterLuid_f)(LUID *);
  LUID ret;
  (device->*(GetAdapterLuid_f)(&ID3D12Device::GetAdapterLuid))(&ret);
  return ret;
}

Vấn đề tương tự cũng xuất hiện ở các API như ID3D12DescriptorHeap, và nghiêm trọng hơn ở ID3D12Resource::GetDesc() - hàm trả về cấu trúc D3D12_RESOURCE_DESC. Đặc biệt, API này được sử dụng trực tiếp trong hàm inline UpdateSubresources của tệp d3dx12.h, khiến lỗi trở nên phổ biến hơn.

Tôi đã thử nghiệm một giải pháp proxy:

1
2
3
4
5
6
7
8
9
struct D3D12ResourceProxy : public ID3D12Resource {
  // Triển khai lại hàm GetDesc()
  virtual D3D12_RESOURCE_DESC STDMETHODCALLTYPE GetDesc(void) {
    return ID3D12ResourceGetDesc(m_ptr);
  }
  // Các phương thức khác...
private:
  ID3D12Resource *m_ptr;
};

Tuy nhiên, cách này không hiệu quả khi gọi UpdateSubresources vì hàm này nội bộ lại gọi ID3D12GraphicsCommandList::CopyTextureRegion - API được triển khai trong d3d12.dll, tiếp tục gây ra xung đột giao thức.

Bài học thiết kế

Việc Microsoft đặt mã triển khai quan trọng trong các tệp .h công khai là một quyết định thiết kế tồi. Điều này không chỉ gây ra lỗi tương thích nghiêm trọng, mà còn làm phức tạp hóa việc sửa chữa khi sự cố xảy ra. Vấn đề này phơi bày sự thiếu sót trong việc kiểm tra tính tương thích đa nền tảng khi thiết kế API hệ thống.

Hiện tại, giải pháp duy nhất là:

  1. Sử dụng Visual C++ thay vì GCC
  2. Tự sửa đổi tệp d3dx12.h để thay thế các hàm có vấn đề
  3. Tránh sử dụng các hàm inline phụ thuộc vào GetDesc()
0%