(Phần 1, Phần 2, Phần 3, Phần 4)
Nội dung
Chương 3: Device Driver Fundamentals in C
- 1. Hiểu về Memory Map
- 2. Planning the Driver Interfaces
- 3. Design by Contract
- 4. Assertion Fundamentals
- 5. Device Driver Models
- 6. Polling Versus Interrupt-Driven Drivers
- 7. Driver Component Definition
- 8. Naming Convention Recommendations
- 9. Object-Oriented Programming in C
- 10. Abstractions and Abstract Data Types (ADTs)
- 11. Encapsulation and Data Hiding
- 12. Callback Functions
- 13. Error Handling
- 14. Leverage Design Patterns
- 15. Expected Results and Recommendations
- 16. Going Further
“Software is like entropy. It is difficult to grasp, weighs nothing, and obeys the second law of thermodynamics; i.e., it always increases.”
(Phần mềm giống như entropy. Rất khó để nắm bắt, không khối lượng, và tuân theo định luật hai của nhiệt động lực học; tức là nó luôn tăng.)
—Norman Ralph Augustine
1. Hiểu về Memory Map
Bộ nhớ (memory) trong mọi vi điều khiển (microcontroller) đều được chia thành các vùng (region) khác nhau liên quan đến các chức năng cụ thể của microcontroller. Mặc dù giữa các microcontroller có hành vi (behavior) và khả năng (capability) tương tự nhau, nhưng vẫn có sự khác biệt ở các vùng nhớ (memory region), cách tổ chức giữa những microcontroller và thậm chí là trong cùng một họ microcontroller. Mặc dù mỗi microcontroller được tổ chức khác nhau, developer vẫn có thể phát triển những driver có thể tái sử dụng (reusable) và dễ dàng di chuyển (portable) từ microcontroller này sang microcontroller khác.
Để tạo driver, developer phải hiểu về các memory region khác nhau, mục đích của chúng và các kỹ thuật có sẵn trong ngôn ngữ lập trình C để ánh xạ (map) tới các memory region đó. Memory được tổ chức thành các vùng khác nhau, chẳng hạn như CPU, ROM, RAM, FLASH, Ngoại vi (Peripheral) và EEPROM. Các region này được kết nối với CPU thông qua nhiều loại bus, nhưng các chi tiết cụ thể sẽ biến đổi từ kiến trúc này sang kiến trúc khác. Figure 3-1 cho thấy một ví dụ về những gì một developer mong đợi sẽ tìm thấy trong bản đồ bộ nhớ (memory map).
Các vùng bộ nhớ ROM được lập trình bởi nhà sản xuất microcontroller và chứa bất kì thứ gì mà nhà sản xuất cảm thấy hữu ích với khách hàng. Ví dụ như người ta thường tìm thấy các thuật toán bootloader, điều khiển động cơ (motor control) hoặc flash được lưu trữ vĩnh viễn trong các vùng ROM này. Developer không thể sửa đổi vùng bộ nhớ ROM và các thuật toán ở đó là vĩnh viễn. Một vùng ROM không được tính vào tổng code space có sẵn cho một developer. Developer có thể truy cập các thuật toán được lưu trữ trong ROM bằng cách ánh xạ một con trỏ hàm tới code nằm ở đó và tham chiếu (de-referencing) nó.
Các vùng bộ nhớ RAM là những khu vực bộ nhớ dễ bay hơi (volatile memory) có thể được lập trình trong quá trình thực thi chương trình nhưng sẽ mất dữ liệu khi reset, power cycle hoặc tắt nguồn. RAM chứa program stack, heap và các biến được cấp phát tĩnh (static). Developer phải cho trình biên dịch (compiler) biết memory area nào sẽ chứa program stack – đối với một ứng dụng baremetal, và heap (chứa các biến được cấp phát động (dynamic) như stack trong một Hệ Điều Hành Thời Gian Thực (RTOS) và các nhu cầu khác của ứng dụng). Một khi các memory region này đã được chỉ định, những memory còn lại có thể được sử dụng cho các biến ứng dụng có mục đích chung (general-purpose application variable) và lưu trữ dữ liệu (data storage).
Các vùng bộ nhớ Flash chứa các hướng dẫn ứng dụng có thể thực thi, bảng dữ liệu (data table – chẳng hạn như dữ liệu hiệu chuẩn) và các giá trị biến được khởi tạo. Nói chung, các vùng bộ nhớ flash được lập trình khi một thiết bị được sản xuất. Tuy nhiên, nội dung flash có thể được sửa đổi thông qua một ứng dụng bootloader. Nội dung flash được giám sát cẩn thận trong quá trình develop chương trình để đảm bảo rằng khu vực có kích thước thích hợp chứa được toàn bộ ứng dụng. Một nguyên tắc hay đó là xác định kích thước vùng flash đủ cho phép các tính năng mới được thêm vào trong suốt vòng đời sản phẩm.
Vùng CPU chứa các control register cho chính CPU, đôi khi liên quan đến ngắt (interrupt), lỗi (fault), ngoại lệ (exception) và clock control. Những CPU register thường được khởi tạo bằng start-up code, với những cung cấp giao diện (interface) cho memory region của nhà sản xuất. Các CPU region thường được trừu tượng hóa để che giấu hoạt động nội của microcontroller với developer.
CASE STUDY – GIẢ SỬ GIÁ TRỊ RAM ĐƯỢC BẢO TOÀN
Trong nhiều dịp, tôi đã thấy khách hàng có ý tưởng độc đáo để tiết kiệm memory và thời gian của ứng dụng bằng cách dùng RAM để lưu trữ data giữa những power cycle. Giả sử rằng, bằng cách ghi data vô RAM, thực hiện một reset, và kế đó power up, tiếp theo khu vực memory có thể sẽ được đọc với giá trị và trạng thái của ứng dụng trước đó. Tôi đã thấy nhiều developer có ý định làm việc này khi tạo một bootloader. Data được lưu trữ nhằm báo cho ứng dụng biết rằng nên load ứng dụng hay bootloader.
Vấn đề chính của phương pháp sử dụng RAM để lưu trữ data giữa mỗi lần reset là data được lưu trữ trong memory KHÔNG đảm bảo còn giữ nguyên giữa mỗi power cycle hoặc reset. Data có thể được bảo toàn trong hầu hết các trường hợp nhưng chắc chắn đôi khi bị xóa hoặc bị hư, dẫn đến hành vi không mong muốn. Việc giả sử rằng RAM data vẫn tồn tại giữa những power cycle sẽ gây hậu quả là software bug khó phát hiện và khó tái tạo.
Các vùng EEPROM ít khi và hầu như không có trên hầu hết microcontroller. EEPROM cung cấp cho developer một vùng làm việc an toàn để chỉnh sửa data. Data được ngăn cách khỏi flash và cung cấp một phương tiện an toàn để cập nhật data mà không bị nguy cơ xóa application code bất ngờ. Những microcontoller không bao gồm EEPROM thường sẽ cung cấp những thư viện flash có thể được dùng để dùng cho hành vi của EEPROM nhưng sẽ có nguy cơ hư hại application code.
Vùng bộ nhớ Peripheral là nơi được quan tâm nhất đối với các driver developer. Vùng bộ nhớ peripheral chứa đựng các register điều khiển peripheral của microcontroller, như là general-purpose input và output (GPIO), analog-to-digital converter, serial peripheral interface, và nhiều loại khác. Để tạo một driver, một developer phải ánh xạ (map) driver code tới vùng bộ nhớ (memory region) mà các peripheral register hiện diện. Một lần nữa, những vùng này là khác nhau với mỗi microcontroller. Ở chương này, chúng ta sẽ thảo luận kĩ thuật tổng quát và chiến lược để develop driver. Sau đó, ở chương tiếp theo chúng ta sẽ đi sâu vào những chi tiết tường tận.
Các vùng bộ nhớ cho một microcontroller không bắt buộc phải tiếp giáp với nhau ở bất kỳ hình dạng hoặc hình thức nào. Memory map có thể bắt đầu với các vị trí bộ nhớ cho application code, chuyển sang RAM, sau đó là những peripheral, rồi quay lại application code. Thậm chí có thể có những khoảng trống lớn giữa các vị trí bộ nhớ (sử dụng được) thường được gọi là các memory-map hole. Một ví dụ về memory map với các hole như trong Figure 3.2.
2. Planning the Driver Interfaces
Phát triển phần mềm nhúng hạn chế tài nguyên (resource-constrained embedded-software) dễ có xu hướng bị hỗn loạn. Quay lại lúc ngôn ngữ lập trình C mới được giới thiệu, các phương pháp hay nhất và kiến trúc phần mềm phân lớp (layered software architecture) chưa tồn tại. Phần mềm nhúng có rất nhiều câu lệnh goto. Driver code được kết hợp chặt chẽ với application code, và không có sự phân biệt rõ về nơi bắt đầu hoặc kết thúc middleware. Kết quả là một mớ code khổng lồ xứng đáng với cái tên spaghetti code (như món mì Ý).
Bây giờ, đối với những người chưa rút ra được mối liên hệ, Beningo là một cái tên Ý, và giống như bất kỳ người Ý tốt bụng nào, tình yêu với mì ống (pasta) là một điều được ban tặng. Trong trường hợp này, sự tương tự giữa mì ống và cách thức mà phần mềm được cấu trúc là hoàn toàn phù hợp. Hãy dành một chút thời gian để xem xét điều này: mì spaghetti hỗn loạn; các sợi mì đan xen vào nhau theo cách này và cách kia, dẫn đến sự thiếu hoàn chỉnh về cấu trúc. Viết code không có cấu trúc giống hệt như mì spaghetti; với mỗi lần cắn bạn không nhận được manh mối gì! (Ít nhất với mì Ý thì nó sẽ rất ngon!)
Mặt khác, lasagna (một dạng mì Ý dạng tấm hoặc lát)! Các sợi mì được xếp thành từng lớp, tạo ra cấu trúc và thứ tự bữa ăn. Code được develope bằng cách sử dụng các lớp không chỉ dễ hiểu hơn mà còn có khả năng xóa bỏ một lớp và thêm một lớp mới, về cơ bản cho phép tái sử dụng và dễ bảo trì. (Đôi khi, tôi muốn hoán đổi các lớp lasagna, nhưng tôi luôn thấy ăn nó sẽ ngon hơn!) Hãy nhớ rằng — chúng ta muốn viết lasagna code, không phải spaghetti code! Figure 3-3 là một ví dụ về kiến trúc phần mềm mà developer có thể chọn để tách các lớp phần mềm khác nhau. Chúng ta đã thảo luận về kiến trúc phần mềm trong Chương 1.
Figure 3-3. The lasagna software architecture
Việc viết phần mềm theo cách phân lớp (layer), giống như lasagna cho phép developer dễ dàng xác định nơi một kiểu phần mềm (hoặc gọi là layer) kết thúc và một kiểu khác bắt đầu. Tại thời điểm đó, chúng ta có cái được gọi là giao diện phần mềm (software interface). Trong Figure 3-3, chúng ta có bốn giao diện khả thi cần được xác định rõ ràng. Giao diện cho phép lớp phần mềm bên trên trực tiếp tương tác với phần mềm hoặc có thể là phần cứng tồn tại bên dưới nó. Việc xác định một giao diện sạch sẽ (clean) và có thể mở rộng (extensible) cho phép các developer tổ chức code và cung cấp một giao diện chung có thể tái sử dụng từ ứng dụng này sang ứng dụng khác.
Bắt đầu từ Chương 6, chúng ta sẽ thảo luận chi tiết làm thế nào để hiểu và thiết kế đúng và lập kế hoạch làm giao diện. Chúng ta sẽ đi qua cách develop các giao diện cho lớp trừu tượng phần cứng (hardware abstraction layer). Tại điểm này, để lập kế hoạch đúng cho một giao diện phần mềm, một developer cần xem lại memory map mà chúng ta đã thảo luận ở phần trước và xác định các phần tử cấp thấp (low-level) cần thiết trong lớp driver. Một danh sách tương tự có thể được develop cho mỗi phần tử. Mỗi phần tử này sẽ tồn tại trong lớp middleware và lớp application. Kết quả có thể là một sơ đồ giống như Figure 3-4.
Figure 3-4. Component identification
Các giao diện thành phần (component interface) xuất hiện khi một lớp này chạm vào lớp khác. Giao diện (interface) sẽ bao gồm các chức năng (function) để làm một số hành động được thực hiện bởi thành phần (component), chẳng hạn như chuyển đổi (toggle) trạng thái pin, thiết lập (set) một thanh ghi hoặc đơn giản là đọc (read) dữ liệu. Để các chức năng đó — giao diện — hoạt động như mong đợi, nó cực kỳ hữu ích cho các developer khi tạo mối quan hệ hợp đồng giữa giao diện và các developer sử dụng nó
3. Design by Contract
Giao diện phần mềm tăng độ phức tạp rất nhanh. Một API và HAL hiện đại có thể có hơn một trăm giao diện được dùng để hệ thống hành xử theo cách mong muốn. Một phương pháp được dùng để đảm bảo cho developer hiểu rõ ràng cách sử dụng giao diện, là sử dụng thiết kế theo hợp đồng (design-by-contract).1 Thiết kế theo hợp đồng là một phương pháp để developer có thể sử dụng để chỉ định điều kiện trước (pre-condition), điều kiện sau (post-condition), tác dụng phụ (side effect), và những bất biến (invariant) liên quan đến giao diện. Mọi component đều có một hợp đồng (contract) phải được tuân thủ để thành phần đó tích hợp thành công vào ứng dụng. Figure 3-5 minh họa cách hoạt động của thiết kế theo hợp đồng.
Figure 3-5. Design by contract
Là một developer, chúng ta phải kiểm tra input, output của component và công việc (những tác dụng phụ) sẽ được thực hiện. Các điều kiện trước mô tả những điều kiện phải tồn tại sẵn trong hệ thống trước khi thực hiện một hành động với component. Ví dụ: không thể toggle trạng thái chân GPIO trừ khi nó được bật GPIO clock trước. Việc kích hoạt clock sẽ là điều kiện trước (pre-condition) hoặc điều kiện tiên quyết (pre-requisite) đối với GPIO component. Không đáp ứng điều kiện này thì sẽ không có gì xảy ra khi có một lệnh gọi GPIO được thực hiện.
Khi các điều kiện trước (pre-condition) của một function được đáp ứng, sẽ có những input được đưa vào component để nó thực hiện được chức năng của nó. Một ví dụ như toggle trạng thái của một chân GPIO. Giao diện này cần một function được thiết kế để gợi ra hành vi toggle, yêu cầu nhập số của chân để xác định được chân sẽ được toggle. Một vài giao diện sẽ không yêu cầu input nào ngoài việc thực hiện lệnh gọi đến giao diện, trong khi những giao diện khác yêu cầu hàng chục input trở lên để có được hành vi mong muốn.
Nếu các điều kiện trước được đáp ứng và dữ liệu input hợp lệ, developer sẽ mong đợi có một tác dụng phụ xuất hiện. Tác dụng phụ (side effect) cơ bản chỉ là một cái gì đó thay đổi trong hệ thống. Có thể một vùng bộ nhớ được ghi hoặc đọc, trạng thái i/o bị thay đổi hoặc dữ liệu được trả về. Điều gì đó hữu ích sẽ xảy ra bằng cách tương tác với giao diện của component. Sau đó, tác dụng phụ sản xuất các điều kiện sau (post-condition) mà nhà phát triển mong đợi. Trạng thái hệ thống đã chuyển thành trạng thái mong muốn.
Cuối cùng, các output cho component được trích xuất. Có lẽ giao diện trả về một cờ thành công (success flag) hoặc thất bại (failure flag) – thậm chí có thể là mã lỗi (error code). Một cái gì đó được trả về để người gọi biết rằng mọi thứ diễn ra như mong đợi và những tác dụng phụ có thể quan sát được.
ĐỊNH NGHĨA
Pre-condition (Điều kiện trước) là các điều kiện bắt buộc phải được đáp ứng trước khi function được gọi. Các điều kiện trước được quy định trong hợp đồng thành phần (component contract), giúp giải thoát function khỏi phải kiểm tra các điều kiện bên trong.
Post-condition (Điều kiện hậu) là các điều kiện đảm bảo phải được đáp ứng khi component hoàn thành thực thi được yêu cầu với toàn bộ điều kiện trước đã được đáp ứng.
Side effect (Tác dụng phụ) là những tác động mà function được gọi có trên hệ thống khi nó được thực thi. Tác dụng phụ là công việc hữu ích được thực hiện bởi function.
Invariant (Bất biến) là các điều kiện được chỉ định suốt application phải được đáp ứng để sử dụng component. Ví dụ: khi từ khóa hạn chế (restrict keyword) đang được sử dụng với một con trỏ, con trỏ này cho trình biên dịch biết rằng input sẽ không được sử dụng ở bất kỳ nơi nào khác trong program.
4. Assertion Fundamentals
Trước khi thảo luận về các mô hình driver và những phương pháp khác nhau mà developer phần mềm nhúng sử dụng để tạo driver, chúng ta cần dành một chút thời gian để xem xét một cấu trúc quan trọng trong ngôn ngữ lập trình C, việc quan trọng này thường bị thờ ơ hoặc lạm dụng. Cấu trúc này là assert macro. Nó cho phép developer kiểm tra những xác nhận (assertion) phần mềm.
Định nghĩa tốt nhất cho một assertion mà tôi từng gặp là: “Một assert là một biểu thức Boolean tại một điểm cụ thể trong chương trình sẽ đúng (true) nếu không có lỗi trong chương trình.”2 Có thể sử dụng các assertion để chắc chắn rằng trạng thái chương trình chính xác như những gì chúng ta mong đợi. Nếu trạng thái là một cái gì đó khác, một assertion sẽ dừng sự thực thi và đưa ra thông tin gỡ lỗi (debug), chẳng hạn như file và số dòng (line number) nơi assertion bị sai. Từ đó, developer có thể đi sâu vào và hiểu điều gì đã xảy ra trước khi ứng dụng có cơ hội thay đổi trạng thái.
Assert macro được định nghĩa (define) tron file assert.h
. Assert macro thường có dạng như trong Figure 3-6. Nếu assertion là sai (false), một function do developer định nghĩa sẽ được gọi để thông báo cho người dùng về điều kiện thất bại. Trong trường hợp này, khi assertion là false, một message sẽ được print qua UART để liệt kê file và số dòng của assertion thất bại.
void __aeabi_assert(const char *expr, const char *file, int line) { Uart_printf(UART1, "Assertion failed in %s at line %d\n", file, line); for(;;); }
Figure 3-6. Example assert macro implementation
Lý do mà tôi đề cập đến assertion ở chương này, mặc dù chúng thực sự nằm ngoài phạm vi của cuốn sách này, là để chỉ ra rằng assertion là một cách rất tốt để kiểm tra các input, output, điều kiện trước và điều kiện sau cho các giao diện và các function đang sử dụng những định nghĩa giao diện thiết kế theo hợp đồng. Developer có thể sử dụng assert để xác minh rằng các điều kiện và input được đáp ứng. Nếu chúng không được đáp ứng, thì sẽ có bug trong code ứng dụng và developer có thể được thông báo ngay lập tức rằng họ đã làm sai.
Sử dụng assertion thì rõ ràng. Developer xác định điều kiện trước đối với function để develop một biểu thức kiểm tra điều kiện đó. Ví dụ: một Function_X yêu cầu input nhỏ hơn 150, developer sẽ kiểm tra điều kiện trước trong function bằng cách sử dụng đoạn code như sau:
void Function_X(uint8_t input) { assert(input < 150); // Function main body }
Mọi input và điều kiện trước cần phải được kiểm tra ở đầu function. Đây là cách developer xác minh rằng hợp đồng đã được hoàn thành bởi người dùng component. Kỹ thuật tương tự cũng có thể được sử dụng để xác minh rằng các điều kiện sau, output và thậm chí cả tác dụng phụ đã đúng.
Đến đây, một số độc giả có thể nghĩ rằng đã đưa ra đủ assertion vào code, chi phí và code space sẽ nhanh chóng trở nên quá nhiều. Các assertion nhằm bắt lỗi trong chương trình, và trong nhiều trường hợp, chúng chỉ được kích hoạt (enable) khi đang develop. Việc vô hiệu hóa (disable) các assertion sẽ khôi phục code space và một vài chu kỳ lệnh (instruction cycle). Việc định nghĩa macro DEBUG sẽ thay đổi assert macro thành empty macro, do đó sẽ vô hiệu hóa các assertion.
Chú ý! Đây là điều trọng yếu! Nếu các assertion sẽ bị disable để sản xuất (production), thì việc kiểm tra và đánh giá hợp lệ cuối cùng cần được thực hiện với các assertion bị disable. Lý do disable chúng là vì các biểu thức kiểm tra thật sự ảnh hưởng đến hiệu suất thời gian thực (real-time performance), dù nó chỉ chiếm một vài clock cycle. Việc thay đổi thời gian thực thi sau khi thử nghiệm có thể gây ra những hậu quả khôn lường.
(Đọc tiếp Phần 2)
Pingback: Device Driver Fundamentals in C (Phần 2) - Tạ Lục Gia Hoàng
Bổ sung: “1. Hiểu về Memory Map”
Bổ sung: “2. Planning the Driver Interfaces”
Bổ sung: “3. Design by Contract”
Bổ sung: “4. Assertion Fundamentals”
Pingback: Device Driver Fundamentals in C (Phần 3) - Lập Trình Nhúng dành cho Sinh Viên