Raspberry Pi Baremetal的挫败感(译文)
By robot-v1.0
本文链接 https://www.kyfws.com/pi/frustrations-of-raspberry-pi-baremetal-zh/
版权声明 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
- 14 分钟阅读 - 6816 个词 阅读量 0Raspberry Pi Baremetal的挫败感(译文)
原文地址:https://www.codeproject.com/Articles/1156812/Frustrations-of-Raspberry-Pi-Baremetal
原文作者:leon de boer
译文由本站 robot-v1.0 翻译
前言
Failing and musings of two weekends with the Raspberry Pi
Raspberry Pi在两个周末的失败和沉思
第2部分现已推出:(PART 2 now available:) https://www.codeproject.com/Articles/1158245/More-Fun-frustration-and-laughs-with-the-Pi-Part-I(https://www.codeproject.com/Articles/1158245/More-Fun-frustration-and-laughs-with-the-Pi-Part-I)
介绍(Introduction)
我有一个Raspberry Pi在抽奖中坐了大约6个月才能玩.快到圣诞节了,工作有点慢了,所以我想我会花点时间玩一个新玩具.从表面上看,Raspberry Pi是我从嵌入式背景中获得天堂的想法,它的记忆力和速度远远超过了我的常规水平.(I have had a Raspberry Pi sitting in the draw for about 6 months to have a play with. Coming into Christmas, work had slowed down a bit so I thought I would have some time to play with a new toy. On paper, the Raspberry Pi is my idea of heaven from my embedded background, it has far more memory and speed than would be the norm for me.)
背景(Background)
现在的问题是我将如何编程.我的工作范围中的一种新颖之处是图形屏幕,因此您可以打赌,我要做的任何事情都会涉及到屏幕.回到我早期的编程生涯,我通过使用C ++中的TURBO VISION和Borlands原始发行版的Pascal进行完整的图形化移植,引起了公司的注意. FreePascal和FreeVision库中仍然存在许多代码.(Now the question was what would I program onto it. Well sort of a novelty in my line of work is a graphics screen so you can bet that whatever I was going to do would involve the screen. Going way back to my early programming life, I had come to the attention of companies by doing a full graphical port of TURBO VISION in C++ and Pascal from Borlands original release. Much of the code still exists in the FreePascal & FreeVision libraries.) 对于我的第一个大型商业项目,我已经开始将Windows的无尘室移植回一个称为Darkside的通用图形框架.我们已经运行了基本版本,没有任何大问题,但是事件将超越我们.微软这次以可接受的价格重新推出了Windows CE,Wine已在Linux上发布.对于我们的商业项目,以低廉的许可成本重新发明轮子是很愚蠢的,因此我们很容易地改用Windows CE,因此我将Darkside代码归档到档案中.(For my first large commercial project, I had started to do a cleanroom port of windows back onto a generic graphics framework which was called Darkside. We had got basic versions running without any great problems but events were about to overtake us. Microsoft relaunched Windows CE this time with acceptable pricing and Wine had been released on Linux. For our commercial project, it was silly to re-invent the wheel for what amounted to a small licensing cost and so we readily switched to Windows CE and I filed the Darkside code into the archives.) 因此,我认为这可能是清除旧代码并尝试将Darkside作为小型O/S运用于微型Windows的Raspberry Pi的好机会.(So I thought this might be a good chance to dust off the old code and try putting Darkside onto the Raspberry Pi as a small O/S a sort of micro Windows.)
使用代码(Using the Code)
确定任务后,我清除了旧代码.本质上,像Turbo Vision(Free Vision)这样的Darkside是事件驱动器,您可以在其下耦合多任务,但这并不是严格要求的.因此,为了使生活更加轻松,我将使事件驱动器正常工作,并希望在以后的阶段将pthreads实现置于其下.(Having decided on the task, I dusted off the old code. Essentially, Darkside like Turbo Vision (Free Vision) is an event drive you can couple multitasking under them but it isn’t strictly required. So to initially keep life easy, I would get the eventdrive working and look to putting a pthreads implementation under it at a later stage.) Windows的正常消息循环建立了理想的事件驱动器,毫不奇怪,Windows 3.1的传统就是将协作的多任务内核编织到其中的事件驱动器.(The normal message loop of windows sets up an ideal event drive, no surprise its heritage of Windows 3.1 was an event drive with a co-operative multitask kernel weaved into it.) 普通的Windows消息循环采用类似如下的形式:(The normal windows message loop takes a fairly familiar form like this:)
// Standard message handler loop BY THE MSDN BOOK
// https://msdn.microsoft.com/en-us/library/ms644936(v=vs.85).aspx
MSG msg = { 0 };
BOOL bRet;
while ((bRet = GetMessage(&msg, 0, 0, 0)) != 0) {
if (bRet == -1) {
// handle the error and possibly exit
} else {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
所以(So) GetMessage
获取事件并(gets events and) DispatchMessage
提供它们.(delivers them.)
为了使Darkside具有可移植性,硬件需要实现三个外部函数调用,这三个函数涵盖了3个主要输入事件,即Mouse,KeyBoard和Timer.本质上,这3个函数中的代码对于每种硬件设置都是唯一的,您可以创建自己的例程来执行它们,并通过Darkside API中的特殊函数将处理程序设置为您的代码.(To make Darkside portable essentially three external function calls are needed to be implemented by the hardware.These 3 functions cover the 3 main input events being Mouse, KeyBoard and Timer. Essentially, the code in those 3 functions would be unique to each hardware setup and you create your own routines to do them and set the handler to your code by a special function in the Darkside API.)
因此,我决定首先进行最简单的设置计时器.在我在Darkside的实现中,应该证明您提供的功能提供了1毫秒的滴答计数,该滴答计数与(So I decided to do the easiest first which was to setup the timers. In my implementation in Darkside, you are expected to prove a function that provides a 1 millisecond tickcount which matches the) GetTickCount
Windows中的API函数.在致电的底部(API function in Windows. At the bottom of a call to) GetMessage
是一个(is a) NextTimerMsg
该函数获取通过超时计数的下一个计时器作为(function which fetches the next timer that has passed its timeout count as a) WM_TIMER
信息.这是一个非常简单的函数,它从set timer数组的第一个条目开始,并查看是否在该定时器上设置了间隔时间.如果已经过去,则准备一个(message. It’s a rather simple function that starts at the first entry of set timer array and looks if the interval time set on that timer has elapsed. If it has elapsed, it prepares a) WM_TIMER
消息匹配并设置,以便下次(message to match and sets up so the next time) NextTimerMsg
输入后,将从下一个计时器开始.您不想总是从数组中的第一个条目开始搜索计时器超时,原因很明显,您希望所有计时器都受到同等对待.我的实现如下所示:(is entered, it starts from the next timer. You don’t want to always start the search for timer timeouts from the first entry in array for the obvious reason that you want all timers to be equally treated. My implementation looked like this:)
/*-INTERNAL: NextTimerMsg---------------------------------------------------
If there is a timer has exceeded its timeout then return timer message.
Internal message routine so msg pointer guaranteed to be valid
12Nov16 LdB
--------------------------------------------------------------------------*/
static void NextTimerMsg (LPMSG msg) {
if ((TimerCount > 0) && (TimerTickFunc)) { // Basic check that timers are set and
// system is running
unsigned short i;
i = TimerChkStart; // We will resume at next timer check position
do {
if (TimerQueue[i].TimerId > 0) { // Check timer is in use
DWORD tick, elapsed;
tick = TimerTickFunc(); // Get timer tick
if (tick < TimerQueue[i].LastTime) { // Timer rolled (** Remember it rolls min
// every 49 days **)
elapsed = ULONG_MAX - TimerQueue[i].LastTime; // Amount of ticks left until it
// would have rolled
elapsed += (tick + 1); // Now add the current tick to the amount from above
} else { // Time was in correct order
elapsed = tick - TimerQueue[i].LastTime; // Simply subtract the two times
}
if (elapsed > TimerQueue[i].Interval) { // Elapsed time exceeds timer interval
TimerQueue[i].LastTime += TimerQueue[i].Interval; // Add the timer interval ..
// we want pulses near interval.
msg->hwnd = TimerQueue[i].TimerWindow; // Set the message window handle
if (msg->hwnd == 0) msg->hwnd = (HWND)FocusedWnd; // If window still zero try the
// focused window
if (msg->hwnd == 0) msg->hwnd = (HWND)AppWindow; // If window handle still valid
// try app window
msg->message = WM_TIMER; // WM_TIMER message
msg->wParam = (WPARAM)TimerQueue[i].TimerId; // Set timer id to wParam
msg->lParam = (LPARAM)TimerQueue[i].UserTimerFunc;// Set user timer function
// to lParam
}
}
i++; // Increment to next timer to check
if (i >= TimerMax) i = 0; // Roll back to first timer if it exceeds array
} while ((msg->message == WM_NULL) && (i != TimerChkStart));// Exit if we get a timer message
// or we have check each timer
TimerChkStart = i; // Hold the timer we start next check at
}
}
现在我们的Windows版本(Now our version of windows) DispatchMessage
API函数从本质上看(API function essentially sees a) WM_TIMER
消息,如果(message and if the) Timer
设置了函数指针直接调用它,否则发送一个(function pointer is set calls it directly or else sends a) WM_TIMER
消息到所选窗口.在我们的案例中,后者在修补程序代码中不起作用,因为我们尚未设置窗口.无论如何(message to the selected window. The later in our case is out of play in our tinker code because we haven’t setup a window yet. Anyhow, the) DispatchMessage
采用以下形式:(took this form:)
/*-DispatchMessage----------------------------------------------------------
The DispatchMessage function dispatches a message to a window procedure.
It is typically used to dispatch a message retrieved by GetMessage function.
11Nov16 LdB
--------------------------------------------------------------------------*/
BOOL DispatchMessage (LPMSG lpMsg){
PWNDSTRUCT Wp;
if ((lpMsg) && (lpMsg->message != WM_NULL)) { // Check ptr and message valid
Wp = (PWNDSTRUCT)lpMsg->hwnd; // Typecast window handle to ptr
if (Wp == NULL) Wp = AppWindow; // Zero means application window
if ((lpMsg->message == WM_TIMER) && (lpMsg->lParam)){ // Timer messages with
// valid function pointer called directly
TIMER* timer = TimerFromID((UINT_PTR)lpMsg->wParam); // Find the timer record
timer->UserTimerFunc(lpMsg->hwnd, WM_TIMER,
timer->TimerId, timer->LastTime); // Call timer function pointer
return (1); // Return successful dispatch
}
if (Wp) return ((INT) Wp->lpfnWndProc(lpMsg->hwnd,
lpMsg->message, lpMsg->wParam, lpMsg->lParam)); // Call the handle if Wp valid
}
return (0); // Either message or handler invalid
}
一切看起来都还不错,所以我认为最好检查一下是否可以正常运行,然后我将设置一个Windows控制台程序(但不要包括(Everything looked okay so I thought I had better check it all worked and to do that, I would setup a Windows console program (BUT no include of)Windows系统(Windows.h)).相反,我们将"(). Instead, we include our “)暗黑血统(Darkside.h)",然后我做了一个简单的5行C文件,它将链接实际的Windows(” and I made a simple 5 line C file which would link the actual Windows) GetTickCount
为我.因此,它被称为(in for me. So, it was called)**用户名(user.h)**而且很直截了当(and pretty straight forward.)
//User.H
#ifndef _USER_
#define _USER_
DWORD GetTimerTick (void);
#endif
//User.C
#include <windows.h>
#include "user.h"
DWORD GetTimerTick(void) {
return (GetTickCount());
}
你知道我可以导入(You get the trick that I can import the) GetTickCount
Windows而不拖累整个(of Windows without dragging in the whole of)Windows系统(Windows.h).(.)
现在我的测试代码:(Now my test code:)
#include <stdio.h> // Needed for printf
#include "Darkside.h" // Our windows replacement
#include "User.h" // Gives use GetTimerCount function
// Some count variables
int count1 = 0;
int count2 = 0;
// Forward declare our functions
void CALLBACK MyTimerFunc1(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime);
void CALLBACK MyTimerFunc2(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime);
int main(void) {
printf("Press escape to exit\r\n\r\n");
printf("TIMER 1 = %i\r\n", count1);
printf("TIMER 2 = %i\r\n", count2);
/* OKAY COUPLE THE TIMER TICK FUNCTION TO DARKSIDE */
SetGetTimerTickHandler(GetTimerTick);
// Start some timers like you do in windows
SetTimer(0, 0, 1000, MyTimerFunc1); // 1 second
SetTimer(0, 0, 2500, MyTimerFunc2); // 2.5 sec
// Standard message handler loop BY THE MSDN BOOK
// https://msdn.microsoft.com/en-us/library/ms644936(v=vs.85).aspx
MSG msg = { 0 };
BOOL bRet;
while ((bRet = GetMessage(&msg, 0, 0, 0)) != 0) {
if (bRet == -1) {
// handle the error and possibly exit
} else {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
return(0);
}
/* FUNCTION TIMER 1 */
void CALLBACK MyTimerFunc1(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime) {
count1++;
printf("TIMER 1 = %i\r\n", count1);
}
/* FUNCTION TIMER 2 */
void CALLBACK MyTimerFunc2(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime) {
count2++;
printf("TIMER 2 = %i\r\n", count2);
}
一切正常,我得到了预期的输出.(Everything worked and I got the expected output.)
在完成所有工作后,我开始在Raspberry Pi上实施.阀门网站(网站)上的Brian Sidebotham撰写了有关Pi的BareMetal编程的一些不错的文章((With all working, I then started implementation on the Raspberry Pi. There are some nice articles on BareMetal programming of the Pi by Brian Sidebotham from the website valvers () http://www.valvers.com/open-software/raspberry-pi/step01-bare-metal-programming-in-cpt1/(http://www.valvers.com/open-software/raspberry-pi/step01-bare-metal-programming-in-cpt1/) ).().) 我开始做教程1-3,没有真正的问题.我在他的代码中遇到了几个古怪的编译器错误,因为我违背了他的建议,并使用了GCC 5.4版本而不是他建议的4.7版本.这些错误似乎在优化器中,如果是他的汇编器或编译器出了问题,我也没弄清楚(我的Arm汇编器经验几乎为零).无论如何,解决方案很容易使用编译指示和/或包装程序来停止优化程序,一切都很好.这样一来,我就可以更详细地了解系统计时器了,这很容易,因为它是一个很好的1Mhz系统时钟,采用64位计时器结构.因此,很容易产生所需的接口函数.(I started doing tutorials 1-3 and no real issue. I had a couple of quirky compiler bugs off his code because I had gone against his advice and used GCC version 5.4 rather than 4.7 he suggested. The bugs appear to be in the optimizer and I haven’t got my head around if it was his assembler or the compiler that was at fault (My Arm assembler experience is next to nil). Anyhow, the solution was easy use pragma and/or wrappers to stop the optimizer and everything was fine. So that got me to the detail on the system timer which was easy a nice 1Mhz system clock which rolled in a 64 bit timer structure. So it was easy to produce my required function to interface.)
/*-GetTickCount-------------------------------------------------------------
We are going to try and match GetTickCount on windows which produces return
value of the number of milliseconds that have elapsed since the system was
started. So it frequency is 1000Hz. The raspberry Pi timer is 1Mhz so we need
to divid it down by 1000. Being 64 bits we are fortunate as we have more bits
than needed for our 32 bit result we need.
20Nov16 LdB
--------------------------------------------------------------------------*/
unsigned long GetTickCount (void) {
rpi_sys_timer_t Val = *rpiSystemTimer; // Fetch all the timer registers
unsigned long long resVal = Val.counter_hi; // Fetch high 32 bits
resVal = resVal << 32; // Roll to upper 32 bits
resVal |= rpiSystemTimer->counter_lo; // Add the lower 32 bits in
resVal /= 1000; // Divid by 1000
return((unsigned long)resVal); // Return the 32 bit result
}
我以为自己在家里干活,只有当我开始编译代码时,这个大问题才出现.我在控制台上将链接器错误写入到我的简单程序的屏幕上,这时它陷入了Brian在开始汇编程序文件中所做的工作.我对细节不了解的事实是,编译器绝对没有标准GCC编译器提供的控制台输出…哇,我已经很长时间没有遇到这种情况了.即使在最基本的C嵌入式系统上,控制台也将至少设置为一个串行端口.(I thought I was home and dry and only when I started to compile the code did the big problem hit home. I got linker errors on the console write to screen of my simple program and it was at that moment it sunk in what Brian had been doing with the start assembler files. Lost in the detail to me was the fact the compiler has absolutely no console output possible from the standard GCC compiler … woah I haven’t run across this in a very long time. Even on the most basic C embedded system, the console would be set to at least a serial port.) 我回去做Brian SideBotham的第4和第5期教程,没有控制台输出的原因变得很明显,大多数图形引擎完全隐藏在一个可怕的斑点中,而文档却很少,不完整,而且大多是本文.我怀疑编译器程序员会像我一样对这种想法感到困惑. Brian在第5条中有一小节,他做了一些接线和代码以将控制台输出输出到串行端口,这至少对我来说是最少的,甚至是最小的Micro C编译器所做的.但是,在他文章的补丁中,他已经意识到未启用"视频浮点"单元的问题.这让我感到不寒而栗,我是否真的要尝试将图形密集型内容(例如图形O/S)放在我没有任何规格,也没有真正想法的情况下以及所涉及的时间安排之上.即使在Brian Sidebottoms教程5中,您也可以看到屏幕撕裂,因为他无法更好地组织对屏幕的访问.(I went back to do Brian SideBotham’s number 4 & 5 tutorial and the reason for no console output became obvious most of the graphics engine is totally hidden from you in a horrible blob the documentation is scant, incomplete and mostly heresay. I suspect the compiler programmers baulked at this like I did. Brian has a small section in Article 5 where he does some wiring and code to get a console output to a serial port which would at least be the minimum for me and what almost even the smallest Micro C compiler does. However, in a patch to his article, he had become aware of the issue with the Video Float Point unit not enabled. This sent shudders through me, am I really going to try and put a graphics intensive things like an graphics O/S on top of something I have no specifications on and no real idea what it is doing and the timings involved are. Even in Brian Sidebottoms tutorial 5, you could see screen tearing because he could not organize better access to the screen.) 在阅读论坛时,字体库对许多人来说都是有问题的.这对我来说是微不足道的,我在C代码中同时拥有位图字体和truetype字体. Quad Bezier以及如何使用字体提示有效地对其进行扫描都超出了新手程序员的范围,但是我出于商业需要将其作为标准库.再次,对于许多人来说,图形基元是有问题的,这对我来说也是微不足道的.我有大量的视频例程库,并且大多数都允许粒度部分(例如PI的帧缓冲区),因为所有SuperVGA卡都有颗粒窗口时,代码可以追溯到VESA遵从日期.(In then reading the forums the font libraries was problematic for many. This was trivial to me, I have both bitmap fonts and truetype fonts in c code. The quad bezier and how to scan line it effectively with font hints is well outside the range of novice programmers, but something I carry out of commercial necessity as a standard library. Again, for many, the graphics primitives were problematic which again for me is trivial. I have large libraries of video routines and most allow for granularity sections (like the PI’s framebuffer) because the code harks back to VESA compliance days when all SuperVGA cards had granual windows.) 我的问题不是上述所有问题,可能已阻止了其他问题.您甚至需要在屏幕上使用鼠标光标,才能将其理想地遮盖在屏幕上和屏幕外.这意味着无法进行双向读取和写入屏幕的通信,并且无法获得定时细节.您通过Raspberry PI上的邮箱通道与屏幕交谈,而我不知道同步是什么样的.如果我发出写消息并立即发出读消息,是我返回原始像素还是刚刚发送消息的像素?即使我在板上进行了测试,我也无法知道所有板将如何反应,因为我真正需要的是视频单元的规格,该规格显然要遵守一项大量的保密协议.每当您拖动窗口时,这种双重缓冲在Windows O/S上都会被大量使用,您本质上想重新绘制最小的屏幕,因此您可以组织带有剪辑限制的读/写缓冲区.基本上,我需要在该项目上使用图形进行的几乎所有操作都需要对视频单元进行读/写和屏蔽访问.(My issue is none of the above which may have stopped others. It is to do even a mouse cursor on the screen you need to ideally mask it onto and off the screen. That means two way communication with both reading and writing to the screen and the timing detail is not available. You talk to the screen via mailbox channels on the Raspberry PI and I have no idea of what the synchronization is like. If I issue a write message and immediately issue a read message, is the pixel I get back the original pixel OR the pixel I just sent a message for? Even if I test it on my board, I have no way to know that is how all boards will react because what I really need is the specifications of the video unit which apparently is subject to a large Non Disclosure agreement. This dual buffering is used a lot on a Windows O/S everytime you drag a window you essentially want to redraw the minimum amount of screen and so you organize read/write buffers with clip limits. Basically, almost everything I need to do with graphics on this project will require read/write and masking access to the video unit.) 然后,我去了linux源文件,看看它们如何处理屏幕.正如我所担心的那样,它们全都隐藏在薄垫片例程中,这些例程用最能描述为外语的语言进行注释.(I then went to the linux source files to look at how they deal with the screen and as I feared, it is all hidden inside thin shim routines which are commented in what can be best described as a foreign language.) 现在,我明白了为什么在网上看到的几乎所有Baremetal Raspberry Pi东西都闪烁着灯光或对IO端口进行了脉动.尝试使用超出基本范围的图形意味着要进入linux,因此您可以访问由Arm作为供应商开发的驱动程序和例程(它们当然包含所有详细信息).(I now see why almost all the Baremetal Raspberry Pi stuff you see on the net are blinking the lights or pulsing the IO ports. To try and use the graphics much beyond anything basic would mean going into linux so you have access to the driver and the routines which were developed by the Arm as a vendor (they of course have all the details).) 我对Linux没有任何看法.对于我从事的几乎所有嵌入式工作而言,它都太大了.我在Snapgear防火墙代码和几个FPGA Cortex核心板上使用了ucLinux,但是它们是我所做的仅有的足够大的产品,足以容纳它.(I have nothing against Linux. It is just too large for almost anything embedded I work on. I have used ucLinux on a snapgear firewall code and a couple of FPGA Cortex core boards but they are about the only products I have done that are large enough to accommodate it.) 因此,在我的Darkside项目中使用PI已死了,我实在太不舒服了,无法花大量时间处理我不确定视频访问规范是什么的事情.(So the use of the PI for my Darkside project is dead, I am just way too uncomfortable to put in large amount of time on something I can’t be sure what the specification of the video access is.)
兴趣点(Points of Interest)
因此,我从Raspberry Pi中学到了什么,最重要的是,如果要进行图形处理,使用Baremetal确实是一个非常糟糕的目标.坦率地说,我不会浪费时间将完整的图形引擎移植到您不知道要获得什么样的性能以及如此糟糕的文档上的东西上.(So what did I learn about the Raspberry Pi, well foremost that it is a really bad target to do Baremetal on if you want to do graphics. Bluntly, I wouldn’t waste my time to port a full graphics engine on something you have no idea what performance you are going to get and with such poor documentation.) 因此,我想我的Raspberry Pi将重新使用,直到我需要一个非常小的Linux安装程序.(So I guess my Raspberry Pi will be going back into the draw until I need a really small Linux setup for something.) 我有些失望,但我拖出了旧代码,因此我将四处寻找另一个目标板,并且可能会继续.与许多编程一样,最困难的部分是入门.(I was reasonably disappointed but I have dragged out the old code and so I will look around for another target board and probably continue on. As with a lot of programming the hardest part is getting started.) 我已经包括了(I have included the) 源代码(source code) 如果有人想玩的话,这是基本的消息事件驱动系统.(which is the basic message event drive system if anyone wants to play around with it.)
更新和新文章即将到来(Update and New Article Coming)
在这里..第2部分(It’s here .. Part 2) https://www.codeproject.com/Articles/1158245/More-Fun-frustration-and-laughs-with-the-Pi-Part-I(https://www.codeproject.com/Articles/1158245/More-Fun-frustration-and-laughs-with-the-Pi-Part-I)
到目前为止,对于51K IMG文件和SD卡上的3个文件来说还不错.(Not bad so far for a 51K IMG file and just the 3 files on the SD card.)
许可
本文以及所有相关的源代码和文件均已获得The Code Project Open License (CPOL)的许可。
C C++ Raspberry 新闻 翻译