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ý | Được | Mất |
|---|---|---|
| Chẻ việc dài thành máy trạng thái | Không cần lập lịch, RAM tối thiểu | Mã 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ấp | Không cần lập lịch | Chỉ đượ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ảnh | Viết việc dài theo lối tuần tự tự nhiên | Mỗi nhiệm vụ một ngăn xếp riêng, khoảng 2 KB RAM |
#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.
| Nhóm | Số từ | Ai cất | Gồm những gì |
|---|---|---|---|
| Khung phần cứng | 8 | CPU tự làm khi vào ngoại lệ | R0, R1, R2, R3, R12, LR, PC, xPSR |
| Khung phần mềm | 8 | Lệnh STMDB trong PendSV | R4 tới R11 |
| Khung FPU phần cứng | 18 | CPU, chỉ khi nhiệm vụ đã dùng FPU | S0 tới S15, FPSCR, một từ dự trữ |
| Khung FPU phần mềm | 16 | Lệnh VSTMDB trong PendSV | S16 tới S31 |
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.
#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
#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;
}__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 */
);
}Đọ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ệnhmrs r0, psplấy giá trị đó ra.Cất tám thanh ghi còn lại
stmdb r0!, {r4-r11}giảmr0đ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àor0.Lưu con trỏ ngăn xếp vào cấu trúc nhiệm vụ
str r0, [r2]ghi thẳng vàor2mà không cộng độ lệch, vìsplà trường đầu tiên củaNv. Đổi thứ tự các trường trongstructlà hỏng dòng này.Gọi bộ lập lịch viết bằng C
lap_lichchỉ đổi con trỏg_nv_nay. Nó có thể phár0tớir3vàr12theo quy ước, nên ta phải giữr3vàlrqua lời gọi.Khôi phục và trả về
Nạp lại
R4tớiR11từ ngăn xếp mới, đặt lạiPSP, rồibx lr. Lúc nàylrđ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
| EXC_RETURN | Chế độ | Ngăn xếp | Khung FPU | Gặp khi nào |
|---|---|---|---|---|
| 0xFFFFFFF1 | Handler | MSP | Không | Ngắt lồng, đang trả về một ISR khác |
| 0xFFFFFFF9 | Thread | MSP | Không | Trả về vòng lặp chính khi chưa dùng PSP |
| 0xFFFFFFFD | Thread | PSP | Không | Trả về một nhiệm vụ, trường hợp thường gặp nhất |
| 0xFFFFFFED | Thread | PSP | Có | Trả về nhiệm vụ đã dùng FPU |
#Bộ lập lịch quay vòng và delay
#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 */
}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 */
}#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ề */
}text data bss dec hex filename 6284 28 4936 11248 2bf0 mini-rtos.elf
lap-lich.o .text 248 nv.o .text 180 428 byte ma lenh cho ca bo lap lich.
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ếu | Có trong mini RTOS | Vì sao cần |
|---|---|---|
| Ưu tiên nhiệm vụ | Không, quay vòng đều nhau | Nhiệm vụ điều khiển động cơ phải chạy trước nhiệm vụ ghi nhật ký |
| Mutex và semaphore | Không | Hai 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ông | Truyề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ên | Không | Khô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ếp | Không | Tràn một byte là đè lên nhiệm vụ bên cạnh, không ai báo |
| Chế độ không tick | Không | Tick 1 ms đánh thức CPU 1000 lần mỗi giây, tốn pin |
| Công cụ xem trạng thái | Không | Không nhìn được nhiệm vụ nào chiếm CPU là gỡ lỗi rất khó |
| Lựa chọn | Flash | RAM tối thiểu | Hợp khi nào |
|---|---|---|---|
| Vòng lặp chính, không lập lịch | 0 | 0 | Mọ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ái | 0 | vài chục byte | Có vài việc dài nhưng chẻ được thành bước |
| Mini RTOS như bài này | khoảng 500 byte | 1 KB mỗi nhiệm vụ | Học, hoặc dự án rất nhỏ đã kiểm soát hết |
| FreeRTOS | 6 tới 12 KB | khoảng 300 byte cộng ngăn xếp | Mặc định hợp lý cho hầu hết dự án nghiêm túc |
| Zephyr | 20 KB trở lên | vài KB | Cần mạng, Bluetooth, hệ thống tệp, cập nhật từ xa |
Tự làm thử
- Cài
nv_taodựng khung giả, rồi dừng ở dòng đầu của nhiệm vụ và kiểm trar1,r2,r3đúng bằng các giá trị nhận dạng. - Cố ý bỏ bit T trong
xPSRđi và xác nhận chip vào HardFault ngay lần chuyển đầu tiên. - Viết
PendSV_Handlerbả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. - Đo chi phí chuyển ngữ cảnh bằng
DWT->CYCCNTvà so với bảng ở trên. - Cho một nhiệm vụ dùng
float, chạy bản PendSV không cấtS16tớiS31, và tìm cách làm cho lỗi lộ ra. - 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ừ
R4tớiR11do PendSV cất. - Nhiệm vụ mới được khởi động bằng một khung giả dựng sẵn, trong đó
PClà hàm nhiệm vụ vàxPSRphả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_RETURNtrongLRcho 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.