ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Ubuntu串口编程实战:ttyS0/ttyUSB0与termios配置

Ubuntu串口编程实战:ttyS0/ttyUSB0与termios配置 简介LinuxUbuntu平台下的串口通信应用例程源码包面向嵌入式Linux开发和物联网设备调试人员帮助解决在Ubuntu系统中快速完成串口端口打开、波特率设置、数据读写与收发流程的工程实现需求。例程代码结构精简包含发送端与接收端两个程序模块并对串口操作接口进行了统一封装同时提供对应的编译脚本便于直接编译运行或迁移到自己的项目中。两个程序分别覆盖数据发送与接收的完整流程并演示串口初始化、打开、读写与关闭等核心操作代码量少但思路清晰。整个压缩包共8个文件以C源码、头文件与Makefile编译脚本为主资源包大小仅为6KB适合串口编程入门学习与实际工程参考。目前已有1481人学习下载经过众多开发者检验对于在Ubuntu下需要对接ttyS串口或USB转串口设备的场景尤其适用。1. Ubuntu 下串口编程第一关ttyS0 和 ttyUSB0 该用哪个同样的串口代码在 Windows 上用 CreateFile 和 ReadFile 能跑挪到 Ubuntu 上以后程序往往不是死在设备打不开而是死在分不清 /dev/ttyS0、/dev/ttyUSB0、/dev/ttyACM0 各自的打开方式。这套 LinuxUbuntuCOM 串口应用例程源码把 reader、writer 单独拆成两个可执行文件中间用 uart_api.c 统一封装打开、配置、收发操作并附带 Makefile解决的就是从头搓串口工程的效率问题。适合两类人一类是在 Ubuntu 上写嵌入式工具链的 C 开发和运维另一类是刚接触 Linux 串口、想用最小代码量把 COM 口跑通的初学者。源码里的 open_port 函数把设备名和端口号做了映射后续调设备类型只需要改编译期宏不用在每个读写分支里重复判断设备节点。2. 串口设备节点的命名规则与 open_port 参数设计在 Linux 下串口被抽象成字符设备所有操作都围绕文件描述符展开。例程中的 uart_api.h 暴露了 open_port、read、write 等封装而 uart_api.c 里的 open_port 做的第一件事不是把 fd 打开而是根据编译期宏决定设备名数组。这样做的直接好处是主板上原生 COM 口和 USB 转串口芯片在 Ubuntu 上根本不是一类设备节点用同一套 open 逻辑去套会把代码底层细节泄漏到业务层。2.1 为什么 .c 文件里要区分 GNR_COM 和 USB 转串口设备类型决定了设备节点名称主板原生串口由 8250/16550 UART 驱动USB 转串口则依赖 CH340、CP2102、FT232 这类芯片的驱动两者暴露的设备名完全不同。例程开头用COM_TYPE宏切换避免在每次调用时都判断设备类型也让读者一眼就能看出这套源码面向的是“一般串口”还是“USB 转串口”。设备类型设备节点典型来源驱动主板 COM 口/dev/ttyS0, /dev/ttyS1主板串口、PCIe 串口卡8250/16550USB 转串口/dev/ttyUSB0, /dev/ttyUSB1CH340、CP2102、FT232usb-serial/ch341-uart蓝牙 SPP/dev/rfcomm0蓝牙串口适配器rfcomm虚拟调试口/dev/ttyS0VMware 虚拟串口、开发板虚拟串口virtio-console这里有个容易被忽略的点VMware 虚拟机里给 Ubuntu 添加“串行端口”后设备通常是 /dev/ttyS0不是 /dev/ttyUSB0只有把 USB 转串口线在虚拟机 USB 控制器里连接成功后才会出现 ttyUSB0。如果例程编译时选了 GNR_COM 却插着 USB 线open 就会提示 No such file or directory。在 Ubuntu 物理机上排查时先ls /dev/tty*看设备节点再用dmesg | grep tty看内核识别结果不要一上来改代码。2.2 open_port 里每一行 flag 为什么这样写看例程中的 open_port 实现/* 打开串口函数返回 fdcom_port 从 1 开始编号 */ int open_port(int com_port) { int fd; #if (COM_TYPE GNR_COM) char *dev[] {/dev/ttyS0, /dev/ttyS1, /dev/ttyS2}; #else char *dev[] {/dev/ttyUSB0, /dev/ttyUSB1, /dev/ttyUSB2}; #endif if ((com_port 0) || (com_port MAX_COM_NUM)) { return -1; } fd open(dev[com_port - 1], O_RDWR|O_NOCTTY|O_NDELAY); if (fd 0) { perror(open serial port); return -1; } /* 恢复串口为阻塞状态 */ if (fcntl(fd, F_SETFL, 0) 0) { perror(fcntl); return -1; } return fd; }这段代码的关键在三个地方。第一com_port从 1 开始编号数组下标是com_port - 1所以端口 1 映射到 ttyS0端口 2 映射到 ttyS1。如果调用方传入 0就会访问 dev[-1]因此com_port 0的边界检查必须放在数组下标使用之前。第二O_RDWR表示读写打开O_NOCTTY防止串口成为控制终端避免程序在后台运行时被终端信号干扰。第三O_NDELAY在打开时不等待 DCD 信号这对没有接线的 USB 转串口尤其重要随后用fcntl(fd, F_SETFL, 0)清掉非阻塞标志让后续 read 恢复为阻塞语义也就是读到数据才返回而不是立刻返回 0。这里要强调一下open 时的 O_NDELAY 和 fcntl 后的阻塞恢复是两个阶段。如果不恢复read 在无数据时会一直返回 -1 或 0很多人误判为“串口没数据”其实是把非阻塞模式带到了数据读取阶段。例程这样写既避免了 open 时因 modem 信号未就绪而卡住又保证了后续读操作能正常等数据。2.3 设备节点不存在和权限拒绝怎么区分如果 open_port 返回 -1perror 会直接打出原因。最常见的两种情况No such file or directory说明设备节点不存在或驱动没加载Permission denied说明当前用户不在 dialout 组。Ubuntu 默认把串口设备归属于 dialout 组普通用户直接 open 会失败。排查时按顺序执行以下命令ls -l /dev/ttyS0 /dev/ttyUSB0 dmesg | grep -E ttyS|ttyUSB|ch341|ftdi_sio groups第一条命令确认节点是否存在第二条确认 USB 转串口是否被内核识别第三条确认当前用户组。如果 dmesg 输出出现ch341-uart ttyUSB0: ch341-uart converter now disconnected说明设备被拔掉或接触不良如果什么都不输出就要检查 USB 线是否只供电没有数据线。再深入一点可以用udevadm info -q path -n /dev/ttyUSB0查看设备在 sysfs 中的完整路径确认它挂在哪条 USB 总线上。这个排查顺序对 Linux 运维场景同样适用不只是 C 串口开发。3. 用 termios 把 uart_api 的波特率、校验位和超时调成可用状态打开 fd 只是第一步串口能不能按预期波特率收发取决于 uart_api.c 里的 termios 配置。Windows 串口 API 直接用 DCB 结构体一次性设置波特率、数据位和校验位Ubuntu 的 termios 则要求用位运算把 c_cflag 逐位组合出来。很多例程在 Windows 下能用到了 Ubuntu 上乱码原因不是 open 失败而是 termios 的结构体字段没清干净。3.1 termios 配置的三个固定步骤串口参数配置必须按照“先读、再改、后写”的顺序。先调用 tcgetattr 把当前驱动参数读进 struct termios然后修改 c_iflag、c_oflag、c_cflag、c_lflag最后调用 tcflush 清空缓冲再用 tcsetattr 把新参数应用。直接 memset 一个空结构体再赋值会丢失内核自带的默认值在某些内核版本上反而会导致串口行为异常。#include termios.h #include string.h int uart_set_opt(int fd, int baud) { struct termios tio; speed_t speed; switch (baud) { case 9600: speed B9600; break; case 19200: speed B19200; break; case 57600: speed B57600; break; case 115200: speed B115200; break; default: speed B115200; break; } if (tcgetattr(fd, tio) ! 0) return -1; /* 原始模式关闭规范处理、关闭回显、关闭信号转换 */ cfmakeraw(tio); tio.c_cflag | CLOCAL | CREAD; tio.c_cflag ~CSTOPB; tio.c_cflag ~CSIZE; tio.c_cflag | CS8; cfsetispeed(tio, speed); cfsetospeed(tio, speed); tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, tio) ! 0) return -1; return 0; }上面的代码以cfmakeraw为基础把串口设置成原始模式输入不经过 CANON 规范处理输出不经过 OPOST 转换回显关闭信号字符关闭。这样收到的字节就是设备真正发出来的字节不会被终端驱动改写。之后再重新打开CLOCAL和CREAD前者忽略 modem 控制线路后者使能接收器这两个 flag 在大多数串口通信中必须存在。波特率用了cfsetispeed和cfsetospeed分别设置避免某些 USB 转串口芯片对输入输出波特率处理不一致。3.2 数据位、停止位、校验位映射表termios 的 c_cflag 字段没有“一行指定 8N1”的写法必须通过位运算组合。下面这张表可以直接用到 uart_api 里参数c_cflag 操作8 数据位tio.c_cflag ~CSIZE; tio.c_cflag7 数据位tio.c_cflag ~CSIZE; tio.c_cflag1 位停止位tio.c_cflag ~CSTOPB;2 位停止位tio.c_cflag无校验tio.c_cflag ~PARENB;偶校验tio.c_cflag奇校验tio.c_cflag注意 CSIZE 掩码包含 CS5、CS6、CS7、CS8 四个位只有先清掉 CSIZE再置 CS8 才能保证数据位是 8。很多人只写tio.c_cflag | CS8;不写 ~CSIZE结果内核里残留的 CS7 位把 8 位清了一部分设备端收到的就是乱码。校验位同理先保证 PARENB 正确再通过 PARODD 区分奇偶。如果是硬件流控场景还需要tio.c_cflag | CRTSCTS;但这个 flag 对 CH340 这类 USB 转串口经常不生效因为底层驱动没有真正把 RTS/CTS 引脚引出来调试时别一上来就开硬件流控。3.3 VMIN、VTIME 与串口读超时的配合termios 配置完成后read 的阻塞时长由 c_cc[VMIN] 和 c_cc[VTIME] 决定。VMIN 表示 read 返回前需要读取的最小字节数VTIME 是超时时间单位 0.1 秒取值 0-255。常见组合有两种/* 方式一等至少 1 字节否则一直阻塞 */ tio.c_cc[VMIN] 1; tio.c_cc[VTIME] 0; /* 方式二最多等 1 秒没数据也返回 0 */ tio.c_cc[VMIN] 0; tio.c_cc[VTIME] 10;方式一适合帧长固定、触发条件明确的设备比如 GPS 模块每 100ms 输出一条 NMEA 语句方式二适合不知道对端何时发数据的场景read 返回 0 后程序可以去做其他事避免线程被永久卡死。如果例程里读串口用 while 循环建议把 VMIN 设为 1VTIME 设为 5这样既能抗住偶发空转又不会让 CPU 在无数据时空转。注意 VTIME 只在 VMIN 为 0 时才是纯超时VMIN 非 0 时 VTIME 会变成“收到第一个字节后的字节间隔超时”这个细节经常让人误判数据帧被截断。4. com_reader 与 com_writer 的编译、Makefile 和回环测试整套例程把发送和接收拆成两个独立程序优点是调试时可以各跑一个终端不用在同一个进程里处理线程和事件循环。Makefile 提供两个目标源码包里每个程序都包含 uart_api.h、com_reader.c 或 com_writer.c 以及对应的 Makefile。这样拆分还有个额外好处先单独验证接收路径再单独验证发送路径问题能快速定位到具体某一半。4.1 例程文件结构与 Makefile 的编译目标目录中可见的文件是固定的com_reader.c、com_writer.c、uart_api.c、uart_api.h、Makefile。实际编译时reader 和 writer 都依赖 uart_api.c所以 Makefile 不单独生成 .o而是直接把源文件编进两个可执行文件。工程规模小的时候这样最直接省去维护静态库的成本。CC ? gcc CFLAGS ? -Wall -g -O2 TARGET_READER com_reader TARGET_WRITER com_writer all: $(TARGET_READER) $(TARGET_WRITER) $(TARGET_READER): com_reader.c uart_api.c uart_api.h $(CC) $(CFLAGS) -o $ com_reader.c uart_api.c $(TARGET_WRITER): com_writer.c uart_api.c uart_api.h $(CC) $(CFLAGS) -o $ com_writer.c uart_api.c clean: rm -f $(TARGET_READER) $(TARGET_WRITER) .PHONY: all clean这里的依赖关系把 uart_api.h 也列进去了头文件改动后会触发对应目标重新编译。-g是为了能用 gdb 单步跟踪-O2能让编译结果更接近线上效果。如果环境里没有 Makefile直接执行gcc -o com_reader com_reader.c uart_api.c也能得到同样的二进制但此时要自己保证头文件路径和宏定义一致。4.2 编译、权限和启动参数在 Ubuntu 终端里依次执行make clean make第一次编译如果报fatal error: termios.h: No such file or directory说明缺少 libc6-dev 头文件包执行sudo apt install libc6-dev即可。编译完成后先确认当前用户能访问串口设备sudo usermod -aG dialout $USER newgrp dialout idusermod -aG dialout把当前用户加进 dialout 组newgrp dialout让组权限立即生效免去注销重登。这一步做完后运行例程不一定要加 sudo能用普通用户跑起来才能看到例程里的 perror 打印是否真正被处理过。如果例程的宏定义写成#ifndef COM_TYPE也可以直接在 make 时传宏make CFLAGS-Wall -g -O2 -DCOM_TYPE0把编译目标切成 USB 转串口设备节点。然后按例程入口启动 reader 或 writer。按 open_port 的设计端口号从 1 开始./com_reader 1会打开 /dev/ttyS0./com_reader 0会被边界检查拦下。如果设备是 USB 转串口要把 uart_api.h 里的COM_TYPE改成非 GNR_COM 分支重新 make。4.3 用一根杜邦线做回环测试回环测试是最可靠的串口验证方式。把 USB 转串口模块的 TXD 和 RXD 用杜邦线短接然后开两个终端# 终端 A等待接收 ./com_reader 1 # 终端 B发送一帧 ./com_writer 1 hello ubuntu serial如果 reader 打印出hello ubuntu serial说明 open、termios、read、write 整条链路都是通的。没有硬件线时也可以用虚拟机里映射的两个串口互相发或者把 /dev/ttyS0 和 /dev/ttyS1 用交叉线连起来。还有一种纯软件验证方案用 socat 创建一对虚拟串口sudo apt install socat socat -d -d pty,raw,echo0 pty,raw,echo0socat 会输出两个 /dev/pts/N 设备把 reader 和 writer 分别指向这两个伪终端就能在没有硬件的情况下验证代码逻辑。raw在这里等价于 termios 原始模式echo0防止字符被重复回显。这个方案我一般用来做 CI 冒烟测试效果很接近真实串口。4.4 运行时报错和乱码的快速判断回环测试失败时先对照表格定位不要盲目改代码现象可能原因处理open serial port: Permission denied用户不在 dialout 组usermod -aG dialoutopen serial port: No such file or directory设备节点不存在dmesg 检查驱动read 一直不返回VMIN 非 0 且对端未发数据改成 VMIN0 VTIME收到乱码波特率、数据位、停止位不一致用 stty 查看实际参数writer 发送后 reader 收到重复数据硬件 TX 与 RX 没短接或使用了回环线检查杜邦线连接故障排查时用stty -F /dev/ttyS0 -a可以查看内核侧当前实际生效的波特率和标志位。比如显示speed 115200 baud;就说明波特率设进去了显示cs8 -cstopb -parenb说明是 8N1。这个命令对验证 uart_api.c 里的 tcsetattr 是否生效很有用尤其是怀疑驱动或 USB 转串口芯片把参数改掉的时候。5. 用 strace 和 stty 反查例程的串口参数是否生效最后说一个调试串口例程的高阶技巧用 strace 看系统调用层。termios 配置最终会变成 ioctl 调用运行时的 open 标志、波特率、VMIN/VTIME 都能从 strace 输出里直接看到。首先确保程序按前台模式运行。如果系统没有 strace先sudo apt install stracestrace -f -e traceopenat,ioctl,read,write -s 64 ./com_reader 1输出里会有一行类似这样的记录openat(AT_FDCWD, /dev/ttyS0, O_RDWR|O_NOCTTY|O_NONBLOCK) 3 ioctl(3, TCSETS, {c_iflags0x50a, c_oflags0x5, c_cflags0x8000045, c_lflags0x0}) 0看到openat返回 fd 3说明设备打开成功。TCSETS是 tcsetattr 对应的 ioctl 命令后面的c_cflags0x8000045换算后是0x8000 是 CLOCAL0x40 是 CREAD0x5 是 CS8 加上其他位整体就是 8N1。对照这个值就能确认 uart_api.c 里写的CLOCAL|CREAD|CS8是否真的传到了内核。如果看到的是 0x8000046那就要重新检查 CSIZE 是不是没清干净。另一个常见问题是 writer 发送正常但 reader 永远等不到数据。这时把 reader 的 VMIN/VTIME 改成能立即返回的组合再 strace read 系统调用strace -e traceread -s 128 ./com_reader 1如果 read 始终阻塞在read(3,未返回说明驱动层没有数据进来问题在硬件线路或对端设备根本没发如果 read 返回 0说明超时生效但数据没有被驱动接收这时要检查 USB 转串口的 TX/RX 是否接反。用 strace 还能验证 O_NDELAY 是否被 fcntl 清掉看到fcntl(3, F_SETFL, 0) 0以及后续 read 处于阻塞等待说明代码行为符合预期。这个技巧对没有硬件调试器、只有一台 Ubuntu 的开发者尤其有用可以把串口问题拆成用户态和内核态两段再结合stty -a的输出判断是 termios 参数没写对还是板子根本没发数据。把这些命令保存成 shell 脚本就能在交付前对例程做一轮快速验收。本文还有配套的精品资源点击获取
返回列表