[Tự Học FreeRTOS] Chapter 2: Queue Management (Phần 1)

2.1 Giới Thiệu

‘Queue’ (hàng đợi) là một cơ chế hỗ trợ giao tiếp task qua task (task-to-task), task qua interrupt (interrupt-to-task), và interrupt qua task (interrupt-to-task).

Nội dung chương này gồm có:

  • Cách tạo queue.
  • Queue quản lý dữ liệu của nó như thế nào.
  • Làm sao để gởi dữ liệu tới queue.
  • Làm sao để nhận dữ liệu từ queue.
  • Block trên một queue nghĩa là gì.
  • Làm sao để block trên nhiều queue.
  • Làm sao để ghi đè dữ liệu trong một queue.
  • Làm sao để clear một queue.
  • Ảnh hưởng của độ ưu tiên task khi ghi vô và đọc từ một queue.

Chương này chỉ tập trung vô giao tiếp task-to-task. Các chương sau sẽ đề cập tới giao tiếp task-to-interrupt và interrupt-to-task.

2.2 Đặc Điểm Của Queue

2.2.1 Lưu Trữ Dữ Liệu (Data Storage)

Một queue có thể chứa một số lượng hữu hạn các data item có kích thước cố định. Số lượng tối đa các item mà một queue có thể chứa được gọi là ‘chiều dài’ của nó. Chiều dài và kích thước của mỗi data item đều được thiết lập lúc queue được tạo.

Queue thường được xài như một FIFO buffer (First In First Out), dữ liệu được ghi vô ở đuôi (tail) của queue và được lấy ra ở đầu (head) của queue. Hình 2.1 minh họa dữ liệu được ghi vô và đọc ra từ một queue đang được xài như FIFO. Ngoài ra cũng có thể ghi vô đầu của queue, và ghi đè (overwrite) dữ liệu đã có ở đầu queue.

Hình 2.1. Ví dụ về quá trình dữ liệu được ghi vô và đọc ra từ một queue

Có hai cách để triển khai (implement) hành vi của queue:

  1. Queue by copy: nghĩa là dữ liệu gởi vô queue sẽ được copy theo từng byte vô queue.
  2. Queue by reference: nghĩa là queue chỉ giữ những con trỏ (pointer) đến dữ liệu được gởi vô queue, chứ không giữ dữ liệu thật sự.

FreeRTOS áp dụng phương pháp queue by copy vì nó vừa mạnh mẽ và dễ để xài hơn so với queue by reference, bởi vì:

  • Queue by copy vẫn cho phép queue được xài queue by reference. Ví dụ: khi kích thước của dữ liệu được đưa vô queue quá lớn khiến việc copy trực tiếp vô queue không thực tế, thì có thể thay thế bằng cách copy một pointer trỏ đến dữ liệu đó vô queue.
  • Một biến stack có thể được gởi trực tiếp vô queue, ngay cả khi biến đó sẽ biến mất sau khi hàm khai báo nó kết thúc (exit).
  • Dữ liệu có thể được gởi vô queue mà không cần phải cấp phát một buffer để chứa dữ liệu trước — bạn có thể copy dữ liệu vô một buffer đã cấp phát và đưa vô queue một tham chiếu (reference) đến buffer đó.
  • Task gởi (sending task) có thể ngay lập tức xài lại biến hoặc buffer đã được gởi vô queue.
  • Task gởi (sending task) và task nhận (receiving task) hoàn toàn tách biệt; người thiết kế ứng dụng không cần quan tâm task nào “sở hữu” dữ liệu, hoặc task nào chịu trách nhiệm giải phóng (release) dữ liệu.
  • RTOS chịu trách nhiệm hoàn toàn trong việc cấp phát bộ nhớ dùng để lưu dữ liệu.
  • Các hệ thống có bảo vệ bộ nhớ (memory protected system) sẽ hạn chế truy cập RAM, trong trường hợp đó queue by reference chỉ khả thi nếu cả task gởi và task nhận đều có thể truy cập được dữ liệu tham chiếu. Queue by copy cho phép dữ liệu đi qua các ranh giới bảo vệ bộ nhớ.

2.2.2 Truy Cập Bởi Nhiều Task (Access by Multiple Tasks)

Queue là những đối tượng độc lập và có thể được truy cập bởi bất kỳ task nào hoặc ISR nào biết đến sự tồn tại của chúng. Bất kỳ task nào cũng có thể ghi (write) dữ liệu vô cùng một queue, và bất kỳ task nào cũng có thể đọc (read) dữ liệu từ cùng một queue. Trên thực tế, có rất nhiều trường hợp một queue được nhiều task ghi dữ liệu, nhưng lại ít gặp một queue được nhiều task đọc dữ liệu.

2.2.3 Chặn Khi Đọc Queue (Blocking on Queue Reads)

Khi một task cố gắng đọc (read) từ một queue, nó có thể chỉ định một khoảng thời gian ‘block’. Đây là khoảng thời gian task được giữ ở trạng thái Blocked để chờ dữ liệu xuất hiện trong queue, nếu queue đang rỗng (empty). Một task đang ở trạng thái Blocked để chờ dữ liệu từ queue sẽ tự động được chuyển sang trạng thái Ready khi một task khác hoặc một interrupt gởi dữ liệu vô queue. Task cũng sẽ tự động được chuyển từ trạng thái Blocked sang trạng thái Ready nếu thời gian chỉ định block hết hạn trước khi dữ liệu xuất hiện.

Queue có thể có nhiều task đọc dữ liệu, vì vậy một queue có thể có nhiều task cùng bị block để chờ dữ liệu. Trong trường hợp này, chỉ một task sẽ được unblocked khi dữ liệu xuất hiện. Task được unblocked luôn là task có độ ưu tiên cao nhất trong số các task đang chờ dữ liệu. Nếu có từ hai task trở lên có cùng độ ưu tiên, thì task được unblocked sẽ là task đã chờ lâu nhất.

2.2.4 Chặn Khi Ghi Queue (Blocking on Queue Writes)

Cũng giống như khi đọc từ một queue, một task có thể chỉ định thời gian block khi ghi (write) vô một queue. Trong trường hợp này, thời gian block là khoảng thời gian tối đa task sẽ bị giữ ở trạng thái Blocked để chờ chỗ trống xuất hiện trong queue, nếu queue đã đầy.

Queue có thể có nhiều task ghi dữ liệu, vì vậy một queue đầy (full) có thể có nhiều task cùng bị block để chờ hoàn tất thao tác gởi. Trong tình huống này, chỉ một task sẽ được unblocked khi có chỗ trống trong queue. Task được unblocked luôn là task có độ ưu tiên cao nhất trong số các task đang chờ chỗ trống. Nếu có từ hai task trở lên có cùng độ ưu tiên, thì task được unblocked sẽ là task đã chờ lâu nhất.

2.2.5 Chặn Ở Nhiều Queue (Blocking on Multiple Queues)

Các queue có thể được nhóm lại thành các tập hợp (set), cho phép một task vô trạng thái Blocked để chờ dữ liệu xuất hiện ở bất kỳ queue nào trong tập đó.

2.2.6 Tạo Queue: Statically Allocated Queue và Dynamically Allocated Queue

Các queue được tham chiếu thông qua handle, là biến có kiểu QueueHandle_t. Một queue phải được tạo tường minh trước khi xài.

Có hai hàm API để tạo queue: xQueueCreate(), xQueueCreateStatic().

2.3 Cách Xài Queue

2.3.1 Hàm API xQueueCreate()

Mỗi queue cần hai khối RAM: khối thứ nhất là để giữ cấu trúc dữ liệu của nó (data structure), và khối thứ hai là để giữ dữ liệu được gởi vô queue (queued data). xQueueCreate() sẽ cấp phát RAM cần thiết từ heap (dynamically). xQueueCreateStatic() xài RAM đã được cấp phát sẵn (pre-allocated RAM) truyền vô hàm dưới dạng tham số.

QueueHandle_t xQueueCreate( UBaseType_t uxQueueLength, UBaseType_t uxItemSize );
C

Code Block 2.1. Prototype của hàm API xQueueCreate()

Tham số và giá trị trả về của xQueueCreate():

  • uxQueueLength
    Số phần tử (item) lớn nhất mà queue được tạo có thể giữ ở mọi thời điểm.
  • xItemSize
    Kích thước của mỗi data item tính theo byte được giữ trong queue.
  • Giá trị trả về
    Nếu NULL được trả về, nghĩa là queue không thể được tạo vì không có đủ bộ nhớ heap để FreeRTOS cấp phát cho cấu trúc dữ liệu và vùng lưu trữ của queue.
    Nếu một giá trị khác NULL được trả về thì queue đã được tạo thành công và giá trị trả về chính là handle của queue vừa được tạo.

xQueueReset() là một hàm API dùng để khôi phục một queue đã được tạo trước đó về trạng thái rỗng ban đầu.

2.3.2 Hàm API xQueueSendToBack() và xQueueSendToFront()

Như có thể thấy, xQueueSendToBack() gởi dữ liệu vô phía sau (tail) của queue, và xQueueSendToFront() gởi dữ liệu vô phía trước (head) của queue.

xQueueSend() cũng tương đương, chính xác là xQueueSendToBack().

Chú ý: Đừng bao giờ gọi xQueueSendToFront() hay xQueueSendToBack() bên trong một interrupt service routine. Phiên bản an toàn dành cho interrupt chính là xQueueSendToFrontFromISR()xQueueSendToBackFromISR(). Sẽ được nói đến ở Chương 4.

BaseType_t xQueueSendToFront( QueueHandle_t xQueue,
                              const void * pvItemToQueue,
                              TickType_t xTicksToWait );
C

Code Block 2.2. Prototype của hàm API xQueueSendToFront()

BaseType_t xQueueSendToBack( QueueHandle_t xQueue,
                             const void * pvItemToQueue,
                             TickType_t xTicksToWait );
C

Code Block 2.3. Prototype của hàm API xQueueSendToBack()

Tham số và giá trị trả về của xQueueSendToFront() và xQueueSendToBack():

  • xQueue
    Handle của queue mà dữ liệu sẽ được gởi (ghi). Queue handle sẽ được trả về từ xQueueCreate() hay xQueueCreateStatic() là những hàm dùng để tạo queue.
  • pvItemToQueue
    Một con trỏ (pointer) tới dữ liệu cần copy vô queue. Kích thước của mỗi phần tử (item) mà queue có thể giữ, được chọn khi tạo queue, do đó nhiều byte được copy từ pvItemToQueue vô vùng lưu trữ của queue.
  • xTicksToWait
    Khoảng thời gian tối đa mà một task nên ở trạng thái Blocked để chờ chỗ trống xuất hiện trong queue, nếu queue đã đầy (full).
    Cả xQueueSendToFront()xQueueSendToBack() sẽ trả về ngay lập tức nếu xTicksToWait là 0 và queue đã đầy.
    Thời gian block được tính theo tick period, vì vậy thời gian tuyệt đối mà nó tượng trưng phụ thuộc vô tần số tick (tick frequency). Macro pdMS_TO_TICKS() có thể được xài để chuyển đổi thời gian tính bằng mili giây thành thời gian tính bằng tick.
    Đặt xTicksToWait bằng portMAX_DELAY sẽ khiến cho task chờ vô hạn (không có timeout), cho INCLUDE_vTaskSuspend được đặt bằng 1 trong FreeRTOSConfig.h.
  • Giá trị trả về
    Có hai giá trị trả về khả dĩ:
    • pdPASS
      pdPASS được trả về khi dữ liệu đã được gởi thành công vô queue. Nếu thời gian block được chỉ định (xTicksToWait khác 0), thì có thể task gọi hàm bị đặt vô trạng thái Blocked để chờ chỗ trống xuất hiện trong queue trước khi hàm trả về, nhưng dữ liệu đã được ghi vô queue thành công trước khi thời gian block hết hạn.
    • errQUEUE_FULL (cùng giá trị với pdFAIL)
      errQUEUE_FULL được trả về nếu dữ liệu không thể ghi vô queue vì queue đã đầy. Nếu thời gian block được chỉ định (xTicksToWait khác 0) thì task gọi hàm sẽ bị đặt vô trạng thái Blocked để chờ một task khác hoặc một interrupt tạo chỗ trống trong queue, nhưng thời gian block được chỉ định đã hết hạn trước khi điều đó xảy ra.

2.3.3 Hàm API xQueueReceive()

xQueueReceive() nhận (đọc) một item từ queue. Item nhận được sẽ bị xóa khỏi queue.

Chú ý: Đừng bao giờ gọi xQueueReceive() bên trong một interrupt service routine. Phiên bản an toàn dành cho interrupt chính là xQueueReceiveFromISR(). Sẽ được nói đến ở Chương 4.

BaseType_t xQueueReceive( QueueHandle_t xQueue,
                          void * const pvBuffer,
                          TickType_t xTicksToWait );
C

Code Block 2.4. Prototype của hàm API xQueueReceive()

Tham số và giá trị trả về của xQueueReceive()

  • xQueue
    Handle của queue mà dữ liệu sẽ được nhận (đọc). Queue handle sẽ được trả về từ xQueueCreate() hay xQueueCreateStatic() là những hàm dùng để tạo queue.
  • pvBuffer
    Con trỏ (pointer) tới bộ nhớ nơi dữ liệu nhận về sẽ được copy.
    Kích thước của mỗi data item mà queue giữ được chọn khi tạo queue. Bộ nhớ mà pvBuffer trỏ tới phải ít nhất đủ lớn để lưu số lượng byte đó.
  • xTicksToWait
    Khoảng thời gian tối đa mà một task nên ở trạng thái Blocked để chờ chỗ trống xuất hiện trong queue, nếu queue đã rỗng (empty).
    Nếu xTicksToWait là 0, thì xQueueReceive() sẽ trả về ngay lập tức nếu queue đã rỗng.
    Thời gian block được tính theo tick period, vì vậy thời gian tuyệt đối mà nó tượng trưng phụ thuộc vô tần số tick (tick frequency). Macro pdMS_TO_TICKS() có thể được xài để chuyển đổi thời gian tính bằng mili giây thành thời gian tính bằng tick.
    Đặt xTicksToWait bằng portMAX_DELAY sẽ khiến cho task chờ vô hạn (không có timeout), cho INCLUDE_vTaskSuspend được đặt bằng 1 trong FreeRTOSConfig.h.
  • Giá trị trả về
    Có hai giá trị trả về khả dĩ:
    • pdPASS
      pdPASS được trả về khi dữ liệu đã được đọc thành công từ queue. Nếu thời gian block được chỉ định (xTicksToWait khác 0), thì có thể task gọi hàm bị đặt vô trạng thái Blocked để chờ dữ liệu xuất hiện trong queue, nhưng dữ liệu đã được đọc từ queue thành công trước khi thời gian block hết hạn.
    • errQUEUE_EMPTY (cùng giá trị với pdFAIL)
      errQUEUE_EMPTY được trả về nếu dữ liệu không thể đọc từ queue vì queue đã rỗng. Nếu thời gian block được chỉ định (xTicksToWait khác 0) thì task gọi hàm sẽ bị đặt vô trạng thái Blocked để chờ một task khác hoặc một interrupt gởi dữ liệu vô trong queue, nhưng thời gian block được chỉ định đã hết hạn trước khi điều đó xảy ra.

2.3.4 Hàm API uxQueueMessagesWaiting()

uxQueueMessagesWaiting() truy vấn (query) số item đang có sẵn trong queue.

Chú ý: Đừng bao giờ gọi uxQueueMessagesWaiting() bên trong một interrupt service routine. Phiên bản an toàn dành cho interrupt chính là uxQueueMessagesWaitingFromISR().Sẽ được nói đến ở Chương 4.

UBaseType_t uxQueueMessagesWaiting( QueueHandle_t xQueue );
C

Code Block 2.5. Prototype của hàm API uxQueueMessagesWaiting()

Tham số và giá trị trả về của uxQueueMessagesWaiting()

  • xQueue
    Handle của queue sẽ được truy vấn. Queue handle sẽ được trả về từ xQueueCreate() hay xQueueCreateStatic() là những hàm dùng để tạo queue.
  • Giá trị trả về
    Số lượng item đang có trong queue được truy vấn. Nếu trả về không, thì queue rỗng (empty).

Ví dụ 2.1 Block khi nhận từ một queue

Ví dụ này minh họa việc tạo một queue, gởi dữ liệu vô queue từ nhiều task, và nhận dữ liệu từ queue. Queue được tạo để chứa các data item có kiểu int32_t. Các task gởi dữ liệu vô queue này thì không chỉ định thời gian block, trong khi task nhận dữ liệu từ queue thì có.

Các task gởi dữ liệu vô queue có độ ưu tiên thấp hơn task nhận dữ liệu từ queue. Điều này có nghĩa là queue sẽ không bao giờ chứa nhiều hơn một item, bởi vì ngay khi dữ liệu được gởi vô queue thì task nhận sẽ được unblocked, chiếm quyền (pre-empt) thực thi của task gởi (do có độ ưu tiên cao hơn), và lấy dữ liệu ra, để queue lại rỗng.

Ví dụ này tạo hai instance của task được mô tả trong Code Block 2.6: một task liên tục ghi giá trị 100 vô queue, và một task khác liên tục ghi giá trị 200 vô cùng queue đó. Tham số của task được xài để truyền các giá trị này vô từng instance của task.

static void vSenderTask( void *pvParameters )
{
    int32_t lValueToSend;
    BaseType_t xStatus;

    /* Two instances of this task are created so the value that is sent to
    the queue is passed in via the task parameter - this way each instance
    can use a different value. The queue was created to hold values of type
    int32_t, so cast the parameter to the required type. */
    lValueToSend = ( int32_t ) pvParameters;

    /* As per most tasks, this task is implemented within an infinite loop. */
    for( ;; )
    {
        /* Send the value to the queue.

        The first parameter is the queue to which data is being sent. The
        queue was created before the scheduler was started, so before this
        task started to execute.The second parameter is the address of the data to be sent, in this
        case the address of lValueToSend.

        The third parameter is the Block time – the time the task should be
        kept in the Blocked state to wait for space to become available on
        the queue should the queue already be full. In this case a block
        time is not specified because the queue should never contain more
        than one item, and therefore never be full. */
        xStatus = xQueueSendToBack( xQueue, &lValueToSend, 0 );

        if( xStatus != pdPASS )
        {
            /* The send operation could not complete because the queue was fullthis must be an error as the queue should never contain more than
            one item! */
            vPrintString( "Could not send to the queue.\r\n" );
        }
    }
}
C

Code Block 2.6. Task gởi trong Ví dụ 2.1

Code Block 2.7 là nội dung cách triển khai task nhận dữ liệu từ queue. Task nhận (receiving task) chỉ định thời gian block là 100 mili giây, sau đó vô trạng thái Blocked để chờ dữ liệu xuất hiện. Task sẽ rời khỏi trạng thái Blocked khi dữ liệu có sẵn trong queue, hoặc là sau khi 100 mili giây trôi qua mà không có dữ liệu. Trong ví dụ này, có hai task liên tục gởi dữ liệu vô queue, nên thời gian chờ 100mili giây sẽ không bao giờ hết hạn.

static void vReceiverTask( void *pvParameters )
{
    /* Declare the variable that will hold the values received from the
    queue. */
    int32_t lReceivedValue;
    BaseType_t xStatus;
    const TickType_t xTicksToWait = pdMS_TO_TICKS( 100 );

    /* This task is also defined within an infinite loop. */
    for( ;; )
    {
        /* This call should always find the queue empty because this task will
        immediately remove any data that is written to the queue. */
        if( uxQueueMessagesWaiting( xQueue ) != 0 )
        {
            vPrintString( "Queue should have been empty!\r\n" );
        }

        /* Receive data from the queue.
        The first parameter is the queue from which data is to be received.
        The queue is created before the scheduler is started, and therefore
        before this task runs for the first time.
        The second parameter is the buffer into which the received data will
        be placed. In this case the buffer is simply the address of a
        variable that has the required size to hold the received data.
        The last parameter is the block time – the maximum amount of time
        that the task will remain in the Blocked state to wait for data to
        be available should the queue already be empty. */
        xStatus = xQueueReceive( xQueue, &lReceivedValue, xTicksToWait );

        if( xStatus == pdPASS )
        {
            /* Data was successfully received from the queue, print out the
            received value. */
            vPrintStringAndNumber( "Received = ", lReceivedValue );
        }
        else
        {
            /* Data was not received from the queue even after waiting for
            100ms. This must be an error as the sending tasks are free
            running and will be continuously writing to the queue. */
            vPrintString( "Could not receive from the queue.\r\n" );
        }
    }
}
C

Code Block 2.7. Task nhận trong Ví dụ 2.1

Code Block 2.8 chứa định nghĩa của hàm main(). Hàm này chỉ tạo queue và 3 task trước khi khởi động scheduler. Queue được tạo để chứa tối đa 5 giá trị kiểu int32_t, mặc dù độ ưu tiên tương đối của các task đảm bảo queue sẽ không bao giờ chứa nhiều hơn một item tại một thời điểm.

/* Declare a variable of type QueueHandle_t. This is used to store the
handle to the queue that is accessed by all three tasks. */
QueueHandle_t xQueue;

int main( void )
{
    /* The queue is created to hold a maximum of 5 values, each of which is
    large enough to hold a variable of type int32_t. */
    xQueue = xQueueCreate( 5, sizeof( int32_t ) );

    if( xQueue != NULL )
    {
        /* Create two instances of the task that will send to the queue. The
        task parameter is used to pass the value that the task will write
        to the queue, so one task will continuously write 100 to the queue
        while the other task will continuously write 200 to the queue. Both
        tasks are created at priority 1. */
        xTaskCreate( vSenderTask, "Sender1", 1000, ( void * ) 100, 1, NULL );
        xTaskCreate( vSenderTask, "Sender2", 1000, ( void * ) 200, 1, NULL );/* Create the task that will read from the queue. The task is created
        with priority 2, so above the priority of the sender tasks. */
        xTaskCreate( vReceiverTask, "Receiver", 1000, NULL, 2, NULL );

        /* Start the scheduler so the created tasks start executing. */
        vTaskStartScheduler();
    }
    else
    {
        /* The queue could not be created. */
    }

    /* If all is well then main() will never reach here as the scheduler will
    now be running the tasks. If main() does reach here then it is likely
    that there was insufficient FreeRTOS heap memory available for the idle
    task to be created. Chapter 3 provides more information on heap memory
    management. */
    for( ;; );
}
C

Code Block 2.8. Hàm main() trong Ví dụ 2.1

Hình 2.2 là kết quả tạo ra từ Ví dụ 2.1Hình 2.3 cho thấy lưu đồ thực thi của ví dụ này.

Hình 2.2. Kết quả tạo ra từ Ví dụ 2.1
Hình 2.3. Lưu đồ thời gian của Ví dụ 2.1