Chuyển đến nội dung
MINH VO Ghi chép từ công việc
của một kỹ sư tại Việt Nam
Cơ bản12 phút đọc

Thanh ghi thiết bị không phải biến bộ nhớ thông thường

Phân biệt độ rộng MMIO, ràng buộc trình biên dịch, giao lệnh ghi và hoàn tất thiết bị bằng mô hình thanh ghi giả định có thể chạy.

Minh họa bảng mạch đặt trên giấy vẽ với các nét phác kỹ thuật mờ

Thanh ghi thiết bị có địa chỉ, nhưng truy cập địa chỉ ấy có thể thực hiện một thao tác. Lần ghi có thể khởi động công việc, lần đọc có thể lấy đi một phần tử, và CPU có thể tiếp tục sau lệnh ghi khi dữ liệu vẫn đang chờ trong kết nối tới thiết bị. Xem địa chỉ đó như biến C thông thường vì thế có thể làm mất dữ liệu hoặc nhầm việc giao lệnh với hoàn tất lệnh.

Bài này dành cho lập trình viên bắt đầu viết driver Linux. Bạn cần biết cơ bản về con trỏ và mặt nạ bit. Chúng ta sẽ tách độ rộng truy cập, hành vi trình biên dịch, việc giao dịch đi qua bus và giao thức thiết bị, rồi chạy mô hình thiết bị giả định. Chương trình Python không ánh xạ phần cứng, không phát giao dịch MMIO và không đo bus thật. Trạng thái mô hình được cố ý cho phép quan sát để bạn thấy những khác biệt mà driver thật phải xác lập qua giao diện có tài liệu.

Bắt đầu từ hợp đồng của thanh ghi

Giả sử thiết bị giả định có ba thanh ghi 32 bit, căn chỉnh đúng và dùng thứ tự byte little-endian. CMD tại độ lệch 0 nhận lệnh ghi START. STATUS tại độ lệch 4 báo BUSY ở bit 0 và DONE ở bit 1. RX tại độ lệch 8 trả byte nhận tiếp theo trong tám bit thấp, các bit còn lại bằng 0, đồng thời lấy byte đó khỏi hàng đợi. STATUS không có cơ chế đọc để xóa cờ và không lấy dữ liệu nhận ra khỏi hàng đợi. Tuy vậy, lần đọc STATUS có thể giao một lệnh đang chờ; việc giao lệnh ấy xóa cờ DONE cũ, chứ không phải bản thân thao tác đọc tự xóa cờ.

Đây là các hợp đồng riêng dù cả ba thanh ghi đều chiếm bốn byte. Đọc RX hai lần lấy hai phần tử hàng đợi, không phải quan sát hai lần cùng một biến đã lưu. Ghi một byte vào CMD không tự động tương đương với ghi từ 32 bit được yêu cầu. Tách truy cập có thể đổi giao dịch mà thiết bị nhìn thấy; độ rộng không được hỗ trợ có thể không hợp lệ dù giá trị số trông đúng.

Hướng dẫn truy cập thiết bị độc lập với bus của Linux định nghĩa readb, readw, readl và readq cho đại lượng 8, 16, 32 và 64 bit. Tên lịch sử readl ở đây chỉ truy cập thiết bị 32 bit; nó không phụ thuộc kích thước kiểu C long trên máy biên dịch driver. Thứ tự byte của thiết bị là lựa chọn khác: tài liệu phân biệt hàm truy cập thông thường cho ngoại vi little-endian với nhóm hàm big-endian, đồng thời nêu các ngoại lệ phụ thuộc kiến trúc.

Đừng mặc định thanh ghi 64 bit luôn có thể đọc nguyên tử trên bộ xử lý 32 bit. Cùng tài liệu ấy cung cấp hàm hỗ trợ đọc thấp/cao hoặc cao/thấp cho thiết bị cho phép thao tác tách, với thứ tự do thiết bị quy định. Đặc tả thanh ghi phải xác định có cho phép tách hay không và có cần giao thức chốt giá trị hoặc chụp trạng thái nhất quán không. Chọn kiểu số nguyên C rộng hơn không tạo ra bảo đảm phần cứng đó.

Sơ đồ thanh ghi 32 bit giả định phân biệt ghi lệnh, đọc trạng thái không xóa cờ và đọc nhận có lấy phần tử khỏi hàng đợi.Mở ảnh kích thước đầy đủ ↗

Độ lệch và ngữ nghĩa được đặt riêng cho bài học. Driver thật phải lấy chúng từ tài liệu thiết bị và cơ chế phát hiện tài nguyên tương ứng.

Dùng ánh xạ I/O cùng API truy cập của nó

Địa chỉ thiết bị vừa tìm được không phải con trỏ thông thường trong không gian người dùng. Trong mã kernel Linux có thể dùng trên nhiều kiến trúc, ánh xạ MMIO được biểu diễn bằng con trỏ có nhãn __iomem, thường nhận từ hàm ánh xạ phù hợp. Giá trị này phải được truyền cho các hàm truy cập I/O. Trên một số kiến trúc, nó không đơn giản là địa chỉ ảo có thể giải tham chiếu trực tiếp; vì vậy ép kiểu rồi dùng lệnh tải thông thường không phải cách thay thế có tính khả chuyển.

Phát hiện tài nguyên, quyền quản lý, thuộc tính ánh xạ và dọn dẹp cũng thuộc vòng đời driver. Ví dụ ioremap ở phần đầu hướng dẫn yêu cầu giải phóng bằng iounmap; các hàm quản lý tài nguyên tự động có thể bố trí việc dọn dẹp theo cách khác. Bài này không cung cấp một hàm probe driver đầy đủ hay cho phép ánh xạ địa chỉ tùy ý. Phần giải thích bắt đầu sau khi ánh xạ hợp lệ đã được thiết lập theo quy tắc của phân hệ.

Tương tự, memcpy và memset thông thường không thay thế được các hàm sao chép và điền dữ liệu dành riêng cho I/O của kernel. Hàm bộ nhớ được tối ưu có thể chọn độ rộng hoặc thứ tự truy cập không đáp ứng ngữ nghĩa thiết bị. Ngay cả hàm sao chép I/O cũng không tự động thay thế được việc truy cập lặp vào thanh ghi FIFO: địa chỉ có tăng hay không, và mỗi lần đọc có lấy đi dữ liệu hay không, phải khớp giao diện cụ thể.

Volatile không thiết lập mọi thứ tự cần thiết

Truy cập có volatile có thể ràng buộc tối ưu hóa của trình biên dịch theo quy tắc của hiện thực. Nó không biến giá trị dùng chung thành công cụ đồng bộ và không chứng minh một loại truy cập bộ nhớ khác đã trở nên nhìn thấy được. Tài liệu về truy cập volatile của GCC nói rõ truy cập đối tượng không volatile không được sắp thứ tự tương đối với truy cập volatile. Chỉ ghi bộ nhớ thông thường rồi ghi cờ volatile chưa tạo thành giao thức công bố dữ liệu.

Tài liệu ấy còn cảnh báo trường bit volatile có thể dẫn tới lần đọc ngầm hoặc truy cập từng phần. Điều này nguy hiểm khi thiết bị xem đọc là thao tác có tác dụng, hoặc chỉ hỗ trợ một số độ rộng truy cập. Hãy dùng độ rộng đầy đủ được chỉ định và mặt nạ tường minh khi hợp đồng thanh ghi yêu cầu; đừng suy ra chuỗi giao dịch từ cách một cấu trúc C biểu diễn các bit tiện đến đâu.

Với Linux, bài giải thích volatile trong kernel của Jonathan Corbet hướng việc truy cập MMIO qua các hàm accessor, vốn đã xử lý các ràng buộc trình biên dịch liên quan. Khóa và API đồng bộ thích hợp giải quyết truy cập đồng thời từ phần mềm. Tài liệu không khẳng định hiện thực bare-metal tuyệt đối không được dùng volatile bên trong; nó giải thích vì sao thêm từ khóa ấy vào con trỏ driver không thay cho giao diện kernel.

Giữ riêng thứ tự do trình biên dịch bảo đảm, thứ tự ở CPU/kết nối phần cứng và việc thiết bị hoàn tất công việc. Hàng rào trình biên dịch không tự động là hàng rào phần cứng. Thao tác sắp thứ tự phần cứng không tự động là sự kiện hoàn tất lệnh. Khả năng nhìn thấy dữ liệu DMA còn có giao thức riêng, nên lệnh trỏ tới buffer cần quy tắc ánh xạ và bàn giao quyền dùng buffer DMA bên cạnh MMIO đúng.

Thiết bị nhận lệnh khác với hoàn tất lệnh

Hướng dẫn truy cập thiết bị Linux giải thích rằng lần ghi PCI có thể được đệm để giao sau, thường gọi là posted write: phần mềm có thể tiếp tục trước khi lần ghi đến thiết bị. Trong các tình huống được tài liệu mô tả, một lần đọc từ cùng thiết bị có thể bảo đảm lần ghi trước đã tới. Cần chọn thanh ghi đọc cẩn thận. Thanh ghi phải đọc được an toàn, thiết bị phải còn truy cập được, và trạng thái reset hoặc đặc điểm bus có thể đòi hỏi cơ chế khác theo tài liệu.

Nhận lệnh ghi START chỉ cho biết thiết bị có thể bắt đầu xử lý. Nó chưa chứng minh công việc đã xong. Thiết bị có thể báo hoàn tất bằng cờ, mục trong hàng đợi hoặc ngắt với quy tắc xác nhận riêng. Ngữ nghĩa ấy quyết định bằng chứng hoàn tất. Đọc thanh ghi an toàn để đẩy hết lần ghi đang chờ không thay thế chúng.

Mô hình thể hiện khác biệt này tường minh. write_start đưa một lệnh vào trạng thái chờ giao. deliver nhận lệnh, đặt BUSY và xóa chỉ báo DONE cũ. Mỗi lần đọc thanh ghi thành công đều giao lệnh đang chờ trước. Chỉ step_engine hoàn tất lệnh đã được nhận. Ta chọn một bước xử lý trừu tượng cho đơn giản, không gán thời lượng cho bước ấy. Có thể gọi giao lệnh riêng; kết nối phần cứng thật không nhất thiết phải chờ phần mềm đọc mới giao lần ghi.

Mô hình chỉ cho phép một lệnh đang chờ hoặc đang xử lý, và từ chối gửi lệnh thứ hai cho đến khi lệnh đã nhận hoàn tất. Đây là giới hạn đầu vào phục vụ học tập, không khẳng định phần cứng hay driver thật có thể kiểm tra một biến Boolean về lần ghi đang chờ trên bus. snapshot chỉ lộ trạng thái bộ mô phỏng. Dữ liệu nhận đầu vào phải là chuỗi hữu hạn các byte kiểu số nguyên; Boolean và giá trị ngoài miền bị từ chối. Đọc hàng đợi nhận rỗng cũng bị từ chối, thay vì tự đặt kết quả đọc rỗng cho mọi thiết bị.

class Device:
    def __init__(self, received=(0x41, 0x42)):
        values = tuple(received)
        if any(type(v) is not int or not 0 <= v <= 255 for v in values):
            raise ValueError('received values must be bytes')
        self.received = list(values)
        self.posted = self.busy = self.done = False

    def write_start(self):
        if self.posted or self.busy:
            raise ValueError('this model permits only one command in flight')
        self.posted = True

    def deliver(self):
        if self.posted:
            self.posted = False
            self.busy, self.done = True, False

    def read_status(self):
        self.deliver()
        return int(self.busy) | (int(self.done) << 1)

    def read_rx(self):
        if not self.received:
            raise ValueError('this model rejects an empty receive read')
        self.deliver()
        return self.received.pop(0)

    def step_engine(self):
        if self.busy:
            self.busy, self.done = False, True

    def snapshot(self):
        return self.posted, self.busy, self.done, tuple(self.received)

safe = Device()
safe.write_start()
print('after write:', safe.snapshot())
safe.step_engine()
assert safe.snapshot() == (True, False, False, (65, 66))
print('status after delivery:', safe.read_status())
assert safe.snapshot() == (False, True, False, (65, 66))
safe.step_engine()
print('status after engine step:', safe.read_status())
assert safe.read_status() == 2 and safe.received == [65, 66]

consuming = Device()
consuming.write_start()
print('receive read:', consuming.read_rx())
assert consuming.snapshot() == (False, True, False, (66,))
print('next receive read:', consuming.read_rx())
assert consuming.received == []
after write: (True, False, False, (65, 66))
status after delivery: 1
status after engine step: 2
receive read: 65
next receive read: 66

Sau lần ghi đầu, lệnh còn chờ giao và bộ xử lý lệnh trong thiết bị đang nhàn rỗi. Cho bộ xử lý ấy tiến một bước không làm gì vì lệnh chưa tới. Lần đọc STATUS sau đó giao lệnh và trả giá trị 1, nghĩa là BUSY. Chỉ sau bước xử lý tiếp theo, STATUS mới trả 2, nghĩa là DONE. Đọc STATUS lặp lại giữ nguyên hàng đợi nhận theo hợp đồng thanh ghi giả định này.

Trong mô hình, lệnh đi từ chờ giao sang đã nhận rồi hoàn tất; đọc RX cũng giao lệnh nhưng lấy đi dữ liệu nhận.Mở ảnh kích thước đầy đủ ↗

Đối tượng thứ hai dùng RX làm lần đọc để đẩy lệnh ghi đang chờ. Nó cũng giao lệnh, nhưng lấy byte 65 khỏi hàng đợi. Lần đọc kế tiếp trả byte 66. Vì vậy, in dữ liệu để gỡ lỗi hoặc đọc lại tưởng như vô hại có thể đổi trạng thái bạn định quan sát. Hai thanh ghi trong mô hình có cùng tác dụng giao lần ghi đang chờ nhưng khác tác dụng theo giao thức.

Kiểm tra từng bảo đảm ở đúng ranh giới

Các hàm truy cập Linux thông thường cung cấp bảo đảm thứ tự được tài liệu xác định; biến thể relaxed cố ý dùng hợp đồng hẹp hơn. Đừng chọn hàm relaxed chỉ vì tên gợi cảm giác nhanh hơn. Hãy xác định truy cập cụ thể cần được sắp thứ tự với điều gì, kể cả DMA khi có liên quan, rồi giải thích tại sao thao tác yếu hơn đã đủ. Tài liệu còn cảnh báo posted write không được sắp thứ tự chặt chẽ với spinlock; loại trừ lẫn nhau trong phần mềm và thiết bị nhận lần ghi vẫn là hai câu hỏi riêng.

Khi đánh giá driver, trước hết xác định ánh xạ, độ rộng, thứ tự byte và tác dụng phụ của thanh ghi. Sau đó xác định bảo đảm của trình biên dịch và kiến trúc mà API đã chọn cung cấp, thời điểm biết chắc lần ghi cần thiết đã đến, và bằng chứng riêng rằng thao tác đã xong. Nếu hai kết luận cuối dựa vào cùng lần đọc, đặc tả thiết bị phải xác lập cả hai ý nghĩa. Việc có một lệnh đọc trong mã nguồn tự nó không chứng minh kết luận nào.

Hãy mở rộng mô hình bằng cách gọi deliver tường minh rồi cho bộ xử lý tiến một bước trước mọi lần đọc STATUS. Kiểm tra lần đọc sau báo DONE mà không lấy dữ liệu nhận. Tiếp theo, thử ghi START lặp lại, hàng đợi RX rỗng và gửi lệnh mới sau khi lệnh cũ đã hoàn tất. Kiểm tra việc giao lệnh mới xóa DONE cũ. Các bài tập này làm rõ giả định về giao lệnh, độ mới của trạng thái và tác dụng phụ trước khi chúng trở thành lỗi phần cứng khó điều tra.

Nguồn tham khảo & đọc thêm

  1. Linux kernel: Bus-independent device accesses
  2. GCC: When is a volatile object accessed?
  3. Jonathan Corbet: Why volatile should not be used in kernel code
← Về trang bài viết

Hình minh họa

100%