晟辉智能制造

C串口通信如何实现高效数据传输?

C 语言串口通信技术详解

串口通信是一种历史悠久但至今仍广泛应用的设备通信方式,它以其简单、可靠、易于实现的特点,在工业控制、嵌入式系统、仪器仪表、模块通信(如 GPS、GPRS、蓝牙模块)等领域扮演着重要角色。

C串口通信如何实现高效数据传输?-图1
(图片来源网络,侵删)

本指南将分为以下几个部分:

  1. 基础概念:理解串口通信的核心原理。
  2. 核心术语:波特率、数据位、停止位、校验位等。
  3. Linux 环境下的 C 语言编程:详细讲解如何使用 termios 结构体配置和使用串口。
  4. Windows 环境下的 C 语言编程:讲解如何使用 Windows API 操作串口。
  5. 一个完整的 C 语言示例:一个双向收发的测试程序。
  6. 高级应用与最佳实践:多线程、非阻塞 I/O、数据帧设计等。
  7. 常见问题与调试技巧

基础概念

串口通信,也称为串行通信,是指数据在单条数据线上,一位一位地按顺序进行传输,与并行通信(同时传输多位数据)相比,串口通信的速度较慢,但所需的线路少,成本低,适合长距离通信。

  • 全双工:可以同时进行数据的发送和接收。
  • 异步通信:通信双方没有共同的时钟信号,通过在数据帧中加入起始位和停止位来同步,这是串口通信最常见的形式。

物理连接

  • DB-9 接口:经典的串口连接器,定义了 9 个引脚,但实际上只使用了其中的 3 个(TXD, RXD, GND)进行基本通信。
    • TXD (Transmitted Data): 发送数据。
    • RXD (Received Data): 接收数据。
    • GND (Ground): 信号地线,用于提供共同的参考电位。
  • 电平标准
    • TTL (0V/5V):用于芯片与芯片之间的短距离通信。
    • RS-232 (-12V/+12V):用于设备间的较长距离通信(通常小于 15 米),需要电平转换芯片(如 MAX232)在 TTL 和 RS-232 之间转换。
    • RS-485 (差分信号):支持多点通信,传输距离更远(可达 1200 米),抗干扰能力更强。

核心术语

配置串口时,必须正确设置以下几个参数,否则通信将无法进行。

C串口通信如何实现高效数据传输?-图2
(图片来源网络,侵删)

a. 波特率

指每秒传输的比特数,是衡量通信速度的指标,发送方和接收方的波特率必须严格一致

  • 常见波特率:9600, 19200, 38400, 57600, 115200115200 是目前最常用的。

b. 数据位

指每个数据帧中实际包含的数据位数,通常是 8 位,也可以是 5, 6, 7 位,标准字符(如 ASCII)使用 8 位。

c. 停止位

用于表示一个数据帧的结束,可以是 1, 5, 或 2 位,现在大多使用 1 位。

d. 校验位

一种简单的错误检测机制,它根据数据位计算出一个校验位,附加在数据位之后。

C串口通信如何实现高效数据传输?-图3
(图片来源网络,侵删)
  • 无校验:不使用校验位。
  • 奇校验:确保整个数据帧(数据位 + 校验位)中 '1' 的个数为奇数。
  • 偶校验:确保整个数据帧中 '1' 的个数为偶数。
  • 标志位/空位:较少使用。

一个典型的串口配置115200, 8N1,表示波特率为 115200,8 个数据位,无校验,1 个停止位。


Linux 环境下的 C 语言编程

在 Linux 系统中,串口被当作一个普通的设备文件来操作,通常位于 /dev/ 目录下,/dev/ttyS0 (COM1), /dev/ttyUSB0 (USB转串口)。

操作流程与文件 I/O 操作非常相似:open() -> read()/write() -> close()

关键在于如何使用 termios 结构体来配置串口参数。

步骤详解:

包含头文件

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <termios.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/time.h>

打开串口 使用 open() 函数,以非阻塞模式打开是一个好习惯,这样如果串口被占用,程序会立即返回错误,而不是无限等待。

int fd;
fd = open("/dev/ttyUSB0", O_RDWR | O_NOCTTY | O_NDELAY); // O_NOCTTY: 不让串口成为终端 O_NDELAY: 非阻塞
if (fd < 0) {
    perror("Failed to open serial port");
    return -1;
}
// 恢复为阻塞模式
fcntl(fd, F_SETFL, 0);

配置串口参数 这是最核心的一步,通过修改 termios 结构体来实现。

struct termios options;
tcgetattr(fd, &options); // 获取当前串口配置
// 设置波特率
cfsetispeed(&options, B115200); // 设置输入波特率
cfsetospeed(&options, B115200); // 设置输出波特率
// 设置数据位、校验位、停止位
options.c_cflag &= ~CSIZE; // 清除数据位设置
options.c_cflag |= CS8;    // 设置为8位数据位
options.c_cflag &= ~PARENB; // 无校验
options.c_cflag &= ~CSTOPB; // 1位停止位
// 设置控制模式
options.c_cflag |= (CLOCAL | CREAD); // CLOCAL: 忽略调制解调器控制线 CREAD: 启用接收
// 设置原始输入模式
options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); // ICANON: 非规范模式,read()直接读取字节,而不是行
// 设置输出模式
options.c_oflag &= ~OPOST; // 原始输出
// 设置超时
options.c_cc[VTIME] = 10; // 读取每个字符的超时时间 (deciseconds, 1/10秒)
options.c_cc[VMIN] = 0;   // 设置为0时,read()立即返回,读取多少字节就返回多少
// 应用设置
if (tcsetattr(fd, TCSANOW, &options) != 0) {
    perror("Failed to set serial attributes");
    close(fd);
    return -1;
}

读写串口

  • read() 函数会阻塞,直到有数据到达或超时。
    char buffer[256];
    int n = read(fd, buffer, sizeof(buffer));
    if (n > 0) {
        buffer[n] = '\0'; // 确保字符串以 NULL 
        printf("Received: %s\n", buffer);
    }
  • write() 函数将数据写入串口输出缓冲区。
    char *msg = "Hello from C program!\n";
    write(fd, msg, strlen(msg));

关闭串口

close(fd);

Windows 环境下的 C 语言编程

在 Windows 中,串口操作通过 Windows API 完成,流程与 Linux 类似,但函数和结构体完全不同。

包含头文件和链接库

#include <windows.h>
#pragma comment(lib, "kernel32.lib") // 通常不需要显式链接,但写上更保险

打开串口 使用 CreateFile() 函数。

HANDLE hSerial;
hSerial = CreateFile("COM1",                  // 串口名
                     GENERIC_READ | GENERIC_WRITE, // 读写权限
                     0,                       // 不共享
                     NULL,                    // 默认安全属性
                     OPEN_EXISTING,           // 必须已存在
                     FILE_ATTRIBUTE_NORMAL,   // 同步I/O
                     NULL);                   // 无模板文件
if (hSerial == INVALID_HANDLE_VALUE) {
    DWORD error = GetLastError();
    printf("Error opening serial port: %d\n", error);
    return 1;
}

配置串口参数 使用 DCB (Device Control Block) 结构体。

DCB dcbSerialParams = {0};
dcbSerialParams.DCBlength = sizeof(dcbSerialParams);
// 获取当前DCB配置
if (!GetCommState(hSerial, &dcbSerialParams)) {
    printf("Error getting device state\n");
    CloseHandle(hSerial);
    return 1;
}
// 修改配置
dcbSerialParams.BaudRate = CBR_115200; // 波特率115200
dcbSerialParams.ByteSize = 8;          // 8位数据位
dcbSerialParams.StopBits = ONESTOPBIT; // 1位停止位
dcbSerialParams.Parity = NOPARITY;     // 无校验
// 应用设置
if (!SetCommState(hSerial, &dcbSerialParams)) {
    printf("Error setting device parameters\n");
    CloseHandle(hSerial);
    return 1;
}

配置I/O缓冲区和超时 使用 COMMTIMEOUTS 结构体。

COMMTIMEOUTS timeouts = {0};
timeouts.ReadIntervalTimeout = 50;          // 两字符间最大超时(毫秒)
timeouts.ReadTotalTimeoutConstant = 50;     // 总读取超时(毫秒)
timeouts.ReadTotalTimeoutMultiplier = 10;   // 每字节超时(毫秒)
timeouts.WriteTotalTimeoutConstant = 50;    // 总写入超时(毫秒)
timeouts.WriteTotalTimeoutMultiplier = 10;  // 每字节超时(毫秒)
SetCommTimeouts(hSerial, &timeouts);

读写串口

  • :使用 ReadFile()
    char buffer[256];
    DWORD bytesRead;
    if (ReadFile(hSerial, buffer, sizeof(buffer), &bytesRead, NULL)) {
        if (bytesRead != 0) {
            buffer[bytesRead] = '\0';
            printf("Received: %s\n", buffer);
        }
    }
  • :使用 WriteFile()
    char *msg = "Hello from Windows C program!\r\n";
    DWORD bytesWritten;
    WriteFile(hSerial, msg, strlen(msg), &bytesWritten, NULL);

关闭串口

CloseHandle(hSerial);

一个完整的 C 语言示例 (Linux 环境)

这个示例实现了一个简单的回环测试程序:发送一个字符串,然后接收并打印从另一端(或自发自收)返回的数据。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <termios.h>
#include <sys/types.h>
#include <sys/stat.h>
// 函数:初始化串口
int init_serial_port(const char *port_name, int baud_rate) {
    int fd = open(port_name, O_RDWR | O_NOCTTY | O_NDELAY);
    if (fd < 0) {
        perror("open serial port failed");
        return -1;
    }
    fcntl(fd, F_SETFL, 0); // 设置为阻塞模式
    struct termios options;
    tcgetattr(fd, &options);
    // 设置波特率
    cfsetispeed(&options, baud_rate);
    cfsetospeed(&options, baud_rate);
    // 8N1
    options.c_cflag &= ~CSIZE;
    options.c_cflag |= CS8;
    options.c_cflag &= ~PARENB;
    options.c_cflag &= ~CSTOPB;
    options.c_cflag |= (CLOCAL | CREAD);
    // 原始输入模式
    options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);
    // 原始输出模式
    options.c_oflag &= ~OPOST;
    // 超时设置: 读取立即返回,读取0个字节
    options.c_cc[VTIME] = 0;
    options.c_cc[VMIN] = 0;
    if (tcsetattr(fd, TCSANOW, &options) != 0) {
        perror("tcsetattr failed");
        close(fd);
        return -1;
    }
    return fd;
}
int main() {
    const char *port_name = "/dev/ttyUSB0";
    int baud_rate = B115200;
    int fd;
    fd = init_serial_port(port_name, baud_rate);
    if (fd < 0) {
        fprintf(stderr, "Exiting.\n");
        exit(EXIT_FAILURE);
    }
    printf("Serial port %s opened successfully.\n", port_name);
    char *send_msg = "AT\r\n"; // 一个简单的AT指令,用于测试
    char buffer[256];
    int bytes_sent, bytes_received;
    // 发送数据
    bytes_sent = write(fd, send_msg, strlen(send_msg));
    if (bytes_sent < 0) {
        perror("write failed");
    } else {
        printf("Sent %d bytes: %s", bytes_sent, send_msg);
    }
    // 等待一小段时间,让设备有时间响应
    usleep(500000); // 等待500毫秒
    // 接收数据
    bytes_received = read(fd, buffer, sizeof(buffer) - 1);
    if (bytes_received < 0) {
        perror("read failed");
    } else if (bytes_received == 0) {
        printf("No data received.\n");
    } else {
        buffer[bytes_received] = '\0'; // 确保字符串结束
        printf("Received %d bytes: %s\n", bytes_received, buffer);
    }
    close(fd);
    return 0;
}

高级应用与最佳实践

a. 多线程编程

在许多应用中,串口的发送和接收是两个独立的任务,使用多线程可以简化程序逻辑:

  • 主线程:负责业务逻辑,生成要发送的数据。
  • 发送线程:从主线程获取数据并发送。
  • 接收线程:持续监听串口,一旦有数据到达就读取并通知主线程。

示例框架

void *send_thread_func(void *arg) {
    int fd = *((int *)arg);
    while (1) {
        // 从队列或共享内存获取数据
        // write(fd, data, len);
    }
    return NULL;
}
void *receive_thread_func(void *arg) {
    int fd = *((int *)arg);
    char buffer[256];
    while (1) {
        int n = read(fd, buffer, sizeof(buffer));
        if (n > 0) {
            // 将接收到的数据放入队列或通知主线程
        }
    }
    return NULL;
}
// 在main函数中
pthread_t send_tid, recv_tid;
pthread_create(&send_tid, NULL, send_thread_func, &fd);
pthread_create(&recv_tid, NULL, receive_thread_func, &fd);
// ...
pthread_join(send_tid, NULL);
pthread_join(recv_tid, NULL);

b. 非阻塞 I/O

在 Linux 中,通过 fcntl(fd, F_SETFL, O_NONBLOCK); 可以将串口设置为非阻塞模式,这样 read()write() 调用会立即返回,而不是等待,这对于需要同时处理多个 I/O 源(如串口、网络、用户输入)的应用非常有用,通常与 select()poll() 函数配合使用。

c. 数据帧设计

直接发送裸文本(如 "Hello")在简单场景下可行,但在复杂环境中容易出错,通常需要设计自定义的数据帧协议。 一个健壮的数据帧通常包含:

  • 帧头:用于标识一帧的开始,0xAA 0x55
  • 长度:数据部分的长度,方便接收方知道要读取多少字节。
  • 命令/地址:用于区分不同的指令或设备。
  • 数据:有效载荷。
  • 校验和:如 CRC16 或简单的和校验,用于验证数据在传输中是否出错。
  • 帧尾:用于标识一帧的结束,0x0D 0x0A (回车换行)。

示例帧[0xAA, 0x55, 0x04, 0x01, 0x02, 0x03, 0x04, 0xXX, 0x0D, 0x0A]

  • 帧头: 0xAA, 0x55
  • 长度: 0x04 (数据有4个字节)
  • 命令: 0x01
  • 数据: 0x02, 0x03, 0x04
  • 校验和: 0xXX (是前面所有字节之和的低8位)
  • 帧尾: 0x0D, 0x0A

接收方需要编写状态机来解析这样的数据流,确保能正确识别帧头、完整读取数据,并校验数据的有效性。


常见问题与调试技巧

  1. 数据收发不正常/乱码

    • 首要检查:波特率、数据位、停止位、校验位是否完全一致,这是 90% 问题的根源。
    • 电平问题:确保设备间使用的是同一种电平标准(TTL vs RS-232),TTL 设备直接连接 RS-232 端口,需要电平转换芯片。
    • 交叉连接:确保一方的 TXD 连接到另一方的 RXDGND 连接到 GND
  2. 无法接收到数据

    • 硬件问题:检查线缆是否插好,是否有损坏。
    • 软件问题
      • 在 Linux 下,检查用户是否对该串口设备有读写权限 (/dev/ttyUSB0 可能属于 dialout 组)。
      • 检查 read() 函数的超时设置是否过短,导致数据还没来就读取超时了。
      • 使用 minicomscreen 等工具先手动测试串口,确认硬件和基本配置是通的。
  3. 调试工具

    • Linux: minicom, screen, cutecom (图形界面)。
    • Windows: 超级终端, SecureCRT, PuTTY, 串口调试助手
    • 硬件: USB to TTL/RS232/RS485 转换器 是必备工具,可以方便地将电脑的 USB 口转换为串口。示波器逻辑分析仪是诊断物理层问题的终极武器。

希望这份详尽的指南能帮助你全面掌握 C 语言下的串口通信技术!

分享:
扫描分享到社交APP
上一篇
下一篇