Phát triển công cụ giao tiếp cổng nối tiếp thực chiến

Giới thiệu: Giao tiếp cổng nối tiếp trong các hệ thống hiện đại

Cổng nối tiếp (COM port) vẫn là phương thức quan trọng để máy tính trao đổi dữ liệu với thiết bị ngoại vi, đặc biệt trong lĩnh vực công nghiệp, nhúng và IoT. Bài viết này trình bày kiến thức cốt lõi về "công cụ đọc cổng nối tiếp", bao gồm nền tảng giao tiếp nối tiếp, cấu hình COM, giao thức bắt tay và kiểm soát truyền dữ liệu. Thông qua dự án này, nhà phát triển có thể triển khai việc mở cổng, thiết lập tham số, đọc và phân tích dữ liệu trong môi trường C++ hoặc C#, nắm vững phương pháp gỡ lỗi và kiểm tra giao tiếp nối tiếp, đồng thời mở rộng các chức năng thực tế như ghi nhật ký, xử lý lỗi, phù hợp cho việc gỡ lỗi nhúng, điều khiển thiết bị và truyền dữ liệu trong môi trường không có mạng.

1. Nguyên lý cơ bản và tham số cốt lõi của giao tiếp nối tiếp

1.1 Khái niệm và cơ chế truyền tín hiệu giao tiếp nối tiếp

Giao tiếp nối tiếp truyền dữ liệu theo từng bit một, khác với giao tiếp song song chỉ cần một đường dữ liệu để tương tác hai chiều. Cốt lõi của phương pháp này là chuyển đổi dữ liệu song song thành luồng nối tiếp, tại đầu phát dữ liệu được xuất ra theo thứ tự thời gian bởi bộ dịch chuyển, tại đầu thu dữ liệu được tái tạo lại. Chế độ này làm giảm đáng kể yêu cầu chân phần cứng và nguy cơ nhiễu điện từ, phù hợp cho các kịch bản khoảng cách xa, tốc độ thấp. Ứng dụng điển hình bao gồm giao tiếp giữa vi điều khiển với cảm biến, module GPS và các thiết bị tương tự.

1.2 Định nghĩa và cấu hình logic các thông số giao tiếp quan trọng

Tham số Giá trị phổ biến Mô tả
Tốc độ baud 9600, 115200, v.v. Xác định số ký hiệu truyền mỗi giây, cả hai bên phải khớp chính xác
Bit dữ liệu 7 hoặc 8 bit Độ dài dữ liệu hiệu quả của mỗi ký tự
Bit dừng 1 hoặc 2 bit Đánh dấu kết thúc một khung dữ liệu, cung cấp thời gian phục hồi chuỗi
Bit kiểm tra Không/Kiểm tra lẻ/Kiểm tra chẵn Cơ chế phát hiện lỗi đơn giản, tăng độ tin cậy truyền tải

Các tham số này cùng nhau tạo thành nền tảng của giao thức truyền thông. Bất kỳ sự không khớp nào cũng sẽ dẫn đến việc phân tích dữ liệu thất bại.

1.3 Nhận diện cổng COM và hỗ trợ hệ điều hành

Trong hệ thống Windows, cổng nối tiếp vật lý hoặc ảo (như USB sang COM) đều được ánh xạ thành cổng COM (COM1-COM9). Qua "Trình quản lý thiết bị", có thể xem số cổng hiện có. Khi lập trình, cần mở handle tương ứng theo định dạng \\\\.\\COMx. Trong phát triển hiện đại, thường sử dụng API để truy vấn trạng thái PnP và lấy danh sách cổng động, tránh vấn đề tương thích do mã cứng.

1.4 Nguyên lý đồng bộ tốc độ baud

Tốc độ baud về bản chất là thỏa thuận đồng hồ lấy mẫu giữa hai bên. Ví dụ, 115200bps có nghĩa mỗi bit kéo dài khoảng 8.68μs. Đầu thu thường lấy mẫu ở giữa bit, nếu độ lệch đồng hồ vượt quá dung sai (thường ±3%), sẽ dẫn đến nhận nhầm biên bit, gây ra lỗi bit liên tiếp. Sự khác biệt nhỏ về đồng hồ sẽ tích lũy dần, gây ra sai lệch toàn bộ khung dữ liệu, chứng minh tầm quan trọng của việc khớp tốc độ baud chính xác.

2. Giao thức và cơ chế bắt tay giao tiếp nối tiếp

Mặc dù ở tầng vật lý, giao tiếp nối tiếp chỉ dựa vào hai dây đơn giản (TX/RX) để truyền dữ liệu, nhưng cơ chế giao thức đằng lại lại ảnh hưởng sâu sắc đến sự ổn định, hiệu quả và độ tin cậy của truyền thông. Đặc biệt trong các kịch bản yêu cầu cao như tự động hóa công nghiệp, điều khiển thiết bị nhúng và thu thập dữ liệu từ xa, nếu thiếu hiểu biết sâu sắc về cấu trúc giao thức và cơ chế điều khiển dòng chảy, rất dễ xảy ra mất dữ liệu, tràn bộ đệm hoặc treo hệ thống. Phần này sẽ phân tích từ cấu trúc khung dữ liệu底层, hệ thống phân tích logic đóng gói dữ liệu giao tiếp nối tiếp, và tập trung phân tích nguyên lý hoạt động của bắt tay phần mềm và phần cứng, tiết lộ ưu nhược điểm của các chế độ điều khiển dòng chảy khác nhau trong ứng dụng thực tế. Đồng thời, kết hợp với cách thức gọi API của hệ điều hành hiện đại, thảo luận về cách cấu hình chính xác thông số giao tiếp thông qua lập trình, và xử lý vấn đề cạnh tranh tài nguyên trong môi trường đa luồng, cung cấp nền tảng lý thuyết và đường dẫn triển khai hoàn chỉnh cho việc xây dựng hệ thống giao tiếp nối tiếp mạnh mẽ.

2.1 Cấu trúc khung dữ liệu và luồng truyền thông giao tiếp nối tiếp

Giao tiếp nối tiếp về bản chất là một phương thức giao tiếp nối tiếp không đồng bộ, không có tín hiệu đồng hồ chia sẻ giữa đầu phát và đầu thu, do đó phải dựa vào "khung dữ liệu" được định nghĩa trước để đảm bảo cả hai bên có thể nhận diện chính xác từng bit dữ liệu. Một khung dữ liệu hoàn chỉnh bao gồm bit bắt đầu, bit dữ liệu, bit kiểm tra tùy chọn và bit dừng, các thành phần này cùng nhau tạo ra một cửa sổ thời gian tự đồng bộ, cho phép bên nhận lấy mẫu chính xác mỗi bit thông tin mà không cần đồng hồ bên ngoài.

2.1.1 Mối quan hệ thời gian giữa bit bắt đầu, bit dữ liệu, bit kiểm tra và bit dừng

Trong giao tiếp không đồng bộ, mỗi byte được đóng gói độc lập thành khung để truyền. Khi đường dây ở trạng thái rảnh, chân TX duy trì mức cao (logic 1), biểu thị không có dữ liệu được gửi. Khi bắt đầu gửi, trước tiên kéo xuống mức thấp trong thời gian một bit để thông báo cho đầu thu chuẩn bị nhận dữ liệu. Tiếp theo là 5-8 bit dữ liệu (thường là 8 bit), được gửi từ thấp đến cao (LSB first). Sau đó là bit kiểm tra tùy chọn để phát hiện lỗi đơn giản. Cuối cùng là một hoặc nhiều bit dừng (mức cao), đánh dấu kết thúc khung hiện tại.

Để minh họa luồng dữ liệu điển hình với cấu hình 8-N-1 (8 bit dữ liệu, không kiểm tra, 1 bit dừng), chúng ta có thể sử dụng sơ đồ sau:

sequenceDiagram
    participant T as Đầu phát (TX)
    participant R as Đầu thu (RX)

    T->>R: Trạng thái rảnh (mức cao)
    T->>R: Bit bắt đầu (mức thấp, 1 bit)
    T->>R: Bit dữ liệu D0 (LSB)
    T->>R: Bit dữ liệu D1
    T->>R: ...
    T->>R: Bit dữ liệu D7 (MSB)
    T->>R: Bit kiểm tra (tùy chọn)
    T->>R: Bit dừng (mức cao, 1 hoặc 2 bit)
    T->>R: Trạng thái rảnh (mức cao)

Quan trọng nhất trong quy trình này là đồng bộ hóa thời gian: đầu thu phát hiện cạnh xuống và khởi động bộ đếm thời gian nội bộ, lấy mẫu ở giữa mỗi bit để tránh nhầm lẫn do rung động biên. Ví dụ, ở tốc độ baud 9600bps, mỗi bit kéo dài khoảng 104.17 micro giây (1/9600 giây). Bộ thu sẽ lấy mẫu lần đầu sau nửa chu kỳ bit (~52μs) từ cạnh xuống bit bắt đầu, sau đó cứ mỗi chu kỳ bit đầy đủ để lấy mẫu các bit tiếp theo.

Lưu ý rằng sự tồn tại của bit bắt đầu phá vỡ sự liên tục của trạng thái rảnh, trở thành cốt lõi để đồng bộ hóa khung; trong khi bit dừng buộc khôi phục mức cao, để lại đủ thời gian phục vụ cho khung tiếp theo. Nếu bit dừng không đủ hoặc bị nhiễu, có thể dẫn đến nhầm lẫn giữa các khung, gây ra hiện tượng "dán gói".

Ngoài ra, lựa chọn bit kiểm tra ảnh hưởng trực tiếp đến khả năng chịu lỗi truyền. Kiểm tra lẻ yêu cầu tổng số "1" trong toàn bộ khung dữ liệu là số lẻ, kiểm tra chẵn thì ngược lại. Mặc dù nó không thể sửa lỗi, nhưng trong môi trường nhiễu thấp, nó có thể phát hiện hiệu quả việc lật bit đơn, thường được sử dụng trong giao tiếp thiết bị công nghiệp (như Modbus RTU).

Tham số Giá trị có thể chọn Mô tả
Bit bắt đầu Cố định 1 bit Kích hoạt đồng bộ hóa nhận
Bit dữ liệu 5, 6, 7, 8 Độ dài dữ liệu thực tế truyền
Bit kiểm tra None, Odd, Even, Mark, Space Cơ chế phát hiện lỗi
Bit dừng 1, 1.5, 2 Cung cấp khoảng cách khung và thời gian phục hồi

Bảng này tóm tắt phạm vi giá trị hợp lệ cho mỗi trường và chức năng định vị của chúng, thể hiện tính linh hoạt cao của giao thức nối tiếp. Ví dụ, một số thiết bị cũ vẫn sử dụng cấu hình 7-E-1 (7 bit dữ liệu, kiểm tra chẵn, 1 bit dừng) để tương thích với việc xử lý ký tự điều khiển trong bộ ký tự ASCII.

Tóm lại, hiểu rõ đặc tính điện và thứ tự sắp xếp trên chiều thời gian của các trường là bước đầu tiên để thiết kế hệ thống truyền thông ổn định. Bất kỳ sự không khớp nào về tham số cũng sẽ dẫn đến việc phân tích thất bại tại đầu thu, thậm chí gây ra sai lệch dữ liệu chuỗi.

2.1.2 Cấu trúc hoàn chỉnh của khung dữ liệu và quá trình truyền ở cấp byte

Trong một quá trình giao tiếp UART điển hình, mỗi byte cần gửi sẽ được đóng gói thành cấu trúc khung như đã mô tả. Giả sử chúng ta muốn gửi ký tự 'A' (mã ASCII là 0x41, nhị phân 01000001), sử dụng cấu hình 8-N-1, chuỗi truyền hoàn chỉnh sẽ như sau:

Trường Chuỗi điện平 (nhị phân) Thời gian (bit)
Bit bắt đầu 0 1
Bit dữ liệu 1 0 0 0 0 0 1 0 8 (LSB trước)
Bit dừng 1 1

Lưu ý: Vì gửi từ thấp đến cao, dữ liệu gốc 01000001 có bit thấp nhất là 1, bit cao nhất là 0, do đó thứ tự gửi thực tế là 1 0 0 0 0 0 1 0.

Toàn bộ quá trình truyền có thể chia thành các giai đoạn sau:

  1. Chờ rảnh: Dây duy trì mức cao;
  2. Kích hoạt bắt đầu: Đầu phát kéo xuống mức thấp, duy trì 1 bit thời gian;
  3. Dịch chuyển dữ liệu: Xuất từng bit qua bộ dịch chuyển;
  4. Tạo và đính kèm bit kiểm tra (nếu có);
  5. Điền bit dừng: Buộc xuất mức cao ít nhất 1 bit;
  6. Phục hồi rảnh: Cho phép khoảng thời gian rảnh tùy ý trước khung tiếp theo.

Cách đóng gói từng byte này tuy tăng thêm chi phí (mỗi byte thêm 2-4 bit), nhưng cực kỳ đơn giản hóa thiết kế phần cứng, không cần mạch nhận diện đầu cuối/đuôi khung phức tạp. Tuy nhiên, điều này cũng có nghĩa là cổng nối tiếp không phù hợp cho truyền dữ liệu số lượng lớn tốc độ cao - ví dụ, ở tốc độ 9600bps, mỗi giây chỉ có thể truyền khoảng 960 byte dữ liệu hiệu quả (xem xét 10 bit/byte), thấp hơn nhiều so với các giao tiếp đồng bộ như SPI hay USB.

Để minh họa rõ hơn quá trình này, đoạn mã C sau mô phỏng hàm tạo khung dữ liệu đơn giản:

#include <stdio.h>
#include <stdint.h>

void print_frame(uint8_t data) {
    printf("Dữ liệu gửi: 0x%02X ('%c')\n", data, (data >= 32 && data <= 126) ? data : '?');
    printf("Bit bắt đầu: 0\n");

    for (int i = 0; i < 8; i++) {
        int bit = (data >> i) & 0x01;  // LSB trước
        printf("Bit dữ liệu[%d]: %d\n", i, bit);
    }

    printf("Bit dừng: 1\n\n");
}

int main() {
    print_frame('A');  // Gửi ký tự A
    return 0;
}
Giải thích từng dòng mã:
  • #include <stdio.h><stdint.h>: đưa vào thư viện nhập/xuất chuẩn và kiểu nguyên độ rộng cố định.
  • Hàm print_frame() nhận dữ liệu byte làm đầu vào.
  • printf("Bit bắt đầu: 0\n");: xuất bắt buộc bit bắt đầu, biểu thị bắt đầu khung.
  • Trong vòng lặp for, (data >> i) & 0x01 trích xuất từng bit bắt đầu từ bit thấp nhất, phù hợp với thứ tự truyền UART.
  • printf("Bit dữ liệu[%d]: %d\n", i, bit);: in giá trị của từng bit.
  • Cuối cùng xuất bit dừng "1".

Kết quả ví dụ:

Dữ liệu gửi: 0x41 ('A')
Bit bắt đầu: 0
Bit dữ liệu[0]: 1
Bit dữ liệu[1]: 0
Bit dữ liệu[2]: 0
Bit dữ liệu[3]: 0
Bit dữ liệu[4]: 0
Bit dữ liệu[5]: 0
Bit dữ liệu[6]: 1
Bit dữ liệu[7]: 0
Bit dừng: 1

Mô phỏng này tuy không thao tác GPIO thực tế, nhưng tái tạo hoàn chỉnh logic cấu trúc khung dữ liệu, có thể dùng để gỡ lỗi hoặc giảng dạy demo. Trong phát triển nhúng thực tế, logic này thường được module UART phần cứng tự động hoàn thành, nhà phát triển chỉ cần ghi vào bộ ghi dữ liệu.

2.1.3 Xử lý trạng thái rảnh và khoảng cách khung trong giao tiếp không đồng bộ

Trong giao tiếp không đồng bộ, "trạng thái rảnh" không chỉ là khoảng cách tự nhiên giữa các khung, mà còn là điều kiện quan trọng để duy trì sự ổn định truyền thông. Trong trạng thái rảnh, dây TX duy trì mức cao (logic 1), cho biết hiện không có dữ liệu đang truyền. Đầu thu sử dụng đặc tính này để xác định có đang ở giai đoạn chuẩn bị nhận khung mới hay không.

Khi gửi liên tục nhiều byte, bit dừng của khung trước kết thúc ngay lập tức bắt đầu bit bắt đầu của khung tiếp theo, không có khoảng cách bổ sung. Nhưng trong một số giao thức (như Modbus RTU), yêu cầu ít nhất 3.5 thời gian ký tự khoảng lặng giữa các khung, để phân biệt các thông báo tin nhắn khác nhau. Khoảng cách khung này phải được kiểm soát rõ ràng bằng phần mềm, nếu không sẽ dẫn đến việc đầu thu nhầm là sự tiếp nối của cùng một khung.

Với tốc độ baud 9600, một thời gian ký tự khoảng 10ms (10 bit × 104.17μs ≈ 1.04ms), do đó 3.5 thời gian ký tự tương ứng khoảng 3.64ms. Nếu gửi xong yêu cầu Modbus mà không delay, ngay lập tức gửi lệnh tiếp theo, phía bên kia có thể nhầm là một phần của cùng một phản hồi, dẫn đến thất bại kiểm tra CRC hoặc phân tích sai lệch.

Giải pháp cho vấn đề này bao gồm:

  • Sử dụng bộ định thời chính xác để kiểm soát độ trễ giữa các khung;
  • Chèn usleep() hoặc DelayMs() ở cuối hàm gửi;
  • Tận dụng cơ chế DMA + ngắt để gửi không chặn và tự động chèn khoảng cách.

Dưới đây là ví dụ hàm gửi với kiểm soát khoảng cách khung (dựa trên API Windows):

#include <windows.h>

BOOL SendWithInterFrameDelay(HANDLE hCom, const BYTE* buffer, DWORD len) {
    DWORD written;
    if (!WriteFile(hCom, buffer, len, &written, NULL)) {
        return FALSE;
    }
    // Tính toán độ trễ 3.5 ký tự (dùng cho Modbus RTU)
    // Giả sử tốc độ baud 9600, 10 bit/ký tự → ~1.04ms/ký tự
    // 3.5 * 1.04 ≈ 3.64ms → làm tròn lên 4ms
    Sleep(4);  

    return (written == len);
}
Tham số giải thích:
  • hCom: handle cổng nối tiếp đã mở;
  • buffer: bộ đệm dữ liệu cần gửi;
  • len: độ dài dữ liệu;
  • Sleep(4): chèn khoảng cách khung tối thiểu, tránh dán gói.

Hàm này sau khi gọi WriteFile thành công chủ động ngủ 4 mili giây, đảm bảo yêu cầu giao thức Modbus RTU. ỉ tốc độ baud cao hơn (như 115200), độ trễ này nên được tính toán động:

double char_time_us = 1000000.0 / baudrate * bits_per_char;
int inter_frame_delay_ms = (int)(3.5 * char_time_us / 1000 + 0.5);
Sleep(inter_frame_delay_ms > 1 ? inter_frame_delay_ms : 1);

Như vậy, quản lý hợp lý trạng thái rảnh và khoảng cách khung là khâu quan trọng để triển khai giao thức cổng nối tiếp phức tạp. Bỏ qua điểm này thường dẫn đến các sự cố truyền thông ngẫu nhiên, khó tái tạo và khó gỡ lỗi.

3. Thiết kế chức năng và phát triển mô-đun công cụ cổng nối tiếp

Trong các ứng dụng hệ thống nhúng và tự động hóa công nghiệp hiện đại, một công cụ giao tiếp cổng nối tiếp đầy đủ chức năng, cấu trúc rõ ràng không chỉ là phương pháp cốt lõi để gỡ lỗi thiết bị, mà còn là cơ sở hạ tầng quan trọng để đạt được tương tác dữ liệu ổn định. Khi độ phức tạp dự án tăng lên, việc sử dụng trực tiếp API để thao tác cổng nối tiếp không còn đáp ứng được yêu cầu khả năng bảo trì, mở rộng và trải nghiệm người dùng. Do đó, xây dựng một công cụ cổng nối tiếp có kiến trúc tốt, phân chia mô-đun rõ ràng và hệ thống xử lý lỗi hoàn thiện trở thành khâu then chốt trong quá trình phát triển. Phần này sẽ xoay quanh mục tiêu này, bắt đầu từ kiến trúc phần mềm tổng thể, phân tích sâu logic thiết kế và đường triển khai cho từng chức năng mô-đun, tập trung vào luồng khởi tạo, cơ chế nhận/gửi dữ liệu và hệ thống phản ứng bất thường, đảm bảo nhà phát triển có thể xây dựng nhanh chóng ứng dụng cổng nối tiếp có độ tin cậy cao, dễ mở rộng dựa trên khuôn khổ này.

3.1 Thiết kế kiến trúc tổng thể của phần mềm công cụ

Để đảm bảo công cụ giao tiếp cổng nối tiếp có khả năng đọc tốt, khả năng kiểm tra và khả năng bảo trì, phải sử dụng triết lý thiết kế kiến trúc phân tầng giải耦. Thiết kế này không chỉ giúp phát triển hợp tác nhóm mà còn thuận tiện cho việc mở rộng chức năng và gỡ lỗi sau này. Toàn bộ kiến trúc nên được chia thành ba phần cốt lõi: giao diện người dùng (UI), engine giao tiếp backend và tầng quản lý dữ liệu ở giữa, tạo thành cấu trúc ba tầng điển hình "tầng biểu hiện - tầng logic nghiệp vụ - tầng truy cập dữ liệu".

3.1.1 Thiết kế tách biệt giao diện người dùng và engine giao tiếp backend

Giao diện người dùng chịu trách nhiệm nhận đầu vào từ người dùng, hiển thị trạng thái giao tiếp và dữ liệu nhận/gửi, trong khi engine giao tiếp backend tập trung vào quản lý tài nguyên cổng nối tiếp, thiết lập tham số và thao tác IO tầng thấp. Hai tương tác này thông qua giao diện rõ ràng, tránh gọi trực tiếp API tầng thấp dẫn đến độ ghép nối cao.

Lợi thế của chế độ tách biệt này bao gồm:

  • Nâng cao khả năng di chuyển: Cùng một engine giao tiếp có thể được sử dụng cho các nền tảng UI khác nhau (như WinForms, WPF hoặc frontend Web);
  • Tăng khả năng kiểm thử: Có thể kiểm tra độc lập logic giao tiếp qua unit test, không cần phụ thuộc vào giao diện đồ họa;
  • Hỗ trợ giao tiếp không đồng bộ: Frontend không bị chặn, người dùng có thể tiếp tục thao tác các chức năng khác trong khi dữ liệu đang được truyền.

Ví dụ, trong môi trường C++ có thể sử dụng mẫu Quan sát (Observer Pattern), để các thành phần UI đăng ký làm người nghe sự kiện, khi nhận dữ liệu mới hoặc xảy ra lỗi, engine giao tiếp chủ động thông báo cho tất cả người đăng ký cập nhật trạng thái.

class ISerialPortListener {
public:
virtual void onDataReceived(const std::vector& data) = 0;
virtual void onStatusChanged(const std::string& status) = 0;
virtual void onErrorOccurred(const std::string& errorMsg) = 0;
};

Giải thích từng dòng mã:

  • Dòng 2: Định nghĩa lớp trừu tượng ISerialPortListener, làm lớp cơ sở cho tất cả người nghe;
  • Dòng 4: Khai báo hàm thuần ảo onDataReceived, dùng để nhận luồng dữ liệu nhị phân;
  • Dòng 5: onStatusChanged cung cấp thông báo thay đổi trạng thái kết nối;
  • Dòng 6: onErrorOccurred xử lý báo cáo bất thường.

Giao diện này cho phép nhiều thành phần UI cùng lúc nghe sự kiện cổng nối tiếp, phù hợp với nguyên tắc ghép nối lỏng.

Sơ đồ kiến trúc (Mermaid flowchart):
graph TD
A[Giao diện người dùng UI] -->|Gửi lệnh| B(Mô-đun điều khiển giao tiếp)
B --> C{Tầng điều khiển driver cổng nối tiếp}
C -->|Read/Write| D[(Hệ thống con cổng nối tiếp hệ điều hành)]
D --> E[Cổng COM vật lý]
C --> F[Quản lý bộ đệm]
F --> G[Bộ phân tích dữ liệu]
G --> H[Mô-đun ghi nhật ký]
B --> I[Bộ giám sát trạng thái]
I --> J[Logic thử lại khi timeout]
K[Thiết bị bên ngoài]  E
style A fill:#f9f,stroke:#333
style D fill:#bbf,stroke:#333,color:#fff

Giải thích: Sơ đồ luồng thể hiện chu trình hoàn chỉnh từ thao tác người dùng đến giao tiếp phần cứng. UI chỉ tương tác với mô-đun điều khiển giao tiếp, tất cả chi tiết tầng thấp được đóng gói dưới tầng driver, nâng cao an toàn và ổn định hệ thống.

3.1.2 Phân chia mô-đun: Khởi tạo, nhận/gửi dữ liệu, giám sát trạng thái, xuất nhật ký

Để đạt nguyên tắc trách nhiệm đơn lẻ và tính gắn kết cao, giải耦 thấp, toàn bộ công cụ cổng nối tiếp nên được chia thành bốn mô-đun chức năng chính:

Tên mô-đun Trách nhiệm chính Giao diện/Lớp chính
Mô-đun khởi tạo Mở cổng, thiết lập tham số, thiết lập liên kết giao tiếp Open(), ConfigureDCB()
Mô-đun nhận/gửi dữ liệu Triển khai đọc/viết không chặn, quản lý bộ đệm, chuyển đổi mã hóa Read(), Write(), OnReadThread()
Mô-đun giám sát trạng thái Giám sát trạng thái kết nối, mức tín hiệu đường dây, mã lỗi GetLineStatus(), IsConnected()
Mô-đun xuất nhật ký Ghi lại nội dung nhận/gửi, dấu thời gian, định hướng thông điệp LogData(), RotateLogFile()

Mỗi mô-đun nên được đóng gói thành lớp hoặc không gian tên độc lập, cung cấp phương thức công khai bên ngoài. Ví dụ, định nghĩa lớp SerialPortManager làm bộ điều khiển chính, điều phối công việc của các mô-đun con:

class SerialPortManager {
private:
HANDLE hCom;                    // Handle cổng nối tiếp
DCB dcb;                        // Khối điều khiển thiết bị
COMMTIMEOUTS timeouts;          // Thiết lập timeout
bool isOpen;

public:
bool Open(const std::string& portName, int baudrate);
void Close();
int Read(uint8_t* buffer, int maxSize);
int Write(const uint8_t* data, int length);
void SetHexMode(bool enabled);
std::string GetLastErrorString();
};

Giải thích tham số và phân tích mở rộng:

  • hCom: Handle hiệu quả do CreateFile() trả về, đại diện cho thiết bị cổng nối tiếp đã mở;
  • dcb: Cấu trúc chứa các tham số như tốc độ baud, bit dữ liệu, bit dừng, cần được viết vào qua SetCommState();
  • timeouts: Định nghĩa thời gian chờ tối đa cho thao tác đọc/viết, ngăn luồng bị chặn vô hạn;
  • isOpen: Cờ trạng thái, ngăn chặn mở hoặc đóng lặp lại;
  • Phương thức Open() cần kiểm tra tính hợp lệ của tên cổng (như "COM3"), và tự động ánh xạ sang định dạng \\.\COM3;
  • Read()Write() đóng gói API Windows ReadFileWriteFile, thêm kiểm tra lỗi;
  • SetHexMode() điều khiển định dạng hiển thị UI, ảnh hưởng đến xuất nhật ký nhưng không ảnh hưởng đến truyền tải thực tế.

Thiết kế lớp này thể hiện nguyên tắc đóng gói của lập trình hướng đối tượng, che chiếu chi tiết phức tạp của API Win32, cung cấp giao diện đơn giản dễ dùng cho tầng trên. Các chương tiếp theo sẽ triển khai chi tiết cơ chế bên trong.

3.2 Luồng hoàn chỉnh khởi tạo và đóng cổng nối tiếp

Bước đầu tiên của giao tiếp cổng nối tiếp là mở và cấu hình thành công cổng COM chỉ định. Vì cổng nối tiếp là tài nguyên độc quyền, nếu không được giải phóng đúng cách có thể dẫn đến không thể truy cập lần sau. Do đó, luồng khởi tạo và đóng phải tuân thủ nghiêm ngặt thứ tự gọi API được quy định bởi hệ điều hành, đảm bảo tài nguyên được lấy và trả về an toàn.

3.2.1 Chuỗi gọi hàm API mở cổng COM (CreateFile)

Trên nền tảng Windows, tất cả thiết bị đều được xử lý như đối tượng tệp, cổng nối tiếp cũng không ngoại lệ. Bằng cách gọi hàm CreateFile() có thể nhận được handle thao tác với cổng COM.

HANDLE hCom = CreateFile(
L"\\\\.\\COM3",                 // Tên cổng (lưu ý ký tự thoát)
GENERIC_READ | GENERIC_WRITE,   // Quyền đọc/viết
0,                              // Không cho phép chia sẻ
NULL,                           // Thuộc tính bảo mật mặc định
OPEN_EXISTING,                  // Phải đã tồn tại
FILE_ATTRIBUTE_NORMAL,          // Thuộc tính tệp thông thường
NULL                            // Không có tệp mẫu
);

Giải thích từng dòng logic:

  • Dòng 2: Sử dụng định dạng \\.\COM3 chỉ định cổng nối tiếp vật lý, tương thích với cổng lớn hơn COM9;
  • Dòng 3: Yêu cầu quyền đọc/viết, thiếu một trong hai thao tác sau sẽ thất bại;
  • Dòng 4: Đặt bằng 0 biểu thị không cho phép tiến trình khác cùng lúc mở cổng này;
  • Dòng 6: Phải sử dụng OPEN_EXISTING, vì cổng nối tiếp là thiết bị vật lý chứ không phải tệp mới tạo;
  • Dòng 7: Đặt thuộc tính thông thường; nếu cần thao tác không đồng bộ có thể thêm FILE_FLAG_OVERLAPPED;
  • Nếu trả về INVALID_HANDLE_VALUE, cần gọi GetLastError() để lấy mã lỗi cụ thể.

Các lỗi phổ biến bao gồm:

  • ERROR_ACCESS_DENIED: Cổng đã bị sử dụng;
  • ERROR_FILE_NOT_FOUND: Cổng không tồn tại hoặc tên sai;
  • ERROR_SHARING_VIOLATION: Phiên bản trước chưa đóng đúng cách.

Khuyên nên liệt kê danh sách cổng khả dụng trước khi mở để nâng cao trải nghiệm người dùng.

3.2.2 Cấu trúc DCB để viết tham số giao tiếp

Sau khi nhận được handle, phải thiết lập thông số giao tiếp qua cấu trúc Device Control Block (DCB). Dưới đây là ví dụ cấu hình điển hình:

DCB dcb = {0};
dcb.DCBlength = sizeof(DCB);
if (!GetCommState(hCom, &dcb)) {
// Xử lý lỗi
}

dcb.BaudRate = CBR_115200;          // Tốc độ baud
dcb.ByteSize = 8;                   // Bit dữ liệu
dcb.StopBits = ONESTOPBIT;          // Bit dừng
dcb.Parity   = NOPARITY;            // Bit kiểm tra
dcb.fBinary  = TRUE;                // Kích chế chế độ nhị phân
dcb.fDtrControl = DTR_CONTROL_ENABLE;// Kích hoạt tín hiệu DTR

if (!SetCommState(hCom, &dcb)) {
DWORD err = GetLastError();
// Hiển thị thông báo lỗi
}

Giải thích tham số:

  • BaudRate: Giá trị phổ biến như 9600, 19200, 115200, phải khớp với thiết bị;
  • ByteSize: Thường 7 hoặc 8 bit, tùy yêu cầu giao thức;
  • StopBits: Có thể chọn ONESTOPBIT, ONE5STOPBITS, TWOSTOPBITS;
  • Parity: EVENPARITY, ODDPARITY, MARKPARITY, v.v.;
  • fDtrControl: Kiểm soát trạng thái tín hiệu DTR, một số thiết bị cần tín hiệu này để thức dậy;
  • SetCommState() thất bại có thể do tham số không hợp lệ hoặc phần cứng không hỗ trợ.

Để tăng tính linh hoạt, có thể tổ chức các tham số này thành lớp cấu hình:

struct SerialConfig {
std::string portName;
int baudRate;
int dataBits;
int stopBits; // 1=1, 2=1.5, 3=2
int parity;   // 0=None, 1=Odd, 2=Even
bool useHardwareFlowControl;
};

Và tạo ra DCB tương ứng qua phương thức nhà máy.

3.2.3 Giải phóng tài nguyên và dọn dẹp handle khi đóng cổng

Luồng đóng phải tuân thủ nghiêm ngặt, nếu không sẽ dẫn đến rò rỉ handle hoặc khóa thiết bị.

void Close() {
if (hCom != INVALID_HANDLE_VALUE) {
PurgeComm(hCom, PURGE_TXCLEAR | PURGE_RXCLEAR); // Xóa sạch bộ đệm
EscapeCommFunction(hCom, CLRDTR);               // Kéo xuống DTR
CloseHandle(hCom);                              // Đóng handle
hCom = INVALID_HANDLE_VALUE;
isOpen = false;
}
}

Giải thích thứ tự thực hiện:

  • PurgeComm: Xóa hàng đợi gửi và nhận, ngăn chặn dữ liệu còn sót lại can thiệp vào kết nối lần sau;
  • EscapeCommFunction: Kiểm soát rõ ràng các tín hiệu RTS/DRT, mô phỏng hành vi "ngắt kết nối";
  • CloseHandle: Giải phóng tài nguyên hệ điều hành;
  • Cuối cùng đặt lại biến trạng thái, tránh thao tác sai.

Khuyên gọi logic đóng tự động trong hàm hủy hoặc cơ chế RAII, tránh bỏ quên.

3.3 Triển khai chức năng nhận và gửi dữ liệu cốt lõi

Nhận và gửi dữ liệu hiệu quả, ổn định là chức năng cốt lõi của công cụ cổng nối tiếp. Xem xét tốc độ truyền cổng nối tiếp thấp và tồn tại độ trễ, phải sử dụng phương thức không đồng bộ hoặc đa luồng để xử lý IO, tránh chặn luồng chính.

3.3.1 Sử dụng ReadFile/WriteFile cho thao tác IO không chặn

Windows cung cấp đồng bộ và không đồng bộ hai chế độ IO. Đối với chương trình GUI, khuyên dùng chế độ không đồng bộ kết hợp thông báo sự kiện.

// Ví dụ gửi không đồng bộ
OVERLAPPED ovlWrite = {0};
ovlWrite.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

BOOL result = WriteFile(
hCom,
sendData,
sendLength,
&bytesWritten,
&ovlWrite
);

if (!result && GetLastError() == ERROR_IO_PENDING) {
WaitForSingleObject(ovlWrite.hEvent, INFINITE);
GetOverlappedResult(hCom, &ovlWrite, &bytesWritten, FALSE);
}

Giải thích tham số:

  • Cấu trúc OVERLAPPED hỗ trợ IO chồng chéo;
  • hEvent dùng cho kích hoạt sự kiện;
  • Nếu ngay lập tức trả về ERROR_IO_PENDING, nghĩa là thao tác đang thực hiện ở nền;
  • WaitForSingleObject có thể thay bằng vòng lặp thông điệp, tránh đóng băng UI.

Thao tác đọc tương tự, nhưng cần khởi chạy luồng đọc chuyên dụng để liên tục giám sát:

DWORD WINAPI ReadThread(LPVOID lpParam) {
SerialPortManager* pMgr = (SerialPortManager*)lpParam;
uint8_t buffer[256];
DWORD bytesRead;

while (pMgr->IsOpen()) {
if (ReadFile(pMgr->hCom, buffer, 256, &bytesRead, NULL)) {
pMgr->NotifyDataReceived(buffer, bytesRead);
}
Sleep(10); // Ngăn chiếm dụng CPU quá cao
}
return 0;
}

Điểm thiết kế:

  • Sử dụng luồng độc lập để tránh chặn;
  • Sleep(10) kiểm soát tần suất lấy mẫu;
  • Thông báo UI cập nhật qua callback.

3.3.2 Quản lý bộ đệm nhận và chiến lược ghép nối dữ liệu

Vì cổng nối tiếp truyền dưới dạng luồng byte, một thông điệp hoàn chỉnh có thể bị chia thành nhiều lần nhận. Do đó cần giới thiệu cơ chế quản lý bộ đệm vòng (Ring Buffer) hoặc hàng động động.

class CircularBuffer {
private:
std::vector buf;
size_t head, tail;
bool full;

public:
void Put(uint8_t byte);
bool Get(uint8_t& byte);
size_t Size();
void Reset();
};

Kết hợp với thuật toán nhận diện biên khung (như gặp \r\n hoặc ký tự bắt đầu cụ thể 0x7E) để trích xuất thông điệp hoàn chỉnh.

3.3.3 Thiết kế tương tác người dùng chuyển đổi chế độ Hex/ASCII

Người dùng thường cần xem dưới dạng văn bản ASCII hoặc dữ liệu thô Hex. Có thể kiểm soát logic hiển thị qua hộp kiểm:

std::string FormatData(const uint8_t* data, int len, bool hexMode) {
if (hexMode) {
std::stringstream ss;
for (int i = 0; i < len; ++i) {
ss

Thẻ: serial-port c-plus-plus embedded-systems UART modbus

Đăng vào ngày 25 tháng 7 lúc 18:04