第二部分:通过网口下载内核映像
要实现通过网口下载文件的功能,从底层到上层需要做的工作包括:开发板上的网卡芯片的驱动程序;TCP/IP协议栈的实现;TFTP客户端应用程序的实现。我们使用的OK2440开发板配备CS8900A网卡芯片。 为了简单起见,网络数据包的发送和接收都使用轮询方式,不使用中断;协议栈只使用ARP/IP/UDP协议,不涉及TCP及其他协议;应用程序只实现最简单的TFTP客户端。 1. 全局配置信息 发送和接收的数据缓冲区,使用全局静态缓冲区,不使用动态内存分配。第一阶段运行结束之后,CPU内部4KB的SteppingStone可以用作其它用途,我们就用它做网络数据接收、发送的缓冲区。亦可用作标准输入输出的缓冲区。 unsigned char *TxBuf = (unsigned char *)0; unsigned char *RxBuf = (unsigned char *)1024; 使用若干个全局变量来保存网络配置信息: unsigned char NetOurEther[6] = /* Our ethernet address */ {0x00, 0x09, 0x58, 0xD8, 0x11, 0x22}; 开发板的MAC地址,这个是任意设置的。 unsigned char NetServerEther[6] = /* Boot server enet address */ {0x00, 0x14, 0x2A, 0xA5, 0x50, 0x97}; 服务器也就是主机的MAC地址,这个要跟主机MAC一致,可以在主机上运行ifconfig命令查到。 unsigned long NetOurIP = 0xC0A801FC; /* Our IP addr 192.168.1.252 */ unsigned long NetServerIP = 0xC0A801F9; /* Server IP 192.168.1.249 */ 网络协议中IP地址一般是用一个4字节整型数表示的。 2. CS8900A以太网驱动程序 硬件电路决定了CS8900的物理地址是在BANK3的区间内,CS8900是16位的寄存器,故我们设置BANK3的BUS WIDTH也为16位。设置BANK3: 总线宽度16,使能nWait,使能UB/LB BANKCON3:0x1F7C
读芯片ID: CS8900的芯片ID存放在PP_ChipID寄存器中,读该寄存器得到的正确值应该是0x630E,这可以初步判断一些地址/引脚的设置是否正确,如果读出的不是0x630E,那么CS8900肯定不能正常工作。 设置MAC地址: MAC地址并不是固定的,可以由我们随意设置。从寄存器PP_IA开始的6个字节存放MAC地址。比如下面的代码把MAC地址设为 00 09 58 D8 11 22:
因为是Little Endian, 所以0x09<<8, 但是在寄存器内存中还是 0x00放在前面。 寄存器初始化: 设置CS8900的工作模式
发送数据包: int eth_send (volatile void *packet, int length) 两个参数:要发送的数据包首地址、长度 TxCMD 和TxLen寄存器用来初始化数据包的发送,其具体含义见CS8900数据手册第70页。这里PP_TxCmd_TxStart_Full被定义为 0x00C0,表示直到整个数据侦都加载到CS8900内部缓存之后才开始发送,数据侦的长度为CS8900_TxLEN.
使用TxCMD下达发送数据的命令后,再读取 PP_BusSTAT 总线状态寄存器判断是否做好发送数据的准备。当get_reg (PP_BusSTAT) & PP_BusSTAT_TxRDY 不等于零时表示可以发送了。 使用一个循环进行实际的发送操作:
这里 addr 也是unsigned short类型的指针, 每次向CS8900_RTDATA写入两个字节数据。这里假设要发送的数据包长度为偶数。 最后,通过读取PP_TER寄存器可以知道是否发送完毕,是否发送成功。 接收数据包: 首先,通过读取PP_RER寄存器判断是否接收到数据。如果接收到数据,则连续两次读取 CS8900_RTDATA 的值, status = CS8900_RTDATA; /* stat */ rxlen = CS8900_RTDATA; /* len */ rxlen 为接收到的数据长度。 然后用一个循环连续读取 rxlen 长度的数据:
其中 RxBuf 为预先在内存中开辟的一块接收缓冲区。 每次循环读取两个字节,还需要处理长度为奇数的情况。 最后,把RxBuf交给上层的协议处理:net_receive( &RxBuf[0], rxlen ); 3. Ethernet MAC层协议的实现 上层的数据包(如IP包、ARP包)到来时,需要添加一个14字节的MAC头, 然后再交给网卡发送出去。 MAC头包含目的MAC地址、源MAC地址、协议类型三个字段。如下图所示。数据包末尾的CRC校验我们不使用。 ![]() 使用下面的代码填充MAC头。其中协议类型,对IP为0x0800, 对ARP为0x0806
4. ARP协议的实现 一般的方式是建立一个全局的ARP映射缓存表,随着系统的运行不断查找、更新该表。但是我们要完成的功能仅仅是从TFTP服务器下载内核和文件系统映像,而服务器的IP和MAC地址都是固定的,因此可以简化ARP映射表,只用两个变量分别保存服务器IP和MAC,再用两个变量保存开发板IP和MAC即可。并且更新映射表的功能也可以省略,只在系统初始化时把这四个地址都设置好,使用过程中不会发生改变,所以不需要更新。这样,我们的ARP协议只需要完成接受ARP请求、发送ARP应答的功能,而发送ARP请求和接受ARP应答的功能可以省略,这样大大简化了协议栈的设计。 按照维基百科上的介绍(http://en./wiki/Address_Resolution_Protocol),ARP 是一个数据链路层协议,(我感觉它应该是网络层的协议),它的作用是在只知道一个主机网络层IP地址的情况下找到它的硬件地址。在以太网上,它主要用来把 IP地址转换为以太网MAC地址。由于是链路层协议,ARP的作用范围仅限于本地局域网。 ![]() 对各个段作简单的解释: Hardware type (HTYPE) 每个数据链路层协议都被分配到一个数,比如,Ethernet 是 1 Protocol type (PTYPE) 在这个域,每个网络层协议都被分配到一个数(标号),比如,IP是0x0800 Hardware length (HLEN) 硬件地址的长度。以太网Ethernet的MAC地址长度是6个字节 Protocol length (PLEN) 维基上写的是“逻辑地址”的长度,其实也就是网络层地址的长度。IPv4地址的长度为4个字节。 Operation 表明发送者的操作,也就是数据包的类型:1表示ARP请求;2表示ARP回应;3表示RARP请求;4表示RARP回应。 Sender hardware address (SHA) 发送者的硬件地址 Sender protocol address (SPA) 发送者的协议地址,也就是发送者IP地址。 Target hardware address (THA) 目标接收者的硬件MAC地址。如果是ARP请求,这个域被忽略。 Target protocol address (TPA) 目标接收者的IP地址。 知道了包结构,我们就可以设计一个结构体:
属性 __attribute__((packet)) 告诉编译器使用紧缩方式存放结构体内容(1 Byte align), 不使用默认的4字节对齐, 这样就不会产生冗余字节。此时的 sizeof(struct arp_header) = 28。 如果不加packed属性, 运行 sizeof(struct arp_header) 得到 32, 而不是 28。 数据段就产生了错位。 前面已经说过,我们只实现接收ARP请求并发送ARP应答的功能,因此只用一个简单的函数就可实现:
接收到的数据保存在pRx地址处,要发送的数据地址指定为pTx位于发送缓冲区中。如果接收到的是ARP请求包并且IP地址也符合,则在pTx处构造一个ARP应答包并交给mac_send()发送出去。 5. IP协议的实现 IP数据包的格式如下表所示:
IP协议的简化:IP协议在网络中主要完成路由选择和网络分段的功能。起始Bit 0-3表示版本号,对IPv4来说取值为4即0100即可。Header length域指明IP数据包header的长度(不包括数据Data域),以四字节为单位,因为Options域是可选的所以IP Header的长度并不固定。我们不使用Option域,所以取最小值5,表示Header长度为20字节。服务类型域(Type of Service, TOS)是为特殊的应用如VoIP等保留的,我们不使用,赋值为零即可。接下来2个字节的Total Length域表示整个数据包的长度,包括Header和Data,以字节为单位。 标识域(Identification)用来给数据包一个唯一的编号,用于验证和跟踪等,我们不使用,直接赋值为零即可。Flags和Offset用于分段包的重组,我们不使用,把Flags的第2位设为1表示是不可分段的,Offset赋值为零即可。生存时间(Time to Live, TTL)表示该数据包在网络上的有效期,我们简单的把它设为最大值0xFF即可。协议域(Protocol)表示传输层使用什么协议,RFC790文档为每个协议都规定了唯一的编号,如UDP编号为17。Header Checksum为Header区域的校验和,在校验之前该域初始为0,然后计算整个头部的校验和,把结果存放在该域,计算校验的方法是把头部看成以16位为单位的数字组成,依次进行二进制反码求和。接下来的八个字节是源IP地址和目的IP地址,没什么可说的。 综上所述,我们只保留了IP协议中必须的关键字段,因而简化了设计,对IP数据包进行填充的代码段如下:
CheckSum 校验和: IP,TCP,UDP等许多协议的头部都设置了校验和项,它们采用的算法是一样的,将被校验的数据按16位进行划分(若数据字节长度为奇数,则在数据尾部补一个字节0),对每16位求反码和,然后再对和取反码。 代码如下:
6. UDP协议的实现
在传输层我们抛弃了复杂的TCP协议而使用简单的UDP协议。虽然UDP是无连接的协议,它不保证数据包一定能够到达目的主机,但是在嵌入式开发中,开发板跟主机通常位于同一内部局域网内,网络环境良好,数据丢失的可能性很小,并且UDP容易实现,占用资源小,因此更适合于嵌入式环境。 UDP头部包含了可选的校验和字段,而校验要涉及到伪报头,为了简化设计和减小开销,我们不使用校验,直接把该字段设为零,表示不使用校验。UDP包填充代码如下:
关于源端口号和目的端口号的设定,在TFTP实现时会详细说明。 7. TFTP客户端的实现 tftp是一个很简单的文件传输协议,在传输层使用UDP协议。它有四种类型的包: 读请求RRQ包,DATA包,ACK包,ERROR包,每个包的前两个字节Opcode指定包的类型。(RRQ用于请求下载,WRQ用于请求上传,我们只用到RRQ)。 ![]() 下载文件的过程分析如下: 客户端(A)从任意端口X向服务器(S)的端口69发送一个RRQ包,该包中指明了要求下载的文件名;服务器(S)找到该文件,读取文件内容组成DATA包,从任意端口Y向客户端(A)的端口X发送这个DATA包,第一个DATA包编号为1;从此以后,客户端确定使用端口X,服务器确定使用端口Y, 客户端向服务器发送ACK包,编号为1。服务器接到编号为1的ACK包之后,发送第二个DATA包,如此继续下去。
怎样判断传输结束呢? 按照规定,DATA包中的数据段为512字节, 如果小于512字节,表示这是最后一个DATA包,文件已传输完毕。 ![]() (R1) Host A requests to read ![]() (R2) Server S sends data packet 1 ![]() 注意在这个过程中端口的变化。开始RRQ是69,但是DATA和ACK都不是使用69,而是使用另外一个随机的端口。 服务器在接到RRQ后,不返回任何回应信息,直接发送第一个DATA包,而且DATA包编号从1开始,而不是从0开始。
编程时为简单起见,客户端使用了固定的端口号X=0x8DA4,服务器端口号Y是随机的,只能通过解析UDP数据包获得。
第三部分:源代码,运行结果
这一部分将对前文没有提到的几段关键代码进行简单说明,介绍一下源代码组织结构和Makefile系统,展示一下实验运行结果,并提供全部源代码下载。 1. 定时器初始化和延时程序
因为在 CS8900A的驱动程序中需要用到延时,因此有必要对S3C2440的计时器进行使能和初始化,并编写延时程序。S3C2440A共有5个定时器,编号为Timer0 ~ Timer4。其中Timer0 ~ Timer3都有输出引脚,可以通过定时器来控制引脚电平周期性的变化,这称为脉冲宽度调制(PWM:Pulse Width Modulation)功能。而Timer4没有输出引脚,也就没有PWM功能,所以Timer4常被程序里的延时函数使用。 定时器部件的时钟源为PCLK,但是需要经过两级预分频之后才真正供定时器使用。第一级预分频由TCFG0寄存器控制,其位[7:0]设置预分频器0的值,供Timer0和Timer1使用,位[15:8]设置预分频器1的值,供Timer2 ~ Timer4使用。第二级预分频由TCFG1寄存器控制,其每四位控制一个定时器,可以从2分频、4分频、8分频、16分频、外接TCLK0/TCLK1 这五种频率中选择。 我们的延时函数使用Timer4,其它定时器全部关闭。初始化程序中设置:TCFG0 = 0x0f00; 表示Timer4的第一级预分频值为 15+1 = 16。寄存器TCFG1使用默认值全0,表示第二级预分频为2分频。前面已经设置PCLK为50MHz,这样Timer4实际的工作频率为: 50MHz/16/2 = 50000000/32 = 1562500Hz 注意计算时钟频率时的MHz是指10^6,而不是2^20;同理KHz是指1000Hz,而不是1024Hz。 我们在TCON中把Timer4设为”自动加载“。当Timer4启动时,TCNTB4的值将被自动装入内部寄存器TCNT4,然后在工作频率下,TCNT4开始减1计数,当到达0时,TCNTB4的值又被自动装入TCNT4,下一个计数流程开始。我们把TCNTB4设为15625,则一个计数流程的的长度为10毫秒。 假设要延时的时间为msec毫秒,则共需要的计数值为 tmo = msec*15625/10,设一个变量timestamp保存已经过去的时间戳,每次读取TCNT4的值后更新timestamp,直到它大于 tmo 。程序如下:
TCNT4的值可由寄存器TCNTO4读出。程序中保存了最近两次读出的TCNTO4值, 如果本次值比上次小,说明在同一个计数流程内;如果本次值比上次大,说明已经进入了下一个计数流程。 2. 串口标准输入输出要想在Bootloader中使用scanf()和print()并不容易,因为不能直接使用C库函数。scanf()要从串口获得输入, print()要向串口进行输出。必须自己实现常用的C库函数, 不仅包括输入输出函数,还包括字符串操作函数如strcmp(), strcpy()等。幸好在《嵌入式Linux应用开发完全手册》这本书的源代码中提供了这样简化的C库,所以就直接拿来用了。代码中定义了两个全局数组作为输入输出缓冲区: static unsigned char g_pcOutBuf[ 1024 ]; static unsigned char g_pcInBuf[ 1024 ]; 其实我们可以把这两个缓冲区定位在CPU的 SteppingStone 里面,这样可以节省2K的空间。 scanf()的实现里面调用 getc() 函数, printf() 的实现里面调用 putc() 函数。我们自己写getc()函数为从串口读取字符, putc()函数实现为向串口发送字符, 这样标准输入输出就跟串口联系在一起了。
3. 源代码组织结构源代码跟目录下只有两个文件, 主Makefile和链接脚本sboot.lds。文件夹start内有start.S和nand.c,前者是上电后最初运行的汇编代码,后者含有Nand Flash的读函数,负责把S-Boot代码从Nand拷贝到RAM中。 文件夹main内有main.c,是一个死循环,提供若干菜单供用户选择,然后调用相应功能的程序。 文件夹lib内是简化和移植过的C标准库,包括输入输出和字符串操作函数。 文件夹include内是一些头文件。 文件夹app内有boot_linux.c和tftp.c,从名字就能看出它们的功能。 文件夹device内含有设备驱动程序,如串口初始化、定时器初始化和延时函数、网卡驱动、网络协议实现等。 每个文件夹内都有自己的Makefile,根目录下的主Makefile会进入各个子目录并调用各自的Makefile。每个子目录下的Makefile把自己编译的代码链接成一个build-in.o文件, 主Makefile把各个子目录下的build-in.o链接成一个可执行文件。 编译器使用自己制作的 arm-hwlee-linux-gnueabi-gcc. 可以从这里下载。 给gcc增加 -nostdinc 选项, 表示不使用标准C库函数,不到/usr/include目录下寻找包含文件, 只在-I$(INCLUDEDIR)指定的目录寻找包含文件。 4. 提供全部源代码下载:
5. 运行结果截图![]() 图中,首先选择3从TFTP服务器下载内核到RAM中, 然后选择4从RAM成功启动内核。 选择2还有通过串口Kermit协议下载内核的功能,前文没有对这部分代码作分析,有时间再补上。下面附一张截图: ![]() http://blog./u/7459/showart_2022660.html |
|