简介:一份基于51单片机与C语言实现LCD液晶屏文字滚动功能的入门级源码示例,面向单片机开发初学者,也适合需要复习LCD驱动与定时器用法的嵌入式爱好者。压缩包仅含1个C语言源文件(lesson18.c),大小约560B,结构非常精简,便于直接剖析核心代码。已有132人学习下载。程序围绕液晶显示相关知识点展开,包含LCD初始化、显示位置设置、字符写入、控制指令发送以及文字滚动逻辑;通过阅读该C代码,可以直观理解51单片机I/O口与LCD接口的数据交互、C语言对硬件寄存器的底层访问方式,以及如何利用定时器中断稳定刷新显示位置。完成示例学习后,开发者能够掌握单片机驱动外设的基本流程,也能为后续编写更复杂的动态显示程序打下基础。在Keil等环境中编译烧录后,还可进一步调整滚动速度与显示策略,加深对单片机实时控制的理解。
1. 拿到 lesson18.rar:先确认这节单片机课跑在哪条工具链上
在课程群或网盘里,lesson18.rar 这种命名很常见:数字是课时编号,rar 是归档格式,真实身份是单片机开发课的 C/C++ 例程包。解压之后通常遇到两种结果:要么是十几层文件夹套着一个老 Keil 工程,要么是 .c 和 .cpp 混在一起,双击 .uvprojx 却提示缺少器件支持包,或者编译器版本对不上。问题不在压缩包本身,而在工具链:51、STM32、九齐这些内核的编译器和调试链路完全不同,C 与 C++ 的编译规则也不同。这篇文章从解压开始,把工程识别、工具链配置、编译烧录到最后验证的整条链路走一遍,适合拿到课程包但一直没跑通的人。
2. 解压工程先认清对象:文件结构、编译器和 C/C++ 的边界
2.1 从 rar 归档到本地工程:先解决编码与目录嵌套
rar 归档里最常见的坑不在压缩格式,而在字符集。课程包多数是在简体中文 Windows 上用 WinRAR 打的包,文件名按 GBK 编码写入,传到 macOS 或 Linux 上用默认 UTF-8 解压,中文目录名直接变成乱码,甚至解压失败。Windows 上直接用 WinRAR 或 7-Zip 一般没问题,跨平台环境下我会先指定解压目录,再用 find 把工程关键文件捞出来。
7z x lesson18.rar -o./lesson18 find ./lesson18 -maxdepth 2 -type f \( -name "*.uvprojx" -o -name "*.uvproj" -o -name "Makefile" -o -name "*.c" -o -name "*.cpp" -o -name "*.h" \) | sort第一条命令里7z x表示解压到指定目录,-o后面直接跟路径,中间不能有空格。第二条命令里的-maxdepth 2是控制扫描深度,课程包目录嵌套超过两层时可以把数字调大;括号里是文件类型白名单,只看工程文件和源码,避免被一堆.bak、.uvguix干扰。解压后如果没有找到任何工程文件,先怀疑编码问题,把压缩包拷到 Windows 下重新解压一遍再判断。
2.2 用工程文件反推编译器:.uvprojx、.uvproj 与 Makefile
解压后第一件事不是双击工程文件,而是看工程文件的类型和后缀。Keil 5 的工程文件是.uvprojx,本质是 XML;Keil 4 老工程是.uvproj;还有些课程包用的是 Makefile 或 cmake 组织源码。对这些文件可以直接 grep,不用打开 IDE:
grep -E "<Device>|<Cpu>" lesson18.uvprojx | head -20<Device>后面跟着的是芯片型号,比如 STM32F103C8、STC89C52 这类字符串;<Cpu>后面描述存储区域。看到 IRAM/IROM 且地址从 0 开始,基本可以判断是 51 内核;看到0x08000000开头的 IROM,是带 Flash 映射的 ARM 内核,典型的就是 STM32 或 GD32。这一眼决定后面用哪套编译器和烧录工具,搞反了后面全白做。
| 工程标志 | 内核 | 默认编译器 | C++ 支持 |
|---|---|---|---|
.uvprojx+ 8051/STC 型号 | 51 | Keil C51 | 不支持,仅扩展 C |
.uvprojx+ Cortex-M 型号 | ARM | MDK AC5/AC6 | C++11/C++14 |
| Makefile + arm-none-eabi-gcc | ARM | GNU Arm 工具链 | C++17 |
| Makefile + sdcc | 51 | SDCC | 有限支持 |
这里有个课程包常见的坑:.uvprojx是老版本 MDK 生成的,新装的 Keil 打开时提示「Device not found」,是因为没有安装对应的器件支持包,也就是 DFP。Keil 5 的 Pack Installer 里按芯片厂商搜型号安装即可,这个动作不改变工程源码,只补齐器件数据库。如果打开直接提示编译器版本过期,那是工程里留了 AC5 的编译配置而当前只装了 AC6,后面配置工具链时再处理。
2.3 C 与 C++ 的编译分工:extern "C" 是怎么串起来的
同一个工程里同时出现 .c 和 .cpp,先要分清谁编译谁。单片机工程里 C 负责寄存器操作、启动代码、厂商库,这些编译单元按 C 规则编译,函数符号就是函数名本身;C++ 支持重载,函数符号会把参数类型揉进去。如果让 C++ 直接链接一个 C 函数符号,链接器找不到,于是要在头文件上加一道隔离:
#ifdef __cplusplus extern "C" { #endif void led_on(void); void uart_send_byte(unsigned char ch); #ifdef __cplusplus } #endif当这个头文件被 .cpp 包含时,__cplusplus宏存在,编译器看到 extern "C",对声明里的函数按 C 链接规则处理,不生成 C++ 修饰名;当头文件被 .c 包含时,__cplusplus不存在,条件编译到空分支,不影响 C 编译。这是 C 与 C++ 混编最基础也最关键的一段代码,拿到课程包后先检查所有被 C++ 调用的 C 函数头文件是否包了这层,没包的补上,编译错误能少一半。注意 extern "C" 只作用于声明,不能把 C++ 的类定义塞进去,否则会直接语法错误。
3. 配置工具链:从 MDK 参数到 VSCode 的 C/C++ 环境
3.1 Keil 工程必调的四个参数:标准、路径、宏与优化
用 MDK 打开 lesson18 的 .uvprojx 后,先别急着编译。打开「Options for Target」里的 C/C++ 页签,把下面四个位置过一遍,这是老课程包最常出问题的地方。
| 参数 | 位置 | 常见错误表现 |
|---|---|---|
| C 标准 | C/C++ 页签 → C Standard | implicit declaration 警告 |
| Include Paths | C/C++ 页签 → Include Paths | No such file or directory |
| Define 宏 | C/C++ 页签 → Define | 外设库分支选错,寄存器错乱 |
| Optimization | C/C++ 页签 → Optimization | 调试无法设断点,变量被优化掉 |
第一个是 C 标准。AC6 编译器默认按较新的标准处理,老课程代码里常见的「函数未声明就直接调用」在默认标准下可能只是警告,在严格模式下直接报错。调试阶段把 C Standard 设为 C99,够用且兼容性最好。第二个是 Include Paths。工程换电脑后绝对路径经常断,编译器报找不到 stm32f10x.h 这类头文件,十有八九是这里漏路径。第三个是 Define 宏。STM32 标准外设库依赖STM32F10X_HD这类容量宏来判断芯片型号,宏不写或写错,库头文件会把配置分支全部走错,编译能过但行为完全不对。第四个是优化等级。路径、宏配好后,先用-O0编译跑通,确认功能正常再调优化,这个顺序对新手工程尤其重要。
3.2 VSCode 配置 C/C++ 环境:让补全和 Keil 保持一致
用 Keil 写单片机 C/C++ 最大的痛点是编辑体验,于是很多人的做法是:用 VSCode 写代码和看补全,用 Keil 编译下载。这个流程要跑通,核心是让 VSCode 的 IntelliSense 配置和 Keil 的实际编译参数对齐。新建.vscode/c_cpp_properties.json,把 Keil 里的 Include Paths 和 Define 原样搬过来:
{ "configurations": [ { "name": "MDK-ARM", "includePath": [ "C:/Keil_v5/ARM/ARMCLANG/include", "${workspaceFolder}/User", "${workspaceFolder}/Libraries/CMSIS/Device/ST/STM32F1xx/Include", "${workspaceFolder}/Libraries/CMSIS/Include" ], "defines": ["STM32F10X_HD", "USE_STDPERIPH_DRIVER"], "compilerPath": "C:/Keil_v5/ARM/ARMCLANG/bin/armclang.exe", "cStandard": "c99", "cppStandard": "c++14", "intelliSenseMode": "gcc-arm" } ], "version": 4 }includePath里的顺序有讲究:先放编译器自带路径,再放工程内路径,最后放库路径,避免同名头文件被错误覆盖。defines必须和 Keil 的 Define 完全一致,否则 VSCode 里看到的条件编译分支是错的。compilerPath指向 armclang.exe,注意 Keil 5 默认装在 C 盘,路径别写错。Windows 上第一次运行这类命令行工具时,安装器经常提示「已检测到匹配的 Visual C++ Redistributable」然后直接跳过,这是本机运行库的自检,和交叉编译结果没有关系,不用反复重装运行库。配置好后重启 VSCode,头文件波浪线消失,补全就正常了。
再看.vscode/tasks.json,让 VSCode 直接调用 Keil 的批编译模式,不用来回切窗口:
{ "version": "2.0.0", "tasks": [ { "label": "build:lesson18", "type": "shell", "command": "C:/Keil_v5/UV4/UV4.exe", "args": ["-b", "${workspaceFolder}/lesson18.uvprojx", "-j0", "-o", "${workspaceFolder}/build.log"], "group": { "kind": "build", "isDefault": true } } ] }UV4.exe是 MDK 的 IDE 主程序,加上-b参数后进入无界面编译模式,-j0让编译器用满所有 CPU 核心,-o指定日志输出文件。这段配置的意义在于把构建动作从 IDE 里解耦出来,编译一个工程只需要看 build.log 的末尾几行,错误定位速度和写脚本跑 CI 是一样的体验。命令行编译后面还要用,这里先接上。
3.3 8 位内核的差异:九齐和 51 的 C 编译器边界
不是所有单片机课程包都能用 C++ 编译。九齐单片机这类 8 位内核的编译器沿用 C51 体系,官方工具链只认扩展 C,不支持类、模板和命名空间,连 C99 都得省着用。如果 lesson18 解压出来是一堆 .c 文件加九齐或 STC 的工程,别试图把代码改成 .cpp 编译,方向就错了。8 位内核的 C 编译器有自己的一套内存关键字,比如:
#pragma SAVE #pragma LARGE unsigned char xdata buf[256]; #pragma RESTORE这段代码里xdata是 C51 体系的扩展关键字,把变量放到外部数据存储器,#pragma LARGE把当前文件的默认内存模型切到 large 模式,#pragma SAVE和#pragma RESTORE成对使用,保证不影响其他文件的编译选项。这些关键字放到 C++ 编译器里全部不认。对这种工程,合理的做法是保持底层驱动为 .c 文件,内存关键字全部留在驱动层,上层逻辑如果要写复杂数据结构,单独抽成算法模块,用纯 C 实现。8 位机的 C/C++ 混编没有 ARM 上那么顺滑,这一点在拿到课程包时要先做好预期管理。
4. 编译、烧录与启动验证:让开发板把代码跑起来
4.1 命令行构建:UV4 批量模式和 fromelf 生成烧录文件
配置全对齐后,直接用命令行验证一次完整构建,既能看到完整报错,也给后续反复构建留一条可复制的路径。用上一节配置的 tasks.json 等价命令,在终端里执行:
C:/Keil_v5/UV4/UV4.exe -b lesson18.uvprojx -j0 -o build_$(date +%Y%m%d).txt C:/Keil_v5/ARM/ARMCLANG/bin/fromelf.exe --i32 \ --output=build/lesson18.hex build/lesson18.axf第一条命令里-b是 build 模式,只编译不打开 IDE;-j0表示并行编译,日志里的文件顺序可能交错;-o输出日志文件,文件名带日期方便追溯。编译成功后,Keil 默认输出的是.axf文件,这是带调试信息的 ELF 变体,烧录软件通常不直接识别,需要用 fromelf 转换成烧录用格式。第二条命令里的--i32生成 Intel HEX 32 格式,也就是最常见的.hex文件。build/lesson18.axf这个路径不是固定的,实际以工程 Options 里 Output 页签的 Name of Executable 和输出目录为准,如果找不到文件就去那里看一眼。构建失败时不要看整段日志,先搜Error:和warning:两个关键字,编译器报的第一个错误通常就是根因,后面的错误经常是连锁反应。
4.2 烧录验证:SWD 与串口 ISP 两条路径
编译产物拿到手,烧录路径由芯片决定。ARM 内核的板子一般带 SWD 接口,用 ST-Link 或 J-Link 连接,命令行烧录比 IDE 点按钮更可复用。以 OpenOCD 为例:
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program build/lesson18.hex verify reset exit"-f指定接口配置和目标芯片配置,program后面跟要烧录的文件,verify让烧录后回读校验,reset烧完自动复位运行,exit结束后退出 OpenOCD。这一条命令跑完,板子如果没有反应,先别怀疑代码,按这个顺序排查:SWD 四根线是否接对、目标芯片供电是否正常、OpenOCD 打印里是否识别到芯片 ID。
51 和九齐这类 8 位内核不走 SWD,而是串口 ISP。常见流程是:安装对应厂商的 ISP 软件,把 hex 文件加载进去,选择串口号和波特率,点下载后给板子重新上电。这里有个手速问题:很多 8 位 ISP 协议要求芯片上电后立刻进入引导加载,操作顺序是「先点下载,再给开发板通电」,反过来大概率超时。波特率不要一上来选最高,115200 不通就先降到 9600,排除线材质量因素。九齐的烧录器这里不展开,只要记住一点:8 位机的烧录链路和 ARM 完全不通用,拿到工程先确认烧录器型号,再决定后面所有动作。
4.3 启动流程与 C++ 静态对象:__main 到 main 中间发生了什么
代码烧进去能运行,很多人会直接开始点灯,但这里值得多留一步。在 ARM 工具链里,程序入口不是 main 函数,而是__main。__main负责把 RW 数据从 Flash 拷贝到 RAM,把 ZI 段清零,然后调用__rt_entry初始化 C 库环境,最后才进入 main。如果工程里出现了 C++ 全局对象,事情又多了一层:全局对象的构造函数要在 main 之前执行,链接器会生成一张初始化表,由__cpp_initialize__aeabi_批量调用。写一个最简单的 C++ 类验证一下:
class Led { public: Led(uint32_t gpio_base, uint16_t pin) : pin_(pin) { GPIO_InitTypeDef init = {0}; init.Pin = pin_; init.Mode = GPIO_MODE_OUTPUT_PP; HAL_GPIO_Init((GPIO_TypeDef *)gpio_base, &init); } void toggle() { HAL_GPIO_TogglePin((GPIO_TypeDef *)base_, pin_); } private: uint32_t base_; uint16_t pin_; }; static Led user_led(0x50000000, GPIO_PIN_5);user_led是一个静态全局对象,构造参数在编译期就写死在初始化表里。程序上电后,__cpp_initialize__aeabi_会先于 main 调用它的构造函数,在构造函数里完成 GPIO 时钟和引脚配置。这个机制看起来方便,实际是隐患:构造函数里如果访问了还没完成初始化的外设,或者在中断开启前调用了依赖中断的接口,HardFault 会在 main 第一行代码之前发生,调试器定位时很难把问题关联到构造函数。所以单片机 C++ 工程里,全局对象只放简单数据型对象,涉及外设初始化的逻辑放到 main 里显式调用,避免把启动流程变成黑盒。验证这一步是否正常,下一章用 map 文件直接看。
5. 用 .map 和构建日志做一次工程体检,把参数留到下次复用
编译输出的 .map 文件是链接器生成的完整内存地图,排障价值被严重低估。工程能编译能烧录,不等于链接结果正确,尤其是 C/C++ 混编工程,map 文件能直接回答「C++ 那段代码到底进没进固件」这个问题。用一条 grep 把关键字段一次性捞出来:
grep -E "Program Size|Image component sizes|__cpp_initialize__aeabi_|__main|__rt_entry" build/lesson18.map cp build/lesson18.map builds/lesson18_$(date +%m%d_%H%M).map第一行查符号是否在最终镜像里。__cpp_initialize__aeabi_出现在 map 里,说明 C++ 全局构造机制被链接进去;如果工程里写了静态对象但这个符号消失,说明链接器把它优化掉了,或者构造对象所在的目标文件根本没参与链接。__main和__rt_entry存在且地址正确,说明 C 库入口完整。第二行是归档动作:每次构建把 map 文件按日期归档到 builds 目录,几天后对比两个 map 的大小差异,比对比源码更直观。
Program Size 一行有四个数字:Code 是程序指令占的 Flash 空间,RO-data 是只读常量也要占 Flash,RW-data 是初值非零的全局变量,运行时要从 Flash 搬到 RAM,ZI-data 是零初始化数据,只占 RAM。Flash 总占用约等于 Code 加 RO-data 加 RW-data,RAM 占用约等于 RW-data 加 ZI-data,再加设置的堆栈。C++ 工程里第一次引入类对象,Code 增长几百字节是正常的,如果发现 ZI-data 暴涨,大概率是某个全局对象体积过大,可以从内存方向检查设计。
把 map 检查、构建日志归档和前面所有配置串成一条固定动作序列,下次再拿到 lesson19.rar、lesson20.rar,路径就清晰了:解压后先 grep 工程文件判断工具链,再核对 Keil 的四个关键参数和 VSCode 的 include 配置,构建一次并归档 map,最后烧录验证启动流程。这套动作跑过三轮,课程包本身是 C 还是 C++、8 位还是 ARM 已经不重要,因为每一层都已经拆开验证过。
本文还有配套的精品资源,点击获取