ARTICLE DETAIL

资讯详情

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

STM32嵌入式C++实战:从寄存器到状态机的工程化编程

STM32嵌入式C++实战:从寄存器到状态机的工程化编程 你们催更的留言我都看到了。“看了三篇了一行都没让我写呢”这话确实没冤枉我。前三篇一直在铺垫我把它理解成“打地基”但对急着想上手的兄弟来说确实有点煎熬。这篇咱们换换节奏直接开写写真正能跑在STM32上的C代码。不是那种抄来抄去的demo而是从工程结构到类封装从LED闪烁到按键状态机一行一行让你知道自己在干什么。这一篇写完你手里应该有一份自己敲出来、能编译、能烧录、能跑的嵌入式C工程。先说清楚这篇适合谁。如果你刚看完前三篇对Cortex-M内核、寄存器、编译链接这些概念有了初步印象但手还没痒够那这篇正合适。如果你已经写了好几年代码从C转C想知道在嵌入式里怎么落地面向对象这篇也有参考价值。如果你连STM32是什么都还迷糊建议先回看系列第一篇或者把这篇文章当作一个带有详细注释的实战录抄一遍也行但抄的时候要动脑子。1. 铺垫了三篇为什么现在才开始写代码1.1 前三篇到底在做什么咱们这个系列的名字是“基于STM32的嵌入式C编程之旅”有人会觉得既然是编程之旅第一篇就该写一个LED闪烁第二篇就该写串口打印结果我连着讲了内核、寄存器映射、启动文件当然中间也穿插了C的基本语法但确实没有让你动手写完整程序。回顾一下前三篇的内容先讲了ARM Cortex-M3/M4的存储器映射聊了为什么0x08000000是Flash0x20000000是SRAM然后讲了GPIO控制器内部结构什么是MODER寄存器什么是BSRR寄存器为什么要用“读-改-写”的方式来修改ODR接着讲了C在嵌入式环境里和桌面环境的关键差异比如没有异常处理、没有标准模板库也没有让你随便new的内存池。这些知识在今天会全部派上用场。为什么不在第一篇就直接给代码因为嵌入式编程有个非常现实的门槛你写的代码最终不是跑在有个操作系统的电脑上而是直接跑在裸金属硬件上。你没有printf可用没有动态内存管理器帮你擦屁股没有crash时的堆栈打印。一旦工程配置不对、启动文件不全、链接脚本里Flash和RAM地址错了程序烧进去是黑屏你连个报错提示都看不到。前三篇做的就是把这些“看不见的坑”提前扫一遍。现在开始写代码你觉得只是在写几个函数实际上你是在把你刚刚学过的内核知识全部调动起来。1.2 直接上手写代码的代价很多人买一块开发板第一件事就是复制例程点亮LED然后感觉自己会了。但离开开发板、换一块最小系统板的时候往往连时钟配置都不会改。为什么因为例程太“完整”了完整到把所有细节都藏起来了。直接上手写代码也不是不行但代价是你遇到一个报错根本不知道报错在说谁。比如编译器告诉你undefined reference to SystemInit你如果不清楚这是启动文件里引用的一个时钟初始化函数你可能就卡住了。再比如你把main函数改成了int main()工程却链接失败因为你没有加上crt0的启动流程也不知道main在嵌入式环境里其实是被启动文件调用的一个裸函数。所以我宁可前三篇慢一点也要先把“你写的代码最终是怎么变成二进制、怎么被烧录到Flash、怎么被CPU一条条执行的”这条链路讲透。现在到了动手环节你会发现这些知识会反复出现不是无用功。2. 从零搭一个能编译C的STM32工程2.1 开发环境选型CubeIDE还是VS Code动手之前先定工具。支持STM32的C开发环境常见三套Keil MDKSTM32CubeIDE以及VS Code加GCC工具链。我个人的建议是如果你不是公司项目强制要求用Keil那就用STM32CubeIDE或者用VS Code配置一套GCC工具链。先说Keil MDK。Keil在中文社区占有率很高教程多遇到问题容易搜到答案但它对C的支持在某些场合会让你头疼旧版编译器对C11标准支持不够完整工程配置界面也偏老旧。如果你手头只有Keil也不是不能用就是要确认你的版本编译器支持你写的C特性。STM32CubeIDE是ST官方基于Eclipse的IDE集成了STM32CubeMX可以图形化配置时钟树、引脚、外设然后生成初始化代码。它对C的支持很友好默认启用arm-none-eabi-g编译器你只需要在工程设置里把编译器语言切到C或者直接把源文件改成.cpp后缀。缺点是Eclipse框架有点笨重启动慢但胜在“开箱即用”。VS Code的方案是这三套里最灵活、也最“程序员味”的。你用VS Code当编辑器装Cortex-Debug插件和Embedded Tools插件后端用arm-none-eabi-gcc编译器配合CMake或者Makefile管理构建。这套方案配置一次之后编译、烧录、调试都能在VS Code里完成。热词里很多人搜“vscode配置stm32开发环境”说明走这条路的人越来越多。我自己的主力开发环境就是VS Code加CMake写代码的体验比Eclipse舒服太多。2.2 一个最小工程的完整结构不管用哪个IDE一个能从源码变成hex文件的STM32 C工程必须包含下面几样东西链接脚本.ld文件定义Flash起始地址0x08000000和大小、RAM起始地址0x20000000和大小以及堆栈尺寸。启动文件startup_stm32f1xx.s初始化栈指针跳转Reset_Handler在C环境下还要负责调用全局对象的构造函数这个非常关键后文细说。系统初始化文件system_stm32f1xx.c设置时钟和总线频率。你的C源文件至少包括一个main.cpp以及你后续写的类定义和实现。一个链接和编译脚本或IDE工程配置。如果从STM32CubeMX生成基础工程它默认生成的是C语言模板但你完全可以继续在工程里添加.cpp文件。如果使用CMake你可以在顶层CMakeLists.txt里把源文件扩展名改成.cpp编译器会自动从gcc切到g。我这里列一个最小工程目录方便你对着建project/ ├── Core/ │ ├── Inc/ │ │ └── main.h │ ├── Src/ │ │ ├── main.cpp │ │ └── system_stm32f1xx.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ ├── startup_stm32f1xx.s ├── STM32F103C8Tx_FLASH.ld └── CMakeLists.txt别小看这个结构它有很强的约束力。你写的类最好放到独立目录不要在main.cpp里堆一两千行代码。嵌入式C最容易犯的毛病不是内存泄漏而是“一个main.cpp走天下”。2.3 点亮第一颗LED背后的步骤假设你已经用CubeMX或者自己搭好了工程接下来点灯这件事可以拆解成几个明确的步骤确认开发板上LED接的引脚。绝大多数板上LED接PC13STM32F103C8T6最小系统板也有接PA1或PB0的要看原理图。使能GPIO所在总线的时钟。GPIO外设挂在APB2总线上RCC-APB2ENR里对应位置1。配置引脚模式为输出GPIOx-CRL或CRH寄存器里把引脚模式配成“通用推挽输出”速度随便设个2MHz就够。往BSRR寄存器写值BSRR低16位对应引脚输出高电平高16位对应输出低电平。BSRR的好处是写0不会影响其他引脚直接按位写就行。这些动作你当然可以用HAL库一句话完成HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET);但那样你其实不知道底层发生了什么。我建议起步阶段自己操作寄存器体会一下存储器映射的感觉然后慢慢地再抽象封装。3. 第一段真正属于你的C代码GPIO类3.1 为什么用类封装寄存器搞嵌入式的人常年被人问“我们是写C的为什么要用C”我的回答是你不需要为了面向对象而突然引入一大堆虚函数和继承但在嵌入式里类同样是组织代码的好工具。比如说GPIO操作如果你有四个LED、三个按键用C写你可能会复制粘贴好几段初始化代码或者用宏定义把每个引脚定义一堆#define。用类封装一次定义好端口、引脚和模式之后调用成员函数代码的可读性能提高一个层次。关键问题是嵌入式C里的类不能滥用。理论上类可以有构造函数、析构函数、虚函数但这些特性会隐含地引入额外代码和运行时开销。写STM32代码时我们的原则是只要这个功能是编译期就能确定的就不要让代码在运行期多花一个字节。比如GPIO类我可以把引脚配置全部在构造函数里完成构造函数内联展开后实际生成的机器码和直接操作寄存器的C代码是一模一样的但代码结构却清晰得多。3.2 从底层寄存器实现GPIO类下面这段代码可以直接抄进你的GPIO.h里注意我是基于STM32F103的寄存器定义来写的核心原理对F4也适用只不过寄存器名字不同。class GPIO { public: enum class Mode { InputFloating, OutputPushPull, AlternatePushPull, }; GPIO(GPIO_TypeDef* port, uint16_t pin, Mode mode) : m_port(port), m_pin(pin) { // 使能GPIO时钟简化处理默认时钟已开启 // 这里需要根据端口选择对应的RCC使能位 if (port GPIOA) { RCC-APB2ENR | RCC_APB2ENR_IOPAEN; } else if (port GPIOB) { RCC-APB2ENR | RCC_APB2ENR_IOPBEN; } else if (port GPIOC) { RCC-APB2ENR | RCC_APB2ENR_IOPCEN; } uint32_t pinIndex 0; while (!(m_pin (1U pinIndex))) { pinIndex; } // 配置CRL或CRH uint32_t regOffset (pinIndex % 8) * 4; uint32_t* configReg (pinIndex 8) ? port-CRL : port-CRH; if (mode Mode::OutputPushPull) { *configReg ~(0b11U regOffset); // MODE[1:0] *configReg | (0b01U regOffset); // 输出模式最大速度2MHz *configReg ~(0b11U (regOffset 2)); // CNF[1:0] *configReg ~(0b01U (regOffset 2)); // 推挽输出CNF[1:0] 00 } else if (mode Mode::InputFloating) { *configReg ~(0b11U (regOffset 2)); // CNF[1:0] *configReg | (0b01U (regOffset 2)); // 浮空输入CNF[1:0] 01 *configReg ~(0b11U regOffset); // MODE 00输入模式 } // 更多模式可自行扩展 } void set() { m_port-BSRR m_pin; } void reset(){ m_port-BSRR m_pin 16; } void toggle() { if (m_port-ODR m_pin) { reset(); } else { set(); } } uint8_t read() const { return (m_port-IDR m_pin) ? 1 : 0; } private: GPIO_TypeDef* m_port; uint16_t m_pin; };这段代码有几个细节值得你注意pinIndex用while循环找引脚掩码里第一个置位的位这样就不需要为PA0~PA15各写一套逻辑。配置寄存器时我同时对MODE和CNF字段做处理顺序是先清MODE再置MODE再清CNF。这个讲究因为这两组bit互相独立但寄存器一次读写不能只改一个字段。toggle()的用法是读ODR。读ODR会有问题吗在大多数场景下没问题但如果引脚输出还要同时做复用功能读回来的值未必等于引脚真实电平这时用read()比较好。这里先解释为什么要用BSRR而不是ODR来控制输出。BSRR寄存器是写1有效置位和复位分成两个32位段写0则不影响。而ODR是整体寄存器要改某个位就得读-改-写万一在改写过程中引脚电平变了可能出错。BSRR写入后生效是硬件级别的原子操作不会被打断。嵌入式开发里能用硬件原子指令完成的就不要用软件去模仿原子。3.3 加入构造析构与错误检查的完整代码上面的GPIO类只用了构造函数和普通成员函数没有析构函数也没有虚函数这是有意的。析构函数不好写因为GPIO被销毁后引脚到底应该回到什么状态有一种选择是恢复为浮空输入但那可能影响电路其他部分。更安全的做法是提供一个deinit()方法显式关闭配置析构时什么都不做避免隐式副作用。我们再看一个更稳妥的使用场景。假设板上有两个LED一个在PC13一个在PA1你可以在main函数里这样写GPIO ledRed(GPIOC, GPIO_PIN_13, GPIO::Mode::OutputPushPull); GPIO ledGreen(GPIOA, GPIO_PIN_1, GPIO::Mode::OutputPushPull); int main() { while (1) { ledRed.toggle(); delay_ms(200); ledGreen.toggle(); delay_ms(200); } }delay_ms需要你自己实现。最简单的是用SysTick定时器初始化为“每1ms产生一次中断”在中断服务函数里递减一个全局计数然后delay_ms里死等计数归零。也可以用while循环空转做粗略延时但空转延时依赖编译优化非常不推荐用于生产。后面我会给一个基于SysTick的非阻塞延时做法。这段代码你编译、烧录后如果LED交替闪烁说明你的C工程已经正常运作了。到这一步你写的第一段已经不是“一行代码没写”而是已经写了至少四十行而且全部是你自己敲出来的。实际上你还完成了寄存器配置、位操作、类封装这一整套流程。4. 从闪烁到按键第一次完整的C状态机4.1 用状态机代替delay的思路点灯成功后很多人会习惯性地用延时函数控制闪烁亮500ms灭500ms。这个写法在纯LED场景没毛病但如果你加一个按键你很快就发现延时函数执行期间程序什么都干不了。单片机是单线程的你死等在delay里按键扫描就停了即使中断能打断延时整个程序也显得很乱。解决这个问题的主流思路是把程序写成“状态机 轮询”的结构。代码每隔一毫秒跑一次“心跳”心跳里判断当前时间如果应该切换状态就切换。这样程序永远不会阻塞按键、通信、显示都能同时维护。从C的角度看状态机非常适合用类来封装。把一个对象的状态hiding在private成员变量里外部只需要调用update()方法它会根据内部状态和当前时间决定行为。这个思想在大型嵌入式系统里同样适用比如电梯控制、洗衣机流程都是状态机的例子。4.2 状态机实现与核心代码我直接上一个按键控制LED的模式短按按键一次LED进入慢闪再短按一次进入快闪第三次短按关闭。这个需求实际上是一个三状态状态机。先定义按键类。按键输入有一个老生常谈的问题机械抖动。消抖最简单的办法是连续采样多次或者用“状态到达后等待稳定时间”。我给出一个基于时间片的消抖实现class Button { public: enum class State { Idle, Pressed, Released, }; Button(GPIO_TypeDef* port, uint16_t pin, uint32_t debounceMs) : m_port(port), m_pin(pin), m_debounceMs(debounceMs) {} // 每次循环调用传入当前毫秒计数 void update(uint32_t now) { bool level (m_port-IDR m_pin) ? true : false; switch (m_state) { case State::Idle: if (level) { m_lastTick now; m_state State::Pressed; } break; case State::Pressed: if (!level) { // 如果已经稳定了表示一次完整的按下并松开 if (now - m_lastTick m_debounceMs) { m_pressed true; } m_state State::Idle; } break; } } bool wasPressed() { bool result m_pressed; m_pressed false; return result; } private: GPIO_TypeDef* m_port; uint16_t m_pin; uint32_t m_debounceMs; uint32_t m_lastTick 0; bool m_pressed false; State m_state State::Idle; };其实这里我用了一个简化版状态机只处理“按下后回到Idle”的事件。消抖的核心在于最后一次跳变到稳定的时间如果时间太短我们认为是一次抖动。这个方法比“每隔几毫秒采样多次”更节省时间也更容易用C的状态枚举表达。然后是LED控制状态机class LedController { public: enum class Pattern { Off, SlowBlink, FastBlink, }; LedController(GPIO led, uint32_t slowPeriod, uint32_t fastPeriod) : m_led(led), m_slowPeriod(slowPeriod), m_fastPeriod(fastPeriod) {} void update(uint32_t now) { switch (m_pattern) { case Pattern::Off: m_led.reset(); break; case Pattern::SlowBlink: case Pattern::FastBlink: { uint32_t period (m_pattern Pattern::SlowBlink) ? m_slowPeriod : m_fastPeriod; if (now - m_lastToggle period) { m_led.toggle(); m_lastToggle now; } break; } } } void setPattern(Pattern p) { m_pattern p; m_lastToggle 0; } private: GPIO m_led; uint32_t m_slowPeriod; uint32_t m_fastPeriod; Pattern m_pattern Pattern::Off; uint32_t m_lastToggle 0; };这段代码体现了几个重要的C习惯LedController持有GPIO引用而不是GPIO*指针表示这个控制器离不开LED实例而且在构造之后这个关系不会变。模式切换时m_lastToggle 0确保下次update立即翻转LED而不是要等满一个周期。最后在main.cpp里串起来GPIO ledRed(GPIOC, GPIO_PIN_13, GPIO::Mode::OutputPushPull); Button btn(GPIOA, GPIO_PIN_0, 20); // PA0连按键按下为高 LedController controller(ledRed, 500, 200); extern C void SysTick_Handler(void) { g_tick; } void delay_cycles() { volatile uint32_t i; for (i 0; i 400000; i) { } } int main() { SysTick_Config(72000); // 72MHz主频每毫秒中断一次 while (1) { btn.update(g_tick); if (btn.wasPressed()) { static uint8_t cnt 0; cnt (cnt 1) % 3; if (cnt 0) controller.setPattern(LedController::Pattern::Off); else if (cnt 1) controller.setPattern(LedController::Pattern::SlowBlink); else controller.setPattern(LedController::Pattern::FastBlink); } controller.update(g_tick); } }SysTick_Config(72000)这个值是怎么来的STM32F103的最高主频是72MHzSysTick定时器底层是一个24位递减计数器每计一个数消耗一个时钟周期把重装载值设为72000也就是1ms递减计数一次。如果外部晶振是8MHz系统时钟经过PLL倍频到72MHz那这个值就是72M/1000 72000。如果你的板子主频改了这个数字也要同步改。到这一步你写的C代码行数已经接近两百行。你说你“一行都没写”只是因为你总是等在屏幕前看别人写。现在你已经完成了按键消抖、非阻塞延时、状态机切换这一整套嵌入式开发中最常见的逻辑。4.3 收获你其实已经写了多少行不算GPIO类底层封装不算Button类只算main.cpp里的逻辑你已经手写了大概50行有效代码。加上类的定义接近200行。这200行不是无意义的抄写而是你自己能看懂的、有结构的代码。你觉得没写可能是因为你还没有建立“写代码”的体感。体感很重要。写C和写嵌入式C相比最大的体感差异在于组织方式C代码习惯是文件函数全局变量而C代码习惯是把数据和相关操作绑定在一个类内部。同样的点灯按键功能C版本往往是一堆函数名加参数到处传而C版本你在main函数里很清晰地看到三个对象互相协作。在工程慢慢变大时这种清晰度就是生产力的保证。5. 嵌入式C实战中的高频坑与排查记录5.1 编译链接阶段的问题我第一次用C写STM32时踩的第一个坑是链接错误。错误信息类似这样undefined reference to __aeabi_atexit当时百思不得其解后来一查才知道C的静态对象析构要用到__aeabi_atexit函数但嵌入式环境里默认没有标准库支持。解决办法是在编译器参数里加-nostartfiles——不对准确说是在链接阶段加上--specsnano.specs或者直接实现一个空函数void __aeabi_atexit(void*) {}。更麻烦的版本是“纯裸机”环境你要确保全局对象构造不会引用到操作系统层面的初始化。同理undefined reference to __gxx_personality_v0也是常见问题。这是C异常处理相关符号嵌入式环境默认关掉异常需要在编译选项里加-fno-exceptions同时链接的时候告诉编译器不生成异常相关的内容。这个我建议从第一天就在编译器参数里加上-fno-exceptions -fno-rtti省得以后每次报错都来查一遍。RTTI运行时类型识别在嵌入式里几乎用不到关闭它不仅能减小代码体积还能避免依赖。还有一个链接问题经常被忽略未声明的extern C导致中断函数链接不上。STM32的启动文件是汇编写的里面声明Reset_Handler、SysTick_Handler等符号供你实现。如果你用C写main.cpp并且写了void SysTick_Handler()这个函数名会被C编译器进行名字修饰变成一串带参数类型信息的新符号名汇编启动文件就找不到了。解决办法是给中断函数显式加上extern C声明extern C void SysTick_Handler(void); void SysTick_Handler(void) { g_tick; }这个坑非常隐蔽因为编译不会报错只是烧录后中断完全不起作用。很多人点了半天灯都不亮其实不是引脚配错了是这个原因。5.2 运行期静默故障编译链接通过之后麻烦往往在运行期。嵌入式调试工具少经常程序看起来没反应你甚至不知道它跑到哪一行了。下面几个运行期问题是我个人碰到次数最多的。首先是volatile关键字。用优化级别O2编译时编译器会把不改变的变量优化掉。比如你写一个循环for (uint32_t i 0; i 100000; i);如果i只在循环里使用而且循环体为空编译器在优化时可能直接把整个循环删掉或者把i优化成一个死循环。为了让延时函数真正生效你得把i声明为volatile uint32_t i告诉编译器“不要对这个变量的读写做优化”。同理在中断和main之间共享的所有变量都应该加上volatile或者使用原子操作。比如前面例子里的g_tick在main的while循环里被读取同时在SysTick中断中被增加这两个地方共享必须用volatile修饰否则编译器可能只在进入循环前读一次后面每次都使用缓存值按键消抖和控制器时间判断全部失效。第二个是全局对象的构造时机。在C里全局对象有一个构造阶段发生在main函数之前。嵌入式工程里这个构造由启动文件调用__libc_init_array来执行。很多从STM32CubeMX生成的工程启动文件确实有这个调用但如果你自己写了一个精简启动文件很可能漏掉这一步。漏掉的结果是你的GPIO类构造函数没被调用但是全局对象的内存已经分配了你调用set()时会往一个没有初始化的寄存器地址写值自然没有任何反应。所以如果你用了全局对象必须确认启动文件里有构造段处理逻辑。如果没有要么老老实实改启动文件要么改为在main函数里用局部对象或者用单例模式懒初始化。第三个是中断上下文和普通函数的资源竞争。比如你在Button的update()里读GPIO而稍后又在中断里写另一个GPIO两者没有共享数据还好。但如果中断里修改了某个标志位而main里读取后清零这个“读-改-写”过程可能被打断导致丢失事件。解决方法是要么在进入临界区前关中断要么把事件处理设计成更安全的方式。普通嵌入式课本会教你用__disable_irq()和__enable_irq()包住临界区但这个操作有代价会引入不确定的中断延迟能用read-modify-write原子指令解决的就优先用硬件方案。5.3 调试手段与经验嵌入式调试别指望像桌面开发那样打断点看变量真机现场往往没有串口打印也不可能随时接J-Link。你有三样趁手的工具调试器的断点与寄存器窗口、串口打印、LED指示灯。用ST-Link/J-Link配合IDE调试时你确实可以在源代码设断点、单步执行但一旦涉及到多线程中断并发问题调试器反而会干扰时序。我的经验是能用一个二进制位的LED指示状态就不要急着打印字符串。比如你怀疑按键消抖逻辑有没有进入Pressed状态可以让LED在进入该状态时亮一下然后观察现象。这比读串口日志直观得多。串口打印也可以用但要注意log函数本身不能阻塞太久。很多工程师在中断里打印日志结果打印几条就把程序卡死了。正确做法是建一个FIFO缓冲区中断只负责把数据写进缓冲区里main函数循环负责像倒垃圾一样慢慢发送缓冲区数据。如果你不会实现FIFO宁可在中断里设置一个标志位main里看到标志位就打印也别直接在中断里调printf。另外我建议每个模块开头的调试阶段都保留一个“自检模式”。比如GPIO类可以设计一个selfTest()方法初始化后把所有引脚都翻一遍观察LED有没有反应。自检模式看起来不起眼但在排错时能帮你快速区分“引脚初始化配置错了”和“后续逻辑写错了”。写在最后的一点体会回到开头那位朋友的问题看了三篇了一行都没让我写呢。现在你回看这一篇是不是已经写了不少行其实关键不在行数而在于你有没有在写代码的时候意识到自己在操作硬件。你写RCC-APB2ENR | 1 4;的时候脑子里有没有想到那一根使能总线的硬件线在某个时钟周期被拉起来你写GPIOA-BSRR 0x00000001;的时候有没有想到前面那根引脚的电平在几纳秒后发生变化这才是嵌入式编程有意思的地方也是C在这个领域里发挥价值的场景你要在足够高的抽象层级上描述逻辑同时不能丢掉对每一行机器指令背后硬件行为的感觉。我自己早年习惯用纯C写STM32后来在项目里逐渐引入C最开始的几十个类都是“寄存器操作的结构体套了一层方法”说实话收益不大。真正的收益出现在写出了状态机、消息队列、任务调度这些结构之后模块之间的耦合变弱了测试也变得容易。我相信你继续写下去也会遇到同样的问题遇到的时候别急着写一大堆设计模式先从最简单的封装开始一点一点把C的语法落地到寄存器之上。这条路走到后面自然是水到渠成。
返回列表