第3天:用C语言重写点灯程序

📅 2026/7/19 17:25:48 👁️ 阅读次数 📝 编程学习
第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语言中,操作数通过变量常数来表示。

变量在使用前必须定义类型,告诉编译器"这个变量占几个字节、有没有正负"。

常用数据类型一览表

类型

位宽

范围

说明

char

8位

-128 ~ 127

有符号字符

unsigned char

8位

0 ~ 255

无符号字符

short

16位

-32768 ~ 32767

有符号短整数

unsigned short

16位

0 ~ 65535

无符号短整数

int

32位

-21亿 ~ 21亿

有符号整数

unsigned int

32位

0 ~ 42亿

无符号整数

变量定义的写法,定义结束以“;”结尾。

//这是单行注释, /* 这是多行注释,在两个“/”之间的文字全是注释 注释不是程序,知识说明性帮助理解的文字 */ int a; // 定义一个int变量a int b = 5; // 定义并初始化为5 unsigned int c; // 定义无符号整数c

2.2操作——对变量做什么

对变量做操作,包括赋值和运算:

操作

符号

示例

赋值

=

a = 5;

加法

+

c = a + b;

减法

-

c = a - b;

按位或

|

a = a | 0x04;

按位与

&

a = a & ~0x04;

按位取反

~

~0x04

2.3语句——以;结尾的完整操作

a = 5; // 这是一条语句 b = 3; // 这也是 c = a + b; // 这也是

2.4语句块——用{}把多条语句包起来

{ a = 5; b = 3; c = a + b; }

2.5 控制语句——让程序"做决定"或"循环"

控制语句

作用

示例

if-else

条件成立时执行分支A,否则执行分支B

if (a > 5)

{ c = a+b; } //分支A

else

{ c = a-b; } //分支B

while

条件成立时反复执行

while(1) { }//永远循环

for由循环变量控制循环次数

int i;

for(i=0; i<100; i=i+1) { }

/* 先定义循环控制变量i

i起始值0,终值99,每循环1次加1。因此可以循环100次。

*/

注意:if/elsewhile、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指向D

A,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.sstart.sstart.o汇编start.s → 目标文件
arm-none-eabi-gcc ... led.cled.cled2.o编译C代码 → 目标文件
ld ... start.o led2.ostart.o + led2.oled2.elf链接两个目标文件 → 可执行文件
objcopy ... led2.elfled2.elfled2.bin提取纯机器码
objdump ... led2.elfled2.elfled2.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语言的价值

封装硬件细节,关注“做什么”而非“怎么做”

变量类型

告诉编译器“占几个字节、有没有正负”

语句

;结尾的完整操作

语句块

{}把多条语句包成一个整体

控制语句

if-elsewhile,for,让程序做决定

函数

打包代码块,起个名字反复用

#define

编译前做文本替换,给数字起名字

&

取地址(“的地址”)

*

解引用(“里面的值”),也用于定义指针

(unsigned int *)

类型转换,告诉编译器“地址里存的是4字节数据”

明天预告:数据到底存在哪里?——.text、.data、.bss、堆、栈。