CSDN仅用于增加百度收录权重,排版未优化,日常不维护。请访问:www.hceng.cn 查看、评论。
本博文对应地址: https://hceng.cn/2018/03/28/iMX6ULL上手体验/#more
第一次接触NXP/Freescale的SOC,记录拿到板子后快速上手的技巧和思维的方式。
iMX6ULL感觉还是很有优势的,除了之前接触的NanoPi(全志H3),就没见过几个运行Linux,只卖100多元的开发板。
Cortex-A7架构,主频528MHz,一些普通的嵌入式Linux应用领域足够了。
感觉未来几年,运行Linux的板子会越来越便宜,嵌入式Linux会越来越普及。
对于一个陌生的SOC,首先就是要准备相关的资料,核心的就是参考手册和电路图。
资料的来源无非有三个:
芯片官网
官网是参考手册的来源;
NXP的官网做得还是比较清晰,很容易就找到了i.MX6ULL提供的文档。
开发板提供厂家
开发板厂家一般都提供所有的资料,包括参考手册、电路图、使用手册、工具、系统等;
我这使用的是米尔科技的板子,官网的资料路径不好找,直接问客服要资料链接更快。
网络
Google/Baidu用于搜索相关博文的一些细节资料,比如某一块别人的分析。
随便提一下,科学上网是基本素养。
拿到一个板子,首先是观察板子上大致有什么资源。
比如看到SOC附近有两个芯片,一般一个是RAM,一个Flash;
有个TF卡接口和拨动开关,多半是TF卡启动和Flash的启动选择;
一个网口、USB接口、Micro USB接口、电源接口,USB接口可能用于下载或串口或供电;
三组排针,其中三针上的丝印有RX、TX、GND,肯定是串口接口;
两个按键和几个LED灯,背后还有一个FPC插座,多半是接显示屏;
以及我的是IOT版,还有一个WIFI天线。
再查看厂家提供的资料,验证一些猜想。
上面的猜想几乎八九不离十,现在知道了可以通过“Boot Select”来TF/Nand启动。
现在有三个方向,
其实,这三个领域,都能玩,但都比较尴尬,
既然这样,就做无任何资料的裸机吧,开启hard模式。
确定了方向,先是做裸机,
首先就需要知道如何将裸机代码放到存储介质(Nand或TF卡),然后启动裸机代码。
如何下手呢?
我也不知道,跟着厂家提供的资料,重新烧写一遍系统,这个过程中肯定包含Uboot,Uboot就是一个大裸机程序,只要炮制Uboot的烧写方式烧写裸机即可。
i.MX6ULL系统更新使用两种方法,MfgTool更新和SD卡更新。
MFGTools是NXP官方推荐的一个使用OTG来升级镜像的软件。可以用来升级Linux、升级Android;单独刷写某一系统分区,如 android的boot.img分区等;独立地刷写spi nor、emmc 等等;
操作方式按着厂商的操作即可。
另外,MfgTool的文件更新有两个部分:firmware和files。
firmware是烧写系统的镜像文件(作为媒介用途的镜像),路径为"MYS-6ULXmfgtools/
Profiles/Linux/OS Firmware/firmware/"。
files目录下为烧写的目标镜像文件(真正烧录到emmc或者nand的镜像文件),路径为"MYS-6ULX-mfgtools/Profiles/Linux/OS
Firmware/files/"。
之所以存着这两种镜像,是因为MFGTools的烧写原理是先将媒介镜像下载到到ddr内存里面,然后启动linux,再通过这个启动的linux把目标镜像固化到emmc或者nand里
因此,当更新系统的分区大小或烧写方式时才需要更新firmware中的文件。
更新完,重新启动开开发板即是新系统。
build-sdcard.sh
脚本即可。.sdcard
后缀的文件,即是“用于SD启动更新的镜像”,下面需要将它烧到SD卡上,可以使用Linux下的dd
命令。sudo dd if=mys6ull-xx.rootfs.sdcard of=/dev/sdb conv=fsync
然后改为SD卡启动,就可以进入SD卡的系统,并在系统里自动的烧写Nand。
完成后,改为Nand启动,即可进入新系统。
综上两个方法,都可以实现烧写Uboot到Nand上,但却都是通过进入“媒介系统”完成的烧写,看来直接烧写裸机到Nand是比较麻烦的。
反观SD卡启动,是通过先使用脚本制作一个.sdcard
后缀的文件,再通过dd
命令,完整的复制到SD卡上。
因此只需要分析下脚本如何操作即可。
通过过脚本build-sdcard.sh
进行分析:
dd if=${FIRMWARE_DIR}/u-boot-${MACHINE}.${UBOOT_SUFFIX_SDCARD} of=${SDCARD} conv=notrunc seek=2 bs=512
以及博客参考。
确定了Uboot是被放在了SD卡开始的512x2=1K处。
即,裸机代码必须放在SD卡的偏移地址1K位置处。
这时候,按理说下一步是编写个LED裸机程序,使用dd
命令放在偏移地址1K位置处。
但是,如厂商提供资料文档里说的:
由于i.MX6ULL/i.MX6UL烧写bootloaer时需要使用kobs-ng工具添加头部信
息,需要在操作系统上才可以烧写。
同时,Uboot文件名为*.imx
后缀,因此这里的裸机文件还需要先加一个头。
那么问题来了,这个头怎么加?
肯定还是从Uboot切入,使用厂家提供Uboot,重新编译生成Uboot,在这个过程中,肯定会将u-boot.bin
变为u-boot.imx
。
编译Uboot的过程参考厂家文档,先安装交叉编译工具链,再指定配置文件编译即可。
这里编译完后,是不会有什么提示信息的,这里就需要--just-print
编译参数,将整个编译过程打印出来:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- --just-print > 123.txt
在生成的123.txt
里搜索u-boot.bin
,很快就能定位到加头操作附近:
echo ' MKIMAGE u-boot.imx'; ./tools/mkimage -n board/myir/mys_imx6ull/imximage.cfg.cfgtmp -T imximage -e 0x87800000 -d u-boot.bin u-boot.imx >/dev/null;
这里的./tools/mkimage -n board/myir/mys_imx6ull/imximage.cfg.cfgtmp -T imximage -e 0x87800000 -d u-boot.bin u-boot.imx
命令就是加头操作。
需要mkimage
工具和imximage.cfg.cfgtmp
配置文件,而且这几个文件路径也可以从命令得知。
此时,将编译好的u-boot.bin
,使用上面的命令完成加头操作,得到自己的u-boot.imx
,尝试烧到SD卡上,看能否启动。
这里的烧写有一个坑,当使用dd
命令进行烧写:
sudo dd if=u-boot.imx of=/dev/sdb bs=512 seek=2 conv=fsync
还是先介绍下dd
命令,dd
是对块进行操作,cp
是对文件操作,
比如复制一个数据从A->B,dd
是放在指定的位置,cp
是放在空闲的位置。
同时结合SD卡的分区简图:
可以看出,烧写到SD卡上,是无法直观的从SD上得知是否烧写成功的,烧写的偏移地址1K位置处,无法从SD卡的分区剩余大小上判断。
解决方法是,通过dd
命令读取出数据,再将读取的数据和烧写的数据进行简单比较,因此烧写完成后,要使用以下命令进行检查,判断是否烧写成功:
sudo dd if=/dev/sdb of=read_uboot.bin bs=512 skip=2 count=2
hexdump u-boot.imx | more
hexdump read_uboot.bin | more
比较两者前面部分数据相同即可。
然后把SD卡插上开发板,设置为SD卡启动,成功启动Uboot,且打印的Uboot生成日期是当前日期,证明裸机文件加头的方式是正确的。
知道了怎么加头,怎么烧写到SD卡,就可以编写裸机程序了,第一个裸机当然是最简单的点灯。
在点灯之前,一般都需要关看门狗、初始化时钟、初始化SDRAM/DDR等。
上面的操作不一定都是必须的,比如看门狗可能默认时间很长,对于点灯来说,无所谓。
又比如SDRAM/DDR在点灯这个小程序上,没必要用到。
因此,最基本的肯定是设置GPIO引脚,控制LED灯。
点灯一般需要使能引脚时钟、设置引脚功能为GPIO功能、引脚设值等。
在设置了引脚方向寄存器和引脚数据寄存器后,抱着试一试的心态编译、加头后烧写了进去,居然成功亮灯。
确实很惊讶,这应该是遇到的步骤最少的亮灯代码。
看门狗、时钟什么的,猜测应该被初始化了。
而且,还有更大的惊喜。
在前面加头的操作,就很纳闷有个参数是-e 0x87800000
,应该是个地址,这个地址处于:
Start address End address Size Description
8000_0000 FFFF_FFFF 2048 MB MMDC—x16 DDR Controller.
也就是DDR的位置,难道DDR也被初始化了?
写个测试函数,尝试读写DDR所在的0x80000000:
{% codeblock lang:c %}
#define DDR_ADDRESS (*(volatile unsigned long )0x80000000) //P175 ARM Platform Memory Map
#define DDR_ADDR(offset) ((volatile unsigned long *)(0x80000000+offset))
#define TEST_SIZE (1024*1024)
void test_ddr(void)
{
int i;
unsigned int offset;
int equal_flag = 1;
//写寄存器
offset = 0;
for(i=0; i
}
{% endcodeblock %}
结果发现居然能正确读取出来,看来DDR也被初始化了。
不得不说,很强,很完美。(●’◡’●)
倒回来想,不应能初始化DDR,不同的板子,DDR型号不一定相同,不可能做到适配所有的DDR。
整个过程,就做了加头操作,答案应该在加头操作里面。
打开imximage.cfg.cfgtmp
可以看到一堆寄存器:
…………
DATA 4 0x021B0000 0x84180000
DATA 4 0x021B0890 0x00400000
…………
这里的0x021B0000
刚好是DDR的寄存器:MMDC Core Control Register (MMDC_MDCTL);
其上电复位值是0x00,尝试读取寄存器值是不是为0,就知道是否真的被设置了:
{% codeblock lang:c %}
void read_ddr_reg(void)
{
unsigned int reg_value = 0;
reg_value = MMDC_MDCTL;
if (reg_value & (0x01<<30)) //SDE_1
led_mode(1);
if (reg_value & (0x01<<31)) //SDE_0
led_mode(2);//结果亮
while(1);
}
{% endcodeblock %}
结果其31位,还真是1,和imximage.cfg.cfgtmp
的DATA 4 0x021B0000 0x84180000
里的
0x84180000 = 10000100000110000000000000000000
最高为1是吻合的。
点灯很轻松的被解决了,其它常规的初始化也被完成了。
尝试加点难度,移植下串口,为什么是移植呢?
不想从头去看参考手册的详细说明,直接移植Uboot里的串口操作即可。
Uboot里面一堆start.S
,哪一个才是本开发板的呢?
笨方法是根据芯片型号分类去慢慢找,聪明的方法是一个命令解决:
find -name start.o
得到:
./arch/arm/cpu/armv7/start.o
因为前面根据本开发板配置文件编译过Uboot,理论上现在生成的所有*.o
文件都是本开发板所使用的,这样就可以直接找到用到的start.S
。
对start.S
进行分析,没发现里面有串口相关的调用操作。
茫茫代码,如何找到需要的“uart”相关代码呢。
既然所有*.o
才是用到的,就先找出所有*.o
,再在对应的C文件搜索uart
即可。
find -name ".o"
将得到的结果里面所有的文件名改为.*
,再作为参数传给grep
:
grep -nr "uart" ./test/dm/cmd_dm.* \
./test/dm/built-in.* \
./test/built-in.* \
./common/image-fdt.* \
./common/env_attr.* \
…………
可以得到如下结果:
./common/console.c:10:#include
./board/myir/mys_imx6ull/mys_imx6ull.c:330:static iomux_v3_cfg_t const uart1_pads[] = {
./board/myir/mys_imx6ull/mys_imx6ull.c:400:static void setup_iomux_uart(void)
./board/myir/mys_imx6ull/mys_imx6ull.c:402: imx_iomux_v3_setup_multiple_pads(uart1_pads, ARRAY_SIZE(uart1_pads));
./board/myir/mys_imx6ull/mys_imx6ull.c:850: setup_iomux_uart();
./tools/kwbimage.c:34: { 0x69, "uart" },
./arch/arm/cpu/armv7/mx6/soc.c:448:static void set_uart_from_osc(void)
./arch/arm/cpu/armv7/mx6/soc.c:452: /* set uart clk to OSC */
./arch/arm/cpu/armv7/mx6/soc.c:578: set_uart_from_osc();
./arch/arm/cpu/armv7/mx6/clock.c:132:void enable_uart_clk(unsigned char enable)
./arch/arm/cpu/armv7/mx6/clock.c:412:static u32 get_uart_clk(void)
./arch/arm/cpu/armv7/mx6/clock.c:414: u32 reg, uart_podf;
./arch/arm/cpu/armv7/mx6/clock.c:426: uart_podf = reg >> MXC_CCM_CSCDR1_UART_CLK_PODF_OFFSET;
./arch/arm/cpu/armv7/mx6/clock.c:428: return freq / (uart_podf + 1);
./arch/arm/cpu/armv7/mx6/clock.c:1049:u32 imx_get_uartclk(void)
./arch/arm/cpu/armv7/mx6/clock.c:1051: return get_uart_clk();
./arch/arm/cpu/armv7/mx6/clock.c:1269: return get_uart_clk();
./arch/arm/cpu/armv7/mx6/clock.su:3:clock.c:412:12:get_uart_clk 16 static
./arch/arm/cpu/armv7/mx6/clock.su:10:clock.c:132:6:enable_uart_clk 8 static
./arch/arm/cpu/armv7/mx6/clock.su:20:clock.c:1049:5:imx_get_uartclk 0 static
./drivers/serial/serial_mxc.c:145: u32 clk = imx_get_uartclk();
./drivers/serial/serial_mxc.c:241:struct mxc_uart {
./drivers/serial/serial_mxc.c:270: struct mxc_uart *const uart = plat->reg;
./drivers/serial/serial_mxc.c:271: u32 clk = imx_get_uartclk();
./drivers/serial/serial_mxc.c:273: writel(4 << 7, &uart->fcr); /* divide input clock by 2 */
./drivers/serial/serial_mxc.c:274: writel(0xf, &uart->bir);
./drivers/serial/serial_mxc.c:275: writel(clk / (2 * baudrate), &uart->bmr);
./drivers/serial/serial_mxc.c:278: &uart->cr2);
./drivers/serial/serial_mxc.c:279: writel(UCR1_UARTEN, &uart->cr1);
./drivers/serial/serial_mxc.c:287: struct mxc_uart *const uart = plat->reg;
./drivers/serial/serial_mxc.c:289: writel(0, &uart->cr1);
./drivers/serial/serial_mxc.c:290: writel(0, &uart->cr2);
./drivers/serial/serial_mxc.c:291: while (!(readl(&uart->cr2) & UCR2_SRST));
./drivers/serial/serial_mxc.c:292: writel(0x704 | UCR3_ADNIMP, &uart->cr3);
./drivers/serial/serial_mxc.c:293: writel(0x8000, &uart->cr4);
./drivers/serial/serial_mxc.c:294: writel(0x2b, &uart->esc);
./drivers/serial/serial_mxc.c:295: writel(0, &uart->tim);
./drivers/serial/serial_mxc.c:296: writel(0, &uart->ts);
./drivers/serial/serial_mxc.c:304: struct mxc_uart *const uart = plat->reg;
./drivers/serial/serial_mxc.c:306: if (readl(&uart->ts) & UTS_RXEMPTY)
./drivers/serial/serial_mxc.c:309: return readl(&uart->rxd) & URXD_RX_DATA;
./drivers/serial/serial_mxc.c:315: struct mxc_uart *const uart = plat->reg;
./drivers/serial/serial_mxc.c:317: if (!(readl(&uart->ts) & UTS_TXEMPTY))
./drivers/serial/serial_mxc.c:320: writel(ch, &uart->txd);
./drivers/serial/serial_mxc.c:328: struct mxc_uart *const uart = plat->reg;
./drivers/serial/serial_mxc.c:329: uint32_t sr2 = readl(&uart->sr2);
./drivers/serial/serial.c:143:serial_initfunc(mxs_auart_initialize);
./drivers/serial/serial.c:156:serial_initfunc(uartlite_serial_initialize);
./drivers/serial/serial.c:234: mxs_auart_initialize();
./drivers/serial/serial.c:247: uartlite_serial_initialize();
./drivers/serial/serial.c:525: * uart_post_test() - Test the currently selected serial port using POST
./drivers/serial/serial.c:535:/* Mark weak until post/cpu/.../uart.c migrate over */
./drivers/serial/serial.c:537:int uart_post_test(int flags)
可以看到分别是初始化uart
引脚、时钟、设置相关寄存器等函数。
非常的清晰,很容易就移植过来:
{% codeblock lang:c %}
static void uart1_clock_enable(void)
{
//uart时钟
CCM_CSCDR1 |= (0x01<<6); //P676 Selector for the UART clock multiplexor:1 derive clock from osc_clk
CCM_CCGR5 |= (0x03<<24); //uart1 clock (uart1_clk_enable)
}
static void uart1_iomux(void)
{
//uart引脚复用
IOMUXC_UART1_TX |= (0x01<<16 | 0x02<<14 | 0x01<<13 | 0x01<<12 | 0x02<<6 | 0x06<<3 | 0x01<<0);
IOMUXC_UART1_RX |= (0x01<<16 | 0x02<<14 | 0x01<<13 | 0x01<<12 | 0x02<<6 | 0x06<<3 | 0x01<<0);
IOMUXC_UART1_TX &= ~(0x0F<<0); //P1578 0000 ALT0 — Select mux mode: ALT0 mux port: UART1_TX of instance: uart1
IOMUXC_UART1_RX &= ~(0x0F<<0); //P1579 0000 ALT0 — Select mux mode: ALT0 mux port: UART1_RX of instance: uart1
}
void raise (int sig_nr)
{
;
}
unsigned int get_uart_clk(void)
{
unsigned int reg, uart_podf;
unsigned int freq, div;
//div = CCM_ANALOG_PLL_USB1;
div = CCM_CACRR;
div &= 0x00000003;
freq = 26000000 * (20 + (div << 1));
reg = CCM_CSCDR1;
if (reg & (1<<6))
freq = 26000000;
reg &= 0x3F;
uart_podf = reg >> 0;
return freq / (uart_podf + 1);
}
//uart配置
static void uart1_config(void)
{
unsigned int clk;
UART1_UCR1 = 0;
UART1_UCR2 = 0;
while(!(UART1_UCR2 & (1<<0)));
UART1_UCR3 = (0x704 | (1<<7));
UART1_UCR4 = (0x8000);
UART1_UESC = (0x2b);
UART1_UTIM = (0);
UART1_UTS = (0);
clk = get_uart_clk(); //实测是25952384
UART1_UFCR = (4<<7 | 2<<10 | 1<<0);
//UART1_UFCR = (4<<7);
UART1_UBIR = (0xf);
UART1_UBMR = (clk / (2 * 125000));//115200 - 9.42 125000 - 8.75
UART1_UCR2 = (1<<5 | 1<<14 | 1<<1 | 1<<2 | 1<<0);
UART1_UCR1 = (1<<0);
}
void uart_init()
{
uart1_clock_enable();
uart1_iomux();
uart1_config();
}
void uart_PutChar(char c)
{
UART1_UTXD = c;
while(!(UART1_UTS & (1<<6)));
}
void uart_PutString(char *ptr)
{
while(*ptr != '\0')
{
uart_PutChar(*ptr++);
}
}
{% endcodeblock %}
这里的移植后遇到两个问题:
1.程序里打印45
,实际打印出tu
,通过ASCLL表和逻辑分析仪发现数据有点错位,代码里的115200
波特率对应的脉宽宽了,这里直接把程序里的波特率改为125000
,再用逻辑分析仪看就很“正”了。
2.前面的get_uart_clk()
函数涉及到了除法,交叉编译工具链是不支持硬件除法的。解决方法有两个:
# Add GCC lib
PLATFORM_LIBS += -L $(shell dirname `$(CC) $(CFLAGS) -print-libgcc-file-name`) -lgcc
前面的uart程序,后面实测发现一些问题,很大概率打印的数据是错误或者无法打印,研究后发现,是没有重定位的原因。
原来,开发板上电后,会从Flash中复制代码到SRAM,在SRAM里面一句一句的执行指令(此时运行的地址是硬件决定的)。
实际上,我们更多的是希望他在SDRAM上运行,因为SDRAM的空间更大,于是在链接脚本中,指定它应该运行的地址。
于是代码开始时实际运行的地址和期望运行的地址一般是不一样的,就需要重定位代码到链接脚本指定的地址。
不然的话,假如一个数据,在链接脚本里指定放在了高地址某处,但实际代码运行在低地址附近。代码执行时,需要读取高地址位置的数据,但高地址的数据并没有任何东西,一但读取就很可能发生异常。
首先编写链接脚本:
{% codeblock lang:asm [imx6ul.lds] https://github.com/hceng/learn/blob/master/imx6ull/hardware/uart/imx6ul.lds %}
SECTIONS {
. = 0x80000000;
.text : { start.o(.text)
main.o(.text)
led.o(.text)
uart.o(.text)
printf.o(.text)
(.text)
}
.rodata ALIGN(4) : {(.rodata*)}
.data ALIGN(4) : { *(.data) }
__bss_start = .;
.bss ALIGN(4) : { *(.bss) *(COMMON) }
__bss_end = .;
}
{% endcodeblock %}
这是一个比较通用的链接脚本,指定了代码段、只读数据段、数据段、BSS段等的位置。
开始的0x80000000
就是我们期望它运行的地址,一般都是SDRAM中的某个地址,如果这个地址和代码实际运行的地址相同,就没必要重定位了。
然后在start.S
里重定位操作:
{% codeblock lang:asm [start.S] https://github.com/hceng/learn/blob/master/imx6ull/hardware/uart/start.S %}
.text
.global _start
_start:
@设置栈
ldr sp,=0x90000000 @设置栈
bl relocate @重定位
@bl clean_bss @清BSS段
@adr r0, _start @可用于获取当前代码的地址,作为参数传给main,main里面再打印出来"int main(int addr)"
@ldr pc, =main @如果没重定位,这样直接跳到main代码的位置(链接脚本的期望地址),那个位置的数据未知,肯定出错
bl main @bl相对跳转,不管有没有重定位,都能到main的位置
halt:
b halt
relocate:
adr r0, _start @r0:代码当前被放在的位置,由硬件特性决定
ldr r1, =_start @r1:代码期望被放在的位置,即链接脚本里的地址,用户想放在的位置,比如SDRAM
@当两者相同则不用重定位,否则需要重定位
cmp r0, r1 @比较r0和r1
moveq pc,lr @相等则pc=lr,即跳回到调用relocate的位置;不相等跳过执行下面的指令
ldr r2, =__bss_start @r2等于链接脚本里的__bss_start,即代码段、只读数据段、数据段的结束位置
cpy:
ldr r3, [r0], #4 @将r0地址的数据放到r3,r0往后再移动一个字节
str r3, [r1], #4 @将r3的数据放到r1,r1往后再移动一个字节
@这两句完成了代码从当前位置复制到期望的链接地址位置的操作
cmp r1, r2 @判断是不是复制完了
bne cpy @不相等继续复制
mov pc, lr @pc=lr,即跳回到调用relocate的位置;
clean_bss:
ldr r0, =__bss_start @r0=bss段开始位置
ldr r1, =__bss_end @r1=bss段结束位置
mov r2, #0 @r0=0,填充0用
clean_loop:
str r2, [r0], #4 @将0写到bss段开始位置,并r0向后移一个字节
cmp r0, r1 @比较bss段是不是完了
bne clean_loop @不相等则继续清0
mov pc, lr @pc=lr,即跳回到调用clean_bss的位置;
{% endcodeblock %}
开始的栈地址,选择SDRAM的最高地址即可。其它没什么说的了,注释写的很清楚,目的就是把当前位置的代码(一般是SRAM)复制到期望运行的地址(一般是SDRAM)。
移植printf就很简单了,搞定了uart打印字符的函数后,利用以下框架即可:
printf.c
和printf.h
;printf.h
里定义的__out_putchar
宏改为uart里打印字符的函数即可;实测效果:
以上就是拿到一个全新的板子,如何快速上手板子的过程。
将以上思路,应用于RK3288,发现完全适用,也是先编译Uboot,得知加头的方式,然后得知下载方式,点灯,重定位,仅仅半天就可以实现串口的打印。
对RK3288的操作就不详细写了,思路上是完全一摸一样的,相关代码在文章最后。
后续有时间的话,可能会尝试去移植Nand,这些后续再看情况。
对iMX6ULL的初步上手就差不多了,感觉这SOC做得还是很不错,上手很快,价格低廉。
相关代码Github地址:
IMX6ULL
RK3288