Quadlator II第五章
Quadlator II--RT-Linux内核驱动基础
作者:baredog
五,RT-Linux内核驱动基础
2003年筹划写的时候,原先这一章命名为RT-Linux内核驱动基础。现在动笔的时候,不禁有些踌躇。这么大的题目,并非我能力所及,难免让读者失望了。(在国内忽悠盛行的今天,深怕扣上忽悠读者的帽子)。思考下来,说些下面的内容总还能有所帮助:
- 内核模块如何访问和驱动硬件
- 怎样把用户控制指令转换为最终对硬件的指令
- 怎样把各个硬件反馈回来的状态的数据等物理量通知用户
- 怎样规划控制程序的架构
首先要说明,总共有哪些硬件需要驱动,为了组成反馈控制的大闭环(相对于titech robot driver内部的小闭环)。需要以下一些组件:
- 通讯模块,用于各个模块和嵌入式系统的通信。
- DIO模块, 用于数字量输入输出,可以用来直接驱动舵机或者读取光电码盘等等。
- DA模块,用户数字量转换为模拟量输出,输出的电压可以用来进一步驱动电机驱动器。
- AD模块,模拟量转换为数字量,如果是通过电位器进行位置反馈的话,可将位置信息数字化后,输入嵌入系统。
Quadlator II使用了除AD模块以外的全部其他模块。因此需要编写对应各个模块的驱动程序。这里我觉得没有必要把每个驱动程序的所有细节都在这里说明,第一,硬件千变万化,并且随着科技的进步还会不断更新;第二,本着介绍东西到国内的想法,如果讲一些更加通用的原则,会更有帮助。
如果面临这样多的模块,如何把他们组成一个反馈闭环呢?一般有两种思路:直接电缆连接和采用某种总线。直接电缆连接的好处是快速,但是要给每对被连接的模块专门制作连接用的接口。最后造成系统上接口不够用,例如:
核心系统(titechwire或者单片机)<==>DA(假设多通道,可以驱动4个电机)
核心系统<==>DIO (假设多通道可以读取3个光电码盘)
那么一个12电机,12个光点码盘的机器人,共需要上面的接口12/4+12/3=7个接口。这些接口都是数字两,假设是8位数字量,那么几乎很少有核心系统能提供这么多IO口出来(共计8*7=56个IO口)。
因此,针对上述问题,titechwire的思路是采用总线的方法。总线的思路可以用下面的图来说明:
一条蓝色的数据总线从核心系统出发,最后又回到核心系统形成一个环路。这样只需要消耗2x8=16个IO口。所有的模块都接入到这个总线上,每个模块自身有一个唯一的编号。这样当核心系统需要控制第4个模块时,它向总线上发出一个数据包,数据包的开始位置,有模块表示号4,这个数据包出发后沿总线循环一圈后返回。在这个过程中,每个模块都会接收到这个数据包,模块会对比自己的唯一地址和数据包头的地址是否一致。如果不一致就不做任何处理,如果一致,就会收取这个数据包,然后根据包内的内容进行动作。这就好比邮递员沿街道送邮包,只有邮包上标记的门牌号和实际门牌号一致的时候,邮递员才会把邮包送给这家的主人。
著名的I2C总线就是采用这一类型的方案,实现芯片间的通讯(可以参考ACM-R3机器蛇的相关文章)。titechwire为了增加总线的容量,有使用了逻辑地址和物理地址分开的概念,将所有的模块组成一个树状结构:如下图:
每个树枝节点的构造如下:
物理地址和逻辑地址分开的概念,就好比电话分机。例如某个电话局的电话号码只有2位(当然这不大可能,中国很多二线城市的电话号码现在都是8位了)。那么这个电话局最多能够安装00-99共100部电话,假设这100部电话都装好了。但是过了一阵时间,号码为25的用户,由于公司业务需要,要增加3部电话,这怎么办呢?在电话局不升位的前提下,可以给这个用户安装总机-分机系统。用户要和这个公司的第2部电话联系时,需要拨打号码25(物理地址)然后转拨2号分机(逻辑地址)。
这种方案本质上是一种串行化的思想,对比前面并行化的思路,缺点在于速度上受影响,优点在于节省接口资源,如果总线频率足够高的话,其优势就可以体现出来。采用这种方案后,titechwire驱动模块的能力极大的提高,可以充分发挥嵌入式系统的计算能力,控制非常复杂的机械系统。
由于采用了上述结构,造成了titechwire的驱动程序中有相当部分的内容是处理数据包,其定义了Packet作为总线上数据包的模型。然后又专门写了一个驱动来控制总线上数据的收发。这些细节比较复杂,但是写出来对国内的读者的直接参考意义不大,考虑到参考一些I2C总线的介绍书籍即可获得概念,因此略去。
内核模块中,拥有对所有端口的访问控制能力,可以直接IO。例如:
#define IOWRITE16(port, value) outport(port, value)
#define IOREAD16(port) inport(port)
static void IOWRITE32(const unsigned short port, const unsigned long data){
/* First, write the LSB (16 bits) */
IOWRITE16(port , (unsigned short) data);
/* Next, write the MSB (16 bits) */
IOWRITE16((unsigned short)(port+2), (unsigned short) (data >> 16));
}
static unsigned long IOREAD32(const unsigned short port){
unsigned long dataLSB, dataMSB;
/* First, read the LSB data (16 bits) */
dataLSB = IOREAD16(port );
/* Next, read the MSB data (16 bits) */
dataMSB = IOREAD16((unsigned short)(port+2));
/* Finally, construct the 32 bits data */
return ((0xffffL & dataLSB) + (0xffff0000L & (dataMSB << 16)));
}
这段代码十分基本,目的是读写32位数据,因为直接调用Linux的system call函数中的outport和inport是操控16位的,所以如果读写32位数据,就要注意高低字节的顺序。可以看到,在Linux的内核模块中,对硬件IO端口的访问是非常简单直观的。这也是前面第四部分说阐述过的。
大量的硬件操作是通过对硬件上的寄存器进行读写实现的。通常的做法是向命令寄存器中写入命令后,硬件做相应的动作。硬件返回的内容,可以通过读状态寄存器获得。例如:
void TwIsaRegWrite(const unsigned long regAdr, const unsigned long regData){
IOWRITE32(TWReg_IOPortAddr, regAdr );
IOWRITE32(TWDat_IOPortAddr, regData);
}
void TwIsaRegRead(const unsigned long regAdr, unsigned long *regData){
IOWRITE32(TWReg_IOPortAddr, regAdr );
*regData = IOREAD32(TWDat_IOPortAddr);
}
有了这样方便操控硬件的能力,就可以进一步进行复杂组合,操纵模块了,下面是DA驱动的头文件:
#include "TwIoModule.h"
#include "TwPort.h"
typedef struct TwDaModule{
TwIoModule IoModule;
}TwDaModule;
void writeDaData(TwDaModule* pDAC, const int daCh, const unsigned long DAdata);
void writeDaDataAll(TwDaModule* pDAC, const unsigned long DAdata[8]);
void readGeneric8words16bitsData(TwDaModule* pDAC, unsigned long data[8]);
void TwDaModuleCreate(TwDaModule* pDAC, TwPort* pTwPort, int HexSwValue);
void TwDaModuleDestroy(TwDaModule* pDAC);
虽然是ANSI C的,但是仍然使用了面向对象的思想对DA进行建模。前面讨论兼容性的时候,讨论过为什么用C而不是C++的原因。内核模块全部工作在内核空间,而 Linux内核本身就是C写成的(还有极少数汇编)。使用C开发内核程序相对来说,会最大的减小兼容性问题的影响。这个DA模块中,最核心的输出功能实现如下:
//
// 输出数据为32位
// daCh: DA通道号,2个通道对应一个节点
//
void writeDaData(TwDaModule* pDAC, const int daCh, const unsigned long DAdata){
switch(daCh){
case 0:
case 1:
payLoad(&(pDAC->IoModule.outData32bitLSB[0]), daCh, DAdata);
break;
case 2:
case 3:
payLoad(&(pDAC->IoModule.outData32bitMSB[0]), daCh, DAdata);
break;
case 4:
case 5:
payLoad(&(pDAC->IoModule.outData32bitLSB[1]), daCh, DAdata);
break;
case 6:
case 7:
payLoad(&(pDAC->IoModule.outData32bitMSB[1]), daCh, DAdata);
break;
}
}
DA的基本概念,这里不再给出,相信这方面的参考资料应该很丰富。其他模块,包括总线控制,光电码盘读入的DIO,这里都略去。更有参考价值的是这些模块组合在一起形成一个独立的电机驱动的逻辑模块。这个模块和第四部分作为例子给出的Frank模块非常相似。并且第四部分结束时提出了一些值得思考的问题,这些问题的答案也一并在这里给出。
首先,上层的机器人控制程序需要和内核驱动模块建立一个通讯协议,在FIFO中将按照此协议传递数据。其定义如下:
typedef struct RT_CMD{
enum CMD_IDS id; //命令,可以为:ID_POWER_ON, ID_START, ID_STOP, ID_DRIVE
long value; //内核实时线程的运行周期,单位为毫秒
double data[4][3]; //data may be speed, position, or torque
}RT_CMD;
然后内核模块初始化,建立3个FIFO,一个handler,并启动一个线程:
int init_module(void){
rtl_printf("motor_mod.c: call init module..................... ");
EXPORT_NO_SYMBOLS;
//建立用户控制程序和handler通讯用的FIFO
rtf_destroy(HANDLE_FIFO);
rtf_create(HANDLE_FIFO, sizeof(RT_CMD));
rtf_create_handler(HANDLE_FIFO, cmd_handler);
//建立用户控制程序向内核模块发送命令的FIFO
rtf_destroy(CMD_FIFO);
rtf_create(CMD_FIFO, sizeof(RT_CMD));
//建立内核模块向用户控制程序返回数据结果的FIFO
rtf_destroy(RESULT_FIFO);
rtf_create(RESULT_FIFO, sizeof(RET_DATA)*BUFF_SIZE);
return pthread_create(&motor_ctrl_thread, NULL,
(void*)main_routine, NULL);
}
handler的实现,几乎和第四部分介绍的frank相同,也在此略去。控制主线程实现如下:
void* main_routine(void* arg){
int err;
RT_CMD cmd;
while(1){
pthread_wait_np(); //suspend the current thread
//until the next period(np: non-portable)
//get the fifo channel
if((err=rtf_get(CMD_FIFO, &cmd, sizeof(cmd))) == sizeof(cmd)){
switch(cmd.id){
case ID_POWER_ON:
motor_create(&motor);
break;
case ID_START:
rtl_printf("start. ");
pthread_make_periodic_np(pthread_self(), gethrtime(),
cmd.value*1000*1000); //value in ms
tm_start= gethrtime();
ret_data.i_pos=0;
break;
case ID_STOP:
motor_destroy(&motor);
rtl_printf("stop. ");
pthread_suspend_np(pthread_self());
break;
case ID_DRIVE:
motor_drive(cmd.data);
break;
default:
break;
}
} //end get cmd
} //while
return 0;
}
主线程从FIFO通道中读取用户下达的命令,如果为上电命令(power on),就初始化电机驱动模块,反之如果为停止命令(stop)就释放电机控制模块。并且还将上电和启动(start)的概念进行了区分,因此机器人的电源打开,并不一定立即动作,而是所有初始化工作完毕后等待用户最终的启动命令。一旦收到启动命令,就以用户指定的实时周期为单位,不断唤醒线程。如果受到用户发来的驱动命令(drive),就会调用电机的驱动函数motor_drive,其实现如下:
static void motor_drive(double da_value[4][3]){
tm_now=gethrtime();
ret_data.t=(double)(tm_now-tm_start)/1e9;
motor_measure(&motor, ret_data.theta);
motor_output (&motor, da_value);
rtf_put(RESULT_FIFO, &ret_data, sizeof(ret_data));
}
电机驱动的函数,接受4足12个关节的DA输出指令,并且记录当前的时间,这个时间可以精确道纳秒,此后程序立即测量所有12个关节的光电码盘,来测量位置反馈。然后将输出指令写入DA模块,驱动电机转动,最后程序将所有测量出的反馈量作为结果,通过数据FIFO发送给用户程序。
至此,第五部分开头提到的一些内容,都蜻蜓点水般的提了一下,希望能够有所帮助。在下一部分,我将尝试介绍PID控制的一些内容。
=====
回到总目录
返回ROBOTDIY主页
|