第3天:用C语言重写点灯程序
是轻舟泊指尖,能载你去彼岸。
——《嵌入式开发之道》
第2天我们用汇编点亮了LED——虽然成功了,但过程很痛苦:要记住每个寄存器的地址、每个位的含义、每条指令的写法。
今天我们要换一种方式:用C语言来做同样的事。
C语言是一种"系统级编程语言",意思是它既能像汇编一样精确控制硬件,又不用你操心所有细节。你写的C代码,会通过一个叫工具链的东西自动翻译成机器码——就像汇编器把汇编翻译成机器码一样。
在C语言里,一切都是围绕着数据和操作这两个基本点展开的:
数据 → 变量和常数 → 语句(对数据的操作)→ 语句块(用{}包起来)→ 函数(有名字的语句块)→ 程序
今天我们就按照这个链条,从数据开始,一步步走到一个完整的C语言点灯程序。
C语言就是程序员手指间的轻舟,能驶达复杂电子世界的彼岸。
一、为什么需要C语言?
汇编语言能精确控制每一个硬件细节,但代价是程序员必须关心所有硬件细节:
ldr r0, =0x40010010 ; 要记住每个寄存器的地址 mov r1, #1 ; 要记住每个位的含义 str r1, [r0] ; 要自己管理寄存器点一个灯要记2个地址、2个值、3条指令。换成另一个芯片,地址全变,代码要重写。汇编代码和硬件强绑定,换芯片等于重学。
C语言的解决思路是:把硬件细节封装起来,让程序员关注“做什么”而不是“怎么做”。
GPIOA_OUTENSET = 1; // 一行C代码 = 三条汇编指令同样的C代码,换一个芯片只需要改底层的寄存器地址定义,上层逻辑完全不用动。
二、C语言的基本元素
2.1操作数——变量和常数
昨天学过"操作数"的概念。C语言中,操作数通过变量和常数来表示。
变量在使用前必须定义类型,告诉编译器"这个变量占几个字节、有没有正负"。
常用数据类型一览表:
类型 | 位宽 | 范围 | 说明 |
| 8位 | -128 ~ 127 | 有符号字符 |
| 8位 | 0 ~ 255 | 无符号字符 |
| 16位 | -32768 ~ 32767 | 有符号短整数 |
| 16位 | 0 ~ 65535 | 无符号短整数 |
| 32位 | -21亿 ~ 21亿 | 有符号整数 |
| 32位 | 0 ~ 42亿 | 无符号整数 |
变量定义的写法,定义结束以“;”结尾。
//这是单行注释, /* 这是多行注释,在两个“/”之间的文字全是注释 注释不是程序,知识说明性帮助理解的文字 */ int a; // 定义一个int变量a int b = 5; // 定义并初始化为5 unsigned int c; // 定义无符号整数c2.2操作——对变量做什么
对变量做操作,包括赋值和运算:
操作 | 符号 | 示例 |
赋值 |
|
|
加法 |
|
|
减法 |
|
|
按位或 |
|
|
按位与 |
|
|
按位取反 |
|
|
2.3语句——以;结尾的完整操作
a = 5; // 这是一条语句 b = 3; // 这也是 c = a + b; // 这也是2.4语句块——用{}把多条语句包起来
{ a = 5; b = 3; c = a + b; }2.5 控制语句——让程序"做决定"或"循环"
控制语句 | 作用 | 示例 |
| 条件成立时执行分支A,否则执行分支B |
|
| 条件成立时反复执行 |
|
| for | 由循环变量控制循环次数 | int i; for(i=0; i<100; i=i+1) { } /* 先定义循环控制变量i i起始值0,终值99,每循环1次加1。因此可以循环100次。 */ |
注意:if/else、while、for后面既可以跟单条语句,也可以跟一个语句块{ }。
2.6函数——有名字的语句块
函数可以有输入和输出。
返回值类型 函数名(参数列表) { 语句块 return 返回值; }示例:
int add(int x, int y) { // 函数名为add,输入:x和y,返回值:int类型的数 int result = x + y; return result; }调用函数:
int sum; //定义整数sum sum = add(3, 5); //调用add函数,传给函数3,5两个输入,函数运行完后返回一个值,再赋给sum函数的意义:把一段代码打包,起个名字,通过名字反复使用。程序结构清晰,可复用。
2.7宏定义 —— 给文本起名字
#define在编译前做文本替换:
#define 名字 值示例:
#define LED_ADDR 0x40010010 #define DELAY_MS 1000之后代码里所有LED_ADDR都会被替换成0x40010010。
类比:就像Word里的"查找替换"——把文中所有的"LED_ADDR"替换成"0x40010010"。
在点灯程序中,我们用#define给寄存器地址起名字,让代码更易读。
三、C语言需要一个“引路人”
假设我用C语言写了一个函数main作为入口,我如何让CPU上电复位后跳转到main的开头执行呢?
跟第二天的汇编程序类似,用一个最小的汇编文件(start.s)在0x00000004地址处放入main的地址,把CPU“引”到C语言的世界。
.section .text .word 0x20001000 .word main上电复位后CPU取出第二条地址main,然后跳转到c语言定义的main函数。
四、指针 —— 存放地址的变量
4.1 为什么需要指针?
在上面的startup.s汇编代码中,第二个存储单元存放的是main函数的入口地址。CPU上电复位后,从这里读出地址,跳转到main去执行。
这就引出一个关键概念:程序里经常需要"存地址"——比如,记录一个变量放在哪里,或者记录一个函数从哪里开始。
在C语言中,存放地址的变量,叫做指针。
你可以把指针想象成一个"路牌":路牌本身是一块木板(变量),上面写着一个地址(另一个变量的位置)。你顺着路牌找到的,是真正的目的地(变量的值)。
4.2 操作符*和&
指针变量存储的是地址,但该地址所指向的数据类型是多样的:可能是一个 16 位的 short,也可能是一个 32 位的 int,甚至是一个函数比如main。因此,指针的定义必须明确它所指向的变量类型。
为了与普通变量定义区分,C语言在类型和变量名之间加一个*,表示这是定义一个指针变量。
short *A; // A是指针变量,指向short类型的变量 int *C; // C是指针变量,指向int类型的变量如果我想让指针指向某个变量,使用取地址操作符&:
&放在两个变量之间表示按位与操作(如a & b);&放在一个变量前面表示取该变量的地址(如&B)。
short *A; short B; int *C; int D; A = &B; // 将B的地址赋值给指针A,A指向B C = &D; // 将D的地址赋值给指针C,C指向DA,B,C,D四个变量在内存中的关系如下图所示,A存放的是B的地址,C存放的是D的地址。
这时候如果我想对B进行写操作,很简单: B = 5;定义了指针后,我们还可以通过指针对B进行操作,再用操作符“*”,表示对指针指向的变量进行写操作: *A = 5。
两者完全等效。用指针对指向的变量进行读写操作,使用操作符*叫做解引用。
int a = 5; // 定义变量a,初始值为5 int b; // 定义变量b,未初始化 int *p; // 定义指针p,指向int类型的变量; p = &a; // &a = “a的地址”,把a的地址赋值给p *p = 10; // 对p指向的变量进行写操作,即对a进行写操作,改成10;与a = 10操作等效 b = *p; // 对p指向的变量进行读操作,即对a进行读操作,赋值给b;与b = a操作等效注意:
*号放在两个变量之间,表示乘法,如a * b; *号放在变量前面,表示指针定义或解引用操作。
有了指针,程序员就可以对任意指定地址的内容进行操作。下面的点灯操作马上就要用到这个特性了。
五、用C语言点亮LED
5.1往固定地址写值
回顾第2天的汇编操作,我们往2个地址写了2个值:
地址 | 写入值 | 作用 |
0x40010010 | 1 | 配置PA0为输出 |
0x40010004 | 1 | 输出高电平 → LED亮 |
在C语言中,往固定地址写值的方法是:
*(unsigned int *)0x40010010 = 1;拆解(从右往左读更容易理解):
| 部分 | 含义 |
|---|---|
0x40010010 | 一个数字,代表寄存器的地址 |
(unsigned int *) | 类型转换——告诉编译器"把这个数字当成一个32位地址" |
* | 解引用——"对那个地址进行操作" |
= 1 | 把1写入那个地址 |
连起来就是:到地址0x40010010去,往里面写一个1。
如果觉得这个写法太难记,别担心——第5天我们会用标准库,这些强制类型转换就不用自己写了。
5.2 C语言点灯程序
// ============================================ // 定义寄存器地址(给地址起名字,方便阅读) // ============================================ #define GPIOA_OUTENSET (*(unsigned int *)0x40010010) #define GPIOA_DATA (*(unsigned int *)0x40010004) // ============================================ // main函数(程序入口) // ============================================ void main(void) { // 第1步:配置PA0为输出模式 GPIOA_OUTENSET= 1; // bit0=1 // 第2步:输出高电平 → LED亮 GPIOA_DATA = 1; // bit0=1 // 第3步:死循环,程序停在这里 while(1) { // 什么都不做,灯一直亮 } }可以看出C语言跟我们的自然语言一样清楚简洁,优势明显。
六、编译和下载
汇编程序用as翻译,C程序用gcc翻译。这里的arm-none-eabi-gcc是专门为ARM芯片编译C代码的版本。
编译批处理文件如下:
echo off echo as -mcpu=cortex-m3 -mthumb -o start.o start.s arm-none-eabi-gcc -c led.c -mcpu=cortex-m3 -mthumb -o led2.o ld -Ttext=0x0 -o led2.elf start.o led2.o -Map=led2.map -e main objcopy -O binary led2.elf led2.bin objdump -S led2.elf > led2.s echo Build complete! pause各条命令的作用:
| 命令 | 输入 | 输出 | 作用 |
|---|---|---|---|
as ... start.s | start.s | start.o | 汇编start.s → 目标文件 |
arm-none-eabi-gcc ... led.c | led.c | led2.o | 编译C代码 → 目标文件 |
ld ... start.o led2.o | start.o + led2.o | led2.elf | 链接两个目标文件 → 可执行文件 |
objcopy ... led2.elf | led2.elf | led2.bin | 提取纯机器码 |
objdump ... led2.elf | led2.elf | led2.s | 反汇编查看结果 |
反汇编文件对比:
led2.elf: file format elf32-littlearm Disassembly of section .text: 00000000 <main-0x8>: 0: 20001000 .word 0x20001000 4: 00000009 .word 0x00000009 00000008 <main>: 8: b480 push {r7} a: af00 add r7, sp, #0 c: 4b03 ldr r3, [pc, #12] @ (1c <main+0x14>) e: 2201 movs r2, #1 10: 601a str r2, [r3, #0] 12: 4b03 ldr r3, [pc, #12] @ (20 <main+0x18>) 14: 2201 movs r2, #1 16: 601a str r2, [r3, #0] 18: e7fe b.n 18 <main+0x10> 1a: bf00 nop 1c: 40010010 .word 0x40010010 20: 40010004 .word 0x40010004与昨天的程序相比,C语言编译生成的机器码,比汇编只多了两行:
8000000c: b480 push {r7} 8000000e: af00 add r7, sp, #0这个是编译程序在处理C语言函数调用之前必须要做“压堆栈”常规操作——这对我们的两个点灯寄存器无任何影响,我们先不用管。堆栈的概念明天再介绍。
下载led2.bin后灯亮了,证明用C语言编程OK。
如果想深入学习C语言的细节,网上有很多免费资源,比如"菜鸟教程"。这个系列的重点是嵌入式开发入门,不会花太多篇幅讲纯C语法。
七、今日核心收获
概念 | 一句话解释 |
汇编的痛点 | 要关心所有硬件细节,换芯片要重写 |
C语言的价值 | 封装硬件细节,关注“做什么”而非“怎么做” |
变量类型 | 告诉编译器“占几个字节、有没有正负” |
语句 | 以 |
语句块 |
|
控制语句 |
|
函数 | 打包代码块,起个名字反复用 |
| 编译前做文本替换,给数字起名字 |
| 取地址(“的地址”) |
| 解引用(“里面的值”),也用于定义指针 |
| 类型转换,告诉编译器“地址里存的是4字节数据” |
明天预告:数据到底存在哪里?——.text、.data、.bss、堆、栈。
