ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TMS320F28377D双核DSP串口Bootloader设计:从架构到量产

TMS320F28377D双核DSP串口Bootloader设计:从架构到量产 搞嵌入式这么多年Bootloader一直是个让不少团队头疼的话题。最近我给TI的TMS320F28377D做了一套完整的串口Bootloader包含双核联动、Flash擦写、AB分区回退、断点续传等功能从方案设计到量产验证踩了不少坑。今天把整个思路和关键逻辑抽出来分享给正在做2837xD系列包括28379DIAP、OTA升级的朋友一个参考。28377D是TI Delfino家族的双核实时MCU两个C28x核跑200MHz每个核带512KB Flash支持FPU、CLA和大量模拟外设广泛应用于光伏逆变器、伺服驱动器、车载电源这些对实时性要求很高的场景。给这种片子做Bootloader核心价值就一句话不改硬件也能升级固件而且升级失败时设备不能变砖。文章会从资源特性、整体架构、核心代码、双核引导、问题排查这五个维度展开适合有C2000基础、正在做量产固件的工程师阅读。1. 先认清28377D的性格双核DSP的启动资源与难点1.1 内存分布与Flash扇区28377D的资源分布直接决定了Bootloader的分区策略所以第一步先把关键地址信息理清楚。CPU1的Flash起始地址是0x080000一共512KB由6个扇区组成Sector 0、Sector 1各64KBSector 2、Sector 3各128KBSector 4、Sector 5各64KB。CPU2的Flash范围是0x100000到0x17FFFF同样是512KB扇区划分和CPU1一致。这里有个特别重要的约束Flash只能按扇区擦除不能像EEPROM那样按字节擦。带来的直接后果就是Bootloader分区必须按扇区边界对齐否则擦除操作会殃及相邻区域。我见过有人把App区起点设在0x088000结果升级时擦除Sector 1Bootloader尾部一起被抹掉整个板子瞬间变砖。正确的做法是Bootloader占64KBApp区起点从0x090000或者更靠后的0x0A0000开始保证擦除边界绝对干净。RAM方面CPU1和CPU2各有M0/M1 RAM以及LS0-LS7等本地共享RAM。Bootloader运行期间尽量把栈分配在M1或者单独划分的RAM段避免和App的RAM布局冲突。C2000工程里App的RAM段起点往往是固定的如果Bootloader把Stack也放在同一段跳转后新固件初始化RAM会直接把运行栈覆盖随机死机就是这么来的。1.2 Boot ROM与引导模式28377D上电后首先执行的是芯片内部固化的Boot ROM代码它根据Boot模式引脚的上电电平组合来选择引导目标。常见的引导模式有Flash引导、SCI引导、SPI引导、CAN引导、I2C引导和并行IO引导。开发阶段用SCI引导很方便配合TI的serial_flash_programmer工具就能通过串口把固件烧进Flash。但量产阶段绝大多数产品会固定使用Flash引导因为Boot模式引脚一旦固定为Flash启动后续更新就完全由Flash里的Bootloader接管。理解Boot ROM的存在还有一个实际用途调试Bootloader时不要一上来就怀疑芯片没跑先用仿真器连上确认复位向量的执行路径。如果发现Boot ROM卡在等待串口握手说明你的boot引脚电平不对如果Boot ROM跳转到了Flash但程序没反应那问题大概率出在App或者Bootloader第一条指令上。1.3 双核架构带来的差异化难题单核MCU做Bootloader核心就是收数据、擦Flash、写Flash、跳转最多加个CRC和固件版本管理。但28377D有两个核而且CPU2没有独立的上电复位引导流程它必须由CPU1通过IPCInter-Processor Communication机制来扶上马。CPU1要配置CPU2的启动地址和引导模式再触发CPU2复位CPU2才会开始执行。这带来三个具体挑战。第一升级时CPU1和CPU2的固件必须保持版本匹配否则两个核之间共享的数据结构、协议帧版本对不上整机行为会非常诡异。第二CPU2的Flash必须由CPU1的Bootloader负责擦写主核要同时管理两段固件的升级事务。第三如果CPU2固件损坏CPU1要在启动阶段给出明确的状态指示或者回退到备份固件避免设备彻底失联。所以28377D的Bootloader本质上是一个小型双核固件管理框架而不是简单的串口烧录工具。2. 整体架构设计先定规矩再写代码2.1 三区布局Boot、App、Param动手写代码之前一定要先把Flash地图画好。我建议CPU1的512KB Flash按下面的方式做三区划分区域地址范围大小用途Bootloader区0x080000 - 0x08FFFF64KBBootloader主程序、Flash驱动参数/状态区0x090000 - 0x09FFFF64KB启动标志、升级状态、双核版本号App区0x0A0000 - 0x0FFFFF384KBCPU1应用固件App区从0x0A0000起步正好和Sector 2的128KB扇区边界对齐擦除时绝不会误伤Bootloader区。参数区虽然可以反复擦写但Flash的擦写寿命有限量产时要注意写入次数管理不能让每次上电的参数刷新都写一次Flash。我习惯把所有需要频繁改动的运行参数放到CPU的RAM区只有升级状态这种低频关键信息才写参数区。这里有个容易被忽略的点Bootloader区的Linker cmd文件必须把全部代码和常量限定在0x080000到0x08FFFF区间同时RAM段不能和App抢占冲突。TI的CCS工程里可以通过多个.cmd文件来管理不同段的内存分配Bootloader和App分别编译各自的cmd文件定义的段名必须一致但地址区域完全不同。2.2 CPU2的分区方案CPU2的Flash空间从0x100000到0x17FFFF我的方案更简单区域地址范围大小用途CPU2 App区0x100000 - 0x16FFFF448KBCPU2应用固件CPU2参数区0x170000 - 0x17FFFF64KBCPU2参数备份CPU2不需要独立Bootloader因为它的引导完全由CPU1控制。但CPU2的App入口地址必须固定我的工程把CPU2的_c_int00入口固定在0x100000这样CPU1只要把CPU2引导地址设为0x100000再复位CPU2就能拉起整个系统。如果你有AB双分区的需求可以把每个核的App区切成A、B两份CPU1的App A放0x0A0000App B放0x0E0000注意扇区对齐。当前运行固件在A升级目标写入B校验成功后通过参数区标志位切换启动地址断电后依然可以从旧固件启动后面细说。2.3 升级流程握手、擦除、写入、校验、复位整个升级流程要设计成带状态机的事务不能是简单的读一帧写一帧。一个完整的串口升级流程是这样的设备上电Bootloader检查参数区的升级标志。如果升级标志有效进入串口升级模式向主机发送等待握手帧。主机发送握手命令Bootloader返回芯片型号、Bootloader版本、App版本、CPU2版本等设备信息。主机发送擦除命令Bootloader按扇区顺序擦除目标App区先擦CPU1再擦CPU2或者按约定的顺序。主机逐帧下发升级数据每帧带CRC校验和帧序号Bootloader边收边写Flash。全部数据写完后主机发送校验命令Bootloader对Flash区重读并计算CRC返回。校验通过主机发送跳转命令Bootloader清升级标志关闭中断跳转到新App入口。App运行后App内部做自检逻辑向主机回报运行状态异常则回滚到备份分区。先握手、再擦除、后写入、终校验、最后跳转这个顺序是底线。我见过不少团队省掉握手和校验直接裸收裸写结果波特率错了或者数据错了一位固件写进去直接跑飞错误还极难定位。2.4 为什么选串口做主升级通道28377D支持SCI、CAN、SPI、I2C等多种接口我选择SCI也就是UART作为主升级通道理由很实际。串口是最保底的调试接口几乎每块28377D板子都引出了串口硬件改动最小。CAN升级虽然抗干扰强、支持远程升级但调试时得有CAN卡或者CANalyzer这类总线工具门槛比较高。串口只要一根USB转TTL线就能干活现场工程师上手没有压力。串口的波特率对28377D这种200MHz主频的CPU来说完全不是瓶颈115200bps甚至921600bps都能稳定跑配合自定义协议足够覆盖大部分场景。当然如果产品要在严酷电磁环境下做远程升级CAN是更好的通道。所以我协议层做了接口抽象串口和CAN共用同一套帧格式和状态机底层收发函数分开实现后面切换通道不用推倒重来。3. 核心代码实现从帧协议到Flash跳转3.1 帧协议带CRC的二进制协议通信协议是整个Bootloader的中枢字段太多浪费带宽太少又容易出歧义。我的帧结构如下#define FRAME_MAX_DLEN 512 typedef struct { uint16_t len; // data区长度最大512 uint8_t cmd; // 命令 uint8_t seq; // 帧序号防重复 uint8_t data[FRAME_MAX_DLEN]; uint16_t crc16; // CRC16-MODBUS覆盖len/cmd/seq/data } BootFrame;传输顺序是先收包头同步字0xAA55加长度再收数据和CRC。同步字用0xAA55可以有效避免大段0x00或0xFF数据导致的误同步。seq字段用来处理主机重发和Bootloader去重高波特率下特别有用防止同一帧被重复写入Flash。CRC16-MODBUS的实现很简单查表或者按位计算都行uint16_t crc16_modbus(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ buf[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }这里必须提醒一个C2000特有的坑TI的C2000编译器默认char是16位宽不是标准C的8位。如果代码里大量使用uint8_t数组而工程又没有开启--char8编译选项每个数组元素实际占16位内存发送和接收时会出现字节错位。我的做法是开启--char8选项让uint8_t真正成为8位同时协议buffer直接用uint8_t数组传输函数逐个字节操作简单干净。3.2 Flash擦写F021 Flash API的使用28377D的Flash擦写有两条路直接操作Flash控制寄存器自己写指令序列和状态轮询或者使用TI提供的F021 Flash API在C2000Ware里封装。量产项目我强烈建议用官方Flash API省去自己造轮子的时间也不用深挖Flash状态机的细节。核心擦写流程简化如下// 擦除一个扇区sectorAddr必须是扇区起始地址 void erase_sector(uint32_t sectorAddr) { Fapi_issueAsyncCommandWithAddress( Fapi_EraseSector, (uint32_t *)sectorAddr); while (Fapi_checkFsmForReady() Fapi_Flash_FsmNotReady); if (Fapi_getFsmStatus() ! 0) { // 处理擦除错误 } }写入数据时要按照编程粒度对齐。F021 Flash支持16位或128位编程128位编程8个16位字效率最高。我的做法是先把每包数据攒够对齐单位再一次性写入减少Flash操作次数。例如每包数据256字节可以拆成16次128位编程比逐16位写快很多。void program_flash(uint32_t addr, uint16_t *data, uint16_t wordCount) { Fapi_issueAsyncCommandWithAddress( Fapi_Program, (uint32_t *)addr, data, (uint32_t)wordCount, 0, 0, 0, Fapi_DataOnly); while (Fapi_checkFsmForReady() Fapi_Flash_FsmNotReady); }写Flash期间有个非常容易踩的坑千万不要在写Flash期间执行存放在待写入Flash扇区中的代码也不能让中断服务程序去访问同一个Flash bank。28377D的Flash控制器在执行编程或擦除命令时对同一bank的访问会被阻塞。如果你在串口中断里调用Flash API而中断代码本身又驻留在同一个Flash bank就可能写Flash时一进中断就死锁。解决办法有两个把Flash驱动和关键ISR都放到RAM里执行或者在擦写期间屏蔽所有可能触发的可屏蔽中断。另外还要记得配置Flash等待状态。28377D在200MHz主频下Flash读取需要设置合理的等待周期Bootloader里跑Flash驱动之前必须先做好InitFlash()或者等价配置否则擦写和读取都可能出现偶发错误。3.3 跳转App不只是跳过去那么简单所有固件写好后Bootloader最后一步就是跳转到App。很多人以为跳转就是拿函数指针指过去就完事实际要考虑四件事。第一关闭全局中断。C28x用SETC INTM指令关中断否则跳转瞬间串口中断、定时器中断可能插入进来打断跳转过程。第二关闭PIE使能。PIE向量表如果还指向Bootloader的老中断向量跳转后一旦中断触发就会跑回Bootloader。我的习惯是跳转前清PIE使能和PIEIER。第三确认App入口地址。工程约定App区的_c_int00入口地址存放在App起始地址的头一个wordBootloader读出来用函数指针调用。第四喂一次看门狗并在跳转前把看门狗窗口放宽或者直接关闭避免App初始化过程中看门狗复位。跳转核心代码typedef void (*pFunction)(void); void jump_to_app(uint32_t appEntry) { // 1. 关全局中断 __asm( SETC INTM); // 2. 关闭PIE PieCtrlRegs.PIECTRL.bit.ENPIE 0; IER 0x0000; IFR 0x0000; // 3. 看门狗处理按需配置量产建议关闭 // WdRegs.WDCR.bit.WDDIS 1; // 4. 跳转 ((pFunction)appEntry)(); }我踩过一个跟跳转相关的诡异问题App的前几条指令能正常执行但一进初始化就进异常。后来发现是Bootloader里没有关闭外设时钟App初始化时对某些外设寄存器写入遇到冲突。解决方案是在跳转前做一个软重置式的清理把GPIO配置、外部中断标志复位但动作不能过度避免把外设状态清到App无法识别。4. 双核引导CPU1如何拉着CPU2跑4.1 CPU2的启动方式和Boot模式配置28377D的CPU2没有独立的上电启动动作它必须被CPU1主动触发。CPU1通过配置CPU2的Boot模式寄存器例如CPU2BOOTCTL把CPU2设为Flash引导然后向CPU2的复位控制寄存器CPU2RESCTL发出复位命令CPU2的Boot ROM读取boot mode后跳转到对应地址执行。我在Bootloader里的CPU2启动流程简化如下void boot_cpu2_from_flash(void) { // 1. 配置CPU2 boot mode为Flash启动 DevCfgRegs.CPU2BOOTCTL.bit.BOOTMODE CPU2_BOOT_MODE_FLASH; // 2. 释放CPU2复位 DevCfgRegs.CPU2RESCTL.bit.RESET 1; // 3. 等待CPU2完成初始化可以用IPC握手 wait_cpu2_ipc_ready(100); // 超时100ms }注意CPU2BOOTCTL寄存器的BOOTMODE值需要对照28377D技术参考手册TRM中的表格不同模式对应不同值。如果值写错CPU2可能跑进SCI模式或者其他模式而不是从Flash启动。我一般把CPU2的App入口固定在0x100000CPU2BOOTCTL的BOOTMODE设置成Flash启动这样整个启动链路最简洁。如果想让CPU1完全接管CPU2的启动入口也可以直接用IPCBOOTMODE寄存器指定CPU2入口地址这种方式适合从RAM加载CPU2固件。但量产方案我还是让CPU2从自己的Flash启动因为CPU2固件本来就在Flash里不需要绕道。4.2 双核升级时序与版本匹配双核版本必须匹配这个前面说过实际操作中我是这么做的。参数区维护两个版本号字段cpu1_ver和cpu2_ver以及升级状态字段upgrade_state。升级流程开始后主机先下发升级信息帧里面带CPU1固件版本和CPU2固件版本。Bootloader收到后做版本兼容性检查不是简单的大于小于而是要求两个版本同时落在约定的兼容范围内。如果一次改动涉及CPU1和CPU2的共享结构体变化我会在上位机上强制要求两个固件一起升级不允许只升级其中一核。这个约束放在上位机比放在Bootloader里更好维护。Bootloader只需要负责把两段固件都写到Flash并在启动时检查参数区记录的两个版本是否属于兼容对。为了不让现场操作人员选错固件我采用了一个升级包包含两段固件的格式[包头部] magic: 0x4C4F4144 总长度 CPU1镜像长度 CPU2镜像长度 CPU1镜像CRC CPU2镜像CRC [CPU1镜像数据] [CPU2镜像数据]Bootloader收到包后先解析头部再按先CPU1后CPU2的顺序把两个镜像分别写入各自的App区。全部写完后再统一校验校验通过才允许跳转。这样操作人员只需要选择一个升级文件不需要理解双核概念。4.3 CPU2启动失败的识别CPU2复位后是否成功启动CPU1怎么知道用IPC是标准做法。CPU1释放CPU2复位后等待CPU2通过IPC发来启动完成消息。如果CPU2固件损坏或者根本没有有效代码CPU1会一直等不到消息所以必须设一个超时周期比如100ms超时后判断CPU2启动失败。除了IPC消息还可以用共享RAM标志。CPU2启动过程中把运行状态写入一段共享RAM比如GSx区CPU1轮询这个标志。但这个方案依赖CPU2固件主动写如果CPU2代码崩溃在初始化早期状态可能停留在半初始化状态。我的经验是IPC消息加共享RAM标志加看门狗三管齐下任何一路超时都判失败。5. 常见问题与排错实录5.1 跳转后App死机多半出在这几处跳转后死机的排查顺序我总结为三看看PIE、看RAM、看外设。先看PIE向量表是否指向了错误的中断处理。跳转前关了PIEApp正常初始化会重建PIE表这一步没问题的话死机概率不大。但有些App为了省时间没有调用InitPieVectTable()PIE表还残留着Bootloader的旧向量一旦中断进来就会乱跳。排查时在App初始化前把PIE向量表清零裸奔测试就能暴露问题。再看RAM重叠。最常见的坑是Bootloader的.stack段和App的RAM段起始地址重叠。跳转瞬间SP还在Bootloader的栈上App启动代码初始化RAM时把这些地址清零或者写入初始值就会把当前栈破坏。我的做法是在Bootloader链接脚本里把.stack分配到M1RAM0x000800起App则使用LSx RAM区两边物理隔离不共用一个地址段。最后看外设初始化顺序。如果Bootloader里启用了定时器、GPIO、CAN等外设跳转前没有恢复成复位态App初始化时可能会读到未预期的外设状态。比如Bootloader用串口打印升级日志跳转后App重新初始化SCI如果FIFO里有残留数据App一开串口就收到乱码误触发中断处理逻辑。5.2 Flash写入后校验失败校验失败的原因通常有三个写入地址未对齐、擦除范围不对、写Flash时被中断打断。写入地址未对齐F021 Flash编程以16位甚至128位为粒度传入的地址必须是编程粒度的整数倍。我在代码里加了个断言任何Flash写操作前检查地址对齐条件不满足就直接报错避免非法写入把Flash状态搞乱。擦除范围不对擦除时只擦了部分扇区但App镜像跨度跨越了多个扇区导致尾部没擦干净。解决办法是擦除前先根据App起始地址和镜像长度计算需要擦除的扇区列表全部擦一遍不要偷懒只擦擦首尾扇区。中断打断写Flash属于最隐蔽的问题。前面说过Flash API在写Flash期间必须独占Flash总线如果中断服务程序执行的代码在Flash里中断一来就访问Flash总线轻则写失败重则CPU挂死。解决方式是把Flash驱动和关键的ISR都放到RAM执行同时在擦写期间屏蔽可屏蔽中断。C28x上用DINT屏蔽全局中断会拉长擦写过程、影响实时性更精细的做法是只屏蔽那些ISR代码在Flash里的中断。5.3 CAN升级常见的玄学问题如果你的Bootloader用CAN升级有个经典问题CAN控制器初始化失败或者总线错误后Bootloader收不到任何帧又没有超时机制设备卡在升级模式。我的做法是加了升级超时看门狗——进入升级模式后如果3秒内没有收到有效握手包Bootloader自动跳转或者重新从Flash启动不让设备变成一块哑砖。CAN升级的另一个注意点是波特率匹配。28377D的CAN位时间配置和外部收发器、终端电阻都有关系升级通信时必须和上位机工具保持一致。我遇到过一次CAN波特率偏差不到1%导致偶发丢帧的情况用逻辑分析仪看CAN总线波形逐段对比位时间很快定位到位时间参数配错。5.4 CPU2不启动的老大难CPU2不启动排错经验分三层Boot模式配置、复位时序、CPU2代码本身。Boot模式配置不对最常见。CPU2BOOTCTL的BOOTMODE值必须和TRM一致写错的话CPU2 Boot ROM会按错误的模式引导可能进入SCI等模式导致CPU2在串口傻等而不是跑Flash。复位时序也很讲究。CPU1释放CPU2复位后不能立刻期望CPU2已经跑起来。CPU2 Boot ROM从复位到跳到Flash入口有微秒级延迟CPU1应该等待CPU2通过IPC发来已启动消息超时再报错不要指望立刻就能通信。如果是CPU2代码本身的问题通常是链接脚本里入口地址没指定。CPU2的_c_int00必须放在0x100000附近或者和CPU1约定好的固定地址。我用了个笨办法编译CPU2工程后用CCS的map文件查看_c_int00地址确认它和Bootloader设定的一致。地址对不上CPU2复位后跳到一个空地址表现就是似乎没启动。6. 可靠性设计与量产实战6.1 升级标志和AB分区回退量产环境最怕的事情是升级过程中掉电固件写了一半设备重启后起不来。针对这个问题我用了升级状态机加AB分区回退的组合方案。参数区维护一个upgrade_state字段取值如下状态值含义0x0000空闲直接启动App0x5555正在升级CPU10x5556正在升级CPU20xAAAACPU1升级完成待校验0xAABACPU2升级完成待校验每次Bootloader启动都会检查这个状态如果是0x0000直接跳App如果是在升级中途比如0x5555说明上次升级在写CPU1时断电安全策略是继续运行当前分区的旧固件——因为新固件还没完整覆盖旧固件直接回退到旧App即可如果状态是CPU1已写完但CPU2没写完则需要回退到旧分区并标记本次升级失败。AB分区是实现永不砖机的关键。我的AB切换逻辑是A区作为当前运行分区B区作为升级目标区升级时先把新固件写入B区校验通过后修改参数区的启动地址标志下次复位从B区启动B区启动成功后App做自检向主机回报运行状态如果异常就把启动地址标志改回A区并复位。AB分区在28377D上的代价是每个核要割出两份App空间如果Flash紧张可以折中为一个App区加一个备份区备份区保存上一版固件也能解决多数断电场景。6.2 升级数据安全CRC与固件加密光有协议CRC还不够量产时固件文件本身也要有完整性校验。我在升级包头部直接放了整个镜像的CRC32Bootloader写完后重新计算Flash区CRC两边一致才允许跳转。这样可以避免写了160K错了一个Byte这种尴尬问题。如果产品有防抄板需求固件加密基本是一票否决项。28377D本身有安全资源Flash也有CSM保护位升级数据在传输前可以先做AES或者XOR混淆Bootloader解密后写入。提醒一句解密密钥的存储位置和CSM位设置要统一规划别出现Bootloader会解密App却不会这种低级错误。6.3 上位机设计的几个建议上位机工具直接决定现场升级效率。好用的升级工具至少要支持这几项功能。固件版本展示连接后先读设备当前版本和Bootloader版本防止误刷。升级包合法性校验打开升级包先解析头部、校验CRC再提示是否继续。进度显示和断点续传大固件升级时进度百分比加当前扇区号能给现场人员直观反馈。日志记录每次升级把时间、版本、结果写入本地日志方便售后追溯。关于断点续传我的看法是看场景。以28377D为例512KB Flash写满在921600波特率下大约几十秒中途掉线概率不大。但如果产品在弱网或者长距离串口环境下升级断点续传能显著改善体验。实现断点续传的要点是按扇区记录已写入扇区序号重连后从断点继续写不必重发整个固件。6.4 量产测试清单Bootloader开发完成后量产前建议完整跑一遍下面的清单全新空Flash通过Bootloader首次烧写成功。在CPU1写入中途拔电重启后能从旧固件启动。在CPU2写入中途拔电重启后能从旧固件启动。旧固件升级到新固件成功App能正常运行。新固件降级到旧固件成功。串口波特率异常或线路断开Bootloader超时回到App。连续10次升级稳定通过。CRC错误帧被正确丢弃设备不跑飞。八项全过量产风险基本可控。尤其第6项很多人容易忽略结果现场升级失败后设备永远停在Bootloader模式只能人工开盖刷机代价极高。最后分享一点我做28377D Bootloader的最大体会Bootloader不是写完跳转就完事的Demo程序它是长期和可靠性打交道的模块。很多问题在实验室根本遇不到比如现场升级到一半停电、串口线有干扰、固件版本配错都是在量产之后才集中爆发的。所以每次升级方案发布前我都会按上面的测试清单在真实环境里完整跑一遍而不是只做能连通、能开App的冒烟测试。如果你的项目也在做C2000系列的Bootloader建议先把分区、协议、状态机这三大骨架定死再回头抠Flash驱动的细节。规矩定好了后面无论是加CAN升级、加加密、加AB分区都是在骨架上添肉不会推倒重来。这套方案里真正有价值的不是某一段代码而是先想清楚再动手这个顺序希望你能少走我走过的弯路。
返回列表