资讯动态

STM32F2 SPI扩展双UART:WK2114裸机驱动实践

发布时间:2026/10/26 18:40:51 来源:尧图企业网站定制
简介这份面向嵌入式开发者的WK2114串口扩展驱动资料包围绕STM32F2系列微控制器UART接口不足的问题给出通过UART1扩展WK2114芯片的实现思路适用于物联网终端、工业自动化等多串口通信场景的学习与二次开发。压缩包共2个文件包含1个C源码和1个H头文件体积约4KB源码侧重芯片初始化、波特率与数据格式配置、控制信号及收发数据函数实现头文件提供函数原型、常量与结构体声明便于工程集成和模块调用。已有1264人学习说明其在串口扩展驱动入门与参考方面具有一定关注度。读者可从中了解WK2114与STM32F2协同工作的驱动框架、HAL库或寄存器操作方式以及发送、接收缓冲处理的代码组织方法并据此搭建联调测试环境、排查串口通信问题提升多串口系统设计的灵活性。1. 串口不够用WK2114 在一颗 STM32F2 上把 1 路 SPI 变成 2 路 UART去年做一台多路数据采集的板子STM32F2 的 3 个 USART 全被占满还差两路接计量模块和扫码枪重新选型来不及只能在现有 SPI 上想办法。这套 WK2114 驱动就是那时候落地的主机侧只占一路 SPI 加一根中断线芯片帮我们扩出两路完整 UART每路有独立波特率、独立收发 FIFO8N1 之外还能配校验和停止位手册标称的速率上限足够覆盖 115200往上跑到 921600 也有余量。它适合的场景很明确——主控串口见底、又不想换 MCU 的板子尤其是 SPI 总线本来就闲着、只挂了一两片外设的产品。这套代码是裸机版不依赖任何 RTOS接到 FreeRTOS 上也只是把环形缓冲换成队列的事。2. 硬件链路与驱动分层SPI 四线 IRQ 如何撑起两路 UART2.1 接线表与电气约定先把物理层钉死后面所有玄学问题都能少一半。WK2114 是 SPI 从机主机侧要接的就是四根线加两根控制线我在板子上固定成下面这套接法换板子只改 bsp 里的引脚宏。信号方向STM32F2 侧说明SCK主→从SPI1_SCK空闲低电平模式 0MOSI主→从SPI1_MOSI主机发命令和数据MISO从→主SPI1_MISO读寄存器时用CS主→从普通 GPIO 输出软件控制不用硬件 NSSIRQ从→主EXTI 输入上拉开漏输出低电平有效RST主→从普通 GPIO 输出低电平复位CS 我坚持用软件 GPIO理由是硬件 NSS 在帧边界上不够干脆一次寄存器事务中间如果被别的 SPI 设备插队CS 的时序就乱了。软件拉低再拉高配合临界区帧的起止完全由代码说了算。IRQ 线上必须加外部上拉芯片内部是开漏靠 MCU 的内部上拉在长走线上容易边沿变缓中断响应出现偶发丢失这个坑我在别的板子上踩过。SPI 参数起步不要贪快先 1 MHz、8 位、MSB 优先、模式 0 跑通再用示波器看 SCK 和 MISO 的波形质量往上提。WK2114 的 SPI 时钟上限和你的走线长度、上拉阻值都有关系直接抄别人的 8 MHz 配置翻车是常事。提示RST 和 CS 上电默认电平要明确MCU 复位期间 GPIO 是高阻建议各加一颗 10k 电阻到确定电平避免上电瞬间芯片进入不可预期的状态。2.2 驱动分层port 层与 core 层怎么切这套驱动我切成三层好处是换 MCU 只动最下面一层换芯片型号只动中间一层。bsp/ bsp_spi.c/.h /* SPI1 初始化、8 位全双工收发 */ bsp_gpio.c/.h /* CS / RST 控制、IRQ 引脚配置 */ drivers/wk2114/ wk2114_reg.h /* 寄存器偏移与位定义 */ wk2114_port.h /* 平台适配接口声明 */ wk2114_port.c /* 上面两个 bsp 的薄封装 */ wk2114.c/.h /* 芯片核心初始化、读写、FIFO、ISR */ wk2114_ringbuf.c/.h /* 每通道一个的收发环形缓冲 */ app/ ext_uart_task.c /* 业务层取数据、组帧、解析 */core 层只认wk2114_port.h里那几个函数port_spi_xfer()、port_cs()、port_reset()、port_delay_ms()。这样在 STM32F2 上跑通之后我把同一份wk2114.c原样搬到另一颗 Cortex-M0 的板子上只重写了 40 行 port 层就通了。如果一上来就把 SPI 操作和寄存器逻辑揉在一个文件里第二次移植基本上等于重写。另一个分层决策是芯片驱动不碰业务一个字节都不解析。驱动只负责「把字节从 FIFO 搬到环形缓冲」和「把环形缓冲里的字节塞进 FIFO」两件事。协议解析、超时判断、断帧处理全在 app 层。这条边界划清楚之后扫码枪换协议、计量模块改波特率都不用回头改驱动。2.3 寄存器页模型全局页、子串口页与 SPAGE 切换WK2114 的寄存器是分页的这是读手册时最容易看漏的一节。芯片内部有一个全局页页号 0和若干子串口页页号 1 到 NN 就是通道数同一组偏移地址在不同页下含义完全不同。切页靠全局页里的 SPAGE 寄存器写入页号来完成。我把常用偏移固化成宏写在wk2114_reg.h里/* 全局页SPAGE 0 */ #define WK_REG_GENA 0x00u /* 总使能与工作模式 */ #define WK_REG_GRST 0x01u /* 软复位 */ #define WK_REG_GMUT 0x02u /* 主时钟/输出配置 */ #define WK_REG_SPAGE 0x03u /* 子串口页选择0全局1..N子串口 */ /* 子串口页SPAGE 1..N页内偏移 */ #define WK_SUB_SCR 0x00u /* 波特率分频 */ #define WK_SUB_LCR 0x01u /* 数据位/校验/停止位 */ #define WK_SUB_FIER 0x02u /* FIFO 中断使能 */ #define WK_SUB_FCR 0x03u /* FIFO 控制清空、触发水位 */ #define WK_SUB_SIER 0x04u /* 子串口中断使能 */ #define WK_SUB_SIFR 0x05u /* 子串口中断标志写 1 清除 */ #define WK_SUB_TFCNT 0x06u /* 发送 FIFO 剩余空间 */ #define WK_SUB_RFCNT 0x07u /* 接收 FIFO 中已有字节数 */ #define WK_SUB_FSR 0x08u /* FIFO 状态 */ #define WK_SUB_LSR 0x09u /* 线路状态溢出、帧错、奇偶错 */ #define WK_SUB_FDAT 0x0Au /* 数据口读写都走它 */页机制的代价是每次访问子串口之前都要确认当前页。我在wk_dev_t里缓存了一个cur_page写之前比较不一样就先发一次切页事务。缓存这个字段能省掉大量重复的切页开销但也引入了一个隐患——如果别的地方绕过 core 层直接调 SPI 改了 SPAGE缓存就失效了。所以我规定 port 层之上不允许出现裸 SPI 调用这条规矩救过我好几次。注意不同批次、不同手册版本的寄存器偏移可能不一致尤其是 FSR、LSR 这些状态位。拿到芯片第一件事是把手头的偏移表和手册逐条对一遍别直接抄网上的表。3. 底层读写与初始化从软复位到第一帧数据3.1 SPI 事务封装命令字节、切页与连续地址自增所有寄存器访问都收敛到一个函数里这是整个驱动的地基。/* 返回值0 成功负值表示参数或时序错误 */ static int wk_xfer(wk_dev_t *dev, uint8_t page, uint8_t reg, uint8_t *buf, uint16_t len, int is_read) { uint8_t cmd; if (!dev || !buf || len 0u) { return -1; } /* 1. 切页页号变了才发省掉重复开销 */ if (dev-cur_page ! page) { uint8_t pg page; wk_port_cs(dev, 1); /* 拉低选中 */ cmd (uint8_t)(WK_REG_SPAGE 0x7Fu); /* bit70 表示写 */ wk_port_spi_xfer(dev, cmd, NULL, 1); wk_port_spi_xfer(dev, pg, NULL, 1); wk_port_cs(dev, 0); /* 拉高结束 */ dev-cur_page page; } /* 2. 地址 数据同一段 CS 低电平内完成 */ wk_port_cs(dev, 1); cmd (uint8_t)((is_read ? 0x80u : 0x00u) | (reg 0x7Fu)); if (is_read) { wk_port_spi_xfer(dev, cmd, NULL, 1); /* 发地址 */ wk_port_spi_xfer(dev, NULL, buf, len); /* 连续读地址自增 */ } else { /* 写操作地址字节和数据字节要拼在一帧里 */ uart_drv_spi_write_frame(dev, cmd, buf, len); } wk_port_cs(dev, 0); return 0; } /* 写帧的内部实现单独抽出来是因为它和读帧的字节流方向不同 */ static void uart_drv_spi_write_frame(wk_dev_t *dev, uint8_t cmd, const uint8_t *buf, uint16_t len) { uint16_t i; wk_port_spi_xfer(dev, cmd, NULL, 1); for (i 0; i len; i) { wk_port_spi_xfer(dev, (uint8_t *)buf[i], NULL, 1); /* 连续写时地址由芯片内部自增不需要重复发地址 */ } }命令字节的最高位是读写方向位低 7 位是页内偏移这一点和大部分 SPI 从机器件一致。连续访问时地址自动加一所以读写 FIFO 只需要发一次地址后面一路灌数据就行这一点对吞吐影响很大——接收 64 字节如果每字节都重发地址SPI 事务开销直接翻倍。wk_port_cs()参数是「是否拉低」我特意反着写是因为一开始写成cs(0)表示拉低后来改代码时看反了调了半天才发现 CS 根本没动血泪经验命名上宁可啰嗦。3.2 上电初始化复位、波特率分频、FIFO 与中断使能初始化顺序有讲究乱序会出现「配置写进去了但不生效」的假象。我固定的流程是硬件复位 → 软复位 → 全局使能 → 逐通道配置 → 最后开中断。#define WK_XTAL_HZ 12000000u /* 芯片主时钟按板上晶振改 */ #define WK_CH_NUM 2u /* WK2114 是两通道 */ int wk2114_init(wk_dev_t *dev, uint32_t baud) { uint8_t val; uint8_t ch; wk_port_reset(dev); /* RST 拉低再拉高硬件复位 */ wk_port_delay_ms(10); /* 软复位清掉所有寄存器的残留值 */ val 0x01u; wk_xfer(dev, 0, WK_REG_GRST, val, 1, 0); wk_port_delay_ms(5); /* 全局使能退出复位态 */ val 0x01u; wk_xfer(dev, 0, WK_REG_GENA, val, 1, 0); for (ch 1; ch WK_CH_NUM; ch) { if (wk_channel_config(dev, ch, baud) ! 0) { return -2; } } return 0; } static int wk_channel_config(wk_dev_t *dev, uint8_t ch, uint32_t baud) { uint16_t div; uint8_t buf[2]; uint8_t val; /* 波特率分频先按线性公式算误差大就查手册的分频表修正 */ div (uint16_t)((WK_XTAL_HZ baud / 2u) / baud); buf[0] (uint8_t)(div 0xFFu); buf[1] (uint8_t)(div 8); wk_xfer(dev, ch, WK_SUB_SCR, buf, 2, 0); /* 8 数据位、无校验、1 停止位 */ val 0x03u; wk_xfer(dev, ch, WK_SUB_LCR, val, 1, 0); /* 清空收发 FIFO接收触发水位设成 8 字节 */ val 0x03u; wk_xfer(dev, ch, WK_SUB_FCR, val, 1, 0); /* 开接收中断和线路状态中断 */ val 0x03u; wk_xfer(dev, ch, WK_SUB_FIER, val, 1, 0); val 0x01u; wk_xfer(dev, ch, WK_SUB_SIER, val, 1, 0); return 0; }分频值这里我用了线性公式实际手册给的是分频表115200 这种常用波特率算出来的误差通常在可接受范围但如果你要用 250000 这类非标波特率务必拿示波器量 TXD 的位宽误差超过 2% 就得回去查表。晶振频率写成宏换板子只改一个数。初始化里最容易漏的是 FCR 的清 FIFO 位——它是自动清零的写完立刻恢复不需要手动回读确认。但如果你在清 FIFO 之后马上往里写数据有可能被清掉中间隔几个微秒更稳。3.3 中断引脚与 ISR 骨架一次 IRQ 扫完两个通道IRQ 是电平有效、开漏输出芯片内部有中断挂起就会一直拉低。MCU 侧配成下降沿触发但如果 ISR 里没把中断清干净退出中断后引脚还是低会立刻再进一次表现出来就是「程序卡在中断里出不来」。/* 在 stm32f2xx_it.c 里挂到对应的 EXTI 线上 */ void EXTIx_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Linex) ! RESET) { EXTI_ClearITPendingBit(EXTI_Linex); wk2114_irq_handler(g_wk_dev); /* 只做搬运不做解析 */ } } void wk2114_irq_handler(wk_dev_t *dev) { uint8_t ch; uint8_t flag; for (ch 1; ch WK_CH_NUM; ch) { if (wk_xfer(dev, ch, WK_SUB_SIFR, flag, 1, 1) ! 0) { continue; } if (flag 0u) { continue; /* 这一路没事件 */ } /* 写 1 清除先清再处理避免处理期间又被自己打断 */ wk_xfer(dev, ch, WK_SUB_SIFR, flag, 1, 0); if (flag 0x01u) { wk_rx_isr(dev, ch); /* 接收事件 */ } if (flag 0x02u) { wk_tx_isr(dev, ch); /* 发送完成/水位事件 */ } if (flag 0x04u) { wk_err_isr(dev, ch); /* 溢出、帧错、校验错 */ } } }「先清标志再处理」这个顺序是故意的。如果先处理再清处理过程中 FIFO 又进数据产生新事件清标志的时候会把新事件一起清掉表现为偶发丢字节而且极难复现。清完再进去搬 FIFO即使搬运过程中又来了新数据ISR 退出后 IRQ 线还是低会再进一次不会丢。如果两个通道同时有事件这个循环会一起处理完不会出现某个通道饿死的情况。代价是 ISR 执行时间变长所以里面的活必须轻——只搬数据不做任何协议判断。4. 收发链路环形缓冲、FIFO 水位与 485 方向切换4.1 接收路径ISR 只搬字节协议解析交给主循环接收的入口是 RFCNT 寄存器它告诉你 FIFO 里现在有多少字节。读它、循环读 FDAT、塞进环形缓冲就这三步。static void wk_rx_isr(wk_dev_t *dev, uint8_t ch) { uint8_t cnt; uint8_t tmp[32]; uint8_t i; wk_ring_t *rb dev-ch[ch - 1].rx_ring; if (wk_xfer(dev, ch, WK_SUB_RFCNT, cnt, 1, 1) ! 0 || cnt 0u) { return; } if (cnt sizeof(tmp)) { cnt sizeof(tmp); /* 单次最多搬 32 字节防止 ISR 过长 */ } /* 一次事务连续读出地址自增不用逐字节发地址 */ if (wk_xfer(dev, ch, WK_SUB_FDAT, tmp, cnt, 1) ! 0) { return; } for (i 0; i cnt; i) { if (wk_ring_put(rb, tmp[i]) ! 0) { dev-ch[ch - 1].rx_overflow; /* 只有统计不丢帧不阻塞 */ } } }环形缓冲是单生产者单消费者模型ISR 里只调用wk_ring_put()主循环里只调用wk_ring_get()两边都不加锁靠 head/tail 指针的原子读写保证正确。这种写法在 Cortex-M 上成立的前提是指针是 32 位对齐访问编译器不会把它拆成两次半字写。如果换成 8 位机就得进临界区。单次最多搬 32 字节是我加的限制原因是 ISR 里一次 SPI 事务太长会影响其他中断的响应。RFCNT 读出超过 32 字节说明数据积压了这时候剩下的部分会在 ISR 退出后再进来一次继续搬不会丢——前提是 FIFO 没溢出。FIFO 溢出会置位 LSR 里的溢出标志我在wk_err_isr()里把它累加到一个计数器里调试时先看这个数比盲目加打印快得多。主循环侧的取数接口就一行/* 返回实际读到的字节数0 表示当前没有数据 */ int wk2114_read(wk_dev_t *dev, uint8_t ch, uint8_t *buf, uint16_t len);业务层拿到字节之后怎么组帧、怎么判断帧头帧尾、超时多久判为断帧全部在这一层之上驱动不参与。4.2 发送路径用 FIFO 水位续命别在主循环里死等发送最容易写错的地方是「发完一个字节等一个字节」。芯片有 FIFO正确姿势是一次往里灌到水位然后让中断通知你什么时候还能灌。/* 尝试把环形缓冲里的数据推入芯片 FIFO返回推入的字节数 */ int wk2114_write(wk_dev_t *dev, uint8_t ch, const uint8_t *buf, uint16_t len) { uint8_t space; uint16_t n 0; wk_ch_t *c dev-ch[ch - 1]; /* 查询发送 FIFO 剩余空间 */ if (wk_xfer(dev, ch, WK_SUB_TFCNT, space, 1, 1) ! 0 || space 0u) { return 0; } if (len space) { len space; } while (n len) { if (wk_ring_get(c-tx_ring, c-tx_tmp) ! 0) { break; } n; } /* 这里为了可读性做了简化实际是先把 ring 里的数据成批拷进 tmp 数组再一次写 FDAT */ if (n 0u) { wk_xfer(dev, ch, WK_SUB_FDAT, c-tx_buf, n, 0); } return (int)n; }TFCNT返回的是剩余空间而不是已用空间这个语义在手册里写得很清楚但第一次看容易搞反结果就是 FIFO 明明空着却一直不往里写。判断条件写成space 0才对。发送完成的判定有两种FIFO 空标志或者发送完成中断。前者适合轮询后者适合中断驱动。我一般用「水位中断 主循环补数据」的组合FIFO 里剩余空间低于阈值时触发中断ISR 里只置一个标志真正往 FIFO 灌数据在主循环里做。这样 ISR 足够短也避免了在中断里访问环形缓冲的复杂时序。4.3 RS485 半双工自动方向与手动 GPIO 两种做法板子上如果挂的是 485 收发器方向控制是必踩的坑。WK2114 本身支持自动方向控制通过配置寄存器让某根输出脚在发送时自动翻转省掉 MCU 干预。这条路走通之后很省心但有两个前提一是使能位和极性要配对二是收发器的 DE/RE 接法要和芯片的输出逻辑对上。自动方向配不通的时候退回到手动 GPIO 也行/* 手动方向控制发完最后一个字节后必须等移位寄存器空再切回接收 */ static void rs485_send_frame(wk_dev_t *dev, uint8_t ch, const uint8_t *buf, uint16_t len) { rs485_de_high(); /* 切到发送 */ wk2114_write(dev, ch, buf, len); /* 等 FIFO 空 最后一个字节移出两个条件都要满足 */ while (wk_tx_fifo_empty(dev, ch) 0) { ; } wk_port_delay_us(baud_char_time_us(dev-baud)); /* 一个字符时间的余量 */ rs485_de_low(); /* 切回接收 */ }关键在最后那个延时。FIFO 空只代表数据都交给移位器了最后一个字节的停止位还没发完这时候切回接收对方收到的最后一字节会被截断现象是「偶尔最后一字节是乱码」而且波特率越低越明显。延时一个字符时间是最低要求保守一点给两个。注意这个while是阻塞的只在初始化阶段或者低频发送时用。如果业务里要连续发大包改成状态机 定时器别在主线里死等。5. 避坑与排查能读到芯片却收不到数据问题多半在这五处5.1 SPI 层读得到 ID读不到数据现象初始化时读某个寄存器返回固定值看起来通信正常但一配波特率就失败或者写进去读出来对不上。原因SPI 模式或 CS 时序不对。最常见的是 CPOL/CPHA 配成了模式 3主机在第一个边沿采样从机还没把数据推上来读到的全是 0xFF 或 0x00。另一种是 CS 在一帧中间被拉高切页和数据被拆成两段芯片把后半段当成新命令解析。解决先把 SPI 降到 500 kHz用逻辑分析仪抓一帧完整的写-读确认 CS 拉低期间时钟数、数据位序都对得上。确认无误再往上提速。CS 的操作一定要包在临界区里别让其他 SPI 设备插进来。5.2 中断层程序卡在 ISR 里出不来现象一开中断主循环就再也不执行了调试器暂停发现一直在 EXTI 里。原因中断标志没清干净。IRQ 是电平有效SIFR 里只要还有一位没清引脚就保持低电平退出中断立刻重新进入。尤其是有多个中断源时只清了接收位忘了清错误位一样会卡死。解决ISR 里先读 SIFR把整个字节原样写回去清除写 1 清零的寄存器写回读到的值最稳妥再按位处理。处理完之后再读一次确认归零非零就再清一次。这个「再读一次」的兜底动作我在每个带电平中断的驱动里都会加。5.3 FIFO 层数据能收到但每隔一段就丢几个字节现象低速通信正常一旦波特率提到 115200 并且连续发大包就固定间隔丢字节丢的位置还不固定。原因接收 FIFO 溢出。ISR 响应不够快或者单次搬运的字节数太少FIFO 里的数据超过了容量。另外接收触发水位设得太高比如设成 32 字节才触发中断而 FIFO 只有 16 字节深永远等不到中断。解决把接收触发水位降到 8 字节甚至 4 字节让中断更早进来ISR 里一次把 RFCNT 读空别只搬固定几个字节。同时在错误中断里累加溢出计数跑一段时间看这个数是不是 0这是判断接收链路健不健康最直接的指标。5.4 时钟层波特率怎么算都对不上现象能收数据但全是乱码或者对方收到的是错位字符。原因WK_XTAL_HZ宏和板上实际晶振不一致。板子换了个 8 MHz 的晶振代码里还写着 12 MHz分频值算出来偏差 50%自然全乱。另一种是分频寄存器高低字节写反了。解决拿示波器量 TXD 引脚发一个 0x55二进制 01010101直接看方波周期对不对得上波特率。对不上就回去核对晶振宏和字节序。这个办法比任何打印都直观我现在调试串口第一件事就是发 0x55。5.5 应用层两路串口互相干扰现象单独跑一路正常两路同时跑就出问题比如 A 路的回复发到了 B 路。原因切页缓存失效。某处代码绕过 core 层直接操作了 SPI改掉了 SPAGE但cur_page还记着旧值后续所有访问都写到了错误的子串口。解决在 core 层加一个断言每次wk_xfer前后校验cur_page和实际读回的 SPAGE 是否一致不一致就报错。另一个办法更彻底——禁止 port 层之上出现裸 SPI 调用用代码评审和静态检查守住这条线。6. 二次开发从 2 路扩到 4 路、接 DMA、搬进 RTOS这个驱动我后来改过三个方向都是小改动前提是前面那套分层没被破坏。扩通道是最简单的。同系列有 4 通道的型号寄存器布局完全一致只改WK_CH_NUM宏和初始化循环的上界业务侧的通道索引也跟着变。真正要动的地方是中断处理里的循环次数和业务层给每个通道分配缓冲区的数组长度我在wk_dev_t里把通道数组定义成WK_CH_NUM长度扩的时候编译器会帮你把越界的地方都指出来。接 DMA 要改的是wk_port_spi_xfer()。SPI 收发改成 DMA 双缓冲core 层的逻辑一行都不用动因为接口签名没变。这里有个顺序问题要注意DMA 完成中断和 WK2114 的 IRQ 是两个中断源搬运过程中如果 IRQ 进来又要访问 SPI会冲突。我的做法是给 SPI 总线加一个互斥标志谁占用谁置位另一边退让重试。搬进 RTOS 最简单把wk2114_read()外面包一层消息队列ISR 里发信号任务里取数据解析。改造方向改动文件改动量需要注意扩到 4 通道wk2114.h、业务层数组改宏 数组长度通道索引从 1 开始接 DMAwk2114_port.c重写 xfer加总线互斥防重入移植 RTOSext_uart_task.c加队列和任务ISR 里只发信号不解析最后说个教训。早期为了图快我把切页逻辑写在了业务层每个函数开头先写一次 SPAGE当时觉得挺直观。结果第二个开发者接手加了个新功能没写切页查了两天才定位到。从那以后我每次做这类分页寄存器芯片的驱动都强制把页号封装在读写函数内部业务层连 SPAGE 这个寄存器名都看不到。接口收窄一点后面的人就少踩一个坑。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑