Khái niệm lập trình Portable Firmware (Phần 2)

3. Sự Module hóa

Nhiều lần trong vài năm qua, tôi từng làm việc với một khách hàng mà nguyên cả ứng dụng của họ, hơn 50,000 dòng code, chỉ để trong một module là main.c. Nỗ lực bảo trì phần mềm hoặc tái sử dụng các đoạn code nhanh chóng trở thành một cơn ác mộng. Các ứng dụng này vẫn đang sử dụng các kỹ thuật phần mềm từ những năm 1970 và 1980, vốn không hoạt động hiệu quả cho khách hàng của tôi.

Sự module hóa nhấn mạnh rằng chức năng của một chương trình nên được tách thành các module độc lập có thể hoán đổi cho nhau. Mỗi module chứa một header và source file với khả năng thực thi các chức năng hệ thống chuyên biệt được hiển thị thông qua giao diện của module. Lợi ích chính của việc việc module hóa một hệ thống nhúng là chương trình sẽ được chia thành các phần nhỏ hơn và được tổ chức dựa trên mục đích và chức năng sử dụng.

Việc bỏ qua các dữ kiện trước đó và gộp một lượng lớn mã vào một module duy nhất, ngay cả khi nó đã được tổ chức tốt hoặc theo một quy tắc nào đó, thường dẫn đến việc biến chương trình thành một mớ hỗn độn và kiến trúc phần mềm sẽ trông giống như mì spaghetti. Do đó, việc chia nhỏ một chương trình thành các module tách rời là rất quan trọng khi phát triển firmware có khả năng port và có thể tái sử dụng. Bởi vì tính độc lập mà mỗi module thể hiện cho phép nó dễ dàng di chuyển từ ứng dụng này sang ứng dụng khác, hoặc trong một số trường hợp, thậm chí từ nền tảng (platform) này sang nền tảng tiếp theo. Có một số ưu điểm liên quan đến việc chia nhỏ chương trình thành các phần module, chẳng hạn như:

  • Có thể tìm thấy các function hoặc code cần thiết rất nhanh chóng và dễ dàng
  • Dễ hiểu về phần mềm thông qua cách tổ chức của những module
  • Khả năng sao chép các module và sử dụng chúng trong các ứng dụng mới
  • Khả năng loại bỏ các module ra khỏi chương trình và thay thế chúng bằng chức năng mới
  • Dễ dàng truy ra nguồn gốc của những yêu cầu
  • Lập trình kiểm tra hồi quy tự động cho từng module và tính năng riêng lẻ
  • Giảm thời gian tiếp cận thị trường và chi phí phát triển
Example of module organization

Mỗi module được thêm vào một chương trình đều có nhược điểm là trình biên dịch (compiler) sẽ cần mở, xử lý, biên dịch và đóng module. Kết quả trong “những ngày cũ” là thời gian biên dịch chậm hơn. Máy móc ngày nay phát triển quá nhanh và hiệu quả nên thời gian biên dịch bị tăng không còn là lý do để viết những đoạn code cồng kềnh, vụng về nữa.

4. Module Coupling và Cohesion

Tách một chương trình thành nhiều phần nhỏ, có khả năng quản lý dễ hơn, là một bước tiến tốt để phát triển portable firmware, nhưng đó mới chỉ là bước đầu tiên. Để một module có thể port thực sự, nó phải thể hiện khả năng coupling thấp (low coupling) với các module khác trong code base và mức độ cohesion cao (high cohesion). Coupling nói về mối quan hệ chặt chẽ, gần gũi giữa các module hoặc class với nhau, và mức độ phụ thuộc qua lại giữa chúng (tính gắn bó, liên kết). Coupling càng cao, module càng ít độc lập.

Phần mềm có khả năng port nên giảm thiểu tối đa việc coupling giữa các module để dễ sử dụng hơn trong nhiều môi trường phát triển. Lấy ví dụ, biểu đồ sự phụ thuộc file ở Figure 1-3. Việc cố ý đưa module cấp cao nhất (top-level module) vào code base sẽ tạo thành một cơn ác mộng nhỏ, giống như lột một củ hành tây. Top module được bỏ vô chỉ khi nhằm giúp nhà phát triển nhận ra rằng nó phụ thuộc vào một module khác, mà module này lại phụ thuộc vào một moduel khác và một module khác nữa, v.v. Nói một cách ngắn gọn, các nhà phát triển kiểu như chỉ cần bưng toàn bộ code ứng dụng vô hoặc đơn giản là bắt đầu lại từ số 0. Việc cố ý sử dụng những module được coupling chặt chẽ với nhau như vậy rất khó chịu và có thể khiến kích thước code vượt quá tầm kiểm soát nếu không làm cẩn thận.

Figure 1-3. Module coupling

Phần mềm dựa trên Figure 1-3a cho thấy một câu chuyện khác hoàn toàn. Những module trong Figure 1-3b được coupling lỏng lẻo (thưa hơn). Một nhà phát triển cố ý đưa top level module vào sẽ không phải khổ sở vì liên tục gặp những lỗi biên dịch (compiler errors), thiếu file (missing files) hoặc lãng phí hàng giờ để dò tìm hết các phần phụ thuộc (dependencies). Thay vào đó, nhà phát triển nhanh chóng đưa module được coupling lỏng lẻo vào code base mới và bắt đầu thực hiện nhiệm vụ tiếp theo mà không gặp phải rắc rối nào. Coupling thấp là kết quả của một thiết kế phần mềm đã được suy nghĩ thấu đáo và có cấu trúc tốt.


THUẬT NGỮ CHUYÊN NGÀNH

Coupling nói về mối quan hệ chặt chẽ, gần gũi giữa các module hoặc class với nhau, và mức độ phụ thuộc qua lại giữa chúng (tính gắn bó, liên kết)

Cohesion nói về độ phù hợp, thuộc về nhau giữa các module (tính ăn khớp, thích nghi, hòa nhập)


Module coupling chỉ là phần đầu tiên của câu chuyện. Module coupling thấp không có nghĩa là phần mềm sẽ có khả năng port dễ dàng. Mục đích là một module với coupling thấp và cohesion cao. Cohesion nói về độ phù hợp, thuộc về nhau giữa các module (tính ăn khớp, thích nghi, hòa nhập). Trong một môi trường vi điều khiển, một ví dụ về cohesion thấp sẽ là việc gộp mọi chức năng ngoại vi (peripheral function) của vi điều khiển thành một module duy nhất. Module sẽ lớn và cồng kềnh. Thay vào đó, các chức năng ngoại vi của vi điều khiển có thể được chia thành các module riêng biệt, mỗi module có những chức năng cụ thể cho từng ngoại vi. Kết quả sẽ là những lợi ích đã được liệt kê ở phần trước về sự module hóa.

Phần mềm có khả năng port và tái sử dụng cần cố gắng tạo ra các module được coupling lỏng lẻo và có cohesion cao. Những module có đặc điểm này thường dễ tái sử dụng và bảo trì. Thử xét xem trong một hệ thống được coupling chặt chẽ, điều gì sẽ xảy ra nếu một module đơn lẻ nào đó bị thay đổi. Một thay đổi nhỏ sẽ dẫn đến rất nhiều thay đổi bắt buộc trong ít nhất là một module khác, nếu may mắn, và có thể mất nhiều thời gian để truy ra hết tất cả những thay đổi cần thiết khác. Thất bại trong việc thay đổi hoặc một sơ sót nhỏ có thể gây ra bug, mà hậu quả trong trường hợp xấu nhất có thể làm chậm dự án và tăng chi phí.

5. Tuân theo một Tiêu chuẩn

Việc tạo firmware có khả năng port và tái sử dụng có thể là một thách thức. Ví dụ, ngôn ngữ C đã trải qua một số sửa đổi phiên bản tiêu chuẩn khác nhau: C90, C99, và C11. Ngoài các phiên bản C khác biệt, cũng tồn tại các phần mở rộng ngôn ngữ không chuẩn (non-standard language extensions), các bổ sung trình biên dịch (compiler additions) và thậm chí là các phần mở rộng ngôn ngữ (language offshoots). Để phát triển firmware có thể tái sử dụng ở quy mô lớn nhất có thể, nhóm phát triển cần phải chọn một phiên bản tiêu chuẩn được chấp nhận rộng rãi, chẳng hạn như C90 hoặc C99. Phiên bản C99 có một số bổ sung tuyệt vời khiến nó trở thành một lựa chọn tốt cho các nhà phát triển. Tại thời điểm viết bài này, C11 còn bị giới hạn hỗ trợ trong việc phát triển firmware! C99 là lựa chọn tốt nhất để tuân theo một tiêu chuẩn.

Việc hỗ trợ lâu dài cho C và mục đích sử dụng chung của nó đã dẫn đến các phần mở rộng ngôn ngữ (language extensions) và các phiên bản không tiêu chuẩn (non-standard versions) mà ta cần phải tránh. Sử dụng bất kỳ cấu trúc nào không có trong tiêu chuẩn sẽ dẫn đến cần những thay đổi cá biệt đối với code nền và có thể làm rối tung code. Đôi khi việc sử dụng những phần mở rộng (extension) hoặc một phần nội tại (intrinsic) là không thể tránh khỏi do nhu cầu tối ưu hóa, nhưng chúng ta sẽ thảo luận sau về cách làm thế nào để vẫn có thể viết portable code cho những trường hợp này.

Ngoài việc sử dụng tiêu chuẩn C, các nhà phát triển cũng nên hạn chế việc sử dụng của họ trong các cấu trúc được định nghĩa rõ ràng, dễ hiểu, dễ bảo trì và được chỉ định đầy đủ. Ví dụ, các tiêu chuẩn như MISRA-C và Secure-C tồn tại để cung cấp các khuyến nghị về một tập hợp con C và nên được dùng để phát triển firmware. MISRA-C được phát triển cho ngành công nghiệp ô tô, nhưng các khuyến nghị này đã được chứng minh là rất thành công trong việc sản xuất phần mềm chất lượng đến mức các ngành khác cũng đang áp dụng nó.

Các nhà phát triển không nên coi tiêu chuẩn là một sự bó buộc, thay vào đó là một phương pháp để cải thiện chất lượng và khả năng port của firmware mà họ phát triển. Việc xác định và tuân theo các phương ngữ C tiêu chuẩn sẽ khiến các nhà phát triển mất một chặng đường dài phát triển firmware có khả năng tái sử dụng. Việc nhận thức được nhu cầu cần tuân theo tiêu chuẩn ANSI-C và có một kỷ luật để tuân theo tiêu chuẩn đó sẽ hướng dẫn nhóm phát triển cách tạo ra phần mềm nhúng có thể sử dụng lại trong nhiều năm tiếp theo.

6. Portability Issues in C—Data Types

Vấn đề port nổi tiếng và thường gặp ở ngôn ngữ lập trình C liên quan đến việc định nghĩa (defining) số nguyên (integer), kiểu dữ liệu được sử phổ biến nhất. Người ta chỉ cần hỏi một câu hỏi đơn giản để chứng minh một vấn đề tiềm ẩn về khả năng port. Giá trị mà biến LoopCount chứa khi i chuyển về 0 sẽ là bao nhiêu? Đoạn code thể hiện LoopCount có trong Figure 1-4.

Figure 1-4. Integer rollover test

Câu trả lời có thể là 65,535 hoặc 4,294,967,295. Cả hai câu trả lời đều có thể đúng. Lý do là kích thước lưu trữ (storage size) cho một số nguyên không được định nghĩa trong tiêu chuẩn ANSI-C. Các nhà cung cấp trình biên dịch có quyền lựa chọn định nghĩa kích thước lưu trữ cho biến dựa trên những gì họ cho là hiệu quả và/hoặc thích hợp nhất.

Kích thước bộ nhớ cho một số nguyên thông thường có vẻ không phải là vấn đề lớn. Đối với một đoạn code thì một int sẽ là một int, vậy nên, ai thèm quan tâm? Vấn đề chỉ xuất hiện khi cùng một đoạn code đó lại được biên dịch bằng một trình biên dịch (compiler) khác. Liệu trình biên dịch khác sẽ lưu trữ biến với kích thước bằng hay khác nhau? Điều gì xảy ra nếu nó được lưu trữ dưới dạng bốn byte và bây giờ chỉ còn hai byte? Phần mềm hoạt động hoàn hảo nay đã có bug!

Các vấn đề về khả năng port phát sinh từ số nguyên, kiểu dữ liệu được sử dụng phổ biến nhất, được giải quyết theo một cách tương đối đơn giản. File header thư viện stdint.h định nghĩa các số nguyên có chiều rộng cố định. Số nguyên có độ rộng cố định là kiểu dữ liệu dựa trên số lượng bit cần thiết để lưu trữ dữ liệu. Ví dụ: một biến cần lưu trữ dữ liệu không dấu (unsigned data) có chiều rộng 32 bit thì không cần tốn int là 32 bit, thay vào đó, nhà phát triển chỉ cần sử dụng kiểu dữ liệu uint32_t. Các số nguyên có độ rộng cố định 8, 16, 32 bit và trong một số trường hợp thậm chí là 64 bit. Table 1-1 liệt kê danh sách các định nghĩa số nguyên có chiều rộng cố định khác nhau có thể được tìm thấy trong stdint.h.

Table 1-1. Fixed-Width Integers3

Data TypeMinimum ValueMaximum Value
int8_t-128127
uint8_t0255
int16_t-32,76832,767
uint16_t065535
int32_t-2,147,438,6482,147,483,647
uint32_t04,294,967,295

Trong file thư viện stdint.h không chỉ có các kiểu dữ liệu được tìm thấy trong Table 1-1 mà còn có những thứ thú vị ít được biết đến. Lấy ví dụ, uint_fastN_t, định nghĩa một biến có tốc độ xử lý nhanh nhất có độ rộng ít nhất là N bit. Một nhà phát triển có thể bảo với trình biên dịch rằng một dữ liệu phải có ít nhất 16 bit nhưng cũng có thể là 32 bit nếu nó có thể được xử lý nhanh hơn bằng cách sử dụng kiểu dữ liệu lớn hơn. Một ví dụ tuyệt vời khác là uintmax_t, định nghĩa số nguyên có chiều rộng cố định lớn nhất có thể trên hệ thống. Một kiểu được yêu thích khác là uintptr_t, định nghĩa một kiểu đủ rộng để lưu trữ giá trị của một con trỏ.

Sử dụng stdint.h là một cách dễ dàng giúp đảm bảo các kiểu số nguyên của phần mềm nhúng luôn giữ nguyên kích thước lưu trữ của chúng cho dù code được biên dịch trên trình biên dịch nào. Đó là một cách đơn giản và an toàn để đảm bảo các kiểu dữ liệu số nguyên được giữ đúng cách.

7. Portability Issues in C—Structures and Unions

Các tiêu chuẩn của C có một số chỗ khá mơ hồ trong việc định nghĩa của các cấu trúc ngôn ngữ cụ thể. Lấy ví dụ như struture và union. Một nhà phát triển có thể khai báo một structure chứa ba phần tử, x, y và z, như Figure 1-5. Người ta thường chỉ nghĩ khi một biến được khai báo kiểu Axis_t, các phần tử dữ liệu sẽ được tạo theo thứ tự x, y, z trong bộ nhớ. Tuy nhiên, tiêu chuẩn C không nói rõ các phần tử dữ liệu sẽ được xếp theo thứ tự byte như thế nào. Trình biên dịch (compiler) có thể lựa chọn sắp xếp các phần tử dữ liệu theo bất kỳ cách nào mà nó muốn. Kết quả có thể là x, y và z chiếm bộ nhớ liền kề (occupy contiguos memory), hoặc có thể là các byte đệm được thêm (padding bytes added) vào giữa các phần tử dữ liệu khiến các phần tử thêm 2 byte, 4 byte hoặc một giá trị byte khác mà lập trình viên hoàn toàn không mong đợi.

Figure 1-5. Structure definition

Hành vi không chỉ định rõ ràng của structure và union thể trở thành một phần công việc của nhà phát triển khi port frimware đó là hiểu cách mà structure đang được define trong bộ nhớ và liệu structure có đang được sử dụng theo kiểu thêm các padding byte vốn có thể gây ảnh hưởng đến hành vi hoặc hiệu suất của ứng dụng hay không. Structure có thể bao gồm các padding byte hoặc thậm chí là các lỗ trống (hole) tùy thuộc vào kiểu dữ liệu được định nghĩa và cách hãng cung cấp compiler chọn kiểu xử lý sắp xếp byte như thế nào.

8. Portability Issues in C—Bit Fields

Tình huống với structure thậm chí còn tồi tệ hơn khi nói đến định nghĩa của các trường bit (bit field). Bit field được khai báo trong một structure nhằm cho phép nhà phát triển tiết kiệm dung lượng bộ nhớ bằng cách đóng gói chặt chẽ các data member để không chiếm toàn bộ data space. Một ví dụ về việc sử dụng bit field là khai báo một flag trong structure có giá trị true hoặc false, như trong Figure 1-6

Figure 1-6. Bit field definition

Vấn đề của bit field là việc triển khai hoàn toàn không được định nghĩa theo tiêu chuẩn. Compiler của người triển khai cần phải quyết định bit field sẽ được lưu trữ trong bộ nhớ như thế nào, bao gồm vấn đề byte alignment và liệu bit field có vượt giới hạn của bộ nhớ hay không. Một vấn đề khác với bit field là mặc dù chúng có thể tiết kiệm bộ nhớ, nhưng hệ quả là code cần thiết để truy cập bit field sẽ lớn và chậm, điều này ảnh hưởng đến hiệu suất thời gian thực của việc truy cập nó. Khuyến nghị chung khi nghĩ đến các bit field là chúng không có khả năng port và phụ thuộc vào compiler, nên tránh sử dụng trong những firmware cần khả năng tái sử dụng và port.

9. Portability Issues in C—Preprocessor Directives

Tất cả chỉ thị tiền xử lý (preprocessor directive) không được tạo ra như nhau. Một nhà phát triển sẽ có sẵn các preprocessor directive khác nhau tùy thuộc vào việc sử dụng trình biên dịch (compiler) GNU C, IAR Embedded Workbench, Keil uVision hay bất kỳ loại nào khác. ANSI-C có sẵn một số hữu hạn các preprocessor directive theo tiêu chuẩn và có thể được xem xét để port.

Các nhà sản xuất compiler có khả năng thêm những preprocessor directive không có sẵn theo tiêu chuẩn. Ví dụ như #warning là một preprocessor directive thông dụng nhưng không được hỗ trợ bởi C90 và C99! preprocessor directive #error thì thuộc tiêu chuẩn và #warning được thêm vào bởi nhà sản xuất compiler để cho nhà phát triển nâng cao tính cảnh báo của biên dịch. Những nhà phát triển nào dựa nhiều vào #warning có thể sẽ port code sang một compiler không nhận biết được #warning là một preprocessor directive hợp lệ hoặc hiểu sai công dụng của nó!

Một nhà phát triển quan tâm đến việc viết code có khả năng port được cần cẩn thận với preprocessor directive được dùng trong phần mềm nhúng. Rõ ràng nhất là #pragma là preprocessor directive không thể port được. Nó thường được khai báo để thực hiện các hành vi được define trong một ứng dụng. Việc sử dụng #pragma nên được tránh nhiều nhất có thể đối với ứng dụng có mục tiêu sẽ port sang nhiều toolchain khác.

Việc sử dụng #pragma hay những preprocessor directive đặc biệt khác và những thuộc tính (attribute) không phải luôn luôn tránh được mà không gây ra chuyện tăng đáng kể độ phức tạp và cấu trúc code. Một ví dụ là có những vùng code cần có #pragma để chỉ định thực hiện tối ưu hóa. Một nhà phát triển trong một hoàn cảnh khác có thể sử dụng những macro được định nghĩa sẵn (predefined macro) của compiler và cách biên dịch có điều kiện (conditional compilation) để đảm bảo rằng code được tối ưu hóa và nếu nó từng được port qua một compiler khác thì một thông báo lỗi sẽ xuất hiện ngay lúc biên dịch. Mỗi compiler có một bộ những macro được định nghĩa sẵn của riêng nó, bao gồm một macro có thể dùng để xác định chính compiler đang sử dụng. Figure 1-7 trình bày một ví dụ về macro do compiler định nghĩa có thể được nhà phát triển quan tâm.

Figure 1-7. Compiler-defined macros

Mỗi macro được định nghĩa sẵn trong hình Figure 1-7 xác định một compiler có thể được sử dụng như thành phần của một preprocessor directive cho biên dịch code có điều kiện. Với mỗi compiler được dùng, nó sẽ được bổ sung vào câu lệnh điều kiện với những preprocessor directive không thể được port mà lại cần thiết cho tác vụ tiếp theo. Figure 1-8 trình bày cách làm thế nào mà một nhà phát triển tận dụng lợi thế của những macro được định nghĩa sẵn để biên dịch có điều kiện một lệnh #pragma hư cấu trong bộ code.

Figure 1-8. Using conditional compilation for non-portable constructs

Những nhà phát triển quan tâm đến việc viết code ANSI-C có khả năng port nên tham khảo tiêu chuẩn ANSI-C, chẳng hạn như C90, C99 hoặc C11 và kiểm tra các phụ lục để biết các hành vi được define sẵn. Một nhà phát triển cũng có thể muốn tham khảo những hướng dẫn sử dụng compiler của họ để xác định các phần mở rộng (extension) và thuộc tính khả dụng dành cho các nhà phát triển.


3ISO/IEC 9899:1999, C Language Specification

<< Khái niệm lập trình Portable Firmware

Icons made by Freepik from www.flaticon.com