@[TOC] Linux 串口应用编程完全学习笔记(从 API 到 GPS 实战)
🎉 写给读者 :串口(UART)是嵌入式开发中最常用的通信接口之一。本文从 Linux 串口编程的 统一接口 (open/ioctl/read/write)讲起,深入剖析
termios结构体,带你实现 自发自收测试 和 GPS 模块数据解析 。每个知识点都配有 白话解释 、生活化类比 、完整代码 和 常见错误解决,让你彻底掌握串口应用编程。
1. Linux 串口通信概述 ------ 一切皆文件
1.1 一句话白话解释
在 Linux 系统中,串口设备也被当作 文件 来操作。你只需要 open 打开设备文件(如 /dev/ttymxc5),用 read 读数据,用 write 写数据,用 ioctl 或专用函数设置 行规程(波特率、数据位、停止位等)。
1.2 生活化类比 📞
把串口通信想象成 打电话:
- 打开设备 = 拿起电话听筒(
open) - 设置行规程 = 调好通话频率、音量(设置波特率、数据位等)
- 写数据 = 对着话筒说话(
write) - 读数据 = 听对方说话(
read) - 关闭设备 = 挂断电话(
close)
1.3 Linux 串口通信框图
┌─────────────────────────────────────────────┐
│ APP │
│ (调用 open/read/write/ioctl/tcsetattr等) │
├─────────────────────────────────────────────┤
│ 系统调用层 (VFS) │
├─────────────────────────────────────────────┤
│ tty 核心层 / 行规程层 │
│ (处理 termios、线路规则等) │
├─────────────────────────────────────────────┤
│ UART 设备驱动 │
├─────────────────────────────────────────────┤
│ 硬件 (UART 控制器) │
└─────────────────────────────────────────────┘
1.4 串口编程的套路(三步走)
| 步骤 | 函数 | 说明 |
|---|---|---|
| 1 | open() |
打开串口设备文件,如 /dev/ttymxc5 |
| 2 | 设置行规程 | 使用 tcgetattr/tcsetattr 配置 termios 结构体 |
| 3 | read()/write() |
读写数据 |
| 4 | close() |
关闭设备 |
2. termios 结构体 ------ 串口配置的核心
2.1 一句话白话解释
struct termios 是一个 参数包,里面存放了串口的所有配置:波特率、数据位、停止位、校验位、是否回显、是否原始模式等。
2.2 termios 结构体定义
c
typedef unsigned char cc_t;
typedef unsigned int speed_t;
typedef unsigned int tcflag_t;
#define NCCS 19
struct termios {
tcflag_t c_iflag; /* 输入模式标志 */
tcflag_t c_oflag; /* 输出模式标志 */
tcflag_t c_cflag; /* 控制模式标志 */
tcflag_t c_lflag; /* 本地模式标志 */
cc_t c_line; /* 线路规程 */
cc_t c_cc[NCCS]; /* 控制字符数组 */
};
2.3 各字段详解(表格)
| 字段 | 作用 | 常用标志位 |
|---|---|---|
c_iflag |
输入处理选项 | IGNBRK(忽略 break)、INPCK(启用输入奇偶校验) |
c_oflag |
输出处理选项 | OPOST(启用输出处理)、ONLCR(换行转回车换行) |
c_cflag |
硬件控制选项 | CS8(8 位数据)、CLOCAL(忽略 modem 控制)、CREAD(启用接收) |
c_lflag |
本地模式选项 | ECHO(回显)、ICANON(规范模式,即行缓冲) |
c_cc |
特殊控制字符 | VMIN、VTIME(用于非规范模式) |
2.4 生活化类比(termios 就像空调遥控器)
空调遥控器上有 模式、温度、风速、定时 等多个按键 ------ 这就像 termios 的多个标志位。你要按 tcsetattr 这个"确认键"才能让设置生效。
2.5 行规程函数表(常用)
| 函数名 | 作用 | 白话解释 |
|---|---|---|
tcgetattr(fd, &termios) |
获取当前串口属性 | 读取当前的"遥控器设置" |
tcsetattr(fd, opt, &termios) |
设置串口属性 | 把新设置写入"遥控器",opt 决定何时生效 |
tcflush(fd, queue) |
清空输入/输出缓冲区 | 扔掉还没发/还没收的数据 |
cfsetispeed(&termios, speed) |
设置输入波特率 | 设置"说话"的速度 |
cfsetospeed(&termios, speed) |
设置输出波特率 | 设置"听话"的速度 |
cfsetspeed(&termios, speed) |
同时设置输入输出波特率 | 一键设置双向速度 |
2.6 常见波特率常量
| 宏 | 波特率 |
|---|---|
B9600 |
9600 |
B115200 |
115200 |
B4800 |
4800 |
B19200 |
19200 |
B57600 |
57600 |
3. 串口收发实验 ------ 自发自收(短接 TX 和 RX)
3.1 一句话白话解释
把串口的 发送引脚(TX) 和 接收引脚(RX) 用导线短接,这样你 write 出去的数据马上就会被自己 read 回来。这是最简单的串口测试方法。
3.2 硬件连接示意图

⚠️ 注意 :做实验时可以用 金属镊子或杜邦线 短接扩展板上 UART_A 的 TX 和 RX 插针。如果没有扩展板,请查看原理图,短接底板对应引脚。
3.3 完整可编译运行的串口自发自收代码
c
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <errno.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <termios.h>
#include <stdlib.h>
/**
* 配置串口参数
* @param fd 已打开的串口文件描述符
* @param nSpeed 波特率 (支持2400/4800/9600/115200,默认9600)
* @param nBits 数据位 (7 或 8)
* @param nEvent 校验位 ('O'奇校验, 'E'偶校验, 'N'无校验)
* @param nStop 停止位 (1 或 2)
* @return 0成功,-1失败
*/
int set_opt(int fd, int nSpeed, int nBits, char nEvent, int nStop)
{
struct termios newtio, oldtio;
// 获取当前串口属性,保存到 oldtio 以便恢复
if (tcgetattr(fd, &oldtio) != 0) {
perror("SetupSerial 1");
return -1;
}
// 清零 newtio 结构体
bzero(&newtio, sizeof(newtio));
// 启用接收器,设置为本地连接模式(忽略调制解调器控制线)
newtio.c_cflag |= CLOCAL | CREAD;
// 清除数据位掩码,以便重新设置
newtio.c_cflag &= ~CSIZE;
// 设置为原始输入模式:关闭规范输入、回显、信号字符处理
newtio.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); /* 输入标志 */
// 设置为原始输出模式:不进行输出处理
newtio.c_oflag &= ~OPOST; /* 输出标志 */
// 设置数据位
switch (nBits) {
case 7:
newtio.c_cflag |= CS7;
break;
case 8:
newtio.c_cflag |= CS8;
break;
}
// 设置校验位
switch (nEvent) {
case 'O': // 奇校验 (Odd)
newtio.c_cflag |= PARENB; // 启用校验
newtio.c_cflag |= PARODD; // 设置为奇校验
newtio.c_iflag |= (INPCK | ISTRIP); // 启用输入校验检查,并剥离校验位
break;
case 'E': // 偶校验 (Even)
newtio.c_iflag |= (INPCK | ISTRIP);
newtio.c_cflag |= PARENB; // 启用校验
newtio.c_cflag &= ~PARODD; // 清除奇校验位,即为偶校验
break;
case 'N': // 无校验 (None)
newtio.c_cflag &= ~PARENB; // 关闭校验
break;
}
// 设置波特率 (输入/输出波特率分别设置)
switch (nSpeed) {
case 2400:
cfsetispeed(&newtio, B2400);
cfsetospeed(&newtio, B2400);
break;
case 4800:
cfsetispeed(&newtio, B4800);
cfsetospeed(&newtio, B4800);
break;
case 9600:
cfsetispeed(&newtio, B9600);
cfsetospeed(&newtio, B9600);
break;
case 115200:
cfsetispeed(&newtio, B115200);
cfsetospeed(&newtio, B115200);
break;
default:
// 不支持的波特率默认使用 9600
cfsetispeed(&newtio, B9600);
cfsetospeed(&newtio, B9600);
break;
}
// 设置停止位
if (nStop == 1)
newtio.c_cflag &= ~CSTOPB; // 1位停止位
else if (nStop == 2)
newtio.c_cflag |= CSTOPB; // 2位停止位
// 设置读取策略:
// VMIN = 1 表示至少读到1个字节才返回
// VTIME = 0 表示不等待,如果没有数据立即返回(read 返回0)
newtio.c_cc[VMIN] = 1;
newtio.c_cc[VTIME] = 0;
/* 注释中 VTIME 单位有误:实际上是 0.1 秒,此处 VTIME=0 即无限等待直到 VMIN 满足 */
// 清空输入输出缓冲区
tcflush(fd, TCIFLUSH);
// 立即应用新的串口属性
if (tcsetattr(fd, TCSANOW, &newtio) != 0) {
perror("com set error");
return -1;
}
return 0;
}
/**
* 打开串口设备
* @param com 设备节点路径,如 "/dev/ttyS0"
* @return 文件描述符,失败返回 -1
*/
int open_port(char *com)
{
int fd;
// 以读写方式打开,不设置为控制终端 (O_NOCTTY)
fd = open(com, O_RDWR | O_NOCTTY);
if (-1 == fd) {
return -1;
}
// 设置文件状态标志为0,即设置为阻塞模式(清除 O_NONBLOCK)
if (fcntl(fd, F_SETFL, 0) < 0) {
printf("fcntl failed!\n");
return -1;
}
return fd;
}
/**
* 主函数:串口发送并回显测试
* 用法: ./serial_send_recv </dev/ttySAC1 or other>
*/
int main(int argc, char **argv)
{
int fd;
int iRet;
char c;
// 检查命令行参数
if (argc != 2) {
printf("Usage: \n");
printf("%s </dev/ttySAC1 or other>\n", argv[0]);
return -1;
}
// 1. 打开串口设备
fd = open_port(argv[1]);
if (fd < 0) {
printf("open %s err!\n", argv[1]);
return -1;
}
// 2. 配置串口 (115200, 8数据位, 无校验, 1停止位)
iRet = set_opt(fd, 115200, 8, 'N', 1);
if (iRet) {
printf("set port err!\n");
return -1;
}
printf("Enter a char: ");
// 3. 主循环:读取键盘输入 -> 发送到串口 -> 从串口读取一个字符 -> 显示
while (1) {
scanf("%c", &c); // 从标准输入读取一个字符
iRet = write(fd, &c, 1); // 发送该字符到串口
iRet = read(fd, &c, 1); // 尝试从串口读取一个字节
if (iRet == 1)
printf("get: %02x %c\n", c, c); // 以十六进制和字符形式打印
else
printf("can not get data\n");
}
return 0;
}
3.4 上机实验步骤
bash
# Ubuntu 上编译
arm-buildroot-linux-gnueabihf-gcc -o serial_send_recv serial_send_recv.c
# 开发板上运行(假设串口设备为 /dev/ttymxc5)
./serial_send_recv /dev/ttymxc5
3.5 常见错误及解决方法
| 错误现象 | 可能原因 | 解决方法 |
|---|---|---|
open: Permission denied |
没有权限访问设备 | chmod 666 /dev/ttymxc5 或使用 sudo |
tcgetattr: Input/output error |
设备不是串口或驱动问题 | ls -l /dev/ttymxc* 确认设备存在 |
| 发送了但 read 永远阻塞 | TX 和 RX 没有短接 | 用杜邦线短接 TX 和 RX 引脚 |
| 读到的数据乱码 | 波特率不匹配 | 检查设置,与硬件实际波特率一致 |
| 发送后读回的数据多/少 | VMIN/VTIME 设置不当 | 调试 c_cc[VMIN] 和 c_cc[VTIME] |
4. GPS 模块实验 ------ 解析 NMEA0183 数据
4.1 GPS 系统简介(白话版)
GPS 是一个由 24 颗以上卫星 组成的导航系统。你的 GPS 模块(接收机)同时接收多颗卫星的信号,计算出你当前的位置(经度、纬度、海拔),然后通过串口输出这些数据。
4.2 NMEA0183 格式 ------ GPS 数据的"通用语言"
一句话白话 :NMEA0183 是一种 ASCII 文本格式 ,每句话以 $ 开头,以换行结束,逗号分隔字段。不同语句类型(如 $GPGGA、$GPRMC)包含不同的定位信息。
格式示意图:
$GPGG,<1>,<2>,<3>,<4>,<5>,<6>,<7>,<8>,<9>,...*hh<CR><LF>
典型例子:
$GPGGA,074529.82,2429.6717,N,11804.6973,E,1,8,1.098,42.110,M,,M,,*76
4.3 $XXGGA 字段详解(表格)
| 字段 | 含义 | 示例值 | 白话解释 |
|---|---|---|---|
<1> |
UTC 时间(hhmmss.ss) | 074529.82 |
07:45:29.82 世界协调时 |
<2> |
纬度(ddmm.mmmm) | 2429.6717 |
24 度 29.6717 分 |
<3> |
南北半球 | N |
N=北纬,S=南纬 |
<4> |
经度(dddmm.mmmm) | 11804.6973 |
118 度 04.6973 分 |
<5> |
东西半球 | E |
E=东经,W=西经 |
<6> |
GPS 状态 | 1 |
0=未定位,1=单点定位,2=差分定位,4=RTK固定解 |
<7> |
使用的卫星数 | 8 |
8 颗卫星 |
<8> |
HDOP(水平精度因子) | 1.098 |
越小越精确,<2 很好 |
<9> |
海拔高度(米) | 42.110 |
海拔 42.11 米 |
<10> |
高程异常(米) | (空) |
大地水准面与椭球面之差 |
<11> |
差分时间(秒) | (空) |
最近一次差分更新距今秒数 |
<12> |
参考站号 | (空) |
DGPS 参考站 ID |
💡 注意 :语句开头的
GP表示 GPS 系统,也可能是BD(北斗)、GL(格洛纳斯)、GN(多系统联合)。
4.4 GPS 模块硬件连接
┌─────────────┐ ┌─────────────┐
│ IMX6ULL │ │ GPS 模块 │
│ UART5_RX ├──────┼→ TX │
│ (GPIO5) │ │ │
│ UART5_TX ├──────┼→ RX(可选) │
│ 3.3V ├──────┼→ VCC │
│ GND ├──────┼→ GND │
└─────────────┘ └─────────────┘
⚠️ 注意 :大多数 GPS 模块只需要 接收数据,所以只需将 GPS 模块的 TX 接到开发板的 RX。如果不需要发送命令给 GPS,可以不接开发板的 TX。
4.5 完整可编译运行的 GPS 数据读取与解析代码
c
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <errno.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <termios.h>
#include <stdlib.h>
/* set_opt(fd,115200,8,'N',1) */
int set_opt(int fd,int nSpeed, int nBits, char nEvent, int nStop)
{
struct termios newtio,oldtio;
if ( tcgetattr( fd,&oldtio) != 0) {
perror("SetupSerial 1");
return -1;
}
bzero( &newtio, sizeof( newtio ) );
newtio.c_cflag |= CLOCAL | CREAD;
newtio.c_cflag &= ~CSIZE;
newtio.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); /*Input*/
newtio.c_oflag &= ~OPOST; /*Output*/
switch( nBits )
{
case 7:
newtio.c_cflag |= CS7;
break;
case 8:
newtio.c_cflag |= CS8;
break;
}
switch( nEvent )
{
case 'O':
newtio.c_cflag |= PARENB;
newtio.c_cflag |= PARODD;
newtio.c_iflag |= (INPCK | ISTRIP);
break;
case 'E':
newtio.c_iflag |= (INPCK | ISTRIP);
newtio.c_cflag |= PARENB;
newtio.c_cflag &= ~PARODD;
break;
case 'N':
newtio.c_cflag &= ~PARENB;
break;
}
switch( nSpeed )
{
case 2400:
cfsetispeed(&newtio, B2400);
cfsetospeed(&newtio, B2400);
break;
case 4800:
cfsetispeed(&newtio, B4800);
cfsetospeed(&newtio, B4800);
break;
case 9600:
cfsetispeed(&newtio, B9600);
cfsetospeed(&newtio, B9600);
break;
case 115200:
cfsetispeed(&newtio, B115200);
cfsetospeed(&newtio, B115200);
break;
default:
cfsetispeed(&newtio, B9600);
cfsetospeed(&newtio, B9600);
break;
}
if( nStop == 1 )
newtio.c_cflag &= ~CSTOPB;
else if ( nStop == 2 )
newtio.c_cflag |= CSTOPB;
newtio.c_cc[VMIN] = 1; /* 读数据时的最小字节数: 没读到这些数据我就不返回! */
newtio.c_cc[VTIME] = 0; /* 等待第1个数据的时间:
* 比如VMIN设为10表示至少读到10个数据才返回,
* 但是没有数据总不能一直等吧? 可以设置VTIME(单位是10秒)
* 假设VTIME=1,表示:
* 10秒内一个数据都没有的话就返回
* 如果10秒内至少读到了1个字节,那就继续等待,完全读到VMIN个数据再返回
*/
tcflush(fd,TCIFLUSH);
if((tcsetattr(fd,TCSANOW,&newtio))!=0)
{
perror("com set error");
return -1;
}
//printf("set done!\n");
return 0;
}
int open_port(char *com)
{
int fd;
//fd = open(com, O_RDWR|O_NOCTTY|O_NDELAY);
fd = open(com, O_RDWR|O_NOCTTY);
if (-1 == fd){
return(-1);
}
if(fcntl(fd, F_SETFL, 0)<0) /* 设置串口为阻塞状态*/
{
printf("fcntl failed!\n");
return -1;
}
return fd;
}
/*
* ./serial_send_recv <dev>
*/
int main(int argc, char **argv)
{
int fd;
int iRet;
char c;
/* 1. open */
/* 2. setup
* 115200,8N1
* RAW mode
* return data immediately
*/
/* 3. write and read */
if (argc != 2)
{
printf("Usage: \n");
printf("%s </dev/ttySAC1 or other>\n", argv[0]);
return -1;
}
fd = open_port(argv[1]);
if (fd < 0)
{
printf("open %s err!\n", argv[1]);
return -1;
}
iRet = set_opt(fd, 115200, 8, 'N', 1);
if (iRet)
{
printf("set port err!\n");
return -1;
}
printf("Enter a char: ");
while (1)
{
scanf("%c", &c);
iRet = write(fd, &c, 1);
iRet = read(fd, &c, 1);
if (iRet == 1)
printf("get: %02x %c\n", c, c);
else
printf("can not get data\n");
}
return 0;
}
4.6 接线

4.7 上机实验步骤
bash
# Ubuntu 上编译
arm-buildroot-linux-gnueabihf-gcc -o gps_read gps_read.c
# 开发板上运行(假设 GPS 模块连接在 /dev/ttymxc5)
./gps_read /dev/ttymxc5
# 将 GPS 模块放在室外或窗边,等待定位(可能需要几分钟)
预期输出(定位成功后):
Listening to GPS on /dev/ttymxc5 at 9600 baud...
📍 GPS Fix Info:
Time (UTC): 074529.82
Lat: 2429.6717 N
Lon: 11804.6973 E
Quality: 1 (GPS single fix)
Satellites: 8
HDOP: 1.098
Altitude: 42.110 m
4.7 常见错误及解决方法
| 错误现象 | 可能原因 | 解决方法 |
|---|---|---|
| 没有任何输出 | GPS 模块未上电或串口连接错误 | 检查 VCC/GND,确认 TX 接开发板 RX |
输出全是 $GPGGA,,,,,,0 |
GPS 未定位(室内或无卫星信号) | 移到室外,等待几分钟 |
| 输出乱码 | 波特率不匹配 | 确认 GPS 模块波特率(常见 9600) |
open: No such file or directory |
设备节点不存在 | ls /dev/ttymxc* 查看实际串口名 |
| 收到数据但解析不到经纬度 | 语句类型不是 GGA | 打印原始数据,看输出语句类型(可能是 $GPRMC) |

移到室外,等待几分钟
5. 完整速查表 📋
5.1 串口配置速查
| 配置项 | 常用值 | termios 设置 |
|---|---|---|
| 波特率 | 9600, 115200 | cfsetospeed(&term, B9600) |
| 数据位 | 8 | `term.c_cflag |
| 停止位 | 1 | term.c_cflag &= ~CSTOPB |
| 校验位 | 无 | term.c_cflag &= ~PARENB |
| 原始模式 | 是 | cfmakeraw(&term) |
| 阻塞读取 | 至少 1 字节 | term.c_cc[VMIN]=1; term.c_cc[VTIME]=0 |
5.2 常用 NMEA 语句
| 语句 | 内容 | 主要字段 |
|---|---|---|
$GPGGA |
定位数据(时间、经纬度、海拔、卫星数、精度) | 最常用 |
$GPRMC |
推荐最小定位信息(速度、方向、日期) | 用于导航 |
$GPGSA |
当前卫星信息(DOP、使用中的卫星) | 精度评估 |
$GPGSV |
可见卫星状态(仰角、方位、信噪比) | 调试用 |
$GPVTG |
地面速度信息 | 速度方向 |
5.3 GPS 质量值含义
| 值 | 含义 | 精度 |
|---|---|---|
| 0 | 未定位 | 无效 |
| 1 | GPS 单点定位 | 2-5 米 |
| 2 | 差分定位(DGPS) | 0.5-1 米 |
| 4 | RTK 固定解 | 厘米级 |
| 5 | RTK 浮点解 | 分米级 |
6 常见面试题
| 问题 | 答案要点 |
|---|---|
termios 中 c_cflag 的 CREAD 和 CLOCAL 有什么作用? |
CREAD 启用接收,CLOCAL 忽略 modem 控制线(如 DCD) |
| 什么是原始模式?与规范模式有什么区别? | 原始模式不处理特殊字符,不缓冲行,适合传输二进制数据 |
VMIN 和 VTIME 的含义? |
VMIN 最小读取字节数,VTIME 等待时间(0.1 秒单位) |
| GPS 模块为什么需要较长时间才能定位? | 冷启动时需要下载星历数据,约 30 秒到几分钟 |
NMEA 语句中的校验和 *hh 如何计算? |
从 $ 后到 * 前的所有字符异或 |
7.2 仍有哪个知识点你觉得自己解释得不够清楚?为什么?
tcsetattr 的 opt 参数(TCSANOW、TCSADRAIN、TCSAFLUSH) 解释得不够深入。虽然代码中使用了 TCSANOW,但没有详细说明三者区别以及什么时候用哪个。原因是在简单的串口配置场景中,TCSANOW(立即生效)足够用,而 TCSADRAIN(等待输出完成后更改)和 TCSAFLUSH(清空输入输出缓冲区后更改)在更复杂的流控或 modem 控制场景才体现价值。后续可以补充一个对比表格,并给出使用 TCSADRAIN 修改波特率的例子,避免数据损坏。
💡 补充说明:
TCSANOW:立即更改配置,可能丢弃未发送的数据。TCSADRAIN:等待所有输出发送完毕再更改,适合更改影响输出行为的参数(如波特率)。TCSAFLUSH:等待输出完成后,同时清空输入缓冲区。
🎉 恭喜!你已经完整学完了 Linux 串口应用编程的所有核心知识。现在你可以:
- 用
termios结构体配置任意串口参数 - 编写程序实现串口收发和自发自收测试
- 解析 GPS 模块的 NMEA0183 数据,提取经纬度、海拔等信息
- 根据实际需求,扩展为 GSM 模块控制、传感器数据采集等应用
如果本文对你有帮助,欢迎分享给更多嵌入式开发者!