type
status
date
slug
summary
tags
category
icon
password
串口的修改
修改代码
硬件逻辑
- ST-Link
上图就是401有关ST-Link的部分。框出来的部分是103提供的信号(是根据usb信号进行处理得到的调试信号),所以可以看出,只有当CN2两个跳帽都装上的时候,103的信号才能到达TCK、TMS两个端口,而这两个端口是连接到401的。所以,如果两个跳帽都装上了,就能使用usb进行调试;反之就不能使用usb进行调试了。
从上图中也能看出,如果两个跳帽没有装上,CN4输入的调试信号也是没有办法使用的;但是如果将两个跳帽都装上了,那么CN4还是不能使用。因为此时就有两个调试信号到达401了(一个是CN4提供的调试信号,一个是103转换usb信号得到的调试信号)。但是如果将两个跳帽都取下的话,虽然不能使用NUCLEO(这个就是小板子)进行调试,但是就可以使用401自己提供的的SWDIO等功能进行调试了(如果跳帽是装上的应该也是不能使用401自己的调试功能的,因为这样也会导致有两个调试信号的输入)
- 串口
- 从原理图中还能发现NUCLEO还提供了一个串口,这也是我们选择修改串口的原因之一。因为我们希望能够让飞线少一点,那么既然NUCLEO提供了串口,那么为什么我们不直接使用呢?
从这里我们可以看到103同样可以通过usb的信号解析出一个串口信号传送给端口STLKTX,STLKRX,而这两个端口也是连接到401上的:
所以我们就尝试不用ch340直接连线与电脑进行通信了,而是选择使用usb信号“直接”进行串口通信。这样操作对项目的唯一的影响应该就是真正起飞的时候使用蓝牙要换一些引脚,但是当我们还在项目开发阶段的时候如果没有蓝牙(讲道理也没必要使用蓝牙)就可以直接使用usb进行串口通信,简直是瑕不掩瑜好吧
注意到上面STLKTX,STLKRX端口中间还有两个引脚——CN3。但是经过实际测试,当我使用usb的时候(因为usb没有像stlink一样的跳帽能控制,一插上usb应该就已经在输出串口信号了),使用CN3还有401自己的串口引脚时都无法正常通信。大概率还是因为使用ubs时再使用CN3或者401自带的串口引脚就会导致401的串口输入源和输出目的信号不唯一(主要影响的应该是输入信号,因为不同的源都想要输入)。当然这也是我自己的猜测,目前手上的资料好像都没有给出很好的解释,我只能认为跟STlink是同样的问题了=)
操作寄存器
之前的代码是调用hal库的,这里处于性能的考虑,以及为了能够不使我们的项目依赖库函数,所以这里将上个学期使用hal库编写的串口代码修改为直接操作寄存器版本。寄存器的封装是STM32官方已经给我们实现了的(在头文件stm32f401xe.h中),所以这里可以直接不使用hal库,直接将代码修改为操作寄存器版本的。
对于代码修改的具体内容就不在这里细说了,实际上就是将原有的代码所有的hal库函数全部展开再稍微删掉一些不需要的部分,因为hal库本身也是直接操作寄存器的
值得一提的就是hal库计算波特率的公式
hal中设置Baud的宏定义函数如下:
下面逐个说明一下这些函数的作用:
- UARTDIVMANTSAMPLING16:计算USART_DIV的整数部分
- UARTDIVFRAQSAMPLING16:计算USART_DIV的小数部分
- UARTDIVSAMPLING16:这个函数就我来看应该是为了计算小数部分而存在的,因为这里面全都是整数运算,没办法算出小数,所以只能在计算之前将数扩大(体现为在这个函数中对串口频率*25)
其中最复杂的应该就是UARTDIVFRAQSAMPLING16函数了,来解释一下:
- 第一部分:
(UART_DIV_SAMPLING16((_PCLK_), (_BAUD_)) - (UART_DIVMANT_SAMPLING16((_PCLK_), (_BAUD_)) * 100U))
这一个部分就相当于是获取小数部分,但是并不是真正的小数部分,而是小数部分的100倍(这样才能将小数部分存储在一个整形值中)。其中UARTDIVSAMPLING16((PCLK), (BAUD))是USARTDIV的100倍,(UARTDIVMANTSAMPLING16((PCLK), (BAUD)) * 100U)是USARTDIV整数部分的100倍,所以相减之后就是小数部分的100倍了。至于为什么是一百倍,可能是因为100倍已经够用了吧)
- 第二部分
- 第二部分实际上就是做了一个乘16,这个乘16是为了将小数部分的前四位移动到整数部分(因为小数部分一定是0.多少),以便后面将小数部分放到BRR寄存器的低四位
- 第三部分
- 第三部分是一个加50。这一步困扰了我挺久的。这一步实际上就是:(int)(小数部分*16+0.5),所以是一个四舍五入。这个四舍五入是为了让精度更高一点,这样经过移位后的小数就更接近真实的小数,这也导致了在hal库中还加上了一部分,也就是overflow部分,因为经过四舍五入之后可能会导致整数部分变化。
上OS!
- 总体设计
- 串口还是比较复杂的,因为接收和发送(从单片机视角)是不同的逻辑
- 接收:串口接收数据主要是接收从电脑传来的信息,并且接收数据是使用中断实现的。在UC中,虽然不能在中断中获取一个信号量(是为了不让系统在中断程序中待太久了。并且如果在中断中获取信号量很可能导致任务被莫名其妙挂起,因为获取信号量是中断在请求,而不是当前正在执行的任务),但是UCOS却允许在中断中去释放一个信号量(别问为什么,问就是原码这么写的)。在中断中直接释放一个信号量跟一般情况下释放一个信号量唯一的区别就是在信号量释放之后有没有执行任务的调度(因为有中断的情况下UC不允许调度),但是无所谓,中断退出的时候会执行中断退出函数,在中断退出函数中就会实现任务调度。这里由于一定要使用到中断,所以必须采用共享内存的方式了。也就是中断服务程序将数据存放在共享内存处。另外UCOS是允许中断嵌套的,所以在中断服务程序中需要设置临界区,防止中断在访问共享资源的时候被其他中断打断造成共享区域不互斥的情况。由于原来的裸板上的逻辑就是将接收到的数据存放在一个共享内存中,所以几乎没什么要改的,只不过是在ISR中加上一些东西就好了。这里还能顺便封装一下串口接收数据的函数,原来都要直接去操作共享内存的,这里就将这些逻辑都封装在一个函数中了。但是也不能一次串口接收中断就来一次释放信号量,因为每次中断函数接收数据都是只接收一个字节,如果要实现在中断中使用信号量唤醒一个读任务的话就需要在中断中一次读完一条数据,也就是需要读到\r\n的时候才释放一个信号量(所以可以在接收到\n的时候再去释放一个信号量)。中断这么频繁是没办法的,因为stm32就是接收到一个字节就会发送一个RXNE的中断
- 另外,需要注意的是,需要在中断服务程序的开始位置调用OSIntEnter,然后在ISR的末尾调用OSIntExit
- 发送:串口发送数据之前是直接通过重写printf函数实现的。但是在多任务的情况下就不能在任务中随便使用printf函数了,因为有可能一个任务输出数据输出一半被打断,然后另一个任务恰好要输出数据,这个时候就会出现打印信息交错的情况。更微观一点,可能在串口数据寄存器中的数据还没有转移到移位寄存器中时就被打断,这个时候甚至可能出现数据的丢失。出现这个情况的本质原因应该是任务都想要对临界资源进行访问。这里的临界资源应该是串口或是共享内存,每一个任务都应该将当前需要传送的所有字符都成功通过串口传送或者全部放到共享内存中后才能允许下一个任务进入。如果不实现互斥的话就会出现下面这种打印混乱的情况(主要还是因为printf是线程不安全的吧。而且这种情况只有当当前任务正在执行的过程中被高优先级任务抢占了才会出现,如果像点灯那样低优先级和高优先级任务同时到达,这个时候就不会出现这种问题,主要还是因为UC不是传统的时间片轮转型的,并不是那种一个任务执行一会而不考虑优先级的,而是高优先级任务在执行的过程中永远不会被低优先级抢占,这就使得这个问题不太容易出现):
- 任务形式
- 这种形式就要使用共享内存(由于使用了共享内存所以还需要使用到互斥信号量)。主要的逻辑就是,所有需要通过串口发送数据的任务都互斥地将数据存放在一个共享的数据中。然后创建一个任务,周期性的从共享内存中消费数据,这个时候就是一个多生产者,单消费者的同步互斥问题了。还有一种方式就是每使用一次print就创建一个任务来负责(主要是因为任务函数的参数的限制,如果使用共享内存的话,任务函数的参数就是相同的,因为任务函数的参数是在任务创建的时候就确定了的,这个时候就可以只使用一个任务,但是这个时候就需要等待共享内存的读写;如果不使用共享内存的话,那么print任务函数的参数就是不一样的了,这个时候可以创建多个任务,每个需要print的函数都可以全速执行,但是开销比较大),虽然这样可以让所有的任务都全速执行,但是空间开销太大了,而且也不允许这么做,因为优先级没有办法重复。
- 任务形式的开销
- 这种情况下需要创建一个任务,还需要创建一个共享内存,会比较占用空间
- 并且由于新建了一个周期性任务,任务切换的频率应该会变高。
- 并且所有的任务也不一定能全速执行,因为所有的任务之间都需要互斥使用共享内存
- 并且实时性应该比较差,当前的任务已经将数据放到共享内存处了,但是串口发送任务如果周期性执行的频率较低,就不会立即传送出该数据。而且有的数据应该是要求实时性比较强的,比如姿态相关的数据,从不能说飞机都坠机了才传出来传感器的信息吧)
- 并且这种形式是一个多生产单消费的问题,也算是比较复杂吧)
- 纯信号量形式
- 这种形式就单纯使用信号量来实现互斥。主要的逻辑就是,重新封装printf函数,当有任务需要使用printf函数的时候就申请信号量,printf函数结束之后再释放互斥信号量。这种情况下没有使用共享内存,而是直接使用串口将数据传送出去(当然也可以理解为将串口的DR寄存器当成了一个共享内存)。
- 纯信号量形式的开销:使用纯信号量与任务形式的第3点开销是相同的,也就是所有的任务由于需要获取printf的互斥信号量,可能就需要等待一段时间(但是这段时间是不可能省下来的,因为资源一定要互斥)。但是除此以外,从我这里来看,感觉纯信号量是碾压任务形式的。一是不需要占用额外空间,二是只是单纯的函数切换,而不是任务切换,切换的成本变小了,三是数据的实时性变好了,四是实现比较简单(我想偷懒!)
下图是稍微修改了点灯代码之后能让问题稳定出现的版本(实际上就是让低优先级任务不再是周期性任务了,这样低优先级任务在printf的时候被周期性高优先级任务打断的可能性就很大):
从上图中可以看见低优先级任务被打断了,并且低优先级任务原来应该打印出来的LED2OFF后面的D2OFF部分已经被高优先级任务覆盖掉了,导致回到低优先级任务的时候打印出来的不是D2OFF,而是D2ON
所以应该是有两种实现方法的:
综合以上的考虑,发送数据选择使用纯信号量的形式,就不给串口再创建一个发送数据的任务了
- UCOS的信号量机制
- 先不论使用哪种方式来实现串口的发送和接收,信号量是一定要使用的。那么就先来看看UCOS的信号量机制:
- 创建信号量
- 在UCOS中,型号量算是一个事件,所以创建信号量时需要从空闲的事件队列中取出一个ECB(事件控制块)并对其进行相关的初始化。事件控制块的结构如下:
- OSEventType:表示当前时间的类型,可以是信号量等
- OSEventPtr:用于管理事件队列的变量,因为事件队列是一个链表
- OSEventCnt:在使用信号量时,这个成员表示信号量的值(也就是资源的数量)
- OSEventGrp:相当于优先级位图法中的Grp
- OSEventTbl:表示的是阻塞在当前事件上的任务(所以这个应该算是一个记录型的信号量)。这个参数很有意思,他是一个大小为OSEVENTTBLSIZE、类型为OSPRIO(INT8U)的数组,被定义为:
#define OS_EVENT_TBL_SIZE ((OS_LOWEST_PRIO) / 8u + 1u) /* Size of event table
- 获取信号量
- 实际上跟操作系统课上所说的对信号量的操作差不多,只不过真正实现的时候需要很多的校验操作。大致还是先判断当前有无资源可以使用,有的话就执行OSEventCnt--;如果没有资源可以使用的话,就需要更新一下当前尝试获取信号量的任务的状态(也就是准备从就绪状态转换为阻塞了),如设置任务的状态,任务延时时间(这个时候设置的就相当于是最大延时了,因为任务正常情况下应该是由其他任务释放信号量的操作唤醒的,这里多设置了一个OSTCBDly就是说如果当前任务没有被其他任务通过释放信号量的方式唤醒,如果时间到了的话自己就会唤醒自己)等
- 释放信号量
- 实际上也向上面所猜测的,就是从当前事件的阻塞队列中选取出优先级最高的任务,然后将其重新放入就绪队列,并将其从阻塞队列中移除。里面最重要的函数应该就是下面这个了:
这里能大概看出,这个就是一个优先级位图了(所以也能看出优先级位图并不是一定是88的,可能是x\8的),在后续的获取信号量处能够验证
有关这里设置的延时时间,书上是这么说的:
OSSemPend()函数允许用户定义一个最长等待时间作为它的参数,这样可以避免该任务无休止地等待下去,如果一个timeout的值大于0,那么该任务就将一直等到信号有效或者等待超时。如果timeout的值为0,该任务将一直等待下去
这里说的一直等待应该就是指当前任务会一直等待信号量有效
设置完当前任务的状态之后,就需要涉及到优先级位图相关的操作了。在UCOS中使用函数OS_EventTaskWait()来实现:
上面实际上做的操作就是将当前任务放入当前信号量的阻塞事件组中(使用优先级位图),然后同样通过优先级位图法将当前任务移出就绪队列。接下来就是重新调度了
在调度之后过了一段时间如果重新回到了当前任务(说明事件发生,也就是信号量被释放了),那么就表示当前任务应该已经获取到了信号量(当然如果设置了timeout的话也可能是超时了,这里就不考虑超时的因素了,超时了只不过是在后面加上了一些判断),这个时候对应的修改TCB中的一些成员即可。从这里也能大概猜出来,释放信号量的时候做的主要工作就是从当前信号量的阻塞队列中取出一个优先级最高的任务重新移动到就绪队列并重新调度
他做的事情就是我上面所说的,只不过多加了一些TCB成员变量的修改。这个函数执行结束之后将重新启动调度。这个时候不需要对OSEventCnt++是因为还有任务阻塞在这个信号量上,这个时候直接进行调度就相当于是++之后再--,也就是没变了。所以后面如果发现没有任务阻塞在这个信号量上时就要对OSEventCnt++了,也就是下面这段代码:
第二个if只会在没有任务阻塞在当前信号量上的时候才对OSEventCnt++,并且还保证了信号量数量不会溢出(实际上也不会溢出吧,除非一个任务中嵌套请求了一个信号量多次)
- 实现
- 接收:串口的接受主要还是在中断中对共享内存进行写操作,因为如果使用信号量机制在中断中去唤醒一个任务来将接收到的数据写到共享内存的话可能导致串口中的数据被覆盖,因为任务不一定马上被调度,如果要让任务马上被调度的话就需要将任务的优先级设定得很高,这样跟中断也没有什么区别了,而且开销感觉比中断还大。所以这里还是选择直接在中断中完成共享内存的写操作,完成一条数据的写的时候释放一个信号量,剩下的交给读串口数据任务。对于共享内存的读操作,就使用多消费者(就很像那个儿子只吃橘子女儿只吃苹果的题)的情况(这里可以使用多个信号量来实现根据消息唤醒相应的任务,但是目前串口还没有到要接收数据的地步,所以指定接收者的信号量就)。主要的逻辑就是,首先在数据中转任务中读共享内存的时候要保证互斥,因为可能在数据中转任务中处理共享内存中的数据的时候上一次由数据中转任务唤醒的任务也在处理共享内存中的数据,并且在每次被数据中转任务唤醒的任务对共享内存的读都是在消费数据。其次需要根据该共享内存中的数据来唤醒相应的任务(这个判断逻辑就先不写了,等后续有东西需要使用串口接收到的数据的时候再修改,这里就直接打印出来了),出来的结果是这样的(在主函数中没有循环来等待串口接收数据):
- 发送:串口的发送如果不考虑uf(user friendly)的话,我完全可以直接重新定义一个函数,他接收一个字符指针,然后在函数内部先请求信号量,然后调用printf函数打印该字符指针指向的内容,然后再释放信号量。但是这样打印函数的功能相较于printf就差太多了(因为只能传入一个字符串,甚至不能打印变量的值),所以这里选择去模仿printf函数的实现,使用可变参数和格式化字符串。
- 最终实现出来的print函数还是比较简单、但是能够像普通的printf一样使用的(其中的信号量初始化为一个互斥信号量):
- 串口初始化的时候波特率不够精确
- vprintf函数自身在高速运行的情况下容易出错
我发送一个hello自动就唤醒了接收数据的任务将数据重新打印出来,而后面还有两个优先级比较低的点灯任务正在运行。
经过测试(这个测试方法不太行,因为print的速度太快了,所以有的时候会出现一些数据丢失啥的)usart_printf还是明显优于printf的(最少不会出现上面那种因为抢占出现的错误了)。一些数据丢失的情况如下:
这显然不是因为抢占,因为如果抢占的话就一定会打印出高优先级任务需要输出的部分(而且如果是抢占产生的话也不会隔这么近就出现),但是这里是直接丢了几个字符。目前没有对这个问题进行进一步的考虑(因为我认为稳定性足够了。。。),我自己猜测的可能的几个原因如下:
杂记
- hal库中的assert_param()是进行参数检查的,但是需要一个宏定义才能启用它。如果没有启用的话该函数永远返回0,也就是检查无误
- hal库在初始化串口的时候会对串口的句柄进行加锁,但是在我们的代码中是没必要的
- USEHALUARTREGISTERCALLBACKS这个宏定义涉及到回调函数注册的特性:
- 如果 USEHALUARTREGISTERCALLBACKS 被设置为 0 ,那么将不会使用回调函数注册的特性。这意味着你不能使用 HALUARTRegisterCallback() 函数来动态改变在UART事件(如接收完成事件)发生时调用的回调函数。
也就是回调函数无法被动态改变,换句话就是回调函数是静态的了。这个是hal库提供的特性,后续如果需要动态变化的回调函数的话就可以看看hal库是怎么实现的
字面上的理解,回调函数就是一个参数,将这个函数作为参数传到另一个函数里面,当那个函数执行完之后,再执行传进去的这个函数。这个过程就叫做回调
- ctrl+alt+-才是回退到上一个文件
- UNUSED函数是为了防止编译器因为变量未使用而报警告的
- #define UNUSED(X) (void)X /* To avoid gcc/g++ warnings */
- 呃呃,好像要初始化的是串口2而不是串口1,还好所有的串口的寄存器布局都是一样的,只需要改一下宏定义就好了
从上图中可以看见串口连接的是PA2,PA3
手册上这两个引脚是串口2的TX和RX,这两个都要复用为组7
- 关于中断的设置函数,内核已经提供了一些函数:
这些函数还是很简单的直接操作寄存器
- STM32可编程的优先级位数只有4位
Each priority field holds a priority value, 0-255. The lower the value, the greater the priority of the corresponding interrupt. The processor implements only bits[7:4] of each field, bits[3:0] read as zero and ignore writes.
- USARTCR2CLKEN这一位可以让串口输出一个时钟信号
- 在 STM32 的 USART(通用同步异步收发器)中,CLKEN 位用于控制是否输出一个时钟信号。当 CLKEN 位被设置时,USART 将在其对应的 CK 引脚上输出一个时钟信号。这个时钟信号可以用于同步操作
此外串口还有很多的功能,比如智能卡、车网络、红外接口等功能,这些都能在串口的控制寄存器中设置
- 串口是有四个寄存器的,两个是移位寄存器,两个是数据寄存器(分接收还是发送)。接收数据时是先将数据收到移位寄存器中,然后再将移位寄存器中的值移动到数据寄存器,当移位完成的时候,就会产生一个RXNE,表示数据寄存器中有值可以读出;发送数据的时候是先将数据放在数据寄存器中,然后将数据移动到移位寄存器中,如果数据寄存器中所有的值都已经移动到移位寄存器了,就会产生一个TXE,表示数据寄存器是空的,可以继续放数据了:
- 发送跟接收都是有两个中断的。接收在数据寄存器为空的时候会发送一次中断,当数据发送出去的时候(就是移位寄存器也为空的时候)还会再发一次中断(TC这个中断可以在CR1中设置)。接收也是,当数据寄存器不空的时候会发送一个中断,当接收寄存器中有数据然后移位寄存器中还有数据的时候,就会发送一个overrun的中断(这个中断也可以在CR1中设置,但是需要注意的是这个中断和RXNE使用同一个中断使能位的)
- linux上使用picocom串口,实现终端接收
这个是一个设置别名的方式(是linux自带的),这样我就能只输入pc就能打开串口工具了
然后使用picocom真的能收到数据了。。。。大概真是vofa的问题,要不然就是
- linux下的换行符是\r\n哦,(md又忘了),如果只使用\n的话接收到的数据就会有点问题(前面会莫名其妙多空格):
- linux终端中打开picocom,在picocom终端中敲一个回车只会有一个0d(也就是一个\r),不会在后面再跟上一个0a,这也导致了下面没办法再将传送给32的hello回传回来
所以要修改一下picocom的设置:
这个映射可以把一个\r映射为\r\n,需要使用的选项为omap,表示的是从当前设备(电脑)输出(output)到串口设备(也就是板子)的数据
甚至可以在这个的基础上再将板子发送的数据末尾的\n映射为\r\n,这样在代码中就只需要写\n就好了。而且picocom默认发送数据的时候是不回显的,所以在取别名的时候顺便把回显选项(-c)加上
所以我的别名设置如下:
可以成功接收发送哩:
但是要注意的是alias命令只在当前终端中生效,如果想要一个永久的别名的话,那就要在~/.bashrc文件中加入:
也就是把别名echo进~/.bashrc,让别名在全局生效(很像配了个环境变量)。这样就可以让别名全局生效了。下面是一个新开的终端
- 下面这个宏没有任何效果
#ifndef OS_TRACE_SEM_PEND_ENTER #define OS_TRACE_SEM_PEND_ENTER(p_sem, timeout) #endif
- 串口波特率的计算公式:
小问题
为什么st官网上找不到原理图了)
汗流浃背了,原来不是每个引脚单独使能(想想也是,这样的话还要使能整个端口干嘛呢)
md数组名是一个常量指针,指针的值不能修改
UCOS到底允不允许中断嵌套
原来书上的中断服务程序为什么要那样写,把上下文切换放在一起完全是可行的啊,也就是将所有的上下文切换都放在中断结束之后,中断服务程序中就专心处理中断的事务,只需要在ISR执行之前将ISR中需要使用的寄存器压栈即可,然后退出中断的时候去恢复使用的寄存器的值,最后切换回正常模式不是所有的现场都是原来任务的现场,这样再重新调度一次就好了
书上的中断嵌套的层数的增加为什么不算临界区
OSIntEnter函数为什么不在里面写临界区,而是在外面需要调用的时候再写临界区
之前没有发现的问题(共享的数组接受到数据之后没有添加结束符,会导致如果先收到长的数据再收到短的数据就会出现拼接):
大问题
401上的串口是收不到数据的,杜邦线在NUCLEO上反接能收到数据,说明是有数据从103来的;但是正接就不行,说明没有数据从401来(所以也就不是子为说的103接收401的数据然后原封不动地传送回来了,而是应该就是103自己在发送数据,有没有可能是将串口2的功能直接分摊到NUCLEO上了?——这样就解释了为什么正接没有数据,直接接在401的板子上也是没有数据的。那这样的话NUCLEO做的事就很多了,需要在烧录的时候将串口2相关的代码都转换成103的版本并烧录在103上,并且401想要通过串口2发送一条数据的时候就需要以某种方式(甚至不是串口2?因为如果就是串口2的话杜邦线正接就会有数据)去通知103发送数据(但是感觉这样实现的话成本太高了,但是事实表明只有这种可能了,因为杜邦线反接能收到数据,正接不能收到数据)。这个后面还可以试试,但是NUCLEO是个专利,怕是研究不明白了
- 作者:Noah
- 链接:https://imnoah.top/article/Serial
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。