Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 50.532 phút đọc

Mini RTOS

Sau bài này bạn sẽ làm được

  • Mô tả nội dung một khung ngăn xếp khi chuyển ngữ cảnh
  • Cài chuyển ngữ cảnh bằng PendSV và hiểu giá trị EXC_RETURN
  • Viết bộ lập lịch quay vòng và delay không chặn
  • Biết khi nào nên dùng RTOS có sẵn thay vì tự viết

Bốn bài trước xây một hệ thống chạy trên một vòng lặp duy nhất. Bài này chỉ ra vòng lặp đó gãy ở đâu, rồi dựng một bộ lập lịch chuyển ngữ cảnh trong khoảng hai trăm dòng, để bạn hiểu RTOS làm gì bên trong trước khi quyết định có dùng một cái có sẵn hay không.

#Khi vòng lặp chính không đủ nữa

/* Vong lap chinh o Bai 50.1: moi viec phai NGAN */
for (;;) {
    cap_nhat_den(tick_ms());        /* 3 us  */
    cap_nhat_cam_bien(tick_ms());   /* 12 us */
    xu_ly_vo_lenh();                /* 40 us */
}

/* Bay gio them mot viec dai: */
for (;;) {
    cap_nhat_den(tick_ms());
    cap_nhat_cam_bien(tick_ms());
    xu_ly_vo_lenh();
    ghi_the_nho();                  /* 250 ms, cho phan cung tra loi */
}

/* Trong 250 ms do, den khong nhay, cam bien khong duoc doc,
   va vo lenh khong phan hoi. He thong nhu treo. */
Cách xử lýĐượcMất
Chẻ việc dài thành máy trạng tháiKhông cần lập lịch, RAM tối thiểuMã khó đọc, mỗi lệnh chờ thành một trạng thái
Đưa việc dài vào ISR ưu tiên thấpKhông cần lập lịchChỉ được một việc dài, và vẫn chặn ISR thấp hơn
Bộ lập lịch chuyển ngữ cảnhViết việc dài theo lối tuần tự tự nhiênMỗi nhiệm vụ một ngăn xếp riêng, khoảng 2 KB RAM
Chuyển ngữ cảnh
Cất toàn bộ trạng thái CPU của nhiệm vụ đang chạy vào ngăn xếp riêng của nó, rồi nạp trạng thái đã cất của nhiệm vụ khác vào CPU. Sau đó CPU tiếp tục đúng chỗ nhiệm vụ kia dừng lại lần trước.

#Khung ngăn xếp khi chuyển ngữ cảnh

Bài 49.2 đã nói khi vào ngoại lệ, phần cứng tự đẩy tám từ vào ngăn xếp. Tám từ đó là các thanh ghi mà quy ước gọi hàm của ARM cho phép hàm được gọi phá hỏng. Còn tám thanh ghi R4 tới R11 thì quy ước bắt hàm được gọi phải giữ nguyên, nên phần cứng không đụng tới, và ta phải tự cất.

Mười sáu từ trên ngăn xếp của mỗi nhiệm vụ đang ngủ. Tám từ trên do máy tự đẩy, tám từ dưới do PendSV tự đẩy.
NhómSố từAi cấtGồm những gì
Khung phần cứng8CPU tự làm khi vào ngoại lệR0, R1, R2, R3, R12, LR, PC, xPSR
Khung phần mềm8Lệnh STMDB trong PendSVR4 tới R11
Khung FPU phần cứng18CPU, chỉ khi nhiệm vụ đã dùng FPUS0 tới S15, FPSCR, một từ dự trữ
Khung FPU phần mềm16Lệnh VSTMDB trong PendSVS16 tới S31
Không dùng FPU thì mỗi nhiệm vụ ngủ tốn 64 byte ngăn xếp. Có dùng thì tốn 200 byte.

Dựng khung giả cho nhiệm vụ mới

Một nhiệm vụ chưa từng chạy thì chưa có gì trên ngăn xếp để khôi phục. Mẹo là dựng sẵn một khung giống hệt khung mà PendSV sẽ thấy, rồi để bộ lập lịch khôi phục nó như thường. Nhiệm vụ khởi động mà không cần đường mã đặc biệt nào.

nv.c
#include <stdint.h>

#define NV_TOI_DA   4u
#define NGAN_XEP_TU 256u          /* 1 KB mỗi nhiệm vụ */

typedef enum { NV_TRONG, NV_SAN_SANG, NV_NGU } TrangThaiNv;

typedef struct {
    uint32_t    *sp;              /* PHẢI là trường đầu tiên, xem PendSV */
    uint32_t     thuc_day_luc;
    TrangThaiNv  trang_thai;
    void       (*ham)(void);
    uint32_t     ngan_xep[NGAN_XEP_TU];
} Nv;

static Nv       s_nv[NV_TOI_DA];
static uint8_t  s_so_nv    = 0u;
Nv             *g_nv_nay   = NULL;    /* PendSV đọc biến này */

static void nv_thoat(void)
{
    /* Nhiệm vụ không được phép trả về. Nếu tới đây là có lỗi. */
    for (;;) { }
}

int nv_tao(void (*ham)(void))
{
    if (s_so_nv >= NV_TOI_DA) return -1;

    Nv       *n    = &s_nv[s_so_nv];
    uint32_t *dinh = &n->ngan_xep[NGAN_XEP_TU];

    /* Ngăn xếp phải thẳng hàng 8 byte theo quy ước AAPCS */
    dinh = (uint32_t *)((uintptr_t)dinh & ~(uintptr_t)7);

    /* Khung phần cứng, đẩy vào theo thứ tự ngược */
    *(--dinh) = 0x01000000u;               /* xPSR, bit T bắt buộc bằng 1 */
    *(--dinh) = (uint32_t)ham;             /* PC  */
    *(--dinh) = (uint32_t)nv_thoat;        /* LR  */
    *(--dinh) = 0x12121212u;               /* R12, giá trị nhận dạng */
    *(--dinh) = 0x03030303u;               /* R3  */
    *(--dinh) = 0x02020202u;               /* R2  */
    *(--dinh) = 0x01010101u;               /* R1  */
    *(--dinh) = 0x00000000u;               /* R0, tham số của nhiệm vụ */

    /* Khung phần mềm */
    for (int i = 11; i >= 4; --i) *(--dinh) = (uint32_t)(0x11111111u * (uint32_t)i);

    n->sp           = dinh;
    n->ham          = ham;
    n->trang_thai   = NV_SAN_SANG;
    n->thuc_day_luc = 0u;

    ++s_so_nv;

    return 0;
}

#Chuyển ngữ cảnh bằng PendSV

Đặt PendSV xuống ưu tiên thấp nhất
#include "stm32f4xx.h"

void lich_khoi_tao(uint32_t clock_hz)
{
    /* 0xFF là mức thấp nhất trên mọi Cortex-M, dù chip cài mấy bit */
    NVIC_SetPriority(PendSV_IRQn,  0xFFu);
    NVIC_SetPriority(SysTick_IRQn, 0xFEu);   /* thấp, nhưng cao hơn PendSV */

    SysTick_Config(clock_hz / 1000u);        /* tick 1 ms */
}

/* Đặt cờ chờ cho PendSV. Ghi bit 28 của ICSR. */
static inline void yeu_cau_chuyen(void)
{
    SCB->ICSR = SCB_ICSR_PENDSVSET_Msk;
}
PendSV_Handler, phần duy nhất phải viết bằng hợp ngữ
__attribute__((naked)) void PendSV_Handler(void)
{
    __asm volatile (
        "  mrs   r0, psp              \n"   /* r0 = ngăn xếp nhiệm vụ cũ  */
        "  isb                        \n"

        "  ldr   r3, =g_nv_nay        \n"
        "  ldr   r2, [r3]             \n"   /* r2 = con trỏ tới Nv cũ     */

        "  stmdb r0!, {r4-r11}        \n"   /* cất R4-R11, r0 lùi 32 byte */
        "  str   r0, [r2]             \n"   /* Nv.sp = r0, sp là trường đầu */

        "  stmdb sp!, {r3, lr}        \n"   /* giữ r3 và lr qua lời gọi C */
        "  bl    lap_lich             \n"   /* đổi g_nv_nay sang nhiệm vụ khác */
        "  ldmia sp!, {r3, lr}        \n"

        "  ldr   r2, [r3]             \n"   /* r2 = con trỏ tới Nv mới    */
        "  ldr   r0, [r2]             \n"   /* r0 = sp đã cất của nó      */
        "  ldmia r0!, {r4-r11}        \n"   /* khôi phục R4-R11           */

        "  msr   psp, r0              \n"   /* PSP trỏ vào khung phần cứng */
        "  isb                        \n"
        "  bx    lr                   \n"   /* lr đang giữ EXC_RETURN     */
        "  .align 4                   \n"   /* chỗ cho hằng g_nv_nay      */
    );
}
  1. Đọc PSP

    Phần cứng đã đẩy tám từ vào ngăn xếp nhiệm vụ và trừ PSP đi 32. Lệnh mrs r0, psp lấy giá trị đó ra.

  2. Cất tám thanh ghi còn lại

    stmdb r0!, {r4-r11} giảm r0 đi 32 rồi ghi tám thanh ghi vào. Dấu chấm than nghĩa là ghi lại giá trị mới vào r0.

  3. Lưu con trỏ ngăn xếp vào cấu trúc nhiệm vụ

    str r0, [r2] ghi thẳng vào r2 mà không cộng độ lệch, vì sp là trường đầu tiên của Nv. Đổi thứ tự các trường trong struct là hỏng dòng này.

  4. Gọi bộ lập lịch viết bằng C

    lap_lich chỉ đổi con trỏ g_nv_nay. Nó có thể phá r0 tới r3 và r12 theo quy ước, nên ta phải giữ r3 và lr qua lời gọi.

  5. Khôi phục và trả về

    Nạp lại R4 tới R11 từ ngăn xếp mới, đặt lại PSP, rồi bx lr. Lúc này lr đang giữ EXC_RETURN, và phần cứng thấy giá trị đó thì nó tự pop tám từ còn lại.

Giá trị EXC_RETURN

Khi ISR trả về bằng bx lr, phần cứng đọc EXC_RETURN trong LR để biết pop khung từ ngăn xếp nào và pop bao nhiêu từ.
EXC_RETURNChế độNgăn xếpKhung FPUGặp khi nào
0xFFFFFFF1HandlerMSPKhôngNgắt lồng, đang trả về một ISR khác
0xFFFFFFF9ThreadMSPKhôngTrả về vòng lặp chính khi chưa dùng PSP
0xFFFFFFFDThreadPSPKhôngTrả về một nhiệm vụ, trường hợp thường gặp nhất
0xFFFFFFEDThreadPSPCóTrả về nhiệm vụ đã dùng FPU

#Bộ lập lịch quay vòng và delay

nv.c, phần tiếp theo
#include "stm32f4xx.h"

static volatile uint32_t s_tick = 0u;

/* Đổi g_nv_nay sang nhiệm vụ sẵn sàng kế tiếp.
   Hàm này do PendSV gọi, và nó KHÔNG được chặn hay chờ. */
void lap_lich(void)
{
    uint8_t bat_dau = (uint8_t)(g_nv_nay - s_nv);

    for (uint8_t i = 1u; i <= s_so_nv; ++i) {
        uint8_t k = (uint8_t)((bat_dau + i) % s_so_nv);

        if (s_nv[k].trang_thai == NV_SAN_SANG) {
            g_nv_nay = &s_nv[k];

            return;
        }
    }

    /* Không nhiệm vụ nào sẵn sàng: quay lại nhiệm vụ nhàn rỗi ở chỉ số 0 */
    g_nv_nay = &s_nv[0];
}

void SysTick_Handler(void)
{
    ++s_tick;

    /* Đánh thức nhiệm vụ nào đã hết giờ ngủ */
    for (uint8_t i = 0u; i < s_so_nv; ++i)
        if (s_nv[i].trang_thai == NV_NGU &&
            (int32_t)(s_tick - s_nv[i].thuc_day_luc) >= 0)
            s_nv[i].trang_thai = NV_SAN_SANG;

    SCB->ICSR = SCB_ICSR_PENDSVSET_Msk;      /* nhờ PendSV chuyển */
}

uint32_t tick_ms(void) { return s_tick; }

/* Nhường CPU cho tới khi đủ ms mili giây. */
void nv_ngu(uint32_t ms)
{
    if (ms == 0u) { SCB->ICSR = SCB_ICSR_PENDSVSET_Msk; return; }

    uint32_t truoc = __get_PRIMASK();
    __disable_irq();

    g_nv_nay->thuc_day_luc = s_tick + ms;
    g_nv_nay->trang_thai   = NV_NGU;

    __set_PRIMASK(truoc);

    SCB->ICSR = SCB_ICSR_PENDSVSET_Msk;      /* chuyển ngay, đừng đợi tick */

    /* Khi hàm này trở về, đã qua ít nhất ms mili giây */
}
Khởi động nhiệm vụ đầu tiên
void lich_chay(void)
{
    g_nv_nay = &s_nv[0];

    /* PSP trỏ vào đỉnh ngăn xếp nhiệm vụ 0 */
    __set_PSP((uint32_t)&s_nv[0].ngan_xep[NGAN_XEP_TU]);

    /* Bit 1 của CONTROL: chế độ Thread dùng PSP thay vì MSP */
    __set_CONTROL(0x02u);
    __ISB();                        /* bắt buộc sau khi đổi CONTROL */

    s_nv[0].ham();                  /* nhảy thẳng vào, không qua PendSV */

    for (;;) { }                    /* không bao giờ tới đây */
}
main.c hoàn chỉnh
#include "nv.h"

static void nv_nhan_roi(void)
{
    for (;;) __WFI();               /* ngủ tới khi có ngắt, tiết kiệm điện */
}

static void nv_den(void)
{
    for (;;) {
        gpio_dao(GPIOA, 5u);
        nv_ngu(500u);
    }
}

static void nv_cam_bien(void)
{
    for (;;) {
        uint16_t raw = adc_doc(0u);

        g_nhiet_centi = nhiet_centi(raw);
        nv_ngu(100u);
    }
}

static void nv_vo_lenh(void)
{
    BoDocDong doc;

    doc_dong_khoi_tao(&doc);

    for (;;) {
        char *dong = doc_dong_cap_nhat(&doc);

        if (dong != NULL) lenh_chay(dong);
        else              nv_ngu(2u);
    }
}

int main(void)
{
    clock_khoi_tao();
    gpio_khoi_tao();
    adc_khoi_tao();
    uart_khoi_tao(84000000u, 115200u);

    lich_khoi_tao(84000000u);

    nv_tao(nv_nhan_roi);            /* PHẢI là nhiệm vụ số 0 */
    nv_tao(nv_den);
    nv_tao(nv_cam_bien);
    nv_tao(nv_vo_lenh);

    lich_chay();                    /* không bao giờ trở về */
}
terminal
arm-none-eabi-size mini-rtos.elf
   text    data     bss     dec     hex filename
   6284      28    4936   11248    2bf0 mini-rtos.elf
# Bộ lập lịch chiếm bao nhiêu trong đó
arm-none-eabi-size -A nv.o
lap-lich.o  .text   248
nv.o        .text   180

428 byte ma lenh cho ca bo lap lich.
# RAM thì hầu hết là bốn ngăn xếp
arm-none-eabi-nm --size-sort -S mini-rtos.elf | grep ' b s_nv'
20000140 00001030 b s_nv

4 nhiem vu * (1024 byte ngan xep + 12 byte quan ly) = 4144 byte

#Khi nào nên dùng RTOS có sẵn

Bộ lập lịch ở trên chạy được, nhưng nó thiếu gần như mọi thứ mà một hệ thống thật cần. Bảng dưới liệt kê đúng những gì còn thiếu.

Thứ còn thiếuCó trong mini RTOSVì sao cần
Ưu tiên nhiệm vụKhông, quay vòng đều nhauNhiệm vụ điều khiển động cơ phải chạy trước nhiệm vụ ghi nhật ký
Mutex và semaphoreKhôngHai nhiệm vụ dùng chung một SPI sẽ ghi đè lệnh của nhau
Hàng đợi giữa các nhiệm vụKhôngTruyền dữ liệu bằng biến toàn cục là mời gọi tranh chấp, xem Bài 49.4
Kế thừa ưu tiênKhôngKhông có thì gặp đảo ngược ưu tiên, đúng lỗi làm hỏng Mars Pathfinder năm 1997
Bảo vệ tràn ngăn xếpKhôngTràn một byte là đè lên nhiệm vụ bên cạnh, không ai báo
Chế độ không tickKhôngTick 1 ms đánh thức CPU 1000 lần mỗi giây, tốn pin
Công cụ xem trạng tháiKhôngKhông nhìn được nhiệm vụ nào chiếm CPU là gỡ lỗi rất khó
Lựa chọnFlashRAM tối thiểuHợp khi nào
Vòng lặp chính, không lập lịch00Mọi việc dưới vài trăm micro giây, dưới 5 việc
Vòng lặp cộng máy trạng thái0vài chục byteCó vài việc dài nhưng chẻ được thành bước
Mini RTOS như bài nàykhoảng 500 byte1 KB mỗi nhiệm vụHọc, hoặc dự án rất nhỏ đã kiểm soát hết
FreeRTOS6 tới 12 KBkhoảng 300 byte cộng ngăn xếpMặc định hợp lý cho hầu hết dự án nghiêm túc
Zephyr20 KB trở lênvài KBCần mạng, Bluetooth, hệ thống tệp, cập nhật từ xa

Tự làm thử

  1. Cài nv_tao dựng khung giả, rồi dừng ở dòng đầu của nhiệm vụ và kiểm tra r1, r2, r3 đúng bằng các giá trị nhận dạng.
  2. Cố ý bỏ bit T trong xPSR đi và xác nhận chip vào HardFault ngay lần chuyển đầu tiên.
  3. Viết PendSV_Handler bản không FPU, chạy hai nhiệm vụ nháy hai đèn với chu kỳ khác nhau và xác nhận cả hai chạy song song.
  4. Đo chi phí chuyển ngữ cảnh bằng DWT->CYCCNT và so với bảng ở trên.
  5. Cho một nhiệm vụ dùng float, chạy bản PendSV không cất S16 tới S31, và tìm cách làm cho lỗi lộ ra.
  6. Tô kín ngăn xếp mỗi nhiệm vụ bằng một mẫu nhận dạng lúc tạo, rồi thêm lệnh vỏ lệnh in ra chỗ trống còn lại của từng nhiệm vụ.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Mỗi nhiệm vụ ngủ giữ mười sáu từ trên ngăn xếp riêng: tám từ phần cứng tự đẩy và tám từ R4 tới R11 do PendSV cất.
  • Nhiệm vụ mới được khởi động bằng một khung giả dựng sẵn, trong đó PC là hàm nhiệm vụ và xPSR phải có bit T bằng 1.
  • PendSV đặt ưu tiên thấp nhất để chỉ chuyển ngữ cảnh khi không còn ISR nào đang dở. SysTick chỉ đặt cờ chờ.
  • EXC_RETURN trong LR cho phần cứng biết trả về chế độ nào, dùng ngăn xếp nào và khung có phần FPU hay không. Trên chip có FPU nó phải được cất riêng cho từng nhiệm vụ.
  • Chi phí chuyển ngữ cảnh khoảng 66 chu kỳ, tức dưới 0,1 phần trăm CPU với tick 1 ms. Chi phí thật của một bộ lập lịch là RAM cho các ngăn xếp.