Writing Reusable Drivers (Phần 1)
6. Tổng quan về cách tạo Timer Driver
Gần như mọi hệ thống nhúng đều sử dụng một timer tích hợp để tính thời gian. Một timer thường sẽ chạy ở một hoặc mười mili giây (ms) và phối hợp với một scheduler để chạy hệ thống. Mỗi vi điều khiển (microcontroller) sẽ có các khả năng hơi khác nhau một chút vì nó liên quan đến timer peripheral, nhưng có một số điểm chung giữa tất cả microcontroller. Để xác định các khả năng của timer và xây dựng cơ sở hạ tầng cần thiết để tạo một timer driver mà có thể được tái sử dụng và tuân theo phương pháp ánh xạ bộ nhớ mảng con trỏ (pointer array memory-mapping methodology), thì có một số bước mà developer cần phải theo:
- Step #1 – Define the configuration table (Định nghĩa bảng cấu hình)
- Step #2 – Define the peripheral channels (Định nghĩa các kênh ngoại vi)
- Step #3 – Populate the configuration table (Phổ biến bảng cấu hình)
- Step #4 – Create the pointer arrays (Tạo các mảng con trỏ)
- Step #5 – Create the initialization function (Tạo hàm khởi tạo)
- Step #6 – Populate the driver interface (Phổ biến giao diện driver)
- Step #7 – Maintain and port the design pattern (Bảo trì và port mẫu thiết kế)
Khái niệm này có thể dễ dàng áp dụng cho bất kỳ peripheral driver nào.
Step #1: Define the Timer’s Configuration Table
Trước khi đi sâu vào pointer array và cách tạo timer driver cho chính nó, việc bắt đầu bằng phân tích những tham số cấu hình (configuration parameter) cần thiết cho cài đặt timer peripheral là điều hữu ích. Nguyên nhân là những developer cần hiểu rõ datasheet để xác định những register nào tồn tại cho timer và ý nghĩa của từng bit trong những register đó là gì. Trong khi những developer tìm hiểu sâu về những register đó, thì đây là lúc hoàn hảo để tạo cấu trúc bảng cấu hình (configuration table structure) mà sẽ được dùng khởi tạo peripheral. Đối với timer module, người ta kì vọng sẽ tìm thấy những register liên quan đến những mục đích sau:
- Cài đặt mode
- Kích hoạt (enable)
- Cài đặt nguồn clock (clock source)
- Bộ tiền chỉnh clock (clock pre-scaler)
- V.v
Thông tin cần thiết sẽ được tìm thấy khi xem từng register trong timer datasheet và liệt kê chúng ra một structure. Sau khi danh sách cấu hình (configuration list) được tạo, một channel name member có thể được thêm vào và được dùng để gán một giá trị mà con người có thể đọc. Developer sẽ muốn thêm một giá trị khoảng thời gian (timer-interval). Timer interval sẽ báo cho hàm khởi tạo (initialization function) biết tick rate của timer tính theo micro giây (μs) sẽ là bao nhiêu. Hàm khởi tạo được viết để lấy các tham số cấu hình (configuration parameter) cho clock và tự tính những giá trị register cần thiết để timer hành xử thích hợp. Điều này sẽ tiết kiệm nhiều nỗ lực ‘đau khổ’ của developer khỏi phải tính toán những giá trị register.
Một phương pháp tốt là đặt định nghĩa cấu trúc trong một header file, chẳng hạn như timer_config.h
. Một cấu trúc cấu hình timer (timer configuration structure) ví dụ có thể được thấy như Figure 4-19. Hãy nhớ rằng khi cấu trúc này được tạo một lần đầu, nó sẽ chỉ cần sửa đổi một ít là có thể sử dụng với những microcontroller khác sau này.
typedef struct { uint32_t TimerChannel; /**< Name of timer */ uint32_t TimerEnable; /**< Timer Enable State */ uint32_t TimerMode; /**< Counter Mode Settings */ uint32_t ClockSource; /**< Defines the clock source */ uint32_t ClockMode; /**< Clock Mode */ uint32_t Prescaler; /**< Clock Prescaler */ uint32_t ISREnable; /**< ISR Enable State */ uint32_t ISRPriority; /**< ISR Priority */ uint32_t Interval; /**< Interval in microseconds */ } TimerConfig_t;
Figure 4-19. Example timer configuration structure
Step #2: Define the Timer’s Peripheral Channels
Một peripheral channel là một hardware module độc lập với peripheral, chẳng hạn như Timer0, Timer1 và Timer2. Mỗi timer là riêng biệt bên trong microcontroller nhưng thường có các khả năng tương tự hoặc giống nhau so với những timer khác. Developer có thể coi mọi register và giá trị cấu hình liên quan với Timer0 module là Timer0 channel. Có một số lý do giải thích tại sao developer muốn tạo định nghĩa kênh (channel definition) bên trong code base phần mềm.
Đầu tiên, việc tạo một channel definition cho phép developer tạo một giá trị mà con người có thể đọc được, khi được include trong bảng cấu hình (configuration table), giúp việc tìm ra cấu hình liên quan với những cái gì trở nên đơn giản hơn. Trên một microcontroller nhỏ, điều này có vẻ không phải là vấn đề lớn nếu chỉ có hai timer, nhưng trong một microcontroller cao cấp, hiện đại thì có thể có hàng chục timer và việc nhìn vào một configuration table phức tạp có thể dẫn đến bối rối. Sự bối rối có thể dẫn đến lỗi và chúng ta đều muốn giảm thiểu lỗi nhiều nhất có thể.
Thứ hai, channel definition sẽ được driver sử dụng để truy cập phần tử chính xác trong mảng con trỏ (pointer array). Do đó, điều quan trọng là phải đảm bảo rằng thứ tự đặt tên channel khớp với thứ tự mảng con trỏ. Các channel được sử dụng trong giao diện driver và một lần nữa, nó giúp con người có thể đọc code dễ hơn, vì timer được sử dụng trong toàn bộ ứng dụng.
Channel definition không có gì khác hơn ngoài một enum
đơn giản. Nó liệt kê tất cả các peripheral channel hiện có sẵn. Ví dụ, một microcontroller có ba timer sẽ liệt kê ra là TIMER0
, TIMER1
và TIMER2
, như trong Figure 4-20. Ngoài việc liệt kê các channel, một thói quen tốt là bạn nên tạo một phần tử enum
cuối cùng có tên là MAX_TIMER hoặc NUMBER_OF_TIMERS để sau đó có thể được dùng làm cái kiểm tra điều kiện biên (boundary-condition checker).
typedef enum { TIMER0, /**< Timer 0 */ TIMER1, /**< Timer 1 */ TIMER2, /**< Timer 2 */ MAX_TIMER /**< Timers on the microcontroller */ } eTimerChannel;
Figure 4-20. Timer channel definition
Step #3: Populate the Timer’s Configuration Table
Khi các phần đã sẵn sàng để định nghĩa bảng cấu hình (configuration table), các developer có thể đi sâu vào tạo ra nó. Configuration table phải được đặt trong module timer_config.c
. Configuration table sẽ không hơn gì một array mà mọi phần tử đều thuộc kiểu TimerConfig_t
. Vì developer có thể không muốn quá trình khởi tạo có khả năng thay đổi trong quá trình vận hành, configuration table cũng nên được khai báo là const
. Configuration table cũng có thể được khai báo static
để nó chỉ có liên kết nội (internal linkage). Sau đó, ta có thể viết một hàm trợ giúp (helper function) để trả về một pointer trỏ đến configuration table. Pointer này sẽ được sử dụng bởi các phần khác của ứng dụng, và bản thân configuration table vẫn đảm bảo được ẩn.
Một ví dụ về timer configuration table có thể được thấy như Figure 4-21.
Chú ý: trong file timer_config.h
, định nghĩa kiểu eTimerChannel
phải có trước định nghĩa structure TimerConfig_t
, vì phần tử TimerChannel
được định nghĩa bằng kiểu biến eTimerChannel
, cách viết này giúp con người dễ đọc code hơn.
// // timer_config.h // typedef enum { TIMER0, /**< Timer 0 */ TIMER1, /**< Timer 1 */ TIMER2, /**< Timer 2 */ MAX_TIMER /**< Timers on the microcontroller */ } eTimerChannel; typedef struct { uint32_t TimerChannel; /**< Name of timer or channel number */ uint32_t TimerEnable; /**< Timer Enable State */ uint32_t TimerMode; /**< Timer Mode */ uint32_t ClockSource; /**< Clock source */ uint32_t ClockMode; /**< Clock Mode */ uint32_t Prescaler; /**< Clock Prescaler */ uint32_t ISREnable; /**< ISR Enable State */ uint32_t ISRPriority; /**< ISR Priority */ uint32_t Interval; /**< Interval in microseconds */ } TimerConfig_t; // // timer_config.c // static const TmrConfig_t TmrConfig[] = { // Timer Timer Timer Clock Clock Mode Clock Interrupt Interrupt Timer // Name Enable Mode Source Selection Prescaler Enable Priority Interval (us) // {TIMER0, ENABLED, UP_COUNT, FLL_PLL, MODULE_CLK, TMR_DIV_1, DISABLE, 3, 100 }, {TIMER1, DISABLED, UP_COUNT, NOT_APPLICABLE, STOP, TMR_DIV_1, DISABLE, 0, 0 }, {TIMER2, ENABLED, UP_COUNT, FLL_PLL, MODULE_CLK, TMR_DIV_1, DISABLE, 3, 100 }, };
Figure 4-21. Example timer configuration table
Bởi vì cấu hình có liên kết nội, developer sẽ cần phải tạo một helper function trả về một pointer trỏ đến configuration table. Một helper function đơn giản có thể được thấy như Figure 4-22.
// // timer_config.c // const TimerConfig_t* Tmr_ConfigGet(void) { return TmrConfig; }
Figure 4-22. Configuration table helper function
Step #4: Create the Timer’s Pointer Arrays
Việc tạo các mảng con trỏ (pointer array) ánh xạ (map) vào không gian bộ nhớ ngoại vi (peripheral memory space) rất đơn giản nhưng đôi khi có thể gây bối rối. Các pointer array sẽ được để trong driver module cho peripheral. Đối với một timer, nó sẽ là các module timer.h
và timer.c
. Những module này sẽ chứa tất cả timer driver function cùng với timer driver interface.
Một array sẽ được tạo cho mọi register chung tồn tại trong những timer peripheral. Mỗi array sẽ có một dạng tổng quát như trong Figure 4-23, và sẽ được tuân theo ở hầu hết mọi cách ánh xạ bộ nhớ (memory mapping). Cụm từ REG_SIZE
có thể được thay thế bằng kiểu số nguyên có độ rộng cố định (fixed-width integer) được định nghĩa cho bộ xử lý mục tiêu (target processor). Ví dụ: nếu mục tiêu là microcontroller 8 bit, REG_SIZE
sẽ được thay hoặc được define bằng uint8_t
. Đối với processor 32 bit thì REG_SIZE
sẽ được thay bằng là uint32_t
.
Một số fixed-width interger thường dùng để thay thế cho REG_SIZE
: uint8_t
, uint16_t
, uint32_t
, uint64_t
.
static REG_SIZE volatile* const ARRAY_NAME[CHANNELS]
Figure 4-23. Generic pointer array mapping pattern
Cụm từ ARRAY_NAME
được thay bằng một mô tả cho kiểu register mà array sẽ ánh xạ là gì. CHANNELS
có thể được bỏ qua trong phần định nghĩa array. Nhưng nếu một developer muốn code tường minh nhất có thể, điều này luôn luôn là ý tưởng tuyệt vời, thì một con số cụ thể để cho biết số phần tử trong mảng là cần thiết – thay thế CHANNELS
bằng số phần tử trong mảng.
Cần lưu ý rằng vị trí của const
và volatile
là rất quan trọng. Đặt chúng ở một vị trí khác sẽ thay đổi hoàn toàn những gì là hằng số và liệu data hoặc pointer có được đọc lại ở mỗi lần chương trình gặp hay không. Const
nói với trình biên dịch (compiler) rằng pointer trong array không thể thay đổi để trỏ đến bất kỳ thứ gì khác, giữ cho pointer của chúng ta không bị thay đổi. Trên hầu hết compiler, việc này sẽ đồng thời ép buộc array phải được lưu trữ trong flash. Volatile
nói với compiler rằng data trong register có thể thay đổi bất kì lúc nào, vì vậy phải đọc lại data. Một developer có thể muốn đi xa hơn bằng việc hạn chế sự liên kết pointer-array chỉ trong nội bộ bằng khai báo mảng tĩnh (array static), đây là một phương pháp lập trình rất tốt.
Sử dụng cách định nghĩa tổng quát được giới thiệu ở Figure 4-23. Developer sẽ cần sử dụng mẫu định nghĩa trên để tạo một array và điền vào nó những pointer đến register ứng với mỗi peripheral channel. Các định nghĩa register thường được tạo sẵn bởi nhà sản xuất vi điều khiển và đôi khi đã có sẵn ở dạng pointer. Tuy nhiên, phần lớn chỉ có địa chỉ của những register là được định nghĩa sẵn, và developer phải ép kiểu (typecast) những địa chỉ có sẵn này sang pointer khi khởi tạo array. Một ví dụ về timer peripheral cho thấy một cách định nghĩa pointer-array như ở Figure 4-24.
uint32_t volatile* const tmrreg[NUM_TIMERS]= { (uint32_t*)&TPM_SC, (uint32_t*)&TPM1_CNT }
Figure 4-24. Example timer peripheral pointer-array initialization
Step #5: Create the Initialization Function
Tất cả những bước trên là đang cài đặt phần khung được yêu cầu để ánh xạ vào không gian bộ nhớ ngoại vi (peripheral memory space) và cấu hình driver. Bây giờ, đã đến lúc viết hàm khởi tạo peripheral. Ưu điểm tuyệt nhất của sử dụng mảng con trỏ (pointer array) là có thể tạo một hàm khởi tạo (initialization function) đơn giản và có khả năng tái sử dụng! Pointer array cho phép developer tạo một mẫu thiết kế (design pattern) có thể được tái sử dụng từ ứng dụng này sang ứng dụng khác chỉ với những thay đổi nhỏ cần thiết để hỗ trợ các microcontroller mới. Việc cập nhật design pattern cho một microcontroller mới sẽ tốn ít thời gian hơn so với làm lại từ đầu.
Bước đầu tiên để viết hàm khởi tạo là tạo một hàm sơ khai Timer_Init
lấy một pointer đến TimerConfig_t
. Đừng quên rằng TimerConfig_t
là một structure chứa tất cả thông tin khởi tạo cho các timer channel khác nhau. Các developer nên khai báo pointer là const
để code khởi tạo không thể vô ý thay đổi giá trị pointer. Ngoài ra code cấu hình có thể được lưu trong flash, để nó không thể dễ dàng bị thay đổi nếu không có kích hoạt hỗ trợ từ flash controller, do đó khai báo pointer const
dù sao vẫn là một phương pháp lập trình an toàn.
Trước khi viết mỗi dòng code, hãy dành vài phút để develop một sơ đồ kiến trúc (architectural diagram) và một lưu đồ (flowchart) mô tả hàm khởi tạo sẽ hoạt động như thế nào. Một sơ đồ hoạt động đơn giản để khởi tạo timer thông qua bảng cấu hình (configuration table) và mảng con trỏ (pointer array) có thể được thấy như trong Figure 4-25. Theo nghĩa đen, toàn bộ việc được thực hiện là những vòng lặp code với configuration table, mỗi lần là một phần tử, và đọc cấu hình để set cho peripheral. Việc cài đặt sau đó được ánh xạ vào đúng register và các bit trước khi chuyển sang tham số tiếp theo.
Figure 4-25. Timer initialization flowchart
Kết quả là một hàm khởi tạo đơn giản chỉ xài vòng lặp qua configuration table rồi ghi vào pointer array. Một ví dụ về hàm khởi tạo rút gọn có thể thấy trong Figure 4-26. Lưu ý rằng mọi truy cập pointer array yêu cầu chúng ta bỏ tham chiếu (dereference) đến pointer trong phần tử mảng (array element).
Việc khởi tạo có thể được viết để đơn giản hóa phần mềm ứng dụng của developer nhiều nhất có thể. Ví dụ, một timer module muốn có tốc độ truyền (baud-rate) nào đó, nó chỉ cần truyền giá trị baud-rate mong muốn vào quá trình khởi tạo, và driver sẽ có thể tính toán các giá trị register cần thiết dựa trên những input cài đặt clock cấu hình. Khi đó, configuration table sẽ trở thành một sự trừu tượng hóa (abstraction) register cấp rất cao cho phép một developer không quen thuộc với hardware có thể dễ dàng chỉnh sửa timer mà không cần phải lôi datasheet ra đọc.
void Tmr_Init(const TmrConfig_t *Config) { for(i = 0; i < NUM_TIMERS; i++) { // Loop through the configuration table and set each // register if(Config[i].TimerEnable == ENABLED) { // Fill in the timer initialization code } } }
Figure 4-26. Driver high-level loop initialization example
// Enable the clock gate *tmrgate[i] |= tmrpins[i]; // Reset the timer register *tmrreg[i] = 0; // Clear the timer counter register *tmrcnt[i] = 0; // Calculate and set period register for this timer // Timer period = (System Clock Frequency in Hz / Timer Divider) - 1 // = ((1,000,000 / Desired Timer Interval in ms) - 1) *modreg[i] = ((GetSystemClock() / Config[i].ClkPrescaler) / (TMR_PERIOD_DIV / Config[i].Interval)) - 1 // If the timer interrupt is set to ENABLE in the timer // configuration table, set the interrupt enable bit, enable IRQ, // and set interrupt priority. Else, clear the enable bit. if(Config[i].IntEnabled == ENABLED) { *tmrreg[i] |= REGBIT6; Enable_Irq(TmrIrqValue[i]); Set_Irq_Priority(TmrIrqValue[i], Config[i].IntPriority); }
Figure 4-27. Timer init loop code
Xem code đầy đủ tại github
Step #6: Fill in the Timer Driver Interface
Sau khi hoàn thành và kiểm tra hàm khởi tạo, driver sẽ yêu cầu những giao diện (interface) bổ sung để điều khiển timer. Developer có thể cần thêm interface để kích hoạt (enable) và không kích hoạt (disable) timer, thay đổi bộ đếm khoảng thời gian (counter interval), v.v. Trước khi đến giai đoạn thực hiện (implement), các chức năng của inteface cần hoàn thành và một timer đã được khởi tạo, bây giờ chúng có thể được điền vào và kiểm tra.
Chi tiết về cách thiết kế interface sẽ được đề cập chi tiết ở phần sau. Bây giờ, hãy xem ví dụ các hàm sau đây về timer-driver:
Timer_Init
Timer_Control (Enable/Disable)
Timer_IntervalSet
Timer_ModeSet
Step #7: Maintain and Port the Design Pattern
Khi timer-driver đã được triển khai đầy đủ, có thể sử dụng nó làm mẫu thiết kế (design pattern). Hầu như mọi bộ vi điều khiển sẽ có các thiết bị ngoại vi (peripheral) trên bo mạch có các behaviors và hàm tương tự. Ví dụ: mỗi module thời gian cần có sự cho phép, nguồn clock, bộ tiền chỉnh (pre-scaler), bộ đếm (counter), v.v. Các peripheral có thể tồn tại trong một vùng bộ nhớ hoàn toàn khác và có tên khác nhau, đó là lý do tại sao các mảng con trỏ rất tiện dụng. Chỉ cần cập nhật các pointer array với các register pointer đúng và sửa đổi các bit điều khiển là driver có thể port sang một microcontroller mới.
Việc triển khai driver bằng cách sử dụng pointer array có thể giúp giảm thời gian cần thiết để implement và test các driver trong tương lai. Dưới đây là một quy trình đơn giản mà developer có thể làm theo để cập nhật design pattern cho bất kỳ microcontroller nào.
- Step #1 – Update the configuration table definitions (Cập nhật các định nghĩa bảng cấu hình).
- Step #2 – Update the configuration table declarations (Cập nhật các khai báo bảng cấu hình)
- Step #3 – Update the pointer arrays. (Cập nhật các mảng con trỏ)
- Step #4 – Update the initialization and driver functions. (Cập nhật các hàm khởi tạo và driver)
- Step #5 – Perform regression (Thực hiện hồi quy)
7. Selecting the Right Driver Implementation
Tiếp theo, trong chương này, chúng ta đã kiểm tra một vài phương pháp khác nhau có thể dùng để ánh xạ (map) một driver vào không gian bộ nhớ ngoại vi (peripheral memory space). Từ truy cập thanh ghi trực tiếp (direct register access) đến những phương pháp ánh xạ mảng con trỏ phức tạp hơn (pointer array mapping). Chọn lựa đúng phương pháp cho công việc có thể hơi khó, đặc biệt nếu một team muốn tái sử dụng nhưng có một hệ thống rất hạn chế về tài nguyên.
Để đưa ra quyết định sáng suốt, các developer cần cân nhắc một vài yếu tố sau, bao gồm:
- Code size
- Tốc độ xử lý (Execution speed)
- Hiệu suất (Efciency)
- Khả năng port (Portability)
- Khả năng cấu hình (Configurability)
Mapping Technique | Code Size | Execution Speed | Efficiency | Portability | Configurability |
Direct Register Access | Smallest | Fastest | Most Efficiency | Least | Least |
Pointer Structure | Average | Average | Average | Average | Average |
Pointer Array | Largest | Slowest | Least Efficiency | Most | Most |
Table 4-1. Memory Map Comparison
Table 4-1 so sánh các phương pháp ánh xạ bộ nhớ khác nhau và khi nào chúng được triển khai tốt nhất. Chú ý rằng bảng trên đang làm một phép so sánh trực tiếp, và một phương pháp có vẻ được đánh giá là kém hiệu quả nhất, developer hãy quan sát xem điều đó thực sự có ý nghĩa gì. Có thể có một số hướng dẫn bổ sung nói về cách truy cập register bằng việc index một array và tham chiếu đến một pointer. Trong hầu hết ứng dụng, các hướng dẫn bổ sung sẽ không thực sự ảnh hưởng đến hiệu suất ứng dụng, nhưng thực hiện một vài thử nghiệm có thể giúp bạn rút ra được đánh giá những trường hợp nào tốt nhất và xấu nhất.
Nói chung, kỹ thuật truy cập thanh ghi trực tiếp (direct register access) được sử dụng tốt nhất cho các hệ thống rất hạn chế về tài nguyên với code space ít hơn 16 kB. Các hệ thống này thường là 8-bit và có tốc độ clock nhỏ hơn 48 MHz. Ánh xạ cấu trúc con trỏ (pointer-structure mapping) là một kỹ thuật chung khá tốt thường được các nhà sản xuất microcontroller sử dụng mặc định. Mảng con trỏ (pointer array) thực sự yêu cầu microcontroller có ít nhất 32 kB code space. Lý do chính là các bảng cấu hình và mảng con trỏ có thể chiếm nhiều code space, mà trong các thiết bị hạn chế tài nguyên thường không đủ.
8. Going Further (Thực hành)
Hãy kiểm tra xem bạn có thể làm gì để nắm được các khái niệm đã thảo luận trong chương này và bắt đầu áp dụng chúng vào phần mềm nhúng của bạn.
- Chọn một code module trong các ứng dụng của bạn. Xác định tất cả nơi có biến và hàm được ngầm khai báo là
extern
. Những biến nào có thể thay đổi thành static? - Kiểm tra file ánh xạ hardware register cho microcontroller đang dùng. Những từ khóa nào hiện diện?
const
?volatile
? - Phương pháp ánh xạ bộ nhớ (memory-mapping) nào đang được sử dụng?
- Kiểm tra datasheet và những file hardware register cho microcontroller.
- Viết ba loại timer driver khác nhau bằng cách sử dụng từng phương pháp sau:
- Truy cập trực tiếp vào register
- Sử dụng structure
- Sử dụng pointer array
Trả lời các câu hỏi sau về driver:
- Driver nào implement nhanh nhất?
- Cái nào có code size nhỏ nhất? Lớn nhất?
- Cái nào con người dễ đọc hơn?
Bổ sung Step #4: Create the Timer’s Pointer Arrays
Bổ sung Step #5: Create the Initialization Function
Bổ sung
Step #6: Fill in the Timer Driver Interface
Step #7: Maintain and Port the Design Pattern
Bổ sung: Going further
Bổ sung: 7. Selecting the Right Driver Implementation