Dynamic Link Library

Thư viện liên kết động (tiếng Anh: Dynamic Link Library - viết tắt: DLL) là một thành phần của các phần mềm. Đặc điểm của nó là có tính khả chuyển cao, nhờ đó mà một DLL có thể được gắn vào một hoặc nhiều phần mềm khác nhau. DLL còn là đuôi của một tập tin chạy trên hệ điều hành Windows (.dll).

Mở đầu
Thư viện liên kết động, đúng như tên gọi của nó, là một thư viện cho phép các ứng dụng có thể liên kết đến và sử dụng nó. Nó được xem là một tổ hợp các hàm và dữ liệu mà có thể được sử dụng bởi nhiều ứng dụng khác nhau ở cùng một thời điểm. Ví dụ: thư viện user32.dll hoặc kernel32.dll là các thư viện liên kết động mà mỗi ứng dụng Windows đều phải dùng đến.

Khi đã được viết và đóng gói trong một DLL, một hàm có thể được sử dụng ở bất cứ ứng dụng nào dưới dạng mã máy của nó mà không phải quan tâm đến mã nguồn nó được viết chi tiết ra sao. Với các ứng dụng trước kia, ta có thể đính kèm file mã nguồn và sử dụng các hàm trong chương trình; tuy nhiên, giả sử như để có thể thao tác với máy in, in một nội dung ta cần gọi hàm print() và ta đã có mã nguồn của hàm print này. Giả sử như không có DLL, thì bất cứ ứng dụng nào muốn sử dụng hàm print() sẽ bao gồm cả hàm này vào trong mã nguồn: từ chương trình Word, Excel, Internet Explorer,... như vậy trong hệ thống sẽ có rất nhiều bản sao của hàm print() này, làm cho dung lượng để chứa các chương trình tăng lên. Ngoài ra, khi phần cứng thay đổi, ta lại phải thay đổi mã nguồn của hàm print() để nó hoạt động đúng (nếu cần), việc này đẫn tới việc thay đổi theo dây chuyền đến các ứng dụng, gây ra rất nhiều khó khăn. Sau đây ta sẽ tìm hiểu chi tiết về ưu nhược điểm của việc sử dụng thư viện liên kết động. Các thư viện liên kết động là một thành phần không thể thiếu tạo nên hệ điều hành Windows. Ở đó, việc quản lý, trao đổi với phần cứng hay thiết bị ngoại vi đều thông qua các hàm có sẵn trong windows mà ta hay gọi là Windows API. Thay vì ta phải đưa lệnh điều khiển cho màn hình vẽ lên một cửa sổ giao diện như các ứng dụng, thì ta chỉ cần gọi một hàm trong một file DLL nào đó, hệ thống sẽ đảm bảo kết quả như ý muốn.

Ưu và nhược điểm của thư viện liên kết động

Giảm không gian sử dụng của bộ nhớ
Hàm và dữ liệu trong các DLL được chia sẻ bởi các ứng dụng dùng nó. Như trong ví dụ ở trên, các ứng dụng Winword, Microsoft Excel, Internet Explorer sẽ dùng chung một hàm print() đã được biên dịch thành mã máy và để trong một DLL nào đó, mà không cần phải bao gồm toàn bộ mã nguồn của hàm. Bên cạnh đó, ta cũng có thể giảm được dung lượng bộ nhớ của chương trình tùy vào cách dùng các hàm DLL (Run-time hay Load-time, sẽ được trình bày sau): ứng dụng của ta sẽ chỉ nạp các hàm này khi nào dùng đến.

Mã trong DLL nằm trong khu vực chỉ cho đọc (read-only) và được nạp vào các trang nhớ đánh dấu read-only. Nó cho phép hệ thống ánh xạ DLL vào trong không gian địa chỉ nhớ của các tiến trình sử dụng nó. Và như vậy, DLL được nạp chỉ một lần, và nếu một tiến trình yêu cầu sử dụng nó, hệ điều hành chỉ việc ánh xạ DLL tới không gian địa chỉ của ứng dụng gọi DLL.

Giảm Swapping (thay trang bộ nhớ chính)
Ta tưởng tượng có hai tiến trình sử dụng cùng một DLL, một tiến trình kết thúc công việc của nó và thoát ra. Nhưng DLL sẽ không gỡ bỏ ra khỏi bộ nhớ vì DLL quản lý thời gian tồn tại của nó bằng cách giữ một bộ đếm tham khảo cho các tiến trình sử dụng nó. Mỗi khi có một tiến trình nào đó yêu cầu sử dụng DLL, bộ đếm tham khảo sẽ tăng giá trị của nó lên 1; còn khi có một tiến trình gỡ bỏ không sử dụng nữa thì bộ đếm lại giảm đi 1. DLL sẽ tự động xóa bỏ ra khỏi bộ nhớ chừng nào bộ đếm tham khảo trở về 0, trạng thái cho biết không còn có tiến trình nào sử dụng DLL nữa.

Bây giờ giả sử có một tiến trình bắt đầu chạy và yêu cầu hệ thống nạp một DLL hiện đang được dùng bởi một ứng dụng đã chạy trước đó. Chuyện gì sẽ xảy ra? Liệu hệ thống có nạp DLL một lần nữa không? Hiển nhiên là không, vì DLL được định nghĩa ra để có thể dùng chung giữa các ứng dụng khác nhau. Thành phần đã nạp DLL có tất cả các hàm và dữ liệu của nó trong bộ nhớ và hệ thống sẽ chỉ phải ánh xạ chúng tới không gian địa chỉ của tiến trình mới cho chúng hoạt động. Nó liên quan đến việc đọc các hàm và dữ liệu DLL từ trên đĩa.

Có thể đóng gói và đưa vào chương trình khác
Khi đã xây dựng được một DLL với các chức năng hợp lý, ta có thể sử dụng nó trong bất cứ ứng dụng nào mà ta cảm thấy thích hợp. Ví dụ trong một ứng dụng nhỏ, ta có tập hợp các hàm chuyển đổi giá trị từ String sang ngày tháng và đóng gói nó vào trong một DLL. Khi đó, ở một ứng dụng khác, khi có nhu cầu chuyển đổi như trên, thì ta sẽ không phải viết lại các hàm hoặc đính kèm mã nguồn của các hàm đó vào chương trình đó nữa mà sử dụng trực tiếp DLL mà ta đã biên dịch.

Tạo ra khả năng tương tác giữa các ngôn ngữ lập trình
Một ứng dụng có thể sử dụng các DLL viết bằng bất cứ ngôn ngữ lập trình nào. Các nhà phát triển phần mềm chỉ việc đóng gói các module của mình vào trong một DLL với ngôn ngữ ưa thích, sau đó module này có thể được sử dụng trong các ứng dụng viết bằng C++ hay Visual Basic.

Mặc dù hầu hết các ngôn ngữ lập trình đều hỗ trợ việc sử dụng thư viện liên kết động, nhưng lại có rất ít ngôn ngữ lập trình cho phép tạo ra chúng. Với việc sử dụng DLL, người ta có thể tập trung nhiều hơn vào các xử lý logic của hệ thống, mà không cần phải quan tâm đến những xử lý thông thường, mà nếu phát triển từ đầu, sẽ chi phí rất nhiều thời gian và công sức. Các công việc này đã được thực hiện bởi một người nào đó, và đóng gói dưới dạng các DLL.

Dễ dàng đưa ra sự hỗ trợ sau khi đã chuyển giao ứng dụng cho khách hàng
Nếu như ta phát hiện có một số thành phần trong ứng dụng cần phải được thay đổi và sự thay đổi này cần phải được cập nhật cho khách hàng. Đóng gói và phân phối lại toàn bộ sản phẩm đã bán cho tất cả các khách hàng của chúng ta là một công việc hết sức khó khăn. Tuy nhiên ta có thể tránh được điều này nếu như ứng dụng của ta được thiết kế tốt thành các module và đóng gói chúng trong các DLL. Khi một module nào đó cần thay đổi, ta chỉ việc đóng gói lại DLL chứa module đó và gửi tới khách hàng, cho họ cập nhật.

DLL Hell
Là khó khăn lớn nhất thỉnh thoảng gặp khi dùng DLL. Ta có thể gặp những thông báo lỗi dạng như:
The ordinal abc could not be located in the dynamic-link library xyz.dll
hoặc khi cài đặt một ứng dụng mới, một số chương trình khác đang bình thường bỗng nhiên bị trục trặc hoặc thậm chí không thể nạp lên để chạy được.

Đó là các dấu hiệu của DLL Hell trên máy tính của ta. Nguyên nhân cơ bản của sự cố trên là do chương trình cài đặt không kiểm tra phiên bản của các DLL trước khi sao lưu nó vào trong thư mục hệ thống. Khi một DLL mới thay thế một DLL cũ có sẵn, và nếu DLL mới này có một số thay đổi lớn làm cho nó không thể tương thích ngược lại với các chương trình sử dụng phiên bản cũ, nó sẽ làm rối loạn chương trình đó.

Cấu trúc và phân loại DLL

Cấu trúc DLL

Các thành phần chứa trong của DLL

Các DLL thường bao gồm mã lệnh, dữ liệu và các tài nguyên. Mã lệnh được lưu trữ trên một khu vực chỉ đọc (read-only), do đó nó có thể được sử dụng chung cho các yêu cầu từ các ứng dụng. Tuy nhiên, dữ liệu của DLL thì không như vậy. Mỗi một yêu cầu từ phía ứng dụng sẽ nhận được một bản sao riêng của các đoạndữ liệu (data segments) trừ phi đoạn đó được đánh dấu shared.

Khu vực lưu trữ mã bao gồm các lớp và các hàm độc lập (không có quan hệ qua lại) với nhau được thể hiện qua trong DLL. Trong trường hợp các lớp (class) thì tất cả các chức năng và dữ liệu đã được giới hạn trong một thực thể, nhưng với các hàm độc lập có quan hệ với nhau thì ta cũng có một số dữ liệu chia sẻ toàn cục. Các lớp và hàm có trong DLL cung cấp cho ứng dụng sử dụng được gọi là thành phần export từ DLL. Còn nếu DLL của ta sử dụng các hàm từ các DLL khác, thì chúng được gọi là thành phần import tới DLL.

Lấy ra các lớp và hàm ở trong DLL: để sử dụng được các mã lệnh trong DLL mà đã biên dịch, ta phải export nó ra cho các ứng dụng khác sử dụng. Có hai cách để thực hiện công việc này:

Bằng cách tạo ra một module file định nghĩa (.def) và sử dụng file này khi xây dựng DLL. Cách làm này cũng thuận tiện cho việc export các hàm theo số thứ tự hơn là theo tên (mặc định là theo tên). Lúc này ta sẽ phải xác định đường dẫn tới thư mục /DEF khi sử dụng trình biên tập để xây dựng DLL.
Bằng cách sử dụng từ khóa __declspec (dllexport) trong định nghĩa hàm. Trong trường hợp muốn export các lớp, ta đặt từ khóa này sau từ khóa class. Trình biên dịch sẽ đánh dấu các hàm hay lớp này trong DLL có thể export được.
Ví dụ ta có hàm Foo(Type1 a, Type2 b), để export nó từ trong một DLL; ta có thể thêm từ khóa __declspec(dllexport) trước tên của hàm, hoặc viết một module file định nghĩa với nội dung như sau:

::LIBRARY FooLib
::EXPORTS
::Foo private @1
Dòng cuối cùng sẽ chỉ cho trình biên tập (linker) biết tên hàm sẽ được export.

Hàm Entry-Point của DLL

Một DLL có thể chứa rất nhiều hàm, nhưng có một hàm đặc biệt là hàm Entry-point (tạm dịch là hàm đầu vào). Theo định nghĩa của thư viện MSDN, hàm DLLMain() là điểm vào của một DLL (một DLL có thể tùy chọn có hoặc không có hàm này).Nếu như hàm này được sử dụng, nó có thể được gọi bởi hệ thống trong trường hợp:

Các tiến trình và tuyến (thread) được khởi tạo hay kết thúc
Khi có lời gọi tới hàm LoadLibrary hay FreeLibrary.
Hàm này có một chút khác biệt so với các hàm khác trong DLL, theo nghĩa nó cho phép ta thực hiện một quá trình khởi tạo hoặc thu dọn nào đó theo nhu cầu của ta. Dưới đây là cấu trúc của một hàm DllMain:

HINSTANCE g_hInstance;
BOOL WINAPI DllMain(HINSTANCE hInstDLL,DWORD fdwReason,LPVOID lpvReserved)
{
switch(fdwReason)
{
case DLL_PROCESS_ATTACH:
g_hInstance = hInstDLL;
break;
case DLL_THREAD_ATTACH:
break;
case DLL_THREAD_DETACH:
break;
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}

Hàm cung cấp cho ta bốn vị trí cho phép ta sử dụng để thực hiện việc dọn dẹp hay khởi tạo cụ thể trong ứng dụng, bao gồm:

DLL_PROCESS_ATTACH: là giá trị của tham số fdwReson trong trường hợp một tiến trình nạp DLL lần đầu tiên. Mỗi ứng dụng sử dụng DLL này sẽ có một bản sao dữ liệu DLL riêng, trừ trường hợp ta sử dụng dữ liệu dùng chung cho các thể hiện của DLL này (DLL instances).
DLL_THREAD_ATTACH: tương tự như DLL_PROCESS_ATTACH nhưng nó được dùng khi một tuyến (thread) gọi một hàm trong DLL.
DLL_THREAD_DETACH: ngược lại với DLL_THREAD_ATTACH. Nó được gọi khi một tuyến kết thúc việc sử dụng DLL hoặc trong tuyến có lời gọi hàm FreeLibrary(). Các thao tác dọn dẹp tài nguyên mà ta đã cấp phát khi xử lý DLL_THREAD_ATTACH.
DLL_PROCESS_DETACH: được dùng trong trường hợp một tiến trình gỡ bỏ ra khỏi DLL hoặc kết thúc việc dùng các hàm trong DLL, hoặc có lời gọi FreeLibrary(). Các thủ tục dọn dẹp các tài nguyên đã cấp phát trong DLL_PROCESS_ATTACH được thực hiện ở đây.

Ta có thể loại bỏ các xử lý trong trường hợp DLL_THREAD_ATTACH và DLL_THREAD_DETACH bằng cách gọi hàm DisableThreadLibraryCalls(). Một điểm chú ý khác nữa là nếu có sự cấp phát bộ nhớ trong phạm vi DLL ở trong hàm Entry-point, thì nên dùng API TlsAlloc và TlsFree để thực hiện điều này (TLS: Thread Local Storage)

Chi tiết các hàm trong bài viết xin xem thêm trong MSDN.

Các loại liên kết động

Liên kết động có hai dạng phụ thuộc vào cách nhúng thông tin vào trong file thực thi. Đó là liên kết không tường minh và liên kết tường minh (Implicit Linking và Explicit Linking).

Liên kết không tường minh (Implicit Linking)

Liên kết không tường minh hay liên kết ở thời điểm nạp (Load-time dynamic Linking) diễn ra ở thời điểm biên dịch, khi ứng dụng tạo một tham chiếu tới hàm DLL được export. Tại thời điểm mã nguồn của lời gọi đó được biên dịch, lời gọi hàm DLL dịch thành một hàm tham chiếu ngoài trong đối tượng mã. Để hiểu được tham chiếu ngoài này, ứng dụng phải liên kết với thư viện import (file có phần mở rộng là.LIB) đã được DLL tạo ra khi biên dịch.

Ví dụ khi xây dựng một ứng dụng Windows sử dụng công cụ VC6: bản thân các cửa sổ chương trình không tự có mà phải được vẽ ra bởi chương trình. Tuy nhiên, khi lập trình với Windows, ta không phải lo lắng viết mã nguồn cho công việc này, bởi bản thân Windows đã cung cấp API đóng gói trong các DLL, được đặt trong thư mục hệ thống của Windows. Để sử dụng được các hàm đã có trong các DLL này, ta phải liên kết nó với ứng dụng của ta, bằng cách sử dụng các thư viện import như đã trình bày. Khi đã khai báo các thư viện import, ta có thể sử dụng các hàm các DLL tương ứng như đối với các hàm cục bộ viết trong chương trình.

Thư viện import chỉ chứa các thông tin về thành phần được export từ DLL mà không có một dòng lệnh giúp trình biên tập (linker) xử lý các lời gọi hàm tới DLL. Khi trình biên tập tìm thấy thông tin về hàm export trong một file.lib, và giả sử như mã lệnh của hàm đó nằm trong một DLL có sẵn, thì để xử lý các tham chiếu đến hàm, trình biên tập phải nhúng thêm một số thông tin vào file thực thi cuối cùng, thành phần được dùng bởi bộ nạp hệ thống khi mà tiến trình khởi động.

Khi bộ nạp (loader) chuẩn bị chạy một chương trình, trong đó có chứa các liên kết động, thì nó sẽ sử dụng thông tin được nhúng (ở thời điểm biên dịch, như đã nói ở trên) vào file thực thi chương trình để xác định các thư viện yêu cầu. Nếu như nó không thể tìm được DLL, thì hệ thống sẽ chấm dứt tiến trình và hiện ra một hộp thoại để thông báo lỗi tương ứng. Ngược lại, hệ thống sẽ nạp DLL (nếu như trước đó nó chưa được nạp) và ánh xạ các module DLL (hàm và lớp) vào trong không gian địa chỉ của tiến trình. Chú ý rằng mỗi tiến trình đều có một không gian địa chỉ riêng, do vậy nên nhiều tiến trình có thể sử dụng chung một DLL. Khi đó địa chỉ hàm được gọi sẽ nằm trong không gian này.

Nếu như DLL nào đó có hàm Entry-point (như đã đề cập ở phần trước), hệ thống sẽ gọi hàm này. Tham số fdwReason sẽ có giá trị là DLL_PROCESS_ATTACH, xác định rằng DLL đang được gắn vào tiến trình. Nếu như giá trị trả về của hàm Entry-point không phải là TRUE, hệ thống sẽ bỏ dở việc nạp tiến trình và thông báo lỗi.

Khi tất cả các bước trên diễn ra mà không có lỗi nào, cuối cùng thì bộ nạp (loader) sẽ cho phép các mã thực thi của tiến trình có thể gọi hàm DLL bất cứ khi nào tham chiếu đến nó được tạo ra. Các thành phần của DLL sẽ được ánh xạ sang không gian địa chỉ của tiến trình khi tiến trình bắt đầu chạy và nó chỉ được nạp vào bộ nhớ khi nào cần thiết.

Liên kết tường minh (Explicit Linking)

Liên kết tường minh hay còn gọi là liên kết ở thời điểm chạy (Run-time Dynamic Linking): sử dụng các con trỏ hàm ở thời điểm chạy chương trình để trỏ tới các hàm trong DLL mà ta cần sử dụng. Modul sẽ dùng hàm LoadLibrary hoặc hàm LoadLibraryEx để nạp DLL khi nào nó muốn sử dụng hàm trong DLL. Sau khi DLL đã được nạp, modul sử dụng hàm GetProcAddress để lấy về địa chỉ trỏ tới hàm xuất ra trong DLL và đưa vào một con trỏ hàm nào đó. Các thao tác tiếp theo của modul sẽ làm việc với con trỏ hàm này.

Hầu hết các ứng dụng được phát triển đều sử dụng liên kết ở thời điểm nạp (load-time dynamic linking) bởi vì đó là cách liên kết dễ dàng nhất. Nhưng dựa vào một số các ràng buộc, thỉnh thoảng phương pháp trên là không cần thiết. Sau đây là một số trường hợp cụ thể nên dùng liên kết ở thời điểm chạy thay thế:

Khi ứng dụng không biết tên của DLL hoặc thành phần export sẽ sử dụng để nạp. Ví dụ như ứng dụng có thể lưu giữ tên của DLL và thành phần export trong một file cấu hình. Sau khi chương trình đã đóng gói, việc thay đổi DLL đơn giản chỉ là việc thay đổi file cấu hình mà không phải biên dịch lại toàn bộ chương trình.
Một tiến trình sử dụng liên kết ở thời điểm nạp có thể bị chấm dứt bởi hệ thống nếu như DLL không tìm thấy ở thời điểm bắt đầu chạy. Tuy nhiên, nếu sử dụng liên kết động ở thời điểm chạy thì chương trình không bị chấm dứt ngay, mà còn cho ta một số phương án để có thể khắc phục lỗi. Ví dụ như, khi không tìm thấy DLL, chương trình không dừng chương trình ngay lập tức mà có thể tùy chọn cho phép người dùng cung cấp một đường dẫn khác tới DLL.
Một tiến trình sử dụng liên kết ở thời điểm nạp cũng có thể bị thoát nếu như một trong số các DLL nó liên kết đến có hàm Entry-point trả về giá trị không phải là TRUE. Trong khi đó tiến trình sử dụng liên kết ở thời điểm chạy không bị kết thúc trong trường hợp này nếu như ta có cách thức xử lý hợp lý.
Một ứng dụng mà liên kết không tường minh tới quá nhiều DLL sẽ khởi động rất chậm bởi vị Windows nạp tất cả các DLL đó khi ứng dụng nạp để chạy. Để tăng hiệu suất khi khởi động, ứng dụng có thể có các liên kết không tường minh đến các DLL cần thiết được dùng ngay khi chương trình bắt đầu, và dùng liên kết tường minh tới các DLL khác ở thời điểm hợp lý hơn.
Liên kết động ở thời điểm chạy sẽ loại bỏ được việc tạo các thư viện import. Nếu như những thay đổi trong DLL là do thứ tự export của các hàm thay đổi, ứng dụng sử dụng liên kết ở thời điểm chạy sẽ không phải liên kết lại (giả sử các lời gọi GetProcAddress với tham số là tên của hàm chứ không phải là giá trị chỉ số của hàm), còn các ứng dụng sử dụng liên kết ở thời điểm nạp sẽ phải liên kết lại tới thư viện import mới.

Chú thích