USB 串行端口编程有 "disastrous" 个结果

USB Serial port programming has "disastrous" results

我目前正在 Raspberry Pi 3 (Linux Ubuntu) 上开发 C 程序 运行,旨在提供用于配置网络的网页界面在嵌入式系统上。

正在使用 Code::Blocks 和 GDB 调试器开发代码。我在 Web 服务器上使用 microhttpd,加上各种网页,都运行良好。我现在正在使用 "Serial Programming Guide for POSIX Operating Systems".

中的信息将 USB 串行 link 连接到嵌入式系统

下面的代码负责打开 USB 串行 link 到目标系统并且似乎工作正常 - 一次。如果我关闭程序并重新启动它(在命令行上独立运行或从 Code::Blocks 中重新启动)第二次 microhttpd 被清理 - 浏览器 windows 将不再连接。此外,在 Code::Blocks 中,调试器也被控制——一旦程序启动,它就不能暂停或停止。唯一的办法就是通过关闭项目来杀死它。

问题显然出在函数中,因为我可以注释掉对它的调用,一切都像以前一样工作。不幸的是,一旦问题发生,唯一的解决办法似乎就是重启树莓派。

我在使用脚本语言 (Tcl) 之前做过类似的事情,但这次我正在寻找一种非解释性语言的性能提升,因为 Pi 也将是 运行通过类似 USB 串行接口的高带宽数据记录程序。

代码如下:

/******************************************************************************/
/* This function scans through the list of USB Serial ports and tries to      */
/* establish communication with the target system.                            */
/******************************************************************************/

void tapCommInit(void) {
    char line[128];
    char port[15];   // this is always of the form "/dev/TTYACMn"
    char *ptr; 
    FILE *ifd;
    struct termios options;
    uint8_t msgOut[3], msgIn[4];

    msgOut[0] = REQ_ID;                       // now prepare the message to send
    msgOut[1] = 0;                                  // no data so length is zero
    msgOut[2] = 0;


    /**************************************************************************/
    /* First, get the list of USB Serial ports.                               */
    /**************************************************************************/

    system("ls -l /dev/serial/by-path > usbSerial\n");  // get current port list
    ifd = fopen("usbSerial", "r");
    logIt(fprintf(lfd, "serial ports: \n"));


    /**************************************************************************/
    /* The main loop iterates through the file looking for lines containing   */
    /* "tty" which should be a valid USB Serial port.  The port is configured */
    /* in raw mode as 8N1 and an ID request command is sent, which has no     */
    /* data.  If a response is received it's checked to see if the returned   */
    /* ID is a match.  If not, the port is closed and we keep looking.  If a  */
    /* match is found, tapState is set to "UP" and the function returns.  If  */
    /* no match is found, tapState is left in the initial "DOWN" state.       */
    /**************************************************************************/

    while(1) {
        if (fgets(line, 127, ifd) == NULL) {                     // end of file?
            break;                                 // yes - break out and return
        }
        ptr = strstr(line, "tty");  // make sure the line contains a valid entry
        if (ptr == NULL) {
            continue;                         // nothing to process on this line
        }
        strcpy(port, "/dev/");                      // create a correct pathname 
        strcat(port, ptr);              // append the "ttyACMn" part of the line
        port[strlen(port)-1] = 0; // the last character is a newline - remove it
        logIt(fprintf(lfd,"  %s\n", port));     // we have a port to process now
        cfd = open(port, O_RDWR | O_NOCTTY | O_NDELAY);   // cfd is a global int
        if (cfd == -1) {
            logIt(fprintf(lfd, "Could not open port: %s\n", port));
            continue;                   // keep going with the next one (if any)
        }
        fcntl(cfd, F_SETFL, 0);                                 // blocking mode
        tcgetattr(cfd, &options);               // get the current port settings
        options.c_cflag |= (CLOCAL | CREAD); // ena receiver, ignore modem lines
        options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);      // raw, no echo
        options.c_oflag &= ~OPOST;               // no special output processing
        options.c_cc[VMIN] = 0;         // minimum number of raw read characters
        options.c_cc[VTIME] = 10;   // timeout in deciseconds (1 second timeout)
        tcsetattr(cfd, TCSANOW, &options);              // set options right now
        cfsetispeed(&options, B115200);                       // input baud rate
        cfsetospeed(&options, B115200);                      // output baud rate
        options.c_cflag &= ~(CSIZE | PARENB |      // clear size bits, no parity
          CSTOPB | CRTSCTS);                   // 1 stop bit, no hw flow control
        options.c_cflag |= CS8;                // now set size: 8-bit characters
        options.c_cflag &= ~(IXON | IXOFF | IXANY);        // no sw flow control

        if (write(cfd, msgOut, 3) < 3) {
            logIt(fprintf(lfd, "Sending of output message failed\n"));
            close(cfd);
            continue; 
        }
        if (read(cfd, msgIn, 4) != 4) {
            logIt(fprintf(lfd, "Didn't get expected amount of return data\n"));
            close(cfd);
            continue; 
        }
        if (msgIn[3] != HOST_ID) {
            logIt(fprintf(lfd, "Got the wrong HOST_ID response\n"));
            close(cfd);
            continue; 
        }
        logIt(fprintf(lfd, "Port found - communication established\n"));
        tapState = UP;
        break;                             // we're done - break out of the loop
    }
    fclose(ifd);                         // close and remove the file we created
    remove("usbSerial");
}

from within Code::Blocks the debugger is also hosed - once the program is started it cannot be paused or stopped

您不了解您的工具的可能性远远大于您创建了一个无法杀死的程序。

解决这个问题很容易:分而治之。这里有一大堆不相关的组件。开始将它们分开,找出哪些部分在孤立状态下工作良好,哪些在与其他所有部分断开连接时继续表现不佳。那你就有罪魁祸首了。

具体在这里,这意味着尝试 运行 在 IDE 之外运行您的程序,然后在命令行下 gdb 而不是 GDB 通过 IDE.

此外,应该可以 运行 您的程序而不启动 Web 服务器部分,这样您就可以 运行 隔离应用程序的串行部分。这不仅有利于通过最小化混杂变量进行调试,还鼓励松散耦合的程序设计,这本身就是一件好事。

最后,您可能会发现阻止您的程序停止的是 Web 框架,Code::Blocks,或者 GDB 在 Code::Blocks 下的 Pi 上运行的方式,而不是任何东西使用 USB 转串口适配器。

once the problem happens the only solution seems to be to reboot the Pi

如果您的程序仍在后台运行 运行,那么您的下一个实例当然会在尝试打开同一个 USB 端口时失败。

不用猜,找出来:

$ sudo lsof | grep ttyACM

或:

$ lsof -p $(pidof myprogram)

(如果您的系统没有 pidof,请替换为 pgrep。)

I've done things like this before using a scripting language (Tcl) but this time around I'm looking for a performance boost from a non-interpreted language

您的串行端口 运行ning 为 115,200 bps。将其除以 10 以计算停止位和起始位,然后翻转分数以获得每个字节的秒数,得到每个字节 87 微秒。你只有在串行端口 运行ning 全力以赴,每秒发送或接收 11,500 字节时才能实现。想猜猜 Tcl 在 87 微秒内可以解释多少行代码? Tcl 不是超快,但即使在 Tcl 领域,87 微秒也是永恒的。

然后在连接的另一端,您有 HTTP 和 [W]LAN,每个事务可能会再增加一百毫秒左右的延迟。

你对速度的需求是一种错觉。

现在当你需要异步地与其中的 100 个对话时回来再和我谈谈,然后也许我们可以开始证明 C 优于 Tcl。

(我说这是因为他的日常工作涉及维护一个大型 C++ 程序,该程序执行大量串行和网络 I/O。)

现在让我们来看看这段代码的许多问题:

system("ls -l /dev/serial/by-path > usbSerial\n");  // get current port list
ifd = fopen("usbSerial", "r");

不要在管道就足够的地方使用临时文件;在这里使用 popen()

while(1) {

这是完全错误的。在此处输入 while (!feof(ifd)) {,否则您将尝试读取文件末尾。

这个加上下一个错误,可能是您主要症状的关键。

if (fgets(line, 127, ifd) == NULL) { 
    break;

这里有几个问题:

  1. 您假设文档中未遵循的 return 值的含义。 The Linux fopen(3) man page isn't super clear on this; the BSD version 更好:

    The fgets() and gets() functions do not distinguish between end-of-file and error, and callers must use feof(3) and ferror(3) to determine which occurred.

    因为 fgets() 是标准 C,而不是 Linux- 或 BSD 特定的,通常可以安全地查阅其他系统的手册页。更好的是,查阅一个很好的通用 C 参考资料,例如 Harbison & Steele。 (当我做纯 C 而不是 C++ 时,我发现它比 K&R 有用得多。)

    最重要的是,简单地检查 NULL 并不能告诉您这里需要知道的一切。

  2. 其次,如果您缩小 line 缓冲区的大小,硬编码的 127 常量是一个等待爆炸的代码炸弹。在这里说sizeof(line)

    (不,不是 sizeof(line) - 1fgets() 在阅读时留下 space 作为尾随的空字符。再一次,RTFM 仔细。)

  3. break也是一个问题,但我们必须进一步深入代码才能了解原因。

继续:

strcat(port, ptr);              // append the "ttyACMn" part of the line

这里有两个问题:

  1. 你是在盲目地假设 strlen(ptr) <= sizeof(port) - 6。请改用 strncat(3)

    (前一行的 strcpy()(与 strncpy() 相对)是合理的,因为您正在复制字符串文字,所以您可以看到您还没有结束 运行宁缓冲区,但你应该养成假装不检查长度的旧 C 字符串函数甚至不存在的习惯。如果你提高警告级别,一些编译器在你使用它们时实际上会发出警告。 )

    或者,更好的是,放弃 C 字符串,开始使用 std::string。我看得出来你在努力坚持使用 C,但 C++ 中确实有值得使用的东西,即使你主要使用 C。C++ 的自动内存管理工具(不仅是 string,还有 auto_ptr/unique_ptr 和更多)属于这一类。

    此外,C++ 字符串的操作更像 Tcl 字符串,因此您可能会更熟悉它们。

  2. 评论中的事实断言必须始终是真实的,否则它们以后可能会误导您,这可能是危险的。您的特定 USB 转串口适配器可能使用 /dev/ttyACMx,但并非所有人都使用。还有另一种常见的 USB device class 被某些串口转 USB 适配器使用,导致它们在 Linux 下显示为 ttyUSBx。更一般地说,将来的更改可能会以其他方式更改设备名称;例如,您可能会移植到 BSD,现在您的 USB 转串口设备被称为 /dev/cu.usbserial,耗尽了您的 15 字节 port 缓冲区。 不要假设。

    即使不考虑 BSD 情况,您的 port 缓冲区不应小于 line 缓冲区,因为您将后者连接到前者。至少,sizeof(port) 应该是 sizeof(line) + strlen("/dev/"),以防万一。如果这看起来过多,那只是因为 128 字节的行缓冲区不必要地大。 (并不是说我想扭转你的手臂来改变它。RAM 很便宜;程序员调试时间很昂贵。)

下一个:

fcntl(cfd, F_SETFL, 0);                                 // blocking mode

文件句柄在 Unix 中默认是阻塞的。您必须询问 非阻塞文件句柄。无论如何,炸毁所有标志是不好的作风;您不知道要在此处更改的 other 标志是什么。正确的风格是获取、修改,然后设置,就像您使用 tcsetattr():

的方式一样
int flags;
fcntl(cfd, F_GETFL, &flags);
flags &= ~O_NONBLOCK;
fcntl(cfd, F_SETFL, flags);

嗯,你有点正确使用tcsetattr()

tcsetattr(cfd, TCSANOW, &options); 

...随后对 options 进行进一步修改,而无需再次调用 tcsetattr()。糟糕!

您不会认为对 options 结构的修改会立即影响串行端口,是吗?

if (write(cfd, msgOut, 3) < 3) {
    logIt(fprintf(lfd, "Sending of output message failed\n"));
    close(cfd);
    continue; 
}

此处错误成堆:

  1. 您正在折叠短写和错误情况。分别处理:

    int bytes = write(cfd, msgOut, 3);
    if (bytes == 0) {
        // can't happen with USB, but you may later change to a
        // serial-to-Ethernet bridge (e.g. Digi One SP), and then
        // it *can* happen under TCP.
        //
        // complain, close, etc.
    }
    else if (bytes < 0) {
        // plain failure case; could collapse this with the == 0 case
        // close, etc
    }
    else if (bytes < 3) {
        // short write case
    }
    else {
        // success case
    }
    
  2. 您没有记录 errno 或其等效字符串,因此当 (!) 出现错误时,您将不知道 哪个 错误:

    logIt(fprintf(lfd, "Sending of output message failed: %s (code %d)\n",
               strerror(errno), errno));
    

    修改口味。只要意识到 write(2) 和大多数其他 Unix 系统调用一样,有一大堆可能的错误代码。您可能不想以相同的方式处理所有这些问题。 (例如 EINTR

  3. 关闭 FD 后,您将其设置为有效的 FD 值,因此在读取一行后的 EOF 上,您将函数保留为有效但关闭的 FD 值! (这是上面 break 的问题:它可以隐式 return 一个关闭的 FD 给它的调用者。)在每次 close(cfd) 调用后说 cfd = -1

上面写的关于 write() 的所有内容也适用于以下 read() 调用,而且:

if (read(cfd, msgIn, 4) != 4) {

POSIX 中没有任何内容告诉您如果串行设备发送 4 个字节,您将在单个 read() 中获得所有 4 个字节,即使是阻塞 FD。使用慢速串行端口时,每个 read() 获得的字节数尤其不可能超过一个字节,这仅仅是因为与串行端口相比,您的程序快如闪电。您需要在此处循环调用 read(),仅在出错或完成时退出。

以防万一它不明显:

remove("usbSerial");

如果切换到上面的 popen(),则不需要它。不要将临时工作文件散布在管道可以执行的文件系统周围。