第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语言中操作数通过变量和常数来表示。变量在使用前必须定义类型告诉编译器这个变量占几个字节、有没有正负。常用数据类型一览表类型位宽范围说明char8位-128 ~ 127有符号字符unsigned char8位0 ~ 255无符号字符short16位-32768 ~ 32767有符号短整数unsigned short16位0 ~ 65535无符号短整数int32位-21亿 ~ 21亿有符号整数unsigned int32位0 ~ 42亿无符号整数变量定义的写法定义结束以“;”结尾。//这是单行注释 /* 这是多行注释在两个“/”之间的文字全是注释 注释不是程序知识说明性帮助理解的文字 */ int a; // 定义一个int变量a int b 5; // 定义并初始化为5 unsigned int c; // 定义无符号整数c2.2操作——对变量做什么对变量做操作包括赋值和运算操作符号示例赋值a 5;加法c a b;减法-c a - b;按位或|a a | 0x04;按位与a a ~0x04;按位取反~~0x042.3语句——以;结尾的完整操作a 5; // 这是一条语句 b 3; // 这也是 c a b; // 这也是2.4语句块——用{}把多条语句包起来{ a 5; b 3; c a b; }2.5 控制语句——让程序做决定或循环控制语句作用示例if-else条件成立时执行分支A否则执行分支Bif (a 5){ c ab; } //分支Aelse{ c a-b; } //分支Bwhile条件成立时反复执行while(1) { }//永远循环for由循环变量控制循环次数int i;for(i0; i100; ii1) { }/* 先定义循环控制变量ii起始值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函数传给函数35两个输入函数运行完后返回一个值再赋给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的地址赋值给指针AA指向B C D; // 将D的地址赋值给指针CC指向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语言点亮LED5.1往固定地址写值回顾第2天的汇编操作我们往2个地址写了2个值地址写入值作用0x400100101配置PA0为输出0x400100041输出高电平 → 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; // bit01 // 第2步输出高电平 → LED亮 GPIOA_DATA 1; // bit01 // 第3步死循环程序停在这里 while(1) { // 什么都不做灯一直亮 } }可以看出C语言跟我们的自然语言一样清楚简洁优势明显。六、编译和下载汇编程序用as翻译C程序用gcc翻译。这里的arm-none-eabi-gcc是专门为ARM芯片编译C代码的版本。编译批处理文件如下echo off echo as -mcpucortex-m3 -mthumb -o start.o start.s arm-none-eabi-gcc -c led.c -mcpucortex-m3 -mthumb -o led2.o ld -Ttext0x0 -o led2.elf start.o led2.o -Mapled2.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 main0x14) e: 2201 movs r2, #1 10: 601a str r2, [r3, #0] 12: 4b03 ldr r3, [pc, #12] (20 main0x18) 14: 2201 movs r2, #1 16: 601a str r2, [r3, #0] 18: e7fe b.n 18 main0x10 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-elsewhilefor让程序做决定函数打包代码块起个名字反复用#define编译前做文本替换给数字起名字取地址“的地址”*解引用“里面的值”也用于定义指针(unsigned int *)类型转换告诉编译器“地址里存的是4字节数据”明天预告数据到底存在哪里——.text、.data、.bss、堆、栈。