简介:一份面向Linux内核驱动开发与嵌入式串口通信学习者的16550 UART驱动源码包。资源共11个文件,以C源文件、Shell脚本、头文件为主,附Makefile、说明文档及编译好的ko模块,压缩包仅13KB,结构精简,适合通读剖析。目前已有1265人学习浏览。内容围绕16550驱动在Linux下的实现展开,涵盖FIFO缓冲、波特率配置、中断处理、CTS/RTS与XON/XOFF流控制、串口子系统注册及读写流程等关键知识点;源码仓库中3个C文件与2个头文件构成驱动主体,3个Shell脚本可辅助加载、卸载模块,README和Makefile便于二次构建与实验。读者可借助完整源码理解内核驱动与硬件交互机制,并直接在目标环境调试,是嵌入式开发者、驱动学习者和系统调试人员快速上手的实用参考。
1. 把 8250 接管之前:为什么需要一份 16550 自定义驱动
你能搜到16550_serpi-master,大概率不是想看系统自带 8250 驱动怎么运行,而是想绕过它。Linux 发行版默认把 COM1 交给serial8250管着,但当你需要自定义波特率除数、调整 16550 的 FIFO 触发水位、或者在用户态直接控制 RTS/CTS 时,默认驱动反而成了碍事的黑盒。16550_serpi-master这套代码把问题拆得很干净:serpi.c是内核态驱动,直接操作 UART16550 的 DLAB/THR/LSR/MCR 寄存器;serpi_test.c和serpi_write.c是用户态测试工具,负责回环和单字节写出;loads.sh、unloads.sh、disableserial.sh则把模块加载、卸载和释放系统串口资源这些容易踩坑的动作固化成脚本。它适合两类人:一类是刚写内核驱动的嵌入式工程师,想在最小框架里建立“寄存器位到实际波形”的对应关系;另一类是在老平台上做串口扩展的熟手,需要一份不会引入 tty 层心智负担的参考底稿。
2. 16550 寄存器地图与 serpi.c 的初始化路径
2.1 从 serial_reg.h 看 UART16550 寄存器布局
serial_reg.h是内核include/uapi/linux/serial_reg.h的原样拷贝或者裁剪版本,它把 16550 的 8 个寄存器偏移和位掩码统一命名。serpi.c引用它,意味着驱动内部不写裸地址,而是用UART_LSR_THRE、UART_FCR_ENABLE_FIFO这类符号。好处是迁移到内存映射平台时,只需要把inb/outb换成readb/writeb,寄存器定义不用改。
16550 和 8250 最大的差异在接收路径。8250 没有 FIFO,每个字节到达都触发一次中断,115200bps 下 CPU 会被频繁打断;16550 的发送和接收方向各有 16 字节 FIFO,FCR可以控制 FIFO 使能、清除和接收触发阈值。触发阈值由FCR的 bit6-7 决定:00 表示 1 字节,01 表示 4 字节,10 表示 8 字节,11 表示 14 字节。调试串口建议用 8 字节,噪声干扰大的工业总线建议用 1 字节,因为报文短,14 字节的触发值会把一次数据报拆成两批进入系统。
表:16550 寄存器偏移和关键位
| 偏移 | 寄存器 | DLAB=1 时作用 | 常用标志位 |
|---|---|---|---|
| 0 | RBR/THR | 收发数据(写为发送,读为接收) | - |
| 1 | IER | 中断使能 | bit0 接收可用;bit1 发送 FIFO 空 |
| 2 | IIR/FCR | 写 FCR 控制 FIFO | FCR bit0 使能 FIFO,bit2 清发送 FIFO,bit6-7 触发阈值 |
| 3 | LCR | 线路参数与 DLAB | bit7 除数锁存,bit0-1 数据位,bit3 校验 |
| 4 | MCR | 调制解调器控制 | bit4 回环模式 |
| 5 | LSR | 线路状态 | bit0 数据就绪,bit5 THRE,bit6 TEMT |
| 6 | MSR | modem 状态输入 | bit4 CTS,bit0 Delta-CTS |
| 7 | SCR | 厂商保留,可当普通存储 | - |
如果serpi.c的初始化代码把FCR写成0x01,只打开了 FIFO 使能而没有指定触发阈值,那么接收中断仍然会在每个字节到达时产生,FIFO 形同虚设。我在调试串口时习惯把FCR写成0x07(enable + clear 接收 + clear 发送),再把触发级别通过宏暴露给模块参数。
2.2 初始化里的波特率除数与 LCR 设置
16550 波特率由uartclk /(16 × baud)得到除数。标准 PC 串口用 1.8432MHz 晶振,115200 bps 的除数正好是 1;如果你的板子用 22MHz 或者 APB 总线时钟,比如PCLK = 24MHz,2400 bps 的除数是 625,这个值超过 8 位,必须分两字节写入DLM/DLL。serpi.c里常见实现是:
static void serpi_set_baud(void __iomem *base, unsigned int clk, unsigned int baud) { unsigned int divisor = DIV_ROUND_CLOSEST(clk, 16 * baud); unsigned char lcr; lcr = inb(base + UART_LCR); outb(lcr | UART_LCR_DLAB, base + UART_LCR); /* 打开除数锁存 */ outb(divisor & 0xff, base + UART_DLL); /* 低字节 */ outb((divisor >> 8) & 0xff, base + UART_DLM); /* 高字节 */ outb(lcr, base + UART_LCR); /* 正常模式 */ }DIV_ROUND_CLOSEST会做四舍五入,避免 9600 bps 时出现 11.97 这类小数被截成 11。如果平台时钟不是标准值,写完除数后最好在控制台打印实际波特率偏差,常用判断标准是偏差小于 ±2%,超过 2% 就会出现偶发错帧。
配置数据位、校验位、停止位的 LCR 写法,在serpi.c里通常被封装成一个类似serpi_set_line的函数:
static void serpi_set_line(void __iomem *base, unsigned int databits, unsigned int parity, unsigned int stopbits) { unsigned char lcr = UART_LCR_WLEN8; /* 8 数据位 */ if (databits == 7) lcr &= ~UART_LCR_WLEN8; /* 清掉 bit1,保留 bit0,得到 7 位 */ if (parity != 0) { lcr |= UART_LCR_PARITY; /* bit3 使能校验 */ if (parity == 2) /* 2 表示偶校验 */ lcr |= UART_LCR_EPAR; /* bit4 置 1 */ } if (stopbits == 2) lcr |= UART_LCR_STOP; /* bit2 置 1 */ outb(lcr, base + UART_LCR); }这个函数的参数几乎对应termios里的c_cflag。真正的 uart_driver 会把调用时机放在set_termios回调中,而serpi.c因为是裸字符设备,只能直接由用户态通过 ioctl 或 sysfs 触发。如果在serpi.h里留了SERPI_BAUD和SERPI_PARITY宏,我一般会让它们被module_param覆盖,这样不同产品线只需要在loads.sh里加两个参数,不用重新编译模块。
2.3 注册成字符设备还是 uart_driver
从serpi_test.c和serpi_write.c的存在可以判断,这两个工具走的是open/read/write,不经过 termios,因此serpi.c不是完整意义上的 uart_driver,而是更轻量的字符设备驱动。它的主干代码是:
static const struct file_operations serpi_fops = { .owner = THIS_MODULE, .open = serpi_open, .read = serpi_read, .write = serpi_write, .release = serpi_release, }; static int __init serpi_init(void) { int ret; ret = alloc_chrdev_region(&dev->devno, 0, 1, "serpi"); if (ret < 0) return ret; cdev_init(&dev->cdev, &serpi_fops); dev->cdev.owner = THIS_MODULE; ret = cdev_add(&dev->cdev, dev->devno, 1); if (ret < 0) goto out_unregister; return 0; }alloc_chrdev_region分配主设备号后,/proc/devices里会多出一行数字加serpi的记录;cdev_add让open能转到serpi_open。注册设备类不是必须的,但如果没有class_create和device_create,新模块加载后/dev/serpi0不会自动出现,只能靠loads.sh里的mknod手动造节点。我在工程里通常两个都保留:insmod后先用device_create让 udev 生成节点,再检查一次/dev/serpi0权限,避免某些发行版对 0666 权限的 sysfs 有额外限制。
选择裸字符设备的代价是失去 tty 层的termios、line discipline 和poll。官方 8250 驱动把这些功能全包在struct uart_port的ops里,维护成本高但能力完整。serpi显然没有把重心放在这上面,它的定位更像寄存器级教学工具。若要做正式产品,正确的路径还是把serpi.c的寄存器操作迁到uart_ops->startup、stop_rx、tx_empty、set_termios这些回调里,而不是继续用file_operations。
3. 读写路径:FIFO、LSR 轮询与用户态验证
3.1 读路径:先看 LSR 再看 RBR
在没有任何中断和 DMA 的条件下,读串口最可靠的方式是轮询LSR的 bit0(UART_LSR_DR)。该位为 1 说明接收 FIFO 或接收保持寄存器里有至少一个字节,之后inb(base + UART_RX)取回数据。用代码表示为:
static ssize_t serpi_read(struct file *filp, char __user *buf, size_t count, loff_t *off) { struct serpi_dev *dev = filp->private_data; unsigned char ch; int ret; while (!(inb(dev->base + UART_LSR) & UART_LSR_DR)) { if (filp->f_flags & O_NONBLOCK) return -EAGAIN; cpu_relax(); } ch = inb(dev->base + UART_RX); ret = copy_to_user(buf, &ch, 1); if (ret) return -EFAULT; return 1; }这段代码作为教学没问题,但有三个边界条件。第一,一次 read 只返回单字节,count形同虚设;可以用循环把RBR读到count为止,但每次都要再次检查LSR_DR。第二,cpu_relax()在单核上不会触发调度,对端如果停止发送,read 会把整个 CPU 占满;我一般会加一个msleep_interruptible(1)作为超时兜底。第三,O_NONBLOCK的要义是“一旦没有数据立刻返回”,所以应该把-EAGAIN提前到第一次读LSR之前判断,而不是放在循环里反复检查。
serpi_test在回环模式下,会先把数据写入发送 FIFO,再进入serpi_read。由于回环路径没有外部信号延迟,read 通常一遍就能读到数据。如果读出来是 0x00 或者超时,就要检查MCR的 loopback 位和FCR的清除位是否在初始化时被改写。
3.2 写路径:THRE 标志与 FIFO 水位的区别
写路径上,UART_LSR_THRE(bit5)表示发送保持寄存器为空,不等于发送 FIFO 清空。16550 使能 FIFO 后,CPU 写 THR 实际上是把数据推进发送 FIFO,只要 FIFO 不满,THRE 就一直为 1。因此在批量发送场景中,按 THRE 轮询会连续把 16 字节全塞进 FIFO,写完后真正发送完还需要等发送移位寄存器。
一个简化版的写函数:
static ssize_t serpi_write(struct file *filp, const char __user *buf, size_t count, loff_t *off) { struct serpi_dev *dev = filp->private_data; unsigned char data; size_t written = 0; while (written < count) { if (!(inb(dev->base + UART_LSR) & UART_LSR_THRE)) continue; if (copy_from_user(&data, buf + written, 1)) return -EFAULT; outb(data, dev->base + UART_TX); written++; } return written; }如果确认对端不会做流控,可以保留这种忙等。但内核驱动里操作copy_from_user每字节一次,页错误路径会对性能有 <1% 的影响,所以实际改造时可以先把整个用户缓冲区copy_from_user到驱动维护的tx_buf,再用while往 THR 填。关闭设备前必须等待UART_LSR_TEMT(bit6)置 1,否则最后几个字节会残留在 FIFO 里,serpi_write.c如果只检查 THRE 不检查 TEMT,就会在回环读取时发现少了数据。
LSR状态位的整体判断对排查问题很有用:
| bit | 名称 | 含义 |
|---|---|---|
| 0 | DR | 接收数据就绪 |
| 1-4 | OE/PE/FE/BI | 溢出、校验、帧、break 错误 |
| 5 | THRE | 发送保持寄存器空 |
| 6 | TEMT | 发送器完全空(含 FIFO) |
每次读写前读一下LSR,错误位高的时候要把读到的LSR和RBR一起做一次假读,否则错误标志会持续影响后续数据。
3.3 用 serpi_test 和 serpi_write 做回环验证
serpi_test.c和serpi_write.c在这个工程里就是用户态验证入口。测试流程并不复杂:先加载模块并创建设备节点,然后把 UART 切到回环模式,最后跑测试程序回读发送数据。
sudo ./loads.sh sudo /bin/sh -c 'echo 1 > /sys/devices/virtual/serpi/serpi0/loopback' 2>/dev/null || true sudo ./serpi_test如果没有暴露loopback的 sysfs,可以在serpi_write里先用命令把设备/dev/serpi0open 后写一个 magic 字节,再用serpi_test读回来。这里给一个我在调试时用的时序:先把MCR的 bit4 置 1,然后清空 FIFO,再写发送 FIFO。
static void serpi_loopback_enable(struct serpi_dev *dev) { unsigned char mcr = inb(dev->base + UART_MCR); outb(mcr | UART_MCR_LOOP, dev->base + UART_MCR); }UART_MCR_LOOP使能后,芯片内部 TX 和 RX 短接,外部引脚保持空闲,电气上不会影响相邻设备。如果你在串口外用示波器观察,会看不到任何波形,这是回环测试最直接的判断依据。测试程序如果用了select等待读事件,需要注意:serpi_read没有阻塞队列,poll的default实现会永远报告可读,导致select提前返回。要想让select和 read 的阻塞行为一致,必须实现file_operations->poll并加入等待队列,这是从教学代码走向实用驱动的下一个功课。
注意:回环模式下外部看不到波形,这是判断是否进入回环的依据。如果发送端能发但接收端读不到,先确认
MCR_LOOP有没有在每次open时被重置。
3.4 流控位在 serpi 里的取舍
16550 的MCRbit1(RTS)和 bit0(DTR)可以被驱动控制,MSRbit4(CTS)等引脚状态可以被读取,但裸字符设备驱动的read/write框架里没有专门的流控路径。serpi.c如果要支持 CTS/RTS,通常的做法是给ioctl增加一个SERPI_SET_RTS命令,或者在write前用inb(base + UART_MSR) & UART_MSR_CTS做软轮询。反过来,软件流控 XON/XOFF 本身就是链路协议,和 UART 寄存器无关。资源里没有单独列出flow.c,说明作者大概率没有纳入这些,测试时若接外部设备,需要先用短路线短接 TX/RX,别指望serpi自动处理握手。
4. 加载与卸载:loads.sh、disableserial.sh 与 dmesg 排错
4.1 为什么第一件事是释放 8250 占用的 I/O 端口
serial8250驱动几乎是所有 x86 Linux 发行版默认模块,它会在启动时探测传统 COM 口并用request_region标记端口。serpi.c作为自定义驱动,如果想在 0x3F8 上request_region,唯一的结果是-EBUSY。disableserial.sh的存在就是为了避免这个冲突。
常见做法是两层清理。第一层用setserial告诉内核这个端口已经不属于标准 UART:
sudo setserial /dev/ttyS0 uart none sudo setserial /dev/ttyS0 port 0x0 irq 0uart none会把ttyS0的驱动类型改成 none,port 0x0 irq 0则清空资源,防止serial8250再次绑定。第二层是从驱动模型上解绑:
echo 'ttyS0' | sudo tee /sys/class/tty/ttyS0/device/driver/unbind这只对挂载在platform或PNP总线上的设备有效。如果设备树里显式定义了serial@3f8,还需要在 DTS 里加上status = "disabled"。实际上,disableserial.sh里如果把参数写成了硬编码ttyS0,在只有 COM2 生效的机器上跑会直接提示找不到设备,所以我一般会在脚本里先用setserial -g列出现有端口,再对扫描到的每一个 ttyS 执行释放。
4.2 loads.sh 的加载逻辑与动态主设备号
loads.sh这类名称一看就是为开发期设计的重复执行脚本。它要做的事很固定:卸载旧模块,加载新模块,根据/proc/devices生成设备节点。
#!/bin/sh MODULE=serpi sudo rmmod $MODULE 2>/dev/null sudo insmod $MODULE.ko major=$(awk '/serpi/ {print $1}' /proc/devices) if [ -n "$major" ]; then sudo rm -f /dev/serpi0 sudo mknod /dev/serpi0 c $major 0 sudo chmod 666 /dev/serpi0 firmmod前的2>/dev/null是因为脚本第一次执行时模块根本不存在,如果不忽略错误,set -e会中断后续insmod。awk '/serpi/ {print $1}' /proc/devices抓取的是主设备号,从alloc_chrdev_region分配得到的值不是固定的,大部分机器上会在 240 附近,但反复 insmod/rmmod 不保证一致。如果驱动注册了class_create和device_create,加载之后/dev/serpi0会自动出现,这段mknod只作为兼容老内核的 fallback。
如果模块定义了地址和中断参数,insmod时要显式传入:
sudo insmod serpi.ko base=0x3f8 irq=4base类型用ulong,在 64 位平台上如果用int定义,地址会被符号扩展造成inb访问失败。irq=4对应第 2 个传统串口常用的 IRQ,有些嵌入式板卡的 UART 会共用 GPIO 模拟 IRQ,此时request_irq必须带上IRQF_SHARED,否则和别的设备共用中断线会直接返回-EBUSY。这些细节在dmesg里都有明确提示,关键是你得先开启串口驱动的 debug 日志,我一般会在loads.sh里加一行echo 'file serpi.c +p' > /sys/kernel/debug/dynamic_debug/control。
4.3 卸载失败与 dmesg 中的关键信息
模块加载阶段的错误在 dmesg 里最显眼,但卸载阶段的问题更隐蔽。常见的错误和定位手段可以按下面这张表来对照:
| dmesg 或命令输出 | 原因 | 对策 |
|---|---|---|
No such file or directoryon insmod | serpi.ko编译架构与运行内核不一致 | 检查uname -r和Makefile里的KDIR |
Device or resource busyon request_region | 8250 驱动还占着 0x3F8 | 先跑disableserial.sh,确认/proc/ioports无冲突 |
rmmod: Module serpi is in use | 有进程持着/dev/serpi0的 fd | 用lsof /dev/serpi0找到并结束占用进程 |
segfaultin serpi_test | 用户态程序打开了错误设备节点 | 检查/dev/serpi0的主设备号和cat /proc/devices是否一致 |
invalid register access | 基址错,inb访问了不存在的 I/O 空间 | insmod参数base是否为0x3f8,注意不是内存地址 |
卸载时最常见的坑是rmmod提示设备被占用。read/write进程即使只挂在那里没有数据,file_operations的release没执行前,模块引用计数就不会归零。此时不要直接rmmod -f,应该先运行lsof /dev/serpi0 | grep serpi,把进程 kill 掉再重试。如果serpi.c的release里漏了iounmap或者free_irq,模块卸载虽然能成功,但残留的 IRQ handler 会在下一次加载时触发irq handler type mismatch,到时候只能重启清状态。unloads.sh在标准做法里还会多做一步:把disableserial.sh还原,即重新绑定ttyS0到serial8250,保证下一次系统启动时串口恢复默认行为。
5. 把轮询改成中断驱动:一个可下板的 16550 进阶改动
5.1 注册中断取代忙等
前面的serpi_read和serpi_write都依赖忙等,这对教学足够,但对真实硬件不够:接收方长时间不发送,CPU 就会被cpu_relax()卡住。16550 的 IER 寄存器提供了两个最常用的中断源,bit0 表示接收数据或接收超时,bit1 表示发送保持寄存器空。配合等待队列,读路径可以改成典型的阻塞式 I/O。
在serpi_dev中加一个等待队列头:
struct serpi_dev { void __iomem *base; unsigned int irq; struct cdev cdev; dev_t devno; wait_queue_head_t rq; /* 接收唤醒队列 */ };注册 IRQ 时要用request_irq(dev->irq, serpi_irq, IRQF_SHARED, "serpi", dev)。IRQF_SHARED在传统 ISA 串口上几乎是必需的,因为中断控制器里的同一个 IRQ 可能被多个设备共享。中断处理函数先读IIR的最低 4 位,如果是UART_IIR_NO_INT,说明这个中断不是 16550 产生的,直接返回IRQ_NONE,让共享设备继续处理。
5.2 接收中断、发送中断的防风暴处理
接收中断路径只有两行关键代码:
static irqreturn_t serpi_irq(int irq, void *dev_id) { struct serpi_dev *dev = dev_id; unsigned char iir; iir = inb(dev->base + UART_IIR) & 0x0f; if (iir == UART_IIR_NO_INT) return IRQ_NONE; if ((iir & UART_IIR_RDI) || (iir & UART_IIR_RX_TIMEOUT)) wake_up_interruptible(&dev->rq); if (iir & UART_IIR_THRI) { /* 发送 FIFO 有空位,适合在低水位时继续灌数据 */ disable_irq_nosync(dev->irq); } return IRQ_HANDLED; }wake_up_interruptible会把阻塞在wait_event_interruptible的 read 进程唤醒,然后由驱动或用户态继续从RBR取走 FIFO 里的数据。发送中断UART_IIR_THRI必须小心:只要 IER 的 bit1 为 1,发送 FIFO 一有空间就会不停触发中断,如果没有数据可发,CPU 会陷入中断风暴。所以发送中断只应该在发送队列还有 pending 数据时打开,发完后立刻关闭。
读侧从忙等改成:
ret = wait_event_interruptible(dev->rq, inb(dev->base + UART_LSR) & UART_LSR_DR); if (ret) return -ERESTARTSYS;wait_event_interruptible让进程在条件不满足时进入睡眠,不会在单核上烧死 CPU。注意它的条件表达式会在每次唤醒时重新读LSR,所以中断处理函数里并不需要真的把RBR数据取出来,驱动只需唤醒一个读者即可。为验证中断是否正确,可以在 insmod 时故意传一个错误的irq=255,加载会失败并报Invalid argument;换回正确中断号后,重复serpi_test回环,观察dmesg里没有unexpected IRQ,同时top中该进程的 CPU 占用降到接近 0%。把这段改动收进hacks目录后,你已经有了一条从轮询到中断的完整迁移路径,接下来再往前一步就是给发送路径加 tasklet 或工作队列,把 FIFO 压榨到接近 14 字节触发级别的吞吐量。
本文还有配套的精品资源,点击获取