4. NuttX串口驱动框架:uart_register()与uart_register_dev()、串口设备结构体定义、驱动分层(上/下/硬件层)

好,咱们今天聊点硬核的。NuttX的串口驱动,说白了就是一套分层清晰的“流水线”。我刚开始接触NuttX时,第一反应是:这玩意儿怎么比Linux还绕?但真正用起来才发现,这种分层设计,恰恰是它能在资源受限的MCU上跑得稳的关键。

今天咱们就拆开看看,这套框架到底是怎么搭起来的。重点就三个东西:注册函数设备结构体驱动分层。搞懂了这些,你写自己的串口驱动,心里就有谱了。

4.1 驱动分层:上/下/硬件层

NuttX的串口驱动,不是一锅粥。它分了三层,各干各的活。我画了张图,你一看就明白。

上层(Upper Half) uart_register() / uart_register_dev() 负责:设备注册、文件操作接口(open/read/write/ioctl) 下层(Lower Half) 串口底层操作函数集(ops 结构体) 负责:中断处理、DMA传输、FIFO管理 硬件层(Hardware Layer) STM32 USART 外设寄存器操作 负责:波特率设置、GPIO配置、中断使能、数据收发 硬件相关 OS相关

嗯,这张图就是咱们今天的主线。上层是“面子”,下层是“里子”,硬件层是“底子”。

4.2 串口设备结构体定义

在NuttX里,每个串口设备都对应一个结构体。这个结构体,就是驱动的心脏。我习惯叫它 uart_dev_s,但不同芯片厂商可能会包一层自己的结构体。

咱们直接看代码,这是NuttX官方定义的核心结构体:

struct uart_dev_s
{
  /* 公共部分 */
  int      isconsole;   /* 是不是控制台? */
  uint8_t  open_count;  /* 打开次数计数 */
  sem_t    closelock;   /* 关闭锁 */
  
  /* 接收端 */
  struct uart_buffer_s *recv;     /* 接收缓冲区 */
  struct uart_buffer_s *xmit;     /* 发送缓冲区 */
  
  /* 下层操作函数集 */
  const struct uart_ops_s *ops;   /* 重点!下层操作函数指针 */
  
  /* 私有数据 */
  FAR void *priv;                 /* 指向硬件层私有数据 */
};

这里有个关键点:ops 指针。它指向一个 uart_ops_s 结构体,里面全是函数指针。说白了,上层调用的所有底层操作,都是通过这个指针跳转的。

uart_ops_s 长什么样?我截取一部分:

struct uart_ops_s
{
  int  (*setup)(FAR struct uart_dev_s *dev);
  void (*shutdown)(FAR struct uart_dev_s *dev);
  int  (*attach)(FAR struct uart_dev_s *dev);
  void (*detach)(FAR struct uart_dev_s *dev);
  int  (*ioctl)(FAR struct uart_dev_s *dev, int cmd, unsigned long arg);
  int  (*receive)(FAR struct uart_dev_s *dev, unsigned int *status);
  void (*send)(FAR struct uart_dev_s *dev, int ch);
  bool (*rxflow)(FAR struct uart_dev_s *dev, bool enable);
  /* ... 还有更多 */
};

你看,setup 负责初始化硬件,send 负责发一个字节,receive 负责收一个字节。每个函数都对应一个硬件操作。我在移植STM32串口时,主要就是填这个结构体。

我的经验:uart_ops_s 时,别急着把所有函数都实现。先实现 setupsendreceive 这三个,能跑通再说。其他的像 ioctlrxflow,可以后面慢慢补。我刚开始做时,一口气写了十几个函数,结果调试时发现 setup 里波特率算错了,白忙活半天。

4.3 uart_register() 与 uart_register_dev()

结构体定义好了,怎么把它注册到系统里?NuttX提供了两个注册函数,很多人搞混它们。我当年也踩过这个坑。

4.3.1 uart_register()

这个函数是“老版本”的注册方式。它直接创建一个字符设备节点,比如 /dev/ttyS0。原型如下:

int uart_register(FAR const char *path, FAR struct uart_dev_s *dev);

用法很简单:

struct uart_dev_s g_uart1_dev;
/* ... 初始化结构体 ... */
uart_register("/dev/ttyS0", &g_uart1_dev);

调用完,系统里就有了 /dev/ttyS0,应用程序可以 open() 它。

4.3.2 uart_register_dev()

这个函数是“新版本”,NuttX 7.x 以后引入的。它更灵活,支持动态分配设备号。原型:

int uart_register_dev(FAR const char *path, FAR struct uart_dev_s *dev);

用法几乎一样:

uart_register_dev("/dev/ttyS0", &g_uart1_dev);

那区别在哪?我直接说重点:

对比项 uart_register() uart_register_dev()
引入版本 旧版(NuttX 7.x之前) 新版(NuttX 7.x之后)
设备号管理 静态分配 动态分配
内部实现 直接调用 register_driver() 调用 register_driver() + 额外初始化
推荐使用 兼容旧代码 新项目首选
注意: 如果你用的是NuttX 10.x以上版本,建议直接用 uart_register_dev()。我曾经在一个项目里用了 uart_register(),结果升级NuttX版本后,发现设备号冲突,排查了半天。后来改成 uart_register_dev(),问题就解决了。

4.4 驱动分层实战:以STM32为例

光说不练假把式。咱们拿STM32F4的串口驱动,看看这三层到底怎么配合。

4.4.1 硬件层:操作寄存器

STM32的USART外设,寄存器就那么几个。硬件层的代码,说白了就是读写这些寄存器。比如:

/* 发送一个字节 */
static void stm32_serial_send(FAR struct uart_dev_s *dev, int ch)
{
  FAR struct stm32_serial_s *priv = (FAR struct stm32_serial_s *)dev->priv;
  /* 等待发送数据寄存器空 */
  while ((priv->usart_base->sr & USART_SR_TXE) == 0);
  /* 写入数据 */
  priv->usart_base->dr = (uint16_t)ch;
}

你看,这里直接操作了 usart_base->srusart_base->dr。这就是硬件层。

4.4.2 下层:组装操作函数集

下层就是把硬件层的函数,填到 uart_ops_s 结构体里:

static const struct uart_ops_s g_stm32_serial_ops =
{
  .setup    = stm32_serial_setup,
  .shutdown = stm32_serial_shutdown,
  .attach   = stm32_serial_attach,
  .detach   = stm32_serial_detach,
  .ioctl    = stm32_serial_ioctl,
  .receive  = stm32_serial_receive,
  .send     = stm32_serial_send,
  /* ... */
};

嗯,这里有个细节:stm32_serial_sendstm32_serial_receive 都是硬件层的函数,但通过这个结构体,它们被“包装”成了下层接口。

4.4.3 上层:注册到系统

最后,在初始化函数里,调用注册函数:

int stm32_serial_initialize(void)
{
  /* 初始化设备结构体 */
  g_uart1_dev.ops    = &g_stm32_serial_ops;
  g_uart1_dev.priv   = &g_uart1_priv;
  g_uart1_dev.recv   = &g_uart1_recvbuf;
  g_uart1_dev.xmit   = &g_uart1_xmitbuf;
  
  /* 注册到系统 */
  uart_register_dev("/dev/ttyS0", &g_uart1_dev);
  
  return OK;
}

到这里,一个完整的串口驱动就注册好了。应用程序调用 open("/dev/ttyS0", O_RDWR) 时,NuttX的文件系统会找到这个设备,然后通过 ops 指针,调用到硬件层的函数。

核心思想: 上层不关心你用的是STM32还是GD32,它只认 uart_ops_s 里的函数指针。下层不关心文件操作怎么实现,它只管收发字节。硬件层更纯粹,只操作寄存器。这就是分层的好处——各司其职,互不干扰。

4.5 避坑指南

最后,分享几个我实际项目中踩过的坑:

  • 缓冲区大小: 默认的接收缓冲区可能只有256字节。如果你跑的是高波特率(比如921600),中断来得太快,缓冲区会溢出。我建议根据你的数据量,适当调大 CONFIG_SERIAL_RXBUFSIZE
  • 中断优先级: 串口中断优先级不要设得太低,否则在高负载下会丢数据。我曾经把优先级设成了最低,结果系统一忙,串口就丢字节,排查了两天才发现。
  • DMA vs 中断: 对于低波特率(115200以下),中断模式完全够用。高波特率才考虑DMA。别一上来就上DMA,调试起来更复杂。

好了,关于NuttX串口驱动的框架,咱们就聊到这儿。分层设计、结构体定义、注册函数,这三块搞明白了,移植任何MCU的串口驱动,思路都是一样的。


公众号:蓝海资料掘金营,微信deep3321