(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
5. Device Driver Models
Có nhiều cách để develop low-level driver cho microcontroller. Hai mô hình chung mà chúng ta sẽ xem xét là blocking driver và non-blocking driver. Figure 3-7 so sánh mỗi mô hình bằng cách sử dụng biểu đồ tuần tự (sequence diagram), với các đường thẳng biểu thị thời gian sống của ứng dụng. Thời gian sống cho thấy sự truy cập của application code vào CPU và sự truy cập của device driver vào CPU.
Blocking driver có toàn quyền truy cập vào CPU và sẽ không trả lại CPU đến khi thao tác của driver hoàn tất. Một ví dụ điển hình là cách printf
được thiết lập trong một hệ thống nhúng. Việc gọi printf
trước tiên định dạng chuỗi mong muốn và đặt ký tự đầu tiên vào bộ đệm truyền (transmit buffer) UART. Sau đó, chương trình sẽ đợi cho đến khi ký tự được truyền hoàn toàn trước khi nhập ký tự tiếp theo vào bộ đệm (buffer). Quá trình lặp lại cho đến khi tất cả các ký tự được truyền đi. Chỉ khi đó printf mới trả về và cho phép dòng code tiếp theo thực thi. Blocking driver có tiềm năng phá hủy hiệu suất thời gian thực (realtime) của hệ thống nhúng và cần phải cẩn thận để hiểu thời gian thực thi tối thiểu, tối đa và trung bình của các driver được viết theo cách này.
Chiến lược thay thế là sử dụng non-blocking driver. Phiên bản non-blocking cho printf
, là một triển khai không chuẩn (non-standard implementation), sẽ chuẩn bị chuỗi và đặt ký tự đầu tiên vào transmit buffer. Khi ký tự ở trong buffer, hàm printf
liền quay lại ứng dụng chính và cho phép nó tiếp tục thực thi trong khi ký tự đang được truyền. Ứng dụng sẽ sử dụng một ngắt (interrupt) để phát hiện khi nào quá trình truyền ký tự hoàn tất để ký tự tiếp theo có thể được đặt vào buffer.
Mặt khác, blocking driver có thể rất đơn giản vì chúng không cần trả về ứng dụng chính và thực hiện chức năng giám sát. Vấn đề là hiệu suất thời gian thực có thể bị ảnh hưởng nghiêm trọng. Thay vào đó, non-blocking có thể được dùng. Non-blocking đảm bảo hiệu suất thời gian thực nhưng lại tăng độ phức tạp chương trình. Ứng dụng bây giờ phải bằng cách nào đó giảm sát khi nào ký tự kế sẵn sàng để đặt vào buffer. Hai cách chính để giám sát buffer có thể là polling hoặc interrupt-driven.
6. Polling Versus Interrupt-Driven Drivers
Cách dễ nhất để theo dõi sự kiện (event) xảy ra trong hệ thống là chỉ cần kiểm tra định kỳ xem cờ hoàn thành (complete flag) đã được set chưa. Kiểm tra định kỳ một cờ hoặc register bit được gọi là polling. Cơ bản là chúng ta hỏi, trạng thái cờ bây giờ là gì… bây giờ là gì … bây giờ là gì … bây giờ là gì … còn bây giờ thì sao… lặp đi lặp lại cho đến khi cờ được set. Một khi cờ được set, ứng dụng sẽ thực hiện hoạt động tiếp theo của nó. Phương pháp polling rất đơn giản nhưng hiệu quả rất kém. Chu kỳ clock bị lãng phí chỉ đơn giản là kiểm tra xem nên làm gì lúc này hoặc sau đó.
Một ví dụ hoàn hảo cho thấy hiệu suất thời gian thực bị ảnh hưởng đáng kể như thế nào bởi blocking driver hoặc function có thể được nhìn thấy trong Figure 3-8. Hình cho thấy thời gian cần thiết để in “Hello World” bằng cách sử dụng tốc độ truyền (baud rate) tiêu chuẩn là 9600 bit mỗi giây. Sử dụng “Hello World” là một chuỗi tương đối đơn giản, tuy nhiên, khi xem xét hình, người đọc sẽ phát hiện nó mất khoảng 12 mili giây để thực thi!
Figure 3-8. Blocking printf timing to print “Hello World”
Implement tiêu chuẩn của printf thậm chí có thể tồi tệ hơn! In một chuỗi cố định không giúp ích gì khi debug một hệ thống. Dữ liệu được truyền đi thường bao gồm các biến và dữ liệu sẽ thay đổi từ lần lặp này sang lần lặp tiếp theo và cần sự thay đổi. Figure 3-9 cho thấy cùng một triển khai block hiện đang in ra trạng thái hệ thống bằng printf(“The system state is %d”, State). Kết quả là, trung bình quá trình truyền mất 21 mili giây!
Figure 3-9. Blocking printf timing to print “The system state is %d”, State
Rõ ràng, block một ứng dụng trong hàng chục mili giây là một điều không thể chấp nhận được trong một ứng dụng thời gian thực. Giải pháp thay thế cho việc sử dụng polling là sử dụng interrupt. Mọi microcontroller đều có các interrupt đối với hầu hết mọi tình huống theo hướng sự kiện (event-driven) mà developer có thể quan tâm. Chúng có thể hiệu quả hơn nhiều và bản chất của chúng là non-blocking. Thiết lập và cấu hình interrupt có thể là một nỗ lực phức tạp và dễ xảy ra lỗi. Một developer cần phải cân nhắc cẩn thận các lựa chọn của họ và chọn phương pháp phù hợp nhất với tình hình.
Nếu một developer quay lại lập trình printf của họ và quyết định triển khai bằng giải pháp non-blocking, sử dụng interrupt hoàn thành quá trình truyền UART để load một ký tự mới vào transmit buffer, họ sẽ thấy sự cải thiện rõ ràng trong hiệu suất ứng dụng của họ. Đầu tiên, quá trình triển khai mới sẽ xử lý chuỗi và chuẩn bị nó để truyền, tùy thuộc vào độ phức tạp của chuỗi, có thể mất từ 0.5 đến 2.0 mili giây cho các chuỗi được sử dụng trong ví dụ block. Khi ký tự đầu tiên được truyền, các ký tự còn lại sẽ được truyền trong một interrupt được thực thi khoảng 1.2 mili giây một lần, như thể hiện trong Figure 3-10.
Figure 3-10. Transmit interrupt frequency for 9600 bauds
Mối quan tâm lớn tiếp theo là thời gian CPU mà interrupt đang sử dụng là bao nhiêu. Ngắt ứng dụng mỗi 1.2 mili giây có thể tiềm ẩn nguy cơ đến ứng dụng. Một developer sẽ muốn hiểu là interrupt sẽ thực thi trong bao lâu sau mỗi 1.2 mili giây. Figure 3-11 cho thấy thời gian thực hiện truyền UART trung bình cho ví dụ này. Interrupt yêu cầu khoảng 35 micro giây để clear cờ truyền hoàn thành (transmit-complete flag) và sau đó copy ký tự tiếp theo vào transmit buffer.
Figure 3-11. UART transmit interrupt duration
Đó là sự khác biệt rõ rệt về hiệu suất printf giữa các phương pháp blocking và non-blocking! Không có code ứng dụng nào thực thi trong 12 đến 21 mili giây ở triển khai blocking, trong khi các khối non-blocking block từ 1 đến 2 mili giây trước và sau đó ngắt trong 35 micro giây sau mỗi 1.2 mili giây.
CÁCH IMPLEMENT PRINTF HIỆN NAY
Ở kiến trúc ARM 32-bit hiện đại, developer không còn cần quan tâm đến thời gian cần thiết để thực hiện các lệnh printf. Nhiều phương pháp có sẵn cho developer sẽ cho phép các câu lệnh printf, thậm chí phức tạp, truyền trong vài micro giây.
Các implement này bao gồm:
- Segger real-time trace debugger capabilities
- Utilizing the ARM ITM module
Với những debugger và microcontroller không có những khả năng này hoặc khả năng tương đương, developer vẫn sẽ phải rất cẩn thận với những dòng lệnh printf của mình.
Interrupt không phải là phương pháp duy nhất được sử dụng để giảm thời gian driver block chương trình chính. Developer còn có thể sử dụng bộ điều khiển truy cập bộ nhớ trực tiếp (direct memory access – DMA controller). Trong một triển khai DMA, developer sẽ cấu hình DMA controller để ngắt và xử lý luồng di chuyển của data từ memory vào một peripheral hoặc từ peripheral tới memory. Ưu điểm của DMA là nó rất nhanh và không cần CPU. CPU có thể ở trạng thái năng lượng thấp hoặc đang thực thi code khác trong khi DMA controller đang di chuyển data xung quanh hệ thống. Hãy xem ví dụ về printf
, developer có thể thiết lập memory buffer, sau đó cấu hình DMA để truyền x ký tự từ buffer vào UART transmit buffer. Cách triển khai này sẽ loại bỏ interrupt định kỳ và cho phép CPU tập trung vào code ứng dụng. Một ví dụ về cách thiết lập DMA có thể như trong Figure 3-12.
Figure 3-12. DMA-controlled data transfer
DMA là một dụng cụ mạnh mẽ dành cho developer. Nhưng nó có thể phức tạp đối với người sử dụng lần đầu. Sử dụng DMA có thể tăng thêm sự phức tạp không cần thiết với phần mềm hoặc gây ra việc trừu tượng hóa và sự dịch chuyển data không rõ ràng khi nhìn sơ qua hệ thống. Tuy nhiên, hiệu suất lại đáng để bỏ qua phiền phức này. ĐỪNG QUÊN: hầu hết microcontroller có số lượng kênh (channel) DMA giới hạn, cho nên hãy sử dụng chúng một cách sáng suốt!
CASE STUDY — SỬ DỤNG DMA ĐỂ XÁC ĐỊNH VÀ KIỂM SOÁT TỌA ĐỘ GÓC
Trở lại lúc tôi đang làm đề tài thạc sĩ của mình tại Đại học Michigan, tôi đã tham gia vào việc thiết kế và triển khai phần mềm nhúng cho một vệ tinh nhỏ. Nhiệm vụ chính của tôi là tập trung vào code của chiếc máy tính bay chính tương tác với nửa tá hệ thống con hoặc hơn, và tổ chức hành vi cho toàn bộ vệ tinh. Một trong những hệ thống con là Hệ thống Kiểm soát và Xác định Tọa độ góc (Attitude Determination and Controls — ADAC) và nó đang gặp sự cố khi truy xuất và phân tích dữ liệu của nó. Theo định kỳ, dữ liệu sẽ bị mất như thể bộ xử lý không có đủ xuất lượng (throughput) để xử lý luồng dữ liệu.
Khi tôi ngồi xuống để xem xét phần firmware với kỹ sư trẻ hơn và ít kinh nghiệm hơn, tôi phát hiện rằng việc triển khai đã hoàn thiện. Bộ xử lý chỉ là không thể đồng thời theo kịp tốc độ dữ liệu, phân tích và giao tiếp. Tôi không thể thay đổi bộ xử lý. Cách thay thế duy nhất là sử dụng DMA để xử lý việc thu thập dữ liệu và lưu trữ bộ nhớ, đồng thời giảm bớt trách nhiệm cho CPU. Sau một vài cuộc thảo luận ngắn và chỉ mất chưa tới một giờ cập nhật các driver và phần mềm, hệ thống con ADAC đã hoạt động hoàn hảo.
Tất cả những gì cần làm là giảm tải xử lý dữ liệu từ CPU sang DMA controller.
7. Định nghĩa Driver Component
Đôi khi, người ta cảm giác như có hàng triệu thuật ngữ khác nhau xung quanh quá trình develop phần mềm. Trong một số trường hợp, các thuật ngữ này được sử dụng thay thế cho nhau mặc dù có sự khác biệt nhỏ. Trong phần này, chúng ta sẽ khám phá các thuật ngữ thường được kết hợp với driver và framework development với hy vọng rằng chúng ta có thể minh bạch chúng, đồng thời mô tả cách tổ chức driver từ cấp cao (high level).
Một module là đơn vị cơ bản được sử dụng để phát triển driver (hoặc thậm chí phần mềm nhúng nói chung). Những driver đơn giản sẽ chứa một module duy nhất, trong khi một driver phức tạp như Wi-Fi driver có thể chứa hàng tá module. Module chỉ đơn giản là sự kết hợp của header file và source file có liên quan với nó. Header file chứa giao diện (interface) hoặc các API được higher-level application code sử dụng để chạy module code. Source file chứa các chi tiết triển khai (implementation details) và tất cả các chi tiết cần thiết để làm công việc, được phơi bày trong interface.
Với những driver rất đơn giản, các module đôi khi được gọi là các thành phần (component). Một component là một tập hợp các module làm việc cùng nhau để thực hiện một tính năng phần mềm (software feature). Một driver phức tạp, như Wi-Fi driver, là một component đơn có thể được tạo thành từ một vài module. Các component rất đơn giản chỉ cần có header file và source file là có thể dùng trong một dự án. Các component phức tạp thường có cấu trúc thư mục để component có thể được tổ chức và giữ code riêng biệt với nhau.
Một driver thường sẽ có ít nhất ba mảnh (piece) khác nhau:
- The interface (Giao diện)
- The source code (Mã nguồn)
- A configuration module (Một module cấu hình)
Cách tổ chức ba mảnh này phụ thuộc hoàn toàn vào developer. Trong vài trường hợp, developer sẽ chọn tạo một thư mục cho toàn bộ component và include tất cả mảnh này chung tại cấp cao nhất của component. Trong trường hợp khác, developer sẽ quyết định chọn tạo những thư mục riêng biệt, mỗi cái cho một mảnh. Có nhiều cách khả dĩ để tổ chức một component. Một vài ví dụ có thể xem ở Figure 3-13.
Figure 3-13. Component organization
ĐỊNH NGHĨA
Module là một phần của chương trình (program), chứa một hoặc nhiều hàm (routine). Một hoặc nhiều module được develop tạo nên một chương trình.3
Component là một phần có thể nhận dạng của một chương trình lớn hơn hoặc cấu trúc (construction) lớn hơn. Một component cung cấp một chức năng cụ thể cho ứng dụng (application). Một ứng dụng được chia thành các component, mà lần lượt lại được tạo từ các module.4
Interface (Giao diện) là một ranh giới mà qua đó hai hệ thống (system) độc lập gặp và sử dụng để điều khiển hoặc giao tiếp với nhau.5
8. Khuyến Nghị về Quy Ước Đặt Tên
Có nhiều cách developer sử dụng để đặt tên cho các giao diện (interface), module và biến của họ. Có thể rất hấp dẫn nếu tạo ra một quy ước đặt tên mới nổi bật giữa đám đông. Vấn đề với việc tạo ra một quy ước đặt tên mới là đã có những hệ thống tuyệt vời tồn tại với cách đặt tên mọi thứ dành cho developer. Một ví dụ tuyệt vời mà developer nên đọc thử trong bài báo sau:
“Perfecting Naming Conventions” by Jack Ganssle 6
Tất cả bài báo đều cung cấp cho developer một nền tảng quy ước đặt tên có thể áp dụng một cách khôn ngoan. Có một vài ý tưởng cần nhấn mạnh mà tôi tin là rất quan trọng. Đầu tiên, developer cần sử dụng trường hợp lạc đà (camel case). Đây là một tiêu chuẩn được chấp nhận rộng rãi trong ngành công nghiệp phần mềm và việc li khai khỏi nó sẽ ảnh hưởng đáng kể đến khả năng đọc code. Theo quan điểm cá nhân, tôi cũng thích viết hoa ký tự bắt đầu của tên biến hơn. Điều đó làm phù hợp cách trình bày tiếng Anh khi phải viết quá nhiều.
Một quy ước khác mà tôi đặc biệt khuyên bạn nên bắt đầu với hệ thống con (subsystem) và sau đó làm việc theo hướng cụ thể. Ví dụ: một interface sẽ dùng để cung cấp chức năng đọc digital input/output peripheral được đặt tên là:
Dio_Read
Ba chữ cái đầu tiên chỉ định hệ thống con, theo sau là dấu gạch dưới và sau đó là mục đích. Quy ước này nhìn tự nhiên và giúp developer dễ dàng nhìn thấy tác nhân chính trước tiên và sau đó là mục đích của interface.
(Đọc tiếp Phần 3)
3https://www.techopedia.com/definition/3843/module
4http://whatis.techtarget.com/definition/component
Pingback: Device Driver Fundamentals in C (Phần 1) - Tạ Lục Gia Hoàng
Pingback: Device Driver Fundamentals in C (Phần 3) - Lập Trình Nhúng dành cho Sinh Viên