AVR链接器 - 手动链接,裸金属与混合语言编程
这篇文章将讨论如何使用GNU avr-as汇编对AVR单片机编程并链接目标文件为可知执行文件。另外,这篇文章还展示了C语言编写裸金属程序并手动链接,和编写C语言与汇编混合的AVR项目。
AVR-GCC, AVR-AS, AVR-LD, C语言, 汇编, 链接器, 反汇编, 编译器, ABI, AVR, ISA, 程序二进制接口, 指令集架构, 裸金属, 嵌入式
--by Captdam @ Nov 9, 2025[en] Here is the English version of this article
Index
一直以来,我都依赖于使用GCC(或者其它特定机器上的编译器)来将源代码编译为可执行文件,而忽略了中间生成的临时文件和如何链接。最近,因为一个手上的一个项目因为特殊的初始化流程,将特定代码放入特定内存空间,我决定开始研究编译中的链接过程的细节。
我决定先从AVR-GCC的链接器开始研究,因为我更熟悉AVR的架构,而且单片机内存结构比PC简单,方便研究。
我决定把我的研究过程发上来便于我以后参考。另外,网上大部分的资源都是关于Atmel Studio的avrasm2.exe汇编器,而不是GNU的avr-as汇编器,我费了大把时间网上冲浪找资料。如果你也在找关于GNU的avr-as放入资料,希望这篇文章能帮到你。
C语言程序 - avr-hello.c
让我们从一段简单的C代码开始:
main.c
#include <stdint.h>
volatile uint8_t my_data[4] = {2, 3, 5, 7};
uint8_t main() {
volatile uint8_t my_var = my_data[1];
for(;;);
}
在这个例子中,我们从数组中加载一个元素。
我们使用volatile关键字来阻止编译器优化变量:
- 编译器会尽量将变量存储在寄存器而非内存中,因为寄存器速度更快。对于某些CPU(例如AVR),CPU无法直接访问内存。数据必须先加载到通用寄存器(GPR)中才能使用。有些CPU,例如68HC11,允许以更慢的速度为代价直接访问内存。因此,为了提高速度和节省空间,最好使用寄存器而不是内存。
- 如果变量未使用,编译器将删除该变量及所有相关计算,以节省CPU周期。
avr-gcc -mmcu=atmega328 -O0 main.c -o main.o
avr-objcopy -O ihex main.o main.hex
avr-objdump -D main.hex -m avr5
编译,创建闪存文件并反汇编闪存文件。
我们指定-O0来强制编译器不要优化,而是一行一行地将C语言源代码翻译为机器码,以便于我们查看其结果。不然,编译器优化的结果将会高效却反直觉,很难看懂。
下面是闪存文件的内容,让我们逐段研究:
向量表
0: 0c 94 34 00 jmp 0x68 ; 0x68
4: 0c 94 49 00 jmp 0x92 ; 0x92
8: 0c 94 49 00 jmp 0x92 ; 0x92
c: 0c 94 49 00 jmp 0x92 ; 0x92
10: 0c 94 49 00 jmp 0x92 ; 0x92
14: 0c 94 49 00 jmp 0x92 ; 0x92
18: 0c 94 49 00 jmp 0x92 ; 0x92
1c: 0c 94 49 00 jmp 0x92 ; 0x92
20: 0c 94 49 00 jmp 0x92 ; 0x92
24: 0c 94 49 00 jmp 0x92 ; 0x92
28: 0c 94 49 00 jmp 0x92 ; 0x92
2c: 0c 94 49 00 jmp 0x92 ; 0x92
30: 0c 94 49 00 jmp 0x92 ; 0x92
34: 0c 94 49 00 jmp 0x92 ; 0x92
38: 0c 94 49 00 jmp 0x92 ; 0x92
3c: 0c 94 49 00 jmp 0x92 ; 0x92
40: 0c 94 49 00 jmp 0x92 ; 0x92
44: 0c 94 49 00 jmp 0x92 ; 0x92
48: 0c 94 49 00 jmp 0x92 ; 0x92
4c: 0c 94 49 00 jmp 0x92 ; 0x92
50: 0c 94 49 00 jmp 0x92 ; 0x92
54: 0c 94 49 00 jmp 0x92 ; 0x92
58: 0c 94 49 00 jmp 0x92 ; 0x92
5c: 0c 94 49 00 jmp 0x92 ; 0x92
60: 0c 94 49 00 jmp 0x92 ; 0x92
64: 0c 94 49 00 jmp 0x92 ; 0x92
当特定事件发生时,如果该特定中断已启用,CPU将跳转到向量表中该事件对应的地址。
在上面的例子中,地址0x0000处的第一个中断向量是复位中断。当复位且硬件内部初始化完成后,CPU将跳转到地址0x0000(也就是复位中断),并执行这个向量处的指令。在这个例子中,CPU将执行跳转到地址0x0068的指令。
从0x0004到0x0064包含了其他各种ISR(中断进程)的向量(像是时钟溢出、ADC模数转换完成、UART串口发送完成)。因为我们在C语言中并没有提供相应的中断进程,编译器为我们填充了jmp 0x0092。这将会导致CPU将转到一个特殊的中断进程,即__bad_interrupt。当错误发生时(例如错误地开启了时钟溢出中断,但是没有提供中断进程),CPU就会跳转到这个__bad_interrupt。
__bad_interrupt是最后一道保险。它将重置程序,就像是电脑蓝屏重启一样。这防止程序跑飞执行“危险”操作。
不同架构的单片机配备不同的外设,拥有不同的功能,因此向量表也不同。另外,向量的大小也取决于单片机程序空间的大小,因为小的单片机可以使用2字节的rjmp指令即可覆盖整个程序空间,大的单片机需要使用4字节的jmp来覆盖整个程序空间。
尽管架构不同,第一个向量总是复位中断。如果我们不需要其它中断,我们可以省略向量表。
初始化
68: 11 24 eor r1, r1
6a: 1f be out 0x3f, r1 ; 63
6c: cf ef ldi r28, 0xFF ; 255
6e: d8 e0 ldi r29, 0x08 ; 8
70: de bf out 0x3e, r29 ; 62
72: cd bf out 0x3d, r28 ; 61
以上步骤是为了准备AVR-GCC环境。
清零寄存器R1。AVR-GCC使用R1作为零寄存器(它的内容永远为0,这有助于进行和0相关的计算,比如判断正负、进位加法高位)。
复位SREG(状态寄存器,IO地址0x3F)。
设置SP(栈指针,IO地址0x3E:0x3D)到SRAM的顶端(对于ATmega328为0x08FF)。
这段代码被硬编码在AVR-GCC的库中。AVR-GCC直接从toolchain/avr8/avr8-gnu-toolchain/avr/lib/avr5/crtatmega328.o:.init2中复制出来这一段代码,详情可以参考AVR-LibC Memory Sections。我们可以通过avr-objdump -d crtatmega328.o来验证库中内容。
74: 11 e0 ldi r17, 0x01 ; 1
76: a0 e0 ldi r26, 0x00 ; 0
78: b1 e0 ldi r27, 0x01 ; 1
7a: ec ea ldi r30, 0xAC ; 172
7c: f0 e0 ldi r31, 0x00 ; 0
7e: 02 c0 rjmp .+4 ; 0x84
80: 05 90 lpm r0, Z+
82: 0d 92 st X+, r0
84: a4 30 cpi r26, 0x04 ; 4
86: b1 07 cpc r27, r17
88: d9 f7 brne .-10 ; 0x80
复位后,RAM中的数据要么是空的(上电)或被污染的(看门狗超时或错误中断)。但是在我们写C语言程序时,我们默认定义过的变量应该包含定义的值(在这个例子中,静态变量my_data[4]),声明过的变量为零。AVR-GCC帮我们做了变量初始化。在进入用户的main()函数前,编译器需要在初始化阶段将默认值(我们定义的= {2, 3, 5, 7})从ROM中复制出来写入RAM。
在这个例子中,值被保存在闪存(程序)地址0x00AC,变量在RAM(数据)地址0x0100,长度为4字节。将执行以下操作:
for addrRAM = 0x0100 to 0x0103, addrProg = 0x00AC to 0x00AF:
RAM[addrRAM ] ← Prog[addrProg]
入口点
8a: 0e 94 4b 00 call 0x96 ; 0x96
8e: 0c 94 54 00 jmp 0xa8 ; 0xa8
初始化完成。调用位于地址0x0096的用户主函数。用户主函数返回后,跳转到位于地址0x00A8的库提供的死循环。
错误中断进程
92: 0c 94 00 00 jmp 0 ; 0x0
错误中断进程,将会重置单片机。
主函数
uint8_t main() {
volatile uint8_t my_var = my_data[1];
for(;;);
}
96: cf 93 push r28
98: df 93 push r29
9a: 1f 92 push r1
9c: cd b7 in r28, 0x3d ; 61
9e: de b7 in r29, 0x3e ; 62
a0: 80 91 01 01 lds r24, 0x0101 ; 0x800101
a4: 89 83 std Y+1, r24 ; 0x01
a6: ff cf rjmp .-2 ; 0xa6
终于到了我们的主函数了。
根据AVR-GCC ABI(AVR-GCC程序二进制接口)规定,函数保存寄存器(这个例子中的R28与R29)如果需要先备份再使用。因此,我们在函数一开始就将它们入栈。我们将在接下来把它们作为框架指针使用。
局部变量uint8_t my_var将被保存在栈中,更详细地说,函数的框架内。
回顾:在函数外部声明的变量(全局变量)是静态变量,它会被保存在RAM中一个编译时就确定的固定地址。在函数内部声明的变量是局部变量,它会在函数初始化时被保存在栈(函数框架)中。框架指针用于访问框架中的数据(局部变量)。
AVR 堆栈指针指向下一个可用槽位并向下增长。AVR不支持堆栈指针寻址,我们必须使用指针寄存器(X、Y或Z)来构建堆栈指针,以便访问RAM中的数据。此外,AVR指针寄存器不支持负偏移量,因此,框架指针必须指向最低位置,并使用正偏移量来寻址栈中地变量。为此,我们首先将栈指针减1,然后将栈指针复制到指针寄存器Y(R29:R28)。
读取my_data[1]并写入局部变量my_var。因为my_data[1]是一个静态变量,我们在编译时就知道它的地址且这个地址不会改变,我们可以使用直接寻址地lds Rd, absolute_address指令来读它。局部变量my_var地地址在栈中并随函数框架移动,因此在编译时未知,需要借助框架指针使用指针寻址的std, Y+positive_offset, Rs指令。
这里有一个窍门。要在栈中创建函数框架,我们需要生长栈,即下移栈指针。AVR-GCC会使用最简短且没有不良副作用的指令:
这个例子中,AVR-GCC使用push r1来下移栈指针1字节。虽然这个指令会把R1的内容入栈,但是没有关系,我们将在稍后在栈内覆写实际需要的值——也就是局部变量。
如果需要2字节,AVR-GCC会使用1条rcall 0(相对调用)指令。这个指令正常被用作根据偏移量调用子函数,它将当前PC入栈,占用2字节,因为PC宽2字节。接下来,他将会把偏移量加入PC以跳转到指定的(子函数的)位置。因为我们定义子函数的偏移为0,所以实际上PC不会被修改,也就没有跳转。
如果需要更多空间,AVR-GCC会使用sbiw r28, k来将指针下移k字节。
AVR-GCC为数据的地址加上了0x800000的偏移。这有助于分开RAM中的数据与闪存中的程序。实际烧录到单片机时,高位将会被砍掉。
结束
a8: f8 94 cli
aa: ff cf rjmp .-2 ; 0xaa
前面讨论到的死循环。
静态变量
ac: 02 03 mulsu r16, r18
ae: 05 07 cpc r16, r21
前面说到,定义的变量的初始值需要被保存在程序空间中,也就是这里,并在初始化时复制到数据空间。这里只关注二进制值,不要参考其反汇编的指令。
汇编程序 - avr-hello.asm
汇编源代码
接下来,我们把相同的程序用汇编写一下:
我们将使用GNU汇编器avr-as。Atmel Studio使用avrasm2.exe汇编器,这两个汇编器是不同的!语法不同!地址也不用!不要混淆!!!
main.asm
解释
.data
my_array: .space 4
my_array_end:
在这个汇编程序中,我们将有两个段:储存数据,在RAM中的.data段(data segment)、储存程序,在闪存中的.text段(text segment)。
.data段中,我们首先为静态变量创建一个符号my_array,并分配4字节空间。我们还在my_array后立刻创建另一个符号my_array_end。这样,my_array_end的地址就是my_array的地址加上my_array的大小。这有便于我们获取my_array的大小,例如使用size_t size = my_array_end - my_array或for (void* ptr = my_array; ptr < my_array_end; ptr++)。
我们只需要在.data段中声明静态变量。在.data段中声明静态变量将会给变量在数据空间中分配一个固定的地址。局部变量将被保存在栈中,其地址随母函数框架变动,因此,我们不能在.data段定义局部变量。
空间的单位是字节。都写汇编了,应该知道不同数据类型的宽度了吧_(:_| )__比如char是1字节,int是2字节(没错,8位机包括AVR-GCC基本都使用2字节int),long是4字节。
.text
my_routine:
ldi r16, 0x08
out SPH, r16
ldi r16, 0xFF
out SPL, r16
接下来是.text段。
首先初始化栈指针(SP,即stack pointer)为ATmega328的RAM顶端0x08FF。
ldi r31, hi8(my_array_flash)
ldi r30, lo8(my_array_flash)
ldi r29, hi8(my_array)
ldi r28, lo8(my_array)
ldi r27, hi8(my_array_end)
1: lpm r0, Z+
st Y+, r0
cpi r28, lo8(my_array_end)
cpc r29, r27
brne 1b
在C语言中,AVR-GCC帮我们把定义的变量的值写入了静态变量my_array。在汇编中,我们要自己动手。
我们将使用指针寄存器Z(R31:R30)作为读指针来读程序空间,指向程序空间中my_array_flash的起始地址。我们再使用指针寄存器Y(R29:R28)作为写指针,指向数据空间中静态变量my_array的起始地址。
使用特殊的lpm(load from program space,从程序空间读)指令,通过一个for循环来复制数据。从符号my_array开始写,直到但不包括符号my_array_end。
注意语法不同。GNU的avr-as使用hi8()和lo8()来取多字节常量的高位和低位。Atmel Studio的avrasm2.exe使用HIGH()和LOW()。
push r0 ; Allocate stack
in r29, SPH
in r28, SPL
lds r0, my_array + 1
std Y + 1, r0
使用push指令将栈指针下移1字节,以此为局部变量my_var创造1字节框架空间。
复制栈指针到指针寄存器Y来创建框架指针。
将数据my_data[1]读入一个临时的寄存器R0。接下来,使用框架指针来把数据写入栈内的局部变量my_var。因为my_data是静态变量,其地址在编译时就是已知的,所以我们可以把它的地址放入指令中并使用直接寻址的lad Rd, address指令。
loop: jmp loop
死循环停止程序执行。
my_array_flash: .byte 2, 3, 5, 7
保存在程序空间中的静态变量初始化的值。
在AVR-GCC中,main()函数和其他函数一样,需要遵守相同的规则:备份所有函数保存的寄存器(例子中被用作指针寄存器Y的R28和R29)。虽然main()函数永远不会返回,编译器仍然保证这些寄存器可以被还原。
技术上来说,虽然我们被教导C语言中main()函数末尾应该加上死循环,但这不是必须的。我们可以从主函数返回,因为AVR-GCC库在主程序外额外添加了一个死循环。
汇编中,我们有更多的控制权。因为我们知道我们的主函数(例子中的my_routine)不会返回,所以我们也就没必要去备份那些函数保存的寄存器了。
另外,如果我们整个程序都是自己用汇编写,我们也没必要去遵守AVR-GCC的ABI,我们可以随心所欲定义我们自己的函数保存与函数使用寄存器。
汇编后的机器码
avr-as -m avr5 main.asm -o main.o
avr-objdump -Dx main.o
汇编,并指定单片机家族-m family。这个例子中,我们使用ATmega328的家族名avr5。有的指令,像是jmp,只有部分家族支持。如果不指定家族,AVR-AS就会使用默认家族,将不支持一些指令。如果我们使用了不支持的指令,汇编器会报错。
反汇编。这里在avr-objdump中使用-D和-x选项来要求反汇编器输出反汇编的助记符和符号表。
下面展示了反汇编结果:(右侧展示了源代码作为参考)
Disassembly of section .text:
00000000 <my_routine>:
0: 08 e0 ldi r16, 0x08 ; 8
2: 00 b9 out 0x00, r16 ; 0
4: 0f ef ldi r16, 0xFF ; 255
6: 00 b9 out 0x00, r16 ; 0
8: f0 e0 ldi r31, 0x00 ; 0
a: e0 e0 ldi r30, 0x00 ; 0
c: d0 e0 ldi r29, 0x00 ; 0
e: c0 e0 ldi r28, 0x00 ; 0
10: b0 e0 ldi r27, 0x00 ; 0
12: 05 90 lpm r0, Z+
14: 09 92 st Y+, r0
16: b0 30 cpi r28, 0x00 ; 0
18: cb 07 cpc r29, r27
1a: 01 f4 brne .+0 ; 0x1c <my_routine+0x1c>
1c: 0f 92 push r0
1e: d0 b1 in r29, 0x00 ; 0
20: c0 b1 in r28, 0x00 ; 0
22: 00 90 00 00 lds r0, 0x0000 ; 0x800000 <my_array_flash+0x7fffd4>
26: 09 82 std Y+1, r0 ; 0x01
00000028 <loop>:
28: 0c 94 00 00 jmp 0 ; 0x0 <my_routine>
0000002c <my_array_flash>:
2c: 02 03 mulsu r16, r18
2e: 05 07 cpc r16, r21
Disassembly of section .data:
00000000 <my_array>:
0: 00 00 nop
...
.text
my_routine:
ldi r16, 0x08
out SPH, r16
ldi r16, 0xFF
out SPL, r16
ldi r31, hi8(my_array_flash)
ldi r30, lo8(my_array_flash)
ldi r29, hi8(my_array)
ldi r28, lo8(my_array)
ldi r27, hi8(my_array_end)
1: lpm r0, Z+
st Y+, r0
cpi r28, lo8(my_array_end)
cpc r29, r27
brne 1b
push r0 ; Allocate stack
in r29, SPH
in r28, SPL
lds r0, my_array + 1
std Y + 1, r0
loop: jmp loop
my_array_flash: .byte 2, 3, 5, 7
.data
my_array: .space 4
my_array_end:
如我们所见,有的信息被丢失了。例如:
- 反汇编后的程序地址
0x0002,指令out SPL, r16应该是out 0x3D, r16,因为SPL的IO地址是0x3D。但是,我们得到的反汇编码却是out 0x00, r16。同样的状况也发生在下两条SPH上。 - 反汇编后的程序地址
0x0008,指令ldi r31, hi8(my_array_flash)应该是ldi r31, 0x002C,因为my_array_flash的程序地址是0x002C。同样的状况也发生接下来几条的my_array和my_array_end上。
Symbol Table
接下来,我们再看看符号表。
00000000 l d .text 00000000 .text
00000000 l d .data 00000000 .data
00000000 l d .bss 00000000 .bss
00000000 l .data 00000000 my_array
00000004 l .data 00000000 my_array_end
00000000 l .text 00000000 my_routine
0000002c l .text 00000000 my_array_flash
00000028 l .text 00000000 loop
所有的符号都是文件内的局部地址,例如:
00000004 l .data 00000000 my_array_end
这个叫做my_array_end的符号的地址是.data段中的、局部(local)偏移量的0x00000004。
这个局部地址用来告诉链接器富豪的地址。这不能被用作最后生成的指令代码,因为CPU需要符号的绝对地址。
00000000 *UND* 00000000 SPH
00000000 *UND* 00000000 SPL
对于没有定义的符号(例如SPL),符号表显示UND,即未知。
再C语言中,我们需要使用extern关键字来形容一个没有被定义的变量或函数。这能告诉编译器该符号被外部定义,并且会在之后(即链接时)提供。
这样,编译器就不会因为当前文件中找不到这个符号就急得上蹿下跳了。
通常来说,我们需要加上.extern关键字。GNU汇编器gnu-as或gas有点不同,it treats all undefined symbols as external,没被定义的符号都会被自动定义为外部符号。
汇编不是终点也不能生成可执行文件
为什么反汇编显示的符号全部首先,我们要清楚GNU汇编器avr-as的作用:将人可读的助记符翻译为机器可读的二进制机器码,逐个文件地执行。
汇编器将根据输入创建.data段和.text段。但是,汇编器并不知道.data段和.text段在实际机器中该如何放置。因此,汇编器也没办法知道它们的绝对地址。
另外,当有多个目标文件时,使用.data段和.text段的绝对地址将可能导致目标文件之间的冲突。同样的,汇编器一次只处理一个文件,当生成目标文件A时,没办法为了目标文件B做地址规划。
因此,汇编器不适用绝对地址,而是:
- 使用占位符表示符号。这个占位符将在加下来的链接到可执行文件的过程中被确定。在这个过程中,链接器有所有目标文件的具体信息,因此可以为不同目标文件中的符合规划地址。
- 在代码段中使用相对地址。汇编器知道一些符号的大小,因此可以通过其大小计算出代码段中相对偏移量。即使代码段被链接器移动到不同的绝对地址,其内部的偏移量保持不变。
链接器
汇编只是第一步,我们还需要将目标文件链接起来,生成可执行文件。如上文所说,我们需要解决这些问题:
- 内存结构是怎样的?不同的单片机有不同的内存结构,比如,ATmega328的数据内存地址始于
0x0100终于0x08FF,ATtiny25的数据内存地址始于0x0060终于0x00EF。我们应该把.data段放在0x0100还是0x0060? - IO寄存器的地址在哪?不同的单片机有不同的外围设备,因此有不同的IO寄存器地址。
- 在哪放置
.text段?这个包括了程序指令代码、向量表、数据。另外,有的单片机(例如ATmega328)带有特殊的启动分区(bootloader),其编程的属性和其他分区不同。这个分区的大小也是根据fuse设置可变的。对于这些单片机,我们需要正确地把不同的.text段写入不同分区。 - 对于有多个目标文件的情况(或是多个分段),以何种顺序放置它们?更重要的是,应该从哪一段代码开始执行?
avr-gcc是编译与链接的合集。以下情况将会导致错误:
- 我们没有通过
-mmcu=xxx指定单片机型号。AVR-GCC需要知道单片机型号来确定内存结构和IO寄存器地址。 - 我们在C语言代码中使用了未定义的符号(变量、函数)。符号必须被定义,AVR-GCC才能给出其地址。
- 我们没有定义
main()函数。AVR-GCC使用这个名字作为初始化结束后的默认入口点。
我们可以使用gcc -c来只编译,不链接。这样就可以防止以上报错,但是生成的目标文件必须先链接才能被执行。
机器特定IO寄存器地址的目标文件
我们先定义这些无法解析的符号。为此,我们需要创建一个汇编文件:
m328.asm
.global SPL
.equ SPL , 0x3D
.global SPH
.equ SPH , 0x3E
这个文件中,我们使用.equ来给符号赋一个绝对值,即IO空间地址。
同时,我们需要添加.global关键字来让这些符号全局。这样,链接器才能看到这个符号,其它目标文件才能把它们当作.extern符号参考。
或许我们应该把这款单片机所有的IO寄存器的地址都写进来,作为一个标准库以后使用。
avr-as m328.asm -o m328.o
avr-objdump m328.o -Sx
汇编,再反汇编,来看看反汇编的结果:
SYMBOL TABLE:
00000000 l d .text 00000000 .text
00000000 l d .data 00000000 .data
00000000 l d .bss 00000000 .bss
0000003d g *ABS* 00000000 SPL
0000003e g *ABS* 00000000 SPH
如我们所见,这些符号的IO空间的绝对地址现在已经全局化了。
链接器代码
现在,我们来写链接器代码来告诉链接器如何链接目标文件。在这个例子中,我们只讨论包含程序的.text段和包含静态变量的.data段。Fuse与EEPROM超纲了不做讨论。
在这里,我会使用诸如my_function这样的命名方式来展示一些参数可以是任意名字。
main.ld
MEMORY {
my_code(rx): ORIGIN = 0x00, LENGTH = 32K
my_data(rw): ORIGIN = 0x800100, LENGTH = 2K
}
SECTIONS {
. = ORIGIN(my_code);
.text : {
*(.text)
} >my_code
. = ORIGIN(my_data);
.data : {
*(.data)
} >my_data
}
首先,我们根据单片机的内存大小和构造来定义MEMORY内存结构:
- 我们把程序内存叫做
my_code,这一段内存是可读可执行的,地址从0x0000开始,长32K字节。 - 我们把数据内存叫做
my_data,这一段内存是可读可写的,地址从0x800100开始,长2K字节。ATmega328的数据空间前0x100字节被IO寄存器占用,因此RAM起始于0x100。
长度单位为字节。
链接器根据内存的属性(可读、可写、可执行)将其放入不同分区,例如.rodata就是只可读数据。但是,我们的例子不遵守这个规定,因为我们直接控制.text段与.data段在一个简单硬件上的位置。就算我们把.text段与.data都定义为只可读,实际代码在单片机上跑起来也不会有任何区别。
我们在数据段上添加0x800000的偏移,这不是必须的,我只是为了和AVR-GCC的内存写法对齐。这个偏移量会在烧录时被砍掉,因为AVR的地址总线就那么宽。
接下来,我们告诉链接器如何把目标文件放入SECTIONS内:
- 从
my_code的起始点(地址0x0000)开始为.text段。把所有目标文件的.text段的内容都放在这。这个分区是my_code。 - 从
my_data的起始点(地址0x800100)开始为.data段。把所有目标文件的.data段的内容都放在这。这个分区是my_data。
因为现在只有一个目标文件,并且my_code的起始地址地址为0,所以指令的局部相对地址就是绝对地址。
重置后,硬件从程序地址0x0000开始执行。因此,目标文件的第一条指令就是入口点。
最终可执行文件
avr-ld -T main.ld m328.o main.o -o app.elf
avr-objdump -Dx app.elf
链接。反汇编以查看结果:
SYMBOL TABLE:
00000000 l d .text 00000000 .text
00000030 l d .trampolines 00000000 .trampolines
00800100 l d .data 00000000 .data
00000000 l df *ABS* 00000000 main.o
00800100 l .data 00000000 my_array
00800104 l .data 00000000 my_array_end
00000000 l .text 00000000 my_routine
0000002c l .text 00000000 my_array_flash
00000028 l .text 00000000 loop
0000003d g *ABS* 00000000 SPL
0000003e g *ABS* 00000000 SPH
在符号表中,所有的符号都被正确赋值,例如:
00800104 l .data 00000000 my_array_end
符号my_array_end现在位于地址0x00800104。
另外,未定义的IO寄存器也被全局定义了绝对值,例如:
0000003d g *ABS* 00000000 SPL
符号SPL现在位于地址0x3D。
Disassembly of section .text:
00000000 <my_routine>:
0: 08 e0 ldi r16, 0x08 ; 8
2: 0e bf out 0x3e, r16 ; 62
4: 0f ef ldi r16, 0xFF ; 255
6: 0d bf out 0x3d, r16 ; 61
8: f0 e0 ldi r31, 0x00 ; 0
a: ec e2 ldi r30, 0x2C ; 44
c: d1 e0 ldi r29, 0x01 ; 1
e: c0 e0 ldi r28, 0x00 ; 0
10: b1 e0 ldi r27, 0x01 ; 1
12: 05 90 lpm r0, Z+
14: 09 92 st Y+, r0
16: b4 30 cpi r28, 0x04 ; 4
18: cb 07 cpc r29, r27
1a: d9 f7 brne .-10 ; 0x12 <my_routine+0x12>
1c: 0f 92 push r0
1e: de b7 in r29, 0x3e ; 62
20: cd b7 in r28, 0x3d ; 61
22: 00 90 01 01 lds r0, 0x0101 ; 0x800101 <my_array+0x1>
26: 09 82 std Y+1, r0 ; 0x01
00000028 <loop>:
28: 0c 94 14 00 jmp 0x28 ; 0x28 <loop>
0000002c <my_array_flash>:
2c: 02 03 mulsu r16, r18
2e: 05 07 cpc r16, r21
Disassembly of section .data:
00800100 <my_array>:
800100: 00 00 nop
...
反汇编代码现在使用了正确的地址,例如:In the disassembled code, correct addresses are inserted. For example:
16: b4 30 cpi r28, 0x04 ; 4
的源码是cpi r28, lo8(my_array_end)。符号my_array_end的地址低位是0x04,已被正确插入指令中。
裸金属C
C源代码
在这个C语言例子中,我们将要自己手动初始化。让我们来看看下面的代码:
m328.c
#include <stdint.h>
#include <avr/io.h>
#include <avr/pgmspace.h>
volatile const uint8_t my_array[] PROGMEM = {2, 3, 5, 7};
volatile uint8_t some_data1, some_data2;
void __attribute__((noinline)) setOutputPins() {
volatile uint8_t dir = 0b11111111;
DDRB = dir;
}
void __attribute__((naked)) something() {
setOutputPins();
some_data1 = pgm_read_byte(&(my_array[2]));
for(;;);
}
程序中的数据
定义的变量的初始值被保存在程序中。在初始化的过程中,我们将初始值从程序空间中复制到数据空间。AVR-GCC会帮我们做这件事,但是现在我们决定自力更生。
要把数据保存在程序中,我们需要使用AVR的avr/pgmspace.h库提供的特殊关键字PROGMEM:
volatile const uint8_t my_array[] PROGMEM = {2, 3, 5, 7};
要从程序空间中读,使用:
dest = pgm_read_byte(&(program_data));
读写IO寄存器
要想读写单片机的IO寄存器,我们需要包含AVR库avr/io.h。
当我们使用avr-gcc -mmcu=xxx编译代码时,编译器就会根据单片机名称找出IO定义文件,该文件包含了特定单片机IO寄存器的地址和各个比特的位移。
静态变量
我们创建了2个静态变量。C编译器会将它们放入数据空间,我们不需要额外定义。
调用函数与入口点
为了模拟函数调用与调用过程中的栈操作,我们创建了一个函数void setOutputPins()。这个函数会将IO方向读入一个局部(栈)变量,然后再写入IO寄存器。编译器可能为了优化而将这个函数的内容直接插入母函数,为了防止编译器帮倒忙(我们想研究子函数),我们给这个函数添加noinline属性。
我们决定不给主函数默认的入口点名字main()。相反,我们就叫它阿猫阿狗something()。我们将在链接过程中指定其为入口点。此外,因为这个函数时顶层函数,我们没必要备份那些函数保存的寄存器(相反,子函数都必须保存母函数的栈,因为母函数不期望修改)。因此,我们给这个函数添加naked属性来去掉函数的前置与后置代码。在这个函数中,我们将首先调用子函数,然后从程序空间中读一个值,再写入数据控件中的一个静态变量。
编译但不链接
avr-gcc -c main.c -O3 -ffunction-sections -fdata-sections -o main.o -mmcu=atmega328
avr-objdump -Dx main.o
现在,使用以下参数编译:
-c表示不要链接,只编译。我们将在稍后手动链接。-O3允许编译器进行优化,不然生成的代码将很不优雅。-ffunction-sections与-fdata-sections会给每一个函数和变量生成一个段落名,方便在链接时指定特定变量与函数。- 虽然我们稍后会手动链接,但是AVR-LibC提供的IO寄存器地址定义太香了。我们决定在代码中包含
avr/io.h头文件,并在编译时指定单片机型号-mmcu=atmega328。另外,这也有助于编译器使用部分机器才有的指令集来提高效率,比如乘法MUL。
接下来,反汇编。下面展示了反汇编结果:(右侧展示了源代码作为参考)
子函数
Disassembly of section .text.setOutputPins:
00000000 <setOutputPins>:
0: cf 93 push r28
2: df 93 push r29
4: 1f 92 push r1
6: cd b7 in r28, 0x3d ; 61
8: de b7 in r29, 0x3e ; 62
a: 8f ef ldi r24, 0xFF ; 255
c: 89 83 std Y+1, r24 ; 0x01
e: 89 81 ldd r24, Y+1 ; 0x01
10: 84 b9 out 0x04, r24 ; 4
12: 0f 90 pop r0
14: df 91 pop r29
16: cf 91 pop r28
18: 08 95 ret
void __attribute__((noinline)) setOutputPins() {
volatile uint8_t dir = 0b11111111;
DDRB = dir;
}
我们必须使用指针寄存器作为框架指针来读写栈里面的变量。这个例子中,我们使用指针寄存器Y(R29:R28)。因为我们将会覆盖指针寄存器的原始内容,我们需要在函数一开始就把它们入栈保存。
随机抽选一个1字节大小的幸运儿入栈来为局部变量uint8_t dir创建空间。
把栈指针SP复制到指针寄存器Y来创建框架指针。
使用立即寻址模式将值0b11111111读入一个临时寄存器R24。接下来,将这个值通过框架指针写入局部变量dir。根据AVR-GCC ABI,R24是函数使用寄存器,所以不需要入栈备份。
再把这个变量dir读回来到寄存器R24,写入IO寄存器DDRB。注意,在反汇编中,IO寄存器DDRB的地址是正确的0x04,因为我们使用了avr/io.h头文件并针对特定单片机编译。
销毁栈,还原指针寄存器Y的原始值,并从本函数返回。
主函数
Disassembly of section .text.something:
00000000 <something>:
0: 0e 94 00 00 call 0 ; 0x0 <something>
0: R_AVR_CALL .text.setOutputPins
4: e0 e0 ldi r30, 0x00 ; 0
4: R_AVR_LO8_LDI .progmem.data+0x2
6: f0 e0 ldi r31, 0x00 ; 0
6: R_AVR_HI8_LDI .progmem.data+0x2
8: e4 91 lpm r30, Z
a: e0 93 00 00 sts 0x0000, r30 ; 0x800000 <__SREG__+0x7fffc1>
c: R_AVR_16 some_data1
e: 00 c0 rjmp .+0 ; 0x10 <__zero_reg__+0xf>
e: R_AVR_13_PCREL .text.something+0xe
void __attribute__((naked)) something() {
setOutputPins();
some_data1 = pgm_read_byte(&(my_array[2]));
for(;;);
}
调用子函数setOutputPins()。注意,因为我们尚未链接,所以子函数的地址未知,这里先使用一个占位符。
使用指针寄存器Z(R31:R30)来从程序空间读。同样,因为我们尚未链接,所以程序空间内数据的地址未知,这里先使用一个占位符。
使用直接寻址模式的sts addr, Rs指令来把那个数据写入静态变量。同样,因为我们尚未链接,所以数据空间内数据的地址未知,这里先使用一个占位符。
最后,一个死循环。又又又又又同样,因为我们尚未链接,所以死循环(当前指令)的地址未知,这里先使用一个占位符。实际上,这里我们可以使用相对寻址,不过编译器决定把这个工作留到链接。
手动链接
现在,我们来创建链接代码:
main.ld
MEMORY {
my_code(rx): ORIGIN = 0x00, LENGTH = 32K
my_data(rw): ORIGIN = 0x800100, LENGTH = 2K
}
SECTIONS {
. = ORIGIN(my_code);
.text : {
main.o(.text.something)
*(.text)
} >my_code
. = ORIGIN(my_data);
.data : {
*(.data)
} >my_data
}
和上个例子完全相同,除了多加的一行:main.o(.text.something)。我们要求链接器在程序段的开头先链接mian.o(.text.something),也就是说,目标文件main.o中的.text段内的something()函数将被放在程序空间地址0x0000。也就是说,重置后,单片机将会先开始执行这个函数。这也就变相将main.o(.text.something)设置为了入口点。
avr-ld -T main.ld main.o -o app.elf
avr-objdump -Dx app.elf
链接。反汇编查看其结果:
Disassembly of section .text:
00000000 <something>:
0: 0e 94 08 00 call 0x10 ; 0x10 <setOutputPins>
4: ec e2 ldi r30, 0x2C ; 44
6: f0 e0 ldi r31, 0x00 ; 0
8: e4 91 lpm r30, Z
a: e0 93 00 01 sts 0x0100, r30 ; 0x800100 <some_data1>
e: ff cf rjmp .-2 ; 0xe <__zero_reg__+0xd>
Disassembly of section .text.setOutputPins:
00000010 <setOutputPins>:
10: cf 93 push r28
12: df 93 push r29
14: 1f 92 push r1
16: cd b7 in r28, 0x3d ; 61
18: de b7 in r29, 0x3e ; 62
1a: 8f ef ldi r24, 0xFF ; 255
1c: 89 83 std Y+1, r24 ; 0x01
1e: 89 81 ldd r24, Y+1 ; 0x01
20: 84 b9 out 0x04, r24 ; 4
22: 0f 90 pop r0
24: df 91 pop r29
26: cf 91 pop r28
28: 08 95 ret
Disassembly of section .progmem.data:
0000002a <my_array>:
2a: 02 03 mulsu r16, r18
2c: 05 07 cpc r16, r21
Disassembly of section .bss:
00800100 <some_data1>:
...
00800101 <some_data2>:
...
可以看到,函数something()被放在了程序分区的最开头,即程序空间地址0x0000。另外,函数something()的正确地址也被插入指令中,就如程序地址0x0000的call 0x10,包含了函数setOutputPins()的正确地址(0x0010)。
C语言与汇编混用
在接下来的例子中,我们将会混用汇编代码与C语言代码来创建一个项目。这有助于我们使用特定的语言来编写项目的不同部分。例如,我们可以使用C语言来编写一个项目的大部分代码,但是使用汇编来编写ISR(中断进程)与要求快速响应的函数。
这个例子中,我们用2个PWM信号来操控LED。我们使用一个时钟溢出中断来周期性地调整LED亮度(PWM占空比)。我们将创建4个源代码文件:
vector.asm- 包含向量表的汇编代码。startup.c- 初始化IO寄存器与外围设备的C语言函数。task.c- 修改PWM输出信号的C语言函数。timer.asm- 用于修改PWM占空比的汇编ISR,周期性地通过时钟溢出触发。
和1个链接器代码:
make.ld- 链接器代码。
vector.asm - 包含向量表的汇编代码
vector.asm
.text
. = 0
rjmp startup
. = 5 * 2
rjmp timer
. = 8 * 2
rjmp timer
根据ATtiny24/44/84 [DATASHEET],重置向量位于程序地址0x0000,时钟1捕获中断位于0x0005,时钟1溢出中断位于0x0008。当特定中断被触发,单片机硬件将会首先跳转到该中断向量。我们在向量中写入rjmp指令来跳转到相应的中断进程。
该手册使用词作为地址单位,且每个词地长度为2字节。GNU AVR汇编器使用字节作为地址单位。因此,我们需要将手册提供的地址乘以2才能被GNU的avr-as正确使用。
相对跳转rjmp长16比特,包含12比特正负偏移量,足够覆盖小于4K词程序地址空间的单片机,包括ATtiny25、ATtiny84。在这些单片机上,每个向量只有1个词宽,并使用rjmp指令跳转。对于大于4K词程序地址空间的单片机,绝对跳转jmp长32比特,包含22比特绝对地址,足够覆盖4M词程序地址空间,包括ATmega328。在这些单片机上,每个向量有2个词宽,并使用jmp指令跳转。
例如,重置后,CPU硬件将会跳转到程序地址0x0000。我们放在这里的指令rjmp startup则会让CPU跳转到符号startup所指代的地址。不过现在,我们还不知道startup的具体地址,我们将在链接时再解析。
汇编使用:avr-as vector.asm -mmcu=attiny44 -o vector.o
startup.c - 初始化IO寄存器与外围设备的C语言函数
startup.c
#include <avr/io.h>
#include <avr/interrupt.h>
#include "task.h"
extern volatile uint8_t timer_a_scale, timer_b_scale;
void startup() {
SP = 0x015F; // Top of t44
asm("clr r1");
DDRA = 0b10000000; //Output on PA7 and PB2
DDRB = 0b0100;
TCCR0A = (2<<COM0A0) | (2<<COM0B0) | (3<<WGM00); // Fast PWM
TCCR0B = (1<<CS00); // clk/1
OCR0A = 0;
OCR0B = 0;
TCCR1B = (3<<WGM12) | (4<<CS10); // CTC on ICR1, clk / 8
ICR1 = 200;
TIMSK1 = (1<<ICIE1) | (1<<TOIE1); // Interrupt on overflow
timer_a_scale = 4;
timer_b_scale = 16;
task_mailbox = 0; // Clear mailbox
sei();
task_loop();
}
初始化中,我们需要设置栈指针到栈的最顶端,清零R1零寄存器,为定义的静态变量赋值。我们在startup()函数以开始就执行这些操作。
要使用PWM输出,我们需要将相应的引脚设施为输出模式(说有引脚在重置后默认都是输入模式以防止短路)。并且,我们需要设置外围设备的计时器让硬件能生成PWM信号。在这个例子中,我们使用计时器0的两个输出端口作为快速PWM源。
我们配置计时器1的溢出中断来触发ISR。这个例子中,我们设置时钟1以CPU的1/256的速度运行(对于1MHz的CPU,时钟1的速度就是8kHz)。我们将时钟上限设为200,也就是说这个时钟会以8kHz / 200 = 40Hz的频率溢出。
我们引入两个外部变量:timer_a_scale和timer_b_scale。它们定义了每次时钟溢出时该改变多少LED的亮度(PWM占空比)。我们设置timer_a_scale为4,timer_b_scale为16。也就是说,LED A的亮度应该每次增加4,LED B每次16。因为MCU使用8比特的PWM占空比,PWM A应该每256 / 4 = 64个事件就溢出(从零开始),PWM B应该每256 / 16 = 16个事件就溢出(从零开始)。再因为时钟溢出的频率为40Hz,我们应该看到LED A和LED B分别以1.6秒和0.4秒的频率渐变闪烁。
清零邮箱task_mailbox。这个变量没有在这个文件中被定义,但是AVR-GCC会自动将其认为是external变量。
最后,允许全局中断,再调用主循环函数task_loop()。
编译使用:avr-gcc -c -O3 -mmcu=attiny44 startup.c -o startup.o
task.c - 修改PWM输出信号的C语言函数
task.h
#include <stdint.h>
volatile uint8_t task_a, task_b, task_mailbox;
void task_loop();
声明3个全局变量:task_a、task_b、task_mailbox。task_a和task_b包含了新的LED亮度,task_mailbox被用作请求更新LED亮度到PWM硬件。
task.c
#include <avr/io.h>
#include "task.h"
void task_loop() {
for(;;) {
if (!task_mailbox)
continue;
OCR0A = task_a;
OCR0B = task_b;
task_mailbox = 0;
}
}
这个函数是一个死循环,因此可以看作是主程序。
如果邮箱被设置,这个函数就会把task_a和task_b的值更新到PWM硬件控制占空比的的寄存器OCR0A和OCR0B中。然后,重置邮箱。
否则,什么都不做。
编译使用:avr-gcc -c -O3 -mmcu=attiny44 task.c -o task.o
timer.asm - 用于修改PWM占空比的汇编ISR
timer.asm
.data
.global timer_a_scale
.global timer_b_scale
timer_a_scale: .space 1
timer_b_scale: .space 1
.text
.global timer
timer:
push r30
push r31
lds r31, timer_a_scale
lds r30, task_a
add r30, r31
sts task_a, r30
lds r31, timer_b_scale
lds r30, task_b
add r30, r31
sts task_b, r30
ldi r31, 0xFF
sts task_mailbox, r31
pop r31
pop r30
reti
首先定义2个全局变量:timer_a_scale与timer_b_scale。我们在startup.c中就讨论了其作用。
每当这个ISR被触发,就会把timer_a_scale和timer_b_scale(需要改变的量)加入task_a和task_b(PWM占空比)。然后,设置邮箱task_mailbox(在task.h中定义)来请求task.c更新PWM硬件。
汇编使用:avr-as timer.asm -mmcu=attiny44 -o timer.o
make.ld - 链接器代码
linker.ld
MEMORY {
my_code(rx): ORIGIN = 0x00, LENGTH = 4K
my_data(rw): ORIGIN = 0x800060, LENGTH = 256
}
SECTIONS {
. = ORIGIN(my_code);
.text : {
vector.o(.text)
*(.text)
} >my_code
. = ORIGIN(my_data);
.data : {
*(.data)
} >my_data
}
将所有目标文件的.data段与.text段组合起来。我们特定要求链接器将vector.o中的向量表放在程序空间的开头,以便硬件能在重置后和中断时正确跳转。
链接使用:avr-ld -T make.ld vector.o startup.o timer.o task.o -o app.elf
现在生成的app.elf可以被烧录了。