MicroPython trên vi điều khiển¶
MicroPython được thiết kế để có thể chạy trên các vi điều khiển. Các thiết bị này có những hạn chế phần cứng mà các lập trình viên quen với máy tính thông thường có thể chưa quen. Đặc biệt, dung lượng RAM và bộ nhớ lưu trữ không thay đổi (bộ nhớ flash) rất hạn chế. Hướng dẫn này cung cấp các cách để tận dụng tối đa tài nguyên hạn chế. Vì MicroPython chạy trên các bộ điều khiển dựa trên nhiều kiến trúc khác nhau, các phương pháp được trình bày mang tính tổng quát: trong một số trường hợp, sẽ cần lấy thông tin chi tiết từ tài liệu dành riêng cho từng nền tảng.
Bộ nhớ flash¶
Trên các OpenMV Cam, cách đơn giản để giải quyết dung lượng hạn chế là gắn thẻ micro SD. Trong một số trường hợp điều này không thực tế, hoặc vì thiết bị không có khe cắm thẻ SD hoặc vì lý do chi phí hay tiêu thụ điện năng; do đó phải sử dụng bộ nhớ flash tích hợp trên chip. Firmware bao gồm hệ thống con MicroPython được lưu trong bộ nhớ flash tích hợp. Phần dung lượng còn lại có thể được sử dụng. Vì những lý do liên quan đến kiến trúc vật lý của bộ nhớ flash, một phần dung lượng này có thể không thể truy cập được dưới dạng hệ thống tệp. Trong những trường hợp như vậy, không gian này có thể được sử dụng bằng cách tích hợp các module người dùng vào bản build firmware sau đó được nạp vào thiết bị.
Có hai cách để thực hiện điều này: module đóng băng và bytecode đóng băng. Module đóng băng lưu mã nguồn Python cùng với firmware. Bytecode đóng băng sử dụng trình biên dịch chéo để chuyển đổi mã nguồn sang bytecode, sau đó được lưu cùng với firmware. Trong cả hai trường hợp, module có thể được truy cập bằng câu lệnh import:
import mymodule
Quy trình tạo module đóng băng và bytecode phụ thuộc vào nền tảng; hướng dẫn xây dựng firmware có thể tìm thấy trong các tệp README trong phần liên quan của cây mã nguồn.
Nói chung, các bước thực hiện như sau:
Sao chép repository MicroPython.
Lấy toolchain (dành riêng cho nền tảng) để xây dựng firmware.
Xây dựng trình biên dịch chéo.
Đặt các module cần đóng băng vào một thư mục cụ thể (tùy thuộc vào việc module được đóng băng dưới dạng mã nguồn hay bytecode).
Xây dựng firmware. Có thể cần một lệnh cụ thể để xây dựng code đóng băng theo một trong hai loại - xem tài liệu nền tảng.
Nạp firmware vào thiết bị.
RAM¶
Khi giảm mức sử dụng RAM, có hai giai đoạn cần xem xét: biên dịch và thực thi. Ngoài việc tiêu thụ bộ nhớ, còn có một vấn đề được gọi là phân mảnh heap. Nói chung, tốt nhất là giảm thiểu việc tạo và hủy đối tượng lặp đi lặp lại. Lý do cho điều này được đề cập trong phần về heap.
Giai đoạn biên dịch¶
Khi một module được import, MicroPython biên dịch code thành bytecode và bytecode này sau đó được thực thi bởi máy ảo MicroPython (VM). Bytecode được lưu trong RAM. Bản thân trình biên dịch cũng yêu cầu RAM, nhưng RAM này sẽ được giải phóng để sử dụng khi quá trình biên dịch hoàn tất.
Nếu một số module đã được import, có thể xảy ra tình huống không đủ RAM để chạy trình biên dịch. Trong trường hợp này, câu lệnh import sẽ tạo ra một ngoại lệ bộ nhớ.
Nếu một module khởi tạo các đối tượng toàn cục khi import, nó sẽ tiêu thụ RAM tại thời điểm import, và RAM đó sẽ không còn dùng được cho trình biên dịch trong các lần import tiếp theo. Nói chung, tốt nhất là tránh code chạy khi import; cách tiếp cận tốt hơn là có code khởi tạo được chạy bởi ứng dụng sau khi tất cả các module đã được import. Điều này tối đa hóa RAM có sẵn cho trình biên dịch.
Nếu RAM vẫn không đủ để biên dịch tất cả các module, một giải pháp là biên dịch trước các module. MicroPython có trình biên dịch chéo có khả năng biên dịch các module Python thành bytecode (xem README trong thư mục mpy-cross). Tệp bytecode kết quả có phần mở rộng .mpy; nó có thể được sao chép vào hệ thống tệp và import theo cách thông thường. Ngoài ra, một số hoặc tất cả các module có thể được triển khai dưới dạng bytecode đóng băng: trên hầu hết các nền tảng, điều này tiết kiệm RAM nhiều hơn vì bytecode được chạy trực tiếp từ bộ nhớ flash thay vì được lưu trong RAM.
Giai đoạn thực thi¶
Có một số kỹ thuật lập trình để giảm mức sử dụng RAM.
Hằng số
MicroPython cung cấp từ khóa const có thể được sử dụng như sau:
from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS
Trong cả hai trường hợp nơi hằng số được gán cho một biến, trình biên dịch sẽ tránh mã hóa việc tra cứu tên hằng số bằng cách thay thế giá trị trực tiếp của nó. Điều này tiết kiệm bytecode và do đó tiết kiệm RAM. Tuy nhiên, giá trị ROWS sẽ chiếm ít nhất hai từ máy, một cho khóa và một cho giá trị trong từ điển toàn cục. Sự hiện diện trong từ điển là cần thiết vì một module khác có thể import hoặc sử dụng nó. RAM này có thể được tiết kiệm bằng cách thêm dấu gạch dưới vào đầu tên như _COLS: ký hiệu này không hiển thị bên ngoài module nên sẽ không chiếm RAM.
Đối số của const() có thể là bất kỳ thứ gì mà, tại thời điểm biên dịch, đánh giá là một hằng số, ví dụ: 0x100, 1 << 8 hoặc (True, "string", b"bytes") (xem phần dưới để biết chi tiết). Nó thậm chí có thể bao gồm các ký hiệu const khác đã được định nghĩa trước đó, ví dụ: 1 << BIT.
Cấu trúc dữ liệu hằng số
Khi có một khối lượng lớn dữ liệu hằng số và nền tảng hỗ trợ thực thi từ bộ nhớ flash, RAM có thể được tiết kiệm như sau. Dữ liệu nên được đặt trong các module Python và đóng băng dưới dạng bytecode. Dữ liệu phải được định nghĩa dưới dạng đối tượng bytes. Trình biên dịch 'biết' rằng các đối tượng bytes là bất biến và đảm bảo rằng các đối tượng vẫn nằm trong bộ nhớ flash thay vì được sao chép vào RAM. Module struct có thể hỗ trợ trong việc chuyển đổi giữa các kiểu bytes và các kiểu tích hợp Python khác.
Khi xem xét những tác động của bytecode đóng băng, lưu ý rằng trong Python, chuỗi, số thực, bytes, số nguyên, số phức và tuple là bất biến. Theo đó, những thứ này sẽ được đóng băng vào bộ nhớ flash (đối với tuple, chỉ khi tất cả các phần tử của chúng là bất biến). Vì vậy, trong dòng
mystring = "The quick brown fox"
chuỗi thực tế "The quick brown fox" sẽ nằm trong bộ nhớ flash. Tại thời điểm chạy, một tham chiếu đến chuỗi được gán cho biến mystring. Tham chiếu chiếm một từ máy duy nhất. Về nguyên tắc, một số nguyên lớn có thể được dùng để lưu trữ dữ liệu hằng số:
bar = 0xDEADBEEF0000DEADBEEF
Như trong ví dụ về chuỗi, tại thời điểm chạy, một tham chiếu đến số nguyên có kích thước tùy ý được gán cho biến bar. Tham chiếu đó chiếm một từ máy duy nhất.
Các tuple của đối tượng hằng số bản thân chúng là hằng số. Những tuple hằng số như vậy được trình biên dịch tối ưu hóa để chúng không cần được tạo tại thời điểm chạy mỗi khi chúng được sử dụng. Ví dụ:
foo = (1, 2, 3, 4, 5, 6, 100000, ("string", b"bytes", False, True))
Toàn bộ tuple này sẽ tồn tại như một đối tượng duy nhất (có khả năng trong bộ nhớ flash nếu code được đóng băng) và được tham chiếu mỗi khi cần thiết.
Tạo đối tượng không cần thiết
Có một số tình huống mà các đối tượng có thể vô tình được tạo ra và bị hủy. Điều này có thể làm giảm khả năng sử dụng RAM do phân mảnh. Các phần sau thảo luận về các trường hợp như vậy.
Nối chuỗi
Xem xét các đoạn code sau nhằm tạo ra các chuỗi hằng số:
var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""
Mỗi cách tạo ra cùng một kết quả, tuy nhiên cách đầu tiên vô tình tạo ra hai đối tượng chuỗi tại thời điểm chạy, cấp phát thêm RAM cho việc nối trước khi tạo ra chuỗi thứ ba. Các cách khác thực hiện việc nối tại thời điểm biên dịch, hiệu quả hơn, giảm phân mảnh.
Khi các chuỗi phải được tạo động trước khi đưa vào một luồng như tệp, sẽ tiết kiệm RAM nếu thực hiện theo từng phần. Thay vì tạo một đối tượng chuỗi lớn, hãy tạo một chuỗi con và đưa nó vào luồng trước khi xử lý phần tiếp theo.
Cách tốt nhất để tạo chuỗi động là sử dụng phương thức format() của chuỗi:
var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)
Bộ đệm
Khi truy cập các thiết bị như các instance của giao diện UART, I2C và SPI, việc sử dụng các bộ đệm được cấp phát trước tránh việc tạo ra các đối tượng không cần thiết. Xem xét hai vòng lặp sau:
while True:
var = spi.read(100)
# process data
buf = bytearray(100)
while True:
spi.readinto(buf)
# process data in buf
Vòng lặp đầu tiên tạo một bộ đệm ở mỗi lần lặp trong khi vòng lặp thứ hai tái sử dụng một bộ đệm được cấp phát trước; cách này vừa nhanh hơn vừa hiệu quả hơn về mặt phân mảnh bộ nhớ.
Bytes nhỏ hơn int
Trên hầu hết các nền tảng, một số nguyên chiếm bốn byte. Xem xét ba lần gọi hàm foo():
def foo(bar):
for x in bar:
print(x)
foo([1, 2, 0xff])
foo((1, 2, 0xff))
foo(b'\1\2\xff')
Trong lần gọi đầu tiên, một list số nguyên được tạo trong RAM mỗi khi code được thực thi. Lần gọi thứ hai tạo một đối tượng tuple hằng số (một tuple chỉ chứa các đối tượng hằng số) như một phần của giai đoạn biên dịch, vì vậy nó chỉ được tạo một lần và hiệu quả hơn list. Lần gọi thứ ba tạo một đối tượng bytes hiệu quả hơn, tiêu thụ lượng RAM tối thiểu. Nếu module được đóng băng dưới dạng bytecode, cả đối tượng tuple và bytes đều sẽ nằm trong bộ nhớ flash.
Chuỗi so với Bytes
Python3 đã giới thiệu hỗ trợ Unicode. Điều này tạo ra sự phân biệt giữa một chuỗi và một mảng bytes. MicroPython đảm bảo rằng các chuỗi Unicode không chiếm thêm không gian miễn là tất cả các ký tự trong chuỗi là ASCII (tức là có giá trị < 128). Nếu cần các giá trị trong phạm vi 8-bit đầy đủ, các đối tượng bytes và bytearray có thể được sử dụng để đảm bảo rằng không cần thêm không gian. Lưu ý rằng hầu hết các phương thức chuỗi (ví dụ: str.strip()) cũng áp dụng cho các instance bytes nên quá trình loại bỏ Unicode có thể không gặp khó khăn.
s = 'the quick brown fox' # A string instance
b = b'the quick brown fox' # A bytes instance
Khi cần chuyển đổi giữa chuỗi và bytes, có thể sử dụng các phương thức str.encode() và bytes.decode(). Lưu ý rằng cả chuỗi và bytes đều là bất biến. Bất kỳ thao tác nào lấy một đối tượng như vậy làm đầu vào và tạo ra một đối tượng khác đều ngụ ý ít nhất một lần cấp phát RAM để tạo ra kết quả. Ở dòng thứ hai bên dưới, một đối tượng bytes mới được cấp phát. Điều này cũng sẽ xảy ra nếu foo là một chuỗi.
foo = b' empty whitespace'
foo = foo.lstrip()
Thực thi trình biên dịch tại thời điểm chạy
Các hàm Python eval và exec gọi trình biên dịch tại thời điểm chạy, điều này đòi hỏi lượng RAM đáng kể. Lưu ý rằng thư viện pickle từ micropython-lib sử dụng exec. Có thể hiệu quả hơn về RAM nếu sử dụng thư viện json để tuần tự hóa đối tượng.
Lưu trữ chuỗi trong bộ nhớ flash
Các chuỗi Python là bất biến nên có khả năng được lưu trong bộ nhớ chỉ đọc. Trình biên dịch có thể đặt các chuỗi được định nghĩa trong code Python vào bộ nhớ flash. Cũng giống như các module đóng băng, cần có một bản sao của cây mã nguồn trên PC và toolchain để xây dựng firmware. Quy trình sẽ hoạt động ngay cả khi các module chưa được debug hoàn toàn, miễn là chúng có thể được import và chạy.
Sau khi import các module, thực thi:
micropython.qstr_info(1)
Sau đó sao chép và dán tất cả các dòng Q(xxx) vào một trình soạn thảo văn bản. Kiểm tra và xóa các dòng rõ ràng không hợp lệ. Mở tệp qstrdefsport.h sẽ được tìm thấy trong ports/stm32 (hoặc thư mục tương đương cho kiến trúc đang sử dụng). Sao chép và dán các dòng đã sửa vào cuối tệp. Lưu tệp, xây dựng lại và nạp firmware. Kết quả có thể được kiểm tra bằng cách import các module và lại thực thi:
micropython.qstr_info(1)
Các dòng Q(xxx) sẽ biến mất.
Heap¶
Khi một chương trình đang chạy khởi tạo một đối tượng, RAM cần thiết được cấp phát từ một vùng nhớ có kích thước cố định được gọi là heap. Khi đối tượng ra ngoài phạm vi (nói cách khác là trở nên không thể truy cập với code) đối tượng dư thừa được gọi là "rác". Một quá trình được gọi là "thu gom rác" (GC) lấy lại bộ nhớ đó, trả nó về heap trống. Quá trình này chạy tự động, tuy nhiên có thể được gọi trực tiếp bằng cách gọi gc.collect().
Việc thảo luận về điều này có phần phức tạp. Để 'khắc phục nhanh', hãy thực thi định kỳ lệnh sau:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Để biết thêm thông tin, xem bên dưới và tài liệu cho module tích hợp gc.
Để biết chi tiết từ góc độ nội bộ MicroPython/nhà phát triển, xem thêm Quản lý Bộ nhớ.
Phân mảnh¶
Giả sử một chương trình tạo ra một đối tượng foo, sau đó là đối tượng bar. Sau đó foo ra ngoài phạm vi nhưng bar vẫn còn. RAM được sử dụng bởi foo sẽ được GC lấy lại. Tuy nhiên nếu bar được cấp phát ở địa chỉ cao hơn, RAM lấy lại từ foo sẽ chỉ hữu ích cho các đối tượng không lớn hơn foo. Trong một chương trình phức tạp hoặc chạy lâu, heap có thể bị phân mảnh: dù có một lượng RAM đáng kể nhưng không có đủ không gian liên tục để cấp phát một đối tượng cụ thể, và chương trình thất bại với lỗi bộ nhớ.
Các kỹ thuật được nêu ở trên nhằm giảm thiểu điều này. Khi cần các bộ đệm lớn vĩnh viễn hoặc các đối tượng khác, tốt nhất là khởi tạo chúng sớm trong quá trình thực thi chương trình trước khi phân mảnh có thể xảy ra. Có thể cải thiện thêm bằng cách theo dõi trạng thái của heap và bằng cách kiểm soát GC; những điều này được nêu dưới đây.
Báo cáo¶
Có một số hàm thư viện để báo cáo về cấp phát bộ nhớ và kiểm soát GC. Chúng được tìm thấy trong các module gc và micropython. Ví dụ sau có thể được dán tại REPL (Ctrl-E để vào chế độ dán, Ctrl-D để chạy).
import gc
import micropython
gc.collect()
micropython.mem_info()
print('-----------------------------')
print('Initial free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
def func():
a = bytearray(10000)
gc.collect()
print('Func definition: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
func()
print('Func run free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
gc.collect()
print('Garbage collect free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
print('-----------------------------')
micropython.mem_info(1)
Các phương thức được sử dụng ở trên:
gc.collect()Buộc thực hiện thu gom rác. Xem chú thích.micropython.mem_info()In tóm tắt về mức sử dụng RAM.gc.mem_free()Trả về kích thước heap trống tính bằng byte.gc.mem_alloc()Trả về số byte hiện đang được cấp phát.micropython.mem_info(1)In bảng mức sử dụng heap (chi tiết bên dưới).
Các con số được tạo ra phụ thuộc vào nền tảng, nhưng có thể thấy rằng việc khai báo hàm sử dụng một lượng nhỏ RAM dưới dạng bytecode được phát ra bởi trình biên dịch (RAM được sử dụng bởi trình biên dịch đã được lấy lại). Chạy hàm sử dụng hơn 10KiB, nhưng khi trả về a là rác vì nó nằm ngoài phạm vi và không thể được tham chiếu. Lệnh gc.collect() cuối cùng khôi phục lại bộ nhớ đó.
Đầu ra cuối cùng được tạo bởi micropython.mem_info(1) sẽ thay đổi về chi tiết nhưng có thể được hiểu như sau:
Ký hiệu |
Ý nghĩa |
|---|---|
. |
khối trống |
h |
khối đầu |
= |
khối đuôi |
m |
khối đầu được đánh dấu |
T |
tuple |
L |
list |
D |
dict |
F |
float |
B |
byte code |
M |
module |
S |
chuỗi hoặc bytes |
A |
bytearray |
Mỗi chữ cái đại diện cho một khối bộ nhớ duy nhất, một khối là 16 byte. Vì vậy mỗi dòng của heap dump đại diện cho 0x400 byte hay 1KiB RAM.
Kiểm soát thu gom rác¶
Có thể yêu cầu GC bất kỳ lúc nào bằng cách gọi gc.collect(). Việc thực hiện điều này theo chu kỳ có lợi, thứ nhất để ngăn chặn phân mảnh và thứ hai để cải thiện hiệu suất. Một lần GC có thể mất vài millisecond nhưng nhanh hơn khi có ít việc cần làm (khoảng 1ms trên OpenMV Cam). Một lệnh gọi tường minh có thể giảm thiểu độ trễ đó trong khi đảm bảo rằng nó xảy ra tại các điểm trong chương trình khi điều đó là có thể chấp nhận được.
GC tự động được kích hoạt trong các trường hợp sau. Khi một lần cấp phát thất bại, GC được thực hiện và việc cấp phát được thử lại. Chỉ khi điều này thất bại thì một ngoại lệ mới được đưa ra. Thứ hai, GC tự động sẽ được kích hoạt nếu lượng RAM trống giảm xuống dưới một ngưỡng. Ngưỡng này có thể được điều chỉnh khi quá trình thực thi tiến triển:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Điều này sẽ kích hoạt GC khi hơn 25% của heap hiện đang trống trở nên bị chiếm dụng.
Nói chung, các module nên khởi tạo các đối tượng dữ liệu tại thời điểm chạy bằng cách sử dụng các constructor hoặc các hàm khởi tạo khác. Lý do là nếu điều này xảy ra khi khởi tạo, trình biên dịch có thể bị thiếu RAM khi các module tiếp theo được import. Nếu các module thực sự khởi tạo dữ liệu khi import thì gc.collect() được gọi sau lần import sẽ giảm thiểu vấn đề.
Thao tác chuỗi¶
MicroPython xử lý chuỗi theo cách hiệu quả và hiểu điều này có thể giúp thiết kế các ứng dụng chạy trên vi điều khiển. Khi một module được biên dịch, các chuỗi xuất hiện nhiều lần chỉ được lưu một lần, một quá trình được gọi là string interning. Trong MicroPython, một chuỗi được intern được gọi là qstr. Trong một module được import bình thường, instance duy nhất đó sẽ nằm trong RAM, nhưng như đã mô tả ở trên, trong các module được đóng băng dưới dạng bytecode, nó sẽ nằm trong bộ nhớ flash.
So sánh chuỗi cũng được thực hiện hiệu quả bằng cách sử dụng hashing thay vì so sánh từng ký tự. Do đó, chi phí sử dụng chuỗi thay vì số nguyên có thể nhỏ cả về hiệu suất lẫn mức sử dụng RAM - một thực tế có thể gây ngạc nhiên cho các lập trình viên C.
Hậu ký¶
MicroPython truyền, trả về và (theo mặc định) sao chép các đối tượng theo tham chiếu. Một tham chiếu chiếm một từ máy duy nhất nên các quá trình này hiệu quả về mặt sử dụng RAM và tốc độ.
Khi cần các biến có kích thước không phải là một byte cũng không phải là một từ máy, có các thư viện tiêu chuẩn có thể hỗ trợ lưu trữ chúng một cách hiệu quả và thực hiện chuyển đổi. Xem các module array, struct và uctypes.
Chú thích: giá trị trả về của gc.collect()¶
Trên các nền tảng Unix và Windows, phương thức gc.collect() trả về một số nguyên biểu thị số vùng bộ nhớ riêng biệt đã được lấy lại trong quá trình thu gom (chính xác hơn là số lượng head được chuyển thành free). Vì lý do hiệu quả, các cổng bare metal không trả về giá trị này.