BÀI 37 – TIMER NÂNG CAO TRÊN STM32F103: NHIỀU TASK CHẠY ĐỒNG THỜI KHÔNG DÙNG delay()
Ở Bài 36, chúng ta đã tự tạo được:
volatile uint32_t millis = 0;
với:
TIM2
↓
Interrupt mỗi 1 ms
↓
millis++
Bây giờ chúng ta sẽ dùng millis() để xây dựng một hệ thống nhiều tác vụ chạy đồng thời.
Đây là bước cực kỳ quan trọng trước khi học:
- Timer nâng cao
- PWM
- Input Capture
- Output Compare
- UART không blocking
- State Machine
- RTOS
1. Vấn đề của chương trình kiểu Arduino đơn giản
Giả sử bạn muốn Blue Pill làm 4 việc:
LED → mỗi 500 ms
Button → kiểm tra mỗi 10 ms
ADC → đọc mỗi 100 ms
LCD → cập nhật mỗi 1000 ms
Người mới thường viết:
while (1)
{
LED();
delay_ms(500);
Button();
delay_ms(10);
ADC();
delay_ms(100);
LCD();
delay_ms(1000);
}
Đây là sai về mặt kiến trúc.
Các tác vụ sẽ phải chờ nhau.
2. Cách chúng ta muốn
Ta muốn:
millis()
│
┌───────────┼───────────┐
↓ ↓ ↓
LED ADC LCD
500 ms 100 ms 1000 ms
│ │ │
└───────────┴───────────┘
↓
main
Mỗi task tự kiểm tra:
“Đã đến lúc tôi phải chạy chưa?”
3. Đây gọi là Software Timer
Phần cứng chỉ có:
TIM2
nhưng chúng ta tạo ra nhiều timer bằng phần mềm:
Software Timer 1 → LED
Software Timer 2 → ADC
Software Timer 3 → LCD
Software Timer 4 → Button
Software Timer 5 → Motor
Tất cả dựa trên:
millis
4. Một ví dụ cực kỳ quan trọng
uint32_t last_led = 0;
if ((uint32_t)(millis - last_led) >= 500)
{
last_led = millis;
LED_Toggle();
}
Trong khi đó:
uint32_t last_adc = 0;
if ((uint32_t)(millis - last_adc) >= 100)
{
last_adc = millis;
ADC_Read();
}
Và:
uint32_t last_lcd = 0;
if ((uint32_t)(millis - last_lcd) >= 1000)
{
last_lcd = millis;
LCD_Update();
}
Ba task hoạt động độc lập.
5. Hãy hình dung dòng thời gian
Giả sử:
millis = 0
Các task bắt đầu đếm.
100 ms
ADC chạy
200 ms
ADC chạy
300 ms
ADC chạy
400 ms
ADC chạy
500 ms
LED chạy
ADC chạy
600 ms
ADC chạy
1000 ms
LED chạy
ADC chạy
LCD chạy
Không task nào cần delay().
6. Tạo một System Tick
Chúng ta giữ nguyên phần Bài 36:
volatile uint32_t millis = 0;
Timer:
TIM2->PSC = 7199;
TIM2->ARR = 9;
ISR:
void TIM2_IRQHandler(void)
{
if (TIM2->SR & TIM_SR_UIF)
{
TIM2->SR &= ~TIM_SR_UIF;
millis++;
}
}
Đây là:
System Tick 1 ms
7. Tại sao không xử lý task trong ISR?
Ví dụ không nên làm:
void TIM2_IRQHandler(void)
{
millis++;
LED_Toggle();
ADC_Read();
LCD_Update();
Motor_Control();
}
Tại sao?
Vì ISR chạy mỗi:
1 ms
Nếu trong ISR làm quá nhiều việc:
TIM2 interrupt
↓
LED
↓
ADC
↓
LCD
↓
Motor
↓
UART
↓
...
CPU có thể mất quá nhiều thời gian trong interrupt.
8. Kiến trúc đúng
TIM2_IRQHandler()
│
└── millis++
main()
│
├── Task_LED()
├── Task_Button()
├── Task_ADC()
├── Task_LCD()
└── Task_Motor()
Đây là kiến trúc chúng ta sẽ sử dụng.
9. Tách từng task thành function
Ví dụ:
void Task_LED(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 500)
{
last = millis;
GPIOC->ODR ^= (1 << 13);
}
}
Button:
void Task_Button(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 10)
{
last = millis;
// Đọc button
}
}
ADC:
void Task_ADC(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 100)
{
last = millis;
// Đọc ADC
}
}
LCD:
void Task_LCD(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 1000)
{
last = millis;
// Update LCD
}
}
10. static ở đây rất quan trọng
Ví dụ:
void Task_LED(void)
{
static uint32_t last = 0;
}
Tại sao dùng:
static
Nếu viết:
void Task_LED(void)
{
uint32_t last = 0;
}
thì mỗi lần gọi function:
last = 0
lại từ đầu.
Như vậy Timer sẽ không hoạt động đúng.
Còn:
static uint32_t last = 0;
thì biến vẫn giữ giá trị sau khi function kết thúc.
11. Ví dụ
Lần đầu:
Task_LED()
last = 0
Sau 500 ms:
last = 500
Function kết thúc.
Lần sau gọi:
last vẫn = 500
Sau đó:
millis - last
tiếp tục được tính.
12. Đây là một kỹ thuật C rất quan trọng
Bạn đã học:
Bài 7–13
Function
Struct
Typedef
Enum
Bây giờ những kiến thức đó bắt đầu được sử dụng trong firmware thực tế.
13. Tạo task scheduler đơn giản
Ta có thể viết:
void Scheduler_Run(void)
{
Task_LED();
Task_Button();
Task_ADC();
Task_LCD();
}
Trong main:
while (1)
{
Scheduler_Run();
}
Đây chính là một cooperative scheduler rất đơn giản.
14. Vì sao gọi là cooperative?
Bởi vì các task tự nguyện chạy xong rồi trả quyền điều khiển về main.
Ví dụ:
main
↓
LED task
↓
return
↓
Button task
↓
return
↓
ADC task
↓
return
↓
LCD task
↓
return
↓
lặp lại
Không có RTOS.
Không có thread.
Không có context switching.
Nhưng vẫn có thể chạy nhiều tác vụ.
15. Điều kiện cực kỳ quan trọng
Mỗi task phải:
Chạy nhanh và không blocking.
Ví dụ tốt:
void Task_LED(void)
{
if (...)
{
LED_Toggle();
}
}
Ví dụ xấu:
void Task_LED(void)
{
LED_ON();
delay_ms(500);
LED_OFF();
delay_ms(500);
}
Task xấu sẽ khóa toàn bộ scheduler.
16. Ví dụ thực tế
Giả sử:
Task LED → 500 ms
Task Button → 10 ms
Task ADC → 20 ms
Task UART → liên tục
Task LCD → 1000 ms
Main:
while (1)
{
Task_LED();
Task_Button();
Task_ADC();
Task_UART();
Task_LCD();
}
Nếu mỗi task mất:
< 100 µs
thì vòng while có thể chạy rất nhanh.
17. Code hoàn chỉnh
Đây là phiên bản bạn nên chạy thử trên Blue Pill.
#include "stm32f10x.h"
volatile uint32_t millis = 0;
/* =====================================================
GPIO
===================================================== */
void GPIO_Init(void)
{
RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;
/* PC13 = Output Push-Pull 2 MHz */
GPIOC->CRH &= ~(0xF << 20);
GPIOC->CRH |= (0x2 << 20);
}
/* =====================================================
TIM2 - 1 ms System Tick
===================================================== */
void TIM2_Init(void)
{
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
/*
TIM2 clock = 72 MHz
72 MHz / 7200 = 10 kHz
10 kHz / 10 = 1 kHz
1 kHz = 1 ms
*/
TIM2->PSC = 7199;
TIM2->ARR = 9;
TIM2->CNT = 0;
/* Enable Update Interrupt */
TIM2->DIER |= TIM_DIER_UIE;
/* NVIC */
NVIC_SetPriority(TIM2_IRQn, 2);
NVIC_EnableIRQ(TIM2_IRQn);
/* Start Timer */
TIM2->CR1 |= TIM_CR1_CEN;
}
/* =====================================================
TIM2 ISR
===================================================== */
void TIM2_IRQHandler(void)
{
if (TIM2->SR & TIM_SR_UIF)
{
TIM2->SR &= ~TIM_SR_UIF;
millis++;
}
}
/* =====================================================
TASK LED
===================================================== */
void Task_LED(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 500)
{
last = millis;
GPIOC->ODR ^= (1 << 13);
}
}
/* =====================================================
TASK 2
===================================================== */
void Task_2(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 100)
{
last = millis;
/*
Công việc Task 2
*/
}
}
/* =====================================================
TASK 3
===================================================== */
void Task_3(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 1000)
{
last = millis;
/*
Công việc Task 3
*/
}
}
/* =====================================================
SCHEDULER
===================================================== */
void Scheduler_Run(void)
{
Task_LED();
Task_2();
Task_3();
}
/* =====================================================
MAIN
===================================================== */
int main(void)
{
GPIO_Init();
TIM2_Init();
while (1)
{
Scheduler_Run();
}
}
18. Điều gì đang thực sự xảy ra?
CPU liên tục chạy:
Scheduler
↓
Task_LED
↓
Task_2
↓
Task_3
↓
Scheduler
↓
Task_LED
↓
...
Trong khi đó cứ mỗi 1 ms:
TIM2
↓
Interrupt
↓
millis++
↓
return
19. Hãy nhìn vào timeline
Giả sử:
millis = 0
millis = 100
Task_2()
millis = 200
Task_2()
millis = 500
Task_LED()
Task_2()
millis = 1000
Task_LED()
Task_2()
Task_3()
millis = 1500
Task_LED()
Task_2()
millis = 2000
Task_LED()
Task_2()
Task_3()
Đây chính là nhiều task chạy theo chu kỳ riêng.
20. Một vấn đề tinh tế: last = millis hay last += interval?
Chúng ta đang viết:
last = millis;
Ví dụ task:
interval = 100 ms
Nếu task chạy đúng lúc:
100
200
300
400
thì không vấn đề.
Nhưng nếu scheduler bị bận và task chạy muộn:
100
205
310
415
thì chu kỳ thực tế bị trôi.
21. Cách thứ hai
Ta có thể viết:
last += interval;
Ví dụ:
if ((uint32_t)(millis - last) >= 100)
{
last += 100;
Task();
}
Nếu:
last = 100
millis = 205
thì:
last = 200
Thời gian mục tiêu được giữ ổn định hơn.
22. Hai kiểu có ý nghĩa khác nhau
Kiểu 1
last = millis;
Có nghĩa:
“Tính khoảng thời gian tiếp theo kể từ lúc task thực sự chạy.”
Phù hợp cho:
- button polling;
- timeout;
- các tác vụ không cần chu kỳ chính xác.
Kiểu 2
last += interval;
Có nghĩa:
“Giữ nhịp theo lịch định trước.”
Phù hợp cho:
- sampling;
- control loop;
- periodic task.
23. Ví dụ
void Task_ADC(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 10)
{
last += 10;
ADC_Read();
}
}
Nếu ADC cần:
10 ms
thì cách này giữ nhịp tốt hơn.
24. Nhưng có một vấn đề nữa
Nếu CPU bị bận quá lâu:
last = 100
millis = 1000
và:
last += 10;
chỉ xử lý một lần.
Bạn có thể cần:
while ((uint32_t)(millis - last) >= 10)
{
last += 10;
ADC_Read();
}
Nhưng không nên lạm dụng vì có thể làm task chạy nhiều lần liên tiếp và làm scheduler càng bận hơn.
25. Scheduler của chúng ta chưa phải RTOS
Điều này rất quan trọng.
Chúng ta đang có:
Timer Interrupt
+
Software Timer
+
Cooperative Scheduler
Nhưng chưa có:
Thread
Task Priority
Context Switching
Stack riêng
Mutex
Semaphore
Đó là những thứ chúng ta sẽ gặp khi học RTOS.
26. Vậy tại sao phải học cách này trước RTOS?
Bởi vì khi bạn hiểu:
Timer
Interrupt
millis
Task
Scheduler
thì khi chuyển sang FreeRTOS bạn sẽ hiểu:
RTOS đang giải quyết những vấn đề gì.
Nếu chưa hiểu những thứ này mà nhảy ngay vào RTOS, rất dễ trở thành:
copy code
↓
chạy được
↓
không hiểu tại sao
27. Đây cũng là nền tảng cho robot của bạn
Ví dụ sau này Blue Pill điều khiển:
HC-SR04
28BYJ-48
Servo
LCD
Button
UART
Bạn có thể thiết kế:
Task_Ultrasonic() → 50 ms
Task_Motor() → 10 ms
Task_Servo() → 20 ms
Task_LCD() → 200 ms
Task_Button() → 10 ms
Task_UART() → liên tục
Tất cả dựa trên:
TIM2
↓
millis
28. Một ví dụ gần với dự án thực tế của bạn
Giả sử:
28BYJ-48
cần điều khiển theo từng bước.
Không nên:
StepMotor();
delay_ms(5);
StepMotor();
delay_ms(5);
Thay vào đó:
void Task_Stepper(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 5)
{
last += 5;
StepMotor_NextStep();
}
}
Khi đó CPU có thể làm việc khác trong thời gian giữa hai bước.
29. Một ví dụ với cảm biến khoảng cách
Sau này:
void Task_Ultrasonic(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 50)
{
last += 50;
Ultrasonic_StartMeasurement();
}
}
Motor:
void Task_Motor(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 10)
{
last += 10;
Motor_Control();
}
}
LCD:
void Task_LCD(void)
{
static uint32_t last = 0;
if ((uint32_t)(millis - last) >= 200)
{
last += 200;
LCD_Update();
}
}
Đây chính là kiểu kiến trúc bạn sẽ gặp rất nhiều trong embedded.
30. Một khái niệm mới: Task Period
Mỗi task có:
Period
Ví dụ:
Task Motor Period = 10 ms
Task Button Period = 10 ms
Task Sensor Period = 50 ms
Task LCD Period = 200 ms
Task LED Period = 500 ms
Ta có thể lập bảng:
| Task | Period |
|---|---|
| Motor | 10 ms |
| Button | 10 ms |
| Sensor | 50 ms |
| LCD | 200 ms |
| LED | 500 ms |
31. Một nguyên tắc rất quan trọng
Period của task không được nhỏ hơn khả năng xử lý thực tế của task.
Ví dụ:
Task LCD mất 5 ms
thì không hợp lý khi đặt:
LCD period = 1 ms
Vì:
Task cần 5 ms
nhưng cứ 1 ms lại muốn chạy
Scheduler sẽ bị quá tải.
32. CPU utilization
Đây là khái niệm bạn sẽ gặp nhiều hơn về sau.
Ví dụ:
Motor task:
period = 10 ms
execution = 0.2 ms
Tỷ lệ sử dụng:
[
0.2/10 = 2%
]
Nếu có:
Motor = 2%
Sensor = 1%
LCD = 5%
UART = 3%
tổng khoảng:
11%
Hệ thống còn khá nhiều thời gian rảnh.
33. Nếu tổng tải quá cao?
Ví dụ:
Motor = 30%
Sensor = 25%
LCD = 20%
UART = 20%
Other = 15%
Tổng:
110%
Không thể đáp ứng tất cả deadline.
Đây là lúc cần:
tối ưu code
↓
giảm tần suất task
↓
DMA
↓
Interrupt
↓
Timer khác
↓
hoặc RTOS
34. Bài 37 đã đưa chúng ta đến một khái niệm mới
Bạn hãy ghi nhớ:
Hardware Timer
↓
System Tick
↓
Software Timer
↓
Periodic Task
↓
Cooperative Scheduler
Đây là cả một chuỗi kiến thức.
35. Bài tập thực hành trên Blue Pill
Hãy tạo 4 task:
LED → 500 ms
BUTTON → 10 ms
ADC → 100 ms
LCD → 1000 ms
Trong đó hiện tại chưa cần phần cứng ADC/LCD thật.
Bạn có thể dùng biến đếm:
volatile uint32_t led_count = 0;
volatile uint32_t adc_count = 0;
volatile uint32_t lcd_count = 0;
hoặc đơn giản hơn dùng static.
36. Bài tập nâng cao
Hãy thử tạo:
Task 1 → 10 ms
Task 2 → 20 ms
Task 3 → 50 ms
Task 4 → 100 ms
Task 5 → 1000 ms
Không được dùng:
delay()
và không được tạo thêm Timer phần cứng.
Chỉ sử dụng:
TIM2
+
millis
37. Bài tập đặc biệt
Hãy thử trả lời câu hỏi này:
Nếu
millis = 4294967290và sau đó Timer tiếp tục chạy khiếnmillisquay về0, đoạn code sau có còn hoạt động không?
if ((uint32_t)(millis - last_time) >= 100)
{
last_time = millis;
}
Có.
Đây chính là lý do chúng ta dùng phép trừ với kiểu uint32_t.
38. Những điều cần nhớ sau Bài 37
1.
millis++
được thực hiện trong Timer ISR.
2.
Task không nên chạy trong ISR.
3.
Mỗi task có thể có một static last.
4.
Không dùng delay() trong scheduler.
5.
Công thức:
(uint32_t)(millis - last) >= period
là cực kỳ quan trọng.
6.
Một Timer phần cứng có thể tạo ra rất nhiều Software Timer.
7.
Các task phải ngắn và non-blocking.
8.
Đây là một cooperative scheduler đơn giản, chưa phải RTOS.
39. Sơ đồ kiến trúc bạn nên ghi vào vở
STM32F103
│
▼
TIM2
│
1 ms interrupt
│
▼
millis++
│
▼
┌───────────┐
│ main │
│ scheduler │
└─────┬─────┘
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
Task Motor Task Sensor Task LCD
10 ms 50 ms 200 ms
│ │ │
└────────────────┼────────────────┘
│
▼
Task LED
500 ms
🎯 Bài 37 bạn cần làm được 3 việc
Mức 1 – bắt buộc
Tạo TIM2 interrupt mỗi 1 ms và tăng:
volatile uint32_t millis;
Mức 2
Điều khiển LED mỗi 500 ms mà không dùng delay().
Mức 3 – quan trọng nhất
Chạy đồng thời:
Task A → 10 ms
Task B → 100 ms
Task C → 500 ms
Task D → 1000 ms
chỉ bằng một TIM2.
Nếu làm được 3 mức này, bạn đã bắt đầu có tư duy firmware embedded thực thụ, chứ không còn chỉ là viết từng đoạn code điều khiển GPIO nữa.
Bài 38 hợp lý tiếp theo sẽ là TIMER INPUT/OUTPUT COMPARE – hiểu sâu CNT, PSC, ARR, CCR và cách Timer có thể tự tạo sự kiện chính xác mà CPU không cần liên tục kiểm tra.