前言
- 1)上一篇文章介绍了 Air105 OpenOCD 驱动编写的成果,这篇文章想着记录一下中间遇到的问题,一是防止和我一样的小白踩坑(你就当真的听),二是成功不吆喝,如锦衣夜行。
2)ROM API 代码:
// air105_rom_falsh_api @ 0x00008010UL : #define ROM_QSPI_Init (*((void (*)(QSPI_InitTypeDef *)) (*(uint32_t *)0x8010))) #define ROM_QSPI_ReadID (*((uint32_t (*)(QSPI_CommandTypeDef *)) (*(uint32_t *)0x8014))) #define ROM_QSPI_WriteParam (*((uint8_t (*)(QSPI_CommandTypeDef *, uint16_t)) (*(uint32_t *)0x8018))) #define ROM_QSPI_EraseSector (*((uint8_t (*)(QSPI_CommandTypeDef *, uint32_t)) (*(uint32_t *)0x801C))) #define ROM_QSPI_EraseChip (*((uint8_t (*)(QSPI_CommandTypeDef *)) (*(uint32_t *)0x8020))) // This's a reasonable guess. #define ROM_QSPI_ProgramPage (*((uint8_t (*)(QSPI_CommandTypeDef *, DMA_TypeDef *, \ uint32_t, uint32_t, uint8_t *)) (*(uint32_t *)0x8024))) #define ROM_QSPI_ReleaseDeepPowerDown (*((uint8_t (*)(QSPI_CommandTypeDef *)) (*(uint32_t *)0x802C)))
1 驱动实现方式的尝试
1.1 第一次尝试
1)刚开始时,根据 Air001 时的经验,想通过 OpenOCD 模拟 QSPI API 的方式实现,结果烧录巨慢(几 KB 大小的文件烧录要一分多钟),且烧录后无法正常启动,放弃(第一次尝试)。
- (1)现在想来,烧录慢应该是通过 OpenOCD 模拟寄存器操作只适合偶尔简单的操作,如解锁 FLASH,读取 FLASH SIZE 寄存器等操作。而烧录代码涉及到寄存器的频繁操作;
- (2)无法正常启动应该是第一个扇区中未写入 header 所致。
1.2 第二次尝试
- 1)既然操作 QSPI 寄存器不行,就考虑 ROM API 调用的方式。本来想参考 “为国产芯片增加OpenOCD Flash驱动----以AIC8800为例” 这篇文章,写完代码测试时发现运行算法的 target\_run\_algorithm() 函数一直报错。
- 2)由于此时还不太明白 OpenOCD 驱动运行逻辑,因此让我一度怀疑官方的 ROM API 到底是否可用。(从这里开始走弯路。)
3)上面提到的代码中的主要逻辑:
struct aic8800_rom_api_call_code_t { uint16_t ldrn_r3; // 0x4b01 uint16_t blx_r3; // 0x4798 uint16_t bkpt; // 0xbe00 uint16_t nop; // 0xbf00 uint32_t api_addr; // api addr }; struct aic8800_rom_api_call_code_t aic8800_rom_api_call_code_example = { .ldrn_r3 = 0x4b01, /* LDR.N R3, [PC, #0x4]*/ .blx_r3 = 0x4798, /* BLX R3 */ .bkpt = 0xbe00, /* bkpt */ .nop = 0xbf00, /* NOP */ .api_addr = 0x12345678, }; ....... static int aic8800_probe(struct flash_bank *bank) { ...... retval = target_read_buffer(target, AIC8800_ROM_APITBL_BASE, sizeof(rom_api_table), (uint8_t *)rom_api_table); if (retval != ERROR_OK) return retval; for (unsigned int i = 0; i < dimof(aic8800_bank->rom_api_call_code); i++) { init_rom_api_call_code(&aic8800_bank->rom_api_call_code[i], rom_api_table[i]); } ...... }
- 上面通过 target\_read\_buffer() 函数,从 ROM API 的基地址读取出 ROM API 代码。
1.3 第三次尝试
- 1)既然需要运行 ROM 代码,那么我是不是可以把官方 AIR105.FLM 中的算法代码(没有源码)提取出来,然后参考 1.2 中的形式实现来运行?(灵感来自 “利用MDK的FLM文件生成通用flash驱动”)
- 2)说干就干,不过当我终于解析完 AIR105.FLM 文件,驱动写了一半时想到:AIR105.FLM 中的算法是通过 QSPI 实现的,里面有函数多层嵌套,OpenOCD 中该怎么实现呢?难度太大遂放弃,毕竟我是一个小白呢。
- 3)解析 AIR105.FLM 文件见 “附录1:调用 AIR105.FLM 算法的尝试”。
1.4 第四次尝试
- 1)再回头去仔细研究上面的大佬代码,这位大佬的情况好像是先从 FLASH 中读取出 ROM API 的代码,然后将代码写入到 SRAM 中运行的。再看一下 Air105 官方的 ROM API 地址间隔 4 个字节,不像是一个函数的大小啊。
2)经过 “文心一言” 的解答,原来这个是函数指针。那我是不是可以把这个指针指向的地方的代码读取出来运行?好像不行,因为我不知道要读取多长的数据才是一个完整的 ROM API 代码。
#define ROM_QSPI_ReadID (*((uint32_t (*)(QSPI_CommandTypeDef *))(*(uint32_t *)0x8014)))
3)不过大佬的文章仍然给了我启发,那就是将一个地址赋值给一个寄存器,然后可以直接调用。可能官方 ROM API 是没问题的,只是我调用的方式有问题?
LDR.N R3, [PC,#0x4] /* 将后面的入口地址载入 R3,已知的 ROM API 最多只需要用到 R0-R2 */ BLX R3 /* 调用 ROM API */
4)那么就验证一下,当时想到如果有问题就放弃这个项目了(因为在 1.2 小节中,我尝试了各种办法结果都是报错)。通过在 Keil5 中调用 ROM\_QSPI\_ReadID() 成功获得芯片的 ID,然后通过调试找到对应的汇编代码(这也是我认为 Keil5 唯二可取的地方):
0x01002424 F44F4000 MOV r0,#0x8000 0x01002428 6941 LDR r1,[r0,#0x14] 0x0100242A 2000 MOVS r0,#0x00 0x0100242C 4788 BLX r1 0x0100242E 4604 MOV r4,r0
(2)通过 BLX r1 指令来调用 0x8014 处的函数,且参数 NULL 保存在 r0 寄存器中。结果成功获得 id,如下:
- (3)再结合大佬文章中的 BLX 调用 API 代码,那么可以确定,确实可以这样来调用 ROM API 的函数指针,只要我们把参数设置完就好了(乐极生悲的伏笔)
1.5 第五次尝试
1)当时想着先实现 ROM\_QSPI\_ReadID() ROM API 的调用,因为它参数少,且是 OpenOCD 烧录时最先调用的 .probe 中需要实现的。当看到程序输出下面的内容时,就差喜极而泣了:
AIR105 flash base 0x01001000 device id = 0x005E4016
2)路线选择完成,下面就把各个功能函数实现即可:
const struct flash_driver air105_flash = { .name = "air105", .commands = air105_command_handlers, .flash_bank_command = air105_flash_bank_command, .erase = air105_erase, .write = air105_write, .read = default_flash_read, .probe = air105_probe, .auto_probe = air105_auto_probe, .erase_check = default_flash_blank_check, .info = air105_get_info, .free_driver_priv = default_flash_free_driver_priv, };
3)其它情况:
- (1)为了减少烧录时间,当然是采用异步烧录了
(2)驱动完成(见 air105.c),汇编算法完成(见 air105\_write.S),编写过程中,通过 Keil5 测试 ROM API 时发现:
- a. 扇区擦除函数 ROM\_QSPI\_EraseSector(),参数 uint16\_t 为扇区所在地址,那么擦除实现时就需要循环擦除多个扇区了,原来还以为 API 擦除的是指定地址到 FLASH 尾地址呢
- b. 页编程函数:ROM\_QSPI\_ProgramPage(),芯片的写入单位是页,那么就要除了实现异步烧录算法外,还要实现 ROM\_QSPI\_ProgramPage() 函数,防止不足页的情况
2 测试
2.1 汇编传参问题
- 1)通过 “flash write\_image erase” 烧录后,程序无法运行,于是通过 “flash read\_bank 0 [filename]” 将整个 FLASH 读取出来,与 HEX 文件对比:发现异步烧录算法写入数据不对(而且有时在写的过程中会报错),折腾了几天,中间几乎一行一行地查代码感觉没有问题啊。
2)最终在不经意间愤怒地质问 “文心一言”:“arm 汇编中 5 个参数的函数到底应该怎么传参?(概义如此)”,答:r0\~r3 寄存器通常用于参数传递,多于 4 个参数就要放到栈里面去了。对不起,我是小白。下面是 QSPI API(与 ROM API 参数一致),有 5 个参数:
uint8_t QSPI_ProgramPage(QSPI_CommandTypeDef *cmdParam, DMA_TypeDef *DMA_Channelx, uint32_t adr, uint32_t sz, uint8_t *buf );
- (1)ARM 平台下,参数值传递按顺序存放在寄存器 r0,r1,r2,r3 里,超过 4 个参数值传递则放栈里。仔细想想也是,通用寄存器就那十几个,那我要是一个函数 20 个参数怎么办呢?
- (2)页编程函数 QSPI\_ProgramPage() 有 5 个参数,则第 5 个参数需要去 sp 指针指向的位置去取。
3)同时中间想到,由于我们是异步烧录,那么我们需要几个寄存器来保存 FIFO 的起始、结束地址,烧录页数,读写指针等,这些寄存器在烧录过程中不能被外部改变。
- (1)原来想着我从 R12\~R0 开始往下用,BLX 指令调用函数时可能只用到前面的几个寄存器,不会覆盖我后面的寄存器。
- (2)后来查看 AIR105.FLM 文件,以及《ARM Cortex-M3 权威指南》时发现,POP 和 PUSH 指令你值得拥有。
2.2 缓存
1)每次烧录成功后,通过 flash mdw 查看发现内容不对。
- mdw,Memory Display Word,显示内存字,即显示指定地址的一个 4 字节数据。
- 2)然后就一直通过 “flash fillw [address] [value] [length]” 写入数据,“flash erase\_sector [bank] [first] [last]” 擦除扇区,“flash erase\_check [bank]” 查看扇区的标记情况,这三个命令来测试。
- 3)偶然的机会发现,写入数据后,使用 “flash erase\_check” 走一下,然后再通过 “flash mdw” 命令就可以看到正常值,然后就把 “flash erase\_check” 对应的回调函数 “default\_flash\_blank\_check” 添加到每次更新操作的后面,结果非常消耗时间。这样不行啊,再去翻 AIR105.FLM 解析内容,通过 “Cache\_CleanAll()” 函数猜测可能是缓存问题。
- 4)找到官方数据手册,把清除缓存的功能实现一下,再测试,终于正常烧录了。 通过 “flash read\_bank 0 [filename]” 命令读取出来后,与镜像 HEX 文件一致。
2.3 header
2.3.1 无法正常启动
1)程序已经烧录成功,校验也和镜像文件一致,但芯片无法正常启动。需要说明的是,此前的驱动中,我一直以为芯片的 FLASH 基地址为 0x01001000,且大小为 4092KB,因为官方文档这样说得,且 Link Script 中也如下指定(虽然心里也吐槽过,说好的 4MB FLASH 怎么少了 4KB 呢?)
MEMORY { RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 640K FLASH (rx) : ORIGIN = 0x01001000, LENGTH = 4092K }
2)这里是耗时最长的部分,经过大概一周的折腾,包括一行一行检查汇编代码(怀疑自已),去查 SC300 文档(以为 SC300 安全核有啥特性),查看 Cortex-M3/M4 权威指南(想找找 FLASH 寄存器的说明),所有的路都走不能,再总结一下现象如下:
- (1)先通过串口烧录一次,然后使用本驱动多次烧录相同的镜像,都可以正常运行
- (2)通过 flash read\_bank 命令读取出烧录的镜像文件,发现和正常运行的镜像一致
2.3.2 串口烧录程序
1)OpenOCD 烧录无法正常运行,但串口烧录后却可以正常运行,观察下串口烧录的日志:
PS D:\_Workspace\11_MCU\07_AirM2M\Air105\Demos\air105_mh1903s> .\air105-uploader.exe COM17 .\cmake-build-debug\air105_mh1903s.bin >>> No signature key. Ignoring firmware signature >>> Port: COM17 >>> Firmware: 4312 bytes >>> Starting bootloader >>> Received ChipSN Packet >>> Boot Version: V1.3.0 >>> ChipID: 50790400 >>> ROM Version: Unknown >>> Series: S030 (MH1903S) >>> Serial: b'4131303551383854000000000000DAA9' >>> Initializing stage2 >>> Erasing flash memory >>> Erasing flash from 00000000 to 00003000 >>> Sending firmware >>> Writing @0x1001000 >>> Writing @0x1002000 >>> Upload finished >>> Resetting device
- 没发现什么特殊的,烧录地址也是在 0x01001000。就死马当做活马医,看看它是怎么实现烧录的。“马生,你发现了华点!!!”
2)发现了如下内容:
def update(self): if self.Option == 2: hash = hashlib.sha256(self.Data).digest() else: hash = hashlib.sha512(self.Data).digest() self.Hash[:len(hash)] = hash self.header = struct.pack( "<6I", 0x5555AAAA, 0, self.Start, self.Length, self.Version, self.Option) self.header += self.Hash crc = self.c32.compute(self.header[4:]) self.header += struct.pack("<I", crc) if self.rsaKey != NULL_KEY: signData = self.header[4:] signData += b"\x00" * (256 - len(signData)) # Pad to 256 bytes signData = int.from_bytes(signData, byteorder='big') signed = pow(signData, self.rsaKey.d, self.rsaKey.n) self.signed_header = self.header[:4] + signed.to_bytes( self.rsaKey.size_in_bytes(), byteorder='big')
- 好像是对数据进行 SHA256 摘要,还有 CRC-32 签名的。
3)与此同时,官方 SDK 里面竟然有如下定义:
#define MHSCPU_FLASH_BASE (0x01000000UL)
4)尝试将 OpenOCD 中 Air105 驱动程序中的 FLASH 基地址由 0x01001000 修改为 0x01000000,然后通过 “flash mdw 0x01000000 32” 读取内容,结果:
> flash mdw 0x01000000 32 AIR105 flash base 0x00000000 device id = 0x005E4016 0x01000000: 5555aaaa 00000000 01001000 000010d8 00000000 00000002 5ac6ffb0 b06f56f2 0x01000020: 756ff9bb 38a5f690 afbaf183 0c26b5cb d8008a24 d76168e3 00000000 00000000 0x01000040: 00000000 00000000 00000000 00000000 00000000 00000000 2f91401d 00000000 0x01000060: 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
- 从中可以发现,虽然后续内容不一致,但开始的 0x0x5555AAAA 与串口烧录代码中的一模一样。
- 5)再仔细研究一下,终于发现,原来 FLASH 0x01000000 ~ 0x01001000 一个扇区 4KB 大小中,保存的是 0x0x5555AAAA 魔数、默认值0、app 地址、app 数据长度、版本、摘要算法等内容。接下来我们只需要一一实现这些内容即可。
- 参考:sha256 C 语言实现:https://blog.csdn.net/qq_43176116/article/details/110388321
2.4 烧录 .elf 文件
1)我的目标是在 CLion 中使用 OpenOCD 烧录程序,而 CLion 中烧录的是 .elf 文件。问题是,无论在 Link Script 文件怎么配置 FLASH 的起始地址 ORIGIN,结果在烧录时,OpenOCD 获取到的都是 0x01000000
MEMORY { RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 640K FLASH (rx) : ORIGIN = 0x01001000, LENGTH = 4092K }
2)使用 “arm-none-eabi-readelf.exe -a .\air105_mh1903s.elf” 命令解析 .elf 文件,结果也是如此:
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x01000000 0x01000000 0x01cac 0x01cac RWE 0x10000 LOAD 0x008000 0x20008000 0x01001cac 0x0042c 0x0045c RW 0x10000 LOAD 0x000000 0x20000000 0x20000000 0x00000 0x08000 RW 0x10000
3)晚上睡觉时,找了几篇类似的文件了解一下 Link Script 文件:
- ARM Cortex-M文件那些事(6)- 可执行文件(.out/.elf):https://www.cnblogs.com/henjay724/p/8276677.html
- ARM Cortex-M文件那些事(2)- 链接文件(.icf):https://www.cnblogs.com/henjay724/p/8191908.html
- 链接脚本(Linker Scripts)语法和规则解析:https://www.cnblogs.com/jianhua1992/p/16852784.html
- 4)早上起来文章还没看,先用 “flash mdw 0x01001000 32” 读取烧录后的头 32 个字节发现,虽然 .elf 文件的内容从地址 0x01000000 开始,但是它实际烧录的程序代码依然是从 0x01001000 开始写入的。那问题不就解决了,我只需要在计算 header 时,对传入 .write 函数中的 buffer 进行 1 个扇区 4KB 的偏移不就行了。
- 5)结果不出所料,果然烧录成功。至此可以宣布,我的 OpenOCD 驱动终于可以支持 .elf、.hex、.bin 三种文件的烧录了。
3 优化
3.1 DMA
1)可以正常烧录以后,当然就想优化一下烧录速度了。首先从参数类型我们可以看到烧录 ROM API 是支持 DMA 的,那就尝试一下吧。
#define ROM_QSPI_ProgramPage (*((uint8_t (*)(QSPI_CommandTypeDef *, DMA_TypeDef *, uint32_t, uint32_t, uint8_t *))(*(uint32_t *)0x8024))) #define DMA_Channel_0 ((DMA_TypeDef *)DMA_BASE) #define DMA_Channel_1 ((DMA_TypeDef *)(DMA_BASE + 0x58))
2)从 SDK 中可以看到 DMA 通道号的值,再使用 Keil5 进行 Debug 可以看到在 ROM\_QSPI\_ProgramPage() 调用时,r1 的值确实是 0x40008000,那么我们修改汇编代码:
movs r1, #0x4000 /* param of DMA_TypeDef */ lsls r1, #16 adds r1, #0x800 /* DMA always failure */ // 还尝试了下面这种方式: ldr r1, =#0x4000800
- 还尝试了其它 DMA 通道号,结果发现:有时会出现死活烧录下进行的情况。此时测试不加 DMA 的情况下烧录,只比 Keil 慢 5~6 秒,就放弃了。
3.2 多扇区擦除
1)为了对比 Keil 烧录速度,在擦除扇区和烧录分别加入了计时功能,结果发现擦除 150 个扇区都需要十几秒,就想到设置擦除的最后扇区,让擦除算法自已运行,这样可以节省每次擦除算法的初始化和释放过程时间。
- 也就是由 OpenOCD 控制一个扇区一个扇区地擦除,修改为几十个扇区擦除一次。
2)结果发现,当一次擦除 50 个以上扇区时,发现错误的概率大大提高。慢慢减少每次擦除的扇区数,直到等于 20 时,还会偶尔出现错误。没办法,设置成每次擦除 15 个扇区,并加入错误重试功能。
/* erase sectors more than specified sectors will make ROM_QSPI_EraseSector() failed. * and there is a certain probability that the function returns successfully, but the * sector is not completely erased, this can lead to the program not running after being * burned. * The smaller the specified number of sectors, the better. it will always failed if more * than 60 sectors per erased. */ while (num_sectors > 0) { erase_last = erase_first + MIN(num_sectors, MAX_ERASE_SECTORS_ONCE); /* Loop over the selected sectors and erase them. */ retval = ROM_QSPI_EraseSector(bank->target, erase_first, erase_last); if (retval != ERROR_OK) { LOG_WARNING("AIR105 erase sector from %d to %d error(retry):%d", erase_first, erase_last, retval); if (error_times++ > 5) { return retval; } continue; } erase_first = erase_last; num_sectors -= MAX_ERASE_SECTORS_ONCE; }
3)至此,AIR105 驱动终于开发完成了。皇天不负有心人,CLion 给的惊喜太大了:
- 命令行测试时,最高速度只有 40 KiB/s 左右,这一下提高了快 4 倍。
4 附录1:调用 AIR105 FLM 算法的尝试
4.1 关于 Keil FLM
1)首先查看一下 FLASH 相关信息(这里显示了 15 MB 的大小,但手册上说只有 4MB):
2)Keil 规定,一个 FLM 文件中要包含一些指定函数及结构:
- (1)函数(FlashPrg.c):
函数名 是否必须 描述 Init 是 初始化 UnInit 是 反初始化 EraseSector 是 扇擦除 ProgramPage 是 页擦除 EraseChip 否 片擦除。 BlankCheck 否 Blank 检查。 Verify 否 校验。 (2)FLASH 说明结构体(FlashDev.c):
struct FlashDevice const FlashDevice = { FLASH_DRV_VERS, // Driver Version, do not modify! "New Device 256kB Flash", // Device Name ONCHIP, // Device Type 0x00000000, // Device Start Address 0x00040000, // Device Size in Bytes (256kB) 1024, // Programming Page Size 0, // Reserved, must be 0 0xFF, // Initial Content of Erased Memory 100, // Program Page Timeout 100 mSec 3000, // Erase Sector Timeout 3000 mSec // Specify Size and Address of Sectors 0x002000, 0x000000, // Sector Size 8kB (8 Sectors) 0x010000, 0x010000, // Sector Size 64kB (2 Sectors) 0x002000, 0x030000, // Sector Size 8kB (8 Sectors) SECTOR_END };
(3)官方说明文档:
3)函数说明:
/** * FLASH 初始化,每次编程时调用 * * Keil 提供的示例代码中实现了三件事: * (1)设置 FLASH 读取间隔(LATENCY) * (2)解锁 FLASH * (3)增加独立看门狗(IWDG)喂狗时间(应该是防止程序烧录过程中喂狗时间到) * * @param adr Device base address * @param clk Clock frequency(Hz) * @param fnc Function code: 1, Erase; 2, Program; 3,Verify. * @return status information: 0, on success; 1, on failure. */ int Init (unsigned long adr, unsigned long clk, unsigned long fnc); /** * FLASH 反初始化,在一次擦除、编程、或校验后调用。 * * Keil 提供的示例代码:重新锁定 FLASH * * @param fnc Function code: 1, Erase; 2, Program; 3, Verify. * @return status information: 0, on success; 1, on failure. */ int UnInit (unsigned long fnc); /** * 扇区擦除 * * Keil 提供的示例代码:重新锁定 FLASH * * @param addr Sector address * @return status information: 0, on success; 1, on failure. */ int EraseSector (unsigned long adr); /** * 页编程 * * Keil 提供的示例代码为每次写半个字(即 2 个字节) * * @param adr Page start address * @param sz Page size * @param buf Data to be written * @return status information: 0, on success; 1, on failure. */ int ProgramPage (unsigned long adr, unsigned long sz, unsigned char *buf); /** * 片擦除 * * @return status information: 0, on success; 1, on failure. */ int EraseChip (void); /** * Blank 检查,检查指定块是否是指定内容 pat。 * * @param adr Block start address * @param sz Block size in bytes * @param pat Pattern to compare * @return status information: 0, block content == pattern pat; 1, block content != pattern pat. */ int BlankCheck (unsigned long adr, unsigned long sz, unsigned char pat); /** * 数据校验,校验 FLASH 数据是否和 buf 内容一致 * * @param adr Start address * @param sz Size in bytes * @param buf Data to be compared * @return success, sum of adr+sz; failure, other number represents the failing address. */ unsigned long Verify(unsigned long adr, unsigned long sz, unsigned char *buf);
4.2 解析 FLM 文件
1)通过 arm-none-eabi-readelf 命令,找到 FLM 中定义的必要函数所在位置。
$ arm-none-eabi-readelf.exe --help Usage: readelf <option(s)> elf-file(s) Display information about the contents of ELF format files $ arm-none-eabi-readelf.exe -a Air105.FLM Symbol table '.symtab' contains 371 entries: Num: Value Size Type Bind Vis Ndx Name 297: 00000089 32 FUNC GLOBAL HIDDEN 1 BlankCheck 317: 00000d61 12 FUNC GLOBAL HIDDEN 1 EraseChip 318: 00000d6d 40 FUNC GLOBAL HIDDEN 1 EraseSector 319: 00000d95 332 FUNC GLOBAL HIDDEN 1 Init 320: 00000ffd 86 FUNC GLOBAL HIDDEN 1 ProgramPage 365: 00001f89 26 FUNC GLOBAL HIDDEN 1 UnInit
- 参考:https://blog.csdn.net/sinat_31039061/article/details/128350295
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。