上周调试一个工业网关项目,串口通信总是随机丢数据。示波器抓波形一切正常,但应用层收到的报文时不时就少几个字节。熬到凌晨三点,盯着stty -F /dev/ttyS0的输出发呆,突然意识到问题可能不在硬件,而在那个我一直忽略的"线路规程"。
TTY到底是什么?
很多人以为TTY就是串口终端,实际上它是Teletype的缩写,一套历史比UNIX还老的抽象层。现在的Linux TTY子系统包含三层:TTY核心、线路规程和底层驱动。
c
// 典型的串口驱动注册片段
static struct uart_driver my_uart_drv = {
.owner = THIS_MODULE,
.driver_name = "my_uart",
.dev_name = "ttyS", // 注意这个命名约定
.major = TTY_MAJOR, // 4
.minor = 64, // 从ttyS0开始
};
// 这里踩过坑:早期内核版本和现在的minor分配策略不同
// 嵌入式移植时一定要查对应内核的uart_register_driver实现
线路规程:被低估的流量控制器
线路规程(Line Discipline)是TTY架构中最精妙的设计。它像个中间人,坐在TTY核心和硬件驱动之间,负责:
- 特殊字符处理(Ctrl+C、Ctrl+Z)
- 行缓冲(经典模式下的回车才上报)
- 串口数据流控(XON/XOFF)
- 协议转换(如PPP、SLIP)
c
// 看看n_tty的经典实现
static struct tty_ldisc_ops tty_ldisc_N_TTY = {
.name = "n_tty",
.num = N_TTY,
.open = n_tty_open,
.close = n_tty_close,
.receive_buf = n_tty_receive_buf, // 数据在这里被"加工"
.write_wakeup = n_tty_write_wakeup,
};
// 关键点:receive_buf函数决定了数据何时、如何传递给上层
// 我们的丢包问题就出在这里------默认的N_TTY会做行缓冲!
那个深夜发现的真相
回到开头的问题。我们的工业协议是二进制数据,但默认的N_TTY线路规程工作在规范模式(ICANON)。这个模式下,TTY会:
- 等待换行符才提交数据给read()
- 处理退格、删除等编辑字符
- 限制输入行长度(默认4096字节)
解决方案简单得让人想哭:
bash
# 关闭规范模式,原始数据模式
stty -F /dev/ttyS0 raw -echo -icanon
# 或者用程序设置
struct termios options;
tcgetattr(fd, &options);
cfmakeraw(&options); // 这个函数一键设置原始模式
tcsetattr(fd, TCSANOW, &options);
线路规程的切换技巧
除了默认的N_TTY,内核还内置了其他线路规程:
c
#define N_TTY 0 // 默认终端模式
#define N_SLIP 1 // 串行线路IP协议
#define N_MOUSE 2 // 鼠标协议
#define N_PPP 3 // 点对点协议
#define N_STRIP 4 // Starmode Radio IP
#define N_AX25 5 // AX.25
#define N_X25 6 // X.25
#define N_6PACK 7
#define N_MASC 8
#define N_R3964 9
#define N_PROFIBUS_FDL 10
#define N_IRDA 11
#define N_SMSBLOCK 12
#define N_HDLC 13
#define N_SYNC_PPP 14
#define N_HCI 15 // Bluetooth HCI UART
切换线路规程的两种方式:
c
// 方法1:ioctl(老派但有效)
int ldisc = N_TTY;
ioctl(tty_fd, TIOCSETD, &ldisc);
// 方法2:通过ldisc的open方法
struct tty_ldisc *ld = tty_ldisc_get(N_PPP);
tty_ldisc_assign(tty, ld);
tty_ldisc_open(tty, ld);
驱动开发者的注意事项
写TTY底层驱动时,这几个回调必须小心处理:
c
static const struct tty_operations my_serial_ops = {
.open = my_serial_open,
.close = my_serial_close,
.write = my_serial_write, // 这里别直接调硬件写
.write_room = my_serial_write_room, // 缓冲区剩余空间
.chars_in_buffer = my_serial_chars_in_buffer,
.flush_buffer = my_serial_flush_buffer,
.ioctl = my_serial_ioctl,
.set_termios = my_serial_set_termios, // 波特率设置在这里
.stop = my_serial_stop,
.start = my_serial_start,
.hangup = my_serial_hangup,
};
// 血的教训:.write应该把数据放入环形缓冲区
// 然后触发硬件发送中断,别在这里死等硬件发送完成
调试TTY问题的私房工具
- ldisc状态查看:
bash
cat /proc/tty/ldiscs
# 能看到每个TTY设备绑定的线路规程
- 数据流跟踪:
c
// 在驱动里加调试点
#define tty_debug(tty, fmt, args...) \
dev_dbg(tty->dev, fmt, ##args)
// 特别关注tty_insert_flip_string_fixed_flag的调用
// 这是驱动把数据塞给线路规程的入口
- 内存泄漏检查 :
线路规程的open/close必须成对调用,特别是自己实现ldisc时:
c
static int my_ldisc_open(struct tty_struct *tty)
{
struct my_data *data = kmalloc(sizeof(*data), GFP_KERNEL);
// 一定要检查分配失败的情况
if (!data) return -ENOMEM;
tty->disc_data = data; // 这里内核会帮你管理引用计数
return 0;
}
static void my_ldisc_close(struct tty_struct *tty)
{
struct my_data *data = tty->disc_data;
kfree(data); // 别忘了释放
tty->disc_data = NULL; // 这个置空很重要
}
给后来者的经验之谈
TTY子系统是Linux里为数不多的"历史包袱"设计得如此优雅的模块。调试TTY问题,记住三个关键点:
第一,先分清楚问题在哪一层。硬件问题看dmesg | grep ttyS,驱动问题看cat /proc/tty/driver/serial,线路规程问题用stty -a查参数,应用层问题用strace跟系统调用。
第二,二进制协议一定要用raw模式。那些termios的标志位,别自己一个个设,用cfmakeraw()最保险。工业环境里,记得把CREAD、CLOCAL也打开,避免莫名其妙的"设备不存在"错误。
第三,自己实现线路规程的情况比想象中少。现在很多串口协议(如Modbus、Profibus)都在用户态用库实现了。除非你要在内核里做硬件加速或实时性要求极高,否则别碰自定义ldisc。我见过有人为了一点点性能提升,写了个自定义线路规程,结果内存泄漏查了两个月。
最后留个思考题:为什么echo "test" > /dev/ttyS0能发送数据,但cat /dev/ttyS0收不到?提示一下,看看CRTSCTS和CRTSCTS的区别。这个坑,我当年踩了整整一天。
TTY就像老式的机械手表,内部齿轮复杂精密,但一旦理解了工作原理,调试起来反而比那些"现代"的框架更顺手。下次遇到串口问题,别急着换硬件,先问问线路规程同不同意。