AVR链接器 - 手动链接,裸金属与混合语言编程

这篇文章将讨论如何使用GNU avr-as汇编对AVR单片机编程并链接目标文件为可知执行文件。另外,这篇文章还展示了C语言编写裸金属程序并手动链接,和编写C语言与汇编混合的AVR项目。

--by Captdam @ Nov 9, 2025 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的指令。

0x00040x0064包含了其他各种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_arrayfor (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:
		

如我们所见,有的信息被丢失了。例如:

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-asgas有点不同,it treats all undefined symbols as external,没被定义的符号都会被自动定义为外部符号

汇编不是终点也不能生成可执行文件

为什么反汇编显示的符号全部木大为零?

首先,我们要清楚GNU汇编器avr-as的作用:将人可读的助记符翻译为机器可读的二进制机器码,逐个文件地执行。

汇编器将根据输入创建.data段和.text段。但是,汇编器并不知道.data段和.text段在实际机器中该如何放置。因此,汇编器也没办法知道它们的绝对地址。

另外,当有多个目标文件时,使用.data段和.text段的绝对地址将可能导致目标文件之间的冲突。同样的,汇编器一次只处理一个文件,当生成目标文件A时,没办法为了目标文件B做地址规划。

因此,汇编器不适用绝对地址,而是:

链接器

汇编只是第一步,我们还需要将目标文件链接起来,生成可执行文件。如上文所说,我们需要解决这些问题:

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内存结构:

长度单位为字节。

链接器根据内存的属性(可读、可写、可执行)将其放入不同分区,例如.rodata就是只可读数据。但是,我们的例子不遵守这个规定,因为我们直接控制.text段与.data段在一个简单硬件上的位置。就算我们把.text段与.data都定义为只可读,实际代码在单片机上跑起来也不会有任何区别。

我们在数据段上添加0x800000的偏移,这不是必须的,我只是为了和AVR-GCC的内存写法对齐。这个偏移量会在烧录时被砍掉,因为AVR的地址总线就那么宽。

接下来,我们告诉链接器如何把目标文件放入SECTIONS内:

因为现在只有一个目标文件,并且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
	

现在,使用以下参数编译:

接下来,反汇编。下面展示了反汇编结果:(右侧展示了源代码作为参考)

子函数


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()的正确地址也被插入指令中,就如程序地址0x0000call 0x10,包含了函数setOutputPins()的正确地址(0x0010)。

C语言与汇编混用

在接下来的例子中,我们将会混用汇编代码与C语言代码来创建一个项目。这有助于我们使用特定的语言来编写项目的不同部分。例如,我们可以使用C语言来编写一个项目的大部分代码,但是使用汇编来编写ISR(中断进程)与要求快速响应的函数。

这个例子中,我们用2个PWM信号来操控LED。我们使用一个时钟溢出中断来周期性地调整LED亮度(PWM占空比)。我们将创建4个源代码文件:

和1个链接器代码:

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_scaletimer_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_atask_btask_mailboxtask_atask_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_atask_b的值更新到PWM硬件控制占空比的的寄存器OCR0AOCR0B中。然后,重置邮箱。

否则,什么都不做。

编译使用: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_scaletimer_b_scale。我们在startup.c中就讨论了其作用。

每当这个ISR被触发,就会把timer_a_scaletimer_b_scale(需要改变的量)加入task_atask_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可以被烧录了。