Tối ưu hóa (optimization) là một thuật ngữ rất quan trọng trong lập trình nhúng, đặc biệt trong lập trình Vi điều khiển. Làm việc với những chương trình có độ phức tạp cao nhưng tài nguyên trên nền tảng thiết bị phần cứng lại có nhiều hạn chế, bạn sẽ càng thấu hiểu được sự quan trọng của việc tối ưu chương trình.
Tại bài viết này, chúng ta sẽ cùng nhau tìm hiểu tổng quan về tối ưu hóa, các tùy chọn (option) được GCC hỗ trợ và thực hành với ví dụ cụ thể trên STM32.
1. Giới thiệu về tối ưu hóa
Việc tối ưu có thể được triển khai theo 2 cách:
- Tối ưu thủ công
- Tối ưu qua trình biên dịch
1.1. Tối ưu thủ công
Người lập trình chủ động phân tích, đánh giá và tự hiệu chỉnh mã nguồn một cách thủ công. Một số phương thức thường được áp dụng như:
- Loại bỏ mã chết (Dead-code): Dead-code có thể là biến, lệnh, khối lệnh, hàm,… và sự tồn tại của chúng hoàn toàn không làm ảnh hưởng đến kết quả của chương trình
- Sử dụng các cấu trúc dữ liệu hiệu quả đối với nhu cầu truy xuất dữ liệu của bài toán
- Phân tích và lựa chọn thuật toán phù hợp và hiệu quả
- Quản lý bộ nhớ bằng cách giải phóng những phân vùng bộ nhớ không còn được sử dụng sau khi cấp phát.
Tối ưu thủ công là một trong những kỹ năng quan trọng cần có của người lập trình Nhúng vi điều khiển. Để có kỹ năng tốt về tối ưu thủ công, chúng ta cần hiểu được cấu trúc dữ liệu, chương trình thuật toán và kiến trúc bộ nhớ trên thiết bị phần cứng trong dự án của các bạn.
1.2. Tối ưu qua trình biên dịch
Là quá trình trình biên dịch tự động thực hiện các thay đổi đối với mã nguồn để cải thiện hiệu suất chương trình. Bản chất của loại tối ưu này là trình biên dịch sẽ loại bỏ đi nhu cầu đọc và ghi của một số biến không cần thiết trong chương trình.
Optimization được thực hiện bởi trình biên dịch nhằm tác động đến chương trình với các mục đích:
- Giảm số lượng các câu lệnh (code space optimization)
- Giảm thời gian truy cập bộ nhớ (time space optimization)
- Giảm năng lượng tiêu thụ
Vậy thì làm cách nào để chúng ta có thể sử dụng tính năng tối ưu hóa của trình biên dịch? Hầu hết các trình biên dịch cung cấp cho chúng ta các tùy chọn cho các mức độ tối ưu hóa khác nhau để người lập trình có thể dễ dàng lựa chọn và áp dụng.
Khi các bạn xây dựng chương trình mới, mặc định trình biên dịch sẽ không áp dụng bất cứ tùy chọn optimization nào. Chúng ta cần thiết lập thông qua việc lựa chọn một trong các tùy chọn được cung cấp của trình biên dịch. Optimization giúp chương trình tối ưu hơn nhưng đánh đổi lại với việc thời gian biên dịch lớn hơn, có thể ảnh hưởng đến khả năng debug, thậm chí là hệ thống hoạt động không theo mong muốn hoặc sinh ra bug.
2. Các tùy chọn optimization được gcc hỗ trợ
Trong bài viết này mình sẽ đề cập đến đặc điểm của các tùy chọn optimization thường được sử dụng trong arm-none-eabi-gcc – trình biên dịch GCC cho các vi điều khiển sử dụng vi xử lý ARM, ví dụ như STM32. Mỗi tùy chọn sẽ bao gồm một danh sách các yêu cầu tối ưu cụ thể mà trình tối ưu cần thực hiện. Tùy chọn càng có nhiều yêu cầu tối ưu sẽ giúp trình biên dịch cải thiện hiệu suất hoặc kích thước mã chương trình nhưng đổi lại việc gia tăng thời gian biên dịch và ảnh hưởng khả năng debug chương trình.
Nắm rõ về đặc điểm của các tùy chọn sẽ giúp chúng ta có các lựa chọn phù hợp. Các bạn có thể xem cụ thể chi tiết về từng tùy chọn tại đây.
-O0 |
|
-O1 |
|
-O2 |
|
-O3 |
|
Ngoài các tùy chọn thường được sử dụng trên, một số tùy chọn khác mà trình biên dịch gcc cung cấp là -Os, -Ofast, -Og, -Oz. Việc lựa chọn một trong các tùy chọn trên cho dự án của bạn rất quan trọng. Đặc biệt đối với những dự án có nguồn tài nguyên phần cứng hạn chế, bạn có thể sẽ cần phải làm việc với nhiều tùy chọn, kết hợp kiểm thử để lựa chọn được tùy chọn phù hợp nhất.
3. Thực hành với các tùy chọn Optimization
3.1. Debug chương trình ví dụ
Mình lấy một ví dụ đơn giản giúp chúng ta thấy được sự khác nhau giữa các cờ biên dịch.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <stdio.h> int main() { int sum = 0; for (int i = 0; i < 100; ++i) { sum += i; } printf("sum = %d", sum); return sum; } |
Hàm main sẽ trả về giá trị sum là tổng của các số hạng mang giá trị từ 0 đến 99.
Để phân tích rõ hơn về sự khác nhau khi biên dịch với các flag, các bạn cần nắm 4 giai đoạn trong quá trình dịch.
Để có thể phân tích quá trình và kết quả biên dịch tương đồng với Vi điều khiển, mình sẽ tạo project trên STM32CubdeIDE với dòng STM32C8T6. Chúng ta sẽ phân tích chương trình trên dưới dạng kết quả của giai đoạn 2 – giai đoạn Compiler (phân tích cú pháp và chuyển đổi file về ngôn ngữ bậc thấp assembly).
-O0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
main: 0800014c: push {r7, lr} 0800014e: sub sp, #8 08000150: add r7, sp, #0 int sum = 0; 08000152: movs r3, #0 ;Thiết lập giá trị thanh ghi R3 (sum) = 0 08000154: str r3, [r7, #4] ;Lưu giá trị R3 (sum) vào stack với offset là 4 for (int i = 0; i < 100; ++i) { 08000156: movs r3, #0 ;Thiết lập giá trị R3 (i) = 0 08000158: str r3, [r7, #0] ;Lưu giá trị R3 (i) vào stack với offset là 0 0800015a: b.n 0x800016a <main+30> sum += i; 0800015c: ldr r2, [r7, #4] ;Gán giá trị tại offset 4 của stack (sum) cho R2 0800015e: ldr r3, [r7, #0] ;Gán giá trị tại offset 0 (i) cho R3 08000160: add r3, r2 ;R3 = sum + i 08000162: str r3, [r7, #4] ;Lưu lại giá trị của sum tại offset 4 for (int i = 0; i < 100; ++i) { 08000164: ldr r3, [r7, #0] ;R3 = i 08000166: adds r3, #1 ;R3 = R3 + 1 (i = i + 1) 08000168: str r3, [r7, #0] ;Lưu giá trị R3 (i) vào stack với offset là 0 0800016a: ldr r3, [r7, #0] ;Gán giá trị tại offset 0 (i) cho R3 0800016c: cmp r3, #99 ;So sánh R3 (i) với 99(0x63) 0800016e: ble.n 0x800015c <main+16> printf("sum = %d", sum); 08000170: ldr r1, [r7, #4] 08000172: ldr r0, [pc, #16] ; (0x8000184 <main+56>) 08000174: bl 0x80003f4 <printf> return sum; 08000178: ldr r3, [r7, #4] } |
Bạn có thể thấy rõ được sự triển khai code assembly hoàn toàn giống với source code được đưa ra. Video dưới đây sẽ hướng dẫn chi tiết việc thực thi lệnh, lưu trữ dữ liệu trên thanh ghi (register) và bộ nhớ RAM của chương trình trên với option O0.
Chúng ta tiếp tục phân tích với O1.
-O1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
main: 0800014c: push {r3, lr} 0800014e: movs r3, #100 ; 0x64 for (int i = 0; i < 100; ++i) { 08000150: subs r3, #1 08000152: bne.n 0x8000150 <main+4> printf("sum = %d", sum); 08000154: movw r1, #4950 ; 0x1356 08000158: ldr r0, [pc, #8] ; (0x8000164 <main+24>) 0800015a: bl 0x80002e0 <printf> } |
Đơn giản hơn nhiều so với O0, O1 chỉ sử dụng thanh ghi R3 để lưu trữ giá trị của i sau mỗi loop. Còn giá trị của sum đã được tính toán trước và lưu vào R1. Đây là một trong những đặc tính của các cờ tối ưu – Tối ưu hóa các phép tính. Một sự tối ưu lớn về kích thước chương trình và tốc độ thực thi. Nhưng nhìn nhận thì vẫn có sự lãng phí tài nguyên với vòng lặp i chỉ để giảm giá trị của i từ 100 về 0.
Tiếp tục với các cờ có độ tối ưu cao hơn, trong trường hợp này, source code biên dịch của O2 và O3 giống nhau như dưới đây:
-O2
1 2 3 4 5 6 7 8 9 10 11 12 13 |
main: 0800014c: push {r3, lr} printf("sum = %d", sum); 0800014e: movw r1, #4950 ; 0x1356 08000152: ldr r0, [pc, #12] ; (0x8000160 <main+20>) 08000154: bl 0x80002f0 <printf> } |
-O3
1 2 3 4 5 6 7 8 9 10 11 12 13 |
main: 0800014c: push {r3, lr} printf("sum = %d", sum); 0800014e: movw r1, #4950 ; 0x1356 08000152: ldr r0, [pc, #12] ; (0x8000160 <main+20>) 08000154: bl 0x80002f0 <printf> } |
Tại flag O2 và O3, vòng lặp for đã được loại bỏ. Trình biên dịch tính toán sẵn giá trị sum (4950) và chuyển giá trị này vào R1.
Qua lần lượt các phân tích trên, sự tối ưu tăng dần từ việc sử dụng cờ O0 đến O3.
3.2. Không gian bộ nhớ và thời gian triển khai chương trình
Khi chúng ta áp dụng các option tối ưu, dung lượng và thời gian biên dịch, thời gian triển khai sẽ có sự khác nhau. Dưới đây là bảng phân tích thời gian biên dịch và dung lượng của các phân vùng bộ nhớ của chương trình trên. Các bạn có thể tìm hiểu thêm về các phân vùng bộ nhớ tại đây.
Bảng 1. Thời gian biên dịch và dung lượng các phân vùng Text, Data, BSS
Text Session (B) | Data Session (B) | Bss Session (B) | Sum (Dec) | Sum (Hex) | Time compiler (ms) | |
O0 | 5080 | 112 | 1592 | 6784 | 1a80 | 1668 |
O1 | 4804 | 112 | 1592 | 6508 | 196c | 1849 |
O2 | 4820 | 112 | 1592 | 6524 | 197c | 1912 |
O3 | 4820 | 112 | 1592 | 6524 | 197c | 1918 |
Một số nhận xét được rút ra từ kết quả trên:
- Phân vùng Data lưu trữ các biến toàn cục và biến tĩnh được khai báo với giá trị khác 0 nên dung lượng lưu trữ không thay đổi.
- Phân vùng Bss lưu trữ các biến được khởi tạo giá trị bằng 0 hoặc không được khởi tạo giá trị rõ ràng nên dung lượng không thay đổi.
- Phân vùng Text lưu trữ các lệnh chương trình, giá trị hằng, các thuật toán tối ưu, … Một câu hỏi đưa ra là tại sao hàm main của O2 và O3 tối ưu hơn so với O1 nhưng có phân vùng text lại lơn hơn? Một số các yêu cầu của trình biên dịch được sử dụng trong O2 và O3 nhằm làm giảm thời gian triển khai và đánh đổi với việc làm gia tăng kích thước mã chương trình. Cụ thể hơn tại ví dụ trên, ngoài hàm main, chương trình còn bao hàm những hàm khởi tạo cho Vi điều khiển, và đó chính là nguyên nhân làm tăng kích thước phân vùng text với cờ O2, O3.
- Thời gian biên dịch của các tùy chọn lần lượt tăng dần từ O0 đến O3
Vậy là chúng ta đã cùng tìm hiểu về các cờ tối ưu của arm-none-eabi-gcc. Qua bài viết này, mình mong các bạn hiểu được tầm quan trọng của các tùy chọn trong tối ưu. Phân tích và lựa chọn được tùy chọn phù hợp cho dự án của riêng mình.
Chúc các bạn thành công!
N.T.Nhien