前言
控制台,做为一种人机交互接口,相较于其他接口(显示器、网络终端),可能是最简单的。它耗用资源少,容易配置,几乎是任何芯片会自带的外设。而且可以很容易和计算机建立连接。因而,串口控制台可能是程序员进行人机交互的首选。
启用控制台,可以帮我们在系统全速运行时窥探系统运行状况。可以监测其它外设或者组件初始化过程。
一月份,笔者在论坛发的 serialX 串口驱动反响很大,应该会让很多人眼前一亮(老王卖瓜)。当初,决定费力研究它的初衷很简单 —— 应用和驱动弱耦合、真阻塞非阻塞特性。这两个月来,笔者一直在实际项目中使用它,而且应用到了控制台上,同时发现了一些问题。
控制台串口问题汇总
问题一、任务调度器启动前 `rt_kprintf` 死循环到 tx 函数
问题原因是,笔者打开控制台串口设定的 flag 是阻塞写方式,无论是中断发送还是 DMA 发送都依赖中断。但是任务调度器启动前是关全局中断的,这样导致触发发送失败,发送缓冲区满了以后再有写动作就会永久死到 tx 函数里。
> 应对之策:任务调度器启动前只能使用 poll 发送模式。任务调度器启动后或临启动时切换到中断或 DMA 模式。
问题二、出现异常后,`rt_kprintf` 无输出
这个现象和上一条有点儿类似。不同的是,虽然全局中断是开着的,但是串口中断优先级不足,导致控制台设备停止工作。
在 arm9 架构上出现 Undef SWI PAbt DBbt 等等 trap 后,串口外设中断级别不足,导致这些 trap 中的 printf 输出失效。
> 应对之策:unset 控制台串口设备,或者将控制台串口切换到 poll 发送模式。
问题三、`rt_hw_interrupt_disable` 之后 `rt_kprintf` 无输出
这个和“问题一”是一样的,根本原因是,非 poll 模式必须有中断它才能工作,关中断以后设备停止工作。
> 应对之策:尽量减少关中断时间;避免在关中断之后写串口设备。
问题四、遇到 `_rt_scheduler_stack_check` 也会停止输出
因为 `_rt_scheduler_stack_check` 函数最后先关全局中断,然后进入 while 死循环。这个时候串口中断肯定也失效了。
> 应对之策:关全局中断前,先 flush 串口设备。让串口把 “stack overflow” 的提示信息输出完。
问题五、打断点后 `rt_kprintf` 输出不完整,部分数据没输出到控制台
因为 debug 断点停止的时候,前边 printf 缓存的数据可能还没来得及送到串口移位寄存器,cpu 的时钟被断点打断停止运行了,导致部分数据没输出。继续运行程序就可以出现剩余信息输出。这是非阻塞设备的特性。
以上这些问题是所有非 poll 非阻塞设备输出都会遇到的现象。在 RTOS 系统里,应用程序不可避免地要和中断打交道,了解中断对我们编程思想的影响很重要。
完整解决方案
rt_device 增加 flush 接口
flush 接口对带缓存设备是极其有用的,无论是阻塞还是非阻塞模式,我们总有需求要求*在某个代码节点设备的缓存已经是空的*,*或者要求实现通信同步*。
`struct rt_device` 增加 `flush` 接口
struct rt_device
{
...
rt_err_t (*flush) (rt_device_t dev);
...
};
serialX.c 添加 `flush` 回调函数实现 `static rt_err_t rt_serial_flush(struct rt_device *dev)` ,用于等待串口驱动层发送缓存发完数据。另外底层外设也增加 flush 接口,用于等待串口发送寄存器中的*最后一个字节数据*被搬到了移位发送寄存器中。
console 添加 unset flush 控制台设备接口
void rt_console_unset_device()
{
if (_console_device != RT_NULL)
{
/* close old console device */
rt_device_close(_console_device);
_console_device = RT_NULL;
}
}
RT_WEAK void rt_hw_console_flush()
{
/* empty console output */
}
void rt_console_flush()
{
#ifdef RT_USING_DEVICE
if (_console_device == RT_NULL)
{
rt_hw_console_flush();
}
else
{
rt_device_flush(_console_device);
}
#else
rt_hw_console_flush();
#endif /* RT_USING_DEVICE */
}
有 set 也有 unset, 不是吗? unset 是为了调用 `rt_hw_console_output` 而不是 `rt_device_write` 输出打印信息。
`rt_console_flush` 既考虑启用设备框架也考虑未启用设备框架两种情况。`rt_device_flush(_console_device)` 会调用上文的 `rt_serial_flush` ;`rt_hw_console_flush` 和 `rt_hw_console_output` 类似用于不使用设备框架,自定义 `rt_kprintf` 底层接口时要实现的。视实际情况实现 `rt_hw_console_flush` 。例如 NUC970 UART 自带了 FIFO ,需要实现 `rt_hw_console_flush`
> 如果使用了 DMA 模式,底层实现 flush 还是有点儿难度的。需要花点儿心思。
延迟 `rt_console_set_device` 调用
挪到任务调度器启动前,那么之前的控制台输出怎么实现?答案是使用 `rt_hw_console_output`。如上所说,第一次使用 poll 模式打开控制台串口,到这里临启动任务调度器的时候再次用 中断/DMA 模式打开控制台串口也可以。但是,多次用不同模式打开同一个设备会引入另外的问题,要不要先关闭上次的 open 呢?假如之前没有打开过呢?
/* Set the shell console output device */
#ifdef RT_USING_CONSOLE
rt_console_flush();
rt_console_set_device(RT_CONSOLE_DEVICE_NAME);
#endif
/* start scheduler */
rt_system_scheduler_start();
这时候,我们的 `rt_console_set_device` 可以用任何模式打开控制台串口设备
if (rt_device_open(new_device, RT_DEVICE_OFLAG_RDWR | RT_DEVICE_FLAG_STREAM
| RT_DEVICE_FLAG_INT_RX
| RT_DEVICE_FLAG_INT_TX
) == RT_EOK) {
_console_device = new_device;
}
或者,先用 poll 模式 set console device
/* set new console device */
if (rt_device_open(new_device, RT_DEVICE_OFLAG_RDWR | RT_DEVICE_FLAG_STREAM
) == RT_EOK) {
_console_device = new_device;
}
当第二次 reset 的时候,需要先 unset (用到了上面提到的 `rt_console_unset_device`),因为 `rt_console_set_device` 不允许重复 set 同一个设备,也没法修改打开设备的参数。写另外一个 set api 也就变的必要了
if (rt_device_open(new_device, RT_DEVICE_OFLAG_RDWR | RT_DEVICE_FLAG_STREAM
| RT_DEVICE_FLAG_INT_RX
| RT_DEVICE_FLAG_INT_TX
) == RT_EOK) {
_console_device = new_device;
}
但是,我为什么不喜欢这种方式呢?
1. board 初始化阶段需要初始化系统时钟、倍频 cpu 时钟、 `rt_hw_systick_init`、 `rt_system_heap_init`、 `rt_hw_pin_init`、还有 `rt_hw_usart_init` 设备,可能还有 `rt_console_set_device`。为了能第一时间使用上控制台串口,`rt_hw_usart_init` 必须尽早执行,然后是 `rt_console_set_device` 。
2. 但是 uart 设备可能用到动态申请内存,这样就必然要求 `rt_system_heap_init` 先于 `rt_hw_usart_init` 。
3. 初始化系统时钟、倍频 cpu 时钟、 `rt_hw_systick_init`、 `rt_system_heap_init`、 `rt_hw_usart_init` 。也只能这样了,前边几步的串口打印需求就忽略了吧,`rt_system_heap_init`->`rt_memheap_init` 里的 `RT_DEBUG_LOG` 调试信息就忽略了吧。
如果不着急用串口设备,先简单初始化串口外设,让 `rt_hw_console_output` 以最快的速度工作起来。如此一来,初始化流程可能就可以变成,初始化系统时钟、倍频 cpu 时钟、 **`rt_hw_console_init`**、 `rt_hw_systick_init`、... 。后面是初始化顺序都无关紧要了,而且所有的打印信息需求都可以满足。最后在任务调度器启动前选择某个串口设备做控制台串口,将会避免前文说到的*问题一*。
进入不可恢复状态的处理
- 以 `_rt_scheduler_stack_check` 为例,关中断前先 flush 控制台。
rt_console_flush();
level = rt_hw_interrupt_disable();
while (level);
- 还比如 SWI 异常,`rt_hw_cpu_shutdown` 也会关中断,进入 while 死循环。先 unset 控制台,使用 `rt_hw_console_output` 进行 poll 输出之后的输出需求。
void rt_hw_trap_swi(struct rt_hw_register *regs)
{
rt_console_unset_device();
rt_hw_show_register(regs); rt_kprintf("software interrupt\n");
rt_hw_cpu_shutdown();
}
**注:为避免因中断优先级,引起串口设备中断得不到响应,在中断响应里切忌调用 `rt_console_flush` 函数**
结束
控制台串口在系统中扮演着极其重要的角色,对其处理不当,会引起各种依赖问题。有人就有疑虑了,做其它通信用时 serialX 会不会存在同样的隐患?笔者保证,您不在中断响应里调用 `rt_device_flush` 就不会出现以上所有列出来的问题。
笔者下一篇计划聊聊内核启动流程的问题,虽然之前发过一篇文章 [rt-thread 系统启动及 SysTick 初始化流程优化可行性分析]( https://club.rt-thread.org/ask/article/2881.html ),里面提了一种可能的系统启动流程,当时只是一种想法,并不系统。再写一篇,笔者希望把需要考虑的问题以及优缺点系统化地说明白,可能还会提及控制台串口设备,以及控制台对内核启动流程的影响。
相关文章:
rt-thread 驱动篇(一) serialX 框架理论
rt-thread 驱动篇(二) serialX 理论实现
rt-thread 驱动篇(三) serialX 压力测试
rt-thread 驱动篇(四)serialX 多架构适配
rt-thread 驱动篇(五)serialX 小试牛刀
审核编辑:汤梓红