☰
LPC17xx例程实战:Cortex-M3外设库GPIO、UART与定时器调试指南
2026/10/15 1:20:49 网站建设 项目流程

简介:面向LPC17XX系列嵌入式开发者的基础例程合集,基于NXP Cortex-M3内核MCU,覆盖ADC模拟采集、CAN总线通信、DAC输出、外部中断、GPDMA高速传输等常用外设的初始化与使用流程,并附Keil工程模板,便于初学者快速搭建项目并理解编译调试环境。压缩包共1675个文件,以h、c源代码和uvproj、uvopt等Keil工程文件为主,兼有o、axf、map等编译中间产物,以及sct链接脚本和启动文件,可对照查看完整工程结构;整包约7.79MB,已有408人学习下载。例程中的驱动代码多可直接修改复用,配合模板工程能快速生成自定义项目,既适合刚入门的学生熟悉寄存器配置和中断处理,也可供在职工程师作为项目起步参考,缩短原型开发周期。

1. LPC17xx例程:先解决“从哪下手”和“能不能跑”这两个问题

手边如果有一块LPC17xx开发板,最闹心的不是外设本身,而是网上能找到的参考资料太散:有人贴LPC21xx的老代码,有人只发工程截图不发完整源码,还有人把寄存器配置写错还振振有词。LPC17xx例程之所以值得下载,是因为它是官方围绕Cortex-M3标准外设库整理出的一套可直接编译的工程集合,GPIO、UART、定时器、ADC、DAC、CAN、USB、以太网都有对应的完整例子。拿到手先点灯,再改串口,最后组合成自己的小系统,整个过程不需要从芯片手册第一页啃起。适合刚接触LPC17xx系列的初学者,也适合要快速验证某个外设能否按时序完成任务的嵌入式开发者。

2. 硬件准备与工程环境:先让第一个例程在板子上亮起来

2.1 开发板与调试器选型:为什么我不建议一上来就买高配开发板

LPC17xx例程包里的工程大多以某个具体型号为目标,比如系列里最常见的1768。这个系列有个特点:同系列不同型号的Flash和RAM容量差得很多,但寄存器映射基本一致。我习惯的做法是买一块最小系统板,把SWD调试接口、串口、电源引脚引出来,再配一个J-Link或CMSIS-DAP调试器就够了。高配开发板上的板载外设反而容易让人分心,而且很多例程里的引脚定义和板载外设并不一致,改起来更麻烦。

接线方面,SWD只需要四根线:SWDIO、SWCLK、GND、3.3V。LPC17xx上电默认是JTAG模式,SWD模式需要通过调试器初始化序列切换,J-Link和CMSIS-DAP都能自动处理,不需要手动配置。有一点要注意:这个系列把JTAG/SWD引脚和GPIO复用,例程里如果有代码把PINSEL10清零改成GPIO模式,就会导致第二次下载连不上目标板。这不是板子坏了,是引脚被程序占用了,后面避坑章节会专门讲恢复方法。

2.2 例程包工程结构:先搞清楚每个目录里是什么

解压LPC17xx例程包后,通常每个外设对应一个独立的工程目录,目录名就是例程功能,比如GPIO_LED、UART_Interrupt、ADC_Burst、TIMER_Match等。每个目录下都会包含启动文件startup_LPC17xx.s、系统初始化文件system_LPC17xx.c、外设驱动源文件和头文件,以及MDK工程文件。有些版本还会带printf重定向用的retarget.c,方便你在串口助手直接看调试信息。

第一次打开任意一个例程,先确认三件事:芯片型号、编译优化级别、下载算法。MDK的工程文件本质上是XML文本,可以直接用文本搜索工具核对型号,不需要打开IDE。在Windows下用findstr,在Linux下用grep:

findstr /i "LPC17" examples\GPIO_LED\keil\GPIO_LED.uvprojx

这条命令在例程目录下搜索工程文件里的芯片型号关键字。如果搜出来的型号和你板子上的丝印不一致,后面编译链接大概率会出问题,或者下载后程序直接跑飞。参数说明:/i表示忽略大小写,MDK工程里芯片型号可能写作LPC1768、LPC1768FBD等不同格式,模糊搜索能覆盖更多情况。

确认工程配置没问题后,编译生成的hex文件可以直接用命令行下载。J-Link自带的JLinkExe支持脚本方式烧录,适合批量验证多个例程:

r loadfile build\GPIO_LED.hex g exit

这是JLinkExe的交互命令序列:r复位目标板,loadfile加载编译产物,g让程序全速运行,exit退出调试器连接。如果用的是CMSIS-DAP,则直接在MDK里按F8下载更省事。这个脚本的好处是脱离IDE,你在CI环境或命令行下也能反复烧录验证。

2.3 MDK工程设置:芯片型号、优化级别和下载算法一次配好

打开例程工程后,在Project菜单下的Options for Target里,Device选项卡选择目标芯片型号。这里别凭印象选,要看板子丝印。LPC1768和LPC1758的Flash大小不同,下载算法也不通用。Debug选项卡里选择调试器类型,CMSIS-DAP选Cortex-M SWD模式,J-Link选J-LINK/J-TRACE Cortex。下面的Flash Download设置里,Programming Algorithm必须选对:LPC1768对应512KB Flash的算法,选错了下载时会提示校验失败,甚至把芯片锁住。

优化级别的选择也有讲究。官方例程默认通常是不优化或-O1,因为-O2以上可能导致调试时观察变量被优化掉,单步执行时跳来跳去。如果你要照着例程调试,建议编译优化级别先保持默认,等功能验证过再改。

注意:首次用高版本MDK打开低版本创建的例程工程,编辑器会提示安装Legacy Support。这一步必须装,否则编译时会出现大量跟CMSIS相关的头文件错误,后面避坑章节还会提到。

3. 标准外设库的骨架:启动文件、SystemInit与时钟树,例程改不改得动全看这三处

3.1 从复位到main:startup_LPC17xx.s在做什么

很多人在LPC17xx例程上翻车,不是外设代码写错,而是根本不清楚main函数之前发生了什么。Cortex-M3上电后,硬件从复位向量表取Reset_Handler地址,然后跳进启动文件。LPC17xx例程的启动文件干这几件事:初始化栈指针、设定异常向量表、把只读数据复制到RAM、清零BSS段、调用SystemInit配置时钟、最后跳转__main进入C库初始化。

启动文件里最关键的几行如下:

LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0

BLX R0是把控制权交给SystemInit函数,这个函数对应system_LPC17xx.c里的实现,完成PLL0时钟配置。__main是C库的入口符号,它负责把RW数据从Flash复制到RAM、把ZI区清零,然后才调用你的main函数。如果启动文件和库函数版本不匹配,程序会在进入main之前就跳进HardFault异常。所以换芯片型号时,启动文件不要随便从老项目拷贝,例程包里自带的才是对应版本。

3.2 SystemInit与PLL0:例程默认的100MHz系统时钟是怎么来的

LPC17xx上电后内部IRC振荡器只有4MHz,要靠SystemInit里的PLL0配置倍频到100MHz。例程工程里system_LPC17xx.c默认把外部晶振12MHz作为时钟源,通过PLL0倍增到100MHz,再经过CPU时钟分频得到CCLK。这套配置在文件顶部的宏里集中定义,改晶振频率时只改宏,不碰寄存器代码:

/* system_LPC17xx.c 中默认时钟配置(示意) */ #define PLL0_SETUP 1 #define PLL0CFG_VAL 0x0023006B // M=100/12, P=2

PLL0CFG_VAL的高字节决定P分频,低字节决定M倍频。12MHz外部晶振经过M=25倍频再除以P=3,正好得到100MHz。如果你板子上的晶振不是12MHz,必须同步修改这个宏。忽略这一步的话,后面的串口波特率、定时器定时时间、ADC采样时钟全部按错误时钟计算,表现就是串口乱码、延时不准。

这个配置还有一个容易被忽略的坑:SystemInit里配置完PLL0之后,还会通过PCLKSEL0和PCLKSEL1为每个外设分配独立的外设时钟PCLK。外设时钟可以是CCLK、CCLK/2、CCLK/4或CCLK/8,具体由对应两位选择。例程默认把大多数外设设成CCLK,但个别外设可能不同。如果你改动了PCLKSEL,外设的波特率或定时值就要跟着重算。

3.3 点亮一颗LED的完整链路:GPIO例程与PINSEL选型

GPIO点灯是LPC17xx例程里最基础也最能说明问题的一个。很多人按8位单片机的习惯,上来直接写IO方向和数据寄存器,结果LED灭不了,原因就是漏了引脚复用配置。LPC17xx的每个引脚默认可能是GPIO,也可能是某个外设功能,由PINSEL寄存器决定,每两位控制一个引脚:00表示GPIO,01和10表示外设复用,11保留。

一个典型的点亮LED例程如下:

/* 初始化 P1.18 为 GPIO 输出,假设 LED 接在 P1.18,低电平点亮 */ LPC_PINCON->PINSEL3 &= ~(0x3UL << (2UL * 18)); // P1.18 设为 GPIO LPC_GPIO1->FIODIR |= (1UL << 18); // 方向设为输出 LPC_GPIO1->FIOCLR = (1UL << 18); // 输出低电平,点亮

第一行把PINSEL3中控制P1.18的两位清零,确保引脚不作为其他外设功能使用。第二行FIODIR第18位置1,把引脚方向设为输出。第三行FIOCLR写1是输出低电平,LED点亮;如果LED是高电平点亮,改成FIOSET写1。参数说明里最需要注意的是PINSEL的操作方式:用先清零再置位的方式,避免改到相邻引脚配置。

例程库里通常把这一串封装成GPIO_SetDir、GPIO_SetValue这样的库函数,但底层逻辑就是这个。看懂了这段裸寄存器操作,再看库函数就一目了然。GPIO例程的价值不在于点亮一颗灯,而在于让你理解引脚复用、方向、输出的三层关系。

4. 串口、定时器与ADC:三个高频例程的改法与验证方法

4.1 UART例程:从轮询到中断,乱码多半在PCONP和PINSEL

LPC17xx的UART例程最能体现官方外设库的价值,因为串口涉及电源控制、引脚复用、波特率计算、中断四件事,少一件都跑不通。初始化一个UART外设之前,必须先在PCONP寄存器里打开对应外设的电源控制位。这个寄存器控制整个芯片上百个外设的供电,例程初始化代码里都会主动打开自己需要的外设,但如果你自己新建工程,很容易漏掉这一步。

UART2是很多例程默认使用的串口,初始化代码如下:

/* UART2 初始化,PCLK=100MHz,目标波特率 115200 */ LPC_SC->PCONP |= (1UL << 24); // 打开 UART2 电源 LPC_PINCON->PINSEL4 |= (2UL << 20) | (2UL << 22); // P0.10 TX, P0.11 RX LPC_UART2->LCR = 0x83; // 8位数据,1停止位,使能 Divisor Latch LPC_UART2->DLM = 0; LPC_UART2->DLL = 54; // 100MHz/16/115200 = 54.2 LPC_UART2->FDR = 0x10; // 使用小数分频器校准 while (!(LPC_UART2->LSR & (1UL << 5))); // 等待 THR 空 LPC_UART2->THR = 'A'; // 发送一个字符测试

代码解释:PCONP的bit24对应UART2电源控制,PINSEL4的bit20和bit22把P0.10、P0.11配置成UART2的TX和RX。LCR的bit7置1是使能波特率锁存,这样后面写DLM和DLL时操作的是波特率寄存器而不是数据寄存器。DLL=54的由来是100MHz除16再除115200。

实际误差在0.2%以内,串口通信足够稳定。发送前查LSR的bit5是THR空标志,防止数据覆盖。这个例程改起来最需要注意的是引脚复用:不同板子的串口可能接在不同引脚上,PINSEL寄存器都要重新算过,不能照抄。

4.2 定时器例程:用TIMER0做定时查询,拿到精确延时

LPC17xx例程里的定时器一般包含TIMER0匹配中断和捕获两种模式。匹配中断在工程里最常见,它的原理是TIMER0的计数器TC在时钟驱动下不断自增,当TC等于预装载的MR0值时,触发匹配事件。匹配事件可以配置为中断、复位计数器或停止计数。定时计算依据是:定时值 = PCLK / 定时时间。

用TIMER0做一个1秒定时的轮询代码如下:

/* TIMER0 1 秒定时,PCLK=100MHz */ LPC_SC->PCONP |= (1UL << 1); // 打开 TIMER0 电源 LPC_TIM0->MCR |= (1UL << 1); // MR0 匹配后复位 TC LPC_TIM0->MR0 = 100000000UL; // 100MHz 计数 1 亿次 = 1 秒 LPC_TIM0->TCR = 1; // 启动定时器 while (!(LPC_TIM0->IR & (1UL << 0))); // 等待匹配中断标志 LPC_TIM0->IR = 1; // 写 1 清除中断标志

MCR的bit1是复位TC,bit0是产生中断。MR0的值乘以时钟周期等于定时时间,100MHz时钟下100000000就是1秒。IR是中断标志寄存器,查询模式下要在标志置位后手动写1清除。

实际项目中不太建议用查询方式等定时器,因为while会卡死CPU。更常见的做法是把T0_IRQHandler写好,在中断里翻转LED。这个例程的价值在于让你理解MR0、MCR、TCR三者的关系——改MR0就可以改定时间隔,改MCR就可以让定时器变成一次性执行还是循环执行。很多实时任务调度就是在此基础上叠加状态机实现的。

4.3 ADC例程:软件触发一次转换,结果怎么换算成电压

LPC17xx ADC例程提供两种触发方式:软件触发和定时器触发。软件触发适合调试时单次读取,定时器触发适合采集连续波形。ADC的功能框图在芯片手册里占了好几页,但例程里的有效配置就集中在AD0CR一个寄存器:低8位选通道,第8到15位配分频,bit21是启动位。

一个通道3的软件触发读取代码如下:

/* ADC 通道3,对应 P0.25,参考电压 3.3V */ LPC_SC->PCONP |= (1UL << 12); // 打开 ADC 电源 LPC_PINCON->PINSEL1 |= (1UL << 18); // P0.25 设为 AD0.3 LPC_ADC->AD0CR = (1UL << 3) // 选择通道3 | (11UL << 8) // 分频值 12,ADCLK≈4MHz | (1UL << 21); // 软件启动转换 while (!(LPC_ADC->AD0GDR & (1UL << 31))); // 等待 DONE 标志 uint16_t raw = (LPC_ADC->AD0GDR >> 4) & 0xFFF; float volt = (float)raw * 3.3f / 0xFFF; // 12 位分辨率换算电压

P0.25在PINSEL1里的两位分别置成01,就把它从GPIO切换成ADC功能。AD0CR第8到15位的分频值加1之后,用外设PCLK除以它,必须保证ADC时钟不超过12MHz。这里的11配合50MHz的PCLK得到4MHz。如果AD0CR里没启动转换,DONE标志永远不会置位,程序会一直卡在while里。

ADC结果在AD0GDR的高16位,右移4位后取低12位就是原始值。换算电压时把原始值除以4096再乘以参考电压。参考电压是模拟量,不是逻辑量,所以3.3V的ADC值不是精确的3300毫伏——电源纹波和参考源漂移都会反映在结果里。这也是例程里ADC结果总是跳动的根本原因。

4.4 printf重定向:让每个例程的调试信息都从串口出来

LPC17xx例程包里通常有一个retarget.c,它把C库的fputc函数重新定向到UART,让你能在代码里直接用printf输出调试信息,不用每个外设都写串口发送函数。这个文件看起来很简单,但重定向的目标串口是可以改的:

/* retarget.c: 把 printf 输出定向到 UART0 */ int fputc(int ch, FILE *f) { while (!(LPC_UART0->LSR & (1UL << 5))); // 等 THR 空 LPC_UART0->THR = (uint8_t)ch; // 写一个字节 return ch; }

这段代码先查LSR的THR空标志,然后把字符写入THR寄存器。由于printf是同步调用,每个字符都会等上一次发送完成后才继续,所以调试时能看到完整的信息流。如果你的调试串口是UART2,只需要把LPC_UART0改成LPC_UART2,并确保UART2已经初始化。注意这个重定向依赖C库的printf,编译时必须勾选微库或标准C库选项,否则fputc不会被调用。

5. LPC17xx例程调试避坑:五个高频翻车点的现象、原因与解决

5.1 MDK打开例程后编译报几百个错误,头文件一片红

现象:例程工程在MDK5上第一次编译,错误列表里全是跟core_cm3.h、system_LPC17xx.h相关的“cannot open source file”,有时候一条编译信息都没有就报错。

原因:MDK5默认不安装旧版CMSIS器件支持包。LPC17xx例程是从老版本MDK创建的,它的工程文件依赖的组件跟MDK5新初始化的组件不匹配,编译器找不到CMSIS头文件路径。

解决:在MDK的Pack Manager里安装Legacy Support组件。装好之后重新打开工程,头文件路径会被自动修复。如果不行,检查工程里C/C++选项的Include Paths,手动把CMSIS\core和CMSIS\Device\NXP\LPC17xx\Include两个目录加进路径。

5.2 程序烧录后芯片发烫,程序卡在HardFault

现象:用MDK下载例程后,调试时程序停在HardFault_Handler里出不来,甚至有些板子的芯片表面温度异常升高。

原因:芯片型号选错。LPC17xx系列不同型号的Flash容量和地址映射有差异,如果目标芯片是LPC1758等小容量型号,而工程配置的是LPC1768的大容量Flash算法,下载时写入了超出芯片物理地址范围的代码,程序取指就跑飞。另一个原因是PLL0配置超出了芯片允许的最高频率,导致内核供电过压。

解决:先在Options for Target里核对Device型号和板子丝印是否一致,再确认Flash Download算法里的编程器型号。这两个对了,HardFault大概率消失。如果时钟配置改过,把system_LPC17xx.c恢复到例程默认状态再试。

5.3 第二次下载时报Cannot access target,SWD连不上

现象:第一次下载例程能正常烧录,但程序运行后再次连接调试器,报“Cannot access target”或“Connection refused”,板子复位也不行。

原因:例程把SWD/JTAG引脚复用成了GPIO,或者代码里配置了DeepPowerDown模式,把调试器接口电源断掉了。这种问题最典型的触发点是PINSEL10被清零,或者下载了带功耗管理的例程后没有退出低功耗模式。

解决:按住板子的复位键,在MDK里点击下载,保持复位键按到Download开始后再松开。如果这个方法不行,把板子ISP引脚拉低再上电,让芯片进入ISP模式,用串口工具擦除整片Flash,把引脚复用的程序抹掉。擦除后芯片恢复出厂状态,SWD重新连上。

5.4 UART收到一堆乱码,串口助手显示不是预期字符串

现象:用官方UART例程收发数据,串口助手显示的字符跟代码里不一致,或者包含大量重字符。波特率越低乱码越明显。

原因:乱码的本质是收发双方的波特率对不上。LPC17xx的UART波特率由PCLK和DLM/DLL决定,PCLK又和PLL0、PCLKSEL相关。任何一环的时钟估算错,DLL的计算结果就不是你想要的整数。特别常见的是PCLKSEL寄存器配置成了CCLK/4,你还按100MHz算DLL,实际波特率差了4倍。

解决:先做回环测试,把TX和RX引脚短接,串口助手发什么就应收到什么。如果回环正常,说明波特率没问题,问题在外部接线。如果回环乱码,查PCLKSEL里该串口对应的分频系数,按实际PCLK重算DLL。再用示波器看TX引脚的波形周期,验证理论值是否和实际一致。

5.5 定时器延时是预期时间的两倍或一半,时间怎么都不准

现象:用TIMER0做1秒延时,实测时长是2秒或0.5秒,而且不同例程偏差不同。

原因:TIMER0的外设时钟PCLK不是100MHz。LPC17xx的PCLKSEL寄存器把每个外设独立分配时钟,TIMER0对应的位如果配置成CCLK/2,MR0里写入100000000就需要2秒才计数完成。例程里MR0经常按SystemCoreClock计算,但SystemCoreClock是CPU主频,不是外设时钟PCLK。

解决:在调试窗口里查看PCLKSEL寄存器的值,确认TIMER0的时钟源是CCLK还是分频值。然后把MR0改成PCLK * seconds,或者直接用库函数里封装的时钟频率宏。从那以后我每次新建工程或合并例程,都强制走一遍启动文件、时钟、引脚复用、外设时钟四步排查,先看寄存器再看代码,能少走很多弯路。

6. 把多份例程拼成一个小系统:按键消抖、定时器与串口三合一

6.1 从例程目录复制代码,先列清单再合文件

手里有了GPIO、定时器、串口三份独立例程,接下来要做的不是重新写代码,而是把三份例程的内容合并到一个工程里。合并的第一步不是拖文件,而是列清单:哪些文件是公共的,哪些文件只属于单个例程。启动文件、system_LPC17xx.c、lpc17xx.h这些是公共的,每个例程都有,保留一份即可。GPIO、TIMER、UART的驱动源文件和头文件属于外设库,每个外设单独打包。真正需要改的只有每个例程里自己写的main函数和外设初始化逻辑。

6.2 一个用按钮控制LED并输出串口日志的最小系统

合并时最忌讳的是把多个main函数拖进同一个工程,编译直接报重复定义。正确做法是只保留一个main.c,把你需要的外设初始化代码按顺序放进这个main文件里。下面是一个把按键、LED、定时器查询和串口printf组合在一起的最小例子:

/* 组合例程:按键按下 LED 亮,松手熄灭,串口输出状态 */ #include "lpc17xx.h" #define KEY_PIN (1UL << 10) // P2.10 按键输入,按下为低 #define LED_PIN (1UL << 18) // P1.18 LED,低电平点亮 int main(void) { uint32_t last_state = 1, stable_cnt = 0, debounced = 0; SystemInit(); LPC_PINCON->PINSEL3 &= ~(0x3UL << 36); /* P1.18 为 GPIO,36=2*18 */ LPC_GPIO1->FIODIR |= LED_PIN; LPC_PINCON->PINSEL4 &= ~(0x3UL << 20); /* P2.10 为 GPIO */ LPC_GPIO2->FIODIR &= ~KEY_PIN; UART2_Init(115200); /* 复用第 4 章的串口初始化函数 */ while (1) { uint32_t cur = (LPC_GPIO2->FIOPIN & KEY_PIN) ? 1 : 0; if (cur != last_state) { stable_cnt = 0; last_state = cur; } else { stable_cnt++; } if (stable_cnt >= 50 && !debounced) { /* 50ms 消抖 */ debounced = 1; if (cur == 0) { LPC_GPIO1->FIOCLR = LED_PIN; printf("key pressed\r\n"); } else { LPC_GPIO1->FIOSET = LED_PIN; printf("key released\r\n"); } } } }

这段代码里最关键的部分是边沿检测和消抖状态机。stable_cnt用于累计引脚状态不变的周期数,相当于用连续读取的方式过滤按键抖动。last_state记录上一轮状态,状态变化时清零计数器,状态稳定后才执行动作。debounced标志保证一次按下只触发一次动作。fpinc寄存器读取的是引脚的实际电平,不是输出锁存的值,所以读按键必须用FIOPIN而不用FIOSET。

6.3 合完以后的验证顺序:从下载到串口一条线走完

合并后的工程先编译,如果报错就逐个检查重复的宏定义和函数名,特别是UART2_Init这类自己命名的函数。下载后先看LED能不能正常点亮,再看串口助手的输出是不是和按键动作一一对应。如果你发现按键按下后串口输出多次重复信息,多半是消抖的累计阈值太小,把stable_cnt的比较值从50改成100或者更大。从那以后我每次合并例程,都会强制把三份例程的初始化函数和中断服务函数单独放进一个外设初始化文件里,避免main函数越来越难维护,希望这个习惯也能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询